// 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: // /voyage/merge-evidence/.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/`; // - 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 is ambiguous ..."): when // `refs/tags/` or `refs/` resolves, or an entry of any kind // `/` or `/` 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/` or `refs/tags/` // (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 `.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 --branch --project --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 `## ` 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); }