@rigour-labs/core 6.7.2 → 6.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/hooks/index.d.ts +3 -3
  2. package/dist/hooks/index.js +2 -2
  3. package/dist/hooks/session-state.d.ts +3 -0
  4. package/dist/hooks/session-state.js +10 -0
  5. package/dist/hooks/stop-review.d.ts +8 -15
  6. package/dist/hooks/stop-review.js +36 -2
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.js +4 -2
  9. package/dist/review/backtest-init.js +13 -11
  10. package/dist/review/backtest-init.test.js +26 -2
  11. package/dist/review/backtest.js +35 -3
  12. package/dist/review/backtest.test.js +44 -2
  13. package/dist/review/dead-code.test.js +13 -0
  14. package/dist/review/orphan-files.js +3 -2
  15. package/dist/review/quiet.d.ts +1 -1
  16. package/dist/review/quiet.js +3 -1
  17. package/dist/review/review-task.d.ts +2 -6
  18. package/dist/review/review-task.js +2 -2
  19. package/dist/review/reviewer/adapters.d.ts +21 -0
  20. package/dist/review/reviewer/adapters.js +70 -15
  21. package/dist/review/reviewer/adapters.test.js +22 -0
  22. package/dist/review/reviewer/background.test.js +1 -1
  23. package/dist/review/reviewer/context.d.ts +3 -0
  24. package/dist/review/reviewer/context.js +14 -5
  25. package/dist/review/reviewer/context.test.js +26 -1
  26. package/dist/review/reviewer/exec.d.ts +9 -0
  27. package/dist/review/reviewer/exec.js +28 -1
  28. package/dist/review/reviewer/exec.test.js +38 -2
  29. package/dist/review/reviewer/inputs.d.ts +6 -0
  30. package/dist/review/reviewer/inputs.js +26 -0
  31. package/dist/review/reviewer/judge-env.d.ts +3 -0
  32. package/dist/review/reviewer/judge-env.js +10 -0
  33. package/dist/review/reviewer/prompt.js +68 -12
  34. package/dist/review/reviewer/rule-writer.d.ts +10 -0
  35. package/dist/review/reviewer/rule-writer.js +30 -0
  36. package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
  37. package/dist/review/reviewer/rule-writer.test.js +52 -0
  38. package/dist/review/reviewer/settings.d.ts +4 -0
  39. package/dist/review/reviewer/settings.js +2 -1
  40. package/dist/review/reviewer/verdict.d.ts +71 -9
  41. package/dist/review/reviewer/verdict.js +102 -10
  42. package/dist/review/reviewer.d.ts +5 -2
  43. package/dist/review/reviewer.js +56 -41
  44. package/dist/review/reviewer.test.js +170 -17
  45. package/dist/review/typed/constant-inputs.d.ts +35 -0
  46. package/dist/review/typed/constant-inputs.js +168 -0
  47. package/dist/review/typed/dead-null-guards.d.ts +34 -0
  48. package/dist/review/typed/dead-null-guards.js +147 -0
  49. package/dist/review/typed/redundancy.js +6 -0
  50. package/dist/review/typed/redundancy.test.js +136 -0
  51. package/dist/review/unused-exports.js +3 -2
  52. package/dist/review-learning/acted-on.d.ts +23 -3
  53. package/dist/review-learning/acted-on.js +25 -13
  54. package/dist/review-learning/human-edits.d.ts +7 -0
  55. package/dist/review-learning/human-edits.js +99 -0
  56. package/dist/review-learning/human-edits.test.d.ts +1 -0
  57. package/dist/review-learning/human-edits.test.js +47 -0
  58. package/dist/review-learning/learn-from-reviews.d.ts +37 -4
  59. package/dist/review-learning/learn-from-reviews.js +128 -12
  60. package/dist/review-learning/lessons.d.ts +62 -9
  61. package/dist/review-learning/lessons.js +122 -20
  62. package/dist/review-learning/outcomes.d.ts +26 -0
  63. package/dist/review-learning/outcomes.js +73 -0
  64. package/dist/review-learning/review-learning.test.js +149 -22
  65. package/dist/review-learning/review-points.d.ts +4 -0
  66. package/dist/review-learning/review-points.js +14 -0
  67. package/dist/review-learning/rules-from-reviews.d.ts +14 -0
  68. package/dist/review-learning/rules-from-reviews.js +86 -0
  69. package/dist/review-learning/team-lessons.d.ts +17 -1
  70. package/dist/review-learning/team-lessons.js +29 -5
  71. package/dist/templates/universal-config.js +3 -0
  72. package/dist/types/index.d.ts +63 -0
  73. package/dist/types/index.js +11 -0
  74. package/dist/utils/profile.d.ts +5 -1
  75. package/dist/utils/profile.js +7 -1
  76. package/dist/utils/profile.test.js +10 -0
  77. package/package.json +6 -6
