enhance(#2704): resolve documentation links and compare H1 status brackets in the ADR gate (#3266)

* test(#2704): failing-first coverage for ADR link resolution and H1 status brackets

Binds the gate to two assertions it does not yet make: every relative markdown
link under docs/adr/ must resolve, and an H1 trailing status bracket must agree
with the Status: field instead of being silently stripped.

Covers all 51 rows of the phase test matrix across two altitudes - the pure
extractLinks/maskCode IR for fence and inline-code-span boundaries, hostile
input and the fast-check totality properties, and the real CLI verdict for the
end-to-end classes. Includes the DEFECT.GENERATIVE-FIX parity test that iterates
the exported STATUSES array so a sixth status is covered the day it is added.

* feat(#2704): resolve ADR documentation links and compare H1 status brackets

The ADR gate validated naming, relation symmetry and index freshness but never
resolved a link target, and it stripped an ADR's trailing H1 status bracket for
display rather than comparing it against that ADR's own Status: field. Both
classes were structurally invisible: #2691 found five dangling references by
manual audit roughly a year after they were introduced, one of which reached the
published npm payload, while CI reported green throughout.

Both are now assertions on the same --check path, using only node:fs and
node:path - no dependency and no subprocess.

Fenced blocks and inline code spans are masked before scanning, because markdown
does not render a link inside code. That is not a policy choice: the corpus
contains exactly two such sequences today and both are ordinary JavaScript.
Masking preserves length and column positions so findings still name a real line.

Resolution is case-exact on every platform - a link that resolves only through
macOS or Windows case-folding still 404s on github.com and still fails the Linux
lane - and a destination resolving outside the repository is reported before any
filesystem call is made.

Also single-sources two duplicated surfaces this change would otherwise have
extended: the H1 bracket vocabulary (a second hand-written copy of STATUSES with
nothing asserting agreement, a DEFECT.GENERATIVE-FIX instance) and the docs/adr
directory traversal. Two tests added by #2691 that reimplemented link resolution
and bracket comparison inside the test file are removed for the same reason; the
corpus assertion is now made by running the real gate against the real corpus.

* fix(#2704): reject symlinks that leave the repository and linearize code masking

Four defects from the isolated adversarial security review, plus one it noted.

BLOCKER - a symlink defeated path containment. path.relative(ROOT, abs) is
purely lexical, but the case-exact walk then calls readdirSync, which follows
symlinks at the OS level: a contributor-committed docs/adr/x -> /etc together
with a link through it passed containment and listed the real external
directory, and a wrong-case probe echoed a real external filename through the
"Did you mean" hint into publicly-readable fork-PR logs. Every segment is now
lstat'd before descent; a symlink is realpathed and re-checked against
realpath(ROOT) - realpath on both sides, so a root under /var does not produce
false escapes - and an escape emits no hint and reads nothing further.

The same rule now governs which FILES are read: an ADR entry that is a symlink
out of the repository is excluded and reported rather than parsed, closing the
vector this change had widened by newly reading README.md, naming-violation
files, and full bodies rather than only header fields.

MAJOR - inline-span masking rescanned the line remainder per backtick run,
roughly O(n^1.6) on adversarial input: 1.76s for an 800KB line. Rewritten as a
single linear pass pairing runs through forward-only per-length cursors. Same
input now takes 3.31ms, with behavior unchanged.

MINOR - an unreadable or broken entry threw, and the generic handler wrote a
raw stack trace carrying absolute CI paths to stderr. The scan is now
fault-tolerant and reports excluded entries as ordinary violations. The status
vocabulary is escaped before being interpolated into a dynamic RegExp -
defence-in-depth, not a live bug.

The containment predicate had reached three hand-written copies while fixing
this; it is now the single escapesRoot() helper used by all four call sites.

* feat(#2704): add a --json report so the gate's tests assert on typed values

Maintainer-directed addition. CONTRIBUTING.md's "Prohibited: Raw Text Matching
on Test Outputs" requires that a system under test producing text also expose a
structured intermediate representation, and that tests assert on that IR rather
than on rendered prose. This gate had no such surface, so its verdict tests
matched on stderr.

--json runs exactly the same validation as --check and writes a report to stdout
with the same exit code, following the frozen-REASON-enum pattern already used
by verify-reapply-patches.cjs. Every violation carries a stable reason code plus
the fields a consumer needs, so nothing has to pattern-match an error message.
Adding a reason stays three coordinated changes - the enum, the emitting site,
and the test locking Object.keys(REASON).sort().

The human output is unchanged, deliberately: a large pre-existing suite asserts
on it and migrating that is not this PR's concern. Verified by running the
pre-change and post-change scripts against an identical violating corpus and
diffing their stderr - character-for-character identical.

This PR's own verdict tests now assert on parsed --json. Absence checks improve
the most: "no bracket violation" is now a reason-code predicate rather than a
negative regex over prose, which could pass for the wrong reason. The security
assertions were strengthened rather than translated - no leaked filename may
appear in ANY field of the serialized report.

Unknown flags are now rejected instead of silently falling through to printing
the index.

* test(#2704): fix the status-parity fixture and guard hooks/dist before overlay builds

Two failures from the matrix run of 79b29909.

The status-parity fixture was mine. It built, per status token, an ADR whose H1
bracket and Status field both carried that token - but Superseded carries an
obligation beyond the bracket: it must name its successor as a file link and be
symmetric with it. The fixture declared a bare Superseded, tripped that
unrelated invariant, and the test reported a bracket-parity failure for a reason
that had nothing to do with bracket parity. The fixture now satisfies each
token's own obligations in both the agreeing and contradicting corpora, derived
from the status actually declared rather than special-cased on one name, so a
future token carrying obligations is handled rather than silently skipped.

The second failure was not mine but is fixed here rather than deferred.
mcp-catalog-parity.install.test.cjs hardlinks hooks/dist/* while building its
overlay, but hooks/dist is a gitignored build artifact produced only by
build:hooks. The suite had no guard, so it passed only when some other suite
happened to build it first - an execution-order dependency, which is why it
failed on node22 and passed on node24 for identical code. install.test.cjs
already documents this exact hazard and guards it.

Six behaviorally identical copies of that guard existed across three files.
Rather than add a seventh, they are now one canonical
tests/helpers/hooks-dist.cjs - idempotent and bounded by the shared
BUILD_TIMEOUT_MS class norm - which is the same single-sourcing this PR applies
to the ADR gate itself.

* docs(#2704): add a how-to for contributors the ADR gate rejects

The reference and explanation quadrants were covered by Lifecycle rules 5 and 6,
but the task-oriented one was thin: a contributor meets this gate because it
failed on their PR, under pressure, and the rules told them what is checked
without telling them what to do about it.

Adds the command to reproduce the CI failure locally and a message-to-remedy
table covering every reason code that can be hit - unresolved target, wrong case
with the did-you-mean hint, repository escape, symlinked ADR file, bracket
contradiction - plus the backtick escape hatch for illustrative links and the
caveat that indented code blocks are not skipped.

The table is itself written in backticked inline code, so the gate skips it: the
escape hatch demonstrated on the page that documents it.

* chore(#2704): backfill changeset PR number

pr:0 placeholder replaced with the real PR number now that #3266 exists.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-09 17:08:46 -04:00
committed by GitHub
parent 2ac21c7fdb
commit 2e2b8ba4a7
8 changed files with 1860 additions and 191 deletions

View File

@@ -84,6 +84,55 @@ Only an `Accepted` ADR is owed the back-link. A `Proposed` ADR's claim is **pros
An H1 of `# ADR-0175: …` in a file named `218-*.md` is a rename that never finished. The id in the title must match the filename's prefix.
### 5. A trailing H1 status bracket must agree with the `Status` field
Many ADRs restate their status in the H1 — `# ADR-1610: … [Accepted]`. That bracket is the first thing a reader sees, and the index strips it when rendering the title, so a stale one used to be invisible to everyone but the reader it misled.
If the H1 ends in a bracket holding a status token, it must name the **same** status as the `Status` field. Comparison is case-insensitive and against the parsed *token*, so `[Superseded]` agrees with `Status: Superseded by [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) (2026-05-23)`.
A trailing bracket that is **not** a status token — `[Draft]`, `[WIP]` — is treated as part of the title and left alone. If you want a bracket the gate ignores, do not spell it like a status.
### 6. Every relative link resolves
A link whose target does not exist on disk fails the check, naming the file, the line, and the unresolved target. This covers every markdown file in this directory, including this README and any file whose name breaks the convention above.
| Written as | Treated as |
|---|---|
| `[t](900-beta.md)`, `[t](../prd/)` | resolved — a directory counts |
| `[t](900-beta.md#section)` | the **file** is resolved; the `#fragment` is not checked |
| `[t](https://…)`, `[t](mailto:…)`, `[t](//host/x)` | out of scope — absolute destinations are never fetched |
| `[t](#lifecycle-rules)` | out of scope — a same-document anchor is not a file reference |
| `[t](/docs/adr/x.md)` | resolved against the repository root, as GitHub does |
| a link inside a ``` fence or `` `backticks` `` | **not a link** — markdown does not render one there, so it is never resolved |
| `[text][ref]` reference-style, `<a href>`, bare autolinks | not supported; write an inline link |
Two consequences worth stating outright:
- **Case matters, on every platform.** `[t](0001-Alpha.md)` pointing at `0001-alpha.md` fails even on macOS and Windows, because it 404s on github.com and reds the Linux CI lane. The failure names the entry it found so the fix is obvious.
- **A link to a generated or ignored path fails.** Nothing here consults `.gitignore`; the question is only whether a reader following the link lands somewhere. Cite the hand-authored source rather than the build artifact.
### If the gate rejects something you wrote
Reproduce it locally first — it is the same command CI runs, and it names the file, the line, and the target:
```bash
node scripts/gen-adr-index.cjs --check
```
Then work from the `reason`:
| What it says | What to do |
|---|---|
| `does not resolve — no such file or directory at …` | Fix the path. It is relative to `docs/adr/`, so a sibling ADR is just `900-slug.md`. If the target genuinely does not exist yet, drop the link rather than leaving it pointing nowhere. |
| `…Did you mean X? — link targets are case-sensitive on github.com` | Match the on-disk name exactly. Your machine may open the file regardless; github.com and the Linux CI lane will not. |
| `escapes the repository` | The path resolves outside the repo. Link something inside it, or use an absolute URL — those are out of scope and never checked. |
| `is a symlink that escapes the repository` | An ADR file itself is a symlink pointing outside the repo. Commit a real file. |
| `H1 status bracket […] contradicts the Status field (…)` | Update whichever of the two is stale so they agree. The `Status` field is authoritative; the bracket is a restatement for the reader. |
**A link that is an example, not a destination, belongs in backticks.** The gate skips fenced blocks and inline code entirely, because markdown does not render a link there. That is the escape hatch for illustrative syntax — the table above is written that way, which is why it does not fail this check. An indented code block (four spaces) is *not* skipped; use backticks.
To consume the result from a script rather than by eye, use `--json` (below) and branch on each violation's stable `reason` code.
### Ratifying a stale `Proposed`
A stale `Proposed` is not cosmetic: it tells contributors and agents that live architecture is an unbuilt idea. Fix it — but on evidence, not vibes.
@@ -110,10 +159,27 @@ A stale `Proposed` is not cosmetic: it tells contributors and agents that live a
node scripts/gen-adr-index.cjs # print the index
node scripts/gen-adr-index.cjs --write # regenerate it into this file
node scripts/gen-adr-index.cjs --check # CI: fail if stale or invalid
node scripts/gen-adr-index.cjs --json # same checks, machine-readable report
```
After adding an ADR, or changing any ADR's status or relations, run `--write` and commit the result. `npm run lint:generated-sync` runs `--check` in CI, so a missing or stale row fails the build rather than rotting silently.
`--json` runs the same validation as `--check` and writes a report to stdout instead of prose to stderr, with the same exit code. Each violation carries a stable `reason` code, so a tool consuming this never has to pattern-match an error message:
```json
{
"ok": false,
"adrCount": 76,
"indexStale": false,
"violations": [
{ "file": "2704-example.md", "line": 41, "reason": "link_unresolved",
"target": "reference/x.md", "resolved": "docs/adr/reference/x.md" }
]
}
```
An unrecognized flag is rejected rather than ignored.
This replaces a hand-maintained table that had drifted to **40 of 65 ADRs** — the entire capability family and EoS itself were missing from it, which is precisely why the ADRs a reader most needed were the ones they could not find.
## Index