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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@
- 'name': 'Requirements ledger'
'run': 'npm run lint:requirements'

# README.md and CONTRIBUTING.md, held to the repository they describe:
# every named script exists, every backtick-quoted path exists, every
# quoted coverage number matches vitest.config.ts, every quoted Node
# version matches .nvmrc. The link contract above proves a link and its
# anchor resolve; this proves the prose around them is still true.
- 'name': 'Docs contract'
'run': 'npm run lint:docs'

'tests':
'name': 'Tests'
'runs-on': 'ubuntu-latest'
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,15 @@ business. The parked direction work is in
## Local checks

```bash
nvm use # the exact Node version in .nvmrc
nvm use # Node 24.21.0, pinned in .nvmrc
npm ci
npm run verify # lint, format, typecheck, ADR contract, tests + coverage
```

`npm run lint:adrs` alone runs the decision-record contract, and `npm test`
runs the suite without the coverage thresholds.
runs the suite without enforcing coverage. `npm run test:coverage` (part of
`npm run verify`) enforces the ratchet in `vitest.config.ts`: statements
97.71%, branches 89.78%, functions 100%, lines 97.51%.

## Conventions

Expand Down
8 changes: 5 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ clean tree is untested: nothing proves it would fail.

## Gates

Nine gates hold the structure, and each exists because its absence has already
Thirteen gates hold the structure, and each exists because its absence has already
cost something in the generation this compiler replaces. Each runs as its own
CI job, aggregated by one required check that fails when any gate job fails,
is cancelled, or is skipped
Expand All @@ -207,13 +207,15 @@ proves the two never drift apart.
| links | `npm run lint:links` | relative links and heading anchors across every tracked Markdown file |
| manifests | `npm run lint:manifests` | every rendered example object against pinned Kubernetes and CRD schemas |
| requirements | `npm run lint:requirements` | a behaviour ledger row that no longer parses, names a missing or empty test, drifts from its stated count, or is cited by an id no row carries |
| docs | `npm run lint:docs` | a script, path, coverage number or Node version README.md or CONTRIBUTING.md name that no longer matches the repository |
| tests | `npm run test:coverage` | behaviour, plus the coverage ratchet |
| package contents | `node scripts/check-package-contents.ts` | `npm pack` shipping a file outside `docs/adr/` and `spec/`, the boundary the package's `files` field states but does not enforce on its own |
| actionlint | a pinned `actionlint` binary | invalid workflow syntax, an undefined `${{ }}` expression, a shellcheck finding inside a `run:` step |
| secret scan | a pinned `gitleaks` binary | a committed secret matching the default ruleset, or this repository's own allowlist entries |

Decisions, links, manifests and requirements share one CI job, `contracts`:
all four check a document against a rule rather than code against a graph.
Decisions, links, manifests, requirements and docs share one CI job,
`contracts`: all five check a document against a rule rather than code
against a graph.
Boundaries runs alone as `architecture`, because it is the one gate that
speaks for `docs/architecture.md` itself rather than for a document beside it.

Expand Down
3 changes: 2 additions & 1 deletion docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ test file and holds at least one test; ids are unique; the count this
document states matches the number of rows it holds; and every id cited
anywhere in the tracked tree resolves to a row here.

This ledger holds **10** rows. The compiler's behaviours join it as they land.
This ledger holds **11** rows. The compiler's behaviours join it as they land.

| id | a contributor or a consumer can rely on | proved by |
|---|---|---|
Expand All @@ -34,3 +34,4 @@ This ledger holds **10** rows. The compiler's behaviours join it as they land.
| REQ-008 | Coverage is a ratchet: no `v8`, `c8` or `istanbul` ignore comment exempts a line from it | [test/harness.test.ts](../test/harness.test.ts) |
| REQ-009 | The npm package ships nothing outside `docs/adr/` and `spec/`, checked against what npm would really pack rather than the advisory `files` field | [test/package-contents-contract.test.ts](../test/package-contents-contract.test.ts) |
| REQ-010 | A gate's npm script and the CI job that runs it land in the same pull request, so neither can drift from the other unnoticed | [test/pipeline-wiring.test.ts](../test/pipeline-wiring.test.ts) |
| REQ-011 | Every script, path, coverage number and Node version README.md and CONTRIBUTING.md name matches the repository they describe | [test/docs-contract.test.ts](../test/docs-contract.test.ts) |
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@
"lint:manifests": "node scripts/lint-manifests.ts",
"lint:boundaries": "node scripts/lint-boundaries.ts",
"lint:requirements": "node scripts/lint-requirements.ts",
"lint:docs": "node scripts/lint-docs.ts",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"verify": "npm run lint && npm run format:check && npm run typecheck && npm run lint:adrs && npm run lint:links && npm run lint:manifests && npm run lint:requirements && npm run lint:boundaries && npm run test:coverage"
"verify": "npm run lint && npm run format:check && npm run typecheck && npm run lint:adrs && npm run lint:links && npm run lint:manifests && npm run lint:requirements && npm run lint:docs && npm run lint:boundaries && npm run test:coverage"
},
"devDependencies": {
"@eslint/js": "10.0.1",
Expand Down
230 changes: 230 additions & 0 deletions scripts/lint-docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
// Docs contract: holds README.md and CONTRIBUTING.md to the repository they
// describe. Prose goes stale silently, and each of the four claims below
// reads exactly as well wrong as right: a renamed script, a coverage
// threshold that moved, a pinned Node version that changed, a path that no
// longer exists.
//
// The link lint (lint-links.ts) already proves that a Markdown link and its
// heading anchor resolve. This gate covers what that one cannot see: names,
// numbers and versions, most of which are never inside a link at all.
//
// A library first: tests call lintDocs() in-process against fixture trees,
// and `node scripts/lint-docs.ts [root]` is the command.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { isEntrypoint } from "./lib/entrypoint.ts";
import { processOutput, type GateOutput } from "./lib/output.ts";

const REPOSITORY = join(import.meta.dirname, "..");

// The documents this gate holds to the repository. Any other file that
// quotes a script, a number or a path is somebody else's problem.
const DOCUMENTS = ["README.md", "CONTRIBUTING.md"] as const;

const COVERAGE_METRICS = [
"statements",
"branches",
"functions",
"lines",
] as const;
type CoverageMetric = (typeof COVERAGE_METRICS)[number];

export type ClaimKind = "script" | "number" | "version" | "path";

/** How many claims of each kind the documents made, across both files. */
export type ClaimCounts = Readonly<Record<ClaimKind, number>>;

export interface DocsLintResult {
readonly claims: number;
readonly byKind: ClaimCounts;
readonly errors: readonly string[];
}

/**
* The text a regex's first capture group matched. TypeScript types every
* group as possibly absent regardless of quantifier, but a `+`-quantified
* group that `matchAll` actually yielded a match for cannot itself be empty:
* the whole match would not exist otherwise. The cast says so once, rather
* than `?? ""` guessing at a case none of these regexes can reach.
*/
function requiredGroup(match: RegExpMatchArray): string {
return match[1] as string;
}

/** Every `npm run <script>` and bare `npm test` a document names, deduplicated. */
export function scriptClaims(text: string): readonly string[] {
const found = new Set<string>();
for (const match of text.matchAll(/npm run ([a-zA-Z0-9:_-]+)/g))
found.add(requiredGroup(match));
if (/\bnpm test\b/.test(text)) found.add("test");
return [...found];
}

/**
* Whether a backtick-quoted span reads as a file or directory reference
* worth checking, rather than a command, a YAML field, or a bare word like a
* package name. It must be a single token of path characters, and it must
* carry a `/` or a `.`: `deploy-config-schema` is a repository name, not a
* path, and this is what tells the two apart without a hand-kept list.
*/
export function isPathLike(candidate: string): boolean {
if (!/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*\/?$/.test(candidate))
return false;
return candidate.includes("/") || candidate.includes(".");
}

/**
* Every backtick-quoted path a document names, deduplicated. A fenced code
* block is stripped first: its triple backticks are not a pair, and reading
* them as one would pair each one with a wrong partner for the rest of the
* file, the same hazard lint-links.ts strips prose for before it reads links.
*/
export function pathClaims(text: string): readonly string[] {
const prose = text.replace(/^```[\s\S]*?^```/gm, "");
const found = new Set<string>();
for (const match of prose.matchAll(/`([^`]+)`/g)) {
const candidate = requiredGroup(match);
if (isPathLike(candidate)) found.add(candidate);
}
return [...found];
}

/** Every coverage percentage a document quotes, one entry per metric named. */
export function coverageClaims(
text: string,
): readonly { readonly metric: CoverageMetric; readonly value: number }[] {
const claims: { metric: CoverageMetric; value: number }[] = [];
for (const metric of COVERAGE_METRICS) {
const match = new RegExp(`\\b${metric}\\s+([\\d.]+)%`, "i").exec(text);
const value = match?.[1];
if (value !== undefined) claims.push({ metric, value: Number(value) });
}
return claims;
}

/** Every `Node <semver>` a document names, deduplicated. */
export function nodeVersionClaims(text: string): readonly string[] {
const found = new Set<string>();
for (const match of text.matchAll(/\bNode\s+v?(\d+\.\d+\.\d+)\b/g))
found.add(requiredGroup(match));
return [...found];
}

function packageScripts(root: string): Readonly<Record<string, string>> {
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
scripts?: Record<string, string>;
};
return pkg.scripts ?? {};
}

/** The coverage ratchet in `vitest.config.ts`, parsed from its source text. */
function coverageThresholds(
root: string,
): Readonly<Partial<Record<CoverageMetric, number>>> {
const text = readFileSync(join(root, "vitest.config.ts"), "utf8");
const block = /thresholds:\s*{([^}]*)}/s.exec(text)?.[1] ?? "";
const thresholds: Partial<Record<CoverageMetric, number>> = {};
for (const metric of COVERAGE_METRICS) {
const value = new RegExp(`\\b${metric}:\\s*([\\d.]+)`).exec(block)?.[1];
if (value !== undefined) thresholds[metric] = Number(value);
}
return thresholds;
}

function nvmrcVersion(root: string): string {
return readFileSync(join(root, ".nvmrc"), "utf8").trim();
}

/**
* Check every claim README.md and CONTRIBUTING.md make about `root`'s own
* repository: every script exists, every path exists, every quoted coverage
* number matches the ratchet, every quoted Node version matches `.nvmrc`.
*/
export function lintDocs(root: string): DocsLintResult {
const errors: string[] = [];
const byKind: Record<ClaimKind, number> = {
script: 0,
number: 0,
version: 0,
path: 0,
};

const scripts = packageScripts(root);
const thresholds = coverageThresholds(root);
const nvmrc = nvmrcVersion(root);

for (const document of DOCUMENTS) {
let text: string;
try {
text = readFileSync(join(root, document), "utf8");
} catch {
// Neither document is required to exist for this gate to run; a
// repository missing one of them fails elsewhere.
continue;
}

for (const script of scriptClaims(text)) {
byKind.script += 1;
if (!(script in scripts))
errors.push(
`${document}: names \`npm run ${script}\`, which is not a script in package.json`,
);
}

