* 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:
5
.changeset/serene-bears-dance.md
Normal file
5
.changeset/serene-bears-dance.md
Normal 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)
|
||||
19
CONTEXT.md
19
CONTEXT.md
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
95
docs/adr/3626-context-md-seam-claim-gate.md
Normal file
95
docs/adr/3626-context-md-seam-claim-gate.md
Normal 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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
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);
|
||||
}
|
||||
256
tests/lint-seam-enforcement.test.cjs
Normal file
256
tests/lint-seam-enforcement.test.cjs
Normal 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, []);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user