@rigour-labs/core 6.5.2 → 6.5.4

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 (138) 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/deep/risk.d.ts +6 -0
  7. package/dist/deep/risk.js +7 -1
  8. package/dist/deep/risk.test.js +12 -1
  9. package/dist/firewall/arbitration-token.d.ts +12 -3
  10. package/dist/firewall/arbitration-token.js +30 -23
  11. package/dist/firewall/attestation.d.ts +7 -2
  12. package/dist/firewall/attestation.js +49 -28
  13. package/dist/firewall/trust-boundaries.test.d.ts +1 -0
  14. package/dist/firewall/trust-boundaries.test.js +64 -0
  15. package/dist/gates/dedupe-failures.test.d.ts +1 -0
  16. package/dist/gates/dedupe-failures.test.js +12 -0
  17. package/dist/gates/deep-analysis.d.ts +6 -0
  18. package/dist/gates/deep-analysis.js +8 -1
  19. package/dist/gates/deprecated-dependencies.d.ts +27 -0
  20. package/dist/gates/deprecated-dependencies.js +104 -0
  21. package/dist/gates/deprecated-dependencies.test.d.ts +1 -0
  22. package/dist/gates/deprecated-dependencies.test.js +55 -0
  23. package/dist/gates/runner.d.ts +7 -1
  24. package/dist/gates/runner.js +25 -11
  25. package/dist/gates/security-patterns-data.js +2 -1
  26. package/dist/gates/security-patterns-owasp.test.js +15 -0
  27. package/dist/gates/semantic-bugs.js +1 -1
  28. package/dist/gates/trusted-reviews.test.d.ts +1 -0
  29. package/dist/gates/trusted-reviews.test.js +16 -0
  30. package/dist/gates/unindexed-reads/coverage.d.ts +15 -0
  31. package/dist/gates/unindexed-reads/coverage.js +24 -0
  32. package/dist/gates/unindexed-reads/index.d.ts +20 -0
  33. package/dist/gates/unindexed-reads/index.js +98 -0
  34. package/dist/gates/unindexed-reads/queries.d.ts +17 -0
  35. package/dist/gates/unindexed-reads/queries.js +147 -0
  36. package/dist/gates/unindexed-reads/queries.test.d.ts +1 -0
  37. package/dist/gates/unindexed-reads/queries.test.js +54 -0
  38. package/dist/gates/unindexed-reads/schema.d.ts +38 -0
  39. package/dist/gates/unindexed-reads/schema.js +256 -0
  40. package/dist/gates/unindexed-reads/schema.test.d.ts +1 -0
  41. package/dist/gates/unindexed-reads/schema.test.js +81 -0
  42. package/dist/gates/unindexed-reads/sql.d.ts +21 -0
  43. package/dist/gates/unindexed-reads/sql.js +131 -0
  44. package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +1 -0
  45. package/dist/gates/unindexed-reads/unindexed-reads.test.js +90 -0
  46. package/dist/hooks/checker.js +22 -0
  47. package/dist/hooks/checker.test.js +8 -0
  48. package/dist/hooks/index.d.ts +1 -0
  49. package/dist/hooks/index.js +1 -0
  50. package/dist/hooks/session-state.d.ts +8 -0
  51. package/dist/hooks/session-state.js +61 -0
  52. package/dist/hooks/stop-review.d.ts +6 -4
  53. package/dist/hooks/stop-review.js +5 -3
  54. package/dist/hooks/templates.js +1 -1
  55. package/dist/index.d.ts +6 -1
  56. package/dist/index.js +6 -1
  57. package/dist/inference/brain-placeholder.test.d.ts +1 -0
  58. package/dist/inference/brain-placeholder.test.js +28 -0
  59. package/dist/inference/llama-engine-checksum.test.d.ts +1 -0
  60. package/dist/inference/llama-engine-checksum.test.js +27 -0
  61. package/dist/inference/llama-engine.d.ts +4 -1
  62. package/dist/inference/llama-engine.js +15 -8
  63. package/dist/memory/recall.d.ts +37 -0
  64. package/dist/memory/recall.js +93 -0
  65. package/dist/memory/recall.test.d.ts +1 -0
  66. package/dist/memory/recall.test.js +36 -0
  67. package/dist/pattern-index/assess.d.ts +16 -0
  68. package/dist/pattern-index/assess.js +37 -0
  69. package/dist/pattern-index/embeddings.d.ts +19 -0
  70. package/dist/pattern-index/embeddings.js +16 -0
  71. package/dist/pattern-index/index.d.ts +3 -2
  72. package/dist/pattern-index/index.js +4 -2
  73. package/dist/pattern-index/indexer.js +15 -7
  74. package/dist/pattern-index/matcher.d.ts +6 -0
  75. package/dist/pattern-index/matcher.js +13 -3
  76. package/dist/pattern-index/pattern-reuse.test.d.ts +1 -0
  77. package/dist/pattern-index/pattern-reuse.test.js +76 -0
  78. package/dist/pattern-index/staleness.d.ts +2 -15
  79. package/dist/pattern-index/staleness.js +1 -78
  80. package/dist/pattern-index/types.d.ts +2 -0
  81. package/dist/review/agent-fixes.d.ts +4 -0
  82. package/dist/review/agent-fixes.js +10 -7
  83. package/dist/review/agent-fixes.test.js +4 -4
  84. package/dist/review/changed-lines.d.ts +2 -1
  85. package/dist/review/changed-lines.js +12 -2
  86. package/dist/review/check-outcomes.d.ts +32 -0
  87. package/dist/review/check-outcomes.js +90 -0
  88. package/dist/review/check-outcomes.test.d.ts +1 -0
  89. package/dist/review/check-outcomes.test.js +56 -0
  90. package/dist/review/git-diff.d.ts +3 -0
  91. package/dist/review/git-diff.js +5 -0
  92. package/dist/review/migration-order.d.ts +5 -0
  93. package/dist/review/migration-order.js +70 -0
  94. package/dist/review/migration-order.test.d.ts +1 -0
  95. package/dist/review/migration-order.test.js +62 -0
  96. package/dist/review/quiet.d.ts +5 -2
  97. package/dist/review/quiet.js +21 -8
  98. package/dist/review/review.d.ts +6 -0
  99. package/dist/review/review.js +20 -3
  100. package/dist/review/review.test.js +30 -0
  101. package/dist/review/trusted-state.d.ts +6 -0
  102. package/dist/review/trusted-state.js +35 -0
  103. package/dist/semantic/ast.d.ts +1 -0
  104. package/dist/semantic/ast.js +4 -0
  105. package/dist/semantic/learn/compile.js +3 -1
  106. package/dist/semantic/learn/learn.js +40 -5
  107. package/dist/semantic/types.d.ts +2 -0
  108. package/dist/services/context-scope-summary.test.d.ts +1 -0
  109. package/dist/services/context-scope-summary.test.js +17 -0
  110. package/dist/services/context-telemetry-service.js +2 -1
  111. package/dist/storage/context-telemetry.js +33 -19
  112. package/dist/storage/db.js +52 -1
  113. package/dist/storage/findings.js +1 -1
  114. package/dist/storage/fix-lessons.d.ts +13 -0
  115. package/dist/storage/fix-lessons.js +84 -0
  116. package/dist/storage/fix-lessons.test.d.ts +1 -0
  117. package/dist/storage/fix-lessons.test.js +61 -0
  118. package/dist/storage/index.d.ts +2 -1
  119. package/dist/storage/index.js +2 -1
  120. package/dist/storage/lessons.d.ts +16 -2
  121. package/dist/storage/lessons.js +27 -61
  122. package/dist/storage/lessons.test.js +1 -15
  123. package/dist/storage/local-memory.js +39 -5
  124. package/dist/storage/local-memory.test.d.ts +1 -0
  125. package/dist/storage/local-memory.test.js +55 -0
  126. package/dist/storage/patterns.d.ts +1 -1
  127. package/dist/storage/patterns.js +1 -1
  128. package/dist/storage/share-memory.test.d.ts +1 -0
  129. package/dist/storage/share-memory.test.js +34 -0
  130. package/dist/storage/team-vector-store.js +2 -1
  131. package/dist/storage/telemetry-scope.test.d.ts +1 -0
  132. package/dist/storage/telemetry-scope.test.js +38 -0
  133. package/dist/templates/universal-config.js +11 -0
  134. package/dist/types/index.d.ts +137 -0
  135. package/dist/types/index.js +15 -0
  136. package/dist/utils/user-state.d.ts +7 -0
  137. package/dist/utils/user-state.js +21 -0
  138. package/package.json +7 -7
@@ -1,5 +1,5 @@
1
1
  import { anchorInChangedFunction } from './changed-function-spans.js';
2
- export function splitByChangedLines(failures, changedLines, spans = {}) {
2
+ export function splitByChangedLines(failures, changedLines, spans = {}, removed = {}) {
3
3
  const split = { findings: [], fileFindings: [], contextFindings: [], unlocated: 0, outside: 0 };
4
4
  for (const failure of failures) {
5
5
  const files = failure.files ?? [];
@@ -12,7 +12,7 @@ export function splitByChangedLines(failures, changedLines, spans = {}) {
12
12
  else
13
13
  split.outside++;
14
14
  }
15
- else if (files.some(file => changedLines[file]?.has(failure.line))) {
15
+ else if (files.some(file => changedLines[file]?.has(failure.line) || removedInside(failure, removed[file]))) {
16
16
  split.findings.push(failure);
17
17
  }
18
18
  else if (failure.provenance === 'deep-analysis') {
@@ -37,3 +37,13 @@ function placeDeepFinding(failure, files, changedLines, spans, split) {
37
37
  else
38
38
  split.outside++;
39
39
  }
40
+ /**
41
+ * Whether the change deleted lines inside a multi-line finding (its line to endLine), such as an
42
+ * option removed from a call whose first line did not change: that call is part of the change.
43
+ */
44
+ function removedInside(failure, blocks) {
45
+ const end = failure.endLine;
46
+ if (!blocks?.length || end === undefined || failure.line === undefined || end <= failure.line)
47
+ return false;
48
+ return blocks.some(block => block.line > failure.line && block.line <= end);
49
+ }
@@ -0,0 +1,32 @@
1
+ /** Outcomes a check needs before it can be muted, and the posterior mean below which it is. */
2
+ export declare const MUTE_MIN_OUTCOMES = 5;
3
+ export declare const MUTE_BELOW = 0.25;
4
+ export interface CheckOutcome {
5
+ fixed: number;
6
+ dismissed: number;
7
+ }
8
+ export interface CheckPrecision extends CheckOutcome {
9
+ check: string;
10
+ /** Posterior mean of Beta(fixed + 1, dismissed + 1). */
11
+ precision: number;
12
+ muted: boolean;
13
+ }
14
+ /** A check is a gate and the rule within it: `ast` alone would mute complexity and parameter count together. */
15
+ export declare function checkId(finding: {
16
+ id?: string;
17
+ rule?: string;
18
+ title?: string;
19
+ }): string;
20
+ export declare function precisionOf(outcome: CheckOutcome): number;
21
+ export declare function isMuted(outcome: CheckOutcome | undefined): boolean;
22
+ /** Outcomes in the working tree, or at `ref` for an independent review (trusted-state.ts). */
23
+ export declare function readOutcomes(cwd: string, ref?: string): Record<string, CheckOutcome>;
24
+ export declare function recordOutcome(cwd: string, check: string, kind: keyof CheckOutcome): void;
25
+ /** Every check with an outcome, most dismissed first. */
26
+ export declare function checkPrecisions(cwd: string): CheckPrecision[];
27
+ /** Remember which check each reported finding key came from (most recent kept). */
28
+ export declare function rememberReported(cwd: string, entries: Array<{
29
+ key: string;
30
+ check: string;
31
+ }>): void;
32
+ export declare function reportedCheck(cwd: string, key: string): string | undefined;
@@ -0,0 +1,90 @@
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
+ import { readStateFile } from './trusted-state.js';
17
+ const OUTCOMES_FILE = path.join('.rigour', 'check-outcomes.json');
18
+ const REPORTED_FILE = path.join('.rigour', 'reported-findings.json');
19
+ const MAX_REPORTED = 2000;
20
+ /** Outcomes a check needs before it can be muted, and the posterior mean below which it is. */
21
+ export const MUTE_MIN_OUTCOMES = 5;
22
+ export const MUTE_BELOW = 0.25;
23
+ /** A check is a gate and the rule within it: `ast` alone would mute complexity and parameter count together. */
24
+ export function checkId(finding) {
25
+ const gate = finding.id ?? finding.rule ?? 'unknown';
26
+ return finding.title?.trim() ? `${gate}: ${finding.title.trim()}` : gate;
27
+ }
28
+ export function precisionOf(outcome) {
29
+ return (outcome.fixed + 1) / (outcome.fixed + outcome.dismissed + 2);
30
+ }
31
+ export function isMuted(outcome) {
32
+ if (!outcome)
33
+ return false;
34
+ return outcome.fixed + outcome.dismissed >= MUTE_MIN_OUTCOMES && precisionOf(outcome) < MUTE_BELOW;
35
+ }
36
+ /** Outcomes in the working tree, or at `ref` for an independent review (trusted-state.ts). */
37
+ export function readOutcomes(cwd, ref) {
38
+ if (!ref)
39
+ return readJson(path.join(cwd, OUTCOMES_FILE)) ?? {};
40
+ try {
41
+ return JSON.parse(readStateFile(cwd, OUTCOMES_FILE, ref) ?? '{}');
42
+ }
43
+ catch {
44
+ return {};
45
+ }
46
+ }
47
+ export function recordOutcome(cwd, check, kind) {
48
+ const outcomes = readOutcomes(cwd);
49
+ const current = outcomes[check] ?? { fixed: 0, dismissed: 0 };
50
+ outcomes[check] = { ...current, [kind]: current[kind] + 1 };
51
+ writeJson(path.join(cwd, OUTCOMES_FILE), outcomes);
52
+ }
53
+ /** Every check with an outcome, most dismissed first. */
54
+ export function checkPrecisions(cwd) {
55
+ return Object.entries(readOutcomes(cwd))
56
+ .map(([check, outcome]) => ({ check, ...outcome, precision: precisionOf(outcome), muted: isMuted(outcome) }))
57
+ .sort((a, b) => a.precision - b.precision);
58
+ }
59
+ /** Remember which check each reported finding key came from (most recent kept). */
60
+ export function rememberReported(cwd, entries) {
61
+ if (entries.length === 0)
62
+ return;
63
+ const reported = readJson(path.join(cwd, REPORTED_FILE)) ?? {};
64
+ for (const { key, check } of entries) {
65
+ delete reported[key];
66
+ reported[key] = check;
67
+ }
68
+ const kept = Object.entries(reported).slice(-MAX_REPORTED);
69
+ writeJson(path.join(cwd, REPORTED_FILE), Object.fromEntries(kept));
70
+ }
71
+ export function reportedCheck(cwd, key) {
72
+ return readJson(path.join(cwd, REPORTED_FILE))?.[key];
73
+ }
74
+ function readJson(file) {
75
+ try {
76
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
82
+ function writeJson(file, value) {
83
+ try {
84
+ fs.mkdirSync(path.dirname(file), { recursive: true });
85
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
86
+ }
87
+ catch {
88
+ // Learning is best-effort; a read-only checkout still reviews.
89
+ }
90
+ }
@@ -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
+ });
@@ -3,6 +3,9 @@ export type DiffSource = {
3
3
  } | {
4
4
  mode: 'base';
5
5
  base: string;
6
+ } | {
7
+ mode: 'since';
8
+ commit: string;
6
9
  };
7
10
  export declare class GitDiffError extends Error {
8
11
  }
@@ -5,6 +5,8 @@
5
5
  * git does not track yet (what an agent has just written).
6
6
  * - base: everything on this branch since it left `base` (the merge-base),
7
7
  * including uncommitted changes (what a pull request contains).
8
+ * - since: everything after `commit`, committed or not, plus untracked files
9
+ * (what an agent session wrote, even if it committed along the way).
8
10
  */
9
11
  import { spawnSync } from 'child_process';
10
12
  export class GitDiffError extends Error {
@@ -14,6 +16,9 @@ export function diffFromGit(cwd, source = { mode: 'working' }) {
14
16
  const mergeBase = git(cwd, ['merge-base', source.base, 'HEAD']).trim();
15
17
  return git(cwd, ['diff', '--no-color', '--no-ext-diff', mergeBase]);
16
18
  }
19
+ if (source.mode === 'since') {
20
+ return git(cwd, ['diff', '--no-color', '--no-ext-diff', source.commit]) + untrackedFiles(cwd).map(file => newFileDiff(cwd, file)).join('');
21
+ }
17
22
  const tracked = hasHead(cwd) ? git(cwd, ['diff', '--no-color', '--no-ext-diff', 'HEAD']) : '';
18
23
  return tracked + untrackedFiles(cwd).map(file => newFileDiff(cwd, file)).join('');
19
24
  }
@@ -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 : source?.mode === 'since' ? source.commit : '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,11 +6,14 @@ 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>;
12
14
  }
13
- export declare function quietSplit(cwd: string, findings: Failure[], includeHeuristics?: boolean): QuietSplit;
14
- export declare function dismissedKeys(cwd: string): Set<string>;
15
+ /** `trustedRef`: read dismissals and outcomes as of that commit, not as the change left them. */
16
+ export declare function quietSplit(cwd: string, findings: Failure[], includeHeuristics?: boolean, trustedRef?: string): QuietSplit;
17
+ export declare function dismissedKeys(cwd: string, ref?: string): Set<string>;
15
18
  /** Record "not a bug" for a finding key; idempotent. */
16
19
  export declare function dismissFinding(cwd: string, key: string, reason: string, at?: string): boolean;
@@ -11,11 +11,15 @@
11
11
  * blocking. A team that wants them back sets review.include_heuristics.
12
12
  *
13
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).
14
+ * reported again (.rigour/dismissed.json, meant to be committed). An
15
+ * independent review reads it from the base (trusted-state.ts), so a change
16
+ * cannot dismiss its own findings.
15
17
  */
16
18
  import crypto from 'crypto';
17
19
  import fs from 'fs';
18
20
  import path from 'path';
21
+ import { checkId, isMuted, readOutcomes, recordOutcome, reportedCheck } from './check-outcomes.js';
22
+ import { readStateFile } from './trusted-state.js';
19
23
  const PROVEN_GATES = new Set(['semantic-bugs', 'hallucinated-imports', 'security-patterns', 'deep-analysis', 'diff-tests']);
20
24
  export const DISMISSED_FILE = path.join('.rigour', 'dismissed.json');
21
25
  export function isProven(failure) {
@@ -25,9 +29,11 @@ export function isProven(failure) {
25
29
  export function findingKey(failure) {
26
30
  return crypto.createHash('sha256').update(`${failure.id}\u0000${failure.files?.[0] ?? ''}\u0000${failure.details}`).digest('hex').slice(0, 16);
27
31
  }
28
- export function quietSplit(cwd, findings, includeHeuristics = false) {
29
- const dismissed = dismissedKeys(cwd);
30
- const split = { speaking: [], advisory: [], dismissed: 0, dismissedByGate: {} };
32
+ /** `trustedRef`: read dismissals and outcomes as of that commit, not as the change left them. */
33
+ export function quietSplit(cwd, findings, includeHeuristics = false, trustedRef) {
34
+ const dismissed = dismissedKeys(cwd, trustedRef);
35
+ const outcomes = readOutcomes(cwd, trustedRef);
36
+ const split = { speaking: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {} };
31
37
  for (const finding of findings) {
32
38
  if (dismissed.has(findingKey(finding))) {
33
39
  split.dismissed++;
@@ -35,14 +41,16 @@ export function quietSplit(cwd, findings, includeHeuristics = false) {
35
41
  }
36
42
  else if (includeHeuristics || isProven(finding))
37
43
  split.speaking.push(finding);
44
+ else if (isMuted(outcomes[checkId(finding)]))
45
+ split.muted++;
38
46
  else
39
47
  split.advisory.push(finding);
40
48
  }
41
49
  return split;
42
50
  }
43
- export function dismissedKeys(cwd) {
51
+ export function dismissedKeys(cwd, ref) {
44
52
  try {
45
- const parsed = JSON.parse(fs.readFileSync(path.join(cwd, DISMISSED_FILE), 'utf8'));
53
+ const parsed = JSON.parse(readStateFile(cwd, DISMISSED_FILE, ref) ?? '');
46
54
  return new Set(Array.isArray(parsed?.entries) ? parsed.entries.map((e) => e?.key).filter((k) => typeof k === 'string') : []);
47
55
  }
48
56
  catch {
@@ -63,8 +71,13 @@ export function dismissFinding(cwd, key, reason, at = new Date().toISOString())
63
71
  catch {
64
72
  // First dismissal in this repository.
65
73
  }
66
- if (!entries.some(e => e.key === key))
67
- entries.push({ key, reason: reason.trim(), at });
74
+ if (entries.some(e => e.key === key))
75
+ return true;
76
+ // The check this key came from (remembered when review reported it) feeds its precision.
77
+ const check = reportedCheck(cwd, key);
78
+ entries.push({ key, reason: reason.trim(), at, ...(check ? { check } : {}) });
79
+ if (check)
80
+ recordOutcome(cwd, check, 'dismissed');
68
81
  fs.mkdirSync(path.dirname(file), { recursive: true });
69
82
  fs.writeFileSync(file, JSON.stringify({ version: 1, entries }, null, 2) + '\n');
70
83
  return true;
@@ -12,6 +12,8 @@ export interface ReviewInput {
12
12
  diffTests?: boolean;
13
13
  /** Deep analysis; `focusLines` and `removedLines` are filled from the diff. */
14
14
  deep?: Omit<DeepOptions, 'focusLines' | 'removedLines' | 'diff'>;
15
+ /** Read dismissals and check outcomes at this commit (the base), not as the change left them. */
16
+ trustedRef?: string;
15
17
  }
16
18
  export interface ReviewResult {
17
19
  status: 'PASS' | 'FAIL' | 'ERROR';
@@ -21,6 +23,8 @@ export interface ReviewResult {
21
23
  contextFindings: Failure[];
22
24
  /** Heuristic findings on changed lines: returned on request, never deciding the verdict (quiet.ts). */
23
25
  advisory: Failure[];
26
+ /** Advisory findings from checks this repository keeps dismissing: counted, not listed. */
27
+ muted: number;
24
28
  /** Findings a person dismissed as not a bug, and the gates they came from. */
25
29
  dismissed: number;
26
30
  dismissedByGate: Record<string, number>;
@@ -31,6 +35,8 @@ export interface ReviewResult {
31
35
  deepError?: string;
32
36
  /** Gates that crashed instead of running; a proven one makes the result ERROR. */
33
37
  gateErrors: string[];
38
+ /** rigour.yml or .rigour/ files this change edits: they steer the review, so they are called out. */
39
+ controlFilesChanged: string[];
34
40
  }
35
41
  export interface ReviewFinding {
36
42
  id: string;
@@ -15,28 +15,34 @@ 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';
22
+ import { isControlFile } from './trusted-state.js';
20
23
  export async function reviewChange(input) {
21
24
  const diff = input.diff ?? diffFromGit(input.cwd, input.source);
22
25
  const changedLines = withoutGenerated(input.cwd, parseDiff(diff));
23
26
  const targets = input.files?.length ? input.files : Object.keys(changedLines);
24
27
  if (targets.length === 0) {
25
- return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null, gateErrors: [] };
28
+ return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null, gateErrors: [], controlFilesChanged: controlFiles(diff) };
26
29
  }
27
30
  const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
28
31
  const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
29
32
  if (input.diffTests && deep)
30
33
  report.failures.push(...await diffTestFailures(input.cwd, input.source, deep));
31
- const split = splitByChangedLines(report.failures, changedLines, deep ? changedFunctionSpans(input.cwd, changedLines) : {});
34
+ report.failures.push(...migrationOrderFailures(input.cwd, diff, input.source, input.config));
35
+ const split = splitByChangedLines(report.failures, changedLines, deep ? changedFunctionSpans(input.cwd, changedLines) : {}, removedByFile(diff));
32
36
  const deepError = deepAnalysisError(report);
33
- const quiet = quietSplit(input.cwd, split.findings, input.config.review?.include_heuristics);
37
+ const quiet = quietSplit(input.cwd, split.findings, input.config.review?.include_heuristics, input.trustedRef);
38
+ rememberReported(input.cwd, [...quiet.speaking, ...quiet.advisory].map(f => ({ key: findingKey(f), check: checkId(f) })));
34
39
  const gateErrors = crashedGates(report);
35
40
  const provenCrashed = gateErrors.some(id => isProven({ id }));
36
41
  return {
37
42
  status: deepError || provenCrashed ? 'ERROR' : quiet.speaking.length > 0 ? 'FAIL' : 'PASS',
38
43
  findings: quiet.speaking,
39
44
  advisory: quiet.advisory,
45
+ muted: quiet.muted,
40
46
  dismissed: quiet.dismissed,
41
47
  dismissedByGate: quiet.dismissedByGate,
42
48
  fileFindings: split.fileFindings,
@@ -46,9 +52,20 @@ export async function reviewChange(input) {
46
52
  changedLines,
47
53
  report,
48
54
  gateErrors,
55
+ controlFilesChanged: controlFiles(diff),
49
56
  ...(deepError ? { deepError } : {}),
50
57
  };
51
58
  }
59
+ /** Every path the diff touches (including deletions, which parseDiff drops) that steers Rigour itself. */
60
+ function controlFiles(diff) {
61
+ const files = new Set();
62
+ for (const match of diff.matchAll(/^diff --git a\/(\S+) b\/(\S+)$/gm)) {
63
+ for (const file of [match[1], match[2]])
64
+ if (isControlFile(file))
65
+ files.add(file);
66
+ }
67
+ return [...files].sort();
68
+ }
52
69
  function crashedGates(report) {
53
70
  return Object.entries(report.summary).filter(([, status]) => status === 'ERROR').map(([id]) => id);
54
71
  }
@@ -10,6 +10,7 @@ import { splitByChangedLines } from './changed-lines.js';
10
10
  import { anchorInChangedFunction, changedFunctionSpans } from './changed-function-spans.js';
11
11
  import { diffFromGit } from './git-diff.js';
12
12
  import { reviewChange, toReviewFinding } from './review.js';
13
+ import { findingKey } from './quiet.js';
13
14
  const LEAKY = [
14
15
  'export async function notify(endpoint: string, signature: string) {',
15
16
  " return fetch(endpoint, { method: 'POST', headers: { 'x-hook-signature': signature } });",
@@ -37,6 +38,17 @@ describe('splitByChangedLines', () => {
37
38
  expect(split.contextFindings.map(f => f.line)).toEqual([30]); // elsewhere in a changed file: shown, not blocking
38
39
  expect(split.outside).toBe(1); // a rule finding on an unchanged line is pre-existing, as before
39
40
  });
41
+ it('counts a multi-line finding as changed when lines inside it were deleted', () => {
42
+ // fetch(url, { <- line 4, unchanged
43
+ // redirect: ... <- deleted, so the removal sits before new line 5
44
+ // }) <- line 5
45
+ const call = { ...failure('a.ts', 4), endLine: 5 };
46
+ const outsideCall = { ...failure('a.ts', 9), endLine: 10 };
47
+ const removed = { 'a.ts': [{ line: 5, text: [" redirect: 'manual',"] }] };
48
+ const split = splitByChangedLines([call, outsideCall, failure('a.ts', 4)], {}, {}, removed);
49
+ expect(split.findings).toEqual([call]);
50
+ expect(split.outside).toBe(2);
51
+ });
40
52
  });
41
53
  describe('changedFunctionSpans', () => {
42
54
  it('spans the outermost function around changed lines, and anchors to the nearest changed line in it', () => {
@@ -103,6 +115,24 @@ describe('git-backed review', () => {
103
115
  expect(semantic.map(f => [f.files?.[0], f.line])).toEqual([['src/notify.ts', 2]]);
104
116
  expect(toReviewFinding(semantic[0])).toMatchObject({ id: 'semantic-bugs', file: 'src/notify.ts', line: 2, severity: 'high' });
105
117
  });
118
+ it('lets a change dismiss its own finding only when the review trusts the working tree', async () => {
119
+ write('src/old.ts', 'export const a = 1;\n');
120
+ write('.gitignore', '.rigour/*\n!.rigour/dismissed.json\n');
121
+ git('add', '-A');
122
+ git('commit', '-qm', 'init');
123
+ git('checkout', '-q', '-b', 'pr');
124
+ write('src/notify.ts', LEAKY);
125
+ const config = ConfigSchema.parse({ version: 1, gates: { semantic_bugs: { enabled: true } } });
126
+ const key = findingKey((await reviewChange({ cwd: repo, config })).findings[0]);
127
+ write('.rigour/dismissed.json', JSON.stringify({ version: 1, entries: [{ key, reason: 'trust me', at: 'now' }] }));
128
+ git('add', '-A');
129
+ git('commit', '-qm', 'pr');
130
+ const trusting = await reviewChange({ cwd: repo, config, source: { mode: 'base', base: 'main' } });
131
+ expect([trusting.status, trusting.dismissed]).toEqual(['PASS', 1]);
132
+ expect(trusting.controlFilesChanged).toEqual(['.rigour/dismissed.json']);
133
+ const independent = await reviewChange({ cwd: repo, config, source: { mode: 'base', base: 'main' }, trustedRef: 'main' });
134
+ expect([independent.status, independent.dismissed]).toEqual(['FAIL', 0]);
135
+ });
106
136
  it('runs every gate in a git worktree, where .git is a file', async () => {
107
137
  write('src/a.ts', 'export const a = 1;\n');
108
138
  git('add', '-A');
@@ -0,0 +1,6 @@
1
+ /** Paths that steer Rigour itself; a change touching them is called out in the review. */
2
+ export declare function isControlFile(file: string): boolean;
3
+ /** A repository file's content at `ref`, or in the working tree when no ref is given; null when absent. */
4
+ export declare function readStateFile(cwd: string, rel: string, ref?: string): string | null;
5
+ /** The commit a branch left `base` at: the state a pull request is judged against. */
6
+ export declare function mergeBaseOf(cwd: string, base: string): string;