Skip to content

Commit e2118aa

Browse files
committed
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.
1 parent 535970c commit e2118aa

9 files changed

Lines changed: 199 additions & 64 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ firstdraft --version
2020

2121
The package installs the `firstdraft` executable. Pin an exact compatible version, such as
2222
`@firstdraft.com/cli@0.1.0`, when a repeatable installation matters. Candidate publication under `next` is not stable
23-
release completion; see the [release policy](RELEASING.md) and [dated release history](docs/release-history.md).
23+
release completion and does not displace the supported `latest` release before promotion; see the
24+
[release policy](RELEASING.md) and [dated release history](docs/release-history.md).
2425

2526
## Shortest current journey
2627

@@ -61,7 +62,8 @@ contracts, and retained-Compilation operations.
6162
invoked API command.
6263
- API tokens are read from `FIRSTDRAFT_API_TOKEN`, sent as Bearer credentials, and never saved in `.firstdraft` or
6364
printed. Revoke an exposed token in First Draft.
64-
- Package contents are allowlisted and checked before release; repository-only documentation is not packaged.
65+
- Package contents are allowlisted and checked before release. The public documentation graph is packaged with the
66+
exact CLI version; agent instructions and source-only release metadata remain repository-only.
6567
- CI exercises the exact minimum Node.js version separately from current development tooling.
6668
- Public packages carry npm provenance linking registry bytes to the reviewed GitHub workflow and commit.
6769

‎RELEASING.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ move `latest`. Moving `latest` requires a later, separate approval after the exa
2424
its explicitly named release-specific qualification. Candidate publication is not stable release completion. A
2525
stable CLI release is complete only when that separately approved candidate is selected by npm's `latest` dist-tag.
2626
Release-specific qualification means the exact gate named for that candidate; it does not imply unrelated or full
27-
service qualification.
27+
service qualification. Until promotion, `latest` remains the supported stable release; a distinct `next` candidate
28+
is supported only for its named qualification. When both tags identify one version, that version fills both roles.
2829

2930
## Coordinated candidate eligibility
3031

‎SECURITY.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ sensitive details in a public Issue.
66

77
## Supported versions
88

9-
During coordinated trials, only the release currently identified by npm's approval-gated `next` tag receives
10-
security fixes. That channel is independent of version syntax. Before `1.0.0`, increasing the minor version starts a
9+
During coordinated trials, the stable release currently identified by npm's `latest` tag receives security fixes. A
10+
different version under the approval-gated `next` tag is supported only for its explicitly named release-specific
11+
qualification; it does not displace the stable release before separate promotion approval. When `next` and `latest`
12+
identify the same version, that release fills both roles.
13+
14+
Distribution channels are independent of version syntax. Before `1.0.0`, increasing the minor version starts a
1115
breaking compatibility line; increasing the patch version is otherwise backward-compatible within that line. All
1216
other older ordinary versions, historical prereleases, and unreleased source snapshots are not supported unless a
13-
separate support policy says otherwise. Historical prereleases are outside the ordinary version compatibility
14-
guarantee even while one is the current `next` release.
17+
separate support policy says otherwise. Historical prereleases remain outside the ordinary version compatibility
18+
guarantee.

‎docs/README.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,19 @@ evidence for implemented behavior; if they contradict a document, surface the co
2121
- [RELEASING.md](../RELEASING.md) owns living release policy and the operator runbook.
2222
- [release-history.md](release-history.md) preserves dated release observations. Recheck live tags, package versions,
2323
dist-tags, access, and trusted-publisher state before relying on them operationally.
24-
- [AGENTS.md](../AGENTS.md) routes agent work; it should stay compact rather than duplicate these documents.
24+
- The source repository's `AGENTS.md` routes agent work; it should stay compact rather than duplicate these documents.
25+
26+
## Retrieval quality
27+
28+
Start here, then load the one owning document for the task. Follow a cross-link only when the task crosses an
29+
authority boundary, such as moving from successful command behavior to failure recovery. Prefer descriptive
30+
headings, short paragraphs, command maps, and checklists; create another page only when it has a distinct audience,
31+
task, or authority.
32+
33+
The documentation tests keep `AGENTS.md` at or below 2 KiB, the root README at or below 6 KiB, and this map at or
34+
below 4 KiB. They also require every public topic to remain reachable from this map or the root README and verify
35+
repository-local links and fragments. The package check separately verifies that every relative link in the
36+
packaged Markdown resolves inside that exact package.
2537

2638
## Work on the repository
2739

