pi-lens 3.8.68 → 3.8.70
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.
- package/CHANGELOG.md +114 -0
- package/README.md +53 -34
- package/dist/clients/agent-nudge.js +262 -0
- package/dist/clients/biome-client.js +2 -2
- package/dist/clients/bootstrap.js +3 -1
- package/dist/clients/bus-events-logger.js +51 -0
- package/dist/clients/bus-publish.js +153 -0
- package/dist/clients/code-quality-warnings.js +27 -0
- package/dist/clients/dead-code-client.js +5 -23
- package/dist/clients/diagnostic-logger.js +2 -2
- package/dist/clients/diagnostics-publish.js +216 -0
- package/dist/clients/dispatch/auxiliary-lsp.js +62 -6
- package/dist/clients/dispatch/dispatcher.js +2 -0
- package/dist/clients/dispatch/facts/function-facts.js +83 -0
- package/dist/clients/dispatch/integration.js +153 -2
- package/dist/clients/dispatch/rules/async-noise.js +1 -1
- package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +1 -1
- package/dist/clients/dispatch/rules/cors-wildcard.js +1 -1
- package/dist/clients/dispatch/rules/error-obscuring.js +2 -1
- package/dist/clients/dispatch/rules/error-swallowing.js +2 -1
- package/dist/clients/dispatch/rules/framework-call-noise.js +67 -0
- package/dist/clients/dispatch/rules/high-complexity.js +12 -1
- package/dist/clients/dispatch/rules/high-fan-out.js +14 -3
- package/dist/clients/dispatch/rules/high-import-coupling.js +1 -1
- package/dist/clients/dispatch/rules/missing-error-propagation.js +1 -1
- package/dist/clients/dispatch/rules/no-commented-credentials.js +1 -1
- package/dist/clients/dispatch/rules/pass-through-wrappers.js +1 -1
- package/dist/clients/dispatch/rules/placeholder-comments.js +1 -1
- package/dist/clients/dispatch/rules/unsafe-boundary.js +1 -1
- package/dist/clients/dispatch/runners/ast-grep-napi.js +133 -31
- package/dist/clients/dispatch/runners/lsp.js +22 -6
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +68 -19
- package/dist/clients/file-utils.js +20 -7
- package/dist/clients/gitleaks-client.js +40 -5
- package/dist/clients/installer/index.js +155 -7
- package/dist/clients/instance-reaper.js +343 -18
- package/dist/clients/instance-registry.js +71 -2
- package/dist/clients/jscpd-client.js +2 -2
- package/dist/clients/knip-client.js +35 -38
- package/dist/clients/language-profile.js +7 -11
- package/dist/clients/lens-config.js +17 -0
- package/dist/clients/lens-engine.js +56 -10
- package/dist/clients/log-cleanup.js +61 -19
- package/dist/clients/lsp/cascade-tier.js +266 -0
- package/dist/clients/lsp/client.js +57 -8
- package/dist/clients/lsp/config.js +27 -1
- package/dist/clients/lsp/index.js +550 -59
- package/dist/clients/lsp/server-strategies.js +19 -0
- package/dist/clients/lsp/server.js +174 -32
- package/dist/clients/lsp-budget.js +148 -0
- package/dist/clients/mcp/analyze.js +110 -1
- package/dist/clients/mcp/session.js +1 -0
- package/dist/clients/middle-man-analysis.js +260 -0
- package/dist/clients/module-report.js +333 -26
- package/dist/clients/ndjson-logger.js +24 -0
- package/dist/clients/opengrep-client.js +217 -0
- package/dist/clients/path-utils.js +65 -0
- package/dist/clients/persist-debounce.js +63 -0
- package/dist/clients/pipeline.js +83 -3
- package/dist/clients/project-diagnostics/extractors.js +50 -4
- package/dist/clients/project-diagnostics/fresh-fetch.js +275 -0
- package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +40 -0
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +46 -0
- package/dist/clients/project-snapshot.js +7 -2
- package/dist/clients/quiet-window.js +223 -0
- package/dist/clients/read-expansion.js +2 -3
- package/dist/clients/read-guard-tool-lines.js +10 -0
- package/dist/clients/recent-touches.js +233 -0
- package/dist/clients/resource-sampler.js +254 -0
- package/dist/clients/review-graph/builder.js +265 -11
- package/dist/clients/review-graph/symbol-id.js +57 -0
- package/dist/clients/runtime-agent-end.js +51 -1
- package/dist/clients/runtime-coordinator.js +21 -0
- package/dist/clients/runtime-session.js +106 -47
- package/dist/clients/runtime-tool-result.js +46 -0
- package/dist/clients/runtime-turn.js +53 -11
- package/dist/clients/safe-spawn.js +48 -2
- package/dist/clients/sgconfig.js +230 -52
- package/dist/clients/source-filter.js +56 -34
- package/dist/clients/source-walker.js +66 -0
- package/dist/clients/startup-scan.js +37 -42
- package/dist/clients/subagent-mode.js +54 -20
- package/dist/clients/symbol-containment.js +24 -0
- package/dist/clients/test-runner-client.js +544 -29
- package/dist/clients/tree-sitter-symbol-extractor.js +115 -0
- package/dist/clients/tui-fit.js +54 -0
- package/dist/clients/turn-summary-render.js +72 -0
- package/dist/clients/turn-summary.js +132 -0
- package/dist/clients/widget-state.js +88 -31
- package/dist/clients/word-index.js +296 -1
- package/dist/clients/write-ordering-guard.js +58 -0
- package/dist/clients/zizmor-config.js +27 -0
- package/dist/index.js +20060 -14142
- package/dist/mcp/build-staleness.js +123 -0
- package/dist/mcp/server.js +470 -60
- package/dist/tools/activate-tools.js +72 -0
- package/dist/tools/ast-dump.js +2 -8
- package/dist/tools/ast-grep-search.js +1 -1
- package/dist/tools/lens-diagnostics.js +317 -27
- package/dist/tools/lsp-diagnostics.js +617 -64
- package/dist/tools/module-report.js +32 -16
- package/dist/tools/symbol-search.js +110 -0
- package/docs/agent-tooling-improvement-plan.md +660 -0
- package/docs/agent-tools.md +129 -0
- package/docs/ast-grep_rules_catalog.md +564 -0
- package/docs/astplayground.md +178 -0
- package/docs/audit1.md +177 -0
- package/docs/custom-rules.md +231 -0
- package/docs/dependencies.md +54 -0
- package/docs/env_variables.md +10 -0
- package/docs/environment-variables.md +67 -0
- package/docs/fable.md +139 -0
- package/docs/features.md +338 -0
- package/docs/globalconfig.md +79 -0
- package/docs/inspiration_gograph.md +601 -0
- package/docs/inspiration_neovim.md +1795 -0
- package/docs/inspiration_neovim2.md +402 -0
- package/docs/inspiration_tree_sitter_rules.md +369 -0
- package/docs/inspiration_tree_sitter_rules_investigation.md +328 -0
- package/docs/inspiration_tree_sitter_rules_round2.md +470 -0
- package/docs/language-coverage.md +48 -0
- package/docs/lsp-bridge-analysis.md +444 -0
- package/docs/lsp-capability-matrix.md +157 -0
- package/docs/lsp-latency-benchmark.md +169 -0
- package/docs/mcp.md +438 -0
- package/docs/module-report-read-symbol.md +258 -0
- package/docs/newinspiration.md +338 -0
- package/docs/servercapabilities.md +118 -0
- package/docs/subagent-compat.md +281 -0
- package/docs/tools.md +13 -0
- package/docs/tools_improvement2.md +345 -0
- package/docs/tree-sitter_rules_catalog.md +296 -0
- package/docs/usage.md +129 -0
- package/docs/word-index.md +150 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-dart.wasm +0 -0
- package/grammars/tree-sitter-elixir.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-lua.wasm +0 -0
- package/grammars/tree-sitter-ocaml.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-toml.wasm +0 -0
- package/grammars/tree-sitter-vue.wasm +0 -0
- package/grammars/tree-sitter-zig.wasm +0 -0
- package/package.json +5 -1
- package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
- package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-js-test.yml +8 -0
- package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-test.yml +8 -0
- package/rules/ast-grep-rules/rules/array-callback-return-js.yml +1 -1
- package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +1 -1
- package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-typeof-undefined-js.yml +9 -7
- package/rules/ast-grep-rules/rules/no-typeof-undefined.yml +9 -7
- package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +1 -1
- package/skills/{ast-grep → pi-lens-ast-grep}/SKILL.md +4 -2
- package/skills/{lsp-navigation → pi-lens-lsp-navigation}/SKILL.md +1 -1
- package/skills/{write-ast-grep-rule → pi-lens-write-ast-grep-rule}/SKILL.md +1 -1
- package/skills/{write-tree-sitter-rule → pi-lens-write-tree-sitter-rule}/SKILL.md +1 -1
- package/dist/clients/production-readiness.js +0 -493
- package/dist/clients/tree-sitter-fixer.js +0 -127
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,120 @@ All notable changes to pi-lens will be documented in this file.
|
|
|
10
10
|
|
|
11
11
|
### Fixed
|
|
12
12
|
|
|
13
|
+
## [3.8.70] - 2026-07-14
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Review-graph symbols now carry an owner-qualified display name, and `calls`/`references` edges gain two more resolution tiers** (refs #655 — second, still narrowly-scoped slice; `BehaviorFact` extraction, `SymbolMetadataVNext`, and per-language behavior adapters remain untouched) — phase 1 (#659) fixed symbol-node ID collisions but deliberately left qualified ownership (`ClassName.method`) and call-edge resolution at just `"exact"`/`"name-only"`. This slice adds both, reusing existing machinery rather than building parallel logic: (1) **Qualified names**: a new shared containment helper, `clients/symbol-containment.ts`'s `findOwnerName`, implements the SAME strict-range-containment/smallest-span algorithm `module-report.ts`'s outline nesting (`nestEntries`, #301) already uses, so a new `ReviewGraphNode.qualifiedName` field (e.g. `UserService.run`) is computed identically for tree-sitter-based languages (`builder.ts`'s `addTreeSitterFile`, over the file's own deduped symbol list) and jsts (`dispatch/facts/function-facts.ts`'s `functionFactProvider`, which now also collects class/interface ranges in the SAME already-parsed-tree walk and stamps each `FunctionSummary.owner`, since jsts graph nodes come from a different, lighter tree-sitter integration than module-report's own extractor — literally sharing one function call across the two wasn't possible, only the algorithm was factored out). `module-report.ts`'s `resolveUsedBy` now renders `node.qualifiedName ?? node.symbolName`, so two same-file, same-named methods on different classes are distinguishable in `usedBy`/`blastRadius` output without cross-referencing line numbers — and, as a side benefit, its caller-dedup key (previously bare name) no longer risks conflating two different classes' same-named callers. Cross-tool consistency was a hard requirement: a qualified name rendered here is guaranteed to be a valid `Class.method` input to `read_symbol`'s own resolver (`resolveQualifiedMatch`) because both derive from the same "smallest strictly-containing declaration" notion of ownership — verified by a new consistency test, not just asserted. (2) **Two new `resolution` tiers**, both computed only for jsts in this slice (the one ingestion path with import-specifier names and same-file type hints already cheaply available; other languages continue to fall back to `"exact"`/`"name-only"` — a deliberate, documented scope boundary, not an oversight): `"import"` narrows a bare-name callee to the specific in-project file its caller's own `import { x } from "./service.js"` names, via a new `importHintFile` edge-metadata hint threaded from `addJsTsFile` into `resolveDeferredSymbolEdges`, resolved when that ONE file has exactly one same-named symbol (falls through to the existing global-uniqueness `"exact"` check, then `"name-only"`, when it doesn't uniquely narrow). `"receiver-type"` resolves a `obj.method()` call directly to a specific class's method when the receiver's type is determinable from the SAME function's tree-sitter parse — a `const x = new ClassName()` assignment or a typed parameter — via two new, narrowly-scoped `function-facts.ts` collectors (`collectMemberCallSites`, `collectReceiverTypes`) covering only the clearest common shapes (chained/computed/`this.`-receiver calls, cross-function flow, and generics are conservatively left as the pre-existing "external" classification, never guessed). Both tiers fail closed: an owner+name pair that's ITSELF ambiguous (e.g. two same-named methods sharing one qualified name) resolves to a dedicated qualified-name placeholder tagged `"name-only"` rather than picking one of the 2+ candidates, and a genuinely undeterminable receiver type keeps today's plain external classification with no resolution tag at all — never a wrong `"exact"`/`"import"`/`"receiver-type"` claim. Fixed as a necessary side effect (not scope creep): `localImportToFile` never stripped a `.js`/`.jsx`/`.mjs`/`.cjs` extension from an import specifier before trying sibling extensions, so the extremely common TS-as-ESM pattern this very codebase uses everywhere (`import { x } from "./service.js"` pointing at a real `service.ts`) silently failed to resolve to a real file — a latent gap in the existing file→file `imports` edge that the new `"import"` tier depends on directly. Investigated and confirmed UNAFFECTED: `read_enclosing` (`clients/module-report.ts`'s `readEnclosing`) is purely positional (file + line, fresh tree-sitter extraction per call) and touches no symbol ID, resolution, or qualified-name machinery. New tests: `tests/clients/review-graph/qualified-name-resolution.test.ts` (qualified-name rendering for a same-file different-class collision, the read_symbol/module-report qualified-name-format consistency check, both new resolution tiers each with a positive resolving case and a case that correctly stays `"name-only"` rather than over-claiming); one pre-existing `module-report.test.ts` assertion updated from `"exact"` to `"import"` (a strictly more specific, and now correctly reported, tier for that scenario).
|
|
18
|
+
- **`lsp_diagnostics`/`lens_diagnostics` sweeps now warm up the primary server once before the per-file loop starts, instead of the first few files eating cold-spawn-adjacent timeouts** (closes #667) — live dogfooding on a 100-file `lsp_diagnostics` sweep found the first 5 files touched all hit the exact 1000ms per-file timeout with `serverCountReady:1`, while every file from the 6th on was clean and fast (~450-500ms). `serverCountReady:1` only proves the server process spawned and passed the LSP `initialize` handshake — it does NOT prove the server can usefully answer a diagnostics request yet; tsserver-style servers can still be loading/indexing the project internally for seconds after that, and neither sweep tool had any check for this before starting its per-file loop, so whichever files landed first paid that cost individually. New `LSPService.ensureWarmForSweep` (`clients/lsp/index.ts`) is the ONE shared fix both tools route through (they already share `groupFilesByPrimaryServer`/`runPerServerGroups` from #631): a real "has this server already answered a confirmed diagnostics touch this session" check (a new `demonstratedReady` key set, populated by `touchFile` only on a non-inconclusive diagnostics-mode result — strictly stronger than `isAlive()`/spawned/handshake-complete), not just a guessed delay. Cold → performs exactly one bounded warm-up `touchFile` round trip against one representative file from the group, with its own generous, one-time, env-tunable budget (`PI_LENS_LSP_WARMUP_TIMEOUT_MS`, default 20s) distinct from the per-file sweep budget — then the normal sweep proceeds from an already-demonstrated-ready server. Already-warm (from an earlier touch/sweep this session) → a no-op: no extra round trip, no added latency. Wired into `runWorkspaceDiagnostics` (the engine behind `lens_diagnostics mode=full`) per server group, ahead of the per-file pre-open/touch loop but AFTER the opt-in whole-group `workspace/diagnostic` pull fast path (which already gets its own generous per-server budget covering the whole group in one shot, so it doesn't need a separate warm-up); and into `tools/lsp-diagnostics.ts`'s `mapWithConcurrency` (the shared batch/directory-scan primitive), called once per server group before its own per-file loop. Does NOT change the existing per-file wait budgets or confirmed/unconfirmed contract (#242/#611/#634) — purely a pre-loop addition. New tests: `tests/clients/lsp/sweep-warmup.test.ts` covers the pure decision logic directly against `ensureWarmForSweep` (a cold server performs exactly one warm-up round trip then is treated as warm; an already-warm server from a prior `touchFile` is a no-op) and integration-style coverage through `runWorkspaceDiagnostics` itself (a cold sweep pays exactly one extra warm-up round trip on top of its normal per-file touches; an already-warm sweep pays none) — guarding specifically against the warm-up regressing into a mandatory extra round trip on every sweep.
|
|
19
|
+
- **Registry-independent backstop orphan sweep** (closes #658) — live dogfooding found 3 `opengrep.exe`/`opengrep-core.exe` process chains with confirmed-dead parents that survived every `session_start` sweep for ~2 days. Root cause: `clients/instance-reaper.ts`'s existing `sweepOrphans` (#472) is entirely registry-driven — it only ever considers pids currently listed in some instance's `lspChildren[]`, so once a child's registry reference is lost (a stale-heartbeat entry removal per #525's asymmetric design, or a `killProcessTree` call that failed silently), that child becomes permanently invisible to every future sweep; the reaper never asks "what's actually running on this machine." New `sweepUntrackedOrphans` runs as a strictly additive SECOND layer alongside the unchanged registry-driven sweep: it enumerates live OS processes by known pi-lens-managed binary name (`MANAGED_BINARY_NAMES` — ast-grep, opengrep, opengrep-core, marksman, zizmor, typos-lsp, yaml-language-server, typescript-language-server, drawn from `clients/lsp/server.ts`'s spawn candidates) via one batched `Get-CimInstance Win32_Process` WQL query on Windows (reusing the same query pattern as the existing marker-search) or `ps -eo pid=,ppid=,args=` on POSIX, then applies a new pure decision function, `decideBackstopOrphanReaping` — a process is kill-eligible ONLY when it is NOT already tracked in any instance's `lspChildren[]` (deferred to the registry-driven reaper instead) AND its parent pid is confirmed dead via the existing identity-verified `realIsPidAlive` (never on an ambiguous/unresolvable parent pid, never on a live parent, never on binary name alone). Confirmed kills reuse the existing `killPidTree` tree-kill mechanism. Kill-attempt retry was considered per the issue's own framing: no new retry-tracking state was added — a silently-failed kill leaves the process untracked with a still-dead parent, so it stays classified as backstop-kill-eligible and the very next `session_start` sweep naturally retries it, with zero extra bookkeeping (documented in `sweepUntrackedOrphans`'s doc comment; a future hardening making `killPidTree` return a success signal for sharper logging is a reasonable follow-up, not required for correctness). Wired into `index.ts` alongside the existing `sweepOrphans()`/`registerInstance()` call site — fire-and-forget, never blocks or throws into `session_start`. New tests in `tests/clients/instance-reaper.test.ts` (`decideBackstopOrphanReaping`) mirror the existing pure/impure split: an untracked process with a confirmed-dead parent is kill-eligible; a live parent, an already-tracked pid, an unverifiable (zero/negative/NaN) parent pid, and a malformed self-parenting row are all never kill-eligible.
|
|
20
|
+
- **Review-graph symbol-node IDs are now collision-safe** (refs #655 — first, narrowly-scoped slice of a larger tracking issue; the rest — behavior facets, per-language adapters, `SymbolMetadataVNext` — is explicitly NOT part of this change) — `clients/review-graph/builder.ts` minted every symbol node's ID as `${file}:${name}`, so an overloaded function/method, two same-named methods on different classes in the same file, or a same-named nested function all collapsed onto ONE graph node; `pilens_module_report`'s `usedBy`/`blastRadius` sections (which read that node's incoming edges directly) could then silently merge or misattribute two genuinely different symbols' callers. Fix: a new shared helper, `clients/review-graph/symbol-id.ts`'s `buildSymbolId(file, name, kind, startLine)`, builds `${file}:${name}:${kind}:${startLine}` — enough to give every one of those concrete collision cases a distinct ID, since they always sit on different lines. Deliberately scoped DOWN from #655's full proposed `<file>:<qualified-name>:<kind>:<start-line>:<start-column>` shape: no qualified ownership (e.g. `ClassName.method` — needs an owner-chain the extractors don't compute uniformly today, real work #655 leaves for later) and no start column (review-graph's JS/TS symbols come from a different extractor, `dispatch/facts/function-facts.ts`, than module-report's own outline extractor, `tree-sitter-symbol-extractor.ts` — the two agree on start line for every function-like declaration but can diverge by a few columns for arrow functions; line already resolves every in-scope collision case without needing column, and keeps IDs comparable across both extractors). Also fixed a related dormant bug the new ID shape surfaced: some grammars' symbol queries (e.g. python's) match one real declaration under two patterns — a class method also matches the generic top-level `function_definition` rule — yielding two `Symbol` records identical in name/line/column but differing only in `kind`; the old name-only ID silently collapsed these back into one node, so the new kind-qualified ID needed a same-position dedupe (`dedupeSamePositionSymbols`, preferring the more specific kind) to avoid manufacturing phantom duplicate graph nodes. `clients/review-graph/types.ts`'s `ReviewGraphEdge` gains an optional `resolution?: "exact" | "name-only"` field (a narrow slice of #655's full call-edge-metadata proposal): a `calls`/`references` edge starts `"name-only"` (bare-name match, no scope/type info), and `resolveDeferredSymbolEdges` upgrades it to `"exact"` only when exactly one same-named real symbol exists graph-wide — otherwise it's left `"name-only"` so a consumer knows the target may be a name-collision guess. `clients/module-report.ts`'s `ModuleSymbolUsedBy` surfaces that same `resolution` field, and its internal `toEntry` now builds its own graph-node lookup key via the same shared `buildSymbolId` helper (previously a separately-hand-assembled `${normalizedPath}:${sym.name}` string) — with one language-specific wrinkle: for jsts, the lookup always uses kind `"function"` regardless of the outline's own finer-grained `sym.kind` ("method" etc.), because builder.ts's jsts graph nodes come from `function-facts.ts`, which has no method/function distinction. `REVIEW_GRAPH_VERSION` bumped `v3` → `v4` (same safe-rebuild mechanism as the v2→v3 #260 bump: a v3 snapshot's nodes/edges still use the old ID shape throughout, so `loadPersistedGraph` rejects it outright rather than merging it with newly-built v4 IDs). Architecturally, this is an IN-PLACE, versioned extension of the existing review graph — no parallel "v2 graph" — per #655's own stated decision to enrich one graph and expose projections over it rather than build a second production-code model; the existing `REVIEW_GRAPH_VERSION`/`buildGeneration` machinery already exists for exactly this kind of safe schema change. New tests: `tests/clients/review-graph/symbol-id.test.ts` (the old scheme's literal collision, the new scheme's disambiguation by kind+line, and an end-to-end same-file-different-class Python method case demonstrating two distinct graph nodes); `tests/clients/review-graph.service.test.ts` gains a v3-snapshot migration-safety test (flagged stale, blind-read rejected, a fresh build produces `v4` IDs) and a resolution-confidence test (a globally-unique callee resolves `"exact"`, an ambiguous same-named callee across two files stays `"name-only"`); `tests/clients/module-report.test.ts` gains an `"exact"`-resolution assertion on the existing cross-file-caller test, a jsts class-method graph-node-lookup regression test (guarding the "function" idKind mapping), and an ambiguous-same-name-across-files test asserting `"name-only"` is surfaced rather than a false `"exact"`.
|
|
21
|
+
- **`docs/` is now included in the published npm package** (closes #651) — pi.dev rewrites GitHub repo links to npm-package-relative paths for its packages listing, so every `docs/*.md` link was a dead link there since `docs/` was never in `package.json`'s `files` array. Added `"docs/"` to `files`; only the 23 already git-tracked/public docs ship (a handful of untracked local scratch files like `inspiration_*.md` are gitignored and were never part of a real publish regardless). Package size grows ~200KB (3.0MB → 3.2MB packed) — negligible. Reported by @eisterman.
|
|
22
|
+
- **pi-lens can now measure its own total CPU/RAM footprint — host process, every live LSP child, and every transient analyzer spawn** (closes #620) — diagnosed during a 2026-07-13 dogfooding session where a `lens_diagnostics mode=full` sweep's per-file confirmation rate collapsed under real CPU contention from ~25 concurrent `node.exe` processes (other worktrees/sessions on the same box), a theory only confirmable after the fact via manual `tasklist` correlation — pi-lens had no first-class way to answer "how much CPU/RAM is pi-lens itself using right now." New `clients/resource-sampler.ts` wraps `pidusage` (chosen over hand-rolling `/proc` vs Windows `Get-CimInstance`/perf-counters — each OS exposes process CPU/RSS differently, and pidusage already abstracts that; it's a small pure-JS package with one transitive dep, `safe-buffer`, so it bundles like the repo's other pure-JS runtime deps rather than needing an `EXTERNAL` entry in `scripts/bundle-dist.mjs`) with two consumers: (1) **long-lived processes** — `clients/instance-registry.ts`'s `InstanceEntry`/`LspChildEntry` (#449) gain `cpuPercent`/`rssBytes` fields, sampled at the existing quiet-window heartbeat cadence (`clients/quiet-window.ts`'s `buildHeartbeatResourcePatch`, bounded to 2s via `Promise.race` so a slow sample can never block the idle-window task — the per-turn-end heartbeat stays RSS-only, deliberately, since that's a much hotter path and CPU sampling there would make the measurement itself new per-turn overhead); (2) **transient analyzer children** (jscpd, knip, madge, gitleaks, govulncheck, trivy, opengrep, zizmor, …) — every `safeSpawnAsync` invocation (`clients/safe-spawn.ts`) is now bracketed by `startSpawnUsageSampler`, a 750ms poll started right after `spawn()` and stopped in the existing `child.on("close"/"error", ...)` handlers, recording peak/average CPU%+RSS into a new `spawn_resource_usage` phase entry in the existing `latency.log` (alongside the timing data already logged there) and attaching a `resourceUsage` field to the resolved `SpawnResult`. Windows-specific gotcha handled: `safe-spawn.ts` spawns with `shell: true` on Windows, so the sampled pid is `cmd.exe`'s wrapper, not the real tool's — `findDescendantPidsWindows` resolves the live descendant tree via one CIM query per poll tick (pure BFS split out as `walkDescendantPids` for testability) so a `node`/`npx`-wrapped tool is actually captured instead of reporting cmd.exe's near-zero usage. Net query surface: `clients/instance-registry.ts`'s new `computeResourceFootprint`(pure)/`getResourceFootprint` aggregate every registered instance's host + LSP children into total RSS/CPU/child-count across every pi-lens process on the machine, re-exported through the `clients/lens-engine.ts` seam and surfaced in `pilens_health`'s existing text/JSON output (a new "Resource footprint: N pi-lens instance(s) · XMB RSS · Y% CPU · Z LSP child process(es)" line, best-effort — a footprint read failure never breaks the rest of the tool). Every sampling path is best-effort end to end (a sampling failure loses a data point, never throws into the heartbeat/spawn/turn_end path it's attached to) per this repo's existing instrumentation-must-never-fail-the-operation-it-measures convention. Deliberately out of scope (tracked separately in #650): cross-process LSP server sharing/deduplication — this issue is purely about MEASURING footprint, not reducing it. New tests: `tests/clients/resource-sampler.test.ts` (pure `UsageAccumulator` peak/average math, pure `walkDescendantPids` BFS including a cycle-safety guard, `sampleProcesses` batching/absent-pid semantics, `startSpawnUsageSampler`'s tick/stop lifecycle — all against a mocked `pidusage`, no real process tables); `tests/clients/instance-registry.test.ts` gains coverage for `updateHeartbeat`'s new `cpuPercent`/`childUsage` patch fields (including "untouched, never zeroed" semantics for an unsampled tick) and `computeResourceFootprint`/`getResourceFootprint`; `tests/clients/safe-spawn-resource-usage.test.ts` covers the safe-spawn wiring (sampler start/stop timing, `resourceUsage` attachment, the `spawn_resource_usage` log entry, and that a throwing sampler never breaks the underlying spawn) against a mocked `resource-sampler.js`/`latency-logger.js`, driving a real tiny `node -e` child process end to end.
|
|
23
|
+
- **Cross-process LSP budget — first prototype of #449 slice 2** (refs #449) — every concurrent pi-lens process (main session + subagents + concurrent worktrees) has always spawned its own independent LSP fleet with no awareness of any other instance's load; #449 slice 1 (#474/#475) made instances mutually visible via `~/.pi-lens/instances.json` but changed no behavior. This is the first slice that acts on that visibility: a new `clients/lsp-budget.ts` sums live LSP server counts (`lspChildren.length`) across every registry entry whose owning pid is still alive (reusing `instance-reaper.ts`'s existing `realIsPidAlive` liveness check — now exported — rather than a second one) and compares against a configurable ceiling, default 16 (`DEFAULT_LSP_BUDGET_CEILING`, `PI_LENS_LSP_BUDGET_CEILING` override) — a rough RAM-budget estimate (~250MB average per LSP child × ~4GB target machine-wide budget), not yet a measured figure (#620's CPU/RSS sampling hadn't landed as of this prototype). When the machine is at or over the ceiling, the NEW session (never an already-running one) degrades its own spawn plan for the rest of that session: it skips spawning auxiliary LSP servers (`role:"auxiliary"` in `clients/lsp/server.ts` — opengrep/ast-grep/zizmor/typos/marksman) and keeps only the primary language server per file, wired via `clients/dispatch/auxiliary-lsp.ts`'s `enabledAuxiliaryLspServerIds`. This is deliberately a "position limit, not a clearinghouse" (the issue's own framing): the check (`checkCrossProcessLspBudget`) fires fire-and-forget from `session_start` alongside the existing `registerInstance`/`sweepOrphans` calls, reads the registry once, decides locally, and never blocks session start or touches another instance's live servers. Feature-flagged: `PI_LENS_CROSS_PROCESS_BUDGET=0` disables the whole check (today's behavior — always spawn the full fleet). Explicitly NOT implemented in this first cut (documented follow-up): the issue's other suggested degrade mechanism (shortening this session's own idle-reaper timeout) and slice 3 (same-workspace warm attach via the MCP IPC channel) — both remain open. This is a first prototype per the issue's own "gated on registry dogfooding" framing; the ceiling number and the choice of degrade mechanism may both need tuning once real-world multi-agent data (#620) is available. New tests: `tests/clients/lsp-budget.test.ts` (pure `decideLspBudget` decision logic against fake registry data — under/at/over ceiling, dead-parent instances excluded from the live count, env-config parsing with NaN guards, module-scope cache defaults).
|
|
24
|
+
- **`lens_diagnostics mode=full` breaks its confirmed/unconfirmed LSP-sweep tally down per primary server, and separates primary-vs-auxiliary findings** (closes #646) — `tools/lsp-diagnostics.ts` already split every file's result into "primary confirmation" (the real language server, e.g. typescript/pyright) vs "auxiliary findings" (cross-cutting scanners attached via `clientScope: "all"` — ast-grep, opengrep, zizmor, typos, marksman, ...) via a local `primaryServerId(filePath)` helper, but `lens-diagnostics.ts`'s `formatFullMode` had no equivalent: its `confirmedLspResults`/`unconfirmedLspResults` tally (#630/#634) was one flat aggregate with no per-server breakdown. Concretely painful during dogfooding: a real sweep came back 34/155 files unconfirmed, and diagnosing WHICH server was responsible required manually grepping `latency.log` for file extensions instead of the tool just reporting it — it turned out to be 100% one push-only server (marksman). Fix: `primaryServerId` moved out of `tools/lsp-diagnostics.ts` into `clients/lsp/config.ts` (alongside `getServersForFileWithConfig`, which it already depended on) so both tools share the exact same primary/auxiliary classification instead of `lens-diagnostics.ts` growing a second hand-copied version — `lsp-diagnostics.ts` now imports the shared helper with no behavior change. `formatFullMode` gains `tallyLspByPrimaryServer` (groups the existing confirmed/unconfirmed partition by each file's primary server id) and `tallyLspPrimaryVsAuxiliary` (splits the raw per-file diagnostics into primary vs auxiliary counts, the same separation `lsp_diagnostics` already applies to its own findings rendering) — both computed from the raw LSP-sweep results, so neither disturbs the existing confirmed/unconfirmed merge into widget-state summaries or any #634 false-clean-prevention semantics. The rendered note now reads e.g. `"⚠ LSP sweep: 121 file(s) confirmed via LSP, 34 unconfirmed (...) — by server: marksman: 0/34, typescript: 121/121 — NOT the same as 0 diagnostics for: ..."` (breakdown clause only rendered when more than one server is involved) plus a new `"LSP sweep findings: N primary (language server), M auxiliary (ast-grep/opengrep/zizmor/typos/marksman/...)"` line; `details` gains `lspServerBreakdown` (always present, per-server `{confirmed, total}`), `lspPrimaryDiagnosticsCount`, and `lspAuxiliaryDiagnosticsCount` so a caller can check this programmatically. New tests: `tests/clients/lsp/primary-server-id.test.ts` (the shared helper matches the old inline `lsp-diagnostics.ts` implementation exactly across a plain source file, a markdown/marksman file, an unmatched extension, and an auxiliary-only case); `tests/tools/lens-diagnostics.test.ts` gains coverage for the per-server breakdown grouping correctly across two different primary servers (mixed confirmed/unconfirmed), the breakdown clause being omitted for a single-server sweep, and the primary/auxiliary findings split.
|
|
25
|
+
- **`lens_diagnostics mode=full` no longer renders a timed-out (or errored) per-file LSP check as false-clean** (closes #630) — `formatFullMode`'s (`tools/lens-diagnostics.ts`) footer-cache reconciliation already excluded `timedOut`/`error` results (#571: `diagnostics` is a default-EMPTY placeholder for these, not a confirmed clean — see `LSPWorkspaceDiagnosticResult`'s doc comment in `clients/lsp/index.ts`), but the merge feeding the actually-rendered summary was NOT filtered the same way: the unfiltered `lspResults` went straight into `mergeDiagnosticsWithWidgetSummaries`, so a timed-out file's placeholder `[]` merged in and read to the agent as "0 diagnostics" — indistinguishable from a genuinely clean file. This is the exact #533/#570 false-negative class (`tools/lsp-diagnostics.ts`'s `confirmation: "clean"|"unconfirmed"` machinery already protects that tool against it) that `mode=full`'s merge path never got. Fix: `lspResults` is now partitioned once into `confirmedLspResults`/`unconfirmedLspResults`, both the footer write and the merge now consume only `confirmedLspResults` (a file already fed the footer-write filter is now also fed the render/details filter from the SAME partition, rather than two independently-maintained filters), and an unconfirmed file can still legitimately show findings from `widgetSummaries`/project-runner state if those independently have entries for it — only its LSP-sweep contribution is withheld. The rendered output gains a new note (mirroring the spirit, not the literal function, of `lsp-diagnostics.ts`'s `unconfirmedReasonClause`/`tallyConfirmation` — the two tools' output shapes already differ): `"⚠ LSP sweep: N file(s) confirmed via LSP, M unconfirmed (...): <paths>. NOT the same as 0 diagnostics for these files"`, distinguishing a timeout from a hard error same as #570 does upstream, composed alongside the existing `coldNote`/`abortedNote`/`freshNote`/`missingNote` in both the aborted and non-aborted return branches. `details` gains `lspFilesConfirmed`, `lspFilesUnconfirmed`, and `unconfirmedLspFiles` (always present, even when zero, matching the existing `coldRunners`-always-in-details convention) so a caller can check this programmatically without re-parsing the text. Deliberately NOT touched: the #611 tsserver-sync escape hatch (that's `lsp_diagnostics`-specific single/batch-check machinery; adding a second per-file round trip to a project-wide sweep here would reintroduce the extra-round-trip problem #629 is fixing on the other tool) — this fix only reclassifies/reports the EXISTING `timedOut` signal `runWorkspaceDiagnostics` already produces, no new LSP calls. New tests in `tests/tools/lens-diagnostics.test.ts`: a mixed confirmed-clean/confirmed-with-diagnostics/timed-out sweep result where the timed-out file is asserted to be listed as unconfirmed (not clean, not silently dropped) while the footer-write exclusion (#571) is confirmed unaffected; an errored (not timed-out) result distinguishing the note's wording; and an all-confirmed sweep confirming no unconfirmed note/zeroed details fields appear when there's nothing to report.
|
|
26
|
+
- **`lsp_diagnostics` directory-mode file cap raised from 50 to 100**, matching the explicit `paths` batch cap (`MAX_BATCH_FILES`) — `tools/lsp-diagnostics.ts`'s `MAX_FILES` constant now reads `100`. There was no longer a reason for the two caps to diverge: dogfooding confirmed the tool's bounded-concurrency worker pool (default 8) stays fast and timeout-free at 100 files on a real ~150-file project, so directory mode was capping well below what the underlying mechanism can already handle cleanly.
|
|
27
|
+
- **`lsp_diagnostics`/`lens_diagnostics` can now render a genuinely confirmed "clean" for classic `typescript-language-server`, not just "unconfirmed"** (refs #611 — read-only-diagnostics scope; the per-edit dispatch consumer is a separate follow-up, see below) — classic `typescript-language-server` is `silentOnClean: true` (`clients/lsp/server-strategies.ts`): on a clean file it publishes nothing at all, not even an empty confirmation, so per #533/#570 pi-lens's honest response was to render ANY empty result from it as `"unconfirmed"` — a single-file `lsp_diagnostics <clean-file.ts>` call could never say "clean," no matter how long you waited. `tools/lsp-diagnostics.ts`'s `classifyEmptyResult` path now attempts one more thing before giving up: when `classifyCascadeWaitTier` (`clients/lsp/cascade-tier.ts`) says the primary server is `"tier3-silent"` (push-only + `silentOnClean` + NOT native-ts7, which already publishes on clean per #558 and never reaches this branch), it calls `typescript-language-server`'s `typescript.tsserverRequest` escape hatch via the existing #237-hardened `LSPService.executeCommand` (allowlisted by advertisement) with `semanticDiagnosticsSync`/`syntacticDiagnosticsSync` — genuine synchronous tsserver request/response commands, not push/timing-dependent. An empty response body is now a confirmed "clean"; a non-empty body surfaces real diagnostics tsserver had already computed but never published, merged into the result rather than discarded. Verified live against the actual installed `typescript-language-server`/`typescript@5.9.3` (this repo's own `tsconfig.json` as the fixture project, plus a deliberately broken scratch file): the response envelope is `{executed:true, result:{seq,type:"response",command,request_seq,success,body:[...]}}`, where each `body` entry is tsserver's NATIVE protocol diagnostic shape (`message`, `category` "error"/"warning"/"suggestion", `code`, `startLocation`/`endLocation` as 1-based `{line,offset}`) — NOT the LSP `Diagnostic` shape, so a new converter (`tsserverSyncDiagnosticToLsp`) maps it to pi-lens's 0-based `LSPDiagnostic`. Also verified live: a file the client never opened (outside any tsconfig project) makes `executeCommand` reject with a tsserver `ResponseError` ("No Project.") rather than a `success:false` response — every failure mode (command not advertised, `executeCommand` throwing or timing out, a malformed response shape) falls back to exactly today's `"unconfirmed"` behavior, fail-safe and non-throwing. New tests in `tests/tools/lsp-diagnostics.test.ts` (`#611 tsserver sync escape hatch`) cover confirmed-clean via the sync path, real diagnostics surfaced via the sync path (with the 1-based→0-based location conversion asserted), fallback-to-unconfirmed on a rejected `executeCommand` call, fallback when the command isn't advertised, fallback when the service exposes neither method at all, and that a `"waits"`-tier server (covering native-ts7) never even attempts the sync path. The higher-stakes per-edit dispatch consumer (`clients/dispatch/runners/lsp.ts`/`clientWaitForDiagnostics` in `clients/lsp/client.ts`) is explicitly NOT touched by this change — it runs on every edit and #611 calls for its own dedicated latency verification (`semanticDiagnosticsSync` blocks on tsserver's real analysis queue, so a backlogged server could see no speed win, only a correctness one) before it's worth attempting there.
|
|
28
|
+
- **`lens_diagnostics mode=full` runs gitleaks on any git repo, not only ones with an explicit gitleaks config** (#608, dogfooding finding) — session_start and per-edit dispatch keep #130's strict opt-in gate (`GitleaksClient.hasGitleaksSignal`: a `.gitleaks.toml`/`.gitleaksignore`/package.json reference/pre-commit hook) unchanged, since that issue explicitly weighed and rejected looser tiers for the routine/low-noise path. But `mode=full` is an explicitly-requested comprehensive review, and #130's own writeup considered — but didn't ship — a "smart-default" tier: fire on any tracked git repo, since gitleaks is cheap (~10MB binary, no external DB pull, unlike trivy's 30-200MB vuln-DB download which genuinely needs consent) and its findings are advisory-only. New `GitleaksClient.hasGitRepo`/`hasGitRepo()` (`clients/gitleaks-client.ts`) checks for a `.git` entry (file or directory, so both a normal clone and a worktree's gitdir-pointer file count); `GitleaksClient.scan()` gains an optional `{ requireSignal: false }` to skip the strict gate when the caller has already applied a looser one. `clients/project-diagnostics/fresh-fetch.ts`'s gitleaks task now gates on `hasGitRepo` instead of `hasGitleaksSignal` and passes `requireSignal: false`. `trivy`/`govulncheck`/`dead-code` are unaffected — trivy's gate is a genuine download-consent boundary (unchanged), and govulncheck/dead-code are gated by hard language applicability (no `go.mod`/no Python files), not a policy preference, so there was nothing to loosen there. New tests: `tests/clients/gitleaks-client.test.ts` (`hasGitRepo` against a directory `.git`, a worktree file `.git`, and no `.git`; `scan()`'s default-strict vs. `requireSignal:false` behavior), `tests/clients/project-diagnostics/fresh-fetch.test.ts` (gitleaks now fires on a bare git repo with no explicit gitleaks marker, and still fires when both are present).
|
|
29
|
+
- **A genuine `silentOnClean` drift finding now files/updates a persistent tracking issue instead of only riding along in the routine docs-refresh PR** (closes #594) — the nightly `tool-smoke` workflow's `probe-clean-signal.mjs` step (#529) already computed `driftWarnings` (a mismatch between an observed LSP server's clean-scan behavior and its hand-set `silentOnClean` marker in `clients/lsp/server-strategies.ts`) but only logged it to the step's stdout and a `## silentOnClean drift` footnote in `docs/lsp-capability-matrix.md` — both of which only surface via the existing `bot/lsp-docs-refresh` auto-PR, which looks identical to a no-op capability refresh, so nobody had a reason to actually check it. `probe-clean-signal.mjs` now also writes `driftWarnings` as a small JSON summary to a fixed path (`scripts/lib/clean-signal.mjs`'s new `DRIFT_SUMMARY_PATH`, a same-job runner-tmpdir file, never committed), and a new workflow step runs a new script, `scripts/notify-clean-signal-drift.mjs`, which reads that summary and files-or-updates a SINGLE persistent GitHub issue (a fixed `nightly-drift` label + fixed title, found by title match among open issues — never a new issue every night, which would spam) when there's a real finding, and closes a prior open one (with a self-resolved comment) once a nightly run finds no drift. This is purely additive: the probe's own drift-detection logic, its "telemetry only, never a CI gate" guarantee, and the existing docs-footnote/auto-PR behavior are all unchanged. The new step is `continue-on-error: true` (mirroring every other best-effort step in this workflow) and the script itself never exits nonzero on an internal error either — filing/updating/closing an issue is a side effect, not a build gate. Auth reuses the job's existing `GITHUB_TOKEN` via `gh` (the same token the docs-refresh PR step already receives), so the job's `permissions:` block gains `issues: write` alongside its existing `contents`/`pull-requests` write grants — no new auth plumbing. Pure body-building/lookup helpers live in `scripts/lib/drift-issue.mjs` (`buildDriftIssueBody`, `findDriftTrackingIssue`), unit-tested in `tests/scripts/drift-issue.test.ts`; the `gh` CLI shell-outs themselves are untested, matching the existing pattern for `scripts/backfill-github-releases.mjs`'s own `gh` calls elsewhere in this repo.
|
|
30
|
+
- **opengrep moved off the full-workspace LSP sweep onto a dedicated CLI extractor** (closes #584) — `runWorkspaceDiagnostics` (`lens_diagnostics mode=full` / `lsp_diagnostics` full-workspace scans) hardcodes `clientScope: "all"` per file, and #387's deliberate single-flight-per-server serialization meant every file paid opengrep's full per-server wait-tier budget (up to 3500ms) one at a time within its server group — on a real 50-file sweep this produced 49/50 files reporting "unconfirmed (timed out)". opengrep has no `workspace/diagnostic` pull support (push-only, `docs/servercapabilities.md`) and `reopenOnResync: true` (`clients/lsp/server-strategies.ts`) means every LSP touch already forces a full re-scan of that one file anyway, so there's no incremental efficiency lost by moving it off the per-file path for bulk scans. New `clients/opengrep-client.ts` (`OpengrepClient`, mirrors `GitleaksClient`/`TrivyClient`): a single project-wide `opengrep scan --config <local rule file | auto> --json` CLI invocation, config resolution reused from the existing `resolveOpengrepConfig` (same rule-choice logic the LSP server itself uses), parsed via `parseOpengrepReport` (verified against the real installed opengrep 1.25.0 binary's JSON schema — semgrep-compatible but with some CLI-surface drift, e.g. `--files-with-matches` requires `--experimental` where semgrep's doesn't). Wired into `scheduleStartupScans` (`clients/runtime-session.ts`) on the same session-start/cached cadence as knip/jscpd/gitleaks, and into the `project-diagnostics` extractor registry (`clients/project-diagnostics/extractors.ts` + new `runner-adapters/opengrep.ts`) so `lens_diagnostics mode=full` reads its cached findings — `ERROR` severity maps to a blocking diagnostic (mirrors gitleaks secrets), `WARNING`/`INFO` to advisory. `runWorkspaceDiagnostics`'s per-file sweep now explicitly excludes the opengrep server (`clients/lsp/index.ts`'s new `excludeServerIds` touch option / `WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`) — verified every extension opengrep covers (`OPENGREP_KINDS`) already has a dedicated primary LSP server (plus the `typos` auxiliary, which attaches to the same extension set), so no file loses sweep coverage from opengrep's removal. The per-edit real-time LSP path (`clientScope: "primary"`/`"with-auxiliary"`) is completely untouched — opengrep still attaches there exactly as before. Rebased onto #587 (`applyAuxiliarySuppressions` wired into `runWorkspaceDiagnostics`) — both changes coexist: opengrep is excluded from the sweep's per-file touch, and whatever else still flows through it (ast-grep, typos, …) still gets suppression-filtered. `// nosemgrep`/`# nosemgrep` needed NO equivalent filtering added to the new CLI extractor — verified empirically (not assumed) against the real installed opengrep 1.25.0 binary that, unlike opengrep's LSP mode (which does NOT honor it natively, the reason `isNosemgrepSuppressed`/`applyAuxiliarySuppressions` exist at all, #441/#586/#587), the CLI `scan --json` path suppresses `nosemgrep`-annotated findings itself before they reach `--json` output. Verified empirically: a real `opengrep scan --config auto` run against this repo's `clients/` directory (277 files, 1074+ community rules) completed in ~19s as a single process — versus the old per-file approach's worst case of 277 individual LSP touches each up to a 3500ms timeout ceiling.
|
|
31
|
+
- **`lens_diagnostics mode=full` now fetches the heavyweight project analyzers FRESH instead of reading a possibly-hours-stale cache** (#585) — `mode=full`'s `refreshRunners=cached/cheap/all` used to fold in knip/jscpd/madge/gitleaks/govulncheck/trivy/dead-code findings via `extractCachedProjectDiagnostics`, a deliberately cache-only read (per its own header comment) because relaunching those analyzers concurrently with a `session_start` background scan of the same tool could double-spawn a CPU-bound process. That prerequisite is now satisfied for all three previously-unguarded clients — `gitleaks-client.ts`, `govulncheck-client.ts`, `trivy-client.ts` — which already shared `SecurityScanClient`'s `dedupeScan` in-flight guard (landed in #313, verified before wiring this up rather than re-adding it), the same pattern `KnipClient`/`JscpdClient`/`DeadCodeClient` use. New `clients/project-diagnostics/fresh-fetch.ts` (`fetchFreshProjectDiagnostics`) mirrors each analyzer's `session_start` gating (`clients/runtime-session.ts`) but always performs — or, via the de-dupe guard, *joins* — an actual run instead of skipping on a cache hit, running all analyzers in **parallel** via `Promise.all` so total wait is bounded by the single slowest one (trivy's own ~180s ceiling) rather than their sum; every fresh result is written back to cache via the same `cacheManager.writeCache` `session_start`/`turn_end` use. No extra write-ordering guard (`clients/write-ordering-guard.ts`) was added on top — an overlapping call for the same analyzer/root always resolves to the exact same in-flight promise, so concurrent writers are always writing identical data, not racing a stale write over a fresher one. `tools/lens-diagnostics.ts`'s `formatFullMode` now calls `fetchFreshProjectDiagnostics` (via the process-wide `loadBootstrapClients()` singleton, so a fresh-fetch racing session_start/turn_end shares client instances and thus in-flight runs) in place of the old cache-only extractor, and the output now notes per-analyzer elapsed time ("fetched fresh this call: knip (1597ms), jscpd (4242ms), madge (11256ms)") alongside the existing cold-analyzer honesty note (now "not applicable / unavailable this run" rather than "not yet scanned this session", since every requested analyzer is now actually attempted). `session_start`'s and `turn_end`'s own scheduling (still skip-if-cached) and per-edit dispatch are unchanged — this is additive and `mode=full`-only. **Abort handling** (found in review before merge): `formatFullMode` already threads a combined Escape/turn-abort + hard wall-clock-ceiling signal (`FULL_SCAN_WALL_CLOCK_MS`) into the LSP sweep and cheap project-runner scan, but the initial fresh-fetch wiring left it unthreaded into `fetchFreshProjectDiagnostics` — an Escape or ceiling-fire would correctly stop the rest of the scan while the analyzer fresh-fetch kept running uncancelled for up to trivy's own ~180s ceiling before the tool call could return. None of the six analyzer clients accept a cancellation token (checked each `analyze()`/`scan()` signature — none does), so `fetchFreshProjectDiagnostics` now takes an optional `signal` and races the overall `Promise.all(tasks)` against it, returning whatever has already settled rather than cancelling in-flight spawns — the same "partial is OK, a hang is not" shape `clients/deadline-utils.ts`'s `withDeadline(..., onTimeout: "undefined")` and `clients/lsp/index.ts`'s `runWorkspaceDiagnostics` already use; already-spawned processes keep running in the background (bounded by their own per-tool timeout) and still populate the cache for the next caller. Analyzers still in flight when the abort fires are reported via a new `aborted`/`abortedIds` result field, folded into `cold` (so they never silently read as "ran clean") but surfaced in the tool's text as a distinct "stopped mid-scan" note rather than conflated with the "not applicable to this project" cold note. `tools/lens-diagnostics.ts` passes the same `options.signal` it already hands the LSP sweep. Verified empirically against this repo: two successive fresh-fetch calls each took ~11s (not a cache hit) and the knip cache's `meta.timestamp` advanced between them; two concurrent fresh-fetch calls produced identical results confirming the in-flight guard held; a fresh-fetch given a 500ms abort signal returned in ~511ms with `aborted: true` and the correct still-in-flight analyzer ids, instead of the ~11s+ a full run takes. New tests: `tests/clients/gitleaks-client.test.ts`/`govulncheck-client.test.ts`/`trivy-client.test.ts` each gain a de-dupe regression test mirroring `knip-client.test.ts`'s existing pattern (two concurrent `scan()`/`analyze()` calls against the same root spawn exactly one underlying run); `tests/clients/project-diagnostics/fresh-fetch.test.ts` covers per-analyzer gating, cache-key selection (jscpd's TS-project variant, dead-code's per-language keys), a timing-based regression guard proving the analyzers run in parallel, and an abort-mid-scan test confirming a prompt partial return; `tests/tools/lens-diagnostics.test.ts` updated to mock the new `fetchFreshProjectDiagnostics`/`loadBootstrapClients` seams instead of driving the old cache-only path, plus new coverage confirming the abort signal is the SAME instance the LSP sweep receives and that an aborted fresh-fetch renders its own distinct note.
|
|
32
|
+
- **`module_report` flags middle-man / delegate-only classes** (#325, split from #305) — Fowler's "Middle Man" smell (a class whose methods do nothing but forward to one held field) is a whole-class, *universal-quantification* judgment ("EVERY method forwards") that ast-grep's existence matching ("this class *has* a delegate method") can't soundly express without flooding on legitimate forwarding layers, so it's implemented as a structural pass over the already-extracted outline instead of an ast-grep rule. New `clients/middle-man-analysis.ts`: per class, computes a delegation ratio — the share of real methods (accessors and constructors excluded) whose entire body is a single pure-forwarding call (`return this.field.method(...)`, or the same shape without `return` for void methods) to ONE held field — and flags the class (`flags: ["middle man"]`, plus a `delegationRatio` field) only when that ratio clears 90% *and* the class isn't a named facade/adapter/proxy/wrapper/decorator (substring guard on the class name) *and* doesn't structurally `implements` an interface (a legitimate reason for near-total forwarding). Additional false-positive guards: too few methods to judge (<2) never flags, forwards split across more than one delegate field never flags ("one held field" per the issue), and a call that transforms/reorders its arguments doesn't count as pure forwarding. Wired into `moduleReport` (`clients/module-report.ts`) right after member nesting, so the flag rides the same `flags[]`/`delegationRatio` surface `pilens_module_report`/`module_report` already use for "high fanout"/"high complexity". v1 scope: typescript/tsx/javascript, java, kotlin, csharp, swift, dart, python, ruby, rust, php (languages with a deterministic self-reference token and dot-based member access); go/C++ are left for a follow-up since neither has a text-resolvable self-token/access-operator without real AST access. New fixture-driven test suite (`tests/clients/middle-man-analysis.test.ts`) exercises the flag end-to-end through `moduleReport`, with explicit non-flagging fixtures for each guard (named adapter/facade/proxy/wrapper, typed-interface adapter, split-field forwarding, argument-transforming forwarding, and a small class too tiny to judge) alongside positive TS and Python fixtures.
|
|
33
|
+
- **Retroactive changelog entry: `runtimeInstall` + canonical-bin discovery for gopls/csharp-ls/fsautocomplete (Go + .NET slice of #241)** — `ensureTool()` could only auto-install servers in the plain npm/pip/gem/GitHub/maven/archive registry, so toolchain-managed LSP servers stayed PATH-only. `gopls` (`b348ac46`) and `fsautocomplete` (`34427c69`, alongside `csharp-ls`) now use `resolveAndLaunch`'s `runtimeInstall` hook: when the owning runtime (`go` / `dotnet`) is on PATH, pi-lens runs the canonical install (`go install golang.org/x/tools/gopls@latest`; `dotnet tool install --tool-path <pi-lens bin> csharp-ls`/`fsautocomplete`) — never the runtime itself — and falls back to "unavailable" otherwise. New canonical-bin discovery (`goBinCandidates`/`dotnetToolCandidates` in `clients/lsp/server.ts`) also resolves an already-installed server that landed in `$GOPATH/bin` (or `~/go/bin`) or `~/.dotnet/tools` even when that directory isn't on the user's shell PATH, with the bare command tried first so PATH stays authoritative. `rust-analyzer` got the same `cargoBinCandidates` (`$CARGO_HOME/bin`/`~/.cargo/bin`) treatment as a byproduct. This slice was deliberately narrowed to Go + .NET, the two toolchains with a mainstream user base — sourcekit-lsp/haskell-language-server/ocamllsp/nixd remain out of scope and #241 stays open for them. Covered by `tests/clients/lsp/runtime-install-discovery.test.ts` (mocked `launchLSP`/`ensureTool`, no real `go install`/`dotnet tool install` shells out in tests).
|
|
34
|
+
|
|
35
|
+
- **Nightly compat-smoke now pins avtc-pi-subagent's env vocabulary too** (#518, refs #507/#508/#476) — the subagent-extension compat smoke's Layer A already pinned `nicobailon/pi-subagents`' spawn-env contract but had no equivalent guard for the second vocabulary `subagent-mode.ts` detects (`PI_SUBAGENT_CHILD_AGENT` + `PI_SUBAGENT_PARENT_PID`, added in #507). `scripts/lib/compat-contracts.mjs` gains `checkAvtcChildEnv`, a resilient pattern check (grep-verified against the published `avtc-pi-subagent@1.0.3` source, `src/process-runner.ts`) asserting BOTH env-var assignments still exist — mirroring `checkNicobailonChildEnv`'s shape; `scripts/compat-contracts.mjs` now also npm-installs and reads `avtc-pi-subagent`. Layer B (`scripts/compat-smoke-behavioral.mjs`) gains two new behavioral assertions: an avtc-only PAIR (no `PI_SUBAGENT_CHILD`) correctly engages light mode, and the inverse guard — a LONE avtc var (just `PI_SUBAGENT_CHILD_AGENT`, no `PI_SUBAGENT_PARENT_PID`) correctly does NOT, the specific false-positive-protection edge case `subagent-mode.ts`'s doc comment calls out as required. `docs/subagent-compat.md` updated to reflect avtc-pi-subagent as a fully Layer A + Layer B covered contract rather than a deferred gap.
|
|
36
|
+
- **Regression coverage for confusable-hyphen normalization in the read-guard's content comparison** (refs #505) — #505 bundled two items: a did-you-mean suggestion (already shipped) and "Unicode confusable-hyphen normalization before content comparison" (U+2010/2011/2012/2013/2014/2212 -> ASCII hyphen), comparison-only, never applied to written content. Investigating this bundled item found it was already delivered, under a different name, by the host-alignment normalization from #257: `normalizeForGuardMatch` (`clients/host-edit-normalize.ts`) folds `HOST_UNICODE_DASHES` (U+2010, U+2011, U+2012, U+2013, U+2014, U+2015, U+2212 -> ASCII `-`) — exactly the six codepoints #505 names, plus U+2015 — and is precisely the `normalizeContent` that `resolveOldTextEdits` (`clients/read-guard-tool-lines.ts`) uses on its *primary* oldText->range match, ahead of the Tier A/B/C autopatch fallbacks. No production normalization code was added (a second, divergent mechanism would only duplicate #257's). Added: explicit tests pinning this behavior under the #505 framing (`tests/clients/read-guard-tool-lines.test.ts` — ASCII oldText vs. each of the six confusable dashes in file content and vice versa; a hyphen-only difference does not mask an otherwise-different line; a genuine unrelated mismatch still blocks) and a written-content-preservation test (`tests/clients/partial-edit-apply.test.ts` — a newText containing a deliberate em-dash is written to disk byte-for-byte, confirming the normalization never leaks into what gets written), plus a doc comment on `normalizeContent` cross-referencing #505 for discoverability.
|
|
37
|
+
- **Persistent `bus-events.log` for `pilens:files:touched`/`pilens:diagnostics` publish outcomes** — both `pi.events` bus producers (`clients/bus-publish.ts` #482, `clients/diagnostics-publish.ts` #502) are fire-and-forget: on failure or a structural no-op (never wired, kill switch off) they only invoked an optional `dbg` callback, which is a documented no-op in the MCP host (`clients/mcp/session.ts`'s `dbg: noop`) — the same failure shape as the #544 MCP `session_start` incident, just for the bus instead. `clients/bus-events-logger.ts` now writes a small NDJSON summary line (`~/.pi-lens/bus-events.log`, house pattern from `clients/latency-logger.ts`) for every meaningful publish outcome: `emitted` (with a file/diagnostics count and, for diagnostics, the monotonic `seq`) and `emit_failed` (with the error) are logged on every call; `skipped_unwired` and `skipped_disabled` are logged once per process (they're static, session-lifetime facts — wiring happens once at extension factory time, the kill switch is a startup env read — so logging them per publish attempt would spam an identical line with no new information for the life of a long MCP session). The empty-batch no-op is not logged at all (every call site already guards against calling with nothing to report). The existing `dbg` callback contract is unchanged — this is additive, not a replacement.
|
|
38
|
+
- **MCP auto session_start is now visible and self-healing** (#544) — `PI_LENS_MCP_AUTO_SESSION=1`'s self-triggered `session_start` on `initialize` previously only logged to stderr (`console.error`), which Claude Code never surfaces, and had no retry if it never fired or threw before completing — a real incident left a long-lived MCP connection cold for its whole lifetime with no way to tell short of `claude --debug` log spelunking. `mcp/server.ts` now tracks `{ attempted, succeeded, firedAt, error }` state through `maybeAutoSessionStart()` instead of a bare fired boolean, and `pilens_health` surfaces it as an `autoSession` field (`null` when `PI_LENS_MCP_AUTO_SESSION` isn't set at all, distinguishing "feature off" from "attempted and failed"). Self-heal: the first `tools/call` on a connection now also invokes `maybeAutoSessionStart()`, which re-triggers `runSessionStart` if it never attempted, is still in flight, or previously failed — guarded so it's a no-op once a run has already succeeded (never re-runs session_start on every tool call).
|
|
39
|
+
- **`read_symbol` self-healing misses + doc-comment inclusion + duplicate-name disambiguation** (#523, both the pi tool and its `pilens_read_symbol` MCP mirror) — a 2026-07-11 dogfooding assessment found a miss cost an extra round-trip (miss → `module_report` → retry) for what's usually a typo or a qualified name, and that the returned body excluded an attached doc comment even though an agent reading a symbol to edit it needs the contract above it. Four changes, same `readSymbol` (`clients/module-report.ts`): (1) **doc-comment inclusion** (issue author's own follow-up: "probably the highest-value item") — the returned range now starts at an attached doc comment's start line rather than the declaration line, reusing #517's `extractDocCommentInfo` position-based, blank-line-gap-aware attachment computation (a new `docStartLine` field on `Symbol`, alongside the existing `doc` summary); the read-guard coverage recorded for the read (`tools/module-report.ts`'s `recordSymbolRead` tie-in) is extended to match, so editing only the doc comment on an already-read symbol is not wrongly zero-read/out-of-range-blocked. A symbol with no attached comment, or one separated by a blank-line gap, is unaffected. (2) **did-you-mean on miss** — a miss embeds the ~3 nearest symbol/callback names in the file (drawn from the same extraction data `module_report` already builds, no re-parse) via a small dedicated Levenshtein (character edit-distance) similarity function, threshold 0.45 on a normalized 0–1 score, so a typo self-corrects in one turn. Deliberately NOT built on the read-guard's `findSimilarLines`/`tokenSimilarity` (#505) — that does Jaccard similarity over whitespace-tokenized *line content* for relocated-block suggestions, a different comparison shape (a single identifier is one token to it, so a one-character typo on a long name scores 0); no existing levenshtein/editDistance utility was found elsewhere in the codebase. (3) **`Class.method` qualification** — `symbol` accepts a dotted name resolving to a member via line-range containment within the named parent (the same containment shape #301's member nesting uses for the outline, computed directly here since `readSymbol` works off the flat extractor list); an unresolved qualifier (unknown parent, or no matching member) falls through to the plain unqualified lookup and then the did-you-mean miss path, never a crash. (4) **duplicate-name disambiguation** (lowest priority per the issue) — when multiple same-file symbols share the requested name (overloads, an interface and a function sharing a name), the first match is still returned by default (unchanged behavior) but the response now sets `ambiguous: { count, kinds }` and the tool/MCP text notes it; a new optional `kind` parameter picks a specific match.
|
|
40
|
+
- **Shared `makeRunnerCtx()` test helper for dispatch-runner tests** (#187, Tier 2 follow-up to #171) — #171 consolidated the three parallel `ExtensionAPI` mocks onto `tests/support/pi-mock.ts`, but the unrelated `DispatchContext` shape (`clients/dispatch/types.ts`) used by `tests/clients/dispatch/runners/*.test.ts`/`dispatch/rules/*` stayed fragmented: ~26 files each hand-rolled their own local `createCtx(filePath, cwd)`. New `tests/support/runner-ctx.ts` exports `makeRunnerCtx(filePath, cwd, overrides?)`, typed against the real `DispatchContext` and filling in the fields runners actually read (`kind: "jsts"`, `fileRole: "source"`, `autofix: false`, `deltaMode: true`, a fresh `FactStore`, `hasTool` resolving `true`, no-op `log`), with per-test overrides merged on top. `tests/support/runner-ctx.test.ts` covers the helper itself. `ruff.test.ts`, `oxlint.test.ts`, and `biome-check-runner.test.ts` are migrated as the template (pure test-setup refactor, no assertion changes); the remaining ~23 bespoke `createCtx` blocks are tracked in #187 for opportunistic follow-on migration. `AGENTS.md` gains a "Testing dispatch runners (#187)" note pointing future runner tests at the helper.
|
|
41
|
+
- **Auto-install lua-language-server via the archive-tree bundle machinery** (#564, split from #241) — reuses the auto-install path built for clangd: a new `lua-language-server` `ArchiveSpec` in `clients/installer/index.ts` (platform/arch URL resolver over LuaLS's GitHub releases, verified against the live 3.18.2 release asset listing rather than guessed) and `LuaServer.spawn` (`clients/lsp/server.ts`) converted from plain PATH-only `createInteractiveServer` to `resolveAndLaunchTreeBinary`, same as `CppServer`. Resolution order: a system `lua-language-server` on PATH wins; otherwise the managed bundle is installed (when allowed) and `bin/lua-language-server` launched from within it; neither available degrades gracefully (coverage notice, never a hard failure). Covers darwin/linux × x64/arm64 and win32 × x64 (LuaLS has no win32/arm64 build). One asset-shape difference from clangd found during verification: LuaLS's release archives have **no wrapping version directory** (`bin/`, `LICENSE`, `locale/` sit at the archive root), so this entry uses `stripComponents: 0` where clangd uses `1`. Covered by `tests/clients/installer/archive-platform-url.test.ts` (URL-resolution matrix) and a new `tests/clients/lsp/lua-tree-binary.test.ts` (mocked `launchLSP`/`getToolPath`/`ensureTool` — PATH-first, fallback to an already-extracted bundle, on-demand install, and graceful skip when `allowInstall` is false; no real network/download in tests).
|
|
42
|
+
- **`lsp_diagnostics` separates primary-server confirmation from auxiliary-scanner findings, plus a new `serverScope` param** (closes #617, dogfooding finding) — `clientScope: "all"` (the tool's default) touches every attached server for a file, including cross-cutting auxiliaries (ast-grep, opengrep, zizmor, typos, marksman) — a real dogfooding run against `pi-drykiss` returned 55 diagnostics that were 54 ast-grep findings and 1 typescript entry, and the agent's own summary of the result glossed over the fact typescript HAD confirmed the file clean (verified after the fact from `latency.log`'s `lsp_diagnostics_aggregate` entries — `typescript: diagnosticCount:0, health:"ok_empty"` on every file) — real signal buried in aux noise, not a data-loss bug. `tools/lsp-diagnostics.ts` now: (1) always reports the file's actual language server's confirmation (clean/N diagnostics/unconfirmed/timed out) on its own line/section (`primaryServerId(filePath)`, keyed off `LSPServerInfo.role !== "auxiliary"`), independent of how many auxiliary findings accompany it, in single-file, batch, AND directory renders (`Primary LSP (typescript): confirmed clean.` vs. a separate `Auxiliary findings (N):` section) — the batch/directory `cleanFiles`/`unconfirmedFiles` tally was ALREADY primary-only (unchanged), only the flat findings listing was unlabeled; (2) a new optional `serverScope: "primary" | "all"` parameter (default `"all"`, preserving today's behavior) that, when `"primary"`, passes `clientScope: "primary"` to the underlying `touchFile` call and skips every auxiliary scanner entirely — for when the caller just wants "does this have real type errors," fast and low-noise. Deliberately NOT a full removal of auxiliary scanning from this tool: `clientScope: "all"` is the only path that surfaces ast-grep/opengrep findings for a file the agent hasn't dispatched through an edit this session (the same run's Semgrep ReDoS false-positive at `glob-utils.ts:77` was caught exactly this way), so the fix separates the two signals instead of dropping one. New tests in `tests/tools/lsp-diagnostics.test.ts`: single-file and directory-mode splitting of mixed-source diagnostics into Primary/Auxiliary sections, `serverScope: "primary"` threading `clientScope: "primary"` into `touchFile`, and the default still passing `"all"`.
|
|
43
|
+
- **`lens_diagnostics mode=full`/`lsp_diagnostics` no longer burns a full per-file wait on EVERY markdown file in a sweep** (closes #645, dogfooding finding against pi-drykiss) — marksman (the markdown LSP, `clients/lsp/server.ts`) is push-only and its value is CROSS-file (broken intra-repo links, missing anchors), which needs its own one-time whole-workspace index build before a push is meaningful; `server-strategies.ts`'s existing 1500ms `aggregateWaitMs` was tuned for a single warm per-edit touch, not a full-tree sweep. A `runWorkspaceDiagnostics` sweep instead fires a `didOpen` for every markdown file in the project, each one independently racing the SAME cold index build — a real ~155-file project with 34 markdown files came back 34/34 `diagnosticsTimedOut:true` (rendered `unconfirmed` per #634, not falsely clean, but ~51s of structurally-doomed wait for zero signal). Fix (option (a)/(c) from the issue, generalized rather than marksman-hardcoded): `DiagnosticStrategy` gains an optional `workspaceIndexing`/`workspaceIndexingWarmWaitMs` pair (`clients/lsp/server-strategies.ts`) marking a push-only server whose value depends on a one-time index rather than a per-file cost; `runWorkspaceDiagnostics` creates one `SweepIndexGate` per sweep call (`clients/lsp/index.ts`) and threads it through every `touchFile` call via a new `sweepIndexGate` option — the FIRST file touching a `workspaceIndexing` server in one sweep still pays the full `aggregateWaitMs` (1500ms), and every subsequent file in the SAME sweep uses the much shorter `workspaceIndexingWarmWaitMs` (250ms for marksman) instead of re-paying the full budget. Deliberately NOT a global/session-lifetime cache — the gate is a plain per-call object, never stored on `LSPService`, so it can't leak into a later sweep or affect a per-edit touch (which never receives one, and therefore always gets the full budget exactly as before — marksman's single-touch per-edit behavior is completely unchanged, per the issue's explicit non-goal). A genuine timeout on a warm-wait touch still reports `diagnosticsTimedOut`/`inconclusive` exactly as before — the #634 unconfirmed-not-false-clean contract is untouched, this only shrinks the wasted wait for files that pile in behind the first one. New tests: `tests/clients/lsp/workspace-diagnostics-sweep-index-gate.test.ts` asserts the first/subsequent wait-budget split directly (the `ms` argument passed to `waitForDiagnostics`), that a genuine later timeout still reports `timedOut: true`, and that a non-`workspaceIndexing` server (python) is unaffected by the gate.
|
|
44
|
+
- **Test-runner support for PHPUnit and mix test (ExUnit)** — `clients/test-runner-client.ts`'s per-language `RUNNERS` table gains two new entries. PHPUnit is detected via `phpunit.xml`/`phpunit.xml.dist` or a `composer.json` `require`/`require-dev` dependency on `phpunit/phpunit`, invoked via a local `vendor/bin/phpunit` binary when present (falling back to a global `phpunit`), and its default text summary (`OK (N tests, M assertions)` / `Tests: N, Assertions: M, Errors: E, Failures: F, Skipped: S.`) is parsed for pass/fail/skip counts and individual failure names. `mix test` (Elixir/ExUnit) is detected via `mix.exs` and invoked as `mix test <testFile>`, with its `N tests, M failures` summary line parsed similarly. Both add a new `SOURCE_TO_TEST_PATTERNS` entry; test-file discovery reuses the mirrored-directory mechanism from #547 (`relativeSourceDir`) rather than a second parallel one, plus a small targeted addition new `sourceRootMirroredCandidates` helper for the one thing that mechanism didn't already cover: stripping a conventional source-root segment (`src`/`lib`/`app`) and mirroring under each pattern's own configured test root (`SOURCE_TO_TEST_PATTERNS[i].dirs`) — PHPUnit's class-name convention (`src/Foo/Bar.php` -> `tests/Foo/BarTest.php`) and ExUnit's basename-suffix convention (`lib/accounts/user.ex` -> `test/accounts/user_test.exs`, whose `test/` root is singular and wouldn't otherwise be checked) are both discoverable.
|
|
45
|
+
|
|
46
|
+
### Performance
|
|
47
|
+
|
|
48
|
+
- **Pinned `jscpd` bumped from `3.5.10` to `5.0.12`** (#582) — the pin dated back to a real v4 *packaging* bug (`reprism`'s `lib/languages/` dir missing from the published tarball), not a compatibility decision, so v5's ground-up Rust rewrite needed independent re-verification rather than a blind bump. Confirmed by actually installing `jscpd@5.0.12` and running it (not just reading the npm page): the published package is correctly shaped (a real per-platform native binary — `jscpd-windows-x64-msvc` etc. — resolved via `optionalDependencies`, no missing-directory regression); every CLI flag `clients/jscpd-client.ts`'s `runScan()` passes (`--min-lines`, `--min-tokens`, `--reporters json`, `--output`, `--ignore`, plus the positional `.` path) still exists with the same meaning; and the JSON reporter's schema is unchanged for every field `parseReport()` actually reads (`statistics.total.duplicatedLines/lines/percentage`, `duplicates[].firstFile`/`secondFile.name`+`.start`, `.lines`, `.tokens`) — verified against both a synthetic two-file fixture and a real scan of this repo's `clients/` directory, including the exact cwd/positional-arg pattern pi-lens spawns with (`cwd` = scanned dir, arg `"."`), so no adapter changes were needed. Behaviorally, v5 found more clones on this repo (128 vs. v3's 97) and computes its `lines`/`percentage` denominator differently (raw physical lines vs. v3's blank/comment-stripped count — a display-only figure; the unused `formatResult()` helper is the only consumer) — noted as a real, if minor, behavior difference rather than glossed over. The core motivation checked out: ~54x faster on this repo's `clients/` directory (jscpd's own reported detection time 4.105s -> 76ms; wall clock ~4.8s -> ~0.4s), meeting the claimed 24-37x. `clients/installer/index.ts`'s stale v3.5.10-justification comment is replaced with what was verified and why v5 was chosen.
|
|
49
|
+
- **`KnipClient.runAnalyze()` now caches between runs** (#580) — `session_start` and every single `turn_end` (`clients/runtime-turn.ts:390`) each spawned `knip` fresh, forcing a full AST-traversal rescan of the whole project on every call. `runAnalyze()` now passes `--cache --cache-location <dir>` (knip's own disk cache, invalidated conservatively via mtime + file size), with `<dir>` routed through `getProjectDataDir(targetDir)` (`path.join(getProjectDataDir(targetDir), "cache", "knip")`) rather than knip's own `node_modules/.cache/knip` default — matching the existing project convention (`cache-manager.ts`, `call-graph.ts`) and covered by the repo-standing `.pi-lens/` gitignore entry, so the cache never risks getting committed. Verified against the installed knip 6.26.0 (no pinned version in `package.json`; resolved via the installer) rather than trusting the docs blindly: found and worked around a real Windows-specific bug where knip's own auto-`mkdir` for a not-yet-existing `--cache-location` directory silently fails (ENOENT, swallowed internally, only surfaced via `--debug`), degrading every run back to an uncached scan with no visible error. `runAnalyze()` now pre-creates the cache directory (`fs.mkdirSync(cacheLocation, { recursive: true })`) before spawning, sidestepping the bug entirely — confirmed end-to-end against this repo: a warm run reused the pre-created cache and was ~47% faster (1.0s vs. 1.9s) than the cold run, with byte-identical JSON output. Caveat carried over from knip's docs (intentionally not auto-handled): a cached run does not detect a newly-added `.gitignore` file — the cache directory must be deleted to pick it up. New test in `tests/clients/knip-client.test.ts` asserts `runAnalyze()`'s spawned args contain `--cache` and the exact `--cache-location` path, and that the directory actually gets created ahead of the spawn.
|
|
50
|
+
- **zizmor is no longer an LSP candidate for non-GitHub-Actions YAML files** (closes #636) — surfaced while investigating a separate, unrelated dogfooding report of a slow ~5s edit on `.github/workflows/ci.yml` (concluded NOT a bug: the deliberate cost of zizmor's online GitHub-API audit, already escapable via `ZIZMOR_OFFLINE=1`). That investigation's sibling finding: `ZizmorServer` (`clients/lsp/server.ts`) declares `extensions: KIND_EXTENSIONS["yaml"]`, so it was a candidate LSP server for EVERY `.yaml`/`.yml` file, not just actual workflow/action files — the header comment already noted zizmor "only ever emits findings for actual workflow/action files... other YAML is a quiet no-op," but that's a claim about zizmor's *output*, not about whether pi-lens still pays the LSP round-trip *cost* for files it can never report on. Verified empirically rather than assumed: installed real `zizmor` (`pip install zizmor`, v1.26.1) and spoke raw LSP over stdio to a `zizmor --lsp` process against a fixture repo — `.github/workflows/ci.yml` got a `publishDiagnostics` in ~113ms, while a plain `docker-compose.yml` got **no `publishDiagnostics` at all**, not even an empty one, within a 5s window. Cross-referenced against `clients/lsp/server-strategies.ts`'s `zizmor` strategy (`seedFirstPush: true`, `aggregateWaitMs: 2000`): since zizmor never publishes anything for a non-target file, `waitForDiagnostics` has nothing to resolve early on and burns its full per-server budget (2000ms, bounded by the per-edit caller cap) on every such touch, for zero signal, on every edit of any non-GitHub-Actions YAML file (docker-compose.yml, Kubernetes manifests, other CI configs, …) in any project with zizmor installed. Fix: new optional `LSPServerInfo.pathFilter` hook (`clients/lsp/server.ts`) — an additional, narrowing-only candidacy gate beyond `extensions` — wired into `getServersForFileWithConfig` (`clients/lsp/config.ts`). `ZizmorServer.pathFilter` is `isZizmorAuditTarget` (new export, `clients/zizmor-config.ts`), mirroring zizmor's own input-collection rules exactly: `.github/workflows/*.y[a]ml`, `action.yml`/`action.yaml` (anywhere in the repo — composite actions aren't root-only), and `.github/dependabot.y[a]ml` (GitHub only ever reads that one location, so a root-level `dependabot.yml` deliberately does NOT match). No other server needed this hook — zizmor is the only auxiliary whose extension match is provably broader than its useful path set. New tests: `tests/clients/zizmor-config.test.ts` unit-tests `isZizmorAuditTarget` against workflow/action/dependabot paths (including Windows-separator and absolute-path forms) and common non-matches (docker-compose.yml, k8s manifests, issue-template YAML, a root-level dependabot.yml); `tests/clients/lsp/server-policy.test.ts` exercises the real `getServersForFileWithConfig` end-to-end, proving a workflow file's candidate list includes `"zizmor"` and a plain YAML file's does not (while the primary `"yaml"` language server still attaches to both).
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- **Unify the directory-walk decision shared by the three source walkers** (refs #191) — `source-filter.ts` (`collectSourceFiles`/`collectSourceFilesAsync`), `language-profile.ts` (`collectSourceFilesForWarmup`), and `startup-scan.ts` (`countSourceFilesWithinLimit`/`countSourceFilesWithinLimitAsync`) each re-implemented a `readdirSync` + ignore-matcher + exclude-dir walk (the SonarCloud duplication flagged on PR #188's async variants). New `clients/source-walker.ts` centralizes the two genuinely-duplicated pieces — the `readdirSync`-with-try/catch boilerplate (`readDirEntriesSafe`) and the "should I recurse into this directory" decision (`shouldRecurseIntoDir`: ignore-matcher + exclude-dir-name, plus the generated-artifact-directory and symlink-following checks that only `source-filter.ts` opted into) — while each caller keeps its own loop shape, extension/regex rules, build-artifact + generated-header filtering, and hard-cap behavior exactly as before; none of that is silently unified. New `tests/clients/source-walker-equivalence.test.ts` pins a single fixture tree exercising every point where the three callers are supposed to disagree (extension sets, generated-dir skipping, build-artifact shadowing, generated-header sniffing) and asserts each walker's exact historical output, on top of the existing per-file test suites (all of which still pass unmodified). This is only the walker-unification item of #191 — the `isBuildArtifact` memo (already shipped, PR #496), the madge/`actionable_warnings` p99 tail, and `/lens-perf` surfacing remain open.
|
|
55
|
+
- **Adopt pi's dynamic tooling for situational ast-grep/lsp-navigation tools** — pi's `getActiveTools`/`setActiveTools` API lets an extension register tools inactive and activate a subset per-turn, so a lean default tool list doesn't force every situational tool onto every turn. Of pi-lens's 12 pi tools, 6 stay always-active (`lens_diagnostics`, `lsp_diagnostics`, `module_report`, `read_symbol`, `read_enclosing`, `symbol_search`); the other 5 (`ast_grep_search`, `ast_grep_replace`, `ast_grep_outline`, `ast_grep_dump`, `lsp_navigation`) are registered but start inactive, and a new always-active loader tool, `pi_lens_activate_tools` (`tools/activate-tools.ts`), lets the model activate the ones it needs via `pi.setActiveTools([...active, ...requested])` — additive, per the docs' contract; newly-activated tools are callable starting the next turn. Feature-detected the same way as the existing `agent_settled` registration (try/catch, "older pi host?"): `@earendil-works/pi-coding-agent` is a broad, unrestricted peer dependency, so on a host without `getActiveTools`/`setActiveTools` the 5 situational tools are simply left statically active — a silent, graceful fallback matching pi's own behavior on hosts without native deferred-loading support, not a thrown error. The session-start orientation text (`SESSION_START_GUIDANCE`) now calls out which tools are situational and names the loader.
|
|
56
|
+
- **Auxiliary-LSP profile lookup: shared blocking/semantic policy + memoized source lookup** (refs #277 R7) — all four `AUXILIARY_LSP_PROFILES` entries (opengrep, ast-grep, zizmor, typos) carried byte-identical `semantic` lambdas (block on ERROR only when the workspace opted into curated/authored rules, else advisory); extracted to a single shared `blockOnErrorWhenAllowed` so a future policy change is one edit instead of four. `findAuxiliaryProfileForSource` — called once per diagnostic, previously re-scanning all profiles' regexes every time — now memoizes by exact `source` string in a module-level cache, safe because `AUXILIARY_LSP_PROFILES` is a fixed const never mutated at runtime. Behavior-preserving; existing `tests/clients/dispatch/auxiliary-lsp.test.ts` and `tests/clients/dispatch/nosemgrep-suppression.test.ts` pass unmodified.
|
|
57
|
+
- **`dead-code-client.ts` and `knip-client.ts` no longer each hand-roll their own copy of the "climb up looking for a project-root marker" loop** (closes #625) — both `resolveProjectRoot` methods carried a byte-identical depth-64 climb with an inline `isAtOrAboveHomeDir` check per iteration and a `.git`/`.hg`/`.svn` boundary short-circuit, differing only in their marker list (Python project files vs. `package.json`/knip configs) — exactly the duplication `clients/path-utils.ts`'s `findNearestContaining` doc comment already called out as the thing it was meant to be the single source of truth for, except that helper had no boundary concept yet. New `findNearestMarkerRoot(startDir, markers, { boundaries, homeDir })` in `clients/path-utils.ts` extends the same climb (home-ceiling check, then marker match, then boundary short-circuit, then depth-capped parent step, `null` on failure — never a fallback to `startDir`) parameterized by both marker list and boundary list; both call sites now delegate to it instead of maintaining their own copy. Before touching either implementation, pinned the EXACT existing behavior with new tests run against the pre-migration code first (nested-directory resolution, home-dir-itself, above-home ancestor, VCS-boundary stop, no-marker-found) — `tests/clients/dead-code-client.test.ts` gained the same shape of pin tests `tests/clients/knip-client.test.ts` already had (which is how the two were confirmed to be genuine, safe-to-merge duplicates rather than assumed identical); all pins pass unchanged before and after the migration, plus new dedicated `findNearestMarkerRoot` unit tests in `tests/clients/path-utils.test.ts` (including a boundary-vs-marker-at-the-same-directory ordering case, since the marker check must win when both are found in the same directory). Deliberately left alone after auditing: `clients/package-root.ts`'s `getPackageRoot` (same loop shape but a different contract — resolves pi-lens's OWN install root from `import.meta.url`, not a user project, so the home-dir ceiling doesn't apply; and on failure it falls back to the last-reached directory rather than returning `null`, an intentionally different "always resolve to something" semantics that a shared null-on-failure helper would have silently changed); `clients/startup-scan.ts`'s `findNearestProjectRoot` (already shared correctly by `clients/runtime-session.ts`, fixed marker list, no boundaries — confirmed fine as-is, not a duplicate); and everything else that turned up in a grep for the generic `path.dirname(current)`/`parent === current` termination idiom (`clients/dispatch/runner-context.ts`, `.../runners/{shellcheck,shfmt,vale}.ts`, `.../runners/utils/runner-helpers.ts`, `clients/lsp/server.ts`, `clients/file-utils.ts`'s `resolveGitIgnoreRoot`, `clients/formatters.ts`) — each read individually and confirmed to be a genuinely different pattern (single tool-config-file probes, binary/dependency location resolution, gitignore-root resolution with its own always-a-fallback contract, or completely unrelated realpath canonicalization), not assumed to match just because the loop shape looks similar.
|
|
58
|
+
- **`lsp_diagnostics`' batch/directory scan now serializes touches within a single LSP server the same way `lens_diagnostics mode=full` has since #387** (closes #631) — `tools/lsp-diagnostics.ts`'s `collectBatchDiagnostics` used to fan a `paths`/directory file list out across a flat, server-oblivious bounded-concurrency pool (`mapWithConcurrency`, default 8, max 16): up to 8 concurrent touches at the SAME shared, single-threaded LSP server whenever a batch was mostly one language (the common case). `runWorkspaceDiagnostics` (`clients/lsp/index.ts`, the engine behind `lens_diagnostics mode=full`) has been protected against exactly this since #387 (concurrent touches to one server queue server-side instead of parallelizing, cascading per-file timeouts by queue position — observed 51/123 files "timed out" purely from queue position in a flat pool), but `lsp_diagnostics` had no equivalent guard; its current 100-file caps happened to hold up empirically, but that was incidental, not a designed safety property. Extracted `runWorkspaceDiagnostics`'s inline grouping-by-primary-server and one-worker-per-server-group scheduling into two reusable, exported primitives in `clients/lsp/index.ts` — `groupFilesByPrimaryServer` (keys off the same `getServersForFileWithConfig` grouping `runWorkspaceDiagnostics` already used) and `runPerServerGroups` (at most one in-flight `processGroup` call per server group, parallelized across distinct groups up to a `concurrency` cap) — and refactored `runWorkspaceDiagnostics` itself to call them instead of keeping a second, drifting copy of the same logic inline. `tools/lsp-diagnostics.ts`'s `mapWithConcurrency` now groups its file list the same way and schedules through `runPerServerGroups`, preserving the original flat pool's positional result ordering (`results[i]` still matches `items[i]`, via a per-file pending-index queue that also handles duplicate paths in an explicit `paths` batch). The `concurrency` parameter's meaning changes accordingly — it now caps how many DISTINCT server groups run at once, not how many individual files run at once; a single-language batch (the overwhelmingly common case) collapses to one group and runs effectively serially regardless of `concurrency`, which is the CORRECT, intended #387 behavior, not a regression (the tool's parameter description is updated to say so explicitly). New `tests/tools/lsp-diagnostics-per-server-concurrency.test.ts` mirrors `tests/clients/lsp/workspace-diagnostics-per-server.test.ts`'s own proof of the #387 property, driven through the actual `lsp_diagnostics` tool: N files targeting the SAME primary server never have more than 1 in-flight touch at a time while files targeting DIFFERENT servers run concurrently, and `concurrency: 1` correctly forces even distinct server groups to run one at a time. `tests/tools/lsp-diagnostics.test.ts`'s existing mock of `clients/lsp/index.js` now uses `vi.importActual` to keep the real `groupFilesByPrimaryServer`/`runPerServerGroups` wired through alongside its faked `getLSPService` — all 35 existing tests pass unmodified against the real scheduling primitives, not a mock that would trivially satisfy the new behavior either way.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **`nested-ternary`/`long-parameter-list`/`no-dupe-class-members` had ZERO coverage in the ast-grep NAPI fallback runner on `.ts` files** (closes #660, follow-up finding from #657) — `clients/dispatch/runners/ast-grep-napi.ts`'s `TREE_SITTER_OVERLAP` skip-set assumed the tree-sitter query runner already covered these 5 rule ids (`constructor-super`, `empty-catch`, `long-parameter-list`, `nested-ternary`, `no-dupe-class-members`), so the NAPI runner deliberately skipped loading its own copies to avoid double-reporting. Investigated via `git log --follow` on the disabled tree-sitter query (`rules/tree-sitter-queries/typescript-disabled/nested-ternary.yml`): the disable was an intentional April 2026 architecture decision (`84dca1ee`, "ast-grep owns TS/JS rules; tree-sitter owns multi-language structural rules"), not a broken query — so re-enabling the tree-sitter query would have fought that decision, not fixed a bug. Auditing the full skip-set found the false assumption applied to 3 of the 5 entries: `nested-ternary`, `long-parameter-list`, and `no-dupe-class-members` have no active tree-sitter query at all (disabled/never written, confirmed against `clients/tree-sitter-query-loader.ts`'s `-disabled/`-directory exclusion), yet all had a perfectly good, shipped, active ast-grep rule sitting unused. Before #657's fix, `nested-ternary` coverage on `.ts` files was accidentally coming ONLY from the `nested-ternary-js` twin cross-firing on `.ts` — exactly the double-firing bug #657 closes — so closing that bug correctly also closed this rule's only accidental coverage path. The other 2 entries (`constructor-super`, `empty-catch`) are disabled everywhere (ast-grep AND tree-sitter, `rules-disabled/`, #206), so skipping them was already a no-op either way. Fix: removed the entire `TREE_SITTER_OVERLAP` skip-set and its check — a blanket assumption-based list with no verification mechanism is exactly how this drifted false for 3/5 entries without anyone noticing. New tests in `tests/clients/dispatch/runners/ast-grep-sonar-rules.test.ts` cover `nested-ternary` firing on `.ts` again (chained + parenthesized nested ternary, no self-match on a single ternary, and no cross-firing of the `-js` twin per #657) and `long-parameter-list` firing on a 5-required-parameter `.ts` function. Investigating `no-dupe-class-members` surfaced a separate, deeper bug (filed as #663): its rule YAML uses a top-level `utils:` block that `yaml-rule-parser.ts`/the native `findAll` call silently drop, so it still doesn't fire post-fix — documented with a test pinning the current (still-gapped) behavior rather than a false claim of coverage; #663 affects 5 shipped rules total.
|
|
63
|
+
- **ast-grep NAPI fallback runner no longer silently drops a rule's `utils:` block** (closes #663, found while adding a regression test for #660) — `clients/dispatch/runners/yaml-rule-parser.ts`'s `YamlRule` interface had no `utils` field, and `ast-grep-napi.ts`'s native `findAll` call only ever built `{ rule, constraints }`, so any rule with a top-level `utils:` block (reusable named matchers referenced via `matches: <name>` inside `rule`/`constraints` — standard ast-grep YAML syntax) got that block dropped: the unresolved `matches: <name>` reference then either made napi reject the whole rule (silently swallowed by the surrounding `try { ... } catch { matches = []; }`) or matched nothing, so the rule produced zero diagnostics with no error surfaced anywhere. 5 shipped rules declare `utils:` — `no-dupe-class-members`, `no-dupe-keys`, `no-dupe-keys-js`, `rust-2024-let-chain-candidate`, `unnecessary-react-hook` — two of them (`no-dupe-keys`/`-js`) real correctness rules, not cosmetic. Scoped to the NAPI in-process fallback only; the ast-grep CLI/LSP path (used whenever the `ast-grep` binary is installed) already handles `utils:` correctly. Fix: `YamlRule` gains `utils?: Record<string, YamlRuleCondition>` (parsed straight through by the existing faithful js-yaml parse, no parser changes needed), and the native config construction in `evaluateAstGrepRules` now passes `utils` through alongside `rule`/`constraints` — verified against `@ast-grep/napi`'s own `NapiConfig.utils: Record<string, Rule>` type declaration, the exact shape napi's native engine expects. Also: the swallowed `findAll` rejection is now logged via the runner's existing `ctx.log` (a new optional `AstGrepEvaluateOptions.log` sink), so a future malformed-or-unresolvable rule doesn't silently produce zero diagnostics again with zero trace. New tests (`tests/clients/dispatch/runners/ast-grep-utils-block.test.ts`): `no-dupe-keys`/`no-dupe-keys-js` fire on their real duplicate-key fixtures, and (post-#660's removal of `TREE_SITTER_OVERLAP`) `no-dupe-class-members` now also fires end-to-end — all three verified through the actual `runner.run()` front door; `unnecessary-react-hook` (still gated out of the full runner path by an unrelated pre-existing filter — the runner's typescript/javascript-only language filter, since it's tagged `Tsx`, untouched by this fix) is verified directly against napi's real native engine using the exact `{ rule, constraints, utils }` shape the fix now builds; `rust-2024-let-chain-candidate`'s `utils:` block is confirmed to survive YAML parsing (this napi build has no Rust parser at all — Rust rules run via the CLI/LSP path only, already covered by `ast-grep-catalog-rules.test.ts`'s CLI-based fixtures).
|
|
64
|
+
- **ast-grep `-js` twin rules no longer double-fire on `.ts` files in the in-process NAPI runner** (closes #657, confirmed via `~/.pi-lens/latency.log` dogfooding — `hardcoded-url`/`hardcoded-url-js` both firing on one line of a real `.ts` file) — TypeScript's grammar is a syntactic superset of JavaScript, so a `language: JavaScript`-tagged rule using generic node kinds (`variable_declarator`, `assignment_expression`, …) still matched a parsed `.ts` root in `clients/dispatch/runners/ast-grep-napi.ts`'s `evaluateAstGrepRules`, which only filtered OUT non-TS/JS languages (Python, Go, …) but never distinguished TypeScript from JavaScript for the family it actually supports. Audited all 66 `<base>.yml`/`<base>-js.yml` candidate pairs in `rules/ast-grep-rules/rules/`: 65 have byte-identical `rule:`/`message:`/`severity:` bodies (intentional twins — the ast-grep CLI/LSP surface, which DOES correctly gate by `language:`, needs both files shipped for real per-grammar `.js` coverage, per the existing authoring convention in `skills/pi-lens-write-ast-grep-rule/SKILL.md`); 1 (`no-flag-argument`/`no-flag-argument-js`) is genuinely different (`required_parameter` vs `assignment_pattern` node kinds for typed vs default params) and was left untouched. ast-grep's rule schema has no multi-language single-rule mechanism (`language:` is a single required value, confirmed against `rule-schema.json` and every other rule in the catalog), so — matching the issue's own fallback ask — the fix scopes the NAPI runner's dispatch itself: new `ruleLanguageForFile()` maps a file's extension to `"typescript"`/`"javascript"` and a rule's `language:` tag must now match the FILE's actual grammar (not just be "some" JS/TS value) to run against it, closing the bug structurally for every current and future twin pair rather than deleting/merging the intentionally-duplicated rule files. Auditing also surfaced a real, separate copy-paste bug: 7 of the 65 "twin" `-js.yml` files (`array-callback-return-js`, `jwt-no-verify-js`, `no-extra-boolean-cast-js`, `no-implied-eval-js`, `no-javascript-url-js`, `no-sql-in-code-js`, `weak-rsa-key-js`) still carried `language: TypeScript` — their language tag was never updated off the copy-pasted base file, so both files always ran under the SAME language and each had zero actual `.js` coverage under the CLI/LSP path (confirmed against `rules/rule-catalog.json`, which already listed `javascript` for 4 of these ids); corrected all 7 to `language: JavaScript`. New regression coverage: a runner-level vitest test (`tests/clients/ast-grep-rule-precedence-followups.test.ts`) reproduces the exact bug with both a synthetic generic-node-kind rule pair and the real bundled `hardcoded-url`/`hardcoded-url-js` rules, asserting exactly one twin fires per file extension; a new catalog-hygiene script, `scripts/audit-astgrep-rule-pairs.mjs` (`npm run audit:astgrep-rule-pairs`, wired as a new blocking CI lint step), flags any future rule pair with an identical body under the SAME `language:` tag, an identical-body twin whose language tags aren't exactly `{TypeScript, JavaScript}`, or an identical-body twin whose `message`/`severity` has drifted — it would have caught the 7-file copy-paste bug directly. `docs/ast-grep_rules_catalog.md` regenerated (`npm run docs:rule-catalogs`) to reflect the 7 corrected language tags. Deliberately out of scope per the issue: the SEPARATE tree-sitter-engine `rules/rule-catalog.json` catalog (its own `canonical_concept` grouping mechanism, untouched). Also noticed but NOT fixed here (follow-up filed): `nested-ternary` is listed in this same runner's `TREE_SITTER_OVERLAP` skip-set on the assumption the tree-sitter query runner covers it, but that query currently lives under `rules/tree-sitter-queries/typescript-disabled/` (disabled) — meaning `nested-ternary` coverage on `.ts` files was accidentally coming ONLY from the `nested-ternary-js` cross-firing bug this PR fixes, and is now genuinely uncovered by the NAPI runner on `.ts` files (the ast-grep CLI/LSP path, when available, is unaffected since it already gates by language correctly).
|
|
65
|
+
- **Dynamic tool deactivation for the 5 situational tools now actually runs, instead of failing silently on every session_start** (closes #643, found via `~/.pi-lens/sessionstart.log` dogfooding) — the #dynamic-tooling feature (`pi.getActiveTools`/`setActiveTools`) deactivates `ast_grep_search`/`ast_grep_replace`/`ast_grep_outline`/`ast_grep_dump`/`lsp_navigation` at load so the model must call `pi_lens_activate_tools` to bring them in, but the call to `setActiveTools` ran synchronously in `index.ts` right after `registerTool`, still inside the extension's own load/activation function — a point at which the runtime is not yet initialized, so the call structurally cannot succeed on ANY host regardless of version. Every session's log showed the identical caught error, `Error: Extension runtime not initialized. Action methods cannot be called during extension loading`, misleadingly logged as `dynamic tool deactivation failed (older pi host, or tools not registered?)` — the feature-detection `typeof` guard already proved the host supports the API, so the real problem was call-site timing, not host capability. Net effect: the 5 lazy tools were likely never successfully deactivated even once since the feature shipped, defeating the point of the on-demand design. Fix: moved the `getActiveTools()`/`setActiveTools()` call (same feature-detection guard, same filtered-list computation) out of the synchronous registration block and into the existing `pi.on("session_start", ...)` handler, which fires after the extension has finished loading — the correct lifecycle point. Runs on every `session_start` firing (fork/reload/new/resume all fire it in one process's lifetime); safe to re-run each time since `setActiveTools` just replaces the current active set rather than being additive/stateful across calls. The caught-error log message was also reworded, since "older pi host" is now an accurate description if the call still fails there. New/updated tests in `tests/index-wiring.test.ts` drive the real `session_start` handler (mocking `clients/bootstrap.js`/`clients/runtime-session.js` the same way `tests/index-integration.test.ts` does, to keep it a fast wiring check) and assert the 5 situational tools land inactive after it fires, both on a dynamic-tooling host and — unchanged — statically active as a graceful fallback on a host without the API.
|
|
66
|
+
- **`lsp_diagnostics`'s `serverScope: "primary"` actually skips auxiliary scanners now, and the common case drops back to one LSP round trip per file instead of two** (closes #629, #619 regression, live-debugging finding via `latency.log`) — `collectDiagnosticsForFile` (`tools/lsp-diagnostics.ts`) took the `touchFile` branch (correctly `clientScope`-scoped, `collectDiagnostics: true`) whenever `waitMs` was passed or `serverScope: "primary"` was requested, but only ever read `touched?.inconclusive` off its return value and then discarded the array — a second, UNCONDITIONAL `lspService.getDiagnostics()` call (which takes no `serverScope`/`clientScope` argument at all and always queries every registered server) supplied the actual diagnostics content every time. Confirmed live via `latency.log`: a `serverScope:"primary"`/`waitMs:1000` call showed the primary confirmation touch timing out on typescript alone (`clientScope:"primary"`) while a separate `lsp_diagnostics_aggregate` entry for the SAME file, moments later, showed all 4 servers touched (`opengrep` waiting its full 3500ms) — two genuinely separate round trips merged into one inconsistent result, and `serverScope: "primary"`'s own doc comment ("for when the caller just wants confirmation, not a full security/lint pass") was silently broken since the day #619 introduced the parameter. Fix: `touched` — already the correctly-scoped, already-collected `LSPDiagnostic[]` — is now used directly as the diagnostics content whenever the `touchFile` branch was taken and it resolved to something defined; the `getDiagnostics()` call only still runs as a fallback when `touchFile` itself couldn't produce a result (service destroyed, no clients resolved) or when neither `waitMs` nor `serverScope: "primary"` was set in the first place (the pre-existing `openFile`-only path, genuinely unchanged — it never collected anything and still needs the follow-up read). `applyAuxiliarySuppressions` (#586's `// nosemgrep`-style inline-suppression filtering) runs on whichever path produced the diagnostics, so suppression behavior is identical either way. New/updated tests in `tests/tools/lsp-diagnostics.test.ts`: `serverScope: "primary"` and the default `waitMs`-only path now both assert `getDiagnostics` is NOT called at all (not just that the final content matches); a dedicated test proves the rendered diagnostic content comes from `touchFile`'s own return value even when a still-wired `getDiagnostics` mock returns a different (aux-only) finding, which must never leak through; a fallback test confirms `getDiagnostics` is still called when `touchFile` resolves to `undefined`; and a regression guard pins the untouched `openFile`-only path (`getDiagnostics(path, "full")` called, `touchFile` never called) when neither `waitMs` nor `serverScope: "primary"` is set.
|
|
67
|
+
- **Per-edit test-runner findings: fixed a broad vitest include glob producing vacuous `0p/0f` "passes" on plain source files, and stopped silently discarding real results when the turn advanced mid-run** (closes #628, live dogfooding on pi-drykiss) — `~/.pi-lens/sessionstart.log` from a real session showed `src/background-review.ts → test vitest src\background-review.ts (self)` followed by `PASS 0p/0f (0ms)`: a plain source file was targeted as its own test ("self" strategy) and vitest, finding zero actual tests in it, reported a technically-true, practically-meaningless pass. Root cause: `isTestFile()` (`clients/test-runner-client.ts`) let a vitest project's own `test.include` glob override a clean `detectFileRole(...) !== "test"` naming-convention verdict on ANY match — a common real-world glob shape like `src/**/*.ts` (or an unrestricted `**/*.ts`) matches every source file in the tree, not just tests, so the override fired on ordinary edited files. Fixed by tightening the override to a NARROW glob signal (`isNarrowTestGlob`): trusted only when a literal path segment names a conventional test location (`tests/`, `test/`, `spec/`, `specs/`, `__tests__/` — preserving the legitimate case this override exists for, a project whose tests live in such a directory with no `.test.`/`.spec.` in the filename) or the glob's static suffix after its last wildcard encodes more than the bare language extension (e.g. `**/*.check.ts`, preserving the existing unconventional-naming test case); a bare `src/**/*.ts`/`**/*.ts` now fails both checks and no longer self-classifies. Pinned first: new tests reproducing the exact `background-review.ts`-shaped bug were confirmed to fail against the pre-fix code, then confirmed to pass after the fix, alongside the pre-existing narrow-glob legitimate case and a new bare-`tests/`-directory legitimate case, both still passing. Second, independent bug in the same turn_end test-fire path (`clients/runtime-turn.ts`): the deliberately async/non-blocking test-fire (`Promise.allSettled` over `runTestFileAsync`, so tests never stall an edit) detected `runtime.turnIndex !== firedAtTurn` (the turn advanced — another edit landed — before the fired subprocess resolved) and unconditionally discarded the results, logging `discarding test results — turn advanced while tests ran` and writing nothing to the `test-runner-findings` cache the next turn's context injection reads from; real logs showed this firing routinely in a fast-moving session, not as a rare edge case, so the "next turn gets test feedback" contract was regularly unfulfilled. Fixed by caching the results regardless of staleness — a late result is still real information about what's currently broken — tagged `stale: true` and with a `[from a prior turn — …]` prefix on the cached content so a downstream consumer (or the agent reading it) can tell it wasn't from the immediately-preceding edit. Also extended turn_end's test-fire loop to target the test companions of this turn's cascade neighbors (files that import an edited file), not just the edited file itself — a neighbor's own tests can break even though the neighbor's source wasn't touched. Reuses `cascadeResults`, already computed earlier in the same turn_end for the LSP cascade-diagnostics merge (#450's deferred-cascade drain) — no second reverse-dependency walk, and the neighbor set inherits whatever budget (`CASCADE_NEIGHBOUR_BUDGET`) the cascade compute already applied, so this can't turn into unbounded per-edit work. New tests in `tests/clients/runtime-turn-session.test.ts` cover: a stale result still gets cached (with the `stale`/prior-turn tagging), a non-stale result is cached without the tag, and a cascade neighbor's test companion is fired alongside the edited file's own target. Once the underlying data was trustworthy, added a new cache-only extractor (`clients/project-diagnostics/runner-adapters/test-runner.ts`, registered in `extractors.ts`) so `lens_diagnostics mode=full` can also surface `test-runner-findings` per-file, the same way jscpd/knip/madge already do — reads the cache's newly-added structured `results: TestResult[]` field (alongside the pre-existing formatted `content` string the context-injection consumer reads), emits one blocking diagnostic per test failure, and never launches a test run itself (running a whole suite is explicitly out of scope). Covered by new tests in `tests/clients/project-diagnostics.test.ts` (adapts cached failures into per-file diagnostics with the failure name/message; an all-passing cached result contributes nothing).
|
|
68
|
+
- **`lens_diagnostics mode=full`'s workspace sweep no longer front-loads an entire server group's `didOpen` burst in one uninterrupted pass** (closes #621, dogfooding finding) — dogfooding on `pi-drykiss` (~150 TS files) found a full sweep collapsing to near-100% per-file timeouts on every recent run, despite the #608/#616 fixes around the same window being confirmed innocent. Root cause: #608's fix (correctly) pre-opens every file in a server group *before* the per-file diagnostics-wait loop starts, so `WatchedFilesQueue`'s 100ms debounce coalesces the resulting watched-files notifications into one project recheck instead of N cascading ones — but for a single-language project (the common case, one server = one group) that meant firing ALL ~150 files' `didOpen` in one burst, dumping the whole batch on tsserver's single-threaded request queue at once and forcing it to ingest/typecheck the entire burst before any per-file diagnostics request even got a turn. `lsp_diagnostics`' batch/directory mode (`tools/lsp-diagnostics.ts`) never hit this because its flat bounded-concurrency worker pool (default 8) only ever has ~8 files in flight — confirmed directly: an explicit 100-file `paths` batch on `pi-drykiss` via that path completed fast with zero timeouts, while the same project's `mode=full` sweep timed out on nearly every file. The fix chunks `runWorkspaceDiagnostics`'s (`clients/lsp/index.ts`) pre-open+process cycle to `WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE` (default 8, env-tunable via `PI_LENS_LSP_WORKSPACE_PREOPEN_CHUNK`, matching `lsp_diagnostics`' own default concurrency) instead of the whole group: each chunk's opens still land inside the same 100ms debounce window and coalesce into one flush (preserving #608's "no per-file cascade" guarantee — verified an explicit chunked-burst test still coalesces, never regressing to one flush per file), but no single burst dumped on the server ever exceeds the chunk width regardless of total group size, matching the same bounded-concurrency shape that was already proven safe in `lsp_diagnostics`. #387's per-server serialization (one in-flight touch per server, parallel across distinct servers) is unchanged — this only paces the pre-open burst *within* a group, it does not flood across servers. New `tests/clients/lsp/workspace-diagnostics-sweep-preopen-chunk.test.ts` proves both properties directly against the real `WatchedFilesQueue` coalescing primitive: a 40-file group never bursts more than the configured chunk size while still coalescing one flush per chunk (5, not 1 and not 40), and a group smaller than the chunk size is unaffected (still a single flush). The existing `workspace-diagnostics-sweep-batch-open.test.ts` (#608) is updated in place — its 20-file fixture now spans 3 chunks at the default width, so it asserts 3 flushes instead of 1, while still guarding against the original one-flush-per-file regression.
|
|
69
|
+
- **Launching Pi from `$HOME` and then editing an absolute-path file in another repo could block Pi's event loop for 470-500+ SECONDS while the cascade pipeline walked the entire home directory** (closes #622, live dogfooding report) — `runPipeline -> computeCascadeForFile -> buildOrUpdateGraph -> getGraphSourceFiles -> collectSourceFiles -> scan -> isIgnored -> minimatch` enumerated 206,551+ files before the review-graph's `maxGraphFiles` safety cap (#250) even had a chance to trip, because that cap counts post-filter *kept* files, not directory entries visited — an unfiltered tree the size of `$HOME` costs the full readdir/stat walk regardless of how quickly the cap would reject it once hit. Root cause: `buildOrUpdateGraph` (`clients/review-graph/builder.ts`) trusts its `cwd` parameter to already BE a validated project root, an assumption 3 of its 4 real call sites break by passing pi's raw session/pipeline cwd straight through with zero validation — `dispatch/integration.ts`'s `computeCascadeForFile` (the exact path in this issue's stack trace), `mcp/analyze.ts`'s warm-analysis graph update, and `dispatch/runners/tree-sitter.ts`'s `runBlastRadiusInBackground` — while the 4th (`runtime-session.ts`'s session-start call-graph task) is already safe, gated behind `startupScan.canWarmCaches`, itself already rejecting a home-dir cwd. This is the same class of `$HOME` escape `startup-scan.ts`, `dead-code-client.ts`, and `knip-client.ts` each independently closed for their own walks (#250/#253), but the review-graph builder — the one component actually named in this issue's live-inspector stack — never got the equivalent guard. Fixed at the single shared choke point instead of patching each of the 3 unsafe callers separately: `_doBuildGraph` (`clients/review-graph/builder.ts`) now checks `isAtOrAboveHomeDir(path.resolve(cwd))` (`clients/path-utils.ts`, the same ceiling helper the other 4 call sites already use) before any walk, seq-fastpath attempt, or cache lookup runs, and returns an empty, unpersisted graph tagged `mode: "skipped", skipReason: "unsafe_root"` (mirroring the existing `too_many_files` skip shape/`GraphBuildInfo` contract) rather than falling back to walking `cwd` anyway — matching the issue's own stated expected behavior ("skip graph construction when no safe project root exists"). A normal project cwd (the overwhelmingly common case, including one nested under `$HOME` like `~/code/app`) is completely unaffected — only a cwd that IS `$HOME` or an ancestor of it is rejected. The secondary, lower-priority item from the investigation — whether `collectSourceFilesAsync`'s cap should also account for total directory entries visited, not just kept files, as defense in depth — was deliberately left out of this fix (real but separate scope) rather than bundled in. New tests in `tests/clients/review-graph.service.test.ts`: a `buildOrUpdateGraph(os.homedir(), ...)` call now skips near-instantly (`skipReason: "unsafe_root"`, empty graph, well under the ~500s the unfixed walk took) instead of ever starting the walk, and a regression guard confirms a normal project living UNDER home still builds normally.
|
|
70
|
+
- **A genuine LSP server crash could go completely unlogged when the JSON-RPC connection tore down before the process's own `exit` event fired** (closes #618, follow-up to #615) — a live sweep against `pi-drykiss` showed its `ast-grep` client dying and respawning 5 times in ~75s with NOTHING in `latency.log` explaining why (no exit code, no signal, no stderr) — investigation traced it to `setupConnectionLifecycle` (`clients/lsp/client.ts`)'s `lsp_server_unexpected_exit` log being gated on `wasConnected` (captured at the moment the process `exit` event fires): a genuine crash's `connection.onClose`/`onError` handler can run synchronously off the dying stdio pipe and flip `isConnected` false BEFORE `exit` fires, so by the time the exit handler ran, the crash looked indistinguishable from an intentional `clientShutdown()` call and the log was silently skipped. Fixed by adding an explicit `LSPClientState.shutdownRequested` flag, set ONLY by `clientShutdown()`, and gating the exit log on that instead of `isConnected` — a genuine crash is now always logged regardless of event ordering. Also added the missing `exitSignal` (Node's `exit` event's second argument, previously dropped entirely) and a `stderrTail` (last 20 lines, reusing the existing `recentStderr` ring buffer already exposed on the client) to the log's metadata, so a future crash is actually diagnosable instead of just "respawned, uptime was Xms". New `tests/clients/lsp/client-crash-logging.test.ts`, using the same real fake-LSP-server child process the existing integration tests spawn: confirms a `SIGKILL`'d child (no `shutdown()` call first) logs `lsp_server_unexpected_exit` with the real exit signal and a stderr tail, and that an intentional `client.shutdown()` still logs nothing — both against a real process, not a mock. Confirmed the new test fails against the pre-fix `wasConnected` gate (missing `exitSignal`) before confirming it passes against the fix.
|
|
71
|
+
- **`runWorkspaceDiagnostics`'s batch pre-open pass (#608) could hang an entire full-workspace sweep un-abortably** (closes #615, live dogfooding incident) — `preOpenGroupFiles`, added by #608/#610 to fix a watched-files debounce issue, ran ahead of the sweep's already-`withDeadline`-wrapped per-file loop but had no bound of its own: a hung `getClientsForFile` (stuck server spawn/initialize) or `notify.open` (stuck notification write) call froze the whole sweep with no heartbeat, and pressing Escape didn't help either — the loop's `signal?.aborted` check only runs between files, never while one is mid-await. Live symptom: `lsp_workspace_diagnostics_start` logged, then 13+ minutes of total silence, unresponsive to abort. Fix adds two independent bounds around the pre-open attempt: `withDeadline(..., { ms: perFileMs, onTimeout: "undefined" })` (catches a hang even with no user action) and a `Promise.race` against the abort signal (an explicit Escape/turn-abort now unblocks immediately instead of waiting out the rest of the per-file budget). New tests in `tests/clients/lsp/workspace-diagnostics-sweep-batch-open.test.ts` (`#615` block): a pre-open call that never resolves doesn't hang the sweep, and aborting mid-pre-open unblocks well before the per-file deadline would fire — both confirmed to actually hang/timeout against the pre-fix unbounded code before confirming they pass against the fix. Added a standing invariant to AGENTS.md's Performance section: any new async step added to an existing bounded loop needs BOTH a timeout bound and an abort-signal race, not just one — this is the second time in one day a bounded-loop change shipped with an unbounded new step inside it.
|
|
72
|
+
- **`lens_diagnostics mode=full` now runs the heavyweight-analyzer fresh-fetch CONCURRENTLY with the LSP sweep instead of after it, fixing a real-world case where all 7 analyzers went cold** (closes #613, dogfooding finding) — `formatFullMode` (`tools/lens-diagnostics.ts`) `await`ed `fetchFreshProjectDiagnostics` (#585/#590) only AFTER its own `Promise.all([runWorkspaceDiagnostics, ...])` had already fully resolved, sequentially spending the SAME shared wall-clock ceiling (`FULL_SCAN_WALL_CLOCK_MS`) the LSP sweep had already eaten into — despite a comment directly above the call claiming it was "run in parallel with the rest." On a real ~150-file project the LSP sweep alone can take 100+ seconds, leaving the analyzer fetch almost no budget before the shared abort signal fired — killing ALL 7 analyzers (including unconditional ones like knip/jscpd/madge that should always attempt) before any could complete, and rendering `(7 cold: govulncheck, trivy, dead-code, knip, jscpd, madge, gitleaks)` on a scan that had, in fact, barely started the analyzer phase at all. Fix: `fetchFreshProjectDiagnostics`'s promise (built via `analyzersPromise`, gated the same way by `shouldIncludeProjectRunners`) is now included in the SAME `Promise.all` as the LSP sweep and the cheap project-runner scan, so all three phases race the same signal from the same starting point — genuinely concurrent, not stacked. There's no data dependency between the phases (the analyzer fetch never reads the LSP sweep's results), so nothing else about the merge/reconcile logic below needed to change. New test `tests/tools/lens-diagnostics.test.ts` ("starts the analyzer fresh-fetch CONCURRENTLY with the LSP sweep, not after it resolves") holds the LSP sweep's mock promise open and asserts `fetchFreshProjectDiagnostics` has already been invoked before the sweep resolves — verified this test actually fails against the pre-fix sequential code (confirmed by temporarily reverting the fix and re-running it) before confirming it passes against the fix.
|
|
73
|
+
- **`lens_diagnostics mode=full`/`lsp_diagnostics` full-workspace sweeps no longer defeat #271's watched-files debounce, fixing a 0%-100% run-to-run false-timeout rate** (closes #608) — dogfooding a full sweep on an unchanged ~151-file TS project showed a wildly variable per-file timeout rate across successive runs (0%, 100%, 33%, 64%) with the tsserver process never restarted in between, ruling out cold-indexing and already-ruled-out #591 (opengrep exclusion). Root cause: `handleNotifyOpen` (`clients/lsp/client.ts`), the first time it sees a not-yet-open file, enqueues a `workspace/didChangeWatchedFiles` notification via `WatchedFilesQueue` (`clients/lsp/watch-queue.ts`, #271) — built specifically to coalesce a *burst* of file-opens into ONE project-wide recheck instead of N, because classic tsserver (and most push-diagnostics servers) kicks off an expensive full re-analysis on every such notification. `WatchedFilesQueue.enqueue` only arms its 100ms debounce timer on the FIRST call in a burst and just accumulates on every call after — but `runWorkspaceDiagnostics` (`clients/lsp/index.ts`) processes files SERIALLY, waiting up to several seconds per file for its own diagnostics before touching the next one, so consecutive first-opens during a sweep always landed far outside that 100ms window: every previously-unopened file fired its OWN watched-files notification, each independently triggering a project-wide recheck, and later files timed out purely from queueing behind those rechecks — not because anything was actually wrong with them. Whether a given run hit this depended entirely on how many swept files happened to already be open from earlier per-edit dispatch activity that session, explaining the run-to-run variance. Fix: each server group's files are now batch pre-opened (`preOpenGroupFiles`) in one fast, back-to-back pass — with no diagnostics wait between opens — immediately before that group's existing per-file diagnostics loop starts (right after the #387 Item 2 `workspace/diagnostic` pull attempt, so a group whose pull succeeds skips pre-opening entirely and still does zero per-file opens). Firing every open notification with no wait between them keeps every `enqueue()` call inside the 100ms debounce window, so `WatchedFilesQueue` coalesces them into a single flush per server the same way a per-edit dispatch burst already does; by the time the main per-file loop's own `touchFile` call runs, each document is already in `openDocuments`, so `handleNotifyOpen` takes the cheap already-open `didChange` branch and enqueues nothing further. File content read during pre-open is cached so the main loop doesn't re-read the same file from disk. Preserves, unmodified: #387's per-server serialization (pre-opening is itself serialized WITHIN a server group and parallelized ACROSS groups, the identical shape as the diagnostics loop it precedes — sending concurrent opens to one server would reintroduce the exact flooding pathology #387 fixed), #571's inconclusive-signal handling, #586's auxiliary-suppression filtering, and #591's opengrep sweep-exclusion (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`, reused as-is for pre-open's own `getClientsForFile` call) — none of these paths were touched. Per-edit dispatch's own burst-coalescing (`handleNotifyOpen`/`WatchedFilesQueue` themselves) is completely unchanged; this fix is scoped entirely to `runWorkspaceDiagnostics`. New `tests/clients/lsp/workspace-diagnostics-sweep-batch-open.test.ts` imports the real `WatchedFilesQueue` (not reimplemented) and a fake client whose `notify.open` mirrors `handleNotifyOpen`'s open/already-open branch split plus a deliberately slow (150ms, past the debounce window) `waitForDiagnostics` standing in for tsserver's real per-file latency — proving a 20-file sweep over previously-unopened files produces exactly one watched-files flush instead of 20. `tests/clients/lsp/workspace-diagnostics-per-server.test.ts` (#387), `-opengrep-exclusion.test.ts` (#591), and `-suppression.test.ts` (#586) all pass unmodified.
|
|
74
|
+
- **Managed npm tools now re-install when their `packageName` version pin changes** (#589) — `ensureTool()`/`getToolPath()` (`clients/installer/index.ts`) resolved an already-installed managed tool purely by existence + runnability: `verifyToolBinary()` spawned `<bin> --version` and checked only the exit code, discarding the reported version string, so an installed tool never picked up a later pin bump in code (e.g. a `jscpd@3.5.10` -> `jscpd@X.Y.Z`-style change) — it kept running whatever version it happened to install first, forever. Fix, scoped to `installStrategy: "npm"` entries with an explicit `@version` in `packageName` (the only kind with drift to detect — unpinned entries like `madge` have none): `verifyToolBinary` now accepts an optional `onVersionOutput` callback, invoked with the captured `--version` stdout on success; `getToolPath()`'s managed-local-install checks pass it for pinned npm tools, stashing the parsed version (`extractVersionToken`) into a new `lastManagedInstallVersion` map keyed by toolId. `ensureTool()`'s existing-install path compares that against the current pin (`parsePinnedVersion`) and, on mismatch, recurses through the EXISTING `forceReinstall` codepath rather than a new mechanism. Deliberately piggybacks on the spawn `verifyToolBinary` already performs on `ensureTool`'s slow path (post cache-miss) — the in-memory session cache and the 24h persistent probe cache are untouched, so a matching-version tool still resolves with zero new spawns on the hot path, and drift is detected at most once per session (or once per probe-cache TTL, whichever the caller hits first). Investigated whether `installStrategy: "github"/"maven"/"archive"` tools have the same drift bug: they do (an archive tree bundle's extract dir, e.g. `TOOLS_DIR/clangd`, isn't version-named, so bumping a hardcoded version constant in its download URL doesn't force a fresh extract), but none of those resolution paths ever spawn `--version` during discovery (`findGitHubToolPath`/`getArchiveTreeBundlePath` are pure `fs.access` checks) — there is no existing spawn to piggyback on, so fixing them would mean inventing a new mechanism, explicitly out of scope for this fix; tracked separately. New `tests/clients/installer/version-drift.test.ts` covers: a stale-versioned `jscpd` install forces reinstall; a matching-version install resolves normally with no install spawn; a second `ensureTool()` call on an unchanged, matching install hits the in-memory cache with zero new spawns; an unpinned npm tool (`madge`) is completely unaffected; and `getToolPath()` itself still reports a drifted binary as found (discovery is not gated on version — only `ensureTool()` routes drift to reinstall).
|
|
75
|
+
- **`// nosemgrep` inline suppression now honored by `lsp_diagnostics`/`lens_diagnostics`, not just per-edit dispatch** (#586) — pi-lens's own `# nosemgrep`/`// nosemgrep` inline-suppression parser (`isNosemgrepSuppressed`, added for #441 because opengrep's LSP mode doesn't honor it natively) was only ever consulted from the per-edit dispatch runner (`clients/dispatch/runners/lsp.ts`), so a suppression comment that correctly hid a finding during real-time editing still surfaced the identical finding when the same file was queried via the standalone `lsp_diagnostics` tool or `lens_diagnostics mode=full`'s workspace sweep — a real dogfooding report caught opengrep's `detected-github-token`/`detected-jwt-token` rules re-flagging an already-suppressed line. Extracted the profile-lookup-then-`isSuppressed` logic that `clients/dispatch/runners/lsp.ts` already had into two shared, generic helpers in `clients/dispatch/auxiliary-lsp.ts` — `isAuxiliaryDiagnosticSuppressed` (single-diagnostic predicate) and `applyAuxiliarySuppressions` (list filter) — so any `AUXILIARY_LSP_PROFILES` entry with an `isSuppressed` callback (currently only opengrep's) is honored uniformly, not hardcoded to opengrep. `clients/dispatch/runners/lsp.ts` now calls the shared predicate instead of duplicating the lookup; `tools/lsp-diagnostics.ts`'s `collectDiagnosticsForFile` and `clients/lsp/index.ts`'s `runWorkspaceDiagnostics` now filter their raw diagnostics through `applyAuxiliarySuppressions` using the file content already read at each call site (fail-open to unfiltered diagnostics if the content read itself failed). Covered by new tests in `tests/clients/dispatch/auxiliary-lsp.test.ts` (the shared helpers directly), `tests/tools/lsp-diagnostics.test.ts` (single-file and batch `lsp_diagnostics` suppression), and `tests/clients/lsp/workspace-diagnostics-suppression.test.ts` (the `runWorkspaceDiagnostics` sweep).
|
|
76
|
+
- **`high-fan-out`/`high-complexity` no longer false-positive on `describe()`/`it()` test bodies** (#577, found via #576's live-evidence follow-up) — running the real dispatch pipeline against 4 real test files in this repo showed every one triggering a `high-fan-out`/`high-complexity` finding on the outer `describe(...)` callback (up to "51 distinct functions" / cyclomatic complexity 15), while the other 11 `FactRule`s in `clients/dispatch/rules/` had no evidence of test-file noise. Root cause was two-fold, confirmed by directly running `functionFactProvider` against the reported lines: (1) `expect(x).matcher(...)` assertion chains each counted as a *distinct* callee in `high-fan-out`'s fan-out count, because the callee text is the verbatim member-expression source including the differing arguments (`expect(a).toBe` vs `expect(b).toContain` are never deduplicated); (2) the shared tree-sitter walk that computes a function's `outgoingCalls`/`cyclomaticComplexity`/`maxNestingDepth` (`clients/dispatch/facts/function-facts.ts`) doesn't stop at nested-function boundaries, so a `describe()` wrapper's own metrics aggregate every call and branch from ALL of its nested `it()` bodies (each of which also gets its own, correctly-scoped, separate `FunctionSummary`) — a `for` loop inside several sibling `it()`s sums into the enclosing `describe()`'s complexity even though no single test is complex. Scoped narrowly to just these two rules (not a runner-level `skipTestFiles` on `fact-rules.ts`, which would have silently dropped signal from the other 11 rules too, and not a blanket `ctx.fileRole === "test"` exemption, which would have also suppressed genuinely complex test HELPER functions). New shared `clients/dispatch/rules/framework-call-noise.ts` (module named to avoid the pre-existing `test-*.ts` gitignore pattern meant for ad-hoc scratch scripts) exports two call-name-based heuristics used by both rules: `isTestFrameworkNoiseCall` extends `high-fan-out`'s existing "meaningful calls" filter (same style as its `console.*`/`Math.*`/etc. exclusions) with `expect(...)`/`expect`, test lifecycle names (`it`/`test`/`describe`/`beforeEach`/`afterEach`/`beforeAll`/`afterAll`, including `.only`/`.skip`/`.each` variants), and `vi.*`/`jest.*` mock-library prefixes; `isTestSuiteOrganizer` skips evaluating a function entirely (in both rules) when its raw outgoing calls include a direct call to `it`/`test`/`describe` — i.e. it groups nested tests rather than implementing logic itself. Verified empirically that call-name filtering alone was insufficient for the largest reported case (52 calls filtered down to ~25, still over the 20 threshold) before adding the organizer check. A genuinely tangled test HELPER function that does NOT itself call `it`/`describe`/`test` is still flagged by both rules (preserving real signal), and production (non-test) files are completely unaffected — both covered by `tests/clients/dispatch/rules/high-fan-out-complexity-test-noise.test.ts`, which also reproduces the real `describe()`-wrapping-multiple-`it()`s shape from `tests/clients/widget-state.test.ts` end-to-end through `functionFactProvider`.
|
|
77
|
+
- **`fact-rules` diagnostics now stamp `tool: "fact-rules"` instead of their own rule id, fixing scattered turn-summary grouping** (#578) — `clients/dispatch/types.ts`'s `Diagnostic` contract documents `tool` as "which runner produced this" and `rule` as "the specific check within it"; `ast-grep`'s runner follows it correctly (one `tool: "ast-grep"` value shared across every pattern), but all 13 rules under `clients/dispatch/rules/*.ts` (the `fact-rules` runner) stamped their own rule id into `tool` instead (`{ tool: "high-fan-out", rule: "high-fan-out" }`), so `clients/turn-summary.ts`'s collapsed one-liner — which groups by `event.tool` — scattered N fact-rule findings into N separate per-rule-name buckets instead of one clean `fact-rules N` bucket like every other runner. Fixed at the emission source in all 13 files (`async-noise.ts`, `async-unnecessary-wrapper.ts`, `cors-wildcard.ts`, `error-obscuring.ts`, `error-swallowing.ts`, `high-complexity.ts`, `high-fan-out.ts`, `high-import-coupling.ts`, `missing-error-propagation.ts`, `no-commented-credentials.ts`, `pass-through-wrappers.ts`, `placeholder-comments.ts`, `unsafe-boundary.ts`) — `rule` is untouched, so `detectFactRuleId` (`clients/dispatch/integration.ts`, rule-first with an id-prefix fallback) and `code-quality-warnings.ts`'s `warning.rule ?? warning.tool` grouping both keep resolving to the specific rule id. Also closed a latent gap the fix surfaced: `error-obscuring.ts` and `error-swallowing.ts` had no `rule` field at all (only `tool` carried the rule id), which would have made `code-quality-warnings.ts`'s `rule ?? tool` fallback collapse them into `"fact-rules"` too post-fix — both now set `rule` explicitly, matching the other 11 rules' existing shape. New test in `tests/clients/turn-summary.test.ts` constructs three fact-rule diagnostics with distinct `rule` values sharing `tool: "fact-rules"` and asserts they collapse into a single `fact-rules 3` bucket via `formatTurnSummaryLine`.
|
|
78
|
+
- **`clientScope: "all"` (the standalone `lsp_diagnostics` tool and `lens_diagnostics_full`) now gets per-server-aware diagnostics timeouts instead of one flat cap for every spawned server** (#573, found investigating the #570 incident: a 150-file `lens_diagnostics_full` scan took 114s and saturated the TypeScript LSP server) — `LSPService.touchFile` (`clients/lsp/index.ts`) already computed a `perServerTimeout` that reads each server's own `aggregateWaitMs` budget from `server-strategies.ts` (TS ~1s, rust-analyzer 3s, opengrep 3.5s, …), but only wired it up for the single-server `"primary"` hot path (#203) and, later, `clientScope: "with-auxiliary"` (#242). `clientScope: "all"` fell through to the pre-#203 flat `callerCap ?? modeFloor` branch — a leftover from #203 explicitly deferring the "full/cascade path," never revisited when #242 added per-server budgeting for auxiliaries. Every server (fast primary, slow auxiliary) was held to one shared number, either wasting time on a fast server capped to a slow auxiliary's ceiling (multiplied across a full-workspace scan) or starving a slow auxiliary of the time its own strategy says it needs. Fix: `clientScope === "all"` is now included alongside `"with-auxiliary"` in the `perServerTimeout` branch, so each spawned server's individual `waitForDiagnostics` call is bounded by `min(callerCap, ownStrategyBudget)` rather than the flat number. The touch's overall detection deadline (used only for the `lsp_diagnostics_timeout` latency log) is unchanged — it's still `Math.max(...)` over every spawned server's timeout, so "all" still waits for the slowest server before logging a timeout; nothing about auxiliary coverage regresses. `envWait` (`PI_LENS_LSP_DIAGNOSTICS_MAX_WAIT_MS`) and the existing `"primary"`/`"with-auxiliary"` behavior are untouched — this is purely additive coverage of the previously-flattened `"all"` case. Evaluated whether the richer live capability-matrix data (`getCapabilitySnapshots()`'s push/pull `mode`, `diagnosticProviderKind`) should feed `perServerTimeout` beyond what `server-strategies.ts` already encodes; found no case in the current budgeting logic it would improve (the existing `aggregateWaitMs`/`silentOnClean` table already differentiates push vs. pull servers where it matters) — left as a documented non-integration rather than added complexity; `cascade-tier.ts`'s existing capability-matrix consumption is a separate lane (cascade-lane skip-decision, not touch-time budgeting) and is unaffected. New tests in `tests/clients/lsp/service-touch-collect.test.ts` (`#573`): each server on the `"all"` scope gets its own caller-cap-bounded deadline rather than a shared flat number; a fast primary's own wait isn't held to a slow auxiliary's larger budget; a tight caller cap still binds every server as a ceiling; the env override still wins; and a regression guard confirming `"primary"`/`"with-auxiliary"` per-server behavior is unchanged.
|
|
79
|
+
- **A timed-out LSP diagnostics check no longer presents as a confirmed-clean result and no longer erases known-good diagnostic state** (#570, found via live log analysis on a dogfooding project) — `LSPService.touchFile` (`clients/lsp/index.ts`) already tracked `notifyWriteTimedOut`/`diagnosticsTimedOut` per touch (logged to `lsp_touch_file` latency events) but never used them to gate anything: an inconclusive empty `collected` result was cached and returned identically to a genuinely server-confirmed empty result. Concretely, a timeout unconditionally deleted `lastKnownDiagnostics`/`lastKnownContentHash` for the file (so a hot-path consumer like `actionable-warnings` at `turn_end` would see "no known diagnostics" for a file that may still have real errors), and callers of `touchFile` (the per-edit dispatch runner, the `lsp_diagnostics` tool) had no way to distinguish "confirmed 0" from "timed out, defaulted to 0". Fix: `touchFile` now computes `inconclusive = notifyWriteTimedOut || diagnosticsTimedOut` (deliberately touch-wide/conservative — `collected` merges diagnostics across every spawned server, so even a partial per-server timeout means the merge may be incomplete) and (1) skips the `lastKnownDiagnostics` set-or-delete block entirely when inconclusive, leaving whatever was cached from the last confirmed check untouched; (2) flags the returned diagnostics array with a non-enumerable `inconclusive: true` bonus field (a plain array otherwise — existing callers that only read it as an array are unaffected) so callers that care can check `.inconclusive` without a breaking return-type change across `touchFile`'s ~10 production call sites. Two consumers wired: `clients/dispatch/runners/lsp.ts` (the per-edit path feeding the footer/widget via `recordDiagnostics`) now returns `status: "skipped"` (same treatment as "no LSP client was ready") instead of `"succeeded"` with an empty diagnostics list when the touch was inconclusive — this automatically feeds the existing dispatcher coverage-notice mechanism (`getCoverageNotice`), so an inconclusive edit is flagged the same way a fully-skipped one is. The standalone `lsp_diagnostics` tool (`tools/lsp-diagnostics.ts`, and its `pilens_lsp_diagnostics` MCP mirror, which reuses the same `createLspDiagnosticsTool()`) now threads the priming `touchFile`'s `inconclusive` flag through `collectDiagnosticsForFile`/`collectFileDiagnosticResult` and folds a timed-out check into the existing #533 "unconfirmed" bucket (never a bare "0 diagnostics"), while distinguishing WHY in the rendered text/compact-render/batch-and-directory tallies ("N timed out" vs. #533's "cannot confirm clean — push-only, silent-on-clean") via a new per-result `timedOut` field and `timedOutFiles`/`unconfirmedReasonClause` aggregation, rather than collapsing the two distinct reasons into one misleading message. The non-timeout path is unchanged: a genuinely fast, confirmed empty result still clears the cache and reports clean exactly as before. New tests: `tests/clients/lsp/service-touch-collect.test.ts` (`#570` describe block — a timed-out touch does NOT clear a prior confirmed non-empty `lastKnownDiagnostics` record; a confirmed non-timeout empty result still clears it as before), `tests/clients/dispatch/runners/runner-status-semantics.test.ts` (the lsp runner returns `"skipped"` on an inconclusive touch), `tests/tools/lsp-diagnostics.test.ts` (`#570` describe block — single-file/batch renders distinguish timed-out from confirmed-clean/silent-unconfirmed).
|
|
80
|
+
- **`lens_diagnostics` mode=full and `lsp_diagnostics` now reconcile fresh scan results into the footer/widget-state cache** (#571) — `recordDiagnostics` (`clients/widget-state.ts`), the sole writer to the footer's `allDiagnostics` store, previously had exactly one caller: `pipeline.ts`'s per-edit dispatch. A `lens_diagnostics` mode=full workspace scan or a standalone `lsp_diagnostics` check fetches fresh, authoritative diagnostics for files it examines but, until now, only reported that data back to the caller — never reconciled it into the footer. Practical consequence: if file A's diagnostics only became stale/fresh because of a change in file B (e.g. a shared interface), and A itself was never directly re-edited, the footer for A could stay stale indefinitely even after a scan proved the fresher truth. Both tools now call a new shared choke point, `clients/widget-state.ts`'s `reconcileScanDiagnostics(filePath, diagnostics, confirmed, writeIndex?)`, for each file they get a result for; `index.ts` injects the same monotonic `RuntimeCoordinator.nextWriteIndex()` source `pipeline.ts`'s per-edit writes draw from, so the existing `WriteOrderingGuard` (#555/#560) can't be clobbered by (or clobber) a concurrent, genuinely newer per-edit write for the same file. Guardrail: a result the check can't vouch for is never reconciled. `#570` landed in the same window as this fix (see above) and its `inconclusive` signal (`touchFile`'s non-enumerable `.inconclusive` flag, set when the notify write or the diagnostics wait itself timed out) is what BOTH tools now key off: `lsp_diagnostics` reuses it directly (threaded from `collectFileDiagnosticResult`/`runFileDiagnostics`'s own `timedOut`/`confirmation` fields), and `lens_diagnostics` mode=full's per-file LSP sweep (`runWorkspaceDiagnostics`) reads the same flag off each `touchFile` call's result and ORs it with its own outer per-file deadline/throw (`LSPWorkspaceDiagnosticResult.timedOut`) — either reason skips reconciliation for that file. No new batching/throttling was added for full-scan bursts — `recordDiagnostics`'s render trigger is a standard TUI dirty-flag request (already exercised by today's per-edit multi-file cascades without dedicated batching) and each write is independent/synchronous, so a scan touching many files behaves the same as N sequential per-edit writes already do. New tests: `tests/clients/widget-state.test.ts` (`reconcileScanDiagnostics`'s confirmed/unconfirmed gating and write-ordering-guard interaction), `tests/tools/lens-diagnostics.test.ts` and `tests/tools/lsp-diagnostics.test.ts` (each tool's confirmed-vs-timed-out/unconfirmed reconciliation wiring, including batch mode).
|
|
81
|
+
- **Native TS7 no longer inherits classic typescript-language-server's `silentOnClean` cascade fast path** (#558, reverts #541) — PR #541 (2026-07-11) classified TS7's native `tsc --lsp --stdio` launch variant (PR #526) as `silentOnClean`, letting the cascade lane (`clients/lsp/cascade-tier.ts`) skip its in-lane diagnostic wait for native-ts7 edits on the strength of a clean-signal probe run that appeared to show it silent, same as classic. A 2026-07-12 dual-environment re-measurement (nightly CI on Linux and a live local run on Windows dev, same `typescript@7.0.2` both times) found native-ts7 now publishes 2 version-less diagnostic sets on the clean transition (`cleanPubs=2(v:0)`) — it is NOT silent, a drift from the #541 measurement, and skipping the wait could miss or delay real diagnostics on native-ts7 edits. `cascade-tier.ts`'s classifier again routes a `launchVariant === "native-ts7"` snapshot through the fail-safe full-wait path; `server-strategies.ts`'s `silentOnClean: true` for `"typescript"` is effectively classic-only again. Classic typescript-language-server is unaffected — re-confirmed silent (`cleanPubs=0(v:0)`) in the same run. `scripts/probe-clean-signal.mjs`'s nightly drift check no longer routes native-ts7 rows through classic's shared marker; it now compares them against an explicit `false` expectation, so a future TS7 build that goes silent again surfaces as a `silent-not-marked` signal instead of being silently skipped.
|
|
82
|
+
- **LSP diagnostics no longer briefly cache stale results after rapid edits** — `textDocument/publishDiagnostics` pushes were written into the diagnostics cache (`pushDiagnostics`) unconditionally, even when the server's own reported `version` field showed the push was computed against an EARLIER edit than the one currently in flight (e.g. the server was still finishing analysis of edit N when edit N+1 already landed). `isVersionStale()` (`clientWaitForDiagnostics`'s staleness check) only gated whether a diagnostics *wait* resolved early — it was never consulted by the plain read path (`client.getDiagnostics()`/`getAllDiagnostics()`/`pruneDiagnostics()`), so a late push could get cached and served as "current" until the next genuinely fresh push overwrote it: diagnostics would transiently show stale results right after a rapid edit, then self-correct a moment later once real analysis caught up. The `publishDiagnostics` handler in `clients/lsp/client.ts` now drops a push before it reaches the cache (no `pushDiagnostics.set`, no `diagnosticsVersion` bump, no `diagnostics` event emit) whenever the push reports a version that's behind the currently-tracked document version for that path — checked at write time (after the debounce timer fires, not at notification-receipt time) so a push that arrives fresh but whose debounce window straddles a later edit is still caught. Version-less servers (no `version` field reported) are unaffected — that remains an intentional, documented tradeoff, unchanged by this fix. A dropped push correctly emits nothing, so a pending `clientWaitForDiagnostics` call still falls through to its other resolution paths (a later genuinely-fresh push, or the existing timeout backstop) rather than resolving on stale data. Deliberately out of scope, left as known follow-ups: the pull-diagnostics path (`clientRequestPullDiagnostics`/`clientRequestWorkspaceDiagnostics`) has no version stamp to compare against in this codebase's current handling, so nothing analogous is applied there; and `diagnosticsVersion` remains a single global counter rather than per-path, so an unrelated path's fresh push can still satisfy a wait baselined on this path's version — both are separate, larger-blast-radius changes.
|
|
83
|
+
- **`recordDiagnostics` drops superseded writes to the widget-state diagnostics cache (same race class as #555)** — pi-lens deliberately allows concurrent pipeline runs for the same file across different same-turn edits (dedupe key is `filePath + contentHash`, not just `filePath`), so an older edit's pipeline can still be running analysis (e.g. a slow lint/LSP runner) when a newer edit's pipeline has already finished. `clients/widget-state.ts`'s `recordDiagnostics()` — the store `lens_diagnostics` and the TUI widget read as "current" for a file — previously overwrote a file's diagnostics unconditionally on every call, with no ordering check. If the older edit's write landed after the newer edit's, the diagnostics shown for that file could transiently reflect the older, superseded edit rather than the one currently on disk — self-correcting only if/when a later write happened to land. `recordDiagnostics` now takes an optional `writeIndex` (threaded from the monotonic per-edit token already assigned in `clients/runtime-tool-result.ts`, via `clients/pipeline.ts`) and drops a write whose token lags the last one already recorded for that path — no diagnostics/count/timestamp update and no render trigger for a dropped write, so it can't partially corrupt the winning write's state. Call sites with no ordering token (e.g. `clients/mcp/analyze.ts`'s on-demand recorder) are unaffected, same as version-less LSP servers in the #555 fix. Extracted the guard shape into a small, reusable, non-diagnostics-specific primitive, `clients/write-ordering-guard.ts`'s `WriteOrderingGuard` (a `Map`-backed "only proceed if this token is >= the last-seen token for this key" check), available for #557's tracked follow-up (`code-quality-warnings.ts`, suspected same bug shape) rather than duplicating the check a second time. `clients/lsp/client.ts`'s already-merged #555 fix is left as-is (additive only, not retrofitted).
|
|
84
|
+
- **Dropped the redundant `ast_dump` tool registration** — `createAstGrepDumpTool`/`createAstDumpTool` (`tools/ast-dump.ts`) were two separate `registerTool` calls wrapping the exact same implementation under two names, doubling that tool's weight in the tool list for zero functional benefit. `ast_grep_dump` (the already-documented preferred name, referenced throughout `AGENTS.md`/`docs/agent-tools.md`/the ast-grep skill) is kept; the `ast_dump` alias registration and its now-unused `createAstDumpTool` export are removed.
|
|
85
|
+
- **`bus-events.log` registered with the log-cleanup retention sweep, and `MANAGED_LOG_FILES` replaced with auto-derivation** — #551 added `clients/bus-events-logger.ts` (`~/.pi-lens/bus-events.log`) but never added it to `clients/log-cleanup.ts`'s hand-maintained `MANAGED_LOG_FILES` array, so it grew unbounded and untouched by retention — the third time this exact class of mistake has happened (after actionable-warnings/ast-grep-tools/dead-code, per the module's own doc comment). Rather than patch the array again, `clients/ndjson-logger.ts`'s `createNdjsonLogger` now self-registers the absolute path of every static-`filePath` instance into an exported registry (`getRegisteredLogFiles()`) at construction time — i.e. at the `*-logger.ts` module's own load time, with zero action needed in `log-cleanup.ts`. `log-cleanup.ts`'s new `getManagedLogFiles()` derives its list by unioning: (1) that registry, filtered to the target directory; (2) a direct `~/.pi-lens/*.log` directory read (excluding rotated-backup names via the existing `ROTATED_BACKUP_RE`) as an import-order safety net, in case a future logger module is only ever dynamically imported and hasn't self-registered by sweep time; (3) a small explicit `UNMANAGED_STRAGGLER_LOG_FILES` list (currently just `sessionstart.log`, which predates `createNdjsonLogger` and writes via a bespoke `fs.appendFile` in several modules, so it can't self-register). Both `rotateLogIfNeeded`'s sweep and `getLogStorageSummary` now read from `getManagedLogFiles()` instead of the removed static array — a new global log built on the shared `createNdjsonLogger` writer gets retention/rotation coverage automatically, no second list to remember. Audited the four loggers that separately pass their own `maxBytes`/`backupPath` to `createNdjsonLogger` (`actionable-warnings-logger.ts`, `ast-grep-tool-logger.ts`, `dead-code-logger.ts`, `read-guard-logger.ts`, all rotating at a ~1MB default into a `.log.1` backup) against the centralized 10MB-default sweep (which rotates into a `.<timestamp>.log` backup): both mechanisms are active on the same files, but not in real conflict — the lower per-instance threshold fires first in practice, so the centralized sweep is a rarely-triggered backstop for those four, not a race; left as-is rather than restructuring rotation for an unrelated set of files.
|
|
86
|
+
- **Test runner now resolves mirrored test-tree layouts** (#547) — `TestRunnerClient.findTestFile` only checked same-directory, `dir/__tests__/`, and flat top-level `tests/<basename>` locations, missing the common "mirrored subdirectory" layout (`tests/<same-relative-subdir>/<basename>.test.ts`) used by this repo itself (`clients/knip-client.ts` → `tests/clients/knip-client.test.ts`). As a result, pi-lens's own `turn_end` test-runner integration could only resolve its own tests via the slower/brittle import-scan fallback. `findTestFile` now also checks `tests/<relative-subdir>/` and `__tests__/<relative-subdir>/` for TS/JS exact-match candidates, and the equivalent mirrored directory for the Python glob search (`test_*.py` / `*_test.py`), before falling back to import scanning. Additive — existing same-dir, `__tests__/`, and flat-`tests/` candidates are unchanged and still checked first. Two follow-up gaps closed in the same fix: (1) `detectRunner`'s `node_modules` check only looked in `cwd`, missing hoisted monorepo layouts (npm/yarn/pnpm workspaces) where a workspace package's own `node_modules` doesn't exist and vitest/jest only live at the workspace root several directories up — `findHoistedNodeModulesPackage` now walks up parent directories looking for the runner package, bounded to `MAX_NODE_MODULES_WALK_UP` (5) levels and stopping at the filesystem root, never an unbounded walk. (2) Python suites that group tests by kind (`tests/unit/`, `tests/integration/`) rather than mirroring the source tree still fell through to the import-scan fallback, which doesn't handle `.py` files at all — `findPytestMatchRecursive` now does a depth-bounded breadth-first search under the test root (`MAX_PYTEST_RECURSE_DEPTH`, 3 levels, skipping hidden dirs and `__pycache__`) as a last resort before import scanning, only engaged when the exact-mirror candidates don't match so it never overrides the existing mirrored-match preference. Two more follow-up gaps closed: (3) `getTestRunTarget` called `findTestFile` unconditionally even when the edited file was itself already a test file — e.g. editing `foo.test.ts` directly stripped the extension to basename `foo.test` and searched for nonsense candidates like `foo.test.test.ts`, found nothing, fell through the import-scan fallback (which also finds nothing, since nothing imports a test file), and returned `null` — silently disabling the `turn_end` test-runner integration for the very common case of editing a test file directly. `getTestRunTarget` now checks whether the edited file is itself a test file (reusing the shared `detectFileRole` classifier from `clients/file-role.ts` — no second parallel detector) and, if so, skips discovery entirely and returns the file itself as the target (new `strategy: "self"`), while still preferring the existing failed-first rerun path when that same test file is already in the known-failing set. (4) Added a best-effort, text-only scrape of a vitest config's `test.include`/`test.exclude` arrays (`parseVitestTestGlobs`, cached per `cwd`) as a secondary signal alongside `detectFileRole` — deliberately not a real config load (no ESM/TS execution, which `runtime-turn.ts`'s per-edit `turn_end` hot path can't afford), just a regex extraction of a plain string-literal array when the config is written in that simple shape, falling back to `null` (zero behavior change) for anything more dynamic (function calls, spreads, computed values) or when no vitest config exists.
|
|
87
|
+
|
|
88
|
+
## [3.8.69] - 2026-07-11
|
|
89
|
+
|
|
90
|
+
### Fixed
|
|
91
|
+
|
|
92
|
+
- **Warm MCP server no longer silently serves stale code after a rebuild** (#535, refs #514/#256) — the long-lived warm server loads its code once at process start and never re-reads disk, so a `npm run build:dist`/merge that lands after the server started went completely undetected: dogfooding a post-#517 rebuild through an already-running server still returned the pre-#517 `pilens_module_report` schema, the exact "plausible-but-wrong" failure the #240/#511 honesty doctrine exists to prevent. Fix: at startup, `mcp/build-staleness.ts`'s `computeBuildStamp` captures the mtime of the server's OWN entry file (resolved via `import.meta.url`, never a hardcoded repo path — the server may run from an installed package); every `tools/call` and the IPC side-channel handler re-check via a `StalenessGate` (one `fs.stat`, cached at most once/second — same shape as the #492 cross-process reader, so a burst of calls costs one stat). On a detected mismatch: `pilens_analyze` (a stateless per-file dispatch with no warm-only dependency) force-routes through the EXISTING `mode=fresh` worker fork even when the caller asked for warm, tagging the result `servedBy: "fresh (warm code stale — restart the Claude session to re-warm)"`. Every other tool depends on state that only exists inside this process (the in-memory review graph behind `pilens_module_report`/`pilens_symbol_search`, the warm LSP fleet behind `pilens_lsp_navigation`/`pilens_lsp_diagnostics`, the CacheManager/latency log behind the rest) — a fresh fork would answer with an EMPTY graph, a worse result than a stale-but-populated one, so those get an honest `warmCodeStale: true` warning appended instead of routing. The PostToolUse hook's warm-IPC-first path (`clients/mcp/ipc.ts`) gets the same protection for free: on stale, the IPC handler replies with an error, which the hook bin (`mcp/analyze-cli.ts`) already treats as "no usable warm server" and falls back to its own cold, load-fresh-from-disk analysis — no new fresh-fork plumbing needed there. Kill switch: `PI_LENS_WARM_STALENESS_CHECK=0`.
|
|
93
|
+
- **`lsp_diagnostics`/`lens_diagnostics` no longer render an unanswerable scope as "0 diagnostics"** (#533) — dogfooding saw a live session render `workspace — 0 diagnostics` from `lsp_diagnostics` against classic typescript-language-server, a push-only server that publishes NOTHING on a clean→clean transition (`silentOnClean`, `server-strategies.ts`) — that "0" is unverifiable: it can mean clean, still-analyzing, or never-asked (the #240 doctrine, now applied to the tool surface). Root cause: `collectFileDiagnosticResult` (`tools/lsp-diagnostics.ts`) treated an empty diagnostics array as unconditionally clean, with no path for "the server never confirmed this." Fix: a new `classifyEmptyResult` helper reuses the #458 cascade lane's own classifier (`classifyCascadeWaitTier`, `clients/lsp/cascade-tier.ts`) — the same live capability-snapshot + `silentOnClean` check already trusted there — to mark an empty result `"unconfirmed"` when it came from a push-only, silent-on-clean server, vs. `"clean"` otherwise (pull servers, or push servers not known to be silent). Batch and directory aggregation now tally clean vs. unconfirmed per file and surface both in the tool result text (`"7 clean · 2 unconfirmed (server cannot confirm — push-only, silent-on-clean...)"`) and the compact render (`lsp_diagnostics across 12 files — 3 diagnostics · 7 clean · 2 unconfirmed`) — an unconfirmed-containing result can never compact-render as a bare diagnostic/clean count. `lsp_diagnostics` has no workspace-pull mode today (only file/paths/directory), so there is no workspace-pull path to gate on the capability snapshot's mode; the directory/batch per-file scan itself IS the fallback the issue's directive asks for, now made honest. Separately, `lens_diagnostics mode=full`'s cache-only extractor registry (`clients/project-diagnostics/extractors.ts`, knip/jscpd/madge/gitleaks/govulncheck/trivy/dead-code) silently skipped any analyzer with no cache entry — indistinguishable from an analyzer that ran and found nothing clean. `extractCachedProjectDiagnostics` now also returns which extractor ids are cold (never populated this session), and the tool appends an actionable note naming each cold analyzer and what warms it (following #511/#514's honesty-warning shape), both in the result text and the compact render's `(N cold: knip, jscpd, ...)` suffix — a fully-cold registry can no longer render as a plain "clean". Fail-safe throughout: any classification error defaults to the pre-#533 behavior (clean / no cold note) rather than manufacturing a new failure mode.
|
|
94
|
+
- **Test hermeticity for `~/.pi-lens` machine-global state + reaper heartbeat-staleness gap** (#525, refs #515/#474/#449) — dogfooding found a test-fixture instance (`Temp/pi-lens-turn-summary-*` projectRoot) in the developer's REAL `~/.pi-lens/instances.json`, alongside the genuine live session, ~17h after the test run that created it. Two fixes: (1) every machine-global writer (`instances.json`, `probe-cache.json`, all loggers, managed tool/bin dirs, LSP server storage) already routed through the single `getGlobalPiLensDir()` helper (`clients/file-utils.ts`) except four stragglers that bypassed it with a direct `os.homedir()` call (`diagnostic-logger.ts`, the installer's `PROBE_CACHE_PATH`, `biome-client.ts`/`jscpd-client.ts`'s managed-bin lookups, and `lsp/server.ts`'s `tryGemInstall`) — all four now route through it too, and the helper gained a `PI_LENS_HOME` env override (the machine-scoped sibling of the existing project-scoped `PILENS_DATA_DIR`), same pattern as #515's `PI_LENS_CONFIG_PATH` for `config.json`. `tests/support/vitest-setup.ts` now points `PI_LENS_HOME` at a per-worker `mkdtemp` directory (not a nonexistent path — the instance registry actively writes into this root during normal operation), so no test can leak into the real homedir again; a regression test (`tests/clients/pi-lens-home-hermeticity.test.ts`) proves `registerInstance` never touches the real registry file. (2) Root-caused why the fixture entry survived a reap 13h later: `decideOrphanReaping` (`clients/instance-reaper.ts`) only ever classified a parent instance as dead via raw `process.kill(pid, 0)` pid-liveness — unlike child LSP pids, which get a command-line/marker identity check to guard against a recycled pid, the parent pid had NO identity verification at all (there was nothing to check it against). Windows recycles pids far more aggressively than POSIX (no zombie/wait-reaping semantics holding a dead pid "reserved"), so over a long enough window a dead parent's pid is very plausibly reassigned to a live, unrelated process, and `isPidAlive` — correctly, per its own conservative contract — reports it alive forever. `decideOrphanReaping` now also checks heartbeat staleness (new `STALE_HEARTBEAT_MS`, 6 hours), with a deliberate ASYMMETRY by consequence: **staleness cleans registry ENTRIES, never enables kills**. A pid-alive-but-stale instance goes into a new `staleInstances` bucket — its entry is dropped from `instances.json`, but nothing is killed and its children stay marker-protected. Why the asymmetry: heartbeats only fire at turn end (`runtime-turn.ts`) and run settle (`quiet-window.ts`) — no timer exists — so a pi session left open but unused overnight legitimately goes >6h stale while genuinely alive with a warm LSP fleet; killing on staleness would take that fleet down under the idle session, and `matchProcess` identity verification would NOT save it (the children really are that instance's servers — the matcher guards against pid reuse, not against misclassifying a live parent). Process kills still require a pid-confirmed-DEAD parent, exactly as before. Note for anyone reading the real `~/.pi-lens/instances.json` on this machine: any lingering test-fixture entries from before this fix age out of the registry automatically at the next session_start sweep (kills still require a dead pid) — no manual cleanup needed.
|
|
95
|
+
- **Bundled skills namespaced with a `pi-lens-` prefix to avoid user-skill collisions** (#519, reported by @orest-tokovenko-block) — pi discovers both extension-bundled and independently-installed user skills by their frontmatter `name`, and pi-lens's generic skill names collided with unrelated user skills sharing the same name; on a collision, discovery precedence silently skips one copy with a conflict warning (`"ast-grep" collision: ... pi-lens/skills/ast-grep/SKILL.md (skipped)`), so the bundled skill simply stopped being offered. All four bundled skills are renamed with their directories (`git mv`, history preserved) and frontmatter `name` updated to match: `skills/ast-grep` → `skills/pi-lens-ast-grep`, `skills/lsp-navigation` → `skills/pi-lens-lsp-navigation`, `skills/write-ast-grep-rule` → `skills/pi-lens-write-ast-grep-rule`, `skills/write-tree-sitter-rule` → `skills/pi-lens-write-tree-sitter-rule`. Behavior is otherwise unchanged — only the discovery name/path moved. User-facing: anyone who previously invoked the bundled skill explicitly (e.g. `/ast-grep`) must now invoke it by its namespaced name (e.g. `/pi-lens-ast-grep`). `tests/index-wiring.test.ts`'s skill-resolution test now asserts all four namespaced directories exist AND that none of the old generic names exist (a regression guard against renaming back).
|
|
96
|
+
- **Project ast-grep rule precedence follow-ups** (refs #497) — the shared raw-LSP/NAPI discovery seam now walks project and bundled native/CodeRabbit rule trees recursively in deterministic project-primary → project-secondary → bundled-native → bundled-CodeRabbit order. Mutable project-rule caches fingerprint relative paths and contents, so equal-size or preserved-mtime edits, ID changes, renames, additions, and removals invalidate correctly. Synthesized configs and merged rule artifacts are isolated per workspace root in multi-root processes, while NAPI receives the explicit dispatch project root instead of relying on incidental `process.cwd()`. Cross-layer same-ID rules still keep the higher-precedence winner; same-layer duplicates remain raw `sg` errors and now produce equivalent blocking NAPI configuration diagnostics without exposing private paths. Note: recursive bundled discovery also activates the vendored CodeRabbit CWE catalog (~184 rules, all under language subdirectories) for the first time — on the previous top-level-only discovery it silently loaded zero rules in both the NAPI and raw-LSP paths; a regression test now pins that a nested CodeRabbit rule actually fires.
|
|
97
|
+
- **Turn-summary renderer no longer crashes pi on over-width lines** (#513) — both render paths in `clients/turn-summary-render.ts` ignored the `width` parameter of pi-tui's `Component.render(width)` contract, and pi-tui hard-crashes the whole host (`uncaughtException: Rendered line N exceeds terminal width`) on any rendered line wider than the terminal — a 133-column collapsed summary line took down a live 120-column session in the first real-world dogfooding run after #500. The dual-signature `truncateToWidth` shim previously private to the footer widget (`widget-state.ts`) is extracted to a shared `clients/tui-fit.ts` (`fitLine`/`fitLines`), and every turn-summary line — collapsed and expanded — is now fitted to the width the TUI hands us (non-positive/non-finite widths pass through untruncated rather than emitting empty lines). Regression tests measure with the real `visibleWidth`, since the mock-based renderer tests were exactly what let this ship.
|
|
98
|
+
- **`module_report`'s `usedBy`/`semantic` degradation is now honest instead of silent** (#511) — dogfooding found `pilens_module_report` (MCP, warm server) on a recently-added file returning `provenance.usedBy: "none"` and `semantic.source: "none"` with no explanation, indistinguishable from a fully cold (never-built) review graph. Root cause: `moduleReport` (`clients/module-report.ts`) reads the review graph read-only by contract (#256, never builds) — legitimate when the graph just hasn't been rebuilt since a file was added, producing a genuinely stale-but-warm graph (the reported case: the persisted `review-graph.json` predated the file's addition by 12 days, so it had 9,121 nodes but none for the new file). Not a wiring bug — the MCP server read the correct, current graph for that project (`getProjectDataDir`/`normalizeMapKey` keying checked out fine); it was simply stale. Fix: `moduleReport` now distinguishes "no graph at all" (`!graph`, an honest cold start) from "a graph exists but has no node for this file" (`graph && !hasGraphNode`) and pushes an actionable warning in the latter case naming `pilens_rebuild` as the fix, instead of looking identical to the fully-cold case.
|
|
99
|
+
|
|
100
|
+
- **Subagent light mode now also detects avtc-pi-subagent children** (#507) — `isSubagentSession()` (`clients/subagent-mode.ts`) only recognized `PI_SUBAGENT_CHILD=1`, the marker nicobailon/pi-subagents sets; avtc-pi-subagent (the spawn engine under avtc-pi-feature-flow) is the same real child-process execution model but never sets that var — it sets `PI_SUBAGENT_CHILD_AGENT` + `PI_SUBAGENT_PARENT_PID` instead (grep-verified against avtc-pi-subagent@1.0.3). Consequence: light mode silently never engaged for its children, which run the full heavyweight session-start scan suite, multiplied by feature-flow's parallel-reviewer fan-out (up to ~9 subagents per review round). Detection now also treats the PAIR `PI_SUBAGENT_CHILD_AGENT` + `PI_SUBAGENT_PARENT_PID` (both non-empty) as a subagent signal — requiring the pair rather than either var alone is a deliberate false-positive guard, since a lone var set by some unrelated tool must not trigger light mode. `PI_LENS_SUBAGENT_FULL=1` remains the universal opt-out for both vocabularies. `getSubagentIdentity()` now also reports which vocabulary matched (`marker: "pi-subagents" | "avtc-pi-subagent"`), surfaced in the `subagent_light_mode` latency phase so dogfooding can tell the ecosystems apart. The issue's Layer A pinned-contract + Layer B behavioral compat-smoke additions for avtc-pi-subagent are a deferred M-effort follow-up, not part of this fix.
|
|
101
|
+
- **Generated ast-grep/LSP config now honors project-first same-id rule precedence** (#497, reported by @anasalsbey-glitch) — the in-process NAPI runner (`evaluateAstGrepRules`) has always deduped same-id rules project-first (a project rule dir shadows a bundled rule with the same `id`), but the generated raw sgconfig for the ast-grep LSP (`clients/sgconfig.ts`'s `resolveBaselineSgconfig`) listed the project and bundled rule dirs side by side in `ruleDirs`, and raw `sg`/the ast-grep LSP hard-errors ("Duplicate rule id") the instant two listed dirs share an id — verified against a real `sg scan` repro before touching any code. Fix: `resolveBaselineSgconfig` now materializes a single merged, deduped rule directory (doc-level, so multi-rule YAML files are handled correctly) from the SAME project-first-ordered dir list the NAPI runner now also derives from (`shippedRuleDirsInPrecedenceOrder`, shared between both surfaces so they can never drift and disagree on the winner) — `ruleDirs` in the generated config lists just that one directory. Same-layer duplicates (two files in the SAME source dir sharing an id) are deliberately copied through unmodified rather than deduped, so `sg` still hard-errors on them exactly as before — this only resolves the CROSS-layer (project vs. bundled) collision the NAPI runner already tolerated. The merged directory is content-fingerprinted (source dir mtimes) so a mid-session project rule change invalidates the cached config instead of serving a stale winner set. Windows-safe: copies files rather than relying on symlinks (unavailable without elevation on Windows) or junctions (directory-level only, unusable for filtering individual files). — both `no-typeof-undefined` twins previously exempted only `__dirname`/`__filename` from the "use `=== undefined`" hint, so guard-clause idioms like `typeof window === "undefined"` (the standard SSR pattern for browser-only globals, since directly referencing an absent global throws a `ReferenceError`) were flagged as findings. Both rules' `rule.any` now carry a shared `constraints.X.not.regex` excluding `window`, `document`, `navigator`, `self`, `location`, `localStorage`, and `sessionStorage` alongside the existing `__dirname`/`__filename` — a metavariable constraint rather than enumerated `not:` patterns, verified identical (via an napi AST dump) across both the TypeScript and JavaScript grammars, so the twins stay behaviorally aligned. `typeof <declared identifier> === "undefined"` still fires. Separately, `hardcoded-url-js.yml` declared `language: TypeScript` instead of `JavaScript`, so JavaScript/JSX-only syntax never ran through its intended grammar; fixed to `language: JavaScript`, with a new JSX fixture (`const render = () => <a href="http://localhost:3000">...</a>`) proving localhost/API URL detection still fires. Fixture-first: `rule-tests/no-typeof-undefined(-js)-test.yml` and `hardcoded-url-js-test.yml` gained the issue's repro cases, confirmed RED against the pre-fix rules (3 fixtures failing), GREEN after (251/251 `ast-grep test` fixtures pass). Canonical rule catalog (`docs/ast-grep_rules_catalog.md`) regenerated via `npm run docs:rule-catalogs` to move `hardcoded-url-js` from the TypeScript to the JavaScript section.
|
|
102
|
+
|
|
103
|
+
### Added
|
|
104
|
+
|
|
105
|
+
- **`silentOnClean` drift check on the nightly clean-signal probe** (#529) — the #458 tier-aware cascade classification hinges on the hand-set `silentOnClean` marker in `clients/lsp/server-strategies.ts` (today set only for classic `typescript`, measured manually on 2026-07-08); a server update could silently change the answer with no automated re-check. `scripts/lib/clean-signal.mjs` gains a pure `checkCleanSignalDrift`/`findCleanSignalDrift` pair that compares an observed `clean-behavior` classification against the marker: `marked-not-silent` when the marker says silent but the probe saw a real publish (marker too pessimistic — cascade is skipping a wait it doesn't need to), `silent-not-marked` when an unmarked server probes silent (the pre-#458 tsserver situation — cascade burns the full in-lane wait it could skip). Per the #240 doctrine applied to the check itself, `unknown` observations are NEVER treated as drift evidence in either direction — a slow/absent server isn't proof of anything. `scripts/probe-clean-signal.mjs` (already run nightly by tool-smoke, riding the existing `LSP_FIXTURES` clean-fixture infrastructure) now runs this check after classification, resolved through the same clean-fixture-wins `targetLang` logic the matrix merge already used (so the console report, the matrix row, and the drift footnote can never disagree), and writes any mismatch to a new `## silentOnClean drift (nightly-generated)` section in `docs/lsp-capability-matrix.md` — telemetry only, **never a CI gate**, matching the rest of the probe's best-effort/always-exit-0 design. The native TS7 launch variant (`typescript7`/`typescript7-clean`, #524/#526) is deliberately excluded from the comparison: it shares the "typescript" server-strategy key with classic, but the marker is documented classic-only, so comparing it would produce misleading drift rather than a real signal. Live-verified locally: classic `typescript-clean` probes `silent` (tier 3), consistent with the existing marker (no drift reported); the native `typescript7-clean` variant (via a real `typescript@7` install into the fixture's temp workspace) also probes `silent`, correctly excluded from comparison rather than silently validated. 21 new unit tests (`tests/scripts/clean-signal.test.ts`) cover both drift directions, the consistent case, and the never-collapse-unknown guard.
|
|
106
|
+
- **Nightly smoke coverage for the native TypeScript 7 LSP path** (#530, follow-through on #524/#526) — the native `tsc --lsp --stdio` selection previously shipped verified-by-documentation only (the repo pins typescript 6.x), so upstream drift in the `--lsp` flag, stdio handshake, or publish behavior had no regression guard. `scripts/smoke-tools.mjs` gained two fixture-level extensions: an optional `setup` step (string/argv command run in the COPIED temp workspace before `touchFile`, bounded by a 120s timeout — new `typescript7`/`typescript7-clean` fixtures use it to `npm install typescript@7 --no-save --no-audit --no-fund`, since typescript-go's per-platform native binary can't be a committed static fixture; a setup failure reports a distinct `setup-failed` status and never a false pass) and an optional `expectLaunchVariant` assertion (checks the live `getCapabilitySnapshots(file)` `launchVariant`, e.g. `"native-ts7"` — a silent fallback to the classic `typescript-language-server` now FAILS even when a diagnostic arrived, since native and classic share the same `"typescript"` server id and a diagnostic alone can't distinguish them). Verified live against a real `typescript@7.0.2` install: `tsc --lsp --stdio` genuinely speaks LSP framing over stdio and PR #526's assumed invocation is correct. `typescript7-clean` doubles as the future #529 clean-signal probe workspace for the native variant.
|
|
107
|
+
- **`symbol_search` pi tool + always-warm word index (#348 phase 1)** — the word index (identifier inverted index + BM25, #162) previously had exactly one build path (a full-mode-only deferred session task), so it was absent in quick-only sessions and stale after that unless something else happened to rebuild it; empirically `symbolSearch("moduleReport", cwd)` on this repo returned `available: false`. It now gets the same load → rebuild-if-stale → persist lifecycle the call-graph task already uses (`clients/runtime-session.ts`'s new shared `buildOrRefreshWordIndex`, reusing `isProjectSnapshotFresh`/project `seq` — the same edit-provenance signal, not a new one), wired into BOTH the full-mode `runTask("word-index", …)` and the existing quick-mode cold-start warmup pass (the same ~2s-deferred background pass that already warms scan-context + language-profile after a quick first session) — no new mechanism, per the ratified #348 decisions. The file-walk-and-read step is now one shared `collectWordIndexDocs` helper (`clients/word-index.ts`) instead of three near-duplicate copies. A pre-existing snapshot-merge bug is fixed alongside this: `saveRuntimeProjectSnapshot` could "launder" a stale snapshot's leftover word index into looking fresh by re-stamping it with the current `seq` on an unrelated intermediate save — it now only carries the prior index forward when it was built at the SAME seq. Second, `symbol_search` is now a registered **pi tool** (mirroring the existing MCP-only `pilens_symbol_search`) — the entry point of the discovery funnel: `symbol_search` finds ranked candidate files by identifier, `module_report` explains one, `read_symbol` reads the exact body. A cold query (no index yet, e.g. an MCP-only session that never ran `pilens_session_start`) never blocks: it triggers one bounded background build per cwd (deduped via an in-flight guard) and returns `available: false` with an actionable retry hint immediately. Both the new pi tool and the existing MCP `pilens_symbol_search` (which predates #517) now return the slimmed #517 payload: hits carry `startLine`/`endLine` (the best-matching line; read derivation `offset=startLine, limit=endLine-startLine+1`, documented in both tool descriptions) instead of a raw `lines[]` array or a per-hit `read` block, and MCP's JSON is compact (unindented) like `module_report`'s. The `ast_grep_search` 0-match hint and the session-start orientation guidance now route name/usage lookups toward `symbol_search`/`module_report`/LSP `findReferences` instead of only suggesting another AST retry or grep. Phase 2 (per-edit incremental word-index maintenance) remains out of scope and tracked on the still-open #348.
|
|
108
|
+
- **Warm per-edit word-index maintenance, review-graph style (#348 phase 2, closes #348)** — phase 1 kept the word index fresh only via a full session-scoped rebuild; a burst of edits mid-session went unreflected in `symbol_search` rankings until the next rebuild. `clients/word-index.ts` gains a forward index (`WordIndex.forward`: file → per-token distinct-line counts, optional so a pre-phase-2 index/persisted snapshot deserializes as `forward: undefined`) plus `updateWordIndexDocument`/`removeWordIndexDocument`, which use it to do a single-document replace mechanically — subtract this file's own contribution from postings/docLengths/totalTokens/docCount via the forward entry, then add the new one, without rescanning unrelated files. A caller handed a forward-index-less index (old shape) must fall back to a full rebuild — never migrated in place. The per-edit seam lives at the SAME call site as the review graph's `buildOrUpdateGraph` (`computeCascadeForFile` in `clients/dispatch/integration.ts`), reusing content the pipeline already read (no extra I/O); it is keyed by `path.resolve(filePath)` — deliberately NOT the cascade's own `normalizeMapKey`-based key, since the word index's own keys come from the file-walk's plain `path.resolve()` shape and using the wrong key would silently create an orphaned duplicate entry instead of replacing the doc. Cold-session handoff rule: `wordIndex` `null`/absent (nothing loaded yet) is a documented no-op — phase 1's lifecycle/background build owns "cold", never this seam; the update function itself has no `await`, so two overlapping deferred cascades (#450) can never interleave mid-mutation. Files over the existing `WORD_INDEX_MAX_BYTES` cap are removed/absent from the index, never partially indexed; a file the pipeline couldn't read (deleted, transient race) is also a no-op — deletions age out at the next full rebuild, an accepted scope boundary matching the review graph's own. Persistence reuses (generalized, not copied) the review graph's #260 debounced-persist circuit-breaker: `clients/persist-debounce.ts`'s new `createDebounceScheduler` factors out the coalesce-and-flush timer bookkeeping both caches need, while each owns its own serialize+write (the graph writes its own cache file; the word index merges into the shared project-snapshot file via `saveProjectSnapshot`, preserving unrelated fields and honoring the existing seq-laundering guard, extended with a regression test for the previously-untested stale-seq case). New `PI_LENS_WORD_INDEX_PERSIST_DEBOUNCE_MS` env override and `flushWordIndexPersistsForTests()` mirror the graph's `PI_LENS_GRAPH_PERSIST_DEBOUNCE_MS`/`flushReviewGraphPersistsForTests`; `tests/support/vitest-setup.ts` defaults the new debounce to 0 for the same reason it already does for the graph's. The load-bearing test is an equivalence-property check: k randomized incremental edits/additions/removals against an index must produce identical state (postings/df/N/avgdl) AND identical query rankings to a from-scratch `buildWordIndex` over the same final corpus — covering a term disappearing entirely from a doc, a doc shrinking, a doc growing, a doc removal, a brand-new doc, and an unrelated doc verified untouched via forward-index reference identity.
|
|
109
|
+
- **Warm-mode `pilens_analyze` maintains the review graph + word index; MCP graph-staleness signal; `pilens_read_enclosing` (#536, closes #536, refs #522)** — the 2026-07-11 capability-depth parity audit's cheapest-first follow-ups, one PR. (1) **DECISION: retires the #256 "read-only facade" contract for warm mode.** `pilens_analyze`'s warm path (the long-lived MCP server; `fresh` stays read-only — it's an ephemeral forked worker, see `mcp/worker.ts`) now calls `buildOrUpdateGraph` for the analyzed file on a successful, blocker-free dispatch — the SAME call pi's per-edit cascade path makes (`computeCascadeForFile`, `clients/dispatch/integration.ts`), gated by the same `CASCADE_GRAPH_KINDS` file-kind set (now exported for this reuse) and the same "skip on blockers" rule. `buildOrUpdateGraph` owns its own debounced persist/seq machinery internally, so this is the only call needed. Consequence: `pilens_module_report`'s usedBy/blastRadius and `pilens_symbol_search`'s centrality now reflect files analyzed via MCP, not just session-start state — verified end-to-end on a two-file fixture where warm-analyzing the importer flips the imported file's `semantic.source` from `"none"` to `"review-graph"`. Rides the SAME seam for the #348 phase 2 word-index per-edit primitive (`updateWordIndexDocument`/`removeWordIndexDocument`), mirroring (not reusing directly — that function is module-private) `updateWordIndexForCascade`'s rules: a per-cwd live `WordIndex`, loaded once from the persisted snapshot and mutated in place thereafter (the MCP-process equivalent of `runtime.wordIndex`, since MCP has no RuntimeCoordinator to hold it); no forward index cached ⇒ no-op; an oversized file is removed, never partially indexed; a successful update schedules the existing `scheduleWordIndexPersist` debounce (`PI_LENS_WORD_INDEX_PERSIST_DEBOUNCE_MS`) — no second persist mechanism; keyed by `path.resolve(absPath)` to match the word index's own build-path key shape, not `normalizeMapKey`. `symbolSearch()` (`clients/lens-engine.ts`) now prefers this warm in-memory copy over a fresh disk read when one exists, so a `pilens_symbol_search` query immediately following a warm analyze in the SAME process sees the update without waiting on the debounce or a rebuild — verified with a smoke test seeding a snapshot missing a new identifier, warm-analyzing a new file containing it, then confirming `pilens_symbol_search` ranks it with no `pilens_rebuild`/`pilens_session_start` in between. (2) **Graph-staleness signal [S].** `pilens_module_report` and `pilens_symbol_search` gain a staleness hint when their backing data is present but old — extends #514/#511's honesty-warning shape from "missing node" to "aging graph". Surfaces the ALREADY-persisted timestamps (`ReviewGraph.builtAt` for module_report, additively threaded through as `ModuleReport.graphBuiltAt`; `ProjectSnapshot.generatedAt` for symbol_search, additively returned as `SymbolSearchResult.snapshotGeneratedAt`) — no new timestamp had to be invented. A `> 10min` age appends `"<label> last updated <Nm/h/d> ago; run pilens_analyze on recently-changed files, pilens_session_start, or pilens_rebuild to refresh it."` to both the text summary and the JSON payload. MCP-only per the decision: pi's own graph is per-edit warm, so the same line there would be pure noise. (3) **`flushPending` parity for `pilens_diagnostics` [S] — investigated, not wired, because there's genuinely nothing to flush.** pi passes `() => flushDebouncedToolResults()` as `createLensDiagnosticsTool`'s 4th arg; the MCP instantiation passes none. Traced `flushDebouncedToolResults` (`clients/runtime-tool-result.ts`) to a module-level `debouncedPipelines` map that ONLY `handleToolResult` populates — pi's `tool_result` event handler, which the MCP process never calls (`pilens_analyze` routes through the independent `clients/mcp/analyze.ts` facade, calling `dispatchLintWithResult` directly). That map is therefore provably always empty in the MCP process; wiring the flush would be a no-op dressed as a fix. Documented in place at the `mcp/server.ts` instantiation site rather than silently left unexplained. (4) **`pilens_read_enclosing`** (closes #522 item 1) — `readEnclosing` is now re-exported from `clients/lens-engine.ts` and mirrored as a new MCP tool with the same file+line(+kinds/maxLines/onOversize/aroundLine) shape as the pi `read_enclosing` tool and the same header-line-then-body rendering convention as `pilens_read_symbol` (#512); MCP has no read-guard, so — like `pilens_read_symbol` — it returns the body with no coverage recording, an intentional gap, not a bug. `docs/agent-tools.md`'s mirror-exceptions note now lists only `ast_grep_outline`/`ast_grep_dump` as pi-only; `AGENTS.md`'s MCP tool count moves 15 → 16 (both the server.ts file-header comment and the tool-list bullet).
|
|
110
|
+
- **Native TypeScript 7 language-server selection** (#524) — TypeScript projects now inspect the nearest workspace-local `typescript` package before starting an LSP, including dependencies hoisted above a nested monorepo package root. Version 7+ launches that package's matching `node_modules/.bin/tsc --lsp --stdio`, avoiding the previous silent fallback to pi-lens's managed TypeScript 5/6 `tsserver.js`; a nearer TypeScript package always shadows an ancestor (including when that nearer install is malformed/partial — a `node_modules/typescript/` directory with no `package.json` stops resolution there instead of silently falling through to an ancestor TS 7 hoist), and TypeScript <=6 retains `typescript-language-server --stdio` with the existing `TSSERVER_PATH` initialization. Resolution is deliberately workspace-relative (including Windows `.cmd`/`.exe` shims), never a bare global `tsc`, and missing binaries or invalid metadata fall back to the classic discovery path. The launched variant is now recorded on the capability snapshot (`launchVariant: "classic" | "native-ts7"`), so the #458 cascade-lane tier classifier no longer inherits the classic server's `silentOnClean` tier-3 marker for the native TS7 binary — an unverified Go-native server falls back to the fail-safe full in-lane wait instead of risking a dropped clean→clean diagnostic (refs #529, the pending clean-signal probe).
|
|
111
|
+
- **`module_report` doc-comment summaries + `view: "compact"` (#512, slices 1/3/4)** — dogfooding measured `module_report` costing ~1,900 tokens vs ~2,100 to read a representative 266-line file whole, only a ~10% saving; this closes most of the gap. Each `ModuleSymbolEntry` now carries a `doc` field — the first sentence/line of an attached doc comment (whitespace-collapsed, capped ~120 chars) — extracted structurally in the SAME tree-sitter pass that already builds decorators/visibility (`tree-sitter-symbol-extractor.ts`'s new `extractDocComment`, preceding-sibling `comment`-node traversal, position-matched since web-tree-sitter materializes a fresh node object per `.children`/`.parent` access with no stable identity); JS/TS is the primary target, and any grammar sharing the conventional `comment` node shape (Python confirmed) gets it for free. New `view: "compact"` (pi tool + `moduleReport()` engine option, opt-in — default stays JSON) renders the full report as line-oriented text (one line per symbol/member/callback, e.g. `77-81 fn _resetAgentNudgeForTests() — Test-only: clear accumulator state.`) via new `renderCompactModuleReport`, at roughly half the byte size of the JSON view for the same file. Also cut real duplication from the JSON schema: per-symbol `read: {path, offset, limit}` blocks are gone (both tool descriptions now document the derivation — `offset = startLine`, `limit = endLine - startLine + 1`, path = the report's own `path`); `recommendedReads` entries carry `{reason, symbol, startLine, endLine}` instead of repeating the read block; `flags` no longer duplicates `"exported"` (the boolean field already carries it) — cross-file sections (`blastRadius.files[].read`, `usedBy[].file`) are untouched since they legitimately need their own path. The MCP mirror (`pilens_module_report`/`pilens_read_symbol`) now matches the pi tool's compact (unindented) JSON instead of pretty-printing, gained `focus`/`view` passthrough, and `pilens_read_symbol` no longer restates name/kind/startLine/endLine in a trailing JSON block after a header line that already carries them. Deliberate schema break — existing tests updated for the new shape. Follow-ups tracked in #512: MCP parity for `read_enclosing`/`view:"summary"` (slice 2) and size-aware honesty when the outline would cost more than reading the file (slice 5).
|
|
112
|
+
- **Expert LSP for Elixir** (#498) — Expert is now an auto-installed alternate to ElixirLS for `.ex` and `.exs` files. ElixirLS remains the default; add `"elixir"` to `disabledServers` in `.pi-lens/lsp.json` to select Expert. pi-lens launches Expert with its required `--stdio` flag and downloads the official bare GitHub release binary for macOS, Linux, or Windows (Windows arm64 uses the x64 build through emulation).
|
|
113
|
+
- **`pilens:files:touched` bus event** (#482) — pi-lens's first `pi.events` broadcast surface. Every autonomous file write pi-lens makes outside the agent's own tool calls — dispatch autofix (biome/ruff/eslint/stylelint/sqlfluff/rubocop/ktlint/rust-clippy/dart-fix/golangci-lint/detekt/ktfmt/markdownlint/oxlint) and formatter runs (immediate or deferred-at-`agent_end`), plus the conservative actionable-warnings LSP autofix — now emits a versioned `{ v: 1, source: "pi-lens", reason: "autofix" | "format", paths, cwd }` payload via `clients/bus-publish.ts`'s `publishFilesTouched`, one event per logical write batch. Fire-and-forget (never affects write-path success/latency) and null-safe when unwired (unit tests, the MCP server's no-pi-host path). Deliberately excludes agent-authored edits (partial-edit-apply preflight, ast-grep/lsp-navigation tool calls) — the host already knows about those. Kill switch `PI_LENS_BUS_PUBLISH=0`. See `docs/features.md` ("Bus Events") for the full contract; refs #478.
|
|
114
|
+
- **`agent_settled` quiet window** (#483) — pi 0.80.6 added an `agent_settled` extension event, emitted once the whole agent run (including any retry/continue loop) goes fully idle, on both normal completion and aborts. New `clients/quiet-window.ts` registers a handler (feature-detected — the SDK's `pi.on` accepts any event string with no validation, so this is a safe no-op on older pi hosts) that schedules deferred, expensive work in that guaranteed-quiet gap, additive to the existing `turn_end` settle (unchanged). Ships with two built-in tasks run through a small sequential task registry (`registerQuietWindowTask`, so #458/#236 can plug in later without touching the scheduler): a second, more generous settle attempt (`PI_LENS_QUIET_WINDOW_WAIT_MS`, default 15000ms) for cascade computes still carried over past the `turn_end` cap, and the #449 instance-registry heartbeat refresh moved off the turn hot path. Tolerates the event firing multiple times per session (re-entrant runs are skipped, never queued); every task failure is isolated and swallowed; the handler itself never awaits the task chain (kicked off fire-and-forget) so it can't hold up the SDK returning control to the next turn. Kill switch `PI_LENS_QUIET_WINDOW=0`. Logs a `quiet_window` latency phase with per-task `{name, durationMs, ok}` plus a `skipped: "in-progress" | "disabled"` marker.
|
|
115
|
+
- **Tier-aware cascade-lane LSP waits** (#458, re-scoped from the original learned-deadline design after dogfooding: `docs/lsp-capability-matrix.md`'s nightly-refreshed capability matrix already answers the classification question, and #483's quiet window gives the cascade lane somewhere to resolve the ambiguity out-of-lane) — the deferred cascade neighbor-touch fan-out (`clients/dispatch/integration.ts`'s `computeCascadeForFile`) used to actively wait up to its per-touch budget (~1000-2000ms) for `textDocument/publishDiagnostics` on every neighbor, even for a Tier-3 (push-only, silent-on-clean) server that can never distinguish "clean" from "still analyzing" that way — typescript-language-server is the lone core-set instance, and dogfooding measured ~221 such `lsp_diagnostics_timeout` events/day. New `clients/lsp/cascade-tier.ts` classifies each cascade touch's primary server from the LIVE capability snapshot (`workspaceDiagnosticsSupport.mode`) combined with a new `silentOnClean` marker on that server's `DiagnosticStrategy` (`server-strategies.ts`, set only for `typescript`) — never a hardcoded server-name check at the call site, and any ambiguous/missing snapshot fails safe to today's full wait. A Tier-3 touch still fires its didOpen/didChange notify (the server starts real work) but skips the in-lane wait and records the touch as outstanding; a new quiet-window task (`cascade_tier3_reconcile`) checks each outstanding touch against the client's diagnostics cache at the `agent_settled` idle point — diagnostics arrived since the touch ⇒ `resolved-found`/`resolved-clean`, nothing arrived ⇒ `unresolved` (never silently treated as clean — the #240 doctrine holds). Kill switch `PI_LENS_TIER_AWARE_CASCADE=0` restores the old full-wait behavior outright. Logs a `cascade_tier3_skip` cascade-log phase per skipped touch and a `cascade_tier3_reconcile` phase at the quiet window with resolved-found/resolved-clean/unresolved counts and touch ages. Review follow-up (refs #458) hardened the reconcile path: outcomes are decided by the client's PER-FILE publish timestamp (`getAllDiagnostics()`'s `ts`), not the client-wide `diagnosticsVersion` counter — a cascade touches multiple neighbors on the same tsserver client, so a counter advanced by neighbor A's publish could falsely "prove" a silent neighbor B `resolved-clean` (#240 violation); `touchedAt` is sampled BEFORE the notify so a publish racing the record can't be misread as pre-touch; and the quiet-window reconcile looks clients up via `getWarmClientForFile` (warm-only) instead of the get-or-create accessor, so it can never resurrect an idle-reaped server just to write a log line — a warm-miss reconciles as `unresolved`.
|
|
116
|
+
- **Inline agent nudge for out-of-view file mutations** (#485) — deferred-cascade autofixes and formatter writes that land AFTER a tool result (turn_end settling, #483's quiet window) were invisible to the model; an agent running `git status` at the top of a fresh run would find working-tree changes it never made and burn turns investigating. New `clients/agent-nudge.ts` subscribes read-only to the `pilens:files:touched` bus event (#482) via `pi.events.on` (feature-detected — no-op on older pi hosts with no `pi.events`/`.on`), accumulates touched paths across the session, filters them down to files the session actually read or edited (the read-guard's `getReadHistory`/`getEditHistory`, keyed via `normalizeMapKey` for every map access), and injects at most one terse context message per delivery via the same `context` extension event `clients/runtime-context.ts` already uses for turn-end findings: `pi-lens: 2 file(s) were autofixed after your last turn: a.ts, b.ts — working-tree changes to these are expected; re-read before editing.` (capped at 5 names + "and N more"). The accumulator is cleared only on actual injection — never on `turn_start`/`agent_end`/`agent_settled` — so files touched at one run's `turn_end` still nudge at the very next run's first turn in the same session (`context`/`transformContext` fires before every provider call, including the first one of a new `agent_start`). This subscriber never emits back to the bus, so the #482 loop guard's write side has nothing to trip. Kill switch `PI_LENS_AGENT_NUDGE=0`. Logs an `agent_nudge` latency phase with `{filesTotal, filesShown, filesFiltered, reasonMix}` on injection. See the "Three channels, three audiences" doctrine in `AGENTS.md` (bus events → extensions #482, display-only entries → the human #484, context nudges → the model, this feature).
|
|
117
|
+
- **Opt-in per-run transcript summary** (#484) — pi-lens's write-path effects (diagnostics found, autofixes applied, autoformats applied) previously only surfaced as transient inline text or the `/lens-health` command; nothing persisted in the transcript for a human reviewing the session later. New `turnSummary.enabled` config key (default **false** — opt-in; also `lens-turn-summary` CLI flag) turns on a `clients/turn-summary.ts` collector that accumulates `{file → events}` across the RUN from the SAME seams that already produce these signals — no new collection plumbing: the immediate write/edit pipeline result (`clients/runtime-tool-result.ts`, newly-surfaced `PipelineResult.diagnostics`/`formattersUsed`/`fixedCount`/`autofixTools`), the `agent_end` deferred-format completion and the experimental actionable-warnings LSP autofix pass (both in `clients/runtime-agent-end.ts`). The single `pi.sendMessage({customType: "pilens:turn-summary", display: true, details})` entry is emitted at the **`agent_settled` quiet window** (#483 scheduler, `turn_summary_emit` task), NOT at turn_end — a load-bearing choice verified against the installed pi 0.80.6 SDK: `sendCustomMessage` STEERS the live model conversation when the session is streaming, and a mid-run turn_end plausibly fires while streaming; at settle the session is idle, so sendMessage takes the safe append branch. The collector therefore survives turn boundaries (NOT cleared in `beginTurn`) and is consumed exactly once per settle; grain is one entry per RUN (never per-file or per-turn). Honest SDK caveat: this entry is NOT display-only — a `CustomMessageEntry` participates in LLM context (`display` only controls TUI rendering; `buildSessionContext` converts every such entry into a user message on later context builds), so the entry `content` is kept to the single ~80-char collapsed line (an accepted, owner-approved residue, largely redundant with the #493 agent nudge); the structured `details` payload never reaches the model. A registered `registerMessageRenderer` (`clients/turn-summary-render.ts`) renders it natively collapsible/expandable via pi's own entry-expansion toggle: collapsed is one tool-grouped line in the pi-lens brand accent (`pi-lens: 3 diagnostics (eslint 2, tsserver 1) · 2 autofixed (ruff 1) · 1 reformatted (prettier 1)`), expanded is FILE-MAJOR — each touched file lists its formats/autofixes/diagnostics (tool + rule id + line) in its own block, answering "what happened to x.ts?" rather than "what happened this run?". Both `pi.sendMessage` and `pi.registerMessageRenderer` are feature-detected (no-op, never throws, on older pi hosts). The redundant info-level "pi-lens deferred format applied to..." toast in `runtime-agent-end.ts` is suppressed when this entry is opted in (the failure/warning toast is untouched either way). Logs a `turn_summary` latency phase with `{files, diagnostics, autofixes, formats}` on emit. See the "Three channels, three audiences" doctrine in `AGENTS.md` (bus events → extensions #482, this feature → the human, context nudges → the model #485).
|
|
118
|
+
- **Cross-process touched-files nudge** (#492) — #485's inline nudge only covered ONE process: a subagent spawned as a real child `pi` process (the nicobailon/pi-subagents model) never saw the parent's autoformats, and the parent never saw a child's — both real pain cases (`git status` finding unexplained `M` files; a parent's next edit hitting stale `oldText` after a child's pi-lens reformatted on top of its edits). New `clients/recent-touches.ts` adds a project-scoped `recent-touches.json` (via `getProjectDataDir(cwd)`) that every pi-lens instance both writes to and reads from: a ~50-entry ring buffer of `{path, reason, ts, pid, sessionId?}`, atomic tmp+rename writes (same pattern as the #474 instance registry). The producer is wired into the EXISTING `publishFilesTouched` seam (`clients/bus-publish.ts`) — parent and child run identical code, and the record is populated even when no `pi.events` bus is wired (bare/MCP hosts), since the on-disk record is the only one of the two deliveries that survives a process boundary. Two consumers feed the SAME #485 accumulator (`clients/agent-nudge.ts`'s new `recordCrossProcessTouches`) so exactly one batched context message is ever injected regardless of how many files came from which channel: a **child at `session_start`** reads entries from other pids within a 15-minute freshness window whose file still exists (no read-guard history exists this early, so relevance is recency + existence only); a **parent at `turn_start`** does a single mtime-gated `fs.stat` (zero reads/parses when nothing changed since the last turn) and, on a genuine change, applies the SAME shared baseline filter (foreign pid + 15-minute freshness + file still exists — one private helper both readers call, so they can never drift) plus a consumed-ts cursor; beyond that baseline the parent has deliberately no read-guard drop path — a parent about to commit needs attribution even for files it hasn't read yet this session. `AccumulatedFile` gained an `origin: "local" | "cross-process"` field; a file seen via both channels always reads as `"local"` (sticky — once the session's own bus has reported a touch, the local wording is the more precise, more actionable framing, and "another instance" framing no longer applies). Attribution is three-way and never assigns a local file to another instance: a pure-local batch keeps the unchanged #485 wording ("after your last turn"), a pure cross-process batch reads "by another pi-lens instance (e.g. a subagent's)", and a mixed batch reads "after your last turn (N of them by another pi-lens instance)" — always one message, never split. The `agent_nudge` latency phase gained `originLocal`/`originCrossProcess` counts. Reuses the existing `PI_LENS_AGENT_NUDGE=0` kill switch for the producer and both consumers (no new env var); `PI_LENS_BUS_PUBLISH=0` also silences the record append, since the producer lives inside `publishFilesTouched` (both deliveries of a touch die together behind that gate). Deliberately NOT gated on subagent light mode (#449), since this is a cheap file read, not a heavyweight scan. No IPC, no daemon, no `fs.watch` — a passive file, per the #449 no-daemon doctrine.
|
|
119
|
+
|
|
120
|
+
- **`pilens:diagnostics` bus event + `pilens:files:touched` fix provenance** (#502) — extends the #482 producer family from "which files changed" to "what pi-lens knows about them", so terminal-native diff/review extensions can render pi-lens's findings as inline annotations in their own views instead of pi-lens owning a review UI. New `clients/diagnostics-publish.ts` publishes a versioned `pilens:diagnostics` event — `{v: 1, source: "pi-lens", cwd, seq, ts, files: [{path, diagnostics: [{ruleId?, severity, line?, col?, message, tool, fixable?}], truncated?}]}` — once per write batch, immediately after the batch's final per-file diagnostic set is committed (post-format, post-autofix, post-dispatch), so it always reflects the LATEST state rather than an intermediate runner result. Follows LSP `publishDiagnostics` staleness semantics: full-replace per file (never a delta), explicit `diagnostics: []` on a dirty→clean transition (fired exactly once, tracked via a module-level reported-paths set), monotonic `seq`+`ts` so out-of-order receipt resolves deterministically, and `pilens:files:touched` (#482) documented as an invalidation hint (a touched path's held diagnostics are provisional until the next diagnostics event mentions it). Capped at 12 diagnostics per file per event (errors prioritized), file contents never inline; reuses the `PI_LENS_BUS_PUBLISH=0` kill switch. The `PilensDiagnosticsPayload` schema is reserved for #478's future `pilens:rpc:diagnostics` pull response (push and pull share one shape). Separately, `FilesTouchedPayload` (#482) gains an additive optional `fixes?: {path, tool, ruleId?, kind: "autofix" | "format"}[]` field for fix provenance — lets a diff/review consumer distinguish a pi-lens-mechanical hunk from an agent edit; old consumers ignore the field. Before/after file content is intentionally omitted from v1. Full contract in `docs/features.md` ("Bus Events").
|
|
121
|
+
|
|
122
|
+
### Changed
|
|
123
|
+
|
|
124
|
+
- **Native TS7 LSP variant reclassified `silentOnClean` (closes #541, follow-through on #458/#526/#529)** — PR #526 excluded the native TypeScript 7 launch variant (`tsc --lsp --stdio`) from the #458 tier-3 cascade classification as a fail-safe, since `silentOnClean` had only been measured against classic `typescript-language-server`. The #529/#540 clean-signal probe has since measured native-ts7 directly (`typescript7-clean` fixture, repeated local runs): silent on clean transitions, same as classic. Per the maintainer's decision (prefer fast cascade waits), `clients/lsp/cascade-tier.ts`'s classifier no longer branches on the snapshot's `launchVariant` — both variants now skip the in-lane wait. `scripts/lib/clean-signal.mjs`'s nightly drift check also lifts its typescript7 exclusion, routing native rows to the shared `typescript` strategy marker instead of skipping them — the rollback safety net: if a future TS7 build starts publishing on the clean fixture, the drift check now emits a `marked-not-silent` warning instead of silently missing the regression.
|
|
125
|
+
- **Per-walk `isBuildArtifact` sibling-probe memo** (#191, item 1 of 4) — `findSourceSibling`/`isBuildArtifact` (`clients/source-filter.ts`) probe for a higher-precedence source sibling (e.g. does `foo.ts` exist next to `foo.js`?) via `fs.existsSync`; call sites with repeated/overlapping lookups (e.g. `filterSourceFiles` handed an overlapping candidate list) re-issued identical probes. `#191` deliberately deferred this because a *persistent* memo has an awkward invalidation problem — siblings can change between scans, and a stale key risks silently misclassifying a file (lost detection). This ships the narrower, invalidation-free version instead: an optional per-walk `ArtifactProbeCache` (`createArtifactProbeCache()`), created at the start of one `collectSourceFiles`/`collectSourceFilesAsync`/`filterSourceFiles` call and discarded when it returns — no persistent or module-global cache, nothing to invalidate. Callers that don't pass a cache get exactly today's behavior. Keyed via a new cheap, syntactic-only `normalizeEphemeralMapKey` (slash-fold + win32 lowercase, no `realpathSync`) rather than the existing `normalizeMapKey` — using the latter here was measured ~11x *slower* than the `existsSync` probe it would replace, because it resolves nonexistent candidate paths via its own ancestor-walking `existsSync` calls; `normalizeEphemeralMapKey` is intentionally scoped to ephemeral, single-process, single-walk caches only. Measured ~70% faster on a fixture modeling realistic overlapping-lookup call shapes (500 pairs × 4x duplication); near break-even on this repo's own tree, which is pure TypeScript with no compiled `.js` siblings on disk to re-probe — an honest finding, not a regression.
|
|
126
|
+
|
|
13
127
|
## [3.8.68] - 2026-07-10
|
|
14
128
|
|
|
15
129
|
### Added
|