* fix(#2619): prevent extractCurrentMilestone from truncating on phase-vX.Y headings extractCurrentMilestone sliced ROADMAP.md to the current milestone by looking for the next milestone heading with a greedy regex: ^#{1,N}\s+(?:.*v\d+\.\d+|✅|📋|🚧) Any heading that mentioned a version literal matched — including phase headings like "### Phase 12: v1.0 Tech-Debt Closure". When the current milestone was at the same heading level as the phases (### 🚧 v1.1 …), the slice terminated at the first such phase, hiding every phase that followed from phase.insert, validate.health W007, and other SDK commands. Fix: add a `(?!Phase\s+\S)` negative lookahead so phase headings can never be treated as milestone boundaries. Phase headings always start with the literal `Phase `, so this is a clean exclusion. Applied to: - get-shit-done/bin/lib/core.cjs (extractCurrentMilestone) - sdk/src/query/roadmap.ts (extractCurrentMilestone + extractNextMilestoneSection) Regression tests: - tests/roadmap-phase-fallback.test.cjs: extractCurrentMilestone does not truncate on phase heading containing vX.Y (#2619) - sdk/src/query/roadmap.test.ts: extractCurrentMilestone bug-2619: does not truncate at a phase heading containing vX.Y Closes #2619 * fix(#2619): make milestone-boundary Phase lookahead case-insensitive CodeRabbit follow-up on #2619: the negative lookahead `(?!Phase\s+\S)` in the SDK milestone-boundary regex was case-sensitive, so headings like `### PHASE 12: v1.0 Tech-Debt` or `### phase 12: …` still truncated the milestone slice. Add the `i` flag (now `gmi`). The sibling CJS regex in get-shit-done/bin/lib/core.cjs already uses the `mi` flag, so it is already case-insensitive; added a regression test to lock that in. - sdk/src/query/roadmap.ts: change flags from `gm` → `gmi` - sdk/src/query/roadmap.test.ts: add PHASE/phase regression test - tests/roadmap-phase-fallback.test.cjs: add PHASE/phase regression test Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
724 lines
28 KiB
TypeScript
724 lines
28 KiB
TypeScript
/**
|
||
* Roadmap query handlers — ROADMAP.md analysis and phase lookup.
|
||
*
|
||
* Ported from get-shit-done/bin/lib/roadmap.cjs and core.cjs.
|
||
* Provides roadmap.analyze (multi-pass parsing with disk correlation)
|
||
* and roadmap.get-phase (single phase section extraction).
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* import { roadmapAnalyze, roadmapGetPhase } from './roadmap.js';
|
||
*
|
||
* const analysis = await roadmapAnalyze([], '/project');
|
||
* // { data: { phases: [...], phase_count: 6, progress_percent: 50, ... } }
|
||
*
|
||
* const phase = await roadmapGetPhase(['10'], '/project');
|
||
* // { data: { found: true, phase_number: '10', phase_name: 'Read-Only Queries', ... } }
|
||
* ```
|
||
*/
|
||
|
||
import { existsSync } from 'node:fs';
|
||
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
||
import { join } from 'node:path';
|
||
import { GSDError, ErrorClassification } from '../errors.js';
|
||
import {
|
||
escapeRegex,
|
||
normalizePhaseName,
|
||
phaseTokenMatches,
|
||
planningPaths,
|
||
} from './helpers.js';
|
||
import type { QueryHandler, QueryResult } from './utils.js';
|
||
|
||
// ─── Internal types ───────────────────────────────────────────────────────
|
||
|
||
interface PhaseSection {
|
||
found: boolean;
|
||
phase_number: string;
|
||
phase_name: string;
|
||
goal?: string | null;
|
||
success_criteria?: string[];
|
||
section?: string;
|
||
error?: string;
|
||
message?: string;
|
||
}
|
||
|
||
// ─── Exported helpers ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Strip <details>...</details> blocks from content (shipped milestones).
|
||
*
|
||
* Port of stripShippedMilestones from core.cjs line 1082-1084.
|
||
*/
|
||
export function stripShippedMilestones(content: string): string {
|
||
// Pattern 1: <details>...</details> blocks (explicit collapse)
|
||
let result = content.replace(/<details>[\s\S]*?<\/details>/gi, '');
|
||
// Pattern 2: inline milestone headings marked as shipped.
|
||
// Keep aligned with heading levels accepted by extractCurrentMilestone() (## and ###).
|
||
const sections = result.split(/(?=^#{2,3}\s)/m);
|
||
result = sections.filter(s => !/^#{2,3}\s[^\n]*✅\s*SHIPPED\b/im.test(s)).join('');
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Read milestone + name from STATE.md frontmatter when ROADMAP does not encode them.
|
||
*/
|
||
async function parseMilestoneFromState(projectDir: string, workstream?: string): Promise<{ version: string; name: string } | null> {
|
||
try {
|
||
const stateRaw = await readFile(planningPaths(projectDir, workstream).state, 'utf-8');
|
||
const vm = stateRaw.match(/^milestone:\s*(.+)$/m);
|
||
if (!vm) return null;
|
||
const version = vm[1].trim().replace(/^["']|["']$/g, '');
|
||
const nm = stateRaw.match(/^milestone_name:\s*(.+)$/m);
|
||
const name = nm ? nm[1].trim().replace(/^["']|["']$/g, '') : 'milestone';
|
||
return { version, name };
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get milestone version and name from ROADMAP.md (and optionally STATE.md).
|
||
*
|
||
* Port of getMilestoneInfo from core.cjs lines 1367-1402, extended for:
|
||
* - 🟡 in-flight marker (same list shape as 🚧)
|
||
* - milestone bullets `**vX.Y Title**` before `## Phases` (last = current when listed in semver order)
|
||
* - STATE.md frontmatter when ROADMAP has no parseable milestone
|
||
* - **last** bare `vX.Y` fallback (first match was often v1.0 from the shipped list)
|
||
*
|
||
* @param projectDir - Project root directory
|
||
* @returns Object with version and name
|
||
*/
|
||
export async function getMilestoneInfo(projectDir: string, workstream?: string): Promise<{ version: string; name: string }> {
|
||
try {
|
||
// Priority 1: STATE.md frontmatter (authoritative for version; name only when real)
|
||
const fromState = await parseMilestoneFromState(projectDir, workstream);
|
||
const stateVersion = fromState?.version ?? null;
|
||
const stateName = fromState && fromState.name !== 'milestone' ? fromState.name : null;
|
||
if (stateVersion && stateName) {
|
||
return { version: stateVersion, name: stateName };
|
||
}
|
||
// STATE.md has a version but no real name — fall through to ROADMAP for the name,
|
||
// then override the version with the authoritative STATE.md value.
|
||
|
||
const roadmap = await readFile(planningPaths(projectDir, workstream).roadmap, 'utf-8');
|
||
|
||
// List-format: construction / blocked (legacy emoji)
|
||
const barricadeMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
||
if (barricadeMatch) {
|
||
return { version: stateVersion ?? 'v' + barricadeMatch[1], name: barricadeMatch[2].trim() };
|
||
}
|
||
|
||
// List-format: in flight / active (GSD ROADMAP template uses 🟡 for current milestone)
|
||
const inFlightMatch = roadmap.match(/🟡\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
||
if (inFlightMatch) {
|
||
return { version: stateVersion ?? 'v' + inFlightMatch[1], name: inFlightMatch[2].trim() };
|
||
}
|
||
|
||
// Heading-format — strip shipped <details> blocks first
|
||
const cleaned = stripShippedMilestones(roadmap);
|
||
const headingMatch = cleaned.match(/##\s+.*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
|
||
if (headingMatch) {
|
||
return { version: stateVersion ?? 'v' + headingMatch[1], name: headingMatch[2].trim() };
|
||
}
|
||
|
||
// Milestone bullet list (## Milestones … ## Phases): use last **vX.Y Title** — typically the current row
|
||
const beforePhases = roadmap.split(/^##\s+Phases\b/m)[0] ?? roadmap;
|
||
const boldMatches = [...beforePhases.matchAll(/\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/g)];
|
||
if (boldMatches.length > 0) {
|
||
const last = boldMatches[boldMatches.length - 1];
|
||
return { version: stateVersion ?? 'v' + last[1], name: last[2].trim() };
|
||
}
|
||
|
||
const allBare = [...cleaned.matchAll(/\bv(\d+(?:\.\d+)+)\b/g)];
|
||
if (allBare.length > 0) {
|
||
const lastBare = allBare[allBare.length - 1];
|
||
return { version: stateVersion ?? lastBare[0], name: 'milestone' };
|
||
}
|
||
|
||
return { version: stateVersion ?? 'v1.0', name: 'milestone' };
|
||
} catch {
|
||
const fromState = await parseMilestoneFromState(projectDir, workstream);
|
||
if (fromState) return fromState;
|
||
return { version: 'v1.0', name: 'milestone' };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Extract the current milestone section from ROADMAP.md.
|
||
*
|
||
* Port of extractCurrentMilestone from core.cjs lines 1102-1170.
|
||
*
|
||
* @param content - Full ROADMAP.md content
|
||
* @param projectDir - Working directory for reading STATE.md
|
||
* @returns Content scoped to current milestone
|
||
*/
|
||
export async function extractCurrentMilestone(content: string, projectDir: string, workstream?: string): Promise<string> {
|
||
// Get version from STATE.md frontmatter
|
||
let version: string | null = null;
|
||
try {
|
||
const stateRaw = await readFile(planningPaths(projectDir, workstream).state, 'utf-8');
|
||
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
|
||
if (milestoneMatch) {
|
||
version = milestoneMatch[1].trim();
|
||
}
|
||
} catch { /* intentionally empty */ }
|
||
|
||
// Fallback: derive from ROADMAP in-progress marker
|
||
if (!version) {
|
||
const inProgressMatch = content.match(/(?:🚧|🟡)\s*\*\*v(\d+(?:\.\d+)+)\s/);
|
||
if (inProgressMatch) {
|
||
version = 'v' + inProgressMatch[1];
|
||
}
|
||
}
|
||
|
||
if (!version) return stripShippedMilestones(content);
|
||
|
||
// Find section matching this version
|
||
const escapedVersion = escapeRegex(version);
|
||
const sectionPattern = new RegExp(
|
||
`(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`,
|
||
'mi'
|
||
);
|
||
const sectionMatch = content.match(sectionPattern);
|
||
|
||
if (!sectionMatch || sectionMatch.index === undefined) return stripShippedMilestones(content);
|
||
|
||
const sectionStart = sectionMatch.index;
|
||
|
||
// Find end: next milestone heading at same or higher level, or EOF.
|
||
// Skip headings that belong to the SAME version (e.g. "## v2.0 Phase Details").
|
||
const headingLevelMatch = sectionMatch[1].match(/^(#{1,3})\s/);
|
||
const headingLevel = headingLevelMatch ? headingLevelMatch[1].length : 2;
|
||
const restContent = content.slice(sectionStart + sectionMatch[0].length);
|
||
// Extract current version so same-version sub-headings are not treated as boundaries.
|
||
// Capture full semver (major.minor.patch) so v2.0.1 is not collapsed to "2.0".
|
||
const currentVersionMatch = version ? version.match(/v(\d+(?:\.\d+)+)/i) : null;
|
||
const currentVersionStr = currentVersionMatch ? currentVersionMatch[1] : '';
|
||
|
||
// Exclude phase headings (e.g. "### Phase 12: v1.0 Tech-Debt Closure") from
|
||
// being treated as milestone boundaries just because they mention vX.Y in
|
||
// the title. Phase headings always start with the literal `Phase `. See #2619.
|
||
const nextMilestoneRegex = new RegExp(
|
||
`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v(\\d+(?:\\.\\d+)+)[^\\n]*|.*(?:✅|📋|🚧|🟡))`,
|
||
// `i` flag ensures the `(?!Phase\s+\S)` lookahead matches PHASE/phase too
|
||
// (CodeRabbit follow-up on #2619).
|
||
'gmi'
|
||
);
|
||
|
||
let sectionEnd = content.length;
|
||
let m: RegExpExecArray | null;
|
||
while ((m = nextMilestoneRegex.exec(restContent)) !== null) {
|
||
const matchedVersion = m[1];
|
||
// Skip headings that reference the same version (e.g. "## v2.0 Phase Details").
|
||
if (matchedVersion && currentVersionStr && matchedVersion === currentVersionStr) continue;
|
||
sectionEnd = sectionStart + sectionMatch[0].length + m.index;
|
||
break;
|
||
}
|
||
|
||
// Return only the current milestone section — never include the preamble, which
|
||
// may contain ## Backlog and other non-current-milestone phases.
|
||
return content.slice(sectionStart, sectionEnd);
|
||
}
|
||
|
||
// ─── Next-milestone helpers (issue #2497) ─────────────────────────────────
|
||
|
||
/**
|
||
* Phase shape returned by extractPhasesFromSection — mirrors the fields used
|
||
* by the current-milestone phases array in initManager so consumers can
|
||
* render queued phases uniformly.
|
||
*/
|
||
export interface QueuedPhase {
|
||
number: string;
|
||
name: string;
|
||
goal: string | null;
|
||
depends_on: string | null;
|
||
}
|
||
|
||
/**
|
||
* Extract phase entries from an arbitrary ROADMAP milestone section.
|
||
*
|
||
* Parses `#### Phase N: Name` / `### Phase N: Name` / `## Phase N: Name`
|
||
* headings and, for each, captures goal + depends_on via the same patterns
|
||
* used by initManager's current-milestone phase parsing. Used by
|
||
* `initManager` to populate `queued_phases` (#2497).
|
||
*/
|
||
export function extractPhasesFromSection(section: string): QueuedPhase[] {
|
||
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
||
const phases: QueuedPhase[] = [];
|
||
let m: RegExpExecArray | null;
|
||
while ((m = phasePattern.exec(section)) !== null) {
|
||
const phaseNum = m[1];
|
||
const phaseName = m[2].replace(/\(INSERTED\)/i, '').trim();
|
||
const sectionStart = m.index;
|
||
const rest = section.slice(sectionStart);
|
||
const nextHeader = rest.match(/\n#{2,4}\s+Phase\s+\d/i);
|
||
const end = nextHeader ? sectionStart + (nextHeader.index ?? 0) : section.length;
|
||
const body = section.slice(sectionStart, end);
|
||
const goalMatch = body.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||
const dependsMatch = body.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||
phases.push({
|
||
number: phaseNum,
|
||
name: phaseName,
|
||
goal: goalMatch ? goalMatch[1].trim() : null,
|
||
depends_on: dependsMatch ? dependsMatch[1].trim() : null,
|
||
});
|
||
}
|
||
return phases;
|
||
}
|
||
|
||
/**
|
||
* Find the milestone section that comes immediately AFTER the active one.
|
||
*
|
||
* Used by initManager to surface `queued_phases` without conflating the
|
||
* active milestone's phase list with the next one (#2497). Returns null
|
||
* when no subsequent milestone section exists (active is the last one).
|
||
*
|
||
* Reuses the same current-version resolution path as `getMilestoneInfo`:
|
||
* STATE.md frontmatter first, then in-flight emoji markers in ROADMAP.
|
||
* Shipped milestones are stripped first so they can't shadow the real
|
||
* "next" one.
|
||
*/
|
||
export async function extractNextMilestoneSection(
|
||
content: string,
|
||
projectDir: string,
|
||
): Promise<{ version: string; name: string; section: string } | null> {
|
||
const cleaned = stripShippedMilestones(content);
|
||
|
||
// Resolve current version via STATE.md (priority) then in-flight markers.
|
||
let currentVersion: string | null = null;
|
||
const fromState = await parseMilestoneFromState(projectDir);
|
||
if (fromState?.version) {
|
||
const raw = fromState.version.trim();
|
||
currentVersion = /^v\d/i.test(raw) ? raw : `v${raw}`;
|
||
}
|
||
if (!currentVersion) {
|
||
const inProgressMatch = cleaned.match(/(?:🚧|🟡)\s*\*\*v(\d+(?:\.\d+)+)\s/);
|
||
if (inProgressMatch) currentVersion = 'v' + inProgressMatch[1];
|
||
}
|
||
if (!currentVersion) return null;
|
||
|
||
// Find the current milestone ## heading.
|
||
const escaped = escapeRegex(currentVersion);
|
||
const currentHeadingPattern = new RegExp(
|
||
`^##\\s+[^\\n]*${escaped}[^\\n]*$`,
|
||
'mi',
|
||
);
|
||
const currentMatch = cleaned.match(currentHeadingPattern);
|
||
if (!currentMatch || currentMatch.index === undefined) return null;
|
||
|
||
// Look for the next ## milestone heading after the current one.
|
||
const tail = cleaned.slice(currentMatch.index + currentMatch[0].length);
|
||
// Exclude phase headings — see #2619.
|
||
const nextMilestonePattern = /^##\s+(?!Phase\s+\S)([^\n]*(?:v(\d+(?:\.\d+)+)|✅|🚧|🟡|📋)[^\n]*)$/gim;
|
||
let nextMatch: RegExpExecArray | null;
|
||
while ((nextMatch = nextMilestonePattern.exec(tail)) !== null) {
|
||
const heading = nextMatch[1];
|
||
const versionMatch = heading.match(/v(\d+(?:\.\d+)+)/);
|
||
if (!versionMatch) continue;
|
||
const nextVersion = 'v' + versionMatch[1];
|
||
if (nextVersion === currentVersion) continue;
|
||
|
||
// Derive a display name: trim through "vX.Y:" or "vX.Y —" prefix.
|
||
const nameMatch = heading.match(/v\d+(?:\.\d+)+:?\s*[—–-]?\s*([^\n(]+)/);
|
||
const name = nameMatch ? nameMatch[1].trim() : heading.trim();
|
||
|
||
const sectionStart = (nextMatch.index ?? 0) + nextMatch[0].length;
|
||
const afterStart = tail.slice(sectionStart);
|
||
const followingHeader = afterStart.match(/^##\s/m);
|
||
const sectionEnd = followingHeader && followingHeader.index !== undefined
|
||
? sectionStart + followingHeader.index
|
||
: tail.length;
|
||
const section = tail.slice(sectionStart, sectionEnd);
|
||
|
||
return { version: nextVersion, name, section };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ─── Internal helpers ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Search for a phase section in roadmap content.
|
||
*
|
||
* Port of searchPhaseInContent from roadmap.cjs lines 14-73.
|
||
*/
|
||
function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string): PhaseSection | null {
|
||
// Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
|
||
const phasePattern = new RegExp(
|
||
`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
|
||
'i'
|
||
);
|
||
const headerMatch = content.match(phasePattern);
|
||
|
||
if (!headerMatch) {
|
||
// Fallback: check if phase exists in summary list but missing detail section
|
||
const checklistPattern = new RegExp(
|
||
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`,
|
||
'i'
|
||
);
|
||
const checklistMatch = content.match(checklistPattern);
|
||
|
||
if (checklistMatch) {
|
||
return {
|
||
found: false,
|
||
phase_number: phaseNum,
|
||
phase_name: checklistMatch[1].trim(),
|
||
error: 'malformed_roadmap',
|
||
message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`,
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
const phaseName = headerMatch[1].trim();
|
||
const headerIndex = headerMatch.index!;
|
||
|
||
// Find the end of this section (next ## or ### phase header, or end of file)
|
||
const restOfContent = content.slice(headerIndex);
|
||
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
|
||
const sectionEnd = nextHeaderMatch
|
||
? headerIndex + nextHeaderMatch.index!
|
||
: content.length;
|
||
|
||
const section = content.slice(headerIndex, sectionEnd).trim();
|
||
|
||
// Extract goal if present (supports both **Goal:** and **Goal**: formats)
|
||
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||
const goal = goalMatch ? goalMatch[1].trim() : null;
|
||
|
||
// Extract success criteria as structured array
|
||
const criteriaMatch = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i);
|
||
const success_criteria = criteriaMatch
|
||
? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean)
|
||
: [];
|
||
|
||
return {
|
||
found: true,
|
||
phase_number: phaseNum,
|
||
phase_name: phaseName,
|
||
goal,
|
||
success_criteria,
|
||
section,
|
||
};
|
||
}
|
||
|
||
// ─── Exported handlers ────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Query handler for roadmap.get-phase.
|
||
*
|
||
* Port of cmdRoadmapGetPhase from roadmap.cjs lines 75-113.
|
||
*
|
||
* @param args - args[0] is phase number (required)
|
||
* @param projectDir - Project root directory
|
||
* @returns QueryResult with phase section info or { found: false }
|
||
*/
|
||
export const roadmapGetPhase: QueryHandler = async (args, projectDir, workstream) => {
|
||
const phaseNum = args[0];
|
||
if (!phaseNum) {
|
||
throw new GSDError(
|
||
'Usage: roadmap get-phase <phase-number>',
|
||
ErrorClassification.Validation,
|
||
);
|
||
}
|
||
|
||
const roadmapPath = planningPaths(projectDir, workstream).roadmap;
|
||
|
||
let rawContent: string;
|
||
try {
|
||
rawContent = await readFile(roadmapPath, 'utf-8');
|
||
} catch {
|
||
return { data: { found: false, error: 'ROADMAP.md not found' } };
|
||
}
|
||
|
||
const milestoneContent = await extractCurrentMilestone(rawContent, projectDir, workstream);
|
||
const escapedPhase = escapeRegex(phaseNum);
|
||
|
||
// Search the current milestone slice first, then fall back to full roadmap.
|
||
const fullContent = stripShippedMilestones(rawContent);
|
||
const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum);
|
||
const result = (milestoneResult && !milestoneResult.error)
|
||
? milestoneResult
|
||
: searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult;
|
||
|
||
if (!result) {
|
||
return { data: { found: false, phase_number: phaseNum } };
|
||
}
|
||
|
||
return { data: result };
|
||
};
|
||
|
||
/**
|
||
* Query handler for roadmap.analyze.
|
||
*
|
||
* Port of cmdRoadmapAnalyze from roadmap.cjs lines 115-248.
|
||
* Multi-pass regex parsing with disk status correlation.
|
||
*
|
||
* @param args - Unused
|
||
* @param projectDir - Project root directory
|
||
* @returns QueryResult with full roadmap analysis
|
||
*/
|
||
export const roadmapAnalyze: QueryHandler = async (_args, projectDir, workstream) => {
|
||
const roadmapPath = planningPaths(projectDir, workstream).roadmap;
|
||
|
||
let rawContent: string;
|
||
try {
|
||
rawContent = await readFile(roadmapPath, 'utf-8');
|
||
} catch {
|
||
return { data: { error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null } };
|
||
}
|
||
|
||
const content = await extractCurrentMilestone(rawContent, projectDir, workstream);
|
||
const phasesDir = planningPaths(projectDir, workstream).phases;
|
||
|
||
// IMPORTANT: Create regex INSIDE the function to avoid /g lastIndex persistence
|
||
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
||
const phases: Array<Record<string, unknown>> = [];
|
||
let match: RegExpExecArray | null;
|
||
|
||
while ((match = phasePattern.exec(content)) !== null) {
|
||
const phaseNum = match[1];
|
||
const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
|
||
|
||
// Extract goal from the section
|
||
const sectionStart = match.index;
|
||
const restOfContent = content.slice(sectionStart);
|
||
const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
|
||
const sectionEnd = nextHeader ? sectionStart + nextHeader.index! : content.length;
|
||
const section = content.slice(sectionStart, sectionEnd);
|
||
|
||
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||
const goal = goalMatch ? goalMatch[1].trim() : null;
|
||
|
||
const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||
const depends_on = dependsMatch ? dependsMatch[1].trim() : null;
|
||
|
||
// Check completion on disk
|
||
const normalized = normalizePhaseName(phaseNum);
|
||
let diskStatus = 'no_directory';
|
||
let planCount = 0;
|
||
let summaryCount = 0;
|
||
let hasContext = false;
|
||
let hasResearch = false;
|
||
|
||
try {
|
||
const entries = await readdir(phasesDir, { withFileTypes: true });
|
||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
||
const dirMatch = dirs.find(d => phaseTokenMatches(d, normalized));
|
||
|
||
if (dirMatch) {
|
||
const phaseFiles = await readdir(join(phasesDir, dirMatch));
|
||
planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
|
||
summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
|
||
hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
|
||
hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
|
||
|
||
if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete';
|
||
else if (summaryCount > 0) diskStatus = 'partial';
|
||
else if (planCount > 0) diskStatus = 'planned';
|
||
else if (hasResearch) diskStatus = 'researched';
|
||
else if (hasContext) diskStatus = 'discussed';
|
||
else diskStatus = 'empty';
|
||
}
|
||
} catch { /* intentionally empty */ }
|
||
|
||
// Check ROADMAP checkbox status
|
||
const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s]`, 'i');
|
||
const checkboxMatch = content.match(checkboxPattern);
|
||
const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
|
||
|
||
// If roadmap marks phase complete, trust that over disk
|
||
if (roadmapComplete && diskStatus !== 'complete') {
|
||
diskStatus = 'complete';
|
||
}
|
||
|
||
phases.push({
|
||
number: phaseNum,
|
||
name: phaseName,
|
||
goal,
|
||
depends_on,
|
||
plan_count: planCount,
|
||
summary_count: summaryCount,
|
||
has_context: hasContext,
|
||
has_research: hasResearch,
|
||
disk_status: diskStatus,
|
||
roadmap_complete: roadmapComplete,
|
||
});
|
||
}
|
||
|
||
// Extract milestone info
|
||
const milestones: Array<{ heading: string; version: string }> = [];
|
||
const milestonePattern = /##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi;
|
||
let mMatch: RegExpExecArray | null;
|
||
while ((mMatch = milestonePattern.exec(content)) !== null) {
|
||
milestones.push({
|
||
heading: mMatch[1].trim(),
|
||
version: 'v' + mMatch[2],
|
||
});
|
||
}
|
||
|
||
// Find current and next phase
|
||
const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null;
|
||
const nextPhase = phases.find(p => p.disk_status === 'empty' || p.disk_status === 'no_directory' || p.disk_status === 'discussed' || p.disk_status === 'researched') || null;
|
||
|
||
// Aggregated stats
|
||
const totalPlans = phases.reduce((sum, p) => sum + (p.plan_count as number), 0);
|
||
const totalSummaries = phases.reduce((sum, p) => sum + (p.summary_count as number), 0);
|
||
const completedPhases = phases.filter(p => p.disk_status === 'complete').length;
|
||
|
||
// Detect phases in summary list without detail sections (malformed ROADMAP)
|
||
const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi;
|
||
const checklistPhases = new Set<string>();
|
||
let checklistMatch: RegExpExecArray | null;
|
||
while ((checklistMatch = checklistPattern.exec(content)) !== null) {
|
||
checklistPhases.add(checklistMatch[1]);
|
||
}
|
||
const detailPhases = new Set(phases.map(p => p.number as string));
|
||
const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p));
|
||
|
||
const result: Record<string, unknown> = {
|
||
milestones,
|
||
phases,
|
||
phase_count: phases.length,
|
||
completed_phases: completedPhases,
|
||
total_plans: totalPlans,
|
||
total_summaries: totalSummaries,
|
||
progress_percent: totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0,
|
||
current_phase: currentPhase ? currentPhase.number : null,
|
||
next_phase: nextPhase ? nextPhase.number : null,
|
||
missing_phase_details: missingDetails.length > 0 ? missingDetails : null,
|
||
};
|
||
|
||
return { data: result };
|
||
};
|
||
|
||
|
||
// ─── roadmapAnnotateDependencies ─────────────────────────────────────────
|
||
|
||
/**
|
||
* Annotate the ROADMAP.md plan list with wave dependency notes and
|
||
* cross-cutting constraints derived from PLAN frontmatter.
|
||
*
|
||
* Delegates to gsd-tools.cjs which holds the full annotation logic.
|
||
* Returns { updated, phase, waves, cross_cutting_constraints }.
|
||
*/
|
||
export const roadmapAnnotateDependencies: QueryHandler = async (args, projectDir) => {
|
||
const phase = args[0];
|
||
if (!phase) {
|
||
return { data: { updated: false, reason: 'phase argument required' } };
|
||
}
|
||
|
||
const { spawnSync } = await import('node:child_process');
|
||
const { fileURLToPath } = await import('node:url');
|
||
|
||
const toolsPath = fileURLToPath(
|
||
new URL('../../../get-shit-done/bin/gsd-tools.cjs', import.meta.url),
|
||
);
|
||
|
||
const result = spawnSync(process.execPath, [toolsPath, 'roadmap', 'annotate-dependencies', phase], {
|
||
cwd: projectDir,
|
||
encoding: 'utf-8',
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
timeout: 15000,
|
||
maxBuffer: 1024 * 1024,
|
||
});
|
||
|
||
if (result.error) {
|
||
return { data: { updated: false, reason: result.error.message || 'gsd-tools invocation failed' } };
|
||
}
|
||
|
||
if (result.status !== 0) {
|
||
return { data: { updated: false, reason: result.stderr?.trim() || 'gsd-tools error' } };
|
||
}
|
||
|
||
try {
|
||
return { data: JSON.parse(result.stdout.trim()) };
|
||
} catch {
|
||
return { data: { updated: false, reason: 'failed to parse gsd-tools output' } };
|
||
}
|
||
};
|
||
|
||
|
||
// ─── requirementsMarkComplete ─────────────────────────────────────────────
|
||
|
||
/**
|
||
* Mark requirement IDs complete in REQUIREMENTS.md (checkbox + traceability table).
|
||
* Port of `cmdRequirementsMarkComplete` from milestone.cjs lines 11–87.
|
||
*/
|
||
export const requirementsMarkComplete: QueryHandler = async (args, projectDir, workstream) => {
|
||
if (args.length === 0) {
|
||
throw new GSDError(
|
||
'requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02',
|
||
ErrorClassification.Validation,
|
||
);
|
||
}
|
||
|
||
const reqIds = args
|
||
.join(' ')
|
||
.replace(/[\[\]]/g, '')
|
||
.split(/[,\s]+/)
|
||
.map(r => r.trim())
|
||
.filter(Boolean);
|
||
|
||
if (reqIds.length === 0) {
|
||
throw new GSDError('no valid requirement IDs found', ErrorClassification.Validation);
|
||
}
|
||
|
||
const paths = planningPaths(projectDir, workstream);
|
||
if (!existsSync(paths.requirements)) {
|
||
return { data: { updated: false, reason: 'REQUIREMENTS.md not found', ids: reqIds } };
|
||
}
|
||
|
||
let reqContent = (await readFile(paths.requirements, 'utf-8')).replace(/\r\n/g, '\n');
|
||
const updated: string[] = [];
|
||
const alreadyComplete: string[] = [];
|
||
const notFound: string[] = [];
|
||
|
||
for (const reqId of reqIds) {
|
||
let found = false;
|
||
const reqEscaped = escapeRegex(reqId);
|
||
|
||
const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
|
||
const afterCheckbox = reqContent.replace(checkboxPattern, '$1x$2');
|
||
if (afterCheckbox !== reqContent) {
|
||
reqContent = afterCheckbox;
|
||
found = true;
|
||
}
|
||
|
||
const tablePattern = new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
|
||
const afterTable = reqContent.replace(tablePattern, '$1 Complete $2');
|
||
if (afterTable !== reqContent) {
|
||
reqContent = afterTable;
|
||
found = true;
|
||
}
|
||
|
||
if (found) {
|
||
updated.push(reqId);
|
||
} else {
|
||
const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i');
|
||
const doneTable = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|\\s*Complete\\s*\\|`, 'i');
|
||
if (doneCheckbox.test(reqContent) || doneTable.test(reqContent)) {
|
||
alreadyComplete.push(reqId);
|
||
} else {
|
||
notFound.push(reqId);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (updated.length > 0) {
|
||
await writeFile(paths.requirements, reqContent, 'utf-8');
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
updated: updated.length > 0,
|
||
marked_complete: updated,
|
||
already_complete: alreadyComplete,
|
||
not_found: notFound,
|
||
total: reqIds.length,
|
||
},
|
||
};
|
||
};
|