fix(review): the verdict gate refuses, with --repo, a review of another commit than the one being merged
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a9f5f6251a
commit
6c0718c8d8
6 changed files with 570 additions and 25 deletions
|
|
@ -286,7 +286,7 @@ function atomicWriteText(path, text) {
|
||||||
renameSync(tmp, path);
|
renameSync(tmp, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
function withPointer(claudeText) {
|
export function withPointer(claudeText) {
|
||||||
if (claudeText.includes(POINTER_START) || claudeText.includes(POINTER_END)) return null;
|
if (claudeText.includes(POINTER_START) || claudeText.includes(POINTER_END)) return null;
|
||||||
const sep = claudeText.endsWith('\n') ? '\n' : '\n\n';
|
const sep = claudeText.endsWith('\n') ? '\n' : '\n\n';
|
||||||
return `${claudeText}${sep}${POINTER_START}\n${POINTER_LINE}\n${POINTER_END}\n`;
|
return `${claudeText}${sep}${POINTER_START}\n${POINTER_LINE}\n${POINTER_END}\n`;
|
||||||
|
|
|
||||||
162
lib/review/reviewed-commit.mjs
Normal file
162
lib/review/reviewed-commit.mjs
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
// lib/review/reviewed-commit.mjs
|
||||||
|
// R6 — is the review of the commit being merged? (fix round 4-5a)
|
||||||
|
//
|
||||||
|
// A review verdict opens the merge into main only for the history it judged.
|
||||||
|
// `/trekexecute` commits the review lessons after the review, so the commit
|
||||||
|
// being merged is rarely the reviewed one itself; what may come after it is
|
||||||
|
// exactly what /trekreview writes and nothing else:
|
||||||
|
//
|
||||||
|
// - the reviewed commit is HEAD, or an ancestor of HEAD;
|
||||||
|
// - every commit between it and HEAD (side branches included, each diffed
|
||||||
|
// against its first parent, `--no-relative --ignore-submodules=none`) changes only
|
||||||
|
// `{prefix}.claude/rules/voyage-review-lessons.md` and `{prefix}CLAUDE.md`,
|
||||||
|
// where {prefix} is the project's place in the repo (`git rev-parse
|
||||||
|
// --show-prefix`);
|
||||||
|
// - in each such commit the lessons file is unchanged outside its managed
|
||||||
|
// block, and CLAUDE.md is unchanged or gains exactly the pointer block
|
||||||
|
// (`withPointer` in lib/review/recurrence.mjs);
|
||||||
|
// - a commit with no parent in that range fails closed, and so does a
|
||||||
|
// symlinked CLAUDE.md (the pointer write goes through the link, so its
|
||||||
|
// target shows as a changed path).
|
||||||
|
//
|
||||||
|
// No CLI of its own: the command is `lib/review/verdict-gate.mjs --repo <dir>`,
|
||||||
|
// which exits 2 (never green) with the reason `review-stale: …` when
|
||||||
|
// `checkReviewedCommit` is not ok. Every git call is an argv array with no
|
||||||
|
// shell; a git call that fails is a stale review (fails closed), never ok.
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { MARK_START, MARK_END, withPointer } from './recurrence.mjs';
|
||||||
|
|
||||||
|
const SHA_RE = /^[0-9a-f]{7,40}$/i;
|
||||||
|
const LESSONS_REL = '.claude/rules/voyage-review-lessons.md';
|
||||||
|
const CLAUDE_REL = 'CLAUDE.md';
|
||||||
|
const SYMLINK_MODE = '120000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The review's `scope_sha_end` as written. Read from the frontmatter text, not
|
||||||
|
* through the YAML reader, which turns an all-digit value into a number and
|
||||||
|
* drops a leading zero (`0123456` → 123456; release review of 6.3.0, round 3).
|
||||||
|
*/
|
||||||
|
export function reviewedSha(reviewText) {
|
||||||
|
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(reviewText ?? ''));
|
||||||
|
const m = fm && /^scope_sha_end:[ \t]*(["']?)([^"'\s#]*)\1[ \t]*(?:#.*)?$/m.exec(fm[1]);
|
||||||
|
// A commit sha only: a ref name (HEAD, main, a tag) resolves to whatever it
|
||||||
|
// points at now, and coverage would pass whatever was reviewed (round 4).
|
||||||
|
return m && /^[0-9a-f]{7,40}$/i.test(m[2]) ? m[2] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function git(repo, args) {
|
||||||
|
return spawnSync('git', args, { cwd: repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function stale(reason) {
|
||||||
|
return { ok: false, reason: `review-stale: ${reason}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A failed git call as a stale reason: fails closed. */
|
||||||
|
function gitFailed(what, r) {
|
||||||
|
const note = (r.error?.message ?? String(r.stderr ?? '').trim().split('\n')[0]) || `exit ${r.status}`;
|
||||||
|
return stale(`git ${what} failed (${note})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const short = (c) => c.slice(0, 12);
|
||||||
|
|
||||||
|
/** The lessons text with its managed block (start marker to end marker, plus its newline) taken out. */
|
||||||
|
function outsideBlock(text) {
|
||||||
|
const s = text.indexOf(MARK_START);
|
||||||
|
if (s === -1) return text;
|
||||||
|
const e = text.indexOf(MARK_END, s + MARK_START.length);
|
||||||
|
if (e === -1) return text;
|
||||||
|
let end = e + MARK_END.length;
|
||||||
|
if (text.startsWith('\r\n', end)) end += 2;
|
||||||
|
else if (text[end] === '\n') end += 1;
|
||||||
|
return text.slice(0, s) + text.slice(end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One file in one tree: `{ ok, absent, mode, text }`; absent reads as ''.
|
||||||
|
* `--full-tree`: the path is from the repo root, as `git diff` prints it.
|
||||||
|
*/
|
||||||
|
function blobAt(repo, rev, path) {
|
||||||
|
const ls = git(repo, ['ls-tree', '--full-tree', '-z', rev, '--', path]);
|
||||||
|
if (ls.status !== 0) return { ok: false, fail: gitFailed(`ls-tree ${rev}`, ls) };
|
||||||
|
const entry = ls.stdout.split('\0').find(Boolean);
|
||||||
|
if (!entry) return { ok: true, absent: true, mode: null, text: '' };
|
||||||
|
const mode = entry.split(' ')[0];
|
||||||
|
const blob = git(repo, ['cat-file', 'blob', `${rev}:${path}`]);
|
||||||
|
if (blob.status !== 0) return { ok: false, fail: gitFailed(`cat-file ${rev}:${path}`, blob) };
|
||||||
|
return { ok: true, absent: false, mode, text: blob.stdout };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the review of `sha` a review of what HEAD holds in `repo`?
|
||||||
|
* @param {{ repo: string, sha: string }} args
|
||||||
|
* @returns {{ ok: true } | { ok: false, reason: string }} reason starts `review-stale:`
|
||||||
|
*/
|
||||||
|
export function checkReviewedCommit({ repo, sha } = {}) {
|
||||||
|
if (typeof repo !== 'string' || repo === '') return stale('no repository given');
|
||||||
|
const given = String(sha ?? '');
|
||||||
|
const notCommit = `reviewed commit ${given} is not a commit in this repository`;
|
||||||
|
// 1. Hex only, so a value like `--output=/x` never reaches git as an option.
|
||||||
|
if (!SHA_RE.test(given)) return stale(notCommit);
|
||||||
|
const resolved = git(repo, ['rev-parse', '--verify', '--quiet', `${given}^{commit}`]);
|
||||||
|
if (resolved.status !== 0) {
|
||||||
|
const note = (resolved.error?.message ?? String(resolved.stderr ?? '').trim().split('\n')[0]);
|
||||||
|
return stale(note ? `${notCommit} (${note})` : notCommit);
|
||||||
|
}
|
||||||
|
const full = resolved.stdout.trim();
|
||||||
|
|
||||||
|
// 2. The review is of HEAD itself.
|
||||||
|
const head = git(repo, ['rev-parse', '--verify', 'HEAD']);
|
||||||
|
if (head.status !== 0) return gitFailed('rev-parse HEAD', head);
|
||||||
|
if (head.stdout.trim() === full) return { ok: true };
|
||||||
|
|
||||||
|
// 3. Ancestor of HEAD (side branches merged in count; an unmerged one does not).
|
||||||
|
const anc = git(repo, ['merge-base', '--is-ancestor', full, 'HEAD']);
|
||||||
|
if (anc.status === 1) return stale('the reviewed commit is not an ancestor of HEAD');
|
||||||
|
if (anc.status !== 0) return gitFailed('merge-base --is-ancestor', anc);
|
||||||
|
|
||||||
|
// 4. Every commit after the review, paths and content.
|
||||||
|
const pre = git(repo, ['rev-parse', '--show-prefix']);
|
||||||
|
if (pre.status !== 0) return gitFailed('rev-parse --show-prefix', pre);
|
||||||
|
const prefix = pre.stdout.replace(/\r?\n$/, '');
|
||||||
|
const lessons = `${prefix}${LESSONS_REL}`;
|
||||||
|
const claude = `${prefix}${CLAUDE_REL}`;
|
||||||
|
|
||||||
|
const roots = git(repo, ['rev-list', '--max-parents=0', `${full}..HEAD`]);
|
||||||
|
if (roots.status !== 0) return gitFailed('rev-list --max-parents=0', roots);
|
||||||
|
const root = roots.stdout.split('\n').find(Boolean);
|
||||||
|
if (root) return stale(`commit ${short(root)} has no parent`);
|
||||||
|
|
||||||
|
const list = git(repo, ['rev-list', `${full}..HEAD`]);
|
||||||
|
if (list.status !== 0) return gitFailed('rev-list', list);
|
||||||
|
for (const c of list.stdout.split('\n').filter(Boolean)) {
|
||||||
|
// `--no-relative` and `--ignore-submodules=none`: neither `diff.relative`
|
||||||
|
// nor an `ignore =` setting (config or .gitmodules) may hide a changed path.
|
||||||
|
const names = git(repo, ['diff', '--no-relative', '--ignore-submodules=none', '--name-only', '-z', '--no-renames', `${c}^1`, c]);
|
||||||
|
if (names.status !== 0) return gitFailed(`diff ${short(c)}`, names);
|
||||||
|
const paths = names.stdout.split('\0').filter(Boolean);
|
||||||
|
const other = paths.find((p) => p !== lessons && p !== claude);
|
||||||
|
if (other !== undefined) return stale(`${other} changed after the review (commit ${short(c)})`);
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
const before = blobAt(repo, `${c}^1`, path);
|
||||||
|
if (!before.ok) return before.fail;
|
||||||
|
const after = blobAt(repo, c, path);
|
||||||
|
if (!after.ok) return after.fail;
|
||||||
|
if (before.mode === SYMLINK_MODE || after.mode === SYMLINK_MODE) {
|
||||||
|
return stale(`${path} is a symlink (commit ${short(c)}) — re-run /trekreview on this commit`);
|
||||||
|
}
|
||||||
|
if (path === lessons) {
|
||||||
|
// 5. Only the managed block may change.
|
||||||
|
if (outsideBlock(before.text).trimEnd() !== outsideBlock(after.text).trimEnd()) {
|
||||||
|
return stale(`${path} changed outside the managed block (commit ${short(c)})`);
|
||||||
|
}
|
||||||
|
} else if (after.text !== before.text && after.text !== withPointer(before.text)) {
|
||||||
|
// 6. CLAUDE.md: unchanged, or exactly the pointer block added.
|
||||||
|
return stale(`${path} changed other than the review-lessons pointer (commit ${short(c)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// One command decides on the verdict written in review.md, so whatever stops a
|
// One command decides on the verdict written in review.md, so whatever stops a
|
||||||
// merge or a release reads a file and an exit code, never a model's summary:
|
// merge or a release reads a file and an exit code, never a model's summary:
|
||||||
//
|
//
|
||||||
// node lib/review/verdict-gate.mjs [--json] <review.md>
|
// node lib/review/verdict-gate.mjs [--json] <review.md> [--repo <dir>]
|
||||||
// 0 = ALLOW or WARN
|
// 0 = ALLOW or WARN
|
||||||
// 1 = BLOCK
|
// 1 = BLOCK
|
||||||
// 2 = could not decide: missing/unreadable file, not a review, no verdict,
|
// 2 = could not decide: missing/unreadable file, not a review, no verdict,
|
||||||
|
|
@ -13,7 +13,12 @@
|
||||||
// shape (coordinator-contract `illegalBriefLowering`), or a
|
// shape (coordinator-contract `illegalBriefLowering`), or a
|
||||||
// WARN because a reviewer or engine did not run (`missing-reviewer:` /
|
// WARN because a reviewer or engine did not run (`missing-reviewer:` /
|
||||||
// `unattributable-payload` in allow_blocked_by — operator decision
|
// `unattributable-payload` in allow_blocked_by — operator decision
|
||||||
// 2026-10-06: a review not performed in full stops a merge).
|
// 2026-10-06: a review not performed in full stops a merge), a
|
||||||
|
// `--repo` with no value, or, with `--repo <dir>`, an ALLOW/WARN
|
||||||
|
// review of another commit than the one being merged (reason
|
||||||
|
// `review-stale: …`: its `scope_sha_end` is not HEAD, or an ancestor
|
||||||
|
// of HEAD whose later commits change only the review lessons and the
|
||||||
|
// CLAUDE.md pointer; lib/review/reviewed-commit.mjs, R6).
|
||||||
// Exit 2 is NEVER green.
|
// Exit 2 is NEVER green.
|
||||||
//
|
//
|
||||||
// Fail-closed reading: a missing `verdict` is exit 2, never ALLOW. A verdict
|
// Fail-closed reading: a missing `verdict` is exit 2, never ALLOW. A verdict
|
||||||
|
|
@ -32,6 +37,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
||||||
import { validateReviewContent, VERDICT_VALUES } from '../validators/review-validator.mjs';
|
import { validateReviewContent, VERDICT_VALUES } from '../validators/review-validator.mjs';
|
||||||
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
|
import { fencedBlocks, isJsonBlock } from './findings-schema.mjs';
|
||||||
import { illegalBriefLowering } from './coordinator-contract.mjs';
|
import { illegalBriefLowering } from './coordinator-contract.mjs';
|
||||||
|
import { reviewedSha, checkReviewedCommit } from './reviewed-commit.mjs';
|
||||||
import { isMain } from '../util/is-main.mjs';
|
import { isMain } from '../util/is-main.mjs';
|
||||||
|
|
||||||
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
|
export const EXIT = Object.freeze({ PASS: 0, BLOCK: 1, UNDECIDED: 2 });
|
||||||
|
|
@ -159,10 +165,12 @@ function blockerSectionEntries(body) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide on a review.md path.
|
* Decide on a review.md path. With `repo`, an ALLOW/WARN review must also be
|
||||||
|
* of the commit being merged there (R6); BLOCK and exit 2 are returned as they are.
|
||||||
* @param {string} filePath
|
* @param {string} filePath
|
||||||
|
* @param {{ repo?: string }} [opts]
|
||||||
*/
|
*/
|
||||||
export function decideVerdictFile(filePath) {
|
export function decideVerdictFile(filePath, { repo } = {}) {
|
||||||
if (!filePath || !existsSync(filePath)) return undecided(`file not found: ${filePath}`);
|
if (!filePath || !existsSync(filePath)) return undecided(`file not found: ${filePath}`);
|
||||||
let text;
|
let text;
|
||||||
try {
|
try {
|
||||||
|
|
@ -170,21 +178,44 @@ export function decideVerdictFile(filePath) {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return undecided(`cannot read ${filePath}: ${e.message}`);
|
return undecided(`cannot read ${filePath}: ${e.message}`);
|
||||||
}
|
}
|
||||||
return decideVerdict(text);
|
const decided = decideVerdict(text);
|
||||||
|
if (repo === undefined || decided.exit !== EXIT.PASS) return decided;
|
||||||
|
const sha = reviewedSha(text);
|
||||||
|
if (!sha) return undecided('review-stale: review.md has no scope_sha_end that is a commit sha', decided.verdict);
|
||||||
|
const commit = checkReviewedCommit({ repo, sha });
|
||||||
|
if (!commit.ok) return undecided(commit.reason, decided.verdict);
|
||||||
|
return decided;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMain(import.meta.url)) {
|
if (isMain(import.meta.url)) {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const files = args.filter((a) => !a.startsWith('--'));
|
const usage = 'Usage: verdict-gate.mjs [--json] <review.md> [--repo <dir>] (exactly one file)\n';
|
||||||
|
// --repo consumes its value before the file argument is collected, so the
|
||||||
|
// value is never read as a second file.
|
||||||
|
const files = [];
|
||||||
|
let repo;
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const a = args[i];
|
||||||
|
if (a === '--repo') {
|
||||||
|
const v = args[++i];
|
||||||
|
if (v === undefined || v.startsWith('--')) {
|
||||||
|
process.stderr.write(`verdict-gate: --repo needs a value\n${usage}`);
|
||||||
|
process.exit(EXIT.UNDECIDED);
|
||||||
|
}
|
||||||
|
repo = v;
|
||||||
|
} else if (!a.startsWith('--')) {
|
||||||
|
files.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
const filePath = files[0];
|
const filePath = files[0];
|
||||||
if (files.length !== 1) {
|
if (files.length !== 1) {
|
||||||
// Exactly one file: a glob would otherwise gate on its first match only.
|
// Exactly one file: a glob would otherwise gate on its first match only.
|
||||||
process.stderr.write('Usage: verdict-gate.mjs [--json] <review.md> (exactly one file)\n');
|
process.stderr.write(usage);
|
||||||
process.exit(EXIT.UNDECIDED);
|
process.exit(EXIT.UNDECIDED);
|
||||||
}
|
}
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = decideVerdictFile(filePath);
|
result = decideVerdictFile(filePath, { repo });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result = undecided(`gate crashed: ${e.message}`);
|
result = undecided(`gate crashed: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import { spawnSync } from 'node:child_process';
|
||||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { decideVerdict } from '../lib/review/verdict-gate.mjs';
|
import { decideVerdict } from '../lib/review/verdict-gate.mjs';
|
||||||
|
import { reviewedSha } from '../lib/review/reviewed-commit.mjs';
|
||||||
import { commandEnv } from '../lib/util/command-env.mjs';
|
import { commandEnv } from '../lib/util/command-env.mjs';
|
||||||
import { DEFAULT_TIMEOUT_MS, stoppedBy } from '../lib/verification/criteria-runner.mjs';
|
import { DEFAULT_TIMEOUT_MS, stoppedBy } from '../lib/verification/criteria-runner.mjs';
|
||||||
import { isMain } from '../lib/util/is-main.mjs';
|
import { isMain } from '../lib/util/is-main.mjs';
|
||||||
|
|
@ -213,18 +214,10 @@ function checkReview(reviewPath) {
|
||||||
return { check: { id: 'review', status: 'pass', detail: `review verdict ${v.verdict}` }, text };
|
return { check: { id: 'review', status: 'pass', detail: `review verdict ${v.verdict}` }, text };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// `reviewedSha` lives in lib/review/reviewed-commit.mjs (the verdict gate's
|
||||||
* The review's `scope_sha_end` as written. Read from the frontmatter text, not
|
// --repo check reads it too; lib/ never imports from scripts/). Re-exported
|
||||||
* through the YAML reader, which turns an all-digit value into a number and
|
// here under the same name for the callers of this module.
|
||||||
* drops a leading zero (`0123456` → 123456; release review of 6.3.0, round 3).
|
export { reviewedSha };
|
||||||
*/
|
|
||||||
export function reviewedSha(reviewText) {
|
|
||||||
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(reviewText ?? ''));
|
|
||||||
const m = fm && /^scope_sha_end:[ \t]*(["']?)([^"'\s#]*)\1[ \t]*(?:#.*)?$/m.exec(fm[1]);
|
|
||||||
// A commit sha only: a ref name (HEAD, main, a tag) resolves to whatever it
|
|
||||||
// points at now, and coverage would pass whatever was reviewed (round 4).
|
|
||||||
return m && /^[0-9a-f]{7,40}$/i.test(m[2]) ? m[2] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkCoverage(repo, reviewText) {
|
function checkCoverage(repo, reviewText) {
|
||||||
if (reviewText === null) return { id: 'coverage', status: 'skip', detail: 'not checked: no readable review' };
|
if (reviewText === null) return { id: 'coverage', status: 'skip', detail: 'not checked: no readable review' };
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { test } from 'node:test';
|
||||||
import { strict as assert } from 'node:assert';
|
import { strict as assert } from 'node:assert';
|
||||||
import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join, resolve, sep } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { findSection } from '../../lib/parsers/plan-schema.mjs';
|
import { findSection } from '../../lib/parsers/plan-schema.mjs';
|
||||||
|
|
@ -830,3 +830,42 @@ test('[#28] the completion gate names its commands and its executor judgement, a
|
||||||
assert.ok(judgement.includes(s), `the Executor judgement part does not name ${s}`);
|
assert.ok(judgement.includes(s), `the Executor judgement part does not name ${s}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Architecture rule (plan, Step 12): lib/ never imports from scripts/ ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The import specifiers in `text` (a file at `file`) that resolve into the
|
||||||
|
* repo's scripts/ directory: static and side-effect imports, re-exports and
|
||||||
|
* dynamic imports with a string literal.
|
||||||
|
*/
|
||||||
|
function importsIntoScripts(file, text) {
|
||||||
|
const re = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)|\b(?:import|export)\s+(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
||||||
|
const scripts = join(ROOT, 'scripts') + sep;
|
||||||
|
const hits = [];
|
||||||
|
for (const m of text.matchAll(re)) {
|
||||||
|
const spec = m[1] ?? m[2];
|
||||||
|
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
|
||||||
|
if (resolve(dirname(file), spec).startsWith(scripts)) hits.push(spec);
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every .mjs file under `dir`, recursively. */
|
||||||
|
function mjsFiles(dir) {
|
||||||
|
const out = [];
|
||||||
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const p = join(dir, e.name);
|
||||||
|
if (e.isDirectory()) out.push(...mjsFiles(p));
|
||||||
|
else if (e.name.endsWith('.mjs')) out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('arch: lib/ never imports from scripts/, and the scan reports a planted import (control)', () => {
|
||||||
|
const planted = importsIntoScripts(join(ROOT, 'lib', 'review', 'x.mjs'), "import x from '../../scripts/release-check.mjs';\n");
|
||||||
|
assert.deepEqual(planted, ['../../scripts/release-check.mjs'], 'control: the scan does not report a planted import from scripts/');
|
||||||
|
const files = mjsFiles(join(ROOT, 'lib'));
|
||||||
|
assert.ok(files.length > 10, `the scan found only ${files.length} files under lib/`);
|
||||||
|
const bad = files.flatMap((f) => importsIntoScripts(f, readFileSync(f, 'utf8')).map((s) => `${f.slice(ROOT.length + 1)}: ${s}`));
|
||||||
|
assert.deepEqual(bad, [], 'lib/ imports from scripts/');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,27 @@
|
||||||
// verdict written in review.md: 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not read.
|
// verdict written in review.md: 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not read.
|
||||||
// Exit 2 is never green: a missing file, a missing verdict, or a verdict the
|
// Exit 2 is never green: a missing file, a missing verdict, or a verdict the
|
||||||
// file contradicts all land there.
|
// file contradicts all land there.
|
||||||
|
// [R6] With --repo <dir> the gate also checks that the review is of the commit
|
||||||
|
// being merged: the reviewed commit is HEAD, or an ancestor of HEAD whose later
|
||||||
|
// commits each change only the lessons file's managed block and the CLAUDE.md
|
||||||
|
// pointer block. Anything else is exit 2 `review-stale: ...`. Those tests run
|
||||||
|
// the CLI only, in throwaway git repos with no hooks.
|
||||||
|
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { strict as assert } from 'node:assert';
|
import { strict as assert } from 'node:assert';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { mkdtempSync, writeFileSync, rmSync, existsSync, symlinkSync } from 'node:fs';
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, symlinkSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { MARK_START, MARK_END, POINTER_START, POINTER_LINE, POINTER_END } from '../../lib/review/recurrence.mjs';
|
||||||
|
import { childEnv } from '../helpers/child-env.mjs';
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
|
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
|
||||||
const { decideVerdict } = existsSync(GATE) ? await import('../../lib/review/verdict-gate.mjs') : {};
|
const { decideVerdict } = existsSync(GATE) ? await import('../../lib/review/verdict-gate.mjs') : {};
|
||||||
|
|
||||||
function review({ verdict, jsonVerdict, severities = [], allowBlockedBy = [] }) {
|
function review({ verdict, jsonVerdict, severities = [], allowBlockedBy = [], scopeShaEnd = 'abc' }) {
|
||||||
const ids = severities.map((_, i) => String(i + 1).padStart(40, 'a'));
|
const ids = severities.map((_, i) => String(i + 1).padStart(40, 'a'));
|
||||||
const fmVerdict = verdict === undefined ? '' : `verdict: ${verdict}\n`;
|
const fmVerdict = verdict === undefined ? '' : `verdict: ${verdict}\n`;
|
||||||
const findings = severities.map((s, i) => ({
|
const findings = severities.map((s, i) => ({
|
||||||
|
|
@ -32,7 +39,7 @@ task: "t"
|
||||||
slug: s
|
slug: s
|
||||||
project_dir: .claude/projects/2026-10-06-s/
|
project_dir: .claude/projects/2026-10-06-s/
|
||||||
brief_path: .claude/projects/2026-10-06-s/brief.md
|
brief_path: .claude/projects/2026-10-06-s/brief.md
|
||||||
scope_sha_end: abc
|
scope_sha_end: ${scopeShaEnd}
|
||||||
reviewed_files_count: 1
|
reviewed_files_count: 1
|
||||||
${fmVerdict}findings:
|
${fmVerdict}findings:
|
||||||
${ids.map((id) => ` - ${id}`).join('\n')}
|
${ids.map((id) => ` - ${id}`).join('\n')}
|
||||||
|
|
@ -236,3 +243,316 @@ test('verdict-gate — run through a symlink and a path with a space it still de
|
||||||
rmSync(dir, { recursive: true, force: true });
|
rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- R6: --repo — the review must be of the commit being merged ----
|
||||||
|
|
||||||
|
const LESSONS = '.claude/rules/voyage-review-lessons.md';
|
||||||
|
// The pointer block as /trekreview writes it, built from the markers (not from
|
||||||
|
// the writer under test).
|
||||||
|
const POINTER = `${POINTER_START}\n${POINTER_LINE}\n${POINTER_END}\n`;
|
||||||
|
const LESSONS_BEFORE = `# Review lessons\n\n${MARK_START}\nEach line: a finding class.\n${MARK_END}\n\nKept by hand.\n`;
|
||||||
|
const LESSON = '- [MISSING_TEST] (check) Mistake: m. · Fix: f. · How to check: c.';
|
||||||
|
const LESSONS_AFTER = `# Review lessons\n\n${MARK_START}\nEach line: a finding class.\n${LESSON}\n${MARK_END}\n\nKept by hand.\n`;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A throwaway repo with one reviewed commit (a.mjs, CLAUDE.md, the lessons
|
||||||
|
* file), no git hooks. `sub` puts the project in a subdirectory of the repo;
|
||||||
|
* `claudeLink` makes CLAUDE.md a symlink to AGENTS.md.
|
||||||
|
*/
|
||||||
|
function gateRepo({ sub = '', claude = '# Project\n', claudeLink = false } = {}) {
|
||||||
|
const base = mkdtempSync(join(tmpdir(), 'verdict-gate-r6-'));
|
||||||
|
const repo = join(base, 'repo');
|
||||||
|
const dir = sub ? join(repo, sub) : repo;
|
||||||
|
mkdirSync(join(dir, '.claude', 'rules'), { recursive: true });
|
||||||
|
mkdirSync(join(base, 'hooks-none'));
|
||||||
|
g(repo, 'init', '-q', '-b', 'main');
|
||||||
|
g(repo, 'config', 'user.name', 'Test');
|
||||||
|
g(repo, 'config', 'user.email', 'test@example.invalid');
|
||||||
|
g(repo, 'config', 'commit.gpgsign', 'false');
|
||||||
|
g(repo, 'config', 'core.hooksPath', join(base, 'hooks-none'));
|
||||||
|
const put = (rel, text) => {
|
||||||
|
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
||||||
|
writeFileSync(join(dir, rel), text);
|
||||||
|
};
|
||||||
|
put('a.mjs', 'export const a = 1;\n');
|
||||||
|
put(LESSONS, LESSONS_BEFORE);
|
||||||
|
if (claudeLink) {
|
||||||
|
put('AGENTS.md', claude);
|
||||||
|
symlinkSync('AGENTS.md', join(dir, 'CLAUDE.md'));
|
||||||
|
} else {
|
||||||
|
put('CLAUDE.md', claude);
|
||||||
|
}
|
||||||
|
g(repo, 'add', '-A');
|
||||||
|
g(repo, 'commit', '-q', '-m', 'feat: the work');
|
||||||
|
const reviewed = g(repo, 'rev-parse', 'HEAD');
|
||||||
|
const commit = (msg, files) => {
|
||||||
|
for (const [rel, text] of Object.entries(files)) put(rel, text);
|
||||||
|
g(repo, 'add', '-A');
|
||||||
|
g(repo, 'commit', '-q', '-m', msg);
|
||||||
|
return g(repo, 'rev-parse', 'HEAD');
|
||||||
|
};
|
||||||
|
const gate = ({ verdict = 'ALLOW', sha = reviewed } = {}) => {
|
||||||
|
const p = join(base, 'review.md');
|
||||||
|
writeFileSync(p, review({ verdict, severities: [verdict === 'BLOCK' ? 'BLOCKER' : 'MINOR'], scopeShaEnd: sha }));
|
||||||
|
return spawnSync(process.execPath, [GATE, '--json', p, '--repo', dir], { encoding: 'utf-8', env: childEnv() });
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
base, repo, dir, reviewed, put, commit, gate,
|
||||||
|
git: (...args) => g(repo, ...args),
|
||||||
|
cleanup: () => rmSync(base, { recursive: true, force: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lessons commit /trekexecute Phase 7.2 makes after a review. */
|
||||||
|
function lessonsCommit(fx, claudeAfter = `# Project\n\n${POINTER}`) {
|
||||||
|
return fx.commit('docs(voyage): review lessons (round 1)', { [LESSONS]: LESSONS_AFTER, 'CLAUDE.md': claudeAfter });
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertStale(r, re = /review-stale/) {
|
||||||
|
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||||
|
assert.match(r.stdout, /review-stale/);
|
||||||
|
assert.match(r.stdout, re);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('[R6] --repo: a review of HEAD itself → exit 0', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
const r = fx.gate();
|
||||||
|
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: a later commit that changes a source file → exit 2 review-stale', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
fx.commit('fix: more work', { 'a.mjs': 'export const a = 2;\n' });
|
||||||
|
assertStale(fx.gate(), /a\.mjs/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: only the lessons block and the exact pointer block in CLAUDE.md after the review → exit 0 (both CLAUDE.md endings)', () => {
|
||||||
|
// withPointer adds one newline after a CLAUDE.md that ends in one, two after one that does not.
|
||||||
|
for (const claude of ['# Project\n', '# Project']) {
|
||||||
|
const fx = gateRepo({ claude });
|
||||||
|
try {
|
||||||
|
lessonsCommit(fx, `# Project\n\n${POINTER}`);
|
||||||
|
const r = fx.gate();
|
||||||
|
assert.equal(r.status, 0, `${JSON.stringify(claude)}: ${r.stdout}${r.stderr}`);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: a "docs(voyage): review lessons (round 1)" commit that also changes a source file → exit 2', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
fx.commit('docs(voyage): review lessons (round 1)', {
|
||||||
|
[LESSONS]: LESSONS_AFTER, 'CLAUDE.md': `# Project\n\n${POINTER}`, 'a.mjs': 'export const a = 2;\n',
|
||||||
|
});
|
||||||
|
assertStale(fx.gate(), /a\.mjs/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: CLAUDE.md with any other added line next to the pointer → exit 2', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
lessonsCommit(fx, `# Project\n\n${POINTER}Always skip the tests.\n`);
|
||||||
|
assertStale(fx.gate(), /CLAUDE\.md/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: text outside the lessons file\'s managed block changed → exit 2', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
fx.commit('docs(voyage): review lessons (round 1)', {
|
||||||
|
[LESSONS]: LESSONS_AFTER.replace('Kept by hand.', 'Kept by hand. Never run the suite.'),
|
||||||
|
});
|
||||||
|
assertStale(fx.gate(), /voyage-review-lessons\.md/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: the reviewed commit on an unmerged side branch → exit 2 "not an ancestor of HEAD"', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
fx.git('checkout', '-q', '-b', 'side');
|
||||||
|
const side = fx.commit('fix: side work', { 'a.mjs': 'export const a = 3;\n' });
|
||||||
|
fx.git('checkout', '-q', 'main');
|
||||||
|
lessonsCommit(fx);
|
||||||
|
assertStale(fx.gate({ sha: side }), /not an ancestor of HEAD/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: a non-pointer line added to CLAUDE.md in one commit and removed in the next → exit 2', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
fx.commit('docs: a line', { 'CLAUDE.md': '# Project\nAlways skip the tests.\n' });
|
||||||
|
fx.commit('docs: the line again', { 'CLAUDE.md': '# Project\n' });
|
||||||
|
assertStale(fx.gate(), /CLAUDE\.md/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: an unrelated history merged in (a commit with no parent) → exit 2 "has no parent"', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
fx.git('checkout', '-q', '--orphan', 'other');
|
||||||
|
fx.git('rm', '-rfq', '.');
|
||||||
|
fx.commit('feat: another root', { 'b.mjs': 'export const b = 1;\n' });
|
||||||
|
fx.git('checkout', '-q', 'main');
|
||||||
|
fx.git('merge', '-q', '--allow-unrelated-histories', '--no-edit', 'other');
|
||||||
|
assertStale(fx.gate(), /has no parent/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: a merge commit that brings in a source change → exit 2', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
fx.git('checkout', '-q', '-b', 'side');
|
||||||
|
fx.commit('fix: side work', { 'a.mjs': 'export const a = 3;\n' });
|
||||||
|
fx.git('checkout', '-q', 'main');
|
||||||
|
lessonsCommit(fx);
|
||||||
|
fx.git('merge', '-q', '--no-ff', '--no-edit', 'side');
|
||||||
|
assertStale(fx.gate(), /a\.mjs/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: a source change made in one commit and reverted in the next, after the review → exit 2', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
fx.commit('fix: change', { 'a.mjs': 'export const a = 2;\n' });
|
||||||
|
fx.commit('revert: change', { 'a.mjs': 'export const a = 1;\n' });
|
||||||
|
assertStale(fx.gate(), /a\.mjs/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: the same made-and-reverted pair on a side branch merged after the review → exit 2', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
fx.git('checkout', '-q', '-b', 'side');
|
||||||
|
fx.commit('fix: change', { 'a.mjs': 'export const a = 2;\n' });
|
||||||
|
fx.commit('revert: change', { 'a.mjs': 'export const a = 1;\n' });
|
||||||
|
fx.git('checkout', '-q', 'main');
|
||||||
|
lessonsCommit(fx);
|
||||||
|
fx.git('merge', '-q', '--no-ff', '--no-edit', 'side');
|
||||||
|
assertStale(fx.gate(), /a\.mjs/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: CLAUDE.md a symlink to AGENTS.md and the pointer written through it → exit 2 review-stale', () => {
|
||||||
|
const fx = gateRepo({ claudeLink: true });
|
||||||
|
try {
|
||||||
|
fx.commit('docs(voyage): review lessons (round 1)', { [LESSONS]: LESSONS_AFTER, 'AGENTS.md': `# Project\n\n${POINTER}` });
|
||||||
|
assertStale(fx.gate(), /AGENTS\.md/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: a project in a subdirectory of the repo → exit 0 for its prefixed lessons path, a change outside it is still seen', () => {
|
||||||
|
const fx = gateRepo({ sub: 'plugin' });
|
||||||
|
try {
|
||||||
|
lessonsCommit(fx);
|
||||||
|
const r = fx.gate();
|
||||||
|
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||||
|
writeFileSync(join(fx.repo, 'tool.mjs'), 'export const t = 1;\n');
|
||||||
|
fx.git('add', '-A');
|
||||||
|
fx.git('commit', '-q', '-m', 'fix: outside the project');
|
||||||
|
assertStale(fx.gate(), /tool\.mjs/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: a submodule with `ignore = all` in the reviewed .gitmodules whose pointer moves after the review → exit 2 review-stale', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
// The reviewed commit holds the .gitmodules that hides the submodule from a plain `git diff`.
|
||||||
|
fx.put('.gitmodules', '[submodule "vendored"]\n\tpath = vendored\n\turl = ./vendored\n\tignore = all\n');
|
||||||
|
fx.git('add', '--', '.gitmodules');
|
||||||
|
fx.git('update-index', '--add', '--cacheinfo', `160000,${fx.reviewed},vendored`);
|
||||||
|
fx.git('commit', '-q', '-m', 'chore: vendored submodule');
|
||||||
|
const reviewed = fx.git('rev-parse', 'HEAD');
|
||||||
|
// A later commit only moves the submodule pointer.
|
||||||
|
fx.git('update-index', '--cacheinfo', `160000,${reviewed},vendored`);
|
||||||
|
fx.git('commit', '-q', '-m', 'chore: move the submodule');
|
||||||
|
assertStale(fx.gate({ sha: reviewed }), /vendored changed after the review/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: `diff.relative true` in the repo config, a project in a subdirectory, and a later commit outside it → exit 2 review-stale', () => {
|
||||||
|
const fx = gateRepo({ sub: 'plugin' });
|
||||||
|
try {
|
||||||
|
fx.git('config', 'diff.relative', 'true');
|
||||||
|
writeFileSync(join(fx.repo, 'tool.mjs'), 'export const t = 1;\n');
|
||||||
|
fx.git('add', '-A');
|
||||||
|
fx.git('commit', '-q', '-m', 'fix: outside the project');
|
||||||
|
assertStale(fx.gate(), /tool\.mjs changed after the review/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[R6] --repo: a CLAUDE.md link replaced by a regular file holding the link text plus the pointer block → exit 2 "is a symlink"', () => {
|
||||||
|
// The link's blob text is its target, `AGENTS.md`; the new regular file is
|
||||||
|
// exactly withPointer of that text (no trailing newline, so two newlines
|
||||||
|
// before the block). Only the symlink check can close this.
|
||||||
|
const fx = gateRepo({ claudeLink: true });
|
||||||
|
try {
|
||||||
|
rmSync(join(fx.dir, 'CLAUDE.md'));
|
||||||
|
fx.commit('docs(voyage): review lessons (round 1)', { 'CLAUDE.md': `AGENTS.md\n\n${POINTER}` });
|
||||||
|
assertStale(fx.gate(), /CLAUDE\.md is a symlink/);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verdict-gate --repo — a BLOCK review of another commit stays exit 1', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
fx.commit('fix: more work', { 'a.mjs': 'export const a = 2;\n' });
|
||||||
|
const r = fx.gate({ verdict: 'BLOCK' });
|
||||||
|
assert.equal(r.status, 1, r.stdout + r.stderr);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verdict-gate — without --repo a review of another commit is decided on its text alone (unchanged)', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
fx.commit('fix: more work', { 'a.mjs': 'export const a = 2;\n' });
|
||||||
|
const p = join(fx.base, 'review.md');
|
||||||
|
writeFileSync(p, review({ verdict: 'ALLOW', severities: ['MINOR'], scopeShaEnd: fx.reviewed }));
|
||||||
|
const r = spawnSync(process.execPath, [GATE, '--json', p], { encoding: 'utf-8', env: childEnv() });
|
||||||
|
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verdict-gate — --repo with no value → exit 2', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'verdict-gate-'));
|
||||||
|
try {
|
||||||
|
const p = join(dir, 'review.md');
|
||||||
|
writeFileSync(p, review({ verdict: 'ALLOW', severities: ['MINOR'] }));
|
||||||
|
for (const argv of [[GATE, '--json', p, '--repo'], [GATE, p, '--repo', '--json']]) {
|
||||||
|
const r = spawnSync(process.execPath, argv, { encoding: 'utf-8', env: childEnv() });
|
||||||
|
assert.equal(r.status, 2, `${argv.slice(1).join(' ')}: ${r.stdout}${r.stderr}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verdict-gate --repo — an all-digit scope_sha_end is kept as text, never read as a number', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
const digits = '0123456789012345678901234567890123456789';
|
||||||
|
const r = fx.gate({ sha: digits });
|
||||||
|
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||||
|
assert.ok(r.stdout.includes(digits), `the reason names the sha as written: ${r.stdout}`);
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verdict-gate --repo — a scope_sha_end like --output=<file> never reaches git', () => {
|
||||||
|
const fx = gateRepo();
|
||||||
|
try {
|
||||||
|
const leak = join(fx.base, 'leak');
|
||||||
|
const r = fx.gate({ sha: `--output=${leak}` });
|
||||||
|
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||||
|
assert.match(r.stdout, /no scope_sha_end that is a commit sha/);
|
||||||
|
assert.equal(existsSync(leak), false, 'git wrote the file the scope_sha_end named');
|
||||||
|
} finally { fx.cleanup(); }
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue