@rigour-labs/core 6.7.1 → 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.
- package/README.md +1 -1
- 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/review.js +19 -4
- package/dist/review/review.test.js +11 -0
- 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 +122 -20
- 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 +149 -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/services/temporal-drift.js +1 -1
- package/dist/storage/db.d.ts +4 -6
- package/dist/storage/db.js +63 -90
- package/dist/storage/db.test.d.ts +1 -0
- package/dist/storage/db.test.js +46 -0
- 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 +8 -9
|
@@ -1,22 +1,70 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Review lessons: what a team's
|
|
3
|
-
*
|
|
2
|
+
* Review lessons: what a team's reviews taught, ready to hand to the next agent
|
|
3
|
+
* and judge.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Evidence only. Every review point, a person's, an AI's posted under a person's
|
|
6
|
+
* login, or a bot's, is a candidate; who wrote it is recorded, never the gate. A
|
|
7
|
+
* candidate becomes a lesson on evidence (lessonState): an outcome (a later fix
|
|
8
|
+
* to the lines it pointed at), a person accepting it, or, weak alone, the same
|
|
9
|
+
* point recurring across pull requests by different authors. Counter-evidence (the
|
|
10
|
+
* lines shipped unchanged and nothing needed fixing within a window) holds it
|
|
11
|
+
* back, and a person rejecting it makes it an anti-lesson the judges are told is
|
|
12
|
+
* settled. Every piece of evidence stays on the lesson. Lessons stay in the
|
|
13
|
+
* repository's .rigour/ folder and are sent nowhere except to the user's own model.
|
|
9
14
|
*/
|
|
10
15
|
import crypto from 'crypto';
|
|
11
16
|
import fs from 'fs';
|
|
12
17
|
import path from 'path';
|
|
18
|
+
import { bodyPoints } from './review-points.js';
|
|
13
19
|
const STORE = path.join('.rigour', 'review-lessons.json');
|
|
14
20
|
const MAX_TEXT = 220;
|
|
15
21
|
const MAX_SYMBOLS = 8;
|
|
16
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Recurrence is weak evidence, and only when independent: the same point on this many pull requests,
|
|
24
|
+
* by this many different authors, raised by different reviewers or by one reviewer in different words
|
|
25
|
+
* (a senior re-raising a standard counts; a bot pasting its template on every pull request does not).
|
|
26
|
+
*/
|
|
27
|
+
const RECUR_PRS = 2;
|
|
28
|
+
const RECUR_AUTHORS = 2;
|
|
29
|
+
/** Team standards served with a change, on top of the lessons about its files (an agent's question; a judge takes more), and the words they must share with it. */
|
|
30
|
+
const MAX_STANDARDS = 3;
|
|
31
|
+
const STANDARD_WORDS = 2;
|
|
32
|
+
/** Words that say nothing about what a rule or a change is about. */
|
|
33
|
+
const PLAIN_WORDS = new Set(['when', 'that', 'this', 'with', 'from', 'before', 'after', 'every', 'their', 'them', 'than', 'each', 'only', 'into', 'rather', 'instead', 'should', 'make', 'keep', 'sure', 'does', 'what', 'which', 'there', 'these', 'those', 'such', 'more', 'other', 'same', 'then', 'also', 'both', 'must', 'never', 'always', 'once', 'been', 'have', 'will', 'your', 'about', 'over', 'under', 'change', 'changes', 'code', 'value', 'values', 'data', 'true', 'false', 'null', 'undefined', 'return', 'const', 'function', 'export', 'import', 'await', 'async', 'string', 'number', 'type']);
|
|
17
34
|
/** Lessons from these teach about one document, test or config file, not about code that will change again. */
|
|
18
35
|
const NOT_CODE = /(\.(md|mdx|txt|ya?ml|json|lock|snap)$)|(\.(test|spec)\.[cm]?[jt]sx?$)|(^|\/)(__tests__|docs?|migrations|\.github)\//i;
|
|
19
36
|
const NOT_SYMBOLS = new Set(['this', 'that', 'with', 'from', 'return', 'const', 'await', 'async', 'function', 'true', 'false', 'null', 'undefined', 'string', 'number', 'boolean', 'export', 'import', 'type', 'interface', 'else', 'when', 'then', 'should', 'line', 'lines']);
|
|
37
|
+
/**
|
|
38
|
+
* A lesson's state from its evidence trail. A person's decision wins; then an outcome; counter-evidence
|
|
39
|
+
* holds a candidate back; recurrence across pull requests and authors is enough only without it. A record
|
|
40
|
+
* from before evidence kinds keeps the state it had.
|
|
41
|
+
*/
|
|
42
|
+
export function lessonState(lesson) {
|
|
43
|
+
const kinds = new Set(lesson.evidence.map(e => e.kind));
|
|
44
|
+
if (!lesson.evidence.some(e => e.kind))
|
|
45
|
+
return lesson.state === 'verified' ? { state: 'verified', promotedBy: lesson.promotedBy ?? 'legacy' } : { state: lesson.state };
|
|
46
|
+
const decisions = lesson.evidence.filter(e => e.kind === 'accepted' || e.kind === 'rejected');
|
|
47
|
+
const last = decisions.at(-1);
|
|
48
|
+
if (last?.kind === 'rejected')
|
|
49
|
+
return { state: 'rejected' };
|
|
50
|
+
if (last?.kind === 'accepted')
|
|
51
|
+
return { state: 'verified', promotedBy: 'person' };
|
|
52
|
+
if (kinds.has('norule'))
|
|
53
|
+
return { state: 'candidate' };
|
|
54
|
+
if (kinds.has('correction'))
|
|
55
|
+
return { state: 'verified', promotedBy: 'correction' };
|
|
56
|
+
if (kinds.has('outcome'))
|
|
57
|
+
return { state: 'verified', promotedBy: 'outcome' };
|
|
58
|
+
if (kinds.has('counter'))
|
|
59
|
+
return { state: 'candidate' };
|
|
60
|
+
const points = lesson.evidence.filter(e => (e.kind ?? 'point') === 'point');
|
|
61
|
+
const prs = new Set(points.map(e => e.pr)).size;
|
|
62
|
+
const authors = new Set(points.map(e => e.prAuthor).filter(Boolean)).size;
|
|
63
|
+
const reviewers = new Set(points.map(e => e.author).filter(Boolean)).size;
|
|
64
|
+
const wordings = new Set(points.map(e => normalize(e.text ?? '')).filter(Boolean)).size;
|
|
65
|
+
const independent = reviewers >= 2 || wordings >= 2;
|
|
66
|
+
return prs >= RECUR_PRS && authors >= RECUR_AUTHORS && independent ? { state: 'verified', promotedBy: 'recurrence' } : { state: 'candidate' };
|
|
67
|
+
}
|
|
20
68
|
/** The point of a review comment: its bold title, else its first sentence, without tool output or markup. */
|
|
21
69
|
export function lessonText(body) {
|
|
22
70
|
const cleaned = body
|
|
@@ -58,9 +106,30 @@ export function lessonFromComment(git, comment, at = new Date().toISOString()) {
|
|
|
58
106
|
return {
|
|
59
107
|
id: crypto.createHash('sha256').update(`${comment.path}\u0000${normalize(text)}`).digest('hex').slice(0, 12),
|
|
60
108
|
text, file: comment.path, symbols: lessonSymbols(comment.body, codeLines), state: 'candidate',
|
|
61
|
-
evidence: [{ pr: comment.prNumber, comment: comment.id, author: comment.author
|
|
109
|
+
evidence: [{ kind: 'point', pr: comment.prNumber, comment: comment.id, author: comment.author, ...pointMeta(comment), text, at }],
|
|
110
|
+
at: { commit: comment.commit, start: comment.start, end: comment.end }, createdAt: at, updatedAt: at,
|
|
62
111
|
};
|
|
63
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Candidate lessons from a review body's points. A point naming a path is about that file; one naming
|
|
115
|
+
* only a file the PR changed after the review (by its name) is about that file; a point naming none is
|
|
116
|
+
* a team standard (file ''), served with any change it is about once evidence makes it a lesson.
|
|
117
|
+
* Whether files changed after it is recorded, not required.
|
|
118
|
+
*/
|
|
119
|
+
export function lessonsFromReview(review, changedAfter, at = new Date().toISOString()) {
|
|
120
|
+
return bodyPoints(review.body).flatMap((point, i) => {
|
|
121
|
+
const text = point.replace(/[*_]/g, '').slice(0, MAX_TEXT).trim();
|
|
122
|
+
if (text.length < 12)
|
|
123
|
+
return [];
|
|
124
|
+
const named = /(?:^|[\s`(])((?:[\w.-]+\/)+[\w.-]+\.\w+)/.exec(point)?.[1];
|
|
125
|
+
const file = named ?? changedAfter.find(f => text.includes(path.posix.basename(f))) ?? '';
|
|
126
|
+
return [{
|
|
127
|
+
id: crypto.createHash('sha256').update(`${file}\u0000${normalize(text)}`).digest('hex').slice(0, 12),
|
|
128
|
+
text, file, symbols: lessonSymbols(point, []), state: 'candidate',
|
|
129
|
+
evidence: [{ kind: 'point', pr: review.prNumber, comment: `review-${review.id}-${i}`, author: review.author, ...pointMeta(review), actedOn: changedAfter.length > 0, text, at }], createdAt: at, updatedAt: at,
|
|
130
|
+
}];
|
|
131
|
+
});
|
|
132
|
+
}
|
|
64
133
|
/**
|
|
65
134
|
* Add lessons to the store. The same lesson again (same text, or the same
|
|
66
135
|
* file with two shared symbols) adds evidence; acted on in enough PRs, it is
|
|
@@ -70,23 +139,26 @@ export function mergeLessons(existing, incoming) {
|
|
|
70
139
|
const lessons = existing.map(l => ({ ...l, evidence: [...l.evidence] }));
|
|
71
140
|
let added = 0;
|
|
72
141
|
for (const lesson of incoming) {
|
|
73
|
-
|
|
142
|
+
// Shared names find the same point made again on another pull request; two points on one pull request are two points.
|
|
143
|
+
const otherPr = (l) => !l.evidence.some(e => lesson.evidence.some(x => x.pr === e.pr));
|
|
144
|
+
const same = lessons.find(l => l.id === lesson.id || (l.file === lesson.file && shared(l.symbols, lesson.symbols) >= 2 && otherPr(l))
|
|
145
|
+
|| (!l.file && !lesson.file && sameWords(l.text, lesson.text)));
|
|
74
146
|
if (!same) {
|
|
75
|
-
lessons.push(lesson);
|
|
147
|
+
lessons.push({ ...lesson, evidence: [...lesson.evidence] });
|
|
76
148
|
added++;
|
|
77
149
|
continue;
|
|
78
150
|
}
|
|
79
151
|
for (const e of lesson.evidence)
|
|
80
|
-
if (!same.evidence.some(x => x.comment === e.comment))
|
|
152
|
+
if (!same.evidence.some(x => x.comment === e.comment && x.kind === e.kind))
|
|
81
153
|
same.evidence.push(e);
|
|
82
154
|
same.updatedAt = lesson.updatedAt;
|
|
83
155
|
}
|
|
84
156
|
let verified = 0;
|
|
85
157
|
for (const lesson of lessons) {
|
|
86
|
-
|
|
87
|
-
|
|
158
|
+
const before = lesson.state;
|
|
159
|
+
Object.assign(lesson, lessonState(lesson));
|
|
160
|
+
if (lesson.state === 'verified' && before !== 'verified')
|
|
88
161
|
verified++;
|
|
89
|
-
}
|
|
90
162
|
}
|
|
91
163
|
return { lessons, added, verified };
|
|
92
164
|
}
|
|
@@ -94,12 +166,13 @@ export function mergeLessons(existing, incoming) {
|
|
|
94
166
|
* Lessons that apply to a change, best first. A lesson applies when the change
|
|
95
167
|
* touches its file, or shares at least two specific identifiers with it; a
|
|
96
168
|
* shared directory only breaks ties. Lessons about docs, tests or config never
|
|
97
|
-
* apply to other files.
|
|
169
|
+
* apply to other files. A team standard (a lesson with no file) applies to every
|
|
170
|
+
* change; the best-evidenced few follow the file lessons.
|
|
98
171
|
*/
|
|
99
172
|
export function matchLessons(lessons, change, options = {}) {
|
|
100
173
|
const dirs = new Set(change.files.map(f => path.posix.dirname(f)));
|
|
101
174
|
const scored = lessons
|
|
102
|
-
.filter(l => (options.includeCandidates
|
|
175
|
+
.filter(l => !!l.file && (l.state === 'verified' || (options.includeCandidates && l.state === 'candidate')) && !NOT_CODE.test(l.file))
|
|
103
176
|
.map(l => ({
|
|
104
177
|
lesson: l,
|
|
105
178
|
score: (change.files.includes(l.file) ? 3 : 0) + (dirs.has(path.posix.dirname(l.file)) ? 0.5 : 0)
|
|
@@ -107,7 +180,17 @@ export function matchLessons(lessons, change, options = {}) {
|
|
|
107
180
|
}))
|
|
108
181
|
.filter(s => s.score >= 3)
|
|
109
182
|
.sort((a, b) => b.score - a.score || b.lesson.evidence.length - a.lesson.evidence.length);
|
|
110
|
-
|
|
183
|
+
// A team standard (no file) applies when its words are the change's: its paths and the names on its added lines,
|
|
184
|
+
// split into words. A rule about keyboard shortcuts says nothing to a change to a database job.
|
|
185
|
+
const changeWords = new Set([...change.files.flatMap(f => f.split(/[/._-]+/)), ...change.symbols].flatMap(words));
|
|
186
|
+
const standards = lessons
|
|
187
|
+
.filter(l => !l.file && (l.state === 'verified' || (options.includeCandidates && l.state === 'candidate')))
|
|
188
|
+
.map(l => ({ lesson: l, shared: new Set(words(l.text)).size === 0 ? 0 : [...new Set(words(l.text))].filter(w => changeWords.has(w)).length }))
|
|
189
|
+
.filter(s => s.shared >= STANDARD_WORDS)
|
|
190
|
+
.sort((a, b) => b.shared - a.shared || b.lesson.evidence.length - a.lesson.evidence.length)
|
|
191
|
+
.slice(0, options.standards ?? MAX_STANDARDS)
|
|
192
|
+
.map(s => s.lesson);
|
|
193
|
+
return [...scored.slice(0, options.limit ?? 5).map(s => s.lesson), ...standards];
|
|
111
194
|
}
|
|
112
195
|
/** RIGOUR_REVIEW_LESSONS points at a lessons file outside the clone (CI, or a team's shared copy). */
|
|
113
196
|
export function lessonsPath(cwd) {
|
|
@@ -127,13 +210,15 @@ export function writeLessons(cwd, lessons) {
|
|
|
127
210
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
128
211
|
fs.writeFileSync(file, JSON.stringify({ version: 1, lessons }, null, 2) + '\n');
|
|
129
212
|
}
|
|
130
|
-
|
|
213
|
+
/** A person's decision on a lesson, kept as evidence: accepted makes it a lesson, rejected an anti-lesson. Undefined for an unknown id. */
|
|
214
|
+
export function decideLesson(cwd, id, decision, by, why = '') {
|
|
131
215
|
const lessons = readLessons(cwd);
|
|
132
216
|
const lesson = lessons.find(l => l.id === id);
|
|
133
217
|
if (!lesson)
|
|
134
218
|
return undefined;
|
|
135
|
-
|
|
136
|
-
lesson.
|
|
219
|
+
const at = new Date().toISOString();
|
|
220
|
+
lesson.evidence.push({ kind: decision, pr: lesson.evidence[0]?.pr ?? 0, comment: `${decision}-${at}`, author: by, detail: why, at });
|
|
221
|
+
Object.assign(lesson, lessonState(lesson), { updatedAt: at });
|
|
137
222
|
writeLessons(cwd, lessons);
|
|
138
223
|
return lesson;
|
|
139
224
|
}
|
|
@@ -147,3 +232,20 @@ function normalize(text) {
|
|
|
147
232
|
function shared(a, b) {
|
|
148
233
|
return a.filter(s => b.includes(s)).length;
|
|
149
234
|
}
|
|
235
|
+
/** Two team standards are the same point when most of their words are: a reviewer rarely repeats a sentence exactly. */
|
|
236
|
+
function sameWords(a, b) {
|
|
237
|
+
const words = (t) => new Set(normalize(t).split(' ').filter(w => w.length >= 4));
|
|
238
|
+
const x = words(a), y = words(b);
|
|
239
|
+
if (x.size === 0 || y.size === 0)
|
|
240
|
+
return false;
|
|
241
|
+
const common = [...x].filter(w => y.has(w)).length;
|
|
242
|
+
return common / Math.max(x.size, y.size) >= 0.6;
|
|
243
|
+
}
|
|
244
|
+
/** The meaningful words of a text or an identifier: `hasLaterAttempt` and "a later attempt" share later and attempt. */
|
|
245
|
+
function words(text) {
|
|
246
|
+
return text.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z]+/).filter(w => w.length >= 4 && !PLAIN_WORDS.has(w));
|
|
247
|
+
}
|
|
248
|
+
/** Who made a point and on whose pull request: recorded with it, never a filter. */
|
|
249
|
+
function pointMeta(x) {
|
|
250
|
+
return { ...(x.source ? { source: x.source } : {}), ...(x.prAuthor ? { prAuthor: x.prAuthor } : {}), ...(x.actedOn !== undefined ? { actedOn: x.actedOn } : {}) };
|
|
251
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome evidence from git, free and deterministic: what happened to the lines a review point named
|
|
3
|
+
* after the pull request merged. A point the pull request did not act on, whose lines a later commit
|
|
4
|
+
* on main changed with a fix, was right (outcome). One whose lines shipped and stayed unchanged for the
|
|
5
|
+
* whole window was not needed (counter). A revert of the pull request is an outcome for every point it
|
|
6
|
+
* left unaddressed. With `until`, only history before it counts, so a backtest never sees the future.
|
|
7
|
+
*/
|
|
8
|
+
import type { Git } from './acted-on.js';
|
|
9
|
+
import type { LessonEvidence, ReviewLesson } from './lessons.js';
|
|
10
|
+
export interface OutcomeOptions {
|
|
11
|
+
/** The main branch the pull request merged into. */
|
|
12
|
+
mainRef: string;
|
|
13
|
+
/** Only history before this time (ISO); now when unset. */
|
|
14
|
+
until?: string;
|
|
15
|
+
/** How long unchanged lines must ship before that counts against the point. */
|
|
16
|
+
windowDays?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface MergedAt {
|
|
19
|
+
number: number;
|
|
20
|
+
mergeSha: string;
|
|
21
|
+
mergedAt: string;
|
|
22
|
+
}
|
|
23
|
+
/** The outcome or counter evidence for an inline point the pull request did not act on, or undefined when there is none yet. */
|
|
24
|
+
export declare function outcomeFor(git: Git, lesson: ReviewLesson, pr: MergedAt, options: OutcomeOptions): LessonEvidence | undefined;
|
|
25
|
+
/** A revert of the pull request on main, before `until`: an outcome for every point it left unaddressed. */
|
|
26
|
+
export declare function revertOf(git: Git, pr: MergedAt, options: OutcomeOptions): LessonEvidence | undefined;
|
|
@@ -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, 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('
|
|
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,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('
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
expect(
|
|
55
|
-
expect(
|
|
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
|
-
|
|
99
|
-
expect(
|
|
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([]); //
|
|
102
|
-
expect(lessonsSection(lessonsForDiff(repo, diff, 'all'))).toContain('src/orders.ts: Use an upsert keyed on `id` so retries do not duplicate orders.
|
|
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
|
+
}>;
|