@rigour-labs/core 6.7.2 → 6.7.4

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 (77) hide show
  1. package/dist/hooks/index.d.ts +3 -3
  2. package/dist/hooks/index.js +2 -2
  3. package/dist/hooks/session-state.d.ts +3 -0
  4. package/dist/hooks/session-state.js +10 -0
  5. package/dist/hooks/stop-review.d.ts +8 -15
  6. package/dist/hooks/stop-review.js +36 -2
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.js +4 -2
  9. package/dist/review/backtest-init.js +13 -11
  10. package/dist/review/backtest-init.test.js +26 -2
  11. package/dist/review/backtest.js +35 -3
  12. package/dist/review/backtest.test.js +44 -2
  13. package/dist/review/dead-code.test.js +13 -0
  14. package/dist/review/orphan-files.js +3 -2
  15. package/dist/review/quiet.d.ts +1 -1
  16. package/dist/review/quiet.js +3 -1
  17. package/dist/review/review-task.d.ts +2 -6
  18. package/dist/review/review-task.js +2 -2
  19. package/dist/review/reviewer/adapters.d.ts +21 -0
  20. package/dist/review/reviewer/adapters.js +70 -15
  21. package/dist/review/reviewer/adapters.test.js +22 -0
  22. package/dist/review/reviewer/background.test.js +1 -1
  23. package/dist/review/reviewer/context.d.ts +3 -0
  24. package/dist/review/reviewer/context.js +14 -5
  25. package/dist/review/reviewer/context.test.js +26 -1
  26. package/dist/review/reviewer/exec.d.ts +9 -0
  27. package/dist/review/reviewer/exec.js +28 -1
  28. package/dist/review/reviewer/exec.test.js +38 -2
  29. package/dist/review/reviewer/inputs.d.ts +6 -0
  30. package/dist/review/reviewer/inputs.js +26 -0
  31. package/dist/review/reviewer/judge-env.d.ts +3 -0
  32. package/dist/review/reviewer/judge-env.js +10 -0
  33. package/dist/review/reviewer/prompt.js +68 -12
  34. package/dist/review/reviewer/rule-writer.d.ts +10 -0
  35. package/dist/review/reviewer/rule-writer.js +30 -0
  36. package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
  37. package/dist/review/reviewer/rule-writer.test.js +52 -0
  38. package/dist/review/reviewer/settings.d.ts +4 -0
  39. package/dist/review/reviewer/settings.js +2 -1
  40. package/dist/review/reviewer/verdict.d.ts +71 -9
  41. package/dist/review/reviewer/verdict.js +102 -10
  42. package/dist/review/reviewer.d.ts +5 -2
  43. package/dist/review/reviewer.js +56 -41
  44. package/dist/review/reviewer.test.js +170 -17
  45. package/dist/review/typed/constant-inputs.d.ts +35 -0
  46. package/dist/review/typed/constant-inputs.js +168 -0
  47. package/dist/review/typed/dead-null-guards.d.ts +34 -0
  48. package/dist/review/typed/dead-null-guards.js +147 -0
  49. package/dist/review/typed/redundancy.js +6 -0
  50. package/dist/review/typed/redundancy.test.js +136 -0
  51. package/dist/review/unused-exports.js +3 -2
  52. package/dist/review-learning/acted-on.d.ts +23 -3
  53. package/dist/review-learning/acted-on.js +25 -13
  54. package/dist/review-learning/human-edits.d.ts +7 -0
  55. package/dist/review-learning/human-edits.js +99 -0
  56. package/dist/review-learning/human-edits.test.d.ts +1 -0
  57. package/dist/review-learning/human-edits.test.js +47 -0
  58. package/dist/review-learning/learn-from-reviews.d.ts +37 -4
  59. package/dist/review-learning/learn-from-reviews.js +128 -12
  60. package/dist/review-learning/lessons.d.ts +62 -9
  61. package/dist/review-learning/lessons.js +122 -20
  62. package/dist/review-learning/outcomes.d.ts +26 -0
  63. package/dist/review-learning/outcomes.js +73 -0
  64. package/dist/review-learning/review-learning.test.js +149 -22
  65. package/dist/review-learning/review-points.d.ts +4 -0
  66. package/dist/review-learning/review-points.js +14 -0
  67. package/dist/review-learning/rules-from-reviews.d.ts +14 -0
  68. package/dist/review-learning/rules-from-reviews.js +86 -0
  69. package/dist/review-learning/team-lessons.d.ts +17 -1
  70. package/dist/review-learning/team-lessons.js +29 -5
  71. package/dist/templates/universal-config.js +3 -0
  72. package/dist/types/index.d.ts +63 -0
  73. package/dist/types/index.js +11 -0
  74. package/dist/utils/profile.d.ts +5 -1
  75. package/dist/utils/profile.js +7 -1
  76. package/dist/utils/profile.test.js +10 -0
  77. package/package.json +6 -6
