From dc673230a39439c1f94e78e6746d99759cdefab5 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Thu, 13 Aug 2026 15:31:06 -0500 Subject: [PATCH] Package the CLI documentation graph README links pointed into docs that npm omitted, so installed users could not follow the documented command and recovery routes. Ship the public documentation graph with each CLI version and verify package-local links. Define latest as the supported stable release while a distinct next candidate remains limited to its named qualification. --- README.md | 6 +- RELEASING.md | 3 +- SECURITY.md | 12 ++-- docs/README.md | 14 +++- package.json | 5 +- scripts/check-pack.js | 46 +++++++++++-- scripts/markdown-documentation.js | 47 +++++++++++++ test/documentation.test.js | 107 +++++++++++++++++------------- test/package.test.js | 23 ++++++- 9 files changed, 199 insertions(+), 64 deletions(-) create mode 100644 scripts/markdown-documentation.js diff --git a/README.md b/README.md index 672b5ce..8d3e05c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ firstdraft --version The package installs the `firstdraft` executable. Pin an exact compatible version, such as `@firstdraft.com/cli@0.1.0`, when a repeatable installation matters. Candidate publication under `next` is not stable -release completion; see the [release policy](RELEASING.md) and [dated release history](docs/release-history.md). +release completion and does not displace the supported `latest` release before promotion; see the +[release policy](RELEASING.md) and [dated release history](docs/release-history.md). ## Shortest current journey @@ -61,7 +62,8 @@ contracts, and retained-Compilation operations. invoked API command. - API tokens are read from `FIRSTDRAFT_API_TOKEN`, sent as Bearer credentials, and never saved in `.firstdraft` or printed. Revoke an exposed token in First Draft. -- Package contents are allowlisted and checked before release; repository-only documentation is not packaged. +- Package contents are allowlisted and checked before release. The public documentation graph is packaged with the + exact CLI version; agent instructions and source-only release metadata remain repository-only. - CI exercises the exact minimum Node.js version separately from current development tooling. - Public packages carry npm provenance linking registry bytes to the reviewed GitHub workflow and commit. diff --git a/RELEASING.md b/RELEASING.md index a5637ec..ce1711a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -24,7 +24,8 @@ move `latest`. Moving `latest` requires a later, separate approval after the exa its explicitly named release-specific qualification. Candidate publication is not stable release completion. A stable CLI release is complete only when that separately approved candidate is selected by npm's `latest` dist-tag. Release-specific qualification means the exact gate named for that candidate; it does not imply unrelated or full -service qualification. +service qualification. Until promotion, `latest` remains the supported stable release; a distinct `next` candidate +is supported only for its named qualification. When both tags identify one version, that version fills both roles. ## Coordinated candidate eligibility diff --git a/SECURITY.md b/SECURITY.md index 0c132b2..d00c328 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,9 +6,13 @@ sensitive details in a public Issue. ## Supported versions -During coordinated trials, only the release currently identified by npm's approval-gated `next` tag receives -security fixes. That channel is independent of version syntax. Before `1.0.0`, increasing the minor version starts a +During coordinated trials, the stable release currently identified by npm's `latest` tag receives security fixes. A +different version under the approval-gated `next` tag is supported only for its explicitly named release-specific +qualification; it does not displace the stable release before separate promotion approval. When `next` and `latest` +identify the same version, that release fills both roles. + +Distribution channels are independent of version syntax. Before `1.0.0`, increasing the minor version starts a breaking compatibility line; increasing the patch version is otherwise backward-compatible within that line. All other older ordinary versions, historical prereleases, and unreleased source snapshots are not supported unless a -separate support policy says otherwise. Historical prereleases are outside the ordinary version compatibility -guarantee even while one is the current `next` release. +separate support policy says otherwise. Historical prereleases remain outside the ordinary version compatibility +guarantee. diff --git a/docs/README.md b/docs/README.md index eae81c2..d285c5f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,19 @@ evidence for implemented behavior; if they contradict a document, surface the co - [RELEASING.md](../RELEASING.md) owns living release policy and the operator runbook. - [release-history.md](release-history.md) preserves dated release observations. Recheck live tags, package versions, dist-tags, access, and trusted-publisher state before relying on them operationally. -- [AGENTS.md](../AGENTS.md) routes agent work; it should stay compact rather than duplicate these documents. +- The source repository's `AGENTS.md` routes agent work; it should stay compact rather than duplicate these documents. + +## Retrieval quality + +Start here, then load the one owning document for the task. Follow a cross-link only when the task crosses an +authority boundary, such as moving from successful command behavior to failure recovery. Prefer descriptive +headings, short paragraphs, command maps, and checklists; create another page only when it has a distinct audience, +task, or authority. + +The documentation tests keep `AGENTS.md` at or below 2 KiB, the root README at or below 6 KiB, and this map at or +below 4 KiB. They also require every public topic to remain reachable from this map or the root README and verify +repository-local links and fragments. The package check separately verifies that every relative link in the +packaged Markdown resolves inside that exact package. ## Work on the repository diff --git a/package.json b/package.json index 4e075b8..a2038c7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,10 @@ }, "files": [ "bin", - "src" + "docs", + "src", + "RELEASING.md", + "SECURITY.md" ], "engines": { "node": ">=22.0.0" diff --git a/scripts/check-pack.js b/scripts/check-pack.js index b49709d..0f9e4b9 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -1,5 +1,12 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { + isExternalTarget, + markdownLinkTargets, +} from "./markdown-documentation.js"; const npmCli = process.env.npm_execpath; assert(npmCli, "npm_execpath is required; run this check through npm"); @@ -20,16 +27,16 @@ if (result.status !== 0) { assert(manifest, "npm pack did not return a manifest"); const paths = manifest.files.map(({ path }) => path).sort(); - assert.equal( - paths.some((filePath) => filePath.startsWith("docs/")), - false, - "repository documentation must stay outside the npm tarball", - ); - assert.deepEqual(paths, [ "LICENSE", "README.md", + "RELEASING.md", + "SECURITY.md", "bin/firstdraft.js", + "docs/README.md", + "docs/commands.md", + "docs/errors.md", + "docs/release-history.md", "package.json", "src/api-authentication.js", "src/api-response.js", @@ -48,4 +55,31 @@ if (result.status !== 0) { "src/uuid-v7.js", "src/version.js", ]); + + const packagePaths = new Set(paths); + + for (const markdownPath of paths.filter((filePath) => + filePath.endsWith(".md"), + )) { + const source = readFileSync(markdownPath, "utf8"); + + for (const target of markdownLinkTargets(source)) { + if (isExternalTarget(target)) continue; + + const [rawPath] = target.split("#", 1); + if (rawPath === undefined || rawPath === "") continue; + + const targetPath = path.posix.normalize( + path.posix.join( + path.posix.dirname(markdownPath), + decodeURIComponent(rawPath), + ), + ); + assert.equal( + packagePaths.has(targetPath), + true, + `${markdownPath} links to unpackaged ${target}`, + ); + } + } } diff --git a/scripts/markdown-documentation.js b/scripts/markdown-documentation.js new file mode 100644 index 0000000..e96118a --- /dev/null +++ b/scripts/markdown-documentation.js @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; + +/** @param {string} source @returns {string[]} */ +export function markdownLinkTargets(source) { + const targets = []; + const linkPattern = /(?$/g, "")); + } + + return targets; +} + +/** @param {string} target */ +export function isExternalTarget(target) { + return /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//"); +} + +/** @param {string} source */ +export function withoutFencedCode(source) { + /** @type {string | undefined} */ + let fence; + + return source + .split("\n") + .filter((line) => { + const match = /^ {0,3}(`{3,}|~{3,})/.exec(line); + if (match) { + const marker = match[1]; + assert(marker); + + if (fence === undefined) { + fence = marker[0]; + } else if (marker[0] === fence) { + fence = undefined; + } + + return false; + } + + return fence === undefined; + }) + .join("\n"); +} diff --git a/test/documentation.test.js b/test/documentation.test.js index 15eeb62..6adf1b2 100644 --- a/test/documentation.test.js +++ b/test/documentation.test.js @@ -4,6 +4,12 @@ import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { + isExternalTarget, + markdownLinkTargets, + withoutFencedCode, +} from "../scripts/markdown-documentation.js"; + const repository = fileURLToPath(new URL("..", import.meta.url)); const markdownFiles = [ ...["AGENTS.md", "README.md", "RELEASING.md", "SECURITY.md"].map((file) => @@ -59,6 +65,61 @@ test("documentation routes commands, recovery, and release knowledge", () => { ); }); +test("documentation entrypoints stay lean and route every public topic", () => { + const entrypointBudgets = new Map([ + [path.join(repository, "AGENTS.md"), 2_048], + [path.join(repository, "README.md"), 6_144], + [path.join(repository, "docs/README.md"), 4_096], + ]); + + for (const [file, budget] of entrypointBudgets) { + const source = sources.get(file); + assert(source); + assert.ok( + Buffer.byteLength(source) <= budget, + `${path.relative(repository, file)} exceeds its ${budget}-byte retrieval budget`, + ); + } + + const publicTopics = new Set( + markdownFiles.filter((file) => file !== path.join(repository, "AGENTS.md")), + ); + const pending = [ + path.join(repository, "README.md"), + path.join(repository, "docs/README.md"), + ]; + const reachable = new Set(); + + while (pending.length > 0) { + const sourceFile = pending.pop(); + assert(sourceFile); + if (reachable.has(sourceFile)) continue; + reachable.add(sourceFile); + + const source = sources.get(sourceFile); + assert(source); + for (const target of markdownLinkTargets(source)) { + if (isExternalTarget(target)) continue; + + const [rawPath] = target.split("#", 1); + if (rawPath === undefined || rawPath === "") continue; + const targetFile = path.resolve( + path.dirname(sourceFile), + decodeURIComponent(rawPath), + ); + if (publicTopics.has(targetFile)) pending.push(targetFile); + } + } + + for (const file of publicTopics) { + assert.equal( + reachable.has(file), + true, + `${path.relative(repository, file)} is not reachable from a documentation entrypoint`, + ); + } +}); + test("local documentation links and fragments resolve", () => { for (const [sourceFile, source] of sources) { for (const target of markdownLinkTargets(source)) { @@ -101,25 +162,6 @@ function findMarkdownFiles(directory) { .sort(); } -/** @param {string} source @returns {string[]} */ -function markdownLinkTargets(source) { - const targets = []; - const linkPattern = /(?$/g, "")); - } - - return targets; -} - -/** @param {string} target */ -function isExternalTarget(target) { - return /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//"); -} - /** @param {string} source @returns {Set} */ function markdownHeadingFragments(source) { const fragments = new Set(); @@ -145,30 +187,3 @@ function markdownHeadingFragments(source) { return fragments; } - -/** @param {string} source */ -function withoutFencedCode(source) { - /** @type {string | undefined} */ - let fence; - - return source - .split("\n") - .filter((line) => { - const match = /^ {0,3}(`{3,}|~{3,})/.exec(line); - if (match) { - const marker = match[1]; - assert(marker); - - if (fence === undefined) { - fence = marker[0]; - } else if (marker[0] === fence) { - fence = undefined; - } - - return false; - } - - return fence === undefined; - }) - .join("\n"); -} diff --git a/test/package.test.js b/test/package.test.js index 4230ebd..127bdf2 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -17,6 +17,10 @@ const releasingGuide = await readFile( new URL("../RELEASING.md", import.meta.url), "utf8", ); +const securityGuide = await readFile( + new URL("../SECURITY.md", import.meta.url), + "utf8", +); const releaseHistory = await readFile( new URL("../docs/release-history.md", import.meta.url), "utf8", @@ -64,8 +68,13 @@ test("package metadata preserves the audited runtime boundary", () => { assert.equal(metadata.type, "module"); assert.equal(metadata.engines.node, ">=22.0.0"); assert.deepEqual(metadata.bin, { firstdraft: "bin/firstdraft.js" }); - assert.deepEqual(metadata.files, ["bin", "src"]); - assert.equal(metadata.files.includes("docs"), false); + assert.deepEqual(metadata.files, [ + "bin", + "docs", + "src", + "RELEASING.md", + "SECURITY.md", + ]); assert.equal(metadata.scripts.test, "node scripts/run-tests.js"); for (const property of [ @@ -111,7 +120,15 @@ test("stable release completion requires qualified latest promotion", () => { ); assert.match( readme, - /stable release selected by npm's `latest` dist-tag[\s\S]*?Candidate publication under `next` is not stable\s+release completion[\s\S]*?\[dated release history\]\(docs\/release-history\.md\)/, + /stable release selected by npm's `latest` dist-tag[\s\S]*?Candidate publication under `next` is not stable\s+release completion and does not displace the supported `latest` release before promotion[\s\S]*?\[dated release history\]\(docs\/release-history\.md\)/, + ); + assert.match( + releasingGuide, + /Until promotion, `latest` remains the supported stable release; a distinct `next` candidate\s+is supported only for its named qualification\. When both tags identify one version, that version fills both roles\./, + ); + assert.match( + securityGuide, + /stable release currently identified by npm's `latest` tag receives security fixes[\s\S]*?different version under the approval-gated `next` tag is supported only for its explicitly named release-specific[\s\S]*?does not displace the stable release before separate promotion approval[\s\S]*?When `next` and `latest`\s+identify the same version, that release fills both roles/, ); assert.match( releaseHistory,