@rigour-labs/core 6.6.5 → 6.6.6
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/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 +4 -1
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/stop-review.d.ts +6 -9
- package/dist/hooks/stop-review.js +5 -10
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- 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.d.ts +315 -0
- package/dist/review/backtest.js +197 -0
- package/dist/review/backtest.test.d.ts +1 -0
- package/dist/review/backtest.test.js +129 -0
- package/dist/review/dead-code.test.js +8 -0
- package/dist/review/lint-overlay.d.ts +21 -0
- package/dist/review/lint-overlay.js +89 -0
- 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 +8 -1
- package/dist/review/reviewer/adapters.d.ts +35 -0
- package/dist/review/reviewer/adapters.js +156 -0
- package/dist/review/reviewer/background.d.ts +34 -0
- package/dist/review/reviewer/background.js +128 -0
- package/dist/review/reviewer/background.test.d.ts +1 -0
- package/dist/review/reviewer/background.test.js +104 -0
- package/dist/review/reviewer/exec.d.ts +17 -0
- package/dist/review/reviewer/exec.js +23 -0
- package/dist/review/reviewer/inputs.d.ts +37 -0
- package/dist/review/reviewer/inputs.js +103 -0
- package/dist/review/reviewer/merge-impact.d.ts +2 -0
- package/dist/review/reviewer/merge-impact.js +64 -0
- package/dist/review/reviewer/prompt.d.ts +21 -0
- package/dist/review/reviewer/prompt.js +123 -0
- package/dist/review/reviewer/store.d.ts +26 -0
- package/dist/review/reviewer/store.js +57 -0
- package/dist/review/reviewer/verdict.d.ts +127 -0
- package/dist/review/reviewer/verdict.js +156 -0
- package/dist/review/reviewer.d.ts +41 -49
- package/dist/review/reviewer.js +210 -202
- package/dist/review/reviewer.test.js +199 -48
- package/dist/review/toolchain.d.ts +5 -2
- package/dist/review/toolchain.js +81 -6
- package/dist/review/toolchain.test.js +39 -1
- package/dist/review/typed/program.d.ts +27 -0
- package/dist/review/typed/program.js +90 -0
- package/dist/review/typed/redundancy.d.ts +10 -0
- package/dist/review/typed/redundancy.js +339 -0
- package/dist/review/typed/redundancy.test.d.ts +1 -0
- package/dist/review/typed/redundancy.test.js +121 -0
- package/dist/review/unused-exports.js +8 -4
- package/dist/templates/universal-config.js +8 -1
- package/dist/types/index.d.ts +94 -8
- package/dist/types/index.js +24 -2
- package/package.json +6 -6
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Config } from '../types/index.js';
|
|
3
|
+
import { type Exec, type Progress } from './reviewer.js';
|
|
4
|
+
declare const Point: z.ZodObject<{
|
|
5
|
+
/** A regular expression over the finding's file path. */
|
|
6
|
+
file: z.ZodString;
|
|
7
|
+
/** A line window at the reviewed commit: a finding inside it matches. */
|
|
8
|
+
lines: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
|
|
9
|
+
/** A regular expression over the finding's text; the alternative for a point with no line. */
|
|
10
|
+
text: z.ZodOptional<z.ZodString>;
|
|
11
|
+
/** Left by `rigour backtest init` on a row a person still has to complete. */
|
|
12
|
+
needs: z.ZodOptional<z.ZodString>;
|
|
13
|
+
} & {
|
|
14
|
+
id: z.ZodString;
|
|
15
|
+
point: z.ZodString;
|
|
16
|
+
}, "strip", z.ZodTypeAny, {
|
|
17
|
+
id: string;
|
|
18
|
+
file: string;
|
|
19
|
+
point: string;
|
|
20
|
+
text?: string | undefined;
|
|
21
|
+
lines?: [number, number] | undefined;
|
|
22
|
+
needs?: string | undefined;
|
|
23
|
+
}, {
|
|
24
|
+
id: string;
|
|
25
|
+
file: string;
|
|
26
|
+
point: string;
|
|
27
|
+
text?: string | undefined;
|
|
28
|
+
lines?: [number, number] | undefined;
|
|
29
|
+
needs?: string | undefined;
|
|
30
|
+
}>;
|
|
31
|
+
declare const Round: z.ZodObject<{
|
|
32
|
+
id: z.ZodString;
|
|
33
|
+
commit: z.ZodString;
|
|
34
|
+
base: z.ZodString;
|
|
35
|
+
/** When the human review was posted: the reviewer sees nothing from then on. */
|
|
36
|
+
reviewed_at: z.ZodOptional<z.ZodString>;
|
|
37
|
+
pr: z.ZodOptional<z.ZodNumber>;
|
|
38
|
+
points: z.ZodArray<z.ZodObject<{
|
|
39
|
+
/** A regular expression over the finding's file path. */
|
|
40
|
+
file: z.ZodString;
|
|
41
|
+
/** A line window at the reviewed commit: a finding inside it matches. */
|
|
42
|
+
lines: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
|
|
43
|
+
/** A regular expression over the finding's text; the alternative for a point with no line. */
|
|
44
|
+
text: z.ZodOptional<z.ZodString>;
|
|
45
|
+
/** Left by `rigour backtest init` on a row a person still has to complete. */
|
|
46
|
+
needs: z.ZodOptional<z.ZodString>;
|
|
47
|
+
} & {
|
|
48
|
+
id: z.ZodString;
|
|
49
|
+
point: z.ZodString;
|
|
50
|
+
}, "strip", z.ZodTypeAny, {
|
|
51
|
+
id: string;
|
|
52
|
+
file: string;
|
|
53
|
+
point: string;
|
|
54
|
+
text?: string | undefined;
|
|
55
|
+
lines?: [number, number] | undefined;
|
|
56
|
+
needs?: string | undefined;
|
|
57
|
+
}, {
|
|
58
|
+
id: string;
|
|
59
|
+
file: string;
|
|
60
|
+
point: string;
|
|
61
|
+
text?: string | undefined;
|
|
62
|
+
lines?: [number, number] | undefined;
|
|
63
|
+
needs?: string | undefined;
|
|
64
|
+
}>, "many">;
|
|
65
|
+
must_not_flag: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
66
|
+
/** A regular expression over the finding's file path. */
|
|
67
|
+
file: z.ZodString;
|
|
68
|
+
/** A line window at the reviewed commit: a finding inside it matches. */
|
|
69
|
+
lines: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
|
|
70
|
+
/** A regular expression over the finding's text; the alternative for a point with no line. */
|
|
71
|
+
text: z.ZodOptional<z.ZodString>;
|
|
72
|
+
/** Left by `rigour backtest init` on a row a person still has to complete. */
|
|
73
|
+
needs: z.ZodOptional<z.ZodString>;
|
|
74
|
+
}, "strip", z.ZodTypeAny, {
|
|
75
|
+
file: string;
|
|
76
|
+
text?: string | undefined;
|
|
77
|
+
lines?: [number, number] | undefined;
|
|
78
|
+
needs?: string | undefined;
|
|
79
|
+
}, {
|
|
80
|
+
file: string;
|
|
81
|
+
text?: string | undefined;
|
|
82
|
+
lines?: [number, number] | undefined;
|
|
83
|
+
needs?: string | undefined;
|
|
84
|
+
}>, "many">>;
|
|
85
|
+
}, "strip", z.ZodTypeAny, {
|
|
86
|
+
id: string;
|
|
87
|
+
commit: string;
|
|
88
|
+
base: string;
|
|
89
|
+
points: {
|
|
90
|
+
id: string;
|
|
91
|
+
file: string;
|
|
92
|
+
point: string;
|
|
93
|
+
text?: string | undefined;
|
|
94
|
+
lines?: [number, number] | undefined;
|
|
95
|
+
needs?: string | undefined;
|
|
96
|
+
}[];
|
|
97
|
+
must_not_flag: {
|
|
98
|
+
file: string;
|
|
99
|
+
text?: string | undefined;
|
|
100
|
+
lines?: [number, number] | undefined;
|
|
101
|
+
needs?: string | undefined;
|
|
102
|
+
}[];
|
|
103
|
+
pr?: number | undefined;
|
|
104
|
+
reviewed_at?: string | undefined;
|
|
105
|
+
}, {
|
|
106
|
+
id: string;
|
|
107
|
+
commit: string;
|
|
108
|
+
base: string;
|
|
109
|
+
points: {
|
|
110
|
+
id: string;
|
|
111
|
+
file: string;
|
|
112
|
+
point: string;
|
|
113
|
+
text?: string | undefined;
|
|
114
|
+
lines?: [number, number] | undefined;
|
|
115
|
+
needs?: string | undefined;
|
|
116
|
+
}[];
|
|
117
|
+
pr?: number | undefined;
|
|
118
|
+
reviewed_at?: string | undefined;
|
|
119
|
+
must_not_flag?: {
|
|
120
|
+
file: string;
|
|
121
|
+
text?: string | undefined;
|
|
122
|
+
lines?: [number, number] | undefined;
|
|
123
|
+
needs?: string | undefined;
|
|
124
|
+
}[] | undefined;
|
|
125
|
+
}>;
|
|
126
|
+
export declare const LedgerSchema: z.ZodObject<{
|
|
127
|
+
rounds: z.ZodArray<z.ZodObject<{
|
|
128
|
+
id: z.ZodString;
|
|
129
|
+
commit: z.ZodString;
|
|
130
|
+
base: z.ZodString;
|
|
131
|
+
/** When the human review was posted: the reviewer sees nothing from then on. */
|
|
132
|
+
reviewed_at: z.ZodOptional<z.ZodString>;
|
|
133
|
+
pr: z.ZodOptional<z.ZodNumber>;
|
|
134
|
+
points: z.ZodArray<z.ZodObject<{
|
|
135
|
+
/** A regular expression over the finding's file path. */
|
|
136
|
+
file: z.ZodString;
|
|
137
|
+
/** A line window at the reviewed commit: a finding inside it matches. */
|
|
138
|
+
lines: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
|
|
139
|
+
/** A regular expression over the finding's text; the alternative for a point with no line. */
|
|
140
|
+
text: z.ZodOptional<z.ZodString>;
|
|
141
|
+
/** Left by `rigour backtest init` on a row a person still has to complete. */
|
|
142
|
+
needs: z.ZodOptional<z.ZodString>;
|
|
143
|
+
} & {
|
|
144
|
+
id: z.ZodString;
|
|
145
|
+
point: z.ZodString;
|
|
146
|
+
}, "strip", z.ZodTypeAny, {
|
|
147
|
+
id: string;
|
|
148
|
+
file: string;
|
|
149
|
+
point: string;
|
|
150
|
+
text?: string | undefined;
|
|
151
|
+
lines?: [number, number] | undefined;
|
|
152
|
+
needs?: string | undefined;
|
|
153
|
+
}, {
|
|
154
|
+
id: string;
|
|
155
|
+
file: string;
|
|
156
|
+
point: string;
|
|
157
|
+
text?: string | undefined;
|
|
158
|
+
lines?: [number, number] | undefined;
|
|
159
|
+
needs?: string | undefined;
|
|
160
|
+
}>, "many">;
|
|
161
|
+
must_not_flag: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
162
|
+
/** A regular expression over the finding's file path. */
|
|
163
|
+
file: z.ZodString;
|
|
164
|
+
/** A line window at the reviewed commit: a finding inside it matches. */
|
|
165
|
+
lines: z.ZodOptional<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>;
|
|
166
|
+
/** A regular expression over the finding's text; the alternative for a point with no line. */
|
|
167
|
+
text: z.ZodOptional<z.ZodString>;
|
|
168
|
+
/** Left by `rigour backtest init` on a row a person still has to complete. */
|
|
169
|
+
needs: z.ZodOptional<z.ZodString>;
|
|
170
|
+
}, "strip", z.ZodTypeAny, {
|
|
171
|
+
file: string;
|
|
172
|
+
text?: string | undefined;
|
|
173
|
+
lines?: [number, number] | undefined;
|
|
174
|
+
needs?: string | undefined;
|
|
175
|
+
}, {
|
|
176
|
+
file: string;
|
|
177
|
+
text?: string | undefined;
|
|
178
|
+
lines?: [number, number] | undefined;
|
|
179
|
+
needs?: string | undefined;
|
|
180
|
+
}>, "many">>;
|
|
181
|
+
}, "strip", z.ZodTypeAny, {
|
|
182
|
+
id: string;
|
|
183
|
+
commit: string;
|
|
184
|
+
base: string;
|
|
185
|
+
points: {
|
|
186
|
+
id: string;
|
|
187
|
+
file: string;
|
|
188
|
+
point: string;
|
|
189
|
+
text?: string | undefined;
|
|
190
|
+
lines?: [number, number] | undefined;
|
|
191
|
+
needs?: string | undefined;
|
|
192
|
+
}[];
|
|
193
|
+
must_not_flag: {
|
|
194
|
+
file: string;
|
|
195
|
+
text?: string | undefined;
|
|
196
|
+
lines?: [number, number] | undefined;
|
|
197
|
+
needs?: string | undefined;
|
|
198
|
+
}[];
|
|
199
|
+
pr?: number | undefined;
|
|
200
|
+
reviewed_at?: string | undefined;
|
|
201
|
+
}, {
|
|
202
|
+
id: string;
|
|
203
|
+
commit: string;
|
|
204
|
+
base: string;
|
|
205
|
+
points: {
|
|
206
|
+
id: string;
|
|
207
|
+
file: string;
|
|
208
|
+
point: string;
|
|
209
|
+
text?: string | undefined;
|
|
210
|
+
lines?: [number, number] | undefined;
|
|
211
|
+
needs?: string | undefined;
|
|
212
|
+
}[];
|
|
213
|
+
pr?: number | undefined;
|
|
214
|
+
reviewed_at?: string | undefined;
|
|
215
|
+
must_not_flag?: {
|
|
216
|
+
file: string;
|
|
217
|
+
text?: string | undefined;
|
|
218
|
+
lines?: [number, number] | undefined;
|
|
219
|
+
needs?: string | undefined;
|
|
220
|
+
}[] | undefined;
|
|
221
|
+
}>, "many">;
|
|
222
|
+
}, "strip", z.ZodTypeAny, {
|
|
223
|
+
rounds: {
|
|
224
|
+
id: string;
|
|
225
|
+
commit: string;
|
|
226
|
+
base: string;
|
|
227
|
+
points: {
|
|
228
|
+
id: string;
|
|
229
|
+
file: string;
|
|
230
|
+
point: string;
|
|
231
|
+
text?: string | undefined;
|
|
232
|
+
lines?: [number, number] | undefined;
|
|
233
|
+
needs?: string | undefined;
|
|
234
|
+
}[];
|
|
235
|
+
must_not_flag: {
|
|
236
|
+
file: string;
|
|
237
|
+
text?: string | undefined;
|
|
238
|
+
lines?: [number, number] | undefined;
|
|
239
|
+
needs?: string | undefined;
|
|
240
|
+
}[];
|
|
241
|
+
pr?: number | undefined;
|
|
242
|
+
reviewed_at?: string | undefined;
|
|
243
|
+
}[];
|
|
244
|
+
}, {
|
|
245
|
+
rounds: {
|
|
246
|
+
id: string;
|
|
247
|
+
commit: string;
|
|
248
|
+
base: string;
|
|
249
|
+
points: {
|
|
250
|
+
id: string;
|
|
251
|
+
file: string;
|
|
252
|
+
point: string;
|
|
253
|
+
text?: string | undefined;
|
|
254
|
+
lines?: [number, number] | undefined;
|
|
255
|
+
needs?: string | undefined;
|
|
256
|
+
}[];
|
|
257
|
+
pr?: number | undefined;
|
|
258
|
+
reviewed_at?: string | undefined;
|
|
259
|
+
must_not_flag?: {
|
|
260
|
+
file: string;
|
|
261
|
+
text?: string | undefined;
|
|
262
|
+
lines?: [number, number] | undefined;
|
|
263
|
+
needs?: string | undefined;
|
|
264
|
+
}[] | undefined;
|
|
265
|
+
}[];
|
|
266
|
+
}>;
|
|
267
|
+
export type Ledger = z.infer<typeof LedgerSchema>;
|
|
268
|
+
export type LedgerRound = z.infer<typeof Round>;
|
|
269
|
+
export type LedgerPoint = z.infer<typeof Point>;
|
|
270
|
+
export declare const LEDGER_PATH = ".rigour/backtest.json";
|
|
271
|
+
/** A finding as the score sees it, from a gate or the reviewer. */
|
|
272
|
+
export interface BacktestItem {
|
|
273
|
+
gate: string;
|
|
274
|
+
file: string;
|
|
275
|
+
line: number | undefined;
|
|
276
|
+
text: string;
|
|
277
|
+
blocking: boolean;
|
|
278
|
+
}
|
|
279
|
+
export interface PointOutcome extends Pick<LedgerPoint, 'id' | 'point'> {
|
|
280
|
+
caught: boolean;
|
|
281
|
+
/** Matched by an advisory note only: visible, but it would not have stopped the push. */
|
|
282
|
+
noted: boolean;
|
|
283
|
+
by?: string;
|
|
284
|
+
}
|
|
285
|
+
export interface RoundResult {
|
|
286
|
+
round: string;
|
|
287
|
+
head: string;
|
|
288
|
+
base: string;
|
|
289
|
+
points: PointOutcome[];
|
|
290
|
+
falseBlocks: BacktestItem[];
|
|
291
|
+
items: BacktestItem[];
|
|
292
|
+
durationMs: number;
|
|
293
|
+
/** Why the reviewer gave no verdict, when it ran. */
|
|
294
|
+
reviewerError?: string;
|
|
295
|
+
}
|
|
296
|
+
export interface BacktestOptions {
|
|
297
|
+
round?: string;
|
|
298
|
+
reviewer?: boolean;
|
|
299
|
+
exec?: Exec;
|
|
300
|
+
progress?: Progress;
|
|
301
|
+
/** Produces the findings for a worktree; tests replace it. */
|
|
302
|
+
collect?: (worktree: string, round: LedgerRound, config: Config) => Promise<{
|
|
303
|
+
items: BacktestItem[];
|
|
304
|
+
reviewerError?: string;
|
|
305
|
+
}>;
|
|
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
|
+
export declare function formatBacktest(results: RoundResult[]): string;
|
|
315
|
+
export {};
|
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
const Match = z.object({
|
|
19
|
+
/** A regular expression over the finding's file path. */
|
|
20
|
+
file: z.string(),
|
|
21
|
+
/** A line window at the reviewed commit: a finding inside it matches. */
|
|
22
|
+
lines: z.tuple([z.number().int().positive(), z.number().int().positive()]).optional(),
|
|
23
|
+
/** A regular expression over the finding's text; the alternative for a point with no line. */
|
|
24
|
+
text: z.string().optional(),
|
|
25
|
+
/** Left by `rigour backtest init` on a row a person still has to complete. */
|
|
26
|
+
needs: z.string().optional(),
|
|
27
|
+
});
|
|
28
|
+
const Point = Match.extend({ id: z.string().min(1), point: z.string().min(1) });
|
|
29
|
+
const Round = z.object({
|
|
30
|
+
id: z.string().min(1),
|
|
31
|
+
commit: z.string().min(7),
|
|
32
|
+
base: z.string().min(1),
|
|
33
|
+
/** When the human review was posted: the reviewer sees nothing from then on. */
|
|
34
|
+
reviewed_at: z.string().optional(),
|
|
35
|
+
pr: z.number().int().positive().optional(),
|
|
36
|
+
points: z.array(Point),
|
|
37
|
+
must_not_flag: z.array(Match).default([]),
|
|
38
|
+
});
|
|
39
|
+
export const LedgerSchema = z.object({ rounds: z.array(Round).min(1) });
|
|
40
|
+
export const LEDGER_PATH = '.rigour/backtest.json';
|
|
41
|
+
export function loadLedger(cwd) {
|
|
42
|
+
const file = path.join(cwd, LEDGER_PATH);
|
|
43
|
+
if (!fs.existsSync(file))
|
|
44
|
+
throw new Error(`no ledger at ${LEDGER_PATH}: run \`rigour backtest init --pr <number>\` or write one (docs/BACKTEST.md)`);
|
|
45
|
+
const parsed = LedgerSchema.safeParse(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
46
|
+
if (!parsed.success)
|
|
47
|
+
throw new Error(`${LEDGER_PATH} is not a ledger: ${parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
|
|
48
|
+
const problems = ledgerProblems(parsed.data);
|
|
49
|
+
if (problems.length)
|
|
50
|
+
throw new Error(`${LEDGER_PATH} has rows a person still has to complete:\n ${problems.join('\n ')}`);
|
|
51
|
+
return parsed.data;
|
|
52
|
+
}
|
|
53
|
+
/** Rows that cannot match anything: no file pattern, neither a line window nor a text pattern, or a scaffold mark. */
|
|
54
|
+
export function ledgerProblems(ledger) {
|
|
55
|
+
const problems = [];
|
|
56
|
+
for (const round of ledger.rounds) {
|
|
57
|
+
const rows = [...round.points.map((p) => [p.id, p]), ...round.must_not_flag.map((m, i) => [`must_not_flag[${i}]`, m])];
|
|
58
|
+
for (const [name, row] of rows) {
|
|
59
|
+
if (row.needs)
|
|
60
|
+
problems.push(`${round.id} ${name}: needs ${row.needs}`);
|
|
61
|
+
else if (!row.file)
|
|
62
|
+
problems.push(`${round.id} ${name}: no file pattern`);
|
|
63
|
+
else if (!row.lines && !row.text)
|
|
64
|
+
problems.push(`${round.id} ${name}: needs a line window or a text pattern`);
|
|
65
|
+
else if (!valid(row.file) || (row.text !== undefined && !valid(row.text)))
|
|
66
|
+
problems.push(`${round.id} ${name}: not a regular expression`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return problems;
|
|
70
|
+
}
|
|
71
|
+
function valid(pattern) {
|
|
72
|
+
try {
|
|
73
|
+
new RegExp(pattern, 'i');
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function runBacktest(cwd, config, ledger, options = {}) {
|
|
81
|
+
const rounds = options.round ? ledger.rounds.filter(r => r.id === options.round) : ledger.rounds;
|
|
82
|
+
if (rounds.length === 0)
|
|
83
|
+
throw new Error(`no round "${options.round}" in ${LEDGER_PATH}: ${ledger.rounds.map(r => r.id).join(', ')}`);
|
|
84
|
+
const exec = options.exec ?? defaultExec;
|
|
85
|
+
const progress = options.progress ?? (() => undefined);
|
|
86
|
+
const results = [];
|
|
87
|
+
for (const round of rounds) {
|
|
88
|
+
const started = Date.now();
|
|
89
|
+
const worktree = await worktreeFor(cwd, round.commit, exec);
|
|
90
|
+
const head = (await exec('git', ['rev-parse', 'HEAD'], { cwd: worktree, timeoutMs: GIT_TIMEOUT_MS })).stdout.trim();
|
|
91
|
+
progress(`backtest ${round.id}: ${head.slice(0, 9)} against ${round.base}${round.reviewed_at ? `, reviews hidden from ${round.reviewed_at}` : ''}`);
|
|
92
|
+
const collect = options.collect ?? ((tree, r, c) => collectItems(tree, r, c, !!options.reviewer, exec, progress));
|
|
93
|
+
const { items, reviewerError } = await collect(worktree, round, config);
|
|
94
|
+
const result = score(round, head, items, Date.now() - started, reviewerError);
|
|
95
|
+
record(cwd, result);
|
|
96
|
+
results.push(result);
|
|
97
|
+
}
|
|
98
|
+
return results;
|
|
99
|
+
}
|
|
100
|
+
/** Every point caught, nothing the reviewer called good flagged, and a verdict when the reviewer ran. */
|
|
101
|
+
export function backtestPassed(results) {
|
|
102
|
+
return results.every(r => r.points.every(p => p.caught) && r.falseBlocks.length === 0 && !r.reviewerError);
|
|
103
|
+
}
|
|
104
|
+
export function score(round, head, items, durationMs, reviewerError) {
|
|
105
|
+
const points = round.points.map(point => {
|
|
106
|
+
const hit = items.find(item => item.blocking && matches(point, item));
|
|
107
|
+
const noted = !hit && items.some(item => !item.blocking && matches(point, item));
|
|
108
|
+
return { id: point.id, point: point.point, caught: !!hit, noted, ...(hit ? { by: `${hit.gate} ${hit.file}${hit.line ? `:${hit.line}` : ''}` } : {}) };
|
|
109
|
+
});
|
|
110
|
+
const falseBlocks = items.filter(item => item.blocking && round.must_not_flag.some(guard => matches(guard, item)));
|
|
111
|
+
return { round: round.id, head, base: round.base, points, falseBlocks, items, durationMs, ...(reviewerError ? { reviewerError } : {}) };
|
|
112
|
+
}
|
|
113
|
+
/** The file pattern must match, then either the line window holds the finding's line or the text pattern matches its text. */
|
|
114
|
+
function matches(row, item) {
|
|
115
|
+
if (!new RegExp(row.file, 'i').test(item.file))
|
|
116
|
+
return false;
|
|
117
|
+
const inWindow = !!row.lines && item.line !== undefined && item.line >= row.lines[0] && item.line <= row.lines[1];
|
|
118
|
+
const inText = row.text !== undefined && new RegExp(row.text, 'i').test(item.text);
|
|
119
|
+
return inWindow || inText;
|
|
120
|
+
}
|
|
121
|
+
const GIT_TIMEOUT_MS = 60_000;
|
|
122
|
+
/** A detached worktree at the commit, under the git directory (shared by worktrees), reused on the next run. */
|
|
123
|
+
async function worktreeFor(cwd, commit, exec) {
|
|
124
|
+
const common = (await exec('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd, timeoutMs: GIT_TIMEOUT_MS })).stdout.trim();
|
|
125
|
+
if (!common)
|
|
126
|
+
throw new Error(`${cwd} is not a git repository`);
|
|
127
|
+
const resolved = await exec('git', ['rev-parse', '--verify', `${commit}^{commit}`], { cwd, timeoutMs: GIT_TIMEOUT_MS });
|
|
128
|
+
if (resolved.exitCode !== 0)
|
|
129
|
+
throw new Error(`commit ${commit} is not in this repository (fetch the branch it was reviewed on)`);
|
|
130
|
+
const dir = path.join(common, 'rigour-backtest', resolved.stdout.trim().slice(0, 12));
|
|
131
|
+
if (!fs.existsSync(path.join(dir, '.git'))) {
|
|
132
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
133
|
+
const added = await exec('git', ['worktree', 'add', '--detach', dir, resolved.stdout.trim()], { cwd, timeoutMs: 5 * GIT_TIMEOUT_MS });
|
|
134
|
+
if (added.exitCode !== 0)
|
|
135
|
+
throw new Error(`could not check out ${commit} for the backtest: ${added.stderr.trim()}`);
|
|
136
|
+
}
|
|
137
|
+
shareDependencies(cwd, dir);
|
|
138
|
+
return dir;
|
|
139
|
+
}
|
|
140
|
+
/** The checkout's installed dependencies serve the worktree too (the typed checks need the project's compiler), never a download. */
|
|
141
|
+
function shareDependencies(cwd, worktree) {
|
|
142
|
+
const root = path.join(cwd, 'node_modules');
|
|
143
|
+
if (fs.existsSync(root) && !fs.existsSync(path.join(worktree, 'node_modules')))
|
|
144
|
+
fs.symlinkSync(root, path.join(worktree, 'node_modules'), 'junction');
|
|
145
|
+
}
|
|
146
|
+
async function collectItems(worktree, round, config, reviewer, exec, progress) {
|
|
147
|
+
const review = await reviewChange({ cwd: worktree, config, source: { mode: 'base', base: round.base }, typed: true });
|
|
148
|
+
const items = [
|
|
149
|
+
...review.findings.map(f => asItem(f, true)),
|
|
150
|
+
...[...review.advisory, ...review.contextFindings].map(f => asItem(f, false)),
|
|
151
|
+
];
|
|
152
|
+
if (!reviewer)
|
|
153
|
+
return { items };
|
|
154
|
+
const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: round.reviewed_at, trigger: 'backtest', force: true, hints: review.hints.join('\n') });
|
|
155
|
+
if (result.outcome === 'unavailable' || result.outcome === 'skipped')
|
|
156
|
+
return { items, reviewerError: result.reason ?? result.outcome };
|
|
157
|
+
// The reviewer behind each catch is part of the score, so the gate is `reviewer:<name>`.
|
|
158
|
+
const asReviewerItem = (item, blocking) => ({ gate: `reviewer:${item.reviewer ?? result.reviewers[0]}`, file: item.file ?? '', line: item.line, text: [item.issue, item.evidence].filter(Boolean).join(' '), blocking });
|
|
159
|
+
items.push(...result.items.map(i => asReviewerItem(i, true)), ...result.unverified.map(i => asReviewerItem(i, false)));
|
|
160
|
+
return { items };
|
|
161
|
+
}
|
|
162
|
+
function asItem(failure, blocking) {
|
|
163
|
+
return { gate: failure.id, file: failure.files?.[0] ?? '', line: failure.line, text: [failure.title, failure.details, failure.hint].filter(Boolean).join(' '), blocking };
|
|
164
|
+
}
|
|
165
|
+
/** The score and every item reported, beside the worktrees, so a pattern that missed can be checked against what was there. */
|
|
166
|
+
function record(cwd, result) {
|
|
167
|
+
try {
|
|
168
|
+
const dir = path.join(cwd, '.rigour', 'backtest');
|
|
169
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
170
|
+
fs.writeFileSync(path.join(dir, `${result.round}-${result.head.slice(0, 12)}.json`), JSON.stringify(result, null, 2));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// the score is printed either way
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export function formatBacktest(results) {
|
|
177
|
+
const lines = [];
|
|
178
|
+
for (const r of results) {
|
|
179
|
+
const caught = r.points.filter(p => p.caught).length;
|
|
180
|
+
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)}`);
|
|
181
|
+
for (const p of r.points)
|
|
182
|
+
lines.push(` ${p.caught ? 'caught ' : p.noted ? 'noted ' : 'MISSED '} ${p.id} ${p.point}${p.by ? ` (${p.by})` : p.noted ? ' (advisory only)' : ''}`);
|
|
183
|
+
for (const f of r.falseBlocks)
|
|
184
|
+
lines.push(` FALSE ${f.gate} ${f.file}${f.line ? `:${f.line}` : ''} ${f.text.slice(0, 100)}`);
|
|
185
|
+
if (r.reviewerError)
|
|
186
|
+
lines.push(` NO VERDICT ${r.reviewerError}`);
|
|
187
|
+
}
|
|
188
|
+
return lines.join('\n');
|
|
189
|
+
}
|
|
190
|
+
/** Which gate (or the reviewer) caught what: `; caught by unused-export 2, reviewer 1`, so a rule is judged on its own rows. */
|
|
191
|
+
function byGate(result) {
|
|
192
|
+
const counts = new Map();
|
|
193
|
+
for (const p of result.points)
|
|
194
|
+
if (p.by)
|
|
195
|
+
counts.set(p.by.split(' ')[0], (counts.get(p.by.split(' ')[0]) ?? 0) + 1);
|
|
196
|
+
return counts.size ? `; caught by ${[...counts].map(([gate, n]) => `${gate} ${n}`).join(', ')}` : '';
|
|
197
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { ConfigSchema } from '../types/index.js';
|
|
7
|
+
import { backtestPassed, formatBacktest, ledgerProblems, loadLedger, runBacktest, score, LEDGER_PATH } from './backtest.js';
|
|
8
|
+
const item = (over = {}) => ({ gate: 'unused-export', file: 'src/lib/server/billing/invoices.ts', line: 40, text: 'Unused export `isRefund` is not imported anywhere', blocking: true, ...over });
|
|
9
|
+
describe('a ledger point matches a finding', () => {
|
|
10
|
+
const caught = (row, finding) => score({ id: 'r', commit: 'abcdef0', base: 'main', points: [{ id: 'P', point: 'p', ...row }], must_not_flag: [] }, 'h', [finding], 0, undefined).points[0].caught;
|
|
11
|
+
it('by file, then by line window or text pattern, case-insensitively', () => {
|
|
12
|
+
expect(caught({ file: 'invoices', lines: [30, 50] }, item())).toBe(true);
|
|
13
|
+
expect(caught({ file: 'invoices', lines: [41, 50] }, item())).toBe(false);
|
|
14
|
+
expect(caught({ file: 'invoices', text: 'isrefund|twice' }, item())).toBe(true);
|
|
15
|
+
expect(caught({ file: 'invoices', lines: [41, 50], text: 'isRefund' }, item())).toBe(true); // either is enough
|
|
16
|
+
expect(caught({ file: 'receipts', text: 'isRefund' }, item())).toBe(false); // the file must match
|
|
17
|
+
expect(caught({ file: 'invoices', lines: [30, 50] }, item({ line: undefined }))).toBe(false); // a lineless finding cannot be placed in a window
|
|
18
|
+
});
|
|
19
|
+
it('rejects rows that can match nothing before any round runs', () => {
|
|
20
|
+
const ledger = { rounds: [{ id: 'r1', commit: 'abcdef0', base: 'main', points: [
|
|
21
|
+
{ id: 'A', point: 'p', file: 'x', lines: [1, 2] },
|
|
22
|
+
{ id: 'B', point: 'p', file: '', text: 'x' },
|
|
23
|
+
{ id: 'C', point: 'p', file: 'x' },
|
|
24
|
+
{ id: 'D', point: 'p', file: 'x', text: 'x', needs: 'a text pattern' },
|
|
25
|
+
{ id: 'E', point: 'p', file: '[', text: 'x' },
|
|
26
|
+
], must_not_flag: [{ file: 'x' }] }] };
|
|
27
|
+
expect(ledgerProblems(ledger)).toEqual([
|
|
28
|
+
'r1 B: no file pattern',
|
|
29
|
+
'r1 C: needs a line window or a text pattern',
|
|
30
|
+
'r1 D: needs a text pattern',
|
|
31
|
+
'r1 E: not a regular expression',
|
|
32
|
+
'r1 must_not_flag[0]: needs a line window or a text pattern',
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe('scoring a round', () => {
|
|
37
|
+
const round = {
|
|
38
|
+
id: 'r1', commit: 'abcdef0', base: 'main',
|
|
39
|
+
points: [
|
|
40
|
+
{ id: 'P1', point: 'dead export', file: 'invoices', lines: [30, 50] },
|
|
41
|
+
{ id: 'P2', point: 'copies the accumulator', file: 'totals', text: 'copied|quadratic' },
|
|
42
|
+
{ id: 'P3', point: 'drops the currency', file: 'checkout', text: 'currency' },
|
|
43
|
+
],
|
|
44
|
+
must_not_flag: [{ file: 'session', text: 'cookie' }],
|
|
45
|
+
};
|
|
46
|
+
it('counts a point as caught only by a blocking finding; an advisory match is noted and still missed', () => {
|
|
47
|
+
const items = [item(), item({ gate: 'quadratic-copy', file: 'src/totals.ts', line: 128, text: 'accumulator copied whole', blocking: false }), item({ gate: 'reviewer', file: 'src/hooks/session.ts', line: 53, text: 'the cookie is cleared' })];
|
|
48
|
+
const result = score(round, 'abcdef0123456', items, 1200, undefined);
|
|
49
|
+
expect(result.points).toEqual([
|
|
50
|
+
{ id: 'P1', point: 'dead export', caught: true, noted: false, by: 'unused-export src/lib/server/billing/invoices.ts:40' },
|
|
51
|
+
{ id: 'P2', point: 'copies the accumulator', caught: false, noted: true },
|
|
52
|
+
{ id: 'P3', point: 'drops the currency', caught: false, noted: false },
|
|
53
|
+
]);
|
|
54
|
+
expect(result.falseBlocks).toHaveLength(1);
|
|
55
|
+
expect(backtestPassed([result])).toBe(false);
|
|
56
|
+
expect(formatBacktest([result])).toContain('r1 at abcdef012: 1/3 caught, 1 false block(s), 3 finding(s), 1s; caught by unused-export 1');
|
|
57
|
+
expect(formatBacktest([result])).toContain('noted P2 copies the accumulator (advisory only)');
|
|
58
|
+
expect(formatBacktest([result])).toContain('MISSED P3');
|
|
59
|
+
expect(formatBacktest([result])).toContain('FALSE reviewer src/hooks/session.ts:53');
|
|
60
|
+
});
|
|
61
|
+
it('passes only when every point is caught, nothing good is flagged and the reviewer answered', () => {
|
|
62
|
+
const all = [item(), item({ gate: 'quadratic-copy', file: 'src/totals.ts', text: 'copied whole' }), item({ gate: 'reviewer', file: 'src/checkout.ts', text: 'drops the currency' })];
|
|
63
|
+
expect(backtestPassed([score(round, 'h', all, 1, undefined)])).toBe(true);
|
|
64
|
+
expect(backtestPassed([score(round, 'h', all, 1, 'the reviewer did not answer')])).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('rigour backtest on a repository', () => {
|
|
68
|
+
let repo;
|
|
69
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
|
|
70
|
+
const write = (rel, body) => {
|
|
71
|
+
fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
|
|
72
|
+
fs.writeFileSync(path.join(repo, rel), body);
|
|
73
|
+
};
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'backtest-'));
|
|
76
|
+
git('init', '-q', '-b', 'main');
|
|
77
|
+
git('config', 'user.email', 't@example.com');
|
|
78
|
+
git('config', 'user.name', 't');
|
|
79
|
+
git('config', 'commit.gpgsign', 'false');
|
|
80
|
+
write('src/app.ts', 'export const app = 1;\n');
|
|
81
|
+
git('add', '-A');
|
|
82
|
+
git('commit', '-qm', 'base');
|
|
83
|
+
git('checkout', '-qb', 'feature');
|
|
84
|
+
write('src/lib/util.ts', 'export function helper(): number {\n return 1;\n}\n');
|
|
85
|
+
git('add', '-A');
|
|
86
|
+
git('commit', '-qm', 'add a helper nothing uses');
|
|
87
|
+
});
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
try {
|
|
90
|
+
git('worktree', 'prune');
|
|
91
|
+
}
|
|
92
|
+
catch { /* the repository may be gone */ }
|
|
93
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
94
|
+
});
|
|
95
|
+
it('reviews the reviewed commit in a worktree with the review hidden, scores it, and leaves the checkout alone', async () => {
|
|
96
|
+
const reviewed = git('rev-parse', 'HEAD');
|
|
97
|
+
const base = git('rev-parse', 'main');
|
|
98
|
+
git('checkout', '-q', 'main'); // the person runs it from wherever they are
|
|
99
|
+
write(LEDGER_PATH, JSON.stringify({ rounds: [{
|
|
100
|
+
id: 'r1', commit: reviewed, base,
|
|
101
|
+
points: [
|
|
102
|
+
{ id: 'R1-1', point: 'helper is never imported', file: 'util\\.ts', lines: [1, 3] },
|
|
103
|
+
{ id: 'R1-2', point: 'something no gate knows', file: 'util', text: 'quadratic' },
|
|
104
|
+
],
|
|
105
|
+
must_not_flag: [{ file: 'app\\.ts', lines: [1, 1] }],
|
|
106
|
+
}] }));
|
|
107
|
+
const config = ConfigSchema.parse({ version: 1 });
|
|
108
|
+
const results = await runBacktest(repo, config, loadLedger(repo), { progress: () => undefined });
|
|
109
|
+
expect(results).toHaveLength(1);
|
|
110
|
+
expect(results[0].points).toMatchObject([{ id: 'R1-1', caught: true, by: 'unused-export src/lib/util.ts:1' }, { id: 'R1-2', caught: false, noted: false }]);
|
|
111
|
+
expect(results[0].falseBlocks).toEqual([]);
|
|
112
|
+
expect(results[0].head).toBe(reviewed);
|
|
113
|
+
expect(backtestPassed(results)).toBe(false);
|
|
114
|
+
expect(git('branch', '--show-current')).toBe('main');
|
|
115
|
+
expect(fs.existsSync(path.join(repo, 'src/lib/util.ts'))).toBe(false); // the checkout did not move
|
|
116
|
+
expect(fs.existsSync(path.join(repo, '.git/rigour-backtest', reviewed.slice(0, 12), 'src/lib/util.ts'))).toBe(true);
|
|
117
|
+
expect(fs.readdirSync(path.join(repo, '.rigour/backtest'))).toEqual([`r1-${reviewed.slice(0, 12)}.json`]);
|
|
118
|
+
// The worktree is reused on the next run.
|
|
119
|
+
const again = await runBacktest(repo, config, loadLedger(repo), { round: 'r1', progress: () => undefined });
|
|
120
|
+
expect(again[0].points[0].caught).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
it('names a round or a commit it cannot find', async () => {
|
|
123
|
+
const config = ConfigSchema.parse({ version: 1 });
|
|
124
|
+
const ledger = { rounds: [{ id: 'r1', commit: '0000000000000000000000000000000000000000', base: 'main', points: [{ id: 'P', point: 'p', file: 'x', text: 'x' }], must_not_flag: [] }] };
|
|
125
|
+
await expect(runBacktest(repo, config, ledger, { round: 'r9' })).rejects.toThrow('no round "r9"');
|
|
126
|
+
await expect(runBacktest(repo, config, ledger, { progress: () => undefined })).rejects.toThrow('is not in this repository');
|
|
127
|
+
expect(() => loadLedger(repo)).toThrow('no ledger');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -35,6 +35,14 @@ describe('unused exports', () => {
|
|
|
35
35
|
expect(failures.map(f => [f.files?.[0], f.line, f.id])).toEqual([['src/app.ts', 4, 'unused-export']]);
|
|
36
36
|
expect(failures[0].details).toContain('`lonely`');
|
|
37
37
|
});
|
|
38
|
+
it('does not count a test as a consumer: an export only its tests import is dead in production', () => {
|
|
39
|
+
write('src/app.ts', 'export function start() {}\n\nexport function onlyTested() { return 1; }\nexport function used() { return 2; }\n');
|
|
40
|
+
write('src/app.test.ts', "import { onlyTested, used } from './app';\nonlyTested();\nused();\n");
|
|
41
|
+
write('tests/helpers.ts', "import { onlyTested } from '../src/app';\nonlyTested();\n");
|
|
42
|
+
write('src/use.ts', "import { used } from './app';\nused();\n");
|
|
43
|
+
const names = unusedExportFailures(repo, diffFromGit(repo), config()).map(f => f.details.match(/`([^`]+)`/)[1]);
|
|
44
|
+
expect(names).toEqual(['onlyTested']);
|
|
45
|
+
});
|
|
38
46
|
it('reports a re-export nothing imports, without counting the module it comes from as a use', () => {
|
|
39
47
|
write('src/types.ts', 'export type Row = { id: string };\nexport type Used = { n: number };\n');
|
|
40
48
|
write('src/index.ts', "import { start } from './app';\nstart();\nexport type { Row, Used } from './types';\n");
|