Step 3 av v4.1-execute (Wave 1, Session 1). Legg ny eksportert const OPTIONAL_STRING_KEYS = ['profile_used'] parallel til eksisterende OPTIONAL_KEYS. Utvid parseManifest med ny dispatch-loop etter OPTIONAL_BOOLEAN_KEYS. Returnerer MANIFEST_OPTIONAL_TYPE hvis profile_used finnes men ikke er string. Forskjell fra OPTIONAL_BOOLEAN_KEYS: absence == not-present (NOT defaulted til false, unlike boolean). Downstream-konsumenter kan dermed skille mellom unset og empty-string. Tester (5 nye, baseline 372 → 377): - OPTIONAL_STRING_KEYS export drift-pin - profile_used: economy parses successfully (SC #10 forward-compat) - profile_used: numeric rejected - absence: field NOT in parsed (string-key semantics) - profile_used + skip_commit_check + memory_write co-existence Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
170 lines
5.6 KiB
JavaScript
170 lines
5.6 KiB
JavaScript
// 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|<custom>) 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 };
|
|
}
|