@rigour-labs/core 6.4.4 → 6.5.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.
Files changed (123) hide show
  1. package/dist/deep/agent-review.d.ts +19 -0
  2. package/dist/deep/agent-review.js +19 -0
  3. package/dist/deep/agent-review.test.d.ts +1 -0
  4. package/dist/deep/agent-review.test.js +53 -0
  5. package/dist/deep/code-pass.d.ts +10 -0
  6. package/dist/deep/code-pass.js +64 -33
  7. package/dist/deep/code-pass.test.js +53 -12
  8. package/dist/deep/code-verifier.d.ts +3 -1
  9. package/dist/deep/code-verifier.js +11 -11
  10. package/dist/deep/code-verifier.test.js +8 -0
  11. package/dist/deep/pr-diff.d.ts +7 -0
  12. package/dist/deep/pr-diff.js +47 -0
  13. package/dist/deep/pr-review.d.ts +33 -0
  14. package/dist/deep/pr-review.js +81 -0
  15. package/dist/deep/pr-review.test.d.ts +1 -0
  16. package/dist/deep/pr-review.test.js +80 -0
  17. package/dist/deep/related-changes.d.ts +13 -0
  18. package/dist/deep/related-changes.js +56 -0
  19. package/dist/deep/related-changes.test.d.ts +1 -0
  20. package/dist/deep/related-changes.test.js +33 -0
  21. package/dist/deep/review-tools.d.ts +16 -0
  22. package/dist/deep/review-tools.js +111 -0
  23. package/dist/deep/review-tools.test.d.ts +1 -0
  24. package/dist/deep/review-tools.test.js +39 -0
  25. package/dist/deep/risk.d.ts +41 -0
  26. package/dist/deep/risk.js +135 -0
  27. package/dist/deep/risk.test.d.ts +1 -0
  28. package/dist/deep/risk.test.js +63 -0
  29. package/dist/deep/router.d.ts +27 -0
  30. package/dist/deep/router.js +15 -0
  31. package/dist/deep/self-check.d.ts +14 -9
  32. package/dist/deep/self-check.js +41 -32
  33. package/dist/deep/tool-loop.d.ts +19 -0
  34. package/dist/deep/tool-loop.js +23 -0
  35. package/dist/gates/deep-analysis.d.ts +27 -0
  36. package/dist/gates/deep-analysis.js +73 -2
  37. package/dist/gates/deep-runner.js +14 -0
  38. package/dist/gates/frontend-secret-exposure.d.ts +2 -0
  39. package/dist/gates/frontend-secret-exposure.js +8 -0
  40. package/dist/gates/frontend-secret-exposure.test.js +14 -0
  41. package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.d.ts +1 -0
  42. package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.js +27 -0
  43. package/dist/gates/hallucinated-imports/framework-modules.d.ts +12 -3
  44. package/dist/gates/hallucinated-imports/framework-modules.js +32 -6
  45. package/dist/gates/hallucinated-imports/index.js +2 -1
  46. package/dist/gates/hallucinated-imports/js-resolver-types.test.d.ts +1 -0
  47. package/dist/gates/hallucinated-imports/js-resolver-types.test.js +14 -0
  48. package/dist/gates/hallucinated-imports/js-resolver.d.ts +6 -1
  49. package/dist/gates/hallucinated-imports/js-resolver.js +25 -3
  50. package/dist/gates/phantom-apis.js +2 -2
  51. package/dist/gates/phantom-apis.test.js +10 -0
  52. package/dist/gates/promise-safety.js +1 -1
  53. package/dist/gates/test-quality-matchers.js +3 -0
  54. package/dist/gates/test-quality.test.js +13 -0
  55. package/dist/hooks/checker.test.js +9 -0
  56. package/dist/hooks/stop-review.d.ts +2 -0
  57. package/dist/hooks/stop-review.js +19 -3
  58. package/dist/hooks/templates.d.ts +2 -0
  59. package/dist/hooks/templates.js +16 -2
  60. package/dist/hooks/templates.test.d.ts +1 -0
  61. package/dist/hooks/templates.test.js +16 -0
  62. package/dist/index.d.ts +13 -0
  63. package/dist/index.js +13 -0
  64. package/dist/inference/cloud-provider.d.ts +12 -1
  65. package/dist/inference/cloud-provider.js +137 -2
  66. package/dist/inference/cloud-provider.test.d.ts +1 -0
  67. package/dist/inference/cloud-provider.test.js +118 -0
  68. package/dist/inference/pricing.d.ts +15 -0
  69. package/dist/inference/pricing.js +13 -0
  70. package/dist/inference/types.d.ts +42 -0
  71. package/dist/review/changed-function-spans.d.ts +4 -0
  72. package/dist/review/changed-function-spans.js +48 -0
  73. package/dist/review/changed-lines.d.ts +9 -3
  74. package/dist/review/changed-lines.js +19 -2
  75. package/dist/review/deep-runs.d.ts +22 -0
  76. package/dist/review/deep-runs.js +59 -0
  77. package/dist/review/deep-runs.test.d.ts +1 -0
  78. package/dist/review/deep-runs.test.js +23 -0
  79. package/dist/review/generated-files.d.ts +3 -0
  80. package/dist/review/generated-files.js +35 -0
  81. package/dist/review/generated-files.test.d.ts +1 -0
  82. package/dist/review/generated-files.test.js +25 -0
  83. package/dist/review/ledger.d.ts +27 -0
  84. package/dist/review/ledger.js +72 -0
  85. package/dist/review/quiet.d.ts +16 -0
  86. package/dist/review/quiet.js +71 -0
  87. package/dist/review/quiet.test.d.ts +1 -0
  88. package/dist/review/quiet.test.js +27 -0
  89. package/dist/review/review-ack.d.ts +16 -0
  90. package/dist/review/review-ack.js +25 -0
  91. package/dist/review/review-task.d.ts +40 -0
  92. package/dist/review/review-task.js +50 -0
  93. package/dist/review/review-task.test.d.ts +1 -0
  94. package/dist/review/review-task.test.js +80 -0
  95. package/dist/review/review.d.ts +12 -1
  96. package/dist/review/review.js +16 -6
  97. package/dist/review/review.test.js +25 -0
  98. package/dist/review-learning/acted-on.d.ts +25 -0
  99. package/dist/review-learning/acted-on.js +69 -0
  100. package/dist/review-learning/learn-from-reviews.d.ts +34 -0
  101. package/dist/review-learning/learn-from-reviews.js +65 -0
  102. package/dist/review-learning/lessons.d.ts +52 -0
  103. package/dist/review-learning/lessons.js +149 -0
  104. package/dist/review-learning/repo-rules.d.ts +16 -0
  105. package/dist/review-learning/repo-rules.js +84 -0
  106. package/dist/review-learning/repo-rules.test.d.ts +1 -0
  107. package/dist/review-learning/repo-rules.test.js +39 -0
  108. package/dist/review-learning/review-learning.test.d.ts +1 -0
  109. package/dist/review-learning/review-learning.test.js +104 -0
  110. package/dist/review-learning/team-lessons.d.ts +4 -0
  111. package/dist/review-learning/team-lessons.js +24 -0
  112. package/dist/semantic/rules/credential-redirect.js +4 -1
  113. package/dist/settings.js +4 -1
  114. package/dist/telemetry/telemetry.d.ts +28 -0
  115. package/dist/telemetry/telemetry.js +152 -0
  116. package/dist/telemetry/telemetry.test.d.ts +1 -0
  117. package/dist/telemetry/telemetry.test.js +59 -0
  118. package/dist/telemetry/token.d.ts +1 -0
  119. package/dist/telemetry/token.js +1 -0
  120. package/dist/templates/universal-config.js +7 -3
  121. package/dist/types/index.d.ts +276 -10
  122. package/dist/types/index.js +38 -5
  123. package/package.json +6 -6
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Quiet by default: a review speaks only when it can prove the defect.
3
+ *
4
+ * Proven findings come from rules that trace the defect itself: a value
5
+ * followed from where it enters to where it does harm (semantic bugs and the
6
+ * rules learned from fixes), an import that resolves to nothing, a secret in
7
+ * the source, a model finding grounded in code it read (only when a model was
8
+ * asked for), a function that behaves differently before and after.
9
+ * Heuristics (size, complexity, patterns that guess at intent) are advisory:
10
+ * returned for whoever asks, never deciding a verdict, never posted, never
11
+ * blocking. A team that wants them back sets review.include_heuristics.
12
+ *
13
+ * A finding a person judged "not a bug" is dismissed by its key and never
14
+ * reported again (.rigour/dismissed.json, meant to be committed).
15
+ */
16
+ import crypto from 'crypto';
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ const PROVEN_GATES = new Set(['semantic-bugs', 'hallucinated-imports', 'security-patterns', 'deep-analysis', 'diff-tests']);
20
+ export const DISMISSED_FILE = path.join('.rigour', 'dismissed.json');
21
+ export function isProven(failure) {
22
+ return PROVEN_GATES.has(failure.id);
23
+ }
24
+ /** The same finding across runs and pushes: gate, file and message, never the line (lines move). */
25
+ export function findingKey(failure) {
26
+ return crypto.createHash('sha256').update(`${failure.id}\u0000${failure.files?.[0] ?? ''}\u0000${failure.details}`).digest('hex').slice(0, 16);
27
+ }
28
+ export function quietSplit(cwd, findings, includeHeuristics = false) {
29
+ const dismissed = dismissedKeys(cwd);
30
+ const split = { speaking: [], advisory: [], dismissed: 0, dismissedByGate: {} };
31
+ for (const finding of findings) {
32
+ if (dismissed.has(findingKey(finding))) {
33
+ split.dismissed++;
34
+ split.dismissedByGate[finding.id] = (split.dismissedByGate[finding.id] ?? 0) + 1;
35
+ }
36
+ else if (includeHeuristics || isProven(finding))
37
+ split.speaking.push(finding);
38
+ else
39
+ split.advisory.push(finding);
40
+ }
41
+ return split;
42
+ }
43
+ export function dismissedKeys(cwd) {
44
+ try {
45
+ const parsed = JSON.parse(fs.readFileSync(path.join(cwd, DISMISSED_FILE), 'utf8'));
46
+ return new Set(Array.isArray(parsed?.entries) ? parsed.entries.map((e) => e?.key).filter((k) => typeof k === 'string') : []);
47
+ }
48
+ catch {
49
+ return new Set();
50
+ }
51
+ }
52
+ /** Record "not a bug" for a finding key; idempotent. */
53
+ export function dismissFinding(cwd, key, reason, at = new Date().toISOString()) {
54
+ if (!/^[0-9a-f]{16}$/.test(key))
55
+ return false;
56
+ const file = path.join(cwd, DISMISSED_FILE);
57
+ let entries = [];
58
+ try {
59
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
60
+ if (Array.isArray(parsed?.entries))
61
+ entries = parsed.entries;
62
+ }
63
+ catch {
64
+ // First dismissal in this repository.
65
+ }
66
+ if (!entries.some(e => e.key === key))
67
+ entries.push({ key, reason: reason.trim(), at });
68
+ fs.mkdirSync(path.dirname(file), { recursive: true });
69
+ fs.writeFileSync(file, JSON.stringify({ version: 1, entries }, null, 2) + '\n');
70
+ return true;
71
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { dismissFinding, findingKey, quietSplit } from './quiet.js';
6
+ let dir;
7
+ beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'quiet-')); });
8
+ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
9
+ const finding = (id, details = 'd', line = 3) => ({ id, title: id, details, files: ['src/a.ts'], line });
10
+ describe('quiet by default', () => {
11
+ it('lets only findings that prove a defect speak; heuristics are advisory unless the team asks for them', () => {
12
+ const all = [finding('semantic-bugs'), finding('security-patterns'), finding('promise-safety'), finding('file-size'), finding('deep-analysis')];
13
+ const quiet = quietSplit(dir, all);
14
+ expect(quiet.speaking.map(f => f.id)).toEqual(['semantic-bugs', 'security-patterns', 'deep-analysis']);
15
+ expect(quiet.advisory.map(f => f.id)).toEqual(['promise-safety', 'file-size']);
16
+ expect(quietSplit(dir, all, true).speaking).toHaveLength(5);
17
+ });
18
+ it('never reports a dismissed finding again, even after its line moves', () => {
19
+ const leak = finding('security-patterns', 'Hardcoded token in `client`');
20
+ expect(dismissFinding(dir, findingKey(leak), 'test fixture token, never deployed')).toBe(true);
21
+ expect(dismissFinding(dir, findingKey(leak), 'again')).toBe(true); // idempotent
22
+ const moved = { ...leak, line: 40 };
23
+ expect(quietSplit(dir, [moved, finding('semantic-bugs')])).toMatchObject({ dismissed: 1, dismissedByGate: { 'security-patterns': 1 }, speaking: [expect.objectContaining({ id: 'semantic-bugs' })] });
24
+ expect(JSON.parse(fs.readFileSync(path.join(dir, '.rigour/dismissed.json'), 'utf8')).entries).toHaveLength(1);
25
+ expect(dismissFinding(dir, 'not-a-key', 'x')).toBe(false);
26
+ });
27
+ });
@@ -0,0 +1,16 @@
1
+ import { type LedgerEntry } from './ledger.js';
2
+ export interface ReviewAck {
3
+ file: string;
4
+ function: string;
5
+ verdict: string;
6
+ note: string;
7
+ reviewer?: string;
8
+ }
9
+ export type AckResult = {
10
+ ok: true;
11
+ entry: LedgerEntry;
12
+ } | {
13
+ ok: false;
14
+ error: string;
15
+ };
16
+ export declare function acknowledgeReview(cwd: string, ack: ReviewAck): AckResult;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Acknowledge a review-task item: "I checked this function, here is what I
3
+ * found". The hash is taken from the file as it is now, so an acknowledgement
4
+ * covers exactly the code that was reviewed; editing the function afterwards
5
+ * puts it back in the task.
6
+ */
7
+ import { findFunction } from '../deep/risk.js';
8
+ import { recordReview } from './ledger.js';
9
+ const MIN_NOTE_CHARS = 10;
10
+ const VERDICTS = ['fixed', 'no_issue'];
11
+ export function acknowledgeReview(cwd, ack) {
12
+ if (!VERDICTS.includes(ack.verdict))
13
+ return { ok: false, error: `verdict must be one of: ${VERDICTS.join(', ')}` };
14
+ const note = (ack.note ?? '').trim();
15
+ if (note.length < MIN_NOTE_CHARS)
16
+ return { ok: false, error: 'note must say what was checked (at least 10 characters)' };
17
+ const file = ack.file.replace(/\\/g, '/').replace(/^\.\//, '');
18
+ const current = findFunction(cwd, file, ack.function);
19
+ if (!current)
20
+ return { ok: false, error: `no function \`${ack.function}\` in ${file}` };
21
+ const entry = recordReview(cwd, {
22
+ file, function: ack.function, hash: current.hash, reviewer: ack.reviewer || 'agent', verdict: ack.verdict, note,
23
+ });
24
+ return { ok: true, entry };
25
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * A review task for the developer's own coding agent (or the developer).
3
+ *
4
+ * Inside Claude Code, Cursor and the like, a frontier model is already
5
+ * running and already paid for. Rigour does not need to call a model of its
6
+ * own: it picks the changed functions worth a careful look (risk.ts), says
7
+ * what to check in each, and records the agent's verdict in the ledger.
8
+ * On real PRs, a model that read the repository with these questions in mind
9
+ * pre-empted four times as many acted-on review comments as chunked prompts.
10
+ */
11
+ import { type RouterPolicy } from '../deep/router.js';
12
+ import { type LessonMode } from '../review-learning/team-lessons.js';
13
+ export interface ReviewTaskItem {
14
+ file: string;
15
+ function: string;
16
+ start: number;
17
+ end: number;
18
+ score: number;
19
+ hash: string;
20
+ /** What to check, from the function's risk signals. */
21
+ questions: string[];
22
+ }
23
+ export interface ReviewTask {
24
+ items: ReviewTaskItem[];
25
+ /** Risky functions already reviewed at their current content. */
26
+ alreadyReviewed: number;
27
+ /** The team's past review lessons that apply to this change. */
28
+ lessons: Array<{
29
+ file: string;
30
+ text: string;
31
+ prs: number[];
32
+ }>;
33
+ /** The repository's own rules (AGENTS.md, …) that name what this change touches. */
34
+ rules: Array<{
35
+ source: string;
36
+ text: string;
37
+ }>;
38
+ instructions: string;
39
+ }
40
+ export declare function buildReviewTask(cwd: string, diff: string, policy?: RouterPolicy, lessonMode?: LessonMode, repoRules?: boolean): ReviewTask;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A review task for the developer's own coding agent (or the developer).
3
+ *
4
+ * Inside Claude Code, Cursor and the like, a frontier model is already
5
+ * running and already paid for. Rigour does not need to call a model of its
6
+ * own: it picks the changed functions worth a careful look (risk.ts), says
7
+ * what to check in each, and records the agent's verdict in the ledger.
8
+ * On real PRs, a model that read the repository with these questions in mind
9
+ * pre-empted four times as many acted-on review comments as chunked prompts.
10
+ */
11
+ import { DEFAULT_MAX_FUNCTIONS, DEFAULT_MIN_SCORE } from '../deep/router.js';
12
+ import { rankChangedFunctions } from '../deep/risk.js';
13
+ import { changedLinesByFile, parseDiff, removedByFile } from '../utils/diff.js';
14
+ import { isReviewed, reviewedKeys } from './ledger.js';
15
+ import { lessonsForDiff } from '../review-learning/team-lessons.js';
16
+ import { rulesForDiff } from '../review-learning/repo-rules.js';
17
+ const QUESTIONS = {
18
+ 'data-write': 'Is the write safe under concurrent calls (no check-then-act), idempotent on retry, and does an upsert/conflict clause do what the caller expects?',
19
+ paging: 'Can the cursor, offset or limit skip, repeat or stall rows at page boundaries, caps or retries?',
20
+ auth: 'Is every path authorized, and are tokens or secrets kept out of logs, errors and responses?',
21
+ 'money-or-time': 'Are units, rounding, time zones and clock comparisons consistent with the callers?',
22
+ concurrency: 'What happens if two calls overlap, or one step fails half-way through?',
23
+ network: 'Are timeouts, aborts and non-2xx responses handled, and does a retry repeat a side effect?',
24
+ };
25
+ export function buildReviewTask(cwd, diff, policy = {}, lessonMode = 'off', repoRules = false) {
26
+ const changed = parseDiff(diff);
27
+ const ranked = rankChangedFunctions(cwd, changedLinesByFile(changed), removedByFile(diff));
28
+ const risky = ranked.filter(f => f.score >= (policy.min_score ?? DEFAULT_MIN_SCORE)).slice(0, policy.max_functions ?? DEFAULT_MAX_FUNCTIONS);
29
+ const reviewed = reviewedKeys(cwd);
30
+ const pending = risky.filter(f => !isReviewed(reviewed, { file: f.file, function: f.name, hash: f.hash }));
31
+ const lessons = lessonsForDiff(cwd, diff, lessonMode).map(l => ({ file: l.file, text: l.text, prs: [...new Set(l.evidence.map(e => e.pr))] }));
32
+ return {
33
+ items: pending.map(toItem),
34
+ alreadyReviewed: risky.length - pending.length,
35
+ lessons,
36
+ rules: rulesForDiff(cwd, diff, repoRules).map(r => ({ source: r.source, text: r.text })),
37
+ instructions: pending.length === 0
38
+ ? 'No risky changed function is waiting for review.'
39
+ : 'For each item: read the function, its callers and what it calls; answer its questions against the code. '
40
+ + 'Fix any real defect. Then call rigour_review_ack with verdict "fixed" or "no_issue" and a one-line note saying what you checked.',
41
+ };
42
+ }
43
+ function toItem(f) {
44
+ const questions = [
45
+ ...(f.signals.removedGuard ? ['The change removed a condition, return or throw here: which inputs did it guard against, and are they still handled?'] : []),
46
+ ...f.signals.sensitive.map(kind => QUESTIONS[kind]).filter(Boolean),
47
+ `Do the callers of \`${f.name}\` still get what they assume (types, empty and error cases)?`,
48
+ ];
49
+ return { file: f.file, function: f.name, start: f.start, end: f.end, score: f.score, hash: f.hash, questions };
50
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,80 @@
1
+ import { execFileSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { diffFromGit } from './git-diff.js';
7
+ import { acknowledgeReview } from './review-ack.js';
8
+ import { buildReviewTask } from './review-task.js';
9
+ import { exportReviewed, readLedger, REVIEWED_FILE } from './ledger.js';
10
+ import { reviewAckMessage } from '../hooks/stop-review.js';
11
+ let repo;
12
+ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
13
+ const write = (body) => fs.writeFileSync(path.join(repo, 'sync.ts'), body);
14
+ const BEFORE = [
15
+ 'export async function syncOrders(db, cursor) {',
16
+ ' const rows = await page(cursor);',
17
+ ' return rows;',
18
+ '}',
19
+ 'function label(name) {',
20
+ ' return name;',
21
+ '}',
22
+ '',
23
+ ].join('\n');
24
+ const AFTER = [
25
+ 'export async function syncOrders(db, cursor) {',
26
+ ' const rows = await page(cursor);',
27
+ " await db.from('orders').upsert(rows);",
28
+ ' return rows;',
29
+ '}',
30
+ 'function label(name) {',
31
+ ' return name.trim();',
32
+ '}',
33
+ '',
34
+ ].join('\n');
35
+ beforeEach(() => {
36
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'review-task-'));
37
+ git('init', '-q', '-b', 'main');
38
+ git('config', 'user.email', 't@example.com');
39
+ git('config', 'user.name', 't');
40
+ git('config', 'commit.gpgsign', 'false');
41
+ write(BEFORE);
42
+ git('add', '-A');
43
+ git('commit', '-q', '-m', 'base');
44
+ write(AFTER);
45
+ });
46
+ afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
47
+ describe('review task and acknowledgements', () => {
48
+ it('asks about the risky changed function only, with questions from its signals', () => {
49
+ const task = buildReviewTask(repo, diffFromGit(repo));
50
+ expect(task.items.map(i => i.function)).toEqual(['syncOrders']);
51
+ expect(task.items[0].questions[0]).toMatch(/concurrent calls/);
52
+ expect(task.items[0].questions.at(-1)).toContain('`syncOrders`');
53
+ });
54
+ it('drops an acknowledged function from the task until its code changes again', () => {
55
+ const ack = acknowledgeReview(repo, { file: 'sync.ts', function: 'syncOrders', verdict: 'no_issue', note: 'upsert key is the unique order id; callers retry safely' });
56
+ expect(ack.ok).toBe(true);
57
+ expect(buildReviewTask(repo, diffFromGit(repo))).toMatchObject({ items: [], alreadyReviewed: 1 });
58
+ write(AFTER.replace('return rows;', 'return rows.slice(1);'));
59
+ expect(buildReviewTask(repo, diffFromGit(repo)).items.map(i => i.function)).toEqual(['syncOrders']);
60
+ });
61
+ it('refuses an acknowledgement without a real note, a known verdict, or a real function', () => {
62
+ expect(acknowledgeReview(repo, { file: 'sync.ts', function: 'syncOrders', verdict: 'no_issue', note: 'ok' })).toMatchObject({ ok: false });
63
+ expect(acknowledgeReview(repo, { file: 'sync.ts', function: 'syncOrders', verdict: 'lgtm', note: 'looked at it carefully' })).toMatchObject({ ok: false });
64
+ expect(acknowledgeReview(repo, { file: 'sync.ts', function: 'nope', verdict: 'fixed', note: 'looked at it carefully' })).toMatchObject({ ok: false });
65
+ expect(readLedger(repo)).toEqual([]);
66
+ });
67
+ it('exports hashes and verdicts only, and a committed export clears the task too', () => {
68
+ acknowledgeReview(repo, { file: 'sync.ts', function: 'syncOrders', verdict: 'fixed', note: 'made the upsert key explicit' });
69
+ exportReviewed(repo);
70
+ const exported = JSON.parse(fs.readFileSync(path.join(repo, REVIEWED_FILE), 'utf8'));
71
+ expect(Object.keys(exported.entries[0]).sort()).toEqual(['file', 'function', 'hash', 'reviewer', 'verdict']);
72
+ fs.rmSync(path.join(repo, '.rigour', 'review-ledger.jsonl'));
73
+ expect(buildReviewTask(repo, diffFromGit(repo)).items).toEqual([]);
74
+ });
75
+ it('tells a stopping agent which functions still need review, and how', () => {
76
+ const message = reviewAckMessage(buildReviewTask(repo, diffFromGit(repo)).items, 1);
77
+ expect(message).toContain('sync.ts:1 `syncOrders`');
78
+ expect(message).toContain('rigour_review_ack');
79
+ });
80
+ });
@@ -11,12 +11,19 @@ export interface ReviewInput {
11
11
  /** Run changed functions before and after the change (needs deep, on the max or a cloud tier). */
12
12
  diffTests?: boolean;
13
13
  /** Deep analysis; `focusLines` and `removedLines` are filled from the diff. */
14
- deep?: Omit<DeepOptions, 'focusLines' | 'removedLines'>;
14
+ deep?: Omit<DeepOptions, 'focusLines' | 'removedLines' | 'diff'>;
15
15
  }
16
16
  export interface ReviewResult {
17
17
  status: 'PASS' | 'FAIL' | 'ERROR';
18
18
  findings: Failure[];
19
19
  fileFindings: Failure[];
20
+ /** Deep findings elsewhere in a changed file: shown, never blocking. */
21
+ contextFindings: Failure[];
22
+ /** Heuristic findings on changed lines: returned on request, never deciding the verdict (quiet.ts). */
23
+ advisory: Failure[];
24
+ /** Findings a person dismissed as not a bug, and the gates they came from. */
25
+ dismissed: number;
26
+ dismissedByGate: Record<string, number>;
20
27
  unlocated: number;
21
28
  excludedOutsideChangedLines: number;
22
29
  changedLines: Record<string, Set<number>>;
@@ -31,6 +38,10 @@ export interface ReviewFinding {
31
38
  message: string;
32
39
  file: string;
33
40
  line: number | null;
41
+ /** For a finding inside a changed function but off the changed lines: the changed line to post it on. */
42
+ anchor_line?: number;
43
+ /** Stable across runs: `rigour dismiss <key>` silences this finding for good. */
44
+ key: string;
34
45
  suggestion?: string;
35
46
  }
36
47
  export declare function reviewChange(input: ReviewInput): Promise<ReviewResult>;
@@ -11,25 +11,33 @@ import { changedLinesByFile, parseDiff, removedByFile } from '../utils/diff.js';
11
11
  import { normalizeScopePatterns } from '../utils/scope.js';
12
12
  import { deepAnalysisError } from '../utils/deep-status.js';
13
13
  import { splitByChangedLines } from './changed-lines.js';
14
+ import { changedFunctionSpans } from './changed-function-spans.js';
15
+ import { withoutGenerated } from './generated-files.js';
16
+ import { findingKey, quietSplit } from './quiet.js';
14
17
  import { diffFromGit } from './git-diff.js';
15
18
  import { diffTestFailures } from './diff-test-findings.js';
16
19
  export async function reviewChange(input) {
17
20
  const diff = input.diff ?? diffFromGit(input.cwd, input.source);
18
- const changedLines = parseDiff(diff);
21
+ const changedLines = withoutGenerated(input.cwd, parseDiff(diff));
19
22
  const targets = input.files?.length ? input.files : Object.keys(changedLines);
20
23
  if (targets.length === 0) {
21
- return { status: 'PASS', findings: [], fileFindings: [], unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null };
24
+ return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null };
22
25
  }
23
- const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff) } : undefined;
26
+ const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
24
27
  const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
25
28
  if (input.diffTests && deep)
26
29
  report.failures.push(...await diffTestFailures(input.cwd, input.source, deep));
27
- const split = splitByChangedLines(report.failures, changedLines);
30
+ const split = splitByChangedLines(report.failures, changedLines, deep ? changedFunctionSpans(input.cwd, changedLines) : {});
28
31
  const deepError = deepAnalysisError(report);
32
+ const quiet = quietSplit(input.cwd, split.findings, input.config.review?.include_heuristics);
29
33
  return {
30
- status: deepError ? 'ERROR' : split.findings.length > 0 ? 'FAIL' : 'PASS',
31
- findings: split.findings,
34
+ status: deepError ? 'ERROR' : quiet.speaking.length > 0 ? 'FAIL' : 'PASS',
35
+ findings: quiet.speaking,
36
+ advisory: quiet.advisory,
37
+ dismissed: quiet.dismissed,
38
+ dismissedByGate: quiet.dismissedByGate,
32
39
  fileFindings: split.fileFindings,
40
+ contextFindings: split.contextFindings,
33
41
  unlocated: split.unlocated,
34
42
  excludedOutsideChangedLines: split.outside,
35
43
  changedLines,
@@ -45,8 +53,10 @@ export function toReviewFinding(failure) {
45
53
  severity: failure.severity || 'medium',
46
54
  provenance: failure.provenance || 'traditional',
47
55
  message: failure.details,
56
+ key: findingKey(failure),
48
57
  file: failure.files?.[0] || '',
49
58
  line: failure.line ?? null,
59
+ ...(failure.anchorLine !== undefined ? { anchor_line: failure.anchorLine } : {}),
50
60
  ...(failure.hint ? { suggestion: failure.hint } : {}),
51
61
  };
52
62
  }
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
6
  import { ConfigSchema } from '../types/index.js';
7
7
  import { parseDiff } from '../utils/diff.js';
8
8
  import { splitByChangedLines } from './changed-lines.js';
9
+ import { anchorInChangedFunction, changedFunctionSpans } from './changed-function-spans.js';
9
10
  import { diffFromGit } from './git-diff.js';
10
11
  import { reviewChange, toReviewFinding } from './review.js';
11
12
  const LEAKY = [
@@ -26,6 +27,30 @@ describe('splitByChangedLines', () => {
26
27
  expect(split.unlocated).toBe(1);
27
28
  expect(split.outside).toBe(2);
28
29
  });
30
+ it('keeps a deep finding inside a changed function, anchored on the nearest changed line', () => {
31
+ const deep = (line) => ({ ...failure('a.ts', line), provenance: 'deep-analysis' });
32
+ const changed = { 'a.ts': new Set([12, 14]) };
33
+ const spans = { 'a.ts': [[10, 20]] };
34
+ const split = splitByChangedLines([deep(11), deep(30), failure('a.ts', 11)], changed, spans);
35
+ expect(split.findings).toEqual([expect.objectContaining({ line: 11, anchorLine: 12 })]);
36
+ expect(split.contextFindings.map(f => f.line)).toEqual([30]); // elsewhere in a changed file: shown, not blocking
37
+ expect(split.outside).toBe(1); // a rule finding on an unchanged line is pre-existing, as before
38
+ });
39
+ });
40
+ describe('changedFunctionSpans', () => {
41
+ it('spans the outermost function around changed lines, and anchors to the nearest changed line in it', () => {
42
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spans-'));
43
+ try {
44
+ fs.writeFileSync(path.join(dir, 'a.ts'), 'const x = 1;\nexport function f(a: number) {\n if (a > 0) {\n return a;\n }\n return 0;\n}\n');
45
+ const spans = changedFunctionSpans(dir, { 'a.ts': new Set([4]), 'missing.ts': new Set([1]), 'notes.md': new Set([1]) });
46
+ expect(spans).toEqual({ 'a.ts': [[2, 7]] });
47
+ expect(anchorInChangedFunction(6, new Set([4]), spans['a.ts'])).toBe(4);
48
+ expect(anchorInChangedFunction(1, new Set([4]), spans['a.ts'])).toBeUndefined();
49
+ }
50
+ finally {
51
+ fs.rmSync(dir, { recursive: true, force: true });
52
+ }
53
+ });
29
54
  });
