* test(#3412): failing-first suite for the pattern-construction seam Phase 1 of epic #3212 (ADR-3212 §1/§2/§7). Tests only — src/pattern.cts and eslint-rules/no-adhoc-regex-escape.cjs do not exist yet, so both suites fail with MODULE_NOT_FOUND, which is the intended RED. Locks the measured behavior rather than the assumed behavior: RegExp.escape hex-escapes the leading character of nearly every string ("abc" -> "\x61bc"), so the suite asserts match-equivalence against an inlined historical oracle (the implementation being deleted) rather than byte-equivalence of pattern text — 200 seeded fast-check runs plus a fixed corpus, 0 mismatches. Also locks the latent character-class range bug this phase fixes as a side effect: a hyphen-bearing value interpolated into [...] currently forms a real range and matches an unintended character; post-migration it must not. * chore(#3412): src/pattern.cts owns runtime-value regex construction Phase 1 of epic #3212 (ADR-3212 §1/§2/§6/§7). Adds the pattern seam delegating to the built-in RegExp.escape, deletes every hand-rolled copy, and raises the Node floor to the Active LTS line. The census was low, three times over. ADR-3212 counted 10 copies; a graph query found 12; the new lint rule — once live — found 27 more. The difference is that the census counted named helper FUNCTIONS while the rule counts the escape SHAPE, so inline .replace(<class>, '\$&') copies were never in scope. ADR §1's actual requirement is that no module outside the seam escapes a value for regex use, so all of them are, and CLAUDE.md's no-defer rule makes them this change's work. Fourth consecutive epic here whose copy count was low — the argument for ADR-3180 Amendment 3's "state N found by the guard" rule. Also corrected mid-implementation: the survey reported phase-id.cts's escapeRegex had 0 external importers. It had 8 production importers, making its removal a public-surface change to an ADR-2121-owned module and requiring an update to that ADR's locked-surface test. Blast radius revised Medium-High -> High. RegExp.escape is match-equivalent but NOT text-equivalent: it hex-escapes the leading char of nearly every string ("abc" -> "\x61bc"). Equivalence is proven by a seeded fast-check property test against the deleted implementation as oracle. It also fixes a latent bug: a hyphen-bearing value interpolated into a character class previously formed a real range and matched an unintended character. Node floor 22 -> 24 (RegExp.escape is Node 24+), across engines, .nvmrc, package-lock, 9 CI matrix entries, and 5 docs. The aggregate `required-tests` context is unchanged and no job was added or removed, so branch protection cannot be orphaned by the dropped lanes. Enforced by eslint-rules/no-adhoc-regex-escape.cjs (shape-matched, with structural provenance for reviewed pattern-fragment constants rather than a name heuristic) plus a whole-tree companion guard covering the directories ESLint's globs miss. * fix(#3412): close the _SOURCE guard evasion, correct two false claims Three findings from the orthogonal review pass, all fixed. 1. The ESLint rule's `_SOURCE` provenance fallback was pure identifier- name matching with no binding check, so `new RegExp(userInput_SOURCE)` — a function parameter — sailed past the guard. That is the same rename-evasion class issue #3410 documents, reopened by the very fallback meant to complement the structural check. Now bound to the identifier's actual binding kind: import, require-derived const, or module-scope const; parameters, `let`/`var`, and unresolvable bindings fail closed. Four RuleTester cases cover the evasion and prove the legitimate cross-module case still passes. 2. src/pattern.cts's own header carried the stale pre-correction counts (12 copies / 17 call sites) while CONTEXT.md and the design doc carried the corrected ones (~39 / ~44) — a self-contradiction inside the PR whose entire purpose is deleting divergent copies. Rewritten, preserving the durable lesson: a named-function census cannot see inline copies; only a shape-matching guard can. 3. The claim that all deleted copies threw TypeError on non-string was false. phase-id.cts's copy — the one with 8 external importers — did String(value).replace(...) and never threw. The seam's locked signature does not coerce, so this is a real, now-disclosed behavior change rather than the pure preservation the tests asserted. Audited all 32 invocations across the 8 importers and 6 in-file callers: every one is safe by construction (upstream truthy guard or a string-producing derivation), verified by runtime probe against the compiled modules rather than by TS compilation, which cannot see a runtime undefined. Corrected the false claim in both the test comment and the design doc, and added it to Known limits. * docs(#3412): add Changed changeset for the Node 24 floor The only user-visible break in this phase. The escape-behavior change is internal and match-equivalent, so it carries no user-facing note. * fix(#3412): resolve the seam's require graph in script fixtures and packaging Checkpoint 2 came back red with 90 failures on the node24 lane. Three distinct defects, all introduced by routing scripts/ through the new pattern seam, none reproducible by any local gate: 1. ~82 failures — tests/adr-index-gate.test.cjs and tests/removed-but-needed-lint.test.cjs copy a scripts/*.cjs into an mkdtemp fixture and spawn it there (necessary: those scripts resolve their scan root from __dirname/.., so running the real script would scan the real repo). Each harness hand-listed the dependencies to copy alongside. Adding require('../gsd-core/bin/lib/pattern.cjs') to gen-adr-index.cjs made both lists silently incomplete -> MODULE_NOT_FOUND, plus 17 downstream 'did not emit parseable JSON' failures from the same crash. Fixed as a class, not an instance: new tests/helpers/copy-script- fixture.cjs walks a script's transitive static relative-require graph and copies it, so dependencies are derived and never re-declared. It throws (naming the unbuilt artifact) instead of letting the child die with a bare MODULE_NOT_FOUND. Verified for all four seam-consuming scripts: gen-adr-index, lint-removed-but-needed, gen-loop-host- contract, sync-runtime-launcher. 2. 2 failures — scripts/ ships wholesale but eslint-rules/ does not, so the new scripts/lint-no-adhoc-regex-escape.cjs would be MODULE_NOT_FOUND in a published install (#2858 guard). Excluded from the tarball, matching the existing precedent for gen-emitted- baseline.cjs, which is excluded for the identical reason, and locked with a test modeled on that one. Confirmed against a real npm pack: 890 files, 0 from eslint-rules/, and gsd-core/bin/lib/pattern.cjs present (so the other four scripts' requires are legitimate). 3. 6 failures — tests/phase-id.test.cjs asserted the literal escaped source text ('0*29', 'PROJ-42'). RegExp.escape is match-equivalent to the retired hand-rolled escaper but NOT text-equivalent: it hex- escapes the leading character and all hyphens ('0*\x329', '\x50ROJ\x2d42'). Verified NOT a behavior change — 576 match decisions across all three real interpolation prefixes, zero divergence. Those tests now compile each source into the same heading regex src/roadmap.cts's searchPhaseInContent builds and assert what matches and what does not, including the 'i'-flag canonicalization the hex escape has to preserve. Re-pinning the new literals would have rebuilt the same brittleness one layer down. Adds a test for the property the escape exists for: a dot in '1.2' must not act as a wildcard. Also shares one definition of 'a require' between the packaging guard and the fixture copier, so the two cannot disagree about what they scan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3412): refuse to copy a fixture dependency outside the fixture root copyScriptWithDeps resolved each relative require and joined the repo-relative result onto fixtureRoot. A require resolving OUTSIDE the repo yields a '../'-prefixed relative path, so path.join climbed out of the fixture and wrote into the surrounding temp dir (verified: repoRoot=/repo + depAbs=/etc/passwd wrote /tmp/etc/passwd). No script in the tree does this today, so this closes an available escape rather than an active one. Refuses via the existing unresolved- require path so the failure names the offending specifier. Covered by a negative proof that the guard fires and that nothing lands outside the fixture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3412): parse requires instead of pattern-matching them; restore the foreign-prefix contract Applies all findings from the second orthogonal review round, re-run because real code changed after round 1. HIGH (security) — extractRequires stripped BLOCK comments before LINE comments, so a '//' comment containing '/*' opened a phantom block comment, and a '//' inside a string literal truncated the line. Both hid real requires: 'const u="http://x"; require("./real.cjs")' returned [], and four real requires in gsd-core/bin/gsd-tools.cjs were invisible. Replaced with a real AST parse via espree. This is ADR-3212's own Decision 4 — tokenizer-first for stateful grammars — applied to the case it describes; comment/string/regex nesting is exactly such a grammar, which is why the regex version was wrong. The function was moved byte-identical out of the #2858 packaging guard, so the bug PRE-DATES this branch and has been a live blind spot there: a shipped script could have required an unshipped path undetected. Fixing it makes that guard strictly stronger than on next. espree is promoted from a transitive eslint dependency to an explicit devDependency rather than relying on hoisting. The script parse attempt sets ecmaFeatures.globalReturn because Node wraps CommonJS bodies in a function, making a top-level return legal — scripts/check-coverage-gate .cjs relies on it, and without the flag the guard throws on a file it is supposed to scan. Verified 0 unparseable across all 324 .cjs/.js under scripts/, bin/, and gsd-core/bin/, and 0 new violations against a real npm pack, so the exact extractor does not newly fail the guard. MEDIUM (security) — the repo-containment check guarded dependencies but not the entry path. One escapesContainment predicate now guards both. LOW (security) — containment was lexical while fs follows symlinks, and a directory symlink could mint a fresh dedupe key per level. realpath now resolves both repoRoot and each dependency before the decision, and the realpath-derived path is the dedupe key. Destination layout still uses the original repo-relative path, so copied trees are unchanged. MAJOR (standards) — the round-1 behavioral rewrite of phase-id tests lost the foreign-prefix contract: every assertion was satisfied by an impl returning [A-Z]+\x2d42, i.e. ANY project code — the exact #3599 bug class the exact-source prevents. The literal assertions it replaced were catching this. Now asserts the compiled regex REJECTS a different prefix with the same number. MAJOR (standards) — the test hand-duplicated production's heading regex with no parity guard (CLAUDE.md's 'Generative Fix Divergence'). Removed the parallel surface instead of policing it: src/roadmap.cts exports buildPhaseHeadingRegex, searchPhaseInContent calls it, the test imports it. Byte-identical .source and .flags verified for both escaped forms. MINOR — '..foo' no longer false-flagged as an escape; the inverted spurious-vs-missing doc claim corrected; the dead allow-test-rule header removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3412): backfill changeset pr number to 3416 * fix(#3412): make the escape guard's own regex linear, reword an injection-scan collision Two CI failures on PR #3416, both in code this branch added. CodeQL js/redos (high) — REPLACE_CALL_RE's outer alternation let a bracket run be consumed EITHER by the character-class branch OR one character at a time by the trailing catch-all, so a failing match explored both parses of every pair. Measured on the real regex: n=26 -> 204ms, n=28 -> 791ms, n=30 -> 3475ms, a clean 2^n. This script scans repo source, so a file with a long bracket run after '.replace(/' would hang CI outright — a guard against undisciplined pattern construction was itself the worst pattern in the diff. Fixed the way ADR-3212 already prescribes: the catch-all branch now excludes '[' and ']' so a bracket can only be consumed by the class branch (this is what makes it linear), and every quantifier is bounded (the locked bounded-quantifiers decision) as a second line of defense. Now 0ms at n=2000. Disclosed coverage tradeoff, recorded at the constant: a regex literal with a BARE unescaped ']' outside a class is no longer matched by this backstop. No census shape has that form, and the AST rule remains the primary detector. Verified the guard did not go blind doing it: a real census-shape violation is still reported, and an allow-adhoc-regex-escape suppression comment is still honored. Regression test drives the exported findViolations on a 2000-repetition adversarial input and asserts the RESULT. It makes no wall-clock assertion — elapsed-time tests are forbidden — so a regression surfaces as a harness timeout, which is the correct signal. Prompt injection scan — 'must not act as a regex wildcard' in a test comment matched the scanner's jailbreak pattern act\s+as\s+(a|an|if| my). Reworded to 'behave as'. Deliberately NOT allowlisted: silencing a whole test file over one phrase would blunt the scanner permanently, and the comment has nothing to do with injection. Neither failure was reachable from the remote runner — CodeQL and the injection scan are not in that matrix, so the sha it passed was green and still wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1219 lines
51 KiB
JavaScript
1219 lines
51 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* Generates the ADR index table in docs/adr/README.md from the ADR files
|
|
* themselves, and validates the corpus' lifecycle invariants.
|
|
*
|
|
* The index is a DERIVED artifact: it is regenerated from every
|
|
* `docs/adr/<id>-<slug>.md` on disk, so it cannot silently drift out of date
|
|
* the way a hand-maintained table does. CI re-runs this with `--check` and
|
|
* fails on any diff or invariant violation.
|
|
*
|
|
* Invariants enforced (see docs/adr/README.md "Lifecycle rules"):
|
|
* 1. Every ADR declares `- **Status:** <Token>` with Token in STATUSES.
|
|
* 2. A Superseded/Retired ADR names its successor as a markdown link to the
|
|
* target file — never a bare "ADR-N", which is ambiguous (ADR-0010 and
|
|
* ADR-0011 each resolve to more than one file).
|
|
* 3. Supersession is symmetric: if A supersedes B, B records superseded-by A.
|
|
* 4. An ADR whose H1 declares an id must match its filename's id.
|
|
* 5. The committed index equals the generated index.
|
|
*
|
|
* Usage:
|
|
* node scripts/gen-adr-index.cjs # print the index to stdout
|
|
* node scripts/gen-adr-index.cjs --write # rewrite the index in README.md
|
|
* node scripts/gen-adr-index.cjs --check # exit 1 if stale or invalid
|
|
* node scripts/gen-adr-index.cjs --json # --check semantics; JSON report on stdout
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
const { escapeRegex: escapeRegExp } = require('../gsd-core/bin/lib/pattern.cjs');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const ADR_DIR = path.join(ROOT, 'docs', 'adr');
|
|
const README_PATH = path.join(ADR_DIR, 'README.md');
|
|
|
|
const START_MARKER = '<!-- ADR-INDEX:START — generated by scripts/gen-adr-index.cjs; do not edit by hand -->';
|
|
const END_MARKER = '<!-- ADR-INDEX:END -->';
|
|
|
|
/**
|
|
* The canonical status vocabulary.
|
|
*
|
|
* `Legacy` and `Retired` are deliberately distinct from `Superseded`:
|
|
* - Superseded — a specific newer ADR replaced this decision. Names it.
|
|
* - Retired — the thing this ADR decided no longer exists at all, and no
|
|
* single ADR replaced it (e.g. a deleted package boundary).
|
|
* - Legacy — frozen historical record, kept for provenance, not a
|
|
* pattern to imitate.
|
|
*
|
|
* NOTE: `Legacy` describes a DECISION's standing, not a filename. The
|
|
* `0001-`..`0012-` sequential *naming* era is legacy, but many of those ADRs
|
|
* (e.g. 0002, 0004, 0008, 0009) are Accepted and load-bearing today. Do not
|
|
* conflate the two: grep the naming rule in README.md, not this enum.
|
|
*/
|
|
const STATUSES = ['Accepted', 'Proposed', 'Superseded', 'Legacy', 'Retired'];
|
|
|
|
/**
|
|
* Stable reason codes for every lifecycle violation this gate can emit.
|
|
* Tests assert via `assert.equal(record.reason, REASON.X)` (or `.some(...)`
|
|
* over the `--json` `violations` array) rather than regex-matching stderr
|
|
* prose — see CONTRIBUTING.md "Prohibited: Raw Text Matching on Test
|
|
* Outputs" and the worked example in `bin/verify-reapply-patches.cjs`.
|
|
*
|
|
* Adding a reason is a deliberate three-part change: a new entry here, the
|
|
* emitting `add(...)` call site, and the corpus test that locks
|
|
* `Object.keys(REASON).sort()` — so a new violation class cannot ship
|
|
* without its own typed identity.
|
|
*/
|
|
const REASON = Object.freeze({
|
|
FILENAME_INVALID: 'filename_invalid',
|
|
STATUS_MISSING: 'status_missing',
|
|
STATUS_INVALID: 'status_invalid',
|
|
STATUS_BRACKET_MISMATCH: 'status_bracket_mismatch',
|
|
ID_MISMATCH: 'id_mismatch',
|
|
SUPERSEDED_NO_SUCCESSOR: 'superseded_no_successor',
|
|
SUPERSEDED_BARE_ID: 'superseded_bare_id',
|
|
RELATION_LINK_MISSING: 'relation_link_missing',
|
|
RELATION_BARE_ID_MISSING: 'relation_bare_id_missing',
|
|
RELATION_BARE_ID_UNLINKED: 'relation_bare_id_unlinked',
|
|
RELATION_ASYMMETRIC: 'relation_asymmetric',
|
|
LINK_UNRESOLVED: 'link_unresolved',
|
|
LINK_ESCAPES_REPO: 'link_escapes_repo',
|
|
LINK_ESCAPES_REPO_SYMLINK: 'link_escapes_repo_symlink',
|
|
DIRENT_UNREADABLE: 'dirent_unreadable',
|
|
DIRENT_ESCAPES_REPO_SYMLINK: 'dirent_escapes_repo_symlink',
|
|
});
|
|
|
|
/**
|
|
* The H1 trailing-bracket vocabulary, derived from `STATUSES` — not a second
|
|
* hand-written literal. Before this PR, `parseAdr`'s title strip carried its
|
|
* own copy of these five tokens, and nothing asserted the two lists agreed:
|
|
* a textbook `DEFECT.GENERATIVE-FIX` instance (a generated surface and its
|
|
* hand-authored source drifting apart with no parity check). A 6th status
|
|
* added to `STATUSES` now covers the bracket for free, and the corpus's
|
|
* parity test iterates the real exported array rather than a copy.
|
|
*/
|
|
// Escaped for defence-in-depth, not a live-bug fix: `STATUSES` is a static
|
|
// array literal today, so nothing in it can currently carry a regex
|
|
// metacharacter. But nothing enforces that it STAYS static — if a future
|
|
// change ever derives it from external input (a config file, a corpus scan),
|
|
// an unescaped `join('|')` would let a status token break out of the
|
|
// alternation it is meant to be one branch of.
|
|
const STATUS_BRACKET_RE = new RegExp(String.raw`\s*\[(${STATUSES.map(escapeRegExp).join('|')})\]\s*$`, 'i');
|
|
|
|
/**
|
|
* Header fields that assert a lifecycle relation.
|
|
*
|
|
* Two DISTINCT relations, deliberately not conflated:
|
|
*
|
|
* supersedes — the target decision is REPLACED. The target's status becomes
|
|
* Superseded and it must name this ADR. (ADR-0174 → ADR-0005.)
|
|
*
|
|
* subsumes — the target decision still HOLDS, but a broader ADR now frames
|
|
* it; the target keeps its Accepted status and becomes a component of the
|
|
* larger decision. (ADR-1239/EoS subsumes ADR-1016 "as the declarative
|
|
* adapter" — the descriptor is still real and still correct.)
|
|
*
|
|
* Both directions are symmetry-checked, but only `supersedes` implies a status
|
|
* change on the target. Collapsing subsumption into supersession would mark
|
|
* four live, load-bearing ADRs as dead — the opposite of the truth.
|
|
*/
|
|
const RELATION_FIELDS = new Map([
|
|
['supersedes', { kind: 'supersedes', dir: 'out' }],
|
|
['superseded by', { kind: 'supersedes', dir: 'in' }],
|
|
['subsumes', { kind: 'subsumes', dir: 'out' }],
|
|
['subsumed by', { kind: 'subsumes', dir: 'in' }],
|
|
]);
|
|
|
|
/** Relation kinds and the header field a reader should add to fix each gap. */
|
|
const RELATION_SPEC = {
|
|
supersedes: { out: 'Supersedes', in: 'Superseded by' },
|
|
subsumes: { out: 'Subsumes', in: 'Subsumed by' },
|
|
};
|
|
|
|
/**
|
|
* A relation field whose value opens with "nothing"/"none"/"n/a" asserts the
|
|
* absence of the relation, whatever prose follows it.
|
|
*/
|
|
const NEGATED_RELATION_RE = /^\s*(?:nothing|none|n\/a|[—–-])\s*(?:$|[;,.]|\s)/i;
|
|
|
|
/**
|
|
* Header fields appear in two shapes across the corpus, both legitimate:
|
|
* bullet — `- **Status:** Accepted`
|
|
* table — `| **Status** | Accepted |`
|
|
* Yield [field, value] for either.
|
|
*/
|
|
function* headerFields(header) {
|
|
const bullet = /^\s*[-*]\s*\*\*([^*:]+?)(?::)?\*\*\s*(.*)$/gm;
|
|
let m;
|
|
while ((m = bullet.exec(header)) !== null) yield [m[1].trim(), m[2].trim()];
|
|
|
|
const row = /^\s*\|\s*\*\*([^*|]+?)(?::)?\*\*\s*\|\s*(.*?)\s*\|\s*$/gm;
|
|
while ((m = row.exec(header)) !== null) yield [m[1].trim(), m[2].trim()];
|
|
}
|
|
|
|
/** Numeric identity of an ADR: "0011" and "11" are the same id. */
|
|
function canonicalId(raw) {
|
|
return String(raw).replace(/^0+(?=\d)/, '');
|
|
}
|
|
|
|
/** The documented filename shape: `<issue#>-<kebab-slug>.md`. */
|
|
const ADR_FILENAME_RE = /^[0-9]+-[a-z0-9-]+\.md$/;
|
|
|
|
/**
|
|
* Whole-segment containment test: true if `abs` is NOT inside `root`.
|
|
*
|
|
* The SINGLE copy of this predicate. Before this PR it was hand-written
|
|
* inline in two places (the lexical pre-stat check in `validateLinks` and the
|
|
* post-realpath escape check in `existsCaseExact`) with no shared name; a
|
|
* third copy for `markdownFilesInAdrDir`'s own symlink check would have made
|
|
* three. All three call sites now share this one function.
|
|
*
|
|
* `rel.startsWith('..')` alone would also match an in-repo path whose first
|
|
* segment merely BEGINS with two dots (`..hidden.md`), and a false "escapes
|
|
* the repository" on a valid path is a worse failure than a miss — hence the
|
|
* whole-segment `rel === '..' || rel.startsWith('..' + sep)` form.
|
|
*/
|
|
function escapesRoot(abs, root) {
|
|
const rel = path.relative(root, abs);
|
|
return rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
|
|
}
|
|
|
|
/**
|
|
* `fs.realpathSync(ROOT)`, tolerant of an unreadable/vanished ROOT (degrades
|
|
* to the lexical ROOT itself rather than throwing — callers still get a
|
|
* usable comparison root, just without symlink-normalization on hosts where
|
|
* ROOT itself sits under a symlinked ancestor, e.g. macOS's /var -> /private/var).
|
|
*/
|
|
function realRootOrFallback() {
|
|
try {
|
|
return fs.realpathSync(ROOT);
|
|
} catch {
|
|
return ROOT;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether `joined` (a `*.md` dirent directly under docs/adr/) should be
|
|
* treated as an ADR file: a regular file, or a symlink that resolves to a
|
|
* regular file WITHOUT leaving the repository. Applies the same rule to which
|
|
* FILES are read as `validateLinks` already applies to which link TARGETS
|
|
* resolve — a symlink escaping the repo is never followed and its content is
|
|
* never touched (no `statSync`/`readFileSync` past the `lstatSync`/
|
|
* `realpathSync` calls below), because `parseAdr` and `validateLinks` both
|
|
* read the FULL body of every accepted file and echo fragments into stderr.
|
|
*/
|
|
function isAcceptedAdrEntry(joined, realRoot) {
|
|
let lst;
|
|
try {
|
|
lst = fs.lstatSync(joined);
|
|
} catch {
|
|
return false; // vanished / unreadable
|
|
}
|
|
if (lst.isFile()) return true;
|
|
if (!lst.isSymbolicLink()) return false;
|
|
|
|
let real;
|
|
try {
|
|
real = fs.realpathSync(joined);
|
|
} catch {
|
|
return false; // broken symlink
|
|
}
|
|
if (escapesRoot(real, realRoot)) return false; // escapes the repository
|
|
|
|
try {
|
|
return fs.statSync(real).isFile();
|
|
} catch {
|
|
return false; // vanished between realpath and stat (TOCTOU)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every markdown file directly under docs/adr/, README.md included.
|
|
*
|
|
* Single source of the traversal rule: `adrFiles()` is this minus README.md
|
|
* (which is the index, not an ADR), and the link-resolution pass is this
|
|
* unfiltered (the generated index can point nowhere too). Two hand-copied
|
|
* readdir filters would drift the moment either grew a rule — the exact
|
|
* DEFECT.GENERATIVE-FIX shape this gate now enforces against the corpus.
|
|
*/
|
|
function markdownFilesInAdrDir() {
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(ADR_DIR);
|
|
} catch {
|
|
// An unreadable docs/adr/ itself degrades to "no files" here — the caller
|
|
// (validate/validateLinks) surfaces the real problem elsewhere; this
|
|
// traversal helper must never throw a raw fs error up into `runMain`,
|
|
// which would print `err.stack` (absolute host paths) to public CI logs.
|
|
entries = [];
|
|
}
|
|
const realRoot = realRootOrFallback();
|
|
return entries
|
|
.filter((f) => f.endsWith('.md'))
|
|
.filter((f) => {
|
|
try {
|
|
return isAcceptedAdrEntry(path.join(ADR_DIR, f), realRoot);
|
|
} catch {
|
|
// A `*.md` dirent that cannot be classified — most commonly a broken
|
|
// symlink — is excluded here rather than crashing the caller. It is
|
|
// not silently dropped from the gate: `validateLinks` diffs this
|
|
// filtered list against the raw `readdirSync` listing and reports
|
|
// the exclusion as its own violation, naming the file.
|
|
return false;
|
|
}
|
|
})
|
|
.sort();
|
|
}
|
|
|
|
function adrFiles() {
|
|
return markdownFilesInAdrDir().filter((f) => f !== 'README.md');
|
|
}
|
|
|
|
/**
|
|
* Split the directory into files this tool can parse and files it cannot.
|
|
*
|
|
* A file without a numeric prefix is not merely unparseable — it is invisible
|
|
* to the index, which is the failure this gate exists to prevent. Report it as
|
|
* a violation naming the convention, rather than crashing on `match(...)[1]`
|
|
* or silently skipping it.
|
|
*/
|
|
function partitionAdrFiles() {
|
|
const conforming = [];
|
|
const nonConforming = [];
|
|
for (const f of adrFiles()) (ADR_FILENAME_RE.test(f) ? conforming : nonConforming).push(f);
|
|
return { conforming, nonConforming };
|
|
}
|
|
|
|
/** Extract the leading bullet-field header block (everything before the first `##`). */
|
|
function headerBlock(text) {
|
|
const body = text.split(/\r?\n/);
|
|
const stop = body.findIndex((l) => /^##\s/.test(l));
|
|
return (stop === -1 ? body : body.slice(0, stop)).join('\n');
|
|
}
|
|
|
|
/**
|
|
* A relation may also be declared as a whole SECTION rather than a header field.
|
|
* ADR-0174 is the exemplar: a `## Supersedes` heading over a table whose first
|
|
* column links each superseded ADR and whose remaining columns explain why.
|
|
* That is the richest form in the corpus and must count — reading only the
|
|
* header block would report the repo's best-documented supersession as missing.
|
|
*
|
|
* Returns { supersedes: [file…], subsumes: [file…] } from matching sections.
|
|
*/
|
|
const RELATION_SECTION_RE = /^##\s+(Supersedes|Subsumes)\b[^\n]*$/i;
|
|
|
|
function relationSections(text) {
|
|
const lines = text.split(/\r?\n/);
|
|
const out = { supersedes: [], subsumes: [] };
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const m = lines[i].match(RELATION_SECTION_RE);
|
|
if (!m) continue;
|
|
const kind = m[1].toLowerCase() === 'supersedes' ? 'supersedes' : 'subsumes';
|
|
// Collect until the next heading of any level.
|
|
let j = i + 1;
|
|
const body = [];
|
|
for (; j < lines.length && !/^#{1,6}\s/.test(lines[j]); j++) body.push(lines[j]);
|
|
const chunk = body.join('\n');
|
|
if (NEGATED_RELATION_RE.test(chunk.trim())) continue;
|
|
out[kind].push(...linkedAdrFiles(chunk));
|
|
i = j - 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** All markdown links to sibling ADR files inside a chunk of text. */
|
|
function linkedAdrFiles(text) {
|
|
const out = [];
|
|
const re = /\]\(\s*(?:\.\/)?([0-9]+-[a-z0-9-]+\.md)\s*\)/gi;
|
|
let m;
|
|
while ((m = re.exec(text)) !== null) out.push(m[1]);
|
|
return out;
|
|
}
|
|
|
|
/** Bare `ADR-123` / `ADR 123` mentions that are NOT part of a markdown link. */
|
|
function bareAdrRefs(text) {
|
|
const withoutLinks = text.replace(/\[[^\]]*\]\([^)]*\)/g, '');
|
|
const out = [];
|
|
const re = /\bADR[-\s]0*(\d+)\b/gi;
|
|
let m;
|
|
while ((m = re.exec(withoutLinks)) !== null) out.push(canonicalId(m[1]));
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Mask code (fenced blocks and inline spans) so link resolution never reads a
|
|
* `[…](…)` sequence that markdown does not render as a link. The corpus has
|
|
* two real examples of this: `mod[entry.router]({ args, cwd, raw, error })`
|
|
* inside a ``` fence, and `` `require(module)[router]()` `` inline — both
|
|
* ordinary JavaScript, neither a link.
|
|
*
|
|
* The output is the SAME LENGTH as the input, with every masked character
|
|
* replaced by a single space and every newline left untouched — so a finding
|
|
* computed against the masked text still names the correct 1-indexed line
|
|
* (Kernighan's Law: keep the debug surface honest rather than deleting text).
|
|
*/
|
|
function maskCode(text) {
|
|
// Capturing split keeps the line terminators as their own array elements
|
|
// (even indices are line content, odd indices are the terminator that
|
|
// followed), so the rebuild below never has to guess LF vs CRLF.
|
|
const parts = String(text).split(/(\r?\n)/);
|
|
|
|
// null outside a fence; otherwise the marker char ('`' or '~') and the
|
|
// length of the run that opened it — both are load-bearing for closing:
|
|
// only the SAME char with a run length >= the opener's closes the fence.
|
|
let fence = null;
|
|
|
|
for (let i = 0; i < parts.length; i += 2) {
|
|
const line = parts[i];
|
|
|
|
if (fence) {
|
|
// Whichever way this line resolves, it is code: the closing fence line
|
|
// is still a fence delimiter, not prose.
|
|
const closeRe = fence.char === '`' ? /^ {0,3}(`{3,})\s*$/ : /^ {0,3}(~{3,})\s*$/;
|
|
const close = line.match(closeRe);
|
|
parts[i] = ' '.repeat(line.length);
|
|
if (close && close[1].length >= fence.len) fence = null;
|
|
continue;
|
|
}
|
|
|
|
const open = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
|
if (open) {
|
|
fence = { char: open[1][0], len: open[1].length };
|
|
parts[i] = ' '.repeat(line.length);
|
|
continue;
|
|
}
|
|
|
|
parts[i] = maskInlineCodeSpans(line);
|
|
}
|
|
|
|
return parts.join('');
|
|
}
|
|
|
|
/**
|
|
* Mask backtick-delimited inline code spans within a single line (fences are
|
|
* handled by the caller, per-line, before this runs — a span never crosses a
|
|
* newline). CommonMark's rule: a run of N backticks opens a span, closed by
|
|
* the NEXT run of exactly N backticks; a run of any other length in between
|
|
* is part of the span's content, not a delimiter. An opening run with no
|
|
* matching close is literal text, not a span.
|
|
*
|
|
* LINEAR, not the naive per-opener rescan this replaced: the old
|
|
* implementation, for every backtick run, rescanned the entire remainder of
|
|
* the line looking for a same-length closer. A line of strictly-ascending-
|
|
* length backtick runs (nothing ever closes) forced a near-full rescan per
|
|
* run — measured ~O(n^1.6) and unbounded (34ms/50KB -> 220ms/200KB ->
|
|
* 1.76s/800KB on adversarial input). This version scans the line ONCE to
|
|
* collect every backtick run as `{start, end, len}`, then walks that run
|
|
* list left to right with a per-length cursor (`byLen`/`cursor` below) that
|
|
* only ever advances forward — so finding "the next run of equal length" is
|
|
* amortized O(1) per step and the whole pass is O(line length).
|
|
*
|
|
* Behavior is identical to the rescan version for every input: once an
|
|
* opener at run `r` is paired with the next same-length run `r'`, every run
|
|
* strictly between them is consumed as span content and is never
|
|
* reconsidered as its own delimiter — exactly what the old code did by
|
|
* jumping `i` straight to the close and never revisiting the interior.
|
|
*/
|
|
function maskInlineCodeSpans(line) {
|
|
const runs = [];
|
|
let i = 0;
|
|
while (i < line.length) {
|
|
if (line[i] !== '`') {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
const start = i;
|
|
while (i < line.length && line[i] === '`') i += 1;
|
|
runs.push({ start, end: i, len: i - start });
|
|
}
|
|
if (runs.length === 0) return line;
|
|
|
|
// Every run's index, grouped by length, in left-to-right order (already
|
|
// sorted — `runs` was built in scan order).
|
|
const byLen = new Map();
|
|
for (let idx = 0; idx < runs.length; idx += 1) {
|
|
const len = runs[idx].len;
|
|
if (!byLen.has(len)) byLen.set(len, []);
|
|
byLen.get(len).push(idx);
|
|
}
|
|
const cursor = new Map(); // len -> next unexamined index into byLen.get(len)
|
|
|
|
const spans = []; // [start, end) ranges to mask, in order, non-overlapping
|
|
let r = 0;
|
|
while (r < runs.length) {
|
|
const len = runs[r].len;
|
|
const candidates = byLen.get(len);
|
|
let c = cursor.get(len) || 0;
|
|
// Skip past any candidate at or before `r`: `r` itself, or an index
|
|
// already consumed as interior content of an earlier matched span (a run
|
|
// inside a completed span is never revisited — same as the rescan
|
|
// version never re-examining a delimiter it has already masked over).
|
|
while (c < candidates.length && candidates[c] <= r) c += 1;
|
|
if (c < candidates.length) {
|
|
const closeIdx = candidates[c];
|
|
spans.push([runs[r].start, runs[closeIdx].end]);
|
|
cursor.set(len, c + 1);
|
|
r = closeIdx + 1;
|
|
} else {
|
|
cursor.set(len, c);
|
|
r += 1; // no closer of equal length anywhere ahead — literal text
|
|
}
|
|
}
|
|
|
|
let out = '';
|
|
let pos = 0;
|
|
for (const [s, e] of spans) {
|
|
out += line.slice(pos, s) + ' '.repeat(e - s);
|
|
pos = e;
|
|
}
|
|
out += line.slice(pos);
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Every inline `[text](dest)` / `` link or image in `text`, with
|
|
* code masked out first so a code-shaped bracket/paren sequence is never
|
|
* misread as a link (see `maskCode`).
|
|
*
|
|
* `[^\][\n]*` for the link-text class deliberately excludes BOTH bracket
|
|
* characters, not just `]` — so `[see [1]](x.md)` does not match (nested
|
|
* brackets are out of the inline-links-only scope this gate supports) and a
|
|
* regex character class in prose like `[A-Z][A-Z0-9_]` cannot be misread as
|
|
* one either. Reference-style links (`[text][ref]`) are correspondingly not
|
|
* supported: the corpus has zero reference definitions to resolve against.
|
|
*
|
|
* Returns `{ line, target }` per match — `line` is 1-indexed, `target` is the
|
|
* RAW parenthesized capture, untrimmed and unresolved; callers normalize.
|
|
*/
|
|
function extractLinks(text) {
|
|
const masked = maskCode(String(text));
|
|
const lines = masked.split(/\r?\n/);
|
|
const out = [];
|
|
const re = /!?\[[^\][\n]*\]\(([^()\n]*)\)/g;
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
re.lastIndex = 0;
|
|
let m;
|
|
while ((m = re.exec(lines[i])) !== null) {
|
|
out.push({ line: i + 1, target: m[1] });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Case-exact existence of `abs` (which MUST already be verified inside ROOT
|
|
* by the caller — LEXICALLY, via `path.relative`). Walks each path segment
|
|
* against a cached, real `readdirSync` listing of its parent rather than
|
|
* calling `fs.existsSync(abs)` directly: existsSync resolves through the OS's
|
|
* case-folding rules, which pass on macOS/Windows for a link that 404s on
|
|
* github.com and reds the Linux CI lane — every platform must agree, so
|
|
* resolution never trusts the filesystem's own case sensitivity (or lack
|
|
* of it).
|
|
*
|
|
* SYMLINK ESCAPE (the reason this function is more than a readdir loop): the
|
|
* caller's containment check is purely lexical string math on `abs` — it
|
|
* proves nothing about what is actually ON DISK at each segment. But
|
|
* `readdirSync` FOLLOWS symlinks at the OS level while walking further down a
|
|
* path. A contributor can commit `docs/adr/x -> /etc` (a symlink; Linux CI
|
|
* lanes, including fork PRs, preserve symlinks) plus an ADR linking
|
|
* `[t](x/passwd)`: the caller's lexical check sees `docs/adr/x/passwd`, which
|
|
* LOOKS repo-internal, and this walk would then list the real external
|
|
* directory — and the "Did you mean X?" hint below is built from exactly that
|
|
* listing, so a wrong-case probe (`[t](x/PASSWD)`) would echo a real filename
|
|
* from OUTSIDE the repo into PUBLIC CI LOGS on a fork PR. So every segment is
|
|
* lstat'd, and a symlink is realpath'd and re-checked against the REAL root,
|
|
* BEFORE this walk ever descends into or reads what it points at.
|
|
*
|
|
* `dirCache` is a Map<directory, Set<entryName>|null> (null = unreadable),
|
|
* built and owned by the caller so repeated links into the same directory
|
|
* cost one `readdirSync` total, not one per link.
|
|
*
|
|
* `realRoot` is `fs.realpathSync(ROOT)`, computed ONCE by the caller (never
|
|
* per-segment/per-link here) and passed in — ROOT itself may sit under a
|
|
* symlinked path (macOS's `/var` -> `/private/var`), so comparing a
|
|
* realpath'd descendant against a non-realpath'd ROOT would misclassify every
|
|
* legitimate path on such a host as an escape.
|
|
*
|
|
* Returns `{ exists, hint, escaped }`. `escaped: true` means a symlink
|
|
* resolved outside `realRoot`; in that case `exists` is `false` and `hint` is
|
|
* ALWAYS `null` — the caller must report a distinct "escapes" message and
|
|
* never fall back to the generic "does not resolve" wording or a hint, both
|
|
* of which would leak into the escape's own disclosure hazard.
|
|
*/
|
|
function existsCaseExact(abs, dirCache, realRoot) {
|
|
const rel = path.relative(ROOT, abs);
|
|
if (rel === '') return { exists: true, hint: null, escaped: false }; // ROOT itself
|
|
|
|
const segments = rel.split(path.sep);
|
|
let dir = ROOT;
|
|
for (const seg of segments) {
|
|
let entries = dirCache.get(dir);
|
|
if (entries === undefined) {
|
|
try {
|
|
entries = new Set(fs.readdirSync(dir));
|
|
} catch {
|
|
entries = null;
|
|
}
|
|
dirCache.set(dir, entries);
|
|
}
|
|
if (!entries || !entries.has(seg)) {
|
|
const hint = entries ? [...entries].find((e) => e.toLowerCase() === seg.toLowerCase()) : null;
|
|
return { exists: false, hint: hint || null, escaped: false };
|
|
}
|
|
|
|
const joined = path.join(dir, seg);
|
|
|
|
// `entries.has(seg)` above proved a directory ENTRY named `seg` exists —
|
|
// it says nothing about what that entry IS. Check before descending.
|
|
let lst;
|
|
try {
|
|
lst = fs.lstatSync(joined);
|
|
} catch {
|
|
// Vanished between readdir and lstat (TOCTOU race) — degrade to "does
|
|
// not resolve", never throw.
|
|
return { exists: false, hint: null, escaped: false };
|
|
}
|
|
|
|
if (lst.isSymbolicLink()) {
|
|
let real;
|
|
try {
|
|
real = fs.realpathSync(joined);
|
|
} catch {
|
|
// Broken symlink — degrade to "does not resolve", never throw.
|
|
return { exists: false, hint: null, escaped: false };
|
|
}
|
|
if (escapesRoot(real, realRoot)) {
|
|
// No further readdirSync down this path, and no hint: both would
|
|
// disclose facts about a directory outside the repo.
|
|
return { exists: false, hint: null, escaped: true };
|
|
}
|
|
dir = real; // resolves inside the repo — continue the walk from there.
|
|
continue;
|
|
}
|
|
|
|
dir = joined;
|
|
}
|
|
return { exists: true, hint: null, escaped: false };
|
|
}
|
|
|
|
/**
|
|
* The link-resolution pass: every inline link/image target in every `*.md`
|
|
* file directly under `docs/adr/` — INCLUDING README.md (the generated index
|
|
* can point nowhere too) and files that fail the naming convention (their
|
|
* naming violation is reported separately by `partitionAdrFiles`, but a
|
|
* reader still follows their links). Non-recursive, matching `adrFiles()`.
|
|
*
|
|
* Errors are reported through the same `add(file, msg)` channel `validate`
|
|
* uses elsewhere, keeping the `${file}: ${msg}` prefix uniform — but the
|
|
* "file" half of that prefix is `${file}:${line}` here, so the emitted line
|
|
* reads `<file>:<line>: <prose>` (a literal colon immediately before the line
|
|
* number, compiler-diagnostic style) rather than `<file>: <line>: <prose>`.
|
|
*/
|
|
function validateLinks(add) {
|
|
const files = markdownFilesInAdrDir();
|
|
|
|
// Computed ONCE per pass, never per-link/per-dirent: see existsCaseExact's
|
|
// doc comment for why comparing against the REAL root (not the lexical
|
|
// ROOT constant) is required to avoid false escapes when the repo checkout
|
|
// itself sits under a symlinked ancestor (e.g. macOS's /var -> /private/var).
|
|
const realRoot = realRootOrFallback();
|
|
|
|
// Report any `*.md` dirent that `markdownFilesInAdrDir` silently excluded —
|
|
// because it could not be stat'd (e.g. a broken symlink) OR because it IS a
|
|
// symlink that resolves outside the repository — so it surfaces as a gate
|
|
// finding instead of quietly vanishing from the index. Reading the
|
|
// directory again here (rather than threading a second return value
|
|
// through `markdownFilesInAdrDir`) keeps that function's contract
|
|
// (`string[]`) simple for its other callers. Wrapped in try/catch for the
|
|
// same reason as inside `markdownFilesInAdrDir`: an unreadable ADR_DIR
|
|
// degrades to "nothing more to report" here, never a crash.
|
|
let dirents;
|
|
try {
|
|
dirents = fs.readdirSync(ADR_DIR);
|
|
} catch {
|
|
dirents = [];
|
|
}
|
|
const included = new Set(files);
|
|
const BROKEN_MSG = 'could not be read (broken symlink?) and was excluded from the index. Remove it or fix its target.';
|
|
for (const f of dirents) {
|
|
if (!f.endsWith('.md') || included.has(f)) continue;
|
|
const joined = path.join(ADR_DIR, f);
|
|
let lst;
|
|
try {
|
|
lst = fs.lstatSync(joined);
|
|
} catch {
|
|
add(f, BROKEN_MSG, { reason: REASON.DIRENT_UNREADABLE, line: null });
|
|
continue;
|
|
}
|
|
if (!lst.isSymbolicLink()) {
|
|
// Not a symlink and still excluded — some other legitimate reason
|
|
// (e.g. it's a directory literally named `*.md`), not unreadable.
|
|
continue;
|
|
}
|
|
let real;
|
|
try {
|
|
real = fs.realpathSync(joined);
|
|
} catch {
|
|
add(f, BROKEN_MSG, { reason: REASON.DIRENT_UNREADABLE, line: null });
|
|
continue;
|
|
}
|
|
if (escapesRoot(real, realRoot)) {
|
|
// Distinct message from the broken-symlink one above, and — same
|
|
// discipline as the link-target escape below — no path or hint from
|
|
// outside the repo is ever included: only the in-repo dirent name.
|
|
add(
|
|
f,
|
|
'is a symlink that escapes the repository and was excluded from the index. Point it at a file inside docs/adr/, or remove it.',
|
|
{ reason: REASON.DIRENT_ESCAPES_REPO_SYMLINK, line: null },
|
|
);
|
|
continue;
|
|
}
|
|
// Resolves inside the repo but is not a regular file (e.g. a symlink to
|
|
// a directory) — a legitimate exclusion, not a disclosure hazard.
|
|
}
|
|
|
|
const dirCache = new Map();
|
|
|
|
for (const file of files) {
|
|
const text = fs.readFileSync(path.join(ADR_DIR, file), 'utf8');
|
|
|
|
for (const { line, target: rawTarget } of extractLinks(text)) {
|
|
let t = String(rawTarget).trim();
|
|
|
|
if (t.startsWith('<') && t.endsWith('>')) {
|
|
t = t.slice(1, -1).trim();
|
|
} else {
|
|
// A link title: `dest "Title"` / `dest 'Title'`. Drop it, keep dest.
|
|
const titled = t.match(/^(\S+)\s+(?:"[^"]*"|'[^']*')$/);
|
|
if (titled) t = titled[1];
|
|
}
|
|
|
|
if (t === '' || t.startsWith('#') || t.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(t)) {
|
|
continue; // empty, same-document anchor, protocol-relative, or any URI scheme — out of scope
|
|
}
|
|
|
|
t = t.split('#')[0];
|
|
if (t === '') continue; // was only a fragment
|
|
|
|
try {
|
|
t = decodeURIComponent(t);
|
|
} catch {
|
|
// Malformed escape (e.g. "%zz"): resolve the raw, non-decoded text
|
|
// rather than throwing — an unresolvable literal is still reportable.
|
|
}
|
|
|
|
const abs = t.startsWith('/') ? path.resolve(ROOT, t.slice(1)) : path.resolve(ADR_DIR, t);
|
|
|
|
// Containment BEFORE any filesystem call: never `stat` outside ROOT.
|
|
const rel = path.relative(ROOT, abs);
|
|
if (escapesRoot(abs, ROOT)) {
|
|
add(`${file}:${line}`, `link "${rawTarget}" escapes the repository. Link a path inside the repo.`, {
|
|
reason: REASON.LINK_ESCAPES_REPO,
|
|
file,
|
|
line,
|
|
target: rawTarget,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const { exists, hint, escaped } = existsCaseExact(abs, dirCache, realRoot);
|
|
if (escaped) {
|
|
// A symlink under the (lexically in-repo) target path resolves
|
|
// outside the repository. Distinct message from the generic
|
|
// "does not resolve" below, and — deliberately — no hint: the hint
|
|
// itself would be the disclosure (see existsCaseExact's doc comment).
|
|
add(`${file}:${line}`, `link "${rawTarget}" escapes the repository via a symlink. Link a path inside the repo.`, {
|
|
reason: REASON.LINK_ESCAPES_REPO_SYMLINK,
|
|
file,
|
|
line,
|
|
target: rawTarget,
|
|
});
|
|
continue;
|
|
}
|
|
if (!exists) {
|
|
const relFromRoot = rel.split(path.sep).join('/');
|
|
const hintSuffix = hint ? ` Did you mean ${hint}? — link targets are case-sensitive on github.com.` : '';
|
|
add(
|
|
`${file}:${line}`,
|
|
`link "${rawTarget}" does not resolve — no such file or directory at ${relFromRoot}.${hintSuffix}`,
|
|
{ reason: REASON.LINK_UNRESOLVED, file, line, target: rawTarget, resolved: relFromRoot },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseAdr(file) {
|
|
const full = path.join(ADR_DIR, file);
|
|
const text = fs.readFileSync(full, 'utf8');
|
|
const lines = text.split(/\r?\n/);
|
|
|
|
// `fileId` is the numeric identity used for comparison ("0011" === "11");
|
|
// `displayId` preserves the filename's prefix exactly as written, because the
|
|
// corpus and its cross-references say "ADR-0001" and "ADR-58", not "ADR-1".
|
|
const rawId = file.match(/^([0-9]+)-/)[1];
|
|
const fileId = canonicalId(rawId);
|
|
const displayId = rawId;
|
|
|
|
const h1 = (lines.find((l) => /^#\s/.test(l)) || '').replace(/^#\s+/, '').trim();
|
|
|
|
// Capture the trailing status bracket against the RAW h1, before the title
|
|
// strip below discards it. This is the value the H1-vs-Status comparison in
|
|
// `validate` checks against — the strip alone throws the information away.
|
|
const bracketMatch = h1.match(STATUS_BRACKET_RE);
|
|
const bracketStatus = bracketMatch ? bracketMatch[1] : null;
|
|
|
|
// Title as displayed: drop a leading "ADR-123 — " / "ADR-123: " prefix and a
|
|
// trailing "[Proposed]"-style status bracket, both of which the index renders
|
|
// from structured fields instead.
|
|
const title = h1
|
|
.replace(/^ADR[-\s]?0*\d+\s*(?:[—:-]\s*)?/i, '')
|
|
.replace(STATUS_BRACKET_RE, '')
|
|
.trim();
|
|
|
|
const declaredIdMatch = h1.match(/^ADR[-\s]?0*(\d+)\b/i);
|
|
const declaredId = declaredIdMatch ? canonicalId(declaredIdMatch[1]) : null;
|
|
|
|
const header = headerBlock(text);
|
|
|
|
let statusRaw = null;
|
|
// relations[kind][dir] = [{field, value, links, bare}]
|
|
const relations = { supersedes: { out: [], in: [] }, subsumes: { out: [], in: [] } };
|
|
|
|
for (const [field, value] of headerFields(header)) {
|
|
if (field.toLowerCase() === 'status') {
|
|
if (statusRaw === null) statusRaw = value;
|
|
continue;
|
|
}
|
|
// "Supersedes (generalizes)" / "Subsumes as adapters" → "supersedes" / "subsumes"
|
|
const key = field.toLowerCase().replace(/\s*\([^)]*\)\s*/g, ' ').replace(/\s+as\s+.*$/, '').trim();
|
|
const spec = RELATION_FIELDS.get(key);
|
|
if (!spec) continue;
|
|
// "Supersedes: nothing; amends the ADR-1239 harness" asserts NO relation. Such a
|
|
// field routinely name-drops other ADRs in its prose ("related", "amends", "builds
|
|
// on"); reading those as supersession claims invents links that were never made.
|
|
if (NEGATED_RELATION_RE.test(value)) continue;
|
|
relations[spec.kind][spec.dir].push({ field, value, links: linkedAdrFiles(value), bare: bareAdrRefs(value) });
|
|
}
|
|
|
|
const statusToken = statusRaw ? (statusRaw.match(/^([A-Za-z]+)/) || [])[1] : null;
|
|
|
|
// A "Superseded by X" written into the Status line itself is the relation.
|
|
if (statusRaw && /^Superseded\b/i.test(statusRaw)) {
|
|
relations.supersedes.in.push({ field: 'Status', value: statusRaw, links: linkedAdrFiles(statusRaw), bare: bareAdrRefs(statusRaw) });
|
|
}
|
|
|
|
// `## Supersedes` / `## Subsumes` sections count as OUT claims (ADR-0174's table).
|
|
const sections = relationSections(text);
|
|
for (const kind of ['supersedes', 'subsumes']) {
|
|
if (sections[kind].length === 0) continue;
|
|
relations[kind].out.push({ field: `## ${kind === 'supersedes' ? 'Supersedes' : 'Subsumes'} section`, value: '', links: sections[kind], bare: [] });
|
|
}
|
|
|
|
return { file, fileId, displayId, title, declaredId, statusRaw, statusToken, bracketStatus, relations, text };
|
|
}
|
|
|
|
function buildCorpus() {
|
|
const { conforming, nonConforming } = partitionAdrFiles();
|
|
const adrs = conforming.map(parseAdr);
|
|
const byFile = new Map(adrs.map((a) => [a.file, a]));
|
|
const byId = new Map();
|
|
for (const a of adrs) {
|
|
if (!byId.has(a.fileId)) byId.set(a.fileId, []);
|
|
byId.get(a.fileId).push(a);
|
|
}
|
|
return { adrs, byFile, byId, nonConforming };
|
|
}
|
|
|
|
function validate({ adrs, byFile, byId, nonConforming }) {
|
|
const errors = [];
|
|
const violations = [];
|
|
// `record` carries the STRUCTURED half of every violation — `reason` plus
|
|
// whatever typed fields a `--json` consumer needs (line, target, resolved,
|
|
// expected/actual, status, …) — kept alongside, never instead of, the
|
|
// human `${file}: ${msg}` line: a large pre-existing test suite asserts on
|
|
// that string verbatim and is out of scope to migrate. `record` may supply
|
|
// its own `file` (spread AFTER the outer `file`) so link-violation records
|
|
// carry the plain filename as a field distinct from the human message's
|
|
// `<file>:<line>` prefix string.
|
|
const add = (file, msg, record) => {
|
|
errors.push(`${file}: ${msg}`);
|
|
violations.push({ file, ...record });
|
|
};
|
|
|
|
for (const f of nonConforming) {
|
|
add(
|
|
f,
|
|
'filename does not match the `<issue#>-<kebab-slug>.md` convention, so it cannot appear in the index. ' +
|
|
'Rename it (see docs/adr/README.md "Naming Convention"), or move it out of docs/adr/ if it is not an ADR.',
|
|
{ reason: REASON.FILENAME_INVALID, line: null },
|
|
);
|
|
}
|
|
|
|
for (const a of adrs) {
|
|
if (!a.statusToken) {
|
|
add(a.file, 'no `- **Status:** <Token>` field found in the header block.', {
|
|
reason: REASON.STATUS_MISSING,
|
|
line: null,
|
|
});
|
|
continue;
|
|
}
|
|
if (!STATUSES.includes(a.statusToken)) {
|
|
add(a.file, `status "${a.statusToken}" is not one of ${STATUSES.join(' | ')} (full line: "${a.statusRaw}").`, {
|
|
reason: REASON.STATUS_INVALID,
|
|
line: null,
|
|
status: a.statusToken,
|
|
});
|
|
} else if (
|
|
// Only compare when the status token is itself valid — an already-invalid
|
|
// token gets its own report above, and piling a bracket-disagreement
|
|
// message on top of it would be a second complaint about the same defect.
|
|
a.bracketStatus &&
|
|
a.bracketStatus.toLowerCase() !== a.statusToken.toLowerCase()
|
|
) {
|
|
add(
|
|
a.file,
|
|
`H1 status bracket [${a.bracketStatus}] contradicts the Status field (${a.statusToken}). ` +
|
|
'Update the H1 bracket (or the Status field) so they agree — a stale bracket is the first thing a reader sees.',
|
|
{ reason: REASON.STATUS_BRACKET_MISMATCH, line: null, expected: a.statusToken, actual: a.bracketStatus },
|
|
);
|
|
}
|
|
if (a.declaredId && a.declaredId !== a.fileId) {
|
|
add(a.file, `H1 declares ADR-${a.declaredId} but the filename says ${a.fileId}. The id must match the filename.`, {
|
|
reason: REASON.ID_MISMATCH,
|
|
line: null,
|
|
expected: a.fileId,
|
|
actual: a.declaredId,
|
|
});
|
|
}
|
|
|
|
// A Superseded ADR must point at its successor by FILE LINK.
|
|
if (a.statusToken === 'Superseded') {
|
|
const links = a.relations.supersedes.in.flatMap((r) => r.links);
|
|
if (links.length === 0) {
|
|
const bare = a.relations.supersedes.in.flatMap((r) => r.bare);
|
|
if (bare.length) {
|
|
add(
|
|
a.file,
|
|
`status is Superseded and mentions ADR-${bare.join('/')} but not as a markdown link to the file. ` +
|
|
'Bare ids are ambiguous (ADR-0010 and ADR-0011 each resolve to multiple files) — link the target file.',
|
|
{ reason: REASON.SUPERSEDED_BARE_ID, line: null, bare },
|
|
);
|
|
} else {
|
|
add(a.file, 'status is Superseded but names no successor. Write `Superseded by [ADR-N](N-slug.md)`.', {
|
|
reason: REASON.SUPERSEDED_NO_SUCCESSOR,
|
|
line: null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every relation link must resolve; every bare id must be linked (and exist).
|
|
for (const kind of Object.keys(RELATION_SPEC)) {
|
|
for (const dir of ['out', 'in']) {
|
|
for (const rel of a.relations[kind][dir]) {
|
|
for (const l of rel.links) {
|
|
if (!byFile.has(l)) {
|
|
add(a.file, `"${rel.field}" links "${l}", which does not exist in docs/adr/.`, {
|
|
reason: REASON.RELATION_LINK_MISSING,
|
|
line: null,
|
|
field: rel.field,
|
|
target: l,
|
|
});
|
|
}
|
|
}
|
|
// The synthetic relation lifted out of the Status line is already covered by
|
|
// the dedicated Superseded check above; reporting it again just duplicates.
|
|
if (rel.field === 'Status') continue;
|
|
// Ids already linked ANYWHERE in this field. A field legitimately
|
|
// reads "…([ADR-58](58-x.md)) — see 'Relation to ADR-58' below": the
|
|
// trailing prose repeats an id that is linked earlier, and flagging
|
|
// that would be noise. But an id that appears ONLY bare is an
|
|
// unchecked claim — and testing `rel.links.length` instead of the
|
|
// specific id silently dropped every bare claim in a field that
|
|
// happened to carry one link.
|
|
const linkedIds = new Set(rel.links.map((l) => (byFile.get(l) || {}).fileId).filter(Boolean));
|
|
for (const b of rel.bare) {
|
|
if (linkedIds.has(b)) continue;
|
|
const candidates = byId.get(b) || [];
|
|
if (candidates.length === 0) {
|
|
add(
|
|
a.file,
|
|
`"${rel.field}" names ADR-${b}, which does not exist in docs/adr/. If it is an ISSUE number, write "#${b}" — not "ADR-${b}".`,
|
|
{ reason: REASON.RELATION_BARE_ID_MISSING, line: null, field: rel.field, target: b },
|
|
);
|
|
} else {
|
|
add(
|
|
a.file,
|
|
`"${rel.field}" names ADR-${b} without a file link` +
|
|
(candidates.length > 1 ? ` (ambiguous — resolves to ${candidates.length} files: ${candidates.map((c) => c.file).join(', ')})` : '') +
|
|
'. Link the target file so the relation is checkable.',
|
|
{
|
|
reason: REASON.RELATION_BARE_ID_UNLINKED,
|
|
line: null,
|
|
field: rel.field,
|
|
target: b,
|
|
candidates: candidates.map((c) => c.file),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Symmetry, per relation kind: A -out-> B <=> B -in-> A.
|
|
//
|
|
// Only a RATIFIED (Accepted) claimant is owed the back-link. A Proposed ADR's
|
|
// supersession claim is prospective — it has not taken effect, so stamping its
|
|
// target as superseded would assert something untrue. When such an ADR is
|
|
// ratified to Accepted, this check starts demanding the back-links at exactly
|
|
// the right moment — as ADR-857 shows: it was Proposed when this guard was
|
|
// written, was ratified to Accepted on 2026-07-17 (its claim over ADR-0011 /
|
|
// ADR-58 restated as "Subsumes", since both remain Accepted and live), and
|
|
// both targets now carry the reciprocal "Subsumed by" back-link this demands.
|
|
const OPPOSITE = { out: 'in', in: 'out' };
|
|
for (const a of adrs) {
|
|
for (const kind of Object.keys(RELATION_SPEC)) {
|
|
for (const dir of ['out', 'in']) {
|
|
// The ratification guard applies to the OUT direction only: an unratified
|
|
// ADR's claim over someone else is prospective and must not obligate the
|
|
// target. The IN direction is this ADR's statement about ITSELF ("I am
|
|
// superseded by X") and is always owed a reciprocal — guarding it too
|
|
// would skip every Superseded ADR (statusToken !== 'Accepted') and leave
|
|
// dangling one-way claims unchecked, which is the bug this gate exists
|
|
// to catch.
|
|
if (dir === 'out' && a.statusToken !== 'Accepted') continue;
|
|
for (const target of new Set(a.relations[kind][dir].flatMap((r) => r.links))) {
|
|
const b = byFile.get(target);
|
|
if (!b) continue;
|
|
const back = new Set(b.relations[kind][OPPOSITE[dir]].flatMap((r) => r.links));
|
|
if (back.has(a.file)) continue;
|
|
const needed = RELATION_SPEC[kind][OPPOSITE[dir]];
|
|
const claim = dir === 'out' ? `it ${kind} this ADR` : `it is ${kind === 'supersedes' ? 'superseded' : 'subsumed'} by this ADR`;
|
|
add(
|
|
target,
|
|
`${a.file} declares ${claim}, but this ADR does not record it. ` +
|
|
`Add \`- **${needed}:** [ADR-${a.displayId}](${a.file})\` so a reader of THIS file learns the decision moved on.`,
|
|
{ reason: REASON.RELATION_ASYMMETRIC, line: null, source: a.file, kind, neededField: needed },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Link resolution reads the directory directly rather than the parsed
|
|
// `adrs` list — it must ALSO cover README.md and naming-violation files,
|
|
// neither of which is in `adrs` (see `validateLinks`'s own doc comment).
|
|
validateLinks(add);
|
|
|
|
return { errors, violations };
|
|
}
|
|
|
|
const GROUPS = [
|
|
{
|
|
heading: 'Active decisions',
|
|
blurb: 'These govern the system as it stands. Cite these.',
|
|
match: (a) => a.statusToken === 'Accepted',
|
|
},
|
|
{
|
|
heading: 'Proposed',
|
|
blurb: 'Decided in principle, not yet ratified. Do not cite as settled architecture.',
|
|
match: (a) => a.statusToken === 'Proposed',
|
|
},
|
|
{
|
|
heading: 'Superseded, Retired, and Legacy',
|
|
blurb: 'Historical record. **Do not follow these** — each names what replaced it, or why it was retired.',
|
|
match: (a) => ['Superseded', 'Retired', 'Legacy'].includes(a.statusToken),
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Render ADR-authored text (a title) into a markdown table cell.
|
|
*
|
|
* Three hazards, all from text this script does not control:
|
|
* - `|` would split the cell and corrupt the row.
|
|
* - An HTML comment would be emitted verbatim into README.md. A title
|
|
* containing the END marker relocates it, so the NEXT `--write` splices
|
|
* against the wrong boundary and silently eats the rest of the file.
|
|
* Escaping `<`/`>` makes a comment sequence unformable, which also blocks
|
|
* any other HTML injected through a title.
|
|
* - A backslash is markdown's own escape character, so it MUST be escaped
|
|
* first. Escaping `|` → `\|` without it turns the input `\|` into `\\|`,
|
|
* which markdown reads as a literal backslash followed by an UNESCAPED
|
|
* pipe — re-opening the cell break the pipe escape exists to prevent.
|
|
* Order is load-bearing: backslash first, then everything that emits one.
|
|
*/
|
|
function cellText(text) {
|
|
return String(text)
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/\|/g, '\\|')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\r?\n/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function linkCell(files, byFile) {
|
|
if (files.length === 0) return '—';
|
|
return files.map((l) => `[ADR-${(byFile.get(l) || {}).displayId || '?'}](${l})`).join(', ');
|
|
}
|
|
|
|
function renderIndex(corpus) {
|
|
const { byFile } = corpus;
|
|
const out = [START_MARKER, ''];
|
|
|
|
for (const g of GROUPS) {
|
|
const rows = corpus.adrs.filter(g.match).sort((x, y) => Number(x.fileId) - Number(y.fileId));
|
|
if (rows.length === 0) continue;
|
|
|
|
// No row count in the heading: it is a numeric cell inside the generated
|
|
// region, shared by every ADR-adding PR. Two PRs that add different ADRs
|
|
// touch different table rows and merge cleanly — but both rewrite this
|
|
// same count line, so whichever lands second gets a stale local --check
|
|
// pass and a red CI --check against the merged tree (#3251). Same failure
|
|
// mode CHANGELOG.md and drift-acks already solved by moving to per-PR
|
|
// fragment files (.changeset/, tests/emitted-drift-acks/); here the fix is
|
|
// simpler still — the count carries no verification value (--check
|
|
// regenerates and diffs the whole region regardless) and is trivially
|
|
// derivable by counting the table rows. Do not add it back.
|
|
out.push(`### ${g.heading}`, '', g.blurb, '');
|
|
const isHistorical = g.heading.startsWith('Superseded');
|
|
// "Read first" points at the broader ADR that now frames this one. It is how a
|
|
// reader of a still-Accepted component decision (e.g. the runtime descriptor)
|
|
// discovers the wider decision that reframed it (e.g. EoS) instead of assuming
|
|
// the component IS the architecture.
|
|
out.push(
|
|
isHistorical ? '| ADR | Title | Status | Replaced by |' : '| ADR | Title | Status | Read first |',
|
|
isHistorical ? '|-----|-------|--------|-------------|' : '|-----|-------|--------|------------|',
|
|
);
|
|
for (const a of rows) {
|
|
const cells = [`[ADR-${a.displayId}](${a.file})`, cellText(a.title), a.statusToken];
|
|
cells.push(
|
|
isHistorical
|
|
? linkCell([...new Set(a.relations.supersedes.in.flatMap((r) => r.links))], byFile)
|
|
: linkCell([...new Set(a.relations.subsumes.in.flatMap((r) => r.links))], byFile),
|
|
);
|
|
out.push(`| ${cells.join(' | ')} |`);
|
|
}
|
|
out.push('');
|
|
}
|
|
|
|
// No total ADR count either, for the same reason as the per-group heading
|
|
// count above: it is a second shared mutable cell in the generated region
|
|
// that every ADR-adding PR would rewrite, guaranteeing the identical merge
|
|
// race (#3251). Leave it out; the count is derivable by reading the table.
|
|
out.push(
|
|
`_Generated by \`scripts/gen-adr-index.cjs\` — run \`--write\` after adding or restatusing an ADR._`,
|
|
'',
|
|
END_MARKER,
|
|
);
|
|
return out.join('\n');
|
|
}
|
|
|
|
function spliceIntoReadme(readme, index) {
|
|
const start = readme.indexOf(START_MARKER);
|
|
const end = readme.indexOf(END_MARKER);
|
|
if (start === -1 || end === -1) {
|
|
throw new ExitError(
|
|
1,
|
|
`docs/adr/README.md is missing the index markers.\nExpected:\n ${START_MARKER}\n ${END_MARKER}\n`,
|
|
);
|
|
}
|
|
return readme.slice(0, start) + index + readme.slice(end + END_MARKER.length);
|
|
}
|
|
|
|
/**
|
|
* Parse CLI flags from `argv` (already sliced to just the flags, i.e.
|
|
* `process.argv.slice(2)`). Supports `--write`, `--check`, `--json` in any
|
|
* order. FAIL-CLOSED on an unrecognized flag: silently falling through to
|
|
* the no-flags "print the index" behavior would mask a typo (e.g.
|
|
* `--jsno`) as a clean run instead of failing loudly, so any argument that
|
|
* is not one of the three recognized flags throws `ExitError(1, …)` naming
|
|
* the offender rather than being ignored.
|
|
*/
|
|
function parseArgs(argv) {
|
|
const opts = { write: false, check: false, json: false };
|
|
for (const arg of argv) {
|
|
if (arg === '--write') opts.write = true;
|
|
else if (arg === '--check') opts.check = true;
|
|
else if (arg === '--json') opts.json = true;
|
|
else throw new ExitError(1, `unknown flag: ${arg}\nRecognized flags: --write, --check, --json.`);
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
function main() {
|
|
const { write, check, json } = parseArgs(process.argv.slice(2));
|
|
|
|
const corpus = buildCorpus();
|
|
const { errors, violations } = validate(corpus);
|
|
const index = renderIndex(corpus);
|
|
|
|
if (json) {
|
|
// `--json` implies `--check` semantics (`--check --json` is identical to
|
|
// `--json` alone) but emits a single JSON document to stdout instead of
|
|
// the human stderr report, and writes nothing to stderr at all. Unlike
|
|
// the human `--check` path below — which short-circuits on lifecycle
|
|
// violations and never even reads README.md to check staleness — the
|
|
// JSON report always computes BOTH facts (`violations` and
|
|
// `indexStale`) independently, since a consumer parsing the document
|
|
// needs the complete picture in one shot rather than one violation
|
|
// class masking the other.
|
|
const readme = fs.readFileSync(README_PATH, 'utf8');
|
|
const expected = spliceIntoReadme(readme, index);
|
|
const indexStale = expected !== readme;
|
|
const ok = violations.length === 0 && !indexStale;
|
|
process.stdout.write(JSON.stringify({ ok, adrCount: corpus.adrs.length, indexStale, violations }) + '\n');
|
|
return ok ? 0 : 1;
|
|
}
|
|
|
|
if (errors.length > 0 && !write) {
|
|
process.stderr.write(
|
|
`docs/adr/ has ${errors.length} lifecycle violation(s).\n` +
|
|
'See docs/adr/README.md "Lifecycle rules" for the contract.\n\n',
|
|
);
|
|
for (const e of errors) process.stderr.write(` ✗ ${e}\n`);
|
|
process.stderr.write('\n');
|
|
throw new ExitError(1);
|
|
}
|
|
|
|
// `--write` takes precedence over a co-supplied `--check`: neither
|
|
// combination is part of this CLI's documented contract (the flags exist
|
|
// to be used one at a time, or as `--check --json`), so this is an
|
|
// arbitrary-but-safe tiebreak rather than a specified behavior.
|
|
if (write) {
|
|
const readme = fs.readFileSync(README_PATH, 'utf8');
|
|
fs.writeFileSync(README_PATH, spliceIntoReadme(readme, index));
|
|
process.stdout.write(`Wrote ADR index into ${README_PATH} (${corpus.adrs.length} ADRs).\n`);
|
|
if (errors.length > 0) {
|
|
process.stderr.write(`\n${errors.length} lifecycle violation(s) remain — --check will fail:\n\n`);
|
|
for (const e of errors) process.stderr.write(` ✗ ${e}\n`);
|
|
}
|
|
} else if (check) {
|
|
const readme = fs.readFileSync(README_PATH, 'utf8');
|
|
const expected = spliceIntoReadme(readme, index);
|
|
if (expected !== readme) {
|
|
process.stderr.write(
|
|
'docs/adr/README.md index is stale. Run:\n node scripts/gen-adr-index.cjs --write\n\n',
|
|
);
|
|
throw new ExitError(1);
|
|
}
|
|
process.stdout.write(`docs/adr/README.md index is up to date (${corpus.adrs.length} ADRs).\n`);
|
|
} else {
|
|
process.stdout.write(index + '\n');
|
|
}
|
|
}
|
|
|
|
// Guarded: `require`-ing this module (the test suite imports STATUSES and
|
|
// the pure scanner directly) must not also run the generator as a side
|
|
// effect of loading it.
|
|
if (require.main === module) runMain(main);
|
|
|
|
module.exports = { STATUSES, REASON, extractLinks, maskCode };
|