@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
package/README.md CHANGED
@@ -46,7 +46,7 @@ Rigour's deep analysis is not a wrapper around a generic LLM. The model operates
46
46
  2. **Interpret** — the model receives structured facts (not raw source), focuses on SOLID, design patterns, language idioms, architecture. Constrained input prevents hallucination.
47
47
  3. **Verify** — every LLM finding is cross-referenced against all five signal streams. Wrong line numbers, phantom patterns, non-existent functions → discarded. Only verified findings with confidence scores reach the report.
48
48
 
49
- Both model tiers (lite sidecar + pro code-specialized) are fine-tuned via the [DriftBench RLAIF pipeline](https://github.com/rigour-labs/driftbench) where the five signal streams serve as the teacher signal.
49
+ The lite and deep tiers are fine-tuned via the [DriftBench RLAIF pipeline](https://github.com/rigour-labs/driftbench), where the five signal streams serve as the teacher signal. The max tier (Qwen2.5-Coder-7B) runs the stock model until a fine-tune passes the evaluation gate.
50
50
 
51
51
  ### Temporal Drift Engine (v5.1)
52
52
 
@@ -8,6 +8,11 @@ export interface AutomaticIndexStatus {
8
8
  updatedAt: string;
9
9
  }
10
10
  export declare function mergeSemanticEmbeddings(latest: PatternIndex, enriched: PatternIndex): PatternIndex;
11
+ /**
12
+ * What an explicit `rigour index` (CLI or MCP) chose, so automatic indexing and Studio's health
13
+ * agree with it: 'disabled' for --no-semantic, else ready or degraded by what was embedded.
14
+ */
15
+ export declare function recordIndexChoice(cwd: string, index: PatternIndex, semantic: boolean): Promise<void>;
11
16
  export declare function getAutomaticIndexStatus(cwd: string): Promise<AutomaticIndexStatus>;
12
17
  export declare function ensureAutomaticIndex(cwd: string, options?: {
13
18
  backgroundSemantic?: boolean;
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import { PatternIndexer, getDefaultIndexPath, loadPatternIndex, savePatternIndex } from '../pattern-index/indexer.js';
4
+ import { EMBEDDING_TEXT_VERSION } from '../pattern-index/embeddings.js';
4
5
  import { affectedDependents, buildDependencyGraph, updateDependencyGraph } from './dependency-graph.js';
5
6
  import { syncIndexToContextCache } from './index-bridge.js';
6
7
  const enrichmentJobs = new Map();
@@ -33,15 +34,35 @@ async function writeStatus(cwd, status) {
33
34
  await fs.ensureDir(path.join(cwd, '.rigour'));
34
35
  await fs.writeJson(statusPath(cwd), status, { spaces: 2 });
35
36
  }
37
+ /** Embedded, and from the current embedding text (an older one is re-embedded on enrichment). */
38
+ function hasCurrentEmbeddings(index) {
39
+ return index.embeddingTextVersion === EMBEDDING_TEXT_VERSION
40
+ && index.patterns.some(pattern => (pattern.embedding?.length ?? 0) > 0);
41
+ }
42
+ /**
43
+ * What an explicit `rigour index` (CLI or MCP) chose, so automatic indexing and Studio's health
44
+ * agree with it: 'disabled' for --no-semantic, else ready or degraded by what was embedded.
45
+ */
46
+ export async function recordIndexChoice(cwd, index, semantic) {
47
+ const embedded = index.patterns.filter(pattern => (pattern.embedding?.length ?? 0) > 0).length;
48
+ await writeStatus(cwd, {
49
+ structural: 'ready',
50
+ graph: 'ready',
51
+ semantic: !semantic ? 'disabled' : embedded > 0 ? 'ready' : 'degraded',
52
+ ...(semantic && embedded === 0 ? { message: 'Local embedding model unavailable; text matching remains active.' } : {}),
53
+ updatedAt: new Date().toISOString(),
54
+ });
55
+ }
36
56
  export async function getAutomaticIndexStatus(cwd) {
37
57
  try {
38
58
  return await fs.readJson(statusPath(cwd));
39
59
  }
40
60
  catch {
61
+ // No status yet is not an opt-out: only `rigour index --no-semantic` records 'disabled'.
41
62
  return {
42
63
  structural: 'missing',
43
64
  graph: 'missing',
44
- semantic: 'disabled',
65
+ semantic: 'warming',
45
66
  updatedAt: new Date().toISOString(),
46
67
  };
47
68
  }
@@ -97,7 +118,7 @@ export async function ensureAutomaticIndex(cwd, options = {}) {
97
118
  if (!(await fs.pathExists(path.join(cwd, '.rigour', 'dependency-graph.json')))) {
98
119
  await buildDependencyGraph(cwd, index);
99
120
  }
100
- const hasEmbeddings = index.patterns.some((pattern) => (pattern.embedding?.length ?? 0) > 0);
121
+ const hasEmbeddings = hasCurrentEmbeddings(index);
101
122
  await writeStatus(cwd, {
102
123
  structural: 'ready',
103
124
  graph: 'ready',
@@ -130,7 +151,7 @@ export async function updateAutomaticIndexForFiles(cwd, changedFiles) {
130
151
  await buildDependencyGraph(cwd, index);
131
152
  await writeStatus(cwd, {
132
153
  structural: 'ready', graph: 'ready',
133
- semantic: index.patterns.some(pattern => (pattern.embedding?.length ?? 0) > 0)
154
+ semantic: hasCurrentEmbeddings(index)
134
155
  ? 'ready'
135
156
  : previousStatus.semantic === 'disabled' ? 'disabled' : 'warming',
136
157
  message: `${normalizedChangedFiles.length} changed file(s); ${affectedFiles.length} affected file(s) refreshed without a full scan.`,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
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 { getAutomaticIndexStatus, recordIndexChoice } from './automatic-index.js';
6
+ let cwd;
7
+ beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'index-status-')); });
8
+ afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
9
+ const index = (embedded) => ({
10
+ version: '1.0.0', lastUpdated: '', rootDir: cwd, files: [],
11
+ patterns: [{ id: 'a', type: 'function', name: 'a', file: 'a.ts', line: 1, endLine: 1, signature: '', description: '', keywords: [], hash: 'a', exported: true, usageCount: 0, indexedAt: '', ...(embedded ? { embedding: [0.1] } : {}) }],
12
+ stats: { totalPatterns: 1, totalFiles: 1, byType: {}, indexDurationMs: 0 },
13
+ });
14
+ describe('semantic index status', () => {
15
+ it('treats no status yet as warming, not as an opt-out', async () => {
16
+ expect((await getAutomaticIndexStatus(cwd)).semantic).toBe('warming');
17
+ });
18
+ it('records what an explicit index chose, so automatic indexing and Studio agree', async () => {
19
+ await recordIndexChoice(cwd, index(false), false);
20
+ expect((await getAutomaticIndexStatus(cwd)).semantic).toBe('disabled');
21
+ await recordIndexChoice(cwd, index(true), true);
22
+ expect((await getAutomaticIndexStatus(cwd)).semantic).toBe('ready');
23
+ await recordIndexChoice(cwd, index(false), true);
24
+ expect((await getAutomaticIndexStatus(cwd)).semantic).toBe('degraded');
25
+ });
26
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { dedupeFailures } from './runner.js';
3
+ const finding = (title, line = 3) => ({ id: 'semantic-bugs', title, details: '', severity: 'high', provenance: 'traditional', files: ['src/a.ts'], line });
4
+ describe('dedupeFailures', () => {
5
+ it('keeps a learned rule that fires on the same line as a built-in rule of the same gate', () => {
6
+ const kept = dedupeFailures([finding('credential-redirect'), finding('learned-rule: require-option-redirect')]);
7
+ expect(kept.map(f => f.title)).toEqual(['credential-redirect', 'learned-rule: require-option-redirect']);
8
+ });
9
+ it('still drops the same rule reported twice on one line', () => {
10
+ expect(dedupeFailures([finding('credential-redirect'), finding('credential-redirect'), finding('credential-redirect', 9)])).toHaveLength(2);
11
+ });
12
+ });
@@ -0,0 +1,27 @@
1
+ import { Gate, type GateContext } from './base.js';
2
+ import type { Failure } from '../types/index.js';
3
+ export interface DeprecatedDependenciesConfig {
4
+ enabled?: boolean;
5
+ registry?: string;
6
+ }
7
+ type Fetch = (url: string, init?: {
8
+ signal?: AbortSignal;
9
+ }) => Promise<{
10
+ ok: boolean;
11
+ json(): Promise<any>;
12
+ }>;
13
+ export declare class DeprecatedDependenciesGate extends Gate {
14
+ private config;
15
+ private fetchImpl;
16
+ constructor(config?: DeprecatedDependenciesConfig, fetchImpl?: Fetch);
17
+ run(context: GateContext): Promise<Failure[]>;
18
+ /** Deprecation messages for installed versions, from the day's cache and then the registry. */
19
+ private deprecations;
20
+ /** The version's deprecation message, null when it is not deprecated, undefined when the registry did not answer. */
21
+ private lookup;
22
+ }
23
+ /** Installed versions from package-lock.json, else node_modules; names with neither are left out. */
24
+ export declare function installedVersions(cwd: string, names: string[]): Promise<Map<string, string>>;
25
+ /** The package.json line declaring a dependency, so review can tell whether the change touched it. */
26
+ export declare function lineOfDependency(manifestText: string, name: string): number | undefined;
27
+ export {};
@@ -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,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[];