ktg-plugin-marketplace/plugins/ultraplan-local/lib/parsers/manifest-yaml.mjs
Kjell Tore Guttormsen 205cdbf77f feat(ultraplan-local): Spor 1 wave 1 — lib/parsers + 66 tests grønn
7 nye moduler:
- lib/util/result.mjs — Result-shape m/ ok/fail/combine helpers
- lib/util/frontmatter.mjs — håndruller YAML-frontmatter-parser (subset, zero deps)
- lib/parsers/plan-schema.mjs — v1.7 step-regex + forbidden-heading-deteksjon (Fase/Phase/Stage/Steg)
- lib/parsers/manifest-yaml.mjs — per-step Manifest YAML-ekstraksjon m/ regex-validering
- lib/parsers/project-discovery.mjs — finn brief/research/architecture/plan/progress i prosjektmappe
- lib/parsers/arg-parser.mjs — $ARGUMENTS for alle 4 commands m/ flag-schema
- lib/parsers/bash-normalize.mjs — løftet fra hooks/scripts/pre-bash-executor.mjs

6 test-filer (66 tester totalt) — alle grønn:
- frontmatter (CRLF/BOM, scalars, lister, indent-rejection)
- plan-schema (positive Step-form, negative Fase/Phase/Stage/Steg, numbering, slicing)
- manifest-yaml (extraction, parsing, regex-validering, missing-key detection)
- project-discovery (sortert research, architecture-detection, phase-requirements)
- arg-parser (boolean/valued/multi-value flags, kvotert positional, ukjente flag)
- bash-normalize (\${x}/\\\\evasion, ANSI-stripping, full canonicalize-pipeline)

Forbereder Wave 2 (validators) og Spor 1-wiring inn i commands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 05:35:28 +02:00

118 lines
3.8 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',
];
/**
* 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 };
}