pi-lens 3.8.73 → 3.8.74

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 (205) hide show
  1. package/CHANGELOG.md +1116 -0
  2. package/README.md +37 -21
  3. package/dist/clients/atomic-write.js +3 -3
  4. package/dist/clients/bus-events-logger.js +5 -1
  5. package/dist/clients/cache/rule-cache.js +43 -2
  6. package/dist/clients/cache-observability.js +211 -0
  7. package/dist/clients/call-graph.js +20 -0
  8. package/dist/clients/cascade-logger.js +5 -1
  9. package/dist/clients/complexity-client.js +4 -7
  10. package/dist/clients/dependency-checker.js +151 -20
  11. package/dist/clients/diagnostic-dispositions.js +22 -2
  12. package/dist/clients/dispatch/auxiliary-lsp.js +34 -0
  13. package/dist/clients/dispatch/dispatcher.js +16 -1
  14. package/dist/clients/dispatch/fact-store.js +11 -0
  15. package/dist/clients/dispatch/facts/function-facts.js +14 -0
  16. package/dist/clients/dispatch/facts/import-facts.js +100 -101
  17. package/dist/clients/dispatch/facts/tree-sitter-facts.js +5 -8
  18. package/dist/clients/dispatch/facts/try-catch-facts.js +13 -0
  19. package/dist/clients/dispatch/integration.js +94 -15
  20. package/dist/clients/dispatch/rules/async-noise.js +1 -0
  21. package/dist/clients/dispatch/rules/unsafe-boundary.js +7 -0
  22. package/dist/clients/dispatch/runners/ast-grep-napi.js +24 -1
  23. package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -1
  24. package/dist/clients/dispatch/runners/tree-sitter.js +153 -166
  25. package/dist/clients/dispatch/runners/utils/runner-helpers.js +17 -18
  26. package/dist/clients/feature-hints.js +53 -7
  27. package/dist/clients/file-kinds.js +86 -31
  28. package/dist/clients/fix-worklog.js +14 -2
  29. package/dist/clients/generated-artifacts.js +21 -0
  30. package/dist/clients/gzip-stage-write.js +84 -0
  31. package/dist/clients/installer/index.js +358 -129
  32. package/dist/clients/knip-client.js +64 -1
  33. package/dist/clients/language-profile.js +61 -72
  34. package/dist/clients/latency-logger.js +6 -16
  35. package/dist/clients/lens-config.js +128 -165
  36. package/dist/clients/lens-engine.js +39 -5
  37. package/dist/clients/lens-flag-registry.js +285 -0
  38. package/dist/clients/lens-map.js +2 -7
  39. package/dist/clients/log-cleanup.js +6 -17
  40. package/dist/clients/lsp/cascade-tier.js +22 -2
  41. package/dist/clients/lsp/client.js +74 -10
  42. package/dist/clients/lsp/config.js +62 -9
  43. package/dist/clients/lsp/index.js +34 -24
  44. package/dist/clients/lsp/language.js +4 -1
  45. package/dist/clients/lsp/launch.js +8 -6
  46. package/dist/clients/lsp/server.js +84 -29
  47. package/dist/clients/lsp-document-symbols.js +112 -0
  48. package/dist/clients/mcp/analyze.js +5 -4
  49. package/dist/clients/mcp/review.js +36 -3
  50. package/dist/clients/module-report.js +100 -80
  51. package/dist/clients/ndjson-logger.js +51 -21
  52. package/dist/clients/path-keyed-map.js +76 -0
  53. package/dist/clients/path-utils.js +35 -0
  54. package/dist/clients/performance-report.js +327 -0
  55. package/dist/clients/pipeline.js +7 -0
  56. package/dist/clients/project-changes.js +77 -4
  57. package/dist/clients/project-diagnostics/extractors.js +26 -115
  58. package/dist/clients/project-diagnostics/fresh-fetch.js +149 -19
  59. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +109 -0
  60. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +37 -5
  61. package/dist/clients/project-diagnostics/scanner.js +247 -127
  62. package/dist/clients/project-lens-config.js +53 -26
  63. package/dist/clients/project-report.js +39 -14
  64. package/dist/clients/project-scale.js +8 -10
  65. package/dist/clients/project-snapshot-persist-worker.js +6 -0
  66. package/dist/clients/project-snapshot.js +611 -7
  67. package/dist/clients/quiet-window.js +9 -1
  68. package/dist/clients/read-expansion.js +22 -12
  69. package/dist/clients/read-guard.js +78 -0
  70. package/dist/clients/redact/secrets.js +449 -0
  71. package/dist/clients/resource-sampler.js +133 -14
  72. package/dist/clients/reverse-deps.js +48 -0
  73. package/dist/clients/review-graph/builder.js +1522 -179
  74. package/dist/clients/review-graph/persist-worker.js +10 -0
  75. package/dist/clients/review-graph/query.js +5 -0
  76. package/dist/clients/review-graph/shared-extraction-ir.js +90 -0
  77. package/dist/clients/review-graph-logger.js +24 -0
  78. package/dist/clients/runtime-agent-end.js +4 -2
  79. package/dist/clients/runtime-coordinator.js +2 -2
  80. package/dist/clients/runtime-session.js +433 -59
  81. package/dist/clients/runtime-tool-call.js +46 -14
  82. package/dist/clients/runtime-turn.js +97 -26
  83. package/dist/clients/safe-spawn.js +102 -15
  84. package/dist/clients/session-start-observability.js +15 -0
  85. package/dist/clients/session-state-store.js +2 -1
  86. package/dist/clients/sessionstart-logger.js +18 -0
  87. package/dist/clients/sg-runner.js +48 -33
  88. package/dist/clients/source-filter.js +49 -25
  89. package/dist/clients/startup-marker.js +6 -0
  90. package/dist/clients/startup-timing.js +9 -0
  91. package/dist/clients/todo-scanner.js +30 -3
  92. package/dist/clients/tree-sitter-cache.js +147 -139
  93. package/dist/clients/tree-sitter-client.js +1340 -67
  94. package/dist/clients/tree-sitter-logger.js +43 -1
  95. package/dist/clients/tree-sitter-query-loader.js +110 -11
  96. package/dist/clients/tree-sitter-shared.js +43 -19
  97. package/dist/clients/tree-sitter-symbol-extractor.js +71 -4
  98. package/dist/clients/widget-state.js +50 -14
  99. package/dist/clients/word-index-logger.js +44 -0
  100. package/dist/clients/word-index.js +252 -35
  101. package/dist/clients/workspace-topology.js +11 -1
  102. package/dist/index.js +45544 -39255
  103. package/dist/mcp/analyze-cli.js +3 -0
  104. package/dist/mcp/cli.js +110 -0
  105. package/dist/mcp/server.js +63 -8
  106. package/dist/tools/lens-diagnostics.js +47 -5
  107. package/dist/tools/lsp-navigation.js +5 -28
  108. package/dist/tools/symbol-search.js +21 -2
  109. package/docs/agent-guide.md +247 -0
  110. package/docs/astplayground.md +3 -4
  111. package/docs/audit1.md +5 -5
  112. package/docs/environment-variables.md +153 -11
  113. package/docs/features.md +4 -3
  114. package/docs/globalconfig.md +56 -4
  115. package/docs/language-coverage.md +2 -2
  116. package/docs/mcp.md +17 -16
  117. package/docs/servercapabilities.md +5 -2
  118. package/docs/settings.md +181 -0
  119. package/docs/tools.md +14 -0
  120. package/docs/tree-sitter_rules_catalog.md +11 -31
  121. package/docs/usage.md +13 -0
  122. package/package.json +139 -138
  123. package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
  124. package/rules/ast-grep-rules/rule-tests/hardcoded-url-test.yml +2 -0
  125. package/rules/ast-grep-rules/rule-tests/no-case-declarations-js-test.yml +10 -0
  126. package/rules/ast-grep-rules/rule-tests/no-case-declarations-test.yml +10 -0
  127. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +10 -0
  128. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +10 -0
  129. package/rules/ast-grep-rules/rule-tests/strict-equality-js-test.yml +3 -0
  130. package/rules/ast-grep-rules/rule-tests/strict-equality-test.yml +3 -0
  131. package/rules/ast-grep-rules/rule-tests/strict-inequality-js-test.yml +3 -0
  132. package/rules/ast-grep-rules/rule-tests/strict-inequality-test.yml +4 -0
  133. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +22 -4
  134. package/rules/ast-grep-rules/rules/hardcoded-url.yml +22 -4
  135. package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +4 -3
  136. package/rules/ast-grep-rules/rules/no-case-declarations.yml +4 -3
  137. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +10 -0
  138. package/rules/ast-grep-rules/rules/no-console-except-error.yml +10 -0
  139. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +42 -6
  140. package/rules/ast-grep-rules/rules/no-javascript-url.yml +42 -6
  141. package/rules/ast-grep-rules/rules/strict-equality-js.yml +14 -1
  142. package/rules/ast-grep-rules/rules/strict-equality.yml +14 -1
  143. package/rules/ast-grep-rules/rules/strict-inequality-js.yml +14 -1
  144. package/rules/ast-grep-rules/rules/strict-inequality.yml +14 -1
  145. package/rules/tree-sitter-queries/cpp/no-memset-sensitive-data.yml +9 -3
  146. package/rules/tree-sitter-queries/cpp/no-scoped-lock-without-args.yml +6 -6
  147. package/rules/tree-sitter-queries/css/calc-spacing.yml +8 -6
  148. package/rules/tree-sitter-queries/go/go-mutex-copy.yml +21 -18
  149. package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +3 -3
  150. package/rules/tree-sitter-queries/java/infinite-loop-java.yml +3 -3
  151. package/rules/tree-sitter-queries/java/junit-call-super.yml +6 -4
  152. package/rules/tree-sitter-queries/java/no-double-checked-locking.yml +10 -8
  153. package/rules/tree-sitter-queries/java/no-field-shadowing.yml +1 -1
  154. package/rules/tree-sitter-queries/java/prepared-statement-valid-indices.yml +2 -3
  155. package/rules/tree-sitter-queries/java/resources-closed.yml +2 -2
  156. package/rules/tree-sitter-queries/java/springboot-default-package.yml +6 -3
  157. package/rules/tree-sitter-queries/java/switch-fall-through.yml +6 -4
  158. package/rules/tree-sitter-queries/java/switch-non-case-labels.yml +4 -3
  159. package/rules/tree-sitter-queries/java/tests-include-assertions.yml +6 -5
  160. package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +5 -8
  161. package/rules/tree-sitter-queries/javascript/switch-non-case-labels.yml +3 -2
  162. package/rules/tree-sitter-queries/kotlin/prepared-statement-indices.yml +9 -5
  163. package/rules/tree-sitter-queries/php/this-in-static-context.yml +5 -5
  164. package/rules/tree-sitter-queries/python/exit-signature-check.yml +2 -9
  165. package/rules/tree-sitter-queries/python/in-operator-unsupported.yml +3 -4
  166. package/rules/tree-sitter-queries/python/no-super-torchscript.yml +4 -8
  167. package/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml +1 -5
  168. package/rules/tree-sitter-queries/python/python-empty-except.yml +1 -1
  169. package/rules/tree-sitter-queries/python/return-in-init.yml +0 -2
  170. package/rules/tree-sitter-queries/python/yield-return-outside-function.yml +0 -3
  171. package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +1 -12
  172. package/rules/tree-sitter-queries/ruby/ruby-eval.yml +1 -1
  173. package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +4 -4
  174. package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +4 -11
  175. package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +1 -1
  176. package/rules/tree-sitter-queries/ruby/ruby-string-eval.yml +2 -3
  177. package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +4 -4
  178. package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +4 -3
  179. package/rules/tree-sitter-queries/typescript/console-statement.yml +11 -0
  180. package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +4 -9
  181. package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +13 -9
  182. package/rules/tree-sitter-queries/typescript/infinite-loop.yml +2 -2
  183. package/rules/tree-sitter-queries/typescript/mixed-async-styles.yml +10 -1
  184. package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +5 -8
  185. package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +7 -10
  186. package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +4 -2
  187. package/rules/typos/_typos.toml +17 -0
  188. package/skills/pi-lens-write-tree-sitter-rule/SKILL.md +5 -0
  189. package/dist/clients/config-enabled-shape.js +0 -39
  190. package/docs/env_variables.md +0 -10
  191. package/rules/tree-sitter-queries/abap-disabled/delete-where.yml +0 -46
  192. package/rules/tree-sitter-queries/cpp/no-confused-move-forward.yml +0 -59
  193. package/rules/tree-sitter-queries/cpp/noexcept-functions.yml +0 -58
  194. package/rules/tree-sitter-queries/java/mockito-initialized.yml +0 -66
  195. package/rules/tree-sitter-queries/java/no-wait-notify-on-thread.yml +0 -58
  196. package/rules/tree-sitter-queries/java/short-circuit-logic.yml +0 -57
  197. package/rules/tree-sitter-queries/java/spring-session-attributes-setcomplete.yml +0 -75
  198. package/rules/tree-sitter-queries/plsql-disabled/delete-update-where.yml +0 -56
  199. package/rules/tree-sitter-queries/plsql-disabled/end-loop-semicolon.yml +0 -51
  200. package/rules/tree-sitter-queries/plsql-disabled/fetch-bulk-collect-limit.yml +0 -47
  201. package/rules/tree-sitter-queries/plsql-disabled/forallsave-exceptions.yml +0 -51
  202. package/rules/tree-sitter-queries/plsql-disabled/nchar-nvarchar2-bytes.yml +0 -54
  203. package/rules/tree-sitter-queries/plsql-disabled/not-null-initialization.yml +0 -59
  204. package/rules/tree-sitter-queries/plsql-disabled/raise-application-error-codes.yml +0 -50
  205. package/rules/tree-sitter-queries/typescript-disabled/ts-path-traversal.yml +0 -71
