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
90 lines
3.3 KiB
JavaScript
90 lines
3.3 KiB
JavaScript
'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)`);
|
|
});
|
|
});
|