fix(2137): skip worktree isolation when .gitmodules detected (#2144)
* feat(sdk): add typed query foundation and gsd-sdk query (Phase 1) Add sdk/src/query registry and handlers with tests, GSDQueryError, CLI query wiring, and supporting type/tool-scoping hooks. Update CHANGELOG. Vitest 4 constructor mock fixes in milestone-runner tests. Made-with: Cursor * fix(2137): skip worktree isolation when .gitmodules detected When a project contains git submodules, worktree isolation cannot correctly handle submodule commits — three separate gaps exist in worktree setup, executor commit protocol, and merge-back. Rather than patch each gap individually, detect .gitmodules at phase start and fall back to sequential execution, which handles submodules transparently (Option B). Affected workflows: execute-phase.md, quick.md --------- Co-authored-by: David Sienkowski <dave@sienkowski.com>
This commit is contained in:
470
sdk/src/query/roadmap.ts
Normal file
470
sdk/src/query/roadmap.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* 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 { 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 {
|
||||
return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get milestone version and name from ROADMAP.md.
|
||||
*
|
||||
* Port of getMilestoneInfo from core.cjs lines 1367-1402.
|
||||
*
|
||||
* @param projectDir - Project root directory
|
||||
* @returns Object with version and name
|
||||
*/
|
||||
export async function getMilestoneInfo(projectDir: string): Promise<{ version: string; name: string }> {
|
||||
try {
|
||||
const roadmap = await readFile(planningPaths(projectDir).roadmap, 'utf-8');
|
||||
|
||||
// First: check for list-format using in-progress marker
|
||||
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
||||
if (inProgressMatch) {
|
||||
return { version: 'v' + inProgressMatch[1], name: inProgressMatch[2].trim() };
|
||||
}
|
||||
|
||||
// Second: heading-format — strip shipped milestones
|
||||
const cleaned = stripShippedMilestones(roadmap);
|
||||
const headingMatch = cleaned.match(/## .*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
|
||||
if (headingMatch) {
|
||||
return { version: 'v' + headingMatch[1], name: headingMatch[2].trim() };
|
||||
}
|
||||
|
||||
// Fallback: bare version match
|
||||
const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
|
||||
return {
|
||||
version: versionMatch ? versionMatch[0] : 'v1.0',
|
||||
name: 'milestone',
|
||||
};
|
||||
} catch {
|
||||
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): Promise<string> {
|
||||
// Get version from STATE.md frontmatter
|
||||
let version: string | null = null;
|
||||
try {
|
||||
const stateRaw = await readFile(planningPaths(projectDir).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
|
||||
const headingLevelMatch = sectionMatch[1].match(/^(#{1,3})\s/);
|
||||
const headingLevel = headingLevelMatch ? headingLevelMatch[1].length : 2;
|
||||
const restContent = content.slice(sectionStart + sectionMatch[0].length);
|
||||
const nextMilestonePattern = new RegExp(
|
||||
`^#{1,${headingLevel}}\\s+(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
|
||||
'mi'
|
||||
);
|
||||
const nextMatch = restContent.match(nextMilestonePattern);
|
||||
|
||||
let sectionEnd: number;
|
||||
if (nextMatch && nextMatch.index !== undefined) {
|
||||
sectionEnd = sectionStart + sectionMatch[0].length + nextMatch.index;
|
||||
} else {
|
||||
sectionEnd = content.length;
|
||||
}
|
||||
|
||||
const beforeMilestones = content.slice(0, sectionStart);
|
||||
const currentSection = content.slice(sectionStart, sectionEnd);
|
||||
|
||||
// Strip <details> from preamble
|
||||
const preamble = beforeMilestones.replace(/<details>[\s\S]*?<\/details>/gi, '');
|
||||
|
||||
return preamble + currentSection;
|
||||
}
|
||||
|
||||
// ─── 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) => {
|
||||
const phaseNum = args[0];
|
||||
if (!phaseNum) {
|
||||
throw new GSDError(
|
||||
'Usage: roadmap get-phase <phase-number>',
|
||||
ErrorClassification.Validation,
|
||||
);
|
||||
}
|
||||
|
||||
const roadmapPath = planningPaths(projectDir).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);
|
||||
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) => {
|
||||
const roadmapPath = planningPaths(projectDir).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);
|
||||
const phasesDir = planningPaths(projectDir).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 };
|
||||
};
|
||||
|
||||
// ─── roadmapUpdatePlanProgress ────────────────────────────────────────────
|
||||
|
||||
export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir) => {
|
||||
const phase = args[0];
|
||||
const paths = planningPaths(projectDir);
|
||||
|
||||
if (!phase) {
|
||||
return { data: { updated: false, reason: 'phase argument required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
let content = await readFile(paths.roadmap, 'utf-8');
|
||||
const phaseNum = normalizePhaseName(phase);
|
||||
const updated = content.replace(
|
||||
/(-\s*\[\s*\]\s*(?:Plan\s+\d+|plan\s+\d+|\*\*Plan))/gi,
|
||||
(match) => match.replace('[ ]', '[x]'),
|
||||
);
|
||||
if (updated !== content) {
|
||||
await writeFile(paths.roadmap, updated, 'utf-8');
|
||||
return { data: { updated: true, phase: phaseNum } };
|
||||
}
|
||||
return { data: { updated: false, phase: phaseNum, reason: 'no matching checkbox found' } };
|
||||
} catch {
|
||||
return { data: { updated: false, reason: 'ROADMAP.md not found or unreadable' } };
|
||||
}
|
||||
};
|
||||
|
||||
// ─── requirementsMarkComplete ─────────────────────────────────────────────
|
||||
|
||||
export const requirementsMarkComplete: QueryHandler = async (args, projectDir) => {
|
||||
const reqIds = args;
|
||||
const paths = planningPaths(projectDir);
|
||||
|
||||
if (reqIds.length === 0) {
|
||||
return { data: { marked: false, reason: 'requirement IDs required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
let content = await readFile(paths.requirements, 'utf-8');
|
||||
let changeCount = 0;
|
||||
|
||||
for (const id of reqIds) {
|
||||
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`(-\\s*\\[\\s*\\]\\s*)([^\\n]*${escaped})`, 'gi');
|
||||
content = content.replace(pattern, (_m, _bracket, rest) => `- [x] ${rest}`.trim() + '\n' || `- [x] ${rest}`);
|
||||
if (content.includes(`[x]`) && content.includes(id)) changeCount++;
|
||||
}
|
||||
|
||||
await writeFile(paths.requirements, content, 'utf-8');
|
||||
return { data: { marked: true, ids: reqIds, changed: changeCount } };
|
||||
} catch {
|
||||
return { data: { marked: false, reason: 'REQUIREMENTS.md not found or unreadable' } };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user