@rigour-labs/core 6.7.9 → 6.8.0
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/brief/briefing.d.ts +59 -0
- package/dist/brief/briefing.js +126 -0
- package/dist/brief/briefing.test.d.ts +1 -0
- package/dist/brief/briefing.test.js +107 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/review/backtest-init.d.ts +8 -5
- package/dist/review/backtest-init.js +28 -10
- package/dist/review/backtest-init.test.js +39 -1
- package/dist/review/backtest-last.js +3 -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/adapters.d.ts +4 -0
- package/dist/review/reviewer/adapters.js +23 -0
- package/dist/review/reviewer/adapters.test.js +40 -0
- package/dist/review/reviewer/inputs.d.ts +6 -0
- package/dist/review/reviewer/inputs.js +46 -6
- package/dist/review/reviewer/inputs.test.d.ts +1 -0
- package/dist/review/reviewer/inputs.test.js +52 -0
- package/dist/review/reviewer/prompt.js +8 -2
- package/dist/review/reviewer/record.d.ts +5 -0
- package/dist/review/reviewer/record.js +3 -3
- package/dist/review/reviewer/record.test.js +8 -0
- package/dist/review/reviewer/verdict.d.ts +40 -1
- package/dist/review/reviewer/verdict.js +150 -9
- package/dist/review/reviewer.d.ts +2 -0
- package/dist/review/reviewer.js +29 -9
- package/dist/review/reviewer.test.js +173 -8
- package/dist/review-learning/repo-rules.d.ts +14 -2
- package/dist/review-learning/repo-rules.js +59 -9
- package/dist/review-learning/repo-rules.test.js +39 -1
- package/dist/task/thread.d.ts +47 -0
- package/dist/task/thread.js +234 -0
- package/dist/task/thread.test.d.ts +1 -0
- package/dist/task/thread.test.js +133 -0
- package/dist/templates/universal-config.js +4 -0
- package/dist/types/index.d.ts +21 -0
- package/dist/types/index.js +7 -0
- package/package.json +6 -6
|
@@ -7,10 +7,83 @@
|
|
|
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
|
+
/** How alike a judge's prior point and a labelled line of the review must read to take the reviewer's label. */
|
|
16
|
+
const LABEL_SIMILARITY = 0.4;
|
|
17
|
+
/**
|
|
18
|
+
* The point with the severity its reviewer wrote, when the review labels it; the judge's reading otherwise. The reviewer
|
|
19
|
+
* is matched by login; the review by the date the judge names (judges write a timestamp, a date, or reformat it), and
|
|
20
|
+
* when no review of that reviewer has that date, by that reviewer's latest labelled review. A disagreement is said.
|
|
21
|
+
*/
|
|
22
|
+
function labelled(p, labels) {
|
|
23
|
+
const [login, ...rest] = (p.review ?? '').trim().split(/\s+/);
|
|
24
|
+
const day = /\d{4}-\d{2}-\d{2}/.exec(rest.join(' '))?.[0];
|
|
25
|
+
const theirs = login ? labels.filter(l => l.login === login) : [];
|
|
26
|
+
const sameDay = day ? theirs.filter(l => l.at.startsWith(day)) : [];
|
|
27
|
+
const latest = theirs.reduce((max, l) => (l.at > max ? l.at : max), '');
|
|
28
|
+
const candidates = sameDay.length ? sameDay : theirs.filter(l => l.at === latest);
|
|
29
|
+
const asItem = (issue) => ({ id: '', kind: 'prior', class: 'prior point', issue });
|
|
30
|
+
let best;
|
|
31
|
+
for (const label of candidates) {
|
|
32
|
+
const score = textSimilarity(asItem(p.point), asItem(label.text));
|
|
33
|
+
if (score >= LABEL_SIMILARITY && (!best || score > best.score))
|
|
34
|
+
best = { label, score };
|
|
35
|
+
}
|
|
36
|
+
if (!best)
|
|
37
|
+
return { point: p, took: false, disagreed: false };
|
|
38
|
+
if (best.label.severity === p.severity)
|
|
39
|
+
return { point: p, took: true, disagreed: false };
|
|
40
|
+
const note = `the review labels it ${best.label.severity}${p.severity ? `; the judge read ${p.severity}` : ''}`;
|
|
41
|
+
return { point: { ...p, severity: best.label.severity, evidence: p.evidence ? `${p.evidence}; ${note}` : note }, took: true, disagreed: true };
|
|
42
|
+
}
|
|
43
|
+
const NO_PRIOR_CHECKS = { approvals: [], inCheckout: () => undefined };
|
|
44
|
+
/** The touched lines of a unified diff (`git diff base...head`). */
|
|
45
|
+
export function changedLinesOf(diff) {
|
|
46
|
+
const changed = new Map();
|
|
47
|
+
let file;
|
|
48
|
+
let line = 0;
|
|
49
|
+
for (const raw of diff.split('\n')) {
|
|
50
|
+
if (raw.startsWith('diff --git ') || /^--- (a\/|\/dev\/null)/.test(raw)) {
|
|
51
|
+
file = undefined; // headers, until the new file's name
|
|
52
|
+
}
|
|
53
|
+
else if (/^\+\+\+ (b\/|\/dev\/null)/.test(raw)) {
|
|
54
|
+
const name = raw.slice(4).replace(/^b\//, '').replace(/\t.*$/, '');
|
|
55
|
+
file = name === '/dev/null' ? undefined : (changed.get(name) ?? new Set());
|
|
56
|
+
if (file)
|
|
57
|
+
changed.set(name, file);
|
|
58
|
+
}
|
|
59
|
+
else if (raw.startsWith('@@ ')) {
|
|
60
|
+
line = Number(/\+(\d+)/.exec(raw)?.[1] ?? 0);
|
|
61
|
+
}
|
|
62
|
+
else if (raw.startsWith('+')) {
|
|
63
|
+
file?.add(line);
|
|
64
|
+
line++;
|
|
65
|
+
}
|
|
66
|
+
else if (raw.startsWith('-')) {
|
|
67
|
+
file?.add(line);
|
|
68
|
+
}
|
|
69
|
+
else if (raw.startsWith(' ') || raw === '') {
|
|
70
|
+
line++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return changed;
|
|
74
|
+
}
|
|
75
|
+
/** Whether a line of a file is within CHANGED_WINDOW lines of one the change touched. */
|
|
76
|
+
function nearChanged(changed, file, line) {
|
|
77
|
+
const lines = changed.get(file);
|
|
78
|
+
if (!lines)
|
|
79
|
+
return false;
|
|
80
|
+
for (let l = line - CHANGED_WINDOW; l <= line + CHANGED_WINDOW; l++)
|
|
81
|
+
if (lines.has(l))
|
|
82
|
+
return true;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
/** How far from a touched line a block may sit: a judge names the statement, the diff names the line. */
|
|
86
|
+
const CHANGED_WINDOW = 3;
|
|
14
87
|
/**
|
|
15
88
|
* 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
89
|
* quoted code must also be there, within QUOTE_WINDOW lines of the line named (whitespace aside): a claim about code
|
|
@@ -44,6 +117,34 @@ export function checkoutVerifier(cwd) {
|
|
|
44
117
|
}
|
|
45
118
|
/** How far from the line it names a finding's quote may sit: judges count lines loosely. */
|
|
46
119
|
const QUOTE_WINDOW = 3;
|
|
120
|
+
/** The approval, if any, the point's own reviewer gave after raising it (`review` reads `<login> <submitted_at>`). */
|
|
121
|
+
function settledBy(point, approvals) {
|
|
122
|
+
const [login, ...rest] = (point.review ?? '').trim().split(/\s+/);
|
|
123
|
+
if (!login)
|
|
124
|
+
return undefined;
|
|
125
|
+
const at = rest.join(' ');
|
|
126
|
+
return approvals.find(a => a.login === login && (!at || a.at > at));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Searches the whole checkout for a line of text (the first non-empty line of `text`, whitespace aside): `file:line` of
|
|
130
|
+
* the first place it is, or undefined. A `git grep` so the tracked tree is searched and nothing else.
|
|
131
|
+
*/
|
|
132
|
+
export function checkoutSearch(cwd) {
|
|
133
|
+
return text => {
|
|
134
|
+
const line = text.split('\n').map(l => l.trim()).find(Boolean);
|
|
135
|
+
if (!line)
|
|
136
|
+
return undefined;
|
|
137
|
+
try {
|
|
138
|
+
const out = execFileSync('git', ['grep', '-n', '-I', '-F', '-e', line, '--', '.'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 });
|
|
139
|
+
const hit = out.split('\n').find(Boolean);
|
|
140
|
+
const m = hit?.match(/^(.*?):(\d+):/);
|
|
141
|
+
return m ? `${m[1]}:${m[2]}` : undefined;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
47
148
|
/** How alike a finding and a point a human accepted as non-blocking must read to be that point again. */
|
|
48
149
|
const ACCEPTED_SIMILARITY = 0.4;
|
|
49
150
|
/** The reviewer's working steps: shown so a person can follow the reasoning, never a block on their own. */
|
|
@@ -135,13 +236,16 @@ export function evidenceTouched(evidence, touched) {
|
|
|
135
236
|
}
|
|
136
237
|
const id = (...parts) => createHash('sha1').update(parts.map(p => String(p ?? '')).join('|').toLowerCase().replace(/\s+/g, ' ')).digest('hex').slice(0, 10);
|
|
137
238
|
/** Everything the reviewers reported blocks; in delta mode, previous open items carry unless resolved with evidence. */
|
|
138
|
-
export function account(verdict, previousOpen, verify) {
|
|
239
|
+
export function account(verdict, previousOpen, verify, prior = NO_PRIOR_CHECKS) {
|
|
139
240
|
const open = [];
|
|
140
241
|
const unverified = [];
|
|
141
242
|
const notes = [];
|
|
142
243
|
const advisory = [];
|
|
143
244
|
const seen = new Set();
|
|
144
|
-
|
|
245
|
+
// The reviewer's own label wins over the judge's reading of it: a judge that calls a blocker a should-fix would demote it silently.
|
|
246
|
+
const read = verdict.prior_points.map(p => labelled(p, prior.labels ?? []));
|
|
247
|
+
const points = read.map(r => r.point);
|
|
248
|
+
const accepted = points.filter(p => p.severity === 'non-blocking');
|
|
145
249
|
// A should-fix is shown only when the judge could show it: a quote Rigour finds. One that cannot be checked is not a claim worth a person's time.
|
|
146
250
|
const advise = (item) => {
|
|
147
251
|
if (seen.has(item.id))
|
|
@@ -159,17 +263,49 @@ export function account(verdict, previousOpen, verify) {
|
|
|
159
263
|
if (WORKING_NOTES.has(item.kind))
|
|
160
264
|
return void notes.push(item);
|
|
161
265
|
const placed = !!item.file && !!item.quote?.trim() && verify(item.file, item.line, item.quote);
|
|
162
|
-
(placed
|
|
266
|
+
if (!placed)
|
|
267
|
+
return void unverified.push(item);
|
|
268
|
+
// A block is about this change. A finding or rule break in a touched file but on lines the change did not touch is
|
|
269
|
+
// what the code already had: shown as a note, never a block on this change. A human's point is about the change by
|
|
270
|
+
// definition. Without the diff (a judge's own items for a panel, a test) nothing is known and nothing is moved.
|
|
271
|
+
if (item.kind !== 'prior' && prior.changed && (item.line === undefined || !nearChanged(prior.changed, item.file, item.line))) {
|
|
272
|
+
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` });
|
|
273
|
+
}
|
|
274
|
+
open.push(item);
|
|
163
275
|
};
|
|
164
276
|
const answerInReply = [];
|
|
165
|
-
for (const p of
|
|
277
|
+
for (const p of points) {
|
|
166
278
|
if (p.resolved)
|
|
167
279
|
continue;
|
|
168
|
-
if (p.severity === 'non-blocking')
|
|
280
|
+
if (p.severity === 'non-blocking') {
|
|
169
281
|
answerInReply.push(p);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
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 } : {}) };
|
|
285
|
+
// The person who raised it approved afterwards: settled, whatever the judge makes of the code now. The same class
|
|
286
|
+
// coming back in later code is a finding of the judge's own, with its own input and consequence.
|
|
287
|
+
const approval = settledBy(p, prior.approvals);
|
|
288
|
+
if (approval) {
|
|
289
|
+
if (!seen.has(item.id))
|
|
290
|
+
notes.push({ ...item, evidence: `${approval.login} approved on ${approval.at}, after raising it: settled` });
|
|
291
|
+
seen.add(item.id);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
// A point that says something is still missing names what it searched for, and the checkout is searched for it: the
|
|
295
|
+
// reply or a later commit may have put it in another file than the one the point named.
|
|
296
|
+
const found = p.absent?.trim() ? prior.inCheckout(p.absent) : undefined;
|
|
297
|
+
if (found) {
|
|
298
|
+
if (!seen.has(item.id))
|
|
299
|
+
unverified.push({ ...item, evidence: `says "${p.absent.trim()}" is missing, and the checkout has it at ${found}` });
|
|
300
|
+
seen.add(item.id);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
170
303
|
// Still open only where the judge quotes the code that keeps it open: a point a later commit already fixed never blocks.
|
|
304
|
+
// A should-fix keeps the tier its reviewer gave it: shown with its quote, never a block, like a should-fix finding.
|
|
305
|
+
if (p.severity === 'should-fix')
|
|
306
|
+
advise(item);
|
|
171
307
|
else
|
|
172
|
-
add(
|
|
308
|
+
add(item);
|
|
173
309
|
}
|
|
174
310
|
for (const r of verdict.redundant) {
|
|
175
311
|
if (r.removed === false)
|
|
@@ -273,7 +409,7 @@ export function account(verdict, previousOpen, verify) {
|
|
|
273
409
|
}
|
|
274
410
|
}
|
|
275
411
|
}
|
|
276
|
-
return { open: onePerRootCause(open), unverified, resolved, answerInReply, notes, advisory: onePerRootCause(advisory) };
|
|
412
|
+
return { open: onePerRootCause(open), unverified, resolved, answerInReply, notes, advisory: onePerRootCause(advisory), labels: { served: prior.labels?.length ?? 0, taken: read.filter(r => r.took).length, disagreed: read.filter(r => r.disagreed).length } };
|
|
277
413
|
}
|
|
278
414
|
/** How alike two items' words must be to be the same point made in two places; and, on the same lines, to be one point said two ways. */
|
|
279
415
|
const SAME_POINT = 0.6;
|
|
@@ -286,9 +422,14 @@ const SAME_LINES = 3;
|
|
|
286
422
|
function onePerRootCause(items) {
|
|
287
423
|
const kept = [];
|
|
288
424
|
for (const item of items) {
|
|
289
|
-
// The same class in the same words anywhere, or any two non-human items on the same lines that read alike (a rule break and
|
|
425
|
+
// The same class in the same words anywhere, or any two non-human items on the same lines that read alike (a rule break and
|
|
426
|
+
// the finding it caused). A human's point owns its lines: a rule break or finding there in like words is that point found
|
|
427
|
+
// again and folds into it; the human's words stay. One in other words is a different problem and stays its own item, so
|
|
428
|
+
// fixing the human's point does not leave it for the next round. Two human points never merge.
|
|
290
429
|
const nearby = (k) => !!k.file && k.file === item.file && k.line !== undefined && item.line !== undefined && Math.abs(k.line - item.line) <= SAME_LINES;
|
|
291
|
-
const same = item.kind === 'prior' ? undefined : kept.find(k => k.kind
|
|
430
|
+
const same = item.kind === 'prior' ? undefined : kept.find(k => k.kind === 'prior'
|
|
431
|
+
? nearby(k) && textSimilarity(k, item) >= SAME_PLACE
|
|
432
|
+
: (k.class === item.class && textSimilarity(k, item) >= SAME_POINT) || (nearby(k) && textSimilarity(k, item) >= SAME_PLACE));
|
|
292
433
|
if (!same) {
|
|
293
434
|
kept.push(item);
|
|
294
435
|
continue;
|
|
@@ -84,6 +84,8 @@ export interface ReviewerResult {
|
|
|
84
84
|
/** The latest human review it worked from (`<login>, <date> (<n> reviews)`). */
|
|
85
85
|
previousReview?: string;
|
|
86
86
|
pr?: number;
|
|
87
|
+
/** The pull request's title, when one was read. */
|
|
88
|
+
prTitle?: string;
|
|
87
89
|
}
|
|
88
90
|
export interface ModeRecord {
|
|
89
91
|
asked: 'single' | 'cross' | 'full' | 'panel';
|
package/dist/review/reviewer.js
CHANGED
|
@@ -34,8 +34,9 @@ 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
|
+
import { appendTaskEvent } from '../task/thread.js';
|
|
39
40
|
export { defaultExec, githubEnv, githubToken, parseJsonArrays } from './reviewer/exec.js';
|
|
40
41
|
export { itemLine } from './reviewer/verdict.js';
|
|
41
42
|
/** Findings and no verdict stop a push; a skipped review does not, and is reported as owed. */
|
|
@@ -48,7 +49,16 @@ const MAX_DELTA_LINES = 400;
|
|
|
48
49
|
/** The reviewer, and one anonymous usage event for what it did (only when the person opted in to telemetry). */
|
|
49
50
|
export async function runReviewer(cwd, base, config, exec = defaultExec, progress = message => process.stderr.write(`${message}\n`), options = {}) {
|
|
50
51
|
const result = await review(cwd, base, config, exec, progress, options);
|
|
51
|
-
|
|
52
|
+
const trigger = options.trigger ?? (options.reviewsBefore ? 'backtest' : 'review');
|
|
53
|
+
await trackUsage('reviewer_completed', reviewerUsage(result, trigger));
|
|
54
|
+
// On the task's thread, unless it replays history: a backtest's worktree is not anyone's task.
|
|
55
|
+
if (trigger !== 'backtest' && result.outcome !== 'skipped')
|
|
56
|
+
appendTaskEvent(cwd, {
|
|
57
|
+
kind: 'review', trigger, outcome: result.outcome, blocking: result.items.length, should_fix: result.advisory.length,
|
|
58
|
+
...(result.pr ? { pr: result.pr } : {}),
|
|
59
|
+
...(result.prTitle ? { pr_title: result.prTitle } : {}),
|
|
60
|
+
...(result.record ? { integrity: result.record.integrity, cost_usd: result.record.judges.reduce((sum, j) => sum + (j.cost_usd ?? 0), 0), judges: result.record.judges.map(j => j.reviewer) } : {}),
|
|
61
|
+
});
|
|
52
62
|
return result;
|
|
53
63
|
}
|
|
54
64
|
async function review(cwd, base, config, exec, progress, options) {
|
|
@@ -107,7 +117,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
107
117
|
if (skip)
|
|
108
118
|
return none('skipped', skip, { reviewers, pr: pr?.number });
|
|
109
119
|
}
|
|
110
|
-
let reviews = { markdown: 'none\n', key: '', count: 0 };
|
|
120
|
+
let reviews = { markdown: 'none\n', key: '', count: 0, approvals: [], labels: [] };
|
|
111
121
|
if (gh && pr) {
|
|
112
122
|
const read = await humanReviews(gh, pr, options.reviewsBefore);
|
|
113
123
|
if (read.error)
|
|
@@ -187,11 +197,17 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
187
197
|
const openFile = store.openPath(verdictFile);
|
|
188
198
|
const previousOpen = scope === 'delta' ? store.readJson(store.openPath(previous.verdict)) ?? [] : undefined;
|
|
189
199
|
const verify = checkoutVerifier(cwd);
|
|
200
|
+
const prior = { approvals: reviews.approvals, inCheckout: checkoutSearch(cwd), changed: changedLinesOf(fullDiff), labels: reviews.labels };
|
|
190
201
|
const modelFor = (name) => settings.models[name] ?? (name === 'claude' ? settings.model : undefined);
|
|
191
202
|
// The record of the review, written beside the verdict once and rebuilt from the same verdict on a cached read.
|
|
203
|
+
// What a judge reads besides the repository and Rigour's inputs, on this machine: on the record, so "the same judge" is a claim it can check.
|
|
204
|
+
const outsideOf = (reviewer) => {
|
|
205
|
+
const outside = ADAPTERS[reviewer]?.outsideRepo?.(os.homedir(), installed.get(reviewer)?.version);
|
|
206
|
+
return outside ? { outside_repo: outside } : {};
|
|
207
|
+
};
|
|
192
208
|
const withRecord = (accounted, verdict, cached) => {
|
|
193
209
|
const res = result(accounted, verdict, reviewers, scope, why, cached, reviews, pr, modeRecord, settings.dismissals);
|
|
194
|
-
const judges = (verdict.reviewers ?? []).map(r => ({ reviewer: r.reviewer, ...(installed.get(r.reviewer)?.version ? { version: installed.get(r.reviewer).version } : {}), ...(modelFor(r.reviewer) ? { model: modelFor(r.reviewer) } : {}), ...(typeof r.cost_usd === 'number' ? { cost_usd: r.cost_usd } : {}), ...(r.trace?.turns ? { turns: r.trace.turns } : {}) }));
|
|
210
|
+
const judges = (verdict.reviewers ?? []).map(r => ({ reviewer: r.reviewer, ...(installed.get(r.reviewer)?.version ? { version: installed.get(r.reviewer).version } : {}), ...(modelFor(r.reviewer) ? { model: modelFor(r.reviewer) } : {}), ...(typeof r.cost_usd === 'number' ? { cost_usd: r.cost_usd } : {}), ...(r.trace?.turns ? { turns: r.trace.turns } : {}), ...outsideOf(r.reviewer) }));
|
|
195
211
|
const recordPath = store.recordPath(verdictFile);
|
|
196
212
|
const record = (cached && store.readJson(recordPath)) || buildRecord({ head, base: baseSha, scope, verdict, accounted, judges, lessonsServed: context.lessons, humanReviews: reviews.count });
|
|
197
213
|
if (!cached || !fs.existsSync(recordPath))
|
|
@@ -200,7 +216,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
200
216
|
};
|
|
201
217
|
if (!options.force && fs.existsSync(verdictFile) && fs.existsSync(openFile)) {
|
|
202
218
|
const verdict = store.readJson(verdictFile);
|
|
203
|
-
return withRecord(decide(verdict, previousOpen, verify, dismissals), verdict, true);
|
|
219
|
+
return withRecord(decide(verdict, previousOpen, verify, prior, dismissals), verdict, true);
|
|
204
220
|
}
|
|
205
221
|
// The daily caps, before any judge starts: a cached or reused verdict above cost nothing and never reaches here.
|
|
206
222
|
const over = overBudget(store.spend(), settings, reviewers.length);
|
|
@@ -211,7 +227,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
211
227
|
let inlineInputs = [];
|
|
212
228
|
const runJudge = (name, prompt, model) => name === 'api'
|
|
213
229
|
? 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 } : {}) })
|
|
214
|
-
: 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) });
|
|
230
|
+
: 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), ...(ADAPTERS[name].env ? { env: ADAPTERS[name].env } : {}) });
|
|
215
231
|
try {
|
|
216
232
|
const file = (name, text) => {
|
|
217
233
|
const target = path.join(work, name);
|
|
@@ -334,7 +350,10 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
334
350
|
});
|
|
335
351
|
verdict = { ...verdict, panel: { judgeItemIds: judgeItems.flat().map(item => item.id), items }, reviewers: [...(verdict.reviewers ?? []), ...cross] };
|
|
336
352
|
}
|
|
337
|
-
const accounted = decide(verdict, previousOpen, verify, dismissals);
|
|
353
|
+
const accounted = decide(verdict, previousOpen, verify, prior, dismissals);
|
|
354
|
+
// Said every run where the reviews carry labels, so a matcher that takes none is visible rather than silent.
|
|
355
|
+
if (accounted.labels?.served)
|
|
356
|
+
progress(`Rigour reviewer: ${accounted.labels.taken} of ${verdict.prior_points.length} prior point(s) took the review's own severity label (${accounted.labels.served} labelled line(s)); the judge read ${accounted.labels.disagreed} otherwise`);
|
|
338
357
|
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() } });
|
|
339
358
|
store.writeJson(openFile, accounted.open);
|
|
340
359
|
store.writeJson(store.decidedPath(verdictFile), accounted);
|
|
@@ -347,8 +366,8 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
347
366
|
}
|
|
348
367
|
}
|
|
349
368
|
/** The accounting a verdict leads to: with a panel, only what it confirmed blocks; a finding the team dismissed never blocks. */
|
|
350
|
-
function decide(verdict, previousOpen, verify, dismissals) {
|
|
351
|
-
const accounted = account(verdict, previousOpen, verify);
|
|
369
|
+
function decide(verdict, previousOpen, verify, prior, dismissals) {
|
|
370
|
+
const accounted = account(verdict, previousOpen, verify, prior);
|
|
352
371
|
// An item an earlier round confirmed stays open until it is resolved with evidence, whatever this panel says of it.
|
|
353
372
|
const earlier = new Set((previousOpen ?? []).map(item => item.id));
|
|
354
373
|
const decided = verdict.panel
|
|
@@ -461,5 +480,6 @@ function result(accounted, verdict, reviewers, scope, why, cached, reviews, pr,
|
|
|
461
480
|
cached,
|
|
462
481
|
...(reviews.label ? { previousReview: reviews.label } : {}),
|
|
463
482
|
...(pr ? { pr: pr.number } : {}),
|
|
483
|
+
...(pr?.title ? { prTitle: pr.title } : {}),
|
|
464
484
|
};
|
|
465
485
|
}
|
|
@@ -8,8 +8,9 @@ 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';
|
|
12
|
-
import { recordIntact } from './reviewer/record.js';
|
|
11
|
+
import { account, attachServedRules, carryResolved, changedLinesOf, checkoutSearch, checkoutVerifier, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
12
|
+
import { recordIntact, recordLines } from './reviewer/record.js';
|
|
13
|
+
import { readThread } from '../task/thread.js';
|
|
13
14
|
let repo;
|
|
14
15
|
const config = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'] } } });
|
|
15
16
|
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
|
|
@@ -51,6 +52,7 @@ function fakes(answer, seen, pr = PR) {
|
|
|
51
52
|
seen.ran.push(command);
|
|
52
53
|
(seen.args ??= []).push(args);
|
|
53
54
|
(seen.unset ??= []).push(options.unset);
|
|
55
|
+
(seen.env ??= []).push(options.env);
|
|
54
56
|
const name = binary === 'claude' ? 'claude' : binary === 'cursor-agent' ? 'cursor' : 'codex';
|
|
55
57
|
const prompt = binary === 'claude' ? args[args.indexOf('-p') + 1] : args[args.length - 1];
|
|
56
58
|
seen.prompts.push(prompt);
|
|
@@ -326,7 +328,8 @@ describe('the reviewer', () => {
|
|
|
326
328
|
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;' }] : [] });
|
|
327
329
|
const result = await runReviewer(repo, 'main', config, fakes(by, seen), () => undefined, { full: true });
|
|
328
330
|
expect(result.reviewers).toEqual(['claude', 'cursor']);
|
|
329
|
-
expect(result.items.map(i => [i.kind, i.reviewer])).toEqual([['prior', 'cursor']
|
|
331
|
+
expect(result.items.map(i => [i.kind, i.reviewer])).toEqual([['prior', 'cursor']]);
|
|
332
|
+
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
|
|
330
333
|
expect(seen.prompts).toHaveLength(2);
|
|
331
334
|
});
|
|
332
335
|
});
|
|
@@ -446,18 +449,32 @@ describe('verdicts', () => {
|
|
|
446
449
|
expect(unknown.verdict.rules).toEqual([]); // an answer naming no served rule is dropped, whatever it claims
|
|
447
450
|
expect(unknown.open).toEqual([]);
|
|
448
451
|
});
|
|
449
|
-
it(
|
|
452
|
+
it("shows the same point found in several places as one item with every location; a judge item on a human point's lines folds into it, and human points never merge", () => {
|
|
450
453
|
const scan = (file, line, quote) => ({ class: 'production-cost', file, line, issue: `the ${file.split('/').pop()} scan has no upper bound on updated_at`, input: 'a week of rows', consequence: 'rows read grow with time', quote });
|
|
451
454
|
const verdict = { ...EMPTY, prior_points: [
|
|
452
|
-
{ point: 'bound
|
|
455
|
+
{ point: 'the scan has no upper bound on updated_at', severity: 'blocking', resolved: false, file: 'src/job.ts', line: 1, quote: 'export function job() {' },
|
|
453
456
|
{ point: 'bound the window again', severity: 'blocking', resolved: false, file: 'src/job.ts', line: 1, quote: 'export function job() {' },
|
|
454
457
|
], findings: [scan('src/job.ts', 1, 'export function job() {'), scan('a.ts', 1, 'export const a = 1;'), { ...scan('src/job.ts', 2, 'return 1;'), class: 'correctness' }] };
|
|
455
458
|
const { open } = account(verdict, undefined, checkoutVerifier(repo));
|
|
456
459
|
expect(open.map(i => [i.kind, i.class, i.locations ?? []])).toEqual([
|
|
457
|
-
|
|
458
|
-
// The same
|
|
459
|
-
['
|
|
460
|
+
// The judge's scan on the human's lines, in like words, is the human's point found again, and so is the same point said as another class on the next line.
|
|
461
|
+
// The same scan in another file is not on the human's lines: its own item.
|
|
462
|
+
['prior', 'prior point', [{ file: 'src/job.ts', line: 1 }, { file: 'src/job.ts', line: 2 }]], ['prior', 'prior point', []], ['finding', 'production-cost', []],
|
|
460
463
|
]);
|
|
464
|
+
// On other lines than any human point: the same point in another file, and said as another class on the next line, is one item, every place.
|
|
465
|
+
const apart = account({ ...verdict, prior_points: [] }, undefined, checkoutVerifier(repo));
|
|
466
|
+
expect(apart.open.map(i => [i.kind, i.class, i.locations ?? []])).toEqual([['finding', 'production-cost', [{ file: 'a.ts', line: 1 }, { file: 'src/job.ts', line: 2 }]]]);
|
|
467
|
+
// On a human point's lines: a rule break in like words is that point found again; a different rule, or a finding in other
|
|
468
|
+
// words, stays its own item, so fixing the human's point does not leave it for the next round.
|
|
469
|
+
const human = { point: 'null guards on columns the query makes non-null are dead fallbacks', severity: 'blocking', resolved: false, file: 'src/job.ts', line: 2, quote: 'return 1;' };
|
|
470
|
+
const onHuman = account({ ...EMPTY, prior_points: [human],
|
|
471
|
+
findings: [{ class: 'correctness', file: 'src/job.ts', line: 3, issue: 'the retry re-sends the email', input: 'a timeout', consequence: 'two emails', quote: '}' }],
|
|
472
|
+
rules: [
|
|
473
|
+
{ id: 'r1', status: 'broken', file: 'src/job.ts', line: 2, quote: 'return 1;', rule: 'No dead fallbacks or null guards on non-null columns.', source: 'AGENTS.md', requirement: true },
|
|
474
|
+
{ id: 'r2', status: 'broken', file: 'src/job.ts', line: 3, quote: '}', rule: 'Import the JOBS_TABLE constant; do not inline the raw table name.', source: 'AGENTS.md', requirement: true },
|
|
475
|
+
] }, undefined, checkoutVerifier(repo));
|
|
476
|
+
expect(onHuman.open.map(i => [i.kind, i.locations ?? []])).toEqual([['prior', [{ file: 'src/job.ts', line: 2 }]], ['rule', []], ['finding', []]]);
|
|
477
|
+
expect(onHuman.open[1].issue).toContain('JOBS_TABLE');
|
|
461
478
|
// A rule break and the finding it caused, on the same lines and in like words, are one item.
|
|
462
479
|
const twice = account({ ...EMPTY, prior_points: [], findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'the raw table name is inlined instead of the JOBS_TABLE constant', input: 'any run', consequence: 'a rename misses it', quote: 'return 1;' }],
|
|
463
480
|
rules: [{ id: 'r', status: 'broken', file: 'src/job.ts', line: 2, quote: 'return 1;', rule: 'Import the JOBS_TABLE constant; do not inline the raw table name again.', source: 'AGENTS.md', requirement: true }] }, undefined, checkoutVerifier(repo));
|
|
@@ -625,3 +642,151 @@ describe('what the team already knows', () => {
|
|
|
625
642
|
expect(result.notes).toEqual([]);
|
|
626
643
|
});
|
|
627
644
|
});
|
|
645
|
+
describe("a human's prior point", () => {
|
|
646
|
+
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 });
|
|
647
|
+
const verdict = (p) => ({ ...EMPTY, prior_points: [p] });
|
|
648
|
+
const approvals = [{ login: 'senior', at: '2026-09-28T15:12:53Z' }];
|
|
649
|
+
it('blocks where the judge quotes the code that keeps it open and no one approved since', () => {
|
|
650
|
+
const { open } = account(verdict(point({})), undefined, () => true, { approvals: [], inCheckout: () => undefined });
|
|
651
|
+
expect(open.map(i => i.issue)).toEqual(['keep a separate case for a visitor with no account']);
|
|
652
|
+
});
|
|
653
|
+
it('is settled by its own reviewer approving after raising it: a note, never a block', () => {
|
|
654
|
+
const { open, notes } = account(verdict(point({})), undefined, () => true, { approvals, inCheckout: () => undefined });
|
|
655
|
+
expect(open).toEqual([]);
|
|
656
|
+
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' }]);
|
|
657
|
+
});
|
|
658
|
+
it('is not settled by an approval before it, by another person, or when the judge names no reviewer', () => {
|
|
659
|
+
const before = account(verdict(point({})), undefined, () => true, { approvals: [{ login: 'senior', at: '2026-09-20T00:00:00Z' }], inCheckout: () => undefined });
|
|
660
|
+
const other = account(verdict(point({})), undefined, () => true, { approvals: [{ login: 'peer', at: '2026-09-28T15:12:53Z' }], inCheckout: () => undefined });
|
|
661
|
+
const unnamed = account(verdict(point({ review: undefined })), undefined, () => true, { approvals, inCheckout: () => undefined });
|
|
662
|
+
for (const result of [before, other, unnamed])
|
|
663
|
+
expect(result.open).toHaveLength(1);
|
|
664
|
+
const undated = account(verdict(point({ review: 'senior' })), undefined, () => true, { approvals, inCheckout: () => undefined });
|
|
665
|
+
expect(undated.open).toEqual([]); // the reviewer named and approved: settled
|
|
666
|
+
});
|
|
667
|
+
it('that calls something missing is unverified when the checkout has it elsewhere', () => {
|
|
668
|
+
const searched = [];
|
|
669
|
+
const found = account(verdict(point({ absent: 'origin=native&returnTo=' })), undefined, () => true, { approvals: [], inCheckout: text => (searched.push(text), 'src/lib/Upsell.test.ts:128') });
|
|
670
|
+
expect(searched).toEqual(['origin=native&returnTo=']);
|
|
671
|
+
expect(found.open).toEqual([]);
|
|
672
|
+
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' }]);
|
|
673
|
+
const missing = account(verdict(point({ absent: 'origin=native&returnTo=' })), undefined, () => true, { approvals: [], inCheckout: () => undefined });
|
|
674
|
+
expect(missing.open).toHaveLength(1); // searched, not there: the point stands on its quote
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
describe('searching the checkout for what a point calls missing', () => {
|
|
678
|
+
it('finds the first line of the text anywhere in the tracked tree, and nothing untracked', () => {
|
|
679
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-search-'));
|
|
680
|
+
execFileSync('git', ['-C', dir, 'init', '-q']);
|
|
681
|
+
fs.mkdirSync(path.join(dir, 'src'));
|
|
682
|
+
fs.writeFileSync(path.join(dir, 'src', 'a.test.ts'), 'it("no account", () => {\n expect(href).toBe("/checkout?origin=native");\n});\n');
|
|
683
|
+
fs.writeFileSync(path.join(dir, 'untracked.ts'), 'const ghost = 1;\n');
|
|
684
|
+
execFileSync('git', ['-C', dir, 'add', 'src']);
|
|
685
|
+
const search = checkoutSearch(dir);
|
|
686
|
+
expect(search(' expect(href).toBe("/checkout?origin=native");\n more')).toBe('src/a.test.ts:2');
|
|
687
|
+
expect(search('const ghost = 1;')).toBeUndefined();
|
|
688
|
+
expect(search(' \n')).toBeUndefined();
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
describe('a block sits on a line the change touched', () => {
|
|
692
|
+
const diff = [
|
|
693
|
+
'diff --git a/src/player.ts b/src/player.ts', '--- a/src/player.ts', '+++ b/src/player.ts',
|
|
694
|
+
'@@ -10,4 +10,5 @@ function resume() {', ' const a = 1;', '- old();', '+ report(a);', '+ report(b);', ' return a;', ' }',
|
|
695
|
+
'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;',
|
|
696
|
+
'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;', '',
|
|
697
|
+
].join('\n');
|
|
698
|
+
const changed = changedLinesOf(diff);
|
|
699
|
+
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)' }] });
|
|
700
|
+
const checks = { approvals: [], inCheckout: () => undefined, changed };
|
|
701
|
+
it('reads the touched lines of a diff: added lines, the place of a deletion, nothing for a deleted file', () => {
|
|
702
|
+
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)
|
|
703
|
+
expect([...changed.get('src/new.ts')]).toEqual([1, 2]);
|
|
704
|
+
expect(changed.has('src/gone.ts')).toBe(false);
|
|
705
|
+
});
|
|
706
|
+
it('blocks a verified rule break near a touched line, and notes one on lines the change did not touch, or with no line', () => {
|
|
707
|
+
expect(account(rule('src/player.ts', 14), undefined, () => true, checks).open).toHaveLength(1); // within the window of line 12
|
|
708
|
+
const far = account(rule('src/player.ts', 1819), undefined, () => true, checks);
|
|
709
|
+
expect(far.open).toEqual([]);
|
|
710
|
+
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') }]);
|
|
711
|
+
const unplaced = account(rule('src/player.ts', undefined), undefined, () => true, checks);
|
|
712
|
+
expect(unplaced.open).toEqual([]);
|
|
713
|
+
expect(unplaced.notes[0].evidence).toContain('names no line');
|
|
714
|
+
expect(account(rule('src/other.ts', 3), undefined, () => true, checks).open).toEqual([]); // a file the change did not touch at all
|
|
715
|
+
});
|
|
716
|
+
it("leaves a human's point, and every item when the diff is unknown, as before", () => {
|
|
717
|
+
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)' }] };
|
|
718
|
+
expect(account(point, undefined, () => true, checks).open).toHaveLength(1);
|
|
719
|
+
expect(account(rule('src/player.ts', 1819), undefined, () => true, { approvals: [], inCheckout: () => undefined }).open).toHaveLength(1);
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
describe("a human's should-fix point", () => {
|
|
723
|
+
it('is shown with its quote and never blocks, like a should-fix finding', () => {
|
|
724
|
+
const verdict = { ...EMPTY, prior_points: [{ point: 'a reopened deck reads as a return every day', review: 'senior 2026-10-01', severity: 'should-fix', resolved: false, file: 'src/job.ts', line: 2, quote: 'return 1;' }] };
|
|
725
|
+
const { open, advisory, unverified } = account(verdict, undefined, checkoutVerifier(repo));
|
|
726
|
+
expect(open).toEqual([]);
|
|
727
|
+
expect(advisory.map(i => [i.kind, i.issue])).toEqual([['prior', 'a reopened deck reads as a return every day']]);
|
|
728
|
+
expect(unverified).toEqual([]);
|
|
729
|
+
const unplaced = account({ ...verdict, prior_points: [{ ...verdict.prior_points[0], quote: 'not in the file' }] }, undefined, checkoutVerifier(repo));
|
|
730
|
+
expect(unplaced.advisory).toEqual([]); // a should-fix the judge cannot show is not worth a person's time
|
|
731
|
+
expect(unplaced.unverified).toHaveLength(1);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
describe("the reviewer's own severity label", () => {
|
|
735
|
+
const at = '2026-10-01T10:00:00Z';
|
|
736
|
+
const labels = [
|
|
737
|
+
{ login: 'senior', at, severity: 'blocking', text: 'The kill switch is read after every query: check it first.' },
|
|
738
|
+
{ login: 'senior', at, severity: 'should-fix', text: 'The comment on the window still says daily.' },
|
|
739
|
+
];
|
|
740
|
+
const point = (over) => ({ ...EMPTY, prior_points: [{ point: 'the kill switch is read after every query', review: 'senior 2026-10-01T10:00:00Z', severity: 'should-fix', resolved: false, file: 'src/job.ts', line: 2, quote: 'return 1;', ...over }] });
|
|
741
|
+
it('wins over the judge: a blocker the judge read as a should-fix blocks, and the disagreement is said', () => {
|
|
742
|
+
const { open, advisory } = account(point({}), undefined, checkoutVerifier(repo), { approvals: [], inCheckout: () => undefined, labels });
|
|
743
|
+
expect(advisory).toEqual([]);
|
|
744
|
+
expect(open).toMatchObject([{ kind: 'prior', evidence: 'the review labels it blocking; the judge read should-fix' }]);
|
|
745
|
+
});
|
|
746
|
+
it('matches the review by its date however the judge writes the time, and falls back to the reviewer\'s latest labelled review', () => {
|
|
747
|
+
const later = { login: 'senior', at: '2026-10-03T09:00:00Z', severity: 'should-fix', text: 'The kill switch is read after every query: check it first.' };
|
|
748
|
+
for (const review of ['senior 2026-10-01', 'senior 2026-10-01 10:00', 'senior']) {
|
|
749
|
+
const { open, labels: counted } = account(point({ review }), undefined, checkoutVerifier(repo), { approvals: [], inCheckout: () => undefined, labels });
|
|
750
|
+
expect(open).toHaveLength(1);
|
|
751
|
+
expect(counted).toEqual({ served: 2, taken: 1, disagreed: 1 });
|
|
752
|
+
}
|
|
753
|
+
// No review of that reviewer on the judge's date: the reviewer's latest labelled review decides (here, a should-fix).
|
|
754
|
+
const { open, advisory } = account(point({ review: 'senior 2026-09-30', severity: 'blocking' }), undefined, checkoutVerifier(repo), { approvals: [], inCheckout: () => undefined, labels: [...labels, later] });
|
|
755
|
+
expect([open.length, advisory.length]).toEqual([0, 1]);
|
|
756
|
+
});
|
|
757
|
+
it('applies only to the same reviewer, and only to a point that reads like the labelled line; the counts say so', () => {
|
|
758
|
+
for (const over of [{ review: 'peer 2026-10-01T10:00:00Z' }, { point: 'the email retry sends twice' }]) {
|
|
759
|
+
const { open, advisory, labels: counted } = account(point(over), undefined, checkoutVerifier(repo), { approvals: [], inCheckout: () => undefined, labels });
|
|
760
|
+
expect([open.length, advisory.length]).toEqual([0, 1]); // the judge's should-fix stands
|
|
761
|
+
expect(counted).toEqual({ served: 2, taken: 0, disagreed: 0 });
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
describe('the judge Rigour launches', () => {
|
|
766
|
+
it('runs claude with every memory file switched off, and records the isolation as unverified below the version it was verified in', async () => {
|
|
767
|
+
const seen = seenNow();
|
|
768
|
+
const result = await runReviewer(repo, 'main', ConfigSchema.parse({ version: 1, review: { reviewer: { enabled: true, reviewers: ['claude'] } } }), fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined, { trigger: 'review' });
|
|
769
|
+
expect(result.outcome).toBe('passed');
|
|
770
|
+
const claude = seen.ran.findIndex(command => path.basename(command).startsWith('claude'));
|
|
771
|
+
expect(seen.env?.[claude]).toEqual({ CLAUDE_CODE_DISABLE_CLAUDE_MDS: '1', CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' });
|
|
772
|
+
// The fake reports version 1.0.0, older than the one the switches were verified in: the record says so.
|
|
773
|
+
expect(result.record?.judges.map(j => j.outside_repo)).toEqual(['claude 1.0.0: memory isolation unverified (needs 2.1.285 or later)']);
|
|
774
|
+
expect(recordLines(result.record).join('\n')).toContain('[claude 1.0.0: memory isolation unverified (needs 2.1.285 or later)]');
|
|
775
|
+
const current = seenNow();
|
|
776
|
+
// The installed fake is claude on Unix and claude.cmd on Windows: name both.
|
|
777
|
+
current.versions = { [path.join(bins[0], 'claude')]: '2.1.285 (Claude Code)', [path.join(bins[0], 'claude.cmd')]: '2.1.285 (Claude Code)' };
|
|
778
|
+
const verified = await runReviewer(repo, 'main', ConfigSchema.parse({ version: 1, review: { reviewer: { enabled: true, reviewers: ['claude'] } } }), fakes(() => JSON.stringify(EMPTY), current, null), () => undefined, { trigger: 'review', force: true });
|
|
779
|
+
expect(verified.record?.judges.map(j => j.outside_repo)).toEqual([undefined]);
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
describe("the review on the task's thread", () => {
|
|
783
|
+
it('appends each review of a branch to its task, and never a backtest replaying history', async () => {
|
|
784
|
+
const seen = seenNow();
|
|
785
|
+
await runReviewer(repo, 'main', ConfigSchema.parse({ version: 1, review: { reviewer: { enabled: true, reviewers: ['claude'] } } }), fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined, { trigger: 'review' });
|
|
786
|
+
const thread = readThread(repo, 'feature');
|
|
787
|
+
expect(thread?.events.map(e => [e.kind, e.trigger, e.outcome, e.blocking])).toEqual([['review', 'review', 'passed', 0]]);
|
|
788
|
+
expect(thread?.events[0].integrity).toEqual(expect.any(String));
|
|
789
|
+
await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [] }), seenNow()), () => undefined, { pr: 42, reviewsBefore: '2026-10-03', force: true });
|
|
790
|
+
expect(readThread(repo, 'feature')?.events).toHaveLength(1);
|
|
791
|
+
});
|
|
792
|
+
});
|
|
@@ -5,14 +5,26 @@ export interface RepoRule {
|
|
|
5
5
|
text: string;
|
|
6
6
|
/** Paths the rule names (files or directories). */
|
|
7
7
|
paths: string[];
|
|
8
|
+
/** Where the rule applies: the folder of the nested rules file it came from (`services/billing/`); the whole repository when absent. */
|
|
9
|
+
scope?: string;
|
|
8
10
|
/** Identifiers the rule names. */
|
|
9
11
|
symbols: string[];
|
|
10
12
|
/** Worded as a requirement: a break can block. Guidance otherwise: a break is shown. */
|
|
11
13
|
requirement: boolean;
|
|
12
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Every rule file of the repository: the root ones, the AGENTS.md and CLAUDE.md files in folders below it (outside
|
|
17
|
+
* vendored folders), the rule directories, and every file a rule file imports with an `@path` line (relative to the
|
|
18
|
+
* importing file, inside the repository). A judge does not load these by itself; this is how the repository's rules
|
|
19
|
+
* reach it. A nested file's rules apply to its own folder only, and so do the rules of the files it imports.
|
|
20
|
+
*/
|
|
13
21
|
export declare function readRepoRules(cwd: string): RepoRule[];
|
|
14
22
|
/** One rule per top-level bullet or paragraph; headings and import lines are not rules. */
|
|
15
23
|
export declare function splitRules(source: string, text: string): RepoRule[];
|
|
16
24
|
export declare function rulesSection(rules: RepoRule[]): string;
|
|
17
|
-
/**
|
|
18
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The rules that apply to a diff's changed files and added identifiers, the top `limit`. `namedOnly`: only rules that
|
|
27
|
+
* name a path or identifier the change touches (a briefing has no code to judge applicability against, so a rule that
|
|
28
|
+
* merely shares words with a large change is noise there).
|
|
29
|
+
*/
|
|
30
|
+
export declare function rulesForDiff(cwd: string, diff: string, enabled?: boolean, limit?: number, namedOnly?: boolean): RepoRule[];
|