Files
get-shit-done/tests/bug-2630-state-frontmatter-milestone-switch.test.cjs
Tom Boucher e973ff4cb6 fix(#2630): reset STATE.md frontmatter atomically on milestone switch (#2666)
The /gsd:new-milestone workflow Step 5 rewrote STATE.md's Current Position
body but never touched the YAML frontmatter, so every downstream reader
(state.json, getMilestoneInfo, progress bars) kept reporting the stale
milestone until the first phase advance forced a resync. Asymmetric with
milestone.complete, which uses readModifyWriteStateMdFull.

Add a new `state milestone-switch` handler (both SDK and CJS) that atomically:
- Stomps frontmatter milestone/milestone_name with caller-supplied values
- Resets status to 'planning' and progress counters to zero
- Rewrites the ## Current Position section to the new-milestone template
- Preserves Accumulated Context (decisions, blockers, todos)

Wire the workflow Step 5 to invoke `state.milestone-switch` instead of the
manual body rewrite. Note the flag is `--milestone` not `--version`:
gsd-tools reserves `--version` as a globally-invalid help flag.

Red vitest in sdk/src/query/state-mutation.test.ts asserts the frontmatter
reset. Regression guard via node:test in tests/bug-2630-*.test.cjs runs
through gsd-tools end-to-end.

Fixes #2630

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:05:10 -04:00

120 lines
3.5 KiB
JavaScript

/**
* GSD Tools Tests — Bug #2630
*
* Regression guard: `state milestone-switch` resets STATE.md YAML frontmatter
* (milestone, milestone_name, status, progress.*) AND the `## Current Position`
* body in a single atomic write. Prior to the fix, the `/gsd:new-milestone`
* workflow rewrote the body but left the frontmatter pointing at the previous
* milestone, so every downstream reader (state.json, getMilestoneInfo, etc.)
* reported the stale milestone.
*/
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 STALE_STATE = `---
gsd_state_version: 1.0
milestone: v1.0
milestone_name: Foundation
status: completed
progress:
total_phases: 5
completed_phases: 5
total_plans: 12
completed_plans: 12
percent: 100
---
# Project State
## Current Position
Phase: 5 (Foundation) — COMPLETED
Plan: 3 of 3
Status: v1.0 milestone complete
Last activity: 2026-04-20 -- v1.0 shipped
## Accumulated Context
### Decisions
- [Phase 1]: Use Node 20
`;
describe('state milestone-switch (#2630)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
STALE_STATE,
'utf-8',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## v1.1 Notifications\n\n### Phase 6: Notify\n',
'utf-8',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
'{}',
'utf-8',
);
});
afterEach(() => {
cleanup(tmpDir);
});
test('writes new milestone into frontmatter and resets progress + Current Position', () => {
const result = runGsdTools(
['state', 'milestone-switch', '--milestone', 'v1.1', '--name', 'Notifications'],
tmpDir,
);
assert.equal(result.success, true, result.error || result.output);
const after = fs.readFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'utf-8',
);
// Frontmatter reflects the NEW milestone — the core of bug #2630.
assert.match(after, /^milestone:\s*v1\.1\s*$/m, 'frontmatter milestone not switched');
assert.match(
after,
/^milestone_name:\s*Notifications\s*$/m,
'frontmatter milestone_name not switched',
);
assert.match(after, /^status:\s*planning\s*$/m, 'status not reset to planning');
// Progress counters reset to zero.
assert.match(after, /^\s*completed_phases:\s*0\s*$/m, 'completed_phases not reset');
assert.match(after, /^\s*completed_plans:\s*0\s*$/m, 'completed_plans not reset');
assert.match(after, /^\s*percent:\s*0\s*$/m, 'percent not reset');
// Body Current Position reset to the new-milestone template.
assert.match(after, /Status:\s*Defining requirements/, 'body Status not reset');
assert.match(
after,
/Phase:\s*Not started \(defining requirements\)/,
'body Phase not reset',
);
// Accumulated Context is preserved.
assert.match(after, /\[Phase 1\]:\s*Use Node 20/, 'Accumulated Context lost');
});
test('rejects missing --milestone', () => {
const result = runGsdTools(
['state', 'milestone-switch', '--name', 'Something'],
tmpDir,
);
// gsd-tools emits JSON with { error: ... } to stdout even on error paths.
const combined = (result.output || '') + (result.error || '');
assert.match(combined, /milestone required/i);
});
});