fix(sdk): phase.add honors --dry-run; rejects unknown flags (#3226) (#3246)

* test: reproduce phase.add dry-run + flag validation gaps (#3226)

Add failing tests for:
- --dry-run silently absorbed into description (symptom A)
- Unknown --flag should return validation error (symptom C)
- ### Phase N: ROADMAP heading scan verification (symptom B)

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

* fix(sdk): phase.add honors --dry-run; rejects unknown flags (#3226)

- Add flag parser to phaseAdd: strip recognized flags (--dry-run) from
  args before positional parsing so they never silently become description
  or customId values
- --dry-run computes the next phase number and roadmap_entry string but
  skips mkdir, writeFile, and readModifyWriteRoadmapMd; returns
  { dry_run: true, roadmap_entry } alongside normal fields
- Any unrecognized --flag throws a Validation GSDError naming the flag
- ROADMAP ### Phase N: heading scan for numbering (symptom B) was already
  correct; verified with new regression test

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

* changeset: pr=3246 for #3226

* fix(sdk): phase.add scans disk AND roadmap (union, not fallback)

Address CodeRabbit finding: the conditional `if (maxPhase === 0)` guard
around the filesystem scan meant that if ROADMAP had any phases but disk
was ahead (e.g. ROADMAP max=10, dirs include 12-*), phase.add would
pick 11 and collide with the existing directory.

Remove the guard: always scan on-disk phase directories and take the
max across both ROADMAP and filesystem (union semantics).

All 57 phase-lifecycle tests pass.

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

* test: reproduce phase.add concurrent ID collision (CR finding)

Two concurrent phase.add calls against the same project observe
maxPhase before the lock is held, producing duplicate phase IDs.
Adds a Promise.all regression test that asserts both calls succeed
with distinct phase numbers {11, 12}.

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

* fix(sdk): compute phase number under roadmap lock (CR finding)

Move maxPhase/newPhaseId/dirName computation inside the
readModifyWriteRoadmapMd callback so the entire read → compute → write
cycle is serialised under the lock. Previously, two concurrent
phase.add calls could both observe maxPhase=N before either acquired
the lock, then both write with phase ID N+1 — producing duplicate IDs.

In dry-run mode (no write, no race) the computation still happens
outside the lock to avoid unnecessary contention.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-08 08:40:24 -04:00
committed by GitHub
parent 73f7ad33e8
commit 447763411a
3 changed files with 258 additions and 54 deletions

View File

@@ -453,6 +453,144 @@ describe('phaseAdd', () => {
// Should detect CK-45 and CK-46, so new phase = 47
expect(data.phase_number).toBe(47);
});
// ── Symptom A: --dry-run flag (#3226) ─────────────────────────────────
it('--dry-run returns JSON result without creating any files or modifying ROADMAP', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries'],
});
const roadmapBefore = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const result = await phaseAdd(['Dry Run Phase', '--dry-run'], tmpDir);
const data = result.data as Record<string, unknown>;
// Result must include the computed fields
expect(data.phase_number).toBe(11);
expect(data.padded).toBe('11');
expect(data.name).toBe('Dry Run Phase');
expect(data.slug).toBe('dry-run-phase');
expect(data.dry_run).toBe(true);
expect(typeof data.roadmap_entry).toBe('string');
expect((data.roadmap_entry as string)).toContain('### Phase 11: Dry Run Phase');
// ROADMAP.md must be unchanged
const roadmapAfter = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
expect(roadmapAfter).toBe(roadmapBefore);
// No new phase directory must have been created
const phasesDir = join(tmpDir, '.planning', 'phases');
const entries = await readdir(phasesDir, { withFileTypes: true });
const newDir = entries.find(e => e.isDirectory() && e.name.includes('11-dry-run-phase'));
expect(newDir).toBeUndefined();
});
it('--dry-run works when flag appears after customId position', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries'],
});
const roadmapBefore = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// description + --dry-run — no customId; flag must not be mistaken for customId
const result = await phaseAdd(['My Feature', '--dry-run'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.dry_run).toBe(true);
expect(data.phase_number).toBe(11);
// ROADMAP must still be untouched
const roadmapAfter = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
expect(roadmapAfter).toBe(roadmapBefore);
});
// ── Symptom C: unknown flag rejection (#3226) ──────────────────────────
it('rejects unknown --flags with a validation error naming the flag', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
await expect(phaseAdd(['My Feature', '--bogus-flag'], tmpDir)).rejects.toThrow('--bogus-flag');
});
it('rejects any unknown --flag even when mixed with dry-run', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
await expect(phaseAdd(['Desc', '--dry-run', '--unknown'], tmpDir)).rejects.toThrow('--unknown');
});
// ── Symptom B: ROADMAP heading scan counts ### Phase N: (#3226 verify) ─
it('scans ### Phase N: headings in ROADMAP when no on-disk dirs exist (B already fixed)', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
const roadmap = [
'# Roadmap',
'',
'## Current Milestone: v5.0',
'',
'### Phase 5: Foundation',
'',
'**Goal:** Build foundation',
'**Plans:** 0 plans',
'',
].join('\n');
await setupTestProject(tmpDir, {
roadmap,
state: MINIMAL_STATE,
phases: [], // no on-disk dirs — must rely on ROADMAP scan
});
const result = await phaseAdd(['Next Phase'], tmpDir);
const data = result.data as Record<string, unknown>;
// Must detect Phase 5 from ### heading → next = 6, not 1
expect(data.phase_number).toBe(6);
});
// ── Concurrent phase.add: no duplicate IDs (CR finding) ────────────────
it('concurrent phase.add calls produce distinct sequential phase numbers', async () => {
const { phaseAdd } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries'],
});
// Fire two phase.add calls simultaneously. If computation happens outside
// the lock both will observe maxPhase=10 and claim newPhaseId=11 — collision.
const [r1, r2] = await Promise.all([
phaseAdd(['Concurrent Alpha'], tmpDir),
phaseAdd(['Concurrent Beta'], tmpDir),
]);
const n1 = (r1.data as Record<string, unknown>).phase_number as number;
const n2 = (r2.data as Record<string, unknown>).phase_number as number;
// Both must succeed and produce DIFFERENT numbers
expect(n1).not.toBe(n2);
// The pair must be {11, 12} — no gaps, no duplicates
const sorted = [n1, n2].sort((a, b) => a - b);
expect(sorted).toEqual([11, 12]);
// ROADMAP.md must contain exactly one entry for each phase
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const phase11Count = (roadmap.match(/### Phase 11:/g) || []).length;
const phase12Count = (roadmap.match(/### Phase 12:/g) || []).length;
expect(phase11Count).toBe(1);
expect(phase12Count).toBe(1);
// Both phase directories must exist on disk
const phasesDir = join(tmpDir, '.planning', 'phases');
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const has11 = dirs.some(d => d.startsWith('11-'));
const has12 = dirs.some(d => d.startsWith('12-'));
expect(has11).toBe(true);
expect(has12).toBe(true);
});
});
// ─── phaseAddBatch ─────────────────────────────────────────────────────