* chore(#2387): refactor CONTEXT.md legacy content + add glossary drift gate Apply the audit-and-enforce concept from the ADR index (#2356) to CONTEXT.md: correct stale facts, and add a CI gate so the machine-verifiable claims can't silently re-rot. CONTEXT.md was entirely hand-maintained with nothing checking its claims against the shipped tree, so it had rotted. An audit against live code (Memtrace + filesystem + gh), each finding adversarially re-verified, drove 38 factual corrections + 1 surfaced by the new gate: - Dead references: Package Identity named @opengsd/get-shit-done-redux (package is @opengsd/gsd-core); Shell Command Projection named run-git/run-npm/run-tool (real exports execGit/execNpm/execTool); a partial docs/adr/1606 ref; retired sdk/ framing. - Superseded facts: allRuntimes 15 -> 17 (pi #2102, zcode); "seven nested-loader runtimes" -> five (claude reverted flat #924, antigravity flat); stacked-PR examples rebasing onto main -> next; QUOTA_SENTINELS precedence corrected to match src/agent-command-router.cts. - Drifted CONTRIBUTING.md line citations refreshed. Per CONTRIBUTING.md:179, only stale FACTS were corrected -- no maintainer intent, lesson, or opinion was rewritten, and the append-only session log is untouched except one dated in-place superseding note. The three tests that assert on CONTEXT.md content (phase6-capstone-conformance, tracer-bullet, external-job-waiting) keep all their anchors. New scripts/check-glossary-refs.cjs (--check, wired into lint:generated-sync): - Check A: every backticked file reference under a TRACKED_PREFIXES allowlist resolves on disk. Generated gsd-core/bin/lib/*.cjs (77 refs, gitignored), ~/-paths, .planning/, and bare filenames are deliberately skipped so a clean CI checkout never false-fails. - Check B: the allRuntimes count + member set in the glossary prose match bin/install.js's allRuntimes literal (drifts on every runtime addition). tests/check-glossary-refs.test.cjs covers both, including the false-positive guard that a missing bin/lib/*.cjs ref does NOT trip the gate. Closes #2387 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2387): confine glossary-gate file refs to ROOT (no `..` traversal) Pre-PR security review finding (low): extractTrackedRefs fed tokens straight to fs.existsSync(path.join(ROOT, token)), and PATH_TOKEN_RE admits `.` in a segment, so a CONTEXT.md token like `src/../../../etc/passwd` passed the `src/` prefix check and normalized to an out-of-tree absolute path — turning the doc lint into a filesystem-existence oracle on the CI host (existsSync only; CONTEXT.md is a trusted committed file, hence low severity, but a defense-in-depth gap). Add isWithinRoot() confinement in extractTrackedRefs: a token is dropped unless path.resolve(ROOT, token) stays within ROOT. A CONTEXT.md reference is always a plain in-repo path, so a `..` escape is never legitimate. Regression test asserts a `..`-bearing token is skipped and never named in output. Refs #2387 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2387): drop legacy `get-shit-done` name from a CONTEXT.md defect entry CI lint-legacy-dir-name failed: the line-928 upstream-issue re-point I applied wrote the historical provenance as "gsd-build/get-shit-done#3545", and scripts/lint-legacy-dir-name.cjs forbids the legacy `get-shit-done` name. Reword to "moved from #3545 in the predecessor repo" — same provenance, no legacy name. Caught by `npm run lint:ci` (the CI lint chain), which I had not run locally — lint:generated-sync + eslint do not include lint-legacy-dir-name. Refs #2387 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
221 lines
8.0 KiB
JavaScript
221 lines
8.0 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 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']);
|
|
|
|
/**
|
|
* 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) {
|
|
return TRACKED_EXACT.has(token) || TRACKED_PREFIXES.some((prefix) => token.startsWith(prefix));
|
|
}
|
|
|
|
/**
|
|
* True if joining `token` to ROOT stays inside ROOT. `PATH_TOKEN_RE` admits `.`
|
|
* inside a segment, so a token like `src/../../../etc/passwd` matches and (via
|
|
* the `src/` prefix) reads as "tracked" — `path.join(ROOT, token)` would then
|
|
* normalize to an out-of-tree absolute path and `fs.existsSync` would probe it,
|
|
* turning a doc lint into a filesystem-existence oracle on the CI host. A
|
|
* CONTEXT.md reference is always a plain in-repo path, so a `..` escape is never
|
|
* legitimate: confine to ROOT and drop anything that climbs out.
|
|
*/
|
|
function isWithinRoot(token) {
|
|
const resolved = path.resolve(ROOT, token);
|
|
return resolved === ROOT || resolved.startsWith(ROOT + path.sep);
|
|
}
|
|
|
|
/**
|
|
* Every distinct, trackable file-path token referenced in `text`, with any
|
|
* trailing `:<line>` suffix stripped.
|
|
*/
|
|
function extractTrackedRefs(text) {
|
|
const tokens = new Set();
|
|
const re = /`([^`]+)`/g;
|
|
let m;
|
|
while ((m = re.exec(text)) !== null) {
|
|
const raw = m[1];
|
|
if (!PATH_TOKEN_RE.test(raw)) continue;
|
|
const token = raw.replace(/:\d+$/, '');
|
|
if (!isTracked(token)) continue;
|
|
if (!isWithinRoot(token)) continue;
|
|
tokens.add(token);
|
|
}
|
|
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);
|