Fold 94 issue-named command/module regression files into the canonical test file that owns each subject-under-test, across 52 existing suites (state, config, frontmatter, roadmap-parser, capability-registry, shell-command-projection-dispatch, plan-phase-drift-guard, health-validation, runtime-converters, commands, etc.). Verbatim block-scoped describe wrappers; 881 subtests conserved 1:1. No new test files. Host-env pre-check (per B2): the only GSD_WORKSTREAM/GSD_PROJECT-touching destinations (intel, planning-workspace) clear those vars hermetically, so folded CLI tests are safe. Regenerates regression-name allowlist (222->162), ratchets file-count allowlist across 8 buckets (validate entry removed after dropping <=2), makes 34 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes 34 stale ids). Repoints CONTEXT.md + ADR-0002/443/1235/3524 test-file references. lint:ci green. Part of epic #1969. Closes #1972. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1560 lines
64 KiB
JavaScript
1560 lines
64 KiB
JavaScript
/**
|
||
* roadmap-parser.cjs — unit tests
|
||
*
|
||
* Covers the 6 functions extracted from core.cjs per ADR-857 rollout
|
||
* phase 2b (#870): stripShippedMilestones, extractCurrentMilestone,
|
||
* replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo,
|
||
* getMilestonePhaseFilter.
|
||
*
|
||
* Includes:
|
||
* - Behavioral tests against realistic ROADMAP.md content
|
||
* - Adversarial fixtures (malformed frontmatter, unclosed fences,
|
||
* headings inside fences, unicode headings, repeated/decimal phase
|
||
* IDs, mixed CRLF/LF)
|
||
* - Shim-identity assertions verifying core.cjs re-exports are the
|
||
* same function objects as roadmap-parser.cjs exports
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
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 roadmapParser = require('../gsd-core/bin/lib/roadmap-parser.cjs');
|
||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||
|
||
const {
|
||
stripShippedMilestones,
|
||
extractCurrentMilestone,
|
||
replaceInCurrentMilestone,
|
||
getRoadmapPhaseInternal,
|
||
getMilestoneInfo,
|
||
getMilestonePhaseFilter,
|
||
} = roadmapParser;
|
||
|
||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
function writeRoadmap(tmpDir, content) {
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content);
|
||
}
|
||
|
||
function writeState(tmpDir, fields) {
|
||
const lines = Object.entries(fields).map(([k, v]) => `${k}: ${v}`);
|
||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), lines.join('\n') + '\n');
|
||
}
|
||
|
||
|
||
// ─── stripShippedMilestones ───────────────────────────────────────────────────
|
||
|
||
describe('roadmap-parser: stripShippedMilestones', () => {
|
||
test('strips a single <details> block', () => {
|
||
const input = 'before\n<details>\nsome shipped content\n</details>\nafter';
|
||
const result = stripShippedMilestones(input);
|
||
assert.ok(!result.includes('<details>'), 'details tag should be removed');
|
||
assert.ok(!result.includes('shipped content'), 'shipped content should be removed');
|
||
assert.ok(result.includes('before'), 'before content preserved');
|
||
assert.ok(result.includes('after'), 'after content preserved');
|
||
});
|
||
|
||
test('strips multiple <details> blocks', () => {
|
||
const input = '<details>\nA\n</details>\nmiddle\n<details>\nB\n</details>\nend';
|
||
const result = stripShippedMilestones(input);
|
||
assert.ok(result.includes('middle'), 'middle content preserved');
|
||
assert.ok(result.includes('end'), 'end content preserved');
|
||
assert.ok(!result.includes('<details>'), 'all details tags removed');
|
||
});
|
||
|
||
test('returns unchanged string when no <details> blocks', () => {
|
||
const input = '## v1.0: Launch\n### Phase 1: Setup\n**Goal:** init\n';
|
||
assert.strictEqual(stripShippedMilestones(input), input);
|
||
});
|
||
|
||
test('handles case-insensitive <DETAILS> tags', () => {
|
||
const input = '<DETAILS>\nclosed content\n</DETAILS>\nafter';
|
||
const result = stripShippedMilestones(input);
|
||
assert.ok(!result.includes('closed content'), 'content removed');
|
||
assert.ok(result.includes('after'), 'after content preserved');
|
||
});
|
||
});
|
||
|
||
// ─── extractCurrentMilestone ──────────────────────────────────────────────────
|
||
|
||
describe('roadmap-parser: extractCurrentMilestone', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => { tmpDir = createTempProject(); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('no cwd — strips <details> only', () => {
|
||
const input = '<details>\nshipped\n</details>\n## v2.0: Next\n### Phase 1: Setup\n';
|
||
const result = extractCurrentMilestone(input);
|
||
assert.ok(!result.includes('<details>'), 'details stripped');
|
||
assert.ok(result.includes('v2.0'), 'version heading preserved');
|
||
});
|
||
|
||
test('reads milestone from STATE.md and extracts that section', () => {
|
||
writeState(tmpDir, { milestone: 'v2.0' });
|
||
const content = [
|
||
'<details>',
|
||
'<summary>v1.0</summary>',
|
||
'### Phase 1: Old',
|
||
'</details>',
|
||
'## v2.0: Current',
|
||
'### Phase 2-01: Setup',
|
||
'**Goal:** build',
|
||
].join('\n');
|
||
writeRoadmap(tmpDir, content);
|
||
|
||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||
const result = extractCurrentMilestone(roadmap, tmpDir);
|
||
assert.ok(result.includes('v2.0'), 'current milestone section included');
|
||
assert.ok(!result.includes('Old'), 'shipped milestone section excluded');
|
||
});
|
||
|
||
test('falls back to 🚧 marker when STATE.md has no milestone field', () => {
|
||
writeState(tmpDir, { phase: 'some-phase' });
|
||
const content = [
|
||
'## 🚧 **v2.0 Work in Progress**',
|
||
'### Phase 1: Active',
|
||
'**Goal:** do work',
|
||
].join('\n');
|
||
writeRoadmap(tmpDir, content);
|
||
|
||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||
const result = extractCurrentMilestone(roadmap, tmpDir);
|
||
assert.ok(result.includes('v2.0'), 'inferred v2.0 milestone section included');
|
||
});
|
||
|
||
test('strips shipped milestones when no STATE.md and no 🚧 marker', () => {
|
||
const content = [
|
||
'<details>',
|
||
'<summary>v1.0 done</summary>',
|
||
'### Phase 1: Done',
|
||
'</details>',
|
||
'## v2.0: Next (no WIP marker)',
|
||
'### Phase 2: Future',
|
||
].join('\n');
|
||
|
||
const result = extractCurrentMilestone(content);
|
||
assert.ok(!result.includes('<details>'), 'details stripped');
|
||
assert.ok(result.includes('v2.0'), 'remaining content preserved');
|
||
});
|
||
|
||
test('unicode heading — emoji-prefixed milestone', () => {
|
||
writeState(tmpDir, { milestone: 'v3.0' });
|
||
const content = [
|
||
'## ✅ v1.0: Shipped',
|
||
'## 🚧 v3.0: In Progress',
|
||
'### Phase 3-01: Unicode Héros',
|
||
'**Goal:** тест',
|
||
].join('\n');
|
||
writeRoadmap(tmpDir, content);
|
||
|
||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||
const result = extractCurrentMilestone(roadmap, tmpDir);
|
||
assert.ok(result.includes('v3.0'), 'v3.0 heading included');
|
||
assert.ok(result.includes('Unicode'), 'unicode phase name included');
|
||
});
|
||
|
||
test('CRLF line endings are handled', () => {
|
||
writeState(tmpDir, { milestone: 'v1.0' });
|
||
const content = '## v1.0: CRLF\r\n### Phase 1: Setup\r\n**Goal:** crlf goal\r\n';
|
||
writeRoadmap(tmpDir, content);
|
||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||
const result = extractCurrentMilestone(roadmap, tmpDir);
|
||
assert.ok(result.includes('v1.0'), 'section found despite CRLF');
|
||
});
|
||
|
||
test('heading inside fenced code block not confused for milestone boundary', () => {
|
||
writeState(tmpDir, { milestone: 'v1.0' });
|
||
const content = [
|
||
'## v1.0: Current Milestone',
|
||
'### Phase 1: Real Phase',
|
||
'**Goal:** real goal',
|
||
'```markdown',
|
||
'## v2.0: Fake Heading Inside Fence',
|
||
'```',
|
||
'### Phase 2: Also Real',
|
||
'**Goal:** also real',
|
||
].join('\n');
|
||
writeRoadmap(tmpDir, content);
|
||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||
const result = extractCurrentMilestone(roadmap, tmpDir);
|
||
// The section should include Phase 1 content; the fenced heading should not terminate section early
|
||
assert.ok(result.includes('real goal'), 'phase 1 content included');
|
||
assert.ok(result.includes('Also Real'), 'phase 2 content also included');
|
||
});
|
||
});
|
||
|
||
// ─── replaceInCurrentMilestone ────────────────────────────────────────────────
|
||
|
||
describe('roadmap-parser: replaceInCurrentMilestone', () => {
|
||
test('replaces in content after last </details> when present', () => {
|
||
const content = '<details>\nold\n</details>\n**Plans:** 0/1 plans';
|
||
const result = replaceInCurrentMilestone(content, /0\/1 plans/, '1/1 plans complete');
|
||
assert.ok(result.includes('1/1 plans complete'), 'replacement applied after </details>');
|
||
assert.ok(result.includes('<details>'), 'details block untouched');
|
||
});
|
||
|
||
test('replaces anywhere when no </details> present', () => {
|
||
const content = '**Plans:** 0/1 plans';
|
||
const result = replaceInCurrentMilestone(content, /0\/1 plans/, '1/1 plans complete');
|
||
assert.strictEqual(result, '**Plans:** 1/1 plans complete');
|
||
});
|
||
|
||
test('does not replace in shipped sections', () => {
|
||
const content = '<details>\n**Plans:** 0/1 plans\n</details>\n## v2.0\n**Plans:** 0/1 plans';
|
||
const result = replaceInCurrentMilestone(content, /0\/1 plans/, '1/1 plans complete');
|
||
// Only the SECOND occurrence (after </details>) should be replaced
|
||
assert.ok(result.includes('<details>\n**Plans:** 0/1 plans\n</details>'), 'shipped section unchanged');
|
||
assert.ok(result.includes('## v2.0\n**Plans:** 1/1 plans complete'), 'current section updated');
|
||
});
|
||
});
|
||
|
||
// ─── getRoadmapPhaseInternal ──────────────────────────────────────────────────
|
||
|
||
describe('roadmap-parser: getRoadmapPhaseInternal', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => { tmpDir = createTempProject(); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('returns null when ROADMAP.md missing', () => {
|
||
const result = getRoadmapPhaseInternal(tmpDir, '1');
|
||
assert.strictEqual(result, null);
|
||
});
|
||
|
||
test('returns null when phaseNum is falsy', () => {
|
||
writeRoadmap(tmpDir, '### Phase 1: Foo\n**Goal:** bar\n');
|
||
assert.strictEqual(getRoadmapPhaseInternal(tmpDir, null), null);
|
||
assert.strictEqual(getRoadmapPhaseInternal(tmpDir, ''), null);
|
||
assert.strictEqual(getRoadmapPhaseInternal(tmpDir, 0), null);
|
||
});
|
||
|
||
test('finds a phase by number', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Current',
|
||
'### Phase 1: Foundation',
|
||
'**Goal:** Set up infrastructure',
|
||
'',
|
||
'### Phase 2: API',
|
||
'**Goal:** Build the API',
|
||
].join('\n'));
|
||
|
||
const result = getRoadmapPhaseInternal(tmpDir, '1');
|
||
assert.ok(result !== null, 'result should not be null');
|
||
assert.strictEqual(result.found, true);
|
||
assert.strictEqual(result.phase_name, 'Foundation');
|
||
assert.strictEqual(result.goal, 'Set up infrastructure');
|
||
});
|
||
|
||
test('finds drifted project-code-prefixed headings by bare number (#1455)', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Current',
|
||
'### Phase MANIFOLD-117: Prefixed Heading',
|
||
'**Goal:** Recover from roadmapper heading drift',
|
||
].join('\n'));
|
||
|
||
const result = getRoadmapPhaseInternal(tmpDir, '117');
|
||
assert.ok(result !== null, 'bare number lookup should tolerate a prefixed heading');
|
||
assert.strictEqual(result.found, true);
|
||
assert.strictEqual(result.phase_number, '117');
|
||
assert.strictEqual(result.phase_name, 'Prefixed Heading');
|
||
assert.strictEqual(result.goal, 'Recover from roadmapper heading drift');
|
||
});
|
||
|
||
test('finds drifted project-code-prefixed headings by prefixed query (#1455)', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Current',
|
||
'### Phase MANIFOLD-117: Prefixed Heading',
|
||
'**Goal:** Exact prefixed lookup works on init resolver',
|
||
].join('\n'));
|
||
|
||
const result = getRoadmapPhaseInternal(tmpDir, 'MANIFOLD-117');
|
||
assert.ok(result !== null, 'prefixed lookup should resolve the matching prefixed heading');
|
||
assert.strictEqual(result.found, true);
|
||
assert.strictEqual(result.phase_number, 'MANIFOLD-117');
|
||
assert.strictEqual(result.phase_name, 'Prefixed Heading');
|
||
assert.strictEqual(result.goal, 'Exact prefixed lookup works on init resolver');
|
||
});
|
||
|
||
test('prefers canonical bare heading before prefixed drift fallback (#1455)', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Current',
|
||
'### Phase MANIFOLD-117: Prefixed Heading',
|
||
'**Goal:** Drift fallback',
|
||
'',
|
||
'### Phase 117: Bare Heading',
|
||
'**Goal:** Canonical bare',
|
||
].join('\n'));
|
||
|
||
const result = getRoadmapPhaseInternal(tmpDir, '117');
|
||
assert.ok(result !== null, 'bare lookup should resolve');
|
||
assert.strictEqual(result.phase_name, 'Bare Heading');
|
||
assert.strictEqual(result.goal, 'Canonical bare');
|
||
});
|
||
|
||
test('returns null for missing phase number', () => {
|
||
writeRoadmap(tmpDir, '### Phase 1: Foo\n**Goal:** bar\n');
|
||
const result = getRoadmapPhaseInternal(tmpDir, '99');
|
||
assert.strictEqual(result, null);
|
||
});
|
||
|
||
test('finds milestone-prefixed phase ID (e.g. 2-01)', () => {
|
||
writeState(tmpDir, { milestone: 'v2.0' });
|
||
writeRoadmap(tmpDir, [
|
||
'## v2.0: Current',
|
||
'### Phase 2-01: Alpha',
|
||
'**Goal:** first alpha phase',
|
||
'',
|
||
'### Phase 2-02: Beta',
|
||
'**Goal:** beta phase',
|
||
].join('\n'));
|
||
|
||
const result = getRoadmapPhaseInternal(tmpDir, '2-01');
|
||
assert.ok(result !== null);
|
||
assert.strictEqual(result.found, true);
|
||
assert.strictEqual(result.phase_name, 'Alpha');
|
||
assert.strictEqual(result.goal, 'first alpha phase');
|
||
});
|
||
|
||
test('decimal phase ID (e.g. 1.5)', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Current',
|
||
'### Phase 1.5: Intermediate',
|
||
'**Goal:** interstitial step',
|
||
].join('\n'));
|
||
|
||
const result = getRoadmapPhaseInternal(tmpDir, '1.5');
|
||
assert.ok(result !== null);
|
||
assert.strictEqual(result.phase_name, 'Intermediate');
|
||
});
|
||
});
|
||
|
||
// ─── getMilestoneInfo ─────────────────────────────────────────────────────────
|
||
|
||
describe('roadmap-parser: getMilestoneInfo', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => { tmpDir = createTempProject(); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('returns default when ROADMAP.md missing', () => {
|
||
const info = getMilestoneInfo(tmpDir);
|
||
assert.strictEqual(info.version, 'v1.0');
|
||
assert.strictEqual(info.name, 'milestone');
|
||
});
|
||
|
||
test('reads version from STATE.md and heading name', () => {
|
||
writeState(tmpDir, { milestone: 'v2.0' });
|
||
writeRoadmap(tmpDir, '## v2.0: The Big Launch\n### Phase 1: Setup\n');
|
||
const info = getMilestoneInfo(tmpDir);
|
||
assert.strictEqual(info.version, 'v2.0');
|
||
assert.match(info.name, /Big Launch/);
|
||
});
|
||
|
||
test('falls back to 🚧 WIP marker when STATE.md has no milestone', () => {
|
||
writeRoadmap(tmpDir, '## 🚧 **v1.5 Work In Progress**\n### Phase 1: Do stuff\n');
|
||
const info = getMilestoneInfo(tmpDir);
|
||
assert.strictEqual(info.version, 'v1.5');
|
||
assert.match(info.name, /Work In Progress/i);
|
||
});
|
||
|
||
test('extracts from heading when no STATE.md and no WIP marker', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v3.0: Future Milestone',
|
||
'### Phase 1: Not started',
|
||
].join('\n'));
|
||
const info = getMilestoneInfo(tmpDir);
|
||
assert.strictEqual(info.version, 'v3.0');
|
||
assert.match(info.name, /Future Milestone/);
|
||
});
|
||
|
||
test('skips completed ✅ milestones', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## ✅ v1.0: Shipped Already',
|
||
'## v2.0: Next Up',
|
||
].join('\n'));
|
||
const info = getMilestoneInfo(tmpDir);
|
||
// Should not use the ✅-prefixed version as the current milestone
|
||
assert.strictEqual(info.version, 'v2.0');
|
||
});
|
||
});
|
||
|
||
// ─── getMilestonePhaseFilter ──────────────────────────────────────────────────
|
||
|
||
describe('roadmap-parser: getMilestonePhaseFilter', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => { tmpDir = createTempProject(); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('returns passAll (phaseCount=0) when ROADMAP.md missing', () => {
|
||
const filter = getMilestonePhaseFilter(tmpDir);
|
||
assert.strictEqual(filter.phaseCount, 0);
|
||
assert.strictEqual(filter('anything'), true);
|
||
});
|
||
|
||
test('basic milestone phase filter — matches dirs by phase number', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Launch',
|
||
'### Phase 1: Setup',
|
||
'**Goal:** setup',
|
||
'',
|
||
'### Phase 2: Build',
|
||
'**Goal:** build',
|
||
].join('\n'));
|
||
|
||
const filter = getMilestonePhaseFilter(tmpDir);
|
||
assert.strictEqual(filter.phaseCount, 2);
|
||
assert.strictEqual(filter('01-setup'), true, '01-setup matches Phase 1');
|
||
assert.strictEqual(filter('02-build'), true, '02-build matches Phase 2');
|
||
assert.strictEqual(filter('03-deploy'), false, '03-deploy not in milestone');
|
||
});
|
||
|
||
test('milestone-prefixed phase IDs (e.g. 2-01)', () => {
|
||
writeState(tmpDir, { milestone: 'v2.0' });
|
||
writeRoadmap(tmpDir, [
|
||
'## v2.0: Current',
|
||
'### Phase 2-01: Alpha',
|
||
'### Phase 2-02: Beta',
|
||
].join('\n'));
|
||
|
||
const filter = getMilestonePhaseFilter(tmpDir);
|
||
assert.strictEqual(filter('02-01-alpha'), true, '02-01 matches Phase 2-01');
|
||
assert.strictEqual(filter('02-02-beta'), true, '02-02 matches Phase 2-02');
|
||
assert.strictEqual(filter('02-03-other'), false, '02-03 not in milestone');
|
||
});
|
||
|
||
test('versionOverride uses specified version slice', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Old',
|
||
'### Phase 1: Old Phase',
|
||
'',
|
||
'## v2.0: Current',
|
||
'### Phase 2: New Phase',
|
||
].join('\n'));
|
||
|
||
const filter = getMilestonePhaseFilter(tmpDir, 'v2.0');
|
||
assert.strictEqual(filter('02-new-phase'), true, 'phase 2 in v2.0 slice');
|
||
assert.strictEqual(filter('01-old-phase'), false, 'phase 1 not in v2.0 slice');
|
||
});
|
||
|
||
test('missingExplicitVersion set when version not found in versioned roadmap', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Only Milestone',
|
||
'### Phase 1: Foo',
|
||
].join('\n'));
|
||
|
||
const filter = getMilestonePhaseFilter(tmpDir, 'v9.9');
|
||
assert.strictEqual(filter.missingExplicitVersion, true, 'missingExplicitVersion should be true');
|
||
assert.strictEqual(filter.phaseCount, 0);
|
||
});
|
||
|
||
test('zero-padded phase IDs match unpadded dirs and vice versa', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Padded Test',
|
||
'### Phase 01: Setup',
|
||
'### Phase 02: Build',
|
||
].join('\n'));
|
||
|
||
const filter = getMilestonePhaseFilter(tmpDir);
|
||
assert.strictEqual(filter('1-setup'), true, 'unpadded dir matches padded Phase 01');
|
||
assert.strictEqual(filter('02-build'), true, 'padded dir matches padded Phase 02');
|
||
});
|
||
|
||
test('decimal phase IDs in ROADMAP filter correctly', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Decimal Test',
|
||
'### Phase 1.5: Interstitial',
|
||
'### Phase 2: Normal',
|
||
].join('\n'));
|
||
|
||
const filter = getMilestonePhaseFilter(tmpDir);
|
||
assert.ok(filter.phaseCount >= 1, 'at least one phase found');
|
||
// Decimal phase IDs are non-numeric so filter should handle them
|
||
assert.strictEqual(filter('1.5-interstitial'), true, 'decimal phase dir matches');
|
||
});
|
||
|
||
test('repeated phase IDs — deduplication (no double count)', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Repeated',
|
||
'### Phase 1: First',
|
||
'### Phase 1: Duplicate heading',
|
||
].join('\n'));
|
||
|
||
const filter = getMilestonePhaseFilter(tmpDir);
|
||
// Phase 1 appears twice but should only count once
|
||
assert.strictEqual(filter.phaseCount, 1, 'deduplication: only 1 unique phase');
|
||
});
|
||
|
||
test('adversarial: phase heading inside backtick fence is excluded (fix #875)', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Real',
|
||
'```',
|
||
'### Phase 999: Fake Phase Inside Fence',
|
||
'```',
|
||
'### Phase 1: Real Phase',
|
||
'**Goal:** real',
|
||
].join('\n'));
|
||
|
||
const filter = getMilestonePhaseFilter(tmpDir);
|
||
// Phase headings inside fenced code blocks must NOT be counted as real phases.
|
||
// getMilestonePhaseFilter is fence-aware (fix #875).
|
||
assert.strictEqual(filter('01-real'), true, 'real phase matches');
|
||
assert.strictEqual(filter('999-fake'), false, 'fenced phase heading is correctly excluded');
|
||
});
|
||
|
||
test('adversarial: unclosed fence block — does not crash', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Unclosed',
|
||
'```',
|
||
'### Phase 1: Inside unclosed fence',
|
||
'**Goal:** unreachable',
|
||
// Intentionally no closing ``` — adversarial fixture
|
||
].join('\n'));
|
||
|
||
// Should not throw regardless of fence parsing behavior
|
||
let filter;
|
||
assert.doesNotThrow(() => {
|
||
filter = getMilestonePhaseFilter(tmpDir);
|
||
}, 'unclosed fence should not throw');
|
||
assert.ok(typeof filter === 'function', 'filter is a function');
|
||
});
|
||
|
||
test('adversarial: phase heading inside tilde fence is excluded (fix #875)', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Tilde',
|
||
'~~~',
|
||
'### Phase 999: Fake',
|
||
'~~~',
|
||
'### Phase 1: Real',
|
||
].join('\n'));
|
||
|
||
const filter = getMilestonePhaseFilter(tmpDir);
|
||
// Phase headings inside tilde-fenced code blocks must NOT be counted as real phases.
|
||
// getMilestonePhaseFilter is fence-aware (fix #875).
|
||
assert.strictEqual(filter('01-real'), true, 'real phase matches despite tilde fence');
|
||
assert.strictEqual(filter('999-fake'), false, 'tilde-fenced phase heading is correctly excluded');
|
||
});
|
||
|
||
test('adversarial: phase heading inside fence is excluded with CRLF endings (fix #875)', () => {
|
||
const crlf = '## v1.0: CRLF Fence\r\n```\r\n### Phase 999: Fake\r\n```\r\n### Phase 1: Real\r\n';
|
||
writeRoadmap(tmpDir, crlf);
|
||
const filter = getMilestonePhaseFilter(tmpDir);
|
||
assert.strictEqual(filter('01-real'), true, 'real phase matches in CRLF file');
|
||
assert.strictEqual(filter('999-fake'), false, 'fenced phase excluded in CRLF file');
|
||
});
|
||
|
||
test('adversarial: phase headings in back-to-back fences are excluded (fix #875)', () => {
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: Adjacent',
|
||
'```',
|
||
'### Phase 998: Fake A',
|
||
'```',
|
||
'```',
|
||
'### Phase 999: Fake B',
|
||
'```',
|
||
'### Phase 1: Real',
|
||
].join('\n'));
|
||
const filter = getMilestonePhaseFilter(tmpDir);
|
||
assert.strictEqual(filter('01-real'), true, 'real phase matches');
|
||
assert.strictEqual(filter('998-fake'), false, 'first fenced phase excluded');
|
||
assert.strictEqual(filter('999-fake'), false, 'second fenced phase excluded');
|
||
});
|
||
|
||
test('adversarial: CRLF line endings in roadmap', () => {
|
||
const crlf = '## v1.0: CRLF\r\n### Phase 1: Setup\r\n### Phase 2: Build\r\n';
|
||
writeRoadmap(tmpDir, crlf);
|
||
let filter;
|
||
assert.doesNotThrow(() => { filter = getMilestonePhaseFilter(tmpDir); });
|
||
assert.ok(filter.phaseCount >= 1, 'phases found despite CRLF');
|
||
});
|
||
|
||
test('adversarial: mixed CRLF and LF in same file', () => {
|
||
const mixed = '## v1.0: Mixed\r\n### Phase 1: A\n### Phase 2: B\r\n### Phase 3: C\n';
|
||
writeRoadmap(tmpDir, mixed);
|
||
let filter;
|
||
assert.doesNotThrow(() => { filter = getMilestonePhaseFilter(tmpDir); });
|
||
assert.ok(filter.phaseCount >= 1, 'phases found in mixed CRLF/LF');
|
||
});
|
||
|
||
test('adversarial: unicode headings', () => {
|
||
writeState(tmpDir, { milestone: 'v1.0' });
|
||
writeRoadmap(tmpDir, [
|
||
'## v1.0: 日本語マイルストーン',
|
||
'### Phase 1: Héros Réalité',
|
||
'### Phase 2: Тест',
|
||
].join('\n'));
|
||
|
||
let filter;
|
||
assert.doesNotThrow(() => { filter = getMilestonePhaseFilter(tmpDir); });
|
||
assert.strictEqual(filter.phaseCount, 2, '2 unicode phases found');
|
||
assert.strictEqual(filter('01-setup'), true, 'phase 1 dir matches');
|
||
});
|
||
|
||
test('adversarial: bracket-prefixed phase heading ### [GSD] Phase 2-01:', () => {
|
||
writeState(tmpDir, { milestone: 'v2.0' });
|
||
writeRoadmap(tmpDir, [
|
||
'## v2.0: Bracket',
|
||
'### [GSD] Phase 2-01: Setup',
|
||
'### [GSD] Phase 2-02: Build',
|
||
].join('\n'));
|
||
|
||
const filter = getMilestonePhaseFilter(tmpDir);
|
||
assert.strictEqual(filter('02-01-setup'), true, 'bracket-prefixed phase 2-01 matched');
|
||
assert.strictEqual(filter('02-02-build'), true, 'bracket-prefixed phase 2-02 matched');
|
||
});
|
||
});
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-2554-decimal-phase-filter.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-2554-decimal-phase-filter (consolidation epic #1969 B3 #1972)", () => {
|
||
/**
|
||
* Regression test for bug #2554:
|
||
* state disk-scan excludes decimal phase dirs (e.g. "00.1") from progress counts.
|
||
*
|
||
* Root cause: getMilestonePhaseFilter normalized phase IDs with `replace(/^0+/, '')`,
|
||
* which over-strips on decimals: "00.1" → ".1", while the disk-side extractor
|
||
* applied to "00.1-<slug>" yields "0.1" — so the dir is excluded from the milestone.
|
||
*
|
||
* Fix: strip leading zeros only when followed by a digit (`replace(/^0+(?=\d)/, '')`),
|
||
* preserving the zero before the decimal point.
|
||
*/
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||
const { getMilestonePhaseFilter } = require('../gsd-core/bin/lib/roadmap-parser.cjs');
|
||
|
||
describe('bug #2554 — getMilestonePhaseFilter decimal phase dirs', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('matches decimal phase directory like "00.1-<slug>" against ROADMAP phase "00.1"', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
[
|
||
'## Roadmap v1.0: Current',
|
||
'',
|
||
'### Phase 0: Foundation',
|
||
'**Goal:** foundation',
|
||
'',
|
||
'### Phase 00.1: Inserted urgent work',
|
||
'**Goal:** inserted',
|
||
'',
|
||
'### Phase 1: Feature',
|
||
'**Goal:** feature',
|
||
].join('\n')
|
||
);
|
||
|
||
const filter = getMilestonePhaseFilter(tmpDir);
|
||
|
||
// Phase 00.1 inserted between Phase 0 and Phase 1 must match its on-disk dir.
|
||
assert.strictEqual(
|
||
filter('00.1-app-namespace-rename'),
|
||
true,
|
||
'decimal phase dir "00.1-<slug>" must be counted in the milestone'
|
||
);
|
||
|
||
// Neighbours should still match (no regression).
|
||
assert.strictEqual(filter('0-foundation'), true);
|
||
assert.strictEqual(filter('1-feature'), true);
|
||
});
|
||
|
||
test('preserves existing behavior for zero-padded integer phases', () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||
[
|
||
'## Roadmap v1.0: Current',
|
||
'',
|
||
'### Phase 01: One',
|
||
'**Goal:** g',
|
||
'',
|
||
'### Phase 10: Ten',
|
||
'**Goal:** g',
|
||
].join('\n')
|
||
);
|
||
|
||
const filter = getMilestonePhaseFilter(tmpDir);
|
||
assert.strictEqual(filter('01-one'), true);
|
||
assert.strictEqual(filter('10-ten'), true);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-730-milestone-phase-details-scope.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-730-milestone-phase-details-scope (consolidation epic #1969 B3 #1972)", () => {
|
||
/**
|
||
* Regression test for bug #730: phase details defined under a milestone-scoped
|
||
* "## Milestone vX.Y — … (Phase Details)" section are invisible to phase
|
||
* resolution (getRoadmapPhaseInternal / init phase-op) when the flat shared
|
||
* "## Phase Details" section for an earlier milestone sits between the shared
|
||
* ## Phases checklist and the per-milestone Phase Details section.
|
||
*
|
||
* The bug manifests ONLY before any .planning/phases/ directory exists because
|
||
* findPhaseInternal masks it once the dir is created. RED step — tests 1 and 3
|
||
* are expected to fail against current code.
|
||
*/
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared fixture content
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const STATE_CONTENT = `---
|
||
milestone: v1.1
|
||
---
|
||
`;
|
||
|
||
const ROADMAP_CONTENT = `# Roadmap: Example
|
||
|
||
## Phases
|
||
|
||
- [x] **Phase 1: Setup** — initial scaffold
|
||
|
||
### Milestone v1.1 — Second milestone (added 2026-01-01)
|
||
|
||
- [ ] **Phase 2: Feature** — the new thing
|
||
|
||
## Phase Details
|
||
|
||
### Phase 1: Setup
|
||
**Goal:** scaffold the app.
|
||
|
||
## Milestone v1.1 — Second milestone (Phase Details)
|
||
|
||
### Phase 2: Feature
|
||
**Goal:** build the new thing.
|
||
`;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helper: create a bare project with .planning/ but NO .planning/phases/ dir
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function createBareProject() {
|
||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-'));
|
||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||
return tmpDir;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Suite
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('bug #730 — milestone (Phase Details) section scope resolution', () => {
|
||
let dir;
|
||
|
||
beforeEach(() => {
|
||
dir = createBareProject();
|
||
fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), STATE_CONTENT, 'utf-8');
|
||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT, 'utf-8');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(dir);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Test 1 (AC1): init phase-op resolves phase defined only under its
|
||
// per-milestone "(Phase Details)" section
|
||
// -------------------------------------------------------------------------
|
||
test('init phase-op resolves a current-milestone phase defined only under its (Phase Details) section', () => {
|
||
const r = runGsdTools('init phase-op 2', dir);
|
||
assert.ok(r.success, `init phase-op 2 failed: ${r.error}`);
|
||
|
||
const out = JSON.parse(r.output);
|
||
assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, expected_phase_dir=${out.expected_phase_dir}`);
|
||
assert.strictEqual(out.phase_name, 'Feature', `phase_name should be 'Feature'; got '${out.phase_name}'`);
|
||
assert.strictEqual(out.padded_phase, '02', `padded_phase should be '02'; got '${out.padded_phase}'`);
|
||
assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-feature', `expected_phase_dir should be '.planning/phases/02-feature'; got '${out.expected_phase_dir}'`);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Test 2 (AC4): first-milestone phase still resolves via the flat
|
||
// "## Phase Details" section — no regression
|
||
// -------------------------------------------------------------------------
|
||
test('init phase-op still resolves a first-milestone phase (no regression on flat Phase Details)', () => {
|
||
const r = runGsdTools('init phase-op 1', dir);
|
||
assert.ok(r.success, `init phase-op 1 failed: ${r.error}`);
|
||
|
||
const out = JSON.parse(r.output);
|
||
assert.strictEqual(out.phase_found, true, `phase_found should be true for phase 1; got ${out.phase_found}`);
|
||
assert.strictEqual(out.phase_name, 'Setup', `phase_name should be 'Setup'; got '${out.phase_name}'`);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Test 3 (AC5): getRoadmapPhaseInternal resolves the current-milestone phase
|
||
// directly before any phases/ dir exists
|
||
// -------------------------------------------------------------------------
|
||
test('getRoadmapPhaseInternal resolves the current-milestone phase directly before any dir exists', () => {
|
||
const { getRoadmapPhaseInternal } = require('../gsd-core/bin/lib/roadmap-parser.cjs');
|
||
|
||
const res = getRoadmapPhaseInternal(dir, '2');
|
||
assert.ok(res !== null && res !== undefined, `getRoadmapPhaseInternal returned null/undefined for phase 2`);
|
||
assert.strictEqual(res.found, true, `res.found should be true; got ${JSON.stringify(res)}`);
|
||
assert.strictEqual(res.phase_name, 'Feature', `res.phase_name should be 'Feature'; got '${res.phase_name}'`);
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Test 4 (AC3): validate health raises W006 for a current-milestone phase
|
||
// defined under (Phase Details) with no directory on disk.
|
||
//
|
||
// Before the fix, extractCurrentMilestone's slice stopped before the
|
||
// "## Milestone v1.1 — … (Phase Details)" section, so phase 2's
|
||
// "### Phase 2: Feature" header was invisible and W006 was never raised.
|
||
// After the fix the slice includes that section and W006 is emitted.
|
||
//
|
||
// This test uses its OWN local fixture (separate tmpdir) so it does not
|
||
// disturb the shared beforeEach/afterEach fixture used by tests 1–3.
|
||
// -------------------------------------------------------------------------
|
||
test('validate health raises W006 for a started current-milestone phase defined under (Phase Details) with no directory', () => {
|
||
const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t4-'));
|
||
try {
|
||
const planning = path.join(localDir, '.planning');
|
||
fs.mkdirSync(planning, { recursive: true });
|
||
|
||
// STATE.md — milestone: v1.1
|
||
fs.writeFileSync(
|
||
path.join(planning, 'STATE.md'),
|
||
`---\nmilestone: v1.1\n---\n`,
|
||
'utf-8',
|
||
);
|
||
|
||
// ROADMAP.md — phase 2 is [x] (started/complete) so the not-started
|
||
// guard does NOT suppress W006. Phase 2's details live exclusively in
|
||
// the per-milestone "(Phase Details)" section (the blind-spot pre-fix).
|
||
fs.writeFileSync(
|
||
path.join(planning, 'ROADMAP.md'),
|
||
`# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — initial scaffold\n\n### Milestone v1.1 — Second milestone (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — the new thing\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold the app.\n\n## Milestone v1.1 — Second milestone (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build the new thing.\n`,
|
||
'utf-8',
|
||
);
|
||
|
||
// Create the phase 1 directory so phase 1 does NOT trigger W006.
|
||
// Phase 2 has NO directory — that's the missing-dir condition under test.
|
||
fs.mkdirSync(path.join(planning, 'phases', '01-setup'), { recursive: true });
|
||
|
||
const result = runGsdTools(['validate', 'health'], localDir);
|
||
const payload = JSON.parse(result.output);
|
||
const warnings = payload.warnings || [];
|
||
|
||
// Find a W006 entry whose message references phase 2 (by number or name).
|
||
const w006ForPhase2 = warnings.find(
|
||
(w) =>
|
||
w.code === 'W006' &&
|
||
(/\b2\b/.test(w.message) || /\b02\b/.test(w.message) || /Feature/i.test(w.message)),
|
||
);
|
||
|
||
assert.ok(
|
||
w006ForPhase2 != null,
|
||
`Expected a W006 warning referencing phase 2 (Feature) — phase 2 is started ([x]) and has no directory on disk, ` +
|
||
`but its ### Phase 2: header lives in the Milestone v1.1 (Phase Details) section which was invisible before the fix. ` +
|
||
`Got warnings: ${JSON.stringify(warnings)}`,
|
||
);
|
||
} finally {
|
||
cleanup(localDir);
|
||
}
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Test 5: three-milestone roadmap, current = latest (v1.2)
|
||
// -------------------------------------------------------------------------
|
||
test('init phase-op resolves the latest milestone phase in a 3-milestone roadmap', () => {
|
||
const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t5-'));
|
||
try {
|
||
const planning = path.join(localDir, '.planning');
|
||
fs.mkdirSync(planning, { recursive: true });
|
||
|
||
fs.writeFileSync(
|
||
path.join(planning, 'STATE.md'),
|
||
`---\nmilestone: v1.2\n---\n`,
|
||
'utf-8',
|
||
);
|
||
|
||
fs.writeFileSync(
|
||
path.join(planning, 'ROADMAP.md'),
|
||
`# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — done\n\n### Milestone v1.1 — Second (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — done\n\n### Milestone v1.2 — Third (added 2026-02-01)\n\n- [ ] **Phase 3: Polish** — current\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold.\n\n## Milestone v1.1 — Second (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build.\n\n## Milestone v1.2 — Third (Phase Details)\n\n### Phase 3: Polish\n**Goal:** refine.\n`,
|
||
'utf-8',
|
||
);
|
||
|
||
const r = runGsdTools('init phase-op 3', localDir);
|
||
assert.ok(r.success, `init phase-op 3 failed: ${r.error}`);
|
||
|
||
const out = JSON.parse(r.output);
|
||
assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}`);
|
||
assert.strictEqual(out.phase_name, 'Polish', `phase_name should be 'Polish'; got '${out.phase_name}'`);
|
||
assert.strictEqual(out.padded_phase, '03', `padded_phase should be '03'; got '${out.padded_phase}'`);
|
||
assert.strictEqual(out.expected_phase_dir, '.planning/phases/03-polish', `expected_phase_dir should be '.planning/phases/03-polish'; got '${out.expected_phase_dir}'`);
|
||
} finally {
|
||
cleanup(localDir);
|
||
}
|
||
});
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Test 6: sub-milestone sharing a version prefix — closed sibling must NOT
|
||
// cross-pollinate into the active milestone's Phase Details lookup (#730)
|
||
// -------------------------------------------------------------------------
|
||
test('init phase-op anchors Phase Details to the selected sub-milestone, not a closed same-prefix sibling', () => {
|
||
const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t6-'));
|
||
try {
|
||
const planning = path.join(localDir, '.planning');
|
||
fs.mkdirSync(planning, { recursive: true });
|
||
|
||
// STATE.md — milestone: v3.0 (matches v3.0-B active slice)
|
||
fs.writeFileSync(
|
||
path.join(planning, 'STATE.md'),
|
||
`---\nmilestone: v3.0\n---\n`,
|
||
'utf-8',
|
||
);
|
||
|
||
// ROADMAP.md — v3.0-A is SHIPPED (closed), v3.0-B is active.
|
||
// The Phase Details for v3.0-A comes FIRST — without version-boundary
|
||
// anchoring the old code would grab it (first non-closed (Phase Details)
|
||
// heading outside the window), returning phase_name='Alpha' instead of 'Beta'.
|
||
fs.writeFileSync(
|
||
path.join(planning, 'ROADMAP.md'),
|
||
[
|
||
'# Roadmap: Example',
|
||
'',
|
||
'## Phases',
|
||
'',
|
||
'### Milestone v3.0-A — First slice (added 2026-01-01) ✅ SHIPPED',
|
||
'',
|
||
'- [x] **Phase 1: Alpha** — done',
|
||
'',
|
||
'### Milestone v3.0-B — Second slice (added 2026-02-01)',
|
||
'',
|
||
'- [ ] **Phase 2: Beta** — current',
|
||
'',
|
||
'## Phase Details',
|
||
'',
|
||
'## Milestone v3.0-A — First slice (Phase Details)',
|
||
'',
|
||
'### Phase 1: Alpha',
|
||
'**Goal:** alpha goal.',
|
||
'',
|
||
'## Milestone v3.0-B — Second slice (Phase Details)',
|
||
'',
|
||
'### Phase 2: Beta',
|
||
'**Goal:** beta goal.',
|
||
'',
|
||
].join('\n'),
|
||
'utf-8',
|
||
);
|
||
|
||
const r = runGsdTools('init phase-op 2', localDir);
|
||
assert.ok(r.success, `init phase-op 2 failed: ${r.error}`);
|
||
|
||
const out = JSON.parse(r.output);
|
||
assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, output=${JSON.stringify(out)}`);
|
||
assert.strictEqual(out.phase_name, 'Beta', `phase_name should be 'Beta' (v3.0-B section), not '${out.phase_name}' (would indicate v3.0-A cross-pollination)`);
|
||
assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-beta', `expected_phase_dir should be '.planning/phases/02-beta'; got '${out.expected_phase_dir}'`);
|
||
} finally {
|
||
cleanup(localDir);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3128-roadmap-plan-count-slug-layout (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
// allow-test-rule: reads roadmap.cjs source to verify isPlanFile pattern was adopted — structural contract prevents silent regression to old filter (see #3128)
|
||
|
||
// Regression guard for bug #3128.
|
||
//
|
||
// roadmap.cjs countPhasePlansAndSummaries() used to filter plan files with:
|
||
// f.endsWith('-PLAN.md') || f === 'PLAN.md'
|
||
// This misses the {N}-PLAN-{NN}-{slug}.md layout that gsd-plan-phase
|
||
// actually writes (e.g. 5-PLAN-01-setup-database.md), ending in -database.md.
|
||
// Result: init manager returned plan_count=0 and disk_status='discussed' for
|
||
// fully-planned phases, triggering unnecessary background planner agents.
|
||
//
|
||
// Root cause: same regex flaw as #2893 (fixed in phase.cjs via #2896), but
|
||
// the manager-dashboard path in roadmap.cjs was not updated alongside it.
|
||
//
|
||
// Fix: apply the same looksLikePlanFile logic from phase.cjs to roadmap.cjs.
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const ROOT = path.join(__dirname, '..');
|
||
// Require the module under test directly
|
||
const roadmapLib = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'roadmap.cjs');
|
||
const planScanLib = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'plan-scan.cjs');
|
||
|
||
// We test countPhasePlansAndSummaries indirectly via getManagerInfo since
|
||
// it is not exported. We build a real phaseDir on disk and call the full
|
||
// roadmap.cjs init manager path via its exported helper, or fall back to
|
||
// direct filesystem inspection of what the filter would produce.
|
||
// The simplest correct seam: inspect the source for the regex pattern and
|
||
// validate with a synthetic directory that the manager path returns correct counts.
|
||
|
||
|
||
// Import countPhasePlansAndSummaries by monkey-patching: we inline the
|
||
// fixed filter logic and verify it matches the file on disk.
|
||
// Since the function is module-private, we validate via its public caller
|
||
// by using the exported analyzeRoadmap / getPhaseInfo path with a
|
||
// synthetic .planning/ directory tree.
|
||
|
||
describe('bug #3128: roadmap.cjs plan-count for {N}-PLAN-{NN}-{slug}.md layout', () => {
|
||
|
||
test('isPlanFile rejects PLAN-OUTLINE and pre-bounce derivatives', () => {
|
||
// Inlined from fix — mirrors the exact logic in the fix
|
||
const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
|
||
const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
|
||
const isPlanFile = (f) =>
|
||
(f.endsWith('-PLAN.md') || f === 'PLAN.md') ||
|
||
(/\.md$/i.test(f) && /PLAN/i.test(f) && !PLAN_OUTLINE_RE.test(f) && !PLAN_PRE_BOUNCE_RE.test(f));
|
||
|
||
// canonical forms — must match
|
||
assert.ok(isPlanFile('PLAN.md'), 'PLAN.md must match');
|
||
assert.ok(isPlanFile('5-PLAN.md'), '5-PLAN.md must match');
|
||
assert.ok(isPlanFile('05-PLAN.md'), '05-PLAN.md must match');
|
||
|
||
// slug form — was the bug; must now match
|
||
assert.ok(isPlanFile('5-PLAN-01-setup.md'), '5-PLAN-01-setup.md must match');
|
||
assert.ok(isPlanFile('05-PLAN-02-database.md'), '05-PLAN-02-database.md must match');
|
||
assert.ok(isPlanFile('5-PLAN-DELTA-2026-05-05.md'), '5-PLAN-DELTA-2026-05-05.md must match');
|
||
|
||
// derivative files — must NOT match
|
||
assert.ok(!isPlanFile('5-PLAN-OUTLINE.md'), 'PLAN-OUTLINE must not match');
|
||
assert.ok(!isPlanFile('5-PLAN-01.pre-bounce.md'), 'pre-bounce must not match');
|
||
assert.ok(!isPlanFile('CONTEXT.md'), 'CONTEXT.md must not match');
|
||
assert.ok(!isPlanFile('SUMMARY.md'), 'SUMMARY.md must not match');
|
||
assert.ok(!isPlanFile('5-RESEARCH.md'), 'RESEARCH.md must not match');
|
||
});
|
||
|
||
test('roadmap.cjs source uses the extended isPlanFile filter', () => {
|
||
const roadmapSrc = fs.readFileSync(roadmapLib, 'utf8');
|
||
// Verify the fix is in place: the old simple inline filter is gone from roadmap.cjs
|
||
assert.ok(
|
||
!roadmapSrc.includes("phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md')"),
|
||
'Old simple plan filter still present in roadmap.cjs — fix not applied',
|
||
);
|
||
// roadmap.cjs now delegates to plan-scan.cjs via require('./plan-scan.cjs')
|
||
assert.ok(
|
||
roadmapSrc.includes('plan-scan.cjs'),
|
||
'roadmap.cjs does not require plan-scan.cjs — delegation not applied',
|
||
);
|
||
// plan-scan.cjs is where the extended plan-file detection logic lives (isRootPlanFile)
|
||
const planScanSrc = fs.readFileSync(planScanLib, 'utf8');
|
||
assert.ok(
|
||
planScanSrc.includes('isRootPlanFile') && planScanSrc.includes('/PLAN/i'),
|
||
'isRootPlanFile with /PLAN/i not found in plan-scan.cjs — canonical helper missing extended filter',
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-500-planned-phase-progress-corruption.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-500-planned-phase-progress-corruption (consolidation epic #1969 B3 #1972)", () => {
|
||
/**
|
||
* Bug #500: `state planned-phase` corrupts STATE.md milestone progress.* counters.
|
||
*
|
||
* Two independent defects:
|
||
*
|
||
* RC1 — plan-phase resyncs progress it should not touch.
|
||
* cmdStatePlannedPhase wrote via writeStateMd, which unconditionally runs
|
||
* syncStateFrontmatter and rebuilds progress.* from a half-planned disk
|
||
* snapshot, trampling curated counters. It must route through
|
||
* readModifyWriteStateMd(..., { resync: false }) like other body-only writes.
|
||
*
|
||
* RC2 — isRootPlanFile double-counts legacy `<N>-PLAN-<NN>-SUMMARY.md` as a plan.
|
||
* The final `/PLAN/i` fallback matches the substring "PLAN" inside a legacy
|
||
* summary name, so a 4-plan/4-summary phase scans as planCount:8 / completed:false
|
||
* instead of planCount:4 / completed:true. A summary is never a plan.
|
||
*/
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
const planScan = require('../gsd-core/bin/lib/plan-scan.cjs');
|
||
const { isRootPlanFile, scanPhasePlans } = planScan;
|
||
|
||
describe('isRootPlanFile does not count legacy summaries as plans (#500 RC2)', () => {
|
||
test('legacy <N>-PLAN-<NN>-SUMMARY.md is not a root plan file', () => {
|
||
assert.equal(isRootPlanFile('14-PLAN-01-SUMMARY.md'), false);
|
||
});
|
||
|
||
test('legacy <N>-PLAN-<NN>.md is still a root plan file', () => {
|
||
assert.equal(isRootPlanFile('14-PLAN-01.md'), true);
|
||
});
|
||
|
||
test('canonical -PLAN.md is still a root plan file', () => {
|
||
assert.equal(isRootPlanFile('01-PLAN.md'), true);
|
||
});
|
||
|
||
test('a 4-plan / 4-summary legacy phase scans as planCount:4 completed:true', () => {
|
||
const tmp = createTempProject();
|
||
const phaseDir = path.join(tmp, '.planning', 'phases', '14-legacy');
|
||
fs.mkdirSync(phaseDir, { recursive: true });
|
||
for (let i = 1; i <= 4; i++) {
|
||
const nn = String(i).padStart(2, '0');
|
||
fs.writeFileSync(path.join(phaseDir, `14-PLAN-${nn}.md`), '# Plan\n', 'utf-8');
|
||
fs.writeFileSync(path.join(phaseDir, `14-PLAN-${nn}-SUMMARY.md`), '# Summary\n', 'utf-8');
|
||
}
|
||
try {
|
||
const scan = scanPhasePlans(phaseDir);
|
||
assert.equal(scan.planCount, 4, `expected 4 plans, got ${scan.planCount}`);
|
||
assert.equal(scan.summaryCount, 4, `expected 4 summaries, got ${scan.summaryCount}`);
|
||
assert.equal(scan.completed, true, 'a fully-summarized phase must scan as completed');
|
||
} finally {
|
||
cleanup(tmp);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('state planned-phase preserves curated milestone progress.* (#500 RC1)', () => {
|
||
let tmpDir;
|
||
|
||
// Curated progress counters that deliberately do NOT match what a disk scan
|
||
// would derive (disk has only one near-empty phase dir). The bug rebuilds
|
||
// progress.* from that disk snapshot, trampling these values.
|
||
const STATE = `---
|
||
gsd_state_version: 1.0
|
||
milestone: v3.0
|
||
milestone_name: Active
|
||
status: in_progress
|
||
progress:
|
||
total_phases: 7
|
||
completed_phases: 5
|
||
total_plans: 99
|
||
completed_plans: 88
|
||
percent: 88
|
||
---
|
||
|
||
# Project State
|
||
|
||
## Configuration
|
||
Current Phase: 2
|
||
Current Phase Name: builder
|
||
Total Plans in Phase: 0
|
||
Status: Not started
|
||
Last Activity: TBD
|
||
Last Activity Description: pending
|
||
|
||
## Current Position
|
||
|
||
Phase: 2 (builder)
|
||
Status: Not started
|
||
Last activity: TBD
|
||
`;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject();
|
||
const planning = path.join(tmpDir, '.planning');
|
||
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE, 'utf-8');
|
||
fs.writeFileSync(
|
||
path.join(planning, 'ROADMAP.md'),
|
||
'# Roadmap\n\n## 🚧 v3.0: Active\n\n### Phase 2: builder\n',
|
||
'utf-8'
|
||
);
|
||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
||
// One sparse phase dir so a disk resync would derive small/zero counters.
|
||
const dir = path.join(planning, 'phases', '02-builder');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function readProgress() {
|
||
const md = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
const block = md.split('---')[1] || '';
|
||
const num = (key) => {
|
||
const m = block.match(new RegExp(`${key}:\\s*(\\d+)`));
|
||
return m ? Number(m[1]) : null;
|
||
};
|
||
return {
|
||
total_plans: num('total_plans'),
|
||
completed_plans: num('completed_plans'),
|
||
total_phases: num('total_phases'),
|
||
completed_phases: num('completed_phases'),
|
||
};
|
||
}
|
||
|
||
test('planned-phase updates per-phase body fields but leaves milestone progress.* untouched', () => {
|
||
const result = runGsdTools(['state', 'planned-phase', '--phase', '2', '--plans', '3'], tmpDir);
|
||
assert.equal(result.success, true, result.error || result.output);
|
||
|
||
// The command did its real job: per-phase "Total Plans in Phase" was set.
|
||
const md = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.match(md, /Total Plans in Phase:\s*3/, 'per-phase Total Plans in Phase should be updated to 3');
|
||
|
||
// ...but the curated milestone-wide progress block is preserved verbatim.
|
||
assert.deepEqual(readProgress(), {
|
||
total_plans: 99,
|
||
completed_plans: 88,
|
||
total_phases: 7,
|
||
completed_phases: 5,
|
||
}, 'curated milestone progress.* must survive a planned-phase write');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/feat-3262-scan-phase-plans.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:feat-3262-scan-phase-plans (consolidation epic #1969 B3 #1972)", () => {
|
||
/**
|
||
* Tests for the shared scanPhasePlans() helper (k014).
|
||
*
|
||
* Covers:
|
||
* - Top-level plans only (flat layout)
|
||
* - Top-level + nested layout (post-#3139)
|
||
* - Completed-summary detection (summaries >= plans)
|
||
* - Ignored files (OUTLINE, pre-bounce, CONTEXT, RESEARCH)
|
||
* - Empty phase dir → { planCount: 0, summaryCount: 0 }
|
||
* - Parity: helper produces correct counts for mixed flat+nested fixture tree
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
// Helper under test — must exist at this path (GREEN phase wires it up)
|
||
const scanPhasePlans = require('../gsd-core/bin/lib/plan-scan.cjs');
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fixture helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let tmpDir;
|
||
|
||
function phaseDir(name = 'phase') {
|
||
const d = path.join(tmpDir, name);
|
||
fs.mkdirSync(d, { recursive: true });
|
||
return d;
|
||
}
|
||
|
||
function touch(dir, ...filenames) {
|
||
for (const f of filenames) {
|
||
fs.writeFileSync(path.join(dir, f), '');
|
||
}
|
||
}
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plan-scan-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Basic shapes
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('scanPhasePlans — flat layout', () => {
|
||
test('empty directory → zero counts', () => {
|
||
const dir = phaseDir();
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 0, 'planCount');
|
||
assert.strictEqual(result.summaryCount, 0, 'summaryCount');
|
||
});
|
||
|
||
test('bare PLAN.md counts as one plan', () => {
|
||
const dir = phaseDir();
|
||
touch(dir, 'PLAN.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1, 'planCount');
|
||
assert.strictEqual(result.summaryCount, 0, 'summaryCount');
|
||
});
|
||
|
||
test('canonical padded plan file (01-01-PLAN.md)', () => {
|
||
const dir = phaseDir();
|
||
touch(dir, '01-01-PLAN.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1, 'planCount');
|
||
});
|
||
|
||
test('canonical padded plan + matching summary → completed', () => {
|
||
const dir = phaseDir();
|
||
touch(dir, '01-01-PLAN.md', '01-01-SUMMARY.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1);
|
||
assert.strictEqual(result.summaryCount, 1);
|
||
assert.strictEqual(result.completed, true, 'phase should be complete when summaries >= plans');
|
||
});
|
||
|
||
test('plan without summary → not completed', () => {
|
||
const dir = phaseDir();
|
||
touch(dir, '01-01-PLAN.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.completed, false);
|
||
});
|
||
|
||
test('multiple plans all summarized → completed', () => {
|
||
const dir = phaseDir();
|
||
touch(dir, '01-01-PLAN.md', '01-02-PLAN.md', '01-01-SUMMARY.md', '01-02-SUMMARY.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 2);
|
||
assert.strictEqual(result.summaryCount, 2);
|
||
assert.strictEqual(result.completed, true);
|
||
});
|
||
|
||
test('bare SUMMARY.md counts as one summary', () => {
|
||
const dir = phaseDir();
|
||
touch(dir, 'PLAN.md', 'SUMMARY.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1);
|
||
assert.strictEqual(result.summaryCount, 1);
|
||
});
|
||
|
||
test('extended-layout root file (5-PLAN-01-setup.md style)', () => {
|
||
// roadmap.cjs isPlanFile explicitly matches any .md with PLAN in name at root
|
||
// (not just ending with -PLAN.md). The canonical helper must too.
|
||
// e.g. gsd-plan-phase writes "5-PLAN-01-setup.md".
|
||
const dir = phaseDir();
|
||
// The summary for this file follows the canonical *-SUMMARY.md suffix convention.
|
||
touch(dir, '3-PLAN-01-setup.md', '3-01-SUMMARY.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1, 'extended-layout root plan counted');
|
||
assert.strictEqual(result.summaryCount, 1, 'extended-layout root summary counted');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Ignored files
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('scanPhasePlans — ignored files', () => {
|
||
test('PLAN-OUTLINE file is ignored (flat)', () => {
|
||
const dir = phaseDir();
|
||
touch(dir, '01-01-PLAN.md', '01-01-PLAN-OUTLINE.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1, 'OUTLINE should not count as a plan');
|
||
});
|
||
|
||
test('pre-bounce file is ignored (flat)', () => {
|
||
const dir = phaseDir();
|
||
touch(dir, '01-01-PLAN.md', '01-01-PLAN.pre-bounce.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1, 'pre-bounce should not count as a plan');
|
||
});
|
||
|
||
test('CONTEXT.md is not counted as a plan', () => {
|
||
const dir = phaseDir();
|
||
touch(dir, 'PLAN.md', 'CONTEXT.md', '01-01-CONTEXT.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1, 'CONTEXT files should not be plans');
|
||
});
|
||
|
||
test('RESEARCH.md is not counted as a plan', () => {
|
||
const dir = phaseDir();
|
||
touch(dir, 'PLAN.md', 'RESEARCH.md', '01-01-RESEARCH.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1, 'RESEARCH files should not be plans');
|
||
});
|
||
|
||
test('VERIFICATION.md is not counted as a plan', () => {
|
||
const dir = phaseDir();
|
||
touch(dir, 'PLAN.md', 'VERIFICATION.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1, 'VERIFICATION files should not be plans');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Nested layout (post-#3139)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('scanPhasePlans — nested layout', () => {
|
||
test('nested PLAN-NN-slug.md files counted', () => {
|
||
const dir = phaseDir();
|
||
const plansDir = path.join(dir, 'plans');
|
||
fs.mkdirSync(plansDir);
|
||
touch(plansDir, 'PLAN-01-setup.md', 'PLAN-02-impl.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 2, 'nested plans counted');
|
||
assert.strictEqual(result.hasNestedPlans, true, 'hasNestedPlans flag set');
|
||
});
|
||
|
||
test('nested SUMMARY-NN-slug.md files counted', () => {
|
||
const dir = phaseDir();
|
||
const plansDir = path.join(dir, 'plans');
|
||
fs.mkdirSync(plansDir);
|
||
touch(plansDir, 'PLAN-01-setup.md', 'SUMMARY-01-setup.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1);
|
||
assert.strictEqual(result.summaryCount, 1);
|
||
assert.strictEqual(result.completed, true);
|
||
assert.deepStrictEqual(result.planFiles, ['plans/PLAN-01-setup.md']);
|
||
assert.deepStrictEqual(result.summaryFiles, ['plans/SUMMARY-01-setup.md']);
|
||
});
|
||
|
||
test('flat root + nested plans combined', () => {
|
||
const dir = phaseDir();
|
||
const plansDir = path.join(dir, 'plans');
|
||
fs.mkdirSync(plansDir);
|
||
// root: 1 plan, 1 summary
|
||
touch(dir, '01-01-PLAN.md', '01-01-SUMMARY.md');
|
||
// nested: 2 plans, 1 summary
|
||
touch(plansDir, 'PLAN-01-setup.md', 'PLAN-02-impl.md', 'SUMMARY-01-setup.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 3, 'root + nested plans');
|
||
assert.strictEqual(result.summaryCount, 2, 'root + nested summaries');
|
||
assert.strictEqual(result.completed, false, 'not all plans have summaries');
|
||
});
|
||
|
||
test('hasNestedPlans is false when plans/ directory absent', () => {
|
||
const dir = phaseDir();
|
||
touch(dir, 'PLAN.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.hasNestedPlans, false);
|
||
});
|
||
|
||
test('nested OUTLINE files are ignored', () => {
|
||
const dir = phaseDir();
|
||
const plansDir = path.join(dir, 'plans');
|
||
fs.mkdirSync(plansDir);
|
||
touch(plansDir, 'PLAN-01-setup.md', 'PLAN-01-OUTLINE.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1, 'OUTLINE excluded in nested');
|
||
});
|
||
|
||
test('nested pre-bounce files are ignored', () => {
|
||
const dir = phaseDir();
|
||
const plansDir = path.join(dir, 'plans');
|
||
fs.mkdirSync(plansDir);
|
||
touch(plansDir, 'PLAN-01-setup.md', 'PLAN-01.pre-bounce.md');
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1, 'pre-bounce excluded in nested');
|
||
});
|
||
|
||
test('plans/ that is not readable as directory does not throw', () => {
|
||
const dir = phaseDir();
|
||
// Create plans/ as a file (unreadable as directory)
|
||
fs.writeFileSync(path.join(dir, 'plans'), 'not-a-directory');
|
||
touch(dir, 'PLAN.md');
|
||
// Should not throw
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 1);
|
||
assert.strictEqual(result.hasNestedPlans, false);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Parity: helper output shape and mixed fixture
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('scanPhasePlans — call-site parity on mixed fixture', () => {
|
||
// Build a fixture tree that exercises both flat and nested layout:
|
||
// 01-foundation/
|
||
// 01-01-PLAN.md
|
||
// 01-01-SUMMARY.md
|
||
// 01-01-PLAN-OUTLINE.md (should be ignored)
|
||
// 01-02-PLAN.md
|
||
// plans/
|
||
// PLAN-01-setup.md
|
||
// SUMMARY-01-setup.md
|
||
|
||
function buildMixedPhase() {
|
||
const dir = phaseDir('01-foundation');
|
||
const plansDir = path.join(dir, 'plans');
|
||
fs.mkdirSync(plansDir);
|
||
touch(dir, '01-01-PLAN.md', '01-01-SUMMARY.md', '01-01-PLAN-OUTLINE.md', '01-02-PLAN.md');
|
||
touch(plansDir, 'PLAN-01-setup.md', 'SUMMARY-01-setup.md');
|
||
return dir;
|
||
}
|
||
|
||
test('scanPhasePlans() counts match expected values for mixed fixture', () => {
|
||
const dir = buildMixedPhase();
|
||
const result = scanPhasePlans(dir);
|
||
// flat: 01-01-PLAN.md + 01-02-PLAN.md = 2 (OUTLINE ignored)
|
||
// nested: PLAN-01-setup.md = 1
|
||
assert.strictEqual(result.planCount, 3, 'planCount should be 3');
|
||
// flat: 01-01-SUMMARY.md = 1; nested: SUMMARY-01-setup.md = 1
|
||
assert.strictEqual(result.summaryCount, 2, 'summaryCount should be 2');
|
||
assert.strictEqual(result.completed, false, 'not all plans have summaries');
|
||
assert.strictEqual(result.hasNestedPlans, true, 'nested layout present');
|
||
});
|
||
|
||
test('scanPhasePlans() output shape has required fields', () => {
|
||
const dir = buildMixedPhase();
|
||
const result = scanPhasePlans(dir);
|
||
assert.ok('planCount' in result, 'planCount field present');
|
||
assert.ok('summaryCount' in result, 'summaryCount field present');
|
||
assert.ok('completed' in result, 'completed field present');
|
||
assert.ok('hasNestedPlans' in result, 'hasNestedPlans field present');
|
||
assert.ok('planFiles' in result, 'planFiles field present');
|
||
assert.ok('summaryFiles' in result, 'summaryFiles field present');
|
||
assert.ok(Array.isArray(result.planFiles), 'planFiles is array');
|
||
assert.ok(Array.isArray(result.summaryFiles), 'summaryFiles is array');
|
||
});
|
||
|
||
test('parity baseline: 2 flat + 1 nested plans across all call sites', () => {
|
||
// This test documents the exact expected counts for a representative fixture.
|
||
// After the GREEN phase ports roadmap.cjs/state.cjs/init.cjs to use
|
||
// scanPhasePlans, those call sites delegate here and this assertion is
|
||
// the single contract all of them must satisfy.
|
||
const dir = phaseDir('02-api');
|
||
touch(dir, '02-01-PLAN.md', '02-02-PLAN.md', '02-01-SUMMARY.md');
|
||
const plansDir = path.join(dir, 'plans');
|
||
fs.mkdirSync(plansDir);
|
||
touch(plansDir, 'PLAN-01-impl.md', 'SUMMARY-01-impl.md');
|
||
|
||
const result = scanPhasePlans(dir);
|
||
assert.strictEqual(result.planCount, 3, 'helper: 2 flat + 1 nested');
|
||
assert.strictEqual(result.summaryCount, 2, 'helper: 1 flat + 1 nested');
|
||
assert.strictEqual(result.completed, false, '2 summaries < 3 plans');
|
||
assert.strictEqual(result.hasNestedPlans, true, 'plans/ dir exists with plans');
|
||
});
|
||
});
|
||
});
|
||
}
|