@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
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Deprecated dependencies: a package.json dependency whose installed version
3
+ * npm marks deprecated.
4
+ *
5
+ * npm deprecates individual versions, so the check asks about the version
6
+ * actually installed (package-lock.json, else node_modules), never the latest;
7
+ * a dependency whose installed version cannot be known says nothing. The
8
+ * finding sits on the dependency's line in package.json, so review reports it
9
+ * when a change adds or moves a dependency onto a deprecated version.
10
+ *
11
+ * Opt-in and advisory: it sends package names and versions to the registry
12
+ * (as `npm install` does). Answers are cached for a day in .rigour/; a slow or
13
+ * unreachable registry means no findings, never a failed check.
14
+ */
15
+ import fs from 'fs-extra';
16
+ import path from 'path';
17
+ import { Gate } from './base.js';
18
+ import { Logger } from '../utils/logger.js';
19
+ const CACHE_FILE = path.join('.rigour', 'deprecated-dependencies.json');
20
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
21
+ const TIMEOUT_MS = 3000;
22
+ const CONCURRENCY = 8;
23
+ const MAX_PACKAGES = 300;
24
+ export class DeprecatedDependenciesGate extends Gate {
25
+ config;
26
+ fetchImpl;
27
+ constructor(config = {}, fetchImpl = fetch) {
28
+ super('deprecated-dependencies', 'Deprecated dependency');
29
+ this.config = config;
30
+ this.fetchImpl = fetchImpl;
31
+ }
32
+ async run(context) {
33
+ if (!this.config.enabled)
34
+ return [];
35
+ const manifestPath = path.join(context.cwd, 'package.json');
36
+ if (!(await fs.pathExists(manifestPath)))
37
+ return [];
38
+ const manifestText = await fs.readFile(manifestPath, 'utf8');
39
+ const manifest = JSON.parse(manifestText);
40
+ const names = Object.keys({ ...manifest.dependencies, ...manifest.devDependencies }).slice(0, MAX_PACKAGES);
41
+ const installed = await installedVersions(context.cwd, names);
42
+ const answers = await this.deprecations(context.cwd, installed);
43
+ return [...answers].flatMap(([name, message]) => [this.createFailure(`${name}@${installed.get(name)} is deprecated on npm: ${message}`, ['package.json'], `Move to the replacement the deprecation names, or a supported version of ${name}.`, undefined, lineOfDependency(manifestText, name), undefined, 'medium')]);
44
+ }
45
+ /** Deprecation messages for installed versions, from the day's cache and then the registry. */
46
+ async deprecations(cwd, installed) {
47
+ const cachePath = path.join(cwd, CACHE_FILE);
48
+ const cache = await fs.readJson(cachePath).catch(() => ({}));
49
+ const now = Date.now();
50
+ const stale = [...installed].filter(([name, version]) => !(cache[`${name}@${version}`]?.checkedAt > now - CACHE_TTL_MS));
51
+ let unreachable = 0;
52
+ for (let i = 0; i < stale.length; i += CONCURRENCY) {
53
+ await Promise.all(stale.slice(i, i + CONCURRENCY).map(async ([name, version]) => {
54
+ const deprecated = await this.lookup(name, version);
55
+ if (deprecated === undefined)
56
+ unreachable++;
57
+ else
58
+ cache[`${name}@${version}`] = { deprecated, checkedAt: now };
59
+ }));
60
+ }
61
+ if (unreachable)
62
+ Logger.info(`Deprecated dependencies: registry did not answer for ${unreachable} package(s); they are skipped this run.`);
63
+ await fs.outputJson(cachePath, cache, { spaces: 2 }).catch(() => undefined);
64
+ const found = new Map();
65
+ for (const [name, version] of installed) {
66
+ const message = cache[`${name}@${version}`]?.deprecated;
67
+ if (message)
68
+ found.set(name, message);
69
+ }
70
+ return found;
71
+ }
72
+ /** The version's deprecation message, null when it is not deprecated, undefined when the registry did not answer. */
73
+ async lookup(name, version) {
74
+ const registry = (this.config.registry ?? 'https://registry.npmjs.org').replace(/\/+$/, '');
75
+ try {
76
+ const response = await this.fetchImpl(`${registry}/${name.replace('/', '%2F')}/${encodeURIComponent(version)}`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
77
+ if (!response.ok)
78
+ return undefined;
79
+ const body = await response.json();
80
+ return typeof body?.deprecated === 'string' && body.deprecated.trim() ? body.deprecated.trim() : null;
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ }
87
+ /** Installed versions from package-lock.json, else node_modules; names with neither are left out. */
88
+ export async function installedVersions(cwd, names) {
89
+ const lock = await fs.readJson(path.join(cwd, 'package-lock.json')).catch(() => null);
90
+ const versions = new Map();
91
+ for (const name of names) {
92
+ const fromLock = lock?.packages?.[`node_modules/${name}`]?.version ?? lock?.dependencies?.[name]?.version;
93
+ const fromModules = fromLock ? undefined : (await fs.readJson(path.join(cwd, 'node_modules', name, 'package.json')).catch(() => null))?.version;
94
+ const version = fromLock ?? fromModules;
95
+ if (typeof version === 'string' && /^\d+\.\d+\.\d+/.test(version))
96
+ versions.set(name, version);
97
+ }
98
+ return versions;
99
+ }
100
+ /** The package.json line declaring a dependency, so review can tell whether the change touched it. */
101
+ export function lineOfDependency(manifestText, name) {
102
+ const at = manifestText.split('\n').findIndex(line => line.trimStart().startsWith(`${JSON.stringify(name)}:`));
103
+ return at >= 0 ? at + 1 : undefined;
104
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { DeprecatedDependenciesGate, installedVersions, lineOfDependency } from './deprecated-dependencies.js';
6
+ let cwd;
7
+ beforeEach(() => {
8
+ cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'deprecated-deps-'));
9
+ fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ dependencies: { request: '^2.88.0', 'left-pad': '^1.3.0' }, devDependencies: { '@scope/tool': '1.0.0' } }, null, 2));
10
+ fs.writeFileSync(path.join(cwd, 'package-lock.json'), JSON.stringify({ packages: {
11
+ 'node_modules/request': { version: '2.88.2' },
12
+ 'node_modules/@scope/tool': { version: '1.0.0' },
13
+ } }));
14
+ });
15
+ afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
16
+ /** A registry answering per exact version, like registry.npmjs.org/<name>/<version>. */
17
+ const registry = (deprecated) => vi.fn(async (url) => {
18
+ const [, name, version] = /registry\.test\/(.+)\/([^/]+)$/.exec(url);
19
+ return { ok: true, json: async () => ({ version, deprecated: deprecated[`${decodeURIComponent(name)}@${version}`] }) };
20
+ });
21
+ describe('DeprecatedDependenciesGate', () => {
22
+ it('reports the installed version npm deprecated, on its package.json line', async () => {
23
+ const fetch = registry({ 'request@2.88.2': 'request has been deprecated' });
24
+ const failures = await new DeprecatedDependenciesGate({ enabled: true, registry: 'https://registry.test' }, fetch).run({ cwd });
25
+ expect(failures).toEqual([expect.objectContaining({ id: 'deprecated-dependencies', files: ['package.json'], line: 3 })]);
26
+ expect(failures[0].details).toContain('request@2.88.2 is deprecated on npm: request has been deprecated');
27
+ expect(fetch.mock.calls.map(c => c[0])).toEqual(['https://registry.test/request/2.88.2', 'https://registry.test/@scope%2Ftool/1.0.0']);
28
+ });
29
+ it('asks once a day, and says nothing for a version it cannot determine or a registry that does not answer', async () => {
30
+ const fetch = registry({ 'request@2.88.2': 'deprecated' });
31
+ const gate = new DeprecatedDependenciesGate({ enabled: true, registry: 'https://registry.test' }, fetch);
32
+ await gate.run({ cwd });
33
+ await gate.run({ cwd });
34
+ expect(fetch).toHaveBeenCalledTimes(2); // left-pad has no installed version; the second run is cached
35
+ fs.rmSync(path.join(cwd, '.rigour'), { recursive: true });
36
+ const down = vi.fn(async () => { throw new Error('offline'); });
37
+ expect(await new DeprecatedDependenciesGate({ enabled: true }, down).run({ cwd })).toEqual([]);
38
+ });
39
+ it('is off unless enabled', async () => {
40
+ const fetch = registry({});
41
+ expect(await new DeprecatedDependenciesGate({}, fetch).run({ cwd })).toEqual([]);
42
+ expect(fetch).not.toHaveBeenCalled();
43
+ });
44
+ });
45
+ describe('helpers', () => {
46
+ it('reads installed versions from the lockfile, else node_modules', async () => {
47
+ fs.mkdirSync(path.join(cwd, 'node_modules', 'left-pad'), { recursive: true });
48
+ fs.writeFileSync(path.join(cwd, 'node_modules', 'left-pad', 'package.json'), JSON.stringify({ version: '1.3.0' }));
49
+ expect([...(await installedVersions(cwd, ['request', 'left-pad', 'missing']))]).toEqual([['request', '2.88.2'], ['left-pad', '1.3.0']]);
50
+ });
51
+ it('finds the line declaring a dependency', () => {
52
+ expect(lineOfDependency('{\n "dependencies": {\n "request": "^2"\n }\n}', 'request')).toBe(3);
53
+ expect(lineOfDependency('{}', 'request')).toBeUndefined();
54
+ });
55
+ });
@@ -1,5 +1,5 @@
1
1
  import { Gate } from './base.js';