@@ -1,9 +1,17 @@
1
1
  /**
2
- * `rigour learn --from-reviews`: turn a repository's acted-on review comments
3
- * into review lessons. Reads GitHub (merged PRs and their review comments) and
4
- * the local clone; writes only .rigour/review-lessons.json.
2
+ * `rigour learn-reviews`: turn a repository's review points into lessons, on evidence. Reads GitHub
3
+ * (merged PRs, their review comments and review bodies) and the local clone; writes only
4
+ * .rigour/review-lessons.json (and, with rules, the audit log beside it).
5
+ *
6
+ * Every point is a candidate, whoever wrote it: a person, an AI posting under a person's login, a
7
+ * review bot. Who wrote it, and whether the pull request changed those lines, are recorded and never
8
+ * decide anything: agents apply review comments on their own, and people paste AI text. Evidence
9
+ * decides (lessons.ts lessonState): what later happened to the lines (outcomes.ts), a person's
10
+ * decision, or the same point recurring across pull requests by different authors. The pull
11
+ * request's author commenting on their own pull request is not a review point.
5
12
  */
6
13
  import { type Git } from './acted-on.js';
14
+ import { type RuleWriter } from './rules-from-reviews.js';
7
15
  type Fetch = (url: string, init?: any) => Promise<{
8
16
  ok: boolean;
9
17
  status: number;
@@ -15,9 +23,20 @@ export interface LearnFromReviewsOptions {
15
23
  repo: string;
16
24
  /** Only PRs merged on or after this ISO date. */
17
25
  since?: string;
18
- /** Only PRs merged before this ISO date (a time split for measuring). */
26
+ /** Only PRs merged before this ISO date (a time split for measuring); with `pr`, only reviews posted before it. */
19
27
  until?: string;
28
+ /**
29
+ * Learn from this one pull request, open or merged: its reviews up to `until`, acted on when files
30
+ * changed after a review by its head as of `until`. A long-running pull request teaches as it goes.
31
+ */
32
+ pr?: number;
20
33
  limit?: number;
34
+ /** The main branch, for outcome evidence (what later happened to the lines); none is gathered without it. */
35
+ mainRef?: string;
36
+ /** How long a point's unchanged lines must ship before that counts against it. */
37
+ windowDays?: number;
38
+ /** Rewrite each newly promoted lesson as the rule behind it, or drop it as no rule (rules-from-reviews.ts); off: the words as they are. */
39
+ writeRules?: RuleWriter;
21
40
  apiUrl?: string;
22
41
  fetch?: Fetch;
23
42
  git?: Git;
@@ -26,6 +45,20 @@ export interface LearnFromReviewsResult {
26
45
  prs: number;
27
46
  comments: number;
28
47
  actedOn: number;
48
+ /** Review bodies read (each point in one is a candidate). */
49
+ reviewBodies: number;
50
+ /** Candidate points read this run, by who wrote them. */
51
+ candidates: {
52
+ person: number;
53
+ bot: number;
54
+ };
55
+ /** Lessons now promoted, by the evidence that promoted them; anti-lessons; candidates held back by counter-evidence. */
56
+ promoted: Record<'outcome' | 'correction' | 'person' | 'recurrence' | 'legacy', number>;
57
+ rejected: number;
58
+ heldBack: number;
59
+ /** With writeRules: points rewritten as rules, and points the model called no rule (dropped). */
60
+ rules?: number;
61
+ notRules?: number;
29
62
  added: number;
30
63
  verified: number;
31
64
  total: number;
@@ -1,28 +1,89 @@
1
1
  /**
2
- * `rigour learn --from-reviews`: turn a repository's acted-on review comments
3
- * into review lessons. Reads GitHub (merged PRs and their review comments) and
4
- * the local clone; writes only .rigour/review-lessons.json.
2
+ * `rigour learn-reviews`: turn a repository's review points into lessons, on evidence. Reads GitHub
3
+ * (merged PRs, their review comments and review bodies) and the local clone; writes only
4
+ * .rigour/review-lessons.json (and, with rules, the audit log beside it).
5
+ *
6
+ * Every point is a candidate, whoever wrote it: a person, an AI posting under a person's login, a
7
+ * review bot. Who wrote it, and whether the pull request changed those lines, are recorded and never
8
+ * decide anything: agents apply review comments on their own, and people paste AI text. Evidence
9
+ * decides (lessons.ts lessonState): what later happened to the lines (outcomes.ts), a person's
10
+ * decision, or the same point recurring across pull requests by different authors. The pull
11
+ * request's author commenting on their own pull request is not a review point.
5
12
  */
6
- import { actedOn, gitIn } from './acted-on.js';
7
- import { lessonFromComment, mergeLessons, readLessons, writeLessons } from './lessons.js';
13
+ import { changedSince, gitIn, withActedOn } from './acted-on.js';
14
+ import { outcomeFor, revertOf } from './outcomes.js';
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import { lessonFromComment, lessonsFromReview, lessonsPath, lessonState, mergeLessons, readLessons, writeLessons } from './lessons.js';
18
+ import { rulesFromReviews } from './rules-from-reviews.js';
8
19
  export async function learnFromReviews(cwd, options) {
9
20
  const git = options.git ?? gitIn(cwd);
10
21
  const prs = await mergedPrs(options);
11
22
  const lessons = [];
12
23
  let comments = 0;
13
24
  let acted = 0;
25
+ let bodies = 0;
14
26
  for (const pr of prs) {
15
27
  comments += pr.comments.length;
16
- for (const comment of actedOn(git, pr)) {
17
- acted++;
28
+ for (const comment of withActedOn(git, pr)) {
29
+ if (comment.actedOn)
30
+ acted++;
18
31
  const lesson = lessonFromComment(git, comment);
19
32
  if (lesson)
20
33
  lessons.push(lesson);
21
34
  }
35
+ for (const review of pr.reviews) {
36
+ bodies++;
37
+ lessons.push(...lessonsFromReview(review, changedSince(git, review.commit, pr.mergeSha)));
38
+ }
39
+ }
40
+ const known = new Set(readLessons(cwd).flatMap(l => l.evidence.map(e => e.comment)));
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);
44
+ // Outcomes accrue after the merge: every candidate whose pull request was read this run is checked again.
45
+ if (options.mainRef) {
46
+ const byNumber = new Map(prs.filter(pr => pr.mergedAt).map(pr => [pr.number, pr]));
47
+ const reverts = new Map([...byNumber.values()].map(pr => [pr.number, revertOf(git, pr, { mainRef: options.mainRef, until: options.until })]));
48
+ for (const lesson of merged.lessons) {
49
+ if (lesson.state !== 'candidate' || lesson.evidence.some(e => e.kind === 'outcome' || e.kind === 'counter'))
50
+ continue;
51
+ for (const point of lesson.evidence.filter(e => (e.kind ?? 'point') === 'point')) {
52
+ const pr = byNumber.get(point.pr);
53
+ if (!pr)
54
+ continue;
55
+ const found = (point.actedOn === false ? reverts.get(pr.number) : undefined) ?? outcomeFor(git, lesson, pr, { mainRef: options.mainRef, until: options.until, windowDays: options.windowDays });
56
+ if (found) {
57
+ lesson.evidence.push(found);
58
+ break;
59
+ }
60
+ }
61
+ }
62
+ for (const lesson of merged.lessons)
63
+ Object.assign(lesson, lessonState(lesson));
64
+ }
65
+ // Rules are written for what evidence promoted, once: the model is paid only for lessons that will be served.
66
+ let written;
67
+ const unwritten = merged.lessons.filter(l => l.state === 'verified' && !l.evidence.some(e => e.said));
68
+ if (options.writeRules && unwritten.length) {
69
+ written = await rulesFromReviews(unwritten, options.writeRules);
70
+ auditRules(cwd, unwritten, written.lessons);
71
+ const replaced = new Set(unwritten.map(l => l.id));
72
+ merged.lessons = [...merged.lessons.filter(l => !replaced.has(l.id)), ...written.lessons.map(l => ({ ...l, ...lessonState(l) }))];
22
73
  }
23
- const merged = mergeLessons(readLessons(cwd), lessons);
24
74
  writeLessons(cwd, merged.lessons);
25
- return { prs: prs.length, comments, actedOn: acted, added: merged.added, verified: merged.verified, total: merged.lessons.length };
75
+ const promoted = { outcome: 0, correction: 0, person: 0, recurrence: 0, legacy: 0 };
76
+ for (const l of merged.lessons)
77
+ if (l.state === 'verified' && l.promotedBy)
78
+ promoted[l.promotedBy]++;
79
+ return {
80
+ prs: prs.length, comments, actedOn: acted, reviewBodies: bodies,
81
+ candidates: { person: points.filter(e => e.source !== 'bot').length, bot: points.filter(e => e.source === 'bot').length },
82
+ promoted, rejected: merged.lessons.filter(l => l.state === 'rejected').length,
83
+ heldBack: merged.lessons.filter(l => l.state === 'candidate' && l.evidence.some(e => e.kind === 'counter')).length,
84
+ ...(written ? { rules: written.rules, notRules: written.dropped } : {}),
85
+ added: merged.added, verified: merged.lessons.filter(l => l.state === 'verified').length, total: merged.lessons.length,
86
+ };
26
87
  }
27
88
  async function mergedPrs(options) {
28
89
  const fetchImpl = options.fetch ?? fetch;
@@ -33,6 +94,8 @@ async function mergedPrs(options) {
33
94
  throw new Error(`GitHub ${url.replace(base, '')}: HTTP ${response.status}`);
34
95
  return response.json();
35
96
  };
97
+ if (options.pr !== undefined)
98
+ return [await onePr(options, base, get)];
36
99
  const limit = options.limit ?? 100;
37
100
  const prs = [];
38
101
  for (let page = 1; prs.length < limit && page <= 20; page++) {
@@ -46,20 +109,73 @@ async function mergedPrs(options) {
46
109
  continue;
47
110
  if (options.until && pr.merged_at >= options.until)
48
111
  continue;
112
+ const author = String(pr.user?.login ?? '');
113
+ const reviewer = (user) => !!user?.login && user.login !== author;
49
114
  const raw = await get(`${base}/pulls/${pr.number}/comments?per_page=100`);
50
- prs.push({ number: pr.number, mergeSha: pr.merge_commit_sha, mergedAt: pr.merged_at, comments: raw.flatMap(c => toComment(pr.number, c)) });
115
+ const reviews = await get(`${base}/pulls/${pr.number}/reviews?per_page=100`);
116
+ prs.push({
117
+ number: pr.number, mergeSha: pr.merge_commit_sha, mergedAt: pr.merged_at,
118
+ comments: raw.filter(c => reviewer(c.user)).flatMap(c => toComment(pr.number, c, author)),
119
+ reviews: reviews.filter(r => reviewer(r.user) && r.commit_id && String(r.body ?? '').trim()).map((r) => ({ id: String(r.id), prNumber: pr.number, commit: r.commit_id, body: String(r.body), author: String(r.user.login), source: sourceOf(r.user), prAuthor: author })),
120
+ });
51
121
  if (prs.length >= limit)
52
122
  break;
53
123
  }
54
124
  }
55
125
  return prs;
56
126
  }
127
+ /** One pull request as of `until`: its head then, and the reviews by people posted before it. */
128
+ async function onePr(options, base, get) {
129
+ const pr = await get(`${base}/pulls/${options.pr}`);
130
+ const before = (at) => !options.until || (typeof at === 'string' && at < options.until);
131
+ // Every page: a long-running pull request has hundreds of commits and comments.
132
+ const all = async (url) => {
133
+ const items = [];
134
+ for (let page = 1; page <= 30; page++) {
135
+ const batch = await get(`${url}?per_page=100&page=${page}`);
136
+ items.push(...batch);
137
+ if (batch.length < 100)
138
+ break;
139
+ }
140
+ return items;
141
+ };
142
+ const commits = await all(`${base}/pulls/${options.pr}/commits`);
143
+ const atUntil = commits.filter(c => before(c.commit?.committer?.date)).at(-1)?.sha;
144
+ const head = atUntil ?? pr.head?.sha;
145
+ const author = String(pr.user?.login ?? '');
146
+ const reviewer = (user) => !!user?.login && user.login !== author;
147
+ const raw = await all(`${base}/pulls/${options.pr}/comments`);
148
+ const reviews = await all(`${base}/pulls/${options.pr}/reviews`);
149
+ return {
150
+ number: Number(options.pr), mergeSha: head, mergedAt: pr.merged_at ?? '',
151
+ comments: raw.filter(c => reviewer(c.user) && before(c.created_at)).flatMap(c => toComment(Number(options.pr), c, author)),
152
+ reviews: reviews.filter(r => reviewer(r.user) && before(r.submitted_at) && r.commit_id && String(r.body ?? '').trim())
153
+ .map((r) => ({ id: String(r.id), prNumber: Number(options.pr), commit: r.commit_id, body: String(r.body), author: String(r.user.login), source: sourceOf(r.user), prAuthor: author })),
154
+ };
155
+ }
57
156
  /** A review comment where it was written: its original commit and lines. */
58
- function toComment(prNumber, c) {
157
+ function toComment(prNumber, c, prAuthor) {
59
158
  const end = c.original_line ?? c.line;
60
159
  const commit = c.original_commit_id ?? c.commit_id;
61
160
  if (!c.path || !end || !commit || c.in_reply_to_id)
62
161
  return [];
63
162
  const start = c.original_start_line ?? c.start_line ?? end;
64
- return [{ id: String(c.id), prNumber, path: c.path, start: Math.min(start, end), end, commit, body: String(c.body ?? ''), author: String(c.user?.login ?? '') }];
163
+ return [{ id: String(c.id), prNumber, path: c.path, start: Math.min(start, end), end, commit, body: String(c.body ?? ''), author: String(c.user?.login ?? ''), source: sourceOf(c.user), prAuthor }];
164
+ }
165
+ /** A GitHub App or a bot account (`type: Bot`, or a login like `name[bot]`), else a person's login, whose text may itself be an AI's. */
166
+ function sourceOf(user) {
167
+ return user?.type === 'Bot' || /\[bot\]$|bot$/i.test(String(user?.login ?? '')) ? 'bot' : 'person';
168
+ }
169
+ /** Every point the model judged, with the person's words and what it made of them (a rule, or none), beside the lessons for audit. */
170
+ function auditRules(cwd, points, kept) {
171
+ const at = new Date().toISOString();
172
+ const byComment = new Map(kept.flatMap(l => l.evidence.map(e => [e.comment, l])));
173
+ const lines = points.map(p => {
174
+ const e = p.evidence[0];
175
+ const rule = byComment.get(e.comment);
176
+ return JSON.stringify({ at, pr: e.pr, author: e.author, said: p.text, rule: rule && rule.text !== p.text ? rule.text : rule ? '(kept as said)' : null });
177
+ });
178
+ const file = path.join(path.dirname(lessonsPath(cwd)), 'review-rules-log.jsonl');
179
+ fs.mkdirSync(path.dirname(file), { recursive: true });
180
+ fs.appendFileSync(file, `${lines.join('\n')}\n`);
65
181
  }
@@ -1,23 +1,73 @@
1
- import type { Git, ReviewComment } from './acted-on.js';
1
+ import type { Git, ReviewBody, ReviewComment } from './acted-on.js';
2
+ /**
3
+ * One piece of a lesson's evidence trail:
4
+ * point a review point making it (the candidate's own source; weak, but it can recur);
5
+ * outcome a later commit fixed the lines the point named, or the pull request was reverted;
6
+ * counter the lines shipped unchanged and nothing needed fixing within the window;
7
+ * correction a person changed what an agent wrote (human-edits.ts): heavily weighted, it makes a lesson;
8
+ * accepted / rejected a person decided (`rigour learn-reviews --promote / --reject`);
9
+ * norule the rule writer found no rule in it (a report, a template, a one-off): never promoted again.
10
+ * A record from before evidence kinds has none: it is a `point`.
11
+ */
12
+ export type EvidenceKind = 'point' | 'outcome' | 'counter' | 'correction' | 'accepted' | 'rejected' | 'norule';
13
+ export interface LessonEvidence {
14
+ kind?: EvidenceKind;
15
+ pr: number;
16
+ /** The review comment or review-body point (or, for a decision, what decided it). */
17
+ comment: string;
18
+ /** Who wrote the point, or who decided. */
19
+ author: string;
20
+ /** A review bot's login (a GitHub App), or a person's (whose text may itself be an AI's): metadata, never a gate. */
21
+ source?: 'person' | 'bot';
22
+ /** Who wrote the pull request: recurrence counts only across different authors. */
23
+ prAuthor?: string;
24
+ /** Whether the pull request changed the lines before merging: not evidence (agents apply comments on their own), recorded. */
25
+ actedOn?: boolean;
26
+ /** The person's own words, kept when a rule was written from them (rules-from-reviews.ts). */
27
+ said?: string;
28
+ /** A point's own words, as it was made (each point merged into a lesson keeps its own). */
29
+ text?: string;
30
+ /** What the evidence was: the fixing commit, the window, who decided and why. */
31
+ detail?: string;
32
+ at?: string;
33
+ }
2
34
  export interface ReviewLesson {
3
35
  id: string;
4
36
  text: string;
5
37
  file: string;
6
38
  symbols: string[];
7
- state: 'candidate' | 'verified';
8
- evidence: Array<{
9
- pr: number;
10
- comment: string;
11
- author: string;
12
- }>;
39
+ /** `rejected`: an anti-lesson, a point the team decided against. */
40
+ state: 'candidate' | 'verified' | 'rejected';
41
+ /** Which evidence made it a lesson. */
42
+ promotedBy?: 'outcome' | 'correction' | 'person' | 'recurrence' | 'legacy';
43
+ evidence: LessonEvidence[];
44
+ /** Where the point sits in the commit it was made on, for outcome evidence (inline comments only). */
45
+ at?: {
46
+ commit: string;
47
+ start: number;
48
+ end: number;
49
+ };
13
50
  createdAt: string;
14
51
  updatedAt: string;
15
52
  }
53
+ /**
54
+ * A lesson's state from its evidence trail. A person's decision wins; then an outcome; counter-evidence
55
+ * holds a candidate back; recurrence across pull requests and authors is enough only without it. A record
56
+ * from before evidence kinds keeps the state it had.
57
+ */
58
+ export declare function lessonState(lesson: ReviewLesson): Pick<ReviewLesson, 'state' | 'promotedBy'>;
16
59
  /** The point of a review comment: its bold title, else its first sentence, without tool output or markup. */
17
60
  export declare function lessonText(body: string): string;
18
61
  /** Identifiers a lesson is about: those the comment names, then those on the lines it pointed at. */
19
62
  export declare function lessonSymbols(body: string, codeLines: string[]): string[];
20
63
  export declare function lessonFromComment(git: Git, comment: ReviewComment, at?: string): ReviewLesson | undefined;
64
+ /**
65
+ * Candidate lessons from a review body's points. A point naming a path is about that file; one naming
66
+ * only a file the PR changed after the review (by its name) is about that file; a point naming none is
67
+ * a team standard (file ''), served with any change it is about once evidence makes it a lesson.
68
+ * Whether files changed after it is recorded, not required.
69
+ */
70
+ export declare function lessonsFromReview(review: ReviewBody, changedAfter: string[], at?: string): ReviewLesson[];
21
71
  /**
22
72
  * Add lessons to the store. The same lesson again (same text, or the same
23
73
  * file with two shared symbols) adds evidence; acted on in enough PRs, it is
@@ -37,16 +87,19 @@ export interface ChangeShape {
37
87
  * Lessons that apply to a change, best first. A lesson applies when the change
38
88
  * touches its file, or shares at least two specific identifiers with it; a
39
89
  * shared directory only breaks ties. Lessons about docs, tests or config never
40
- * apply to other files.
90
+ * apply to other files. A team standard (a lesson with no file) applies to every
91
+ * change; the best-evidenced few follow the file lessons.
41
92
  */
42
93
  export declare function matchLessons(lessons: ReviewLesson[], change: ChangeShape, options?: {
43
94
  includeCandidates?: boolean;
44
95
  limit?: number;
96
+ standards?: number;
45
97
  }): ReviewLesson[];
46
98
  /** RIGOUR_REVIEW_LESSONS points at a lessons file outside the clone (CI, or a team's shared copy). */
47
99
  export declare function lessonsPath(cwd: string): string;
48
100
  export declare function readLessons(cwd: string): ReviewLesson[];
49
101
  export declare function writeLessons(cwd: string, lessons: ReviewLesson[]): void;
50
- export declare function promoteLesson(cwd: string, id: string): ReviewLesson | undefined;
102
+ /** A person's decision on a lesson, kept as evidence: accepted makes it a lesson, rejected an anti-lesson. Undefined for an unknown id. */
103
+ export declare function decideLesson(cwd: string, id: string, decision: 'accepted' | 'rejected', by: string, why?: string): ReviewLesson | undefined;
51
104
  /** An identifier specific enough to link two pieces of code: camelCase, snake_case, or long. */
52
105
  export declare function isSpecific(symbol: string): boolean;
@@ -1,22 +1,70 @@
1
1
  /**
2
- * Review lessons: what a team's acted-on review comments taught, ready to
3
- * hand to the next agent before it opens a PR.
2
+ * Review lessons: what a team's reviews taught, ready to hand to the next agent
3
+ * and judge.
4
4
  *
5
- * A lesson starts as a candidate with its evidence (PR, comment, author). It
6
- * becomes verified only when the same lesson was acted on in two or more PRs,
7
- * or a person promotes it. Lessons stay in the repository's .rigour/ folder
8
- * (ignored by git) and are sent nowhere except to the user's own model.
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
- const VERIFY_AT_PRS = 2;
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 }], createdAt: at, updatedAt: at,
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
- const same = lessons.find(l => l.id === lesson.id || (l.file === lesson.file && shared(l.symbols, lesson.symbols) >= 2));
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
- if (lesson.state === 'candidate' && new Set(lesson.evidence.map(e => e.pr)).size >= VERIFY_AT_PRS) {
87
- lesson.state = 'verified';
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 || l.state === 'verified') && !NOT_CODE.test(l.file))
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
- return scored.slice(0, options.limit ?? 5).map(s => s.lesson);
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
- export function promoteLesson(cwd, id) {
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
- lesson.state = 'verified';
136
- lesson.updatedAt = new Date().toISOString();
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;