pi-lens 3.8.21 → 3.8.23

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 (92) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +2 -0
  3. package/clients/dispatch/dispatcher.ts +75 -91
  4. package/clients/dispatch/fact-provider-types.ts +22 -0
  5. package/clients/dispatch/fact-rule-runner.ts +22 -0
  6. package/clients/dispatch/fact-runner.ts +28 -0
  7. package/clients/dispatch/fact-scheduler.ts +78 -0
  8. package/clients/dispatch/fact-store.ts +67 -0
  9. package/clients/dispatch/facts/comment-facts.ts +59 -0
  10. package/clients/dispatch/facts/file-content.ts +20 -0
  11. package/clients/dispatch/facts/function-facts.ts +177 -0
  12. package/clients/dispatch/facts/try-catch-facts.ts +80 -0
  13. package/clients/dispatch/integration.ts +130 -24
  14. package/clients/dispatch/priorities.ts +22 -0
  15. package/clients/dispatch/rules/async-noise.ts +43 -0
  16. package/clients/dispatch/rules/error-obscuring.ts +40 -0
  17. package/clients/dispatch/rules/error-swallowing.ts +35 -0
  18. package/clients/dispatch/rules/pass-through-wrappers.ts +52 -0
  19. package/clients/dispatch/rules/placeholder-comments.ts +47 -0
  20. package/clients/dispatch/runners/architect.ts +2 -1
  21. package/clients/dispatch/runners/ast-grep-napi.ts +2 -1
  22. package/clients/dispatch/runners/biome-check.ts +40 -8
  23. package/clients/dispatch/runners/biome.ts +2 -1
  24. package/clients/dispatch/runners/eslint.ts +34 -6
  25. package/clients/dispatch/runners/go-vet.ts +2 -1
  26. package/clients/dispatch/runners/golangci-lint.ts +2 -1
  27. package/clients/dispatch/runners/index.ts +29 -27
  28. package/clients/dispatch/runners/lsp.ts +60 -4
  29. package/clients/dispatch/runners/oxlint.ts +2 -1
  30. package/clients/dispatch/runners/pyright.ts +2 -1
  31. package/clients/dispatch/runners/python-slop.ts +2 -1
  32. package/clients/dispatch/runners/rubocop.ts +2 -1
  33. package/clients/dispatch/runners/ruff.ts +2 -1
  34. package/clients/dispatch/runners/rust-clippy.ts +2 -1
  35. package/clients/dispatch/runners/shellcheck.ts +2 -1
  36. package/clients/dispatch/runners/similarity.ts +2 -1
  37. package/clients/dispatch/runners/spellcheck.ts +2 -1
  38. package/clients/dispatch/runners/sqlfluff.ts +2 -1
  39. package/clients/dispatch/runners/tree-sitter.ts +469 -1
  40. package/clients/dispatch/runners/ts-lsp.ts +2 -1
  41. package/clients/dispatch/runners/type-safety.ts +2 -1
  42. package/clients/dispatch/runners/yamllint.ts +2 -1
  43. package/clients/dispatch/tool-profile.ts +40 -0
  44. package/clients/dispatch/types.ts +3 -13
  45. package/clients/lsp/client.ts +366 -12
  46. package/clients/lsp/index.ts +374 -76
  47. package/clients/lsp/launch.ts +42 -2
  48. package/clients/lsp/server.ts +186 -12
  49. package/clients/pipeline.ts +2 -2
  50. package/clients/runtime-context.ts +2 -2
  51. package/clients/runtime-session.ts +43 -5
  52. package/clients/session-summary.ts +21 -0
  53. package/clients/tree-sitter-client.ts +162 -0
  54. package/clients/tree-sitter-logger.ts +47 -0
  55. package/clients/tree-sitter-query-loader.ts +13 -2
  56. package/index.ts +67 -17
  57. package/package.json +3 -1
  58. package/rules/rule-catalog.json +64 -0
  59. package/rules/tree-sitter-queries/go/go-bare-error.yml +19 -7
  60. package/rules/tree-sitter-queries/go/go-command-injection.yml +55 -0
  61. package/rules/tree-sitter-queries/go/go-direct-panic.yml +45 -0
  62. package/rules/tree-sitter-queries/go/go-empty-if-err.yml +47 -0
  63. package/rules/tree-sitter-queries/go/go-goroutine-loop-capture.yml +49 -0
  64. package/rules/tree-sitter-queries/go/go-ignored-call-result.yml +51 -0
  65. package/rules/tree-sitter-queries/go/go-insecure-random.yml +51 -0
  66. package/rules/tree-sitter-queries/go/go-log-fatal.yml +49 -0
  67. package/rules/tree-sitter-queries/go/go-path-traversal.yml +51 -0
  68. package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +54 -0
  69. package/rules/tree-sitter-queries/go/go-sql-injection.yml +55 -0
  70. package/rules/tree-sitter-queries/go/go-weak-hash.yml +51 -0
  71. package/rules/tree-sitter-queries/python/python-command-injection.yml +63 -0
  72. package/rules/tree-sitter-queries/python/python-insecure-deserialization.yml +48 -0
  73. package/rules/tree-sitter-queries/python/python-insecure-random.yml +51 -0
  74. package/rules/tree-sitter-queries/python/python-path-traversal.yml +55 -0
  75. package/rules/tree-sitter-queries/python/python-sql-injection.yml +47 -0
  76. package/rules/tree-sitter-queries/python/python-ssrf.yml +50 -0
  77. package/rules/tree-sitter-queries/python/python-thread-global-write.yml +58 -0
  78. package/rules/tree-sitter-queries/python/python-weak-hash.yml +51 -0
  79. package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +56 -0
  80. package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +47 -0
  81. package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +54 -0
  82. package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +50 -0
  83. package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +59 -0
  84. package/rules/tree-sitter-queries/typescript/ts-command-injection.yml +60 -0
  85. package/rules/tree-sitter-queries/typescript/ts-detached-async-call.yml +56 -0
  86. package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +54 -0
  87. package/rules/tree-sitter-queries/typescript/ts-ssrf.yml +53 -0
  88. package/rules/tree-sitter-queries/typescript/ts-weak-hash.yml +54 -0
  89. package/scripts/validate-rule-catalog.mjs +227 -0
  90. package/skills/lsp-navigation/SKILL.md +15 -3
  91. package/tools/lsp-navigation.js +466 -79
  92. package/tools/lsp-navigation.ts +587 -85
@@ -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,
@@ -20,11 +22,300 @@ import type {
20
22
  RunnerDefinition,
21
23
  RunnerResult,
22
24
  } from "../types.js";
25
+ import { PRIORITY } from "../priorities.js";
23
26
 
24
27
  // Module-level singleton: web-tree-sitter WASM must only be initialized once per process.
25
28
  // Creating a new TreeSitterClient() on every write resets TRANSFER_BUFFER (a module-level
26
29
  // WASM pointer) — concurrent writes race on _ts_init() and corrupt shared WASM state → crash.
27
30
  let _sharedClient: TreeSitterClient | null = null;
31
+ const entitySnapshotByFile = new Map<string, Map<string, string>>();
32
+ const blastFileCache = new Map<string, { expiresAt: number; files: string[] }>();
33
+ const blastCooldownByFile = new Map<string, number>();
34
+ const BLAST_CACHE_TTL_MS = 30_000;
35
+ const MAX_BLAST_FILES = 300;
36
+ const MAX_BLAST_ENTITIES = 8;
37
+ const BLAST_MAX_FILE_BYTES = 128 * 1024;
38
+ const BLAST_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
39
+ const BLAST_MAX_ELAPSED_MS = 120;
40
+ const BLAST_COOLDOWN_MS = 5_000;
41
+
42
+ interface EntityQueryDef {
43
+ id: string;
44
+ kind: string;
45
+ query: string;
46
+ }
47
+
48
+ const ENTITY_QUERIES: Partial<Record<string, EntityQueryDef[]>> = {
49
+ typescript: [
50
+ {
51
+ id: "entity-ts-function",
52
+ kind: "function",
53
+ query: "(function_declaration name: (identifier) @NAME)",
54
+ },
55
+ {
56
+ id: "entity-ts-class",
57
+ kind: "class",
58
+ query: "(class_declaration name: (type_identifier) @NAME)",
59
+ },
60
+ {
61
+ id: "entity-ts-method",
62
+ kind: "method",
63
+ query: "(method_definition name: (property_identifier) @NAME)",
64
+ },
65
+ ],
66
+ javascript: [
67
+ {
68
+ id: "entity-js-function",
69
+ kind: "function",
70
+ query: "(function_declaration name: (identifier) @NAME)",
71
+ },
72
+ {
73
+ id: "entity-js-class",
74
+ kind: "class",
75
+ query: "(class_declaration name: (identifier) @NAME)",
76
+ },
77
+ {
78
+ id: "entity-js-method",
79
+ kind: "method",
80
+ query: "(method_definition name: (property_identifier) @NAME)",
81
+ },
82
+ ],
83
+ python: [
84
+ {
85
+ id: "entity-py-function",
86
+ kind: "function",
87
+ query: "(function_definition name: (identifier) @NAME)",
88
+ },
89
+ {
90
+ id: "entity-py-class",
91
+ kind: "class",
92
+ query: "(class_definition name: (identifier) @NAME)",
93
+ },
94
+ ],
95
+ go: [
96
+ {
97
+ id: "entity-go-function",
98
+ kind: "function",
99
+ query: "(function_declaration name: (identifier) @NAME)",
100
+ },
101
+ {
102
+ id: "entity-go-method",
103
+ kind: "method",
104
+ query: "(method_declaration name: (field_identifier) @NAME)",
105
+ },
106
+ {
107
+ id: "entity-go-type",
108
+ kind: "type",
109
+ query: "(type_spec name: (type_identifier) @NAME)",
110
+ },
111
+ ],
112
+ rust: [
113
+ {
114
+ id: "entity-rs-function",
115
+ kind: "function",
116
+ query: "(function_item name: (identifier) @NAME)",
117
+ },
118
+ {
119
+ id: "entity-rs-struct",
120
+ kind: "struct",
121
+ query: "(struct_item name: (type_identifier) @NAME)",
122
+ },
123
+ {
124
+ id: "entity-rs-enum",
125
+ kind: "enum",
126
+ query: "(enum_item name: (type_identifier) @NAME)",
127
+ },
128
+ ],
129
+ ruby: [
130
+ {
131
+ id: "entity-rb-method",
132
+ kind: "method",
133
+ query: "(method name: (identifier) @NAME)",
134
+ },
135
+ {
136
+ id: "entity-rb-class",
137
+ kind: "class",
138
+ query: "(class name: (constant) @NAME)",
139
+ },
140
+ {
141
+ id: "entity-rb-module",
142
+ kind: "module",
143
+ query: "(module name: (constant) @NAME)",
144
+ },
145
+ ],
146
+ };
147
+
148
+ function escapeRegex(name: string): string {
149
+ return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
150
+ }
151
+
152
+ async function extractEntitySnapshot(
153
+ client: TreeSitterClient,
154
+ filePath: string,
155
+ languageId: string,
156
+ ): Promise<Map<string, string>> {
157
+ const defs = ENTITY_QUERIES[languageId] ?? [];
158
+ const snapshot = new Map<string, string>();
159
+
160
+ for (const def of defs) {
161
+ const matches = await client.runQueryOnFile(
162
+ {
163
+ id: def.id,
164
+ name: def.id,
165
+ severity: "info",
166
+ category: "entity",
167
+ language: languageId,
168
+ message: "",
169
+ query: def.query,
170
+ metavars: ["NAME"],
171
+ has_fix: false,
172
+ filePath: "",
173
+ },
174
+ filePath,
175
+ languageId,
176
+ { maxResults: 200 },
177
+ );
178
+
179
+ for (const match of matches) {
180
+ const name = match.captures.NAME?.trim();
181
+ if (!name) continue;
182
+ const key = `${def.kind}:${name}`;
183
+ snapshot.set(key, `${match.line}:${match.matchedText.slice(0, 400)}`);
184
+ }
185
+ }
186
+
187
+ return snapshot;
188
+ }
189
+
190
+ function diffEntitySnapshot(
191
+ prev: Map<string, string> | undefined,
192
+ next: Map<string, string>,
193
+ ): { added: string[]; removed: string[]; modified: string[] } {
194
+ if (!prev) {
195
+ return { added: [...next.keys()], removed: [], modified: [] };
196
+ }
197
+
198
+ const added: string[] = [];
199
+ const removed: string[] = [];
200
+ const modified: string[] = [];
201
+
202
+ for (const [key, value] of next.entries()) {
203
+ if (!prev.has(key)) {
204
+ added.push(key);
205
+ continue;
206
+ }
207
+ if (prev.get(key) !== value) {
208
+ modified.push(key);
209
+ }
210
+ }
211
+
212
+ for (const key of prev.keys()) {
213
+ if (!next.has(key)) {
214
+ removed.push(key);
215
+ }
216
+ }
217
+
218
+ return { added, removed, modified };
219
+ }
220
+
221
+ function getBlastFiles(cwd: string): string[] {
222
+ const now = Date.now();
223
+ const cached = blastFileCache.get(cwd);
224
+ if (cached && cached.expiresAt > now) return cached.files;
225
+
226
+ const files = getSourceFiles(cwd).slice(0, MAX_BLAST_FILES);
227
+ blastFileCache.set(cwd, { files, expiresAt: now + BLAST_CACHE_TTL_MS });
228
+ return files;
229
+ }
230
+
231
+ function computeBlastRadius(
232
+ entityNames: string[],
233
+ filePath: string,
234
+ cwd: string,
235
+ ): {
236
+ entities: Array<{ entity: string; dependentFiles: number; references: number }>;
237
+ scannedFiles: number;
238
+ scannedBytes: number;
239
+ totalCandidates: number;
240
+ truncated: boolean;
241
+ elapsedMs: number;
242
+ } {
243
+ const startedAt = Date.now();
244
+ const limited = entityNames.slice(0, MAX_BLAST_ENTITIES);
245
+ if (limited.length === 0) {
246
+ return {
247
+ entities: [],
248
+ scannedFiles: 0,
249
+ scannedBytes: 0,
250
+ totalCandidates: 0,
251
+ truncated: false,
252
+ elapsedMs: 0,
253
+ };
254
+ }
255
+
256
+ const regexByEntity = new Map(
257
+ limited.map((name) => [name, new RegExp(`\\b${escapeRegex(name)}\\b`, "g")]),
258
+ );
259
+ const files = getBlastFiles(cwd);
260
+ const stats = new Map(
261
+ limited.map((name) => [name, { dependentFiles: 0, references: 0 }]),
262
+ );
263
+ let scannedFiles = 0;
264
+ let scannedBytes = 0;
265
+ let truncated = false;
266
+
267
+ for (const candidate of files) {
268
+ if (Date.now() - startedAt > BLAST_MAX_ELAPSED_MS) {
269
+ truncated = true;
270
+ break;
271
+ }
272
+
273
+ if (path.resolve(candidate) === path.resolve(filePath)) continue;
274
+ let size = 0;
275
+ try {
276
+ size = fs.statSync(candidate).size;
277
+ } catch {
278
+ continue;
279
+ }
280
+ if (size > BLAST_MAX_FILE_BYTES) continue;
281
+ if (scannedBytes + size > BLAST_MAX_TOTAL_BYTES) {
282
+ truncated = true;
283
+ break;
284
+ }
285
+
286
+ let content = "";
287
+ try {
288
+ content = fs.readFileSync(candidate, "utf-8");
289
+ } catch {
290
+ continue;
291
+ }
292
+ scannedFiles += 1;
293
+ scannedBytes += size;
294
+
295
+ for (const [name, regex] of regexByEntity.entries()) {
296
+ const matches = content.match(regex);
297
+ if (!matches || matches.length === 0) continue;
298
+ const current = stats.get(name);
299
+ if (!current) continue;
300
+ current.dependentFiles += 1;
301
+ current.references += matches.length;
302
+ }
303
+ }
304
+
305
+ const entities = limited
306
+ .map((name) => ({ entity: name, ...stats.get(name)! }))
307
+ .sort((a, b) => b.dependentFiles - a.dependentFiles)
308
+ .slice(0, 5);
309
+
310
+ return {
311
+ entities,
312
+ scannedFiles,
313
+ scannedBytes,
314
+ totalCandidates: files.length,
315
+ truncated,
316
+ elapsedMs: Date.now() - startedAt,
317
+ };
318
+ }
28
319
 
29
320
  const SILENT_ERROR_QUERY_IDS = new Set([
30
321
  "empty-catch",
@@ -71,19 +362,32 @@ function getSharedClient(): TreeSitterClient {
71
362
  const treeSitterRunner: RunnerDefinition = {
72
363
  id: "tree-sitter",
73
364
  appliesTo: ["jsts", "python", "go", "rust", "ruby"],
74
- priority: 14, // Between oxlint (12) and ast-grep-napi (15)
365
+ priority: PRIORITY.STRUCTURAL_ANALYSIS,
75
366
  enabledByDefault: true,
76
367
  skipTestFiles: false, // Run on test files too (structural issues matter there)
77
368
 
78
369
  async run(ctx: DispatchContext): Promise<RunnerResult> {
79
370
  // Use singleton client — WASM must never be re-initialized after first call
80
371
  const client = getSharedClient();
372
+ logTreeSitter({ phase: "runner_start", filePath: ctx.filePath });
81
373
  if (!client.isAvailable()) {
374
+ logTreeSitter({
375
+ phase: "runner_skip",
376
+ filePath: ctx.filePath,
377
+ reason: "client_unavailable",
378
+ status: "skipped",
379
+ });
82
380
  return { status: "skipped", diagnostics: [], semantic: "none" };
83
381
  }
84
382
 
85
383
  const initialized = await client.init();
86
384
  if (!initialized) {
385
+ logTreeSitter({
386
+ phase: "runner_skip",
387
+ filePath: ctx.filePath,
388
+ reason: "client_init_failed",
389
+ status: "skipped",
390
+ });
87
391
  return { status: "skipped", diagnostics: [], semantic: "none" };
88
392
  }
89
393
 
@@ -106,6 +410,12 @@ const treeSitterRunner: RunnerDefinition = {
106
410
  };
107
411
  const languageId = EXT_TO_LANG[ext];
108
412
  if (!languageId) {
413
+ logTreeSitter({
414
+ phase: "runner_skip",
415
+ filePath: ctx.filePath,
416
+ reason: `unsupported_extension:${ext}`,
417
+ status: "skipped",
418
+ });
109
419
  return { status: "skipped", diagnostics: [], semantic: "none" };
110
420
  }
111
421
 
@@ -132,8 +442,10 @@ const treeSitterRunner: RunnerDefinition = {
132
442
 
133
443
  // Try cache
134
444
  const cached = cache.get(ruleFiles);
445
+ let cacheHit = false;
135
446
  if (cached) {
136
447
  // Use cached queries
448
+ cacheHit = true;
137
449
  languageQueries = cached.queries.map(
138
450
  (q) =>
139
451
  ({
@@ -173,6 +485,16 @@ const treeSitterRunner: RunnerDefinition = {
173
485
  }
174
486
 
175
487
  if (languageQueries.length === 0) {
488
+ logTreeSitter({
489
+ phase: "runner_complete",
490
+ filePath,
491
+ languageId,
492
+ status: "succeeded",
493
+ diagnostics: 0,
494
+ blocking: 0,
495
+ queryCount: 0,
496
+ effectiveQueryCount: 0,
497
+ });
176
498
  return { status: "succeeded", diagnostics: [], semantic: "none" };
177
499
  }
178
500
 
@@ -186,6 +508,16 @@ const treeSitterRunner: RunnerDefinition = {
186
508
  )
187
509
  : languageQueries;
188
510
 
511
+ logTreeSitter({
512
+ phase: "queries_loaded",
513
+ filePath,
514
+ languageId,
515
+ queryCount: languageQueries.length,
516
+ effectiveQueryCount: effectiveQueries.length,
517
+ cacheHit,
518
+ metadata: { blockingOnly: !!ctx.blockingOnly },
519
+ });
520
+
189
521
  const diagnostics: Diagnostic[] = [];
190
522
 
191
523
  // Run each query against the file
@@ -244,15 +576,151 @@ const treeSitterRunner: RunnerDefinition = {
244
576
  } catch (err) {
245
577
  // Individual query failure shouldn't stop other queries
246
578
  console.error(`[tree-sitter] Query ${query.id} failed:`, err);
579
+ logTreeSitter({
580
+ phase: "query_error",
581
+ filePath,
582
+ languageId,
583
+ queryId: query.id,
584
+ error: err instanceof Error ? err.message : String(err),
585
+ });
247
586
  }
248
587
  }
249
588
 
250
589
  if (diagnostics.length === 0) {
590
+ try {
591
+ const snapshot = await extractEntitySnapshot(client, filePath, languageId);
592
+ const prev = entitySnapshotByFile.get(filePath);
593
+ const diff = diffEntitySnapshot(prev, snapshot);
594
+ entitySnapshotByFile.set(filePath, snapshot);
595
+ const changedEntityKeys = [...diff.added, ...diff.modified, ...diff.removed];
596
+ const changedNames = [...new Set(changedEntityKeys.map((k) => k.split(":")[1]).filter(Boolean))];
597
+
598
+ if (changedEntityKeys.length > 0) {
599
+ logTreeSitter({
600
+ phase: "entity_diff",
601
+ filePath,
602
+ languageId,
603
+ metadata: {
604
+ added: diff.added,
605
+ modified: diff.modified,
606
+ removed: diff.removed,
607
+ totalChanged: changedEntityKeys.length,
608
+ },
609
+ });
610
+
611
+ const lastBlast = blastCooldownByFile.get(filePath) ?? 0;
612
+ if (Date.now() - lastBlast < BLAST_COOLDOWN_MS) {
613
+ logTreeSitter({
614
+ phase: "blast_radius",
615
+ filePath,
616
+ languageId,
617
+ metadata: { skipped: "cooldown", cooldownMs: BLAST_COOLDOWN_MS },
618
+ });
619
+ } else {
620
+ blastCooldownByFile.set(filePath, Date.now());
621
+ const blastRadius = computeBlastRadius(changedNames, filePath, ctx.cwd);
622
+ logTreeSitter({
623
+ phase: "blast_radius",
624
+ filePath,
625
+ languageId,
626
+ metadata: {
627
+ entities: blastRadius.entities,
628
+ scannedFiles: blastRadius.scannedFiles,
629
+ scannedBytes: blastRadius.scannedBytes,
630
+ totalCandidates: blastRadius.totalCandidates,
631
+ truncated: blastRadius.truncated,
632
+ elapsedMs: blastRadius.elapsedMs,
633
+ },
634
+ });
635
+ }
636
+ }
637
+ } catch {}
638
+
639
+ logTreeSitter({
640
+ phase: "runner_complete",
641
+ filePath,
642
+ languageId,
643
+ status: "succeeded",
644
+ diagnostics: 0,
645
+ blocking: 0,
646
+ queryCount: languageQueries.length,
647
+ effectiveQueryCount: effectiveQueries.length,
648
+ });
251
649
  return { status: "succeeded", diagnostics: [], semantic: "none" };
252
650
  }
253
651
 
254
652
  // Check if any blocking issues
255
653
  const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
654
+ const blockingCount = diagnostics.filter(
655
+ (d) => d.semantic === "blocking",
656
+ ).length;
657
+ try {
658
+ const snapshot = await extractEntitySnapshot(client, filePath, languageId);
659
+ const prev = entitySnapshotByFile.get(filePath);
660
+ const diff = diffEntitySnapshot(prev, snapshot);
661
+ entitySnapshotByFile.set(filePath, snapshot);
662
+ const changedEntityKeys = [
663
+ ...diff.added,
664
+ ...diff.modified,
665
+ ...diff.removed,
666
+ ];
667
+ const changedNames = [
668
+ ...new Set(changedEntityKeys.map((k) => k.split(":")[1]).filter(Boolean)),
669
+ ];
670
+
671
+ if (changedEntityKeys.length > 0) {
672
+ logTreeSitter({
673
+ phase: "entity_diff",
674
+ filePath,
675
+ languageId,
676
+ metadata: {
677
+ added: diff.added,
678
+ modified: diff.modified,
679
+ removed: diff.removed,
680
+ totalChanged: changedEntityKeys.length,
681
+ },
682
+ });
683
+
684
+ const lastBlast = blastCooldownByFile.get(filePath) ?? 0;
685
+ if (Date.now() - lastBlast < BLAST_COOLDOWN_MS) {
686
+ logTreeSitter({
687
+ phase: "blast_radius",
688
+ filePath,
689
+ languageId,
690
+ metadata: { skipped: "cooldown", cooldownMs: BLAST_COOLDOWN_MS },
691
+ });
692
+ } else {
693
+ blastCooldownByFile.set(filePath, Date.now());
694
+ const blastRadius = computeBlastRadius(changedNames, filePath, ctx.cwd);
695
+ logTreeSitter({
696
+ phase: "blast_radius",
697
+ filePath,
698
+ languageId,
699
+ metadata: {
700
+ entities: blastRadius.entities,
701
+ scannedFiles: blastRadius.scannedFiles,
702
+ scannedBytes: blastRadius.scannedBytes,
703
+ totalCandidates: blastRadius.totalCandidates,
704
+ truncated: blastRadius.truncated,
705
+ elapsedMs: blastRadius.elapsedMs,
706
+ },
707
+ });
708
+ }
709
+ }
710
+ } catch {
711
+ // best-effort experimental telemetry only
712
+ }
713
+
714
+ logTreeSitter({
715
+ phase: "runner_complete",
716
+ filePath,
717
+ languageId,
718
+ status: hasBlocking ? "failed" : "succeeded",
719
+ diagnostics: diagnostics.length,
720
+ blocking: blockingCount,
721
+ queryCount: languageQueries.length,
722
+ effectiveQueryCount: effectiveQueries.length,
723
+ });
256
724
 
257
725
  return {
258
726
  status: hasBlocking ? "failed" : "succeeded",
@@ -16,12 +16,13 @@ import type {
16
16
  RunnerDefinition,
17
17
  RunnerResult,
18
18
  } from "../types.js";
19
+ import { PRIORITY } from "../priorities.js";
19
20
  import { readFileContent } from "./utils.js";
20
21
 
21
22
  const tsLspRunner: RunnerDefinition = {
22
23
  id: "ts-lsp",
23
24
  appliesTo: ["jsts"],
24
- priority: 5,
25
+ priority: PRIORITY.LSP_FALLBACK,
25
26
  enabledByDefault: true,
26
27
 
27
28
  async run(ctx: DispatchContext): Promise<RunnerResult> {
@@ -13,12 +13,13 @@ import type {
13
13
  RunnerDefinition,
14
14
  RunnerResult,
15
15
  } from "../types.js";
16
+ import { PRIORITY } from "../priorities.js";
16
17
  import { readFileContent } from "./utils.js";
17
18
 
18
19
  const typeSafetyRunner: RunnerDefinition = {
19
20
  id: "type-safety",
20
21
  appliesTo: ["jsts"],
21
- priority: 20,
22
+ priority: PRIORITY.GENERAL_ANALYSIS,
22
23
  enabledByDefault: true,
23
24
 
24
25
  async run(ctx: DispatchContext): Promise<RunnerResult> {
@@ -9,6 +9,7 @@ import type {
9
9
  RunnerDefinition,
10
10
  RunnerResult,
11
11
  } from "../types.js";
12
+ import { PRIORITY } from "../priorities.js";
12
13
 
13
14
  const yamllint = createAvailabilityChecker("yamllint", ".exe");
14
15
 
@@ -83,7 +84,7 @@ function parseYamllintParsable(raw: string, filePath: string): Diagnostic[] {
83
84
  const yamllintRunner: RunnerDefinition = {
84
85
  id: "yamllint",
85
86
  appliesTo: ["yaml"],
86
- priority: 22,
87
+ priority: PRIORITY.YAML_LINT,
87
88
  enabledByDefault: true,
88
89
  skipTestFiles: false,
89
90
 
@@ -0,0 +1,40 @@
1
+ export interface ToolProfile {
2
+ dedupPriority: number;
3
+ lintLike: boolean;
4
+ }
5
+
6
+ const DEFAULT_TOOL_PROFILE: ToolProfile = {
7
+ dedupPriority: 50,
8
+ lintLike: false,
9
+ };
10
+
11
+ const TOOL_PROFILE_MAP: Record<string, ToolProfile> = {
12
+ "tree-sitter:silent-error": { dedupPriority: 200, lintLike: false },
13
+ lsp: { dedupPriority: 120, lintLike: false },
14
+ "ts-lsp": { dedupPriority: 120, lintLike: false },
15
+ eslint: { dedupPriority: 110, lintLike: true },
16
+ biome: { dedupPriority: 100, lintLike: true },
17
+ "biome-check-json": { dedupPriority: 100, lintLike: true },
18
+ "tree-sitter": { dedupPriority: 90, lintLike: false },
19
+ "ast-grep-napi": { dedupPriority: 80, lintLike: false },
20
+ "ast-grep": { dedupPriority: 80, lintLike: false },
21
+ "ruff-lint": { dedupPriority: 95, lintLike: true },
22
+ oxlint: { dedupPriority: 95, lintLike: true },
23
+ rubocop: { dedupPriority: 95, lintLike: true },
24
+ "go-vet": { dedupPriority: 95, lintLike: true },
25
+ "golangci-lint": { dedupPriority: 95, lintLike: true },
26
+ "rust-clippy": { dedupPriority: 95, lintLike: true },
27
+ shellcheck: { dedupPriority: 95, lintLike: true },
28
+ "type-safety": { dedupPriority: 95, lintLike: true },
29
+ };
30
+
31
+ export function getToolProfile(
32
+ tool: string,
33
+ defectClass?: string,
34
+ ): ToolProfile {
35
+ const t = tool.toLowerCase();
36
+ if (defectClass === "silent-error" && t === "tree-sitter") {
37
+ return TOOL_PROFILE_MAP["tree-sitter:silent-error"];
38
+ }
39
+ return TOOL_PROFILE_MAP[t] ?? DEFAULT_TOOL_PROFILE;
40
+ }
@@ -88,17 +88,6 @@ export interface DispatchResult {
88
88
  hasBlockers: boolean;
89
89
  }
90
90
 
91
- // --- Baseline Management ---
92
-
93
- export interface BaselineStore {
94
- /** Get baseline for a file */
95
- get(filePath: string): unknown[] | undefined;
96
- /** Set baseline for a file */
97
- set(filePath: string, diagnostics: unknown[]): void;
98
- /** Clear all baselines */
99
- clear(): void;
100
- }
101
-
102
91
  // --- Runner Definition ---
103
92
 
104
93
  export type RunnerMode = "all" | "fallback" | "first-success";
@@ -135,7 +124,7 @@ export interface DispatchContext {
135
124
  readonly pi: PiAgentAPI;
136
125
  readonly autofix: boolean;
137
126
  readonly deltaMode: boolean;
138
- readonly baselines: BaselineStore;
127
+ readonly facts: import("./fact-store.js").FactStore;
139
128
  /** Only run blocking rules (severity: error) - used for fast feedback on file write */
140
129
  readonly blockingOnly?: boolean;
141
130
  readonly modifiedRanges?: ModifiedRange[];
@@ -164,6 +153,7 @@ export interface RunnerGroup {
164
153
  export interface RunnerRegistry {
165
154
  register(runner: RunnerDefinition): void;
166
155
  get(id: string): RunnerDefinition | undefined;
167
- getForKind(kind: FileKind): RunnerDefinition[];
156
+ getForKind(kind: FileKind, filePath?: string): RunnerDefinition[];
168
157
  list(): RunnerDefinition[];
158
+ clear(): void;
169
159
  }