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>
This commit is contained in:
parent
c4183b8b4d
commit
205cdbf77f
13 changed files with 1224 additions and 0 deletions
117
plugins/ultraplan-local/lib/parsers/arg-parser.mjs
Normal file
117
plugins/ultraplan-local/lib/parsers/arg-parser.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// lib/parsers/arg-parser.mjs
|
||||
// Parse $ARGUMENTS strings for the four ultra commands.
|
||||
//
|
||||
// Each command has its own valid-flag set; passing flags from another command
|
||||
// produces an `unknown_flags` array but does not error — the caller decides.
|
||||
|
||||
const FLAG_SCHEMA = {
|
||||
ultrabrief: {
|
||||
boolean: ['--quick', '--fg'],
|
||||
valued: [],
|
||||
aliases: {},
|
||||
},
|
||||
ultraresearch: {
|
||||
boolean: ['--quick', '--local', '--external', '--fg'],
|
||||
valued: ['--project'],
|
||||
aliases: {},
|
||||
},
|
||||
ultraplan: {
|
||||
boolean: ['--quick', '--fg'],
|
||||
valued: ['--project', '--brief', '--export', '--decompose'],
|
||||
multi: ['--research'],
|
||||
aliases: {},
|
||||
},
|
||||
ultraexecute: {
|
||||
boolean: ['--resume', '--dry-run', '--validate', '--fg'],
|
||||
valued: ['--project', '--step', '--session'],
|
||||
aliases: {},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} argString Raw $ARGUMENTS as the command sees it.
|
||||
* @param {keyof FLAG_SCHEMA} command
|
||||
* @returns {{
|
||||
* command: string,
|
||||
* flags: Record<string, true | string | string[]>,
|
||||
* positional: string[],
|
||||
* unknown: string[],
|
||||
* errors: Array<{code: string, message: string}>,
|
||||
* }}
|
||||
*/
|
||||
export function parseArgs(argString, command) {
|
||||
const schema = FLAG_SCHEMA[command];
|
||||
if (!schema) {
|
||||
return {
|
||||
command,
|
||||
flags: {},
|
||||
positional: [],
|
||||
unknown: [],
|
||||
errors: [{ code: 'ARG_UNKNOWN_COMMAND', message: `Unknown command: ${command}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const tokens = tokenize(argString);
|
||||
const flags = {};
|
||||
const positional = [];
|
||||
const unknown = [];
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const tok = tokens[i];
|
||||
|
||||
if (!tok.startsWith('--')) {
|
||||
positional.push(tok);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (schema.boolean.includes(tok)) {
|
||||
flags[tok] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (schema.valued.includes(tok)) {
|
||||
const next = tokens[i + 1];
|
||||
if (next === undefined || next.startsWith('--')) {
|
||||
errors.push({ code: 'ARG_MISSING_VALUE', message: `Flag ${tok} requires a value` });
|
||||
} else {
|
||||
flags[tok] = next;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (schema.multi && schema.multi.includes(tok)) {
|
||||
const collected = [];
|
||||
while (i + 1 < tokens.length && !tokens[i + 1].startsWith('--')) {
|
||||
collected.push(tokens[i + 1]);
|
||||
i++;
|
||||
}
|
||||
if (collected.length === 0) {
|
||||
errors.push({ code: 'ARG_MISSING_VALUE', message: `Flag ${tok} requires at least one value` });
|
||||
} else {
|
||||
flags[tok] = collected;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
unknown.push(tok);
|
||||
}
|
||||
|
||||
return { command, flags, positional, unknown, errors };
|
||||
}
|
||||
|
||||
function tokenize(s) {
|
||||
if (typeof s !== 'string') return [];
|
||||
const trimmed = s.trim();
|
||||
if (trimmed === '') return [];
|
||||
const out = [];
|
||||
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(trimmed)) !== null) {
|
||||
out.push(m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export { FLAG_SCHEMA };
|
||||
48
plugins/ultraplan-local/lib/parsers/bash-normalize.mjs
Normal file
48
plugins/ultraplan-local/lib/parsers/bash-normalize.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// lib/parsers/bash-normalize.mjs
|
||||
// Bash-evasion normalization, lifted from hooks/scripts/pre-bash-executor.mjs.
|
||||
//
|
||||
// Source: ../../hooks/scripts/pre-bash-executor.mjs (lines 22-45) — verbatim
|
||||
// extraction so the runtime hook and the test suite share one implementation.
|
||||
// The hook still inlines a copy because it cannot import from outside the
|
||||
// plugin distribution at this time; both copies must stay in sync.
|
||||
|
||||
/**
|
||||
* Strip bash evasion techniques: empty quotes, ${} expansion, backslash splitting.
|
||||
* Used to canonicalize a command before running denylist regex over it.
|
||||
*/
|
||||
export function normalizeBashExpansion(cmd) {
|
||||
if (typeof cmd !== 'string' || cmd === '') return '';
|
||||
|
||||
let result = cmd
|
||||
.replace(/''/g, '')
|
||||
.replace(/""/g, '')
|
||||
.replace(/\$\{(\w)\}/g, '$1')
|
||||
.replace(/\$\{[^}]*\}/g, '')
|
||||
.replace(/`\s*`/g, '');
|
||||
|
||||
let prev;
|
||||
do {
|
||||
prev = result;
|
||||
result = result.replace(/(\w)\\(\w)/g, '$1$2');
|
||||
} while (result !== prev);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes and collapse whitespace.
|
||||
*/
|
||||
export function normalizeCommand(cmd) {
|
||||
if (typeof cmd !== 'string') return '';
|
||||
return cmd
|
||||
.replace(/\x1B\[[0-9;]*m/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full canonicalization pipeline used by hooks before pattern matching.
|
||||
*/
|
||||
export function canonicalize(cmd) {
|
||||
return normalizeCommand(normalizeBashExpansion(cmd));
|
||||
}
|
||||
118
plugins/ultraplan-local/lib/parsers/manifest-yaml.mjs
Normal file
118
plugins/ultraplan-local/lib/parsers/manifest-yaml.mjs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// 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 };
|
||||
}
|
||||
126
plugins/ultraplan-local/lib/parsers/plan-schema.mjs
Normal file
126
plugins/ultraplan-local/lib/parsers/plan-schema.mjs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// lib/parsers/plan-schema.mjs
|
||||
// Plan v1.7 schema parser — heading shape detection.
|
||||
//
|
||||
// The canonical step heading is `### Step N: <title>` (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 } };
|
||||
}
|
||||
89
plugins/ultraplan-local/lib/parsers/project-discovery.mjs
Normal file
89
plugins/ultraplan-local/lib/parsers/project-discovery.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// lib/parsers/project-discovery.mjs
|
||||
// Discover ultra-suite artifacts inside a project directory.
|
||||
//
|
||||
// Layout (post-v3.0.0 project-directory contract):
|
||||
// .claude/projects/<YYYY-MM-DD>-<slug>/
|
||||
// brief.md
|
||||
// research/<NN>-<slug>.md (sorted by filename)
|
||||
// architecture/overview.md (opt-in, owned by separate ultra-cc-architect plugin)
|
||||
// plan.md
|
||||
// progress.json
|
||||
|
||||
import { existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* projectDir: string,
|
||||
* brief: string|null,
|
||||
* research: string[],
|
||||
* architecture: { overview: string|null, gaps: string|null, looseFiles: string[] },
|
||||
* plan: string|null,
|
||||
* progress: string|null,
|
||||
* }} ProjectArtifacts
|
||||
*/
|
||||
|
||||
/** @returns {ProjectArtifacts} */
|
||||
export function discoverProject(projectDir) {
|
||||
const out = {
|
||||
projectDir,
|
||||
brief: null,
|
||||
research: [],
|
||||
architecture: { overview: null, gaps: null, looseFiles: [] },
|
||||
plan: null,
|
||||
progress: null,
|
||||
};
|
||||
|
||||
if (!projectDir || !existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
|
||||
return out;
|
||||
}
|
||||
|
||||
const briefPath = join(projectDir, 'brief.md');
|
||||
if (existsSync(briefPath) && statSync(briefPath).isFile()) out.brief = briefPath;
|
||||
|
||||
const planPath = join(projectDir, 'plan.md');
|
||||
if (existsSync(planPath) && statSync(planPath).isFile()) out.plan = planPath;
|
||||
|
||||
const progressPath = join(projectDir, 'progress.json');
|
||||
if (existsSync(progressPath) && statSync(progressPath).isFile()) out.progress = progressPath;
|
||||
|
||||
const researchDir = join(projectDir, 'research');
|
||||
if (existsSync(researchDir) && statSync(researchDir).isDirectory()) {
|
||||
out.research = readdirSync(researchDir)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.sort()
|
||||
.map(f => join(researchDir, f));
|
||||
}
|
||||
|
||||
const archDir = join(projectDir, 'architecture');
|
||||
if (existsSync(archDir) && statSync(archDir).isDirectory()) {
|
||||
const overviewPath = join(archDir, 'overview.md');
|
||||
const gapsPath = join(archDir, 'gaps.md');
|
||||
if (existsSync(overviewPath)) out.architecture.overview = overviewPath;
|
||||
if (existsSync(gapsPath)) out.architecture.gaps = gapsPath;
|
||||
const all = readdirSync(archDir).filter(f => f.endsWith('.md'));
|
||||
out.architecture.looseFiles = all
|
||||
.filter(f => f !== 'overview.md' && f !== 'gaps.md')
|
||||
.map(f => join(archDir, f));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that artifact set is consistent for a given pipeline phase.
|
||||
* Phase = 'brief' | 'research' | 'plan' | 'execute'.
|
||||
*/
|
||||
export function checkPhaseRequirements(artifacts, phase) {
|
||||
const errors = [];
|
||||
if (phase === 'research' && !artifacts.brief) {
|
||||
errors.push({ code: 'PROJECT_NO_BRIEF', message: 'research phase requires brief.md' });
|
||||
}
|
||||
if (phase === 'plan' && !artifacts.brief) {
|
||||
errors.push({ code: 'PROJECT_NO_BRIEF', message: 'plan phase requires brief.md' });
|
||||
}
|
||||
if (phase === 'execute' && !artifacts.plan) {
|
||||
errors.push({ code: 'PROJECT_NO_PLAN', message: 'execute phase requires plan.md' });
|
||||
}
|
||||
return { valid: errors.length === 0, errors, warnings: [], parsed: artifacts };
|
||||
}
|
||||
138
plugins/ultraplan-local/lib/util/frontmatter.mjs
Normal file
138
plugins/ultraplan-local/lib/util/frontmatter.mjs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// lib/util/frontmatter.mjs
|
||||
// Hand-rolled YAML-frontmatter parser.
|
||||
//
|
||||
// Supported subset:
|
||||
// - String scalars (quoted or unquoted)
|
||||
// - Numbers (integer + float)
|
||||
// - Booleans (true / false)
|
||||
// - null
|
||||
// - Single-level dicts
|
||||
// - Lists of scalars (- value)
|
||||
//
|
||||
// Deliberately rejects: nested dicts in lists, multi-line strings,
|
||||
// anchors/aliases, tags, flow style ({...} / [...]).
|
||||
//
|
||||
// Why no js-yaml: zero-deps invariant. Templates emit only this subset.
|
||||
|
||||
import { issue, ok, fail } from './result.mjs';
|
||||
|
||||
const FRONTMATTER_RE = /^?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/;
|
||||
|
||||
/**
|
||||
* Split raw markdown into { frontmatter, body }.
|
||||
* Returns { hasFrontmatter: false } when no leading --- block exists.
|
||||
*/
|
||||
export function splitFrontmatter(text) {
|
||||
if (typeof text !== 'string') return { hasFrontmatter: false, body: '' };
|
||||
const stripped = text.replace(/^/, '');
|
||||
const m = stripped.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
|
||||
if (!m) return { hasFrontmatter: false, body: stripped };
|
||||
return {
|
||||
hasFrontmatter: true,
|
||||
frontmatter: m[1],
|
||||
body: m[2] || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a YAML-frontmatter string into a JS object.
|
||||
* @returns {import('./result.mjs').Result}
|
||||
*/
|
||||
export function parseFrontmatter(yamlText) {
|
||||
if (typeof yamlText !== 'string') {
|
||||
return fail(issue('FM_INPUT', 'Frontmatter input is not a string'));
|
||||
}
|
||||
const lines = yamlText.split(/\r?\n/);
|
||||
const out = {};
|
||||
const errors = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.trim() === '' || line.trimStart().startsWith('#')) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const indentMatch = line.match(/^(\s*)/);
|
||||
const indent = indentMatch ? indentMatch[0].length : 0;
|
||||
if (indent > 0) {
|
||||
errors.push(issue('FM_INDENT', `Unexpected indentation at line ${i + 1}`, 'Top-level keys only; nested dicts unsupported.'));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
||||
if (!kv) {
|
||||
errors.push(issue('FM_SYNTAX', `Cannot parse line ${i + 1}: ${line}`));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = kv[1];
|
||||
const rest = kv[2];
|
||||
|
||||
if (rest === '' || rest === undefined) {
|
||||
const list = [];
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const next = lines[j];
|
||||
if (next.trim() === '') { j++; continue; }
|
||||
const m2 = next.match(/^\s+-\s+(.*)$/);
|
||||
if (!m2) break;
|
||||
list.push(parseScalar(m2[1]));
|
||||
j++;
|
||||
}
|
||||
if (list.length > 0) {
|
||||
out[key] = list;
|
||||
i = j;
|
||||
} else {
|
||||
out[key] = null;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
out[key] = parseScalar(rest);
|
||||
i++;
|
||||
}
|
||||
|
||||
if (errors.length > 0) return { valid: false, errors, warnings: [], parsed: out };
|
||||
return ok(out);
|
||||
}
|
||||
|
||||
function parseScalar(raw) {
|
||||
const s = raw.trim();
|
||||
if (s === '') return '';
|
||||
if (s === 'null' || s === '~') return null;
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
if (s === '[]') return [];
|
||||
if (s === '{}') return {};
|
||||
if (/^-?\d+$/.test(s)) return Number.parseInt(s, 10);
|
||||
if (/^-?\d+\.\d+$/.test(s)) return Number.parseFloat(s);
|
||||
if (s.startsWith('"') && s.endsWith('"')) {
|
||||
return s.slice(1, -1).replace(/\\(.)/g, (_, ch) => {
|
||||
if (ch === 'n') return '\n';
|
||||
if (ch === 't') return '\t';
|
||||
if (ch === 'r') return '\r';
|
||||
return ch;
|
||||
});
|
||||
}
|
||||
if (s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1);
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a markdown file's frontmatter directly from its full text.
|
||||
* @returns {import('./result.mjs').Result}
|
||||
*/
|
||||
export function parseDocument(text) {
|
||||
const split = splitFrontmatter(text);
|
||||
if (!split.hasFrontmatter) {
|
||||
return fail(issue('FM_MISSING', 'No frontmatter block found'));
|
||||
}
|
||||
const result = parseFrontmatter(split.frontmatter);
|
||||
return { ...result, parsed: { frontmatter: result.parsed, body: split.body } };
|
||||
}
|
||||
35
plugins/ultraplan-local/lib/util/result.mjs
Normal file
35
plugins/ultraplan-local/lib/util/result.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// lib/util/result.mjs
|
||||
// Validation result shape used by every validator and parser.
|
||||
|
||||
/**
|
||||
* @typedef {{ code: string, message: string, hint?: string, location?: string }} Issue
|
||||
* @typedef {{ valid: boolean, errors: Issue[], warnings: Issue[], parsed?: any }} Result
|
||||
*/
|
||||
|
||||
/** @returns {Result} */
|
||||
export function ok(parsed) {
|
||||
return { valid: true, errors: [], warnings: [], parsed };
|
||||
}
|
||||
|
||||
/** @returns {Result} */
|
||||
export function fail(errors, parsed) {
|
||||
return { valid: false, errors: Array.isArray(errors) ? errors : [errors], warnings: [], parsed };
|
||||
}
|
||||
|
||||
/** @returns {Result} */
|
||||
export function combine(results) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
let parsed;
|
||||
for (const r of results) {
|
||||
if (r.errors) errors.push(...r.errors);
|
||||
if (r.warnings) warnings.push(...r.warnings);
|
||||
if (r.parsed !== undefined && parsed === undefined) parsed = r.parsed;
|
||||
}
|
||||
return { valid: errors.length === 0, errors, warnings, parsed };
|
||||
}
|
||||
|
||||
/** @returns {Issue} */
|
||||
export function issue(code, message, hint, location) {
|
||||
return { code, message, hint, location };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue