pi-lens 4.1.5 → 4.2.0

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 (219) hide show
  1. package/CHANGELOG.md +415 -0
  2. package/README.md +13 -8
  3. package/config/dependency-cruiser-eager-allowlist.json +6 -1
  4. package/dist/clients/actionable-warnings.js +30 -4
  5. package/dist/clients/agent-behavior-client.js +2 -0
  6. package/dist/clients/analysed-root.js +1 -0
  7. package/dist/clients/ast-grep-tool-logger.js +1 -1
  8. package/dist/clients/bash-file-access.js +26 -0
  9. package/dist/clients/biome-client.js +13 -2
  10. package/dist/clients/blocker-freshness.js +403 -35
  11. package/dist/clients/bootstrap.js +6 -1
  12. package/dist/clients/bounded-telemetry.js +2 -0
  13. package/dist/clients/cache-observability.js +122 -1
  14. package/dist/clients/cascade-format.js +200 -4
  15. package/dist/clients/code-quality-warnings.js +10 -2
  16. package/dist/clients/complexity-client.js +5 -0
  17. package/dist/clients/config-diagnostic-codes.js +2 -0
  18. package/dist/clients/config-resolve.js +6 -3
  19. package/dist/clients/config-schema.js +17 -0
  20. package/dist/clients/config-warn.js +2 -2
  21. package/dist/clients/dead-code-client.js +8 -1
  22. package/dist/clients/degradation-ledger.js +16 -1
  23. package/dist/clients/dependency-checker.js +5 -1
  24. package/dist/clients/diagnostic-dispositions.js +12 -1
  25. package/dist/clients/dispatch/ast-grep-catalog.js +65 -0
  26. package/dist/clients/dispatch/auxiliary-lsp.js +28 -0
  27. package/dist/clients/dispatch/dispatcher.js +47 -17
  28. package/dist/clients/dispatch/facts/function-facts.js +1 -1
  29. package/dist/clients/dispatch/facts/import-facts.js +5 -1
  30. package/dist/clients/dispatch/finding-policy.js +222 -0
  31. package/dist/clients/dispatch/indent-detect.js +389 -13
  32. package/dist/clients/dispatch/integration.js +175 -25
  33. package/dist/clients/dispatch/rule-ignores.js +62 -0
  34. package/dist/clients/dispatch/runners/actionlint.js +2 -1
  35. package/dist/clients/dispatch/runners/ast-grep-napi.js +21 -41
  36. package/dist/clients/dispatch/runners/biome-check.js +8 -4
  37. package/dist/clients/dispatch/runners/cpp-check.js +4 -3
  38. package/dist/clients/dispatch/runners/credo.js +2 -1
  39. package/dist/clients/dispatch/runners/cue-vet.js +2 -1
  40. package/dist/clients/dispatch/runners/dart-analyze.js +2 -1
  41. package/dist/clients/dispatch/runners/detekt.js +2 -1
  42. package/dist/clients/dispatch/runners/dotnet-build.js +2 -1
  43. package/dist/clients/dispatch/runners/elixir-check.js +2 -1
  44. package/dist/clients/dispatch/runners/eslint.js +2 -1
  45. package/dist/clients/dispatch/runners/fish-indent.js +2 -1
  46. package/dist/clients/dispatch/runners/gleam-check.js +2 -1
  47. package/dist/clients/dispatch/runners/go-vet.js +2 -1
  48. package/dist/clients/dispatch/runners/golangci-lint.js +2 -1
  49. package/dist/clients/dispatch/runners/hadolint.js +2 -1
  50. package/dist/clients/dispatch/runners/helm-lint.js +3 -1
  51. package/dist/clients/dispatch/runners/helm-render.js +2 -1
  52. package/dist/clients/dispatch/runners/htmlhint.js +2 -1
  53. package/dist/clients/dispatch/runners/javac.js +2 -1
  54. package/dist/clients/dispatch/runners/ktlint.js +2 -1
  55. package/dist/clients/dispatch/runners/lsp.js +19 -1
  56. package/dist/clients/dispatch/runners/markdownlint.js +3 -2
  57. package/dist/clients/dispatch/runners/mypy.js +2 -1
  58. package/dist/clients/dispatch/runners/oxlint.js +2 -1
  59. package/dist/clients/dispatch/runners/php-lint.js +2 -1
  60. package/dist/clients/dispatch/runners/phpstan.js +2 -1
  61. package/dist/clients/dispatch/runners/prisma-validate.js +2 -1
  62. package/dist/clients/dispatch/runners/psscriptanalyzer.js +12 -12
  63. package/dist/clients/dispatch/runners/pyright.js +2 -1
  64. package/dist/clients/dispatch/runners/rubocop.js +2 -1
  65. package/dist/clients/dispatch/runners/ruff.js +2 -2
  66. package/dist/clients/dispatch/runners/rust-clippy.js +12 -11
  67. package/dist/clients/dispatch/runners/shellcheck.js +2 -1
  68. package/dist/clients/dispatch/runners/shfmt.js +4 -13
  69. package/dist/clients/dispatch/runners/spellcheck.js +2 -2
  70. package/dist/clients/dispatch/runners/spotbugs.js +2 -1
  71. package/dist/clients/dispatch/runners/sqlfluff.js +3 -2
  72. package/dist/clients/dispatch/runners/stylelint.js +3 -2
  73. package/dist/clients/dispatch/runners/swiftlint.js +2 -1
  74. package/dist/clients/dispatch/runners/taplo.js +2 -1
  75. package/dist/clients/dispatch/runners/terragrunt.js +2 -1
  76. package/dist/clients/dispatch/runners/tflint.js +2 -1
  77. package/dist/clients/dispatch/runners/tree-sitter.js +2 -21
  78. package/dist/clients/dispatch/runners/trivy-config.js +2 -1
  79. package/dist/clients/dispatch/runners/utils/availability-policy.js +5 -0
  80. package/dist/clients/dispatch/runners/utils/candidate-probe.js +2 -2
  81. package/dist/clients/dispatch/runners/utils/runner-helpers.js +8 -2
  82. package/dist/clients/dispatch/runners/vale.js +2 -1
  83. package/dist/clients/dispatch/runners/yamllint.js +4 -3
  84. package/dist/clients/dispatch/runners/zig-check.js +2 -1
  85. package/dist/clients/dispatch/types.js +4 -1
  86. package/dist/clients/disposition-publish.js +1 -0
  87. package/dist/clients/effective-config.js +12 -2
  88. package/dist/clients/extension-log.js +2 -0
  89. package/dist/clients/file-kinds.js +26 -1
  90. package/dist/clients/file-role.js +23 -6
  91. package/dist/clients/file-utils.js +156 -10
  92. package/dist/clients/finding-delivery-gate.js +60 -9
  93. package/dist/clients/format-service.js +39 -21
  94. package/dist/clients/formatters.js +113 -200
  95. package/dist/clients/generation-guard.js +6 -2
  96. package/dist/clients/git-guard.js +11 -3
  97. package/dist/clients/gitleaks-client.js +42 -6
  98. package/dist/clients/govulncheck-client.js +48 -7
  99. package/dist/clients/indent-retarget.js +78 -8
  100. package/dist/clients/installer/index.js +524 -161
  101. package/dist/clients/instance-reaper-state.js +7 -0
  102. package/dist/clients/instance-reaper.js +20 -10
  103. package/dist/clients/jscpd-client.js +9 -1
  104. package/dist/clients/knip-client.js +9 -2
  105. package/dist/clients/language-profile.js +35 -3
  106. package/dist/clients/language-registry.js +5 -1
  107. package/dist/clients/latency-logger.js +6 -1
  108. package/dist/clients/lens-config.js +4 -0
  109. package/dist/clients/lens-engine.js +12 -3
  110. package/dist/clients/lens-events.js +2 -1
  111. package/dist/clients/lens-flag-registry.js +79 -0
  112. package/dist/clients/live-bus-emitter.js +2 -1
  113. package/dist/clients/lsp/client.js +25 -21
  114. package/dist/clients/lsp/config.js +36 -7
  115. package/dist/clients/lsp/diagnostic-binding.js +24 -1
  116. package/dist/clients/lsp/document-drift.js +78 -0
  117. package/dist/clients/lsp/index.js +288 -45
  118. package/dist/clients/lsp/launch.js +16 -4
  119. package/dist/clients/lsp/server.js +79 -15
  120. package/dist/clients/lsp/workspace-diagnostics-cache.js +39 -5
  121. package/dist/clients/lsp-mutation.js +5 -3
  122. package/dist/clients/map-with-concurrency.js +12 -8
  123. package/dist/clients/mcp/analyze.js +3 -0
  124. package/dist/clients/mcp/session.js +38 -4
  125. package/dist/clients/ndjson-logger.js +24 -7
  126. package/dist/clients/observed-mutation.js +40 -2
  127. package/dist/clients/opaque-mutation-scan.js +23 -3
  128. package/dist/clients/opengrep-client.js +111 -14
  129. package/dist/clients/package-manager.js +2 -1
  130. package/dist/clients/path-utils.js +123 -3
  131. package/dist/clients/performance-report.js +5 -1
  132. package/dist/clients/php-cs-fixer-config.js +8 -3
  133. package/dist/clients/pipeline.js +77 -7
  134. package/dist/clients/project-diagnostics/cache.js +37 -5
  135. package/dist/clients/project-diagnostics/fresh-fetch.js +173 -18
  136. package/dist/clients/project-diagnostics/runner-adapters/dead-code.js +4 -1
  137. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +10 -9
  138. package/dist/clients/project-diagnostics/scanner.js +59 -3
  139. package/dist/clients/project-lens-config.js +48 -2
  140. package/dist/clients/read-guard-logger.js +2 -0
  141. package/dist/clients/read-guard.js +70 -11
  142. package/dist/clients/resource-sampler.js +147 -9
  143. package/dist/clients/review-graph-logger.js +2 -0
  144. package/dist/clients/ruff-client.js +18 -9
  145. package/dist/clients/runtime-agent-end.js +42 -5
  146. package/dist/clients/runtime-context.js +6 -2
  147. package/dist/clients/runtime-coordinator.js +82 -7
  148. package/dist/clients/runtime-session.js +33 -5
  149. package/dist/clients/runtime-tool-call.js +43 -12
  150. package/dist/clients/runtime-tool-result.js +334 -86
  151. package/dist/clients/runtime-turn.js +195 -18
  152. package/dist/clients/safe-spawn.js +258 -5
  153. package/dist/clients/scratch-tree-policy.js +6 -0
  154. package/dist/clients/security-scan-client.js +2 -2
  155. package/dist/clients/session-event-guard.js +119 -6
  156. package/dist/clients/sg-runner.js +25 -12
  157. package/dist/clients/sgconfig.js +79 -2
  158. package/dist/clients/situational-tool-telemetry.js +108 -0
  159. package/dist/clients/test-runner-client.js +258 -78
  160. package/dist/clients/test-runner-delivery.js +43 -0
  161. package/dist/clients/tool-agreement.js +423 -0
  162. package/dist/clients/tool-config.js +249 -0
  163. package/dist/clients/tool-cwd.js +270 -0
  164. package/dist/clients/tool-policy.js +163 -7
  165. package/dist/clients/tool-probe.js +53 -0
  166. package/dist/clients/tool-set-policy.js +42 -0
  167. package/dist/clients/tree-sitter-client.js +120 -0
  168. package/dist/clients/tree-sitter-query-loader.js +127 -189
  169. package/dist/clients/trivy-client.js +9 -1
  170. package/dist/clients/turn-context.js +52 -0
  171. package/dist/clients/widget-state.js +102 -3
  172. package/dist/clients/word-index.js +5 -2
  173. package/dist/clients/write-ordering-guard.js +7 -0
  174. package/dist/index.js +26968 -21741
  175. package/dist/mcp/analyze-cli.js +3 -2
  176. package/dist/mcp/server.js +140 -161
  177. package/dist/tools/activate-tools.js +10 -7
  178. package/dist/tools/ast-grep-outline.js +2 -13
  179. package/dist/tools/ast-grep-replace.js +8 -13
  180. package/dist/tools/ast-grep-search.js +83 -39
  181. package/dist/tools/effective-config.js +4 -3
  182. package/dist/tools/lens-diagnostic-mark.js +8 -11
  183. package/dist/tools/lens-diagnostics.js +516 -133
  184. package/dist/tools/lsp-diagnostics.js +265 -182
  185. package/dist/tools/lsp-navigation.js +20 -44
  186. package/dist/tools/module-report.js +5 -9
  187. package/dist/tools/project-report.js +9 -17
  188. package/dist/tools/render-compact.js +280 -1
  189. package/dist/tools/symbol-search.js +2 -2
  190. package/docs/agent-guide.md +3 -4
  191. package/docs/agent-tools.md +46 -22
  192. package/docs/audit1.md +5 -5
  193. package/docs/configuration.md +9 -0
  194. package/docs/dependencies.md +11 -0
  195. package/docs/dispositions.md +52 -0
  196. package/docs/environment-variables.md +2 -7
  197. package/docs/features.md +7 -7
  198. package/docs/globalconfig.md +14 -2
  199. package/docs/language-coverage.md +1 -0
  200. package/docs/mcp.md +13 -1
  201. package/docs/pi-lens-fixer.md +48 -18
  202. package/docs/pi-lens-investigator.md +38 -18
  203. package/docs/pi-lens-monitor.md +88 -0
  204. package/docs/pi-lens-retro.md +97 -0
  205. package/docs/pi-lens-reviewer.md +53 -31
  206. package/docs/pi-lens-subagent.md +12 -4
  207. package/docs/public-api-stability.md +1 -0
  208. package/docs/real-harness.md +46 -0
  209. package/docs/release-qa-baseline.md +27 -1
  210. package/docs/servercapabilities.md +1 -6
  211. package/docs/settings.md +44 -6
  212. package/docs/tools_improvement2.md +4 -4
  213. package/docs/usage.md +15 -3
  214. package/package.json +10 -1
  215. package/rules/tree-sitter-queries/typescript/sql-injection.yml +23 -7
  216. package/scripts/analyze-pi-lens-logs.mjs +40 -0
  217. package/skills/pi-lens-ast-grep/SKILL.md +23 -6
  218. package/skills/pi-lens-lsp-navigation/SKILL.md +26 -9
  219. package/dist/tools/ast-dump.js +0 -103
@@ -78,6 +78,11 @@ import { lineContentHash } from "./read-guard.js";
78
78
  // below goes through `deferredKey`, which folds the resolved cwd into the Set
79
79
  // key, so the two projects can no longer collide.
80
80
  const deferredThisSession = new Set();
81
+ let widgetDispositionReconciler = null;
82
+ /** Register the widget's mirror of the durable disposition state. */
83
+ export function registerWidgetDispositionReconciler(reconciler) {
84
+ widgetDispositionReconciler = reconciler;
85
+ }
81
86
  function deferredKey(cwd, anchor) {
82
87
  return `${normalizeMapKey(cwd)}::${anchor}`;
83
88
  }
@@ -97,7 +102,8 @@ export const normalizeMessage = sharedNormalizeMessage;
97
102
  // case, symlink/realpath, or slash difference between the two forms silently
98
103
  // orphans the agent's own false-positive/flagged mark (a #533 dropped-signal).
99
104
  // Canonicalize BOTH inputs through `normalizeMapKey` (the SAME normalizer the
100
- // read side already relies on — realpathSync.native on Windows) BEFORE computing
105
+ // read side already relies on — realpathSync.native on Windows, and on POSIX
106
+ // too since #3098) BEFORE computing
101
107
  // the relative path, so write and read produce identical anchors regardless of
102
108
  // the form the caller held. `normalizeMapKey` is idempotent, so the already-
103
109
  // canonicalized read side is unaffected; the realpath I/O is acceptable here
@@ -357,6 +363,7 @@ function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing, i
357
363
  publishDisposition({
358
364
  cwd,
359
365
  filePath: target.filePath,
366
+ message: target.message,
360
367
  disposition,
361
368
  tool: target.tool,
362
369
  rule: target.rule,
@@ -390,6 +397,7 @@ export function markDisposition(cwd, target, disposition, reason, identity) {
390
397
  const existing = readState(cwd).dispositions?.[anchor];
391
398
  if (disposition === "defer") {
392
399
  deferredThisSession.add(deferredKey(cwd, anchor));
400
+ widgetDispositionReconciler?.(cwd, target, disposition);
393
401
  emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing, identity);
394
402
  return anchor;
395
403
  }
@@ -407,6 +415,9 @@ export function markDisposition(cwd, target, disposition, reason, identity) {
407
415
  lineText,
408
416
  };
409
417
  commitDisposition(cwd, anchor, entry);
418
+ // The durable commit is the guard for this mirror: a failed commit must not
419
+ // make the in-memory widget claim that a mark exists.
420
+ widgetDispositionReconciler?.(cwd, target, disposition);
410
421
  emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing, identity);
411
422
  return anchor;
412
423
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The effective ast-grep rule catalog for a workspace root (#3053).
3
+ *
4
+ * Before this module, `clients/dispatch/runners/ast-grep-napi.ts` (the NAPI
5
+ * runner) and `clients/dispatch/rule-ignores.ts` (the LSP-seam matcher, #3041)
6
+ * each walked `getAstGrepRuleSources(root)` and resolved rule-id precedence
7
+ * independently — two implementations of the same derivation. They had
8
+ * already drifted once by the time #3053 was filed: #3046 round 2 found the
9
+ * LSP seam claiming an id only when the winning document declared `ignores`,
10
+ * so a project rule that redefines a bundled id with NO `ignores` fired
11
+ * per-edit (the runner) but stayed carved out over LSP (the seam) — the same
12
+ * defect the whole PR existed to remove, just inverted. This module is the
13
+ * one place that walk and that precedence rule now live.
14
+ *
15
+ * Precedence: sources are walked in `getAstGrepRuleSources`'s order (project
16
+ * primary, project secondary, bundled primary, bundled secondary), each
17
+ * loaded with the same loader the runner always used (`loadYamlRulesFresh`
18
+ * for mutable project trees, `loadYamlRules`'s mtime-cached path for
19
+ * immutable bundled catalogs). The FIRST rule sighted for an id — scanning
20
+ * sources in order, and each source's own rules in file order — claims it;
21
+ * no later document for the same id is ever consulted.
22
+ *
23
+ * Deliberately does NOT special-case an id that recurs WITHIN one source's
24
+ * own rule list. That is `duplicateRuleIds` in ast-grep-napi.ts, a
25
+ * runner-only concern layered on top of this catalog's plain result: the
26
+ * runner drops such an id entirely (one Duplicate-rule-id diagnostic, no
27
+ * rule fires for it), but `materializeMergedRuleDir` (clients/sgconfig.ts) —
28
+ * what ast-grep's own LSP binary actually runs — keeps every document an
29
+ * EARLIER source hasn't already claimed, so both copies of a within-source
30
+ * duplicate still survive into what ast-grep publishes over LSP. Baking the
31
+ * runner's drop into this catalog would leave those published findings with
32
+ * no registered `ignores` at all, silently unsuppressed — reopening #3041 for
33
+ * that id (#3046 round 2 F7). So the catalog resolves a within-source
34
+ * duplicate to its first-sighted document like any other collision, and it
35
+ * is each consumer's job to decide what a within-source duplicate means for
36
+ * it — the runner still computes `duplicateRuleIds` itself and skips those
37
+ * ids before ever consulting this catalog's answer for them.
38
+ */
39
+ import { getAstGrepRuleSources } from "../sgconfig.js";
40
+ import { loadYamlRules, loadYamlRulesFresh, } from "./runners/yaml-rule-parser.js";
41
+ /**
42
+ * Build the effective ast-grep catalog for `root`. See the module docstring
43
+ * for the precedence rule and the within-source-duplicate decision.
44
+ *
45
+ * No try/catch around either loader: every I/O path inside them already
46
+ * swallows its own failure (missing dir, unreadable readdir, unreadable file
47
+ * all yield an empty list — `yaml-rule-parser.ts`'s `findYamlRuleFiles` and
48
+ * `loadYamlRuleFiles`), so a catch here would be a guard no mutation can red.
49
+ */
50
+ export function buildEffectiveAstGrepCatalog(root) {
51
+ const sources = [];
52
+ const effectiveRules = new Map();
53
+ for (const source of getAstGrepRuleSources(root)) {
54
+ const rules = source.origin === "project"
55
+ ? loadYamlRulesFresh(source.dir)
56
+ : loadYamlRules(source.dir);
57
+ sources.push({ source, rules });
58
+ for (const rule of rules) {
59
+ if (effectiveRules.has(rule.id))
60
+ continue;
61
+ effectiveRules.set(rule.id, { rule, source });
62
+ }
63
+ }
64
+ return { sources, effectiveRules };
65
+ }
@@ -22,6 +22,7 @@ import { findLocalOpengrepConfig } from "../opengrep-config.js";
22
22
  import { findLocalTyposConfig } from "../typos-config.js";
23
23
  import { findLocalZizmorConfig } from "../zizmor-config.js";
24
24
  import { classifyDefect } from "./diagnostic-taxonomy.js";
25
+ import { isRuleIgnoredForPath, loadRuleIgnorePatterns, } from "./rule-ignores.js";
25
26
  /**
26
27
  * Semgrep/opengrep `# nosemgrep` / `# nosemgrep: <rule-id>[,<rule-id>]` inline
27
28
  * suppression (#441). A bare `# nosemgrep` drops every finding on its line; the
@@ -248,8 +249,22 @@ export function isAuxiliaryDiagnosticSuppressed(d, content) {
248
249
  * files that are suppressed per-edit reappeared wholesale in every
249
250
  * `mode=full` sweep. Omitting `opts` (or `opts.fileRole`) keeps every existing
250
251
  * 2-arg call site's behavior byte-for-byte unchanged.
252
+ *
253
+ * #3041: `opts.filePath` + `opts.scanRoot` additionally apply each rule's own
254
+ * `ignores` path carve-out (#965) — the THIRD member of the same defect class
255
+ * as #586 and #692 above. `ast-grep scan` applies `ignores` during its own
256
+ * project walk, but `ast-grep lsp` publishes per-document diagnostics WITHOUT
257
+ * applying them (measured against ast-grep 0.45.3), so a rule the NAPI runner
258
+ * correctly skips on `scripts/**` came back over LSP on every `source=lsp`
259
+ * query and every `mode=full` sweep. Both routes reach this function, so the
260
+ * carve-out belongs here rather than in either route's own output merge.
251
261
  */
252
262
  export function applyAuxiliarySuppressions(diagnostics, content, opts) {
263
+ // One catalog read per call, not per diagnostic. Both rule loaders behind it
264
+ // are cached (~0.09 ms per call on pi-lens's own catalog), so a per-file call
265
+ // from a sweep costs the same order as the NAPI runner's own per-file load.
266
+ const { filePath, scanRoot } = opts ?? {};
267
+ const ruleIgnores = filePath && scanRoot ? loadRuleIgnorePatterns(scanRoot) : undefined;
253
268
  return diagnostics.filter((d) => {
254
269
  if (isAuxiliaryDiagnosticSuppressed(d, content))
255
270
  return false;
@@ -258,6 +273,19 @@ export function applyAuxiliarySuppressions(diagnostics, content, opts) {
258
273
  if (profile?.skipTestFiles)
259
274
  return false;
260
275
  }
276
+ // Keyed by the diagnostic's own rule id — ast-grep's LSP reports the bare
277
+ // catalog id as `code`. Gated on the producing tool as well: the map is
278
+ // built from the AST-GREP catalog, so without the gate any auxiliary whose
279
+ // own rule id happened to collide with a catalog id (several are generic —
280
+ // `no-raw-types`, `no-string-concat-in-loop`) would be silently dropped on
281
+ // a path an unrelated rule carved out. Losing a finding silently is the
282
+ // harm this whole change exists to stop, so the collision is gated, not
283
+ // accepted (#3041 r2 F4).
284
+ if (ruleIgnores !== undefined &&
285
+ findAuxiliaryProfileForSource(d.source)?.tool === "ast-grep" &&
286
+ isRuleIgnoredForPath(filePath, scanRoot, ruleIgnores.get(String(d.code)))) {
287
+ return false;
288
+ }
261
289
  return true;
262
290
  });
263
291
  }
@@ -26,10 +26,10 @@ import { getPrimaryDispatchGroup } from "../language-policy.js";
26
26
  import { resolveLanguageRootForFile } from "../language-profile.js";
27
27
  import { logLatency, phaseFinished, phaseStarted } from "../latency-logger.js";
28
28
  import { isSpawnableCommand } from "../installer/index.js";
29
- import { isAbsoluteFilePath, normalizeEphemeralMapKey, normalizeMapKey, } from "../path-utils.js";
29
+ import { isAbsoluteFilePath, isWindowsPath, normalizeEphemeralMapKey, normalizeMapKey, } from "../path-utils.js";
30
30
  import { loadPiLensProjectConfig } from "../project-lens-config.js";
31
31
  import { RUNTIME_CONFIG, getRunnerTimeoutFloorMs } from "../runtime-config.js";
32
- import { safeSpawnAsync } from "../safe-spawn.js";
32
+ import { probeToolAsync } from "../tool-probe.js";
33
33
  import { classifyDiagnostic } from "./diagnostic-taxonomy.js";
34
34
  import { classifyProbeFailure, logAvailabilityDecision, startHostStallSampler, transientRetryDelayMs, } from "./runners/utils/availability-policy.js";
35
35
  import { recordAvailabilityProbeOverrun, getDispatchAvailabilityGeneration, } from "./runners/utils/runner-helpers.js";
@@ -147,7 +147,7 @@ export async function checkToolAvailability(command, facts) {
147
147
  let hostStallMs;
148
148
  let probeJoined = false;
149
149
  try {
150
- const shared = dispatcherProbeFlights.run(`dispatcher:${key}`, () => safeSpawnAsync(command, ["--version"], {
150
+ const shared = dispatcherProbeFlights.run(`dispatcher:${key}`, () => probeToolAsync(command, ["--version"], {
151
151
  timeout: TOOL_PROBE_TIMEOUT_MS,
152
152
  }));
153
153
  probeJoined = shared.joined;
@@ -249,7 +249,13 @@ telemetryModel, telemetryProvider) {
249
249
  const normalizedProjectRoot = normalizeMapKey(path.resolve(projectRoot ?? cwd));
250
250
  const normalizedCwd = normalizeMapKey(resolveLanguageRootForFile(absoluteFilePath, cwd));
251
251
  const normalizedFilePath = normalizeMapKey(absoluteFilePath);
252
- const kind = detectFileKind(normalizedFilePath);
252
+ // `resolveRunnerPath` intentionally normalizes map keys, which folds a
253
+ // literal POSIX backslash into a separator. Preserve that one proven
254
+ // classifier distinction while still resolving relative inputs from `cwd`.
255
+ const kindFilePath = !isWindowsPath(filePath) && filePath.includes("\\")
256
+ ? path.resolve(cwd, filePath)
257
+ : absoluteFilePath;
258
+ const kind = detectFileKind(kindFilePath);
253
259
  const contentPrefix = readFilePrefix(normalizedFilePath);
254
260
  const fileRole = detectFileRole(normalizedFilePath, contentPrefix);
255
261
  // Captured once here so the generated short-circuit below can emit a
@@ -282,14 +288,17 @@ telemetryModel, telemetryProvider) {
282
288
  writeIndex,
283
289
  telemetryModel,
284
290
  telemetryProvider,
291
+ toolCwdMemo: {},
285
292
  async hasTool(command) {
286
293
  return checkToolAvailability(command, facts);
287
294
  },
288
- log(message) {
295
+ log(message, level) {
289
296
  // #1333: pi owns the terminal — a runner advisory must never be a raw
290
297
  // write. Every DispatchContext.log line lands in extension.log instead.
291
298
  logExtension({
292
299
  subsystem: "dispatch",
300
+ // exactOptionalPropertyTypes: an omitted level must stay omitted.
301
+ ...(level !== undefined && { level }),
293
302
  message,
294
303
  metadata: { filePath: normalizedFilePath, kind },
295
304
  });
@@ -446,31 +455,49 @@ function buildCoverageNotice(ctx, runnerLatencies) {
446
455
  ...new Set(relevant.flatMap((r) => r.unconfirmedServerIds ?? [])),
447
456
  ];
448
457
  if (unconfirmedServerIds.length > 0) {
449
- // The marker describes this exact silent-scanner set. A scanner can
450
- // recover while another goes dark on the same file, so the set belongs
451
- // in the session dedupe identity rather than only kind and path.
458
+ const deferredIds = new Set(relevant.flatMap((r) => r.deferredServerIds ?? []));
459
+ const markerFor = (ids) => {
460
+ const shown = ids.slice(0, 4);
461
+ const remainder = ids.length - shown.length;
462
+ return `${shown.join(", ")}${remainder > 0 ? ` +${remainder}` : ""}`;
463
+ };
464
+ const deferredServerIds = unconfirmedServerIds.filter((id) => deferredIds.has(id));
465
+ const silentServerIds = unconfirmedServerIds.filter((id) => !deferredIds.has(id));
466
+ // The marker describes this exact scanner set. A scanner can recover
467
+ // while another goes dark on the same file, so the set belongs in the
468
+ // session dedupe identity rather than only kind and path.
452
469
  // #2016: these are SCANNER IDS, not filesystem paths. `normalizeMapKey`
453
470
  // would realpath each one; on Windows that fails, falls through to
454
471
  // `resolveNonExisting`, and resolves the id against the CURRENT process
455
472
  // cwd, so the dedupe key differed by platform and by cwd (the #2219
456
473
  // non-path-sentinel class). The cheap syntactic fold is what this
457
- // session-scoped dedupe key actually needs.
458
- const silentScannerSet = [...new Set(unconfirmedServerIds)]
474
+ // session-scoped dedupe key actually needs. Code-unit comparator: the
475
+ // sorted set is a KEY, so ordering must be deterministic across locales
476
+ // — localeCompare is not.
477
+ const dedupeSet = (ids) => [...new Set(ids)]
459
478
  .map(normalizeEphemeralMapKey)
460
- // Code-unit comparator: the sorted set is a dedupe KEY, so ordering
461
- // must be deterministic across locales — localeCompare is not.
462
479
  .sort((a, b) => Number(a > b) - Number(a < b))
463
480
  .join(",");
464
- const onceKey = `${ctx.kind}:${ctx.filePath}:${silentScannerSet}`;
481
+ // #2810 round 4: the PARTITION is part of the identity, not just the
482
+ // scanner set. The same scanner can be silent on one edit and marked for
483
+ // late delivery on the next; keying on the unconfirmed set alone showed
484
+ // the session whichever message came first and suppressed the other, so a
485
+ // scanner that recovered a delivery path (or lost one) kept the stale
486
+ // wording for the rest of the session.
487
+ const onceKey = `${ctx.kind}:${ctx.filePath}:${dedupeSet(silentServerIds)}|${dedupeSet(deferredServerIds)}`;
465
488
  if (coverageNoticeSeen.has(onceKey))
466
489
  return undefined;
467
490
  coverageNoticeSeen.add(onceKey);
468
- const shown = unconfirmedServerIds.slice(0, 4);
469
- const remainder = unconfirmedServerIds.length - shown.length;
470
- const marker = `${shown.join(", ")}${remainder > 0 ? ` +${remainder}` : ""}`;
491
+ const coverageParts = [];
492
+ if (deferredServerIds.length > 0) {
493
+ coverageParts.push(`coverage: ${markerFor(deferredServerIds)} deferred — diagnostics are incomplete; findings arrive at turn end if the scan lands.`);
494
+ }
495
+ if (silentServerIds.length > 0) {
496
+ coverageParts.push(`coverage: ${markerFor(silentServerIds)} silent — diagnostics are incomplete (not a clean result).`);
497
+ }
471
498
  return {
472
499
  id: `coverage-partial:${ctx.kind}:${path.basename(ctx.filePath)}`,
473
- message: `coverage: ${marker} silent — diagnostics are incomplete (not a clean result).`,
500
+ message: coverageParts.join("\n"),
474
501
  filePath: ctx.filePath,
475
502
  severity: "warning",
476
503
  semantic: "warning",
@@ -837,6 +864,9 @@ async function runGroup(ctx, group, registry, onRunnerResult) {
837
864
  ...(result.unconfirmedServerIds !== undefined && {
838
865
  unconfirmedServerIds: result.unconfirmedServerIds,
839
866
  }),
867
+ ...(result.deferredServerIds !== undefined && {
868
+ deferredServerIds: result.deferredServerIds,
869
+ }),
840
870
  });
841
871
  logLatency({
842
872
  type: "runner",
@@ -135,7 +135,7 @@ function isCallPassThrough(stmt, paramNames) {
135
135
  }
136
136
  return { pass: true, target: (expr.children ?? [])[0]?.text };
137
137
  }
138
- function calcCyclomaticComplexity(body) {
138
+ export function calcCyclomaticComplexity(body) {
139
139
  let cc = 1;
140
140
  walk(body, (node) => {
141
141
  if (COMPLEXITY_TYPES.has(node.type)) {
@@ -1,6 +1,10 @@
1
1
  import { logLatency } from "../../latency-logger.js";
2
2
  import { isJstsFactFile } from "../../file-kinds.js";
3
3
  import { childrenOfType, firstChildOfType, withFactTree, walk, } from "./tree-sitter-facts.js";
4
+ /** Whether the import-facts provider applies to a source path. */
5
+ export function importFactsApplyTo(filePath) {
6
+ return isJstsFactFile(filePath);
7
+ }
4
8
  function stripQuotes(raw) {
5
9
  return raw.replace(/^["'`]+|["'`]+$/g, "");
6
10
  }
@@ -69,7 +73,7 @@ export const importFactProvider = {
69
73
  provides: ["file.imports", "file.reexports", "file.importFactsCoverage"],
70
74
  requires: ["file.content"],
71
75
  appliesTo(ctx) {
72
- return isJstsFactFile(ctx.filePath);
76
+ return importFactsApplyTo(ctx.filePath);
73
77
  },
74
78
  async run(ctx, store) {
75
79
  const content = store.getFileFact(ctx.filePath, "file.content");
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The ONE filter stack a findings surface applies before a diagnostic reaches
3
+ * the agent: inline `pi-lens-ignore` comments (#442), stored dispositions
4
+ * (#690 — false-positive/suppress/defer), and the project's `.pi-lens.json`
5
+ * `rules.<id>.disable`/`select` policy, in that order.
6
+ *
7
+ * The order is load-bearing and is the one the per-edit dispatcher established:
8
+ * inline suppression first (the user's in-file intent), then the stored marks,
9
+ * then the project policy — so the policy filter sees the same set the per-edit
10
+ * path produces, with no double counting and no policy-rejected leftovers.
11
+ *
12
+ * #3088: these were three separate calls open-coded in `lens-diagnostics.ts`'s
13
+ * `mode=full` merge and nothing at all on the `lens_diagnostics source=lsp` /
14
+ * `lsp_diagnostics` probe lane — so a finding an agent had marked
15
+ * `false-positive` was hidden in `mode=delta`/`mode=full` and re-reported on
16
+ * the lane `skills/pi-lens-lsp-navigation` steers agents to as PRIMARY. Both
17
+ * lanes call this module now: `applyFindingPolicy` for surfaces that already
18
+ * hold dispatch `Diagnostic`s, `applyLspFindingPolicy` for the probe lane,
19
+ * which holds raw `LSPDiagnostic`s.
20
+ */
21
+ import { applyDispositions, hasAnyDispositionMarks, } from "../diagnostic-dispositions.js";
22
+ import { loadPiLensProjectConfig } from "../project-lens-config.js";
23
+ import { retagAuxiliaryDiagnostics } from "./auxiliary-lsp.js";
24
+ import { applyInlineSuppressions } from "./inline-suppressions.js";
25
+ import { applyRulePolicy, rulePolicyMapFromConfig } from "./rule-policy.js";
26
+ import { convertLspDiagnostics } from "./utils/lsp-diagnostics.js";
27
+ /**
28
+ * Inline suppression → stored dispositions → project rule policy, over
29
+ * diagnostics that already carry dispatch identity (`tool`/`rule`/`line`).
30
+ */
31
+ export function applyFindingPolicy(diagnostics, options) {
32
+ const inlineKept = applyInlineSuppressions(diagnostics, options.content);
33
+ const disposed = filterDisposed(inlineKept, options);
34
+ return { kept: applyRulePolicy(disposed, options.policyMap), inlineKept };
35
+ }
36
+ /** The project's `.pi-lens.json` `rules.<id>.disable`/`select` policy map.
37
+ * One derivation for every surface that filters by it — `loadPiLensProjectConfig`
38
+ * is mtime-cached, so repeated calls in one sweep cost one stat. */
39
+ export function loadProjectRulePolicyMap(cwd) {
40
+ return rulePolicyMapFromConfig(loadPiLensProjectConfig(cwd).rules);
41
+ }
42
+ /**
43
+ * Disposition filter over the candidate identities a mark can carry.
44
+ *
45
+ * With no `identities` provider this is `applyDispositions` on the diagnostics
46
+ * themselves — one anchor pair per diagnostic, the behavior every existing
47
+ * caller has. With one, the SAME `applyDispositions` runs over the flattened
48
+ * candidate list — no second strict/weak implementation to drift away from it
49
+ * (#1617's single-source rule) — and a diagnostic drops when ANY of its
50
+ * candidate identities was disposed.
51
+ */
52
+ function filterDisposed(diagnostics, options) {
53
+ const identities = options.identities;
54
+ if (!identities) {
55
+ return applyDispositions(diagnostics, options.cwd, options.filePath, options.content);
56
+ }
57
+ // Same zero-I/O hoist the cache-only modes use (#1625 F2): with no marks at
58
+ // all there is nothing a candidate expansion could match, so the probe lane
59
+ // pays no per-finding allocation for the overwhelmingly common empty store.
60
+ if (!diagnostics.length || !hasAnyDispositionMarks(options.cwd)) {
61
+ return diagnostics;
62
+ }
63
+ const expanded = diagnostics.flatMap((diagnostic) => identities(diagnostic).map((identity) => ({
64
+ diagnostic,
65
+ candidate: {
66
+ ...identity,
67
+ message: diagnostic.message,
68
+ ...(diagnostic.line !== undefined && { line: diagnostic.line }),
69
+ ...(diagnostic.semantic !== undefined && {
70
+ semantic: diagnostic.semantic,
71
+ }),
72
+ },
73
+ })));
74
+ const survivors = new Set(applyDispositions(expanded.map((entry) => entry.candidate), options.cwd, options.filePath, options.content));
75
+ const disposed = new Set();
76
+ for (const entry of expanded) {
77
+ if (!survivors.has(entry.candidate))
78
+ disposed.add(entry.diagnostic);
79
+ }
80
+ return disposed.size === 0
81
+ ? diagnostics
82
+ : diagnostics.filter((diagnostic) => !disposed.has(diagnostic));
83
+ }
84
+ /**
85
+ * #3088 / AGENTS.md shape 26: the identity spellings a mark against a probe
86
+ * finding can carry, derived from the surfaces that RENDER it rather than
87
+ * guessed:
88
+ *
89
+ * - `convertLspDiagnostics` + `retagAuxiliaryDiagnostics` — the canonical
90
+ * dispatch identity (`tool: "lsp"`, or the auxiliary's own id, and
91
+ * `rule: "<source>:<code>"`). This is what the widget footer, `mode=delta`
92
+ * and `mode=full` render, so it is the spelling of a mark made anywhere
93
+ * else in the session.
94
+ * - `formatDiag` in `tools/lsp-diagnostics.ts` — the probe's OWN text render
95
+ * is `[<source>] (<code>)`, so a mark made from THIS lane's output carries
96
+ * `tool: "<source>"`, `rule: "<code>"`.
97
+ *
98
+ * plus, for each, the form with `tool` omitted: `lens_diagnostic_mark`'s
99
+ * `tool` parameter is optional and #3088's own reproduction omits it. A
100
+ * single-spelling filter honors a mark made on one surface while re-reporting
101
+ * the same finding when the mark was made on the other, which is exactly the
102
+ * non-convergence #3088 reported.
103
+ */
104
+ function probeIdentities(canonical, raw) {
105
+ const rendered = [
106
+ [canonical.tool, canonical.rule],
107
+ ];
108
+ if (raw !== undefined &&
109
+ (raw.source !== undefined || raw.code !== undefined)) {
110
+ rendered.push([
111
+ raw.source,
112
+ raw.code === undefined ? undefined : String(raw.code),
113
+ ]);
114
+ }
115
+ return expandRenderedIdentities(rendered);
116
+ }
117
+ /**
118
+ * #3102: the identity spellings a mark against a surface that renders a
119
+ * finding's RULE but never its TOOL can carry — the turn-end late-auxiliary
120
+ * advisory (`<file>:<line>:<col> [<rule>] <message>`) and the cold-neighbour
121
+ * cascade run (`line N, col M rule=<rule>: <message>`). Both print the
122
+ * canonical dispatch `rule`, so a mark made ELSEWHERE (the widget, `mode=full`)
123
+ * carries the full `(tool, rule)` pair while a mark made from the surface's own
124
+ * output has no tool to pass on — and `lens_diagnostic_mark`'s `tool` parameter
125
+ * is optional. Honoring only one of the two spellings is exactly the
126
+ * non-convergence #3088 reported, so both go through the SAME expansion
127
+ * `probeIdentities` uses.
128
+ */
129
+ export function renderedRuleIdentities(diagnostic) {
130
+ return expandRenderedIdentities([[diagnostic.tool, diagnostic.rule]]);
131
+ }
132
+ /** Every rendered `(tool, rule)` spelling plus the `tool`-omitted form of
133
+ * each, de-duplicated, in order. One derivation for both callers. */
134
+ function expandRenderedIdentities(rendered) {
135
+ const out = [];
136
+ const seen = new Set();
137
+ for (const [tool, rule] of rendered) {
138
+ // Both spellings also in the `tool`-omitted form the mark tool admits.
139
+ for (const candidateTool of [tool, undefined]) {
140
+ const key = `${candidateTool ?? ""}\u0000${rule ?? ""}`;
141
+ if (seen.has(key))
142
+ continue;
143
+ seen.add(key);
144
+ out.push({
145
+ ...(candidateTool !== undefined && { tool: candidateTool }),
146
+ ...(rule !== undefined && { rule }),
147
+ });
148
+ }
149
+ }
150
+ return out;
151
+ }
152
+ /* No drop COUNT is returned here on purpose (round 2 F1): the number an agent
153
+ * is shown has to be scoped to that caller's severity threshold, and this
154
+ * module must not learn about a threshold its other caller (`mode=full`) does
155
+ * not have. `kept` versus the input is all a caller needs to derive its own. */
156
+ /**
157
+ * The probe lane's entry point: the same stack, over RAW LSP diagnostics.
158
+ *
159
+ * Identity comes from the two shared converters the widget footer already runs
160
+ * on this very path (`convertLspDiagnostics` + `retagAuxiliaryDiagnostics`),
161
+ * never a third derivation — a second copy of the conversion giving the same
162
+ * finding a different `rule`, and orphaning every mark against it, is the #692
163
+ * defect itself.
164
+ *
165
+ * `retagAuxiliaryDiagnostics` is used for its in-place re-tag only; its own
166
+ * drop set (native aux comments, `skipTestFiles`) is deliberately ignored here
167
+ * because `applyAuxiliarySuppressions` already applied exactly that filter to
168
+ * these diagnostics at collection time — re-dropping would double-apply a
169
+ * filter this route owns upstream.
170
+ */
171
+ export function applyLspFindingPolicy(diagnostics, options) {
172
+ if (!diagnostics.length)
173
+ return { kept: diagnostics, inlineKept: diagnostics };
174
+ const content = options.content ?? "";
175
+ // `convertLspDiagnostics` drops entries with no start line, which would
176
+ // break the 1:1 index alignment `retagAuxiliaryDiagnostics` and the map-back
177
+ // below depend on. Partition first so the converted array IS aligned with
178
+ // `anchored`; a line-less entry passes straight through (it carries no line
179
+ // for an anchor to bind to, and dropping it would silently lose a finding
180
+ // this filter has no claim over).
181
+ const anchored = [];
182
+ for (const diagnostic of diagnostics) {
183
+ if (diagnostic.range?.start?.line !== undefined)
184
+ anchored.push(diagnostic);
185
+ }
186
+ if (!anchored.length)
187
+ return { kept: diagnostics, inlineKept: diagnostics };
188
+ const converted = convertLspDiagnostics(anchored, options.filePath);
189
+ retagAuxiliaryDiagnostics(converted, anchored, content, {
190
+ cwd: options.cwd,
191
+ fileRole: options.fileRole,
192
+ });
193
+ // `convertLspDiagnostics` maps its (pre-filtered) input 1:1, so `converted[i]`
194
+ // belongs to `anchored[i]`. Pair them up front and work from the pairing: a
195
+ // converted entry with no counterpart is simply never paired, so it can never
196
+ // be dropped — fail open, never hide a finding this filter could not identify.
197
+ const rawByConverted = new Map();
198
+ for (const [index, diagnostic] of converted.entries()) {
199
+ const raw = anchored[index];
200
+ if (raw !== undefined)
201
+ rawByConverted.set(diagnostic, raw);
202
+ }
203
+ const { kept, inlineKept } = applyFindingPolicy(converted, {
204
+ cwd: options.cwd,
205
+ filePath: options.filePath,
206
+ content,
207
+ policyMap: options.policyMap,
208
+ identities: (diagnostic) => probeIdentities(diagnostic, rawByConverted.get(diagnostic)),
209
+ });
210
+ const survivorsOf = (survivors) => {
211
+ if (survivors.length === converted.length)
212
+ return diagnostics;
213
+ const keptConverted = new Set(survivors);
214
+ const dropped = new Set();
215
+ for (const [diagnostic, raw] of rawByConverted) {
216
+ if (!keptConverted.has(diagnostic))
217
+ dropped.add(raw);
218
+ }
219
+ return diagnostics.filter((d) => !dropped.has(d));
220
+ };
221
+ return { kept: survivorsOf(kept), inlineKept: survivorsOf(inlineKept) };
222
+ }