@rigour-labs/core 6.7.8 → 6.7.10
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/review/backtest-init.js +1 -1
- package/dist/review/backtest.d.ts +10 -0
- package/dist/review/backtest.js +11 -2
- package/dist/review/backtest.test.js +12 -0
- package/dist/review/reviewer/api-judge.d.ts +5 -0
- package/dist/review/reviewer/api-judge.js +34 -5
- package/dist/review/reviewer/api-judge.test.js +21 -0
- package/dist/review/reviewer/inputs.d.ts +3 -0
- package/dist/review/reviewer/inputs.js +10 -4
- package/dist/review/reviewer/inputs.test.d.ts +1 -0
- package/dist/review/reviewer/inputs.test.js +26 -0
- package/dist/review/reviewer/prompt.js +8 -2
- package/dist/review/reviewer/verdict.d.ts +26 -1
- package/dist/review/reviewer/verdict.js +106 -5
- package/dist/review/reviewer.js +29 -10
- package/dist/review/reviewer.test.js +101 -5
- package/package.json +6 -6
|
@@ -40,7 +40,7 @@ export async function roundsForPr(cwd, pr, config, exec = defaultExec, options =
|
|
|
40
40
|
}
|
|
41
41
|
const approval = options.approvedHead ? byPerson.find((r) => r.state === 'APPROVED' && r.commit_id) : undefined;
|
|
42
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: [] }
|
|
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), approved: true, pr, points: [], must_not_flag: [] }
|
|
44
44
|
: undefined;
|
|
45
45
|
if (rounds.length === 0 && !approved)
|
|
46
46
|
throw new Error(`pull request ${pr} has no review by a person yet`);
|
|
@@ -55,6 +55,8 @@ declare const Round: z.ZodObject<{
|
|
|
55
55
|
base: z.ZodString;
|
|
56
56
|
/** When the human review was posted: the reviewer sees nothing from then on. */
|
|
57
57
|
reviewed_at: z.ZodOptional<z.ZodString>;
|
|
58
|
+
/** The head a person approved: `reviewed_at` is the approval, and the reviewer sees it (it closes the points before it). */
|
|
59
|
+
approved: z.ZodOptional<z.ZodBoolean>;
|
|
58
60
|
pr: z.ZodOptional<z.ZodNumber>;
|
|
59
61
|
points: z.ZodArray<z.ZodObject<{
|
|
60
62
|
/** A regular expression over the finding's file path. */
|
|
@@ -123,6 +125,7 @@ declare const Round: z.ZodObject<{
|
|
|
123
125
|
}[];
|
|
124
126
|
pr?: number | undefined;
|
|
125
127
|
reviewed_at?: string | undefined;
|
|
128
|
+
approved?: boolean | undefined;
|
|
126
129
|
}, {
|
|
127
130
|
id: string;
|
|
128
131
|
commit: string;
|
|
@@ -137,6 +140,7 @@ declare const Round: z.ZodObject<{
|
|
|
137
140
|
}[];
|
|
138
141
|
pr?: number | undefined;
|
|
139
142
|
reviewed_at?: string | undefined;
|
|
143
|
+
approved?: boolean | undefined;
|
|
140
144
|
must_not_flag?: {
|
|
141
145
|
file: string;
|
|
142
146
|
text?: string | undefined;
|
|
@@ -151,6 +155,8 @@ export declare const LedgerSchema: z.ZodObject<{
|
|
|
151
155
|
base: z.ZodString;
|
|
152
156
|
/** When the human review was posted: the reviewer sees nothing from then on. */
|
|
153
157
|
reviewed_at: z.ZodOptional<z.ZodString>;
|
|
158
|
+
/** The head a person approved: `reviewed_at` is the approval, and the reviewer sees it (it closes the points before it). */
|
|
159
|
+
approved: z.ZodOptional<z.ZodBoolean>;
|
|
154
160
|
pr: z.ZodOptional<z.ZodNumber>;
|
|
155
161
|
points: z.ZodArray<z.ZodObject<{
|
|
156
162
|
/** A regular expression over the finding's file path. */
|
|
@@ -219,6 +225,7 @@ export declare const LedgerSchema: z.ZodObject<{
|
|
|
219
225
|
}[];
|
|
220
226
|
pr?: number | undefined;
|
|
221
227
|
reviewed_at?: string | undefined;
|
|
228
|
+
approved?: boolean | undefined;
|
|
222
229
|
}, {
|
|
223
230
|
id: string;
|
|
224
231
|
commit: string;
|
|
@@ -233,6 +240,7 @@ export declare const LedgerSchema: z.ZodObject<{
|
|
|
233
240
|
}[];
|
|
234
241
|
pr?: number | undefined;
|
|
235
242
|
reviewed_at?: string | undefined;
|
|
243
|
+
approved?: boolean | undefined;
|
|
236
244
|
must_not_flag?: {
|
|
237
245
|
file: string;
|
|
238
246
|
text?: string | undefined;
|
|
@@ -261,6 +269,7 @@ export declare const LedgerSchema: z.ZodObject<{
|
|
|
261
269
|
}[];
|
|
262
270
|
pr?: number | undefined;
|
|
263
271
|
reviewed_at?: string | undefined;
|
|
272
|
+
approved?: boolean | undefined;
|
|
264
273
|
}[];
|
|
265
274
|
}, {
|
|
266
275
|
rounds: {
|
|
@@ -277,6 +286,7 @@ export declare const LedgerSchema: z.ZodObject<{
|
|
|
277
286
|
}[];
|
|
278
287
|
pr?: number | undefined;
|
|
279
288
|
reviewed_at?: string | undefined;
|
|
289
|
+
approved?: boolean | undefined;
|
|
280
290
|
must_not_flag?: {
|
|
281
291
|
file: string;
|
|
282
292
|
text?: string | undefined;
|
package/dist/review/backtest.js
CHANGED
|
@@ -35,6 +35,8 @@ const Round = z.object({
|
|
|
35
35
|
base: z.string().min(1),
|
|
36
36
|
/** When the human review was posted: the reviewer sees nothing from then on. */
|
|
37
37
|
reviewed_at: z.string().optional(),
|
|
38
|
+
/** The head a person approved: `reviewed_at` is the approval, and the reviewer sees it (it closes the points before it). */
|
|
39
|
+
approved: z.boolean().optional(),
|
|
38
40
|
pr: z.number().int().positive().optional(),
|
|
39
41
|
points: z.array(Point),
|
|
40
42
|
must_not_flag: z.array(Match).default([]),
|
|
@@ -91,7 +93,7 @@ export async function runBacktest(cwd, config, ledger, options = {}) {
|
|
|
91
93
|
const started = Date.now();
|
|
92
94
|
const worktree = await worktreeFor(cwd, round.commit, exec);
|
|
93
95
|
const head = (await exec('git', ['rev-parse', 'HEAD'], { cwd: worktree, timeoutMs: GIT_TIMEOUT_MS })).stdout.trim();
|
|
94
|
-
progress(`backtest ${round.id}: ${head.slice(0, 9)} against ${round.base}${round.reviewed_at ? `, reviews hidden from ${round
|
|
96
|
+
progress(`backtest ${round.id}: ${head.slice(0, 9)} against ${round.base}${round.reviewed_at ? `, reviews hidden from ${reviewsHiddenFrom(round)}` : ''}`);
|
|
95
97
|
const stale = await staleBase(worktree, head, round.base, exec);
|
|
96
98
|
if (stale)
|
|
97
99
|
progress(`backtest ${round.id}: warning: ${stale}`);
|
|
@@ -192,7 +194,7 @@ async function collectItems(worktree, round, config, reviewer, exec, progress) {
|
|
|
192
194
|
];
|
|
193
195
|
if (!reviewer)
|
|
194
196
|
return { items };
|
|
195
|
-
const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: round
|
|
197
|
+
const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: reviewsHiddenFrom(round), blind: round.pr === undefined, trigger: 'backtest', force: true, ...reviewerInputs(review) });
|
|
196
198
|
if (result.outcome === 'unavailable' || result.outcome === 'skipped')
|
|
197
199
|
return { items, reviewerError: result.reason ?? result.outcome };
|
|
198
200
|
// The reviewer behind each catch is part of the score, so the gate is `reviewer:<name>`.
|
|
@@ -237,3 +239,10 @@ function byGate(result) {
|
|
|
237
239
|
counts.set(p.by.split(' ')[0], (counts.get(p.by.split(' ')[0]) ?? 0) + 1);
|
|
238
240
|
return counts.size ? `; caught by ${[...counts].map(([gate, n]) => `${gate} ${n}`).join(', ')}` : '';
|
|
239
241
|
}
|
|
242
|
+
/** The moment the reviewer sees nothing from: the review itself for a round, and just after the approval for an approved head. */
|
|
243
|
+
function reviewsHiddenFrom(round) {
|
|
244
|
+
if (!round.reviewed_at || !round.approved)
|
|
245
|
+
return round.reviewed_at;
|
|
246
|
+
const at = Date.parse(round.reviewed_at);
|
|
247
|
+
return Number.isNaN(at) ? round.reviewed_at : new Date(at + 1000).toISOString();
|
|
248
|
+
}
|
|
@@ -92,6 +92,18 @@ describe('rigour backtest on a repository', () => {
|
|
|
92
92
|
catch { /* the repository may be gone */ }
|
|
93
93
|
fs.rmSync(repo, { recursive: true, force: true });
|
|
94
94
|
});
|
|
95
|
+
it('hides the review itself for a round, and shows the approval for an approved head', async () => {
|
|
96
|
+
const commit = git('rev-parse', 'HEAD');
|
|
97
|
+
const base = git('rev-parse', 'main');
|
|
98
|
+
const lines = [];
|
|
99
|
+
const ledger = { rounds: [
|
|
100
|
+
{ id: 'r1', commit, base, reviewed_at: '2026-09-28T15:12:53Z', points: [], must_not_flag: [] },
|
|
101
|
+
{ id: 'pr1-approved', commit, base, reviewed_at: '2026-09-28T15:12:53Z', approved: true, points: [], must_not_flag: [] },
|
|
102
|
+
{ id: 'odd', commit, base, reviewed_at: 'not a date', approved: true, points: [], must_not_flag: [] },
|
|
103
|
+
] };
|
|
104
|
+
await runBacktest(repo, ConfigSchema.parse({ version: 1 }), ledger, { progress: line => lines.push(line), collect: async () => ({ items: [] }) });
|
|
105
|
+
expect(lines.filter(l => l.includes('reviews hidden from')).map(l => l.replace(/^.*reviews hidden from /, ''))).toEqual(['2026-09-28T15:12:53Z', '2026-09-28T15:12:54.000Z', 'not a date']);
|
|
106
|
+
}, 60_000);
|
|
95
107
|
it('reviews the reviewed commit in a worktree with the review hidden, scores it, and leaves the checkout alone', async () => {
|
|
96
108
|
const reviewed = git('rev-parse', 'HEAD');
|
|
97
109
|
const base = git('rev-parse', 'main');
|
|
@@ -12,6 +12,11 @@ export interface ApiJudgeOptions {
|
|
|
12
12
|
roots: string[];
|
|
13
13
|
reasoning?: Reasoning;
|
|
14
14
|
fetchImpl?: typeof fetch;
|
|
15
|
+
/** The review's input files, given inline in the first message: a model that never calls a tool still has what it needs. */
|
|
16
|
+
inputs?: Array<{
|
|
17
|
+
path: string;
|
|
18
|
+
text: string;
|
|
19
|
+
}>;
|
|
15
20
|
}
|
|
16
21
|
export interface ApiJudgeRun {
|
|
17
22
|
exitCode: number;
|
|
@@ -11,6 +11,10 @@ import path from 'path';
|
|
|
11
11
|
import { execa } from 'execa';
|
|
12
12
|
const SYSTEM = 'You review code with read-only tools. Read the files the task names with read_file (the review inputs are named by absolute path), search the repository with search, read history with git. When you are done, reply with the final answer the task asks for and nothing else.';
|
|
13
13
|
const MAX_RESULT_CHARS = 60_000;
|
|
14
|
+
/** Output tokens a turn may use, reasoning included: a review verdict is long, and a reasoning model's thinking counts against the default budget. */
|
|
15
|
+
const MAX_OUTPUT_TOKENS = 32_000;
|
|
16
|
+
/** Characters of the inputs given inline in the first message; the rest stays in the files, for the tools. */
|
|
17
|
+
const MAX_INLINE_CHARS = 240_000;
|
|
14
18
|
const GIT_ALLOWED = new Set(['log', 'show', 'diff', 'blame', 'grep', 'ls-files', 'rev-parse', 'merge-base']);
|
|
15
19
|
/** Git options that write, or point git at another repository. */
|
|
16
20
|
const GIT_REFUSED = /^(--output|-o$|--git-dir|--work-tree|-C$|--exec-path|-c$|--config-env)/;
|
|
@@ -24,7 +28,7 @@ const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
|
24
28
|
export async function runApiJudge(prompt, o) {
|
|
25
29
|
const fetchImpl = o.fetchImpl ?? fetch;
|
|
26
30
|
const deadline = Date.now() + o.timeoutMs;
|
|
27
|
-
const messages = [{ role: 'system', content: SYSTEM }, { role: 'user', content: prompt }];
|
|
31
|
+
const messages = [{ role: 'system', content: SYSTEM }, { role: 'user', content: withInputs(prompt, o.inputs ?? []) }];
|
|
28
32
|
const usage = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
|
|
29
33
|
const calls = [];
|
|
30
34
|
let cost;
|
|
@@ -40,7 +44,7 @@ export async function runApiJudge(prompt, o) {
|
|
|
40
44
|
response = await fetchImpl(`${o.url.replace(/\/$/, '')}/chat/completions`, {
|
|
41
45
|
method: 'POST',
|
|
42
46
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${o.key}` },
|
|
43
|
-
body: JSON.stringify({ model: o.model, messages, tools: TOOLS, tool_choice: 'auto', ...(o.reasoning ? { reasoning_effort: o.reasoning } : {}) }),
|
|
47
|
+
body: JSON.stringify({ model: o.model, messages, tools: TOOLS, tool_choice: 'auto', max_tokens: MAX_OUTPUT_TOKENS, ...(o.reasoning ? { reasoning_effort: o.reasoning } : {}) }),
|
|
44
48
|
signal: controller.signal,
|
|
45
49
|
});
|
|
46
50
|
}
|
|
@@ -66,13 +70,22 @@ export async function runApiJudge(prompt, o) {
|
|
|
66
70
|
usage.output += n(u.completion_tokens);
|
|
67
71
|
if (typeof u.cost === 'number')
|
|
68
72
|
cost = (cost ?? 0) + u.cost;
|
|
69
|
-
const
|
|
73
|
+
const choice = body.choices?.[0];
|
|
74
|
+
// A gateway reports a provider's failure inside the choice, with the choice's finish_reason "error": say what it said.
|
|
75
|
+
if (choice?.error || body.error)
|
|
76
|
+
return fail(`the API reported an error: ${JSON.stringify(choice?.error ?? body.error).slice(0, 300)}`);
|
|
77
|
+
const message = choice?.message;
|
|
70
78
|
if (!message)
|
|
71
79
|
return fail('no choices in the answer');
|
|
72
|
-
|
|
80
|
+
// Echo back what the API needs to continue (reasoning_details carries a reasoning model's chain), not the reasoning prose.
|
|
81
|
+
messages.push({ role: 'assistant', content: message.content ?? null, ...(message.tool_calls ? { tool_calls: message.tool_calls } : {}), ...(message.reasoning_details ? { reasoning_details: message.reasoning_details } : {}) });
|
|
73
82
|
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
74
83
|
if (toolCalls.length === 0) {
|
|
75
|
-
const
|
|
84
|
+
const text = String(message.content ?? '').trim();
|
|
85
|
+
// An empty final message is not an answer: say why (the output budget ran out, a refusal), never report it as one.
|
|
86
|
+
if (!text)
|
|
87
|
+
return fail(`an empty answer on turn ${turn} (finish_reason ${String(body.choices?.[0]?.finish_reason ?? 'unknown')}${message.refusal ? `, refusal: ${String(message.refusal).slice(0, 120)}` : ''})`);
|
|
88
|
+
const answer = { result: text, usage, ...(cost !== undefined ? { cost_usd: cost } : {}), trace: { turns: turn, usage, calls } };
|
|
76
89
|
return { exitCode: 0, stdout: JSON.stringify(answer), stderr: '' };
|
|
77
90
|
}
|
|
78
91
|
for (const call of toolCalls) {
|
|
@@ -83,6 +96,22 @@ export async function runApiJudge(prompt, o) {
|
|
|
83
96
|
}
|
|
84
97
|
return fail(`no answer within ${o.maxTurns} turns`);
|
|
85
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* The prompt with the inputs it names appended inline, smallest first so the diff takes what budget is left: a judge
|
|
101
|
+
* is fed, not left to decide whether to read. What does not fit is cut with a note naming the file to read.
|
|
102
|
+
*/
|
|
103
|
+
function withInputs(prompt, inputs) {
|
|
104
|
+
if (inputs.length === 0)
|
|
105
|
+
return prompt;
|
|
106
|
+
const ordered = [...inputs].sort((a, b) => a.text.length - b.text.length);
|
|
107
|
+
let left = MAX_INLINE_CHARS;
|
|
108
|
+
const parts = ordered.map(input => {
|
|
109
|
+
const text = input.text.length > left ? `${input.text.slice(0, Math.max(0, left))}\n…[cut here: read ${input.path} with read_file for the rest]` : input.text;
|
|
110
|
+
left = Math.max(0, left - input.text.length);
|
|
111
|
+
return `### ${input.path}\n${text}`;
|
|
112
|
+
});
|
|
113
|
+
return `${prompt}\n\nThe inputs named above, inline (the files are also there for your tools):\n\n${parts.join('\n\n')}`;
|
|
114
|
+
}
|
|
86
115
|
/** One tool call, inside the allowed roots only; the trace names tools as the CLI judges do (Read, Grep, Glob, Bash). */
|
|
87
116
|
async function runTool(call, o) {
|
|
88
117
|
const name = String(call?.function?.name ?? '');
|
|
@@ -78,6 +78,27 @@ describe('the API judge', () => {
|
|
|
78
78
|
const slow = (async (_u, init) => new Promise((_r, reject) => init.signal.addEventListener('abort', () => reject(new Error('aborted')))));
|
|
79
79
|
expect(await runApiJudge('p', options(slow, { timeoutMs: 50 }))).toMatchObject({ exitCode: 1, stderr: expect.stringContaining('request failed') });
|
|
80
80
|
});
|
|
81
|
+
it('fails closed on an empty final message, saying why, and echoes back only what the API needs to continue', async () => {
|
|
82
|
+
const empty = (async () => new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: '', reasoning: 'thinking…' }, finish_reason: 'length' }], usage: {} }), { status: 200 }));
|
|
83
|
+
expect(await runApiJudge('p', options(empty))).toMatchObject({ exitCode: 1, stderr: expect.stringContaining('an empty answer on turn 1 (finish_reason length)') });
|
|
84
|
+
const providerError = (async () => new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: '' }, finish_reason: 'error', error: { message: 'Provider returned error', code: 502 } }], usage: {} }), { status: 200 }));
|
|
85
|
+
expect(await runApiJudge('p', options(providerError))).toMatchObject({ exitCode: 1, stderr: expect.stringContaining('the API reported an error: {"message":"Provider returned error","code":502}') });
|
|
86
|
+
const { fetchImpl, seen } = model([{ tools: [{ name: 'list_dir', args: { path: '.' } }] }, { text: 'ok' }]);
|
|
87
|
+
await runApiJudge('p', options(fetchImpl));
|
|
88
|
+
const echoed = seen[1].messages.find((m) => m.role === 'assistant');
|
|
89
|
+
expect(Object.keys(echoed).sort()).toEqual(['content', 'role', 'tool_calls']); // no reasoning prose sent back
|
|
90
|
+
expect(seen[0].max_tokens).toBe(32000);
|
|
91
|
+
});
|
|
92
|
+
it('gives the judge its inputs inline, smallest first, the diff cut with a note when the budget runs out', async () => {
|
|
93
|
+
const { fetchImpl, seen } = model([{ text: 'ok' }]);
|
|
94
|
+
const inputs = [{ path: '/w/full.diff', text: 'x'.repeat(300_000) }, { path: '/w/pr-description.md', text: 'the description' }, { path: '/w/previous-reviews.md', text: 'the reviews' }];
|
|
95
|
+
await runApiJudge('review this', options(fetchImpl, { inputs }));
|
|
96
|
+
const first = seen[0].messages[1].content;
|
|
97
|
+
expect(first.startsWith('review this\n\nThe inputs named above, inline')).toBe(true);
|
|
98
|
+
expect(first.indexOf('### /w/previous-reviews.md')).toBeLessThan(first.indexOf('### /w/full.diff')); // smallest first
|
|
99
|
+
expect(first).toContain('…[cut here: read /w/full.diff with read_file for the rest]');
|
|
100
|
+
expect(first.length).toBeLessThan(241_000);
|
|
101
|
+
});
|
|
81
102
|
it('passes the reasoning effort when asked', async () => {
|
|
82
103
|
const { fetchImpl, seen } = model([{ text: 'ok' }]);
|
|
83
104
|
await runApiJudge('p', options(fetchImpl, { reasoning: 'low' }));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Exec } from './exec.js';
|
|
2
|
+
import type { Approval } from './verdict.js';
|
|
2
3
|
export interface PullRequest {
|
|
3
4
|
number: number;
|
|
4
5
|
state: 'open' | 'closed' | 'merged';
|
|
@@ -12,6 +13,8 @@ export interface HumanReviews {
|
|
|
12
13
|
/** Changes when a review or comment is added or edited: part of the verdict fingerprint. */
|
|
13
14
|
key: string;
|
|
14
15
|
count: number;
|
|
16
|
+
/** Each person's approval, in time order: every point that person raised before it is settled. */
|
|
17
|
+
approvals: Approval[];
|
|
15
18
|
/** `<login>, <date> (<n> reviews)` of the latest, for the report. */
|
|
16
19
|
label?: string;
|
|
17
20
|
}
|
|
@@ -87,18 +87,24 @@ export async function humanReviews(gh, pr, reviewsBefore) {
|
|
|
87
87
|
return { error: `could not read the comments of pull request ${pr.number}: ${comments.stderr.trim().slice(0, 200)}` };
|
|
88
88
|
const human = (x) => isHuman(x, pr.author);
|
|
89
89
|
const before = (at) => !reviewsBefore || (typeof at === 'string' && at < reviewsBefore);
|
|
90
|
-
const
|
|
90
|
+
const worded = (r) => !!String(r.body ?? '').trim();
|
|
91
|
+
// A review with words carries points. An approval without any carries none, and still settles every point its
|
|
92
|
+
// author raised before it: left out, the judge would see the points and never the approval that closed them.
|
|
93
|
+
const rounds = parseJsonArrays(reviews.stdout).filter((r) => human(r) && (worded(r) || r.state === 'APPROVED') && before(r.submitted_at));
|
|
91
94
|
const inline = parseJsonArrays(comments.stdout).filter((c) => human(c) && before(c.created_at));
|
|
92
|
-
|
|
95
|
+
const approvals = rounds.filter((r) => r.state === 'APPROVED').map((r) => ({ login: String(r.user.login), at: String(r.submitted_at), commit: String(r.commit_id ?? '') }));
|
|
96
|
+
let markdown = rounds.map((r) => `## Review by ${r.user.login}, ${r.submitted_at}, ${r.state} (on ${String(r.commit_id ?? '').slice(0, 9)})\n\n${worded(r) ? String(r.body).trim() : `(approved without comment: every point ${r.user.login} raised before this is settled)`}\n`).join('\n');
|
|
93
97
|
if (inline.length)
|
|
94
98
|
markdown += `\n## Inline comments\n${inline.map((c) => `- ${c.created_at} ${c.path}:${c.line ?? c.original_line ?? '?'}: ${String(c.body ?? '').trim()}`).join('\n')}\n`;
|
|
95
99
|
const latest = rounds.at(-1);
|
|
100
|
+
const count = rounds.filter(worded).length;
|
|
96
101
|
return {
|
|
97
102
|
reviews: {
|
|
98
103
|
markdown: markdown || 'none\n',
|
|
99
104
|
key: [...rounds.map((r) => `${r.id},${r.submitted_at},${r.commit_id},${r.state}`), ...inline.map((c) => `${c.id},${c.updated_at}`)].join('|'),
|
|
100
|
-
count
|
|
101
|
-
|
|
105
|
+
count,
|
|
106
|
+
approvals,
|
|
107
|
+
...(latest ? { label: `${latest.user.login}, ${latest.submitted_at} (${count} review${count === 1 ? '' : 's'})` } : {}),
|
|
102
108
|
},
|
|
103
109
|
};
|
|
104
110
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { humanReviews } from './inputs.js';
|
|
3
|
+
const pr = { number: 7, state: 'open', draft: false, author: 'author', body: '' };
|
|
4
|
+
const reviews = [
|
|
5
|
+
{ id: 1, user: { login: 'senior', type: 'User' }, body: 'Keep a separate case for a visitor with no account.', state: 'CHANGES_REQUESTED', submitted_at: '2026-09-25T18:09:11Z', commit_id: 'a0a0a0a0a0aa' },
|
|
6
|
+
{ id: 2, user: { login: 'senior', type: 'User' }, body: '', state: 'APPROVED', submitted_at: '2026-09-28T15:12:53Z', commit_id: 'b1b1b1b1b1bb' },
|
|
7
|
+
{ id: 3, user: { login: 'author', type: 'User' }, body: '', state: 'APPROVED', submitted_at: '2026-09-28T16:00:00Z', commit_id: 'b1b1b1b1b1bb' },
|
|
8
|
+
];
|
|
9
|
+
const gh = async (args) => ({ exitCode: 0, stdout: JSON.stringify(args[1].endsWith('/reviews') ? reviews : []), stderr: '' });
|
|
10
|
+
describe('the human reviews a judge reads', () => {
|
|
11
|
+
it('keeps an approval without words: it settles the points its author raised before it', async () => {
|
|
12
|
+
const { reviews: read } = await humanReviews(gh, pr, undefined);
|
|
13
|
+
expect(read.markdown).toContain('## Review by senior, 2026-09-25T18:09:11Z, CHANGES_REQUESTED (on a0a0a0a0a)');
|
|
14
|
+
expect(read.markdown).toContain('## Review by senior, 2026-09-28T15:12:53Z, APPROVED (on b1b1b1b1b)\n\n(approved without comment: every point senior raised before this is settled)');
|
|
15
|
+
expect(read.approvals).toEqual([{ login: 'senior', at: '2026-09-28T15:12:53Z', commit: 'b1b1b1b1b1bb' }]); // the author's own approval is not a review
|
|
16
|
+
expect(read.count).toBe(1); // reviews with points: the judge must answer those, and an approval has none
|
|
17
|
+
expect(read.label).toBe('senior, 2026-09-28T15:12:53Z (1 review)');
|
|
18
|
+
});
|
|
19
|
+
it('hides the approval like any review from the reviewed moment on', async () => {
|
|
20
|
+
const { reviews: read } = await humanReviews(gh, pr, '2026-09-28T15:12:53Z');
|
|
21
|
+
expect(read.approvals).toEqual([]);
|
|
22
|
+
expect(read.markdown).not.toContain('APPROVED');
|
|
23
|
+
const later = await humanReviews(gh, pr, '2026-09-28T15:12:54.000Z');
|
|
24
|
+
expect(later.reviews.approvals).toHaveLength(1);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -39,7 +39,13 @@ Do these steps in order. Report only what you verified in the code, with file:li
|
|
|
39
39
|
description. Give each point the severity its reviewer gave it. For a point you say is NOT
|
|
40
40
|
resolved, give file, line and quote: the code at this commit that shows it is still open, copied
|
|
41
41
|
exactly. Rigour checks the quote; a point you cannot show still open that way is not open. Check the
|
|
42
|
-
code at THIS commit: a later commit may already have done what the point asked.
|
|
42
|
+
code at THIS commit: a later commit may already have done what the point asked. A reviewer's
|
|
43
|
+
APPROVED review settles every point that reviewer raised before it, with or without words: report
|
|
44
|
+
such a point resolved, citing the approval; the same class coming back in code written after the
|
|
45
|
+
approved commit is a finding of your own (step 11), with its own input and consequence. When a point
|
|
46
|
+
asks for something to exist (a test case, a guard, a call) and you say it is still missing, search
|
|
47
|
+
the WHOLE checkout for it, not only the file the point names: a reply or a later commit may have put
|
|
48
|
+
it elsewhere. Give absent: the exact code or test text you searched for. Rigour searches too.
|
|
43
49
|
|
|
44
50
|
2. Redundancy. A fix often leaves behind what it made unnecessary. For every hunk in the reviewed
|
|
45
51
|
range that moves a condition into a query (.not, .gte, .lte, .gt, .lt, .in, .like, .eq added),
|
|
@@ -152,7 +158,7 @@ and no material cost is an opinion: leave consequence empty and it is shown, nev
|
|
|
152
158
|
report style preferences or trade-offs you would not request changes for.
|
|
153
159
|
|
|
154
160
|
Your final message must be ONLY this JSON, starting with { and ending with }, nothing before or after it:
|
|
155
|
-
{"prior_points":[{"point":"...","review":"<login> <submitted_at>","severity":"blocking"|"should-fix"|"non-blocking","resolved":true|false,"evidence":"file:line ...","file":"<when not resolved>","line":0,"quote":"<when not resolved: the code that shows it still open>","checked_siblings":["file:line"]}],
|
|
161
|
+
{"prior_points":[{"point":"...","review":"<login> <submitted_at>","severity":"blocking"|"should-fix"|"non-blocking","resolved":true|false,"evidence":"file:line ...","file":"<when not resolved>","line":0,"quote":"<when not resolved: the code that shows it still open>","absent":"<when not resolved because something is missing: the exact text you searched the checkout for>","checked_siblings":["file:line"]}],
|
|
156
162
|
"redundant":[{"file":"...","line":0,"what":"...","made_redundant_by":"file:line","removed":true|false}],
|
|
157
163
|
"reads":[{"file":"...","line":0,"read":"...","rules":[{"rule":"...","known_before_read":true|false,"applied_before_read":true|false}],"consumer":{"file":"...","line":0,"uses":"ids-only"|"rows"|"aggregate"},"narrower_source":null|"...","keys":[{"name":"...","inputs":"...","stable_under_edit":true|false}],"window_bounded":true|false|null,"keyset":true|false|null,"index":"..."}],
|
|
158
164
|
"scans":[{"file":"...","line":0,"function":"...","outer":"...","inner":"...","fix":"..."}],
|
|
@@ -11,7 +11,27 @@ export interface PriorPoint {
|
|
|
11
11
|
file?: string;
|
|
12
12
|
line?: number;
|
|
13
13
|
quote?: string;
|
|
14
|
+
absent?: string;
|
|
15
|
+
}
|
|
16
|
+
/** A person's approval of the pull request: every point they raised before `at` is settled. */
|
|
17
|
+
export interface Approval {
|
|
18
|
+
login: string;
|
|
19
|
+
at: string;
|
|
20
|
+
commit?: string;
|
|
14
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* What an item is checked against besides its quote: who approved (a prior point), whether the checkout has what a
|
|
24
|
+
* point calls missing, and which lines the change touched (a block must sit on one; unknown when absent).
|
|
25
|
+
*/
|
|
26
|
+
export interface PriorChecks {
|
|
27
|
+
approvals: Approval[];
|
|
28
|
+
inCheckout: (text: string) => string | undefined;
|
|
29
|
+
changed?: ChangedLines;
|
|
30
|
+
}
|
|
31
|
+
/** The lines a change touched, per file: every line added, and the place of every deletion, in the new file's numbering. */
|
|
32
|
+
export type ChangedLines = Map<string, Set<number>>;
|
|
33
|
+
/** The touched lines of a unified diff (`git diff base...head`). */
|
|
34
|
+
export declare function changedLinesOf(diff: string): ChangedLines;
|
|
15
35
|
interface Redundant {
|
|
16
36
|
file: string;
|
|
17
37
|
line?: number;
|
|
@@ -195,6 +215,11 @@ export type Verify = (file: string, line: number | undefined, quote?: string) =>
|
|
|
195
215
|
* that is not where the judge says it is never blocks, whichever model made it.
|
|
196
216
|
*/
|
|
197
217
|
export declare function checkoutVerifier(cwd: string): Verify;
|
|
218
|
+
/**
|
|
219
|
+
* Searches the whole checkout for a line of text (the first non-empty line of `text`, whitespace aside): `file:line` of
|
|
220
|
+
* the first place it is, or undefined. A `git grep` so the tracked tree is searched and nothing else.
|
|
221
|
+
*/
|
|
222
|
+
export declare function checkoutSearch(cwd: string): (text: string) => string | undefined;
|
|
198
223
|
/** The verdict in a reviewer's answer, or why it is not one. `needsPriorPoints`: a human review exists and none of its points is carried. */
|
|
199
224
|
export declare function parseVerdict(text: string, needsPriorPoints: boolean, reviewer: string, spend: Spend): {
|
|
200
225
|
verdict: Verdict;
|
|
@@ -229,7 +254,7 @@ export interface Accounting {
|
|
|
229
254
|
advisory: OpenItem[];
|
|
230
255
|
}
|
|
231
256
|
/** Everything the reviewers reported blocks; in delta mode, previous open items carry unless resolved with evidence. */
|
|
232
|
-
export declare function account(verdict: Verdict, previousOpen: OpenItem[] | undefined, verify: Verify): Accounting;
|
|
257
|
+
export declare function account(verdict: Verdict, previousOpen: OpenItem[] | undefined, verify: Verify, prior?: PriorChecks): Accounting;
|
|
233
258
|
export declare function itemLine(item: OpenItem): string;
|
|
234
259
|
/** The rules Rigour served, onto the judge's answers by id: an answer naming no served rule is dropped. */
|
|
235
260
|
export declare function attachServedRules(verdict: Verdict, served: ServedRule[]): void;
|
|
@@ -7,10 +7,55 @@
|
|
|
7
7
|
* checked against the checkout before it can block: a file not in the repository, or a line past
|
|
8
8
|
* its end, is a reviewer's slip, reported apart and never a block.
|
|
9
9
|
*/
|
|
10
|
+
import { execFileSync } from 'child_process';
|
|
10
11
|
import { createHash } from 'crypto';
|
|
11
12
|
import fs from 'fs';
|
|
12
13
|
import path from 'path';
|
|
13
14
|
import { textSimilarity } from './consensus.js';
|
|
15
|
+
const NO_PRIOR_CHECKS = { approvals: [], inCheckout: () => undefined };
|
|
16
|
+
/** The touched lines of a unified diff (`git diff base...head`). */
|
|
17
|
+
export function changedLinesOf(diff) {
|
|
18
|
+
const changed = new Map();
|
|
19
|
+
let file;
|
|
20
|
+
let line = 0;
|
|
21
|
+
for (const raw of diff.split('\n')) {
|
|
22
|
+
if (raw.startsWith('diff --git ') || /^--- (a\/|\/dev\/null)/.test(raw)) {
|
|
23
|
+
file = undefined; // headers, until the new file's name
|
|
24
|
+
}
|
|
25
|
+
else if (/^\+\+\+ (b\/|\/dev\/null)/.test(raw)) {
|
|
26
|
+
const name = raw.slice(4).replace(/^b\//, '').replace(/\t.*$/, '');
|
|
27
|
+
file = name === '/dev/null' ? undefined : (changed.get(name) ?? new Set());
|
|
28
|
+
if (file)
|
|
29
|
+
changed.set(name, file);
|
|
30
|
+
}
|
|
31
|
+
else if (raw.startsWith('@@ ')) {
|
|
32
|
+
line = Number(/\+(\d+)/.exec(raw)?.[1] ?? 0);
|
|
33
|
+
}
|
|
34
|
+
else if (raw.startsWith('+')) {
|
|
35
|
+
file?.add(line);
|
|
36
|
+
line++;
|
|
37
|
+
}
|
|
38
|
+
else if (raw.startsWith('-')) {
|
|
39
|
+
file?.add(line);
|
|
40
|
+
}
|
|
41
|
+
else if (raw.startsWith(' ') || raw === '') {
|
|
42
|
+
line++;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return changed;
|
|
46
|
+
}
|
|
47
|
+
/** Whether a line of a file is within CHANGED_WINDOW lines of one the change touched. */
|
|
48
|
+
function nearChanged(changed, file, line) {
|
|
49
|
+
const lines = changed.get(file);
|
|
50
|
+
if (!lines)
|
|
51
|
+
return false;
|
|
52
|
+
for (let l = line - CHANGED_WINDOW; l <= line + CHANGED_WINDOW; l++)
|
|
53
|
+
if (lines.has(l))
|
|
54
|
+
return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/** How far from a touched line a block may sit: a judge names the statement, the diff names the line. */
|
|
58
|
+
const CHANGED_WINDOW = 3;
|
|
14
59
|
/**
|
|
15
60
|
* A file in the checkout, and a line it has: an item naming anything else is a reviewer's slip. With a quote, the
|
|
16
61
|
* quoted code must also be there, within QUOTE_WINDOW lines of the line named (whitespace aside): a claim about code
|
|
@@ -44,6 +89,34 @@ export function checkoutVerifier(cwd) {
|
|
|
44
89
|
}
|
|
45
90
|
/** How far from the line it names a finding's quote may sit: judges count lines loosely. */
|
|
46
91
|
const QUOTE_WINDOW = 3;
|
|
92
|
+
/** The approval, if any, the point's own reviewer gave after raising it (`review` reads `<login> <submitted_at>`). */
|
|
93
|
+
function settledBy(point, approvals) {
|
|
94
|
+
const [login, ...rest] = (point.review ?? '').trim().split(/\s+/);
|
|
95
|
+
if (!login)
|
|
96
|
+
return undefined;
|
|
97
|
+
const at = rest.join(' ');
|
|
98
|
+
return approvals.find(a => a.login === login && (!at || a.at > at));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Searches the whole checkout for a line of text (the first non-empty line of `text`, whitespace aside): `file:line` of
|
|
102
|
+
* the first place it is, or undefined. A `git grep` so the tracked tree is searched and nothing else.
|
|
103
|
+
*/
|
|
104
|
+
export function checkoutSearch(cwd) {
|
|
105
|
+
return text => {
|
|
106
|
+
const line = text.split('\n').map(l => l.trim()).find(Boolean);
|
|
107
|
+
if (!line)
|
|
108
|
+
return undefined;
|
|
109
|
+
try {
|
|
110
|
+
const out = execFileSync('git', ['grep', '-n', '-I', '-F', '-e', line, '--', '.'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 });
|
|
111
|
+
const hit = out.split('\n').find(Boolean);
|
|
112
|
+
const m = hit?.match(/^(.*?):(\d+):/);
|
|
113
|
+
return m ? `${m[1]}:${m[2]}` : undefined;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
47
120
|
/** How alike a finding and a point a human accepted as non-blocking must read to be that point again. */
|
|
48
121
|
const ACCEPTED_SIMILARITY = 0.4;
|
|
49
122
|
/** The reviewer's working steps: shown so a person can follow the reasoning, never a block on their own. */
|
|
@@ -135,7 +208,7 @@ export function evidenceTouched(evidence, touched) {
|
|
|
135
208
|
}
|
|
136
209
|
const id = (...parts) => createHash('sha1').update(parts.map(p => String(p ?? '')).join('|').toLowerCase().replace(/\s+/g, ' ')).digest('hex').slice(0, 10);
|
|
137
210
|
/** Everything the reviewers reported blocks; in delta mode, previous open items carry unless resolved with evidence. */
|
|
138
|
-
export function account(verdict, previousOpen, verify) {
|
|
211
|
+
export function account(verdict, previousOpen, verify, prior = NO_PRIOR_CHECKS) {
|
|
139
212
|
const open = [];
|
|
140
213
|
const unverified = [];
|
|
141
214
|
const notes = [];
|
|
@@ -159,17 +232,45 @@ export function account(verdict, previousOpen, verify) {
|
|
|
159
232
|
if (WORKING_NOTES.has(item.kind))
|
|
160
233
|
return void notes.push(item);
|
|
161
234
|
const placed = !!item.file && !!item.quote?.trim() && verify(item.file, item.line, item.quote);
|
|
162
|
-
(placed
|
|
235
|
+
if (!placed)
|
|
236
|
+
return void unverified.push(item);
|
|
237
|
+
// A block is about this change. A finding or rule break in a touched file but on lines the change did not touch is
|
|
238
|
+
// what the code already had: shown as a note, never a block on this change. A human's point is about the change by
|
|
239
|
+
// definition. Without the diff (a judge's own items for a panel, a test) nothing is known and nothing is moved.
|
|
240
|
+
if (item.kind !== 'prior' && prior.changed && (item.line === undefined || !nearChanged(prior.changed, item.file, item.line))) {
|
|
241
|
+
return void notes.push({ ...item, evidence: `${item.evidence ? `${item.evidence}; ` : ''}${item.line === undefined ? 'names no line' : 'on a line this change did not touch'}: what the code already had, never a block on this change` });
|
|
242
|
+
}
|
|
243
|
+
open.push(item);
|
|
163
244
|
};
|
|
164
245
|
const answerInReply = [];
|
|
165
246
|
for (const p of verdict.prior_points) {
|
|
166
247
|
if (p.resolved)
|
|
167
248
|
continue;
|
|
168
|
-
if (p.severity === 'non-blocking')
|
|
249
|
+
if (p.severity === 'non-blocking') {
|
|
169
250
|
answerInReply.push(p);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const item = { id: id('prior', p.review, p.point), kind: 'prior', class: 'prior point', issue: p.point, evidence: p.evidence, reviewer: p.reviewer, ...(p.file ? { file: p.file } : {}), ...(p.line ? { line: p.line } : {}), ...(p.quote ? { quote: p.quote } : {}) };
|
|
254
|
+
// The person who raised it approved afterwards: settled, whatever the judge makes of the code now. The same class
|
|
255
|
+
// coming back in later code is a finding of the judge's own, with its own input and consequence.
|
|
256
|
+
const approval = settledBy(p, prior.approvals);
|
|
257
|
+
if (approval) {
|
|
258
|
+
if (!seen.has(item.id))
|
|
259
|
+
notes.push({ ...item, evidence: `${approval.login} approved on ${approval.at}, after raising it: settled` });
|
|
260
|
+
seen.add(item.id);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
// A point that says something is still missing names what it searched for, and the checkout is searched for it: the
|
|
264
|
+
// reply or a later commit may have put it in another file than the one the point named.
|
|
265
|
+
const found = p.absent?.trim() ? prior.inCheckout(p.absent) : undefined;
|
|
266
|
+
if (found) {
|
|
267
|
+
if (!seen.has(item.id))
|
|
268
|
+
unverified.push({ ...item, evidence: `says "${p.absent.trim()}" is missing, and the checkout has it at ${found}` });
|
|
269
|
+
seen.add(item.id);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
170
272
|
// Still open only where the judge quotes the code that keeps it open: a point a later commit already fixed never blocks.
|
|
171
|
-
|
|
172
|
-
add({ id: id('prior', p.review, p.point), kind: 'prior', class: 'prior point', issue: p.point, evidence: p.evidence, reviewer: p.reviewer, ...(p.file ? { file: p.file } : {}), ...(p.line ? { line: p.line } : {}), ...(p.quote ? { quote: p.quote } : {}) });
|
|
273
|
+
add(item);
|
|
173
274
|
}
|
|
174
275
|
for (const r of verdict.redundant) {
|
|
175
276
|
if (r.removed === false)
|
package/dist/review/reviewer.js
CHANGED
|
@@ -34,7 +34,7 @@ import { trackUsage } from '../telemetry/telemetry.js';
|
|
|
34
34
|
import { reviewerUsage } from './reviewer/usage.js';
|
|
35
35
|
import { buildContext, dismissedAs, readReviewDismissals, relatedDocs } from './reviewer/context.js';
|
|
36
36
|
import { buildRecord } from './reviewer/record.js';
|
|
37
|
-
import { account, attachServedRules, checkoutVerifier, carryResolved, evidenceTouched, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
37
|
+
import { account, attachServedRules, changedLinesOf, checkoutSearch, checkoutVerifier, carryResolved, evidenceTouched, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
38
38
|
import { judgeUnset } from './reviewer/judge-env.js';
|
|
39
39
|
export { defaultExec, githubEnv, githubToken, parseJsonArrays } from './reviewer/exec.js';
|
|
40
40
|
export { itemLine } from './reviewer/verdict.js';
|
|
@@ -107,7 +107,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
107
107
|
if (skip)
|
|
108
108
|
return none('skipped', skip, { reviewers, pr: pr?.number });
|
|
109
109
|
}
|
|
110
|
-
let reviews = { markdown: 'none\n', key: '', count: 0 };
|
|
110
|
+
let reviews = { markdown: 'none\n', key: '', count: 0, approvals: [] };
|
|
111
111
|
if (gh && pr) {
|
|
112
112
|
const read = await humanReviews(gh, pr, options.reviewsBefore);
|
|
113
113
|
if (read.error)
|
|
@@ -187,6 +187,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
187
187
|
const openFile = store.openPath(verdictFile);
|
|
188
188
|
const previousOpen = scope === 'delta' ? store.readJson(store.openPath(previous.verdict)) ?? [] : undefined;
|
|
189
189
|
const verify = checkoutVerifier(cwd);
|
|
190
|
+
const prior = { approvals: reviews.approvals, inCheckout: checkoutSearch(cwd), changed: changedLinesOf(fullDiff) };
|
|
190
191
|
const modelFor = (name) => settings.models[name] ?? (name === 'claude' ? settings.model : undefined);
|
|
191
192
|
// The record of the review, written beside the verdict once and rebuilt from the same verdict on a cached read.
|
|
192
193
|
const withRecord = (accounted, verdict, cached) => {
|
|
@@ -200,7 +201,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
200
201
|
};
|
|
201
202
|
if (!options.force && fs.existsSync(verdictFile) && fs.existsSync(openFile)) {
|
|
202
203
|
const verdict = store.readJson(verdictFile);
|
|
203
|
-
return withRecord(decide(verdict, previousOpen, verify, dismissals), verdict, true);
|
|
204
|
+
return withRecord(decide(verdict, previousOpen, verify, prior, dismissals), verdict, true);
|
|
204
205
|
}
|
|
205
206
|
// The daily caps, before any judge starts: a cached or reused verdict above cost nothing and never reaches here.
|
|
206
207
|
const over = overBudget(store.spend(), settings, reviewers.length);
|
|
@@ -208,8 +209,9 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
208
209
|
return none(settings.required.panel || settings.required.mode ? 'unavailable' : 'skipped', over, { reviewers, scope, why, pr: pr?.number });
|
|
209
210
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-reviewer-'));
|
|
210
211
|
// One judge run, by CLI or by API: the same prompt, the same cost accounting, the same trace.
|
|
212
|
+
let inlineInputs = [];
|
|
211
213
|
const runJudge = (name, prompt, model) => name === 'api'
|
|
212
|
-
? runApiJudge(prompt, { url: settings.api.url, model: settings.api.model, key: process.env[settings.api.key_env] ?? '', maxTurns: settings.api.max_turns, timeoutMs: settings.timeout_ms, cwd, roots: [cwd, work], ...(settings.reasoning[name] ? { reasoning: settings.reasoning[name] } : {}), ...(options.fetch ? { fetchImpl: options.fetch } : {}) })
|
|
214
|
+
? runApiJudge(prompt, { url: settings.api.url, model: settings.api.model, key: process.env[settings.api.key_env] ?? '', maxTurns: settings.api.max_turns, timeoutMs: settings.timeout_ms, cwd, roots: [cwd, work], inputs: inlineInputs, ...(settings.reasoning[name] ? { reasoning: settings.reasoning[name] } : {}), ...(options.fetch ? { fetchImpl: options.fetch } : {}) })
|
|
213
215
|
: exec(installed.get(name).binary, ADAPTERS[name].args(prompt, model, { reasoning: settings.reasoning[name] }), { cwd, timeoutMs: settings.timeout_ms, unset: judgeUnset(name, settings.judge_env) });
|
|
214
216
|
try {
|
|
215
217
|
const file = (name, text) => {
|
|
@@ -223,6 +225,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
223
225
|
const diffFile = file('full.diff', fullDiff);
|
|
224
226
|
const contextFile = file('team-knowledge.md', context.text);
|
|
225
227
|
const hintsFile = file('hints.txt', options.hints?.trim() || 'none\n');
|
|
228
|
+
inlineInputs = [[reviewsFile, reviews.markdown], [prBodyFile, body], [diffstatFile, await git(['diff', '--stat', `${baseSha}...HEAD`])], [diffFile, fullDiff], [contextFile, context.text], [hintsFile, options.hints?.trim() || 'none\n']].map(([p, text]) => ({ path: p, text }));
|
|
226
229
|
let delta = '';
|
|
227
230
|
// A reviewer must report on the human reviews, unless every point was settled by the previous verdict and is carried.
|
|
228
231
|
let needsPriorPoints = reviews.count > 0;
|
|
@@ -258,13 +261,29 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
258
261
|
return { run, answer, verdict: run.exitCode === 0 || answer.text.trim() ? parseVerdict(answer.text, needsPriorPoints, name, answer) : undefined };
|
|
259
262
|
};
|
|
260
263
|
let first = await ask();
|
|
261
|
-
//
|
|
262
|
-
if (first.verdict
|
|
263
|
-
progress(`Rigour reviewer: ${name} gave no valid verdict; asking once more`);
|
|
264
|
+
// No verdict, whether a malformed answer or a run that died, is a slip, not a decision: asked once more, inside the caps.
|
|
265
|
+
if ((!first.verdict || 'error' in first.verdict) && !overBudget(store.spend(), settings, 1)) {
|
|
266
|
+
progress(`Rigour reviewer: ${name} gave no ${first.verdict ? 'valid verdict' : 'answer'}; asking once more`);
|
|
264
267
|
first = await ask();
|
|
265
268
|
}
|
|
266
269
|
return first.verdict ?? { error: `${name}: no answer (exit ${first.run.exitCode}): ${first.run.stderr.trim().slice(-200)}` };
|
|
267
270
|
}));
|
|
271
|
+
// A judge that gives nothing is replaced by the next one installed, so the boundary stays up: a review ends unavailable only when every judge failed.
|
|
272
|
+
const spare = candidates.filter(c => installed.has(c) && !reviewers.includes(c));
|
|
273
|
+
for (let i = 0; i < answers.length; i++) {
|
|
274
|
+
let answer = answers[i];
|
|
275
|
+
while ('error' in answer && spare.length && !overBudget(store.spend(), settings, 1)) {
|
|
276
|
+
const next = spare.shift();
|
|
277
|
+
progress(`Rigour reviewer: ${reviewers[i]} gave no verdict (${answer.error}); ${next} judges instead`);
|
|
278
|
+
modeRecord = { ...modeRecord, degraded: `${modeRecord.degraded ? `${modeRecord.degraded}; ` : ''}${reviewers[i]} gave no verdict, ${next} judged instead` };
|
|
279
|
+
reviewers[i] = next;
|
|
280
|
+
const run = await runJudge(next, prompt, modelFor(next));
|
|
281
|
+
const got = ADAPTERS[next].answer(run.stdout);
|
|
282
|
+
store.addSpend(1, got.costUsd);
|
|
283
|
+
answer = run.exitCode === 0 || got.text.trim() ? parseVerdict(got.text, needsPriorPoints, next, got) : { error: `${next}: no answer (exit ${run.exitCode}): ${run.stderr.trim().slice(-200)}` };
|
|
284
|
+
}
|
|
285
|
+
answers[i] = answer;
|
|
286
|
+
}
|
|
268
287
|
const failed = answers.find(a => 'error' in a);
|
|
269
288
|
if (failed && 'error' in failed)
|
|
270
289
|
return none('unavailable', failed.error, { reviewers, scope, why, pr: pr?.number });
|
|
@@ -316,7 +335,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
316
335
|
});
|
|
317
336
|
verdict = { ...verdict, panel: { judgeItemIds: judgeItems.flat().map(item => item.id), items }, reviewers: [...(verdict.reviewers ?? []), ...cross] };
|
|
318
337
|
}
|
|
319
|
-
const accounted = decide(verdict, previousOpen, verify, dismissals);
|
|
338
|
+
const accounted = decide(verdict, previousOpen, verify, prior, dismissals);
|
|
320
339
|
store.writeJson(verdictFile, { ...verdict, inputs: { head, base: baseSha, scope, why, mode: modeRecord, reviewers, versions: reviewerVersions, authors: [...authors], fingerprint, human_reviews: reviews.count, reviews_before: options.reviewsBefore ?? null, since: previous?.head ?? null, at: new Date().toISOString() } });
|
|
321
340
|
store.writeJson(openFile, accounted.open);
|
|
322
341
|
store.writeJson(store.decidedPath(verdictFile), accounted);
|
|
@@ -329,8 +348,8 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
329
348
|
}
|
|
330
349
|
}
|
|
331
350
|
/** The accounting a verdict leads to: with a panel, only what it confirmed blocks; a finding the team dismissed never blocks. */
|
|
332
|
-
function decide(verdict, previousOpen, verify, dismissals) {
|
|
333
|
-
const accounted = account(verdict, previousOpen, verify);
|
|
351
|
+
function decide(verdict, previousOpen, verify, prior, dismissals) {
|
|
352
|
+
const accounted = account(verdict, previousOpen, verify, prior);
|
|
334
353
|
// An item an earlier round confirmed stays open until it is resolved with evidence, whatever this panel says of it.
|
|
335
354
|
const earlier = new Set((previousOpen ?? []).map(item => item.id));
|
|
336
355
|
const decided = verdict.panel
|
|
@@ -8,7 +8,7 @@ import { reviewerBlocks, runReviewer } from './reviewer.js';
|
|
|
8
8
|
import { dismissReviewerFinding } from './reviewer/context.js';
|
|
9
9
|
import { reviewStatus } from './reviewer/background.js';
|
|
10
10
|
import { selectReviewers, vendorsOf } from './reviewer/adapters.js';
|
|
11
|
-
import { account, attachServedRules, carryResolved, checkoutVerifier, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
11
|
+
import { account, attachServedRules, carryResolved, changedLinesOf, checkoutSearch, checkoutVerifier, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
12
12
|
import { recordIntact } from './reviewer/record.js';
|
|
13
13
|
let repo;
|
|
14
14
|
const config = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'] } } });
|
|
@@ -170,7 +170,7 @@ describe('the reviewer', () => {
|
|
|
170
170
|
expect(hidden.outcome).toBe('passed');
|
|
171
171
|
const shown = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [] }), seen), () => undefined, { pr: 42, reviewsBefore: '2026-10-04', force: true });
|
|
172
172
|
expect(seen.files['previous-reviews.md']).toContain('Review by senior');
|
|
173
|
-
expect(shown).toMatchObject({ outcome: 'unavailable', reason: '
|
|
173
|
+
expect(shown).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('did not report on the human reviews') });
|
|
174
174
|
});
|
|
175
175
|
it('for a backtest, gives the description as it read at the review, never a later edit', async () => {
|
|
176
176
|
const versions = { lastEditedAt: '2026-10-06T00:00:00Z', body: 'today: refunds are issued by the nightly job', userContentEdits: { totalCount: 3, nodes: [
|
|
@@ -200,7 +200,7 @@ describe('the reviewer', () => {
|
|
|
200
200
|
});
|
|
201
201
|
it('never passes without a verdict: a crash, a malformed answer, an unreadable pull request or no installed reviewer', async () => {
|
|
202
202
|
const crashed = await runReviewer(repo, 'main', config, fakes(() => ({ exitCode: 1, stdout: '', stderr: 'API error' }), seenNow()), () => undefined);
|
|
203
|
-
expect(crashed).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('
|
|
203
|
+
expect(crashed).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('cursor: no answer (exit 1)'), mode: { degraded: expect.stringContaining('claude gave no verdict, cursor judged instead') } }); // asked twice, then the spare judge, which failed too
|
|
204
204
|
const prose = await runReviewer(repo, 'main', config, fakes(() => 'Looks good to me!', seenNow()), () => undefined);
|
|
205
205
|
expect(prose).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no valid verdict') });
|
|
206
206
|
const broken = async (command, args, options) => command === 'gh' && args[0] === 'pr' ? { exitCode: 1, stdout: '', stderr: 'HTTP 500' } : fakes(() => '', seenNow())(command, args, options);
|
|
@@ -283,16 +283,34 @@ describe('the reviewer', () => {
|
|
|
283
283
|
delete process.env.TEST_JUDGE_KEY;
|
|
284
284
|
}
|
|
285
285
|
});
|
|
286
|
+
it('replaces a judge that gives nothing with the next one installed, and says so', async () => {
|
|
287
|
+
const seen = seenNow();
|
|
288
|
+
const silent = (async () => new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: '' }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 0 } }), { status: 200 }));
|
|
289
|
+
const twoJudges = ConfigSchema.parse({ version: 1, review: { reviewer: { enabled: true, reviewers: ['api', 'claude'], api: { url: 'https://example.test/v1', model: 'silent-model', key_env: 'TEST_JUDGE_KEY' } } } });
|
|
290
|
+
process.env.TEST_JUDGE_KEY = 'secret';
|
|
291
|
+
try {
|
|
292
|
+
const result = await runReviewer(repo, 'main', twoJudges, fakes(() => JSON.stringify(EMPTY), seen), () => undefined, { fetch: silent, force: true });
|
|
293
|
+
expect(result).toMatchObject({ outcome: 'passed', reviewers: ['claude'], mode: { degraded: expect.stringContaining('api gave no verdict, claude judged instead') } });
|
|
294
|
+
expect(seen.prompts).toHaveLength(1); // claude ran once, after the api judge's two empty answers
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
delete process.env.TEST_JUDGE_KEY;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
286
300
|
it('asks a judge once more after an answer that is not a verdict, and is unavailable only when the second is not one either', async () => {
|
|
287
301
|
const seen = seenNow();
|
|
288
302
|
let calls = 0;
|
|
289
303
|
const slipOnce = await runReviewer(repo, 'main', config, fakes(() => (++calls === 1 ? '{"prior_points":[], "findings":[{"class"' : JSON.stringify(EMPTY)), seen), () => undefined, { force: true });
|
|
290
304
|
expect(slipOnce.outcome).toBe('passed');
|
|
291
305
|
expect(seen.prompts).toHaveLength(2);
|
|
306
|
+
let crashes = 0;
|
|
307
|
+
const crashOnce = seenNow();
|
|
308
|
+
const recovered = await runReviewer(repo, 'main', config, fakes(() => (++crashes === 1 ? { exitCode: 1, stdout: '', stderr: 'API error' } : JSON.stringify(EMPTY)), crashOnce), () => undefined, { force: true });
|
|
309
|
+
expect(recovered.outcome).toBe('passed'); // a run that died is asked once more too
|
|
292
310
|
const twice = seenNow();
|
|
293
311
|
const slipTwice = await runReviewer(repo, 'main', config, fakes(() => 'not json', twice), () => undefined, { force: true });
|
|
294
312
|
expect(slipTwice).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no valid verdict') });
|
|
295
|
-
expect(twice.prompts).toHaveLength(
|
|
313
|
+
expect(twice.prompts).toHaveLength(3); // once more, then the spare judge once: never a loop
|
|
296
314
|
});
|
|
297
315
|
it('says what was asked and that nothing ran when a review ends early, with why a judge is missing', async () => {
|
|
298
316
|
const seen = { ...seenNow(), installed: ['claude'] }; // cursor is listed but not installed
|
|
@@ -308,7 +326,8 @@ describe('the reviewer', () => {
|
|
|
308
326
|
const by = (name) => JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: name === 'claude', evidence: 'src/job.ts:2', file: 'src/job.ts', line: 2, quote: 'export function job() {' }], findings: name === 'cursor' ? [{ class: 'dead-code', file: 'a.ts', line: 1, issue: 'a is unused', consequence: 'a second run reads stale rows', quote: 'export const a = 1;' }] : [] });
|
|
309
327
|
const result = await runReviewer(repo, 'main', config, fakes(by, seen), () => undefined, { full: true });
|
|
310
328
|
expect(result.reviewers).toEqual(['claude', 'cursor']);
|
|
311
|
-
expect(result.items.map(i => [i.kind, i.reviewer])).toEqual([['prior', 'cursor']
|
|
329
|
+
expect(result.items.map(i => [i.kind, i.reviewer])).toEqual([['prior', 'cursor']]);
|
|
330
|
+
expect(result.notes.map(i => [i.kind, i.file, i.reviewer])).toEqual([['finding', 'a.ts', 'cursor']]); // a.ts is not in the change: what the code already had
|
|
312
331
|
expect(seen.prompts).toHaveLength(2);
|
|
313
332
|
});
|
|
314
333
|
});
|
|
@@ -607,3 +626,80 @@ describe('what the team already knows', () => {
|
|
|
607
626
|
expect(result.notes).toEqual([]);
|
|
608
627
|
});
|
|
609
628
|
});
|
|
629
|
+
describe("a human's prior point", () => {
|
|
630
|
+
const point = (over) => ({ point: 'keep a separate case for a visitor with no account', review: 'senior 2026-09-25T18:09:11Z', severity: 'blocking', resolved: false, evidence: 'tests/e2e/gate.ts:137', file: 'tests/e2e/gate.ts', line: 137, quote: 'expect(href).toMatch(/account_id=/)', ...over });
|
|
631
|
+
const verdict = (p) => ({ ...EMPTY, prior_points: [p] });
|
|
632
|
+
const approvals = [{ login: 'senior', at: '2026-09-28T15:12:53Z' }];
|
|
633
|
+
it('blocks where the judge quotes the code that keeps it open and no one approved since', () => {
|
|
634
|
+
const { open } = account(verdict(point({})), undefined, () => true, { approvals: [], inCheckout: () => undefined });
|
|
635
|
+
expect(open.map(i => i.issue)).toEqual(['keep a separate case for a visitor with no account']);
|
|
636
|
+
});
|
|
637
|
+
it('is settled by its own reviewer approving after raising it: a note, never a block', () => {
|
|
638
|
+
const { open, notes } = account(verdict(point({})), undefined, () => true, { approvals, inCheckout: () => undefined });
|
|
639
|
+
expect(open).toEqual([]);
|
|
640
|
+
expect(notes).toMatchObject([{ kind: 'prior', issue: 'keep a separate case for a visitor with no account', evidence: 'senior approved on 2026-09-28T15:12:53Z, after raising it: settled' }]);
|
|
641
|
+
});
|
|
642
|
+
it('is not settled by an approval before it, by another person, or when the judge names no reviewer', () => {
|
|
643
|
+
const before = account(verdict(point({})), undefined, () => true, { approvals: [{ login: 'senior', at: '2026-09-20T00:00:00Z' }], inCheckout: () => undefined });
|
|
644
|
+
const other = account(verdict(point({})), undefined, () => true, { approvals: [{ login: 'peer', at: '2026-09-28T15:12:53Z' }], inCheckout: () => undefined });
|
|
645
|
+
const unnamed = account(verdict(point({ review: undefined })), undefined, () => true, { approvals, inCheckout: () => undefined });
|
|
646
|
+
for (const result of [before, other, unnamed])
|
|
647
|
+
expect(result.open).toHaveLength(1);
|
|
648
|
+
const undated = account(verdict(point({ review: 'senior' })), undefined, () => true, { approvals, inCheckout: () => undefined });
|
|
649
|
+
expect(undated.open).toEqual([]); // the reviewer named and approved: settled
|
|
650
|
+
});
|
|
651
|
+
it('that calls something missing is unverified when the checkout has it elsewhere', () => {
|
|
652
|
+
const searched = [];
|
|
653
|
+
const found = account(verdict(point({ absent: 'origin=native&returnTo=' })), undefined, () => true, { approvals: [], inCheckout: text => (searched.push(text), 'src/lib/Upsell.test.ts:128') });
|
|
654
|
+
expect(searched).toEqual(['origin=native&returnTo=']);
|
|
655
|
+
expect(found.open).toEqual([]);
|
|
656
|
+
expect(found.unverified).toMatchObject([{ kind: 'prior', evidence: 'says "origin=native&returnTo=" is missing, and the checkout has it at src/lib/Upsell.test.ts:128' }]);
|
|
657
|
+
const missing = account(verdict(point({ absent: 'origin=native&returnTo=' })), undefined, () => true, { approvals: [], inCheckout: () => undefined });
|
|
658
|
+
expect(missing.open).toHaveLength(1); // searched, not there: the point stands on its quote
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
describe('searching the checkout for what a point calls missing', () => {
|
|
662
|
+
it('finds the first line of the text anywhere in the tracked tree, and nothing untracked', () => {
|
|
663
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-search-'));
|
|
664
|
+
execFileSync('git', ['-C', dir, 'init', '-q']);
|
|
665
|
+
fs.mkdirSync(path.join(dir, 'src'));
|
|
666
|
+
fs.writeFileSync(path.join(dir, 'src', 'a.test.ts'), 'it("no account", () => {\n expect(href).toBe("/checkout?origin=native");\n});\n');
|
|
667
|
+
fs.writeFileSync(path.join(dir, 'untracked.ts'), 'const ghost = 1;\n');
|
|
668
|
+
execFileSync('git', ['-C', dir, 'add', 'src']);
|
|
669
|
+
const search = checkoutSearch(dir);
|
|
670
|
+
expect(search(' expect(href).toBe("/checkout?origin=native");\n more')).toBe('src/a.test.ts:2');
|
|
671
|
+
expect(search('const ghost = 1;')).toBeUndefined();
|
|
672
|
+
expect(search(' \n')).toBeUndefined();
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
describe('a block sits on a line the change touched', () => {
|
|
676
|
+
const diff = [
|
|
677
|
+
'diff --git a/src/player.ts b/src/player.ts', '--- a/src/player.ts', '+++ b/src/player.ts',
|
|
678
|
+
'@@ -10,4 +10,5 @@ function resume() {', ' const a = 1;', '- old();', '+ report(a);', '+ report(b);', ' return a;', ' }',
|
|
679
|
+
'diff --git a/src/gone.ts b/src/gone.ts', '--- a/src/gone.ts', '+++ /dev/null', '@@ -1,2 +0,0 @@', '-export const x = 1;', '-export const y = 2;',
|
|
680
|
+
'diff --git a/src/new.ts b/src/new.ts', '--- /dev/null', '+++ b/src/new.ts', '@@ -0,0 +1,2 @@', '+export const z = 1;', '+export const w = 2;', '',
|
|
681
|
+
].join('\n');
|
|
682
|
+
const changed = changedLinesOf(diff);
|
|
683
|
+
const rule = (file, line) => ({ ...EMPTY, prior_points: [], rules: [{ id: 'r1', status: 'broken', rule: 'wrap every navigation target in resolve()', source: 'AGENTS.md', requirement: true, file, line, quote: 'preloadCode(target)' }] });
|
|
684
|
+
const checks = { approvals: [], inCheckout: () => undefined, changed };
|
|
685
|
+
it('reads the touched lines of a diff: added lines, the place of a deletion, nothing for a deleted file', () => {
|
|
686
|
+
expect([...changed.get('src/player.ts')].sort((a, b) => a - b)).toEqual([11, 12]); // the deletion's place, then the two added lines (11 is both)
|
|
687
|
+
expect([...changed.get('src/new.ts')]).toEqual([1, 2]);
|
|
688
|
+
expect(changed.has('src/gone.ts')).toBe(false);
|
|
689
|
+
});
|
|
690
|
+
it('blocks a verified rule break near a touched line, and notes one on lines the change did not touch, or with no line', () => {
|
|
691
|
+
expect(account(rule('src/player.ts', 14), undefined, () => true, checks).open).toHaveLength(1); // within the window of line 12
|
|
692
|
+
const far = account(rule('src/player.ts', 1819), undefined, () => true, checks);
|
|
693
|
+
expect(far.open).toEqual([]);
|
|
694
|
+
expect(far.notes).toMatchObject([{ kind: 'rule', line: 1819, evidence: expect.stringContaining('on a line this change did not touch: what the code already had, never a block on this change') }]);
|
|
695
|
+
const unplaced = account(rule('src/player.ts', undefined), undefined, () => true, checks);
|
|
696
|
+
expect(unplaced.open).toEqual([]);
|
|
697
|
+
expect(unplaced.notes[0].evidence).toContain('names no line');
|
|
698
|
+
expect(account(rule('src/other.ts', 3), undefined, () => true, checks).open).toEqual([]); // a file the change did not touch at all
|
|
699
|
+
});
|
|
700
|
+
it("leaves a human's point, and every item when the diff is unknown, as before", () => {
|
|
701
|
+
const point = { ...EMPTY, prior_points: [{ point: 'wrap the target', review: 'senior 2026-10-01', severity: 'blocking', resolved: false, file: 'src/player.ts', line: 1819, quote: 'preloadCode(target)' }] };
|
|
702
|
+
expect(account(point, undefined, () => true, checks).open).toHaveLength(1);
|
|
703
|
+
expect(account(rule('src/player.ts', 1819), undefined, () => true, { approvals: [], inCheckout: () => undefined }).open).toHaveLength(1);
|
|
704
|
+
});
|
|
705
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/core",
|
|
3
|
-
"version": "6.7.
|
|
3
|
+
"version": "6.7.10",
|
|
4
4
|
"description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, across TypeScript, JavaScript, Python, Go, Ruby and C#.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.13"
|
|
@@ -72,11 +72,11 @@
|
|
|
72
72
|
"@anthropic-ai/sdk": "^0.30.1",
|
|
73
73
|
"pg": "^8.16.3",
|
|
74
74
|
"openai": "^5.23.2",
|
|
75
|
-
"@rigour-labs/brain-darwin-arm64": "6.7.
|
|
76
|
-
"@rigour-labs/brain-darwin-x64": "6.7.
|
|
77
|
-
"@rigour-labs/brain-linux-x64": "6.7.
|
|
78
|
-
"@rigour-labs/brain-
|
|
79
|
-
"@rigour-labs/brain-
|
|
75
|
+
"@rigour-labs/brain-darwin-arm64": "6.7.10",
|
|
76
|
+
"@rigour-labs/brain-darwin-x64": "6.7.10",
|
|
77
|
+
"@rigour-labs/brain-linux-x64": "6.7.10",
|
|
78
|
+
"@rigour-labs/brain-win-x64": "6.7.10",
|
|
79
|
+
"@rigour-labs/brain-linux-arm64": "6.7.10"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
82
|
"@types/fs-extra": "^11.0.4",
|