voyage/tests/lib/review-recurrence.test.mjs
2026-10-26 17:27:33 +01:00

475 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// tests/lib/review-recurrence.test.mjs
// P5 — the project's memory (ordre 5). A finding class (the same rule_key) that
// shows up in two review.md files in the target repo becomes ONE proposal; one
// sighting is no proposal. Proposals are read from the review.md files the repo
// already has (no new store), and an approved one is written only between
// Voyage's own markers, under a size budget, with CLAUDE.md getting a pointer.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import {
mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, symlinkSync, lstatSync, chmodSync, readdirSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, delimiter } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const CLI = join(ROOT, 'lib', 'review', 'recurrence.mjs');
const mod = existsSync(CLI) ? await import('../../lib/review/recurrence.mjs') : null;
function reviewText({ slug, sha, findings }) {
const json = JSON.stringify({
verdict: 'WARN',
counts: { BLOCKER: 0, MAJOR: findings.length, MINOR: 0, SUGGESTION: 0 },
findings,
}, null, 2);
return `---
type: trekreview
review_version: "1.1"
task: "${slug}"
slug: ${slug}
project_dir: .claude/projects/2026-10-0${sha.length % 9}-${slug}/
brief_path: .claude/projects/x/brief.md
scope_sha_end: ${sha}
reviewed_files_count: 1
verdict: WARN
findings:
${findings.map((f) => ` - ${f.id}`).join('\n')}
---
# Review: ${slug}
## Executive Summary
x
## Coverage
x
## Remediation Summary
x
\`\`\`json
${json}
\`\`\`
`;
}
function finding(id, rule_key, extra = {}) {
return {
id: id.padEnd(40, '0'), severity: 'MAJOR', rule_key, file: 'lib/a.mjs', line: 3,
title: `${rule_key} title ${id}`, detail: 'd', recommended_action: `Fix it (${id}).`, ...extra,
};
}
function repoWith(reviews) {
const dir = mkdtempSync(join(tmpdir(), 'recurrence-'));
reviews.forEach((r, i) => {
const p = join(dir, '.claude', 'projects', `2026-10-0${i + 1}-${r.slug}`);
mkdirSync(p, { recursive: true });
writeFileSync(join(p, 'review.md'), reviewText(r));
});
return dir;
}
/** The same reviews written as `<claudeDir>/projects/...` (a .claude directory kept elsewhere). */
function reviewsInto(claudeDir, reviews) {
reviews.forEach((r, i) => {
const p = join(claudeDir, 'projects', `2026-10-0${i + 1}-${r.slug}`);
mkdirSync(p, { recursive: true });
writeFileSync(join(p, 'review.md'), reviewText(r));
});
}
test('recurrence — the module exists', () => {
assert.ok(mod, 'lib/review/recurrence.mjs must exist (P5)');
});
test('recurrence — two review.md with the same finding class → exactly one proposal', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST', { file: 'lib/b.mjs' })] },
]);
try {
const { proposals } = mod.proposeLessons(repo);
assert.equal(proposals.length, 1);
assert.equal(proposals[0].rule_key, 'MISSING_TEST');
assert.equal(proposals[0].evidence.length, 2, 'the proposal carries both findings as its evidence');
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — one review.md with the class → no proposal', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'PLACEHOLDER_IN_CODE')] },
]);
try {
assert.deepEqual(mod.proposeLessons(repo).proposals, []);
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — the same class twice inside ONE review is still one sighting → no proposal', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST'), finding('a2', 'MISSING_TEST', { line: 9 })] },
]);
try {
assert.deepEqual(mod.proposeLessons(repo).proposals, []);
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — routing: mechanical → a check, judgement → a .claude/rules/ file', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'PLACEHOLDER_IN_CODE'), finding('a2', 'PLAN_EXECUTE_DRIFT')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'PLACEHOLDER_IN_CODE'), finding('b2', 'PLAN_EXECUTE_DRIFT')] },
]);
try {
const byKey = Object.fromEntries(mod.proposeLessons(repo).proposals.map((p) => [p.rule_key, p]));
assert.equal(byKey.PLACEHOLDER_IN_CODE.route, 'check');
assert.equal(byKey.PLAN_EXECUTE_DRIFT.route, 'rule');
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — the proposed line has the three parts: mistake · fix · how to check', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
try {
const [p] = mod.proposeLessons(repo).proposals;
assert.match(p.line, /Mistake:/);
assert.match(p.line, /Fix:/);
assert.match(p.line, /How to check:/);
assert.equal(mod.validateLessonLine(p.line).valid, true);
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — review-hygiene classes never become target-repo lessons', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_BRIEF_REF'), finding('a2', 'COVERAGE_SILENT_SKIP')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_BRIEF_REF'), finding('b2', 'COVERAGE_SILENT_SKIP')] },
]);
try {
assert.deepEqual(mod.proposeLessons(repo).proposals, []);
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — apply writes only between Voyage markers and gives CLAUDE.md a pointer only', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
try {
writeFileSync(join(repo, 'CLAUDE.md'), '# Project\n\nOperator text stays.\n');
mkdirSync(join(repo, '.claude', 'rules'), { recursive: true });
writeFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), 'Operator preface.\n');
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(r.written, true, JSON.stringify(r));
const rules = readFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), 'utf-8');
assert.ok(rules.startsWith('Operator preface.\n'), 'text outside the markers is untouched');
const start = rules.indexOf(mod.MARK_START);
const end = rules.indexOf(mod.MARK_END);
assert.ok(start >= 0 && end > start);
assert.match(rules.slice(start, end), /MISSING_TEST/);
const claude = readFileSync(join(repo, 'CLAUDE.md'), 'utf-8');
assert.ok(claude.startsWith('# Project\n\nOperator text stays.\n'));
assert.ok(claude.includes(mod.LESSONS_FILE_NAME), 'CLAUDE.md gets a pointer to the rules file');
assert.ok(!claude.includes('Mistake:'), 'CLAUDE.md gets ONLY the pointer, never the lesson');
assert.deepEqual(mod.proposeLessons(repo).proposals, [], 'an applied class is not proposed again');
const again = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(again.written, false, 'applying twice writes nothing');
const claude2 = readFileSync(join(repo, 'CLAUDE.md'), 'utf-8');
assert.equal(claude2, claude, 'the pointer is written once');
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — apply refuses past the size budget (the lesson belongs in a skill then)', () => {
const keys = ['MISSING_TEST', 'PLACEHOLDER_IN_CODE', 'PLAN_EXECUTE_DRIFT', 'SCOPE_CREEP_BUILT'];
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: keys.map((k, i) => finding(`a${i}`, k)) },
{ slug: 'two', sha: 'bbb', findings: keys.map((k, i) => finding(`b${i}`, k)) },
]);
try {
const r = mod.applyLesson(repo, 'MISSING_TEST', { budgetEntries: 0 });
assert.equal(r.written, false);
assert.equal(r.reason, 'over-budget');
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — an edited line without the three parts is refused', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
try {
const r = mod.applyLesson(repo, 'MISSING_TEST', { line: 'just do better' });
assert.equal(r.written, false);
assert.equal(r.reason, 'invalid-line');
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — an earlier committed version of the same review.md counts as history (git, no new store)', () => {
// review.md is overwritten per iteration; git keeps the earlier one.
const repo = repoWith([{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'PLAN_EXECUTE_DRIFT')] }]);
const g = (...a) => spawnSync('git', ['-C', repo, ...a], { encoding: 'utf-8' });
try {
assert.equal(g('init', '-q').status, 0);
g('config', 'user.email', 't@example.invalid');
g('config', 'user.name', 't');
g('add', '.claude');
assert.equal(g('commit', '-q', '-m', 'review 1').status, 0);
assert.deepEqual(mod.proposeLessons(repo).proposals, [], 'one iteration is one sighting');
const p = join(repo, '.claude', 'projects', '2026-10-01-one', 'review.md');
writeFileSync(p, reviewText({ slug: 'one', sha: 'bbbb', findings: [finding('b1', 'PLAN_EXECUTE_DRIFT', { line: 40 })] }));
const { proposals } = mod.proposeLessons(repo);
assert.equal(proposals.length, 1, 'the committed iteration plus the current one recur');
assert.equal(proposals[0].rule_key, 'PLAN_EXECUTE_DRIFT');
} finally { rmSync(repo, { recursive: true, force: true }); }
});
// ---- Independent review of d88245c: each of these was wrong before ----------
test('recurrence — the SAME unfixed finding in two iterations is one defect, not a recurring class', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('cc', 'MISSING_TEST')] },
{ slug: 'one', sha: 'bbbb', findings: [finding('cc', 'MISSING_TEST')] },
]);
try {
assert.deepEqual(mod.proposeLessons(repo).proposals, []);
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — catch-all engine keys and keys normalised from unknown ones are never proposed', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'CODE_REVIEW_DEFECT'), finding('a2', 'SECURITY_REVIEW_VULNERABILITY'), finding('a3', 'PLAN_EXECUTE_DRIFT', { original_rule_key: 'AD_HOC' })] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'CODE_REVIEW_DEFECT'), finding('b2', 'SECURITY_REVIEW_VULNERABILITY'), finding('b3', 'PLAN_EXECUTE_DRIFT', { original_rule_key: 'AD_HOC' })] },
]);
try {
assert.deepEqual(mod.proposeLessons(repo).proposals, []);
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — a proposal built from long titles, actions and marker text is still a line apply accepts', () => {
const long = 'x'.repeat(300);
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST', { title: `<!-- ${long}`, file: `lib/${long}.mjs`, recommended_action: long })] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST', { title: long, recommended_action: `${long} -->` })] },
]);
try {
const [p] = mod.proposeLessons(repo).proposals;
assert.ok(p, 'the proposal is not lost');
assert.equal(mod.validateLessonLine(p.line).valid, true, p.line);
assert.equal(mod.applyLesson(repo, 'MISSING_TEST').written, true);
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — an edited line with \\r or U+2028 (a hidden second lesson) is refused', () => {
for (const sep of ['\r', '
']) {
const line = `Mistake: x${sep}- [SCOPE_CREEP_BUILT] (rule) Mistake: y Fix: z How to check: w`;
assert.equal(mod.validateLessonLine(line).valid, false, JSON.stringify(sep));
}
});
test('recurrence — damaged markers refuse the write instead of crashing', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
try {
mkdirSync(join(repo, '.claude', 'rules'), { recursive: true });
writeFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), `${mod.MARK_END}\n${mod.MARK_START}\n`);
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.deepEqual([r.written, r.reason], [false, 'markers-damaged']);
} finally { rmSync(repo, { recursive: true, force: true }); }
});
// DJ4 (4.5b): the pointer is no longer written through a symlinked CLAUDE.md.
// Committing the link's target would change the R6 exception approved in 4.5a,
// so the link target stays untouched and the result says why.
test('[DJ4] a symlinked CLAUDE.md keeps its link and the link target is untouched', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
try {
writeFileSync(join(repo, 'AGENTS.md'), '# Agents\n');
symlinkSync('AGENTS.md', join(repo, 'CLAUDE.md'));
assert.equal(mod.applyLesson(repo, 'MISSING_TEST').written, true);
assert.ok(lstatSync(join(repo, 'CLAUDE.md')).isSymbolicLink(), 'the link survives');
assert.equal(readFileSync(join(repo, 'AGENTS.md'), 'utf-8'), '# Agents\n',
'the link target is untouched: no pointer is written through the link');
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('[DJ4] a symlinked CLAUDE.md gets no pointer and the result says symlink-skipped', () => {
assert.equal(typeof mod.applyLesson, 'function', 'applyLesson must be exported');
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
try {
writeFileSync(join(repo, 'AGENTS.md'), '# Agents\n');
symlinkSync('AGENTS.md', join(repo, 'CLAUDE.md'));
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(r.written, true, JSON.stringify(r));
assert.equal(r.pointer, 'symlink-skipped', JSON.stringify(r));
assert.match(readFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), 'utf-8'), /MISSING_TEST/,
'the lesson itself is still written');
assert.ok(!readFileSync(join(repo, 'CLAUDE.md'), 'utf-8').includes(mod.POINTER_START), 'no pointer through the link');
} finally { rmSync(repo, { recursive: true, force: true }); }
});
/** A git repository holding the two recurring reviews and a plain CLAUDE.md. */
function gitRepoWithClaude() {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
const init = spawnSync('git', ['-C', repo, 'init', '-q'], { encoding: 'utf-8', env: childEnv() });
assert.equal(init.status, 0, init.stderr);
writeFileSync(join(repo, 'CLAUDE.md'), '# Project\n');
return repo;
}
test('[PTR] an ignored lessons file gets no pointer and the result says ignored-skipped', () => {
assert.equal(typeof mod.applyLesson, 'function', 'applyLesson must be exported');
const repo = gitRepoWithClaude();
const control = gitRepoWithClaude();
try {
writeFileSync(join(repo, '.gitignore'), '.claude/rules/\n');
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(r.written, true, JSON.stringify(r));
assert.equal(r.pointer, 'ignored-skipped', JSON.stringify(r));
assert.equal(readFileSync(join(repo, 'CLAUDE.md'), 'utf-8'), '# Project\n',
'CLAUDE.md gets no pointer to a file git ignores');
// Control: the same repo shape without the ignore rule (check-ignore exits 1) gets its pointer.
const c = mod.applyLesson(control, 'MISSING_TEST');
assert.equal(c.pointer, 'written', JSON.stringify(c));
assert.ok(readFileSync(join(control, 'CLAUDE.md'), 'utf-8').includes(mod.POINTER_START));
} finally {
rmSync(repo, { recursive: true, force: true });
rmSync(control, { recursive: true, force: true });
}
});
test('[PTR] a git that fails check-ignore gives check-failed and no pointer', () => {
const repo = gitRepoWithClaude();
const bin = mkdtempSync(join(tmpdir(), 'recurrence-fake-git-'));
try {
// A fake git first on PATH: every call fails with exit 3 (not 0, 1 or 128).
writeFileSync(join(bin, 'git'), '#!/bin/sh\necho "fake git: refused" >&2\nexit 3\n');
chmodSync(join(bin, 'git'), 0o755);
const r = spawnSync(process.execPath, [CLI, 'apply', '--repo', repo, '--rule-key', 'MISSING_TEST'], {
encoding: 'utf-8',
env: childEnv({ PATH: `${bin}${delimiter}${process.env.PATH ?? ''}` }),
});
assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`);
const out = JSON.parse(r.stdout);
assert.equal(out.written, true, r.stdout);
assert.equal(out.pointer, 'check-failed', `the CLI prints the pointer result: ${r.stdout}`);
assert.equal(typeof out.pointer_note, 'string', `check-failed carries the git note: ${r.stdout}`);
assert.match(out.pointer_note, /check-ignore/, r.stdout);
assert.equal(readFileSync(join(repo, 'CLAUDE.md'), 'utf-8'), '# Project\n', 'no pointer when the check failed');
} finally {
rmSync(repo, { recursive: true, force: true });
rmSync(bin, { recursive: true, force: true });
}
});
test('[DJ4] sibling: a symlinked lessons file is not written through', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
const outside = mkdtempSync(join(tmpdir(), 'recurrence-outside-'));
try {
writeFileSync(join(outside, 'target.md'), 'outside\n');
mkdirSync(join(repo, '.claude', 'rules'), { recursive: true });
const lessons = join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME);
symlinkSync(join(outside, 'target.md'), lessons);
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(r.written, false, JSON.stringify(r));
assert.match(String(r.reason), /symlink/, JSON.stringify(r));
assert.equal(readFileSync(join(outside, 'target.md'), 'utf-8'), 'outside\n', 'the link target is untouched');
assert.ok(lstatSync(lessons).isSymbolicLink(), 'the link itself is left as it was');
} finally {
rmSync(repo, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
}
});
test('[DJ4] sibling: a symlinked .claude/rules directory pointing outside the repo is not written through', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
const outside = mkdtempSync(join(tmpdir(), 'recurrence-outside-'));
try {
symlinkSync(outside, join(repo, '.claude', 'rules'));
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(r.written, false, JSON.stringify(r));
assert.match(String(r.reason), /\.claude\/rules/, `the reason names the directory: ${JSON.stringify(r)}`);
assert.deepEqual(readdirSync(outside), [], 'nothing is written outside the repo');
} finally {
rmSync(repo, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
}
});
test('[DJ4] sibling: a symlinked .claude directory pointing outside the repo gets no directory made outside', () => {
const outside = mkdtempSync(join(tmpdir(), 'recurrence-outside-'));
const repo = mkdtempSync(join(tmpdir(), 'recurrence-'));
try {
// The reviews live outside; the repo's .claude is a link to them.
reviewsInto(outside, [
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
symlinkSync(outside, join(repo, '.claude'));
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(r.written, false, JSON.stringify(r));
assert.match(String(r.reason), /\.claude\b/, `the reason names the directory: ${JSON.stringify(r)}`);
assert.ok(!existsSync(join(outside, 'rules')), 'no directory is created through the link');
} finally {
rmSync(repo, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
}
});
test('[DJ4] sibling: the first lesson creates the missing lessons directory, one level after its parent, and is written', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
try {
assert.ok(!existsSync(join(repo, '.claude', 'rules')), 'fixture: no .claude/rules yet');
const r = mod.applyLesson(repo, 'MISSING_TEST');
assert.equal(r.written, true, JSON.stringify(r));
assert.ok(lstatSync(join(repo, '.claude', 'rules')).isDirectory(), '.claude/rules is created as a directory');
assert.match(readFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), 'utf-8'), /MISSING_TEST/);
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — CLI propose prints JSON and exits 0; a missing repo exits 2', () => {
const repo = repoWith([
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
]);
try {
const r = spawnSync(process.execPath, [CLI, 'propose', '--repo', repo], { encoding: 'utf-8' });
assert.equal(r.status, 0, r.stderr);
assert.equal(JSON.parse(r.stdout).proposals.length, 1);
const missing = spawnSync(process.execPath, [CLI, 'propose', '--repo', join(repo, 'nope')], { encoding: 'utf-8' });
assert.equal(missing.status, 2);
} finally { rmSync(repo, { recursive: true, force: true }); }
});