* test(4): reproduce non-idempotent phase complete + unclamped percent in CJS CLI RED regression tests for issue #4: - T1: double invocation of cmdPhaseComplete double-increments **Completed Phases:** in STATE.md body (blind parseInt+1 instead of deriving from ROADMAP) - T2: progress percent can exceed 100% when Completed Phases > Total Phases The CJS path (bin/lib/phase.cjs:cmdPhaseComplete) has the bug; the SDK path (phase-lifecycle.ts:phaseComplete, fixed in ~PR#3520) already derives completed_phases from ROADMAP Complete-row count, making it idempotent. References: - Issue #4 (open-gsd/get-shit-done-redux) - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) - /tmp/adr-3524-review-findings.md (architectural justification) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(4): add sdk/scripts/gen-phase-lifecycle-policy.mjs generator + freshness check placeholder Generates phase-lifecycle-policy.generated.cjs from sdk/src/query/phase-lifecycle-policy.ts. All functions in phase-lifecycle-policy.ts are pure transforms (no I/O), directly serializable via Function.prototype.toString(). The GSDError dependency is replaced with a lightweight stub that throws plain Error objects — CJS callers that need process.exit(1) behavior catch these and delegate to error(). This is the "I/O adapter pattern" from ADR-3524 Section 4 applied to pure helpers. References: - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) - /tmp/adr-3524-review-findings.md (architectural justification) - Issue #4 (open-gsd/get-shit-done-redux) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(4): add sdk/scripts/gen-phase.mjs generator Generates phase.generated.cjs from sdk/src/query/phase.ts. Only pure helpers (isCanonicalPlanFile, describeNonCanonicalPlans) are generated; async query handlers (findPhase, phasePlanIndex) are I/O-bound and remain per-side per ADR-3524 Section 4. References: - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) - /tmp/adr-3524-review-findings.md (architectural justification) - Issue #4 (open-gsd/get-shit-done-redux) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(4): add sdk/scripts/gen-phase-lifecycle.mjs + core idempotency fix logic Generates phase-lifecycle.generated.cjs providing two pure functions that are the root-cause fix for issue #4: 1. deriveProgressFromRoadmap(roadmapContent): counts Complete rows in ROADMAP progress table — makes completed_phases idempotent (derived from ground truth instead of blind +1). Direct transcription of the SDK's "Root cause 1 fix" block in phase-lifecycle.ts (~line 1644). 2. clampPercent(completed, total): percent capped at 100 — prevents >100% progress when Completed Phases exceeds Total Phases. Design note: the full phase lifecycle mutations (add, insert, remove, complete) are inherently async and I/O-bound. Per ADR-3524 Section 4 ("I/O stays per-side"), those are NOT generated. Only the pure-computation kernel is extracted, following the I/O adapter pattern: pure logic shared; each side (CJS sync, SDK async) supplies its own I/O adapter. The pure functions are defined in the generator as real JS functions and serialized via Function.prototype.toString() — same technique as gen-project-root.mjs — rather than embedded in template literals (which would require double-escaping all regex backslashes). References: - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) - /tmp/adr-3524-review-findings.md (architectural justification) - Issue #4 (open-gsd/get-shit-done-redux) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(4): emit phase.generated.cjs, phase-lifecycle.generated.cjs, phase-lifecycle-policy.generated.cjs Three generated CJS artifacts from their respective generator scripts: - phase.generated.cjs (1.7K): isCanonicalPlanFile + describeNonCanonicalPlans from sdk/src/query/phase.ts - phase-lifecycle.generated.cjs (3.6K): deriveProgressFromRoadmap + clampPercent — the idempotency+clamp fix for issue #4 - phase-lifecycle-policy.generated.cjs (7.0K): 14 pure phase naming/directory helpers from sdk/src/query/phase-lifecycle-policy.ts Run to regenerate: node sdk/scripts/gen-phase.mjs node sdk/scripts/gen-phase-lifecycle.mjs node sdk/scripts/gen-phase-lifecycle-policy.mjs References: - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) - Issue #4 (open-gsd/get-shit-done-redux) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(4): migrate bin/lib/phase.cjs cmdPhaseComplete to use generated helpers Replace the blind-increment + unclamped percent bug in cmdPhaseComplete with the idempotent ROADMAP-derived approach: read freshly-updated ROADMAP, call deriveProgressFromRoadmap() from phase-lifecycle.generated.cjs, fall back to existing value when ROADMAP is unavailable. clampPercent() prevents >100%. Root cause fix for issue #4: the original parseInt(completedRaw) + 1 on every call made phase complete non-idempotent; the missing Math.min(100, ...) clamp allowed Progress to exceed 100%. I/O adapter pattern (ADR-3524 §4): pure computation in generated module; CJS supplies sync readFileSync; SDK supplies async readFile. Same logic, two adapters. Closes: Tests in 4-phase-complete-cjs-regression.test.cjs go GREEN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(4): add freshness checks + npm scripts for phase generated artifacts (D4/D6) Add check-phase-fresh.mjs, check-phase-lifecycle-fresh.mjs, and check-phase-lifecycle-policy-fresh.mjs (same pattern as check-project-root-fresh.mjs: import buildXCjs() from the generator, regenerate in-memory, byte-compare to committed file, exit 1 if stale). Add gen:phase, check:phase-fresh, gen:phase-lifecycle, check:phase-lifecycle-fresh, gen:phase-lifecycle-policy, check:phase-lifecycle-policy-fresh to sdk/package.json. Note: gen:phase-lifecycle / check:phase-lifecycle-fresh do not require 'npm run build' because the generator defines pure functions directly rather than importing dist. Update shared-module-handsync-allowlist.json: reclassify phase.cjs justification to reflect that it now consumes phase-lifecycle.generated.cjs for cmdPhaseComplete. The *.generated.cjs files are excluded by the lint scanner (excludes *.generated.cjs) so no new allowlist entries are required for the generated artifacts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(4): add freshness-check CI steps for phase, phase-lifecycle, phase-lifecycle-policy Add three drift-check steps to .github/workflows/test.yml following the same pattern as the existing freshness checks (ubuntu-latest + node 24 only): - SDK generated phase artifact drift check - SDK generated phase-lifecycle artifact drift check - SDK generated phase-lifecycle-policy artifact drift check These guard against editors modifying the generated *.cjs files directly. They run check-phase-fresh.mjs, check-phase-lifecycle-fresh.mjs, and check-phase-lifecycle-policy-fresh.mjs respectively (added in D4). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(4): amend ADR-3524 — phase * I/O adapter pattern for issue #4 (D8) Append a 2026-05-23 amendment to docs/adr/3524-cjs-sdk-hard-seam.md documenting the Phase * cooperating-sibling retirement: three new generator scripts extract pure-computation helpers from phase.ts / phase-lifecycle.ts / phase-lifecycle-policy.ts, cmdPhaseComplete migrates to deriveProgressFromRoadmap + clampPercent for idempotency, freshness checks + CI steps added. Clarifies what is NOT generated (async I/O-bound mutation handlers stay per-side per Section 4) and notes open drift bugs #6 and #26 for traceability. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(4): add changeset fragment for cmdPhaseComplete fix Refs #4 * fix(154): use canonical /gsd:plan-phase form in phase-lifecycle-policy.ts Replaces the retired /gsd-plan-phase slash command reference with the canonical colon-namespaced /gsd:plan-phase in the TS source template string that feeds the generated CJS roadmap entry helper. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(154): regenerate phase-lifecycle-policy.generated.cjs after slash-namespace fix Regenerated via node sdk/scripts/gen-phase-lifecycle-policy.mjs after fixing /gsd-plan-phase → /gsd:plan-phase in the TS source. Generated file now contains the canonical colon form. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(154): cross-platform frontmatter regex anchor in 4-phase-complete-cjs-regression.test.cjs Replaces /^---\n/ with /^---\r?\n/ so the frontmatter extraction helper in the regression test tolerates Windows CRLF line endings (autocrlf=true checkout leaves \r before \n, causing /^---\n/ to never match). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(154): add new generated CJS modules to INVENTORY.md and regenerate manifest Adds three missing rows to the CLI Modules table: - phase-lifecycle-policy.generated.cjs - phase-lifecycle.generated.cjs - phase.generated.cjs Bumps the headline count from 74 to 77 to match the filesystem. Also regenerates docs/INVENTORY-MANIFEST.json via node scripts/gen-inventory-manifest.cjs --write. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(154): regenerate phase-lifecycle-policy.generated.cjs with hyphen form Root cause: commit 6cd701f4 regenerated the CJS artifact but at that point sdk/dist/query/phase-lifecycle-policy.js already had the correct /gsd-plan-phase (hyphen) form while sdk/src/query/phase-lifecycle-policy.ts still had /gsd:plan-phase (colon). The generator uses Function.prototype.toString() on the compiled dist, so the CJS picked up the wrong string from the stale TS source that was compiled into dist at some earlier point. Fix: correct the TS source to /gsd-plan-phase and re-run gen-phase-lifecycle-policy.mjs so that buildPhaseRoadmapEntry in the CJS emits the hyphen form, satisfying the bug-3584-runtime-slash-emitters.test.cjs assertion at line 179. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(154): restore /gsd:plan-phase canonical form in phase-lifecycle-policy Commit 0c3a9c75 incorrectly reverted the slash-namespace fix by misreading sdk/dist/ (a build artifact in hyphen form for non-Claude runtimes) as the authoritative source. The canonical form for Claude-facing source is /gsd:plan-phase (colon-namespaced). Fix: revert TS source back to /gsd:plan-phase, rebuild dist, regenerate phase-lifecycle-policy.generated.cjs. Fixes bug-2543-gsd-slash-namespace test failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(154): update INVENTORY.md CLI Modules count to 79 after rebase onto main Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(154): use hyphen form /gsd-plan-phase in persisted phase section template - sdk/src/query/phase-lifecycle-policy.ts: use /gsd-plan-phase (routable hyphen form) in the phase scaffold template that gets persisted to ROADMAP.md; bug-3584 requires persisted artifacts use the hyphen form - docs/INVENTORY.md: add missing runtime-name-policy.cjs row in CLI Modules table - tests/4-phase-complete-cjs-regression.test.cjs: add maxRetries/retryDelay to rmSync calls to satisfy Windows parity ratchet (baseline was 95) - Regenerate phase-lifecycle-policy.generated.cjs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
282 lines
11 KiB
JavaScript
282 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Regression test for issue #4 (open-gsd/get-shit-done-redux):
|
|
* bin/lib/phase.cjs cmdPhaseComplete — non-idempotent and unclamped.
|
|
*
|
|
* Root cause (pre-fix):
|
|
* cmdPhaseComplete blindly increments "Completed Phases" by 1 on every call
|
|
* (parseInt(completedRaw, 10) + 1) and recomputes Progress without a clamp.
|
|
* Double-calling yields Completed Phases +2 (not +1), and Progress can exceed 100%.
|
|
*
|
|
* Fix: derive completed_phases from ROADMAP Complete-row count (idempotent) and
|
|
* clamp percent to 100. This mirrors the SDK fix in phase-lifecycle.ts
|
|
* (commit deriving from ROADMAP, referenced as "PR #3520" in issue #4).
|
|
*
|
|
* Note on test structure: phase-command-router.cjs delegates to the SDK when
|
|
* the SDK dist is present, so the CJS path is exercised by calling
|
|
* cmdPhaseComplete directly (bypassing the router), which is the actual
|
|
* function containing the bug.
|
|
*
|
|
* References:
|
|
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) — architectural foundation
|
|
* - /tmp/adr-3524-review-findings.md — architectural justification
|
|
* - Issue #4 (open-gsd/get-shit-done-redux)
|
|
*/
|
|
|
|
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 os = require('node:os');
|
|
|
|
// ── Load cmdPhaseComplete directly from phase.cjs (bypass the SDK router) ────
|
|
// phase-command-router.cjs delegates to SDK when available; we must test the
|
|
// CJS implementation directly since that is where the bug lives.
|
|
const phaseModule = require('../get-shit-done/bin/lib/phase.cjs');
|
|
const { cmdPhaseComplete } = phaseModule;
|
|
|
|
// ── Fixture builder ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Creates a minimal fixture project with:
|
|
* - ROADMAP.md with a 4-column progress table (Phase | Plans | Status | Completed)
|
|
* - STATE.md with Completed Phases: 0 and Total Phases: 2 (Progress 0%)
|
|
* - Phase 01 directory with one plan+summary (to satisfy phase complete guard)
|
|
* - Phase 02 directory (next phase)
|
|
*/
|
|
function createFixture(prefix = 'gsd-4-regression-') {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
const planningDir = path.join(tmpDir, '.planning');
|
|
const phasesDir = path.join(planningDir, 'phases');
|
|
fs.mkdirSync(phasesDir, { recursive: true });
|
|
|
|
// ROADMAP.md: Phase 01 not yet complete, Phase 02 not started
|
|
// 4-column progress table: Phase | Plans Complete | Status | Completed
|
|
const roadmap = [
|
|
'# Roadmap',
|
|
'',
|
|
'- [ ] Phase 01: Foundation',
|
|
'- [ ] Phase 02: API',
|
|
'',
|
|
'### Phase 01: Foundation',
|
|
'**Goal:** Build the foundation',
|
|
'**Plans:** 1 plans',
|
|
'',
|
|
'### Phase 02: API',
|
|
'**Goal:** Build the API',
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
'| Phase | Plans Complete | Status | Completed |',
|
|
'|-------|----------------|--------|-----------|',
|
|
'| 01. Foundation | 0/1 | Not started | - |',
|
|
'| 02. API | 0/1 | Not started | - |',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap);
|
|
|
|
// STATE.md: Completed Phases: 0, Total Phases: 2, Progress: 0%
|
|
// Uses body-field format (bold **Field:** value) so the CJS handler's
|
|
// stateExtractField/stateReplaceField path is exercised.
|
|
const state = [
|
|
'# State',
|
|
'',
|
|
'**Current Phase:** 01',
|
|
'**Current Phase Name:** Foundation',
|
|
'**Status:** In progress',
|
|
'**Current Plan:** 01-01',
|
|
'**Last Activity:** 2025-01-01',
|
|
'**Last Activity Description:** Working on phase 1',
|
|
'**Completed Phases:** 0',
|
|
'**Total Phases:** 2',
|
|
'**Progress:** 0%',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(planningDir, 'STATE.md'), state);
|
|
|
|
// Phase 01 directory with a PLAN and SUMMARY so phase complete guard passes
|
|
const phase01Dir = path.join(phasesDir, '01-foundation');
|
|
fs.mkdirSync(phase01Dir, { recursive: true });
|
|
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\nDo the work.\n');
|
|
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\nDone.\n');
|
|
|
|
// Phase 02 directory (needed for "next phase" detection)
|
|
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
|
|
|
return tmpDir;
|
|
}
|
|
|
|
function readStateMd(tmpDir) {
|
|
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
|
|
}
|
|
|
|
function extractField(stateContent, fieldName) {
|
|
const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const boldMatch = stateContent.match(new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i'));
|
|
if (boldMatch) return boldMatch[1].trim();
|
|
const plainMatch = stateContent.match(new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im'));
|
|
return plainMatch ? plainMatch[1].trim() : null;
|
|
}
|
|
|
|
function extractFrontmatterField(stateContent, fieldName) {
|
|
// Extract from YAML frontmatter block
|
|
const fmMatch = stateContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
if (!fmMatch) return null;
|
|
const fm = fmMatch[1];
|
|
// Handle both scalar and nested (progress.completed_phases)
|
|
const parts = fieldName.split('.');
|
|
if (parts.length === 1) {
|
|
const m = fm.match(new RegExp(`^${parts[0]}:\\s*(.+)`, 'm'));
|
|
return m ? m[1].trim() : null;
|
|
}
|
|
// Nested: e.g. "progress.completed_phases"
|
|
const sectionMatch = fm.match(new RegExp(`^${parts[0]}:\\s*\\n([\\s\\S]*?)(?=\\n[a-z]|$)`, 'm'));
|
|
if (!sectionMatch) return null;
|
|
const sectionContent = sectionMatch[1];
|
|
const fieldMatch = sectionContent.match(new RegExp(`^\\s+${parts[1]}:\\s*(.+)`, 'm'));
|
|
return fieldMatch ? fieldMatch[1].trim() : null;
|
|
}
|
|
|
|
// Capture stdout from cmdPhaseComplete (it calls output() which writes to stdout)
|
|
function capturePhaseComplete(cwd, phaseNum) {
|
|
const { execFileSync } = require('child_process');
|
|
const TOOLS = path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs');
|
|
// We invoke gsd-tools directly for the full CJS path, but with GSD_DISABLE_SDK_BRIDGE=1
|
|
// to force the CJS implementation. Since no env var disables bridge, we call cmdPhaseComplete
|
|
// directly and redirect output capture.
|
|
const chunks = [];
|
|
const origWrite = process.stdout.write.bind(process.stdout);
|
|
process.stdout.write = (chunk) => { chunks.push(chunk); return true; };
|
|
try {
|
|
cmdPhaseComplete(cwd, phaseNum, false);
|
|
} finally {
|
|
process.stdout.write = origWrite;
|
|
}
|
|
return chunks.join('');
|
|
}
|
|
|
|
// ── T1: Double invocation must NOT double-increment Completed Phases ─────────
|
|
|
|
describe('issue #4 (CJS): cmdPhaseComplete — idempotency (blind-increment bug)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createFixture();
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
});
|
|
|
|
test('T1: double invocation does NOT double-increment Completed Phases in STATE.md body', () => {
|
|
// First call — legitimate completion
|
|
capturePhaseComplete(tmpDir, '1');
|
|
|
|
const stateAfter1 = readStateMd(tmpDir);
|
|
const completedAfter1Body = extractField(stateAfter1, 'Completed Phases');
|
|
const completedAfter1Fm = extractFrontmatterField(stateAfter1, 'progress.completed_phases');
|
|
// After first call: Completed Phases in the body should be 1
|
|
// (derived from ROADMAP: 1 Complete row after marking phase 01 complete)
|
|
// OR the YAML frontmatter completed_phases should be 1
|
|
const completedAfter1 = completedAfter1Body || completedAfter1Fm;
|
|
assert.equal(
|
|
completedAfter1,
|
|
'1',
|
|
`After first call: completed_phases should be 1.\n` +
|
|
`Body field: ${completedAfter1Body}, FM field: ${completedAfter1Fm}\n\n` +
|
|
`STATE:\n${stateAfter1}`,
|
|
);
|
|
|
|
// Second call on the same phase — must be idempotent
|
|
capturePhaseComplete(tmpDir, '1');
|
|
|
|
const stateAfter2 = readStateMd(tmpDir);
|
|
const completedAfter2Body = extractField(stateAfter2, 'Completed Phases');
|
|
const completedAfter2Fm = extractFrontmatterField(stateAfter2, 'progress.completed_phases');
|
|
const completedAfter2 = completedAfter2Body || completedAfter2Fm;
|
|
|
|
// Pre-fix: completedAfter2 would be "2" (blind increment: 1+1=2)
|
|
// Post-fix: must remain "1" (derived from ROADMAP)
|
|
assert.equal(
|
|
completedAfter2,
|
|
'1',
|
|
`T1 FAILED: Completed Phases was double-incremented.\n` +
|
|
`After first call: ${completedAfter1}, after second call: ${completedAfter2}.\n` +
|
|
`This is the #4 non-idempotency bug — blind parseInt+1 instead of deriving from ROADMAP.\n\n` +
|
|
`STATE after second call (body: ${completedAfter2Body}, fm: ${completedAfter2Fm}):\n${stateAfter2}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── T2: Progress percent must never exceed 100% ──────────────────────────────
|
|
|
|
describe('issue #4 (CJS): cmdPhaseComplete — progress percent clamp', () => {
|
|
let tmpDir;
|
|
|
|
afterEach(() => {
|
|
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
});
|
|
|
|
test('T2: Progress percent never exceeds 100 after double invocation', () => {
|
|
tmpDir = createFixture();
|
|
|
|
// Pre-load STATE.md with Completed Phases: 1, Total Phases: 1 (already 100%)
|
|
// so that a blind +1 on a second call would yield 200%
|
|
let stateContent = readStateMd(tmpDir);
|
|
stateContent = stateContent.replace('**Completed Phases:** 0', '**Completed Phases:** 1');
|
|
stateContent = stateContent.replace('**Total Phases:** 2', '**Total Phases:** 1');
|
|
stateContent = stateContent.replace('**Progress:** 0%', '**Progress:** 100%');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateContent);
|
|
|
|
// Also update ROADMAP to show just 1 phase total
|
|
const roadmap = [
|
|
'# Roadmap',
|
|
'',
|
|
'- [ ] Phase 01: Foundation',
|
|
'',
|
|
'### Phase 01: Foundation',
|
|
'**Goal:** Build the foundation',
|
|
'**Plans:** 1 plans',
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
'| Phase | Plans Complete | Status | Completed |',
|
|
'|-------|----------------|--------|-----------|',
|
|
'| 01. Foundation | 0/1 | Not started | - |',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
|
|
|
// First call
|
|
capturePhaseComplete(tmpDir, '1');
|
|
// Second call — this is the problematic one
|
|
capturePhaseComplete(tmpDir, '1');
|
|
|
|
const stateAfterBoth = readStateMd(tmpDir);
|
|
|
|
// Check body Progress field
|
|
const progressStr = extractField(stateAfterBoth, 'Progress');
|
|
const fmPercent = extractFrontmatterField(stateAfterBoth, 'progress.percent');
|
|
|
|
// Try to extract numeric percent from either source
|
|
const bodyPercentMatch = progressStr && progressStr.match(/(\d+)%/);
|
|
const bodyPercent = bodyPercentMatch ? parseInt(bodyPercentMatch[1], 10) : null;
|
|
const fmPercentNum = fmPercent ? parseInt(fmPercent, 10) : null;
|
|
|
|
// At least one of body or frontmatter percent must exist and be ≤ 100
|
|
const anyPercent = bodyPercent ?? fmPercentNum;
|
|
assert.ok(
|
|
anyPercent !== null,
|
|
`T2: Could not find any percent value in STATE.md\n\nSTATE:\n${stateAfterBoth}`,
|
|
);
|
|
assert.ok(
|
|
anyPercent <= 100,
|
|
`T2 FAILED: Progress percent exceeds 100.\n` +
|
|
`Body Progress: "${progressStr}" (${bodyPercent}%), FM percent: ${fmPercentNum}%\n` +
|
|
`This is the #4 unclamped-percent bug — (N+1)/total can exceed 100.\n\n` +
|
|
`STATE:\n${stateAfterBoth}`,
|
|
);
|
|
});
|
|
});
|