Files
msd-core/tests/bug-3489-complete-phase-idempotent.test.cjs
Tom Boucher beca07cf3a fix(sdk): make state.complete-phase idempotent (#3489)
Re-invoking `state complete-phase --phase <N>` on a phase that was
already marked complete in STATE.md silently rolled STATE.md back to
that phase's moment-of-completion — clobbering Status, Last Activity,
Last Activity Description, and the ## Current Position body. The bug
fired whenever a follow-up phase had been inserted (or the next phase
had begun) and a downstream workflow re-ran complete-phase on the
already-closed phase. Damage was silent: handler reported
{"updated":["Status","Last Activity","Current Position"]} and no error.

Root cause: cmdStateCompletePhase wrote unconditionally — it never
consulted STATE.md to detect that the requested phase had been
superseded. The handler is a legacy-bridge fallback (no native SDK
registration), so the SDK CLI fell through to gsd-tools.cjs.

Fix: add an idempotency guard at the top of cmdStateCompletePhase.
If STATE.md's canonical Current Phase field already names a phase
distinct from the one we are being asked to mark complete, return a
no-op payload ({updated:[], phase:"<N>", idempotent:true, note:"phase
already superseded; no-op"}) without writing to STATE.md.

The guard is conservative — it only fires when Current Phase is set
and differs from the resolved target. First-time completion (Current
Phase == target, or Current Phase absent) is unaffected, so the four
existing complete-phase test cases (#2761, #3063) continue to pass.

Regression test: tests/bug-3489-complete-phase-idempotent.test.cjs
- re-running complete-phase --phase 02.2 with Current Phase=02.2.1
  in STATE.md leaves the file byte-identical and reports idempotent:true
- normal first-time completion is NOT flagged idempotent

Scope: handler-level idempotency only. Does not address the related
stopped_at filename-sort ordering issue called out in the bug report
(filed under suggested fix #2) or porting to the native registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:47:18 -04:00

125 lines
4.7 KiB
JavaScript

'use strict';
// allow-test-rule: source-text-is-the-product
// State.md is the deployed artifact; asserting on its literal text content
// tests the deployed contract.
/**
* Regression test for #3489
*
* `gsd state complete-phase --phase <N>` was non-idempotent. Re-invoking it
* on a phase already marked complete in STATE.md silently rolled STATE.md
* back to that phase's moment-of-completion — overwriting Status, Last
* Activity, Current Position and the body Status/Phase with stale values
* derived from the just-completed phase.
*
* Expected: when the target phase is already marked complete (and STATE.md
* has clearly advanced past it — e.g. a later phase is now in progress or
* inserted), `complete-phase` must be a no-op. No STATE.md write at all.
*/
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 { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
describe('bug #3489: state complete-phase must be idempotent', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('bug-3489-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('re-running complete-phase on an already-complete phase does not roll STATE.md back', () => {
// STATE.md as it would appear AFTER phase 02.2 was legitimately completed
// AND a follow-up Phase 02.2.1 has since been inserted as in-progress.
// Re-invoking `state complete-phase --phase 02.2` from a downstream tool
// (e.g. a re-run of /gsd-execute-phase) must NOT regress this content.
const stateMd = [
'---',
'milestone: v1.0',
'---',
'',
'# State',
'',
'**Status:** in-progress',
'**Current Phase:** 02.2.1',
'**Last Activity:** 2026-05-13',
'**Last Activity Description:** Phase 02.2.1 inserted (urgent — gates Phase 5)',
'',
'## Current Position',
'',
'Phase: 02.2.1 — Not planned yet',
'Status: Phase 02.2.1 inserted (urgent — gates Phase 5)',
'Last activity: 2026-05-13 -- Phase 02.2.1 inserted (urgent — gates Phase 5)',
'',
].join('\n');
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, stateMd, 'utf8');
const before = fs.readFileSync(statePath, 'utf8');
const result = runGsdTools(['state', 'complete-phase', '--phase', '02.2'], tmpDir);
assert.ok(result.success, `command should not error, got: ${result.error || result.output}`);
const after = fs.readFileSync(statePath, 'utf8');
// Hard assertion: file is byte-identical to its pre-call snapshot.
assert.equal(
after,
before,
`STATE.md must not be rewritten when phase is already complete.\n\n--- before ---\n${before}\n--- after ---\n${after}`,
);
// Output should advertise the no-op so downstream consumers can detect it.
let payload = null;
try { payload = JSON.parse(result.output); } catch (_) { /* ignore */ }
assert.ok(payload && typeof payload === 'object', `expected JSON payload, got: ${result.output}`);
assert.deepEqual(payload.updated, [], `expected empty updated list, got: ${JSON.stringify(payload.updated)}`);
assert.equal(payload.phase, '02.2');
assert.equal(payload.idempotent, true, `expected idempotent:true flag, got: ${JSON.stringify(payload)}`);
});
test('completing the currently in-progress phase still works normally (no false-positive idempotency)', () => {
// Sanity check: the guard must not fire on the legitimate first completion.
const stateMd = [
'---',
'milestone: v1.0',
'---',
'',
'# State',
'',
'**Status:** in-progress',
'**Current Phase:** 03',
'**Last Activity:** 2026-05-13',
'',
'## Current Position',
'',
'Phase: 03',
'Status: Phase 03 executing',
'',
].join('\n');
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
fs.writeFileSync(statePath, stateMd, 'utf8');
const result = runGsdTools(['state', 'complete-phase', '--phase', '03'], tmpDir);
assert.ok(result.success, `command failed: ${result.error || result.output}`);
const after = fs.readFileSync(statePath, 'utf8');
assert.ok(
after.includes('**Status:** Phase 03 complete'),
`expected Status updated to "Phase 03 complete", got:\n${after}`,
);
const payload = JSON.parse(result.output);
assert.notEqual(payload.idempotent, true, 'first completion must not be flagged idempotent');
assert.ok(Array.isArray(payload.updated) && payload.updated.length > 0, 'expected non-empty updated list');
});
});