@rigour-labs/core 6.7.1 → 6.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/hooks/index.d.ts +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/hooks/session-state.d.ts +3 -0
- package/dist/hooks/session-state.js +10 -0
- package/dist/hooks/stop-review.d.ts +8 -15
- package/dist/hooks/stop-review.js +36 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/review/backtest-init.js +13 -11
- package/dist/review/backtest-init.test.js +26 -2
- package/dist/review/backtest.js +35 -3
- package/dist/review/backtest.test.js +44 -2
- package/dist/review/dead-code.test.js +13 -0
- package/dist/review/orphan-files.js +3 -2
- package/dist/review/quiet.d.ts +1 -1
- package/dist/review/quiet.js +3 -1
- package/dist/review/review-task.d.ts +2 -6
- package/dist/review/review-task.js +2 -2
- package/dist/review/review.js +19 -4
- package/dist/review/review.test.js +11 -0
- package/dist/review/reviewer/adapters.d.ts +21 -0
- package/dist/review/reviewer/adapters.js +70 -15
- package/dist/review/reviewer/adapters.test.js +22 -0
- package/dist/review/reviewer/background.test.js +1 -1
- package/dist/review/reviewer/context.d.ts +3 -0
- package/dist/review/reviewer/context.js +14 -5
- package/dist/review/reviewer/context.test.js +26 -1
- package/dist/review/reviewer/exec.d.ts +9 -0
- package/dist/review/reviewer/exec.js +28 -1
- package/dist/review/reviewer/exec.test.js +38 -2
- package/dist/review/reviewer/inputs.d.ts +6 -0
- package/dist/review/reviewer/inputs.js +26 -0
- package/dist/review/reviewer/judge-env.d.ts +3 -0
- package/dist/review/reviewer/judge-env.js +10 -0
- package/dist/review/reviewer/prompt.js +68 -12
- package/dist/review/reviewer/rule-writer.d.ts +10 -0
- package/dist/review/reviewer/rule-writer.js +30 -0
- package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
- package/dist/review/reviewer/rule-writer.test.js +52 -0
- package/dist/review/reviewer/settings.d.ts +4 -0
- package/dist/review/reviewer/settings.js +2 -1
- package/dist/review/reviewer/verdict.d.ts +71 -9
- package/dist/review/reviewer/verdict.js +102 -10
- package/dist/review/reviewer.d.ts +5 -2
- package/dist/review/reviewer.js +56 -41
- package/dist/review/reviewer.test.js +170 -17
- package/dist/review/typed/constant-inputs.d.ts +35 -0
- package/dist/review/typed/constant-inputs.js +168 -0
- package/dist/review/typed/dead-null-guards.d.ts +34 -0
- package/dist/review/typed/dead-null-guards.js +147 -0
- package/dist/review/typed/redundancy.js +6 -0
- package/dist/review/typed/redundancy.test.js +136 -0
- package/dist/review/unused-exports.js +3 -2
- package/dist/review-learning/acted-on.d.ts +23 -3
- package/dist/review-learning/acted-on.js +25 -13
- package/dist/review-learning/human-edits.d.ts +7 -0
- package/dist/review-learning/human-edits.js +99 -0
- package/dist/review-learning/human-edits.test.d.ts +1 -0
- package/dist/review-learning/human-edits.test.js +47 -0
- package/dist/review-learning/learn-from-reviews.d.ts +37 -4
- package/dist/review-learning/learn-from-reviews.js +128 -12
- package/dist/review-learning/lessons.d.ts +62 -9
- package/dist/review-learning/lessons.js +122 -20
- package/dist/review-learning/outcomes.d.ts +26 -0
- package/dist/review-learning/outcomes.js +73 -0
- package/dist/review-learning/review-learning.test.js +149 -22
- package/dist/review-learning/review-points.d.ts +4 -0
- package/dist/review-learning/review-points.js +14 -0
- package/dist/review-learning/rules-from-reviews.d.ts +14 -0
- package/dist/review-learning/rules-from-reviews.js +86 -0
- package/dist/review-learning/team-lessons.d.ts +17 -1
- package/dist/review-learning/team-lessons.js +29 -5
- package/dist/services/temporal-drift.js +1 -1
- package/dist/storage/db.d.ts +4 -6
- package/dist/storage/db.js +63 -90
- package/dist/storage/db.test.d.ts +1 -0
- package/dist/storage/db.test.js +46 -0
- package/dist/templates/universal-config.js +3 -0
- package/dist/types/index.d.ts +63 -0
- package/dist/types/index.js +11 -0
- package/dist/utils/profile.d.ts +5 -1
- package/dist/utils/profile.js +7 -1
- package/dist/utils/profile.test.js +10 -0
- package/package.json +8 -9
package/dist/review/reviewer.js
CHANGED
|
@@ -23,7 +23,7 @@ import os from 'os';
|
|
|
23
23
|
import path from 'path';
|
|
24
24
|
import { ADAPTERS, isReviewerName, resolveAdapter, selectReviewers, vendorsOf } from './reviewer/adapters.js';
|
|
25
25
|
import { defaultExec, GH_TIMEOUT_MS, githubEnv } from './reviewer/exec.js';
|
|
26
|
-
import { findPullRequest, ghFor, humanReviews, linesChanged, mergesBaseIn, rulesText, sha } from './reviewer/inputs.js';
|
|
26
|
+
import { bodyAsOf, findPullRequest, ghFor, humanReviews, linesChanged, mergesBaseIn, rulesText, sha } from './reviewer/inputs.js';
|
|
27
27
|
import { mergeImpact } from './reviewer/merge-impact.js';
|
|
28
28
|
import { applyPanel, parseAnswers, runPanel } from './reviewer/panel.js';
|
|
29
29
|
import { crossExamPrompt, deltaBlock, mergeBlock, PROMPT_VERSION, renderPrompt } from './reviewer/prompt.js';
|
|
@@ -32,8 +32,9 @@ import { VerdictStore } from './reviewer/store.js';
|
|
|
32
32
|
import { trackUsage } from '../telemetry/telemetry.js';
|
|
33
33
|
import { reviewerUsage } from './reviewer/usage.js';
|
|
34
34
|
import { buildContext, dismissedAs, readReviewDismissals, relatedDocs } from './reviewer/context.js';
|
|
35
|
-
import { account, carryResolved, evidenceTouched, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
36
|
-
|
|
35
|
+
import { account, checkoutVerifier, carryResolved, evidenceTouched, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
36
|
+
import { judgeUnset } from './reviewer/judge-env.js';
|
|
37
|
+
export { defaultExec, githubEnv, githubToken, parseJsonArrays } from './reviewer/exec.js';
|
|
37
38
|
export { itemLine } from './reviewer/verdict.js';
|
|
38
39
|
/** Findings and no verdict stop a push; a skipped review does not, and is reported as owed. */
|
|
39
40
|
export function reviewerBlocks(result) {
|
|
@@ -57,11 +58,13 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
57
58
|
const branch = options.branch ?? await git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
58
59
|
const repoRoot = (await git(['rev-parse', '--show-toplevel'])) || cwd;
|
|
59
60
|
let attempts;
|
|
61
|
+
// What was asked, from the first line: every verdict, a review that never ran included, says what was asked and what ran.
|
|
62
|
+
let modeRecord = askedOnly(settings);
|
|
60
63
|
// A review that ends without a verdict says why where the person looks (status, Studio, MCP), not only in a log.
|
|
61
64
|
const none = (outcome, reason, extra = {}) => {
|
|
62
65
|
if (attempts && branch !== 'HEAD')
|
|
63
66
|
attempts.recordAttempt(branch, { head, outcome, reason, at: new Date().toISOString() });
|
|
64
|
-
return { outcome, items: [], unverified: [], resolved: [], answerInReply: [], notes: [], disputed: [], dropped: [], dismissed: [], reason, reviewers: [], cached: false, ...extra };
|
|
67
|
+
return { outcome, items: [], unverified: [], resolved: [], answerInReply: [], notes: [], disputed: [], dropped: [], dismissed: [], reason, reviewers: [], cached: false, ...extra, mode: { ...modeRecord, ...extra.mode, ran: 'none' } };
|
|
65
68
|
};
|
|
66
69
|
if (!head || !baseSha)
|
|
67
70
|
return none('unavailable', `not a repository, or ${base} is unknown`);
|
|
@@ -81,12 +84,12 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
81
84
|
let reviewers = selectReviewers(candidates, mode, authors, new Set(installed.keys()), settings.judges);
|
|
82
85
|
if (reviewers.length === 0)
|
|
83
86
|
return none('unavailable', `no reviewer installed: ${candidates.map(n => ADAPTERS[n].binary).join(', ') || 'review.reviewer.reviewers is empty'}`);
|
|
84
|
-
|
|
87
|
+
modeRecord = modeRan(settings, reviewers, candidates, installed);
|
|
85
88
|
if (reviewers.length < 2 && mode === 'full' && (settings.required.panel || settings.required.mode)) {
|
|
86
89
|
return none('unavailable', `rigour.yml requires two reviewers from different vendors, and ${modeRecord.degraded}`, { reviewers, mode: modeRecord });
|
|
87
90
|
}
|
|
88
|
-
const gh = ghFor(cwd, exec, await githubEnv(cwd, config.review?.github_account ?? process.env.RIGOUR_GITHUB_ACCOUNT, exec));
|
|
89
|
-
const found = await findPullRequest(gh, branch, head, options.pr);
|
|
91
|
+
const gh = options.blind ? undefined : ghFor(cwd, exec, await githubEnv(cwd, config.review?.github_account ?? process.env.RIGOUR_GITHUB_ACCOUNT, exec));
|
|
92
|
+
const found = gh ? await findPullRequest(gh, branch, head, options.pr) : {};
|
|
90
93
|
if (found.error)
|
|
91
94
|
return none('unavailable', found.error, { reviewers });
|
|
92
95
|
const pr = found.pr;
|
|
@@ -96,7 +99,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
96
99
|
return none('skipped', skip, { reviewers, pr: pr?.number });
|
|
97
100
|
}
|
|
98
101
|
let reviews = { markdown: 'none\n', key: '', count: 0 };
|
|
99
|
-
if (pr) {
|
|
102
|
+
if (gh && pr) {
|
|
100
103
|
const read = await humanReviews(gh, pr, options.reviewsBefore);
|
|
101
104
|
if (read.error)
|
|
102
105
|
return none('unavailable', read.error, { reviewers, pr: pr.number });
|
|
@@ -105,7 +108,10 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
105
108
|
const stateRoot = options.stateRoot ?? cwd;
|
|
106
109
|
// Dismissals count only where the team allows them; otherwise the file, if any, is ignored.
|
|
107
110
|
const dismissals = settings.dismissals ? readReviewDismissals(stateRoot) : [];
|
|
108
|
-
|
|
111
|
+
// A backtest sees the description as it read at the review, never a later edit describing later code.
|
|
112
|
+
const body = gh && pr && options.reviewsBefore
|
|
113
|
+
? (await bodyAsOf(gh, pr.number, options.reviewsBefore)) ?? '(the description as of this review could not be recovered: judge claims against the code and its comments only)\n'
|
|
114
|
+
: pr?.body || '(no pull request description)\n';
|
|
109
115
|
const rules = rulesText(cwd);
|
|
110
116
|
const previous = branch !== 'HEAD' ? store.branchState(branch) : undefined;
|
|
111
117
|
// The same commit, asked again with the same settings and reviews (the background run, then the person): the verdict it already has.
|
|
@@ -122,7 +128,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
122
128
|
const sincePrevious = previousIsAncestor ? new Set((await git(['diff', '--name-only', `${previous.head}..HEAD`])).split('\n').filter(Boolean)) : new Set();
|
|
123
129
|
const changedFiles = [...fullDiff.matchAll(/^diff --git a\/.* b\/(.*)$/gm)].map(m => m[1]);
|
|
124
130
|
const context = buildContext({
|
|
125
|
-
cwd, stateRoot, dismissals, diff: fullDiff, router: config.gates.deep?.router, touched: sincePrevious, checks: options.checks ?? [],
|
|
131
|
+
cwd, stateRoot, dismissals, diff: fullDiff, router: config.gates.deep?.router, lessons: config.gates.deep?.review_lessons, touched: sincePrevious, checks: options.checks ?? [],
|
|
126
132
|
previousPanel: previousIsAncestor ? store.readJson(previous.verdict)?.panel?.items : undefined,
|
|
127
133
|
docs: await relatedDocs(cwd, changedFiles, exec),
|
|
128
134
|
});
|
|
@@ -167,7 +173,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
167
173
|
const verdictFile = store.verdictPath(head, fingerprint);
|
|
168
174
|
const openFile = store.openPath(verdictFile);
|
|
169
175
|
const previousOpen = scope === 'delta' ? store.readJson(store.openPath(previous.verdict)) ?? [] : undefined;
|
|
170
|
-
const verify =
|
|
176
|
+
const verify = checkoutVerifier(cwd);
|
|
171
177
|
if (!options.force && fs.existsSync(verdictFile) && fs.existsSync(openFile)) {
|
|
172
178
|
const verdict = store.readJson(verdictFile);
|
|
173
179
|
return result(decide(verdict, previousOpen, verify, dismissals), verdict, reviewers, scope, why, true, reviews, pr, modeRecord, settings.dismissals);
|
|
@@ -217,18 +223,28 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
217
223
|
try {
|
|
218
224
|
const answers = await Promise.all(reviewers.map(async (name) => {
|
|
219
225
|
const adapter = ADAPTERS[name];
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
? parseVerdict(answer.text, needsPriorPoints, name, answer)
|
|
226
|
-
|
|
226
|
+
const ask = async () => {
|
|
227
|
+
const run = await exec(installed.get(name).binary, adapter.args(prompt, modelFor(name)), { cwd, timeoutMs: settings.timeout_ms, unset: judgeUnset(name, settings.judge_env) });
|
|
228
|
+
progress(`Rigour reviewer: ${name} finished in ${Math.round((Date.now() - started) / 1000)}s (exit ${run.exitCode})`);
|
|
229
|
+
const answer = adapter.answer(run.stdout);
|
|
230
|
+
store.addSpend(1, answer.costUsd); // every run counts against the caps, an answer or not
|
|
231
|
+
return { run, answer, verdict: run.exitCode === 0 || answer.text.trim() ? parseVerdict(answer.text, needsPriorPoints, name, answer) : undefined };
|
|
232
|
+
};
|
|
233
|
+
let first = await ask();
|
|
234
|
+
// An answer that is not a verdict is a slip, not a decision: asked once more, inside the caps, before the review is unavailable.
|
|
235
|
+
if (first.verdict && 'error' in first.verdict && !overBudget(store.spend(), settings, 1)) {
|
|
236
|
+
progress(`Rigour reviewer: ${name} gave no valid verdict; asking once more`);
|
|
237
|
+
first = await ask();
|
|
238
|
+
}
|
|
239
|
+
return first.verdict ?? { error: `${name}: no answer (exit ${first.run.exitCode}): ${first.run.stderr.trim().slice(-200)}` };
|
|
227
240
|
}));
|
|
228
241
|
const failed = answers.find(a => 'error' in a);
|
|
229
242
|
if (failed && 'error' in failed)
|
|
230
243
|
return none('unavailable', failed.error, { reviewers, scope, why, pr: pr?.number });
|
|
231
244
|
parts = answers.map(a => a.verdict);
|
|
245
|
+
for (const part of parts)
|
|
246
|
+
if (part.trace)
|
|
247
|
+
labelReads(part.trace, work, changedFiles);
|
|
232
248
|
}
|
|
233
249
|
finally {
|
|
234
250
|
clearInterval(ticker);
|
|
@@ -260,7 +276,7 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
260
276
|
},
|
|
261
277
|
ask: async (judge, asked) => {
|
|
262
278
|
const name = judge;
|
|
263
|
-
const run = await exec(installed.get(name).binary, ADAPTERS[name].args(crossExamPrompt(repoRoot, head.slice(0, 9), diffFile, asked), settings.cross_models[name] ?? modelFor(name)), { cwd, timeoutMs: settings.timeout_ms });
|
|
279
|
+
const run = await exec(installed.get(name).binary, ADAPTERS[name].args(crossExamPrompt(repoRoot, head.slice(0, 9), diffFile, asked), settings.cross_models[name] ?? modelFor(name)), { cwd, timeoutMs: settings.timeout_ms, unset: judgeUnset(name, settings.judge_env) });
|
|
264
280
|
const answer = ADAPTERS[name].answer(run.stdout);
|
|
265
281
|
store.addSpend(1, answer.costUsd);
|
|
266
282
|
reserved--;
|
|
@@ -320,16 +336,20 @@ function escalationFor(humanReviews, risky) {
|
|
|
320
336
|
? { escalate: true, why: `${risky} risky changed function(s)` }
|
|
321
337
|
: { escalate: false, why: 'no risky changed function and no human review: one judge (review.reviewer.escalate: risk)' };
|
|
322
338
|
}
|
|
323
|
-
/**
|
|
324
|
-
function
|
|
339
|
+
/** What was asked and where the choice came from, before anything has run. */
|
|
340
|
+
function askedOnly(settings) {
|
|
325
341
|
const asked = settings.panel ? 'panel' : settings.mode;
|
|
326
|
-
const refused = settings.refused.length ? { refused: settings.refused } : {};
|
|
327
342
|
const source = settings.panel ? settings.source.panel : settings.source.mode;
|
|
343
|
+
return { asked, ran: asked, source, ...(settings.refused.length ? { refused: settings.refused } : {}) };
|
|
344
|
+
}
|
|
345
|
+
/** The mode asked for and the one the installed reviewers allow: a panel or full review needs two vendors. */
|
|
346
|
+
function modeRan(settings, reviewers, candidates, installed) {
|
|
347
|
+
const record = askedOnly(settings);
|
|
328
348
|
if (settings.mode !== 'full' || reviewers.length >= settings.judges)
|
|
329
|
-
return
|
|
349
|
+
return record;
|
|
330
350
|
const missing = candidates.filter(name => !installed.has(name)).map(name => ADAPTERS[name].binary);
|
|
331
351
|
const degraded = `${settings.judges} judges asked, ${reviewers.join(' and ')} could run (${missing.length ? `not installed: ${missing.join(', ')}` : 'no other vendor in review.reviewer.reviewers'})`;
|
|
332
|
-
return {
|
|
352
|
+
return { ...record, ran: reviewers.length > 1 ? record.asked : 'single', degraded };
|
|
333
353
|
}
|
|
334
354
|
/**
|
|
335
355
|
* Before any checkout or typed review: whether this push gets a model review at all (an open, ready
|
|
@@ -344,6 +364,18 @@ export async function pushReviewSkip(cwd, branch, head, config, exec = defaultEx
|
|
|
344
364
|
return skipReason(settings.on_push, branch, found.pr);
|
|
345
365
|
}
|
|
346
366
|
/** Why no model is asked at this push: `review.reviewer.on_push` and whether someone will read the push (an open, non-draft pull request). */
|
|
367
|
+
/** What each tool call of a run read: one of Rigour's own input files, a file the change touched, another file, git, or a search. */
|
|
368
|
+
function labelReads(trace, work, changed) {
|
|
369
|
+
const touched = new Set(changed);
|
|
370
|
+
for (const call of trace.calls) {
|
|
371
|
+
const target = call.target.replace(/\\/g, '/');
|
|
372
|
+
call.category = target.startsWith(work.replace(/\\/g, '/')) ? 'rigour-input'
|
|
373
|
+
: call.tool === 'Bash' && /^git\b/.test(target) ? 'git'
|
|
374
|
+
: call.tool === 'Grep' || call.tool === 'Glob' ? 'search'
|
|
375
|
+
: call.tool === 'Read' ? ([...touched].some(f => target.endsWith(`/${f}`) || target === f) ? 'changed-file' : 'other-file')
|
|
376
|
+
: 'other';
|
|
377
|
+
}
|
|
378
|
+
}
|
|
347
379
|
function skipReason(onPush, branch, pr) {
|
|
348
380
|
if (onPush === 'off')
|
|
349
381
|
return 'review.reviewer.on_push is off: run `rigour review --reviewer`';
|
|
@@ -355,23 +387,6 @@ function skipReason(onPush, branch, pr) {
|
|
|
355
387
|
return `pull request #${pr.number} is ${pr.state}`;
|
|
356
388
|
return undefined;
|
|
357
389
|
}
|
|
358
|
-
/** A file in the checkout, and a line it has: an item naming anything else is a reviewer's slip. */
|
|
359
|
-
function verifier(cwd) {
|
|
360
|
-
const lengths = new Map();
|
|
361
|
-
return (file, line) => {
|
|
362
|
-
const target = path.join(cwd, file);
|
|
363
|
-
if (!lengths.has(file)) {
|
|
364
|
-
try {
|
|
365
|
-
lengths.set(file, fs.readFileSync(target, 'utf8').split('\n').length);
|
|
366
|
-
}
|
|
367
|
-
catch {
|
|
368
|
-
lengths.set(file, -1);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
const length = lengths.get(file);
|
|
372
|
-
return length >= 0 && (line === undefined || line <= length);
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
390
|
function result(accounted, verdict, reviewers, scope, why, cached, reviews, pr, mode, dismissable) {
|
|
376
391
|
const cost = (verdict.reviewers ?? []).map(r => r.cost_usd).filter((c) => typeof c === 'number');
|
|
377
392
|
const used = (verdict.reviewers ?? []).map(r => r.tokens).filter((t) => !!t);
|
|
@@ -8,7 +8,7 @@ import { reviewerBlocks, runReviewer } from './reviewer.js';
|
|
|
8
8
|
import { dismissReviewerFinding } from './reviewer/context.js';
|
|
9
9
|
import { reviewStatus } from './reviewer/background.js';
|
|
10
10
|
import { selectReviewers, vendorsOf } from './reviewer/adapters.js';
|
|
11
|
-
import { account, carryResolved, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
11
|
+
import { account, carryResolved, checkoutVerifier, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
12
12
|
let repo;
|
|
13
13
|
const config = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'] } } });
|
|
14
14
|
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
|
|
@@ -49,6 +49,7 @@ function fakes(answer, seen, pr = PR) {
|
|
|
49
49
|
return (seen.installed ?? ['claude', 'cursor-agent']).includes(binary) ? { exitCode: 0, stdout: `${seen.versions?.[command] ?? '1.0.0'}\n`, stderr: '' } : { exitCode: 127, stdout: '', stderr: 'not found' };
|
|
50
50
|
seen.ran.push(command);
|
|
51
51
|
(seen.args ??= []).push(args);
|
|
52
|
+
(seen.unset ??= []).push(options.unset);
|
|
52
53
|
const name = binary === 'claude' ? 'claude' : binary === 'cursor-agent' ? 'cursor' : 'codex';
|
|
53
54
|
const prompt = binary === 'claude' ? args[args.indexOf('-p') + 1] : args[args.length - 1];
|
|
54
55
|
seen.prompts.push(prompt);
|
|
@@ -99,7 +100,7 @@ afterEach(() => {
|
|
|
99
100
|
describe('the reviewer', () => {
|
|
100
101
|
it('works from every human review with inline comments and the description, written to files, and blocks on an open point', async () => {
|
|
101
102
|
const seen = seenNow();
|
|
102
|
-
const answer = JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: false, evidence: 'src/job.ts:2' }] });
|
|
103
|
+
const answer = JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: false, evidence: 'src/job.ts:2', file: 'src/job.ts', line: 2, quote: 'export function job() {' }] });
|
|
103
104
|
const result = await runReviewer(repo, 'main', config, fakes(() => answer, seen), () => undefined);
|
|
104
105
|
expect(seen.files['previous-reviews.md']).toContain('Review by senior');
|
|
105
106
|
expect(seen.files['previous-reviews.md']).toContain('- 2026-10-03 src/job.ts:12: Check the lock before the first read.');
|
|
@@ -115,7 +116,7 @@ describe('the reviewer', () => {
|
|
|
115
116
|
});
|
|
116
117
|
it('caches the verdict per commit and inputs, so the same push is free, and the next commit gets a delta review that carries what is not accounted for', async () => {
|
|
117
118
|
const seen = seenNow();
|
|
118
|
-
const open = JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', consequence: 'a second run reads stale rows', why: 'x' }] });
|
|
119
|
+
const open = JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', quote: 'export function job() {', consequence: 'a second run reads stale rows', why: 'x' }] });
|
|
119
120
|
const first = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined, { checks: ['src/job.ts:1 Unused export `job`'] });
|
|
120
121
|
expect(first.items).toHaveLength(1);
|
|
121
122
|
const again = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined, { checks: ['src/job.ts:1 Unused export `job`'] });
|
|
@@ -138,7 +139,7 @@ describe('the reviewer', () => {
|
|
|
138
139
|
});
|
|
139
140
|
it('resolves a previous item only with evidence, and never blocks on an item that names code the checkout does not have', async () => {
|
|
140
141
|
const seen = seenNow();
|
|
141
|
-
const first = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', consequence: 'a second run reads stale rows' }, { class: 'dead-code', file: 'src/ghost.ts', line: 1, issue: 'unused', consequence: 'a second run reads stale rows' }, { class: 'dead-code', file: '', issue: 'somewhere, no file named', consequence: 'a second run reads stale rows' }] }), seen), () => undefined);
|
|
142
|
+
const first = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', quote: 'export function job() {', consequence: 'a second run reads stale rows' }, { class: 'dead-code', file: 'src/ghost.ts', line: 1, issue: 'unused', consequence: 'a second run reads stale rows' }, { class: 'dead-code', file: '', issue: 'somewhere, no file named', consequence: 'a second run reads stale rows' }] }), seen), () => undefined);
|
|
142
143
|
expect(first.items.map(i => i.file)).toEqual(['src/job.ts']);
|
|
143
144
|
expect(first.unverified.map(i => i.file)).toEqual(['src/ghost.ts', '']); // a slip and a finding with no place to check: shown, never a block
|
|
144
145
|
const id = first.items[0].id;
|
|
@@ -170,6 +171,32 @@ describe('the reviewer', () => {
|
|
|
170
171
|
expect(seen.files['previous-reviews.md']).toContain('Review by senior');
|
|
171
172
|
expect(shown).toMatchObject({ outcome: 'unavailable', reason: 'claude did not report on the human reviews' });
|
|
172
173
|
});
|
|
174
|
+
it('for a backtest, gives the description as it read at the review, never a later edit', async () => {
|
|
175
|
+
const versions = { lastEditedAt: '2026-10-06T00:00:00Z', body: 'today: refunds are issued by the nightly job', userContentEdits: { totalCount: 3, nodes: [
|
|
176
|
+
{ editedAt: '2026-10-06T00:00:00Z', diff: 'today: refunds are issued by the nightly job' },
|
|
177
|
+
{ editedAt: '2026-10-02T00:00:00Z', diff: 'then: refunds are issued on request' },
|
|
178
|
+
{ editedAt: '2026-09-30T00:00:00Z', diff: 'first draft' },
|
|
179
|
+
] } };
|
|
180
|
+
const withEdits = (answer, seen, graphql) => {
|
|
181
|
+
const base = fakes(answer, seen);
|
|
182
|
+
return async (command, args, options) => command === 'gh' && args[0] === 'api' && args[1] === 'graphql'
|
|
183
|
+
? { exitCode: graphql ? 0 : 1, stdout: JSON.stringify({ data: { repository: { pullRequest: graphql } } }), stderr: '' }
|
|
184
|
+
: base(command, args, options);
|
|
185
|
+
};
|
|
186
|
+
const reply = () => JSON.stringify({ ...EMPTY, prior_points: [] });
|
|
187
|
+
const seen = seenNow();
|
|
188
|
+
await runReviewer(repo, 'main', config, withEdits(reply, seen, versions), () => undefined, { pr: 42, reviewsBefore: '2026-10-03T00:00:00Z', force: true });
|
|
189
|
+
expect(seen.files['pr-description.md']).toBe('then: refunds are issued on request');
|
|
190
|
+
await runReviewer(repo, 'main', config, withEdits(reply, seen, null), () => undefined, { pr: 42, reviewsBefore: '2026-10-03T00:00:00Z', force: true });
|
|
191
|
+
expect(seen.files['pr-description.md']).toContain('could not be recovered');
|
|
192
|
+
});
|
|
193
|
+
it('blind, reviews the commit alone and never asks GitHub', async () => {
|
|
194
|
+
const seen = seenNow();
|
|
195
|
+
const result = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [] }), seen), () => undefined, { blind: true, trigger: 'backtest', force: true });
|
|
196
|
+
expect(result.outcome).toBe('passed');
|
|
197
|
+
expect(seen.ghArgs).toEqual([]);
|
|
198
|
+
expect(seen.files['previous-reviews.md']).toBe('none\n');
|
|
199
|
+
});
|
|
173
200
|
it('never passes without a verdict: a crash, a malformed answer, an unreadable pull request or no installed reviewer', async () => {
|
|
174
201
|
const crashed = await runReviewer(repo, 'main', config, fakes(() => ({ exitCode: 1, stdout: '', stderr: 'API error' }), seenNow()), () => undefined);
|
|
175
202
|
expect(crashed).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('claude: no answer (exit 1)') });
|
|
@@ -182,9 +209,51 @@ describe('the reviewer', () => {
|
|
|
182
209
|
for (const result of [crashed, prose])
|
|
183
210
|
expect(reviewerBlocks(result)).toBe(true);
|
|
184
211
|
});
|
|
212
|
+
it('keeps where each judge run spent its tokens and what it read, labelled, in the local verdict', async () => {
|
|
213
|
+
const seen = seenNow();
|
|
214
|
+
const base = fakes(() => JSON.stringify(EMPTY), seen);
|
|
215
|
+
const streaming = async (command, args, options) => {
|
|
216
|
+
if (path.basename(command).replace(/\.(cmd|exe)$/, '') !== 'claude' || args[0] === '--version')
|
|
217
|
+
return base(command, args, options); // claude.cmd on Windows
|
|
218
|
+
const prompt = args[args.indexOf('-p') + 1];
|
|
219
|
+
const diff = /(\S+full\.diff)/.exec(prompt)[1];
|
|
220
|
+
const call = (id, name, input) => ({ type: 'assistant', message: { id: `m-${id}`, usage: { input_tokens: 1, cache_read_input_tokens: 100, cache_creation_input_tokens: 10, output_tokens: 5 }, content: [{ type: 'tool_use', id, name, input }] } });
|
|
221
|
+
const events = [
|
|
222
|
+
call('1', 'Read', { file_path: diff }), call('2', 'Read', { file_path: path.join(repo, 'src/job.ts') }), call('3', 'Read', { file_path: path.join(repo, 'a.ts') }),
|
|
223
|
+
call('4', 'Bash', { command: 'git log -3' }), call('5', 'Grep', { pattern: 'job' }),
|
|
224
|
+
{ type: 'result', result: JSON.stringify(EMPTY), total_cost_usd: 0.2, usage: { input_tokens: 5, output_tokens: 25 } },
|
|
225
|
+
];
|
|
226
|
+
return { exitCode: 0, stdout: events.map(e => JSON.stringify(e)).join('\n'), stderr: '' };
|
|
227
|
+
};
|
|
228
|
+
await runReviewer(repo, 'main', config, streaming, () => undefined, { force: true });
|
|
229
|
+
const store = path.join(repo, '.git', 'rigour-reviewer');
|
|
230
|
+
const verdict = fs.readdirSync(store).filter(f => /^[0-9a-f]{40}\.[0-9a-f]{8}\.json$/.test(f)).map(f => JSON.parse(fs.readFileSync(path.join(store, f), 'utf8')))[0];
|
|
231
|
+
expect(verdict.reviewers[0].trace).toMatchObject({ turns: 5, usage: { input: 5, cacheRead: 500, cacheWrite: 50, output: 25 } });
|
|
232
|
+
expect(verdict.reviewers[0].trace.calls.map((c) => c.category)).toEqual(['rigour-input', 'changed-file', 'other-file', 'git', 'search']);
|
|
233
|
+
});
|
|
234
|
+
it('asks a judge once more after an answer that is not a verdict, and is unavailable only when the second is not one either', async () => {
|
|
235
|
+
const seen = seenNow();
|
|
236
|
+
let calls = 0;
|
|
237
|
+
const slipOnce = await runReviewer(repo, 'main', config, fakes(() => (++calls === 1 ? '{"prior_points":[], "findings":[{"class"' : JSON.stringify(EMPTY)), seen), () => undefined, { force: true });
|
|
238
|
+
expect(slipOnce.outcome).toBe('passed');
|
|
239
|
+
expect(seen.prompts).toHaveLength(2);
|
|
240
|
+
const twice = seenNow();
|
|
241
|
+
const slipTwice = await runReviewer(repo, 'main', config, fakes(() => 'not json', twice), () => undefined, { force: true });
|
|
242
|
+
expect(slipTwice).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no valid verdict') });
|
|
243
|
+
expect(twice.prompts).toHaveLength(2); // once more, never a loop
|
|
244
|
+
});
|
|
245
|
+
it('says what was asked and that nothing ran when a review ends early, with why a judge is missing', async () => {
|
|
246
|
+
const seen = { ...seenNow(), installed: ['claude'] }; // cursor is listed but not installed
|
|
247
|
+
const unreadable = async (command, args, options) => command === 'gh' && args[0] === 'pr' ? { exitCode: 1, stdout: '', stderr: 'gh auth login required' } : fakes(() => '', seen)(command, args, options);
|
|
248
|
+
const result = await runReviewer(repo, 'main', config, unreadable, () => undefined, { choice: { mode: 'full', panel: true } });
|
|
249
|
+
expect(result).toMatchObject({ outcome: 'unavailable', reviewers: ['claude'] });
|
|
250
|
+
expect(result.mode).toMatchObject({ asked: 'panel', ran: 'none', source: 'flag', degraded: expect.stringContaining('not installed: cursor-agent') });
|
|
251
|
+
const early = await runReviewer(repo, 'main', config, fakes(() => '', { ...seenNow(), installed: [] }), () => undefined, { choice: { mode: 'full', panel: true } });
|
|
252
|
+
expect(early).toMatchObject({ outcome: 'unavailable', mode: { asked: 'panel', ran: 'none', source: 'flag' } }); // before any judge was found
|
|
253
|
+
});
|
|
185
254
|
it('in full mode runs two vendors and resolves a human point only when both say so', async () => {
|
|
186
255
|
const seen = seenNow();
|
|
187
|
-
const by = (name) => JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: name === 'claude', evidence: 'src/job.ts:2' }], findings: name === 'cursor' ? [{ class: 'dead-code', file: 'a.ts', line: 1, issue: 'a is unused', consequence: 'a second run reads stale rows' }] : [] });
|
|
256
|
+
const by = (name) => JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: name === 'claude', evidence: 'src/job.ts:2', file: 'src/job.ts', line: 2, quote: 'export function job() {' }], findings: name === 'cursor' ? [{ class: 'dead-code', file: 'a.ts', line: 1, issue: 'a is unused', consequence: 'a second run reads stale rows', quote: 'export const a = 1;' }] : [] });
|
|
188
257
|
const result = await runReviewer(repo, 'main', config, fakes(by, seen), () => undefined, { full: true });
|
|
189
258
|
expect(result.reviewers).toEqual(['claude', 'cursor']);
|
|
190
259
|
expect(result.items.map(i => [i.kind, i.reviewer])).toEqual([['prior', 'cursor'], ['finding', 'cursor']]);
|
|
@@ -221,7 +290,72 @@ describe('verdicts', () => {
|
|
|
221
290
|
expect(parseVerdict('Looks good to me!', false, 'claude', {})).toMatchObject({ error: expect.stringContaining('no valid verdict') });
|
|
222
291
|
expect(parseVerdict(JSON.stringify({ ...EMPTY, prior_points: [] }), true, 'claude', {})).toEqual({ error: 'claude did not report on the human reviews' });
|
|
223
292
|
});
|
|
224
|
-
it('
|
|
293
|
+
it('keeps the journey, sibling parity and claims as working notes, never blocks, and reads a verdict cached before they existed', () => {
|
|
294
|
+
const verdict = {
|
|
295
|
+
...EMPTY,
|
|
296
|
+
journey: [
|
|
297
|
+
{ file: 'src/w.ts', line: 4, what: 'stamps sent_at', cleared_by: null, retry_safe: false, overlap_safe: true, can_move_back: true, keys: [{ name: 'dedupeKey', inputs: 'updated_at', stable_under_edit: false }] },
|
|
298
|
+
{ file: 'src/w.ts', line: 9, what: 'caches the token', cleared_by: 'ttl', retry_safe: true, overlap_safe: true, can_move_back: null },
|
|
299
|
+
],
|
|
300
|
+
siblings: [
|
|
301
|
+
{ changed: 'src/a/run.ts:3', sibling: 'src/b/run.ts:7', needs_same_change: true, has_it: false, why: 'same lock' },
|
|
302
|
+
{ changed: 'src/a/run.ts:3', sibling: 'src/c/run.ts:2', needs_same_change: true, has_it: true },
|
|
303
|
+
{ changed: 'src/a/run.ts:3', sibling: 'src/d/run.ts:2', needs_same_change: false, has_it: false },
|
|
304
|
+
],
|
|
305
|
+
claims: [
|
|
306
|
+
{ source: 'description', claim: 'at worst one email', file: 'src/w.ts', line: 12, holds: false, evidence: 'loop sends per row' },
|
|
307
|
+
{ source: 'comment', claim: 'runs daily', file: 'src/cron.ts', line: 1, holds: true },
|
|
308
|
+
],
|
|
309
|
+
};
|
|
310
|
+
const { open, notes } = account(verdict, undefined, () => true);
|
|
311
|
+
expect(open).toEqual([]);
|
|
312
|
+
expect(notes.map(i => `${i.kind} ${i.class} ${i.file}:${i.line}`)).toEqual([
|
|
313
|
+
'journey correctness src/w.ts:4', 'journey correctness src/w.ts:4', 'journey correctness src/w.ts:4',
|
|
314
|
+
'sibling correctness src/b/run.ts:7', 'claim stale-claim src/w.ts:12',
|
|
315
|
+
]);
|
|
316
|
+
expect(notes[3].issue).toContain('needs the same change as src/a/run.ts:3: same lock');
|
|
317
|
+
const cached = { ...EMPTY };
|
|
318
|
+
delete cached.journey;
|
|
319
|
+
delete cached.siblings;
|
|
320
|
+
delete cached.claims;
|
|
321
|
+
expect(account(cached, undefined, () => true).open).toEqual([]);
|
|
322
|
+
expect(mergeVerdicts([cached, { ...verdict, reviewer: 'codex' }]).claims).toHaveLength(2);
|
|
323
|
+
});
|
|
324
|
+
it('blocks on a finding only when the code it quotes is at the line it names, and never carries an old working note as a block', () => {
|
|
325
|
+
const verify = checkoutVerifier(repo);
|
|
326
|
+
const at = (quote, line = 2) => account({ ...EMPTY, prior_points: [], findings: [{ class: 'correctness', file: 'src/job.ts', line, issue: 'returns before the lock', input: 'two runs at once', consequence: 'two emails', ...(quote === undefined ? {} : { quote }) }] }, undefined, verify);
|
|
327
|
+
expect(at(' return 1;').open).toHaveLength(1);
|
|
328
|
+
expect(at('return 1;').open).toHaveLength(1); // whitespace aside
|
|
329
|
+
expect(at('return 1;', 9)).toMatchObject({ open: [], unverified: [expect.objectContaining({ issue: 'returns before the lock' })] }); // past the end
|
|
330
|
+
expect(at('return 99;')).toMatchObject({ open: [], unverified: [expect.anything()] }); // not in the file
|
|
331
|
+
expect(at()).toMatchObject({ open: [], unverified: [expect.anything()] }); // no quote at all
|
|
332
|
+
const old = { id: 'r1', kind: 'read', class: 'production-cost', file: 'src/q.ts', line: 3, issue: 'window not bounded' };
|
|
333
|
+
const carried = account({ ...EMPTY, prior_points: [] }, [old], verify);
|
|
334
|
+
expect(carried).toMatchObject({ open: [], notes: [expect.objectContaining({ id: 'r1' })] });
|
|
335
|
+
});
|
|
336
|
+
it('shows a team lesson the change repeats as a note, never a block on its own', () => {
|
|
337
|
+
const verdict = { ...EMPTY, lessons: [
|
|
338
|
+
{ lesson: 'Regenerate the API client after changing the schema.', applies: true, file: 'src/schema.ts', line: 3, evidence: 'schema.ts changed, client not' },
|
|
339
|
+
{ lesson: 'Paginate with keyset.', applies: false, file: 'src/scan.ts', line: 9 },
|
|
340
|
+
] };
|
|
341
|
+
const { open, notes } = account(verdict, undefined, () => true);
|
|
342
|
+
expect(open).toEqual([]);
|
|
343
|
+
expect(notes.map(n => [n.kind, n.class, n.issue])).toEqual([['lesson', 'team-lesson', 'repeats a team lesson: Regenerate the API client after changing the schema.']]);
|
|
344
|
+
});
|
|
345
|
+
it('blocks only on what it can show: a quoted open human point, a blocking finding, never a missing thing that is there or a point a human accepted', () => {
|
|
346
|
+
const verify = checkoutVerifier(repo);
|
|
347
|
+
const decide = (v) => account({ ...EMPTY, prior_points: [], ...v }, undefined, verify);
|
|
348
|
+
const open = { point: 'take the lock before the first read', severity: 'blocking', resolved: false };
|
|
349
|
+
expect(decide({ prior_points: [{ ...open, file: 'src/job.ts', line: 2, quote: 'return 1;' }] }).open).toHaveLength(1);
|
|
350
|
+
expect(decide({ prior_points: [open] })).toMatchObject({ open: [], unverified: [expect.objectContaining({ kind: 'prior' })] }); // a later commit may have done it: no quote, no block
|
|
351
|
+
const finding = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'job never closes the connection', input: 'every run', consequence: 'one connection leaks per run', quote: 'return 1;' };
|
|
352
|
+
expect(decide({ findings: [{ ...finding, absent: 'return 1' }] })).toMatchObject({ open: [], unverified: [expect.anything()] }); // "missing", but the file has it
|
|
353
|
+
expect(decide({ findings: [{ ...finding, absent: 'conn.close(' }] }).open).toHaveLength(1);
|
|
354
|
+
expect(decide({ findings: [{ ...finding, severity: 'should' }] })).toMatchObject({ open: [], notes: [expect.anything()] });
|
|
355
|
+
const accepted = { point: 'job never closes the connection after the read', severity: 'non-blocking', resolved: false };
|
|
356
|
+
expect(decide({ prior_points: [accepted], findings: [finding] })).toMatchObject({ open: [], notes: [expect.anything()] }); // a human raised it and accepted it
|
|
357
|
+
});
|
|
358
|
+
it('keeps reads, scans, redundancy and merge impact as notes with stable ids, and answers non-blocking points in the reply', () => {
|
|
225
359
|
const verdict = {
|
|
226
360
|
...EMPTY,
|
|
227
361
|
prior_points: [{ point: 'nit: rename', severity: 'non-blocking', resolved: false }],
|
|
@@ -230,12 +364,13 @@ describe('verdicts', () => {
|
|
|
230
364
|
redundant: [{ file: 'src/r.ts', line: 5, what: 'null guard', made_redundant_by: 'src/r.ts:2', removed: false }, { file: 'src/r.ts', line: 9, what: 'removed guard', removed: true }],
|
|
231
365
|
merge_impact: [{ symbol: 'parseId', main_file: 'src/id.ts', call_site: 'src/link.ts:4', holds: false, why: 'band dropped' }],
|
|
232
366
|
};
|
|
233
|
-
const { open, answerInReply } = account(verdict, undefined, () => true);
|
|
234
|
-
expect(open
|
|
367
|
+
const { open, notes, answerInReply } = account(verdict, undefined, () => true);
|
|
368
|
+
expect(open).toEqual([]);
|
|
369
|
+
expect(notes.map(i => `${i.class} ${i.file}:${i.line}`)).toEqual([
|
|
235
370
|
'dead-code src/r.ts:5', 'production-cost src/q.ts:10', 'production-cost src/q.ts:10', 'production-cost src/q.ts:10', 'correctness src/q.ts:10', 'production-cost src/s.ts:3', 'correctness src/link.ts:4',
|
|
236
371
|
]);
|
|
237
|
-
expect(new Set(
|
|
238
|
-
expect(account(verdict, undefined, () => true).
|
|
372
|
+
expect(new Set(notes.map(i => i.id)).size).toBe(notes.length);
|
|
373
|
+
expect(account(verdict, undefined, () => true).notes.map(i => i.id)).toEqual(notes.map(i => i.id)); // stable
|
|
239
374
|
expect(answerInReply).toEqual([expect.objectContaining({ point: 'nit: rename' })]);
|
|
240
375
|
});
|
|
241
376
|
it('merges two verdicts: a point is resolved only when every reviewer resolves it', () => {
|
|
@@ -255,9 +390,27 @@ describe('verdicts', () => {
|
|
|
255
390
|
});
|
|
256
391
|
describe('a panel of judges', () => {
|
|
257
392
|
const panelConfig = (reviewer) => ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor', 'codex'], mode: 'full', panel: 'on', ...reviewer } } });
|
|
258
|
-
const LOCK = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', consequence: 'two runs send the same email' };
|
|
259
|
-
const LONE = { class: 'dead-code', file: 'src/job.ts', line: 1, issue: 'job is exported and never called', consequence: 'a reader treats it as the contract' };
|
|
260
|
-
const OPINION = { class: 'duplication', file: 'src/job.ts', line: 2, issue: 'could be one line shorter', consequence: '' };
|
|
393
|
+
const LOCK = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', quote: 'export function job() {', consequence: 'two runs send the same email' };
|
|
394
|
+
const LONE = { class: 'dead-code', file: 'src/job.ts', line: 1, issue: 'job is exported and never called', quote: 'export function job() {', consequence: 'a reader treats it as the contract' };
|
|
395
|
+
const OPINION = { class: 'duplication', file: 'src/job.ts', line: 2, issue: 'could be one line shorter', quote: 'export function job() {', consequence: '' };
|
|
396
|
+
it("keeps Rigour's own key from every judge, and a key the team names from that judge, in reviews and cross-examinations alike", async () => {
|
|
397
|
+
installFake(bins[0], 'codex');
|
|
398
|
+
const seen = { ...seenNow(), installed: ['claude', 'cursor-agent', 'codex'] };
|
|
399
|
+
const reply = (name) => {
|
|
400
|
+
const prompt = seen.prompts.at(-1) ?? '';
|
|
401
|
+
if (prompt.includes('The other\nreviewer raised')) {
|
|
402
|
+
const ids = [...prompt.matchAll(/"id": "([0-9a-f]+)"/g)].map(m => m[1]);
|
|
403
|
+
return JSON.stringify({ answers: ids.map(id => ({ id, call: 'refute', evidence: `src/job.ts:1 ${name}: job is imported by the runner` })) });
|
|
404
|
+
}
|
|
405
|
+
return JSON.stringify({ ...EMPTY, findings: name === 'codex' ? [LONE] : [] });
|
|
406
|
+
};
|
|
407
|
+
await runReviewer(repo, 'main', panelConfig({ judges: 3, judge_env: { codex: { unset: ['OPENAI_API_KEY'] } } }), fakes(reply, seen), () => undefined);
|
|
408
|
+
const runs = seen.ran.map((command, i) => ({ judge: path.basename(command).replace(/\.(cmd|exe)$/, ''), unset: seen.unset?.[i] ?? [] }));
|
|
409
|
+
expect(runs.length).toBe(5); // three reviews and two cross-examinations
|
|
410
|
+
for (const run of runs)
|
|
411
|
+
expect(run.unset).toContain('RIGOUR_API_KEY');
|
|
412
|
+
expect(runs.filter(r => r.unset.includes('OPENAI_API_KEY')).map(r => r.judge)).toEqual(['codex']);
|
|
413
|
+
});
|
|
261
414
|
it('confirms what a majority raised, drops what the others refute with evidence, and never blocks on an opinion', async () => {
|
|
262
415
|
installFake(bins[0], 'codex');
|
|
263
416
|
const seen = { ...seenNow(), installed: ['claude', 'cursor-agent', 'codex'] };
|
|
@@ -303,7 +456,7 @@ describe('a panel of judges', () => {
|
|
|
303
456
|
});
|
|
304
457
|
it('counts every run, stops new reviews at the cost cap, and leaves a cross-examination past the run cap disputed', async () => {
|
|
305
458
|
const seen = seenNow();
|
|
306
|
-
const lone = { class: 'dead-code', file: 'src/job.ts', line: 1, issue: 'job is exported and never called', consequence: 'a reader treats it as the contract' };
|
|
459
|
+
const lone = { class: 'dead-code', file: 'src/job.ts', line: 1, issue: 'job is exported and never called', quote: 'export function job() {', consequence: 'a reader treats it as the contract' };
|
|
307
460
|
const reply = (name) => JSON.stringify({ ...EMPTY, findings: name === 'claude' ? [LOCK] : [{ ...LOCK, line: 3, issue: 'the lock is taken only after it returns' }, lone] });
|
|
308
461
|
// Two judges fit in a cap of 2; the cross-examination of cursor's lone finding would be a third run.
|
|
309
462
|
const result = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 2 }), fakes(reply, seen), () => undefined);
|
|
@@ -326,7 +479,7 @@ describe('a panel of judges', () => {
|
|
|
326
479
|
describe('what the team already knows', () => {
|
|
327
480
|
const allowing = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'], dismissals: true } } });
|
|
328
481
|
it('refuses a dismissal unless the team allows them: fix the code, or the reviewer', async () => {
|
|
329
|
-
const first = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', consequence: 'two runs send the same email' }] }), seenNow()), () => undefined);
|
|
482
|
+
const first = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', quote: 'export function job() {', consequence: 'two runs send the same email' }] }), seenNow()), () => undefined);
|
|
330
483
|
expect((await dismissReviewerFinding(repo, first.items[0].id, 'the runner holds a lock', false)).error).toContain('this team does not dismiss reviewer findings');
|
|
331
484
|
expect(fs.existsSync(path.join(repo, '.rigour/dismissed-review-items.json'))).toBe(false);
|
|
332
485
|
});
|
|
@@ -335,7 +488,7 @@ describe('what the team already knows', () => {
|
|
|
335
488
|
fs.writeFileSync(path.join(repo, 'docs/jobs.md'), 'The job runner (src/job.ts) takes the lock first.\n');
|
|
336
489
|
git('add', '-A');
|
|
337
490
|
git('commit', '-qm', 'docs');
|
|
338
|
-
const finding = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', consequence: 'two runs send the same email' };
|
|
491
|
+
const finding = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', quote: 'export function job() {', consequence: 'two runs send the same email' };
|
|
339
492
|
const first = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [finding] }), seenNow()), () => undefined);
|
|
340
493
|
expect(first.outcome).toBe('findings');
|
|
341
494
|
expect(await dismissReviewerFinding(repo, 'abcdef0123', 'not one of ours', true)).toEqual({ error: 'no open reviewer finding abcdef0123 on feature: run `rigour review --reviewer` and copy the id it shows' });
|
|
@@ -358,7 +511,7 @@ describe('what the team already knows', () => {
|
|
|
358
511
|
expect(told.files['team-knowledge.md']).toContain("## Already found by Rigour's checks: they block on their own, so do not report them again\n- src/job.ts:1 Unused export `job`");
|
|
359
512
|
});
|
|
360
513
|
it('fails closed: a finding whose judge left out the consequence still blocks', async () => {
|
|
361
|
-
const result = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock' }] }), seenNow()), () => undefined);
|
|
514
|
+
const result = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', quote: 'export function job() {' }] }), seenNow()), () => undefined);
|
|
362
515
|
expect(result.items.map(i => i.issue)).toEqual(['returns before the lock']);
|
|
363
516
|
expect(result.notes).toEqual([]);
|
|
364
517
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inputs that never vary in production, so the code that handles other values is dead:
|
|
3
|
+
*
|
|
4
|
+
* constant-member a property every production object of the type sets to the same literal
|
|
5
|
+
* (`count: 0` in each), while code branches on it: the other branches never run.
|
|
6
|
+
* constant-argument a parameter every production call passes the same literal, while the body
|
|
7
|
+
* branches on it: the other branch never runs outside tests.
|
|
8
|
+
*
|
|
9
|
+
* Tests are not production: a test that passes other values is exactly the sign the generality
|
|
10
|
+
* exists only for them. Scoped like the other typed checks: the change declared the member or the
|
|
11
|
+
* function, or wrote every production host or call.
|
|
12
|
+
*/
|
|
13
|
+
import type * as TS from 'typescript';
|
|
14
|
+
import type { TypedProgram } from './program.js';
|
|
15
|
+
export interface DeclaredType {
|
|
16
|
+
node: TS.InterfaceDeclaration | TS.TypeAliasDeclaration;
|
|
17
|
+
name: string;
|
|
18
|
+
members: TS.PropertySignature[];
|
|
19
|
+
}
|
|
20
|
+
export interface ConstantHelpers {
|
|
21
|
+
hostsOf: (t: DeclaredType & {
|
|
22
|
+
sym: TS.Symbol;
|
|
23
|
+
type: TS.Type;
|
|
24
|
+
}) => TS.ObjectLiteralExpression[];
|
|
25
|
+
touched: (node: TS.Node) => boolean;
|
|
26
|
+
spanTouched: (node: TS.Node) => boolean;
|
|
27
|
+
at: (node: TS.Node) => string;
|
|
28
|
+
rel: (sf: TS.SourceFile) => string;
|
|
29
|
+
report: (id: string, title: string, node: TS.Node, details: string, hint: string) => void;
|
|
30
|
+
walk: (node: TS.Node, fn: (n: TS.Node) => void) => void;
|
|
31
|
+
}
|
|
32
|
+
export declare function constantInputs(typed: TypedProgram, changedSources: TS.SourceFile[], declared: Array<DeclaredType & {
|
|
33
|
+
sym: TS.Symbol;
|
|
34
|
+
type: TS.Type;
|
|
35
|
+
}>, h: ConstantHelpers): void;
|