Files
msd-core/scripts/lint-hooks-runtime-build-seam.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

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);