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.
263 lines
11 KiB
JavaScript
263 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* lint-hooks-runtime-build-seam.cjs — every `hooks/**` file that requires a
|
|
* compiled runtime-library module (`msd-core/bin/lib/*.cjs`) must also go
|
|
* through the self-healing build seam (`msd-core/bin/ensure-runtime-build.cjs`,
|
|
* #2002) before that require can be reached (#3582).
|
|
*
|
|
* ## Why
|
|
*
|
|
* `msd-core/bin/lib/*.cjs` are compiled from `src/*.cts` by `npm run
|
|
* build:lib` (ADR-457, "build-at-publish") and are gitignored. The npm
|
|
* package builds before publishing, so the artifacts exist on that path.
|
|
* `msd-core/bin/msd-tools.cjs` calls `ensureRuntimeBuild()` before requiring
|
|
* its own `./lib` — but a plugin-marketplace / git-clone install that never
|
|
* runs `npm run build:lib` materializes the raw tree with the compiled
|
|
* `./lib` absent, and NO hook self-healed before this issue. A hook that
|
|
* requires a compiled module directly dies at module load (or first call)
|
|
* with `Cannot find module`, and — in a guard whose own catch treats "cannot
|
|
* resolve" as fail-closed — that `Cannot find module` gets misreported as an
|
|
* unrelated "could not read or resolve …configuration" denial instead of the
|
|
* seam's own actionable message (#3050 lesson: a guard that cannot verify
|
|
* must not silently misreport why).
|
|
*
|
|
* ## What this enforces
|
|
*
|
|
* For every `.js`/`.cjs` file under `hooks/` (excluding the generated
|
|
* `hooks/dist/` build-output copy, which is not source): if the file's code
|
|
* (comments stripped) contains a `require(...)` of a path that names
|
|
* `msd-core/bin/lib/<name>.cjs` (any relative-path spelling — `../msd-core/…`,
|
|
* `../../msd-core/…`, etc. — the segment `msd-core/bin/lib/` plus a trailing
|
|
* `.cjs` is the discriminator, not the exact prefix), the SAME file must ALSO
|
|
* contain BOTH:
|
|
* 1. a `require(...)` of a path naming `msd-core/bin/ensure-runtime-build.cjs`
|
|
* (the seam module itself), AND
|
|
* 2. an actual INVOCATION `ensureRuntimeBuild(` — not merely a destructuring
|
|
* import with no call, which would import the seam but never run it.
|
|
*
|
|
* File-level co-occurrence, not line-by-line ordering: this repo's hooks
|
|
* define helper functions in whatever order reads best and call them in a
|
|
* different order at runtime (e.g. `hooks/msd-cursor-subagent-start.js`'s
|
|
* `resolveFallbackIsolation` is defined textually ABOVE the single
|
|
* `ensureRuntimeBuild()` call site in `evaluateRootIsolation`, which is its
|
|
* only caller) — a strict "seam call must appear on an earlier LINE than the
|
|
* require" check would false-positive on exactly that, real, correct
|
|
* pattern. This guard is therefore a drift ratchet at the file level ("did
|
|
* the seam get wired in at all"), not a full control-flow verifier; exact
|
|
* placement (does the call precede every reaching path) is a code-review
|
|
* concern, same tradeoff every other structural drift guard in this repo
|
|
* makes (see e.g. `lint-unreachable-guard-drift.cjs`'s own per-line, not
|
|
* per-flow, scope).
|
|
*
|
|
* ## What PASSES
|
|
*
|
|
* - A hook that never requires `msd-core/bin/lib/*.cjs` at all (most of
|
|
* `hooks/`) — nothing to check.
|
|
* - A hook that requires a compiled module AND requires + calls
|
|
* `ensureRuntimeBuild()` somewhere in the same file.
|
|
* - A prose comment that mentions `msd-core/bin/lib/…` without a real,
|
|
* well-formed `require('....cjs')` call (comments are stripped before
|
|
* scanning).
|
|
*
|
|
* ## What FAILS
|
|
*
|
|
* - A hook that requires a compiled `msd-core/bin/lib/*.cjs` module but never
|
|
* requires `ensure-runtime-build.cjs`, or requires it but never calls
|
|
* `ensureRuntimeBuild(...)`.
|
|
*
|
|
* ## Known limitations (documented, not defects — read before trusting a green run)
|
|
*
|
|
* - **Literal-string matching only.** `REQUIRE_RE` matches `require(...)`
|
|
* called with a single- OR double-quoted string literal argument. A
|
|
* concatenated/computed path (`require(base + '/msd-core/bin/lib/x.cjs')`),
|
|
* a template literal (`` require(`${dir}/x.cjs`) ``), or `createRequire(...)`
|
|
* / `require.resolve` used indirectly all evade `isCompiledLibRequire` and
|
|
* `isSeamRequire` — none of them appear as a literal quoted `require(...)`
|
|
* call, so a file using one of these forms scans as "requires nothing",
|
|
* even if it genuinely needs the seam.
|
|
* - **`hooks/` only.** `scanRepo`'s `walk()` only descends `SCAN_ROOT`
|
|
* (`hooks/`, minus `hooks/dist/`). A compiled `msd-core/bin/lib/*.cjs`
|
|
* require sitting inside a NON-hooks helper module that a hook file then
|
|
* requires (directly or transitively) is invisible to this scan — the scan
|
|
* only ever reads the hook file's OWN text, never follows its require
|
|
* graph. Deliberate: this is a same-file drift ratchet ("did the hook wire
|
|
* the seam in at all"), not a whole-program static analyzer.
|
|
*
|
|
* Because of both limits, a file that passes this lint is not a
|
|
* correctness proof — it is a co-occurrence heuristic that catches the
|
|
* exact regression shape #3582 fixed (a bare literal compiled-lib require
|
|
* with no seam call anywhere in the same hook file). Anything routed around
|
|
* a literal require or around `hooks/` needs a code-review check, not this
|
|
* script, to catch a missing self-heal.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const SCAN_ROOT = 'hooks';
|
|
const SCAN_EXT = new Set(['.js', '.cjs']);
|
|
// hooks/dist/ is a generated build-output copy (scripts/build-hooks.js),
|
|
// gitignored and not source — never scanned.
|
|
const EXCLUDE_DIR_NAMES = new Set(['dist']);
|
|
|
|
// Line-based comment stripper (deliberately NOT the naive two-regex
|
|
// `/\*[\s\S]*?\*\//g` then `//` approach other tests in this repo use for
|
|
// simpler files): this module's own source comments legitimately spell
|
|
// `msd-core/bin/lib/*.cjs` (a glob) inside a `//` line, which forms a bare
|
|
// `/*` token — a whole-text block-comment regex applied naively would read
|
|
// that as an OPENING block comment and swallow everything up to the next
|
|
// unrelated `*/` anywhere later in the file, silently deleting real code
|
|
// (verified while authoring this guard: it ate the very require() line the
|
|
// guard exists to check for). Processing line-by-line and stripping a
|
|
// trailing `//` comment BEFORE ever checking that line's remainder for `/*`
|
|
// means a `/*`-shaped token inside a `//` comment is never seen at all.
|
|
function stripComments(text) {
|
|
const lines = text.split(/\r?\n/);
|
|
const out = [];
|
|
let inBlock = false;
|
|
for (const raw of lines) {
|
|
let line = raw;
|
|
if (inBlock) {
|
|
const end = line.indexOf('*/');
|
|
if (end === -1) { out.push(''); continue; }
|
|
line = line.slice(end + 2);
|
|
inBlock = false;
|
|
}
|
|
if (line.trim().startsWith('//')) { out.push(''); continue; }
|
|
// Strip a trailing `//` comment (not a `://` URL) BEFORE any `/*` check
|
|
// on the remainder — see the function header for why this ordering
|
|
// matters.
|
|
const trailing = /(^|[^:])\/\/.*$/.exec(line);
|
|
if (trailing) line = line.slice(0, trailing.index + trailing[1].length);
|
|
// Same-line and newly-opened block comments in what remains.
|
|
for (;;) {
|
|
const start = line.indexOf('/*');
|
|
if (start === -1) break;
|
|
const end = line.indexOf('*/', start + 2);
|
|
if (end !== -1) {
|
|
line = line.slice(0, start) + line.slice(end + 2);
|
|
// keep scanning from the same position in case of a second
|
|
// same-line block comment
|
|
} else {
|
|
line = line.slice(0, start);
|
|
inBlock = true;
|
|
break;
|
|
}
|
|
}
|
|
out.push(line);
|
|
}
|
|
return out.join('\n');
|
|
}
|
|
|
|
// Captures the quoted path of every require(...) call. Single bounded
|
|
// negated-class quantifier per alternative, no nesting.
|
|
const REQUIRE_RE = /require\(\s*(['"])([^'"]+)\1\s*\)/g;
|
|
|
|
function isCompiledLibRequire(requirePath) {
|
|
return requirePath.includes('msd-core/bin/lib/') && requirePath.endsWith('.cjs');
|
|
}
|
|
|
|
function isSeamRequire(requirePath) {
|
|
return requirePath.endsWith('msd-core/bin/ensure-runtime-build.cjs');
|
|
}
|
|
|
|
// An actual invocation, not merely a `{ ensureRuntimeBuild }` destructuring
|
|
// import — the identifier immediately followed by `(`.
|
|
const SEAM_CALL_RE = /\bensureRuntimeBuild\s*\(/;
|
|
|
|
/**
|
|
* Pure: scan one file's contents for the violation described above.
|
|
* Returns `{ compiledLibRequires: string[], hasSeamRequire: boolean,
|
|
* hasSeamCall: boolean }`. `compiledLibRequires` is `[]` when the file
|
|
* requires no compiled module — the caller treats that as "nothing to
|
|
* check" regardless of the other two fields.
|
|
*/
|
|
function scanFile(text) {
|
|
const code = stripComments(text);
|
|
const compiledLibRequires = [];
|
|
let hasSeamRequire = false;
|
|
let m;
|
|
REQUIRE_RE.lastIndex = 0;
|
|
while ((m = REQUIRE_RE.exec(code)) !== null) {
|
|
const requirePath = m[2];
|
|
if (isCompiledLibRequire(requirePath)) compiledLibRequires.push(requirePath);
|
|
if (isSeamRequire(requirePath)) hasSeamRequire = true;
|
|
}
|
|
const hasSeamCall = SEAM_CALL_RE.test(code);
|
|
return { compiledLibRequires, hasSeamRequire, hasSeamCall };
|
|
}
|
|
|
|
function walk(dir) {
|
|
const out = [];
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return out; // missing root is not an error here — caller decides
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory() && EXCLUDE_DIR_NAMES.has(entry.name)) continue;
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
out.push(...walk(full));
|
|
} else if (entry.isFile() && SCAN_EXT.has(path.extname(entry.name))) {
|
|
out.push(full);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Scan `root`'s hooks/ tree and return every violating file.
|
|
* @param {string} root repo root (or a fixture root for tests)
|
|
* @returns {{ file: string, compiledLibRequires: string[], missing: string[] }[]}
|
|
*/
|
|
function scanRepo(root) {
|
|
const violations = [];
|
|
for (const full of walk(path.join(root, SCAN_ROOT))) {
|
|
const text = fs.readFileSync(full, 'utf8');
|
|
const { compiledLibRequires, hasSeamRequire, hasSeamCall } = scanFile(text);
|
|
if (compiledLibRequires.length === 0) continue;
|
|
if (hasSeamRequire && hasSeamCall) continue;
|
|
const missing = [];
|
|
if (!hasSeamRequire) missing.push('require(".../msd-core/bin/ensure-runtime-build.cjs")');
|
|
if (!hasSeamCall) missing.push('a call to ensureRuntimeBuild(...)');
|
|
violations.push({
|
|
file: path.relative(root, full).split(path.sep).join('/'),
|
|
compiledLibRequires,
|
|
missing,
|
|
});
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
function main() {
|
|
const violations = scanRepo(ROOT);
|
|
if (violations.length > 0) {
|
|
const detail = violations
|
|
.map((v) => (
|
|
` ${v.file}\n` +
|
|
` requires: ${v.compiledLibRequires.join(', ')}\n` +
|
|
` missing: ${v.missing.join(' AND ')}`
|
|
))
|
|
.join('\n');
|
|
throw new ExitError(
|
|
1,
|
|
`lint-hooks-runtime-build-seam: ${violations.length} hooks/ file(s) require a compiled\n` +
|
|
`msd-core/bin/lib/*.cjs module without also self-healing via\n` +
|
|
`ensureRuntimeBuild() from msd-core/bin/ensure-runtime-build.cjs first (#3582) — on a\n` +
|
|
`plugin-marketplace/git-clone install that never ran \`npm run build:lib\`, that\n` +
|
|
`require crashes (or is silently misreported) instead of self-building:\n\n${detail}\n`,
|
|
);
|
|
}
|
|
console.log('ok lint-hooks-runtime-build-seam: every hooks/ compiled-lib require goes through ensureRuntimeBuild()');
|
|
}
|
|
|
|
module.exports = { scanFile, scanRepo, walk, stripComments, isCompiledLibRequire, isSeamRequire, SEAM_CALL_RE };
|
|
|
|
if (require.main === module) runMain(main);
|