fix(verdict-gate): a failed git call is reported as not measured, not stale

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-26 14:00:42 +01:00
commit 77544841d7
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
7 changed files with 233 additions and 13 deletions

View file

@ -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:822-855 | 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:2969-2991 | 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:2972-2994 | 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:640-690 | profile, phase_models, profile_source | none |

View file

@ -0,0 +1,103 @@
// tests/lib/fix-round-4-5b-prose.test.mjs
// Prose pins for fix round 4-5b (plan
// .claude/projects/2026-10-25-fix-round-4-5b-findings): the fixes that are
// text a session or a helper reads. The tags in brackets are 4.5b ids from the
// round's brief (for example `[M4]`, `[M11]`); each tagged test fails on an
// assertion at be0b631, the round's start point. A sibling's test is tagged
// `[<parent id>] sibling:`.
// M4 A git call the verdict gate cannot complete is reported as
// `review-not-measured:`, never as a stale review: Handover 6 names the
// prefix and lists review_version 1.1 as current, and /trekexecute's
// main-merge gate maps it to "not measured" with the merge locked.
// M11 Handover 6 states that only a full 40- or 64-hex reviewed commit is read.
// No static import of a new export: the file must load at be0b631.
// Fix the SOURCE a pin guards, not the pin.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const readCache = new Map();
/** A repo file's text; the file must exist. Read from disk once per run. */
function read(rel) {
if (!readCache.has(rel)) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
}
return readCache.get(rel);
}
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/** The text from the line holding `start` up to (not including) the next line holding `stop`; both must exist. */
function between(rel, start, stop) {
const text = read(rel);
const s = text.indexOf(start);
assert.ok(s !== -1, `${rel} must hold "${start}"`);
const e = text.indexOf(stop, s + start.length);
assert.ok(e !== -1, `${rel} must hold "${stop}" after "${start}"`);
return text.slice(s, e);
}
/** Handover 6 of the handover contracts, up to Handover 7. */
const handover6 = () => between('docs/HANDOVER-CONTRACTS.md', '## Handover 6 ', '## Handover 7 ');
/** The one table row of Handover 6 whose first cell is `field`. */
function fieldRow(field) {
const rows = handover6().split('\n').filter((l) => l.startsWith(`| \`${field}\` |`));
assert.equal(rows.length, 1, `Handover 6 must hold exactly one \`${field}\` row; found ${rows.length}`);
return rows[0];
}
/** The verdict-gate paragraph of Handover 6 (the "Second consumer" one), flattened. */
function gateParagraph() {
const para = handover6().split('\n').filter((l) => l.startsWith('**Second consumer — the verdict gate'));
assert.equal(para.length, 1, `Handover 6 must hold one verdict-gate paragraph; found ${para.length}`);
return flat(para[0]);
}
/** Every sentence of `text` (flattened) that holds `needle`. */
function sentencesWith(text, needle) {
return flat(text).split(/(?<=\.)\s+(?=[A-Z`"*(])/).filter((s) => s.includes(needle));
}
// --- M4 ----------------------------------------------------------------------------
test('[M4] Handover 6 names the review-not-measured reason', () => {
const hits = sentencesWith(gateParagraph(), '`review-not-measured:`');
assert.ok(hits.length > 0, 'the verdict-gate paragraph of Handover 6 does not name the `review-not-measured:` reason prefix');
assert.ok(hits.some((s) => /\bgit\b/.test(s) && /never green|not measured/.test(s)),
`no sentence ties \`review-not-measured:\` to a failed git call read as not measured: ${hits.join(' | ')}`);
});
test('[M4] Handover 6 lists review_version 1.1 as current', () => {
const row = fieldRow('review_version');
assert.match(row, /"1\.1"`? \(current\)/, `the review_version row does not list 1.1 as current: ${row}`);
assert.doesNotMatch(row, /"1\.0"`? \(current\)/, `the review_version row still lists 1.0 as current: ${row}`);
});
test('[M4] the main merge gate maps review-not-measured to not measured', () => {
const gate = between('commands/trekexecute.md', '**1. The verdict.**', '**2. Open:');
const hits = sentencesWith(gate, '`review-not-measured:`');
assert.ok(hits.length > 0, 'the main-merge gate (Phase 8, 1. The verdict) does not name the `review-not-measured:` reason prefix');
const ok = hits.find((s) => {
const rest = s.split('`review-not-measured:`').join('');
return /"not measured"|\bnot measured\b/.test(rest) && /never green/.test(rest) && /merge stays (locked|closed)/.test(rest);
});
assert.ok(ok, `no sentence says a \`review-not-measured:\` reason is reported as "not measured", never green, with the merge locked: ${hits.join(' | ')}`);
});
// --- M11 ---------------------------------------------------------------------------
test('[M11] Handover 6 states the full 40- or 64-hex reviewed commit', () => {
const row = fieldRow('scope_sha_end');
assert.match(row, /full 40- or 64-hex/, `the scope_sha_end row does not state the full 40- or 64-hex commit id: ${row}`);
assert.match(gateParagraph(), /`scope_sha_end`[^.]*full 40- or 64-hex/,
'the verdict-gate paragraph of Handover 6 does not state that only a full 40- or 64-hex scope_sha_end is read');
});

View file

@ -8,16 +8,20 @@
// 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.
// [M4] A git call that fails, a --repo path that is missing and a --repo that
// is no repository are exit 2 `review-not-measured: ...`, never review-stale:
// the gate could not look, so it cannot say the review is of another commit.
import { after, test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, symlinkSync, appendFileSync, cpSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { join, dirname, delimiter } from 'node:path';
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';
import { pathWith } from '../helpers/bin-path.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
@ -690,3 +694,86 @@ test('[M6] --json is accepted while --jsonx is refused', () => {
assert.match(bad.stderr, /unknown option --jsonx/);
});
});
// ---- M4: a git that fails, or a --repo it cannot look in, is not measured ----
/**
* A PATH whose first `git` fails the subcommand `sub` (exit 128, one stderr
* line) and runs the real git for every other one. The real git is reached
* through bin-path.mjs's pass-through wrapper, so this file never guesses
* where git lives.
*/
function failingGit(sub) {
const real = pathWith(['git']);
const dir = mkdtempSync(join(tmpdir(), 'verdict-gate-m4-git-'));
writeFileSync(join(dir, 'git'),
`#!/bin/sh\nif [ "$1" = "${sub}" ]; then echo "fatal: fake git: ${sub} fails" >&2; exit 128; fi\nexec '${join(real.path, 'git')}' "$@"\n`,
{ mode: 0o755 });
return {
path: `${dir}${delimiter}${process.env.PATH ?? ''}`,
cleanup: () => { real.cleanup(); rmSync(dir, { recursive: true, force: true }); },
};
}
/** The gate on an ALLOW review of the fixture's reviewed commit, with `env` and `repo` given. */
function gateWith(fx, { env = {}, repo = fx.dir } = {}) {
const p = join(fx.base, 'review.md');
writeFileSync(p, review({ verdict: 'ALLOW', severities: ['MINOR'], scopeShaEnd: fx.reviewed }));
return spawnSync(process.execPath, [GATE, '--json', p, '--repo', repo], { encoding: 'utf-8', env: childEnv(env) });
}
function assertNotMeasured(r, re) {
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.doesNotMatch(r.stdout, /review-stale/);
const { reason } = JSON.parse(r.stdout);
assert.ok(String(reason).startsWith('review-not-measured:'), `the reason must start review-not-measured: ${reason}`);
if (re) assert.match(reason, re);
}
test('[M4] a git that cannot run rev-parse is not measured', () => {
const fx = gateRepo();
const git = failingGit('rev-parse');
try {
assertNotMeasured(gateWith(fx, { env: { PATH: git.path } }), /rev-parse/);
} finally { git.cleanup(); fx.cleanup(); }
});
test('[M4] a git that fails rev-list after the commit resolves is not measured', () => {
const fx = gateRepo();
const git = failingGit('rev-list');
try {
lessonsCommit(fx);
// Control: with the real git the same review opens the gate, so the run below gets past rev-parse.
const control = gateWith(fx);
assert.equal(control.status, 0, control.stdout + control.stderr);
assertNotMeasured(gateWith(fx, { env: { PATH: git.path } }), /rev-list/);
} finally { git.cleanup(); fx.cleanup(); }
});
test('[M4] a git that fails diff after the commit resolves is not measured', () => {
const fx = gateRepo();
const git = failingGit('diff');
try {
lessonsCommit(fx);
const control = gateWith(fx);
assert.equal(control.status, 0, control.stdout + control.stderr);
assertNotMeasured(gateWith(fx, { env: { PATH: git.path } }), /diff/);
} finally { git.cleanup(); fx.cleanup(); }
});
test('[M4] --repo with a missing path is not measured', () => {
const fx = gateRepo();
try {
assertNotMeasured(gateWith(fx, { repo: join(fx.base, 'no-such-dir') }), /no-such-dir/);
} finally { fx.cleanup(); }
});
test('[M4] --repo that is no repository is not measured', () => {
const fx = gateRepo();
try {
const plain = join(fx.base, 'plain');
mkdirSync(plain);
// The ceiling stops git from finding a repository above the fixture.
assertNotMeasured(gateWith(fx, { repo: plain, env: { GIT_CEILING_DIRECTORIES: fx.base } }), /not a git repository/);
} finally { fx.cleanup(); }
});