// lib/parsers/plan-schema.mjs // Plan v1.7 schema parser — heading shape detection. // // The canonical step heading is `### Step N: ` (literal colon-space). // Forbidden narrative drift formats (introduced in v1.8.0 to defend against // Opus 4.7 schema-drift): `## Fase N`, `### Phase N`, `### Stage N`, `### Steg N`. // // This module extracts step boundaries; per-step body parsing lives elsewhere. import { ok, fail, issue } from '../util/result.mjs'; export const STEP_HEADING_REGEX = /^### Step (\d+):\s+(.+?)\s*$/m; export const STEP_HEADING_GLOBAL = /^### Step (\d+):\s+(.+?)\s*$/gm; export const FORBIDDEN_HEADING_REGEX = /^(?:##|###) (?:Fase|Phase|Stage|Steg) \d+/m; export const FORBIDDEN_HEADING_GLOBAL = /^(?:##|###) (?:Fase|Phase|Stage|Steg) \d+/gm; export const PLAN_VERSION_REGEX = /^plan_version:\s*['"]?([\d.]+)['"]?/m; /** * Find all step heading positions in plan text. * @returns {Array<{n: number, title: string, line: number, offset: number}>} */ export function findSteps(text) { if (typeof text !== 'string') return []; const out = []; STEP_HEADING_GLOBAL.lastIndex = 0; let m; while ((m = STEP_HEADING_GLOBAL.exec(text)) !== null) { const offset = m.index; const line = text.slice(0, offset).split(/\r?\n/).length; out.push({ n: Number.parseInt(m[1], 10), title: m[2].trim(), line, offset }); } return out; } /** * Find forbidden narrative-drift heading occurrences (Fase/Phase/Stage/Steg N). * @returns {Array<{form: string, line: number, offset: number, raw: string}>} */ export function findForbiddenHeadings(text) { if (typeof text !== 'string') return []; const out = []; FORBIDDEN_HEADING_GLOBAL.lastIndex = 0; let m; while ((m = FORBIDDEN_HEADING_GLOBAL.exec(text)) !== null) { const offset = m.index; const line = text.slice(0, offset).split(/\r?\n/).length; const raw = m[0]; out.push({ form: raw, line, offset, raw }); } return out; } /** * Slice plan text into per-step sections. * @returns {Array<{n: number, title: string, body: string, line: number}>} */ export function sliceSteps(text) { const heads = findSteps(text); const sections = []; for (let i = 0; i < heads.length; i++) { const start = heads[i].offset; const end = i + 1 < heads.length ? heads[i + 1].offset : text.length; const block = text.slice(start, end); sections.push({ n: heads[i].n, title: heads[i].title, body: block, line: heads[i].line, }); } return sections; } /** * Extract `plan_version: X.Y` from frontmatter or doc body. */ export function extractPlanVersion(text) { const m = typeof text === 'string' ? text.match(PLAN_VERSION_REGEX) : null; return m ? m[1] : null; } /** * Validate plan structure at the heading level. * Strict mode: forbidden-heading count > 0 → error. Step numbers must be 1..N contiguous. * @returns {import('../util/result.mjs').Result} */ export function validatePlanHeadings(text, opts = {}) { const strict = opts.strict !== false; const errors = []; const warnings = []; if (typeof text !== 'string') { return fail(issue('PLAN_INPUT', 'Plan text is not a string')); } const forbidden = findForbiddenHeadings(text); if (forbidden.length > 0) { const list = forbidden.map(f => `line ${f.line}: ${f.raw}`).join('; '); const errorIssue = issue( 'PLAN_FORBIDDEN_HEADING', `Found ${forbidden.length} forbidden narrative-drift heading(s): ${list}`, 'Use canonical "### Step N: <title>". Forbidden forms: Fase/Phase/Stage/Steg.', ); if (strict) errors.push(errorIssue); else warnings.push(errorIssue); } const steps = findSteps(text); if (steps.length === 0) { errors.push(issue('PLAN_NO_STEPS', 'No step headings found', 'Expected at least one "### Step 1: <title>".')); } else { const numbers = steps.map(s => s.n); for (let i = 0; i < numbers.length; i++) { if (numbers[i] !== i + 1) { errors.push(issue( 'PLAN_STEP_NUMBERING', `Step numbering breaks at position ${i + 1} (got Step ${numbers[i]})`, 'Steps must be 1..N contiguous and ordered.', )); break; } } } return { valid: errors.length === 0, errors, warnings, parsed: { steps, forbidden } }; }