pi-lens 3.7.1 → 3.8.2
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/CHANGELOG.md +217 -0
- package/README.md +706 -619
- package/clients/architect-client.ts +7 -2
- package/clients/ast-grep-client.ts +7 -1
- package/clients/dispatch/plan.ts +10 -4
- package/clients/dispatch/runners/architect.ts +20 -7
- package/clients/dispatch/runners/ast-grep-napi.ts +5 -2
- package/clients/dispatch/runners/ast-grep.ts +29 -18
- package/clients/dispatch/runners/biome.ts +4 -4
- package/clients/dispatch/runners/python-slop.ts +17 -7
- package/clients/dispatch/runners/ruff.ts +4 -4
- package/clients/dispatch/runners/tree-sitter.ts +30 -19
- package/clients/dispatch/runners/ts-slop.ts +17 -7
- package/clients/dispatch/runners/utils/runner-helpers.ts +76 -8
- package/clients/dispatch/utils/format-utils.ts +2 -1
- package/clients/fix-scanners.ts +8 -8
- package/clients/installer/index.ts +19 -1
- package/clients/lsp/index.ts +0 -40
- package/clients/lsp/launch.ts +5 -2
- package/clients/package-root.ts +44 -0
- package/clients/pipeline.ts +179 -8
- package/clients/scan-utils.ts +20 -32
- package/clients/sg-runner.ts +7 -5
- package/clients/source-filter.ts +222 -0
- package/clients/startup-scan.ts +142 -0
- package/clients/todo-scanner.ts +44 -55
- package/clients/tree-sitter-cache.ts +315 -0
- package/clients/tree-sitter-client.ts +208 -52
- package/clients/tree-sitter-fixer.ts +217 -0
- package/clients/tree-sitter-navigator.ts +329 -0
- package/clients/tree-sitter-query-loader.ts +55 -32
- package/commands/booboo.ts +47 -35
- package/default-architect.yaml +76 -87
- package/docs/ARCHITECTURE.md +74 -0
- package/docs/AST_GREP_RULES.md +266 -0
- package/docs/COMPLEXITY_METRICS.md +120 -0
- package/docs/EXCLUSIONS.md +83 -0
- package/docs/LSP_CONFIG.md +240 -0
- package/docs/TREE_SITTER_RULES.md +340 -0
- package/docs/WRITING_NEW_AST_GREP_RULES.md +200 -0
- package/index.ts +209 -86
- package/package.json +13 -4
- package/rules/ast-grep-rules/rules/array-callback-return-js.yml +33 -0
- package/rules/ast-grep-rules/rules/array-callback-return.yml +1 -1
- package/rules/ast-grep-rules/rules/constructor-super-js.yml +22 -0
- package/rules/ast-grep-rules/rules/empty-catch-js.yml +45 -0
- package/rules/ast-grep-rules/rules/empty-catch.yml +1 -1
- package/rules/ast-grep-rules/rules/getter-return-js.yml +59 -0
- package/rules/ast-grep-rules/rules/getter-return.yml +1 -1
- package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +12 -0
- package/rules/ast-grep-rules/rules/jsx-boolean-short-circuit.yml +1 -1
- package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +14 -0
- package/rules/ast-grep-rules/rules/missed-concurrency-js.yml +25 -0
- package/rules/ast-grep-rules/rules/nested-ternary-js.yml +10 -0
- package/rules/ast-grep-rules/rules/no-alert-js.yml +6 -0
- package/rules/ast-grep-rules/rules/no-architecture-violation.yml +21 -18
- package/rules/ast-grep-rules/rules/no-array-constructor-js.yml +10 -0
- package/rules/ast-grep-rules/rules/no-async-promise-executor-js.yml +15 -0
- package/rules/ast-grep-rules/rules/no-async-promise-executor.yml +1 -1
- package/rules/ast-grep-rules/rules/no-await-in-loop-js.yml +30 -0
- package/rules/ast-grep-rules/rules/no-await-in-promise-all-js.yml +20 -0
- package/rules/ast-grep-rules/rules/no-await-in-promise-all.yml +1 -1
- package/rules/ast-grep-rules/rules/no-bare-except.yml +1 -1
- package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +16 -0
- package/rules/ast-grep-rules/rules/no-compare-neg-zero-js.yml +13 -0
- package/rules/ast-grep-rules/rules/no-compare-neg-zero.yml +1 -1
- package/rules/ast-grep-rules/rules/no-comparison-to-none.yml +1 -1
- package/rules/ast-grep-rules/rules/no-cond-assign-js.yml +36 -0
- package/rules/ast-grep-rules/rules/no-cond-assign.yml +1 -1
- package/rules/ast-grep-rules/rules/no-constant-condition-js.yml +25 -0
- package/rules/ast-grep-rules/rules/no-constant-condition.yml +1 -1
- package/rules/ast-grep-rules/rules/no-constructor-return-js.yml +28 -0
- package/rules/ast-grep-rules/rules/no-constructor-return.yml +1 -1
- package/rules/ast-grep-rules/rules/no-discarded-error-js.yml +25 -0
- package/rules/ast-grep-rules/rules/no-discarded-error.yml +25 -0
- package/rules/ast-grep-rules/rules/no-dupe-args-js.yml +15 -0
- package/rules/ast-grep-rules/rules/no-dupe-keys-js.yml +73 -0
- package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +25 -0
- package/rules/ast-grep-rules/rules/no-hardcoded-secrets-js.yml +17 -0
- package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +15 -0
- package/rules/ast-grep-rules/rules/no-inner-html-js.yml +13 -0
- package/rules/ast-grep-rules/rules/no-insecure-randomness-js.yml +20 -0
- package/rules/ast-grep-rules/rules/no-insecure-randomness.yml +1 -1
- package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +11 -0
- package/rules/ast-grep-rules/rules/no-nan-comparison-js.yml +22 -0
- package/rules/ast-grep-rules/rules/no-nan-comparison.yml +22 -0
- package/rules/ast-grep-rules/rules/no-new-symbol-js.yml +8 -0
- package/rules/ast-grep-rules/rules/no-new-wrappers-js.yml +13 -0
- package/rules/ast-grep-rules/rules/no-open-redirect-js.yml +15 -0
- package/rules/ast-grep-rules/rules/no-prototype-builtins-js.yml +15 -0
- package/rules/ast-grep-rules/rules/no-prototype-builtins.yml +1 -1
- package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +13 -0
- package/rules/ast-grep-rules/rules/no-sql-in-code.yml +1 -1
- package/rules/ast-grep-rules/rules/no-throw-string-js.yml +12 -0
- package/rules/ast-grep-rules/rules/no-throw-string.yml +1 -1
- package/rules/ast-grep-rules/rules/strict-equality-js.yml +10 -0
- package/rules/ast-grep-rules/rules/strict-inequality-js.yml +10 -0
- package/rules/ast-grep-rules/rules/toctou-js.yml +112 -0
- package/rules/ast-grep-rules/rules/toctou.yml +1 -1
- package/rules/ast-grep-rules/rules/unchecked-sync-fs-js.yml +44 -0
- package/rules/ast-grep-rules/rules/unchecked-sync-fs.yml +44 -0
- package/rules/ast-grep-rules/rules/unchecked-throwing-call-js.yml +31 -0
- package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +48 -0
- package/rules/ast-grep-rules/rules/unchecked-throwing-call-ruby.yml +47 -0
- package/rules/ast-grep-rules/rules/unchecked-throwing-call.yml +31 -0
- package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +15 -0
- package/rules/tree-sitter-queries/go/go-bare-error.yml +47 -0
- package/rules/tree-sitter-queries/go/go-defer-in-loop.yml +47 -0
- package/rules/tree-sitter-queries/go/go-hardcoded-secrets.yml +54 -0
- package/rules/tree-sitter-queries/python/is-vs-equals.yml +1 -1
- package/rules/tree-sitter-queries/python/python-debugger.yml +46 -0
- package/rules/tree-sitter-queries/python/python-empty-except.yml +48 -0
- package/rules/tree-sitter-queries/python/python-hardcoded-secrets.yml +44 -0
- package/rules/tree-sitter-queries/python/python-mutable-class-attr.yml +57 -0
- package/rules/tree-sitter-queries/python/python-print-statement.yml +53 -0
- package/rules/tree-sitter-queries/python/python-raise-string.yml +38 -0
- package/rules/tree-sitter-queries/python/python-unsafe-regex.yml +58 -0
- package/rules/tree-sitter-queries/ruby/ruby-debugger.yml +44 -0
- package/rules/tree-sitter-queries/ruby/ruby-empty-rescue.yml +47 -0
- package/rules/tree-sitter-queries/ruby/ruby-eval.yml +43 -0
- package/rules/tree-sitter-queries/ruby/ruby-hardcoded-secrets.yml +40 -0
- package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +48 -0
- package/rules/tree-sitter-queries/ruby/ruby-puts-statement.yml +52 -0
- package/rules/tree-sitter-queries/ruby/ruby-rescue-exception.yml +51 -0
- package/rules/tree-sitter-queries/ruby/ruby-unsafe-regex.yml +49 -0
- package/rules/tree-sitter-queries/rust/rust-clone-in-loop.yml +49 -0
- package/rules/tree-sitter-queries/rust/rust-unwrap.yml +45 -0
- package/rules/tree-sitter-queries/typescript/console-statement.yml +3 -3
- package/rules/tree-sitter-queries/typescript/hardcoded-secrets.yml +13 -27
- package/rules/tree-sitter-queries/typescript/injections.scm +40 -0
- package/rules/tree-sitter-queries/typescript/no-console-in-tests.yml +52 -0
- package/rules/tree-sitter-queries/typescript/sql-injection.yml +55 -0
- package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +71 -0
- package/rules/tree-sitter-queries/typescript/variable-shadowing.yml +51 -0
- package/scripts/download-grammars.ts +78 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree-sitter Fixer
|
|
3
|
+
*
|
|
4
|
+
* Calculates text replacements for tree-sitter structural matches.
|
|
5
|
+
* Used to implement auto-fixes for tree-sitter rules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
|
|
10
|
+
export interface FixTemplate {
|
|
11
|
+
action: "remove" | "replace" | "wrap";
|
|
12
|
+
template?: string; // For replace/wrap: use {{VAR}} for metavariable interpolation
|
|
13
|
+
replacement?: string; // Simple string replacement
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FixEdit {
|
|
17
|
+
filePath: string;
|
|
18
|
+
startLine: number;
|
|
19
|
+
startColumn: number;
|
|
20
|
+
endLine: number;
|
|
21
|
+
endColumn: number;
|
|
22
|
+
oldText: string;
|
|
23
|
+
newText: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class TreeSitterFixer {
|
|
27
|
+
/**
|
|
28
|
+
* Calculate fix for a structural match
|
|
29
|
+
*/
|
|
30
|
+
calculateFix(
|
|
31
|
+
filePath: string,
|
|
32
|
+
nodeRange: {
|
|
33
|
+
startLine: number;
|
|
34
|
+
startColumn: number;
|
|
35
|
+
endLine: number;
|
|
36
|
+
endColumn: number;
|
|
37
|
+
},
|
|
38
|
+
nodeText: string,
|
|
39
|
+
template: FixTemplate,
|
|
40
|
+
captures: Record<string, string>,
|
|
41
|
+
): FixEdit | null {
|
|
42
|
+
switch (template.action) {
|
|
43
|
+
case "remove":
|
|
44
|
+
return this.calculateRemove(filePath, nodeRange, nodeText);
|
|
45
|
+
case "replace":
|
|
46
|
+
return this.calculateReplace(
|
|
47
|
+
filePath,
|
|
48
|
+
nodeRange,
|
|
49
|
+
nodeText,
|
|
50
|
+
template,
|
|
51
|
+
captures,
|
|
52
|
+
);
|
|
53
|
+
case "wrap":
|
|
54
|
+
return this.calculateWrap(
|
|
55
|
+
filePath,
|
|
56
|
+
nodeRange,
|
|
57
|
+
nodeText,
|
|
58
|
+
template,
|
|
59
|
+
captures,
|
|
60
|
+
);
|
|
61
|
+
default:
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Calculate removal fix (delete the matched node)
|
|
68
|
+
*/
|
|
69
|
+
private calculateRemove(
|
|
70
|
+
filePath: string,
|
|
71
|
+
nodeRange: {
|
|
72
|
+
startLine: number;
|
|
73
|
+
startColumn: number;
|
|
74
|
+
endLine: number;
|
|
75
|
+
endColumn: number;
|
|
76
|
+
},
|
|
77
|
+
nodeText: string,
|
|
78
|
+
): FixEdit {
|
|
79
|
+
return {
|
|
80
|
+
filePath,
|
|
81
|
+
startLine: nodeRange.startLine,
|
|
82
|
+
startColumn: nodeRange.startColumn,
|
|
83
|
+
endLine: nodeRange.endLine,
|
|
84
|
+
endColumn: nodeRange.endColumn,
|
|
85
|
+
oldText: nodeText,
|
|
86
|
+
newText: "",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Calculate replacement fix
|
|
92
|
+
*/
|
|
93
|
+
private calculateReplace(
|
|
94
|
+
filePath: string,
|
|
95
|
+
nodeRange: {
|
|
96
|
+
startLine: number;
|
|
97
|
+
startColumn: number;
|
|
98
|
+
endLine: number;
|
|
99
|
+
endColumn: number;
|
|
100
|
+
},
|
|
101
|
+
nodeText: string,
|
|
102
|
+
template: FixTemplate,
|
|
103
|
+
captures: Record<string, string>,
|
|
104
|
+
): FixEdit | null {
|
|
105
|
+
let newText = template.replacement || template.template || "";
|
|
106
|
+
|
|
107
|
+
// Interpolate captures: {{VAR}} -> capture value
|
|
108
|
+
for (const [name, value] of Object.entries(captures)) {
|
|
109
|
+
newText = newText.replace(new RegExp(`\\{\\{${name}\\}\\}`, "g"), value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
filePath,
|
|
114
|
+
startLine: nodeRange.startLine,
|
|
115
|
+
startColumn: nodeRange.startColumn,
|
|
116
|
+
endLine: nodeRange.endLine,
|
|
117
|
+
endColumn: nodeRange.endColumn,
|
|
118
|
+
oldText: nodeText,
|
|
119
|
+
newText,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Calculate wrap fix (wrap matched code in new structure)
|
|
125
|
+
*/
|
|
126
|
+
private calculateWrap(
|
|
127
|
+
filePath: string,
|
|
128
|
+
nodeRange: {
|
|
129
|
+
startLine: number;
|
|
130
|
+
startColumn: number;
|
|
131
|
+
endLine: number;
|
|
132
|
+
endColumn: number;
|
|
133
|
+
},
|
|
134
|
+
nodeText: string,
|
|
135
|
+
template: FixTemplate,
|
|
136
|
+
captures: Record<string, string>,
|
|
137
|
+
): FixEdit | null {
|
|
138
|
+
if (!template.template) return null;
|
|
139
|
+
|
|
140
|
+
let wrapped = template.template;
|
|
141
|
+
|
|
142
|
+
// Replace {{BODY}} or similar with the actual code
|
|
143
|
+
for (const [name, value] of Object.entries(captures)) {
|
|
144
|
+
wrapped = wrapped.replace(new RegExp(`\\{\\{${name}\\}\\}`, "g"), value);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
filePath,
|
|
149
|
+
startLine: nodeRange.startLine,
|
|
150
|
+
startColumn: nodeRange.startColumn,
|
|
151
|
+
endLine: nodeRange.endLine,
|
|
152
|
+
endColumn: nodeRange.endColumn,
|
|
153
|
+
oldText: nodeText,
|
|
154
|
+
newText: wrapped,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Apply a fix to a file
|
|
160
|
+
*/
|
|
161
|
+
applyFix(edit: FixEdit): void {
|
|
162
|
+
const content = fs.readFileSync(edit.filePath, "utf-8");
|
|
163
|
+
const lines = content.split("\n");
|
|
164
|
+
|
|
165
|
+
// Calculate absolute positions
|
|
166
|
+
const startLineIdx = edit.startLine - 1; // 0-indexed
|
|
167
|
+
const endLineIdx = edit.endLine - 1;
|
|
168
|
+
|
|
169
|
+
// Build new content
|
|
170
|
+
const before = lines.slice(0, startLineIdx).join("\n");
|
|
171
|
+
const after = lines.slice(endLineIdx + 1).join("\n");
|
|
172
|
+
|
|
173
|
+
// Handle same-line case
|
|
174
|
+
if (startLineIdx === endLineIdx) {
|
|
175
|
+
const line = lines[startLineIdx];
|
|
176
|
+
const beforePart = line.slice(0, edit.startColumn);
|
|
177
|
+
const afterPart = line.slice(edit.endColumn);
|
|
178
|
+
const newLine = beforePart + edit.newText + afterPart;
|
|
179
|
+
lines[startLineIdx] = newLine;
|
|
180
|
+
fs.writeFileSync(edit.filePath, lines.join("\n"), "utf-8");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Multi-line replacement
|
|
185
|
+
const newContent =
|
|
186
|
+
(before ? before + "\n" : "") +
|
|
187
|
+
edit.newText +
|
|
188
|
+
(after ? "\n" + after : "");
|
|
189
|
+
|
|
190
|
+
fs.writeFileSync(edit.filePath, newContent, "utf-8");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Check if two edits overlap (can't apply both)
|
|
195
|
+
*/
|
|
196
|
+
editsOverlap(edit1: FixEdit, edit2: FixEdit): boolean {
|
|
197
|
+
if (edit1.filePath !== edit2.filePath) return false;
|
|
198
|
+
|
|
199
|
+
// Simple line-based overlap check
|
|
200
|
+
// More sophisticated would check column ranges too
|
|
201
|
+
return !(
|
|
202
|
+
edit1.endLine < edit2.startLine || edit2.endLine < edit1.startLine
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Sort edits by position (top to bottom) for sequential application
|
|
208
|
+
*/
|
|
209
|
+
sortEdits(edits: FixEdit[]): FixEdit[] {
|
|
210
|
+
return [...edits].sort((a, b) => {
|
|
211
|
+
if (a.startLine !== b.startLine) {
|
|
212
|
+
return a.startLine - b.startLine;
|
|
213
|
+
}
|
|
214
|
+
return a.startColumn - b.startColumn;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree-sitter AST Navigator
|
|
3
|
+
*
|
|
4
|
+
* Provides tree traversal utilities for context-aware analysis:
|
|
5
|
+
* - Parent/sibling navigation
|
|
6
|
+
* - Scope detection (test vs production)
|
|
7
|
+
* - Variable shadowing detection
|
|
8
|
+
* - Context-aware filtering
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// biome-ignore lint/suspicious/noExplicitAny: AST node type from web-tree-sitter
|
|
12
|
+
export type ASTNode = any;
|
|
13
|
+
|
|
14
|
+
export interface ScopeContext {
|
|
15
|
+
isTestBlock: boolean;
|
|
16
|
+
isLoop: boolean;
|
|
17
|
+
isAsync: boolean;
|
|
18
|
+
functionDepth: number;
|
|
19
|
+
scopeChain: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class TreeSitterNavigator {
|
|
23
|
+
/**
|
|
24
|
+
* Find parent node of a specific type
|
|
25
|
+
*/
|
|
26
|
+
findParent(node: ASTNode, type: string | string[]): ASTNode | null {
|
|
27
|
+
let current = node.parent;
|
|
28
|
+
const types = Array.isArray(type) ? type : [type];
|
|
29
|
+
|
|
30
|
+
while (current) {
|
|
31
|
+
if (types.includes(current.type)) {
|
|
32
|
+
return current;
|
|
33
|
+
}
|
|
34
|
+
current = current.parent;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if node is inside a specific type of parent
|
|
41
|
+
*/
|
|
42
|
+
isInside(node: ASTNode, type: string | string[]): boolean {
|
|
43
|
+
return this.findParent(node, type) !== null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get all ancestors of a node
|
|
48
|
+
*/
|
|
49
|
+
getAncestors(node: ASTNode): ASTNode[] {
|
|
50
|
+
const ancestors: ASTNode[] = [];
|
|
51
|
+
let current = node.parent;
|
|
52
|
+
|
|
53
|
+
while (current) {
|
|
54
|
+
ancestors.push(current);
|
|
55
|
+
current = current.parent;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return ancestors;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Detect if we're in a test block (it, describe, test, etc.)
|
|
63
|
+
*/
|
|
64
|
+
isInTestBlock(node: ASTNode): boolean {
|
|
65
|
+
const testPatterns = [
|
|
66
|
+
"call_expression", // it(), describe(), test()
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
let current = node.parent;
|
|
70
|
+
while (current) {
|
|
71
|
+
if (testPatterns.includes(current.type)) {
|
|
72
|
+
// Check if the call is a test function
|
|
73
|
+
const callName = this.getCallName(current);
|
|
74
|
+
if (
|
|
75
|
+
callName &&
|
|
76
|
+
/^\b(it|describe|test|before|after|beforeEach|afterEach)\b/.test(
|
|
77
|
+
callName,
|
|
78
|
+
)
|
|
79
|
+
) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
current = current.parent;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get the name of a call expression (e.g., "it" from it("test", ...))
|
|
90
|
+
*/
|
|
91
|
+
getCallName(node: ASTNode): string | null {
|
|
92
|
+
if (node.type !== "call_expression") return null;
|
|
93
|
+
|
|
94
|
+
const func = node.children[0];
|
|
95
|
+
if (!func) return null;
|
|
96
|
+
|
|
97
|
+
if (func.type === "identifier") {
|
|
98
|
+
return func.text;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (func.type === "member_expression") {
|
|
102
|
+
// Handle cases like describe.skip, it.only, etc.
|
|
103
|
+
const parts: string[] = [];
|
|
104
|
+
for (const child of func.children) {
|
|
105
|
+
if (
|
|
106
|
+
child.type === "identifier" ||
|
|
107
|
+
child.type === "property_identifier"
|
|
108
|
+
) {
|
|
109
|
+
parts.push(child.text);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return parts.join(".");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Detect if we're inside a try/catch block
|
|
120
|
+
* Use as post_filter: not_in_try_catch to enforce "must be wrapped"
|
|
121
|
+
*/
|
|
122
|
+
isInTryCatch(node: ASTNode): boolean {
|
|
123
|
+
// try_statement: TypeScript, JavaScript, Python
|
|
124
|
+
// begin: Ruby (begin/rescue/ensure)
|
|
125
|
+
// rescue: Ruby inline rescue modifier
|
|
126
|
+
return this.isInside(node, ["try_statement", "begin", "rescue"]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Detect if we're inside a loop (for, while, forEach)
|
|
131
|
+
*/
|
|
132
|
+
isInLoop(node: ASTNode): boolean {
|
|
133
|
+
return this.isInside(node, [
|
|
134
|
+
"for_statement",
|
|
135
|
+
"while_statement",
|
|
136
|
+
"do_statement",
|
|
137
|
+
"for_in_statement",
|
|
138
|
+
"for_of_statement",
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Detect if we're in an async context (async function or contains await)
|
|
144
|
+
*/
|
|
145
|
+
isInAsyncContext(node: ASTNode): boolean {
|
|
146
|
+
// Check parent function for async keyword
|
|
147
|
+
const functionTypes = [
|
|
148
|
+
"function_declaration",
|
|
149
|
+
"function_expression",
|
|
150
|
+
"arrow_function",
|
|
151
|
+
"method_definition",
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
let current = node.parent;
|
|
155
|
+
while (current) {
|
|
156
|
+
if (functionTypes.includes(current.type)) {
|
|
157
|
+
// Check for async keyword
|
|
158
|
+
if (current.children?.some((c: ASTNode) => c.text === "async")) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
current = current.parent;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get scope chain (list of function/block scopes enclosing this node)
|
|
169
|
+
*/
|
|
170
|
+
getScopeChain(node: ASTNode): string[] {
|
|
171
|
+
const chain: string[] = [];
|
|
172
|
+
let current = node.parent;
|
|
173
|
+
|
|
174
|
+
while (current) {
|
|
175
|
+
if (
|
|
176
|
+
[
|
|
177
|
+
"function_declaration",
|
|
178
|
+
"function_expression",
|
|
179
|
+
"arrow_function",
|
|
180
|
+
"method_definition",
|
|
181
|
+
"block",
|
|
182
|
+
"statement_block",
|
|
183
|
+
].includes(current.type)
|
|
184
|
+
) {
|
|
185
|
+
const name = this.getNodeName(current);
|
|
186
|
+
if (name) {
|
|
187
|
+
chain.push(name);
|
|
188
|
+
} else {
|
|
189
|
+
chain.push(`<${current.type}>`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
current = current.parent;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return chain;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get name of a function/class node
|
|
200
|
+
*/
|
|
201
|
+
getNodeName(node: ASTNode): string | null {
|
|
202
|
+
// Try to find name identifier
|
|
203
|
+
for (const child of node.children || []) {
|
|
204
|
+
if (child.type === "identifier" && child.isNamed) {
|
|
205
|
+
return child.text;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// For member definitions, check name field
|
|
210
|
+
if (node.type === "method_definition") {
|
|
211
|
+
const nameNode = node.children?.find(
|
|
212
|
+
(c: ASTNode) => c.type === "property_identifier",
|
|
213
|
+
);
|
|
214
|
+
return nameNode?.text || null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Check if a variable is shadowed in current scope
|
|
222
|
+
*/
|
|
223
|
+
isShadowed(node: ASTNode, varName: string): boolean {
|
|
224
|
+
// Navigate up to find if this variable name is redeclared
|
|
225
|
+
let current = node.parent;
|
|
226
|
+
|
|
227
|
+
while (current) {
|
|
228
|
+
// Check for variable declarations
|
|
229
|
+
if (
|
|
230
|
+
["variable_declaration", "lexical_declaration"].includes(current.type)
|
|
231
|
+
) {
|
|
232
|
+
// Check if this declaration shadows our variable
|
|
233
|
+
const declarator = current.children?.find(
|
|
234
|
+
(c: ASTNode) => c.type === "variable_declarator",
|
|
235
|
+
);
|
|
236
|
+
if (declarator) {
|
|
237
|
+
const idNode = declarator.children?.find(
|
|
238
|
+
(c: ASTNode) => c.type === "identifier",
|
|
239
|
+
);
|
|
240
|
+
if (idNode?.text === varName) {
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Check function parameters
|
|
247
|
+
if (
|
|
248
|
+
[
|
|
249
|
+
"function_declaration",
|
|
250
|
+
"function_expression",
|
|
251
|
+
"arrow_function",
|
|
252
|
+
"method_definition",
|
|
253
|
+
].includes(current.type)
|
|
254
|
+
) {
|
|
255
|
+
const params = current.children?.find(
|
|
256
|
+
(c: ASTNode) => c.type === "formal_parameters",
|
|
257
|
+
);
|
|
258
|
+
if (params) {
|
|
259
|
+
for (const param of params.children || []) {
|
|
260
|
+
if (param.type === "identifier" && param.text === varName) {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
current = current.parent;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get comprehensive scope context for a node
|
|
275
|
+
*/
|
|
276
|
+
getScopeContext(node: ASTNode): ScopeContext {
|
|
277
|
+
const ancestors = this.getAncestors(node);
|
|
278
|
+
const functionDepth = ancestors.filter((a) =>
|
|
279
|
+
[
|
|
280
|
+
"function_declaration",
|
|
281
|
+
"function_expression",
|
|
282
|
+
"arrow_function",
|
|
283
|
+
"method_definition",
|
|
284
|
+
].includes(a.type),
|
|
285
|
+
).length;
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
isTestBlock: this.isInTestBlock(node),
|
|
289
|
+
isLoop: this.isInLoop(node),
|
|
290
|
+
isAsync: this.isInAsyncContext(node),
|
|
291
|
+
functionDepth,
|
|
292
|
+
scopeChain: this.getScopeChain(node),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Find sibling nodes (nodes at the same level)
|
|
298
|
+
*/
|
|
299
|
+
getSiblings(node: ASTNode): ASTNode[] {
|
|
300
|
+
if (!node.parent) return [];
|
|
301
|
+
return node.parent.children?.filter((c: ASTNode) => c !== node) || [];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Get previous sibling
|
|
306
|
+
*/
|
|
307
|
+
getPreviousSibling(node: ASTNode): ASTNode | null {
|
|
308
|
+
if (!node.parent) return null;
|
|
309
|
+
const siblings = node.parent.children || [];
|
|
310
|
+
const index = siblings.indexOf(node);
|
|
311
|
+
if (index > 0) {
|
|
312
|
+
return siblings[index - 1];
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Get next sibling
|
|
319
|
+
*/
|
|
320
|
+
getNextSibling(node: ASTNode): ASTNode | null {
|
|
321
|
+
if (!node.parent) return null;
|
|
322
|
+
const siblings = node.parent.children || [];
|
|
323
|
+
const index = siblings.indexOf(node);
|
|
324
|
+
if (index >= 0 && index < siblings.length - 1) {
|
|
325
|
+
return siblings[index + 1];
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -7,9 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
9
|
import * as path from "node:path";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
import { resolvePackagePath } from "./package-root.js";
|
|
13
11
|
|
|
14
12
|
export interface TreeSitterQuery {
|
|
15
13
|
id: string;
|
|
@@ -24,6 +22,15 @@ export interface TreeSitterQuery {
|
|
|
24
22
|
post_filter?: string;
|
|
25
23
|
// biome-ignore lint/suspicious/noExplicitAny: Flexible filter params
|
|
26
24
|
post_filter_params?: Record<string, any>;
|
|
25
|
+
/**
|
|
26
|
+
* Native tree-sitter predicates for filtering (#eq?, #match?)
|
|
27
|
+
* These run in WASM and are faster than post-filters
|
|
28
|
+
*/
|
|
29
|
+
predicates?: Array<{
|
|
30
|
+
type: "eq" | "match" | "any-of";
|
|
31
|
+
var: string;
|
|
32
|
+
value: string | string[];
|
|
33
|
+
}>;
|
|
27
34
|
tags?: string[];
|
|
28
35
|
has_fix: boolean;
|
|
29
36
|
fix_action?: string;
|
|
@@ -56,38 +63,45 @@ export class TreeSitterQueryLoader {
|
|
|
56
63
|
async loadQueries(): Promise<Map<string, TreeSitterQuery[]>> {
|
|
57
64
|
if (this.loaded) return this.queries;
|
|
58
65
|
|
|
59
|
-
|
|
66
|
+
// Load from user's project rules AND package built-in rules (coexist)
|
|
67
|
+
const queryDirs = [
|
|
68
|
+
...new Set([
|
|
69
|
+
path.join(process.cwd(), "rules", "tree-sitter-queries"),
|
|
70
|
+
resolvePackagePath(import.meta.url, "rules", "tree-sitter-queries"),
|
|
71
|
+
]),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const queriesDir of queryDirs) {
|
|
75
|
+
if (!fs.existsSync(queriesDir)) {
|
|
76
|
+
this.dbg(`Queries directory not found: ${queriesDir}`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
60
79
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
80
|
+
const languageDirs = fs
|
|
81
|
+
.readdirSync(queriesDir, { withFileTypes: true })
|
|
82
|
+
.filter((d) => d.isDirectory())
|
|
83
|
+
.map((d) => d.name);
|
|
84
|
+
|
|
85
|
+
for (const lang of languageDirs) {
|
|
86
|
+
const langDir = path.join(queriesDir, lang);
|
|
87
|
+
const queryFiles = fs
|
|
88
|
+
.readdirSync(langDir)
|
|
89
|
+
.filter((f) => f.endsWith(".yml"));
|
|
65
90
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const queryFiles = fs
|
|
75
|
-
.readdirSync(langDir)
|
|
76
|
-
.filter((f) => f.endsWith(".yml"));
|
|
77
|
-
|
|
78
|
-
const langQueries: TreeSitterQuery[] = [];
|
|
79
|
-
|
|
80
|
-
for (const file of queryFiles) {
|
|
81
|
-
const filePath = path.join(langDir, file);
|
|
82
|
-
const query = this.parseQueryFile(filePath, lang);
|
|
83
|
-
if (query) {
|
|
84
|
-
langQueries.push(query);
|
|
91
|
+
const langQueries = this.queries.get(lang) ?? [];
|
|
92
|
+
|
|
93
|
+
for (const file of queryFiles) {
|
|
94
|
+
const filePath = path.join(langDir, file);
|
|
95
|
+
const query = this.parseQueryFile(filePath, lang);
|
|
96
|
+
if (query) {
|
|
97
|
+
langQueries.push(query);
|
|
98
|
+
}
|
|
85
99
|
}
|
|
86
|
-
}
|
|
87
100
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
101
|
+
if (langQueries.length > 0) {
|
|
102
|
+
this.queries.set(lang, langQueries);
|
|
103
|
+
this.dbg(`Loaded ${langQueries.length} queries for ${lang}`);
|
|
104
|
+
}
|
|
91
105
|
}
|
|
92
106
|
}
|
|
93
107
|
|
|
@@ -133,6 +147,14 @@ export class TreeSitterQueryLoader {
|
|
|
133
147
|
: undefined,
|
|
134
148
|
// biome-ignore lint/suspicious/noExplicitAny: Post filter params
|
|
135
149
|
post_filter_params: parsed.post_filter_params as any,
|
|
150
|
+
// Parse predicates if present
|
|
151
|
+
predicates: Array.isArray(parsed.predicates)
|
|
152
|
+
? parsed.predicates.map((p: any) => ({
|
|
153
|
+
type: p.type,
|
|
154
|
+
var: p.var,
|
|
155
|
+
value: p.value,
|
|
156
|
+
}))
|
|
157
|
+
: undefined,
|
|
136
158
|
tags: Array.isArray(parsed.tags) ? parsed.tags.map(String) : undefined,
|
|
137
159
|
has_fix: parsed.has_fix === true || parsed.has_fix === "true",
|
|
138
160
|
fix_action: parsed.fix_action ? String(parsed.fix_action) : undefined,
|
|
@@ -364,7 +386,8 @@ export class TreeSitterQueryLoader {
|
|
|
364
386
|
return query;
|
|
365
387
|
break;
|
|
366
388
|
case "console-statement":
|
|
367
|
-
if (pattern.includes("console")
|
|
389
|
+
if (pattern.includes("console") && !pattern.includes("test"))
|
|
390
|
+
return query;
|
|
368
391
|
break;
|
|
369
392
|
case "long-parameter-list":
|
|
370
393
|
if (pattern.includes("PARAMS")) return query;
|