Files
msd-core/scripts/check-glossary-refs.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

311 lines
13 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('../msd-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 `msd-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/',
'msd-core/references/',
'msd-core/workflows/',
'msd-core/templates/',
'msd-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) {
// Maps token -> the ContainedPath tryWithinRoot returned for it. ADR-4650:
// the value that was validated for containment must be the exact value
// that gets probed later — never a path re-derived (e.g. re-joined) from
// the token, which could diverge from what was actually checked.
const tokens = new Map();
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. Carry
// the returned ContainedPath forward so checkFileRefs stats the SAME
// value that was validated, instead of re-joining `token` onto ROOT.
//
// The containment ANSWER is unchanged from the retired lexical-only
// `isWithinRoot`, but the canonical predicate resolves symlinks, so a
// rejected token is now realpath-resolved before being rejected rather
// than rejected by string comparison alone; the result is still never
// surfaced and the token is never stat'd unless it is contained.
const contained = tryWithinRoot(token, ROOT, PathAcceptance.AbsoluteInsideRoot);
if (contained === null) return;
tokens.set(token, contained);
};
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 entries = [...extractTrackedRefs(contextText)].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
const findings = [];
for (const [token, contained] of entries) {
// Stat the ContainedPath returned by tryWithinRoot — NOT a re-joined
// path.join(ROOT, token) — so the path that was validated for
// containment is the path that is probed (ADR-4650).
if (!fs.existsSync(contained)) {
findings.push(`CONTEXT.md references \`${token}\` which does not exist in the repo.`);
}
}
return { findings, checked: entries.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);