@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/review/backtest-init.d.ts +15 -0
- package/dist/review/backtest-init.js +30 -16
- package/dist/review/backtest-last.d.ts +40 -0
- package/dist/review/backtest-last.js +116 -0
- package/dist/review/backtest-last.test.d.ts +1 -0
- package/dist/review/backtest-last.test.js +109 -0
- package/dist/review/backtest.d.ts +26 -0
- package/dist/review/backtest.js +4 -4
- package/dist/review/reviewer/adapters.d.ts +11 -4
- package/dist/review/reviewer/adapters.js +44 -5
- package/dist/review/reviewer/adapters.test.js +16 -1
- package/dist/review/reviewer/api-judge.d.ts +28 -0
- package/dist/review/reviewer/api-judge.js +161 -0
- package/dist/review/reviewer/api-judge.test.d.ts +1 -0
- package/dist/review/reviewer/api-judge.test.js +87 -0
- package/dist/review/reviewer/context.d.ts +2 -0
- package/dist/review/reviewer/context.js +4 -1
- package/dist/review/reviewer/settings.d.ts +10 -0
- package/dist/review/reviewer/settings.js +3 -1
- package/dist/review/reviewer/verdict.js +8 -3
- package/dist/review/reviewer.d.ts +2 -0
- package/dist/review/reviewer.js +17 -5
- package/dist/review/reviewer.test.js +35 -3
- package/dist/review-learning/lessons.d.ts +2 -0
- package/dist/review-learning/lessons.js +17 -1
- package/dist/review-learning/repo-rules.test.js +3 -0
- package/dist/review-learning/review-learning.test.js +9 -0
- package/dist/review-learning/team-lessons.d.ts +2 -2
- package/dist/review-learning/team-lessons.js +3 -3
- package/dist/templates/universal-config.js +1 -0
- package/dist/types/index.d.ts +74 -0
- package/dist/types/index.js +14 -0
- 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
|
-
/**
|
|
16
|
-
|
|
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
|
|
28
|
-
|
|
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 {};
|
package/dist/review/backtest.js
CHANGED
|
@@ -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
|
|
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', '
|
|
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(
|
|
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 =>
|
|
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
|
-
|
|
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
|
+
});
|