Files
msd-core/scripts/lint-seam-enforcement.cjs
Tom Boucher 355c943b08 enhance(#3626): make CONTEXT.md seam claims checkable via a SEAM.*.enforced-by gate (#3975)
* feat(#3626): make CONTEXT.md seam claims checkable via SEAM.*.enforced-by gate

Adds SEAM.<id>.owns / SEAM.<id>.enforced-by=lint-rule:<name>|test:<path>
predicates to CONTEXT.md, generalizing the existing WORKTREE.SEAM.* shape,
plus scripts/lint-seam-enforcement.cjs (wired into lint:ci) which fails
when a declared single-owner seam names no existing, registered
enforcement mechanism. Backs all six current module-level single-seam/
single-canonical-owner claims found in CONTEXT.md, including the Shell
Command Projection Module's Windows-binary-resolution claim via #3619's
local/no-private-binary-resolution rule.

Scope is resolves-only per maintainer decision: the gate proves an
enforcement pointer exists and is registered, not that its surface
covers every file the seam claims. See docs/adr/3626-context-md-seam-claim-gate.md.

Closes #3626

* fix(#3626): back the Package Identity Module's seam claim too

Isolated adversarial review caught a miss in the "no grandfather list"
sweep: the Package Identity Module also declares itself "Single seam
owning GSD's published-package coordinates" and already names its real
enforcement (scripts/lint-package-identity-drift.cjs). Backs it with
SEAM.package-identity.owns/enforced-by=test:tests/package-identity.test.cjs,
bringing the total to 7 backed seams. Also makes explicit, in the design
doc and ADR, that function-level "single owner" sentences inside
already-covered modules (STATE.md Document Module, etc.) are deliberately
out of scope — a seam claim is about a module's boundary, not every
function inside it.

* docs(#3626): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
2026-08-28 08:06:41 -04:00

183 lines
6.9 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* #3626: verify every CONTEXT.md `SEAM.<id>.owns=` claim carries a
* `SEAM.<id>.enforced-by=` pointer that RESOLVES — the named lint rule is
* registered in eslint.config.mjs and its source file exists, or the named
* test file exists on disk. Deliberately resolves-only (maintainer decision,
* chat, 2026-08-27): it does not verify the mechanism's surface actually
* covers the seam's files — see .gsd/phase/feat-3626-context-seam-claim-gate/40-design.md.
*
* Design: .gsd/phase/feat-3626-context-seam-claim-gate/40-design.md
* Test matrix: .gsd/phase/feat-3626-context-seam-claim-gate/50-test-matrix.md
*
* Usage:
* node scripts/lint-seam-enforcement.cjs [path-to-context-md]
*/
const fs = require('fs');
const path = require('path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.resolve(__dirname, '..');
const DEFAULT_CONTEXT_PATH = path.join(ROOT, 'CONTEXT.md');
const ESLINT_CONFIG_PATH = path.join(ROOT, 'eslint.config.mjs');
const SEAM_FACT_RE = /^`SEAM\.([A-Za-z0-9_-]+)\.(owns|enforced-by)=(.*)`\s*$/;
/**
* Extract SEAM.<id>.owns / SEAM.<id>.enforced-by facts from CONTEXT.md text.
* Any other `*.SEAM.*` line (WORKTREE.SEAM.*, PLANNING.PATH.SEAM.*, ...) does
* not match this anchored regex and is silently ignored — see design doc
* "Not-corruption / negative space".
*
* Returns { owns: Map<id,string>, enforcedBy: Map<id,string> }.
*/
function extractSeamFacts(text) {
const owns = new Map();
const enforcedBy = new Map();
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
const match = SEAM_FACT_RE.exec(line);
if (!match) continue;
const [, id, key, value] = match;
if (key === 'owns') {
owns.set(id, value);
} else {
enforcedBy.set(id, value);
}
}
return { owns, enforcedBy };
}
/**
* Parse a `lint-rule:<name>` or `test:<path>` enforcement pointer value.
* Returns { scheme: 'lint-rule'|'test'|null, target: string }.
*/
function parseEnforcementPointer(value) {
const lintMatch = /^lint-rule:(\S+)$/.exec(value);
if (lintMatch) return { scheme: 'lint-rule', target: lintMatch[1] };
const testMatch = /^test:(\S+)$/.exec(value);
if (testMatch) return { scheme: 'test', target: testMatch[1] };
return { scheme: null, target: value };
}
/**
* Pure check function — no filesystem access, everything injected, so the
* unit tests can drive every branch (dangling pointer, unrecognized scheme,
* owns-with-no-enforced-by, enforced-by-with-no-owns, ...) without touching
* disk. `deps.ruleIsRegistered(name)` / `deps.ruleFileExists(name)` /
* `deps.testFileExists(relPath)` are the three injected filesystem seams.
*
* Returns an array of finding strings; empty means clean.
*/
function checkSeamFacts({ owns, enforcedBy }, deps) {
const findings = [];
const ids = new Set([...owns.keys(), ...enforcedBy.keys()]);
for (const id of ids) {
const hasOwns = owns.has(id);
const hasEnforcedBy = enforcedBy.has(id);
if (hasOwns && !hasEnforcedBy) {
findings.push(`SEAM.${id}: declared (owns=${JSON.stringify(owns.get(id))}) but no enforced-by pointer — no enforcement pointer`);
continue;
}
if (hasEnforcedBy && !hasOwns) {
findings.push(`SEAM.${id}: has enforced-by but no matching owns claim — enforcement pointer with no ownership claim`);
continue;
}
const pointerValue = enforcedBy.get(id);
const { scheme, target } = parseEnforcementPointer(pointerValue);
if (scheme === 'lint-rule') {
if (!deps.ruleIsRegistered(target)) {
findings.push(`SEAM.${id}: enforced-by=lint-rule:${target} — dangling lint-rule pointer (not registered in eslint.config.mjs)`);
} else if (!deps.ruleFileExists(target)) {
findings.push(`SEAM.${id}: enforced-by=lint-rule:${target} — registered rule has no source file`);
}
} else if (scheme === 'test') {
if (!deps.testFileExists(target)) {
findings.push(`SEAM.${id}: enforced-by=test:${target} — dangling test-anchor pointer (file does not exist)`);
}
} else {
findings.push(`SEAM.${id}: enforced-by=${JSON.stringify(pointerValue)} — unrecognized enforcement-pointer scheme (expected lint-rule: or test:)`);
}
}
return findings.sort();
}
/**
* Parse eslint.config.mjs's localPlugin.rules map to get the set of
* registered rule names, e.g. { 'no-source-grep': ..., 'no-private-binary-resolution': ... }.
* A textual scan, not a real ESM import — this file is a static, hand-authored
* literal object (see eslint.config.mjs:35-60), and importing ESM config from
* a CJS script for one lint gate is not worth the module-system friction.
*/
function readRegisteredRuleNames(eslintConfigText) {
const rulesBlockMatch = /const localPlugin = \{\s*rules: \{([\s\S]*?)\},\s*\};/.exec(eslintConfigText);
if (!rulesBlockMatch) return new Set();
const names = new Set();
const keyRe = /'([a-z0-9-]+)':/g;
let m;
while ((m = keyRe.exec(rulesBlockMatch[1])) !== null) {
names.add(m[1]);
}
return names;
}
function main() {
const contextPath = process.argv[2] ? path.resolve(process.argv[2]) : DEFAULT_CONTEXT_PATH;
let contextText;
try {
contextText = fs.readFileSync(contextPath, 'utf8');
} catch (error) {
throw new ExitError(1, `lint-seam-enforcement: failed to read ${contextPath}: ${error.message}`);
}
let eslintConfigText = '';
try {
eslintConfigText = fs.readFileSync(ESLINT_CONFIG_PATH, 'utf8');
} catch {
eslintConfigText = '';
}
const registeredRules = readRegisteredRuleNames(eslintConfigText);
const facts = extractSeamFacts(contextText);
const totalIds = new Set([...facts.owns.keys(), ...facts.enforcedBy.keys()]).size;
if (totalIds === 0) {
process.stdout.write('ok lint-seam-enforcement: 0 SEAM.*.owns/enforced-by claims found in CONTEXT.md — nothing to check\n');
return 0;
}
const findings = checkSeamFacts(facts, {
ruleIsRegistered: (name) => registeredRules.has(name),
ruleFileExists: (name) => fs.existsSync(path.join(ROOT, 'eslint-rules', `${name}.cjs`)),
testFileExists: (relPath) => fs.existsSync(path.join(ROOT, relPath)),
});
if (findings.length === 0) {
process.stdout.write(`ok lint-seam-enforcement: ${totalIds} seam claim(s) checked, all enforcement pointers resolve\n`);
return 0;
}
process.stderr.write(`ERROR lint-seam-enforcement: ${findings.length} unbacked seam claim(s) in ${path.relative(ROOT, contextPath)}\n`);
for (const finding of findings) {
process.stderr.write(` - ${finding}\n`);
}
process.stderr.write('Every SEAM.<id>.owns claim needs a SEAM.<id>.enforced-by=lint-rule:<name>|test:<path> pointer that resolves.\n');
process.stderr.write('Back the claim with a real, registered rule or existing test, or correct the prose to stop claiming single ownership.\n');
return 1;
}
module.exports = { extractSeamFacts, parseEnforcementPointer, checkSeamFacts, readRegisteredRuleNames };
if (require.main === module) {
runMain(main);
}