2
- import { Config, Report, DeepOptions } from '../types/index.js';
2
+ import { Failure, Config, Report, DeepOptions } from '../types/index.js';
3
3
  export declare class GateRunner {
4
4
  private config;
5
5
  private gates;
@@ -13,3 +13,9 @@ export declare class GateRunner {
13
13
  onProgress?: (msg: string) => void;
14
14
  }): Promise<Report>;
15
15
  }
16
+ /**
17
+ * One finding per rule, file and line. The title names the rule: a learned rule shares its gate
18
+ * id with the built-in rules, and keying on the gate alone dropped it whenever a built-in rule
19
+ * fired on the same line.
20
+ */
21
+ export declare function dedupeFailures(failures: Failure[]): Failure[];
@@ -29,6 +29,8 @@ import { SideEffectAnalysisGate } from './side-effect-analysis/index.js';
29
29
  import { StyleDriftGate } from './style-drift.js';
30
30
  import { SemanticBugsGate } from './semantic-bugs.js';
31
31
  import { LogicDriftGate } from './logic-drift.js';
32
+ import { UnindexedReadsGate } from './unindexed-reads/index.js';
33
+ import { DeprecatedDependenciesGate } from './deprecated-dependencies.js';
32
34
  import { execa } from 'execa';
33
35
  import { Logger } from '../utils/logger.js';
34
36
  import { FileSystemCache } from '../services/filesystem-cache.js';
@@ -101,6 +103,12 @@ export class GateRunner {
101
103
  if (this.config.gates.phantom_apis?.enabled !== false) {
102
104
  this.gates.push(new PhantomApisGate(this.config.gates.phantom_apis));
103
105
  }
106
+ if (this.config.gates.deprecated_dependencies?.enabled) {
107
+ this.gates.push(new DeprecatedDependenciesGate(this.config.gates.deprecated_dependencies));
108
+ }
109
+ if (this.config.gates.unindexed_reads?.enabled) {
110
+ this.gates.push(new UnindexedReadsGate(this.config.gates.unindexed_reads));
111
+ }
104
112
  if (this.config.gates.deprecated_apis?.enabled !== false) {
105
113
  this.gates.push(new DeprecatedApisGate(this.config.gates.deprecated_apis));
106
114
  }
@@ -212,17 +220,8 @@ export class GateRunner {
212
220
  }
213
221
  const status = failures.length > 0 ? 'FAIL' : 'PASS';
214
222
  // Severity-weighted scoring with per-gate cap and deduplication
215
- // Step 1: Deduplicate — same file + line + gate should not stack
216
- const deduped = [];
217
- const seen = new Set();
218
- for (const f of failures) {
219
- const key = `${f.id}:${(f.files || []).join(',')}:${f.line || 0}`;
220
- if (!seen.has(key)) {
221
- seen.add(key);
222
- deduped.push(f);
223
- }
224
- }
225
- // Replace failures array with deduplicated version
223
+ // Step 1: Deduplicate — the same rule on the same file and line should not stack
224
+ const deduped = dedupeFailures(failures);
226
225
  failures.length = 0;
227
226
  failures.push(...deduped);
228
227
  // Step 2: Calculate per-gate deductions with dynamic cap
@@ -340,3 +339,18 @@ export class GateRunner {
340
339
  return report;
341
340
  }
342
341
  }
342
+ /**
343
+ * One finding per rule, file and line. The title names the rule: a learned rule shares its gate
344
+ * id with the built-in rules, and keying on the gate alone dropped it whenever a built-in rule
345
+ * fired on the same line.
346
+ */
347
+ export function dedupeFailures(failures) {
348
+ const seen = new Set();
349
+ return failures.filter(f => {
350
+ const key = `${f.id}:${f.title}:${(f.files || []).join(',')}:${f.line || 0}`;
351
+ if (seen.has(key))
352
+ return false;
353
+ seen.add(key);
354
+ return true;
355
+ });
356
+ }
@@ -128,7 +128,8 @@ export const VULNERABILITY_PATTERNS = [
128
128
  // ReDoS — Denial of Service via regex (OWASP #7)
129
129
  {
130
130
  type: 'redos',
131
- regex: /new RegExp\s*\([^)]*(?:req\.|params|query|body|input|user)/g,
131
+ // Request data, or a first argument that is exactly an input name; not any argument that merely contains "user" or "query".
132
+ regex: /new RegExp\s*\(\s*(?:[^)]*\b(?:req|request)\.(?:query|params|body)\b|(?:userInput|input|searchTerm|search|query|term|keyword|pattern)\s*[,)])/g,
132
133
  severity: 'high',
133
134
  description: 'Dynamic regex from user input — potential ReDoS',
134
135
  cwe: 'CWE-1333',
@@ -25,6 +25,21 @@ describe('SecurityPatternsGate — OWASP extended patterns', () => {
25
25
  const vulns = await checkSecurityPatterns(filePath);
26
26
  expect(vulns.some(v => v.type === 'redos')).toBe(true);
27
27
  });
28
+ it('detects request data and input-named arguments, not constants that contain those words', async () => {
29
+ const tainted = path.join(testDir, 'tainted.ts');
30
+ fs.writeFileSync(tainted, `
31
+ const a = new RegExp(\`^\${req.body.term}$\`);
32
+ const b = new RegExp(input, 'i');
33
+ `);
34
+ expect((await checkSecurityPatterns(tainted)).filter(v => v.type === 'redos')).toHaveLength(2);
35
+ const constant = path.join(testDir, 'constant.ts');
36
+ fs.writeFileSync(constant, `
37
+ const user = String.raw\`auth\\.uid\\(\\)\`;
38
+ const left = new RegExp(String.raw\`^\${user} ?= ?(\${NAME})$\`, 'i');
39
+ const byQuery = new RegExp(queryPrefix + '$');
40
+ `);
41
+ expect((await checkSecurityPatterns(constant)).filter(v => v.type === 'redos')).toEqual([]);
42
+ });
28
43
  it('should detect nested quantifiers', async () => {
29
44
  const filePath = path.join(testDir, 'regex.ts');
30
45
  fs.writeFileSync(filePath, `
@@ -42,7 +42,7 @@ export class SemanticBugsGate extends Gate {
42
42
  return wanted?.length ? [...ALL_RULES, ...learned].filter(rule => wanted.includes(rule.id)) : [...BUILT_IN_RULES, ...learned];
43
43
  }
44
44
  toFailure(finding) {
45
- const failure = this.createFailure(`${finding.message}\n${finding.evidence.map(e => ` at ${e}`).join('\n')}`, [finding.file], finding.hint, `[${finding.rule}] ${finding.message.split('. ')[0]}`.slice(0, 120), finding.line, undefined, finding.severity);
45
+ const failure = this.createFailure(`${finding.message}\n${finding.evidence.map(e => ` at ${e}`).join('\n')}`, [finding.file], finding.hint, `[${finding.rule}] ${finding.message.split('. ')[0]}`.slice(0, 120), finding.line, finding.endLine, finding.severity);
46
46
  const category = finding.rule.startsWith(LEARNED_PREFIX) ? 'learned-rule' : finding.rule;
47
47
  return { ...failure, provenance: finding.provenance, source: 'ast', category, verified: true, confidence: 1 };
48
48
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { trustedReviews } from './deep-analysis.js';
6
+ let cwd;
7
+ afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
8
+ describe('trustedReviews', () => {
9
+ it('lets a normal run skip what agents recorded as reviewed, and an independent run skip nothing', () => {
10
+ cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'trusted-reviews-'));
11
+ fs.mkdirSync(path.join(cwd, '.rigour'));
12
+ fs.writeFileSync(path.join(cwd, '.rigour', 'reviewed.json'), JSON.stringify({ version: 1, entries: [{ file: 'src/a.ts', function: 'f', hash: 'h', reviewer: 'agent', verdict: 'no_issue' }] }));
13
+ expect(trustedReviews(cwd, {}).map(r => r.function)).toEqual(['f']);
14
+ expect(trustedReviews(cwd, { independent: true })).toEqual([]);
15
+ });
16
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Whether any index on a table can serve a read.
3
+ *
4
+ * The claim is deliberately narrow, so it can be proved from the schema
5
+ * alone: no index starts with a column the read compares to a value or sorts
6
+ * by first, or every index that does is partial with a WHERE the read's
7
+ * filters do not satisfy. `IS NOT NULL` alone is not a comparison: it narrows
8
+ * nothing on a mostly filled column. When a predicate cannot be read, the
9
+ * index counts as serving, so doubt keeps the gate silent.
10
+ */
11
+ import type { IndexDef } from './schema.js';
12
+ import type { Read } from './queries.js';
13
+ /** Columns the read narrows by value or orders by first (lower case). */
14
+ export declare function seekColumns(read: Read): Set<string>;
15
+ export declare function servesRead(index: IndexDef, read: Read): boolean;
@@ -0,0 +1,24 @@
1
+ /** Columns the read narrows by value or orders by first (lower case). */
2
+ export function seekColumns(read) {
3
+ const columns = new Set(read.filters.filter(f => f.op !== 'notnull').map(f => f.column.toLowerCase()));
4
+ if (read.orderBy)
5
+ columns.add(read.orderBy.toLowerCase());
6
+ return columns;
7
+ }
8
+ export function servesRead(index, read) {
9
+ if (index.leading === null || !seekColumns(read).has(index.leading.toLowerCase()))
10
+ return false;
11
+ return index.predicate.every(term => satisfied(term, read.filters));
12
+ }
13
+ /** Whether the read's filters satisfy one partial-index term; an unreadable term is assumed satisfied. */
14
+ function satisfied(term, filters) {
15
+ if (term.kind === 'unknown')
16
+ return true;
17
+ const own = filters.filter(f => f.column.toLowerCase() === term.column.toLowerCase());
18
+ if (term.kind === 'null')
19
+ return own.some(f => f.op === 'isnull');
20
+ // Every operator but IS NULL is strict: a row it matches has a value, so it implies IS NOT NULL.
21
+ if (term.kind === 'notnull')
22
+ return own.some(f => f.op !== 'isnull');
23
+ return own.some(f => f.op === 'eq' && (f.value === undefined || f.value === term.value)) || own.some(f => f.op === 'in' || f.op === 'match');
24
+ }
@@ -0,0 +1,20 @@
1
+ import { Gate, type GateContext } from '../base.js';
2
+ import type { Failure } from '../../types/index.js';
3
+ import { type Schema } from './schema.js';
4
+ import { type Read } from './queries.js';
5
+ export interface UnindexedReadsConfig {
6
+ enabled?: boolean;
7
+ /** Globs for migration .sql files; each directory is replayed as its own database. */
8
+ migrations?: string[];
9
+ }
10
+ export declare class UnindexedReadsGate extends Gate {
11
+ private config;
12
+ constructor(config?: UnindexedReadsConfig);
13
+ run(context: GateContext): Promise<Failure[]>;
14
+ private finding;
15
+ }
16
+ /**
17
+ * The columns the read narrows by (its own and the table's policy filters) when
18
+ * no migration set can serve it; undefined when one can, or when that is not provable.
19
+ */
20
+ export declare function unservedColumns(read: Read, schemas: Schema[]): string[] | undefined;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Unindexed read: a supabase-js read whose table, as the repository's own
3
+ * migrations define it, has no index that can serve it (coverage.ts).
4
+ *
5
+ * Advisory and off by default. It speaks only when the claim is provable:
6
+ * the read is fully literal, its table is created in the migrations, and no
7
+ * DDL touching that table is beyond what schema.ts models. A small table
8
+ * read without an index is fine; dismiss the finding for it.
9
+ */
10
+ import fs from 'fs-extra';
11
+ import path from 'path';
12
+ import { Gate } from '../base.js';
13
+ import { FileScanner } from '../../utils/scanner.js';
14
+ import { Logger } from '../../utils/logger.js';
15
+ import { policyFilters, replayMigrations } from './schema.js';
16
+ import { findReads } from './queries.js';
17
+ import { seekColumns, servesRead } from './coverage.js';
18
+ const DEFAULT_MIGRATIONS = ['**/migrations/**/*.sql'];
19
+ const SOURCE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
20
+ const SKIP = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.test.*', '**/*.spec.*', '**/__tests__/**', '**/*.d.ts'];
21
+ export class UnindexedReadsGate extends Gate {
22
+ config;
23
+ constructor(config = {}) {
24
+ super('unindexed-reads', 'Unindexed read');
25
+ this.config = config;
26
+ }
27
+ async run(context) {
28
+ if (!this.config.enabled)
29
+ return [];
30
+ const schemas = await loadSchemas(context.cwd, this.config.migrations?.length ? this.config.migrations : DEFAULT_MIGRATIONS, context.ignore ?? []);
31
+ if (schemas.length === 0)
32
+ return [];
33
+ const files = (await FileScanner.findFiles({ cwd: context.cwd, patterns: context.patterns, ignore: [...(context.ignore ?? []), ...SKIP] }))
34
+ .filter(file => SOURCE.test(file));
35
+ Logger.info(`Unindexed reads: ${files.length} files against ${schemas.length} migration set(s)`);
36
+ const failures = [];
37
+ for (const file of files) {
38
+ const content = await fs.readFile(path.join(context.cwd, file), 'utf-8');
39
+ if (!content.includes('.from('))
40
+ continue;
41
+ for (const read of findReads(file, content)) {
42
+ const columns = unservedColumns(read, schemas);
43
+ if (columns)
44
+ failures.push(this.finding(file, read, columns));
45
+ }
46
+ }
47
+ return failures;
48
+ }
49
+ finding(file, read, columns) {
50
+ const list = columns.map(c => `\`${c}\``).join(', ');
51
+ return this.createFailure(`No index on \`${read.table}\` starts with a column this read compares to a value or sorts by first, including any its row-level policy adds (${list}), and none of its partial indexes match the read's filters, so Postgres cannot seek to these rows.`, [file], `Add an index in a new migration that leads with the most selective of ${list}, in the read's sort order. If the table stays small, dismiss this finding.`, undefined, read.line, undefined, 'medium');
52
+ }
53
+ }
54
+ /**
55
+ * The columns the read narrows by (its own and the table's policy filters) when
56
+ * no migration set can serve it; undefined when one can, or when that is not provable.
57
+ */
58
+ export function unservedColumns(read, schemas) {
59
+ if (read.uncertain || seekColumns(read).size === 0)
60
+ return undefined;
61
+ const tables = schemas.map(schema => schema.get(read.table)).filter(table => table !== undefined);
62
+ if (tables.length === 0 || tables.some(table => table.uncertain || policyFilters(table).unknown))
63
+ return undefined;
64
+ const columns = new Set();
65
+ for (const table of tables) {
66
+ const filtered = withPolicyFilters(read, policyFilters(table).columns);
67
+ if (table.indexes.some(index => servesRead(index, filtered)))
68
+ return undefined;
69
+ for (const column of seekColumns(filtered))
70
+ columns.add(column);
71
+ }
72
+ return [...columns];
73
+ }
74
+ /** The read as Postgres runs it for a signed-in user: the policy's columns are compared too. */
75
+ function withPolicyFilters(read, columns) {
76
+ return { ...read, filters: [...read.filters, ...columns.map(column => ({ column, op: 'eq', value: undefined }))] };
77
+ }
78
+ /** One schema per migrations directory, replayed in filename order. */
79
+ async function loadSchemas(cwd, globs, ignore) {
80
+ const files = await FileScanner.findFiles({ cwd, patterns: globs, ignore });
81
+ const byDirectory = new Map();
82
+ for (const file of files) {
83
+ const directory = migrationsRoot(file);
84
+ byDirectory.set(directory, [...(byDirectory.get(directory) ?? []), file]);
85
+ }
86
+ const schemas = [];
87
+ for (const group of byDirectory.values()) {
88
+ const sql = await Promise.all(group.sort().map(file => fs.readFile(path.join(cwd, file), 'utf-8')));
89
+ schemas.push(replayMigrations(sql));
90
+ }
91
+ return schemas;
92
+ }
93
+ /** The directory a migration belongs to: up to and including its `migrations` segment, else its own folder. */
94
+ function migrationsRoot(file) {
95
+ const parts = file.split('/');
96
+ const at = parts.lastIndexOf('migrations');
97
+ return at >= 0 ? parts.slice(0, at + 1).join('/') : path.posix.dirname(file);
98
+ }
@@ -0,0 +1,17 @@
1
+ export type Filter = {
2
+ column: string;
3
+ op: 'eq';
4
+ value: string | undefined;
5
+ } | {
6
+ column: string;
7
+ op: 'in' | 'range' | 'isnull' | 'notnull' | 'match';
8
+ };
9
+ export interface Read {
10
+ table: string;
11
+ line: number;
12
+ filters: Filter[];
13
+ /** First `.order()` column: the only one an index can return rows in. */
14
+ orderBy?: string;
15
+ uncertain: boolean;
16
+ }
17
+ export declare function findReads(fileName: string, content: string): Read[];
@@ -0,0 +1,147 @@
1
+ /**
2
+ * supabase-js reads in a TypeScript or JavaScript file: `.from('table')`
3
+ * followed by `.select(…)` and filters, as one call chain.
4
+ *
5
+ * A read is only modelled when every part of it is literal: the table, the
6
+ * filter columns, and the methods. Anything else (a computed table name,
7
+ * `.or(…)`, a filter on an embedded resource, a method this does not know)
8
+ * makes the read `uncertain`, and the gate says nothing about it.
9
+ */
10
+ import ts from 'typescript';
11
+ const RANGE = new Set(['gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'likeAllOf', 'likeAnyOf', 'ilikeAllOf', 'ilikeAnyOf']);
12
+ /** Operators a GIN or GiST index could serve: they keep the read silent rather than prove anything. */
13
+ const MATCH = new Set(['contains', 'containedBy', 'overlaps', 'textSearch', 'rangeGt', 'rangeGte', 'rangeLt', 'rangeLte', 'rangeAdjacent']);
14
+ const NEUTRAL = new Set(['select', 'limit', 'range', 'single', 'maybeSingle', 'returns', 'abortSignal', 'csv', 'geojson', 'explain', 'throwOnError', 'setHeader', 'then', 'neq', 'overrideTypes']);
15
+ const WRITES = new Set(['insert', 'update', 'upsert', 'delete']);
16
+ export function findReads(fileName, content) {
17
+ const source = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
18
+ const reads = [];
19
+ const visit = (node) => {
20
+ const read = readAt(source, node);
21
+ if (read)
22
+ reads.push(read);
23
+ ts.forEachChild(node, visit);
24
+ };
25
+ visit(source);
26
+ return reads;
27
+ }
28
+ /** A read rooted at this `.from(…)` call, or undefined when the node is not one. */
29
+ function readAt(source, node) {
30
+ if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== 'from')
31
+ return undefined;
32
+ const tableArg = node.arguments[0];
33
+ const schema = schemaOf(node.expression.expression);
34
+ const chain = callsAfter(node);
35
+ if (!chain.some(call => call.method === 'select') || chain.some(call => WRITES.has(call.method)))
36
+ return undefined;
37
+ const line = source.getLineAndCharacterOfPosition(node.expression.name.getStart(source)).line + 1;
38
+ const literalTable = tableArg && ts.isStringLiteralLike(tableArg) ? tableArg.text : undefined;
39
+ const read = { table: literalTable ? qualified(schema, literalTable) : '', line, filters: [], uncertain: !literalTable || schema === null };
40
+ for (const call of chain)
41
+ applyCall(read, call);
42
+ return read;
43
+ }
44
+ /** Calls chained onto `node`: `.from(…).select(…).eq(…)` gives select, eq. */
45
+ function callsAfter(node) {
46
+ const calls = [];
47
+ let current = node;
48
+ while (ts.isPropertyAccessExpression(current.parent) && ts.isCallExpression(current.parent.parent) && current.parent.parent.expression === current.parent) {
49
+ calls.push({ method: current.parent.name.text, args: current.parent.parent.arguments });
50
+ current = current.parent.parent;
51
+ }
52
+ return calls;
53
+ }
54
+ /** `client.schema('s').from(…)`: the schema name; undefined for the default; null when it is not a literal. */
55
+ function schemaOf(target) {
56
+ if (!ts.isCallExpression(target) || !ts.isPropertyAccessExpression(target.expression) || target.expression.name.text !== 'schema')
57
+ return undefined;
58
+ const arg = target.arguments[0];
59
+ return arg && ts.isStringLiteralLike(arg) ? arg.text : null;
60
+ }
61
+ function qualified(schema, table) {
62
+ const name = table.toLowerCase();
63
+ return schema && schema !== 'public' ? `${schema.toLowerCase()}.${name}` : name;
64
+ }
65
+ function applyCall(read, call) {
66
+ const { method, args } = call;
67
+ if (NEUTRAL.has(method))
68
+ return;
69
+ if (method === 'order') {
70
+ if (read.orderBy === undefined)
71
+ read.orderBy = columnOf(read, args[0]);
72
+ return;
73
+ }
74
+ if (method === 'match')
75
+ return applyMatch(read, args[0]);
76
+ if (method === 'not')
77
+ return applyNot(read, args);
78
+ if (method === 'filter')
79
+ return applyFilter(read, args);
80
+ const column = columnOf(read, args[0]);
81
+ if (column === undefined)
82
+ return;
83
+ if (method === 'eq')
84
+ read.filters.push({ column, op: 'eq', value: literal(args[1]) });
85
+ else if (method === 'in')
86
+ read.filters.push({ column, op: 'in' });
87
+ else if (method === 'is')
88
+ read.filters.push(args[1]?.kind === ts.SyntaxKind.NullKeyword ? { column, op: 'isnull' } : { column, op: 'eq', value: literal(args[1]) });
89
+ else if (RANGE.has(method))
90
+ read.filters.push({ column, op: 'range' });
91
+ else if (MATCH.has(method))
92
+ read.filters.push({ column, op: 'match' });
93
+ else
94
+ read.uncertain = true;
95
+ }
96
+ /** `.not('col', 'is', null)` is IS NOT NULL; any other negation filters nothing an index can seek. */
97
+ function applyNot(read, args) {
98
+ const column = columnOf(read, args[0]);
99
+ const op = args[1] && ts.isStringLiteralLike(args[1]) ? args[1].text : undefined;
100
+ if (column !== undefined && op === 'is' && args[2]?.kind === ts.SyntaxKind.NullKeyword)
101
+ read.filters.push({ column, op: 'notnull' });
102
+ else if (op === undefined)
103
+ read.uncertain = true;
104
+ }
105
+ /** `.filter('col', 'eq', v)`: the same as the named method when the operator is a literal. */
106
+ function applyFilter(read, args) {
107
+ const op = args[1] && ts.isStringLiteralLike(args[1]) ? args[1].text : undefined;
108
+ if (op === undefined || !['eq', 'in', 'is', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike'].includes(op)) {
109
+ read.uncertain = true;
110
+ return;
111
+ }
112
+ applyCall(read, { method: op, args: [args[0], args[2]] });
113
+ }
114
+ /** `.match({ a: 1, b: 'x' })`: equality on each key of an object literal. */
115
+ function applyMatch(read, arg) {
116
+ if (!arg || !ts.isObjectLiteralExpression(arg)) {
117
+ read.uncertain = true;
118
+ return;
119
+ }
120
+ for (const property of arg.properties) {
121
+ const name = ts.isPropertyAssignment(property) && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : undefined;
122
+ if (name === undefined || name.includes('.'))
123
+ read.uncertain = true;
124
+ else
125
+ read.filters.push({ column: name.toLowerCase(), op: 'eq', value: literal(property.initializer) });
126
+ }
127
+ }
128
+ /** A literal column on the read's own table; a computed or embedded (`other.col`) column makes the read uncertain. */
129
+ function columnOf(read, arg) {
130
+ if (!arg || !ts.isStringLiteralLike(arg) || arg.text.includes('.') || arg.text.includes('->')) {
131
+ read.uncertain = true;
132
+ return undefined;
133
+ }
134
+ return arg.text.toLowerCase();
135
+ }
136
+ /** A literal value as text, or undefined when it is computed at run time. */
137
+ function literal(arg) {
138
+ if (!arg)
139
+ return undefined;
140
+ if (ts.isStringLiteralLike(arg) || ts.isNumericLiteral(arg))
141
+ return arg.text;
142
+ if (arg.kind === ts.SyntaxKind.TrueKeyword)
143
+ return 'true';
144
+ if (arg.kind === ts.SyntaxKind.FalseKeyword)
145
+ return 'false';
146
+ return undefined;
147
+ }
@@ -0,0 +1 @@
1
+ export {};