diff --git a/eslint-rules/no-source-grep.cjs b/eslint-rules/no-source-grep.cjs index 825425d83..49ca93641 100644 --- a/eslint-rules/no-source-grep.cjs +++ b/eslint-rules/no-source-grep.cjs @@ -21,6 +21,12 @@ * 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). + * + * A `readFileSync(...)` path argument that is a bare Identifier is resolved + * ONE hop back to its `VariableDeclarator` initializer before classification, + * so a path built once and passed by reference is recognized the same as an + * inline path expression; `hooks` is also a recognized source directory + * alongside `bin`/`lib`/`gsd-core`/`src` (#3545 / epic #3464 phase 7). */ // How many derivation hops from the original readFileSync() binding to @@ -326,9 +332,27 @@ const rule = { return identifierVariableMap.get(identifierNode) || null; } + // WIDENING (fold, #3545/epic #3464 phase 7): resolve a bare Identifier + // path argument back to its variable initializer, ONE hop, before + // text-matching it -- so `const p = path.join(__dirname, '..', 'src', + // 'x.cjs'); readFileSync(p)` is classified the same as an inline + // `readFileSync(path.join(__dirname, '..', 'src', 'x.cjs'))`. Only + // resolves a `VariableDeclarator`'s `init` (not an `AssignmentExpression` + // — an assignment-bound identifier is a documented, deliberate miss, see + // 40-design.md "Known limits"), and only ONE hop (a chain of two or more + // indirections stays invisible -- also documented). + function resolveOneHopText(node) { + if (node.type !== 'Identifier') return sourceCode.getText(node); + const v = resolveVariable(node); + if (!v) return sourceCode.getText(node); + const def = v.defs.find((d) => d.type === 'Variable' && d.node && d.node.init); + if (!def) return sourceCode.getText(node); + return sourceCode.getText(def.node.init); + } + // Detect if a node represents a readFileSync call on a source file // (.cjs/.cts/.js/.mjs/.mts/.ts) that lives in a source directory - // (bin, lib, gsd-core, src). + // (bin, lib, gsd-core, hooks, src). function isSourceReadFileSync(node) { if (!node || node.type !== 'CallExpression') return false; @@ -345,7 +369,7 @@ const rule = { if (!args || args.length === 0) return false; const firstArg = args[0]; - const fullSrc = sourceCode.getText(firstArg); + const fullSrc = resolveOneHopText(firstArg); return looksLikeSourcePath(fullSrc); } @@ -359,8 +383,9 @@ const rule = { const hasSourceExt = /['"`.][^'"`.]*\.(?:cts|mts|mjs|cjs|js|ts)['"`)]/i.test(src); if (!hasSourceExt) return false; - // Must reference a source directory indicator somewhere in the expression - const hasSourceDir = /['"](?:bin|lib|gsd-core|src)['"]/i.test(src); + // Must reference a source directory indicator somewhere in the + // expression (bin, lib, gsd-core, hooks, src). + const hasSourceDir = /['"](?:bin|lib|gsd-core|hooks|src)['"]/i.test(src); return hasSourceDir; } diff --git a/scripts/lint-allow-test-rule-refs.effective-ceiling.json b/scripts/lint-allow-test-rule-refs.effective-ceiling.json index 3546fc07b..416338cc8 100644 --- a/scripts/lint-allow-test-rule-refs.effective-ceiling.json +++ b/scripts/lint-allow-test-rule-refs.effective-ceiling.json @@ -1,4 +1,4 @@ { - "maxSites": 10, + "maxSites": 81, "grace": 2 } diff --git a/tests/bugs-1656-1657.test.cjs b/tests/bugs-1656-1657.test.cjs index e6c6ce038..b54d0747d 100644 --- a/tests/bugs-1656-1657.test.cjs +++ b/tests/bugs-1656-1657.test.cjs @@ -55,6 +55,11 @@ describe('#1656: community .sh hooks must be present in hooks/dist', () => { describe('#1657 / #191: installer/package metadata retires sdk seam', () => { let src; test('install.js does not contain the legacy promptSdk() prompt (#1657)', () => { + // allow-test-rule: structural-regression-guard (#3545) — regression guard + // against literally reintroducing dead/removed code strings (promptSdk(), + // --sdk/--no-sdk parsing, installSdkIfNeeded({) — none of them are live + // exports or behavior to exercise via require(), so the source text + // itself is the contract this test protects src = fs.readFileSync(INSTALL_SRC, 'utf-8'); assert.ok( !src.includes('promptSdk('), diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index ae77241d0..cee06d76f 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -765,6 +765,10 @@ describe('--check drift detection', () => { // helpers the CLI uses, applied to in-memory strings — giving identical coverage // without touching the filesystem. const originalContent = fs.readFileSync(REGISTRY_PATH, 'utf8'); + // allow-test-rule: source-text-is-the-product (#3545) — checkPipeline() below is a + // raw-text diffing pipeline; this .replace() builds an in-memory tampered + // TEXT fixture to drive that real pipeline call, not a text-grep proxy for + // module behavior const tamperedContent = originalContent.replace( "version: '" + SCHEMA_VERSION + "'", "version: '0-stale'", @@ -790,6 +794,8 @@ describe('--check drift detection', () => { // Also verify the tampered content contains the stale marker (so the above // assertion is meaningful and not vacuously true due to other diff). assert.ok( + // allow-test-rule: source-text-is-the-product (#3545) — sanity check on the + // same in-memory tampered TEXT fixture, not a proxy for module behavior tamperedContent.includes("version: '0-stale'"), 'precondition: tampered content must contain the stale version marker', ); diff --git a/tests/cjs-command-router-adapter.test.cjs b/tests/cjs-command-router-adapter.test.cjs index 7fb069806..4409ee304 100644 --- a/tests/cjs-command-router-adapter.test.cjs +++ b/tests/cjs-command-router-adapter.test.cjs @@ -269,6 +269,9 @@ describe('bug #224: --pick stdout capture contract', () => { let src; before(() => { + // allow-test-rule: structural-implementation-guard (see #224) — locks the + // main()-body byte-length seam contract structurally (no deterministic + // Windows repro harness yet); see block header above. src = fs.readFileSync(GSD_TOOLS_SRC, 'utf-8'); }); diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index 8b1297388..06b15a65f 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -3314,32 +3314,36 @@ test('writeNonClaudeDefaults is called before installCodexConfig in the Codex in const src = fs.readFileSync(INSTALL_JS, 'utf8'); // Find the call to writeNonClaudeDefaults that precedes installCodexConfig. - const writeIdx = src.indexOf('writeNonClaudeDefaults(runtime);'); + const writeIdx = src.indexOf('writeNonClaudeDefaults(runtime);'); // allow-test-rule: structural-implementation-guard (#2834) assert.ok(writeIdx !== -1, 'writeNonClaudeDefaults(runtime) must be called in the install flow'); // Find the FIRST installCodexConfig call AFTER the writeNonClaudeDefaults call. - const codexGenIdx = src.indexOf('installCodexConfig(targetDir', writeIdx); + const codexGenIdx = src.indexOf('installCodexConfig(targetDir', writeIdx); // allow-test-rule: structural-implementation-guard (#2834) assert.ok(codexGenIdx !== -1 && codexGenIdx > writeIdx, 'installCodexConfig must be called AFTER writeNonClaudeDefaults so defaults.json ' + '(resolve_model_ids + runtime) exists before agent TOML generation reads it (#2834)'); // The #2834 comment must be present at the call site. const callSite = src.slice(writeIdx - 300, writeIdx + 100); - assert.ok(/#2834/.test(callSite), 'the writeNonClaudeDefaults call must carry the #2834 rationale comment'); + assert.ok(/#2834/.test(callSite), 'the writeNonClaudeDefaults call must carry the #2834 rationale comment'); // allow-test-rule: structural-implementation-guard (#2834) }); test('writeNonClaudeDefaults function exists and is a no-op for Claude (#2834)', () => { const src = fs.readFileSync(INSTALL_JS, 'utf8'); - const fnIdx = src.indexOf('function writeNonClaudeDefaults('); + const fnIdx = src.indexOf('function writeNonClaudeDefaults('); // allow-test-rule: structural-implementation-guard (#2834) assert.ok(fnIdx !== -1, 'writeNonClaudeDefaults must be defined as a function'); // Bound the slice by the next top-level declaration rather than a fixed // character count, so adding a comment or a guard inside the function cannot // push the asserted tokens out of the window and red this test spuriously. - const nextFnIdx = src.indexOf('\nfunction ', fnIdx + 1); + const nextFnIdx = src.indexOf('\nfunction ', fnIdx + 1); // allow-test-rule: structural-implementation-guard (#2834) const fnBody = src.slice(fnIdx, nextFnIdx === -1 ? undefined : nextFnIdx); - assert.ok(/nativeModelAliases/.test(fnBody), 'writeNonClaudeDefaults must early-return for Claude (nativeModelAliases check)'); - assert.ok(/resolve_model_ids/.test(fnBody), 'writeNonClaudeDefaults must write resolve_model_ids'); - assert.ok(/defaults\.runtime/.test(fnBody), 'writeNonClaudeDefaults must write runtime'); + // Source-text guard, not a behavioral call: writeNonClaudeDefaults() early-returns + // as a no-op whenever process.env.GSD_TEST_MODE is set (see its own body), and this + // suite sets GSD_TEST_MODE='1' file-wide (line 14), so invoking it here could never + // observe the resolve_model_ids/runtime writes it is supposed to make (#2834). + assert.ok(/nativeModelAliases/.test(fnBody), 'writeNonClaudeDefaults must early-return for Claude (nativeModelAliases check)'); // allow-test-rule: structural-implementation-guard (#2834) + assert.ok(/resolve_model_ids/.test(fnBody), 'writeNonClaudeDefaults must write resolve_model_ids'); // allow-test-rule: structural-implementation-guard (#2834) + assert.ok(/defaults\.runtime/.test(fnBody), 'writeNonClaudeDefaults must write runtime'); // allow-test-rule: structural-implementation-guard (#2834) }); }); } @@ -6254,16 +6258,18 @@ const src = fs.readFileSync(INSTALL_JS, 'utf8'); describe('bug #279: Codex adapter documents Agent() and deferred tool discovery', () => { test('adapter mapping section includes explicit Agent(...) -> spawn_agent mapping', () => { + // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) assert.ok( - /Task\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src) && - /Agent\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src), + /Task\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src) && // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) + /Agent\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src), // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) 'Codex adapter must explicitly map both Task(...) and Agent(...) to spawn_agent', ); }); test('adapter includes deferred tool_search discovery guidance before inline fallback', () => { + // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) assert.ok( - src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), + src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), // allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js] (see #279) 'Codex adapter must instruct deferred tool discovery via tool_search before deciding to run inline', ); }); @@ -10089,9 +10095,10 @@ const src = fs.readFileSync(INSTALL_JS, 'utf8'); // Helper: extract Section C from the raw source text. // Anchors on the heading and ends at . function getSectionC() { - const headingIdx = src.indexOf('## C. Task() → spawn_agent Mapping'); + // allow-test-rule: source-text-is-the-product (see #851) + const headingIdx = src.indexOf('## C. Task() → spawn_agent Mapping'); // allow-test-rule: source-text-is-the-product (see #851) assert.ok(headingIdx >= 0, 'Section C heading must exist in bin/install.js'); - const closeTag = src.indexOf('', headingIdx); + const closeTag = src.indexOf('', headingIdx); // allow-test-rule: source-text-is-the-product (see #851) assert.ok(closeTag >= 0, 'Section C must be followed by '); return src.slice(headingIdx, closeTag); } @@ -10281,8 +10288,9 @@ describe('bug #851: Codex adapter documents multi_agent_v1 schema limitation and // Regression guard: deferred tool discovery must remain (bug-279 contract). test('adapter deferred tool discovery instruction is preserved', () => { // The pre-existing bug-279 contract must remain intact + // allow-test-rule: source-text-is-the-product (see #851) assert.ok( - src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), + src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'), // allow-test-rule: source-text-is-the-product (see #851) 'Adapter must still instruct deferred tool discovery via tool_search before deciding to run inline', ); }); diff --git a/tests/config-field-docs.test.cjs b/tests/config-field-docs.test.cjs index c71ccf28a..0ed1279c6 100644 --- a/tests/config-field-docs.test.cjs +++ b/tests/config-field-docs.test.cjs @@ -83,17 +83,12 @@ describe('config-field-docs', () => { }); test('every CONFIG_DEFAULTS key appears in the doc', () => { - // Extract CONFIG_DEFAULTS keys from config-loader.cjs source (moved from core.cjs by ADR-857 phase 2e) - const coreSource = fs.readFileSync(CORE_PATH, 'utf-8'); - const defaultsMatch = coreSource.match( - // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own config-loader.cjs source, fixed-size author-controlled content - /const CONFIG_DEFAULTS\s*=\s*\{([\s\S]*?)\r?\n\};/ - ); - assert.ok(defaultsMatch, 'Could not find CONFIG_DEFAULTS in config-loader.cjs'); + // Read CONFIG_DEFAULTS' actual keys straight from the module (moved from + // core.cjs by ADR-857 phase 2e) instead of regex-parsing its source text. + const { CONFIG_DEFAULTS } = require(CORE_PATH); + assert.ok(CONFIG_DEFAULTS && typeof CONFIG_DEFAULTS === 'object', 'Could not find CONFIG_DEFAULTS export in config-loader.cjs'); - const body = defaultsMatch[1]; - // Match property keys (word characters before the colon) - const keys = [...body.matchAll(/^\s*(\w+)\s*:/gm)].map(m => m[1]); + const keys = Object.keys(CONFIG_DEFAULTS); assert.ok(keys.length > 0, 'Could not extract any keys from CONFIG_DEFAULTS'); // CONFIG_DEFAULTS uses flat keys; the doc may use namespaced equivalents. diff --git a/tests/declarative-reference-augment.test.cjs b/tests/declarative-reference-augment.test.cjs index aca252021..723e16e5f 100644 --- a/tests/declarative-reference-augment.test.cjs +++ b/tests/declarative-reference-augment.test.cjs @@ -182,6 +182,9 @@ test('no `isAugment` occurrence gates a call to an Augment converter function (t const converterCallPattern = new RegExp(`(${converterNames.join('|')})\\s*\\(`); const file = path.join(__dirname, '..', 'bin', 'install.js'); + // allow-test-rule: structural-regression-guard (#3545) — absence of an + // isAugment-gated converter call is a source-text property (see file + // header, #2097). const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); const offenders = []; for (let i = 0; i < lines.length; i++) { @@ -200,6 +203,9 @@ test('no `isAugment` occurrence gates a call to an Augment converter function (t test('legitimate isAugment destructure/enumeration sites survive (not eliminated, unlike isAntigravity)', () => { const file = path.join(__dirname, '..', 'bin', 'install.js'); + // allow-test-rule: structural-regression-guard (#3545) — presence of the + // isAugment identifier itself is a source-text property (see file header, + // #2097). const src = fs.readFileSync(file, 'utf8'); assert.ok(/isAugment/.test(src), 'isAugment must still be destructured from runtimeFlags() for non-conversion uses'); }); @@ -234,6 +240,9 @@ test('augment capability.json declares a real, converter-bearing agents kind for test('_DESCRIPTOR_AGENTS_RUNTIMES no longer exists as a live declaration — the descriptor is authoritative for every runtime, not an allow-listed subset', () => { const file = path.join(__dirname, '..', 'bin', 'install.js'); + // allow-test-rule: structural-regression-guard (#3545) — absence of a live + // _DESCRIPTOR_AGENTS_RUNTIMES declaration is a source-text property (see + // file header, #2097). const src = fs.readFileSync(file, 'utf8'); // A residual mention in a historical // comment (documenting the #2875 // deletion itself) is fine; a live `const _DESCRIPTOR_AGENTS_RUNTIMES =` diff --git a/tests/eslint-rules.test.cjs b/tests/eslint-rules.test.cjs index 269a12ed1..7294adc81 100644 --- a/tests/eslint-rules.test.cjs +++ b/tests/eslint-rules.test.cjs @@ -541,6 +541,161 @@ describe('no-source-grep rule — widening (#3502)', () => { ], }); }); + + // ─── fold + hooks widening (#3545 / Phase 7 of #3464) ───────────────────── + // + // One RuleTester case per row of + // .gsd/phase/chore-3545-fold-hooks-widening-migration/50-test-matrix.md, + // rows 1-8. Covers `fold` (a bare-Identifier readFileSync + // path argument resolved ONE hop back to its VariableDeclarator init) and + // `hooks` (now a recognized source directory alongside bin/lib/gsd-core/src). + + test('#3545 row 1: baseline inline literal src path — unchanged by fold/hooks widening', () => { + ruleTester.run('no-source-grep', noSourceGrep, { + valid: [], + invalid: [ + { + code: ` + const fs = require('fs'); + const path = require('path'); + const s = fs.readFileSync(path.join(__dirname, '..', 'src', 'x.cjs'), 'utf-8'); + s.includes('y'); + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'noSourceGrep' }], + }, + ], + }); + }); + + test('#3545 row 2: one-hop identifier bound to a src-dir path is now flagged (fold)', () => { + ruleTester.run('no-source-grep', noSourceGrep, { + valid: [], + invalid: [ + { + code: ` + const fs = require('fs'); + const path = require('path'); + const p = path.join(__dirname, '..', 'src', 'x.cjs'); + const s = fs.readFileSync(p, 'utf-8'); + s.includes('y'); + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'noSourceGrep' }], + }, + ], + }); + }); + + test('#3545 row 3: one-hop identifier bound to a hooks-dir path is now flagged (fold + hooks)', () => { + ruleTester.run('no-source-grep', noSourceGrep, { + valid: [], + invalid: [ + { + code: ` + const fs = require('fs'); + const path = require('path'); + const p = path.join(__dirname, '..', 'hooks', 'x.cjs'); + const s = fs.readFileSync(p, 'utf-8'); + s.includes('y'); + `, + filename: 'tests/foo.test.cjs', + errors: [{ messageId: 'noSourceGrep' }], + }, + ], + }); + }); + + test('#3545 row 4: identifier bound to a non-path value is not flagged (fold negative space)', () => { + ruleTester.run('no-source-grep', noSourceGrep, { + valid: [ + { + code: ` + const fs = require('fs'); + const p = process.env.FOO; + const s = fs.readFileSync(p, 'utf-8'); + s.includes('y'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('#3545 row 5: identifier bound to a non-source-extension path is not flagged (fold negative space)', () => { + ruleTester.run('no-source-grep', noSourceGrep, { + valid: [ + { + code: ` + const fs = require('fs'); + const path = require('path'); + const p = path.join(__dirname, '..', 'src', 'x.md'); + const s = fs.readFileSync(p, 'utf-8'); + s.includes('y'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('#3545 row 6: two-hop indirection is not flagged — fold only resolves one hop (boundary)', () => { + ruleTester.run('no-source-grep', noSourceGrep, { + valid: [ + { + code: ` + const fs = require('fs'); + const path = require('path'); + const a = path.join(__dirname, '..', 'src', 'x.cjs'); + const p = a; + const s = fs.readFileSync(p, 'utf-8'); + s.includes('y'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('#3545 row 7: assignment-bound path is not flagged — fold only resolves a VariableDeclarator init (boundary)', () => { + ruleTester.run('no-source-grep', noSourceGrep, { + valid: [ + { + code: ` + const fs = require('fs'); + const path = require('path'); + let p; + p = path.join(__dirname, '..', 'src', 'x.cjs'); + const s = fs.readFileSync(p, 'utf-8'); + s.includes('y'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('#3545 row 8: "hooks" as a substring of a longer quoted segment is not flagged (hasSourceDir requires an exact quoted segment)', () => { + ruleTester.run('no-source-grep', noSourceGrep, { + valid: [ + { + code: ` + const fs = require('fs'); + const path = require('path'); + const p = path.join(__dirname, '..', 'my-hooks-dir', 'x.cjs'); + const s = fs.readFileSync(p, 'utf-8'); + s.includes('y'); + `, + filename: 'tests/foo.test.cjs', + }, + ], + invalid: [], + }); + }); }); // ─── no-source-grep site-scoped suppression (#3508 / Phase 4 of #3464) ────── diff --git a/tests/gsd-check-update-worker-platform-gate.test.cjs b/tests/gsd-check-update-worker-platform-gate.test.cjs index 2c6fb65a0..566e16827 100644 --- a/tests/gsd-check-update-worker-platform-gate.test.cjs +++ b/tests/gsd-check-update-worker-platform-gate.test.cjs @@ -477,18 +477,15 @@ describe('Issue #815: --next dist-tag support', () => { * 4. Single-source: check-latest-version's PACKAGE_NAME === the seam's * packageName === the scoped '@opengsd/gsd-core'. * - * Source-grep policy: this test reads hook source via readFileSync. The repo's - * lint-no-source-grep rule targets bin/lib/gsd-core — hooks/ is out of - * scope. The behavior (correct name → no E404) only manifests at runtime + * Source-grep policy: this test reads hook source via readFileSync. Since + * #3545 lint-no-source-grep also covers hooks/, not just bin/lib/gsd-core. + * The behavior (correct name → no E404) only manifests at runtime * against the live registry; structural assertions are the minimum-cost - * contract for the worker, the same rationale #378 carried. + * contract for the worker, the same rationale #378 carried. See the + * site-scoped `allow-test-rule` marker directly above the readFileSync() + * call in workerCodeOnly() below. */ -// allow-test-rule: structural-regression-guard (see #378) -// structural assertion on hook delegation; the behavior being -// tested (correct package name → no E404) only manifests at runtime against the -// live npm registry, which CI does not call. - 'use strict'; const { test, describe } = require('node:test'); @@ -502,6 +499,11 @@ const SEAM = require('../gsd-core/bin/lib/package-identity.cjs'); const { PACKAGE_NAME } = require('../gsd-core/bin/check-latest-version.cjs'); function workerCodeOnly() { + // allow-test-rule: structural-regression-guard (see #378) — the behavior + // being tested (correct scoped package name → no E404) only manifests at + // runtime against the live npm registry, which CI does not call; the + // stripped-source text is the minimum-cost contract for this worker + // (#3545 widening brings hooks/ into the rule's scope) const src = fs.readFileSync(WORKER_PATH, 'utf8'); return src // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded hooks source, not adversarial input @@ -566,10 +568,11 @@ describe('bug #378 / #498: update worker queries the scoped name via the seam', { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2784-update-cache-clear-path (consolidation epic #1969 B5 #1974)", () => { -// allow-test-rule: structural-regression-guard (see #2784) // Reads hook .js or bin/install.js source to assert structural invariants // (search array order, function wiring, path constants) that cannot be -// verified by observing runtime outputs alone. Per CONTRIBUTING.md exception matrix. +// verified by observing runtime outputs alone. Per CONTRIBUTING.md exception +// matrix. See the site-scoped `allow-test-rule` marker directly above the +// readFileSync() call below (#3545 widening brings hooks/ into scope). /** * Regression test for bug #2784 @@ -603,6 +606,9 @@ const CHECK_UPDATE_HOOK = path.join(REPO_ROOT, 'hooks', 'gsd-check-update.js'); describe('bug-2784: update.md cache-clear covers shared cache path', () => { test('gsd-check-update.js hook constructs cache dir from .cache and gsd path segments', () => { + // allow-test-rule: structural-regression-guard (see #2784) — asserts + // the path.join() segment structure that cannot be verified by + // observing runtime outputs alone (#3545) const hookContent = fs.readFileSync(CHECK_UPDATE_HOOK, 'utf-8'); // Parse the path.join() call structurally rather than text-grepping. // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded hooks/gsd-check-update.js source, not adversarial input diff --git a/tests/io.test.cjs b/tests/io.test.cjs index 01809690e..90664a1b7 100644 --- a/tests/io.test.cjs +++ b/tests/io.test.cjs @@ -533,6 +533,10 @@ describe('bug #1891: @file: resolution in gsd-tools.cjs', () => { let src; before(() => { + // allow-test-rule: structural-implementation-guard (see #1891) — gsd-tools.cjs's + // stdout @file: interception has no exported symbol to assert on directly; every + // src.includes()/indexOf()/match() call in this describe block traces back to this + // read (#3545) src = fs.readFileSync(GSD_TOOLS_SRC, 'utf-8'); }); diff --git a/tests/kimi-normalize-payload.property.test.cjs b/tests/kimi-normalize-payload.property.test.cjs index 492bcd0fe..fe1da65cb 100644 --- a/tests/kimi-normalize-payload.property.test.cjs +++ b/tests/kimi-normalize-payload.property.test.cjs @@ -54,9 +54,15 @@ const HOOK = path.join(__dirname, '..', 'hooks', 'gsd-worktree-path-guard.js'); function loadNormalizer() { const src = fs.readFileSync(HOOK, 'utf8'); + // allow-test-rule: source-text-is-the-product (#3545) — normalizeKimiPayload + // is deliberately inlined per hook script with no require()-able module (see + // file-header note above); text-index extraction is the only way to bind + // and eval it, mirroring tests/kimi-guard-normalization-parity.test.cjs const start = src.indexOf('const KIMI_TOOL_NAMES'); assert.notEqual(start, -1, 'KIMI_TOOL_NAMES block not found in hook source'); const endMarker = ' return data;\n}'; + // allow-test-rule: source-text-is-the-product (#3545) — same inlined-block + // extraction as above, locating the end of the block to eval const end = src.indexOf(endMarker, start); assert.notEqual(end, -1, 'normalizeKimiPayload end not found in hook source'); const block = src.slice(start, end + endMarker.length); diff --git a/tests/legacy-cleanup-config-dir.test.cjs b/tests/legacy-cleanup-config-dir.test.cjs index 798a49d54..16a568f49 100644 --- a/tests/legacy-cleanup-config-dir.test.cjs +++ b/tests/legacy-cleanup-config-dir.test.cjs @@ -24,6 +24,7 @@ const REPO_ROOT = path.join(__dirname, '..'); const INSTALL_BIN = path.join(REPO_ROOT, 'bin', 'install.js'); const { cleanupLegacyGsdCc } = require(INSTALL_BIN); const { createTempDir, cleanup } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); const LEGACY_PKG_SIGNAL = 'get-shit-done-cc'; @@ -103,17 +104,24 @@ describe('#3799: cleanupLegacyGsdCc honors an explicit configDirs scope', () => }); describe('#3799: --no-legacy-cleanup and --config-dir CLI flags', () => { - const HELP_TEXT = fs.readFileSync(INSTALL_BIN, 'utf-8'); - test('the --no-legacy-cleanup flag exists and is documented in --help', () => { + const result = runNode([INSTALL_BIN, '--help']); + assert.equal(result.outcome, 'exited', `install.js --help did not exit cleanly: ${result.stderr}`); assert.ok( - HELP_TEXT.includes('--no-legacy-cleanup'), - 'the escape hatch must be documented in the installer help text', + result.stdout.includes('--no-legacy-cleanup'), + 'the escape hatch must be documented in the installer --help output', ); }); test('the install() call site threads the config-dir scope and the skip flag', () => { - const src = HELP_TEXT; // same file read — the shipped installer source + // allow-test-rule: structural-implementation-guard (#3545) — structural assertion + // on install()'s internal wiring (that its cleanupLegacyGsdCc call site threads configDirs/ + // skipNoLegacyCleanup) — install() is a ~2500-line, side-effect-heavy + // top-level installer routine, so exercising this specific plumbing + // behaviorally would require a full install() run; the source-slice + // check is the minimum-cost regression guard for the exact #3799 defect + // shape + const src = fs.readFileSync(INSTALL_BIN, 'utf-8'); // Slice the install() body up to its cleanup call so the conditional // spread's braces cannot defeat a single-regex match. const fnStart = src.indexOf('function install(isGlobal, runtime = DEFAULT_RUNTIME'); diff --git a/tests/orphaned-hooks.test.cjs b/tests/orphaned-hooks.test.cjs index 62ee1c8b3..c770516dc 100644 --- a/tests/orphaned-hooks.test.cjs +++ b/tests/orphaned-hooks.test.cjs @@ -42,6 +42,9 @@ describe('orphaned hooks stale detection (#1750)', () => { }); test('gsd-check-update-worker.js imports managed-hooks-registry.cjs (not inline array)', () => { + // allow-test-rule: structural-regression-guard (#3545) — presence of a + // require() wire-up and absence of a reintroduced inline array are source + // structure facts, not runtime behavior; see file header. const content = fs.readFileSync(WORKER_PATH, 'utf8'); assert.ok( content.includes('managed-hooks-registry.cjs'), @@ -55,6 +58,9 @@ describe('orphaned hooks stale detection (#1750)', () => { }); test('gsd-check-update.js spawns the worker by file path (not inline -e code)', () => { + // allow-test-rule: structural-regression-guard (#3545) — spawn-target + // wiring and absence of inline `-e` code are source structure facts, not + // runtime behavior; see file header. const content = fs.readFileSync(CHECK_UPDATE_PATH, 'utf8'); assert.ok( content.includes('gsd-check-update-worker.js'), diff --git a/tests/portability-vocab-drift.test.cjs b/tests/portability-vocab-drift.test.cjs index f5287e6b2..a4d1b72c5 100644 --- a/tests/portability-vocab-drift.test.cjs +++ b/tests/portability-vocab-drift.test.cjs @@ -367,6 +367,9 @@ describe('portability-vocab drift guard', () => { const srcPath = path.join(__dirname, '..', 'bin', 'install.js'); const raw = fs.readFileSync(srcPath, 'utf8'); // bin/install.js starts with a shebang espree cannot parse — rewrite #! -> //. + // allow-test-rule: source-text-is-the-product (#3545) — this is shebang-stripping + // to make the source parseable by espree; the test then walks the real AST + // structurally (below), it is not a text-grep proxy for behavior const src = raw.startsWith('#!') ? '//' + raw.slice(2) : raw; const ast = espree.parse(src, { ecmaVersion: 2022, loc: true, range: true, tolerant: true }); @@ -432,6 +435,9 @@ describe('portability-vocab drift guard', () => { test('bin/install.js: the curated INSTALL_JS_PATH_HELPERS still exist (no stale vocab entries after a rename)', () => { const srcPath = path.join(__dirname, '..', 'bin', 'install.js'); const raw = fs.readFileSync(srcPath, 'utf8'); + // allow-test-rule: source-text-is-the-product (#3545) — shebang-stripping to + // make the source parseable by espree; the test walks the real AST + // structurally (below), not a text-grep proxy for behavior const src = raw.startsWith('#!') ? '//' + raw.slice(2) : raw; const ast = espree.parse(src, { ecmaVersion: 2022, loc: true, range: true, tolerant: true }); diff --git a/tests/read-guard.test.cjs b/tests/read-guard.test.cjs index 9b4d2dfad..200b3ffbe 100644 --- a/tests/read-guard.test.cjs +++ b/tests/read-guard.test.cjs @@ -215,11 +215,12 @@ describe('gsd-read-guard hook', () => { }); test('hook is registered in install.js uninstall hook list', () => { - const installPath = path.join(__dirname, '..', 'bin', 'install.js'); - const content = fs.readFileSync(installPath, 'utf8'); + // Check the actual exported uninstall hook list instead of grepping + // install.js source text. + const { GSD_UNINSTALL_HOOKS } = require('../bin/install.js'); assert.ok( - content.includes("'gsd-read-guard.js'"), - 'gsd-read-guard.js must be in the uninstall gsdHooks list' + GSD_UNINSTALL_HOOKS.includes('gsd-read-guard.js'), + 'gsd-read-guard.js must be in the uninstall GSD_UNINSTALL_HOOKS list' ); }); diff --git a/tests/repo-layout.test.cjs b/tests/repo-layout.test.cjs index 0e6ae424c..6139b64e1 100644 --- a/tests/repo-layout.test.cjs +++ b/tests/repo-layout.test.cjs @@ -156,6 +156,10 @@ test('enhancement #191: published package no longer exposes gsd-sdk artifacts', }); test('enhancement #191: installer does not maintain gsd-sdk shim compatibility path', () => { + // allow-test-rule: structural-regression-guard (#3545) — asserting the + // ABSENCE of a retired flag/function reference from install.js is a source + // structure fact (dead-code regression), not something observable by + // exercising the installer's runtime behavior. const installJs = fs.readFileSync(INSTALL_PATH, 'utf8'); assert.equal(/\b--sdk\b/.test(installJs), false, diff --git a/tests/roadmap-parser.test.cjs b/tests/roadmap-parser.test.cjs index bed776812..dc7989056 100644 --- a/tests/roadmap-parser.test.cjs +++ b/tests/roadmap-parser.test.cjs @@ -1845,10 +1845,10 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { createTempDir, cleanup } = require('./helpers.cjs'); const ROOT = path.join(__dirname, '..'); // Require the module under test directly -const roadmapLib = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'roadmap.cjs'); const planScanLib = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'plan-scan.cjs'); // We test countPhasePlansAndSummaries indirectly via getManagerInfo since @@ -1893,24 +1893,29 @@ describe('bug #3128: roadmap.cjs plan-count for {N}-PLAN-{NN}-{slug}.md layout', assert.ok(!isPlanFile('5-RESEARCH.md'), 'RESEARCH.md must not match'); }); - test('roadmap.cjs source uses the extended isPlanFile filter', () => { - const roadmapSrc = fs.readFileSync(roadmapLib, 'utf8'); - // Verify the fix is in place: the old simple inline filter is gone from roadmap.cjs + test('roadmap.cjs source uses the extended isPlanFile filter', (t) => { + // roadmap.cjs's countPhasePlansAndSummaries (module-private) delegates its + // plan counting to plan-scan.cjs's scanPhasePlans/isRootPlanFile -- exercise + // the REAL exported module directly instead of grepping roadmap.cjs's source + // text for the delegation. + const planScan = require(planScanLib); + + // isRootPlanFile must recognize the {N}-PLAN-{NN}-{slug}.md layout (#3128) + // that the old inline `f.endsWith('-PLAN.md') || f === 'PLAN.md'` filter missed. assert.ok( - !roadmapSrc.includes("phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md')"), - 'Old simple plan filter still present in roadmap.cjs — fix not applied', - ); - // roadmap.cjs now delegates to plan-scan.cjs via require('./plan-scan.cjs') - assert.ok( - roadmapSrc.includes('plan-scan.cjs'), - 'roadmap.cjs does not require plan-scan.cjs — delegation not applied', - ); - // plan-scan.cjs is where the extended plan-file detection logic lives (isRootPlanFile) - const planScanSrc = fs.readFileSync(planScanLib, 'utf8'); - assert.ok( - planScanSrc.includes('isRootPlanFile') && planScanSrc.includes('/PLAN/i'), - 'isRootPlanFile with /PLAN/i not found in plan-scan.cjs — canonical helper missing extended filter', + planScan.isRootPlanFile('5-PLAN-01-setup-database.md'), + 'isRootPlanFile must recognize the slug-form plan filename from bug #3128', ); + + // Exercise scanPhasePlans against a synthetic phase directory containing + // only a slug-form plan file -- this is the SAME production function + // roadmap.cjs's countPhasePlansAndSummaries calls, so a correct count here + // proves the extended filter is what actually runs, not a copy of it. + const tmpDir = createTempDir('roadmap-plan-scan-'); + t.after(() => cleanup(tmpDir)); + fs.writeFileSync(path.join(tmpDir, '5-PLAN-01-setup-database.md'), '# plan\n'); + const scanResult = planScan(tmpDir); + assert.equal(scanResult.planCount, 1, 'scanPhasePlans must count the slug-form plan file'); }); }); }); diff --git a/tests/runtime-homes-legacy-ids-drift-guard.test.cjs b/tests/runtime-homes-legacy-ids-drift-guard.test.cjs index 2081e20aa..bf24e760a 100644 --- a/tests/runtime-homes-legacy-ids-drift-guard.test.cjs +++ b/tests/runtime-homes-legacy-ids-drift-guard.test.cjs @@ -130,6 +130,11 @@ describe('#3024 review finding 2: LEGACY_NON_REGISTRY_RUNTIME_IDS drift guard', assert.ok( !registryIds.includes(SENTINEL_ID) && !legacyIds.includes(SENTINEL_ID) && + // allow-test-rule: source-text-is-the-product (#3545) — sourceParsedIds + // is enumeration scaffolding derived from readFileSync'd source text + // (see parseHardcodedBranchIds doc comment above); every id it turns + // up is verified BEHAVIORALLY below via getGlobalConfigDir, never + // trusted on its own !sourceParsedIds.includes(SENTINEL_ID), `sentinel id ${SENTINEL_ID} unexpectedly collides with a real candidate id — pick a different sentinel`, ); diff --git a/tests/settings-jsonc.test.cjs b/tests/settings-jsonc.test.cjs index dbb2d63f3..8c35d2d98 100644 --- a/tests/settings-jsonc.test.cjs +++ b/tests/settings-jsonc.test.cjs @@ -147,22 +147,35 @@ describe('stripJsonComments (#1461)', () => { }); describe('readSettings null return on malformed files (#1461)', () => { - test('install.js contains JSONC stripping in readSettings', () => { - const installPath = path.join(__dirname, '..', 'bin', 'install.js'); - const content = fs.readFileSync(installPath, 'utf8'); - assert.ok(content.includes('stripJsonComments'), - 'install.js should use stripJsonComments in readSettings'); + test('readSettings strips JSONC comments when reading a real file', (t) => { + const tmpFile = path.join(os.tmpdir(), `gsd-settings-test-jsonc-${process.pid}.json`); + fs.writeFileSync(tmpFile, `{\n // a comment\n "key": "value"\n}`); + t.after(() => fs.unlinkSync(tmpFile)); + const result = readSettings(tmpFile); + assert.deepStrictEqual( + result, + { key: 'value' }, + 'readSettings should use stripJsonComments so a commented file parses, not warns-as-malformed' + ); }); - test('readSettings returns null on truly malformed files (not empty object)', () => { - const installPath = path.join(__dirname, '..', 'bin', 'install.js'); - const content = fs.readFileSync(installPath, 'utf8'); - assert.ok(content.includes('return null'), - 'readSettings should return null on parse failure, not empty object'); + test('readSettings returns null on truly malformed files (not empty object)', (t) => { + const tmpFile = path.join(os.tmpdir(), `gsd-settings-test-malformed-return-${process.pid}.json`); + fs.writeFileSync(tmpFile, '{ this is not valid json'); + t.after(() => fs.unlinkSync(tmpFile)); + const result = readSettings(tmpFile); + assert.strictEqual(result, null, 'readSettings should return null on parse failure, not empty object'); }); test('callers guard against null readSettings return', () => { const installPath = path.join(__dirname, '..', 'bin', 'install.js'); + // allow-test-rule: structural-implementation-guard (#1461) (#3545) — structural + // assertion on internal wiring inside install()'s (~2500-line) + // settings-configuration call sites — the + // null-guard only manifests behaviorally deep inside a full install() + // run, so the source-text check is the minimum-cost regression guard + // that a caller was not added without also checking readSettings' + // documented null return const content = fs.readFileSync(installPath, 'utf8'); // Should have null guards at the settings configuration call sites assert.ok( @@ -180,13 +193,16 @@ describe('readSettings null return on malformed files (#1461)', () => { // the behavioural ones beneath it. describe('readSettings: JSON null coalesced to empty, malformed warns (#1191)', () => { - test('source contains the null-coalescing guard (parsed === null ? {})', () => { - // Structural anchor: if someone removes the coalescing, this test catches it - // before the behavioural test below even runs. - const installPath = path.join(__dirname, '..', 'bin', 'install.js'); - const content = fs.readFileSync(installPath, 'utf8'); - assert.ok( - content.includes('parsed === null ? {}'), + test('valid JSON null coalesces to {} via the real function (early behavioral anchor)', (t) => { + // Behavioral anchor: if someone removes the coalescing, this test catches + // it before the more detailed behavioural test below even runs. + const tmpFile = path.join(os.tmpdir(), `gsd-settings-test-null-anchor-${process.pid}.json`); + fs.writeFileSync(tmpFile, 'null'); + t.after(() => fs.unlinkSync(tmpFile)); + const result = readSettings(tmpFile); + assert.deepStrictEqual( + result, + {}, 'install.js readSettings must coalesce valid JSON null to {} (not malformed warning)' ); }); diff --git a/tests/worktree-safety.test.cjs b/tests/worktree-safety.test.cjs index a16a31f71..d1d63e5c4 100644 --- a/tests/worktree-safety.test.cjs +++ b/tests/worktree-safety.test.cjs @@ -5490,6 +5490,10 @@ describe('install.js guard for gsd-worktree-path-guard.js', () => { before(() => { // ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts. // Concatenate both sources so structural assertions find patterns in either file. + // allow-test-rule: structural-implementation-guard (#3545) — structural install.js + // guard; install.js has side effects on require and no exported symbol for hook-registration wiring; + // every src.includes()/indexOf() and block.includes() call below traces + // back to this read const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8'); let hooksSurfaceSrc = ''; try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ }