voyage/lib/review/merge-evidence.mjs
Kjell Tore Guttormsen 9edc998fac
refactor: simplify pass (trekexecute)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:29:46 +01:00

253 lines
12 KiB
JavaScript

// 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. The gate is asked
// only when `git rev-parse --verify HEAD` in --repo is the branch head (R6
// is HEAD-based); from any other HEAD the verdict is null ("not measured"
// on the question) and `review.sha` is still written;
// - 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".
// A branch name git would read as something else is refused before the head
// is read ({"written": false}, "branch name <b> is ambiguous ..."): when
// `refs/tags/<b>` or `refs/<b>` resolves, or an entry of any kind
// `<git dir>/<b>` or `<git common dir>/<b>` exists (so `voyage`, the evidence
// directory's parent, is refused). The guard's reader (hooks/lib/merge-evidence.mjs)
// is stricter: it also refuses a directory at `refs/<b>` or `refs/tags/<b>`
// (a branch named `heads`, a tag `feat/x`), so it may show no evidence for a
// file this writer wrote; never the reverse.
// 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' });
}
/**
* True when git would read `branch` as something other than refs/heads/<branch>:
* `refs/tags/<b>` or `refs/<b>` resolves (argv only, never a shell), or an
* entry of any kind (file, directory, link) `<git dir>/<b>` or
* `<git common dir>/<b>` exists. The guard's reader
* (hooks/lib/merge-evidence.mjs) shows no evidence for the same names.
*/
function ambiguousName(repoDir, gitDir, common, branch) {
for (const ref of [`refs/tags/${branch}`, `refs/${branch}`]) {
if (git(repoDir, ['rev-parse', '--verify', '--quiet', ref]).status === 0) return true;
}
for (const dir of [gitDir, common]) {
try { lstatSync(join(dir, branch)); return true; } catch (e) {
// No entry is ENOENT/ENOTDIR; any other error cannot tell, so it counts as ambiguous.
if (e?.code !== 'ENOENT' && e?.code !== 'ENOTDIR') return true;
}
}
return false;
}
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 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 gd = git(repoDir, ['rev-parse', '--git-dir']);
const rawGitDir = gd.status === 0 ? gd.stdout.trim() : '';
if (!rawGitDir) return { written: false, reason: `git rev-parse --git-dir failed in ${repoDir}` };
const gitDir = isAbsolute(rawGitDir) ? rawGitDir : resolve(repoDir, rawGitDir);
if (ambiguousName(repoDir, gitDir, common, branch)) {
return { written: false, reason: `branch name ${branch} is ambiguous (a tag or another ref has that name)` };
}
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}` };
// The gate's R6 check is HEAD-based: it is asked only when HEAD is the branch head.
const at = git(repoDir, ['rev-parse', '--verify', 'HEAD']);
const headIsBranch = at.status === 0 && at.stdout.trim() === sha;
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 && headIsBranch) {
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);
}