* 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>
This commit is contained in:
182
scripts/lint-seam-enforcement.cjs
Normal file
182
scripts/lint-seam-enforcement.cjs
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in New Issue
Block a user