@rigour-labs/core 6.5.2 → 6.5.3

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 (110) hide show
  1. package/README.md +1 -1
  2. package/dist/context/automatic-index.d.ts +5 -0
  3. package/dist/context/automatic-index.js +24 -3
  4. package/dist/context/index-status.test.d.ts +1 -0
  5. package/dist/context/index-status.test.js +26 -0
  6. package/dist/gates/dedupe-failures.test.d.ts +1 -0
  7. package/dist/gates/dedupe-failures.test.js +12 -0
  8. package/dist/gates/deprecated-dependencies.d.ts +27 -0
  9. package/dist/gates/deprecated-dependencies.js +104 -0
  10. package/dist/gates/deprecated-dependencies.test.d.ts +1 -0
  11. package/dist/gates/deprecated-dependencies.test.js +55 -0
  12. package/dist/gates/runner.d.ts +7 -1
  13. package/dist/gates/runner.js +25 -11
  14. package/dist/gates/security-patterns-data.js +2 -1
  15. package/dist/gates/security-patterns-owasp.test.js +15 -0
  16. package/dist/gates/semantic-bugs.js +1 -1
  17. package/dist/gates/unindexed-reads/coverage.d.ts +15 -0
  18. package/dist/gates/unindexed-reads/coverage.js +24 -0
  19. package/dist/gates/unindexed-reads/index.d.ts +20 -0
  20. package/dist/gates/unindexed-reads/index.js +98 -0
  21. package/dist/gates/unindexed-reads/queries.d.ts +17 -0
  22. package/dist/gates/unindexed-reads/queries.js +147 -0
  23. package/dist/gates/unindexed-reads/queries.test.d.ts +1 -0
  24. package/dist/gates/unindexed-reads/queries.test.js +54 -0
  25. package/dist/gates/unindexed-reads/schema.d.ts +38 -0
  26. package/dist/gates/unindexed-reads/schema.js +256 -0
  27. package/dist/gates/unindexed-reads/schema.test.d.ts +1 -0
  28. package/dist/gates/unindexed-reads/schema.test.js +81 -0
  29. package/dist/gates/unindexed-reads/sql.d.ts +21 -0
  30. package/dist/gates/unindexed-reads/sql.js +131 -0
  31. package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +1 -0
  32. package/dist/gates/unindexed-reads/unindexed-reads.test.js +90 -0
  33. package/dist/index.d.ts +4 -1
  34. package/dist/index.js +4 -1
  35. package/dist/inference/brain-placeholder.test.d.ts +1 -0
  36. package/dist/inference/brain-placeholder.test.js +28 -0
  37. package/dist/inference/llama-engine-checksum.test.d.ts +1 -0
  38. package/dist/inference/llama-engine-checksum.test.js +27 -0
  39. package/dist/inference/llama-engine.d.ts +4 -1
  40. package/dist/inference/llama-engine.js +15 -8
  41. package/dist/memory/recall.d.ts +37 -0
  42. package/dist/memory/recall.js +93 -0
  43. package/dist/memory/recall.test.d.ts +1 -0
  44. package/dist/memory/recall.test.js +36 -0
  45. package/dist/pattern-index/assess.d.ts +16 -0
  46. package/dist/pattern-index/assess.js +37 -0
  47. package/dist/pattern-index/embeddings.d.ts +19 -0
  48. package/dist/pattern-index/embeddings.js +16 -0
  49. package/dist/pattern-index/index.d.ts +3 -2
  50. package/dist/pattern-index/index.js +4 -2
  51. package/dist/pattern-index/indexer.js +15 -7
  52. package/dist/pattern-index/matcher.d.ts +6 -0
  53. package/dist/pattern-index/matcher.js +13 -3
  54. package/dist/pattern-index/pattern-reuse.test.d.ts +1 -0
  55. package/dist/pattern-index/pattern-reuse.test.js +76 -0
  56. package/dist/pattern-index/staleness.d.ts +2 -15
  57. package/dist/pattern-index/staleness.js +1 -78
  58. package/dist/pattern-index/types.d.ts +2 -0
  59. package/dist/review/agent-fixes.d.ts +4 -0
  60. package/dist/review/agent-fixes.js +10 -7
  61. package/dist/review/agent-fixes.test.js +4 -4
  62. package/dist/review/changed-lines.d.ts +2 -1
  63. package/dist/review/changed-lines.js +12 -2
  64. package/dist/review/check-outcomes.d.ts +31 -0
  65. package/dist/review/check-outcomes.js +81 -0
  66. package/dist/review/check-outcomes.test.d.ts +1 -0
  67. package/dist/review/check-outcomes.test.js +56 -0
  68. package/dist/review/migration-order.d.ts +5 -0
  69. package/dist/review/migration-order.js +70 -0
  70. package/dist/review/migration-order.test.d.ts +1 -0
  71. package/dist/review/migration-order.test.js +62 -0
  72. package/dist/review/quiet.d.ts +2 -0
  73. package/dist/review/quiet.js +12 -3
  74. package/dist/review/review.d.ts +2 -0
  75. package/dist/review/review.js +7 -2
  76. package/dist/review/review.test.js +11 -0
  77. package/dist/semantic/ast.d.ts +1 -0
  78. package/dist/semantic/ast.js +4 -0
  79. package/dist/semantic/learn/compile.js +3 -1
  80. package/dist/semantic/learn/learn.js +40 -5
  81. package/dist/semantic/types.d.ts +2 -0
  82. package/dist/services/context-scope-summary.test.d.ts +1 -0
  83. package/dist/services/context-scope-summary.test.js +17 -0
  84. package/dist/services/context-telemetry-service.js +2 -1
  85. package/dist/storage/context-telemetry.js +33 -19
  86. package/dist/storage/db.js +52 -1
  87. package/dist/storage/findings.js +1 -1
  88. package/dist/storage/fix-lessons.d.ts +13 -0
  89. package/dist/storage/fix-lessons.js +84 -0
  90. package/dist/storage/fix-lessons.test.d.ts +1 -0
  91. package/dist/storage/fix-lessons.test.js +61 -0
  92. package/dist/storage/index.d.ts +2 -1
  93. package/dist/storage/index.js +2 -1
  94. package/dist/storage/lessons.d.ts +16 -2
  95. package/dist/storage/lessons.js +27 -61
  96. package/dist/storage/lessons.test.js +1 -15
  97. package/dist/storage/local-memory.js +39 -5
  98. package/dist/storage/local-memory.test.d.ts +1 -0
  99. package/dist/storage/local-memory.test.js +55 -0
  100. package/dist/storage/patterns.d.ts +1 -1
  101. package/dist/storage/patterns.js +1 -1
  102. package/dist/storage/share-memory.test.d.ts +1 -0
  103. package/dist/storage/share-memory.test.js +34 -0
  104. package/dist/storage/team-vector-store.js +2 -1
  105. package/dist/storage/telemetry-scope.test.d.ts +1 -0
  106. package/dist/storage/telemetry-scope.test.js +38 -0
  107. package/dist/templates/universal-config.js +11 -0
  108. package/dist/types/index.d.ts +132 -0
  109. package/dist/types/index.js +15 -0
  110. package/package.json +7 -7
