@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.
@@ -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[];
@@ -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
- const findings = parseFindings(loop.text).slice(0, MAX_FINDINGS);
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}`,
@@ -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
- const classMatches = content.matchAll(/class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g);
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.test(line)) {
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.test(line)) {
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.test(line)) {
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.test(line)) {
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.test(line)) {
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.test(line)) {
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);
@@ -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
- const deep = await runDeepAnalysis(this.config, { cwd, ignore, patterns }, deepOptions);
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)\s*[:=]\s*['"][^'"\s]{8,}['"]/gi,
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
- const escaped = func.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
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 escapedName = funcName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
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.
@@ -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;
@@ -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
- /** Drop the rules' findings the base already had; returns how many. Model findings stay: the model reviews only the change. */
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(f => `${f.files?.[0] ?? '?'}${f.line ? `:${f.line}` : ''} ${f.title}`) };
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(`## Already found by Rigour's checks: they block on their own, so do not report them again\n${input.checks.slice(0, MAX_SETTLED).map(c => `- ${c}`).join('\n')}`);
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;
@@ -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 => ![...(users.get(exp.name) ?? [])].some(user => !isOwnFile(user, exp) && !isTestFile(user) && points(user, exp.file)))
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.
@@ -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.9.0",
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-arm64": "6.9.0",
79
- "@rigour-labs/brain-darwin-x64": "6.9.0",
80
- "@rigour-labs/brain-linux-arm64": "6.9.0",
81
- "@rigour-labs/brain-linux-x64": "6.9.0",
82
- "@rigour-labs/brain-win-x64": "6.9.0"
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",