From 23d3807ae113232d765fd4c8ab09b3d09be657fc Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Mon, 28 Sep 2026 19:12:16 +0800 Subject: [PATCH] docs: add upgrade guide structure, skill, and gate Introduce docs/upgrade-guide/v//guide.md for externally perceptible breaking changes. The dsh-create-upgrade-guide skill owns scope, placement, maintenance, and format; verify-upgrade-guides enforces layout, frontmatter, sections, and the 500-word ceiling. Guides are English-only and registered as the upgrade-guide document kind. --- .../skills/dsh-create-upgrade-guide/SKILL.md | 53 +++++++++ .agents/skills/dsh-doc/SKILL.md | 3 +- .../dsh-doc/references/metadata-links-i18n.md | 3 +- .../skills/dsh-doc/templates/upgrade-guide.md | 27 +++++ AGENTS.md | 2 + docs/AGENTS.md | 2 +- docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 1 + docs/i18n/README.zh.md | 1 + package.json | 1 + scripts/doc-budgets.manifest.json | 2 +- scripts/doc-standard.spec.ts | 1 + scripts/run-gates.ts | 1 + scripts/translation-pairing.manifest.json | 3 +- scripts/verify-upgrade-guides.spec.ts | 112 ++++++++++++++++++ scripts/verify-upgrade-guides.ts | 78 ++++++++++++ 16 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 .agents/skills/dsh-create-upgrade-guide/SKILL.md create mode 100644 .agents/skills/dsh-doc/templates/upgrade-guide.md create mode 100644 scripts/verify-upgrade-guides.spec.ts create mode 100644 scripts/verify-upgrade-guides.ts diff --git a/.agents/skills/dsh-create-upgrade-guide/SKILL.md b/.agents/skills/dsh-create-upgrade-guide/SKILL.md new file mode 100644 index 0000000000..cf3b46bfdd --- /dev/null +++ b/.agents/skills/dsh-create-upgrade-guide/SKILL.md @@ -0,0 +1,53 @@ +--- +name: dsh-create-upgrade-guide +description: Use when a deepseek-harness change breaks an externally perceptible surface (CLI, profiles, cordis.yml or settings keys, persisted user data, SDK or wire APIs, published package entry points), or when changing a surface that an unreleased upgrade guide already covers, to write or update docs/upgrade-guide/v//guide.md. +--- + +# DSH Create Upgrade Guide + +An upgrade guide tells a reader who runs release vT what to change to run the next release. Write it in the change that introduces the break, as soon as the break is recognized; do not defer it to release preparation. + +## Scope + +Write a guide only for breaks that someone outside the repository can observe after upgrading: + +- `dsh` commands, flags, and profile names; +- `cordis.yml`, patch, overlay, and settings keys or their accepted values; +- persisted user data: Session logs, SQLite stores, and user data directories; +- TypeScript and Python SDK exports, JSON-RPC, HTTP, and ACP messages; +- published package names and entry points. + +Pre-stable internal APIs whose consumers are all updated in the same change need no guide. A Session-format change still follows [type acknowledgements](../../../docs/cookbook/reviewing-persistence-type-changes.md); its guide states only the reader's actions and links that record. + +## Location + +`docs/upgrade-guide/v//guide.md` + +- `` is the `version` field of the root `package.json` when you write the guide, for example `v0.1.7-rc.2`. The guide describes the upgrade from that release to the next one. +- `` is a kebab-case name of the changed surface, for example `profile-flag-rename`. +- No other files belong in the tree: no index, no Chinese counterpart, no attachments. + +## Maintenance + +While the root version is unchanged, the current version directory describes the delta from vT to current `master`. When a later change touches a surface a guide there covers, update that guide in the same change; delete the guide when the break is reverted. After the version bump, guides in older version directories are frozen; a new break goes into the new version directory. Never write a guide spanning more than one release. + +## Format + +Start from the [upgrade-guide template](../dsh-doc/templates/upgrade-guide.md). `verify-upgrade-guides` enforces: + +- frontmatter with exactly `kind: upgrade-guide` and a one-sentence `description` naming what breaks; add a key only together with its gate check, then backfill every guide; +- one `#` title, then exactly the `## Change` and `## Migration` sections, in that order; +- at most 500 words, frontmatter included. + +`Change` states the old and new behavior of the surface and who is affected. `Migration` lists ordered steps that name exact files, keys, commands, or symbols, and states how to confirm the migration worked. + +When a guide needs more than 500 words, the extra text belongs elsewhere: link source files or symbols instead of restating code, move rationale into an [Agent Note](../../notes/README.md), and turn repeated mechanical steps into a script or `dsh` command that the guide invokes. + +Write English only, following [dsh-prose-standard](../dsh-prose-standard/SKILL.md). + +## Verify + +```sh +pnpm run verify-upgrade-guides +pnpm run verify-md-links +``` diff --git a/.agents/skills/dsh-doc/SKILL.md b/.agents/skills/dsh-doc/SKILL.md index 83eee5d526..0c97714672 100644 --- a/.agents/skills/dsh-doc/SKILL.md +++ b/.agents/skills/dsh-doc/SKILL.md @@ -57,6 +57,7 @@ The `kind` frontmatter field selects exactly one README template. Every kind in - `persistence-change` → [templates/persistence-change.md](templates/persistence-change.md): a dated record in `docs/persistence-changes/` — acknowledge a detected type transition with per-root predecessors and generated after schemas. - `persistence-release` → [templates/persistence-release.md](templates/persistence-release.md): a pinned tag in `docs/persistence-changes/releases/` — compare reconstructed historical types without claiming compatibility acknowledgement. - `persistence-format` → [templates/persistence-format.md](templates/persistence-format.md): a historical Session format checkpoint in `docs/persistence-changes/historical-formats/`, with complete schemas; the current writer uses the existing catalog. +- `upgrade-guide` → [templates/upgrade-guide.md](templates/upgrade-guide.md): an English-only `docs/upgrade-guide/v//guide.md` — migration steps for one externally perceptible break, owned by [dsh-create-upgrade-guide](../dsh-create-upgrade-guide/SKILL.md). Open the template before writing and follow its skeleton and rules; it states what the kind is, how the page is structured, and the fact checks each section owes. Add a new kind only together with a distinct template file, a documented repository position or declared owner, and a focused check that maps documents to it. @@ -67,7 +68,7 @@ These rules decide what a section may say. They apply to every authored human-fa - **Summary says what the subject does.** The opening `Summary` and the user-facing sections describe what a user or agent can DO with the subject — outcomes, benefits, when to choose it, main cost — never its role, type, or internal identity. In a package Summary, “what it is” means only its reader-visible capability, not its Cordis role, registrations, or internal components. Omit source identifiers unless the reader directly uses them in configuration, a command, or a public API. "The seam registers `ctx.x` and appends `x/event` records" is identity narration; "you can save a note per message and it survives restarts" is what it does. - **Developer sections explain, never enumerate.** Folded implementation content covers the overall design concept, architecture, and hand-waving dataflow — enough to understand how the package works — and links code for exact detail. No full API catalogs, exhaustive column lists, event-payload enumerations, or JSDoc restatement inside the folds. - **Dev Note is the only slop zone.** Partial ideas, scratches, undecided directions, measured artifacts, and working hypotheses live only in the final Dev Note, marked explicitly non-authoritative. Every other section is polished, current-state prose. -- **Current state only.** Ordinary documentation describes the current codebase. Dedicated `persistence-change`, `persistence-release`, and `persistence-format` records retain historical type evidence; release comparisons and format checkpoints describe observed history without asserting compatibility. Agent Notes and postmortems retain their own historical scope. +- **Current state only.** Ordinary documentation describes the current codebase. Dedicated `persistence-change`, `persistence-release`, and `persistence-format` records retain historical type evidence; release comparisons and format checkpoints describe observed history without asserting compatibility. An `upgrade-guide` describes the change from its version directory's release to the next one. Agent Notes and postmortems retain their own historical scope. - **Use controlled technical English.** Give each sentence an explicit actor and one main action when ambiguity can change behavior. Reuse one term per concept, prefer direct verbs, split stacked instructions and conditions, and preserve modality and exceptions. Apply the non-certified, ASD-STE100-inspired discipline in [the page-style reference](references/style.md#controlled-technical-english). Do not force a shorter sentence when precision would fall. ## Quality criteria diff --git a/.agents/skills/dsh-doc/references/metadata-links-i18n.md b/.agents/skills/dsh-doc/references/metadata-links-i18n.md index 19ee5dde40..34e4cc55bb 100644 --- a/.agents/skills/dsh-doc/references/metadata-links-i18n.md +++ b/.agents/skills/dsh-doc/references/metadata-links-i18n.md @@ -29,7 +29,7 @@ kind: "package-reference" ## The kind system -`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Files named `docs/persistence-changes/historical-formats/vN.md` use `persistence-format`. Their Chinese siblings use the same kind. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order: +`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Files named `docs/persistence-changes/historical-formats/vN.md` use `persistence-format`. Their Chinese siblings use the same kind. Files named `docs/upgrade-guide/v//guide.md` use `upgrade-guide` and have no Chinese sibling. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order: 1. The README is `packages/README.md` or `packages//README.md` → `package-group`. 2. The package manifest declares `dsh.bundle.patch` → `package-bundle`. @@ -45,6 +45,7 @@ kind: "package-reference" | `persistence-change` | `docs/persistence-changes/YYYY-MM-DD-slug.md` and its Chinese sibling | [persistence-change.md](../templates/persistence-change.md) | Historical acknowledgement: mechanically detected type changes, per-root predecessor references, compatibility decision, and generated after schemas. | | `persistence-release` | `docs/persistence-changes/releases/dsh-v*.md` and its Chinese sibling | [persistence-release.md](../templates/persistence-release.md) | Retrospective observation: pinned tag, adjacent before/after digests, actual version constants, and changed after schemas; no compatibility acknowledgement. | | `persistence-format` | `docs/persistence-changes/historical-formats/vN.md` and its Chinese sibling | [persistence-format.md](../templates/persistence-format.md) | Selected historical format: source evidence, complete root schemas and reachable types; the current writer uses the generated catalog. | +| `upgrade-guide` | `docs/upgrade-guide/v//guide.md`, English only | [upgrade-guide.md](../templates/upgrade-guide.md) | Upgrade path: one externally perceptible break from the directory's release to the next, with `Change` and `Migration` sections in at most 500 words. | Before assigning `package-library` or `package-bundle`, inspect the facts: read `package.json` for `dsh.bundle.patch` and `src/index.ts` for the entry shape (`apply` export or a default service export is a plugin; a plain module API is a library). `dsh plugin --profile add ` installs any npm dependency, but the profile reconcile activates a layer only for a package that declares `dsh.bundle`; never present that command as an install path for a library or a plain plugin. The documentation check derives the expected kind from these same facts, rejects another value, and rejects `name`, `audience`, `tags`, and README-local `i18n` metadata. Add a new kind only with a distinct template, an unambiguous repository position or declared owner, and a focused check that maps documents to it. diff --git a/.agents/skills/dsh-doc/templates/upgrade-guide.md b/.agents/skills/dsh-doc/templates/upgrade-guide.md new file mode 100644 index 0000000000..c5308115d3 --- /dev/null +++ b/.agents/skills/dsh-doc/templates/upgrade-guide.md @@ -0,0 +1,27 @@ +# Template: upgrade-guide + +Use this kind for `docs/upgrade-guide/v//guide.md`. The guides are English-only and have no folder index. [dsh-create-upgrade-guide](../../dsh-create-upgrade-guide/SKILL.md) owns scope, placement, maintenance, and length rules; `pnpm run verify-upgrade-guides` enforces them. + +## Frontmatter + +```yaml +--- +kind: upgrade-guide +description: "The externally perceptible surface that breaks and what replaces it." +--- +``` + +## Skeleton + +````markdown +# + +## Change + +State the old and new behavior of the surface, who observes it, and the pull request that introduced it. + +## Migration + +1. Name the exact file, key, command, or symbol to change, with a before/after snippet when it is shorter than prose. +2. State how to confirm the migration worked. +```` diff --git a/AGENTS.md b/AGENTS.md index 37cfd8d280..fc94452a0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,8 @@ Public APIs are pre-stable; update every consumer. Follow [version/status](docs/ Acknowledge [declared persistence-type changes](docs/cookbook/reviewing-persistence-type-changes.md). +Record each externally perceptible breaking change immediately in an [upgrade guide](.agents/skills/dsh-create-upgrade-guide/SKILL.md). + **Application launch.** Only `dsh` profiles launch supported Node apps; package bins, demos, and public SDK argv escapes are forbidden ([rule](docs/architecture.md#application-launch)). ## Repository layout diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 56485c7234..76de4d3632 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -55,7 +55,7 @@ When the gate goes red: 2. **Condense** content that belongs here but can be shorter. 3. **Raise** the ceiling only when the words need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug. -Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 1,950; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 750 and this file ≤ 1,320; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `defensive-patterns.md` 550, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers. +Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 1,960; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 750 and this file ≤ 1,320; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `defensive-patterns.md` 550, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers. ## The slop checklist diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 4c794f49bd..ddb3521a91 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -12,8 +12,8 @@ en: 08386e96eebf06d9 zh: c6d2b41199b0c466 /bilingual-documentation/scope-and-exclusions: - en: db70e758813d339b - zh: 3dcea283a08c47cb + en: 093bd091c9d46eda + zh: ab6cd07f94c740e4 /bilingual-documentation/division-of-labor: en: 88097c97cb72f3ba zh: 78a18000626cc818 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 4c23fac67e..ca970d6b2d 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -60,6 +60,7 @@ Generated English sources omit the language switcher that ordinary authored sour - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction. - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior. - [review-ownership/README.md](../../.github/review-ownership/README.md) — repository-internal approval policy maintained in English only. +- `docs/upgrade-guide/` — agent-facing upgrade guides maintained in English only. - `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them. **Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index a905a0bdae..edc6f972fc 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -62,6 +62,7 @@ - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 - [review-ownership/README.md](../../.github/review-ownership/README.md):仓库内部审批策略,只以英文维护。 +- `docs/upgrade-guide/`:面向 agent 的升级指南,只以英文维护。 - `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。 **统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。 diff --git a/package.json b/package.json index 4317f7dc94..e254b86997 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "verify-archived-agent-notes": "tsx scripts/verify-archived-agent-notes.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "verify-skill-invocation-metadata": "tsx scripts/verify-skill-invocation-metadata.ts", + "verify-upgrade-guides": "tsx scripts/verify-upgrade-guides.ts", "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "test:docs": "tsx scripts/run-gates.ts doc-quick", diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index d06682d7fd..2eba3f4c17 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1950, + "AGENTS.md": 1960, "docs/AGENTS.md": 1320, "docs/architecture.md": 2410, "docs/cordis-primer.md": 600, diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index e8ed611e60..84dd6e1515 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -45,6 +45,7 @@ const KIND_TEMPLATES: Readonly> = { 'persistence-change': '.agents/skills/dsh-doc/templates/persistence-change.md', 'persistence-release': '.agents/skills/dsh-doc/templates/persistence-release.md', 'persistence-format': '.agents/skills/dsh-doc/templates/persistence-format.md', + 'upgrade-guide': '.agents/skills/dsh-doc/templates/upgrade-guide.md', } /** diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index e1e2de9c21..ade1057e24 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -820,6 +820,7 @@ function docSyncLeafGates(options: { pnpmScript('skill-invocation-metadata', 'verify-skill-invocation-metadata', { label: 'skill invocation metadata', quick: true }), pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt', quick: true }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets', quick: true }), + pnpmScript('upgrade-guides', 'verify-upgrade-guides', { label: 'upgrade guides', quick: true }), pnpmExec('doc-standard-tests', ['vitest', 'run', 'scripts/doc-standard.spec.ts'], { label: 'documentation standard tests', quick: true, diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index f9bb1549a2..fe517aa995 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -8,6 +8,7 @@ "docs/cordis-api/inherited.md", "docs/i18n/style-samples.md", "docs/i18n/terminology.md", - "docs/i18n/translation-prompt.md" + "docs/i18n/translation-prompt.md", + "docs/upgrade-guide/" ] } diff --git a/scripts/verify-upgrade-guides.spec.ts b/scripts/verify-upgrade-guides.spec.ts new file mode 100644 index 0000000000..07b1e516aa --- /dev/null +++ b/scripts/verify-upgrade-guides.spec.ts @@ -0,0 +1,112 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectUpgradeGuideViolations } from './verify-upgrade-guides.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +const VALID = [ + '---', + 'kind: upgrade-guide', + 'description: "The `--preset` flag is removed."', + '---', + '', + '# `--profile` replaces `--preset`', + '', + '## Change', + '', + 'Old and new behavior.', + '', + '### Detail', + '', + '```md', + '# not a heading', + '```', + '', + '## Migration', + '', + '1. Rename the flag.', + '', +].join('\n') + +function fixtureRoot(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-upgrade-guides-')) + roots.push(root) + for (const [file, source] of Object.entries(files)) { + const path = join(root, 'docs/upgrade-guide', file) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, source) + } + return root +} + +describe('upgrade guide gate', () => { + it('accepts an absent tree and conforming guides, including prerelease versions and CRLF files', () => { + expect(collectUpgradeGuideViolations(fixtureRoot({}))).toEqual([]) + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'v0.1.7-rc.2/profile-flag/guide.md': VALID, + 'v1.0.0/crlf-item/guide.md': VALID.replaceAll('\n', '\r\n'), + }))).toEqual([]) + }) + + it('rejects files outside the version/item/guide.md layout', () => { + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'README.md': VALID, + '0.1.7/item/guide.md': VALID, + 'v0.1/item/guide.md': VALID, + 'v0.1.7/Item_Name/guide.md': VALID, + 'v0.1.7/item/guide.zh.md': VALID, + 'v0.1.7/item/extra/guide.md': VALID, + }))).toEqual([ + 'docs/upgrade-guide/0.1.7/item/guide.md', + 'docs/upgrade-guide/README.md', + 'docs/upgrade-guide/v0.1.7/Item_Name/guide.md', + 'docs/upgrade-guide/v0.1.7/item/extra/guide.md', + 'docs/upgrade-guide/v0.1.7/item/guide.zh.md', + 'docs/upgrade-guide/v0.1/item/guide.md', + ].map(path => `${path}: only v//guide.md files belong in docs/upgrade-guide`)) + }) + + it('rejects missing, extra, or invalid frontmatter fields', () => { + const body = VALID.slice(VALID.indexOf('# ')) + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'v0.1.7/none/guide.md': body, + 'v0.1.7/list/guide.md': `---\n- a\n---\n${body}`, + 'v0.1.7/fields/guide.md': `---\nkind: persistence-change\ndescription: ""\nversion: v0.1.7\n---\n${body}`, + }))).toEqual([ + 'docs/upgrade-guide/v0.1.7/fields/guide.md: frontmatter keys must be exactly description, kind; got description, kind, version', + 'docs/upgrade-guide/v0.1.7/fields/guide.md: kind must be upgrade-guide', + 'docs/upgrade-guide/v0.1.7/fields/guide.md: description must be a non-empty string', + 'docs/upgrade-guide/v0.1.7/list/guide.md: frontmatter must be a YAML object', + 'docs/upgrade-guide/v0.1.7/none/guide.md: must start with YAML frontmatter', + ]) + }) + + it('rejects a missing title and missing, renamed, extra, or reordered sections', () => { + const cases = { + 'no-title': VALID.replace('# `--profile` replaces `--preset`\n', ''), + 'two-titles': VALID.replace('## Migration', '# Migration'), + 'renamed': VALID.replace('## Change', '## Changes'), + 'extra': `${VALID}\n## References\n`, + 'reordered': VALID.replace('## Change', '## Placeholder').replace('## Migration', '## Change').replace('## Placeholder', '## Migration'), + } + const violations = collectUpgradeGuideViolations(fixtureRoot(Object.fromEntries( + Object.entries(cases).map(([item, source]) => [`v0.1.7/${item}/guide.md`, source]), + ))) + expect(violations.map(violation => violation.split('/')[3])).toEqual(['extra', 'no-title', 'renamed', 'reordered', 'two-titles']) + expect(violations[0]).toBe('docs/upgrade-guide/v0.1.7/extra/guide.md: headings must be one "#" title followed by "## Change", "## Migration"; got "# `--profile` replaces `--preset`", "## Change", "## Migration", "## References"') + }) + + it('rejects a guide over 500 words, counting frontmatter', () => { + const words = VALID.split(/\s+/u).filter(Boolean).length + expect(collectUpgradeGuideViolations(fixtureRoot({ + 'v0.1.7/at-limit/guide.md': `${VALID}${'word '.repeat(500 - words)}\n`, + 'v0.1.7/over-limit/guide.md': `${VALID}${'word '.repeat(501 - words)}\n`, + }))).toEqual(['docs/upgrade-guide/v0.1.7/over-limit/guide.md: 501 words exceeds the 500-word ceiling']) + }) +}) diff --git a/scripts/verify-upgrade-guides.ts b/scripts/verify-upgrade-guides.ts new file mode 100644 index 0000000000..9a776013af --- /dev/null +++ b/scripts/verify-upgrade-guides.ts @@ -0,0 +1,78 @@ +/** + * Enforce the `docs/upgrade-guide/v//guide.md` layout, metadata, + * sections, and word ceiling defined by the `dsh-create-upgrade-guide` skill. + * @module scripts/verify-upgrade-guides + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { JSON_SCHEMA, load } from 'js-yaml' +import { fromMarkdown } from 'mdast-util-from-markdown' + +const ROOT = resolve(import.meta.dirname, '..') +const GUIDE_ROOT = 'docs/upgrade-guide' +const MAX_WORDS = 500 +const FRONTMATTER_KEYS = ['description', 'kind'] +const SECTIONS = ['Change', 'Migration'] +const SEMVER = String.raw`(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?` +const GUIDE_PATH = new RegExp(`^v${SEMVER}/[a-z0-9]+(?:-[a-z0-9]+)*/guide\\.md$`, 'u') + +/** Return one guide's frontmatter and structure violations. */ +function guideViolations(source: string): string[] { + const violations: string[] = [] + const words = source.split(/\s+/u).filter(Boolean).length + if (words > MAX_WORDS) violations.push(`${String(words)} words exceeds the ${String(MAX_WORDS)}-word ceiling`) + + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/u.exec(source) + if (frontmatter === null) return [...violations, 'must start with YAML frontmatter'] + const metadata = load(frontmatter[1] as string, { schema: JSON_SCHEMA }) + if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) return [...violations, 'frontmatter must be a YAML object'] + const fields = metadata as Record + const keys = Object.keys(fields).sort() + if (keys.join() !== FRONTMATTER_KEYS.join()) violations.push(`frontmatter keys must be exactly ${FRONTMATTER_KEYS.join(', ')}; got ${keys.join(', ') || 'none'}`) + if (fields.kind !== 'upgrade-guide') violations.push('kind must be upgrade-guide') + if (typeof fields.description !== 'string' || fields.description.trim() === '') violations.push('description must be a non-empty string') + + const body = source.slice(frontmatter[0].length) + const outline = fromMarkdown(body).children + .flatMap(node => node.type === 'heading' && node.depth <= 2 ? [body.slice(node.position?.start.offset, node.position?.end.offset)] : []) + const expected = SECTIONS.map(section => `## ${section}`) + if (outline.length !== expected.length + 1 || !outline[0]?.startsWith('# ') || outline.slice(1).join('\n') !== expected.join('\n')) { + violations.push(`headings must be one "#" title followed by ${expected.map(heading => `"${heading}"`).join(', ')}; got ${outline.map(heading => `"${heading}"`).join(', ') || 'none'}`) + } + return violations +} + +/** + * Report upgrade-guide tree violations. + * @param root - Repository root containing `docs/upgrade-guide`. + * @returns diagnostics prefixed by the repository-relative file path; empty when the tree is absent. + */ +export function collectUpgradeGuideViolations(root: string): string[] { + const directory = join(root, GUIDE_ROOT) + if (!existsSync(directory)) return [] + const violations: string[] = [] + const files = readdirSync(directory, { recursive: true, withFileTypes: true }) + .filter(entry => !entry.isDirectory()) + .map(entry => join(entry.parentPath, entry.name).slice(directory.length + 1).replaceAll('\\', '/')) + .sort() + for (const file of files) { + const path = `${GUIDE_ROOT}/${file}` + if (!GUIDE_PATH.test(file)) { + violations.push(`${path}: only v//guide.md files belong in ${GUIDE_ROOT}`) + continue + } + for (const violation of guideViolations(readFileSync(join(directory, file), 'utf8'))) violations.push(`${path}: ${violation}`) + } + return violations +} + +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + const violations = collectUpgradeGuideViolations(ROOT) + if (violations.length > 0) { + process.stderr.write('verify-upgrade-guides: violations found (see .agents/skills/dsh-create-upgrade-guide/SKILL.md):\n') + for (const violation of violations) process.stderr.write(` ${violation}\n`) + process.exit(1) + } + process.stdout.write('verify-upgrade-guides: upgrade guides conform.\n') +}