diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ad54ac..8c4cca0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/README.md b/README.md index ee13883..55a2c22 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index cffbbd4..7f3d94c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 @@ -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. diff --git a/docs/requirements.md b/docs/requirements.md index 8e8e6f7..52dfd43 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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 | |---|---|---| @@ -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) | diff --git a/package.json b/package.json index ac9f5b1..213da26 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/lint-docs.ts b/scripts/lint-docs.ts new file mode 100644 index 0000000..74eebc2 --- /dev/null +++ b/scripts/lint-docs.ts @@ -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>; + +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