@rigour-labs/core 6.10.0-rc.1 → 6.10.0-rc.2

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.
@@ -11,6 +11,8 @@ import fs from 'fs-extra';
11
11
  import path from 'path';
12
12
  import yaml from 'yaml';
13
13
  import { ConfigSchema } from '../types/index.js';
14
+ import { codeOffsets } from '../utils/code-mask.js';
15
+ import { resolveTsPathTarget } from '../gates/hallucinated-imports/ts-path-target.js';
14
16
  import { scanInputForCredentials } from './input-validator.js';
15
17
  import { evaluateWriteScope, loadAgentScopesFromDisk } from '../firewall/scope-enforcement.js';
16
18
  const JS_TS_PATTERN = /\.(ts|tsx|js|jsx|mts|mjs)$/;
@@ -57,7 +59,7 @@ async function resolveFile(filePath, cwd) {
57
59
  /**
58
60
  * Run all fast gates on a single file's content.
59
61
  */
60
- function checkFile(content, relPath, cwd, config) {
62
+ async function checkFile(content, relPath, cwd, config) {
61
63
  const failures = [];
62
64
  const lines = content.split('\n');
63
65
  // Gate 0a: Protected paths — BLOCK writes to .github/, rigour.yml, etc.
@@ -77,7 +79,7 @@ function checkFile(content, relPath, cwd, config) {
77
79
  const isJsTs = JS_TS_PATTERN.test(relPath);
78
80
  // Gate 2: Hallucinated imports (JS/TS only)
79
81
  if (isJsTs) {
80
- checkHallucinatedImports(content, relPath, cwd, failures);
82
+ await checkHallucinatedImports(content, relPath, cwd, failures);
81
83
  }
82
84
  // Gate 3: Promise safety (JS/TS only)
83
85
  if (isJsTs) {
@@ -131,7 +133,7 @@ export async function runHookChecker(options) {
131
133
  continue;
132
134
  }
133
135
  }
134
- const fileFailures = checkFile(resolved.content, resolved.relPath, cwd, config);
136
+ const fileFailures = await checkFile(resolved.content, resolved.relPath, cwd, config);
135
137
  failures.push(...fileFailures);
136
138
  }
137
139
  if (timedOut) {
@@ -163,28 +165,24 @@ export async function runHookChecker(options) {
163
165
  }
164
166
  }
165
167
  /**
166
- * Check for imports of non-existent relative files.
168
+ * Check for imports of non-existent relative files, resolved by the same rule as the review's hallucinated-imports gate
169
+ * (ts-path-target.ts): an extensionless path with any source extension or as a folder's index, and a TypeScript ESM
170
+ * specifier naming the emitted file (`./b.js` for `b.ts` or `b.tsx`, `.mjs` for `.mts`, `.cjs` for `.cts`).
167
171
  */
168
- function checkHallucinatedImports(content, relPath, cwd, failures) {
172
+ async function checkHallucinatedImports(content, relPath, cwd, failures) {
169
173
  const importRegex = /(?:import\s+.*\s+from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g;
174
+ // An import written inside a string (a template, a fixture) or a comment is text, not an import.
175
+ const isCode = codeOffsets(content, path.extname(relPath).slice(1));
170
176
  let match;
171
177
  while ((match = importRegex.exec(content)) !== null) {
178
+ if (!isCode(match.index))
179
+ continue;
172
180
  const specifier = match[1] || match[2];
173
181
  if (!specifier || !specifier.startsWith('.')) {
174
182
  continue;
175
183
  }
176
184
  const dir = path.dirname(path.join(cwd, relPath));
177
- const resolved = path.resolve(dir, specifier);
178
- const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '/index.ts', '/index.js'];
179
- const exists = extensions.some(ext => {
180
- try {
181
- return fs.existsSync(resolved + ext);
182
- }
183
- catch {
184
- return false;
185
- }
186
- });
187
- if (!exists) {
185
+ if (!await resolveTsPathTarget(dir, specifier, cwd, new Set())) {
188
186
  const lineNum = content.substring(0, match.index).split('\n').length;
189
187
  failures.push({
190
188
  gate: 'hallucinated-imports',
package/dist/index.d.ts CHANGED
@@ -53,7 +53,7 @@ export { recordAgentWrites, captureHumanEdits } from './review-learning/human-ed
53
53
  export { readRepoRules, rulesForDiff, rulesSection, splitRules, type RepoRule } from './review-learning/repo-rules.js';
54
54
  export { diffFromGit, GitDiffError, type DiffSource } from './review/git-diff.js';
55
55
  export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW, type CheckPrecision, type CheckOutcome } from './review/check-outcomes.js';
56
- export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, listOpenFindings, type ResolvedFix, type FixCapture } from './review/agent-fixes.js';
56
+ export { recordReviewOutcome, recheckOpenFindings, listResolvedFixes, removeResolvedFix, openFindingCount, listOpenFindings, type ResolvedFix, type FixCapture } from './review/agent-fixes.js';
57
57
  export { appendStory, readStories, compactDiff, STORIES_FILE, type Story, type CatchStage } from './review/stories.js';
58
58
  export { recordLessonsServed, recordPrCatches, recordReuseSuggested, type LessonChannel } from './review/learning-events.js';
59
59
  export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys, type AgentEvent, type ReviewEffectiveness } from './review/effectiveness.js';
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ export { recordAgentWrites, captureHumanEdits } from './review-learning/human-ed
51
51
  export { readRepoRules, rulesForDiff, rulesSection, splitRules } from './review-learning/repo-rules.js';
52
52
  export { diffFromGit, GitDiffError } from './review/git-diff.js';
53
53
  export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW } from './review/check-outcomes.js';
54
- export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, listOpenFindings } from './review/agent-fixes.js';
54
+ export { recordReviewOutcome, recheckOpenFindings, listResolvedFixes, removeResolvedFix, openFindingCount, listOpenFindings } from './review/agent-fixes.js';
55
55
  export { appendStory, readStories, compactDiff, STORIES_FILE } from './review/stories.js';
56
56
  export { recordLessonsServed, recordPrCatches, recordReuseSuggested } from './review/learning-events.js';
57
57
  export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys } from './review/effectiveness.js';
@@ -12,6 +12,8 @@ interface OpenFinding {
12
12
  stage?: CatchStage;
13
13
  /** Its dismissal key (quiet.ts): a finding someone dismissed was not fixed. */
14
14
  key?: string;
15
+ /** The version of the checks that reported it (CHECKER_VERSION); absent in entries captured before it was kept. */
16
+ checker?: string;
15
17
  }
16
18
  export interface ResolvedFix {
17
19
  id: string;
@@ -31,12 +33,25 @@ export interface FixCapture {
31
33
  resolved: number;
32
34
  /** The fixes this review resolved, for turning into lessons (storage/fix-lessons.ts). */
33
35
  fixes: ResolvedFix[];
36
+ /** Findings another version of the checks opened that this one no longer reports: closed, never credited as fixes. */
37
+ checkerChanged: number;
34
38
  }
35
39
  /**
36
40
  * Record a review's findings: open new ones, resolve open ones the review no longer reports.
37
41
  * Each resolved fix is also kept as a story, credited to the stage that first reported it.
38
42
  */
39
43
  export declare function recordReviewOutcome(cwd: string, findings: Failure[], reviewedFiles: string[], stage?: CatchStage): FixCapture;
44
+ /**
45
+ * Re-checks, in place, the findings another version of the checks opened at `stage` (with `all`, every one open at
46
+ * it), with `check` (this version's, for that stage): what it still reports stays open, now as its own. What it no
47
+ * longer reports closes: one another version opened as checker-changed, never a fix; one this version opened the way
48
+ * any review resolves it (a fix when its file changed since, creditFix). Runs nothing when no such finding is open.
49
+ */
50
+ export declare function recheckOpenFindings(cwd: string, stage: CatchStage, check: (files: string[]) => Promise<Failure[]>, all?: boolean): Promise<{
51
+ closed: number;
52
+ kept: number;
53
+ fixed: number;
54
+ }>;
40
55
  export declare function listResolvedFixes(cwd: string): ResolvedFix[];
41
56
  export declare function removeResolvedFix(cwd: string, id: string): void;
42
57
  export declare function openFindingCount(cwd: string): number;
@@ -9,6 +9,10 @@
9
9
  *
10
10
  * A finding only counts as resolved when its file was part of the review that
11
11
  * no longer reports it: a file committed out of the working tree was not fixed.
12
+ *
13
+ * Nor is a finding an older version of Rigour's checks reported: when the checks change (an upgrade), a finding they
14
+ * no longer report closes as "checker changed", never as a fix, with no story and no outcome. Otherwise a false
15
+ * finding the old checks made would be credited to the agent the first time it edited the file.
12
16
  */
13
17
  import crypto from 'crypto';
14
18
  import fs from 'fs';
@@ -19,6 +23,18 @@ import { dismissedKeys, findingKey } from './quiet.js';
19
23
  const DIR = path.join('.rigour', 'agent-fixes');
20
24
  const MAX_FILE_BYTES = 200_000;
21
25
  const OPEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
26
+ /** The version of Rigour's checks: the core package's own. A finding another version opened is never credited as a fix. */
27
+ const CHECKER_VERSION = coreVersion();
28
+ function coreVersion() {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version ?? 'unknown';
31
+ }
32
+ catch {
33
+ return 'unknown';
34
+ }
35
+ }
36
+ /** Whether another version of the checks opened it. */
37
+ const stale = (entry) => entry.checker !== CHECKER_VERSION;
22
38
  /**
23
39
  * Record a review's findings: open new ones, resolve open ones the review no longer reports.
24
40
  * Each resolved fix is also kept as a story, credited to the stage that first reported it.
@@ -26,21 +42,21 @@ const OPEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
26
42
  export function recordReviewOutcome(cwd, findings, reviewedFiles, stage = 'review') {
27
43
  const open = readOpen(cwd);
28
44
  const current = new Map(findings.flatMap(f => (f.files?.[0] ? [[`${f.id}:${f.files[0]}`, f]] : [])));
29
- const fixes = resolveGone(cwd, open, current, new Set(reviewedFiles));
30
- for (const fix of fixes) {
31
- appendStory(cwd, { at: fix.resolvedAt, openedAt: fix.openedAt, stage: fix.stage ?? stage, file: fix.file, rule: fix.rule, title: fix.title ?? fix.rule, details: fix.details, diff: compactDiff(fix.before, fix.after) });
32
- }
45
+ const { resolved: fixes, checkerChanged } = resolveGone(cwd, open, current, new Set(reviewedFiles), stage);
33
46
  const opened = openNew(cwd, open, current, stage);
34
47
  writeOpen(cwd, open);
35
- return { opened, resolved: fixes.length, fixes };
48
+ return { opened, resolved: fixes.length, fixes, checkerChanged };
36
49
  }
37
50
  /** Open findings this review no longer reports: resolved when their file was reviewed and changed. */
38
- function resolveGone(cwd, open, current, reviewed) {
51
+ function resolveGone(cwd, open, current, reviewed, stage) {
39
52
  const resolved = [];
53
+ let checkerChanged = 0;
40
54
  const dismissed = dismissedKeys(cwd);
41
55
  for (const [key, entry] of Object.entries(open)) {
42
- if (current.has(key))
56
+ if (current.has(key)) {
57
+ entry.checker = CHECKER_VERSION; // still reported by these checks: theirs now
43
58
  continue;
59
+ }
44
60
  if (entry.key && dismissed.has(entry.key)) {
45
61
  delete open[key]; // gone because a person dismissed it, not because anyone fixed it
46
62
  continue;
@@ -50,14 +66,60 @@ function resolveGone(cwd, open, current, reviewed) {
50
66
  delete open[key];
51
67
  continue;
52
68
  }
53
- const after = readSmall(cwd, entry.file);
54
- if (after !== null && after !== entry.before) {
55
- resolved.push(writeResolved(cwd, entry, after));
56
- recordOutcome(cwd, checkId({ rule: entry.rule, title: entry.title }), 'fixed');
69
+ if (stale(entry)) {
70
+ delete open[key]; // the checks changed, not the code: no fix, no story, no outcome
71
+ checkerChanged++;
72
+ continue;
73
+ }
74
+ const fix = creditFix(cwd, entry, stage);
75
+ if (fix)
76
+ resolved.push(fix);
77
+ delete open[key];
78
+ }
79
+ return { resolved, checkerChanged };
80
+ }
81
+ /**
82
+ * A finding these checks reported and no longer do, in a file that changed since: the agent's fix, kept with its before
83
+ * and after, a story credited to the stage that first reported it, and a `fixed` outcome. A file unchanged since is no
84
+ * fix (the finding vanished without an edit): undefined.
85
+ */
86
+ function creditFix(cwd, entry, stage) {
87
+ const after = readSmall(cwd, entry.file);
88
+ if (after === null || after === entry.before)
89
+ return undefined;
90
+ const fix = writeResolved(cwd, entry, after);
91
+ recordOutcome(cwd, checkId({ rule: entry.rule, title: entry.title }), 'fixed');
92
+ appendStory(cwd, { at: fix.resolvedAt, openedAt: fix.openedAt, stage: fix.stage ?? stage, file: fix.file, rule: fix.rule, title: fix.title ?? fix.rule, details: fix.details, diff: compactDiff(fix.before, fix.after) });
93
+ return fix;
94
+ }
95
+ /**
96
+ * Re-checks, in place, the findings another version of the checks opened at `stage` (with `all`, every one open at
97
+ * it), with `check` (this version's, for that stage): what it still reports stays open, now as its own. What it no
98
+ * longer reports closes: one another version opened as checker-changed, never a fix; one this version opened the way
99
+ * any review resolves it (a fix when its file changed since, creditFix). Runs nothing when no such finding is open.
100
+ */
101
+ export async function recheckOpenFindings(cwd, stage, check, all = false) {
102
+ const open = readOpen(cwd);
103
+ const old = Object.entries(open).filter(([, entry]) => (all || stale(entry)) && entry.stage === stage);
104
+ if (old.length === 0)
105
+ return { closed: 0, kept: 0, fixed: 0 };
106
+ const files = [...new Set(old.map(([, entry]) => entry.file))].filter(file => fs.existsSync(path.join(cwd, file)));
107
+ const reported = new Set((await check(files)).flatMap(f => (f.files?.[0] ? [`${f.id}:${f.files[0]}`] : [])));
108
+ let closed = 0;
109
+ let fixed = 0;
110
+ for (const [key, entry] of old) {
111
+ if (reported.has(key)) {
112
+ entry.checker = CHECKER_VERSION;
113
+ continue;
57
114
  }
115
+ if (!stale(entry) && creditFix(cwd, entry, stage))
116
+ fixed++;
117
+ else
118
+ closed++;
58
119
  delete open[key];
59
120
  }
60
- return resolved;
121
+ writeOpen(cwd, open);
122
+ return { closed, kept: old.length - closed - fixed, fixed };
61
123
  }
62
124
  function openNew(cwd, open, current, stage) {
63
125
  let opened = 0;
@@ -68,7 +130,7 @@ function openNew(cwd, open, current, stage) {
68
130
  const before = readSmall(cwd, file);
69
131
  if (before === null)
70
132
  continue;
71
- open[key] = { file, rule: finding.id, title: finding.title, details: finding.details, before, openedAt: new Date().toISOString(), stage, key: findingKey(finding) };
133
+ open[key] = { file, rule: finding.id, title: finding.title, details: finding.details, before, openedAt: new Date().toISOString(), stage, key: findingKey(finding), checker: CHECKER_VERSION };
72
134
  opened++;
73
135
  }
74
136
  return opened;
@@ -7,9 +7,11 @@
7
7
  * a string. Every line starts as code: nothing carries to the next line, so a regex literal holding a quote (`/"/`) or
8
8
  * an unclosed quote affects only its own line and can never hide a real call below it.
9
9
  *
10
+ * In the C family, a line that starts with `/*` or `*` is a block comment's line (the JSDoc shape) and is all comment.
11
+ *
10
12
  * 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
+ * - a string or a block comment spanning lines (a Python `"""` docstring, a multi-line template literal, a block
14
+ * comment whose lines do not start with `*`) is read as code past its first line, as before;
13
15
  * - code inside a string interpolation (Python `f"{…}"`, JS `${…}`, Ruby `#{…}`) is string, so a call written inside
14
16
  * one does not count as a call.
15
17
  */
@@ -7,9 +7,11 @@
7
7
  * a string. Every line starts as code: nothing carries to the next line, so a regex literal holding a quote (`/"/`) or
8
8
  * an unclosed quote affects only its own line and can never hide a real call below it.
9
9
  *
10
+ * In the C family, a line that starts with `/*` or `*` is a block comment's line (the JSDoc shape) and is all comment.
11
+ *
10
12
  * 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
+ * - a string or a block comment spanning lines (a Python `"""` docstring, a multi-line template literal, a block
14
+ * comment whose lines do not start with `*`) is read as code past its first line, as before;
13
15
  * - code inside a string interpolation (Python `f"{…}"`, JS `${…}`, Ruby `#{…}`) is string, so a call written inside
14
16
  * one does not count as a call.
15
17
  */
@@ -22,6 +24,8 @@ const HASH_COMMENTS = new Set(['py', 'rb', 'sh', 'bash', 'zsh', 'yml', 'yaml', '
22
24
  /** For one line, whether column `col` is code. `ext` is the file's extension, which decides the comment marker. */
23
25
  export function codeColumns(line, ext) {
24
26
  const hash = HASH_COMMENTS.has(ext.toLowerCase());
27
+ if (!hash && /^\s*(\/\*|\*)/.test(line))
28
+ return () => false;
25
29
  const masked = new Uint8Array(line.length);
26
30
  for (let i = 0; i < line.length; i++) {
27
31
  const c = line[i];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.10.0-rc.1",
3
+ "version": "6.10.0-rc.2",
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-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"
78
+ "@rigour-labs/brain-darwin-arm64": "6.10.0-rc.2",
79
+ "@rigour-labs/brain-darwin-x64": "6.10.0-rc.2",
80
+ "@rigour-labs/brain-linux-x64": "6.10.0-rc.2",
81
+ "@rigour-labs/brain-linux-arm64": "6.10.0-rc.2",
82
+ "@rigour-labs/brain-win-x64": "6.10.0-rc.2"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/fs-extra": "^11.0.4",