pi-lens 3.8.21 → 3.8.22

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 (47) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -0
  3. package/clients/dispatch/runners/lsp.ts +58 -3
  4. package/clients/dispatch/runners/tree-sitter.ts +467 -0
  5. package/clients/lsp/client.ts +229 -3
  6. package/clients/lsp/index.ts +111 -1
  7. package/clients/pipeline.ts +2 -2
  8. package/clients/runtime-session.ts +43 -5
  9. package/clients/tree-sitter-client.ts +162 -0
  10. package/clients/tree-sitter-logger.ts +47 -0
  11. package/clients/tree-sitter-query-loader.ts +13 -2
  12. package/package.json +3 -1
  13. package/rules/rule-catalog.json +64 -0
  14. package/rules/tree-sitter-queries/go/go-bare-error.yml +19 -7
  15. package/rules/tree-sitter-queries/go/go-command-injection.yml +55 -0
  16. package/rules/tree-sitter-queries/go/go-direct-panic.yml +45 -0
  17. package/rules/tree-sitter-queries/go/go-empty-if-err.yml +47 -0
  18. package/rules/tree-sitter-queries/go/go-goroutine-loop-capture.yml +49 -0
  19. package/rules/tree-sitter-queries/go/go-ignored-call-result.yml +51 -0
  20. package/rules/tree-sitter-queries/go/go-insecure-random.yml +51 -0
  21. package/rules/tree-sitter-queries/go/go-log-fatal.yml +49 -0
  22. package/rules/tree-sitter-queries/go/go-path-traversal.yml +51 -0
  23. package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +54 -0
  24. package/rules/tree-sitter-queries/go/go-sql-injection.yml +55 -0
  25. package/rules/tree-sitter-queries/go/go-weak-hash.yml +51 -0
  26. package/rules/tree-sitter-queries/python/python-command-injection.yml +63 -0
  27. package/rules/tree-sitter-queries/python/python-insecure-deserialization.yml +48 -0
  28. package/rules/tree-sitter-queries/python/python-insecure-random.yml +51 -0
  29. package/rules/tree-sitter-queries/python/python-path-traversal.yml +55 -0
  30. package/rules/tree-sitter-queries/python/python-sql-injection.yml +47 -0
  31. package/rules/tree-sitter-queries/python/python-ssrf.yml +50 -0
  32. package/rules/tree-sitter-queries/python/python-thread-global-write.yml +58 -0
  33. package/rules/tree-sitter-queries/python/python-weak-hash.yml +51 -0
  34. package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +56 -0
  35. package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +47 -0
  36. package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +54 -0
  37. package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +50 -0
  38. package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +59 -0
  39. package/rules/tree-sitter-queries/typescript/ts-command-injection.yml +60 -0
  40. package/rules/tree-sitter-queries/typescript/ts-detached-async-call.yml +56 -0
  41. package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +54 -0
  42. package/rules/tree-sitter-queries/typescript/ts-ssrf.yml +53 -0
  43. package/rules/tree-sitter-queries/typescript/ts-weak-hash.yml +54 -0
  44. package/scripts/validate-rule-catalog.mjs +227 -0
  45. package/skills/lsp-navigation/SKILL.md +15 -3
  46. package/tools/lsp-navigation.js +259 -28
  47. package/tools/lsp-navigation.ts +294 -29
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to pi-lens will be documented in this file.
4
4
 
5
+ ## [3.8.22] - 2026-04-09
6
+
7
+ ### Changed
8
+ - **Quick startup path for one-shot print sessions** — `--print`/`-p` now auto-selects quick startup mode to skip heavy bootstrap work and reduce startup latency. Added `PI_LENS_STARTUP_MODE=full|minimal|quick` override for explicit control.
9
+
10
+ ### Fixed
11
+ - **Cascade diagnostics formatting clarity** — turn-end cascade entries now render source location as `line <n>, col <m> code=<id>:` so diagnostic codes (for example `TS2322`) are no longer formatted in a way that can be mistaken for file line numbers.
12
+
5
13
  ## [3.8.21] - 2026-04-08
6
14
 
7
15
  ### Changed
package/README.md CHANGED
@@ -28,6 +28,8 @@ At `session_start`, pi-lens:
28
28
  - emits missing-tool install hints for detected languages when relevant
29
29
  - injects session guidance through internal context (non-user channel) to reduce acknowledgement-only first responses
30
30
 
31
+ For one-shot print sessions (for example `pi --print ...`), pi-lens auto-uses a quick startup path that skips heavy bootstrap work to reduce startup latency. You can override startup behavior with `PI_LENS_STARTUP_MODE=full|minimal|quick`.
32
+
31
33
  ### Turn End
32
34
 
33
35
  At `turn_end`, pi-lens:
@@ -25,6 +25,33 @@ import { readFileContent } from "./utils.js";
25
25
 
26
26
  const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
27
27
  const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
28
+ const MAX_CODE_ACTION_LOOKUPS = 6;
29
+ const MAX_CODE_ACTION_TITLES = 3;
30
+
31
+ function normalizeActionTitle(title: string): string {
32
+ return title.replace(/\s+/g, " ").trim();
33
+ }
34
+
35
+ function buildCodeActionSuggestion(
36
+ actions: import("../../lsp/client.js").LSPCodeAction[],
37
+ ): string | undefined {
38
+ if (!actions.length) return undefined;
39
+ const quickFixes = actions.filter((action) =>
40
+ action.kind?.startsWith("quickfix"),
41
+ );
42
+ if (!quickFixes.length) return undefined;
43
+
44
+ const titles = Array.from(
45
+ new Set(
46
+ quickFixes
47
+ .map((action) => normalizeActionTitle(action.title))
48
+ .filter((title) => title.length > 0),
49
+ ),
50
+ ).slice(0, MAX_CODE_ACTION_TITLES);
51
+
52
+ if (!titles.length) return undefined;
53
+ return `LSP quick fixes: ${titles.join("; ")}`;
54
+ }
28
55
 
29
56
  const lspRunner: RunnerDefinition = {
30
57
  id: "lsp",
@@ -125,9 +152,35 @@ const lspRunner: RunnerDefinition = {
125
152
 
126
153
  // Convert LSP diagnostics to our format
127
154
  // Defensive: filter out malformed diagnostics that may lack range
128
- const diagnostics: Diagnostic[] = lspDiags
129
- .filter((d) => d.range?.start?.line !== undefined)
130
- .map((d) => ({
155
+ const validLspDiags = lspDiags.filter((d) => d.range?.start?.line !== undefined);
156
+ const fixSuggestionByIndex = new Map<number, string>();
157
+
158
+ const blockingDiagIndexes = validLspDiags
159
+ .map((d, idx) => ({ d, idx }))
160
+ .filter(({ d }) => d.severity === 1)
161
+ .slice(0, MAX_CODE_ACTION_LOOKUPS);
162
+
163
+ for (const { d, idx } of blockingDiagIndexes) {
164
+ try {
165
+ const start = d.range.start;
166
+ const end = d.range.end ?? d.range.start;
167
+ const actions = await lspService.codeAction(
168
+ ctx.filePath,
169
+ start.line,
170
+ start.character,
171
+ end.line,
172
+ end.character,
173
+ );
174
+ const suggestion = buildCodeActionSuggestion(actions);
175
+ if (suggestion) {
176
+ fixSuggestionByIndex.set(idx, suggestion);
177
+ }
178
+ } catch {
179
+ // Best-effort enrichment only; base diagnostics remain authoritative.
180
+ }
181
+ }
182
+
183
+ const diagnostics: Diagnostic[] = validLspDiags.map((d, idx) => ({
131
184
  id: `lsp:${d.code ?? "unknown"}:${d.range.start.line}`,
132
185
  message: d.message,
133
186
  filePath: diagnosticPath,
@@ -143,6 +196,8 @@ const lspRunner: RunnerDefinition = {
143
196
  : "none",
144
197
  tool: "lsp",
145
198
  code: String(d.code ?? ""),
199
+ fixable: fixSuggestionByIndex.has(idx),
200
+ fixSuggestion: fixSuggestionByIndex.get(idx),
146
201
  }));
147
202
 
148
203
  const hasErrors = diagnostics.some((d) => d.semantic === "blocking");
@@ -8,7 +8,9 @@
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import { RuleCache } from "../../cache/rule-cache.js";
11
+ import { getSourceFiles } from "../../scan-utils.js";
11
12
  import { TreeSitterClient } from "../../tree-sitter-client.js";
13
+ import { logTreeSitter } from "../../tree-sitter-logger.js";
12
14
  import { classifyDefect } from "../diagnostic-taxonomy.js";
13
15
  import {
14
16
  queryLoader,
@@ -25,6 +27,294 @@ import type {
25
27
  // Creating a new TreeSitterClient() on every write resets TRANSFER_BUFFER (a module-level
26
28
  // WASM pointer) — concurrent writes race on _ts_init() and corrupt shared WASM state → crash.
27
29
  let _sharedClient: TreeSitterClient | null = null;
30
+ const entitySnapshotByFile = new Map<string, Map<string, string>>();
31
+ const blastFileCache = new Map<string, { expiresAt: number; files: string[] }>();
32
+ const blastCooldownByFile = new Map<string, number>();
33
+ const BLAST_CACHE_TTL_MS = 30_000;
34
+ const MAX_BLAST_FILES = 300;
35
+ const MAX_BLAST_ENTITIES = 8;
36
+ const BLAST_MAX_FILE_BYTES = 128 * 1024;
37
+ const BLAST_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
38
+ const BLAST_MAX_ELAPSED_MS = 120;
39
+ const BLAST_COOLDOWN_MS = 5_000;
40
+
41
+ interface EntityQueryDef {
42
+ id: string;
43
+ kind: string;
44
+ query: string;
45
+ }
46
+
47
+ const ENTITY_QUERIES: Partial<Record<string, EntityQueryDef[]>> = {
48
+ typescript: [
49
+ {
50
+ id: "entity-ts-function",
51
+ kind: "function",
52
+ query: "(function_declaration name: (identifier) @NAME)",
53
+ },
54
+ {
55
+ id: "entity-ts-class",
56
+ kind: "class",
57
+ query: "(class_declaration name: (type_identifier) @NAME)",
58
+ },
59
+ {
60
+ id: "entity-ts-method",
61
+ kind: "method",
62
+ query: "(method_definition name: (property_identifier) @NAME)",
63
+ },
64
+ ],
65
+ javascript: [
66
+ {
67
+ id: "entity-js-function",
68
+ kind: "function",
69
+ query: "(function_declaration name: (identifier) @NAME)",
70
+ },
71
+ {
72
+ id: "entity-js-class",
73
+ kind: "class",
74
+ query: "(class_declaration name: (identifier) @NAME)",
75
+ },
76
+ {
77
+ id: "entity-js-method",
78
+ kind: "method",
79
+ query: "(method_definition name: (property_identifier) @NAME)",
80
+ },
81
+ ],
82
+ python: [
83
+ {
84
+ id: "entity-py-function",
85
+ kind: "function",
86
+ query: "(function_definition name: (identifier) @NAME)",
87
+ },
88
+ {
89
+ id: "entity-py-class",
90
+ kind: "class",
91
+ query: "(class_definition name: (identifier) @NAME)",
92
+ },
93
+ ],
94
+ go: [
95
+ {
96
+ id: "entity-go-function",
97
+ kind: "function",
98
+ query: "(function_declaration name: (identifier) @NAME)",
99
+ },
100
+ {
101
+ id: "entity-go-method",
102
+ kind: "method",
103
+ query: "(method_declaration name: (field_identifier) @NAME)",
104
+ },
105
+ {
106
+ id: "entity-go-type",
107
+ kind: "type",
108
+ query: "(type_spec name: (type_identifier) @NAME)",
109
+ },
110
+ ],
111
+ rust: [
112
+ {
113
+ id: "entity-rs-function",
114
+ kind: "function",
115
+ query: "(function_item name: (identifier) @NAME)",
116
+ },
117
+ {
118
+ id: "entity-rs-struct",
119
+ kind: "struct",
120
+ query: "(struct_item name: (type_identifier) @NAME)",
121
+ },
122
+ {
123
+ id: "entity-rs-enum",
124
+ kind: "enum",
125
+ query: "(enum_item name: (type_identifier) @NAME)",
126
+ },
127
+ ],
128
+ ruby: [
129
+ {
130
+ id: "entity-rb-method",
131
+ kind: "method",
132
+ query: "(method name: (identifier) @NAME)",
133
+ },
134
+ {
135
+ id: "entity-rb-class",
136
+ kind: "class",
137
+ query: "(class name: (constant) @NAME)",
138
+ },
139
+ {
140
+ id: "entity-rb-module",
141
+ kind: "module",
142
+ query: "(module name: (constant) @NAME)",
143
+ },
144
+ ],
145
+ };
146
+
147
+ function escapeRegex(name: string): string {
148
+ return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
149
+ }
150
+
151
+ async function extractEntitySnapshot(
152
+ client: TreeSitterClient,
153
+ filePath: string,
154
+ languageId: string,
155
+ ): Promise<Map<string, string>> {
156
+ const defs = ENTITY_QUERIES[languageId] ?? [];
157
+ const snapshot = new Map<string, string>();
158
+
159
+ for (const def of defs) {
160
+ const matches = await client.runQueryOnFile(
161
+ {
162
+ id: def.id,
163
+ name: def.id,
164
+ severity: "info",
165
+ category: "entity",
166
+ language: languageId,
167
+ message: "",
168
+ query: def.query,
169
+ metavars: ["NAME"],
170
+ has_fix: false,
171
+ filePath: "",
172
+ },
173
+ filePath,
174
+ languageId,
175
+ { maxResults: 200 },
176
+ );
177
+
178
+ for (const match of matches) {
179
+ const name = match.captures.NAME?.trim();
180
+ if (!name) continue;
181
+ const key = `${def.kind}:${name}`;
182
+ snapshot.set(key, `${match.line}:${match.matchedText.slice(0, 400)}`);
183
+ }
184
+ }
185
+
186
+ return snapshot;
187
+ }
188
+
189
+ function diffEntitySnapshot(
190
+ prev: Map<string, string> | undefined,
191
+ next: Map<string, string>,
192
+ ): { added: string[]; removed: string[]; modified: string[] } {
193
+ if (!prev) {
194
+ return { added: [...next.keys()], removed: [], modified: [] };
195
+ }
196
+
197
+ const added: string[] = [];
198
+ const removed: string[] = [];
199
+ const modified: string[] = [];
200
+
201
+ for (const [key, value] of next.entries()) {
202
+ if (!prev.has(key)) {
203
+ added.push(key);
204
+ continue;
205
+ }
206
+ if (prev.get(key) !== value) {
207
+ modified.push(key);
208
+ }
209
+ }
210
+
211
+ for (const key of prev.keys()) {
212
+ if (!next.has(key)) {
213
+ removed.push(key);
214
+ }
215
+ }
216
+
217
+ return { added, removed, modified };
218
+ }
219
+
220
+ function getBlastFiles(cwd: string): string[] {
221
+ const now = Date.now();
222
+ const cached = blastFileCache.get(cwd);
223
+ if (cached && cached.expiresAt > now) return cached.files;
224
+
225
+ const files = getSourceFiles(cwd).slice(0, MAX_BLAST_FILES);
226
+ blastFileCache.set(cwd, { files, expiresAt: now + BLAST_CACHE_TTL_MS });
227
+ return files;
228
+ }
229
+
230
+ function computeBlastRadius(
231
+ entityNames: string[],
232
+ filePath: string,
233
+ cwd: string,
234
+ ): {
235
+ entities: Array<{ entity: string; dependentFiles: number; references: number }>;
236
+ scannedFiles: number;
237
+ scannedBytes: number;
238
+ totalCandidates: number;
239
+ truncated: boolean;
240
+ elapsedMs: number;
241
+ } {
242
+ const startedAt = Date.now();
243
+ const limited = entityNames.slice(0, MAX_BLAST_ENTITIES);
244
+ if (limited.length === 0) {
245
+ return {
246
+ entities: [],
247
+ scannedFiles: 0,
248
+ scannedBytes: 0,
249
+ totalCandidates: 0,
250
+ truncated: false,
251
+ elapsedMs: 0,
252
+ };
253
+ }
254
+
255
+ const regexByEntity = new Map(
256
+ limited.map((name) => [name, new RegExp(`\\b${escapeRegex(name)}\\b`, "g")]),
257
+ );
258
+ const files = getBlastFiles(cwd);
259
+ const stats = new Map(
260
+ limited.map((name) => [name, { dependentFiles: 0, references: 0 }]),
261
+ );
262
+ let scannedFiles = 0;
263
+ let scannedBytes = 0;
264
+ let truncated = false;
265
+
266
+ for (const candidate of files) {
267
+ if (Date.now() - startedAt > BLAST_MAX_ELAPSED_MS) {
268
+ truncated = true;
269
+ break;
270
+ }
271
+
272
+ if (path.resolve(candidate) === path.resolve(filePath)) continue;
273
+ let size = 0;
274
+ try {
275
+ size = fs.statSync(candidate).size;
276
+ } catch {
277
+ continue;
278
+ }
279
+ if (size > BLAST_MAX_FILE_BYTES) continue;
280
+ if (scannedBytes + size > BLAST_MAX_TOTAL_BYTES) {
281
+ truncated = true;
282
+ break;
283
+ }
284
+
285
+ let content = "";
286
+ try {
287
+ content = fs.readFileSync(candidate, "utf-8");
288
+ } catch {
289
+ continue;
290
+ }
291
+ scannedFiles += 1;
292
+ scannedBytes += size;
293
+
294
+ for (const [name, regex] of regexByEntity.entries()) {
295
+ const matches = content.match(regex);
296
+ if (!matches || matches.length === 0) continue;
297
+ const current = stats.get(name);
298
+ if (!current) continue;
299
+ current.dependentFiles += 1;
300
+ current.references += matches.length;
301
+ }
302
+ }
303
+
304
+ const entities = limited
305
+ .map((name) => ({ entity: name, ...stats.get(name)! }))
306
+ .sort((a, b) => b.dependentFiles - a.dependentFiles)
307
+ .slice(0, 5);
308
+
309
+ return {
310
+ entities,
311
+ scannedFiles,
312
+ scannedBytes,
313
+ totalCandidates: files.length,
314
+ truncated,
315
+ elapsedMs: Date.now() - startedAt,
316
+ };
317
+ }
28
318
 
29
319
  const SILENT_ERROR_QUERY_IDS = new Set([
30
320
  "empty-catch",
@@ -78,12 +368,25 @@ const treeSitterRunner: RunnerDefinition = {
78
368
  async run(ctx: DispatchContext): Promise<RunnerResult> {
79
369
  // Use singleton client — WASM must never be re-initialized after first call
80
370
  const client = getSharedClient();
371
+ logTreeSitter({ phase: "runner_start", filePath: ctx.filePath });
81
372
  if (!client.isAvailable()) {
373
+ logTreeSitter({
374
+ phase: "runner_skip",
375
+ filePath: ctx.filePath,
376
+ reason: "client_unavailable",
377
+ status: "skipped",
378
+ });
82
379
  return { status: "skipped", diagnostics: [], semantic: "none" };
83
380
  }
84
381
 
85
382
  const initialized = await client.init();
86
383
  if (!initialized) {
384
+ logTreeSitter({
385
+ phase: "runner_skip",
386
+ filePath: ctx.filePath,
387
+ reason: "client_init_failed",
388
+ status: "skipped",
389
+ });
87
390
  return { status: "skipped", diagnostics: [], semantic: "none" };
88
391
  }
89
392
 
@@ -106,6 +409,12 @@ const treeSitterRunner: RunnerDefinition = {
106
409
  };
107
410
  const languageId = EXT_TO_LANG[ext];
108
411
  if (!languageId) {
412
+ logTreeSitter({
413
+ phase: "runner_skip",
414
+ filePath: ctx.filePath,
415
+ reason: `unsupported_extension:${ext}`,
416
+ status: "skipped",
417
+ });
109
418
  return { status: "skipped", diagnostics: [], semantic: "none" };
110
419
  }
111
420
 
@@ -132,8 +441,10 @@ const treeSitterRunner: RunnerDefinition = {
132
441
 
133
442
  // Try cache
134
443
  const cached = cache.get(ruleFiles);
444
+ let cacheHit = false;
135
445
  if (cached) {
136
446
  // Use cached queries
447
+ cacheHit = true;
137
448
  languageQueries = cached.queries.map(
138
449
  (q) =>
139
450
  ({
@@ -173,6 +484,16 @@ const treeSitterRunner: RunnerDefinition = {
173
484
  }
174
485
 
175
486
  if (languageQueries.length === 0) {
487
+ logTreeSitter({
488
+ phase: "runner_complete",
489
+ filePath,
490
+ languageId,
491
+ status: "succeeded",
492
+ diagnostics: 0,
493
+ blocking: 0,
494
+ queryCount: 0,
495
+ effectiveQueryCount: 0,
496
+ });
176
497
  return { status: "succeeded", diagnostics: [], semantic: "none" };
177
498
  }
178
499
 
@@ -186,6 +507,16 @@ const treeSitterRunner: RunnerDefinition = {
186
507
  )
187
508
  : languageQueries;
188
509
 
510
+ logTreeSitter({
511
+ phase: "queries_loaded",
512
+ filePath,
513
+ languageId,
514
+ queryCount: languageQueries.length,
515
+ effectiveQueryCount: effectiveQueries.length,
516
+ cacheHit,
517
+ metadata: { blockingOnly: !!ctx.blockingOnly },
518
+ });
519
+
189
520
  const diagnostics: Diagnostic[] = [];
190
521
 
191
522
  // Run each query against the file
@@ -244,15 +575,151 @@ const treeSitterRunner: RunnerDefinition = {
244
575
  } catch (err) {
245
576
  // Individual query failure shouldn't stop other queries
246
577
  console.error(`[tree-sitter] Query ${query.id} failed:`, err);
578
+ logTreeSitter({
579
+ phase: "query_error",
580
+ filePath,
581
+ languageId,
582
+ queryId: query.id,
583
+ error: err instanceof Error ? err.message : String(err),
584
+ });
247
585
  }
248
586
  }
249
587
 
250
588
  if (diagnostics.length === 0) {
589
+ try {
590
+ const snapshot = await extractEntitySnapshot(client, filePath, languageId);
591
+ const prev = entitySnapshotByFile.get(filePath);
592
+ const diff = diffEntitySnapshot(prev, snapshot);
593
+ entitySnapshotByFile.set(filePath, snapshot);
594
+ const changedEntityKeys = [...diff.added, ...diff.modified, ...diff.removed];
595
+ const changedNames = [...new Set(changedEntityKeys.map((k) => k.split(":")[1]).filter(Boolean))];
596
+
597
+ if (changedEntityKeys.length > 0) {
598
+ logTreeSitter({
599
+ phase: "entity_diff",
600
+ filePath,
601
+ languageId,
602
+ metadata: {
603
+ added: diff.added,
604
+ modified: diff.modified,
605
+ removed: diff.removed,
606
+ totalChanged: changedEntityKeys.length,
607
+ },
608
+ });
609
+
610
+ const lastBlast = blastCooldownByFile.get(filePath) ?? 0;
611
+ if (Date.now() - lastBlast < BLAST_COOLDOWN_MS) {
612
+ logTreeSitter({
613
+ phase: "blast_radius",
614
+ filePath,
615
+ languageId,
616
+ metadata: { skipped: "cooldown", cooldownMs: BLAST_COOLDOWN_MS },
617
+ });
618
+ } else {
619
+ blastCooldownByFile.set(filePath, Date.now());
620
+ const blastRadius = computeBlastRadius(changedNames, filePath, ctx.cwd);
621
+ logTreeSitter({
622
+ phase: "blast_radius",
623
+ filePath,
624
+ languageId,
625
+ metadata: {
626
+ entities: blastRadius.entities,
627
+ scannedFiles: blastRadius.scannedFiles,
628
+ scannedBytes: blastRadius.scannedBytes,
629
+ totalCandidates: blastRadius.totalCandidates,
630
+ truncated: blastRadius.truncated,
631
+ elapsedMs: blastRadius.elapsedMs,
632
+ },
633
+ });
634
+ }
635
+ }
636
+ } catch {}
637
+
638
+ logTreeSitter({
639
+ phase: "runner_complete",
640
+ filePath,
641
+ languageId,
642
+ status: "succeeded",
643
+ diagnostics: 0,
644
+ blocking: 0,
645
+ queryCount: languageQueries.length,
646
+ effectiveQueryCount: effectiveQueries.length,
647
+ });
251
648
  return { status: "succeeded", diagnostics: [], semantic: "none" };
252
649
  }
253
650
 
254
651
  // Check if any blocking issues
255
652
  const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
653
+ const blockingCount = diagnostics.filter(
654
+ (d) => d.semantic === "blocking",
655
+ ).length;
656
+ try {
657
+ const snapshot = await extractEntitySnapshot(client, filePath, languageId);
658
+ const prev = entitySnapshotByFile.get(filePath);
659
+ const diff = diffEntitySnapshot(prev, snapshot);
660
+ entitySnapshotByFile.set(filePath, snapshot);
661
+ const changedEntityKeys = [
662
+ ...diff.added,
663
+ ...diff.modified,
664
+ ...diff.removed,
665
+ ];
666
+ const changedNames = [
667
+ ...new Set(changedEntityKeys.map((k) => k.split(":")[1]).filter(Boolean)),
668
+ ];
669
+
670
+ if (changedEntityKeys.length > 0) {
671
+ logTreeSitter({
672
+ phase: "entity_diff",
673
+ filePath,
674
+ languageId,
675
+ metadata: {
676
+ added: diff.added,
677
+ modified: diff.modified,
678
+ removed: diff.removed,
679
+ totalChanged: changedEntityKeys.length,
680
+ },
681
+ });
682
+
683
+ const lastBlast = blastCooldownByFile.get(filePath) ?? 0;
684
+ if (Date.now() - lastBlast < BLAST_COOLDOWN_MS) {
685
+ logTreeSitter({
686
+ phase: "blast_radius",
687
+ filePath,
688
+ languageId,
689
+ metadata: { skipped: "cooldown", cooldownMs: BLAST_COOLDOWN_MS },
690
+ });
691
+ } else {
692
+ blastCooldownByFile.set(filePath, Date.now());
693
+ const blastRadius = computeBlastRadius(changedNames, filePath, ctx.cwd);
694
+ logTreeSitter({
695
+ phase: "blast_radius",
696
+ filePath,
697
+ languageId,
698
+ metadata: {
699
+ entities: blastRadius.entities,
700
+ scannedFiles: blastRadius.scannedFiles,
701
+ scannedBytes: blastRadius.scannedBytes,
702
+ totalCandidates: blastRadius.totalCandidates,
703
+ truncated: blastRadius.truncated,
704
+ elapsedMs: blastRadius.elapsedMs,
705
+ },
706
+ });
707
+ }
708
+ }
709
+ } catch {
710
+ // best-effort experimental telemetry only
711
+ }
712
+
713
+ logTreeSitter({
714
+ phase: "runner_complete",
715
+ filePath,
716
+ languageId,
717
+ status: hasBlocking ? "failed" : "succeeded",
718
+ diagnostics: diagnostics.length,
719
+ blocking: blockingCount,
720
+ queryCount: languageQueries.length,
721
+ effectiveQueryCount: effectiveQueries.length,
722
+ });
256
723
 
257
724
  return {
258
725
  status: hasBlocking ? "failed" : "succeeded",