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>
This commit is contained in:
119
tests/bug-2630-state-frontmatter-milestone-switch.test.cjs
Normal file
119
tests/bug-2630-state-frontmatter-milestone-switch.test.cjs
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user