test(S06/T01): Extract parseRoadmap/parsePlan into parsers-legacy.ts, u…
- 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
This commit is contained in:
parent
3af95e601b
commit
56efa72886
16 changed files with 321 additions and 235 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<T>(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<SecretsManifestEntryStatus>(['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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
271
src/resources/extensions/gsd/parsers-legacy.ts
Normal file
271
src/resources/extensions/gsd/parsers-legacy.ts
Normal file
|
|
@ -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<string, unknown>();
|
||||
|
||||
function cachedParse<T>(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;
|
||||
}
|
||||
|
|
@ -14,6 +14,9 @@ import type {
|
|||
import {
|
||||
parseRoadmap,
|
||||
parsePlan,
|
||||
} from './parsers-legacy.js';
|
||||
|
||||
import {
|
||||
parseSummary,
|
||||
loadFile,
|
||||
parseRequirementCounts,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ async function main(): Promise<void> {
|
|||
{
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ import {
|
|||
import {
|
||||
parseRoadmap,
|
||||
parsePlan,
|
||||
} from '../parsers-legacy.ts';
|
||||
import {
|
||||
parseSummary,
|
||||
parseTaskPlanFile,
|
||||
clearParseCache,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import {
|
|||
import {
|
||||
parseRoadmap,
|
||||
parsePlan,
|
||||
} from '../parsers-legacy.ts';
|
||||
import {
|
||||
parseSummary,
|
||||
parseRequirementCounts,
|
||||
} from '../files.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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue