@rigour-labs/core 6.6.5 → 6.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -92
- package/dist/environment.test.js +0 -4
- package/dist/gates/agent-team.d.ts +0 -2
- package/dist/gates/agent-team.js +0 -8
- package/dist/gates/checkpoint.d.ts +0 -1
- package/dist/gates/checkpoint.js +0 -1
- package/dist/gates/context.d.ts +0 -4
- package/dist/gates/context.js +7 -17
- package/dist/gates/deep-analysis.d.ts +0 -1
- package/dist/gates/deep-runner.js +0 -1
- package/dist/gates/frontend-secret-exposure.d.ts +1 -1
- package/dist/gates/frontend-secret-exposure.js +9 -3
- package/dist/gates/frontend-secret-exposure.test.js +6 -3
- package/dist/gates/hallucinated-imports/index.d.ts +0 -2
- package/dist/gates/hallucinated-imports/index.js +2 -2
- package/dist/gates/hallucinated-imports/js-resolver.js +1 -0
- package/dist/gates/hallucinated-imports.test.js +10 -0
- package/dist/gates/runner.js +3 -4
- package/dist/gates/test-quality-matchers.d.ts +0 -4
- package/dist/gates/test-quality-matchers.js +0 -7
- package/dist/gates/test-quality.d.ts +0 -2
- package/dist/gates/test-quality.js +1 -10
- package/dist/hooks/checker.js +1 -1
- package/dist/hooks/index.d.ts +2 -3
- package/dist/hooks/index.js +1 -2
- package/dist/hooks/stop-review.d.ts +6 -9
- package/dist/hooks/stop-review.js +5 -10
- package/dist/hooks/types.d.ts +0 -14
- package/dist/hooks/types.js +1 -15
- package/dist/index.d.ts +11 -3
- package/dist/index.js +11 -3
- package/dist/inference/cloud-provider.d.ts +2 -0
- package/dist/inference/cloud-provider.js +16 -6
- package/dist/inference/cloud-provider.test.js +21 -0
- package/dist/pattern-index/embeddings.d.ts +0 -5
- package/dist/pattern-index/embeddings.js +11 -4
- package/dist/pattern-index/semantic-runtime.d.ts +10 -0
- package/dist/pattern-index/semantic-runtime.js +46 -0
- package/dist/pattern-index/semantic-runtime.test.d.ts +1 -0
- package/dist/pattern-index/semantic-runtime.test.js +31 -0
- package/dist/review/backtest-init.d.ts +8 -0
- package/dist/review/backtest-init.js +87 -0
- package/dist/review/backtest-init.test.d.ts +1 -0
- package/dist/review/backtest-init.test.js +74 -0
- package/dist/review/backtest-judges.d.ts +38 -0
- package/dist/review/backtest-judges.js +63 -0
- package/dist/review/backtest-judges.test.d.ts +1 -0
- package/dist/review/backtest-judges.test.js +32 -0
- package/dist/review/backtest.d.ts +320 -0
- package/dist/review/backtest.js +207 -0
- package/dist/review/backtest.test.d.ts +1 -0
- package/dist/review/backtest.test.js +129 -0
- package/dist/review/baseline.js +2 -1
- package/dist/review/dead-code.test.js +15 -0
- package/dist/review/fix-scope.d.ts +21 -0
- package/dist/review/fix-scope.js +94 -0
- package/dist/review/fix-scope.test.d.ts +1 -0
- package/dist/review/fix-scope.test.js +104 -0
- package/dist/review/lint-overlay.d.ts +21 -0
- package/dist/review/lint-overlay.js +89 -0
- package/dist/review/orphan-files.js +3 -2
- package/dist/review/partial-wiring.js +5 -3
- package/dist/review/query-patterns.js +3 -2
- package/dist/review/quiet.d.ts +6 -0
- package/dist/review/quiet.js +12 -2
- package/dist/review/quiet.test.js +11 -0
- package/dist/review/review.d.ts +6 -0
- package/dist/review/review.js +10 -2
- package/dist/review/reviewer/adapters.d.ts +51 -0
- package/dist/review/reviewer/adapters.js +181 -0
- package/dist/review/reviewer/adapters.test.d.ts +1 -0
- package/dist/review/reviewer/adapters.test.js +23 -0
- package/dist/review/reviewer/background.d.ts +41 -0
- package/dist/review/reviewer/background.js +153 -0
- package/dist/review/reviewer/background.test.d.ts +1 -0
- package/dist/review/reviewer/background.test.js +118 -0
- package/dist/review/reviewer/consensus.d.ts +36 -0
- package/dist/review/reviewer/consensus.js +144 -0
- package/dist/review/reviewer/context.d.ts +75 -0
- package/dist/review/reviewer/context.js +140 -0
- package/dist/review/reviewer/context.test.d.ts +1 -0
- package/dist/review/reviewer/context.test.js +18 -0
- package/dist/review/reviewer/exec.d.ts +17 -0
- package/dist/review/reviewer/exec.js +27 -0
- package/dist/review/reviewer/exec.test.d.ts +1 -0
- package/dist/review/reviewer/exec.test.js +23 -0
- package/dist/review/reviewer/inputs.d.ts +39 -0
- package/dist/review/reviewer/inputs.js +107 -0
- package/dist/review/reviewer/merge-impact.d.ts +2 -0
- package/dist/review/reviewer/merge-impact.js +64 -0
- package/dist/review/reviewer/panel.d.ts +67 -0
- package/dist/review/reviewer/panel.js +101 -0
- package/dist/review/reviewer/panel.test.d.ts +1 -0
- package/dist/review/reviewer/panel.test.js +110 -0
- package/dist/review/reviewer/prompt.d.ts +32 -0
- package/dist/review/reviewer/prompt.js +145 -0
- package/dist/review/reviewer/settings.d.ts +58 -0
- package/dist/review/reviewer/settings.js +152 -0
- package/dist/review/reviewer/settings.test.d.ts +1 -0
- package/dist/review/reviewer/settings.test.js +57 -0
- package/dist/review/reviewer/store.d.ts +49 -0
- package/dist/review/reviewer/store.js +99 -0
- package/dist/review/reviewer/usage.d.ts +2 -0
- package/dist/review/reviewer/usage.js +28 -0
- package/dist/review/reviewer/usage.test.d.ts +1 -0
- package/dist/review/reviewer/usage.test.js +14 -0
- package/dist/review/reviewer/verdict.d.ts +141 -0
- package/dist/review/reviewer/verdict.js +166 -0
- package/dist/review/reviewer.d.ts +84 -49
- package/dist/review/reviewer.js +364 -195
- package/dist/review/reviewer.test.js +316 -50
- package/dist/review/test-files.d.ts +5 -1
- package/dist/review/test-files.js +6 -2
- package/dist/review/toolchain.d.ts +5 -2
- package/dist/review/toolchain.js +89 -7
- package/dist/review/toolchain.test.js +45 -4
- package/dist/review/typed/program.d.ts +26 -0
- package/dist/review/typed/program.js +90 -0
- package/dist/review/typed/redundancy.d.ts +10 -0
- package/dist/review/typed/redundancy.js +378 -0
- package/dist/review/typed/redundancy.test.d.ts +1 -0
- package/dist/review/typed/redundancy.test.js +179 -0
- package/dist/review/typed/schema-nullability.d.ts +6 -0
- package/dist/review/typed/schema-nullability.js +218 -0
- package/dist/review/typed/schema-nullability.test.d.ts +1 -0
- package/dist/review/typed/schema-nullability.test.js +63 -0
- package/dist/review/unused-exports.js +10 -7
- package/dist/semantic/learn/types.d.ts +2 -2
- package/dist/services/adaptive-thresholds.d.ts +2 -52
- package/dist/services/adaptive-thresholds.js +2 -147
- package/dist/services/adaptive-thresholds.test.js +1 -81
- package/dist/settings.d.ts +14 -6
- package/dist/settings.js +0 -12
- package/dist/storage/team-diagnostics.js +1 -1
- package/dist/storage/team-schema.d.ts +1 -1
- package/dist/storage/team-schema.js +2 -0
- package/dist/storage/team-store.js +26 -3
- package/dist/storage/team-sync-scope.test.js +35 -2
- package/dist/telemetry/telemetry.d.ts +5 -0
- package/dist/telemetry/telemetry.js +11 -1
- package/dist/templates/paradigms.js +0 -8
- package/dist/templates/presets.d.ts +0 -1
- package/dist/templates/presets.js +0 -23
- package/dist/templates/universal-config.js +16 -46
- package/dist/types/index.d.ts +202 -574
- package/dist/types/index.js +64 -67
- package/dist/types/index.test.d.ts +1 -0
- package/dist/types/index.test.js +33 -0
- package/dist/utils/command-line.d.ts +8 -0
- package/dist/utils/command-line.js +8 -0
- package/dist/utils/command-line.test.d.ts +1 -0
- package/dist/utils/command-line.test.js +8 -0
- package/dist/utils/profile.js +1 -1
- package/package.json +6 -7
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { ConfigSchema } from '../../types/index.js';
|
|
7
|
+
import { backgroundReview, reviewStatus, startBackgroundReview } from './background.js';
|
|
8
|
+
import { VerdictStore } from './store.js';
|
|
9
|
+
let repo;
|
|
10
|
+
let bin;
|
|
11
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
|
|
12
|
+
const config = ConfigSchema.parse({ version: 1, review: { reviewer: { enabled: true } } });
|
|
13
|
+
const originalPath = process.env.PATH;
|
|
14
|
+
let PR = { number: 7, state: 'OPEN', isDraft: false, author: { login: 'author' }, body: 'desc' };
|
|
15
|
+
const VERDICT = JSON.stringify({ prior_points: [], redundant: [], reads: [], scans: [], merge_impact: [], findings: [{ class: 'correctness', file: 'src/job.ts', line: 1, issue: 'never locks', consequence: 'a second run reads stale rows' }], carried: [], resolved_previous: [] });
|
|
16
|
+
/** Real git; a pull request on the branch; a claude that answers one finding. */
|
|
17
|
+
const exec = async (command, args, options) => {
|
|
18
|
+
if (command === 'git') {
|
|
19
|
+
try {
|
|
20
|
+
return { exitCode: 0, stdout: execFileSync('git', args, { cwd: options.cwd, encoding: 'utf8' }), stderr: '' };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return { exitCode: 1, stdout: '', stderr: String(error.message) };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (command === 'gh') {
|
|
27
|
+
if (args[0] === 'pr')
|
|
28
|
+
return { exitCode: 0, stdout: JSON.stringify(PR), stderr: '' };
|
|
29
|
+
return { exitCode: 0, stdout: '[]', stderr: '' };
|
|
30
|
+
}
|
|
31
|
+
if (args[0] === '--version')
|
|
32
|
+
return { exitCode: 0, stdout: '1.0.0\n', stderr: '' };
|
|
33
|
+
return { exitCode: 0, stdout: JSON.stringify({ result: VERDICT }), stderr: '' };
|
|
34
|
+
};
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
bin = fs.mkdtempSync(path.join(os.tmpdir(), 'bin-'));
|
|
37
|
+
fs.writeFileSync(path.join(bin, process.platform === 'win32' ? 'claude.cmd' : 'claude'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
38
|
+
process.env.PATH = [bin, originalPath ?? ''].join(path.delimiter);
|
|
39
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'background-'));
|
|
40
|
+
git('init', '-q', '-b', 'main');
|
|
41
|
+
git('config', 'user.email', 't@example.com');
|
|
42
|
+
git('config', 'user.name', 't');
|
|
43
|
+
git('config', 'commit.gpgsign', 'false');
|
|
44
|
+
fs.writeFileSync(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
|
45
|
+
git('add', '-A');
|
|
46
|
+
git('commit', '-qm', 'init');
|
|
47
|
+
git('checkout', '-qb', 'feature');
|
|
48
|
+
fs.mkdirSync(path.join(repo, 'src'));
|
|
49
|
+
fs.writeFileSync(path.join(repo, 'src/job.ts'), 'export const job = 1;\n');
|
|
50
|
+
git('add', '-A');
|
|
51
|
+
git('commit', '-qm', 'job');
|
|
52
|
+
});
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
process.env.PATH = originalPath;
|
|
55
|
+
try {
|
|
56
|
+
git('worktree', 'prune');
|
|
57
|
+
}
|
|
58
|
+
catch { /* gone */ }
|
|
59
|
+
for (const dir of [repo, bin])
|
|
60
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
describe('the background review', () => {
|
|
63
|
+
it('reviews the pushed commit in a worktree of its own, records the verdict under the branch, and leaves the checkout alone', async () => {
|
|
64
|
+
const head = git('rev-parse', 'HEAD');
|
|
65
|
+
fs.writeFileSync(path.join(repo, 'src/job.ts'), 'export const job = 2; // edited after the push\n'); // must not be what the reviewer reads
|
|
66
|
+
const lines = [];
|
|
67
|
+
const result = await backgroundReview(repo, { head, branch: 'feature', base: 'main' }, config, exec, line => lines.push(line));
|
|
68
|
+
expect(result).toMatchObject({ outcome: 'findings', pr: 7 });
|
|
69
|
+
expect(result.items).toEqual([expect.objectContaining({ file: 'src/job.ts', issue: 'never locks' })]);
|
|
70
|
+
expect(lines.at(-1)).toContain('never locks');
|
|
71
|
+
expect(fs.readFileSync(path.join(repo, 'src/job.ts'), 'utf8')).toContain('edited after the push');
|
|
72
|
+
const store = (await VerdictStore.open(repo, exec));
|
|
73
|
+
expect(store.branchState('feature')).toMatchObject({ head, mode: 'full' });
|
|
74
|
+
expect(fs.existsSync(store.worktreeDir(head))).toBe(false); // removed when done
|
|
75
|
+
const status = await reviewStatus(repo, 'feature', exec);
|
|
76
|
+
expect(status).toMatchObject({ branch: 'feature', last: { head, mode: 'full', open: [expect.objectContaining({ issue: 'never locks' })] } });
|
|
77
|
+
expect(status?.running).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
it('asks whether a review is due before checking anything out, and the status says why it was skipped', async () => {
|
|
80
|
+
PR = { ...PR, isDraft: true };
|
|
81
|
+
try {
|
|
82
|
+
const head = git('rev-parse', 'HEAD');
|
|
83
|
+
const result = await backgroundReview(repo, { head, branch: 'feature', base: 'main' }, config, exec, () => undefined);
|
|
84
|
+
expect(result).toMatchObject({ outcome: 'skipped', reason: expect.stringContaining('is a draft') });
|
|
85
|
+
const store = (await VerdictStore.open(repo, exec));
|
|
86
|
+
expect(fs.existsSync(store.worktreeDir(head))).toBe(false); // never checked out
|
|
87
|
+
expect((await reviewStatus(repo, 'feature', exec))?.attempt).toMatchObject({ head, outcome: 'skipped', reason: expect.stringContaining('is a draft') });
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
PR = { ...PR, isDraft: false };
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
it('starts detached with a pid the status reports, and a newer start for the branch ends the older one', async () => {
|
|
94
|
+
const head = git('rev-parse', 'HEAD');
|
|
95
|
+
const sleeper = [process.execPath, '-e', 'setTimeout(() => {}, 20000)'];
|
|
96
|
+
const log = await startBackgroundReview(repo, { head, branch: 'feature', base: 'main' }, sleeper, exec);
|
|
97
|
+
expect(log).toMatch(/feature\.log$/);
|
|
98
|
+
const first = (await reviewStatus(repo, 'feature', exec))?.running;
|
|
99
|
+
expect(first).toMatchObject({ head });
|
|
100
|
+
expect(alive(first.pid)).toBe(true);
|
|
101
|
+
await startBackgroundReview(repo, { head: 'b'.repeat(40), branch: 'feature', base: 'main' }, sleeper, exec);
|
|
102
|
+
const second = (await reviewStatus(repo, 'feature', exec))?.running;
|
|
103
|
+
expect(second?.pid).not.toBe(first.pid);
|
|
104
|
+
for (let waited = 0; alive(first.pid) && waited < 3000; waited += 50)
|
|
105
|
+
await new Promise(resolve => setTimeout(resolve, 50)); // under load the signal lands later
|
|
106
|
+
expect(alive(first.pid)).toBe(false);
|
|
107
|
+
process.kill(second.pid);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
function alive(pid) {
|
|
111
|
+
try {
|
|
112
|
+
process.kill(pid, 0);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a panel agrees, as pure functions: when two judges' items are the same finding (similarity),
|
|
3
|
+
* the minimum-cost one-to-one assignment (the Hungarian algorithm), the grouping of every judge's
|
|
4
|
+
* items into findings, and the majority rule that decides a finding from every judge's call.
|
|
5
|
+
* Deterministic: inputs sorted, ties broken on file, line and id. panel.ts runs the judges around it.
|
|
6
|
+
*/
|
|
7
|
+
import type { OpenItem } from './verdict.js';
|
|
8
|
+
export type Call = 'raised' | 'confirm' | 'refute' | 'unsure' | 'not asked';
|
|
9
|
+
export type PanelStatus = 'confirmed' | 'disputed' | 'dropped';
|
|
10
|
+
/** Similarity at or above which two items are the same finding. */
|
|
11
|
+
export declare const MATCH_THRESHOLD = 0.5;
|
|
12
|
+
export declare const order: (a: OpenItem, b: OpenItem) => number;
|
|
13
|
+
/** Shared words over all words (Jaccard) of two items' issue and consequence. */
|
|
14
|
+
export declare function textSimilarity(a: OpenItem, b: OpenItem): number;
|
|
15
|
+
/** 0 for different files; otherwise wording, line distance and class, weighted. A far-apart pair needs close wording. */
|
|
16
|
+
export declare function similarity(a: OpenItem, b: OpenItem): number;
|
|
17
|
+
/** One finding as the panel sees it: the item each judge who raised it wrote, in judge order. */
|
|
18
|
+
export interface Cluster {
|
|
19
|
+
members: Array<{
|
|
20
|
+
judge: string;
|
|
21
|
+
item: OpenItem;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The judges' items grouped into findings. Judges are taken in order; each judge's items are
|
|
26
|
+
* assigned one-to-one to the findings so far by the Hungarian algorithm (a finding scores as its
|
|
27
|
+
* closest member), never first-fit. An item left over that a finding already holding this judge
|
|
28
|
+
* resembles joins it (one judge split what another named once); any other leftover is a new finding.
|
|
29
|
+
*/
|
|
30
|
+
export declare function clusterItems(judges: string[], items: OpenItem[][]): Cluster[];
|
|
31
|
+
/**
|
|
32
|
+
* Status from every judge's call. Confirmed: a strict majority raised it or confirmed it with
|
|
33
|
+
* evidence. Dropped: refutations with evidence at least match the support and no judge is unsure.
|
|
34
|
+
* Disputed otherwise. With two judges: both raised it, or the other confirmed it; refuted, dropped.
|
|
35
|
+
*/
|
|
36
|
+
export declare function statusOf(calls: Record<string, Call>, judges: number): PanelStatus;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** Similarity at or above which two items are the same finding. */
|
|
2
|
+
export const MATCH_THRESHOLD = 0.5;
|
|
3
|
+
/** Below this share of words, two items are the same finding only on the same line and in the same class. */
|
|
4
|
+
const MIN_SHARED_WORDS = 0.15;
|
|
5
|
+
const LINE_WINDOW = 5;
|
|
6
|
+
const STOP = new Set(['the', 'and', 'for', 'that', 'this', 'with', 'from', 'when', 'into', 'are', 'not', 'but', 'its', 'has', 'have', 'every', 'each', 'one', 'can']);
|
|
7
|
+
export const order = (a, b) => (a.file ?? '').localeCompare(b.file ?? '') || (a.line ?? 0) - (b.line ?? 0) || a.id.localeCompare(b.id);
|
|
8
|
+
/** Shared words over all words (Jaccard) of two items' issue and consequence. */
|
|
9
|
+
export function textSimilarity(a, b) {
|
|
10
|
+
const wa = words(a);
|
|
11
|
+
const wb = words(b);
|
|
12
|
+
const shared = [...wa].filter(w => wb.has(w)).length;
|
|
13
|
+
return wa.size + wb.size - shared ? shared / (wa.size + wb.size - shared) : 0;
|
|
14
|
+
}
|
|
15
|
+
function words(item) {
|
|
16
|
+
const text = `${item.issue} ${item.consequence ?? ''}`.toLowerCase();
|
|
17
|
+
return new Set((text.match(/[a-z_][a-z0-9_]{2,}/g) ?? []).filter(w => !STOP.has(w)));
|
|
18
|
+
}
|
|
19
|
+
/** 0 for different files; otherwise wording, line distance and class, weighted. A far-apart pair needs close wording. */
|
|
20
|
+
export function similarity(a, b) {
|
|
21
|
+
if (!a.file || a.file !== b.file)
|
|
22
|
+
return 0;
|
|
23
|
+
const text = textSimilarity(a, b);
|
|
24
|
+
const distance = a.line !== undefined && b.line !== undefined ? Math.abs(a.line - b.line) : undefined;
|
|
25
|
+
const line = distance === undefined ? 0.5 : distance <= LINE_WINDOW ? 1 - distance / (LINE_WINDOW + 1) : 0;
|
|
26
|
+
if (line === 0 && text < 0.5)
|
|
27
|
+
return 0;
|
|
28
|
+
if (text < MIN_SHARED_WORDS && !(distance === 0 && a.class === b.class))
|
|
29
|
+
return 0;
|
|
30
|
+
return 0.4 * text + 0.35 * line + 0.25 * (a.class === b.class ? 1 : 0);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Minimum-cost assignment of rows to columns (Hungarian algorithm with potentials, O(n²m)).
|
|
34
|
+
* `cost` is rows × columns with rows ≤ columns; returns the column of each row.
|
|
35
|
+
*/
|
|
36
|
+
function assign(cost) {
|
|
37
|
+
const n = cost.length;
|
|
38
|
+
const m = n ? cost[0].length : 0;
|
|
39
|
+
const u = new Array(n + 1).fill(0);
|
|
40
|
+
const v = new Array(m + 1).fill(0);
|
|
41
|
+
const p = new Array(m + 1).fill(0);
|
|
42
|
+
const way = new Array(m + 1).fill(0);
|
|
43
|
+
for (let i = 1; i <= n; i++) {
|
|
44
|
+
p[0] = i;
|
|
45
|
+
let j0 = 0;
|
|
46
|
+
const minv = new Array(m + 1).fill(Infinity);
|
|
47
|
+
const used = new Array(m + 1).fill(false);
|
|
48
|
+
do {
|
|
49
|
+
used[j0] = true;
|
|
50
|
+
const i0 = p[j0];
|
|
51
|
+
let delta = Infinity;
|
|
52
|
+
let j1 = 0;
|
|
53
|
+
for (let j = 1; j <= m; j++) {
|
|
54
|
+
if (used[j])
|
|
55
|
+
continue;
|
|
56
|
+
const cur = cost[i0 - 1][j - 1] - u[i0] - v[j];
|
|
57
|
+
if (cur < minv[j]) {
|
|
58
|
+
minv[j] = cur;
|
|
59
|
+
way[j] = j0;
|
|
60
|
+
}
|
|
61
|
+
if (minv[j] < delta) {
|
|
62
|
+
delta = minv[j];
|
|
63
|
+
j1 = j;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (let j = 0; j <= m; j++) {
|
|
67
|
+
if (used[j]) {
|
|
68
|
+
u[p[j]] += delta;
|
|
69
|
+
v[j] -= delta;
|
|
70
|
+
}
|
|
71
|
+
else
|
|
72
|
+
minv[j] -= delta;
|
|
73
|
+
}
|
|
74
|
+
j0 = j1;
|
|
75
|
+
} while (p[j0] !== 0);
|
|
76
|
+
do {
|
|
77
|
+
const j1 = way[j0];
|
|
78
|
+
p[j0] = p[j1];
|
|
79
|
+
j0 = j1;
|
|
80
|
+
} while (j0);
|
|
81
|
+
}
|
|
82
|
+
const column = new Array(n).fill(-1);
|
|
83
|
+
for (let j = 1; j <= m; j++)
|
|
84
|
+
if (p[j])
|
|
85
|
+
column[p[j] - 1] = j - 1;
|
|
86
|
+
return column;
|
|
87
|
+
}
|
|
88
|
+
const clusterSimilarity = (cluster, item) => Math.max(...cluster.members.map(m => similarity(m.item, item)));
|
|
89
|
+
/**
|
|
90
|
+
* The judges' items grouped into findings. Judges are taken in order; each judge's items are
|
|
91
|
+
* assigned one-to-one to the findings so far by the Hungarian algorithm (a finding scores as its
|
|
92
|
+
* closest member), never first-fit. An item left over that a finding already holding this judge
|
|
93
|
+
* resembles joins it (one judge split what another named once); any other leftover is a new finding.
|
|
94
|
+
*/
|
|
95
|
+
export function clusterItems(judges, items) {
|
|
96
|
+
const clusters = [];
|
|
97
|
+
judges.forEach((judge, j) => {
|
|
98
|
+
const mine = [...items[j]].sort(order);
|
|
99
|
+
const open = clusters.map((c, i) => ({ c, i })).filter(x => !x.c.members.some(m => m.judge === judge));
|
|
100
|
+
const sims = mine.map(item => open.map(x => clusterSimilarity(x.c, item)));
|
|
101
|
+
const placed = new Set();
|
|
102
|
+
if (mine.length && open.length) {
|
|
103
|
+
const transpose = mine.length > open.length;
|
|
104
|
+
const matrix = transpose ? open.map((_, k) => mine.map((__, i) => sims[i][k])) : sims;
|
|
105
|
+
const column = assign(matrix.map(row => row.map(s => (s >= MATCH_THRESHOLD ? 1 - s : 1))));
|
|
106
|
+
column.forEach((col, row) => {
|
|
107
|
+
if (col < 0)
|
|
108
|
+
return;
|
|
109
|
+
const [i, k] = transpose ? [col, row] : [row, col];
|
|
110
|
+
if (sims[i][k] < MATCH_THRESHOLD)
|
|
111
|
+
return;
|
|
112
|
+
open[k].c.members.push({ judge, item: mine[i] });
|
|
113
|
+
placed.add(i);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
mine.forEach((item, i) => {
|
|
117
|
+
if (placed.has(i))
|
|
118
|
+
return;
|
|
119
|
+
// Only a finding another judge also holds: a judge's own two findings are never merged into one.
|
|
120
|
+
const home = clusters.filter(c => c.members.some(m => m.judge === judge) && c.members.some(m => m.judge !== judge)).map(c => ({ c, s: clusterSimilarity(c, item) }))
|
|
121
|
+
.filter(x => x.s >= MATCH_THRESHOLD).sort((a, b) => b.s - a.s || order(a.c.members[0].item, b.c.members[0].item))[0];
|
|
122
|
+
if (home)
|
|
123
|
+
home.c.members.push({ judge, item });
|
|
124
|
+
else
|
|
125
|
+
clusters.push({ members: [{ judge, item }] });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
return clusters;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Status from every judge's call. Confirmed: a strict majority raised it or confirmed it with
|
|
132
|
+
* evidence. Dropped: refutations with evidence at least match the support and no judge is unsure.
|
|
133
|
+
* Disputed otherwise. With two judges: both raised it, or the other confirmed it; refuted, dropped.
|
|
134
|
+
*/
|
|
135
|
+
export function statusOf(calls, judges) {
|
|
136
|
+
const values = Object.values(calls);
|
|
137
|
+
const support = values.filter(c => c === 'raised' || c === 'confirm').length;
|
|
138
|
+
const refuted = values.filter(c => c === 'refute').length;
|
|
139
|
+
if (support > judges / 2)
|
|
140
|
+
return 'confirmed';
|
|
141
|
+
if (refuted && refuted >= support && !values.includes('unsure') && !values.includes('not asked'))
|
|
142
|
+
return 'dropped';
|
|
143
|
+
return 'disputed';
|
|
144
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { RouterPolicy } from '../../deep/router.js';
|
|
2
|
+
import type { PanelItem } from './panel.js';
|
|
3
|
+
import { type Exec } from './exec.js';
|
|
4
|
+
import type { OpenItem } from './verdict.js';
|
|
5
|
+
export declare const REVIEW_DISMISSALS: string;
|
|
6
|
+
export interface ReviewDismissal {
|
|
7
|
+
id: string;
|
|
8
|
+
file?: string;
|
|
9
|
+
line?: number;
|
|
10
|
+
class: string;
|
|
11
|
+
issue: string;
|
|
12
|
+
reason: string;
|
|
13
|
+
at: string;
|
|
14
|
+
by?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function readReviewDismissals(cwd: string): ReviewDismissal[];
|
|
17
|
+
/**
|
|
18
|
+
* The dismissal an item repeats: the same id, or the same finding re-worded: the same file and
|
|
19
|
+
* class, within a few lines, and mostly the same words. A different bug on the same line, or a
|
|
20
|
+
* finding that only shares the file and class, is not dismissed.
|
|
21
|
+
*/
|
|
22
|
+
export declare function dismissedAs(item: OpenItem, dismissals: ReviewDismissal[]): ReviewDismissal | undefined;
|
|
23
|
+
export interface ContextInput {
|
|
24
|
+
cwd: string;
|
|
25
|
+
/** Where .rigour lives: the main checkout when cwd is the background reviewer's worktree. */
|
|
26
|
+
stateRoot: string;
|
|
27
|
+
/** The team's dismissals, when it allows them (empty otherwise). */
|
|
28
|
+
dismissals: ReviewDismissal[];
|
|
29
|
+
diff: string;
|
|
30
|
+
router: RouterPolicy | undefined;
|
|
31
|
+
/** The previous verdict's panel decisions, and the files changed since it. */
|
|
32
|
+
previousPanel: PanelItem[] | undefined;
|
|
33
|
+
touched: Set<string>;
|
|
34
|
+
/** Docs that name the changed code (relatedDocs). */
|
|
35
|
+
docs: Array<{
|
|
36
|
+
doc: string;
|
|
37
|
+
names: string[];
|
|
38
|
+
}>;
|
|
39
|
+
/** Findings Rigour's checks already report on this change, one line each. */
|
|
40
|
+
checks: string[];
|
|
41
|
+
}
|
|
42
|
+
/** A review's result as the reviewer's inputs: its hints, and what its checks found, as settled. */
|
|
43
|
+
export declare function reviewerInputs(review: {
|
|
44
|
+
hints: string[];
|
|
45
|
+
findings: Array<{
|
|
46
|
+
files?: string[];
|
|
47
|
+
line?: number;
|
|
48
|
+
title: string;
|
|
49
|
+
}>;
|
|
50
|
+
}): {
|
|
51
|
+
hints: string;
|
|
52
|
+
checks: string[];
|
|
53
|
+
};
|
|
54
|
+
/** The context pack as Markdown, its hash for the fingerprint, and the router's count of risky changed functions (undefined when it could not score). */
|
|
55
|
+
export declare function buildContext(input: ContextInput): {
|
|
56
|
+
text: string;
|
|
57
|
+
key: string;
|
|
58
|
+
risky: number | undefined;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Tracked Markdown docs that name a changed file by path or by a distinctive file stem: one
|
|
62
|
+
* `git grep` over the docs, never a read of every one, stopping at the first few.
|
|
63
|
+
*/
|
|
64
|
+
export declare function relatedDocs(cwd: string, changedFiles: string[], exec?: Exec): Promise<Array<{
|
|
65
|
+
doc: string;
|
|
66
|
+
names: string[];
|
|
67
|
+
}>>;
|
|
68
|
+
/**
|
|
69
|
+
* `rigour dismiss <id>` on a reviewer finding: only where the team allows it (`review.reviewer.dismissals`), with a
|
|
70
|
+
* reason, recorded with who dismissed it. The item is found in this branch's latest verdict.
|
|
71
|
+
*/
|
|
72
|
+
export declare function dismissReviewerFinding(cwd: string, id: string, reason: string, allowed: boolean, exec?: Exec): Promise<{
|
|
73
|
+
item?: OpenItem;
|
|
74
|
+
error?: string;
|
|
75
|
+
}>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the team already knows, handed to every judge so a review starts from it instead of from
|
|
3
|
+
* nothing: the verified review lessons and the repository rules for the files the change touches,
|
|
4
|
+
* the findings the team settled (dismissed as not a bug, or refuted with evidence in an earlier
|
|
5
|
+
* round on a file nothing has changed since), and the docs that name the changed code. Built
|
|
6
|
+
* deterministically, without a model; its hash is part of the verdict's fingerprint, so a new
|
|
7
|
+
* lesson or dismissal is never answered by a verdict cached before it.
|
|
8
|
+
*
|
|
9
|
+
* The reviewer dismissal store lives here too: `rigour dismiss <id>` on a reviewer finding records
|
|
10
|
+
* the finding itself, so the same finding re-worded on the same file is recognised and never
|
|
11
|
+
* blocks again.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { createHash } from 'crypto';
|
|
16
|
+
import { buildReviewTask } from '../review-task.js';
|
|
17
|
+
import { reviewedKeys } from '../ledger.js';
|
|
18
|
+
import { textSimilarity } from './consensus.js';
|
|
19
|
+
import { defaultExec, GH_TIMEOUT_MS } from './exec.js';
|
|
20
|
+
import { VerdictStore } from './store.js';
|
|
21
|
+
export const REVIEW_DISMISSALS = path.join('.rigour', 'dismissed-review-items.json');
|
|
22
|
+
const MAX_DOCS = 10;
|
|
23
|
+
const MAX_SETTLED = 40;
|
|
24
|
+
export function readReviewDismissals(cwd) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(cwd, REVIEW_DISMISSALS), 'utf8'));
|
|
27
|
+
return Array.isArray(parsed?.entries) ? parsed.entries.filter((e) => typeof e?.id === 'string' && typeof e?.issue === 'string') : [];
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Records "not a bug" for a reviewer finding; idempotent. A human's own review point is never dismissed this way. */
|
|
34
|
+
function dismissReviewItem(cwd, item, reason, by, at = new Date().toISOString()) {
|
|
35
|
+
if (item.kind === 'prior')
|
|
36
|
+
return false;
|
|
37
|
+
const entries = readReviewDismissals(cwd);
|
|
38
|
+
if (entries.some(e => e.id === item.id))
|
|
39
|
+
return true;
|
|
40
|
+
entries.push({ id: item.id, ...(item.file ? { file: item.file } : {}), ...(item.line ? { line: item.line } : {}), class: item.class, issue: item.issue, reason, at, ...(by ? { by } : {}) });
|
|
41
|
+
fs.mkdirSync(path.join(cwd, '.rigour'), { recursive: true });
|
|
42
|
+
fs.writeFileSync(path.join(cwd, REVIEW_DISMISSALS), JSON.stringify({ entries }, null, 2));
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
/** Close enough in place and wording that a dismissal of one is a dismissal of the other; a different bug nearby never is. */
|
|
46
|
+
const DISMISSAL_LINES = 3;
|
|
47
|
+
const DISMISSAL_WORDS = 0.6;
|
|
48
|
+
/**
|
|
49
|
+
* The dismissal an item repeats: the same id, or the same finding re-worded: the same file and
|
|
50
|
+
* class, within a few lines, and mostly the same words. A different bug on the same line, or a
|
|
51
|
+
* finding that only shares the file and class, is not dismissed.
|
|
52
|
+
*/
|
|
53
|
+
export function dismissedAs(item, dismissals) {
|
|
54
|
+
if (item.kind === 'prior')
|
|
55
|
+
return undefined;
|
|
56
|
+
return dismissals.find(d => d.id === item.id) ?? dismissals.find(d => d.file === item.file && d.class === item.class
|
|
57
|
+
&& (d.line === undefined || item.line === undefined || Math.abs(d.line - item.line) <= DISMISSAL_LINES)
|
|
58
|
+
&& textSimilarity({ ...item, consequence: undefined }, { id: d.id, kind: 'finding', class: d.class, issue: d.issue }) >= DISMISSAL_WORDS);
|
|
59
|
+
}
|
|
60
|
+
/** A review's result as the reviewer's inputs: its hints, and what its checks found, as settled. */
|
|
61
|
+
export function reviewerInputs(review) {
|
|
62
|
+
return { hints: review.hints.join('\n'), checks: review.findings.map(f => `${f.files?.[0] ?? '?'}${f.line ? `:${f.line}` : ''} ${f.title}`) };
|
|
63
|
+
}
|
|
64
|
+
/** The context pack as Markdown, its hash for the fingerprint, and the router's count of risky changed functions (undefined when it could not score). */
|
|
65
|
+
export function buildContext(input) {
|
|
66
|
+
const sections = [];
|
|
67
|
+
let task;
|
|
68
|
+
try {
|
|
69
|
+
task = buildReviewTask(input.cwd, input.diff, input.router, undefined, undefined, reviewedKeys(input.stateRoot));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
task = undefined;
|
|
73
|
+
}
|
|
74
|
+
if (task?.lessons.length)
|
|
75
|
+
sections.push(`## Lessons this team verified on earlier reviews, for the files this change touches\n${task.lessons.map(l => `- ${l.file}: ${l.text}${l.prs.length ? ` (PR ${l.prs.join(', ')})` : ''}`).join('\n')}`);
|
|
76
|
+
if (task?.rules.length)
|
|
77
|
+
sections.push(`## Repository rules that name what this change touches\n${task.rules.map(r => `- ${r.source}: ${r.text}`).join('\n')}`);
|
|
78
|
+
if (input.checks.length)
|
|
79
|
+
sections.push(`## Already found by Rigour's checks: they block on their own, so do not report them again\n${input.checks.slice(0, MAX_SETTLED).map(c => `- ${c}`).join('\n')}`);
|
|
80
|
+
const dismissed = input.dismissals.slice(-MAX_SETTLED).map(d => `- dismissed as not a bug${d.by ? ` by ${d.by}` : ''}: ${where(d)} [${d.class}] ${d.issue} (reason: ${d.reason})`);
|
|
81
|
+
const refuted = (input.previousPanel ?? []).filter(p => p.status === 'dropped' && !!p.item.file && !input.touched.has(p.item.file)).slice(0, MAX_SETTLED)
|
|
82
|
+
.map(p => `- refuted with evidence in the last round: ${where(p.item)} [${p.item.class}] ${p.item.issue} (${(p.cross ?? []).find(c => c.call === 'refute')?.evidence ?? 'evidence in the previous verdict'})`);
|
|
83
|
+
if (dismissed.length || refuted.length)
|
|
84
|
+
sections.push(`## Settled: do not raise these again unless the code now shows something new\n${[...dismissed, ...refuted].join('\n')}`);
|
|
85
|
+
const docs = input.docs;
|
|
86
|
+
if (docs.length)
|
|
87
|
+
sections.push(`## Docs that describe the changed code (read one when its claim matters to a finding)\n${docs.map(d => `- ${d.doc} (names ${d.names.join(', ')})`).join('\n')}`);
|
|
88
|
+
const text = sections.length ? `# What this team already knows\n\n${sections.join('\n\n')}\n` : 'none\n';
|
|
89
|
+
return { text, key: createHash('sha256').update(text).digest('hex').slice(0, 16), risky: task ? task.items.length + task.alreadyReviewed : undefined };
|
|
90
|
+
}
|
|
91
|
+
function where(x) {
|
|
92
|
+
return x.file ? `${x.file}${x.line ? `:${x.line}` : ''}` : '(no file)';
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Tracked Markdown docs that name a changed file by path or by a distinctive file stem: one
|
|
96
|
+
* `git grep` over the docs, never a read of every one, stopping at the first few.
|
|
97
|
+
*/
|
|
98
|
+
export async function relatedDocs(cwd, changedFiles, exec = defaultExec) {
|
|
99
|
+
const names = [...new Set(changedFiles.flatMap(file => {
|
|
100
|
+
const stem = path.posix.basename(file).replace(/\.[^.]+$/, '');
|
|
101
|
+
return stem.length >= 5 && !/^(index|types|utils|helpers|config|main)$/.test(stem) ? [file, stem] : [file];
|
|
102
|
+
}))];
|
|
103
|
+
if (!names.length)
|
|
104
|
+
return [];
|
|
105
|
+
const grep = await exec('git', ['grep', '-I', '-F', '-o', ...names.flatMap(name => ['-e', name]), '--', '*.md'], { cwd, timeoutMs: GH_TIMEOUT_MS });
|
|
106
|
+
const found = new Map();
|
|
107
|
+
for (const line of grep.stdout.split('\n')) {
|
|
108
|
+
const at = line.indexOf(':');
|
|
109
|
+
if (at <= 0)
|
|
110
|
+
continue;
|
|
111
|
+
const doc = line.slice(0, at);
|
|
112
|
+
if (!found.has(doc) && found.size >= MAX_DOCS)
|
|
113
|
+
continue;
|
|
114
|
+
found.set(doc, (found.get(doc) ?? new Set()).add(line.slice(at + 1)));
|
|
115
|
+
}
|
|
116
|
+
return [...found].map(([doc, hits]) => ({ doc, names: [...hits].slice(0, 3) }));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* `rigour dismiss <id>` on a reviewer finding: only where the team allows it (`review.reviewer.dismissals`), with a
|
|
120
|
+
* reason, recorded with who dismissed it. The item is found in this branch's latest verdict.
|
|
121
|
+
*/
|
|
122
|
+
export async function dismissReviewerFinding(cwd, id, reason, allowed, exec = defaultExec) {
|
|
123
|
+
if (!allowed)
|
|
124
|
+
return { error: 'this team does not dismiss reviewer findings (review.reviewer.dismissals is off): fix the code, or improve the reviewer (its prompt, rules or the reviewers it runs) so the finding is not raised' };
|
|
125
|
+
if (reason.trim().length < 5)
|
|
126
|
+
return { error: 'say why it is not a bug, in a few words' };
|
|
127
|
+
const store = await VerdictStore.open(cwd, exec);
|
|
128
|
+
if (!store)
|
|
129
|
+
return { error: 'not a git repository' };
|
|
130
|
+
const branch = (await exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout.trim();
|
|
131
|
+
const state = store.branchState(branch);
|
|
132
|
+
const open = state ? store.readJson(store.openPath(state.verdict)) ?? [] : [];
|
|
133
|
+
const item = open.find(i => i.id === id);
|
|
134
|
+
if (!item)
|
|
135
|
+
return { error: `no open reviewer finding ${id} on ${branch}: run \`rigour review --reviewer\` and copy the id it shows` };
|
|
136
|
+
const who = (await exec('git', ['config', 'user.email'], { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout.trim() || (await exec('git', ['config', 'user.name'], { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout.trim();
|
|
137
|
+
if (!dismissReviewItem(cwd, item, reason, who))
|
|
138
|
+
return { error: `${id} is a human review point: answer it in the reply, it cannot be dismissed here` };
|
|
139
|
+
return { item };
|
|
140
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { dismissedAs } from './context.js';
|
|
3
|
+
const dismissal = { id: 'abcdef0123', file: 'src/x.ts', line: 40, class: 'correctness', issue: 'cache key built from user id may collide', reason: 'ids are unique per tenant', at: '2026-10-06' };
|
|
4
|
+
const finding = (over) => ({ id: 'ffff000011', kind: 'finding', class: 'correctness', file: 'src/x.ts', line: 40, issue: 'cache key built from the user id may collide', consequence: 'two users share an entry', ...over });
|
|
5
|
+
describe('a dismissal', () => {
|
|
6
|
+
it('covers the same finding re-worded nearby', () => {
|
|
7
|
+
expect(dismissedAs(finding({}), [dismissal])).toBe(dismissal);
|
|
8
|
+
expect(dismissedAs(finding({ line: 42, issue: 'the cache key built from user id may collide' }), [dismissal])).toBe(dismissal);
|
|
9
|
+
expect(dismissedAs(finding({ id: 'abcdef0123', issue: 'anything at all' }), [dismissal])).toBe(dismissal); // its own id
|
|
10
|
+
});
|
|
11
|
+
it('never hides a different bug, even on the same line and in the same class', () => {
|
|
12
|
+
expect(dismissedAs(finding({ issue: 'cache entry never invalidated when the user changes' }), [dismissal])).toBeUndefined();
|
|
13
|
+
expect(dismissedAs(finding({ line: 42, issue: 'cache entry never invalidated when user id changes' }), [dismissal])).toBeUndefined();
|
|
14
|
+
expect(dismissedAs(finding({ line: 80 }), [dismissal])).toBeUndefined(); // far away
|
|
15
|
+
expect(dismissedAs(finding({ class: 'production-cost' }), [dismissal])).toBeUndefined();
|
|
16
|
+
expect(dismissedAs({ ...finding({}), kind: 'prior' }, [dismissal])).toBeUndefined(); // a human's point is never dismissed this way
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type Exec = (command: string, args: string[], options: {
|
|
2
|
+
cwd: string;
|
|
3
|
+
timeoutMs: number;
|
|
4
|
+
env?: Record<string, string>;
|
|
5
|
+
}) => Promise<{
|
|
6
|
+
exitCode: number;
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
}>;
|
|
10
|
+
export declare const defaultExec: Exec;
|
|
11
|
+
/** Called while the reviewer works, so a slow run and a stuck one look different. */
|
|
12
|
+
export type Progress = (message: string) => void;
|
|
13
|
+
export declare const GH_TIMEOUT_MS = 60000;
|
|
14
|
+
/** The named GitHub account's token for `gh`, when the person keeps several; otherwise gh's own. */
|
|
15
|
+
export declare function githubEnv(cwd: string, account: string | undefined, exec: Exec): Promise<Record<string, string> | undefined>;
|
|
16
|
+
/** `gh --paginate` prints one JSON array per page. */
|
|
17
|
+
export declare function parseJsonArrays(text: string): any[];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** How the reviewer runs commands (git, gh, the agent CLIs); tests replace it. */
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
export const defaultExec = async (command, args, options) => {
|
|
4
|
+
// stdin is /dev/null, not an empty pipe: an agent CLI that reads stdin sees its end at once, and a command that exits
|
|
5
|
+
// before an empty write lands (git rev-parse) cannot fail the run with EPIPE, as it did on Linux.
|
|
6
|
+
const result = await execa(command, args, { cwd: options.cwd, reject: false, timeout: options.timeoutMs, stdin: 'ignore', env: options.env ? { ...process.env, ...options.env } : undefined, maxBuffer: 64 * 1024 * 1024 });
|
|
7
|
+
const stderr = String(result.stderr ?? '');
|
|
8
|
+
// A command that could not start, timed out or was killed writes nothing itself: say which, never an empty failure.
|
|
9
|
+
return { exitCode: result.exitCode ?? 1, stdout: String(result.stdout ?? ''), stderr: stderr || (result.failed && 'shortMessage' in result ? String(result.shortMessage) : '') };
|
|
10
|
+
};
|
|
11
|
+
export const GH_TIMEOUT_MS = 60_000;
|
|
12
|
+
/** The named GitHub account's token for `gh`, when the person keeps several; otherwise gh's own. */
|
|
13
|
+
export async function githubEnv(cwd, account, exec) {
|
|
14
|
+
if (!account || process.env.GH_TOKEN)
|
|
15
|
+
return undefined;
|
|
16
|
+
const token = await exec('gh', ['auth', 'token', '--user', account], { cwd, timeoutMs: GH_TIMEOUT_MS });
|
|
17
|
+
return token.exitCode === 0 && token.stdout.trim() ? { GH_TOKEN: token.stdout.trim() } : undefined;
|
|
18
|
+
}
|
|
19
|
+
/** `gh --paginate` prints one JSON array per page. */
|
|
20
|
+
export function parseJsonArrays(text) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(`[${text.trim().replace(/\]\s*\[/g, '],[')}]`).flat();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { defaultExec } from './exec.js';
|
|
4
|
+
describe('running a command', () => {
|
|
5
|
+
it('passes the command\'s own output through', async () => {
|
|
6
|
+
const result = await defaultExec(process.execPath, ['-e', 'process.stdout.write("out"); process.stderr.write("err"); process.exit(3)'], { cwd: os.tmpdir(), timeoutMs: 30_000 });
|
|
7
|
+
expect(result).toEqual({ exitCode: 3, stdout: 'out', stderr: 'err' });
|
|
8
|
+
});
|
|
9
|
+
it('gives the command an ended stdin, so one that reads it never waits and one that exits at once never fails', async () => {
|
|
10
|
+
const reader = await defaultExec(process.execPath, ['-e', 'let n = 0; process.stdin.on("data", d => n += d.length).on("end", () => process.stdout.write(`read ${n}`))'], { cwd: os.tmpdir(), timeoutMs: 30_000 });
|
|
11
|
+
expect(reader).toEqual({ exitCode: 0, stdout: 'read 0', stderr: '' });
|
|
12
|
+
const runs = await Promise.all(Array.from({ length: 50 }, () => defaultExec(process.execPath, ['-e', 'process.stdout.write("ok")'], { cwd: os.tmpdir(), timeoutMs: 30_000 })));
|
|
13
|
+
expect(runs.every(r => r.exitCode === 0 && r.stdout === 'ok')).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
it('says why a command that never answered failed: not started, or timed out', async () => {
|
|
16
|
+
const missing = await defaultExec('rigour-no-such-command', [], { cwd: os.tmpdir(), timeoutMs: 30_000 });
|
|
17
|
+
expect(missing.exitCode).not.toBe(0);
|
|
18
|
+
expect(missing.stderr).toContain('rigour-no-such-command'); // ENOENT on Unix, "is not recognized" on Windows
|
|
19
|
+
const slow = await defaultExec(process.execPath, ['-e', 'setTimeout(() => {}, 10_000)'], { cwd: os.tmpdir(), timeoutMs: 200 });
|
|
20
|
+
expect(slow.exitCode).not.toBe(0);
|
|
21
|
+
expect(slow.stderr).toMatch(/timed out/i);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Exec } from './exec.js';
|
|
2
|
+
export interface PullRequest {
|
|
3
|
+
number: number;
|
|
4
|
+
state: 'open' | 'closed' | 'merged';
|
|
5
|
+
draft: boolean;
|
|
6
|
+
author: string;
|
|
7
|
+
body: string;
|
|
8
|
+
}
|
|
9
|
+
export interface HumanReviews {
|
|
10
|
+
/** As the reviewer reads them, oldest first, inline comments after; `none` when there are none. */
|
|
11
|
+
markdown: string;
|
|
12
|
+
/** Changes when a review or comment is added or edited: part of the verdict fingerprint. */
|
|
13
|
+
key: string;
|
|
14
|
+
count: number;
|
|
15
|
+
/** `<login>, <date> (<n> reviews)` of the latest, for the report. */
|
|
16
|
+
label?: string;
|
|
17
|
+
}
|
|
18
|
+
type Gh = (args: string[]) => ReturnType<Exec>;
|
|
19
|
+
export declare function ghFor(cwd: string, exec: Exec, env: Record<string, string> | undefined): Gh;
|
|
20
|
+
/** The pull request for the branch (or the one named, or the one the commit is on), or none, or why it could not be read. */
|
|
21
|
+
export declare function findPullRequest(gh: Gh, branch: string, head: string, named: number | undefined): Promise<{
|
|
22
|
+
pr?: PullRequest;
|
|
23
|
+
error?: string;
|
|
24
|
+
}>;
|
|
25
|
+
/** Written by a person: not a bot, and not the pull request's author answering their own review. */
|
|
26
|
+
export declare function isHuman(item: any, author: string): boolean;
|
|
27
|
+
/** Every review by a person and every inline comment by a person, hidden from `reviewsBefore` on. */
|
|
28
|
+
export declare function humanReviews(gh: Gh, pr: PullRequest, reviewsBefore: string | undefined): Promise<{
|
|
29
|
+
reviews?: HumanReviews;
|
|
30
|
+
error?: string;
|
|
31
|
+
}>;
|
|
32
|
+
/** The repository's rules as the reviewer must read them. */
|
|
33
|
+
export declare function rulesText(cwd: string): string;
|
|
34
|
+
export declare function sha(parts: string[]): string;
|
|
35
|
+
/** True when HEAD is a merge whose second parent is on the base: the branch merged main in. */
|
|
36
|
+
export declare function mergesBaseIn(cwd: string, baseSha: string, exec: Exec): Promise<boolean>;
|
|
37
|
+
/** Added and removed lines between two commits. */
|
|
38
|
+
export declare function linesChanged(cwd: string, from: string, to: string, exec: Exec): Promise<number>;
|
|
39
|
+
export {};
|