diff --git a/eslint-rules/no-source-grep.cjs b/eslint-rules/no-source-grep.cjs index 8be9da7ef..6f383f204 100644 --- a/eslint-rules/no-source-grep.cjs +++ b/eslint-rules/no-source-grep.cjs @@ -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: + * Honor a SITE-SCOPED escape comment: // allow-test-rule: (#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: ` 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: 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: (#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: ` 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); } } }, diff --git a/tests/adr-index-gate.test.cjs b/tests/adr-index-gate.test.cjs index d34c91a47..4b37463aa 100644 --- a/tests/adr-index-gate.test.cjs +++ b/tests/adr-index-gate.test.cjs @@ -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) { diff --git a/tests/claude-imperative-reference.test.cjs b/tests/claude-imperative-reference.test.cjs index 452199695..5de70767a 100644 --- a/tests/claude-imperative-reference.test.cjs +++ b/tests/claude-imperative-reference.test.cjs @@ -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, diff --git a/tests/completion-predicate-drift-guard.test.cjs b/tests/completion-predicate-drift-guard.test.cjs index 6909f80f3..61ea98291 100644 --- a/tests/completion-predicate-drift-guard.test.cjs +++ b/tests/completion-predicate-drift-guard.test.cjs @@ -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)'); }); }); diff --git a/tests/config-schema.property.test.cjs b/tests/config-schema.property.test.cjs index 4282d2de5..9cf19da86 100644 --- a/tests/config-schema.property.test.cjs +++ b/tests/config-schema.property.test.cjs @@ -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 diff --git a/tests/config.test.cjs b/tests/config.test.cjs index b51f793d5..914374a79 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -1911,11 +1911,41 @@ describe('#3086: git.create_tag config key', () => { stepFile.includes('') && 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', ); }); }); diff --git a/tests/eslint-rules.test.cjs b/tests/eslint-rules.test.cjs index 205b495ea..d151c9cc4 100644 --- a/tests/eslint-rules.test.cjs +++ b/tests/eslint-rules.test.cjs @@ -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 diff --git a/tests/install-minimal-hooks.test.cjs b/tests/install-minimal-hooks.test.cjs index a0b7e6cf5..c6021909d 100644 --- a/tests/install-minimal-hooks.test.cjs +++ b/tests/install-minimal-hooks.test.cjs @@ -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', () => { diff --git a/tests/phase6-capstone-conformance.test.cjs b/tests/phase6-capstone-conformance.test.cjs index 08af76cf4..22ce31d27 100644 --- a/tests/phase6-capstone-conformance.test.cjs +++ b/tests/phase6-capstone-conformance.test.cjs @@ -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.`); diff --git a/tests/runtime-config-adapter-registry.test.cjs b/tests/runtime-config-adapter-registry.test.cjs index 6f210b0eb..5b5abf0c7 100644 --- a/tests/runtime-config-adapter-registry.test.cjs +++ b/tests/runtime-config-adapter-registry.test.cjs @@ -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]),