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.
311 lines
13 KiB
JavaScript
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);
|