pi-lens 4.1.1 → 4.1.2

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 (186) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/dist/clients/actionable-warnings-logger.js +2 -1
  3. package/dist/clients/actionable-warnings.js +14 -5
  4. package/dist/clients/advisory-provenance.js +48 -18
  5. package/dist/clients/ast-grep-client.js +1 -3
  6. package/dist/clients/atomic-write-staging.js +16 -4
  7. package/dist/clients/bash-file-access.js +3 -1
  8. package/dist/clients/biome-client.js +1 -3
  9. package/dist/clients/blocker-freshness.js +21 -8
  10. package/dist/clients/bounded-cache.js +24 -8
  11. package/dist/clients/bounded-pid-file-lock.js +4 -3
  12. package/dist/clients/cache-manager.js +6 -7
  13. package/dist/clients/cache-observability.js +461 -66
  14. package/dist/clients/call-graph.js +238 -63
  15. package/dist/clients/codebase-model.js +13 -5
  16. package/dist/clients/complexity-client.js +1 -3
  17. package/dist/clients/dead-code-client.js +19 -8
  18. package/dist/clients/dead-code-logger.js +1 -2
  19. package/dist/clients/deadline-utils.js +3 -1
  20. package/dist/clients/debug-heap.js +8 -1
  21. package/dist/clients/degradation-ledger.js +1 -1
  22. package/dist/clients/dependency-checker.js +11 -7
  23. package/dist/clients/diagnostic-dispositions.js +39 -2
  24. package/dist/clients/diagnostic-line-freshness.js +5 -1
  25. package/dist/clients/dispatch/auxiliary-lsp.js +2 -3
  26. package/dist/clients/dispatch/dispatcher.js +13 -1
  27. package/dist/clients/dispatch/facts/function-facts.js +3 -1
  28. package/dist/clients/dispatch/facts/import-facts.js +2 -1
  29. package/dist/clients/dispatch/facts/tree-sitter-facts.js +1 -1
  30. package/dist/clients/dispatch/integration.js +9 -4
  31. package/dist/clients/dispatch/rules/error-swallowing.js +3 -1
  32. package/dist/clients/dispatch/rules/pass-through-wrappers.js +6 -2
  33. package/dist/clients/dispatch/rules/unsafe-boundary.js +31 -7
  34. package/dist/clients/dispatch/runners/actionlint.js +5 -2
  35. package/dist/clients/dispatch/runners/biome-check.js +10 -14
  36. package/dist/clients/dispatch/runners/cpp-check.js +5 -1
  37. package/dist/clients/dispatch/runners/dotnet-build.js +1 -1
  38. package/dist/clients/dispatch/runners/elixir-check.js +3 -1
  39. package/dist/clients/dispatch/runners/eslint.js +25 -6
  40. package/dist/clients/dispatch/runners/fish-indent.js +1 -1
  41. package/dist/clients/dispatch/runners/gleam-check.js +1 -1
  42. package/dist/clients/dispatch/runners/golangci-lint.js +3 -1
  43. package/dist/clients/dispatch/runners/hadolint.js +13 -8
  44. package/dist/clients/dispatch/runners/helm-lint.js +4 -1
  45. package/dist/clients/dispatch/runners/htmlhint.js +25 -10
  46. package/dist/clients/dispatch/runners/javac.js +1 -1
  47. package/dist/clients/dispatch/runners/ktlint.js +1 -1
  48. package/dist/clients/dispatch/runners/lsp.js +1 -1
  49. package/dist/clients/dispatch/runners/markdownlint.js +28 -4
  50. package/dist/clients/dispatch/runners/mypy.js +6 -8
  51. package/dist/clients/dispatch/runners/oxlint.js +212 -11
  52. package/dist/clients/dispatch/runners/php-lint.js +1 -1
  53. package/dist/clients/dispatch/runners/phpstan.js +8 -4
  54. package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -6
  55. package/dist/clients/dispatch/runners/pyright.js +23 -14
  56. package/dist/clients/dispatch/runners/rust-clippy.js +1 -1
  57. package/dist/clients/dispatch/runners/shellcheck.js +7 -9
  58. package/dist/clients/dispatch/runners/shfmt.js +5 -2
  59. package/dist/clients/dispatch/runners/sqlfluff.js +7 -7
  60. package/dist/clients/dispatch/runners/stylelint.js +6 -8
  61. package/dist/clients/dispatch/runners/terragrunt.js +13 -8
  62. package/dist/clients/dispatch/runners/tflint.js +13 -8
  63. package/dist/clients/dispatch/runners/tree-sitter.js +17 -3
  64. package/dist/clients/dispatch/runners/utils/candidate-probe.js +7 -1
  65. package/dist/clients/dispatch/runners/utils/runner-helpers.js +24 -1
  66. package/dist/clients/dispatch/runners/utils/tool-failure.js +35 -0
  67. package/dist/clients/dispatch/runners/yaml-rule-parser.js +1 -3
  68. package/dist/clients/dispatch/runners/yamllint.js +6 -8
  69. package/dist/clients/dispatch/runners/zig-check.js +1 -1
  70. package/dist/clients/dispatch/suppress-writer.js +2 -1
  71. package/dist/clients/dispatch/types.js +6 -1
  72. package/dist/clients/dispatch/utils/lsp-diagnostics.js +6 -2
  73. package/dist/clients/extension-log.js +5 -3
  74. package/dist/clients/feature-hints.js +8 -1
  75. package/dist/clients/file-kinds.js +34 -129
  76. package/dist/clients/file-utils.js +17 -1
  77. package/dist/clients/finding-delivery-gate.js +57 -2
  78. package/dist/clients/formatters.js +144 -27
  79. package/dist/clients/freshness.js +38 -0
  80. package/dist/clients/git-guard.js +43 -13
  81. package/dist/clients/go-client.js +1 -3
  82. package/dist/clients/grammar-source.js +1 -1
  83. package/dist/clients/gzip-stage-write.js +30 -4
  84. package/dist/clients/host-edit-normalize.js +1 -3
  85. package/dist/clients/install-diagnostics.js +4 -2
  86. package/dist/clients/installer/index.js +86 -77
  87. package/dist/clients/instance-reaper.js +5 -2
  88. package/dist/clients/instance-registry.js +4 -2
  89. package/dist/clients/jscpd-client.js +6 -1
  90. package/dist/clients/knip-client.js +29 -15
  91. package/dist/clients/language-profile.js +4 -1
  92. package/dist/clients/latency-logger.js +13 -2
  93. package/dist/clients/lens-config.js +1 -1
  94. package/dist/clients/lens-map.js +8 -3
  95. package/dist/clients/live-bus-emitter.js +1 -1
  96. package/dist/clients/lsp/config.js +4 -6
  97. package/dist/clients/lsp/edits.js +119 -32
  98. package/dist/clients/lsp/index.js +94 -35
  99. package/dist/clients/lsp/inferred-project.js +2 -1
  100. package/dist/clients/lsp/jvm-runtime.js +4 -1
  101. package/dist/clients/lsp/launch.js +11 -3
  102. package/dist/clients/lsp/pending-aux-coverage.js +152 -0
  103. package/dist/clients/lsp/server.js +19 -7
  104. package/dist/clients/lsp/tsserver-sync.js +10 -2
  105. package/dist/clients/lsp/workspace-diagnostics-cache.js +9 -4
  106. package/dist/clients/lsp-document-symbols.js +1 -3
  107. package/dist/clients/lsp-mutation.js +10 -20
  108. package/dist/clients/mcp/host-shim.js +1 -1
  109. package/dist/clients/memory-sampler.js +73 -7
  110. package/dist/clients/metrics-client.js +1 -3
  111. package/dist/clients/middle-man-analysis.js +8 -3
  112. package/dist/clients/module-report-lsp.js +7 -1
  113. package/dist/clients/module-report.js +14 -17
  114. package/dist/clients/ndjson-logger.js +3 -5
  115. package/dist/clients/opaque-mutation-scan.js +220 -0
  116. package/dist/clients/package-manager.js +6 -1
  117. package/dist/clients/pipeline.js +57 -9
  118. package/dist/clients/project-conventions.js +2 -8
  119. package/dist/clients/project-diagnostics/cache.js +10 -7
  120. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +2 -1
  121. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +3 -1
  122. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +13 -9
  123. package/dist/clients/project-diagnostics/runner-adapters/trivy.js +3 -1
  124. package/dist/clients/project-diagnostics/scanner.js +5 -2
  125. package/dist/clients/project-lens-config.js +12 -5
  126. package/dist/clients/project-snapshot-fingerprint.js +48 -0
  127. package/dist/clients/project-snapshot-persist-worker.js +15 -1
  128. package/dist/clients/project-snapshot.js +472 -66
  129. package/dist/clients/read-expansion.js +12 -2
  130. package/dist/clients/read-guard-logger.js +5 -4
  131. package/dist/clients/read-guard-tool-lines.js +5 -2
  132. package/dist/clients/read-guard.js +1 -1
  133. package/dist/clients/resource-sampler.js +1 -1
  134. package/dist/clients/reverse-deps.js +6 -5
  135. package/dist/clients/review-graph/builder.js +42 -17
  136. package/dist/clients/review-graph/git-identity.js +8 -2
  137. package/dist/clients/review-graph/import-resolvers.js +10 -1
  138. package/dist/clients/review-graph/query.js +2 -1
  139. package/dist/clients/review-graph/tsconfig-paths.js +4 -2
  140. package/dist/clients/review-graph/workspace-modules.js +2 -2
  141. package/dist/clients/ruff-client.js +6 -5
  142. package/dist/clients/runtime-agent-end.js +63 -31
  143. package/dist/clients/runtime-context.js +12 -4
  144. package/dist/clients/runtime-coordinator.js +83 -4
  145. package/dist/clients/runtime-session.js +14 -0
  146. package/dist/clients/runtime-tool-call.js +59 -3
  147. package/dist/clients/runtime-tool-result.js +148 -34
  148. package/dist/clients/runtime-turn.js +147 -6
  149. package/dist/clients/rust-client.js +1 -3
  150. package/dist/clients/safe-spawn.js +150 -11
  151. package/dist/clients/security-scan-client.js +1 -3
  152. package/dist/clients/session-summary.js +3 -1
  153. package/dist/clients/sg-runner.js +1 -3
  154. package/dist/clients/source-filter.js +10 -6
  155. package/dist/clients/source-walker.js +1 -1
  156. package/dist/clients/spawn-timeout-cooldown.js +96 -0
  157. package/dist/clients/startup-scan.js +4 -1
  158. package/dist/clients/tool-definition.js +7 -0
  159. package/dist/clients/tool-policy.js +26 -11
  160. package/dist/clients/tool-render.js +2 -1
  161. package/dist/clients/tree-sitter-client.js +10 -4
  162. package/dist/clients/tree-sitter-logger.js +2 -2
  163. package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
  164. package/dist/clients/warm-attach.js +12 -5
  165. package/dist/clients/widget-state.js +27 -11
  166. package/dist/clients/word-index.js +3 -1
  167. package/dist/clients/workspace-topology.js +8 -2
  168. package/dist/clients/zizmor-config.js +9 -1
  169. package/dist/i18n.js +13 -5
  170. package/dist/index.js +10797 -8627
  171. package/dist/mcp/server.js +3 -1
  172. package/dist/tools/ast-grep-replace.js +68 -12
  173. package/dist/tools/lens-diagnostic-mark.js +21 -7
  174. package/dist/tools/lens-diagnostics.js +164 -35
  175. package/dist/tools/lsp-diagnostics.js +24 -7
  176. package/dist/tools/lsp-navigation.js +3 -1
  177. package/dist/tools/module-report.js +1 -1
  178. package/dist/tools/project-report.js +1 -1
  179. package/dist/tools/symbol-search.js +5 -2
  180. package/docs/durable-store-audit-1202.md +1 -1
  181. package/docs/servercapabilities.md +1 -1
  182. package/package.json +160 -156
  183. package/rules/typos/_typos.toml +2 -2
  184. package/scripts/analyze-pi-lens-logs.mjs +31 -13
  185. package/scripts/download-grammars.js +9 -3
  186. package/scripts/rpc-load-check.mjs +30 -9
