chore(#3508): site-scoped allow-test-rule suppression (#3512)

Phase 4 of #3464, following #3465, #3466 and #3502. Those cut the ceiling
305 -> 278 and made the rule accurate. This closes the remaining structural
weakness: suppression was FILE-WIDE, so a single justified exemption silently
absolved every other source-grep in that file, forever, including ones added
later by someone else.

hasAllowAnnotation did comments.some(...) over the whole file and returned {}
early. A marker is now checked per report: a violation is suppressed only by a
marker on its own line, or on a line above it with nothing but blank lines and
other comments in between, bounded by MAX_MARKER_LOOKAHEAD_LINES = 8.

The bound is comment-purity rather than raw distance, and that distinction is
load-bearing: an intervening line of real code (a `test(...)` opener, say)
ends the window even when the marker is physically close. Chosen from the
actual placements in the affected files rather than picked a priori -- the
repo's convention puts several lines of prose rationale between the marker and
the code, so a tighter rule would have invalidated legitimate existing markers
and forced churn for no correctness gain.

Measured before writing any code, by running the real rule with the
suppression check neutralized across all 1194 files its globs match: 14
violation sites in 8 files, and ZERO in files carrying no marker -- so the
green build was legitimate, and the entire migration surface was those 14.

11 sites were mechanical: an existing marker already stated the right reason,
it just sat too far away. Those were relocated to their call sites with the
original #NNN citations preserved.

Three were orphans -- the file's markers were about an entirely different
concern and nobody had ever justified these reads. All three are fixed
BEHAVIORALLY, with no new markers:

  install-minimal-hooks.test.cjs:975 asserted
  src.includes('gsd-update-check') && src.includes('replace(') against
  bin/install.js. It now calls the exported stripStaleGsdHookBlocks() on a
  legacy TOML fixture and asserts the actual stripped output. This is the
  case this phase was opened around: it could be added with no review friction
  and stay invisible indefinitely under file-wide amnesty.

  config.test.cjs:1917 regex-tested src/init.cts for detectGitCreateTag. It now
  drives `init complete-milestone` and asserts the git_create_tag field.

  config-schema.property.test.cjs:1107 did the same for detectFallowConfig; it
  now drives `init code-review` and asserts fallow_enabled.

Each was proven RED against a broken production file and GREEN against the real
one, with src/init.cts and bin/install.js confirmed byte-identical afterwards.

Marker lines in the 8 files went 20 -> 24, against a filed expectation of
"must not increase" (projected 14). That projection was wrong and is corrected
on #3508 rather than met by deletion. It assumed every existing marker was a
distant blanket that site-scoping would consolidate. Some are already
site-adjacent and guard real source-greps the rule CANNOT detect -- verified in
install-minimal-hooks.test.cjs:2686-2757, where seven markers each sit directly
above a readFileSync(reloadScript) + .includes() pair reading
hooks/gsd-config-reload.js. Removing them to hit a number would have repeated
the Phase 1 mistake: deleting markers on "the rule doesn't fire" evidence when
the rule provably cannot see the violation.

An earlier revision of this commit message attributed that invisibility to the
#3502 dynamic-path blind spot, on the grounds that reloadScript is a variable.
Adversarial review caught that as a false causal claim and it is corrected
here. looksLikeSourcePath's hasSourceDir regex is
/['"](?:bin|lib|gsd-core|src)['"]/i, and those reads target hooks/ -- so a
fully literal path.join(ROOT,'hooks','gsd-config-reload.js') is equally
invisible. The variable indirection is irrelevant. This is a FIFTH, distinct
blind spot: the source-dir allowlist omits hooks/, which is a real shipped
production directory (eslint.config.mjs registers its own rule block for
hooks/**/*.js). Recorded in 40-design.md Known limits and left for a follow-on
phase -- widening the allowlist is unmeasured, and measuring before widening is
the discipline #3502 established. The conclusion was right; the stated
mechanism was not, and asserting an unverified cause is the error being
corrected.

The honest metric is not fewer markers. It is that every marker now sits
adjacent to the specific read it justifies instead of absolving a whole file.
Site-scoping turns one blanket marker covering N sites into N site markers by
design; the count rising is the mechanism working.

A second review finding is fixed here too. Suppression originally keyed only
off the text-search line, so a marker placed directly above the readFileSync()
call -- the intuitive place to annotate "this read is fine" -- did NOT suppress
when the search sat on the following line, because the read's own assignment
line breaks comment-purity. It failed safe (a loud error, never silent
suppression), but it was a trap contributors would hit, and it contradicted
this change's own claim that the placement rule would not force churn. A
violation is now suppressed by a marker adjacent to EITHER the search site or
the originating read. The violation is fundamentally the read+search pair, so
annotating either half is legitimate, and it stays strictly site-scoped -- the
decisive isolation row still holds.

17 RuleTester rows cover the new semantics. The decisive one asserts that a
marker adjacent to one violation does NOT suppress an unrelated violation
elsewhere in the same file -- exactly 1 error, reported at the second site.
Teeth-checked by reverting the predicate to file-wide, confirming that row and
two others flip pass->fail, then restoring. Two pre-existing RuleTester cases
that asserted the old file-wide semantics were corrected.

Compatibility held where it matters: 277 marker-bearing files have no
detectable violation at all, and site-scoping makes their markers no-ops rather
than errors. All stay green, untouched.

Ceiling unchanged at 278; lint-allow-test-rule-refs reports 278/278.

Deliberately not done here, and recorded for the follow-on phase: the same
measurement found only 8 of 285 marker-bearing files contain a detectable
violation. That suggests a large honest ceiling drop, but "the rule doesn't
fire" is the unsound oracle that forced the Phase 1 revert of 295 files, and
the rule still has documented blind spots -- as install-minimal-hooks itself
demonstrates above. It needs two independent signals agreeing, which is only
credible now that the rule is accurate. With suppression site-scoped, "an
effective exemption" is finally well-defined, which is what makes re-pointing
the ratchet at effective exemptions -- rather than at marker-text presence --
the natural next step.

Closes #3508

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-14 19:02:14 -04:00
committed by GitHub
parent 7a02f98574
commit c66b010052
10 changed files with 649 additions and 96 deletions

View File

@@ -13,7 +13,14 @@
* string, so a same-named binding in an unrelated or shadowing scope is
* never conflated with a tracked one.
*
* Honor file-level escape comment: // allow-test-rule: <reason>
* Honor a SITE-SCOPED escape comment: // allow-test-rule: <reason> (#NNN)
* A marker suppresses only the violation(s) it sits next to (same line, or
* above with nothing but blank/comment lines between), not the whole file
* (#3508 / epic #3464 phase 4). "Next to" is checked against EITHER half of
* the read+search pair -- the text-search call site, or the readFileSync()
* call that originated the tracked value -- so annotating the read directly
* (the intuitive placement) suppresses the violation just as well as
* annotating the search call (adversarial-review fix, epic #3464 phase 4).
*/
// How many derivation hops from the original readFileSync() binding to
@@ -24,6 +31,20 @@
// a bug — see 40-design.md "Known limits".
const MAX_TRANSITIVE_HOPS = 3;
// How many source lines a `// allow-test-rule: <reason>` marker is allowed
// to sit above the violation it suppresses (0 = same line as the marker's
// own line, i.e. the line directly below it). The repo's real placement
// style is a marker followed by a short run of CONTINUATION PROSE (more
// `//` comment lines expanding on the reason) immediately before the flagged
// call -- observed spans across the 8 real #3508 violation sites run 0-4
// comment lines (e.g. the #3502 marker in tests/adr-index-gate.test.cjs, the
// #770 markers in tests/install-minimal-hooks.test.cjs). 8 gives that a
// comfortable margin without being effectively unbounded -- large enough to
// never force churn on a legitimately-placed marker, small enough that a
// marker meant for one call site cannot drift into covering an unrelated
// site 40+ lines later (test-matrix.md row 4, the defect this closes).
const MAX_MARKER_LOOKAHEAD_LINES = 8;
const TEXT_METHODS = new Set([
'includes',
'match',
@@ -102,7 +123,7 @@ const rule = {
schema: [],
messages: {
noSourceGrep:
'Source-grep test: do not read source .cjs/.cts/.js/.mjs/.mts/.ts files with readFileSync and call .includes/.match/.matchAll/.startsWith/.indexOf/.split/.replace/.search (or regex.test()) on the result. Use require() to run the module instead. Add // allow-test-rule: <reason> at the top of the file to suppress.',
'Source-grep test: do not read source .cjs/.cts/.js/.mjs/.mts/.ts files with readFileSync and call .includes/.match/.matchAll/.startsWith/.indexOf/.split/.replace/.search (or regex.test()) on the result. Use require() to run the module instead. Add // allow-test-rule: <reason> (#NNN) directly above (or trailing) the flagged line to suppress just that site.',
},
},
create(context) {
@@ -110,12 +131,64 @@ const rule = {
? context.getSourceCode()
: context.sourceCode;
// Check for file-level escape comment
const comments = sourceCode.getAllComments();
const hasAllowAnnotation = comments.some(
(c) => /allow-test-rule:\s*\S/.test(c.value)
);
if (hasAllowAnnotation) return {};
// All comments in the file (used both to find markers and to know which
// lines are "just a comment" for the lookahead purity check below).
const allComments = sourceCode.getAllComments();
// Line numbers of every `// allow-test-rule: <reason>` marker comment in
// the file. A marker may span one line (the normal `//` form) or several
// (a block comment) -- record every line it occupies so a violation on
// any of those lines counts as "same line" (trailing-marker form, row 2
// of the test matrix).
const markerLines = [];
for (const c of allComments) {
if (/allow-test-rule:\s*\S/.test(c.value)) {
for (let l = c.loc.start.line; l <= c.loc.end.line; l++) {
markerLines.push(l);
}
}
}
// Line numbers fully occupied by ANY comment (marker or not) -- a marker
// followed by ordinary prose lines before the flagged call is the repo's
// real style (test-matrix.md row 3), so those in-between lines must not
// disqualify the marker.
const commentLineSet = new Set();
for (const c of allComments) {
for (let l = c.loc.start.line; l <= c.loc.end.line; l++) {
commentLineSet.add(l);
}
}
function isBlankLine(line) {
const text = sourceCode.lines[line - 1];
return text !== undefined && text.trim() === '';
}
// A violation at `violationLine` is suppressed if some marker sits on
// that exact line (trailing form) or on an earlier line within
// MAX_MARKER_LOOKAHEAD_LINES, with every line strictly between the
// marker and the violation being blank and/or itself a comment line --
// i.e. no live code (not even the readFileSync() call the marker is
// ostensibly about) sits between the marker and the call it suppresses.
// This is what makes suppression SITE-scoped rather than file-wide: a
// marker parked far above an unrelated later violation (test-matrix.md
// row 4) no longer reaches it.
function isSuppressed(violationLine) {
for (const markerLine of markerLines) {
if (markerLine > violationLine) continue;
if (violationLine - markerLine > MAX_MARKER_LOOKAHEAD_LINES) continue;
let pure = true;
for (let l = markerLine + 1; l < violationLine; l++) {
if (!isBlankLine(l) && !commentLineSet.has(l)) {
pure = false;
break;
}
}
if (pure) return true;
}
return false;
}
// Map from Identifier AST node -> resolved ESLint `Variable`, built once
// per file (see buildIdentifierVariableMap) so that resolveVariable() is
@@ -205,27 +278,40 @@ const rule = {
// increments by 1, capped at MAX_TRANSITIVE_HOPS.
const hopOf = new Map();
// Variable -> line number of the readFileSync() call that originated the
// value tracked at that variable (same line as the hop=1 seed for a
// direct binding; propagated unchanged through every derivation hop,
// since a transitive chain is still fundamentally about the SAME
// original read+search pair). Populated in lockstep with hopOf below so
// a report can consult "where was this text actually read from" and
// honor a marker placed at either half of the pair (adversarial-review
// fix: marker adjacent to the read alone must suppress too, not just a
// marker adjacent to the search call).
const readLineOf = new Map();
// Generic conservative fallback: walk every Identifier under `node` and
// return the smallest hop number among identifiers that resolve to an
// already-tracked variable, or null if none do. This is the DEFAULT for
// any expression shape not explicitly recognized below (arguments to an
// unknown function call, logical expressions, etc.) -- for an
// unrecognized shape we choose to PROPAGATE (risking a rarer false
// positive) rather than silently drop a true positive, because the
// callee/operator may still be returning text derived from the tracked
// value. Clearly-scalar shapes (member access, comparisons, numeric/
// boolean methods, Number()/parseInt()/etc.) are special-cased below to
// explicitly NOT propagate instead, since for those we know for certain
// the result cannot carry text.
function walkForTrackedHop(node) {
let min = null;
// return the {hop, line} of the identifier with the smallest hop number
// among identifiers that resolve to an already-tracked variable, or null
// if none do. This is the DEFAULT for any expression shape not
// explicitly recognized below (arguments to an unknown function call,
// logical expressions, etc.) -- for an unrecognized shape we choose to
// PROPAGATE (risking a rarer false positive) rather than silently drop a
// true positive, because the callee/operator may still be returning text
// derived from the tracked value. Clearly-scalar shapes (member access,
// comparisons, numeric/boolean methods, Number()/parseInt()/etc.) are
// special-cased below to explicitly NOT propagate instead, since for
// those we know for certain the result cannot carry text.
function walkForTrackedInfo(node) {
let best = null;
(function walk(n) {
if (!n || typeof n.type !== 'string') return;
if (n.type === 'Identifier') {
const v = resolveVariable(n);
if (v && hopOf.has(v)) {
const h = hopOf.get(v);
if (min === null || h < min) min = h;
if (best === null || h < best.hop) {
best = { hop: h, line: readLineOf.get(v) };
}
}
}
for (const key of Object.keys(n)) {
@@ -240,45 +326,50 @@ const rule = {
}
}
})(node);
return min;
return best;
}
// Determine whether tracking should propagate through `node`'s value
// into whatever it is assigned/bound to, and if so, at what (minimum)
// hop it draws from. Returns null when the value shape is one we know
// for certain cannot still carry the tracked file's text.
function minTrackedHop(node) {
// hop -- and from which original read line -- it draws from. Returns
// null when the value shape is one we know for certain cannot still
// carry the tracked file's text.
function trackedInfo(node) {
if (!node || typeof node.type !== 'string') return null;
switch (node.type) {
case 'Identifier': {
// Identity: `const b = a;`
const v = resolveVariable(node);
return v && hopOf.has(v) ? hopOf.get(v) : null;
return v && hopOf.has(v)
? { hop: hopOf.get(v), line: readLineOf.get(v) }
: null;
}
case 'AwaitExpression':
return minTrackedHop(node.argument);
return trackedInfo(node.argument);
case 'ConditionalExpression': {
// `cond ? a : other` -- only the branches can carry the tracked
// value; the test itself is a boolean and does not propagate.
const c = minTrackedHop(node.consequent);
const a = minTrackedHop(node.alternate);
const c = trackedInfo(node.consequent);
const a = trackedInfo(node.alternate);
if (c === null) return a;
if (a === null) return c;
return Math.min(c, a);
return c.hop <= a.hop ? c : a;
}
case 'TemplateLiteral': {
// `` `${a}` `` -- a template embedding a tracked value still
// carries its text.
let min = null;
let best = null;
for (const expr of node.expressions) {
const h = minTrackedHop(expr);
if (h !== null && (min === null || h < min)) min = h;
const info = trackedInfo(expr);
if (info !== null && (best === null || info.hop < best.hop)) {
best = info;
}
}
return min;
return best;
}
case 'BinaryExpression': {
@@ -287,11 +378,11 @@ const rule = {
// >=, arithmetic, etc.) produces a boolean/number and must not
// propagate.
if (node.operator !== '+') return null;
const l = minTrackedHop(node.left);
const r = minTrackedHop(node.right);
const l = trackedInfo(node.left);
const r = trackedInfo(node.right);
if (l === null) return r;
if (r === null) return l;
return Math.min(l, r);
return l.hop <= r.hop ? l : r;
}
case 'UnaryExpression':
@@ -344,30 +435,30 @@ const rule = {
callee.type === 'MemberExpression' &&
callee.property.type === 'Identifier'
) {
const objHop = minTrackedHop(callee.object);
if (objHop !== null) {
const objInfo = trackedInfo(callee.object);
if (objInfo !== null) {
const propName = callee.property.name;
if (NON_PROPAGATING_METHODS.has(propName)) return null;
if (PROPAGATING_STRING_METHODS.has(propName)) return objHop;
if (PROPAGATING_STRING_METHODS.has(propName)) return objInfo;
// Unrecognized method name on a known-tracked receiver:
// conservative default for an unrecognized call result (see
// fallback rationale above) -- propagate rather than risk
// silently dropping a true positive.
return objHop;
return objInfo;
}
}
// Not a recognized narrowing/receiver call shape: fall through
// to the generic conservative walk (covers "tracked value passed
// as an argument to any call", e.g. `const b = strip(a);`).
return walkForTrackedHop(node);
return walkForTrackedInfo(node);
}
default:
// Any other expression shape (LogicalExpression, parenthesized
// expressions -- which are not a distinct AST node -- etc.):
// conservative default, see walkForTrackedHop doc comment.
return walkForTrackedHop(node);
// conservative default, see walkForTrackedInfo doc comment.
return walkForTrackedInfo(node);
}
}
@@ -396,45 +487,74 @@ const rule = {
}
},
'Program:exit'() {
// Seed hop=1 for variables bound directly to a source readFileSync().
// Seed hop=1 for variables bound directly to a source readFileSync(),
// recording the readFileSync() call's own line as the "origin read
// line" for that variable.
for (const { id, init } of pendingDeclarators) {
if (isSourceReadFileSync(init)) {
const v = resolveVariable(id);
if (v && !hopOf.has(v)) hopOf.set(v, 1);
if (v && !hopOf.has(v)) {
hopOf.set(v, 1);
readLineOf.set(v, init.loc.start.line);
}
}
}
for (const { left, right } of pendingAssignments) {
if (isSourceReadFileSync(right)) {
const v = resolveVariable(left);
if (v && !hopOf.has(v)) hopOf.set(v, 1);
if (v && !hopOf.has(v)) {
hopOf.set(v, 1);
readLineOf.set(v, right.loc.start.line);
}
}
}
// Fixpoint over derived bindings, bounded by MAX_TRANSITIVE_HOPS.
// Each variable is added at most once, so this always terminates.
// The origin read line is carried through unchanged from whichever
// parent variable the hop was derived from -- a transitive chain is
// still fundamentally about the same original read+search pair.
let changed = true;
while (changed) {
changed = false;
for (const { id, init } of pendingDeclarators) {
const v = resolveVariable(id);
if (!v || hopOf.has(v)) continue;
const parentHop = minTrackedHop(init);
if (parentHop !== null && parentHop + 1 <= MAX_TRANSITIVE_HOPS) {
hopOf.set(v, parentHop + 1);
const parentInfo = trackedInfo(init);
if (parentInfo !== null && parentInfo.hop + 1 <= MAX_TRANSITIVE_HOPS) {
hopOf.set(v, parentInfo.hop + 1);
readLineOf.set(v, parentInfo.line);
changed = true;
}
}
for (const { left, right } of pendingAssignments) {
const v = resolveVariable(left);
if (!v || hopOf.has(v)) continue;
const parentHop = minTrackedHop(right);
if (parentHop !== null && parentHop + 1 <= MAX_TRANSITIVE_HOPS) {
hopOf.set(v, parentHop + 1);
const parentInfo = trackedInfo(right);
if (parentInfo !== null && parentInfo.hop + 1 <= MAX_TRANSITIVE_HOPS) {
hopOf.set(v, parentInfo.hop + 1);
readLineOf.set(v, parentInfo.line);
changed = true;
}
}
}
// Report a violation at `node` unless a marker's site-scoped
// suppression (see isSuppressed above) covers either the search
// call's own line OR the line of the readFileSync() call that
// originated the tracked value (adversarial-review fix: the
// violation is fundamentally about the read+search PAIR, so a
// marker adjacent to either half is a legitimate, still strictly
// site-scoped, way to annotate it). `readLine` is optional -- pass
// it whenever the call site can determine one.
function reportUnlessSuppressed(node, readLine) {
if (isSuppressed(node.loc.start.line)) return;
if (readLine !== undefined && readLine !== null && isSuppressed(readLine)) {
return;
}
context.report({ node, messageId: 'noSourceGrep' });
}
// Now that hopOf is stable, evaluate every candidate call site.
for (const { node, kind } of pendingCalls) {
const obj = node.callee.object;
@@ -444,13 +564,14 @@ const rule = {
if (obj.type === 'Identifier') {
const v = resolveVariable(obj);
if (v && hopOf.has(v)) {
context.report({ node, messageId: 'noSourceGrep' });
reportUnlessSuppressed(node, readLineOf.get(v));
continue;
}
}
// Inline: readFileSync(...).includes(...)
// Inline: readFileSync(...).includes(...) -- read and search are
// the same line, so no separate read line to pass.
if (isSourceReadFileSync(obj)) {
context.report({ node, messageId: 'noSourceGrep' });
reportUnlessSuppressed(node);
}
continue;
}
@@ -464,8 +585,9 @@ const rule = {
const args = node.arguments;
if (!args || args.length === 0) continue;
if (minTrackedHop(args[0]) !== null) {
context.report({ node, messageId: 'noSourceGrep' });
const argInfo = trackedInfo(args[0]);
if (argInfo !== null) {
reportUnlessSuppressed(node, argInfo.line);
}
}
},

View File

@@ -683,15 +683,15 @@ const ADR_DIR = path.join(REPO_ROOT, 'docs', 'adr');
// the real gate treats fenced (and inline) code as code — markdown does not
// render a link there, so masking it out is correct, not a regression.
// allow-test-rule: source-text-is-the-product — the ADR citation is a comment in src/plan-drift-guard.cts, erased at compile time, so no runtime observation can reach it (#3502)
// This site surfaced only after no-source-grep was widened to recognize .cts
// reads and .matchAll() (#3502); it is irreducible, not unconverted — there is
// no exported value to require() in its place, since a comment leaves no
// runtime trace to assert against.
test('the ADR path cited by src/plan-drift-guard.cts exists', () => {
// This module is compiled into the published payload, so a wrong citation
// here ships to users.
const src = fs.readFileSync(path.join(REPO_ROOT, 'src', 'plan-drift-guard.cts'), 'utf8');
// allow-test-rule: source-text-is-the-product — the ADR citation is a comment in src/plan-drift-guard.cts, erased at compile time, so no runtime observation can reach it (#3502)
// This site surfaced only after no-source-grep was widened to recognize .cts
// reads and .matchAll() (#3502); it is irreducible, not unconverted — there is
// no exported value to require() in its place, since a comment leaves no
// runtime trace to assert against.
const cited = [...src.matchAll(/docs\/adr\/([A-Za-z0-9._-]+\.md)/g)].map((m) => m[1]);
assert.notEqual(cited.length, 0, 'expected plan-drift-guard.cts to cite its governing ADR');
for (const name of cited) {

View File

@@ -1,4 +1,3 @@
// allow-test-rule: structural-regression-guard — AC2 requires asserting no `runtime === 'claude'` string-equality branch remains in bin/install.js — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2086)
'use strict';
/**
@@ -137,6 +136,7 @@ test('bin/install.js contains no `runtime === "claude"` / `runtime !== "claude"`
// Strip comments + backtick/inline-code spans so PROSE mentions of the old
// pattern (a comment explaining "not a string-equality branch") do not
// false-positive — only LIVE code counts.
// allow-test-rule: structural-regression-guard — AC2 requires asserting no `runtime === 'claude'` string-equality branch remains in bin/install.js — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2086)
const codeOnly = src
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded bin/install.js source, not adversarial input
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
@@ -144,6 +144,7 @@ test('bin/install.js contains no `runtime === "claude"` / `runtime !== "claude"`
.replace(/\/\/[^\r\n]*/g, '') // line comments (CRLF-safe)
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded bin/install.js source, not adversarial input
.replace(/`[^`]*`/g, ''); // backtick / inline-code spans
// allow-test-rule: structural-regression-guard — same #2086 source-grep as above; `.match()` is how the stripped source is scanned for the retired string-equality branch (#2086)
const offenders = codeOnly.match(/runtime\s*[!=]==\s*'claude'/g) || [];
assert.deepEqual(
offenders,

View File

@@ -1,7 +1,6 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
// allow-test-rule: structural-regression-guard, see #3186
// D3 below reads src/plan-scan.cts / gsd-core/bin/lib/plan-scan.cjs and
// src/verification.cts and regex-tests them for require/import statements.
// This asserts a DEPENDENCY-DIRECTION invariant (the owner consumes plan
@@ -607,12 +606,14 @@ describe('E12 — shape (d): scanPhasePlans(...).completed read as a completion
describe('D3 — dependency direction: plan-scan.cts does not import verification.cts', () => {
test('the compiled plan-scan.cjs source contains no reference to verification.cjs', () => {
const compiled = fs.readFileSync(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'plan-scan.cjs'), 'utf-8');
// allow-test-rule: structural-regression-guard — D3 reads compiled plan-scan.cjs and regex-tests it for a require() of verification.cjs; asserts a dependency-direction invariant (ADR-3180 §7.4) with no runtime/behavioral surface (see #3186)
assert.ok(!/require\(['"]\.\/verification(\.cjs)?['"]\)/.test(compiled), 'plan-scan.cjs must not require verification.cjs');
});
test('the plan-scan.cts source has no import/require of verification.cjs/.cts (a prose comment MENTIONING it, e.g. explaining why a field is not routed through it, is not an import and must not false-positive)', () => {
const source = fs.readFileSync(path.join(ROOT, 'src', 'plan-scan.cts'), 'utf-8');
assert.ok(
// allow-test-rule: structural-regression-guard — same D3 dependency-direction invariant as above, checked against the .cts source instead of the compiled .cjs (see #3186)
!/\b(?:require|import)\s*(?:\(|\{|[A-Za-z_$][\w$]*\s*=)[^;\r\n]*verification\.c(?:j|t)s/.test(source),
'src/plan-scan.cts must not import/require verification.cts/.cjs',
);
@@ -622,6 +623,7 @@ describe('D3 — dependency direction: plan-scan.cts does not import verificatio
// Documents the ALLOWED direction so the pair of assertions above reads
// as a genuine one-way constraint, not an accidental total decoupling.
const source = fs.readFileSync(path.join(ROOT, 'src', 'verification.cts'), 'utf-8');
// allow-test-rule: structural-regression-guard — documents the ALLOWED direction of the D3 dependency invariant: verification.cts consumes plan-scan.cjs (see #3186)
assert.ok(/require\(['"]\.\/plan-scan\.cjs['"]\)/.test(source), 'src/verification.cts is expected to import plan-scan.cjs (for staleness-check summary listing, pre-existing/unrelated to isPhaseComplete)');
});
});

View File

@@ -1098,15 +1098,41 @@ describe('feat-3210: workflow and config contracts', () => {
stepFile.includes('FALLOW.json'),
'structural-pre-pass.md step file must reference the FALLOW.json output artifact',
);
});
// Structural property: the config-gate fact is resolved from the real
// code_quality.fallow.enabled config key — checked against the resolver
// it was hoisted into (src/init.cts's detectFallowConfig).
const initSource = fs.readFileSync(path.join(ROOT, 'src', 'init.cts'), 'utf8');
assert.ok(
/detectFallowConfig[\s\S]{0,600}'code_quality'[\s\S]{0,40}'fallow'[\s\S]{0,40}'enabled'/.test(initSource),
'detectFallowConfig (src/init.cts) must gate on code_quality.fallow.enabled',
);
// #3508: behavioral replacement for the `detectFallowConfig` source-grep
// that used to sit here. `detectFallowConfig` (src/init.cts) is unexported,
// but its EFFECT is observable through `init code-review`'s `fallow_enabled`
// output field (wired at cmdInitCodeReview) -- driving the real CLI with
// code_quality.fallow.enabled set both ways proves detectFallowConfig
// resolves THAT config key specifically, without reading init.cts's source.
test('init code-review\'s fallow_enabled field tracks the code_quality.fallow.enabled config key (behavioral form of detectFallowConfig)', () => {
const tmpDir = createTempProject('gsd-fallow-detect-');
try {
const setTrue = runGsdTools(['config-set', 'code_quality.fallow.enabled', 'true'], tmpDir, { HOME: tmpDir });
assert.ok(setTrue.success, `config-set code_quality.fallow.enabled true failed: ${setTrue.error}`);
const trueResult = runGsdTools(['init', 'code-review', '1'], tmpDir, { HOME: tmpDir });
assert.ok(trueResult.success, `init code-review failed: ${trueResult.error}`);
assert.strictEqual(
JSON.parse(trueResult.output).fallow_enabled,
true,
'init code-review must report fallow_enabled: true once code_quality.fallow.enabled is set true',
);
const setFalse = runGsdTools(['config-set', 'code_quality.fallow.enabled', 'false'], tmpDir, { HOME: tmpDir });
assert.ok(setFalse.success, `config-set code_quality.fallow.enabled false failed: ${setFalse.error}`);
const falseResult = runGsdTools(['init', 'code-review', '1'], tmpDir, { HOME: tmpDir });
assert.ok(falseResult.success, `init code-review failed: ${falseResult.error}`);
assert.strictEqual(
JSON.parse(falseResult.output).fallow_enabled,
false,
'init code-review must report fallow_enabled: false once code_quality.fallow.enabled is set false',
);
} finally {
cleanup(tmpDir);
}
});
// B4: agent output contract — doc-parity check (approved fallback per config-schema-docs-parity

View File

@@ -1911,11 +1911,41 @@ describe('#3086: git.create_tag config key', () => {
stepFile.includes('<step name="git_tag">') && stepFile.includes('git tag -a'),
'git-tag.md step file must contain the git_tag step body (git tag creation)',
);
});
const initSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'init.cts'), 'utf8');
assert.ok(
/detectGitCreateTag[\s\S]{0,300}'git'[\s\S]{0,40}'create_tag'/.test(initSource),
'src/init.cts detectGitCreateTag must resolve the git.create_tag config key',
// #3508: behavioral replacement for the `detectGitCreateTag` source-grep
// that used to sit here. `detectGitCreateTag` (src/init.cts) is unexported,
// but its EFFECT is observable through `init complete-milestone`'s
// `git_create_tag` output field (wired at cmdInitCompleteMilestone,
// gsd-core/bin/lib/init-command-router.cjs's 'complete-milestone' handler)
// -- driving the real CLI with git.create_tag set both ways proves
// detectGitCreateTag resolves THAT config key specifically (not just that
// config-get does, which tests A/B above already cover) without reading
// init.cts's source text.
test('D2. init complete-milestone\'s git_create_tag field tracks the git.create_tag config key (behavioral form of detectGitCreateTag)', (t) => {
const tmpDir = createTempProject('gsd-3086-detect-git-create-tag-');
t.after(() => cleanup(tmpDir));
const setFalse = runGsdTools(['config-set', 'git.create_tag', 'false'], tmpDir, { HOME: tmpDir });
assert.ok(setFalse.success, `config-set git.create_tag false failed:\n${setFalse.error}`);
const falseResult = runGsdTools(['init', 'complete-milestone'], tmpDir, { HOME: tmpDir });
assert.ok(falseResult.success, `init complete-milestone failed:\n${falseResult.error}`);
assert.strictEqual(
JSON.parse(falseResult.output).git_create_tag,
false,
'init complete-milestone must report git_create_tag: false once git.create_tag is set false',
);
const setTrue = runGsdTools(['config-set', 'git.create_tag', 'true'], tmpDir, { HOME: tmpDir });
assert.ok(setTrue.success, `config-set git.create_tag true failed:\n${setTrue.error}`);
const trueResult = runGsdTools(['init', 'complete-milestone'], tmpDir, { HOME: tmpDir });
assert.ok(trueResult.success, `init complete-milestone failed:\n${trueResult.error}`);
assert.strictEqual(
JSON.parse(trueResult.output).git_create_tag,
true,
'init complete-milestone must report git_create_tag: true once git.create_tag is set true',
);
});
});

View File

@@ -103,17 +103,16 @@ describe('no-source-grep rule', () => {
});
});
test('valid: file with allow-test-rule annotation is exempt', () => {
test('valid: allow-test-rule annotation adjacent to the read exempts that site (#3508: site-scoped, not file-wide)', () => {
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [
{
// The allow annotation exempts the whole file
// The marker sits directly above the read+search it suppresses.
code: `
// allow-test-rule: pending migration
const fs = require('fs');
const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'io.cjs'), 'utf-8');
src.includes('someFunction');
// allow-test-rule: pending migration
const src = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'io.cjs'), 'utf-8'); src.includes('someFunction');
`,
filename: 'tests/foo.test.cjs',
},
@@ -485,19 +484,21 @@ describe('no-source-grep rule — widening (#3502)', () => {
});
});
test('row 19: file carrying the file-level suppression annotation suppresses an otherwise-invalid fixture', () => {
test('row 19: a marker adjacent to the read+search suppresses it (#3508: site-scoped, not file-wide)', () => {
// The raw marker text is assembled via string concatenation so this
// FILE's own bytes never contain a contiguous "allow" + "-test-rule:"
// token (scripts/lint-allow-test-rule-refs.cjs does a raw whole-file
// substring scan). At RuleTester-run time the concatenation resolves to
// a real single-line comment, which the rule under test honors normally.
// The marker sits directly above the read+search (site-scoped, #3508),
// not merely somewhere earlier in the file (the pre-#3508 file-wide form
// this row originally exercised).
const marker = '// ' + 'allow' + '-test-rule: split marker for row 19, see #3502';
const code = [
"const fs = require('fs');",
"const path = require('path');",
marker,
"const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8');",
"src.includes('x');",
"const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); src.includes('x');",
].join('\n');
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [
@@ -535,6 +536,367 @@ describe('no-source-grep rule — widening (#3502)', () => {
});
});
// ─── no-source-grep site-scoped suppression (#3508 / Phase 4 of #3464) ──────
//
// One RuleTester case per row of
// .gsd/phase/chore-3464-site-scoped-suppression/50-test-matrix.md, rows 1-12.
// Row 4 is the one that actually proves the defect is closed: file-wide
// amnesty is gone, so a marker adjacent to one violation must NOT reach an
// unrelated violation later in the same file. Rows 3 and 6 are the
// compatibility guards (prose between marker and read; marker + zero
// violations) that must keep working or this would break the 277
// marker-bearing files that rely on file-level markers being a documented
// no-op when there's nothing to suppress.
//
// Marker text is always assembled via string concatenation (`AT` below) so
// THIS file's raw bytes never contain a contiguous "allow" + "-test-rule:"
// token — same fixture-host discipline as the row 19/20 cases above
// (scripts/lint-allow-test-rule-refs.cjs does a raw whole-file substring
// scan and must not newly count this file).
//
// NOTE: row 9's fixture length is tied to MAX_MARKER_LOOKAHEAD_LINES (8) in
// eslint-rules/no-source-grep.cjs — if that constant changes, this fixture's
// filler-line count must change with it.
// Row 12 ("marker with no #NNN") is explicitly a script-level check, not a
// RuleTester case (test-matrix.md marks it "(script, not RuleTester)") —
// it's covered by `node scripts/lint-allow-test-rule-refs.cjs` instead.
describe('no-source-grep rule — site-scoped suppression (#3508)', () => {
const AT = 'allow' + '-test-rule:';
test('row 1: marker directly above the read+search is suppressed (site-scoped)', () => {
const code = [
"const fs = require('fs');",
"const path = require('path');",
`// ${AT} reason (#1)`,
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
].join('\n');
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [{ code, filename: 'tests/foo.test.cjs' }],
invalid: [],
});
});
test('row 2: marker trailing on the same line as the search is suppressed', () => {
const code = [
"const fs = require('fs');",
"const path = require('path');",
`const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x'); // ${AT} reason (#1)`,
].join('\n');
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [{ code, filename: 'tests/foo.test.cjs' }],
invalid: [],
});
});
test('row 3: marker with prose lines between it and the read is still suppressed (repo real-style guard)', () => {
const code = [
"const fs = require('fs');",
"const path = require('path');",
`// ${AT} reason (#1)`,
'// continuation prose line one explaining the reason',
'// continuation prose line two continuing the explanation',
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
].join('\n');
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [{ code, filename: 'tests/foo.test.cjs' }],
invalid: [],
});
});
test('row 4: marker adjacent to V1 does NOT reach an unrelated V2 later in the file (the defect this phase closes)', () => {
const filler = Array.from({ length: 40 }, (_, i) => `// unrelated filler line ${i + 1}, pushing V2 well past the lookahead bound`);
const lines = [
"const fs = require('fs');",
"const path = require('path');",
`// ${AT} reason for V1 (#1)`,
"const s1 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s1.includes('x');",
...filler,
"const s2 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'b.cjs'), 'utf-8'); s2.includes('y');",
];
const code = lines.join('\n');
const v2Line = lines.length; // s2's line is the last line of the fixture
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [],
invalid: [
{
code,
filename: 'tests/foo.test.cjs',
// Exactly ONE error, reported at V2 -- V1 stays suppressed, and the
// marker's reach does NOT extend to the unrelated V2 40 lines later.
errors: [{ messageId: 'noSourceGrep', line: v2Line }],
},
],
});
});
test('row 5: marker far above a violation with no marker text of its own is not suppressed', () => {
const filler = Array.from({ length: 100 }, (_, i) => `// unrelated filler line ${i + 1}`);
const lines = [
"const fs = require('fs');",
"const path = require('path');",
`// ${AT} reason (#1)`,
...filler,
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
];
const code = lines.join('\n');
const violationLine = lines.length;
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [],
invalid: [
{
code,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'noSourceGrep', line: violationLine }],
},
],
});
});
test('row 6: file with a marker and zero violations stays green (the 277 inert-marker files compatibility guard)', () => {
const code = [
`// ${AT} reason (#1)`,
"const fs = require('fs');",
"const path = require('path');",
"const content = fs.readFileSync(path.join(__dirname, '..', 'docs', 'readme.md'), 'utf-8');",
"content.includes('hello');",
].join('\n');
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [{ code, filename: 'tests/foo.test.cjs' }],
invalid: [],
});
});
test('row 7: no marker, one violation is flagged (baseline unchanged)', () => {
const code = [
"const fs = require('fs');",
"const path = require('path');",
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
].join('\n');
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [],
invalid: [
{
code,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'noSourceGrep' }],
},
],
});
});
test('row 8: two violations, two adjacent markers -- per-site marking works', () => {
const code = [
"const fs = require('fs');",
"const path = require('path');",
`// ${AT} reason for V1 (#1)`,
"const s1 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s1.includes('x');",
`// ${AT} reason for V2 (#1)`,
"const s2 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'b.cjs'), 'utf-8'); s2.includes('y');",
].join('\n');
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [{ code, filename: 'tests/foo.test.cjs' }],
invalid: [],
});
});
test('row 9: marker beyond the lookahead bound does not suppress (the bound is where it claims)', () => {
// MAX_MARKER_LOOKAHEAD_LINES is 8 in eslint-rules/no-source-grep.cjs.
// 9 filler comment lines between the marker and the read pushes the gap
// to 10 lines (> 8), just past the bound.
const filler = Array.from({ length: 9 }, (_, i) => `// filler comment line ${i + 1}`);
const lines = [
"const fs = require('fs');",
"const path = require('path');",
`// ${AT} reason (#1)`,
...filler,
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
];
const code = lines.join('\n');
const violationLine = lines.length;
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [],
invalid: [
{
code,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'noSourceGrep', line: violationLine }],
},
],
});
});
test('row 10: marker text inside a fixture string (not a real comment) is not a directive', () => {
// The marker-looking text lives inside a STRING LITERAL in the linted
// fixture, never as a `//` comment -- ESLint's comment AST (what the
// rule inspects) never sees string-literal contents, so this must not
// suppress the real, unmarked violation below it (the #3465 lesson).
const code = [
"const fs = require('fs');",
"const path = require('path');",
`const note = 'not a directive: ${AT} fake reason';`,
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes(note);",
].join('\n');
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [],
invalid: [
{
code,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'noSourceGrep' }],
},
],
});
});
test('row 11: marker citing #NNN on the same line still suppresses (citation contract unaffected)', () => {
const code = [
"const fs = require('fs');",
"const path = require('path');",
`// ${AT} reason for this read (#3508)`,
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf-8'); s.includes('x');",
].join('\n');
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [{ code, filename: 'tests/foo.test.cjs' }],
invalid: [],
});
});
// ─── read-site suppression (adversarial-review fix, ITEM 1) ────────────
//
// A violation is fundamentally about a read+search PAIR. Before this fix,
// a marker adjacent to the readFileSync() call (the intuitive annotation
// spot) failed to suppress once the search happened on a later line,
// because the readFileSync assignment line itself is "real code" and
// broke comment-purity on the marker->search lookahead path. The rule now
// also checks a marker's site-scoping against the ORIGINATING read call's
// own line, independent of the marker->search path.
test('valid: marker directly above the read, search on the very next (non-comment) line', () => {
const code = [
"const fs = require('fs');",
"const path = require('path');",
`// ${AT} reason (#1)`,
"const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
"src.includes('x');",
].join('\n');
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [{ code, filename: 'tests/foo.test.cjs' }],
invalid: [],
});
});
test('valid: marker directly above the read, search several comment-pure lines later (read-line real code no longer breaks the marker->search path)', () => {
const code = [
"const fs = require('fs');",
"const path = require('path');",
`// ${AT} reason (#1)`,
"const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
'// comment-pure line one',
'// comment-pure line two',
'// comment-pure line three',
"src.includes('x');",
].join('\n');
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [{ code, filename: 'tests/foo.test.cjs' }],
invalid: [],
});
});
test('invalid: marker above the read suppresses that pair, but an unrelated tracked variable searched further down is still flagged', () => {
const lines = [
"const fs = require('fs');",
"const path = require('path');",
`// ${AT} reason for V1 (#1)`,
"const s1 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
"s1.includes('x');",
"const s2 = fs.readFileSync(path.join(__dirname, '..', 'lib', 'b.cjs'), 'utf8');",
"s2.includes('y');",
];
const code = lines.join('\n');
const v2Line = lines.length; // s2.includes(...) is the last line
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [],
invalid: [
{
code,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'noSourceGrep', line: v2Line }],
},
],
});
});
test('invalid: marker far from both the read and the search is still flagged', () => {
const filler = Array.from({ length: 20 }, (_, i) => `// unrelated filler line ${i + 1}`);
const lines = [
"const fs = require('fs');",
"const path = require('path');",
`// ${AT} reason (#1)`,
...filler,
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
"s.includes('x');",
];
const code = lines.join('\n');
const violationLine = lines.length; // s.includes(...) is the last line
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [],
invalid: [
{
code,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'noSourceGrep', line: violationLine }],
},
],
});
});
test('boundary: marker exactly MAX_MARKER_LOOKAHEAD_LINES (8) above the read is suppressed via the read-site path', () => {
// 7 comment-pure filler lines between the marker and the read puts the
// read exactly 8 lines below the marker -- the inclusive boundary.
const filler = Array.from({ length: 7 }, (_, i) => `// filler comment line ${i + 1}`);
const lines = [
"const fs = require('fs');",
"const path = require('path');",
`// ${AT} reason (#1)`,
...filler,
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
"s.includes('x');",
];
const code = lines.join('\n');
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [{ code, filename: 'tests/foo.test.cjs' }],
invalid: [],
});
});
test('boundary: marker one line beyond MAX_MARKER_LOOKAHEAD_LINES (9) above the read is not suppressed', () => {
// 8 comment-pure filler lines between the marker and the read puts the
// read 9 lines below the marker -- one past the inclusive boundary.
const filler = Array.from({ length: 8 }, (_, i) => `// filler comment line ${i + 1}`);
const lines = [
"const fs = require('fs');",
"const path = require('path');",
`// ${AT} reason (#1)`,
...filler,
"const s = fs.readFileSync(path.join(__dirname, '..', 'lib', 'a.cjs'), 'utf8');",
"s.includes('x');",
];
const code = lines.join('\n');
const violationLine = lines.length; // s.includes(...) is the last line
ruleTester.run('no-source-grep', noSourceGrep, {
valid: [],
invalid: [
{
code,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'noSourceGrep', line: violationLine }],
},
],
});
});
});
// ─── no-source-grep hop-propagation value-shape (adversarial-review fix) ────
//
// minTrackedHop() used to walk EVERY Identifier under a derivation's RHS

