* 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:
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user