@rigour-labs/core 6.6.6 → 6.7.1
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.test.js +2 -2
- 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 +1 -2
- package/dist/hooks/index.js +0 -1
- package/dist/hooks/types.d.ts +0 -14
- package/dist/hooks/types.js +1 -15
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- 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-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 +9 -4
- package/dist/review/backtest.js +19 -9
- package/dist/review/backtest.test.js +2 -2
- package/dist/review/baseline.js +2 -1
- package/dist/review/dead-code.test.js +7 -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.js +1 -1
- 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/review.js +2 -1
- package/dist/review/reviewer/adapters.d.ts +23 -7
- package/dist/review/reviewer/adapters.js +32 -7
- 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 +8 -1
- package/dist/review/reviewer/background.js +34 -9
- package/dist/review/reviewer/background.test.js +16 -2
- 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.js +6 -2
- 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 +2 -0
- package/dist/review/reviewer/inputs.js +5 -1
- 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 +11 -0
- package/dist/review/reviewer/prompt.js +26 -4
- 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 +25 -2
- package/dist/review/reviewer/store.js +43 -1
- 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 +15 -1
- package/dist/review/reviewer/verdict.js +17 -7
- package/dist/review/reviewer.d.ts +45 -2
- package/dist/review/reviewer.js +186 -25
- package/dist/review/reviewer.test.js +127 -12
- package/dist/review/test-files.d.ts +5 -1
- package/dist/review/test-files.js +6 -2
- package/dist/review/toolchain.js +10 -3
- package/dist/review/toolchain.test.js +6 -3
- package/dist/review/typed/program.d.ts +0 -1
- package/dist/review/typed/program.js +3 -3
- package/dist/review/typed/redundancy.js +43 -4
- package/dist/review/typed/redundancy.test.js +58 -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 +5 -6
- 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 +8 -45
- package/dist/types/index.d.ts +108 -566
- package/dist/types/index.js +41 -66
- 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
package/dist/review/reviewer.js
CHANGED
|
@@ -25,8 +25,13 @@ import { ADAPTERS, isReviewerName, resolveAdapter, selectReviewers, vendorsOf }
|
|
|
25
25
|
import { defaultExec, GH_TIMEOUT_MS, githubEnv } from './reviewer/exec.js';
|
|
26
26
|
import { findPullRequest, ghFor, humanReviews, linesChanged, mergesBaseIn, rulesText, sha } from './reviewer/inputs.js';
|
|
27
27
|
import { mergeImpact } from './reviewer/merge-impact.js';
|
|
28
|
-
import {
|
|
28
|
+
import { applyPanel, parseAnswers, runPanel } from './reviewer/panel.js';
|
|
29
|
+
import { crossExamPrompt, deltaBlock, mergeBlock, PROMPT_VERSION, renderPrompt } from './reviewer/prompt.js';
|
|
30
|
+
import { resolveReviewer } from './reviewer/settings.js';
|
|
29
31
|
import { VerdictStore } from './reviewer/store.js';
|
|
32
|
+
import { trackUsage } from '../telemetry/telemetry.js';
|
|
33
|
+
import { reviewerUsage } from './reviewer/usage.js';
|
|
34
|
+
import { buildContext, dismissedAs, readReviewDismissals, relatedDocs } from './reviewer/context.js';
|
|
30
35
|
import { account, carryResolved, evidenceTouched, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
31
36
|
export { defaultExec, githubEnv, parseJsonArrays } from './reviewer/exec.js';
|
|
32
37
|
export { itemLine } from './reviewer/verdict.js';
|
|
@@ -37,20 +42,33 @@ export function reviewerBlocks(result) {
|
|
|
37
42
|
const PROGRESS_EVERY_MS = 60_000;
|
|
38
43
|
const MAX_DELTAS = 4;
|
|
39
44
|
const MAX_DELTA_LINES = 400;
|
|
45
|
+
/** The reviewer, and one anonymous usage event for what it did (only when the person opted in to telemetry). */
|
|
40
46
|
export async function runReviewer(cwd, base, config, exec = defaultExec, progress = message => process.stderr.write(`${message}\n`), options = {}) {
|
|
41
|
-
const
|
|
47
|
+
const result = await review(cwd, base, config, exec, progress, options);
|
|
48
|
+
await trackUsage('reviewer_completed', reviewerUsage(result, options.trigger ?? (options.reviewsBefore ? 'backtest' : 'review')));
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
async function review(cwd, base, config, exec, progress, options) {
|
|
52
|
+
const settings = resolveReviewer(config, { ...options.choice, ...(options.full ? { mode: 'full' } : {}) });
|
|
42
53
|
const trigger = options.trigger ?? (options.reviewsBefore ? 'backtest' : 'review');
|
|
43
54
|
const git = async (args) => (await exec('git', args, { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout.trim();
|
|
44
55
|
const head = await git(['rev-parse', 'HEAD']);
|
|
45
56
|
const baseSha = await git(['rev-parse', `${base}^{commit}`]);
|
|
46
57
|
const branch = options.branch ?? await git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
47
58
|
const repoRoot = (await git(['rev-parse', '--show-toplevel'])) || cwd;
|
|
48
|
-
|
|
59
|
+
let attempts;
|
|
60
|
+
// A review that ends without a verdict says why where the person looks (status, Studio, MCP), not only in a log.
|
|
61
|
+
const none = (outcome, reason, extra = {}) => {
|
|
62
|
+
if (attempts && branch !== 'HEAD')
|
|
63
|
+
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 };
|
|
65
|
+
};
|
|
49
66
|
if (!head || !baseSha)
|
|
50
67
|
return none('unavailable', `not a repository, or ${base} is unknown`);
|
|
51
68
|
const store = await VerdictStore.open(cwd, exec);
|
|
52
69
|
if (!store)
|
|
53
70
|
return none('unavailable', 'no git directory to keep verdicts in');
|
|
71
|
+
attempts = store;
|
|
54
72
|
const candidates = settings.reviewers.filter(isReviewerName);
|
|
55
73
|
const installed = new Map();
|
|
56
74
|
for (const name of candidates) {
|
|
@@ -59,11 +77,14 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
|
|
|
59
77
|
installed.set(name, found);
|
|
60
78
|
}
|
|
61
79
|
const authors = vendorsOf(await git(['log', '--format=%(trailers:key=Co-Authored-By,valueonly)%(trailers:key=Co-authored-by,valueonly)', `${baseSha}..HEAD`]));
|
|
62
|
-
const mode =
|
|
63
|
-
|
|
80
|
+
const mode = settings.mode;
|
|
81
|
+
let reviewers = selectReviewers(candidates, mode, authors, new Set(installed.keys()), settings.judges);
|
|
64
82
|
if (reviewers.length === 0)
|
|
65
83
|
return none('unavailable', `no reviewer installed: ${candidates.map(n => ADAPTERS[n].binary).join(', ') || 'review.reviewer.reviewers is empty'}`);
|
|
66
|
-
|
|
84
|
+
let modeRecord = modeRan(settings, reviewers, candidates, installed);
|
|
85
|
+
if (reviewers.length < 2 && mode === 'full' && (settings.required.panel || settings.required.mode)) {
|
|
86
|
+
return none('unavailable', `rigour.yml requires two reviewers from different vendors, and ${modeRecord.degraded}`, { reviewers, mode: modeRecord });
|
|
87
|
+
}
|
|
67
88
|
const gh = ghFor(cwd, exec, await githubEnv(cwd, config.review?.github_account ?? process.env.RIGOUR_GITHUB_ACCOUNT, exec));
|
|
68
89
|
const found = await findPullRequest(gh, branch, head, options.pr);
|
|
69
90
|
if (found.error)
|
|
@@ -81,11 +102,41 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
|
|
|
81
102
|
return none('unavailable', read.error, { reviewers, pr: pr.number });
|
|
82
103
|
reviews = read.reviews;
|
|
83
104
|
}
|
|
105
|
+
const stateRoot = options.stateRoot ?? cwd;
|
|
106
|
+
// Dismissals count only where the team allows them; otherwise the file, if any, is ignored.
|
|
107
|
+
const dismissals = settings.dismissals ? readReviewDismissals(stateRoot) : [];
|
|
84
108
|
const body = pr?.body || '(no pull request description)\n';
|
|
85
109
|
const rules = rulesText(cwd);
|
|
110
|
+
const previous = branch !== 'HEAD' ? store.branchState(branch) : undefined;
|
|
111
|
+
// The same commit, asked again with the same settings and reviews (the background run, then the person): the verdict it already has.
|
|
112
|
+
const inputsKey = sha([PROMPT_VERSION, rules, body, reviews.key, JSON.stringify([settings.mode, settings.panel, settings.judges, settings.escalate, settings.panel_max_items, settings.cross_models, settings.models, candidates]), [...installed].map(([n, i]) => `${n} ${i.version}`).join(';')]);
|
|
113
|
+
if (!options.force && previous?.head === head && previous.inputsKey === inputsKey && fs.existsSync(store.decidedPath(previous.verdict))) {
|
|
114
|
+
const verdict = store.readJson(previous.verdict);
|
|
115
|
+
const decided = store.readJson(store.decidedPath(previous.verdict));
|
|
116
|
+
if (verdict && decided)
|
|
117
|
+
return result(redismiss(decided, dismissals), verdict, verdict.inputs?.reviewers ?? reviewers, previous.mode, 'same commit and inputs as the last verdict', true, reviews, pr, verdict.inputs?.mode ?? modeRecord, settings.dismissals);
|
|
118
|
+
}
|
|
119
|
+
// What the team already knows, for every judge; built once, and its risk count decides escalation.
|
|
120
|
+
const fullDiff = (await exec('git', ['diff', `${baseSha}...HEAD`], { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout;
|
|
121
|
+
const previousIsAncestor = !!previous && previous.head !== head && fs.existsSync(previous.verdict) && (await exec('git', ['merge-base', '--is-ancestor', previous.head, 'HEAD'], { cwd, timeoutMs: GH_TIMEOUT_MS })).exitCode === 0;
|
|
122
|
+
const sincePrevious = previousIsAncestor ? new Set((await git(['diff', '--name-only', `${previous.head}..HEAD`])).split('\n').filter(Boolean)) : new Set();
|
|
123
|
+
const changedFiles = [...fullDiff.matchAll(/^diff --git a\/.* b\/(.*)$/gm)].map(m => m[1]);
|
|
124
|
+
const context = buildContext({
|
|
125
|
+
cwd, stateRoot, dismissals, diff: fullDiff, router: config.gates.deep?.router, touched: sincePrevious, checks: options.checks ?? [],
|
|
126
|
+
previousPanel: previousIsAncestor ? store.readJson(previous.verdict)?.panel?.items : undefined,
|
|
127
|
+
docs: await relatedDocs(cwd, changedFiles, exec),
|
|
128
|
+
});
|
|
129
|
+
// escalate: risk. More judges only where a second opinion can change the outcome; the --full hard stop always gets them.
|
|
130
|
+
if (settings.escalate === 'risk' && reviewers.length > 1 && !options.full) {
|
|
131
|
+
const escalation = escalationFor(reviews.count, context.risky);
|
|
132
|
+
if (!escalation.escalate)
|
|
133
|
+
reviewers = reviewers.slice(0, 1);
|
|
134
|
+
modeRecord = { ...modeRecord, ...(escalation.escalate ? {} : { ran: 'single' }), escalation: escalation.why };
|
|
135
|
+
}
|
|
136
|
+
const reviewerVersions = reviewers.map(name => `${name} ${installed.get(name).version}`).join(';');
|
|
137
|
+
// What changes the instructions themselves; the context pack changes with every commit, so it is in the fingerprint, not here.
|
|
86
138
|
const rulesHash = sha([reviewerVersions, PROMPT_VERSION, rules, body, reviews.key]);
|
|
87
139
|
// Full or delta.
|
|
88
|
-
const previous = branch !== 'HEAD' ? store.branchState(branch) : undefined;
|
|
89
140
|
const mergedBase = await mergesBaseIn(cwd, baseSha, exec);
|
|
90
141
|
let scope = 'full';
|
|
91
142
|
let why;
|
|
@@ -93,7 +144,7 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
|
|
|
93
144
|
why = 'full review requested';
|
|
94
145
|
else if (!previous || !fs.existsSync(previous.verdict))
|
|
95
146
|
why = `no earlier verdict on ${branch}`;
|
|
96
|
-
else if (
|
|
147
|
+
else if (previous.head !== head && !previousIsAncestor)
|
|
97
148
|
why = `last reviewed commit ${previous.head.slice(0, 9)} is not an ancestor (rebase or amend)`;
|
|
98
149
|
else if (mergedBase)
|
|
99
150
|
why = `HEAD merges ${base}`;
|
|
@@ -112,16 +163,19 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
|
|
|
112
163
|
why = `since ${previous.head.slice(0, 9)}`;
|
|
113
164
|
}
|
|
114
165
|
const previousVerdictText = scope === 'delta' ? fs.readFileSync(previous.verdict, 'utf8') : '';
|
|
115
|
-
const fingerprint = sha([head, baseSha, scope,
|
|
166
|
+
const fingerprint = sha([head, baseSha, scope, modeRecord.ran, context.key, inputsKey, reviewerVersions, previousVerdictText]);
|
|
116
167
|
const verdictFile = store.verdictPath(head, fingerprint);
|
|
117
168
|
const openFile = store.openPath(verdictFile);
|
|
118
169
|
const previousOpen = scope === 'delta' ? store.readJson(store.openPath(previous.verdict)) ?? [] : undefined;
|
|
119
170
|
const verify = verifier(cwd);
|
|
120
171
|
if (!options.force && fs.existsSync(verdictFile) && fs.existsSync(openFile)) {
|
|
121
172
|
const verdict = store.readJson(verdictFile);
|
|
122
|
-
|
|
123
|
-
return result(accounted, verdict, reviewers, scope, why, true, reviews, pr);
|
|
173
|
+
return result(decide(verdict, previousOpen, verify, dismissals), verdict, reviewers, scope, why, true, reviews, pr, modeRecord, settings.dismissals);
|
|
124
174
|
}
|
|
175
|
+
// The daily caps, before any judge starts: a cached or reused verdict above cost nothing and never reaches here.
|
|
176
|
+
const over = overBudget(store.spend(), settings, reviewers.length);
|
|
177
|
+
if (over)
|
|
178
|
+
return none(settings.required.panel || settings.required.mode ? 'unavailable' : 'skipped', over, { reviewers, scope, why, pr: pr?.number });
|
|
125
179
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-reviewer-'));
|
|
126
180
|
try {
|
|
127
181
|
const file = (name, text) => {
|
|
@@ -132,7 +186,8 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
|
|
|
132
186
|
const reviewsFile = file('previous-reviews.md', reviews.markdown);
|
|
133
187
|
const prBodyFile = file('pr-description.md', body);
|
|
134
188
|
const diffstatFile = file('diffstat.txt', await git(['diff', '--stat', `${baseSha}...HEAD`]));
|
|
135
|
-
const diffFile = file('full.diff',
|
|
189
|
+
const diffFile = file('full.diff', fullDiff);
|
|
190
|
+
const contextFile = file('team-knowledge.md', context.text);
|
|
136
191
|
const hintsFile = file('hints.txt', options.hints?.trim() || 'none\n');
|
|
137
192
|
let delta = '';
|
|
138
193
|
// A reviewer must report on the human reviews, unless every point was settled by the previous verdict and is carried.
|
|
@@ -142,8 +197,7 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
|
|
|
142
197
|
const commitsFile = file('delta-commits.txt', await git(['log', '--format=%h %s', `${previous.head}..HEAD`]));
|
|
143
198
|
const deltaDiffFile = file('delta.diff', (await exec('git', ['diff', `${previous.head}..HEAD`], { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout);
|
|
144
199
|
// Human points the previous verdict resolved, whose files the new commits leave alone, are not judged again.
|
|
145
|
-
const
|
|
146
|
-
const settled = (store.readJson(previous.verdict)?.prior_points ?? []).filter(p => p.resolved && !evidenceTouched(p.evidence, touchedFiles));
|
|
200
|
+
const settled = (store.readJson(previous.verdict)?.prior_points ?? []).filter(p => p.resolved && !evidenceTouched(p.evidence, sincePrevious));
|
|
147
201
|
const settledFile = file('previous-resolved.json', JSON.stringify(settled, null, 2));
|
|
148
202
|
delta = deltaBlock(previous.head, previous.verdict, previousOpenFile, commitsFile, deltaDiffFile, settledFile);
|
|
149
203
|
if (settled.length)
|
|
@@ -154,20 +208,21 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
|
|
|
154
208
|
const impact = await mergeImpact(cwd, await git(['merge-base', 'HEAD^1', 'HEAD^2']), 'HEAD^2', 'HEAD', exec);
|
|
155
209
|
merge = mergeBlock(base, impact ? file('merge-impact.md', impact) : undefined);
|
|
156
210
|
}
|
|
157
|
-
const prompt = renderPrompt({ repoRoot, branch, head: head.slice(0, 9), base, baseSha, mode: scope, reviewsFile, humanCount: reviews.count, prBodyFile, diffstatFile, diffFile, hintsFile, deltaBlock: delta, mergeBlock: merge });
|
|
211
|
+
const prompt = renderPrompt({ repoRoot, branch, head: head.slice(0, 9), base, baseSha, mode: scope, reviewsFile, humanCount: reviews.count, prBodyFile, diffstatFile, diffFile, hintsFile, contextFile, deltaBlock: delta, mergeBlock: merge });
|
|
158
212
|
progress(`Rigour reviewer: reviewing ${head.slice(0, 9)} against ${base} (${scope}: ${why}; ${reviews.count} human review(s), written by ${[...authors].join(', ') || 'a person'}) with ${reviewers.join(', ')}`);
|
|
213
|
+
const modelFor = (name) => settings.models[name] ?? (name === 'claude' ? settings.model : undefined);
|
|
159
214
|
const started = Date.now();
|
|
160
215
|
const ticker = setInterval(() => progress(`Rigour reviewer: still working (${Math.round((Date.now() - started) / 60_000)} min)`), PROGRESS_EVERY_MS);
|
|
161
216
|
let parts;
|
|
162
217
|
try {
|
|
163
218
|
const answers = await Promise.all(reviewers.map(async (name) => {
|
|
164
219
|
const adapter = ADAPTERS[name];
|
|
165
|
-
const
|
|
166
|
-
const run = await exec(installed.get(name).binary, adapter.args(prompt, model), { cwd, timeoutMs: settings.timeout_ms });
|
|
220
|
+
const run = await exec(installed.get(name).binary, adapter.args(prompt, modelFor(name)), { cwd, timeoutMs: settings.timeout_ms });
|
|
167
221
|
progress(`Rigour reviewer: ${name} finished in ${Math.round((Date.now() - started) / 1000)}s (exit ${run.exitCode})`);
|
|
168
222
|
const answer = adapter.answer(run.stdout);
|
|
223
|
+
store.addSpend(1, answer.costUsd); // every run counts against the caps, an answer or not
|
|
169
224
|
return run.exitCode === 0 || answer.text.trim()
|
|
170
|
-
? parseVerdict(answer.text, needsPriorPoints, name, answer
|
|
225
|
+
? parseVerdict(answer.text, needsPriorPoints, name, answer)
|
|
171
226
|
: { error: `${name}: no answer (exit ${run.exitCode}): ${run.stderr.trim().slice(-200)}` };
|
|
172
227
|
}));
|
|
173
228
|
const failed = answers.find(a => 'error' in a);
|
|
@@ -179,19 +234,115 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
|
|
|
179
234
|
clearInterval(ticker);
|
|
180
235
|
}
|
|
181
236
|
const merged = mergeVerdicts(parts); // one part too: every item is tagged with who found it
|
|
182
|
-
const touched = scope === 'delta' ?
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
237
|
+
const touched = scope === 'delta' ? sincePrevious : new Set();
|
|
238
|
+
let verdict = scope === 'delta' ? carryResolved(merged, store.readJson(previous.verdict), touched) : merged;
|
|
239
|
+
if (modeRecord.ran === 'panel' && parts.length > 1) {
|
|
240
|
+
// Each judge's own blocking items, unmerged: the panel groups them into findings and decides each one.
|
|
241
|
+
const judgeItems = parts.map(part => account({ ...part, prior_points: [], carried: [], resolved_previous: [] }, undefined, verify).open);
|
|
242
|
+
const previousPanel = scope === 'delta' ? store.readJson(previous.verdict)?.panel : undefined;
|
|
243
|
+
const cross = [];
|
|
244
|
+
let reserved = 0;
|
|
245
|
+
const items = await runPanel({
|
|
246
|
+
judges: parts.map(part => part.reviewer),
|
|
247
|
+
items: judgeItems,
|
|
248
|
+
// Only what judges actually disagreed on: a finding parked by the item cap was never asked.
|
|
249
|
+
previousDisputed: (previousPanel?.items ?? []).filter(d => d.status === 'disputed' && d.cross?.length).map(d => d.item),
|
|
250
|
+
evidenced: text => evidenceNames(text).some(([file, line]) => verify(file, line)),
|
|
251
|
+
touched,
|
|
252
|
+
maxItems: settings.panel_max_items,
|
|
253
|
+
// Judges cross-examine at the same time: a run is reserved when allowed, so together they never pass the cap.
|
|
254
|
+
capped: () => {
|
|
255
|
+
const spent = store.spend();
|
|
256
|
+
const reason = overBudget({ ...spent, runs: spent.runs + reserved }, settings, 1);
|
|
257
|
+
if (!reason)
|
|
258
|
+
reserved++;
|
|
259
|
+
return reason;
|
|
260
|
+
},
|
|
261
|
+
ask: async (judge, asked) => {
|
|
262
|
+
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 });
|
|
264
|
+
const answer = ADAPTERS[name].answer(run.stdout);
|
|
265
|
+
store.addSpend(1, answer.costUsd);
|
|
266
|
+
reserved--;
|
|
267
|
+
cross.push({ reviewer: `${name} cross-exam`, ...(answer.costUsd !== undefined ? { cost_usd: answer.costUsd } : {}), ...(answer.tokens ? { tokens: answer.tokens } : {}) });
|
|
268
|
+
progress(`Rigour reviewer: ${name} cross-examined ${asked.length} finding(s) (exit ${run.exitCode})`);
|
|
269
|
+
return parseAnswers(answer.text);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
verdict = { ...verdict, panel: { judgeItemIds: judgeItems.flat().map(item => item.id), items }, reviewers: [...(verdict.reviewers ?? []), ...cross] };
|
|
273
|
+
}
|
|
274
|
+
const accounted = decide(verdict, previousOpen, verify, dismissals);
|
|
275
|
+
store.writeJson(verdictFile, { ...verdict, inputs: { head, base: baseSha, scope, why, mode: modeRecord, reviewers, versions: reviewerVersions, authors: [...authors], fingerprint, human_reviews: reviews.count, reviews_before: options.reviewsBefore ?? null, since: previous?.head ?? null, at: new Date().toISOString() } });
|
|
186
276
|
store.writeJson(openFile, accounted.open);
|
|
277
|
+
store.writeJson(store.decidedPath(verdictFile), accounted);
|
|
187
278
|
if (branch !== 'HEAD')
|
|
188
|
-
store.recordBranch(branch, { head, verdict: verdictFile, mode: scope, rulesHash, reviewsKey: reviews.key });
|
|
189
|
-
return result(accounted, verdict, reviewers, scope, why, false, reviews, pr);
|
|
279
|
+
store.recordBranch(branch, { head, verdict: verdictFile, mode: scope, rulesHash, reviewsKey: reviews.key, inputsKey });
|
|
280
|
+
return result(accounted, verdict, reviewers, scope, why, false, reviews, pr, modeRecord, settings.dismissals);
|
|
190
281
|
}
|
|
191
282
|
finally {
|
|
192
283
|
fs.rmSync(work, { recursive: true, force: true });
|
|
193
284
|
}
|
|
194
285
|
}
|
|
286
|
+
/** The accounting a verdict leads to: with a panel, only what it confirmed blocks; a finding the team dismissed never blocks. */
|
|
287
|
+
function decide(verdict, previousOpen, verify, dismissals) {
|
|
288
|
+
const accounted = account(verdict, previousOpen, verify);
|
|
289
|
+
// An item an earlier round confirmed stays open until it is resolved with evidence, whatever this panel says of it.
|
|
290
|
+
const earlier = new Set((previousOpen ?? []).map(item => item.id));
|
|
291
|
+
const decided = verdict.panel
|
|
292
|
+
? applyPanel(accounted, new Set(verdict.panel.judgeItemIds.filter(id => !earlier.has(id))), verdict.panel.items.filter(d => !earlier.has(d.item.id)))
|
|
293
|
+
: { ...accounted, disputed: [], dropped: [] };
|
|
294
|
+
return redismiss({ ...decided, dismissed: [] }, dismissals);
|
|
295
|
+
}
|
|
296
|
+
/** A finding the team dismissed leaves what blocks, now: at decision time and when a stored decision is read again. */
|
|
297
|
+
function redismiss(decided, dismissals) {
|
|
298
|
+
const dismissed = decided.open.filter(item => dismissedAs(item, dismissals));
|
|
299
|
+
return dismissed.length ? { ...decided, open: decided.open.filter(item => !dismissed.includes(item)), dismissed: [...decided.dismissed, ...dismissed] } : decided;
|
|
300
|
+
}
|
|
301
|
+
/** Every `file:line` an answer quotes. */
|
|
302
|
+
function evidenceNames(text) {
|
|
303
|
+
return [...text.matchAll(/([\w./-]+\.[A-Za-z0-9]+):(\d+)/g)].map(m => [m[1], Number(m[2])]);
|
|
304
|
+
}
|
|
305
|
+
/** Why the caps leave no room for `planned` more runs today, or nothing when they do. */
|
|
306
|
+
function overBudget(spent, caps, planned) {
|
|
307
|
+
if (caps.max_runs_per_day !== undefined && spent.runs + planned > caps.max_runs_per_day)
|
|
308
|
+
return `the daily run cap is reached: ${spent.runs} of ${caps.max_runs_per_day} agent runs used today in this repository, and this needs ${planned} more (review.reviewer.max_runs_per_day)`;
|
|
309
|
+
if (caps.max_usd_per_day !== undefined && spent.usd >= caps.max_usd_per_day)
|
|
310
|
+
return `the daily cost cap is reached: $${spent.usd.toFixed(2)} of $${caps.max_usd_per_day.toFixed(2)} reported today in this repository (review.reviewer.max_usd_per_day)`;
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
/** Whether a risk-escalated review adds judges: a human review exists, or the router finds a risky changed function. */
|
|
314
|
+
function escalationFor(humanReviews, risky) {
|
|
315
|
+
if (humanReviews > 0)
|
|
316
|
+
return { escalate: true, why: `${humanReviews} human review(s) to check` };
|
|
317
|
+
if (risky === undefined)
|
|
318
|
+
return { escalate: true, why: 'the risk router could not score this change, so every judge reviews it' };
|
|
319
|
+
return risky > 0
|
|
320
|
+
? { escalate: true, why: `${risky} risky changed function(s)` }
|
|
321
|
+
: { escalate: false, why: 'no risky changed function and no human review: one judge (review.reviewer.escalate: risk)' };
|
|
322
|
+
}
|
|
323
|
+
/** The mode asked for and the one the installed reviewers allow: a panel or full review needs two vendors. */
|
|
324
|
+
function modeRan(settings, reviewers, candidates, installed) {
|
|
325
|
+
const asked = settings.panel ? 'panel' : settings.mode;
|
|
326
|
+
const refused = settings.refused.length ? { refused: settings.refused } : {};
|
|
327
|
+
const source = settings.panel ? settings.source.panel : settings.source.mode;
|
|
328
|
+
if (settings.mode !== 'full' || reviewers.length >= settings.judges)
|
|
329
|
+
return { asked, ran: asked, source, ...refused };
|
|
330
|
+
const missing = candidates.filter(name => !installed.has(name)).map(name => ADAPTERS[name].binary);
|
|
331
|
+
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 { asked, ran: reviewers.length > 1 ? asked : 'single', source, degraded, ...refused };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Before any checkout or typed review: whether this push gets a model review at all (an open, ready
|
|
336
|
+
* pull request, `on_push`). The background job asks first, so a push nobody will read costs one lookup.
|
|
337
|
+
*/
|
|
338
|
+
export async function pushReviewSkip(cwd, branch, head, config, exec = defaultExec) {
|
|
339
|
+
const settings = resolveReviewer(config);
|
|
340
|
+
const gh = ghFor(cwd, exec, await githubEnv(cwd, config.review?.github_account ?? process.env.RIGOUR_GITHUB_ACCOUNT, exec));
|
|
341
|
+
const found = await findPullRequest(gh, branch, head, undefined);
|
|
342
|
+
if (found.error)
|
|
343
|
+
return undefined; // the review itself reports a lookup that failed, as unavailable
|
|
344
|
+
return skipReason(settings.on_push, branch, found.pr);
|
|
345
|
+
}
|
|
195
346
|
/** 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). */
|
|
196
347
|
function skipReason(onPush, branch, pr) {
|
|
197
348
|
if (onPush === 'off')
|
|
@@ -221,18 +372,28 @@ function verifier(cwd) {
|
|
|
221
372
|
return length >= 0 && (line === undefined || line <= length);
|
|
222
373
|
};
|
|
223
374
|
}
|
|
224
|
-
function result(accounted, verdict, reviewers, scope, why, cached, reviews, pr) {
|
|
375
|
+
function result(accounted, verdict, reviewers, scope, why, cached, reviews, pr, mode, dismissable) {
|
|
225
376
|
const cost = (verdict.reviewers ?? []).map(r => r.cost_usd).filter((c) => typeof c === 'number');
|
|
377
|
+
const used = (verdict.reviewers ?? []).map(r => r.tokens).filter((t) => !!t);
|
|
226
378
|
return {
|
|
227
379
|
outcome: accounted.open.length ? 'findings' : 'passed',
|
|
228
380
|
items: accounted.open,
|
|
229
381
|
unverified: accounted.unverified,
|
|
230
382
|
resolved: accounted.resolved,
|
|
231
383
|
answerInReply: accounted.answerInReply,
|
|
384
|
+
notes: accounted.notes,
|
|
385
|
+
disputed: accounted.disputed,
|
|
386
|
+
dropped: accounted.dropped,
|
|
387
|
+
dismissed: accounted.dismissed,
|
|
388
|
+
...(verdict.panel ? { panel: verdict.panel.items } : {}),
|
|
232
389
|
reviewers,
|
|
233
390
|
scope,
|
|
234
391
|
why,
|
|
235
392
|
...(cost.length ? { costUsd: cost.reduce((a, b) => a + b, 0) } : {}),
|
|
393
|
+
...(used.length ? { tokens: used.reduce((a, b) => ({ input: a.input + b.input, output: a.output + b.output }), { input: 0, output: 0 }) } : {}),
|
|
394
|
+
runs: (verdict.reviewers ?? []).length,
|
|
395
|
+
mode,
|
|
396
|
+
dismissable,
|
|
236
397
|
cached,
|
|
237
398
|
...(reviews.label ? { previousReview: reviews.label } : {}),
|
|
238
399
|
...(pr ? { pr: pr.number } : {}),
|
|
@@ -5,6 +5,8 @@ import path from 'path';
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
6
|
import { ConfigSchema } from '../types/index.js';
|
|
7
7
|
import { reviewerBlocks, runReviewer } from './reviewer.js';
|
|
8
|
+
import { dismissReviewerFinding } from './reviewer/context.js';
|
|
9
|
+
import { reviewStatus } from './reviewer/background.js';
|
|
8
10
|
import { selectReviewers, vendorsOf } from './reviewer/adapters.js';
|
|
9
11
|
import { account, carryResolved, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
|
|
10
12
|
let repo;
|
|
@@ -46,17 +48,19 @@ function fakes(answer, seen, pr = PR) {
|
|
|
46
48
|
if (args[0] === '--version')
|
|
47
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' };
|
|
48
50
|
seen.ran.push(command);
|
|
51
|
+
(seen.args ??= []).push(args);
|
|
49
52
|
const name = binary === 'claude' ? 'claude' : binary === 'cursor-agent' ? 'cursor' : 'codex';
|
|
50
|
-
const prompt = binary === '
|
|
53
|
+
const prompt = binary === 'claude' ? args[args.indexOf('-p') + 1] : args[args.length - 1];
|
|
51
54
|
seen.prompts.push(prompt);
|
|
52
55
|
// Paths as the prompt names them, on either separator (Windows writes `D:\...`).
|
|
53
|
-
for (const match of prompt.matchAll(/(\S+(?:previous-reviews\.md|pr-description\.md|full\.diff|hints\.txt|previous-open\.json|delta\.diff|previous-resolved\.json))/g)) {
|
|
56
|
+
for (const match of prompt.matchAll(/(\S+(?:previous-reviews\.md|pr-description\.md|full\.diff|hints\.txt|previous-open\.json|delta\.diff|previous-resolved\.json|team-knowledge\.md))/g)) {
|
|
54
57
|
seen.files[path.basename(match[1])] = fs.readFileSync(match[1], 'utf8');
|
|
55
58
|
}
|
|
56
59
|
const reply = answer(name);
|
|
57
60
|
if (typeof reply !== 'string')
|
|
58
61
|
return reply;
|
|
59
|
-
|
|
62
|
+
const stdout = binary === 'claude' ? JSON.stringify({ result: reply, total_cost_usd: 1.5 }) : binary === 'codex' ? JSON.stringify({ type: 'item.completed', item: { text: reply } }) : JSON.stringify({ result: reply });
|
|
63
|
+
return { exitCode: 0, stdout, stderr: '' };
|
|
60
64
|
};
|
|
61
65
|
}
|
|
62
66
|
const seenNow = () => ({ prompts: [], files: {}, ghArgs: [], ran: [] });
|
|
@@ -111,16 +115,17 @@ describe('the reviewer', () => {
|
|
|
111
115
|
});
|
|
112
116
|
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 () => {
|
|
113
117
|
const seen = seenNow();
|
|
114
|
-
const open = JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', why: 'x' }] });
|
|
115
|
-
const first = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined);
|
|
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 first = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined, { checks: ['src/job.ts:1 Unused export `job`'] });
|
|
116
120
|
expect(first.items).toHaveLength(1);
|
|
117
|
-
const again = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined);
|
|
121
|
+
const again = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined, { checks: ['src/job.ts:1 Unused export `job`'] });
|
|
118
122
|
expect(again.cached).toBe(true);
|
|
119
123
|
expect(seen.prompts).toHaveLength(1);
|
|
120
124
|
expect(fs.readdirSync(repo).sort()).toEqual(['.git', 'a.ts', 'src']); // nothing written to the working tree
|
|
121
125
|
fs.writeFileSync(path.join(repo, 'src/job.ts'), 'export function job() {\n return 2;\n}\n');
|
|
122
126
|
git('commit', '-qam', 'tweak');
|
|
123
|
-
|
|
127
|
+
// What the checks found changed with the commit: the context changes, the instructions do not, so it is still a delta.
|
|
128
|
+
const delta = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [], carried: [], resolved_previous: [] }), seen), () => undefined, { checks: ['src/job.ts:2 Unused export `other`'] });
|
|
124
129
|
expect(delta.scope).toBe('delta');
|
|
125
130
|
expect(seen.prompts[1]).toContain('DELTA MODE');
|
|
126
131
|
expect(seen.files['delta.diff']).toContain('- return 1;');
|
|
@@ -133,7 +138,7 @@ describe('the reviewer', () => {
|
|
|
133
138
|
});
|
|
134
139
|
it('resolves a previous item only with evidence, and never blocks on an item that names code the checkout does not have', async () => {
|
|
135
140
|
const seen = seenNow();
|
|
136
|
-
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' }, { class: 'dead-code', file: 'src/ghost.ts', line: 1, issue: 'unused' }, { class: 'dead-code', file: '', issue: 'somewhere, no file named' }] }), seen), () => undefined);
|
|
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);
|
|
137
142
|
expect(first.items.map(i => i.file)).toEqual(['src/job.ts']);
|
|
138
143
|
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
|
|
139
144
|
const id = first.items[0].id;
|
|
@@ -179,7 +184,7 @@ describe('the reviewer', () => {
|
|
|
179
184
|
});
|
|
180
185
|
it('in full mode runs two vendors and resolves a human point only when both say so', async () => {
|
|
181
186
|
const seen = seenNow();
|
|
182
|
-
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' }] : [] });
|
|
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' }] : [] });
|
|
183
188
|
const result = await runReviewer(repo, 'main', config, fakes(by, seen), () => undefined, { full: true });
|
|
184
189
|
expect(result.reviewers).toEqual(['claude', 'cursor']);
|
|
185
190
|
expect(result.items.map(i => [i.kind, i.reviewer])).toEqual([['prior', 'cursor'], ['finding', 'cursor']]);
|
|
@@ -211,10 +216,10 @@ describe('verdicts', () => {
|
|
|
211
216
|
it('finds the verdict when the reviewer wraps it in a summary or a code fence, and refuses prose', () => {
|
|
212
217
|
const verdict = JSON.stringify(EMPTY);
|
|
213
218
|
for (const text of [`## Summary\nAll checked.\n\n\`\`\`json\n${verdict}\n\`\`\`\nDone.`, `Notes first.\n${verdict}\nThat is all {see above}.`]) {
|
|
214
|
-
expect(parseVerdict(text, true, 'claude',
|
|
219
|
+
expect(parseVerdict(text, true, 'claude', {})).toMatchObject({ verdict: { prior_points: [{ point: 'lock before read' }], reviewer: 'claude' } });
|
|
215
220
|
}
|
|
216
|
-
expect(parseVerdict('Looks good to me!', false, 'claude',
|
|
217
|
-
expect(parseVerdict(JSON.stringify({ ...EMPTY, prior_points: [] }), true, 'claude',
|
|
221
|
+
expect(parseVerdict('Looks good to me!', false, 'claude', {})).toMatchObject({ error: expect.stringContaining('no valid verdict') });
|
|
222
|
+
expect(parseVerdict(JSON.stringify({ ...EMPTY, prior_points: [] }), true, 'claude', {})).toEqual({ error: 'claude did not report on the human reviews' });
|
|
218
223
|
});
|
|
219
224
|
it('turns reads, scans, redundancy and merge impact into open items with stable ids, and answers non-blocking points in the reply', () => {
|
|
220
225
|
const verdict = {
|
|
@@ -248,3 +253,113 @@ describe('verdicts', () => {
|
|
|
248
253
|
expect(carryResolved({ ...EMPTY, prior_points: [{ point: 'Lock before read', resolved: false }] }, previous, new Set()).prior_points).toHaveLength(2);
|
|
249
254
|
});
|
|
250
255
|
});
|
|
256
|
+
describe('a panel of judges', () => {
|
|
257
|
+
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: '' };
|
|
261
|
+
it('confirms what a majority raised, drops what the others refute with evidence, and never blocks on an opinion', async () => {
|
|
262
|
+
installFake(bins[0], 'codex');
|
|
263
|
+
const seen = { ...seenNow(), installed: ['claude', 'cursor-agent', 'codex'] };
|
|
264
|
+
const reply = (name) => {
|
|
265
|
+
const prompt = seen.prompts.at(-1) ?? '';
|
|
266
|
+
if (prompt.includes('The other\nreviewer raised')) {
|
|
267
|
+
const ids = [...prompt.matchAll(/"id": "([0-9a-f]+)"/g)].map(m => m[1]);
|
|
268
|
+
return JSON.stringify({ answers: ids.map(id => ({ id, call: 'refute', evidence: `src/job.ts:1 ${name}: job is imported by the runner` })) });
|
|
269
|
+
}
|
|
270
|
+
if (name === 'codex')
|
|
271
|
+
return JSON.stringify({ ...EMPTY, findings: [LONE] });
|
|
272
|
+
return JSON.stringify({ ...EMPTY, findings: name === 'claude' ? [LOCK, OPINION] : [{ ...LOCK, line: 3, issue: 'the lock is taken only after it returns' }] });
|
|
273
|
+
};
|
|
274
|
+
const result = await runReviewer(repo, 'main', panelConfig({ judges: 3, cross_models: { claude: 'claude-haiku-4-5' } }), fakes(reply, seen), () => undefined);
|
|
275
|
+
expect(result.reviewers).toEqual(['claude', 'cursor', 'codex']);
|
|
276
|
+
expect(result.mode).toMatchObject({ asked: 'panel', ran: 'panel', source: 'team' });
|
|
277
|
+
expect(result.items.map(i => [i.issue, i.reviewer])).toEqual([[LOCK.issue, 'claude+cursor']]);
|
|
278
|
+
expect(result.dropped.map(i => i.issue)).toEqual([LONE.issue]);
|
|
279
|
+
expect(result.notes.map(i => i.issue)).toEqual([OPINION.issue]);
|
|
280
|
+
expect(seen.prompts).toHaveLength(5); // three blind reviews, then claude and cursor each cross-examine codex's lone finding once
|
|
281
|
+
expect(result.panel?.find(d => d.item.issue === LONE.issue)).toMatchObject({ judges: ['codex'], calls: { codex: 'raised', claude: 'refute', cursor: 'refute' }, status: 'dropped' });
|
|
282
|
+
expect(result.costUsd).toBe(3); // claude's review and claude's cross-examination
|
|
283
|
+
const claudeCalls = (seen.args ?? []).filter((_, i) => path.basename(seen.ran[i]).startsWith('claude'));
|
|
284
|
+
expect(claudeCalls.map(a => a.includes('claude-haiku-4-5'))).toEqual([false, true]); // the cheaper model for the cross-examination only
|
|
285
|
+
});
|
|
286
|
+
it('runs one judge and says why when only one vendor is installed, and is unavailable when the team requires the panel', async () => {
|
|
287
|
+
const seen = seenNow();
|
|
288
|
+
const reply = () => JSON.stringify({ ...EMPTY, findings: [LOCK] });
|
|
289
|
+
const fallback = await runReviewer(repo, 'main', panelConfig({ reviewers: ['claude', 'codex'] }), fakes(reply, seen), () => undefined);
|
|
290
|
+
expect(fallback.mode).toMatchObject({ asked: 'panel', ran: 'single', degraded: expect.stringContaining('not installed: codex') });
|
|
291
|
+
expect(fallback.items.map(i => i.issue)).toEqual([LOCK.issue]);
|
|
292
|
+
const required = await runReviewer(repo, 'main', panelConfig({ reviewers: ['claude', 'codex'], panel: 'required' }), fakes(reply, seenNow()), () => undefined);
|
|
293
|
+
expect(required.outcome).toBe('unavailable');
|
|
294
|
+
expect(required.reason).toContain('rigour.yml requires two reviewers');
|
|
295
|
+
});
|
|
296
|
+
it('keeps to the daily caps: a review past the run cap is skipped, or unavailable when the team requires the reviewer', async () => {
|
|
297
|
+
const reply = () => JSON.stringify({ ...EMPTY, findings: [LOCK] });
|
|
298
|
+
const skipped = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 1 }), fakes(reply, seenNow()), () => undefined);
|
|
299
|
+
expect(skipped.outcome).toBe('skipped');
|
|
300
|
+
expect(skipped.reason).toContain('the daily run cap is reached: 0 of 1 agent runs used today in this repository, and this needs 2 more');
|
|
301
|
+
const required = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 1, panel: 'required' }), fakes(reply, seenNow()), () => undefined);
|
|
302
|
+
expect(required.outcome).toBe('unavailable');
|
|
303
|
+
});
|
|
304
|
+
it('counts every run, stops new reviews at the cost cap, and leaves a cross-examination past the run cap disputed', async () => {
|
|
305
|
+
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' };
|
|
307
|
+
const reply = (name) => JSON.stringify({ ...EMPTY, findings: name === 'claude' ? [LOCK] : [{ ...LOCK, line: 3, issue: 'the lock is taken only after it returns' }, lone] });
|
|
308
|
+
// Two judges fit in a cap of 2; the cross-examination of cursor's lone finding would be a third run.
|
|
309
|
+
const result = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 2 }), fakes(reply, seen), () => undefined);
|
|
310
|
+
expect(seen.prompts).toHaveLength(2);
|
|
311
|
+
expect(result.items.map(i => i.issue)).toEqual([LOCK.issue]);
|
|
312
|
+
expect(result.panel?.find(d => d.item.issue === lone.issue)).toMatchObject({ status: 'disputed', note: expect.stringContaining('the daily run cap is reached') });
|
|
313
|
+
// claude reported $1.50: a cost cap of $1 lets no new review start today.
|
|
314
|
+
const capped = await runReviewer(repo, 'main', panelConfig({ max_usd_per_day: 1 }), fakes(reply, seenNow()), () => undefined, { force: true });
|
|
315
|
+
expect(capped).toMatchObject({ outcome: 'skipped', reason: expect.stringContaining('the daily cost cap is reached: $1.50 of $1.00') });
|
|
316
|
+
});
|
|
317
|
+
it('escalates on risk: one judge for a change with no risky function and no human review', async () => {
|
|
318
|
+
const seen = seenNow();
|
|
319
|
+
const result = await runReviewer(repo, 'main', panelConfig({ escalate: 'risk' }), fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined);
|
|
320
|
+
expect(result.mode).toMatchObject({ asked: 'panel', ran: 'single', escalation: expect.stringContaining('no risky changed function') });
|
|
321
|
+
expect(seen.prompts).toHaveLength(1);
|
|
322
|
+
const full = await runReviewer(repo, 'main', panelConfig({ escalate: 'risk' }), fakes(() => JSON.stringify(EMPTY), seenNow(), null), () => undefined, { full: true, force: true });
|
|
323
|
+
expect(full.mode?.ran).toBe('panel'); // the --full hard stop always gets every judge
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
describe('what the team already knows', () => {
|
|
327
|
+
const allowing = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'], dismissals: true } } });
|
|
328
|
+
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);
|
|
330
|
+
expect((await dismissReviewerFinding(repo, first.items[0].id, 'the runner holds a lock', false)).error).toContain('this team does not dismiss reviewer findings');
|
|
331
|
+
expect(fs.existsSync(path.join(repo, '.rigour/dismissed-review-items.json'))).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
it('a dismissed finding reaches the next judge as settled, and a re-worded repeat never blocks', async () => {
|
|
334
|
+
fs.mkdirSync(path.join(repo, 'docs'));
|
|
335
|
+
fs.writeFileSync(path.join(repo, 'docs/jobs.md'), 'The job runner (src/job.ts) takes the lock first.\n');
|
|
336
|
+
git('add', '-A');
|
|
337
|
+
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' };
|
|
339
|
+
const first = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [finding] }), seenNow()), () => undefined);
|
|
340
|
+
expect(first.outcome).toBe('findings');
|
|
341
|
+
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' });
|
|
342
|
+
expect((await dismissReviewerFinding(repo, first.items[0].id, 'the runner holds a lock one level up', true)).item?.issue).toBe('returns before the lock is taken');
|
|
343
|
+
expect((await reviewStatus(repo, 'feature'))?.last?.open).toEqual([]); // not work any more, right away
|
|
344
|
+
const seen = seenNow();
|
|
345
|
+
// The same commit again: no new run; the stored decision is reused, with the dismissal applied.
|
|
346
|
+
const reused = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify(EMPTY), seenNow()), () => undefined);
|
|
347
|
+
expect(reused).toMatchObject({ cached: true, outcome: 'passed' });
|
|
348
|
+
expect(reused.dismissed.map(i => i.issue)).toEqual(['returns before the lock is taken']);
|
|
349
|
+
// A fresh review: the judge is told it is settled, and a re-worded repeat does not block either.
|
|
350
|
+
const again = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ ...finding, issue: 'returns before the lock is taken, so it races' }] }), seen), () => undefined, { force: true });
|
|
351
|
+
expect(again.cached).toBe(false);
|
|
352
|
+
expect(again.outcome).toBe('passed');
|
|
353
|
+
expect(again.dismissed.map(i => i.issue)).toEqual(['returns before the lock is taken, so it races']);
|
|
354
|
+
expect(seen.files['team-knowledge.md']).toContain('dismissed as not a bug by t@example.com: src/job.ts:2 [correctness] returns before the lock is taken (reason: the runner holds a lock one level up)');
|
|
355
|
+
expect(seen.files['team-knowledge.md']).toContain('docs/jobs.md (names src/job.ts');
|
|
356
|
+
const told = seenNow();
|
|
357
|
+
await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify(EMPTY), told), () => undefined, { force: true, checks: ['src/job.ts:1 Unused export `job`'] });
|
|
358
|
+
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
|
+
});
|
|
360
|
+
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);
|
|
362
|
+
expect(result.items.map(i => i.issue)).toEqual(['returns before the lock']);
|
|
363
|
+
expect(result.notes).toEqual([]);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
@@ -1,2 +1,6 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* A test file, by the conventions test runners use: by name (`x.test.ts`, `x.spec.ts`, `x.e2e.ts`,
|
|
3
|
+
* tsd's `x.test-d.ts`) or by folder (`test/`, `tests/`, `__tests__/`, `__mocks__/`, `e2e/`, tsd's
|
|
4
|
+
* `test-d/`). The one definition every review check uses, so they never disagree about a file.
|
|
5
|
+
*/
|
|
2
6
|
export declare function isTestFile(file: string): boolean;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* A test file, by the conventions test runners use: by name (`x.test.ts`, `x.spec.ts`, `x.e2e.ts`,
|
|
3
|
+
* tsd's `x.test-d.ts`) or by folder (`test/`, `tests/`, `__tests__/`, `__mocks__/`, `e2e/`, tsd's
|
|
4
|
+
* `test-d/`). The one definition every review check uses, so they never disagree about a file.
|
|
5
|
+
*/
|
|
2
6
|
export function isTestFile(file) {
|
|
3
|
-
return /\.(test|spec|e2e)\.[cm]?[jt]sx?$/.test(file) || /(^|\/)(__tests__|tests?|e2e)\//.test(file);
|
|
7
|
+
return /\.(test|spec|e2e|test-d)\.[cm]?[jt]sx?$/.test(file) || /(^|\/)(__tests__|__mocks__|tests?|test-d|e2e)\//.test(file);
|
|
4
8
|
}
|
package/dist/review/toolchain.js
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
* the moment that matters (before a push) so nobody has to remember. Only tools the project
|
|
5
5
|
* installed are run (node_modules/.bin, never a download). A tool the project never declared is
|
|
6
6
|
* skipped and said so; one declared in package.json but not installed FAILS, since a checkout
|
|
7
|
-
* without its dependencies cannot prove anything about the change. A tool configured
|
|
8
|
-
* `commands:` is
|
|
7
|
+
* without its dependencies cannot prove anything about the change. A tool the team configured
|
|
8
|
+
* under `commands:` is run as that command instead, as written (whole project, not per file).
|
|
9
9
|
*/
|
|
10
10
|
import { execa } from 'execa';
|
|
11
11
|
import fs from 'fs';
|
|
12
12
|
import path from 'path';
|
|
13
|
+
import { splitCommand } from '../utils/command-line.js';
|
|
13
14
|
import { installedBin } from '../utils/installed-bin.js';
|
|
14
15
|
import { onChangedLines, overlayUnavailable, writeOverlayConfig } from './lint-overlay.js';
|
|
15
16
|
import { ownOutputs } from './unused-exports.js';
|
|
@@ -27,7 +28,13 @@ export async function runToolchain(cwd, changedFiles, config, changedLines = {})
|
|
|
27
28
|
const configured = config.commands ?? {};
|
|
28
29
|
const results = [];
|
|
29
30
|
const run = async (tool, step) => {
|
|
30
|
-
|
|
31
|
+
const command = configured[tool];
|
|
32
|
+
if (!command)
|
|
33
|
+
results.push(await step());
|
|
34
|
+
else {
|
|
35
|
+
const { bin, args } = splitCommand(command);
|
|
36
|
+
results.push(await once(tool, cwd, bin, args));
|
|
37
|
+
}
|
|
31
38
|
};
|
|
32
39
|
await run('format', () => formatCheck(cwd, files.filter(file => FORMATTED.test(file))));
|
|
33
40
|
await run('lint', () => lint(cwd, code));
|