@rigour-labs/core 6.7.2 → 6.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hooks/index.d.ts +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/hooks/session-state.d.ts +3 -0
- package/dist/hooks/session-state.js +10 -0
- package/dist/hooks/stop-review.d.ts +8 -15
- package/dist/hooks/stop-review.js +36 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/review/backtest-init.js +13 -11
- package/dist/review/backtest-init.test.js +26 -2
- package/dist/review/backtest.js +35 -3
- package/dist/review/backtest.test.js +44 -2
- package/dist/review/dead-code.test.js +13 -0
- package/dist/review/orphan-files.js +3 -2
- package/dist/review/quiet.d.ts +1 -1
- package/dist/review/quiet.js +3 -1
- package/dist/review/review-task.d.ts +2 -6
- package/dist/review/review-task.js +2 -2
- package/dist/review/reviewer/adapters.d.ts +21 -0
- package/dist/review/reviewer/adapters.js +70 -15
- package/dist/review/reviewer/adapters.test.js +22 -0
- package/dist/review/reviewer/background.test.js +1 -1
- package/dist/review/reviewer/context.d.ts +3 -0
- package/dist/review/reviewer/context.js +14 -5
- package/dist/review/reviewer/context.test.js +26 -1
- package/dist/review/reviewer/exec.d.ts +9 -0
- package/dist/review/reviewer/exec.js +28 -1
- package/dist/review/reviewer/exec.test.js +38 -2
- package/dist/review/reviewer/inputs.d.ts +6 -0
- package/dist/review/reviewer/inputs.js +26 -0
- package/dist/review/reviewer/judge-env.d.ts +3 -0
- package/dist/review/reviewer/judge-env.js +10 -0
- package/dist/review/reviewer/prompt.js +68 -12
- package/dist/review/reviewer/rule-writer.d.ts +10 -0
- package/dist/review/reviewer/rule-writer.js +30 -0
- package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
- package/dist/review/reviewer/rule-writer.test.js +52 -0
- package/dist/review/reviewer/settings.d.ts +4 -0
- package/dist/review/reviewer/settings.js +2 -1
- package/dist/review/reviewer/verdict.d.ts +71 -9
- package/dist/review/reviewer/verdict.js +102 -10
- package/dist/review/reviewer.d.ts +5 -2
- package/dist/review/reviewer.js +56 -41
- package/dist/review/reviewer.test.js +170 -17
- package/dist/review/typed/constant-inputs.d.ts +35 -0
- package/dist/review/typed/constant-inputs.js +168 -0
- package/dist/review/typed/dead-null-guards.d.ts +34 -0
- package/dist/review/typed/dead-null-guards.js +147 -0
- package/dist/review/typed/redundancy.js +6 -0
- package/dist/review/typed/redundancy.test.js +136 -0
- package/dist/review/unused-exports.js +3 -2
- package/dist/review-learning/acted-on.d.ts +23 -3
- package/dist/review-learning/acted-on.js +25 -13
- package/dist/review-learning/human-edits.d.ts +7 -0
- package/dist/review-learning/human-edits.js +99 -0
- package/dist/review-learning/human-edits.test.d.ts +1 -0
- package/dist/review-learning/human-edits.test.js +47 -0
- package/dist/review-learning/learn-from-reviews.d.ts +37 -4
- package/dist/review-learning/learn-from-reviews.js +128 -12
- package/dist/review-learning/lessons.d.ts +62 -9
- package/dist/review-learning/lessons.js +132 -21
- package/dist/review-learning/outcomes.d.ts +26 -0
- package/dist/review-learning/outcomes.js +73 -0
- package/dist/review-learning/review-learning.test.js +158 -22
- package/dist/review-learning/review-points.d.ts +4 -0
- package/dist/review-learning/review-points.js +14 -0
- package/dist/review-learning/rules-from-reviews.d.ts +14 -0
- package/dist/review-learning/rules-from-reviews.js +86 -0
- package/dist/review-learning/team-lessons.d.ts +17 -1
- package/dist/review-learning/team-lessons.js +29 -5
- package/dist/templates/universal-config.js +3 -0
- package/dist/types/index.d.ts +63 -0
- package/dist/types/index.js +11 -0
- package/dist/utils/profile.d.ts +5 -1
- package/dist/utils/profile.js +7 -1
- package/dist/utils/profile.test.js +10 -0
- 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 {
|
|
6
|
+
import { gitIn, withActedOn } from './acted-on.js';
|
|
7
7
|
import { learnFromReviews } from './learn-from-reviews.js';
|
|
8
|
-
import {
|
|
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, lessonsFromReview, 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,36 +26,61 @@ 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('
|
|
27
|
-
it('
|
|
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
|
|
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(
|
|
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
|
+
it('never makes a lesson of a bare path or a review bot\'s line-range scaffolding', () => {
|
|
44
|
+
expect(lessonText('src/routes/learner/+layout.ts')).toBe('');
|
|
45
|
+
expect(lessonText('`src/routes/learner/api/[planId]/+server.ts`')).toBe('');
|
|
46
|
+
expect(lessonText('In src/a.ts around lines 10-12: Check the lock before the first read.')).toBe('Check the lock before the first read.');
|
|
47
|
+
expect(lessonText('Around line 124-141: Add a test for the failed save.')).toBe('Add a test for the failed save.');
|
|
48
|
+
expect(lessonsFromReview({ id: '1', prNumber: 3, commit: 'c', author: 'rabbit[bot]', source: 'bot', body: '- `src/a/b.ts`\n- Bound both ends of the time window.' }, ['src/a/b.ts']).map(l => l.text))
|
|
49
|
+
.toEqual(['Bound both ends of the time window.']);
|
|
50
|
+
});
|
|
40
51
|
it('keeps the point of a review comment and drops tool output and markup', () => {
|
|
41
52
|
const body = '_⚠️ Potential issue_ | _🟠 Major_\n\n**Use an upsert keyed on `id`.**\n\n<details>\n<summary>🏁 Script executed</summary>\nrg -n insert\n</details>';
|
|
42
53
|
expect(lessonText(body)).toBe('Use an upsert keyed on `id`.');
|
|
43
54
|
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
55
|
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
56
|
});
|
|
46
|
-
it('
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
expect(
|
|
55
|
-
expect(
|
|
57
|
+
it('makes a candidate a lesson only on evidence, and keeps every piece of it', () => {
|
|
58
|
+
const point = (pr, prAuthor, source = 'person', reviewer = `reviewer-of-${pr}`) => ({ kind: 'point', pr, comment: `c${pr}${prAuthor}`, author: reviewer, source, prAuthor });
|
|
59
|
+
const lesson = (...evidence) => ({ id: 'x', text: 'Use upsert', file: 'src/orders.ts', symbols: [], state: 'candidate', evidence, createdAt: '', updatedAt: '' });
|
|
60
|
+
expect(lessonState(lesson(point(1, 'ann')))).toEqual({ state: 'candidate' });
|
|
61
|
+
expect(lessonState(lesson(point(1, 'ann'), point(2, 'ann')))).toEqual({ state: 'candidate' }); // two PRs, one author: weak, not enough
|
|
62
|
+
expect(lessonState(lesson(point(1, 'ann'), point(2, 'bob', 'bot')))).toEqual({ state: 'verified', promotedBy: 'recurrence' }); // a bot's point counts the same
|
|
63
|
+
const said = (e, text) => ({ ...e, text });
|
|
64
|
+
expect(lessonState(lesson(said(point(1, 'ann', 'bot', 'helper[bot]'), 'Around line 60-103: update the callers.'), said(point(2, 'bob', 'bot', 'helper[bot]'), 'Around line 12-14: update the loader.')))).toEqual({ state: 'candidate' }); // a bot rewording its own point is one source
|
|
65
|
+
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
|
|
66
|
+
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
|
|
67
|
+
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
|
|
68
|
+
const outcome = { kind: 'outcome', pr: 1, comment: 'o', author: '', detail: 'fixed later by abc' };
|
|
69
|
+
const counter = { kind: 'counter', pr: 1, comment: 'k', author: '', detail: 'unchanged 40 days' };
|
|
70
|
+
expect(lessonState(lesson(point(1, 'ann', 'bot'), outcome))).toEqual({ state: 'verified', promotedBy: 'outcome' });
|
|
71
|
+
expect(lessonState(lesson(point(1, 'ann'), point(2, 'bob'), counter))).toEqual({ state: 'candidate' }); // counter-evidence holds recurrence back
|
|
72
|
+
const accepted = { kind: 'accepted', pr: 1, comment: 'y', author: 'lead@x' };
|
|
73
|
+
const rejected = { kind: 'rejected', pr: 1, comment: 'n', author: 'lead@x' };
|
|
74
|
+
expect(lessonState(lesson(point(1, 'ann'), outcome, rejected))).toEqual({ state: 'rejected' }); // a person's decision wins
|
|
75
|
+
expect(lessonState(lesson(point(1, 'ann'), rejected, accepted))).toEqual({ state: 'verified', promotedBy: 'person' }); // the latest decision
|
|
76
|
+
expect(lessonState({ ...lesson({ pr: 1, comment: 'old', author: 'r' }), state: 'verified' })).toEqual({ state: 'verified', promotedBy: 'legacy' }); // a record from before evidence keeps its state
|
|
77
|
+
});
|
|
78
|
+
it('records a person accepting or rejecting a lesson as evidence, with who and why', () => {
|
|
79
|
+
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: '' }]);
|
|
80
|
+
expect(decideLesson(repo, 'x', 'rejected', 'lead@x', 'we accept duplicates here')).toMatchObject({ state: 'rejected' });
|
|
81
|
+
expect(readLessons(repo)[0].evidence.at(-1)).toMatchObject({ kind: 'rejected', author: 'lead@x', detail: 'we accept duplicates here' });
|
|
82
|
+
expect(matchLessons(readLessons(repo), { files: ['src/orders.ts'], symbols: new Set() }, { includeCandidates: true })).toEqual([]); // never served as a lesson
|
|
83
|
+
expect(decideLesson(repo, 'nope', 'accepted', 'lead@x')).toBeUndefined();
|
|
56
84
|
});
|
|
57
85
|
it('matches lessons by file, symbols and directory, verified only unless asked', () => {
|
|
58
86
|
const base = { symbols: ['insert'], evidence: [{ pr: 1, comment: 'c', author: 'r' }], createdAt: '', updatedAt: '' };
|
|
@@ -69,6 +97,93 @@ describe('lessons', () => {
|
|
|
69
97
|
expect(matchLessons(lessons, change, { includeCandidates: true }).map(l => l.id)).toEqual(['2', '1', '3']);
|
|
70
98
|
});
|
|
71
99
|
});
|
|
100
|
+
describe('learning from one pull request as it goes', () => {
|
|
101
|
+
it('reads its reviews up to a time, and counts a point acted on by the head as of then', async () => {
|
|
102
|
+
write(V1);
|
|
103
|
+
const reviewed = commit('pr head');
|
|
104
|
+
write(V2);
|
|
105
|
+
const fixed = commit('address review');
|
|
106
|
+
const api = 'https://api.github.com/repos/acme/app';
|
|
107
|
+
const pages = {
|
|
108
|
+
[`${api}/pulls/42`]: { number: 42, user: { login: 'dev' }, head: { sha: fixed } },
|
|
109
|
+
[`${api}/pulls/42/commits?per_page=100&page=1`]: [
|
|
110
|
+
{ sha: reviewed, commit: { committer: { date: '2026-09-01T00:00:00Z' } } },
|
|
111
|
+
{ sha: fixed, commit: { committer: { date: '2026-09-02T00:00:00Z' } } },
|
|
112
|
+
],
|
|
113
|
+
[`${api}/pulls/42/comments?per_page=100&page=1`]: [],
|
|
114
|
+
[`${api}/pulls/42/reviews?per_page=100&page=1`]: [
|
|
115
|
+
{ 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.' },
|
|
116
|
+
{ 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.' },
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
const fetchImpl = async (url) => ({ ok: url in pages, status: url in pages ? 200 : 404, json: async () => pages[url] });
|
|
120
|
+
const early = await learnFromReviews(repo, { token: 't', repo: 'acme/app', fetch: fetchImpl, pr: 42, until: '2026-09-01T18:00:00Z' });
|
|
121
|
+
expect(early).toMatchObject({ prs: 1, reviewBodies: 1, total: 1 }); // the later review is not seen yet
|
|
122
|
+
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 }] });
|
|
123
|
+
fs.rmSync(path.join(repo, '.rigour', 'review-lessons.json'));
|
|
124
|
+
await learnFromReviews(repo, { token: 't', repo: 'acme/app', fetch: fetchImpl, pr: 42, until: '2026-09-03T00:00:00Z' });
|
|
125
|
+
expect(readLessons(repo)[0].evidence[0]).toMatchObject({ actedOn: true }); // the fix landed before this cut
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe('outcome evidence from git', () => {
|
|
129
|
+
it('finds a later fix to the lines a point named, counts lines left alone against it, and never looks past the cut', () => {
|
|
130
|
+
write('export function total(order) {\n return order.items.length;\n}\n');
|
|
131
|
+
commit('base');
|
|
132
|
+
git('checkout', '-qb', 'feature');
|
|
133
|
+
write('export function total(order) {\n const n = order.items.length;\n return n;\n}\n');
|
|
134
|
+
const reviewed = commit('pr head');
|
|
135
|
+
git('checkout', '-q', 'main');
|
|
136
|
+
git('merge', '-q', '--no-ff', '--no-edit', 'feature');
|
|
137
|
+
const merge = git('rev-parse', 'HEAD');
|
|
138
|
+
write('// header\nexport function total(order) {\n const n = order.items?.length ?? 0;\n return n;\n}\n');
|
|
139
|
+
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' } });
|
|
140
|
+
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: '' });
|
|
141
|
+
const pr = { number: 7, mergeSha: merge, mergedAt: '2026-09-01T00:00:00Z' };
|
|
142
|
+
const later = outcomeFor(gitIn(repo), point(2), pr, { mainRef: 'main' });
|
|
143
|
+
expect(later).toMatchObject({ kind: 'outcome', detail: expect.stringContaining('fix: total crashes on an order with no items') });
|
|
144
|
+
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
|
|
145
|
+
expect(outcomeFor(gitIn(repo), point(3), pr, { mainRef: 'main' })).toMatchObject({ kind: 'counter' }); // `return n;` shipped and stayed, past the window
|
|
146
|
+
expect(outcomeFor(gitIn(repo), point(2, true), pr, { mainRef: 'main' })).toBeUndefined(); // acted on in the PR: no outcome to read
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('team standards', () => {
|
|
150
|
+
it('verifies a standard when the same point recurs in another author\'s PR, and serves it only to a change it is about', () => {
|
|
151
|
+
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: '' });
|
|
152
|
+
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.')]);
|
|
153
|
+
expect(merged.lessons).toHaveLength(1);
|
|
154
|
+
expect(merged.lessons[0].state).toBe('verified');
|
|
155
|
+
const unrelated = mergeLessons(merged.lessons, [standard(3, 'Log the request id on every error.')]);
|
|
156
|
+
expect(unrelated.lessons).toHaveLength(2);
|
|
157
|
+
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.']);
|
|
158
|
+
expect(matchLessons(unrelated.lessons, { files: ['src/ui/Dialog.svelte'], symbols: new Set(['onKeydown']) })).toEqual([]); // nothing in common: not served
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe('turning reviews into rules', () => {
|
|
162
|
+
const point = (id, text, file = '') => ({ id, text, file, symbols: [], state: 'candidate', evidence: [{ pr: 7, comment: `c-${id}`, author: 'priya' }], createdAt: '', updatedAt: '' });
|
|
163
|
+
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 () => {
|
|
164
|
+
const prompts = [];
|
|
165
|
+
const write = async (prompt) => {
|
|
166
|
+
prompts.push(prompt);
|
|
167
|
+
return 'Here you go:\n{"rules":[{"id":"a","rule":"Apply discounts before computing an invoice total.","file":null},{"id":"b","rule":null,"file":null}]}';
|
|
168
|
+
};
|
|
169
|
+
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.')];
|
|
170
|
+
const out = await rulesFromReviews(points, write);
|
|
171
|
+
expect(out).toMatchObject({ rules: 1, dropped: 1, unanswered: 1 });
|
|
172
|
+
expect(out.lessons.map(l => [l.text, l.evidence.at(-1)?.kind])).toEqual([
|
|
173
|
+
['Apply discounts before computing an invoice total.', undefined], ['npm run check: 0 errors, lint clean.', 'norule'], ['A retry posts the payment twice.', undefined],
|
|
174
|
+
]); // the no-rule point is kept and marked, never sent again
|
|
175
|
+
expect(out.lessons[0].evidence[0].said).toBe('The invoice total is computed before the discount is applied.');
|
|
176
|
+
expect(prompts[0]).toContain('"said": "npm run check: 0 errors, lint clean."');
|
|
177
|
+
expect(describeLesson(lessonView({ ...out.lessons[0], evidence: [...out.lessons[0].evidence, { pr: 9, comment: 'x', author: 'sam' }] })))
|
|
178
|
+
.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)');
|
|
179
|
+
});
|
|
180
|
+
it('never loses a point when the model cannot run, and keeps a file only when the point was about it', async () => {
|
|
181
|
+
const out = await rulesFromReviews([point('a', 'In src/run.ts the flag is read last.', 'src/run.ts')], async () => undefined);
|
|
182
|
+
expect(out.lessons.map(l => [l.text, l.file])).toEqual([['In src/run.ts the flag is read last.', 'src/run.ts']]);
|
|
183
|
+
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"}]}');
|
|
184
|
+
expect(moved.lessons[0].file).toBe('');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
72
187
|
describe('isSpecific', () => {
|
|
73
188
|
it('counts identifiers that name something, not common words', () => {
|
|
74
189
|
expect(['orderId', 'max_rows', 'renderTransactionalMessage'].map(isSpecific)).toEqual([true, true, true]);
|
|
@@ -84,21 +199,42 @@ describe('learnFromReviews', () => {
|
|
|
84
199
|
const api = 'https://api.github.com/repos/acme/app';
|
|
85
200
|
const pages = {
|
|
86
201
|
[`${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 },
|
|
202
|
+
{ number: 7, merged_at: '2026-09-01T00:00:00Z', merge_commit_sha: merged, user: { login: 'dev' } },
|
|
88
203
|
{ number: 8, merged_at: null, merge_commit_sha: null },
|
|
89
204
|
],
|
|
90
205
|
[`${api}/pulls?state=closed&sort=updated&direction=desc&per_page=100&page=2`]: [],
|
|
91
206
|
[`${api}/pulls/7/comments?per_page=100`]: [
|
|
92
207
|
{ 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
208
|
{ id: 12, path: 'src/orders.ts', original_line: 2, original_commit_id: reviewed, body: 'agreed', in_reply_to_id: 11, user: { login: 'sam' } },
|
|
209
|
+
{ 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' } },
|
|
210
|
+
{ id: 14, path: 'src/orders.ts', original_line: 2, original_commit_id: reviewed, body: '**Self note: tidy this later.**', user: { login: 'dev' } },
|
|
211
|
+
],
|
|
212
|
+
[`${api}/pulls/7/reviews?per_page=100`]: [
|
|
213
|
+
{ 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!' },
|
|
214
|
+
{ id: 22, commit_id: reviewed, user: { login: 'review-helper[bot]', type: 'Bot' }, body: '- Consider adding more unit tests for orders.' },
|
|
215
|
+
{ id: 23, commit_id: reviewed, user: { login: 'dev' }, body: '- Addressed every point in the next commit.' },
|
|
94
216
|
],
|
|
95
217
|
};
|
|
96
218
|
const fetchImpl = async (url) => ({ ok: url in pages, status: url in pages ? 200 : 404, json: async () => pages[url] });
|
|
97
219
|
const result = await learnFromReviews(repo, { token: 't', repo: 'acme/app', fetch: fetchImpl });
|
|
98
|
-
|
|
99
|
-
expect(
|
|
220
|
+
// Every point is a candidate, the bot's too; the author's own comments are not review points.
|
|
221
|
+
expect(result).toMatchObject({ prs: 1, comments: 2, actedOn: 2, reviewBodies: 2, candidates: { person: 3, bot: 2 }, verified: 0 });
|
|
222
|
+
const lessons = readLessons(repo);
|
|
223
|
+
expect(lessons.every(l => l.state === 'candidate')).toBe(true); // nothing is a lesson without evidence
|
|
224
|
+
const sources = new Set(lessons.flatMap(l => l.evidence.map(e => `${e.author}/${e.source}/${e.prAuthor}`)));
|
|
225
|
+
expect([...sources].sort()).toEqual(['priya/person/dev', 'review-helper[bot]/bot/dev']);
|
|
226
|
+
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
227
|
const diff = '+++ b/src/orders.ts\n@@ -1,0 +1,1 @@\n+ await db.insert(other);\n';
|
|
101
|
-
expect(lessonsForDiff(repo, diff, 'verified')).toEqual([]); //
|
|
102
|
-
expect(lessonsSection(lessonsForDiff(repo, diff, 'all'))).toContain('src/orders.ts: Use an upsert keyed on `id` so retries do not duplicate orders.
|
|
228
|
+
expect(lessonsForDiff(repo, diff, 'verified')).toEqual([]); // candidates are not served by default
|
|
229
|
+
expect(lessonsSection(lessonsForDiff(repo, diff, 'all'))).toContain('src/orders.ts: Use an upsert keyed on `id` so retries do not duplicate orders.');
|
|
230
|
+
const again = await learnFromReviews(repo, { token: 't', repo: 'acme/app', fetch: fetchImpl, writeRules: async () => { throw new Error('nothing promoted: no rule is written'); } });
|
|
231
|
+
expect(again).toMatchObject({ added: 0, total: lessons.length });
|
|
232
|
+
const upsert = lessons.find(l => l.text.startsWith('Use an upsert'));
|
|
233
|
+
decideLesson(repo, upsert.id, 'accepted', 'lead@acme');
|
|
234
|
+
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' })) }) });
|
|
235
|
+
expect(ruled).toMatchObject({ rules: 1, notRules: 0, promoted: { person: 1 } }); // written only for the promoted lesson
|
|
236
|
+
expect(readLessons(repo).find(l => l.state === 'verified')).toMatchObject({ text: 'Make every write idempotent on retry.', promotedBy: 'person' });
|
|
237
|
+
const audit = fs.readFileSync(path.join(repo, '.rigour', 'review-rules-log.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
|
238
|
+
expect(audit.map(a => [a.said, a.rule])).toEqual([[upsert.text, 'Make every write idempotent on retry.']]);
|
|
103
239
|
});
|
|
104
240
|
});
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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 => `- ${
|
|
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: [],
|