@rigour-labs/core 6.11.0 → 6.11.1-rc.1

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/index.d.ts CHANGED
@@ -49,7 +49,7 @@ export { decideCompiledCheck, proposeCompiledChecks, readCompiledChecks, suspens
49
49
  export { ruleWriterFor } from './review/reviewer/rule-writer.js';
50
50
  export { backtestLast, formatLast, scoreLast, LAST_LEDGER_PATH, type LastReport } from './review/backtest-last.js';
51
51
  export { buildRecord, recordLines, recordIntact, type ReviewRecord } from './review/reviewer/record.js';
52
- export { readLessons, writeLessons, decideLesson, lessonState, pendingDecision, matchLessons, lessonText, lessonsPath, type ReviewLesson, type LessonEvidence } from './review-learning/lessons.js';
52
+ export { readLessons, writeLessons, decideLesson, acceptSuggestedText, lessonState, pendingDecision, matchLessons, lessonText, lessonsPath, type ReviewLesson, type LessonEvidence } from './review-learning/lessons.js';
53
53
  export { lessonsForDiff, lessonsSection, type LessonMode } from './review-learning/team-lessons.js';
54
54
  export { recordAgentWrites, captureHumanEdits } from './review-learning/human-edits.js';
55
55
  export { readRepoRules, rulesForDiff, rulesSection, splitRules, type RepoRule } from './review-learning/repo-rules.js';
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ export { decideCompiledCheck, proposeCompiledChecks, readCompiledChecks, suspens
47
47
  export { ruleWriterFor } from './review/reviewer/rule-writer.js';
48
48
  export { backtestLast, formatLast, scoreLast, LAST_LEDGER_PATH } from './review/backtest-last.js';
49
49
  export { buildRecord, recordLines, recordIntact } from './review/reviewer/record.js';
50
- export { readLessons, writeLessons, decideLesson, lessonState, pendingDecision, matchLessons, lessonText, lessonsPath } from './review-learning/lessons.js';
50
+ export { readLessons, writeLessons, decideLesson, acceptSuggestedText, lessonState, pendingDecision, matchLessons, lessonText, lessonsPath } from './review-learning/lessons.js';
51
51
  export { lessonsForDiff, lessonsSection } from './review-learning/team-lessons.js';
52
52
  export { recordAgentWrites, captureHumanEdits } from './review-learning/human-edits.js';
53
53
  export { readRepoRules, rulesForDiff, rulesSection, splitRules } from './review-learning/repo-rules.js';
@@ -39,8 +39,9 @@ export async function learnFromReviews(cwd, options) {
39
39
  }
40
40
  const known = new Set(readLessons(cwd).flatMap(l => l.evidence.map(e => e.comment)));
41
41
  const points = lessons.flatMap(l => l.evidence).filter(e => e.kind === 'point' && !known.has(e.comment));
42
- const fresh = lessons.filter(l => !l.evidence.every(e => known.has(e.comment)));
43
- const merged = mergeLessons(readLessons(cwd), fresh);
42
+ // Comments read before go through too: each lands on its own lesson (mergeLessons matches the comment), which
43
+ // takes the text this version derives from it. Nothing is added twice.
44
+ const merged = mergeLessons(readLessons(cwd), lessons);
44
45
  // Outcomes accrue after the merge: every candidate whose pull request was read this run is checked again.
45
46
  if (options.mainRef) {
46
47
  const byNumber = new Map(prs.filter(pr => pr.mergedAt).map(pr => [pr.number, pr]));
@@ -18,7 +18,7 @@ import type { Git, ReviewBody, ReviewComment } from './acted-on.js';
18
18
  * until a person promotes it.
19
19
  * A record from before evidence kinds has none: it is a `point`.
20
20
  */
21
- export type EvidenceKind = 'point' | 'outcome' | 'counter' | 'correction' | 'accepted' | 'rejected' | 'norule' | 'followup' | 'lines' | 'dismissed' | 'against' | 'demoted' | 'reclassified' | 'compiled';
21
+ export type EvidenceKind = 'point' | 'outcome' | 'counter' | 'correction' | 'accepted' | 'rejected' | 'norule' | 'followup' | 'lines' | 'dismissed' | 'against' | 'demoted' | 'reclassified' | 'compiled' | 'reworded';
22
22
  export interface LessonEvidence {
23
23
  kind?: EvidenceKind;
24
24
  pr: number;
@@ -50,6 +50,13 @@ export interface ReviewLesson {
50
50
  /** Which evidence made it a lesson. */
51
51
  promotedBy?: 'outcome' | 'correction' | 'person' | 'recurrence' | 'legacy';
52
52
  evidence: LessonEvidence[];
53
+ /**
54
+ * A corrected wording a newer version derived from the lesson's own comment, waiting for a person. A lesson
55
+ * a person decided keeps the wording they decided on until they take this one (acceptSuggestedText).
56
+ */
57
+ suggestedText?: string;
58
+ /** Why there is a suggestion: `parser fix`, a newer version reading the comment better. */
59
+ suggestedWhy?: string;
53
60
  /** Where the point sits in the commit it was made on, for outcome evidence (inline comments only). */
54
61
  at?: {
55
62
  commit: string;
@@ -65,7 +72,10 @@ export interface ReviewLesson {
65
72
  * evidence, never a promotion. A record from before evidence kinds keeps the state it had.
66
73
  */
67
74
  export declare function lessonState(lesson: ReviewLesson): Pick<ReviewLesson, 'state' | 'promotedBy'>;
68
- /** The point of a review comment: its bold title, else its first sentence, without tool output or markup. */
75
+ /**
76
+ * The point of a review comment: its bold title, else its first sentence and, when that only says what is
77
+ * wrong, the first later sentence that says what to do. Without tool output or markup.
78
+ */
69
79
  export declare function lessonText(body: string): string;
70
80
  /** Identifiers a lesson is about: those the comment names, then those on the lines it pointed at. */
71
81
  export declare function lessonSymbols(body: string, codeLines: string[]): string[];
@@ -78,8 +88,8 @@ export declare function lessonFromComment(git: Git, comment: ReviewComment, at?:
78
88
  */
79
89
  export declare function lessonsFromReview(review: ReviewBody, changedAfter: string[], at?: string): ReviewLesson[];
80
90
  /**
81
- * Add lessons to the store. The same lesson again (same text, or the same
82
- * file with two shared symbols) adds evidence; acted on in enough PRs, it is
91
+ * Add lessons to the store. The same lesson again (isSameLesson: the same text, the same review
92
+ * comment, or the same file with two shared symbols) adds evidence; acted on in enough PRs, it is
83
93
  * verified.
84
94
  */
85
95
  export declare function mergeLessons(existing: ReviewLesson[], incoming: ReviewLesson[]): {
@@ -118,6 +128,8 @@ export declare function pendingDecision(lesson: ReviewLesson): LessonEvidence |
118
128
  export declare function writeLessons(cwd: string, lessons: ReviewLesson[]): void;
119
129
  /** A person's decision on a lesson, kept as evidence: accepted makes it a lesson, rejected an anti-lesson. Undefined for an unknown id. */
120
130
  export declare function decideLesson(cwd: string, id: string, decision: 'accepted' | 'rejected' | 'dismissed', by: string, why?: string): ReviewLesson | undefined;
131
+ /** A person takes a decided lesson's suggested wording; who and the wording it replaced are kept as evidence. */
132
+ export declare function acceptSuggestedText(cwd: string, id: string, by: string): ReviewLesson | undefined;
121
133
  /** An identifier specific enough to link two pieces of code: camelCase, snake_case, or long. */
122
134
  export declare function isSpecific(symbol: string): boolean;
123
135
  /** The meaningful words of a text or an identifier: `hasLaterAttempt` and "a later attempt" share later and attempt. */
@@ -15,7 +15,7 @@
15
15
  import crypto from 'crypto';
16
16
  import fs from 'fs';
17
17
  import path from 'path';
18
- import { bodyPoints } from './review-points.js';
18
+ import { bodyPoints, withoutEmphasis } from './review-points.js';
19
19
  const STORE = path.join('.rigour', 'review-lessons.json');
20
20
  const MAX_TEXT = 220;
21
21
  const MAX_SYMBOLS = 8;
@@ -69,7 +69,12 @@ export function lessonState(lesson) {
69
69
  const independent = reviewers >= 2 || wordings >= 2;
70
70
  return prs >= RECUR_PRS && authors >= RECUR_AUTHORS && independent ? { state: 'verified', promotedBy: 'recurrence' } : { state: 'candidate' };
71
71
  }
72
- /** The point of a review comment: its bold title, else its first sentence, without tool output or markup. */
72
+ /** A sentence that tells the author what to do, not only what is wrong. */
73
+ const INSTRUCTION = /^(?:please\s+)?(?:use|pick|avoid|don'?t|do not|never|always|make|keep|move|read|filter|check|change|drop|add|remove|pass|split|prefer|replace|rename|derive|select|return|validate|extract|bound|lock)\b|\b(?:should|must|instead)\b/i;
74
+ /**
75
+ * The point of a review comment: its bold title, else its first sentence and, when that only says what is
76
+ * wrong, the first later sentence that says what to do. Without tool output or markup.
77
+ */
73
78
  export function lessonText(body) {
74
79
  const cleaned = body
75
80
  .replace(/<!--[\s\S]*?-->/g, '')
@@ -80,8 +85,10 @@ export function lessonText(body) {
80
85
  .filter(line => !/^\s*#*\s*\**\s*(low|medium|high|critical)\s+severity\s*\**\s*$/i.test(line))
81
86
  .join('\n');
82
87
  const bold = /\*\*(.+?)\*\*/.exec(cleaned)?.[1]?.trim();
83
- const text = bold || cleaned.replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s/)[0] || '';
84
- return pointText(text.replace(/[*_]/g, '')).slice(0, MAX_TEXT).trim();
88
+ const sentences = cleaned.replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s/);
89
+ const instruction = INSTRUCTION.test(sentences[0] ?? '') ? undefined : sentences.slice(1).find(s => INSTRUCTION.test(s));
90
+ const text = bold || [sentences[0] ?? '', instruction ?? ''].filter(Boolean).join(' ');
91
+ return pointText(withoutEmphasis(text)).slice(0, MAX_TEXT).trim();
85
92
  }
86
93
  /**
87
94
  * A point's words without a review bot's scaffolding (`In src/a.ts around lines 10-12:`), or '' when
@@ -130,7 +137,7 @@ export function lessonFromComment(git, comment, at = new Date().toISOString()) {
130
137
  */
131
138
  export function lessonsFromReview(review, changedAfter, at = new Date().toISOString()) {
132
139
  return bodyPoints(review.body).flatMap((point, i) => {
133
- const text = pointText(point.replace(/[*_]/g, '')).slice(0, MAX_TEXT).trim();
140
+ const text = pointText(withoutEmphasis(point)).slice(0, MAX_TEXT).trim();
134
141
  if (text.length < 12)
135
142
  return [];
136
143
  const named = /(?:^|[\s`(])((?:[\w.-]+\/)+[\w.-]+\.\w+)/.exec(point)?.[1];
@@ -143,18 +150,15 @@ export function lessonsFromReview(review, changedAfter, at = new Date().toISOStr
143
150
  });
144
151
  }
145
152
  /**
146
- * Add lessons to the store. The same lesson again (same text, or the same
147
- * file with two shared symbols) adds evidence; acted on in enough PRs, it is
153
+ * Add lessons to the store. The same lesson again (isSameLesson: the same text, the same review
154
+ * comment, or the same file with two shared symbols) adds evidence; acted on in enough PRs, it is
148
155
  * verified.
149
156
  */
150
157
  export function mergeLessons(existing, incoming) {
151
158
  const lessons = existing.map(l => ({ ...l, evidence: [...l.evidence] }));
152
159
  let added = 0;
153
160
  for (const lesson of incoming) {
154
- // Shared names find the same point made again on another pull request; two points on one pull request are two points.
155
- const otherPr = (l) => !l.evidence.some(e => lesson.evidence.some(x => x.pr === e.pr));
156
- const same = lessons.find(l => l.id === lesson.id || (l.file === lesson.file && shared(l.symbols, lesson.symbols) >= 2 && otherPr(l))
157
- || (!l.file && !lesson.file && sameWords(l.text, lesson.text)));
161
+ const same = lessons.find(l => isSameLesson(l, lesson));
158
162
  if (!same) {
159
163
  lessons.push({ ...lesson, evidence: [...lesson.evidence] });
160
164
  added++;
@@ -163,6 +167,7 @@ export function mergeLessons(existing, incoming) {
163
167
  for (const e of lesson.evidence)
164
168
  if (!same.evidence.some(x => x.comment === e.comment && x.kind === e.kind))
165
169
  same.evidence.push(e);
170
+ refreshText(same, lesson);
166
171
  same.updatedAt = lesson.updatedAt;
167
172
  }
168
173
  let verified = 0;
@@ -174,6 +179,49 @@ export function mergeLessons(existing, incoming) {
174
179
  }
175
180
  return { lessons, added, verified };
176
181
  }
182
+ /** Whether a lesson carries the review point `point` (the same comment on the same pull request). */
183
+ function hasPoint(lesson, point) {
184
+ return lesson.evidence.some(x => x.kind === 'point' && x.pr === point.pr && x.comment === point.comment);
185
+ }
186
+ /** A person accepted, rejected or dismissed it, or approved or took back a check compiled from it. */
187
+ function decidedByPerson(lesson) {
188
+ return lesson.evidence.some(e => e.kind === 'accepted' || e.kind === 'rejected' || e.kind === 'dismissed' || e.kind === 'compiled');
189
+ }
190
+ /**
191
+ * Re-read from the comment it was learned from, an undecided lesson takes the text this version derives; its id
192
+ * and evidence stay. One a person decided keeps the wording they decided on, and the new one waits as a
193
+ * suggestion for them. A later comment merged into a lesson never rewrites it.
194
+ */
195
+ function refreshText(stored, lesson) {
196
+ const origin = stored.evidence.find(e => e.kind === 'point');
197
+ if (stored.id === lesson.id || !origin || !hasPoint(lesson, origin))
198
+ return;
199
+ if (lesson.text === stored.text) {
200
+ delete stored.suggestedText;
201
+ delete stored.suggestedWhy;
202
+ }
203
+ else if (decidedByPerson(stored)) {
204
+ stored.suggestedText = lesson.text;
205
+ stored.suggestedWhy = 'parser fix';
206
+ }
207
+ else
208
+ stored.text = lesson.text;
209
+ }
210
+ /**
211
+ * The same lesson: the same id; the same review comment read again, whatever text an earlier version took from it;
212
+ * shared names on another pull request (two points on one pull request are two points); or a team standard in the
213
+ * same words.
214
+ */
215
+ function isSameLesson(stored, lesson) {
216
+ if (stored.id === lesson.id)
217
+ return true;
218
+ if (stored.evidence.some(e => e.kind === 'point' && hasPoint(lesson, e)))
219
+ return true;
220
+ const otherPr = !stored.evidence.some(e => lesson.evidence.some(x => x.pr === e.pr));
221
+ if (stored.file === lesson.file && shared(stored.symbols, lesson.symbols) >= 2 && otherPr)
222
+ return true;
223
+ return !stored.file && !lesson.file && sameWords(stored.text, lesson.text);
224
+ }
177
225
  /**
178
226
  * Lessons that apply to a change, best first. A lesson applies when the change
179
227
  * touches its file, or shares at least two specific identifiers with it; a
@@ -278,6 +326,21 @@ export function decideLesson(cwd, id, decision, by, why = '') {
278
326
  writeLessons(cwd, lessons);
279
327
  return lesson;
280
328
  }
329
+ /** A person takes a decided lesson's suggested wording; who and the wording it replaced are kept as evidence. */
330
+ export function acceptSuggestedText(cwd, id, by) {
331
+ const lessons = readLessons(cwd);
332
+ const lesson = lessons.find(l => l.id === id);
333
+ if (!lesson?.suggestedText)
334
+ return undefined;
335
+ const at = new Date().toISOString();
336
+ lesson.evidence.push({ kind: 'reworded', pr: lesson.evidence[0]?.pr ?? 0, comment: `reworded-${at}`, author: by, detail: `${lesson.suggestedWhy ?? 'reworded'}; was: ${lesson.text}`, at });
337
+ lesson.text = lesson.suggestedText;
338
+ delete lesson.suggestedText;
339
+ delete lesson.suggestedWhy;
340
+ lesson.updatedAt = at;
341
+ writeLessons(cwd, lessons);
342
+ return lesson;
343
+ }
281
344
  /** An identifier specific enough to link two pieces of code: camelCase, snake_case, or long. */
282
345
  export function isSpecific(symbol) {
283
346
  return /[a-z][A-Z]|[A-Za-z]_[A-Za-z]/.test(symbol) || symbol.length >= 10;
@@ -1,4 +1,6 @@
1
1
  /** The bullet and numbered lines of a review body: the points a reviewer makes outside any one line. */
2
2
  export declare function bodyPoints(body: string): string[];
3
- /** A comment's first non-empty line, markup removed, at most 140 characters. */
3
+ /** Markdown emphasis off, never the underscores of an identifier (`RESTOCK_CHUNK`) or anything inside a code span. */
4
+ export declare function withoutEmphasis(text: string): string;
5
+ /** A comment's first non-empty line, heading marks and emphasis removed, at most 140 characters. */
4
6
  export declare function firstLine(text: string): string;
@@ -7,8 +7,13 @@ const BULLET = /^\s*(?:[-*]|\d+[.)])\s+(.{8,})$/;
7
7
  export function bodyPoints(body) {
8
8
  return body.split('\n').map(l => BULLET.exec(l)?.[1]).filter((l) => !!l).map(firstLine);
9
9
  }
10
- /** A comment's first non-empty line, markup removed, at most 140 characters. */
10
+ /** Markdown emphasis off, never the underscores of an identifier (`RESTOCK_CHUNK`) or anything inside a code span. */
11
+ export function withoutEmphasis(text) {
12
+ return text.split(/(`[^`]*`)/).map(part => part.startsWith('`') ? part
13
+ : part.replace(/\*+/g, '').replace(/(^|[^\w])__?(?=\S)(.*?\S)__?(?=[^\w]|$)/g, '$1$2')).join('');
14
+ }
15
+ /** A comment's first non-empty line, heading marks and emphasis removed, at most 140 characters. */
11
16
  export function firstLine(text) {
12
- const line = text.split('\n').find(l => l.trim())?.replace(/[*_`#]/g, '').trim() ?? '';
17
+ const line = withoutEmphasis(text.split('\n').find(l => l.trim())?.replace(/^\s*#+\s*/, '') ?? '').trim();
13
18
  return line.length > 140 ? `${line.slice(0, 137)}...` : line;
14
19
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.11.0",
3
+ "version": "6.11.1-rc.1",
4
4
  "description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, across TypeScript, JavaScript, Python, Go, Ruby and C#.",
5
5
  "engines": {
6
6
  "node": ">=22.13"
@@ -75,11 +75,11 @@
75
75
  "@anthropic-ai/sdk": "^0.132.1",
76
76
  "pg": "^8.16.3",
77
77
  "openai": "^5.23.2",
78
- "@rigour-labs/brain-darwin-arm64": "6.11.0",
79
- "@rigour-labs/brain-darwin-x64": "6.11.0",
80
- "@rigour-labs/brain-linux-arm64": "6.11.0",
81
- "@rigour-labs/brain-win-x64": "6.11.0",
82
- "@rigour-labs/brain-linux-x64": "6.11.0"
78
+ "@rigour-labs/brain-darwin-arm64": "6.11.1-rc.1",
79
+ "@rigour-labs/brain-darwin-x64": "6.11.1-rc.1",
80
+ "@rigour-labs/brain-linux-x64": "6.11.1-rc.1",
81
+ "@rigour-labs/brain-linux-arm64": "6.11.1-rc.1",
82
+ "@rigour-labs/brain-win-x64": "6.11.1-rc.1"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/fs-extra": "^11.0.4",