@rigour-labs/core 6.6.4 → 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.
Files changed (70) hide show
  1. package/dist/gates/frontend-secret-exposure.d.ts +1 -1
  2. package/dist/gates/frontend-secret-exposure.js +9 -3
  3. package/dist/gates/frontend-secret-exposure.test.js +4 -1
  4. package/dist/hooks/index.d.ts +1 -1
  5. package/dist/hooks/index.js +1 -1
  6. package/dist/hooks/stop-review.d.ts +6 -9
  7. package/dist/hooks/stop-review.js +5 -10
  8. package/dist/index.d.ts +5 -2
  9. package/dist/index.js +5 -2
  10. package/dist/review/backtest-init.d.ts +8 -0
  11. package/dist/review/backtest-init.js +87 -0
  12. package/dist/review/backtest-init.test.d.ts +1 -0
  13. package/dist/review/backtest-init.test.js +74 -0
  14. package/dist/review/backtest.d.ts +315 -0
  15. package/dist/review/backtest.js +197 -0
  16. package/dist/review/backtest.test.d.ts +1 -0
  17. package/dist/review/backtest.test.js +129 -0
  18. package/dist/review/code-patterns.test.js +58 -0
  19. package/dist/review/dead-code.test.js +8 -0
  20. package/dist/review/duplicate-functions.js +7 -2
  21. package/dist/review/lint-overlay.d.ts +21 -0
  22. package/dist/review/lint-overlay.js +89 -0
  23. package/dist/review/loop-copies.d.ts +2 -0
  24. package/dist/review/loop-copies.js +116 -0
  25. package/dist/review/migration-order.js +5 -1
  26. package/dist/review/orphan-files.js +5 -1
  27. package/dist/review/partial-fixes.d.ts +2 -0
  28. package/dist/review/partial-fixes.js +138 -0
  29. package/dist/review/partial-wiring.d.ts +2 -0
  30. package/dist/review/partial-wiring.js +201 -0
  31. package/dist/review/quiet.d.ts +6 -0
  32. package/dist/review/quiet.js +12 -2
  33. package/dist/review/quiet.test.js +11 -0
  34. package/dist/review/review.d.ts +6 -0
  35. package/dist/review/review.js +12 -2
  36. package/dist/review/reviewer/adapters.d.ts +35 -0
  37. package/dist/review/reviewer/adapters.js +156 -0
  38. package/dist/review/reviewer/background.d.ts +34 -0
  39. package/dist/review/reviewer/background.js +128 -0
  40. package/dist/review/reviewer/background.test.d.ts +1 -0
  41. package/dist/review/reviewer/background.test.js +104 -0
  42. package/dist/review/reviewer/exec.d.ts +17 -0
  43. package/dist/review/reviewer/exec.js +23 -0
  44. package/dist/review/reviewer/inputs.d.ts +37 -0
  45. package/dist/review/reviewer/inputs.js +103 -0
  46. package/dist/review/reviewer/merge-impact.d.ts +2 -0
  47. package/dist/review/reviewer/merge-impact.js +64 -0
  48. package/dist/review/reviewer/prompt.d.ts +21 -0
  49. package/dist/review/reviewer/prompt.js +123 -0
  50. package/dist/review/reviewer/store.d.ts +26 -0
  51. package/dist/review/reviewer/store.js +57 -0
  52. package/dist/review/reviewer/verdict.d.ts +127 -0
  53. package/dist/review/reviewer/verdict.js +156 -0
  54. package/dist/review/reviewer.d.ts +41 -49
  55. package/dist/review/reviewer.js +210 -194
  56. package/dist/review/reviewer.test.js +199 -48
  57. package/dist/review/toolchain.d.ts +5 -2
  58. package/dist/review/toolchain.js +81 -6
  59. package/dist/review/toolchain.test.js +39 -1
  60. package/dist/review/typed/program.d.ts +27 -0
  61. package/dist/review/typed/program.js +90 -0
  62. package/dist/review/typed/redundancy.d.ts +10 -0
  63. package/dist/review/typed/redundancy.js +339 -0
  64. package/dist/review/typed/redundancy.test.d.ts +1 -0
  65. package/dist/review/typed/redundancy.test.js +121 -0
  66. package/dist/review/unused-exports.js +8 -4
  67. package/dist/templates/universal-config.js +11 -1
  68. package/dist/types/index.d.ts +128 -8
  69. package/dist/types/index.js +26 -2
  70. package/package.json +6 -6
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Partial wiring: a change adds a prop to some mounts of a component, and a sibling mount of the
3
+ * same kind (agreeing on a literal prop, such as `mode="quiz"`, in at least three files) still lacks it, so that place keeps the
4
+ * old behaviour (a handler never called, events missing). Svelte and JSX mounts are read with a
5
+ * brace-aware scanner; a mount that spreads props (`{...rest}`) is given the benefit of the doubt.
6
+ * Reported on the line that adds the prop.
7
+ */
8
+ import { spawnSync } from 'child_process';
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ const MOUNTING = /\.(svelte|tsx|jsx)$/;
12
+ const SKIPPED = /\.(test|spec|stories)\.|(^|\/)(__tests__|tests?)\//;
13
+ const GIT_TIMEOUT_MS = 10_000;
14
+ export function partialWiringFailures(cwd, changedLines, config) {
15
+ if (!config.gates.change_sweep?.enabled)
16
+ return [];
17
+ const failures = [];
18
+ const seen = new Set();
19
+ for (const [file, lines] of Object.entries(changedLines)) {
20
+ if (!MOUNTING.test(file) || SKIPPED.test(file) || lines.size === 0)
21
+ continue;
22
+ for (const mount of mountsIn(cwd, file)) {
23
+ for (const { prop, line } of addedProps(cwd, mount, lines)) {
24
+ const key = `${mount.component}\0${prop}`;
25
+ if (seen.has(key))
26
+ continue;
27
+ seen.add(key);
28
+ // A missed wiring is a callback the project's own component offers (`onResume`), among mounts
29
+ // of the same kind (a literal they agree on, `mode="quiz"`) in several files. UI-kit and icon
30
+ // components, styling props and DOM events take different values everywhere by design.
31
+ if (!/^on[A-Z]/.test(prop) || mount.literals.size === 0 || !isLocalComponent(cwd, mount))
32
+ continue;
33
+ const kin = mountsOf(cwd, mount.component).filter(m => sameKind(m, mount));
34
+ if (new Set(kin.map(m => m.file)).size < 3)
35
+ continue;
36
+ const missing = kin.filter(m => !m.props.has(prop) && !m.spreads);
37
+ const having = kin.filter(m => m.props.has(prop));
38
+ if (kin.length >= 3 && having.length >= 2 && missing.length >= 1 && missing.length < having.length) {
39
+ failures.push(unwired(mount, prop, line, missing));
40
+ }
41
+ }
42
+ }
43
+ }
44
+ return failures;
45
+ }
46
+ /** Props on the mount written on a line the change added, with that line (where a finding belongs). */
47
+ function addedProps(cwd, mount, lines) {
48
+ const text = fs.readFileSync(path.join(cwd, mount.file), 'utf8').split('\n');
49
+ const added = new Map();
50
+ for (let line = mount.line; line <= mount.endLine; line++) {
51
+ if (!lines.has(line))
52
+ continue;
53
+ for (const prop of mount.props) {
54
+ if (!added.has(prop) && new RegExp(`(^|[\\s{])${prop.replace(/[$]/g, '\\$')}(\\s*=|\\s*}|\\s|$)`).test(text[line - 1] ?? ''))
55
+ added.set(prop, line);
56
+ }
57
+ }
58
+ return [...added].map(([prop, line]) => ({ prop, line }));
59
+ }
60
+ /** The component is imported from the project itself (a relative path or a project alias), not a package. */
61
+ function isLocalComponent(cwd, mount) {
62
+ const text = fs.readFileSync(path.join(cwd, mount.file), 'utf8');
63
+ const name = mount.component.split('.')[0];
64
+ const imported = new RegExp(`import\\s+(?:type\\s+)?(?:${name}\\b[^;]*?|\\{[^}]*\\b${name}\\b[^}]*\\})\\s*from\\s*['"]([^'"]+)['"]`).exec(text);
65
+ return !!imported && /^(\.|\$lib\/|@\/|~\/|src\/)/.test(imported[1]);
66
+ }
67
+ function sameKind(a, b) {
68
+ return [...b.literals].every(([name, value]) => a.literals.get(name) === value);
69
+ }
70
+ const mountCache = new Map();
71
+ /** Every mount of `component` in the repository's Svelte and JSX files. */
72
+ function mountsOf(cwd, component) {
73
+ const key = `${cwd}\0${component}`;
74
+ const cached = mountCache.get(key);
75
+ if (cached)
76
+ return cached;
77
+ const grep = spawnSync('git', ['grep', '--untracked', '-l', '-F', '-e', `<${component}`], { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
78
+ const files = grep.status === 0 ? grep.stdout.split('\n').filter(f => f && MOUNTING.test(f) && !SKIPPED.test(f)) : [];
79
+ const mounts = files.flatMap(file => mountsIn(cwd, file).filter(m => m.component === component));
80
+ mountCache.set(key, mounts);
81
+ return mounts;
82
+ }
83
+ /** Component tags (capitalised) in a file, with their props, read without breaking on `>` inside braces or quotes. */
84
+ function mountsIn(cwd, file) {
85
+ let text;
86
+ try {
87
+ text = fs.readFileSync(path.join(cwd, file), 'utf8');
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ const mounts = [];
93
+ const tag = /<([A-Z][\w.]*)(?=[\s/>])/g;
94
+ for (let match = tag.exec(text); match; match = tag.exec(text)) {
95
+ const end = tagEnd(text, match.index + match[0].length);
96
+ if (end < 0)
97
+ continue;
98
+ const body = text.slice(match.index + match[0].length, end);
99
+ const line = text.slice(0, match.index).split('\n').length;
100
+ mounts.push({ file, line, endLine: line + body.split('\n').length - 1, component: match[1], ...props(body) });
101
+ }
102
+ return mounts;
103
+ }
104
+ /** Index of the `>` that closes the tag starting at `from`, skipping braces and quotes. */
105
+ function tagEnd(text, from) {
106
+ let depth = 0;
107
+ let quote = '';
108
+ for (let i = from; i < text.length; i++) {
109
+ const c = text[i];
110
+ if (quote) {
111
+ if (c === quote)
112
+ quote = '';
113
+ }
114
+ else if (c === '"' || c === "'" || c === '`') {
115
+ quote = c;
116
+ }
117
+ else if (c === '{') {
118
+ depth++;
119
+ }
120
+ else if (c === '}') {
121
+ depth--;
122
+ }
123
+ else if (c === '>' && depth === 0) {
124
+ return i;
125
+ }
126
+ }
127
+ return -1;
128
+ }
129
+ /**
130
+ * A tag's props: `name="literal"`, `name={…}`, the shorthand `{name}`, and whether it spreads
131
+ * `{...rest}`. Text inside braces is code, not props, and never read as one.
132
+ */
133
+ function props(body) {
134
+ let top = '';
135
+ let depth = 0;
136
+ let quote = '';
137
+ let inner = '';
138
+ for (const c of body) {
139
+ if (quote) {
140
+ if (depth === 0)
141
+ top += c;
142
+ else
143
+ inner += c;
144
+ if (c === quote)
145
+ quote = '';
146
+ continue;
147
+ }
148
+ if (c === '"' || c === "'" || c === '`') {
149
+ quote = c;
150
+ if (depth === 0)
151
+ top += c;
152
+ else
153
+ inner += c;
154
+ continue;
155
+ }
156
+ if (c === '{') {
157
+ if (depth > 0)
158
+ inner += c;
159
+ depth++;
160
+ continue;
161
+ }
162
+ if (c === '}') {
163
+ depth--;
164
+ if (depth > 0) {
165
+ inner += c;
166
+ continue;
167
+ }
168
+ const content = inner.trim();
169
+ top += /^\.\.\./.test(content) ? '{...}' : /^[A-Za-z_$][\w$]*$/.test(content) ? `{${content}}` : '{}';
170
+ inner = '';
171
+ continue;
172
+ }
173
+ if (depth === 0)
174
+ top += c;
175
+ else
176
+ inner += c;
177
+ }
178
+ const names = new Set();
179
+ const literals = new Map();
180
+ for (const m of top.matchAll(/([A-Za-z_$][\w$:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{)/g)) {
181
+ names.add(m[1]);
182
+ if (m[2] !== undefined || m[3] !== undefined)
183
+ literals.set(m[1], m[2] ?? m[3]);
184
+ }
185
+ for (const m of top.matchAll(/(?<![=\w$])\{([A-Za-z_$][\w$]*)\}/g))
186
+ names.add(m[1]);
187
+ return { props: names, literals, spreads: top.includes('{...}') };
188
+ }
189
+ function unwired(mount, prop, line, missing) {
190
+ const where = missing.slice(0, 5).map(m => `\`${m.file}:${m.line}\``).join(', ');
191
+ return {
192
+ id: 'partial-wiring',
193
+ title: 'Wired in some places, not all',
194
+ details: `\`${prop}\` is passed to \`<${mount.component}>\` here and in other mounts of the same kind, but not at ${where}, so that place keeps the old behaviour.`,
195
+ severity: 'medium',
196
+ provenance: 'traditional',
197
+ files: [mount.file],
198
+ line,
199
+ hint: `Pass \`${prop}\` there too, or say why that mount should not have it.`,
200
+ };
201
+ }
@@ -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 {
@@ -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']);
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 || isProven(finding))
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);
@@ -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;
@@ -24,6 +24,10 @@ 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';
28
+ import { loopCopyFailures } from './loop-copies.js';
29
+ import { partialFixFailures } from './partial-fixes.js';
30
+ import { partialWiringFailures } from './partial-wiring.js';
27
31
  import { isControlFile } from './trusted-state.js';
28
32
  import { baseCommit, baseFindings, splitIntroduced } from './baseline.js';
29
33
  export async function reviewChange(input) {
@@ -31,7 +35,7 @@ export async function reviewChange(input) {
31
35
  const changedLines = withoutGenerated(input.cwd, parseDiff(diff));
32
36
  const targets = input.files?.length ? input.files : Object.keys(changedLines);
33
37
  if (targets.length === 0) {
34
- 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: [] };
35
39
  }
36
40
  const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
37
41
  const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
@@ -40,7 +44,11 @@ export async function reviewChange(input) {
40
44
  report.failures.push(...await diffTestFailures(input.cwd, input.source, deep));
41
45
  report.failures.push(...migrationOrderFailures(input.cwd, diff, input.source, input.config));
42
46
  report.failures.push(...unusedExportFailures(input.cwd, diff, input.config), ...orphanFileFailures(input.cwd, diff, input.config));
43
- report.failures.push(...queryPatternFailures(input.cwd, changedLines, input.config), ...optionalParamFailures(input.cwd, changedLines, input.config), ...duplicateFunctionFailures(input.cwd, changedLines, input.config));
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
44
52
  const split = splitByChangedLines(report.failures, changedLines, deep ? changedFunctionSpans(input.cwd, changedLines) : {}, removedByFile(diff));
45
53
  const deepError = deepAnalysisError(report);
46
54
  const quiet = quietSplit(input.cwd, split.findings, input.config.review?.include_heuristics, input.trustedRef);
@@ -63,7 +71,9 @@ export async function reviewChange(input) {
63
71
  report,
64
72
  gateErrors,
65
73
  controlFilesChanged: controlFiles(diff),
74
+ hints: typed.hints,
66
75
  ...(deepError ? { deepError } : {}),
76
+ ...(typed.error ? { typedError: typed.error } : {}),
67
77
  };
68
78
  }
69
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>;