Files
msd-core/tests/bug-500-planned-phase-progress-corruption.test.cjs
Tom Boucher d720cef8e9 fix(#500): state planned-phase corrupts STATE.md milestone progress.* counters (#514)
* fix(#500): stop state planned-phase corrupting milestone progress.* counters

Two independent defects combined to corrupt STATE.md progress.* on a
plan-phase run:

RC1 — cmdStatePlannedPhase wrote via writeStateMd, which unconditionally
runs syncStateFrontmatter and rebuilds progress.* (total/completed
plans+phases) from a half-planned disk snapshot, trampling curated
counters. It now routes through readModifyWriteStateMd(..., { resync:false }),
the same body-only-write guard state.update uses — per-phase body fields
are updated, milestone progress.* is preserved.

RC2 — isRootPlanFile's loose /PLAN/i fallback matched legacy
`<N>-PLAN-<NN>-SUMMARY.md` names (they contain "PLAN"), double-counting
summaries as plans (a 4-plan/4-summary phase scanned as planCount:8,
completed:false). isRootPlanFile now rejects isRootSummaryFile before the
fallback, so summaries are never counted as plans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#500): add changeset for planned-phase progress fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 12:01:01 -04:00

146 lines
5.2 KiB
JavaScript

/**
* Bug #500: `state planned-phase` corrupts STATE.md milestone progress.* counters.
*
* Two independent defects:
*
* RC1 — plan-phase resyncs progress it should not touch.
* cmdStatePlannedPhase wrote via writeStateMd, which unconditionally runs
* syncStateFrontmatter and rebuilds progress.* from a half-planned disk
* snapshot, trampling curated counters. It must route through
* readModifyWriteStateMd(..., { resync: false }) like other body-only writes.
*
* RC2 — isRootPlanFile double-counts legacy `<N>-PLAN-<NN>-SUMMARY.md` as a plan.
* The final `/PLAN/i` fallback matches the substring "PLAN" inside a legacy
* summary name, so a 4-plan/4-summary phase scans as planCount:8 / completed:false
* instead of planCount:4 / completed:true. A summary is never a plan.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const planScan = require('../get-shit-done/bin/lib/plan-scan.cjs');
const { isRootPlanFile, scanPhasePlans } = planScan;
describe('isRootPlanFile does not count legacy summaries as plans (#500 RC2)', () => {
test('legacy <N>-PLAN-<NN>-SUMMARY.md is not a root plan file', () => {
assert.equal(isRootPlanFile('14-PLAN-01-SUMMARY.md'), false);
});
test('legacy <N>-PLAN-<NN>.md is still a root plan file', () => {
assert.equal(isRootPlanFile('14-PLAN-01.md'), true);
});
test('canonical -PLAN.md is still a root plan file', () => {
assert.equal(isRootPlanFile('01-PLAN.md'), true);
});
test('a 4-plan / 4-summary legacy phase scans as planCount:4 completed:true', () => {
const tmp = createTempProject();
const phaseDir = path.join(tmp, '.planning', 'phases', '14-legacy');
fs.mkdirSync(phaseDir, { recursive: true });
for (let i = 1; i <= 4; i++) {
const nn = String(i).padStart(2, '0');
fs.writeFileSync(path.join(phaseDir, `14-PLAN-${nn}.md`), '# Plan\n', 'utf-8');
fs.writeFileSync(path.join(phaseDir, `14-PLAN-${nn}-SUMMARY.md`), '# Summary\n', 'utf-8');
}
try {
const scan = scanPhasePlans(phaseDir);
assert.equal(scan.planCount, 4, `expected 4 plans, got ${scan.planCount}`);
assert.equal(scan.summaryCount, 4, `expected 4 summaries, got ${scan.summaryCount}`);
assert.equal(scan.completed, true, 'a fully-summarized phase must scan as completed');
} finally {
cleanup(tmp);
}
});
});
describe('state planned-phase preserves curated milestone progress.* (#500 RC1)', () => {
let tmpDir;
// Curated progress counters that deliberately do NOT match what a disk scan
// would derive (disk has only one near-empty phase dir). The bug rebuilds
// progress.* from that disk snapshot, trampling these values.
const STATE = `---
gsd_state_version: 1.0
milestone: v3.0
milestone_name: Active
status: in_progress
progress:
total_phases: 7
completed_phases: 5
total_plans: 99
completed_plans: 88
percent: 88
---
# Project State
## Configuration
Current Phase: 2
Current Phase Name: builder
Total Plans in Phase: 0
Status: Not started
Last Activity: TBD
Last Activity Description: pending
## Current Position
Phase: 2 (builder)
Status: Not started
Last activity: TBD
`;
beforeEach(() => {
tmpDir = createTempProject();
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE, 'utf-8');
fs.writeFileSync(
path.join(planning, 'ROADMAP.md'),
'# Roadmap\n\n## 🚧 v3.0: Active\n\n### Phase 2: builder\n',
'utf-8'
);
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
// One sparse phase dir so a disk resync would derive small/zero counters.
const dir = path.join(planning, 'phases', '02-builder');
fs.mkdirSync(dir, { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
function readProgress() {
const md = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const block = md.split('---')[1] || '';
const num = (key) => {
const m = block.match(new RegExp(`${key}:\\s*(\\d+)`));
return m ? Number(m[1]) : null;
};
return {
total_plans: num('total_plans'),
completed_plans: num('completed_plans'),
total_phases: num('total_phases'),
completed_phases: num('completed_phases'),
};
}
test('planned-phase updates per-phase body fields but leaves milestone progress.* untouched', () => {
const result = runGsdTools(['state', 'planned-phase', '--phase', '2', '--plans', '3'], tmpDir);
assert.equal(result.success, true, result.error || result.output);
// The command did its real job: per-phase "Total Plans in Phase" was set.
const md = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.match(md, /Total Plans in Phase:\s*3/, 'per-phase Total Plans in Phase should be updated to 3');
// ...but the curated milestone-wide progress block is preserved verbatim.
assert.deepEqual(readProgress(), {
total_plans: 99,
completed_plans: 88,
total_phases: 7,
completed_phases: 5,
}, 'curated milestone progress.* must survive a planned-phase write');
});
});