Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 8 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 13 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
},
"files": [
"bin",
"src"
"docs",
"src",
"RELEASING.md",
"SECURITY.md"
],
"engines": {
"node": ">=22.0.0"
Expand Down
46 changes: 40 additions & 6 deletions scripts/check-pack.js
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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",
Expand All @@ -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}`,
);
}
}
}
47 changes: 47 additions & 0 deletions scripts/markdown-documentation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";

/** @param {string} source @returns {string[]} */
export function markdownLinkTargets(source) {
const targets = [];
const linkPattern = /(?<!!)\[[^\]]+\]\(([^\s)]+)(?:\s+"[^"]*")?\)/g;

for (const match of withoutFencedCode(source).matchAll(linkPattern)) {
const target = match[1];
assert(target);
targets.push(target.replace(/^<|>$/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");
}
107 changes: 61 additions & 46 deletions test/documentation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -101,25 +162,6 @@ function findMarkdownFiles(directory) {
.sort();
}

/** @param {string} source @returns {string[]} */
function markdownLinkTargets(source) {
const targets = [];
const linkPattern = /(?<!!)\[[^\]]+\]\(([^\s)]+)(?:\s+"[^"]*")?\)/g;

for (const match of withoutFencedCode(source).matchAll(linkPattern)) {
const target = match[1];
assert(target);
targets.push(target.replace(/^<|>$/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<string>} */
function markdownHeadingFragments(source) {
const fragments = new Set();
Expand All @@ -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");
}
23 changes: 20 additions & 3 deletions test/package.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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,
Expand Down