Files
msd-core/tests/execute-plan-update-codebase-map-diff-base.test.cjs
Tom Boucher 66dbb104a0 fix(#4459): anchor update_codebase_map's diff base on the phase directory (#4549)
* fix(#4459): anchor update_codebase_map's diff base on the phase directory

execute-plan.md's update_codebase_map step derived its diff base from:

  git log --oneline --grep="feat({phase}-{plan}):" ... --reverse | head -1

A phase number is unique within a MILESTONE, not a repository (#3995).
`--reverse | head -1` deliberately selects the OLDEST matching commit
subject, so on a milestone that reuses a phase number, the diff base
lands in the PREVIOUS milestone's same-numbered phase -- silently
widening the file list that then drives which .planning/codebase/*.md
files get amended, with no warning and nothing downstream that would
notice.

This is the same defect class already fixed at two other sites in this
repo (code-review.md, structural-pre-pass.md) via a phase-DIRECTORY
anchor instead of a commit-subject grep: PHASE_START = the first commit
that ADDED anything under the phase directory, diffing from its parent
(or the commit itself on a root commit). Mirrored that exact pattern
here rather than inventing a new one.

Added tests/execute-plan-update-codebase-map-diff-base.test.cjs:
static regression guards (old grep gone, new #3995-shaped anchor
present) plus a real-execution test reproducing the issue's own
scenario -- two milestones reusing a phase number with a real
constructed git fixture, extracting and running the step's actual bash
fence, asserting the resulting diff is scoped to the current
milestone's files only.

Emitted-Drift-Ack-Growth: execute-plan.md — #4459 replaces the unbounded commit-subject grep with the phase-directory anchor already used by code-review.md/structural-pre-pass.md, net +687 bytes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4459): cite this issue in the new file's allow-test-rule marker

gsd-test caught tests/lint-allow-test-rule-refs.test.cjs failing: the new
test file's `// allow-test-rule: source-text-is-the-product` comment
(copied from the two sibling precedent files) was missing the required
issue-ref suffix -- ADR-456 requires a NEW exemption to cite an issue via
`#NNN` on the same comment line. Added `(see #4459)`. Verified via
`node scripts/lint-allow-test-rule-refs.cjs` directly (clean) before
re-running the full suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4459): backfill changeset PR number

pr: 0 -> pr: 4549

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:25:56 -04:00

149 lines
7.0 KiB
JavaScript

// allow-test-rule: source-text-is-the-product (see #4459)
// Workflow markdown is the installed orchestration contract — this file's
// text IS what the executor runs at runtime.
'use strict';
/**
* Regression coverage for #4459: execute-plan.md's `update_codebase_map`
* step used a commit-subject `--grep` with no milestone bound to find the
* diff base for the codebase-map update. A phase NUMBER is unique within a
* MILESTONE, not a repository — on a project that reuses a phase number
* across milestones, the old `--reverse | head -1` deliberately picked the
* OLDEST matching commit subject, dragging the previous milestone's
* same-numbered phase's files into the diff.
*
* The fix (matching the already-established anchor in code-review.md's
* structural-pre-pass step, #3995) anchors on the phase's own DIRECTORY
* instead: the parent of the first commit that added anything under it.
*
* This test extracts the step's real bash fence from the workflow file and
* runs it against a constructed git fixture that reproduces the issue's
* exact scenario — two milestones, phase number reused with a different
* slug (the realistic case; same-number-same-slug reuse is a documented
* residual limitation shared with code-review.md's own anchor) — so a
* regression in the fix is caught by real git behavior, not just text
* matching.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const { GIT_FIXTURE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-plan.md');
function extractNamedBlock(markdown, blockName) {
const openStep = `<step name="${blockName}">`;
const start = markdown.indexOf(openStep);
assert.ok(start !== -1, `execute-plan.md must contain a <step name="${blockName}"> block`);
const end = markdown.indexOf('</step>', start + openStep.length);
assert.ok(end !== -1, `<step name="${blockName}"> must be closed with </step>`);
return markdown.slice(start + openStep.length, end);
}
function extractFirstBashBlock(block) {
const lines = block.split('\n');
let inFence = false;
const buffer = [];
for (const line of lines) {
const trimmed = line.trimStart();
if (trimmed.startsWith('```bash')) {
inFence = true;
continue;
}
if (inFence && trimmed.startsWith('```')) break;
if (inFence) buffer.push(line);
}
assert.ok(buffer.length > 0, 'update_codebase_map must contain a ```bash fence');
return buffer.join('\n');
}
function seedFixtureRepo(dir) {
gitOrThrow(['init', '-q'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['config', 'user.email', 't@example.com'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['config', 'user.name', 'T'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
}
function writeAndCommit(dir, relPath, content, message) {
const abs = path.join(dir, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
gitOrThrow(['add', '-A'], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['commit', '-q', '-m', message], { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
}
describe('#4459: update_codebase_map diff base is anchored to the phase directory, not an unbounded commit-subject grep', () => {
const workflowContent = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const stepBlock = extractNamedBlock(workflowContent, 'update_codebase_map');
const bashFence = extractFirstBashBlock(stepBlock);
test('the old unbounded commit-subject grep is gone', () => {
assert.ok(
!bashFence.includes('--grep="feat({phase}-{plan}):"'),
'update_codebase_map must no longer derive its diff base from an unbounded --grep',
);
});
test('the new fence anchors on the phase directory via --diff-filter=A, matching code-review.md\'s #3995 pattern', () => {
assert.ok(
bashFence.includes('--diff-filter=A') && bashFence.includes('.planning/phases/'),
'update_codebase_map must derive PHASE_START from the first commit that added the phase directory',
);
});
test('real execution: milestone-2 reusing phase 03 scopes the diff to milestone-2\'s own files, not milestone-1\'s (issue #4459 repro)', () => {
const tmpDir = fs.realpathSync(createTempDir('gsd-4459-'));
try {
seedFixtureRepo(tmpDir);
writeAndCommit(tmpDir, 'README.md', '# init\n', 'chore: init');
// Milestone 1, phase 03 (slug "alpha") — this occupant must NOT leak
// into milestone 2's diff.
writeAndCommit(tmpDir, '.planning/phases/03-alpha/03-01-PLAN.md', '# plan\n', 'feat(03-01): milestone-1 phase 3 first task');
writeAndCommit(tmpDir, 'm1/alpha.js', 'm1 alpha\n', 'feat(03-01): milestone-1 phase 3 work');
writeAndCommit(tmpDir, 'm1/beta.js', 'm1 beta\n', 'test(03-01): milestone-1 phase 3 tests');
// Unrelated intervening work (a later phase in milestone 1).
for (let i = 1; i <= 3; i++) {
writeAndCommit(tmpDir, `src/f${i}.js`, `f${i}\n`, `feat(07-0${i}): unrelated later work ${i}`);
}
// Milestone 2 reuses phase NUMBER 03 with a DIFFERENT slug ("beta") —
// the realistic reuse case (same-number-same-slug is code-review.md's
// own documented residual, not this fix's regression target).
writeAndCommit(tmpDir, '.planning/phases/03-beta/03-01-PLAN.md', '# plan\n', 'feat(03-01): milestone-2 phase 3 first task');
writeAndCommit(tmpDir, 'm2/gamma.js', 'm2 gamma\n', 'feat(03-01): milestone-2 phase 3 work');
writeAndCommit(tmpDir, 'm2/delta.js', 'm2 delta\n', 'test(03-01): milestone-2 phase 3 tests');
const substituted = bashFence.replace('.planning/phases/XX-name', '.planning/phases/03-beta');
const scriptPath = path.join(tmpDir, '.diff-base-script.sh');
fs.writeFileSync(scriptPath, substituted);
const output = execFileSync('bash', [scriptPath], {
cwd: tmpDir,
encoding: 'utf8',
timeout: GIT_FIXTURE_TIMEOUT_MS,
});
const files = output.split('\n').map((l) => l.trim()).filter(Boolean).sort();
assert.deepEqual(
files,
['.planning/phases/03-beta/03-01-PLAN.md', 'm2/delta.js', 'm2/gamma.js'].sort(),
`diff must be scoped to milestone-2's own phase 03-beta files only, got: ${JSON.stringify(files)}`,
);
// The defect this test guards against: milestone-1's files must NOT
// leak into the diff.
assert.ok(!files.includes('m1/alpha.js'), 'milestone-1 files must not appear in the diff');
assert.ok(!files.includes('.planning/phases/03-alpha/03-01-PLAN.md'), 'milestone-1 phase dir must not appear in the diff');
} finally {
cleanup(tmpDir);
}
});
});