feat(voyage)!: marketplace handoff — rename plugins/ultraplan-local to plugins/voyage [skip-docs]
Session 5 of voyage-rebrand (V6). Operator-authorized cross-plugin scope. - git mv plugins/ultraplan-local plugins/voyage (rename detected, history preserved) - .claude-plugin/marketplace.json: voyage entry replaces ultraplan-local - CLAUDE.md: voyage row in plugin list, voyage in design-system consumer list - README.md: bulk rename ultra*-local commands -> trek* commands; ultraplan-local refs -> voyage; type discriminators (type: trekbrief/trekreview); session-title pattern (voyage:<command>:<slug>); v4.0.0 release-note paragraph - plugins/voyage/.claude-plugin/plugin.json: homepage/repository URLs point to monorepo voyage path - plugins/voyage/verify.sh: drop URL whitelist exception (no longer needed) Closes voyage-rebrand. bash plugins/voyage/verify.sh PASS 7/7. npm test 361/361.
This commit is contained in:
parent
8f1bf9b7b4
commit
7a90d348ad
149 changed files with 26 additions and 33 deletions
125
plugins/voyage/tests/lib/agent-frontmatter.test.mjs
Normal file
125
plugins/voyage/tests/lib/agent-frontmatter.test.mjs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// tests/lib/agent-frontmatter.test.mjs
|
||||
// Pin the agent-frontmatter contract from Steps 1-3 of plan-v2:
|
||||
// every agents/*.md MUST declare:
|
||||
// - model: (one of opus | sonnet | haiku)
|
||||
// - tools: (allowlist) OR disallowedTools: (denylist), at least one
|
||||
// Orchestrator agents (planning/research/review) MUST be model: opus and
|
||||
// MUST include the `Agent` tool in their tools allowlist (they spawn the swarm).
|
||||
//
|
||||
// When this test fails, fix the agent file — do NOT relax the assertion to
|
||||
// hide drift. The contract is what /trek* commands rely on for
|
||||
// disciplined model selection + tool scoping.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const AGENTS_DIR = join(ROOT, 'agents');
|
||||
|
||||
const ORCHESTRATORS = new Set([
|
||||
'planning-orchestrator.md',
|
||||
'research-orchestrator.md',
|
||||
'review-orchestrator.md',
|
||||
]);
|
||||
|
||||
const ALLOWED_MODELS = new Set(['opus', 'sonnet', 'haiku']);
|
||||
|
||||
function read(rel) {
|
||||
return readFileSync(join(ROOT, rel), 'utf-8');
|
||||
}
|
||||
|
||||
function extractFrontmatter(text) {
|
||||
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function hasTopLevelKey(fm, key) {
|
||||
return new RegExp(`^${key}\\s*:`, 'm').test(fm);
|
||||
}
|
||||
|
||||
function getTopLevelValue(fm, key) {
|
||||
const m = fm.match(new RegExp(`^${key}\\s*:\\s*(.+?)\\s*$`, 'm'));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
const agentFiles = readdirSync(AGENTS_DIR).filter(f => f.endsWith('.md'));
|
||||
|
||||
test('every agents/*.md declares a model: field', () => {
|
||||
assert.ok(agentFiles.length > 0, 'No agent files found under agents/');
|
||||
for (const f of agentFiles) {
|
||||
const fm = extractFrontmatter(read(`agents/${f}`));
|
||||
assert.ok(fm, `agents/${f}: missing YAML frontmatter block`);
|
||||
assert.ok(
|
||||
hasTopLevelKey(fm, 'model'),
|
||||
`agents/${f}: required \`model:\` field missing from frontmatter`,
|
||||
);
|
||||
const value = getTopLevelValue(fm, 'model');
|
||||
assert.ok(
|
||||
value && ALLOWED_MODELS.has(value),
|
||||
`agents/${f}: model: "${value}" must be one of ${[...ALLOWED_MODELS].join(' | ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every agents/*.md declares tools: or disallowedTools:', () => {
|
||||
for (const f of agentFiles) {
|
||||
const fm = extractFrontmatter(read(`agents/${f}`));
|
||||
assert.ok(fm, `agents/${f}: missing YAML frontmatter block`);
|
||||
assert.ok(
|
||||
hasTopLevelKey(fm, 'tools') || hasTopLevelKey(fm, 'disallowedTools'),
|
||||
`agents/${f}: required \`tools:\` (allowlist) or \`disallowedTools:\` (denylist) field missing`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every agents/*.md frontmatter name matches its filename', () => {
|
||||
for (const f of agentFiles) {
|
||||
const fm = extractFrontmatter(read(`agents/${f}`));
|
||||
assert.ok(fm, `agents/${f}: missing frontmatter`);
|
||||
const expected = f.replace(/\.md$/, '');
|
||||
const value = getTopLevelValue(fm, 'name');
|
||||
assert.equal(
|
||||
value,
|
||||
expected,
|
||||
`agents/${f}: frontmatter name="${value}" should match filename "${expected}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('orchestrator agents are model: opus and include the Agent tool', () => {
|
||||
for (const f of ORCHESTRATORS) {
|
||||
const path = `agents/${f}`;
|
||||
const fm = extractFrontmatter(read(path));
|
||||
assert.ok(fm, `${path}: missing frontmatter`);
|
||||
const model = getTopLevelValue(fm, 'model');
|
||||
assert.equal(
|
||||
model,
|
||||
'opus',
|
||||
`${path}: orchestrator must be model: opus (drives multi-agent swarm reasoning) — got "${model}"`,
|
||||
);
|
||||
const tools = getTopLevelValue(fm, 'tools');
|
||||
assert.ok(
|
||||
tools && /\bAgent\b/.test(tools),
|
||||
`${path}: orchestrator tools: must include "Agent" so it can spawn the swarm — got ${tools}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-orchestrator agents do NOT include the Agent tool (no recursive swarming)', () => {
|
||||
for (const f of agentFiles) {
|
||||
if (ORCHESTRATORS.has(f)) continue;
|
||||
const fm = extractFrontmatter(read(`agents/${f}`));
|
||||
assert.ok(fm, `agents/${f}: missing frontmatter`);
|
||||
const tools = getTopLevelValue(fm, 'tools');
|
||||
if (tools === null) continue; // disallowedTools-only agent — fine
|
||||
assert.ok(
|
||||
!/\bAgent\b/.test(tools),
|
||||
`agents/${f}: non-orchestrator must NOT include the Agent tool ` +
|
||||
`(only orchestrators spawn sub-agents) — got tools: ${tools}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
140
plugins/voyage/tests/lib/arg-parser.test.mjs
Normal file
140
plugins/voyage/tests/lib/arg-parser.test.mjs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { parseArgs } from '../../lib/parsers/arg-parser.mjs';
|
||||
|
||||
test('trekbrief — empty args', () => {
|
||||
const r = parseArgs('', 'trekbrief');
|
||||
assert.equal(r.command, 'trekbrief');
|
||||
assert.deepEqual(r.flags, {});
|
||||
});
|
||||
|
||||
test('trekbrief — --quick boolean', () => {
|
||||
const r = parseArgs('--quick', 'trekbrief');
|
||||
assert.equal(r.flags['--quick'], true);
|
||||
});
|
||||
|
||||
test('trekresearch — --project value capture', () => {
|
||||
const r = parseArgs('--project .claude/projects/2026-04-30-foo', 'trekresearch');
|
||||
assert.equal(r.flags['--project'], '.claude/projects/2026-04-30-foo');
|
||||
});
|
||||
|
||||
test('trekresearch — --quick --local combined', () => {
|
||||
const r = parseArgs('--quick --local', 'trekresearch');
|
||||
assert.equal(r.flags['--quick'], true);
|
||||
assert.equal(r.flags['--local'], true);
|
||||
});
|
||||
|
||||
test('trekplan — --research multi-value', () => {
|
||||
const r = parseArgs('--research a.md b.md c.md', 'trekplan');
|
||||
assert.deepEqual(r.flags['--research'], ['a.md', 'b.md', 'c.md']);
|
||||
});
|
||||
|
||||
test('trekplan — --research multi stops at next flag', () => {
|
||||
const r = parseArgs('--research a.md b.md --project /x', 'trekplan');
|
||||
assert.deepEqual(r.flags['--research'], ['a.md', 'b.md']);
|
||||
assert.equal(r.flags['--project'], '/x');
|
||||
});
|
||||
|
||||
test('trekplan — --brief required-value flag', () => {
|
||||
const r = parseArgs('--brief brief.md', 'trekplan');
|
||||
assert.equal(r.flags['--brief'], 'brief.md');
|
||||
});
|
||||
|
||||
test('trekplan — missing value for --brief produces error', () => {
|
||||
const r = parseArgs('--brief --quick', 'trekplan');
|
||||
assert.ok(r.errors.find(e => e.code === 'ARG_MISSING_VALUE'));
|
||||
});
|
||||
|
||||
test('trekplan — --decompose value flag', () => {
|
||||
const r = parseArgs('--decompose plan.md', 'trekplan');
|
||||
assert.equal(r.flags['--decompose'], 'plan.md');
|
||||
});
|
||||
|
||||
test('trekexecute — --resume + --project', () => {
|
||||
const r = parseArgs('--resume --project /tmp/p', 'trekexecute');
|
||||
assert.equal(r.flags['--resume'], true);
|
||||
assert.equal(r.flags['--project'], '/tmp/p');
|
||||
});
|
||||
|
||||
test('trekexecute — --step N value', () => {
|
||||
const r = parseArgs('--step 3', 'trekexecute');
|
||||
assert.equal(r.flags['--step'], '3');
|
||||
});
|
||||
|
||||
test('trekexecute — unknown flag goes to unknown[]', () => {
|
||||
const r = parseArgs('--mystery foo', 'trekexecute');
|
||||
assert.ok(r.unknown.includes('--mystery'));
|
||||
});
|
||||
|
||||
test('quoted positional with spaces preserved', () => {
|
||||
const r = parseArgs('"hello world" simple', 'trekbrief');
|
||||
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'));
|
||||
});
|
||||
|
||||
test('trekreview — --project value capture', () => {
|
||||
const r = parseArgs('--project .claude/projects/2026-05-01-foo', 'trekreview');
|
||||
assert.equal(r.flags['--project'], '.claude/projects/2026-05-01-foo');
|
||||
});
|
||||
|
||||
test('trekreview — --since <ref> value', () => {
|
||||
const r = parseArgs('--since HEAD~5', 'trekreview');
|
||||
assert.equal(r.flags['--since'], 'HEAD~5');
|
||||
});
|
||||
|
||||
test('trekreview — --quick + --validate combined', () => {
|
||||
const r = parseArgs('--quick --validate', 'trekreview');
|
||||
assert.equal(r.flags['--quick'], true);
|
||||
assert.equal(r.flags['--validate'], true);
|
||||
});
|
||||
|
||||
test('trekreview — unknown flag goes to unknown[]', () => {
|
||||
const r = parseArgs('--mystery foo', 'trekreview');
|
||||
assert.ok(r.unknown.includes('--mystery'));
|
||||
});
|
||||
|
||||
test('trekcontinue — empty args produce no flags and no positional', () => {
|
||||
const r = parseArgs('', 'trekcontinue');
|
||||
assert.equal(r.command, 'trekcontinue');
|
||||
assert.deepEqual(r.flags, {});
|
||||
assert.deepEqual(r.positional, []);
|
||||
assert.deepEqual(r.errors, []);
|
||||
});
|
||||
|
||||
test('trekcontinue — --help boolean flag', () => {
|
||||
const r = parseArgs('--help', 'trekcontinue');
|
||||
assert.equal(r.flags['--help'], true);
|
||||
});
|
||||
|
||||
test('trekcontinue — -h treated as positional (no alias resolution)', () => {
|
||||
const r = parseArgs('-h', 'trekcontinue');
|
||||
assert.deepEqual(r.positional, ['-h']);
|
||||
assert.deepEqual(r.errors, []);
|
||||
assert.equal(r.flags['--help'], undefined);
|
||||
});
|
||||
|
||||
test('trekcontinue — --cleanup boolean flag', () => {
|
||||
const r = parseArgs('--cleanup', 'trekcontinue');
|
||||
assert.equal(r.flags['--cleanup'], true);
|
||||
});
|
||||
|
||||
test('trekcontinue — --cleanup --confirm both flags', () => {
|
||||
const r = parseArgs('--cleanup --confirm', 'trekcontinue');
|
||||
assert.equal(r.flags['--cleanup'], true);
|
||||
assert.equal(r.flags['--confirm'], true);
|
||||
});
|
||||
|
||||
test('trekcontinue — positional project dir captured', () => {
|
||||
const r = parseArgs('.claude/projects/2026-05-04-foo', 'trekcontinue');
|
||||
assert.deepEqual(r.positional, ['.claude/projects/2026-05-04-foo']);
|
||||
});
|
||||
|
||||
test('trekcontinue — .md positional accepted by parser (rejection is command-level)', () => {
|
||||
const r = parseArgs('NEXT-SESSION-PROMPT.local.md', 'trekcontinue');
|
||||
assert.deepEqual(r.positional, ['NEXT-SESSION-PROMPT.local.md']);
|
||||
assert.deepEqual(r.errors, []);
|
||||
});
|
||||
61
plugins/voyage/tests/lib/atomic-write.test.mjs
Normal file
61
plugins/voyage/tests/lib/atomic-write.test.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// tests/lib/atomic-write.test.mjs
|
||||
// Unit tests for lib/util/atomic-write.mjs
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { atomicWriteJson } from '../../lib/util/atomic-write.mjs';
|
||||
|
||||
test('atomicWriteJson — writes valid JSON and round-trips', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'aw-test-'));
|
||||
try {
|
||||
const path = join(dir, 'state.json');
|
||||
const obj = { schema_version: 1, status: 'in_progress', items: [1, 2, 3] };
|
||||
atomicWriteJson(path, obj);
|
||||
const read = JSON.parse(readFileSync(path, 'utf-8'));
|
||||
assert.deepEqual(read, obj);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('atomicWriteJson — leaves no .tmp orphan after success', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'aw-test-'));
|
||||
try {
|
||||
const path = join(dir, 'state.json');
|
||||
atomicWriteJson(path, { ok: true });
|
||||
assert.equal(existsSync(path), true);
|
||||
assert.equal(existsSync(path + '.tmp'), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('atomicWriteJson — overwrites existing file atomically', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'aw-test-'));
|
||||
try {
|
||||
const path = join(dir, 'state.json');
|
||||
writeFileSync(path, '{"old":true}');
|
||||
atomicWriteJson(path, { new: true });
|
||||
const read = JSON.parse(readFileSync(path, 'utf-8'));
|
||||
assert.deepEqual(read, { new: true });
|
||||
assert.equal(existsSync(path + '.tmp'), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('atomicWriteJson — pretty-prints with 2-space indent', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'aw-test-'));
|
||||
try {
|
||||
const path = join(dir, 'state.json');
|
||||
atomicWriteJson(path, { a: 1, b: { c: 2 } });
|
||||
const text = readFileSync(path, 'utf-8');
|
||||
assert.match(text, /\n {2}"a": 1/);
|
||||
assert.match(text, /\n {4}"c": 2/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
147
plugins/voyage/tests/lib/autonomy-gate.test.mjs
Normal file
147
plugins/voyage/tests/lib/autonomy-gate.test.mjs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// tests/lib/autonomy-gate.test.mjs
|
||||
// Cover the autonomy-gate state machine (lib/util/autonomy-gate.mjs):
|
||||
// every legal transition + every invalid-transition error + idempotent
|
||||
// re-entry to `completed` + CLI-shim JSON-on-stdout contract.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { transition, isTerminal, STATES, EVENTS } from '../../lib/util/autonomy-gate.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SHIM = join(HERE, '..', '..', 'lib', 'util', 'autonomy-gate.mjs');
|
||||
|
||||
function runShim(args) {
|
||||
try {
|
||||
const out = execFileSync(process.execPath, [SHIM, ...args], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { code: 0, out };
|
||||
} catch (e) {
|
||||
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
|
||||
}
|
||||
}
|
||||
|
||||
test('idle + start + gates=true → gates_on', () => {
|
||||
const r = transition(STATES.IDLE, EVENTS.START, { gates: true });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.GATES_ON);
|
||||
});
|
||||
|
||||
test('idle + start + gates=false → auto_running', () => {
|
||||
const r = transition(STATES.IDLE, EVENTS.START, { gates: false });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.AUTO_RUNNING);
|
||||
});
|
||||
|
||||
test('idle + start + gates omitted defaults to auto_running', () => {
|
||||
const r = transition(STATES.IDLE, EVENTS.START);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.AUTO_RUNNING);
|
||||
});
|
||||
|
||||
test('gates_on + phase_boundary → paused_for_gate', () => {
|
||||
const r = transition(STATES.GATES_ON, EVENTS.PHASE_BOUNDARY);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.PAUSED_FOR_GATE);
|
||||
});
|
||||
|
||||
test('gates_on + finish → completed', () => {
|
||||
const r = transition(STATES.GATES_ON, EVENTS.FINISH);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.COMPLETED);
|
||||
});
|
||||
|
||||
test('auto_running + phase_boundary → auto_running (no pause)', () => {
|
||||
const r = transition(STATES.AUTO_RUNNING, EVENTS.PHASE_BOUNDARY);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.AUTO_RUNNING);
|
||||
});
|
||||
|
||||
test('auto_running + finish → completed', () => {
|
||||
const r = transition(STATES.AUTO_RUNNING, EVENTS.FINISH);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.COMPLETED);
|
||||
});
|
||||
|
||||
test('paused_for_gate + resume → gates_on', () => {
|
||||
const r = transition(STATES.PAUSED_FOR_GATE, EVENTS.RESUME);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.GATES_ON);
|
||||
});
|
||||
|
||||
test('paused_for_gate + finish → completed', () => {
|
||||
const r = transition(STATES.PAUSED_FOR_GATE, EVENTS.FINISH);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.COMPLETED);
|
||||
});
|
||||
|
||||
test('completed + any event → completed (idempotent re-entry)', () => {
|
||||
for (const ev of Object.values(EVENTS)) {
|
||||
const r = transition(STATES.COMPLETED, ev);
|
||||
assert.equal(r.ok, true, `event ${ev} should be tolerated from completed`);
|
||||
assert.equal(r.next_state, STATES.COMPLETED, `event ${ev} broke idempotency`);
|
||||
}
|
||||
});
|
||||
|
||||
test('idle + non-start event → invalid transition error', () => {
|
||||
const r = transition(STATES.IDLE, EVENTS.PHASE_BOUNDARY);
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.error, /invalid transition.*idle/);
|
||||
});
|
||||
|
||||
test('gates_on + resume → invalid (resume is only valid from paused_for_gate)', () => {
|
||||
const r = transition(STATES.GATES_ON, EVENTS.RESUME);
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
|
||||
test('auto_running + resume → invalid (auto-mode never pauses)', () => {
|
||||
const r = transition(STATES.AUTO_RUNNING, EVENTS.RESUME);
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
|
||||
test('unknown state rejected with descriptive error', () => {
|
||||
const r = transition('zombie', EVENTS.START);
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.error, /unknown state/);
|
||||
});
|
||||
|
||||
test('unknown event rejected with descriptive error', () => {
|
||||
const r = transition(STATES.IDLE, 'snooze');
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.error, /unknown event/);
|
||||
});
|
||||
|
||||
test('isTerminal — only completed is terminal', () => {
|
||||
assert.equal(isTerminal(STATES.COMPLETED), true);
|
||||
for (const s of [STATES.IDLE, STATES.GATES_ON, STATES.AUTO_RUNNING, STATES.PAUSED_FOR_GATE]) {
|
||||
assert.equal(isTerminal(s), false, `${s} should not be terminal`);
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI shim returns valid JSON on success (exit 0)', () => {
|
||||
const r = runShim(['--state', 'idle', '--event', 'start', '--gates', 'true']);
|
||||
assert.equal(r.code, 0);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.next_state, 'gates_on');
|
||||
});
|
||||
|
||||
test('CLI shim returns JSON error on invalid transition (exit 1)', () => {
|
||||
const r = runShim(['--state', 'idle', '--event', 'phase_boundary']);
|
||||
assert.equal(r.code, 1);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.match(parsed.error, /invalid transition/);
|
||||
});
|
||||
|
||||
test('CLI shim missing required args returns usage error (exit 1)', () => {
|
||||
const r = runShim(['--state', 'idle']);
|
||||
assert.equal(r.code, 1);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.match(parsed.error, /usage:/);
|
||||
});
|
||||
49
plugins/voyage/tests/lib/bash-normalize.test.mjs
Normal file
49
plugins/voyage/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');
|
||||
});
|
||||
134
plugins/voyage/tests/lib/cleanup.test.mjs
Normal file
134
plugins/voyage/tests/lib/cleanup.test.mjs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// tests/lib/cleanup.test.mjs
|
||||
// Unit tests for lib/util/cleanup.mjs (Bug 4).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { cleanupProject } from '../../lib/util/cleanup.mjs';
|
||||
|
||||
function buildProject(dir, status) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const stateObj = {
|
||||
schema_version: 1,
|
||||
project: dir,
|
||||
next_session_brief_path: join(dir, 'brief.md'),
|
||||
next_session_label: 'Cleanup test fixture',
|
||||
status,
|
||||
updated_at: '2026-05-04T16:00:00.000Z',
|
||||
};
|
||||
writeFileSync(join(dir, '.session-state.local.json'), JSON.stringify(stateObj, null, 2));
|
||||
writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'),
|
||||
`---\nproduced_by: trekexecute\nproduced_at: 2026-05-04T16:00:00.000Z\n---\n\n# Done\n`);
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('cleanupProject — dry-run on completed project lists candidates without deleting', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cleanup-'));
|
||||
try {
|
||||
const dir = buildProject(join(root, 'project-a'), 'completed');
|
||||
const r = cleanupProject(dir, { dryRun: true });
|
||||
assert.equal(r.valid, true, JSON.stringify(r.errors));
|
||||
assert.equal(r.parsed.wouldDelete.length, 2);
|
||||
assert.deepEqual(r.parsed.deleted, []);
|
||||
// Files MUST still exist.
|
||||
assert.equal(existsSync(join(dir, '.session-state.local.json')), true);
|
||||
assert.equal(existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), true);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('cleanupProject — confirm-mode deletes both candidate files', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cleanup-'));
|
||||
try {
|
||||
const dir = buildProject(join(root, 'project-b'), 'completed');
|
||||
const r = cleanupProject(dir, { dryRun: false, confirm: true });
|
||||
assert.equal(r.valid, true, JSON.stringify(r.errors));
|
||||
assert.equal(r.parsed.deleted.length, 2);
|
||||
assert.equal(existsSync(join(dir, '.session-state.local.json')), false);
|
||||
assert.equal(existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('cleanupProject — idempotent re-run after partial cleanup succeeds with deleted: []', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cleanup-'));
|
||||
try {
|
||||
const dir = buildProject(join(root, 'project-c'), 'completed');
|
||||
// First confirm-mode deletes the prompt file BUT we still have the state file.
|
||||
// Manually remove the prompt file FIRST so the state file (still completed) is
|
||||
// the only candidate left after first cleanup.
|
||||
unlinkSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'));
|
||||
const first = cleanupProject(dir, { dryRun: false, confirm: true });
|
||||
assert.equal(first.valid, true);
|
||||
assert.equal(first.parsed.deleted.length, 1, 'first cleanup deletes only the state file (prompt was pre-removed)');
|
||||
// Second invocation must fail — no state file → CLEANUP_NO_STATE_FILE.
|
||||
// This is the documented "fully cleaned" terminal state and is NOT an error
|
||||
// for the operator (they can ignore CLEANUP_NO_STATE_FILE), but the function
|
||||
// signals it deterministically.
|
||||
const second = cleanupProject(dir, { dryRun: false, confirm: true });
|
||||
assert.equal(second.valid, false);
|
||||
assert.ok(second.errors.find(e => e.code === 'CLEANUP_NO_STATE_FILE'));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('cleanupProject — refuses on status: in_progress (CLEANUP_NOT_COMPLETED)', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cleanup-'));
|
||||
try {
|
||||
const dir = buildProject(join(root, 'project-d'), 'in_progress');
|
||||
const r = cleanupProject(dir, { dryRun: false, confirm: true });
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(r.errors.find(e => e.code === 'CLEANUP_NOT_COMPLETED'));
|
||||
// Files MUST still exist (refusal must not partially clean).
|
||||
assert.equal(existsSync(join(dir, '.session-state.local.json')), true);
|
||||
assert.equal(existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), true);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('cleanupProject — refuses dryRun: false without confirm: true (CLEANUP_REQUIRES_CONFIRM)', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cleanup-'));
|
||||
try {
|
||||
const dir = buildProject(join(root, 'project-e'), 'completed');
|
||||
const r = cleanupProject(dir, { dryRun: false }); // no confirm
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(r.errors.find(e => e.code === 'CLEANUP_REQUIRES_CONFIRM'));
|
||||
assert.equal(existsSync(join(dir, '.session-state.local.json')), true);
|
||||
assert.equal(existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), true);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('cleanupProject — defaults to dry-run when opts is omitted', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cleanup-'));
|
||||
try {
|
||||
const dir = buildProject(join(root, 'project-f'), 'completed');
|
||||
const r = cleanupProject(dir);
|
||||
assert.equal(r.valid, true);
|
||||
assert.deepEqual(r.parsed.deleted, []);
|
||||
assert.equal(r.parsed.wouldDelete.length, 2);
|
||||
assert.equal(existsSync(join(dir, '.session-state.local.json')), true);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('cleanupProject — missing state file returns CLEANUP_NO_STATE_FILE', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cleanup-'));
|
||||
try {
|
||||
const dir = join(root, 'project-empty');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const r = cleanupProject(dir);
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(r.errors.find(e => e.code === 'CLEANUP_NO_STATE_FILE'));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
268
plugins/voyage/tests/lib/doc-consistency.test.mjs
Normal file
268
plugins/voyage/tests/lib/doc-consistency.test.mjs
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
// tests/lib/doc-consistency.test.mjs
|
||||
// Pin invariants between prose (CLAUDE.md, README.md) and source files
|
||||
// (agents/*.md, commands/*.md, templates/, settings.json).
|
||||
//
|
||||
// When this test fails, fix the source-of-truth — do NOT rewrite the test to
|
||||
// hide drift. Borrowed pattern from llm-security commit 97c5c9d.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDocument } from '../../lib/util/frontmatter.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
|
||||
function read(rel) { return readFileSync(join(ROOT, rel), 'utf-8'); }
|
||||
function listMd(rel) { return readdirSync(join(ROOT, rel)).filter(f => f.endsWith('.md')); }
|
||||
|
||||
test('CLAUDE.md agents table row count == agents/*.md file count', () => {
|
||||
const md = read('CLAUDE.md');
|
||||
const agentFiles = listMd('agents');
|
||||
const agentTable = md.split('## Agents')[1] || '';
|
||||
const tableSection = agentTable.split('\n## ')[0];
|
||||
const dataRows = tableSection
|
||||
.split('\n')
|
||||
.filter(l => l.startsWith('|') && !l.match(/^\|[\s-]+\|/) && !l.match(/^\|\s*Agent\s*\|/));
|
||||
assert.equal(
|
||||
dataRows.length,
|
||||
agentFiles.length,
|
||||
`Drift: ${agentFiles.length} agent files vs ${dataRows.length} CLAUDE.md table rows. ` +
|
||||
`Sync agents/ ↔ CLAUDE.md.`,
|
||||
);
|
||||
});
|
||||
|
||||
test('CLAUDE.md commands table mentions every commands/*.md file', () => {
|
||||
const md = read('CLAUDE.md');
|
||||
const commandFiles = listMd('commands');
|
||||
for (const f of commandFiles) {
|
||||
const cmdName = `/${f.replace(/\.md$/, '')}`;
|
||||
assert.ok(
|
||||
md.includes(cmdName),
|
||||
`commands/${f} not mentioned in CLAUDE.md (looked for ${cmdName})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every command frontmatter name matches its filename', () => {
|
||||
for (const f of listMd('commands')) {
|
||||
const text = read(`commands/${f}`);
|
||||
const doc = parseDocument(text);
|
||||
if (!doc.valid) continue;
|
||||
const expected = f.replace(/\.md$/, '');
|
||||
if (doc.parsed.frontmatter && doc.parsed.frontmatter.name !== undefined) {
|
||||
assert.equal(
|
||||
doc.parsed.frontmatter.name,
|
||||
expected,
|
||||
`commands/${f} frontmatter.name="${doc.parsed.frontmatter.name}" should be "${expected}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('templates/plan-template.md declares plan_version: 1.7', () => {
|
||||
const tpl = read('templates/plan-template.md');
|
||||
assert.match(tpl, /plan_version:\s*['"]?1\.7['"]?/);
|
||||
});
|
||||
|
||||
test('commands/trekexecute.md still parses v1.7 plan schema', () => {
|
||||
const cmd = read('commands/trekexecute.md');
|
||||
const tpl = read('templates/plan-template.md');
|
||||
const tplVersion = (tpl.match(/plan_version:\s*['"]?([\d.]+)['"]?/) || [])[1];
|
||||
assert.ok(tplVersion, 'templates/plan-template.md missing plan_version');
|
||||
assert.ok(
|
||||
cmd.includes(`plan_version`) || cmd.includes(`Step N:`) || cmd.includes('### Step '),
|
||||
'commands/trekexecute.md should reference v1.7 plan-schema parsing',
|
||||
);
|
||||
});
|
||||
|
||||
test('settings.json has only known top-level scopes after Spor 0 cleanup', () => {
|
||||
const cfg = JSON.parse(read('settings.json'));
|
||||
const known = ['trekplan', 'trekresearch'];
|
||||
for (const k of Object.keys(cfg)) {
|
||||
assert.ok(known.includes(k), `Unknown top-level scope in settings.json: ${k}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('settings.json no longer carries vestigial exploration block', () => {
|
||||
const cfg = JSON.parse(read('settings.json'));
|
||||
assert.equal(cfg.trekplan?.exploration, undefined,
|
||||
'exploration block was vestigial — should be deleted in v3.1.0 Spor 0');
|
||||
assert.equal(cfg.trekplan?.agentTeam, undefined,
|
||||
'agentTeam block was vestigial — should be deleted in v3.1.0 Spor 0');
|
||||
});
|
||||
|
||||
test('CLAUDE.md mentions all five pipeline commands', () => {
|
||||
const md = read('CLAUDE.md');
|
||||
for (const c of ['/trekbrief', '/trekresearch', '/trekplan', '/trekexecute', '/trekreview']) {
|
||||
assert.ok(md.includes(c), `CLAUDE.md missing reference to ${c}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('HANDOVER-CONTRACTS.md contains Handover 6 section', () => {
|
||||
const text = read('docs/HANDOVER-CONTRACTS.md');
|
||||
assert.ok(
|
||||
text.includes('## Handover 6'),
|
||||
'docs/HANDOVER-CONTRACTS.md should document Handover 6 (review → plan)',
|
||||
);
|
||||
});
|
||||
|
||||
test('HANDOVER-CONTRACTS.md contains Handover 7 section (session-state)', () => {
|
||||
const text = read('docs/HANDOVER-CONTRACTS.md');
|
||||
assert.ok(
|
||||
text.includes('## Handover 7'),
|
||||
'docs/HANDOVER-CONTRACTS.md should document Handover 7 (.session-state.local.json) ' +
|
||||
'consumed by /trekcontinue',
|
||||
);
|
||||
assert.ok(
|
||||
text.includes('.session-state.local.json'),
|
||||
'Handover 7 section should name the artifact path',
|
||||
);
|
||||
});
|
||||
|
||||
test('review-validator has CLI shim', () => {
|
||||
const text = read('lib/validators/review-validator.mjs');
|
||||
assert.ok(
|
||||
text.includes('import.meta.url === '),
|
||||
'lib/validators/review-validator.mjs should expose the standard CLI shim ' +
|
||||
'(if (import.meta.url === `file://${process.argv[1]}`)) so commands can call it from Bash',
|
||||
);
|
||||
});
|
||||
|
||||
test('session-state-validator has CLI shim', () => {
|
||||
const text = read('lib/validators/session-state-validator.mjs');
|
||||
assert.ok(
|
||||
text.includes('import.meta.url === '),
|
||||
'lib/validators/session-state-validator.mjs should expose the standard CLI shim ' +
|
||||
'(if (import.meta.url === `file://${process.argv[1]}`)) so /trekcontinue can call it from Bash',
|
||||
);
|
||||
});
|
||||
|
||||
test('next-session-prompt-validator has CLI shim', () => {
|
||||
const text = read('lib/validators/next-session-prompt-validator.mjs');
|
||||
assert.ok(
|
||||
text.includes('import.meta.url === '),
|
||||
'lib/validators/next-session-prompt-validator.mjs should expose the standard CLI shim ' +
|
||||
'(if (import.meta.url === `file://${process.argv[1]}`)) so /trekcontinue Phase 1.5 can call it from Bash',
|
||||
);
|
||||
});
|
||||
|
||||
test('HANDOVER-CONTRACTS.md Handover 7 documents § Lifecycle subsection', () => {
|
||||
const text = read('docs/HANDOVER-CONTRACTS.md');
|
||||
const h7Start = text.indexOf('## Handover 7');
|
||||
assert.ok(h7Start >= 0, 'Handover 7 heading missing');
|
||||
const h7End = text.indexOf('## Stability summary', h7Start);
|
||||
assert.ok(h7End > h7Start, 'Stability summary heading missing — could not bound Handover 7');
|
||||
const h7 = text.slice(h7Start, h7End);
|
||||
assert.ok(
|
||||
h7.includes('Lifecycle'),
|
||||
'Handover 7 section should include a § Lifecycle subsection (SC-5 stale-file principle)',
|
||||
);
|
||||
});
|
||||
|
||||
test('HANDOVER-CONTRACTS.md Handover 7 § Lifecycle names --cleanup and produced_by contract', () => {
|
||||
const text = read('docs/HANDOVER-CONTRACTS.md');
|
||||
const h7Start = text.indexOf('## Handover 7');
|
||||
const h7End = text.indexOf('## Stability summary', h7Start);
|
||||
const h7 = text.slice(h7Start, h7End);
|
||||
assert.ok(
|
||||
h7.includes('--cleanup'),
|
||||
'Handover 7 § Lifecycle should mention --cleanup as the operator-invoked stale-file remover',
|
||||
);
|
||||
assert.ok(
|
||||
h7.includes('produced_by'),
|
||||
'Handover 7 § Lifecycle should document the produced_by frontmatter contract for NEXT-SESSION-PROMPT.local.md',
|
||||
);
|
||||
});
|
||||
|
||||
test('CLAUDE.md mentions /trekcontinue command', () => {
|
||||
const md = read('CLAUDE.md');
|
||||
assert.ok(
|
||||
md.includes('/trekcontinue') || md.includes('trekcontinue'),
|
||||
'CLAUDE.md should document /trekcontinue in the Commands table ' +
|
||||
'(added in v3.3.0 alongside the new command file)',
|
||||
);
|
||||
});
|
||||
|
||||
test('rule-catalogue has exactly 12 entries', async () => {
|
||||
const mod = await import('../../lib/review/rule-catalogue.mjs');
|
||||
assert.strictEqual(
|
||||
mod.RULE_CATALOGUE.length,
|
||||
12,
|
||||
'lib/review/rule-catalogue.mjs RULE_CATALOGUE size invariant: must be 12 (v1.0 baseline)',
|
||||
);
|
||||
});
|
||||
|
||||
test('headless-launch-template.md mirrors Phase 2.6 hardenings', () => {
|
||||
const tpl = read('templates/headless-launch-template.md');
|
||||
for (const needle of [
|
||||
'GIT_OPTIONAL_LOCKS',
|
||||
'--max-turns',
|
||||
'--max-budget-usd',
|
||||
'--append-system-prompt-file',
|
||||
'SHARED_CONTEXT_FILE',
|
||||
'SAFETY_PREAMBLE',
|
||||
'git push origin',
|
||||
'GH #36071',
|
||||
'push-before-cleanup',
|
||||
]) {
|
||||
assert.ok(
|
||||
tpl.includes(needle),
|
||||
`templates/headless-launch-template.md should include "${needle}" (Step 10 mirrors Phase 2.6)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Phase 9 prose mandates parallel single-message dispatch + inline dedup', () => {
|
||||
const cmd = read('commands/trekplan.md');
|
||||
const orch = read('agents/planning-orchestrator.md');
|
||||
// Single-message reinforcement appears in both (command + orchestrator)
|
||||
assert.ok(
|
||||
cmd.includes('single assistant message turn'),
|
||||
'commands/trekplan.md Phase 9 should reinforce single-message parallel dispatch',
|
||||
);
|
||||
assert.ok(
|
||||
orch.includes('single assistant message turn'),
|
||||
'agents/planning-orchestrator.md Phase 6 should mirror the single-message parallel-dispatch contract',
|
||||
);
|
||||
// Dedup CLI shim is wired in both
|
||||
assert.ok(
|
||||
cmd.includes('plan-review-dedup.mjs'),
|
||||
'commands/trekplan.md Phase 9 should call lib/review/plan-review-dedup.mjs after both reviewers complete',
|
||||
);
|
||||
assert.ok(
|
||||
orch.includes('plan-review-dedup.mjs'),
|
||||
'agents/planning-orchestrator.md Phase 6 should reference the dedup helper',
|
||||
);
|
||||
});
|
||||
|
||||
test('commands/trekplan.md Phase 8 seals Opus-4.7 schema-drift defense', () => {
|
||||
const cmd = read('commands/trekplan.md');
|
||||
// Locate Phase 8 section
|
||||
const phase8Start = cmd.indexOf('## Phase 8');
|
||||
assert.ok(phase8Start >= 0, 'Phase 8 heading missing');
|
||||
const phase8End = cmd.indexOf('## Phase 9', phase8Start);
|
||||
assert.ok(phase8End > phase8Start, 'Phase 9 heading missing — could not bound Phase 8');
|
||||
const phase8 = cmd.slice(phase8Start, phase8End);
|
||||
// Required regex source-of-truth references
|
||||
assert.ok(
|
||||
phase8.includes('STEP_HEADING_REGEX'),
|
||||
'Phase 8 should inline STEP_HEADING_REGEX so format contract survives without orchestrator-doc loading',
|
||||
);
|
||||
assert.ok(
|
||||
phase8.includes('FORBIDDEN_HEADING_REGEX'),
|
||||
'Phase 8 should inline FORBIDDEN_HEADING_REGEX (Step 7 — schema-drift seal)',
|
||||
);
|
||||
// Required validator self-check
|
||||
assert.ok(
|
||||
phase8.includes('plan-validator.mjs --strict'),
|
||||
'Phase 8 should mandate post-write `plan-validator.mjs --strict` self-check',
|
||||
);
|
||||
// Forbidden-headings list (literal "FORBIDDEN" appears more than once: in regex const + in human-readable list)
|
||||
assert.ok(
|
||||
/FORBIDDEN/.test(phase8),
|
||||
'Phase 8 should explicitly enumerate FORBIDDEN headings',
|
||||
);
|
||||
});
|
||||
59
plugins/voyage/tests/lib/finding-id.test.mjs
Normal file
59
plugins/voyage/tests/lib/finding-id.test.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { computeFindingId, parseFindingId } from '../../lib/parsers/finding-id.mjs';
|
||||
|
||||
test('computeFindingId — deterministic on same inputs', () => {
|
||||
const a = computeFindingId('lib/foo.mjs', 42, 'MISSING_TEST');
|
||||
const b = computeFindingId('lib/foo.mjs', 42, 'MISSING_TEST');
|
||||
assert.equal(a, b);
|
||||
});
|
||||
|
||||
test('computeFindingId — different file → different ID', () => {
|
||||
const a = computeFindingId('lib/foo.mjs', 42, 'MISSING_TEST');
|
||||
const b = computeFindingId('lib/bar.mjs', 42, 'MISSING_TEST');
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
test('computeFindingId — different line → different ID', () => {
|
||||
const a = computeFindingId('lib/foo.mjs', 42, 'MISSING_TEST');
|
||||
const b = computeFindingId('lib/foo.mjs', 43, 'MISSING_TEST');
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
test('computeFindingId — different rule_key → different ID', () => {
|
||||
const a = computeFindingId('lib/foo.mjs', 42, 'MISSING_TEST');
|
||||
const b = computeFindingId('lib/foo.mjs', 42, 'MISSING_BRIEF_REF');
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
test('computeFindingId — output is 40-char lowercase hex', () => {
|
||||
const id = computeFindingId('lib/foo.mjs', 42, 'MISSING_TEST');
|
||||
assert.match(id, /^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
test('computeFindingId — throws TypeError on null/undefined/empty inputs', () => {
|
||||
assert.throws(() => computeFindingId(null, 1, 'X'), TypeError);
|
||||
assert.throws(() => computeFindingId('', 1, 'X'), TypeError);
|
||||
assert.throws(() => computeFindingId('a', null, 'X'), TypeError);
|
||||
assert.throws(() => computeFindingId('a', undefined, 'X'), TypeError);
|
||||
assert.throws(() => computeFindingId('a', '', 'X'), TypeError);
|
||||
assert.throws(() => computeFindingId('a', 1, ''), TypeError);
|
||||
assert.throws(() => computeFindingId('a', 1, null), TypeError);
|
||||
assert.throws(() => computeFindingId('a', NaN, 'X'), TypeError);
|
||||
});
|
||||
|
||||
test('parseFindingId — valid 40-char hex returns valid:true', () => {
|
||||
const id = computeFindingId('a', 1, 'X');
|
||||
assert.equal(parseFindingId(id).valid, true);
|
||||
});
|
||||
|
||||
test('parseFindingId — bad input returns valid:false (no throw)', () => {
|
||||
assert.equal(parseFindingId('').valid, false);
|
||||
assert.equal(parseFindingId('xyz').valid, false);
|
||||
assert.equal(parseFindingId('A'.repeat(40)).valid, false); // uppercase rejected
|
||||
assert.equal(parseFindingId('0'.repeat(39)).valid, false); // too short
|
||||
assert.equal(parseFindingId('0'.repeat(41)).valid, false); // too long
|
||||
assert.equal(parseFindingId(null).valid, false);
|
||||
assert.equal(parseFindingId(undefined).valid, false);
|
||||
assert.equal(parseFindingId(42).valid, false);
|
||||
});
|
||||
74
plugins/voyage/tests/lib/frontmatter.test.mjs
Normal file
74
plugins/voyage/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: trekbrief\nslug: jwt-auth\n');
|
||||
assert.equal(r.valid, true);
|
||||
assert.equal(r.parsed.type, 'trekbrief');
|
||||
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: trekbrief\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, 'trekbrief');
|
||||
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'));
|
||||
});
|
||||
48
plugins/voyage/tests/lib/gates-flag-coverage.test.mjs
Normal file
48
plugins/voyage/tests/lib/gates-flag-coverage.test.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// tests/lib/gates-flag-coverage.test.mjs
|
||||
// Step 11 (plan-v2) — pin that all four pipeline commands document the
|
||||
// --gates autonomy-control flag and consume the autonomy-gate state
|
||||
// machine via the lib/util/autonomy-gate.mjs CLI shim.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
|
||||
function read(rel) { return readFileSync(join(ROOT, rel), 'utf-8'); }
|
||||
|
||||
const COMMANDS = [
|
||||
'commands/trekbrief.md',
|
||||
'commands/trekresearch.md',
|
||||
'commands/trekplan.md',
|
||||
'commands/trekexecute.md',
|
||||
];
|
||||
|
||||
for (const cmdPath of COMMANDS) {
|
||||
test(`${cmdPath} documents the --gates flag`, () => {
|
||||
const text = read(cmdPath);
|
||||
assert.ok(
|
||||
text.includes('--gates'),
|
||||
`${cmdPath} should document the --gates autonomy-control flag (Step 11)`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${cmdPath} wires the autonomy-gate.mjs CLI shim`, () => {
|
||||
const text = read(cmdPath);
|
||||
assert.ok(
|
||||
text.includes('autonomy-gate.mjs'),
|
||||
`${cmdPath} should reference lib/util/autonomy-gate.mjs as the state-machine implementation`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('commands/trekexecute.md mentions MAIN_MERGE_GATE', () => {
|
||||
const text = read('commands/trekexecute.md');
|
||||
assert.ok(
|
||||
text.includes('MAIN_MERGE_GATE'),
|
||||
'commands/trekexecute.md should name MAIN_MERGE_GATE — the only boundary that always pauses regardless of --gates',
|
||||
);
|
||||
});
|
||||
56
plugins/voyage/tests/lib/jaccard.test.mjs
Normal file
56
plugins/voyage/tests/lib/jaccard.test.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { jaccardSimilarity, meetsThreshold } from '../../lib/parsers/jaccard.mjs';
|
||||
|
||||
test('jaccardSimilarity — identical sets → 1.0', () => {
|
||||
assert.equal(jaccardSimilarity(['a', 'b', 'c'], ['a', 'b', 'c']), 1.0);
|
||||
});
|
||||
|
||||
test('jaccardSimilarity — disjoint sets → 0.0', () => {
|
||||
assert.equal(jaccardSimilarity(['a', 'b'], ['c', 'd']), 0.0);
|
||||
});
|
||||
|
||||
test('jaccardSimilarity — partial overlap [a,b,c] vs [b,c,d] → 0.5', () => {
|
||||
assert.equal(jaccardSimilarity(['a', 'b', 'c'], ['b', 'c', 'd']), 0.5);
|
||||
});
|
||||
|
||||
test('jaccardSimilarity — both empty → 1.0', () => {
|
||||
assert.equal(jaccardSimilarity([], []), 1.0);
|
||||
});
|
||||
|
||||
test('jaccardSimilarity — one empty → 0.0', () => {
|
||||
assert.equal(jaccardSimilarity([], ['a']), 0.0);
|
||||
assert.equal(jaccardSimilarity(['a'], []), 0.0);
|
||||
});
|
||||
|
||||
test('jaccardSimilarity — duplicates deduplicated within each set', () => {
|
||||
// [a,a,b] dedup → {a,b}; [a,b,b] dedup → {a,b}; identical → 1.0
|
||||
assert.equal(jaccardSimilarity(['a', 'a', 'b'], ['a', 'b', 'b']), 1.0);
|
||||
});
|
||||
|
||||
test('jaccardSimilarity — fixture sets {α..ε} vs {α..ζ} → 0.833 (SC4 anchor)', () => {
|
||||
// SC4 fixture math: A=5 IDs, B=A∪{ζ}=6 IDs, intersection=5, union=6 → 5/6
|
||||
const A = ['α', 'β', 'γ', 'δ', 'ε'];
|
||||
const B = ['α', 'β', 'γ', 'δ', 'ε', 'ζ'];
|
||||
const sim = jaccardSimilarity(A, B);
|
||||
assert.ok(Math.abs(sim - 5 / 6) < 1e-9);
|
||||
assert.ok(sim >= 0.70); // SC4 threshold
|
||||
});
|
||||
|
||||
test('jaccardSimilarity — non-array input throws TypeError', () => {
|
||||
assert.throws(() => jaccardSimilarity('a', ['b']), TypeError);
|
||||
assert.throws(() => jaccardSimilarity(['a'], null), TypeError);
|
||||
});
|
||||
|
||||
test('meetsThreshold — boundary 0.699 → false, 0.700 → true', () => {
|
||||
assert.equal(meetsThreshold(0.699, 0.7), false);
|
||||
assert.equal(meetsThreshold(0.7, 0.7), true);
|
||||
assert.equal(meetsThreshold(0.71, 0.7), true);
|
||||
});
|
||||
|
||||
test('meetsThreshold — non-finite or non-number → false', () => {
|
||||
assert.equal(meetsThreshold(NaN, 0.7), false);
|
||||
assert.equal(meetsThreshold(Infinity, 0.7), false);
|
||||
assert.equal(meetsThreshold('0.8', 0.7), false);
|
||||
assert.equal(meetsThreshold(0.8, null), false);
|
||||
});
|
||||
42
plugins/voyage/tests/lib/main-merge-gate.test.mjs
Normal file
42
plugins/voyage/tests/lib/main-merge-gate.test.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// tests/lib/main-merge-gate.test.mjs
|
||||
// Step 12 (plan-v2) — pin that commands/trekexecute.md Phase 8
|
||||
// names the main-merge-gate lifecycle event, the decline + recovery
|
||||
// surface, and the always-on gate prose.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const CMD = readFileSync(join(ROOT, 'commands/trekexecute.md'), 'utf-8');
|
||||
|
||||
test('Phase 8 names the main-merge-gate lifecycle event', () => {
|
||||
assert.ok(
|
||||
CMD.includes('main-merge-gate'),
|
||||
'commands/trekexecute.md should emit `main-merge-gate` from Phase 8',
|
||||
);
|
||||
});
|
||||
|
||||
test('Phase 8 documents both approved + declined event branches', () => {
|
||||
assert.ok(CMD.includes('main-merge-approved'), 'should emit main-merge-approved on confirm');
|
||||
assert.ok(CMD.includes('main-merge-declined'), 'should emit main-merge-declined on decline');
|
||||
});
|
||||
|
||||
test('Phase 8 documents the --resume recovery surface for the main-merge gate', () => {
|
||||
assert.ok(
|
||||
CMD.includes('--resume re-enters'),
|
||||
'Phase 8 should document that `--resume re-enters at the gate` after a decline',
|
||||
);
|
||||
});
|
||||
|
||||
test('Phase 8 main-merge gate is always-on (regardless of gates_mode)', () => {
|
||||
// Main-merge gate is the one boundary that pauses on every run; the prose
|
||||
// must say so explicitly so the contract survives copy-edit drift.
|
||||
assert.ok(
|
||||
/always[\s\S]{0,200}gates_mode|gates_mode[\s\S]{0,200}always|always pauses on every run/.test(CMD),
|
||||
'Phase 8 should state main-merge gate is always-on, regardless of gates_mode',
|
||||
);
|
||||
});
|
||||
92
plugins/voyage/tests/lib/manifest-schema-extensions.test.mjs
Normal file
92
plugins/voyage/tests/lib/manifest-schema-extensions.test.mjs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// tests/lib/manifest-schema-extensions.test.mjs
|
||||
// Cover the OPTIONAL_KEYS extension to lib/parsers/manifest-yaml.mjs:
|
||||
// - skip_commit_check (boolean, default false)
|
||||
// - memory_write (boolean, default false)
|
||||
//
|
||||
// Defaults must NOT break the REQUIRED_KEYS contract.
|
||||
// Non-boolean values must produce MANIFEST_OPTIONAL_TYPE error.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { parseManifest, OPTIONAL_KEYS } from '../../lib/parsers/manifest-yaml.mjs';
|
||||
|
||||
const BASE = `### Step 1: Cover
|
||||
- Manifest:
|
||||
\`\`\`yaml
|
||||
manifest:
|
||||
expected_paths:
|
||||
- lib/foo.mjs
|
||||
min_file_count: 1
|
||||
commit_message_pattern: "^feat:"
|
||||
bash_syntax_check: []
|
||||
forbidden_paths: []
|
||||
must_contain: []`;
|
||||
|
||||
function bodyWithExtras(extras) {
|
||||
return `${BASE}\n${extras}\n \`\`\`\n`;
|
||||
}
|
||||
|
||||
function bodyOnlyRequired() {
|
||||
return `${BASE}\n \`\`\`\n`;
|
||||
}
|
||||
|
||||
test('OPTIONAL_KEYS exports skip_commit_check + memory_write', () => {
|
||||
assert.deepEqual(
|
||||
[...OPTIONAL_KEYS].sort(),
|
||||
['memory_write', 'skip_commit_check'].sort(),
|
||||
'OPTIONAL_KEYS export drift — pin contract',
|
||||
);
|
||||
});
|
||||
|
||||
test('absence of optional keys → defaults to false (both fields)', () => {
|
||||
const r = parseManifest(bodyOnlyRequired());
|
||||
assert.equal(r.valid, true, JSON.stringify(r.errors));
|
||||
assert.equal(r.parsed.skip_commit_check, false);
|
||||
assert.equal(r.parsed.memory_write, false);
|
||||
});
|
||||
|
||||
test('skip_commit_check: true honored', () => {
|
||||
const r = parseManifest(bodyWithExtras(' skip_commit_check: true'));
|
||||
assert.equal(r.valid, true, JSON.stringify(r.errors));
|
||||
assert.equal(r.parsed.skip_commit_check, true);
|
||||
assert.equal(r.parsed.memory_write, false);
|
||||
});
|
||||
|
||||
test('memory_write: true honored', () => {
|
||||
const r = parseManifest(bodyWithExtras(' memory_write: true'));
|
||||
assert.equal(r.valid, true, JSON.stringify(r.errors));
|
||||
assert.equal(r.parsed.memory_write, true);
|
||||
assert.equal(r.parsed.skip_commit_check, false);
|
||||
});
|
||||
|
||||
test('both optional fields together — both honored', () => {
|
||||
const r = parseManifest(bodyWithExtras(' skip_commit_check: true\n memory_write: true'));
|
||||
assert.equal(r.valid, true, JSON.stringify(r.errors));
|
||||
assert.equal(r.parsed.skip_commit_check, true);
|
||||
assert.equal(r.parsed.memory_write, true);
|
||||
});
|
||||
|
||||
test('skip_commit_check: non-boolean rejected with MANIFEST_OPTIONAL_TYPE', () => {
|
||||
const r = parseManifest(bodyWithExtras(' skip_commit_check: "yes"'));
|
||||
assert.equal(r.valid, false);
|
||||
const found = r.errors.find(e => e.code === 'MANIFEST_OPTIONAL_TYPE');
|
||||
assert.ok(found, `expected MANIFEST_OPTIONAL_TYPE, got: ${JSON.stringify(r.errors)}`);
|
||||
assert.match(found.message, /skip_commit_check/);
|
||||
});
|
||||
|
||||
test('memory_write: numeric rejected with MANIFEST_OPTIONAL_TYPE', () => {
|
||||
const r = parseManifest(bodyWithExtras(' memory_write: 1'));
|
||||
assert.equal(r.valid, false);
|
||||
const found = r.errors.find(e => e.code === 'MANIFEST_OPTIONAL_TYPE');
|
||||
assert.ok(found, `expected MANIFEST_OPTIONAL_TYPE, got: ${JSON.stringify(r.errors)}`);
|
||||
assert.match(found.message, /memory_write/);
|
||||
});
|
||||
|
||||
test('extension does NOT break REQUIRED_KEYS contract', () => {
|
||||
const r = parseManifest(bodyOnlyRequired());
|
||||
assert.equal(r.valid, true);
|
||||
for (const k of ['expected_paths', 'min_file_count', 'commit_message_pattern',
|
||||
'bash_syntax_check', 'forbidden_paths', 'must_contain']) {
|
||||
assert.ok(k in r.parsed, `required key ${k} missing after extension`);
|
||||
}
|
||||
});
|
||||
138
plugins/voyage/tests/lib/manifest-yaml.test.mjs
Normal file
138
plugins/voyage/tests/lib/manifest-yaml.test.mjs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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('parseManifest — must_contain list-of-dicts (real-world template form)', () => {
|
||||
const body = `### Step 1: Real
|
||||
- Manifest:
|
||||
\`\`\`yaml
|
||||
manifest:
|
||||
expected_paths:
|
||||
- a.json
|
||||
- b.md
|
||||
min_file_count: 2
|
||||
commit_message_pattern: "^chore:"
|
||||
bash_syntax_check: []
|
||||
forbidden_paths:
|
||||
- CHANGELOG.md
|
||||
must_contain:
|
||||
- path: a.json
|
||||
pattern: '"version": "2\\.3\\.0"'
|
||||
- path: b.md
|
||||
pattern: "version-blue"
|
||||
\`\`\`
|
||||
`;
|
||||
const r = parseManifest(body);
|
||||
assert.equal(r.valid, true, JSON.stringify(r.errors));
|
||||
assert.equal(r.parsed.must_contain.length, 2);
|
||||
assert.equal(r.parsed.must_contain[0].path, 'a.json');
|
||||
assert.equal(r.parsed.must_contain[1].path, 'b.md');
|
||||
assert.equal(r.parsed.forbidden_paths[0], 'CHANGELOG.md');
|
||||
});
|
||||
|
||||
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)));
|
||||
});
|
||||
134
plugins/voyage/tests/lib/plan-review-dedup.test.mjs
Normal file
134
plugins/voyage/tests/lib/plan-review-dedup.test.mjs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// tests/lib/plan-review-dedup.test.mjs
|
||||
// Cover lib/review/plan-review-dedup.mjs:
|
||||
// - identical findings dedupe to 1 (exact-id path)
|
||||
// - distinct findings stay separate
|
||||
// - jaccard threshold 0.7 catches near-duplicates
|
||||
// - empty / missing payloads tolerated
|
||||
// - CLI shim emits parseable JSON on stdout
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dedupFindings, tokenize, DEFAULT_THRESHOLD } from '../../lib/review/plan-review-dedup.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SHIM = join(HERE, '..', '..', 'lib', 'review', 'plan-review-dedup.mjs');
|
||||
|
||||
function tmp(prefix = 'plan-review-dedup-') {
|
||||
return mkdtempSync(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test('tokenize splits on non-word and lowercases', () => {
|
||||
assert.deepEqual(
|
||||
tokenize('Step 4 LACKS verifiable acceptance!'),
|
||||
['step', '4', 'lacks', 'verifiable', 'acceptance'],
|
||||
);
|
||||
assert.deepEqual(tokenize(''), []);
|
||||
assert.deepEqual(tokenize(undefined), []);
|
||||
});
|
||||
|
||||
test('DEFAULT_THRESHOLD is 0.7 per plan-v2 spec', () => {
|
||||
assert.equal(DEFAULT_THRESHOLD, 0.7);
|
||||
});
|
||||
|
||||
test('identical findings (same file/line/rule_key) dedupe to 1, raised_by merged', () => {
|
||||
const sources = [
|
||||
{ agent: 'plan-critic', payload: { agent: 'plan-critic', findings: [{ file: 'plan.md', line: 42, rule_key: 'PC1', text: 'Step 4 lacks verifiable acceptance criteria' }] } },
|
||||
{ agent: 'scope-guardian', payload: { agent: 'scope-guardian', findings: [{ file: 'plan.md', line: 42, rule_key: 'PC1', text: 'Step 4 lacks verifiable acceptance criteria' }] } },
|
||||
];
|
||||
const r = dedupFindings(sources);
|
||||
assert.equal(r.findings.length, 1);
|
||||
assert.deepEqual(r.findings[0].raised_by.sort(), ['plan-critic', 'scope-guardian']);
|
||||
assert.equal(r.dedup_stats.total_in, 2);
|
||||
assert.equal(r.dedup_stats.total_out, 1);
|
||||
assert.equal(r.dedup_stats.exact_id_dups, 1);
|
||||
});
|
||||
|
||||
test('distinct findings (different file/line/rule_key) stay separate', () => {
|
||||
const sources = [
|
||||
{ agent: 'plan-critic', payload: { findings: [
|
||||
{ file: 'plan.md', line: 10, rule_key: 'PC1', text: 'thing one' },
|
||||
{ file: 'plan.md', line: 20, rule_key: 'PC2', text: 'thing two unrelated entirely' },
|
||||
] } },
|
||||
];
|
||||
const r = dedupFindings(sources);
|
||||
assert.equal(r.findings.length, 2);
|
||||
assert.equal(r.dedup_stats.exact_id_dups, 0);
|
||||
assert.equal(r.dedup_stats.jaccard_dups, 0);
|
||||
});
|
||||
|
||||
test('jaccard ≥ 0.7 on near-duplicate text merges (different file/line so id differs)', () => {
|
||||
const sources = [
|
||||
{ agent: 'plan-critic', payload: { findings: [{ file: 'plan.md', line: 10, rule_key: 'PC1', text: 'step lacks verifiable acceptance criteria for path A' }] } },
|
||||
{ agent: 'scope-guardian', payload: { findings: [{ file: 'plan.md', line: 11, rule_key: 'SG1', text: 'step lacks verifiable acceptance criteria for path A' }] } },
|
||||
];
|
||||
const r = dedupFindings(sources);
|
||||
assert.equal(r.findings.length, 1, 'jaccard merge should collapse near-duplicates');
|
||||
assert.deepEqual(r.findings[0].raised_by.sort(), ['plan-critic', 'scope-guardian']);
|
||||
assert.equal(r.dedup_stats.jaccard_dups, 1);
|
||||
});
|
||||
|
||||
test('jaccard below threshold keeps both findings separate', () => {
|
||||
const sources = [
|
||||
{ agent: 'plan-critic', payload: { findings: [{ file: 'a.md', line: 1, rule_key: 'PC1', text: 'database migration risk' }] } },
|
||||
{ agent: 'scope-guardian', payload: { findings: [{ file: 'b.md', line: 2, rule_key: 'SG1', text: 'unrelated frontend hover state polish' }] } },
|
||||
];
|
||||
const r = dedupFindings(sources);
|
||||
assert.equal(r.findings.length, 2);
|
||||
assert.equal(r.dedup_stats.jaccard_dups, 0);
|
||||
});
|
||||
|
||||
test('empty / missing payloads tolerated (single-agent input)', () => {
|
||||
const r = dedupFindings([
|
||||
{ agent: 'plan-critic', payload: { findings: [{ file: 'a.md', line: 1, rule_key: 'PC1', text: 'one' }] } },
|
||||
{ agent: 'scope-guardian', payload: null },
|
||||
]);
|
||||
assert.equal(r.findings.length, 1);
|
||||
assert.deepEqual(r.findings[0].raised_by, ['plan-critic']);
|
||||
});
|
||||
|
||||
test('all sources empty → empty result, dedup_stats zeros', () => {
|
||||
const r = dedupFindings([
|
||||
{ agent: 'plan-critic', payload: null },
|
||||
{ agent: 'scope-guardian', payload: { findings: [] } },
|
||||
]);
|
||||
assert.equal(r.findings.length, 0);
|
||||
assert.equal(r.dedup_stats.total_in, 0);
|
||||
assert.equal(r.dedup_stats.total_out, 0);
|
||||
});
|
||||
|
||||
test('CLI shim parses input files and emits valid deduped JSON', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const planCritic = join(dir, 'pc.json');
|
||||
const scopeGuardian = join(dir, 'sg.json');
|
||||
writeFileSync(planCritic, JSON.stringify({
|
||||
agent: 'plan-critic',
|
||||
findings: [{ file: 'plan.md', line: 5, rule_key: 'PC1', text: 'duplicate finding shared by both' }],
|
||||
}));
|
||||
writeFileSync(scopeGuardian, JSON.stringify({
|
||||
agent: 'scope-guardian',
|
||||
findings: [{ file: 'plan.md', line: 5, rule_key: 'PC1', text: 'duplicate finding shared by both' }],
|
||||
}));
|
||||
const out = execFileSync(process.execPath, [
|
||||
SHIM, '--plan-critic', planCritic, '--scope-guardian', scopeGuardian,
|
||||
], { encoding: 'utf-8' });
|
||||
const parsed = JSON.parse(out);
|
||||
assert.equal(parsed.findings.length, 1);
|
||||
assert.deepEqual(parsed.findings[0].raised_by.sort(), ['plan-critic', 'scope-guardian']);
|
||||
assert.equal(parsed.dedup_stats.total_out, 1);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI shim tolerates missing input files (returns empty deduped JSON)', () => {
|
||||
const out = execFileSync(process.execPath, [SHIM], { encoding: 'utf-8' });
|
||||
const parsed = JSON.parse(out);
|
||||
assert.equal(parsed.findings.length, 0);
|
||||
assert.equal(parsed.dedup_stats.total_in, 0);
|
||||
});
|
||||
137
plugins/voyage/tests/lib/plan-schema.test.mjs
Normal file
137
plugins/voyage/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);
|
||||
});
|
||||
148
plugins/voyage/tests/lib/project-discovery.test.mjs
Normal file
148
plugins/voyage/tests/lib/project-discovery.test.mjs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
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(), 'trekplan-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);
|
||||
});
|
||||
|
||||
test('discoverProject — finds review.md when present', () => {
|
||||
const root = setupProject({
|
||||
'brief.md': 'b',
|
||||
'review.md': 'r',
|
||||
});
|
||||
try {
|
||||
const a = discoverProject(root);
|
||||
assert.equal(a.review, join(root, 'review.md'));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('discoverProject — review null when absent', () => {
|
||||
const root = setupProject({
|
||||
'brief.md': 'b',
|
||||
});
|
||||
try {
|
||||
const a = discoverProject(root);
|
||||
assert.equal(a.review, null);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('checkPhaseRequirements — review phase needs brief (error) and tolerates missing progress (warning)', () => {
|
||||
// Missing brief → error
|
||||
const r1 = checkPhaseRequirements({ brief: null, progress: null }, 'review');
|
||||
assert.equal(r1.valid, false);
|
||||
assert.ok(r1.errors.find(e => e.code === 'PROJECT_NO_BRIEF'));
|
||||
|
||||
// Has brief, no progress → valid (with warning)
|
||||
const r2 = checkPhaseRequirements({ brief: 'x', progress: null }, 'review');
|
||||
assert.equal(r2.valid, true, JSON.stringify(r2));
|
||||
assert.ok(r2.warnings.find(w => w.code === 'PROJECT_NO_PROGRESS'));
|
||||
|
||||
// Has both → valid, no warning
|
||||
const r3 = checkPhaseRequirements({ brief: 'x', progress: 'p' }, 'review');
|
||||
assert.equal(r3.valid, true);
|
||||
assert.equal(r3.warnings.length, 0);
|
||||
});
|
||||
69
plugins/voyage/tests/lib/review-determinism.test.mjs
Normal file
69
plugins/voyage/tests/lib/review-determinism.test.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// tests/lib/review-determinism.test.mjs
|
||||
// SC4 determinism floor — Jaccard pipeline test.
|
||||
//
|
||||
// Reads two synthetic review-run fixtures (A ⊂ B), parses their findings
|
||||
// arrays from frontmatter, and asserts:
|
||||
// 1. Jaccard(A, B) ≥ 0.70 (the SC4 brief threshold)
|
||||
// 2. every finding-ID is 40-char hex (matches lib/parsers/finding-id.mjs format)
|
||||
// 3. no duplicate IDs within either run
|
||||
//
|
||||
// This test exercises the Jaccard PIPELINE on a known input. It does NOT
|
||||
// measure real-LLM determinism — that is deferred to v1.1, see
|
||||
// tests/fixtures/trekreview/README.md.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { jaccardSimilarity } from '../../lib/parsers/jaccard.mjs';
|
||||
import { parseDocument } from '../../lib/util/frontmatter.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
|
||||
const HEX_ID_RE = /^[0-9a-f]{40}$/;
|
||||
const SC4_THRESHOLD = 0.70;
|
||||
|
||||
function loadFindings(rel) {
|
||||
const text = readFileSync(join(ROOT, rel), 'utf-8');
|
||||
const doc = parseDocument(text);
|
||||
assert.ok(doc.valid, `frontmatter of ${rel} did not parse: ${(doc.errors || []).map(e => e.message).join(', ')}`);
|
||||
const findings = doc.parsed.frontmatter && doc.parsed.frontmatter.findings;
|
||||
assert.ok(Array.isArray(findings), `frontmatter.findings of ${rel} is not an array`);
|
||||
return findings;
|
||||
}
|
||||
|
||||
test('review determinism — Jaccard of fixture run-A vs run-B meets SC4 threshold (0.70)', () => {
|
||||
const a = loadFindings('tests/fixtures/trekreview/review-run-A.md');
|
||||
const b = loadFindings('tests/fixtures/trekreview/review-run-B.md');
|
||||
const jaccard = jaccardSimilarity(a, b);
|
||||
assert.ok(
|
||||
jaccard >= SC4_THRESHOLD,
|
||||
`Jaccard(A, B) = ${jaccard} < ${SC4_THRESHOLD} (SC4 threshold). ` +
|
||||
`Fixtures may have drifted — recompute IDs via lib/parsers/finding-id.mjs.`,
|
||||
);
|
||||
});
|
||||
|
||||
test('review determinism — finding IDs are 40-char hex', () => {
|
||||
for (const rel of ['tests/fixtures/trekreview/review-run-A.md', 'tests/fixtures/trekreview/review-run-B.md']) {
|
||||
const findings = loadFindings(rel);
|
||||
for (const id of findings) {
|
||||
assert.ok(
|
||||
typeof id === 'string' && HEX_ID_RE.test(id),
|
||||
`${rel}: ID ${JSON.stringify(id)} is not a 40-char lowercase hex string`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('review determinism — no duplicate IDs within run', () => {
|
||||
for (const rel of ['tests/fixtures/trekreview/review-run-A.md', 'tests/fixtures/trekreview/review-run-B.md']) {
|
||||
const findings = loadFindings(rel);
|
||||
assert.strictEqual(
|
||||
new Set(findings).size,
|
||||
findings.length,
|
||||
`${rel}: contains duplicate finding-IDs (${findings.length} entries vs ${new Set(findings).size} unique)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
54
plugins/voyage/tests/lib/rule-catalogue.test.mjs
Normal file
54
plugins/voyage/tests/lib/rule-catalogue.test.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import {
|
||||
RULE_CATALOGUE,
|
||||
RULE_KEYS,
|
||||
SEVERITY_VALUES,
|
||||
CATEGORY_VALUES,
|
||||
getRule,
|
||||
} from '../../lib/review/rule-catalogue.mjs';
|
||||
|
||||
test('RULE_CATALOGUE — every entry has all 4 required fields', () => {
|
||||
for (const entry of RULE_CATALOGUE) {
|
||||
assert.ok(typeof entry.rule_key === 'string' && entry.rule_key.length > 0, `bad rule_key: ${entry.rule_key}`);
|
||||
assert.ok(typeof entry.severity === 'string' && entry.severity.length > 0, `bad severity: ${entry.severity}`);
|
||||
assert.ok(typeof entry.category === 'string' && entry.category.length > 0, `bad category: ${entry.category}`);
|
||||
assert.ok(typeof entry.description === 'string' && entry.description.length > 0, `bad description for ${entry.rule_key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('RULE_CATALOGUE — no duplicate rule_key', () => {
|
||||
const seen = new Set();
|
||||
for (const entry of RULE_CATALOGUE) {
|
||||
assert.ok(!seen.has(entry.rule_key), `duplicate rule_key: ${entry.rule_key}`);
|
||||
seen.add(entry.rule_key);
|
||||
}
|
||||
assert.equal(seen.size, RULE_CATALOGUE.length);
|
||||
});
|
||||
|
||||
test('RULE_CATALOGUE — all severity values within enum', () => {
|
||||
for (const entry of RULE_CATALOGUE) {
|
||||
assert.ok(SEVERITY_VALUES.includes(entry.severity), `${entry.rule_key} has invalid severity: ${entry.severity}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('RULE_CATALOGUE — all category values within enum', () => {
|
||||
for (const entry of RULE_CATALOGUE) {
|
||||
assert.ok(CATEGORY_VALUES.includes(entry.category), `${entry.rule_key} has invalid category: ${entry.category}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('RULE_KEYS.size === RULE_CATALOGUE.length (== 12) — pinned by doc-consistency', () => {
|
||||
assert.equal(RULE_KEYS.size, RULE_CATALOGUE.length);
|
||||
assert.equal(RULE_CATALOGUE.length, 12);
|
||||
});
|
||||
|
||||
test('getRule — returns frozen entry on hit, null on miss, null on bad input', () => {
|
||||
const hit = getRule('UNIMPLEMENTED_CRITERION');
|
||||
assert.ok(hit !== null);
|
||||
assert.equal(hit.severity, 'BLOCKER');
|
||||
assert.throws(() => { hit.severity = 'MINOR'; }); // frozen
|
||||
assert.equal(getRule('NOPE'), null);
|
||||
assert.equal(getRule(undefined), null);
|
||||
assert.equal(getRule(123), null);
|
||||
});
|
||||
63
plugins/voyage/tests/lib/source-findings.test.mjs
Normal file
63
plugins/voyage/tests/lib/source-findings.test.mjs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// tests/lib/source-findings.test.mjs
|
||||
// SC3(b) structural test for Handover 6.
|
||||
//
|
||||
// The brief requires `plan.md` produced from a `type: trekreview` brief to
|
||||
// contain `source_findings: [<id>, ...]` in its frontmatter. Without an
|
||||
// automated test, SC3(b) is unverified.
|
||||
//
|
||||
// This test exercises the STRUCTURAL contract:
|
||||
// 1. plan-validator accepts a plan with source_findings (additive optional field)
|
||||
// 2. frontmatter parser extracts source_findings as an array of strings
|
||||
// 3. each ID is 40-char hex (matches lib/parsers/finding-id.mjs format)
|
||||
//
|
||||
// LLM behavior (the planner actually emitting source_findings when it consumes
|
||||
// a review.md) is non-testable without live invocation — this test only covers
|
||||
// the schema half.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDocument } from '../../lib/util/frontmatter.mjs';
|
||||
import { validatePlan } from '../../lib/validators/plan-validator.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const FIXTURE = join(ROOT, 'tests/fixtures/trekreview/plan-with-source-findings.md');
|
||||
|
||||
const HEX_ID_RE = /^[0-9a-f]{40}$/;
|
||||
|
||||
test('plan-validator accepts plan.md with source_findings field', () => {
|
||||
const result = validatePlan(FIXTURE, { strict: true });
|
||||
assert.ok(
|
||||
result.valid,
|
||||
`plan-validator rejected synthetic plan with source_findings: ` +
|
||||
`${(result.errors || []).map(e => `[${e.code}] ${e.message}`).join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('frontmatter parser extracts source_findings as array of strings', () => {
|
||||
const text = readFileSync(FIXTURE, 'utf-8');
|
||||
const doc = parseDocument(text);
|
||||
assert.ok(doc.valid, `frontmatter did not parse: ${(doc.errors || []).map(e => e.message).join(', ')}`);
|
||||
const sf = doc.parsed.frontmatter && doc.parsed.frontmatter.source_findings;
|
||||
assert.ok(Array.isArray(sf), `frontmatter.source_findings is not an array (got ${typeof sf})`);
|
||||
assert.ok(sf.length > 0, 'frontmatter.source_findings is empty — fixture should carry at least one ID');
|
||||
for (const id of sf) {
|
||||
assert.strictEqual(typeof id, 'string', `source_findings entry is not a string: ${JSON.stringify(id)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('source_findings IDs match the format from finding-id.mjs (40-char hex)', () => {
|
||||
const text = readFileSync(FIXTURE, 'utf-8');
|
||||
const doc = parseDocument(text);
|
||||
const sf = doc.parsed.frontmatter.source_findings;
|
||||
for (const id of sf) {
|
||||
assert.ok(
|
||||
HEX_ID_RE.test(id),
|
||||
`source_findings ID ${JSON.stringify(id)} is not 40-char lowercase hex ` +
|
||||
`(format produced by lib/parsers/finding-id.mjs computeFindingId)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
158
plugins/voyage/tests/lib/stats-event-emit.test.mjs
Normal file
158
plugins/voyage/tests/lib/stats-event-emit.test.mjs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// tests/lib/stats-event-emit.test.mjs
|
||||
// Cover lib/stats/event-emit.mjs:
|
||||
// - emit appends a JSONL line with required ISO-8601 ts
|
||||
// - known_event flag distinguishes recognized vs unknown events
|
||||
// - missing CLAUDE_PLUGIN_DATA does NOT throw (stats must never block)
|
||||
// - CLI shim parses --payload JSON and writes via emit()
|
||||
// - concurrent appends don't corrupt the file (smoke test)
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { emit, buildRecord, resolveStatsPath, KNOWN_EVENTS } from '../../lib/stats/event-emit.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SHIM = join(HERE, '..', '..', 'lib', 'stats', 'event-emit.mjs');
|
||||
|
||||
const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
|
||||
function tmp(prefix = 'stats-event-emit-') {
|
||||
return mkdtempSync(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test('KNOWN_EVENTS contains plan-v2 spec set', () => {
|
||||
for (const e of ['brief-approved', 'main-merge-gate', 'user_input']) {
|
||||
assert.ok(KNOWN_EVENTS.has(e), `missing recognized event: ${e}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildRecord emits ISO-8601 ts (REQUIRED per SC4)', () => {
|
||||
const r = buildRecord('brief-approved', { foo: 1 });
|
||||
assert.match(r.ts, ISO_8601_RE);
|
||||
assert.equal(r.event, 'brief-approved');
|
||||
assert.equal(r.known_event, true);
|
||||
assert.deepEqual(r.payload, { foo: 1 });
|
||||
});
|
||||
|
||||
test('buildRecord marks unrecognized events known_event: false', () => {
|
||||
const r = buildRecord('totally-made-up-event');
|
||||
assert.equal(r.known_event, false);
|
||||
assert.deepEqual(r.payload, {});
|
||||
});
|
||||
|
||||
test('buildRecord rejects empty event name', () => {
|
||||
assert.throws(() => buildRecord(''), TypeError);
|
||||
assert.throws(() => buildRecord(null), TypeError);
|
||||
});
|
||||
|
||||
test('emit appends one JSONL line per call', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const path = join(dir, 'stats.jsonl');
|
||||
const r1 = emit('brief-approved', { ok: true }, { path });
|
||||
const r2 = emit('main-merge-gate', { branch: 'main' }, { path });
|
||||
assert.equal(r1.written, true);
|
||||
assert.equal(r2.written, true);
|
||||
const lines = readFileSync(path, 'utf-8').trim().split('\n');
|
||||
assert.equal(lines.length, 2);
|
||||
const a = JSON.parse(lines[0]);
|
||||
const b = JSON.parse(lines[1]);
|
||||
assert.match(a.ts, ISO_8601_RE);
|
||||
assert.match(b.ts, ISO_8601_RE);
|
||||
assert.equal(a.event, 'brief-approved');
|
||||
assert.equal(b.event, 'main-merge-gate');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('emit creates the stats directory on demand', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const path = join(dir, 'nested', 'stats.jsonl');
|
||||
const r = emit('user_input', {}, { path });
|
||||
assert.equal(r.written, true);
|
||||
assert.ok(existsSync(path));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('emit with no CLAUDE_PLUGIN_DATA returns { written: false } (silent skip)', () => {
|
||||
const r = emit('brief-approved', {}, { env: {} });
|
||||
assert.equal(r.written, false);
|
||||
assert.equal(r.path, null);
|
||||
assert.match(r.reason, /CLAUDE_PLUGIN_DATA unset/);
|
||||
});
|
||||
|
||||
test('emit never throws when stats path is unwritable', () => {
|
||||
// Pointing at a path under a non-existent dir on a readonly mount would
|
||||
// be brittle in CI; instead, force the env-resolved path to be empty
|
||||
// and confirm no exception leaks.
|
||||
let threw = false;
|
||||
try { emit('user_input', { foo: 'bar' }, { env: {} }); }
|
||||
catch { threw = true; }
|
||||
assert.equal(threw, false);
|
||||
});
|
||||
|
||||
test('resolveStatsPath honors CLAUDE_PLUGIN_DATA env var', () => {
|
||||
const r = resolveStatsPath({ CLAUDE_PLUGIN_DATA: '/var/data/plugin' });
|
||||
assert.equal(r, '/var/data/plugin/trekexecute-stats.jsonl');
|
||||
assert.equal(resolveStatsPath({}), null);
|
||||
});
|
||||
|
||||
test('CLI shim writes via emit when CLAUDE_PLUGIN_DATA is set', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
execFileSync(process.execPath, [
|
||||
SHIM, '--event', 'brief-approved', '--payload', '{"foo":42}',
|
||||
], {
|
||||
env: { ...process.env, CLAUDE_PLUGIN_DATA: dir },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const path = join(dir, 'trekexecute-stats.jsonl');
|
||||
assert.ok(existsSync(path));
|
||||
const line = readFileSync(path, 'utf-8').trim();
|
||||
const parsed = JSON.parse(line);
|
||||
assert.equal(parsed.event, 'brief-approved');
|
||||
assert.deepEqual(parsed.payload, { foo: 42 });
|
||||
assert.match(parsed.ts, ISO_8601_RE);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI shim with malformed --payload returns reason payload-not-json (exit 0)', () => {
|
||||
const r = execFileSync(process.execPath, [
|
||||
SHIM, '--event', 'user_input', '--payload', 'not-json{{',
|
||||
], { encoding: 'utf-8' });
|
||||
const parsed = JSON.parse(r.trim());
|
||||
assert.equal(parsed.written, false);
|
||||
assert.equal(parsed.reason, 'payload-not-json');
|
||||
});
|
||||
|
||||
test('concurrent appends do not corrupt JSONL (smoke)', async () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const path = join(dir, 'stats.jsonl');
|
||||
const N = 25;
|
||||
await Promise.all(
|
||||
Array.from({ length: N }, (_, i) =>
|
||||
Promise.resolve().then(() => emit('user_input', { i }, { path })),
|
||||
),
|
||||
);
|
||||
const lines = readFileSync(path, 'utf-8').trim().split('\n');
|
||||
assert.equal(lines.length, N);
|
||||
for (const l of lines) {
|
||||
const parsed = JSON.parse(l); // throws if any line is corrupt
|
||||
assert.ok('ts' in parsed);
|
||||
assert.equal(parsed.event, 'user_input');
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue