@rigour-labs/core 6.2.5 → 6.3.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/README.md +2 -0
- package/dist/discovery-signals.d.ts +27 -0
- package/dist/discovery-signals.js +96 -0
- package/dist/discovery.d.ts +1 -2
- package/dist/discovery.js +36 -49
- package/dist/discovery.test.js +84 -71
- package/dist/gates/deep-analysis.d.ts +2 -0
- package/dist/gates/deep-analysis.js +5 -1
- package/dist/gates/deep-intent.d.ts +3 -0
- package/dist/gates/deep-intent.js +57 -0
- package/dist/gates/deep-intent.test.d.ts +1 -0
- package/dist/gates/deep-intent.test.js +51 -0
- package/dist/gates/deep-runner.js +1 -0
- package/dist/gates/deprecated-apis-ast.d.ts +8 -0
- package/dist/gates/deprecated-apis-ast.js +25 -0
- package/dist/gates/deprecated-apis-rules-node.js +2 -8
- package/dist/gates/deprecated-apis.js +5 -1
- package/dist/gates/deprecated-apis.test.js +30 -0
- package/dist/gates/frontend-secret-exposure.js +5 -1
- package/dist/gates/frontend-secret-exposure.test.js +20 -0
- package/dist/gates/hallucinated-imports/framework-modules.d.ts +14 -0
- package/dist/gates/hallucinated-imports/framework-modules.js +75 -0
- package/dist/gates/hallucinated-imports/index.js +14 -6
- package/dist/gates/hallucinated-imports/js-resolver.d.ts +16 -12
- package/dist/gates/hallucinated-imports/js-resolver.js +65 -153
- package/dist/gates/hallucinated-imports/ts-path-target.js +16 -0
- package/dist/gates/hallucinated-imports/tsconfig-paths.d.ts +16 -0
- package/dist/gates/hallucinated-imports/tsconfig-paths.js +120 -0
- package/dist/gates/hallucinated-imports-sveltekit.test.d.ts +1 -0
- package/dist/gates/hallucinated-imports-sveltekit.test.js +108 -0
- package/dist/gates/runner.js +5 -0
- package/dist/gates/semantic-bugs.d.ts +23 -0
- package/dist/gates/semantic-bugs.js +48 -0
- package/dist/gates/semantic-bugs.test.d.ts +1 -0
- package/dist/gates/semantic-bugs.test.js +70 -0
- package/dist/gates/side-effect-analysis/index.js +11 -1
- package/dist/gates/side-effect-analysis.test.d.ts +1 -0
- package/dist/gates/side-effect-analysis.test.js +162 -0
- package/dist/gates/side-effect-helpers/pattern-detection.js +35 -7
- package/dist/gates/side-effect-helpers/resource-tracking.js +17 -10
- package/dist/gates/side-effect-helpers/scope-analysis.d.ts +6 -0
- package/dist/gates/side-effect-helpers/scope-analysis.js +20 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -1
- package/dist/inference/model-download.test.js +54 -2
- package/dist/inference/model-manager.d.ts +6 -0
- package/dist/inference/model-manager.js +58 -18
- package/dist/inference/sidecar-provider.js +12 -1
- package/dist/inference/types.d.ts +8 -2
- package/dist/inference/types.js +7 -3
- package/dist/semantic/ast.d.ts +29 -0
- package/dist/semantic/ast.js +122 -0
- package/dist/semantic/benchmark.d.ts +16 -0
- package/dist/semantic/benchmark.js +31 -0
- package/dist/semantic/benchmark.test.d.ts +1 -0
- package/dist/semantic/benchmark.test.js +18 -0
- package/dist/semantic/engine.d.ts +8 -0
- package/dist/semantic/engine.js +45 -0
- package/dist/semantic/intent/benchmark.d.ts +31 -0
- package/dist/semantic/intent/benchmark.js +74 -0
- package/dist/semantic/intent/intent-check.d.ts +43 -0
- package/dist/semantic/intent/intent-check.js +72 -0
- package/dist/semantic/intent/intent.test.d.ts +1 -0
- package/dist/semantic/intent/intent.test.js +46 -0
- package/dist/semantic/intent/parallel-reads.d.ts +23 -0
- package/dist/semantic/intent/parallel-reads.js +59 -0
- package/dist/semantic/learn/benchmark.d.ts +20 -0
- package/dist/semantic/learn/benchmark.js +71 -0
- package/dist/semantic/learn/compile.d.ts +4 -0
- package/dist/semantic/learn/compile.js +56 -0
- package/dist/semantic/learn/conditions.d.ts +22 -0
- package/dist/semantic/learn/conditions.js +93 -0
- package/dist/semantic/learn/extract.d.ts +30 -0
- package/dist/semantic/learn/extract.js +122 -0
- package/dist/semantic/learn/git-trees.d.ts +8 -0
- package/dist/semantic/learn/git-trees.js +32 -0
- package/dist/semantic/learn/identity.d.ts +18 -0
- package/dist/semantic/learn/identity.js +97 -0
- package/dist/semantic/learn/learn.d.ts +25 -0
- package/dist/semantic/learn/learn.js +109 -0
- package/dist/semantic/learn/learn.test.d.ts +1 -0
- package/dist/semantic/learn/learn.test.js +67 -0
- package/dist/semantic/learn/store.d.ts +4 -0
- package/dist/semantic/learn/store.js +39 -0
- package/dist/semantic/learn/types.d.ts +468 -0
- package/dist/semantic/learn/types.js +55 -0
- package/dist/semantic/origins.d.ts +42 -0
- package/dist/semantic/origins.js +149 -0
- package/dist/semantic/origins.test.d.ts +1 -0
- package/dist/semantic/origins.test.js +77 -0
- package/dist/semantic/program.d.ts +13 -0
- package/dist/semantic/program.js +62 -0
- package/dist/semantic/rules/credential-redirect.d.ts +2 -0
- package/dist/semantic/rules/credential-redirect.js +209 -0
- package/dist/semantic/rules/degraded-response-cached.d.ts +2 -0
- package/dist/semantic/rules/degraded-response-cached.js +271 -0
- package/dist/semantic/rules/in-memory-aggregation.d.ts +2 -0
- package/dist/semantic/rules/in-memory-aggregation.js +179 -0
- package/dist/semantic/summaries.d.ts +18 -0
- package/dist/semantic/summaries.js +33 -0
- package/dist/semantic/types.d.ts +32 -0
- package/dist/semantic/types.js +1 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/team-diagnostics.d.ts +22 -0
- package/dist/storage/team-diagnostics.js +58 -0
- package/dist/storage/team-diagnostics.test.d.ts +1 -0
- package/dist/storage/team-diagnostics.test.js +51 -0
- package/dist/storage/team-schema.d.ts +6 -0
- package/dist/storage/team-schema.js +82 -0
- package/dist/storage/team-store.js +25 -72
- package/dist/templates/universal-config.js +4 -0
- package/dist/types/index.d.ts +56 -0
- package/dist/types/index.js +7 -0
- package/package.json +6 -6
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tsconfig `paths`, `baseUrl` and `rootDirs`, resolved the way tsc does.
|
|
3
|
+
*
|
|
4
|
+
* Each option is resolved against the config that declares it, not the one
|
|
5
|
+
* that extends it: SvelteKit's generated `.svelte-kit/tsconfig.json` says
|
|
6
|
+
* `"$lib": ["../src/lib"]`, which means `<repo>/src/lib`, not `<repo>/../src/lib`.
|
|
7
|
+
* A child's `paths` replaces its parent's; later entries of an `extends`
|
|
8
|
+
* array override earlier ones.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'fs-extra';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { Logger } from '../../utils/logger.js';
|
|
13
|
+
const CONFIG_NAMES = ['tsconfig.json', 'jsconfig.json', 'tsconfig.base.json'];
|
|
14
|
+
/** The first config in `searchDir` that defines `paths` or `rootDirs` (directly or inherited). */
|
|
15
|
+
export async function loadTsPathConfig(searchDir) {
|
|
16
|
+
for (const name of CONFIG_NAMES) {
|
|
17
|
+
const configPath = path.join(searchDir, name);
|
|
18
|
+
if (!(await fs.pathExists(configPath)))
|
|
19
|
+
continue;
|
|
20
|
+
const effective = await readChain(configPath, new Set());
|
|
21
|
+
if (!effective.paths && !effective.rootDirs?.length)
|
|
22
|
+
continue;
|
|
23
|
+
return {
|
|
24
|
+
baseDir: effective.baseUrl ?? effective.paths?.dir ?? searchDir,
|
|
25
|
+
rules: toRules(effective.paths?.entries ?? {}),
|
|
26
|
+
rootDirs: effective.rootDirs ?? [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
async function readChain(configPath, visited) {
|
|
32
|
+
const resolved = path.resolve(configPath);
|
|
33
|
+
if (visited.has(resolved))
|
|
34
|
+
return {};
|
|
35
|
+
visited.add(resolved);
|
|
36
|
+
const parsed = await readLooseJson(resolved);
|
|
37
|
+
if (!parsed)
|
|
38
|
+
return {};
|
|
39
|
+
let effective = {};
|
|
40
|
+
for (const extendsPath of extendsList(parsed.extends)) {
|
|
41
|
+
const target = await resolveExtends(resolved, extendsPath);
|
|
42
|
+
if (target)
|
|
43
|
+
effective = { ...effective, ...await readChain(target, visited) };
|
|
44
|
+
else
|
|
45
|
+
warnMissingExtends(resolved, extendsPath);
|
|
46
|
+
}
|
|
47
|
+
const dir = path.dirname(resolved);
|
|
48
|
+
const options = parsed.compilerOptions ?? {};
|
|
49
|
+
if (typeof options.baseUrl === 'string')
|
|
50
|
+
effective.baseUrl = path.resolve(dir, options.baseUrl);
|
|
51
|
+
if (options.paths && typeof options.paths === 'object')
|
|
52
|
+
effective.paths = { entries: options.paths, dir };
|
|
53
|
+
if (Array.isArray(options.rootDirs)) {
|
|
54
|
+
effective.rootDirs = options.rootDirs.filter((r) => typeof r === 'string').map((r) => path.resolve(dir, r));
|
|
55
|
+
}
|
|
56
|
+
return effective;
|
|
57
|
+
}
|
|
58
|
+
function extendsList(value) {
|
|
59
|
+
if (typeof value === 'string')
|
|
60
|
+
return [value];
|
|
61
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
|
|
62
|
+
}
|
|
63
|
+
async function resolveExtends(configPath, extendsPath) {
|
|
64
|
+
const base = extendsPath.startsWith('.') || path.isAbsolute(extendsPath)
|
|
65
|
+
? path.resolve(path.dirname(configPath), extendsPath)
|
|
66
|
+
: path.resolve(path.dirname(configPath), 'node_modules', extendsPath);
|
|
67
|
+
for (const candidate of [base, `${base}.json`, path.join(base, 'tsconfig.json')]) {
|
|
68
|
+
if (await fs.pathExists(candidate) && (await fs.stat(candidate)).isFile())
|
|
69
|
+
return candidate;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function toRules(entries) {
|
|
74
|
+
const rules = [];
|
|
75
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
76
|
+
if (!Array.isArray(value))
|
|
77
|
+
continue;
|
|
78
|
+
const targets = value.filter((v) => typeof v === 'string');
|
|
79
|
+
if (targets.length === 0)
|
|
80
|
+
continue;
|
|
81
|
+
const [prefix, suffix = ''] = key.split('*');
|
|
82
|
+
rules.push({ key, hasWildcard: key.includes('*'), prefix, suffix, targets });
|
|
83
|
+
}
|
|
84
|
+
// Exact keys first, then the longest wildcard prefix/suffix (tsc's precedence).
|
|
85
|
+
return rules.sort((left, right) => {
|
|
86
|
+
if (left.hasWildcard !== right.hasWildcard)
|
|
87
|
+
return left.hasWildcard ? 1 : -1;
|
|
88
|
+
return (right.prefix.length + right.suffix.length) - (left.prefix.length + left.suffix.length);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const warnedMissingExtends = new Set();
|
|
92
|
+
/**
|
|
93
|
+
* A missing `extends` target drops its options. Say so once, instead of
|
|
94
|
+
* reporting every aliased import as missing without a reason.
|
|
95
|
+
*/
|
|
96
|
+
function warnMissingExtends(configPath, extendsPath) {
|
|
97
|
+
const key = `${configPath}\0${extendsPath}`;
|
|
98
|
+
if (warnedMissingExtends.has(key))
|
|
99
|
+
return;
|
|
100
|
+
warnedMissingExtends.add(key);
|
|
101
|
+
const hint = extendsPath.includes('.svelte-kit') ? ' Run `svelte-kit sync` first to generate it.' : '';
|
|
102
|
+
Logger.warn(`${path.basename(configPath)} extends '${extendsPath}', which does not exist; its path aliases are ignored.${hint}`);
|
|
103
|
+
}
|
|
104
|
+
/** JSON with comments and trailing commas, as tsconfig files allow. */
|
|
105
|
+
async function readLooseJson(filePath) {
|
|
106
|
+
try {
|
|
107
|
+
const text = await fs.readFile(filePath, 'utf-8');
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(text);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
const noBlockComments = text.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
113
|
+
const noLineComments = noBlockComments.replace(/(^|\s)\/\/.*$/gm, '$1');
|
|
114
|
+
return JSON.parse(noLineComments.replace(/,\s*([}\]])/g, '$1'));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SvelteKit on a fresh clone: `.svelte-kit/` (and its tsconfig with the
|
|
3
|
+
* `$lib` alias) does not exist until `svelte-kit sync` runs.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
9
|
+
import { HallucinatedImportsGate } from './hallucinated-imports/index.js';
|
|
10
|
+
function write(root, rel, body) {
|
|
11
|
+
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
|
12
|
+
fs.writeFileSync(path.join(root, rel), body);
|
|
13
|
+
}
|
|
14
|
+
async function missingImports(cwd) {
|
|
15
|
+
const failures = await new HallucinatedImportsGate().run({ cwd });
|
|
16
|
+
return failures.map(f => f.details).join('\n');
|
|
17
|
+
}
|
|
18
|
+
describe('HallucinatedImportsGate — SvelteKit', () => {
|
|
19
|
+
let cwd;
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-imports-'));
|
|
22
|
+
write(cwd, 'package.json', JSON.stringify({ devDependencies: { '@sveltejs/kit': '2.0.0', svelte: '5.0.0' } }));
|
|
23
|
+
write(cwd, 'tsconfig.json', JSON.stringify({ extends: './.svelte-kit/tsconfig.json' }));
|
|
24
|
+
write(cwd, 'src/lib/format.ts', 'export const format = (n: number) => String(n);\n');
|
|
25
|
+
write(cwd, 'src/lib/components/badge.svelte', '<span><slot /></span>\n');
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => { fs.rmSync(cwd, { recursive: true, force: true }); });
|
|
28
|
+
it('accepts kit virtual modules, generated $types, $lib and .svelte imports without svelte-kit sync', async () => {
|
|
29
|
+
write(cwd, 'src/routes/+page.ts', [
|
|
30
|
+
"import { goto } from '$app/navigation';",
|
|
31
|
+
"import { page } from '$app/state';",
|
|
32
|
+
"import { PUBLIC_API_URL } from '$env/static/public';",
|
|
33
|
+
"import { env } from '$env/dynamic/private';",
|
|
34
|
+
"import type { PageLoad } from './$types';",
|
|
35
|
+
"import { format } from '$lib/format';",
|
|
36
|
+
"import Badge from '$lib/components/badge.svelte';",
|
|
37
|
+
"import Local from '../lib/components/badge.svelte';",
|
|
38
|
+
'export const load: PageLoad = () => ({ goto, page, PUBLIC_API_URL, env, format, Badge, Local });',
|
|
39
|
+
].join('\n'));
|
|
40
|
+
expect(await missingImports(cwd)).toBe('');
|
|
41
|
+
});
|
|
42
|
+
it('still flags a $lib import that points at no file', async () => {
|
|
43
|
+
write(cwd, 'src/routes/+page.ts', "import { gone } from '$lib/does-not-exist';\nexport const x = gone;\n");
|
|
44
|
+
expect(await missingImports(cwd)).toContain("'$lib/does-not-exist'");
|
|
45
|
+
});
|
|
46
|
+
it('still flags a missing relative .svelte component', async () => {
|
|
47
|
+
write(cwd, 'src/routes/+page.ts', "import Missing from './missing.svelte';\nexport const x = Missing;\n");
|
|
48
|
+
expect(await missingImports(cwd)).toContain("'./missing.svelte'");
|
|
49
|
+
});
|
|
50
|
+
it('still flags a real missing package in a kit project', async () => {
|
|
51
|
+
write(cwd, 'src/routes/+page.ts', "import made from 'package-that-does-not-exist';\nexport const x = made;\n");
|
|
52
|
+
expect(await missingImports(cwd)).toContain("'package-that-does-not-exist'");
|
|
53
|
+
});
|
|
54
|
+
it('does not treat $app or $lib as provided outside a SvelteKit project', async () => {
|
|
55
|
+
write(cwd, 'package.json', JSON.stringify({ dependencies: {} }));
|
|
56
|
+
write(cwd, 'src/index.ts', "import { goto } from '$app/navigation';\nimport { format } from '$lib/format';\nexport const x = [goto, format];\n");
|
|
57
|
+
const details = await missingImports(cwd);
|
|
58
|
+
expect(details).toContain("'$app/navigation'");
|
|
59
|
+
expect(details).toContain("'$lib/format'");
|
|
60
|
+
});
|
|
61
|
+
it('resolves a $lib import written with the emitted .js extension to its .ts source', async () => {
|
|
62
|
+
write(cwd, 'src/lib/utils.ts', 'export const cn = (...c: string[]) => c.join(" ");\n');
|
|
63
|
+
write(cwd, 'src/lib/components/button-variants.ts', "import { cn } from '$lib/utils.js';\nexport const x = cn;\n");
|
|
64
|
+
expect(await missingImports(cwd)).toBe('');
|
|
65
|
+
});
|
|
66
|
+
it('resolves a tsconfig path alias written with .js to its .ts source (any framework)', async () => {
|
|
67
|
+
write(cwd, 'package.json', JSON.stringify({ dependencies: {} }));
|
|
68
|
+
write(cwd, 'tsconfig.json', JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } } }));
|
|
69
|
+
write(cwd, 'src/util/math.ts', 'export const add = (a: number, b: number) => a + b;\n');
|
|
70
|
+
write(cwd, 'src/index.ts', "import { add } from '@/util/math.js';\nexport const x = add;\n");
|
|
71
|
+
expect(await missingImports(cwd)).toBe('');
|
|
72
|
+
});
|
|
73
|
+
it('resolves inherited paths against the config that declares them (after svelte-kit sync)', async () => {
|
|
74
|
+
write(cwd, '.svelte-kit/tsconfig.json', JSON.stringify({
|
|
75
|
+
compilerOptions: { paths: { $lib: ['../src/lib'], '$lib/*': ['../src/lib/*'] }, rootDirs: ['..', './types'] },
|
|
76
|
+
}));
|
|
77
|
+
write(cwd, 'src/routes/+page.ts', "import { format } from '$lib/format';\nimport { gone } from '$lib/does-not-exist';\nexport const x = [format, gone];\n");
|
|
78
|
+
const details = await missingImports(cwd);
|
|
79
|
+
expect(details).not.toContain("'$lib/format'");
|
|
80
|
+
expect(details).toContain("'$lib/does-not-exist'");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('HallucinatedImportsGate — tsconfig inheritance and rootDirs', () => {
|
|
84
|
+
let cwd;
|
|
85
|
+
beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'tsconfig-inherit-')); });
|
|
86
|
+
afterEach(() => { fs.rmSync(cwd, { recursive: true, force: true }); });
|
|
87
|
+
it('resolves paths inherited from a shared base config against that config (Nx-style)', async () => {
|
|
88
|
+
write(cwd, 'package.json', JSON.stringify({ dependencies: {} }));
|
|
89
|
+
write(cwd, 'libs/tsconfig.base.json', JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@org/util': ['util/src/index.ts'] } } }));
|
|
90
|
+
write(cwd, 'libs/util/src/index.ts', 'export const util = 1;\n');
|
|
91
|
+
write(cwd, 'apps/web/tsconfig.json', JSON.stringify({ extends: '../../libs/tsconfig.base.json' }));
|
|
92
|
+
write(cwd, 'apps/web/src/main.ts', "import { util } from '@org/util';\nexport const x = util;\n");
|
|
93
|
+
expect(await missingImports(cwd)).toBe('');
|
|
94
|
+
});
|
|
95
|
+
it('resolves a relative import through rootDirs into a generated folder', async () => {
|
|
96
|
+
write(cwd, 'package.json', JSON.stringify({ dependencies: {} }));
|
|
97
|
+
write(cwd, 'tsconfig.json', JSON.stringify({ compilerOptions: { rootDirs: ['src', 'generated'] } }));
|
|
98
|
+
write(cwd, 'generated/schema.ts', 'export const schema = {};\n');
|
|
99
|
+
write(cwd, 'src/api.ts', "import { schema } from './schema';\nexport const x = schema;\n");
|
|
100
|
+
expect(await missingImports(cwd)).toBe('');
|
|
101
|
+
});
|
|
102
|
+
it('still flags a relative import found in no root', async () => {
|
|
103
|
+
write(cwd, 'package.json', JSON.stringify({ dependencies: {} }));
|
|
104
|
+
write(cwd, 'tsconfig.json', JSON.stringify({ compilerOptions: { rootDirs: ['src', 'generated'] } }));
|
|
105
|
+
write(cwd, 'src/api.ts', "import { schema } from './nowhere';\nexport const x = schema;\n");
|
|
106
|
+
expect(await missingImports(cwd)).toContain("'./nowhere'");
|
|
107
|
+
});
|
|
108
|
+
});
|
package/dist/gates/runner.js
CHANGED
|
@@ -27,6 +27,7 @@ import { DeprecatedApisGate } from './deprecated-apis.js';
|
|
|
27
27
|
import { TestQualityGate } from './test-quality.js';
|
|
28
28
|
import { SideEffectAnalysisGate } from './side-effect-analysis/index.js';
|
|
29
29
|
import { StyleDriftGate } from './style-drift.js';
|
|
30
|
+
import { SemanticBugsGate } from './semantic-bugs.js';
|
|
30
31
|
import { LogicDriftGate } from './logic-drift.js';
|
|
31
32
|
import { execa } from 'execa';
|
|
32
33
|
import { Logger } from '../utils/logger.js';
|
|
@@ -68,6 +69,10 @@ export class GateRunner {
|
|
|
68
69
|
if (this.config.gates.checkpoint?.enabled) {
|
|
69
70
|
this.gates.push(new CheckpointGate(this.config.gates.checkpoint));
|
|
70
71
|
}
|
|
72
|
+
// Semantic Bugs Gate (type-aware, proven findings) — opt-in until field-validated
|
|
73
|
+
if (this.config.gates.semantic_bugs?.enabled) {
|
|
74
|
+
this.gates.push(new SemanticBugsGate(this.config.gates.semantic_bugs));
|
|
75
|
+
}
|
|
71
76
|
// Security Patterns Gate (code-level vulnerability detection) — enabled by default since v2.15
|
|
72
77
|
if (this.config.gates.security?.enabled !== false) {
|
|
73
78
|
this.gates.push(new SecurityPatternsGate(this.config.gates.security));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic Bugs Gate: type-aware rules that prove a defect before reporting it.
|
|
3
|
+
*
|
|
4
|
+
* Each rule traces a value from where it enters to where it does harm, across
|
|
5
|
+
* files when needed (see src/semantic/). A finding names both ends; anything
|
|
6
|
+
* the engine cannot resolve produces no finding.
|
|
7
|
+
*/
|
|
8
|
+
import { Gate, GateContext } from './base.js';
|
|
9
|
+
import { Failure, Provenance } from '../types/index.js';
|
|
10
|
+
export interface SemanticBugsConfig {
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
/** Rule ids to run (`learned/<id>` for a learned rule); all rules when omitted. */
|
|
13
|
+
rules?: string[];
|
|
14
|
+
}
|
|
15
|
+
export declare class SemanticBugsGate extends Gate {
|
|
16
|
+
private readonly config;
|
|
17
|
+
constructor(config?: SemanticBugsConfig);
|
|
18
|
+
protected get provenance(): Provenance;
|
|
19
|
+
run(context: GateContext): Promise<Failure[]>;
|
|
20
|
+
/** Built-in rules plus the repository's learned rules (`.rigour/rules/`), filtered by config. */
|
|
21
|
+
private selectRules;
|
|
22
|
+
private toFailure;
|
|
23
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic Bugs Gate: type-aware rules that prove a defect before reporting it.
|
|
3
|
+
*
|
|
4
|
+
* Each rule traces a value from where it enters to where it does harm, across
|
|
5
|
+
* files when needed (see src/semantic/). A finding names both ends; anything
|
|
6
|
+
* the engine cannot resolve produces no finding.
|
|
7
|
+
*/
|
|
8
|
+
import { Gate } from './base.js';
|
|
9
|
+
import { FileScanner } from '../utils/scanner.js';
|
|
10
|
+
import { Logger } from '../utils/logger.js';
|
|
11
|
+
import { analyzeFiles, BUILT_IN_RULES } from '../semantic/engine.js';
|
|
12
|
+
import { compileLearnedRule, LEARNED_PREFIX } from '../semantic/learn/compile.js';
|
|
13
|
+
import { loadLearnedRules } from '../semantic/learn/store.js';
|
|
14
|
+
const SOURCE_PATTERNS = ['**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}'];
|
|
15
|
+
const TEST_FILE = /(?:^|\/)(?:__tests__|__mocks__|tests?)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
16
|
+
export class SemanticBugsGate extends Gate {
|
|
17
|
+
config;
|
|
18
|
+
constructor(config = {}) {
|
|
19
|
+
super('semantic-bugs', 'Semantic Bug Detection');
|
|
20
|
+
this.config = config;
|
|
21
|
+
}
|
|
22
|
+
get provenance() { return 'traditional'; }
|
|
23
|
+
async run(context) {
|
|
24
|
+
const files = (await FileScanner.findFiles({
|
|
25
|
+
cwd: context.cwd,
|
|
26
|
+
patterns: context.patterns || SOURCE_PATTERNS,
|
|
27
|
+
ignore: context.ignore,
|
|
28
|
+
})).filter(file => /\.(?:[cm]?[jt]s|[jt]sx)$/i.test(file) && !/\.d\.[cm]?ts$/i.test(file) && !TEST_FILE.test(file));
|
|
29
|
+
if (files.length === 0)
|
|
30
|
+
return [];
|
|
31
|
+
const rules = this.selectRules(context.cwd);
|
|
32
|
+
if (rules.length === 0)
|
|
33
|
+
return [];
|
|
34
|
+
Logger.info(`Semantic Bugs: analyzing ${files.length} files with ${rules.length} rule(s)`);
|
|
35
|
+
return analyzeFiles(context.cwd, files, { rules }).map(finding => this.toFailure(finding));
|
|
36
|
+
}
|
|
37
|
+
/** Built-in rules plus the repository's learned rules (`.rigour/rules/`), filtered by config. */
|
|
38
|
+
selectRules(cwd) {
|
|
39
|
+
const all = [...BUILT_IN_RULES, ...loadLearnedRules(cwd).map(compileLearnedRule)];
|
|
40
|
+
const wanted = this.config.rules;
|
|
41
|
+
return wanted?.length ? all.filter(rule => wanted.includes(rule.id)) : all;
|
|
42
|
+
}
|
|
43
|
+
toFailure(finding) {
|
|
44
|
+
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 category = finding.rule.startsWith(LEARNED_PREFIX) ? 'learned-rule' : finding.rule;
|
|
46
|
+
return { ...failure, provenance: finding.provenance, source: 'ast', category, verified: true, confidence: 1 };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
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 { SemanticBugsGate } from './semantic-bugs.js';
|
|
6
|
+
import { GateRunner } from './runner.js';
|
|
7
|
+
const LEAKY = [
|
|
8
|
+
'export async function notify(endpoint: string, signature: string) {',
|
|
9
|
+
" return fetch(endpoint, { method: 'POST', headers: { 'x-hook-signature': signature } });",
|
|
10
|
+
'}',
|
|
11
|
+
].join('\n');
|
|
12
|
+
describe('SemanticBugsGate', () => {
|
|
13
|
+
let cwd;
|
|
14
|
+
beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'semantic-gate-')); });
|
|
15
|
+
afterEach(() => { fs.rmSync(cwd, { recursive: true, force: true }); });
|
|
16
|
+
function write(rel, body) {
|
|
17
|
+
fs.mkdirSync(path.dirname(path.join(cwd, rel)), { recursive: true });
|
|
18
|
+
fs.writeFileSync(path.join(cwd, rel), body);
|
|
19
|
+
}
|
|
20
|
+
it('reports a proven finding with its evidence, rule and verified metadata', async () => {
|
|
21
|
+
write('src/notify.ts', LEAKY);
|
|
22
|
+
const [failure, ...rest] = await new SemanticBugsGate().run({ cwd });
|
|
23
|
+
expect(rest).toHaveLength(0);
|
|
24
|
+
expect(failure).toMatchObject({
|
|
25
|
+
id: 'semantic-bugs', files: ['src/notify.ts'], line: 2, severity: 'high',
|
|
26
|
+
provenance: 'security', source: 'ast', category: 'credential-redirect', verified: true, confidence: 1,
|
|
27
|
+
});
|
|
28
|
+
expect(failure.title).toMatch(/^\[credential-redirect\]/);
|
|
29
|
+
expect(failure.details).toContain('at src/notify.ts:2');
|
|
30
|
+
});
|
|
31
|
+
it('skips test files and declaration files', async () => {
|
|
32
|
+
write('src/notify.test.ts', LEAKY);
|
|
33
|
+
write('src/__tests__/notify.ts', LEAKY);
|
|
34
|
+
write('types/notify.d.ts', 'export declare function notify(): void;\n');
|
|
35
|
+
expect(await new SemanticBugsGate().run({ cwd })).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
it('honours a scoped run', async () => {
|
|
38
|
+
write('src/notify.ts', LEAKY);
|
|
39
|
+
write('src/other.ts', LEAKY);
|
|
40
|
+
const failures = await new SemanticBugsGate().run({ cwd, patterns: ['src/other.ts'] });
|
|
41
|
+
expect(failures.map(f => f.files?.[0])).toEqual(['src/other.ts']);
|
|
42
|
+
});
|
|
43
|
+
it('runs only the configured rules', async () => {
|
|
44
|
+
write('src/notify.ts', LEAKY);
|
|
45
|
+
expect(await new SemanticBugsGate({ rules: ['in-memory-aggregation'] }).run({ cwd })).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
it('runs learned rules from .rigour/rules next to the built-in ones', async () => {
|
|
48
|
+
write('src/load.ts', "export const load = (url: string) => fetch(url, { method: 'GET' });\n");
|
|
49
|
+
write('.rigour/rules/require-option-redirect-abcd1234.json', JSON.stringify({
|
|
50
|
+
id: 'require-option-redirect-abcd1234', version: 1,
|
|
51
|
+
pattern: { template: 'require-option', callee: { level: 'name', key: 'fetch' }, argIndex: 1, property: 'redirect' },
|
|
52
|
+
message: 'fetch(...) does not set `redirect`.', severity: 'medium',
|
|
53
|
+
source: { commit: 'abcdef0123', file: 'src/http.ts', function: 'post' },
|
|
54
|
+
validation: { firesBefore: 1, firesAfter: 0, maxHits: 3, repoHits: [] },
|
|
55
|
+
}));
|
|
56
|
+
const [failure, ...rest] = await new SemanticBugsGate().run({ cwd });
|
|
57
|
+
expect(rest).toHaveLength(0);
|
|
58
|
+
expect(failure).toMatchObject({ files: ['src/load.ts'], line: 1, category: 'learned-rule', severity: 'medium' });
|
|
59
|
+
expect(failure.title).toContain('[learned/require-option-redirect-abcd1234]');
|
|
60
|
+
expect(failure.hint).toContain('fix abcdef01 in src/http.ts (post)');
|
|
61
|
+
});
|
|
62
|
+
it('is off unless enabled in rigour.yml', async () => {
|
|
63
|
+
write('src/notify.ts', LEAKY);
|
|
64
|
+
const base = { version: 1, commands: {}, gates: { max_file_lines: 500 } };
|
|
65
|
+
const off = await new GateRunner(base).run(cwd);
|
|
66
|
+
expect(off.failures.some(f => f.id === 'semantic-bugs')).toBe(false);
|
|
67
|
+
const on = await new GateRunner({ ...base, gates: { ...base.gates, semantic_bugs: { enabled: true } } }).run(cwd);
|
|
68
|
+
expect(on.failures.some(f => f.id === 'semantic-bugs')).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -162,7 +162,8 @@ export class SideEffectAnalysisGate extends Gate {
|
|
|
162
162
|
}
|
|
163
163
|
if (varName) {
|
|
164
164
|
// Variable is stored — check for cleanup using that specific variable
|
|
165
|
-
const
|
|
165
|
+
const cleanupScope = timerCleanupScope(lines, i, scope);
|
|
166
|
+
const hasPairedCleanup = hasCleanupForVariable(lines, varName, cleanupScope.start, cleanupScope.end, cleanupPats, lang);
|
|
166
167
|
if (hasPairedCleanup)
|
|
167
168
|
continue; // Properly paired
|
|
168
169
|
}
|
|
@@ -545,3 +546,12 @@ export class SideEffectAnalysisGate extends Gate {
|
|
|
545
546
|
}
|
|
546
547
|
}
|
|
547
548
|
}
|
|
549
|
+
/**
|
|
550
|
+
* Where a stored timer's cleanup may live. A local declaration (`const t = …`)
|
|
551
|
+
* must be cleared in its own function; a timer kept on a field or an outer
|
|
552
|
+
* variable is usually cleared by another method, so the whole file counts.
|
|
553
|
+
*/
|
|
554
|
+
function timerCleanupScope(lines, lineIdx, scope) {
|
|
555
|
+
const isLocal = /^\s*(?:const|let|var)\s/.test(lines[lineIdx]);
|
|
556
|
+
return isLocal ? scope : { start: 0, end: lines.length };
|
|
557
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,162 @@
|
|
|
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 { SideEffectAnalysisGate } from './side-effect-analysis/index.js';
|
|
6
|
+
describe('SideEffectAnalysisGate', () => {
|
|
7
|
+
let cwd;
|
|
8
|
+
beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'side-effect-')); });
|
|
9
|
+
afterEach(() => { fs.rmSync(cwd, { recursive: true, force: true }); });
|
|
10
|
+
async function titlesFor(file, source) {
|
|
11
|
+
fs.mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true });
|
|
12
|
+
fs.writeFileSync(path.join(cwd, file), source);
|
|
13
|
+
const failures = await new SideEffectAnalysisGate().run({ cwd });
|
|
14
|
+
return failures.map(f => f.title);
|
|
15
|
+
}
|
|
16
|
+
describe('recursion', () => {
|
|
17
|
+
it('ends an expression-bodied arrow at its expression, not at the next block', async () => {
|
|
18
|
+
// Shape of scripts/measure-intent.mjs: a one-line helper used inside a later loop that logs.
|
|
19
|
+
const titles = await titlesFor('scripts/measure.mjs', [
|
|
20
|
+
'const pct = (n, d) => (d === 0 ? \'n/a\' : `${Math.round((100 * n) / d)}%`);',
|
|
21
|
+
'for (const run of [1, 2]) {',
|
|
22
|
+
' process.stderr.write(`run ${run}: ${pct(run, 2)}\\n`);',
|
|
23
|
+
'}',
|
|
24
|
+
].join('\n'));
|
|
25
|
+
expect(titles).not.toContain('Side-Effect: Unbounded Recursion');
|
|
26
|
+
});
|
|
27
|
+
it('still flags a block-bodied arrow that recurses with I/O and no base case', async () => {
|
|
28
|
+
const titles = await titlesFor('src/walk.ts', [
|
|
29
|
+
"import fs from 'fs';",
|
|
30
|
+
'export const walk = (dir) => {',
|
|
31
|
+
' for (const entry of fs.readdirSync(dir)) walk(`${dir}/${entry}`);',
|
|
32
|
+
'};',
|
|
33
|
+
].join('\n'));
|
|
34
|
+
expect(titles).toContain('Side-Effect: Unbounded Recursion');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('process spawns', () => {
|
|
38
|
+
it('does not treat RegExp.prototype.exec as a process spawn', async () => {
|
|
39
|
+
// Verbatim shape of the SvelteKit repo's scripts/lint-fonts.mjs.
|
|
40
|
+
const titles = await titlesFor('scripts/lint-fonts.mjs', [
|
|
41
|
+
'function checkFontFamilyDeclarations(file, source) {',
|
|
42
|
+
'\tconst findings = [];',
|
|
43
|
+
'\tconst re = /font-family\\s*:/gi;',
|
|
44
|
+
'\tlet match;',
|
|
45
|
+
'\twhile ((match = re.exec(source)) !== null) {',
|
|
46
|
+
'\t\tconst valueStart = match.index + match[0].length;',
|
|
47
|
+
'\t\tfindings.push({ file, valueStart });',
|
|
48
|
+
'\t}',
|
|
49
|
+
'\treturn findings;',
|
|
50
|
+
'}',
|
|
51
|
+
].join('\n'));
|
|
52
|
+
expect(titles.some(t => /Orphan Process/.test(t))).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
it('does not treat a database exec method as a process spawn', async () => {
|
|
55
|
+
const titles = await titlesFor('src/db.ts', "export function migrate(db: any) { db.exec('create table t (id int)'); }\n");
|
|
56
|
+
expect(titles.some(t => /Orphan Process/.test(t))).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
it('does not flag a synchronous execFileSync call', async () => {
|
|
59
|
+
const titles = await titlesFor('scripts/eval/run.ts', [
|
|
60
|
+
"import { execFileSync } from 'node:child_process';",
|
|
61
|
+
"export const revision = () => execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();",
|
|
62
|
+
].join('\n'));
|
|
63
|
+
expect(titles.some(t => /Orphan Process/.test(t))).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
it('accepts once("exit") as exit handling for a spawned process', async () => {
|
|
66
|
+
const titles = await titlesFor('scripts/upload.ts', [
|
|
67
|
+
"import { spawn } from 'node:child_process';",
|
|
68
|
+
'export async function upload(args: string[]) {',
|
|
69
|
+
' const code = await new Promise<number>((resolve, reject) => {',
|
|
70
|
+
" const child = spawn('npx', args, { stdio: 'inherit' });",
|
|
71
|
+
" child.once('error', reject);",
|
|
72
|
+
" child.once('exit', (c) => resolve(c ?? 1));",
|
|
73
|
+
' });',
|
|
74
|
+
' return code;',
|
|
75
|
+
'}',
|
|
76
|
+
].join('\n'));
|
|
77
|
+
expect(titles.some(t => /Orphan Process/.test(t))).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
it('does not read prose in a comment as a fork call', async () => {
|
|
80
|
+
const titles = await titlesFor('src/lib/seo/json-ld.ts', [
|
|
81
|
+
"// Canonical profiles, ported as data from the fork (verified against production).",
|
|
82
|
+
'export const profiles = [];',
|
|
83
|
+
].join('\n'));
|
|
84
|
+
expect(titles.some(t => /Orphan Process/.test(t))).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
it('still flags a child_process exec whose process is never handled', async () => {
|
|
87
|
+
const titles = await titlesFor('src/run.ts', [
|
|
88
|
+
"import { exec } from 'child_process';",
|
|
89
|
+
'export function run() {',
|
|
90
|
+
" exec('ls -la');",
|
|
91
|
+
'}',
|
|
92
|
+
].join('\n'));
|
|
93
|
+
expect(titles.some(t => /Orphan Process/.test(t))).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
it('still flags a spawn through a child_process namespace', async () => {
|
|
96
|
+
const titles = await titlesFor('src/run.ts', [
|
|
97
|
+
"import * as cp from 'node:child_process';",
|
|
98
|
+
'export function run() {',
|
|
99
|
+
" cp.spawn('node', ['worker.js']);",
|
|
100
|
+
'}',
|
|
101
|
+
].join('\n'));
|
|
102
|
+
expect(titles.some(t => /Orphan Process/.test(t))).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('timers', () => {
|
|
106
|
+
it('does not flag a one-shot awaited setTimeout delay', async () => {
|
|
107
|
+
const titles = await titlesFor('scripts/deploy.ts', [
|
|
108
|
+
'export async function retry(attempt: number) {',
|
|
109
|
+
' await new Promise((resolve) => setTimeout(resolve, attempt * 250));',
|
|
110
|
+
'}',
|
|
111
|
+
].join('\n'));
|
|
112
|
+
expect(titles.some(t => /Unbounded Timer/.test(t))).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
it('pairs a timer kept on a private field with a clearInterval in another method', async () => {
|
|
115
|
+
const titles = await titlesFor('src/lib/review-state.svelte.ts', [
|
|
116
|
+
'export class ReviewState {',
|
|
117
|
+
' #progressTimer: ReturnType<typeof setInterval> | undefined;',
|
|
118
|
+
' start() {',
|
|
119
|
+
' this.#progressTimer = setInterval(() => this.tick(), 500);',
|
|
120
|
+
' }',
|
|
121
|
+
' stop() {',
|
|
122
|
+
' clearInterval(this.#progressTimer);',
|
|
123
|
+
' }',
|
|
124
|
+
' tick() {}',
|
|
125
|
+
'}',
|
|
126
|
+
].join('\n'));
|
|
127
|
+
expect(titles.some(t => /Unbounded Timer/.test(t))).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
it('pairs a timer kept in an outer variable with a clearInterval in another function', async () => {
|
|
130
|
+
const titles = await titlesFor('src/lib/poller.ts', [
|
|
131
|
+
'let pollTimer: ReturnType<typeof setInterval> | undefined;',
|
|
132
|
+
'export function startPolling(refresh: () => void) {',
|
|
133
|
+
' pollTimer = setInterval(refresh, 5000);',
|
|
134
|
+
'}',
|
|
135
|
+
'export function stopPolling() {',
|
|
136
|
+
' clearInterval(pollTimer);',
|
|
137
|
+
'}',
|
|
138
|
+
].join('\n'));
|
|
139
|
+
expect(titles.some(t => /Unbounded Timer/.test(t))).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
it('still flags a local interval whose own function never clears it', async () => {
|
|
142
|
+
const titles = await titlesFor('src/lib/leak.ts', [
|
|
143
|
+
'export function start(refresh: () => void) {',
|
|
144
|
+
' const timer = setInterval(refresh, 5000);',
|
|
145
|
+
' return 1;',
|
|
146
|
+
'}',
|
|
147
|
+
'export function stop(other: ReturnType<typeof setInterval>) {',
|
|
148
|
+
' clearInterval(other);',
|
|
149
|
+
'}',
|
|
150
|
+
].join('\n'));
|
|
151
|
+
expect(titles.some(t => /Unbounded Timer/.test(t))).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
it('still flags a setInterval that is never cleared', async () => {
|
|
154
|
+
const titles = await titlesFor('src/poll.ts', [
|
|
155
|
+
'export function poll(check: () => void) {',
|
|
156
|
+
' setInterval(check, 1000);',
|
|
157
|
+
'}',
|
|
158
|
+
].join('\n'));
|
|
159
|
+
expect(titles.some(t => /Unbounded Timer/.test(t))).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|