package/CHANGELOG.md CHANGED
@@ -16,7 +16,7 @@ All notable changes to pi-lens will be documented in this file.
16
16
 
17
17
  ### Security
18
18
 
19
- ## [4.1.1] - 2026-08-21
19
+ ## [4.1.2] - 2026-08-24
20
20
 
21
21
  ### Added
22
22
 
@@ -44,6 +44,52 @@ All notable changes to pi-lens will be documented in this file.
44
44
 
45
45
  - **latency.log now says whether the LSP pool reused a client or spawned one (closes #1934)** — `lsp_client_selected` fired 5601 times in a 20.8h dogfood window carrying only `{serverId, candidateCount}`, so nothing in the log said whether the pool served a warm client or paid for a language-server spawn. This is the most expensive cache in pi-lens: a miss costs a process. The only estimate was a cross-record inference against `lsp_launch_candidate_success`, which counts something else, so a regression that halved pool reuse was invisible. The record now carries an `outcome` field with one of three values on the same record and the same denominator. `warm-reuse` means the pool served an already-connected client. `cold-spawn` means this selection waited on a spawn, whether it started that spawn or joined one already in flight, so a session-start burst where many files wait on the same spawn reads as several cold spawns and deflates the apparent reuse rate for that window. `spawn-failure` means a spawn ran and failed. Reuse rate is `warm-reuse / (warm-reuse + cold-spawn)`. A spawn failure is deliberately distinct from a clean decline: no root, a breaker already open, host trust refused, or a binary absent while installs are disabled. Declines emit no outcome record and keep their existing ones, so `lsp_client_unavailable` and the `outcome` field can both describe the same event and are counted separately. `getWarmClientForFile`, the warm-only lookup the cascade quiet window and read expansion call per file, previously returned `undefined` and emitted nothing; it now emits a bounded `lsp_warm_client_missing` record naming the candidate server and root, rising-edge per candidate set with the exact count in the degradation ledger.
46
46
 
47
+ - **Dogfood sessions on pi-lens's own repo now activate the oxlint, spellcheck, yamllint, and taplo runner lanes (refs #1844)** — four new root config files (`.oxlintrc.json`, `typos.toml`, `.yamllint`, `taplo.toml`) opt pi-lens's own repository into linters it already ships for every other project. `.oxlintrc.json` pins `oxlint` as a new exact-version devDependency (1.79.0, matching the runner's tested fixture), makes oxlint this repo's sole preferred JS/TS lint runner (dropping the `biome-check-json` fallback — that IS the dogfood intent), and excludes the fixture directories that carry no config override of their own; oxlint's nested-config discovery still finds the two fixture directories that deliberately escalate severity for the runner's own smoke tests, so those stay as they are. The oxlint runner also now tells a config-excluded file (`number_of_files: 0`) apart from a genuinely clean one, reporting `skipped` instead of a false `succeeded`. `typos.toml` allowlists evidenced false positives (a routine "mis-" prefix, HashiCorp's brand abbreviation, and similar); its presence enables typos-lsp's blocking opt-in, but findings stay advisory in practice since typos-lsp's own default diagnostic severity is warning. `.yamllint` scopes yamllint to hand-authored YAML (GitHub Actions workflows, top-level config), downgrades line-length to a warning rather than raising its threshold, and defuses the `on:` truthy false positive. `taplo.toml` activates both the TOML linter and the `taplo fmt` autofix formatter, and this PR runs that formatter once so activation doesn't start the lane red. Markdown linting was already live via the pre-existing `.markdownlint-cli2.jsonc` (since #1917) — confirmed with a real `markdownlint-cli2` run, no new config needed.
48
+
49
+ - **Memory-sample observability upgrades (refs #1999)** — The periodic
50
+ `memory_sample` latency.log line gains three fields. `peakWorkingSetBytes`
51
+ records the OS high-water working set from `process.resourceUsage().maxRSS`,
52
+ so an idle-moment rss sample can be told apart from true growth (on Windows
53
+ libuv backs both counters with one `GetProcessMemoryInfo()` call: rss reads
54
+ current `WorkingSetSize`, maxRSS reads `PeakWorkingSetSize`). A rising-edge
55
+ cadence tightens sampling from every 10 turns to every turn while heapUsed
56
+ grows more than 20% between samples, reverting once growth stabilizes; the
57
+ state resets at each primary session start. Each sample now also carries
58
+ session age, session start time, and turn count, so growth-vs-age curves are
59
+ plottable from logs alone. The `/lens-health` memory line shows peak WS when
60
+ known.
61
+
62
+ - **Opaque-write recovery for bash commands (refs #2000 phase 2)** — commands the path extractor does not recognize (python/node/perl/PowerShell internal writes) now get a bounded pre/post stat diff of the project source universe. Recovered files are attributed to the read guard as agent-authored and dispatched through the mutation seam, with explicit coverage-unknown telemetry instead of silent gaps.
63
+
64
+ - **Collect-later delivery for slow auxiliary LSP servers (closes #2001, refs #2002)** —
65
+ When an auxiliary scanner such as opengrep misses its aux-grace window,
66
+ pi-lens now marks the file and server pair in a bounded pending store
67
+ instead of dropping the scanner's eventual findings. The next turn end
68
+ probes the auxiliary's client cache through a read-only seam,
69
+ freshness-gates the result against the mark timestamp, and delivers
70
+ survivors as a `Late auxiliary diagnostics` advisory. A cited file that was
71
+ edited or deleted since the mark drops its findings, and both drop arms are
72
+ counted in the new `late_auxiliary_findings` latency record.
73
+
74
+ - **Freshness kernel: one comparator, one verdict type for staleness gates (closes #1739)** — `clients/freshness.ts` owns the mtime-vs-reference comparison and shared drift tolerance that six stores had independently reimplemented (three copies carried the identical #1710/#1711 tolerance defect). `freshnessFromMtime` returns an explicit verdict (`fresh` / `stale: modified-after-reference` / `indeterminate: no-mtime-evidence`) so each caller keeps its own no-evidence policy while sharing the comparison. A registered-or-fail sweep fails on any new out-of-kernel mtime-vs-reference comparison in `clients/`.
75
+
76
+ - **Shared fault-injection test kit (closes #1838)** — `tests/support/fault-injection.ts` promotes the bespoke fault probes reviewers kept rebuilding by hand into four one-call primitives: `spawnWedgedChild` (a real child whose stdin pipe is genuinely full — the #1811 fixture generalized, with its fail-fast-instead-of-hang trap asserted against), `delayInside` (deterministic completion delay inside any mocked async seam), `fireResetAt` (fire a lifecycle hook from inside a seam's implementation at a chosen call — the #1746-R2-F1 shape), and `starveBudget` plus `gatedPromise` (the tiny-budget starvation repro). Every primitive carries its own fidelity test in `fault-injection.test.ts`, so a neutered primitive goes red in CI instead of silently weakening every consumer.
77
+
78
+ - **Adversarial garbage battery for every CLI lint runner (closes #1839)** — Twenty runners now meet a 12-case battery of hostile outputs (truncated JSON, usage prose on the findings stream, unknown severities, hostile numbers) under format-blind invariants: never crash, never emit malformed diagnostics, never report clean on a nonzero exit carrying bytes. The first pass found 79 violations of that last invariant across 14 runners — all fixed by consolidating their identical tails onto one shared `finishParsedRun` seam. Also fixes htmlhint's `--rules` flag being fed JSON (the tool wants a ruleid list), which left zero rules enabled so every file read clean.
79
+
80
+ ### Changed
81
+
82
+ - **pi-lens formats its own TypeScript with oxfmt (refs #1844)** — The
83
+ repository now carries an `.oxfmtrc.json` and an `oxfmt` devDependency, and
84
+ an advisory `oxfmt --check` job runs in CI. Because `hasOxfmtConfig` gates
85
+ the oxfmt formatter on exactly that config file, a pi-lens session opened on
86
+ this repository now dispatches oxfmt as the format runner for edited
87
+ TypeScript and JavaScript files.
88
+
89
+ - **Opaque-write recovery goes git-first (refs #2000)** — inside a git worktree the pre side records only a timestamp and `git status --porcelain` plus an mtime window answers what changed, with no file-universe cap: recovery now works on any repo size, including large monorepos where the stat-walk previously degraded every command to coverage-unknown. The bounded stat-diff path remains for small non-git trees.
90
+
91
+ - **ONE mutation seam: `RuntimeCoordinator.recordProjectMutation` (refs #2000 phase 1)** — the triplicated bump+change-log pairing (runtime-tool-result, runtime-agent-end, lsp-mutation) is consolidated onto one seam that bumps the seq store, appends a bounded attributed receipt ring (`getMutationsSince`, cap 512 with a surfaced dropped-count), and appends the durable change-log entry. Consumers derive touched-files answers from one store instead of three hand-copied pairings; phase 2's opaque-write recovery feeds the same seam.
92
+
47
93
  ### Fixed
48
94
 
49
95
  - **Search credit ignores pipe truncation (closes #1908)** — `grep -C2 pattern
@@ -106,6 +152,55 @@ All notable changes to pi-lens will be documented in this file.
106
152
 
107
153
  - **Warmup and workspace-scan walkers gate by file extension before consulting the ignore matcher, cutting a 31.7s stall to sub-second on repos with large ignored file piles (closes #1974, reported by @0xkite)** — `isIgnored` recompiles minimatch patterns per ancestor directory on every call; a runtime-output directory like `wal/` holding tens of thousands of `.log` files, gitignored only by a file-level `*.log` pattern (so the directory itself is never pruned), forced every one of those files through that cost. Six walkers checked `isIgnored` before their own cheap extension gate, paying the expensive check for files the gate would have dropped anyway: `collectSourceFilesForWarmup` (`language-profile.ts`, the reported 31.7s case), `JscpdClient.hasSourceFilesRecursive` (`jscpd-client.ts`), `classifyEntry` (`source-filter.ts`), `makeSourceCountVisitor` (`startup-scan.ts`), `getModuleSourceFiles` (`review-graph/workspace-modules.ts`), and `collectWorkspaceDiagnosticFiles` (`lsp/index.ts`). All six now run their extension (or LSP-server) gate first; output is unchanged, since both gates still have to pass to keep a file — only the order changed. A repo-wide sweep of all 14 files that call `isIgnored` found `tree-sitter-client.ts` and `tools/lsp-diagnostics.ts` already extension-first, and cleared the remaining per-event/no-ext-gate sites by construction; see the PR body for the full per-site table.
108
154
 
155
+ - **oxfmt no longer fails on a file its own config ignores (refs #1844)** —
156
+ pi-lens offers oxfmt for every extension oxfmt supports, so in a project
157
+ whose oxfmt config carries `ignorePatterns`, oxfmt was selected for files it
158
+ then refused to touch. It exits 2 with "Expected at least one target file",
159
+ which the strict exit-code posture read as a formatting failure, so every
160
+ edit to an ignored file surfaced an error. pi-lens now passes
161
+ `--no-error-on-unmatched-pattern`, which makes an empty target set a clean
162
+ no-op. Every other nonzero exit, including an unparseable file, still fails.
163
+
164
+ - **ESLint warnings are no longer discarded (closes #1954)** — The eslint runner treated every exit 0 as "nothing found". ESLint exits 0 whenever no rule reaches error severity, so a run that produced only warning-severity findings was thrown away silently. The runner now parses stdout unconditionally and surfaces the findings; a clean file still reports clean, and exit 2 stays an unavailable skip.
165
+
166
+ - **Registered tools now always have descriptions in child/subagent sessions (closes #1988)** — the final registration boundary fills missing, empty, and
167
+ whitespace-only descriptions for active, lazy, activation, and wrapped tool
168
+ definitions.
169
+
170
+ - **mode=full no longer replays stale mid-edit blockers (refs #1993)** — a confirmed, fully-covered LSP sweep is now authoritative for its files: widget-store diagnostics captured from a since-fixed broken intermediate state are retired instead of rendering as current 🔴 blockers beside a clean sweep. Unconfirmed or timed-out sweeps keep the fail-open behavior.
171
+
172
+ - **Cache-miss attribution stays useful in long sessions (closes #1996)** — classify full model/provider identities and split unexplained or malformed cache evidence by bounded reason, with fail-closed request hashing, activation-owned primary/secondary isolation, sanitized numeric metadata, and a per-session cause summary.
173
+
174
+ - **Project snapshots no longer rewrite unchanged same-generation bodies (closes #1997)** — Persistence now coalesces concurrent requests and computes semantic identity on its worker before gzip or staging. Same-generation content changes and failed-write repairs still publish.
175
+
176
+ - **Classify oxlint no-match results as expected skips (closes #1998)**. Oxlint now carries `no-files-matched` through runner telemetry without emitting an extension error or claiming the file is clean. A single fail-closed state machine validates process completion, exit status, stderr, the captured banner, and every JSON summary field's type and range; truncated, malformed, wrong-status, or error-bearing lookalikes retain failure or unconfirmed telemetry.
177
+
178
+ - **Project-snapshot persistence validates gzip body integrity before skipping (closes #2008)** —
179
+ The skip decision trusted the meta sidecar and a fingerprint match alone, so a torn or
180
+ truncated gzip under an intact meta kept winning unchanged-dedupe and stayed canonical until
181
+ the project sequence advanced. The meta sidecar now records the on-disk gz byte length
182
+ (`gzBytes`) at every successful persist, and the dedupe baselines compare it against a live
183
+ stat: a size mismatch, or a legacy meta without the field, withholds the dedupe fingerprints
184
+ so the pending save republishes and rewrites the body. A skip that would honor evidence
185
+ failing this gate between dispatch and promotion is refused and rewritten synchronously.
186
+ Detections emit one `project_snapshot_body_integrity` latency record plus a bounded
187
+ `snapshot-integrity` degradation-ledger entry per corrupted body path. The baselines read is
188
+ also now a pure read: its in-process seeding write moved to the single dispatch seam that
189
+ owns the persist lifecycle.
190
+
191
+ - **Installer verify loop broken at the root (closes #2015)** — `verifyToolBinary` now spawns through `safeSpawnAsync` (tree-kill on timeout, typed kill-reason) instead of raw spawn whose SIGTERM orphaned grandchild node processes on Windows `.cmd` shims. A killed/inconclusive prober no longer counts as a verdict: the freshly installed binary is KEPT for cheap re-probe instead of deleted, ending the install/verify/reinstall churn (23 SIGTERMs and 4 cleanups observed in one day).
192
+
193
+ - **Windows `.cmd`/`.bat` spawns no longer fail when System32 is absent from the child PATH (closes #2023)** — The cmd.exe wrapper prefixed every spawn
194
+ with a bare `chcp 65001 &&`. When the child environment's PATH could not
195
+ resolve `chcp.com`, the lookup failed and `&&` short-circuited, so the whole
196
+ spawn exited 1 with empty output. chcp is now invoked via its pinned
197
+ `%SystemRoot%\System32\chcp.com` absolute path and chained with `&`, so a
198
+ code-page failure can never suppress the real command.
199
+
200
+ - **Register remaining agent-facing delivery surfaces (#2028)** — The per-edit 🔴 STOP block now drops blockers whose cited file no longer exists, so a deleted file's stale blocker no longer re-blocks the agent with no remediation. All five previously unregistered surfaces (stop blocker, `lsp_diagnostics` output, git-guard verdicts, read-guard preflight errors, thrashing notices) are registered in the finding-delivery gate registry.
201
+
202
+ - **Wall-clock budget tests get a quiet serial phase (closes #1920)** — Five test files asserting real elapsed-time budgets ran inside the default project's fork storm, measuring scheduler contention instead of code speed (`startup-overhead` measured 659-2321ms against a 500ms budget under load, green solo every time). They now run in a dedicated fully-serialized `wall-clock-budget` Vitest project that phases dead last on a quiet host. A coverage guard keeps the new include list from silently dropping renamed files.
203
+
109
204
  ## [4.1.0] - 2026-08-20
110
205
 
111
206
  ### Added
@@ -5,7 +5,8 @@ import { createNdjsonLogger } from "./ndjson-logger.js";
5
5
  const AW_LOG_DIR = getGlobalPiLensDir();
6
6
  const AW_LOG_FILE = path.join(AW_LOG_DIR, "actionable-warnings.log");
7
7
  const AW_LOG_BACKUP_FILE = path.join(AW_LOG_DIR, "actionable-warnings.log.1");
8
- const MAX_LOG_BYTES = Math.max(128 * 1024, Number.parseInt(process.env.PI_LENS_AW_LOG_MAX_BYTES ?? "1048576", 10) || 1048576);
8
+ const MAX_LOG_BYTES = Math.max(128 * 1024, Number.parseInt(process.env.PI_LENS_AW_LOG_MAX_BYTES ?? "1048576", 10) ||
9
+ 1048576);
9
10
  const writer = createNdjsonLogger({
10
11
  filePath: AW_LOG_FILE,
11
12
  maxBytes: MAX_LOG_BYTES,
@@ -633,7 +633,12 @@ export async function applyConservativeActionableWarningFixes(args) {
633
633
  }
634
634
  const edit = selected.edit;
635
635
  const applied = await applyWorkspaceEdit(edit, args.cwd, args.mutationContext
636
- ? { mutationContext: { ...args.mutationContext, emitSummary: false } }
636
+ ? {
637
+ mutationContext: {
638
+ ...args.mutationContext,
639
+ emitSummary: false,
640
+ },
641
+ }
637
642
  : undefined);
638
643
  appliedResults.push(applied);
639
644
  for (const changedFile of applied.files)
@@ -642,8 +647,7 @@ export async function applyConservativeActionableWarningFixes(args) {
642
647
  }
643
648
  catch (err) {
644
649
  failedCount++;
645
- const partial = err
646
- .appliedWorkspaceEdit;
650
+ const partial = err.appliedWorkspaceEdit;
647
651
  if (partial) {
648
652
  appliedResults.push(partial);
649
653
  for (const changedFile of partial.files)
@@ -656,13 +660,18 @@ export async function applyConservativeActionableWarningFixes(args) {
656
660
  }
657
661
  }
658
662
  summary.changedFiles = [...changedFiles];
659
- if (args.mutationContext && (summary.considered > 0 || appliedResults.length > 0)) {
663
+ if (args.mutationContext &&
664
+ (summary.considered > 0 || appliedResults.length > 0)) {
660
665
  recordLspMutationBatch(args.mutationContext, {
661
666
  results: appliedResults,
662
667
  considered: summary.considered,
663
668
  completed: summary.applied,
664
669
  failedCount,
665
- status: failedCount > 0 ? "failed" : appliedResults.length > 0 ? "success" : "skipped",
670
+ status: failedCount > 0
671
+ ? "failed"
672
+ : appliedResults.length > 0
673
+ ? "success"
674
+ : "skipped",
666
675
  bookkeep: false,
667
676
  });
668
677
  }
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { logLatency } from "./latency-logger.js";
5
5
  import { normalizeMapKey, toProjectRelativePath } from "./path-utils.js";
6
6
  import { resolveRunnerPath } from "./dispatch/runner-context.js";
7
- import { MTIME_DRIFT_TOLERANCE_MS } from "./blocker-freshness.js";
7
+ import { freshnessFromMtime } from "./freshness.js";
8
8
  export const MAX_ADVISORY_AFFECTED_FILES = 256;
9
9
  export function advisoryPathKey(filePath, cwd) {
10
10
  return normalizeMapKey(path.resolve(cwd, filePath));
@@ -68,25 +68,41 @@ function isFiniteNumber(value) {
68
68
  return typeof value === "number" && Number.isFinite(value);
69
69
  }
70
70
  function isCapturedHash(value) {
71
- return typeof value === "string" &&
72
- (/^[a-f0-9]{64}$/.test(value) || value === "missing" || value.startsWith("unreadable:"));
71
+ return (typeof value === "string" &&
72
+ (/^[a-f0-9]{64}$/.test(value) ||
73
+ value === "missing" ||
74
+ value.startsWith("unreadable:")));
73
75
  }
74
76
  function isWellFormed(value) {
75
77
  if (!value || typeof value !== "object")
76
78
  return false;
77
79
  const record = value;
78
80
  const revision = record.revision;
79
- return !!revision && typeof revision.sessionId === "string" &&
80
- isFiniteNumber(revision.projectSeq) && isFiniteNumber(revision.turnIndex) &&
81
- isFiniteNumber(revision.generation) && isFiniteNumber(revision.capturedAt) &&
82
- Array.isArray(record.files) && record.files.length > 0 && record.files.every((file) => !!file && typeof file.path === "string" &&
83
- (file.role === "source" || file.role === "test" || file.role === "affected") &&
84
- isFiniteNumber(file.mtimeMs) && isFiniteNumber(file.size) &&
85
- isCapturedHash(file.sha256));
81
+ return (!!revision &&
82
+ typeof revision.sessionId === "string" &&
83
+ isFiniteNumber(revision.projectSeq) &&
84
+ isFiniteNumber(revision.turnIndex) &&
85
+ isFiniteNumber(revision.generation) &&
86
+ isFiniteNumber(revision.capturedAt) &&
87
+ Array.isArray(record.files) &&
88
+ record.files.length > 0 &&
89
+ record.files.every((file) => !!file &&
90
+ typeof file.path === "string" &&
91
+ (file.role === "source" ||
92
+ file.role === "test" ||
93
+ file.role === "affected") &&
94
+ isFiniteNumber(file.mtimeMs) &&
95
+ isFiniteNumber(file.size) &&
96
+ isCapturedHash(file.sha256)));
86
97
  }
87
98
  export function validateAdvisoryProvenance(record, cwd, runtime) {
88
99
  if (!isWellFormed(record.provenance)) {
89
- return { status: "unknown", reasons: ["malformed-or-legacy-provenance"], allFilesDeleted: false, changedPathCount: 0 };
100
+ return {
101
+ status: "unknown",
102
+ reasons: ["malformed-or-legacy-provenance"],
103
+ allFilesDeleted: false,
104
+ changedPathCount: 0,
105
+ };
90
106
  }
91
107
  const provenance = record.provenance;
92
108
  const reasons = [];
@@ -149,9 +165,19 @@ export function validateAdvisoryProvenance(record, cwd, runtime) {
149
165
  }
150
166
  const allFilesDeleted = deletedFiles === provenance.files.length;
151
167
  if (unknown)
152
- return { status: "unknown", reasons, allFilesDeleted, changedPathCount: changedPaths.size };
168
+ return {
169
+ status: "unknown",
170
+ reasons,
171
+ allFilesDeleted,
172
+ changedPathCount: changedPaths.size,
173
+ };
153
174
  return reasons.length > 0
154
- ? { status: "superseded", reasons, allFilesDeleted, changedPathCount: changedPaths.size }
175
+ ? {
176
+ status: "superseded",
177
+ reasons,
178
+ allFilesDeleted,
179
+ changedPathCount: changedPaths.size,
180
+ }
155
181
  : { status: "current", reasons: [], allFilesDeleted, changedPathCount: 0 };
156
182
  }
157
183
  // ── Finding-cited path existence (#1461 slice 1) ──────────────────────────────
@@ -224,11 +250,15 @@ const MAX_LOGGED_DEAD_PATHS = 3;
224
250
  export function findingPathFreshness(resolvedPath, scannedAtMs) {
225
251
  try {
226
252
  const stat = fs.statSync(resolvedPath);
227
- if (scannedAtMs !== undefined &&
228
- stat.mtimeMs > scannedAtMs + MTIME_DRIFT_TOLERANCE_MS) {
229
- return "stale";
230
- }
231
- return "live";
253
+ // No scan timestamp recorded: the pre-kernel behavior treated the
254
+ // path as live (no reference to be stale against) - preserved.
255
+ if (scannedAtMs === undefined)
256
+ return "live";
257
+ const verdict = freshnessFromMtime({
258
+ mtimeMs: stat.mtimeMs,
259
+ referenceMs: scannedAtMs,
260
+ });
261
+ return verdict.verdict === "stale" ? "stale" : "live";
232
262
  }
233
263
  catch (error) {
234
264
  const code = error.code ?? "unknown";
@@ -113,9 +113,7 @@ export class AstGrepClient {
113
113
  (fs.existsSync(projectRuleDir)
114
114
  ? projectRuleDir
115
115
  : resolvePackagePath(import.meta.url, "rules"));
116
- this.log = verbose
117
- ? createSubsystemLogger("ast-grep")
118
- : () => { };
116
+ this.log = verbose ? createSubsystemLogger("ast-grep") : () => { };
119
117
  this.ruleManager = new AstGrepRuleManager(this.ruleDir, this.log);
120
118
  this.runner = new SgRunner(verbose);
121
119
  }
@@ -19,7 +19,13 @@ export function stageOwnerPidFromName(name) {
19
19
  }
20
20
  export async function sweepOwnStagingFiles(directories, options) {
21
21
  const uniqueDirectories = [...new Set(directories)].filter(Boolean);
22
- const result = { directories: uniqueDirectories.length, scanned: 0, removed: 0, liveSkipped: 0, truncated: false };
22
+ const result = {
23
+ directories: uniqueDirectories.length,
24
+ scanned: 0,
25
+ removed: 0,
26
+ liveSkipped: 0,
27
+ truncated: false,
28
+ };
23
29
  for (const directory of uniqueDirectories) {
24
30
  let handle;
25
31
  try {
@@ -47,14 +53,20 @@ export async function sweepOwnStagingFiles(directories, options) {
47
53
  continue;
48
54
  }
49
55
  try {
50
- await fs.promises.rm(path.join(directory, entry.name), { force: true });
56
+ await fs.promises.rm(path.join(directory, entry.name), {
57
+ force: true,
58
+ });
51
59
  result.removed++;
52
60
  }
53
- catch { /* leave it for the next bounded sweep */ }
61
+ catch {
62
+ /* leave it for the next bounded sweep */
63
+ }
54
64
  }
55
65
  result.truncated ||= reachedEntryCap;
56
66
  }
57
- catch { /* best-effort directory scan */ }
67
+ catch {
68
+ /* best-effort directory scan */
69
+ }
58
70
  finally {
59
71
  await handle.close().catch(() => { });
60
72
  }
@@ -634,7 +634,9 @@ function collectGrepCommandFiles(command, cwd) {
634
634
  return {
635
635
  hasLineNumberGrep,
636
636
  files,
637
- context: truncated ? { before: 0, after: 0 } : (context ?? { before: 0, after: 0 }),
637
+ context: truncated
638
+ ? { before: 0, after: 0 }
639
+ : (context ?? { before: 0, after: 0 }),
638
640
  };
639
641
  }
640
642
  function dedupePushSearchRead(out, seen, loc) {
@@ -58,9 +58,7 @@ export class BiomeClient {
58
58
  ensureFlight = createSingleFlight();
59
59
  log;
60
60
  constructor(verbose = false) {
61
- this.log = verbose
62
- ? createSubsystemLogger("biome")
63
- : () => { };
61
+ this.log = verbose ? createSubsystemLogger("biome") : () => { };
64
62
  }
65
63
  /**
66
64
  * Resolve the fastest available biome binary for `cwd`.
@@ -126,7 +126,11 @@ async function resolveForwardImportsMemoized(cwd, filePath, resolveForwardImport
126
126
  catch {
127
127
  imports = [];
128
128
  }
129
- importResolutionMemo.set(filePath, { mtimeMs: sig.mtimeMs, size: sig.size, imports });
129
+ importResolutionMemo.set(filePath, {
130
+ mtimeMs: sig.mtimeMs,
131
+ size: sig.size,
132
+ imports,
133
+ });
130
134
  return imports;
131
135
  }
132
136
  /** Test-only: clear the extractor and import-resolution memos. */
@@ -232,17 +236,26 @@ export async function collectForwardImportMtimes(cwd, filePath, resolveForwardIm
232
236
  * +50ms). +50ms comfortably clears the measured skew while staying far below the
233
237
  * gap between genuinely distinct edits.
234
238
  */
235
- export const MTIME_DRIFT_TOLERANCE_MS = 50;
239
+ // Single implementation lives in the freshness kernel (#1739); this
240
+ // re-export preserves every existing importer's path.
241
+ export { MTIME_DRIFT_TOLERANCE_MS } from "./freshness.js";
242
+ import { freshnessFromMtime } from "./freshness.js";
236
243
  async function detectDrift(cwd, filePath, recordedAtMs, resolveForwardImports, turnIndex) {
237
244
  const drifted = [];
238
- const ownMtimeMs = await statMtimeMs(filePath);
239
- if (ownMtimeMs !== undefined && ownMtimeMs > recordedAtMs + MTIME_DRIFT_TOLERANCE_MS) {
245
+ const ownFreshness = freshnessFromMtime({
246
+ mtimeMs: await statMtimeMs(filePath),
247
+ referenceMs: recordedAtMs,
248
+ });
249
+ if (ownFreshness.verdict === "stale")
240
250
  drifted.push(filePath);
241
- }
242
251
  const { mtimes, truncated } = await collectForwardImportMtimes(cwd, filePath, resolveForwardImports, turnIndex);
243
- for (const { path, mtimeMs } of mtimes) {
244
- if (mtimeMs > recordedAtMs + MTIME_DRIFT_TOLERANCE_MS)
245
- drifted.push(path);
252
+ for (const { path: depPath, mtimeMs } of mtimes) {
253
+ const verdict = freshnessFromMtime({
254
+ mtimeMs,
255
+ referenceMs: recordedAtMs,
256
+ });
257
+ if (verdict.verdict === "stale")
258
+ drifted.push(depPath);
246
259
  }
247
260
  return { drifted, truncated };
248
261
  }
@@ -13,7 +13,9 @@ export class BoundedLruCache {
13
13
  }
14
14
  return value;
15
15
  }
16
- has(key) { return this.entries.has(key); }
16
+ has(key) {
17
+ return this.entries.has(key);
18
+ }
17
19
  set(key, value) {
18
20
  this.entries.delete(key);
19
21
  this.entries.set(key, value);
@@ -24,11 +26,25 @@ export class BoundedLruCache {
24
26
  this.entries.delete(oldest);
25
27
  }
26
28
  }
27
- delete(key) { return this.entries.delete(key); }
28
- clear() { this.entries.clear(); }
29
- get size() { return this.entries.size; }
30
- keys() { return this.entries.keys(); }
31
- values() { return this.entries.values(); }
32
- entriesArray() { return [...this.entries.entries()]; }
33
- [Symbol.iterator]() { return this.entries[Symbol.iterator](); }
29
+ delete(key) {
30
+ return this.entries.delete(key);
31
+ }
32
+ clear() {
33
+ this.entries.clear();
34
+ }
35
+ get size() {
36
+ return this.entries.size;
37
+ }
38
+ keys() {
39
+ return this.entries.keys();
40
+ }
41
+ values() {
42
+ return this.entries.values();
43
+ }
44
+ entriesArray() {
45
+ return [...this.entries.entries()];
46
+ }
47
+ [Symbol.iterator]() {
48
+ return this.entries[Symbol.iterator]();
49
+ }
34
50
  }
@@ -35,8 +35,7 @@ async function releaseQuarantineLock(lockPath, token) {
35
35
  break;
36
36
  }
37
37
  catch (error) {
38
- if (error.code !== "ENOENT" ||
39
- attempt === 2)
38
+ if (error.code !== "ENOENT" || attempt === 2)
40
39
  return;
41
40
  await new Promise((resolve) => setImmediate(resolve));
42
41
  }
@@ -111,7 +110,9 @@ async function tryAcquireQuarantineLock(lockPath, staleMs) {
111
110
  await fsp.writeFile(path.join(lockPath, "owner.json"), JSON.stringify(owner), "utf8");
112
111
  }
113
112
  catch (error) {
114
- await fsp.rm(lockPath, { recursive: true, force: true }).catch(() => { });
113
+ await fsp
114
+ .rm(lockPath, { recursive: true, force: true })
115
+ .catch(() => { });
115
116
  throw error;
116
117
  }
117
118
  return () => releaseQuarantineLock(lockPath, owner.token);
@@ -39,9 +39,7 @@ function getTurnStatePath(cwd) {
39
39
  export class CacheManager {
40
40
  log;
41
41
  constructor(verbose = false) {
42
- this.log = verbose
43
- ? createSubsystemLogger("cache")
44
- : () => { };
42
+ this.log = verbose ? createSubsystemLogger("cache") : () => { };
45
43
  }
46
44
  /**
47
45
  * Convert a file path to a stable turn-state key.
@@ -226,11 +224,12 @@ export class CacheManager {
226
224
  // stale-session eviction behavior for this legacy shape.
227
225
  return "available";
228
226
  }
229
- if (state.owner.kind === owner.kind &&
230
- state.owner.id === owner.id) {
227
+ if (state.owner.kind === owner.kind && state.owner.id === owner.id) {
231
228
  return "owned";
232
229
  }
233
- return this.isTurnStateOwnerStale(state.owner) ? "available" : "foreign-live";
230
+ return this.isTurnStateOwnerStale(state.owner)
231
+ ? "available"
232
+ : "foreign-live";
234
233
  }
235
234
  isTurnStateOwnerStale(owner) {
236
235
  if (owner.pid > 0 && owner.pid !== process.pid) {
@@ -243,7 +242,7 @@ export class CacheManager {
243
242
  }
244
243
  }
245
244
  const lastSeen = Date.parse(owner.lastSeen);
246
- return !Number.isFinite(lastSeen) || Date.now() - lastSeen > TURN_OWNER_STALE_MS;
245
+ return (!Number.isFinite(lastSeen) || Date.now() - lastSeen > TURN_OWNER_STALE_MS);
247
246
  }
248
247
  /**
249
248
  * Add or update a file's modified ranges in turn state.