@rigour-labs/core 6.6.5 → 6.7.0
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 +16 -92
- package/dist/environment.test.js +0 -4
- package/dist/gates/agent-team.d.ts +0 -2
- package/dist/gates/agent-team.js +0 -8
- package/dist/gates/checkpoint.d.ts +0 -1
- package/dist/gates/checkpoint.js +0 -1
- package/dist/gates/context.d.ts +0 -4
- package/dist/gates/context.js +7 -17
- package/dist/gates/deep-analysis.d.ts +0 -1
- package/dist/gates/deep-runner.js +0 -1
- package/dist/gates/frontend-secret-exposure.d.ts +1 -1
- package/dist/gates/frontend-secret-exposure.js +9 -3
- package/dist/gates/frontend-secret-exposure.test.js +6 -3
- package/dist/gates/hallucinated-imports/index.d.ts +0 -2
- package/dist/gates/hallucinated-imports/index.js +2 -2
- package/dist/gates/hallucinated-imports/js-resolver.js +1 -0
- package/dist/gates/hallucinated-imports.test.js +10 -0
- package/dist/gates/runner.js +3 -4
- package/dist/gates/test-quality-matchers.d.ts +0 -4
- package/dist/gates/test-quality-matchers.js +0 -7
- package/dist/gates/test-quality.d.ts +0 -2
- package/dist/gates/test-quality.js +1 -10
- package/dist/hooks/checker.js +1 -1
- package/dist/hooks/index.d.ts +2 -3
- package/dist/hooks/index.js +1 -2
- package/dist/hooks/stop-review.d.ts +6 -9
- package/dist/hooks/stop-review.js +5 -10
- package/dist/hooks/types.d.ts +0 -14
- package/dist/hooks/types.js +1 -15
- package/dist/index.d.ts +11 -3
- package/dist/index.js +11 -3
- package/dist/inference/cloud-provider.d.ts +2 -0
- package/dist/inference/cloud-provider.js +16 -6
- package/dist/inference/cloud-provider.test.js +21 -0
- package/dist/pattern-index/embeddings.d.ts +0 -5
- package/dist/pattern-index/embeddings.js +11 -4
- package/dist/pattern-index/semantic-runtime.d.ts +10 -0
- package/dist/pattern-index/semantic-runtime.js +46 -0
- package/dist/pattern-index/semantic-runtime.test.d.ts +1 -0
- package/dist/pattern-index/semantic-runtime.test.js +31 -0
- package/dist/review/backtest-init.d.ts +8 -0
- package/dist/review/backtest-init.js +87 -0
- package/dist/review/backtest-init.test.d.ts +1 -0
- package/dist/review/backtest-init.test.js +74 -0
- package/dist/review/backtest-judges.d.ts +38 -0
- package/dist/review/backtest-judges.js +63 -0
- package/dist/review/backtest-judges.test.d.ts +1 -0
- package/dist/review/backtest-judges.test.js +32 -0
- package/dist/review/backtest.d.ts +320 -0
- package/dist/review/backtest.js +207 -0
- package/dist/review/backtest.test.d.ts +1 -0
- package/dist/review/backtest.test.js +129 -0
- package/dist/review/baseline.js +2 -1
- package/dist/review/dead-code.test.js +15 -0
- package/dist/review/fix-scope.d.ts +21 -0
- package/dist/review/fix-scope.js +94 -0
- package/dist/review/fix-scope.test.d.ts +1 -0
- package/dist/review/fix-scope.test.js +104 -0
- package/dist/review/lint-overlay.d.ts +21 -0
- package/dist/review/lint-overlay.js +89 -0
- package/dist/review/orphan-files.js +3 -2
- package/dist/review/partial-wiring.js +5 -3
- package/dist/review/query-patterns.js +3 -2
- package/dist/review/quiet.d.ts +6 -0
- package/dist/review/quiet.js +12 -2
- package/dist/review/quiet.test.js +11 -0
- package/dist/review/review.d.ts +6 -0
- package/dist/review/review.js +10 -2
- package/dist/review/reviewer/adapters.d.ts +51 -0
- package/dist/review/reviewer/adapters.js +181 -0
- package/dist/review/reviewer/adapters.test.d.ts +1 -0
- package/dist/review/reviewer/adapters.test.js +23 -0
- package/dist/review/reviewer/background.d.ts +41 -0
- package/dist/review/reviewer/background.js +153 -0
- package/dist/review/reviewer/background.test.d.ts +1 -0
- package/dist/review/reviewer/background.test.js +118 -0
- package/dist/review/reviewer/consensus.d.ts +36 -0
- package/dist/review/reviewer/consensus.js +144 -0
- package/dist/review/reviewer/context.d.ts +75 -0
- package/dist/review/reviewer/context.js +140 -0
- package/dist/review/reviewer/context.test.d.ts +1 -0
- package/dist/review/reviewer/context.test.js +18 -0
- package/dist/review/reviewer/exec.d.ts +17 -0
- package/dist/review/reviewer/exec.js +27 -0
- package/dist/review/reviewer/exec.test.d.ts +1 -0
- package/dist/review/reviewer/exec.test.js +23 -0
- package/dist/review/reviewer/inputs.d.ts +39 -0
- package/dist/review/reviewer/inputs.js +107 -0
- package/dist/review/reviewer/merge-impact.d.ts +2 -0
- package/dist/review/reviewer/merge-impact.js +64 -0
- package/dist/review/reviewer/panel.d.ts +67 -0
- package/dist/review/reviewer/panel.js +101 -0
- package/dist/review/reviewer/panel.test.d.ts +1 -0
- package/dist/review/reviewer/panel.test.js +110 -0
- package/dist/review/reviewer/prompt.d.ts +32 -0
- package/dist/review/reviewer/prompt.js +145 -0
- package/dist/review/reviewer/settings.d.ts +58 -0
- package/dist/review/reviewer/settings.js +152 -0
- package/dist/review/reviewer/settings.test.d.ts +1 -0
- package/dist/review/reviewer/settings.test.js +57 -0
- package/dist/review/reviewer/store.d.ts +49 -0
- package/dist/review/reviewer/store.js +99 -0
- package/dist/review/reviewer/usage.d.ts +2 -0
- package/dist/review/reviewer/usage.js +28 -0
- package/dist/review/reviewer/usage.test.d.ts +1 -0
- package/dist/review/reviewer/usage.test.js +14 -0
- package/dist/review/reviewer/verdict.d.ts +141 -0
- package/dist/review/reviewer/verdict.js +166 -0
- package/dist/review/reviewer.d.ts +84 -49
- package/dist/review/reviewer.js +364 -195
- package/dist/review/reviewer.test.js +316 -50
- package/dist/review/test-files.d.ts +5 -1
- package/dist/review/test-files.js +6 -2
- package/dist/review/toolchain.d.ts +5 -2
- package/dist/review/toolchain.js +89 -7
- package/dist/review/toolchain.test.js +45 -4
- package/dist/review/typed/program.d.ts +26 -0
- package/dist/review/typed/program.js +90 -0
- package/dist/review/typed/redundancy.d.ts +10 -0
- package/dist/review/typed/redundancy.js +378 -0
- package/dist/review/typed/redundancy.test.d.ts +1 -0
- package/dist/review/typed/redundancy.test.js +179 -0
- package/dist/review/typed/schema-nullability.d.ts +6 -0
- package/dist/review/typed/schema-nullability.js +218 -0
- package/dist/review/typed/schema-nullability.test.d.ts +1 -0
- package/dist/review/typed/schema-nullability.test.js +63 -0
- package/dist/review/unused-exports.js +10 -7
- package/dist/semantic/learn/types.d.ts +2 -2
- package/dist/services/adaptive-thresholds.d.ts +2 -52
- package/dist/services/adaptive-thresholds.js +2 -147
- package/dist/services/adaptive-thresholds.test.js +1 -81
- package/dist/settings.d.ts +14 -6
- package/dist/settings.js +0 -12
- package/dist/storage/team-diagnostics.js +1 -1
- package/dist/storage/team-schema.d.ts +1 -1
- package/dist/storage/team-schema.js +2 -0
- package/dist/storage/team-store.js +26 -3
- package/dist/storage/team-sync-scope.test.js +35 -2
- package/dist/telemetry/telemetry.d.ts +5 -0
- package/dist/telemetry/telemetry.js +11 -1
- package/dist/templates/paradigms.js +0 -8
- package/dist/templates/presets.d.ts +0 -1
- package/dist/templates/presets.js +0 -23
- package/dist/templates/universal-config.js +16 -46
- package/dist/types/index.d.ts +202 -574
- package/dist/types/index.js +64 -67
- package/dist/types/index.test.d.ts +1 -0
- package/dist/types/index.test.js +33 -0
- package/dist/utils/command-line.d.ts +8 -0
- package/dist/utils/command-line.js +8 -0
- package/dist/utils/command-line.test.d.ts +1 -0
- package/dist/utils/command-line.test.js +8 -0
- package/dist/utils/profile.js +1 -1
- package/package.json +6 -7
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatJudges, judgedFrom } from './backtest-judges.js';
|
|
3
|
+
const base = { outcome: 'findings', items: [], unverified: [], resolved: [], answerInReply: [], notes: [], disputed: [], dropped: [], dismissed: [], reviewers: ['claude', 'codex'], cached: false };
|
|
4
|
+
describe('measuring each judge', () => {
|
|
5
|
+
it('computes Cohen\'s kappa between two judges: 1 in full agreement, 0 at chance, and says when they share blind spots', () => {
|
|
6
|
+
const round = (claude, codex) => ({ judges: { judges: ['claude', 'codex'], caught: { claude, codex }, points: ['p1', 'p2', 'p3', 'p4'], runs: 2 }, points: [] });
|
|
7
|
+
expect(formatJudges([round(['p1', 'p3'], ['p1', 'p3'])])).toContain('kappa 1.00 over 4 point(s) (they share blind spots: a second judge adds little)');
|
|
8
|
+
expect(formatJudges([round(['p1', 'p2'], ['p1', 'p3'])])).toContain('kappa 0.00 over 4 point(s)');
|
|
9
|
+
expect(formatJudges([round(['p1'], ['p2'])])).toMatch(/kappa -0\.\d\d over 4/);
|
|
10
|
+
});
|
|
11
|
+
it('takes each judge\'s own item from the panel, including what it raised in its own words', () => {
|
|
12
|
+
const lock = { id: 'a', kind: 'finding', class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock' };
|
|
13
|
+
const judged = judgedFrom({ ...base, costUsd: 2, runs: 3, panel: [
|
|
14
|
+
{ item: lock, judges: ['claude', 'codex'], calls: { claude: 'raised', codex: 'raised' }, status: 'confirmed', members: [{ judge: 'claude', item: lock }, { judge: 'codex', item: { ...lock, id: 'b', line: 3, issue: 'lock taken late' } }] },
|
|
15
|
+
] });
|
|
16
|
+
expect(judged).toEqual({ judges: ['claude', 'codex'], costUsd: 2, runs: 3, raised: [
|
|
17
|
+
{ judge: 'claude', file: 'src/job.ts', line: 2, text: 'returns before the lock' },
|
|
18
|
+
{ judge: 'codex', file: 'src/job.ts', line: 3, text: 'lock taken late' },
|
|
19
|
+
] });
|
|
20
|
+
expect(judgedFrom({ ...base, reviewers: ['claude'] })).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
it('reports each judge, what only it found, each pair\'s agreement and the cost of a caught point', () => {
|
|
23
|
+
const round = (caught) => ({ judges: { judges: ['claude', 'codex'], caught, points: ['p1', 'p2', 'p3', 'p4'], runs: 3, costUsd: 3 }, points: ['p1', 'p2', 'p3', 'p4'].map(id => ({ id, caught: Object.values(caught).some(ids => ids.includes(id)) })) });
|
|
24
|
+
const report = formatJudges([round({ claude: ['p1', 'p2'], codex: ['p1', 'p3'] }), round({ claude: ['p1'], codex: ['p1'] })]);
|
|
25
|
+
expect(report).toContain('Judges (2 round(s), 8 human point(s))');
|
|
26
|
+
expect(report).toContain('claude: raised 3/8; 1 that no other judge raised');
|
|
27
|
+
expect(report).toContain('codex: raised 3/8; 1 that no other judge raised');
|
|
28
|
+
expect(report).toMatch(/claude and codex: kappa 0\.\d\d over 8 point\(s\)/);
|
|
29
|
+
expect(report).toContain('6 agent run(s), $6.00 recorded, $1.50 per caught point');
|
|
30
|
+
expect(formatJudges([{ points: [] }])).toBe('');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Config } from '../types/index.js';
|
|
3
|
+
import { type Exec, type Progress } from './reviewer.js';
|
|
4
|
+
import { type JudgeCatches, type JudgeRun } from './backtest-judges.js';
|
|
5
|
+
declare const Point: z.ZodObject<{
|
|
6
|
+
/** A regular expression over the finding's file path. */
|
|
7
|
+
file: z.ZodString;
|
|
8
|
+
/** A line window at the reviewed commit: a finding inside it matches. */
|
|
9
|
+
lines: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
|
|
10
|
+
/** A regular expression over the finding's text; the alternative for a point with no line. */
|
|
11
|
+
text: z.ZodOptional<z.ZodString>;
|
|
12
|
+
/** Left by `rigour backtest init` on a row a person still has to complete. */
|
|
13
|
+
needs: z.ZodOptional<z.ZodString>;
|
|
14
|
+
} & {
|
|
15
|
+
id: z.ZodString;
|
|
16
|
+
point: z.ZodString;
|
|
17
|
+
}, "strip", z.ZodTypeAny, {
|
|
18
|
+
id: string;
|
|
19
|
+
file: string;
|
|
20
|
+
point: string;
|
|
21
|
+
text?: string | undefined;
|
|
22
|
+
lines?: [number, number] | undefined;
|
|
23
|
+
needs?: string | undefined;
|
|
24
|
+
}, {
|
|
25
|
+
id: string;
|
|
26
|
+
file: string;
|
|
27
|
+
point: string;
|
|
28
|
+
text?: string | undefined;
|
|
29
|
+
lines?: [number, number] | undefined;
|
|
30
|
+
needs?: string | undefined;
|
|
31
|
+
}>;
|
|
32
|
+
declare const Round: z.ZodObject<{
|
|
33
|
+
id: z.ZodString;
|
|
34
|
+
commit: z.ZodString;
|
|
35
|
+
base: z.ZodString;
|
|
36
|
+
/** When the human review was posted: the reviewer sees nothing from then on. */
|
|
37
|
+
reviewed_at: z.ZodOptional<z.ZodString>;
|
|
38
|
+
pr: z.ZodOptional<z.ZodNumber>;
|
|
39
|
+
points: z.ZodArray<z.ZodObject<{
|
|
40
|
+
/** A regular expression over the finding's file path. */
|
|
41
|
+
file: z.ZodString;
|
|
42
|
+
/** A line window at the reviewed commit: a finding inside it matches. */
|
|
43
|
+
lines: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
|
|
44
|
+
/** A regular expression over the finding's text; the alternative for a point with no line. */
|
|
45
|
+
text: z.ZodOptional<z.ZodString>;
|
|
46
|
+
/** Left by `rigour backtest init` on a row a person still has to complete. */
|
|
47
|
+
needs: z.ZodOptional<z.ZodString>;
|
|
48
|
+
} & {
|
|
49
|
+
id: z.ZodString;
|
|
50
|
+
point: z.ZodString;
|
|
51
|
+
}, "strip", z.ZodTypeAny, {
|
|
52
|
+
id: string;
|
|
53
|
+
file: string;
|
|
54
|
+
point: string;
|
|
55
|
+
text?: string | undefined;
|
|
56
|
+
lines?: [number, number] | undefined;
|
|
57
|
+
needs?: string | undefined;
|
|
58
|
+
}, {
|
|
59
|
+
id: string;
|
|
60
|
+
file: string;
|
|
61
|
+
point: string;
|
|
62
|
+
text?: string | undefined;
|
|
63
|
+
lines?: [number, number] | undefined;
|
|
64
|
+
needs?: string | undefined;
|
|
65
|
+
}>, "many">;
|
|
66
|
+
must_not_flag: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
67
|
+
/** A regular expression over the finding's file path. */
|
|
68
|
+
file: z.ZodString;
|
|
69
|
+
/** A line window at the reviewed commit: a finding inside it matches. */
|
|
70
|
+
lines: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
|
|
71
|
+
/** A regular expression over the finding's text; the alternative for a point with no line. */
|
|
72
|
+
text: z.ZodOptional<z.ZodString>;
|
|
73
|
+
/** Left by `rigour backtest init` on a row a person still has to complete. */
|
|
74
|
+
needs: z.ZodOptional<z.ZodString>;
|
|
75
|
+
}, "strip", z.ZodTypeAny, {
|
|
76
|
+
file: string;
|
|
77
|
+
text?: string | undefined;
|
|
78
|
+
lines?: [number, number] | undefined;
|
|
79
|
+
needs?: string | undefined;
|
|
80
|
+
}, {
|
|
81
|
+
file: string;
|
|
82
|
+
text?: string | undefined;
|
|
83
|
+
lines?: [number, number] | undefined;
|
|
84
|
+
needs?: string | undefined;
|
|
85
|
+
}>, "many">>;
|
|
86
|
+
}, "strip", z.ZodTypeAny, {
|
|
87
|
+
id: string;
|
|
88
|
+
commit: string;
|
|
89
|
+
base: string;
|
|
90
|
+
points: {
|
|
91
|
+
id: string;
|
|
92
|
+
file: string;
|
|
93
|
+
point: string;
|
|
94
|
+
text?: string | undefined;
|
|
95
|
+
lines?: [number, number] | undefined;
|
|
96
|
+
needs?: string | undefined;
|
|
97
|
+
}[];
|
|
98
|
+
must_not_flag: {
|
|
99
|
+
file: string;
|
|
100
|
+
text?: string | undefined;
|
|
101
|
+
lines?: [number, number] | undefined;
|
|
102
|
+
needs?: string | undefined;
|
|
103
|
+
}[];
|
|
104
|
+
pr?: number | undefined;
|
|
105
|
+
reviewed_at?: string | undefined;
|
|
106
|
+
}, {
|
|
107
|
+
id: string;
|
|
108
|
+
commit: string;
|
|
109
|
+
base: string;
|
|
110
|
+
points: {
|
|
111
|
+
id: string;
|
|
112
|
+
file: string;
|
|
113
|
+
point: string;
|
|
114
|
+
text?: string | undefined;
|
|
115
|
+
lines?: [number, number] | undefined;
|
|
116
|
+
needs?: string | undefined;
|
|
117
|
+
}[];
|
|
118
|
+
pr?: number | undefined;
|
|
119
|
+
reviewed_at?: string | undefined;
|
|
120
|
+
must_not_flag?: {
|
|
121
|
+
file: string;
|
|
122
|
+
text?: string | undefined;
|
|
123
|
+
lines?: [number, number] | undefined;
|
|
124
|
+
needs?: string | undefined;
|
|
125
|
+
}[] | undefined;
|
|
126
|
+
}>;
|
|
127
|
+
export declare const LedgerSchema: z.ZodObject<{
|
|
128
|
+
rounds: z.ZodArray<z.ZodObject<{
|
|
129
|
+
id: z.ZodString;
|
|
130
|
+
commit: z.ZodString;
|
|
131
|
+
base: z.ZodString;
|
|
132
|
+
/** When the human review was posted: the reviewer sees nothing from then on. */
|
|
133
|
+
reviewed_at: z.ZodOptional<z.ZodString>;
|
|
134
|
+
pr: z.ZodOptional<z.ZodNumber>;
|
|
135
|
+
points: z.ZodArray<z.ZodObject<{
|
|
136
|
+
/** A regular expression over the finding's file path. */
|
|
137
|
+
file: z.ZodString;
|
|
138
|
+
/** A line window at the reviewed commit: a finding inside it matches. */
|
|
139
|
+
lines: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
|
|
140
|
+
/** A regular expression over the finding's text; the alternative for a point with no line. */
|
|
141
|
+
text: z.ZodOptional<z.ZodString>;
|
|
142
|
+
/** Left by `rigour backtest init` on a row a person still has to complete. */
|
|
143
|
+
needs: z.ZodOptional<z.ZodString>;
|
|
144
|
+
} & {
|
|
145
|
+
id: z.ZodString;
|
|
146
|
+
point: z.ZodString;
|
|
147
|
+
}, "strip", z.ZodTypeAny, {
|
|
148
|
+
id: string;
|
|
149
|
+
file: string;
|
|
150
|
+
point: string;
|
|
151
|
+
text?: string | undefined;
|
|
152
|
+
lines?: [number, number] | undefined;
|
|
153
|
+
needs?: string | undefined;
|
|
154
|
+
}, {
|
|
155
|
+
id: string;
|
|
156
|
+
file: string;
|
|
157
|
+
point: string;
|
|
158
|
+
text?: string | undefined;
|
|
159
|
+
lines?: [number, number] | undefined;
|
|
160
|
+
needs?: string | undefined;
|
|
161
|
+
}>, "many">;
|
|
162
|
+
must_not_flag: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
163
|
+
/** A regular expression over the finding's file path. */
|
|
164
|
+
file: z.ZodString;
|
|
165
|
+
/** A line window at the reviewed commit: a finding inside it matches. */
|
|
166
|
+
lines: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
|
|
167
|
+
/** A regular expression over the finding's text; the alternative for a point with no line. */
|
|
168
|
+
text: z.ZodOptional<z.ZodString>;
|
|
169
|
+
/** Left by `rigour backtest init` on a row a person still has to complete. */
|
|
170
|
+
needs: z.ZodOptional<z.ZodString>;
|
|
171
|
+
}, "strip", z.ZodTypeAny, {
|
|
172
|
+
file: string;
|
|
173
|
+
text?: string | undefined;
|
|
174
|
+
lines?: [number, number] | undefined;
|
|
175
|
+
needs?: string | undefined;
|
|
176
|
+
}, {
|
|
177
|
+
file: string;
|
|
178
|
+
text?: string | undefined;
|
|
179
|
+
lines?: [number, number] | undefined;
|
|
180
|
+
needs?: string | undefined;
|
|
181
|
+
}>, "many">>;
|
|
182
|
+
}, "strip", z.ZodTypeAny, {
|
|
183
|
+
id: string;
|
|
184
|
+
commit: string;
|
|
185
|
+
base: string;
|
|
186
|
+
points: {
|
|
187
|
+
id: string;
|
|
188
|
+
file: string;
|
|
189
|
+
point: string;
|
|
190
|
+
text?: string | undefined;
|
|
191
|
+
lines?: [number, number] | undefined;
|
|
192
|
+
needs?: string | undefined;
|
|
193
|
+
}[];
|
|
194
|
+
must_not_flag: {
|
|
195
|
+
file: string;
|
|
196
|
+
text?: string | undefined;
|
|
197
|
+
lines?: [number, number] | undefined;
|
|
198
|
+
needs?: string | undefined;
|
|
199
|
+
}[];
|
|
200
|
+
pr?: number | undefined;
|
|
201
|
+
reviewed_at?: string | undefined;
|
|
202
|
+
}, {
|
|
203
|
+
id: string;
|
|
204
|
+
commit: string;
|
|
205
|
+
base: string;
|
|
206
|
+
points: {
|
|
207
|
+
id: string;
|
|
208
|
+
file: string;
|
|
209
|
+
point: string;
|
|
210
|
+
text?: string | undefined;
|
|
211
|
+
lines?: [number, number] | undefined;
|
|
212
|
+
needs?: string | undefined;
|
|
213
|
+
}[];
|
|
214
|
+
pr?: number | undefined;
|
|
215
|
+
reviewed_at?: string | undefined;
|
|
216
|
+
must_not_flag?: {
|
|
217
|
+
file: string;
|
|
218
|
+
text?: string | undefined;
|
|
219
|
+
lines?: [number, number] | undefined;
|
|
220
|
+
needs?: string | undefined;
|
|
221
|
+
}[] | undefined;
|
|
222
|
+
}>, "many">;
|
|
223
|
+
}, "strip", z.ZodTypeAny, {
|
|
224
|
+
rounds: {
|
|
225
|
+
id: string;
|
|
226
|
+
commit: string;
|
|
227
|
+
base: string;
|
|
228
|
+
points: {
|
|
229
|
+
id: string;
|
|
230
|
+
file: string;
|
|
231
|
+
point: string;
|
|
232
|
+
text?: string | undefined;
|
|
233
|
+
lines?: [number, number] | undefined;
|
|
234
|
+
needs?: string | undefined;
|
|
235
|
+
}[];
|
|
236
|
+
must_not_flag: {
|
|
237
|
+
file: string;
|
|
238
|
+
text?: string | undefined;
|
|
239
|
+
lines?: [number, number] | undefined;
|
|
240
|
+
needs?: string | undefined;
|
|
241
|
+
}[];
|
|
242
|
+
pr?: number | undefined;
|
|
243
|
+
reviewed_at?: string | undefined;
|
|
244
|
+
}[];
|
|
245
|
+
}, {
|
|
246
|
+
rounds: {
|
|
247
|
+
id: string;
|
|
248
|
+
commit: string;
|
|
249
|
+
base: string;
|
|
250
|
+
points: {
|
|
251
|
+
id: string;
|
|
252
|
+
file: string;
|
|
253
|
+
point: string;
|
|
254
|
+
text?: string | undefined;
|
|
255
|
+
lines?: [number, number] | undefined;
|
|
256
|
+
needs?: string | undefined;
|
|
257
|
+
}[];
|
|
258
|
+
pr?: number | undefined;
|
|
259
|
+
reviewed_at?: string | undefined;
|
|
260
|
+
must_not_flag?: {
|
|
261
|
+
file: string;
|
|
262
|
+
text?: string | undefined;
|
|
263
|
+
lines?: [number, number] | undefined;
|
|
264
|
+
needs?: string | undefined;
|
|
265
|
+
}[] | undefined;
|
|
266
|
+
}[];
|
|
267
|
+
}>;
|
|
268
|
+
export type Ledger = z.infer<typeof LedgerSchema>;
|
|
269
|
+
export type LedgerRound = z.infer<typeof Round>;
|
|
270
|
+
export type LedgerPoint = z.infer<typeof Point>;
|
|
271
|
+
export declare const LEDGER_PATH = ".rigour/backtest.json";
|
|
272
|
+
/** A finding as the score sees it, from a gate or the reviewer. */
|
|
273
|
+
export interface BacktestItem {
|
|
274
|
+
gate: string;
|
|
275
|
+
file: string;
|
|
276
|
+
line: number | undefined;
|
|
277
|
+
text: string;
|
|
278
|
+
blocking: boolean;
|
|
279
|
+
}
|
|
280
|
+
export interface PointOutcome extends Pick<LedgerPoint, 'id' | 'point'> {
|
|
281
|
+
caught: boolean;
|
|
282
|
+
/** Matched by an advisory note only: visible, but it would not have stopped the push. */
|
|
283
|
+
noted: boolean;
|
|
284
|
+
by?: string;
|
|
285
|
+
}
|
|
286
|
+
export interface RoundResult {
|
|
287
|
+
round: string;
|
|
288
|
+
head: string;
|
|
289
|
+
base: string;
|
|
290
|
+
points: PointOutcome[];
|
|
291
|
+
falseBlocks: BacktestItem[];
|
|
292
|
+
items: BacktestItem[];
|
|
293
|
+
durationMs: number;
|
|
294
|
+
/** Why the reviewer gave no verdict, when it ran. */
|
|
295
|
+
reviewerError?: string;
|
|
296
|
+
/** With two or more judges: the ledger points each raised on its own, and the round's runs and cost. */
|
|
297
|
+
judges?: JudgeCatches;
|
|
298
|
+
}
|
|
299
|
+
export interface BacktestOptions {
|
|
300
|
+
round?: string;
|
|
301
|
+
reviewer?: boolean;
|
|
302
|
+
exec?: Exec;
|
|
303
|
+
progress?: Progress;
|
|
304
|
+
/** Produces the findings for a worktree; tests replace it. */
|
|
305
|
+
collect?: (worktree: string, round: LedgerRound, config: Config) => Promise<Collected>;
|
|
306
|
+
}
|
|
307
|
+
export declare function loadLedger(cwd: string): Ledger;
|
|
308
|
+
/** Rows that cannot match anything: no file pattern, neither a line window nor a text pattern, or a scaffold mark. */
|
|
309
|
+
export declare function ledgerProblems(ledger: Ledger): string[];
|
|
310
|
+
export declare function runBacktest(cwd: string, config: Config, ledger: Ledger, options?: BacktestOptions): Promise<RoundResult[]>;
|
|
311
|
+
/** Every point caught, nothing the reviewer called good flagged, and a verdict when the reviewer ran. */
|
|
312
|
+
export declare function backtestPassed(results: RoundResult[]): boolean;
|
|
313
|
+
export declare function score(round: LedgerRound, head: string, items: BacktestItem[], durationMs: number, reviewerError: string | undefined): RoundResult;
|
|
314
|
+
interface Collected {
|
|
315
|
+
items: BacktestItem[];
|
|
316
|
+
reviewerError?: string;
|
|
317
|
+
judged?: JudgeRun;
|
|
318
|
+
}
|
|
319
|
+
export declare function formatBacktest(results: RoundResult[]): string;
|
|
320
|
+
export {};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backtest: run the review on a commit a person reviewed, with that review hidden, and score what
|
|
3
|
+
* Rigour reports against the points the person made. The ledger (`.rigour/backtest.json`) holds,
|
|
4
|
+
* per round, the reviewed commit, the base it was reviewed against, when the review was posted,
|
|
5
|
+
* the reviewer's points (each a file pattern plus a line window or a text pattern) and code the
|
|
6
|
+
* reviewer called good (a blocking finding there is a false block).
|
|
7
|
+
*
|
|
8
|
+
* A point counts as caught only when a BLOCKING finding matches it. A match in an advisory note
|
|
9
|
+
* is shown as "noted" and still counted as missed: nothing advisory stops a push. Each round runs
|
|
10
|
+
* in a detached worktree under the git directory, so the repository and its branches stay as they
|
|
11
|
+
* are, and the findings reported are kept beside the score so a pattern can be checked.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { reviewChange } from './review.js';
|
|
17
|
+
import { defaultExec, runReviewer } from './reviewer.js';
|
|
18
|
+
import { reviewerInputs } from './reviewer/context.js';
|
|
19
|
+
import { formatJudges, judgedFrom } from './backtest-judges.js';
|
|
20
|
+
const Match = z.object({
|
|
21
|
+
/** A regular expression over the finding's file path. */
|
|
22
|
+
file: z.string(),
|
|
23
|
+
/** A line window at the reviewed commit: a finding inside it matches. */
|
|
24
|
+
lines: z.tuple([z.number().int().positive(), z.number().int().positive()]).optional(),
|
|
25
|
+
/** A regular expression over the finding's text; the alternative for a point with no line. */
|
|
26
|
+
text: z.string().optional(),
|
|
27
|
+
/** Left by `rigour backtest init` on a row a person still has to complete. */
|
|
28
|
+
needs: z.string().optional(),
|
|
29
|
+
});
|
|
30
|
+
const Point = Match.extend({ id: z.string().min(1), point: z.string().min(1) });
|
|
31
|
+
const Round = z.object({
|
|
32
|
+
id: z.string().min(1),
|
|
33
|
+
commit: z.string().min(7),
|
|
34
|
+
base: z.string().min(1),
|
|
35
|
+
/** When the human review was posted: the reviewer sees nothing from then on. */
|
|
36
|
+
reviewed_at: z.string().optional(),
|
|
37
|
+
pr: z.number().int().positive().optional(),
|
|
38
|
+
points: z.array(Point),
|
|
39
|
+
must_not_flag: z.array(Match).default([]),
|
|
40
|
+
});
|
|
41
|
+
export const LedgerSchema = z.object({ rounds: z.array(Round).min(1) });
|
|
42
|
+
export const LEDGER_PATH = '.rigour/backtest.json';
|
|
43
|
+
export function loadLedger(cwd) {
|
|
44
|
+
const file = path.join(cwd, LEDGER_PATH);
|
|
45
|
+
if (!fs.existsSync(file))
|
|
46
|
+
throw new Error(`no ledger at ${LEDGER_PATH}: run \`rigour backtest init --pr <number>\` or write one (docs/BACKTEST.md)`);
|
|
47
|
+
const parsed = LedgerSchema.safeParse(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
48
|
+
if (!parsed.success)
|
|
49
|
+
throw new Error(`${LEDGER_PATH} is not a ledger: ${parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
|
|
50
|
+
const problems = ledgerProblems(parsed.data);
|
|
51
|
+
if (problems.length)
|
|
52
|
+
throw new Error(`${LEDGER_PATH} has rows a person still has to complete:\n ${problems.join('\n ')}`);
|
|
53
|
+
return parsed.data;
|
|
54
|
+
}
|
|
55
|
+
/** Rows that cannot match anything: no file pattern, neither a line window nor a text pattern, or a scaffold mark. */
|
|
56
|
+
export function ledgerProblems(ledger) {
|
|
57
|
+
const problems = [];
|
|
58
|
+
for (const round of ledger.rounds) {
|
|
59
|
+
const rows = [...round.points.map((p) => [p.id, p]), ...round.must_not_flag.map((m, i) => [`must_not_flag[${i}]`, m])];
|
|
60
|
+
for (const [name, row] of rows) {
|
|
61
|
+
if (row.needs)
|
|
62
|
+
problems.push(`${round.id} ${name}: needs ${row.needs}`);
|
|
63
|
+
else if (!row.file)
|
|
64
|
+
problems.push(`${round.id} ${name}: no file pattern`);
|
|
65
|
+
else if (!row.lines && !row.text)
|
|
66
|
+
problems.push(`${round.id} ${name}: needs a line window or a text pattern`);
|
|
67
|
+
else if (!valid(row.file) || (row.text !== undefined && !valid(row.text)))
|
|
68
|
+
problems.push(`${round.id} ${name}: not a regular expression`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return problems;
|
|
72
|
+
}
|
|
73
|
+
function valid(pattern) {
|
|
74
|
+
try {
|
|
75
|
+
new RegExp(pattern, 'i');
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function runBacktest(cwd, config, ledger, options = {}) {
|
|
83
|
+
const rounds = options.round ? ledger.rounds.filter(r => r.id === options.round) : ledger.rounds;
|
|
84
|
+
if (rounds.length === 0)
|
|
85
|
+
throw new Error(`no round "${options.round}" in ${LEDGER_PATH}: ${ledger.rounds.map(r => r.id).join(', ')}`);
|
|
86
|
+
const exec = options.exec ?? defaultExec;
|
|
87
|
+
const progress = options.progress ?? (() => undefined);
|
|
88
|
+
const results = [];
|
|
89
|
+
for (const round of rounds) {
|
|
90
|
+
const started = Date.now();
|
|
91
|
+
const worktree = await worktreeFor(cwd, round.commit, exec);
|
|
92
|
+
const head = (await exec('git', ['rev-parse', 'HEAD'], { cwd: worktree, timeoutMs: GIT_TIMEOUT_MS })).stdout.trim();
|
|
93
|
+
progress(`backtest ${round.id}: ${head.slice(0, 9)} against ${round.base}${round.reviewed_at ? `, reviews hidden from ${round.reviewed_at}` : ''}`);
|
|
94
|
+
const collect = options.collect ?? ((tree, r, c) => collectItems(tree, r, c, !!options.reviewer, exec, progress));
|
|
95
|
+
const { items, reviewerError, judged } = await collect(worktree, round, config);
|
|
96
|
+
const result = { ...score(round, head, items, Date.now() - started, reviewerError), ...(judged ? { judges: judgeCatches(round, judged) } : {}) };
|
|
97
|
+
record(cwd, result);
|
|
98
|
+
results.push(result);
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
/** Every point caught, nothing the reviewer called good flagged, and a verdict when the reviewer ran. */
|
|
103
|
+
export function backtestPassed(results) {
|
|
104
|
+
return results.every(r => r.points.every(p => p.caught) && r.falseBlocks.length === 0 && !r.reviewerError);
|
|
105
|
+
}
|
|
106
|
+
export function score(round, head, items, durationMs, reviewerError) {
|
|
107
|
+
const points = round.points.map(point => {
|
|
108
|
+
const hit = items.find(item => item.blocking && matches(point, item));
|
|
109
|
+
const noted = !hit && items.some(item => !item.blocking && matches(point, item));
|
|
110
|
+
return { id: point.id, point: point.point, caught: !!hit, noted, ...(hit ? { by: `${hit.gate} ${hit.file}${hit.line ? `:${hit.line}` : ''}` } : {}) };
|
|
111
|
+
});
|
|
112
|
+
const falseBlocks = items.filter(item => item.blocking && round.must_not_flag.some(guard => matches(guard, item)));
|
|
113
|
+
return { round: round.id, head, base: round.base, points, falseBlocks, items, durationMs, ...(reviewerError ? { reviewerError } : {}) };
|
|
114
|
+
}
|
|
115
|
+
/** The file pattern must match, then either the line window holds the finding's line or the text pattern matches its text. */
|
|
116
|
+
function matches(row, item) {
|
|
117
|
+
if (!new RegExp(row.file, 'i').test(item.file))
|
|
118
|
+
return false;
|
|
119
|
+
const inWindow = !!row.lines && item.line !== undefined && item.line >= row.lines[0] && item.line <= row.lines[1];
|
|
120
|
+
const inText = row.text !== undefined && new RegExp(row.text, 'i').test(item.text);
|
|
121
|
+
return inWindow || inText;
|
|
122
|
+
}
|
|
123
|
+
const GIT_TIMEOUT_MS = 60_000;
|
|
124
|
+
/** A detached worktree at the commit, under the git directory (shared by worktrees), reused on the next run. */
|
|
125
|
+
async function worktreeFor(cwd, commit, exec) {
|
|
126
|
+
const common = (await exec('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd, timeoutMs: GIT_TIMEOUT_MS })).stdout.trim();
|
|
127
|
+
if (!common)
|
|
128
|
+
throw new Error(`${cwd} is not a git repository`);
|
|
129
|
+
const resolved = await exec('git', ['rev-parse', '--verify', `${commit}^{commit}`], { cwd, timeoutMs: GIT_TIMEOUT_MS });
|
|
130
|
+
if (resolved.exitCode !== 0) {
|
|
131
|
+
const reason = resolved.stderr.trim().split('\n').at(-1);
|
|
132
|
+
throw new Error(`commit ${commit} is not in this repository (fetch the branch it was reviewed on)${reason ? `: git said "${reason}"` : ''}`);
|
|
133
|
+
}
|
|
134
|
+
const dir = path.join(common, 'rigour-backtest', resolved.stdout.trim().slice(0, 12));
|
|
135
|
+
if (!fs.existsSync(path.join(dir, '.git'))) {
|
|
136
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
137
|
+
const added = await exec('git', ['worktree', 'add', '--detach', dir, resolved.stdout.trim()], { cwd, timeoutMs: 5 * GIT_TIMEOUT_MS });
|
|
138
|
+
if (added.exitCode !== 0)
|
|
139
|
+
throw new Error(`could not check out ${commit} for the backtest: ${added.stderr.trim()}`);
|
|
140
|
+
}
|
|
141
|
+
shareDependencies(cwd, dir);
|
|
142
|
+
return dir;
|
|
143
|
+
}
|
|
144
|
+
/** The checkout's installed dependencies serve the worktree too (the typed checks need the project's compiler), never a download. */
|
|
145
|
+
function shareDependencies(cwd, worktree) {
|
|
146
|
+
const root = path.join(cwd, 'node_modules');
|
|
147
|
+
if (fs.existsSync(root) && !fs.existsSync(path.join(worktree, 'node_modules')))
|
|
148
|
+
fs.symlinkSync(root, path.join(worktree, 'node_modules'), 'junction');
|
|
149
|
+
}
|
|
150
|
+
/** Which ledger points each judge raised itself, matched the way the score matches any finding. */
|
|
151
|
+
function judgeCatches(round, judged) {
|
|
152
|
+
const caught = Object.fromEntries(judged.judges.map(judge => [judge, round.points.filter(point => judged.raised.some(r => r.judge === judge && matches(point, { gate: `judge:${judge}`, file: r.file, line: r.line, text: r.text, blocking: true }))).map(p => p.id)]));
|
|
153
|
+
return { judges: judged.judges, caught, points: round.points.map(p => p.id), runs: judged.runs, ...(judged.costUsd !== undefined ? { costUsd: judged.costUsd } : {}) };
|
|
154
|
+
}
|
|
155
|
+
async function collectItems(worktree, round, config, reviewer, exec, progress) {
|
|
156
|
+
const review = await reviewChange({ cwd: worktree, config, source: { mode: 'base', base: round.base }, typed: true });
|
|
157
|
+
const items = [
|
|
158
|
+
...review.findings.map(f => asItem(f, true)),
|
|
159
|
+
...[...review.advisory, ...review.contextFindings].map(f => asItem(f, false)),
|
|
160
|
+
];
|
|
161
|
+
if (!reviewer)
|
|
162
|
+
return { items };
|
|
163
|
+
const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: round.reviewed_at, trigger: 'backtest', force: true, ...reviewerInputs(review) });
|
|
164
|
+
if (result.outcome === 'unavailable' || result.outcome === 'skipped')
|
|
165
|
+
return { items, reviewerError: result.reason ?? result.outcome };
|
|
166
|
+
// The reviewer behind each catch is part of the score, so the gate is `reviewer:<name>`.
|
|
167
|
+
const asReviewerItem = (item, blocking) => ({ gate: `reviewer:${item.reviewer ?? result.reviewers[0]}`, file: item.file ?? '', line: item.line, text: [item.issue, item.consequence, item.evidence].filter(Boolean).join(' '), blocking });
|
|
168
|
+
items.push(...result.items.map(i => asReviewerItem(i, true)), ...[...result.unverified, ...result.notes, ...result.disputed].map(i => asReviewerItem(i, false)));
|
|
169
|
+
const judged = judgedFrom(result);
|
|
170
|
+
return { items, ...(judged ? { judged } : {}) };
|
|
171
|
+
}
|
|
172
|
+
function asItem(failure, blocking) {
|
|
173
|
+
return { gate: failure.id, file: failure.files?.[0] ?? '', line: failure.line, text: [failure.title, failure.details, failure.hint].filter(Boolean).join(' '), blocking };
|
|
174
|
+
}
|
|
175
|
+
/** The score and every item reported, beside the worktrees, so a pattern that missed can be checked against what was there. */
|
|
176
|
+
function record(cwd, result) {
|
|
177
|
+
try {
|
|
178
|
+
const dir = path.join(cwd, '.rigour', 'backtest');
|
|
179
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
180
|
+
fs.writeFileSync(path.join(dir, `${result.round}-${result.head.slice(0, 12)}.json`), JSON.stringify(result, null, 2));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// the score is printed either way
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
export function formatBacktest(results) {
|
|
187
|
+
const lines = [];
|
|
188
|
+
for (const r of results) {
|
|
189
|
+
const caught = r.points.filter(p => p.caught).length;
|
|
190
|
+
lines.push(`${r.round} at ${r.head.slice(0, 9)}: ${caught}/${r.points.length} caught, ${r.falseBlocks.length} false block(s), ${r.items.length} finding(s), ${Math.round(r.durationMs / 1000)}s${byGate(r)}`);
|
|
191
|
+
for (const p of r.points)
|
|
192
|
+
lines.push(` ${p.caught ? 'caught ' : p.noted ? 'noted ' : 'MISSED '} ${p.id} ${p.point}${p.by ? ` (${p.by})` : p.noted ? ' (advisory only)' : ''}`);
|
|
193
|
+
for (const f of r.falseBlocks)
|
|
194
|
+
lines.push(` FALSE ${f.gate} ${f.file}${f.line ? `:${f.line}` : ''} ${f.text.slice(0, 100)}`);
|
|
195
|
+
if (r.reviewerError)
|
|
196
|
+
lines.push(` NO VERDICT ${r.reviewerError}`);
|
|
197
|
+
}
|
|
198
|
+
return lines.join('\n') + formatJudges(results);
|
|
199
|
+
}
|
|
200
|
+
/** Which gate (or the reviewer) caught what: `; caught by unused-export 2, reviewer 1`, so a rule is judged on its own rows. */
|
|
201
|
+
function byGate(result) {
|
|
202
|
+
const counts = new Map();
|
|
203
|
+
for (const p of result.points)
|
|
204
|
+
if (p.by)
|
|
205
|
+
counts.set(p.by.split(' ')[0], (counts.get(p.by.split(' ')[0]) ?? 0) + 1);
|
|
206
|
+
return counts.size ? `; caught by ${[...counts].map(([gate, n]) => `${gate} ${n}`).join(', ')}` : '';
|
|
207
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|