Migrate cmdStateSync, cmdStatePrune, cmdStateUpdate (state.cts) onto the STATE.md Transition Module substrate and close the maintenance bug pair #1760/#1761 (ADR-1769, epic #1769). Completes the substrate: all 10 lifecycle/maintenance transitions now route through transitionCore. - Add {kind: 'sync'|'prune'|'update'} to StateTransitionIntent with syncCore, pruneCore, updateCore in src/state-transition.cts. - updateCore: body-only single-field update (strip/reassemble), mirroring the pre-migration cmdStateUpdate contract. - pruneCore: pure content→content section pruning (Decisions / Recently Completed / resolved Blockers / Performance Metrics rows at or below cutoff), byte-identical tokenizeHeadings splicing. Adapter owns currentPhase, dry-run, and STATE-ARCHIVE.md. - syncCore: body writes (Total Plans in Phase, Progress bar, Last Activity) given disk-derived numbers. Adapter owns the disk scan + roadmap scope. - #1760: cmdStatePrune now derives currentPhase from 'Current Phase' OR 'Phase' (the canonical template emits 'Phase: X of Y'), so prune engages on template-conformant STATE.md instead of bailing 'Only 0 phases'. - #1761: cmdStateSync skips the Progress write (percent=null) when a milestone version is set in frontmatter but the ROADMAP has no versioned heading for it (milestone cannot be bounded). Projects without a milestone version are unaffected. Leaves progress untouched rather than silently writing fallback- derived wrong values. - Regression: bug-1760 (prune engages on template field) + bug-1761 (sync leaves progress untouched when unbounded). ADR-1769 marked Accepted. All 82 transition + 515 regression tests pass. Closes #1793
This commit is contained in:
80
tests/bug-1760-state-prune-noop-template-field.test.cjs
Normal file
80
tests/bug-1760-state-prune-noop-template-field.test.cjs
Normal file
@@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
// Regression test for issue #1760 — `state prune` no-ops on template-conformant
|
||||
// STATE.md because it reads `Current Phase` only, never the `Phase: X of Y` line
|
||||
// the canonical template emits.
|
||||
//
|
||||
// ADR-1769 Phase 7 fix: derive the current phase with a `Phase` / `Current Phase`
|
||||
// fallback (mirroring buildStateFrontmatter), so prune engages on template STATE.md.
|
||||
|
||||
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, cleanup } = require('./helpers.cjs');
|
||||
|
||||
function writeStateMd(tmpDir, content) {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||
}
|
||||
function readStateMd(tmpDir) {
|
||||
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
}
|
||||
|
||||
describe('#1760: state prune engages on template-conformant STATE.md (Phase: X of Y)', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('prune engages when STATE.md uses "Phase: N of M" (no Current Phase field)', () => {
|
||||
// Template-conformant STATE.md: Current Position uses `Phase: 10 of 15`, and
|
||||
// there is NO `**Current Phase:**` body field. Pre-fix this made prune bail
|
||||
// with "Only 0 phases — nothing to prune".
|
||||
writeStateMd(tmpDir, [
|
||||
'# Session State',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 10 of 15',
|
||||
'Plan: 2 of 4',
|
||||
'Status: Executing Phase 10',
|
||||
'',
|
||||
'## Decisions',
|
||||
'',
|
||||
'- [Phase 1]: Old decision',
|
||||
'- [Phase 3]: Older decision',
|
||||
'- [Phase 9]: Recent decision',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
|
||||
// Pre-fix: { pruned: false, reason: 'Only 0 phases — nothing to prune...' }.
|
||||
// Post-fix: prune engages and reports a real cutoff_phase (10 - 3 = 7).
|
||||
assert.strictEqual(out.pruned, false, 'dry-run must report pruned:false');
|
||||
assert.ok(out.reason === undefined || !/Only 0 phases/i.test(String(out.reason)),
|
||||
`prune must not bail with "Only 0 phases" on template STATE.md; got reason=${JSON.stringify(out.reason)}`);
|
||||
assert.strictEqual(out.cutoff_phase, 7,
|
||||
`cutoff_phase must be 7 (current 10 - keep-recent 3); got ${JSON.stringify(out.cutoff_phase)}`);
|
||||
});
|
||||
|
||||
test('prune still engages when "Current Phase:" IS present (no regression)', () => {
|
||||
writeStateMd(tmpDir, [
|
||||
'# Session State',
|
||||
'',
|
||||
'**Current Phase:** 10',
|
||||
'',
|
||||
'## Decisions',
|
||||
'',
|
||||
'- [Phase 1]: Old decision',
|
||||
'- [Phase 9]: Recent decision',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.cutoff_phase, 7);
|
||||
});
|
||||
});
|
||||
89
tests/bug-1761-state-sync-wrong-progress.test.cjs
Normal file
89
tests/bug-1761-state-sync-wrong-progress.test.cjs
Normal file
@@ -0,0 +1,89 @@
|
||||
'use strict';
|
||||
// Regression test for issue #1761 — `state sync` silently writes wrong progress
|
||||
// when ROADMAP lacks versioned milestone headings.
|
||||
//
|
||||
// ADR-1769 Phase 7 fix: when getMilestonePhaseFilter().missingExplicitVersion is
|
||||
// true (the current milestone cannot be bounded to a versioned phase set), the
|
||||
// sync transition leaves Progress untouched (percent=null) rather than silently
|
||||
// computing/writing values off a fallback milestone.
|
||||
|
||||
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, cleanup } = require('./helpers.cjs');
|
||||
|
||||
function buildStateWithProgress({ percent = 50 } = {}) {
|
||||
const barWidth = 10;
|
||||
const filled = Math.round((percent / 100) * barWidth);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
return [
|
||||
'---',
|
||||
'gsd_state_version: 1.0',
|
||||
'milestone: v1.0',
|
||||
'milestone_name: Test',
|
||||
'current_phase: "3"',
|
||||
'status: executing',
|
||||
'progress:',
|
||||
' total_phases: 10',
|
||||
' completed_phases: 5',
|
||||
` percent: ${percent}`,
|
||||
'---',
|
||||
'',
|
||||
'# GSD State',
|
||||
'',
|
||||
'**Current Phase:** 3',
|
||||
'**Total Plans in Phase:** 4',
|
||||
'**Current Plan:** 2',
|
||||
'**Status:** Executing Phase 3',
|
||||
'**Last Activity:** 2026-06-20',
|
||||
`**Progress:** [${bar}] ${percent}%`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ROADMAP with an UNVERSIONED milestone heading (no vX.Y) — the #1761 trigger.
|
||||
function buildUnversionedRoadmap(numPhases) {
|
||||
const lines = ['# ROADMAP', '', '## Milestone 1: Test Milestone', ''];
|
||||
for (let i = 1; i <= numPhases; i++) {
|
||||
lines.push(`### Phase ${i}: phase-${i}`);
|
||||
lines.push('');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function readBodyProgress(statePath) {
|
||||
const m = fs.readFileSync(statePath, 'utf-8').match(/\*\*Progress:\*\*\s*(.*)/);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
describe('#1761: state sync leaves Progress untouched when milestone is unbounded', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('sync does NOT rewrite the Progress bar when ROADMAP lacks a versioned milestone heading', () => {
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
fs.writeFileSync(statePath, buildStateWithProgress({ percent: 50 }));
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), buildUnversionedRoadmap(10));
|
||||
|
||||
// Seed disk: 2 of 10 phases fully summarized → if sync naively recomputes,
|
||||
// it would write ~20%, clobbering the curated 50% (#1761).
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
const dir = path.join(phasesDir, String(i).padStart(2, '0'));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(dir, '01-SUMMARY.md'), '# Summary\n');
|
||||
}
|
||||
|
||||
const before = readBodyProgress(statePath);
|
||||
const result = runGsdTools('state sync', tmpDir);
|
||||
assert.ok(result.success, `state sync failed: ${result.error}`);
|
||||
const after = readBodyProgress(statePath);
|
||||
|
||||
assert.strictEqual(after, before,
|
||||
`Progress must be left untouched when the milestone is unbounded; before=${JSON.stringify(before)} after=${JSON.stringify(after)} (#1761)`);
|
||||
});
|
||||
});
|
||||
@@ -1085,3 +1085,128 @@ describe('ADR-1769 Phase 6: patch transition — field updates', () => {
|
||||
assert.deepStrictEqual(result.data && result.data.updated, ['stopped_at']);
|
||||
});
|
||||
});
|
||||
|
||||
// ADR-1769 Phase 7: update, prune, sync
|
||||
|
||||
describe('ADR-1769 Phase 7: update transition — single body field', () => {
|
||||
const deps = { clock: fixedClock, progressProvider: noProgress };
|
||||
|
||||
test('replaces a body field and reports updated:true', () => {
|
||||
const input = '# Project State\n\n**Status:** Planning\n**Current Plan:** 2\n';
|
||||
const result = transitionCore(input, { kind: 'update', field: 'Current Plan', value: '3' }, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '3');
|
||||
assert.strictEqual(result.data && result.data.updated, true);
|
||||
});
|
||||
|
||||
test('reports updated:false when the field is absent', () => {
|
||||
const input = '# Project State\n\n**Status:** Planning\n';
|
||||
const result = transitionCore(input, { kind: 'update', field: 'Nonexistent', value: 'x' }, deps);
|
||||
assert.strictEqual(result.content, input);
|
||||
assert.strictEqual(result.data && result.data.updated, false);
|
||||
});
|
||||
|
||||
test('preserves frontmatter across the body update', () => {
|
||||
const input = ['---', 'status: planning', '---', '', '# State', '', '**Status:** Planning', ''].join('\n');
|
||||
const result = transitionCore(input, { kind: 'update', field: 'Status', value: 'Paused' }, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Status'), 'Paused');
|
||||
assert.ok(/^---\r?\n[\s\S]*?\r?\n---/.test(result.content));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADR-1769 Phase 7: prune transition — section pruning', () => {
|
||||
const deps = { clock: fixedClock, progressProvider: noProgress };
|
||||
|
||||
test('archives Decisions entries at or below the cutoff phase', () => {
|
||||
const input = [
|
||||
'# Session State',
|
||||
'',
|
||||
'## Decisions',
|
||||
'',
|
||||
'- [Phase 1]: Old',
|
||||
'- [Phase 3]: Older',
|
||||
'- [Phase 9]: Recent',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(input, { kind: 'prune', cutoff: 7 }, deps);
|
||||
const archived = (result.data && result.data.archivedSections) || [];
|
||||
assert.strictEqual(result.content.includes('[Phase 1]: Old'), false);
|
||||
assert.strictEqual(result.content.includes('[Phase 3]: Older'), false);
|
||||
assert.ok(result.content.includes('[Phase 9]: Recent'));
|
||||
const decisions = archived.find((s) => s.section === 'Decisions');
|
||||
assert.ok(decisions, 'Decisions archive entry must exist');
|
||||
assert.strictEqual(decisions.count, 2);
|
||||
});
|
||||
|
||||
test('archives Performance Metrics table rows at or below the cutoff', () => {
|
||||
const input = [
|
||||
'# State',
|
||||
'',
|
||||
'## Performance Metrics',
|
||||
'',
|
||||
'| Phase | Plans | Total | Avg/Plan |',
|
||||
'| --- | --- | --- | --- |',
|
||||
'| 1 | 4 | 8 | 2 |',
|
||||
'| 9 | 2 | 4 | 2 |',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(input, { kind: 'prune', cutoff: 7 }, deps);
|
||||
assert.ok(result.content.includes('| 9 | 2 | 4 | 2 |'), 'phase-9 row must remain');
|
||||
assert.strictEqual(result.content.includes('| 1 | 4 | 8 | 2 |'), false, 'phase-1 row must be archived');
|
||||
assert.ok(result.content.includes('| Phase | Plans |'), 'header row preserved');
|
||||
});
|
||||
|
||||
test('no-op when nothing is old enough (totalPruned === 0)', () => {
|
||||
const input = '# State\n\n## Decisions\n\n- [Phase 9]: Recent\n';
|
||||
const result = transitionCore(input, { kind: 'prune', cutoff: 7 }, deps);
|
||||
assert.strictEqual(result.content, input);
|
||||
assert.strictEqual((result.data && result.data.totalPruned) || 0, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADR-1769 Phase 7: sync transition — body writes + #1761', () => {
|
||||
const deps = { clock: fixedClock, progressProvider: noProgress };
|
||||
|
||||
test('updates Total Plans in Phase + Progress bar + Last Activity when bounded', () => {
|
||||
const input = [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Total Plans in Phase:** 2',
|
||||
'**Last Activity:** 2026-06-20',
|
||||
'**Progress:** [████░░░░░░] 40%',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(
|
||||
input,
|
||||
{ kind: 'sync', totalPlansInPhase: 5, percent: 60 },
|
||||
deps,
|
||||
);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '5');
|
||||
assert.strictEqual(stateExtractField(result.content, 'Last Activity'), '2026-06-27');
|
||||
assert.ok(/\[██████░░░░\] 60%/.test(result.content), 'Progress bar must be 60%');
|
||||
});
|
||||
|
||||
test('#1761: leaves Progress untouched when percent is null (milestone unbounded)', () => {
|
||||
const input = [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Total Plans in Phase:** 2',
|
||||
'**Last Activity:** 2026-06-20',
|
||||
'**Progress:** [█████░░░░░] 50%',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(
|
||||
input,
|
||||
{ kind: 'sync', totalPlansInPhase: 5, percent: null },
|
||||
deps,
|
||||
);
|
||||
// Total Plans + Last Activity still advance; Progress bar is left untouched.
|
||||
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '5');
|
||||
assert.ok(/\[█████░░░░░\] 50%/.test(result.content), 'Progress bar must be unchanged when percent is null');
|
||||
});
|
||||
|
||||
test('skips Total Plans write when totalPlansInPhase is null', () => {
|
||||
const input = '# Project State\n\n**Total Plans in Phase:** 2\n**Progress:** 40%\n';
|
||||
const result = transitionCore(input, { kind: 'sync', totalPlansInPhase: null, percent: null }, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '2');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user