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,315 @@
1
+ /**
2
+ * Tree-sitter Tree Cache with Incremental Parsing Support
3
+ *
4
+ * Caches parsed ASTs and enables incremental updates for large files.
5
+ * This provides 10-100× speedup on edits to large files (>1000 lines).
6
+ */
7
+
8
+ import * as crypto from "node:crypto";
9
+ import * as fs from "node:fs";
10
+
11
+ export interface CachedTree {
12
+ tree: any; // Tree-sitter Tree instance
13
+ contentHash: string;
14
+ languageId: string;
15
+ fileSize: number;
16
+ lineCount: number;
17
+ lastModified: number;
18
+ }
19
+
20
+ export class TreeCache {
21
+ private cache = new Map<string, CachedTree>();
22
+ private maxSize: number;
23
+ private debug: (msg: string) => void;
24
+
25
+ constructor(maxSize = 50, debug = false) {
26
+ this.maxSize = maxSize;
27
+ this.debug = debug
28
+ ? (msg: string) => console.error(`[tree-cache] ${msg}`)
29
+ : () => {};
30
+ }
31
+
32
+ /**
33
+ * Generate hash for file content
34
+ */
35
+ private hashContent(content: string): string {
36
+ return crypto
37
+ .createHash("sha256")
38
+ .update(content)
39
+ .digest("hex")
40
+ .slice(0, 16);
41
+ }
42
+
43
+ /**
44
+ * Get cache key for a file
45
+ */
46
+ private getCacheKey(filePath: string, languageId: string): string {
47
+ return `${languageId}:${filePath}`;
48
+ }
49
+
50
+ /**
51
+ * Check if tree is cached and valid
52
+ */
53
+ get(filePath: string, content: string, languageId: string): any | null {
54
+ const key = this.getCacheKey(filePath, languageId);
55
+ const cached = this.cache.get(key);
56
+
57
+ if (!cached) {
58
+ this.debug(`Cache miss: ${filePath}`);
59
+ return null;
60
+ }
61
+
62
+ // Verify language matches
63
+ if (cached.languageId !== languageId) {
64
+ this.debug(`Language mismatch for ${filePath}`);
65
+ this.cache.delete(key);
66
+ return null;
67
+ }
68
+
69
+ // Check content hash
70
+ const contentHash = this.hashContent(content);
71
+ if (cached.contentHash !== contentHash) {
72
+ this.debug(
73
+ `Content changed: ${filePath} (${cached.lineCount} → ${content.split("\n").length} lines)`,
74
+ );
75
+ // Keep old tree for potential incremental update, but mark as stale
76
+ return null;
77
+ }
78
+
79
+ // Check if file was modified on disk (mtime changed)
80
+ try {
81
+ const stats = fs.statSync(filePath);
82
+ if (stats.mtimeMs !== cached.lastModified) {
83
+ this.debug(`File modified on disk: ${filePath}`);
84
+ this.cache.delete(key);
85
+ return null;
86
+ }
87
+ } catch {
88
+ // File might be deleted, invalidate cache
89
+ this.cache.delete(key);
90
+ return null;
91
+ }
92
+
93
+ this.debug(`Cache hit: ${filePath} (${cached.lineCount} lines)`);
94
+ return cached.tree;
95
+ }
96
+
97
+ /**
98
+ * Store parsed tree in cache
99
+ */
100
+ set(filePath: string, content: string, languageId: string, tree: any): void {
101
+ // Evict oldest entries if cache is full
102
+ if (this.cache.size >= this.maxSize) {
103
+ const firstKey = this.cache.keys().next().value;
104
+ if (firstKey) {
105
+ this.cache.delete(firstKey);
106
+ this.debug(`Evicted: ${firstKey}`);
107
+ }
108
+ }
109
+
110
+ const key = this.getCacheKey(filePath, languageId);
111
+ let mtime = 0;
112
+ try {
113
+ mtime = fs.statSync(filePath).mtimeMs;
114
+ } catch {
115
+ // File deleted between parse and cache — cache with mtime=0;
116
+ // next get() will miss on mtime check and re-parse
117
+ }
118
+
119
+ this.cache.set(key, {
120
+ tree,
121
+ contentHash: this.hashContent(content),
122
+ languageId,
123
+ fileSize: content.length,
124
+ lineCount: content.split("\n").length,
125
+ lastModified: mtime,
126
+ });
127
+
128
+ this.debug(`Cached: ${filePath} (${content.split("\n").length} lines)`);
129
+ }
130
+
131
+ /**
132
+ * Calculate the diff between old and new content
133
+ * Returns edit information for incremental parsing
134
+ */
135
+ calculateEdit(
136
+ oldContent: string,
137
+ newContent: string,
138
+ ): {
139
+ startIndex: number;
140
+ oldEndIndex: number;
141
+ newEndIndex: number;
142
+ startPosition: { row: number; column: number };
143
+ oldEndPosition: { row: number; column: number };
144
+ newEndPosition: { row: number; column: number };
145
+ } | null {
146
+ // Find the first difference
147
+ let startIndex = 0;
148
+ while (
149
+ startIndex < oldContent.length &&
150
+ startIndex < newContent.length &&
151
+ oldContent[startIndex] === newContent[startIndex]
152
+ ) {
153
+ startIndex++;
154
+ }
155
+
156
+ // Find the last difference (working backwards)
157
+ let oldEndIndex = oldContent.length;
158
+ let newEndIndex = newContent.length;
159
+ while (
160
+ oldEndIndex > startIndex &&
161
+ newEndIndex > startIndex &&
162
+ oldContent[oldEndIndex - 1] === newContent[newEndIndex - 1]
163
+ ) {
164
+ oldEndIndex--;
165
+ newEndIndex--;
166
+ }
167
+
168
+ // No change detected
169
+ if (startIndex === oldContent.length && startIndex === newContent.length) {
170
+ return null;
171
+ }
172
+
173
+ // Calculate positions
174
+ const startPosition = this.indexToPosition(oldContent, startIndex);
175
+ const oldEndPosition = this.indexToPosition(oldContent, oldEndIndex);
176
+ const newEndPosition = this.indexToPosition(newContent, newEndIndex);
177
+
178
+ return {
179
+ startIndex,
180
+ oldEndIndex,
181
+ newEndIndex,
182
+ startPosition,
183
+ oldEndPosition,
184
+ newEndPosition,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Convert byte index to row/column position
190
+ */
191
+ private indexToPosition(
192
+ content: string,
193
+ index: number,
194
+ ): { row: number; column: number } {
195
+ const lines = content.slice(0, index).split("\n");
196
+ return {
197
+ row: lines.length - 1,
198
+ column: lines[lines.length - 1].length,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Attempt incremental update using tree.edit()
204
+ * Returns updated tree or null if incremental update failed
205
+ */
206
+ async incrementalUpdate(
207
+ filePath: string,
208
+ oldContent: string,
209
+ newContent: string,
210
+ languageId: string,
211
+ parser: any,
212
+ ): Promise<any | null> {
213
+ const key = this.getCacheKey(filePath, languageId);
214
+ const cached = this.cache.get(key);
215
+
216
+ if (!cached) {
217
+ this.debug(`No cached tree for incremental update: ${filePath}`);
218
+ return null;
219
+ }
220
+
221
+ // Only use incremental for large files (>100 lines)
222
+ const lineCount = oldContent.split("\n").length;
223
+ if (lineCount < 100) {
224
+ this.debug(
225
+ `File too small for incremental: ${filePath} (${lineCount} lines)`,
226
+ );
227
+ return null;
228
+ }
229
+
230
+ // Calculate edit
231
+ const edit = this.calculateEdit(oldContent, newContent);
232
+ if (!edit) {
233
+ this.debug(`No edit detected for: ${filePath}`);
234
+ return null;
235
+ }
236
+
237
+ this.debug(
238
+ `Incremental update: ${filePath} (lines ${edit.startPosition.row}-${edit.oldEndPosition.row})`,
239
+ );
240
+
241
+ try {
242
+ // Apply edit to tree
243
+ cached.tree.edit({
244
+ startIndex: edit.startIndex,
245
+ oldEndIndex: edit.oldEndIndex,
246
+ newEndIndex: edit.newEndIndex,
247
+ startPosition: edit.startPosition,
248
+ oldEndPosition: edit.oldEndPosition,
249
+ newEndPosition: edit.newEndPosition,
250
+ });
251
+
252
+ // Re-parse only changed region
253
+ const newTree = parser.parse(newContent, cached.tree);
254
+
255
+ // Update cache
256
+ this.set(filePath, newContent, languageId, newTree);
257
+
258
+ this.debug(`Incremental update successful: ${filePath}`);
259
+ return newTree;
260
+ } catch (err) {
261
+ this.debug(`Incremental update failed: ${err}`);
262
+ return null;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Clear cache for a specific file
268
+ */
269
+ invalidate(filePath: string, languageId?: string): void {
270
+ if (languageId) {
271
+ const key = this.getCacheKey(filePath, languageId);
272
+ this.cache.delete(key);
273
+ this.debug(`Invalidated: ${key}`);
274
+ } else {
275
+ // Invalidate all entries for this file
276
+ for (const [key, value] of this.cache.entries()) {
277
+ if (key.includes(filePath)) {
278
+ this.cache.delete(key);
279
+ this.debug(`Invalidated: ${key}`);
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Clear entire cache
287
+ */
288
+ clear(): void {
289
+ this.cache.clear();
290
+ this.debug("Cache cleared");
291
+ }
292
+
293
+ /**
294
+ * Get cache statistics
295
+ */
296
+ getStats(): {
297
+ size: number;
298
+ maxSize: number;
299
+ totalLines: number;
300
+ totalBytes: number;
301
+ } {
302
+ let totalLines = 0;
303
+ let totalBytes = 0;
304
+ for (const entry of this.cache.values()) {
305
+ totalLines += entry.lineCount;
306
+ totalBytes += entry.fileSize;
307
+ }
308
+ return {
309
+ size: this.cache.size,
310
+ maxSize: this.maxSize,
311
+ totalLines,
312
+ totalBytes,
313
+ };
314
+ }
315
+ }
@@ -18,6 +18,9 @@
18
18
  import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import { EXCLUDED_DIRS } from "./file-utils.js";
21
+ import { resolvePackagePath } from "./package-root.js";
22
+ import { TreeCache } from "./tree-sitter-cache.js";
23
+ import { TreeSitterNavigator } from "./tree-sitter-navigator.js";
21
24
  import {
22
25
  type TreeSitterQuery,
23
26
  TreeSitterQueryLoader,
@@ -88,6 +91,8 @@ export class TreeSitterClient {
88
91
  private initialized = false;
89
92
  private languages: Map<string, TreeSitterLanguage> = new Map();
90
93
  private parsers: Map<string, TreeSitterParserInstance> = new Map();
94
+ private treeCache: TreeCache;
95
+ private navigator = new TreeSitterNavigator();
91
96
  private grammarsDir: string;
92
97
  // biome-ignore lint/suspicious/noExplicitAny: Optional dependency loaded dynamically
93
98
  private ParserClass: any = null;
@@ -102,6 +107,7 @@ export class TreeSitterClient {
102
107
  constructor(verbose = false) {
103
108
  this.grammarsDir = this.findGrammarsDir();
104
109
  this.verbose = verbose;
110
+ this.treeCache = new TreeCache(50, verbose);
105
111
  }
106
112
 
107
113
  /** Debug logging helper */
@@ -113,32 +119,17 @@ export class TreeSitterClient {
113
119
 
114
120
  /** Find tree-sitter grammar directory */
115
121
  private findGrammarsDir(): string {
116
- // Check for downloaded grammars in web-tree-sitter/grammars first
122
+ const pkgGrammars = resolvePackagePath(
123
+ import.meta.url,
124
+ "node_modules",
125
+ "web-tree-sitter",
126
+ "grammars",
127
+ );
117
128
  const downloadedGrammars = [
118
129
  path.join(process.cwd(), "node_modules", "web-tree-sitter", "grammars"),
130
+ pkgGrammars,
119
131
  ];
120
132
 
121
- // Add __dirname-based paths if __dirname is available (CommonJS)
122
- if (typeof __dirname !== "undefined") {
123
- downloadedGrammars.push(
124
- path.join(
125
- __dirname,
126
- "..",
127
- "..",
128
- "node_modules",
129
- "web-tree-sitter",
130
- "grammars",
131
- ),
132
- path.join(
133
- __dirname,
134
- "..",
135
- "node_modules",
136
- "web-tree-sitter",
137
- "grammars",
138
- ),
139
- );
140
- }
141
-
142
133
  for (const dir of downloadedGrammars) {
143
134
  if (
144
135
  fs.existsSync(dir) &&
@@ -148,31 +139,21 @@ export class TreeSitterClient {
148
139
  }
149
140
  }
150
141
 
151
- // Fallback to legacy locations
152
142
  const candidates: string[] = [
153
143
  path.join(process.cwd(), "node_modules", "tree-sitter-wasms", "out"),
144
+ resolvePackagePath(
145
+ import.meta.url,
146
+ "node_modules",
147
+ "tree-sitter-wasms",
148
+ "out",
149
+ ),
154
150
  ];
155
151
 
156
- if (typeof __dirname !== "undefined") {
157
- candidates.push(
158
- path.join(
159
- __dirname,
160
- "..",
161
- "..",
162
- "node_modules",
163
- "tree-sitter-wasms",
164
- "out",
165
- ),
166
- path.join(__dirname, "..", "node_modules", "tree-sitter-wasms", "out"),
167
- );
168
- }
169
-
170
152
  for (const dir of candidates) {
171
153
  if (fs.existsSync(dir)) return dir;
172
154
  }
173
155
 
174
- // Default to web-tree-sitter/grammars (may need manual download)
175
- return downloadedGrammars[0];
156
+ return pkgGrammars;
176
157
  }
177
158
 
178
159
  /** Initialize tree-sitter WASM runtime */
@@ -194,8 +175,8 @@ export class TreeSitterClient {
194
175
  this.LanguageLoader = mod.Language;
195
176
 
196
177
  // Log what we're trying to load
197
- const wasmPath = path.join(
198
- process.cwd(),
178
+ const wasmPath = resolvePackagePath(
179
+ import.meta.url,
199
180
  "node_modules",
200
181
  "web-tree-sitter",
201
182
  "tree-sitter.wasm",
@@ -206,9 +187,8 @@ export class TreeSitterClient {
206
187
 
207
188
  await ParserClass.init({
208
189
  locateFile: (scriptName: string) => {
209
- // Always return the full path to the WASM file
210
- const fullPath = path.join(
211
- process.cwd(),
190
+ const fullPath = resolvePackagePath(
191
+ import.meta.url,
212
192
  "node_modules",
213
193
  "web-tree-sitter",
214
194
  scriptName,
@@ -319,8 +299,21 @@ export class TreeSitterClient {
319
299
  try {
320
300
  const content = fs.readFileSync(filePath, "utf-8");
321
301
  this.dbg(`File content length: ${content.length}`);
302
+
303
+ // Check cache first
304
+ const cachedTree = this.treeCache.get(filePath, content, languageId);
305
+ if (cachedTree) {
306
+ this.dbg(`Using cached tree for ${filePath}`);
307
+ return cachedTree;
308
+ }
309
+
310
+ // Parse and cache
322
311
  const tree = parser.parse(content);
323
312
  this.dbg(`Parsed, root node type: ${tree.rootNode.type}`);
313
+
314
+ // Cache the tree
315
+ this.treeCache.set(filePath, content, languageId, tree);
316
+
324
317
  return tree;
325
318
  } catch (err) {
326
319
  this.dbg(`Parse error: ${err}`);
@@ -328,6 +321,67 @@ export class TreeSitterClient {
328
321
  }
329
322
  }
330
323
 
324
+ /**
325
+ * Detect and extract injected content from template literals
326
+ * Used for security analysis (SQL injection, unsafe regex, etc.)
327
+ */
328
+ extractInjections(
329
+ filePath: string,
330
+ content: string,
331
+ ): Array<{
332
+ type: "sql" | "css" | "html" | "gql" | "regex";
333
+ content: string;
334
+ line: number;
335
+ column: number;
336
+ }> {
337
+ const injections: Array<{
338
+ type: "sql" | "css" | "html" | "gql" | "regex";
339
+ content: string;
340
+ line: number;
341
+ column: number;
342
+ }> = [];
343
+
344
+ // Pattern: sql`SELECT * FROM users` or query`...`
345
+ const sqlPattern = /\b(sql|query|execute)\s*`([^`]+)`/gi;
346
+ let match: RegExpExecArray | null;
347
+ while ((match = sqlPattern.exec(content)) !== null) {
348
+ const lines = content.slice(0, match.index).split("\n");
349
+ injections.push({
350
+ type: "sql",
351
+ content: match[2],
352
+ line: lines.length,
353
+ column: lines[lines.length - 1].length,
354
+ });
355
+ }
356
+
357
+ // Pattern: styled.div`color: red;` or css`...`
358
+ const cssPattern = /\b(styled(?:\.\w+)?|css)\s*`([^`]+)`/gi;
359
+ while ((match = cssPattern.exec(content)) !== null) {
360
+ const lines = content.slice(0, match.index).split("\n");
361
+ injections.push({
362
+ type: "css",
363
+ content: match[2],
364
+ line: lines.length,
365
+ column: lines[lines.length - 1].length,
366
+ });
367
+ }
368
+
369
+ // Pattern: new RegExp(`pattern`)
370
+ const regexPattern = /new\s+RegExp\s*\(\s*`([^`]+)`/gi;
371
+ while ((match = regexPattern.exec(content)) !== null) {
372
+ const lines = content.slice(0, match.index).split("\n");
373
+ injections.push({
374
+ type: "regex",
375
+ content: match[1],
376
+ line: lines.length,
377
+ column: lines[lines.length - 1].length,
378
+ });
379
+ }
380
+
381
+ this.dbg(`Found ${injections.length} injections in ${filePath}`);
382
+ return injections;
383
+ }
384
+
331
385
  /** Check if tree-sitter is available (grammars installed) */
332
386
  isAvailable(): boolean {
333
387
  return fs.existsSync(this.grammarsDir);
@@ -520,6 +574,10 @@ export class TreeSitterClient {
520
574
  return { query: "", metavars: [] };
521
575
  }
522
576
 
577
+ /**
578
+ * Inject native tree-sitter predicates into S-expression query
579
+ * This moves text filtering to WASM for better performance
580
+ */
523
581
  /** Generate cache key for compiled query */
524
582
  private getQueryCacheKey(pattern: string, languageId: string): string {
525
583
  // Simple hash for the query string
@@ -667,14 +725,6 @@ export class TreeSitterClient {
667
725
  }
668
726
  }
669
727
 
670
- if (postFilter === "not_dbg_method") {
671
- const methodNode = captures.METHOD;
672
- if (methodNode) {
673
- const methodName = methodNode.text;
674
- if (methodName === "dbg") continue; // Skip console.dbg()
675
- }
676
- }
677
-
678
728
  if (postFilter === "no_super_call") {
679
729
  const bodyNode = captures.BODY;
680
730
  if (bodyNode) {
@@ -687,6 +737,112 @@ export class TreeSitterClient {
687
737
  }
688
738
  }
689
739
 
740
+ // Scope-aware: keep only matches inside test blocks
741
+ if (postFilter === "in_test_block") {
742
+ const capturesArray = Object.values(captures);
743
+ if (capturesArray.length > 0 && capturesArray[0]) {
744
+ if (!this.navigator.isInTestBlock(capturesArray[0])) continue;
745
+ }
746
+ }
747
+
748
+ // Structural: keep only matches NOT inside a try/catch (or begin/rescue in Ruby)
749
+ if (postFilter === "not_in_try_catch") {
750
+ const capturesArray = Object.values(captures);
751
+ if (capturesArray.length > 0 && capturesArray[0]) {
752
+ if (this.navigator.isInTryCatch(capturesArray[0])) continue;
753
+ }
754
+ }
755
+
756
+ // Structural: keep only matches that ARE inside a try/catch (or begin/rescue)
757
+ if (postFilter === "in_try_catch") {
758
+ const capturesArray = Object.values(captures);
759
+ if (capturesArray.length > 0 && capturesArray[0]) {
760
+ if (!this.navigator.isInTryCatch(capturesArray[0])) continue;
761
+ }
762
+ }
763
+
764
+ // Scope-aware: keep only matches outside test blocks
765
+ if (postFilter === "not_in_test_block") {
766
+ const capturesArray = Object.values(captures);
767
+ if (capturesArray.length > 0 && capturesArray[0]) {
768
+ if (this.navigator.isInTestBlock(capturesArray[0])) continue;
769
+ }
770
+ }
771
+
772
+ // Structural: NAME capture must equal PARAM capture (actual shadowing check)
773
+ if (postFilter === "name_matches_param") {
774
+ const nameNode = captures.NAME;
775
+ const paramNode = captures.PARAM;
776
+ if (!nameNode || !paramNode) continue;
777
+ if (nameNode.text !== paramNode.text) continue;
778
+ }
779
+
780
+ // Scope: skip matches inside any function/method definition
781
+ if (postFilter === "not_in_function") {
782
+ const first = Object.values(captures)[0];
783
+ if (
784
+ first &&
785
+ this.navigator.isInside(first, [
786
+ "function_definition",
787
+ "function_declaration",
788
+ "method_definition",
789
+ "arrow_function",
790
+ ])
791
+ )
792
+ continue;
793
+ }
794
+
795
+ // Security: variable name must match secret naming patterns
796
+ if (postFilter === "check_secret_pattern") {
797
+ const varName = (captures.VARNAME?.text ?? "").toLowerCase();
798
+ const secretPatterns = [
799
+ /api[_-]?key/,
800
+ /api[_-]?secret/,
801
+ /password/,
802
+ /passwd/,
803
+ /secret/,
804
+ /token/,
805
+ /auth/,
806
+ /private[_-]?key/,
807
+ /access[_-]?token/,
808
+ /credentials/,
809
+ /aws[_-]?secret/,
810
+ /github[_-]?token/,
811
+ /private[_-]?key/,
812
+ /client[_-]?secret/,
813
+ ];
814
+ if (!secretPatterns.some((p) => p.test(varName))) continue;
815
+ }
816
+
817
+ // Python: except body that only contains pass (effectively empty)
818
+ if (postFilter === "python_empty_except") {
819
+ const bodyNode = captures.BODY;
820
+ if (bodyNode) {
821
+ // biome-ignore lint/suspicious/noExplicitAny: tree-sitter node
822
+ const realStmts = bodyNode.children.filter(
823
+ (c: any) =>
824
+ c.isNamed &&
825
+ c.type !== "pass_statement" &&
826
+ c.type !== "comment",
827
+ );
828
+ if (realStmts.length > 0) continue; // has real statements
829
+ }
830
+ }
831
+
832
+ // Ruby: rescue body with no meaningful statements
833
+ if (postFilter === "ruby_empty_rescue") {
834
+ const bodyNode = captures.BODY;
835
+ if (bodyNode) {
836
+ // biome-ignore lint/suspicious/noExplicitAny: tree-sitter node
837
+ const realStmts = bodyNode.children.filter(
838
+ (c: any) =>
839
+ c.isNamed &&
840
+ !["comment", "nil", "nil_literal"].includes(c.type),
841
+ );
842
+ if (realStmts.length > 0) continue;
843
+ }
844
+ }
845
+
690
846
  // Use first capture for position info
691
847
  if (match.captures.length > 0) {
692
848
  const firstNode = match.captures[0].node;