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.
Files changed (135) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/README.md +706 -619
  3. package/clients/architect-client.ts +7 -2
  4. package/clients/ast-grep-client.ts +7 -1
  5. package/clients/dispatch/plan.ts +10 -4
  6. package/clients/dispatch/runners/architect.ts +20 -7
  7. package/clients/dispatch/runners/ast-grep-napi.ts +5 -2
  8. package/clients/dispatch/runners/ast-grep.ts +29 -18
  9. package/clients/dispatch/runners/biome.ts +4 -4
  10. package/clients/dispatch/runners/python-slop.ts +17 -7
  11. package/clients/dispatch/runners/ruff.ts +4 -4
  12. package/clients/dispatch/runners/tree-sitter.ts +30 -19
  13. package/clients/dispatch/runners/ts-slop.ts +17 -7
  14. package/clients/dispatch/runners/utils/runner-helpers.ts +76 -8
  15. package/clients/dispatch/utils/format-utils.ts +2 -1
  16. package/clients/fix-scanners.ts +8 -8
  17. package/clients/installer/index.ts +19 -1
  18. package/clients/lsp/index.ts +0 -40
  19. package/clients/lsp/launch.ts +5 -2
  20. package/clients/package-root.ts +44 -0
  21. package/clients/pipeline.ts +179 -8
  22. package/clients/scan-utils.ts +20 -32
  23. package/clients/sg-runner.ts +7 -5
  24. package/clients/source-filter.ts +222 -0
  25. package/clients/startup-scan.ts +142 -0
  26. package/clients/todo-scanner.ts +44 -55
  27. package/clients/tree-sitter-cache.ts +315 -0
  28. package/clients/tree-sitter-client.ts +208 -52
  29. package/clients/tree-sitter-fixer.ts +217 -0
  30. package/clients/tree-sitter-navigator.ts +329 -0
  31. package/clients/tree-sitter-query-loader.ts +55 -32
  32. package/commands/booboo.ts +47 -35
  33. package/default-architect.yaml +76 -87
  34. package/docs/ARCHITECTURE.md +74 -0
  35. package/docs/AST_GREP_RULES.md +266 -0
  36. package/docs/COMPLEXITY_METRICS.md +120 -0
  37. package/docs/EXCLUSIONS.md +83 -0
  38. package/docs/LSP_CONFIG.md +240 -0
  39. package/docs/TREE_SITTER_RULES.md +340 -0
  40. package/docs/WRITING_NEW_AST_GREP_RULES.md +200 -0
  41. package/index.ts +209 -86
  42. package/package.json +13 -4
  43. package/rules/ast-grep-rules/rules/array-callback-return-js.yml +33 -0
  44. package/rules/ast-grep-rules/rules/array-callback-return.yml +1 -1
  45. package/rules/ast-grep-rules/rules/constructor-super-js.yml +22 -0
  46. package/rules/ast-grep-rules/rules/empty-catch-js.yml +45 -0
  47. package/rules/ast-grep-rules/rules/empty-catch.yml +1 -1
  48. package/rules/ast-grep-rules/rules/getter-return-js.yml +59 -0
  49. package/rules/ast-grep-rules/rules/getter-return.yml +1 -1
  50. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +12 -0
  51. package/rules/ast-grep-rules/rules/jsx-boolean-short-circuit.yml +1 -1
  52. package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +14 -0
  53. package/rules/ast-grep-rules/rules/missed-concurrency-js.yml +25 -0
  54. package/rules/ast-grep-rules/rules/nested-ternary-js.yml +10 -0
  55. package/rules/ast-grep-rules/rules/no-alert-js.yml +6 -0
  56. package/rules/ast-grep-rules/rules/no-architecture-violation.yml +21 -18
  57. package/rules/ast-grep-rules/rules/no-array-constructor-js.yml +10 -0
  58. package/rules/ast-grep-rules/rules/no-async-promise-executor-js.yml +15 -0
  59. package/rules/ast-grep-rules/rules/no-async-promise-executor.yml +1 -1
  60. package/rules/ast-grep-rules/rules/no-await-in-loop-js.yml +30 -0
  61. package/rules/ast-grep-rules/rules/no-await-in-promise-all-js.yml +20 -0
  62. package/rules/ast-grep-rules/rules/no-await-in-promise-all.yml +1 -1
  63. package/rules/ast-grep-rules/rules/no-bare-except.yml +1 -1
  64. package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +16 -0
  65. package/rules/ast-grep-rules/rules/no-compare-neg-zero-js.yml +13 -0
  66. package/rules/ast-grep-rules/rules/no-compare-neg-zero.yml +1 -1
  67. package/rules/ast-grep-rules/rules/no-comparison-to-none.yml +1 -1
  68. package/rules/ast-grep-rules/rules/no-cond-assign-js.yml +36 -0
  69. package/rules/ast-grep-rules/rules/no-cond-assign.yml +1 -1
  70. package/rules/ast-grep-rules/rules/no-constant-condition-js.yml +25 -0
  71. package/rules/ast-grep-rules/rules/no-constant-condition.yml +1 -1
  72. package/rules/ast-grep-rules/rules/no-constructor-return-js.yml +28 -0
  73. package/rules/ast-grep-rules/rules/no-constructor-return.yml +1 -1
  74. package/rules/ast-grep-rules/rules/no-discarded-error-js.yml +25 -0
  75. package/rules/ast-grep-rules/rules/no-discarded-error.yml +25 -0
  76. package/rules/ast-grep-rules/rules/no-dupe-args-js.yml +15 -0
  77. package/rules/ast-grep-rules/rules/no-dupe-keys-js.yml +73 -0
  78. package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +25 -0
  79. package/rules/ast-grep-rules/rules/no-hardcoded-secrets-js.yml +17 -0
  80. package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +15 -0
  81. package/rules/ast-grep-rules/rules/no-inner-html-js.yml +13 -0
  82. package/rules/ast-grep-rules/rules/no-insecure-randomness-js.yml +20 -0
  83. package/rules/ast-grep-rules/rules/no-insecure-randomness.yml +1 -1
  84. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +11 -0
  85. package/rules/ast-grep-rules/rules/no-nan-comparison-js.yml +22 -0
  86. package/rules/ast-grep-rules/rules/no-nan-comparison.yml +22 -0
  87. package/rules/ast-grep-rules/rules/no-new-symbol-js.yml +8 -0
  88. package/rules/ast-grep-rules/rules/no-new-wrappers-js.yml +13 -0
  89. package/rules/ast-grep-rules/rules/no-open-redirect-js.yml +15 -0
  90. package/rules/ast-grep-rules/rules/no-prototype-builtins-js.yml +15 -0
  91. package/rules/ast-grep-rules/rules/no-prototype-builtins.yml +1 -1
  92. package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +13 -0
  93. package/rules/ast-grep-rules/rules/no-sql-in-code.yml +1 -1
  94. package/rules/ast-grep-rules/rules/no-throw-string-js.yml +12 -0
  95. package/rules/ast-grep-rules/rules/no-throw-string.yml +1 -1
  96. package/rules/ast-grep-rules/rules/strict-equality-js.yml +10 -0
  97. package/rules/ast-grep-rules/rules/strict-inequality-js.yml +10 -0
  98. package/rules/ast-grep-rules/rules/toctou-js.yml +112 -0
  99. package/rules/ast-grep-rules/rules/toctou.yml +1 -1
  100. package/rules/ast-grep-rules/rules/unchecked-sync-fs-js.yml +44 -0
  101. package/rules/ast-grep-rules/rules/unchecked-sync-fs.yml +44 -0
  102. package/rules/ast-grep-rules/rules/unchecked-throwing-call-js.yml +31 -0
  103. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +48 -0
  104. package/rules/ast-grep-rules/rules/unchecked-throwing-call-ruby.yml +47 -0
  105. package/rules/ast-grep-rules/rules/unchecked-throwing-call.yml +31 -0
  106. package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +15 -0
  107. package/rules/tree-sitter-queries/go/go-bare-error.yml +47 -0
  108. package/rules/tree-sitter-queries/go/go-defer-in-loop.yml +47 -0
  109. package/rules/tree-sitter-queries/go/go-hardcoded-secrets.yml +54 -0
  110. package/rules/tree-sitter-queries/python/is-vs-equals.yml +1 -1
  111. package/rules/tree-sitter-queries/python/python-debugger.yml +46 -0
  112. package/rules/tree-sitter-queries/python/python-empty-except.yml +48 -0
  113. package/rules/tree-sitter-queries/python/python-hardcoded-secrets.yml +44 -0
  114. package/rules/tree-sitter-queries/python/python-mutable-class-attr.yml +57 -0
  115. package/rules/tree-sitter-queries/python/python-print-statement.yml +53 -0
  116. package/rules/tree-sitter-queries/python/python-raise-string.yml +38 -0
  117. package/rules/tree-sitter-queries/python/python-unsafe-regex.yml +58 -0
  118. package/rules/tree-sitter-queries/ruby/ruby-debugger.yml +44 -0
  119. package/rules/tree-sitter-queries/ruby/ruby-empty-rescue.yml +47 -0
  120. package/rules/tree-sitter-queries/ruby/ruby-eval.yml +43 -0
  121. package/rules/tree-sitter-queries/ruby/ruby-hardcoded-secrets.yml +40 -0
  122. package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +48 -0
  123. package/rules/tree-sitter-queries/ruby/ruby-puts-statement.yml +52 -0
  124. package/rules/tree-sitter-queries/ruby/ruby-rescue-exception.yml +51 -0
  125. package/rules/tree-sitter-queries/ruby/ruby-unsafe-regex.yml +49 -0
  126. package/rules/tree-sitter-queries/rust/rust-clone-in-loop.yml +49 -0
  127. package/rules/tree-sitter-queries/rust/rust-unwrap.yml +45 -0
  128. package/rules/tree-sitter-queries/typescript/console-statement.yml +3 -3
  129. package/rules/tree-sitter-queries/typescript/hardcoded-secrets.yml +13 -27
  130. package/rules/tree-sitter-queries/typescript/injections.scm +40 -0
  131. package/rules/tree-sitter-queries/typescript/no-console-in-tests.yml +52 -0
  132. package/rules/tree-sitter-queries/typescript/sql-injection.yml +55 -0
  133. package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +71 -0
  134. package/rules/tree-sitter-queries/typescript/variable-shadowing.yml +51 -0
  135. 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 { fileURLToPath } from "node:url";
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
- const queriesDir = path.join(process.cwd(), "rules", "tree-sitter-queries");
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
- if (!fs.existsSync(queriesDir)) {
62
- this.dbg(`Queries directory not found: ${queriesDir}`);
63
- return this.queries;
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
- // Load queries from each language subdirectory
67
- const languageDirs = fs
68
- .readdirSync(queriesDir, { withFileTypes: true })
69
- .filter((d) => d.isDirectory())
70
- .map((d) => d.name);
71
-
72
- for (const lang of languageDirs) {
73
- const langDir = path.join(queriesDir, lang);
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
- if (langQueries.length > 0) {
89
- this.queries.set(lang, langQueries);
90
- this.dbg(`Loaded ${langQueries.length} queries for ${lang}`);
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")) return query;
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;