From 95b9951baa8f72a46d4674bfbe7a6775760de27d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sun, 31 May 2026 17:49:48 +1000 Subject: [PATCH 1/4] feat(guides): major-scoping, per-version buckets, marker fix, incremental pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - semverMajor + scope guides to one major (v3→v4 window); previousMajorAnchor for 'from'. - Per-version buckets (VersionBuckets) surfaced for the site's from-version selector. - Detect [**BREAKING**]/**BREAKING** markers (jest/babel monorepo changelogs). - version.ts stamps (BUCKET_RULES/RUNBOOK_PROMPT/PIPELINE_VERSION); runbook synthesis cached by hash(synthInput)+promptVersion; batch skips current-stamp guides → incremental. --- scripts/guides-batch.ts | 107 ++++++++ src/core/semver.ts | 79 +++++- src/guides/bucket-pipeline.ts | 197 ++++++++++++++ src/guides/buckets.ts | 260 +++++++++++++++++++ src/guides/generate.ts | 466 ++++++++++++++++++++++++++++++++++ src/guides/version.ts | 19 ++ 6 files changed, 1127 insertions(+), 1 deletion(-) create mode 100644 scripts/guides-batch.ts create mode 100644 src/guides/bucket-pipeline.ts create mode 100644 src/guides/buckets.ts create mode 100644 src/guides/generate.ts create mode 100644 src/guides/version.ts diff --git a/scripts/guides-batch.ts b/scripts/guides-batch.ts new file mode 100644 index 00000000..ad0fd5cd --- /dev/null +++ b/scripts/guides-batch.ts @@ -0,0 +1,107 @@ +/** + * Batch-generate migration guides for the curated package set. + * + * Usage: + * tsx scripts/guides-batch.ts [--all] [--limit N] [--concurrency N] + * [--out DIR] [--model M] [--force] + * [--packages FILE] + * + * Writes /.json (GeneratedGuide) + /.md per package and a + * /_manifest.json summary. Re-runs skip already-generated guides unless + * --force. Designed to run locally; skilld.dev ingests the JSON. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'pathe' +import { listCuratedPackages } from '../src/guides/curated.ts' +import { generateGuide } from '../src/guides/generate.ts' +import { PIPELINE_VERSION } from '../src/guides/version.ts' + +function arg(name: string, fallback?: string): string | undefined { + const i = process.argv.indexOf(`--${name}`) + return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback +} +function flag(name: string): boolean { + return process.argv.includes(`--${name}`) +} + +/** Filesystem-safe slug: `@scope/name` → `@scope__name`. */ +function safeSlug(pkg: string): string { + return pkg.replace(/\//g, '__') +} + +const all = flag('all') +const force = flag('force') +const limit = Number(arg('limit') ?? Infinity) +const out = arg('out') ?? '.guides-out' +const model = (arg('model') ?? 'sonnet') as any +const distillModel = arg('distill-model') as any +const packagesFile = arg('packages') + +// Local Ollama serializes requests, so parallel workers give no speedup and can +// thrash VRAM — default to 1 for `ollama:` models, 3 for cloud APIs. +const isLocal = String(model).startsWith('ollama:') +const concurrency = Number(arg('concurrency') ?? (isLocal ? '1' : '3')) +// Local models (esp. prose repos with many flagged LLM calls) are slow; give +// them plenty of headroom per guide. +const timeout = Number(arg('timeout') ?? (isLocal ? '1200000' : '300000')) + +const packages = (packagesFile + ? readFileSync(packagesFile, 'utf8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')) + : listCuratedPackages({ all }) +).slice(0, limit) + +mkdirSync(out, { recursive: true }) + +interface Row { pkg: string, status: 'ok' | 'skip' | 'fail', version?: string, cost?: number, error?: string } +const rows: Row[] = [] +let totalCost = 0 +let cursor = 0 + +async function worker(id: number): Promise { + while (cursor < packages.length) { + const pkg = packages[cursor++]! + const jsonPath = join(out, `${safeSlug(pkg)}.json`) + + // Incremental: skip a guide only when its persisted pipeline stamp matches + // the current logic version. A version bump (bucketing/prompt) marks older + // guides stale → they regenerate; everything else is skipped. --force redoes all. + if (!force && existsSync(jsonPath)) { + const stamp = JSON.parse(readFileSync(jsonPath, 'utf8'))?.pipelineVersion + if (stamp === PIPELINE_VERSION) { + rows.push({ pkg, status: 'skip' }) + process.stderr.write(` [${id}] ⤏ skip ${pkg} (current: ${stamp})\n`) + continue + } + process.stderr.write(` [${id}] ↻ stale ${pkg} (${stamp ?? 'unstamped'} → ${PIPELINE_VERSION})\n`) + } + + process.stderr.write(` [${id}] … gen ${pkg}\n`) + const result = await generateGuide(pkg, { model, distillModel, timeout }).catch(err => ({ ok: false as const, error: String(err?.message ?? err) })) + + if (!result.ok) { + rows.push({ pkg, status: 'fail', error: result.error }) + process.stderr.write(` [${id}] ✗ fail ${pkg}: ${result.error}\n`) + continue + } + + const { guide } = result + writeFileSync(jsonPath, JSON.stringify(guide, null, 2)) + writeFileSync(join(out, `${safeSlug(pkg)}.md`), guide.markdown) + totalCost += guide.cost ?? 0 + const c = guide.counts + rows.push({ pkg, status: 'ok', version: guide.version, cost: guide.cost }) + process.stderr.write(` [${id}] ✓ ok ${pkg}@${guide.version} [${c.breaking}b/${c.features}f/${c.fixes}x/${c.improvements}i]${guide.cost ? ` ($${guide.cost.toFixed(3)})` : ''}\n`) + } +} + +process.stderr.write(`Generating ${packages.length} guides (model ${model}${distillModel ? `, distill ${distillModel}` : ''}, concurrency ${concurrency}) → ${out}\n\n`) +await Promise.all(Array.from({ length: Math.min(concurrency, packages.length) }, (_, i) => worker(i + 1))) + +const ok = rows.filter(r => r.status === 'ok').length +const skip = rows.filter(r => r.status === 'skip').length +const fail = rows.filter(r => r.status === 'fail') +writeFileSync(join(out, '_manifest.json'), JSON.stringify({ generatedCount: ok, skipped: skip, failed: fail.length, totalCost, rows }, null, 2)) + +process.stderr.write(`\n── done ──\n ok=${ok} skip=${skip} fail=${fail.length} cost=$${totalCost.toFixed(2)}\n`) +if (fail.length) + process.stderr.write(` failures:\n${fail.map(f => ` - ${f.pkg}: ${f.error}`).join('\n')}\n`) diff --git a/src/core/semver.ts b/src/core/semver.ts index 1cca06bd..3d4ea9a7 100644 --- a/src/core/semver.ts +++ b/src/core/semver.ts @@ -3,7 +3,21 @@ * Centralized so the loose flag stays consistent across the project. */ -import { diff as _diff, gt as _gt, valid as _valid } from 'semver' +import { diff as _diff, gt as _gt, major as _major, prerelease as _prerelease, valid as _valid } from 'semver' + +export interface DistTagVersion { + version: string + releasedAt?: string +} + +export interface PickedTag { + /** dist-tag name, e.g. 'latest', 'beta', 'rc', 'next'. */ + tag: string + version: string + releasedAt?: string + /** True when the picked version is a prerelease (beta/rc/etc). */ + prerelease: boolean +} /** Returns the cleaned version if valid semver, null otherwise. */ export function semverValid(v: string): string | null { @@ -19,3 +33,66 @@ export function semverGt(a: string, b: string): boolean { export function semverDiff(a: string, b: string): string | null { return _diff(a, b) } + +/** Major version number (e.g. `9.2.2` → 9, `1.0.0-rc.3` → 1), or null if invalid. */ +export function semverMajor(v: string): number | null { + const clean = _valid(v, true) + return clean ? _major(clean) : null +} + +/** True if `v` carries a prerelease component (e.g. 1.0.0-beta.8). */ +export function semverIsPrerelease(v: string): boolean { + return !!_prerelease(v, true) +} + +/** Trailing git short-hash, optionally `g`-prefixed: `-5d5b77c`, `-gabc1234`. */ +const SNAPSHOT_RE = /-g?[0-9a-f]{7,40}$/i + +/** + * True for per-commit snapshot publishes (e.g. drizzle's `1.0.0-rc.4-5d5b77c`). + * These get published under throwaway CI branch dist-tags and should not be the + * canonical version a guide targets. + */ +export function isSnapshotVersion(v: string): boolean { + return SNAPSHOT_RE.test(v) +} + +/** + * Pick the largest version across all npm dist-tags, including prereleases. + * + * npm publishes prereleases under tags like `beta`/`rc`/`next` while `latest` + * stays on the last stable. semver ranks `1.0.0-beta.8 > 0.44.7`, so the max + * naturally surfaces the bleeding-edge release we want to index (the drizzle + * `1.0.0-beta.8` case). Ties resolve to the `latest` tag, then alphabetically + * for determinism. Invalid versions are skipped; returns null if none valid. + */ +export function pickLatestTag(distTags: Record | undefined): PickedTag | null { + if (!distTags) + return null + + const valid = Object.entries(distTags) + .filter(([, info]) => info?.version && semverValid(info.version)) + + if (!valid.length) + return null + + // Prefer clean releases; fall back to snapshots only if that's all there is. + const clean = valid.filter(([, info]) => !isSnapshotVersion(info.version)) + const candidates = clean.length ? clean : valid + + let [bestTag, bestInfo] = candidates[0]! + for (const [tag, info] of candidates.slice(1)) { + if (semverGt(info.version, bestInfo.version) + || (info.version === bestInfo.version && tag === 'latest')) { + bestTag = tag + bestInfo = info + } + } + + return { + tag: bestTag, + version: bestInfo.version, + releasedAt: bestInfo.releasedAt, + prerelease: semverIsPrerelease(bestInfo.version), + } +} diff --git a/src/guides/bucket-pipeline.ts b/src/guides/bucket-pipeline.ts new file mode 100644 index 00000000..39d61cbc --- /dev/null +++ b/src/guides/bucket-pipeline.ts @@ -0,0 +1,197 @@ +/** + * Bucketing pipeline: deterministic first, LLM only for flagged releases. + * + * Each release is bucketed by the free md4x parser (see buckets.ts). Releases + * whose coverage is too low (prose / custom format) are re-bucketed by the LLM: + * the model rewrites the note into the canonical `## Breaking/Features/Fixes/ + * Improvements` sections, which we then run back through the SAME deterministic + * parser — one parser, no bespoke LLM-output parsing. Across a representative + * 53-package sample the deterministic path already covers ~93% of changes, so + * the LLM fork fires on only a handful of repos. + */ + +import type { Buckets, BucketType, HeadingRule } from './buckets.ts' +import { BUCKET_TYPES, bucketCounts, mergeBuckets, normalizeHeading, parseReleaseToBuckets } from './buckets.ts' + +const BUCKET_HEADINGS: Record = { + breaking: '## Breaking changes', + features: '## New features', + fixes: '## Fixes', + improvements: '## Improvements', +} + +/** Render the given bucket types as Markdown sections. */ +function formatBuckets(buckets: Buckets, types: BucketType[]): string { + return types + .filter(type => buckets[type].length) + .map(type => `${BUCKET_HEADINGS[type]}\n${buckets[type].map(item => `- ${item}`).join('\n')}`) + .join('\n\n') +} + +/** All four buckets — full reference rendering. */ +export function formatBucketsAsMarkdown(buckets: Buckets): string { + return formatBuckets(buckets, ['breaking', 'features', 'fixes', 'improvements']) +} + +/** + * Runbook input: only the actionable buckets (breaking changes + code-affecting + * features). Fixes/improvements are passed to synthesis as counts, not steps. + */ +export function formatBucketsForRunbook(buckets: Buckets): string { + return formatBuckets(buckets, ['breaking', 'features']) +} + +export interface ReleaseInput { + version: string + markdown: string +} + +/** One release's buckets — powers per-version sections + the from-version window. */ +export interface VersionBuckets { + version: string + buckets: Buckets + counts: Record +} + +export interface BucketPipelineResult { + buckets: Buckets + counts: Record + releases: number + /** Per-version buckets, newest-first — for windowing/filtering in the UI. */ + perVersion: VersionBuckets[] + /** Per-release LLM re-bucket calls (the expensive fork). */ + llmCalls: number + /** Pattern-inference calls (0 or 1). */ + inferenceCalls: number +} + +export interface BucketPipelineOptions { + packageName: string + /** Runs one LLM completion; omit to stay fully deterministic (no fork). */ + complete?: (prompt: string) => Promise + flagThreshold?: number + concurrency?: number + onProgress?: (message: string) => void +} + +function LLM_BUCKET_PROMPT(pkg: string, md: string): string { + return `Re-express the migration-relevant changes in this ${pkg} release note as Markdown under EXACTLY these headings (omit a heading that has no items): + +## Breaking +## Features +## Fixes +## Improvements + +Rules: one concise bullet per change; copy any code/diff the note shows; ignore contributors, links, and version-bump noise; do not invent changes. Output only the Markdown sections. + +Release note: +${md}` +} + +// Only attempt pattern inference when enough releases flag that one cheap +// learn-the-vocabulary call can beat many per-release calls. +const INFER_MIN_FLAGGED = 3 +const BULLET_PREFIX_RE = /^[-*]\s*/ +const REGEX_ESCAPE_RE = /[.*+?^${}()|[\]\\]/g + +/** + * Pattern inference: one LLM call that maps a repo's unrecognized section + * headings to buckets, turning many per-release LLM calls into a single + * learn-the-vocabulary call. Helps repos with consistent non-standard headings; + * for genuinely freeform prose it simply learns little and we fall back to the + * per-release fork. Returns repo-specific HeadingRules (anchored to the + * normalized heading text), `ignore` headings are dropped. + */ +export async function inferHeadingRules( + packageName: string, + unmatchedHeadings: string[], + complete: (prompt: string) => Promise, +): Promise { + const distinct = [...new Set(unmatchedHeadings.map(h => h.trim()).filter(Boolean))].slice(0, 40) + if (!distinct.length) + return [] + + const prompt = `These are section headings from \`${packageName}\` release notes that an automated classifier could not categorize. Map EACH to exactly one of: breaking, features, fixes, improvements, ignore (use "ignore" for navigation/wrapper/noise headings). + +Reply one per line, format: => . No other text. + +Headings: +${distinct.map(h => `- ${h}`).join('\n')}` + + const out = await complete(prompt).catch(() => '') + const rules: HeadingRule[] = [] + for (const line of out.split('\n')) { + const sep = line.lastIndexOf('=>') + if (sep === -1) + continue + const bucket = line.slice(sep + 2).trim().toLowerCase() + if (!BUCKET_TYPES.includes(bucket as BucketType)) + continue // skips "ignore" and any malformed value + const norm = normalizeHeading(line.slice(0, sep).replace(BULLET_PREFIX_RE, '')) + if (norm) + rules.push([new RegExp(`^${norm.replace(REGEX_ESCAPE_RE, '\\$&')}$`), bucket as BucketType]) + } + return rules +} + +/** Bounded-concurrency map preserving order. */ +async function pool(items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise { + const out: R[] = Array.from({ length: items.length }) + let cursor = 0 + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { + while (cursor < items.length) { + const i = cursor++ + out[i] = await fn(items[i]!, i) + } + })) + return out +} + +export async function bucketReleases(releases: ReleaseInput[], opts: BucketPipelineOptions): Promise { + const { packageName, complete, flagThreshold = 0.6, concurrency = 2, onProgress } = opts + + // Pass 1: deterministic. + const parsed = await Promise.all(releases.map(r => parseReleaseToBuckets(r.version, r.markdown, flagThreshold))) + let flaggedIdx = parsed.map((p, i) => (p.flagged ? i : -1)).filter(i => i >= 0) + let inferenceCalls = 0 + + // Pass 1.5: one cheap inference call learns the repo's heading vocabulary and + // re-buckets the flagged releases deterministically — collapsing many + // per-release calls into one when the repo just uses non-standard headings. + if (complete && flaggedIdx.length >= INFER_MIN_FLAGGED) { + const unmatched = flaggedIdx.flatMap(i => parsed[i]!.unmatchedHeadings) + const learned = await inferHeadingRules(packageName, unmatched, complete) + inferenceCalls = 1 + onProgress?.(`Inferred ${learned.length} heading rules from ${flaggedIdx.length} flagged releases`) + if (learned.length) { + await Promise.all(flaggedIdx.map(async (i) => { + parsed[i] = await parseReleaseToBuckets(releases[i]!.version, releases[i]!.markdown, flagThreshold, learned) + })) + flaggedIdx = parsed.map((p, i) => (p.flagged ? i : -1)).filter(i => i >= 0) + } + } + + // Pass 2: per-release LLM re-buckets whatever still flags after inference. + let llmCalls = 0 + if (complete && flaggedIdx.length) { + let done = 0 + const rebucketed = await pool(flaggedIdx, concurrency, async (idx) => { + const out = await complete(LLM_BUCKET_PROMPT(packageName, releases[idx]!.markdown)) + onProgress?.(`LLM bucketed ${++done}/${flaggedIdx.length} flagged releases`) + // Re-parse the LLM's structured output; threshold 0 so it never re-flags. + return parseReleaseToBuckets(releases[idx]!.version, out, 0) + }) + flaggedIdx.forEach((idx, k) => { + parsed[idx] = rebucketed[k]! + }) + llmCalls = flaggedIdx.length + } + + const buckets = mergeBuckets(parsed.map(p => p.buckets)) + const perVersion: VersionBuckets[] = parsed.map(p => ({ + version: p.version, + buckets: p.buckets, + counts: bucketCounts(p.buckets), + })) + return { buckets, counts: bucketCounts(buckets), releases: releases.length, perVersion, llmCalls, inferenceCalls } +} diff --git a/src/guides/buckets.ts b/src/guides/buckets.ts new file mode 100644 index 00000000..c149954d --- /dev/null +++ b/src/guides/buckets.ts @@ -0,0 +1,260 @@ +/** + * Deterministic release-note bucketing. + * + * Most npm release notes are machine-generated (changelogen, release-please, + * semantic-release, GitHub auto-notes) with predictable structure — section + * headings (`### Features`, `### Bug Fixes`) or conventional-commit bullet + * prefixes (`feat:`, `fix:`, `perf:`). We can bucket those for free with no LLM. + * + * Each release is parsed into change8-style buckets and given a `coverage` + * score. Low coverage (prose-heavy / custom format, e.g. drizzle's 1.0 notes) + * flags the release for LLM bucketing instead — that's the only place the model + * is needed. + */ + +import { parseAST } from 'md4x' + +export type BucketType = 'breaking' | 'features' | 'fixes' | 'improvements' + +export const BUCKET_TYPES: BucketType[] = ['breaking', 'features', 'fixes', 'improvements'] + +export type Buckets = Record + +export function emptyBuckets(): Buckets { + return { breaking: [], features: [], fixes: [], improvements: [] } +} + +export interface ReleaseBuckets { + version: string + buckets: Buckets + totalItems: number + classifiedItems: number + /** Fraction of bullet items the deterministic parser could classify. */ + coverage: number + /** True when this release should be bucketed by the LLM instead. */ + flagged: boolean + /** Heading texts that matched no bucket rule — candidates for new matchers. */ + unmatchedHeadings: string[] +} + +// Heading text → bucket. Matched against lowercased, emoji/punctuation-stripped +// heading text via substring, so `🚀 Enhancements` and `Bug Fixes` both hit. +/** A heading-text matcher → bucket. Built-in rules + repo-learned (inferred) ones. */ +export type HeadingRule = [RegExp, BucketType] + +const HEADING_RULES: HeadingRule[] = [ + // changesets headings (`### Major/Minor/Patch Changes`) map by semver impact. + [/major changes/, 'breaking'], + [/minor changes/, 'features'], + [/patch changes/, 'fixes'], + [/breaking|removed|deprecat|upgrad|migrat/, 'breaking'], + [/feat|enhancement|added|new|highlight|labs/, 'features'], + [/fix|bug/, 'fixes'], + [/perf|refactor|improvement|chore|doc|style|build|ci|dependenc|revert|test|maintenance/, 'improvements'], +] + +// Wrapper/noise headings that carry no bucket signal. Treated as transparent: +// they don't reset the active bucket and aren't counted as unmatched. Matched +// against the normalized (lowercased, punctuation-stripped) heading, anchored +// so "breaking changes" still routes to a real bucket via HEADING_RULES first. +const TRANSPARENT_RE = /^(?:whats changed|contributors?|change ?log|changes|changed|packages|notes|view changes on github|full change ?log)$/ +// Version-number section headers (CHANGELOG.md style: `## v1.2.3`, `## 3.0.0`). +const VERSION_HEADING_RE = /^v?\d+\.\d+/ + +// Conventional-commit prefix → bucket (when bullets carry no heading context). +const PREFIX_RULES: Array<[RegExp, BucketType]> = [ + [/^feat\b/, 'features'], + [/^fix\b/, 'fixes'], + [/^(perf|refactor|style|docs?|chore|build|ci|test|revert|deps?)\b/, 'improvements'], +] + +const EMOJI_PUNCT_RE = /[^a-z\s]/g +// Conventional bullet: `feat(scope)!: desc` / `* fix: desc` +const CONVENTIONAL_RE = /^([a-z]+)(?:\([^)]*\))?(!)?:\s*/i +const BREAKING_BODY_RE = /breaking[\s-]?change/i +// Inline breaking markers common in monorepo changelogs (jest, babel, lerna): +// `[**BREAKING**]`, `[BREAKING]`, `**BREAKING**`, `[**BREAKING CHANGE**]`. These +// often sit under a `### Features` heading, so the bullet itself must flag it. +// Brackets/bold required so the word "breaking" in prose doesn't false-positive. +const BREAKING_MARKER_RE = /\[\s*(?:\*{1,2}\s*)?breaking(?:\s+changes?)?\s*(?:\*{1,2}\s*)?\]|\*\*\s*breaking(?:\s+changes?)?\s*\*\*/i + +type HeadingDisposition = BucketType | 'transparent' | null + +const HTML_ENTITY_RE = /&[a-z]+;|&#\d+;/g + +/** Normalize a heading for matching: lowercase, drop entities + punctuation. */ +export function normalizeHeading(text: string): string { + return text.toLowerCase().replace(HTML_ENTITY_RE, ' ').replace(EMOJI_PUNCT_RE, ' ').replace(/\s+/g, ' ').trim() +} + +function headingDisposition(text: string, extraRules: HeadingRule[]): HeadingDisposition { + const lower = text.toLowerCase().trim() + // Version headers must be tested before punctuation stripping turns `1.2` → `1 2`. + if (VERSION_HEADING_RE.test(lower)) + return 'transparent' + const norm = normalizeHeading(text) + if (!norm) + return 'transparent' + // Repo-learned rules (pattern inference) win first, then the built-in rules, + // so "breaking changes" / "3.0 migration" route correctly before transparent. + for (const [re, bucket] of [...extraRules, ...HEADING_RULES]) { + if (re.test(norm)) + return bucket + } + if (TRANSPARENT_RE.test(norm)) + return 'transparent' + return null +} + +function bucketForBullet(text: string): BucketType | null { + // An explicit breaking marker wins regardless of conventional prefix or the + // bullet's section heading (jest lists `[**BREAKING**]` items under Features). + if (BREAKING_MARKER_RE.test(text)) + return 'breaking' + const conv = text.match(CONVENTIONAL_RE) + if (conv) { + if (conv[2] === '!' || BREAKING_BODY_RE.test(text)) + return 'breaking' + const prefix = `${conv[1]!.toLowerCase()}:` + for (const [re, bucket] of PREFIX_RULES) { + if (re.test(prefix)) + return bucket + } + } + if (BREAKING_BODY_RE.test(text)) + return 'breaking' + return null +} + +// Leading-verb heuristic for freeform bullets (GitHub auto-notes PR titles like +// `Fix #123 …` / `Added support …`) that carry no conventional prefix. Lowest +// confidence, so it's only consulted after conventional prefix + heading. +const LEADING_VERB_RULES: Array<[RegExp, BucketType]> = [ + [/^(?:remove|removed|drop|dropped|deprecat|renamed?|breaking|delete|disallow)\b/, 'breaking'], + [/^(?:add|added|adds|introduc\w*|implement\w*|support|new|create\w*|enable\w*|allow)\b/, 'features'], + [/^(?:fix|fixed|fixes|resolve\w*|correct\w*|patch\w*|prevent\w*|handle\w*|ensure\w*|avoid\w*)\b/, 'fixes'], + [/^(?:updat\w*|improv\w*|refactor\w*|bump|chore|perf\w*|optimi\w*|clean\w*|test|docs?|migrat\w*|upgrad\w*|tweak\w*|adjust\w*|revert\w*)\b/, 'improvements'], +] + +function looseBucketForBullet(text: string): BucketType | null { + const t = text.toLowerCase().trim() + for (const [re, bucket] of LEADING_VERB_RULES) { + if (re.test(t)) + return bucket + } + return null +} + +/** Strip the conventional prefix so the surfaced bullet reads cleanly. */ +function cleanBullet(text: string): string { + return text.replace(CONVENTIONAL_RE, '').trim() +} + +// The hyperscript tuple shape (`[tag, props, ...children]`) fights TS's tuple +// inference on `.slice`, so the walker operates on `any` with runtime checks. +/** Concatenate the visible text of a node, recursing into inline children. */ +function nodeText(node: any): string { + if (typeof node === 'string') + return node + if (Array.isArray(node)) + return node.slice(2).map(nodeText).join('') + return '' +} + +function isTag(node: any, ...tags: string[]): boolean { + return Array.isArray(node) && tags.includes(node[0]) +} + +/** + * Parse a release note into buckets via the md4x AST. Headings set the active + * bucket; list items inherit it (or fall back to their own conventional-commit + * prefix). Code blocks parse as `pre` nodes, so `-`/`+` diff lines never leak in + * as bullets. Unclassified items lower `coverage`; low coverage flags the + * release for LLM bucketing instead. + */ +export async function parseReleaseToBuckets(version: string, markdown: string, flagThreshold = 0.6, extraHeadingRules: HeadingRule[] = []): Promise { + const { nodes } = await parseAST(markdown) + const buckets = emptyBuckets() + const unmatchedHeadings: string[] = [] + let currentHeadingBucket: BucketType | null = null + let total = 0 + let classified = 0 + let proseChars = 0 + + const classifyItem = (text: string) => { + if (!text.trim()) + return + total++ + // A bullet's own conventional-commit prefix is more specific than its + // section, so it wins (lets GitHub's "What's Changed" lists of `feat:`/`fix:` + // bullets classify correctly); breaking always wins; bare bullets inherit + // the active heading bucket. + const own = bucketForBullet(text) + // Priority: explicit breaking > conventional prefix > active heading > + // leading-verb guess. The guess only fires for bare freeform bullets. + const bucket = own === 'breaking' ? 'breaking' : (own ?? currentHeadingBucket ?? looseBucketForBullet(text)) + if (bucket) { + buckets[bucket].push(cleanBullet(text.trim())) + classified++ + } + } + + const walk = (siblings: any[]) => { + for (const node of siblings) { + if (isTag(node, 'h1', 'h2', 'h3', 'h4', 'h5', 'h6')) { + const text = nodeText(node).trim() + const disp = headingDisposition(text, extraHeadingRules) + if (disp === 'transparent') + continue // keep the active bucket; carries no signal of its own + currentHeadingBucket = disp + if (!disp && text) + unmatchedHeadings.push(text) + } + else if (isTag(node, 'ul', 'ol')) { + for (const li of node.slice(2) as any[]) { + if (!isTag(li, 'li')) + continue + const children = li.slice(2) as any[] + const nested = children.filter(c => isTag(c, 'ul', 'ol')) + const direct = children.filter(c => !isTag(c, 'ul', 'ol')) + classifyItem(direct.map(nodeText).join('')) + walk(nested) // sub-bullets, same heading context + } + } + else if (isTag(node, 'p')) { + proseChars += nodeText(node).length + } + // pre/code/blockquote/table etc: skip — no migration bullets there. + } + } + walk(nodes) + + const coverage = total === 0 ? 0 : classified / total + // Flag prose-heavy/custom releases (drizzle-style) for LLM bucketing: either + // we classified too little, or there were no list items but real prose. + const flagged = total === 0 ? proseChars > 200 : coverage < flagThreshold + + return { version, buckets, totalItems: total, classifiedItems: classified, coverage, flagged, unmatchedHeadings } +} + +/** Merge many releases' buckets into one, deduping identical items. */ +export function mergeBuckets(all: Buckets[]): Buckets { + const merged = emptyBuckets() + for (const type of BUCKET_TYPES) { + const seen = new Set() + for (const b of all) { + for (const item of b[type]) { + const key = item.toLowerCase() + if (!seen.has(key)) { + seen.add(key) + merged[type].push(item) + } + } + } + } + return merged +} + +export function bucketCounts(b: Buckets): Record { + return { breaking: b.breaking.length, features: b.features.length, fixes: b.fixes.length, improvements: b.improvements.length } +} diff --git a/src/guides/generate.ts b/src/guides/generate.ts new file mode 100644 index 00000000..cdc24859 --- /dev/null +++ b/src/guides/generate.ts @@ -0,0 +1,466 @@ +/** + * Migration-guide generation — npm package → agent-digestible upgrade guide. + * + * Reuses skilld's data layer end to end: + * 1. pick the largest version across dist-tags (incl. prereleases) + * 2. resolve the source repo at that version + * 3. fetch + cache release notes / CHANGELOG into the reference cache + * 4. synthesise the guide via the configured LLM executor + * + * Produces a `GeneratedGuide` the caller persists (skilld.dev `npm-guides`). + */ + +import type { OptimizeModel } from '../agent/index.ts' +import type { FeaturesConfig } from '../core/config.ts' +import type { VersionBuckets } from './bucket-pipeline.ts' +import type { Buckets, BucketType } from './buckets.ts' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'pathe' +import { selectExecutor } from '../agent/clis/executors.ts' +import { CACHE_DIR, getCacheDir, getRepoCacheDir } from '../cache/index.ts' +import { fetchAndCacheResources } from '../commands/sync/pipeline.ts' +import { isSnapshotVersion, pickLatestTag, semverGt, semverMajor, semverValid } from '../core/semver.ts' +import { parseGitHubUrl, parsePackageSpec } from '../core/url.ts' +import { fetchNpmRegistryMeta } from '../sources/npm-registry.ts' +import { resolvePackageOrCrate } from '../sources/resolve-package.ts' +import { bucketReleases, formatBucketsForRunbook } from './bucket-pipeline.ts' +import { buildGuidePrompt } from './prompt.ts' +import { PIPELINE_VERSION, RUNBOOK_PROMPT_VERSION } from './version.ts' + +type BucketCounts = Record +interface ReleaseInput { version: string, markdown: string } + +/** Guide synthesis only needs releases; skip issues/discussions for speed. */ +const GUIDE_FEATURES: FeaturesConfig = { + search: false, + issues: false, + discussions: false, + releases: true, +} + +const DEFAULT_TIMEOUT = 240_000 +/** Cap releases bucketed per guide — bounds the LLM fork on huge-history packages. */ +const MAX_RELEASES = Number(process.env.GUIDE_MAX_RELEASES) || 30 + +export interface GenerateGuideOptions { + cwd?: string + model?: OptimizeModel + /** Reuse cached references when present (default true). */ + useCache?: boolean + timeout?: number + /** Run the clean+extract distillation pass before synthesis (default true). */ + distill?: boolean + /** Model for the cleanup/extract map-pass; defaults to `model`. Use a faster one. */ + distillModel?: OptimizeModel + /** + * Major version to scope the guide to (the v3→v4 jump). Defaults to the major + * of the latest published version. Each major is its own content collection, + * so older majors are generated as separate batched pages. + */ + major?: number + onProgress?: (message: string) => void +} + +export interface GeneratedGuide { + packageName: string + /** URL-safe slug (scoped names keep their `/`; caller encodes for the path). */ + slug: string + /** Largest version, the canonical target of the guide. */ + version: string + tag: string + prerelease: boolean + /** Stable version migrated from, when distinct from `version`. */ + fromVersion?: string + repoUrl?: string + releasedAt?: string + title: string + markdown: string + /** Per-type change counts from bucketing — powers the change8-style listing. */ + counts: BucketCounts + /** + * Per-version buckets (newest-first) within the major — powers the from-version + * selector + per-version sections on the page. + */ + releaseBuckets: VersionBuckets[] + /** Other published versions this canonical guide stands in for (301 sources). */ + supersedes: string[] + /** Pipeline stamp (`b.p`); mismatch ⇒ this guide is stale. */ + pipelineVersion: string + model: OptimizeModel + usage?: { input: number, output: number } + cost?: number +} + +export type GenerateGuideResult + = | { ok: true, guide: GeneratedGuide } + | { ok: false, error: string } + +const MD_SUFFIX_RE = /\.md$/ +const V_PREFIX_RE = /^v/ +// First semver-looking token anywhere in a filename — recovers the version from +// monorepo/scoped release tags like `vquasar-v2.18.0` or `@scope/pkg@2.6.0`. +const SEMVER_IN_NAME_RE = /(\d+\.\d+\.\d+(?:-[0-9A-Z.-]+)?)/i + +/** + * Honest stub for a release with no actionable changes (no breaking changes or + * code-affecting features). Without it, synthesis on raw release notes invents + * breaking-change sections out of version-bump noise (e.g. quasar's per-release + * `## v2.18.0` bumps). Surfaces the fix/improvement counts so the page still + * reflects what shipped, while making clear no migration work is required. + */ +function buildNoChangesStub(packageName: string, version: string, counts: BucketCounts, repoUrl?: string): string { + const tally = [ + counts.fixes ? `${counts.fixes} fix${counts.fixes === 1 ? '' : 'es'}` : '', + counts.improvements ? `${counts.improvements} improvement${counts.improvements === 1 ? '' : 's'}` : '', + ].filter(Boolean).join(' and ') + const summary = tally + ? `This release ships ${tally} but no breaking changes or new APIs, so upgrading requires no code changes.` + : `No breaking changes or new APIs were detected in this release, so upgrading requires no code changes.` + const ref = repoUrl ? `\n\nFor the full changelog, see ${repoUrl}/releases.` : '' + return `# Migrating ${packageName} to ${version} + +${summary} + +## Upgrade steps + +1. Update the dependency: + \`\`\`bash + npm install ${packageName}@${version} + \`\`\` +2. Run your build and test suite to confirm nothing breaks. + +If you depend on internal or undocumented APIs, review the upstream release notes before upgrading.${ref}` +} + +/** Version parsed from a release filename, e.g. `v1.0.0-beta.8.md` → `1.0.0-beta.8`. */ +function versionFromReleaseFile(file: string): string | null { + const base = file.replace(MD_SUFFIX_RE, '') + // Plain `v1.2.3.md` / `1.2.3.md`. + const direct = base.replace(V_PREFIX_RE, '') + if (semverValid(direct)) + return direct + // Monorepo/scoped tag (`vquasar-v2.18.0`, `@scope/pkg@2.6.0`): pull the semver out. + const m = base.match(SEMVER_IN_NAME_RE) + return m && semverValid(m[1]!) ? m[1]! : null +} + +/** + * Locate the cached `releases/` dir. Timeline releases write to the repo-level + * cache (`~/.skilld/repos///releases/`); fall back to the + * per-package dir for sources without a resolved repo (e.g. blog releases). + */ +function findReleasesDir(packageName: string, version: string, repoUrl?: string): string | null { + const repo = repoUrl ? parseGitHubUrl(repoUrl) : null + if (repo) { + const dir = join(getRepoCacheDir(repo.owner, repo.repo), 'releases') + if (existsSync(dir)) + return dir + } + const pkgDir = join(getCacheDir(packageName, version), 'releases') + return existsSync(pkgDir) ? pkgDir : null +} + +// CHANGELOG version heading: `## 1.2.3`, `### [1.2.3]`, `## v1.2.3 (2024…)`. +const CHANGELOG_VERSION_HEADING_RE = /^#{1,4}\s+\[?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/ + +/** + * Split a CHANGELOG.md into per-version sections so the bucketer has structured + * input. Used when a package ships no per-release files (or they're stale), + * keeping synthesis on bucketed bullets rather than raw prose. + */ +function splitChangelog(markdown: string): ReleaseInput[] { + const out: ReleaseInput[] = [] + let cur: { version: string, body: string[] } | null = null + for (const line of markdown.split('\n')) { + const m = line.match(CHANGELOG_VERSION_HEADING_RE) + if (m && semverValid(m[1]!)) { + if (cur) + out.push({ version: cur.version, markdown: cur.body.join('\n') }) + cur = { version: m[1]!, body: [line] } + } + else if (cur) { + cur.body.push(line) + } + } + if (cur) + out.push({ version: cur.version, markdown: cur.body.join('\n') }) + return out +} + +/** + * Read a major version's releases as individual notes (newest-first) for the + * bucketing pipeline, which classifies each release on its own. A guide is + * scoped to ONE major (the v3→v4 jump), so we keep only releases whose major + * equals `targetMajor` — this is what keeps "Migrating to v9" from absorbing the + * previous major's patch noise. Per-release files are preferred; when none + * survive, the CHANGELOG is split into per-version sections as a fallback. + */ +function readReleaseList( + packageName: string, + version: string, + repoUrl: string | undefined, + targetMajor: number, +): ReleaseInput[] { + const releasesDir = findReleasesDir(packageName, version, repoUrl) + if (!releasesDir) + return [] + + const inMajor = (v: string) => semverMajor(v) === targetMajor + // Cap within the major: an extremely active major could exceed this, but the + // newest N releases of the major are the relevant upgrade surface. + const files = readdirSync(releasesDir).filter(f => f.endsWith('.md')) + const perRelease = files + .filter(f => f !== '_INDEX.md' && f !== 'CHANGELOG.md') + .map(f => ({ file: f, version: versionFromReleaseFile(f) })) + .filter((e): e is { file: string, version: string } => e.version != null) + .filter(e => inMajor(e.version)) + .sort((a, b) => (semverGt(a.version, b.version) ? -1 : 1)) + .slice(0, MAX_RELEASES) + .map(e => ({ version: e.version, markdown: readFileSync(join(releasesDir, e.file), 'utf8') })) + if (perRelease.length) + return perRelease + + if (files.includes('CHANGELOG.md')) { + return splitChangelog(readFileSync(join(releasesDir, 'CHANGELOG.md'), 'utf8')) + .filter(e => inMajor(e.version)) + .sort((a, b) => (semverGt(a.version, b.version) ? -1 : 1)) + .slice(0, MAX_RELEASES) + } + return [] +} + +/** + * The latest cached release of the previous major — the "migrating from" anchor + * so a v9 guide reads "8.3.18 → 9.2.2". Undefined when the previous major isn't + * in the cached window (e.g. a brand-new package with only one major). + */ +function previousMajorAnchor( + packageName: string, + version: string, + repoUrl: string | undefined, + targetMajor: number, +): string | undefined { + const releasesDir = findReleasesDir(packageName, version, repoUrl) + if (!releasesDir) + return undefined + return readdirSync(releasesDir) + .filter(f => f.endsWith('.md') && f !== '_INDEX.md' && f !== 'CHANGELOG.md') + .map(versionFromReleaseFile) + .filter((v): v is string => v != null && semverMajor(v) === targetMajor - 1) + .sort((a, b) => (semverGt(a, b) ? -1 : 1))[0] +} + +interface PickedLike { version: string, tag: string, prerelease: boolean, releasedAt?: string } +interface MetaLike { distTags?: Record } + +/** Assemble the final `GeneratedGuide`, deriving the 301-source `supersedes` list. */ +function buildGuideResult( + packageName: string, + picked: PickedLike, + fromVersion: string | undefined, + repoUrl: string | undefined, + resolvedReleasedAt: string | undefined, + counts: BucketCounts, + releaseBuckets: VersionBuckets[], + meta: MetaLike, + model: OptimizeModel, + markdown: string, + usage?: { input: number, output: number }, + cost?: number, +): GeneratedGuide { + // Clean, redirect-worthy prior versions only — drop per-commit snapshots and + // dedupe. These are the versioned URLs that 301 to the canonical guide. + const supersedes = Object.values(meta.distTags ?? {}) + .map(t => t.version) + .filter(v => v !== picked.version && !isSnapshotVersion(v)) + .filter((v, i, all) => all.indexOf(v) === i) + .sort((a, b) => (semverGt(a, b) ? -1 : 1)) + + return { + packageName, + slug: packageName, + version: picked.version, + tag: picked.tag, + prerelease: picked.prerelease, + fromVersion, + repoUrl, + releasedAt: picked.releasedAt ?? resolvedReleasedAt, + title: `Migrating ${packageName} to ${picked.version}`, + markdown, + counts, + releaseBuckets, + supersedes, + pipelineVersion: PIPELINE_VERSION, + model, + usage, + cost, + } +} + +export async function generateGuide( + packageSpec: string, + opts: GenerateGuideOptions = {}, +): Promise { + const { cwd = process.cwd(), model = 'sonnet', useCache = true, timeout = DEFAULT_TIMEOUT, distill, distillModel, major, onProgress = () => {} } = opts + const { name: packageName } = parsePackageSpec(packageSpec) + + onProgress(`Resolving dist-tags for ${packageName}`) + const meta = await fetchNpmRegistryMeta(packageName, '') + const picked = pickLatestTag(meta.distTags) + if (!picked) + return { ok: false, error: `No valid published version found for ${packageName}` } + + // Scope the guide to one major (default: the latest version's major). This is + // the v3→v4 unit — keeping a guide from spilling across a major boundary. + const targetMajor = major ?? semverMajor(picked.version) + if (targetMajor == null) + return { ok: false, error: `Could not determine major version for ${packageName}@${picked.version}` } + + onProgress(`Resolving ${packageName}@${picked.version} source`) + const resolution = await resolvePackageOrCrate(`${packageName}@${picked.version}`, { cwd, onProgress }) + const resolved = resolution.resolved + if (!resolved?.repoUrl) + return { ok: false, error: `Could not resolve a source repo for ${packageName}` } + + onProgress(`Fetching release notes for ${packageName}@${picked.version}`) + await fetchAndCacheResources({ + packageName, + resolved, + version: picked.version, + useCache, + features: GUIDE_FEATURES, + onProgress, + }) + + const releases = readReleaseList(packageName, picked.version, resolved.repoUrl, targetMajor) + // Synthesis runs only on bucketed bullets, never raw prose — without structured + // release notes the model hallucinates (e.g. remark's CHANGELOG is just a "see + // GitHub Releases" pointer). Refuse rather than publish an invented guide. + if (!releases.length) + return { ok: false, error: `No structured release notes found for ${packageName}@${picked.version} (major ${targetMajor})` } + + // "Migrating from" anchor = the previous major's last release, when cached. + const fromVersion = previousMajorAnchor(packageName, picked.version, resolved.repoUrl, targetMajor) + + const executor = selectExecutor(model) + if ('error' in executor) + return { ok: false, error: executor.error } + + // The flagged-release re-bucketing is a simpler task than synthesis, so it can + // run on a smaller, faster model (distillModel) while synthesis uses `model`. + const distillExecutor = distillModel && distillModel !== model ? selectExecutor(distillModel) : executor + if ('error' in distillExecutor) + return { ok: false, error: distillExecutor.error } + + // The per-package cache dir may not exist (data can live in the repo cache), + // so ensure it before any read/write of the bucket cache or skillDir. + const cacheDir = getCacheDir(packageName, picked.version) + mkdirSync(cacheDir, { recursive: true }) + + // Build a one-shot completion helper around a given executor. + const completeWith = (ex: typeof executor) => async (p: string): Promise => { + const r = await ex.run({ + section: 'custom', + prompt: p, + skillDir: cacheDir, + skilldDir: CACHE_DIR, + timeout, + onProgress: prog => onProgress(prog.type), + }) + return (r.text || r.writeContent || '').trim() + } + + // Bucket the release notes: deterministic md4x parsing for the ~93% that are + // structured, LLM re-bucketing only for prose-heavy flagged releases. The + // merged buckets feed synthesis and the counts power the listing; cache both + // so re-runs and prompt iteration skip the (expensive) LLM fork. + const bucketCachePath = join(cacheDir, 'guide-buckets.json') + const cached = useCache && existsSync(bucketCachePath) + ? JSON.parse(readFileSync(bucketCachePath, 'utf8')) as { buckets: Buckets, counts: BucketCounts, perVersion?: VersionBuckets[] } + : null + let buckets: Buckets + let counts: BucketCounts + let perVersion: VersionBuckets[] + // Cache invalidates when it predates per-version data (older cache shape). + if (cached && cached.perVersion) { + onProgress('Using cached buckets') + buckets = cached.buckets + counts = cached.counts + perVersion = cached.perVersion + } + else { + onProgress(`Bucketing ${releases.length} releases${distill === false ? ' (deterministic only)' : ''}`) + const res = await bucketReleases(releases, { + packageName, + complete: distill === false ? undefined : completeWith(distillExecutor), + onProgress, + }) + buckets = res.buckets + counts = res.counts + perVersion = res.perVersion + writeFileSync(bucketCachePath, JSON.stringify({ buckets, counts, perVersion })) + } + + // Actionable guard: a runbook needs breaking changes or code-affecting + // features to migrate against. Pure fix/improvement releases (and releases + // that bucketed to nothing) get an honest stub instead of a synthesised + // runbook — this is what kills the version-bump-noise hallucinations. + const synthInput = formatBucketsForRunbook(buckets) + if (!synthInput.trim()) { + onProgress(`No actionable changes for ${packageName}@${picked.version}; emitting stub`) + return { + ok: true, + guide: buildGuideResult(packageName, picked, fromVersion, resolved.repoUrl, resolved.releasedAt, counts, perVersion, meta, model, buildNoChangesStub(packageName, picked.version, counts, resolved.repoUrl)), + } + } + + // Runbook synthesis is the expensive LLM stage. Cache it keyed by the synthesis + // input + prompt version, so bucketing/UI/windowing tweaks that don't change the + // breaking+features input reuse the prose; only a prompt-version bump (or genuinely + // changed inputs) re-synthesises. This is what makes future regens incremental. + const runbookCachePath = join(cacheDir, 'guide-runbook.json') + const synthHash = createHash('sha256').update(synthInput).digest('hex').slice(0, 16) + const cachedRunbook = useCache && existsSync(runbookCachePath) + ? JSON.parse(readFileSync(runbookCachePath, 'utf8')) as { hash: string, promptVersion: number, markdown: string } + : null + + let markdown: string + let usage: { input: number, output: number } | undefined + let cost: number | undefined + if (cachedRunbook && cachedRunbook.hash === synthHash && cachedRunbook.promptVersion === RUNBOOK_PROMPT_VERSION) { + onProgress('Using cached runbook (synthesis input unchanged)') + markdown = cachedRunbook.markdown + } + else { + onProgress(`Synthesising guide with ${model}`) + const prompt = buildGuidePrompt({ + packageName, + version: picked.version, + fromVersion, + prerelease: picked.prerelease, + repoUrl: resolved.repoUrl, + material: synthInput, + contextCounts: { fixes: counts.fixes, improvements: counts.improvements }, + }) + const out = await executor.run({ + section: 'custom', + prompt, + skillDir: getCacheDir(packageName, picked.version), + skilldDir: CACHE_DIR, + timeout, + onProgress: p => onProgress(p.type), + }) + markdown = (out.text || out.writeContent || '').trim() + if (!markdown) + return { ok: false, error: `LLM produced no output for ${packageName}${out.stderr ? `: ${out.stderr}` : ''}` } + usage = out.usage + cost = out.cost + writeFileSync(runbookCachePath, JSON.stringify({ hash: synthHash, promptVersion: RUNBOOK_PROMPT_VERSION, markdown })) + } + + return { + ok: true, + guide: buildGuideResult(packageName, picked, fromVersion, resolved.repoUrl, resolved.releasedAt, counts, perVersion, meta, model, markdown, usage, cost), + } +} diff --git a/src/guides/version.ts b/src/guides/version.ts new file mode 100644 index 00000000..6ef49eb6 --- /dev/null +++ b/src/guides/version.ts @@ -0,0 +1,19 @@ +/** + * Pipeline stage versions — bump the relevant one when its logic changes so + * cached artifacts and persisted guides become "stale" and only the affected + * stage re-runs (on only the affected guides). This is what keeps a tweak from + * forcing a blanket regen: + * - BUCKET_RULES_VERSION → bump on bucketing-rule changes (buckets.ts). + * - RUNBOOK_PROMPT_VERSION → bump on synthesis prompt changes (prompt.ts). + * Per-version sections, counts, windowing, and all UI are deterministic from + * stored buckets and need NO version bump (zero regen). + */ + +/** Bumped 1→2: `[**BREAKING**]` marker detection (jest/babel monorepo changelogs). */ +export const BUCKET_RULES_VERSION = 2 + +/** Synthesis prompt revision. */ +export const RUNBOOK_PROMPT_VERSION = 1 + +/** Composite stamp persisted on each guide; mismatch ⇒ regenerate that guide. */ +export const PIPELINE_VERSION = `b${BUCKET_RULES_VERSION}.p${RUNBOOK_PROMPT_VERSION}` From 8e41432fa10e0766ccc14d6c0dc4da502bc68882 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sun, 31 May 2026 18:10:21 +1000 Subject: [PATCH 2/4] =?UTF-8?q?fix(guides):=20parse=20two-part=20version?= =?UTF-8?q?=20tags=20(TypeScript=20v6.0-beta=20=E2=86=92=206.0.0-beta)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pad major.minor[-pre] release tags so they're valid semver and land in the right major. Note: TS v6 migration notes live in excluded prerelease releases — capturing those needs selectReleases to include same-major prereleases for migration guides. --- src/guides/generate.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/guides/generate.ts b/src/guides/generate.ts index cdc24859..7ce058b2 100644 --- a/src/guides/generate.ts +++ b/src/guides/generate.ts @@ -101,6 +101,8 @@ const V_PREFIX_RE = /^v/ // First semver-looking token anywhere in a filename — recovers the version from // monorepo/scoped release tags like `vquasar-v2.18.0` or `@scope/pkg@2.6.0`. const SEMVER_IN_NAME_RE = /(\d+\.\d+\.\d+(?:-[0-9A-Z.-]+)?)/i +// Two-part `major.minor` with optional prerelease (e.g. TypeScript `6.0-beta`). +const SEMVER_2PART_RE = /\b(\d+)\.(\d+)(-[0-9A-Z.-]+)?\b/i /** * Honest stub for a release with no actionable changes (no breaking changes or @@ -142,7 +144,17 @@ function versionFromReleaseFile(file: string): string | null { return direct // Monorepo/scoped tag (`vquasar-v2.18.0`, `@scope/pkg@2.6.0`): pull the semver out. const m = base.match(SEMVER_IN_NAME_RE) - return m && semverValid(m[1]!) ? m[1]! : null + if (m && semverValid(m[1]!)) + return m[1]! + // Two-part major.minor tags, optionally prereleased (TypeScript's `v6.0-beta`, + // `v6.0-rc`): pad the patch so they're valid semver and land in the right major. + const two = base.match(SEMVER_2PART_RE) + if (two) { + const padded = `${two[1]}.${two[2]}.0${two[3] ?? ''}` + if (semverValid(padded)) + return padded + } + return null } /** From 3967ff607aa0f73a7feb7f3070ced3d3132637c2 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sun, 31 May 2026 18:24:33 +1000 Subject: [PATCH 3/4] =?UTF-8?q?feat(guides):=20best-practices=20artifact?= =?UTF-8?q?=20generator=20(docs=20=E2=86=92=20skill,=20stamped=20+=20cache?= =?UTF-8?q?d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pillar alongside migration guides: synthesises a 'how to use ' skill from cached docs/README (stable latest version), with the same stamp + synthesis-cache machinery. Validated on zod (Setup/Core API/Idioms/Mistakes/Example). --- src/guides/best-practices.ts | 206 +++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 src/guides/best-practices.ts diff --git a/src/guides/best-practices.ts b/src/guides/best-practices.ts new file mode 100644 index 00000000..60ce5606 --- /dev/null +++ b/src/guides/best-practices.ts @@ -0,0 +1,206 @@ +/** + * Best-practices artifact — the "how to use correctly" skill, the second + * pillar alongside migration guides (see [[project_npm_guides_pseo]]). Mirrors + * the migration pipeline (resolve → fetch → synthesise → stamped/cached markdown) + * but sources from the package's DOCS/README rather than release notes, and is + * evergreen (one per package, scoped to the latest version, not per major). + * + * Output: an agent-digestible best-practices skill that doubles as the `/npm/` + * hub's "Best practices" section and SEO surface. + */ + +import type { OptimizeModel } from '../agent/index.ts' +import type { FeaturesConfig } from '../core/config.ts' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'pathe' +import { selectExecutor } from '../agent/clis/executors.ts' +import { CACHE_DIR, getCacheDir } from '../cache/index.ts' +import { fetchAndCacheResources } from '../commands/sync/pipeline.ts' +import { pickLatestTag } from '../core/semver.ts' +import { parsePackageSpec } from '../core/url.ts' +import { fetchNpmRegistryMeta } from '../sources/npm-registry.ts' +import { resolvePackageOrCrate } from '../sources/resolve-package.ts' + +/** Bump when the best-practices synthesis prompt changes (stage stamp). */ +export const BP_PROMPT_VERSION = 1 +export const BP_PIPELINE_VERSION = `bp${BP_PROMPT_VERSION}` + +const DEFAULT_TIMEOUT = 240_000 +const MAX_MATERIAL_CHARS = Number(process.env.BP_MAX_MATERIAL_CHARS) || 90_000 +/** Docs only — no releases/issues/discussions for the best-practices artifact. */ +const BP_FEATURES: FeaturesConfig = { search: false, issues: false, discussions: false, releases: false } +const MD_RE = /\.md$/ + +export interface GenerateBestPracticesOptions { + cwd?: string + model?: OptimizeModel + useCache?: boolean + timeout?: number + onProgress?: (message: string) => void +} + +export interface GeneratedBestPractices { + packageName: string + slug: string + version: string + repoUrl?: string + title: string + markdown: string + pipelineVersion: string + model: OptimizeModel + usage?: { input: number, output: number } + cost?: number +} + +export type GenerateBestPracticesResult + = | { ok: true, skill: GeneratedBestPractices } + | { ok: false, error: string } + +/** + * Read cached documentation material for synthesis, newest doc first. Pulls the + * README, `docs/`, and `llms-docs/` markdown from the reference cache (skilld's + * doc fetch already wrote these), capped so a huge doc set can't blow the context. + */ +function readDocMaterial(packageName: string, version: string): string { + const base = getCacheDir(packageName, version) + if (!existsSync(base)) + return '' + // Priority order: README first (overview), then curated docs, then llms-docs. + const candidates: string[] = [] + const readme = ['pkg/README.md', 'README.md', 'packages/docs/README.md', 'docs/README.md'] + .map(p => join(base, p)) + .find(existsSync) + if (readme) + candidates.push(readme) + for (const sub of ['docs', 'llms-docs']) { + const dir = join(base, sub) + if (existsSync(dir)) { + for (const f of readdirSync(dir)) { + if (MD_RE.test(f) && f !== '_INDEX.md') + candidates.push(join(dir, f)) + } + } + } + const parts: string[] = [] + let budget = MAX_MATERIAL_CHARS + for (const file of candidates) { + if (budget <= 0) + break + const body = readFileSync(file, 'utf8').slice(0, budget) + budget -= body.length + parts.push(body) + } + return parts.join('\n\n---\n\n') +} + +function buildBestPracticesPrompt(packageName: string, version: string, material: string): string { + return `You are writing a concise BEST-PRACTICES skill for the npm package \`${packageName}\` (current version ${version}), for a coding agent that must use it correctly today. Base every claim ONLY on the documentation below — do not invent APIs. + +Output Markdown with these sections (omit one only if the docs truly lack it): + +## Setup +Install command and the minimum config to get running. + +## Core API +The handful of APIs/exports an agent actually uses, each with a one-line purpose and a short code example copied or adapted from the docs. + +## Idiomatic patterns +The recommended way to do the common tasks — the patterns the docs steer you toward. + +## Common mistakes +Pitfalls, deprecated approaches, and gotchas the docs warn about. Concrete and specific. + +## Minimal example +One complete, runnable snippet showing the typical usage. + +Rules: imperative and dense; real code from the docs (no placeholders/invented APIs); no marketing prose, contributor lists, or links-only filler. + +Documentation: +${material}` +} + +export async function generateBestPractices( + packageSpec: string, + opts: GenerateBestPracticesOptions = {}, +): Promise { + const { cwd = process.cwd(), model = 'sonnet', useCache = true, timeout = DEFAULT_TIMEOUT, onProgress = () => {} } = opts + const { name: packageName } = parsePackageSpec(packageSpec) + + onProgress(`Resolving dist-tags for ${packageName}`) + const meta = await fetchNpmRegistryMeta(packageName, '') + // Best practices describe the CURRENT STABLE release, so prefer the `latest` + // dist-tag; only fall back to the largest tag (incl. prereleases) when there + // is no stable release. (Prerelease/canary versions ship without docs.) + const version = meta.distTags?.latest?.version ?? pickLatestTag(meta.distTags)?.version + if (!version) + return { ok: false, error: `No valid published version found for ${packageName}` } + + onProgress(`Resolving ${packageName}@${version} source`) + const resolution = await resolvePackageOrCrate(`${packageName}@${version}`, { cwd, onProgress }) + const resolved = resolution.resolved + if (!resolved?.repoUrl) + return { ok: false, error: `Could not resolve a source repo for ${packageName}` } + + onProgress(`Fetching docs for ${packageName}@${version}`) + await fetchAndCacheResources({ packageName, resolved, version, useCache, features: BP_FEATURES, onProgress }) + + const material = readDocMaterial(packageName, version) + if (material.trim().length < 200) + return { ok: false, error: `No documentation found for ${packageName}@${version}` } + + const executor = selectExecutor(model) + if ('error' in executor) + return { ok: false, error: executor.error } + + const cacheDir = getCacheDir(packageName, version) + mkdirSync(cacheDir, { recursive: true }) + + // Synthesis cache: skip the LLM when the docs + prompt version are unchanged. + const cachePath = join(cacheDir, 'best-practices.json') + const hash = createHash('sha256').update(material).digest('hex').slice(0, 16) + const cached = useCache && existsSync(cachePath) + ? JSON.parse(readFileSync(cachePath, 'utf8')) as { hash: string, promptVersion: number, markdown: string } + : null + + let markdown: string + let usage: { input: number, output: number } | undefined + let cost: number | undefined + if (cached && cached.hash === hash && cached.promptVersion === BP_PROMPT_VERSION) { + onProgress('Using cached best-practices') + markdown = cached.markdown + } + else { + onProgress(`Synthesising best-practices with ${model}`) + const out = await executor.run({ + section: 'custom', + prompt: buildBestPracticesPrompt(packageName, version, material), + skillDir: cacheDir, + skilldDir: CACHE_DIR, + timeout, + onProgress: p => onProgress(p.type), + }) + markdown = (out.text || out.writeContent || '').trim() + if (!markdown) + return { ok: false, error: `LLM produced no output for ${packageName}${out.stderr ? `: ${out.stderr}` : ''}` } + usage = out.usage + cost = out.cost + writeFileSync(cachePath, JSON.stringify({ hash, promptVersion: BP_PROMPT_VERSION, markdown })) + } + + return { + ok: true, + skill: { + packageName, + slug: packageName, + version, + repoUrl: resolved.repoUrl, + title: `${packageName} best practices`, + markdown, + pipelineVersion: BP_PIPELINE_VERSION, + model, + usage, + cost, + }, + } +} From ecba04ae0f770da5ed163b1608d8b5a58c2e057e Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 4 Aug 2026 17:32:03 +1000 Subject: [PATCH 4/4] fix(guides): include generator inputs --- package.json | 1 + pnpm-lock.yaml | 12 +++++++++ pnpm-workspace.yaml | 1 + src/guides/curated.ts | 36 +++++++++++++++++++++++++ src/guides/md4x.d.ts | 10 +++++++ src/guides/prompt.ts | 63 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+) create mode 100644 src/guides/curated.ts create mode 100644 src/guides/md4x.d.ts create mode 100644 src/guides/prompt.ts diff --git a/package.json b/package.json index 60101358..305af178 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "hookable": "catalog:", "jsonc-parser": "catalog:", "log-update": "catalog:deps", + "md4x": "catalog:", "mdream": "catalog:", "ofetch": "catalog:", "oxc-parser": "catalog:deps", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 683377e2..407b3f79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ catalogs: jsonc-parser: specifier: ^3.3.1 version: 3.3.1 + md4x: + specifier: ^0.0.25 + version: 0.0.25 mdream: specifier: ^1.2.2 version: 1.2.2 @@ -147,6 +150,9 @@ importers: log-update: specifier: catalog:deps version: 8.0.0 + md4x: + specifier: 'catalog:' + version: 0.0.25 mdream: specifier: 'catalog:' version: 1.2.2 @@ -3019,6 +3025,10 @@ packages: resolution: {integrity: sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==} engines: {node: '>=10'} + md4x@0.0.25: + resolution: {integrity: sha512-GrexawUhrKcwl7o2hkgs7Ut0PqI/meOCevmaRB1ueKo2y1Fh2nIl8e6KKawYGm9eXJP3u6BzVs4rzZOc2+w3hA==} + hasBin: true + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -6648,6 +6658,8 @@ snapshots: dependencies: escape-string-regexp: 4.0.0 + md4x@0.0.25: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ec540b43..3ebb05d1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,7 @@ catalog: giget: ^3.2.0 hookable: ^6.1.1 jsonc-parser: ^3.3.1 + md4x: ^0.0.25 mdream: ^1.2.2 ofetch: ^1.5.1 pathe: ^2.0.3 diff --git a/src/guides/curated.ts b/src/guides/curated.ts new file mode 100644 index 00000000..990787e5 --- /dev/null +++ b/src/guides/curated.ts @@ -0,0 +1,36 @@ +/** + * Curated guide seed — the package set we generate migration guides for. + * + * Sourced from skilld's `REPO_REGISTRY` (the frameworks/libs we already track), + * deduped to one headline package per repo so we don't emit guides for internal + * sub-packages like `@vue/shared`. Expand the registry to grow the guide set. + */ + +import { REPO_REGISTRY } from '../sources/package-registry.data.ts' + +export interface CuratedListOptions { + /** Emit every package, not just the primary one per repo. */ + all?: boolean +} + +/** The headline package for a repo: the `primary` one, else the first listed. */ +function headlinePackage(packages: Record): string | undefined { + const names = Object.keys(packages) + return names.find(n => packages[n]?.primary) ?? names[0] +} + +export function listCuratedPackages(opts: CuratedListOptions = {}): string[] { + const out = new Set() + for (const entry of Object.values(REPO_REGISTRY)) { + if (opts.all) { + for (const name of Object.keys(entry.packages)) + out.add(name) + } + else { + const headline = headlinePackage(entry.packages) + if (headline) + out.add(headline) + } + } + return [...out] +} diff --git a/src/guides/md4x.d.ts b/src/guides/md4x.d.ts new file mode 100644 index 00000000..023282d3 --- /dev/null +++ b/src/guides/md4x.d.ts @@ -0,0 +1,10 @@ +// md4x ships types only for its `./wasm` subpath; the root export (napi on +// node) is untyped. Declare just the surface we use. +declare module 'md4x' { + interface Md4xAst { + nodes: unknown[] + frontmatter: Record + meta: Record + } + export function parseAST(markdown: string): Promise +} diff --git a/src/guides/prompt.ts b/src/guides/prompt.ts new file mode 100644 index 00000000..6b2154af --- /dev/null +++ b/src/guides/prompt.ts @@ -0,0 +1,63 @@ +/** + * Upgrade-runbook synthesis prompt. + * + * The primary consumer is a coding agent handed this via `npx skilld add`, so + * the output is an *executable runbook*, not a prose guide: breaking changes + * first (the only changes that require action), as concrete transformations the + * agent applies top-to-bottom, then verification. It stays valid Markdown so it + * reads fine for humans on the web too. + * + * Input is the pre-bucketed breaking + code-affecting feature changes; fixes and + * improvements are passed only as counts (context, no action needed). + */ + +export interface GuidePromptInput { + packageName: string + /** Largest version we are migrating TO (may be a prerelease). */ + version: string + /** Stable version we are migrating FROM, when known. */ + fromVersion?: string + prerelease: boolean + repoUrl?: string + /** Bucketed breaking + feature changes (Markdown sections). */ + material: string + /** Fixes/improvements counts — surfaced as context, not steps. */ + contextCounts?: { fixes: number, improvements: number } +} + +export function buildGuidePrompt(input: GuidePromptInput): string { + const { packageName, version, fromVersion, prerelease, repoUrl, material, contextCounts } = input + const fromClause = fromVersion ? `from \`${fromVersion}\` to \`${version}\`` : `to \`${version}\`` + const prereleaseNote = prerelease + ? `\nThis is a prerelease (\`${version}\`) — state that in the summary and note APIs may change before stable.` + : '' + const contextNote = contextCounts && (contextCounts.fixes || contextCounts.improvements) + ? `\nThis release also contains ${contextCounts.fixes} bug fixes and ${contextCounts.improvements} improvements that need no code changes — mention them in one closing line, do NOT expand them into steps.` + : '' + + return `You are producing an EXECUTABLE UPGRADE RUNBOOK that a coding agent will follow to upgrade the npm package \`${packageName}\` ${fromClause}.${prereleaseNote}${contextNote} + +The reader is an agent that will apply each step to a real codebase. Optimise for execution: imperative voice, code-first, every step concrete and verifiable. No marketing, no narrative. + +BUCKETED CHANGES (breaking changes + code-affecting features, already extracted from the release notes): + +${material} + + +Output ONLY GitHub-flavoured Markdown, no preamble or closing commentary, no fence around the whole document. + +Structure: + +# Migrating ${packageName} to ${version} + +1. A 2-3 sentence summary: what this upgrade requires and who must act. +2. \`## Breaking changes\` — the mandatory work. Each as its own \`###\` subsection: a one-line statement of what changed, a \`diff\` or before/after block when the source shows the code, and a \`grep\`/find pattern to locate affected code. Omit only if there are genuinely none. +3. \`## New APIs to adopt\` — code-affecting features worth adopting (optional for the agent); one per bullet with a minimal example. Omit if none. +4. \`## Upgrade steps\` — a numbered checklist the agent runs top to bottom: start with \`npm i ${packageName}@${version}\`, then ONE step per breaking change listed above (find pattern → change to apply), then any required config edits. Derive steps ONLY from the Breaking changes section — do NOT add steps for APIs that aren't listed as breaking, and do NOT speculate about deprecations. New APIs from section 3 are optional adoptions, not upgrade steps. If a step would carry a caveat like "not deprecated in this release", omit it entirely. +5. \`## Verification\` — exact commands to confirm success (\`grep\` for removed APIs returning nothing, typecheck/build, relevant CLI checks). + +Rules: +- Ground every step in the bucketed changes above. Do NOT invent changes, APIs, method names, import paths, or signatures not present in the source. +- CODE FIDELITY (a wrong transformation is worse than none): only emit a \`diff\`/code block when the source shows real code or an exact API name; otherwise describe the change in prose naming the exact symbol and point to the release notes. NEVER emit a diff whose \`-\` and \`+\` lines match or whose \`+\` is a placeholder/comment. +- Keep it tight: cut anything the agent does not need to complete the upgrade.${repoUrl ? `\n- Link to ${repoUrl} for release notes where the new signature isn't shown.` : ''}` +}