@rigour-labs/core 6.6.5 → 6.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gates/frontend-secret-exposure.d.ts +1 -1
- package/dist/gates/frontend-secret-exposure.js +9 -3
- package/dist/gates/frontend-secret-exposure.test.js +4 -1
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/stop-review.d.ts +6 -9
- package/dist/hooks/stop-review.js +5 -10
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/review/backtest-init.d.ts +8 -0
- package/dist/review/backtest-init.js +87 -0
- package/dist/review/backtest-init.test.d.ts +1 -0
- package/dist/review/backtest-init.test.js +74 -0
- package/dist/review/backtest.d.ts +315 -0
- package/dist/review/backtest.js +197 -0
- package/dist/review/backtest.test.d.ts +1 -0
- package/dist/review/backtest.test.js +129 -0
- package/dist/review/dead-code.test.js +8 -0
- package/dist/review/lint-overlay.d.ts +21 -0
- package/dist/review/lint-overlay.js +89 -0
- package/dist/review/quiet.d.ts +6 -0
- package/dist/review/quiet.js +12 -2
- package/dist/review/quiet.test.js +11 -0
- package/dist/review/review.d.ts +6 -0
- package/dist/review/review.js +8 -1
- package/dist/review/reviewer/adapters.d.ts +35 -0
- package/dist/review/reviewer/adapters.js +156 -0
- package/dist/review/reviewer/background.d.ts +34 -0
- package/dist/review/reviewer/background.js +128 -0
- package/dist/review/reviewer/background.test.d.ts +1 -0
- package/dist/review/reviewer/background.test.js +104 -0
- package/dist/review/reviewer/exec.d.ts +17 -0
- package/dist/review/reviewer/exec.js +23 -0
- package/dist/review/reviewer/inputs.d.ts +37 -0
- package/dist/review/reviewer/inputs.js +103 -0
- package/dist/review/reviewer/merge-impact.d.ts +2 -0
- package/dist/review/reviewer/merge-impact.js +64 -0
- package/dist/review/reviewer/prompt.d.ts +21 -0
- package/dist/review/reviewer/prompt.js +123 -0
- package/dist/review/reviewer/store.d.ts +26 -0
- package/dist/review/reviewer/store.js +57 -0
- package/dist/review/reviewer/verdict.d.ts +127 -0
- package/dist/review/reviewer/verdict.js +156 -0
- package/dist/review/reviewer.d.ts +41 -49
- package/dist/review/reviewer.js +210 -202
- package/dist/review/reviewer.test.js +199 -48
- package/dist/review/toolchain.d.ts +5 -2
- package/dist/review/toolchain.js +81 -6
- package/dist/review/toolchain.test.js +39 -1
- package/dist/review/typed/program.d.ts +27 -0
- package/dist/review/typed/program.js +90 -0
- package/dist/review/typed/redundancy.d.ts +10 -0
- package/dist/review/typed/redundancy.js +339 -0
- package/dist/review/typed/redundancy.test.d.ts +1 -0
- package/dist/review/typed/redundancy.test.js +121 -0
- package/dist/review/unused-exports.js +8 -4
- package/dist/templates/universal-config.js +8 -1
- package/dist/types/index.d.ts +94 -8
- package/dist/types/index.js +24 -2
- package/package.json +6 -6
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface OverlayMessage {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
column: number;
|
|
5
|
+
rule: string;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
/** Why the overlay cannot run here, or undefined when it can. */
|
|
9
|
+
export declare function overlayUnavailable(cwd: string): string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Writes the overlay config to a scratch directory and returns its path. It is an ES module that
|
|
12
|
+
* imports the repository's config by absolute path, so it is never written into the repository.
|
|
13
|
+
*/
|
|
14
|
+
export declare function writeOverlayConfig(cwd: string): string;
|
|
15
|
+
/** ESLint's JSON report, kept to messages on changed lines; the rest is counted. */
|
|
16
|
+
export declare function onChangedLines(json: string, cwd: string, changedLines: Record<string, Set<number>>): {
|
|
17
|
+
blocking: OverlayMessage[];
|
|
18
|
+
preexisting: number;
|
|
19
|
+
} | {
|
|
20
|
+
error: string;
|
|
21
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-checked lint rules the repository does not enable yet, on changed lines only. They are the
|
|
3
|
+
* mechanical dead-code classes a person keeps finding after lint is green: a condition that can
|
|
4
|
+
* never be true once the types say so, a `?.` on a member every host supplies, a redundant
|
|
5
|
+
* comparison, a promise nobody awaits. The overlay loads the repository's own flat config first,
|
|
6
|
+
* so nothing it enforces is lost, and uses its own `typescript-eslint` (never a download). A
|
|
7
|
+
* message on a line the change did not touch is the team's backlog: counted, not blocking.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import { createRequire } from 'module';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { pathToFileURL } from 'url';
|
|
14
|
+
const CONFIG_FILES = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts', 'eslint.config.mts'];
|
|
15
|
+
const TYPED_RULES = {
|
|
16
|
+
'@typescript-eslint/no-unnecessary-condition': 'error',
|
|
17
|
+
'@typescript-eslint/no-unnecessary-boolean-literal-compare': 'error',
|
|
18
|
+
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
|
19
|
+
'@typescript-eslint/no-redundant-type-constituents': 'error',
|
|
20
|
+
'@typescript-eslint/prefer-optional-chain': 'error',
|
|
21
|
+
'@typescript-eslint/switch-exhaustiveness-check': 'error',
|
|
22
|
+
'@typescript-eslint/no-floating-promises': 'error',
|
|
23
|
+
'@typescript-eslint/no-misused-promises': 'error',
|
|
24
|
+
};
|
|
25
|
+
/** Why the overlay cannot run here, or undefined when it can. */
|
|
26
|
+
export function overlayUnavailable(cwd) {
|
|
27
|
+
if (!CONFIG_FILES.some(name => fs.existsSync(path.join(cwd, name))))
|
|
28
|
+
return 'no eslint.config.* in this repository';
|
|
29
|
+
try {
|
|
30
|
+
createRequire(path.join(cwd, 'package.json')).resolve('typescript-eslint');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return 'typescript-eslint is not installed';
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Writes the overlay config to a scratch directory and returns its path. It is an ES module that
|
|
39
|
+
* imports the repository's config by absolute path, so it is never written into the repository.
|
|
40
|
+
*/
|
|
41
|
+
export function writeOverlayConfig(cwd) {
|
|
42
|
+
const base = CONFIG_FILES.find(name => fs.existsSync(path.join(cwd, name)));
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-lint-overlay-'));
|
|
44
|
+
const file = path.join(dir, 'eslint.overlay.config.mjs');
|
|
45
|
+
fs.writeFileSync(file, `import path from 'node:path';
|
|
46
|
+
import { createRequire } from 'node:module';
|
|
47
|
+
const repo = ${JSON.stringify(cwd)};
|
|
48
|
+
const require = createRequire(path.join(repo, 'package.json'));
|
|
49
|
+
const base = (await import(${JSON.stringify(pathToFileURL(path.join(cwd, base)).href)})).default;
|
|
50
|
+
const ts = require('typescript-eslint');
|
|
51
|
+
const typed = ${JSON.stringify(TYPED_RULES, null, 4)};
|
|
52
|
+
export default [
|
|
53
|
+
...base,
|
|
54
|
+
{
|
|
55
|
+
files: ['**/*.ts', '**/*.mts', '**/*.cts', '**/*.js', '**/*.mjs', '**/*.svelte.ts', '**/*.svelte.js'],
|
|
56
|
+
ignores: ['**/*.d.ts'],
|
|
57
|
+
languageOptions: { parser: ts.parser, parserOptions: { projectService: { allowDefaultProject: ['*.mjs', 'scripts/*.mjs'] }, tsconfigRootDir: repo } },
|
|
58
|
+
rules: typed,
|
|
59
|
+
},
|
|
60
|
+
{ files: ['**/*.svelte'], rules: typed },
|
|
61
|
+
// A test fake deliberately covers cases the types call impossible.
|
|
62
|
+
{ files: ['**/*.test.ts', '**/*.spec.ts', '**/*.svelte.test.ts', 'tests/**', 'test/**', '**/__tests__/**'], rules: { '@typescript-eslint/no-unnecessary-condition': 'off', '@typescript-eslint/no-floating-promises': 'off' } },
|
|
63
|
+
];
|
|
64
|
+
`);
|
|
65
|
+
return file;
|
|
66
|
+
}
|
|
67
|
+
/** ESLint's JSON report, kept to messages on changed lines; the rest is counted. */
|
|
68
|
+
export function onChangedLines(json, cwd, changedLines) {
|
|
69
|
+
let results;
|
|
70
|
+
try {
|
|
71
|
+
results = JSON.parse(json);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return { error: `eslint printed no JSON: ${json.trim().slice(0, 200)}` };
|
|
75
|
+
}
|
|
76
|
+
const blocking = [];
|
|
77
|
+
let preexisting = 0;
|
|
78
|
+
for (const result of results) {
|
|
79
|
+
const file = path.relative(cwd, result.filePath).split(path.sep).join('/');
|
|
80
|
+
const lines = changedLines[file];
|
|
81
|
+
for (const message of result.messages) {
|
|
82
|
+
if (lines?.has(message.line))
|
|
83
|
+
blocking.push({ file, line: message.line, column: message.column, rule: message.ruleId ?? 'error', message: message.message });
|
|
84
|
+
else
|
|
85
|
+
preexisting++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { blocking, preexisting };
|
|
89
|
+
}
|
package/dist/review/quiet.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { Failure } from '../types/index.js';
|
|
2
2
|
export declare const DISMISSED_FILE: string;
|
|
3
3
|
export declare function isProven(failure: Failure): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* What a change must fix: a proven finding, a critical one, or a high one the semantic engine
|
|
6
|
+
* verified or a security gate found. The one rule behind the review's verdict, the stop hook and
|
|
7
|
+
* the push gate, so the three never disagree about the same finding.
|
|
8
|
+
*/
|
|
9
|
+
export declare function mustFix(failure: Failure): boolean;
|
|
4
10
|
/** The same finding across runs and pushes: gate, file and message, never the line (lines move). */
|
|
5
11
|
export declare function findingKey(failure: Failure): string;
|
|
6
12
|
export interface QuietSplit {
|
package/dist/review/quiet.js
CHANGED
|
@@ -20,11 +20,21 @@ import fs from 'fs';
|
|
|
20
20
|
import path from 'path';
|
|
21
21
|
import { checkId, isMuted, readOutcomes, recordOutcome, reportedCheck } from './check-outcomes.js';
|
|
22
22
|
import { readStateFile } from './trusted-state.js';
|
|
23
|
-
const PROVEN_GATES = new Set(['semantic-bugs', 'hallucinated-imports', 'security-patterns', 'deep-analysis', 'diff-tests', 'unused-export', 'orphan-file', 'offset-paging', 'unbounded-window', 'duplicate-function', 'partial-fix', 'partial-wiring'
|
|
23
|
+
const PROVEN_GATES = new Set(['semantic-bugs', 'hallucinated-imports', 'security-patterns', 'deep-analysis', 'diff-tests', 'unused-export', 'orphan-file', 'offset-paging', 'unbounded-window', 'duplicate-function', 'partial-fix', 'partial-wiring', 'migration-order',
|
|
24
|
+
'duplicate-null-filter', 'nullable-filtered-column', 'optional-always-supplied', 'write-only-property', 'typed-checks-unavailable']);
|
|
24
25
|
export const DISMISSED_FILE = path.join('.rigour', 'dismissed.json');
|
|
25
26
|
export function isProven(failure) {
|
|
26
27
|
return PROVEN_GATES.has(failure.id);
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* What a change must fix: a proven finding, a critical one, or a high one the semantic engine
|
|
31
|
+
* verified or a security gate found. The one rule behind the review's verdict, the stop hook and
|
|
32
|
+
* the push gate, so the three never disagree about the same finding.
|
|
33
|
+
*/
|
|
34
|
+
export function mustFix(failure) {
|
|
35
|
+
const severity = failure.severity ?? 'medium';
|
|
36
|
+
return isProven(failure) || severity === 'critical' || (severity === 'high' && (failure.verified === true || failure.provenance === 'security'));
|
|
37
|
+
}
|
|
28
38
|
/** The same finding across runs and pushes: gate, file and message, never the line (lines move). */
|
|
29
39
|
export function findingKey(failure) {
|
|
30
40
|
return crypto.createHash('sha256').update(`${failure.id}\u0000${failure.files?.[0] ?? ''}\u0000${failure.details}`).digest('hex').slice(0, 16);
|
|
@@ -39,7 +49,7 @@ export function quietSplit(cwd, findings, includeHeuristics = false, trustedRef)
|
|
|
39
49
|
split.dismissed++;
|
|
40
50
|
split.dismissedByGate[finding.id] = (split.dismissedByGate[finding.id] ?? 0) + 1;
|
|
41
51
|
}
|
|
42
|
-
else if (includeHeuristics ||
|
|
52
|
+
else if (includeHeuristics || mustFix(finding))
|
|
43
53
|
split.speaking.push(finding);
|
|
44
54
|
else if (isMuted(outcomes[checkId(finding)]))
|
|
45
55
|
split.muted++;
|
|
@@ -15,6 +15,17 @@ describe('quiet by default', () => {
|
|
|
15
15
|
expect(quiet.advisory.map(f => f.id)).toEqual(['promise-safety', 'file-size']);
|
|
16
16
|
expect(quietSplit(dir, all, true).speaking).toHaveLength(5);
|
|
17
17
|
});
|
|
18
|
+
it('speaks on what must be fixed, the one rule the stop hook and the push gate share: critical, or high and verified or from a security gate', () => {
|
|
19
|
+
const all = [
|
|
20
|
+
{ ...finding('frontend-secret-exposure'), severity: 'high', provenance: 'security' },
|
|
21
|
+
{ ...finding('promise-safety'), severity: 'high' },
|
|
22
|
+
{ ...finding('promise-safety', 'verified'), severity: 'high', verified: true },
|
|
23
|
+
{ ...finding('file-size'), severity: 'critical' },
|
|
24
|
+
finding('migration-order'),
|
|
25
|
+
];
|
|
26
|
+
expect(quietSplit(dir, all).speaking.map(f => f.id)).toEqual(['frontend-secret-exposure', 'promise-safety', 'file-size', 'migration-order']);
|
|
27
|
+
expect(quietSplit(dir, all).advisory.map(f => f.details)).toEqual(['d']);
|
|
28
|
+
});
|
|
18
29
|
it('never reports a dismissed finding again, even after its line moves', () => {
|
|
19
30
|
const leak = finding('security-patterns', 'Hardcoded token in `client`');
|
|
20
31
|
expect(dismissFinding(dir, findingKey(leak), 'test fixture token, never deployed')).toBe(true);
|
package/dist/review/review.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface ReviewInput {
|
|
|
14
14
|
deep?: Omit<DeepOptions, 'focusLines' | 'removedLines' | 'diff'>;
|
|
15
15
|
/** Read dismissals and check outcomes at this commit (the base), not as the change left them. */
|
|
16
16
|
trustedRef?: string;
|
|
17
|
+
/** Run the typed checks (review/typed): the project's TypeScript program takes seconds, so at push, in `rigour review` and in a backtest, not at every stop. */
|
|
18
|
+
typed?: boolean;
|
|
17
19
|
}
|
|
18
20
|
export interface ReviewResult {
|
|
19
21
|
status: 'PASS' | 'FAIL' | 'ERROR';
|
|
@@ -39,6 +41,10 @@ export interface ReviewResult {
|
|
|
39
41
|
gateErrors: string[];
|
|
40
42
|
/** rigour.yml or .rigour/ files this change edits: they steer the review, so they are called out. */
|
|
41
43
|
controlFilesChanged: string[];
|
|
44
|
+
/** Candidates for the reviewer to confirm (a nested scan, a property that only leaves through serialisation), from the typed checks. */
|
|
45
|
+
hints: string[];
|
|
46
|
+
/** Why the typed checks could not run on a TypeScript project (`gateErrors` then names `typed-checks-unavailable`). */
|
|
47
|
+
typedError?: string;
|
|
42
48
|
}
|
|
43
49
|
export interface ReviewFinding {
|
|
44
50
|
id: string;
|
package/dist/review/review.js
CHANGED
|
@@ -24,6 +24,7 @@ import { unusedExportFailures } from './unused-exports.js';
|
|
|
24
24
|
import { queryPatternFailures } from './query-patterns.js';
|
|
25
25
|
import { optionalParamFailures } from './optional-params.js';
|
|
26
26
|
import { duplicateFunctionFailures } from './duplicate-functions.js';
|
|
27
|
+
import { typedChecks, TYPED_CHECKS } from './typed/redundancy.js';
|
|
27
28
|
import { loopCopyFailures } from './loop-copies.js';
|
|
28
29
|
import { partialFixFailures } from './partial-fixes.js';
|
|
29
30
|
import { partialWiringFailures } from './partial-wiring.js';
|
|
@@ -34,7 +35,7 @@ export async function reviewChange(input) {
|
|
|
34
35
|
const changedLines = withoutGenerated(input.cwd, parseDiff(diff));
|
|
35
36
|
const targets = input.files?.length ? input.files : Object.keys(changedLines);
|
|
36
37
|
if (targets.length === 0) {
|
|
37
|
-
return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, preexisting: 0, changedLines, report: null, gateErrors: [], controlFilesChanged: controlFiles(diff) };
|
|
38
|
+
return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, preexisting: 0, changedLines, report: null, gateErrors: [], controlFilesChanged: controlFiles(diff), hints: [] };
|
|
38
39
|
}
|
|
39
40
|
const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
|
|
40
41
|
const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
|
|
@@ -44,6 +45,10 @@ export async function reviewChange(input) {
|
|
|
44
45
|
report.failures.push(...migrationOrderFailures(input.cwd, diff, input.source, input.config));
|
|
45
46
|
report.failures.push(...unusedExportFailures(input.cwd, diff, input.config), ...orphanFileFailures(input.cwd, diff, input.config));
|
|
46
47
|
report.failures.push(...queryPatternFailures(input.cwd, changedLines, input.config), ...optionalParamFailures(input.cwd, changedLines, input.config), ...duplicateFunctionFailures(input.cwd, changedLines, input.config), ...loopCopyFailures(input.cwd, changedLines, input.config), ...partialFixFailures(input.cwd, changedLines, input.config), ...partialWiringFailures(input.cwd, changedLines, input.config));
|
|
48
|
+
const typed = input.typed ? typedChecks(input.cwd, changedLines, input.config) : { failures: [], hints: [] };
|
|
49
|
+
report.failures.push(...typed.failures);
|
|
50
|
+
if (typed.error)
|
|
51
|
+
report.summary[TYPED_CHECKS] = 'ERROR'; // a check that could not run is a crashed gate, never a pass
|
|
47
52
|
const split = splitByChangedLines(report.failures, changedLines, deep ? changedFunctionSpans(input.cwd, changedLines) : {}, removedByFile(diff));
|
|
48
53
|
const deepError = deepAnalysisError(report);
|
|
49
54
|
const quiet = quietSplit(input.cwd, split.findings, input.config.review?.include_heuristics, input.trustedRef);
|
|
@@ -66,7 +71,9 @@ export async function reviewChange(input) {
|
|
|
66
71
|
report,
|
|
67
72
|
gateErrors,
|
|
68
73
|
controlFilesChanged: controlFiles(diff),
|
|
74
|
+
hints: typed.hints,
|
|
69
75
|
...(deepError ? { deepError } : {}),
|
|
76
|
+
...(typed.error ? { typedError: typed.error } : {}),
|
|
70
77
|
};
|
|
71
78
|
}
|
|
72
79
|
/** Drop the rules' findings the base already had; returns how many. Model findings stay: the model reviews only the change. */
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Exec } from './exec.js';
|
|
2
|
+
export type ReviewerName = 'claude' | 'cursor' | 'codex';
|
|
3
|
+
export type Vendor = 'anthropic' | 'cursor' | 'openai';
|
|
4
|
+
export type ReviewMode = 'single' | 'cross' | 'full';
|
|
5
|
+
export interface Adapter {
|
|
6
|
+
vendor: Vendor;
|
|
7
|
+
binary: string;
|
|
8
|
+
/** The command line for one review: the prompt is passed as text, never through a shell. */
|
|
9
|
+
args(prompt: string, model: string | undefined): string[];
|
|
10
|
+
/** The reviewer's final message and, when the CLI reports it, what the run cost. */
|
|
11
|
+
answer(stdout: string): {
|
|
12
|
+
text: string;
|
|
13
|
+
costUsd?: number;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export declare const ADAPTERS: Record<ReviewerName, Adapter>;
|
|
17
|
+
export declare function isReviewerName(name: string): name is ReviewerName;
|
|
18
|
+
export interface Installed {
|
|
19
|
+
binary: string;
|
|
20
|
+
version: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The newest installed copy of the adapter's CLI, or undefined when none is. Several copies are
|
|
24
|
+
* common (an old global npm install first on PATH, a current one from Homebrew), and the old one
|
|
25
|
+
* refuses current models, so the version decides, not PATH order.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveAdapter(adapter: Adapter, cwd: string, exec: Exec): Promise<Installed | undefined>;
|
|
28
|
+
/** Which vendors wrote the commits, from their Co-Authored-By trailers. No trailer means a person. */
|
|
29
|
+
export declare function vendorsOf(trailers: string): Set<Vendor>;
|
|
30
|
+
/**
|
|
31
|
+
* The reviewers a run uses. single: the first available of the list. cross: the first available
|
|
32
|
+
* whose vendor is not on the trailers, else the first available. full: that one plus the first
|
|
33
|
+
* available of another vendor. Empty when none is installed.
|
|
34
|
+
*/
|
|
35
|
+
export declare function selectReviewers(candidates: ReviewerName[], mode: ReviewMode, authors: Set<Vendor>, available: Set<ReviewerName>): ReviewerName[];
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reviewer adapters: the person's own coding-agent CLIs, each run headless and read-only on the
|
|
3
|
+
* same prompt. `review.reviewer.reviewers` is the contract (an explicit list, any vendors); the
|
|
4
|
+
* mode picks among it: `single` runs the first available (the default, and the fastest), `cross`
|
|
5
|
+
* prefers a vendor not on the commits' Co-Authored-By trailers, `full` runs two vendors and the
|
|
6
|
+
* verdicts are merged. Which mode earns its minutes is decided by the backtest ledger, which
|
|
7
|
+
* records the reviewer behind each catch, not by preference.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
const READ_ONLY_TOOLS = ['Read', 'Grep', 'Glob', 'Bash(git diff:*)', 'Bash(git show:*)', 'Bash(git log:*)', 'Bash(git grep:*)'];
|
|
13
|
+
export const ADAPTERS = {
|
|
14
|
+
claude: {
|
|
15
|
+
vendor: 'anthropic',
|
|
16
|
+
binary: 'claude',
|
|
17
|
+
// Isolated: no MCP servers, no hooks, no user-level settings (an output style or permission
|
|
18
|
+
// of the author's session must not shape the reviewer), read-only tools.
|
|
19
|
+
args: (prompt, model) => [
|
|
20
|
+
'-p', prompt,
|
|
21
|
+
...(model ? ['--model', model] : []),
|
|
22
|
+
'--output-format', 'json',
|
|
23
|
+
'--max-turns', '80',
|
|
24
|
+
'--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
|
|
25
|
+
'--setting-sources', 'project', '--settings', '{"hooks":{},"outputStyle":"default"}',
|
|
26
|
+
'--allowedTools', ...READ_ONLY_TOOLS,
|
|
27
|
+
'--disallowedTools', 'Edit', 'Write', 'NotebookEdit', 'Bash(git push:*)', 'Bash(git commit:*)',
|
|
28
|
+
],
|
|
29
|
+
answer: stdout => {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(stdout);
|
|
32
|
+
return { text: String(parsed.result ?? ''), ...(typeof parsed.total_cost_usd === 'number' ? { costUsd: parsed.total_cost_usd } : {}) };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { text: stdout };
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
cursor: {
|
|
40
|
+
vendor: 'cursor',
|
|
41
|
+
binary: 'cursor-agent',
|
|
42
|
+
// Ask mode is read-only and cannot run git: every input the prompt names is a file.
|
|
43
|
+
args: (prompt, model) => ['-p', '--print', '--output-format', 'json', '--trust', '--mode', 'ask', '--model', model ?? 'auto', prompt],
|
|
44
|
+
answer: stdout => {
|
|
45
|
+
try {
|
|
46
|
+
const pick = (o) => typeof o === 'string' ? o : o && typeof o === 'object' ? (Array.isArray(o) ? pick(o[o.length - 1]) : String(o.result ?? o.text ?? o.content ?? o.message ?? '')) : '';
|
|
47
|
+
return { text: pick(JSON.parse(stdout)) };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { text: stdout };
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
codex: {
|
|
55
|
+
vendor: 'openai',
|
|
56
|
+
binary: 'codex',
|
|
57
|
+
args: (prompt, model) => ['exec', '--sandbox', 'read-only', '--json', ...(model ? ['--model', model] : []), '-c', 'model_reasoning_effort=high', prompt],
|
|
58
|
+
// `codex exec --json` streams events; the last text-bearing one carries the answer.
|
|
59
|
+
answer: stdout => {
|
|
60
|
+
let text = '';
|
|
61
|
+
for (const line of stdout.split('\n').filter(Boolean)) {
|
|
62
|
+
try {
|
|
63
|
+
const event = JSON.parse(line);
|
|
64
|
+
const candidate = event?.item?.text ?? event?.msg?.message ?? event?.text;
|
|
65
|
+
if (typeof candidate === 'string')
|
|
66
|
+
text = candidate;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// not an event line
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { text: text || stdout };
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
export function isReviewerName(name) {
|
|
77
|
+
return name in ADAPTERS;
|
|
78
|
+
}
|
|
79
|
+
/** Where a CLI tends to be installed besides PATH: a global npm prefix, Homebrew, a user-local bin, the agent's own installer. */
|
|
80
|
+
const EXTRA_BIN_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', path.join(os.homedir(), '.local', 'bin'), path.join(os.homedir(), '.npm-global', 'bin'), path.join(os.homedir(), '.claude', 'local')];
|
|
81
|
+
/**
|
|
82
|
+
* The newest installed copy of the adapter's CLI, or undefined when none is. Several copies are
|
|
83
|
+
* common (an old global npm install first on PATH, a current one from Homebrew), and the old one
|
|
84
|
+
* refuses current models, so the version decides, not PATH order.
|
|
85
|
+
*/
|
|
86
|
+
export async function resolveAdapter(adapter, cwd, exec) {
|
|
87
|
+
const names = process.platform === 'win32' ? [`${adapter.binary}.cmd`, `${adapter.binary}.exe`, adapter.binary] : [adapter.binary];
|
|
88
|
+
const dirs = [...(process.env.PATH ?? '').split(path.delimiter), ...EXTRA_BIN_DIRS].filter(Boolean);
|
|
89
|
+
const candidates = new Set();
|
|
90
|
+
for (const dir of dirs) {
|
|
91
|
+
for (const name of names) {
|
|
92
|
+
const candidate = path.join(dir, name);
|
|
93
|
+
if (executable(candidate))
|
|
94
|
+
candidates.add(candidate);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
let best;
|
|
98
|
+
for (const binary of candidates) {
|
|
99
|
+
const result = await exec(binary, ['--version'], { cwd, timeoutMs: 20_000 });
|
|
100
|
+
if (result.exitCode !== 0)
|
|
101
|
+
continue;
|
|
102
|
+
const version = result.stdout.trim().split('\n')[0];
|
|
103
|
+
if (!best || newer(version, best.version))
|
|
104
|
+
best = { binary, version };
|
|
105
|
+
}
|
|
106
|
+
return best;
|
|
107
|
+
}
|
|
108
|
+
function executable(file) {
|
|
109
|
+
try {
|
|
110
|
+
fs.accessSync(file, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
|
|
111
|
+
return fs.statSync(file).isFile();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** `a` carries a higher dotted version than `b` (the first such run of digits in each line). */
|
|
118
|
+
function newer(a, b) {
|
|
119
|
+
const parse = (line) => (line.match(/\d+(?:\.\d+)+/)?.[0] ?? '0').split('.').map(Number);
|
|
120
|
+
const [x, y] = [parse(a), parse(b)];
|
|
121
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
122
|
+
if ((x[i] ?? 0) !== (y[i] ?? 0))
|
|
123
|
+
return (x[i] ?? 0) > (y[i] ?? 0);
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
/** Which vendors wrote the commits, from their Co-Authored-By trailers. No trailer means a person. */
|
|
128
|
+
export function vendorsOf(trailers) {
|
|
129
|
+
const vendors = new Set();
|
|
130
|
+
for (const line of trailers.toLowerCase().split('\n')) {
|
|
131
|
+
if (/claude/.test(line))
|
|
132
|
+
vendors.add('anthropic');
|
|
133
|
+
if (/cursor/.test(line))
|
|
134
|
+
vendors.add('cursor');
|
|
135
|
+
if (/codex|openai/.test(line))
|
|
136
|
+
vendors.add('openai');
|
|
137
|
+
}
|
|
138
|
+
return vendors;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The reviewers a run uses. single: the first available of the list. cross: the first available
|
|
142
|
+
* whose vendor is not on the trailers, else the first available. full: that one plus the first
|
|
143
|
+
* available of another vendor. Empty when none is installed.
|
|
144
|
+
*/
|
|
145
|
+
export function selectReviewers(candidates, mode, authors, available) {
|
|
146
|
+
const installed = candidates.filter(name => available.has(name));
|
|
147
|
+
if (installed.length === 0)
|
|
148
|
+
return [];
|
|
149
|
+
let first = installed[0];
|
|
150
|
+
if (mode !== 'single')
|
|
151
|
+
first = installed.find(name => !authors.has(ADAPTERS[name].vendor)) ?? first;
|
|
152
|
+
if (mode !== 'full')
|
|
153
|
+
return [first];
|
|
154
|
+
const second = installed.find(name => name !== first && ADAPTERS[name].vendor !== ADAPTERS[first].vendor);
|
|
155
|
+
return second ? [first, second] : [first];
|
|
156
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Config } from '../../types/index.js';
|
|
2
|
+
import { type ReviewerResult } from '../reviewer.js';
|
|
3
|
+
import { type Exec } from './exec.js';
|
|
4
|
+
import { type OpenItem } from './verdict.js';
|
|
5
|
+
export interface BackgroundJob {
|
|
6
|
+
head: string;
|
|
7
|
+
branch: string;
|
|
8
|
+
base: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Starts `command` detached (its own process group, output to the branch's log) and records its
|
|
12
|
+
* pid; a job still running for the same branch is ended first. Returns the log path.
|
|
13
|
+
*/
|
|
14
|
+
export declare function startBackgroundReview(cwd: string, job: BackgroundJob, command: string[], exec?: Exec): Promise<string | undefined>;
|
|
15
|
+
/** The detached process: review the commit in a worktree of its own, write the verdict, tell the person. */
|
|
16
|
+
export declare function backgroundReview(cwd: string, job: BackgroundJob, config: Config, exec?: Exec, log?: (line: string) => void): Promise<ReviewerResult>;
|
|
17
|
+
export interface ReviewStatus {
|
|
18
|
+
branch: string;
|
|
19
|
+
/** A review still running, and for which commit. */
|
|
20
|
+
running?: {
|
|
21
|
+
pid: number;
|
|
22
|
+
head: string;
|
|
23
|
+
};
|
|
24
|
+
/** The last verdict recorded for the branch. */
|
|
25
|
+
last?: {
|
|
26
|
+
head: string;
|
|
27
|
+
mode: 'full' | 'delta';
|
|
28
|
+
at: string;
|
|
29
|
+
open: OpenItem[];
|
|
30
|
+
};
|
|
31
|
+
log?: string;
|
|
32
|
+
}
|
|
33
|
+
/** What the background reviewer has done for a branch: running, the last verdict and its open items. */
|
|
34
|
+
export declare function reviewStatus(cwd: string, branch: string, exec?: Exec): Promise<ReviewStatus | undefined>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model review that does not hold the push. Once the deterministic gates pass, the pushed
|
|
3
|
+
* commit is reviewed in a detached worktree of that exact commit (later commits or edits cannot
|
|
4
|
+
* change what it reads), in its own process group, so the push returns in seconds. A newer push
|
|
5
|
+
* of the same branch ends a review still running for an older commit. The verdict goes to the
|
|
6
|
+
* store (shared by the worktree, the checkout and the status command), a desktop notification
|
|
7
|
+
* and `rigour review --status`. The hard stop stays explicit: `rigour review --reviewer --full`
|
|
8
|
+
* before asking a person to look.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn, spawnSync } from 'child_process';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { reviewChange } from '../review.js';
|
|
14
|
+
import { runReviewer } from '../reviewer.js';
|
|
15
|
+
import { defaultExec, GH_TIMEOUT_MS } from './exec.js';
|
|
16
|
+
import { VerdictStore } from './store.js';
|
|
17
|
+
import { itemLine } from './verdict.js';
|
|
18
|
+
/**
|
|
19
|
+
* Starts `command` detached (its own process group, output to the branch's log) and records its
|
|
20
|
+
* pid; a job still running for the same branch is ended first. Returns the log path.
|
|
21
|
+
*/
|
|
22
|
+
export async function startBackgroundReview(cwd, job, command, exec = defaultExec) {
|
|
23
|
+
const store = await VerdictStore.open(cwd, exec);
|
|
24
|
+
if (!store)
|
|
25
|
+
return undefined;
|
|
26
|
+
endRunning(store, job.branch);
|
|
27
|
+
const log = store.branchFile(job.branch, 'log');
|
|
28
|
+
const out = fs.openSync(log, 'w');
|
|
29
|
+
const child = spawn(command[0], command.slice(1), { cwd, detached: true, stdio: ['ignore', out, out], env: process.env });
|
|
30
|
+
fs.closeSync(out);
|
|
31
|
+
if (child.pid)
|
|
32
|
+
fs.writeFileSync(store.branchFile(job.branch, 'pid'), `${child.pid} ${job.head}\n`);
|
|
33
|
+
child.unref();
|
|
34
|
+
return log;
|
|
35
|
+
}
|
|
36
|
+
/** The detached process: review the commit in a worktree of its own, write the verdict, tell the person. */
|
|
37
|
+
export async function backgroundReview(cwd, job, config, exec = defaultExec, log = line => process.stdout.write(`${line}\n`)) {
|
|
38
|
+
const store = await VerdictStore.open(cwd, exec);
|
|
39
|
+
if (!store)
|
|
40
|
+
throw new Error(`${cwd} is not a repository`);
|
|
41
|
+
const worktree = store.worktreeDir(job.head);
|
|
42
|
+
const added = await exec('git', ['worktree', 'add', '--detach', worktree, job.head], { cwd, timeoutMs: 5 * GH_TIMEOUT_MS });
|
|
43
|
+
if (added.exitCode !== 0 && !fs.existsSync(path.join(worktree, '.git')))
|
|
44
|
+
throw new Error(`could not check out ${job.head.slice(0, 9)} for the review: ${added.stderr.trim()}`);
|
|
45
|
+
try {
|
|
46
|
+
shareDependencies(cwd, worktree);
|
|
47
|
+
// The typed checks' hints (a nested scan, a value that only leaves through serialisation) are the reviewer's candidates to confirm.
|
|
48
|
+
const hints = (await reviewChange({ cwd: worktree, config, source: { mode: 'base', base: job.base }, typed: true })).hints.join('\n');
|
|
49
|
+
const result = await runReviewer(worktree, job.base, config, exec, log, { trigger: 'push', branch: job.branch, hints });
|
|
50
|
+
log(`review of ${job.head.slice(0, 9)}: ${result.outcome}${result.reason ? ` (${result.reason})` : ''}${result.items.length ? `, ${result.items.length} open item(s)` : ''}`);
|
|
51
|
+
for (const item of result.items)
|
|
52
|
+
log(` ${itemLine(item)}`);
|
|
53
|
+
notify(`Rigour review of ${job.branch} @ ${job.head.slice(0, 9)}: ${result.outcome}${result.items.length ? `, ${result.items.length} open item(s)` : ''}`);
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await exec('git', ['worktree', 'remove', '--force', worktree], { cwd, timeoutMs: 5 * GH_TIMEOUT_MS });
|
|
58
|
+
const pidFile = store.branchFile(job.branch, 'pid');
|
|
59
|
+
if (fs.existsSync(pidFile) && fs.readFileSync(pidFile, 'utf8').startsWith(`${process.pid} `))
|
|
60
|
+
fs.unlinkSync(pidFile);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** What the background reviewer has done for a branch: running, the last verdict and its open items. */
|
|
64
|
+
export async function reviewStatus(cwd, branch, exec = defaultExec) {
|
|
65
|
+
const store = await VerdictStore.open(cwd, exec);
|
|
66
|
+
if (!store)
|
|
67
|
+
return undefined;
|
|
68
|
+
const status = { branch };
|
|
69
|
+
const running = runningJob(store, branch);
|
|
70
|
+
if (running)
|
|
71
|
+
status.running = running;
|
|
72
|
+
const state = store.branchState(branch);
|
|
73
|
+
if (state)
|
|
74
|
+
status.last = { head: state.head, mode: state.mode, at: state.at, open: store.readJson(store.openPath(state.verdict)) ?? [] };
|
|
75
|
+
const log = store.branchFile(branch, 'log');
|
|
76
|
+
if (fs.existsSync(log))
|
|
77
|
+
status.log = log;
|
|
78
|
+
return status;
|
|
79
|
+
}
|
|
80
|
+
function runningJob(store, branch) {
|
|
81
|
+
const pidFile = store.branchFile(branch, 'pid');
|
|
82
|
+
if (!fs.existsSync(pidFile))
|
|
83
|
+
return undefined;
|
|
84
|
+
const [pid, head] = fs.readFileSync(pidFile, 'utf8').trim().split(' ');
|
|
85
|
+
if (!alive(Number(pid))) {
|
|
86
|
+
fs.unlinkSync(pidFile);
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
return { pid: Number(pid), head: head ?? '' };
|
|
90
|
+
}
|
|
91
|
+
/** Ends a review still running for the branch (the whole process group: the CLI and the agent it runs). */
|
|
92
|
+
function endRunning(store, branch) {
|
|
93
|
+
const running = runningJob(store, branch);
|
|
94
|
+
if (!running)
|
|
95
|
+
return;
|
|
96
|
+
try {
|
|
97
|
+
process.kill(process.platform === 'win32' ? running.pid : -running.pid, 'SIGTERM');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// already gone
|
|
101
|
+
}
|
|
102
|
+
fs.unlinkSync(store.branchFile(branch, 'pid'));
|
|
103
|
+
}
|
|
104
|
+
/** The checkout's installed dependencies serve the worktree too (the typed checks need the project's compiler), never a download. */
|
|
105
|
+
function shareDependencies(cwd, worktree) {
|
|
106
|
+
const root = path.join(cwd, 'node_modules');
|
|
107
|
+
if (fs.existsSync(root) && !fs.existsSync(path.join(worktree, 'node_modules')))
|
|
108
|
+
fs.symlinkSync(root, path.join(worktree, 'node_modules'), 'junction');
|
|
109
|
+
}
|
|
110
|
+
function alive(pid) {
|
|
111
|
+
if (!pid)
|
|
112
|
+
return false;
|
|
113
|
+
try {
|
|
114
|
+
process.kill(pid, 0);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** A desktop notification where the platform has one; silence otherwise. */
|
|
122
|
+
function notify(message) {
|
|
123
|
+
const safe = message.replace(/["\\]/g, '');
|
|
124
|
+
if (process.platform === 'darwin')
|
|
125
|
+
spawnSync('osascript', ['-e', `display notification "${safe}" with title "Rigour"`], { stdio: 'ignore', timeout: 5000 });
|
|
126
|
+
else if (process.platform === 'linux')
|
|
127
|
+
spawnSync('notify-send', ['Rigour', safe], { stdio: 'ignore', timeout: 5000 });
|
|
128
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|