@rigour-labs/core 6.9.0-rc.1 → 6.9.0-rc.3
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/goal/goal.js +2 -2
- package/dist/outcomes/outcome.d.ts +30 -12
- package/dist/outcomes/outcome.js +69 -41
- package/dist/outcomes/run.d.ts +3 -0
- package/dist/outcomes/run.js +31 -4
- package/dist/review/backtest-init.d.ts +1 -0
- package/dist/review/backtest-init.js +2 -2
- package/dist/review/reviewer/context.d.ts +11 -0
- package/dist/review/reviewer/context.js +10 -4
- package/dist/review/reviewer.d.ts +2 -0
- package/dist/review/reviewer.js +4 -2
- package/dist/review-learning/learn-from-reviews.js +3 -2
- package/dist/review-learning/lessons.d.ts +15 -6
- package/dist/review-learning/lessons.js +26 -6
- package/dist/review-learning/outcome-evidence.d.ts +41 -0
- package/dist/review-learning/outcome-evidence.js +72 -0
- package/dist/review-learning/outcomes.d.ts +4 -0
- package/dist/review-learning/outcomes.js +5 -3
- package/dist/switches.d.ts +2 -0
- package/dist/task/thread.d.ts +2 -0
- package/dist/task/thread.js +7 -0
- package/dist/templates/universal-config.js +1 -1
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.js +2 -0
- package/package.json +6 -6
package/dist/goal/goal.js
CHANGED
|
@@ -44,9 +44,9 @@ const FILE_EXTENSIONS = new Set(['ts', 'tsx', 'mts', 'cts', 'js', 'mjs', 'cjs',
|
|
|
44
44
|
'ini', 'cfg', 'conf', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'php', 'scala', 'ex', 'exs', 'dart', 'lua', 'tf', 'hcl', 'snap']);
|
|
45
45
|
/**
|
|
46
46
|
* A token that names a file or path: has a folder separator or a glob, or ends in a known file extension. Member
|
|
47
|
-
* access (`JSON.parse`, `
|
|
47
|
+
* access (`JSON.parse`, `order.customerId`, `res.status`) is a symbol, not a file. A bare `name.ext` is a file only when
|
|
48
48
|
* `exists` (the repository's file names) knows it, so `package.json` is a file and `res.json` is a member access. A URL
|
|
49
|
-
* or an app route (`https://host/checkout?x=1`, `/
|
|
49
|
+
* or an app route (`https://host/checkout?x=1`, `/app/dashboard`) is never a file of the repository.
|
|
50
50
|
*/
|
|
51
51
|
function isPath(token, exists) {
|
|
52
52
|
if (/:\/\//.test(token) || token.startsWith('/') || !/^[\w.@/*?[\]{}!,+()$~-]+$/.test(token))
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { type Exec } from '../review/reviewer/exec.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* failure: a check that passed on the commit before the merge failed on the merge commit (the merge broke it; a check
|
|
4
|
+
* already failing on main says nothing about this pull request); success: none did; pending while one runs; none when
|
|
5
|
+
* there are no runs; unavailable when GitHub could not say.
|
|
6
|
+
*/
|
|
3
7
|
export type CiResult = 'success' | 'failure' | 'pending' | 'none' | 'unavailable';
|
|
4
8
|
export interface FollowUp {
|
|
5
9
|
sha: string;
|
|
@@ -15,6 +19,8 @@ export interface PrOutcome {
|
|
|
15
19
|
mergeSha: string;
|
|
16
20
|
mergedAt: string;
|
|
17
21
|
branch: string;
|
|
22
|
+
/** Who wrote the pull request: independence between pull requests is judged by it. */
|
|
23
|
+
author: string;
|
|
18
24
|
/** What the merge changed on main: its first-parent diff. */
|
|
19
25
|
files: string[];
|
|
20
26
|
ci: CiResult;
|
|
@@ -35,28 +41,40 @@ export interface OutcomeOptions {
|
|
|
35
41
|
windowDays: number;
|
|
36
42
|
/** Only history before this time (a backtest); now when unset. */
|
|
37
43
|
until?: string;
|
|
38
|
-
/** The CI result for
|
|
39
|
-
ci: (sha: string) => Promise<CiResult>;
|
|
44
|
+
/** The CI result for the merge commit against the commit before it on main (gh); `unavailable` when it cannot be read. */
|
|
45
|
+
ci: (sha: string, parent: string) => Promise<CiResult>;
|
|
46
|
+
/** How git is run (tests count the calls); `spawnSync` by default. */
|
|
47
|
+
git?: (args: string[]) => string;
|
|
40
48
|
}
|
|
49
|
+
type MergedRef = {
|
|
50
|
+
number: number;
|
|
51
|
+
mergeSha: string;
|
|
52
|
+
mergedAt: string;
|
|
53
|
+
branch: string;
|
|
54
|
+
author: string;
|
|
55
|
+
};
|
|
41
56
|
/**
|
|
42
|
-
* The CI result for a commit from all its check runs, every page (a big matrix runs more than 100: a failure on
|
|
43
|
-
* must not read as success)
|
|
57
|
+
* The CI result for a merge commit from all its check runs, every page (a big matrix runs more than 100: a failure on
|
|
58
|
+
* page two must not read as success). A run that failed is counted only when the same check did not fail on the commit
|
|
59
|
+
* before the merge, read only then: on a main branch where a check is often red, "any run failed" says nothing about the
|
|
60
|
+
* pull request. Any error is `unavailable`, never a throw.
|
|
44
61
|
*/
|
|
45
|
-
export declare function checkRunsCi(cwd: string, exec: Exec, env?: Record<string, string>): (sha: string) => Promise<CiResult>;
|
|
62
|
+
export declare function checkRunsCi(cwd: string, exec: Exec, env?: Record<string, string>): (sha: string, parent: string) => Promise<CiResult>;
|
|
63
|
+
interface OutcomeStore {
|
|
64
|
+
version: 1;
|
|
65
|
+
outcomes: Record<string, PrOutcome>;
|
|
66
|
+
}
|
|
67
|
+
export declare function readPrOutcomes(cwd: string): OutcomeStore;
|
|
46
68
|
/**
|
|
47
69
|
* Brings the store up to date for these merged pull requests: a settled record is kept as it is, anything else is read
|
|
48
70
|
* again. The branch's thread (when this machine has one) gets a `merge` event the first time and an `outcome` event when
|
|
49
71
|
* the record settles. Stops at `deadline` (ms since epoch) and says so: what was read is kept.
|
|
50
72
|
*/
|
|
51
|
-
export declare function updatePrOutcomes(cwd: string, prs:
|
|
52
|
-
number: number;
|
|
53
|
-
mergeSha: string;
|
|
54
|
-
mergedAt: string;
|
|
55
|
-
branch: string;
|
|
56
|
-
}>, options: OutcomeOptions & {
|
|
73
|
+
export declare function updatePrOutcomes(cwd: string, prs: MergedRef[], options: OutcomeOptions & {
|
|
57
74
|
deadline?: number;
|
|
58
75
|
}): Promise<{
|
|
59
76
|
outcomes: PrOutcome[];
|
|
60
77
|
read: number;
|
|
61
78
|
stopped?: string;
|
|
62
79
|
}>;
|
|
80
|
+
export {};
|
package/dist/outcomes/outcome.js
CHANGED
|
@@ -14,73 +14,95 @@ import { FIX, revertsPr } from '../review-learning/outcomes.js';
|
|
|
14
14
|
import { GH_TIMEOUT_MS } from '../review/reviewer/exec.js';
|
|
15
15
|
import { appendBranchEvent } from '../task/thread.js';
|
|
16
16
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
/** An unsettled record read this recently is kept as it is: its window is open, and reading it again every run starves the older ones. */
|
|
18
|
+
const RECHECK_MS = 12 * 60 * 60 * 1000;
|
|
17
19
|
const OUTCOMES_FILE = path.join('.rigour', 'outcomes.json');
|
|
18
|
-
/** The outcome of one merged pull request as of now (or `until`). */
|
|
19
|
-
async function prOutcome(
|
|
20
|
+
/** The outcome of one merged pull request as of now (or `until`), its follow-ups taken from main's log read once for the run. */
|
|
21
|
+
async function prOutcome(pr, options, run, log) {
|
|
20
22
|
const now = options.until ? Date.parse(options.until) : Date.now();
|
|
21
23
|
const windowEnd = new Date(Math.min(Date.parse(pr.mergedAt) + options.windowDays * DAY_MS, now)).toISOString();
|
|
22
24
|
const closed = Date.parse(pr.mergedAt) + options.windowDays * DAY_MS <= now;
|
|
23
|
-
const files = lines(
|
|
24
|
-
const followUps =
|
|
25
|
+
const files = lines(run(['diff', '--name-only', `${pr.mergeSha}^1`, pr.mergeSha]));
|
|
26
|
+
const followUps = followUpsOf(log, pr, files, windowEnd);
|
|
25
27
|
const revert = followUps.find(f => revertsPr(f.subject, pr));
|
|
26
|
-
const ci = await options.ci(pr.mergeSha);
|
|
28
|
+
const ci = await options.ci(pr.mergeSha, `${pr.mergeSha}^1`);
|
|
27
29
|
return {
|
|
28
|
-
pr: pr.number, mergeSha: pr.mergeSha, mergedAt: pr.mergedAt, branch: pr.branch, files, ci, followUps,
|
|
30
|
+
pr: pr.number, mergeSha: pr.mergeSha, mergedAt: pr.mergedAt, branch: pr.branch, author: pr.author, files, ci, followUps,
|
|
29
31
|
...(revert ? { reverted: { sha: revert.sha, subject: revert.subject } } : {}),
|
|
30
32
|
windowEnd,
|
|
31
33
|
settled: closed && (ci === 'success' || ci === 'failure' || ci === 'none'),
|
|
32
34
|
checkedAt: new Date(now).toISOString(),
|
|
33
35
|
};
|
|
34
36
|
}
|
|
37
|
+
/** The commits after the merge on main's first-parent history, up to the window's end, that touched any of the pull request's files. */
|
|
38
|
+
function followUpsOf(log, pr, files, windowEnd) {
|
|
39
|
+
const wanted = new Set(files);
|
|
40
|
+
const merged = log.findIndex(c => c.sha === pr.mergeSha);
|
|
41
|
+
// The merge itself is in the log (it is read from a day before the earliest merge); without it, time decides.
|
|
42
|
+
const after = merged >= 0 ? log.slice(merged + 1) : log.filter(c => c.at > pr.mergedAt);
|
|
43
|
+
return after.filter(c => c.at <= windowEnd).flatMap(c => {
|
|
44
|
+
const touched = c.files.filter(file => wanted.has(file));
|
|
45
|
+
return touched.length ? [{ sha: c.sha, at: c.at, subject: c.subject, files: touched, fix: FIX.test(c.subject) }] : [];
|
|
46
|
+
});
|
|
47
|
+
}
|
|
35
48
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* limit (about 32 KB on Windows)
|
|
49
|
+
* Main's first-parent history between two times, oldest first, with each commit's files: read once for every pull
|
|
50
|
+
* request a run brings up to date (their windows overlap). The files are matched in code, not passed to git: a large
|
|
51
|
+
* pull request's paths would pass the command-line limit (about 32 KB on Windows).
|
|
39
52
|
*/
|
|
40
|
-
function
|
|
41
|
-
const
|
|
42
|
-
const out = git(cwd, ['log', '--first-parent', '--name-only', '--format=%x00%H%x09%cI%x09%s', `--since=${from}`, `--until=${to}`, `${mergeSha}..${mainRef}`]);
|
|
53
|
+
function mainLog(run, mainRef, since, until) {
|
|
54
|
+
const out = run(['log', '--first-parent', '--name-only', '--format=%x00%H%x09%cI%x09%s', `--since=${since}`, `--until=${until}`, mainRef]);
|
|
43
55
|
return out.split('\0').filter(Boolean).flatMap(block => {
|
|
44
56
|
const [header, ...rest] = block.split('\n');
|
|
45
57
|
const [sha, at, ...subject] = header.split('\t');
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const touched = rest.map(line => line.trim()).filter(file => wanted.has(file));
|
|
49
|
-
if (touched.length === 0)
|
|
50
|
-
return [];
|
|
51
|
-
const text = subject.join('\t');
|
|
52
|
-
return [{ sha, at, subject: text, files: touched, fix: FIX.test(text) }];
|
|
53
|
-
}).reverse(); // oldest first
|
|
58
|
+
return sha && at ? [{ sha, at, subject: subject.join('\t'), files: rest.map(line => line.trim()).filter(Boolean) }] : [];
|
|
59
|
+
}).reverse();
|
|
54
60
|
}
|
|
55
61
|
/**
|
|
56
|
-
* The CI result for a commit from all its check runs, every page (a big matrix runs more than 100: a failure on
|
|
57
|
-
* must not read as success)
|
|
62
|
+
* The CI result for a merge commit from all its check runs, every page (a big matrix runs more than 100: a failure on
|
|
63
|
+
* page two must not read as success). A run that failed is counted only when the same check did not fail on the commit
|
|
64
|
+
* before the merge, read only then: on a main branch where a check is often red, "any run failed" says nothing about the
|
|
65
|
+
* pull request. Any error is `unavailable`, never a throw.
|
|
58
66
|
*/
|
|
59
67
|
export function checkRunsCi(cwd, exec, env) {
|
|
60
|
-
|
|
68
|
+
const runs = async (sha) => {
|
|
61
69
|
try {
|
|
62
|
-
const read = await exec('gh', ['api', '--paginate', `repos/{owner}/{repo}/commits/${sha}/check-runs?per_page=100`, '--jq', '[.check_runs[] | {status, conclusion}]'], { cwd, timeoutMs: GH_TIMEOUT_MS, ...(env ? { env } : {}) });
|
|
70
|
+
const read = await exec('gh', ['api', '--paginate', `repos/{owner}/{repo}/commits/${sha}/check-runs?per_page=100`, '--jq', '[.check_runs[] | {name, status, conclusion}]'], { cwd, timeoutMs: GH_TIMEOUT_MS, ...(env ? { env } : {}) });
|
|
63
71
|
if (read.exitCode !== 0)
|
|
64
|
-
return
|
|
72
|
+
return undefined;
|
|
65
73
|
// One array per page; unlike parseJsonArrays, output that does not parse is unavailable here, never an empty "none" that would settle.
|
|
66
|
-
return
|
|
74
|
+
return JSON.parse(`[${read.stdout.trim().replace(/\]\s*\[/g, '],[')}]`).flat();
|
|
67
75
|
}
|
|
68
76
|
catch {
|
|
69
|
-
return
|
|
77
|
+
return undefined;
|
|
70
78
|
}
|
|
71
79
|
};
|
|
80
|
+
return async (sha, parent) => {
|
|
81
|
+
const merge = await runs(sha);
|
|
82
|
+
if (!merge)
|
|
83
|
+
return 'unavailable';
|
|
84
|
+
const failed = merge.filter(failedRun);
|
|
85
|
+
if (failed.length === 0)
|
|
86
|
+
return ciFrom(merge);
|
|
87
|
+
const before = await runs(parent);
|
|
88
|
+
if (!before)
|
|
89
|
+
return 'unavailable';
|
|
90
|
+
const redBefore = new Set(before.filter(failedRun).map(r => r.name));
|
|
91
|
+
return failed.some(r => !redBefore.has(r.name)) ? 'failure' : ciFrom(merge.filter(r => !failedRun(r)));
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function failedRun(run) {
|
|
95
|
+
return run.conclusion === 'failure' || run.conclusion === 'timed_out';
|
|
72
96
|
}
|
|
73
|
-
/**
|
|
97
|
+
/** With no new failure: a run still going keeps it pending; cancelled and skipped runs say nothing. */
|
|
74
98
|
function ciFrom(runs) {
|
|
75
99
|
if (!Array.isArray(runs) || runs.length === 0)
|
|
76
100
|
return 'none';
|
|
77
|
-
if (runs.some(r => r.conclusion === 'failure' || r.conclusion === 'timed_out'))
|
|
78
|
-
return 'failure';
|
|
79
101
|
if (runs.some(r => r.status !== 'completed'))
|
|
80
102
|
return 'pending';
|
|
81
103
|
return runs.some(r => r.conclusion === 'success') ? 'success' : 'none';
|
|
82
104
|
}
|
|
83
|
-
function readPrOutcomes(cwd) {
|
|
105
|
+
export function readPrOutcomes(cwd) {
|
|
84
106
|
try {
|
|
85
107
|
const parsed = JSON.parse(fs.readFileSync(path.join(cwd, OUTCOMES_FILE), 'utf8'));
|
|
86
108
|
return parsed?.version === 1 && parsed.outcomes && typeof parsed.outcomes === 'object' ? parsed : { version: 1, outcomes: {} };
|
|
@@ -97,29 +119,35 @@ function readPrOutcomes(cwd) {
|
|
|
97
119
|
export async function updatePrOutcomes(cwd, prs, options) {
|
|
98
120
|
const store = readPrOutcomes(cwd);
|
|
99
121
|
const result = [];
|
|
122
|
+
const run = options.git ?? ((args) => git(cwd, args));
|
|
100
123
|
let read = 0;
|
|
101
124
|
let stopped;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
125
|
+
const now = options.until ? Date.parse(options.until) : Date.now();
|
|
126
|
+
const fresh = (outcome) => !!outcome && (outcome.settled || now - Date.parse(outcome.checkedAt) < RECHECK_MS);
|
|
127
|
+
// Oldest merge first: those are the ones whose windows close, so a run cut short by the deadline still moves records
|
|
128
|
+
// towards settled instead of reading the newest, still-open ones again and again.
|
|
129
|
+
const pending = prs.filter(pr => pr.mergeSha && !fresh(store.outcomes[pr.mergeSha])).sort((a, b) => (a.mergedAt < b.mergedAt ? -1 : 1));
|
|
130
|
+
// Main's log once, across every pull request to read: from a day before the earliest merge (so each merge commit is in
|
|
131
|
+
// it) to the latest window's end, bounded at both ends.
|
|
132
|
+
const log = pending.length ? mainLog(run, options.mainRef, new Date(Math.min(...pending.map(pr => Date.parse(pr.mergedAt))) - DAY_MS).toISOString(), new Date(Math.min(now, Math.max(...pending.map(pr => Date.parse(pr.mergedAt))) + options.windowDays * DAY_MS)).toISOString()) : [];
|
|
133
|
+
for (const pr of pending) {
|
|
110
134
|
if (options.deadline !== undefined && Date.now() > options.deadline) {
|
|
111
135
|
stopped = `read deadline reached after ${read} pull request(s); run again to read the rest`;
|
|
112
136
|
break;
|
|
113
137
|
}
|
|
114
|
-
const
|
|
138
|
+
const known = store.outcomes[pr.mergeSha];
|
|
139
|
+
const outcome = await prOutcome(pr, options, run, log);
|
|
115
140
|
read++;
|
|
116
141
|
if (!known)
|
|
117
142
|
appendBranchEvent(cwd, pr.branch, { kind: 'merge', pr: pr.number, merge_sha: pr.mergeSha, merged_at: pr.mergedAt });
|
|
118
143
|
if (outcome.settled)
|
|
119
144
|
appendBranchEvent(cwd, pr.branch, { kind: 'outcome', pr: pr.number, ci: outcome.ci, follow_ups: outcome.followUps.length, fixes: outcome.followUps.filter(f => f.fix).length, reverted: !!outcome.reverted });
|
|
120
145
|
store.outcomes[pr.mergeSha] = outcome;
|
|
121
|
-
result.push(outcome);
|
|
122
146
|
}
|
|
147
|
+
// In the order asked for: every record known for these pull requests, read now or kept.
|
|
148
|
+
for (const pr of prs)
|
|
149
|
+
if (pr.mergeSha && store.outcomes[pr.mergeSha])
|
|
150
|
+
result.push(store.outcomes[pr.mergeSha]);
|
|
123
151
|
fs.mkdirSync(path.join(cwd, '.rigour'), { recursive: true });
|
|
124
152
|
fs.writeFileSync(path.join(cwd, OUTCOMES_FILE), JSON.stringify(store, null, 2) + '\n');
|
|
125
153
|
return { outcomes: result, read, ...(stopped ? { stopped } : {}) };
|
package/dist/outcomes/run.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { Config } from '../types/index.js';
|
|
|
7
7
|
import { type Exec } from '../review/reviewer/exec.js';
|
|
8
8
|
import { type ResolvedSwitch } from '../switches.js';
|
|
9
9
|
import { type PrOutcome } from './outcome.js';
|
|
10
|
+
import { type OutcomeEvidenceResult } from '../review-learning/outcome-evidence.js';
|
|
10
11
|
export interface OutcomesRun {
|
|
11
12
|
switch: ResolvedSwitch;
|
|
12
13
|
outcomes: PrOutcome[];
|
|
@@ -14,6 +15,8 @@ export interface OutcomesRun {
|
|
|
14
15
|
read: number;
|
|
15
16
|
/** Why the run read less than it was asked to, when it did. */
|
|
16
17
|
stopped?: string;
|
|
18
|
+
/** What the records did to the team's review lessons (review-learning/outcome-evidence.ts). */
|
|
19
|
+
lessons?: OutcomeEvidenceResult;
|
|
17
20
|
}
|
|
18
21
|
export declare function runOutcomes(cwd: string, config: Config, options: {
|
|
19
22
|
flag?: boolean;
|
package/dist/outcomes/run.js
CHANGED
|
@@ -2,9 +2,15 @@ import { branchBase } from '../gates/logic-drift-git-base.js';
|
|
|
2
2
|
import { mergedPrs } from '../review/backtest-init.js';
|
|
3
3
|
import { defaultExec, githubEnv, GH_TIMEOUT_MS } from '../review/reviewer/exec.js';
|
|
4
4
|
import { resolveSwitch } from '../switches.js';
|
|
5
|
-
import { checkRunsCi, updatePrOutcomes } from './outcome.js';
|
|
5
|
+
import { checkRunsCi, readPrOutcomes, updatePrOutcomes } from './outcome.js';
|
|
6
|
+
import { applyOutcomeEvidence } from '../review-learning/outcome-evidence.js';
|
|
7
|
+
import { readLessons, writeLessons } from '../review-learning/lessons.js';
|
|
8
|
+
import { gitIn } from '../review-learning/acted-on.js';
|
|
9
|
+
import { eventsOfKind } from '../task/thread.js';
|
|
6
10
|
/** How long one run may read before it stops and keeps what it has. */
|
|
7
11
|
const READ_DEADLINE_MS = 2 * 60_000;
|
|
12
|
+
/** How long one run may follow points' lines through history; the next run carries on. */
|
|
13
|
+
const LESSON_DEADLINE_MS = 60_000;
|
|
8
14
|
export async function runOutcomes(cwd, config, options) {
|
|
9
15
|
const exec = options.exec ?? defaultExec;
|
|
10
16
|
const resolved = resolveSwitch('outcomes', config, options.flag);
|
|
@@ -23,14 +29,35 @@ export async function runOutcomes(cwd, config, options) {
|
|
|
23
29
|
});
|
|
24
30
|
const incomplete = listed.incomplete ? 'the list of merged pull requests may be incomplete (gh listed too many updates to prove it)' : undefined;
|
|
25
31
|
const stopped = [run.stopped, incomplete].filter(Boolean).join('; ');
|
|
26
|
-
|
|
32
|
+
const lessons = lessonEvidence(cwd, mainRef, config.learning?.outcomes?.demote_after ?? 2);
|
|
33
|
+
return { switch: resolved, outcomes: run.outcomes, read: run.read, ...(lessons ? { lessons } : {}), ...(stopped ? { stopped } : {}) };
|
|
34
|
+
}
|
|
35
|
+
/** Every record kept (not only this run's) against the team's lessons, and the reviews that found a lesson repeated; written only when something changed. */
|
|
36
|
+
function lessonEvidence(cwd, mainRef, demoteAfter) {
|
|
37
|
+
const lessons = readLessons(cwd);
|
|
38
|
+
if (lessons.length === 0)
|
|
39
|
+
return undefined;
|
|
40
|
+
const applied = new Map();
|
|
41
|
+
for (const e of eventsOfKind(cwd, 'review')) {
|
|
42
|
+
if (typeof e.pr !== 'number' || !Array.isArray(e.lessons_applied))
|
|
43
|
+
continue;
|
|
44
|
+
const ids = applied.get(e.pr) ?? new Set();
|
|
45
|
+
for (const id of e.lessons_applied)
|
|
46
|
+
if (typeof id === 'string')
|
|
47
|
+
ids.add(id);
|
|
48
|
+
applied.set(e.pr, ids);
|
|
49
|
+
}
|
|
50
|
+
const result = applyOutcomeEvidence(lessons, Object.values(readPrOutcomes(cwd).outcomes), applied, { demoteAfter, git: gitIn(cwd), mainRef, deadline: Date.now() + LESSON_DEADLINE_MS });
|
|
51
|
+
if (result.added)
|
|
52
|
+
writeLessons(cwd, lessons);
|
|
53
|
+
return result;
|
|
27
54
|
}
|
|
28
55
|
async function onePr(cwd, pr, exec, env) {
|
|
29
|
-
const view = await exec('gh', ['pr', 'view', String(pr), '--json', 'number,mergedAt,mergeCommit,headRefName,state'], { cwd, timeoutMs: GH_TIMEOUT_MS, ...(env ? { env } : {}) });
|
|
56
|
+
const view = await exec('gh', ['pr', 'view', String(pr), '--json', 'number,mergedAt,mergeCommit,headRefName,author,state'], { cwd, timeoutMs: GH_TIMEOUT_MS, ...(env ? { env } : {}) });
|
|
30
57
|
if (view.exitCode !== 0)
|
|
31
58
|
throw new Error(`could not read pull request #${pr}: ${view.stderr.trim() || 'is gh signed in?'}`);
|
|
32
59
|
const p = JSON.parse(view.stdout);
|
|
33
60
|
if (p.state !== 'MERGED' || !p.mergeCommit?.oid)
|
|
34
61
|
throw new Error(`pull request #${pr} is not merged`);
|
|
35
|
-
return { prs: [{ number: p.number, mergedAt: p.mergedAt, mergeSha: p.mergeCommit.oid, branch: p.headRefName ?? '' }], incomplete: false };
|
|
62
|
+
return { prs: [{ number: p.number, mergedAt: p.mergedAt, mergeSha: p.mergeCommit.oid, branch: p.headRefName ?? '', author: p.author?.login ?? '' }], incomplete: false };
|
|
36
63
|
}
|
|
@@ -17,6 +17,7 @@ export interface MergedPr {
|
|
|
17
17
|
mergedAt: string;
|
|
18
18
|
mergeSha: string;
|
|
19
19
|
branch: string;
|
|
20
|
+
author: string;
|
|
20
21
|
}
|
|
21
22
|
/** The last `n` merged pull requests, newest merge first; `incomplete` when the listing could not prove it has them all. */
|
|
22
23
|
export declare function mergedPrs(cwd: string, n: number, config: Config, exec?: Exec): Promise<{
|
|
@@ -58,7 +58,7 @@ export async function mergedPrs(cwd, n, config, exec = defaultExec) {
|
|
|
58
58
|
// than its update, which is no later than that). Until then the listing doubles: bots that touch old pull requests after
|
|
59
59
|
// merge (backports, labels, stale comments) can crowd a window.
|
|
60
60
|
for (let limit = n * MERGED_OVERFETCH;; limit *= 2) {
|
|
61
|
-
const list = await exec('gh', ['pr', 'list', '--state', 'merged', '--limit', String(limit), '--search', 'sort:updated-desc', '--json', 'number,mergedAt,updatedAt,mergeCommit,headRefName'], { cwd, timeoutMs: GH_TIMEOUT_MS, env });
|
|
61
|
+
const list = await exec('gh', ['pr', 'list', '--state', 'merged', '--limit', String(limit), '--search', 'sort:updated-desc', '--json', 'number,mergedAt,updatedAt,mergeCommit,headRefName,author'], { cwd, timeoutMs: GH_TIMEOUT_MS, env });
|
|
62
62
|
if (list.exitCode !== 0)
|
|
63
63
|
throw new Error(`could not list merged pull requests: ${list.stderr.trim() || 'is gh signed in?'}`);
|
|
64
64
|
let listed;
|
|
@@ -68,7 +68,7 @@ export async function mergedPrs(cwd, n, config, exec = defaultExec) {
|
|
|
68
68
|
catch {
|
|
69
69
|
throw new Error('could not read the list of merged pull requests');
|
|
70
70
|
}
|
|
71
|
-
const prs = [...listed].sort((a, b) => (a.mergedAt < b.mergedAt ? 1 : -1)).slice(0, n).map(({ number, mergedAt, mergeCommit, headRefName }) => ({ number, mergedAt, mergeSha: mergeCommit?.oid ?? '', branch: headRefName ?? '' }));
|
|
71
|
+
const prs = [...listed].sort((a, b) => (a.mergedAt < b.mergedAt ? 1 : -1)).slice(0, n).map(({ number, mergedAt, mergeCommit, headRefName, author }) => ({ number, mergedAt, mergeSha: mergeCommit?.oid ?? '', branch: headRefName ?? '', author: author?.login ?? '' }));
|
|
72
72
|
const oldestUpdate = listed.reduce((min, p) => (p.updatedAt < min ? p.updatedAt : min), listed[0]?.updatedAt ?? '');
|
|
73
73
|
const complete = listed.length < limit || (prs.length === n && oldestUpdate <= prs[n - 1].mergedAt);
|
|
74
74
|
if (complete)
|
|
@@ -56,6 +56,16 @@ export declare function reviewerInputs(review: {
|
|
|
56
56
|
hints: string;
|
|
57
57
|
checks: string[];
|
|
58
58
|
};
|
|
59
|
+
/** A lesson as the judge was shown it: its id, and the line it was listed as (the judge answers by that line). */
|
|
60
|
+
export interface ServedLesson {
|
|
61
|
+
id: string;
|
|
62
|
+
listed: string;
|
|
63
|
+
}
|
|
64
|
+
/** The ids of the served lessons the judge said this change repeats; an answer that names no served lesson says nothing. */
|
|
65
|
+
export declare function lessonsApplied(answers: Array<{
|
|
66
|
+
lesson: string;
|
|
67
|
+
applies: boolean;
|
|
68
|
+
}>, served: ServedLesson[]): string[];
|
|
59
69
|
/** The context pack as Markdown, its hash for the fingerprint, and the router's count of risky changed functions (undefined when it could not score). */
|
|
60
70
|
export declare function buildContext(input: ContextInput): {
|
|
61
71
|
text: string;
|
|
@@ -63,6 +73,7 @@ export declare function buildContext(input: ContextInput): {
|
|
|
63
73
|
risky: number | undefined;
|
|
64
74
|
rules: ServedRule[];
|
|
65
75
|
lessons: number;
|
|
76
|
+
servedLessons: ServedLesson[];
|
|
66
77
|
};
|
|
67
78
|
/**
|
|
68
79
|
* Tracked Markdown docs that name a changed file by path or by a distinctive file stem: one
|
|
@@ -70,6 +70,12 @@ export function dismissedAs(item, dismissals) {
|
|
|
70
70
|
export function reviewerInputs(review) {
|
|
71
71
|
return { hints: review.hints.join('\n'), checks: review.findings.map(f => `${f.files?.[0] ?? '?'}${f.line ? `:${f.line}` : ''} ${f.title}`) };
|
|
72
72
|
}
|
|
73
|
+
/** The ids of the served lessons the judge said this change repeats; an answer that names no served lesson says nothing. */
|
|
74
|
+
export function lessonsApplied(answers, served) {
|
|
75
|
+
const norm = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
76
|
+
const ids = answers.filter(a => a.applies === true && typeof a.lesson === 'string').map(a => served.find(s => norm(s.listed) === norm(a.lesson) || (norm(a.lesson).length >= 20 && norm(s.listed).startsWith(norm(a.lesson))))?.id);
|
|
77
|
+
return [...new Set(ids.filter((id) => !!id))];
|
|
78
|
+
}
|
|
73
79
|
/** The context pack as Markdown, its hash for the fingerprint, and the router's count of risky changed functions (undefined when it could not score). */
|
|
74
80
|
export function buildContext(input) {
|
|
75
81
|
const sections = [];
|
|
@@ -81,9 +87,9 @@ export function buildContext(input) {
|
|
|
81
87
|
task = undefined;
|
|
82
88
|
}
|
|
83
89
|
// A judge reads the whole pull request: more of what the team taught fits than an agent's one question at the stop.
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
86
|
-
sections.push(`## Lessons this team taught on earlier reviews, for what this change touches (context: a lesson never blocks on its own; a finding still needs its quote)\n${
|
|
90
|
+
const servedLessons = input.lessons === 'off' ? [] : lessonsForDiff(input.cwd, input.diff, input.lessons, JUDGE_STANDARDS, JUDGE_FILE_LESSONS, JUDGE_LESSONS_PER_FILE, input.pr).map(l => ({ id: l.id, listed: describeLesson(lessonView(l)) }));
|
|
91
|
+
if (servedLessons.length)
|
|
92
|
+
sections.push(`## Lessons this team taught on earlier reviews, for what this change touches (context: a lesson never blocks on its own; a finding still needs its quote)\n${servedLessons.map(l => `- ${l.listed}`).join('\n')}`);
|
|
87
93
|
// The repository's own rules, always: the reviewer is the boundary, and what the team wrote is the standard it checks.
|
|
88
94
|
const rules = rulesForDiff(input.cwd, input.diff, true, JUDGE_RULES).map((r) => ({ id: r.id, source: r.source, text: r.text, requirement: r.requirement }));
|
|
89
95
|
if (rules.length)
|
|
@@ -103,7 +109,7 @@ export function buildContext(input) {
|
|
|
103
109
|
if (docs.length)
|
|
104
110
|
sections.push(`## Docs that describe the changed code (read one when its claim matters to a finding)\n${docs.map(d => `- ${d.doc} (names ${d.names.join(', ')})`).join('\n')}`);
|
|
105
111
|
const text = sections.length ? `# What this team already knows\n\n${sections.join('\n\n')}\n` : 'none\n';
|
|
106
|
-
return { text, key: createHash('sha256').update(text).digest('hex').slice(0, 16), risky: task ? task.items.length + task.alreadyReviewed : undefined, rules, lessons:
|
|
112
|
+
return { text, key: createHash('sha256').update(text).digest('hex').slice(0, 16), risky: task ? task.items.length + task.alreadyReviewed : undefined, rules, lessons: servedLessons.length, servedLessons };
|
|
107
113
|
}
|
|
108
114
|
function where(x) {
|
|
109
115
|
return x.file ? `${x.file}${x.line ? `:${x.line}` : ''}` : '(no file)';
|
|
@@ -73,6 +73,8 @@ export interface ReviewerResult {
|
|
|
73
73
|
};
|
|
74
74
|
/** The record of this review (record.ts) and where it is kept, beside the verdict. */
|
|
75
75
|
record?: ReviewRecord;
|
|
76
|
+
/** The team's lessons the judge said this change repeats, by id: what the outcome loop reads against a lesson (review-learning/outcome-evidence.ts). */
|
|
77
|
+
lessonsApplied?: string[];
|
|
76
78
|
recordPath?: string;
|
|
77
79
|
/** Tokens every run reported, summed: the only measure of a CLI that reports no dollars (Codex). */
|
|
78
80
|
tokens?: Tokens;
|
package/dist/review/reviewer.js
CHANGED
|
@@ -34,7 +34,7 @@ import { resolveReviewer } from './reviewer/settings.js';
|
|
|
34
34
|
import { VerdictStore } from './reviewer/store.js';
|
|
35
35
|
import { trackUsage } from '../telemetry/telemetry.js';
|
|
36
36
|
import { reviewerUsage } from './reviewer/usage.js';
|
|
37
|
-
import { buildContext, dismissedAs, readReviewDismissals, relatedDocs } from './reviewer/context.js';
|
|
37
|
+
import { buildContext, dismissedAs, lessonsApplied, readReviewDismissals, relatedDocs } from './reviewer/context.js';
|
|
38
38
|
import { buildRecord } from './reviewer/record.js';
|
|
39
39
|
import { account, attachServedRules, changedLinesOf, checkoutSearch, checkoutVerifier, carryResolved, evidenceTouched, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
40
40
|
import { judgeUnset } from './reviewer/judge-env.js';
|
|
@@ -59,6 +59,7 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
|
|
|
59
59
|
kind: 'review', trigger, outcome: result.outcome, blocking: result.items.length, should_fix: result.advisory.length,
|
|
60
60
|
...(result.pr ? { pr: result.pr } : {}),
|
|
61
61
|
...(result.prTitle ? { pr_title: result.prTitle } : {}),
|
|
62
|
+
...(result.lessonsApplied ? { lessons_applied: result.lessonsApplied } : {}),
|
|
62
63
|
...(result.record ? { integrity: result.record.integrity, cost_usd: result.record.judges.reduce((sum, j) => sum + (j.cost_usd ?? 0), 0), judges: result.record.judges.map(j => j.reviewer) } : {}),
|
|
63
64
|
});
|
|
64
65
|
return result;
|
|
@@ -217,7 +218,8 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
217
218
|
const record = (cached && store.readJson(recordPath)) || buildRecord({ head, base: baseSha, scope, verdict, accounted, judges, lessonsServed: context.lessons, humanReviews: reviews.count });
|
|
218
219
|
if (!cached || !fs.existsSync(recordPath))
|
|
219
220
|
store.writeJson(recordPath, record);
|
|
220
|
-
|
|
221
|
+
const applied = lessonsApplied(verdict.lessons ?? [], context.servedLessons);
|
|
222
|
+
return { ...res, record, recordPath, ...(applied.length ? { lessonsApplied: applied } : {}) };
|
|
221
223
|
};
|
|
222
224
|
if (!options.force && fs.existsSync(verdictFile) && fs.existsSync(openFile)) {
|
|
223
225
|
const verdict = store.readJson(verdictFile);
|
|
@@ -46,7 +46,7 @@ export async function learnFromReviews(cwd, options) {
|
|
|
46
46
|
const byNumber = new Map(prs.filter(pr => pr.mergedAt).map(pr => [pr.number, pr]));
|
|
47
47
|
const reverts = new Map([...byNumber.values()].map(pr => [pr.number, revertOf(git, pr, { mainRef: options.mainRef, until: options.until })]));
|
|
48
48
|
for (const lesson of merged.lessons) {
|
|
49
|
-
if (lesson.state !== 'candidate' || lesson.evidence.some(e => e.kind === 'outcome' || e.kind === 'counter'))
|
|
49
|
+
if (lesson.state !== 'candidate' || lesson.evidence.some(e => e.kind === 'outcome' || e.kind === 'lines' || e.kind === 'counter'))
|
|
50
50
|
continue;
|
|
51
51
|
for (const point of lesson.evidence.filter(e => (e.kind ?? 'point') === 'point')) {
|
|
52
52
|
const pr = byNumber.get(point.pr);
|
|
@@ -54,7 +54,8 @@ export async function learnFromReviews(cwd, options) {
|
|
|
54
54
|
continue;
|
|
55
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
56
|
if (found) {
|
|
57
|
-
|
|
57
|
+
// A fix on the point's lines, or a revert, is evidence for a person to promote in Studio, never a promotion (lessonState).
|
|
58
|
+
lesson.evidence.push(found.kind === 'outcome' ? { ...found, kind: 'lines' } : found);
|
|
58
59
|
break;
|
|
59
60
|
}
|
|
60
61
|
}
|
|
@@ -6,10 +6,19 @@ import type { Git, ReviewBody, ReviewComment } from './acted-on.js';
|
|
|
6
6
|
* counter the lines shipped unchanged and nothing needed fixing within the window;
|
|
7
7
|
* correction a person changed what an agent wrote (human-edits.ts): heavily weighted, it makes a lesson;
|
|
8
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
|
|
9
|
+
* norule the rule writer found no rule in it (a report, a template, a one-off): never promoted again;
|
|
10
|
+
* followup a later fix touched the point's file, with nothing else to show it was this point (outcome-evidence.ts):
|
|
11
|
+
* recorded, never enough on its own;
|
|
12
|
+
* lines a later fix changed the point's own lines (outcome-evidence.ts): shown for a person to promote or dismiss,
|
|
13
|
+
* never a promotion on its own (a fix on the same lines is often unrelated work);
|
|
14
|
+
* dismissed a person looked at that evidence and set it aside: the lesson stays as it was;
|
|
15
|
+
* reclassified a lesson an outcome alone had promoted, back to a candidate when outcomes stopped promoting (once);
|
|
16
|
+
* against a later pull request a review found repeating the lesson merged anyway and settled clean;
|
|
17
|
+
* demoted enough independent `against` pull requests took back a lesson evidence had promoted: a candidate again,
|
|
18
|
+
* until a person promotes it.
|
|
10
19
|
* A record from before evidence kinds has none: it is a `point`.
|
|
11
20
|
*/
|
|
12
|
-
export type EvidenceKind = 'point' | 'outcome' | 'counter' | 'correction' | 'accepted' | 'rejected' | 'norule';
|
|
21
|
+
export type EvidenceKind = 'point' | 'outcome' | 'counter' | 'correction' | 'accepted' | 'rejected' | 'norule' | 'followup' | 'lines' | 'dismissed' | 'against' | 'demoted' | 'reclassified';
|
|
13
22
|
export interface LessonEvidence {
|
|
14
23
|
kind?: EvidenceKind;
|
|
15
24
|
pr: number;
|
|
@@ -51,9 +60,9 @@ export interface ReviewLesson {
|
|
|
51
60
|
updatedAt: string;
|
|
52
61
|
}
|
|
53
62
|
/**
|
|
54
|
-
* A lesson's state from its evidence trail. A person's decision wins; then
|
|
55
|
-
* holds a candidate back; recurrence across pull requests and authors is enough only without it.
|
|
56
|
-
* from before evidence kinds keeps the state it had.
|
|
63
|
+
* A lesson's state from its evidence trail. A person's decision wins; then a person's correction; counter-evidence
|
|
64
|
+
* holds a candidate back; recurrence across pull requests and authors is enough only without it. An outcome is
|
|
65
|
+
* evidence, never a promotion. A record from before evidence kinds keeps the state it had.
|
|
57
66
|
*/
|
|
58
67
|
export declare function lessonState(lesson: ReviewLesson): Pick<ReviewLesson, 'state' | 'promotedBy'>;
|
|
59
68
|
/** The point of a review comment: its bold title, else its first sentence, without tool output or markup. */
|
|
@@ -102,7 +111,7 @@ export declare function lessonsPath(cwd: string): string;
|
|
|
102
111
|
export declare function readLessons(cwd: string): ReviewLesson[];
|
|
103
112
|
export declare function writeLessons(cwd: string, lessons: ReviewLesson[]): void;
|
|
104
113
|
/** A person's decision on a lesson, kept as evidence: accepted makes it a lesson, rejected an anti-lesson. Undefined for an unknown id. */
|
|
105
|
-
export declare function decideLesson(cwd: string, id: string, decision: 'accepted' | 'rejected', by: string, why?: string): ReviewLesson | undefined;
|
|
114
|
+
export declare function decideLesson(cwd: string, id: string, decision: 'accepted' | 'rejected' | 'dismissed', by: string, why?: string): ReviewLesson | undefined;
|
|
106
115
|
/** An identifier specific enough to link two pieces of code: camelCase, snake_case, or long. */
|
|
107
116
|
export declare function isSpecific(symbol: string): boolean;
|
|
108
117
|
/** The meaningful words of a text or an identifier: `hasLaterAttempt` and "a later attempt" share later and attempt. */
|
|
@@ -35,9 +35,9 @@ const PLAIN_WORDS = new Set(['when', 'that', 'this', 'with', 'from', 'before', '
|
|
|
35
35
|
const NOT_CODE = /(\.(md|mdx|txt|ya?ml|json|lock|snap)$)|(\.(test|spec)\.[cm]?[jt]sx?$)|(^|\/)(__tests__|docs?|migrations|\.github)\//i;
|
|
36
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
37
|
/**
|
|
38
|
-
* A lesson's state from its evidence trail. A person's decision wins; then
|
|
39
|
-
* holds a candidate back; recurrence across pull requests and authors is enough only without it.
|
|
40
|
-
* from before evidence kinds keeps the state it had.
|
|
38
|
+
* A lesson's state from its evidence trail. A person's decision wins; then a person's correction; counter-evidence
|
|
39
|
+
* holds a candidate back; recurrence across pull requests and authors is enough only without it. An outcome is
|
|
40
|
+
* evidence, never a promotion. A record from before evidence kinds keeps the state it had.
|
|
41
41
|
*/
|
|
42
42
|
export function lessonState(lesson) {
|
|
43
43
|
const kinds = new Set(lesson.evidence.map(e => e.kind));
|
|
@@ -51,10 +51,13 @@ export function lessonState(lesson) {
|
|
|
51
51
|
return { state: 'verified', promotedBy: 'person' };
|
|
52
52
|
if (kinds.has('norule'))
|
|
53
53
|
return { state: 'candidate' };
|
|
54
|
+
// Taken back by evidence (outcome-evidence.ts): a person's decision above is the only way back.
|
|
55
|
+
if (kinds.has('demoted'))
|
|
56
|
+
return { state: 'candidate' };
|
|
54
57
|
if (kinds.has('correction'))
|
|
55
58
|
return { state: 'verified', promotedBy: 'correction' };
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
// An outcome (a later fix on the point's lines, a revert) no longer promotes on its own: judged by a person against
|
|
60
|
+
// real history, it was most often unrelated work. It is shown in Studio for a person to promote.
|
|
58
61
|
if (kinds.has('counter'))
|
|
59
62
|
return { state: 'candidate' };
|
|
60
63
|
const points = lesson.evidence.filter(e => (e.kind ?? 'point') === 'point');
|
|
@@ -224,12 +227,29 @@ export function lessonsPath(cwd) {
|
|
|
224
227
|
export function readLessons(cwd) {
|
|
225
228
|
try {
|
|
226
229
|
const parsed = JSON.parse(fs.readFileSync(lessonsPath(cwd), 'utf8'));
|
|
227
|
-
return Array.isArray(parsed?.lessons) ? parsed.lessons : [];
|
|
230
|
+
return Array.isArray(parsed?.lessons) ? parsed.lessons.map(reclassified) : [];
|
|
228
231
|
}
|
|
229
232
|
catch {
|
|
230
233
|
return [];
|
|
231
234
|
}
|
|
232
235
|
}
|
|
236
|
+
/** Why a lesson an outcome alone had promoted is a candidate again. */
|
|
237
|
+
const RECLASSIFIED = 'promoted by the exact-line rule, which no longer promotes on its own';
|
|
238
|
+
/**
|
|
239
|
+
* A lesson stored as verified by an outcome (a later fix on its lines, or a revert), as every reader sees it now that
|
|
240
|
+
* outcomes no longer promote: its state worked out again from its evidence, and, when that leaves it a candidate, one
|
|
241
|
+
* `reclassified` record saying why. Never deleted, nothing else changed; the next write keeps it, and a lesson that
|
|
242
|
+
* already has the record is left as it is. Recurrence, a correction or a person's decision keeps a lesson verified.
|
|
243
|
+
*/
|
|
244
|
+
function reclassified(lesson) {
|
|
245
|
+
if (lesson.state !== 'verified' || lesson.promotedBy !== 'outcome' || lesson.evidence.some(e => e.kind === 'reclassified'))
|
|
246
|
+
return lesson;
|
|
247
|
+
const next = lessonState(lesson);
|
|
248
|
+
if (next.state === 'verified')
|
|
249
|
+
return { ...lesson, ...next };
|
|
250
|
+
const at = new Date().toISOString();
|
|
251
|
+
return { ...lesson, state: 'candidate', promotedBy: undefined, evidence: [...lesson.evidence, { kind: 'reclassified', pr: lesson.evidence[0]?.pr ?? 0, comment: `reclassified-${lesson.id}`, author: '', detail: RECLASSIFIED, at }] };
|
|
252
|
+
}
|
|
233
253
|
export function writeLessons(cwd, lessons) {
|
|
234
254
|
const file = lessonsPath(cwd);
|
|
235
255
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the outcome records (outcomes/outcome.ts) say about the team's review lessons. Deterministic, and a person's
|
|
3
|
+
* decision always wins (lessonState).
|
|
4
|
+
*
|
|
5
|
+
* For a lesson: a point its pull request did not act on, whose own lines (within three either side, followed through
|
|
6
|
+
* every later commit as code moves: outcomes.ts outcomeFor) a later commit inside the record's window changed, where
|
|
7
|
+
* that commit says it fixed something and touches at most fifteen files: `lines` evidence, with CI regressing on the
|
|
8
|
+
* merge commit or a revert recorded as context. It never promotes on its own: judged by a person on real history, a fix
|
|
9
|
+
* on the same lines was most often unrelated work. Studio shows it on the candidate for a person to promote or dismiss.
|
|
10
|
+
* A fix that touched only the point's file is `followup` evidence.
|
|
11
|
+
*
|
|
12
|
+
* Against a lesson: only when a review of a later pull request recorded the lesson as APPLYING (the change does what the
|
|
13
|
+
* lesson warns against; the reviewer's lessons step, review event `lessons_applied`) and the pull request merged anyway
|
|
14
|
+
* and settled clean: CI passed on the merge commit, no fix touched the lesson's file in the window, and no revert. A
|
|
15
|
+
* pull request that followed the lesson, or that no review checked against it, never counts. `demote_after` such pull
|
|
16
|
+
* requests, independent (more than one author, or merged at least a week apart), take back a lesson recurrence
|
|
17
|
+
* promoted: it is `demoted` to a candidate. A lesson a person promoted, or corrected into being,
|
|
18
|
+
* is not.
|
|
19
|
+
*/
|
|
20
|
+
import type { PrOutcome } from '../outcomes/outcome.js';
|
|
21
|
+
import type { Git } from './acted-on.js';
|
|
22
|
+
import { type ReviewLesson } from './lessons.js';
|
|
23
|
+
export interface OutcomeEvidenceOptions {
|
|
24
|
+
demoteAfter: number;
|
|
25
|
+
/** git in the repository, and its main branch: without them, no lines can be followed. */
|
|
26
|
+
git?: Git;
|
|
27
|
+
mainRef?: string;
|
|
28
|
+
/** Stop following lines at this time (ms since epoch); what was found is kept. */
|
|
29
|
+
deadline?: number;
|
|
30
|
+
}
|
|
31
|
+
/** `suggested`: lessons that got new `lines` evidence, for a person to promote or dismiss; `demoted`: lessons taken back. */
|
|
32
|
+
export interface OutcomeEvidenceResult {
|
|
33
|
+
added: number;
|
|
34
|
+
suggested: string[];
|
|
35
|
+
demoted: string[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Adds the evidence the settled and unsettled records give, to `lessons` in place, once each (by its comment key), and
|
|
39
|
+
* recomputes their states. `applied` is, per pull request, the lessons a review of it recorded as applying.
|
|
40
|
+
*/
|
|
41
|
+
export declare function applyOutcomeEvidence(lessons: ReviewLesson[], records: PrOutcome[], applied: Map<number, Set<string>>, options: OutcomeEvidenceOptions): OutcomeEvidenceResult;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { lessonState } from './lessons.js';
|
|
2
|
+
import { outcomeFor } from './outcomes.js';
|
|
3
|
+
/** Lines either side of a point's own that count as its lines, and the most files a fixing commit may touch to say anything about one point. */
|
|
4
|
+
const POINT_SLACK = 3;
|
|
5
|
+
const MAX_FIX_FILES = 15;
|
|
6
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
7
|
+
/**
|
|
8
|
+
* Adds the evidence the settled and unsettled records give, to `lessons` in place, once each (by its comment key), and
|
|
9
|
+
* recomputes their states. `applied` is, per pull request, the lessons a review of it recorded as applying.
|
|
10
|
+
*/
|
|
11
|
+
export function applyOutcomeEvidence(lessons, records, applied, options) {
|
|
12
|
+
const byPr = new Map(records.map(r => [r.pr, r]));
|
|
13
|
+
const result = { added: 0, suggested: [], demoted: [] };
|
|
14
|
+
for (const lesson of lessons) {
|
|
15
|
+
const before = lesson.state;
|
|
16
|
+
const add = (evidence) => {
|
|
17
|
+
if (lesson.evidence.some(e => e.comment === evidence.comment))
|
|
18
|
+
return false;
|
|
19
|
+
lesson.evidence.push(evidence);
|
|
20
|
+
result.added++;
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
const own = new Set(lesson.evidence.filter(e => (e.kind ?? 'point') === 'point').map(e => e.pr));
|
|
24
|
+
// For it: a point its pull request left alone, whose own lines a later fix inside the window changed.
|
|
25
|
+
for (const point of lesson.evidence.filter(e => (e.kind ?? 'point') === 'point' && e.actedOn === false)) {
|
|
26
|
+
const record = byPr.get(point.pr);
|
|
27
|
+
// Only where the record shows a fix on the file at all: following lines costs a git walk, done only then.
|
|
28
|
+
const fileFix = lesson.file ? record?.followUps.find(f => f.fix && f.files.includes(lesson.file)) : undefined;
|
|
29
|
+
if (!record || !fileFix)
|
|
30
|
+
continue;
|
|
31
|
+
const context = [record.ci === 'failure' ? 'CI regressed on the merge commit' : '', record.reverted ? `the pull request was reverted by ${record.reverted.sha.slice(0, 9)}` : ''].filter(Boolean).join('; ');
|
|
32
|
+
const inTime = options.deadline === undefined || Date.now() <= options.deadline;
|
|
33
|
+
const lines = options.git && options.mainRef && lesson.at && inTime
|
|
34
|
+
? outcomeFor(options.git, lesson, { number: record.pr, mergeSha: record.mergeSha, mergedAt: record.mergedAt }, { mainRef: options.mainRef, until: record.windowEnd, slack: POINT_SLACK, maxFiles: MAX_FIX_FILES })
|
|
35
|
+
: undefined;
|
|
36
|
+
// Evidence for a person, never a promotion: a fix on the same lines is often unrelated work.
|
|
37
|
+
if (lines?.kind === 'outcome') {
|
|
38
|
+
const evidence = { ...lines, kind: 'lines', comment: `lines-${record.pr}-${lines.comment.replace(/^outcome-/, '')}`, detail: `${lines.detail}${context ? `; ${context}` : ''}` };
|
|
39
|
+
if (add(evidence) && !result.suggested.includes(lesson.id))
|
|
40
|
+
result.suggested.push(lesson.id);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
add({ kind: 'followup', pr: record.pr, comment: `followup-${record.pr}-${fileFix.sha.slice(0, 12)}`, author: '', detail: `${lesson.file} fixed later by ${fileFix.sha.slice(0, 9)} "${fileFix.subject}", not on the point's lines${context ? `; ${context}` : ''}`, at: fileFix.at });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Against it: later pull requests a review found repeating it, merged anyway, settled clean.
|
|
47
|
+
if (lesson.state === 'verified' && lesson.promotedBy === 'recurrence') {
|
|
48
|
+
for (const [pr, ids] of applied) {
|
|
49
|
+
const record = byPr.get(pr);
|
|
50
|
+
if (own.has(pr) || !ids.has(lesson.id) || !record || !settledClean(record, lesson.file))
|
|
51
|
+
continue;
|
|
52
|
+
add({ kind: 'against', pr, comment: `against-${pr}`, author: '', prAuthor: record.author, detail: `a review found #${pr} repeating it; merged ${record.mergedAt.slice(0, 10)}, CI passed, no fix on ${lesson.file || 'its files'} within the window, no revert`, at: record.mergedAt });
|
|
53
|
+
}
|
|
54
|
+
const against = lesson.evidence.filter(e => e.kind === 'against');
|
|
55
|
+
const authors = new Set(against.map(e => e.prAuthor).filter(Boolean)).size;
|
|
56
|
+
// Independent: more than one author, or merged at least a week apart (a span, never calendar buckets: two merges a day apart across a week boundary are not).
|
|
57
|
+
const times = against.map(e => Date.parse(e.at ?? '')).filter(Number.isFinite);
|
|
58
|
+
const apart = times.length > 1 && Math.max(...times) - Math.min(...times) >= WEEK_MS;
|
|
59
|
+
if (new Set(against.map(e => e.pr)).size >= options.demoteAfter && (authors > 1 || apart)) {
|
|
60
|
+
add({ kind: 'demoted', pr: against.at(-1).pr, comment: `demoted-${against.map(e => e.pr).sort((a, b) => a - b).join('-')}`, author: '', detail: `taken back: ${against.map(e => `#${e.pr}`).join(', ')} repeated it and settled clean`, at: new Date().toISOString() });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
Object.assign(lesson, lessonState(lesson));
|
|
64
|
+
if (before === 'verified' && lesson.state === 'candidate')
|
|
65
|
+
result.demoted.push(lesson.id);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
/** CI passed on the merge commit, no fix touched the file (any file, for a team standard) within the window, no revert. Settled only. */
|
|
70
|
+
function settledClean(record, file) {
|
|
71
|
+
return record.settled && record.ci === 'success' && !record.reverted && !record.followUps.some(f => f.fix && (!file || f.files.includes(file)));
|
|
72
|
+
}
|
|
@@ -16,6 +16,10 @@ export interface OutcomeOptions {
|
|
|
16
16
|
until?: string;
|
|
17
17
|
/** How long unchanged lines must ship before that counts against the point. */
|
|
18
18
|
windowDays?: number;
|
|
19
|
+
/** Lines either side of the point's own that count as its lines (outcome-evidence.ts: 3). */
|
|
20
|
+
slack?: number;
|
|
21
|
+
/** A fixing commit that touches more files than this is a sweep, and says nothing about one point: no verdict. */
|
|
22
|
+
maxFiles?: number;
|
|
19
23
|
}
|
|
20
24
|
export interface MergedAt {
|
|
21
25
|
number: number;
|
|
@@ -7,7 +7,8 @@ export function outcomeFor(git, lesson, pr, options) {
|
|
|
7
7
|
const point = lesson.evidence.find(e => (e.kind ?? 'point') === 'point' && e.pr === pr.number);
|
|
8
8
|
if (!point || point.actedOn || !lesson.at || !lesson.file || !pr.mergedAt)
|
|
9
9
|
return undefined;
|
|
10
|
-
|
|
10
|
+
const slack = options.slack ?? 0;
|
|
11
|
+
let range = shift(changedHunks(git, lesson.at.commit, pr.mergeSha, lesson.file), [Math.max(1, lesson.at.start - slack), lesson.at.end + slack]);
|
|
11
12
|
if (!range)
|
|
12
13
|
return undefined; // the pull request changed them after all
|
|
13
14
|
const before = options.until ? [`--before=${options.until}`] : [];
|
|
@@ -26,8 +27,9 @@ export function outcomeFor(git, lesson, pr, options) {
|
|
|
26
27
|
continue;
|
|
27
28
|
}
|
|
28
29
|
const [subject, date] = git(['log', '-1', '--format=%s%x09%cI', sha]).trim().split('\t');
|
|
29
|
-
// Changed by a commit that says it fixed something: the point was right. Changed otherwise: no verdict.
|
|
30
|
-
|
|
30
|
+
// Changed by a commit that says it fixed something, and is not a sweep across the codebase: the point was right. Changed otherwise: no verdict.
|
|
31
|
+
const sweep = options.maxFiles !== undefined && git(['show', '--name-only', '--format=', sha]).split('\n').filter(Boolean).length > options.maxFiles;
|
|
32
|
+
return FIX.test(subject) && !sweep ? { kind: 'outcome', pr: pr.number, comment: `outcome-${sha.slice(0, 12)}`, author: '', detail: `fixed later by ${sha.slice(0, 9)} "${subject}"`, at: date } : undefined;
|
|
31
33
|
}
|
|
32
34
|
const now = options.until ? Date.parse(options.until) : Date.now();
|
|
33
35
|
const days = Math.floor((now - Date.parse(pr.mergedAt)) / DAY_MS);
|
package/dist/switches.d.ts
CHANGED
|
@@ -275,6 +275,7 @@ export declare const SWITCHES: {
|
|
|
275
275
|
outcomes: {
|
|
276
276
|
mode: "off" | "on" | "required";
|
|
277
277
|
window_days: number;
|
|
278
|
+
demote_after: number;
|
|
278
279
|
};
|
|
279
280
|
};
|
|
280
281
|
review: {
|
|
@@ -582,6 +583,7 @@ export declare const SWITCHES: {
|
|
|
582
583
|
outcomes: {
|
|
583
584
|
mode: "off" | "on" | "required";
|
|
584
585
|
window_days: number;
|
|
586
|
+
demote_after: number;
|
|
585
587
|
};
|
|
586
588
|
};
|
|
587
589
|
review: {
|
package/dist/task/thread.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export declare function taskOf(cwd: string): {
|
|
|
29
29
|
} | undefined;
|
|
30
30
|
/** Appends one event to the checkout's task thread. Best effort: a thread never breaks the hook or command it runs in. */
|
|
31
31
|
export declare function appendTaskEvent(cwd: string, event: TaskEvent): ThreadEvent | undefined;
|
|
32
|
+
/** Every thread's events of one kind, oldest first: what the outcome loop reads across tasks (the reviews that found a lesson repeated). */
|
|
33
|
+
export declare function eventsOfKind(cwd: string, kind: TaskEventKind): ThreadEvent[];
|
|
32
34
|
/**
|
|
33
35
|
* Appends one event to a named branch's thread, under the task its own events carry: what happened to a pull request
|
|
34
36
|
* after it merged, recorded from wherever the outcome was read. Only a thread that already exists is written: a branch
|
package/dist/task/thread.js
CHANGED
|
@@ -99,6 +99,13 @@ export function appendTaskEvent(cwd, event) {
|
|
|
99
99
|
return undefined;
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
+
/** Every thread's events of one kind, oldest first: what the outcome loop reads across tasks (the reviews that found a lesson repeated). */
|
|
103
|
+
export function eventsOfKind(cwd, kind) {
|
|
104
|
+
const dir = threadsDir(cwd);
|
|
105
|
+
if (!dir)
|
|
106
|
+
return [];
|
|
107
|
+
return listThreads(dir).flatMap(file => readEvents(path.join(dir, file))).filter(e => e.kind === kind).sort(byTime);
|
|
108
|
+
}
|
|
102
109
|
/**
|
|
103
110
|
* Appends one event to a named branch's thread, under the task its own events carry: what happened to a pull request
|
|
104
111
|
* after it merged, recorded from wherever the outcome was read. Only a thread that already exists is written: a branch
|
package/dist/types/index.d.ts
CHANGED
|
@@ -2420,22 +2420,28 @@ export declare const ConfigSchema: z.ZodObject<{
|
|
|
2420
2420
|
mode: z.ZodDefault<z.ZodOptional<z.ZodEnum<["off", "on", "required"]>>>;
|
|
2421
2421
|
/** How long after a merge later commits count, in days. */
|
|
2422
2422
|
window_days: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
2423
|
+
/** How many later pull requests, independent and settled clean after a review found them repeating a lesson, demote it (review-learning/outcome-evidence.ts). */
|
|
2424
|
+
demote_after: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
2423
2425
|
}, "strip", z.ZodTypeAny, {
|
|
2424
2426
|
mode: "off" | "on" | "required";
|
|
2425
2427
|
window_days: number;
|
|
2428
|
+
demote_after: number;
|
|
2426
2429
|
}, {
|
|
2427
2430
|
mode?: "off" | "on" | "required" | undefined;
|
|
2428
2431
|
window_days?: number | undefined;
|
|
2432
|
+
demote_after?: number | undefined;
|
|
2429
2433
|
}>>>;
|
|
2430
2434
|
}, "strip", z.ZodTypeAny, {
|
|
2431
2435
|
outcomes: {
|
|
2432
2436
|
mode: "off" | "on" | "required";
|
|
2433
2437
|
window_days: number;
|
|
2438
|
+
demote_after: number;
|
|
2434
2439
|
};
|
|
2435
2440
|
}, {
|
|
2436
2441
|
outcomes?: {
|
|
2437
2442
|
mode?: "off" | "on" | "required" | undefined;
|
|
2438
2443
|
window_days?: number | undefined;
|
|
2444
|
+
demote_after?: number | undefined;
|
|
2439
2445
|
} | undefined;
|
|
2440
2446
|
}>>>;
|
|
2441
2447
|
/** rigour review / rigour_review / the PR bot / the stop hook. */
|
|
@@ -2928,6 +2934,7 @@ export declare const ConfigSchema: z.ZodObject<{
|
|
|
2928
2934
|
outcomes: {
|
|
2929
2935
|
mode: "off" | "on" | "required";
|
|
2930
2936
|
window_days: number;
|
|
2937
|
+
demote_after: number;
|
|
2931
2938
|
};
|
|
2932
2939
|
};
|
|
2933
2940
|
review: {
|
|
@@ -3231,6 +3238,7 @@ export declare const ConfigSchema: z.ZodObject<{
|
|
|
3231
3238
|
outcomes?: {
|
|
3232
3239
|
mode?: "off" | "on" | "required" | undefined;
|
|
3233
3240
|
window_days?: number | undefined;
|
|
3241
|
+
demote_after?: number | undefined;
|
|
3234
3242
|
} | undefined;
|
|
3235
3243
|
} | undefined;
|
|
3236
3244
|
review?: {
|
package/dist/types/index.js
CHANGED
|
@@ -380,6 +380,8 @@ export const ConfigSchema = z.object({
|
|
|
380
380
|
mode: z.enum(['off', 'on', 'required']).optional().default('off'),
|
|
381
381
|
/** How long after a merge later commits count, in days. */
|
|
382
382
|
window_days: z.number().int().min(7).max(90).optional().default(30),
|
|
383
|
+
/** How many later pull requests, independent and settled clean after a review found them repeating a lesson, demote it (review-learning/outcome-evidence.ts). */
|
|
384
|
+
demote_after: z.number().int().min(2).optional().default(2),
|
|
383
385
|
}).optional().default({}),
|
|
384
386
|
}).optional().default({}),
|
|
385
387
|
/** rigour review / rigour_review / the PR bot / the stop hook. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/core",
|
|
3
|
-
"version": "6.9.0-rc.
|
|
3
|
+
"version": "6.9.0-rc.3",
|
|
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.9.0-rc.
|
|
79
|
-
"@rigour-labs/brain-darwin-x64": "6.9.0-rc.
|
|
80
|
-
"@rigour-labs/brain-linux-
|
|
81
|
-
"@rigour-labs/brain-
|
|
82
|
-
"@rigour-labs/brain-
|
|
78
|
+
"@rigour-labs/brain-darwin-arm64": "6.9.0-rc.3",
|
|
79
|
+
"@rigour-labs/brain-darwin-x64": "6.9.0-rc.3",
|
|
80
|
+
"@rigour-labs/brain-linux-arm64": "6.9.0-rc.3",
|
|
81
|
+
"@rigour-labs/brain-win-x64": "6.9.0-rc.3",
|
|
82
|
+
"@rigour-labs/brain-linux-x64": "6.9.0-rc.3"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/fs-extra": "^11.0.4",
|