package/CHANGELOG.md CHANGED
@@ -10,6 +10,1122 @@ All notable changes to pi-lens will be documented in this file.
10
10
 
11
11
  ### Fixed
12
12
 
13
+ ## [3.8.74] - 2026-08-01
14
+
15
+ ### Added
16
+
17
+ - **🙏 Huge thanks to @AngriestBird (Kenny McCormick), this round's standout community contributor** — five merged PRs landed this release: a single source of truth for CLI flags (#1011), secret redaction at the log-write boundary (#954), new slash commands (#906), tree-cache performance improvements (#877), and a test harness for rule-dispatch behavior (#871). Sustained, high-quality work spanning perf, security, config, and test infrastructure — thank you.
18
+
19
+ - **Prompt-cache observability (refs #1018, closes #1018)** — two provider-independent
20
+ signals now land in `~/.pi-lens/latency.log` as `type: "phase"` records.
21
+ (1) Response-side: a defensively feature-detected `message_end` subscription
22
+ (clients/agent-nudge.ts pattern — guarded, never throws on older hosts) logs one
23
+ `cache_usage` record per assistant message that carries a `usage`, with
24
+ `metadata: { provider, model, cacheRead, cacheWrite, input, output, cost }`
25
+ (provider/model read straight off the assistant message; `cost` is the total).
26
+ Messages with no usage (or non-assistant messages) are skipped rather than
27
+ logged as zeros. (2) Request-side: the existing `context` handler now hashes
28
+ `messages[0]` on every call and logs a `cache_prefix_break` record
29
+ (`metadata: { turnIndex, previousHash, currentHash, sessionId, sessionRole }`,
30
+ plus a baseline record on first observation) whenever that hash changes
31
+ turn-over-turn — a regression guard that flags anything (pi-lens or otherwise)
32
+ breaking the byte-stable prefix #1016 established. The baseline is keyed by the
33
+ stable per-session id (`ctx.sessionManager.getSessionId()`) in a small bounded
34
+ LRU, so a resume/reload keeps comparing against its own baseline while a
35
+ new/fork or a concurrent in-process subagent (#473) gets an independent baseline
36
+ instead of a spurious cross-session break; each record is tagged with the
37
+ read-only #473 `sessionRole` classification, and a primary `session_shutdown`
38
+ drops the ended session's entry. The hash is pure observation: it never changes
39
+ the handler's injection behavior or return value, and runs even on non-injecting
40
+ turns. New `clients/cache-observability.ts`; no new dependencies.
41
+
42
+ - **Word-index build/refresh/persist outcomes are now durably observable**
43
+ (refs #958, #926, #533) — every word-index signal previously rode solely on
44
+ the optional `dbg` callback, a documented no-op in the MCP host where
45
+ `symbol_search` reads the index. The structured logger (`~/.pi-lens/
46
+ word-index.log`, shared `createNdjsonLogger` infra) now records, independent
47
+ of `dbg`: the full-rebuild-vs-incremental decision plus honest coverage
48
+ (`indexedFileCount`/`truncated` and `refreshed`/`dropped`/`skipped`/`reused`
49
+ counts) for both the session-start and cold-query (MCP) build paths; a
50
+ swallowed snapshot **persist failure** as `persist_failed` (a silent stale
51
+ index otherwise leaves no trace); and the safety refusal for a root at/above
52
+ `$HOME`. The full-build collector (`collectWordIndexDocs`) now also returns a
53
+ `skipped` count for files it enumerated but could not index (over the byte
54
+ cap / unreadable), so a partial index is never reported as complete. The
55
+ fragile string-parsing `dbg` adapter this replaces is removed.
56
+
57
+ - **Closed four observability gaps in recently-changed typos-config and
58
+ project-snapshot code** (refs #533) — none of these change behavior, only
59
+ what's now logged: (1) the typos LSP's inject-vs-step-aside decision
60
+ (#967) now logs a `typos_config_resolved` phase in `latency.log` /
61
+ `sessionstart.log` with `mode: "project_config" | "injected_default"` and
62
+ the resolved `configPath`, so which typos config is actually active is no
63
+ longer a guess; (2) a project-snapshot body persist that falls back to the
64
+ synchronous main-thread gzip because the persist worker died/was
65
+ unavailable now logs an explicit `project_snapshot_worker_fallback` phase
66
+ with the `reason` (previously only visible via a test-only variable, or
67
+ buried in an `offloaded:false` success line) — this is the +656MB-risk
68
+ degraded path from #950; (3) a corrupt/truncated gzipped snapshot body
69
+ (gunzip/JSON.parse failure) now logs `project_snapshot_body_corrupt`
70
+ before failing open to a rebuild, instead of being indistinguishable from
71
+ "no snapshot yet"; (4) dropping the in-process authoritative snapshot
72
+ entry for an oversized (>24MB) body now logs
73
+ `project_snapshot_authoritative_dropped_oversized` — low-priority, but no
74
+ longer silent.
75
+
76
+ - **Review-graph checkpoint discards and write failures are now observable**
77
+ (refs #936, #533) — a present resume checkpoint that's rejected now logs
78
+ `checkpoint_discarded` with a `reason` (`corrupt`, `version_mismatch`,
79
+ `not_in_progress`, `git_stamp_mismatch`, `ignored_ids_mismatch`,
80
+ `removed_file`, `all_stale`) instead of silently falling back to a full cold
81
+ rebuild, and a failed checkpoint write (worker error/death, promote failure,
82
+ sync-write failure) logs `checkpoint_write_failed` — so "why isn't my
83
+ checkpoint resuming / persisting?" is diagnosable from `review-graph.log`.
84
+
85
+ - **The review-graph full build is now resumable across sessions** (refs #936
86
+ limit 2) — a cold full build (walk + tree-sitter parse of every source file)
87
+ previously restarted from scratch every session, so on a large repo with
88
+ short-lived sessions it could never finish. The extraction loop now
89
+ periodically checkpoints the PRE-resolution graph plus the exact set of files
90
+ already folded into it (with content hashes) to a dedicated
91
+ `review-graph.checkpoint.json.gz`, and a later session resumes from it,
92
+ re-walking only files that changed/appeared since. The checkpoint lives in
93
+ its own file (never the authoritative `review-graph.json.gz`) and its
94
+ hydrated graph carries `persistCoverage.inProgress` on top of `partial`, so
95
+ no reader (`getCachedReviewGraph`, `loadPersistedGraph`) can ever serve or
96
+ launder a mid-build checkpoint as a complete graph (honesty doctrine, #533).
97
+ Resume equivalence to a cold build is guaranteed by `addFileToGraph`'s
98
+ per-file contribution being order-independent (all cross-file linking is
99
+ deferred to `resolveDeferredSymbolEdges`): content-changed processed files
100
+ are evicted and re-walked, orphaned placeholder nodes are pruned, and any
101
+ removed file, ignored-id-set change, version bump, or git-identity mismatch
102
+ fails open to a cold build rather than risking a wrong graph. Checkpoint
103
+ stride/interval are tunable via `PI_LENS_GRAPH_CHECKPOINT_EVERY_FILES` /
104
+ `PI_LENS_GRAPH_CHECKPOINT_MIN_INTERVAL_MS`.
105
+
106
+ - **Bundled default typos allowlist for projects without their own config**
107
+ (fixes #967) — the `typos` auxiliary LSP now injects a small pi-lens-shipped
108
+ `_typos.toml` (`rules/typos/_typos.toml`, `[default.extend-identifiers]`
109
+ only: `dito`, `unparseable`) via `initializationOptions.config` whenever a
110
+ project has no `typos.toml`/`_typos.toml`/`.typos.toml` of its own. A
111
+ project's own config still always wins outright: when one is found,
112
+ pi-lens injects nothing at all (rather than letting typos-lsp merge ours
113
+ on top and risk our defaults outranking the team's own allowlist on key
114
+ collisions).
115
+
116
+ - **Session-start latency is attributable end to end** (refs #948) — latency
117
+ telemetry now separates host boot from pi-lens evaluation and records quick
118
+ and full session-start totals, pre-handler/bootstrap work, runtime reset,
119
+ log cleanup, LSP reset, sequence/snapshot reads (including snapshot bytes),
120
+ and delayed warmup scan/profile/index phases.
121
+
122
+ - **Review-graph LSP fallback nodes** (refs #307) — when tree-sitter yields
123
+ zero declarations, the builder may use `documentSymbol` from an already-live,
124
+ already-open capable server. Nodes carry `provenance: "lsp"`, hierarchical
125
+ containment survives persistence (including flat native-TypeScript-7 results
126
+ reconstructed through `containerName`), productive tree-sitter files never
127
+ pay the request, and unavailable/failed fallback attempts degrade without
128
+ opening or spawning while remaining visible in `review-graph.log`.
129
+
130
+ - **Standalone out-of-band review-graph build CLI** (refs #924) — `npx pi-lens
131
+ build-graph [--cwd <dir>]` reuses the session builder and queued atomic
132
+ persist path for CI/cron, forces the debounced snapshot write before exit,
133
+ and prints file/node/edge/element counts, JSON bytes, and duration. Unsafe
134
+ roots, build errors/skips, persist failures, and persist-cap trips exit
135
+ non-zero with their reason instead of silently leaving no snapshot.
136
+
137
+ - **`/lens-perf` surfaces slow phases in-session** (closes #767) — the command
138
+ shows independent top-five p50 and p99 rankings with sample counts for both
139
+ the current process session and the machine-wide active `latency.log` window.
140
+ It flushes pending writes, streams a tail bounded by the log rotation
141
+ threshold (`PI_LENS_MAX_LOG_SIZE_MB`, 10MB by default), caps retained samples,
142
+ and reports malformed/truncated input instead of silently reading it as clean.
143
+ Session startup total and scan-context computation are now logged as phases so
144
+ the startup regressions that motivated the command are visible there too.
145
+
146
+ ### Changed
147
+
148
+ - **Session-start perf: bound the change-log replay + defer log cleanup (closes #1019)** —
149
+ the interactive session-start path recomputed the project sequence by reading
150
+ the ENTIRE append-only change log and folding every line — and each fold does a
151
+ `normalizeMapKey`/`realpathSync.native()` syscall per historical entry, so the
152
+ `session_start_sequence_read` phase (measured ~94ms, ~47% of a 200ms warm start)
153
+ grew unbounded with total log length, not the working set. The project snapshot
154
+ now embeds the DERIVED sequence index (`projectSeq` + per-file `fileSeqByPath`)
155
+ as of its `seq` (`clients/project-snapshot.ts`), mirrored into the tiny
156
+ `project-snapshot.meta.json` sidecar so session-start can hydrate it WITHOUT
157
+ parsing the 40-112MB body (preserving the #947 skip-stale optimization).
158
+ `readLatestProjectSequence` (`clients/project-changes.ts`) gained an optional
159
+ base param: it hydrates that index (O(files-in-snapshot), keys already
160
+ normalized → no per-key `realpath`) and folds ONLY entries with
161
+ `seq > snapshot.seq` — O(changes-since-snapshot). The fold uses the SAME
162
+ `Math.max`/`normalizeMapKey` the full replay does, so the result is
163
+ byte-identical to a full replay (proven by an equivalence test suite covering
164
+ no-new-entries, new/existing files, deletes, gaps/out-of-order, and the empty
165
+ log) and order-independent. Correctness-first fallbacks to a full replay: a
166
+ legacy/missing meta with no embedded index, a version-mismatched meta, and a
167
+ snapshot whose `seq` is AHEAD of the log (truncation/rotation) — it never serves
168
+ a wrong seq. Wired into BOTH the quick/interactive and the full session-start
169
+ paths. Secondary: `log_cleanup` (~7ms) was moved off the synchronous critical
170
+ path into a deferred `setImmediate` (it still runs every session and notifies
171
+ async — nothing on the hot path consumed its result).
172
+
173
+ - **Config-consistency pass on the #166 flag registry (refs #166, #533)** — the
174
+ #883 registry core (scope split, precedence, negation) was audited consistent
175
+ and left untouched; the gaps were all in #533 malformed/unknown-input
176
+ signalling, asymmetric between the global and project loaders. (1) The
177
+ project loader (`.pi-lens.json`) now warns once on an unrecognized top-level
178
+ key — a typo like `maxProjectFile` or `lps` previously did nothing silently —
179
+ while tolerating the foreign namespaces the shared file legitimately carries
180
+ for the LSP loader (`servers`, `serverOverrides`, `disabledServers`,
181
+ `warmFiles`, plus `$schema`); a user-level-only lens key placed at project
182
+ scope (e.g. `lsp`, `tests`, `delta`) gets a distinct "not honored at project
183
+ scope" signal instead of being lumped in with typos. (2) The recognized-key
184
+ catalogs are single-sourced (#883): `GLOBAL_NON_FLAG_CONFIG_SECTIONS` and
185
+ `PROJECT_FOREIGN_CONFIG_NAMESPACES` are declared once beside `LENS_FLAGS`, and
186
+ both loaders derive flag sections from the registry, so adding a namespace is
187
+ a one-line edit and adding a flag needs none — replacing a drift-prone inline
188
+ literal set in the global loader. (3) Three global scalars
189
+ (`dispatch.runnerTimeoutFloorMs`, `widget.visible`, `format.mode`) that
190
+ silently coerced a present-but-malformed value to `undefined` now warn on
191
+ invalid input through the same path as `actionableWarnings.autoFix.maxFixes`,
192
+ while staying silent when the key is absent (no false warnings). (4) Removed
193
+ five dead global-only accessors that bypassed the precedence chain
194
+ (`getGlobalAutoformatEnabled`, `getGlobalAutofixEnabled`,
195
+ `getGlobalImmediateFormatDefault`, `getGlobalContextInjectionEnabled`,
196
+ `getGlobalTurnSummaryEnabled`) — no non-test callers existed. (5) Updated
197
+ `docs/globalconfig.md`, whose "unknown keys are ignored" claim was stale.
198
+
199
+ - **Normalized call-graph impact checks into structured `ProjectDiagnostic`s**
200
+ (refs #179) — the last remaining adapter gap in #179's normalization sweep.
201
+ `runtime-turn.ts`'s turn-end call-graph impact check (WillBreak/MayBreak
202
+ callers of symbols edited this turn, via `impact()`/`call-graph.ts`) was
203
+ advisory-text only, unlike the other 9 runners already normalized through
204
+ `runner-adapters/*.ts`. Added `callGraphImpactToProjectDiagnostics`
205
+ (`project-diagnostics/runner-adapters/call-graph-impact.ts`), a pure mapper
206
+ attributing each diagnostic to the CALLER's file (no line — `impact()`'s
207
+ `ImpactResult` never carries one, and the underlying call-graph symbol
208
+ table only has a placeholder `line: 1`, so inventing one would be a fake
209
+ location). Severity is deliberately capped below this codebase's
210
+ "blocking" hard-stop tier for BOTH WillBreak and MayBreak (#533 honesty):
211
+ `impact()` is a name-resolution heuristic with no type information, so a
212
+ resolved caller is never a confirmed break. Wired at turn-end (not
213
+ `fresh-fetch.ts`/`ANALYZER_IDS`) since impact analysis is inherently
214
+ per-turn-edit, not a whole-project scan — same reasoning that keeps
215
+ test-runner's own turn-end fire out of that registry. The existing
216
+ human-readable advisory string is kept as-is (separate surface, same
217
+ dual-surface pattern already used by knip's turn-end delta). The call-graph
218
+ block now runs BEFORE the single `writeProjectDiagnosticsDeltaReport`
219
+ serialization point (alongside knip's delta push) rather than after it —
220
+ previously it pushed into `projectDiagnosticsDelta`/`projectDiagnosticsSources`
221
+ past the one-shot write, so a call-graph-only turn persisted nothing and a
222
+ mixed turn dropped the call-graph entries, leaving `lens_diagnostics` (a
223
+ pure reader of the persisted report) unable to ever surface the findings
224
+ (#533). Covered by a new `handleTurnEnd`-level regression test asserting the
225
+ persisted report for both call-graph-only and mixed turns.
226
+
227
+ - **Parallelized the per-turn madge dependency check** (refs #766) — the
228
+ turn-end circular-dependency pass previously ran one `await checkFile()`
229
+ per import-changed file in a sequential `for…await` loop, serializing N
230
+ madge subprocess spawns. `DependencyChecker.checkFilesBatch()` now runs
231
+ those spawns concurrently (bounded to 6 in flight) via a new turn-end
232
+ entry point. `DependencyChecker` keeps its circular-dep findings
233
+ (`lastCircular`/`circularFiles`) as shared instance state that a single-file
234
+ check overwrites wholesale, so naively parallelizing risked one file's
235
+ spawn clobbering a sibling's write depending on subprocess completion
236
+ order. The fix keeps every spawn's result local until all have settled,
237
+ then folds them into the shared state in original file order — matching
238
+ the sequential loop's file-by-file overwrite exactly, just without waiting
239
+ for each subprocess before starting the next. Behavior-preserving: same
240
+ per-file results, same final circular-dep state, same `dbg` logs — only
241
+ wall-clock time changes.
242
+
243
+ - **Every runtime toggle is now settable from BOTH the CLI and
244
+ `~/.pi-lens/config.json`, driven by one declarative registry** (closes #166) —
245
+ the flag/config mapping used to live in two disconnected places (twelve
246
+ `pi.registerFlag` calls in `index.ts` and an if/else chain in
247
+ `resolvePiLensFlagWithSource`), which let coverage gaps open and persist.
248
+ Seven flags were registered on the CLI but fell straight through the
249
+ resolver, so config could never set them: `--no-lens`, `--no-lsp`,
250
+ `--no-tests`, `--no-delta`, `--lens-guard`, `--no-opengrep`, and
251
+ `--no-read-guard`. They now read `lens.enabled`, `lsp.enabled`,
252
+ `tests.enabled`, `delta.enabled`, `guard.enabled`, `opengrep.enabled`, and
253
+ `readGuard.enabled` (positive keys — a `--no-*` flag is the key set `false`).
254
+ The four actionable-warning toggles went the other way: they were
255
+ config-only and are now registered CLI flags too
256
+ (`--lens-actionable-warnings`, `--lens-actionable-warning-actions`,
257
+ `--lens-actionable-warning-autofix`, `--lens-actionable-warning-all`).
258
+ `clients/lens-flag-registry.ts` is the single source both surfaces read, so
259
+ they cannot diverge again; the wiring test derives its expected flag set from
260
+ it rather than restating it (the old hand-written list had already drifted,
261
+ missing `lens-turn-summary`).
262
+
263
+ - **`actionableWarnings.autoFix.maxFixes` is now actually read** (#166) —
264
+ documented in `globalconfig.md` since #792 but no loader ever parsed it, so
265
+ `agent_end` always used the hardcoded default of 5 no matter what the config
266
+ said. Same defect class as the seven flags above: a documented config key
267
+ with nothing wiring it to the code that consumes it. Accepts any
268
+ non-negative whole number; `0` keeps the warning report while applying
269
+ nothing.
270
+
271
+ - **Flag provenance gained an `env` tier and reports the global tier more
272
+ accurately** (#166) — `PI_LENS_NO_CONTEXT_INJECTION` moved out of a
273
+ hardcoded check in `index.ts` into the registry's `env` binding, so it
274
+ resolves through the same chain as everything else (env → cli →
275
+ nested-project → project → global → default) and reports `source: "env"`.
276
+ The global tier now reports `source: "global"` whenever the key is present,
277
+ not only when it happens to differ from the built-in default. Affects debug
278
+ and mutation-skip log lines only, no resolved values change.
279
+
280
+ - **Unknown top-level keys in `~/.pi-lens/config.json` now warn once instead of
281
+ being dropped silently** (#166, refs #533) — a typo like `lps` for `lsp`
282
+ previously did nothing with no signal. The recognized-key set is derived from
283
+ the flag registry (`LENS_FLAGS`) plus the non-flag sections (`ignore`,
284
+ `dispatch`, `actionableWarnings`, `widget`) and `$schema`, so it stays in sync
285
+ with the single source of truth; anything else logs one `[pi-lens] ignoring
286
+ invalid global config …: unknown key "…"` line and is ignored.
287
+
288
+ - **The review-graph resume checkpoint (#936) now offloads its gzip to the
289
+ shared persist worker** (refs #958, #883) — mid-build checkpoint writes
290
+ previously ran a synchronous `gzipSync` of the growing graph on the event
291
+ loop; they now stream the stringify+gzip through the same worker the
292
+ authoritative snapshot uses (via the newly-shared `writeGzipStageFile` core),
293
+ generation-gated so a slow write can't clobber a newer checkpoint or
294
+ resurrect one after the build completes and retires it, and falling back to a
295
+ synchronous write when the worker is unavailable. Best-effort as before — a
296
+ lost checkpoint only costs a cold rebuild.
297
+
298
+ - **Project snapshot body is now written gzipped by a worker thread** (refs
299
+ #958 item 2) — the snapshot body (40-112MB observed) is persisted as
300
+ `project-snapshot.json.gz`, with the `JSON.stringify` + gzip run on a worker
301
+ thread off the save path, mirroring the review graph's
302
+ `persist-worker.ts`/generation-gated-promotion pattern (gzip measured 5-10x
303
+ on top of the #957 compaction win; the review-graph's own measurement was
304
+ 60MB → 1.4MB). A slow worker write for generation N is discarded rather than
305
+ promoted over a newer generation N+1 already on disk, and the loader still
306
+ reads the previous uncompressed `project-snapshot.json` for one compatibility
307
+ release so an upgrade never loses a snapshot. The save path deliberately does
308
+ NOT sync-gzip (the #950 review measured a naïve sync gzip regressing host
309
+ memory by +656MB); when the worker is unavailable/dies the pending body falls
310
+ back to a synchronous main-thread gzip write, surfaced via the logger rather
311
+ than silently presented as saved (#533). Read-your-writes across the async
312
+ promotion is preserved by an in-process authoritative "latest write" that
313
+ `loadProjectSnapshot` consults before disk, so the merge-write callers
314
+ (`saveRuntimeProjectSnapshot`, word index, reverse deps) never observe a
315
+ stale body in the promotion window.
316
+
317
+ - **Session warmup refreshes the word index incrementally** (refs #958) —
318
+ the persisted serializer now carries per-file mtimes. Startup still performs
319
+ the bounded source walk, but reuses unchanged postings, re-tokenizes only
320
+ stale/new files, and drops deleted files; legacy indexes, refresh failures,
321
+ and file-set churn above 30% fall back to a full rebuild. The current derived
322
+ file cap and `truncated` state are re-evaluated on every refresh, and
323
+ `warmup_word_index` telemetry records mode/refreshed/dropped/reused counts.
324
+
325
+ - **Project scans feed compact structural IR into review-graph builds** (refs
326
+ #939) — each fully completed scanner file publishes content-hash-bound
327
+ imports, reexports, function summaries, symbols, and references. A following
328
+ or overlapping graph build reuses only exact-hash, successful entries;
329
+ stale, failed, absent, and cold one-shot paths parse normally. The handoff
330
+ retains neither source content nor WASM trees, and cancelled scans expose
331
+ only files completed before cancellation.
332
+
333
+ - **Session-start performance: LSP pre-warm, snapshot meta-gate, compact cached snapshot** (refs #947) — three
334
+ measured startup wins: (1) the dominant-language LSP pre-warm was gated on
335
+ full startup mode that the first-session heuristic never allows (82 quick
336
+ vs 0 full starts in 31k dogfood log lines), so it now runs inside the
337
+ quick-mode +2s background warmup pass — once per process,
338
+ generation-guarded, honoring subagent light mode, warm-attach, the
339
+ `no-lsp` flag, and the `canWarmCaches` guard, with a `warmup_lsp_prewarm`
340
+ phase record; (2) session start no longer sync-parses the whole
341
+ `project-snapshot.json` body (110-130ms at 40MB, ~0.5s at 112MB) before
342
+ checking freshness — the tiny `project-snapshot.meta.json` sidecar is read
343
+ first and a stale seq/version skips the body parse entirely
344
+ (`session_start_snapshot_load` records `skippedStale: true`), with
345
+ missing-meta installs falling back to the legacy parse; (3) the snapshot
346
+ is serialized compactly (~30% smaller) and its parsed body is cached
347
+ in-process per (path, mtime), so `saveRuntimeProjectSnapshot` and the
348
+ session-start/word-index/scan-context consumers no longer re-parse a file
349
+ pi-lens itself wrote seconds earlier.
350
+
351
+ - **Warm LSP names enrich tree-sitter read expansion** (refs #158) — partial
352
+ read expansion keeps tree-sitter's line boundaries authoritative, but an
353
+ already-open document with an already-active LSP can now replace the display
354
+ name/kind from `documentSymbol` (including `Class.method` ancestry) within a
355
+ 150 ms best-effort ceiling. Cold, closed, unsupported, timed-out, or failed
356
+ servers retain the tree-sitter identity, and `ts_range_expanded` records
357
+ whether enrichment succeeded.
358
+
359
+ - **Installer subprocesses are lifetime-coupled** (refs #945) — npm, pip, gem,
360
+ and archive extraction now use the shared safe-spawn path, await full Windows
361
+ process-tree termination on timeout, and synchronously clean registered
362
+ installer children during parent exit/signals.
363
+
364
+ - **Managed tool installs are cross-process serialized** (refs #945) — a
365
+ dependency-free atomic lock protects the shared tools tree, verifies owners
366
+ before stale recovery, bounds lock waits with an honest error, and rechecks
367
+ discovery after acquisition to avoid duplicate package-manager runs.
368
+
369
+ - **Ordinary tests never install managed tools** (refs #945) — Vitest sets
370
+ `PI_LENS_DISABLE_TOOL_INSTALL=1`, its prewarm step creates a local synthetic
371
+ oxlint probe-cache entry without networking, and one-shot analysis explicitly
372
+ awaits probe-cache persistence before exit.
373
+
374
+ - **Installer orphan/locking regressions are process-tested** (refs #945) —
375
+ fake package-manager coverage verifies Windows timeout tree-kill, exactly one
376
+ install across concurrent processes, explicit install-disable refusal, and
377
+ Vitest's default no-install environment.
378
+
379
+ - **Downgrade TypeScript `unsafe-regex` to advisory and suppress escaped-before-
380
+ assignment false positives** (refs #932) — the coarse dynamic `RegExp`
381
+ heuristic no longer blocks edits and recognizes escape/replace calls in a
382
+ same-file identifier initializer; structural ReDoS detection remains with
383
+ the `redos-nested-quantifier` ast-grep rule.
384
+
385
+ - **Review-graph persistence no longer serializes or compresses on the event
386
+ loop** (refs #939) — debounced snapshots are materialized in one lazy,
387
+ unref'd worker and streamed through gzip into the new canonical
388
+ `review-graph.json.gz` cache. The main thread promotes only the current
389
+ generation's atomic staged file, so the synchronous CLI/exit flush can
390
+ supersede an in-flight worker without a stale overwrite. Loads retain one
391
+ release of fallback support for legacy uncompressed `review-graph.json`
392
+ snapshots; worker failures are logged and degrade to a synchronous persist.
393
+ Persist telemetry now records element count, raw/gzip bytes, serialization
394
+ and write time, and whether the work was offloaded.
395
+
396
+ - **Raise and make the review-graph persist ceiling useful** (refs #936) — the
397
+ default `GRAPH_PERSIST_MAX_ELEMENTS` cap is now 500,000 (still overrideable
398
+ through `PI_LENS_GRAPH_PERSIST_MAX_ELEMENTS`), matching measured startup
399
+ load/reindex costs and allowing the ~208,000-element #919 repository to
400
+ persist completely. Above the cap, persistence retains whole-file node groups
401
+ in existing reverse-dependency-centrality order plus induced edges that fit,
402
+ instead of dropping the snapshot. Exact total-versus-persisted node/edge
403
+ counts are stored and surfaced; read-only queries may use the partial graph,
404
+ while incremental builds reject it as a complete base.
405
+
406
+ - **Logger hot paths now coalesce queued lines and rotate during long sessions** (refs #935) — contiguous NDJSON entries drain through one append up to each truncate boundary while retaining peek-then-remove exit safety and one-write cross-process atomicity. `sessionstart.log` now uses one shared asynchronous writer for ordinary diagnostics (with the crash-adjacent LSP launch write intentionally synchronous), and latency/cascade/tree-sitter/bus-event logs enforce the existing 10 MB cap in process.
407
+
408
+ - **Incremental review-graph updates avoid redundant whole-graph copies and
409
+ index rebuilds** (refs #939) — file re-extraction now rebuilds derived indexes
410
+ once, immutable edges are array-copied without cloning every edge object, the
411
+ updated graph itself becomes the workspace snapshot, and debounced persistence
412
+ defers its O(graph) array materialization until the quiet-window flush.
413
+
414
+ - **Reverse-dependency indexes update at import-edge granularity** (refs #939)
415
+ instead of rebuilding from every graph edge after a one-file edit. Body-only
416
+ edits reuse the cached index without rewriting the project snapshot; import
417
+ changes patch only the touched `imports` and `importedBy` buckets.
418
+
419
+ - **Review-graph file-cap degradation is now explicit and count-honest** (refs
420
+ #921) — `project_report` says a capped project has “more than N files” instead
421
+ of presenting the cap+1 early-exit sentinel as an exact count. `module_report`
422
+ now marks graph-backed `usedBy`, blast-radius provenance, and
423
+ `semantic.source` as `unavailable:file-cap` and emits an actionable warning
424
+ with the cap plus both `.pi-lens.json#maxProjectFiles` and
425
+ `PI_LENS_REVIEW_GRAPH_MAX_FILES` controls, keeping disabled data distinct from
426
+ a genuinely empty/cold graph.
427
+
428
+ - **Project scans release every scan-local fact after each file** (refs #886,
429
+ #939) instead of retaining source content, imports, summaries, and other
430
+ derived per-file facts until the scan ends. The live dispatch store remains
431
+ untouched; only the scanner-owned store is cleared.
432
+
433
+ - Repair eight non-compiling Java, C++, CSS and PHP tree-sitter rules (refs #884).
434
+
435
+ - Repair four non-compiling Go, Rust, and Kotlin tree-sitter rules (refs #884).
436
+
437
+ - **Project diagnostics now use one file-major scan pass** (refs #896) —
438
+ tree-sitter rules, fact rules, and bundled ast-grep share each eligible
439
+ file's content read while retaining their individual extension/size gates,
440
+ diagnostic ordering, cancellation behavior, and latency telemetry. Full
441
+ review-graph builds likewise hash the bytes already read for extraction
442
+ instead of rereading every file after the graph is built.
443
+
444
+ - **A project scan runs its rule set in one tree walk, not one walk per rule**
445
+ (refs #675) — `runQueriesOnFile` compiles a language's rules into a single
446
+ multi-pattern query and maps matches back to the owning rule, keeping per-rule
447
+ metavars, predicates, post-filters, caps and ordering. Measured at 3.3× on
448
+ identical matches; the scan's tree-sitter phase went **14.4s → 3.6s** and a
449
+ full `mode=full` scan of pi-lens **24.4s → 12.2s**.
450
+
451
+ - **Project scans parse each file once** (refs #675) — the tree-sitter rules and
452
+ the fact rules both tree-sitter-parse the same files, and running them as two
453
+ whole-project sweeps meant the second one re-parsed every file the first had
454
+ already parsed and evicted (357 of 357 first touches missed on capacity in a
455
+ 500-file `mode=full` scan). They now share one file-major pass: **722 parses →
456
+ 367, 892ms → 437ms of parse time, zero capacity misses**, same diagnostics in
457
+ the same order. The 50-entry cache is unchanged and was never the problem —
458
+ within a phase it already ran at a 96.8% hit rate, any capacity short of the
459
+ full working set saves nothing, and one big enough to hold it would cost
460
+ 150-300MB of resident WASM trees.
461
+
462
+ - **Tree-sitter cache pressure is measurable** (refs #675): every production
463
+ consumer, including partial-read expansion, uses the shared process-wide
464
+ client, and scope-isolated cache counters plus parser timing are emitted for
465
+ project scans and full review-graph builds.
466
+
467
+ - **Dead incremental-parse cache API removed.** `TreeCache.incrementalUpdate`,
468
+ `calculateEdit`, and `invalidate` had no callers; the header's "10-100×
469
+ speedup on edits" was advertising a path nothing ever took.
470
+
471
+ - **Faster LSP teardown and tool probes** (refs #448) — `killProcessTree`
472
+ resolves on the child's exit event instead of sleeping the full 1.5s
473
+ SIGTERM→SIGKILL escalation window (saves ~1.5s per graceful client
474
+ shutdown); tool availability checks skip `--version` spawns for binaries
475
+ that aren't on disk and no longer re-verify a probe-cache-validated managed
476
+ install on every analyze.
477
+
478
+ ### Fixed
479
+
480
+ - **Read-before-edit guard now survives a session resume (#1041)** — the
481
+ guard's read-set was in-memory only, so a `pi --session <id>` resume (which
482
+ resets the runtime to a fresh empty guard) falsely `zero_read`-blocked the
483
+ first edit of any file read in the prior session. The read-set now rides the
484
+ same #190 `PersistedSessionState` save/load path that widget diagnostics use:
485
+ `ReadGuard.exportState()`/`importState()` persist and rehydrate `reads`,
486
+ reconciling each read against current disk (line-hash verified) so a resume
487
+ drops any read whose file changed or vanished — a rehydrated read never masks
488
+ a real staleness. Forked sessions adopt the parent's read-set via the same
489
+ in-memory hand-off as widget state. Backward-compatible: the field is optional
490
+ and pre-#1041 persisted sessions load cleanly as "no prior reads".
491
+
492
+ - **`turn_summary_emit` quiet-window task no longer fails on a stale pi ctx (the #483 quiet window's most frequent live-dogfood error — 55×)** —
493
+ the `agent_settled` quiet-window task `turn_summary_emit` reads the
494
+ `lens-turn-summary` flag through `pi.getFlag()` (via `getLensFlag`). The task
495
+ holder's `pi` is refreshed on every activation, but the quiet window is fired
496
+ fire-and-forget from `agent_settled`, so an interim session
497
+ replacement/reload (`ctx.newSession/fork/switchSession/reload` — common in the
498
+ in-process subagent flow #473) invalidates that captured `pi`; the very next
499
+ `pi.getFlag()` then throws the SDK's stale-ctx guard. That throw sat OUTSIDE
500
+ the existing `sendMessage` try/catch, so it escaped the task and the scheduler
501
+ logged `quiet_window: task "turn_summary_emit" failed: … stale after session
502
+ replacement or reload` — 55 times across two weeks of dogfooding, the single
503
+ most frequent error in `~/.pi-lens/sessionstart.log`, and it fired even with
504
+ the feature OFF (the flag READ throws before the flag value is known). Fix:
505
+ the task now recognises the stale-ctx throw (new `isStaleExtensionCtxError`
506
+ message matcher) at both pi touch points (flag read + emit) and degrades to a
507
+ logged no-op — the session that run's summary belonged to is gone, so there is
508
+ nothing to emit into — while a genuine error still propagates to the scheduler
509
+ (recorded `ok:false`). User impact was minimal (a lost summary for the exact
510
+ settle that raced a session swap, plus log noise), not silent feature
511
+ degradation: in steady state the task runs `ok:true`. Separately, the
512
+ quiet-window runner now logs the failing task's **stack** (not just `${err}`'s
513
+ message) so the next failure is diagnosable from the log alone; it remains
514
+ strictly non-fatal (per-task try/catch, `runQuietWindow` never rethrows, and
515
+ `agent_settled` invokes it fire-and-forget — a failing task can never break the
516
+ turn). Regression test drives the registered task against a `pi` whose
517
+ `getFlag` throws the stale-ctx error and asserts a no-op (no throw, no emit);
518
+ it fails pre-fix.
519
+
520
+ - **Word index no longer serves stale postings after a case/separator-divergent edit (refs #1025, closes #1025 item #2)** —
521
+ the word index's `docLengths`/`forward`/`fileMtimes` maps are keyed on file
522
+ paths, but the full/incremental BUILD keyed on the on-disk casing the
523
+ `readdirSync` walk reports while the per-edit UPDATE seams
524
+ (`clients/dispatch/integration.ts`, `clients/mcp/analyze.ts`) key via
525
+ `path.resolve()` on the raw tool-input path. When those forms differed only by
526
+ separator (`\` vs `/`, always) or case (on a case-insensitive FS), the plain-`Map`
527
+ keying made `forward.has(doc.path)` MISS, so the stale build-form entry was never
528
+ removed — a duplicate doc entry with stale postings served until a full rebuild,
529
+ silently degrading `symbol_search` relevance (the path-key divergence class,
530
+ #1020/#210). Fix: a new typed **`PathKeyedMap<V>`** primitive
531
+ (`clients/path-keyed-map.ts`) folds every key through a caller-supplied
532
+ normalizer INTERNALLY, so keying a raw path is structurally impossible; the word
533
+ index now routes all three path maps through it using the shared, exported
534
+ `wordIndexKey` (= `normalizeEphemeralMapKey` — cheap slash-fold + win32-lowercase,
535
+ NO `realpathSync`, so the hot BM25 path adds no filesystem I/O), and the
536
+ token-keyed `postings` cleanup compares `WordHit.file` through the same
537
+ normalizer, and the incremental-refresh set-difference
538
+ (`refreshWordIndexIncrementally`) now computes its `current`/`oldSet` keys in
539
+ that same normalized space (previously raw-keyed — a file whose stored display
540
+ path became the edit form would have been double-counted as churn and
541
+ dropped-then-re-added, defeating the incremental path and opening a
542
+ drop-before-readd regression window). `PathKeyedMap` stores each value's
543
+ ORIGINAL display path, so `symbol_search`/rendering surfaces still see the
544
+ on-disk form, not the folded key. Build-form, edit-form, and refresh keys now
545
+ converge on ONE entry regardless of casing/separator. The runtime-coordinator suspect maps (#1025 item #4) are left
546
+ for a follow-up (unproven trigger, and their correct normalizer is the
547
+ realpath-based `normalizeMapKey` matching the file's own `_fileSeq` convention —
548
+ a larger, higher-risk change across the concurrent-session drain logic).
549
+
550
+ - **Cascade honesty: degraded/uncomputed impact no longer renders as clean (refs #1023, closes #1023)** —
551
+ the cascade impact subsystem previously emitted an all-clear that was
552
+ indistinguishable from "genuinely nothing impacted" whenever it could NOT
553
+ compute impact: the review graph was size-skipped (`too_many_files` over
554
+ `PI_LENS_REVIEW_GRAPH_MAX_FILES`) or root-skipped (`unsafe_root`), the changed
555
+ file had no graph node, or the deferred compute threw — a silent under-report
556
+ exactly where downstream breakage is most likely (large/over-cap monorepos)
557
+ (#533). Impact results now carry a first-class `indeterminate` marker
558
+ (`clients/review-graph/types.ts`) set ONLY on a degraded/cold/errored/
559
+ missing-node compute; the already-known degraded state is threaded from
560
+ `getLastGraphBuildInfo().mode`/`skipReason` at the compute call site (no
561
+ re-derivation), a `missing_node` marker comes straight from
562
+ `computeImpactCascade`, and a thrown compute is tagged in the pipeline catch. A
563
+ new `indeterminate` `CascadeSkipReason` flows to the EXISTING turn-end seam
564
+ (`clients/runtime-turn.ts`), which renders a short honest note in the ADVISORY
565
+ tier ("Cascade could not compute downstream impact … a clean cascade result
566
+ does not cover them") instead of silence. The advisory tier (not the blocker
567
+ tier) is deliberate: an over-cap monorepo skips the graph on every edit, so a
568
+ blocker would fire hard and never clear turn state every turn (over-escalation,
569
+ the mirror of the silent-all-clear bug). A HEALTHY graph with a genuinely empty
570
+ dependent set stays silent (keyed strictly off the degraded mode/marker, never
571
+ off `neighbors.length === 0` alone — no crying wolf). Companion: the Tier-3 quiet-window reconcile
572
+ (`clients/lsp/cascade-tier.ts`) now RE-INJECTS a `resolved-found` cold-neighbor
573
+ error through the same neighbor→turn-end formatting via an `onResolvedFound`
574
+ callback wired in `index.ts`, instead of leaving it logs-only. The
575
+ neighbor-touch/freshness pipeline (neighborTouchCache, recentlyCleanNeighborCache,
576
+ the 40-neighbor budget, tier-aware touch model, deferred cascade flow) is
577
+ untouched.
578
+
579
+ - **Disposition marks under a nested monorepo language root are no longer silently dropped (refs #1030, closes #1030; pairs with #1024)** —
580
+ a `false-positive`/`flagged`/`defer` mark recorded via `lens_diagnostic_mark`
581
+ was invisible to the per-edit dispatch filter for every file living under a
582
+ nested language-root marker (`packages/app/tsconfig.json`, a nested
583
+ `package.json`/`pyproject.toml`/`go.mod`/etc.) in a monorepo. The mark tool
584
+ writes dispositions keyed on the **project root** (`runtime.projectRoot`), but
585
+ `dispatcher.ts`'s `applyDispositions` read keyed on `ctx.cwd`, which
586
+ `createDispatchContext` resolves to the nearest **language root** via
587
+ `resolveLanguageRootForFile` (a dispatch-internal tool/config-resolution
588
+ detail). Because both the anchor AND the persisted store location
589
+ (`getProjectDataDir`, keyed on `cwd`) derive from that base, write and read
590
+ computed different anchors AND opened entirely different
591
+ `diagnostic-dispositions.json` files — a total, deterministic failure (not
592
+ probabilistic) for every file under a nested marker. The tool returned success
593
+ while dispatch never saw the mark (a #533 honesty failure); `suppress` survived
594
+ only via its independent inline `pi-lens-ignore` comment. Fixed by reading the
595
+ disposition layer from the project root (`ctx.projectRoot`, already computed in
596
+ `createDispatchContext`), matching the write side — anchor base and store base
597
+ now agree on both sides. `resolveLanguageRootForFile` is untouched and still
598
+ drives actual dispatch/tool-root resolution. Orthogonal to #1024 (which fixes
599
+ the path *form* of the anchor); the two compose for a monorepo-stable anchor.
600
+ No new dependencies.
601
+
602
+ - **Disposition anchor path-form stability (refs #1024, closes #1024)** — the
603
+ `dd:`/`ddw:` disposition anchors (clients/diagnostic-dispositions.ts) are now
604
+ derived from a single canonical path form, closing a #533-adjacent
605
+ dropped-signal bug (sibling of #210/#1020). `relativeFile` — the chokepoint
606
+ both `computeStrictAnchor` and `computeWeakAnchor` route through — computed
607
+ `path.relative` on whichever raw path form the caller happened to hold: the
608
+ mark tool (tools/lens-diagnostic-mark.ts) passes a RAW `cwd` /
609
+ `path.resolve(cwd, arg)`, while the dispatch read side
610
+ (clients/dispatch/dispatcher.ts `createDispatchContext`) passes
611
+ `normalizeMapKey`-canonicalized (realpath'd) cwd/filePath. When the two forms
612
+ diverged (Windows drive/segment case, symlink/realpath), the agent's own
613
+ `false-positive`/`flagged` mark anchored under one id and the later
614
+ `applyDispositions` lookup under another, so the mark was invisible and the
615
+ "resolved" diagnostic kept re-firing / re-blocking every turn. Fix routes
616
+ BOTH `cwd` and `filePath` through `normalizeMapKey` inside `relativeFile`
617
+ (the same normalizer the read side already relies on — the #210 "every guard
618
+ map keys through `normalizeFilePath`" invariant), so write and read derive
619
+ identical anchors regardless of the form the caller passed. Anchor semantics
620
+ are unchanged: strict (`dd:`, line-hash) vs weak (`ddw:`) split, the prefixes,
621
+ and the `..`-escape fallback all behave exactly as before — only the path
622
+ component is now form-stable. Existing on-disk marks written under the old raw
623
+ form re-anchor to the canonical id; a stale orphan is re-derivable and simply
624
+ reappears once for the agent to re-mark (identical to today's pre-fix
625
+ behavior), so no read-time legacy fallback is needed.
626
+
627
+ - **`lens_diagnostics mode=all` no longer replays a resolved blocker (refs #1020, closes #1020)** —
628
+ the widget-state `files` map was keyed by the raw, non-normalized path string,
629
+ so the SAME file could land under two different key forms in one session:
630
+ the forward-slash form (`C:/…/x.ts`) the LSP client + cascade fold produce via
631
+ `normalizeFilePath`, and the backslash form (`C:\…\x.ts`) that `mode=full`'s
632
+ clean reconcile (`result.filePath`) and `path.resolve`/event inputs produce on
633
+ Windows. A stale blocking entry and the fresh clean entry then coexisted as two
634
+ separate map entries. `mode=full` rendered clean because its merge re-keys every
635
+ summary through `path.resolve`, but `mode=all`'s `formatAllMode` reads
636
+ `getFileDiagnosticSummaries()` verbatim with no dedup, so it saw both and
637
+ rendered the stale entry's `blocking:1` as a 🔴 — a resolved state that replayed
638
+ as still-broken on every `mode=all` (worst on Windows + resumed sessions), and a
639
+ #533 honesty failure since the tool prompt tells agents to use `mode=all` to
640
+ verify no blockers remain. Fixed at source: every write/read seam on the `files`
641
+ map (and the `diagnosticsWriteGuard`) now folds its key through one normalizer,
642
+ `normalizeEphemeralMapKey` (slash-fold + win32-lowercase, no filesystem I/O —
643
+ chosen over the `realpathSync`-backed `normalizeMapKey` because this is a hot
644
+ write path), and `importWidgetState` folds persisted keys on rehydrate so a
645
+ `/`-key snapshot and a fresh `\`-key write collapse across a resumed session.
646
+ The human-readable display path on each record is preserved verbatim (rendering
647
+ and path-relative math unchanged). No new dependencies.
648
+
649
+ - **Cache-friendly ephemeral context injection (refs #1016, closes #1016)** — the
650
+ `context` event handler in `index.ts` now splices pi-lens's ephemeral turn-end
651
+ findings in **immediately before the final message** instead of prepending them
652
+ at message index 0. Prepending flipped `messages[0]` every turn, which
653
+ invalidated the entire prompt-cache prefix on every prefix-caching provider
654
+ (Anthropic, Bedrock, and OpenAI — all key the cache on the exact token prefix).
655
+ Inserting before the last message keeps `messages[0]` (the real first user turn)
656
+ byte-stable so the prior conversation stays cached, while still keeping the real
657
+ user prompt as the trailing message — preserving the trailing-`user` cache
658
+ breakpoint and the historical `fe0ed5da` guarantee that input is never empty (the
659
+ existing transcript is always preserved, never dropped; an empty transcript falls
660
+ back to the prior prepend-only behavior). Because the `context` event fires
661
+ before **every** provider call (not just at turn boundaries), a trailing-role
662
+ guard (`isPlainUserPrompt`) only splices before the last message when it is a
663
+ plain user prompt; when the tail is a mid-loop `tool_result` (or assistant/tool
664
+ message) the findings are **appended** after the whole transcript instead — a
665
+ pure append that both preserves the `tool_use`↔`tool_result` adjacency all three
666
+ providers require (splicing between them is an HTTP 400) and keeps the entire
667
+ prior transcript as an untouched cache prefix. Changes shipped `dist` behavior
668
+ (the handler lives in the bundled dist path).
669
+
670
+ - **Fixed: `ts-ssrf` no longer flags fixed/const endpoint URLs built with
671
+ `new URL(...)` as SSRF sinks, while still catching tainted URLs** (closes
672
+ #1000, refs #533, #963) — a project-wide pi-free scan flagged fixed outbound
673
+ OAuth/profile endpoints as SSRF: `fetch(authUrl.toString())` where
674
+ `authUrl = new URL("auth/authorize", \`${BASE_URL_CLINE}/\`)` with a fixed
675
+ (module-const or imported) base. The `ts_ssrf_sink` post-filter already
676
+ exempted a `fetch()` of a file-local `const` literal string (#963) but had no
677
+ awareness of the `new URL(literalPath, fixedBase)` shape, so
678
+ `url.toString()`/`url.href` fell through to the broad taint heuristic. Extended
679
+ the post-filter to also exempt `fetch(u.toString())`/`fetch(u.href)` when `u`
680
+ resolves (same file) to a clean `const u = new URL(<literalPath>, <fixedBase>)`
681
+ — literal path plus a base that is a literal, a file-local literal `const`, or
682
+ an import binding (the `URL` ctor may be aliased, e.g. `NodeURL`). Query params
683
+ added later via `searchParams.set(...)` don't control origin/path and never
684
+ taint the destination. Still fires (regression fixtures added) when the base
685
+ or path comes from a function parameter, `process.env`/config (including a
686
+ const initialized from `process.env`), request data, or a parsed
687
+ redirect/`location` value — a missed SSRF stays far worse than one FP.
688
+ Adversarial review then closed two bypasses in that exemption: (1) a
689
+ post-construction origin mutation (`u.host = req.x` / `u.href = …` /
690
+ `u["host"] = …`, incl. augmented assignments) now re-taints the receiver and
691
+ fails closed — only query-only writes (`u.search`/`u.searchParams`) stay
692
+ exempt; (2) a request-tainted function *parameter* base is no longer exempted
693
+ just because an unrelated same-named module-level `const` literal exists
694
+ (scope-aware shadow check; imported bases stay trusted).
695
+
696
+ - **Fixed: `no-javascript-url`/`no-javascript-url-js` no longer flag defensive
697
+ `javascript:`-URL filters** (refs #533) — a dogfood run flagged code that was
698
+ *rejecting* `javascript:` links (e.g. `url.startsWith("javascript:")` inside a
699
+ guard) as if it were introducing one. The rule matched the `javascript:`
700
+ string literal alone with no way to tell "used as a sink" from "used to
701
+ detect/block it". Narrowed both rules to also exclude the literal when it's
702
+ the needle in `.startsWith`/`.endsWith`/`.includes`/`.indexOf`/`.search`/
703
+ `.match`, or one side of an `===`/`!==`/`==`/`!=` comparison — deliberately
704
+ NOT `.replace`/`.replaceAll`, since the literal there could be the malicious
705
+ replacement argument rather than the defensive search argument, and
706
+ excluding the whole call would hide that true positive. Both new exclusions
707
+ deliberately avoid `stopBy: end` (unlike the pre-existing test-literal
708
+ exclusion, which needs it) so the literal must be a DIRECT argument/operand
709
+ of the defensive call/comparison, not merely somewhere in its subtree — an
710
+ adversarial review caught that `stopBy: end` let a real sink smuggled
711
+ inside the call/comparison (e.g. `candidates.includes(link.href =
712
+ "javascript:...")`, a comma-operator sink, or `(a.href = "javascript:...")
713
+ === expected`) go unflagged. Added valid fixtures reproducing the filter FP
714
+ and invalid fixtures for the replace-based sink and all three smuggled-sink
715
+ bypasses, confirmed against the real ast-grep 0.45.0 binary.
716
+
717
+ - **Contained spawn/callback failures that could crash the host (pidusage bug
718
+ class)** (refs #533) — a best-effort telemetry sampler recently killed a live
719
+ pi host with `Error: spawn UNKNOWN` (uncaughtException) because a bundled
720
+ dep's `child_process.spawn(...)` threw SYNCHRONOUSLY with no try/catch, from
721
+ a detached context the caller's `try/await/catch` could not contain. Audited
722
+ every spawn/detached-callback site in `clients/` and hardened the at-risk
723
+ best-effort/background ones to the repo's existing guard shape (try/catch
724
+ around the synchronous `spawn` + resolve gracefully, matching the async
725
+ `'error'`-listener path): `safeSpawnAsync`'s core spawn (the shared wrapper
726
+ dozens of best-effort callers rely on to NEVER reject), its Windows
727
+ `killPidTreeSync` (runs from `process` exit/signal handlers, where a throw
728
+ becomes an uncaughtException during shutdown; the POSIX branch was already
729
+ guarded), `SgRunner.exec`'s three ast-grep spawn branches, all six installer
730
+ tool-discovery/verify probes, the PSScriptAnalyzer runner's `spawnPs`, and
731
+ the MCP `analyzeFileFresh` worker fork. A synchronous spawn throw now
732
+ resolves the operation's normal failure value instead of escaping as an
733
+ unhandledRejection/uncaughtException. Behavior-preserving otherwise.
734
+
735
+ - **Fixed: opengrep security findings now surface project-wide in
736
+ `lens_diagnostics mode=full`** (refs #585, #584, #533) — opengrep's
737
+ whole-tree CLI scan ran at session-start and cached its findings, and the
738
+ cache-only extractor registry (`project-diagnostics/extractors.ts`) even
739
+ registered an `"opengrep"` row — but #585 replaced that registry in
740
+ production with `fetchFreshProjectDiagnostics`
741
+ (`project-diagnostics/fresh-fetch.ts`), whose `ANALYZER_IDS` list omitted
742
+ opengrep. Net: opengrep scanned + cached, yet nothing production read it
743
+ back, so semgrep-grade security findings (ERROR→blocking, CWE-tagged) never
744
+ reached the agent for unedited/project-wide files — a scan-and-orphan
745
+ honesty gap (#533). `fetchFreshProjectDiagnostics` now runs opengrep the
746
+ same way it runs gitleaks/trivy: an availability probe (opengrep is
747
+ structurally always-on, so no static project-type gate), then a fresh scan
748
+ that JOINS the session-start scan of the same root via
749
+ `SecurityScanClient.dedupeScan` rather than double-spawning a heavy scan,
750
+ with the result written back to cache and adapted to project diagnostics.
751
+ The per-edit aux-LSP push path (`clientScope: with-auxiliary`,
752
+ `AUXILIARY_LSP_PROFILES`) was verified already-working and is untouched;
753
+ opengrep stays excluded from the push-only LSP workspace sweep (#584). The
754
+ now-dead cache-only `extractCachedProjectDiagnostics` reader — the parallel
755
+ registry that shadowed `ANALYZER_IDS` and let opengrep silently diverge — was
756
+ removed so a single source of truth (#883) remains.
757
+
758
+ - **Fixed: test-runner findings now surface project-wide in `lens_diagnostics
759
+ mode=full`** (closes #1004, refs #585, #533) — the same `ANALYZER_IDS`
760
+ omission #585/#1003 fixed for opengrep also orphaned test-runner: the
761
+ per-edit turn_end test fire (`runtime-turn.ts`) caches failures under
762
+ `"test-runner-findings"`, but `fetchFreshProjectDiagnostics`
763
+ (`project-diagnostics/fresh-fetch.ts`) never read that cache back, so test
764
+ failures never reached the agent for unedited/project-wide `mode=full`
765
+ calls. Unlike opengrep/gitleaks/trivy, this is wired as a CACHE-READ (not a
766
+ fresh scan): test-runner has no "whole project" run to trigger — turn_end
767
+ only ever runs the targeted, cascade-aware test files touched by that
768
+ turn's edits — so `fetchFreshProjectDiagnostics` now peeks at the same
769
+ `"test-runner-findings"` cache key (never re-running the suite, never
770
+ writing back) and adapts it via the existing
771
+ `testRunnerFindingsToProjectDiagnostics`. Added a coverage guardrail
772
+ (`tests/clients/project-diagnostics/analyzer-coverage.test.ts`) that greps
773
+ the real session-start (`runHeavyweightTask`) and EVERY turn_end
774
+ `cacheManager.writeCache` call site in `runtime-turn.ts` (excluding a
775
+ short, explicit bookkeeping exclude-list — `errorDebt`/`turn-end-findings`/
776
+ `turn-end-findings-last` — that isn't analyzer-shaped) and asserts every
777
+ remaining id is a member of `ANALYZER_IDS`, so this whole #585 bug class
778
+ can't silently regress again for a future analyzer added to EITHER writer.
779
+ Adversarial review also caught two honesty/fidelity gaps in the initial
780
+ fix: (1) test-runner is cache-read, edit-scoped (the targeted test files
781
+ touched by the most recent turn's edits), unlike every other analyzer's
782
+ fresh whole-project scan — `mode=full` now emits an explicit "coverage is
783
+ edit-scoped, NOT a full-project run" caveat whenever test-runner
784
+ contributed findings (not only when cold), and a `stale` cached result
785
+ (the turn advanced before the test run finished) is prefixed
786
+ `[stale — from a prior turn]` in its diagnostic message, matching the
787
+ one-shot turn-context message's own stale wording; (2) `TestFailure.location`
788
+ (`"file.ts:42"`, set by the vitest/jest JSON parser in
789
+ `test-runner-client.ts`) never reached `ProjectDiagnostic.line`, so every
790
+ test-runner finding rendered as `L?:` — `runner-adapters/runner-findings.ts`
791
+ now parses a numeric trailing `:line[:col]` out of `location` (left alone
792
+ for pytest/mix's non-numeric `location` strings, which carry a test name or
793
+ module, not a line).
794
+
795
+ - **Guarded Windows CPU/RSS resource sampling so a best-effort sampler can no
796
+ longer crash the pi host** (refs #620, #533) — `clients/resource-sampler.ts`
797
+ sampled CPU%/RSS via `pidusage`, whose Windows path shells out to `gwmi`
798
+ through an internal `spawn(..., { shell: "powershell.exe" })` that has no
799
+ try/catch and runs from inside a ChildProcess `close` callback. Under real
800
+ Windows handle/commit pressure that spawn can throw `spawn UNKNOWN`
801
+ (errno -4094) **synchronously in that detached callback**, which the call
802
+ site's `try { await pidusage() } catch {}` cannot catch → `uncaughtException`
803
+ → the whole host process dies (observed live). pidusage 4.0.1 offers no
804
+ option to avoid the gwmi path. The sampler now runs its OWN fully guarded
805
+ `Get-CimInstance Win32_Process` query on Windows (never calling `pidusage`
806
+ there), mirroring the existing `findDescendantPidsWindows` guard: a
807
+ synchronous spawn throw, a child `error` event, and a non-zero/garbage exit
808
+ all resolve to a partial/empty map — the sampler can now only ever lose a
809
+ data point, never throw into the heartbeat/spawn path. RSS comes from
810
+ `WorkingSetSize`; CPU% is preserved via the same KernelModeTime/UserModeTime
811
+ delta-over-elapsed-wall-time computation gwmi uses (a small per-pid history
812
+ tracks the prior cumulative CPU time). Linux/macOS keep using `pidusage`
813
+ (procfs/`ps` — not the crash vector) unchanged. A pid absent from the
814
+ returned map still means "unsampled this tick", never zero.
815
+
816
+ - **Stabilized the two remaining flaky/environment-sensitive tests tracked in
817
+ #902.** LSP workspace-diagnostics sweep flush-count assertions
818
+ (`workspace-diagnostics-sweep-batch-open.test.ts`,
819
+ `workspace-diagnostics-sweep-preopen-chunk.test.ts`) pinned an exact flush
820
+ count, but the pre-open pass's real `fs.promises.readFile` genuinely races
821
+ the real 100ms `WatchedFilesQueue` debounce timer — under full-suite
822
+ scheduler contention that can legitimately fragment or merge a chunk's
823
+ flush by one. Replaced the exact-count assertions with invariant checks
824
+ (coalescing happened; nowhere near one flush per file; no lost/duplicated
825
+ URIs) that tolerate that jitter without losing the #608/#621 regression
826
+ coverage — a test-robustness fix, not a product bug. Separately, two
827
+ ast-grep dispatch test harnesses
828
+ (`ast-grep-rule-tests.test.ts`, `ast-grep-catalog-rules.test.ts`) shelled
829
+ out to the real `ast-grep` CLI via a raw `execFileSync(..., { shell: true
830
+ })` per call — an uncached cmd.exe wrapper on Windows whose own exit code
831
+ can mask the real child's and which can intermittently fail to spawn at
832
+ all under the process-creation pressure of a full parallel test run.
833
+ Switched both to the already-hardened `safeSpawn` (`clients/safe-spawn.ts`,
834
+ #817) — cached PATH+PATHEXT resolution, direct `.exe`/`.com` spawn with no
835
+ shell involved — the same deterministic resolution production dispatch
836
+ already relies on. The third tracked item
837
+ (`startup-overhead.test.ts`'s "quick mode self-reports within 100ms")
838
+ was already reworked to a generous fixed budget + `retry: 2` in an earlier
839
+ pass; confirmed still adequate, no further change needed.
840
+
841
+ - **`pi-lens build-graph` honestly reports a capped, over-the-cap persist**
842
+ (closes #924, refs #936 limit 3) — the CLI's build-attempt check no longer
843
+ mistakes the benign "succeeded, but persisted a partial subgraph" reason
844
+ (#960's over-cap circuit-breaker) for a hard failure: it now exits 0 and
845
+ prints a `PARTIAL persist` line with `persistedNodes=X/Y`,
846
+ `persistedEdges=X/Y`, and the cap that was hit, instead of either silently
847
+ reporting "done" or failing loudly on a build/persist that actually
848
+ succeeded. `flushReviewGraphPersist()` now returns the persisted
849
+ `coverage` so any standalone caller can make the same distinction.
850
+
851
+ - **`ts-ssrf` no longer trusts naming convention as proof of a fixed URL**
852
+ (fixes #963) — the post-filter now resolves a bare `fetch(IDENT)` argument
853
+ against the file's AST and exempts it only when `IDENT` provably resolves
854
+ to a single `const` declarator initialized with a string literal (or a
855
+ template literal with no `${...}` substitutions). SCREAMING_SNAKE_CASE
856
+ naming is no longer sufficient by itself: `const TARGET_URL = req.query.url`
857
+ still flags, since the identifier's *initializer* — not its name — is
858
+ what's checked. Ambiguous/shadowed declarations, `let`/`var` bindings,
859
+ reassigned identifiers, and unresolved identifiers all fall through to the
860
+ existing broad heuristic and keep being flagged.
861
+
862
+ - **Remaining test-suite spawns routed off `shell:true` to close the Windows-spawn flake class** (refs #902) —
863
+ `tests/clients/ast-grep-rule-precedence-followups.test.ts` (CLI probe + `runCli`),
864
+ `tests/clients/coderabbit-ast-grep-rules.test.ts` (vendored-catalog smoke),
865
+ `tests/clients/dispatch/runners/ast-grep-playground-verify.test.ts` (async `runVerify`),
866
+ `tests/clients/dead-code-client.test.ts` (vulture probe), and
867
+ `tests/clients/lsp/clangd-lazy-indexing.test.ts` (clangd `where`/`which` probe) now spawn
868
+ through the hardened `safeSpawn`/`safeSpawnAsync` (`clients/safe-spawn.ts`, #817) instead of
869
+ a raw `execFileSync`/`spawnSync`/`spawn`/`execSync` with a fresh, uncached `cmd.exe` wrapper
870
+ per call — the documented intermittent ENOENT/EAGAIN source under windows-latest CI's
871
+ parallel process-creation pressure. Completes the sweep PR #993 started for the two
872
+ ast-grep runner test files; `git` spawns are left as-is (no `.cmd` shim, so out of the flake
873
+ class) and the `mcp/*` and MCP harness spawns that need bidirectional stdin streaming are
874
+ left as-is (`safeSpawnAsync` only exposes a close-based result, not a live stdin/stdout pipe).
875
+
876
+ - **Tree-sitter post-filters no longer leave silently dead rules** (refs #879) —
877
+ the 25 unknown filter references were resolved by implementing eight bounded,
878
+ fail-open-on-filter-error AST checks, expressing two conditions directly in
879
+ their queries, and removing fifteen rules whose promised semantic/framework
880
+ analysis could not be supported honestly. Coarse resource, assertion, and
881
+ sensitive-`memset` heuristics are advisory rather than blocking.
882
+
883
+ - **Capped word indexes disclose partial coverage** (refs #928) — snapshots now
884
+ persist indexed-file count and truncation state, and both `symbol_search`
885
+ surfaces report coverage instead of presenting capped zero-hit results as
886
+ authoritative.
887
+
888
+ - **`pilens_health` keeps disabled LSP servers visible** (refs #927) —
889
+ permanently broken server/root pairs now render with their failure count, and
890
+ temporary circuit-breaker cooldowns expose their retry deadline.
891
+
892
+ - **Cold `symbol_search` failures are now observable and honest** (refs #926) —
893
+ unavailable results distinguish an active build, a safety refusal, and the
894
+ last build's failure, while background build/persist errors reach a persistent
895
+ NDJSON log.
896
+
897
+ - **Failed heavyweight analyzers no longer masquerade as clean runs** (refs #925) —
898
+ unsuccessful results are reported distinctly, omitted from cache so the next
899
+ session retries, and valid fix-worklog records survive neighboring corrupt lines.
900
+
901
+ - **The footer refreshes as LSP servers come online during a cold
902
+ `lens_diagnostics mode=full` sweep** (refs #798), instead of showing
903
+ `LSP Inactive` until turn end. The repaint captures UI methods during the
904
+ active tool event, so async warm-up never touches a stale session context.
905
+
906
+ - **Tree-sitter WASM aborts are now visible instead of silently disabling
907
+ structural analysis for the rest of the process** (refs #915). The shared
908
+ runtime records a process-wide, timestamped `restart_required` health state,
909
+ logs a one-time actionable error, exposes it through `pilens_health`, and
910
+ marks project-scan responses with `treeSitterStatus`. A poisoned scan remains
911
+ truncated and never replaces the last complete snapshot. In-process retry is
912
+ deliberately unsafe: every new client imports the same cached `web-tree-sitter`
913
+ ES module and therefore reuses its corrupted Emscripten heap; restarting the
914
+ host is the isolation boundary.
915
+
916
+ - **`pilens_rebuild` can no longer destroy an npm-installed pi-lens** (refs
917
+ #920) — rebuilds are refused before spawning a package script unless the
918
+ package is a source checkout with `tsconfig.dist.json` outside
919
+ `node_modules`; installed servers also omit the tool from `tools/list`, so
920
+ subagent allowlists cannot discover it.
921
+
922
+ - **Review-graph background failures are no longer silent** (refs #919) —
923
+ `project_report`/`pilens_project_report` now surface the most recent build
924
+ attempt and its terminal skip/failure reason, including the persistence
925
+ element circuit-breaker. A dedicated `~/.pi-lens/review-graph.log` records
926
+ build and persistence starts, successes, skips, and failures, so a rejected
927
+ fire-and-forget build or an over-cap graph cannot look perpetually in progress.
928
+
929
+ - Resolve nested C# and F# project roots for dotnet builds (refs #895).
930
+
931
+ - **A mid-scan tree-sitter WASM abort no longer replaces the authoritative
932
+ project-diagnostics snapshot with a silently truncated result** (refs #891).
933
+ The partial scan is returned with `scanTruncated`, logs its completed/total
934
+ file counts and abort point, and leaves the previous complete cache intact.
935
+
936
+ - **Block-wrapped switch cases no longer report false fall-through errors**
937
+ (refs #910) — `switch-case-termination` now follows trailing statement
938
+ blocks to recognize a nested `break`, `return`, `throw`, or `continue`, while
939
+ still flagging empty and non-terminating blocks.
940
+
941
+ - **Project-wide enumeration now covers every registered file kind** (refs
942
+ #894) — `ALL_SCANNABLE_EXTENSIONS`, `WARMUP_SOURCE_EXTS`, and
943
+ `SUPPORTED_FILE_KINDS` now derive from the single `KIND_EXTENSIONS`
944
+ authority instead of three drifting language lists. TODO scans, symbol
945
+ search indexing, dominant-language LSP warmup, and language-profile
946
+ detection can now see Java, Swift, C/C++, PHP, and every other supported
947
+ kind. Code kinds keep priority over data/doc kinds (json/yaml/markdown/…)
948
+ inside the existing caps: the dominant-language LSP warm ranks code kinds
949
+ first, and the capped warmup/word-index walks fill code files before
950
+ non-code files, so a locale/fixture pile can't starve real languages.
951
+ Package-manager lockfiles (package-lock.json, pnpm-lock.yaml, …) are
952
+ filtered as generated artifacts, and the TODO scanner caps per-file reads
953
+ at 512 KiB. A coverage guard makes a newly registered kind automatically
954
+ enumerable — and classified as code or non-code — on both project-wide
955
+ paths.
956
+
957
+ - **CMake files now reach a real LSP server** (refs #892) — the CMake policy's
958
+ previous `lsp` fallback had no registered server and silently produced no
959
+ diagnostics. `cmake-language-server` now covers both `.cmake` files and the
960
+ canonical `CMakeLists.txt` basename, with managed pip installation.
961
+
962
+ - **Fish LSP policy is no longer dead wiring** (refs #893) — `fish-lsp` is now
963
+ registered for `.fish` files and auto-installed through npm; `fish_indent`
964
+ continues to run alongside it.
965
+
966
+ - **Editing an inherited tree-sitter rule now invalidates the inheriting language's RuleCache** (refs #878) — the cache key fingerprinted only the language's OWN rules directory, but `tsx` also runs the `typescript` rule set (`queriesForLanguage`), so a typescript-rule edit left the tsx entry's hash unchanged and stale compiled rules kept being served from the on-disk cache until a tsx rule happened to change. The fingerprint now covers the full effective rule set via `ruleFilesForLanguage`, a new loader-owned seam that derives from the same rule-source composition as rule selection, so the cache key can't drift from what the runner actually runs. The runner's cache-miss path also forces the query loader past its in-memory memo (`loadQueries(root, { force: true })`) — a correct key alone wasn't enough: the memo ignores rule-file mtimes, so within one process a miss re-persisted the PRE-edit rules under the fresh fingerprint and the staleness then survived restarts. The client's compiled-batch cache is likewise now keyed on rule content instead of rule ids — ids are stable across edits, so the shared client kept serving the pre-edit compiled patterns (and messages) for the process lifetime even after the reload. `CACHE_VERSION` bumped to `v6`.
967
+
968
+ - **Small edits no longer pay the entity-extraction cost** (refs #885) — the
969
+ <5-line skip threshold only guarded the zero-diagnostics early return; a
970
+ second `extractEntitySnapshot` block ran unconditionally, so trivial edits
971
+ still spent ~500-800ms per dispatch and, on unsaved buffers, parsed stale
972
+ disk content (thrashing the parse-cache entry). Extraction is now one
973
+ threshold-guarded block that receives the same `file.content` override the
974
+ diagnostics phase used.
975
+
976
+ - **The per-edit tree-sitter runner walks the tree once, not once per rule**
977
+ (refs #888) — the dispatch hot path ran ~30-40 `runQueryOnFile` walks per
978
+ edit behind a concurrency limiter that could not parallelize synchronous
979
+ WASM. It now calls `runQueriesOnFile` once (#675 batching) and distributes
980
+ the per-rule results; the per-rule `maxResults(10)` cap and modified-ranges
981
+ gating are unchanged.
982
+
983
+ - **A transient grammar-load failure no longer disables batched rule scans for the process lifetime** (refs #889) — `compileQueryBatch` cached `null` on ANY `build()` failure, including a transient `loadLanguage()` miss (offline lazy grammar fetch, mid-scan load error), so every later scan fell back to the per-rule path and paid ~3.3× forever. Load failures are now distinguished from genuine batch-compile failures: they are not cached (the next scan retries the load and recovers), with retries bounded at 3 consecutive load failures per rule set before the miss is cached, so a grammar that never loads doesn't hot-loop. Deterministic compile failures still cache `null` permanently. The query/batch cache keys also switched from a collision-prone 32-bit hash to the full pattern text.
984
+
985
+ - **module-report parses plain JS under the correct tree-sitter grammar** (closes
986
+ #887) — `tsLangForFile` hand-rolled a local extension map that sent
987
+ `.js`/`.mjs`/`.cjs` to the typescript grammar and `.jsx` to tsx, while every
988
+ other tree-sitter consumer resolves those extensions to `javascript` via the
989
+ shared `EXT_TO_LANG` (`clients/tree-sitter-shared.ts`), so each plain-JS file
990
+ was parsed and cached twice under two grammars (TreeCache keys are
991
+ `languageId:path`) and ran TS-grammar symbol queries on JS trees. It now
992
+ routes the extension-split kinds (jsts, and the c-vs-cpp split for cxx)
993
+ through the shared `resolveTreeSitterLanguage`, keeping the historical kind
994
+ default only for extensions the shared map does not cover (`.svelte`/`.vue`,
995
+ the C++ module-interface/Objective-C tail). The symbol extractor gains a
996
+ dedicated `javascript` defs/refs/import query set: the TypeScript symbol
997
+ queries do NOT compile against the javascript grammar
998
+ (`interface_declaration`/`type_alias_declaration`/`type_identifier` do not
999
+ exist there — a query naming them fails with "Bad node name"), so the
1000
+ javascript set is the same queries minus the type-only patterns, with class
1001
+ names matched as `(identifier)` instead of `(type_identifier)`.
1002
+
1003
+ - **A rule whose query fails to compile now warns once instead of silently reporting nothing** (refs #884) — both compile paths (`compileQueryBatch`'s per-rule drop and `compileRawQuery`, which every per-edit `runQueryOnFile` call falls back on) previously either `dbg()`-logged (invisible without verbose mode) or returned `null`/`[]` with no trail at all. They now call a shared `reportQueryCompileFailure`, mirroring the existing unimplemented-`post_filter` warning: one `console.error` per broken rule id, not per file. A new compile-guard test (`tests/clients/tree-sitter-rule-compile-guard.test.ts`) compiles every non-disabled shipped rule against its real grammar and caught the 32 rules #884 reports as currently broken (tracked there in a shrink-only `KNOWN_BROKEN` allowlist so follow-up fix PRs are forced to remove their entries, and the list can't grow or go stale unnoticed).
1004
+
1005
+ - **TreeCache mtime false misses and FIFO eviction** (closes #890) — a content
1006
+ hash match is now authoritative: a save-without-change (same bytes, newer
1007
+ mtime) is a cache hit that refreshes the entry's stat metadata instead of
1008
+ invalidating and re-parsing, and `get()` re-inserts hit entries so eviction
1009
+ is true LRU — hot per-edit files are no longer evicted by scan traffic.
1010
+
1011
+ - **Eight enabled typescript/javascript tree-sitter rules whose queries never
1012
+ compiled** (refs #884) — each had been silently dead since authoring because
1013
+ its query failed to compile against the real grammar, so it matched nothing
1014
+ and reported no diagnostics. Repaired against the actual node/field names and
1015
+ verified end-to-end (matches the bug, leaves correct code alone):
1016
+ `empty-switch-case`, `switch-case-termination`, `switch-case-termination-js`
1017
+ (switch cases carry their statements as direct `body:` children, not a
1018
+ `consequence: (statement_block)`); `infinite-loop` (`while (true)` wraps the
1019
+ condition in a `parenthesized_expression`; `for (;;)` has an `empty_statement`
1020
+ condition, not `(null)`); `duplicate-function-arg` (typescript parameters are
1021
+ `required_parameter`, not bare `(identifier)`; now also catches non-adjacent
1022
+ duplicates); `mixed-async-styles` (no `async_modifier` node — match the `async`
1023
+ token); `switch-non-case-labels` (JS) (a `labeled_statement` lives inside a
1024
+ `switch_case`, not directly under `switch_body`); and `ts-insecure-random`
1025
+ (the inline `(?i)` regex flag is invalid in JS `RegExp` — dropped the
1026
+ redundant name predicate and let the post-filter do the case-insensitive
1027
+ check, walking up from the `Math.random()` call so chained forms like
1028
+ `Math.random().toString(36)` are still attributed to their binding). Also
1029
+ implemented the four post-filters these rules referenced but that were never
1030
+ defined (`is_empty_block`, `no_break_or_return_in_body`, `same_param_name`,
1031
+ `no_terminating_statement`), which the batch runner had been failing closed on.
1032
+
1033
+ - **A column-0 comment after a `query: |` block no longer breaks the rule**
1034
+ (refs #884) — the query-block extractor kept every line more-indented than the
1035
+ key (to preserve `#eq?`/`#match?` predicate lines) but did not stop at a
1036
+ document-level `# …` comment sitting between the block and the next key, so the
1037
+ comment was appended to the query and made it fail to compile. This is what
1038
+ kept `mixed-async-styles` dead even after its query was otherwise correct.
1039
+
1040
+ - **Seven ruby security rules never compiled and never produced a finding** (refs
1041
+ #884) — `ruby-command-injection`, `ruby-eval`, `ruby-insecure-deserialization`,
1042
+ `ruby-insecure-random`, `ruby-open-struct`, `ruby-string-eval` and
1043
+ `ruby-weak-hash` were authored against JavaScript-grammar node names
1044
+ (`call_expression`, `method_call`, `command`, `interpolated_string`) that do not
1045
+ exist in tree-sitter-ruby, so every one failed to compile with a "Bad node name"
1046
+ error and was silently skipped. Each query is rewritten against the real ruby
1047
+ grammar (`call` with `method:`/`receiver:` fields, `scope_resolution` receivers
1048
+ for `Digest::MD5`, `string` for interpolated literals), with `#match?`/`#eq?`
1049
+ predicates moved inside the pattern's outermost parens. Per-rule positive and
1050
+ negative fixtures (`system` vs `File.read`, `Marshal.load` vs `YAML.safe_load`,
1051
+ `rand` vs `SecureRandom`, `OpenStruct.new` vs `Struct.new`, `Digest::MD5` vs
1052
+ `Digest::SHA256`, string vs block `class_eval`) pin the security intent.
1053
+
1054
+ - **Six python rules never compiled or never fired** (refs #884) —
1055
+ `python-empty-except` used a `body:` field that doesn't exist on
1056
+ `except_clause`; `in-operator-unsupported` used bare `"in"`/`"not"` `"in"`
1057
+ tokens the grammar doesn't expose that way (`not in` is a single token) and
1058
+ only matched `identifier` targets, so `x in None` never matched at all;
1059
+ `no-super-torchscript` looked for a `(call function: (identifier))` inside the
1060
+ decorator (`@torch.jit.script` has no call — it's a bare `attribute`) and
1061
+ expected the decorator directly on the method rather than the class;
1062
+ `notimplemented-boolean-context` used `("and" | "or")` (not valid tree-sitter
1063
+ query syntax — alternation is `[...]`) inside a non-existent `binary_operator`
1064
+ form and `unary_operator operator: ("not")`, when python's logical `not` is
1065
+ its own `not_operator` node with an `argument:` field;
1066
+ `yield-return-outside-function` referenced a `yield_expression` node type that
1067
+ doesn't exist in tree-sitter-python. All five queries are rewritten against
1068
+ the real grammar (verified via AST dumps against `tree-sitter-python.wasm`);
1069
+ `no-super-torchscript` and `in-operator-unsupported` gained
1070
+ `torchscript_super_call` / `check_in_operator_types` post-filters
1071
+ (`clients/tree-sitter-client.ts`) since neither had a working implementation
1072
+ behind their declared `post_filter` name. `exit-signature-check` compiled
1073
+ fine but its `@PARAM1?`/`@PARAM2?`/`@PARAM3?` captures never matched anything
1074
+ — the `?` quantifier was written after the capture name instead of the node,
1075
+ so the captured names were literally `PARAM1?`/`PARAM2?`/`PARAM3?` while the
1076
+ post_filter read `captures.PARAM1`; fixed to capture the whole `parameters`
1077
+ node and count named children instead (per-slot optional quantifiers turned
1078
+ out to match every valid sub-alignment, not just the maximal one, producing
1079
+ spurious duplicate matches for a fully-correct signature).
1080
+
1081
+ - **Project scans run tree-sitter rules for every supported language, not just 10 extensions** (closes #882, refs #877, #880) — the scanner's `TREE_SITTER_EXT_TO_LANG` covered only ts/tsx/js/py/go/rs/rb, so files whose grammars and non-disabled rule dirs already exist (c, cpp, csharp, css, php, java, kotlin) were silently skipped by the tree-sitter phase of project scans. It now derives the shared per-edit resolver (`EXT_TO_LANG`) so c/cpp/csharp/php/css and the `.tsx`→tsx / `.jsx`→javascript nuances can't drift from the per-edit path, and layers java/kotlin on top (grammars + rule dirs exist but no per-edit `appliesTo`). A regression test asserts the map covers every non-disabled rule dir whose grammar is loadable.
1082
+
1083
+ - **`.dart` files are now included in project-wide source enumeration** (closes #880, refs #876) — `ALL_SCANNABLE_EXTENSIONS` (`clients/source-filter.ts`) and `WARMUP_SOURCE_EXTS` (`clients/language-profile.ts`) were missing `.dart`, so Dart projects were fully supported per-edit (LSP, `dart-analyze`, `dart format`, autofix) but skipped by project-wide scans and cold-start language-profile warmup.
1084
+
1085
+ - **Tree-sitter rules were compiled against the wrong grammar** — a compiled
1086
+ query is bound to the language it compiled against, and running it on a tree
1087
+ from another grammar returns zero matches silently. Rules now compile against
1088
+ the language the file is parsed as. This is why the javascript→typescript rule
1089
+ merge never fired a single diagnostic in its life. That merge stays off — the
1090
+ rules are written against typescript node types and misfire on JS — but the
1091
+ same fix is what makes `.tsx` work.
1092
+
1093
+ - **Project scans ran rules from `-disabled/` directories** — the per-edit runner
1094
+ excluded them, the scanner read the raw loader map and ran them anyway.
1095
+ **1,936 of a 500-file scan's 2,590 tree-sitter findings came from rules that
1096
+ were explicitly switched off.** Rule-set selection now goes through one seam,
1097
+ `queriesForLanguage`.
1098
+
1099
+ - **`.tsx` parsed with the typescript grammar**, which produces ERROR nodes on
1100
+ JSX, and under a different language id than the fact providers use — so every
1101
+ `.tsx` file was parsed twice, once wrongly. It now parses as `tsx` and
1102
+ inherits the typescript rule set, which is rule-for-rule identical on both
1103
+ grammars. `.tsx` also gets those rules in the per-edit runner now, where it
1104
+ previously saw only its own two JSX rules.
1105
+
1106
+ - **An unimplemented `post_filter` reported every raw match instead of none.**
1107
+ 40 of the 84 filters rules reference have no implementation; the default now
1108
+ drops the match and warns once. `duplicate-function-arg` (whose
1109
+ `same_param_name` filter was never implemented) was reporting 59 phantom
1110
+ duplicates across 60 files.
1111
+
1112
+ - **YAML scalars kept their trailing comments** in the rule loader's hand-rolled
1113
+ parser — `post_filter: not_in_test_block # skip test blocks` carried the
1114
+ comment as part of the filter name, so the filter silently never applied.
1115
+
1116
+ - **tree-sitter runner no longer double-increments already 1-indexed
1117
+ line/column** (refs #448) — dispositions recorded against the old off-by-one
1118
+ are anchored (`clients/diagnostic-dispositions.ts`) to the wrong physical
1119
+ line's content and will resurface once after upgrading; this is expected and
1120
+ one-time, not a regression.
1121
+
1122
+ ### Security
1123
+
1124
+ - **Debug logs redact credential-shaped text before it reaches disk** (closes
1125
+ #327). The shared NDJSON boundary scrubs private keys, provider tokens, AWS
1126
+ access keys, and JWT/JWE compact tokens with deterministic linear scanners.
1127
+ The synchronous crash-adjacent LSP launch diagnostic uses the same redactor.
1128
+
13
1129
  ## [3.8.73] - 2026-07-28
14
1130
 
15
1131
  ### Added