@@ -0,0 +1,73 @@
1
+ import { changedHunks } from './acted-on.js';
2
+ /** Commit subjects that say the commit fixed something. */
3
+ const FIX = /\b(fix(e[sd])?|bug|hotfix|regression|revert|broke|broken)\b/i;
4
+ const DAY_MS = 24 * 60 * 60 * 1000;
5
+ /** The outcome or counter evidence for an inline point the pull request did not act on, or undefined when there is none yet. */
6
+ export function outcomeFor(git, lesson, pr, options) {
7
+ const point = lesson.evidence.find(e => (e.kind ?? 'point') === 'point' && e.pr === pr.number);
8
+ if (!point || point.actedOn || !lesson.at || !lesson.file || !pr.mergedAt)
9
+ return undefined;
10
+ let range = shift(changedHunks(git, lesson.at.commit, pr.mergeSha, lesson.file), [lesson.at.start, lesson.at.end]);
11
+ if (!range)
12
+ return undefined; // the pull request changed them after all
13
+ const before = options.until ? [`--before=${options.until}`] : [];
14
+ let commits = [];
15
+ try {
16
+ commits = git(['rev-list', '--reverse', '--first-parent', ...before, `${pr.mergeSha}..${options.mainRef}`, '--', lesson.file]).split('\n').filter(Boolean);
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ for (const sha of commits) {
22
+ const hunks = changedHunks(git, `${sha}^`, sha, lesson.file);
23
+ const next = shift(hunks, range);
24
+ if (next) {
25
+ range = next;
26
+ continue;
27
+ }
28
+ const [subject, date] = git(['log', '-1', '--format=%s%x09%cI', sha]).trim().split('\t');
29
+ // Changed by a commit that says it fixed something: the point was right. Changed otherwise: no verdict.
30
+ return FIX.test(subject) ? { kind: 'outcome', pr: pr.number, comment: `outcome-${sha.slice(0, 12)}`, author: '', detail: `fixed later by ${sha.slice(0, 9)} "${subject}"`, at: date } : undefined;
31
+ }
32
+ const now = options.until ? Date.parse(options.until) : Date.now();
33
+ const days = Math.floor((now - Date.parse(pr.mergedAt)) / DAY_MS);
34
+ if (days < (options.windowDays ?? 30))
35
+ return undefined;
36
+ return { kind: 'counter', pr: pr.number, comment: `counter-${pr.number}-${lesson.at.start}`, author: '', detail: `the lines shipped unchanged for ${days} days after the merge`, at: options.until ?? new Date().toISOString() };
37
+ }
38
+ /** A revert of the pull request on main, before `until`: an outcome for every point it left unaddressed. */
39
+ export function revertOf(git, pr, options) {
40
+ let log = '';
41
+ try {
42
+ log = git(['log', '--first-parent', '--format=%H%x09%cI%x09%s', ...(options.until ? [`--before=${options.until}`] : []), `${pr.mergeSha}..${options.mainRef}`]);
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ for (const line of log.split('\n')) {
48
+ const [sha, date, subject] = line.split('\t');
49
+ if (sha && /^revert\b/i.test(subject ?? '') && (subject.includes(`#${pr.number}`) || subject.includes(pr.mergeSha.slice(0, 7)))) {
50
+ return { kind: 'outcome', pr: pr.number, comment: `revert-${sha.slice(0, 12)}`, author: '', detail: `the pull request was reverted by ${sha.slice(0, 9)} "${subject}"`, at: date };
51
+ }
52
+ }
53
+ return undefined;
54
+ }
55
+ /** The range after a commit's hunks, or undefined when a hunk touched it. */
56
+ function shift(hunks, [start, end]) {
57
+ let offset = 0;
58
+ for (const [s, e, removed, added] of hunks) {
59
+ if (removed === 0) {
60
+ // An insertion after line s: inside the range touches it, before it moves it down.
61
+ if (s >= start && s < end)
62
+ return undefined;
63
+ if (s < start)
64
+ offset += added;
65
+ continue;
66
+ }
67
+ if (s <= end && start <= e)
68
+ return undefined;
69
+ if (e < start)
70
+ offset += added - removed;
71
+ }
72
+ return [start + offset, end + offset];
73
+ }
@@ -3,9 +3,12 @@ import fs from 'fs';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
5
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
- import { actedOn, gitIn } from './acted-on.js';
6
+ import { gitIn, withActedOn } from './acted-on.js';
7
7
  import { learnFromReviews } from './learn-from-reviews.js';
8
- import { isSpecific, lessonText, matchLessons, mergeLessons, readLessons } from './lessons.js';
8
+ import { outcomeFor } from './outcomes.js';
9
+ import { rulesFromReviews } from './rules-from-reviews.js';
10
+ import { describeLesson, lessonView } from './team-lessons.js';
11
+ import { decideLesson, isSpecific, lessonState, lessonText, matchLessons, mergeLessons, readLessons, writeLessons } from './lessons.js';
9
12
  import { lessonsForDiff, lessonsSection } from './team-lessons.js';
10
13
  let repo;
11
14
  const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
@@ -23,17 +26,17 @@ beforeEach(() => {
23
26
  });
24
27
  afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
25
28
  const comment = (id, sha, start, body, prNumber = 7) => ({ id, prNumber, path: 'src/orders.ts', start, end: start, commit: sha, body, author: 'reviewer' });
26
- describe('actedOn', () => {
27
- it('keeps a comment whose lines changed before merge, and drops one nobody acted on', () => {
29
+ describe('acted on', () => {
30
+ it('marks a comment whose lines changed before merge, and one nobody acted on, keeping both', () => {
28
31
  write(V1);
29
32
  const reviewed = commit('pr head');
30
33
  write(V2);
31
34
  const merged = commit('address review');
32
- const acted = actedOn(gitIn(repo), { number: 7, mergeSha: merged, mergedAt: '', comments: [
35
+ const marked = withActedOn(gitIn(repo), { number: 7, mergeSha: merged, mergedAt: '', comments: [
33
36
  comment('a', reviewed, 2, '**Use an upsert keyed on `id` so a retry does not duplicate the order.**'),
34
37
  comment('b', reviewed, 3, 'Nit: return the whole order?'),
35
- ] });
36
- expect(acted.map(c => c.id)).toEqual(['a']);
38
+ ], reviews: [] });
39
+ expect(marked.map(c => [c.id, c.actedOn])).toEqual([['a', true], ['b', false]]);
37
40
  });
38
41
  });
39
42
  describe('lessons', () => {
@@ -43,16 +46,32 @@ describe('lessons', () => {
43
46
  expect(lessonText('Callers retry on timeout, so this insert duplicates orders. Please use upsert.')).toBe('Callers retry on timeout, so this insert duplicates orders.');
44
47
  expect(lessonText('### Medium Severity\n\nThe retry loop never resets `attempt`, so a second failure gives up at once.')).toBe('The retry loop never resets `attempt`, so a second failure gives up at once.');
45
48
  });
46
- it('verifies a lesson only when it was acted on in two PRs', () => {
47
- const lesson = (pr, comment) => ({
48
- id: 'x', text: 'Use upsert', file: 'src/orders.ts', symbols: ['save', 'insert'], state: 'candidate',
49
- evidence: [{ pr, comment, author: 'r' }], createdAt: '', updatedAt: '',
50
- });
51
- const once = mergeLessons([], [lesson(1, 'c1'), lesson(1, 'c2')]);
52
- expect(once.lessons[0].state).toBe('candidate');
53
- const twice = mergeLessons(once.lessons, [lesson(2, 'c3')]);
54
- expect(twice).toMatchObject({ added: 0, verified: 1 });
55
- expect(twice.lessons[0].evidence).toHaveLength(3);
49
+ it('makes a candidate a lesson only on evidence, and keeps every piece of it', () => {
50
+ const point = (pr, prAuthor, source = 'person', reviewer = `reviewer-of-${pr}`) => ({ kind: 'point', pr, comment: `c${pr}${prAuthor}`, author: reviewer, source, prAuthor });
51
+ const lesson = (...evidence) => ({ id: 'x', text: 'Use upsert', file: 'src/orders.ts', symbols: [], state: 'candidate', evidence, createdAt: '', updatedAt: '' });
52
+ expect(lessonState(lesson(point(1, 'ann')))).toEqual({ state: 'candidate' });
53
+ expect(lessonState(lesson(point(1, 'ann'), point(2, 'ann')))).toEqual({ state: 'candidate' }); // two PRs, one author: weak, not enough
54
+ expect(lessonState(lesson(point(1, 'ann'), point(2, 'bob', 'bot')))).toEqual({ state: 'verified', promotedBy: 'recurrence' }); // a bot's point counts the same
55
+ const said = (e, text) => ({ ...e, text });
56
+ expect(lessonState(lesson(said(point(1, 'ann', 'bot', 'helper[bot]'), 'Consider more tests.'), said(point(2, 'bob', 'bot', 'helper[bot]'), 'Consider more tests.')))).toEqual({ state: 'candidate' }); // one bot's template on every PR is one source
57
+ expect(lessonState(lesson(said(point(1, 'ann', 'person', 'lead'), 'Regenerate the API client after changing the schema.'), said(point(2, 'bob', 'person', 'lead'), 'The generated client is stale again.')))).toEqual({ state: 'verified', promotedBy: 'recurrence' }); // a senior re-raising it in their own words
58
+ expect(lessonState(lesson(point(1, 'ann'), point(2, 'bob'), { kind: 'norule', pr: 1, comment: 'n', author: '' }))).toEqual({ state: 'candidate' }); // no rule in it: never promoted again
59
+ const outcome = { kind: 'outcome', pr: 1, comment: 'o', author: '', detail: 'fixed later by abc' };
60
+ const counter = { kind: 'counter', pr: 1, comment: 'k', author: '', detail: 'unchanged 40 days' };
61
+ expect(lessonState(lesson(point(1, 'ann', 'bot'), outcome))).toEqual({ state: 'verified', promotedBy: 'outcome' });
62
+ expect(lessonState(lesson(point(1, 'ann'), point(2, 'bob'), counter))).toEqual({ state: 'candidate' }); // counter-evidence holds recurrence back
63
+ const accepted = { kind: 'accepted', pr: 1, comment: 'y', author: 'lead@x' };
64
+ const rejected = { kind: 'rejected', pr: 1, comment: 'n', author: 'lead@x' };
65
+ expect(lessonState(lesson(point(1, 'ann'), outcome, rejected))).toEqual({ state: 'rejected' }); // a person's decision wins
66
+ expect(lessonState(lesson(point(1, 'ann'), rejected, accepted))).toEqual({ state: 'verified', promotedBy: 'person' }); // the latest decision
67
+ expect(lessonState({ ...lesson({ pr: 1, comment: 'old', author: 'r' }), state: 'verified' })).toEqual({ state: 'verified', promotedBy: 'legacy' }); // a record from before evidence keeps its state
68
+ });
69
+ it('records a person accepting or rejecting a lesson as evidence, with who and why', () => {
70
+ writeLessons(repo, [{ id: 'x', text: 'Use upsert', file: 'src/orders.ts', symbols: [], state: 'candidate', evidence: [{ kind: 'point', pr: 1, comment: 'c', author: 'r' }], createdAt: '', updatedAt: '' }]);
71
+ expect(decideLesson(repo, 'x', 'rejected', 'lead@x', 'we accept duplicates here')).toMatchObject({ state: 'rejected' });
72
+ expect(readLessons(repo)[0].evidence.at(-1)).toMatchObject({ kind: 'rejected', author: 'lead@x', detail: 'we accept duplicates here' });
73
+ expect(matchLessons(readLessons(repo), { files: ['src/orders.ts'], symbols: new Set() }, { includeCandidates: true })).toEqual([]); // never served as a lesson
74
+ expect(decideLesson(repo, 'nope', 'accepted', 'lead@x')).toBeUndefined();
56
75
  });
57
76
  it('matches lessons by file, symbols and directory, verified only unless asked', () => {
58
77
  const base = { symbols: ['insert'], evidence: [{ pr: 1, comment: 'c', author: 'r' }], createdAt: '', updatedAt: '' };
@@ -69,6 +88,93 @@ describe('lessons', () => {
69
88
  expect(matchLessons(lessons, change, { includeCandidates: true }).map(l => l.id)).toEqual(['2', '1', '3']);
70
89
  });
71
90
  });
91
+ describe('learning from one pull request as it goes', () => {
92
+ it('reads its reviews up to a time, and counts a point acted on by the head as of then', async () => {
93
+ write(V1);
94
+ const reviewed = commit('pr head');
95
+ write(V2);
96
+ const fixed = commit('address review');
97
+ const api = 'https://api.github.com/repos/acme/app';
98
+ const pages = {
99
+ [`${api}/pulls/42`]: { number: 42, user: { login: 'dev' }, head: { sha: fixed } },
100
+ [`${api}/pulls/42/commits?per_page=100&page=1`]: [
101
+ { sha: reviewed, commit: { committer: { date: '2026-09-01T00:00:00Z' } } },
102
+ { sha: fixed, commit: { committer: { date: '2026-09-02T00:00:00Z' } } },
103
+ ],
104
+ [`${api}/pulls/42/comments?per_page=100&page=1`]: [],
105
+ [`${api}/pulls/42/reviews?per_page=100&page=1`]: [
106
+ { id: 1, commit_id: reviewed, submitted_at: '2026-09-01T12:00:00Z', user: { login: 'priya' }, body: '- In src/orders.ts make the insert idempotent on retry.' },
107
+ { id: 2, commit_id: fixed, submitted_at: '2026-09-06T00:00:00Z', user: { login: 'priya' }, body: '- A later point the learner must not see yet.' },
108
+ ],
109
+ };
110
+ const fetchImpl = async (url) => ({ ok: url in pages, status: url in pages ? 200 : 404, json: async () => pages[url] });
111
+ const early = await learnFromReviews(repo, { token: 't', repo: 'acme/app', fetch: fetchImpl, pr: 42, until: '2026-09-01T18:00:00Z' });
112
+ expect(early).toMatchObject({ prs: 1, reviewBodies: 1, total: 1 }); // the later review is not seen yet
113
+ expect(readLessons(repo)[0]).toMatchObject({ file: 'src/orders.ts', text: 'In src/orders.ts make the insert idempotent on retry.', evidence: [{ pr: 42, author: 'priya', actedOn: false }] });
114
+ fs.rmSync(path.join(repo, '.rigour', 'review-lessons.json'));
115
+ await learnFromReviews(repo, { token: 't', repo: 'acme/app', fetch: fetchImpl, pr: 42, until: '2026-09-03T00:00:00Z' });
116
+ expect(readLessons(repo)[0].evidence[0]).toMatchObject({ actedOn: true }); // the fix landed before this cut
117
+ });
118
+ });
119
+ describe('outcome evidence from git', () => {
120
+ it('finds a later fix to the lines a point named, counts lines left alone against it, and never looks past the cut', () => {
121
+ write('export function total(order) {\n return order.items.length;\n}\n');
122
+ commit('base');
123
+ git('checkout', '-qb', 'feature');
124
+ write('export function total(order) {\n const n = order.items.length;\n return n;\n}\n');
125
+ const reviewed = commit('pr head');
126
+ git('checkout', '-q', 'main');
127
+ git('merge', '-q', '--no-ff', '--no-edit', 'feature');
128
+ const merge = git('rev-parse', 'HEAD');
129
+ write('// header\nexport function total(order) {\n const n = order.items?.length ?? 0;\n return n;\n}\n');
130
+ execFileSync('git', ['-C', repo, 'commit', '-qam', 'fix: total crashes on an order with no items'], { env: { ...process.env, GIT_COMMITTER_DATE: '2026-09-20T00:00:00Z' } });
131
+ const point = (start, actedOn = false) => ({ id: `p${start}`, text: 'Guard a missing items list.', file: 'src/orders.ts', symbols: [], state: 'candidate', at: { commit: reviewed, start, end: start }, evidence: [{ kind: 'point', pr: 7, comment: `c${start}`, author: 'bot', source: 'bot', actedOn }], createdAt: '', updatedAt: '' });
132
+ const pr = { number: 7, mergeSha: merge, mergedAt: '2026-09-01T00:00:00Z' };
133
+ const later = outcomeFor(gitIn(repo), point(2), pr, { mainRef: 'main' });
134
+ expect(later).toMatchObject({ kind: 'outcome', detail: expect.stringContaining('fix: total crashes on an order with no items') });
135
+ expect(outcomeFor(gitIn(repo), point(2), pr, { mainRef: 'main', until: '2026-09-10T00:00:00Z' })).toBeUndefined(); // before the fix, within the window: no verdict yet
136
+ expect(outcomeFor(gitIn(repo), point(3), pr, { mainRef: 'main' })).toMatchObject({ kind: 'counter' }); // `return n;` shipped and stayed, past the window
137
+ expect(outcomeFor(gitIn(repo), point(2, true), pr, { mainRef: 'main' })).toBeUndefined(); // acted on in the PR: no outcome to read
138
+ });
139
+ });
140
+ describe('team standards', () => {
141
+ it('verifies a standard when the same point recurs in another author\'s PR, and serves it only to a change it is about', () => {
142
+ const standard = (pr, text) => ({ id: `s${pr}`, text, file: '', symbols: [], state: 'candidate', evidence: [{ kind: 'point', pr, comment: `r${pr}`, author: `reviewer${pr}`, prAuthor: `dev${pr}` }], createdAt: '', updatedAt: '' });
143
+ const merged = mergeLessons([], [standard(1, 'Bound both ends of every time window a job reads.'), standard(2, 'Bound both ends of the time window this job reads.')]);
144
+ expect(merged.lessons).toHaveLength(1);
145
+ expect(merged.lessons[0].state).toBe('verified');
146
+ const unrelated = mergeLessons(merged.lessons, [standard(3, 'Log the request id on every error.')]);
147
+ expect(unrelated.lessons).toHaveLength(2);
148
+ expect(matchLessons(unrelated.lessons, { files: ['src/jobs/scan.ts'], symbols: new Set(['timeWindowStart', 'readRows']) }).map(l => l.text)).toEqual(['Bound both ends of every time window a job reads.']);
149
+ expect(matchLessons(unrelated.lessons, { files: ['src/ui/Dialog.svelte'], symbols: new Set(['onKeydown']) })).toEqual([]); // nothing in common: not served
150
+ });
151
+ });
152
+ describe('turning reviews into rules', () => {
153
+ const point = (id, text, file = '') => ({ id, text, file, symbols: [], state: 'candidate', evidence: [{ pr: 7, comment: `c-${id}`, author: 'priya' }], createdAt: '', updatedAt: '' });
154
+ it('keeps the rule behind a point, drops a status note, keeps an unanswered point as said, and keeps the words beside the rule', async () => {
155
+ const prompts = [];
156
+ const write = async (prompt) => {
157
+ prompts.push(prompt);
158
+ return 'Here you go:\n{"rules":[{"id":"a","rule":"Apply discounts before computing an invoice total.","file":null},{"id":"b","rule":null,"file":null}]}';
159
+ };
160
+ const points = [point('a', 'The invoice total is computed before the discount is applied.'), point('b', 'npm run check: 0 errors, lint clean.'), point('c', 'A retry posts the payment twice.')];
161
+ const out = await rulesFromReviews(points, write);
162
+ expect(out).toMatchObject({ rules: 1, dropped: 1, unanswered: 1 });
163
+ expect(out.lessons.map(l => [l.text, l.evidence.at(-1)?.kind])).toEqual([
164
+ ['Apply discounts before computing an invoice total.', undefined], ['npm run check: 0 errors, lint clean.', 'norule'], ['A retry posts the payment twice.', undefined],
165
+ ]); // the no-rule point is kept and marked, never sent again
166
+ expect(out.lessons[0].evidence[0].said).toBe('The invoice total is computed before the discount is applied.');
167
+ expect(prompts[0]).toContain('"said": "npm run check: 0 errors, lint clean."');
168
+ expect(describeLesson(lessonView({ ...out.lessons[0], evidence: [...out.lessons[0].evidence, { pr: 9, comment: 'x', author: 'sam' }] })))
169
+ .toBe('team standard: Apply discounts before computing an invoice total. (in their words: "The invoice total is computed before the discount is applied.") (acted on in PR #7, #9)');
170
+ });
171
+ it('never loses a point when the model cannot run, and keeps a file only when the point was about it', async () => {
172
+ const out = await rulesFromReviews([point('a', 'In src/run.ts the flag is read last.', 'src/run.ts')], async () => undefined);
173
+ expect(out.lessons.map(l => [l.text, l.file])).toEqual([['In src/run.ts the flag is read last.', 'src/run.ts']]);
174
+ const moved = await rulesFromReviews([point('a', 'A point about one place.', 'src/a.ts')], async () => '{"rules":[{"id":"a","rule":"A rule the model placed in another file.","file":"src/b.ts"}]}');
175
+ expect(moved.lessons[0].file).toBe('');
176
+ });
177
+ });
72
178
  describe('isSpecific', () => {
73
179
  it('counts identifiers that name something, not common words', () => {
74
180
  expect(['orderId', 'max_rows', 'renderTransactionalMessage'].map(isSpecific)).toEqual([true, true, true]);
@@ -84,21 +190,42 @@ describe('learnFromReviews', () => {
84
190
  const api = 'https://api.github.com/repos/acme/app';
85
191
  const pages = {
86
192
  [`${api}/pulls?state=closed&sort=updated&direction=desc&per_page=100&page=1`]: [
87
- { number: 7, merged_at: '2026-09-01T00:00:00Z', merge_commit_sha: merged },
193
+ { number: 7, merged_at: '2026-09-01T00:00:00Z', merge_commit_sha: merged, user: { login: 'dev' } },
88
194
  { number: 8, merged_at: null, merge_commit_sha: null },
89
195
  ],
90
196
  [`${api}/pulls?state=closed&sort=updated&direction=desc&per_page=100&page=2`]: [],
91
197
  [`${api}/pulls/7/comments?per_page=100`]: [
92
198
  { id: 11, path: 'src/orders.ts', original_line: 2, original_commit_id: reviewed, body: '**Use an upsert keyed on `id` so retries do not duplicate orders.**', user: { login: 'priya' } },
93
199
  { id: 12, path: 'src/orders.ts', original_line: 2, original_commit_id: reviewed, body: 'agreed', in_reply_to_id: 11, user: { login: 'sam' } },
200
+ { id: 13, path: 'src/orders.ts', original_line: 2, original_commit_id: reviewed, body: '**Rename the variable to orderRow for clarity.**', user: { login: 'review-helper[bot]', type: 'Bot' } },
201
+ { id: 14, path: 'src/orders.ts', original_line: 2, original_commit_id: reviewed, body: '**Self note: tidy this later.**', user: { login: 'dev' } },
202
+ ],
203
+ [`${api}/pulls/7/reviews?per_page=100`]: [
204
+ { id: 21, commit_id: reviewed, user: { login: 'priya' }, body: '## Review\n\n- Bound both ends of every time window a scheduled job reads.\n- In src/orders.ts the insert needs the idempotency key.\nThanks!' },
205
+ { id: 22, commit_id: reviewed, user: { login: 'review-helper[bot]', type: 'Bot' }, body: '- Consider adding more unit tests for orders.' },
206
+ { id: 23, commit_id: reviewed, user: { login: 'dev' }, body: '- Addressed every point in the next commit.' },
94
207
  ],
95
208
  };
96
209
  const fetchImpl = async (url) => ({ ok: url in pages, status: url in pages ? 200 : 404, json: async () => pages[url] });
97
210
  const result = await learnFromReviews(repo, { token: 't', repo: 'acme/app', fetch: fetchImpl });
98
- expect(result).toMatchObject({ prs: 1, comments: 1, actedOn: 1, added: 1, total: 1 });
99
- expect(readLessons(repo)[0]).toMatchObject({ file: 'src/orders.ts', text: 'Use an upsert keyed on `id` so retries do not duplicate orders.', evidence: [{ pr: 7, author: 'priya' }] });
211
+ // Every point is a candidate, the bot's too; the author's own comments are not review points.
212
+ expect(result).toMatchObject({ prs: 1, comments: 2, actedOn: 2, reviewBodies: 2, candidates: { person: 3, bot: 2 }, verified: 0 });
213
+ const lessons = readLessons(repo);
214
+ expect(lessons.every(l => l.state === 'candidate')).toBe(true); // nothing is a lesson without evidence
215
+ const sources = new Set(lessons.flatMap(l => l.evidence.map(e => `${e.author}/${e.source}/${e.prAuthor}`)));
216
+ expect([...sources].sort()).toEqual(['priya/person/dev', 'review-helper[bot]/bot/dev']);
217
+ expect(lessons.map(l => l.text)).toEqual(expect.arrayContaining(['Bound both ends of every time window a scheduled job reads.', 'Consider adding more unit tests for orders.']));
100
218
  const diff = '+++ b/src/orders.ts\n@@ -1,0 +1,1 @@\n+ await db.insert(other);\n';
101
- expect(lessonsForDiff(repo, diff, 'verified')).toEqual([]); // one PR is not enough to verify
102
- expect(lessonsSection(lessonsForDiff(repo, diff, 'all'))).toContain('src/orders.ts: Use an upsert keyed on `id` so retries do not duplicate orders. (acted on in PR #7)');
219
+ expect(lessonsForDiff(repo, diff, 'verified')).toEqual([]); // candidates are not served by default
220
+ expect(lessonsSection(lessonsForDiff(repo, diff, 'all'))).toContain('src/orders.ts: Use an upsert keyed on `id` so retries do not duplicate orders.');
221
+ const again = await learnFromReviews(repo, { token: 't', repo: 'acme/app', fetch: fetchImpl, writeRules: async () => { throw new Error('nothing promoted: no rule is written'); } });
222
+ expect(again).toMatchObject({ added: 0, total: lessons.length });
223
+ const upsert = lessons.find(l => l.text.startsWith('Use an upsert'));
224
+ decideLesson(repo, upsert.id, 'accepted', 'lead@acme');
225
+ const ruled = await learnFromReviews(repo, { token: 't', repo: 'acme/app', fetch: fetchImpl, writeRules: async (prompt) => JSON.stringify({ rules: [...prompt.matchAll(/"id": "(\w+)"/g)].map(m => ({ id: m[1], rule: 'Make every write idempotent on retry.', file: 'src/orders.ts' })) }) });
226
+ expect(ruled).toMatchObject({ rules: 1, notRules: 0, promoted: { person: 1 } }); // written only for the promoted lesson
227
+ expect(readLessons(repo).find(l => l.state === 'verified')).toMatchObject({ text: 'Make every write idempotent on retry.', promotedBy: 'person' });
228
+ const audit = fs.readFileSync(path.join(repo, '.rigour', 'review-rules-log.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
229
+ expect(audit.map(a => [a.said, a.rule])).toEqual([[upsert.text, 'Make every write idempotent on retry.']]);
103
230
  });
104
231
  });
@@ -0,0 +1,4 @@
1
+ /** The bullet and numbered lines of a review body: the points a reviewer makes outside any one line. */
2
+ export declare function bodyPoints(body: string): string[];
3
+ /** A comment's first non-empty line, markup removed, at most 140 characters. */
4
+ export declare function firstLine(text: string): string;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The points a person makes in a review body: its bullet and numbered lines, each cut to its first
3
+ * line. One reader for the backtest's ledger and the learner, so both see the same points.
4
+ */
5
+ const BULLET = /^\s*(?:[-*]|\d+[.)])\s+(.{8,})$/;
6
+ /** The bullet and numbered lines of a review body: the points a reviewer makes outside any one line. */
7
+ export function bodyPoints(body) {
8
+ return body.split('\n').map(l => BULLET.exec(l)?.[1]).filter((l) => !!l).map(firstLine);
9
+ }
10
+ /** A comment's first non-empty line, markup removed, at most 140 characters. */
11
+ export function firstLine(text) {
12
+ const line = text.split('\n').find(l => l.trim())?.replace(/[*_`#]/g, '').trim() ?? '';
13
+ return line.length > 140 ? `${line.slice(0, 137)}...` : line;
14
+ }
@@ -0,0 +1,14 @@
1
+ import type { ReviewLesson } from './lessons.js';
2
+ /** One model call: the prompt in, the answer text out, or undefined when it could not run (learning never fails on it). */
3
+ export type RuleWriter = (prompt: string) => Promise<string | undefined>;
4
+ /**
5
+ * The points as the rules the model wrote: a point it called no rule is kept and marked so (never
6
+ * promoted again), a point it did not answer is kept as it was (a failed call never loses evidence), and
7
+ * the person's words stay in the evidence. A rule about a file keeps that file only when the point was already about it.
8
+ */
9
+ export declare function rulesFromReviews(lessons: ReviewLesson[], write: RuleWriter): Promise<{
10
+ lessons: ReviewLesson[];
11
+ rules: number;
12
+ dropped: number;
13
+ unanswered: number;
14
+ }>;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Turning reviews into rules: a frontier model rewrites each point a person made, which the pull
3
+ * request then acted on, as the plain-language rule behind it, or says it is not a rule (a test
4
+ * report, a status note, a one-off fix). Memory, never training: the rule is text the next judge and
5
+ * agent read, it keeps the person's own words as evidence, and it stays a candidate until the same
6
+ * rule recurs in two or more pull requests or a person promotes it.
7
+ */
8
+ import crypto from 'crypto';
9
+ /** Points per call: enough to share the context, few enough that every one gets an answer. */
10
+ const BATCH = 20;
11
+ const MAX_RULE = 200;
12
+ function rulesPrompt(lessons) {
13
+ const points = lessons.map(l => ({ id: l.id, pr: l.evidence[0]?.pr, file: l.file || null, said: l.text }));
14
+ return `A team's senior reviewers teach through code review. Below are points people made in reviews of
15
+ this repository, each of which the pull request then acted on. For each point, decide whether it
16
+ teaches a rule the team would want applied to future changes, beyond this one pull request.
17
+
18
+ - If it does, write that rule as one plain imperative sentence of at most ${MAX_RULE} characters
19
+ ("Apply every filter known before a read in the query itself."). Keep a name only when the rule is
20
+ about that code. Give "file" when the rule is about one file, else null.
21
+ - If it is a test or check report, a status note, praise, a question, or a fix of one specific bug that
22
+ teaches nothing general, give "rule": null.
23
+
24
+ Do not read or change any file; answer from the points alone. Your final message must be ONLY this
25
+ JSON: {"rules":[{"id":"<the point's id>","rule":"..." | null,"file":"<path>" | null}]}
26
+
27
+ Points:
28
+ ${JSON.stringify(points, null, 1)}`;
29
+ }
30
+ /**
31
+ * The points as the rules the model wrote: a point it called no rule is kept and marked so (never
32
+ * promoted again), a point it did not answer is kept as it was (a failed call never loses evidence), and
33
+ * the person's words stay in the evidence. A rule about a file keeps that file only when the point was already about it.
34
+ */
35
+ export async function rulesFromReviews(lessons, write) {
36
+ const out = [];
37
+ let rules = 0, dropped = 0, unanswered = 0;
38
+ for (let i = 0; i < lessons.length; i += BATCH) {
39
+ const batch = lessons.slice(i, i + BATCH);
40
+ const answers = parseRules(await write(rulesPrompt(batch)));
41
+ for (const lesson of batch) {
42
+ const answer = answers.get(lesson.id);
43
+ if (!answer) {
44
+ unanswered++;
45
+ out.push(lesson);
46
+ }
47
+ else if (!answer.rule) {
48
+ // Kept, marked: a point the writer found no rule in is never promoted, or paid for, again.
49
+ dropped++;
50
+ out.push({ ...lesson, evidence: [...lesson.evidence, { kind: 'norule', pr: lesson.evidence[0]?.pr ?? 0, comment: `norule-${lesson.id}`, author: '', detail: 'the rule writer found no rule in it' }] });
51
+ }
52
+ else {
53
+ rules++;
54
+ const file = answer.file && answer.file === lesson.file ? lesson.file : '';
55
+ out.push({
56
+ ...lesson, text: answer.rule, file,
57
+ id: crypto.createHash('sha256').update(`${file}\u0000${answer.rule.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()}`).digest('hex').slice(0, 12),
58
+ evidence: lesson.evidence.map(e => ({ ...e, said: e.said ?? lesson.text })),
59
+ });
60
+ }
61
+ }
62
+ }
63
+ return { lessons: out, rules, dropped, unanswered };
64
+ }
65
+ function parseRules(text) {
66
+ const found = new Map();
67
+ if (!text)
68
+ return found;
69
+ const start = text.indexOf('{"rules"') >= 0 ? text.indexOf('{"rules"') : text.indexOf('{');
70
+ for (let end = text.lastIndexOf('}'); start >= 0 && end > start; end = text.lastIndexOf('}', end - 1)) {
71
+ try {
72
+ const parsed = JSON.parse(text.slice(start, end + 1));
73
+ for (const r of Array.isArray(parsed?.rules) ? parsed.rules : []) {
74
+ if (typeof r?.id !== 'string')
75
+ continue;
76
+ const rule = typeof r.rule === 'string' && r.rule.trim().length >= 12 ? r.rule.trim().slice(0, MAX_RULE) : null;
77
+ found.set(r.id, { rule, file: typeof r.file === 'string' ? r.file : null });
78
+ }
79
+ return found;
80
+ }
81
+ catch {
82
+ // not a whole object yet: try a shorter one
83
+ }
84
+ }
85
+ return found;
86
+ }
@@ -4,5 +4,21 @@ 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
- export declare function lessonsForDiff(cwd: string, diff: 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[];
9
+ /** The points this team rejected that a change touches: what the judges are told is settled. */
10
+ export declare function rejectedForDiff(cwd: string, diff: string): ReviewLesson[];
11
+ /** A lesson as a judge or agent sees it, in one place. */
12
+ export interface LessonView {
13
+ file: string;
14
+ text: string;
15
+ prs: number[];
16
+ said?: string;
17
+ }
18
+ export declare function lessonView(l: ReviewLesson): LessonView;
19
+ /**
20
+ * One line per lesson: where it applies, the rule, and, when a model wrote the rule from a person's
21
+ * point, their own words, so anyone can see when the rule went beyond what was said.
22
+ */
23
+ export declare function describeLesson(l: LessonView): string;
8
24
  export declare function lessonsSection(lessons: ReviewLesson[]): string;
@@ -13,20 +13,44 @@ 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
- export function lessonsForDiff(cwd, diff, mode = DEFAULT_LESSON_MODE) {
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) {
17
18
  if (mode === 'off')
18
19
  return [];
19
20
  const lessons = readLessons(cwd);
20
21
  if (lessons.length === 0)
21
22
  return [];
22
- const files = Object.keys(parseDiff(diff));
23
+ return matchLessons(lessons, changeShape(diff), { includeCandidates: mode === 'all', ...(standards !== undefined ? { standards } : {}) });
24
+ }
25
+ /** The points this team rejected that a change touches: what the judges are told is settled. */
26
+ export function rejectedForDiff(cwd, diff) {
27
+ const rejected = readLessons(cwd).filter(l => l.state === 'rejected');
28
+ if (rejected.length === 0)
29
+ return [];
30
+ const matched = new Set(matchLessons(rejected.map(l => ({ ...l, state: 'verified' })), changeShape(diff), { standards: 5 }).map(l => l.id));
31
+ return rejected.filter(l => matched.has(l.id));
32
+ }
33
+ function changeShape(diff) {
23
34
  const added = diff.split('\n').filter(line => line.startsWith('+') && !line.startsWith('+++')).join('\n');
24
- const symbols = new Set(added.match(/[A-Za-z_$][\w$]{3,}/g) ?? []);
25
- return matchLessons(lessons, { files, symbols }, { includeCandidates: mode === 'all' });
35
+ return { files: Object.keys(parseDiff(diff)), symbols: new Set(added.match(/[A-Za-z_$][\w$]{3,}/g) ?? []) };
36
+ }
37
+ export function lessonView(l) {
38
+ const said = l.evidence.find(e => e.said && e.said !== l.text)?.said;
39
+ return { file: l.file, text: l.text, prs: [...new Set(l.evidence.map(e => e.pr))], ...(said ? { said } : {}) };
40
+ }
41
+ /**
42
+ * One line per lesson: where it applies, the rule, and, when a model wrote the rule from a person's
43
+ * point, their own words, so anyone can see when the rule went beyond what was said.
44
+ */
45
+ export function describeLesson(l) {
46
+ const where = l.file ? `${l.file}: ` : 'team standard: ';
47
+ const said = l.said ? ` (in their words: "${l.said}")` : '';
48
+ const prs = l.prs.length ? ` (acted on in PR ${l.prs.map(p => `#${p}`).join(', ')})` : '';
49
+ return `${where}${l.text}${said}${prs}`;
26
50
  }
27
51
  export function lessonsSection(lessons) {
28
52
  if (lessons.length === 0)
29
53
  return '';
30
- const lines = lessons.map(l => `- ${l.file}: ${l.text} (acted on in PR ${[...new Set(l.evidence.map(e => `#${e.pr}`))].join(', ')})`);
54
+ const lines = lessons.map(l => `- ${describeLesson(lessonView(l))}`);
31
55
  return `TEAM LESSONS (this team asked for these in past reviews; check whether the change repeats any):\n${lines.join('\n')}`;
32
56
  }
@@ -211,10 +211,12 @@ export const UNIVERSAL_CONFIG = {
211
211
  unused_exports: {
212
212
  enabled: true,
213
213
  allow: [],
214
+ block: false,
214
215
  },
215
216
  orphan_files: {
216
217
  enabled: true,
217
218
  allow: [],
219
+ block: false,
218
220
  },
219
221
  query_patterns: {
220
222
  enabled: true,
@@ -290,6 +292,7 @@ export const UNIVERSAL_CONFIG = {
290
292
  judges: 2,
291
293
  escalate: 'always',
292
294
  cross_models: {},
295
+ judge_env: {},
293
296
  },
294
297
  },
295
298
  ignore: [],