Files
msd-core/tests/roadmap-parser.test.cjs
2026-06-08 14:04:47 -04:00

588 lines
23 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 core = require('../gsd-core/bin/lib/core.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');
}
// ─── Shim-identity assertions ─────────────────────────────────────────────────
describe('roadmap-parser: shim-identity — core.cjs re-exports same function objects', () => {
test('core.extractCurrentMilestone === roadmapParser.extractCurrentMilestone', () => {
assert.strictEqual(core.extractCurrentMilestone, roadmapParser.extractCurrentMilestone);
});
test('core.stripShippedMilestones === roadmapParser.stripShippedMilestones', () => {
assert.strictEqual(core.stripShippedMilestones, roadmapParser.stripShippedMilestones);
});
test('core.replaceInCurrentMilestone === roadmapParser.replaceInCurrentMilestone', () => {
assert.strictEqual(core.replaceInCurrentMilestone, roadmapParser.replaceInCurrentMilestone);
});
test('core.getRoadmapPhaseInternal === roadmapParser.getRoadmapPhaseInternal', () => {
assert.strictEqual(core.getRoadmapPhaseInternal, roadmapParser.getRoadmapPhaseInternal);
});
test('core.getMilestoneInfo === roadmapParser.getMilestoneInfo', () => {
assert.strictEqual(core.getMilestoneInfo, roadmapParser.getMilestoneInfo);
});
test('core.getMilestonePhaseFilter === roadmapParser.getMilestonePhaseFilter', () => {
assert.strictEqual(core.getMilestonePhaseFilter, roadmapParser.getMilestonePhaseFilter);
});
});
// ─── 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('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');
});
});