@rigour-labs/core 6.2.5 → 6.3.0
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/sidecar-provider.js +12 -1
- package/dist/inference/types.d.ts +2 -0
- 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,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the edit a fix made and proposes rules that would have caught it.
|
|
3
|
+
*
|
|
4
|
+
* Functions are aligned by name path, calls inside them by callee text and
|
|
5
|
+
* order. Two edit shapes are generalised (Getafix-style templates):
|
|
6
|
+
* - require-option: an argument gains a property (`init` -> `{ ...init, redirect: 'manual' }`);
|
|
7
|
+
* - require-guard: a property's value gains a condition (`max-age` only when `stepsAvailable`).
|
|
8
|
+
* Each edit yields candidates from most general (callee name) to most
|
|
9
|
+
* specific (this call in this function); validation picks the first that holds.
|
|
10
|
+
*/
|
|
11
|
+
import ts from 'typescript';
|
|
12
|
+
import { forEachNode, isFunctionLike } from '../ast.js';
|
|
13
|
+
import { guardsOf, literalPropertyNames, namesIn, propertiesNamed } from './conditions.js';
|
|
14
|
+
import { calleeDeclarationOf, calleeNameOf, calleeTextOf, functionKeyOf, functionName, invocationArgs, isInvocation, } from './identity.js';
|
|
15
|
+
export function extractEdits(file, before, after) {
|
|
16
|
+
const beforeFns = functionTexts(before.sourceFile);
|
|
17
|
+
const afterFns = functionTexts(after.sourceFile);
|
|
18
|
+
const changed = [...new Set([...beforeFns.keys(), ...afterFns.keys()])]
|
|
19
|
+
.filter(fn => beforeFns.get(fn) !== afterFns.get(fn));
|
|
20
|
+
const beforeCalls = callsByFunction(before.sourceFile);
|
|
21
|
+
const afterCalls = callsByFunction(after.sourceFile);
|
|
22
|
+
const edits = [];
|
|
23
|
+
const withEdit = new Set();
|
|
24
|
+
for (const [slot, afterCall] of afterCalls) {
|
|
25
|
+
const beforeCall = beforeCalls.get(slot);
|
|
26
|
+
if (!beforeCall || beforeCall.getText() === afterCall.getText())
|
|
27
|
+
continue;
|
|
28
|
+
const fn = functionKeyOf(afterCall);
|
|
29
|
+
const found = [...optionEdits(file, fn, before, beforeCall, afterCall), ...guardEdits(file, fn, before, beforeCall, afterCall)];
|
|
30
|
+
if (found.length > 0)
|
|
31
|
+
withEdit.add(fn);
|
|
32
|
+
edits.push(...found);
|
|
33
|
+
}
|
|
34
|
+
// A function whose only change is inside a nested function that yielded an edit is covered by it.
|
|
35
|
+
const covered = (fn) => [...withEdit].some(key => key === fn || key.startsWith(`${fn}>`));
|
|
36
|
+
return { edits, unsupported: changed.filter(fn => !covered(fn)) };
|
|
37
|
+
}
|
|
38
|
+
/** Text of each named function, keyed by name path. */
|
|
39
|
+
function functionTexts(sf) {
|
|
40
|
+
const texts = new Map();
|
|
41
|
+
forEachNode(sf, (node) => {
|
|
42
|
+
if (isFunctionLike(node) && functionName(node))
|
|
43
|
+
texts.set(functionKeyOf(node), node.getText(sf));
|
|
44
|
+
});
|
|
45
|
+
return texts;
|
|
46
|
+
}
|
|
47
|
+
/** Calls keyed by `<function>|<callee text>|<ordinal>`, so a call pairs with its counterpart. */
|
|
48
|
+
function callsByFunction(sf) {
|
|
49
|
+
const calls = new Map();
|
|
50
|
+
const counts = new Map();
|
|
51
|
+
forEachNode(sf, (node) => {
|
|
52
|
+
if (!isInvocation(node))
|
|
53
|
+
return;
|
|
54
|
+
const base = `${functionKeyOf(node)}|${calleeTextOf(node)}`;
|
|
55
|
+
const ordinal = counts.get(base) ?? 0;
|
|
56
|
+
counts.set(base, ordinal + 1);
|
|
57
|
+
calls.set(`${base}|${ordinal}`, node);
|
|
58
|
+
});
|
|
59
|
+
return calls;
|
|
60
|
+
}
|
|
61
|
+
function optionEdits(file, fn, before, beforeCall, afterCall) {
|
|
62
|
+
const edits = [];
|
|
63
|
+
const afterArgs = invocationArgs(afterCall);
|
|
64
|
+
afterArgs.forEach((arg, argIndex) => {
|
|
65
|
+
const had = new Set(literalPropertyNames(invocationArgs(beforeCall)[argIndex]).map(n => n.toLowerCase()));
|
|
66
|
+
for (const property of literalPropertyNames(arg)) {
|
|
67
|
+
if (had.has(property.toLowerCase()))
|
|
68
|
+
continue;
|
|
69
|
+
const value = literalValue(arg, property);
|
|
70
|
+
const candidates = calleeLevels(before, beforeCall).map(({ callee, scoped }) => ({
|
|
71
|
+
template: 'require-option', callee, argIndex, property, ...(value ? { value } : {}), ...(scoped ? { scope: { file, fn } } : {}),
|
|
72
|
+
}));
|
|
73
|
+
edits.push({ file, fn, description: `${calleeTextOf(afterCall)}(...) gained \`${property}\``, candidates });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return edits;
|
|
77
|
+
}
|
|
78
|
+
function guardEdits(file, fn, before, beforeCall, afterCall) {
|
|
79
|
+
const edits = [];
|
|
80
|
+
const seen = new Map();
|
|
81
|
+
for (const afterProp of invocationArgs(afterCall).flatMap(arg => allProperties(arg))) {
|
|
82
|
+
const key = afterProp.name.getText().replace(/^['"`]|['"`]$/g, '');
|
|
83
|
+
const ordinal = seen.get(key.toLowerCase()) ?? 0;
|
|
84
|
+
seen.set(key.toLowerCase(), ordinal + 1);
|
|
85
|
+
const beforeProp = invocationArgs(beforeCall).flatMap(arg => propertiesNamed(arg, key))[ordinal];
|
|
86
|
+
if (!beforeProp)
|
|
87
|
+
continue;
|
|
88
|
+
const had = new Set(namesIn(guardsOf(beforeProp, beforeCall)));
|
|
89
|
+
const guard = namesIn(guardsOf(afterProp, afterCall)).find(n => !had.has(n));
|
|
90
|
+
if (!guard)
|
|
91
|
+
continue;
|
|
92
|
+
const candidates = calleeLevels(before, beforeCall).map(({ callee, scoped }) => ({
|
|
93
|
+
template: 'require-guard', callee, property: key, guard, appliesWhenArgsMention: !scoped, ...(scoped ? { scope: { file, fn } } : {}),
|
|
94
|
+
}));
|
|
95
|
+
edits.push({ file, fn, description: `\`${key}\` in ${calleeTextOf(afterCall)}(...) gained a condition on \`${guard}\``, candidates });
|
|
96
|
+
}
|
|
97
|
+
return edits;
|
|
98
|
+
}
|
|
99
|
+
/** Callee matchers from most to least general, ending with this exact call in this function. */
|
|
100
|
+
function calleeLevels(tree, call) {
|
|
101
|
+
const levels = [];
|
|
102
|
+
const name = calleeNameOf(call);
|
|
103
|
+
if (name)
|
|
104
|
+
levels.push({ callee: { level: 'name', key: name }, scoped: false });
|
|
105
|
+
const declaration = calleeDeclarationOf(tree.checker, tree.cwd, call);
|
|
106
|
+
if (declaration)
|
|
107
|
+
levels.push({ callee: { level: 'declaration', key: declaration }, scoped: false });
|
|
108
|
+
const text = calleeTextOf(call);
|
|
109
|
+
levels.push({ callee: { level: 'text', key: text }, scoped: false });
|
|
110
|
+
levels.push({ callee: { level: 'text', key: text }, scoped: true });
|
|
111
|
+
return levels;
|
|
112
|
+
}
|
|
113
|
+
function allProperties(root) {
|
|
114
|
+
const found = [];
|
|
115
|
+
forEachNode(root, (node) => { if (ts.isPropertyAssignment(node))
|
|
116
|
+
found.push(node); });
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
function literalValue(arg, property) {
|
|
120
|
+
const prop = propertiesNamed(arg, property)[0];
|
|
121
|
+
return prop && ts.isStringLiteralLike(prop.initializer) ? prop.initializer.text : undefined;
|
|
122
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trees before and after a fix commit, extracted with `git archive` into
|
|
3
|
+
* a temporary folder. Read-only against the repository: no checkout, no
|
|
4
|
+
* worktree, no change to its index or refs.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from 'child_process';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
export function extractFixTrees(repoDir, rev) {
|
|
11
|
+
const git = (...args) => execFileSync('git', ['-C', repoDir, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
|
|
12
|
+
const commit = git('rev-parse', '--verify', `${rev}^{commit}`);
|
|
13
|
+
const parent = git('rev-parse', '--verify', `${commit}^`);
|
|
14
|
+
const files = git('diff', '--name-only', '--diff-filter=M', parent, commit).split('\n').filter(Boolean);
|
|
15
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-learn-'));
|
|
16
|
+
const unpack = (sha, name) => {
|
|
17
|
+
const dir = path.join(root, name);
|
|
18
|
+
const archive = path.join(root, `${name}.tar`);
|
|
19
|
+
fs.mkdirSync(dir);
|
|
20
|
+
git('archive', '--format=tar', '-o', archive, sha);
|
|
21
|
+
execFileSync('tar', ['-xf', archive, '-C', dir]);
|
|
22
|
+
fs.rmSync(archive);
|
|
23
|
+
return dir;
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
commit,
|
|
27
|
+
beforeDir: unpack(parent, 'before'),
|
|
28
|
+
afterDir: unpack(commit, 'after'),
|
|
29
|
+
files,
|
|
30
|
+
dispose: () => fs.rmSync(root, { recursive: true, force: true }),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import type { CalleeMatcher } from './types.js';
|
|
3
|
+
export type Invocation = ts.CallExpression | ts.NewExpression;
|
|
4
|
+
export declare function isInvocation(node: ts.Node): node is Invocation;
|
|
5
|
+
export declare function invocationArgs(call: Invocation): readonly ts.Expression[];
|
|
6
|
+
/** Last name of the callee: `fetch` for `deps.fetch(...)`, `Response` for `new Response(...)`. */
|
|
7
|
+
export declare function calleeNameOf(call: Invocation): string | undefined;
|
|
8
|
+
export declare function calleeTextOf(call: Invocation): string;
|
|
9
|
+
/**
|
|
10
|
+
* `<file>#<qualified name>` of the declaration the callee resolves to: a
|
|
11
|
+
* cwd-relative path inside the project, `lib:<file>` for a library.
|
|
12
|
+
*/
|
|
13
|
+
export declare function calleeDeclarationOf(checker: ts.TypeChecker, cwd: string, call: Invocation): string | undefined;
|
|
14
|
+
export declare function calleeKey(checker: ts.TypeChecker, cwd: string, call: Invocation, level: CalleeMatcher['level']): string | undefined;
|
|
15
|
+
/** A function-like node with a name: declared, a method, or assigned to a variable or property. */
|
|
16
|
+
export declare function functionName(fn: ts.Node): string | undefined;
|
|
17
|
+
/** `outer>inner` path of the named functions around `node`, or `<module>` at the top level. */
|
|
18
|
+
export declare function functionKeyOf(node: ts.Node): string;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable identities for calls and functions, shared by learning and scanning.
|
|
3
|
+
*
|
|
4
|
+
* The same identity must come out of the code before a fix, the fixed code
|
|
5
|
+
* and the rest of the repository, so declaration keys are cwd-relative and
|
|
6
|
+
* function keys are name paths (`Class.method`, `outer>inner`), not positions.
|
|
7
|
+
*/
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import ts from 'typescript';
|
|
10
|
+
import { isFunctionLike, propertyNameText, symbolOf, unwrap } from '../ast.js';
|
|
11
|
+
export function isInvocation(node) {
|
|
12
|
+
return ts.isCallExpression(node) || ts.isNewExpression(node);
|
|
13
|
+
}
|
|
14
|
+
export function invocationArgs(call) {
|
|
15
|
+
return call.arguments ?? [];
|
|
16
|
+
}
|
|
17
|
+
/** Last name of the callee: `fetch` for `deps.fetch(...)`, `Response` for `new Response(...)`. */
|
|
18
|
+
export function calleeNameOf(call) {
|
|
19
|
+
const callee = unwrap(call.expression);
|
|
20
|
+
if (ts.isIdentifier(callee))
|
|
21
|
+
return callee.text;
|
|
22
|
+
if (ts.isPropertyAccessExpression(callee))
|
|
23
|
+
return callee.name.text;
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
export function calleeTextOf(call) {
|
|
27
|
+
return call.expression.getText(call.getSourceFile()).replace(/\s+/g, '');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* `<file>#<qualified name>` of the declaration the callee resolves to: a
|
|
31
|
+
* cwd-relative path inside the project, `lib:<file>` for a library.
|
|
32
|
+
*/
|
|
33
|
+
export function calleeDeclarationOf(checker, cwd, call) {
|
|
34
|
+
const decl = symbolOf(checker, call.expression)?.declarations?.[0];
|
|
35
|
+
if (!decl)
|
|
36
|
+
return undefined;
|
|
37
|
+
const file = decl.getSourceFile().fileName;
|
|
38
|
+
const relative = path.relative(cwd, file).replace(/\\/g, '/');
|
|
39
|
+
const where = relative.startsWith('..') || relative.includes('node_modules/') ? `lib:${path.basename(file)}` : relative;
|
|
40
|
+
return `${where}#${qualifiedName(decl)}`;
|
|
41
|
+
}
|
|
42
|
+
export function calleeKey(checker, cwd, call, level) {
|
|
43
|
+
if (level === 'name')
|
|
44
|
+
return calleeNameOf(call);
|
|
45
|
+
if (level === 'text')
|
|
46
|
+
return calleeTextOf(call);
|
|
47
|
+
return calleeDeclarationOf(checker, cwd, call);
|
|
48
|
+
}
|
|
49
|
+
/** Name path of a named declaration: `HttpDeps.fetch`, `Client.send`, `load`. */
|
|
50
|
+
function qualifiedName(decl) {
|
|
51
|
+
const names = [];
|
|
52
|
+
for (let node = decl; node && !ts.isSourceFile(node); node = node.parent) {
|
|
53
|
+
const name = declarationName(node);
|
|
54
|
+
if (name)
|
|
55
|
+
names.unshift(name);
|
|
56
|
+
}
|
|
57
|
+
return names.join('.') || '<anonymous>';
|
|
58
|
+
}
|
|
59
|
+
const NAMED_DECLARATIONS = new Set([
|
|
60
|
+
ts.SyntaxKind.VariableDeclaration, ts.SyntaxKind.FunctionDeclaration, ts.SyntaxKind.ClassDeclaration,
|
|
61
|
+
ts.SyntaxKind.InterfaceDeclaration, ts.SyntaxKind.TypeAliasDeclaration, ts.SyntaxKind.MethodDeclaration,
|
|
62
|
+
ts.SyntaxKind.MethodSignature, ts.SyntaxKind.PropertySignature, ts.SyntaxKind.PropertyDeclaration,
|
|
63
|
+
ts.SyntaxKind.ModuleDeclaration, ts.SyntaxKind.PropertyAssignment,
|
|
64
|
+
]);
|
|
65
|
+
function declarationName(node) {
|
|
66
|
+
if (!NAMED_DECLARATIONS.has(node.kind))
|
|
67
|
+
return undefined;
|
|
68
|
+
const name = node.name;
|
|
69
|
+
if (!name || ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name))
|
|
70
|
+
return undefined;
|
|
71
|
+
return propertyNameText(name);
|
|
72
|
+
}
|
|
73
|
+
/** A function-like node with a name: declared, a method, or assigned to a variable or property. */
|
|
74
|
+
export function functionName(fn) {
|
|
75
|
+
if (!isFunctionLike(fn))
|
|
76
|
+
return undefined;
|
|
77
|
+
if ((ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn)) && fn.name)
|
|
78
|
+
return propertyNameText(fn.name);
|
|
79
|
+
const parent = fn.parent;
|
|
80
|
+
if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name))
|
|
81
|
+
return parent.name.text;
|
|
82
|
+
if (ts.isPropertyAssignment(parent))
|
|
83
|
+
return propertyNameText(parent.name);
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
/** `outer>inner` path of the named functions around `node`, or `<module>` at the top level. */
|
|
87
|
+
export function functionKeyOf(node) {
|
|
88
|
+
const names = [];
|
|
89
|
+
for (let current = node; current && !ts.isSourceFile(current); current = current.parent) {
|
|
90
|
+
const name = functionName(current);
|
|
91
|
+
if (!name)
|
|
92
|
+
continue;
|
|
93
|
+
const owner = ts.isMethodDeclaration(current) && ts.isClassLike(current.parent) ? current.parent.name?.text : undefined;
|
|
94
|
+
names.unshift(owner ? `${owner}.${name}` : name);
|
|
95
|
+
}
|
|
96
|
+
return names.join('>') || '<module>';
|
|
97
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ProposedEdit } from './extract.js';
|
|
2
|
+
import type { LearnedRule } from './types.js';
|
|
3
|
+
export interface LearnInput {
|
|
4
|
+
beforeDir: string;
|
|
5
|
+
afterDir: string;
|
|
6
|
+
/** Repository the rules will guard; hits here are listed for review. */
|
|
7
|
+
repoDir: string;
|
|
8
|
+
/** Changed files, relative to both trees. */
|
|
9
|
+
files: string[];
|
|
10
|
+
commit?: string;
|
|
11
|
+
maxHits?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface Rejection {
|
|
14
|
+
edit: ProposedEdit;
|
|
15
|
+
reason: string;
|
|
16
|
+
}
|
|
17
|
+
export interface LearnReport {
|
|
18
|
+
learned: LearnedRule[];
|
|
19
|
+
rejected: Rejection[];
|
|
20
|
+
unsupported: Array<{
|
|
21
|
+
file: string;
|
|
22
|
+
fn: string;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
25
|
+
export declare function learnFromFix(input: LearnInput): Promise<LearnReport>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rigour learn`: from a fix (before and after trees) to validated rules.
|
|
3
|
+
*
|
|
4
|
+
* For each supported edit, candidates are tried from most general to most
|
|
5
|
+
* specific. The first one that fires on the code before the fix, is silent on
|
|
6
|
+
* the fixed code, and fires on at most `maxHits` places in the repository is
|
|
7
|
+
* kept. Nothing is kept on a guess: an edit whose candidates all fail is
|
|
8
|
+
* reported with the reason the most specific one failed.
|
|
9
|
+
*/
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { globby } from 'globby';
|
|
13
|
+
import { analyzeFiles } from '../engine.js';
|
|
14
|
+
import { programBatches, loadProjectConfig } from '../program.js';
|
|
15
|
+
import { compileLearnedRule, LEARNED_PREFIX } from './compile.js';
|
|
16
|
+
import { extractEdits } from './extract.js';
|
|
17
|
+
const DEFAULT_MAX_HITS = 3;
|
|
18
|
+
const SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/i;
|
|
19
|
+
const NOT_SOURCE = /\.d\.[cm]?ts$|(?:^|\/)(?:__tests__|__mocks__|tests?)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
20
|
+
export async function learnFromFix(input) {
|
|
21
|
+
const maxHits = input.maxHits ?? DEFAULT_MAX_HITS;
|
|
22
|
+
const files = input.files.filter(f => SOURCE.test(f) && !NOT_SOURCE.test(f));
|
|
23
|
+
const report = { learned: [], rejected: [], unsupported: [] };
|
|
24
|
+
const edits = [];
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
const extraction = extractEdits(file, openTree(input.beforeDir, file), openTree(input.afterDir, file));
|
|
27
|
+
edits.push(...extraction.edits);
|
|
28
|
+
report.unsupported.push(...extraction.unsupported.map(fn => ({ file, fn })));
|
|
29
|
+
}
|
|
30
|
+
if (edits.length === 0)
|
|
31
|
+
return report;
|
|
32
|
+
const candidates = edits.flatMap(edit => edit.candidates.map(pattern => toRule(edit, pattern, input.commit, maxHits)));
|
|
33
|
+
const repoFiles = await sourceFiles(input.repoDir);
|
|
34
|
+
const counts = countFindings(input, candidates, files, repoFiles);
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
for (const edit of edits) {
|
|
37
|
+
const tried = candidates.filter(rule => rule.source.file === edit.file && rule.source.function === edit.fn && edit.candidates.includes(rule.pattern));
|
|
38
|
+
const kept = tried.find(rule => failure(counts.get(rule.id), maxHits) === null);
|
|
39
|
+
if (kept && !seen.has(kept.id)) {
|
|
40
|
+
seen.add(kept.id);
|
|
41
|
+
const { before, after, hits } = counts.get(kept.id);
|
|
42
|
+
report.learned.push({ ...kept, validation: { firesBefore: before, firesAfter: after, maxHits, repoHits: hits } });
|
|
43
|
+
}
|
|
44
|
+
else if (!kept) {
|
|
45
|
+
report.rejected.push({ edit, reason: failure(counts.get(tried[tried.length - 1].id), maxHits) ?? 'no candidate' });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return report;
|
|
49
|
+
}
|
|
50
|
+
function openTree(cwd, file) {
|
|
51
|
+
const root = path.join(cwd, file);
|
|
52
|
+
const [program] = [...programBatches([root], loadProjectConfig(cwd).options)];
|
|
53
|
+
const sourceFile = program.getSourceFile(root);
|
|
54
|
+
if (!sourceFile)
|
|
55
|
+
throw new Error(`Cannot parse ${file} in ${cwd}`);
|
|
56
|
+
return { cwd, checker: program.getTypeChecker(), sourceFile };
|
|
57
|
+
}
|
|
58
|
+
function countFindings(input, rules, files, repoFiles) {
|
|
59
|
+
const compiled = rules.map(compileLearnedRule);
|
|
60
|
+
const tally = (dir, scan) => analyzeFiles(dir, scan, { rules: compiled });
|
|
61
|
+
const byRule = (findings, id) => findings.filter(f => f.rule === `${LEARNED_PREFIX}${id}`);
|
|
62
|
+
const before = tally(input.beforeDir, files);
|
|
63
|
+
const after = tally(input.afterDir, files);
|
|
64
|
+
const repo = tally(input.repoDir, repoFiles);
|
|
65
|
+
return new Map(rules.map(rule => [rule.id, {
|
|
66
|
+
before: byRule(before, rule.id).length,
|
|
67
|
+
after: byRule(after, rule.id).length,
|
|
68
|
+
hits: byRule(repo, rule.id).map(f => `${f.file}:${f.line}`),
|
|
69
|
+
}]));
|
|
70
|
+
}
|
|
71
|
+
function failure(counts, maxHits) {
|
|
72
|
+
if (counts.before === 0)
|
|
73
|
+
return 'does not fire on the code before the fix';
|
|
74
|
+
if (counts.after > 0)
|
|
75
|
+
return `still fires ${counts.after} time(s) on the fixed code`;
|
|
76
|
+
if (counts.hits.length > maxHits)
|
|
77
|
+
return `fires on ${counts.hits.length} places in the repository (max ${maxHits})`;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
async function sourceFiles(cwd) {
|
|
81
|
+
const files = await globby('**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}', {
|
|
82
|
+
cwd, gitignore: true, ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/coverage/**'],
|
|
83
|
+
});
|
|
84
|
+
return files.filter(f => !NOT_SOURCE.test(f));
|
|
85
|
+
}
|
|
86
|
+
function toRule(edit, pattern, commit, maxHits) {
|
|
87
|
+
return {
|
|
88
|
+
id: ruleId(pattern),
|
|
89
|
+
version: 1,
|
|
90
|
+
pattern,
|
|
91
|
+
message: messageFor(pattern, edit.file),
|
|
92
|
+
severity: 'medium',
|
|
93
|
+
source: { ...(commit ? { commit } : {}), file: edit.file, function: edit.fn },
|
|
94
|
+
validation: { firesBefore: 0, firesAfter: 0, maxHits, repoHits: [] },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function messageFor(pattern, file) {
|
|
98
|
+
const callee = pattern.callee.key.split('#').pop();
|
|
99
|
+
if (pattern.template === 'require-option') {
|
|
100
|
+
const value = pattern.value ? ` (e.g. '${pattern.value}')` : '';
|
|
101
|
+
return `${callee}(...) does not set \`${pattern.property}\`${value} in argument ${pattern.argIndex + 1}; a fix in ${file} added it to the same call.`;
|
|
102
|
+
}
|
|
103
|
+
return `\`${pattern.property}\` in ${callee}(...) is not conditional on \`${pattern.guard}\`; a fix in ${file} added that condition.`;
|
|
104
|
+
}
|
|
105
|
+
function ruleId(pattern) {
|
|
106
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24);
|
|
107
|
+
const hash = crypto.createHash('sha256').update(JSON.stringify(pattern)).digest('hex').slice(0, 8);
|
|
108
|
+
return `${pattern.template}-${slug(pattern.property)}-${hash}`;
|
|
109
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import ts from 'typescript';
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { forEachNode } from '../ast.js';
|
|
8
|
+
import { loadLearnCases, runLearnCase } from './benchmark.js';
|
|
9
|
+
import { guardsOf, namesIn, propertiesNamed } from './conditions.js';
|
|
10
|
+
import { functionKeyOf, isInvocation } from './identity.js';
|
|
11
|
+
import { loadLearnedRules, saveLearnedRule } from './store.js';
|
|
12
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../../benchmarks/learn');
|
|
13
|
+
describe('learn benchmark', () => {
|
|
14
|
+
for (const bench of loadLearnCases(ROOT)) {
|
|
15
|
+
it(`${bench.name}: ${bench.note}`, async () => {
|
|
16
|
+
const result = await runLearnCase(ROOT, bench);
|
|
17
|
+
expect(result.problems).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
function parse(text) {
|
|
22
|
+
return ts.createSourceFile('x.ts', text, ts.ScriptTarget.Latest, true);
|
|
23
|
+
}
|
|
24
|
+
function firstCall(sf) {
|
|
25
|
+
let call;
|
|
26
|
+
forEachNode(sf, (n) => { if (!call && isInvocation(n))
|
|
27
|
+
call = n; });
|
|
28
|
+
return call;
|
|
29
|
+
}
|
|
30
|
+
describe('guardsOf', () => {
|
|
31
|
+
it('collects the spread condition and the value condition', () => {
|
|
32
|
+
const sf = parse("h({ ...(ok ? { 'cache-control': fresh ? 'max-age=1' : 'no-store' } : {}) });");
|
|
33
|
+
const [prop] = propertiesNamed(sf, 'cache-control');
|
|
34
|
+
expect(namesIn(guardsOf(prop, firstCall(sf)))).toEqual(['fresh', 'ok']);
|
|
35
|
+
});
|
|
36
|
+
it('lists property names before receivers', () => {
|
|
37
|
+
const sf = parse("h({ c: status === 200 && body.stepsAvailable !== false ? 'a' : 'b' });");
|
|
38
|
+
const [prop] = propertiesNamed(sf, 'c');
|
|
39
|
+
expect(namesIn(guardsOf(prop, firstCall(sf)))[0]).toBe('stepsAvailable');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('functionKeyOf', () => {
|
|
43
|
+
it('names nested arrows, methods and the module level', () => {
|
|
44
|
+
const sf = parse('class A { run() { const inner = () => f(); } }\ng();\n');
|
|
45
|
+
const keys = [];
|
|
46
|
+
forEachNode(sf, (n) => { if (isInvocation(n))
|
|
47
|
+
keys.push(functionKeyOf(n)); });
|
|
48
|
+
expect(keys).toEqual(['A.run>inner', '<module>']);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('learned rule store', () => {
|
|
52
|
+
let dir;
|
|
53
|
+
afterEach(() => { if (dir)
|
|
54
|
+
fs.rmSync(dir, { recursive: true, force: true }); dir = undefined; });
|
|
55
|
+
it('round-trips a rule and skips files that are not rules', () => {
|
|
56
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'learned-'));
|
|
57
|
+
const rule = {
|
|
58
|
+
id: 'require-option-redirect-abcd1234', version: 1,
|
|
59
|
+
pattern: { template: 'require-option', callee: { level: 'name', key: 'fetch' }, argIndex: 1, property: 'redirect' },
|
|
60
|
+
message: 'm', severity: 'medium', source: { file: 'http.ts', function: 'post' },
|
|
61
|
+
validation: { firesBefore: 1, firesAfter: 0, maxHits: 3, repoHits: [] },
|
|
62
|
+
};
|
|
63
|
+
saveLearnedRule(dir, rule);
|
|
64
|
+
fs.writeFileSync(path.join(dir, '.rigour', 'rules', 'broken.json'), '{"id": 1}');
|
|
65
|
+
expect(loadLearnedRules(dir)).toEqual([rule]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Learned rules live in `.rigour/rules/<id>.json`, committed and reviewed
|
|
3
|
+
* like code. A file that does not parse as a rule is skipped with a warning.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { Logger } from '../../utils/logger.js';
|
|
8
|
+
import { ZodError } from 'zod';
|
|
9
|
+
import { LearnedRuleSchema } from './types.js';
|
|
10
|
+
export const LEARNED_RULES_DIR = path.join('.rigour', 'rules');
|
|
11
|
+
export function saveLearnedRule(cwd, rule) {
|
|
12
|
+
const dir = path.join(cwd, LEARNED_RULES_DIR);
|
|
13
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
14
|
+
const file = path.join(dir, `${rule.id}.json`);
|
|
15
|
+
fs.writeFileSync(file, `${JSON.stringify(LearnedRuleSchema.parse(rule), null, 2)}\n`);
|
|
16
|
+
return file;
|
|
17
|
+
}
|
|
18
|
+
export function loadLearnedRules(cwd) {
|
|
19
|
+
const dir = path.join(cwd, LEARNED_RULES_DIR);
|
|
20
|
+
if (!fs.existsSync(dir))
|
|
21
|
+
return [];
|
|
22
|
+
const rules = [];
|
|
23
|
+
for (const name of fs.readdirSync(dir).filter(n => n.endsWith('.json')).sort()) {
|
|
24
|
+
try {
|
|
25
|
+
rules.push(LearnedRuleSchema.parse(JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))));
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
Logger.warn(`Skipping ${path.join(LEARNED_RULES_DIR, name)}: not a valid learned rule (${reasonOf(error)})`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return rules;
|
|
32
|
+
}
|
|
33
|
+
function reasonOf(error) {
|
|
34
|
+
if (error instanceof ZodError) {
|
|
35
|
+
const [issue] = error.issues;
|
|
36
|
+
return `${issue.path.join('.') || 'rule'}: ${issue.message}`;
|
|
37
|
+
}
|
|
38
|
+
return error instanceof Error ? error.message : String(error);
|
|
39
|
+
}
|