@rigour-labs/core 6.9.0 → 6.10.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deep/pr-review.d.ts +3 -0
- package/dist/deep/pr-review.js +8 -1
- package/dist/gates/context.js +4 -2
- package/dist/gates/deep-analysis.js +2 -2
- package/dist/gates/deprecated-apis.js +21 -6
- package/dist/gates/inconsistent-error-handling.d.ts +3 -0
- package/dist/gates/inconsistent-error-handling.js +12 -1
- package/dist/gates/runner.js +3 -1
- package/dist/gates/security-patterns-data.d.ts +6 -0
- package/dist/gates/security-patterns-data.js +7 -1
- package/dist/gates/security-patterns.js +5 -0
- package/dist/gates/side-effect-analysis/index.js +3 -5
- package/dist/gates/side-effect-helpers/pattern-detection.d.ts +7 -1
- package/dist/gates/side-effect-helpers/pattern-detection.js +13 -3
- package/dist/inference/types.d.ts +2 -0
- package/dist/review/review.d.ts +2 -0
- package/dist/review/review.js +31 -6
- package/dist/review/reviewer/context.js +3 -3
- package/dist/review/reviewer/store.d.ts +17 -0
- package/dist/review/reviewer/store.js +23 -0
- package/dist/review/reviewer/usage.js +1 -0
- package/dist/review/reviewer.d.ts +2 -0
- package/dist/review/reviewer.js +52 -1
- package/dist/review/settled-checks.d.ts +39 -0
- package/dist/review/settled-checks.js +47 -0
- package/dist/review/unused-exports.js +42 -2
- package/dist/types/index.d.ts +6 -0
- package/dist/utils/code-mask.d.ts +19 -0
- package/dist/utils/code-mask.js +67 -0
- package/package.json +6 -6
package/dist/deep/pr-review.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DeepFinding, InferenceOptions, InferenceProvider } from '../inference/types.js';
|
|
2
2
|
import type { CodeContext } from './code-context.js';
|
|
3
|
+
import { type SettledCheck } from '../review/settled-checks.js';
|
|
3
4
|
import type { RelatedChange } from './related-changes.js';
|
|
4
5
|
export interface FocusItem {
|
|
5
6
|
file: string;
|
|
@@ -19,6 +20,8 @@ export interface PrReviewInput {
|
|
|
19
20
|
/** The repository's own rules that apply to this change, already rendered. */
|
|
20
21
|
rules?: string;
|
|
21
22
|
prBody?: string;
|
|
23
|
+
/** What Rigour's checks already found on the change: listed as settled; a finding of the same kind at one of their lines is dropped. */
|
|
24
|
+
settled?: SettledCheck[];
|
|
22
25
|
}
|
|
23
26
|
export interface PrReviewResult {
|
|
24
27
|
findings: DeepFinding[];
|
package/dist/deep/pr-review.js
CHANGED
|
@@ -14,6 +14,7 @@ import { REVIEW_CATEGORIES } from './code-review-prompt.js';
|
|
|
14
14
|
import { parseFindings } from './parse-findings.js';
|
|
15
15
|
import { diffSections } from './pr-diff.js';
|
|
16
16
|
import { runToolLoop } from './tool-loop.js';
|
|
17
|
+
import { againstSettled, settledLine, settledSection } from '../review/settled-checks.js';
|
|
17
18
|
const BUDGET = { maxToolCalls: 24, maxTurns: 14 };
|
|
18
19
|
const MAX_DIFF_CHARS = 80_000;
|
|
19
20
|
const MAX_FINDINGS = 5;
|
|
@@ -21,7 +22,12 @@ const PR_BODY_CHARS = 1500;
|
|
|
21
22
|
export async function reviewPullRequest(provider, input, inference) {
|
|
22
23
|
const { prompt, sentDiff } = buildPrPrompt(input);
|
|
23
24
|
const loop = await runToolLoop(provider, prompt, input.cwd, inference, BUDGET);
|
|
24
|
-
|
|
25
|
+
// A model finding of the same kind as a check's, on its line, is that check's finding said again: dropped. Anything
|
|
26
|
+
// else there is a different problem on a line a check also flags: kept, and it says so.
|
|
27
|
+
const findings = parseFindings(loop.text).flatMap(f => {
|
|
28
|
+
const against = againstSettled(f, input.settled ?? []);
|
|
29
|
+
return against.same ? [] : [against.alsoAt.length ? { ...f, alsoAt: against.alsoAt.join('; ') } : f];
|
|
30
|
+
}).slice(0, MAX_FINDINGS);
|
|
25
31
|
const files = new Set([...diffSections(input.diff).map(s => s.file), ...loop.toolbox.reads.keys(), ...findings.map(f => f.file)]);
|
|
26
32
|
const shown = `${sentDiff}\n${loop.toolbox.readText}`;
|
|
27
33
|
return { findings, contexts: [...files].flatMap(file => wholeFile(input.cwd, file, shown)), toolCalls: loop.toolCalls };
|
|
@@ -61,6 +67,7 @@ export function buildPrPrompt(input) {
|
|
|
61
67
|
input.prBody ? `PR DESCRIPTION (what the author intended):\n${input.prBody.slice(0, PR_BODY_CHARS)}` : '',
|
|
62
68
|
input.rules ?? '',
|
|
63
69
|
input.lessons ?? '',
|
|
70
|
+
settledSection((input.settled ?? []).map(settledLine)),
|
|
64
71
|
contracts ? `BOTH SIDES OF A CALL CHANGED (check these contracts first):\n${contracts}` : '',
|
|
65
72
|
focus ? `LOOK FIRST (riskiest changed functions, and what to check):\n${focus}` : '',
|
|
66
73
|
`PR DIFF (new-side line numbers on the left; "-" lines were removed):\n${sentDiff}`,
|
package/dist/gates/context.js
CHANGED
|
@@ -93,8 +93,10 @@ export class ContextGate extends Gate {
|
|
|
93
93
|
const casing = this.detectCasing(match[1]);
|
|
94
94
|
this.addPattern(patterns, 'function', { casing, file, count: 1 });
|
|
95
95
|
}
|
|
96
|
-
// Class declarations
|
|
97
|
-
|
|
96
|
+
// Class declarations: `class` opening a line (after modifiers: C# partial, Kotlin enum/inner/value/annotation
|
|
97
|
+
// and the rest), its name on the same line. Not the end of
|
|
98
|
+
// `dataclass`, and not the word in a comment or a string ("# this class of errors").
|
|
99
|
+
const classMatches = content.matchAll(/^[ \t]*(?:(?:export|default|abstract|public|private|protected|internal|final|sealed|static|data|open|partial|enum|inner|value|annotation)[ \t]+)*class[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)/gm);
|
|
98
100
|
for (const match of classMatches) {
|
|
99
101
|
const casing = this.detectCasing(match[1]);
|
|
100
102
|
this.addPattern(patterns, 'class', { casing, file, count: 1 });
|
|
@@ -159,7 +159,7 @@ export class DeepAnalysisGate extends Gate {
|
|
|
159
159
|
this.config.onProgress?.(` Reviewing the PR as a whole (${focus.length} risky function(s) first)...`);
|
|
160
160
|
try {
|
|
161
161
|
const result = await reviewPullRequest(this.provider, { cwd, diff: options.diff, focus, related, lessons: lessonsSection(lessonsForDiff(cwd, options.diff, this.config.reviewLessons)),
|
|
162
|
-
rules: rulesSection(rulesForDiff(cwd, options.diff, this.config.repoRules)), prBody: options.prBody }, inferenceOptions(this.config));
|
|
162
|
+
rules: rulesSection(rulesForDiff(cwd, options.diff, this.config.repoRules)), prBody: options.prBody, settled: options.settled }, inferenceOptions(this.config));
|
|
163
163
|
this.recordPass({ findings: [], chunksTotal: 1, chunksFailed: 0 });
|
|
164
164
|
this.outcome.findingsProposed = result.findings.length;
|
|
165
165
|
this.outcome.findingsWithdrawn = 0;
|
|
@@ -239,7 +239,7 @@ export class DeepAnalysisGate extends Gate {
|
|
|
239
239
|
return provider;
|
|
240
240
|
}
|
|
241
241
|
toFailure(finding) {
|
|
242
|
-
const failure = this.createFailure(finding.description, [finding.file], finding.suggestion, `[${finding.category}] ${finding.description.substring(0, 80)}`, finding.line, undefined, finding.severity);
|
|
242
|
+
const failure = this.createFailure(finding.alsoAt ? `${finding.description} (Rigour's checks also flag this line, for another reason: ${finding.alsoAt}.)` : finding.description, [finding.file], finding.suggestion, `[${finding.category}] ${finding.description.substring(0, 80)}`, finding.line, undefined, finding.severity);
|
|
243
243
|
return { ...failure, confidence: finding.confidence, source: 'llm', category: finding.category, verified: finding.verified };
|
|
244
244
|
}
|
|
245
245
|
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* Java — Deprecated JDK APIs (Date, Vector, Hashtable, etc.)
|
|
19
19
|
*
|
|
20
20
|
*/
|
|
21
|
+
import { codeColumns } from '../utils/code-mask.js';
|
|
21
22
|
import { Gate } from './base.js';
|
|
22
23
|
import { FileScanner } from '../utils/scanner.js';
|
|
23
24
|
import { Logger } from '../utils/logger.js';
|
|
@@ -146,7 +147,7 @@ export class DeprecatedApisGate extends Gate {
|
|
|
146
147
|
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*'))
|
|
147
148
|
continue;
|
|
148
149
|
for (const rule of NODE_DEPRECATED_RULES) {
|
|
149
|
-
if (rule.pattern
|
|
150
|
+
if (callIn(line, rule.pattern, file)) {
|
|
150
151
|
deprecated.push({
|
|
151
152
|
file, line: i + 1,
|
|
152
153
|
api: rule.api,
|
|
@@ -166,7 +167,7 @@ export class DeprecatedApisGate extends Gate {
|
|
|
166
167
|
if (trimmed.startsWith('//') || trimmed.startsWith('*'))
|
|
167
168
|
continue;
|
|
168
169
|
for (const rule of WEB_DEPRECATED_RULES) {
|
|
169
|
-
if (rule.pattern
|
|
170
|
+
if (callIn(line, rule.pattern, file)) {
|
|
170
171
|
deprecated.push({
|
|
171
172
|
file, line: i + 1,
|
|
172
173
|
api: rule.api,
|
|
@@ -186,7 +187,7 @@ export class DeprecatedApisGate extends Gate {
|
|
|
186
187
|
if (trimmed.startsWith('#'))
|
|
187
188
|
continue;
|
|
188
189
|
for (const rule of PYTHON_DEPRECATED_RULES) {
|
|
189
|
-
if (rule.pattern
|
|
190
|
+
if (callIn(line, rule.pattern, file)) {
|
|
190
191
|
deprecated.push({
|
|
191
192
|
file, line: i + 1,
|
|
192
193
|
api: rule.api,
|
|
@@ -206,7 +207,7 @@ export class DeprecatedApisGate extends Gate {
|
|
|
206
207
|
if (trimmed.startsWith('//'))
|
|
207
208
|
continue;
|
|
208
209
|
for (const rule of GO_DEPRECATED_RULES) {
|
|
209
|
-
if (rule.pattern
|
|
210
|
+
if (callIn(line, rule.pattern, file)) {
|
|
210
211
|
deprecated.push({
|
|
211
212
|
file, line: i + 1,
|
|
212
213
|
api: rule.api, reason: rule.reason,
|
|
@@ -224,7 +225,7 @@ export class DeprecatedApisGate extends Gate {
|
|
|
224
225
|
if (trimmed.startsWith('//') || trimmed.startsWith('/*'))
|
|
225
226
|
continue;
|
|
226
227
|
for (const rule of CSHARP_DEPRECATED_RULES) {
|
|
227
|
-
if (rule.pattern
|
|
228
|
+
if (callIn(line, rule.pattern, file)) {
|
|
228
229
|
deprecated.push({
|
|
229
230
|
file, line: i + 1,
|
|
230
231
|
api: rule.api, reason: rule.reason,
|
|
@@ -242,7 +243,7 @@ export class DeprecatedApisGate extends Gate {
|
|
|
242
243
|
if (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*'))
|
|
243
244
|
continue;
|
|
244
245
|
for (const rule of JAVA_DEPRECATED_RULES) {
|
|
245
|
-
if (rule.pattern
|
|
246
|
+
if (callIn(line, rule.pattern, file)) {
|
|
246
247
|
deprecated.push({
|
|
247
248
|
file, line: i + 1,
|
|
248
249
|
api: rule.api, reason: rule.reason,
|
|
@@ -253,3 +254,17 @@ export class DeprecatedApisGate extends Gate {
|
|
|
253
254
|
}
|
|
254
255
|
}
|
|
255
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Whether a rule matches this line where it is code: every match is tried, and one that starts in a string literal or a
|
|
259
|
+
* comment (utils/code-mask.ts) is not a use of the API. Every rule here is a call or an import.
|
|
260
|
+
*/
|
|
261
|
+
function callIn(line, pattern, file) {
|
|
262
|
+
if (!pattern.test(line))
|
|
263
|
+
return false;
|
|
264
|
+
const isCode = codeColumns(line, path.extname(file).slice(1));
|
|
265
|
+
const every = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
|
|
266
|
+
for (const m of line.matchAll(every))
|
|
267
|
+
if (isCode(m.index ?? 0))
|
|
268
|
+
return true;
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* 3. Compare handling strategies within each cluster
|
|
12
12
|
* 4. Flag types with >2 distinct handling patterns across files
|
|
13
13
|
*
|
|
14
|
+
* A handler that only hands the error to a helper (`return handle_value_error(e)`) is no strategy of its own: the
|
|
15
|
+
* helper holds the handling, so it is counted as neither consistent nor not.
|
|
16
|
+
*
|
|
14
17
|
* Examples of inconsistency:
|
|
15
18
|
* - File A: catch(e) { console.log(e) }
|
|
16
19
|
* - File B: catch(e) { throw new AppError(e) }
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* 3. Compare handling strategies within each cluster
|
|
12
12
|
* 4. Flag types with >2 distinct handling patterns across files
|
|
13
13
|
*
|
|
14
|
+
* A handler that only hands the error to a helper (`return handle_value_error(e)`) is no strategy of its own: the
|
|
15
|
+
* helper holds the handling, so it is counted as neither consistent nor not.
|
|
16
|
+
*
|
|
14
17
|
* Examples of inconsistency:
|
|
15
18
|
* - File A: catch(e) { console.log(e) }
|
|
16
19
|
* - File B: catch(e) { throw new AppError(e) }
|
|
@@ -24,6 +27,12 @@ import { Logger } from '../utils/logger.js';
|
|
|
24
27
|
import { languageAdapters } from './language-adapters/index.js';
|
|
25
28
|
import fs from 'fs-extra';
|
|
26
29
|
import path from 'path';
|
|
30
|
+
const DELEGATE = 'delegate';
|
|
31
|
+
/** One call and nothing else, returned or awaited or not: `return handle(e)`, `report.failed(x, e);`, `{ await retry(e) }`. */
|
|
32
|
+
const DELEGATION = /^(?:return\s+)?(?:await\s+)?[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*\([^\n]*\)\s*;?$/;
|
|
33
|
+
function delegates(body) {
|
|
34
|
+
return DELEGATION.test(body.trim().replace(/^\{\s*/, '').replace(/\s*\}$/, '').trim());
|
|
35
|
+
}
|
|
27
36
|
export class InconsistentErrorHandlingGate extends Gate {
|
|
28
37
|
config;
|
|
29
38
|
constructor(config = {}) {
|
|
@@ -65,7 +74,7 @@ export class InconsistentErrorHandlingGate extends Gate {
|
|
|
65
74
|
file,
|
|
66
75
|
line: fact.startLine,
|
|
67
76
|
errorType: fact.type,
|
|
68
|
-
strategy: fact.strategy,
|
|
77
|
+
strategy: fact.strategy === 'other' && delegates(fact.body) ? DELEGATE : fact.strategy,
|
|
69
78
|
rawPattern: fact.body.split('\n')[0]?.trim() || '',
|
|
70
79
|
});
|
|
71
80
|
}
|
|
@@ -90,6 +99,8 @@ export class InconsistentErrorHandlingGate extends Gate {
|
|
|
90
99
|
// Count unique strategies
|
|
91
100
|
const strategies = new Map();
|
|
92
101
|
for (const handler of typeHandlers) {
|
|
102
|
+
if (handler.strategy === DELEGATE)
|
|
103
|
+
continue;
|
|
93
104
|
const existing = strategies.get(handler.strategy) || [];
|
|
94
105
|
existing.push(handler);
|
|
95
106
|
strategies.set(handler.strategy, existing);
|
package/dist/gates/runner.js
CHANGED
|
@@ -35,6 +35,7 @@ import { DeprecatedDependenciesGate } from './deprecated-dependencies.js';
|
|
|
35
35
|
import { execa } from 'execa';
|
|
36
36
|
import { Logger } from '../utils/logger.js';
|
|
37
37
|
import { FileSystemCache } from '../services/filesystem-cache.js';
|
|
38
|
+
import { onChangedLines, settledChecks } from '../review/settled-checks.js';
|
|
38
39
|
export class GateRunner {
|
|
39
40
|
config;
|
|
40
41
|
gates = [];
|
|
@@ -213,7 +214,8 @@ export class GateRunner {
|
|
|
213
214
|
// 3. Run Deep Analysis (if enabled)
|
|
214
215
|
let deepStats = undefined;
|
|
215
216
|
if (deepOptions?.enabled) {
|
|
216
|
-
|
|
217
|
+
// What the checks found on the change's lines is settled: the model is told so and never reports it again.
|
|
218
|
+
const deep = await runDeepAnalysis(this.config, { cwd, ignore, patterns }, { ...deepOptions, settled: settledChecks(onChangedLines(failures, deepOptions.focusLines)) });
|
|
217
219
|
failures.push(...deep.failures);
|
|
218
220
|
summary['deep-analysis'] = deep.summary;
|
|
219
221
|
deepStats = deep.stats;
|
|
@@ -11,4 +11,10 @@ export declare const VULNERABILITY_PATTERNS: {
|
|
|
11
11
|
description: string;
|
|
12
12
|
cwe: string;
|
|
13
13
|
languages: string[];
|
|
14
|
+
/**
|
|
15
|
+
* `anywhere` for a pattern about a string's contents (a secret in a literal, a header value, a template): it matches
|
|
16
|
+
* inside strings. Every other pattern is an API used as a call or a statement, and counts only when its match starts
|
|
17
|
+
* in code, not in a string literal or a comment (utils/code-mask.ts).
|
|
18
|
+
*/
|
|
19
|
+
where?: 'anywhere';
|
|
14
20
|
}[];
|
|
@@ -68,7 +68,8 @@ export const VULNERABILITY_PATTERNS = [
|
|
|
68
68
|
{
|
|
69
69
|
type: 'hardcoded_secrets',
|
|
70
70
|
// A secret literal has no whitespace: `hidePassword: 'Hide password'` is a UI label, not a credential.
|
|
71
|
-
regex: /(?:password|secret|api_key|apikey|auth_token|access_token|private_key)
|
|
71
|
+
regex: /(?:password|secret|api_key|apikey|auth_token|access_token|private_key)['"]?\s*[:=]\s*['"][^'"\s]{8,}['"]/gi, // a key may be quoted: JSON, a Python dict, a JS object
|
|
72
|
+
where: 'anywhere',
|
|
72
73
|
severity: 'critical',
|
|
73
74
|
description: 'Hardcoded secret detected in code',
|
|
74
75
|
cwe: 'CWE-798',
|
|
@@ -77,6 +78,7 @@ export const VULNERABILITY_PATTERNS = [
|
|
|
77
78
|
{
|
|
78
79
|
type: 'hardcoded_secrets',
|
|
79
80
|
regex: /(?:sk-|pk-|rk-|ghp_|gho_|ghu_|ghs_|ghr_)[a-zA-Z0-9]{20,}/g,
|
|
81
|
+
where: 'anywhere',
|
|
80
82
|
severity: 'critical',
|
|
81
83
|
description: 'API key pattern detected (OpenAI, GitHub, etc.)',
|
|
82
84
|
cwe: 'CWE-798',
|
|
@@ -85,6 +87,7 @@ export const VULNERABILITY_PATTERNS = [
|
|
|
85
87
|
{
|
|
86
88
|
type: 'hardcoded_secrets',
|
|
87
89
|
regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g,
|
|
90
|
+
where: 'anywhere',
|
|
88
91
|
severity: 'critical',
|
|
89
92
|
description: 'Private key embedded in source code',
|
|
90
93
|
cwe: 'CWE-798',
|
|
@@ -138,6 +141,7 @@ export const VULNERABILITY_PATTERNS = [
|
|
|
138
141
|
{
|
|
139
142
|
type: 'redos',
|
|
140
143
|
regex: /\(\?:[^)]*\+[^)]*\)\+|\([^)]*\*[^)]*\)\+|\(\.\*\)\{/g,
|
|
144
|
+
where: 'anywhere',
|
|
141
145
|
severity: 'medium',
|
|
142
146
|
description: 'Regex with nested quantifiers — potential ReDoS',
|
|
143
147
|
cwe: 'CWE-1333',
|
|
@@ -171,6 +175,7 @@ export const VULNERABILITY_PATTERNS = [
|
|
|
171
175
|
{
|
|
172
176
|
type: 'overly_permissive',
|
|
173
177
|
regex: /(?:Access-Control-Allow-Origin|x-powered-by)['"`,\s:]+\*/gi,
|
|
178
|
+
where: 'anywhere',
|
|
174
179
|
severity: 'high',
|
|
175
180
|
description: 'Wildcard Access-Control-Allow-Origin header',
|
|
176
181
|
cwe: 'CWE-942',
|
|
@@ -188,6 +193,7 @@ export const VULNERABILITY_PATTERNS = [
|
|
|
188
193
|
{
|
|
189
194
|
type: 'unsafe_output',
|
|
190
195
|
regex: /\$\{[^}]*(?:req\.|params|query|body|input|user)[^}]*\}.*(?:html|template|render)/gi,
|
|
196
|
+
where: 'anywhere',
|
|
191
197
|
severity: 'high',
|
|
192
198
|
description: 'User input interpolated into template/HTML output',
|
|
193
199
|
cwe: 'CWE-79',
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* - Insecure Randomness
|
|
13
13
|
* - Command Injection
|
|
14
14
|
*/
|
|
15
|
+
import { codeOffsets } from '../utils/code-mask.js';
|
|
15
16
|
import { Gate } from './base.js';
|
|
16
17
|
import { FileScanner } from '../utils/scanner.js';
|
|
17
18
|
import { Logger } from '../utils/logger.js';
|
|
@@ -138,6 +139,8 @@ export class SecurityPatternsGate extends Gate {
|
|
|
138
139
|
});
|
|
139
140
|
}
|
|
140
141
|
}
|
|
142
|
+
// A call written in a message or a comment is not a call: a pattern of that kind counts only from code.
|
|
143
|
+
const isCode = codeOffsets(content, ext);
|
|
141
144
|
for (const pattern of VULNERABILITY_PATTERNS) {
|
|
142
145
|
// Check if pattern applies to this file type
|
|
143
146
|
if (!pattern.languages.includes('*') && !pattern.languages.includes(ext)) {
|
|
@@ -147,6 +150,8 @@ export class SecurityPatternsGate extends Gate {
|
|
|
147
150
|
pattern.regex.lastIndex = 0;
|
|
148
151
|
let match;
|
|
149
152
|
while ((match = pattern.regex.exec(content)) !== null) {
|
|
153
|
+
if (pattern.where !== 'anywhere' && !isCode(match.index))
|
|
154
|
+
continue;
|
|
150
155
|
// For hardcoded_secrets: filter out placeholder/dummy values and env var names
|
|
151
156
|
if (pattern.type === 'hardcoded_secrets' && this.isDummySecretValue(match[0])) {
|
|
152
157
|
continue;
|
|
@@ -42,7 +42,7 @@ import { LANG_MAP, FILE_GLOBS, stripStrings,
|
|
|
42
42
|
// Detectors
|
|
43
43
|
isTimerCreation, getTimerCleanupPatterns, isProcessSpawn, getProcessCleanupPatterns, isUnboundedLoop, containsIO, isFileWatcher, extractWatchedPath, extractWritePath, pathsOverlap, findWriteInBody, hasDebounceProtection, isResourceOpen, getResourceClosePatterns, isExitHandler,
|
|
44
44
|
// Loop/recursion
|
|
45
|
-
hasRetryLimit, hasCatchWithContinue, hasBaseCase, hasDepthParameter,
|
|
45
|
+
hasRetryLimit, hasCatchWithContinue, hasBaseCase, selfCallPattern, hasDepthParameter,
|
|
46
46
|
// Variable binding
|
|
47
47
|
extractVariableBinding, hasCleanupForVariable,
|
|
48
48
|
// Go defer
|
|
@@ -481,15 +481,13 @@ export class SideEffectAnalysisGate extends Gate {
|
|
|
481
481
|
const bodyLines = lines.slice(func.start + 1, func.end);
|
|
482
482
|
const body = bodyLines.join('\n');
|
|
483
483
|
// Check if function calls itself
|
|
484
|
-
|
|
485
|
-
const selfCallPat = new RegExp(`\\b${escaped}\\s*\\(`);
|
|
486
|
-
if (!selfCallPat.test(body))
|
|
484
|
+
if (!selfCallPattern(func.name, func.params).test(body))
|
|
487
485
|
continue;
|
|
488
486
|
// Check for depth/limit parameter in function signature
|
|
489
487
|
if (hasDepthParameter(func.params))
|
|
490
488
|
continue;
|
|
491
489
|
// Check for base case BEFORE recursive call (ordering matters)
|
|
492
|
-
if (hasBaseCase(bodyLines, func.name))
|
|
490
|
+
if (hasBaseCase(bodyLines, func.name, func.params))
|
|
493
491
|
continue;
|
|
494
492
|
// Only flag if there's I/O in the recursive function
|
|
495
493
|
// (pure recursion = stack overflow, not a side-effect issue)
|
|
@@ -45,12 +45,18 @@ export declare function extractFunctionDefs(lines: string[], lang: SideEffectLan
|
|
|
45
45
|
end: number;
|
|
46
46
|
params: string;
|
|
47
47
|
}[];
|
|
48
|
+
/**
|
|
49
|
+
* A call of the function by its own name: bare (`walk(`), or on `self`, `cls`, `this` or the function's own Go receiver
|
|
50
|
+
* (`self.crawl(`, `w.walk(` in `func (w *Walker) walk`). A call of another object's method that shares the name
|
|
51
|
+
* (`subprocess.run(` inside `def run`) is not one. `header` is the function's declaration line.
|
|
52
|
+
*/
|
|
53
|
+
export declare function selfCallPattern(funcName: string, header?: string): RegExp;
|
|
48
54
|
/**
|
|
49
55
|
* Check if a function has a base case (return/break before recursive call).
|
|
50
56
|
* Smart: actually checks that the base case comes BEFORE the recursive call,
|
|
51
57
|
* not just that both exist somewhere in the body.
|
|
52
58
|
*/
|
|
53
|
-
export declare function hasBaseCase(bodyLines: string[], funcName: string): boolean;
|
|
59
|
+
export declare function hasBaseCase(bodyLines: string[], funcName: string, header?: string): boolean;
|
|
54
60
|
/**
|
|
55
61
|
* Check if a function has a depth/limit parameter (implies bounded recursion).
|
|
56
62
|
* Smarter than just checking for the word "depth" anywhere — checks the
|
|
@@ -156,14 +156,24 @@ function getFuncDefPatterns(lang) {
|
|
|
156
156
|
];
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* A call of the function by its own name: bare (`walk(`), or on `self`, `cls`, `this` or the function's own Go receiver
|
|
161
|
+
* (`self.crawl(`, `w.walk(` in `func (w *Walker) walk`). A call of another object's method that shares the name
|
|
162
|
+
* (`subprocess.run(` inside `def run`) is not one. `header` is the function's declaration line.
|
|
163
|
+
*/
|
|
164
|
+
export function selfCallPattern(funcName, header = '') {
|
|
165
|
+
const escaped = funcName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
166
|
+
const receiver = /^\s*func\s*\(\s*([A-Za-z_]\w*)\s/.exec(header)?.[1];
|
|
167
|
+
const owners = ['self', 'cls', 'this', ...(receiver ? [receiver] : [])].join('|');
|
|
168
|
+
return new RegExp(`(?:(?<![\\w$.])|(?<=\\b(?:${owners})\\.))${escaped}\\s*\\(`);
|
|
169
|
+
}
|
|
159
170
|
/**
|
|
160
171
|
* Check if a function has a base case (return/break before recursive call).
|
|
161
172
|
* Smart: actually checks that the base case comes BEFORE the recursive call,
|
|
162
173
|
* not just that both exist somewhere in the body.
|
|
163
174
|
*/
|
|
164
|
-
export function hasBaseCase(bodyLines, funcName) {
|
|
165
|
-
const
|
|
166
|
-
const selfCallPat = new RegExp(`\\b${escapedName}\\s*\\(`);
|
|
175
|
+
export function hasBaseCase(bodyLines, funcName, header = '') {
|
|
176
|
+
const selfCallPat = selfCallPattern(funcName, header);
|
|
167
177
|
let foundBaseReturn = false;
|
|
168
178
|
for (const line of bodyLines) {
|
|
169
179
|
const stripped = stripStrings(line);
|
|
@@ -92,6 +92,8 @@ export interface DeepFinding {
|
|
|
92
92
|
suggestion: string;
|
|
93
93
|
/** LLM confidence score 0.0-1.0 */
|
|
94
94
|
confidence: number;
|
|
95
|
+
/** The titles of Rigour's checks that also flag this line, for a different problem (deep/pr-review.ts). */
|
|
96
|
+
alsoAt?: string;
|
|
95
97
|
}
|
|
96
98
|
/**
|
|
97
99
|
* Result of a deep analysis batch.
|
package/dist/review/review.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface ReviewResult {
|
|
|
37
37
|
excludedOutsideChangedLines: number;
|
|
38
38
|
/** Findings the base already had: counted, not reported (baseline.ts). */
|
|
39
39
|
preexisting: number;
|
|
40
|
+
/** A diff given with no base whose change HEAD already holds (committed work): nothing was compared, so nothing was dropped as the base's. */
|
|
41
|
+
baseUnknown?: boolean;
|
|
40
42
|
changedLines: Record<string, Set<number>>;
|
|
41
43
|
report: Report | null;
|
|
42
44
|
deepError?: string;
|
package/dist/review/review.js
CHANGED
|
@@ -42,7 +42,7 @@ export async function reviewChange(input) {
|
|
|
42
42
|
const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
|
|
43
43
|
// The team's `commands:` run at push (toolchain.ts), where a failure blocks; here they would only cost time.
|
|
44
44
|
const report = await new GateRunner({ ...input.config, commands: {} }).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
|
|
45
|
-
const preexisting = await dropPreexisting(input, report, targets);
|
|
45
|
+
const { preexisting, baseUnknown } = await dropPreexisting(input, report, targets);
|
|
46
46
|
if (input.diffTests && deep)
|
|
47
47
|
report.failures.push(...await diffTestFailures(input.cwd, input.source, deep));
|
|
48
48
|
// The review's own checks, each recorded in the summary beside the gates, so a report says everything that ran.
|
|
@@ -91,6 +91,7 @@ export async function reviewChange(input) {
|
|
|
91
91
|
unlocated: split.unlocated,
|
|
92
92
|
excludedOutsideChangedLines: split.outside,
|
|
93
93
|
preexisting,
|
|
94
|
+
...(baseUnknown ? { baseUnknown } : {}),
|
|
94
95
|
changedLines,
|
|
95
96
|
report,
|
|
96
97
|
gateErrors,
|
|
@@ -101,23 +102,47 @@ export async function reviewChange(input) {
|
|
|
101
102
|
...(checkedGoal ? { goal: checkedGoal } : {}),
|
|
102
103
|
};
|
|
103
104
|
}
|
|
104
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Drop the rules' findings the base already had; returns how many. Model findings stay: the model reviews only the change.
|
|
107
|
+
* A diff given with no `--base` is compared with HEAD, which is right for uncommitted work. When HEAD already holds the
|
|
108
|
+
* change (a diff of committed work), HEAD is no base: every finding would look like the base's, so none is dropped and
|
|
109
|
+
* `baseUnknown` says why.
|
|
110
|
+
*/
|
|
105
111
|
async function dropPreexisting(input, report, targets) {
|
|
106
112
|
if (input.config.review?.show_preexisting)
|
|
107
|
-
return 0;
|
|
113
|
+
return { preexisting: 0 };
|
|
114
|
+
if (input.diff !== undefined && input.source?.mode !== 'base' && input.source?.mode !== 'since' && headHolds(input.cwd, input.diff))
|
|
115
|
+
return { preexisting: 0, baseUnknown: true };
|
|
108
116
|
const commit = baseCommit(input.cwd, input.source ?? (input.diff ? undefined : { mode: 'working' }));
|
|
109
117
|
const rules = report.failures.filter(f => f.provenance !== 'deep-analysis');
|
|
110
118
|
if (!commit || rules.length === 0)
|
|
111
|
-
return 0;
|
|
119
|
+
return { preexisting: 0 };
|
|
112
120
|
try {
|
|
113
121
|
const { preexisting } = splitIntroduced(rules, await baseFindings(input.cwd, input.config, commit, targets));
|
|
114
122
|
const old = new Set(preexisting);
|
|
115
123
|
report.failures = report.failures.filter(f => !old.has(f));
|
|
116
|
-
return old.size;
|
|
124
|
+
return { preexisting: old.size };
|
|
117
125
|
}
|
|
118
126
|
catch {
|
|
119
|
-
return 0; // the comparison is a courtesy; it never fails a review
|
|
127
|
+
return { preexisting: 0 }; // the comparison is a courtesy; it never fails a review
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Whether HEAD already has a file as the diff leaves it (its `index <before>..<after>` blob), read in one `git ls-tree`. */
|
|
131
|
+
function headHolds(cwd, diff) {
|
|
132
|
+
const after = new Map();
|
|
133
|
+
for (const m of diff.matchAll(/^diff --git a\/.+? b\/(.+)\n(?:(?!diff --git ).*\n)*?index [0-9a-f]+\.\.([0-9a-f]+)/gm)) {
|
|
134
|
+
if (!/^0+$/.test(m[2]))
|
|
135
|
+
after.set(m[1], m[2]);
|
|
120
136
|
}
|
|
137
|
+
if (after.size === 0)
|
|
138
|
+
return false;
|
|
139
|
+
const tree = spawnSync('git', ['ls-tree', '-z', 'HEAD', '--', ...after.keys()], { cwd, encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024 * 1024 });
|
|
140
|
+
if (tree.status !== 0)
|
|
141
|
+
return false;
|
|
142
|
+
return tree.stdout.split('\0').some(line => {
|
|
143
|
+
const m = /^\d+ blob ([0-9a-f]+)\t(.+)$/.exec(line);
|
|
144
|
+
return !!m && !!after.get(m[2]) && m[1].startsWith(after.get(m[2]));
|
|
145
|
+
});
|
|
121
146
|
}
|
|
122
147
|
/** Every path the diff touches (including deletions, which parseDiff drops) that steers Rigour itself. */
|
|
123
148
|
function controlFiles(diff) {
|
|
@@ -20,6 +20,7 @@ import { reviewedKeys } from '../ledger.js';
|
|
|
20
20
|
import { textSimilarity } from './consensus.js';
|
|
21
21
|
import { defaultExec, GH_TIMEOUT_MS } from './exec.js';
|
|
22
22
|
import { VerdictStore } from './store.js';
|
|
23
|
+
import { MAX_SETTLED, settledChecks, settledLine, settledSection } from '../settled-checks.js';
|
|
23
24
|
export const REVIEW_DISMISSALS = path.join('.rigour', 'dismissed-review-items.json');
|
|
24
25
|
const MAX_DOCS = 10;
|
|
25
26
|
/** Team standards a judge is shown with the lessons about the changed files. */
|
|
@@ -29,7 +30,6 @@ const JUDGE_FILE_LESSONS = 30;
|
|
|
29
30
|
const JUDGE_LESSONS_PER_FILE = 3;
|
|
30
31
|
/** Rules from the repository's own rules files a judge is asked to answer, most relevant first. */
|
|
31
32
|
const JUDGE_RULES = 15;
|
|
32
|
-
const MAX_SETTLED = 40;
|
|
33
33
|
export function readReviewDismissals(cwd) {
|
|
34
34
|
try {
|
|
35
35
|
const parsed = JSON.parse(fs.readFileSync(path.join(cwd, REVIEW_DISMISSALS), 'utf8'));
|
|
@@ -68,7 +68,7 @@ export function dismissedAs(item, dismissals) {
|
|
|
68
68
|
}
|
|
69
69
|
/** A review's result as the reviewer's inputs: its hints, and what its checks found, as settled. */
|
|
70
70
|
export function reviewerInputs(review) {
|
|
71
|
-
return { hints: review.hints.join('\n'), checks: review.findings.map(
|
|
71
|
+
return { hints: review.hints.join('\n'), checks: settledChecks(review.findings).map(settledLine) };
|
|
72
72
|
}
|
|
73
73
|
/** The ids of the served lessons the judge said this change repeats; an answer that names no served lesson says nothing. */
|
|
74
74
|
export function lessonsApplied(answers, served) {
|
|
@@ -95,7 +95,7 @@ export function buildContext(input) {
|
|
|
95
95
|
if (rules.length)
|
|
96
96
|
sections.push(`## Rules this repository wrote for itself that apply to this change (answer every one in rules, by id)\n${rules.map(r => `- [${r.id}] (${r.source}, ${r.requirement ? 'requirement' : 'guidance'}) ${r.text}`).join('\n')}`);
|
|
97
97
|
if (input.checks.length)
|
|
98
|
-
sections.push(
|
|
98
|
+
sections.push(settledSection(input.checks));
|
|
99
99
|
const rejected = input.lessons === 'off' ? [] : rejectedForDiff(input.cwd, input.diff).map(l => {
|
|
100
100
|
const no = l.evidence.filter(e => e.kind === 'rejected').at(-1);
|
|
101
101
|
return `- this team decided against: ${l.text}${no?.author ? ` (rejected by ${no.author}${no.detail ? `: ${no.detail}` : ''})` : ''}`;
|
|
@@ -41,6 +41,19 @@ export interface ReviewCost {
|
|
|
41
41
|
/** What every run reported costing; a judge that reports no dollars adds none. */
|
|
42
42
|
actualUsd: number;
|
|
43
43
|
runs: number;
|
|
44
|
+
/** A verdict reused instead of run: for the same content on another commit (`content`). Runs and actual cost are 0. */
|
|
45
|
+
cache?: 'content';
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A verdict, findable by the content it reviewed rather than its commit: a rebase, an amend, a cherry-pick or the same
|
|
49
|
+
* change on another branch reads it instead of paying again. `cited` is the merge-base blob of every file its items cite:
|
|
50
|
+
* a later base under one of them makes the entry stale.
|
|
51
|
+
*/
|
|
52
|
+
export interface ContentEntry {
|
|
53
|
+
verdict: string;
|
|
54
|
+
head: string;
|
|
55
|
+
at: string;
|
|
56
|
+
cited: Record<string, string>;
|
|
44
57
|
}
|
|
45
58
|
/** What one judge cost per character here, frozen once, at the orchestrator's first review: `null` without enough single reviews. */
|
|
46
59
|
export interface CostBaseline {
|
|
@@ -52,6 +65,10 @@ export declare class VerdictStore {
|
|
|
52
65
|
private readonly dir;
|
|
53
66
|
private constructor();
|
|
54
67
|
static open(cwd: string, exec: Exec): Promise<VerdictStore | undefined>;
|
|
68
|
+
/** The verdict reviewed under this content key, unless it is older than CONTENT_TTL_MS or its verdict is gone. */
|
|
69
|
+
contentEntry(key: string): ContentEntry | undefined;
|
|
70
|
+
/** Keeps a content entry; past CONTENT_KEPT, the oldest are removed. */
|
|
71
|
+
recordContent(key: string, entry: ContentEntry): void;
|
|
55
72
|
verdictPath(head: string, fingerprint: string): string;
|
|
56
73
|
/** The open items beside a verdict, by id; what a delta review is asked to carry or resolve. */
|
|
57
74
|
openPath(verdictPath: string): string;
|
|
@@ -12,6 +12,10 @@ import { GH_TIMEOUT_MS } from './exec.js';
|
|
|
12
12
|
function localDay(at = new Date()) {
|
|
13
13
|
return `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')}`;
|
|
14
14
|
}
|
|
15
|
+
/** Content entries kept, newest first; older ones are removed when a new one is written. */
|
|
16
|
+
const CONTENT_KEPT = 500;
|
|
17
|
+
/** A content entry older than this is not reused. */
|
|
18
|
+
const CONTENT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
15
19
|
const COSTS_KEPT = 200;
|
|
16
20
|
export class VerdictStore {
|
|
17
21
|
dir;
|
|
@@ -26,6 +30,25 @@ export class VerdictStore {
|
|
|
26
30
|
fs.mkdirSync(path.join(dir, 'branches'), { recursive: true });
|
|
27
31
|
return new VerdictStore(dir);
|
|
28
32
|
}
|
|
33
|
+
/** The verdict reviewed under this content key, unless it is older than CONTENT_TTL_MS or its verdict is gone. */
|
|
34
|
+
contentEntry(key) {
|
|
35
|
+
const entry = this.readJson(path.join(this.dir, 'content', `${key}.json`));
|
|
36
|
+
if (!entry || Date.now() - Date.parse(entry.at) > CONTENT_TTL_MS || !fs.existsSync(entry.verdict))
|
|
37
|
+
return undefined;
|
|
38
|
+
return entry;
|
|
39
|
+
}
|
|
40
|
+
/** Keeps a content entry; past CONTENT_KEPT, the oldest are removed. */
|
|
41
|
+
recordContent(key, entry) {
|
|
42
|
+
const dir = path.join(this.dir, 'content');
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
44
|
+
this.writeJson(path.join(dir, `${key}.json`), entry);
|
|
45
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
46
|
+
if (files.length <= CONTENT_KEPT)
|
|
47
|
+
return;
|
|
48
|
+
const byAge = files.map(f => ({ f, at: fs.statSync(path.join(dir, f)).mtimeMs })).sort((a, b) => a.at - b.at);
|
|
49
|
+
for (const { f } of byAge.slice(0, files.length - CONTENT_KEPT))
|
|
50
|
+
fs.rmSync(path.join(dir, f), { force: true });
|
|
51
|
+
}
|
|
29
52
|
verdictPath(head, fingerprint) {
|
|
30
53
|
return path.join(this.dir, `${head}.${fingerprint.slice(0, 8)}.json`);
|
|
31
54
|
}
|
|
@@ -17,6 +17,7 @@ export function reviewerUsage(result, trigger) {
|
|
|
17
17
|
refused: mode?.refused?.length ?? 0,
|
|
18
18
|
judges: result.reviewers.length,
|
|
19
19
|
cached: result.cached,
|
|
20
|
+
...(result.cache ? { cache: result.cache } : {}),
|
|
20
21
|
confirmed: result.items.length,
|
|
21
22
|
disputed: result.disputed.length,
|
|
22
23
|
dropped: result.dropped.length,
|
|
@@ -68,6 +68,8 @@ export interface ReviewerResult {
|
|
|
68
68
|
costUsd?: number;
|
|
69
69
|
/** What every run of this fresh review reported costing, failed runs included: the number its cost row and its thread event carry. */
|
|
70
70
|
spentUsd?: number;
|
|
71
|
+
/** A verdict reused for the same content reviewed on another commit: nothing ran. */
|
|
72
|
+
cache?: 'content';
|
|
71
73
|
/** The repository's own rules the judge answered, and how. */
|
|
72
74
|
rules?: {
|
|
73
75
|
checked: number;
|
package/dist/review/reviewer.js
CHANGED
|
@@ -29,7 +29,7 @@ import { mergeImpact } from './reviewer/merge-impact.js';
|
|
|
29
29
|
import { applyPanel, parseAnswers, runPanel } from './reviewer/panel.js';
|
|
30
30
|
import { crossExamPrompt, deltaBlock, goalStep, mergeBlock, PROMPT_VERSION, renderPrompt } from './reviewer/prompt.js';
|
|
31
31
|
import { BASELINE_MIN_SINGLES, focusBlock, formatLedger, ledger, passLimit, runPasses, SPECIALISTS, SPECIALISTS_KEY, splitNeeds } from './reviewer/orchestrator.js';
|
|
32
|
-
import { MAX_PARTS, parseHunks, planPasses, reviewable, triage } from './reviewer/triage.js';
|
|
32
|
+
import { MAX_PARTS, parseHunks, planPasses, reviewable, skipped, triage } from './reviewer/triage.js';
|
|
33
33
|
import { modelGoalItems, parseGoal } from '../goal/goal.js';
|
|
34
34
|
import { resolveSwitch } from '../switches.js';
|
|
35
35
|
import { resolveReviewer } from './reviewer/settings.js';
|
|
@@ -250,6 +250,27 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
250
250
|
const size = reviewable(hunks);
|
|
251
251
|
const shared = reviews.markdown.length + body.length + context.text.length + (options.hints?.trim() || 'none\n').length + (goalText?.length ?? 0);
|
|
252
252
|
const projectedSingle = shared + size.chars;
|
|
253
|
+
// The same content, reviewed on another commit (a rebase, an amend, a cherry-pick, another branch): its verdict, once the
|
|
254
|
+
// base under every file it cites is checked unchanged. Only a full review: a delta depends on the verdict before it.
|
|
255
|
+
const contentKey = scope === 'full' ? sha([inputsKey, modeRecord.ran, reviewerVersions, context.key, reviewableBlobs(fullDiff)]) : undefined;
|
|
256
|
+
const reused = contentKey && !options.force ? store.contentEntry(contentKey) : undefined;
|
|
257
|
+
if (reused && sameBlobs(reused.cited, await baseBlobs(git, baseSha, Object.keys(reused.cited)))) {
|
|
258
|
+
const verdict = store.readJson(reused.verdict);
|
|
259
|
+
if (verdict) {
|
|
260
|
+
why = `same content as the verdict on ${reused.head.slice(0, 9)}`;
|
|
261
|
+
const accounted = decide(verdict, undefined, verify, prior, dismissals);
|
|
262
|
+
store.writeJson(verdictFile, { ...verdict, inputs: { head, base: baseSha, scope, why, mode: modeRecord, reviewers, versions: reviewerVersions, authors: [...authors], fingerprint, human_reviews: reviews.count, reviews_before: options.reviewsBefore ?? null, since: null, at: new Date().toISOString(), cache: 'content', from: reused.head } });
|
|
263
|
+
store.writeJson(openFile, accounted.open);
|
|
264
|
+
store.writeJson(store.decidedPath(verdictFile), accounted);
|
|
265
|
+
// A reused verdict ran nothing: it credits the savings ledger with what one judge would have been given.
|
|
266
|
+
if (modeRecord.asked === 'orchestrator' || (modeRecord.asked === 'single' && modeRecord.ran === 'single')) {
|
|
267
|
+
store.recordCost({ at: new Date().toISOString(), mode: modeRecord.asked === 'orchestrator' ? 'orchestrator' : 'single', lines: size.lines, projectedSingleChars: projectedSingle, ...(modeRecord.asked === 'orchestrator' ? { projectedChars: 0 } : {}), actualChars: 0, actualUsd: 0, runs: 0, cache: 'content' });
|
|
268
|
+
}
|
|
269
|
+
if (branch !== 'HEAD')
|
|
270
|
+
store.recordBranch(branch, { head, verdict: verdictFile, mode: scope, rulesHash, reviewsKey: reviews.key, inputsKey });
|
|
271
|
+
return { ...withRecord(accounted, verdict, true), cache: 'content' };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
253
274
|
// The orchestrator's plan: which specialists the change needs, hunk by hunk, as one combined pass. A change over the
|
|
254
275
|
// judge's limit is split by hunk only when the savings ledger covers the split's extra. Nothing to review: no pass.
|
|
255
276
|
const picked = orchestrate ? triage(hunks, { humanReviews: reviews.count, rulesAndLessons: context.rules.length + context.lessons, goal: goalItems.length > 0 }) : new Map();
|
|
@@ -504,6 +525,10 @@ async function review(cwd, base, config, exec, progress, options) {
|
|
|
504
525
|
recordReviewCost();
|
|
505
526
|
if (branch !== 'HEAD')
|
|
506
527
|
store.recordBranch(branch, { head, verdict: verdictFile, mode: scope, rulesHash, reviewsKey: reviews.key, inputsKey });
|
|
528
|
+
if (contentKey) {
|
|
529
|
+
const cited = [...new Set(verdict.findings.map(f => f.file).filter((file) => !!file))];
|
|
530
|
+
store.recordContent(contentKey, { verdict: verdictFile, head, at: new Date().toISOString(), cited: await baseBlobs(git, baseSha, cited) });
|
|
531
|
+
}
|
|
507
532
|
return { ...withRecord(accounted, verdict, false), spentUsd: spentUsd() };
|
|
508
533
|
}
|
|
509
534
|
finally {
|
|
@@ -637,3 +662,29 @@ function names(target, file) {
|
|
|
637
662
|
const after = target[at + file.length];
|
|
638
663
|
return (before === undefined || /[\s/'"=]/.test(before)) && (after === undefined || /[\s'":)]/.test(after));
|
|
639
664
|
}
|
|
665
|
+
/** The reviewable diff as content: each file's path and its before and after blobs, sorted. Lockfiles and generated files are left out. */
|
|
666
|
+
function reviewableBlobs(diff) {
|
|
667
|
+
const files = [];
|
|
668
|
+
for (const block of diff.split(/^(?=diff --git )/m)) {
|
|
669
|
+
const file = /^diff --git a\/.+? b\/(.+)$/m.exec(block)?.[1];
|
|
670
|
+
if (!file || skipped(file))
|
|
671
|
+
continue;
|
|
672
|
+
files.push(`${file} ${/^index ([0-9a-f]+\.\.[0-9a-f]+)/m.exec(block)?.[1] ?? 'no-index'}`);
|
|
673
|
+
}
|
|
674
|
+
return files.sort().join('\n');
|
|
675
|
+
}
|
|
676
|
+
/** Each file's blob at `commit`, in one `git ls-tree`; a file the commit does not have is ''. */
|
|
677
|
+
async function baseBlobs(git, commit, files) {
|
|
678
|
+
const blobs = Object.fromEntries(files.map(file => [file, '']));
|
|
679
|
+
if (files.length === 0)
|
|
680
|
+
return blobs;
|
|
681
|
+
for (const line of (await git(['ls-tree', commit, '--', ...files])).split('\n')) {
|
|
682
|
+
const m = /^\d+ blob ([0-9a-f]+)\t(.+)$/.exec(line);
|
|
683
|
+
if (m && m[2] in blobs)
|
|
684
|
+
blobs[m[2]] = m[1];
|
|
685
|
+
}
|
|
686
|
+
return blobs;
|
|
687
|
+
}
|
|
688
|
+
function sameBlobs(a, b) {
|
|
689
|
+
return Object.keys(a).every(file => a[file] === b[file]);
|
|
690
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Rigour's checks already found on a change, as a model reviewer is told it: settled, blocking on their own, never
|
|
3
|
+
* to be reported again. The judge (reviewer/context.ts) and the deep PR review (deep/pr-review.ts) both say it this way,
|
|
4
|
+
* so neither spends a model's turn on what a free check proves.
|
|
5
|
+
*/
|
|
6
|
+
/** A check's finding where it sits; `kind` is the check's id. */
|
|
7
|
+
export interface SettledCheck {
|
|
8
|
+
file: string;
|
|
9
|
+
line?: number;
|
|
10
|
+
title: string;
|
|
11
|
+
kind?: string;
|
|
12
|
+
}
|
|
13
|
+
/** What a model finding is against the settled checks: the same issue as one (drop it), or on a line one flags (keep it, say so). */
|
|
14
|
+
export declare function againstSettled(finding: {
|
|
15
|
+
file: string;
|
|
16
|
+
line?: number;
|
|
17
|
+
category: string;
|
|
18
|
+
}, settled: SettledCheck[]): {
|
|
19
|
+
same: boolean;
|
|
20
|
+
alsoAt: string[];
|
|
21
|
+
};
|
|
22
|
+
/** The findings on a change's lines (`focusLines`, per file); every finding when there is no change to scope to. */
|
|
23
|
+
export declare function onChangedLines<F extends {
|
|
24
|
+
files?: string[];
|
|
25
|
+
line?: number;
|
|
26
|
+
}>(findings: F[], focusLines: Record<string, number[]> | undefined): F[];
|
|
27
|
+
/** The most settled items (findings, dismissals, refuted points) a prompt lists. */
|
|
28
|
+
export declare const MAX_SETTLED = 40;
|
|
29
|
+
/** Findings as settled checks: their first file, line and title. */
|
|
30
|
+
export declare function settledChecks(findings: Array<{
|
|
31
|
+
id?: string;
|
|
32
|
+
files?: string[];
|
|
33
|
+
line?: number;
|
|
34
|
+
title: string;
|
|
35
|
+
}>): SettledCheck[];
|
|
36
|
+
/** One line per finding: `file:line title`. */
|
|
37
|
+
export declare function settledLine(check: SettledCheck): string;
|
|
38
|
+
/** The prompt section; empty when there is nothing settled. */
|
|
39
|
+
export declare function settledSection(lines: string[]): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Rigour's checks already found on a change, as a model reviewer is told it: settled, blocking on their own, never
|
|
3
|
+
* to be reported again. The judge (reviewer/context.ts) and the deep PR review (deep/pr-review.ts) both say it this way,
|
|
4
|
+
* so neither spends a model's turn on what a free check proves.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* The model reviewer's categories (deep/code-review-prompt.ts) each check's findings belong to: a model finding of one of
|
|
8
|
+
* them, on a check's line, is the check's finding said again. A check missing here matches no category, so a model finding
|
|
9
|
+
* on its line is always kept.
|
|
10
|
+
*/
|
|
11
|
+
const CHECK_CATEGORIES = {
|
|
12
|
+
'security-patterns': ['security'],
|
|
13
|
+
'deprecated-apis': ['security', 'api_misuse'],
|
|
14
|
+
'semantic-bugs': ['correctness'],
|
|
15
|
+
'hallucinated-imports': ['api_misuse', 'correctness'],
|
|
16
|
+
'promise-safety': ['error_handling'],
|
|
17
|
+
'offset-paging': ['scalability'],
|
|
18
|
+
'unbounded-window': ['scalability'],
|
|
19
|
+
};
|
|
20
|
+
/** What a model finding is against the settled checks: the same issue as one (drop it), or on a line one flags (keep it, say so). */
|
|
21
|
+
export function againstSettled(finding, settled) {
|
|
22
|
+
const here = settled.filter(c => c.line !== undefined && c.file === finding.file && c.line === finding.line);
|
|
23
|
+
return { same: here.some(c => !!c.kind && (CHECK_CATEGORIES[c.kind] ?? []).includes(finding.category)), alsoAt: here.map(c => c.title) };
|
|
24
|
+
}
|
|
25
|
+
/** The findings on a change's lines (`focusLines`, per file); every finding when there is no change to scope to. */
|
|
26
|
+
export function onChangedLines(findings, focusLines) {
|
|
27
|
+
if (!focusLines)
|
|
28
|
+
return findings;
|
|
29
|
+
return findings.filter(f => {
|
|
30
|
+
const lines = focusLines[f.files?.[0] ?? ''];
|
|
31
|
+
return !!lines && (f.line === undefined || lines.includes(f.line));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** The most settled items (findings, dismissals, refuted points) a prompt lists. */
|
|
35
|
+
export const MAX_SETTLED = 40;
|
|
36
|
+
/** Findings as settled checks: their first file, line and title. */
|
|
37
|
+
export function settledChecks(findings) {
|
|
38
|
+
return findings.map(f => ({ file: f.files?.[0] ?? '?', ...(f.line ? { line: f.line } : {}), title: f.title, ...(f.id ? { kind: f.id } : {}) }));
|
|
39
|
+
}
|
|
40
|
+
/** One line per finding: `file:line title`. */
|
|
41
|
+
export function settledLine(check) {
|
|
42
|
+
return `${check.file}${check.line ? `:${check.line}` : ''} ${check.title}`;
|
|
43
|
+
}
|
|
44
|
+
/** The prompt section; empty when there is nothing settled. */
|
|
45
|
+
export function settledSection(lines) {
|
|
46
|
+
return lines.length ? `## Already found by Rigour's checks: they block on their own, so do not report them again\n${lines.slice(0, MAX_SETTLED).map(c => `- ${c}`).join('\n')}` : '';
|
|
47
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* dead exports pile up in agent-written code. Only exports on added lines are checked. Another
|
|
6
6
|
* file uses an export when it names the symbol as a whole word AND points at the module: an import,
|
|
7
7
|
* re-export, dynamic import or mock whose specifier ends in the module's name (its folder's, for an
|
|
8
|
-
* index file). A same-named word elsewhere, such as an unrelated route parameter, is not a use, and
|
|
8
|
+
* index file), or the name of a barrel that re-exports the module with `export * from` (a chain of them too). A same-named word elsewhere, such as an unrelated route parameter, is not a use, and
|
|
9
9
|
* neither is a test: an export only its tests import is dead in production, and exporting a thing
|
|
10
10
|
* for its test is the habit that keeps it so. What a framework calls by convention (SvelteKit and
|
|
11
11
|
* Next.js route modules, hooks, serverless functions) is never reported.
|
|
@@ -111,10 +111,50 @@ export function unusedExportFailures(cwd, diff, config) {
|
|
|
111
111
|
if (!users)
|
|
112
112
|
return []; // git could not answer: say nothing rather than guess
|
|
113
113
|
const points = pointsAtCache(cwd);
|
|
114
|
+
const barrels = barrelsOf(cwd);
|
|
114
115
|
return candidates
|
|
115
|
-
.filter(exp =>
|
|
116
|
+
.filter(exp => {
|
|
117
|
+
const modules = [exp.file, ...reExporters(exp.file, barrels)];
|
|
118
|
+
return ![...(users.get(exp.name) ?? [])].some(user => !isOwnFile(user, exp) && !isTestFile(user) && modules.some(module => points(user, module)));
|
|
119
|
+
})
|
|
116
120
|
.map(exp => unused(exp, !settings.block));
|
|
117
121
|
}
|
|
122
|
+
const STAR_EXPORT = /^\s*export\s*\*\s*from\s*['"](\.{1,2}\/[^'"]+)['"]/gm;
|
|
123
|
+
/** Per module (path without extension), the barrel files that re-export all of it with `export * from './module'`: one git grep. */
|
|
124
|
+
function barrelsOf(cwd) {
|
|
125
|
+
const barrels = new Map();
|
|
126
|
+
const found = spawnSync('git', ['grep', '--untracked', '-l', '-E', 'export[[:space:]]*\\*[[:space:]]*from', '--', '.'], { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 });
|
|
127
|
+
if (found.status !== 0)
|
|
128
|
+
return barrels;
|
|
129
|
+
for (const barrel of found.stdout.split('\n').filter(file => CODE.test(file))) {
|
|
130
|
+
let text = '';
|
|
131
|
+
try {
|
|
132
|
+
text = fs.readFileSync(path.join(cwd, barrel), 'utf8');
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
for (const match of text.matchAll(STAR_EXPORT)) {
|
|
138
|
+
const module = withoutExtension(path.posix.join(path.posix.dirname(barrel), match[1]));
|
|
139
|
+
barrels.set(module, [...(barrels.get(module) ?? []), barrel]);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return barrels;
|
|
143
|
+
}
|
|
144
|
+
/** Every barrel that re-exports `file`, directly or through other barrels. */
|
|
145
|
+
function reExporters(file, barrels) {
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
const queue = [withoutExtension(file)];
|
|
148
|
+
while (queue.length) {
|
|
149
|
+
for (const barrel of barrels.get(queue.shift()) ?? []) {
|
|
150
|
+
if (seen.has(barrel))
|
|
151
|
+
continue;
|
|
152
|
+
seen.add(barrel);
|
|
153
|
+
queue.push(withoutExtension(barrel));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return [...seen];
|
|
157
|
+
}
|
|
118
158
|
/**
|
|
119
159
|
* In a project that emits declarations, an exported type named elsewhere in its own file is part
|
|
120
160
|
* of another export's signature (TypeScript requires it to stay exported); in an application it is not.
|
package/dist/types/index.d.ts
CHANGED
|
@@ -3784,4 +3784,10 @@ export interface DeepOptions {
|
|
|
3784
3784
|
independent?: boolean;
|
|
3785
3785
|
/** The change's unified diff (from reviewChange): a cloud agentic review reads the PR as a whole. */
|
|
3786
3786
|
diff?: string;
|
|
3787
|
+
/** What Rigour's checks already found on the change's lines (the runner fills it): settled, never reported again. */
|
|
3788
|
+
settled?: Array<{
|
|
3789
|
+
file: string;
|
|
3790
|
+
line?: number;
|
|
3791
|
+
title: string;
|
|
3792
|
+
}>;
|
|
3787
3793
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which characters of a source file are code, and which sit inside a string literal or a line comment: what tells a
|
|
3
|
+
* call (`subprocess.call(cmd, shell=True)`) from the same text in a message (`MSG = "subprocess.call(...)"`).
|
|
4
|
+
*
|
|
5
|
+
* The string literals are the ones `stripStrings` already strips (side-effect-helpers/types.ts): `"…"`, `'…'` and
|
|
6
|
+
* `` `…` ``, with backslash escapes. A line comment starts at `#` (Python, Ruby, shell) or `//` (the C family) outside
|
|
7
|
+
* a string. Every line starts as code: nothing carries to the next line, so a regex literal holding a quote (`/"/`) or
|
|
8
|
+
* an unclosed quote affects only its own line and can never hide a real call below it.
|
|
9
|
+
*
|
|
10
|
+
* Two limits, both narrowing a false positive and never losing a match the gates made before:
|
|
11
|
+
* - a string or a block comment spanning lines (a Python `"""` docstring, a multi-line template literal, `/* … *\/`)
|
|
12
|
+
* is read as code past its first line, as before;
|
|
13
|
+
* - code inside a string interpolation (Python `f"{…}"`, JS `${…}`, Ruby `#{…}`) is string, so a call written inside
|
|
14
|
+
* one does not count as a call.
|
|
15
|
+
*/
|
|
16
|
+
/** For one line, whether column `col` is code. `ext` is the file's extension, which decides the comment marker. */
|
|
17
|
+
export declare function codeColumns(line: string, ext: string): (col: number) => boolean;
|
|
18
|
+
/** For a whole file, whether the character at `offset` is code: line by line, each line starting as code. */
|
|
19
|
+
export declare function codeOffsets(content: string, ext: string): (offset: number) => boolean;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which characters of a source file are code, and which sit inside a string literal or a line comment: what tells a
|
|
3
|
+
* call (`subprocess.call(cmd, shell=True)`) from the same text in a message (`MSG = "subprocess.call(...)"`).
|
|
4
|
+
*
|
|
5
|
+
* The string literals are the ones `stripStrings` already strips (side-effect-helpers/types.ts): `"…"`, `'…'` and
|
|
6
|
+
* `` `…` ``, with backslash escapes. A line comment starts at `#` (Python, Ruby, shell) or `//` (the C family) outside
|
|
7
|
+
* a string. Every line starts as code: nothing carries to the next line, so a regex literal holding a quote (`/"/`) or
|
|
8
|
+
* an unclosed quote affects only its own line and can never hide a real call below it.
|
|
9
|
+
*
|
|
10
|
+
* Two limits, both narrowing a false positive and never losing a match the gates made before:
|
|
11
|
+
* - a string or a block comment spanning lines (a Python `"""` docstring, a multi-line template literal, `/* … *\/`)
|
|
12
|
+
* is read as code past its first line, as before;
|
|
13
|
+
* - code inside a string interpolation (Python `f"{…}"`, JS `${…}`, Ruby `#{…}`) is string, so a call written inside
|
|
14
|
+
* one does not count as a call.
|
|
15
|
+
*/
|
|
16
|
+
const LITERAL = {
|
|
17
|
+
'"': /"(?:[^"\\]|\\.)*"/y,
|
|
18
|
+
"'": /'(?:[^'\\]|\\.)*'/y,
|
|
19
|
+
'`': /`(?:[^`\\]|\\.)*`/y,
|
|
20
|
+
};
|
|
21
|
+
const HASH_COMMENTS = new Set(['py', 'rb', 'sh', 'bash', 'zsh', 'yml', 'yaml', 'toml', 'r', 'pl']);
|
|
22
|
+
/** For one line, whether column `col` is code. `ext` is the file's extension, which decides the comment marker. */
|
|
23
|
+
export function codeColumns(line, ext) {
|
|
24
|
+
const hash = HASH_COMMENTS.has(ext.toLowerCase());
|
|
25
|
+
const masked = new Uint8Array(line.length);
|
|
26
|
+
for (let i = 0; i < line.length; i++) {
|
|
27
|
+
const c = line[i];
|
|
28
|
+
if ((hash && c === '#') || (!hash && c === '/' && line[i + 1] === '/')) {
|
|
29
|
+
masked.fill(1, i);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
const literal = LITERAL[c];
|
|
33
|
+
if (!literal)
|
|
34
|
+
continue;
|
|
35
|
+
literal.lastIndex = i;
|
|
36
|
+
const m = literal.exec(line);
|
|
37
|
+
if (!m)
|
|
38
|
+
continue; // an unclosed quote: the rest of the line stays code
|
|
39
|
+
masked.fill(1, i, i + m[0].length);
|
|
40
|
+
i += m[0].length - 1;
|
|
41
|
+
}
|
|
42
|
+
return col => col < 0 || col >= line.length || masked[col] === 0;
|
|
43
|
+
}
|
|
44
|
+
/** For a whole file, whether the character at `offset` is code: line by line, each line starting as code. */
|
|
45
|
+
export function codeOffsets(content, ext) {
|
|
46
|
+
const starts = [0];
|
|
47
|
+
for (let i = 0; i < content.length; i++)
|
|
48
|
+
if (content[i] === '\n')
|
|
49
|
+
starts.push(i + 1);
|
|
50
|
+
const lines = new Map();
|
|
51
|
+
return offset => {
|
|
52
|
+
let lo = 0;
|
|
53
|
+
let hi = starts.length - 1;
|
|
54
|
+
while (lo < hi) {
|
|
55
|
+
const mid = (lo + hi + 1) >> 1;
|
|
56
|
+
if (starts[mid] <= offset)
|
|
57
|
+
lo = mid;
|
|
58
|
+
else
|
|
59
|
+
hi = mid - 1;
|
|
60
|
+
}
|
|
61
|
+
if (!lines.has(lo)) {
|
|
62
|
+
const end = lo + 1 < starts.length ? starts[lo + 1] - 1 : content.length;
|
|
63
|
+
lines.set(lo, codeColumns(content.slice(starts[lo], end), ext));
|
|
64
|
+
}
|
|
65
|
+
return lines.get(lo)(offset - starts[lo]);
|
|
66
|
+
};
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/core",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.10.0-rc.1",
|
|
4
4
|
"description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, across TypeScript, JavaScript, Python, Go, Ruby and C#.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.13"
|
|
@@ -75,11 +75,11 @@
|
|
|
75
75
|
"@anthropic-ai/sdk": "^0.132.1",
|
|
76
76
|
"pg": "^8.16.3",
|
|
77
77
|
"openai": "^5.23.2",
|
|
78
|
-
"@rigour-labs/brain-darwin-
|
|
79
|
-
"@rigour-labs/brain-darwin-
|
|
80
|
-
"@rigour-labs/brain-linux-
|
|
81
|
-
"@rigour-labs/brain-linux-
|
|
82
|
-
"@rigour-labs/brain-win-x64": "6.
|
|
78
|
+
"@rigour-labs/brain-darwin-x64": "6.10.0-rc.1",
|
|
79
|
+
"@rigour-labs/brain-darwin-arm64": "6.10.0-rc.1",
|
|
80
|
+
"@rigour-labs/brain-linux-x64": "6.10.0-rc.1",
|
|
81
|
+
"@rigour-labs/brain-linux-arm64": "6.10.0-rc.1",
|
|
82
|
+
"@rigour-labs/brain-win-x64": "6.10.0-rc.1"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/fs-extra": "^11.0.4",
|