@rigour-labs/core 6.4.0 → 6.4.1
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/dist/deep/changed-functions.d.ts +15 -0
- package/dist/deep/changed-functions.js +34 -0
- package/dist/deep/code-pass.d.ts +10 -0
- package/dist/deep/code-pass.js +37 -2
- package/dist/deep/code-pass.test.d.ts +1 -0
- package/dist/deep/code-pass.test.js +69 -0
- package/dist/deep/code-review-prompt.d.ts +1 -1
- package/dist/deep/code-review-prompt.js +3 -2
- package/dist/deep/diff-tests/calls.d.ts +8 -0
- package/dist/deep/diff-tests/calls.js +85 -0
- package/dist/deep/diff-tests/calls.test.d.ts +1 -0
- package/dist/deep/diff-tests/calls.test.js +33 -0
- package/dist/deep/diff-tests/run.d.ts +21 -0
- package/dist/deep/diff-tests/run.js +178 -0
- package/dist/deep/diff-tests/run.test.d.ts +1 -0
- package/dist/deep/diff-tests/run.test.js +58 -0
- package/dist/deep/reference-pack.d.ts +19 -0
- package/dist/deep/reference-pack.js +101 -0
- package/dist/deep/reference-pack.test.d.ts +1 -0
- package/dist/deep/reference-pack.test.js +46 -0
- package/dist/deep/review-context-export.d.ts +11 -0
- package/dist/deep/review-context-export.js +31 -0
- package/dist/deep/review-context-export.test.d.ts +1 -0
- package/dist/deep/review-context-export.test.js +32 -0
- package/dist/deep/self-check.d.ts +21 -0
- package/dist/deep/self-check.js +49 -0
- package/dist/firewall/trusted-control.d.ts +1 -2
- package/dist/gates/deep-analysis.d.ts +2 -0
- package/dist/gates/deep-analysis.js +12 -0
- package/dist/gates/deep-runner.js +2 -1
- package/dist/gates/semantic-bugs.js +4 -3
- package/dist/gates/semantic-bugs.test.js +6 -0
- package/dist/hooks/checker.d.ts +1 -2
- package/dist/hooks/index.d.ts +2 -0
- package/dist/index.d.ts +20 -1
- package/dist/index.js +4 -5
- package/dist/inference/index.d.ts +1 -1
- package/dist/inference/index.js +3 -5
- package/dist/inference/local-model.test.d.ts +1 -0
- package/dist/inference/local-model.test.js +23 -0
- package/dist/inference/sidecar-provider.d.ts +10 -1
- package/dist/inference/sidecar-provider.js +28 -6
- package/dist/inference/types.d.ts +8 -1
- package/dist/inference/types.js +14 -0
- package/dist/inference/types.test.d.ts +1 -0
- package/dist/inference/types.test.js +19 -0
- package/dist/review/diff-test-findings.d.ts +3 -0
- package/dist/review/diff-test-findings.js +41 -0
- package/dist/review/review.d.ts +4 -2
- package/dist/review/review.js +5 -2
- package/dist/semantic/benchmark.js +2 -2
- package/dist/semantic/benchmark.test.js +5 -1
- package/dist/semantic/engine.d.ts +3 -0
- package/dist/semantic/engine.js +8 -2
- package/dist/semantic/failure-handling.d.ts +12 -0
- package/dist/semantic/failure-handling.js +41 -0
- package/dist/semantic/intent/parallel-reads.js +2 -30
- package/dist/semantic/project-facts.d.ts +25 -0
- package/dist/semantic/project-facts.js +104 -0
- package/dist/semantic/project-facts.test.d.ts +1 -0
- package/dist/semantic/project-facts.test.js +45 -0
- package/dist/semantic/rules/ecosystem/deps-codegen-undeclared-import.d.ts +2 -0
- package/dist/semantic/rules/ecosystem/deps-codegen-undeclared-import.js +71 -0
- package/dist/semantic/rules/ecosystem/deps-internal-symbol.d.ts +2 -0
- package/dist/semantic/rules/ecosystem/deps-internal-symbol.js +36 -0
- package/dist/semantic/rules/ecosystem/dom-self-query-in-component.d.ts +2 -0
- package/dist/semantic/rules/ecosystem/dom-self-query-in-component.js +60 -0
- package/dist/semantic/rules/ecosystem/env-bare-browser-global.d.ts +2 -0
- package/dist/semantic/rules/ecosystem/env-bare-browser-global.js +57 -0
- package/dist/semantic/rules/ecosystem/exports-condition-parity.d.ts +2 -0
- package/dist/semantic/rules/ecosystem/exports-condition-parity.js +57 -0
- package/dist/semantic/rules/ecosystem/exports-forgotten-type.d.ts +2 -0
- package/dist/semantic/rules/ecosystem/exports-forgotten-type.js +128 -0
- package/dist/semantic/rules/ecosystem/imports-barrel-cycle.d.ts +2 -0
- package/dist/semantic/rules/ecosystem/imports-barrel-cycle.js +78 -0
- package/dist/semantic/rules/ecosystem/index.d.ts +15 -0
- package/dist/semantic/rules/ecosystem/index.js +22 -0
- package/dist/semantic/rules/ecosystem/paths-unnormalized-module-key.d.ts +2 -0
- package/dist/semantic/rules/ecosystem/paths-unnormalized-module-key.js +87 -0
- package/dist/semantic/rules/ecosystem/react-inline-html-object.d.ts +2 -0
- package/dist/semantic/rules/ecosystem/react-inline-html-object.js +30 -0
- package/dist/semantic/rules/ecosystem/shared.d.ts +18 -0
- package/dist/semantic/rules/ecosystem/shared.js +43 -0
- package/dist/semantic/rules/ecosystem/solid-jsx-and-conditional.d.ts +2 -0
- package/dist/semantic/rules/ecosystem/solid-jsx-and-conditional.js +36 -0
- package/dist/semantic/rules/ecosystem/ts-internal-in-public-signature.d.ts +2 -0
- package/dist/semantic/rules/ecosystem/ts-internal-in-public-signature.js +66 -0
- package/dist/semantic/rules/ecosystem/vite-rollup-only-hook-field.d.ts +2 -0
- package/dist/semantic/rules/ecosystem/vite-rollup-only-hook-field.js +31 -0
- package/dist/semantic/rules/ecosystem/vue-static-computed.d.ts +2 -0
- package/dist/semantic/rules/ecosystem/vue-static-computed.js +51 -0
- package/dist/semantic/sites/call-sites.d.ts +25 -0
- package/dist/semantic/sites/call-sites.js +69 -0
- package/dist/semantic/sites/call-sites.test.d.ts +1 -0
- package/dist/semantic/sites/call-sites.test.js +46 -0
- package/dist/semantic/sites/export.d.ts +2 -0
- package/dist/semantic/sites/export.js +22 -0
- package/dist/semantic/types.d.ts +4 -0
- package/dist/services/engineering-knowledge-graph.d.ts +1 -2
- package/dist/templates/universal-config.js +1 -0
- package/dist/types/index.d.ts +31 -8
- package/dist/types/index.js +2 -1
- package/dist/utils/diff-removed.test.d.ts +1 -0
- package/dist/utils/diff-removed.test.js +29 -0
- package/dist/utils/diff.d.ts +12 -0
- package/dist/utils/diff.js +26 -0
- package/package.json +6 -6
|
@@ -29,13 +29,19 @@ const PLATFORM_PACKAGES = {
|
|
|
29
29
|
'win32-x64': '@rigour-labs/brain-win-x64',
|
|
30
30
|
};
|
|
31
31
|
export class SidecarProvider {
|
|
32
|
+
modelPath;
|
|
32
33
|
name = 'sidecar';
|
|
33
34
|
binaryPath = null;
|
|
34
35
|
model = null;
|
|
35
36
|
schemaPath = null;
|
|
36
37
|
tier;
|
|
37
38
|
threads;
|
|
38
|
-
|
|
39
|
+
/**
|
|
40
|
+
* @param modelPath a local GGUF to load instead of the tier's published model:
|
|
41
|
+
* how a fine-tune candidate is measured before it is published.
|
|
42
|
+
*/
|
|
43
|
+
constructor(tier = 'lite', threads = 4, modelPath) {
|
|
44
|
+
this.modelPath = modelPath;
|
|
39
45
|
this.tier = tier;
|
|
40
46
|
this.threads = threads;
|
|
41
47
|
}
|
|
@@ -58,11 +64,7 @@ export class SidecarProvider {
|
|
|
58
64
|
async setup(onProgress) {
|
|
59
65
|
this.binaryPath = await this.resolveBinaryPath() ?? await installLlamaEngine(onProgress);
|
|
60
66
|
onProgress?.('✓ Inference engine ready');
|
|
61
|
-
await
|
|
62
|
-
if (percent !== undefined && percent < 100)
|
|
63
|
-
onProgress?.(` ${msg}`);
|
|
64
|
-
});
|
|
65
|
-
this.model = await getCachedModel(this.tier);
|
|
67
|
+
this.model = this.modelPath ? await localModel(this.tier, this.modelPath) : await this.publishedModel(onProgress);
|
|
66
68
|
if (!this.model) {
|
|
67
69
|
throw new Error(`Model for tier "${this.tier}" is not cached after download.`);
|
|
68
70
|
}
|
|
@@ -72,6 +74,13 @@ export class SidecarProvider {
|
|
|
72
74
|
this.schemaPath = await writeFindingsSchema();
|
|
73
75
|
onProgress?.('✓ Model ready');
|
|
74
76
|
}
|
|
77
|
+
async publishedModel(onProgress) {
|
|
78
|
+
await ensureModel(this.tier, (msg, percent) => {
|
|
79
|
+
if (percent !== undefined && percent < 100)
|
|
80
|
+
onProgress?.(` ${msg}`);
|
|
81
|
+
});
|
|
82
|
+
return getCachedModel(this.tier);
|
|
83
|
+
}
|
|
75
84
|
/** The model file setup() loaded, or null before setup. */
|
|
76
85
|
getActiveModel() {
|
|
77
86
|
if (!this.model)
|
|
@@ -176,3 +185,16 @@ async function writeFindingsSchema() {
|
|
|
176
185
|
await fs.writeJson(schemaPath, FINDINGS_JSON_SCHEMA);
|
|
177
186
|
return schemaPath;
|
|
178
187
|
}
|
|
188
|
+
/** A GGUF on disk, used as is: no download, no version check. */
|
|
189
|
+
export async function localModel(tier, modelPath) {
|
|
190
|
+
const resolved = path.resolve(modelPath);
|
|
191
|
+
const stat = await fs.stat(resolved).catch(() => null);
|
|
192
|
+
if (!stat?.isFile())
|
|
193
|
+
throw new Error(`Model file not found: ${resolved}`);
|
|
194
|
+
const filename = path.basename(resolved);
|
|
195
|
+
return {
|
|
196
|
+
path: resolved,
|
|
197
|
+
info: { tier, name: `${filename} (local)`, filename, url: '', sizeBytes: stat.size, sizeHuman: `${Math.round(stat.size / 1e6)}MB` },
|
|
198
|
+
fallback: false,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -66,8 +66,15 @@ export interface DeepAnalysisResult {
|
|
|
66
66
|
* - deep: Qwen2.5-Coder-1.5B fine-tuned — full power, company-hosted
|
|
67
67
|
* - lite: Qwen2.5-Coder-0.5B fine-tuned — lightweight, ships as default CLI sidecar
|
|
68
68
|
* - legacy: Qwen2.5-Coder-0.5B fine-tuned — previous default, reproducibility
|
|
69
|
+
* - max: Qwen2.5-Coder-7B (4.7GB q4) — the strongest model a 16GB laptop runs;
|
|
70
|
+
* stock until a fine-tune passes the eval gate and the Review Arena
|
|
69
71
|
*/
|
|
70
|
-
export type ModelTier = 'deep' | 'lite' | 'legacy';
|
|
72
|
+
export type ModelTier = 'deep' | 'lite' | 'legacy' | 'max';
|
|
73
|
+
/** The local tier for a run: --max, then --pro, then the lite default. */
|
|
74
|
+
export declare function localTier(options: {
|
|
75
|
+
max?: boolean;
|
|
76
|
+
pro?: boolean;
|
|
77
|
+
}): ModelTier;
|
|
71
78
|
/**
|
|
72
79
|
* Model info for download/caching.
|
|
73
80
|
*/
|
package/dist/inference/types.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** The local tier for a run: --max, then --pro, then the lite default. */
|
|
2
|
+
export function localTier(options) {
|
|
3
|
+
return options.max ? 'max' : options.pro ? 'deep' : 'lite';
|
|
4
|
+
}
|
|
1
5
|
/**
|
|
2
6
|
* Minimum bundled model version — used as fallback when the auto-update
|
|
3
7
|
* check fails (offline, HF down, first run). The RLAIF training pipeline
|
|
@@ -23,6 +27,7 @@ export function buildModelInfo(tier, version) {
|
|
|
23
27
|
deep: { base: 'Qwen2.5-Coder-1.5B', size: 900_000_000, sizeH: '900MB' },
|
|
24
28
|
lite: { base: 'Qwen2.5-Coder-0.5B', size: 400_000_000, sizeH: '400MB' },
|
|
25
29
|
legacy: { base: 'Qwen2.5-Coder-0.5B', size: 350_000_000, sizeH: '350MB' },
|
|
30
|
+
max: { base: 'Qwen2.5-Coder-7B', size: 4_700_000_000, sizeH: '4.7GB' },
|
|
26
31
|
};
|
|
27
32
|
const m = meta[tier];
|
|
28
33
|
return {
|
|
@@ -39,6 +44,7 @@ export const MODELS = {
|
|
|
39
44
|
deep: buildModelInfo('deep', BUNDLED_MODEL_VERSION),
|
|
40
45
|
lite: buildModelInfo('lite', BUNDLED_MODEL_VERSION),
|
|
41
46
|
legacy: buildModelInfo('legacy', BUNDLED_MODEL_VERSION),
|
|
47
|
+
max: buildModelInfo('max', BUNDLED_MODEL_VERSION),
|
|
42
48
|
};
|
|
43
49
|
/**
|
|
44
50
|
* Update MODELS in-place to point to a newer version.
|
|
@@ -79,4 +85,12 @@ export const FALLBACK_MODELS = {
|
|
|
79
85
|
sizeBytes: 350_000_000,
|
|
80
86
|
sizeHuman: '350MB',
|
|
81
87
|
},
|
|
88
|
+
max: {
|
|
89
|
+
tier: 'max',
|
|
90
|
+
name: 'Qwen2.5-Coder-7B-Instruct (stock)',
|
|
91
|
+
filename: 'qwen2.5-coder-7b-instruct-q4_k_m.gguf',
|
|
92
|
+
url: 'https://huggingface.co/Qwen/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/main/qwen2.5-coder-7b-instruct-q4_k_m.gguf',
|
|
93
|
+
sizeBytes: 4_683_073_536,
|
|
94
|
+
sizeHuman: '4.7GB',
|
|
95
|
+
},
|
|
82
96
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildModelInfo, FALLBACK_MODELS, localTier, MODELS } from './types.js';
|
|
3
|
+
describe('local model tiers', () => {
|
|
4
|
+
it('picks max over pro over the lite default', () => {
|
|
5
|
+
expect(localTier({})).toBe('lite');
|
|
6
|
+
expect(localTier({ pro: true })).toBe('deep');
|
|
7
|
+
expect(localTier({ max: true })).toBe('max');
|
|
8
|
+
expect(localTier({ max: true, pro: true })).toBe('max');
|
|
9
|
+
});
|
|
10
|
+
it('asks for a published max fine-tune and falls back to the stock 7B coder model', () => {
|
|
11
|
+
expect(buildModelInfo('max', '6.0.0').url)
|
|
12
|
+
.toBe('https://huggingface.co/rigour-labs/rigour-max-v6.0.0-gguf/resolve/main/rigour-max-v6.0.0-q4_k_m.gguf');
|
|
13
|
+
expect(MODELS.max.tier).toBe('max');
|
|
14
|
+
expect(FALLBACK_MODELS.max).toMatchObject({
|
|
15
|
+
filename: 'qwen2.5-coder-7b-instruct-q4_k_m.gguf',
|
|
16
|
+
url: 'https://huggingface.co/Qwen/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/main/qwen2.5-coder-7b-instruct-q4_k_m.gguf',
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rigour review --diff-tests`: behaviour changes found by running changed
|
|
3
|
+
* functions before and after the change, as review findings on the changed line.
|
|
4
|
+
*/
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
|
+
import { runDiffTests } from '../deep/diff-tests/run.js';
|
|
7
|
+
import { createProvider } from '../inference/index.js';
|
|
8
|
+
export async function diffTestFailures(cwd, source, deep) {
|
|
9
|
+
const provider = createProvider(deep);
|
|
10
|
+
await provider.setup();
|
|
11
|
+
try {
|
|
12
|
+
const changes = await runDiffTests({
|
|
13
|
+
cwd, baseRef: baseRef(cwd, source), focusLines: deep.focusLines ?? {}, prBody: deep.prBody,
|
|
14
|
+
provider, inference: { maxTokens: 1024, temperature: 0.2 },
|
|
15
|
+
});
|
|
16
|
+
return changes.map(toFailure);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
provider.dispose();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** What the change is compared with: the merge base for a branch review, HEAD for working changes. */
|
|
23
|
+
function baseRef(cwd, source) {
|
|
24
|
+
if (source?.mode !== 'base')
|
|
25
|
+
return 'HEAD';
|
|
26
|
+
return execFileSync('git', ['merge-base', source.base, 'HEAD'], { cwd, encoding: 'utf8' }).trim();
|
|
27
|
+
}
|
|
28
|
+
function toFailure(change) {
|
|
29
|
+
return {
|
|
30
|
+
id: 'diff-tests',
|
|
31
|
+
title: `[diff-tests] ${change.name} behaves differently after this change`,
|
|
32
|
+
details: `\`${change.call}\` ${change.before} before this change and ${change.after} after it.`,
|
|
33
|
+
severity: 'medium',
|
|
34
|
+
provenance: 'deep-analysis',
|
|
35
|
+
files: [change.file],
|
|
36
|
+
line: change.line,
|
|
37
|
+
hint: 'If this is intended, say so in the PR description; otherwise restore the old behaviour for this input.',
|
|
38
|
+
source: 'hybrid',
|
|
39
|
+
verified: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
package/dist/review/review.d.ts
CHANGED
|
@@ -8,8 +8,10 @@ export interface ReviewInput {
|
|
|
8
8
|
source?: DiffSource;
|
|
9
9
|
/** Review exactly these files instead of the ones the diff touches. */
|
|
10
10
|
files?: string[];
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/** Run changed functions before and after the change (needs deep, on the max or a cloud tier). */
|
|
12
|
+
diffTests?: boolean;
|
|
13
|
+
/** Deep analysis; `focusLines` and `removedLines` are filled from the diff. */
|
|
14
|
+
deep?: Omit<DeepOptions, 'focusLines' | 'removedLines'>;
|
|
13
15
|
}
|
|
14
16
|
export interface ReviewResult {
|
|
15
17
|
status: 'PASS' | 'FAIL' | 'ERROR';
|
package/dist/review/review.js
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* but could not analyze anything makes the result ERROR, never a clean PASS.
|
|
8
8
|
*/
|
|
9
9
|
import { GateRunner } from '../gates/runner.js';
|
|
10
|
-
import { changedLinesByFile, parseDiff } from '../utils/diff.js';
|
|
10
|
+
import { changedLinesByFile, parseDiff, removedByFile } from '../utils/diff.js';
|
|
11
11
|
import { normalizeScopePatterns } from '../utils/scope.js';
|
|
12
12
|
import { deepAnalysisError } from '../utils/deep-status.js';
|
|
13
13
|
import { splitByChangedLines } from './changed-lines.js';
|
|
14
14
|
import { diffFromGit } from './git-diff.js';
|
|
15
|
+
import { diffTestFailures } from './diff-test-findings.js';
|
|
15
16
|
export async function reviewChange(input) {
|
|
16
17
|
const diff = input.diff ?? diffFromGit(input.cwd, input.source);
|
|
17
18
|
const changedLines = parseDiff(diff);
|
|
@@ -19,8 +20,10 @@ export async function reviewChange(input) {
|
|
|
19
20
|
if (targets.length === 0) {
|
|
20
21
|
return { status: 'PASS', findings: [], fileFindings: [], unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null };
|
|
21
22
|
}
|
|
22
|
-
const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines) } : undefined;
|
|
23
|
+
const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff) } : undefined;
|
|
23
24
|
const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
|
|
25
|
+
if (input.diffTests && deep)
|
|
26
|
+
report.failures.push(...await diffTestFailures(input.cwd, input.source, deep));
|
|
24
27
|
const split = splitByChangedLines(report.failures, changedLines);
|
|
25
28
|
const deepError = deepAnalysisError(report);
|
|
26
29
|
return {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
|
-
import { analyzeFiles } from './engine.js';
|
|
9
|
+
import { ALL_RULES, analyzeFiles } from './engine.js';
|
|
10
10
|
export function loadCases(root) {
|
|
11
11
|
return fs.readdirSync(root, { withFileTypes: true })
|
|
12
12
|
.filter(entry => entry.isDirectory() && fs.existsSync(path.join(root, entry.name, 'case.json')))
|
|
@@ -27,5 +27,5 @@ function findingsIn(dir) {
|
|
|
27
27
|
if (!fs.existsSync(dir))
|
|
28
28
|
return [];
|
|
29
29
|
const files = fs.readdirSync(dir).filter(f => /\.[cm]?[jt]sx?$/.test(f));
|
|
30
|
-
return analyzeFiles(dir, files).map(f => `${f.rule} ${f.file}:${f.line}`);
|
|
30
|
+
return analyzeFiles(dir, files, { rules: ALL_RULES }).map(f => `${f.rule} ${f.file}:${f.line}`);
|
|
31
31
|
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { CANDIDATE_RULES, ECOSYSTEM_RULES } from './rules/ecosystem/index.js';
|
|
3
4
|
import { loadCases, runCase } from './benchmark.js';
|
|
4
5
|
const ROOT = path.resolve(__dirname, '../../../../benchmarks/semantic');
|
|
5
6
|
describe('semantic benchmark', () => {
|
|
6
7
|
const cases = loadCases(ROOT);
|
|
7
8
|
it('has cases for every built-in rule, including negatives', () => {
|
|
8
9
|
const rules = new Set(cases.map(c => c.rule));
|
|
9
|
-
expect(rules).toEqual(new Set([
|
|
10
|
+
expect(rules).toEqual(new Set([
|
|
11
|
+
'in-memory-aggregation', 'credential-redirect', 'degraded-response-cached',
|
|
12
|
+
...[...ECOSYSTEM_RULES, ...CANDIDATE_RULES].map(rule => rule.id), null,
|
|
13
|
+
]));
|
|
10
14
|
});
|
|
11
15
|
for (const bench of cases) {
|
|
12
16
|
it(`${bench.name}: ${bench.note}`, () => {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { SemanticFinding, SemanticRule } from './types.js';
|
|
2
|
+
/** Rules that run by default. */
|
|
2
3
|
export declare const BUILT_IN_RULES: SemanticRule[];
|
|
4
|
+
/** Every rule Rigour ships, candidates included: what can be named to run. */
|
|
5
|
+
export declare const ALL_RULES: SemanticRule[];
|
|
3
6
|
export interface EngineOptions {
|
|
4
7
|
rules?: SemanticRule[];
|
|
5
8
|
batchSize?: number;
|
package/dist/semantic/engine.js
CHANGED
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { loadProjectConfig, programBatches } from './program.js';
|
|
6
|
+
import { ProjectFacts } from './project-facts.js';
|
|
6
7
|
import { Summaries } from './summaries.js';
|
|
7
8
|
import { inMemoryAggregation } from './rules/in-memory-aggregation.js';
|
|
8
9
|
import { credentialRedirect } from './rules/credential-redirect.js';
|
|
9
10
|
import { degradedResponseCached } from './rules/degraded-response-cached.js';
|
|
10
|
-
|
|
11
|
+
import { CANDIDATE_RULES, ECOSYSTEM_RULES } from './rules/ecosystem/index.js';
|
|
12
|
+
/** Rules that run by default. */
|
|
13
|
+
export const BUILT_IN_RULES = [inMemoryAggregation, credentialRedirect, degradedResponseCached, ...ECOSYSTEM_RULES];
|
|
14
|
+
/** Every rule Rigour ships, candidates included: what can be named to run. */
|
|
15
|
+
export const ALL_RULES = [...BUILT_IN_RULES, ...CANDIDATE_RULES];
|
|
11
16
|
/** Analyze `files` (cwd-relative or absolute). Declaration files are skipped. */
|
|
12
17
|
export function analyzeFiles(cwd, files, opts = {}) {
|
|
13
18
|
const rules = opts.rules ?? BUILT_IN_RULES;
|
|
@@ -19,6 +24,7 @@ export function analyzeFiles(cwd, files, opts = {}) {
|
|
|
19
24
|
const { options } = loadProjectConfig(cwd);
|
|
20
25
|
const rootSet = new Set(roots.map(r => path.normalize(r)));
|
|
21
26
|
const findings = [];
|
|
27
|
+
const project = new ProjectFacts(cwd);
|
|
22
28
|
for (const program of programBatches(roots, options, opts.batchSize)) {
|
|
23
29
|
const checker = program.getTypeChecker();
|
|
24
30
|
const summaries = new Summaries(checker);
|
|
@@ -26,7 +32,7 @@ export function analyzeFiles(cwd, files, opts = {}) {
|
|
|
26
32
|
if (!rootSet.has(path.normalize(sourceFile.fileName)))
|
|
27
33
|
continue;
|
|
28
34
|
for (const rule of rules) {
|
|
29
|
-
findings.push(...rule.check({ cwd, checker, sourceFile, summaries }));
|
|
35
|
+
findings.push(...rule.check({ cwd, program, checker, sourceFile, summaries, project }));
|
|
30
36
|
}
|
|
31
37
|
rootSet.delete(path.normalize(sourceFile.fileName));
|
|
32
38
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does a call handle its own failure? Shared by the intent checks and the
|
|
3
|
+
* training-site export, so both see a call the same way.
|
|
4
|
+
*/
|
|
5
|
+
import ts from 'typescript';
|
|
6
|
+
import { type FunctionLike } from './ast.js';
|
|
7
|
+
export declare function isPromiseAll(call: ts.CallExpression): boolean;
|
|
8
|
+
export declare function isPromiseAllSettled(call: ts.CallExpression): boolean;
|
|
9
|
+
/** Inside a `try` (with a catch) of the same function: the rejection is handled there. */
|
|
10
|
+
export declare function isInsideTry(node: ts.Node, fn: FunctionLike): boolean;
|
|
11
|
+
/** `x().catch(...)`, `x().then(ok, fail)`, or a callee whose body catches and returns. */
|
|
12
|
+
export declare function handlesOwnFailure(checker: ts.TypeChecker, call: ts.CallExpression): boolean;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does a call handle its own failure? Shared by the intent checks and the
|
|
3
|
+
* training-site export, so both see a call the same way.
|
|
4
|
+
*/
|
|
5
|
+
import ts from 'typescript';
|
|
6
|
+
import { calledFunction } from './ast.js';
|
|
7
|
+
export function isPromiseAll(call) {
|
|
8
|
+
return isPromiseMethod(call, 'all');
|
|
9
|
+
}
|
|
10
|
+
export function isPromiseAllSettled(call) {
|
|
11
|
+
return isPromiseMethod(call, 'allSettled');
|
|
12
|
+
}
|
|
13
|
+
function isPromiseMethod(call, name) {
|
|
14
|
+
const callee = call.expression;
|
|
15
|
+
return ts.isPropertyAccessExpression(callee) && callee.name.text === name
|
|
16
|
+
&& ts.isIdentifier(callee.expression) && callee.expression.text === 'Promise';
|
|
17
|
+
}
|
|
18
|
+
/** Inside a `try` (with a catch) of the same function: the rejection is handled there. */
|
|
19
|
+
export function isInsideTry(node, fn) {
|
|
20
|
+
for (let child = node, parent = node.parent; parent && parent !== fn; child = parent, parent = parent.parent) {
|
|
21
|
+
if (ts.isTryStatement(parent) && parent.tryBlock === child && parent.catchClause)
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
/** `x().catch(...)`, `x().then(ok, fail)`, or a callee whose body catches and returns. */
|
|
27
|
+
export function handlesOwnFailure(checker, call) {
|
|
28
|
+
const callee = call.expression;
|
|
29
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
30
|
+
if (callee.name.text === 'catch' || callee.name.text === 'finally')
|
|
31
|
+
return true;
|
|
32
|
+
if (callee.name.text === 'then' && call.arguments.length >= 2)
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const target = calledFunction(checker, call);
|
|
36
|
+
const body = target?.body;
|
|
37
|
+
return !!body && ts.isBlock(body) && body.statements.some(s => ts.isTryStatement(s) && !!s.catchClause && catchReturns(s.catchClause));
|
|
38
|
+
}
|
|
39
|
+
function catchReturns(clause) {
|
|
40
|
+
return clause.block.statements.some(s => ts.isReturnStatement(s));
|
|
41
|
+
}
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* which the code cannot say; see intent-check.ts.
|
|
8
8
|
*/
|
|
9
9
|
import ts from 'typescript';
|
|
10
|
-
import {
|
|
10
|
+
import { enclosingFunction, forEachNode, lineOf, relativeFile, unwrap } from '../ast.js';
|
|
11
|
+
import { handlesOwnFailure, isInsideTry, isPromiseAll } from '../failure-handling.js';
|
|
11
12
|
import { promiseAllItems } from '../origins.js';
|
|
12
13
|
import { functionKeyOf } from '../learn/identity.js';
|
|
13
14
|
export function findParallelReads(cwd, checker, sourceFile) {
|
|
@@ -28,32 +29,3 @@ export function findParallelReads(cwd, checker, sourceFile) {
|
|
|
28
29
|
});
|
|
29
30
|
return sites;
|
|
30
31
|
}
|
|
31
|
-
function isPromiseAll(call) {
|
|
32
|
-
const callee = call.expression;
|
|
33
|
-
return ts.isPropertyAccessExpression(callee) && callee.name.text === 'all'
|
|
34
|
-
&& ts.isIdentifier(callee.expression) && callee.expression.text === 'Promise';
|
|
35
|
-
}
|
|
36
|
-
/** Inside a `try` (with a catch) of the same function: the rejection is handled there. */
|
|
37
|
-
function isInsideTry(node, fn) {
|
|
38
|
-
for (let child = node, parent = node.parent; parent && parent !== fn; child = parent, parent = parent.parent) {
|
|
39
|
-
if (ts.isTryStatement(parent) && parent.tryBlock === child && parent.catchClause)
|
|
40
|
-
return true;
|
|
41
|
-
}
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
/** `x().catch(...)`, `x().then(ok, fail)`, or a callee whose body catches and returns. */
|
|
45
|
-
function handlesOwnFailure(checker, call) {
|
|
46
|
-
const callee = call.expression;
|
|
47
|
-
if (ts.isPropertyAccessExpression(callee)) {
|
|
48
|
-
if (callee.name.text === 'catch' || callee.name.text === 'finally')
|
|
49
|
-
return true;
|
|
50
|
-
if (callee.name.text === 'then' && call.arguments.length >= 2)
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
|
-
const target = calledFunction(checker, call);
|
|
54
|
-
const body = target?.body;
|
|
55
|
-
return !!body && ts.isBlock(body) && body.statements.some(s => ts.isTryStatement(s) && !!s.catchClause && catchReturns(s.catchClause));
|
|
56
|
-
}
|
|
57
|
-
function catchReturns(clause) {
|
|
58
|
-
return clause.block.statements.some(s => ts.isReturnStatement(s));
|
|
59
|
-
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export declare class ProjectFacts {
|
|
3
|
+
private readonly cwd;
|
|
4
|
+
private readonly packages;
|
|
5
|
+
private readonly options;
|
|
6
|
+
constructor(cwd: string);
|
|
7
|
+
/** Declared dependency names and version ranges of the package that owns `file`. */
|
|
8
|
+
dependencies(file: string): Map<string, string>;
|
|
9
|
+
/** Directory of the package.json that owns `file`. */
|
|
10
|
+
packageDir(file: string): string | undefined;
|
|
11
|
+
/** The `name` of the package that owns `file`. */
|
|
12
|
+
packageName(file: string): string | undefined;
|
|
13
|
+
declares(file: string, ...names: string[]): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Highest major version a declared dependency may run on, across every field
|
|
16
|
+
* and `||` alternative (`>=18.0.0 || >=19.0.0` -> 19). A package that supports
|
|
17
|
+
* a major must work on it. Undefined when no range names a version (`catalog:`, `workspace:*`).
|
|
18
|
+
*/
|
|
19
|
+
majorVersion(file: string, name: string): number | undefined;
|
|
20
|
+
/** Compiler options of the nearest tsconfig.json above `file`, extends resolved. */
|
|
21
|
+
compilerOptions(file: string): ts.CompilerOptions;
|
|
22
|
+
private dirsUp;
|
|
23
|
+
private nearestPackage;
|
|
24
|
+
private readPackage;
|
|
25
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a file's own package declares: its nearest package.json dependencies and
|
|
3
|
+
* nearest tsconfig compiler options. Ecosystem rules are gated on these, so a
|
|
4
|
+
* Solid rule only runs where Solid is a dependency, and in a monorepo each
|
|
5
|
+
* package is judged by its own manifest. Lookups never leave `cwd`, and are
|
|
6
|
+
* cached for one analysis run.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import ts from 'typescript';
|
|
11
|
+
const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
|
12
|
+
export class ProjectFacts {
|
|
13
|
+
cwd;
|
|
14
|
+
packages = new Map();
|
|
15
|
+
options = new Map();
|
|
16
|
+
constructor(cwd) {
|
|
17
|
+
this.cwd = cwd;
|
|
18
|
+
}
|
|
19
|
+
/** Declared dependency names and version ranges of the package that owns `file`. */
|
|
20
|
+
dependencies(file) {
|
|
21
|
+
return this.nearestPackage(file)?.dependencies ?? new Map();
|
|
22
|
+
}
|
|
23
|
+
/** Directory of the package.json that owns `file`. */
|
|
24
|
+
packageDir(file) {
|
|
25
|
+
return this.dirsUp(file).find(dir => this.readPackage(dir));
|
|
26
|
+
}
|
|
27
|
+
/** The `name` of the package that owns `file`. */
|
|
28
|
+
packageName(file) {
|
|
29
|
+
return this.nearestPackage(file)?.name;
|
|
30
|
+
}
|
|
31
|
+
declares(file, ...names) {
|
|
32
|
+
const deps = this.dependencies(file);
|
|
33
|
+
return names.some(name => deps.has(name));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Highest major version a declared dependency may run on, across every field
|
|
37
|
+
* and `||` alternative (`>=18.0.0 || >=19.0.0` -> 19). A package that supports
|
|
38
|
+
* a major must work on it. Undefined when no range names a version (`catalog:`, `workspace:*`).
|
|
39
|
+
*/
|
|
40
|
+
majorVersion(file, name) {
|
|
41
|
+
const majors = (this.nearestPackage(file)?.ranges.get(name) ?? [])
|
|
42
|
+
.flatMap(range => range.split('||'))
|
|
43
|
+
.map(alternative => alternative.match(/(\d+)(?:\.[\dx*]+)*/)?.[1])
|
|
44
|
+
.filter((m) => m !== undefined)
|
|
45
|
+
.map(Number);
|
|
46
|
+
return majors.length ? Math.max(...majors) : undefined;
|
|
47
|
+
}
|
|
48
|
+
/** Compiler options of the nearest tsconfig.json above `file`, extends resolved. */
|
|
49
|
+
compilerOptions(file) {
|
|
50
|
+
const dir = path.dirname(path.resolve(this.cwd, file));
|
|
51
|
+
const cached = this.options.get(dir);
|
|
52
|
+
if (cached)
|
|
53
|
+
return cached;
|
|
54
|
+
const configPath = this.dirsUp(file).map(d => path.join(d, 'tsconfig.json')).find(p => fs.existsSync(p));
|
|
55
|
+
let options = {};
|
|
56
|
+
if (configPath) {
|
|
57
|
+
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
58
|
+
if (!read.error)
|
|
59
|
+
options = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath)).options;
|
|
60
|
+
}
|
|
61
|
+
this.options.set(dir, options);
|
|
62
|
+
return options;
|
|
63
|
+
}
|
|
64
|
+
dirsUp(file) {
|
|
65
|
+
const root = path.resolve(this.cwd);
|
|
66
|
+
const dirs = [];
|
|
67
|
+
for (let dir = path.dirname(path.resolve(root, file)); dir.startsWith(root); dir = path.dirname(dir)) {
|
|
68
|
+
dirs.push(dir);
|
|
69
|
+
if (dir === root)
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
return dirs;
|
|
73
|
+
}
|
|
74
|
+
nearestPackage(file) {
|
|
75
|
+
for (const dir of this.dirsUp(file)) {
|
|
76
|
+
const manifest = this.readPackage(dir);
|
|
77
|
+
if (manifest)
|
|
78
|
+
return manifest;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
readPackage(dir) {
|
|
83
|
+
if (this.packages.has(dir))
|
|
84
|
+
return this.packages.get(dir);
|
|
85
|
+
let manifest = null;
|
|
86
|
+
try {
|
|
87
|
+
const json = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
88
|
+
const dependencies = new Map();
|
|
89
|
+
const ranges = new Map();
|
|
90
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
91
|
+
for (const [name, range] of Object.entries(json[field] ?? {})) {
|
|
92
|
+
dependencies.set(name, String(range));
|
|
93
|
+
ranges.set(name, [...(ranges.get(name) ?? []), String(range)]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
manifest = { name: typeof json.name === 'string' ? json.name : undefined, dependencies, ranges };
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
manifest = null;
|
|
100
|
+
}
|
|
101
|
+
this.packages.set(dir, manifest);
|
|
102
|
+
return manifest;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
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 { ProjectFacts } from './project-facts.js';
|
|
6
|
+
let root;
|
|
7
|
+
afterEach(() => { if (root)
|
|
8
|
+
fs.rmSync(root, { recursive: true, force: true }); root = undefined; });
|
|
9
|
+
function write(files) {
|
|
10
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'project-facts-'));
|
|
11
|
+
for (const [name, text] of Object.entries(files)) {
|
|
12
|
+
fs.mkdirSync(path.dirname(path.join(root, name)), { recursive: true });
|
|
13
|
+
fs.writeFileSync(path.join(root, name), text);
|
|
14
|
+
}
|
|
15
|
+
return root;
|
|
16
|
+
}
|
|
17
|
+
describe('ProjectFacts', () => {
|
|
18
|
+
it('reads the nearest package.json, so each monorepo package is judged by its own', () => {
|
|
19
|
+
const dir = write({
|
|
20
|
+
'package.json': JSON.stringify({ name: 'root', devDependencies: { vite: '^5.0.0' } }),
|
|
21
|
+
'packages/ui/package.json': JSON.stringify({ name: '@x/ui', peerDependencies: { 'solid-js': '^1.8.0' } }),
|
|
22
|
+
'packages/ui/src/a.tsx': '',
|
|
23
|
+
});
|
|
24
|
+
const facts = new ProjectFacts(dir);
|
|
25
|
+
expect(facts.declares('packages/ui/src/a.tsx', 'solid-js')).toBe(true);
|
|
26
|
+
expect(facts.declares('packages/ui/src/a.tsx', 'vite')).toBe(false);
|
|
27
|
+
expect(facts.majorVersion('packages/ui/src/a.tsx', 'solid-js')).toBe(1);
|
|
28
|
+
expect(facts.packageName('packages/ui/src/a.tsx')).toBe('@x/ui');
|
|
29
|
+
expect(facts.packageDir('packages/ui/src/a.tsx')).toBe(path.join(dir, 'packages/ui'));
|
|
30
|
+
});
|
|
31
|
+
it('never reads a manifest above cwd', () => {
|
|
32
|
+
const dir = write({ 'package.json': JSON.stringify({ dependencies: { react: '^19.0.0' } }), 'app/src/a.tsx': '' });
|
|
33
|
+
const facts = new ProjectFacts(path.join(dir, 'app'));
|
|
34
|
+
expect(facts.dependencies('src/a.tsx').size).toBe(0);
|
|
35
|
+
expect(facts.packageDir('src/a.tsx')).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
it('resolves compiler options through tsconfig extends', () => {
|
|
38
|
+
const dir = write({
|
|
39
|
+
'tsconfig.base.json': JSON.stringify({ compilerOptions: { stripInternal: true } }),
|
|
40
|
+
'pkg/tsconfig.json': JSON.stringify({ extends: '../tsconfig.base.json' }),
|
|
41
|
+
'pkg/src/a.ts': '',
|
|
42
|
+
});
|
|
43
|
+
expect(new ProjectFacts(dir).compilerOptions('pkg/src/a.ts').stripInternal).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|