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)
This commit is contained in:
@@ -103,7 +103,7 @@
|
|||||||
"lint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
|
"lint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs",
|
"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:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
|
||||||
"lint:regression-names": "node scripts/lint-regression-test-names.cjs",
|
"lint:regression-names": "node scripts/lint-regression-test-names.cjs",
|
||||||
"lint:descriptions": "node scripts/lint-descriptions.cjs",
|
"lint:descriptions": "node scripts/lint-descriptions.cjs",
|
||||||
|
|||||||
131
scripts/lint-fix-has-regression-test.cjs
Normal file
131
scripts/lint-fix-has-regression-test.cjs
Normal file
@@ -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);
|
||||||
@@ -3585,3 +3585,33 @@ describe('query commit --files scoping (#2269)', () => {
|
|||||||
assert.match(content.slice(idx, idx + 200), /ROADMAP\.md/);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -101,6 +101,24 @@ describe('resolveRuntimeArtifactLayout — codex', () => {
|
|||||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
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', () => {
|
describe('resolveRuntimeArtifactLayout — copilot', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user