471 lines
17 KiB
JavaScript
471 lines
17 KiB
JavaScript
'use strict';
|
|
// Regression tests for issue #3242 — two distinct bugs in state.cjs:
|
|
//
|
|
// Bug A: cmdStateUpdate("Last Activity", date) triggers a full disk-derived
|
|
// progress.* block rebuild via readModifyWriteStateMd → syncStateFrontmatter →
|
|
// buildStateFrontmatter, which tramples manually-curated cross-milestone counters
|
|
// in STATE.md frontmatter. A body-only field update must not modify progress.*.
|
|
//
|
|
// Bug B: buildStateFrontmatter (and the duplicate in cmdStateSync) derives
|
|
// progress.percent = completedPlans / totalPlans. When ROADMAP declares more
|
|
// phases than have dirs on disk, all plans being summarised gives percent: 100
|
|
// even though half the phases are unrealised. The formula must be
|
|
// min(plan_fraction, phase_fraction) to reflect true completion.
|
|
|
|
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');
|
|
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build a minimal STATE.md body with frontmatter that has curated progress.*.
|
|
* The progress values are cross-milestone aggregates that must NOT be overwritten
|
|
* by a body-only field update.
|
|
*/
|
|
function buildStateWithCuratedProgress(opts) {
|
|
const {
|
|
completedPlans = 22,
|
|
totalPlans = 22,
|
|
completedPhases = 6,
|
|
totalPhases = 12,
|
|
percent = 50,
|
|
lastActivity = '2026-01-01',
|
|
} = opts || {};
|
|
|
|
return [
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'status: executing',
|
|
'progress:',
|
|
` total_phases: ${totalPhases}`,
|
|
` completed_phases: ${completedPhases}`,
|
|
` total_plans: ${totalPlans}`,
|
|
` completed_plans: ${completedPlans}`,
|
|
` percent: ${percent}`,
|
|
'---',
|
|
'',
|
|
'# GSD State',
|
|
'',
|
|
'## Configuration',
|
|
'Current Phase: 6',
|
|
'Current Phase Name: test-phase',
|
|
'Total Plans in Phase: 4',
|
|
'Current Plan: 1',
|
|
'Status: Executing Phase 6',
|
|
`Last Activity: ${lastActivity}`,
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
/**
|
|
* Write a ROADMAP.md with `numPhases` phase headings (matching `## Phase N:` pattern).
|
|
* Only `numRealizedDirs` phase dirs will have plan/summary files on disk.
|
|
*/
|
|
function buildRoadmap(numPhases) {
|
|
const lines = ['# ROADMAP', '', '## Milestone v1.0', ''];
|
|
for (let i = 1; i <= numPhases; i++) {
|
|
lines.push(`### Phase ${i}: phase-${i}`);
|
|
lines.push('');
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Create phase dirs with full plan+summary coverage for the first `count` phases.
|
|
* Each dir gets 1 PLAN + 1 SUMMARY so the disk-scan treats them as complete.
|
|
*/
|
|
function createPhaseDirs(phasesDir, count) {
|
|
for (let i = 1; i <= count; 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`);
|
|
}
|
|
}
|
|
|
|
function createPhasePlanOnlyDirs(phasesDir, count) {
|
|
for (let i = 1; i <= count; 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`);
|
|
}
|
|
}
|
|
|
|
function readPersistedProgress(statePath) {
|
|
const fm = extractFrontmatter(fs.readFileSync(statePath, 'utf-8'));
|
|
assert.ok(fm.progress, 'persisted frontmatter must have a progress block');
|
|
return Object.fromEntries(
|
|
Object.entries(fm.progress).map(([key, value]) => [key, Number(value)]),
|
|
);
|
|
}
|
|
|
|
function assertProgressEquals(actual, expected) {
|
|
for (const [key, value] of Object.entries(expected)) {
|
|
assert.strictEqual(
|
|
actual[key],
|
|
value,
|
|
`persisted progress.${key} expected ${value}, got ${actual[key]}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Bug A: state.update must not trample curated progress.* frontmatter
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#3242 Bug A: body-only state.update preserves curated progress frontmatter', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('state.update "Last Activity" does not overwrite progress.completed_plans', (_t) => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
fs.writeFileSync(statePath, buildStateWithCuratedProgress({
|
|
completedPlans: 22,
|
|
totalPlans: 22,
|
|
completedPhases: 6,
|
|
totalPhases: 12,
|
|
percent: 50,
|
|
lastActivity: '2026-01-01',
|
|
}));
|
|
|
|
// Write 6 phase dirs with full coverage — disk would report 6/6 phases done,
|
|
// 6/6 plans done (percent=100 from plans-only formula), but frontmatter says 50%.
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
createPhaseDirs(phasesDir, 6);
|
|
|
|
const updateResult = runGsdTools(
|
|
['state', 'update', 'Last Activity', '2026-05-07'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
|
|
|
// Read back and assert via state json (JSON return value, not raw file grep)
|
|
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 have a progress block');
|
|
|
|
// completed_plans must NOT have been trampled to 6 (disk reality) from the
|
|
// curated 22 that was stored in the frontmatter before the update.
|
|
assert.strictEqual(
|
|
fm.progress.completed_plans,
|
|
22,
|
|
`state.update "Last Activity" must not overwrite curated progress.completed_plans ` +
|
|
`(was 22, got ${fm.progress.completed_plans})`,
|
|
);
|
|
|
|
// total_phases must NOT have been trampled to 6 (disk dirs) from curated 12.
|
|
assert.strictEqual(
|
|
fm.progress.total_phases,
|
|
12,
|
|
`state.update "Last Activity" must not overwrite curated progress.total_phases ` +
|
|
`(was 12, got ${fm.progress.total_phases})`,
|
|
);
|
|
|
|
// percent must NOT have been trampled to 100 (plan-only formula on 6 realized dirs).
|
|
assert.strictEqual(
|
|
fm.progress.percent,
|
|
50,
|
|
`state.update "Last Activity" must not overwrite curated progress.percent ` +
|
|
`(was 50, got ${fm.progress.percent})`,
|
|
);
|
|
});
|
|
|
|
test('state.update "Last Activity" updates the body field itself', () => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
fs.writeFileSync(statePath, buildStateWithCuratedProgress({ lastActivity: '2026-01-01' }));
|
|
|
|
const updateResult = runGsdTools(
|
|
['state', 'update', 'Last Activity', '2026-05-07'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(updateResult.success, `state update failed: ${updateResult.error}`);
|
|
|
|
// Assert via structured JSON output — not raw file text scanning.
|
|
// state json extracts Last Activity from the body and surfaces it as
|
|
// fm.last_activity, matching the no-source-grep testing standard.
|
|
const jsonResult = runGsdTools('state json', tmpDir);
|
|
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
|
const fm = JSON.parse(jsonResult.output);
|
|
assert.strictEqual(
|
|
fm.last_activity,
|
|
'2026-05-07',
|
|
'state.update should have written the new date to the Last Activity body field',
|
|
);
|
|
});
|
|
|
|
test('state.update "Progress" resyncs progress frontmatter from the updated body', () => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
fs.writeFileSync(statePath, buildStateWithCuratedProgress({
|
|
completedPlans: 22,
|
|
totalPlans: 22,
|
|
completedPhases: 6,
|
|
totalPhases: 12,
|
|
percent: 50,
|
|
}).replace('Last Activity: 2026-01-01\n', 'Last Activity: 2026-01-01\nProgress: [█████░░░░░] 50%\n'));
|
|
|
|
const updateResult = runGsdTools(
|
|
['state', 'update', 'Progress', '[████████░░] 80%'],
|
|
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.progress.percent, 80);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #1264: state.patch must apply the same progress preservation policy
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#1264: state.patch preserves curated progress frontmatter for non-progress fields', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('query state.patch of Current Phase preserves persisted progress.* values', () => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
const curatedProgress = {
|
|
total_phases: 4,
|
|
completed_phases: 3,
|
|
total_plans: 11,
|
|
completed_plans: 11,
|
|
percent: 75,
|
|
};
|
|
fs.writeFileSync(statePath, buildStateWithCuratedProgress({
|
|
completedPlans: curatedProgress.completed_plans,
|
|
totalPlans: curatedProgress.total_plans,
|
|
completedPhases: curatedProgress.completed_phases,
|
|
totalPhases: curatedProgress.total_phases,
|
|
percent: curatedProgress.percent,
|
|
}));
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
buildRoadmap(5),
|
|
);
|
|
createPhasePlanOnlyDirs(path.join(tmpDir, '.planning', 'phases'), 5);
|
|
|
|
const patchResult = runGsdTools([
|
|
'query',
|
|
'state.patch',
|
|
JSON.stringify({ 'Current Phase': '08.2' }),
|
|
], tmpDir);
|
|
assert.ok(patchResult.success, `state patch failed: ${patchResult.error}`);
|
|
|
|
const output = JSON.parse(patchResult.output);
|
|
assert.deepEqual(output.updated, ['Current Phase']);
|
|
|
|
const progress = readPersistedProgress(statePath);
|
|
assertProgressEquals(progress, curatedProgress);
|
|
});
|
|
|
|
test('query state.patch of Total Plans in Phase still resyncs persisted progress.* from the updated body', () => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
fs.writeFileSync(statePath, buildStateWithCuratedProgress({
|
|
completedPlans: 22,
|
|
totalPlans: 22,
|
|
completedPhases: 6,
|
|
totalPhases: 12,
|
|
percent: 50,
|
|
}));
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
buildRoadmap(8),
|
|
);
|
|
createPhasePlanOnlyDirs(path.join(tmpDir, '.planning', 'phases'), 8);
|
|
|
|
const patchResult = runGsdTools([
|
|
'query',
|
|
'state.patch',
|
|
JSON.stringify({ 'Total Plans in Phase': '8' }),
|
|
], tmpDir);
|
|
assert.ok(patchResult.success, `state patch failed: ${patchResult.error}`);
|
|
|
|
const output = JSON.parse(patchResult.output);
|
|
assert.deepEqual(output.updated, ['Total Plans in Phase']);
|
|
|
|
const progress = readPersistedProgress(statePath);
|
|
assert.strictEqual(progress.total_plans, 8);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Bug B: progress.percent must use min(plan_fraction, phase_fraction)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#3242 Bug B: progress.percent reflects phase fraction when ROADMAP declares future phases', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('12 declared phases / 6 realized / 6/6 plans done → percent is 50, not 100', (_t) => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
|
|
// Body: 6 realized phases visible to disk scan.
|
|
// Frontmatter: intentionally absent so buildStateFrontmatter runs fresh.
|
|
fs.writeFileSync(statePath, [
|
|
'# GSD State',
|
|
'',
|
|
'## Configuration',
|
|
'Current Phase: 6',
|
|
'Current Phase Name: test-phase-6',
|
|
'Total Plans in Phase: 1',
|
|
'Current Plan: 1',
|
|
'Status: Executing Phase 6',
|
|
'Last Activity: 2026-01-01',
|
|
'',
|
|
].join('\n'));
|
|
|
|
// ROADMAP with 12 phase headings — only 6 will have dirs on disk
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
buildRoadmap(12),
|
|
);
|
|
|
|
// 6 fully-realized phases (all plans have summaries)
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
createPhaseDirs(phasesDir, 6);
|
|
|
|
// state json rebuilds frontmatter from disk+body — this exercises buildStateFrontmatter
|
|
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 have a progress block');
|
|
|
|
// ROADMAP declares 12 phases; only 6 exist on disk → totalPhases = 12
|
|
assert.strictEqual(
|
|
fm.progress.total_phases,
|
|
12,
|
|
`total_phases must reflect ROADMAP-declared count (12), got ${fm.progress.total_phases}`,
|
|
);
|
|
|
|
// 6 of 12 phases realized → phase_fraction = 50%
|
|
// 6/6 plans done → plan_fraction = 100%
|
|
// percent = min(100, 50) = 50
|
|
assert.strictEqual(
|
|
fm.progress.percent,
|
|
50,
|
|
`percent must be 50 (phase fraction), not 100 (plan fraction) — ` +
|
|
`6 of 12 ROADMAP phases realized. Got ${fm.progress.percent}`,
|
|
);
|
|
});
|
|
|
|
test('all phases realized: percent equals plan fraction (no artificial cap)', (_t) => {
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
|
|
fs.writeFileSync(statePath, [
|
|
'# GSD State',
|
|
'',
|
|
'## Configuration',
|
|
'Current Phase: 3',
|
|
'Current Phase Name: final-phase',
|
|
'Total Plans in Phase: 1',
|
|
'Current Plan: 1',
|
|
'Status: Executing Phase 3',
|
|
'Last Activity: 2026-01-01',
|
|
'',
|
|
].join('\n'));
|
|
|
|
// ROADMAP declares 3 phases; all 3 have dirs and full plan+summary coverage
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
buildRoadmap(3),
|
|
);
|
|
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
createPhaseDirs(phasesDir, 3);
|
|
|
|
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 have progress block');
|
|
|
|
// 3/3 phases done → phase_fraction = 100%
|
|
// 3/3 plans done → plan_fraction = 100%
|
|
// percent = min(100, 100) = 100
|
|
assert.strictEqual(
|
|
fm.progress.percent,
|
|
100,
|
|
`percent must be 100 when all phases are realized and all plans summarized`,
|
|
);
|
|
});
|
|
|
|
test('state sync also reflects phase-fraction-capped percent in body Progress field', () => {
|
|
// state sync updates the body's Progress: field — it must use the same capped formula
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
|
|
fs.writeFileSync(statePath, [
|
|
'# GSD State',
|
|
'',
|
|
'## Configuration',
|
|
'Current Phase: 6',
|
|
'Current Phase Name: phase-6',
|
|
'Total Plans in Phase: 1',
|
|
'Current Plan: 1',
|
|
'Status: Executing Phase 6',
|
|
'Last Activity: 2026-01-01',
|
|
'Progress: [░░░░░░░░░░] 0%',
|
|
'',
|
|
].join('\n'));
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
buildRoadmap(12),
|
|
);
|
|
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
createPhaseDirs(phasesDir, 6);
|
|
|
|
const syncResult = runGsdTools('state sync', tmpDir);
|
|
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
|
|
|
// Read the body's Progress field via state json (JSON output is authoritative)
|
|
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 have progress block');
|
|
|
|
// state sync wrote a Progress: body field; state json re-derives percent from disk.
|
|
// Both must agree: 50%, not 100%.
|
|
assert.strictEqual(
|
|
fm.progress.percent,
|
|
50,
|
|
`state sync must cap percent at phase fraction (50%), got ${fm.progress.percent}`,
|
|
);
|
|
});
|
|
});
|