@rigour-labs/core 6.5.1 → 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.
- package/README.md +1 -1
- package/dist/context/automatic-index.d.ts +5 -0
- package/dist/context/automatic-index.js +24 -3
- package/dist/context/index-status.test.d.ts +1 -0
- package/dist/context/index-status.test.js +26 -0
- package/dist/deep/fact-extractor.js +2 -1
- package/dist/deep/fact-extractor.test.js +2 -1
- package/dist/gates/ast.js +4 -2
- package/dist/gates/coverage.js +4 -2
- package/dist/gates/dedupe-failures.test.d.ts +1 -0
- package/dist/gates/dedupe-failures.test.js +12 -0
- package/dist/gates/deprecated-dependencies.d.ts +27 -0
- package/dist/gates/deprecated-dependencies.js +104 -0
- package/dist/gates/deprecated-dependencies.test.d.ts +1 -0
- package/dist/gates/deprecated-dependencies.test.js +55 -0
- package/dist/gates/runner.d.ts +7 -1
- package/dist/gates/runner.js +25 -11
- package/dist/gates/security-patterns-data.js +2 -1
- package/dist/gates/security-patterns-owasp.test.js +15 -0
- package/dist/gates/semantic-bugs.js +1 -1
- package/dist/gates/unindexed-reads/coverage.d.ts +15 -0
- package/dist/gates/unindexed-reads/coverage.js +24 -0
- package/dist/gates/unindexed-reads/index.d.ts +20 -0
- package/dist/gates/unindexed-reads/index.js +98 -0
- package/dist/gates/unindexed-reads/queries.d.ts +17 -0
- package/dist/gates/unindexed-reads/queries.js +147 -0
- package/dist/gates/unindexed-reads/queries.test.d.ts +1 -0
- package/dist/gates/unindexed-reads/queries.test.js +54 -0
- package/dist/gates/unindexed-reads/schema.d.ts +38 -0
- package/dist/gates/unindexed-reads/schema.js +256 -0
- package/dist/gates/unindexed-reads/schema.test.d.ts +1 -0
- package/dist/gates/unindexed-reads/schema.test.js +81 -0
- package/dist/gates/unindexed-reads/sql.d.ts +21 -0
- package/dist/gates/unindexed-reads/sql.js +131 -0
- package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +1 -0
- package/dist/gates/unindexed-reads/unindexed-reads.test.js +90 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/inference/brain-placeholder.test.d.ts +1 -0
- package/dist/inference/brain-placeholder.test.js +28 -0
- package/dist/inference/llama-engine-checksum.test.d.ts +1 -0
- package/dist/inference/llama-engine-checksum.test.js +27 -0
- package/dist/inference/llama-engine.d.ts +4 -1
- package/dist/inference/llama-engine.js +15 -8
- package/dist/memory/recall.d.ts +37 -0
- package/dist/memory/recall.js +93 -0
- package/dist/memory/recall.test.d.ts +1 -0
- package/dist/memory/recall.test.js +36 -0
- package/dist/pattern-index/assess.d.ts +16 -0
- package/dist/pattern-index/assess.js +37 -0
- package/dist/pattern-index/embeddings.d.ts +19 -0
- package/dist/pattern-index/embeddings.js +16 -0
- package/dist/pattern-index/index.d.ts +3 -2
- package/dist/pattern-index/index.js +4 -2
- package/dist/pattern-index/indexer.js +19 -9
- package/dist/pattern-index/matcher.d.ts +6 -0
- package/dist/pattern-index/matcher.js +13 -3
- package/dist/pattern-index/pattern-reuse.test.d.ts +1 -0
- package/dist/pattern-index/pattern-reuse.test.js +76 -0
- package/dist/pattern-index/staleness.d.ts +2 -15
- package/dist/pattern-index/staleness.js +1 -78
- package/dist/pattern-index/types.d.ts +2 -0
- package/dist/review/agent-fixes.d.ts +4 -0
- package/dist/review/agent-fixes.js +10 -7
- package/dist/review/agent-fixes.test.js +4 -4
- package/dist/review/changed-lines.d.ts +2 -1
- package/dist/review/changed-lines.js +12 -2
- package/dist/review/check-outcomes.d.ts +31 -0
- package/dist/review/check-outcomes.js +81 -0
- package/dist/review/check-outcomes.test.d.ts +1 -0
- package/dist/review/check-outcomes.test.js +56 -0
- package/dist/review/migration-order.d.ts +5 -0
- package/dist/review/migration-order.js +70 -0
- package/dist/review/migration-order.test.d.ts +1 -0
- package/dist/review/migration-order.test.js +62 -0
- package/dist/review/quiet.d.ts +2 -0
- package/dist/review/quiet.js +12 -3
- package/dist/review/review.d.ts +4 -0
- package/dist/review/review.js +17 -5
- package/dist/review/review.test.js +36 -1
- package/dist/semantic/ast.d.ts +1 -0
- package/dist/semantic/ast.js +4 -0
- package/dist/semantic/learn/compile.js +3 -1
- package/dist/semantic/learn/learn.js +40 -5
- package/dist/semantic/types.d.ts +2 -0
- package/dist/services/context-scope-summary.test.d.ts +1 -0
- package/dist/services/context-scope-summary.test.js +17 -0
- package/dist/services/context-telemetry-service.js +2 -1
- package/dist/storage/context-telemetry.js +33 -19
- package/dist/storage/db.js +52 -1
- package/dist/storage/findings.js +1 -1
- package/dist/storage/fix-lessons.d.ts +13 -0
- package/dist/storage/fix-lessons.js +84 -0
- package/dist/storage/fix-lessons.test.d.ts +1 -0
- package/dist/storage/fix-lessons.test.js +61 -0
- package/dist/storage/index.d.ts +2 -1
- package/dist/storage/index.js +2 -1
- package/dist/storage/lessons.d.ts +16 -2
- package/dist/storage/lessons.js +27 -61
- package/dist/storage/lessons.test.js +1 -15
- package/dist/storage/local-memory.js +39 -5
- package/dist/storage/local-memory.test.d.ts +1 -0
- package/dist/storage/local-memory.test.js +55 -0
- package/dist/storage/patterns.d.ts +1 -1
- package/dist/storage/patterns.js +1 -1
- package/dist/storage/share-memory.test.d.ts +1 -0
- package/dist/storage/share-memory.test.js +34 -0
- package/dist/storage/team-vector-store.js +2 -1
- package/dist/storage/telemetry-scope.test.d.ts +1 -0
- package/dist/storage/telemetry-scope.test.js +38 -0
- package/dist/templates/universal-config.js +11 -0
- package/dist/types/index.d.ts +132 -0
- package/dist/types/index.js +15 -0
- package/dist/utils/glob-paths.d.ts +4 -0
- package/dist/utils/glob-paths.js +26 -0
- package/dist/utils/glob-paths.test.d.ts +1 -0
- package/dist/utils/glob-paths.test.js +40 -0
- package/dist/utils/scanner.js +4 -2
- package/dist/utils/scanner.test.js +2 -1
- 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
|
-
|
|
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: '
|
|
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
|
|
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
|
|
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
|
+
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'fs-extra';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import { globby } from 'globby';
|
|
9
|
+
import { directoriesAsGlobs, withDirectoryForms } from '../utils/glob-paths.js';
|
|
9
10
|
const ANALYZABLE_EXTENSION = /\.(ts|js|tsx|jsx|py|go|rs|cs|java|rb|kt)$/i;
|
|
10
11
|
/**
|
|
11
12
|
* Lightweight regex-based fact extraction.
|
|
@@ -20,7 +21,7 @@ export async function extractFacts(cwd, ignore, scope) {
|
|
|
20
21
|
'**/.git/**', '**/vendor/**', '**/__pycache__/**',
|
|
21
22
|
'**/*.min.js', '**/*.bundle.js',
|
|
22
23
|
];
|
|
23
|
-
const matched = await globby(patterns, { cwd, ignore: ignorePatterns, followSymbolicLinks: false });
|
|
24
|
+
const matched = await globby(directoriesAsGlobs(cwd, patterns), { cwd, ignore: withDirectoryForms(ignorePatterns), expandDirectories: false, followSymbolicLinks: false });
|
|
24
25
|
const files = matched.filter(f => ANALYZABLE_EXTENSION.test(f));
|
|
25
26
|
const allFacts = [];
|
|
26
27
|
for (const file of files) {
|
|
@@ -3,7 +3,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
3
3
|
// But we need to test extraction logic directly, so we also test via the public API
|
|
4
4
|
const mockGlobby = vi.hoisted(() => vi.fn());
|
|
5
5
|
const mockReadFile = vi.hoisted(() => vi.fn());
|
|
6
|
-
vi.mock('globby', () => ({
|
|
6
|
+
vi.mock('globby', async (importOriginal) => ({
|
|
7
|
+
isDynamicPattern: (await importOriginal()).isDynamicPattern,
|
|
7
8
|
globby: mockGlobby,
|
|
8
9
|
}));
|
|
9
10
|
vi.mock('fs-extra', () => ({
|
package/dist/gates/ast.js
CHANGED
|
@@ -5,6 +5,7 @@ import { Gate } from './base.js';
|
|
|
5
5
|
import { TypeScriptHandler } from './ast-handlers/typescript.js';
|
|
6
6
|
import { PythonHandler } from './ast-handlers/python.js';
|
|
7
7
|
import { UniversalASTHandler } from './ast-handlers/universal.js';
|
|
8
|
+
import { directoriesAsGlobs, withDirectoryForms } from '../utils/glob-paths.js';
|
|
8
9
|
export class ASTGate extends Gate {
|
|
9
10
|
config;
|
|
10
11
|
handlers = [];
|
|
@@ -35,9 +36,10 @@ export class ASTGate extends Gate {
|
|
|
35
36
|
const ignore = [...new Set([...(context.ignore || []), ...defaultIgnore])].map(p => p.replace(/\\/g, '/'));
|
|
36
37
|
const normalizedCwd = context.cwd.replace(/\\/g, '/');
|
|
37
38
|
// Find all supported files
|
|
38
|
-
const files = await globby(patterns, {
|
|
39
|
+
const files = await globby(directoriesAsGlobs(normalizedCwd, patterns), {
|
|
39
40
|
cwd: normalizedCwd,
|
|
40
|
-
ignore: ignore,
|
|
41
|
+
ignore: withDirectoryForms(ignore),
|
|
42
|
+
expandDirectories: false,
|
|
41
43
|
});
|
|
42
44
|
// Process files concurrently in batches for performance
|
|
43
45
|
const CONCURRENCY = 16;
|
package/dist/gates/coverage.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import readline from 'node:readline';
|
|
4
4
|
import { Gate } from './base.js';
|
|
5
5
|
import { globby } from 'globby';
|
|
6
|
+
import { withDirectoryForms } from '../utils/glob-paths.js';
|
|
6
7
|
export class CoverageGate extends Gate {
|
|
7
8
|
config;
|
|
8
9
|
constructor(config) {
|
|
@@ -62,7 +63,8 @@ export class CoverageGate extends Gate {
|
|
|
62
63
|
followSymbolicLinks: false,
|
|
63
64
|
onlyFiles: true,
|
|
64
65
|
deep: 8,
|
|
65
|
-
|
|
66
|
+
expandDirectories: false,
|
|
67
|
+
ignore: withDirectoryForms([...new Set([
|
|
66
68
|
...(context.ignore || []),
|
|
67
69
|
'**/node_modules/**',
|
|
68
70
|
'**/.git/**',
|
|
@@ -75,7 +77,7 @@ export class CoverageGate extends Gate {
|
|
|
75
77
|
'**/vendor/**',
|
|
76
78
|
'**/.venv/**',
|
|
77
79
|
'**/venv/**',
|
|
78
|
-
])],
|
|
80
|
+
])]),
|
|
79
81
|
});
|
|
80
82
|
if (reports.length === 0)
|
|
81
83
|
return null;
|
|
@@ -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
|
+
});
|
package/dist/gates/runner.d.ts
CHANGED
|
@@ -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[];
|
package/dist/gates/runner.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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;
|