for (const path of pathClaims(text)) {
byKind.path += 1;
if (!existsSync(join(root, path)))
errors.push(`${document}: names \`${path}\`, which does not exist`);
}

for (const { metric, value } of coverageClaims(text)) {
byKind.number += 1;
const configured = thresholds[metric];
if (configured === undefined)
errors.push(
`${document}: quotes ${metric} coverage as ${value}%, but ` +
"vitest.config.ts sets no such threshold",
);
else if (configured !== value)
errors.push(
`${document}: quotes ${metric} coverage as ${value}%, but ` +
`vitest.config.ts sets it to ${configured}%`,
);
}

for (const version of nodeVersionClaims(text)) {
byKind.version += 1;
if (version !== nvmrc)
errors.push(
`${document}: names Node ${version}, but .nvmrc pins ${nvmrc}`,
);
}
}

const claims = byKind.script + byKind.number + byKind.version + byKind.path;
return { claims, byKind, errors };
}

/** Lint the tree named by argv[0], or this repository, and say what was found. */
export function main(
argv: readonly string[],
output: GateOutput = processOutput,
): number {
const { claims, byKind, errors } = lintDocs(argv[0] ?? REPOSITORY);
if (errors.length > 0) {
output.err(
`docs lint: ${errors.length} error(s)\n` +
errors.map((e) => ` - ${e}\n`).join(""),
);
return 1;
}
output.out(
`docs lint: ${claims} claim(s) clean ` +
`(scripts ${byKind.script}, numbers ${byKind.number}, ` +
`versions ${byKind.version}, paths ${byKind.path})\n`,
);
return 0;
}

if (isEntrypoint(import.meta.url, process.argv[1]))
process.exitCode = main(process.argv.slice(2));
Loading
Loading