* fix: require fresh phase verification before transition * no-mistakes(review): Fix canonical verification closeout gates * no-mistakes(review): Fix verify-work frontmatter promotion command * no-mistakes(review): Fix stale verification gates * no-mistakes(review): Fix canonical verification routing gates * no-mistakes(review): Fix verification dependency and runtime routing gates * no-mistakes(review): Block stale verification bypasses * fix: handle large init manager outputs in verification workflows * chore: update changeset pr number * fix(verify-work): use fresh verification.status for stale gate The stale check after UAT used phase_completion.verification_status from session-start INIT while human_needed promotion already queried fresh verification.status. Align the stale gate with the canonical query so mid-session verification refresh is not ignored. * fix(init): skip roadmap-checked phases when selecting next_phase Roadmap-only phases without a disk directory were still promoted to next_phase when their checkbox was already checked. Exclude checkboxComplete phases so progress routing does not point at work the roadmap already marks done. * fix: gaps_found not overridden by stale, transition uses canonical verification - verification.cts: check gaps_found before stale so gap-closure routing is not masked by a newer summary mtime - phase.cts: remove redundant findStaleVerificationSummary — readVerificationStatus already handles stale detection - transition.md: replace raw grep on file content with verification.status query to avoid false-positive blocks from body text matching * ci: retrigger tests after rebase * fix(transition): replace gsd_run advisory check with awk frontmatter extraction The runtime launcher is not defined until the update_roadmap_and_state step bash block (~line 165). The early verify_completion block used gsd_run to query verification.status, which violated the runtime-launcher-parity test: 'preamble appears AFTER the first gsd_run reference'. Replace the gsd_run call with an awk-based frontmatter extractor that reads only the status: field between the two --- fences. This avoids both the preamble-ordering constraint and the original false-positive grep bug where body text like 'previous_status: gaps_found' would match a full-text regex. The phase.complete gate at update_roadmap_and_state is the canonical enforcement point; this early check is advisory only. Also update workflow-size-baseline.json for the updated transition.md size. Fixes: runtime-launcher-parity test (B) Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix: re-check verification under planning lock in phase complete Move readVerificationStatus into withPlanningLock so stale verification cannot slip through when a SUMMARY.md is written between the gate and the roadmap/state mutation. Return the blocked status from the lock callback and emit the error after release to avoid leaving .lock behind. * fix(transition): gate on canonical verification.status including stale Replace awk frontmatter read with verification.status query so transition blocks when summaries are newer than VERIFICATION.md, matching phase.complete and other workflows (autonomous, progress, verify-work). * Fix workflow verification gates for yolo transition and stale routing Require VERIFY_STATUS passed before yolo/interactive transition advance. Route stale verification recovery to verify-work, matching canonical projection. * fix(transition): use verification.status query for stale-aware advisory check The awk-based check read raw frontmatter status: passed, which misses the stale case where summaries are newer than the VERIFICATION.md file even though the frontmatter still says passed. The stale status is computed from file modification times, not stored in frontmatter. Move the preamble to the verify_completion bash block (the first block with a gsd_run call) so gsd_run query verification.status can be used for the advisory check. This gives the full readVerificationStatus logic including mtime-based staleness detection, matching the enforcement gate at phase.complete. Capture full JSON (VERIFY_JSON) so next_action can be included in the advisory output alongside the status. Also update workflow-size-baseline.json for the updated transition.md size. Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * ci: trigger test matrix for 525b946 Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix(transition): restore awk frontmatter extraction for pre-shim verification check The gsd_run launcher shim is not defined until line ~163 of transition.md, so the verification debt check at line ~80 cannot use gsd_run. Restore the awk-based frontmatter extraction that correctly reads status without needing the runtime, and restore the shim at its proper location before phase.complete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(#1522): clarify transition verification gate wording * fix(#1522): update transition workflow size baseline * fix(#1522): update workflow-size-baseline after rebase onto next Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix(#1522): guard findStaleVerificationSummary FS calls + thread opts.fs seam (review) Address review blocker B1 on #1548: findStaleVerificationSummary ran fs.readdirSync and two fs.statSync calls unguarded between readVerificationStatus's try/catch sections, so a TOCTOU race (a SUMMARY listed by scanPhasePlans then removed before statSync) or any FS error threw uncaught into callers NOT under the planning lock (init.manager / init.progress / uat-predicate). Wrap the body in try/catch degrading to 'not stale', and thread the injectable opts.fs seam (add statSync to FsLike, pass fsImpl from the caller) for parity with readVerificationStatus's no-throw contract and testability. Also adds the Verification Module glossary entry to CONTEXT.md (review B3). --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
1100 lines
43 KiB
JavaScript
1100 lines
43 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Regression test for issue #4 (open-gsd/gsd-core):
|
|
* 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/gsd-core)
|
|
*/
|
|
|
|
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');
|
|
|
|
const { cleanup, runGsdTools } = require('./helpers.cjs');
|
|
|
|
// ── 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('../gsd-core/bin/lib/phase.cjs');
|
|
const { cmdPhaseComplete } = phaseModule;
|
|
|
|
function writePassedVerificationFile(phaseDir, phase = '01') {
|
|
fs.writeFileSync(path.join(phaseDir, `${phase}-VERIFICATION.md`), [
|
|
'---',
|
|
'status: passed',
|
|
'---',
|
|
'',
|
|
'# Verification',
|
|
'',
|
|
].join('\n'));
|
|
}
|
|
|
|
// ── Fixture builder ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Creates a minimal fixture project with:
|
|
* - ROADMAP.md with a 4-column progress table (Phase | Plans | Status | Completed)
|
|
* - REQUIREMENTS.md with a phase-scoped REQ-ID and Traceability row
|
|
* - 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',
|
|
'**Requirements:** REQ-1',
|
|
'**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);
|
|
|
|
const requirements = [
|
|
'# Requirements',
|
|
'',
|
|
'## Functional Requirements',
|
|
'',
|
|
'- [ ] **REQ-1** Foundation must be complete.',
|
|
'',
|
|
'## Traceability',
|
|
'',
|
|
'| Requirement | Phase | Status |',
|
|
'|-------------|-------|--------|',
|
|
'| REQ-1 | Phase 01 | Pending |',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(planningDir, 'REQUIREMENTS.md'), requirements);
|
|
|
|
// 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');
|
|
writePassedVerificationFile(phase01Dir);
|
|
|
|
// 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 roadmapCompletionSnapshot(roadmapContent) {
|
|
const snapshot = {
|
|
phaseCheckboxes: [],
|
|
progressRows: [],
|
|
};
|
|
|
|
for (const line of roadmapContent.split(/\r?\n/)) {
|
|
let match = line.match(/^- \[([ x])\] Phase ([^:]+): (.*)$/);
|
|
if (match) {
|
|
snapshot.phaseCheckboxes.push({
|
|
checked: match[1] === 'x',
|
|
phase: match[2].trim(),
|
|
title: match[3].replace(/\s+\(completed [^)]+\)$/, '').trim(),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
match = line.match(/^\|\s*(\d+[A-Z]?(?:\.\d+)*)\.?\s*([^|]*)\|\s*([^|]*)\|\s*([^|]*)\|\s*([^|]*)\|$/i);
|
|
if (match) {
|
|
snapshot.progressRows.push({
|
|
phase: match[1].trim(),
|
|
title: match[2].trim(),
|
|
plans: match[3].trim(),
|
|
status: match[4].trim(),
|
|
completed: match[5].trim(),
|
|
});
|
|
}
|
|
}
|
|
|
|
return snapshot;
|
|
}
|
|
|
|
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) {
|
|
// 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);
|
|
const origErrWrite = process.stderr.write.bind(process.stderr);
|
|
process.stdout.write = (chunk) => { chunks.push(chunk); return true; };
|
|
process.stderr.write = () => true;
|
|
try {
|
|
cmdPhaseComplete(cwd, phaseNum, false);
|
|
} finally {
|
|
process.stdout.write = origWrite;
|
|
process.stderr.write = origErrWrite;
|
|
}
|
|
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(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
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}`,
|
|
);
|
|
});
|
|
|
|
test('rolls back ROADMAP when STATE write fails during phase completion', (t) => {
|
|
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
|
const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md');
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
const originalRoadmap = fs.readFileSync(roadmapPath, 'utf8');
|
|
const originalReq = fs.readFileSync(reqPath, 'utf8');
|
|
const originalState = fs.readFileSync(statePath, 'utf8');
|
|
const originalWriteFileSync = fs.writeFileSync;
|
|
|
|
t.mock.method(fs, 'writeFileSync', function injectedStateWriteFailure(target, ...args) {
|
|
const targetPath = String(target);
|
|
const isStatePublish = targetPath === statePath || targetPath === `${statePath}.tmp.${process.pid}`;
|
|
if (isStatePublish) {
|
|
const err = new Error('injected STATE.md write failure');
|
|
err.code = 'EIO';
|
|
throw err;
|
|
}
|
|
return originalWriteFileSync.call(this, target, ...args);
|
|
});
|
|
|
|
assert.throws(
|
|
() => capturePhaseComplete(tmpDir, '1'),
|
|
/injected STATE\.md write failure/,
|
|
);
|
|
|
|
const roadmapAfter = fs.readFileSync(roadmapPath, 'utf8');
|
|
const reqAfter = fs.readFileSync(reqPath, 'utf8');
|
|
const stateAfter = fs.readFileSync(statePath, 'utf8');
|
|
|
|
assert.deepEqual(
|
|
roadmapCompletionSnapshot(roadmapAfter),
|
|
roadmapCompletionSnapshot(originalRoadmap),
|
|
'ROADMAP.md should roll back to its original completion state',
|
|
);
|
|
assert.equal(reqAfter, originalReq, 'REQUIREMENTS.md should roll back when STATE.md write fails');
|
|
assert.equal(stateAfter, originalState, 'STATE.md should remain unchanged after injected write failure');
|
|
});
|
|
|
|
test('rolls back ROADMAP when REQUIREMENTS write fails during phase completion', (t) => {
|
|
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
|
const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md');
|
|
const originalRoadmap = fs.readFileSync(roadmapPath, 'utf8');
|
|
const originalReq = fs.readFileSync(reqPath, 'utf8');
|
|
const originalWriteFileSync = fs.writeFileSync;
|
|
|
|
t.mock.method(fs, 'writeFileSync', function injectedRequirementsWriteFailure(target, ...args) {
|
|
const targetPath = String(target);
|
|
if (targetPath === reqPath || targetPath === `${reqPath}.tmp.${process.pid}`) {
|
|
const err = new Error('injected REQUIREMENTS.md write failure');
|
|
err.code = 'EIO';
|
|
throw err;
|
|
}
|
|
return originalWriteFileSync.call(this, target, ...args);
|
|
});
|
|
|
|
assert.throws(
|
|
() => capturePhaseComplete(tmpDir, '1'),
|
|
/injected REQUIREMENTS\.md write failure/,
|
|
);
|
|
|
|
assert.deepEqual(
|
|
roadmapCompletionSnapshot(fs.readFileSync(roadmapPath, 'utf8')),
|
|
roadmapCompletionSnapshot(originalRoadmap),
|
|
'ROADMAP.md should roll back when the REQUIREMENTS write fails',
|
|
);
|
|
assert.equal(fs.readFileSync(reqPath, 'utf8'), originalReq, 'REQUIREMENTS.md should be unchanged');
|
|
});
|
|
|
|
test('reports rollback failure when restoring an earlier planning file fails', (t) => {
|
|
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
|
const reqPath = path.join(tmpDir, '.planning', 'REQUIREMENTS.md');
|
|
const originalWriteFileSync = fs.writeFileSync;
|
|
let requirementsWriteFailed = false;
|
|
|
|
t.mock.method(fs, 'writeFileSync', function injectedRollbackFailure(target, ...args) {
|
|
const targetPath = String(target);
|
|
if (targetPath === reqPath || targetPath === `${reqPath}.tmp.${process.pid}`) {
|
|
requirementsWriteFailed = true;
|
|
const err = new Error('injected REQUIREMENTS.md write failure');
|
|
err.code = 'EIO';
|
|
throw err;
|
|
}
|
|
if (requirementsWriteFailed && (targetPath === roadmapPath || targetPath === `${roadmapPath}.tmp.${process.pid}`)) {
|
|
const err = new Error('injected ROADMAP.md rollback failure');
|
|
err.code = 'EIO';
|
|
throw err;
|
|
}
|
|
return originalWriteFileSync.call(this, target, ...args);
|
|
});
|
|
|
|
assert.throws(
|
|
() => capturePhaseComplete(tmpDir, '1'),
|
|
/injected REQUIREMENTS\.md write failure[\s\S]*WARNING: rollback failed while restoring[\s\S]*injected ROADMAP\.md rollback failure/,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Regressions: phase complete preserves completion date (#1161)
|
|
// Tests drive the REAL handler (cmdPhaseComplete) via the CLI entry point
|
|
// `runGsdTools('phase complete <N>')` so the fix in phase.cts is exercised
|
|
// end-to-end rather than hitting the roadmap.cjs helper in isolation.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** Extract the Completed cell from the progress table row for a given phase number.
|
|
* The Completed column is always the LAST cell, regardless of whether the table is
|
|
* 4-col (Phase | Plans | Status | Completed) or 5-col (Phase | Milestone | Plans | Status | Completed).
|
|
*/
|
|
function extractCompletedCell(roadmapContent, phaseNum) {
|
|
// Match the full progress table row whose first cell starts with the phase number.
|
|
// Use [^|\n] to avoid crossing line boundaries. Capture everything up to the final '|'.
|
|
const re = new RegExp(`^(\\|\\s*${phaseNum}[^|\\n]*(?:\\|[^|\\n]*)*)\\|\\s*$`, 'm');
|
|
const m = roadmapContent.match(re);
|
|
if (!m) return null;
|
|
// m[1] = '| 01. Foundation | 1/1 | Complete | 2026-01-01 '
|
|
// Split on '|' → ['', ' 01. Foundation ', ' 1/1 ', ' Complete ', ' 2026-01-01 ']
|
|
// Drop the leading empty string and take the last element.
|
|
const cells = m[1].split('|').slice(1); // drop leading ''
|
|
return cells[cells.length - 1].trim();
|
|
}
|
|
|
|
/**
|
|
* Build a minimal 4-col ROADMAP project fixture whose Phase 01 row already has
|
|
* the Completed cell set to `existingDate` and Status `Complete`.
|
|
* The phase directory has plan+summary so `phase complete 1` can run.
|
|
*
|
|
* @param {string} existingDate - value in the Completed cell ('2026-01-01', '-', ' ', etc.)
|
|
* @param {boolean} [alreadyComplete] - if true the checkbox is already checked and status Complete
|
|
*/
|
|
function create4ColFixture(existingDate, alreadyComplete = true) {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1161-4col-'));
|
|
const planDir = path.join(tmpDir, '.planning');
|
|
const phasesDir = path.join(planDir, 'phases');
|
|
fs.mkdirSync(phasesDir, { recursive: true });
|
|
|
|
const checkbox = alreadyComplete ? '[x]' : '[ ]';
|
|
const checkboxSuffix = alreadyComplete ? ' (completed 2026-01-01)' : '';
|
|
const status = alreadyComplete ? 'Complete ' : 'Not started';
|
|
|
|
const roadmap = [
|
|
'# Roadmap',
|
|
'',
|
|
`- ${checkbox} Phase 01: Foundation${checkboxSuffix}`,
|
|
'- [ ] Phase 02: API',
|
|
'',
|
|
'### Phase 01: Foundation',
|
|
'**Goal:** Build the foundation',
|
|
'**Plans:** 1/1 plans complete',
|
|
'',
|
|
'### Phase 02: API',
|
|
'**Goal:** Build the API',
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
'| Phase | Plans Complete | Status | Completed |',
|
|
'|-------|----------------|--------|-----------|',
|
|
`| 01. Foundation | 1/1 | ${status} | ${existingDate} |`,
|
|
'| 02. API | 0/1 | Not started | - |',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), roadmap);
|
|
|
|
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(planDir, 'STATE.md'), state);
|
|
|
|
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');
|
|
writePassedVerificationFile(phase01Dir);
|
|
|
|
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
|
|
|
return tmpDir;
|
|
}
|
|
|
|
/**
|
|
* Build a minimal 5-col ROADMAP project fixture (Phase | Milestone | Plans | Status | Completed).
|
|
* Phase 01 row already has Completed cell set to `existingDate`.
|
|
*/
|
|
function create5ColFixture(existingDate, alreadyComplete = true) {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1161-5col-'));
|
|
const planDir = path.join(tmpDir, '.planning');
|
|
const phasesDir = path.join(planDir, 'phases');
|
|
fs.mkdirSync(phasesDir, { recursive: true });
|
|
|
|
const checkbox = alreadyComplete ? '[x]' : '[ ]';
|
|
const checkboxSuffix = alreadyComplete ? ' (completed 2026-01-01)' : '';
|
|
const status = alreadyComplete ? 'Complete ' : 'Not started';
|
|
|
|
const roadmap = [
|
|
'# Roadmap',
|
|
'',
|
|
`- ${checkbox} Phase 01: Foundation${checkboxSuffix}`,
|
|
'- [ ] Phase 02: API',
|
|
'',
|
|
'### Phase 01: Foundation',
|
|
'**Goal:** Build the foundation',
|
|
'**Plans:** 1/1 plans complete',
|
|
'',
|
|
'### Phase 02: API',
|
|
'**Goal:** Build the API',
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
'| Phase | Milestone | Plans | Status | Completed |',
|
|
'|-------|-----------|-------|--------|-----------|',
|
|
`| 01. Foundation | v1.0 | 1/1 | ${status} | ${existingDate} |`,
|
|
'| 02. API | v1.0 | 0/1 | Not started | - |',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), roadmap);
|
|
|
|
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(planDir, 'STATE.md'), state);
|
|
|
|
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');
|
|
writePassedVerificationFile(phase01Dir);
|
|
|
|
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
|
|
|
return tmpDir;
|
|
}
|
|
|
|
// Fixed historical instant — will never collide with a real today() in CI.
|
|
const PINNED_MS_1161 = Date.parse('2021-03-22T10:00:00.000Z');
|
|
const PINNED_DATE_1161 = '2021-03-22';
|
|
// Env passed to runGsdTools to pin the clock in the subprocess SUT.
|
|
const PINNED_CLOCK_ENV = {
|
|
GSD_TEST_MODE: '1',
|
|
GSD_NOW_MS: String(PINNED_MS_1161),
|
|
};
|
|
|
|
describe('regressions: phase complete preserves completion date (#1161)', () => {
|
|
let tmpDir;
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// ── (a) 4-col: already Complete with a date — repeat phase complete must NOT overwrite ──
|
|
|
|
test('#1161 (a): 4-col ROADMAP — repeat `phase complete 1` preserves existing Completed date', () => {
|
|
// Arrange: Row is already Complete with '2026-01-01'.
|
|
tmpDir = create4ColFixture('2026-01-01', true);
|
|
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
|
|
|
// Act: run `phase complete 1` via the real CLI handler, clock pinned to PINNED_DATE.
|
|
const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV);
|
|
assert.ok(result.success, `phase complete failed: ${result.error || result.output}`);
|
|
|
|
// Assert: Completed cell must still be '2026-01-01', NOT the pinned '2021-03-22'.
|
|
const after = fs.readFileSync(roadmapPath, 'utf8');
|
|
const completedCell = extractCompletedCell(after, '01');
|
|
assert.strictEqual(
|
|
completedCell,
|
|
'2026-01-01',
|
|
`#1161 (a) FAILED: repeat phase complete on 4-col table overwrote the existing date.\n` +
|
|
`Expected '2026-01-01', got '${completedCell}'.\n` +
|
|
`Pinned clock was '${PINNED_DATE_1161}' — if that appears the date was overwritten.\n\n` +
|
|
`ROADMAP after:\n${after}`,
|
|
);
|
|
});
|
|
|
|
// ── (b) 5-col: already Complete with a date — repeat phase complete must NOT overwrite ──
|
|
|
|
test('#1161 (b): 5-col ROADMAP — repeat `phase complete 1` preserves existing Completed date', () => {
|
|
// Arrange: 5-col table row is already Complete with '2026-01-01'.
|
|
tmpDir = create5ColFixture('2026-01-01', true);
|
|
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
|
|
|
// Act: run `phase complete 1` via the real CLI handler, clock pinned to PINNED_DATE.
|
|
const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV);
|
|
assert.ok(result.success, `phase complete failed: ${result.error || result.output}`);
|
|
|
|
// Assert: Completed cell must still be '2026-01-01', NOT the pinned '2021-03-22'.
|
|
const after = fs.readFileSync(roadmapPath, 'utf8');
|
|
const completedCell5 = extractCompletedCell(after, '01');
|
|
assert.strictEqual(
|
|
completedCell5,
|
|
'2026-01-01',
|
|
`#1161 (b) FAILED: repeat phase complete on 5-col table overwrote the existing date.\n` +
|
|
`Expected '2026-01-01', got '${completedCell5}'.\n` +
|
|
`Pinned clock was '${PINNED_DATE_1161}' — if that appears the date was overwritten.\n\n` +
|
|
`ROADMAP after:\n${after}`,
|
|
);
|
|
});
|
|
|
|
// ── (c) First-time completion (placeholder '-') must stamp the pinned date ──
|
|
|
|
test('#1161 (c): 4-col ROADMAP — first `phase complete 1` (placeholder date) stamps pinned date', () => {
|
|
// Arrange: Row has '-' as Completed cell and is Not started (never completed).
|
|
tmpDir = create4ColFixture('-', false);
|
|
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
|
|
|
// Act: first-time phase complete.
|
|
const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV);
|
|
assert.ok(result.success, `phase complete failed: ${result.error || result.output}`);
|
|
|
|
// Assert: Completed cell is now the pinned date.
|
|
const after = fs.readFileSync(roadmapPath, 'utf8');
|
|
const completedCell = extractCompletedCell(after, '01');
|
|
assert.strictEqual(
|
|
completedCell,
|
|
PINNED_DATE_1161,
|
|
`#1161 (c) FAILED: first-time completion should stamp '${PINNED_DATE_1161}', got '${completedCell}'.\n\n` +
|
|
`ROADMAP after:\n${after}`,
|
|
);
|
|
});
|
|
|
|
// ── (d) Whitespace-only Completed cell is treated as empty and gets stamped ──
|
|
|
|
test('#1161 (d): 4-col ROADMAP — whitespace-only Completed cell treated as empty, gets stamped', () => {
|
|
// Arrange: Row has ' ' (spaces) as Completed cell.
|
|
tmpDir = create4ColFixture(' ', false);
|
|
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
|
|
|
// Act: first-time phase complete.
|
|
const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV);
|
|
assert.ok(result.success, `phase complete failed: ${result.error || result.output}`);
|
|
|
|
// Assert: Completed cell is now the pinned date (whitespace was treated as empty).
|
|
const after = fs.readFileSync(roadmapPath, 'utf8');
|
|
const completedCell = extractCompletedCell(after, '01');
|
|
assert.strictEqual(
|
|
completedCell,
|
|
PINNED_DATE_1161,
|
|
`#1161 (d) FAILED: whitespace-only Completed cell should be stamped '${PINNED_DATE_1161}', got '${completedCell}'.\n\n` +
|
|
`ROADMAP after:\n${after}`,
|
|
);
|
|
});
|
|
|
|
// ── (e) Non-date garbage in Completed cell is self-healed and gets re-stamped ──
|
|
|
|
test('#1161 (e): 5-col ROADMAP — non-date garbage Completed cell is self-healed and re-stamped', () => {
|
|
// Arrange: 5-col row is already Complete but the Completed cell contains 'TBD'
|
|
// (a non-date garbage value that the old guard would have preserved).
|
|
tmpDir = create5ColFixture('TBD', true);
|
|
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
|
|
|
// Act: run `phase complete 1` via the real CLI handler, clock pinned to PINNED_DATE.
|
|
const result = runGsdTools('phase complete 1', tmpDir, PINNED_CLOCK_ENV);
|
|
assert.ok(result.success, `phase complete failed: ${result.error || result.output}`);
|
|
|
|
// Assert: garbage 'TBD' must be replaced with the pinned date (self-heal).
|
|
// Pre-Fix 2: the old guard (existingDate && existingDate !== '-') would preserve 'TBD'.
|
|
// Post-Fix 2: the date-shape guard (/^\d{4}-\d{2}-\d{2}$/) rejects 'TBD' → re-stamps.
|
|
const after = fs.readFileSync(roadmapPath, 'utf8');
|
|
const completedCell = extractCompletedCell(after, '01');
|
|
assert.strictEqual(
|
|
completedCell,
|
|
PINNED_DATE_1161,
|
|
`#1161 (e) FAILED: non-date garbage 'TBD' in Completed cell should be self-healed to '${PINNED_DATE_1161}', got '${completedCell}'.\n` +
|
|
`Old guard (non-empty && !== '-') would preserve 'TBD'. New guard must require a date shape.\n\n` +
|
|
`ROADMAP after:\n${after}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── T2: Progress percent must never exceed 100% ──────────────────────────────
|
|
|
|
describe('issue #4 (CJS): cmdPhaseComplete — progress percent clamp', () => {
|
|
let tmpDir;
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
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}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Regressions: issue #1159 — Defect A
|
|
// VERIFICATION.md with `previous_status: gaps_found` in the body but
|
|
// `status: passed` in frontmatter must NOT emit a "has unresolved gaps" warning.
|
|
// The bug: /status: gaps_found/.test(fullContent) matches the substring inside
|
|
// `previous_status: gaps_found`, causing a false positive.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build a minimal project fixture with a VERIFICATION.md file whose
|
|
* frontmatter status is `verFmStatus` and whose body contains `previous_status: gaps_found`.
|
|
* Phase 01 has a plan+summary; Phase 02 exists for next-phase detection.
|
|
*/
|
|
function createVerificationFixture(verFmStatus) {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1159-verif-'));
|
|
const planDir = path.join(tmpDir, '.planning');
|
|
const phasesDir = path.join(planDir, 'phases');
|
|
const phase01Dir = path.join(phasesDir, '01-foundation');
|
|
fs.mkdirSync(phase01Dir, { recursive: true });
|
|
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
|
|
|
fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [
|
|
'# Roadmap',
|
|
'',
|
|
'- [ ] Phase 01: Foundation',
|
|
'- [ ] Phase 02: API',
|
|
'',
|
|
'### Phase 01: Foundation',
|
|
'**Goal:** Build the foundation',
|
|
'**Plans:** 1 plans',
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
'| Phase | Plans Complete | Status | Completed |',
|
|
'|-------|----------------|--------|-----------|',
|
|
'| 01. Foundation | 0/1 | Not started | - |',
|
|
'| 02. API | 0/1 | Not started | - |',
|
|
'',
|
|
].join('\n'));
|
|
|
|
fs.writeFileSync(path.join(planDir, 'STATE.md'), [
|
|
'# State',
|
|
'',
|
|
'**Current Phase:** 01',
|
|
'**Current Phase Name:** Foundation',
|
|
'**Status:** In progress',
|
|
'**Completed Phases:** 0',
|
|
'**Total Phases:** 2',
|
|
'**Progress:** 0%',
|
|
'',
|
|
].join('\n'));
|
|
|
|
// No REQUIREMENTS.md intentionally (not needed for this defect check)
|
|
|
|
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');
|
|
|
|
// The VERIFICATION.md has the CURRENT status in frontmatter but historical
|
|
// `previous_status: gaps_found` in the body — this is the false-positive trigger.
|
|
fs.writeFileSync(path.join(phase01Dir, '01-VERIFICATION.md'), [
|
|
'---',
|
|
`status: ${verFmStatus}`,
|
|
'phase: "01"',
|
|
'---',
|
|
'',
|
|
'# Verification',
|
|
'',
|
|
'<!-- Historical context from previous run -->',
|
|
'previous_status: gaps_found',
|
|
'',
|
|
'## Summary',
|
|
'All checks passed on re-run.',
|
|
'',
|
|
].join('\n'));
|
|
|
|
return tmpDir;
|
|
}
|
|
|
|
describe('issue #1159 (Defect A): VERIFICATION.md historical metadata must not trigger gap warning', () => {
|
|
let tmpDir;
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test(
|
|
'#1159-A-1: status:passed + previous_status:gaps_found in body → NO "has unresolved gaps" warning',
|
|
() => {
|
|
tmpDir = createVerificationFixture('passed');
|
|
const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir);
|
|
// The output is JSON; parse and check warnings array
|
|
const parsed = JSON.parse(output);
|
|
const warnings = parsed.warnings || [];
|
|
const gapWarnings = warnings.filter((w) => /unresolved gaps/i.test(w));
|
|
assert.equal(
|
|
gapWarnings.length,
|
|
0,
|
|
`#1159-A-1 FAILED: got false gap warning(s) when frontmatter status=passed.\n` +
|
|
`Warnings: ${JSON.stringify(warnings)}\n` +
|
|
`(The regex /status: gaps_found/ matched 'previous_status: gaps_found' in the body.)`,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'#1159-A-2 (boundary): status:gaps_found in frontmatter → blocks phase completion',
|
|
() => {
|
|
tmpDir = createVerificationFixture('gaps_found');
|
|
const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir);
|
|
assert.equal(result.success, false, 'gaps_found verification must block phase completion');
|
|
const parsed = JSON.parse(result.error);
|
|
assert.equal(parsed.reason, 'phase_verification_incomplete');
|
|
assert.match(parsed.message, /Gaps found/i);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'#1159-A-3 (boundary): status:human_needed in frontmatter → blocks phase completion',
|
|
() => {
|
|
tmpDir = createVerificationFixture('human_needed');
|
|
const result = runGsdTools(['--json-errors', 'phase', 'complete', '1'], tmpDir);
|
|
assert.equal(result.success, false, 'human_needed verification must block phase completion');
|
|
const parsed = JSON.parse(result.error);
|
|
assert.equal(parsed.reason, 'phase_verification_incomplete');
|
|
assert.match(parsed.message, /Human verification required/i);
|
|
},
|
|
);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Regressions: issue #1159 — Defect B
|
|
// Requirement IDs (e.g. FILE-001) that appear under explicitly deferred/future/v2
|
|
// sections in REQUIREMENTS.md must NOT be flagged as "missing from Traceability".
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build a minimal project fixture with a REQUIREMENTS.md that has:
|
|
* - An active requirement ACTIVE-001 that IS in the Traceability table
|
|
* - A deferred requirement DEFER-001 under a "Deferred v2 Requirements" heading
|
|
* that is NOT in the Traceability table (correctly out of scope)
|
|
* - Optionally a truly-missing active requirement MISSING-001 (not in table)
|
|
*/
|
|
function createDeferredReqFixture({ includeMissingActive = false } = {}) {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1159-deferred-'));
|
|
const planDir = path.join(tmpDir, '.planning');
|
|
const phasesDir = path.join(planDir, 'phases');
|
|
const phase01Dir = path.join(phasesDir, '01-foundation');
|
|
fs.mkdirSync(phase01Dir, { recursive: true });
|
|
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
|
|
|
const missingActiveLines = includeMissingActive
|
|
? ['', '- **MISSING-001** Active req not in traceability table.']
|
|
: [];
|
|
|
|
fs.writeFileSync(path.join(planDir, 'REQUIREMENTS.md'), [
|
|
'# Requirements',
|
|
'',
|
|
'## Functional Requirements',
|
|
'',
|
|
'- **ACTIVE-001** Core feature must work.',
|
|
...missingActiveLines,
|
|
'',
|
|
'## Deferred v2 Requirements',
|
|
'',
|
|
'- **DEFER-001** Nice-to-have for v2, explicitly out of scope.',
|
|
'',
|
|
'## Future Backlog',
|
|
'',
|
|
'- **FUTURE-001** Consider for next major release.',
|
|
'',
|
|
'## Traceability',
|
|
'',
|
|
'| Requirement | Phase | Status |',
|
|
'|-------------|-------|--------|',
|
|
'| ACTIVE-001 | Phase 01 | Pending |',
|
|
'',
|
|
].join('\n'));
|
|
|
|
// Roadmap references ACTIVE-001 for phase 01
|
|
fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [
|
|
'# Roadmap',
|
|
'',
|
|
'- [ ] Phase 01: Foundation',
|
|
'- [ ] Phase 02: API',
|
|
'',
|
|
'### Phase 01: Foundation',
|
|
'**Goal:** Build the foundation',
|
|
'**Requirements:** ACTIVE-001',
|
|
'**Plans:** 1 plans',
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
'| Phase | Plans Complete | Status | Completed |',
|
|
'|-------|----------------|--------|-----------|',
|
|
'| 01. Foundation | 0/1 | Not started | - |',
|
|
'| 02. API | 0/1 | Not started | - |',
|
|
'',
|
|
].join('\n'));
|
|
|
|
fs.writeFileSync(path.join(planDir, 'STATE.md'), [
|
|
'# State',
|
|
'',
|
|
'**Current Phase:** 01',
|
|
'**Current Phase Name:** Foundation',
|
|
'**Status:** In progress',
|
|
'**Completed Phases:** 0',
|
|
'**Total Phases:** 2',
|
|
'**Progress:** 0%',
|
|
'',
|
|
].join('\n'));
|
|
|
|
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');
|
|
writePassedVerificationFile(phase01Dir);
|
|
|
|
return tmpDir;
|
|
}
|
|
|
|
describe('issue #1159 (Defect B): deferred/future requirement IDs must not trigger traceability warning', () => {
|
|
let tmpDir;
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test(
|
|
'#1159-B-1: IDs under "Deferred v2 Requirements" and "Future Backlog" sections → NO traceability warning',
|
|
() => {
|
|
tmpDir = createDeferredReqFixture({ includeMissingActive: false });
|
|
const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir);
|
|
const parsed = JSON.parse(output);
|
|
const warnings = parsed.warnings || [];
|
|
const traceWarnings = warnings.filter((w) => /Traceability/i.test(w));
|
|
assert.equal(
|
|
traceWarnings.length,
|
|
0,
|
|
`#1159-B-1 FAILED: got false traceability warning(s) for deferred/future IDs.\n` +
|
|
`Warnings: ${JSON.stringify(warnings)}\n` +
|
|
`(DEFER-001 and FUTURE-001 are under deferred/future sections and must be ignored.)`,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'#1159-B-2 (boundary): truly-missing ACTIVE ID (not in table, not in deferred section) → DOES warn',
|
|
() => {
|
|
tmpDir = createDeferredReqFixture({ includeMissingActive: true });
|
|
const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir);
|
|
const parsed = JSON.parse(output);
|
|
const warnings = parsed.warnings || [];
|
|
const traceWarnings = warnings.filter((w) => /Traceability/i.test(w));
|
|
assert.ok(
|
|
traceWarnings.length > 0,
|
|
`#1159-B-2 FAILED: expected traceability warning for MISSING-001 (active, not in table) but got none.\n` +
|
|
`Warnings: ${JSON.stringify(warnings)}`,
|
|
);
|
|
// Verify MISSING-001 is specifically mentioned
|
|
const mentionsMissing = traceWarnings.some((w) => w.includes('MISSING-001'));
|
|
assert.ok(
|
|
mentionsMissing,
|
|
`#1159-B-2 FAILED: warning exists but MISSING-001 not mentioned.\n` +
|
|
`Traceability warnings: ${JSON.stringify(traceWarnings)}`,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'#1159-B-3 (boundary): deferred IDs must not contaminate warning even when active ID is also missing',
|
|
() => {
|
|
tmpDir = createDeferredReqFixture({ includeMissingActive: true });
|
|
const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir);
|
|
const parsed = JSON.parse(output);
|
|
const warnings = parsed.warnings || [];
|
|
const traceWarnings = warnings.filter((w) => /Traceability/i.test(w));
|
|
// DEFER-001 and FUTURE-001 must NOT appear in the traceability warnings
|
|
const mentionsDefer = traceWarnings.some((w) => w.includes('DEFER-001') || w.includes('FUTURE-001'));
|
|
assert.ok(
|
|
!mentionsDefer,
|
|
`#1159-B-3 FAILED: deferred IDs (DEFER-001/FUTURE-001) appeared in traceability warning.\n` +
|
|
`Traceability warnings: ${JSON.stringify(traceWarnings)}`,
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'#1159-B-4 (subheading): IDs under sub-headings of a deferred section are also suppressed',
|
|
() => {
|
|
// Codex adversarial finding: splitting on EVERY heading failed to propagate
|
|
// deferred status to sub-headings (e.g. "## Future Backlog" → "### Sub").
|
|
// The fix uses heading-depth tracking so sub-headings inherit deferred state.
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1159-subhead-'));
|
|
const planDir = path.join(tmpDir, '.planning');
|
|
const phasesDir = path.join(planDir, 'phases');
|
|
const phase01Dir = path.join(phasesDir, '01-foundation');
|
|
fs.mkdirSync(phase01Dir, { recursive: true });
|
|
fs.mkdirSync(path.join(phasesDir, '02-api'), { recursive: true });
|
|
|
|
fs.writeFileSync(path.join(planDir, 'REQUIREMENTS.md'), [
|
|
'# Requirements',
|
|
'',
|
|
'## Functional Requirements',
|
|
'',
|
|
'- **ACTIVE-001** Core feature.',
|
|
'',
|
|
'## Future Backlog',
|
|
'',
|
|
'### Sub-category A',
|
|
'',
|
|
'- **SUB-001** This is under a sub-heading of a deferred section.',
|
|
'',
|
|
'## Traceability',
|
|
'',
|
|
'| Requirement | Phase | Status |',
|
|
'|-------------|-------|--------|',
|
|
'| ACTIVE-001 | Phase 01 | Pending |',
|
|
'',
|
|
].join('\n'));
|
|
|
|
fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), [
|
|
'# Roadmap',
|
|
'',
|
|
'- [ ] Phase 01: Foundation',
|
|
'- [ ] Phase 02: API',
|
|
'',
|
|
'### Phase 01: Foundation',
|
|
'**Goal:** Build the foundation',
|
|
'**Requirements:** ACTIVE-001',
|
|
'**Plans:** 1 plans',
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
'| Phase | Plans Complete | Status | Completed |',
|
|
'|-------|----------------|--------|-----------|',
|
|
'| 01. Foundation | 0/1 | Not started | - |',
|
|
'| 02. API | 0/1 | Not started | - |',
|
|
'',
|
|
].join('\n'));
|
|
|
|
fs.writeFileSync(path.join(planDir, 'STATE.md'), [
|
|
'# State',
|
|
'',
|
|
'**Current Phase:** 01',
|
|
'**Completed Phases:** 0',
|
|
'**Total Phases:** 2',
|
|
'**Progress:** 0%',
|
|
'',
|
|
].join('\n'));
|
|
|
|
fs.writeFileSync(path.join(phase01Dir, '01-01-PLAN.md'), '# Plan 1\n');
|
|
fs.writeFileSync(path.join(phase01Dir, '01-01-SUMMARY.md'), '# Summary 1\n');
|
|
writePassedVerificationFile(phase01Dir);
|
|
|
|
const { output } = runGsdTools(['phase', 'complete', '1'], tmpDir);
|
|
const parsed = JSON.parse(output);
|
|
const warnings = parsed.warnings || [];
|
|
const traceWarnings = warnings.filter((w) => /Traceability/i.test(w));
|
|
const mentionsSub = traceWarnings.some((w) => w.includes('SUB-001'));
|
|
assert.ok(
|
|
!mentionsSub,
|
|
`#1159-B-4 FAILED: SUB-001 (under sub-heading of deferred section) appeared in warning.\n` +
|
|
`Traceability warnings: ${JSON.stringify(traceWarnings)}`,
|
|
);
|
|
},
|
|
);
|
|
});
|