From 56efa728864d1474c96bbdb165b1f124c1a09577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Mon, 23 Mar 2026 12:53:49 -0600 Subject: [PATCH] =?UTF-8?q?test(S06/T01):=20Extract=20parseRoadmap/parsePl?= =?UTF-8?q?an=20into=20parsers-legacy.ts,=20u=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/resources/extensions/gsd/parsers-legacy.ts - src/resources/extensions/gsd/files.ts - src/resources/extensions/gsd/state.ts - src/resources/extensions/gsd/md-importer.ts - src/resources/extensions/gsd/commands-maintenance.ts - src/resources/extensions/gsd/markdown-renderer.ts - src/resources/extensions/gsd/auto-recovery.ts - src/resources/extensions/gsd/tests/parsers.test.ts --- .gsd/milestones/M001/slices/S06/S06-PLAN.md | 9 +- src/resources/extensions/gsd/auto-recovery.ts | 8 +- .../extensions/gsd/commands-maintenance.ts | 3 +- src/resources/extensions/gsd/files.ts | 236 ++------------- .../extensions/gsd/markdown-renderer.ts | 4 +- src/resources/extensions/gsd/md-importer.ts | 3 +- .../extensions/gsd/parsers-legacy.ts | 271 ++++++++++++++++++ src/resources/extensions/gsd/state.ts | 3 + .../gsd/tests/auto-recovery.test.ts | 3 +- .../gsd/tests/complete-milestone.test.ts | 2 +- .../gsd/tests/markdown-renderer.test.ts | 2 + .../tests/migrate-writer-integration.test.ts | 3 +- .../gsd/tests/migrate-writer.test.ts | 2 + .../extensions/gsd/tests/parsers.test.ts | 3 +- .../gsd/tests/planning-crossval.test.ts | 2 +- .../gsd/tests/roadmap-slices.test.ts | 2 +- 16 files changed, 321 insertions(+), 235 deletions(-) create mode 100644 src/resources/extensions/gsd/parsers-legacy.ts diff --git a/.gsd/milestones/M001/slices/S06/S06-PLAN.md b/.gsd/milestones/M001/slices/S06/S06-PLAN.md index 1c1abd99a..9d6d939d5 100644 --- a/.gsd/milestones/M001/slices/S06/S06-PLAN.md +++ b/.gsd/milestones/M001/slices/S06/S06-PLAN.md @@ -69,9 +69,16 @@ node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental src/resources/extensions/gsd/tests/complete-milestone.test.ts ``` +## Observability / Diagnostics + +- **Failure visibility:** `doctor.test.ts` (and any test exercising the 16 migrated callers' fallback paths) will fail with `TypeError: getLazyParsers(...).parseRoadmap is not a function` after T01 completes — this is expected intermediate breakage that T02 resolves by stripping the fallback paths entirely. +- **Runtime signal:** `clearParseCache()` in `files.ts` invokes all registered cache-clear callbacks via `registerCacheClearCallback()`. If `parsers-legacy.ts` is not loaded (e.g., no consumer imported it), its cache won't be cleared — but this is correct: if nobody imported the parsers, there's nothing cached. +- **Inspection surface:** `grep -rn 'parseRoadmap\|parsePlan' src/resources/extensions/gsd/files.ts` must return exit code 1 (no matches) to confirm parser functions are fully extracted. +- **Diagnostic check:** After both tasks, `grep -rn 'createRequire' src/resources/extensions/gsd/{dispatch-guard,auto-dispatch,...}.ts` returns no matches — confirms all fallback paths removed. + ## Tasks -- [ ] **T01: Create parsers-legacy.ts and relocate all parser functions from files.ts** `est:45m` +- [x] **T01: Create parsers-legacy.ts and relocate all parser functions from files.ts** `est:45m` - Why: Parser functions must be extracted from `files.ts` into a dedicated legacy module before fallback paths can be stripped — otherwise removing exports from `files.ts` breaks the 4 legitimate consumers and 8 test files simultaneously - Files: `src/resources/extensions/gsd/parsers-legacy.ts` (new), `src/resources/extensions/gsd/files.ts`, `src/resources/extensions/gsd/state.ts`, `src/resources/extensions/gsd/md-importer.ts`, `src/resources/extensions/gsd/commands-maintenance.ts`, `src/resources/extensions/gsd/markdown-renderer.ts`, `src/resources/extensions/gsd/tests/parsers.test.ts`, `src/resources/extensions/gsd/tests/roadmap-slices.test.ts`, `src/resources/extensions/gsd/tests/planning-crossval.test.ts`, `src/resources/extensions/gsd/tests/auto-recovery.test.ts`, `src/resources/extensions/gsd/tests/markdown-renderer.test.ts`, `src/resources/extensions/gsd/tests/complete-milestone.test.ts`, `src/resources/extensions/gsd/tests/migrate-writer.test.ts`, `src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts` - Do: Create `parsers-legacy.ts` containing `parseRoadmap()`, `_parseRoadmapImpl()`, `parsePlan()`, `_parsePlanImpl()`, `cachedParse()`, and re-exporting `parseRoadmapSlices` from `roadmap-slices.js`. Import `extractSection`, `parseBullets`, `extractBoldField` from `./files.js`. Import `splitFrontmatter`, `parseFrontmatterMap` from `../shared/frontmatter.js`. Import `nativeParseRoadmap`, `nativeParsePlanFile` from `./native-parser-bridge.js`. Import `debugTime`, `debugCount` from `./debug-logger.js`. Keep `clearParseCache()` exported from `files.ts` (other callers depend on it) — have `parsers-legacy.ts` import it from `./files.js`. Remove `parseRoadmap`, `_parseRoadmapImpl`, `parsePlan`, `_parsePlanImpl` from `files.ts`. Remove `import { parseRoadmapSlices }` and `nativeParseRoadmap`/`nativeParsePlanFile` from `files.ts` imports (keep `nativeExtractSection`/`nativeParseSummaryFile`/`NATIVE_UNAVAILABLE` — used by non-parser functions). Update `state.ts` import to `./parsers-legacy.js`. Update `md-importer.ts` import to `./parsers-legacy.js`. Update `commands-maintenance.ts` dynamic import to `./parsers-legacy.js`. Update `markdown-renderer.ts` detectStaleRenders lazy import to `./parsers-legacy.ts`/`.js`. Update all 8 test files' imports. diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index f4f818a3b..de5fd6c65 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -379,8 +379,8 @@ export function verifyExpectedArtifact( const planContent = readFileSync(absPath, "utf-8"); const _require = createRequire(import.meta.url); let parsePlan: Function; - try { parsePlan = _require("./files.ts").parsePlan; } - catch { parsePlan = _require("./files.js").parsePlan; } + try { parsePlan = _require("./parsers-legacy.ts").parsePlan; } + catch { parsePlan = _require("./parsers-legacy.js").parsePlan; } const plan = parsePlan(planContent); if (plan.tasks.length > 0) taskIds = plan.tasks.map((t: { id: string }) => t.id); } @@ -425,8 +425,8 @@ export function verifyExpectedArtifact( const roadmapContent = readFileSync(roadmapFile, "utf-8"); const _require = createRequire(import.meta.url); let parseRoadmap: Function; - try { parseRoadmap = _require("./files.ts").parseRoadmap; } - catch { parseRoadmap = _require("./files.js").parseRoadmap; } + try { parseRoadmap = _require("./parsers-legacy.ts").parseRoadmap; } + catch { parseRoadmap = _require("./parsers-legacy.js").parseRoadmap; } const roadmap = parseRoadmap(roadmapContent); const slice = roadmap.slices.find((s) => s.id === sid); if (slice && !slice.done) return false; diff --git a/src/resources/extensions/gsd/commands-maintenance.ts b/src/resources/extensions/gsd/commands-maintenance.ts index 457c4b16e..aeb082df0 100644 --- a/src/resources/extensions/gsd/commands-maintenance.ts +++ b/src/resources/extensions/gsd/commands-maintenance.ts @@ -44,7 +44,8 @@ export async function handleCleanupBranches(ctx: ExtensionCommandContext, basePa try { const { listWorktrees } = await import("./worktree-manager.js"); const { resolveMilestoneFile } = await import("./paths.js"); - const { loadFile, parseRoadmap } = await import("./files.js"); + const { loadFile } = await import("./files.js"); + const { parseRoadmap } = await import("./parsers-legacy.js"); const { isMilestoneComplete } = await import("./state.js"); const attachedBranches = new Set( diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts index c5d7fada0..c2095ab70 100644 --- a/src/resources/extensions/gsd/files.ts +++ b/src/resources/extensions/gsd/files.ts @@ -10,8 +10,7 @@ import { resolveMilestoneFile, relMilestoneFile, resolveGsdRootFile } from './pa import { milestoneIdSort, findMilestoneIds } from './milestone-ids.js'; import type { - Roadmap, BoundaryMapEntry, - SlicePlan, TaskPlanEntry, TaskPlanFile, TaskPlanFrontmatter, + TaskPlanFile, TaskPlanFrontmatter, Summary, SummaryFrontmatter, SummaryRequires, FileModified, Continue, ContinueFrontmatter, ContinueStatus, RequirementCounts, @@ -21,9 +20,7 @@ import type { } from './types.js'; import { checkExistingEnvKeys } from './env-utils.js'; -import { parseRoadmapSlices } from './roadmap-slices.js'; -import { nativeParseRoadmap, nativeExtractSection, nativeParsePlanFile, nativeParseSummaryFile, NATIVE_UNAVAILABLE } from './native-parser-bridge.js'; -import { debugTime, debugCount } from './debug-logger.js'; +import { nativeExtractSection, nativeParseSummaryFile, NATIVE_UNAVAILABLE } from './native-parser-bridge.js'; import { CACHE_MAX } from './constants.js'; import { splitFrontmatter, parseFrontmatterMap } from '../shared/frontmatter.js'; @@ -55,9 +52,22 @@ function cachedParse(content: string, tag: string, parseFn: (c: string) => T) return result; } -/** Clear the module-scoped parse cache. Call when files change on disk. */ +// ─── Cross-module cache clear registry ──────────────────────────────────── +// parsers-legacy.ts registers its cache-clear callback here at module init +// to avoid circular imports. clearParseCache() calls all registered callbacks. +const _cacheClearCallbacks: (() => void)[] = []; + +/** Register a callback to be invoked when clearParseCache() is called. + * Used by parsers-legacy.ts to synchronously clear its own cache. */ +export function registerCacheClearCallback(cb: () => void): void { + _cacheClearCallbacks.push(cb); +} + +/** Clear the module-scoped parse cache. Call when files change on disk. + * Also clears any registered external caches (e.g. parsers-legacy.ts). */ export function clearParseCache(): void { _parseCache.clear(); + for (const cb of _cacheClearCallbacks) cb(); } // ─── Helpers ─────────────────────────────────────────────────────────────── @@ -117,95 +127,6 @@ export function extractBoldField(text: string, key: string): string | null { return match ? match[1].trim() : null; } -// ─── Roadmap Parser ──────────────────────────────────────────────────────── - -export function parseRoadmap(content: string): Roadmap { - return cachedParse(content, 'roadmap', _parseRoadmapImpl); -} - -function _parseRoadmapImpl(content: string): Roadmap { - const stopTimer = debugTime("parse-roadmap"); - // Try native parser first for better performance - const nativeResult = nativeParseRoadmap(content); - if (nativeResult) { - stopTimer({ native: true, slices: nativeResult.slices.length, boundaryEntries: nativeResult.boundaryMap.length }); - debugCount("parseRoadmapCalls"); - return nativeResult; - } - - const lines = content.split('\n'); - - const h1 = lines.find(l => l.startsWith('# ')); - const title = h1 ? h1.slice(2).trim() : ''; - const vision = extractBoldField(content, 'Vision') || ''; - - const scSection = extractSection(content, 'Success Criteria', 2) || - (() => { - const idx = content.indexOf('**Success Criteria:**'); - if (idx === -1) return ''; - const rest = content.slice(idx); - const nextSection = rest.indexOf('\n---'); - const block = rest.slice(0, nextSection === -1 ? undefined : nextSection); - const firstNewline = block.indexOf('\n'); - return firstNewline === -1 ? '' : block.slice(firstNewline + 1); - })(); - const successCriteria = scSection ? parseBullets(scSection) : []; - - // Slices - const slices = parseRoadmapSlices(content); - - // Boundary map - const boundaryMap: BoundaryMapEntry[] = []; - const bmSection = extractSection(content, 'Boundary Map'); - - if (bmSection) { - const h3Sections = extractAllSections(bmSection, 3); - for (const [heading, sectionContent] of h3Sections) { - const arrowMatch = heading.match(/^(\S+)\s*→\s*(\S+)/); - if (!arrowMatch) continue; - - const fromSlice = arrowMatch[1]; - const toSlice = arrowMatch[2]; - - let produces = ''; - let consumes = ''; - - // Use indexOf-based parsing instead of [\s\S]*? regex to avoid - // catastrophic backtracking on content with code fences (#468). - const prodIdx = sectionContent.search(/^Produces:\s*$/m); - if (prodIdx !== -1) { - const afterProd = sectionContent.indexOf('\n', prodIdx); - if (afterProd !== -1) { - const consIdx = sectionContent.search(/^Consumes/m); - const endIdx = consIdx !== -1 && consIdx > afterProd ? consIdx : sectionContent.length; - produces = sectionContent.slice(afterProd + 1, endIdx).trim(); - } - } - - const consLineMatch = sectionContent.match(/^Consumes[^:]*:\s*(.+)$/m); - if (consLineMatch) { - consumes = consLineMatch[1].trim(); - } - if (!consumes) { - const consIdx = sectionContent.search(/^Consumes[^:]*:\s*$/m); - if (consIdx !== -1) { - const afterCons = sectionContent.indexOf('\n', consIdx); - if (afterCons !== -1) { - consumes = sectionContent.slice(afterCons + 1).trim(); - } - } - } - - boundaryMap.push({ fromSlice, toSlice, produces, consumes }); - } - } - - const result = { title, vision, successCriteria, slices, boundaryMap }; - stopTimer({ native: false, slices: slices.length, boundaryEntries: boundaryMap.length }); - debugCount("parseRoadmapCalls"); - return result; -} - // ─── Secrets Manifest Parser ─────────────────────────────────────────────── const VALID_STATUSES = new Set(['pending', 'collected', 'skipped']); @@ -314,131 +235,6 @@ export function parseTaskPlanFile(content: string): TaskPlanFile { }; } -export function parsePlan(content: string): SlicePlan { - return cachedParse(content, 'plan', _parsePlanImpl); -} - -function _parsePlanImpl(content: string): SlicePlan { - const stopTimer = debugTime("parse-plan"); - const [, body] = splitFrontmatter(content); - // Try native parser first for better performance - const nativeResult = nativeParsePlanFile(body); - if (nativeResult) { - stopTimer({ native: true }); - return { - id: nativeResult.id, - title: nativeResult.title, - goal: nativeResult.goal, - demo: nativeResult.demo, - mustHaves: nativeResult.mustHaves, - tasks: nativeResult.tasks.map(t => ({ - id: t.id, - title: t.title, - description: t.description, - done: t.done, - estimate: t.estimate, - ...(t.files.length > 0 ? { files: t.files } : {}), - ...(t.verify ? { verify: t.verify } : {}), - })), - filesLikelyTouched: nativeResult.filesLikelyTouched, - }; - } - - const lines = body.split('\n'); - - const h1 = lines.find(l => l.startsWith('# ')); - let id = ''; - let title = ''; - if (h1) { - const match = h1.match(/^#\s+(\w+):\s+(.+)/); - if (match) { - id = match[1]; - title = match[2].trim(); - } else { - title = h1.slice(2).trim(); - } - } - - const goal = extractBoldField(body, 'Goal') || ''; - const demo = extractBoldField(body, 'Demo') || ''; - - const mhSection = extractSection(body, 'Must-Haves'); - const mustHaves = mhSection ? parseBullets(mhSection) : []; - - const tasksSection = extractSection(body, 'Tasks'); - const tasks: TaskPlanEntry[] = []; - - if (tasksSection) { - const taskLines = tasksSection.split('\n'); - let currentTask: TaskPlanEntry | null = null; - - for (const line of taskLines) { - const cbMatch = line.match(/^-\s+\[([ xX])\]\s+\*\*([\w.]+):\s+(.+?)\*\*\s*(.*)/); - // Heading-style: ### T01 -- Title, ### T01: Title, ### T01 — Title - const hdMatch = !cbMatch ? line.match(/^#{2,4}\s+([\w.]+)\s*(?:--|—|:)\s*(.+)/) : null; - if (cbMatch || hdMatch) { - if (currentTask) tasks.push(currentTask); - - if (cbMatch) { - const rest = cbMatch[4] || ''; - const estMatch = rest.match(/`est:([^`]+)`/); - const estimate = estMatch ? estMatch[1] : ''; - - currentTask = { - id: cbMatch[2], - title: cbMatch[3], - description: '', - done: cbMatch[1].toLowerCase() === 'x', - estimate, - }; - } else { - const rest = hdMatch![2] || ''; - const titleEstMatch = rest.match(/^(.+?)\s*`est:([^`]+)`\s*$/); - const title = titleEstMatch ? titleEstMatch[1].trim() : rest.trim(); - const estimate = titleEstMatch ? titleEstMatch[2] : ''; - - currentTask = { - id: hdMatch![1], - title, - description: '', - done: false, - estimate, - }; - } - } else if (currentTask && line.match(/^\s*-\s+Files:\s*(.*)/)) { - const filesMatch = line.match(/^\s*-\s+Files:\s*(.*)/); - if (filesMatch) { - currentTask.files = filesMatch[1] - .split(',') - .map(f => f.replace(/`/g, '').trim()) - .filter(f => f.length > 0); - } - } else if (currentTask && line.match(/^\s*-\s+Verify:\s*(.*)/)) { - const verifyMatch = line.match(/^\s*-\s+Verify:\s*(.*)/); - if (verifyMatch) { - currentTask.verify = verifyMatch[1].trim(); - } - } else if (currentTask && line.trim() && !line.startsWith('#')) { - const desc = line.trim(); - if (desc) { - currentTask.description = currentTask.description - ? currentTask.description + ' ' + desc - : desc; - } - } - } - if (currentTask) tasks.push(currentTask); - } - - const filesSection = extractSection(body, 'Files Likely Touched'); - const filesLikelyTouched = filesSection ? parseBullets(filesSection) : []; - - const result = { id, title, goal, demo, mustHaves, tasks, filesLikelyTouched }; - stopTimer({ tasks: tasks.length }); - debugCount("parsePlanCalls"); - return result; -} - // ─── Summary Parser ──────────────────────────────────────────────────────── export function parseSummary(content: string): Summary { diff --git a/src/resources/extensions/gsd/markdown-renderer.ts b/src/resources/extensions/gsd/markdown-renderer.ts index f47432185..e6cc0fb90 100644 --- a/src/resources/extensions/gsd/markdown-renderer.ts +++ b/src/resources/extensions/gsd/markdown-renderer.ts @@ -781,10 +781,10 @@ export function detectStaleRenders(basePath: string): StaleEntry[] { const _require = createRequire(import.meta.url); let parseRoadmap: Function, parsePlan: Function; try { - const m = _require("./files.ts"); + const m = _require("./parsers-legacy.ts"); parseRoadmap = m.parseRoadmap; parsePlan = m.parsePlan; } catch { - const m = _require("./files.js"); + const m = _require("./parsers-legacy.js"); parseRoadmap = m.parseRoadmap; parsePlan = m.parsePlan; } diff --git a/src/resources/extensions/gsd/md-importer.ts b/src/resources/extensions/gsd/md-importer.ts index fcec7c300..f0ba20231 100644 --- a/src/resources/extensions/gsd/md-importer.ts +++ b/src/resources/extensions/gsd/md-importer.ts @@ -29,7 +29,8 @@ import { resolveTaskFiles, } from './paths.js'; import { findMilestoneIds } from './guided-flow.js'; -import { parseRoadmap, parsePlan, parseContextDependsOn } from './files.js'; +import { parseRoadmap, parsePlan } from './parsers-legacy.js'; +import { parseContextDependsOn } from './files.js'; // ─── DECISIONS.md Parser ─────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/parsers-legacy.ts b/src/resources/extensions/gsd/parsers-legacy.ts new file mode 100644 index 000000000..c1a00e554 --- /dev/null +++ b/src/resources/extensions/gsd/parsers-legacy.ts @@ -0,0 +1,271 @@ +// GSD Extension - Legacy Parsers +// parseRoadmap() and parsePlan() extracted from files.ts. +// Used only by: md-importer.ts (migration), state.ts (pre-migration fallback), +// markdown-renderer.ts (detectStaleRenders disk-vs-DB comparison), +// commands-maintenance.ts (cold-path branch cleanup), and tests. +// +// NOT used in the dispatch loop or any hot-path runtime code. + +import { extractSection, parseBullets, extractBoldField, extractAllSections, registerCacheClearCallback } from './files.js'; +import { splitFrontmatter } from '../shared/frontmatter.js'; +import { nativeParseRoadmap, nativeParsePlanFile } from './native-parser-bridge.js'; +import { debugTime, debugCount } from './debug-logger.js'; +import { CACHE_MAX } from './constants.js'; + +import type { + Roadmap, BoundaryMapEntry, + SlicePlan, TaskPlanEntry, +} from './types.js'; + +// Re-export parseRoadmapSlices so callers can import all legacy parsers from one module +import { parseRoadmapSlices } from './roadmap-slices.js'; +export { parseRoadmapSlices }; + +// ─── Parse Cache (local to this module) ─────────────────────────────────── + +/** Fast composite key: length + first/mid/last 100 chars. The middle sample + * prevents collisions when only a few characters change in the interior of + * a file (e.g., a checkbox [ ] → [x] that doesn't alter length or endpoints). */ +function cacheKey(content: string): string { + const len = content.length; + const head = content.slice(0, 100); + const midStart = Math.max(0, Math.floor(len / 2) - 50); + const mid = len > 200 ? content.slice(midStart, midStart + 100) : ''; + const tail = len > 100 ? content.slice(-100) : ''; + return `${len}:${head}:${mid}:${tail}`; +} + +const _parseCache = new Map(); + +function cachedParse(content: string, tag: string, parseFn: (c: string) => T): T { + const key = tag + '|' + cacheKey(content); + if (_parseCache.has(key)) return _parseCache.get(key) as T; + if (_parseCache.size >= CACHE_MAX) _parseCache.clear(); + const result = parseFn(content); + _parseCache.set(key, result); + return result; +} + +/** Clear the legacy parser cache. Called by clearParseCache() in files.ts. */ +export function clearLegacyParseCache(): void { + _parseCache.clear(); +} + +// Register with files.ts so clearParseCache() also clears our cache +registerCacheClearCallback(clearLegacyParseCache); + +// ─── Roadmap Parser ──────────────────────────────────────────────────────── + +export function parseRoadmap(content: string): Roadmap { + return cachedParse(content, 'roadmap', _parseRoadmapImpl); +} + +function _parseRoadmapImpl(content: string): Roadmap { + const stopTimer = debugTime("parse-roadmap"); + // Try native parser first for better performance + const nativeResult = nativeParseRoadmap(content); + if (nativeResult) { + stopTimer({ native: true, slices: nativeResult.slices.length, boundaryEntries: nativeResult.boundaryMap.length }); + debugCount("parseRoadmapCalls"); + return nativeResult; + } + + const lines = content.split('\n'); + + const h1 = lines.find(l => l.startsWith('# ')); + const title = h1 ? h1.slice(2).trim() : ''; + const vision = extractBoldField(content, 'Vision') || ''; + + const scSection = extractSection(content, 'Success Criteria', 2) || + (() => { + const idx = content.indexOf('**Success Criteria:**'); + if (idx === -1) return ''; + const rest = content.slice(idx); + const nextSection = rest.indexOf('\n---'); + const block = rest.slice(0, nextSection === -1 ? undefined : nextSection); + const firstNewline = block.indexOf('\n'); + return firstNewline === -1 ? '' : block.slice(firstNewline + 1); + })(); + const successCriteria = scSection ? parseBullets(scSection) : []; + + // Slices + const slices = parseRoadmapSlices(content); + + // Boundary map + const boundaryMap: BoundaryMapEntry[] = []; + const bmSection = extractSection(content, 'Boundary Map'); + + if (bmSection) { + const h3Sections = extractAllSections(bmSection, 3); + for (const [heading, sectionContent] of h3Sections) { + const arrowMatch = heading.match(/^(\S+)\s*→\s*(\S+)/); + if (!arrowMatch) continue; + + const fromSlice = arrowMatch[1]; + const toSlice = arrowMatch[2]; + + let produces = ''; + let consumes = ''; + + // Use indexOf-based parsing instead of [\s\S]*? regex to avoid + // catastrophic backtracking on content with code fences (#468). + const prodIdx = sectionContent.search(/^Produces:\s*$/m); + if (prodIdx !== -1) { + const afterProd = sectionContent.indexOf('\n', prodIdx); + if (afterProd !== -1) { + const consIdx = sectionContent.search(/^Consumes/m); + const endIdx = consIdx !== -1 && consIdx > afterProd ? consIdx : sectionContent.length; + produces = sectionContent.slice(afterProd + 1, endIdx).trim(); + } + } + + const consLineMatch = sectionContent.match(/^Consumes[^:]*:\s*(.+)$/m); + if (consLineMatch) { + consumes = consLineMatch[1].trim(); + } + if (!consumes) { + const consIdx = sectionContent.search(/^Consumes[^:]*:\s*$/m); + if (consIdx !== -1) { + const afterCons = sectionContent.indexOf('\n', consIdx); + if (afterCons !== -1) { + consumes = sectionContent.slice(afterCons + 1).trim(); + } + } + } + + boundaryMap.push({ fromSlice, toSlice, produces, consumes }); + } + } + + const result = { title, vision, successCriteria, slices, boundaryMap }; + stopTimer({ native: false, slices: slices.length, boundaryEntries: boundaryMap.length }); + debugCount("parseRoadmapCalls"); + return result; +} + +// ─── Slice Plan Parser ───────────────────────────────────────────────────── + +export function parsePlan(content: string): SlicePlan { + return cachedParse(content, 'plan', _parsePlanImpl); +} + +function _parsePlanImpl(content: string): SlicePlan { + const stopTimer = debugTime("parse-plan"); + const [, body] = splitFrontmatter(content); + // Try native parser first for better performance + const nativeResult = nativeParsePlanFile(body); + if (nativeResult) { + stopTimer({ native: true }); + return { + id: nativeResult.id, + title: nativeResult.title, + goal: nativeResult.goal, + demo: nativeResult.demo, + mustHaves: nativeResult.mustHaves, + tasks: nativeResult.tasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + done: t.done, + estimate: t.estimate, + ...(t.files.length > 0 ? { files: t.files } : {}), + ...(t.verify ? { verify: t.verify } : {}), + })), + filesLikelyTouched: nativeResult.filesLikelyTouched, + }; + } + + const lines = body.split('\n'); + + const h1 = lines.find(l => l.startsWith('# ')); + let id = ''; + let title = ''; + if (h1) { + const match = h1.match(/^#\s+(\w+):\s+(.+)/); + if (match) { + id = match[1]; + title = match[2].trim(); + } else { + title = h1.slice(2).trim(); + } + } + + const goal = extractBoldField(body, 'Goal') || ''; + const demo = extractBoldField(body, 'Demo') || ''; + + const mhSection = extractSection(body, 'Must-Haves'); + const mustHaves = mhSection ? parseBullets(mhSection) : []; + + const tasksSection = extractSection(body, 'Tasks'); + const tasks: TaskPlanEntry[] = []; + + if (tasksSection) { + const taskLines = tasksSection.split('\n'); + let currentTask: TaskPlanEntry | null = null; + + for (const line of taskLines) { + const cbMatch = line.match(/^-\s+\[([ xX])\]\s+\*\*([\w.]+):\s+(.+?)\*\*\s*(.*)/); + // Heading-style: ### T01 -- Title, ### T01: Title, ### T01 — Title + const hdMatch = !cbMatch ? line.match(/^#{2,4}\s+([\w.]+)\s*(?:--|—|:)\s*(.+)/) : null; + if (cbMatch || hdMatch) { + if (currentTask) tasks.push(currentTask); + + if (cbMatch) { + const rest = cbMatch[4] || ''; + const estMatch = rest.match(/`est:([^`]+)`/); + const estimate = estMatch ? estMatch[1] : ''; + + currentTask = { + id: cbMatch[2], + title: cbMatch[3], + description: '', + done: cbMatch[1].toLowerCase() === 'x', + estimate, + }; + } else { + const rest = hdMatch![2] || ''; + const titleEstMatch = rest.match(/^(.+?)\s*`est:([^`]+)`\s*$/); + const title = titleEstMatch ? titleEstMatch[1].trim() : rest.trim(); + const estimate = titleEstMatch ? titleEstMatch[2] : ''; + + currentTask = { + id: hdMatch![1], + title, + description: '', + done: false, + estimate, + }; + } + } else if (currentTask && line.match(/^\s*-\s+Files:\s*(.*)/)) { + const filesMatch = line.match(/^\s*-\s+Files:\s*(.*)/); + if (filesMatch) { + currentTask.files = filesMatch[1] + .split(',') + .map(f => f.replace(/`/g, '').trim()) + .filter(f => f.length > 0); + } + } else if (currentTask && line.match(/^\s*-\s+Verify:\s*(.*)/)) { + const verifyMatch = line.match(/^\s*-\s+Verify:\s*(.*)/); + if (verifyMatch) { + currentTask.verify = verifyMatch[1].trim(); + } + } else if (currentTask && line.trim() && !line.startsWith('#')) { + const desc = line.trim(); + if (desc) { + currentTask.description = currentTask.description + ? currentTask.description + ' ' + desc + : desc; + } + } + } + if (currentTask) tasks.push(currentTask); + } + + const filesSection = extractSection(body, 'Files Likely Touched'); + const filesLikelyTouched = filesSection ? parseBullets(filesSection) : []; + + const result = { id, title, goal, demo, mustHaves, tasks, filesLikelyTouched }; + stopTimer({ tasks: tasks.length }); + debugCount("parsePlanCalls"); + return result; +} diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 5b70699aa..aca92bc8e 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -14,6 +14,9 @@ import type { import { parseRoadmap, parsePlan, +} from './parsers-legacy.js'; + +import { parseSummary, loadFile, parseRequirementCounts, diff --git a/src/resources/extensions/gsd/tests/auto-recovery.test.ts b/src/resources/extensions/gsd/tests/auto-recovery.test.ts index 8c36c8cfe..a216c8a8d 100644 --- a/src/resources/extensions/gsd/tests/auto-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/auto-recovery.test.ts @@ -13,7 +13,8 @@ import { selfHealRuntimeRecords, hasImplementationArtifacts, } from "../auto-recovery.ts"; -import { parseRoadmap, parsePlan, parseTaskPlanFile, clearParseCache } from "../files.ts"; +import { parseRoadmap, parsePlan } from "../parsers-legacy.ts"; +import { parseTaskPlanFile, clearParseCache } from "../files.ts"; import { invalidateAllCaches } from "../cache.ts"; import { deriveState, invalidateStateCache } from "../state.ts"; import { diff --git a/src/resources/extensions/gsd/tests/complete-milestone.test.ts b/src/resources/extensions/gsd/tests/complete-milestone.test.ts index 31c77e054..1216c0908 100644 --- a/src/resources/extensions/gsd/tests/complete-milestone.test.ts +++ b/src/resources/extensions/gsd/tests/complete-milestone.test.ts @@ -158,7 +158,7 @@ async function main(): Promise { { const { deriveState, isMilestoneComplete } = await import("../state.ts"); const { invalidateAllCaches: invalidateAllCachesDynamic } = await import("../cache.ts"); - const { parseRoadmap } = await import("../files.ts"); + const { parseRoadmap } = await import("../parsers-legacy.ts"); const base = createFixtureBase(); try { diff --git a/src/resources/extensions/gsd/tests/markdown-renderer.test.ts b/src/resources/extensions/gsd/tests/markdown-renderer.test.ts index ccb00cb7b..f7896d9ac 100644 --- a/src/resources/extensions/gsd/tests/markdown-renderer.test.ts +++ b/src/resources/extensions/gsd/tests/markdown-renderer.test.ts @@ -30,6 +30,8 @@ import { import { parseRoadmap, parsePlan, +} from '../parsers-legacy.ts'; +import { parseSummary, parseTaskPlanFile, clearParseCache, diff --git a/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts b/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts index fca6a533b..96deac0a7 100644 --- a/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +++ b/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts @@ -9,7 +9,8 @@ import { tmpdir } from 'node:os'; import { writeGSDDirectory } from '../migrate/writer.ts'; import { generatePreview } from '../migrate/preview.ts'; -import { parseRoadmap, parsePlan, parseSummary } from '../files.ts'; +import { parseRoadmap, parsePlan } from '../parsers-legacy.ts'; +import { parseSummary } from '../files.ts'; import { deriveState } from '../state.ts'; import { invalidateAllCaches } from '../cache.ts'; import type { diff --git a/src/resources/extensions/gsd/tests/migrate-writer.test.ts b/src/resources/extensions/gsd/tests/migrate-writer.test.ts index 53ce74a52..c779f2e31 100644 --- a/src/resources/extensions/gsd/tests/migrate-writer.test.ts +++ b/src/resources/extensions/gsd/tests/migrate-writer.test.ts @@ -18,6 +18,8 @@ import { import { parseRoadmap, parsePlan, +} from '../parsers-legacy.ts'; +import { parseSummary, parseRequirementCounts, } from '../files.ts'; diff --git a/src/resources/extensions/gsd/tests/parsers.test.ts b/src/resources/extensions/gsd/tests/parsers.test.ts index 144b95857..7325e9916 100644 --- a/src/resources/extensions/gsd/tests/parsers.test.ts +++ b/src/resources/extensions/gsd/tests/parsers.test.ts @@ -1,4 +1,5 @@ -import { parseRoadmap, parsePlan, parseTaskPlanFile, parseSummary, parseContinue, parseRequirementCounts, parseSecretsManifest, formatSecretsManifest } from '../files.ts'; +import { parseRoadmap, parsePlan } from '../parsers-legacy.ts'; +import { parseTaskPlanFile, parseSummary, parseContinue, parseRequirementCounts, parseSecretsManifest, formatSecretsManifest } from '../files.ts'; import { createTestContext } from './test-helpers.ts'; const { assertEq, assertTrue, report } = createTestContext(); diff --git a/src/resources/extensions/gsd/tests/planning-crossval.test.ts b/src/resources/extensions/gsd/tests/planning-crossval.test.ts index 38f68d14d..1fe06da00 100644 --- a/src/resources/extensions/gsd/tests/planning-crossval.test.ts +++ b/src/resources/extensions/gsd/tests/planning-crossval.test.ts @@ -21,7 +21,7 @@ import { renderPlanFromDb, } from '../markdown-renderer.ts'; import { parseRoadmapSlices } from '../roadmap-slices.ts'; -import { parsePlan } from '../files.ts'; +import { parsePlan } from '../parsers-legacy.ts'; import { createTestContext } from './test-helpers.ts'; const { assertEq, assertTrue, report } = createTestContext(); diff --git a/src/resources/extensions/gsd/tests/roadmap-slices.test.ts b/src/resources/extensions/gsd/tests/roadmap-slices.test.ts index 3a954d353..f326dd858 100644 --- a/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +++ b/src/resources/extensions/gsd/tests/roadmap-slices.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { parseRoadmap } from "../files.ts"; +import { parseRoadmap } from "../parsers-legacy.ts"; import { parseRoadmapSlices, expandDependencies } from "../roadmap-slices.ts"; const content = `# M003: Current