refactor: simplify pass (trekexecute)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f6bdc2de59
commit
af90e82794
12 changed files with 303 additions and 186 deletions
|
|
@ -7,7 +7,8 @@
|
|||
# "Back to planning" line saves:
|
||||
# a. a new file registered --intent-to-add is parked (exit 0, gone from
|
||||
# the tree and the index, kept under .git/voyage/parked/);
|
||||
# b. a coloured diff (color.diff=always) still parks its new file;
|
||||
# b. a coloured diff (--color=always, as color.diff=always gives it)
|
||||
# still parks its new file;
|
||||
# c. --cwd a subdirectory of the repo parks the root-relative path.
|
||||
# 2. The verdict gate (lib/review/verdict-gate.mjs --json <review> --repo .):
|
||||
# a review of HEAD (0), a later lessons-only commit (0), a later source
|
||||
|
|
@ -44,10 +45,8 @@ mkdir -p "$TMP/hooks-none" || fail "mkdir hooks-none"
|
|||
newrepo() {
|
||||
mkdir -p "$1" || fail "mkdir $1"
|
||||
git -C "$1" init -q || fail "git init $1"
|
||||
git -C "$1" config user.name "Test" || fail "git config user.name"
|
||||
git -C "$1" config user.email "test@example.invalid" || fail "git config user.email"
|
||||
git -C "$1" config commit.gpgsign false || fail "git config commit.gpgsign"
|
||||
git -C "$1" config core.hooksPath "$TMP/hooks-none" || fail "git config core.hooksPath"
|
||||
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' \
|
||||
"$TMP/hooks-none" >> "$1/.git/config" || fail "git config $1"
|
||||
}
|
||||
|
||||
# jsonok <check> <js expression on j>: stdin is JSON; exit 0 when the expression is true.
|
||||
|
|
@ -66,9 +65,12 @@ git -C "$P" commit -q -m "base" || fail "git commit base"
|
|||
START="$(git -C "$P" rev-parse HEAD)" || fail "rev-parse start"
|
||||
PARKED="$P/.git/voyage/parked"
|
||||
|
||||
# savediff <file>: the "Back to planning" save line of commands/trekexecute.md.
|
||||
# savediff <file> [color]: the "Back to planning" save line of commands/trekexecute.md;
|
||||
# with "color", the same diff coloured (--color=always in place of --no-color).
|
||||
savediff() {
|
||||
git -C "$P" -c core.quotepath=off diff --no-ext-diff --no-color --no-renames --src-prefix=a/ --dst-prefix=b/ "$START" > "$1" \
|
||||
color=--no-color
|
||||
[ "${2:-}" = color ] && color=--color=always
|
||||
git -C "$P" -c core.quotepath=off diff --no-ext-diff "$color" --no-renames --src-prefix=a/ --dst-prefix=b/ "$START" > "$1" \
|
||||
|| fail "git diff for $1"
|
||||
}
|
||||
|
||||
|
|
@ -92,8 +94,7 @@ parked_ok a.sh 1
|
|||
# 1b. a coloured diff
|
||||
printf 'stale\n' > "$P/tests/b.sh"
|
||||
git -C "$P" add --intent-to-add -- tests/b.sh || fail "git add --intent-to-add tests/b.sh"
|
||||
git -C "$P" -c color.diff=always diff --no-ext-diff --no-renames --src-prefix=a/ --dst-prefix=b/ "$START" > "$TMP/park-2.diff" \
|
||||
|| fail "git diff (coloured)"
|
||||
savediff "$TMP/park-2.diff" color
|
||||
ESC="$(printf '\033')"
|
||||
grep -q "$ESC" "$TMP/park-2.diff" || fail "park 2: control - the diff holds no colour code"
|
||||
out="$(node "$PARK" --diff "$TMP/park-2.diff" --step 1 --attempt 2 --cwd "$P")" || fail "park 2 (coloured diff) exited non-zero: $out"
|
||||
|
|
|
|||
|
|
@ -464,9 +464,13 @@ function orders(xs) {
|
|||
return xs.flatMap((x, i) => orders([...xs.slice(0, i), ...xs.slice(i + 1)]).map((rest) => [x, ...rest]));
|
||||
}
|
||||
|
||||
/** A row's members, rebuilt from its survivor plus each "Also flagged by" line, as sorted `reviewer|title` strings. */
|
||||
/**
|
||||
* A row's members, rebuilt from its survivor plus each "Also flagged by" line,
|
||||
* as sorted `reviewer|title` strings; an untitled member is named by its
|
||||
* rule_key, as the line names it.
|
||||
*/
|
||||
function members(row) {
|
||||
const out = [`${row.reviewer}|${row.title}`];
|
||||
const out = [`${row.reviewer}|${row.title ?? row.rule_key}`];
|
||||
for (const m of String(row.detail ?? '').matchAll(/^Also flagged by (.+?): (.+)\.$/gm)) out.push(`${m[1]}|${m[2]}`);
|
||||
return out.sort();
|
||||
}
|
||||
|
|
@ -535,13 +539,6 @@ test('[ORD] a full tie on one row: two /code-review MAJORs titled T, one marked
|
|||
assert.deepEqual(split(a), split(b), 'the kept/unverified split');
|
||||
});
|
||||
|
||||
/** A row's member set, rebuilt from its survivor plus each "Also flagged by" line; an untitled member is named by its rule_key, as the line names it. */
|
||||
function memberSet(row) {
|
||||
const out = [`${row.reviewer}|${row.title ?? row.rule_key}`];
|
||||
for (const m of String(row.detail ?? '').matchAll(/^Also flagged by (.+?): (.+)\.$/gm)) out.push(`${m[1]}|${m[2]}`);
|
||||
return out.sort().join('+');
|
||||
}
|
||||
|
||||
test('[ORD] the title classes are placed in alphabetical order: cA, fB, cB and an untitled correctness BLOCKER give {cA, cU} BLOCKER and {fB, cB} MAJOR and one verdict in all 24 orders', () => {
|
||||
const K = 'NOT_IN_THE_CATALOGUE';
|
||||
const at = { file: 'bin/slug.mjs', line: 46, rule_key: K, detail: 'd' };
|
||||
|
|
@ -562,12 +559,12 @@ test('[ORD] the title classes are placed in alphabetical order: cA, fB, cB and a
|
|||
for (const order of all) {
|
||||
const name = order.map(([n]) => n).join(', ');
|
||||
const rows = dedupByTriplet(order.map(([, f]) => structuredClone(f)));
|
||||
const bySet = Object.fromEntries(rows.map((r) => [memberSet(r), r.severity]));
|
||||
const bySet = Object.fromEntries(rows.map((r) => [members(r).join('+'), r.severity]));
|
||||
assert.deepEqual(bySet, expected, `order ${name}`);
|
||||
const j = judgeFilter(rows);
|
||||
verdicts.add(JSON.stringify({
|
||||
kept: j.kept.map(memberSet).sort(),
|
||||
unverified: j.unverified.map(memberSet).sort(),
|
||||
kept: j.kept.map((r) => members(r).join('+')).sort(),
|
||||
unverified: j.unverified.map((r) => members(r).join('+')).sort(),
|
||||
verdict: computeVerdict(j.kept, { unverified: j.unverified }).verdict,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
// `unverified`, not dropped (lib/review/coordinator-contract.mjs).
|
||||
// Fix the SOURCE a pin guards, not the pin.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { after, test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -33,10 +33,15 @@ import { childEnv } from '../helpers/child-env.mjs';
|
|||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
||||
/** A repo file's text; the file must exist. */
|
||||
const readCache = new Map();
|
||||
|
||||
/** A repo file's text; the file must exist. Read from disk once per run. */
|
||||
function read(rel) {
|
||||
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
|
||||
return readFileSync(join(ROOT, rel), 'utf8');
|
||||
if (!readCache.has(rel)) {
|
||||
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
|
||||
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
|
||||
}
|
||||
return readCache.get(rel);
|
||||
}
|
||||
|
||||
/** Whitespace (line breaks too) collapsed to one space. */
|
||||
|
|
@ -84,6 +89,16 @@ function bash(cmd, cwd) {
|
|||
return spawnSync('bash', ['-c', cmd.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT)], { cwd, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
/** The runtime ```bash block of `text` that holds `cmd`, or undefined. */
|
||||
function bashBlockHolding(text, cmd) {
|
||||
return text.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0]).find((b) => b.includes(cmd));
|
||||
}
|
||||
|
||||
/** A Phase 0 parser line run through bash() with `args` in place of $ARGUMENTS. */
|
||||
function runParser(cmd, args) {
|
||||
return bash(cmd.split('$ARGUMENTS').join(args), ROOT);
|
||||
}
|
||||
|
||||
// --- #23 ---------------------------------------------------------------------------
|
||||
|
||||
test('[#23] no agent file refers to a MANDATORY OVERRIDE rule', () => {
|
||||
|
|
@ -656,11 +671,16 @@ function gatesShim(cmd, value) {
|
|||
return l.replace(' [value]', value ? ` ${value}` : '');
|
||||
}
|
||||
|
||||
/** Run a shim line from the prose; its `next_state`. */
|
||||
const shimCache = new Map();
|
||||
|
||||
/** Run a shim line from the prose; its `next_state`. Each distinct line runs once. */
|
||||
function shimNext(cmdLine) {
|
||||
const r = bash(cmdLine, ROOT);
|
||||
assert.equal(r.status, 0, `shim exited ${r.status}: ${r.stdout}${r.stderr}`);
|
||||
return JSON.parse(r.stdout).next_state;
|
||||
if (!shimCache.has(cmdLine)) {
|
||||
const r = bash(cmdLine, ROOT);
|
||||
assert.equal(r.status, 0, `shim exited ${r.status}: ${r.stdout}${r.stderr}`);
|
||||
shimCache.set(cmdLine, JSON.parse(r.stdout).next_state);
|
||||
}
|
||||
return shimCache.get(cmdLine);
|
||||
}
|
||||
|
||||
for (const cmd of GATES_COMMANDS) {
|
||||
|
|
@ -1087,6 +1107,15 @@ function staleRepo() {
|
|||
return { repo, reviewed, head, review, cleanup: () => rmSync(base, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
let sharedStale = null;
|
||||
after(() => sharedStale?.cleanup());
|
||||
|
||||
/** The file's one staleRepo, built on first use: its tests only rewrite review.md, outside the repo. */
|
||||
function staleFixture() {
|
||||
if (!sharedStale) sharedStale = staleRepo();
|
||||
return sharedStale;
|
||||
}
|
||||
|
||||
/** The lock line with its review path filled in, run in `repo` through bash(). */
|
||||
function runLock(cmd, dir, repo) {
|
||||
const filled = cmd.split('{review_path}').join(join(dir, 'review.md')).split('{project_dir}').join(dir);
|
||||
|
|
@ -1104,17 +1133,15 @@ test('[R6] the Main-merge gate and Phase 7.2 step 4 each hold exactly one verdic
|
|||
test('[R6] the extracted lock lines, run on an ALLOW review of an older commit with a later source commit, exit 2 with review-stale', () => {
|
||||
const text = read(EXEC);
|
||||
const lines = [line(section(text, MERGE_GATE), 'review/verdict-gate.mjs'), line(section(text, PHASE_72), 'review/verdict-gate.mjs')];
|
||||
const fx = staleRepo();
|
||||
try {
|
||||
for (const cmd of lines) {
|
||||
const ok = runLock(cmd, fx.review('ALLOW', fx.head), fx.repo);
|
||||
assert.equal(ok.status, 0, `control: an ALLOW review of HEAD opens the lock: ${ok.stdout}${ok.stderr}`);
|
||||
const r = runLock(cmd, fx.review('ALLOW', fx.reviewed), fx.repo);
|
||||
assert.equal(r.status, 2, `${cmd}: an ALLOW review of an older commit must exit 2: ${r.stdout}${r.stderr}`);
|
||||
const reason = JSON.parse(r.stdout).reason;
|
||||
assert.ok(reason.startsWith('review-stale'), `the reason must start review-stale: ${reason}`);
|
||||
}
|
||||
} finally { fx.cleanup(); }
|
||||
const fx = staleFixture();
|
||||
for (const cmd of lines) {
|
||||
const ok = runLock(cmd, fx.review('ALLOW', fx.head), fx.repo);
|
||||
assert.equal(ok.status, 0, `control: an ALLOW review of HEAD opens the lock: ${ok.stdout}${ok.stderr}`);
|
||||
const r = runLock(cmd, fx.review('ALLOW', fx.reviewed), fx.repo);
|
||||
assert.equal(r.status, 2, `${cmd}: an ALLOW review of an older commit must exit 2: ${r.stdout}${r.stderr}`);
|
||||
const reason = JSON.parse(r.stdout).reason;
|
||||
assert.ok(reason.startsWith('review-stale'), `the reason must start review-stale: ${reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[R6] both decline lists name review-stale, the exit table says to re-run /trekreview on this commit, and --resume runs the same gate', () => {
|
||||
|
|
@ -1133,12 +1160,10 @@ test('[R6] both decline lists name review-stale, the exit table says to re-run /
|
|||
|
||||
test('keep: Phase 7.2 step 4 run on a BLOCK review of an older commit still exits 1, so the fix loop opens', () => {
|
||||
const step4 = line(section(read(EXEC), PHASE_72), 'review/verdict-gate.mjs');
|
||||
const fx = staleRepo();
|
||||
try {
|
||||
const r = runLock(step4, fx.review('BLOCK', fx.reviewed), fx.repo);
|
||||
assert.equal(r.status, 1, `a BLOCK review must exit 1 before any staleness check: ${r.stdout}${r.stderr}`);
|
||||
assert.equal(JSON.parse(r.stdout).verdict, 'BLOCK');
|
||||
} finally { fx.cleanup(); }
|
||||
const fx = staleFixture();
|
||||
const r = runLock(step4, fx.review('BLOCK', fx.reviewed), fx.repo);
|
||||
assert.equal(r.status, 1, `a BLOCK review must exit 1 before any staleness check: ${r.stdout}${r.stderr}`);
|
||||
assert.equal(JSON.parse(r.stdout).verdict, 'BLOCK');
|
||||
});
|
||||
|
||||
test('keep: the other verdict-gate callers carry no --repo', () => {
|
||||
|
|
@ -1202,11 +1227,6 @@ test('[D67] the End to end section says to record run_seen, also when no app was
|
|||
const ENDSESSION = 'commands/trekendsession.md';
|
||||
const ENDSESSION_PARSER_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession -- $ARGUMENTS';
|
||||
|
||||
/** The Phase 0 parser line run through bash() with `args` in place of $ARGUMENTS. */
|
||||
function runEndsessionParser(cmd, args) {
|
||||
return bash(cmd.split('$ARGUMENTS').join(args), ROOT);
|
||||
}
|
||||
|
||||
test('[#15] /trekendsession Phase 0 has no "contains --help or -h" check, and its parser line run on fix-header gives no help', () => {
|
||||
const phase0 = section(read(ENDSESSION), '## Phase 0 — `--help` handling');
|
||||
assert.doesNotMatch(flat(phase0), /contains\s+`?--help`?\s+or\s+`?-h`?/i,
|
||||
|
|
@ -1215,19 +1235,19 @@ test('[#15] /trekendsession Phase 0 has no "contains --help or -h" check, and it
|
|||
assert.match(flat(phase0), /`positional\[0\] === '-h'`/, 'Phase 0 does not dispatch on a lone -h as the first positional');
|
||||
const cmd = line(phase0, 'parsers/arg-parser.mjs');
|
||||
assert.equal(cmd, ENDSESSION_PARSER_LINE, 'the Phase 0 parser line is not the trekendsession parser call on $ARGUMENTS');
|
||||
const block = phase0.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0]).find((b) => b.includes(cmd));
|
||||
const block = bashBlockHolding(phase0, cmd);
|
||||
assert.ok(block, 'the parser line is not in a runtime ```bash block');
|
||||
assert.equal(block.split('\n')[0], 'setopt nonomatch 2>/dev/null || true', 'the block does not open with setopt nonomatch');
|
||||
const plain = runEndsessionParser(cmd, 'fix-header');
|
||||
const plain = runParser(cmd, 'fix-header');
|
||||
assert.equal(plain.status, 0, `the parser line refused fix-header: ${plain.stdout}${plain.stderr}`);
|
||||
const r = JSON.parse(plain.stdout);
|
||||
assert.equal(r.flags['--help'], undefined, 'fix-header set the --help flag');
|
||||
assert.deepEqual(r.positional, ['fix-header']);
|
||||
assert.notEqual(r.positional[0], '-h');
|
||||
const help = runEndsessionParser(cmd, '--help');
|
||||
const help = runParser(cmd, '--help');
|
||||
assert.equal(help.status, 0, help.stdout + help.stderr);
|
||||
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'control: --help must set the help flag');
|
||||
const two = runEndsessionParser(cmd, 'fix-header "Session 2"');
|
||||
const two = runParser(cmd, 'fix-header "Session 2"');
|
||||
assert.equal(two.status, 0, two.stdout + two.stderr);
|
||||
assert.deepEqual(JSON.parse(two.stdout).positional, ['fix-header', 'Session 2']);
|
||||
});
|
||||
|
|
@ -1276,31 +1296,26 @@ const CONTINUE = 'commands/trekcontinue.md';
|
|||
const CONTINUE_PARSER_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue -- $ARGUMENTS';
|
||||
const CONSISTENCY_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/validators/next-session-prompt-validator.mjs --json --consistency <RESOLVED-PATH-A> <RESOLVED-PATH-B> --state-file <RESOLVED-PATH-STATE>';
|
||||
|
||||
/** The Phase 0 parser line run through bash() with `args` in place of $ARGUMENTS. */
|
||||
function runContinueParser(cmd, args) {
|
||||
return bash(cmd.split('$ARGUMENTS').join(args), ROOT);
|
||||
}
|
||||
|
||||
test('[#66] /trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its parser line run with --help sets the help flag', () => {
|
||||
const phase0 = section(read(CONTINUE), '## Phase 0 — `--help` handling');
|
||||
assert.doesNotMatch(phase0, /parseArgs\(\$ARGUMENTS/, 'Phase 0 still calls parseArgs($ARGUMENTS, ...) as a function');
|
||||
const cmd = line(phase0, 'parsers/arg-parser.mjs');
|
||||
assert.equal(cmd, CONTINUE_PARSER_LINE, 'the Phase 0 parser line is not the trekcontinue parser call on $ARGUMENTS');
|
||||
const block = phase0.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0]).find((b) => b.includes(cmd));
|
||||
const block = bashBlockHolding(phase0, cmd);
|
||||
assert.ok(block, 'the parser line is not in a runtime ```bash block');
|
||||
assert.match(flat(phase0), /Run first, exactly like this:/);
|
||||
assert.match(flat(phase0), /Read `flags` and `positional` from the JSON it prints/);
|
||||
assert.match(flat(phase0), /`flags\['--help'\] === true`/, 'Phase 0 does not dispatch on the parsed --help flag');
|
||||
assert.match(flat(phase0), /`positional\[0\] === '-h'`/, 'Phase 0 does not dispatch on a lone -h as the first positional');
|
||||
const help = runContinueParser(cmd, '--help');
|
||||
const help = runParser(cmd, '--help');
|
||||
assert.equal(help.status, 0, help.stdout + help.stderr);
|
||||
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'the parser line run with --help does not set the help flag');
|
||||
const dir = runContinueParser(cmd, '.claude/projects/demo');
|
||||
const dir = runParser(cmd, '.claude/projects/demo');
|
||||
assert.equal(dir.status, 0, dir.stdout + dir.stderr);
|
||||
const r = JSON.parse(dir.stdout);
|
||||
assert.equal(r.flags['--help'], undefined, 'a project dir set the --help flag');
|
||||
assert.deepEqual(r.positional, ['.claude/projects/demo']);
|
||||
const h = runContinueParser(cmd, '-h');
|
||||
const h = runParser(cmd, '-h');
|
||||
assert.equal(h.status, 0, h.stdout + h.stderr);
|
||||
assert.equal(JSON.parse(h.stdout).positional[0], '-h');
|
||||
const p05 = flat(section(read(CONTINUE), '## Phase 0.5 — Cleanup mode dispatch'));
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
// build of the re-planned step they are moved into the report dir, never
|
||||
// deleted (voyage-lekerepo trial 5, finding 4).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { after, test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, symlinkSync, cpSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -20,15 +20,25 @@ function git(cwd, ...args) {
|
|||
return r.stdout;
|
||||
}
|
||||
|
||||
// The base repo (bin/slug.mjs committed), built once; repo() gives each test its own copy.
|
||||
let seed = null;
|
||||
after(() => {
|
||||
if (seed) rmSync(seed, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function repo() {
|
||||
if (!seed) {
|
||||
seed = mkdtempSync(join(tmpdir(), 'voyage-park-seed-'));
|
||||
git(seed, 'init', '-q');
|
||||
git(seed, 'config', 'user.email', 't@example.invalid');
|
||||
git(seed, 'config', 'user.name', 't');
|
||||
mkdirSync(join(seed, 'bin'));
|
||||
writeFileSync(join(seed, 'bin/slug.mjs'), 'old\n');
|
||||
git(seed, 'add', 'bin/slug.mjs');
|
||||
git(seed, 'commit', '-q', '-m', 'base');
|
||||
}
|
||||
const dir = mkdtempSync(join(tmpdir(), 'voyage-park-'));
|
||||
git(dir, 'init', '-q');
|
||||
git(dir, 'config', 'user.email', 't@example.invalid');
|
||||
git(dir, 'config', 'user.name', 't');
|
||||
mkdirSync(join(dir, 'bin'));
|
||||
writeFileSync(join(dir, 'bin/slug.mjs'), 'old\n');
|
||||
git(dir, 'add', 'bin/slug.mjs');
|
||||
git(dir, 'commit', '-q', '-m', 'base');
|
||||
cpSync(seed, dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
|
|
@ -57,9 +67,10 @@ const DIFF = [
|
|||
'',
|
||||
].join('\n');
|
||||
|
||||
function run(dir, diff = DIFF) {
|
||||
/** The CLI run with `--cwd <cwd>` (the repo by default) on `diff` written into the repo's git dir. */
|
||||
function run(dir, diff = DIFF, cwd = dir) {
|
||||
writeFileSync(join(dir, '.git', 'upward.diff'), diff);
|
||||
return spawnSync(process.execPath, [CLI, '--diff', join(dir, '.git', 'upward.diff'), '--step', '1', '--attempt', '1', '--cwd', dir], { encoding: 'utf8' });
|
||||
return spawnSync(process.execPath, [CLI, '--diff', join(dir, '.git', 'upward.diff'), '--step', '1', '--attempt', '1', '--cwd', cwd], { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
// Parked outside the work tree, in the git dir, so a later `git ls-files --others` never lists them.
|
||||
|
|
@ -207,12 +218,6 @@ test('trekexecute: a re-planned step parks the earlier attempt\'s new files befo
|
|||
// [F4c] The root was the --cwd as given, so a subdirectory read every
|
||||
// root-relative path as absent; a git call that failed read as "nothing tracked".
|
||||
|
||||
/** The CLI run with `--cwd <cwd>` on `diff` written into the repo's git dir. */
|
||||
function runFrom(dir, cwd, diff = DIFF) {
|
||||
writeFileSync(join(dir, '.git', 'upward.diff'), diff);
|
||||
return spawnSync(process.execPath, [CLI, '--diff', join(dir, '.git', 'upward.diff'), '--step', '1', '--attempt', '1', '--cwd', cwd], { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
/** The attempt's two new files, written on disk. */
|
||||
function attemptFiles(dir) {
|
||||
mkdirSync(join(dir, 'tests'));
|
||||
|
|
@ -275,7 +280,7 @@ test('[F4c] --cwd <repo>/bin parks the root-relative path', () => {
|
|||
const dir = repo();
|
||||
try {
|
||||
attemptFiles(dir);
|
||||
const r = runFrom(dir, join(dir, 'bin'));
|
||||
const r = run(dir, DIFF, join(dir, 'bin'));
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.ok(JSON.parse(r.stdout).parked.includes('tests/batch.sh'), `tests/batch.sh was not parked from a subdirectory: ${r.stdout}`);
|
||||
assert.equal(existsSync(join(dir, 'tests/batch.sh')), false, 'the file is still in the tree');
|
||||
|
|
|
|||
|
|
@ -53,16 +53,20 @@ function ev(name, over = {}) {
|
|||
}
|
||||
const passing = (name) => ev(name, { ok: true, code: null, cname: null, failureType: null });
|
||||
|
||||
/** The judge on a results file, the id list written into `dir`, with extra flags. */
|
||||
function judgeFile(dir, ids, results, extra = []) {
|
||||
writeFileSync(join(dir, 'ids'), ids.join('\n') + '\n');
|
||||
const r = spawnSync(process.execPath, [JUDGE, '--ids', join(dir, 'ids'), '--results', results, ...extra],
|
||||
{ encoding: 'utf8', env: childEnv() });
|
||||
return { status: r.status, out: `${r.stdout}${r.stderr}` };
|
||||
}
|
||||
|
||||
/** Run the judge on an id list and planted events, with extra flags. */
|
||||
function judge(ids, events, extra = []) {
|
||||
return inTmp((dir) => {
|
||||
const idsFile = join(dir, 'ids');
|
||||
const results = join(dir, 'results.jsonl');
|
||||
writeFileSync(idsFile, ids.join('\n') + '\n');
|
||||
writeFileSync(results, events.map((e) => JSON.stringify(e)).join('\n') + (events.length ? '\n' : ''));
|
||||
const r = spawnSync(process.execPath, [JUDGE, '--ids', idsFile, '--results', results, ...extra],
|
||||
{ encoding: 'utf8', env: childEnv() });
|
||||
return { status: r.status, out: `${r.stdout}${r.stderr}` };
|
||||
return judgeFile(dir, ids, results, extra);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -170,14 +174,6 @@ function report(dir, files) {
|
|||
return out;
|
||||
}
|
||||
|
||||
/** The judge on a results file the reporter wrote. */
|
||||
function judgeFile(dir, ids, results) {
|
||||
writeFileSync(join(dir, 'ids'), ids.join('\n') + '\n');
|
||||
const r = spawnSync(process.execPath, [JUDGE, '--ids', join(dir, 'ids'), '--results', results],
|
||||
{ encoding: 'utf8', env: childEnv() });
|
||||
return { status: r.status, out: `${r.stdout}${r.stderr}` };
|
||||
}
|
||||
|
||||
const HEAD = "import { test } from 'node:test';\nimport { strict as assert } from 'node:assert';\n";
|
||||
|
||||
test('red reporter + judge: a passing [X1] test and an [X2] test that throws a TypeError, run by node --test -> exit 1', () => {
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@
|
|||
// pointer block. Anything else is exit 2 `review-stale: ...`. Those tests run
|
||||
// the CLI only, in throwaway git repos with no hooks.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { after, test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, symlinkSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, symlinkSync, appendFileSync, cpSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -260,42 +260,63 @@ function g(cwd, ...args) {
|
|||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A throwaway repo with one reviewed commit (a.mjs, CLAUDE.md, the lessons
|
||||
* file), no git hooks. `sub` puts the project in a subdirectory of the repo;
|
||||
* `claudeLink` makes CLAUDE.md a symlink to AGENTS.md.
|
||||
*/
|
||||
function gateRepo({ sub = '', claude = '# Project\n', claudeLink = false } = {}) {
|
||||
const base = mkdtempSync(join(tmpdir(), 'verdict-gate-r6-'));
|
||||
/** Write `text` to `rel` under `dir`, making its directories. */
|
||||
function putIn(dir, rel, text) {
|
||||
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
||||
writeFileSync(join(dir, rel), text);
|
||||
}
|
||||
|
||||
/** A fixture variant's seed: `{ base, reviewed }`, built once (see gateRepo) and removed after the file's tests. */
|
||||
const seeds = new Map();
|
||||
after(() => {
|
||||
for (const seed of seeds.values()) rmSync(seed.base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seedRepo({ sub, claude, claudeLink }) {
|
||||
const base = mkdtempSync(join(tmpdir(), 'verdict-gate-r6-seed-'));
|
||||
const repo = join(base, 'repo');
|
||||
const dir = sub ? join(repo, sub) : repo;
|
||||
mkdirSync(join(dir, '.claude', 'rules'), { recursive: true });
|
||||
mkdirSync(join(base, 'hooks-none'));
|
||||
g(repo, 'init', '-q', '-b', 'main');
|
||||
g(repo, 'config', 'user.name', 'Test');
|
||||
g(repo, 'config', 'user.email', 'test@example.invalid');
|
||||
g(repo, 'config', 'commit.gpgsign', 'false');
|
||||
g(repo, 'config', 'core.hooksPath', join(base, 'hooks-none'));
|
||||
const put = (rel, text) => {
|
||||
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
||||
writeFileSync(join(dir, rel), text);
|
||||
};
|
||||
put('a.mjs', 'export const a = 1;\n');
|
||||
put(LESSONS, LESSONS_BEFORE);
|
||||
appendFileSync(join(repo, '.git', 'config'), '[user]\n\tname = Test\n\temail = test@example.invalid\n'
|
||||
+ `[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "${join(base, 'hooks-none')}"\n`);
|
||||
putIn(dir, 'a.mjs', 'export const a = 1;\n');
|
||||
putIn(dir, LESSONS, LESSONS_BEFORE);
|
||||
if (claudeLink) {
|
||||
put('AGENTS.md', claude);
|
||||
putIn(dir, 'AGENTS.md', claude);
|
||||
symlinkSync('AGENTS.md', join(dir, 'CLAUDE.md'));
|
||||
} else {
|
||||
put('CLAUDE.md', claude);
|
||||
putIn(dir, 'CLAUDE.md', claude);
|
||||
}
|
||||
g(repo, 'add', '-A');
|
||||
g(repo, 'commit', '-q', '-m', 'feat: the work');
|
||||
const reviewed = g(repo, 'rev-parse', 'HEAD');
|
||||
return { base, reviewed: g(repo, 'rev-parse', 'HEAD') };
|
||||
}
|
||||
|
||||
/**
|
||||
* A throwaway repo with one reviewed commit (a.mjs, CLAUDE.md, the lessons
|
||||
* file), no git hooks. `sub` puts the project in a subdirectory of the repo;
|
||||
* `claudeLink` makes CLAUDE.md a symlink to AGENTS.md. Each variant is built
|
||||
* once; every call gets its own copy of it.
|
||||
*/
|
||||
function gateRepo({ sub = '', claude = '# Project\n', claudeLink = false } = {}) {
|
||||
const key = JSON.stringify([sub, claude, claudeLink]);
|
||||
if (!seeds.has(key)) seeds.set(key, seedRepo({ sub, claude, claudeLink }));
|
||||
const seed = seeds.get(key);
|
||||
const base = mkdtempSync(join(tmpdir(), 'verdict-gate-r6-'));
|
||||
cpSync(seed.base, base, { recursive: true, verbatimSymlinks: true });
|
||||
const repo = join(base, 'repo');
|
||||
const dir = sub ? join(repo, sub) : repo;
|
||||
// The copy's hooks path is its own empty hooks-none, not the seed's.
|
||||
const config = join(repo, '.git', 'config');
|
||||
writeFileSync(config, readFileSync(config, 'utf8').split(seed.base).join(base));
|
||||
const { reviewed } = seed;
|
||||
const put = (rel, text) => putIn(dir, rel, text);
|
||||
const commit = (msg, files) => {
|
||||
for (const [rel, text] of Object.entries(files)) put(rel, text);
|
||||
g(repo, 'add', '-A');
|
||||
g(repo, 'commit', '-q', '-m', msg);
|
||||
return g(repo, 'rev-parse', 'HEAD');
|
||||
};
|
||||
const gate = ({ verdict = 'ALLOW', sha = reviewed } = {}) => {
|
||||
const p = join(base, 'review.md');
|
||||
|
|
@ -311,7 +332,7 @@ function gateRepo({ sub = '', claude = '# Project\n', claudeLink = false } = {})
|
|||
|
||||
/** The lessons commit /trekexecute Phase 7.2 makes after a review. */
|
||||
function lessonsCommit(fx, claudeAfter = `# Project\n\n${POINTER}`) {
|
||||
return fx.commit('docs(voyage): review lessons (round 1)', { [LESSONS]: LESSONS_AFTER, 'CLAUDE.md': claudeAfter });
|
||||
fx.commit('docs(voyage): review lessons (round 1)', { [LESSONS]: LESSONS_AFTER, 'CLAUDE.md': claudeAfter });
|
||||
}
|
||||
|
||||
function assertStale(r, re = /review-stale/) {
|
||||
|
|
@ -380,7 +401,8 @@ test('[R6] --repo: the reviewed commit on an unmerged side branch → exit 2 "no
|
|||
const fx = gateRepo();
|
||||
try {
|
||||
fx.git('checkout', '-q', '-b', 'side');
|
||||
const side = fx.commit('fix: side work', { 'a.mjs': 'export const a = 3;\n' });
|
||||
fx.commit('fix: side work', { 'a.mjs': 'export const a = 3;\n' });
|
||||
const side = fx.git('rev-parse', 'HEAD');
|
||||
fx.git('checkout', '-q', 'main');
|
||||
lessonsCommit(fx);
|
||||
assertStale(fx.gate({ sha: side }), /not an ancestor of HEAD/);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { strict as assert } from 'node:assert';
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { illegalBriefLowering, severityRank as rank } from '../../lib/review/coordinator-contract.mjs';
|
||||
import { dedupByTriplet, illegalBriefLowering, severityRank as rank } from '../../lib/review/coordinator-contract.mjs';
|
||||
import { ENGINES } from '../../lib/review/engine-findings.mjs';
|
||||
import { RULE_CATALOGUE } from '../../lib/review/rule-catalogue.mjs';
|
||||
|
||||
|
|
@ -719,6 +719,90 @@ test('[ORD] sibling: workflow — the title classes are placed in alphabetical o
|
|||
}
|
||||
});
|
||||
|
||||
// ---- Parity: the workflow's inlined dedup groups as coordinator-contract Pass 1 does ----
|
||||
//
|
||||
// The script cannot import, so its groupTriplet is a hand-kept copy of
|
||||
// lib/review/coordinator-contract.mjs's. Both run on the ORD fixtures of this
|
||||
// file and of tests/lib/coordinator-contract.test.mjs, plus the WF floor
|
||||
// fixture below (the one where two titles from two sources share a row), in
|
||||
// every arrival order within a payload, and must give the same rows in the
|
||||
// same order. Each finding
|
||||
// carries its workflow source as `reviewer` (what the contract groups and
|
||||
// orders by) and, from /code-review, `engine` last (where the workflow puts
|
||||
// it), so both read the same objects. A row is compared by its survivor and its
|
||||
// group's severity.
|
||||
|
||||
/** Every order of `xs`. */
|
||||
function permutations(xs) {
|
||||
if (xs.length <= 1) return [xs];
|
||||
return xs.flatMap((x, i) => permutations([...xs.slice(0, i), ...xs.slice(i + 1)]).map((rest) => [x, ...rest]));
|
||||
}
|
||||
|
||||
const SRC_F = 'reviewer:brief-conformance';
|
||||
const SRC_C = 'reviewer:code-correctness';
|
||||
const SRC_E = 'engine:claude-code-review';
|
||||
const from = (src, f) => ({ ...f, reviewer: src, ...(src === SRC_E ? { engine: 'code-review' } : {}) });
|
||||
const rowSig = (r) => JSON.stringify([r.title ?? null, r.severity, r.engine ?? null, r.contradicts_brief ?? null, r.recommended_action ?? null]);
|
||||
|
||||
function parityFixtures() {
|
||||
const out = [];
|
||||
const add = (name, { f = [], c = [], e = [] }) => out.push({
|
||||
name, f: f.map((x) => from(SRC_F, x)), c: c.map((x) => from(SRC_C, x)), e: e.map((x) => from(SRC_E, x)),
|
||||
});
|
||||
// tests/lib/coordinator-contract.test.mjs
|
||||
const slug = { file: 'bin/slug.mjs', line: 46, detail: 'd' };
|
||||
for (const [rule_key, a, b, cb] of [['MISSING_TEST', 'MAJOR', 'MAJOR', 'MAJOR'], ['NOT_IN_THE_CATALOGUE', 'MINOR', 'MAJOR', 'MINOR']]) {
|
||||
add(`cA ${a}, fB ${b}, cB ${cb} (${rule_key})`, {
|
||||
f: [{ ...slug, rule_key, severity: b, title: 'B' }],
|
||||
c: [{ ...slug, rule_key, severity: a, title: 'A' }, { ...slug, rule_key, severity: cb, title: 'B' }],
|
||||
});
|
||||
}
|
||||
const off = { ...slug, rule_key: 'NOT_IN_THE_CATALOGUE' };
|
||||
add('cA, fB, cB and an untitled correctness BLOCKER', {
|
||||
f: [{ ...off, severity: 'MAJOR', title: 'B' }],
|
||||
c: [{ ...off, severity: 'BLOCKER', title: 'A', recommended_action: '' }, { ...off, severity: 'MINOR', title: 'B' }, { ...off, severity: 'BLOCKER' }],
|
||||
});
|
||||
const inj = { file: 'x.mjs', line: 3, rule_key: 'SECURITY_INJECTION', severity: 'BLOCKER' };
|
||||
add('one reviewer\'s two same-title BLOCKERs', {
|
||||
c: [{ ...inj, title: 'Injection', recommended_action: '' }, { ...inj, title: 'injection ', recommended_action: 'Quote the argument.' }],
|
||||
});
|
||||
const t = { file: 'x.mjs', line: 3, rule_key: 'CODE_REVIEW_DEFECT', severity: 'MAJOR', title: 'T' };
|
||||
add('two /code-review MAJORs titled T, one marked', { e: [{ ...t, contradicts_brief: MARK }, { ...t }] });
|
||||
// this file
|
||||
const K = 'CODE_REVIEW_DEFECT';
|
||||
add('conformance B with /code-review A and B', {
|
||||
f: [F(K, 'MINOR', { title: 'B' })], e: [F(K, 'MINOR', { title: 'A' }), F(K, 'MAJOR', { title: 'B' })],
|
||||
});
|
||||
add('a conformance R next to /code-review E1 and E2 (WF)', {
|
||||
f: [F(K, 'MAJOR', { title: 'R' })], e: [F(K, 'MINOR', { title: 'E1' }), F(K, 'MINOR', { title: 'E2' })],
|
||||
});
|
||||
const { title: _t, ...untitled } = F(K, 'BLOCKER');
|
||||
add('conformance B with /code-review A, B and an untitled BLOCKER', {
|
||||
f: [F(K, 'MAJOR', { title: 'B' })], e: [F(K, 'BLOCKER', { title: 'A' }), F(K, 'MINOR', { title: 'B' }), untitled],
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
test('parity: the workflow dedup and coordinator-contract dedupByTriplet give the same rows on the ORD fixtures, in every arrival order within a payload', async () => {
|
||||
let runs = 0;
|
||||
for (const fx of parityFixtures()) {
|
||||
for (const f of permutations(fx.f)) for (const c of permutations(fx.c)) for (const e of permutations(fx.e)) {
|
||||
const name = `${fx.name}, order ${[...f, ...c, ...e].map((x) => `${x.reviewer}/${x.title ?? 'U'}`).join(', ')}`;
|
||||
const s = stubs({
|
||||
reviewers: [{ reviewer: 'brief-conformance', findings: structuredClone(f) }, { reviewer: 'code-correctness', findings: structuredClone(c) }],
|
||||
coordinator: { verdict: 'WARN', findings: [] },
|
||||
});
|
||||
await exec({ ...BASE, enginePayloads: BOTH_ENGINES(structuredClone(e)) }, s);
|
||||
const prompt = s.calls.coordinatorPrompt;
|
||||
assert.ok(typeof prompt === 'string', `${name}: the coordinator ran`);
|
||||
const merged = JSON.parse(prompt.slice(prompt.indexOf(PROMPT_JSON_MARK) + PROMPT_JSON_MARK.length));
|
||||
assert.deepEqual(merged.map(rowSig), dedupByTriplet(structuredClone([...f, ...c, ...e])).map(rowSig), name);
|
||||
runs++;
|
||||
}
|
||||
}
|
||||
assert.equal(runs, 24, 'control: every fixture ran in each of its arrival orders');
|
||||
});
|
||||
|
||||
// ---- WF: the floor and the put-back match each returned finding to its own row ----
|
||||
//
|
||||
// A row is a merged finding; its key is its triplet plus its normalized title.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue