// 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', ]; // Optional manifest keys (plan-v2 Step 4). Absence == false. // `skip_commit_check`: opt out of the per-step commit assertion (e.g. memory-only steps). // `memory_write` : marks a step that writes to ~/.claude/projects/.../memory/ // so the executor can route it through the memory truth gate. const OPTIONAL_KEYS = [ 'skip_commit_check', 'memory_write', ]; const OPTIONAL_BOOLEAN_KEYS = new Set(OPTIONAL_KEYS); // Optional string-typed manifest keys (v4.1 Step 3 — additive forward-compat). // `profile_used`: name of the model profile (economy|balanced|premium|) the // step was executed under. Absence is fine (v4.0 manifests have no // profile concept); presence MUST be a string. // Unlike OPTIONAL_BOOLEAN_KEYS, absence is NOT defaulted — the field is simply // missing from `parsed` so downstream consumers can distinguish "not set" from // "explicitly empty string". const OPTIONAL_STRING_KEYS = [ 'profile_used', ]; const OPTIONAL_STRING_KEYS_SET = new Set(OPTIONAL_STRING_KEYS); export { OPTIONAL_KEYS, OPTIONAL_STRING_KEYS }; /** * 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')); } for (const k of OPTIONAL_BOOLEAN_KEYS) { if (k in parsed) { if (typeof parsed[k] !== 'boolean') { errors.push(issue( 'MANIFEST_OPTIONAL_TYPE', `${k} must be boolean if present (got ${typeof parsed[k]})`, )); } } else { parsed[k] = false; // default: absence == false } } // v4.1 Step 3 — string-typed optional keys. Absence == not-present (no default, // unlike boolean keys above) so downstream can distinguish unset vs empty string. for (const k of OPTIONAL_STRING_KEYS_SET) { if (k in parsed) { if (typeof parsed[k] !== 'string') { errors.push(issue( 'MANIFEST_OPTIONAL_TYPE', `${k} must be string if present (got ${typeof parsed[k]})`, )); } } } 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 }; }