Files
msd-core/scripts/lint-fix-has-regression-test.cjs
Tom Boucher a5180d96a3 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)
2026-07-23 12:13:12 -04:00

132 lines
4.0 KiB
JavaScript

#!/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);