From a5180d96a3672cd290f19df1ce879a9332c37a54 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 23 Jul 2026 12:13:12 -0400 Subject: [PATCH] fix: add regression-test-presence gate + missing tests for #2429/#2279 (#2563) lint-fix-has-regression-test.cjs: new gate that fails if a fix(#NNNN) or feat(#NNNN) commit has zero behavioral test files (*.test.cjs, excluding auto-generated fixtures/baselines) in its diff. Wired into lint:ci so it runs before PR creation. Missing regression tests added: - #2429: codex local scope does not set $HOME/.agents skills home; global scope does (tests/runtime-artifact-layout.test.cjs) - #2279: map-codebase instructions say to overwrite existing dates, not just replace [YYYY-MM-DD] placeholders (tests/commands.test.cjs) --- package.json | 2 +- scripts/lint-fix-has-regression-test.cjs | 131 +++++++++++++++++++++++ tests/commands.test.cjs | 30 ++++++ tests/runtime-artifact-layout.test.cjs | 18 ++++ 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 scripts/lint-fix-has-regression-test.cjs diff --git a/package.json b/package.json index c89ae0d44..02b8daccf 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/", "lint:fix": "eslint . --fix", "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs", - "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs", + "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs", diff --git a/scripts/lint-fix-has-regression-test.cjs b/scripts/lint-fix-has-regression-test.cjs new file mode 100644 index 000000000..2707ca01b --- /dev/null +++ b/scripts/lint-fix-has-regression-test.cjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +'use strict'; + +/** + * lint-fix-has-regression-test.cjs — gate: every fix(#NNNN) commit must + * include at least one behavioral test file (tests/*.test.cjs) that is NOT + * an auto-generated fixture/baseline. + * + * ## Why + * + * CONTRIBUTING.md:47: "Fix it. Write a test that would have caught the bug." + * CLAUDE.md: "Regression Protocol: Write the regression test first." + * + * Three merged PRs shipped without regression tests because the golden-fixture + * regeneration (20+ files under tests/) camouflaged the absence of real + * *.test.cjs changes. This gate makes the rule machine-enforced. + * + * ## What this enforces + * + * For every commit in `git log origin/next..HEAD` whose subject matches + * /^fix\(#\d+\)/ or /^feat\(#\d+\)/, the cumulative diff must include at + * least one file under tests/ matching /\.test\.cjs$/ that is NOT: + * - under tests/fixtures/ + * - under tests/install-tree/ + * - a *-baseline.json file + * + * If the filtered list is empty, the gate fails with a message naming the + * fix commits and directing the author to add a regression test. + * + * ## Overrides + * + * Set GSD_SKIP_REGRESSION_TEST_GATE=1 for legitimate exceptions (e.g. a + * pure-config or pure-refactor PR where no behavioral test is possible). + * This env var is auditable in CI logs. + */ + +const { execSync } = require('child_process'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +const FIX_OR_FEAT_RE = /^(?:fix|feat)\(#(\d+)\)/; + +const EXCLUDE_PATTERNS = [ + /tests[\\/]fixtures[\\/]/, + /tests[\\/]install-tree[\\/]/, + /-baseline\.json$/, +]; + +function isRealTestFile(filePath) { + if (!filePath.includes('tests/')) return false; + if (!/\.test\.cjs$/.test(filePath)) return false; + return !EXCLUDE_PATTERNS.some((re) => re.test(filePath)); +} + +function getFixCommits(baseRef) { + const log = execSync( + `git log ${baseRef}..HEAD --format='%H%x09%s' --no-merges`, + { encoding: 'utf8', timeout: 10000 }, + ); + return log + .trim() + .split('\n') + .filter(Boolean) + .map((line) => { + const [sha, subject] = line.split('\t'); + const match = subject.match(FIX_OR_FEAT_RE); + return match ? { sha: sha.slice(0, 12), subject, issue: match[1] } : null; + }) + .filter(Boolean); +} + +function getChangedTestFiles(baseRef) { + const diff = execSync( + `git diff ${baseRef}...HEAD --name-only --no-merges`, + { encoding: 'utf8', timeout: 10000 }, + ); + return diff + .trim() + .split('\n') + .filter(Boolean) + .filter(isRealTestFile); +} + +function main() { + if (process.env.GSD_SKIP_REGRESSION_TEST_GATE === '1') { + console.log('lint-fix-has-regression-test: SKIPPED (GSD_SKIP_REGRESSION_TEST_GATE=1)'); + return; + } + + const baseRef = process.env.GSD_REGRESSION_GATE_BASE || 'origin/next'; + + let fixCommits; + try { + fixCommits = getFixCommits(baseRef); + } catch { + console.log(`lint-fix-has-regression-test: no fix/feat commits found vs ${baseRef}, skipping`); + return; + } + + if (fixCommits.length === 0) { + console.log('lint-fix-has-regression-test: no fix/feat commits, passing'); + return; + } + + let testFiles; + try { + testFiles = getChangedTestFiles(baseRef); + } catch { + testFiles = []; + } + + if (testFiles.length === 0) { + const commitList = fixCommits + .map((c) => ` ${c.sha} ${c.subject}`) + .join('\n'); + throw new ExitError(1, + `lint-fix-has-regression-test: ${fixCommits.length} fix/feat commit(s) but ZERO behavioral test files (*.test.cjs) in the diff.\n` + + `Auto-generated fixtures (tests/fixtures/, *-baseline.json) do NOT count.\n\n` + + `Fix commits:\n${commitList}\n\n` + + `CONTRIBUTING.md:47: "Write a test that would have caught the bug."\n` + + `Add a regression test to an existing tests/*.test.cjs file, or set ` + + `GSD_SKIP_REGRESSION_TEST_GATE=1 if no behavioral test is possible (auditable in CI).` + ); + } + + console.log( + `lint-fix-has-regression-test: PASS — ${fixCommits.length} fix/feat commit(s), ` + + `${testFiles.length} behavioral test file(s): ${testFiles.join(', ')}` + ); +} + +runMain(main); diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index 9408df4cf..ed2cc7764 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -3585,3 +3585,33 @@ describe('query commit --files scoping (#2269)', () => { assert.match(content.slice(idx, idx + 200), /ROADMAP\.md/); }); }); + +describe('#2279: map-codebase date stamp instructions overwrite existing dates', () => { + const REPO_ROOT = path.join(__dirname, '..'); + + test('codebase-mapper agent says to SET date stamps, overwriting existing values', () => { + const content = fs.readFileSync( + path.join(REPO_ROOT, 'agents', 'gsd-codebase-mapper.md'), 'utf-8' + ); + assert.match(content, /overwriting whatever date is already there/i, + 'must instruct the agent to SET date stamps unconditionally, not just replace [YYYY-MM-DD] placeholders'); + }); + + test('map-codebase workflow spawn prompts say to SET date stamps, not replace placeholders', () => { + const content = fs.readFileSync( + path.join(REPO_ROOT, 'gsd-core', 'workflows', 'map-codebase.md'), 'utf-8' + ); + const stampLines = content.match(/Set all date stamps[^\r\n]*/g) || []; + assert.ok(stampLines.length >= 4, + `must have ≥4 "Set all date stamps" instructions (4 spawn prompts + 1 sequential); got ${stampLines.length}`); + }); + + test('map-codebase sequential path says to SET date stamps overwriting existing dates', () => { + const content = fs.readFileSync( + path.join(REPO_ROOT, 'gsd-core', 'workflows', 'map-codebase.md'), 'utf-8' + ); + const idx = content.indexOf('overwriting any existing date'); + assert.notEqual(idx, -1, + 'workflow must instruct agents to overwrite existing dates, not just replace [YYYY-MM-DD] placeholders'); + }); +}); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 18ce8fa81..1509bff05 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -101,6 +101,24 @@ describe('resolveRuntimeArtifactLayout — codex', () => { assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); assert.strictEqual(typeof layout.kinds[0].stage, 'function'); }); + + test('#2429: codex local scope does not set $HOME/.agents skills home override', () => { + const layout = resolveRuntimeArtifactLayout('codex', FAKE_DIR, 'local'); + const skills = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skills, 'codex local must have a skills kind'); + assert.strictEqual(skills.home, undefined, + 'codex local skills must NOT have a home override (would redirect to $HOME/.agents instead of project-local)'); + }); + + test('#2429: codex global scope DOES set $HOME/.agents skills home override', () => { + const layout = resolveRuntimeArtifactLayout('codex', FAKE_DIR, 'global'); + const skills = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skills, 'codex global must have a skills kind'); + assert.ok(typeof skills.home === 'string' && skills.home.length > 0, + 'codex global skills MUST have a home override ($HOME/.agents)'); + assert.ok(skills.home.includes('.agents'), + 'codex global skills home should point to .agents directory'); + }); }); describe('resolveRuntimeArtifactLayout — copilot', () => {