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
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue