@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
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Each file as the agent just left it (repository-relative paths). */
|
|
2
|
+
export declare function recordAgentWrites(cwd: string, files: string[]): void;
|
|
3
|
+
/**
|
|
4
|
+
* The files a person changed since the agent last wrote them, stored as candidate lessons with correction
|
|
5
|
+
* evidence (the rule writer turns each into the rule behind it, or finds none). Returns how many were taken.
|
|
6
|
+
*/
|
|
7
|
+
export declare function captureHumanEdits(cwd: string, by?: string): number;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A person editing what an agent wrote is the strongest lesson there is: they corrected the work
|
|
3
|
+
* toward what the team wants. The after-edit hook records each file as the agent left it
|
|
4
|
+
* (recordAgentWrites); at the stop and the push, a recorded file that now reads differently was
|
|
5
|
+
* changed by someone else (captureHumanEdits), and that change becomes a candidate lesson with
|
|
6
|
+
* correction evidence. A change that came from git (a checkout, a pull) or only moved whitespace
|
|
7
|
+
* is not a correction. Each edit is taken once. Everything stays in the repository's .rigour/.
|
|
8
|
+
*/
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import { execFileSync } from 'child_process';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { compactDiff } from '../review/stories.js';
|
|
14
|
+
import { mergeLessons, readLessons, writeLessons } from './lessons.js';
|
|
15
|
+
const DIR = path.join('.rigour', 'agent-writes');
|
|
16
|
+
const MAX_FILE_BYTES = 200_000;
|
|
17
|
+
/** Lines of the change kept as the lesson's words. */
|
|
18
|
+
const MAX_DIFF_LINES = 12;
|
|
19
|
+
const recordPath = (cwd, file) => path.join(cwd, DIR, `${crypto.createHash('sha256').update(file).digest('hex').slice(0, 16)}.json`);
|
|
20
|
+
const squash = (text) => text.replace(/\s+/g, '');
|
|
21
|
+
function headOf(cwd) {
|
|
22
|
+
try {
|
|
23
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function atHead(cwd, file) {
|
|
30
|
+
try {
|
|
31
|
+
return execFileSync('git', ['show', `HEAD:${file}`], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 4 * MAX_FILE_BYTES });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Each file as the agent just left it (repository-relative paths). */
|
|
38
|
+
export function recordAgentWrites(cwd, files) {
|
|
39
|
+
const head = headOf(cwd);
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
try {
|
|
42
|
+
const content = fs.readFileSync(path.join(cwd, file), 'utf8');
|
|
43
|
+
if (content.length > MAX_FILE_BYTES)
|
|
44
|
+
continue;
|
|
45
|
+
fs.mkdirSync(path.join(cwd, DIR), { recursive: true });
|
|
46
|
+
fs.writeFileSync(recordPath(cwd, file), JSON.stringify({ file, content, ...(head ? { head } : {}), at: new Date().toISOString() }));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// a file the agent deleted or cannot be read: nothing to compare later
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The files a person changed since the agent last wrote them, stored as candidate lessons with correction
|
|
55
|
+
* evidence (the rule writer turns each into the rule behind it, or finds none). Returns how many were taken.
|
|
56
|
+
*/
|
|
57
|
+
export function captureHumanEdits(cwd, by = 'a person') {
|
|
58
|
+
let entries = [];
|
|
59
|
+
try {
|
|
60
|
+
entries = fs.readdirSync(path.join(cwd, DIR));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
const at = new Date().toISOString();
|
|
66
|
+
const lessons = [];
|
|
67
|
+
const head = headOf(cwd);
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const record = path.join(cwd, DIR, entry);
|
|
70
|
+
let write;
|
|
71
|
+
let now;
|
|
72
|
+
try {
|
|
73
|
+
write = JSON.parse(fs.readFileSync(record, 'utf8'));
|
|
74
|
+
now = fs.readFileSync(path.join(cwd, write.file), 'utf8');
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
fs.rmSync(record, { force: true });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (now === write.content)
|
|
81
|
+
continue;
|
|
82
|
+
fs.rmSync(record, { force: true }); // taken once, whatever it was
|
|
83
|
+
if (squash(now) === squash(write.content))
|
|
84
|
+
continue; // whitespace only
|
|
85
|
+
if (head && head !== write.head && atHead(cwd, write.file) === now)
|
|
86
|
+
continue; // came from git: a checkout, a pull, a merge
|
|
87
|
+
const diff = compactDiff(write.content, now).slice(0, MAX_DIFF_LINES).join('\n');
|
|
88
|
+
const text = `In ${write.file}, a person changed what the agent wrote:\n${diff}`;
|
|
89
|
+
lessons.push({
|
|
90
|
+
id: crypto.createHash('sha256').update(`${write.file}\u0000${diff}`).digest('hex').slice(0, 12),
|
|
91
|
+
text, file: write.file, symbols: [], state: 'candidate',
|
|
92
|
+
evidence: [{ kind: 'correction', pr: 0, comment: `edit-${crypto.createHash('sha256').update(`${write.file}${write.at}`).digest('hex').slice(0, 12)}`, author: by, text, detail: `edited after the agent's write of ${write.at}`, at }],
|
|
93
|
+
createdAt: at, updatedAt: at,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (lessons.length)
|
|
97
|
+
writeLessons(cwd, mergeLessons(readLessons(cwd), lessons).lessons);
|
|
98
|
+
return lessons.length;
|
|
99
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { captureHumanEdits, recordAgentWrites } from './human-edits.js';
|
|
7
|
+
import { readLessons } from './lessons.js';
|
|
8
|
+
let repo;
|
|
9
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
|
|
10
|
+
const write = (body) => fs.writeFileSync(path.join(repo, 'src/job.ts'), body);
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'human-edits-'));
|
|
13
|
+
git('init', '-q', '-b', 'main');
|
|
14
|
+
git('config', 'user.email', 't@example.com');
|
|
15
|
+
git('config', 'user.name', 't');
|
|
16
|
+
git('config', 'commit.gpgsign', 'false');
|
|
17
|
+
fs.mkdirSync(path.join(repo, 'src'));
|
|
18
|
+
write('export function job(rows) {\n return rows.map(r => r.id);\n}\n');
|
|
19
|
+
git('add', '-A');
|
|
20
|
+
git('commit', '-qm', 'base');
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
23
|
+
describe('a person editing what the agent wrote', () => {
|
|
24
|
+
it('becomes a lesson with correction evidence, taken once', () => {
|
|
25
|
+
write('export function job(rows) {\n return rows.slice(0, 10000).map(r => r.id);\n}\n'); // the agent's write
|
|
26
|
+
recordAgentWrites(repo, ['src/job.ts']);
|
|
27
|
+
write('export function job(rows, after) {\n return rows.filter(r => r.id > after).slice(0, 500).map(r => r.id);\n}\n'); // the person's fix
|
|
28
|
+
expect(captureHumanEdits(repo, 'lead@acme')).toBe(1);
|
|
29
|
+
const [lesson] = readLessons(repo);
|
|
30
|
+
expect(lesson).toMatchObject({ file: 'src/job.ts', state: 'verified', promotedBy: 'correction' });
|
|
31
|
+
expect(lesson.text).toContain('a person changed what the agent wrote');
|
|
32
|
+
expect(lesson.text).toContain('+ return rows.filter(r => r.id > after).slice(0, 500).map(r => r.id);');
|
|
33
|
+
expect(lesson.evidence[0]).toMatchObject({ kind: 'correction', author: 'lead@acme' });
|
|
34
|
+
expect(captureHumanEdits(repo, 'lead@acme')).toBe(0); // taken once
|
|
35
|
+
});
|
|
36
|
+
it('is not a correction when only whitespace moved, or the change came from git', () => {
|
|
37
|
+
recordAgentWrites(repo, ['src/job.ts']);
|
|
38
|
+
write('export function job(rows) {\n return rows.map(r => r.id);\n}\n'); // a formatter
|
|
39
|
+
expect(captureHumanEdits(repo)).toBe(0);
|
|
40
|
+
recordAgentWrites(repo, ['src/job.ts']);
|
|
41
|
+
git('checkout', '-q', '-b', 'other');
|
|
42
|
+
write('export const job = (rows) => rows.map(r => r.id);\n');
|
|
43
|
+
git('commit', '-qam', 'someone else\'s branch');
|
|
44
|
+
expect(captureHumanEdits(repo)).toBe(0); // a checkout brought it, not a person correcting the agent
|
|
45
|
+
expect(readLessons(repo)).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `rigour learn
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
7
|
-
import {
|
|
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
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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;
|