30
55
  describe('git-backed review', () => {
31
56
  let repo;
@@ -0,0 +1,25 @@
1
+ export interface ReviewComment {
2
+ id: string;
3
+ prNumber: number;
4
+ path: string;
5
+ /** Lines the comment covers on its commit (start ≤ end). */
6
+ start: number;
7
+ end: number;
8
+ /** The commit the comment was written on. */
9
+ commit: string;
10
+ body: string;
11
+ author: string;
12
+ }
13
+ export interface MergedPr {
14
+ number: number;
15
+ mergeSha: string;
16
+ mergedAt: string;
17
+ comments: ReviewComment[];
18
+ }
19
+ /** git in one repository; tests substitute their own. */
20
+ export type Git = (args: string[]) => string;
21
+ export declare function gitIn(cwd: string): Git;
22
+ /** Acted-on comments of a merged PR; comments whose commit or file cannot be found are skipped. */
23
+ export declare function actedOn(git: Git, pr: MergedPr): ReviewComment[];
24
+ /** Old-side line ranges that changed between two commits in one file. */
25
+ export declare function changedHunks(git: Git, from: string, to: string, file: string): Array<[number, number]>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Which review comments a developer acted on: the lines a comment points at
3
+ * changed between the commit it was written on and the merged result.
4
+ *
5
+ * Acted-on comments are review work the team valued, whoever wrote them (a
6
+ * person, a review bot, Rigour). A comment nobody acted on is not evidence of
7
+ * anything and never becomes a lesson.
8
+ */
9
+ import { execFileSync } from 'child_process';
10
+ export function gitIn(cwd) {
11
+ return (args) => execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
12
+ }
13
+ /** Acted-on comments of a merged PR; comments whose commit or file cannot be found are skipped. */
14
+ export function actedOn(git, pr) {
15
+ ensureCommits(git, pr);
16
+ return pr.comments.filter(comment => {
17
+ if (!hasFile(git, comment.commit, comment.path) || !hasFile(git, pr.mergeSha, comment.path))
18
+ return false;
19
+ return changedHunks(git, comment.commit, pr.mergeSha, comment.path).some(([s, e]) => s <= comment.end && comment.start <= e);
20
+ });
21
+ }
22
+ /** Old-side line ranges that changed between two commits in one file. */
23
+ export function changedHunks(git, from, to, file) {
24
+ let diff = '';
25
+ try {
26
+ diff = git(['diff', '--no-color', '-U0', from, to, '--', file]);
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ const hunks = [];
32
+ for (const match of diff.matchAll(/^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@/gm)) {
33
+ const start = Number(match[1]);
34
+ const count = match[2] === undefined ? 1 : Number(match[2]);
35
+ // A pure insertion (count 0) sits after `start`; it touches that line.
36
+ hunks.push([start, start + Math.max(count, 1) - 1]);
37
+ }
38
+ return hunks;
39
+ }
40
+ /** Commits a comment was written on may have been force-pushed away; GitHub still serves the PR ref. */
41
+ function ensureCommits(git, pr) {
42
+ const missing = [...new Set(pr.comments.map(c => c.commit))].filter(sha => !hasCommit(git, sha));
43
+ if (missing.length === 0)
44
+ return;
45
+ try {
46
+ git(['fetch', '-q', 'origin', `pull/${pr.number}/head`]);
47
+ }
48
+ catch {
49
+ // Offline or no remote: the comments on missing commits are skipped.
50
+ }
51
+ }
52
+ function hasCommit(git, sha) {
53
+ try {
54
+ git(['cat-file', '-e', `${sha}^{commit}`]);
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ function hasFile(git, sha, file) {
62
+ try {
63
+ git(['cat-file', '-e', `${sha}:${file}`]);
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `rigour learn --from-reviews`: turn a repository's acted-on review comments
3
+ * into review lessons. Reads GitHub (merged PRs and their review comments) and
4
+ * the local clone; writes only .rigour/review-lessons.json.
5
+ */
6
+ import { type Git } from './acted-on.js';
7
+ type Fetch = (url: string, init?: any) => Promise<{
8
+ ok: boolean;
9
+ status: number;
10
+ json(): Promise<any>;
11
+ }>;
12
+ export interface LearnFromReviewsOptions {
13
+ token: string;
14
+ /** owner/name */
15
+ repo: string;
16
+ /** Only PRs merged on or after this ISO date. */
17
+ since?: string;
18
+ /** Only PRs merged before this ISO date (a time split for measuring). */
19
+ until?: string;
20
+ limit?: number;
21
+ apiUrl?: string;
22
+ fetch?: Fetch;
23
+ git?: Git;
24
+ }
25
+ export interface LearnFromReviewsResult {
26
+ prs: number;
27
+ comments: number;
28
+ actedOn: number;
29
+ added: number;
30
+ verified: number;
31
+ total: number;
32
+ }
33
+ export declare function learnFromReviews(cwd: string, options: LearnFromReviewsOptions): Promise<LearnFromReviewsResult>;
34
+ export {};
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `rigour learn --from-reviews`: turn a repository's acted-on review comments
3
+ * into review lessons. Reads GitHub (merged PRs and their review comments) and
4
+ * the local clone; writes only .rigour/review-lessons.json.
5
+ */
6
+ import { actedOn, gitIn } from './acted-on.js';
7
+ import { lessonFromComment, mergeLessons, readLessons, writeLessons } from './lessons.js';
8
+ export async function learnFromReviews(cwd, options) {
9
+ const git = options.git ?? gitIn(cwd);
10
+ const prs = await mergedPrs(options);
11
+ const lessons = [];
12
+ let comments = 0;
13
+ let acted = 0;
14
+ for (const pr of prs) {
15
+ comments += pr.comments.length;
16
+ for (const comment of actedOn(git, pr)) {
17
+ acted++;
18
+ const lesson = lessonFromComment(git, comment);
19
+ if (lesson)
20
+ lessons.push(lesson);
21
+ }
22
+ }
23
+ const merged = mergeLessons(readLessons(cwd), lessons);
24
+ writeLessons(cwd, merged.lessons);
25
+ return { prs: prs.length, comments, actedOn: acted, added: merged.added, verified: merged.verified, total: merged.lessons.length };
26
+ }
27
+ async function mergedPrs(options) {
28
+ const fetchImpl = options.fetch ?? fetch;
29
+ const base = `${(options.apiUrl || 'https://api.github.com').replace(/\/$/, '')}/repos/${options.repo}`;
30
+ const get = async (url) => {
31
+ const response = await fetchImpl(url, { headers: { Authorization: `Bearer ${options.token}`, Accept: 'application/vnd.github+json' } });
32
+ if (!response.ok)
33
+ throw new Error(`GitHub ${url.replace(base, '')}: HTTP ${response.status}`);
34
+ return response.json();
35
+ };
36
+ const limit = options.limit ?? 100;
37
+ const prs = [];
38
+ for (let page = 1; prs.length < limit && page <= 20; page++) {
39
+ const batch = await get(`${base}/pulls?state=closed&sort=updated&direction=desc&per_page=100&page=${page}`);
40
+ if (batch.length === 0)
41
+ break;
42
+ for (const pr of batch) {
43
+ if (!pr.merged_at || !pr.merge_commit_sha)
44
+ continue;
45
+ if (options.since && pr.merged_at < options.since)
46
+ continue;
47
+ if (options.until && pr.merged_at >= options.until)
48
+ continue;
49
+ const raw = await get(`${base}/pulls/${pr.number}/comments?per_page=100`);
50
+ prs.push({ number: pr.number, mergeSha: pr.merge_commit_sha, mergedAt: pr.merged_at, comments: raw.flatMap(c => toComment(pr.number, c)) });
51
+ if (prs.length >= limit)
52
+ break;
53
+ }
54
+ }
55
+ return prs;
56
+ }
57
+ /** A review comment where it was written: its original commit and lines. */
58
+ function toComment(prNumber, c) {
59
+ const end = c.original_line ?? c.line;
60
+ const commit = c.original_commit_id ?? c.commit_id;
61
+ if (!c.path || !end || !commit || c.in_reply_to_id)
62
+ return [];
63
+ const start = c.original_start_line ?? c.start_line ?? end;
64
+ return [{ id: String(c.id), prNumber, path: c.path, start: Math.min(start, end), end, commit, body: String(c.body ?? ''), author: String(c.user?.login ?? '') }];
65
+ }