#!/usr/bin/env node 'use strict'; /** * gen-context-index.cjs — generates docs/CONTEXT-INDEX.json from the * predicate declarations (`` `CLASS.subkey=value` `` lines) in the * repo-root CONTEXT.md. * * Usage: * node scripts/gen-context-index.cjs # print to stdout * node scripts/gen-context-index.cjs --write # write docs/CONTEXT-INDEX.json * node scripts/gen-context-index.cjs --check # exit 1 if committed file is stale * node scripts/gen-context-index.cjs --check --json # same, + typed report on stdout * node scripts/gen-context-index.cjs --write --context-path
--index-path
* # override the two hardcoded
* # repo-root paths (tests use
* # this to point the real CLI at
* # a temp fixture tree with no fs
* # monkeypatching required)
*
* ADR-1671 ("Dynamic context management platform", #2928) Phase 1 commits 1+3.
* The generated artifact is plain JSON (docs/CONTEXT-INDEX.json), mirroring
* docs/INVENTORY-MANIFEST.json's precedent: a committed, generated,
* `--check`-guarded JSON manifest that is NOT runtime code. It previously
* lived at msd-core/bin/lib/context-index.cjs — a shipped runtime module is
* the wrong place for ~120 KB of arbitrary CONTEXT.md prose: it tripped both
* the leaked-`.claude`-path guard and
* tests/package-name-single-source.test.cjs's hardcoded-package-name guard,
* both true positives against runtime-code content scanning. Moving the
* artifact to docs/ (never scanned as runtime code) fixes both without
* weakening either guard.
*
* Depends on the COMPILED msd-core/bin/lib/context-predicates.cjs
* (src/context-predicates.cts, built by `npm run build:lib`). This is safe
* for CI: `.github/workflows/test.yml` runs `build:lib` before `lint:ci`.
*
* `--check --json` (CONTRIBUTING.md "Prohibited: Raw Text Matching on Test
* Outputs"): emits `{ ok, reason, duplicates, count, classes }` — `reason` is
* always one of the frozen `REASON` values (exported below) so tests assert
* `report.reason === REASON.FAIL_X` instead of regex-matching the prose the
* non-JSON mode still prints for human operators. The human-readable output
* is unchanged.
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const { normalizeEol } = require('../msd-core/bin/lib/text-lines.cjs');
const ROOT = path.resolve(__dirname, '..');
const CONTEXT_PREDICATES_LIB_PATH = path.join(ROOT, 'msd-core', 'bin', 'lib', 'context-predicates.cjs');
const CONTEXT_PATH = path.join(ROOT, 'CONTEXT.md');
const INDEX_PATH = path.join(ROOT, 'docs', 'CONTEXT-INDEX.json');
// ─── Typed reason enum (CONTRIBUTING.md "Prohibited: Raw Text Matching") ───────
/**
* Stable reason codes for `checkReport`'s `reason` field. Tests assert via
* `assert.equal(report.reason, REASON.X)` rather than regex-matching the
* human-readable prose the non-JSON `--check` mode still writes to
* stdout/stderr, so the diagnostic surface is a typed enum, not free text.
*
* Adding a new reason requires updating this map AND the tests' shape
* assertion that locks the documented set of codes
* (`Object.keys(REASON).sort()`).
*/
const REASON = Object.freeze({
OK_UP_TO_DATE: 'ok_up_to_date',
FAIL_STALE: 'fail_stale',
FAIL_INDEX_MISSING: 'fail_index_missing',
FAIL_INDEX_UNPARSEABLE: 'fail_index_unparseable',
FAIL_DUPLICATE_IDS: 'fail_duplicate_ids',
FAIL_CONTEXT_MISSING: 'fail_context_missing',
FAIL_CONTEXT_UNREADABLE: 'fail_context_unreadable',
FAIL_LIB_NOT_BUILT: 'fail_lib_not_built',
});
// ─── Loaders ──────────────────────────────────────────────────────────────────
/**
* Load the compiled context-predicates library. The artifact is a gitignored
* tsc build output of src/context-predicates.cts and only exists after
* `npm run build:lib`. Throws a clean ExitError (never a bare
* MODULE_NOT_FOUND stack) naming the remedy when it is missing.
*
* @returns {{ parsePredicates: Function, selectPredicates: Function, buildIndex: Function }}
*/
function loadContextPredicatesLib() {
try {
delete require.cache[require.resolve(CONTEXT_PREDICATES_LIB_PATH)];
return require(CONTEXT_PREDICATES_LIB_PATH);
} catch (err) {
throw new ExitError(
1,
`Cannot load ${path.relative(ROOT, CONTEXT_PREDICATES_LIB_PATH)}: ${err && err.message}\n` +
'Run:\n npm run build:lib\n',
);
}
}
/**
* Read a CONTEXT.md-shaped markdown file. Throws a clean ExitError naming the
* path (never a bare stack trace) when it is missing or unreadable.
*
* @param {string} [contextPath] - defaults to the real repo-root CONTEXT.md.
* @returns {string}
*/
function readContextMarkdown(contextPath = CONTEXT_PATH) {
try {
return fs.readFileSync(contextPath, 'utf8');
} catch (err) {
throw new ExitError(1, `Cannot read ${path.relative(ROOT, contextPath)}: ${err && err.message}`);
}
}
/**
* Build a fresh ContextIndex from the given CONTEXT.md-shaped content.
*
* @param {string} [contextPath] - defaults to the real repo-root CONTEXT.md.
* @returns {object}
*/
function buildFreshIndex(contextPath = CONTEXT_PATH) {
const { parsePredicates, buildIndex } = loadContextPredicatesLib();
const markdown = readContextMarkdown(contextPath);
const { predicates } = parsePredicates(markdown);
return buildIndex(predicates);
}
// ─── Serialization ────────────────────────────────────────────────────────────
/**
* Serialize a ContextIndex to the committed plain-JSON artifact text
* (docs/CONTEXT-INDEX.json). Plain JSON — not a CommonJS module — because
* this is a generated data manifest (mirroring docs/INVENTORY-MANIFEST.json),
* not runtime code: it must never be `require()`-able from a shipped
* msd-core/bin/lib/*.cjs module, which is exactly the mistake that leaked
* ~120 KB of CONTEXT.md prose (including `.claude/hooks/...` path literals
* and hardcoded package-name strings) into runtime-code content scanning.
*
* @param {object} index
* @returns {string}
*/
function serializeIndex(index) {
return JSON.stringify(index, null, 2) + '\n';
}
/**
* Extract the sorted list of duplicate predicate ids from a built index.
*
* @param {{ duplicates: Array<{ id: string, count: number }> }} index
* @returns {string[]}
*/
function duplicateIds(index) {
return index.duplicates.map((d) => d.id);
}
// ─── Typed check report ───────────────────────────────────────────────────────
/**
* Empty-report shape shared by every early-exit branch below, so callers
* (JSON mode, tests) always see the same four data fields regardless of
* which reason fired.
*
* @returns {{ duplicates: Array, count: number, classes: object }}
*/
function emptyReportFields() {
return { duplicates: [], count: 0, classes: {} };
}
/**
* Compute the full `--check` result as a typed, non-throwing report — the
* structured intermediate representation CONTRIBUTING.md's "Prohibited: Raw
* Text Matching on Test Outputs" section requires alongside the human prose
* `main()` still prints. Never throws; every failure mode is a `reason` code
* from the frozen `REASON` enum.
*
* @param {string} [contextPath] - defaults to the real repo-root CONTEXT.md.
* @param {string} [indexPath] - defaults to the real committed index.
* @returns {{ ok: boolean, reason: string, duplicates: Array<{id:string,count:number}>, count: number, classes: object, message: string }}
*/
function checkReport(contextPath = CONTEXT_PATH, indexPath = INDEX_PATH) {
let markdown;
try {
markdown = fs.readFileSync(contextPath, 'utf8');
} catch (err) {
const reason = err && err.code === 'ENOENT' ? REASON.FAIL_CONTEXT_MISSING : REASON.FAIL_CONTEXT_UNREADABLE;
return {
ok: false,
reason,
...emptyReportFields(),
message: `Cannot read ${path.relative(ROOT, contextPath)}: ${err && err.message}`,
};
}
let parsePredicates;
let buildIndex;
try {
delete require.cache[require.resolve(CONTEXT_PREDICATES_LIB_PATH)];
({ parsePredicates, buildIndex } = require(CONTEXT_PREDICATES_LIB_PATH));
} catch (err) {
return {
ok: false,
reason: REASON.FAIL_LIB_NOT_BUILT,
...emptyReportFields(),
message: `Cannot load ${path.relative(ROOT, CONTEXT_PREDICATES_LIB_PATH)}: ${err && err.message}\n` +
'Run:\n npm run build:lib\n',
};
}
const { predicates } = parsePredicates(markdown);
const live = buildIndex(predicates);
const dups = duplicateIds(live);
if (dups.length > 0) {
return {
ok: false,
reason: REASON.FAIL_DUPLICATE_IDS,
duplicates: live.duplicates,
count: live.count,
classes: live.classes,
message: 'CONTEXT.md has duplicate predicate id(s): ' + dups.join(', ') + '\n' +
'Each predicate id must be declared exactly once.\n',
};
}
if (!fs.existsSync(indexPath)) {
return {
ok: false,
reason: REASON.FAIL_INDEX_MISSING,
duplicates: live.duplicates,
count: live.count,
classes: live.classes,
message: `${path.relative(ROOT, indexPath)} does not exist. Run:\n node scripts/gen-context-index.cjs --write\n`,
};
}
let committedIndex;
try {
const committedText = fs.readFileSync(indexPath, 'utf8');
committedIndex = JSON.parse(committedText);
if (!committedIndex || typeof committedIndex !== 'object' || !Array.isArray(committedIndex.predicates)) {
throw new Error('parsed JSON does not have the expected ContextIndex shape');
}
} catch (err) {
return {
ok: false,
reason: REASON.FAIL_INDEX_UNPARSEABLE,
duplicates: live.duplicates,
count: live.count,
classes: live.classes,
message: `${path.relative(ROOT, indexPath)} is unparseable: ${err && err.message}\n` +
'Run:\n node scripts/gen-context-index.cjs --write\n',
};
}
// Comparing parsed JSON (rather than raw file text) is inherently
// CRLF-agnostic: JSON.parse treats \r\n and \n as equivalent insignificant
// whitespace between tokens, and predicate values never contain embedded
// newlines (the parser only extracts single-physical-line declarations).
if (JSON.stringify(committedIndex) !== JSON.stringify(live)) {
return {
ok: false,
reason: REASON.FAIL_STALE,
duplicates: live.duplicates,
count: live.count,
classes: live.classes,
message: `${path.relative(ROOT, indexPath)} is stale. Run:\n node scripts/gen-context-index.cjs --write\n`,
};
}
return {
ok: true,
reason: REASON.OK_UP_TO_DATE,
duplicates: live.duplicates,
count: live.count,
classes: live.classes,
message: `${path.relative(ROOT, indexPath)} is up to date.\n`,
};
}
// ─── Argument parsing ─────────────────────────────────────────────────────────
/**
* True when `value` cannot be accepted as a `--context-path`/`--index-path`
* argument: absent (no more argv), empty string, or flag-shaped (starts with
* `-`, so a dangling `--context-path` immediately followed by the NEXT flag
* is rejected rather than silently swallowing that flag as a literal path).
*
* @param {string|undefined} value
* @returns {boolean}
*/
function isMissingPathValue(value) {
return value === undefined || value === '' || value.startsWith('-');
}
/**
* Parse CLI arguments into a structured options object.
*
* `--context-path` / `--index-path` override the two hardcoded repo-root
* paths — added so tests can point the real CLI at a temp fixture tree
* directly, with no fs monkeypatching required.
*
* Two usage-error conditions (DEFECT.GEN-CONTEXT-INDEX-PARSEARGS-GATE-BYPASS,
* MAJOR review finding) collapse into `mode: 'unknown'`, the same clean,
* no-stack-trace usage-error path `main()` already uses for an unrecognized
* flag:
* (a) `--check` and `--write` given together — previously the LAST one
* seen silently won, so `--check --write` exited 0 and REWROTE the
* committed index instead of gating. Conflicting mode flags are now a
* hard usage error regardless of order.
* (b) a missing/empty/flag-shaped value for `--context-path` /
* `--index-path` — previously `path.resolve(argv[++i] ?? '')`
* resolved to the current working directory, and in `--write` mode
* that later threw an uncaught, uncleaned `EISDIR` stack trace from
* `fs.writeFileSync` (CONTRIBUTING.md: no stack trace in non-debug
* failure output). Rejected up front instead, before any I/O.
*
* @param {string[]} argv - process.argv.slice(2)
* @returns {{ mode: 'check'|'write'|'default'|'unknown', json: boolean, contextPath: string, indexPath: string, unknownArg?: string, usageMessage?: string }}
*/
function parseArgs(argv) {
const opts = { mode: 'default', json: false, contextPath: CONTEXT_PATH, indexPath: INDEX_PATH };
let sawCheck = false;
let sawWrite = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--check') {
sawCheck = true;
opts.mode = 'check';
} else if (arg === '--write') {
sawWrite = true;
opts.mode = 'write';
} else if (arg === '--json') {
opts.json = true;
} else if (arg === '--context-path' || arg === '--index-path') {
const value = argv[i + 1];
if (isMissingPathValue(value)) {
return {
...opts,
mode: 'unknown',
unknownArg: arg,
usageMessage: `${arg} requires a non-empty path argument (got ${value === undefined ? 'nothing' : JSON.stringify(value)})`,
};
}
i++;
if (arg === '--context-path') opts.contextPath = path.resolve(value);
else opts.indexPath = path.resolve(value);
} else {
opts.mode = 'unknown';
opts.unknownArg = arg;
}
}
if (sawCheck && sawWrite) {
return {
...opts,
mode: 'unknown',
usageMessage: '--check and --write are mutually exclusive',
};
}
return opts;
}
// ─── Main ─────────────────────────────────────────────────────────────────────
function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.mode === 'unknown') {
process.stderr.write('Usage: gen-context-index.cjs [--write|--check] [--json] [--context-path