pi-lens 4.2.0 → 4.3.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 (157) hide show
  1. package/CHANGELOG.md +543 -0
  2. package/README.md +45 -2
  3. package/dist/clients/actionable-warnings.js +44 -16
  4. package/dist/clients/advisory-provenance.js +187 -51
  5. package/dist/clients/cache-manager.js +64 -5
  6. package/dist/clients/cascade-format.js +24 -0
  7. package/dist/clients/child-unref.js +17 -8
  8. package/dist/clients/config-diagnostic-codes.js +2 -0
  9. package/dist/clients/config-locations.js +237 -9
  10. package/dist/clients/config-resolve.js +7 -1
  11. package/dist/clients/degradation-ledger.js +33 -3
  12. package/dist/clients/dependency-checker.js +87 -64
  13. package/dist/clients/diagnostic-dispositions.js +39 -3
  14. package/dist/clients/dispatch/finding-policy.js +118 -1
  15. package/dist/clients/dispatch/indent-detect.js +8 -2
  16. package/dist/clients/dispatch/inline-suppressions.js +23 -4
  17. package/dist/clients/dispatch/rule-ignores.js +14 -7
  18. package/dist/clients/dispatch/runners/actionlint.js +26 -28
  19. package/dist/clients/dispatch/runners/biome-check.js +38 -25
  20. package/dist/clients/dispatch/runners/cpp-check.js +32 -49
  21. package/dist/clients/dispatch/runners/credo.js +16 -14
  22. package/dist/clients/dispatch/runners/cue-vet.js +25 -7
  23. package/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  24. package/dist/clients/dispatch/runners/detekt.js +11 -4
  25. package/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  26. package/dist/clients/dispatch/runners/elixir-check.js +31 -46
  27. package/dist/clients/dispatch/runners/eslint.js +29 -46
  28. package/dist/clients/dispatch/runners/gleam-check.js +91 -11
  29. package/dist/clients/dispatch/runners/go-vet.js +16 -2
  30. package/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  31. package/dist/clients/dispatch/runners/hadolint.js +27 -15
  32. package/dist/clients/dispatch/runners/htmlhint.js +17 -8
  33. package/dist/clients/dispatch/runners/javac.js +31 -21
  34. package/dist/clients/dispatch/runners/ktlint.js +34 -60
  35. package/dist/clients/dispatch/runners/lsp.js +6 -5
  36. package/dist/clients/dispatch/runners/markdownlint.js +12 -5
  37. package/dist/clients/dispatch/runners/mypy.js +2 -1
  38. package/dist/clients/dispatch/runners/oxlint.js +22 -6
  39. package/dist/clients/dispatch/runners/php-lint.js +39 -16
  40. package/dist/clients/dispatch/runners/phpstan.js +1 -1
  41. package/dist/clients/dispatch/runners/rubocop.js +27 -18
  42. package/dist/clients/dispatch/runners/ruff.js +18 -19
  43. package/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  44. package/dist/clients/dispatch/runners/shellcheck.js +6 -2
  45. package/dist/clients/dispatch/runners/spellcheck.js +10 -3
  46. package/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  47. package/dist/clients/dispatch/runners/stylelint.js +10 -2
  48. package/dist/clients/dispatch/runners/swiftlint.js +11 -2
  49. package/dist/clients/dispatch/runners/taplo.js +12 -6
  50. package/dist/clients/dispatch/runners/tflint.js +30 -15
  51. package/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  52. package/dist/clients/dispatch/runners/trivy-config.js +8 -2
  53. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  54. package/dist/clients/dispatch/runners/vale.js +15 -3
  55. package/dist/clients/dispatch/runners/yamllint.js +12 -2
  56. package/dist/clients/dispatch/runners/zig-check.js +27 -39
  57. package/dist/clients/dispatch/suppress-writer.js +13 -3
  58. package/dist/clients/effective-config.js +3 -1
  59. package/dist/clients/extension-log.js +48 -1
  60. package/dist/clients/finding-delivery-gate.js +53 -23
  61. package/dist/clients/formatters.js +49 -11
  62. package/dist/clients/git-guard.js +247 -34
  63. package/dist/clients/gradle-ktfmt-style.js +121 -25
  64. package/dist/clients/inline-blocker-dispositions.js +186 -0
  65. package/dist/clients/install-diagnostics.js +19 -6
  66. package/dist/clients/installer/index.js +281 -32
  67. package/dist/clients/jscpd-client.js +8 -0
  68. package/dist/clients/latency-logger.js +8 -0
  69. package/dist/clients/lens-config.js +66 -8
  70. package/dist/clients/lsp/cascade-tier.js +3 -0
  71. package/dist/clients/lsp/client.js +139 -11
  72. package/dist/clients/lsp/config.js +9 -7
  73. package/dist/clients/lsp/content-limits.js +41 -0
  74. package/dist/clients/lsp/index.js +42 -8
  75. package/dist/clients/lsp/launch.js +23 -2
  76. package/dist/clients/lsp/server.js +106 -25
  77. package/dist/clients/lsp/sync-kind.js +14 -0
  78. package/dist/clients/lsp/wait-policy/strategies.js +105 -16
  79. package/dist/clients/mcp/analyze.js +9 -2
  80. package/dist/clients/mcp/ipc.js +232 -44
  81. package/dist/clients/mcp/review.js +53 -7
  82. package/dist/clients/opengrep-client.js +11 -6
  83. package/dist/clients/path-utils.js +51 -9
  84. package/dist/clients/persistent-reverify.js +271 -0
  85. package/dist/clients/pipeline.js +136 -67
  86. package/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  87. package/dist/clients/project-lens-config.js +11 -3
  88. package/dist/clients/review-graph/builder.js +34 -26
  89. package/dist/clients/review-graph/git-identity.js +16 -10
  90. package/dist/clients/ruff-client.js +31 -4
  91. package/dist/clients/runtime-coordinator.js +38 -4
  92. package/dist/clients/runtime-tool-call.js +16 -5
  93. package/dist/clients/runtime-tool-result.js +27 -12
  94. package/dist/clients/runtime-turn.js +571 -287
  95. package/dist/clients/safe-spawn.js +256 -65
  96. package/dist/clients/skills-resolver.js +38 -2
  97. package/dist/clients/spawn-output-cap.js +84 -1
  98. package/dist/clients/tree-sitter-client.js +119 -58
  99. package/dist/clients/turn-end/lane.js +51 -0
  100. package/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  101. package/dist/clients/turn-end/lanes/secrets.js +204 -0
  102. package/dist/clients/warm-attach.js +59 -0
  103. package/dist/clients/widget-state.js +294 -17
  104. package/dist/clients/workspace-topology.js +57 -3
  105. package/dist/clients/zizmor-config.js +26 -13
  106. package/dist/index.js +6342 -3995
  107. package/dist/mcp/analyze-cli.js +4 -0
  108. package/dist/mcp/server.js +33 -21
  109. package/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  110. package/dist/tools/lens-diagnostic-mark.js +14 -2
  111. package/dist/tools/lens-diagnostics.js +127 -33
  112. package/dist/tools/lsp-diagnostics.js +150 -12
  113. package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  114. package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  115. package/docs/adr/0003-git-guard-latch-writer.md +203 -0
  116. package/docs/adr/0004-disposition-policy-seam.md +38 -0
  117. package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  118. package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  119. package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  120. package/docs/adr/0008-turn-end-lane-interface.md +165 -0
  121. package/docs/adr/0009-reported-path-attribution.md +114 -0
  122. package/docs/adr/README.md +26 -0
  123. package/docs/agent-tools.md +18 -1
  124. package/docs/ast-grep_rules_catalog.md +4 -3
  125. package/docs/configuration.md +55 -1
  126. package/docs/engineering-principles.md +202 -0
  127. package/docs/environment-variables.md +61 -4
  128. package/docs/features.md +2 -2
  129. package/docs/globalconfig.md +6 -1
  130. package/docs/language-coverage.md +17 -0
  131. package/docs/lsp-capability-matrix.md +98 -31
  132. package/docs/pi-lens-fixer.md +57 -3
  133. package/docs/pi-lens-investigator.md +9 -0
  134. package/docs/pi-lens-reviewer.md +16 -0
  135. package/docs/pi-lens-subagent.md +9 -0
  136. package/docs/public-api-stability.md +1 -0
  137. package/docs/release-qa-baseline.md +2 -0
  138. package/docs/servercapabilities.md +21 -11
  139. package/docs/settings.md +12 -3
  140. package/docs/subagent-compat.md +1 -1
  141. package/package.json +5 -3
  142. package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  143. package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  144. package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  145. package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  146. package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  147. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  148. package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  149. package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  150. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  151. package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  152. package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  153. package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  154. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  155. package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  156. package/scripts/install-selftest.mjs +21 -17
  157. package/scripts/lib/web-tree-sitter-dir.mjs +123 -0
@@ -7,6 +7,7 @@ import { logLatency } from "./latency-logger.js";
7
7
  import { touchCoverageGap } from "./lsp/diagnostic-binding.js";
8
8
  import { loadLspService } from "./lsp-lazy.js";
9
9
  import { contentHash, createWarmIpcLineReader, diagnosticsIpcPathForCwd, requestWarmCodeActions, requestWarmDiagnostics, WARM_CODE_ACTION_LOOKUP_LIMIT, WARM_DIAGNOSTICS_SCHEMA_VERSION, } from "./mcp/ipc.js";
10
+ import { incrementDegradationCount, recordDegradationOnce, } from "./degradation-ledger.js";
10
11
  import { normalizeFilePath } from "./path-utils.js";
11
12
  const state = { local: true, servedDiagnosticHashes: new Map() };
12
13
  function record(event, cwd, reason, pid, source) {
@@ -138,6 +139,33 @@ function startServer(cwd) {
138
139
  }
139
140
  const server = net.createServer((socket) => {
140
141
  socket.setEncoding("utf8");
142
+ // #3389: an ACCEPTED socket with no `error` listener rethrows, and
143
+ // `server.on("error")` below covers only the listener. A client that walks
144
+ // away while this incumbent is answering — every `requestWarmDiagnostics`
145
+ // timeout, schema refusal and validation refusal destroys its socket
146
+ // (`clients/mcp/ipc.ts:312`) — therefore turned a routine
147
+ // `read ECONNRESET` into an uncaught exception in THIS host, the same
148
+ // ending #3375 closed for an unbounded `data` handler. The handler does NOT
149
+ // tear the connection down: a real system failure has already destroyed the
150
+ // stream before this runs (measured: `destroyed` is true at entry), and an
151
+ // `error` emitted with no errno leaves a socket that is still answering
152
+ // (review round 1) — destroying that one would drop a live request. So the
153
+ // handler exists to keep the event catchable and to leave the peer's
154
+ // behavior visible in the ledger.
155
+ socket.on("error", (failure) => {
156
+ incrementDegradationCount({
157
+ // Subject is the errno, so one peer's repeated resets stay one row.
158
+ // No `?? "unknown"` fallback: `normalizeForLedger`
159
+ // (`clients/ledger-bounds.ts:29`) already maps a missing value to
160
+ // `"unknown"`, and a second copy of that rule here was a branch no
161
+ // mutation could red (#3389 review round 1, F3395-01) — an error
162
+ // emitted with no `code` is pinned end-to-end in
163
+ // `tests/clients/warm-attach-socket-error.test.ts`.
164
+ kind: "warm-attach-socket-error",
165
+ subject: failure.code,
166
+ reason: String(failure),
167
+ });
168
+ });
141
169
  // One-shot per connection (#1219 family): the same defect shape as the
142
170
  // MCP warm socket — clients write exactly one request and read one
143
171
  // reply, so a handler that kept re-reading the same buffered line
@@ -153,6 +181,11 @@ function startServer(cwd) {
153
181
  socket.end(`${JSON.stringify({ error: String(error) })}\n`);
154
182
  }
155
183
  })();
184
+ }, {
185
+ label: "warm-attach-server",
186
+ // #3383: an unterminated request line is answered like any other
187
+ // unusable request rather than buffered without a ceiling.
188
+ onOverflow: () => socket.end(`${JSON.stringify({ error: "warm request line exceeded the framing limit" })}\n`),
156
189
  }));
157
190
  });
158
191
  server.on("error", (error) => {
@@ -191,6 +224,20 @@ export async function tryWarmAttachedDiagnostics(file, content, timeoutMs, sourc
191
224
  }
192
225
  const result = await requestWarmDiagnostics(state.cwd, state.incumbentPid, file, content, timeoutMs);
193
226
  if (!result.available) {
227
+ // #3255 H1: a confirmed-alive incumbent whose endpoint has no listener is
228
+ // the upgrade-stranded shape — narrowing the workspace-id case fold
229
+ // renamed the socket, so a peer that registered before the upgrade is
230
+ // still serving the previous name. `promoteToLocal` alone left that in the
231
+ // latency log only; this is the entry `/lens-perf` renders. Bounded twice
232
+ // over: once per kind/subject by the ledger, and once per session by
233
+ // `promoteToLocal`'s latch, which returns early on every later call.
234
+ if (result.reason === "no-listener") {
235
+ recordDegradationOnce({
236
+ kind: "warm-ipc-endpoint-missing",
237
+ subject: diagnosticsIpcPathForCwd(state.cwd, state.incumbentPid),
238
+ reason: "the registry confirms this incumbent is alive but nothing is listening on its derived endpoint — if pi-lens was upgraded while it was running, restart it",
239
+ });
240
+ }
194
241
  promoteToLocal(result.reason);
195
242
  }
196
243
  else {
@@ -231,6 +278,18 @@ export function _resetWarmAttachForTests() {
231
278
  state.local = true;
232
279
  state.servedDiagnosticHashes.clear();
233
280
  }
281
+ /**
282
+ * The live diagnostics server, exposed for tests (#3389 review round 1,
283
+ * F3395-01). The `error` handler on an ACCEPTED socket has exactly one input
284
+ * channel — an `error` event on that socket — and a test cannot reach the
285
+ * socket any other way: it is created by `net.createServer` inside the closure
286
+ * above, and Node's ESM namespace for `node:net` is not configurable, so the
287
+ * factory cannot be wrapped from a test either. A test observes the server's
288
+ * own `connection` event through this accessor; nothing in production reads it.
289
+ */
290
+ export function _warmAttachServerForTests() {
291
+ return state.server;
292
+ }
234
293
  export function _setWarmAttachForTests(cwd, incumbentPid) {
235
294
  state.cwd = path.resolve(cwd);
236
295
  state.incumbentPid = incumbentPid;
@@ -9,10 +9,11 @@ import { normalizeEphemeralMapKey, normalizeMapKey } from "./path-utils.js";
9
9
  import { fitLine } from "./tui-fit.js";
10
10
  import { WriteOrderingGuard } from "./write-ordering-guard.js";
11
11
  import { collectForwardImportMtimes } from "./blocker-freshness.js";
12
+ import { normalizeMessage } from "./finding-identity.js";
12
13
  import { freshnessFromMtime } from "./freshness.js";
13
14
  import { PAST_EOF_STALE_MARKER } from "./diagnostic-line-freshness.js";
14
15
  import { STALE_LINE_MARKER } from "./stale-marker.js";
15
- import { anchorsForDiagnostic, applyDispositions, getDisposition, registerWidgetDispositionReconciler, } from "./diagnostic-dispositions.js";
16
+ import { anchorsForDiagnostic, applyDispositions, getDisposition, registerWidgetDispositionReconciler, strictAnchorSpan, strictAnchorSpansIn, } from "./diagnostic-dispositions.js";
16
17
  import { recordDegradationOnce } from "./degradation-ledger.js";
17
18
  /**
18
19
  * Canonical key for the `files` map (and `diagnosticsWriteGuard`) — #1020.
@@ -49,6 +50,19 @@ function fileMapKey(filePath) {
49
50
  * from what the footer counts.
50
51
  */
51
52
  export function isBlocking(d) {
53
+ // #3158 round 2 F1: a finding the agent marked `false-positive`/`suppress` is
54
+ // not a hard stop — it is an explicitly dismissed one. Before this line only
55
+ // `countDiagnostics` knew that (it skips tagged entries), so the footer header
56
+ // counted zero errors while every consumer that asks THIS predicate still
57
+ // treated the row as blocking: `renderWidget` drew a red ● row for it,
58
+ // `capStoredDiagnostics` hoisted it ahead of live findings (evicting one at the
59
+ // display cap), and `getWidgetBlockingFilesForSweep`,
60
+ // `markWidgetFileBlockersStale` and `reconcileStaleWidgetDependencyBlockers`
61
+ // admitted it to the turn-end blocker sweep. The mismatch was latent while a
62
+ // tagged row lived only between a mark and the next scan; retention makes it
63
+ // durable, so the predicate has to answer for the tag.
64
+ if (d.disposition)
65
+ return false;
52
66
  // #1631: a dependency-drift-demoted finding is no longer a hard stop. The gate
53
67
  // sets `stale` rather than dropping the entry (#1419 demote-not-drop), so every
54
68
  // tally and render that asks "is this blocking?" must answer no once demoted.
@@ -82,6 +96,20 @@ const diagnosticsWriteGuard = new WriteOrderingGuard();
82
96
  */
83
97
  const runnerWriteGuard = new WriteOrderingGuard();
84
98
  const MAX_STORED_DIAGNOSTICS_PER_FILE = 12;
99
+ /**
100
+ * #3158 / AGENTS.md shape 9: the per-file bound on
101
+ * {@link WidgetDiagnostic.suppressedRetained} rows. Retention is the only rule
102
+ * that lets a record hold findings the latest scan did not report, so it is the
103
+ * only axis of `allDiagnostics` that can grow while the live set shrinks — every
104
+ * other row is replaced wholesale by the next write. Its own constant rather
105
+ * than `MAX_STORED_DIAGNOSTICS_PER_FILE`: that one is the TUI's display cap
106
+ * (`capStoredDiagnostics`) and the two would drift the moment either policy
107
+ * moved. Twelve because the chip is a COUNT, not a list — past a dozen
108
+ * suppressed findings on one unchanged file the exact total stops informing the
109
+ * footer, and the truncation is stated once per file through the ledger rather
110
+ * than dropped silently.
111
+ */
112
+ const MAX_RETAINED_SUPPRESSED_PER_FILE = 12;
85
113
  const MAX_INACTIVE_FILE_RECORDS = 1024;
86
114
  const ACTIVE_FILE_IDLE_MS = 30 * 60_000;
87
115
  export const MAX_LSP_SERVER_RECORDS = 128;
@@ -140,24 +168,53 @@ export function reconcileWidgetDisposition(cwd, target, _disposition, content) {
140
168
  }
141
169
  }
142
170
  const active = new Set(applyDispositions(current, cwd, target.filePath, source));
143
- const normalized = current.map((diagnostic) => {
171
+ const normalized = current.flatMap((diagnostic) => {
144
172
  const { strict, weak } = anchorsForDiagnostic(cwd, target.filePath, diagnostic, source);
145
173
  const entry = getDisposition(cwd, strict) ?? getDisposition(cwd, weak);
146
174
  if (!active.has(diagnostic) &&
147
175
  (entry?.disposition === "false-positive" ||
148
176
  entry?.disposition === "suppress")) {
149
- return { ...diagnostic, disposition: entry.disposition, flagged: false };
177
+ // #3183: stamp the strict anchor's own SPAN so the row's lifetime can
178
+ // later ask the mark instead of the file's mtime. `source` is the content
179
+ // the anchors above were derived from, so the span is the one the mark
180
+ // itself binds to. Only the STRICT arm has a span — a weak `suppress`
181
+ // mark hashes no line — and re-tagging always re-derives it, so a stale
182
+ // span from an earlier tag can never survive into a new disposition.
183
+ const { anchorSpan: _previousSpan, ...base } = diagnostic;
184
+ const span = entry.disposition === "false-positive"
185
+ ? strictAnchorSpan(source, diagnostic.line)
186
+ : undefined;
187
+ return [
188
+ {
189
+ ...base,
190
+ disposition: entry.disposition,
191
+ flagged: false,
192
+ ...(span === undefined ? {} : { anchorSpan: span }),
193
+ },
194
+ ];
150
195
  }
151
- const { disposition: _disposition, flagged: _flagged, ...baseDiagnostic } = diagnostic;
196
+ // #3158 retirement rule 3: a row the store holds ONLY because it was
197
+ // suppressed (`suppressedRetained`) has no live scan behind it — the last
198
+ // scan of this file did not report it. Once its mark stops suppressing it
199
+ // (a strict `false-positive` anchor whose marked line changed), retire it
200
+ // rather than re-arming it below as a finding the agent would read as
201
+ // currently present.
202
+ if (diagnostic.suppressedRetained)
203
+ return [];
204
+ const { disposition: _disposition, flagged: _flagged,
205
+ // #3183: a row that is no longer disposition-tagged has no mark to
206
+ // anchor, so its span goes with the tag — leaving it would exempt a LIVE
207
+ // row from the sweep's mtime gate.
208
+ anchorSpan: _anchorSpan, ...baseDiagnostic } = diagnostic;
152
209
  return entry?.disposition === "flagged"
153
- ? { ...baseDiagnostic, flagged: true }
154
- : baseDiagnostic;
210
+ ? [{ ...baseDiagnostic, flagged: true }]
211
+ : [baseDiagnostic];
155
212
  });
156
213
  const rec = getOrCreate(target.filePath);
157
214
  const writeIndex = admitWidgetDiagnosticsWrite(target.filePath);
158
215
  if (!diagnosticsWriteGuard.shouldWrite(fileMapKey(target.filePath), writeIndex))
159
216
  return;
160
- commitDiagnostics(rec, target.filePath, normalized, Date.now());
217
+ commitDiagnostics(rec, target.filePath, normalized, Date.now(), fileMapKey(target.filePath), false);
161
218
  }
162
219
  /** Reserve a widget write for a producer that has no runtime write index. */
163
220
  export function admitWidgetDiagnosticsWrite(filePath, writeIndex) {
@@ -473,11 +530,175 @@ function normalizeDiagnostics(filePath, diagnostics, observedTs) {
473
530
  };
474
531
  });
475
532
  }
533
+ /**
534
+ * The identity a retained suppressed row is matched against a later scan's
535
+ * entries by (#3158), so a scan that reports the finding AGAIN replaces the
536
+ * retained row instead of standing beside it as a duplicate.
537
+ *
538
+ * These are the WEAK ANCHOR's identity parts — `computeWeakAnchor`
539
+ * (`clients/diagnostic-dispositions.ts`) hashes `[tool, rule,
540
+ * normalizeMessage(message)]` over the file — deliberately, because the weak
541
+ * anchor is how the disposition store itself decides which findings a mark
542
+ * covers. Matching at any finer granularity means the store can disagree with
543
+ * the mark that created the row.
544
+ *
545
+ * Round 2 F2: `line` USED to be part of it, and that was the finer granularity
546
+ * the paragraph above forbids. A writer that re-reports the marked finding one
547
+ * line down — `clients/pipeline.ts`'s per-edit `recordDiagnostics`, or
548
+ * `reconcileCascadeNeighborLspErrors`, neither of which runs
549
+ * `reconcileStaleWidgetFiles` — left the retained row standing beside the live
550
+ * one, so a single finding was counted live AND in `suppressed: N`.
551
+ *
552
+ * `computeWeakAnchor` itself is not called here: it needs `cwd` to build its
553
+ * path component, which `commitDiagnostics` does not have and could only get by
554
+ * widening `recordDiagnostics`, `reconcileScanDiagnostics` and
555
+ * `reconcileCorrelatedScanDiagnostics` up through `tools/`. That component is
556
+ * constant across one file record — both sides of this comparison are the same
557
+ * file — so it would discriminate nothing. `normalizeMessage` is the shared
558
+ * derivation from `clients/finding-identity.ts` that the anchor uses, imported
559
+ * rather than re-spelled.
560
+ *
561
+ * #3183: this identity is OCCURRENCE-BLIND, and for a STRICT (`false-positive`)
562
+ * row that is not enough on its own. Two findings of one rule with identical
563
+ * messages on different lines collapse onto it, so a scan reporting only the
564
+ * UNMARKED one matched the marked row and replaced it — the chip lost a mark
565
+ * that was still applying. A collision is therefore resolved by the second half
566
+ * of {@link WidgetDiagnostic.anchorSpan}'s contract, in `retainSuppressedRows`:
567
+ * only a colliding incoming finding sitting ON the marked line is the same
568
+ * finding. That test, not this one, is what keeps the round-2 F2 case above
569
+ * closed — the marked line's TEXT moved, so its span still matches.
570
+ */
571
+ function retentionIdentity(d) {
572
+ return JSON.stringify([
573
+ d.tool ?? "",
574
+ d.rule ?? "",
575
+ normalizeMessage(d.message),
576
+ ]);
577
+ }
578
+ /** `filePath`'s current content, or `undefined` when it cannot be read. */
579
+ function readContentOrUndefined(filePath) {
580
+ try {
581
+ return readFileSync(filePath, "utf8");
582
+ }
583
+ catch {
584
+ // Only reached once this module has established there IS a span-carrying row
585
+ // to ask about, so the record is bounded by that population — and worth
586
+ // making: with no content both callers fall back to the pre-#3183 rules (the
587
+ // per-entry mtime gate, the coarse retention identity), which is the
588
+ // conservative direction but can retire a mark that still stands.
589
+ recordDegradationOnce({
590
+ kind: "widget-mark-anchor-unreadable",
591
+ subject: filePath,
592
+ reason: "the marked file could not be read, so its suppressed rows fall back to the file-mtime retirement gate and the footer chip may under-count it",
593
+ });
594
+ return undefined;
595
+ }
596
+ }
597
+ /**
598
+ * {@link strictAnchorSpansIn} over `filePath`'s current content (#3183), or
599
+ * `undefined` when it cannot be read.
600
+ *
601
+ * Synchronous in both callers, matching `applyCachedDispositions`
602
+ * (`tools/lens-diagnostics.ts`) — the seam that asks the very same question on
603
+ * the very same read path, with `statSync`/`readFileSync`. An async read here
604
+ * would add two unbounded awaits to a module a registered hook handler imports
605
+ * directly (`tests/config/hook-await-bounds.test.ts`) for no behavioural gain.
606
+ */
607
+ function readAnchorSpans(filePath) {
608
+ const content = readContentOrUndefined(filePath);
609
+ return content === undefined ? undefined : strictAnchorSpansIn(content);
610
+ }
611
+ /**
612
+ * #3158: carry the record's disposition-tagged rows across a whole-replace
613
+ * write when the incoming set no longer reports them, so a `false-positive` /
614
+ * `suppress` mark keeps contributing to the footer's `suppressed: N` chip. See
615
+ * {@link WidgetDiagnostic.suppressedRetained} for the full lifetime statement.
616
+ *
617
+ * Retained rows are APPENDED after the incoming ones: `capStoredDiagnostics`
618
+ * fills the TUI's display list from the front, so a retained row never evicts a
619
+ * live finding from the rendered set.
620
+ *
621
+ * The tagged-row check comes FIRST so a project with no disposition marks — the
622
+ * overwhelmingly common case, and this runs on the per-edit write path — pays
623
+ * one predicate per stored row and never builds the identity set.
624
+ */
625
+ function retainSuppressedRows(previous, incoming, filePath) {
626
+ const tagged = previous.filter((d) => d.disposition !== undefined);
627
+ if (tagged.length === 0)
628
+ return incoming;
629
+ const reported = new Map();
630
+ for (const d of incoming) {
631
+ const id = retentionIdentity(d);
632
+ const bucket = reported.get(id);
633
+ if (bucket)
634
+ bucket.push(d);
635
+ else
636
+ reported.set(id, [d]);
637
+ }
638
+ // #3183: the ONE file read on this path, and only for the state the coarse
639
+ // identity genuinely cannot resolve — a strict-anchored row whose identity an
640
+ // incoming finding collides with. Every other write (no marks at all, a mark
641
+ // with no collision, a weak `suppress` mark) stays zero-I/O, which is what
642
+ // keeps this off the per-edit `recordDiagnostics` cost.
643
+ const content = tagged.some((d) => d.anchorSpan !== undefined && reported.has(retentionIdentity(d)))
644
+ ? readContentOrUndefined(filePath)
645
+ : undefined;
646
+ const spans = content === undefined ? undefined : strictAnchorSpansIn(content);
647
+ const retained = tagged.filter((d) => {
648
+ const collisions = reported.get(retentionIdentity(d));
649
+ // Nothing re-reported this finding, so retention rule 2 has nothing to say.
650
+ if (collisions === undefined)
651
+ return true;
652
+ // No span to ask (a weak `suppress` mark, or a row from an older build), or
653
+ // the file is unreadable: fall back to the pre-#3183 coarse rule. The safe
654
+ // direction is to UNDER-count, never to leave a phantom duplicate standing
655
+ // beside a live finding.
656
+ if (d.anchorSpan === undefined || spans === undefined)
657
+ return false;
658
+ // The mark stopped applying — its line is gone from the file — so the
659
+ // colliding finding IS this one, re-reported now that the filter no longer
660
+ // drops it (retirement rule 2, and the same verdict the sweep reaches).
661
+ if (!spans.has(d.anchorSpan))
662
+ return false;
663
+ // The mark still applies. Only a collision sitting ON the marked line is
664
+ // the same finding (its line number may have shifted under an edit — #3158
665
+ // round 2 F2); any other is a DIFFERENT occurrence this strict anchor never
666
+ // covered, and replacing the row with it loses a live mark (#3183).
667
+ return !collisions.some((i) => strictAnchorSpan(content, i.line) === d.anchorSpan);
668
+ });
669
+ if (retained.length > MAX_RETAINED_SUPPRESSED_PER_FILE) {
670
+ // Freshest observations win the cap. One record per FILE, never one per
671
+ // dropped row (AGENTS.md "bounded observability"): the chip under-counts
672
+ // by exactly `dropped` for this file until its content changes.
673
+ retained.sort((a, b) => (b.observedAt ?? 0) - (a.observedAt ?? 0));
674
+ recordDegradationOnce({
675
+ kind: "widget-suppressed-retention-capped",
676
+ subject: filePath,
677
+ reason: `${retained.length - MAX_RETAINED_SUPPRESSED_PER_FILE} suppressed row(s) beyond the ${MAX_RETAINED_SUPPRESSED_PER_FILE}-row per-file retention cap are no longer counted by the footer chip`,
678
+ });
679
+ retained.length = MAX_RETAINED_SUPPRESSED_PER_FILE;
680
+ }
681
+ return [
682
+ ...incoming,
683
+ ...retained.map((d) => d.suppressedRetained ? d : { ...d, suppressedRetained: true }),
684
+ ];
685
+ }
476
686
  /** Store `normalized` as the record's complete diagnostic set: recompute counts,
477
687
  * cap the display list, stamp `touchedAt` (at `observedAt` when given, else now
478
688
  * — #1093), persist, and re-render. The caller decides what `normalized`
479
689
  * contains (a full replace, or a merge — see `reconcileCascadeNeighborLspErrors`). */
480
- function commitDiagnostics(rec, filePath, normalized, observedAt, key = fileMapKey(filePath)) {
690
+ function commitDiagnostics(rec, filePath, incoming, observedAt, key = fileMapKey(filePath),
691
+ /**
692
+ * #3158: apply the suppressed-row retention rule to this write. True for
693
+ * every seam that hands in a fresh SCAN of the file (`recordDiagnostics` and
694
+ * both reconciles), false for `reconcileWidgetDisposition`, which re-maps the
695
+ * record's own current rows and is the seam that RETIRES a retained row —
696
+ * re-adding what it just dropped would make retirement rule 3 inert.
697
+ */
698
+ retainSuppressed = true) {
699
+ const normalized = retainSuppressed
700
+ ? retainSuppressedRows(rec.allDiagnostics, incoming, filePath)
701
+ : incoming;
481
702
  rec.diagnosticCounts = countDiagnostics(normalized);
482
703
  rec.diagnostics = capStoredDiagnostics(normalized);
483
704
  rec.allDiagnostics = normalized;
@@ -765,13 +986,24 @@ export async function reconcileStaleWidgetFiles() {
765
986
  // at dispatch/integration.ts). A missing per-entry stamp (a migrated
766
987
  // pre-#1186 record) inherits the record's `touchedAt`. Tolerance matches
767
988
  // the Windows host-clock skew rationale above.
768
- const survivors = rec.allDiagnostics.filter((d) => {
769
- const v = freshnessFromMtime({
770
- mtimeMs,
771
- referenceMs: d.observedAt ?? rec.touchedAt,
772
- });
773
- return v.verdict !== "stale";
774
- });
989
+ const mtimeStale = (d) => freshnessFromMtime({
990
+ mtimeMs,
991
+ referenceMs: d.observedAt ?? rec.touchedAt,
992
+ }).verdict === "stale";
993
+ // #3183: a disposition-tagged row carrying a strict anchor span is gated
994
+ // on its MARK, not on the file's mtime (retirement rule 1 — see
995
+ // `WidgetDiagnostic.suppressedRetained`): it survives while the marked
996
+ // LINE is still somewhere in the file, and is retired when it is not.
997
+ // The content read is gated on a span-carrying row the mtime gate WOULD
998
+ // have dropped — the only state where reading can change an outcome — so
999
+ // the debounced render sweep stays off the disk both for a file with no
1000
+ // marks and for a marked file nothing has touched.
1001
+ const spans = rec.allDiagnostics.some((d) => d.anchorSpan !== undefined && mtimeStale(d))
1002
+ ? readAnchorSpans(rec.filePath)
1003
+ : undefined;
1004
+ const survivors = rec.allDiagnostics.filter((d) => d.anchorSpan !== undefined && spans !== undefined
1005
+ ? spans.has(d.anchorSpan)
1006
+ : !mtimeStale(d));
775
1007
  if (survivors.length === rec.allDiagnostics.length) {
776
1008
  return { mapKey, action: "keep" }; // nothing stale
777
1009
  }
@@ -1064,21 +1296,66 @@ export function scheduleStaleReconcile() {
1064
1296
  * Used by lens_diagnostics tool (mode: "all"). Exposes the FULL per-file
1065
1297
  * diagnostic set — decoupled from the widget's display cap — so the agent sees
1066
1298
  * everything, not just the 12 the TUI keeps for rendering.
1299
+ *
1300
+ * #3183: "everything" means every row a live scan stands behind.
1301
+ * `disposition`-tagged rows are the store's own bookkeeping for a mark — the
1302
+ * scan that produced this record did not report them — and they are excluded
1303
+ * here. That is the same predicate `countDiagnostics` and `isBlocking` have
1304
+ * always used, so this makes the projected array agree with the counts beside
1305
+ * it; before #3183 the array disagreed, and once retirement rule 1 let a row
1306
+ * outlive the file's mtime, `applyCachedDispositions` reached it in the
1307
+ * WEAK-fallback branch — which can never match a STRICT `false-positive`
1308
+ * anchor — and `mode=all` served a marked finding as a live warning (#3158 AC3).
1309
+ * The exclusion lives HERE, at the one projection every cache-only surface and
1310
+ * the mode=full merge read, rather than as a second disposition rule inside
1311
+ * `summarizeDiagnostics`' tally: that tally still skips `stale` only, and the
1312
+ * disposition filter still reaches it pre-applied through the shared
1313
+ * `applyCachedDispositions` seam.
1314
+ *
1315
+ * Deliberately NOT applied to {@link getFileDiagnostics}, the per-file read:
1316
+ * `reconcileWidgetDisposition` needs the tagged rows to enforce retirement
1317
+ * rule 3, and `lens_diagnostic_mark`'s cross-check re-marks through them.
1067
1318
  */
1068
1319
  export function getFileDiagnosticSummaries() {
1069
1320
  return [...files.values()].map((rec) => {
1070
1321
  applyPastEofGate(rec);
1322
+ const live = rec.allDiagnostics.filter((d) => d.disposition === undefined);
1071
1323
  return {
1072
1324
  filePath: rec.filePath,
1073
1325
  blocking: rec.diagnosticCounts.blocking,
1074
1326
  errors: rec.diagnosticCounts.errors,
1075
1327
  warnings: rec.diagnosticCounts.warnings,
1076
- advisories: countAdvisories(rec.allDiagnostics),
1328
+ advisories: countAdvisories(live),
1077
1329
  hasFinalSnapshot: rec.hasFinalDiagnosticsSnapshot,
1078
- diagnostics: rec.allDiagnostics.map((d) => ({ ...d })),
1330
+ diagnostics: live.map((d) => ({ ...d })),
1079
1331
  };
1080
1332
  });
1081
1333
  }
1334
+ /**
1335
+ * How many {@link WidgetDiagnostic.suppressedRetained} rows this file's record
1336
+ * is currently carrying (#3158 round 2 F4).
1337
+ *
1338
+ * The success-path counterpart of `widget-suppressed-retention-capped`: the
1339
+ * ledger only hears about retention when the per-file cap TRUNCATES it, so
1340
+ * nothing observed the healthy case. `tools/lsp-diagnostics.ts` folds this into
1341
+ * the `lsp_probe_disposition_filter` phase it already emits once per filtered
1342
+ * file per scan, so the count is bounded by that record's existing cardinality
1343
+ * — never one row per retained finding.
1344
+ *
1345
+ * Deliberately not {@link getFileDiagnostics}: that applies the past-EOF gate,
1346
+ * which can `stat` the file. This is a map lookup plus a count over rows the
1347
+ * caller's own scan just filtered, so it adds no I/O to the probe path.
1348
+ */
1349
+ export function countRetainedSuppressedRows(filePath) {
1350
+ const rec = files.get(fileMapKey(filePath));
1351
+ if (!rec)
1352
+ return 0;
1353
+ let n = 0;
1354
+ for (const d of rec.allDiagnostics)
1355
+ if (d.suppressedRetained)
1356
+ n += 1;
1357
+ return n;
1358
+ }
1082
1359
  /**
1083
1360
  * Return the current FULL (uncapped) diagnostic set for a single file, as
1084
1361
  * last recorded by {@link recordDiagnostics} — the same `allDiagnostics`
@@ -39,7 +39,7 @@
39
39
  import * as fs from "node:fs";
40
40
  import * as os from "node:os";
41
41
  import * as path from "node:path";
42
- import { PROJECT_CONFIG_BASENAMES } from "./config-locations.js";
42
+ import { PROJECT_CONFIG_BASENAMES, isResolvedGlobalConfigPath, } from "./config-locations.js";
43
43
  import { logLatency } from "./latency-logger.js";
44
44
  import { isAtOrAboveHomeDir, nameMatchesMarkerGlob, walkUpDirs, } from "./path-utils.js";
45
45
  /** Depth cap on any upward marker walk — mirrors `findNearestMarkerRoot`'s bound. */
@@ -214,9 +214,53 @@ export function getDirectoryMarkers(dir) {
214
214
  function walkCacheKey(startDir, markerKey) {
215
215
  return `${path.resolve(startDir)}\0${markerKey}`;
216
216
  }
217
- function walkStillFresh(dirMtimes) {
217
+ export function dirMtimesStillFresh(dirMtimes) {
218
218
  return dirMtimes.every(({ dir, mtimeMs }) => safeDirMtimeMs(dir) === mtimeMs);
219
219
  }
220
+ /**
221
+ * Async arm of `dirMtimesStillFresh` — same records, same comparison, stats
222
+ * issued in parallel. It exists for the async marker walk in
223
+ * `clients/lsp/server.ts`'s `NearestRoot`, whose own marker probes are
224
+ * `fs/promises` and which runs on the per-file LSP touch path: a `statSync` per
225
+ * directory there would block the event loop the walk deliberately keeps free.
226
+ * Both arms live here so "is this recorded walk still current" has one owner
227
+ * (#3412).
228
+ *
229
+ * Written with promise combinators rather than `await`, along with the two
230
+ * readers below, so the ONLY place that waits on these stats is the caller's
231
+ * `await bounded(...)` — a hook-path await that a later editor cannot leave
232
+ * unbounded, instead of an inner await bounded only by a registry admission
233
+ * (#3412 review round 1, M-3421-02).
234
+ */
235
+ export function dirMtimesStillFreshAsync(dirMtimes) {
236
+ return Promise.all(dirMtimes.map(({ dir }) => dirMtimeMsAsync(dir))).then((current) => dirMtimes.every(({ mtimeMs }, index) => current[index] === mtimeMs));
237
+ }
238
+ /**
239
+ * Current mtime of `dir`, or `-1` when it cannot be stat'ed — the async arm of
240
+ * `safeDirMtimeMs`. A recorder and `dirMtimesStillFreshAsync` MUST read through
241
+ * the same function so the absent-directory sentinel is the same value on both
242
+ * sides: `prisma/` not existing yet is a recordable state, and the record must
243
+ * compare unequal once it is created (#3412).
244
+ */
245
+ function dirMtimeMsAsync(dir) {
246
+ return fs.promises.stat(dir).then((stats) => stats.mtimeMs, () => -1);
247
+ }
248
+ /** One freshness record per directory, read in parallel (#3412). */
249
+ export function dirMtimeRecordsAsync(dirs) {
250
+ return Promise.all(dirs.map((dir) => dirMtimeMsAsync(dir).then((mtimeMs) => ({ dir, mtimeMs }))));
251
+ }
252
+ /**
253
+ * Records for directories whose mtime could NOT be read — because the caller's
254
+ * bound fired, so the answer is unknown rather than absent. `NaN` compares
255
+ * unequal to every mtime including itself, so an entry carrying one of these
256
+ * can never be served: the walk's answer is still returned, it is simply not
257
+ * memoized. Unknown must never be spelled as a number a directory could
258
+ * actually have, and dropping the directory from the signature (recording
259
+ * nothing) would hide every later change in it (#3412 review round 1, S9).
260
+ */
261
+ export function unknownDirMtimeRecords(dirs) {
262
+ return dirs.map((dir) => ({ dir, mtimeMs: Number.NaN }));
263
+ }
220
264
  /**
221
265
  * Shared core of the two nearest-marker walkers below: the cache check, the
222
266
  * home-guarded/depth-capped upward loop, per-directory mtime recording, and
@@ -229,7 +273,7 @@ function walkStillFresh(dirMtimes) {
229
273
  function walkToNearestMatch(startDir, cacheSuffix, matches, capMetadata, homeDir) {
230
274
  const key = walkCacheKey(startDir, cacheSuffix);
231
275
  const cached = walkCache.get(key);
232
- if (cached && walkStillFresh(cached.dirMtimes)) {
276
+ if (cached && dirMtimesStillFresh(cached.dirMtimes)) {
233
277
  touchWalk(key, cached);
234
278
  return cached.dir;
235
279
  }
@@ -339,11 +383,21 @@ export function findNearestDirWithAnyBasename(startDir, basenames, homeDir = os.
339
383
  * `.pi-lens.json`/`pi-lens.json` directly IN `dir` — no upward walk. The
340
384
  * shared-index equivalent of `project-lens-config.ts`'s old private
341
385
  * `findPiLensConfigInDir` probe loop.
386
+ *
387
+ * A candidate that IS the resolved global config path is refused
388
+ * (global-config-location PR, refs #2457): the global tier reads that file by
389
+ * absolute path, and discovering it here a second time made the nested ignore
390
+ * layering full-validate it and warn that its global-only keys are "not
391
+ * honored in a project config" — including the notice advising the user to
392
+ * set them in the very file being ignored. The refusal is observable through
393
+ * the marker staying absent, the same shape as a directory with no marker.
342
394
  */
343
395
  export function findPiLensConfigMarkerInDir(dir) {
344
396
  const markers = getDirectoryMarkers(dir);
345
397
  if (!markers.piLensConfigPath)
346
398
  return undefined;
399
+ if (isResolvedGlobalConfigPath(markers.piLensConfigPath))
400
+ return undefined;
347
401
  const stat = (() => {
348
402
  try {
349
403
  return fs.statSync(markers.piLensConfigPath);