@rigour-labs/core 6.7.7 → 6.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,6 +31,8 @@ export interface ContextInput {
31
31
  router: RouterPolicy | undefined;
32
32
  /** Which of the team's review lessons the judges see (gates.deep.review_lessons): verified by default, all, or off. */
33
33
  lessons?: LessonMode;
34
+ /** The pull request under review: lessons learned only from it are its own reviews, which the judge already reads. */
35
+ pr?: number;
34
36
  /** The previous verdict's panel decisions, and the files changed since it. */
35
37
  previousPanel: PanelItem[] | undefined;
36
38
  touched: Set<string>;
@@ -24,6 +24,9 @@ export const REVIEW_DISMISSALS = path.join('.rigour', 'dismissed-review-items.js
24
24
  const MAX_DOCS = 10;
25
25
  /** Team standards a judge is shown with the lessons about the changed files. */
26
26
  const JUDGE_STANDARDS = 15;
27
+ /** File lessons a judge is shown: on a pull request touching a hundred files, enough for every file, at most this many per file. */
28
+ const JUDGE_FILE_LESSONS = 30;
29
+ const JUDGE_LESSONS_PER_FILE = 3;
27
30
  /** Rules from the repository's own rules files a judge is asked to answer, most relevant first. */
28
31
  const JUDGE_RULES = 15;
29
32
  const MAX_SETTLED = 40;
@@ -78,7 +81,7 @@ export function buildContext(input) {
78
81
  task = undefined;
79
82
  }
80
83
  // A judge reads the whole pull request: more of what the team taught fits than an agent's one question at the stop.
81
- const lessons = input.lessons === 'off' ? [] : lessonsForDiff(input.cwd, input.diff, input.lessons, JUDGE_STANDARDS).map(lessonView);
84
+ const lessons = input.lessons === 'off' ? [] : lessonsForDiff(input.cwd, input.diff, input.lessons, JUDGE_STANDARDS, JUDGE_FILE_LESSONS, JUDGE_LESSONS_PER_FILE, input.pr).map(lessonView);
82
85
  if (lessons.length)
83
86
  sections.push(`## Lessons this team taught on earlier reviews, for what this change touches (context: a lesson never blocks on its own; a finding still needs its quote)\n${lessons.map(l => `- ${describeLesson(l)}`).join('\n')}`);
84
87
  // The repository's own rules, always: the reviewer is the boundary, and what the team wrote is the standard it checks.
@@ -224,7 +224,8 @@ export function account(verdict, previousOpen, verify) {
224
224
  for (const r of verdict.rules ?? []) {
225
225
  if (r.status !== 'broken' || !r.rule)
226
226
  continue;
227
- const item = { id: id('repo-rule', r.file, r.id), kind: 'rule', class: 'repo-rule', file: r.file, line: r.line, issue: `breaks a rule this repository wrote for itself (${r.source}): ${r.rule}`, consequence: r.requirement ? 'the team wrote this rule as a requirement' : 'the team wrote this rule as guidance', ...(r.quote ? { quote: r.quote } : {}), evidence: r.evidence, reviewer: r.reviewer };
227
+ // The rule's own words are the issue, so the same point found as a finding reads alike; where it came from is the evidence.
228
+ const item = { id: id('repo-rule', r.file, r.id), kind: 'rule', class: 'repo-rule', file: r.file, line: r.line, issue: r.rule, consequence: r.requirement ? 'the team wrote this rule as a requirement' : 'the team wrote this rule as guidance', ...(r.quote ? { quote: r.quote } : {}), evidence: `breaks a rule this repository wrote for itself (${r.source})${r.evidence ? `: ${r.evidence}` : ''}`, reviewer: r.reviewer };
228
229
  if (r.requirement)
229
230
  add(item);
230
231
  else
@@ -274,8 +275,10 @@ export function account(verdict, previousOpen, verify) {
274
275
  }
275
276
  return { open: onePerRootCause(open), unverified, resolved, answerInReply, notes, advisory: onePerRootCause(advisory) };
276
277
  }
277
- /** How alike two items' words must be to be the same point made in two places. */
278
+ /** 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. */
278
279
  const SAME_POINT = 0.6;
280
+ const SAME_PLACE = 0.3;
281
+ const SAME_LINES = 3;
279
282
  /**
280
283
  * The same point found in several places is one item carrying every location, so a person reads one
281
284
  * line, not one per file. Blocking is unchanged: the item blocks until every location is fixed.
@@ -283,7 +286,9 @@ const SAME_POINT = 0.6;
283
286
  function onePerRootCause(items) {
284
287
  const kept = [];
285
288
  for (const item of items) {
286
- const same = item.kind === 'prior' ? undefined : kept.find(k => k.kind !== 'prior' && k.class === item.class && textSimilarity(k, item) >= SAME_POINT);
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).
290
+ 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)));
287
292
  if (!same) {
288
293
  kept.push(item);
289
294
  continue;
@@ -141,7 +141,7 @@ async function review(cwd, base, config, exec, progress, options) {
141
141
  const sincePrevious = previousIsAncestor ? new Set((await git(['diff', '--name-only', `${previous.head}..HEAD`])).split('\n').filter(Boolean)) : new Set();
142
142
  const changedFiles = [...fullDiff.matchAll(/^diff --git a\/.* b\/(.*)$/gm)].map(m => m[1]);
143
143
  const context = buildContext({
144
- cwd, stateRoot, dismissals, diff: fullDiff, router: config.gates.deep?.router, lessons: config.gates.deep?.review_lessons, touched: sincePrevious, checks: options.checks ?? [],
144
+ cwd, stateRoot, dismissals, diff: fullDiff, router: config.gates.deep?.router, lessons: config.gates.deep?.review_lessons, ...(pr ? { pr: pr.number } : {}), touched: sincePrevious, checks: options.checks ?? [],
145
145
  previousPanel: previousIsAncestor ? store.readJson(previous.verdict)?.panel?.items : undefined,
146
146
  docs: await relatedDocs(cwd, changedFiles, exec),
147
147
  });
@@ -420,7 +420,7 @@ describe('verdicts', () => {
420
420
  return { verdict, ...account(verdict, undefined, verify) };
421
421
  };
422
422
  const broken = judged([{ id: 'r1', status: 'broken', file: 'src/job.ts', line: 2, quote: 'return 1;', evidence: 'no lock before the read' }]);
423
- expect(broken.open.map(i => [i.kind, i.class, i.issue])).toEqual([['rule', 'repo-rule', 'breaks a rule this repository wrote for itself (AGENTS.md): Every job must take the lock before its first read.']]);
423
+ expect(broken.open.map(i => [i.kind, i.class, i.issue, i.evidence])).toEqual([['rule', 'repo-rule', 'Every job must take the lock before its first read.', 'breaks a rule this repository wrote for itself (AGENTS.md): no lock before the read']]);
424
424
  expect(judged([{ id: 'r1', status: 'broken', file: 'src/job.ts', line: 2 }])).toMatchObject({ open: [], unverified: [expect.objectContaining({ kind: 'rule' })] }); // no quote: not shown as a block
425
425
  expect(judged([{ id: 'r2', status: 'broken', file: 'src/job.ts', line: 2, quote: 'return 1;' }])).toMatchObject({ open: [], advisory: [expect.objectContaining({ class: 'repo-rule' })] }); // guidance: shown, never a block
426
426
  expect(judged([{ id: 'r1', status: 'followed' }, { id: 'r1', status: 'not-applicable' }])).toMatchObject({ open: [], notes: [], advisory: [], unverified: [] });
@@ -437,9 +437,14 @@ describe('verdicts', () => {
437
437
  const { open } = account(verdict, undefined, checkoutVerifier(repo));
438
438
  expect(open.map(i => [i.kind, i.class, i.locations ?? []])).toEqual([
439
439
  ['prior', 'prior point', []], ['prior', 'prior point', []],
440
- ['finding', 'production-cost', [{ file: 'a.ts', line: 1 }]], // the same point in another file: one item, both places
441
- ['finding', 'correctness', []], // a different class is a different point
440
+ // The same point in another file, and the same point said as another class on the next line: one item, every place.
441
+ ['finding', 'production-cost', [{ file: 'a.ts', line: 1 }, { file: 'src/job.ts', line: 2 }]],
442
442
  ]);
443
+ // A rule break and the finding it caused, on the same lines and in like words, are one item.
444
+ 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;' }],
445
+ 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));
446
+ expect(twice.open.map(i => i.class)).toEqual(['repo-rule']);
447
+ expect(twice.open[0].locations).toEqual([{ file: 'src/job.ts', line: 2 }]);
443
448
  });
444
449
  it('keeps reads, scans, redundancy and merge impact as notes with stable ids, and answers non-blocking points in the reply', () => {
445
450
  const verdict = {
@@ -94,6 +94,8 @@ export declare function matchLessons(lessons: ReviewLesson[], change: ChangeShap
94
94
  includeCandidates?: boolean;
95
95
  limit?: number;
96
96
  standards?: number;
97
+ perFile?: number;
98
+ excludePr?: number;
97
99
  }): ReviewLesson[];
98
100
  /** RIGOUR_REVIEW_LESSONS points at a lessons file outside the clone (CI, or a team's shared copy). */
99
101
  export declare function lessonsPath(cwd: string): string;
@@ -179,6 +179,9 @@ export function mergeLessons(existing, incoming) {
179
179
  * change; the best-evidenced few follow the file lessons.
180
180
  */
181
181
  export function matchLessons(lessons, change, options = {}) {
182
+ // A lesson whose only evidence is the pull request under review is already in front of the judge as the reviewer's own points.
183
+ if (options.excludePr !== undefined)
184
+ lessons = lessons.filter(l => !l.evidence.length || l.evidence.some(e => e.pr !== options.excludePr));
182
185
  const dirs = new Set(change.files.map(f => path.posix.dirname(f)));
183
186
  const scored = lessons
184
187
  .filter(l => !!l.file && (l.state === 'verified' || (options.includeCandidates && l.state === 'candidate')) && !NOT_CODE.test(l.file))
@@ -199,7 +202,20 @@ export function matchLessons(lessons, change, options = {}) {
199
202
  .sort((a, b) => b.shared - a.shared || b.lesson.evidence.length - a.lesson.evidence.length)
200
203
  .slice(0, options.standards ?? MAX_STANDARDS)
201
204
  .map(s => s.lesson);
202
- return [...scored.slice(0, options.limit ?? 5).map(s => s.lesson), ...standards];
205
+ // On a large change, one file's many lessons must not crowd out another file's only one: a cap per file, then the total.
206
+ const perFile = options.perFile ?? Infinity;
207
+ const taken = [];
208
+ const perFileCount = new Map();
209
+ for (const { lesson } of scored) {
210
+ if (taken.length >= (options.limit ?? 5))
211
+ break;
212
+ const n = perFileCount.get(lesson.file) ?? 0;
213
+ if (n >= perFile)
214
+ continue;
215
+ perFileCount.set(lesson.file, n + 1);
216
+ taken.push(lesson);
217
+ }
218
+ return [...taken, ...standards];
203
219
  }
204
220
  /** RIGOUR_REVIEW_LESSONS points at a lessons file outside the clone (CI, or a team's shared copy). */
205
221
  export function lessonsPath(cwd) {
@@ -29,6 +29,9 @@ describe('repository rules', () => {
29
29
  expect(rules[1]).toMatchObject({ paths: ['src/lib/delivery.ts'], symbols: ['deliverOrder'] });
30
30
  expect(rules[0].paths).toEqual(['migrations/']);
31
31
  expect(rules.map(r => r.requirement)).toEqual([true, true, false]); // "never", "every"; "prefer" is guidance
32
+ // A section that only describes an exception, with no imperative, is guidance; one that ends in an imperative is a requirement.
33
+ const [exceptionOnly, withImperative] = splitRules('AGENTS.md', '- **One narrow exception:** the queue table is still literally named `study_jobs`, not renamed with the feature.\n\n- **One narrow exception:** the queue table is still literally named `study_jobs`. Import the `JOBS_TABLE` constant; do not inline the raw table name again.\n');
34
+ expect([exceptionOnly.requirement, withImperative.requirement]).toEqual([false, true]);
32
35
  expect(rules[0].id).toMatch(/^[0-9a-f]{10}$/);
33
36
  expect(splitRules('AGENTS.md', AGENTS)[0].id).toBe(rules[0].id); // stable across runs
34
37
  });
@@ -95,6 +95,15 @@ describe('lessons', () => {
95
95
  const change = { files: ['src/orders.ts'], symbols: new Set(['insert', 'insertOrder', 'orderId']) };
96
96
  expect(matchLessons(lessons, change).map(l => l.id)).toEqual(['2', '1']); // two specific shared names outrank a same-file lesson with only generic ones
97
97
  expect(matchLessons(lessons, change, { includeCandidates: true }).map(l => l.id)).toEqual(['2', '1', '3']);
98
+ // A lesson learned only from the pull request under review is its own reviews, already in front of the judge.
99
+ const own = { ...base, id: '7', text: 'from this very pull request', file: 'src/orders.ts', state: 'verified', evidence: [{ pr: 42, comment: 'c', author: 'r' }] };
100
+ const also = { ...own, id: '8', text: 'from this and another', evidence: [{ pr: 42, comment: 'c', author: 'r' }, { pr: 3, comment: 'd', author: 'r' }] };
101
+ expect(matchLessons([...lessons, own, also], change, { excludePr: 42 }).map(l => l.id).sort()).toEqual(['1', '2', '8']); // '7' is left out; '8' has evidence elsewhere too
102
+ // One file's many lessons never crowd out another file's only one.
103
+ const many = Array.from({ length: 6 }, (_, i) => ({ ...base, id: `m${i}`, text: `orders lesson ${i}`, file: 'src/orders.ts', state: 'verified', symbols: ['insertOrder', 'orderId'] }));
104
+ const lone = { ...base, id: 'lone', text: 'the only lesson about the manifest', file: 'src/manifest.sha', state: 'verified' };
105
+ const served = matchLessons([...many, lone], { files: ['src/orders.ts', 'src/manifest.sha'], symbols: new Set(['insertOrder', 'orderId']) }, { limit: 4, perFile: 3 });
106
+ expect(served.map(l => l.id)).toEqual(['m0', 'm1', 'm2', 'lone']);
98
107
  });
99
108
  });
100
109
  describe('learning from one pull request as it goes', () => {
@@ -4,8 +4,8 @@ export type LessonMode = 'verified' | 'all' | 'off';
4
4
  export declare const DEFAULT_LESSON_MODE: LessonMode;
5
5
  /** The team's review lessons in play for this mode: none when off, verified ones by default. */
6
6
  export declare function activeLessons(cwd: string, mode?: LessonMode): ReviewLesson[];
7
- /** `standards`: how many team standards may come with the file lessons (a judge reading a whole pull request takes more than an agent's one question). */
8
- export declare function lessonsForDiff(cwd: string, diff: string, mode?: LessonMode, standards?: number): ReviewLesson[];
7
+ /** `standards`, `limit`, `perFile`: how many team standards and file lessons may come, and how many per file (a judge reading a whole pull request takes more than an agent's one question). */
8
+ export declare function lessonsForDiff(cwd: string, diff: string, mode?: LessonMode, standards?: number, limit?: number, perFile?: number, excludePr?: number): ReviewLesson[];
9
9
  /** The points this team rejected that a change touches: what the judges are told is settled. */
10
10
  export declare function rejectedForDiff(cwd: string, diff: string): ReviewLesson[];
11
11
  /** A lesson as a judge or agent sees it, in one place. */
@@ -13,14 +13,14 @@ export function activeLessons(cwd, mode = DEFAULT_LESSON_MODE) {
13
13
  return [];
14
14
  return readLessons(cwd).filter(l => mode === 'all' || l.state === 'verified');
15
15
  }
16
- /** `standards`: how many team standards may come with the file lessons (a judge reading a whole pull request takes more than an agent's one question). */
17
- export function lessonsForDiff(cwd, diff, mode = DEFAULT_LESSON_MODE, standards) {
16
+ /** `standards`, `limit`, `perFile`: how many team standards and file lessons may come, and how many per file (a judge reading a whole pull request takes more than an agent's one question). */
17
+ export function lessonsForDiff(cwd, diff, mode = DEFAULT_LESSON_MODE, standards, limit, perFile, excludePr) {
18
18
  if (mode === 'off')
19
19
  return [];
20
20
  const lessons = readLessons(cwd);
21
21
  if (lessons.length === 0)
22
22
  return [];
23
- return matchLessons(lessons, changeShape(diff), { includeCandidates: mode === 'all', ...(standards !== undefined ? { standards } : {}) });
23
+ return matchLessons(lessons, changeShape(diff), { includeCandidates: mode === 'all', ...(standards !== undefined ? { standards } : {}), ...(limit !== undefined ? { limit } : {}), ...(perFile !== undefined ? { perFile } : {}), ...(excludePr !== undefined ? { excludePr } : {}) });
24
24
  }
25
25
  /** The points this team rejected that a change touches: what the judges are told is settled. */
26
26
  export function rejectedForDiff(cwd, diff) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.7.7",
3
+ "version": "6.7.8",
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.7",
76
- "@rigour-labs/brain-darwin-x64": "6.7.7",
77
- "@rigour-labs/brain-linux-arm64": "6.7.7",
78
- "@rigour-labs/brain-win-x64": "6.7.7",
79
- "@rigour-labs/brain-linux-x64": "6.7.7"
75
+ "@rigour-labs/brain-darwin-arm64": "6.7.8",
76
+ "@rigour-labs/brain-darwin-x64": "6.7.8",
77
+ "@rigour-labs/brain-linux-x64": "6.7.8",
78
+ "@rigour-labs/brain-linux-arm64": "6.7.8",
79
+ "@rigour-labs/brain-win-x64": "6.7.8"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@types/fs-extra": "^11.0.4",