‎package.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
},
1010
"files": [
1111
"bin",
12-
"src"
12+
"docs",
13+
"src",
14+
"RELEASING.md",
15+
"SECURITY.md"
1316
],
1417
"engines": {
1518
"node": ">=22.0.0"

‎scripts/check-pack.js‎

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import assert from "node:assert/strict";
22
import { spawnSync } from "node:child_process";
3+
import { readFileSync } from "node:fs";
4+
import path from "node:path";
5+
6+
import {
7+
isExternalTarget,
8+
markdownLinkTargets,
9+
} from "./markdown-documentation.js";
310

411
const npmCli = process.env.npm_execpath;
512
assert(npmCli, "npm_execpath is required; run this check through npm");
@@ -20,16 +27,16 @@ if (result.status !== 0) {
2027
assert(manifest, "npm pack did not return a manifest");
2128
const paths = manifest.files.map(({ path }) => path).sort();
2229

23-
assert.equal(
24-
paths.some((filePath) => filePath.startsWith("docs/")),
25-
false,
26-
"repository documentation must stay outside the npm tarball",
27-
);
28-
2930
assert.deepEqual(paths, [
3031
"LICENSE",
3132
"README.md",
33+
"RELEASING.md",
34+
"SECURITY.md",
3235
"bin/firstdraft.js",
36+
"docs/README.md",
37+
"docs/commands.md",
38+
"docs/errors.md",
39+
"docs/release-history.md",
3340
"package.json",
3441
"src/api-authentication.js",
3542
"src/api-response.js",
@@ -48,4 +55,31 @@ if (result.status !== 0) {
4855
"src/uuid-v7.js",
4956
"src/version.js",
5057
]);
58+
59+
const packagePaths = new Set(paths);
60+
61+
for (const markdownPath of paths.filter((filePath) =>
62+
filePath.endsWith(".md"),
63+
)) {
64+
const source = readFileSync(markdownPath, "utf8");
65+
66+
for (const target of markdownLinkTargets(source)) {
67+
if (isExternalTarget(target)) continue;
68+
69+
const [rawPath] = target.split("#", 1);
70+
if (rawPath === undefined || rawPath === "") continue;
71+
72+
const targetPath = path.posix.normalize(
73+
path.posix.join(
74+
path.posix.dirname(markdownPath),
75+
decodeURIComponent(rawPath),
76+
),
77+
);
78+
assert.equal(
79+
packagePaths.has(targetPath),
80+
true,
81+
`${markdownPath} links to unpackaged ${target}`,
82+
);
83+
}
84+
}
5185
}

‎scripts/markdown-documentation.js‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import assert from "node:assert/strict";
2+
3+
/** @param {string} source @returns {string[]} */
4+
export function markdownLinkTargets(source) {
5+
const targets = [];
6+
const linkPattern = /(?<!!)\[[^\]]+\]\(([^\s)]+)(?:\s+"[^"]*")?\)/g;
7+
8+
for (const match of withoutFencedCode(source).matchAll(linkPattern)) {
9+
const target = match[1];
10+
assert(target);
11+
targets.push(target.replace(/^<|>$/g, ""));
12+
}
13+
14+
return targets;
15+
}
16+
17+
/** @param {string} target */
18+
export function isExternalTarget(target) {
19+
return /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//");
20+
}
21+
22+
/** @param {string} source */
23+
export function withoutFencedCode(source) {
24+
/** @type {string | undefined} */
25+
let fence;
26+
27+
return source
28+
.split("\n")
29+
.filter((line) => {
30+
const match = /^ {0,3}(`{3,}|~{3,})/.exec(line);
31+
if (match) {
32+
const marker = match[1];
33+
assert(marker);
34+
35+
if (fence === undefined) {
36+
fence = marker[0];
37+
} else if (marker[0] === fence) {
38+
fence = undefined;
39+
}
40+
41+
return false;
42+
}
43+
44+
return fence === undefined;
45+
})
46+
.join("\n");
47+
}

‎test/documentation.test.js‎

Lines changed: 61 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import path from "node:path";
44
import test from "node:test";
55
import { fileURLToPath } from "node:url";
66

7+
import {
8+
isExternalTarget,
9+
markdownLinkTargets,
10+
withoutFencedCode,
11+
} from "../scripts/markdown-documentation.js";
12+
713
const repository = fileURLToPath(new URL("..", import.meta.url));
814
const markdownFiles = [
915
...["AGENTS.md", "README.md", "RELEASING.md", "SECURITY.md"].map((file) =>
@@ -59,6 +65,61 @@ test("documentation routes commands, recovery, and release knowledge", () => {
5965
);
6066
});
6167

68+
test("documentation entrypoints stay lean and route every public topic", () => {
69+
const entrypointBudgets = new Map([
70+
[path.join(repository, "AGENTS.md"), 2_048],
71+
[path.join(repository, "README.md"), 6_144],
72+
[path.join(repository, "docs/README.md"), 4_096],
73+
]);
74+
75+
for (const [file, budget] of entrypointBudgets) {
76+
const source = sources.get(file);
77+
assert(source);
78+
assert.ok(
79+
Buffer.byteLength(source) <= budget,
80+
`${path.relative(repository, file)} exceeds its ${budget}-byte retrieval budget`,
81+
);
82+
}
83+
84+
const publicTopics = new Set(
85+
markdownFiles.filter((file) => file !== path.join(repository, "AGENTS.md")),
86+
);
87+
const pending = [
88+
path.join(repository, "README.md"),
89+
path.join(repository, "docs/README.md"),
90+
];
91+
const reachable = new Set();
92+
93+
while (pending.length > 0) {
94+
const sourceFile = pending.pop();
95+
assert(sourceFile);
96+
if (reachable.has(sourceFile)) continue;
97+
reachable.add(sourceFile);
98+
99+
const source = sources.get(sourceFile);
100+
assert(source);
101+
for (const target of markdownLinkTargets(source)) {
102+
if (isExternalTarget(target)) continue;
103+
104+
const [rawPath] = target.split("#", 1);
105+
if (rawPath === undefined || rawPath === "") continue;
106+
const targetFile = path.resolve(
107+
path.dirname(sourceFile),
108+
decodeURIComponent(rawPath),
109+
);
110+
if (publicTopics.has(targetFile)) pending.push(targetFile);
111+
}
112+
}
113+
114+
for (const file of publicTopics) {
115+
assert.equal(
116+
reachable.has(file),
117+
true,
118+
`${path.relative(repository, file)} is not reachable from a documentation entrypoint`,
119+
);
120+
}
121+
});
122+
62123
test("local documentation links and fragments resolve", () => {
63124
for (const [sourceFile, source] of sources) {
64125
for (const target of markdownLinkTargets(source)) {
@@ -101,25 +162,6 @@ function findMarkdownFiles(directory) {
101162
.sort();
102163
}
103164

104-
/** @param {string} source @returns {string[]} */
105-
function markdownLinkTargets(source) {
106-
const targets = [];
107-
const linkPattern = /(?<!!)\[[^\]]+\]\(([^\s)]+)(?:\s+"[^"]*")?\)/g;
108-
109-
for (const match of withoutFencedCode(source).matchAll(linkPattern)) {
110-
const target = match[1];
111-
assert(target);
112-
targets.push(target.replace(/^<|>$/g, ""));
113-
}
114-
115-
return targets;
116-
}
117-
118-
/** @param {string} target */
119-
function isExternalTarget(target) {
120-
return /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//");
121-
}
122-
123165
/** @param {string} source @returns {Set<string>} */
124166
function markdownHeadingFragments(source) {
125167
const fragments = new Set();
@@ -145,30 +187,3 @@ function markdownHeadingFragments(source) {
145187

146188
return fragments;
147189
}
148-
149-
/** @param {string} source */
150-
function withoutFencedCode(source) {
151-
/** @type {string | undefined} */
152-
let fence;
153-
154-
return source
155-
.split("\n")
156-
.filter((line) => {
157-
const match = /^ {0,3}(`{3,}|~{3,})/.exec(line);
158-
if (match) {
159-
const marker = match[1];
160-
assert(marker);
161-
162-
if (fence === undefined) {
163-
fence = marker[0];
164-
} else if (marker[0] === fence) {
165-
fence = undefined;
166-
}
167-
168-
return false;
169-
}
170-
171-
return fence === undefined;
172-
})
173-
.join("\n");
174-
}

‎test/package.test.js‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ const releasingGuide = await readFile(
1717
new URL("../RELEASING.md", import.meta.url),
1818
"utf8",
1919
);
20+
const securityGuide = await readFile(
21+
new URL("../SECURITY.md", import.meta.url),
22+
"utf8",
23+
);
2024
const releaseHistory = await readFile(
2125
new URL("../docs/release-history.md", import.meta.url),
2226
"utf8",
@@ -64,8 +68,13 @@ test("package metadata preserves the audited runtime boundary", () => {
6468
assert.equal(metadata.type, "module");
6569
assert.equal(metadata.engines.node, ">=22.0.0");
6670
assert.deepEqual(metadata.bin, { firstdraft: "bin/firstdraft.js" });
67-
assert.deepEqual(metadata.files, ["bin", "src"]);
68-
assert.equal(metadata.files.includes("docs"), false);
71+
assert.deepEqual(metadata.files, [
72+
"bin",
73+
"docs",
74+
"src",
75+
"RELEASING.md",
76+
"SECURITY.md",
77+
]);
6978
assert.equal(metadata.scripts.test, "node scripts/run-tests.js");
7079

7180
for (const property of [
@@ -111,7 +120,15 @@ test("stable release completion requires qualified latest promotion", () => {
111120
);
112121
assert.match(
113122
readme,
114-
/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\)/,
123+
/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\)/,
124+
);
125+
assert.match(
126+
releasingGuide,
127+
/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\./,
128+
);
129+
assert.match(
130+
securityGuide,
131+
/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/,
115132
);
116133
assert.match(
117134
releaseHistory,

0 commit comments

Comments
 (0)