@rigour-labs/core 6.7.6 → 6.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.js +1 -0
  3. package/dist/review/backtest-init.d.ts +15 -0
  4. package/dist/review/backtest-init.js +30 -16
  5. package/dist/review/backtest-last.d.ts +40 -0
  6. package/dist/review/backtest-last.js +116 -0
  7. package/dist/review/backtest-last.test.d.ts +1 -0
  8. package/dist/review/backtest-last.test.js +109 -0
  9. package/dist/review/backtest.d.ts +26 -0
  10. package/dist/review/backtest.js +4 -4
  11. package/dist/review/reviewer/adapters.d.ts +11 -4
  12. package/dist/review/reviewer/adapters.js +44 -5
  13. package/dist/review/reviewer/adapters.test.js +16 -1
  14. package/dist/review/reviewer/api-judge.d.ts +28 -0
  15. package/dist/review/reviewer/api-judge.js +161 -0
  16. package/dist/review/reviewer/api-judge.test.d.ts +1 -0
  17. package/dist/review/reviewer/api-judge.test.js +87 -0
  18. package/dist/review/reviewer/context.d.ts +2 -0
  19. package/dist/review/reviewer/context.js +4 -1
  20. package/dist/review/reviewer/settings.d.ts +10 -0
  21. package/dist/review/reviewer/settings.js +3 -1
  22. package/dist/review/reviewer/verdict.js +8 -3
  23. package/dist/review/reviewer.d.ts +2 -0
  24. package/dist/review/reviewer.js +17 -5
  25. package/dist/review/reviewer.test.js +35 -3
  26. package/dist/review-learning/lessons.d.ts +2 -0
  27. package/dist/review-learning/lessons.js +17 -1
  28. package/dist/review-learning/repo-rules.test.js +3 -0
  29. package/dist/review-learning/review-learning.test.js +9 -0
  30. package/dist/review-learning/team-lessons.d.ts +2 -2
  31. package/dist/review-learning/team-lessons.js +3 -3
  32. package/dist/templates/universal-config.js +1 -0
  33. package/dist/types/index.d.ts +74 -0
  34. package/dist/types/index.js +14 -0
  35. package/package.json +6 -6
package/dist/index.d.ts CHANGED
@@ -39,6 +39,7 @@ export { routeFiles, type RouterPolicy, type RouterStats } from './deep/router.j
39
39
  export { appendDeepRun, readDeepRuns, summarizeDeepRuns, type DeepRun, type DeepRunSummary } from './review/deep-runs.js';
40
40
  export { learnFromReviews, type LearnFromReviewsOptions, type LearnFromReviewsResult } from './review-learning/learn-from-reviews.js';
41
41
  export { ruleWriterFor } from './review/reviewer/rule-writer.js';
42
+ export { backtestLast, formatLast, scoreLast, LAST_LEDGER_PATH, type LastReport } from './review/backtest-last.js';
42
43
  export { buildRecord, recordLines, recordIntact, type ReviewRecord } from './review/reviewer/record.js';
43
44
  export { readLessons, writeLessons, decideLesson, lessonState, matchLessons, lessonText, lessonsPath, type ReviewLesson, type LessonEvidence } from './review-learning/lessons.js';
44
45
  export { lessonsForDiff, lessonsSection, type LessonMode } from './review-learning/team-lessons.js';
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ export { routeFiles } from './deep/router.js';
39
39
  export { appendDeepRun, readDeepRuns, summarizeDeepRuns } from './review/deep-runs.js';
40
40
  export { learnFromReviews } from './review-learning/learn-from-reviews.js';
41
41
  export { ruleWriterFor } from './review/reviewer/rule-writer.js';
42
+ export { backtestLast, formatLast, scoreLast, LAST_LEDGER_PATH } from './review/backtest-last.js';
42
43
  export { buildRecord, recordLines, recordIntact } from './review/reviewer/record.js';
43
44
  export { readLessons, writeLessons, decideLesson, lessonState, matchLessons, lessonText, lessonsPath } from './review-learning/lessons.js';
44
45
  export { lessonsForDiff, lessonsSection } from './review-learning/team-lessons.js';
@@ -1,6 +1,21 @@
1
1
  import type { Config } from '../types/index.js';
2
2
  import { type LedgerRound } from './backtest.js';
3
3
  import { type Exec } from './reviewer.js';
