feat(hooks): the merge question says how the run was tested and how it is undone
This commit is contained in:
parent
fcba232ccd
commit
ec5b6483ec
10 changed files with 1371 additions and 6 deletions
|
|
@ -2665,7 +2665,18 @@ git worktree list --porcelain
|
|||
commits are already on main. Emit `main-merge-approved` and say in the
|
||||
report that the review ran after the commits landed on main and could not
|
||||
hold them back: run on a branch when the review should gate the merge.
|
||||
- **Another branch:** merge it into main in ONE Bash call, with the values
|
||||
- **Another branch:** first, before the merge call, write the run evidence
|
||||
the guard's question reads, in a Bash call of its own:
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/review/merge-evidence.mjs write --repo . --branch "{run branch}" --project "{project_dir}" --json
|
||||
```
|
||||
It derives every field from the run's artifacts (the branch head, the
|
||||
plan's `End-to-end:` command and `progress.json`, the verdict gate on
|
||||
`review.md`, the plan's or the CHANGELOG's door) and writes
|
||||
`<git common dir>/voyage/merge-evidence/<branch head sha>.json`, internal
|
||||
and never tracked; the guard reads it into a "Tested by" and an "Undone by"
|
||||
line of its merge question, and its answer stays "ask". On `"written": false` (exit 1), print its `reason` in one line and continue: the question then says "no run evidence".
|
||||
Then merge it into main in ONE Bash call, with the values
|
||||
written out as literals (the guard reads the command; it cannot follow a
|
||||
shell variable). When `git worktree list` shows main checked out in a
|
||||
worktree, merge there:
|
||||
|
|
|
|||
|
|
@ -639,3 +639,25 @@ The `next-session-prompt-validator` (`lib/validators/next-session-prompt-validat
|
|||
| 7. session-state (multi-session resume) | required-fields + status enum + drift-WARN extras | this plugin | low — readers tolerate unknown keys; writers are owned by trekexecute Phase 8 + helper command |
|
||||
|
||||
When extending the plugin or adding a new pipeline stage, follow the same pattern: produce an artifact with a versioned frontmatter (or `schema_version` for JSON), write a validator under `lib/validators/`, add fixtures under `tests/fixtures/`, and add an entry to this document.
|
||||
|
||||
---
|
||||
|
||||
## Internal files
|
||||
|
||||
Files Voyage writes for its own use. They are no handover: no other stage reads
|
||||
them, they carry no version promise, and they may change in any release.
|
||||
|
||||
**Merge evidence** (fix round 4.5b2 part B). `/trekexecute` Phase 8 runs
|
||||
`lib/review/merge-evidence.mjs write` before it merges a run's branch into
|
||||
main. It writes `<git common dir>/voyage/merge-evidence/<branch head sha>.json`:
|
||||
inside the git directory, so it is never tracked and never published, and
|
||||
shared by every worktree of the repo, so the guard finds it from main's tree.
|
||||
The Bash guard reads it with `hooks/lib/merge-evidence.mjs` into a "Tested by"
|
||||
and an "Undone by" line of its merge question; the guard's answer stays "ask",
|
||||
and a missing, stale, foreign or unreadable file reads "no run evidence" or
|
||||
"not measured". Fields: `v` (`1`), `branch`, `branch_head_sha`, `written_at`,
|
||||
`end_to_end` (`{command, exit}`; `exit` is `0` when `progress.json` says
|
||||
`verified`, else `null`), `verification` (`{passed, total}`), `review`
|
||||
(`{verdict, sha}`) and `door` (`{type: one-way | two-way | null, text}`).
|
||||
Files older than 30 days are removed when the next one is written. It is a new
|
||||
internal file, not a field of `progress.json`: it is not part of Handover 5.
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ function classifyGit(call, ctx) {
|
|||
return classifyPush(args, ctx);
|
||||
case 'merge':
|
||||
if (hasFlag(args, '--abort', '--continue', '--quit')) return [];
|
||||
return MAIN.test(ctx.branch || '') ? [{ cls: 'merge', name: 'merge into main', kind: 'merge' }] : [];
|
||||
return MAIN.test(ctx.branch || '') ? [{ cls: 'merge', name: 'merge into main', kind: 'merge', merge: { args } }] : [];
|
||||
case 'tag': {
|
||||
if (hasFlag(args, '-l', '--list', '-v', '--verify') || shortHas(args, 'l')) return [];
|
||||
if (hasFlag(args, '-d', '--delete')) return [{ cls: 'release', name: 'delete a tag', kind: null }];
|
||||
|
|
@ -323,7 +323,7 @@ function gitDirOf(found) {
|
|||
}
|
||||
|
||||
// A worktree's git dir names the shared one in `commondir`; refs live there.
|
||||
function commonDirOf(gitDir) {
|
||||
export function commonDirOf(gitDir) {
|
||||
try {
|
||||
const c = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
|
||||
return isAbsolute(c) ? c : resolve(gitDir, c);
|
||||
|
|
|
|||
182
hooks/lib/merge-evidence.mjs
Normal file
182
hooks/lib/merge-evidence.mjs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// hooks/lib/merge-evidence.mjs
|
||||
// The merge question's run evidence (fix round 4.5b2 part B, Step 11). Before
|
||||
// it merges a run's branch into main, /trekexecute Phase 8 writes an evidence
|
||||
// file (lib/review/merge-evidence.mjs write) under the git common dir, named
|
||||
// by the branch's head sha. The Bash guard (hooks/scripts/pre-bash-executor.mjs)
|
||||
// reads it here into two lines of the question it already asks:
|
||||
// Tested by: End-to-end `<cmd>` exit <n>; Verification <passed>/<total>; review <verdict> at <sha8>.
|
||||
// Undone by: `git revert -m 1 <merge>` (two-way). | (one-way: <text>). | (door not stated).
|
||||
//
|
||||
// Fail-safe by construction: the guard's `ask` decision is made before this
|
||||
// runs, and this only returns text. It reads the filesystem only (no process
|
||||
// is spawned, no module outside hooks/ is imported), never follows a symlink,
|
||||
// reads at most 64 KiB of a regular file, and never throws: any error, a
|
||||
// missing or foreign file, a head sha that does not match, or a field of the
|
||||
// wrong type reads "no run evidence" or "not measured", never a green line.
|
||||
// Text taken from the file prints with every non-printable or non-ASCII
|
||||
// character as `?`.
|
||||
|
||||
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync } from 'node:fs';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
import { commonDirOf } from './git-approval.mjs';
|
||||
|
||||
export const MAX_EVIDENCE_BYTES = 64 * 1024;
|
||||
|
||||
const NO_EVIDENCE = 'Tested by: no run evidence.\nUndone by: no run evidence.';
|
||||
const UNREADABLE = 'Tested by: not measured (the run evidence file could not be read).\nUndone by: not measured.';
|
||||
const NOT = 'not measured';
|
||||
const UNDO = 'Undone by: `git revert -m 1 <merge>`';
|
||||
|
||||
// `git merge` options that take a separate value: their value is not the branch.
|
||||
const MERGE_VALUED = ['-m', '-F', '-s', '-X', '--message', '--file', '--strategy', '--strategy-option', '--into-name'];
|
||||
|
||||
const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
||||
const VERDICTS = ['ALLOW', 'WARN', 'BLOCK'];
|
||||
|
||||
/** Printable ASCII only; anything else prints as `?`. Cut to `max` characters. */
|
||||
function clean(s, max = 200) {
|
||||
const out = String(s).replace(/[^\x20-\x7e]/g, '?');
|
||||
return out.length > max ? `${out.slice(0, max)}...` : out;
|
||||
}
|
||||
|
||||
/** The merged branch: the one positional after the valued options; else null. */
|
||||
export function mergedBranch(mergeArgs) {
|
||||
if (!Array.isArray(mergeArgs)) return null;
|
||||
const pos = [];
|
||||
for (let i = 0; i < mergeArgs.length; i += 1) {
|
||||
const a = mergeArgs[i];
|
||||
if (typeof a !== 'string') return null;
|
||||
if (MERGE_VALUED.includes(a)) { i += 1; continue; }
|
||||
if (!a.startsWith('-')) pos.push(a);
|
||||
}
|
||||
if (pos.length !== 1) return null;
|
||||
const name = pos[0];
|
||||
// A plain branch name only: no `..`, no control character, no leading `-`.
|
||||
if (name === '' || name.includes('..') || /[\x00-\x1f\x7f]/.test(name) || name.startsWith('-')) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
/** The git dir of the repo whose top is `root` (a `.git` dir, or a `.git` file naming one). */
|
||||
// Same reading as gitDirOf in hooks/lib/git-approval.mjs (not exported there).
|
||||
function gitDirAt(root) {
|
||||
const dotGit = join(root, '.git');
|
||||
let st;
|
||||
try { st = lstatSync(dotGit); } catch { return null; }
|
||||
if (st.isDirectory()) return dotGit;
|
||||
if (!st.isFile()) return null;
|
||||
const m = readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
|
||||
if (!m) return null;
|
||||
return isAbsolute(m[1]) ? m[1] : resolve(root, m[1]);
|
||||
}
|
||||
|
||||
/** A small regular file's text, or null (missing, not regular, too big). */
|
||||
function smallRegular(path, max) {
|
||||
let st;
|
||||
try { st = lstatSync(path); } catch { return null; }
|
||||
if (!st.isFile() || st.size > max) return null;
|
||||
return readFileSync(path, 'utf8');
|
||||
}
|
||||
|
||||
/** The head sha of `refs/heads/<branch>`: the loose ref, else `packed-refs`; else null. */
|
||||
function headSha(common, branch) {
|
||||
const loose = smallRegular(join(common, 'refs', 'heads', ...branch.split('/')), 4096);
|
||||
if (loose !== null) {
|
||||
const sha = loose.trim();
|
||||
if (SHA_RE.test(sha)) return sha;
|
||||
}
|
||||
const packed = smallRegular(join(common, 'packed-refs'), 16 * 1024 * 1024);
|
||||
if (packed === null) return null;
|
||||
for (const line of packed.split('\n')) {
|
||||
const m = line.match(/^([0-9a-f]{40}|[0-9a-f]{64}) (.+)$/);
|
||||
if (m && m[2] === `refs/heads/${branch}`) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The evidence file's text: 'missing', null (there, but not a regular file of
|
||||
* at most 64 KiB), or the text. Never follows a symlink and never opens a FIFO
|
||||
* for a blocking read.
|
||||
*/
|
||||
function readEvidence(path) {
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
} catch (e) {
|
||||
return e && e.code === 'ENOENT' ? 'missing' : null;
|
||||
}
|
||||
if (!st.isFile() || st.isSymbolicLink() || st.size > MAX_EVIDENCE_BYTES) return null;
|
||||
const flags = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(path, flags);
|
||||
const fst = fstatSync(fd);
|
||||
if (!fst.isFile() || fst.size > MAX_EVIDENCE_BYTES) return null;
|
||||
const buf = Buffer.alloc(MAX_EVIDENCE_BYTES + 1);
|
||||
let n = 0;
|
||||
for (;;) {
|
||||
const got = readSync(fd, buf, n, buf.length - n, null);
|
||||
if (got === 0) break;
|
||||
n += got;
|
||||
if (n > MAX_EVIDENCE_BYTES) return null;
|
||||
}
|
||||
return buf.subarray(0, n).toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (fd !== undefined) { try { closeSync(fd); } catch { /* closed */ } }
|
||||
}
|
||||
}
|
||||
|
||||
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
const isCount = (n) => Number.isInteger(n) && n >= 0;
|
||||
|
||||
function testedLine(ev) {
|
||||
const e2e = isObj(ev.end_to_end) ? ev.end_to_end : {};
|
||||
const cmd = typeof e2e.command === 'string' && e2e.command.trim() !== '' ? `\`${clean(e2e.command)}\`` : `command ${NOT}`;
|
||||
const exit = Number.isInteger(e2e.exit) ? String(e2e.exit) : NOT;
|
||||
const ver = isObj(ev.verification) ? ev.verification : {};
|
||||
const count = isCount(ver.passed) && isCount(ver.total) && ver.passed <= ver.total ? `${ver.passed}/${ver.total}` : NOT;
|
||||
const rev = isObj(ev.review) ? ev.review : {};
|
||||
const verdict = VERDICTS.includes(rev.verdict) ? rev.verdict : NOT;
|
||||
const sha = typeof rev.sha === 'string' && SHA_RE.test(rev.sha) ? rev.sha.slice(0, 8) : NOT;
|
||||
return `Tested by: End-to-end ${cmd} exit ${exit}; Verification ${count}; review ${verdict} at ${sha}.`;
|
||||
}
|
||||
|
||||
function undoneLine(ev) {
|
||||
const door = isObj(ev.door) ? ev.door : {};
|
||||
if (door.type === 'two-way') return `${UNDO} (two-way).`;
|
||||
if (door.type === 'one-way' && typeof door.text === 'string' && door.text.trim() !== '') {
|
||||
return `${UNDO} (one-way: ${clean(door.text.trim())}).`;
|
||||
}
|
||||
return `${UNDO} (door not stated).`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two evidence lines for a merge, for the guard's question.
|
||||
* @param {string} repoRoot top of the repo the merge runs in
|
||||
* @param {string[]} mergeArgs the tokens after `merge`
|
||||
* @returns {string} always two lines; never throws
|
||||
*/
|
||||
export function evidenceLines(repoRoot, mergeArgs) {
|
||||
try {
|
||||
const branch = mergedBranch(mergeArgs);
|
||||
if (!branch || typeof repoRoot !== 'string' || repoRoot === '') return NO_EVIDENCE;
|
||||
const gitDir = gitDirAt(repoRoot);
|
||||
if (!gitDir) return NO_EVIDENCE;
|
||||
const common = commonDirOf(gitDir);
|
||||
const sha = headSha(common, branch);
|
||||
if (!sha) return NO_EVIDENCE;
|
||||
const text = readEvidence(join(common, 'voyage', 'merge-evidence', `${sha}.json`));
|
||||
if (text === 'missing') return NO_EVIDENCE;
|
||||
if (text === null) return UNREADABLE;
|
||||
let ev;
|
||||
try { ev = JSON.parse(text); } catch { return UNREADABLE; }
|
||||
if (!isObj(ev)) return UNREADABLE;
|
||||
// A file of another format, another branch or another head is not this run's evidence.
|
||||
if (ev.v !== 1 || ev.branch !== branch || ev.branch_head_sha !== sha) return NO_EVIDENCE;
|
||||
return `${testedLine(ev)}\n${undoneLine(ev)}`;
|
||||
} catch {
|
||||
return UNREADABLE;
|
||||
}
|
||||
}
|
||||
|
|
@ -779,13 +779,32 @@ if (approvals.length > 0) {
|
|||
const unlogged = loggable.length > 0 && !recorded
|
||||
? ' Note: this approval will not be logged (Voyage could not record the question).' : '';
|
||||
const advisory = (warnings.length > 0 ? ` Advisory: ${warnings.map((w) => w.trim()).join(' ')}` : '') + unlogged;
|
||||
process.stdout.write(JSON.stringify({
|
||||
const answer = {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'ask',
|
||||
permissionDecisionReason: askReason(approvals) + advisory,
|
||||
},
|
||||
}));
|
||||
};
|
||||
// BEGIN merge-evidence
|
||||
// A merge into main also says how the run was tested and how it is undone,
|
||||
// read from the evidence file /trekexecute Phase 8 wrote (hooks/lib/merge-evidence.mjs).
|
||||
// The decision above is already 'ask'; this block only adds text to the
|
||||
// reason, and a reader that fails to load or throws leaves a note, never a
|
||||
// green line.
|
||||
const merge = approvals.find((m) => m.cls === 'merge');
|
||||
if (merge) {
|
||||
let evidence;
|
||||
try {
|
||||
const { evidenceLines } = await import('../lib/merge-evidence.mjs');
|
||||
evidence = ' ' + evidenceLines(merge.subject, merge.merge && merge.merge.args);
|
||||
} catch {
|
||||
evidence = ' (run evidence not loaded)';
|
||||
}
|
||||
answer.hookSpecificOutput.permissionDecisionReason += evidence;
|
||||
}
|
||||
// END merge-evidence
|
||||
process.stdout.write(JSON.stringify(answer));
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
|
|
|
|||
211
lib/review/merge-evidence.mjs
Normal file
211
lib/review/merge-evidence.mjs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// lib/review/merge-evidence.mjs
|
||||
// The run evidence behind the merge question (fix round 4.5b2 part B, Step 11).
|
||||
// /trekexecute Phase 8 runs this before it merges a run's branch into main.
|
||||
// It writes one internal file, never tracked and never published:
|
||||
// <git common dir>/voyage/merge-evidence/<branch head sha>.json
|
||||
// The Bash guard (hooks/scripts/pre-bash-executor.mjs, through
|
||||
// hooks/lib/merge-evidence.mjs) reads it into the two lines of its merge
|
||||
// question: how the run was tested, and how the merge is undone.
|
||||
//
|
||||
// Every field is derived from artifacts, never from an argument the model types:
|
||||
// - branch_head_sha: `git rev-parse --verify refs/heads/<branch>`;
|
||||
// - end_to_end: the plan's first `End-to-end:` backticked command, with exit
|
||||
// 0 when progress.json says `end_to_end: verified`, else null;
|
||||
// - verification: progress.json `plan_verification.summary` passed/total;
|
||||
// - review: the verdict gate (`decideVerdictFile` with the repo, R6 first)
|
||||
// on {project}/review.md, and its `scope_sha_end` (`reviewedSha`); a review
|
||||
// the gate does not decide (exit 2) has a null verdict;
|
||||
// - door: the plan's `## Doors` list, else the CHANGELOG's first section when
|
||||
// it holds exactly one door line, else null.
|
||||
// A field that cannot be derived is null, and the guard prints "not measured".
|
||||
// Before writing, evidence files in that directory older than 30 days are
|
||||
// removed (regular files named `<hex>.json` only). The file is written
|
||||
// atomically (a temp file in the same directory, then rename).
|
||||
//
|
||||
// CLI: node lib/review/merge-evidence.mjs write --repo <d> --branch <name> --project <dir> --json
|
||||
// prints {"written": true, "path"} (exit 0) or {"written": false, "reason"} (exit 1).
|
||||
|
||||
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
import { decideVerdictFile, EXIT } from './verdict-gate.mjs';
|
||||
import { reviewedSha } from './reviewed-commit.mjs';
|
||||
import { parseDoors } from '../util/doors.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
export const EVIDENCE_VERSION = 1;
|
||||
export const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const EVIDENCE_NAME_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})\.json$/;
|
||||
const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
||||
|
||||
/** Why `name` is not a branch name this writer accepts, or null. */
|
||||
export function branchNameProblem(name) {
|
||||
if (typeof name !== 'string' || name === '') return 'no --branch given';
|
||||
if (name.includes('..')) return `branch name ${JSON.stringify(name)} contains ..`;
|
||||
if (/[\x00-\x1f\x7f]/.test(name)) return `branch name ${JSON.stringify(name)} contains a control character`;
|
||||
if (name.startsWith('-')) return `branch name ${JSON.stringify(name)} starts with -`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The plan's first `End-to-end:` field command (its first backticked span), or null. */
|
||||
export function firstEndToEnd(planText) {
|
||||
const m = /^[ \t]*(?:[-*][ \t]+)?(?:\*\*)?End-to-end:(?:\*\*)?[ \t]+`([^`\n]+)`/m.exec(String(planText ?? ''));
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
/** The lines of the `## <title>` section of `text`, heading excluded, or null. */
|
||||
function h2Section(text, title) {
|
||||
const lines = String(text ?? '').split('\n');
|
||||
const start = lines.findIndex((l) => new RegExp(`^##\\s+${title}\\s*$`).test(l));
|
||||
if (start === -1) return null;
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i += 1) if (/^#{1,2}\s/.test(lines[i])) { end = i; break; }
|
||||
return lines.slice(start + 1, end).join('\n');
|
||||
}
|
||||
|
||||
/** The CHANGELOG's first `## ` section, heading excluded, or null. */
|
||||
function firstSection(text) {
|
||||
const lines = String(text ?? '').split('\n');
|
||||
const start = lines.findIndex((l) => /^##\s/.test(l));
|
||||
if (start === -1) return null;
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i += 1) if (/^##\s/.test(lines[i])) { end = i; break; }
|
||||
return lines.slice(start + 1, end).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The door: the plan's `## Doors` list (one-way wins over two-way), else the
|
||||
* CHANGELOG's first section when it holds exactly one door line, else null.
|
||||
* @returns {{type: 'one-way'|'two-way'|null, text: string|null}}
|
||||
*/
|
||||
export function doorOf(planText, changelogText) {
|
||||
const planDoors = h2Section(planText, 'Doors');
|
||||
if (planDoors !== null) {
|
||||
const { entries, bad } = parseDoors(`### Doors\n${planDoors}`);
|
||||
if (entries.length > 0 && bad.length === 0) {
|
||||
const oneWay = entries.filter((e) => e.door === 'one-way');
|
||||
if (oneWay.length > 0) return { type: 'one-way', text: oneWay.map((e) => e.text).join('; ') };
|
||||
return { type: 'two-way', text: entries.map((e) => e.text).join('; ') };
|
||||
}
|
||||
}
|
||||
const first = firstSection(changelogText);
|
||||
if (first !== null) {
|
||||
const { entries, bad } = parseDoors(first);
|
||||
if (entries.length === 1 && bad.length === 0) return { type: entries[0].door, text: entries[0].text };
|
||||
}
|
||||
return { type: null, text: null };
|
||||
}
|
||||
|
||||
function git(repo, args) {
|
||||
return spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
const readText = (p) => { try { return existsSync(p) ? readFileSync(p, 'utf8') : null; } catch { return null; } };
|
||||
|
||||
/** Remove evidence files older than MAX_AGE_MS: regular files named `<hex>.json` only. */
|
||||
export function removeOld(dir, now = Date.now()) {
|
||||
let names;
|
||||
try { names = readdirSync(dir); } catch { return []; }
|
||||
const removed = [];
|
||||
for (const name of names) {
|
||||
if (!EVIDENCE_NAME_RE.test(name)) continue;
|
||||
const p = join(dir, name);
|
||||
try {
|
||||
const st = lstatSync(p);
|
||||
if (st.isFile() && now - st.mtimeMs > MAX_AGE_MS) { unlinkSync(p); removed.push(name); }
|
||||
} catch { /* gone or unreadable: left alone */ }
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the evidence for `branch` in `repo` from the run's artifacts in
|
||||
* `projectDir`, and write it.
|
||||
* @returns {{written: true, path: string} | {written: false, reason: string}}
|
||||
*/
|
||||
export function writeEvidence({ repo, branch, projectDir, now = Date.now() } = {}) {
|
||||
const problem = branchNameProblem(branch);
|
||||
if (problem) return { written: false, reason: problem };
|
||||
if (typeof repo !== 'string' || repo === '' || !existsSync(repo)) return { written: false, reason: `--repo ${repo} does not exist` };
|
||||
if (typeof projectDir !== 'string' || projectDir === '') return { written: false, reason: 'no --project given' };
|
||||
const repoDir = resolve(repo);
|
||||
const project = resolve(projectDir);
|
||||
|
||||
const head = git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
|
||||
const sha = head.status === 0 ? head.stdout.trim() : '';
|
||||
if (!SHA_RE.test(sha)) return { written: false, reason: `no branch ${branch} in ${repoDir}` };
|
||||
const cd = git(repoDir, ['rev-parse', '--git-common-dir']);
|
||||
const rawCommon = cd.status === 0 ? cd.stdout.trim() : '';
|
||||
if (!rawCommon) return { written: false, reason: `git rev-parse --git-common-dir failed in ${repoDir}` };
|
||||
const common = isAbsolute(rawCommon) ? rawCommon : resolve(repoDir, rawCommon);
|
||||
const top = git(repoDir, ['rev-parse', '--show-toplevel']);
|
||||
const topDir = top.status === 0 && top.stdout.trim() ? top.stdout.trim() : repoDir;
|
||||
|
||||
const planText = readText(join(project, 'plan.md'));
|
||||
let progress = null;
|
||||
try { progress = JSON.parse(readText(join(project, 'progress.json')) ?? 'null'); } catch { progress = null; }
|
||||
if (!progress || typeof progress !== 'object' || Array.isArray(progress)) progress = {};
|
||||
const summary = progress.plan_verification && typeof progress.plan_verification === 'object'
|
||||
? progress.plan_verification.summary : null;
|
||||
const count = (n) => (Number.isInteger(n) && n >= 0 ? n : null);
|
||||
|
||||
const reviewPath = join(project, 'review.md');
|
||||
const reviewText = readText(reviewPath);
|
||||
let verdict = null;
|
||||
if (reviewText !== null) {
|
||||
try {
|
||||
const gate = decideVerdictFile(reviewPath, { repo: repoDir });
|
||||
if (gate.exit === EXIT.PASS) verdict = gate.verdict ?? null;
|
||||
else if (gate.exit === EXIT.BLOCK) verdict = 'BLOCK';
|
||||
} catch { verdict = null; }
|
||||
}
|
||||
|
||||
const evidence = {
|
||||
v: EVIDENCE_VERSION,
|
||||
branch,
|
||||
branch_head_sha: sha,
|
||||
written_at: new Date(now).toISOString(),
|
||||
end_to_end: { command: firstEndToEnd(planText), exit: progress.end_to_end === 'verified' ? 0 : null },
|
||||
verification: {
|
||||
passed: summary && typeof summary === 'object' ? count(summary.passed) : null,
|
||||
total: summary && typeof summary === 'object' ? count(summary.total) : null,
|
||||
},
|
||||
review: { verdict, sha: reviewText !== null ? (reviewedSha(reviewText) || null) : null },
|
||||
door: doorOf(planText, readText(join(topDir, 'CHANGELOG.md'))),
|
||||
};
|
||||
|
||||
const dir = join(common, 'voyage', 'merge-evidence');
|
||||
const path = join(dir, `${sha}.json`);
|
||||
const tmp = join(dir, `.${sha}.json.${process.pid}.tmp`);
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
removeOld(dir, now);
|
||||
writeFileSync(tmp, `${JSON.stringify(evidence, null, 2)}\n`, { flag: 'wx' });
|
||||
renameSync(tmp, path);
|
||||
} catch (e) {
|
||||
try { rmSync(tmp, { force: true }); } catch { /* nothing to remove */ }
|
||||
return { written: false, reason: `cannot write ${path}: ${e.message}` };
|
||||
}
|
||||
return { written: true, path };
|
||||
}
|
||||
|
||||
function opt(args, name) {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
let result;
|
||||
if (args[0] !== 'write') {
|
||||
result = { written: false, reason: 'usage: merge-evidence.mjs write --repo <d> --branch <name> --project <dir> --json' };
|
||||
} else {
|
||||
try {
|
||||
result = writeEvidence({ repo: opt(args, '--repo'), branch: opt(args, '--branch'), projectDir: opt(args, '--project') });
|
||||
} catch (e) {
|
||||
result = { written: false, reason: `merge-evidence crashed: ${e.message}` };
|
||||
}
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
process.exit(result.written ? 0 : 1);
|
||||
}
|
||||
368
tests/e2e-fix-round-4-5b2-b.sh
Normal file
368
tests/e2e-fix-round-4-5b2-b.sh
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/e2e-fix-round-4-5b2-b.sh
|
||||
# End to end for fix round 4.5b2, part B (plan
|
||||
# .claude/projects/2026-10-31-review-gate-4-5b2, Step 11, End-to-end:). On
|
||||
# throwaway fixture repos it runs the verdict gate the way /trekexecute's
|
||||
# merge lock does (node lib/review/verdict-gate.mjs --json <review> --repo <fx>)
|
||||
# and the Bash guard the way Claude Code does:
|
||||
# 1. an unanswered counting MAJOR keeps the gate at exit 2; after
|
||||
# review-answers.mjs record --answer accepted it exits 0;
|
||||
# 2. a MAJOR answered "fixed" keeps exit 2 (fixed-pending), and a round-2
|
||||
# review (review-rounds.mjs prepare, then review-finalize.mjs) keeps
|
||||
# exit 2 while the finding is carried and exits 0 once it is resolved;
|
||||
# 3. a pre-existing BLOCKER (a line the range did not change) exits 1;
|
||||
# 4. merge-evidence.mjs write records a fixture run, and a
|
||||
# `git merge --no-ff feat` hook input into hooks/scripts/pre-bash-executor.mjs
|
||||
# answers "ask" with the Tested by and Undone by lines.
|
||||
# Every fixture keeps HEAD at the reviewed commit when it asks the gate (R6).
|
||||
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per check and
|
||||
# "e2e: ok" at the end, exit 0; else exit 1. Starts nothing but node, git and
|
||||
# the usual file tools. bash 3.2 compatible, ASCII only, no arrays.
|
||||
|
||||
set -u
|
||||
|
||||
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
|
||||
ROOT="$(pwd)"
|
||||
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
|
||||
GATE="$ROOT/lib/review/verdict-gate.mjs"
|
||||
ANSWERS="$ROOT/lib/review/review-answers.mjs"
|
||||
ROUNDS="$ROOT/lib/review/review-rounds.mjs"
|
||||
FINALIZE="$ROOT/lib/review/review-finalize.mjs"
|
||||
FILL="$ROOT/lib/review/fill-finding-ids.mjs"
|
||||
WRITER="$ROOT/lib/review/merge-evidence.mjs"
|
||||
|
||||
unset CLAUDE_CODE_SESSION_ID
|
||||
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b2-b.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
|
||||
|
||||
export LC_ALL=C
|
||||
export LANG=C
|
||||
|
||||
FAILED=0
|
||||
pass() { echo "ok - $1"; }
|
||||
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
|
||||
|
||||
TRAILER='Co-Authored-By: Claude Test <noreply@example.invalid>'
|
||||
PROJ_REL=.claude/projects/2026-11-02-e2e
|
||||
mkdir -p "$TMP/hooks-none" || { echo "FAIL - setup: cannot make the temp directories"; exit 1; }
|
||||
|
||||
# A review_version 1.2 review with @@ID_<n>@@ placeholders, filled afterwards
|
||||
# by fill-finding-ids.mjs as /trekreview Phase 7 fills them.
|
||||
# node mkreview.mjs <out> <start> <end> <findings JSON> [rstep]: with rstep,
|
||||
# finding 1 gets a "### Step R1" (every BLOCKER gets one anyway).
|
||||
cat > "$TMP/mkreview.mjs" <<'EOF'
|
||||
import { writeFileSync } from 'node:fs';
|
||||
const [out, start, end, findingsJson, rstep] = process.argv.slice(2);
|
||||
const list = JSON.parse(findingsJson).map((f, i) => ({
|
||||
id: `@@ID_${i + 1}@@`, severity: f.severity, rule_key: f.rule_key || 'MISSING_TEST', file: f.file, line: f.line,
|
||||
brief_ref: 'SC1', title: f.title, detail: f.detail || `detail ${i + 1}`, recommended_action: 'Fix it.',
|
||||
}));
|
||||
const counts = { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
|
||||
for (const f of list) counts[f.severity] += 1;
|
||||
const verdict = counts.BLOCKER > 0 ? 'BLOCK' : counts.MAJOR > 0 ? 'WARN' : 'ALLOW';
|
||||
const sections = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'].map((sev) => {
|
||||
const of = list.filter((f) => f.severity === sev);
|
||||
if (of.length === 0) return '';
|
||||
return `## Findings (${sev})\n\n${of.map((f) => `### ${f.id}\n\n- file: ${f.file}\n- line: ${f.line}\n- rule_key: ${f.rule_key}\n- title: ${f.title}\n`).join('\n')}\n`;
|
||||
}).join('');
|
||||
const stepFor = list.filter((f, i) => f.severity === 'BLOCKER' || (rstep === 'rstep' && i === 0));
|
||||
const steps = stepFor.map((f, i) => `### Step R${i + 1}: fix ${f.title}\n\n- **Files:** \`${f.file}\`\n- **Source finding:** \`${f.id}\`\n- **Test first:** a test that fails today\n- **Verify:** re-run the review\n- **On failure:** escalate\n`).join('\n');
|
||||
const json = JSON.stringify({ verdict, counts, allow_blocked_by: [], findings: list }, null, 2);
|
||||
const fmList = list.length ? `\n${list.map((f) => ` - ${f.id}`).join('\n')}` : ' []';
|
||||
writeFileSync(out, `---
|
||||
type: trekreview
|
||||
review_version: "1.2"
|
||||
task: "t"
|
||||
slug: e2e
|
||||
project_dir: .claude/projects/2026-11-02-e2e/
|
||||
brief_path: .claude/projects/2026-11-02-e2e/brief.md
|
||||
scope_sha_start: ${start}
|
||||
scope_sha_end: ${end}
|
||||
reviewed_files_count: 2
|
||||
verdict: ${verdict}
|
||||
findings:${fmList}
|
||||
---
|
||||
|
||||
# Review: t
|
||||
|
||||
## Executive Summary
|
||||
|
||||
x
|
||||
|
||||
## Coverage
|
||||
|
||||
| File | Treatment | Reason |
|
||||
|------|-----------|--------|
|
||||
| a.mjs | deep-review | default |
|
||||
|
||||
${sections}## Declined to Judge
|
||||
|
||||
None.
|
||||
|
||||
Trimmed: 0
|
||||
|
||||
## Remediation Steps
|
||||
|
||||
${steps || 'None.\n'}
|
||||
## Remediation Summary
|
||||
|
||||
x
|
||||
|
||||
\`\`\`json
|
||||
${json}
|
||||
\`\`\`
|
||||
`);
|
||||
EOF
|
||||
|
||||
# jfield <file> <expr>: prints the JS expression <expr> over the parsed JSON
|
||||
# value `j` read from <file> (empty when it does not parse).
|
||||
jfield() {
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
let j;
|
||||
try { j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch (e) { process.exit(0); }
|
||||
const v = Function("j", "return (" + process.argv[2] + ");")(j);
|
||||
process.stdout.write(v === undefined || v === null ? "" : String(v));
|
||||
' "$1" "$2"
|
||||
}
|
||||
|
||||
# reviewjson <review> <expr>: <expr> over the review's trailing JSON block `j`.
|
||||
reviewjson() {
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const t = fs.readFileSync(process.argv[1], "utf8");
|
||||
const j = JSON.parse(t.slice(t.lastIndexOf("```json\n") + 8, t.lastIndexOf("```")));
|
||||
const v = Function("j", "return (" + process.argv[2] + ");")(j);
|
||||
process.stdout.write(v === undefined || v === null ? "" : String(v));
|
||||
' "$1" "$2"
|
||||
}
|
||||
|
||||
# ten <tag> [<line> <text>]: ten lines `export const <tag><n> = <n>;`, line <line> replaced.
|
||||
ten() {
|
||||
tag="$1"
|
||||
at="${2:-0}"
|
||||
txt="${3:-}"
|
||||
n=1
|
||||
while [ "$n" -le 10 ]; do
|
||||
if [ "$n" -eq "$at" ]; then echo "$txt"; else echo "export const $tag$n = $n;"; fi
|
||||
n=$((n + 1))
|
||||
done
|
||||
}
|
||||
|
||||
# mkrepo <dir>: a git repo on main with start (a.mjs, b.mjs) and c1 (line 3 of
|
||||
# both changed); the project folder excluded from git. Sets START and C1.
|
||||
mkrepo() {
|
||||
r="$1"
|
||||
mkdir -p "$r" || return 1
|
||||
git -C "$r" init -q -b main || return 1
|
||||
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' "$TMP/hooks-none" >> "$r/.git/config"
|
||||
printf '.claude/projects/\n' >> "$r/.git/info/exclude"
|
||||
ten a > "$r/a.mjs"
|
||||
ten b > "$r/b.mjs"
|
||||
git -C "$r" add a.mjs b.mjs && git -C "$r" commit -q -m 'chore: start' || return 1
|
||||
START="$(git -C "$r" rev-parse HEAD)"
|
||||
ten a 3 'export const a3 = 33;' > "$r/a.mjs"
|
||||
ten b 3 'export const b3 = 33;' > "$r/b.mjs"
|
||||
git -C "$r" add a.mjs b.mjs && git -C "$r" commit -q -m 'feat: round 1' || return 1
|
||||
C1="$(git -C "$r" rev-parse HEAD)"
|
||||
mkdir -p "$r/$PROJ_REL" || return 1
|
||||
}
|
||||
|
||||
# gate <review> <repo> <out>: the merge lock's gate line; prints its exit status.
|
||||
gate() {
|
||||
node "$GATE" --json "$1" --repo "$2" > "$3" 2>&1
|
||||
echo $?
|
||||
}
|
||||
|
||||
# reviewer <project> <previous JSON>: the code-correctness reviewer's saved answer.
|
||||
reviewer() {
|
||||
printf 'Reviewer prose.\n\n```json\n{"reviewer": "code-correctness-reviewer", "findings": [], "previous": %s}\n```\n' "$2" \
|
||||
> "$1/.review-out-code-correctness-reviewer.md"
|
||||
}
|
||||
|
||||
MAJ='[{"severity":"MAJOR","file":"a.mjs","line":3,"title":"Parser drops the last token","detail":"parse() stops one token early"}]'
|
||||
|
||||
case_accepted() {
|
||||
name="unanswered MAJOR, then accepted"
|
||||
R="$TMP/accepted"
|
||||
mkrepo "$R" || { bad "$name: setup"; return; }
|
||||
P="$R/$PROJ_REL"
|
||||
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" "$MAJ" && node "$FILL" "$P/review.md" > /dev/null \
|
||||
|| { bad "$name: could not write the review"; return; }
|
||||
reviewer "$P" '[]'
|
||||
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/acc-fin.out" 2>&1 \
|
||||
|| { bad "$name: finalize: $(cat "$TMP/acc-fin.out")"; return; }
|
||||
c="$(gate "$P/review.md" "$R" "$TMP/acc-g1.out")"
|
||||
if [ "$c" -eq 2 ] && grep -q '"reason":"review-unanswered:' "$TMP/acc-g1.out"; then
|
||||
pass "$name: an unanswered MAJOR exits 2"
|
||||
else
|
||||
bad "$name: the unanswered MAJOR exited $c: $(cat "$TMP/acc-g1.out")"
|
||||
fi
|
||||
id="$(reviewjson "$P/review.md" 'j.findings[0].id')"
|
||||
printf 'the parser keeps the old contract on purpose' | node "$ANSWERS" record --review "$P/review.md" --finding "$id" \
|
||||
--answer accepted --repo "$R" --round 1 --store local --trailer "$TRAILER" > "$TMP/acc-rec.out" 2>&1
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then bad "$name: record exited $rc: $(cat "$TMP/acc-rec.out")"; return; fi
|
||||
c="$(gate "$P/review.md" "$R" "$TMP/acc-g2.out")"
|
||||
if [ "$c" -eq 0 ]; then
|
||||
pass "$name: accepted, the gate exits 0"
|
||||
else
|
||||
bad "$name: after the accepted answer the gate exited $c: $(cat "$TMP/acc-g2.out")"
|
||||
fi
|
||||
}
|
||||
|
||||
case_fixed() {
|
||||
name="MAJOR answered fixed"
|
||||
R="$TMP/fixed"
|
||||
mkrepo "$R" || { bad "$name: setup"; return; }
|
||||
P="$R/$PROJ_REL"
|
||||
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" "$MAJ" rstep && node "$FILL" "$P/review.md" > /dev/null \
|
||||
|| { bad "$name: could not write the review"; return; }
|
||||
reviewer "$P" '[]'
|
||||
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/fix-fin1.out" 2>&1 \
|
||||
|| { bad "$name: round-1 finalize: $(cat "$TMP/fix-fin1.out")"; return; }
|
||||
id="$(reviewjson "$P/review.md" 'j.findings[0].id')"
|
||||
printf 'the parser gets the missing token back' | node "$ANSWERS" record --review "$P/review.md" --finding "$id" \
|
||||
--answer fixed --repo "$R" --round 1 --store local --trailer "$TRAILER" > "$TMP/fix-rec.out" 2>&1
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then bad "$name: record exited $rc: $(cat "$TMP/fix-rec.out")"; return; fi
|
||||
c="$(gate "$P/review.md" "$R" "$TMP/fix-g1.out")"
|
||||
if [ "$c" -eq 2 ] && grep -q '"reason":"review-unanswered: fixed-pending 1' "$TMP/fix-g1.out"; then
|
||||
pass "$name: round 1 exits 2 (fixed-pending)"
|
||||
else
|
||||
bad "$name: round 1 exited $c: $(cat "$TMP/fix-g1.out")"
|
||||
fi
|
||||
# The fix: round 2's work, committed (only a.mjs; the local approval log stays untracked).
|
||||
ten a 3 'export const a3 = 333;' > "$R/a.mjs"
|
||||
git -C "$R" add a.mjs && git -C "$R" commit -q -m 'fix: the parser' || { bad "$name: commit"; return; }
|
||||
C2="$(git -C "$R" rev-parse HEAD)"
|
||||
(cd "$R" && node "$ROUNDS" prepare --project "$P" --since "$START" --json) > "$TMP/fix-prep.out" 2>&1 \
|
||||
|| { bad "$name: prepare: $(cat "$TMP/fix-prep.out")"; return; }
|
||||
round="$(jfield "$TMP/fix-prep.out" 'j.round')"
|
||||
base="$(jfield "$TMP/fix-prep.out" 'j.round_base')"
|
||||
prev="$(jfield "$TMP/fix-prep.out" 'j.previous')"
|
||||
if [ "$round" != 2 ] || [ "$base" != "$C1" ] || [ -z "$prev" ]; then
|
||||
bad "$name: prepare gave round $round base $base previous $prev"
|
||||
return
|
||||
fi
|
||||
# Round 2, the reviewer not (yet) marking the finding resolved: carried, still open.
|
||||
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C2" '[]' && node "$FILL" "$P/review.md" > /dev/null \
|
||||
|| { bad "$name: could not write the round-2 review"; return; }
|
||||
reviewer "$P" "[{\"id\": \"$id\", \"resolved\": false}]"
|
||||
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 2 --round-base "$base" --previous "$P/$prev" \
|
||||
> "$TMP/fix-fin2.out" 2>&1 || { bad "$name: round-2 finalize: $(cat "$TMP/fix-fin2.out")"; return; }
|
||||
carried="$(reviewjson "$P/review.md" 'j.findings.map((f) => f.carried_from).join(",")')"
|
||||
c="$(gate "$P/review.md" "$R" "$TMP/fix-g2.out")"
|
||||
if [ "$carried" = "$id" ] && [ "$c" -eq 2 ]; then
|
||||
pass "$name: round 2 carries the finding and keeps exit 2"
|
||||
else
|
||||
bad "$name: round 2 not resolved: carried [$carried], gate exit $c: $(cat "$TMP/fix-g2.out")"
|
||||
fi
|
||||
# Round 2 again, the reviewer marking it resolved.
|
||||
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C2" '[]' && node "$FILL" "$P/review.md" > /dev/null \
|
||||
|| { bad "$name: could not rewrite the round-2 review"; return; }
|
||||
reviewer "$P" "[{\"id\": \"$id\", \"resolved\": true}]"
|
||||
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 2 --round-base "$base" --previous "$P/$prev" \
|
||||
> "$TMP/fix-fin3.out" 2>&1 || { bad "$name: round-2 finalize: $(cat "$TMP/fix-fin3.out")"; return; }
|
||||
resolved="$(reviewjson "$P/review.md" '(j.resolved || []).join(",")')"
|
||||
c="$(gate "$P/review.md" "$R" "$TMP/fix-g3.out")"
|
||||
if [ "$resolved" = "$id" ] && [ "$c" -eq 0 ]; then
|
||||
pass "$name: round 2 marks it resolved, the gate exits 0"
|
||||
else
|
||||
bad "$name: resolved [$resolved], gate exit $c: $(cat "$TMP/fix-g3.out")"
|
||||
fi
|
||||
}
|
||||
|
||||
case_preexisting() {
|
||||
name="pre-existing BLOCKER"
|
||||
R="$TMP/pre"
|
||||
mkrepo "$R" || { bad "$name: setup"; return; }
|
||||
P="$R/$PROJ_REL"
|
||||
# b.mjs line 9: a line start..c1 did not change.
|
||||
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" \
|
||||
'[{"severity":"BLOCKER","file":"b.mjs","line":9,"rule_key":"BROKEN_SUCCESS_CRITERION","title":"Build output is never written","detail":"write() returns before the flush"}]' \
|
||||
&& node "$FILL" "$P/review.md" > /dev/null || { bad "$name: could not write the review"; return; }
|
||||
reviewer "$P" '[]'
|
||||
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/pre-fin.out" 2>&1 \
|
||||
|| { bad "$name: finalize: $(cat "$TMP/pre-fin.out")"; return; }
|
||||
mark="$(reviewjson "$P/review.md" 'j.findings[0].pre_existing')"
|
||||
c="$(gate "$P/review.md" "$R" "$TMP/pre-g.out")"
|
||||
if [ "$mark" = true ] && [ "$c" -eq 1 ]; then
|
||||
pass "$name: marked pre-existing, the gate exits 1"
|
||||
else
|
||||
bad "$name: pre_existing [$mark], gate exit $c: $(cat "$TMP/pre-g.out")"
|
||||
fi
|
||||
}
|
||||
|
||||
case_guard() {
|
||||
name="merge question"
|
||||
R="$TMP/merge"
|
||||
mkdir -p "$R" || { bad "$name: setup"; return; }
|
||||
git -C "$R" init -q -b main || { bad "$name: git init"; return; }
|
||||
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' "$TMP/hooks-none" >> "$R/.git/config"
|
||||
ten a > "$R/a.mjs"
|
||||
git -C "$R" add a.mjs && git -C "$R" commit -q -m 'chore: start' || { bad "$name: commit"; return; }
|
||||
MAIN_SHA="$(git -C "$R" rev-parse HEAD)"
|
||||
git -C "$R" checkout -q -b feat || { bad "$name: branch"; return; }
|
||||
ten a 3 'export const a3 = 33;' > "$R/a.mjs"
|
||||
git -C "$R" add a.mjs && git -C "$R" commit -q -m 'feat: the work' || { bad "$name: commit"; return; }
|
||||
FEAT="$(git -C "$R" rev-parse HEAD)"
|
||||
# The run's artifacts, outside the repo.
|
||||
P="$TMP/merge-project"
|
||||
mkdir -p "$P" || { bad "$name: setup"; return; }
|
||||
printf '# Plan\n\n### Step 1: End to end\n\n- **End-to-end:** `/bin/bash tests/e2e-demo.sh` -> expected: `e2e: ok`, exit 0.\n\n## Doors\n\n- two-way: a revert restores the old parser\n' > "$P/plan.md"
|
||||
printf '{"schema_version": "1", "status": "completed", "end_to_end": "verified", "plan_verification": {"status": "passed", "summary": {"total": 3, "passed": 3, "failed": 0, "blocked": 0, "unrunnable": 0}}}\n' > "$P/progress.json"
|
||||
node "$TMP/mkreview.mjs" "$P/review.md" "$MAIN_SHA" "$FEAT" '[]' && node "$FILL" "$P/review.md" > /dev/null \
|
||||
|| { bad "$name: could not write the review"; return; }
|
||||
# HEAD at the reviewed commit while the writer asks the verdict gate.
|
||||
node "$WRITER" write --repo "$R" --branch feat --project "$P" --json > "$TMP/ev.out" 2>&1
|
||||
rc=$?
|
||||
written="$(jfield "$TMP/ev.out" 'j.written')"
|
||||
path="$(jfield "$TMP/ev.out" 'j.path')"
|
||||
if [ "$rc" -eq 0 ] && [ "$written" = true ] && [ "$path" = "$R/.git/voyage/merge-evidence/$FEAT.json" ] && [ -f "$path" ]; then
|
||||
pass "$name: the evidence file is written under the git common dir"
|
||||
else
|
||||
bad "$name: the writer exited $rc: $(cat "$TMP/ev.out")"
|
||||
fi
|
||||
if [ -z "$(git -C "$R" status --porcelain --untracked-files=all)" ]; then
|
||||
pass "$name: git status shows nothing new"
|
||||
else
|
||||
bad "$name: git status: $(git -C "$R" status --porcelain --untracked-files=all)"
|
||||
fi
|
||||
git -C "$R" checkout -q main || { bad "$name: checkout main"; return; }
|
||||
node -e 'process.stdout.write(JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: process.argv[1], tool_input: { command: "git merge --no-ff feat" } }));' "$R" \
|
||||
| node "$HOOK" > "$TMP/hook.out" 2> "$TMP/hook.err"
|
||||
rc=$?
|
||||
decision="$(jfield "$TMP/hook.out" 'j.hookSpecificOutput.permissionDecision')"
|
||||
reason="$(jfield "$TMP/hook.out" 'j.hookSpecificOutput.permissionDecisionReason')"
|
||||
sha8="$(printf '%s' "$FEAT" | cut -c1-8)"
|
||||
tested="Tested by: End-to-end \`/bin/bash tests/e2e-demo.sh\` exit 0; Verification 3/3; review ALLOW at $sha8."
|
||||
undone='Undone by: `git revert -m 1 <merge>` (two-way).'
|
||||
if [ "$rc" -eq 0 ] && [ "$decision" = ask ]; then
|
||||
pass "$name: the guard asks"
|
||||
else
|
||||
bad "$name: the guard exited $rc with decision [$decision]: $(cat "$TMP/hook.out" "$TMP/hook.err")"
|
||||
fi
|
||||
case "$reason" in
|
||||
*"$tested"*) pass "$name: the Tested by line" ;;
|
||||
*) bad "$name: no Tested by line [$tested] in: $reason" ;;
|
||||
esac
|
||||
case "$reason" in
|
||||
*"$undone"*) pass "$name: the Undone by line" ;;
|
||||
*) bad "$name: no Undone by line [$undone] in: $reason" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
case_accepted
|
||||
case_fixed
|
||||
case_preexisting
|
||||
case_guard
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "e2e: $FAILED case(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "e2e: ok"
|
||||
exit 0
|
||||
2
tests/fixtures/jsonl-schemas.md
vendored
2
tests/fixtures/jsonl-schemas.md
vendored
|
|
@ -22,7 +22,7 @@
|
|||
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1141-1165 | profile, phase_models, profile_source | none |
|
||||
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:829-862 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
|
||||
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1307-1335 | profile, phase_models, parallel_agents, profile_source | none |
|
||||
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:3005-3027 | profile, phase_models, profile_source | none |
|
||||
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:3016-3038 | profile, phase_models, profile_source | none |
|
||||
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none |
|
||||
| trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) |
|
||||
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:756-809 | profile, phase_models, profile_source | none |
|
||||
|
|
|
|||
504
tests/hooks/merge-evidence.test.mjs
Normal file
504
tests/hooks/merge-evidence.test.mjs
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
// tests/hooks/merge-evidence.test.mjs
|
||||
// [MEV] fix round 4.5b2 part B, Step 11: the guard's merge question says how
|
||||
// the run was tested and how it is undone. Phase 8 of /trekexecute writes an
|
||||
// evidence file (lib/review/merge-evidence.mjs write) under the git common
|
||||
// dir, named by the head sha of the branch being merged; the Bash guard
|
||||
// (hooks/scripts/pre-bash-executor.mjs) reads it with hooks/lib/merge-evidence.mjs
|
||||
// into two lines of the question it already asks:
|
||||
// Tested by: End-to-end `<cmd>` exit <n>; Verification <passed>/<total>; review <verdict> at <sha8>.
|
||||
// Undone by: `git revert -m 1 <merge>` (two-way). | (one-way: <text>). | (door not stated).
|
||||
// Fail-safe: the `ask` decision is computed before the reader runs and never
|
||||
// changes; a missing, stale, foreign or hostile file, or a field of the wrong
|
||||
// type, reads "no run evidence" or "not measured", never a green line.
|
||||
// The decision is `ask` at the base too, so it is a Control in each case,
|
||||
// never a tagged case of its own. Every child gets childEnv(); every fixture
|
||||
// that reaches the verdict gate keeps HEAD at the reviewed commit (R6).
|
||||
|
||||
import { after, test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
appendFileSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync,
|
||||
symlinkSync, utimesSync, writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
import { pluginCopy } from './guard-helpers.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
||||
const READER = join(ROOT, 'hooks', 'lib', 'merge-evidence.mjs');
|
||||
const WRITER = join(ROOT, 'lib', 'review', 'merge-evidence.mjs');
|
||||
|
||||
// The permission mode in which Claude Code shows no prompts, read from the
|
||||
// guard's own constant: this file only feeds the guard its own value back.
|
||||
const NO_PROMPT_MODE = (() => {
|
||||
const m = readFileSync(PRE_BASH, 'utf8').match(/const NO_PROMPT_MODE = '([A-Za-z]+)';/);
|
||||
assert.ok(m, 'the guard names its no-prompt permission mode');
|
||||
return m[1];
|
||||
})();
|
||||
|
||||
const UNDONE_TWO_WAY = 'Undone by: `git revert -m 1 <merge>` (two-way).';
|
||||
const UNDONE_NOT_STATED = 'Undone by: `git revert -m 1 <merge>` (door not stated).';
|
||||
const NO_EVIDENCE = 'Tested by: no run evidence.\nUndone by: no run evidence.';
|
||||
const UNREADABLE = 'Tested by: not measured (the run evidence file could not be read).\nUndone by: not measured.';
|
||||
|
||||
const made = [];
|
||||
after(() => { for (const b of made) rmSync(b, { recursive: true, force: true }); });
|
||||
|
||||
function g(cwd, ...args) {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv() });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
const run = (args, opts = {}) => spawnSync(process.execPath, args, { encoding: 'utf8', env: childEnv(), ...opts });
|
||||
const parse = (r) => { try { return JSON.parse(r.stdout); } catch { return {}; } };
|
||||
|
||||
/**
|
||||
* A throwaway repo on `main` (README) with a branch `feat` one commit ahead
|
||||
* (a.mjs), checked out on main. No hooks, no signing.
|
||||
*/
|
||||
function mevRepo() {
|
||||
const base = realpathSync(mkdtempSync(join(tmpdir(), 'merge-evidence-mev-')));
|
||||
made.push(base);
|
||||
const repo = join(base, 'repo');
|
||||
mkdirSync(repo, { recursive: true });
|
||||
mkdirSync(join(base, 'hooks-none'));
|
||||
g(repo, 'init', '-q', '-b', 'main');
|
||||
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`);
|
||||
writeFileSync(join(repo, 'README.md'), '# t\n');
|
||||
g(repo, 'add', 'README.md');
|
||||
g(repo, 'commit', '-q', '-m', 'init');
|
||||
const mainSha = g(repo, 'rev-parse', 'HEAD');
|
||||
g(repo, 'checkout', '-q', '-b', 'feat');
|
||||
writeFileSync(join(repo, 'a.mjs'), 'export const a = 1;\n');
|
||||
g(repo, 'add', 'a.mjs');
|
||||
g(repo, 'commit', '-q', '-m', 'feat: the work');
|
||||
const featSha = g(repo, 'rev-parse', 'HEAD');
|
||||
g(repo, 'checkout', '-q', 'main');
|
||||
const common = join(repo, '.git');
|
||||
const evDir = join(common, 'voyage', 'merge-evidence');
|
||||
return {
|
||||
base, repo, mainSha, featSha, common, evDir,
|
||||
evPath: (sha = featSha) => join(evDir, `${sha}.json`),
|
||||
/** Write an evidence file by hand (`raw` text, or an object as JSON). */
|
||||
put: (body, sha = featSha) => {
|
||||
mkdirSync(evDir, { recursive: true });
|
||||
writeFileSync(join(evDir, `${sha}.json`), typeof body === 'string' ? body : JSON.stringify(body, null, 2));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A complete evidence object for `sha` on branch feat. */
|
||||
function evidence(sha, over = {}) {
|
||||
return {
|
||||
v: 1, branch: 'feat', branch_head_sha: sha, written_at: '2026-11-02T10:00:00.000Z',
|
||||
end_to_end: { command: '/bin/bash tests/e2e.sh', exit: 0 },
|
||||
verification: { passed: 5, total: 5 },
|
||||
review: { verdict: 'ALLOW', sha },
|
||||
door: { type: 'two-way', text: 'a revert restores the old gate' },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const tested = (sha, { cmd = '/bin/bash tests/e2e.sh', exit = '0', ver = '5/5', verdict = 'ALLOW' } = {}) =>
|
||||
`Tested by: End-to-end \`${cmd}\` exit ${exit}; Verification ${ver}; review ${verdict} at ${sha.slice(0, 8)}.`;
|
||||
|
||||
/** The guard on a Bash call, run in `cwd`; `script` and `env` pick a plugin copy and its environment. */
|
||||
async function ask(cwd, { command = 'git merge --no-ff feat', mode, script = PRE_BASH, env = {} } = {}) {
|
||||
const input = { hook_event_name: 'PreToolUse', tool_name: 'Bash', cwd, tool_input: { command } };
|
||||
if (mode) input.permission_mode = mode;
|
||||
const r = await runHookWithEnv(script, input, env);
|
||||
let out = null;
|
||||
try { out = JSON.parse(String(r.stdout).trim()); } catch { out = null; }
|
||||
return {
|
||||
...r,
|
||||
decision: out?.hookSpecificOutput?.permissionDecision ?? null,
|
||||
reason: String(out?.hookSpecificOutput?.permissionDecisionReason ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
/** A minimal ALLOW review of `sha` (shape copied from tests/lib/review-answers.test.mjs `review`). */
|
||||
function review(sha) {
|
||||
const id = '1'.padStart(40, 'a');
|
||||
const json = JSON.stringify({
|
||||
verdict: 'ALLOW',
|
||||
counts: { BLOCKER: 0, MAJOR: 0, MINOR: 1, SUGGESTION: 0 },
|
||||
allow_blocked_by: [],
|
||||
findings: [{ id, severity: 'MINOR', rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 't', detail: 'd', recommended_action: 'Add a test.' }],
|
||||
}, null, 2);
|
||||
return `---
|
||||
type: trekreview
|
||||
review_version: "1.1"
|
||||
task: "t"
|
||||
slug: s
|
||||
project_dir: .claude/projects/2026-11-02-s/
|
||||
brief_path: .claude/projects/2026-11-02-s/brief.md
|
||||
scope_sha_end: ${sha}
|
||||
reviewed_files_count: 1
|
||||
verdict: ALLOW
|
||||
findings:
|
||||
- ${id}
|
||||
---
|
||||
|
||||
# Review: t
|
||||
|
||||
## Executive Summary
|
||||
|
||||
x
|
||||
|
||||
## Coverage
|
||||
|
||||
| File | Treatment | Reason |
|
||||
|------|-----------|--------|
|
||||
| a.mjs | summary-only | default |
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
x
|
||||
|
||||
\`\`\`json
|
||||
${json}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
/** A plan with an End-to-end step and an optional `## Doors` list (lines). */
|
||||
function plan({ e2e = '/bin/bash tests/e2e-x.sh', doors } = {}) {
|
||||
return `# Plan
|
||||
|
||||
> Generated by trekplan v6.5.0 on 2026-11-02 — \`plan_version: 1.9\`
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: the work
|
||||
|
||||
- **Files:** \`a.mjs\`
|
||||
- **Verify:** \`node --test tests/a.test.mjs\` → expected: pass
|
||||
|
||||
### Step 2: End to end
|
||||
|
||||
- **End-to-end:** \`${e2e}\` → expected: \`e2e: ok\`, exit 0.
|
||||
${doors ? `\n## Doors\n\n${doors.join('\n')}\n` : ''}
|
||||
## Verification
|
||||
|
||||
\`\`\`bash
|
||||
${e2e}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
/** A project folder outside the repo; files written only when given. */
|
||||
function project(fx, { planText, progress, reviewText } = {}) {
|
||||
const dir = mkdtempSync(join(fx.base, 'project-'));
|
||||
if (planText !== undefined) writeFileSync(join(dir, 'plan.md'), planText);
|
||||
if (progress !== undefined) writeFileSync(join(dir, 'progress.json'), JSON.stringify(progress, null, 2));
|
||||
if (reviewText !== undefined) writeFileSync(join(dir, 'review.md'), reviewText);
|
||||
return dir;
|
||||
}
|
||||
|
||||
const PROGRESS = {
|
||||
schema_version: '1', status: 'completed', end_to_end: 'verified',
|
||||
plan_verification: { status: 'passed', summary: { total: 7, passed: 7, failed: 0, blocked: 0, unrunnable: 0 }, failed_criteria: [] },
|
||||
};
|
||||
|
||||
/** The evidence writer's CLI; the module must exist (so a missing one fails on this assertion, not in the child). */
|
||||
function write(repo, branch, proj) {
|
||||
assert.ok(existsSync(WRITER), 'lib/review/merge-evidence.mjs must exist');
|
||||
return run([WRITER, 'write', '--repo', repo, '--branch', branch, '--project', proj, '--json']);
|
||||
}
|
||||
|
||||
// ---- [MEV] the lines ---------------------------------------------------------------
|
||||
|
||||
test('[MEV] the lines: the merge question holds the Tested by and Undone by lines from an evidence file of the merged branch head', async () => {
|
||||
const fx = mevRepo();
|
||||
fx.put(evidence(fx.featSha));
|
||||
const r = await ask(fx.repo);
|
||||
assert.equal(r.decision, 'ask', `Control: the merge into main is asked about: ${r.stdout}${r.stderr}`);
|
||||
assert.ok(r.reason.includes(`${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`), `the two lines, in order:\n${r.reason}`);
|
||||
// Control: the question the guard already asks is still there, the lines after it.
|
||||
assert.ok(r.reason.startsWith('Voyage asks before this command (P12 approval guard): merge into main.'), r.reason);
|
||||
assert.ok(r.reason.indexOf('Way forward:') < r.reason.indexOf('Tested by:'), r.reason);
|
||||
});
|
||||
|
||||
// ---- [MEV] the door ----------------------------------------------------------------
|
||||
|
||||
test('[MEV] the door: a one-way door in the plan\'s ## Doors prints it; none prints "door not stated"', async () => {
|
||||
const fx = mevRepo();
|
||||
const oneWay = project(fx, { planText: plan({ doors: ['- one-way: v1 files no longer read'] }), progress: PROGRESS });
|
||||
const w = write(fx.repo, 'feat', oneWay);
|
||||
assert.equal(w.status, 0, `writer: ${w.stdout}${w.stderr}`);
|
||||
const r = await ask(fx.repo);
|
||||
assert.equal(r.decision, 'ask', `Control: ${r.stdout}${r.stderr}`);
|
||||
assert.ok(r.reason.includes('Undone by: `git revert -m 1 <merge>` (one-way: v1 files no longer read).'), r.reason);
|
||||
|
||||
const none = project(fx, { planText: plan(), progress: PROGRESS });
|
||||
const w2 = write(fx.repo, 'feat', none);
|
||||
assert.equal(w2.status, 0, `writer: ${w2.stdout}${w2.stderr}`);
|
||||
const n = await ask(fx.repo);
|
||||
assert.equal(n.decision, 'ask', `Control: ${n.stdout}${n.stderr}`);
|
||||
assert.ok(n.reason.includes(UNDONE_NOT_STATED), n.reason);
|
||||
|
||||
// Control: with no door in the plan, the CHANGELOG's first section decides when it holds exactly one door line.
|
||||
writeFileSync(join(fx.repo, 'CHANGELOG.md'), '# Changelog\n\n## Unreleased\n\n### Doors\n\n- two-way: the new lines can be reverted\n\n## v1.0.0\n\n### Doors\n\n- one-way: old\n');
|
||||
assert.equal(write(fx.repo, 'feat', none).status, 0);
|
||||
assert.ok((await ask(fx.repo)).reason.includes(UNDONE_TWO_WAY), 'Control: the CHANGELOG door line');
|
||||
writeFileSync(join(fx.repo, 'CHANGELOG.md'), '# Changelog\n\n## Unreleased\n\n### Doors\n\n- two-way: a\n- one-way: b\n');
|
||||
assert.equal(write(fx.repo, 'feat', none).status, 0);
|
||||
assert.ok((await ask(fx.repo)).reason.includes(UNDONE_NOT_STATED), 'Control: two door lines in the CHANGELOG state no door');
|
||||
});
|
||||
|
||||
// ---- [MEV] fail-safe ---------------------------------------------------------------
|
||||
|
||||
test('[MEV] fail-safe: no file, a stale or foreign file, an unreadable field or a reader that will not load says "no run evidence" or "not measured", and still asks', async () => {
|
||||
const fx = mevRepo();
|
||||
// No file.
|
||||
const none = await ask(fx.repo);
|
||||
assert.equal(none.decision, 'ask', `Control: ${none.stdout}${none.stderr}`);
|
||||
assert.ok(none.reason.includes(NO_EVIDENCE), `no file: ${none.reason}`);
|
||||
|
||||
// Stale: the file at the branch head's name holds another head.
|
||||
fx.put(evidence(fx.mainSha));
|
||||
const stale = await ask(fx.repo);
|
||||
assert.equal(stale.decision, 'ask', `Control: ${stale.stdout}${stale.stderr}`);
|
||||
assert.ok(stale.reason.includes(NO_EVIDENCE), `stale head sha: ${stale.reason}`);
|
||||
assert.ok(!stale.reason.includes('Tested by: End-to-end'), stale.reason);
|
||||
|
||||
// Stale: evidence for the old head, then the branch moved on.
|
||||
fx.put(evidence(fx.featSha));
|
||||
g(fx.repo, 'checkout', '-q', 'feat');
|
||||
writeFileSync(join(fx.repo, 'b.mjs'), 'export const b = 2;\n');
|
||||
g(fx.repo, 'add', 'b.mjs');
|
||||
g(fx.repo, 'commit', '-q', '-m', 'feat: more');
|
||||
g(fx.repo, 'checkout', '-q', 'main');
|
||||
const moved = await ask(fx.repo);
|
||||
assert.equal(moved.decision, 'ask', `Control: ${moved.stdout}${moved.stderr}`);
|
||||
assert.ok(moved.reason.includes(NO_EVIDENCE), `the branch moved on: ${moved.reason}`);
|
||||
const head = g(fx.repo, 'rev-parse', 'feat');
|
||||
|
||||
// Foreign: another format version, or another branch's name.
|
||||
fx.put(evidence(head, { v: 2 }), head);
|
||||
assert.ok((await ask(fx.repo)).reason.includes(NO_EVIDENCE), 'a file of another version');
|
||||
fx.put(evidence(head, { branch: 'other' }), head);
|
||||
assert.ok((await ask(fx.repo)).reason.includes(NO_EVIDENCE), 'a file for another branch');
|
||||
|
||||
// Unreadable fields: each reads "not measured", none prints a number or a verdict.
|
||||
fx.put(evidence(head, {
|
||||
end_to_end: { command: 42, exit: '0' }, verification: { passed: '7', total: 7 },
|
||||
review: { verdict: 'GREEN', sha: 'abc' }, door: { type: 'sideways', text: 'x' },
|
||||
}), head);
|
||||
const bad = await ask(fx.repo);
|
||||
assert.equal(bad.decision, 'ask', `Control: ${bad.stdout}${bad.stderr}`);
|
||||
assert.ok(bad.reason.includes('Tested by: End-to-end command not measured exit not measured; Verification not measured; review not measured at not measured.'), bad.reason);
|
||||
assert.ok(bad.reason.includes(UNDONE_NOT_STATED), bad.reason);
|
||||
// Missing fields read the same.
|
||||
fx.put({ v: 1, branch: 'feat', branch_head_sha: head }, head);
|
||||
const missing = await ask(fx.repo);
|
||||
assert.ok(missing.reason.includes('Tested by: End-to-end command not measured exit not measured; Verification not measured; review not measured at not measured.'), missing.reason);
|
||||
// More passed than total is no count.
|
||||
fx.put(evidence(head, { verification: { passed: 9, total: 7 } }), head);
|
||||
assert.ok((await ask(fx.repo)).reason.includes('Verification not measured;'), 'passed > total');
|
||||
// Non-printable and non-ASCII characters print as `?`.
|
||||
fx.put(evidence(head, { end_to_end: { command: 'e2e\u0007ü\nrm', exit: 0 } }), head);
|
||||
const odd = await ask(fx.repo);
|
||||
assert.ok(odd.reason.includes('End-to-end `e2e???rm` exit 0;'), odd.reason);
|
||||
// Bad JSON.
|
||||
fx.put('{"v": 1, "branch": ', head);
|
||||
const json = await ask(fx.repo);
|
||||
assert.equal(json.decision, 'ask', `Control: ${json.stdout}${json.stderr}`);
|
||||
assert.ok(json.reason.includes(UNREADABLE), `bad JSON: ${json.reason}`);
|
||||
|
||||
// A merge with no single branch named reads "no run evidence".
|
||||
fx.put(evidence(head), head);
|
||||
for (const command of ['git merge --no-ff', 'git merge --no-ff feat other', 'git merge -m feat']) {
|
||||
const r = await ask(fx.repo, { command });
|
||||
assert.equal(r.decision, 'ask', `Control: ${command}`);
|
||||
assert.ok(r.reason.includes(NO_EVIDENCE), `${command}: ${r.reason}`);
|
||||
}
|
||||
// Control: option values are skipped, so the branch is still found.
|
||||
const opts = await ask(fx.repo, { command: 'git merge --no-ff -s ort -X ours feat' });
|
||||
assert.ok(opts.reason.includes(tested(head)), opts.reason);
|
||||
|
||||
// A reader that will not load: the question still asks and says so.
|
||||
const plugin = pluginCopy({ 'hooks/lib/merge-evidence.mjs': 'missing' });
|
||||
made.push(plugin);
|
||||
const gone = await ask(fx.repo, { script: join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs') });
|
||||
assert.equal(gone.decision, 'ask', `Control: ${gone.stdout}${gone.stderr}`);
|
||||
assert.ok(gone.reason.includes('(run evidence not loaded)'), gone.reason);
|
||||
});
|
||||
|
||||
test('[MEV] a hostile evidence path still asks: a FIFO, a symlink and a 10 MiB file read "not measured"', async () => {
|
||||
const fx = mevRepo();
|
||||
// A good file elsewhere: followed, the symlink would print the green line.
|
||||
const elsewhere = join(fx.base, 'good.json');
|
||||
writeFileSync(elsewhere, JSON.stringify(evidence(fx.featSha)));
|
||||
mkdirSync(fx.evDir, { recursive: true });
|
||||
|
||||
symlinkSync(elsewhere, fx.evPath());
|
||||
const link = await ask(fx.repo);
|
||||
assert.equal(link.decision, 'ask', `Control: ${link.stdout}${link.stderr}`);
|
||||
assert.ok(link.reason.includes(UNREADABLE), `a symlink: ${link.reason}`);
|
||||
assert.ok(!link.reason.includes(tested(fx.featSha)), link.reason);
|
||||
rmSync(fx.evPath());
|
||||
|
||||
const fifo = spawnSync('mkfifo', [fx.evPath()], { encoding: 'utf8', env: childEnv() });
|
||||
assert.equal(fifo.status, 0, `mkfifo: ${fifo.stderr}`);
|
||||
const pipe = await ask(fx.repo);
|
||||
assert.equal(pipe.decision, 'ask', `Control: a FIFO does not hang the guard: ${pipe.code} ${pipe.stdout}${pipe.stderr}`);
|
||||
assert.ok(pipe.reason.includes(UNREADABLE), `a FIFO: ${pipe.reason}`);
|
||||
rmSync(fx.evPath());
|
||||
|
||||
// Valid JSON with the good fields, padded past 10 MiB.
|
||||
writeFileSync(fx.evPath(), JSON.stringify(evidence(fx.featSha)) + ' '.repeat(10 * 1024 * 1024));
|
||||
const big = await ask(fx.repo);
|
||||
assert.equal(big.decision, 'ask', `Control: ${big.stdout}${big.stderr}`);
|
||||
assert.ok(big.reason.includes(UNREADABLE), `a 10 MiB file: ${big.reason}`);
|
||||
assert.ok(!big.reason.includes(tested(fx.featSha)), big.reason);
|
||||
|
||||
// A directory at the evidence path.
|
||||
rmSync(fx.evPath());
|
||||
mkdirSync(fx.evPath());
|
||||
const dir = await ask(fx.repo);
|
||||
assert.equal(dir.decision, 'ask', `Control: ${dir.stdout}${dir.stderr}`);
|
||||
assert.ok(dir.reason.includes(UNREADABLE), `a directory: ${dir.reason}`);
|
||||
rmSync(fx.evPath(), { recursive: true });
|
||||
|
||||
// Control: unattended, the merge is still refused, evidence or not.
|
||||
fx.put(evidence(fx.featSha));
|
||||
const unattended = await ask(fx.repo, { mode: NO_PROMPT_MODE });
|
||||
assert.equal(unattended.code, 2, `Control: unattended refuses the merge: ${unattended.stdout}${unattended.stderr}`);
|
||||
assert.equal(unattended.decision, null, 'Control: no ask where no one can be asked');
|
||||
assert.match(unattended.stderr, /BLOCKED: merge into main with no one to ask/);
|
||||
});
|
||||
|
||||
// ---- [MEV] round trip --------------------------------------------------------------
|
||||
|
||||
test('[MEV] round trip: a file written by merge-evidence.mjs write is read back by the guard into the same two lines', async () => {
|
||||
const fx = mevRepo();
|
||||
const proj = project(fx, {
|
||||
planText: plan({ doors: ['- two-way: a revert restores the old gate'] }),
|
||||
progress: PROGRESS,
|
||||
reviewText: review(fx.featSha),
|
||||
});
|
||||
// HEAD at the reviewed commit while the writer asks the verdict gate (R6).
|
||||
g(fx.repo, 'checkout', '-q', 'feat');
|
||||
const w = write(fx.repo, 'feat', proj);
|
||||
assert.equal(w.status, 0, `writer: ${w.stdout}${w.stderr}`);
|
||||
const out = parse(w);
|
||||
assert.equal(out.written, true, w.stdout);
|
||||
assert.equal(out.path, fx.evPath(), w.stdout);
|
||||
const file = JSON.parse(readFileSync(fx.evPath(), 'utf8'));
|
||||
assert.equal(file.v, 1);
|
||||
assert.equal(file.branch, 'feat');
|
||||
assert.equal(file.branch_head_sha, fx.featSha);
|
||||
assert.match(String(file.written_at), /^\d{4}-\d\d-\d\dT/);
|
||||
assert.deepEqual(file.end_to_end, { command: '/bin/bash tests/e2e-x.sh', exit: 0 });
|
||||
assert.deepEqual(file.verification, { passed: 7, total: 7 });
|
||||
assert.deepEqual(file.review, { verdict: 'ALLOW', sha: fx.featSha });
|
||||
assert.deepEqual(file.door, { type: 'two-way', text: 'a revert restores the old gate' });
|
||||
|
||||
g(fx.repo, 'checkout', '-q', 'main');
|
||||
const r = await ask(fx.repo);
|
||||
assert.equal(r.decision, 'ask', `Control: ${r.stdout}${r.stderr}`);
|
||||
assert.ok(r.reason.includes(`${tested(fx.featSha, { cmd: '/bin/bash tests/e2e-x.sh', ver: '7/7' })}\n${UNDONE_TWO_WAY}`), r.reason);
|
||||
|
||||
// Control: an End-to-end that is not verified writes no exit; a review the gate does not pass writes no verdict.
|
||||
g(fx.repo, 'checkout', '-q', 'feat');
|
||||
writeFileSync(join(proj, 'progress.json'), JSON.stringify({ ...PROGRESS, end_to_end: 'not-verified' }));
|
||||
writeFileSync(join(proj, 'review.md'), review(fx.mainSha));
|
||||
assert.equal(write(fx.repo, 'feat', proj).status, 0);
|
||||
g(fx.repo, 'checkout', '-q', 'main');
|
||||
const nv = await ask(fx.repo);
|
||||
assert.ok(nv.reason.includes('Tested by: End-to-end `/bin/bash tests/e2e-x.sh` exit not measured; Verification 7/7; review not measured at '), nv.reason);
|
||||
|
||||
// Control: a branch name that is not a plain name is refused, and nothing is written.
|
||||
for (const branch of ['..feat', 'fe..at', '-feat', 'fe\u0001at', 'nope']) {
|
||||
const bad = write(fx.repo, branch, proj);
|
||||
assert.equal(bad.status, 1, `refused ${JSON.stringify(branch)}: ${bad.stdout}${bad.stderr}`);
|
||||
assert.equal(parse(bad).written, false, bad.stdout);
|
||||
assert.equal(typeof parse(bad).reason, 'string', bad.stdout);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- [MEV] where the file lives ----------------------------------------------------
|
||||
|
||||
test('[MEV] the evidence file sits under the git common dir, and git status --porcelain in the fixture shows nothing new', async () => {
|
||||
const fx = mevRepo();
|
||||
// The run in a linked worktree, main in the first one (Phase 8 merges with `git -C`).
|
||||
const wt = join(fx.base, 'wt');
|
||||
g(fx.repo, 'worktree', 'add', '-q', wt, 'feat');
|
||||
const proj = project(fx, { planText: plan(), progress: PROGRESS, reviewText: review(fx.featSha) });
|
||||
const before = [g(fx.repo, 'status', '--porcelain', '--untracked-files=all'), g(wt, 'status', '--porcelain', '--untracked-files=all')];
|
||||
const w = write(wt, 'feat', proj);
|
||||
assert.equal(w.status, 0, `writer: ${w.stdout}${w.stderr}`);
|
||||
const raw = g(wt, 'rev-parse', '--git-common-dir');
|
||||
const common = realpathSync(isAbsolute(raw) ? raw : resolve(wt, raw));
|
||||
const path = parse(w).path;
|
||||
assert.equal(realpathSync(path), join(common, 'voyage', 'merge-evidence', `${fx.featSha}.json`), w.stdout);
|
||||
assert.ok(!realpathSync(path).startsWith(`${realpathSync(wt)}/`), 'not in the worktree');
|
||||
const afterStatus = [g(fx.repo, 'status', '--porcelain', '--untracked-files=all'), g(wt, 'status', '--porcelain', '--untracked-files=all')];
|
||||
assert.deepEqual(afterStatus, before, 'git status shows nothing new in either tree');
|
||||
// Control: the guard in main's tree finds the file the run's tree wrote.
|
||||
const r = await ask(fx.repo);
|
||||
assert.equal(r.decision, 'ask', `Control: ${r.stdout}${r.stderr}`);
|
||||
assert.ok(r.reason.includes(`${tested(fx.featSha, { cmd: '/bin/bash tests/e2e-x.sh', ver: '7/7' })}\n${UNDONE_NOT_STATED}`), r.reason);
|
||||
});
|
||||
|
||||
// ---- [MEV] the reader's architecture rule ------------------------------------------
|
||||
|
||||
/** What the reader must never hold: a process spawn, a `../../lib/` import, a non-ASCII byte. */
|
||||
function scanReader(path) {
|
||||
const buf = readFileSync(path);
|
||||
const text = buf.toString('latin1');
|
||||
const found = [];
|
||||
if (/child_process/.test(text)) found.push('child_process');
|
||||
if (/['"`]\.\.\/\.\.\/lib\//.test(text)) found.push('../../lib/ import');
|
||||
if (buf.some((b) => b > 0x7f)) found.push('non-ASCII byte');
|
||||
return found;
|
||||
}
|
||||
|
||||
test('[MEV] the reader is ASCII and spawns nothing', () => {
|
||||
assert.ok(existsSync(READER), 'hooks/lib/merge-evidence.mjs must exist');
|
||||
assert.deepEqual(scanReader(READER), [], 'the real reader');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'merge-evidence-scan-'));
|
||||
made.push(dir);
|
||||
const copy = join(dir, 'merge-evidence.mjs');
|
||||
const plants = [
|
||||
["import { spawnSync } from 'node:child_process';\n", 'child_process'],
|
||||
["const x = require('node:child_process');\n", 'child_process'],
|
||||
["import { parseDoors } from '../../lib/util/doors.mjs';\n", '../../lib/ import'],
|
||||
['// café\n', 'non-ASCII byte'],
|
||||
];
|
||||
for (const [line, want] of plants) {
|
||||
copyFileSync(READER, copy);
|
||||
appendFileSync(copy, line);
|
||||
assert.deepEqual(scanReader(copy), [want], `planted: ${line.trim()}`);
|
||||
}
|
||||
copyFileSync(READER, copy);
|
||||
assert.deepEqual(scanReader(copy), [], 'the clean copy');
|
||||
});
|
||||
|
||||
// ---- [MEV] the writer's clean-up ---------------------------------------------------
|
||||
|
||||
test('[MEV] the writer removes an evidence file older than 30 days and keeps a fresh one', () => {
|
||||
const fx = mevRepo();
|
||||
mkdirSync(fx.evDir, { recursive: true });
|
||||
const old = join(fx.evDir, `${'a'.repeat(40)}.json`);
|
||||
const fresh = join(fx.evDir, `${'b'.repeat(40)}.json`);
|
||||
const notes = join(fx.evDir, 'notes.txt');
|
||||
for (const p of [old, fresh, notes]) writeFileSync(p, '{}\n');
|
||||
const longAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||
utimesSync(old, longAgo, longAgo);
|
||||
utimesSync(notes, longAgo, longAgo);
|
||||
const w = write(fx.repo, 'feat', project(fx, { planText: plan(), progress: PROGRESS }));
|
||||
assert.equal(w.status, 0, `writer: ${w.stdout}${w.stderr}`);
|
||||
assert.ok(!existsSync(old), 'the evidence file older than 30 days is removed');
|
||||
assert.ok(existsSync(fresh), 'the fresh evidence file is kept');
|
||||
assert.ok(existsSync(notes), 'Control: a file not named <hex>.json is never removed');
|
||||
assert.ok(existsSync(fx.evPath()), 'the new evidence file is written');
|
||||
});
|
||||
|
|
@ -32,6 +32,10 @@
|
|||
// DPR /trekreview Phase 4 says a changed instruction file at any depth,
|
||||
// `docs/**` included, gets deep review, after the triage table and
|
||||
// with its two pinned rows untouched.
|
||||
// MEV /trekexecute Phase 8 writes the merge evidence file in a Bash call of
|
||||
// its own before the merge call, and goes on when it is not written;
|
||||
// docs/HANDOVER-CONTRACTS.md documents the file as internal: its path
|
||||
// under the git common dir, never tracked, not part of Handover 5.
|
||||
// The helpers below are copied, never imported from an earlier prose test.
|
||||
// Fix the SOURCE a pin guards, not the pin.
|
||||
|
||||
|
|
@ -395,3 +399,47 @@ test('[DPR] /trekreview Phase 4 says a changed instruction file at any depth, do
|
|||
assert.ok(lines.includes('| `summary-only` | `docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**`. |'),
|
||||
'Control: the summary-only row is unchanged');
|
||||
});
|
||||
|
||||
// --- MEV: the merge question's evidence -------------------------------------------
|
||||
|
||||
const PHASE_8 = '## Phase 8 — Final report';
|
||||
const EVIDENCE_CMD = 'node ${CLAUDE_PLUGIN_ROOT}/lib/review/merge-evidence.mjs write --repo . --branch "{run branch}" --project "{project_dir}" --json';
|
||||
|
||||
test('[MEV] Phase 8 writes the evidence file before the merge call', () => {
|
||||
const p8 = section(read(EXEC_CMD), PHASE_8);
|
||||
const open = p8.indexOf('**2. Open: the merge, and the one question.**');
|
||||
assert.ok(open >= 0, 'Phase 8 has its "2. Open" part');
|
||||
const lines = p8.split('\n');
|
||||
const at = lines.findIndex((l) => l.trim() === EVIDENCE_CMD);
|
||||
assert.ok(at >= 0, `Phase 8 runs the evidence writer: ${EVIDENCE_CMD}`);
|
||||
// A Bash call of its own: the command is the one line of its fenced block.
|
||||
assert.match(lines[at - 1].trim(), /^```bash$/, 'the writer opens its own bash block');
|
||||
assert.equal(lines[at + 1].trim(), '```', 'the writer is the only line of its block');
|
||||
const writer = p8.indexOf(EVIDENCE_CMD);
|
||||
assert.ok(writer > open, 'the writer runs in "2. Open"');
|
||||
const merges = [p8.indexOf('merge --no-ff "{run branch}"'), p8.indexOf('git switch "{main}"')];
|
||||
for (const m of merges) assert.ok(m > writer, 'the writer runs before each merge block');
|
||||
assert.ok(sentences(section(read(EXEC_CMD), PHASE_8)).some((s) => s.includes('On `"written": false` (exit 1), print its `reason` in one line and continue')
|
||||
&& /no run evidence/.test(s)), 'a refused write is said in one line and the run goes on; the question then says "no run evidence"');
|
||||
// Control: the merge blocks are unchanged.
|
||||
assert.ok(p8.includes(' if git -C "{main worktree path}" merge --no-ff "{run branch}" -m "merge: trekexecute {slug}"; then\n echo "MERGED"'),
|
||||
'Control: the worktree merge block is unchanged');
|
||||
assert.ok(p8.includes(' git switch "{main}" || { echo "MERGE_REFUSED"; exit 1; }\n if git merge --no-ff "{run branch}" -m "merge: trekexecute {slug}"; then'),
|
||||
'Control: the switch merge block is unchanged');
|
||||
});
|
||||
|
||||
test('[MEV] docs/HANDOVER-CONTRACTS.md documents the evidence file as internal', () => {
|
||||
const text = read(CONTRACTS);
|
||||
const note = section(text, '## Internal files');
|
||||
const f = flat(note);
|
||||
assert.ok(f.includes('`<git common dir>/voyage/merge-evidence/<branch head sha>.json`'), 'the note names the path under the git common dir');
|
||||
assert.match(f, /never tracked/, 'never tracked');
|
||||
assert.match(f, /never published/, 'never published');
|
||||
assert.match(f, /not part of Handover 5/, 'not part of Handover 5');
|
||||
for (const field of ['`v`', '`branch`', '`branch_head_sha`', '`written_at`', '`end_to_end`', '`verification`', '`review`', '`door`']) {
|
||||
assert.ok(f.includes(field), `the note names the field ${field}`);
|
||||
}
|
||||
assert.ok(f.includes('lib/review/merge-evidence.mjs') && f.includes('hooks/lib/merge-evidence.mjs'), 'the note names the writer and the reader');
|
||||
// Control: Handover 5 does not name the file.
|
||||
assert.doesNotMatch(section(text, '## Handover 5'), /merge-evidence/, 'Control: Handover 5 is unchanged');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue