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,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
|
-
|
|
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
|
-
|
|
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 =
|
|
198
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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;
|