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
@@ -6,13 +6,17 @@
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
- import { Type } from "../clients/deps/typebox.js";
10
9
  import { getProjectIgnoreMatcher, isExcludedDirName, } from "../clients/file-utils.js";
11
10
  import { getLSPService, groupFilesByPrimaryServer, runPerServerGroups, } from "../clients/lsp/index.js";
12
11
  import { buildScopeKey, createWorkspaceDiagnosticsCacheContext, } from "../clients/lsp/workspace-diagnostics-cache.js";
13
12
  import { getServersForFileWithConfig, primaryServerId, } from "../clients/lsp/config.js";
13
+ import { mapWithConcurrency } from "../clients/map-with-concurrency.js";
14
14
  import { combineAbortSignals, withDeadline, } from "../clients/deadline-utils.js";
15
15
  import { applyAuxiliarySuppressions, retagAuxiliaryDiagnostics, } from "../clients/dispatch/auxiliary-lsp.js";
16
+ import { applyLspFindingPolicy, loadProjectRulePolicyMap, } from "../clients/dispatch/finding-policy.js";
17
+ import { hasStrictDispositionMarks } from "../clients/diagnostic-dispositions.js";
18
+ import { recordDegradationOnce } from "../clients/degradation-ledger.js";
19
+ import { logLatency } from "../clients/latency-logger.js";
16
20
  import { detectFileRole } from "../clients/file-role.js";
17
21
  import { hashDiagnosticContent, touchCompletedConfirmationPolicy, touchCoverageGap, } from "../clients/lsp/diagnostic-binding.js";
18
22
  import { classifyCascadeWaitTier } from "../clients/lsp/wait-policy/index.js";
@@ -20,23 +24,26 @@ import { attemptTsserverSyncDiagnostics } from "../clients/lsp/tsserver-sync.js"
20
24
  import { convertLspDiagnostics } from "../clients/dispatch/utils/lsp-diagnostics.js";
21
25
  import { demoteInferredProjectDiagnostics } from "../clients/lsp/inferred-project.js";
22
26
  import { isBlocking, reconcileScanDiagnostics, } from "../clients/widget-state.js";
23
- import { baseName, compactRenderResult } from "./render-compact.js";
24
- import { makeProgressReporter, scanningSummaryLine } from "./scan-progress.js";
27
+ import { makeProgressReporter } from "./scan-progress.js";
25
28
  import { isWarmAttached, tryWarmAttachedDiagnostics, } from "../clients/warm-attach.js";
26
29
  import { extensionsForLanguage, SCAN_LANGUAGE_PRIORITY, } from "../clients/language-registry.js";
27
30
  const MAX_FILES = 100;
28
- const MAX_BATCH_FILES = 100;
31
+ export const MAX_BATCH_FILES = 100;
29
32
  const MAX_DIAGNOSTICS = 200;
30
33
  const DEFAULT_BATCH_CONCURRENCY = 8;
31
34
  const MAX_BATCH_CONCURRENCY = 16;
32
35
  const DEFAULT_BATCH_FILE_DEADLINE_MS = 15_000;
33
36
  // LSP severities: 1=Error, 2=Warning, 3=Information, 4=Hint
34
- const SEVERITY_NAMES = {
37
+ export const SEVERITY_NAMES = {
35
38
  1: "error",
36
39
  2: "warning",
37
40
  3: "information",
38
41
  4: "hint",
39
42
  };
43
+ export const LSP_SEVERITY_FILTERS = [
44
+ ...Object.values(SEVERITY_NAMES),
45
+ "all",
46
+ ];
40
47
  function batchFileDeadlineMs() {
41
48
  const raw = Number(process.env.PI_LENS_LSP_BATCH_FILE_MS);
42
49
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_BATCH_FILE_DEADLINE_MS;
@@ -98,46 +105,6 @@ function boundedPositiveInt(value, fallback, min, max) {
98
105
  * for THIS tool's batch/directory sweep the same way #667 fixed it for the
99
106
  * workspace-diagnostics sweep.
100
107
  */
101
- async function mapWithConcurrency(items, concurrency, mapper, lspService, signal, onProgress) {
102
- const results = [];
103
- let completed = 0;
104
- // Multiple original indices can map to the same file path (duplicate
105
- // entries in an explicit `paths` batch) — track them as a per-file queue
106
- // so each occurrence still lands in its own original slot.
107
- const pendingIndices = new Map();
108
- items.forEach((item, index) => {
109
- const queue = pendingIndices.get(item);
110
- if (queue)
111
- queue.push(index);
112
- else
113
- pendingIndices.set(item, [index]);
114
- });
115
- const groups = groupFilesByPrimaryServer(items);
116
- await runPerServerGroups(groups, concurrency, async (group) => {
117
- if (signal?.aborted)
118
- return;
119
- const first = group.files[0];
120
- if (first &&
121
- lspService &&
122
- !isWarmAttached() &&
123
- typeof lspService.ensureWarmForSweep === "function") {
124
- await lspService.ensureWarmForSweep(first, { signal });
125
- if (signal?.aborted)
126
- return;
127
- }
128
- for (const item of group.files) {
129
- // Honor cancellation (Escape / turn abort): stop pulling new items
130
- // rather than grind the whole batch. Completed entries are returned.
131
- if (signal?.aborted)
132
- return;
133
- const index = pendingIndices.get(item).shift();
134
- results[index] = await mapper(item, index);
135
- completed += 1;
136
- onProgress?.(completed, items.length);
137
- }
138
- }, signal);
139
- return results;
140
- }
141
108
  /**
142
109
  * Project-ignore predicate rooted at `root`, fail-open. Lets a directory scan
143
110
  * honor the user's `.pi-lens.json` / `.gitignore` patterns — not just the
@@ -216,102 +183,8 @@ nextWriteIndex,
216
183
  // injecting "Unresolved from this turn" for three more turns after answering
217
184
  // "confirmed clean" for the same file. index.ts injects
218
185
  // `runtime.retireInlineBlockerOnConfirmedClean`. Optional/undefined in tests.
219
- onConfirmedNoBlockers) {
186
+ onConfirmedNoBlockers, getService = getLSPService) {
220
187
  return {
221
- name: "lsp_diagnostics",
222
- label: "LSP Diagnostics",
223
- description: "Get errors, warnings, and hints from language servers for a file or directory. " +
224
- "Use BEFORE running builds to proactively check for issues. " +
225
- "Works on directories by auto-detecting file extensions and scanning all matching files.",
226
- promptSnippet: "Get LSP diagnostics for a file or directory (use before builds)",
227
- renderResult: compactRenderResult(({ details, args, isError, text }) => {
228
- // Streaming progress partials render the live bar (see scanningSummaryLine).
229
- const scanning = scanningSummaryLine(details, text);
230
- if (scanning)
231
- return scanning;
232
- if (isError) {
233
- return `lsp_diagnostics — ${text.split("\n")[0] ?? "error"}`;
234
- }
235
- const count = details?.totalDiagnostics ?? details?.diagnostics?.length ?? 0;
236
- const target = baseName(details?.filePath ?? args.path) || "workspace";
237
- const files = details?.filesChecked ?? details?.filesScanned;
238
- const scope = typeof files === "number" && files > 1
239
- ? ` across ${files} files`
240
- : target
241
- ? ` ${target}`
242
- : "";
243
- const noun = count === 1 ? "diagnostic" : "diagnostics";
244
- // #533: a batch/directory result with any unconfirmed files must NEVER
245
- // compact-render as a bare "N diagnostics" — that erases the fact some
246
- // files' clean status was never actually confirmed by the server.
247
- const unconfirmedFiles = details?.unconfirmedFiles ?? 0;
248
- const navigationOnlyFiles = details?.navigationOnlyFiles ?? 0;
249
- if (navigationOnlyFiles > 0) {
250
- return `lsp_diagnostics${scope} — ${count} ${noun} · ${details?.cleanFiles ?? 0} clean · ${navigationOnlyFiles} navigation-only`;
251
- }
252
- if (unconfirmedFiles > 0) {
253
- const cleanFiles = details?.cleanFiles ?? 0;
254
- const timedOutFiles = details?.timedOutFiles ?? 0;
255
- const suffix = timedOutFiles > 0 ? ` (${timedOutFiles} timed out)` : "";
256
- return `lsp_diagnostics${scope} — ${count} ${noun} · ${cleanFiles} clean · ${unconfirmedFiles} unconfirmed${suffix}`;
257
- }
258
- const outcomeCounts = details?.outcomeCounts;
259
- const notConfirmed = outcomeCounts
260
- ? (outcomeCounts.inconclusive ?? 0) +
261
- (outcomeCounts.unavailable ?? 0) +
262
- (outcomeCounts.unsupported ?? 0) +
263
- (outcomeCounts.failed ?? 0)
264
- : 0;
265
- if (notConfirmed > 0) {
266
- return `lsp_diagnostics${scope} — ${count} ${noun} · ${notConfirmed} checks not confirmed`;
267
- }
268
- if ((details?.incompleteFiles ?? 0) > 0) {
269
- return `lsp_diagnostics${scope} — incomplete (${details?.incompleteFiles} files not confirmed)`;
270
- }
271
- // Single-file mode: 0 diagnostics from an unconfirmed result — either a
272
- // silent-on-clean server or (#570) a timed-out check — is not a clean
273
- // render either.
274
- if (count === 0 && details?.unconfirmed) {
275
- return details?.timedOut
276
- ? `lsp_diagnostics${scope} — timed out (result may be incomplete)`
277
- : `lsp_diagnostics${scope} — unconfirmed (server cannot confirm clean)`;
278
- }
279
- return `lsp_diagnostics${scope} — ${count} ${noun}`;
280
- }),
281
- parameters: Type.Object({
282
- path: Type.Optional(Type.String({
283
- description: "File or directory path to check. For directories, all matching source files are scanned.",
284
- })),
285
- paths: Type.Optional(Type.Array(Type.String(), {
286
- minItems: 1,
287
- maxItems: MAX_BATCH_FILES,
288
- description: "Explicit files to check as a bounded-concurrency batch. When provided, path is ignored.",
289
- })),
290
- severity: Type.Optional(Type.String({
291
- enum: ["error", "warning", "information", "hint", "all"],
292
- description: "Filter by severity level (default: all)",
293
- })),
294
- concurrency: Type.Optional(Type.Number({
295
- description: "Batch/directory concurrency, in distinct LSP server groups run in parallel " +
296
- "(default 8, max 16) — not individual files. Files sharing one server " +
297
- "(e.g. a same-language batch) are always processed one at a time against " +
298
- "that server regardless of this value; this caps how many DIFFERENT " +
299
- "servers run concurrently.",
300
- })),
301
- waitMs: Type.Optional(Type.Number({
302
- description: "Optional per-file LSP wait budget for batch diagnostics. Uses server defaults when omitted.",
303
- })),
304
- serverScope: Type.Optional(Type.String({
305
- enum: ["primary", "all"],
306
- description: "'primary' (fast, low-noise): only the file's actual language " +
307
- "server (e.g. typescript) — for 'does this have real type " +
308
- "errors'. 'all' (default): also touches cross-cutting auxiliary " +
309
- "scanners (ast-grep, opengrep, zizmor, typos, marksman) attached " +
310
- "to this file, including findings for files not yet dispatched " +
311
- "this session. Primary confirmation is always reported " +
312
- "separately from auxiliary findings regardless of this setting.",
313
- })),
314
- }),
315
188
  async execute(_toolCallId, params, _signal, onUpdate, ctx) {
316
189
  // Escape aborts the turn via ctx.signal; honor both it and the tool-call
317
190
  // signal so a batch/directory scan cancels rather than grinding on.
@@ -327,7 +200,7 @@ onConfirmedNoBlockers) {
327
200
  ? Math.floor(typedParams.waitMs)
328
201
  : undefined;
329
202
  const serverScope = typedParams.serverScope === "primary" ? "primary" : "all";
330
- const lspService = getLSPService();
203
+ const lspService = getService();
331
204
  if (!lspService) {
332
205
  return {
333
206
  content: [
@@ -423,7 +296,17 @@ onConfirmedNoBlockers) {
423
296
  },
424
297
  };
425
298
  }
426
- async function collectDiagnosticsForFile(absPath, lspService, waitMs, serverScope = "all") {
299
+ async function collectDiagnosticsForFile(absPath, lspService,
300
+ /**
301
+ * #3041: project root the per-rule `ignores` globs (#965) are resolved
302
+ * against. ast-grep's LSP publishes per-document diagnostics without
303
+ * applying them, so this standalone `source=lsp` query has to — the same
304
+ * carve-out the NAPI runner applies on the same file. Positioned before the
305
+ * optional parameters so it is structurally REQUIRED: an optional
306
+ * `string | undefined` would silently disable the carve-out for whichever
307
+ * caller forgot to pass it.
308
+ */
309
+ scanRoot, waitMs, serverScope = "all") {
427
310
  let timedOut = false;
428
311
  let content;
429
312
  // `touchFile` is the authoritative collection boundary for every scope: it
@@ -450,9 +333,13 @@ async function collectDiagnosticsForFile(absPath, lspService, waitMs, serverScop
450
333
  const attached = await tryWarmAttachedDiagnostics(absPath, content, attachedBudgetMs, "sweep");
451
334
  if (attached?.available) {
452
335
  const scopedDiagnostics = serverScope === "primary"
453
- ? attached.response.diagnostics.filter((item) => item.source === primaryServerId(absPath))
336
+ ? attached.response.diagnostics.filter((item) => item.serverId === primaryServerId(absPath))
454
337
  : attached.response.diagnostics;
455
- const filtered = applyAuxiliarySuppressions(scopedDiagnostics, content, { fileRole: detectFileRole(absPath, content) });
338
+ const filtered = applyAuxiliarySuppressions(scopedDiagnostics, content, {
339
+ fileRole: detectFileRole(absPath, content),
340
+ filePath: absPath,
341
+ scanRoot,
342
+ });
456
343
  return {
457
344
  diagnostics: filtered,
458
345
  timedOut: false,
@@ -512,6 +399,8 @@ async function collectDiagnosticsForFile(absPath, lspService, waitMs, serverScop
512
399
  const filtered = content !== undefined
513
400
  ? applyAuxiliarySuppressions(diagnostics, content, {
514
401
  fileRole: detectFileRole(absPath, content),
402
+ filePath: absPath,
403
+ scanRoot,
515
404
  })
516
405
  : diagnostics;
517
406
  // #1095: surface the touch's content binding (only a touch that resolved
@@ -546,6 +435,7 @@ function diagnosticsToFileDiags(file, diagnostics) {
546
435
  severity: d.severity,
547
436
  message: d.message,
548
437
  source: d.source,
438
+ serverId: d.serverId,
549
439
  code: d.code,
550
440
  }));
551
441
  }
@@ -679,6 +569,97 @@ function demoteInferredForFile(file, diagnostics, cwd, lspService) {
679
569
  service: lspService,
680
570
  });
681
571
  }
572
+ /**
573
+ * #3088: the probe lane's stored-disposition / rule-policy / inline-suppression
574
+ * gate — the same `clients/dispatch/finding-policy.ts` stack `mode=full` and
575
+ * the per-edit dispatcher apply, called once per file at the position
576
+ * `demoteInferredForFile` already established: AFTER the confirmation verdict
577
+ * (which asks "did the server answer for this content" and must not be
578
+ * influenced by a policy drop) and BEFORE the rendered output, the severity
579
+ * filter and `reconcileWidgetFromLspResult`. One filtered set for all three, so
580
+ * the text, the counts and the widget footer cannot disagree — and so the
581
+ * footer reconcile can no longer re-arm a finding the mark-time
582
+ * `reconcileWidgetDisposition` demoted.
583
+ *
584
+ * Fails OPEN: a filter that throws returns the unfiltered set plus one bounded
585
+ * degradation record. A finding that stays visible is recoverable; one silently
586
+ * hidden by a broken filter is the harm this lane exists to prevent
587
+ * (AGENTS.md shape 48).
588
+ */
589
+ function applyProbeFindingPolicy(file, diagnostics, cwd, content,
590
+ /**
591
+ * Round 2 F1: the caller's severity threshold. The count reported to the
592
+ * agent must be scoped to the SAME threshold the rendered result is, or a
593
+ * `severity: "error"` probe of a repo carrying warning-level marks prints
594
+ * "suppressed by disposition: N … Not a clean verdict" on a result that IS
595
+ * clean at that threshold — the skill's own folder-check recipe would emit
596
+ * the banner on every check. The threshold is applied HERE, in the tool that
597
+ * owns both the render and the record, rather than in the three call sites
598
+ * (three copies of one subtraction) or in
599
+ * `clients/dispatch/finding-policy.ts`, which must stay threshold-free: its
600
+ * other caller (`mode=full`) has no severity axis at all.
601
+ */
602
+ severity) {
603
+ const started = Date.now();
604
+ try {
605
+ const result = applyLspFindingPolicy(diagnostics, {
606
+ cwd,
607
+ filePath: file,
608
+ content,
609
+ policyMap: loadProjectRulePolicyMap(cwd),
610
+ fileRole: detectFileRole(file, content),
611
+ });
612
+ const inScopeBefore = applySeverityFilter(diagnostics, severity).length;
613
+ const suppressed = inScopeBefore - applySeverityFilter(result.kept, severity).length;
614
+ if (suppressed > 0) {
615
+ // One record per FILE that actually dropped something the caller would
616
+ // otherwise have SEEN, never one per finding (AGENTS.md "bounded
617
+ // observability"). `total` is the in-scope population for the same
618
+ // reason the count is, so the record and the rendered line agree.
619
+ logLatency({
620
+ type: "phase",
621
+ toolName: "lsp_diagnostics",
622
+ filePath: file,
623
+ phase: "lsp_probe_disposition_filter",
624
+ durationMs: Date.now() - started,
625
+ metadata: {
626
+ suppressed,
627
+ total: inScopeBefore,
628
+ },
629
+ });
630
+ }
631
+ return { kept: result.kept, inlineKept: result.inlineKept, suppressed };
632
+ }
633
+ catch (err) {
634
+ recordDegradationOnce({
635
+ kind: "lsp-probe-finding-policy",
636
+ subject: cwd,
637
+ reason: err instanceof Error ? err.message : String(err),
638
+ });
639
+ return { kept: diagnostics, inlineKept: diagnostics, suppressed: 0 };
640
+ }
641
+ }
642
+ /**
643
+ * #3088 AC5/AC7: the content a cache REPLAY needs to evaluate STRICT
644
+ * (`false-positive`) disposition anchors, which hash the finding's own line.
645
+ * A cache hit means the file is unchanged since the entry was recorded, so this
646
+ * is the same content the recording touch read — but it is a disk read the
647
+ * replay path exists to avoid, so it is paid ONLY when the store actually holds
648
+ * a strict mark. With no marks (the overwhelmingly common case) the probe path
649
+ * adds zero I/O and the weak-anchored marks apply without content at all.
650
+ */
651
+ function replayContentForStrictMarks(file, cwd) {
652
+ if (!hasStrictDispositionMarks(cwd))
653
+ return undefined;
654
+ try {
655
+ return fs.readFileSync(file, "utf-8");
656
+ }
657
+ catch {
658
+ // Fail open — an unreadable file degrades to the weak-anchored filter
659
+ // rather than hiding or dropping anything.
660
+ return undefined;
661
+ }
662
+ }
682
663
  function reconcileWidgetFromLspResult(file, rawDiags, confirmation, writeIndex, cwd, content,
683
664
  // #1095: true when the result's content binding demonstrably mismatches disk
684
665
  // (boundToCurrentDisk === false). Such a result — even a NON-EMPTY one, which
@@ -704,7 +685,9 @@ observedAt) {
704
685
  scanOrigin: "lsp_diagnostics",
705
686
  });
706
687
  const retagged = retagAuxiliaryDiagnostics(diagnostics, rawDiags, content ?? "", { cwd, fileRole: detectFileRole(file, content) });
707
- reconcileScanDiagnostics(file, retagged, true, writeIndex, observedAt);
688
+ const reconciled = reconcileScanDiagnostics(file, retagged, true, writeIndex, observedAt);
689
+ if (!reconciled)
690
+ return { confirmed: false, blocking: true };
708
691
  // #1561: the blocking tally comes from the SAME `isBlocking` predicate the
709
692
  // footer counts with — no second severity rule for the blocker-retire
710
693
  // decision to drift away from.
@@ -759,7 +742,13 @@ onConfirmedNoBlockers) {
759
742
  // fresh touch does, so it gets the same demotion. Without this, replaying
760
743
  // yesterday's cached blocker would re-cement an orphan file as blocking
761
744
  // after mode=full had demoted it.
762
- const cachedDiags = await demoteInferredForFile(file, cached.diagnostics, cwd, lspService);
745
+ const demotedCached = await demoteInferredForFile(file, cached.diagnostics, cwd, lspService);
746
+ // #3088 AC5: a replay is served to the agent exactly like a fresh
747
+ // observation, so it passes the same filter. The cached set is already
748
+ // inline-suppression-clean (the record below stores `inlineKept`), so
749
+ // only the revocable store/config filters run here.
750
+ const cachedPolicy = applyProbeFindingPolicy(file, demotedCached, cwd, replayContentForStrictMarks(file, cwd), severity);
751
+ const cachedDiags = cachedPolicy.kept;
763
752
  const filteredDiags = applySeverityFilter(cachedDiags, severity);
764
753
  // #692: cached.diagnostics were already suppression-/skipTestFiles-
765
754
  // filtered at write time (this same code path); no file content was
@@ -780,10 +769,13 @@ onConfirmedNoBlockers) {
780
769
  diagnostics: diagnosticsToFileDiags(file, filteredDiags),
781
770
  confirmation,
782
771
  primaryServerId: primaryServerId(file),
772
+ ...(cachedPolicy.suppressed > 0 && {
773
+ dispositionSuppressed: cachedPolicy.suppressed,
774
+ }),
783
775
  };
784
776
  }
785
777
  }
786
- const { diagnostics: rawDiags, timedOut, confirmedByTouch, unconfirmedServerIds, diagnosticsUnsupportedServerIds, content: collectedContent, binding, skipReason, } = await collectDiagnosticsForFile(file, lspService, waitMs, serverScope);
778
+ const { diagnostics: rawDiags, timedOut, confirmedByTouch, unconfirmedServerIds, diagnosticsUnsupportedServerIds, content: collectedContent, binding, skipReason, } = await collectDiagnosticsForFile(file, lspService, cwd, waitMs, serverScope);
787
779
  const health = lspService.getDiagnosticsHealth?.(file);
788
780
  // #570: a timed-out priming check is never a confirmed "clean" — treat it
789
781
  // as unconfirmed without consulting the (unrelated) silent-tier
@@ -842,6 +834,9 @@ onConfirmedNoBlockers) {
842
834
  }
843
835
  // #1645 review F3: one demotion rule for every writer of the widget store.
844
836
  effectiveRawDiags = await demoteInferredForFile(file, effectiveRawDiags, cwd, lspService);
837
+ // #3088: one filter for the output, the counts and the footer reconcile.
838
+ const policy = applyProbeFindingPolicy(file, effectiveRawDiags, cwd, collectedContent, severity);
839
+ effectiveRawDiags = policy.kept;
845
840
  const filteredDiags = applySeverityFilter(effectiveRawDiags, severity);
846
841
  const verdict = reconcileWidgetFromLspResult(file, effectiveRawDiags, confirmation, writeIndex, cwd, collectedContent, boundMismatch);
847
842
  if (verdict.confirmed && !verdict.blocking) {
@@ -864,7 +859,14 @@ onConfirmedNoBlockers) {
864
859
  confirmation !== "unconfirmed" &&
865
860
  diagnosticsUnsupportedServerIds.length === 0 &&
866
861
  unconfirmedServerIds.length === 0) {
867
- cacheCtx.record(file, scopeKey, effectiveRawDiags, stat.mtimeMs, collectedContent !== undefined
862
+ // #3088: the entry records the INLINE-suppressed set, never the
863
+ // disposition/rule-policy-filtered one. An inline `pi-lens-ignore` comment
864
+ // cannot change without invalidating this content-keyed entry, so baking it
865
+ // in is safe and makes every later replay consistent with a fresh probe; a
866
+ // disposition mark or a `.pi-lens.json` rule can be revoked at any moment,
867
+ // so caching THEIR effect would keep a finding hidden after its mark was
868
+ // removed. The replay branch above re-applies those two on every serve.
869
+ cacheCtx.record(file, scopeKey, policy.inlineKept, stat.mtimeMs, collectedContent !== undefined
868
870
  ? hashDiagnosticContent(collectedContent)
869
871
  : undefined, stat.size);
870
872
  }
@@ -877,6 +879,9 @@ onConfirmedNoBlockers) {
877
879
  ...(skipReason !== undefined && { skipReason }),
878
880
  primaryServerId: primaryServerId(file),
879
881
  diagnosticsUnsupported: diagnosticsUnsupportedServerIds.length > 0,
882
+ ...(policy.suppressed > 0 && {
883
+ dispositionSuppressed: policy.suppressed,
884
+ }),
880
885
  };
881
886
  }
882
887
  async function runFileDiagnostics(absPath, severity, lspService, waitMs, nextWriteIndex, serverScope = "all", cwd = process.cwd(),
@@ -886,7 +891,7 @@ onConfirmedNoBlockers) {
886
891
  // Reserve the token before awaiting this file's LSP result. The direct-file
887
892
  // path performs its own confirmation/reconciliation below (#1198).
888
893
  const writeIndex = nextWriteIndex?.();
889
- const { diagnostics: rawDiags, timedOut, confirmedByTouch, unconfirmedServerIds, diagnosticsUnsupportedServerIds, content: collectedContent, binding, skipReason, } = await collectDiagnosticsForFile(absPath, lspService, waitMs, serverScope);
894
+ const { diagnostics: rawDiags, timedOut, confirmedByTouch, unconfirmedServerIds, diagnosticsUnsupportedServerIds, content: collectedContent, binding, skipReason, } = await collectDiagnosticsForFile(absPath, lspService, cwd, waitMs, serverScope);
890
895
  const lspHealth = lspService.getDiagnosticsHealth?.(absPath);
891
896
  const unavailable = lspUnavailableMessage(absPath, lspHealth);
892
897
  // #533: an empty result needs a confirmed/unconfirmed verdict — a push-only,
@@ -946,6 +951,9 @@ onConfirmedNoBlockers) {
946
951
  confirmation = "unconfirmed";
947
952
  // #1645 review F3: one demotion rule for every writer of the widget store.
948
953
  effectiveRawDiags = await demoteInferredForFile(absPath, effectiveRawDiags, cwd, lspService);
954
+ // #3088: one filter for the output, the counts and the footer reconcile.
955
+ const policy = applyProbeFindingPolicy(absPath, effectiveRawDiags, cwd, collectedContent, severity);
956
+ effectiveRawDiags = policy.kept;
949
957
  const filtered = applySeverityFilter(effectiveRawDiags, severity);
950
958
  const total = filtered.length;
951
959
  const truncated = total > MAX_DIAGNOSTICS;
@@ -965,8 +973,8 @@ onConfirmedNoBlockers) {
965
973
  });
966
974
  }
967
975
  const primaryId = primaryServerId(absPath);
968
- const primaryDiags = limited.filter((d) => d.source === primaryId);
969
- const auxiliaryDiags = limited.filter((d) => d.source !== primaryId);
976
+ const primaryDiags = limited.filter((d) => d.serverId === primaryId);
977
+ const auxiliaryDiags = limited.filter((d) => d.serverId !== primaryId);
970
978
  // Primary confirmation is always its own line, independent of how many
971
979
  // auxiliary findings exist — a wall of ast-grep/opengrep noise must never
972
980
  // bury whether the actual language server confirmed the file clean.
@@ -999,8 +1007,12 @@ onConfirmedNoBlockers) {
999
1007
  // this result does NOT speak for, so "no auxiliary findings" can never be read
1000
1008
  // as "the security scanners found nothing".
1001
1009
  const coverageLine = unconfirmedServerIds.length > 0
1002
- ? `Auxiliary coverage INCOMPLETE — ${[...unconfirmedServerIds].join(", ")} did not answer within the wait budget, so ${unconfirmedServerIds.length === 1 ? "its findings are" : "their findings are"} NOT included here. This is not a clean bill of health for ${unconfirmedServerIds.length === 1 ? "that scanner" : "those scanners"}; re-check after the next edit, or use waitMs to wait longer.`
1010
+ ? `Auxiliary coverage INCOMPLETE — ${[...unconfirmedServerIds].join(", ")} did not answer within the wait budget, so ${unconfirmedServerIds.length === 1 ? "its findings are" : "their findings are"} NOT included here. This is not a clean bill of health for ${unconfirmedServerIds.length === 1 ? "that scanner" : "those scanners"}; re-check after the next edit. waitMs can extend an ordinary wait, but it cannot override a session-demoted scanner.`
1003
1011
  : undefined;
1012
+ // #3088: the #1616 suppressed-bucket rule on this lane — a policy drop is
1013
+ // stated as a count, never left to read as "nothing was wrong here"
1014
+ // (AGENTS.md shape 10). Bounded: one line per call, whatever the count.
1015
+ const suppressedLine = dispositionSuppressedLine(policy.suppressed);
1004
1016
  let text;
1005
1017
  if (total === 0) {
1006
1018
  text = [
@@ -1008,6 +1020,7 @@ onConfirmedNoBlockers) {
1008
1020
  "",
1009
1021
  unavailable ?? "No auxiliary findings.",
1010
1022
  ...(coverageLine ? [coverageLine] : []),
1023
+ ...(suppressedLine ? [suppressedLine] : []),
1011
1024
  ].join("\n");
1012
1025
  }
1013
1026
  else {
@@ -1021,6 +1034,8 @@ onConfirmedNoBlockers) {
1021
1034
  }
1022
1035
  if (coverageLine)
1023
1036
  lines.push("", coverageLine);
1037
+ if (suppressedLine)
1038
+ lines.push("", suppressedLine);
1024
1039
  if (unavailable)
1025
1040
  lines.unshift(unavailable, "");
1026
1041
  if (truncated) {
@@ -1047,6 +1062,9 @@ onConfirmedNoBlockers) {
1047
1062
  code: d.code,
1048
1063
  })),
1049
1064
  totalDiagnostics: total,
1065
+ ...(policy.suppressed > 0 && {
1066
+ dispositionSuppressed: policy.suppressed,
1067
+ }),
1050
1068
  truncated,
1051
1069
  unconfirmed,
1052
1070
  timedOut: unconfirmed ? timedOut : undefined,
@@ -1172,32 +1190,67 @@ async function collectBatchDiagnostics(files, severity, lspService, options) {
1172
1190
  const resolvedCwd = options.cwd ?? process.cwd();
1173
1191
  const cacheCtx = createWorkspaceDiagnosticsCacheContext(resolvedCwd);
1174
1192
  const scopeKey = buildScopeKey(options.serverScope ?? "all");
1175
- const results = await mapWithConcurrency(files, options.concurrency, async (file) => {
1176
- const work = collectFileDiagnosticResult(file, severity, lspService, options.waitMs, options.nextWriteIndex, options.serverScope, cacheCtx, scopeKey, resolvedCwd, options.onConfirmedNoBlockers);
1177
- const bounded = withDeadline(work, {
1178
- ms: batchFileDeadlineMs(),
1179
- onTimeout: "undefined",
1180
- onReject: "undefined",
1181
- });
1182
- const result = options.signal
1183
- ? await Promise.race([
1184
- bounded,
1185
- new Promise((resolve) => {
1186
- if (options.signal?.aborted) {
1187
- resolve(undefined);
1188
- return;
1189
- }
1190
- options.signal?.addEventListener("abort", () => resolve(undefined), {
1191
- once: true,
1192
- });
1193
- }),
1194
- ])
1195
- : await bounded;
1196
- return (result ??
1197
- inconclusiveBatchResult(file, options.signal?.aborted
1198
- ? "Batch aborted before this file completed."
1199
- : `File check exceeded ${batchFileDeadlineMs()}ms.`));
1200
- }, lspService, options.signal, options.onProgress);
1193
+ const resultsByIndex = new Map();
1194
+ let completed = 0;
1195
+ const pendingIndices = new Map();
1196
+ files.forEach((file, index) => {
1197
+ const queue = pendingIndices.get(file);
1198
+ if (queue)
1199
+ queue.push(index);
1200
+ else
1201
+ pendingIndices.set(file, [index]);
1202
+ });
1203
+ const groups = groupFilesByPrimaryServer(files);
1204
+ await runPerServerGroups(groups, options.concurrency, async (group) => {
1205
+ if (options.signal?.aborted)
1206
+ return;
1207
+ const first = group.files[0];
1208
+ if (first &&
1209
+ lspService &&
1210
+ !isWarmAttached() &&
1211
+ typeof lspService.ensureWarmForSweep === "function") {
1212
+ await lspService.ensureWarmForSweep(first, {
1213
+ signal: options.signal,
1214
+ });
1215
+ if (options.signal?.aborted)
1216
+ return;
1217
+ }
1218
+ await mapWithConcurrency(group.files, 1, async (file) => {
1219
+ // Keep duplicate explicit paths in their original result slots.
1220
+ const index = pendingIndices.get(file).shift();
1221
+ const work = collectFileDiagnosticResult(file, severity, lspService, options.waitMs, options.nextWriteIndex, options.serverScope, cacheCtx, scopeKey, resolvedCwd, options.onConfirmedNoBlockers);
1222
+ const bounded = withDeadline(work, {
1223
+ ms: batchFileDeadlineMs(),
1224
+ onTimeout: "undefined",
1225
+ onReject: "undefined",
1226
+ });
1227
+ const result = options.signal
1228
+ ? await Promise.race([
1229
+ bounded,
1230
+ new Promise((resolve) => {
1231
+ if (options.signal?.aborted) {
1232
+ resolve(undefined);
1233
+ return;
1234
+ }
1235
+ options.signal?.addEventListener("abort", () => resolve(undefined), {
1236
+ once: true,
1237
+ });
1238
+ }),
1239
+ ])
1240
+ : await bounded;
1241
+ resultsByIndex.set(index, result ??
1242
+ inconclusiveBatchResult(file, options.signal?.aborted
1243
+ ? "Batch aborted before this file completed."
1244
+ : `File check exceeded ${batchFileDeadlineMs()}ms.`));
1245
+ completed += 1;
1246
+ options.onProgress?.(completed, files.length);
1247
+ }, options.signal);
1248
+ }, options.signal);
1249
+ // The retired local pool returned only slots whose mapper had run. Keep that
1250
+ // dense abort contract while restoring the original file order for callers.
1251
+ const results = [...resultsByIndex.entries()]
1252
+ .sort(([left], [right]) => left - right)
1253
+ .map(([, result]) => result);
1201
1254
  // Persist whatever was recorded, including a partial/aborted sweep's
1202
1255
  // already-completed files — same "don't throw away confirmed work"
1203
1256
  // posture as `runWorkspaceDiagnostics`.
@@ -1228,8 +1281,8 @@ async function collectBatchDiagnostics(files, severity, lspService, options) {
1228
1281
  // `clean`/`unconfirmed` above already reflect ONLY the primary server's
1229
1282
  // confirmation; this split does the same job for the listed diagnostics.
1230
1283
  const primaryIdByFile = new Map(results.map((r) => [r.file, r.primaryServerId]));
1231
- const primaryDisplay = display.filter((d) => d.source === primaryIdByFile.get(d.file));
1232
- const auxiliaryDisplay = display.filter((d) => d.source !== primaryIdByFile.get(d.file));
1284
+ const primaryDisplay = display.filter((d) => d.serverId === primaryIdByFile.get(d.file));
1285
+ const auxiliaryDisplay = display.filter((d) => d.serverId !== primaryIdByFile.get(d.file));
1233
1286
  return {
1234
1287
  results,
1235
1288
  fileErrors,
@@ -1256,6 +1309,9 @@ async function runBatchFileDiagnostics(absPaths, severity, lspService, options)
1256
1309
  }
1257
1310
  const { results, fileErrors, lspHealthWarnings, total, truncated, display, primaryDisplay, auxiliaryDisplay, clean, unconfirmed, timedOut, outcomeCounts, incompleteFiles, } = await collectBatchDiagnostics(absPaths, severity, lspService, options);
1258
1311
  const navigationOnly = results.filter((result) => result.diagnosticsUnsupported).length;
1312
+ // #3088: the batch's own suppressed bucket — one summed line, never one per
1313
+ // file or per finding.
1314
+ const dispositionSuppressed = results.reduce((sum, result) => sum + (result.dispositionSuppressed ?? 0), 0);
1259
1315
  const lines = [
1260
1316
  `Files checked: ${results.length}`,
1261
1317
  `Total diagnostics: ${total}`,
@@ -1310,6 +1366,9 @@ async function runBatchFileDiagnostics(absPaths, severity, lspService, options)
1310
1366
  lines.push("", `... (${total - MAX_DIAGNOSTICS} more diagnostics not shown)`);
1311
1367
  }
1312
1368
  }
1369
+ const suppressedLine = dispositionSuppressedLine(dispositionSuppressed);
1370
+ if (suppressedLine)
1371
+ lines.push("", suppressedLine);
1313
1372
  return {
1314
1373
  content: [{ type: "text", text: lines.join("\n") }],
1315
1374
  details: {
@@ -1323,6 +1382,7 @@ async function runBatchFileDiagnostics(absPaths, severity, lspService, options)
1323
1382
  primaryDiagnosticsCount: primaryDisplay.length,
1324
1383
  auxiliaryDiagnosticsCount: auxiliaryDisplay.length,
1325
1384
  totalDiagnostics: total,
1385
+ ...(dispositionSuppressed > 0 && { dispositionSuppressed }),
1326
1386
  truncated,
1327
1387
  cleanFiles: clean,
1328
1388
  unconfirmedFiles: unconfirmed,
@@ -1332,8 +1392,8 @@ async function runBatchFileDiagnostics(absPaths, severity, lspService, options)
1332
1392
  file: result.file,
1333
1393
  outcome: result.outcome,
1334
1394
  reason: result.inconclusiveReason ?? result.error ?? result.unavailable,
1335
- primaryDiagnosticsCount: result.diagnostics.filter((diagnostic) => diagnostic.source === result.primaryServerId).length,
1336
- auxiliaryDiagnosticsCount: result.diagnostics.filter((diagnostic) => diagnostic.source !== result.primaryServerId).length,
1395
+ primaryDiagnosticsCount: result.diagnostics.filter((diagnostic) => diagnostic.serverId === result.primaryServerId).length,
1396
+ auxiliaryDiagnosticsCount: result.diagnostics.filter((diagnostic) => diagnostic.serverId !== result.primaryServerId).length,
1337
1397
  })),
1338
1398
  outcomeCounts,
1339
1399
  incompleteFiles: incompleteFiles > 0 ? incompleteFiles : undefined,
@@ -1397,6 +1457,9 @@ async function runDirectoryDiagnostics(absPath, severity, lspService, options) {
1397
1457
  const filesToProcess = collectedFiles.slice(0, MAX_FILES);
1398
1458
  const { results, fileErrors, lspHealthWarnings, total, truncated, display, primaryDisplay, auxiliaryDisplay, clean, unconfirmed, timedOut, } = await collectBatchDiagnostics(filesToProcess, severity, lspService, options);
1399
1459
  const navigationOnly = results.filter((result) => result.diagnosticsUnsupported).length;
1460
+ // #3088: the directory scan's own suppressed bucket — one summed line.
1461
+ const dispositionSuppressed = results.reduce((sum, result) => sum + (result.dispositionSuppressed ?? 0), 0);
1462
+ const suppressedLine = dispositionSuppressedLine(dispositionSuppressed);
1400
1463
  let text;
1401
1464
  if (total === 0) {
1402
1465
  // #533/#570: an unconfirmed-containing directory result must never
@@ -1415,6 +1478,7 @@ async function runDirectoryDiagnostics(absPath, severity, lspService, options) {
1415
1478
  ...lspHealthWarnings.slice(0, 10),
1416
1479
  ]
1417
1480
  : [cleanLine]),
1481
+ ...(suppressedLine ? ["", suppressedLine] : []),
1418
1482
  ].join("\n");
1419
1483
  }
1420
1484
  else {
@@ -1454,6 +1518,8 @@ async function runDirectoryDiagnostics(absPath, severity, lspService, options) {
1454
1518
  if (truncated) {
1455
1519
  lines.push("", `... (${total - MAX_DIAGNOSTICS} more diagnostics not shown)`);
1456
1520
  }
1521
+ if (suppressedLine)
1522
+ lines.push("", suppressedLine);
1457
1523
  text = lines.join("\n");
1458
1524
  }
1459
1525
  return {
@@ -1477,6 +1543,7 @@ async function runDirectoryDiagnostics(absPath, severity, lspService, options) {
1477
1543
  primaryDiagnosticsCount: primaryDisplay.length,
1478
1544
  auxiliaryDiagnosticsCount: auxiliaryDisplay.length,
1479
1545
  totalDiagnostics: total,
1546
+ ...(dispositionSuppressed > 0 && { dispositionSuppressed }),
1480
1547
  truncated,
1481
1548
  cleanFiles: clean,
1482
1549
  unconfirmedFiles: unconfirmed,
@@ -1490,6 +1557,22 @@ async function runDirectoryDiagnostics(absPath, severity, lspService, options) {
1490
1557
  };
1491
1558
  }
1492
1559
  // ── helpers ─────────────────────────────────────────────────────────────
1560
+ /**
1561
+ * #3088: the one rendering of the probe lane's policy drop, shared by the
1562
+ * single-file, batch and directory renders so the three cannot word the same
1563
+ * fact differently. Returns `undefined` when nothing was dropped — the #1616
1564
+ * rule asks for a count when there IS one, not a "0 suppressed" line on every
1565
+ * clean probe.
1566
+ */
1567
+ function dispositionSuppressedLine(suppressed) {
1568
+ if (suppressed <= 0)
1569
+ return undefined;
1570
+ return (`suppressed by disposition: ${suppressed} finding(s) dropped from this ` +
1571
+ "result because they're marked false-positive/won't-fix, disabled by " +
1572
+ "`.pi-lens.json` rule policy, or suppressed by an inline `pi-lens-ignore` " +
1573
+ "comment. Not a clean verdict for those locations — they were found, " +
1574
+ "then intentionally hidden.");
1575
+ }
1493
1576
  function applySeverityFilter(diags, severity) {
1494
1577
  if (severity === "all")
1495
1578
  return diags;