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>
This commit is contained in:
Tom Boucher
2026-08-28 08:06:41 -04:00
committed by GitHub
parent d98b55562c
commit 355c943b08
9 changed files with 981 additions and 266 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 3975
---
**CONTEXT.md seam claims are now checkable.** New `SEAM.<id>.owns`/`SEAM.<id>.enforced-by` predicates plus a `lint:ci` gate (`scripts/lint-seam-enforcement.cjs`) fail the build when a declared single-owner seam names no existing, registered lint rule or test file, so a seam claim can no longer silently decay into an unenforced assertion. (#3626)

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"count": 263,
"count": 277,
"classes": {
"ARCH": 1,
"CI": 2,
@@ -18,6 +18,7 @@
"PROHIB": 10,
"RELEASE-NOTES": 31,
"RULESET": 60,
"SEAM": 14,
"SESSION": 9,
"WAVE": 5,
"WORKSTREAM": 5,
@@ -1184,6 +1185,76 @@
"klass": "RULESET",
"value": "workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an `Emitted-Drift-Ack-Growth:` commit trailer on the PR's own commits (ADR-3942, superseding ADR-2719 §3's fragment model — key is the bare filename, reason follows ` — `)) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: \"not yet baselined\" is exactly \"present in sizeCurrent, absent from sizeBaseline\", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification"
},
{
"id": "SEAM.capability-activation-precedence-owner.enforced-by",
"klass": "SEAM",
"value": "test:tests/capability-precedence-parity.test.cjs"
},
{
"id": "SEAM.capability-activation-precedence-owner.owns",
"klass": "SEAM",
"value": "the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent), owned solely by src/capability-activation.cts"
},
{
"id": "SEAM.git-query-readonly-seam.enforced-by",
"klass": "SEAM",
"value": "test:tests/git-base-branch.test.cjs"
},
{
"id": "SEAM.git-query-readonly-seam.owns",
"klass": "SEAM",
"value": "bounded, never-throw git repository introspection — base-branch detection, worktree-info detection, phase change-set detection"
},
{
"id": "SEAM.package-identity.enforced-by",
"klass": "SEAM",
"value": "test:tests/package-identity.test.cjs"
},
{
"id": "SEAM.package-identity.owns",
"klass": "SEAM",
"value": "GSD's published-package coordinates (packageName, binName, repoSlug, changelogRawUrl, manualInstallCommand) — single seam so a repoint/rename is a one-line change"
},
{
"id": "SEAM.phase-locator-milestone-enum.enforced-by",
"klass": "SEAM",
"value": "test:tests/phase-locator.test.cjs"
},
{
"id": "SEAM.phase-locator-milestone-enum.owns",
"klass": "SEAM",
"value": "listMilestonePhaseDirs(phasesDir, opts) is the single canonical owner of milestone-scoped phase-directory enumeration (ADR-3180 Decision 1, Phase 3, #3185)"
},
{
"id": "SEAM.shellcmdproj-win-binary-resolution.enforced-by",
"klass": "SEAM",
"value": "lint-rule:no-private-binary-resolution"
},
{
"id": "SEAM.shellcmdproj-win-binary-resolution.owns",
"klass": "SEAM",
"value": "Windows binary resolution (resolveExecutableBinary, projectSpawnInvocation) — which file a declared command name actually names, and cmd.exe mediation"
},
{
"id": "SEAM.verification-isphasecomplete.enforced-by",
"klass": "SEAM",
"value": "test:tests/verification-status.test.cjs"
},
{
"id": "SEAM.verification-isphasecomplete.owns",
"klass": "SEAM",
"value": "isPhaseComplete(phaseDir, deps?) is the single canonical owner of \"is phase P complete?\" (ADR-3180 §7.4, issue #3186)"
},
{
"id": "SEAM.worktree-safety-policy.enforced-by",
"klass": "SEAM",
"value": "test:tests/worktree-safety.test.cjs"
},
{
"id": "SEAM.worktree-safety-policy.owns",
"klass": "SEAM",
"value": "Worktree Safety Policy Module — resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, W017 classification (see WORKTREE.SEAM.* above for full interface/invariant detail)"
},
{
"id": "SESSION.2026-05-05",
"klass": "SESSION",

View File

@@ -0,0 +1,95 @@
# ADR-3626: CONTEXT.md seam claims carry a checkable enforcement pointer
- **Status:** Accepted
- **Date:** 2026-08-27
- **Issue:** [#3626](https://github.com/open-gsd/gsd-core/issues/3626)
- **Amends:** none. Applies ADR-1703's "seam it" strategy (Decision 2) by adding the verification
step that strategy lacked.
**Decision summary.** `CONTEXT.md` gains a new machine-readable predicate pair,
`SEAM.<id>.owns=<capability>` / `SEAM.<id>.enforced-by=lint-rule:<name>|test:<path>`, generalizing
the existing `WORKTREE.SEAM.*` vocabulary rather than introducing a parallel one. A new
`scripts/lint-seam-enforcement.cjs`, wired into `lint:ci`, fails when an `owns` claim has no
matching `enforced-by` pointer, or when that pointer names a lint rule that is not registered in
`eslint.config.mjs` (or whose source file is missing), or a test file that does not exist on disk.
The gate is deliberately **resolves-only**: it proves the named enforcement mechanism *exists and
is wired up*, not that its surface actually *covers* every file the seam claims to own. That
broader "coverage" verification was the issue's own explicitly-flagged larger, harder design
(ESLint glob matching for a rule; no static notion of "coverage" at all for a test-file anchor) and
was decided against by the maintainer in chat before implementation (2026-08-27), per the "cheap
and honest" framing in the issue's own scope caveat.
## Context
`CONTEXT.md` declares several single-owner/single-seam claims in prose — "the single canonical
owner of X", "the single seam for Y". ADR-1703 established that a portability class gets either a
lint rule (self-verifying) or a centralized seam (an assertion, with no verification step). Epic
#3411 found the Shell Command Projection Module's Windows-binary-resolution seam claim was false
for years: four divergent implementations existed, and a fix to one never reached the others,
because nothing checked that the claimed seam was actually the only place that logic lived.
The gap: strategy 2 ("seam it") has no equivalent of strategy 1's self-verification. A seam
declaration can decay silently as the next author needs something the seam doesn't offer and
writes around it.
## Decision
1. **Vocabulary**: reuse and generalize the `<NAME>.SEAM.*` predicate-fact shape already shipped
for the Worktree Safety Policy Module (`WORKTREE.SEAM.current`, `.files`, `.interface`,
`.caller-rule`, `.test-anchor-w017`, ...) rather than invent a second one. The new top-level
keys are `SEAM.<id>.owns` and `SEAM.<id>.enforced-by`, coexisting alongside any existing
`<NAME>.SEAM.*` descriptive facts for the same module (see `WORKTREE.SEAM.*` +
`SEAM.worktree-safety-policy.*` in `CONTEXT.md` for the pattern).
2. **Enforcement pointer schemes**: exactly two, `lint-rule:<name>` (must resolve to a key in
`eslint.config.mjs`'s `localPlugin.rules` map AND a corresponding `eslint-rules/<name>.cjs`
file) and `test:<path>` (must exist relative to the repo root). A third scheme is a lint
failure ("unrecognized enforcement-pointer scheme"), not a silent pass.
3. **Scope**: resolves-only. The gate does not compute whether a rule's ESLint `files` glob or a
test's exercised code paths actually reach every file the seam claims. This is a conscious,
disclosed limitation — see Consequences.
4. **No grandfather list** (ADR-1703 Decision 2, applied here): every current *module-level*
single-owner/single-seam claim in `CONTEXT.md` was enumerated and either backed with a real
`SEAM.*.owns`/`enforced-by` pair, or its prose would be corrected to stop claiming exclusive
ownership. Seven claims were found (Shell Command Projection Module's Windows-binary-resolution
axis, Verification Module's `isPhaseComplete`, Phase Locator Module's
`listMilestonePhaseDirs`, Git Query Module, the capability-activation precedence engine, the
Worktree Safety Policy Module, and the Package Identity Module — the last caught by an isolated
adversarial review pass, not the first-pass sweep); all seven already had an existing, on-disk
lint rule or test file that plausibly anchors the claim once actually pointed to — none
required a prose downgrade. **Scope boundary**: several other "single owner" sentences exist at
*function* granularity inside already-covered, multi-function module entries (e.g. individual
STATE.md Document Module functions); these are deliberately out of scope — a seam claim is
about a module's boundary, matching the granularity `WORKTREE.SEAM.*` already set as precedent,
not every function-level ownership sentence. See
`.gsd/phase/feat-3626-context-seam-claim-gate/40-design.md` for the full seam-by-seam
disposition table, the scope-boundary rationale, and verification evidence.
## Consequences
**Positive:** an unbacked seam claim is now a `lint:ci` failure, not a silent, decaying assertion.
The fixture in `tests/lint-seam-enforcement.test.cjs` (row 4, "owns with no matching enforced-by")
proves the gate can actually fail, not just pass vacuously. The mechanism generalizes cleanly —
adding a seventh seam claim later is one predicate pair, not a new gate.
**Cost / risk — the disclosed limitation.** A claim can be "backed" by a real rule or test that is
narrow relative to what the prose claims to own. `SEAM.<id>.enforced-by=test:<path>` accepts any
existing file at that path; the gate does not parse the test to confirm it actually references the
claimed symbol (each of the six seams landed with this PR was spot-checked by hand via Memtrace's
`find_code`, not by the gate itself). A future author could, in principle, satisfy the gate with a
test file that exists but tests something unrelated. This is the accepted trade of the
resolves-only decision: cheap and honest about what it checks, not a claim of exhaustive coverage
verification.
**Revisit-if**: if a claim backed only by an existing-but-irrelevant test/rule pointer is found in
practice (i.e., the resolves-only gap is exploited, deliberately or by drift), re-open the coverage
-verification design the issue flagged and this ADR declined to build.
## Alternatives considered
- **Coverage verification** (does the rule's ESLint `files` glob, or the test's exercised paths,
actually include every file the seam declares) — rejected as the issue's own "much larger
design," requiring a per-enforcement-type coverage computation with no honest static notion of
"coverage" for a test-file anchor. See design doc's Rejected section.
- **A new parallel predicate vocabulary** instead of generalizing `WORKTREE.SEAM.*` — rejected per
explicit maintainer direction (issue comment, 2026-08-18) to generalize the shipped precedent.

View File

@@ -258,6 +258,7 @@ These govern the system as it stands. Cite these.
| [ADR-3473](3473-enforcement-by-construction.md) | Enforcement by Construction — One Owner per Invariant | Accepted | — |
| [ADR-3574](3574-install-materialization-primitives.md) | Install materialization shares primitives, not one writer | Accepted | — |
| [ADR-3625](3625-vetted-spawn-library-evaluation.md) | The platform seam keeps its own Windows binary resolution rather than adopting a spawn library | Accepted | — |
| [ADR-3626](3626-context-md-seam-claim-gate.md) | CONTEXT.md seam claims carry a checkable enforcement pointer | Accepted | — |
| [ADR-3660](3660-runtime-artifact-layout-module.md) | Runtime Artifact Layout Module owns per-runtime artifact placement | Accepted | [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) |
### Proposed

File diff suppressed because one or more lines are too long

View File

@@ -121,7 +121,7 @@
"lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs",
"lint:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs",
"lint:removed-but-needed": "node scripts/lint-removed-but-needed.cjs",
"lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs && node scripts/lint-mutation-test-derivation-drift.cjs",
"lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-tests.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/lint-docs-guard-registration.cjs && node scripts/lint-source-test-name-collision.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs && node scripts/lint-mutation-test-derivation-drift.cjs && node scripts/lint-seam-enforcement.cjs",
"lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
"lint:regression-names": "node scripts/lint-regression-test-names.cjs",
"lint:descriptions": "node scripts/lint-descriptions.cjs",
@@ -133,6 +133,7 @@
"lint:docs": "node scripts/lint-docs-required.cjs",
"lint:qa-smells": "node scripts/qa-smell-ratchet.cjs",
"lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs",
"lint:seam-enforcement": "node scripts/lint-seam-enforcement.cjs",
"lint:docs-command-form": "node scripts/lint-docs-command-form.cjs",
"lint:hooks-runtime-build-seam": "node scripts/lint-hooks-runtime-build-seam.cjs",
"ci:test-scope": "node scripts/ci-test-scope.cjs",

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

View File

@@ -0,0 +1,256 @@
'use strict';
/**
* tests/lint-seam-enforcement.test.cjs
*
* Regression net for scripts/lint-seam-enforcement.cjs (#3626). Drives the
* guard's pure `extractSeamFacts`/`parseEnforcementPointer`/`checkSeamFacts`/
* `readRegisteredRuleNames` exports directly with synthetic fixtures and
* injected fakes, so this test never depends on eslint.config.mjs or CONTEXT.md
* shape churn — plus a final "real repo" integration block.
*
* 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
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const {
extractSeamFacts,
parseEnforcementPointer,
checkSeamFacts,
readRegisteredRuleNames,
} = require('../scripts/lint-seam-enforcement.cjs');
describe('lint-seam-enforcement: extractSeamFacts', () => {
test('captures a backtick-wrapped owns/enforced-by pair', () => {
const text = [
'Some prose.',
'`SEAM.foo.owns=bar`',
'`SEAM.foo.enforced-by=test:tests/x.test.cjs`',
'More prose.',
].join('\n');
const { owns, enforcedBy } = extractSeamFacts(text);
assert.equal(owns.get('foo'), 'bar');
assert.equal(enforcedBy.get('foo'), 'test:tests/x.test.cjs');
});
test('row 13: an unrelated <NAME>.SEAM.<subkey>= line is NOT captured (anchored regex, not <id>.owns|enforced-by)', () => {
const text = '`WORKTREE.SEAM.files=[gsd-core/bin/lib/worktree-safety.cjs]`';
const { owns, enforcedBy } = extractSeamFacts(text);
assert.equal(owns.size, 0);
assert.equal(enforcedBy.size, 0);
});
});
describe('lint-seam-enforcement: parseEnforcementPointer', () => {
test('lint-rule: scheme', () => {
assert.deepEqual(parseEnforcementPointer('lint-rule:no-source-grep'), {
scheme: 'lint-rule',
target: 'no-source-grep',
});
});
test('test: scheme', () => {
assert.deepEqual(parseEnforcementPointer('test:tests/foo.test.cjs'), {
scheme: 'test',
target: 'tests/foo.test.cjs',
});
});
test('unrecognized scheme falls back to null scheme with the raw value as target', () => {
assert.deepEqual(parseEnforcementPointer('bogus-scheme:x'), {
scheme: null,
target: 'bogus-scheme:x',
});
});
});
describe('lint-seam-enforcement: checkSeamFacts', () => {
test('row 1: owns + enforced-by=lint-rule:<name> where the rule is registered and its source exists — no findings', () => {
const owns = new Map([['foo', 'bar']]);
const enforcedBy = new Map([['foo', 'lint-rule:no-source-grep']]);
const deps = {
ruleIsRegistered: () => true,
ruleFileExists: () => true,
testFileExists: () => false,
};
assert.deepEqual(checkSeamFacts({ owns, enforcedBy }, deps), []);
});
test('row 2: owns + enforced-by=test:<path> where the file exists — no findings', () => {
const owns = new Map([['foo', 'bar']]);
const enforcedBy = new Map([['foo', 'test:tests/foo.test.cjs']]);
const deps = {
ruleIsRegistered: () => false,
ruleFileExists: () => false,
testFileExists: () => true,
};
assert.deepEqual(checkSeamFacts({ owns, enforcedBy }, deps), []);
});
test('row 4: owns with no matching enforced-by — THE FIXTURE THAT PROVES THE GATE CAN FAIL (drift-guard-prove-it-can-fail convention)', () => {
const owns = new Map([['foo', 'bar']]);
const enforcedBy = new Map();
const deps = { ruleIsRegistered: () => true, ruleFileExists: () => true, testFileExists: () => true };
const findings = checkSeamFacts({ owns, enforcedBy }, deps);
assert.equal(findings.length, 1);
assert.match(findings[0], /SEAM\.foo/);
assert.match(findings[0], /no enforcement pointer/);
});
test('row 5: enforced-by=lint-rule:<name> where the name is not registered — dangling lint-rule pointer', () => {
const owns = new Map([['foo', 'bar']]);
const enforcedBy = new Map([['foo', 'lint-rule:nope']]);
const deps = { ruleIsRegistered: () => false, ruleFileExists: () => true, testFileExists: () => true };
const findings = checkSeamFacts({ owns, enforcedBy }, deps);
assert.equal(findings.length, 1);
assert.match(findings[0], /dangling lint-rule pointer/);
});
test('row 6: enforced-by=lint-rule:<name> registered but its source file is missing — registered rule has no source file', () => {
const owns = new Map([['foo', 'bar']]);
const enforcedBy = new Map([['foo', 'lint-rule:ghost']]);
const deps = { ruleIsRegistered: () => true, ruleFileExists: () => false, testFileExists: () => true };
const findings = checkSeamFacts({ owns, enforcedBy }, deps);
assert.equal(findings.length, 1);
assert.match(findings[0], /registered rule has no source file/);
});
test('row 7: enforced-by=test:<path> where the path does not exist — dangling test-anchor pointer', () => {
const owns = new Map([['foo', 'bar']]);
const enforcedBy = new Map([['foo', 'test:tests/nope.test.cjs']]);
const deps = { ruleIsRegistered: () => true, ruleFileExists: () => true, testFileExists: () => false };
const findings = checkSeamFacts({ owns, enforcedBy }, deps);
assert.equal(findings.length, 1);
assert.match(findings[0], /dangling test-anchor pointer/);
});
test('row 8: enforced-by with an unrecognized scheme prefix — unrecognized enforcement-pointer scheme', () => {
const owns = new Map([['foo', 'bar']]);
const enforcedBy = new Map([['foo', 'weird:thing']]);
const deps = { ruleIsRegistered: () => true, ruleFileExists: () => true, testFileExists: () => true };
const findings = checkSeamFacts({ owns, enforcedBy }, deps);
assert.equal(findings.length, 1);
assert.match(findings[0], /unrecognized enforcement-pointer scheme/);
});
test('row 9: enforced-by with no matching owns (stale/renamed id) — enforcement pointer with no ownership claim', () => {
const owns = new Map();
const enforcedBy = new Map([['foo', 'lint-rule:no-source-grep']]);
const deps = { ruleIsRegistered: () => true, ruleFileExists: () => true, testFileExists: () => true };
const findings = checkSeamFacts({ owns, enforcedBy }, deps);
assert.equal(findings.length, 1);
assert.match(findings[0], /enforcement pointer with no ownership claim/);
});
test('row 10 (checkSeamFacts-level zero case): empty owns/enforcedBy maps — no findings', () => {
// The CLI-level "explicit zero notice" (row 10's full behavior) is emitted
// via process.stdout.write in main() — CLI/process stdout is not
// unit-tested in this style elsewhere in the repo (see
// mutation-test-derivation-drift.test.cjs), so only the checkSeamFacts
// return value (empty findings on empty input) is asserted here.
assert.deepEqual(checkSeamFacts({ owns: new Map(), enforcedBy: new Map() }, {
ruleIsRegistered: () => true,
ruleFileExists: () => true,
testFileExists: () => true,
}), []);
});
test('row 11 (boundary, limit=1): exactly one valid claim — no findings', () => {
const owns = new Map([['foo', 'bar']]);
const enforcedBy = new Map([['foo', 'test:tests/foo.test.cjs']]);
const deps = { ruleIsRegistered: () => true, ruleFileExists: () => true, testFileExists: () => true };
assert.deepEqual(checkSeamFacts({ owns, enforcedBy }, deps), []);
});
test('row 11 (boundary, limit=1): exactly one INVALID (dangling) claim — findings array of length exactly 1', () => {
const owns = new Map([['foo', 'bar']]);
const enforcedBy = new Map([['foo', 'test:tests/nope.test.cjs']]);
const deps = { ruleIsRegistered: () => true, ruleFileExists: () => true, testFileExists: () => false };
const findings = checkSeamFacts({ owns, enforcedBy }, deps);
assert.equal(findings.length, 1);
});
test('row 12 (independence): two claims, one valid + one dangling — findings name ONLY the dangling id', () => {
const owns = new Map([
['good', 'good-thing'],
['bad', 'bad-thing'],
]);
const enforcedBy = new Map([
['good', 'test:tests/good.test.cjs'],
['bad', 'test:tests/bad.test.cjs'],
]);
const deps = {
ruleIsRegistered: () => true,
ruleFileExists: () => true,
testFileExists: (relPath) => relPath === 'tests/good.test.cjs',
};
const findings = checkSeamFacts({ owns, enforcedBy }, deps);
assert.equal(findings.length, 1);
assert.match(findings[0], /SEAM\.bad/);
assert.doesNotMatch(findings[0], /SEAM\.good/);
});
test('row 14 (hostile, resolves-only scope): a valid lint-rule pointer for an id whose owns prose is unrelated to the rule — still resolves, no content inference', () => {
const owns = new Map([['totally-unrelated-capability', 'some prose describing an unrelated thing']]);
const enforcedBy = new Map([['totally-unrelated-capability', 'lint-rule:no-source-grep']]);
const deps = {
ruleIsRegistered: (name) => name === 'no-source-grep',
ruleFileExists: (name) => name === 'no-source-grep',
testFileExists: () => false,
};
assert.deepEqual(checkSeamFacts({ owns, enforcedBy }, deps), []);
});
});
describe('lint-seam-enforcement: readRegisteredRuleNames', () => {
test('extracts rule names from a synthetic localPlugin.rules block', () => {
const text = `
const localPlugin = {
rules: {
'no-source-grep': noSourceGrep,
'no-private-binary-resolution': noPrivateBinaryResolution,
},
};
`;
const names = readRegisteredRuleNames(text);
assert.ok(names instanceof Set);
assert.ok(names.has('no-source-grep'));
assert.ok(names.has('no-private-binary-resolution'));
});
});
describe('lint-seam-enforcement: real repo has zero unresolved SEAM claims', () => {
test('row 3: extractSeamFacts + checkSeamFacts against the real CONTEXT.md and eslint.config.mjs resolve cleanly', () => {
// Regression net for every SEAM.*.owns/enforced-by fact CONTEXT.md
// declares. deepEqual([]) is correct whether CONTEXT.md has zero facts
// (empty maps produce zero findings trivially) or many (each must
// resolve cleanly to a registered rule or an existing test file).
const root = path.join(__dirname, '..');
const contextText = fs.readFileSync(path.join(root, 'CONTEXT.md'), 'utf8');
const eslintConfigText = fs.readFileSync(path.join(root, 'eslint.config.mjs'), 'utf8');
const registeredRules = readRegisteredRuleNames(eslintConfigText);
const facts = extractSeamFacts(contextText);
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)),
});
assert.deepEqual(findings, []);
});
});