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:
parent
703c508433
commit
77544841d7
7 changed files with 233 additions and 13 deletions
2
tests/fixtures/jsonl-schemas.md
vendored
2
tests/fixtures/jsonl-schemas.md
vendored
|
|
@ -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 |
|
||||
|
|
|
|||
103
tests/lib/fix-round-4-5b-prose.test.mjs
Normal file
103
tests/lib/fix-round-4-5b-prose.test.mjs
Normal 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');
|
||||
});
|
||||
|
|
@ -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(); }
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue