pi-lens 3.8.72 → 3.8.74

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 (210) hide show
  1. package/CHANGELOG.md +1162 -0
  2. package/README.md +45 -28
  3. package/dist/clients/atomic-write.js +3 -3
  4. package/dist/clients/bus-events-logger.js +5 -1
  5. package/dist/clients/cache/rule-cache.js +43 -2
  6. package/dist/clients/cache-observability.js +211 -0
  7. package/dist/clients/call-graph.js +20 -0
  8. package/dist/clients/cascade-logger.js +5 -1
  9. package/dist/clients/complexity-client.js +4 -7
  10. package/dist/clients/dependency-checker.js +151 -20
  11. package/dist/clients/deps/js-yaml.js +1 -1
  12. package/dist/clients/diagnostic-dispositions.js +22 -2
  13. package/dist/clients/dispatch/auxiliary-lsp.js +34 -0
  14. package/dist/clients/dispatch/dispatcher.js +16 -1
  15. package/dist/clients/dispatch/fact-store.js +11 -0
  16. package/dist/clients/dispatch/facts/function-facts.js +14 -0
  17. package/dist/clients/dispatch/facts/import-facts.js +100 -101
  18. package/dist/clients/dispatch/facts/tree-sitter-facts.js +5 -8
  19. package/dist/clients/dispatch/facts/try-catch-facts.js +13 -0
  20. package/dist/clients/dispatch/integration.js +94 -15
  21. package/dist/clients/dispatch/rules/async-noise.js +1 -0
  22. package/dist/clients/dispatch/rules/unsafe-boundary.js +7 -0
  23. package/dist/clients/dispatch/runners/ast-grep-napi.js +84 -8
  24. package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -1
  25. package/dist/clients/dispatch/runners/tree-sitter.js +153 -166
  26. package/dist/clients/dispatch/runners/utils/runner-helpers.js +17 -18
  27. package/dist/clients/feature-hints.js +53 -7
  28. package/dist/clients/file-kinds.js +86 -31
  29. package/dist/clients/fix-worklog.js +14 -2
  30. package/dist/clients/generated-artifacts.js +21 -0
  31. package/dist/clients/gzip-stage-write.js +84 -0
  32. package/dist/clients/installer/index.js +358 -129
  33. package/dist/clients/instance-registry.js +12 -1
  34. package/dist/clients/knip-client.js +64 -1
  35. package/dist/clients/language-profile.js +61 -72
  36. package/dist/clients/latency-logger.js +6 -16
  37. package/dist/clients/lens-config.js +128 -165
  38. package/dist/clients/lens-engine.js +39 -5
  39. package/dist/clients/lens-flag-registry.js +285 -0
  40. package/dist/clients/lens-map.js +2 -7
  41. package/dist/clients/log-cleanup.js +6 -17
  42. package/dist/clients/lsp/cascade-tier.js +22 -2
  43. package/dist/clients/lsp/client.js +74 -10
  44. package/dist/clients/lsp/config.js +62 -9
  45. package/dist/clients/lsp/index.js +113 -31
  46. package/dist/clients/lsp/language.js +4 -1
  47. package/dist/clients/lsp/launch.js +8 -6
  48. package/dist/clients/lsp/server.js +84 -29
  49. package/dist/clients/lsp-document-symbols.js +112 -0
  50. package/dist/clients/mcp/analyze.js +5 -4
  51. package/dist/clients/mcp/review.js +36 -3
  52. package/dist/clients/module-report.js +100 -80
  53. package/dist/clients/ndjson-logger.js +51 -21
  54. package/dist/clients/path-keyed-map.js +76 -0
  55. package/dist/clients/path-utils.js +35 -0
  56. package/dist/clients/performance-report.js +327 -0
  57. package/dist/clients/pipeline.js +7 -0
  58. package/dist/clients/project-changes.js +77 -4
  59. package/dist/clients/project-diagnostics/extractors.js +26 -115
  60. package/dist/clients/project-diagnostics/fresh-fetch.js +149 -19
  61. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +109 -0
  62. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +37 -5
  63. package/dist/clients/project-diagnostics/scanner.js +247 -127
  64. package/dist/clients/project-lens-config.js +53 -26
  65. package/dist/clients/project-report.js +39 -14
  66. package/dist/clients/project-scale.js +8 -10
  67. package/dist/clients/project-snapshot-persist-worker.js +6 -0
  68. package/dist/clients/project-snapshot.js +611 -7
  69. package/dist/clients/quiet-window.js +9 -1
  70. package/dist/clients/read-expansion.js +22 -12
  71. package/dist/clients/read-guard.js +78 -0
  72. package/dist/clients/redact/secrets.js +449 -0
  73. package/dist/clients/resource-sampler.js +133 -14
  74. package/dist/clients/reverse-deps.js +48 -0
  75. package/dist/clients/review-graph/builder.js +1522 -179
  76. package/dist/clients/review-graph/persist-worker.js +10 -0
  77. package/dist/clients/review-graph/query.js +5 -0
  78. package/dist/clients/review-graph/shared-extraction-ir.js +90 -0
  79. package/dist/clients/review-graph-logger.js +24 -0
  80. package/dist/clients/runtime-agent-end.js +4 -2
  81. package/dist/clients/runtime-coordinator.js +2 -2
  82. package/dist/clients/runtime-session.js +433 -59
  83. package/dist/clients/runtime-tool-call.js +46 -14
  84. package/dist/clients/runtime-turn.js +97 -26
  85. package/dist/clients/safe-spawn.js +102 -15
  86. package/dist/clients/session-start-observability.js +15 -0
  87. package/dist/clients/session-state-store.js +2 -1
  88. package/dist/clients/sessionstart-logger.js +18 -0
  89. package/dist/clients/sg-runner.js +48 -33
  90. package/dist/clients/source-filter.js +49 -25
  91. package/dist/clients/startup-marker.js +6 -0
  92. package/dist/clients/startup-timing.js +9 -0
  93. package/dist/clients/todo-scanner.js +30 -3
  94. package/dist/clients/tree-sitter-cache.js +147 -139
  95. package/dist/clients/tree-sitter-client.js +1340 -67
  96. package/dist/clients/tree-sitter-logger.js +43 -1
  97. package/dist/clients/tree-sitter-query-loader.js +110 -11
  98. package/dist/clients/tree-sitter-shared.js +43 -19
  99. package/dist/clients/tree-sitter-symbol-extractor.js +71 -4
  100. package/dist/clients/widget-state.js +50 -14
  101. package/dist/clients/word-index-logger.js +44 -0
  102. package/dist/clients/word-index.js +252 -35
  103. package/dist/clients/workspace-topology.js +11 -1
  104. package/dist/index.js +45976 -39387
  105. package/dist/mcp/analyze-cli.js +3 -0
  106. package/dist/mcp/cli.js +110 -0
  107. package/dist/mcp/server.js +63 -8
  108. package/dist/tools/lens-diagnostics.js +47 -5
  109. package/dist/tools/lsp-diagnostics.js +126 -9
  110. package/dist/tools/lsp-navigation.js +5 -28
  111. package/dist/tools/symbol-search.js +21 -2
  112. package/docs/agent-guide.md +247 -0
  113. package/docs/astplayground.md +3 -4
  114. package/docs/audit1.md +5 -5
  115. package/docs/environment-variables.md +153 -11
  116. package/docs/features.md +4 -3
  117. package/docs/globalconfig.md +56 -4
  118. package/docs/language-coverage.md +2 -2
  119. package/docs/mcp.md +17 -16
  120. package/docs/servercapabilities.md +5 -2
  121. package/docs/settings.md +181 -0
  122. package/docs/tools.md +14 -0
  123. package/docs/tree-sitter_rules_catalog.md +11 -31
  124. package/docs/usage.md +13 -0
  125. package/package.json +139 -139
  126. package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
  127. package/rules/ast-grep-rules/rule-tests/hardcoded-url-test.yml +2 -0
  128. package/rules/ast-grep-rules/rule-tests/no-case-declarations-js-test.yml +10 -0
  129. package/rules/ast-grep-rules/rule-tests/no-case-declarations-test.yml +10 -0
  130. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +10 -0
  131. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +10 -0
  132. package/rules/ast-grep-rules/rule-tests/strict-equality-js-test.yml +3 -0
  133. package/rules/ast-grep-rules/rule-tests/strict-equality-test.yml +3 -0
  134. package/rules/ast-grep-rules/rule-tests/strict-inequality-js-test.yml +3 -0
  135. package/rules/ast-grep-rules/rule-tests/strict-inequality-test.yml +4 -0
  136. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +22 -4
  137. package/rules/ast-grep-rules/rules/hardcoded-url.yml +22 -4
  138. package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +4 -3
  139. package/rules/ast-grep-rules/rules/no-case-declarations.yml +4 -3
  140. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +10 -0
  141. package/rules/ast-grep-rules/rules/no-console-except-error.yml +10 -0
  142. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +42 -6
  143. package/rules/ast-grep-rules/rules/no-javascript-url.yml +42 -6
  144. package/rules/ast-grep-rules/rules/strict-equality-js.yml +14 -1
  145. package/rules/ast-grep-rules/rules/strict-equality.yml +14 -1
  146. package/rules/ast-grep-rules/rules/strict-inequality-js.yml +14 -1
  147. package/rules/ast-grep-rules/rules/strict-inequality.yml +14 -1
  148. package/rules/tree-sitter-queries/cpp/no-memset-sensitive-data.yml +9 -3
  149. package/rules/tree-sitter-queries/cpp/no-scoped-lock-without-args.yml +6 -6
  150. package/rules/tree-sitter-queries/css/calc-spacing.yml +8 -6
  151. package/rules/tree-sitter-queries/go/go-mutex-copy.yml +21 -18
  152. package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +3 -3
  153. package/rules/tree-sitter-queries/java/infinite-loop-java.yml +3 -3
  154. package/rules/tree-sitter-queries/java/junit-call-super.yml +6 -4
  155. package/rules/tree-sitter-queries/java/no-double-checked-locking.yml +10 -8
  156. package/rules/tree-sitter-queries/java/no-field-shadowing.yml +1 -1
  157. package/rules/tree-sitter-queries/java/prepared-statement-valid-indices.yml +2 -3
  158. package/rules/tree-sitter-queries/java/resources-closed.yml +2 -2
  159. package/rules/tree-sitter-queries/java/springboot-default-package.yml +6 -3
  160. package/rules/tree-sitter-queries/java/switch-fall-through.yml +6 -4
  161. package/rules/tree-sitter-queries/java/switch-non-case-labels.yml +4 -3
  162. package/rules/tree-sitter-queries/java/tests-include-assertions.yml +6 -5
  163. package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +5 -8
  164. package/rules/tree-sitter-queries/javascript/switch-non-case-labels.yml +3 -2
  165. package/rules/tree-sitter-queries/kotlin/prepared-statement-indices.yml +9 -5
  166. package/rules/tree-sitter-queries/php/this-in-static-context.yml +5 -5
  167. package/rules/tree-sitter-queries/python/exit-signature-check.yml +2 -9
  168. package/rules/tree-sitter-queries/python/in-operator-unsupported.yml +3 -4
  169. package/rules/tree-sitter-queries/python/no-super-torchscript.yml +4 -8
  170. package/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml +1 -5
  171. package/rules/tree-sitter-queries/python/python-empty-except.yml +1 -1
  172. package/rules/tree-sitter-queries/python/return-in-init.yml +0 -2
  173. package/rules/tree-sitter-queries/python/yield-return-outside-function.yml +0 -3
  174. package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +1 -12
  175. package/rules/tree-sitter-queries/ruby/ruby-eval.yml +1 -1
  176. package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +4 -4
  177. package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +4 -11
  178. package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +1 -1
  179. package/rules/tree-sitter-queries/ruby/ruby-string-eval.yml +2 -3
  180. package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +4 -4
  181. package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +4 -3
  182. package/rules/tree-sitter-queries/typescript/console-statement.yml +11 -0
  183. package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +4 -9
  184. package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +13 -9
  185. package/rules/tree-sitter-queries/typescript/infinite-loop.yml +2 -2
  186. package/rules/tree-sitter-queries/typescript/mixed-async-styles.yml +10 -1
  187. package/rules/tree-sitter-queries/typescript/no-equality-in-for-condition.yml +1 -0
  188. package/rules/tree-sitter-queries/typescript/no-jump-in-finally.yml +1 -0
  189. package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +5 -8
  190. package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +7 -10
  191. package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +4 -2
  192. package/rules/typos/_typos.toml +17 -0
  193. package/skills/pi-lens-write-tree-sitter-rule/SKILL.md +5 -0
  194. package/dist/clients/config-enabled-shape.js +0 -39
  195. package/docs/env_variables.md +0 -10
  196. package/rules/tree-sitter-queries/abap-disabled/delete-where.yml +0 -46
  197. package/rules/tree-sitter-queries/cpp/no-confused-move-forward.yml +0 -59
  198. package/rules/tree-sitter-queries/cpp/noexcept-functions.yml +0 -58
  199. package/rules/tree-sitter-queries/java/mockito-initialized.yml +0 -66
  200. package/rules/tree-sitter-queries/java/no-wait-notify-on-thread.yml +0 -58
  201. package/rules/tree-sitter-queries/java/short-circuit-logic.yml +0 -57
  202. package/rules/tree-sitter-queries/java/spring-session-attributes-setcomplete.yml +0 -75
  203. package/rules/tree-sitter-queries/plsql-disabled/delete-update-where.yml +0 -56
  204. package/rules/tree-sitter-queries/plsql-disabled/end-loop-semicolon.yml +0 -51
  205. package/rules/tree-sitter-queries/plsql-disabled/fetch-bulk-collect-limit.yml +0 -47
  206. package/rules/tree-sitter-queries/plsql-disabled/forallsave-exceptions.yml +0 -51
  207. package/rules/tree-sitter-queries/plsql-disabled/nchar-nvarchar2-bytes.yml +0 -54
  208. package/rules/tree-sitter-queries/plsql-disabled/not-null-initialization.yml +0 -59
  209. package/rules/tree-sitter-queries/plsql-disabled/raise-application-error-codes.yml +0 -50
  210. package/rules/tree-sitter-queries/typescript-disabled/ts-path-traversal.yml +0 -71
@@ -4,15 +4,14 @@
4
4
  * Executes all loaded tree-sitter query files from rules/tree-sitter-queries/
5
5
  * for fast AST-based pattern matching.
6
6
  */
7
- import * as fs from "node:fs";
8
7
  import * as path from "node:path";
9
8
  import { RuleCache } from "../../cache/rule-cache.js";
9
+ import { minimatch } from "../../deps/minimatch.js";
10
10
  import { isTestFile } from "../../file-utils.js";
11
- import { resolvePackagePath } from "../../package-root.js";
12
11
  import { buildOrUpdateGraph, computeImpactCascade, recordEntitySnapshotDiff, } from "../../review-graph/service.js";
13
12
  import { getSharedTreeSitterClient, isTreeSitterWasmAborted, markTreeSitterWasmAborted, resolveTreeSitterLanguage, } from "../../tree-sitter-shared.js";
14
13
  import { logTreeSitter } from "../../tree-sitter-logger.js";
15
- import { isDisabledQueryFilePath, queryLoader, } from "../../tree-sitter-query-loader.js";
14
+ import { isDisabledQueryFilePath, queriesForLanguage, queryLoader, ruleFilesForLanguage, ruleSourceLanguages, } from "../../tree-sitter-query-loader.js";
16
15
  import { classifyDefect } from "../diagnostic-taxonomy.js";
17
16
  import { PRIORITY } from "../priorities.js";
18
17
  const blastCooldownByFile = new Map();
@@ -222,7 +221,7 @@ const ENTITY_QUERIES = {
222
221
  },
223
222
  ],
224
223
  };
225
- async function extractEntitySnapshot(client, filePath, languageId) {
224
+ async function extractEntitySnapshot(client, filePath, languageId, contentOverride) {
226
225
  const defs = ENTITY_QUERIES[languageId] ?? [];
227
226
  const snapshot = new Map();
228
227
  for (const def of defs) {
@@ -237,7 +236,11 @@ async function extractEntitySnapshot(client, filePath, languageId) {
237
236
  metavars: ["NAME"],
238
237
  has_fix: false,
239
238
  filePath: "",
240
- }, filePath, languageId, { maxResults: 200 });
239
+ }, filePath, languageId, { maxResults: 200 },
240
+ // Parse the same buffer content the diagnostics phase used (#885).
241
+ // Without this, unsaved buffers snapshot stale disk content and
242
+ // thrash the parse-cache entry the rule walk just populated.
243
+ contentOverride);
241
244
  for (const match of matches) {
242
245
  const name = match.captures.NAME?.trim();
243
246
  if (!name)
@@ -289,6 +292,23 @@ function getTotalLinesChanged(ranges) {
289
292
  }
290
293
  /** Threshold: skip entity extraction for changes under 5 lines */
291
294
  const ENTITY_EXTRACTION_LINE_THRESHOLD = 5;
295
+ /**
296
+ * `filePath` relative to `root`, forward-slashed, for matching a rule's
297
+ * `ignore_paths` globs. Falls back to the absolute (slash-normalized) path
298
+ * when `filePath` isn't under `root` (e.g. an out-of-tree temp file), so a
299
+ * glob like `scripts/**` simply never matches rather than throwing.
300
+ */
301
+ function relativeForIgnoreGlob(filePath, root) {
302
+ const rel = path.relative(root, filePath);
303
+ const normalized = (rel.startsWith("..") ? filePath : rel).split(path.sep).join("/");
304
+ return normalized;
305
+ }
306
+ function matchesIgnorePaths(filePath, root, patterns) {
307
+ if (!patterns || patterns.length === 0)
308
+ return false;
309
+ const rel = relativeForIgnoreGlob(filePath, root);
310
+ return patterns.some((pattern) => minimatch(rel, pattern, { dot: true }));
311
+ }
292
312
  const treeSitterRunner = {
293
313
  id: "tree-sitter",
294
314
  appliesTo: ["jsts", "python", "go", "rust", "ruby", "cxx", "csharp", "php", "css"],
@@ -334,20 +354,11 @@ const treeSitterRunner = {
334
354
  // Try cache first, fall back to loading from disk
335
355
  let languageQueries = [];
336
356
  const cache = new RuleCache(languageId, ctx.cwd);
337
- // Get all rule files for this language — project-local AND pi-lens built-ins.
338
- // Both sets must be in the hash so the cache is invalidated when either changes.
339
- const rulesDir = path.join(ctx.cwd, "rules", "tree-sitter-queries", languageId);
340
- const builtinRulesDir = resolvePackagePath(import.meta.url, "rules", "tree-sitter-queries", languageId);
341
- const ruleFileSet = new Set();
342
- for (const dir of [rulesDir, builtinRulesDir]) {
343
- if (fs.existsSync(dir)) {
344
- for (const f of fs.readdirSync(dir)) {
345
- if (f.endsWith(".yml"))
346
- ruleFileSet.add(path.join(dir, f));
347
- }
348
- }
349
- }
350
- const ruleFiles = [...ruleFileSet];
357
+ // Fingerprint EVERY rule file the effective rule set for this language is
358
+ // built from — project-local AND bundled built-ins, across all rule-source
359
+ // languages (tsx also runs the typescript rules; #878). The loader owns
360
+ // that composition so this can't drift from `queriesForLanguage`.
361
+ const ruleFiles = ruleFilesForLanguage(languageId, ctx.cwd);
351
362
  // Try cache
352
363
  const cached = cache.get(ruleFiles);
353
364
  let cacheHit = false;
@@ -363,14 +374,18 @@ const treeSitterRunner = {
363
374
  .filter((q) => !isDisabledQueryFilePath(q.filePath));
364
375
  }
365
376
  else {
366
- // Load from disk
367
- await queryLoader.loadQueries(ctx.cwd);
368
- languageQueries = queryLoader.getQueriesForLanguage(languageId);
369
- if (languageId === "javascript") {
370
- // JavaScript files also match TypeScript rules (shared grammar)
371
- const tsQueries = queryLoader.getQueriesForLanguage("typescript");
372
- languageQueries = [...languageQueries, ...tsQueries];
373
- }
377
+ // A miss means the rule-file fingerprint moved (or no cache yet), so the
378
+ // loader's in-memory memo may hold the PRE-edit rules — without `force`
379
+ // it would hand those back and cache.set below would persist stale rules
380
+ // under the fresh fingerprint, poisoning every future process (#878).
381
+ await queryLoader.loadQueries(ctx.cwd, { force: true });
382
+ // The effective rule set is composed by the loader (tsx also runs the
383
+ // typescript rules; javascript deliberately does not — see
384
+ // ruleSourceLanguages in tree-sitter-query-loader.ts).
385
+ languageQueries = queriesForLanguage(new Map(ruleSourceLanguages(languageId).map((lang) => [
386
+ lang,
387
+ queryLoader.getQueriesForLanguage(lang),
388
+ ])), languageId);
374
389
  // Save to cache
375
390
  cache.set(ruleFiles, languageQueries.map((q) => ({
376
391
  id: q.id,
@@ -384,7 +399,10 @@ const treeSitterRunner = {
384
399
  post_filter_params: q.post_filter_params,
385
400
  defect_class: q.defect_class,
386
401
  inline_tier: q.inline_tier,
402
+ skip_test_files: q.skip_test_files,
403
+ ignore_paths: q.ignore_paths,
387
404
  has_fix: q.has_fix,
405
+ fix_action: q.fix_action,
388
406
  filePath: q.filePath,
389
407
  })));
390
408
  }
@@ -408,9 +426,15 @@ const treeSitterRunner = {
408
426
  // python-assert-production — `assert` is the idiomatic test assertion) opt
409
427
  // out via `skip_test_files` while the runner otherwise runs on test files.
410
428
  const fileIsTest = isTestFile(filePath);
429
+ // Per-rule path carve-out (#965): a rule that's noise on CLI scripts or a
430
+ // project's own logging sink (e.g. no-console-except-error firing inside
431
+ // scripts/** or lib/logger.ts) opts out via `ignore_paths`, the same way
432
+ // `skip_test_files` opts a rule out of test files above.
433
+ const ignoreRoot = ctx.projectRoot ?? ctx.cwd;
411
434
  const effectiveQueries = (ctx.blockingOnly
412
435
  ? languageQueries.filter((q) => q.inline_tier !== "review")
413
- : languageQueries).filter((q) => !(fileIsTest && q.skip_test_files));
436
+ : languageQueries).filter((q) => !(fileIsTest && q.skip_test_files) &&
437
+ !matchesIgnorePaths(filePath, ignoreRoot, q.ignore_paths));
414
438
  logTreeSitter({
415
439
  phase: "queries_loaded",
416
440
  filePath,
@@ -424,110 +448,109 @@ const treeSitterRunner = {
424
448
  const contentOverride = contentFromFacts !== undefined && contentFromFacts !== null
425
449
  ? contentFromFacts
426
450
  : undefined;
427
- // Run queries in parallel with concurrency limit for optimal performance
428
- const CONCURRENCY_LIMIT = 6;
429
- const queryResults = [];
430
- for (let i = 0; i < effectiveQueries.length; i += CONCURRENCY_LIMIT) {
431
- // Yield the event loop between batches so already-resolved promises from
432
- // other parallel groups (LSP, eslint skip, etc.) can drain. Each wasm query
433
- // batch is synchronous CPU work that would otherwise pin the event loop for
434
- // the full runner duration, making other runners' latency measurements wrong.
435
- if (i > 0)
436
- await new Promise((r) => setImmediate(r));
437
- const batch = effectiveQueries.slice(i, i + CONCURRENCY_LIMIT);
438
- const batchResults = await Promise.all(batch.map(async (query) => {
439
- const queryDiagnostics = [];
440
- try {
441
- const matches = await client.runQueryOnFile(query, filePath, languageId, { maxResults: 10 }, contentOverride);
442
- for (const match of matches) {
443
- // Get line/column from match (already 0-indexed from tree-sitter)
444
- const line = match.line;
445
- const column = match.column;
446
- // Modified-ranges gate only applies to blocking-tier diagnostics.
447
- // Warning-tier diagnostics always flow through for logging.
448
- const isSeverityBlocking = query.severity === "error" ||
449
- query.inline_tier === "blocking" ||
450
- SILENT_ERROR_QUERY_IDS.has(query.id);
451
- if (ctx.blockingOnly &&
452
- isSeverityBlocking &&
453
- !isLineInModifiedRanges(line + 1, ctx.modifiedRanges)) {
454
- continue;
455
- }
456
- // Map severity to semantic
457
- const semantic = query.severity === "error"
458
- ? "blocking"
459
- : query.severity === "warning"
460
- ? "warning"
461
- : "none";
462
- const defectClass = query.defect_class ??
463
- classifyDefect(query.id, "tree-sitter", query.message);
464
- const suggestion = query.has_fix && query.fix_action
465
- ? `${query.fix_action} this statement`
466
- : semantic === "blocking"
467
- ? defaultFixSuggestion(defectClass, query.id)
468
- : undefined;
469
- const hasSuggestedFix = !!query.has_fix;
470
- queryDiagnostics.push({
471
- id: `tree-sitter:${query.id}:${line}`,
472
- message: query.message.replace(/\{\{(\w+)\}\}/g, (_, name) => match.captures[name]?.trim() ?? `{{${name}}}`),
473
- filePath,
474
- line: line + 1, // 1-indexed
475
- column: column + 1, // 1-indexed
476
- severity: query.severity,
477
- semantic,
478
- tool: "tree-sitter",
479
- rule: query.id,
480
- defectClass,
481
- // Surface fix intent to agent — tree-sitter never auto-applies;
482
- // linters (biome/ruff/eslint) own the autofix phase.
483
- fixable: hasSuggestedFix,
484
- autoFixAvailable: false,
485
- fixKind: hasSuggestedFix ? "suggestion" : undefined,
486
- fixSuggestion: suggestion,
487
- matchedText: match.matchedText || undefined,
488
- astNodeType: match.nodeType || undefined,
489
- });
490
- }
491
- }
492
- catch (err) {
493
- // pi-lens-ignore: missing-error-propagation — per-query resilience loop, intentional
494
- const msg = err instanceof Error ? err.message : String(err);
495
- // Emscripten abort() corrupts the entire module-level wasm heap.
496
- // Poison the singleton so no further queries attempt to use the dead runtime.
497
- if (msg.includes("Aborted") || msg.includes("abort()")) {
498
- markTreeSitterWasmAborted();
499
- logTreeSitter({
500
- phase: "query_error",
501
- filePath,
502
- languageId,
503
- queryId: query.id,
504
- error: "wasm_aborted_fatal",
505
- });
506
- }
507
- else {
508
- console.error(`[tree-sitter] Query ${query.id} failed:`, err);
509
- logTreeSitter({
510
- phase: "query_error",
511
- filePath,
512
- languageId,
513
- queryId: query.id,
514
- error: msg,
515
- });
516
- }
451
+ // ONE tree walk for the whole effective rule set, not one per rule (#888).
452
+ // The per-rule loop re-walked the tree ~30-40 times per edit; the work is
453
+ // synchronous WASM, so the concurrency-limit batching only chunked CPU work
454
+ // without parallelizing anything. runQueriesOnFile concatenates the patterns
455
+ // in rule order and maps each match back to its owning rule, so the per-rule
456
+ // maxResults(10) cap, metavars, predicates and post-filters still apply and
457
+ // results stay grouped in rule order. The post-match gating below (modified
458
+ // ranges, severity mapping) is unchanged.
459
+ const diagnostics = [];
460
+ try {
461
+ const found = await client.runQueriesOnFile(effectiveQueries, filePath, languageId, { maxResults: 10 }, contentOverride);
462
+ for (const { queryDef: query, match } of found) {
463
+ // match.line/column are already 1-indexed — the client emits
464
+ // startPosition.row + 1 (tree-sitter-client searchFileWithQuery).
465
+ const { line, column } = match;
466
+ // Modified-ranges gate only applies to blocking-tier diagnostics.
467
+ // Warning-tier diagnostics always flow through for logging.
468
+ const isSeverityBlocking = query.severity === "error" ||
469
+ query.inline_tier === "blocking" ||
470
+ SILENT_ERROR_QUERY_IDS.has(query.id);
471
+ if (ctx.blockingOnly &&
472
+ isSeverityBlocking &&
473
+ !isLineInModifiedRanges(line, ctx.modifiedRanges)) {
474
+ continue;
517
475
  }
518
- return queryDiagnostics;
519
- }));
520
- queryResults.push(...batchResults);
476
+ // Map severity to semantic
477
+ const semantic = query.severity === "error"
478
+ ? "blocking"
479
+ : query.severity === "warning"
480
+ ? "warning"
481
+ : "none";
482
+ const defectClass = query.defect_class ??
483
+ classifyDefect(query.id, "tree-sitter", query.message);
484
+ const suggestion = query.has_fix && query.fix_action
485
+ ? `${query.fix_action} this statement`
486
+ : semantic === "blocking"
487
+ ? defaultFixSuggestion(defectClass, query.id)
488
+ : undefined;
489
+ const hasSuggestedFix = !!query.has_fix;
490
+ diagnostics.push({
491
+ id: `tree-sitter:${query.id}:${line}`,
492
+ message: query.message.replace(/\{\{(\w+)\}\}/g, (_, name) => match.captures[name]?.trim() ?? `{{${name}}}`),
493
+ filePath,
494
+ line,
495
+ column,
496
+ severity: query.severity,
497
+ semantic,
498
+ tool: "tree-sitter",
499
+ rule: query.id,
500
+ defectClass,
501
+ // Surface fix intent to agent — tree-sitter never auto-applies;
502
+ // linters (biome/ruff/eslint) own the autofix phase.
503
+ fixable: hasSuggestedFix,
504
+ autoFixAvailable: false,
505
+ fixKind: hasSuggestedFix ? "suggestion" : undefined,
506
+ fixSuggestion: suggestion,
507
+ matchedText: match.matchedText || undefined,
508
+ astNodeType: match.nodeType || undefined,
509
+ });
510
+ }
521
511
  }
522
- // Flatten all query results into final diagnostics array
523
- const diagnostics = queryResults.flat();
524
- // Skip expensive entity extraction for trivial changes (< 5 lines)
525
- // This avoids ~500-800ms overhead for small edits like single-line fixes
512
+ catch (err) {
513
+ // pi-lens-ignore: missing-error-propagation — per-dispatch resilience, intentional
514
+ const msg = err instanceof Error ? err.message : String(err);
515
+ // Emscripten abort() corrupts the entire module-level wasm heap.
516
+ // Poison the singleton so no further queries attempt to use the dead runtime.
517
+ if (msg.includes("Aborted") || msg.includes("abort()")) {
518
+ markTreeSitterWasmAborted();
519
+ logTreeSitter({
520
+ phase: "query_error",
521
+ filePath,
522
+ languageId,
523
+ error: "wasm_aborted_fatal",
524
+ metadata: { queryIds: effectiveQueries.map((q) => q.id) },
525
+ });
526
+ }
527
+ else {
528
+ console.error("[tree-sitter] Batched query run failed:", err);
529
+ logTreeSitter({
530
+ phase: "query_error",
531
+ filePath,
532
+ languageId,
533
+ error: msg,
534
+ metadata: { queryIds: effectiveQueries.map((q) => q.id) },
535
+ });
536
+ }
537
+ }
538
+ // Skip expensive entity extraction for trivial changes (< 5 lines).
539
+ // Before #885 this threshold only guarded the zero-diagnostics early
540
+ // return below; a second extractEntitySnapshot block ran UNCONDITIONALLY
541
+ // after it, so small edits (skipEntityExtraction=true) still paid the
542
+ // ~500-800ms snapshot cost on every dispatch. One guarded block now
543
+ // serves both paths.
544
+ // Absent/empty modifiedRanges means "whole file" (full-file dispatches,
545
+ // fresh writes — see isLineInModifiedRanges above), never "trivial edit":
546
+ // entity extraction must run in that case.
526
547
  const totalLinesChanged = getTotalLinesChanged(ctx.modifiedRanges);
527
- const skipEntityExtraction = totalLinesChanged < ENTITY_EXTRACTION_LINE_THRESHOLD;
528
- if (diagnostics.length === 0 && !skipEntityExtraction) {
548
+ const skipEntityExtraction = ctx.modifiedRanges != null &&
549
+ ctx.modifiedRanges.length > 0 &&
550
+ totalLinesChanged < ENTITY_EXTRACTION_LINE_THRESHOLD;
551
+ if (!skipEntityExtraction) {
529
552
  try {
530
- const snapshot = await extractEntitySnapshot(client, filePath, languageId);
553
+ const snapshot = await extractEntitySnapshot(client, filePath, languageId, contentOverride);
531
554
  const diff = recordEntitySnapshotDiff(ctx.facts, filePath, snapshot);
532
555
  const changedEntityKeys = [
533
556
  ...diff.added,
@@ -564,6 +587,8 @@ const treeSitterRunner = {
564
587
  catch {
565
588
  /* entity snapshot / blast-radius enrichment is best-effort */
566
589
  }
590
+ }
591
+ if (diagnostics.length === 0) {
567
592
  logTreeSitter({
568
593
  phase: "runner_complete",
569
594
  filePath,
@@ -579,44 +604,6 @@ const treeSitterRunner = {
579
604
  // Check if any blocking issues
580
605
  const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
581
606
  const blockingCount = diagnostics.filter((d) => d.semantic === "blocking").length;
582
- try {
583
- const snapshot = await extractEntitySnapshot(client, filePath, languageId);
584
- const diff = recordEntitySnapshotDiff(ctx.facts, filePath, snapshot);
585
- const changedEntityKeys = [
586
- ...diff.added,
587
- ...diff.modified,
588
- ...diff.removed,
589
- ];
590
- if (changedEntityKeys.length > 0) {
591
- logTreeSitter({
592
- phase: "entity_diff",
593
- filePath,
594
- languageId,
595
- metadata: {
596
- added: diff.added,
597
- modified: diff.modified,
598
- removed: diff.removed,
599
- totalChanged: changedEntityKeys.length,
600
- },
601
- });
602
- const lastBlast = blastCooldownByFile.get(filePath) ?? 0;
603
- if (Date.now() - lastBlast < BLAST_COOLDOWN_MS) {
604
- logTreeSitter({
605
- phase: "blast_radius",
606
- filePath,
607
- languageId,
608
- metadata: { skipped: "cooldown", cooldownMs: BLAST_COOLDOWN_MS },
609
- });
610
- }
611
- else {
612
- blastCooldownByFile.set(filePath, Date.now());
613
- runBlastRadiusInBackground(ctx.cwd, filePath, languageId, ctx.facts);
614
- }
615
- }
616
- }
617
- catch {
618
- // best-effort experimental telemetry only
619
- }
620
607
  logTreeSitter({
621
608
  phase: "runner_complete",
622
609
  filePath,
@@ -10,7 +10,7 @@ import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { getGlobalPiLensDir } from "../../../file-utils.js";
13
- import { ensureTool } from "../../../installer/index.js";
13
+ import { ensureTool, isSpawnableCommand } from "../../../installer/index.js";
14
14
  import { getServersForFileWithConfig, isServerDisabled, } from "../../../lsp/config.js";
15
15
  import { findGlobalBinary } from "../../../package-manager.js";
16
16
  import { safeSpawnAsync } from "../../../safe-spawn.js";
@@ -207,27 +207,26 @@ export async function resolveToolCommandWithInstallFallback(cwd, toolId, timeout
207
207
  return resolveCommandWithInstallFallback(resolveToolCommand(cwd, toolId) ?? spec.command, spec.managedToolId ?? toolId, cwd, spec.versionArgs ?? ["--version"], timeout);
208
208
  }
209
209
  async function verifyOrInstallCommand(command, toolId, cwd, versionArgs = ["--version"], timeout = 5000) {
210
- const versionCheck = await safeSpawnAsync(command, versionArgs, {
211
- timeout,
212
- cwd,
213
- });
214
- if (!versionCheck.error && versionCheck.status === 0) {
215
- return command;
210
+ // Skip the --version spawn when the command isn't even on disk — the ~μs
211
+ // stat/PATH walk beats a guaranteed-to-fail spawn round-trip.
212
+ if (await isSpawnableCommand(command)) {
213
+ const versionCheck = await safeSpawnAsync(command, versionArgs, {
214
+ timeout,
215
+ cwd,
216
+ });
217
+ if (!versionCheck.error && versionCheck.status === 0) {
218
+ return command;
219
+ }
216
220
  }
217
221
  if (!shouldAutoInstallTool(toolId)) {
218
222
  return null;
219
223
  }
220
- const installed = await ensureTool(toolId);
221
- if (!installed)
222
- return null;
223
- const installedCheck = await safeSpawnAsync(installed, versionArgs, {
224
- timeout,
225
- cwd,
226
- });
227
- if (installedCheck.error || installedCheck.status !== 0) {
228
- return null;
229
- }
230
- return installed;
224
+ // ensureTool's result is already trusted: a probe-cache hit validated
225
+ // path+mtime, and a fresh install verified the binary. Re-probing
226
+ // --version here booted the tool's node shim once per invocation for
227
+ // zero new information; a broken install now surfaces as the runner's
228
+ // own spawn error instead of a silent null.
229
+ return (await ensureTool(toolId)) ?? null;
231
230
  }
232
231
  export async function resolveCommandArgsWithInstallFallback(command, toolId, cwd, versionArgs = ["--version"], timeout = 5000) {
233
232
  const versionCheck = await safeSpawnAsync(command.cmd, [...command.args, ...versionArgs], { timeout, cwd });
@@ -1,5 +1,22 @@
1
1
  function normalizeHintInput(value) {
2
- return value.replace(/\\/g, "/").toLowerCase();
2
+ return value.replace(/\\/g, "/");
3
+ }
4
+ function hasHintToken(value, tokens) {
5
+ const escaped = tokens.map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
6
+ const alternatives = escaped.join("|");
7
+ // Path/word boundaries are deliberately explicit: a token must occupy a
8
+ // segment (or a dot/dash/underscore-separated word), never an arbitrary
9
+ // substring. The second expression preserves camelCase names such as
10
+ // authStore and DatabaseClient without making `adb` match `db`.
11
+ return (new RegExp(`(?:^|[/_.\\-\\s])(?:${alternatives})(?=[/_.\\-\\s]|$)`, "i").test(value) ||
12
+ new RegExp(`(?:^|(?<=[a-z0-9]))(?:${tokens
13
+ .flatMap((token) => [
14
+ token[0].toUpperCase() + token.slice(1),
15
+ ...(token === "openai" ? ["OpenAI"] : []),
16
+ ...(token === "github" ? ["GitHub"] : []),
17
+ ...(token === "gitlab" ? ["GitLab"] : []),
18
+ ])
19
+ .join("|")})(?=[A-Z]|[/_.\\-\\s]|$)`).test(value));
3
20
  }
4
21
  /**
5
22
  * Deterministic feature-kind hint derived from package/entity/file names.
@@ -7,10 +24,19 @@ function normalizeHintInput(value) {
7
24
  */
8
25
  export function inferFeatureKind(nameOrPath) {
9
26
  const normalized = normalizeHintInput(nameOrPath);
10
- if (/config|store|db|database|github|openai|sync|service/.test(normalized)) {
27
+ if (hasHintToken(normalized, [
28
+ "config",
29
+ "store",
30
+ "db",
31
+ "database",
32
+ "github",
33
+ "openai",
34
+ "sync",
35
+ "service",
36
+ ])) {
11
37
  return "service";
12
38
  }
13
- if (/(^|[/_.\-\s])(cli|command|bin)([/_.\-\s]|$)/.test(normalized)) {
39
+ if (/(^|[/_.\-\s])(cli|command|bin)([/_.\-\s]|$)/i.test(normalized)) {
14
40
  return "cli-command";
15
41
  }
16
42
  return "library";
@@ -22,20 +48,40 @@ export function inferFeatureKind(nameOrPath) {
22
48
  export function inferTrustBoundaries(nameOrPath) {
23
49
  const normalized = normalizeHintInput(nameOrPath);
24
50
  const boundaries = new Set();
25
- if (/config|store|db|database|repo|repository|model|migration/.test(normalized)) {
51
+ if (hasHintToken(normalized, [
52
+ "config",
53
+ "store",
54
+ "db",
55
+ "database",
56
+ "repo",
57
+ "repository",
58
+ "model",
59
+ "migration",
60
+ ])) {
26
61
  boundaries.add("filesystem");
27
62
  boundaries.add("database");
28
63
  }
29
- if (/github|gitlab|openai|anthropic|stripe|slack|sync|webhook|api|client/.test(normalized)) {
64
+ if (hasHintToken(normalized, [
65
+ "github",
66
+ "gitlab",
67
+ "openai",
68
+ "anthropic",
69
+ "stripe",
70
+ "slack",
71
+ "sync",
72
+ "webhook",
73
+ "api",
74
+ "client",
75
+ ])) {
30
76
  boundaries.add("network");
31
77
  boundaries.add("external-api");
32
78
  boundaries.add("serialization");
33
79
  }
34
- if (/(^|[/_.\-\s])(cli|command|bin|exec|spawn|process|shell)([/_.\-\s]|$)/.test(normalized)) {
80
+ if (/(^|[/_.\-\s])(cli|command|bin|exec|spawn|process|shell)([/_.\-\s]|$)/i.test(normalized)) {
35
81
  boundaries.add("user-input");
36
82
  boundaries.add("process-exec");
37
83
  }
38
- if (/auth|login|token|session|oauth|jwt/.test(normalized)) {
84
+ if (hasHintToken(normalized, ["auth", "login", "token", "session", "oauth", "jwt"])) {
39
85
  boundaries.add("auth");
40
86
  boundaries.add("user-input");
41
87
  }