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,