View File

@@ -34,6 +34,7 @@ const {
writeManifest,
GSD_UNINSTALL_HOOKS,
resolveSharedHooksDirName,
stripStaleGsdHookBlocks,
} = require('../bin/install.js');
const {
@@ -969,10 +970,20 @@ describe('uninstall settings cleanup preserves user hooks', () => {
});
describe('Codex legacy gsd-update-check migration', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
// #3508: behavioral replacement for a source-grep that used to check
// install.js's own text for the literal strings 'gsd-update-check' and
// 'replace(' -- i.e. it asserted characteristics of the SOURCE CODE, not
// an observable effect. `stripStaleGsdHookBlocks` (bin/install.js) is the
// REAL exported function that performs this migration; drive it directly
// with a legacy Shape-1 config.toml block (same shape the two tests below
// already exercise) and assert the stale hook block is actually removed.
test('install.js strips legacy gsd-update-check hook blocks', () => {
assert.ok(src.includes('gsd-update-check') && src.includes('replace('));
const legacyToml = ['[features]', 'codex_hooks = true', '',
'# GSD Hooks', '[[hooks]]', 'event = "SessionStart"',
'command = "node /old/path/gsd-update-check.js"', ''].join('\n');
const stripped = stripStaleGsdHookBlocks(legacyToml);
assert.ok(!stripped.includes('gsd-update-check'), 'legacy gsd-update-check hook block must be stripped');
assert.ok(stripped.includes('[features]'), 'unrelated config content must survive stripping');
});
test('migration regex removes LF legacy hook block', () => {

View File

@@ -1,4 +1,3 @@
// allow-test-rule: source-text-is-the-product
'use strict';
const { describe, test } = require('node:test');
@@ -248,6 +247,7 @@ describe('ADR-857 phase 6 — capabilities must not bake install paths into the
test('generated capability-registry.cjs contains no ~/.claude install path', () => {
const reg = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'), 'utf8');
// allow-test-rule: source-text-is-the-product
const leakLines = reg.split(/\r?\n/).map((l, i) => [i + 1, l]).filter(([, l]) => LEAK.test(l)).map(([n]) => n);
assert.deepEqual(leakLines, [],
`capability-registry.cjs leaks ~/.claude install paths at line(s) ${leakLines.join(', ')} — the registry is copied verbatim to non-Claude runtimes (only workflow .md files are path-converted at install). Make the source capability fragment path-free.`);

View File

@@ -489,15 +489,14 @@ describe('issue-57 AC2 — config-mutation dispatch is closed over the explicit
}
});
// allow-test-rule: structural-regression-guard (#3336)
// structural guard over bin/install.js source. Behavioral assertions
// cannot observe inline `runtime === '...'` config branching, so this enforces that
// every inline per-runtime branch references a runtime the adapter registry knows
// about — a NEW branch against an unregistered runtime name fails here. ESLint cannot
// currently see this grep because no-source-grep's TEXT_METHODS omits matchAll (#3464).
// about — a NEW branch against an unregistered runtime name fails here.
test('every inline `runtime === "..."` branch references a registry-known runtime', () => {
const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8');
const literals = new Set(
// allow-test-rule: structural-regression-guard — structural guard over bin/install.js source; behavioral assertions cannot observe inline `runtime === '...'` config branching, so this enforces every inline per-runtime branch references a runtime the adapter registry knows about (#3336)
[...src.matchAll(/runtime === (?:'([a-z][a-z0-9-]*)'|"([a-z][a-z0-9-]*)")/g)]
.map((m) => m[1] ?? m[2]),
);
@@ -513,13 +512,12 @@ describe('issue-57 AC2 — config-mutation dispatch is closed over the explicit
);
});
// allow-test-rule: structural-regression-guard (#2103)
// VS Code is a registry runtime but is NEVER CLI-installed (Marketplace/VSIX
// extension); it must stay fully descriptor-driven — bin/install.js must
// never special-case it by name. ESLint cannot currently see this grep because
// no-source-grep's TEXT_METHODS omits matchAll (#3464).
// never special-case it by name.
test('#2103: bin/install.js has ZERO runtime === "vscode" / isVscode branches (vscode stays fully descriptor-driven)', () => {
const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8');
// allow-test-rule: structural-regression-guard — vscode is a registry runtime but is NEVER CLI-installed (Marketplace/VSIX); it must stay fully descriptor-driven, so bin/install.js must never special-case it by name (#2103)
const runtimeComparisons = [...src.matchAll(/runtime === (?:'vscode'|"vscode")/g)];
assert.deepStrictEqual(
runtimeComparisons.map((m) => m[0]),
@@ -528,6 +526,7 @@ describe('issue-57 AC2 — config-mutation dispatch is closed over the explicit
+ 'install surface at all (installSurface: "none") and is never CLI-installed; any '
+ 'vscode-specific behavior belongs in capabilities/vscode/capability.json, not an inline branch.',
);
// allow-test-rule: structural-regression-guard — same #2103 vscode-descriptor-driven guard as above, this time for the isVscode flag name (#2103)
const isVscodeRefs = [...src.matchAll(/\bisVscode\b/g)];
assert.deepStrictEqual(
isVscodeRefs.map((m) => m[0]),