diff --git a/scripts/ci-test-scope.cjs b/scripts/ci-test-scope.cjs index dc06bb3db..c3a77f568 100644 --- a/scripts/ci-test-scope.cjs +++ b/scripts/ci-test-scope.cjs @@ -1,6 +1,7 @@ #!/usr/bin/env node 'use strict'; +const path = require('path'); const { execFileSync } = require('child_process'); const { existsSync, readdirSync, appendFileSync } = require('fs'); @@ -105,9 +106,8 @@ const RULES = [ fullMatrix: true, tests: [ 'tests/check-env.test.cjs', - 'tests/npm-integrity-gate.test.cjs', + 'tests/npm-integrity-gate.test.cjs', // #2758: absorbs the former tests/bug-3588-npm-audit-clean.test.cjs (folded into it by consolidation epic #1969 B6 #1975; the stale filename here was a silent coverage hole this rule never actually re-selected) 'tests/package-manifest.test.cjs', - 'tests/bug-3588-npm-audit-clean.test.cjs', ], }, { @@ -116,9 +116,10 @@ const RULES = [ // still trigger the migrated module's tests (otherwise CI silently skips them). match: path => path.startsWith('src/') || path === 'tsconfig.build.json', tests: [ - 'tests/semver-compare.test.cjs', - 'tests/bug-10-semver-policy-consolidation.test.cjs', + 'tests/semver-compare.test.cjs', // #2758: absorbs the former tests/bug-10-semver-policy-consolidation.test.cjs (folded into it by consolidation epic #1969 B3 #1972; the stale filename here was a silent coverage hole this rule never actually re-selected) 'tests/golden-install-parity.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify golden install parity (drift guard) + 'tests/emitted-provenance.test.cjs', // #2758: the differential travels with the golden — ADR-2719 dual-run + 'tests/emitted-attribution.test.cjs', ], }, { @@ -143,6 +144,8 @@ const RULES = [ // changed test file. 'tests/runtime-artifact-layout.test.cjs', 'tests/golden-install-parity.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify golden install parity (drift guard) + 'tests/emitted-provenance.test.cjs', // #2758: the differential travels with the golden — ADR-2719 dual-run + 'tests/emitted-attribution.test.cjs', ], }, { @@ -155,6 +158,10 @@ const RULES = [ // shipped to next undetected). Union semantics: this ADDS the parity guard on // top of each path's existing content-specific tests. Targeted lane only (the // golden test skips win32 by design), no fullMatrix. + // #2758: the Phase 2/3 emitted differential (ADR-2719) travels alongside the + // golden here too — a PR editing only shipped content is the archetypal + // emitted ripple, and until this fix no rule selected the differential at + // all. Both green, fixtures untouched, is the dual-run premise. // NOTE: intentionally NOT a blanket 'gsd-core/' prefix, for two reasons: // (1) gsd-core/bin/** is tsc-compiled runtime output — EXCLUDED_PREFIXES- // excluded from both manifests, and already covered by the 'installer and @@ -174,6 +181,8 @@ const RULES = [ tests: [ 'tests/golden-install-parity.test.cjs', 'tests/golden-install-tree.test.cjs', + 'tests/emitted-provenance.test.cjs', + 'tests/emitted-attribution.test.cjs', ], }, { @@ -236,7 +245,13 @@ const RULES = [ 'tests/workflow-size-budget.test.cjs', 'tests/workflow-guard-registration.test.cjs', 'tests/commands.test.cjs', - 'tests/bug-3683-workflow-colon-namespace-leak.test.cjs', + // #2758: was 'tests/bug-3683-workflow-colon-namespace-leak.test.cjs', deleted by + // consolidation epic #1969 (B6 #1975) and folded into slash-command-namespace.test.cjs + // ("folded:bug-3683-workflow-colon-namespace-leak" describe block). The stale filename + // here was itself an instance of this issue's defect class — silently dropped by + // existingTests() below, so gsd-core/workflows/ changes stopped re-running this + // regression's coverage with nothing signaling it. + 'tests/slash-command-namespace.test.cjs', ], }, { @@ -291,6 +306,41 @@ const RULES = [ }, ]; +/** + * Every RULES[].tests entry (deduped, across every rule) that does NOT exist on + * disk. #2758: a rule naming a test file that no longer exists is not merely + * inert — existingTests() below silently drops it out of targeted_tests, with + * nothing in the CI output signaling why. Phase 4 (#2724) deletes + * tests/golden-install-parity.test.cjs; without this check, any rule still + * naming it would stop selecting the guard entirely and CI would stay green + * throughout. Pure and independent of which rule / which file: it catches ANY + * phantom entry, not only the two names this issue is about. + * Paths resolve relative to the repo root (this file's parent directory), not + * the caller's cwd, so the check behaves identically whether invoked as the CLI + * (`node scripts/ci-test-scope.cjs ...`, cwd == repo root by convention) or + * required directly by a test. + */ +function missingRuleTestFiles(rules) { + const referenced = new Set(); + for (const rule of rules) { + for (const f of rule.tests) referenced.add(f); + } + return [...referenced].filter(f => !existsSync(path.join(__dirname, '..', f))).sort(); +} + +// Fail loudly at module load, mirroring the PROTECTED_WORKFLOWS check above — +// this fires on EVERY invocation of the CLI (including the real `changes` job +// in .github/workflows/test.yml), not only when a test suite happens to run. +{ + const missing = missingRuleTestFiles(RULES); + if (missing.length > 0) { + throw new Error( + `ci-test-scope: RULES reference test file(s) that do not exist on disk ` + + `(silent coverage hole — see #2758):\n ${missing.join('\n ')}`, + ); + } +} + function usage() { return [ 'Usage:', @@ -523,4 +573,8 @@ function main() { } } -runMain(main); +if (require.main === module) { + runMain(main); +} + +module.exports = { RULES, missingRuleTestFiles }; diff --git a/tests/ci-test-scope.test.cjs b/tests/ci-test-scope.test.cjs index 86cd08344..c56cbad54 100644 --- a/tests/ci-test-scope.test.cjs +++ b/tests/ci-test-scope.test.cjs @@ -708,6 +708,115 @@ describe('shipped install content (golden-parity drift guard, #2267)', () => { }); }); +describe('differential attribution gates travel with golden-parity (#2758)', () => { + // #2758: the golden-parity rules ran tests/golden-install-parity.test.cjs on a + // shipped-content-only PR — the archetypal emitted ripple — but selected neither + // tests/emitted-provenance.test.cjs nor tests/emitted-attribution.test.cjs. Fix: + // every rule that selects the golden also selects both emitted gates, so the + // differential travels with the golden everywhere the golden travels (the + // ADR-2719 dual-run premise: both green, fixtures untouched, until Phase 4). + const { RULES } = require('../scripts/ci-test-scope.cjs'); + const GOLDEN = 'tests/golden-install-parity.test.cjs'; + const GATES = ['tests/emitted-provenance.test.cjs', 'tests/emitted-attribution.test.cjs']; + + test('every RULES entry selecting golden-install-parity also selects both emitted gates', () => { + const goldenRules = RULES.filter(r => r.tests.includes(GOLDEN)); + // Guards the guard: if this count ever drops to 0, the assertion below is + // vacuously true and would silently stop meaning anything. + assert.ok( + goldenRules.length >= 3, + `expected at least 3 RULES entries selecting ${GOLDEN}, found ${goldenRules.length}: ` + + `${goldenRules.map(r => r.name).join(', ')}`, + ); + const offenders = goldenRules.filter(r => !GATES.every(g => r.tests.includes(g))); + assert.deepStrictEqual( + offenders.map(r => r.name), + [], + `rule(s) select ${GOLDEN} without both emitted gates: ${offenders.map(r => r.name).join(', ')}`, + ); + }); + + test('a pure shipped-content path selects both emitted gates alongside the golden', () => { + // #2758 AC: "a pure shipped-content path (e.g. gsd-core/workflows/plan-phase.md) + // selects the differential." The archetypal emitted ripple. + const result = scopeFor(['gsd-core/workflows/plan-phase.md']); + assert.strictEqual(result.code_changed, true); + assert.ok(result.targeted_tests.includes(GOLDEN)); + for (const g of GATES) { + assert.ok( + result.targeted_tests.includes(g), + `expected ${g} in targeted_tests for a shipped-content-only change, got: ${JSON.stringify(result.targeted_tests)}`, + ); + } + }); + + test('a src/*.cts-only change selects both emitted gates (TS runtime sources rule)', () => { + const result = scopeFor(['src/milestone.cts']); + for (const g of GATES) { + assert.ok( + result.targeted_tests.includes(g), + `expected ${g} in targeted_tests for src/ change, got: ${JSON.stringify(result.targeted_tests)}`, + ); + } + }); + + test('bin/install.js selects both emitted gates (installer and package layout rule)', () => { + const result = scopeFor(['bin/install.js']); + for (const g of GATES) { + assert.ok( + result.targeted_tests.includes(g), + `expected ${g} in targeted_tests for bin/install.js, got: ${JSON.stringify(result.targeted_tests)}`, + ); + } + }); + + test('docs-only change still does NOT select the emitted gates (negative case)', () => { + const result = scopeFor(['docs/usage.md']); + for (const g of GATES) { + assert.ok(!result.targeted_tests.includes(g), `docs-only must NOT select ${g}, got: ${JSON.stringify(result.targeted_tests)}`); + } + }); +}); + +describe('RULES totality guard: no rule names a test file absent from disk (#2758)', () => { + // #2758: Phase 4 (#2724) deletes tests/golden-install-parity.test.cjs. Without an + // independent guard, a rule still naming it would produce no signal at all — + // existingTests() (scripts/ci-test-scope.cjs) silently filters missing files out + // of targeted_tests, so the gate simply stops being selected while CI stays + // green. This exists independently of the fix above: it catches ANY rule naming + // ANY absent file, not only the two gate filenames this issue is about. + const { RULES, missingRuleTestFiles } = require('../scripts/ci-test-scope.cjs'); + + test('no RULES entry today references a test file absent from disk', () => { + assert.deepStrictEqual( + missingRuleTestFiles(RULES), [], + 'RULES reference test file(s) that do not exist — see missingRuleTestFiles() in scripts/ci-test-scope.cjs', + ); + }); + + test('the guard mechanism itself catches a phantom entry (hostile input)', () => { + // Runs the REAL checker function used by the module-load assertion in + // scripts/ci-test-scope.cjs — not a hand-copied reimplementation of it — against + // a synthetic rule table, proving the mechanism would have caught exactly the + // Phase-4 shape: a rule naming a file that no longer exists on disk. + const phantomFile = 'tests/does-not-exist-2758.test.cjs'; + assert.ok( + !fs.existsSync(path.join(ROOT, phantomFile)), + 'precondition: the phantom file must genuinely not exist for this test to discriminate', + ); + const synthetic = [ + { name: 'real', tests: ['tests/commands.test.cjs'] }, + { name: 'phantom', tests: [phantomFile, 'tests/commands.test.cjs'] }, + ]; + assert.deepStrictEqual(missingRuleTestFiles(synthetic), [phantomFile]); + }); + + test('an all-real synthetic table reports nothing missing (negative case)', () => { + const synthetic = [{ name: 'real', tests: ['tests/commands.test.cjs', 'tests/ci-test-scope.test.cjs'] }]; + assert.deepStrictEqual(missingRuleTestFiles(synthetic), []); + }); +}); + describe('code_changed=false implies clean output invariant', () => { // Fix 1: when code_changed is false, full_matrix, targeted_tests, windows_tests // must ALL be empty/false — even if a docs path coincidentally