@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.
Files changed (40) hide show
  1. package/dist/brief/briefing.d.ts +59 -0
  2. package/dist/brief/briefing.js +126 -0
  3. package/dist/brief/briefing.test.d.ts +1 -0
  4. package/dist/brief/briefing.test.js +107 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +2 -0
  7. package/dist/review/backtest-init.d.ts +8 -5
  8. package/dist/review/backtest-init.js +28 -10
  9. package/dist/review/backtest-init.test.js +39 -1
  10. package/dist/review/backtest-last.js +3 -1
  11. package/dist/review/backtest.d.ts +10 -0
  12. package/dist/review/backtest.js +11 -2
  13. package/dist/review/backtest.test.js +12 -0
  14. package/dist/review/reviewer/adapters.d.ts +4 -0
  15. package/dist/review/reviewer/adapters.js +23 -0
  16. package/dist/review/reviewer/adapters.test.js +40 -0
  17. package/dist/review/reviewer/inputs.d.ts +6 -0
  18. package/dist/review/reviewer/inputs.js +46 -6
  19. package/dist/review/reviewer/inputs.test.d.ts +1 -0
  20. package/dist/review/reviewer/inputs.test.js +52 -0
  21. package/dist/review/reviewer/prompt.js +8 -2
  22. package/dist/review/reviewer/record.d.ts +5 -0
  23. package/dist/review/reviewer/record.js +3 -3
  24. package/dist/review/reviewer/record.test.js +8 -0
  25. package/dist/review/reviewer/verdict.d.ts +40 -1
  26. package/dist/review/reviewer/verdict.js +150 -9
  27. package/dist/review/reviewer.d.ts +2 -0
  28. package/dist/review/reviewer.js +29 -9
  29. package/dist/review/reviewer.test.js +173 -8
  30. package/dist/review-learning/repo-rules.d.ts +14 -2
  31. package/dist/review-learning/repo-rules.js +59 -9
  32. package/dist/review-learning/repo-rules.test.js +39 -1
  33. package/dist/task/thread.d.ts +47 -0
  34. package/dist/task/thread.js +234 -0
  35. package/dist/task/thread.test.d.ts +1 -0
  36. package/dist/task/thread.test.js +133 -0
  37. package/dist/templates/universal-config.js +4 -0
  38. package/dist/types/index.d.ts +21 -0
  39. package/dist/types/index.js +7 -0
  40. 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
- const accepted = verdict.prior_points.filter(p => p.severity === 'non-blocking');
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 ? open : unverified).push(item);
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 verdict.prior_points) {
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({ 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 } : {}) });
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 the finding it caused).
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 !== 'prior' && ((k.class === item.class && textSimilarity(k, item) >= SAME_POINT) || (nearby(k) && textSimilarity(k, item) >= SAME_PLACE)));
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';
@@ -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
- await trackUsage('reviewer_completed', reviewerUsage(result, options.trigger ?? (options.reviewsBefore ? 'backtest' : 'review')));
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'], ['finding', '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('shows the same point found in several places as one item with every location, and never merges human points', () => {
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 the window', severity: 'blocking', resolved: false, file: 'src/job.ts', line: 1, quote: 'export function job() {' },
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
- ['prior', 'prior point', []], ['prior', 'prior point', []],
458
- // The same point in another file, and the same point said as another class on the next line: one item, every place.
459
- ['finding', 'production-cost', [{ file: 'a.ts', line: 1 }, { file: 'src/job.ts', line: 2 }]],
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
- /** The rules that apply to a diff's changed files and added identifiers, the top `limit`. */
18
- export declare function rulesForDiff(cwd: string, diff: string, enabled?: boolean, limit?: number): RepoRule[];
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[];