@rigour-labs/core 6.7.9 → 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/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 +7 -6
- package/dist/review/reviewer.test.js +80 -2
- 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');
|
|
@@ -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);
|
|
@@ -334,7 +335,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
334
335
|
});
|
|
335
336
|
verdict = { ...verdict, panel: { judgeItemIds: judgeItems.flat().map(item => item.id), items }, reviewers: [...(verdict.reviewers ?? []), ...cross] };
|
|
336
337
|
}
|
|
337
|
-
const accounted = decide(verdict, previousOpen, verify, dismissals);
|
|
338
|
+
const accounted = decide(verdict, previousOpen, verify, prior, dismissals);
|
|
338
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() } });
|
|
339
340
|
store.writeJson(openFile, accounted.open);
|
|
340
341
|
store.writeJson(store.decidedPath(verdictFile), accounted);
|
|
@@ -347,8 +348,8 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
347
348
|
}
|
|
348
349
|
}
|
|
349
350
|
/** 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);
|
|
351
|
+
function decide(verdict, previousOpen, verify, prior, dismissals) {
|
|
352
|
+
const accounted = account(verdict, previousOpen, verify, prior);
|
|
352
353
|
// An item an earlier round confirmed stays open until it is resolved with evidence, whatever this panel says of it.
|
|
353
354
|
const earlier = new Set((previousOpen ?? []).map(item => item.id));
|
|
354
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'] } } });
|
|
@@ -326,7 +326,8 @@ describe('the reviewer', () => {
|
|
|
326
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;' }] : [] });
|
|
327
327
|
const result = await runReviewer(repo, 'main', config, fakes(by, seen), () => undefined, { full: true });
|
|
328
328
|
expect(result.reviewers).toEqual(['claude', 'cursor']);
|
|
329
|
-
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
|
|
330
331
|
expect(seen.prompts).toHaveLength(2);
|
|
331
332
|
});
|
|
332
333
|
});
|
|
@@ -625,3 +626,80 @@ describe('what the team already knows', () => {
|
|
|
625
626
|
expect(result.notes).toEqual([]);
|
|
626
627
|
});
|
|
627
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-
|
|
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",
|