Merge pull request #2736 from gsd-build/fix/2728-phase-complete-roadmap-corruption
fix(phase-lifecycle): prevent plan-line corruption when **Plans:** has no inline value
This commit is contained in:
2
sdk/package-lock.json
generated
2
sdk/package-lock.json
generated
@@ -22,7 +22,7 @@
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
|
||||
@@ -1015,6 +1015,74 @@ describe('phaseComplete', () => {
|
||||
// By Phase table should have a row for phase 10
|
||||
expect(state).toMatch(/\|\s*10\s*\|\s*3\s*\|/);
|
||||
});
|
||||
|
||||
it('does not overwrite plan checkbox when **Plans:** is on its own line (regression #2728)', async () => {
|
||||
const { phaseComplete } = await import('./phase-lifecycle.js');
|
||||
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Current Milestone: v3.0',
|
||||
'',
|
||||
'- [ ] Phase 7: marketing-landing-v2',
|
||||
'',
|
||||
'### Phase 7: marketing-landing-v2',
|
||||
'',
|
||||
'**Goal:** Landing page',
|
||||
'**Plans:**',
|
||||
'- [x] 07-01-cherry-pick-foundation-PLAN.md — Wave 1',
|
||||
'- [x] 07-02-routing-auth-seo-PLAN.md — Wave 2',
|
||||
'',
|
||||
'### Phase 8: p3-nice-to-haves',
|
||||
'',
|
||||
'**Goal:** Nice to haves',
|
||||
'**Plans:** 3 plans',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const state = [
|
||||
'---',
|
||||
'gsd_state_version: 1.0',
|
||||
'milestone: v3.0',
|
||||
'status: executing',
|
||||
'progress:',
|
||||
' total_phases: 2',
|
||||
' completed_phases: 0',
|
||||
' total_plans: 4',
|
||||
' completed_plans: 2',
|
||||
' percent: 50',
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'Phase: 7 of 2 — EXECUTING',
|
||||
'Status: Executing Phase 7',
|
||||
].join('\n');
|
||||
|
||||
await setupTestProject(tmpDir, {
|
||||
roadmap,
|
||||
state,
|
||||
phases: ['07-marketing-landing-v2', '08-p3-nice-to-haves'],
|
||||
});
|
||||
|
||||
const p7Dir = join(tmpDir, '.planning', 'phases', '07-marketing-landing-v2');
|
||||
await writeFile(join(p7Dir, '07-01-PLAN.md'), 'plan1', 'utf-8');
|
||||
await writeFile(join(p7Dir, '07-02-PLAN.md'), 'plan2', 'utf-8');
|
||||
await writeFile(join(p7Dir, '07-01-SUMMARY.md'), 'summary1', 'utf-8');
|
||||
await writeFile(join(p7Dir, '07-02-SUMMARY.md'), 'summary2', 'utf-8');
|
||||
|
||||
await phaseComplete(['7'], tmpDir);
|
||||
|
||||
const updated = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
|
||||
// The plan lines must NOT be replaced with "N/N plans complete"
|
||||
expect(updated).toContain('07-01-cherry-pick-foundation-PLAN.md');
|
||||
expect(updated).toContain('07-02-routing-auth-seo-PLAN.md');
|
||||
expect(updated).not.toMatch(/^2\/2 plans complete/m);
|
||||
|
||||
// Phase 8's **Plans:** line must NOT be touched
|
||||
expect(updated).toContain('**Plans:** 3 plans');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── phasesClear ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1194,7 +1194,7 @@ export const phaseComplete: QueryHandler = async (args, projectDir, workstream)
|
||||
|
||||
// Update plan count in phase section
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{2,4})[\\s\\S])*?\\*\\*Plans:\\*\\*[ \\t]*)[^\\n]+`,
|
||||
'i',
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(
|
||||
|
||||
192
sdk/src/query/roadmap-update-plan-progress.test.ts
Normal file
192
sdk/src/query/roadmap-update-plan-progress.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Unit tests for roadmap.update-plan-progress query handler.
|
||||
*
|
||||
* Focuses on the planCountPattern regex fix: when **Plans:** is on its own
|
||||
* line (followed by a bullet list), the handler must NOT overwrite the next
|
||||
* line with "N/N plans complete/executed".
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, writeFile, readFile, mkdir, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-roadmap-update-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function setupProject(opts: {
|
||||
roadmap: string;
|
||||
phaseDir: string;
|
||||
plans?: string[];
|
||||
summaries?: string[];
|
||||
}) {
|
||||
const planningDir = join(tmpDir, '.planning');
|
||||
const phasesDir = join(planningDir, 'phases');
|
||||
const phaseFullDir = join(phasesDir, opts.phaseDir);
|
||||
|
||||
const phaseNum = parseInt(opts.phaseDir, 10) || 1;
|
||||
|
||||
await mkdir(phaseFullDir, { recursive: true });
|
||||
await writeFile(join(planningDir, 'ROADMAP.md'), opts.roadmap, 'utf-8');
|
||||
await writeFile(
|
||||
join(planningDir, 'STATE.md'),
|
||||
[
|
||||
'---',
|
||||
'gsd_state_version: 1.0',
|
||||
'milestone: v3.0',
|
||||
'status: executing',
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
`Phase: ${phaseNum} of 2 — EXECUTING`,
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
await writeFile(
|
||||
join(planningDir, 'config.json'),
|
||||
JSON.stringify({ model_profile: 'balanced', phase_naming: 'sequential' }),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
for (const plan of opts.plans ?? []) {
|
||||
await writeFile(join(phaseFullDir, plan), 'plan content', 'utf-8');
|
||||
}
|
||||
for (const summary of opts.summaries ?? []) {
|
||||
await writeFile(join(phaseFullDir, summary), 'summary content', 'utf-8');
|
||||
}
|
||||
|
||||
return { roadmapPath: join(planningDir, 'ROADMAP.md') };
|
||||
}
|
||||
|
||||
// ─── planCountPattern regression: **Plans:** on its own line ─────────────
|
||||
|
||||
describe('roadmapUpdatePlanProgress', () => {
|
||||
it('does not overwrite plan bullet list when **Plans:** is on its own line (regression #2728 propagation)', async () => {
|
||||
const { roadmapUpdatePlanProgress } = await import('./roadmap-update-plan-progress.js');
|
||||
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Current Milestone: v3.0',
|
||||
'',
|
||||
'- [ ] Phase 7: marketing-landing-v2',
|
||||
'',
|
||||
'### Phase 7: marketing-landing-v2',
|
||||
'',
|
||||
'**Goal:** Landing page',
|
||||
'**Plans:**',
|
||||
'- [x] 07-01-cherry-pick-foundation-PLAN.md — Wave 1',
|
||||
'- [ ] 07-02-routing-auth-seo-PLAN.md — Wave 2',
|
||||
'',
|
||||
'### Phase 8: p3-nice-to-haves',
|
||||
'',
|
||||
'**Goal:** Nice to haves',
|
||||
'**Plans:** 3 plans',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const { roadmapPath } = await setupProject({
|
||||
roadmap,
|
||||
phaseDir: '07-marketing-landing-v2',
|
||||
plans: ['07-01-PLAN.md', '07-02-PLAN.md'],
|
||||
summaries: ['07-01-SUMMARY.md'],
|
||||
});
|
||||
|
||||
await roadmapUpdatePlanProgress(['7'], tmpDir, undefined);
|
||||
|
||||
const updated = await readFile(roadmapPath, 'utf-8');
|
||||
|
||||
// The bullet list lines must survive intact — not replaced by "N/N plans ..."
|
||||
expect(updated).toContain('07-01-cherry-pick-foundation-PLAN.md');
|
||||
expect(updated).toContain('07-02-routing-auth-seo-PLAN.md');
|
||||
// The replacement text must not appear at the start of a line
|
||||
expect(updated).not.toMatch(/^1\/2 plans/m);
|
||||
|
||||
// Phase 8's **Plans:** line must NOT be touched (cross-section boundary guard)
|
||||
expect(updated).toContain('**Plans:** 3 plans');
|
||||
});
|
||||
|
||||
it('updates inline **Plans:** count when it is on the same line as existing text', async () => {
|
||||
const { roadmapUpdatePlanProgress } = await import('./roadmap-update-plan-progress.js');
|
||||
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Current Milestone: v3.0',
|
||||
'',
|
||||
'- [ ] Phase 9: foundation',
|
||||
'',
|
||||
'### Phase 9: foundation',
|
||||
'',
|
||||
'**Goal:** Build foundation',
|
||||
'**Plans:** 0 plans',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const { roadmapPath } = await setupProject({
|
||||
roadmap,
|
||||
phaseDir: '09-foundation',
|
||||
plans: ['09-01-PLAN.md', '09-02-PLAN.md'],
|
||||
summaries: ['09-01-SUMMARY.md'],
|
||||
});
|
||||
|
||||
await roadmapUpdatePlanProgress(['9'], tmpDir, undefined);
|
||||
|
||||
const updated = await readFile(roadmapPath, 'utf-8');
|
||||
|
||||
// Inline count must be updated
|
||||
expect(updated).toContain('**Plans:** 1/2 plans executed');
|
||||
// Original placeholder must be gone
|
||||
expect(updated).not.toContain('**Plans:** 0 plans');
|
||||
});
|
||||
|
||||
it('does not cross section boundaries when searching for **Plans:**', async () => {
|
||||
const { roadmapUpdatePlanProgress } = await import('./roadmap-update-plan-progress.js');
|
||||
|
||||
// Phase 9 has NO Plans: line; Phase 10 does. The regex must NOT match Phase 10's Plans: line
|
||||
// when updating Phase 9.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Current Milestone: v3.0',
|
||||
'',
|
||||
'- [ ] Phase 9: foundation',
|
||||
'',
|
||||
'### Phase 9: foundation',
|
||||
'',
|
||||
'**Goal:** Build foundation',
|
||||
'',
|
||||
'### Phase 10: queries',
|
||||
'',
|
||||
'**Goal:** Port queries',
|
||||
'**Plans:** 5 plans',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const { roadmapPath } = await setupProject({
|
||||
roadmap,
|
||||
phaseDir: '09-foundation',
|
||||
plans: ['09-01-PLAN.md'],
|
||||
summaries: [],
|
||||
});
|
||||
|
||||
await roadmapUpdatePlanProgress(['9'], tmpDir, undefined);
|
||||
|
||||
const updated = await readFile(roadmapPath, 'utf-8');
|
||||
|
||||
// Phase 10's Plans: line must remain untouched
|
||||
expect(updated).toContain('**Plans:** 5 plans');
|
||||
// Must not be rewritten to Phase 9's count
|
||||
expect(updated).not.toContain('**Plans:** 0/1 plans');
|
||||
});
|
||||
});
|
||||
@@ -84,7 +84,7 @@ export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir,
|
||||
});
|
||||
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{2,4})[\\s\\S])*?\\*\\*Plans:\\*\\*[ \\t]*)[^\\n]+`,
|
||||
'i',
|
||||
);
|
||||
const planCountText = isComplete
|
||||
|
||||
Reference in New Issue
Block a user