// lib/parsers/manifest-yaml.mjs // Extract the `manifest:` YAML block from each step body. // // Plan v1.7 contract: every step has a fenced ```yaml ... ``` block whose // top-level key is `manifest:` and which contains the keys: // expected_paths, min_file_count, commit_message_pattern, bash_syntax_check, // forbidden_paths, must_contain. import { issue, ok, fail } from '../util/result.mjs'; import { parseFrontmatter } from '../util/frontmatter.mjs'; const FENCED_YAML_RE = /```ya?ml\s*\n([\s\S]*?)\n[ \t]*```/g; const REQUIRED_KEYS = [ 'expected_paths', 'min_file_count', 'commit_message_pattern', 'bash_syntax_check', 'forbidden_paths', 'must_contain', ]; /** * Extract the first fenced YAML block whose first non-blank line begins with * `manifest:`. * @returns {string|null} Inner YAML body without the leading `manifest:` line. */ export function extractManifestYaml(stepBody) { if (typeof stepBody !== 'string') return null; FENCED_YAML_RE.lastIndex = 0; let m; while ((m = FENCED_YAML_RE.exec(stepBody)) !== null) { const block = m[1]; const firstNonBlank = block.split(/\r?\n/).find(l => l.trim() !== ''); if (firstNonBlank && /^manifest\s*:/.test(firstNonBlank.trim())) { const after = block.replace(/^[\s\S]*?manifest[ \t]*:[ \t]*\n?/, ''); return after; } } return null; } /** * Parse a single step's manifest into an object. * Reuses the frontmatter parser (same restricted YAML subset). * @returns {import('../util/result.mjs').Result} */ export function parseManifest(stepBody) { const yamlText = extractManifestYaml(stepBody); if (yamlText === null) { return fail(issue('MANIFEST_MISSING', 'No `manifest:` YAML block found in step body')); } const dedented = dedent(yamlText); const result = parseFrontmatter(dedented); if (!result.valid) return result; const errors = []; const warnings = []; const parsed = result.parsed || {}; for (const k of REQUIRED_KEYS) { if (!(k in parsed)) { errors.push(issue('MANIFEST_MISSING_KEY', `Manifest is missing required key: ${k}`)); } } if ('commit_message_pattern' in parsed) { const pat = parsed.commit_message_pattern; if (typeof pat !== 'string') { errors.push(issue('MANIFEST_PATTERN_TYPE', 'commit_message_pattern must be a string')); } else { try { new RegExp(pat); } catch (e) { errors.push(issue('MANIFEST_PATTERN_INVALID', `commit_message_pattern is not a valid regex: ${e.message}`)); } } } if ('expected_paths' in parsed && !Array.isArray(parsed.expected_paths)) { errors.push(issue('MANIFEST_PATHS_TYPE', 'expected_paths must be a list')); } if ('min_file_count' in parsed && typeof parsed.min_file_count !== 'number') { errors.push(issue('MANIFEST_COUNT_TYPE', 'min_file_count must be a number')); } return { valid: errors.length === 0, errors, warnings, parsed }; } function dedent(text) { const lines = text.split(/\r?\n/); const indents = lines .filter(l => l.trim() !== '') .map(l => (l.match(/^(\s*)/) || ['', ''])[1].length); if (indents.length === 0) return text; const min = Math.min(...indents); if (min === 0) return text; return lines.map(l => l.slice(min)).join('\n'); } /** * Validate every step in a parsed plan has a manifest. * @param {Array<{n: number, body: string}>} steps * @returns {import('../util/result.mjs').Result} */ export function validateAllManifests(steps) { const errors = []; const warnings = []; const parsed = []; for (const s of steps) { const r = parseManifest(s.body); if (!r.valid) { for (const e of r.errors) errors.push(issue(e.code, `Step ${s.n}: ${e.message}`, e.hint)); } parsed.push({ n: s.n, manifest: r.parsed, valid: r.valid }); } return { valid: errors.length === 0, errors, warnings, parsed }; }