@@ -0,0 +1,81 @@
1
+ /**
2
+ * How this repository treats each check: findings fixed versus dismissed.
3
+ *
4
+ * Every check gets a Beta(fixed + 1, dismissed + 1) posterior, so its mean is
5
+ * the chance that a finding from it is worth acting on here, and it starts at
6
+ * an honest 0.5 with no evidence. Advisory checks the team keeps dismissing are
7
+ * muted once the evidence is clear; proven checks are never muted, because
8
+ * quiet must not cost correctness.
9
+ *
10
+ * Local files under .rigour/: check-outcomes.json (the counts) and
11
+ * reported-findings.json (which check each recent finding key came from, so a
12
+ * dismissal by key can be attributed).
13
+ */
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+ const OUTCOMES_FILE = path.join('.rigour', 'check-outcomes.json');
17
+ const REPORTED_FILE = path.join('.rigour', 'reported-findings.json');
18
+ const MAX_REPORTED = 2000;
19
+ /** Outcomes a check needs before it can be muted, and the posterior mean below which it is. */
20
+ export const MUTE_MIN_OUTCOMES = 5;
21
+ export const MUTE_BELOW = 0.25;
22
+ /** A check is a gate and the rule within it: `ast` alone would mute complexity and parameter count together. */
23
+ export function checkId(finding) {
24
+ const gate = finding.id ?? finding.rule ?? 'unknown';
25
+ return finding.title?.trim() ? `${gate}: ${finding.title.trim()}` : gate;
26
+ }
27
+ export function precisionOf(outcome) {
28
+ return (outcome.fixed + 1) / (outcome.fixed + outcome.dismissed + 2);
29
+ }
30
+ export function isMuted(outcome) {
31
+ if (!outcome)
32
+ return false;
33
+ return outcome.fixed + outcome.dismissed >= MUTE_MIN_OUTCOMES && precisionOf(outcome) < MUTE_BELOW;
34
+ }
35
+ export function readOutcomes(cwd) {
36
+ return readJson(path.join(cwd, OUTCOMES_FILE)) ?? {};
37
+ }
38
+ export function recordOutcome(cwd, check, kind) {
39
+ const outcomes = readOutcomes(cwd);
40
+ const current = outcomes[check] ?? { fixed: 0, dismissed: 0 };
41
+ outcomes[check] = { ...current, [kind]: current[kind] + 1 };
42
+ writeJson(path.join(cwd, OUTCOMES_FILE), outcomes);
43
+ }
44
+ /** Every check with an outcome, most dismissed first. */
45
+ export function checkPrecisions(cwd) {
46
+ return Object.entries(readOutcomes(cwd))
47
+ .map(([check, outcome]) => ({ check, ...outcome, precision: precisionOf(outcome), muted: isMuted(outcome) }))
48
+ .sort((a, b) => a.precision - b.precision);
49
+ }
50
+ /** Remember which check each reported finding key came from (most recent kept). */
51
+ export function rememberReported(cwd, entries) {
52
+ if (entries.length === 0)
53
+ return;
54
+ const reported = readJson(path.join(cwd, REPORTED_FILE)) ?? {};
55
+ for (const { key, check } of entries) {
56
+ delete reported[key];
57
+ reported[key] = check;
58
+ }
59
+ const kept = Object.entries(reported).slice(-MAX_REPORTED);
60
+ writeJson(path.join(cwd, REPORTED_FILE), Object.fromEntries(kept));
61
+ }
62
+ export function reportedCheck(cwd, key) {
63
+ return readJson(path.join(cwd, REPORTED_FILE))?.[key];
64
+ }
65
+ function readJson(file) {
66
+ try {
67
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ function writeJson(file, value) {
74
+ try {
75
+ fs.mkdirSync(path.dirname(file), { recursive: true });
76
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
77
+ }
78
+ catch {
79
+ // Learning is best-effort; a read-only checkout still reviews.
80
+ }
81
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
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 { checkId, checkPrecisions, isMuted, precisionOf, readOutcomes, recordOutcome, rememberReported } from './check-outcomes.js';
6
+ import { dismissFinding, findingKey, quietSplit } from './quiet.js';
7
+ import { recordReviewOutcome } from './agent-fixes.js';
8
+ let cwd;
9
+ beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'outcomes-')); });
10
+ afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
11
+ const finding = (id, title, file = 'src/a.ts', details = `${title} here`) => ({ id, title, details, severity: 'low', provenance: 'traditional', files: [file], line: 1 });
12
+ describe('check precision', () => {
13
+ it('starts at an honest 0.5 and follows the evidence', () => {
14
+ expect(precisionOf({ fixed: 0, dismissed: 0 })).toBe(0.5);
15
+ expect(precisionOf({ fixed: 0, dismissed: 8 })).toBe(0.1);
16
+ expect(precisionOf({ fixed: 8, dismissed: 0 })).toBe(0.9);
17
+ });
18
+ it('mutes only on clear evidence: enough outcomes and a low posterior', () => {
19
+ expect(isMuted({ fixed: 0, dismissed: 3 })).toBe(false);
20
+ expect(isMuted({ fixed: 0, dismissed: 5 })).toBe(true);
21
+ expect(isMuted({ fixed: 2, dismissed: 4 })).toBe(false);
22
+ });
23
+ it('names a check by gate and rule, so one rule in a gate never mutes its neighbours', () => {
24
+ expect(checkId(finding('ast', 'Function too complex'))).toBe('ast: Function too complex');
25
+ expect(checkId({ rule: 'ast' })).toBe('ast');
26
+ });
27
+ });
28
+ describe('outcomes from real decisions', () => {
29
+ it('attributes a dismissal by key to the check review reported it from', () => {
30
+ const f = finding('ast', 'Too many parameters');
31
+ rememberReported(cwd, [{ key: findingKey(f), check: checkId(f) }]);
32
+ expect(dismissFinding(cwd, findingKey(f), 'builder needs these')).toBe(true);
33
+ expect(dismissFinding(cwd, findingKey(f), 'again')).toBe(true);
34
+ expect(readOutcomes(cwd)).toEqual({ 'ast: Too many parameters': { fixed: 0, dismissed: 1 } });
35
+ });
36
+ it('counts a fix an agent made against the check that reported it', () => {
37
+ fs.mkdirSync(path.join(cwd, 'src'));
38
+ fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'before\n');
39
+ recordReviewOutcome(cwd, [finding('semantic-bugs', 'Unbounded read')], ['src/a.ts']);
40
+ fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'after\n');
41
+ recordReviewOutcome(cwd, [], ['src/a.ts']);
42
+ expect(checkPrecisions(cwd)).toEqual([expect.objectContaining({ check: 'semantic-bugs: Unbounded read', fixed: 1, dismissed: 0 })]);
43
+ });
44
+ });
45
+ describe('quietSplit with precision', () => {
46
+ it('mutes an advisory check the team keeps dismissing, but never a proven one', () => {
47
+ for (let i = 0; i < 5; i++) {
48
+ recordOutcome(cwd, 'ast: Too many parameters', 'dismissed');
49
+ recordOutcome(cwd, 'security-patterns: Hard-coded secret', 'dismissed');
50
+ }
51
+ const split = quietSplit(cwd, [finding('ast', 'Too many parameters'), finding('ast', 'Function too complex'), finding('security-patterns', 'Hard-coded secret')]);
52
+ expect(split.muted).toBe(1);
53
+ expect(split.advisory.map(f => f.title)).toEqual(['Function too complex']);
54
+ expect(split.speaking.map(f => f.title)).toEqual(['Hard-coded secret']);
55
+ });
56
+ });
@@ -0,0 +1,5 @@
1
+ import type { Config, Failure } from '../types/index.js';
2
+ import type { DiffSource } from './git-diff.js';
3
+ export declare function migrationOrderFailures(cwd: string, diff: string, source: DiffSource | undefined, config: Config): Failure[];
4
+ /** Files a unified diff creates (`--- /dev/null`). */
5
+ export declare function addedFiles(diff: string): string[];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Migration out of order: a migration the change adds that sorts before the
3
+ * newest migration already on the base (the base ref, or HEAD for
4
+ * uncommitted work).
5
+ *
6
+ * The Supabase CLI applies migrations in filename order and `db push` stops
7
+ * at a local migration older than the last one applied, so a branch that
8
+ * picked an earlier timestamp than main's newest fails or is skipped on any
9
+ * database main already reached. Only directories listed in
10
+ * `gates.migration_order.dirs` are checked (Supabase's by default): runners
11
+ * that track each migration separately, such as Rails, apply older ones fine.
12
+ */
13
+ import { spawnSync } from 'child_process';
14
+ import path from 'path';
15
+ import micromatch from 'micromatch';
16
+ const GIT_TIMEOUT_MS = 10_000;
17
+ export function migrationOrderFailures(cwd, diff, source, config) {
18
+ const settings = config.gates.migration_order;
19
+ if (!settings?.enabled)
20
+ return [];
21
+ const base = source?.mode === 'base' ? source.base : 'HEAD';
22
+ const byDirectory = new Map();
23
+ for (const file of addedFiles(diff)) {
24
+ const directory = path.posix.dirname(file);
25
+ if (!file.endsWith('.sql') || !micromatch.isMatch(directory, settings.dirs))
26
+ continue;
27
+ byDirectory.set(directory, [...(byDirectory.get(directory) ?? []), file]);
28
+ }
29
+ const failures = [];
30
+ for (const [directory, added] of byDirectory) {
31
+ const newest = newestOnBase(cwd, base, directory);
32
+ if (!newest)
33
+ continue;
34
+ for (const file of added) {
35
+ if (path.posix.basename(file) < newest)
36
+ failures.push(outOfOrder(file, newest, base));
37
+ }
38
+ }
39
+ return failures;
40
+ }
41
+ /** Files a unified diff creates (`--- /dev/null`). */
42
+ export function addedFiles(diff) {
43
+ const added = [];
44
+ const lines = diff.split('\n');
45
+ for (let i = 0; i < lines.length - 1; i++) {
46
+ if (lines[i] === '--- /dev/null' && lines[i + 1].startsWith('+++ b/'))
47
+ added.push(lines[i + 1].slice('+++ b/'.length));
48
+ }
49
+ return added;
50
+ }
51
+ /** The last .sql file name in `directory` at `base`, or undefined when git cannot say. */
52
+ function newestOnBase(cwd, base, directory) {
53
+ const result = spawnSync('git', ['ls-tree', '--name-only', base, `${directory}/`], { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
54
+ if (result.status !== 0)
55
+ return undefined;
56
+ const names = result.stdout.split('\n').map(line => path.posix.basename(line.trim())).filter(name => name.endsWith('.sql'));
57
+ return names.sort().at(-1);
58
+ }
59
+ function outOfOrder(file, newest, base) {
60
+ return {
61
+ id: 'migration-order',
62
+ title: 'Migration out of order',
63
+ details: `\`${path.posix.basename(file)}\` sorts before \`${newest}\`, the newest migration on \`${base}\`. Migrations apply in filename order, so a database that already ran \`${newest}\` stops at or skips this one.`,
64
+ severity: 'medium',
65
+ provenance: 'traditional',
66
+ files: [file],
67
+ line: 1,
68
+ hint: `Rename it with a timestamp after \`${newest}\`.`,
69
+ };
70
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
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 { ConfigSchema } from '../types/index.js';
7
+ import { diffFromGit } from './git-diff.js';
8
+ import { addedFiles, migrationOrderFailures } from './migration-order.js';
9
+ let repo;
10
+ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
11
+ const write = (rel, body = 'select 1;\n') => {
12
+ fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
13
+ fs.writeFileSync(path.join(repo, rel), body);
14
+ };
15
+ const commit = (message) => { git('add', '-A'); git('commit', '-qm', message); };
16
+ const config = (enabled = true) => ConfigSchema.parse({ version: 1, gates: { migration_order: { enabled } } });
17
+ const DIR = 'supabase/migrations';
18
+ beforeEach(() => {
19
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'migration-order-'));
20
+ git('init', '-q', '-b', 'main');
21
+ git('config', 'user.email', 't@example.com');
22
+ git('config', 'user.name', 't');
23
+ git('config', 'commit.gpgsign', 'false');
24
+ write(`${DIR}/20260101000000_init.sql`);
25
+ commit('init');
26
+ });
27
+ afterEach(() => fs.rmSync(repo, { recursive: true, force: true }));
28
+ describe('migrationOrderFailures', () => {
29
+ it('flags a branch migration dated before one main merged since', () => {
30
+ git('checkout', '-q', '-b', 'feature');
31
+ write(`${DIR}/20260107000000_feature.sql`);
32
+ commit('feature');
33
+ git('checkout', '-q', 'main');
34
+ write(`${DIR}/20260110000000_main.sql`);
35
+ commit('main moved');
36
+ git('checkout', '-q', 'feature');
37
+ const source = { mode: 'base', base: 'main' };
38
+ const failures = migrationOrderFailures(repo, diffFromGit(repo, source), source, config());
39
+ expect(failures).toEqual([expect.objectContaining({
40
+ id: 'migration-order', files: [`${DIR}/20260107000000_feature.sql`], line: 1,
41
+ })]);
42
+ expect(failures[0].details).toContain('20260110000000_main.sql');
43
+ });
44
+ it('passes a migration dated after the newest on the base, and uncommitted work against HEAD', () => {
45
+ write(`${DIR}/20260201000000_next.sql`);
46
+ expect(migrationOrderFailures(repo, diffFromGit(repo), { mode: 'working' }, config())).toEqual([]);
47
+ write(`${DIR}/20251231000000_old.sql`);
48
+ expect(migrationOrderFailures(repo, diffFromGit(repo), { mode: 'working' }, config()).map(f => f.files)).toEqual([[`${DIR}/20251231000000_old.sql`]]);
49
+ });
50
+ it('checks only the configured directories, and only when enabled', () => {
51
+ write('db/migrations/0001_old.sql');
52
+ write(`${DIR}/20251231000000_old.sql`);
53
+ expect(migrationOrderFailures(repo, diffFromGit(repo), { mode: 'working' }, config(false))).toEqual([]);
54
+ expect(migrationOrderFailures(repo, diffFromGit(repo), { mode: 'working' }, config()).map(f => f.files[0])).toEqual([`${DIR}/20251231000000_old.sql`]);
55
+ });
56
+ });
57
+ describe('addedFiles', () => {
58
+ it('lists only files the diff creates', () => {
59
+ const diff = ['--- /dev/null', '+++ b/new.sql', '@@ -0,0 +1 @@', '+x', '--- a/old.sql', '+++ b/old.sql'].join('\n');
60
+ expect(addedFiles(diff)).toEqual(['new.sql']);
61
+ });
62
+ });
@@ -6,6 +6,8 @@ export declare function findingKey(failure: Failure): string;
6
6
  export interface QuietSplit {
7
7
  speaking: Failure[];
8
8
  advisory: Failure[];
9
+ /** Advisory findings from checks this repository keeps dismissing (check-outcomes.ts); counted, not listed. */
10
+ muted: number;
9
11
  dismissed: number;
10
12
  /** Which gates the dismissed findings came from: where Rigour is wrong for this team. */
11
13
  dismissedByGate: Record<string, number>;
@@ -16,6 +16,7 @@
16
16
  import crypto from 'crypto';
17
17
  import fs from 'fs';
18
18
  import path from 'path';
19
+ import { checkId, isMuted, readOutcomes, recordOutcome, reportedCheck } from './check-outcomes.js';
19
20
  const PROVEN_GATES = new Set(['semantic-bugs', 'hallucinated-imports', 'security-patterns', 'deep-analysis', 'diff-tests']);
20
21
  export const DISMISSED_FILE = path.join('.rigour', 'dismissed.json');
21
22
  export function isProven(failure) {
@@ -27,7 +28,8 @@ export function findingKey(failure) {
27
28
  }
28
29
  export function quietSplit(cwd, findings, includeHeuristics = false) {
29
30
  const dismissed = dismissedKeys(cwd);
30
- const split = { speaking: [], advisory: [], dismissed: 0, dismissedByGate: {} };
31
+ const outcomes = readOutcomes(cwd);
32
+ const split = { speaking: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {} };
31
33
  for (const finding of findings) {
32
34
  if (dismissed.has(findingKey(finding))) {
33
35
  split.dismissed++;
@@ -35,6 +37,8 @@ export function quietSplit(cwd, findings, includeHeuristics = false) {
35
37
  }
36
38
  else if (includeHeuristics || isProven(finding))
37
39
  split.speaking.push(finding);
40
+ else if (isMuted(outcomes[checkId(finding)]))
41
+ split.muted++;
38
42
  else
39
43
  split.advisory.push(finding);
40
44
  }
@@ -63,8 +67,13 @@ export function dismissFinding(cwd, key, reason, at = new Date().toISOString())
63
67
  catch {
64
68
  // First dismissal in this repository.
65
69
  }
66
- if (!entries.some(e => e.key === key))
67
- entries.push({ key, reason: reason.trim(), at });
70
+ if (entries.some(e => e.key === key))
71
+ return true;
72
+ // The check this key came from (remembered when review reported it) feeds its precision.
73
+ const check = reportedCheck(cwd, key);
74
+ entries.push({ key, reason: reason.trim(), at, ...(check ? { check } : {}) });
75
+ if (check)
76
+ recordOutcome(cwd, check, 'dismissed');
68
77
  fs.mkdirSync(path.dirname(file), { recursive: true });
69
78
  fs.writeFileSync(file, JSON.stringify({ version: 1, entries }, null, 2) + '\n');
70
79
  return true;
@@ -21,6 +21,8 @@ export interface ReviewResult {
21
21
  contextFindings: Failure[];
22
22
  /** Heuristic findings on changed lines: returned on request, never deciding the verdict (quiet.ts). */
23
23
  advisory: Failure[];
24
+ /** Advisory findings from checks this repository keeps dismissing: counted, not listed. */
25
+ muted: number;
24
26
  /** Findings a person dismissed as not a bug, and the gates they came from. */
25
27
  dismissed: number;
26
28
  dismissedByGate: Record<string, number>;
@@ -15,28 +15,33 @@ import { splitByChangedLines } from './changed-lines.js';
15
15
  import { changedFunctionSpans } from './changed-function-spans.js';
16
16
  import { withoutGenerated } from './generated-files.js';
17
17
  import { findingKey, isProven, quietSplit } from './quiet.js';
18
+ import { checkId, rememberReported } from './check-outcomes.js';
18
19
  import { diffFromGit } from './git-diff.js';
19
20
  import { diffTestFailures } from './diff-test-findings.js';
21
+ import { migrationOrderFailures } from './migration-order.js';
20
22
  export async function reviewChange(input) {
21
23
  const diff = input.diff ?? diffFromGit(input.cwd, input.source);
22
24
  const changedLines = withoutGenerated(input.cwd, parseDiff(diff));
23
25
  const targets = input.files?.length ? input.files : Object.keys(changedLines);
24
26
  if (targets.length === 0) {
25
- return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null, gateErrors: [] };
27
+ return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null, gateErrors: [] };
26
28
  }
27
29
  const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
28
30
  const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
29
31
  if (input.diffTests && deep)
30
32
  report.failures.push(...await diffTestFailures(input.cwd, input.source, deep));
31
- const split = splitByChangedLines(report.failures, changedLines, deep ? changedFunctionSpans(input.cwd, changedLines) : {});
33
+ report.failures.push(...migrationOrderFailures(input.cwd, diff, input.source, input.config));
34
+ const split = splitByChangedLines(report.failures, changedLines, deep ? changedFunctionSpans(input.cwd, changedLines) : {}, removedByFile(diff));
32
35
  const deepError = deepAnalysisError(report);
33
36
  const quiet = quietSplit(input.cwd, split.findings, input.config.review?.include_heuristics);
37
+ rememberReported(input.cwd, [...quiet.speaking, ...quiet.advisory].map(f => ({ key: findingKey(f), check: checkId(f) })));
34
38
  const gateErrors = crashedGates(report);
35
39
  const provenCrashed = gateErrors.some(id => isProven({ id }));
36
40
  return {
37
41
  status: deepError || provenCrashed ? 'ERROR' : quiet.speaking.length > 0 ? 'FAIL' : 'PASS',
38
42
  findings: quiet.speaking,
39
43
  advisory: quiet.advisory,
44
+ muted: quiet.muted,
40
45
  dismissed: quiet.dismissed,
41
46
  dismissedByGate: quiet.dismissedByGate,
42
47
  fileFindings: split.fileFindings,
@@ -37,6 +37,17 @@ describe('splitByChangedLines', () => {
37
37
  expect(split.contextFindings.map(f => f.line)).toEqual([30]); // elsewhere in a changed file: shown, not blocking
38
38
  expect(split.outside).toBe(1); // a rule finding on an unchanged line is pre-existing, as before
39
39
  });
40
+ it('counts a multi-line finding as changed when lines inside it were deleted', () => {
41
+ // fetch(url, { <- line 4, unchanged
42
+ // redirect: ... <- deleted, so the removal sits before new line 5
43
+ // }) <- line 5
44
+ const call = { ...failure('a.ts', 4), endLine: 5 };
45
+ const outsideCall = { ...failure('a.ts', 9), endLine: 10 };
46
+ const removed = { 'a.ts': [{ line: 5, text: [" redirect: 'manual',"] }] };
47
+ const split = splitByChangedLines([call, outsideCall, failure('a.ts', 4)], {}, {}, removed);
48
+ expect(split.findings).toEqual([call]);
49
+ expect(split.outside).toBe(2);
50
+ });
40
51
  });
41
52
  describe('changedFunctionSpans', () => {
42
53
  it('spans the outermost function around changed lines, and anchors to the nearest changed line in it', () => {
@@ -20,6 +20,7 @@ export declare function forEachNode(root: ts.Node, visit: (node: ts.Node) => voi
20
20
  /** Text of a property name (`a`, `'a'`, `"x-token"`), or undefined for computed names. */
21
21
  export declare function propertyNameText(name: ts.PropertyName | undefined): string | undefined;
22
22
  export declare function lineOf(node: ts.Node): number;
23
+ export declare function endLineOf(node: ts.Node): number;
23
24
  /** "http.ts:51" style location for evidence, relative to cwd. */
24
25
  export declare function locationOf(cwd: string, node: ts.Node): string;
25
26
  export declare function relativeFile(cwd: string, sf: ts.SourceFile): string;
@@ -94,6 +94,10 @@ export function lineOf(node) {
94
94
  const sf = node.getSourceFile();
95
95
  return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
96
96
  }
97
+ export function endLineOf(node) {
98
+ const sf = node.getSourceFile();
99
+ return sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
100
+ }
97
101
  /** "http.ts:51" style location for evidence, relative to cwd. */
98
102
  export function locationOf(cwd, node) {
99
103
  return `${relativeFile(cwd, node.getSourceFile())}:${lineOf(node)}`;
@@ -1,4 +1,4 @@
1
- import { forEachNode, lineOf, relativeFile, snippet } from '../ast.js';
1
+ import { endLineOf, forEachNode, lineOf, relativeFile, snippet } from '../ast.js';
2
2
  import { guardsOf, mentionsName, namesIn, propertiesNamed, setsProperty } from './conditions.js';
3
3
  import { calleeKey, functionKeyOf, invocationArgs, isInvocation } from './identity.js';
4
4
  export const LEARNED_PREFIX = 'learned/';
@@ -23,6 +23,8 @@ export function compileLearnedRule(rule) {
23
23
  provenance: 'traditional',
24
24
  file,
25
25
  line: lineOf(at),
26
+ // The whole call: an option deleted from inside it is a change to this line.
27
+ endLine: endLineOf(node),
26
28
  message: rule.message,
27
29
  hint: `Learned from ${describeSource(rule)}. Apply the same fix here, or delete .rigour/rules/${rule.id}.json if the rule no longer holds.`,
28
30
  evidence: [`${file}:${lineOf(node)} ${snippet(node)}`],
@@ -16,6 +16,8 @@ import { analyzeFiles } from '../engine.js';
16
16
  import { programBatches, loadProjectConfig } from '../program.js';
17
17
  import { compileLearnedRule, LEARNED_PREFIX } from './compile.js';
18
18
  import { extractEdits } from './extract.js';
19
+ import { endLineOf, forEachNode, lineOf } from '../ast.js';
20
+ import { functionKeyOf, isInvocation } from './identity.js';
19
21
  const DEFAULT_MAX_HITS = 3;
20
22
  const SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/i;
21
23
  const NOT_SOURCE = /\.d\.[cm]?ts$|(?:^|\/)(?:__tests__|__mocks__|tests?)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
@@ -73,6 +75,12 @@ function openTree(cwd, file) {
73
75
  throw new Error(`Cannot parse ${file} in ${cwd}`);
74
76
  return { cwd, checker: program.getTypeChecker(), sourceFile };
75
77
  }
78
+ /**
79
+ * How often each candidate fires: before the fix, after it, and elsewhere. "After" counts only
80
+ * the function that was fixed: an unfixed copy of the bug next to it in the same file is
81
+ * another place the rule should find (listed with the repository hits), not a sign the rule
82
+ * is wrong about the fix.
83
+ */
76
84
  function countFindings(input, rules, files, repoFiles) {
77
85
  const compiled = rules.map(compileLearnedRule);
78
86
  const tally = (dir, scan) => analyzeFiles(dir, scan, { rules: compiled });
@@ -80,11 +88,38 @@ function countFindings(input, rules, files, repoFiles) {
80
88
  const before = tally(input.beforeDir, files);
81
89
  const after = tally(input.afterDir, files);
82
90
  const repo = tally(input.repoDir, repoFiles);
83
- return new Map(rules.map(rule => [rule.id, {
84
- before: byRule(before, rule.id).length,
85
- after: byRule(after, rule.id).length,
86
- hits: byRule(repo, rule.id).map(f => `${f.file}:${f.line}`),
87
- }]));
91
+ const keyAt = functionKeysAt(input.afterDir, files);
92
+ return new Map(rules.map(rule => {
93
+ const afterHits = byRule(after, rule.id);
94
+ const atFix = afterHits.filter(f => f.file === rule.source.file && keyAt(f.file, f.line) === rule.source.function);
95
+ const besideFix = afterHits.filter(f => !atFix.includes(f)).map(f => `${f.file}:${f.line}`);
96
+ const elsewhere = byRule(repo, rule.id).map(f => `${f.file}:${f.line}`);
97
+ return [rule.id, { before: byRule(before, rule.id).length, after: atFix.length, hits: [...new Set([...besideFix, ...elsewhere])] }];
98
+ }));
99
+ }
100
+ /** The key of the function enclosing a call on a given line of a fixed file (the same key rules are scoped by). */
101
+ function functionKeysAt(dir, files) {
102
+ const keys = new Map();
103
+ return (file, line) => {
104
+ if (!keys.has(file)) {
105
+ const byLine = new Map();
106
+ try {
107
+ forEachNode(openTree(dir, file).sourceFile, node => {
108
+ // Every line a call spans: a finding can point inside the call, not at its first line.
109
+ if (!isInvocation(node))
110
+ return;
111
+ for (let line = lineOf(node); line <= endLineOf(node); line++)
112
+ if (!byLine.has(line))
113
+ byLine.set(line, functionKeyOf(node));
114
+ });
115
+ }
116
+ catch {
117
+ // An unparseable file attributes nothing to the fix.
118
+ }
119
+ keys.set(file, byLine);
120
+ }
121
+ return files.includes(file) ? keys.get(file).get(line) : undefined;
122
+ };
88
123
  }
89
124
  function failure(counts, maxHits) {
90
125
  if (counts.before === 0)
@@ -16,6 +16,8 @@ export interface SemanticFinding {
16
16
  /** cwd-relative posix path of the reported line. */
17
17
  file: string;
18
18
  line: number;
19
+ /** Last line of the code the finding is about (a call), when it spans several lines. */
20
+ endLine?: number;
19
21
  message: string;
20
22
  hint: string;
21
23
  /** Supporting locations, e.g. "http.ts:51 deps.fetch(url, init)". */
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
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 { getContextScopeSummary } from './context-telemetry-service.js';
6
+ let root;
7
+ beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'scope-summary-')); });
8
+ afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
9
+ describe('getContextScopeSummary', () => {
10
+ it('counts rule files and skips a rules directory such as .clinerules/', async () => {
11
+ fs.mkdirSync(path.join(root, '.clinerules'));
12
+ fs.writeFileSync(path.join(root, '.clinerules', 'rigour.md'), 'rules');
13
+ fs.writeFileSync(path.join(root, 'CLAUDE.md'), 'Use small PRs.');
14
+ const summary = await getContextScopeSummary(root);
15
+ expect(summary.alwaysOnRuleTokens).toBeGreaterThan(0);
16
+ });
17
+ });
@@ -432,7 +432,8 @@ export async function getContextScopeSummary(cwd) {
432
432
  let alwaysOnRuleTokens = 0;
433
433
  for (const fileName of GOVERNANCE_RULE_FILES) {
434
434
  const filePath = path.join(projectRoot, fileName);
435
- if (await fs.pathExists(filePath)) {
435
+ // .clinerules may be a directory of rule files; only count regular files.
436
+ if ((await fs.stat(filePath).catch(() => null))?.isFile()) {
436
437
  alwaysOnRuleTokens += estimateTokenCount(await fs.readFile(filePath, 'utf8'));
437
438
  }
438
439
  }