Files
msd-core/scripts/check-glossary-refs.cjs
sim cd58aaabf4 refactor(#4653): drain the containment duplicates and record the two rulings
Phase 3 of epic #4636, stage 3c. ADR-4650 decision 6: a wrapper may decide HOW
to degrade, never WHETHER a path is contained. Four implementations are drained
on that rule; two are retained, with the reasons recorded rather than assumed.

DRAINED — the containment decision now comes from the canonical predicate:

  scripts/check-glossary-refs.cjs   local isWithinRoot deleted outright.
  src/installer-migrations.cts      ensureInsideConfig keeps its throw and its
                                    lexical fullPath; only the decision moves.
  src/planning-inspect.cts          isPathContained keeps must-exist as its own
                                    condition; only the decision moves.

Two of those are wrappers rather than deletions, and each is a wrapper for a
reason that would have been a silent behavior change if collapsed naively:

- `isPathContained` returns FALSE for a path that does not exist, because
  fs.realpathSync throws ENOENT and its catch swallows it. The canonical
  predicate does the opposite: for a missing target it walks up to the nearest
  existing ancestor and ACCEPTS a not-yet-created path under the root. Its
  callers at planning-inspect.cts:747 and :839 guard a phaseDir immediately
  before readdirSync, so under a naive swap a missing phaseDir would stop
  reporting scope UNREADABLE and start throwing ENOENT out of readdirSync.
  Existence is therefore kept as an explicit local requirement.

- `ensureInsideConfig` returns a LEXICAL fullPath that both callers consume for
  existsSync and for journal entries. The canonical predicate realpath-resolves,
  so if configDir is itself a symlink the two differ. The decision is canonical;
  the returned value stays lexical. Its message is likewise preserved verbatim,
  which is why this uses tryWithinRoot plus an explicit throw rather than
  assertWithinRoot.

`isWithinRoot` in planning-inspect is left in place and documented: it is a pure
comparison over paths the CALLER has already resolved, which readDocument does
inline specifically to keep a third degradation shape (exists-but-unreadable vs
absent) that neither isPathContained nor the canonical predicate expresses. It
is the comparison step of one implementation, not a second implementation.

RETAINED, DELIBERATELY — gsd-core/bin/gsd-tools.cjs. My own design document said
"collapse" and that was wrong. The file carries an explicit comment forbidding
it, and the comment is correct: its three checks reject symlinks OUTRIGHT, which
is strictly stricter than the canonical predicate, not a reimplementation of it.
The canonical predicate accepts a link whose target lands inside the root — for
a restore that is still wrong, because writing through the link overwrites
whatever it points at instead of materializing a regular file. Collapsing would
have reintroduced that hole. The comment is updated to name the current exported
predicate, to record that this was reviewed under this phase and deliberately
not collapsed, and to note that isInsideDir treats target === root as NOT
contained — the one implementation in the repo that does.

THE configHome RULING — retained lexical, and a false safety claim corrected.
isPathConfined stays lexical because two of its callers must validate a
destSubpath BEFORE the mkdirSync that creates it (install-engine.cts:1608,
install-profiles.cts:880), where realpath cannot resolve and a realpath-based
predicate would reject every legitimate install.

Its docstring's justification, however, did not survive being checked. It cited
capability-source.cts:491,577,675 as the upstream symlink rejection that made
the lexical form safe. Read directly: :491 is a blank line before assertSafeId's
JSDoc and :577 is an entry-count budget check. Neither is a symlink check. The
real guards are :585-586 and :671-674. Worse than stale line numbers, the claim
that this "keeps every caller of this function's callers symlink-safe" is false:
that rejection lives in capability-source's staging path and covers only the
capability-loader route to assertDescriptorConfined. Three other callers do not
reach it, and only retired-artifact-cleanup.cts:69 carries its own defense
(its lstatSync check at :77). The docstring now states what is actually true and
cites the lines that actually exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:29:54 -04:00

295 lines
12 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* Verifies the machine-checkable claims in CONTEXT.md against the shipped
* tree, so the glossary cannot silently re-rot the way the ADR index did
* before scripts/gen-adr-index.cjs (#2340).
*
* Unlike gen-adr-index.cjs this gate has NO `--write` — CONTEXT.md's prose is
* hand-authored, not a derived artifact this tool can regenerate. It only
* verifies.
*
* Two checks:
*
* A. File references resolve. Every backticked token in CONTEXT.md that
* looks like a file path — and whose path is inside a TRACKED_PREFIXES
* directory (or is one of the two named exact files) — must exist on
* disk. Everything else (generated bin/lib/*.cjs, `.planning/` runtime
* artifacts, `~/`- or `/`-rooted paths, bare filenames, example data
* shapes like `capability.json`) is deliberately ignored: asserting
* those would false-fail a clean checkout, which is the exact trap this
* gate exists to avoid falling into itself.
*
* B. `allRuntimes` enum parity. CONTEXT.md documents the runtime enum's
* count and member list in prose (e.g. "Runtime enum: `allRuntimes` (17
* values: claude, ...)"). This is compared against the real
* `allRuntimes` array literal in bin/install.js — both the count and the
* member set — so adding/removing a runtime without updating the prose
* is caught.
*
* Usage:
* node scripts/check-glossary-refs.cjs # print findings to stdout
* node scripts/check-glossary-refs.cjs --check # exit 1 on any finding
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const { tryWithinRoot, PathAcceptance } = require('../gsd-core/bin/lib/security.cjs');
const ROOT = path.resolve(__dirname, '..');
const CONTEXT_PATH = path.join(ROOT, 'CONTEXT.md');
const INSTALL_JS_PATH = path.join(ROOT, 'bin', 'install.js');
/**
* Directory prefixes this gate can verify against the shipped tree. A token
* outside these — most importantly `gsd-core/bin/lib/**`, which is generated
* and gitignored — is not a claim this gate can check, so it is skipped
* rather than asserted.
*/
const TRACKED_PREFIXES = [
'src/',
'tests/',
'scripts/',
'docs/',
'gsd-core/references/',
'gsd-core/workflows/',
'gsd-core/templates/',
'gsd-core/contexts/',
'.github/',
'eslint-rules/',
];
/** The only bare (no-prefix-match) tokens this gate checks by exact name. */
const TRACKED_EXACT = new Set(['bin/install.js', 'package.json']);
/**
* Paths CONTEXT.md documents whose ABSENCE is the healthy steady state.
*
* `TRACKED_PREFIXES` skips claims this gate *cannot* check. This is the narrower
* third case: a claim it must not check, because "does not exist" is the correct
* state rather than drift.
*
* `tests/emitted-drift-ack.json` is the acknowledgment file from ADR-2719 §3. Its
* whole design property is that it appears in the changed-files list ONLY when
* something rippled unexpectedly — "touching it IS the alarm". A permanently
* committed copy would signal nothing, which is exactly why the ADR rejected
* shipping an empty stub. So the file is absent on a healthy `next` and present
* only inside a PR that needs it, and asserting either way is wrong.
*
* The emitted-attribution family (`tests/fixtures/golden-install-parity`,
* `tests/golden-install-parity.test.cjs`, `scripts/gen-golden-install-parity-zcode.cjs`,
* `tests/agent-size-baseline.json`, `tests/workflow-size-baseline.json`,
* `scripts/git-merge-regen-driver.cjs`, `scripts/update-size-baseline.cjs`) was RETIRED
* by the #2724 cutover — the differential attribution check replaced the committed
* baselines and their generator/bridge tooling. CONTEXT.md's RULESET.EMITTED_ATTRIBUTION
* predicate documents that retirement ("Historically …"), so the mentions are history,
* not live claims, and their absence is exactly the healthy state the retirement
* produced. The whole documented family is listed, not just the members today's tick
* parity happens to hide — the two tooling paths were invisible only because of where
* line 585's inner backticks sat, which is the #2778 luck this gate must not rely on.
*
* `scripts/eslint-rules` appears only inside a CONTRASTIVE mention ("the local
* plugin lives at `eslint-rules/` (repo root, NOT `scripts/eslint-rules/`)") — the
* predicate asserts where the directory is NOT, so non-existence is the claim
* being made, not drift away from one.
*
* Until #2778 this set's first entry passed only by accident: CONTEXT.md's
* `RULESET.` entries are themselves backtick-wrapped and contain backticks, so the
* sequential pairing in `extractTrackedRefs` happened to leave this token outside a
* code span. Any edit that shifted the parity — such as #2778's own — exposed it.
* A gate that passes by luck is not passing; naming the exemption makes the intent
* explicit and survives the next edit. #3604 removed the luck itself (pairing is
* per line), which is what surfaced the entries above: each names a path whose
* absence is deliberate, so each is exempted by name for the same reason.
*/
const INTENTIONALLY_ABSENT = new Set([
'tests/emitted-drift-ack.json',
'tests/fixtures/golden-install-parity',
'tests/golden-install-parity.test.cjs',
'scripts/gen-golden-install-parity-zcode.cjs',
'tests/agent-size-baseline.json',
'tests/workflow-size-baseline.json',
'scripts/git-merge-regen-driver.cjs',
'scripts/update-size-baseline.cjs',
'scripts/eslint-rules',
]);
/**
* Shape a backticked token must have to even be considered a path candidate:
* one or more `/`-separated segments of word/dot/dash characters, with an
* optional trailing `:<line>` suffix. Anything else inside backticks (CLI
* invocations with spaces, function signatures with parens/commas, bare
* identifiers, env vars) is prose, not a path reference.
*/
const PATH_TOKEN_RE = /^[\w.-]+(?:\/[\w.-]+)*(?::\d+)?$/;
/** Whether `token` (line-suffix already stripped) is one this gate checks. */
function isTracked(token) {
if (INTENTIONALLY_ABSENT.has(token)) return false;
return TRACKED_EXACT.has(token) || TRACKED_PREFIXES.some((prefix) => token.startsWith(prefix));
}
/**
* Every distinct, trackable file-path token referenced in `text`, with any
* trailing `:<line>` suffix stripped.
*
* #3604: pairing is per LINE, not over the whole text. A single whole-text pass
* `[^`]+` crosses newlines, so one odd-backtick line (the `RULESET.*` predicate
* format is backtick-wrapped and its values sometimes contain backticks) shifted
* the pairing of every later line — a tracked token's visibility depended on
* where it sat, which is how a renamed test file stayed invisible for weeks.
*
* The fact-store predicate lines (one `CLASS.subkey=value` fact per line,
* backtick-wrapped as a whole) carry REAL paths in their values; the span itself
* is not path-shaped (spaces, `=`), so a second pass harvests path-shaped tracked
* tokens from inside any predicate-shaped span rather than letting the outer
* wrapper hide them.
*
* Fragment guards: a real path in this repo never ends in `-` or `.` — those are
* remnants of glob/template mentions (`scripts/gen-*.cjs`, `tests/foo.*.test.cjs`)
* split at the `*` — and `NNNN` is the ADR filename template token
* (CONTRIBUTING's "Do not compute a next number locally"), never a real path.
*/
function extractTrackedRefs(text) {
const tokens = new Set();
const add = (raw) => {
if (!PATH_TOKEN_RE.test(raw)) return;
const token = raw.replace(/:\d+$/, '');
// Fragment guard, on the EMITTED token (after the :line strip, so
// `src/foo-:12` is judged on `src/foo-`): glob/template remnants end in `-`
// or `.`, a real path in this repo never does.
if (!/[A-Za-z0-9_]$/.test(token)) return;
if (token.includes('NNNN')) return;
if (!isTracked(token)) return;
// Containment decision is the canonical predicate's, per ADR-4650.
if (tryWithinRoot(token, ROOT, PathAcceptance.AbsoluteInsideRoot) === null) return;
tokens.add(token);
};
const subTokenRe = /[\w.-]+(?:\/[\w.-]+)*/g;
for (const line of text.split(/\r?\n/)) {
const re = /`([^`]+)`/g;
let m;
while ((m = re.exec(line)) !== null) {
const span = m[1];
if (PATH_TOKEN_RE.test(span)) {
add(span);
continue;
}
if (!/^[A-Z][A-Za-z0-9_.-]*=/.test(span)) continue;
for (const sub of span.matchAll(subTokenRe)) add(sub[0]);
}
}
return tokens;
}
/** Check A: every tracked reference must resolve on disk. */
function checkFileRefs(contextText) {
const tokens = [...extractTrackedRefs(contextText)].sort();
const findings = [];
for (const token of tokens) {
if (!fs.existsSync(path.join(ROOT, token))) {
findings.push(`CONTEXT.md references \`${token}\` which does not exist in the repo.`);
}
}
return { findings, checked: tokens.length };
}
/** The glossary's own claim: `Runtime enum: `allRuntimes` (N values: a, b, c)`. */
const ALLRUNTIMES_CLAIM_RE = /Runtime enum:\s*`allRuntimes`\s*\((\d+)\s*values:\s*([^)]*)\)/;
function parseClaimedRuntimes(contextText) {
const m = contextText.match(ALLRUNTIMES_CLAIM_RE);
if (!m) return null;
return {
count: Number(m[1]),
members: m[2]
.split(',')
.map((s) => s.trim())
.filter(Boolean),
};
}
/** The real `allRuntimes = [...]` array literal in bin/install.js. */
const ALLRUNTIMES_ARRAY_RE = /allRuntimes\s*=\s*\[([^\]]*)\]/;
function parseRealRuntimes(installJsText) {
const m = installJsText.match(ALLRUNTIMES_ARRAY_RE);
if (!m) return null;
return [...m[1].matchAll(/'([^']+)'/g)].map((mm) => mm[1]);
}
/** Check B: CONTEXT.md's prose count + member set must match bin/install.js. */
function checkAllRuntimesParity(contextText, installJsText) {
const claimed = parseClaimedRuntimes(contextText);
if (!claimed) {
return [
'CONTEXT.md is missing the `allRuntimes` enum-count sentence ' +
'("Runtime enum: `allRuntimes` (N values: ...)") that this gate checks against bin/install.js.',
];
}
const real = parseRealRuntimes(installJsText);
if (!real) {
return ['bin/install.js does not contain a parseable `allRuntimes = [...]` array literal.'];
}
const findings = [];
if (claimed.count !== real.length) {
findings.push(
`CONTEXT.md's allRuntimes enum-count sentence claims ${claimed.count} values but bin/install.js's ` +
`allRuntimes array has ${real.length}.`,
);
}
const claimedSet = new Set(claimed.members);
const realSet = new Set(real);
const missingFromProse = real.filter((r) => !claimedSet.has(r)).sort();
const noLongerReal = claimed.members.filter((c) => !realSet.has(c)).sort();
if (missingFromProse.length > 0 || noLongerReal.length > 0) {
const parts = [];
if (missingFromProse.length > 0) parts.push(`missing from CONTEXT.md's list: ${missingFromProse.join(', ')}`);
if (noLongerReal.length > 0) parts.push(`no longer in bin/install.js's allRuntimes: ${noLongerReal.join(', ')}`);
findings.push(`CONTEXT.md's allRuntimes member list has drifted from bin/install.js (${parts.join('; ')}).`);
}
return findings;
}
function main() {
const [, , flag] = process.argv;
const contextText = fs.readFileSync(CONTEXT_PATH, 'utf8');
const installJsText = fs.readFileSync(INSTALL_JS_PATH, 'utf8');
const fileRefs = checkFileRefs(contextText);
const runtimeFindings = checkAllRuntimesParity(contextText, installJsText);
const findings = [...fileRefs.findings, ...runtimeFindings];
if (flag === '--check') {
if (findings.length > 0) {
process.stderr.write(`CONTEXT.md glossary has ${findings.length} drift finding(s).\n\n`);
for (const f of findings) process.stderr.write(` ✗ ${f}\n`);
process.stderr.write('\n');
throw new ExitError(1);
}
process.stdout.write(
`CONTEXT.md glossary references are current (${fileRefs.checked} refs checked, allRuntimes parity ok).\n`,
);
return;
}
if (findings.length === 0) {
process.stdout.write(
`CONTEXT.md glossary references are current (${fileRefs.checked} refs checked, allRuntimes parity ok).\n`,
);
} else {
process.stdout.write(`CONTEXT.md glossary has ${findings.length} drift finding(s):\n\n`);
for (const f of findings) process.stdout.write(` ✗ ${f}\n`);
}
}
runMain(main);