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
@@ -165,6 +165,10 @@ function formatTurnEnd(response) {
165
165
  */
166
166
  const TURN_END_SKIP_REASONS = {
167
167
  "ipc-error": "no warm pi-lens MCP server answered for this workspace, or its build is stale — start the MCP server, or run `npm run build` if you have edited pi-lens sources",
168
+ // #3255: split out of `ipc-error` because its remedy is different and no
169
+ // rebuild reaches it. A server that was already running when pi-lens was
170
+ // upgraded still owns the endpoint name the previous build derived.
171
+ "no-listener": "nothing is listening on this workspace's warm endpoint — start the MCP server; if pi-lens was upgraded while a server was running, that server still owns the endpoint name of the previous build, so restart it",
168
172
  timeout: "the warm pi-lens MCP server did not finish the pass within the hook budget — the turn-end findings are kept and will be delivered on a later Stop",
169
173
  "schema-mismatch": "the warm pi-lens MCP server speaks a different turn-end schema than this hook binary — rebuild and restart the server so both halves match",
170
174
  "stale-answer": "the warm pi-lens MCP server replied about a superseded state — the findings are kept for a later Stop",
@@ -321,8 +321,16 @@ function startIpcServer() {
321
321
  socket.end(`${JSON.stringify({ error: String(err) })}\n`);
322
322
  }
323
323
  });
324
+ }, {
325
+ label: "mcp-warm-server",
326
+ // #3383: a peer that never terminates its request line gets the same
327
+ // error reply every other unusable request gets, instead of this
328
+ // process holding its bytes until the client's timeout.
329
+ onOverflow: () => socket.end(`${JSON.stringify({ error: "warm request line exceeded the framing limit" })}\n`),
324
330
  }));
325
- socket.on("error", () => socket.destroy());
331
+ // The stream is already destroyed when this handler runs; the listener
332
+ // remains the surviving teardown witness, while destroy() was a no-op.
333
+ socket.on("error", () => { });
326
334
  });
327
335
  ipc.on("error", (err) => {
328
336
  // Listener failure must not take down the MCP server — warm channel is an
@@ -1623,28 +1631,32 @@ async function handleRequest(request) {
1623
1631
  }
1624
1632
  }
1625
1633
  // --- stdio read loop (newline-delimited JSON) --------------------------------
1626
- let buffer = "";
1627
1634
  process.stdin.setEncoding("utf8");
1628
- process.stdin.on("data", (chunk) => {
1629
- buffer += chunk;
1630
- let newlineIndex = buffer.indexOf("\n");
1631
- while (newlineIndex !== -1) {
1632
- const line = buffer.slice(0, newlineIndex).trim();
1633
- buffer = buffer.slice(newlineIndex + 1);
1634
- if (line.length > 0) {
1635
- let request;
1636
- try {
1637
- request = JSON.parse(line);
1638
- }
1639
- catch {
1640
- sendError(null, -32700, "Parse error");
1641
- }
1642
- if (request)
1643
- void handleRequest(request);
1644
- }
1645
- newlineIndex = buffer.indexOf("\n");
1635
+ // #3383: framed by the shared reader, so the partial line this loop holds
1636
+ // between chunks has a ceiling. It used to be `buffer += chunk` with none: a
1637
+ // host that never sent a newline grew one JS string until V8 refused the next
1638
+ // concatenation with `RangeError: Invalid string length` — raised inside this
1639
+ // `data` handler, where nothing can catch it, which is the ending #3375 fixed
1640
+ // for child output. `continuous` because this is a stream of requests, not one
1641
+ // request per connection like every socket reader.
1642
+ process.stdin.on("data", createWarmIpcLineReader((raw) => {
1643
+ const line = raw.trim();
1644
+ if (line.length === 0)
1645
+ return;
1646
+ let request;
1647
+ try {
1648
+ request = JSON.parse(line);
1646
1649
  }
1647
- });
1650
+ catch {
1651
+ sendError(null, -32700, "Parse error");
1652
+ }
1653
+ if (request)
1654
+ void handleRequest(request);
1655
+ }, {
1656
+ label: "mcp-stdio",
1657
+ continuous: true,
1658
+ onOverflow: () => sendError(null, -32700, "Parse error: request line exceeded the framing limit"),
1659
+ }));
1648
1660
  process.stdin.on("end", () => {
1649
1661
  endSituationalToolTelemetry();
1650
1662
  void flushExtensionLog().finally(() => process.exit(0));
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Where web-tree-sitter is installed — ONE ladder, for every caller that needs
3
+ * the answer (#3409).
4
+ *
5
+ * Callers: `clients/tree-sitter-client.ts` (the grammar read path and the
6
+ * lazy-fetch write target), `clients/install-diagnostics.ts` (the pasted
7
+ * environment fingerprint) and `scripts/install-selftest.mjs` (the installed
8
+ * host's own diagnostic). Each used to spell its own rungs, and the two that led
9
+ * with a BARE specifier were unconditionally broken on the host pi ships: inside
10
+ * a `bun build --compile` binary `require.resolve("web-tree-sitter")` throws
11
+ * MODULE_NOT_FOUND while `require.resolve("web-tree-sitter/tree-sitter.wasm")` —
12
+ * an explicit file subpath — still resolves.
13
+ *
14
+ * It lives in `scripts/lib/` rather than `clients/` because the installed
15
+ * selftest is packaged (`files[]`) without `clients/`, and this is the house
16
+ * pattern for logic a packaged .mjs script and the TypeScript runtime share
17
+ * (`scripts/lib/skills-predicate.mjs`, `scripts/lib/process-scan.mjs`). It
18
+ * imports nothing but `node:fs` and `node:path`, so it loads even on the hosts
19
+ * whose broken dependency graph the selftest exists to report.
20
+ */
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ /** The package this module is about. Also the name its manifest must carry. */
24
+ const WEB_TREE_SITTER = "web-tree-sitter";
25
+ /**
26
+ * The wasm the runtime loads, at the package root — where 0.25.10's `exports`
27
+ * map puts it (`"./tree-sitter.wasm": "./tree-sitter.wasm"`). If a later release
28
+ * moves it (#381's 0.26 migration), the two FALLBACK rungs below stop matching
29
+ * and the resolver rungs, which ask Node for that same subpath, keep working;
30
+ * this constant is then the one line to update.
31
+ */
32
+ const RUNTIME_WASM = "tree-sitter.wasm";
33
+ /**
34
+ * True when `dir` IS an installed web-tree-sitter package: its own manifest
35
+ * names it, and the wasm the runtime loads is in it.
36
+ *
37
+ * Round 1 review of #3409 (R3418-1): the constructed rungs used to accept any
38
+ * directory that merely EXISTED at `<base>/node_modules/web-tree-sitter`, so an
39
+ * empty or foreign directory of that name became the runtime fetch's write
40
+ * target and a downloaded grammar would land in an unrelated tree. Existence is
41
+ * not identity. The manifest name is the identity; the wasm is what makes the
42
+ * directory the right place to put grammars, since a grammar is only loadable by
43
+ * the web-tree-sitter runtime sitting next to it (the ABI drift #1564 is about).
44
+ */
45
+ export function isWebTreeSitterPackageDir(dir) {
46
+ try {
47
+ if (!fs.existsSync(path.join(dir, RUNTIME_WASM)))
48
+ return false;
49
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
50
+ return manifest?.name === WEB_TREE_SITTER;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ /**
57
+ * The package directory owning `resolvedFile`: walk up until a directory passes
58
+ * the identity check, or the filesystem root is reached. The walk exists because
59
+ * an `exports` map may point into a subdirectory, so the file's own parent is
60
+ * not necessarily the package; the identity check is what stops the walk, so a
61
+ * resolver that answers outside any web-tree-sitter package yields nothing
62
+ * rather than an unrelated ancestor.
63
+ */
64
+ function packageDirOf(resolvedFile) {
65
+ let dir = path.dirname(resolvedFile);
66
+ for (;;) {
67
+ if (isWebTreeSitterPackageDir(dir))
68
+ return dir;
69
+ const parent = path.dirname(dir);
70
+ if (parent === dir)
71
+ return undefined;
72
+ dir = parent;
73
+ }
74
+ }
75
+ /**
76
+ * web-tree-sitter's installed package directory, or undefined.
77
+ *
78
+ * Rung order, most authoritative first:
79
+ * 1. the `tree-sitter.wasm` SUBPATH, which resolves on a compiled host as well
80
+ * as a plain one. This is the rung that makes the non-core grammars
81
+ * fetchable there at all.
82
+ * 2. the BARE specifier, for a future web-tree-sitter whose exports map no
83
+ * longer carries `./tree-sitter.wasm`.
84
+ * 3. pi-lens's own package root, for the temp-dir compile layout (#20) where
85
+ * the resolver's context is a temp directory but the package root still has
86
+ * the right `node_modules`.
87
+ * 4. the working directory, the pre-#3409 last resort.
88
+ *
89
+ * Rungs 1-2 are the resolver's answer and outrank the constructed guesses: the
90
+ * grammars must come from the SAME package whose wasm the runtime loaded. Every
91
+ * rung's candidate passes `isWebTreeSitterPackageDir` before it is returned.
92
+ *
93
+ * `resolve`, `packageRoot` and `cwd` are injected, never defaulted: the caller's
94
+ * resolver context and package root are what differ on a compiled host, so they
95
+ * must not be silently replaced by this module's — and a rung nothing can vary
96
+ * is a rung no test can drive.
97
+ */
98
+ export function resolveWebTreeSitterPackageDir(deps) {
99
+ for (const specifier of [
100
+ `${WEB_TREE_SITTER}/${RUNTIME_WASM}`,
101
+ WEB_TREE_SITTER,
102
+ ]) {
103
+ try {
104
+ const dir = packageDirOf(deps.resolve(specifier));
105
+ if (dir)
106
+ return dir;
107
+ }
108
+ catch {
109
+ /* next rung */
110
+ }
111
+ }
112
+ for (const base of [deps.packageRoot, deps.cwd]) {
113
+ try {
114
+ const candidate = path.join(base(), "node_modules", WEB_TREE_SITTER);
115
+ if (isWebTreeSitterPackageDir(candidate))
116
+ return candidate;
117
+ }
118
+ catch {
119
+ /* next rung */
120
+ }
121
+ }
122
+ return undefined;
123
+ }
@@ -45,8 +45,9 @@ import * as path from "node:path";
45
45
  import { Type } from "../clients/deps/typebox.js";
46
46
  import { markDisposition, normalizeMessage, } from "../clients/diagnostic-dispositions.js";
47
47
  import { insertSuppressComment } from "../clients/dispatch/suppress-writer.js";
48
- import { getFileDiagnostics, } from "../clients/widget-state.js";
48
+ import { normalizeMapKey } from "../clients/path-utils.js";
49
49
  import { resolveLensToolName } from "../clients/tool-config.js";
50
+ import { getFileDiagnostics, } from "../clients/widget-state.js";
50
51
  const DISPOSITIONS = [
51
52
  "false-positive",
52
53
  "suppress",
@@ -70,11 +71,22 @@ function isBlank(text) {
70
71
  * violation) are ambiguous; per #802 this picks the match CLOSEST to the
71
72
  * caller's line rather than guessing — the conservative choice for suppress,
72
73
  * which must never place a comment above the wrong site.
74
+ *
75
+ * #3160: `getFileDiagnostics` keys with `normalizeEphemeralMapKey` — case-
76
+ * preserving on POSIX by design (no filesystem I/O on that hot path) — but
77
+ * the widget's own writers key from `ctx.filePath`, which
78
+ * `createDispatchContext` already canonicalizes to the on-disk casing
79
+ * (#2016/#3098). `absPath` here is the caller's RAW spelling, so on a
80
+ * case-insensitive filesystem a mis-cased mark call must derive the same
81
+ * canonical key the writers used or it silently misses the live diagnostic
82
+ * and falls through to the fuzzy fallback below — do NOT case-fold
83
+ * `normalizeEphemeralMapKey` itself for this: on a case-sensitive filesystem
84
+ * that would merge two genuinely different files.
73
85
  */
74
86
  function widgetCrossCheck(absPath, tool, rule, message, callerLine) {
75
87
  let diagnostics;
76
88
  try {
77
- diagnostics = getFileDiagnostics(absPath);
89
+ diagnostics = getFileDiagnostics(normalizeMapKey(absPath));
78
90
  }
79
91
  catch {
80
92
  return undefined;
@@ -18,13 +18,13 @@ import { DEPENDENCY_DRIFT_MAX_DELIVERIES } from "../clients/blocker-freshness.js
18
18
  import { freshnessFromMtime } from "../clients/freshness.js";
19
19
  import { applyFindingPolicy, loadProjectRulePolicyMap, } from "../clients/dispatch/finding-policy.js";
20
20
  import { gateFindingsByPathFreshness } from "../clients/advisory-provenance.js";
21
- import { markUnreconciledFindings } from "../clients/finding-delivery-gate.js";
21
+ import { formatCacheAgeLabel, markUnreconciledFindings, } from "../clients/finding-delivery-gate.js";
22
22
  import { normalizeRuleId } from "../clients/dispatch/rule-id-normalize.js";
23
23
  import { applyRulePolicy, } from "../clients/dispatch/rule-policy.js";
24
24
  import { compactRenderResult } from "./render-compact.js";
25
25
  import { combineAbortSignals } from "../clients/deadline-utils.js";
26
26
  import { getProjectIgnoreMatcher } from "../clients/file-utils.js";
27
- import { isAtOrAboveHomeDir, normalizeEphemeralMapKey, normalizeFilePath, realpathOrResolve, } from "../clients/path-utils.js";
27
+ import { isAtOrAboveHomeDir, normalizeEphemeralMapKey, normalizeFilePath, normalizeMapKey, realpathOrResolve, } from "../clients/path-utils.js";
28
28
  import { getLSPService } from "../clients/lsp/index.js";
29
29
  import { retireInlineBlockerAndResyncGuard } from "../clients/git-guard.js";
30
30
  import { primaryServerId, resolveLspCwdForFile, } from "../clients/lsp/config.js";
@@ -47,7 +47,7 @@ import { findAuxiliaryProfileForSource, retagAuxiliaryDiagnostics, } from "../cl
47
47
  import { detectFileRole } from "../clients/file-role.js";
48
48
  import { STALE_LINE_MARKER } from "../clients/stale-marker.js";
49
49
  import { makeProgressReporter, scanningSummaryLine } from "./scan-progress.js";
50
- import { createLspDiagnosticsTool, LSP_SEVERITY_FILTERS, MAX_BATCH_FILES, } from "./lsp-diagnostics.js";
50
+ import { createLspDiagnosticsTool, LSP_SEVERITY_FILTERS, MAX_BATCH_FILES, renderBatchOutcomeLines, } from "./lsp-diagnostics.js";
51
51
  import { demotePastEofDiagnostics, PAST_EOF_STALE_MARKER, resyncDocumentOnPastEof, } from "../clients/diagnostic-line-freshness.js";
52
52
  // The widget state exposes the full per-file diagnostic set; this is the tool's
53
53
  // own generous display budget per file (independent of the TUI's 12 cap), to
@@ -197,11 +197,15 @@ captureLspStatusRepaint, getRuntime, isLensGuardEnabled = () => true) {
197
197
  if ((details.unconfirmedFiles ?? 0) > 0)
198
198
  return `lens_diagnostics${scope} — ${count} ${noun} · ${details.cleanFiles ?? 0} clean · ${details.unconfirmedFiles} unconfirmed${details.timedOutFiles ? ` (${details.timedOutFiles} timed out)` : ""}`;
199
199
  const outcomeCounts = details.outcomeCounts;
200
+ const tooLargeLines = renderBatchOutcomeLines(details.outcomes ?? []);
201
+ if (tooLargeLines.length > 0)
202
+ return `lens_diagnostics${scope} — too large: ${tooLargeLines.join("; ")}`;
200
203
  const notConfirmed = outcomeCounts
201
204
  ? (outcomeCounts.inconclusive ?? 0) +
202
205
  (outcomeCounts.unavailable ?? 0) +
203
206
  (outcomeCounts.unsupported ?? 0) +
204
- (outcomeCounts.failed ?? 0)
207
+ (outcomeCounts.failed ?? 0) +
208
+ (outcomeCounts.too_large ?? 0)
205
209
  : 0;
206
210
  if (notConfirmed > 0)
207
211
  return `lens_diagnostics${scope} — ${count} ${noun} · ${notConfirmed} checks not confirmed`;
@@ -508,16 +512,19 @@ function formatProjectDeltaDiagnostic(diagnostic, stale) {
508
512
  * rule/message), live -> unchanged. See `clients/finding-delivery-gate.ts`
509
513
  * surface `lens-diagnostics:mode-delta`.
510
514
  */
511
- function appendProjectDiagnosticsDeltaLines(lines, cwd, report, severity, includeFile) {
515
+ function appendProjectDiagnosticsDeltaLines(groups, cwd, report, severity, includeFile) {
512
516
  const scoped = (report?.diagnostics ?? []).filter((diagnostic) => includeFile(diagnostic.filePath) &&
513
517
  matchesSeverity(projectDiagnosticToWidget(diagnostic, diagnostic.filePath), severity));
514
- const gated = gateFindingsByPathFreshness({
515
- store: "lens-diagnostics-delta-project",
516
- findings: scoped,
518
+ const { "lens-diagnostics-delta-project": gated } = gateFindingsByPathFreshness({
517
519
  cwd,
518
- scannedAt: report?.generatedAt,
519
- citedPath: (d) => d.filePath,
520
- onMissing: "drop",
520
+ sources: {
521
+ "lens-diagnostics-delta-project": {
522
+ findings: scoped,
523
+ scannedAt: report?.generatedAt,
524
+ citedPath: (d) => d.filePath,
525
+ onMissing: "drop",
526
+ },
527
+ },
521
528
  });
522
529
  const staleSet = new Set(gated.stale);
523
530
  // Concatenating live-then-stale reorders a file's demoted rows to the end
@@ -532,11 +539,9 @@ function appendProjectDiagnosticsDeltaLines(lines, cwd, report, severity, includ
532
539
  byFile.set(filePath, bucket);
533
540
  }
534
541
  for (const [filePath, fileDiagnostics] of byFile) {
535
- const rel = path.relative(cwd, filePath);
536
- if (!lines.includes(rel))
537
- lines.push(rel);
542
+ const group = getDeltaFileGroup(groups, cwd, filePath);
538
543
  for (const diagnostic of fileDiagnostics) {
539
- lines.push(formatProjectDeltaDiagnostic(diagnostic, staleSet.has(diagnostic)));
544
+ group.projectLines.push(formatProjectDeltaDiagnostic(diagnostic, staleSet.has(diagnostic)));
540
545
  }
541
546
  }
542
547
  return diagnostics.length;
@@ -704,13 +709,16 @@ function applyDeltaFreshnessGate(files, cwd, generatedAt) {
704
709
  }
705
710
  if (flat.length === 0)
706
711
  return files;
707
- const gated = gateFindingsByPathFreshness({
708
- store: "lens-diagnostics-delta",
709
- findings: flat,
712
+ const { "lens-diagnostics-delta": gated } = gateFindingsByPathFreshness({
710
713
  cwd,
711
- scannedAt: effectiveAt,
712
- citedPath: (f) => f.filePath,
713
- onMissing: "drop",
714
+ sources: {
715
+ "lens-diagnostics-delta": {
716
+ findings: flat,
717
+ scannedAt: effectiveAt,
718
+ citedPath: (f) => f.filePath,
719
+ onMissing: "drop",
720
+ },
721
+ },
714
722
  });
715
723
  // Two passes (live, then stale) reorder a file's demoted rows to the end
716
724
  // of its warnings array rather than the original report order — cosmetic
@@ -723,7 +731,15 @@ function applyDeltaFreshnessGate(files, cwd, generatedAt) {
723
731
  }
724
732
  for (const f of gated.stale) {
725
733
  const arr = byFile.get(f.filePath) ?? [];
726
- arr.push({ ...f.warning, stale: true, line: undefined });
734
+ // Fix B (#3167): carry the stamp the row was judged against so the render
735
+ // can emit one age label per file group — the row's own observation stamp,
736
+ // not the report-level one (the #2504 r4 multi-stamp case).
737
+ arr.push({
738
+ ...f.warning,
739
+ stale: true,
740
+ line: undefined,
741
+ staleAsOf: effectiveAt,
742
+ });
727
743
  byFile.set(f.filePath, arr);
728
744
  }
729
745
  return files
@@ -733,10 +749,34 @@ function applyDeltaFreshnessGate(files, cwd, generatedAt) {
733
749
  }))
734
750
  .filter((file) => file.warnings.length > 0);
735
751
  }
752
+ function getDeltaFileGroup(groups, cwd, filePath) {
753
+ const rel = path.relative(cwd, filePath);
754
+ let group = groups.get(rel);
755
+ if (!group) {
756
+ group = {
757
+ rel,
758
+ actionableLines: [],
759
+ qualityLines: [],
760
+ projectLines: [],
761
+ incomplete: false,
762
+ };
763
+ groups.set(rel, group);
764
+ }
765
+ return group;
766
+ }
736
767
  // @delivery-surface: lens-diagnostics:mode-delta
737
768
  function formatDeltaMode(cacheManager, cwd, severity, pathsScope) {
738
769
  const actionableEntry = cacheManager.readCache("actionable-warnings", cwd);
739
770
  const qualityEntry = cacheManager.readCache("code-quality-warnings", cwd);
771
+ // #3170: files whose re-verify pass could not complete inside its budget —
772
+ // their rows render as carried, plus this explicit gap label (never a
773
+ // false clean). Computed from the RAW actionable-warnings cache entries
774
+ // because the freshness pipeline below rebuilds the file shape. The
775
+ // re-verify only ever writes the actionable-warnings cache — the quality
776
+ // report's file shape carries no such marker.
777
+ const reverifyIncompletePaths = new Set((actionableEntry?.data?.files ?? [])
778
+ .filter((file) => file.reVerifyIncomplete)
779
+ .map((file) => normalizeMapKey(file.filePath)));
740
780
  const actionable = actionableEntry?.data;
741
781
  const quality = qualityEntry?.data;
742
782
  // Project rule policy (`.pi-lens.json` `rules.<id>.disable` /
@@ -778,32 +818,69 @@ function formatDeltaMode(cacheManager, cwd, severity, pathsScope) {
778
818
  warnings: matchingWarnings(file.warnings),
779
819
  }))
780
820
  .filter((file) => file.warnings.length > 0);
781
- const lines = [];
821
+ // #3196: one grouping pass keyed by file — every tier below appends into
822
+ // the SAME per-file group (see `DeltaFileGroup`/`getDeltaFileGroup`), so a
823
+ // file present in more than one report always renders its rows under its
824
+ // own header, wherever that header ends up in the final buffer.
825
+ const groups = new Map();
782
826
  // Fixable warnings from actionable-warnings and quality cache entries retain
783
827
  // their own severity tier. Apply the same threshold semantics as the LSP path.
828
+ // F5/F10 (#3168): at most ONE label group (age and/or #3170's re-verify
829
+ // gap) per file. The actionable tier is folded first, so its stale row (if
830
+ // any) wins the group's age label; the quality tier only supplies one when
831
+ // the actionable tier had none for that file (#3168 F10(c)'s quality-only
832
+ // shape). The `(re-verify incomplete)` flag is a fact about the file (from
833
+ // the raw actionable cache, independent of which tier's rows happen to
834
+ // render it), so it is recorded on the group the first time either tier
835
+ // touches that file and never predicted.
784
836
  if (filteredActionableFiles.length > 0) {
785
837
  for (const file of filteredActionableFiles) {
786
- const rel = path.relative(cwd, file.filePath);
787
- lines.push(`${rel}`);
838
+ const group = getDeltaFileGroup(groups, cwd, file.filePath);
788
839
  for (const w of file.warnings) {
789
840
  const where = w.stale ? STALE_LINE_MARKER : `L${w.line ?? "?"}`;
790
- lines.push(` ⚠ ${where} ${w.rule ?? w.code ?? w.tool} ${w.message}`);
841
+ group.actionableLines.push(` ⚠ ${where} ${w.rule ?? w.code ?? w.tool} ${w.message}`);
842
+ }
843
+ const key = normalizeMapKey(file.filePath);
844
+ if (reverifyIncompletePaths.has(key))
845
+ group.incomplete = true;
846
+ if (!group.staleRow) {
847
+ const staleWarning = file.warnings.find((w) => w.stale);
848
+ if (staleWarning)
849
+ group.staleRow = { staleAsOf: staleWarning.staleAsOf };
791
850
  }
792
851
  }
793
852
  }
794
853
  // Quality issues
795
854
  if (filteredQualityFiles.length > 0) {
796
855
  for (const file of filteredQualityFiles) {
797
- const rel = path.relative(cwd, file.filePath);
798
- if (!lines.includes(rel))
799
- lines.push(rel);
856
+ const group = getDeltaFileGroup(groups, cwd, file.filePath);
800
857
  for (const w of file.warnings) {
801
858
  const where = w.stale ? STALE_LINE_MARKER : `L${w.line ?? "?"}`;
802
- lines.push(` ℹ ${where} ${w.rule ?? w.code ?? w.tool} ${w.message}`);
859
+ group.qualityLines.push(` ℹ ${where} ${w.rule ?? w.code ?? w.tool} ${w.message}`);
803
860
  }
861
+ const key = normalizeMapKey(file.filePath);
862
+ if (reverifyIncompletePaths.has(key))
863
+ group.incomplete = true;
864
+ if (!group.staleRow) {
865
+ const staleWarning = file.warnings.find((w) => w.stale);
866
+ if (staleWarning)
867
+ group.staleRow = { staleAsOf: staleWarning.staleAsOf };
868
+ }
869
+ }
870
+ }
871
+ const projectDeltaCount = appendProjectDiagnosticsDeltaLines(groups, cwd, projectDelta, severity, includeFile);
872
+ const lines = [];
873
+ for (const group of groups.values()) {
874
+ lines.push(group.rel);
875
+ lines.push(...group.actionableLines);
876
+ lines.push(...group.qualityLines);
877
+ lines.push(...group.projectLines);
878
+ if (group.staleRow) {
879
+ lines.push(` (${formatCacheAgeLabel(group.staleRow.staleAsOf)})`);
804
880
  }
881
+ if (group.incomplete)
882
+ lines.push(" (re-verify incomplete)");
805
883
  }
806
- const projectDeltaCount = appendProjectDiagnosticsDeltaLines(lines, cwd, projectDelta, severity, includeFile);
807
884
  const selectedActionableFiles = filteredActionableFiles;
808
885
  const selectedQualityFiles = filteredQualityFiles;
809
886
  const aw = selectedActionableFiles.reduce((count, file) => count + file.warnings.length, 0);
@@ -1093,11 +1170,20 @@ function runnerIdOf(diagnostic) {
1093
1170
  }
1094
1171
  /**
1095
1172
  * The one project-runner retirement decision. Coverage is authoritative when
1096
- * present for the runner; the older id-only arm is used only when absent.
1173
+ * present for the runner; the older id-only arm is used only when the runner
1174
+ * declared no coverage at all.
1175
+ *
1176
+ * #2962: an entry with an EMPTY file set is a declaration, not a missing one —
1177
+ * "this producer analysed the root and proved zero files". Selecting it out by
1178
+ * its own size (the pre-#2962 `entry.files.size > 0` filter) dropped it into the
1179
+ * whole-root id-only arm, so one complete opengrep report with
1180
+ * `paths.scanned: []` retired every retained opengrep finding in the tree even
1181
+ * though no file had been scanned. The id-only arm is unchanged for the eight
1182
+ * runners that declare no coverage.
1097
1183
  */
1098
1184
  export function runnerRetirementDecision(diagnostic, filePath, authoritativeRunnerIds, authoritativeCoverage) {
1099
1185
  const runnerId = runnerIdOf(diagnostic);
1100
- const coverage = (authoritativeCoverage ?? []).filter((entry) => entry.runnerId === runnerId && entry.files.size > 0);
1186
+ const coverage = (authoritativeCoverage ?? []).filter((entry) => entry.runnerId === runnerId);
1101
1187
  if (coverage.length === 0) {
1102
1188
  return authoritativeRunnerIds?.has(runnerId) ? "retire" : "keep";
1103
1189
  }
@@ -1792,6 +1878,14 @@ async function formatFullMode(cwd, severity, lspService, cacheManager, options =
1792
1878
  });
1793
1879
  }
1794
1880
  for (const entry of authoritativeRunnerCoverage) {
1881
+ // #2962: a zero-file declaration retires nothing, so it must neither
1882
+ // write this row (a `runner_coverage_retired` row with count 0 would
1883
+ // report the opposite of what happened) nor BURN the once-per-session
1884
+ // claim — the session's first real coverage row would then be suppressed
1885
+ // by the call that proved no coverage. Its own observation is the
1886
+ // `runner-coverage-empty` ledger row raised at the fresh-fetch seam.
1887
+ if (entry.files.size === 0)
1888
+ continue;
1795
1889
  if (!claimPhaseOncePerSession("runner_coverage_retired", entry.runnerId)) {
1796
1890
  continue;
1797
1891
  }