Files
msd-core/tests/bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs
Tom Boucher d12809985e fix(#905): preserve STATE.md frontmatter scalars in syncStateFrontmatter (#907)
syncStateFrontmatter was silently dropping current_phase, current_phase_name,
current_plan, and progress when body annotations were absent (e.g. after an
agent or tool rewrote the body). These scalars can only be derived from body
annotations — when absent, buildStateFrontmatter returns nothing for those
keys. Added existingFm fallbacks mirroring the same pattern already applied in
cmdStateJson, so every writeStateMd call preserves the existing values instead
of stripping them. Also extended cmdStateJson with the same fallbacks for the
three non-progress scalars.

Adds regression test (7 cases) + lint-test-file-count allowlist entry.

Closes #905

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:52:43 -04:00

322 lines
13 KiB
JavaScript

'use strict';
/**
* Regression guard for bug #905.
*
* `syncStateFrontmatter` (src/state.cts) only preserved `status` from existing
* frontmatter when the body-derived value was missing/unknown. The scalars
* `current_phase`, `current_phase_name`, `current_plan`, and `progress` were
* silently stripped whenever `buildStateFrontmatter` could not extract them from
* the body text — e.g. when an agent removed the bold `**Current Phase:**`
* annotations.
*
* Fix: mirror the `cmdStateJson` fallback pattern in `syncStateFrontmatter` so
* that all four scalars survive a `writeStateMd` / `state sync` call when the
* body no longer carries the annotation but the existing frontmatter does.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
// ─────────────────────────────────────────────────────────────────────────────
// Fixture builders
// ─────────────────────────────────────────────────────────────────────────────
/**
* A STATE.md whose YAML frontmatter holds all four scalars but whose body
* does NOT contain the bold `**Current Phase:**` / `**Current Plan:**`
* annotations that `buildStateFrontmatter` uses to re-derive them.
*
* This is the exact scenario that triggered the bug: the body has already lost
* the annotations (e.g. because a CLI tool or agent overwrote it), but the
* frontmatter still holds the ground-truth values. A subsequent `state sync`
* (or any `writeStateMd` call) must not strip them.
*/
function buildStateMdWithoutBodyAnnotations(opts) {
const {
currentPhase = 3,
currentPhaseName = 'Implementation',
currentPlan = 2,
progressPercent = 42,
} = opts || {};
return [
'---',
'gsd_state_version: 1.0',
`current_phase: ${currentPhase}`,
`current_phase_name: ${currentPhaseName}`,
`current_plan: ${currentPlan}`,
'status: executing',
'progress:',
` total_phases: 5`,
` completed_phases: 2`,
` total_plans: 10`,
` completed_plans: 4`,
` percent: ${progressPercent}`,
'---',
'',
'# GSD State',
'',
'## Configuration',
// Intentionally omitting "Current Phase:", "Current Phase Name:",
// "Current Plan:" body annotations to reproduce the bug scenario.
'Status: Executing',
'Last Activity: 2026-01-01',
'',
'## Accumulated Context',
'',
'### Decisions',
'',
'- Use Node 22',
'',
].join('\n');
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
describe('#905: syncStateFrontmatter preserves scalars when body annotations are absent', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('state sync preserves current_phase from existing frontmatter when body lacks annotation', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhase: 3 }));
const syncResult = runGsdTools('state sync', tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.strictEqual(
fm.current_phase,
'3',
`current_phase must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_phase)})`,
);
});
test('state sync preserves current_phase_name from existing frontmatter when body lacks annotation', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhaseName: 'Implementation' }));
const syncResult = runGsdTools('state sync', tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.strictEqual(
fm.current_phase_name,
'Implementation',
`current_phase_name must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_phase_name)})`,
);
});
test('state sync preserves current_plan from existing frontmatter when body lacks annotation', () => {
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPlan: 2 }));
const syncResult = runGsdTools('state sync', tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.strictEqual(
fm.current_plan,
'2',
`current_plan must be preserved from existing frontmatter after sync (got: ${JSON.stringify(fm.current_plan)})`,
);
});
test('state update (resync:false) preserves curated progress from existing frontmatter when body lacks disk-scan data', () => {
// state update "Last Activity" calls readModifyWriteStateMd with resync:false.
// That path runs syncStateFrontmatter and then explicitly re-applies the
// pre-existing progress block (lines 1243-1253 of state.cts). The curated
// progress values must survive even though the phases dir is empty.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ progressPercent: 42 }));
// Add body annotation for Last Activity so state update can find and replace it
const initial = fs.readFileSync(statePath, 'utf8');
fs.writeFileSync(statePath, initial.replace('Last Activity: 2026-01-01', 'Last Activity: 2026-01-01'));
const updateResult = runGsdTools(
['state', 'update', 'Last Activity', '2026-06-08'],
tmpDir,
);
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.ok(fm.progress, 'frontmatter must retain a progress block after body-only update');
// shouldPreserveExistingProgress: existing completed_plans (4) > derived (0 from empty disk)
// → curated block survives via cmdStateJson read-path fallback.
assert.strictEqual(
fm.progress.completed_plans,
4,
`progress.completed_plans must be preserved via shouldPreserveExistingProgress ` +
`(got: ${JSON.stringify(fm.progress?.completed_plans)})`,
);
});
test('state update field preserves current_phase frontmatter when body lacks annotation', () => {
// Trigger the write path via `state update` (which calls readModifyWriteStateMd
// with resync:true), confirming the fix covers every write path.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, buildStateMdWithoutBodyAnnotations({ currentPhase: 7 }));
const updateResult = runGsdTools(
['state', 'update', 'Last Activity', '2026-06-08'],
tmpDir,
);
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
const jsonResult = runGsdTools('state json', tmpDir);
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
const fm = JSON.parse(jsonResult.output);
assert.strictEqual(
fm.current_phase,
'7',
`current_phase must survive a state.update write (got: ${JSON.stringify(fm.current_phase)})`,
);
});
test('body annotation beats existing frontmatter when both are present', () => {
// When the body DOES carry the annotation, the derived value wins — we must
// not accidentally lock stale frontmatter in place.
// IMPORTANT: assert on the raw written STATE.md file (not just state json,
// which rebuilds from the body and would return body-derived values regardless
// of what syncStateFrontmatter wrote to disk).
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
// Frontmatter says phase 3; body says phase 5. Body should win.
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: 1.0',
'current_phase: 3',
'current_phase_name: Old Phase',
'current_plan: 1',
'status: executing',
'---',
'',
'# GSD State',
'',
'## Configuration',
'Current Phase: 5',
'Current Phase Name: New Phase',
'Current Plan: 2',
'Status: Executing',
'Last Activity: 2026-01-01',
'',
].join('\n'));
const syncResult = runGsdTools('state sync', tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
// Assert on raw file: body-derived values must be written to frontmatter,
// not the stale existing values. This guards against a fallback that locks
// in stale data even when buildStateFrontmatter successfully derived values.
const writtenContent = fs.readFileSync(statePath, 'utf8');
const rawFm = parseFrontmatter(writtenContent);
assert.strictEqual(
rawFm.current_phase,
'5',
`body-derived current_phase (5) must be written to raw frontmatter (not stale 3), got: ${JSON.stringify(rawFm.current_phase)}`,
);
assert.strictEqual(
rawFm.current_phase_name,
'New Phase',
`body-derived current_phase_name must be written to raw frontmatter, got: ${JSON.stringify(rawFm.current_phase_name)}`,
);
assert.strictEqual(
rawFm.current_plan,
'2',
`body-derived current_plan must be written to raw frontmatter, got: ${JSON.stringify(rawFm.current_plan)}`,
);
});
test('syncStateFrontmatter preserves progress from existing frontmatter when disk has no phases dir', () => {
// Directly exercises the !derivedFm['progress'] fallback in syncStateFrontmatter.
// Without a phases dir, buildStateFrontmatter returns no progress block at all
// (the existsSync guard at line ~927 short-circuits the disk scan). The
// existing frontmatter's progress must then survive the writeStateMd call.
// Use createTempDir (no phases dir) and set up .planning/ manually.
const dir = createTempDir('gsd-905-nophasesdir-');
try {
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
const statePath = path.join(dir, '.planning', 'STATE.md');
// Body has the "Current Phase:" annotation so cmdStateSync can proceed;
// the progress block is ONLY in frontmatter (no ROADMAP, no phases dir).
fs.writeFileSync(statePath, [
'---',
'gsd_state_version: 1.0',
'current_phase: 2',
'status: executing',
'progress:',
' total_phases: 4',
' completed_phases: 1',
' total_plans: 8',
' completed_plans: 3',
' percent: 38',
'---',
'',
'# GSD State',
'',
'## Configuration',
'Current Phase: 2',
'Status: Executing',
'Last Activity: 2026-01-01',
'',
].join('\n'));
// state update "Last Activity" → readModifyWriteStateMd (resync:true for
// Progress/Total Phases/Total Plans fields, but resync:false for Last Activity)
// This calls syncStateFrontmatter; without phases dir, buildStateFrontmatter
// produces no progress → !derivedFm['progress'] guard fires → existing preserved.
const updateResult = runGsdTools(
['state', 'update', 'Last Activity', '2026-06-08'],
dir,
);
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
// Assert on the raw frontmatter file — cmdStateJson would apply
// shouldPreserveExistingProgress separately, so we must verify the on-disk state.
const written = fs.readFileSync(statePath, 'utf8');
const rawFm = parseFrontmatter(written);
// The progress block must be present in the written frontmatter.
// parseFrontmatter returns flat keys, so check the presence indicator.
assert.ok(
written.includes('progress:'),
'progress block must be preserved in raw frontmatter when disk has no phases dir',
);
// percent: 38 should survive (no disk scan to overwrite it)
assert.ok(
written.includes('percent: 38'),
`progress.percent: 38 must survive syncStateFrontmatter when no phases dir exists (raw: ${rawFm.progress})`,
);
} finally {
cleanup(dir);
}
});
});