4
+ export interface PrRounds {
5
+ /** One round per review by a person, oldest first. */
6
+ rounds: LedgerRound[];
7
+ /** When asked: the head a person approved, with no points, so a block on it is a false block. */
8
+ approved?: LedgerRound;
9
+ }
10
+ /** The ledger rounds a pull request's human reviews give, and the commit a person approved. */
11
+ export declare function roundsForPr(cwd: string, pr: number, config: Config, exec?: Exec, options?: {
12
+ approvedHead?: boolean;
13
+ }): Promise<PrRounds>;
14
+ /** The last `n` merged pull requests, newest merge first. */
15
+ export declare function mergedPrs(cwd: string, n: number, config: Config, exec?: Exec): Promise<Array<{
16
+ number: number;
17
+ mergedAt: string;
18
+ }>>;
4
19
  export declare function scaffoldLedger(cwd: string, pr: number, config: Config, exec?: Exec): Promise<{
5
20
  file: string;
6
21
  rounds: LedgerRound[];
@@ -12,9 +12,8 @@ import { bodyPoints, firstLine } from '../review-learning/review-points.js';
12
12
  import { LEDGER_PATH, LedgerSchema } from './backtest.js';
13
13
  import { defaultExec, githubEnv, parseJsonArrays } from './reviewer.js';
14
14
  const GH_TIMEOUT_MS = 60_000;
15
- /** Lines either side of an inline comment that a finding for the same point may land on. */
16
- const LINE_SLACK = 10;
17
- export async function scaffoldLedger(cwd, pr, config, exec = defaultExec) {
15
+ /** The ledger rounds a pull request's human reviews give, and the commit a person approved. */
16
+ export async function roundsForPr(cwd, pr, config, exec = defaultExec, options = {}) {
18
17
  const env = await githubEnv(cwd, config.review?.github_account ?? process.env.RIGOUR_GITHUB_ACCOUNT, exec);
19
18
  const gh = (args) => exec('gh', args, { cwd, timeoutMs: GH_TIMEOUT_MS, env });
20
19
  const view = await gh(['pr', 'view', String(pr), '--json', 'author', '-q', '.author.login']);
@@ -24,10 +23,8 @@ export async function scaffoldLedger(cwd, pr, config, exec = defaultExec) {
24
23
  const reviews = await gh(['api', `repos/{owner}/{repo}/pulls/${pr}/reviews`, '--paginate']);
25
24
  if (reviews.exitCode !== 0)
26
25
  throw new Error(`could not read the reviews of pull request ${pr}: ${reviews.stderr.trim()}`);
27
- const humans = parseJsonArrays(reviews.stdout)
28
- .filter((r) => r?.user && r.user.type !== 'Bot' && !/bot/i.test(r.user.login) && r.user.login !== author && (r.body?.trim() || r.state === 'CHANGES_REQUESTED'));
29
- if (humans.length === 0)
30
- throw new Error(`pull request ${pr} has no review by a person yet`);
26
+ const byPerson = parseJsonArrays(reviews.stdout).filter((r) => r?.user && r.user.type !== 'Bot' && !/bot/i.test(r.user.login) && r.user.login !== author);
27
+ const humans = byPerson.filter((r) => r.body?.trim() || r.state === 'CHANGES_REQUESTED');
31
28
  const mainRef = branchBase(cwd)?.mainRef ?? 'origin/main';
32
29
  const rounds = [];
33
30
  for (const [index, review] of humans.entries()) {
@@ -39,16 +36,33 @@ export async function scaffoldLedger(cwd, pr, config, exec = defaultExec) {
39
36
  points.push({ id: `R${round}-B${i + 1}`, point: line, file: '', needs: 'a file pattern and a text pattern (this point was made in the review body, not on a line)' });
40
37
  }
41
38
  const base = await baseAt(cwd, String(review.commit_id), review.submitted_at, mainRef, exec);
42
- rounds.push({
43
- id: `pr${pr}-r${round}`,
44
- commit: String(review.commit_id),
45
- base,
46
- reviewed_at: String(review.submitted_at),
47
- pr,
48
- points,
49
- must_not_flag: [],
50
- });
39
+ rounds.push({ id: `pr${pr}-r${round}`, commit: String(review.commit_id), base, reviewed_at: String(review.submitted_at), pr, points, must_not_flag: [] });
51
40
  }
41
+ const approval = options.approvedHead ? byPerson.find((r) => r.state === 'APPROVED' && r.commit_id) : undefined;
42
+ const approved = approval
43
+ ? { id: `pr${pr}-approved`, commit: String(approval.commit_id), base: await baseAt(cwd, String(approval.commit_id), approval.submitted_at, mainRef, exec), reviewed_at: String(approval.submitted_at), pr, points: [], must_not_flag: [] }
44
+ : undefined;
45
+ if (rounds.length === 0 && !approved)
46
+ throw new Error(`pull request ${pr} has no review by a person yet`);
47
+ return { rounds, ...(approved ? { approved } : {}) };
48
+ }
49
+ /** The last `n` merged pull requests, newest merge first. */
50
+ export async function mergedPrs(cwd, n, config, exec = defaultExec) {
51
+ const env = await githubEnv(cwd, config.review?.github_account ?? process.env.RIGOUR_GITHUB_ACCOUNT, exec);
52
+ const list = await exec('gh', ['pr', 'list', '--state', 'merged', '--limit', String(n), '--json', 'number,mergedAt'], { cwd, timeoutMs: GH_TIMEOUT_MS, env });
53
+ if (list.exitCode !== 0)
54
+ throw new Error(`could not list merged pull requests: ${list.stderr.trim() || 'is gh signed in?'}`);
55
+ try {
56
+ return JSON.parse(list.stdout).sort((a, b) => (a.mergedAt < b.mergedAt ? 1 : -1)).slice(0, n);
57
+ }
58
+ catch {
59
+ throw new Error('could not read the list of merged pull requests');
60
+ }
61
+ }
62
+ /** Lines either side of an inline comment that a finding for the same point may land on. */
63
+ const LINE_SLACK = 10;
64
+ export async function scaffoldLedger(cwd, pr, config, exec = defaultExec) {
65
+ const { rounds } = await roundsForPr(cwd, pr, config, exec);
52
66
  const file = path.join(cwd, LEDGER_PATH);
53
67
  const existing = existingLedger(file);
54
68
  const merged = { rounds: [...existing.rounds.filter(r => !rounds.some(n => n.id === r.id)), ...rounds] };
@@ -0,0 +1,40 @@
1
+ import type { Config } from '../types/index.js';
2
+ import { type BacktestItem, type BacktestOptions, type Ledger, type RoundResult } from './backtest.js';
3
+ export declare const LAST_LEDGER_PATH = ".rigour/backtest-last.json";
4
+ export interface EarlyCatch {
5
+ pr: number;
6
+ round: string;
7
+ point: string;
8
+ by: string;
9
+ roundsEarlier: number;
10
+ }
11
+ export interface LastReport {
12
+ prs: number;
13
+ rounds: number;
14
+ approvedHeads: number;
15
+ /** Blocking items on heads a person approved: every one is a false block. */
16
+ blocksOnApproved: Array<{
17
+ pr: number;
18
+ round: string;
19
+ item: BacktestItem;
20
+ }>;
21
+ /** Points a person raised in a later round that an earlier round had already blocked. */
22
+ early: EarlyCatch[];
23
+ /** Points caught in the round they were raised. */
24
+ sameRound: number;
25
+ /** Points raised after a pull request's first review: the pool `early` is drawn from. */
26
+ pointsLater: number;
27
+ /** Rounds whose commit is no longer reachable (a force-push), with why. */
28
+ skipped: string[];
29
+ costUsd?: number;
30
+ durationMs: number;
31
+ results: RoundResult[];
32
+ }
33
+ export interface LastOptions extends Pick<BacktestOptions, 'reviewer' | 'exec' | 'progress' | 'collect'> {
34
+ last: number;
35
+ }
36
+ export declare function backtestLast(cwd: string, config: Config, options: LastOptions): Promise<LastReport>;
37
+ /** The report's numbers from a ledger and its results: pure, so a change to the scoring is measured by tests. */
38
+ export declare function scoreLast(ledger: Ledger, results: RoundResult[]): Omit<LastReport, 'skipped'>;
39
+ /** The two numbers first, the worse one on top; then what they rest on. */
40
+ export declare function formatLast(r: LastReport): string;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `rigour backtest --last N`: the front door. The last N merged pull requests become a ledger on
3
+ * their own (one round per review by a person, from the inline comments, plus the head a person
4
+ * approved), the review runs on each with that review hidden, and the report leads with the two
5
+ * numbers a team needs before trusting a reviewer: blocks on heads the seniors approved (must be
6
+ * about none, and said first when it is not), and points people raised later that the review had
7
+ * already blocked, with how many rounds earlier. Then cost and time. Nothing is published; the
8
+ * ledger it built is written beside the hand-made one for a person to read.
9
+ */
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import { defaultExec } from './reviewer/exec.js';
13
+ import { matches, runBacktest } from './backtest.js';
14
+ import { mergedPrs, roundsForPr } from './backtest-init.js';
15
+ export const LAST_LEDGER_PATH = '.rigour/backtest-last.json';
16
+ const GIT_TIMEOUT_MS = 5 * 60_000;
17
+ export async function backtestLast(cwd, config, options) {
18
+ const exec = options.exec ?? defaultExec;
19
+ const progress = options.progress ?? (() => undefined);
20
+ const prs = await mergedPrs(cwd, options.last, config, exec);
21
+ const rounds = [];
22
+ const skipped = [];
23
+ for (const pr of prs) {
24
+ // The reviewed commits must be here: a pull request's head is fetched once; a round force-pushed away is skipped, not guessed.
25
+ await exec('git', ['fetch', '-q', 'origin', `pull/${pr.number}/head`], { cwd, timeoutMs: GIT_TIMEOUT_MS });
26
+ let found;
27
+ try {
28
+ found = await roundsForPr(cwd, pr.number, config, exec, { approvedHead: true });
29
+ }
30
+ catch (error) {
31
+ skipped.push(`pull request ${pr.number}: ${error instanceof Error ? error.message : String(error)}`);
32
+ continue;
33
+ }
34
+ for (const round of [...found.rounds, ...(found.approved ? [found.approved] : [])]) {
35
+ const present = await exec('git', ['rev-parse', '--verify', '-q', `${round.commit}^{commit}`], { cwd, timeoutMs: GIT_TIMEOUT_MS });
36
+ if (present.exitCode !== 0) {
37
+ skipped.push(`${round.id}: commit ${round.commit.slice(0, 9)} is not reachable (force-pushed away)`);
38
+ continue;
39
+ }
40
+ // Unattended: only points with a file and a line window; a body point would need a person's pattern.
41
+ rounds.push({ ...round, points: round.points.filter(p => !p.needs && p.file && p.lines) });
42
+ }
43
+ }
44
+ if (rounds.length === 0)
45
+ throw new Error(`none of the last ${options.last} merged pull requests has a review by a person whose commits are reachable${skipped.length ? `:\n ${skipped.join('\n ')}` : ''}`);
46
+ const ledger = { rounds };
47
+ fs.mkdirSync(path.join(cwd, path.dirname(LAST_LEDGER_PATH)), { recursive: true });
48
+ fs.writeFileSync(path.join(cwd, LAST_LEDGER_PATH), JSON.stringify(ledger, null, 2));
49
+ progress(`backtest: ${rounds.length} round(s) from ${prs.length} merged pull request(s), ledger written to ${LAST_LEDGER_PATH}`);
50
+ const results = await runBacktest(cwd, config, ledger, { reviewer: options.reviewer, exec, progress, collect: options.collect });
51
+ return { ...scoreLast(ledger, results), skipped };
52
+ }
53
+ /** The report's numbers from a ledger and its results: pure, so a change to the scoring is measured by tests. */
54
+ export function scoreLast(ledger, results) {
55
+ const byPr = new Map();
56
+ for (const round of ledger.rounds)
57
+ if (round.pr !== undefined)
58
+ byPr.set(round.pr, [...(byPr.get(round.pr) ?? []), round]);
59
+ const resultOf = new Map(results.map(r => [r.round, r]));
60
+ const early = [];
61
+ let pointsLater = 0;
62
+ for (const [pr, prRounds] of byPr) {
63
+ const reviews = prRounds.filter(r => !r.id.endsWith('-approved')).sort((a, b) => ((a.reviewed_at ?? '') < (b.reviewed_at ?? '') ? -1 : 1));
64
+ for (let j = 1; j < reviews.length; j++) {
65
+ for (const point of reviews[j].points) {
66
+ pointsLater++;
67
+ for (let i = 0; i < j; i++) {
68
+ const hit = resultOf.get(reviews[i].id)?.items.find(item => item.blocking && matches(point, item));
69
+ if (hit) {
70
+ early.push({ pr, round: reviews[i].id, point: point.point, by: `${hit.gate} ${hit.file}${hit.line ? `:${hit.line}` : ''}`, roundsEarlier: j - i });
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ const approvedRounds = ledger.rounds.filter(r => r.id.endsWith('-approved'));
78
+ const blocksOnApproved = approvedRounds.flatMap(r => (resultOf.get(r.id)?.items ?? []).filter(i => i.blocking).map(item => ({ pr: r.pr ?? 0, round: r.id, item })));
79
+ const costs = results.map(r => r.costUsd).filter((c) => typeof c === 'number');
80
+ return {
81
+ prs: byPr.size,
82
+ rounds: ledger.rounds.length,
83
+ approvedHeads: approvedRounds.length,
84
+ blocksOnApproved,
85
+ early,
86
+ sameRound: results.reduce((n, r) => n + r.points.filter(p => p.caught).length, 0),
87
+ pointsLater,
88
+ ...(costs.length ? { costUsd: costs.reduce((a, b) => a + b, 0) } : {}),
89
+ durationMs: results.reduce((n, r) => n + r.durationMs, 0),
90
+ results,
91
+ };
92
+ }
93
+ /** The two numbers first, the worse one on top; then what they rest on. */
94
+ export function formatLast(r) {
95
+ const lines = [];
96
+ const approved = r.blocksOnApproved.length === 0
97
+ ? `0 blocking items on ${r.approvedHeads} approved head(s).`
98
+ : `${r.blocksOnApproved.length} blocking item(s) on ${r.approvedHeads} approved head(s): every one is a block the team would have overridden.`;
99
+ const earlyLine = `${r.early.length} of ${r.pointsLater} point(s) people raised in a later round were already blocked${r.early.length ? `, ${(r.early.reduce((n, e) => n + e.roundsEarlier, 0) / r.early.length).toFixed(1)} round(s) earlier on average` : ''}.`;
100
+ if (r.blocksOnApproved.length) {
101
+ lines.push(approved);
102
+ for (const b of r.blocksOnApproved)
103
+ lines.push(` ${b.round}: ${b.item.gate} ${b.item.file}${b.item.line ? `:${b.item.line}` : ''} ${b.item.text.slice(0, 140)}`);
104
+ lines.push(earlyLine);
105
+ }
106
+ else {
107
+ lines.push(earlyLine, approved);
108
+ }
109
+ for (const e of r.early)
110
+ lines.push(` pr ${e.pr}, ${e.round}: "${e.point.slice(0, 100)}" blocked ${e.roundsEarlier} round(s) earlier by ${e.by}`);
111
+ lines.push(`${r.sameRound} point(s) caught in the round they were raised. ${r.prs} pull request(s), ${r.rounds} round(s)${r.costUsd !== undefined ? `, $${r.costUsd.toFixed(2)}` : ''}, ${Math.round(r.durationMs / 1000)} s.`);
112
+ for (const s of r.skipped)
113
+ lines.push(`skipped ${s}`);
114
+ lines.push(`The ledger it ran is in ${LAST_LEDGER_PATH}; nothing was sent anywhere.`);
115
+ return lines.join('\n');
116
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ import { execFileSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { ConfigSchema } from '../types/index.js';
7
+ import { backtestLast, formatLast, scoreLast, LAST_LEDGER_PATH } from './backtest-last.js';
8
+ import { roundsForPr } from './backtest-init.js';
9
+ const config = ConfigSchema.parse({ version: 1 });
10
+ const item = (file, line, blocking = true, gate = 'reviewer:claude') => ({ gate, file, line, text: `${file} scan has no upper bound`, blocking });
11
+ const result = (round, items, caught = 0, costUsd) => ({ round, head: 'h', base: 'b', points: Array.from({ length: caught }, (_, i) => ({ id: `p${i}`, point: 'p', caught: true, noted: false })), falseBlocks: [], items, durationMs: 1000, ...(costUsd !== undefined ? { costUsd } : {}) });
12
+ describe('the front door report', () => {
13
+ const ledger = { rounds: [
14
+ { id: 'pr7-r1', commit: 'c', base: 'b', reviewed_at: '2026-04-12T12:00:00Z', pr: 7, points: [{ id: 'R1-1', point: 'drops the currency', file: 'src/b\\.ts', lines: [1, 11] }], must_not_flag: [] },
15
+ { id: 'pr7-r2', commit: 'c', base: 'b', reviewed_at: '2026-04-12T13:00:00Z', pr: 7, points: [{ id: 'R2-1', point: 'the export reads every line item', file: 'src/a\\.ts', lines: [1, 11] }], must_not_flag: [] },
16
+ { id: 'pr7-r3', commit: 'c', base: 'b', reviewed_at: '2026-04-12T14:00:00Z', pr: 7, points: [{ id: 'R3-1', point: 'still unbounded', file: 'src/a\\.ts', lines: [1, 11] }], must_not_flag: [] },
17
+ { id: 'pr7-approved', commit: 'c', base: 'b', reviewed_at: '2026-04-12T15:00:00Z', pr: 7, points: [], must_not_flag: [] },
18
+ { id: 'pr8-approved', commit: 'c', base: 'b', reviewed_at: '2026-04-13T15:00:00Z', pr: 8, points: [], must_not_flag: [] },
19
+ ] };
20
+ it('counts points raised later that an earlier round blocked, with how many rounds earlier, and every block on an approved head', () => {
21
+ const results = [result('pr7-r1', [item('src/a.ts', 3)]), result('pr7-r2', [item('src/a.ts', 3)], 1, 1.5), result('pr7-r3', [], 0, 0.5), result('pr7-approved', [item('src/a.ts', 3)]), result('pr8-approved', [item('src/z.ts', 9, false)])];
22
+ const r = scoreLast(ledger, results);
23
+ expect(r).toMatchObject({ prs: 2, rounds: 5, approvedHeads: 2, pointsLater: 2, sameRound: 1, costUsd: 2 });
24
+ expect(r.early).toEqual([
25
+ { pr: 7, round: 'pr7-r1', point: 'the export reads every line item', by: 'reviewer:claude src/a.ts:3', roundsEarlier: 1 },
26
+ { pr: 7, round: 'pr7-r1', point: 'still unbounded', by: 'reviewer:claude src/a.ts:3', roundsEarlier: 2 }, // the earliest round that blocked it counts
27
+ ]);
28
+ expect(r.blocksOnApproved).toEqual([{ pr: 7, round: 'pr7-approved', item: item('src/a.ts', 3) }]); // an advisory item on pr8 is not a block
29
+ const text = formatLast({ ...r, skipped: ['pr9-r1: commit abcdef012 is not reachable (force-pushed away)'] });
30
+ expect(text.split('\n')[0]).toBe('1 blocking item(s) on 2 approved head(s): every one is a block the team would have overridden.'); // the worse number first
31
+ expect(text).toContain('2 of 2 point(s) people raised in a later round were already blocked, 1.5 round(s) earlier on average.');
32
+ expect(text).toContain('skipped pr9-r1: commit abcdef012');
33
+ const clean = formatLast({ ...scoreLast(ledger, results.map(x => (x.round.endsWith('-approved') ? { ...x, items: [] } : x))), skipped: [] });
34
+ expect(clean.split('\n')[0]).toContain('point(s) people raised in a later round were already blocked'); // nothing on approved heads: the catches lead
35
+ expect(clean).toContain('0 blocking items on 2 approved head(s).');
36
+ });
37
+ });
38
+ describe('rigour backtest --last', () => {
39
+ let repo;
40
+ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
41
+ const commit = (message, date) => { git('add', '-A'); execFileSync('git', ['-C', repo, 'commit', '-qm', message], { env: { ...process.env, GIT_COMMITTER_DATE: date, GIT_AUTHOR_DATE: date } }); return git('rev-parse', 'HEAD'); };
42
+ let reviewed;
43
+ const fake = (calls) => async (command, args, options) => {
44
+ calls.push([command, ...args]);
45
+ if (command === 'git') {
46
+ if (args[0] === 'fetch')
47
+ return { exitCode: 0, stdout: '', stderr: '' }; // the head is already here
48
+ try {
49
+ return { exitCode: 0, stdout: execFileSync('git', args, { cwd: options.cwd, encoding: 'utf8', stdio: 'pipe' }), stderr: '' };
50
+ }
51
+ catch (error) {
52
+ return { exitCode: error.status ?? 1, stdout: '', stderr: String(error.stderr ?? '') };
53
+ }
54
+ }
55
+ if (args[0] === 'auth')
56
+ return { exitCode: 0, stdout: 'token\n', stderr: '' };
57
+ if (args[0] === 'pr' && args[1] === 'list')
58
+ return { exitCode: 0, stdout: JSON.stringify([{ number: 7, mergedAt: '2026-04-12T16:00:00Z' }, { number: 6, mergedAt: '2026-04-11T16:00:00Z' }]), stderr: '' };
59
+ if (args[0] === 'pr')
60
+ return { exitCode: 0, stdout: 'author\n', stderr: '' };
61
+ const pr = /pulls\/(\d+)\//.exec(args[1])?.[1];
62
+ if (args[1].endsWith('/reviews')) {
63
+ if (pr === '6')
64
+ return { exitCode: 0, stdout: JSON.stringify([{ id: 60, user: { login: 'author', type: 'User' }, body: 'self note', state: 'COMMENTED', submitted_at: '2026-04-11T12:00:00Z', commit_id: reviewed }]), stderr: '' };
65
+ return { exitCode: 0, stdout: JSON.stringify([
66
+ { id: 1, user: { login: 'senior', type: 'User' }, body: 'first pass', state: 'CHANGES_REQUESTED', submitted_at: '2026-04-12T12:00:00Z', commit_id: reviewed },
67
+ { id: 2, user: { login: 'senior', type: 'User' }, body: 'second pass', state: 'CHANGES_REQUESTED', submitted_at: '2026-04-12T13:00:00Z', commit_id: reviewed },
68
+ { id: 3, user: { login: 'senior', type: 'User' }, body: '', state: 'APPROVED', submitted_at: '2026-04-12T14:00:00Z', commit_id: reviewed },
69
+ ]), stderr: '' };
70
+ }
71
+ if (args[1].includes('/reviews/1/'))
72
+ return { exitCode: 0, stdout: JSON.stringify([{ path: 'b.ts', line: 1, body: 'Drops the currency', created_at: '2026-04-12T12:00:00Z' }]), stderr: '' };
73
+ if (args[1].includes('/reviews/2/'))
74
+ return { exitCode: 0, stdout: JSON.stringify([{ path: 'a.ts', line: 1, body: 'The export reads every line item.', created_at: '2026-04-12T13:00:00Z' }]), stderr: '' };
75
+ return { exitCode: 0, stdout: '[]', stderr: '' };
76
+ };
77
+ beforeEach(() => {
78
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'backtest-last-'));
79
+ git('init', '-q', '-b', 'main');
80
+ git('config', 'user.email', 't@example.com');
81
+ git('config', 'user.name', 't');
82
+ git('config', 'commit.gpgsign', 'false');
83
+ fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
84
+ commit('main', '2026-04-12T09:00:00Z');
85
+ git('checkout', '-q', '-b', 'feature');
86
+ fs.writeFileSync(path.join(repo, 'b.ts'), 'export const b = 1;\n');
87
+ reviewed = commit('the branch', '2026-04-12T09:30:00Z');
88
+ });
89
+ afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
90
+ it('builds the ledger from the last merged pull requests, the approved head included, runs it and leads with the two numbers', async () => {
91
+ const calls = [];
92
+ const report = await backtestLast(repo, config, { last: 2, exec: fake(calls), collect: async () => ({ items: [item('a.ts', 1)], costUsd: 0.25 }) });
93
+ expect(report).toMatchObject({ prs: 1, rounds: 3, approvedHeads: 1, pointsLater: 1, sameRound: 1, costUsd: 0.75 });
94
+ expect(report.early).toEqual([{ pr: 7, round: 'pr7-r1', point: 'The export reads every line item.', by: 'reviewer:claude a.ts:1', roundsEarlier: 1 }]);
95
+ expect(report.blocksOnApproved.map(b => b.round)).toEqual(['pr7-approved']);
96
+ expect(report.skipped).toEqual(['pull request 6: pull request 6 has no review by a person yet']); // the author's own note is not a review
97
+ expect(calls.filter(c => c[0] === 'git' && c[1] === 'fetch').map(c => c.at(-1))).toEqual(['pull/7/head', 'pull/6/head']);
98
+ const written = JSON.parse(fs.readFileSync(path.join(repo, LAST_LEDGER_PATH), 'utf8'));
99
+ expect(written.rounds.map(r => [r.id, r.points.length])).toEqual([['pr7-r1', 1], ['pr7-r2', 1], ['pr7-approved', 0]]);
100
+ expect(formatLast(report).split('\n')[0]).toContain('1 blocking item(s) on 1 approved head(s)');
101
+ });
102
+ it('gives the scaffolder the approved head only when asked, with no points', async () => {
103
+ const calls = [];
104
+ const without = await roundsForPr(repo, 7, config, fake(calls));
105
+ expect(without.approved).toBeUndefined();
106
+ const withHead = await roundsForPr(repo, 7, config, fake(calls), { approvedHead: true });
107
+ expect(withHead.approved).toMatchObject({ id: 'pr7-approved', commit: reviewed, reviewed_at: '2026-04-12T14:00:00Z', points: [] });
108
+ });
109
+ });
@@ -2,6 +2,26 @@ import { z } from 'zod';
2
2
  import type { Config } from '../types/index.js';
3
3
  import { type Exec, type Progress } from './reviewer.js';
4
4
  import { type JudgeCatches, type JudgeRun } from './backtest-judges.js';
5
+ declare const Match: z.ZodObject<{
6
+ /** A regular expression over the finding's file path. */
7
+ file: z.ZodString;
8
+ /** A line window at the reviewed commit: a finding inside it matches. */
9
+ lines: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
10
+ /** A regular expression over the finding's text; the alternative for a point with no line. */
11
+ text: z.ZodOptional<z.ZodString>;
12
+ /** Left by `rigour backtest init` on a row a person still has to complete. */
13
+ needs: z.ZodOptional<z.ZodString>;
14
+ }, "strip", z.ZodTypeAny, {
15
+ file: string;
16
+ text?: string | undefined;
17
+ lines?: [number, number] | undefined;
18
+ needs?: string | undefined;
19
+ }, {
20
+ file: string;
21
+ text?: string | undefined;
22
+ lines?: [number, number] | undefined;
23
+ needs?: string | undefined;
24
+ }>;
5
25
  declare const Point: z.ZodObject<{
6
26
  /** A regular expression over the finding's file path. */
7
27
  file: z.ZodString;
@@ -268,6 +288,7 @@ export declare const LedgerSchema: z.ZodObject<{
268
288
  export type Ledger = z.infer<typeof LedgerSchema>;
269
289
  export type LedgerRound = z.infer<typeof Round>;
270
290
  export type LedgerPoint = z.infer<typeof Point>;
291
+ type LedgerMatch = z.infer<typeof Match>;
271
292
  export declare const LEDGER_PATH = ".rigour/backtest.json";
272
293
  /** A finding as the score sees it, from a gate or the reviewer. */
273
294
  export interface BacktestItem {
@@ -293,6 +314,8 @@ export interface RoundResult {
293
314
  durationMs: number;
294
315
  /** Why the reviewer gave no verdict, when it ran. */
295
316
  reviewerError?: string;
317
+ /** What the reviewer's runs cost, when it ran and reported dollars. */
318
+ costUsd?: number;
296
319
  /** With two or more judges: the ledger points each raised on its own, and the round's runs and cost. */
297
320
  judges?: JudgeCatches;
298
321
  }
@@ -311,10 +334,13 @@ export declare function runBacktest(cwd: string, config: Config, ledger: Ledger,
311
334
  /** Every point caught, nothing the reviewer called good flagged, and a verdict when the reviewer ran. */
312
335
  export declare function backtestPassed(results: RoundResult[]): boolean;
313
336
  export declare function score(round: LedgerRound, head: string, items: BacktestItem[], durationMs: number, reviewerError: string | undefined): RoundResult;
337
+ /** The file pattern must match, then either the line window holds the finding's line or the text pattern matches its text. */
338
+ export declare function matches(row: LedgerMatch, item: BacktestItem): boolean;
314
339
  interface Collected {
315
340
  items: BacktestItem[];
316
341
  reviewerError?: string;
317
342
  judged?: JudgeRun;
343
+ costUsd?: number;
318
344
  }
319
345
  export declare function formatBacktest(results: RoundResult[]): string;
320
346
  export {};
@@ -96,8 +96,8 @@ export async function runBacktest(cwd, config, ledger, options = {}) {
96
96
  if (stale)
97
97
  progress(`backtest ${round.id}: warning: ${stale}`);
98
98
  const collect = options.collect ?? ((tree, r, c) => collectItems(tree, r, c, !!options.reviewer, exec, progress));
99
- const { items, reviewerError, judged } = await collect(worktree, round, config);
100
- const result = { ...score(round, head, items, Date.now() - started, reviewerError), ...(judged ? { judges: judgeCatches(round, judged) } : {}) };
99
+ const { items, reviewerError, judged, costUsd } = await collect(worktree, round, config);
100
+ const result = { ...score(round, head, items, Date.now() - started, reviewerError), ...(judged ? { judges: judgeCatches(round, judged) } : {}), ...(costUsd !== undefined ? { costUsd } : {}) };
101
101
  record(cwd, result);
102
102
  results.push(result);
103
103
  }
@@ -136,7 +136,7 @@ export function score(round, head, items, durationMs, reviewerError) {
136
136
  return { round: round.id, head, base: round.base, points, falseBlocks, items, durationMs, ...(reviewerError ? { reviewerError } : {}) };
137
137
  }
138
138
  /** The file pattern must match, then either the line window holds the finding's line or the text pattern matches its text. */
139
- function matches(row, item) {
139
+ export function matches(row, item) {
140
140
  if (!new RegExp(row.file, 'i').test(item.file))
141
141
  return false;
142
142
  const inWindow = !!row.lines && item.line !== undefined && item.line >= row.lines[0] && item.line <= row.lines[1];
@@ -199,7 +199,7 @@ async function collectItems(worktree, round, config, reviewer, exec, progress) {
199
199
  const asReviewerItem = (item, blocking) => ({ gate: `reviewer:${item.reviewer ?? result.reviewers[0]}`, file: item.file ?? '', line: item.line, text: [item.issue, item.consequence, item.evidence].filter(Boolean).join(' '), blocking });
200
200
  items.push(...result.items.map(i => asReviewerItem(i, true)), ...[...result.advisory, ...result.unverified, ...result.notes, ...result.disputed].map(i => asReviewerItem(i, false)));
201
201
  const judged = judgedFrom(result);
202
- return { items, ...(judged ? { judged } : {}) };
202
+ return { items, ...(judged ? { judged } : {}), ...(result.costUsd !== undefined ? { costUsd: result.costUsd } : {}) };
203
203
  }
204
204
  function asItem(failure, blocking) {
205
205
  return { gate: failure.id, file: failure.files?.[0] ?? '', line: failure.line, text: [failure.title, failure.details, failure.hint].filter(Boolean).join(' '), blocking };
@@ -1,12 +1,14 @@
1
1
  import { type Exec } from './exec.js';
2
- export type ReviewerName = 'claude' | 'cursor' | 'codex';
3
- export type Vendor = 'anthropic' | 'cursor' | 'openai';
2
+ export type ReviewerName = 'claude' | 'cursor' | 'codex' | 'api';
3
+ export type Vendor = 'anthropic' | 'cursor' | 'openai' | 'google' | 'other';
4
4
  export type ReviewMode = 'single' | 'cross' | 'full';
5
5
  export interface Adapter {
6
6
  vendor: Vendor;
7
7
  binary: string;
8
8
  /** The command line for one review: the prompt is passed as text, never through a shell. */
9
- args(prompt: string, model: string | undefined): string[];
9
+ args(prompt: string, model: string | undefined, options?: {
10
+ reasoning?: 'low' | 'medium' | 'high';
11
+ }): string[];
10
12
  /** The reviewer's final message and, when the CLI reports them, what the run cost and the tokens it used. */
11
13
  answer(stdout: string): {
12
14
  text: string;
@@ -61,7 +63,12 @@ export declare function vendorsOf(trailers: string): Set<Vendor>;
61
63
  * whose vendor is not on the trailers, else the first available. full: that one plus the next
62
64
  * available of each other vendor, up to `judges`. Empty when none is installed.
63
65
  */
64
- export declare function selectReviewers(candidates: ReviewerName[], mode: ReviewMode, authors: Set<Vendor>, available: Set<ReviewerName>, judges?: number): ReviewerName[];
66
+ export declare function selectReviewers(candidates: ReviewerName[], mode: ReviewMode, authors: Set<Vendor>, available: Set<ReviewerName>, judges?: number, vendorOf?: (name: ReviewerName) => Vendor): ReviewerName[];
67
+ /** The maker of an API judge's model: what the team said, else what the model's name says, else other. */
68
+ export declare function apiVendor(api: {
69
+ model: string;
70
+ vendor?: Vendor;
71
+ } | undefined): Vendor;
65
72
  /** Every reviewer Rigour can run, and whether this machine has it: what bounds the judges of a panel. */
66
73
  export declare function reviewerAvailability(cwd: string, exec?: Exec): Promise<Array<{
67
74
  name: ReviewerName;
@@ -48,7 +48,7 @@ export const ADAPTERS = {
48
48
  codex: {
49
49
  vendor: 'openai',
50
50
  binary: 'codex',
51
- args: (prompt, model) => ['exec', '--sandbox', 'read-only', '--json', ...(model ? ['--model', model] : []), '-c', 'model_reasoning_effort=high', prompt],
51
+ args: (prompt, model, options) => ['exec', '--sandbox', 'read-only', '--json', ...(model ? ['--model', model] : []), '-c', `model_reasoning_effort=${options?.reasoning ?? 'high'}`, prompt],
52
52
  // `codex exec --json` streams events; the last text-bearing one carries the answer.
53
53
  // Warnings arrive as `error` items with a `message`, not `text`, so they are never taken for the answer.
54
54
  // `turn.completed` carries the tokens (Codex reports no dollars).
@@ -71,6 +71,27 @@ export const ADAPTERS = {
71
71
  return { text: text || stdout, ...(tokens ? { tokens } : {}) };
72
72
  },
73
73
  },
74
+ api: {
75
+ // A model API, not a CLI: reviewer.ts runs the loop (api-judge.ts); the answer is the loop's JSON.
76
+ vendor: 'other',
77
+ binary: 'api',
78
+ args: () => [],
79
+ answer: stdout => {
80
+ try {
81
+ const parsed = JSON.parse(stdout);
82
+ const u = parsed.usage ?? {};
83
+ return {
84
+ text: String(parsed.result ?? ''),
85
+ ...(typeof parsed.cost_usd === 'number' ? { costUsd: parsed.cost_usd } : {}),
86
+ tokens: { input: n(u.input) + n(u.cacheRead) + n(u.cacheWrite), output: n(u.output) },
87
+ ...(parsed.trace ? { trace: parsed.trace } : {}),
88
+ };
89
+ }
90
+ catch {
91
+ return { text: stdout };
92
+ }
93
+ },
94
+ },
74
95
  };
75
96
  export function isReviewerName(name) {
76
97
  return name in ADAPTERS;
@@ -83,6 +104,8 @@ const EXTRA_BIN_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', path.join(os.home
83
104
  * refuses current models, so the version decides, not PATH order.
84
105
  */
85
106
  export async function resolveAdapter(adapter, cwd, exec) {
107
+ if (adapter.binary === 'api')
108
+ return undefined; // never a binary: reviewer.ts installs it from review.reviewer.api
86
109
  const names = process.platform === 'win32' ? [`${adapter.binary}.cmd`, `${adapter.binary}.exe`, adapter.binary] : [adapter.binary];
87
110
  const dirs = [...(process.env.PATH ?? '').split(path.delimiter), ...EXTRA_BIN_DIRS].filter(Boolean);
88
111
  const candidates = new Set();
@@ -141,27 +164,43 @@ export function vendorsOf(trailers) {
141
164
  * whose vendor is not on the trailers, else the first available. full: that one plus the next
142
165
  * available of each other vendor, up to `judges`. Empty when none is installed.
143
166
  */
144
- export function selectReviewers(candidates, mode, authors, available, judges = 2) {
167
+ export function selectReviewers(candidates, mode, authors, available, judges = 2, vendorOf = name => ADAPTERS[name].vendor) {
145
168
  const installed = candidates.filter(name => available.has(name));
146
169
  if (installed.length === 0)
147
170
  return [];
148
171
  let first = installed[0];
149
172
  if (mode !== 'single')
150
- first = installed.find(name => !authors.has(ADAPTERS[name].vendor)) ?? first;
173
+ first = installed.find(name => !authors.has(vendorOf(name))) ?? first;
151
174
  if (mode !== 'full')
152
175
  return [first];
153
176
  const chosen = [first];
154
177
  for (const name of installed) {
155
178
  if (chosen.length >= judges)
156
179
  break;
157
- if (!chosen.some(c => ADAPTERS[c].vendor === ADAPTERS[name].vendor))
180
+ if (!chosen.some(c => vendorOf(c) === vendorOf(name)))
158
181
  chosen.push(name);
159
182
  }
160
183
  return chosen;
161
184
  }
185
+ /** The maker of an API judge's model: what the team said, else what the model's name says, else other. */
186
+ export function apiVendor(api) {
187
+ if (!api)
188
+ return 'other';
189
+ if (api.vendor)
190
+ return api.vendor;
191
+ const model = api.model.toLowerCase();
192
+ if (/claude|anthropic/.test(model))
193
+ return 'anthropic';
194
+ if (/gpt|openai|^o[1-9]/.test(model))
195
+ return 'openai';
196
+ if (/gemini|google/.test(model))
197
+ return 'google';
198
+ return 'other';
199
+ }
162
200
  /** Every reviewer Rigour can run, and whether this machine has it: what bounds the judges of a panel. */
163
201
  export async function reviewerAvailability(cwd, exec = defaultExec) {
164
- return Promise.all(Object.keys(ADAPTERS).map(async (name) => {
202
+ // The CLIs only: the api judge is configured, not installed (reviewer.ts).
203
+ return Promise.all(Object.keys(ADAPTERS).filter(name => ADAPTERS[name].binary !== 'api').map(async (name) => {
165
204
  const found = await resolveAdapter(ADAPTERS[name], cwd, exec);
166
205
  return { name, vendor: ADAPTERS[name].vendor, binary: ADAPTERS[name].binary, installed: !!found, ...(found ? { version: found.version } : {}) };
167
206
  }));
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { ADAPTERS } from './adapters.js';
2
+ import { ADAPTERS, apiVendor, selectReviewers } from './adapters.js';
3
3
  /** A real `codex exec --json` run (codex-cli 0.160.1), the warning's path shortened: the shape the adapter must read. */
4
4
  const CODEX = [
5
5
  '{"type":"thread.started","thread_id":"01a11477-f9fd-7472-8d31-5863de4cbbdb"}',
@@ -43,3 +43,18 @@ describe('reading an agent CLI\'s answer', () => {
43
43
  expect(ADAPTERS.claude.args('p', undefined)).toEqual(expect.arrayContaining(['--output-format', 'stream-json', '--verbose']));
44
44
  });
45
45
  });
46
+ describe('a judge reached through an API', () => {
47
+ it('is told apart by its model\'s maker, so cross and full modes pair it with a different vendor', () => {
48
+ expect(apiVendor({ model: 'anthropic/claude-sonnet-4.5' })).toBe('anthropic');
49
+ expect(apiVendor({ model: 'gpt-5' })).toBe('openai');
50
+ expect(apiVendor({ model: 'google/gemini-2.5-pro' })).toBe('google');
51
+ expect(apiVendor({ model: 'qwen3-coder' })).toBe('other');
52
+ expect(apiVendor({ model: 'qwen3-coder', vendor: 'openai' })).toBe('openai');
53
+ const vendorOf = (name) => (name === 'api' ? 'openai' : ADAPTERS[name].vendor);
54
+ expect(selectReviewers(['claude', 'api'], 'cross', new Set(['anthropic']), new Set(['claude', 'api']), 2, vendorOf)).toEqual(['api']); // the author's vendor is skipped
55
+ expect(selectReviewers(['claude', 'api'], 'full', new Set(), new Set(['claude', 'api']), 2, vendorOf)).toEqual(['claude', 'api']);
56
+ expect(ADAPTERS.api.answer(JSON.stringify({ result: '{"prior_points":[]}', usage: { input: 10, cacheRead: 5, cacheWrite: 0, output: 3 }, cost_usd: 0.02, trace: { turns: 2, usage: {}, calls: [] } }))).toMatchObject({ text: '{"prior_points":[]}', costUsd: 0.02, tokens: { input: 15, output: 3 }, trace: { turns: 2 } });
57
+ expect(ADAPTERS.codex.args('p', undefined, { reasoning: 'medium' })).toContain('model_reasoning_effort=medium');
58
+ expect(ADAPTERS.codex.args('p', undefined)).toContain('model_reasoning_effort=high');
59
+ });
60
+ });