fix(#1514): exclude retired/folded phases from progress.total_phases
A retired/folded phase (struck through in ROADMAP, marked [x], with a
directory but no completion artifact) was counted in the total_phases
denominator via max(phaseDirs.length, roadmapPhaseCount), yet could never
satisfy the numerator (no SUMMARY → never "completed"), freezing shipped
milestones below 100% (e.g. 5/6 = 83%).
Both STATE counting paths now read the current-milestone ROADMAP scope and
exclude retired phases from BOTH the disk phase-dir set and the heading
count, so a retired phase counts toward neither denominator nor numerator:
- buildStateFrontmatter (`state json`)
- cmdStateSync (`state sync --verify` / rebuild) — previously re-derived the
inflated denominator and reported "no drift", per the issue.
Retired detection (extractRetiredPhaseNumbers) is scoped to the lines that
canonically mark a phase retired — a checklist entry (`- [x] …`) or a phase
heading — and within those, only a struck span whose SUBJECT is the phase
(`~~**Phase 04: Delta**~~`). So struck prose, a struck goal line, and the fold
target ("folded into Phase 05") are not misread as retired.
Phase matching uses the canonical phase-id helpers (normalizePhaseName +
extractPhaseToken), so numeric, decimal, and project-code IDs (PROJ-42) match
consistently across ROADMAP tokens and on-disk dir names.
Scope boundaries (separate, pre-existing concerns left unchanged):
- `roadmap analyze` (src/roadmap.cts) intentionally trusts the [x] checkbox
(incl. externally-completed phases) — a different reporting surface.
- cmdStateSync does not apply the milestone phase-dir filter (so 999.x /
other-milestone dirs can still affect its count); that is the #1445 /
milestone-filter axis, independent of retired phases.
Same counting family as #549 / #500 / #1445.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
135
src/state.cts
135
src/state.cts
@@ -16,7 +16,7 @@ import configLoaderMod = require('./config-loader.cjs');
|
||||
const { loadConfig } = configLoaderMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { escapeRegex } = phaseIdMod;
|
||||
const { escapeRegex, normalizePhaseName, extractPhaseToken } = phaseIdMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import roadmapParserMod = require('./roadmap-parser.cjs');
|
||||
const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
|
||||
@@ -1294,6 +1294,63 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void {
|
||||
|
||||
// ─── State Frontmatter Sync ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Canonical key for matching a ROADMAP phase token against an on-disk phase
|
||||
* directory: normalizePhaseName collapses padding/case, strips the project-code
|
||||
* prefix, and handles decimals/letter-suffixes/milestone-prefixed IDs, so
|
||||
* "Phase 4"/"Phase 04"/dir "04-delta" and "Phase PROJ-42"/dir "PROJ-42-foo"
|
||||
* each map to one key. For a directory, extract its phase token first.
|
||||
*
|
||||
* Stripping the project-code prefix is GSD's canonical phase identity (a
|
||||
* project_code is a display prefix; normalizePhaseName / phaseTokenMatches treat
|
||||
* `CK-01` and `01` as the same phase, which is what lets a prefixed dir match a
|
||||
* bare ROADMAP token). A consistent project uses one scheme, so a bare numeric
|
||||
* and a same-suffix project-code phase never coexist in one milestone.
|
||||
*/
|
||||
function phaseKeyFromToken(token: string): string {
|
||||
return normalizePhaseName(token).toUpperCase();
|
||||
}
|
||||
function phaseKeyFromDir(dir: string): string {
|
||||
return phaseKeyFromToken(extractPhaseToken(dir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the set of retired/folded phase keys from a ROADMAP milestone scope
|
||||
* (#1514). A retired phase is struck through with GFM strikethrough,
|
||||
* e.g. `- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired`.
|
||||
* Such a phase keeps a `[x]` mark and often a directory but ships no completion
|
||||
* artifact, so it would otherwise inflate `total_phases` (the denominator)
|
||||
* without ever satisfying the numerator, freezing a shipped milestone below
|
||||
* 100%.
|
||||
*
|
||||
* Detection is scoped to the lines that canonically mark a phase retired — a
|
||||
* checklist entry (`- [x] …`) or a phase heading (`#### Phase …`) — and within
|
||||
* those, only a struck span whose SUBJECT is the phase counts: the phase
|
||||
* reference must sit at the start of the `~~…~~` span (after optional markdown
|
||||
* emphasis), as in `~~**Phase 04: Delta**~~`, `~~Phase 04~~`, or
|
||||
* `~~Phase PROJ-42~~`. This ignores struck PROSE that merely mentions a phase
|
||||
* (a goal line `~~folded into Phase 05~~`, or `~~Phase 04 was renamed~~`) and
|
||||
* the fold target in `~~Phase 04~~ — folded into Phase 05` (outside the span).
|
||||
* The phase token shape mirrors the heading counter's `[\w][\w.-]*` so numeric,
|
||||
* decimal, and project-code IDs are detected alike. Returns canonical keys
|
||||
* (see phaseKeyFromToken).
|
||||
*/
|
||||
function extractRetiredPhaseNumbers(scope: string): Set<string> {
|
||||
const retired = new Set<string>();
|
||||
const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/;
|
||||
for (const line of scope.split(/\r?\n/)) {
|
||||
if (!isChecklistOrHeading.test(line)) continue;
|
||||
const strikeSpan = /~~([^~]*?)~~/g;
|
||||
let s: RegExpExecArray | null;
|
||||
while ((s = strikeSpan.exec(line)) !== null) {
|
||||
const phaseRef = /^[\s*_]*Phase\s+([\w][\w.-]*)/i.exec(s[1]);
|
||||
// Require a digit so struck prose like ~~Phase Overview~~ is ignored.
|
||||
if (phaseRef && /\d/.test(phaseRef[1])) retired.add(phaseKeyFromToken(phaseRef[1]));
|
||||
}
|
||||
}
|
||||
return retired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract machine-readable fields from STATE.md markdown body and build
|
||||
* a YAML frontmatter object. Allows hooks and scripts to read state
|
||||
@@ -1346,6 +1403,21 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
|
||||
// on repeated buildStateFrontmatter invocations within the same process (#1967)
|
||||
let cached = _diskScanCache.get(cwd);
|
||||
if (!cached) {
|
||||
// Read the current-milestone ROADMAP scope once: it feeds both the
|
||||
// heading-based phase count below and the retired/folded-phase
|
||||
// exclusion (#1514). Computed before the disk scan so retired phases
|
||||
// can be dropped from the dir set too.
|
||||
let roadmapScope: string | null = null;
|
||||
let retiredPhaseNums = new Set<string>();
|
||||
try {
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
const roadmapRaw = platformReadSync(roadmapPath);
|
||||
if (roadmapRaw !== null) {
|
||||
roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope);
|
||||
}
|
||||
} catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
||||
|
||||
const isDirInMilestone = getMilestonePhaseFilter(cwd) as (dir: string) => boolean;
|
||||
const allMatchingDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory()).map(e => e.name)
|
||||
@@ -1357,6 +1429,11 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
|
||||
// modified dir. This prevents double-counting (e.g. two "Phase 1" dirs).
|
||||
const seenPhaseNums = new Map<string, string>(); // normalizedNum -> dirName
|
||||
for (const dir of allMatchingDirs) {
|
||||
// #1514: a retired/folded phase keeps a directory but no completion
|
||||
// artifact; drop it from the disk phase set so it counts toward
|
||||
// neither the denominator nor the numerator (mirrors the heading
|
||||
// exclusion below). Project-code-aware via phaseKeyFromDir.
|
||||
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir))) continue;
|
||||
const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
|
||||
const key = m ? m[1].toLowerCase() : dir;
|
||||
if (!seenPhaseNums.has(key)) {
|
||||
@@ -1391,22 +1468,21 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
|
||||
// `## Phase Overview:` or `## Phase Details:` — single source of
|
||||
// truth for total_phases (#549).
|
||||
let roadmapPhaseCount = 0;
|
||||
try {
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
const roadmapRaw = platformReadSync(roadmapPath);
|
||||
if (roadmapRaw !== null) {
|
||||
const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
|
||||
// Only count tokens that contain at least one digit — excludes
|
||||
// pure-word section headings (Overview, Details) while keeping
|
||||
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
||||
// Also exclude 999.x backlog phases. Mirrors init.cts filter.
|
||||
if (/\d/.test(m[1]) && !/^999\b/.test(m[1])) roadmapPhaseCount++;
|
||||
}
|
||||
if (roadmapScope !== null) {
|
||||
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
|
||||
// Only count tokens that contain at least one digit — excludes
|
||||
// pure-word section headings (Overview, Details) while keeping
|
||||
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
||||
// Also exclude 999.x backlog phases. Mirrors init.cts filter.
|
||||
if (!/\d/.test(m[1]) || /^999\b/.test(m[1])) continue;
|
||||
// #1514: retired/folded phases are struck through in the ROADMAP;
|
||||
// exclude them from the denominator (they can never be completed).
|
||||
if (retiredPhaseNums.has(phaseKeyFromToken(m[1]))) continue;
|
||||
roadmapPhaseCount++;
|
||||
}
|
||||
} catch { /* fall through: phaseDirs.length used as sole count */ }
|
||||
}
|
||||
|
||||
cached = {
|
||||
totalPhases: roadmapPhaseCount > 0
|
||||
@@ -2400,12 +2476,27 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
|
||||
return;
|
||||
}
|
||||
|
||||
// #1514: read the current-milestone ROADMAP scope once so retired/folded
|
||||
// phases are excluded from BOTH the disk scan and the heading count here,
|
||||
// exactly as buildStateFrontmatter does — otherwise `state sync --verify`
|
||||
// would keep re-deriving the inflated denominator and report "no drift".
|
||||
let syncRoadmapScope: string | null = null;
|
||||
let syncRetiredPhaseNums = new Set<string>();
|
||||
try {
|
||||
const roadmapRaw = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md'));
|
||||
if (roadmapRaw !== null) {
|
||||
syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope);
|
||||
}
|
||||
} catch { /* fall through: no roadmap scope → no retired exclusion */ }
|
||||
|
||||
// Scan all phases
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(phasesDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name))))
|
||||
.sort();
|
||||
} catch {
|
||||
output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined);
|
||||
@@ -2451,17 +2542,17 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
|
||||
let syncTotalPhases: number | null = null;
|
||||
try {
|
||||
let roadmapPhaseCount = 0;
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
const roadmapRaw = platformReadSync(roadmapPath);
|
||||
if (roadmapRaw !== null) {
|
||||
const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
if (syncRoadmapScope !== null) {
|
||||
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
|
||||
while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) {
|
||||
// Only count tokens that contain at least one digit — excludes
|
||||
// pure-word section headings (Overview, Details) while keeping
|
||||
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
||||
if (/\d/.test(m[1])) roadmapPhaseCount++;
|
||||
if (!/\d/.test(m[1])) continue;
|
||||
// #1514: retired/folded phases are struck through; exclude from total.
|
||||
if (syncRetiredPhaseNums.has(phaseKeyFromToken(m[1]))) continue;
|
||||
roadmapPhaseCount++;
|
||||
}
|
||||
}
|
||||
if (roadmapPhaseCount > 0) {
|
||||
|
||||
289
tests/bug-1514-retired-phase-excluded-from-total-phases.test.cjs
Normal file
289
tests/bug-1514-retired-phase-excluded-from-total-phases.test.cjs
Normal file
@@ -0,0 +1,289 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression test for bug #1514:
|
||||
* A retired/folded phase (struck through in ROADMAP, marked `[x]`, with a
|
||||
* directory but no completion artifact) must NOT be counted in
|
||||
* progress.total_phases. Otherwise it inflates the denominator without ever
|
||||
* satisfying the numerator (no SUMMARY → never "completed"), freezing a
|
||||
* fully-shipped milestone below 100%.
|
||||
*
|
||||
* Root cause:
|
||||
* buildStateFrontmatter (state.cts) derived total_phases from
|
||||
* max(phaseDirs.length, roadmapPhaseCount) — both of which counted the
|
||||
* retired phase (its directory and its `### Phase NN:` heading) — while
|
||||
* completed_phases came from a disk SUMMARY scan that the retired phase
|
||||
* can never satisfy. Same counting family as #549 / #500 / #1445.
|
||||
*
|
||||
* Fix:
|
||||
* buildStateFrontmatter now extracts retired phase numbers from the GFM
|
||||
* strikethrough in the current-milestone ROADMAP scope and excludes them
|
||||
* from BOTH the disk phase-dir set and the heading count, so a retired
|
||||
* phase counts toward neither denominator nor numerator.
|
||||
*
|
||||
* Why integration (state json) not a unit test: the bug only manifests in the
|
||||
* assembled progress block a shipped milestone actually writes to STATE.md, so
|
||||
* the test reproduces that artifact rather than a helper in isolation.
|
||||
*/
|
||||
|
||||
const { describe, test, 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');
|
||||
|
||||
// Six phases, all shipped, except Phase 04 which is retired/folded into 05.
|
||||
// Phases 01-03,05,06 have PLAN+SUMMARY (complete); Phase 04 keeps a directory
|
||||
// but no work (retired). `complete` flags which dirs get PLAN+SUMMARY.
|
||||
function seedProject(prefix, roadmap, completeDirs) {
|
||||
const tmpDir = createTempProject(prefix);
|
||||
const planning = path.join(tmpDir, '.planning');
|
||||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmap, 'utf-8');
|
||||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
||||
fs.writeFileSync(
|
||||
path.join(planning, 'STATE.md'),
|
||||
[
|
||||
'---',
|
||||
'gsd_state_version: 1.0',
|
||||
'milestone: v1.0',
|
||||
'status: executing',
|
||||
'---',
|
||||
'',
|
||||
'# GSD State',
|
||||
'',
|
||||
'## Configuration',
|
||||
'Current Phase: 6',
|
||||
'Status: shipped',
|
||||
'Last Activity: 2026-06-01',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
const allDirs = ['01-alpha', '02-beta', '03-gamma', '04-delta', '05-epsilon', '06-zeta'];
|
||||
for (const d of allDirs) {
|
||||
const dir = path.join(planning, 'phases', d);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
if (completeDirs.includes(d)) {
|
||||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
||||
fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8');
|
||||
}
|
||||
}
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
const PHASE_DETAILS = [
|
||||
'### Phase 01: Alpha', '**Goal:** a', '',
|
||||
'### Phase 02: Beta', '**Goal:** b', '',
|
||||
'### Phase 03: Gamma', '**Goal:** c', '',
|
||||
'### Phase 04: Delta', '**Goal:** GOAL_04', '',
|
||||
'### Phase 05: Epsilon', '**Goal:** e', '',
|
||||
'### Phase 06: Zeta', '**Goal:** f',
|
||||
];
|
||||
|
||||
function roadmap(checklist04, goal04) {
|
||||
return [
|
||||
'## Milestone v1.0: Repro',
|
||||
'',
|
||||
'### Phases',
|
||||
'- [x] **Phase 01: Alpha** — done',
|
||||
'- [x] **Phase 02: Beta** — done',
|
||||
'- [x] **Phase 03: Gamma** — done',
|
||||
checklist04,
|
||||
'- [x] **Phase 05: Epsilon** — done',
|
||||
'- [x] **Phase 06: Zeta** — done',
|
||||
'',
|
||||
...PHASE_DETAILS.map((l) => (l === '**Goal:** GOAL_04' ? `**Goal:** ${goal04}` : l)),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const ALL_COMPLETE = ['01-alpha', '02-beta', '03-gamma', '05-epsilon', '06-zeta'];
|
||||
|
||||
describe('bug #1514 — retired/folded phase excluded from progress.total_phases', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => {
|
||||
if (tmpDir) cleanup(tmpDir);
|
||||
tmpDir = undefined;
|
||||
});
|
||||
|
||||
test('struck `[x] ~~Phase 04~~ — folded into Phase 05` → 5/5, percent 100 (not 5/6, 83)', () => {
|
||||
const rm = roadmap(
|
||||
'- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired',
|
||||
'folded into Phase 05',
|
||||
);
|
||||
tmpDir = seedProject('bug-1514-a-', rm, ALL_COMPLETE);
|
||||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||||
const { progress } = JSON.parse(result.output);
|
||||
assert.equal(progress.total_phases, 5, `total_phases must exclude the retired phase. Got ${progress.total_phases}`);
|
||||
assert.equal(progress.completed_phases, 5, `completed_phases must be 5. Got ${progress.completed_phases}`);
|
||||
assert.equal(progress.percent, 100, `shipped milestone must reach 100%. Got ${progress.percent}`);
|
||||
});
|
||||
|
||||
test('fold TARGET is not retired: a struck goal line `~~folded into Phase 05~~` must not drop Phase 05', () => {
|
||||
// Phase 04 retired via checklist; Phase 04 *goal* also struck and mentions
|
||||
// the fold target. The target (Phase 05) must remain a counted phase.
|
||||
const rm = roadmap(
|
||||
'- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired',
|
||||
'~~folded into Phase 05; retired~~',
|
||||
);
|
||||
tmpDir = seedProject('bug-1514-b-', rm, ALL_COMPLETE);
|
||||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||||
const { progress } = JSON.parse(result.output);
|
||||
assert.equal(progress.total_phases, 5, `only Phase 04 is retired; Phase 05 must still count. Got ${progress.total_phases}`);
|
||||
assert.equal(progress.completed_phases, 5, `completed_phases must be 5. Got ${progress.completed_phases}`);
|
||||
assert.equal(progress.percent, 100, `Got ${progress.percent}`);
|
||||
});
|
||||
|
||||
test('regression: no strikethrough → all 6 phases counted (6/6, 100)', () => {
|
||||
const rm = roadmap('- [x] **Phase 04: Delta** — done', 'd');
|
||||
tmpDir = seedProject('bug-1514-c-', rm, [...ALL_COMPLETE, '04-delta']);
|
||||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||||
const { progress } = JSON.parse(result.output);
|
||||
assert.equal(progress.total_phases, 6, `no retired phase: all 6 counted. Got ${progress.total_phases}`);
|
||||
assert.equal(progress.completed_phases, 6, `Got ${progress.completed_phases}`);
|
||||
assert.equal(progress.percent, 100, `Got ${progress.percent}`);
|
||||
});
|
||||
|
||||
// `state sync --verify` is the SECOND counting path (cmdStateSync). Before the
|
||||
// fix it re-derived the same inflated denominator and reported "no drift",
|
||||
// so a manual STATE edit was the only recourse (#1514). It must now agree
|
||||
// with state json and drive the stuck 83% Progress field to 100%.
|
||||
test('state sync --verify drives a stuck 83% Progress to 100% (cmdStateSync path)', () => {
|
||||
const rm = roadmap(
|
||||
'- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired',
|
||||
'folded into Phase 05',
|
||||
);
|
||||
tmpDir = seedProject('bug-1514-sync-', rm, ALL_COMPLETE);
|
||||
// Seed a stuck Progress line that the inflated denominator would "agree" with.
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
fs.appendFileSync(statePath, '\nProgress: [████████░░] 83%\n', 'utf-8');
|
||||
const result = runGsdTools(['state', 'sync', '--verify'], tmpDir);
|
||||
assert.ok(result.success, `state sync --verify failed: ${result.error}`);
|
||||
const { changes } = JSON.parse(result.output);
|
||||
const progressChange = (changes || []).find((c) => /Progress:/.test(c));
|
||||
assert.ok(progressChange, `expected a Progress drift, got changes: ${JSON.stringify(changes)}`);
|
||||
assert.match(progressChange, /-> .*100%/, `sync must want 100%, got: ${progressChange}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Generic seeder for non-canonical phase shapes ──────────────────────────
|
||||
|
||||
/**
|
||||
* Seed a project from explicit phase specs so project-code, decimal,
|
||||
* no-directory, and shipped-then-retired shapes can be exercised.
|
||||
* spec: { id, retired?, dir?, shipped? }
|
||||
* id — ROADMAP phase id (e.g. '04', '05.1', 'PROJ-42')
|
||||
* retired — strike the checklist entry (folded/retired)
|
||||
* dir — directory name to create (omit → no directory)
|
||||
* shipped — write PLAN+SUMMARY into the directory (complete)
|
||||
*/
|
||||
function seedFromSpecs(prefix, specs) {
|
||||
const tmpDir = createTempProject(prefix);
|
||||
const planning = path.join(tmpDir, '.planning');
|
||||
const checklist = specs.map((s) =>
|
||||
s.retired
|
||||
? `- [x] ~~**Phase ${s.id}: P${s.id}**~~ — retired`
|
||||
: `- [x] **Phase ${s.id}: P${s.id}** — done`,
|
||||
);
|
||||
const details = specs.flatMap((s) => [`### Phase ${s.id}: P${s.id}`, '**Goal:** g', '']);
|
||||
const roadmapText = ['## Milestone v1.0: Specs', '', '### Phases', ...checklist, '', ...details].join('\n');
|
||||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmapText, 'utf-8');
|
||||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
||||
fs.writeFileSync(
|
||||
path.join(planning, 'STATE.md'),
|
||||
['---', 'gsd_state_version: 1.0', 'milestone: v1.0', 'status: executing', '---', '', '# GSD State', '', '## Configuration', 'Current Phase: 1'].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
for (const s of specs) {
|
||||
if (!s.dir) continue;
|
||||
const dir = path.join(planning, 'phases', s.dir);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
if (s.shipped) {
|
||||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
||||
fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8');
|
||||
}
|
||||
}
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
describe('bug #1514 — retired exclusion across phase shapes', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => {
|
||||
if (tmpDir) cleanup(tmpDir);
|
||||
tmpDir = undefined;
|
||||
});
|
||||
|
||||
test('project-code retired phase is dropped from the denominator (Phase PROJ-42)', () => {
|
||||
// Project-code dirs are not milestone-mapped for completion counts (a
|
||||
// separate pre-existing limitation), so assert only the total_phases
|
||||
// denominator, which #1514 governs: the struck PROJ-42 heading must not
|
||||
// be counted, while PROJ-41 / PROJ-43 still are.
|
||||
tmpDir = seedFromSpecs('bug-1514-pc-', [
|
||||
{ id: 'PROJ-41', dir: 'PROJ-41-a', shipped: true },
|
||||
{ id: 'PROJ-42', retired: true, dir: 'PROJ-42-d' },
|
||||
{ id: 'PROJ-43', dir: 'PROJ-43-c', shipped: true },
|
||||
]);
|
||||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||||
const { progress } = JSON.parse(result.output);
|
||||
assert.equal(progress.total_phases, 2, `retired project-code phase must be excluded. Got ${progress.total_phases}`);
|
||||
});
|
||||
|
||||
test('decimal, multiple, shipped-then-retired, and no-directory retired phases all excluded', () => {
|
||||
// Retired: 02 (executed → has SUMMARY, then folded), 04 (no work),
|
||||
// 05.1 (decimal, no directory at all). Live: 01, 03, 06.
|
||||
tmpDir = seedFromSpecs('bug-1514-multi-', [
|
||||
{ id: '01', dir: '01-a', shipped: true },
|
||||
{ id: '02', retired: true, dir: '02-b', shipped: true },
|
||||
{ id: '03', dir: '03-c', shipped: true },
|
||||
{ id: '04', retired: true, dir: '04-d' },
|
||||
{ id: '05.1', retired: true },
|
||||
{ id: '06', dir: '06-f', shipped: true },
|
||||
]);
|
||||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||||
const { progress } = JSON.parse(result.output);
|
||||
assert.equal(progress.total_phases, 3, `3 retired of 6 → total 3. Got ${progress.total_phases}`);
|
||||
assert.equal(progress.completed_phases, 3, `live phases 01/03/06 complete. Got ${progress.completed_phases}`);
|
||||
assert.equal(progress.percent, 100, `Got ${progress.percent}`);
|
||||
});
|
||||
|
||||
test('strikethrough in a non-checklist/heading line (a goal) does NOT retire that phase', () => {
|
||||
// Detection is scoped to checklist/heading lines, so a struck GOAL line
|
||||
// that begins with a phase reference must not retire it.
|
||||
const tmp = createTempProject('bug-1514-prose-');
|
||||
const planning = path.join(tmp, '.planning');
|
||||
const roadmapText = [
|
||||
'## Milestone v1.0: Prose',
|
||||
'',
|
||||
'### Phases',
|
||||
'- [x] **Phase 01: A** — done',
|
||||
'- [x] **Phase 02: B** — done',
|
||||
'- [x] **Phase 03: C** — done',
|
||||
'',
|
||||
'### Phase 01: A', '**Goal:** g',
|
||||
'### Phase 02: B', '**Goal:** ~~Phase 02 was renamed from an earlier plan~~',
|
||||
'### Phase 03: C', '**Goal:** g',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmapText, 'utf-8');
|
||||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
||||
fs.writeFileSync(
|
||||
path.join(planning, 'STATE.md'),
|
||||
['---', 'gsd_state_version: 1.0', 'milestone: v1.0', 'status: executing', '---', '', '# GSD State', '', '## Configuration', 'Current Phase: 3'].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
for (const d of ['01-a', '02-b', '03-c']) {
|
||||
const dir = path.join(planning, 'phases', d);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
||||
fs.writeFileSync(path.join(dir, 'SUMMARY.md'), '# Summary\n', 'utf-8');
|
||||
}
|
||||
tmpDir = tmp;
|
||||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||||
const { progress } = JSON.parse(result.output);
|
||||
assert.equal(progress.total_phases, 3, `struck prose in a goal line must not retire Phase 02. Got ${progress.total_phases}`);
|
||||
assert.equal(progress.completed_phases, 3, `Got ${progress.completed_phases}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user