pi-lens 3.8.70 → 3.8.72
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 +129 -0
- package/README.md +24 -0
- package/config/markdownlint/core.json +6 -0
- package/dist/clients/atomic-write.js +83 -0
- package/dist/clients/bus-events-logger.js +10 -8
- package/dist/clients/cache/rule-cache.js +9 -5
- package/dist/clients/cache-manager.js +19 -13
- package/dist/clients/config-enabled-shape.js +39 -0
- package/dist/clients/diagnostic-dispositions.js +360 -0
- package/dist/clients/dispatch/auxiliary-lsp.js +85 -3
- package/dist/clients/dispatch/dispatcher.js +6 -1
- package/dist/clients/dispatch/integration.js +21 -5
- package/dist/clients/dispatch/runners/lsp.js +59 -57
- package/dist/clients/dispatch/runners/markdownlint.js +7 -4
- package/dist/clients/dispatch/runners/oxlint.js +1 -1
- package/dist/clients/dispatch/runners/pyright.js +1 -0
- package/dist/clients/dispatch/runners/tree-sitter.js +1 -1
- package/dist/clients/dispatch/suppress-writer.js +68 -0
- package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
- package/dist/clients/disposition-logger.js +39 -0
- package/dist/clients/disposition-publish.js +109 -0
- package/dist/clients/env-utils.js +38 -0
- package/dist/clients/file-utils.js +115 -36
- package/dist/clients/format-events-publish.js +299 -0
- package/dist/clients/git-tracked-ignore.js +208 -0
- package/dist/clients/instance-registry.js +94 -24
- package/dist/clients/jscpd-client.js +52 -39
- package/dist/clients/json-cache-read.js +65 -0
- package/dist/clients/language-profile.js +16 -19
- package/dist/clients/lens-config.js +106 -27
- package/dist/clients/lens-engine.js +129 -3
- package/dist/clients/lens-map.js +1079 -0
- package/dist/clients/lsp/aggregation.js +69 -2
- package/dist/clients/lsp/cascade-tier.js +1 -38
- package/dist/clients/lsp/client.js +75 -8
- package/dist/clients/lsp/index.js +1093 -95
- package/dist/clients/lsp/server-strategies.js +3 -224
- package/dist/clients/lsp/server.js +18 -2
- package/dist/clients/lsp/tsserver-sync.js +145 -0
- package/dist/clients/lsp/wait-policy/capability-snapshot.js +1 -0
- package/dist/clients/lsp/wait-policy/classification.js +63 -0
- package/dist/clients/lsp/wait-policy/index.js +10 -0
- package/dist/clients/lsp/wait-policy/strategies.js +244 -0
- package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
- package/dist/clients/lsp-budget.js +54 -16
- package/dist/clients/mcp/analyze.js +1 -1
- package/dist/clients/mcp/host-shim.js +12 -8
- package/dist/clients/mcp/ipc.js +118 -0
- package/dist/clients/mcp/session.js +10 -2
- package/dist/clients/opengrep-config.js +2 -10
- package/dist/clients/path-utils.js +26 -0
- package/dist/clients/pipeline.js +15 -6
- package/dist/clients/project-diagnostics/cache.js +5 -12
- package/dist/clients/project-diagnostics/fresh-fetch.js +20 -1
- package/dist/clients/project-diagnostics/scanner.js +37 -6
- package/dist/clients/project-lens-config.js +167 -3
- package/dist/clients/project-report.js +627 -0
- package/dist/clients/project-scale.js +239 -0
- package/dist/clients/project-scan-policy.js +11 -1
- package/dist/clients/project-snapshot.js +3 -6
- package/dist/clients/read-guard.js +1 -1
- package/dist/clients/recent-touches.js +6 -11
- package/dist/clients/review-graph/builder.js +247 -64
- package/dist/clients/review-graph/import-resolvers.js +191 -21
- package/dist/clients/review-graph/tsconfig-paths.js +242 -0
- package/dist/clients/review-graph/workspace-modules.js +21 -9
- package/dist/clients/rules-scanner.js +15 -2
- package/dist/clients/runtime-agent-end.js +127 -14
- package/dist/clients/runtime-config.js +4 -0
- package/dist/clients/runtime-coordinator.js +59 -2
- package/dist/clients/runtime-session.js +93 -13
- package/dist/clients/runtime-tool-call.js +789 -0
- package/dist/clients/runtime-tool-result.js +18 -4
- package/dist/clients/runtime-turn.js +12 -2
- package/dist/clients/safe-spawn.js +264 -40
- package/dist/clients/session-lifecycle.js +36 -0
- package/dist/clients/session-state-store.js +14 -12
- package/dist/clients/sgconfig.js +2 -9
- package/dist/clients/source-filter.js +136 -56
- package/dist/clients/source-walker.js +89 -0
- package/dist/clients/startup-scan.js +229 -84
- package/dist/clients/subagent-mode.js +14 -3
- package/dist/clients/todo-scanner.js +5 -1
- package/dist/clients/tool-policy.js +26 -4
- package/dist/clients/typos-config.js +2 -11
- package/dist/clients/warm-attach.js +204 -0
- package/dist/clients/widget-state.js +22 -0
- package/dist/clients/word-index.js +36 -5
- package/dist/clients/workspace-topology.js +266 -0
- package/dist/clients/zizmor-config.js +2 -10
- package/dist/index.js +35072 -29093
- package/dist/mcp/server.js +125 -10
- package/dist/tools/ast-grep-search.js +21 -0
- package/dist/tools/lens-diagnostic-mark.js +349 -0
- package/dist/tools/lens-diagnostics.js +159 -27
- package/dist/tools/lsp-diagnostics.js +116 -120
- package/dist/tools/lsp-navigation.js +11 -2
- package/dist/tools/project-report.js +94 -0
- package/dist/tools/symbol-search.js +32 -8
- package/docs/agent-tools.md +11 -4
- package/docs/dispositions.md +85 -0
- package/docs/environment-variables.md +26 -0
- package/docs/fable.md +10 -9
- package/docs/features.md +7 -1
- package/docs/globalconfig.md +70 -0
- package/docs/servercapabilities.md +1 -1
- package/docs/tools.md +3 -0
- package/docs/usage.md +13 -2
- package/package.json +6 -1
- package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
- package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
- package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
- package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +12 -0
- package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
- package/scripts/download-grammars.js +1 -0
- package/docs/agent-tooling-improvement-plan.md +0 -660
- package/docs/inspiration_gograph.md +0 -601
- package/docs/inspiration_neovim.md +0 -1795
- package/docs/inspiration_neovim2.md +0 -402
- package/docs/inspiration_tree_sitter_rules.md +0 -369
- package/docs/inspiration_tree_sitter_rules_investigation.md +0 -328
- package/docs/inspiration_tree_sitter_rules_round2.md +0 -470
- package/docs/lsp-bridge-analysis.md +0 -444
- package/docs/newinspiration.md +0 -338
- 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/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,135 @@ All notable changes to pi-lens will be documented in this file.
|
|
|
10
10
|
|
|
11
11
|
### Fixed
|
|
12
12
|
|
|
13
|
+
## [3.8.72] - 2026-07-26
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Subagent identity in the instance registry** (#822) — subagent sessions now persist their marker, agent type, parent PID, and run ID in `instances.json` for concurrency-profile analysis; primary and legacy entries remain unchanged.
|
|
18
|
+
|
|
19
|
+
- **Same-workspace warm LSP attach (opt-in soak)** (#822) — with `PI_LENS_WARM_ATTACH=1`, a new session discovers a PID-confirmed, heartbeat-fresh incumbent for the same workspace root and routes per-edit and project-sweep LSP diagnostic touches through its PID-scoped, versioned IPC endpoint instead of spawning a duplicate fleet. Attached dispatch diagnostics now also request bounded code actions from the incumbent, preserving quickfix enrichment without creating a local client; enrichment failures skip quickfixes without promoting because the authoritative diagnostics already succeeded. Sweep warm-up, pre-open, and workspace-pull paths stay dormant while attached; a mid-sweep attach failure permanently promotes the session and resumes the remaining files locally. Content hashes and deadlines guard freshness; any IPC, schema, freshness, timeout, or incumbent-liveness failure permanently promotes the session to today's local LSP path. Decisions and per-file routing sources are logged as `lsp_warm_attach`.
|
|
20
|
+
|
|
21
|
+
- **Resource-aware cross-process LSP budget policy** (closes #821) — budget pressure now shortens the current session's idle reset, lets pull-capable servers rely on pull diagnostics without a push-warm fallback, and can supplement the live-server ceiling with an opt-in aggregate RSS ceiling (`PI_LENS_LSP_BUDGET_RSS_MB`). Missing or stale resource samples fail open to count-only behavior; the existing kill switch disables every policy.
|
|
22
|
+
|
|
23
|
+
- **Review-graph JavaScript/TypeScript import resolution now follows tsconfig project references** (#819) — a bare package import declared through `"references": [{"path": "../lib"}]` resolves to the referenced package's source entry after `paths` aliases and before workspace-package fallback, including transitive references with cycle protection and directory or direct-config targets.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **Markdownlint no longer passes the invalid `--disable MD013` CLI option** (closes #833) — unconfigured Markdown projects now use the package-owned default config, which disables MD013 and scopes MD024 to sibling headings so intentional repeated changelog category headings are allowed; project markdownlint-cli2 configs are preserved unchanged, including the CLI's JSONC/YAML/CJS/MJS filename variants.
|
|
28
|
+
|
|
29
|
+
- **`safeSpawnAsync` (and the deprecated sync `safeSpawn`) no longer build a cmd.exe command line from caller-tainted input on Windows** (closes #817, CodeQL alerts #17/#18/#19, CWE-78) — after #815/#816 replaced `shell:true` with an explicit `cmd.exe /d /s /c <hand-built string>` invocation, CodeQL re-flagged the same spawn: the escaping was genuinely unsound (`%VAR%` expands even inside double quotes and cannot be escaped on a `/c` line; cmd's quote-state toggling can defeat `""` doubling), not just a taint false positive. `clients/safe-spawn.ts` now resolves `command` itself via a cached PATH+PATHEXT walk (`resolveWindowsCommand`, a `where`-equivalent, keyed by `command`+`PATH`+`cwd`; reset hook `resetSafeSpawnWindowsCommandCache()` wired into `runtime-session.ts`'s `handleSessionStart` alongside the repo's other session-lived caches). `.exe`/`.com` resolutions — the overwhelming majority of spawns (sg, biome, ruff, git, node, `where.exe`...) — now spawn directly with `shell:false` and a real args array: no command line is ever built, so there's nothing for cmd.exe to reinterpret. `.cmd`/`.bat` shims (npm-installed tool wrappers, the common case) keep the cmd.exe wrapper since they can't run without it, but the interpreter is pinned to `%SystemRoot%\System32\cmd.exe` (never `ComSpec`/`COMSPEC`, addressing alert #18 — the same pin `killTree` already used for `taskkill`) and args are VALIDATED instead of escaped: a value containing `"`, `%`, `!`, or CR/LF is rejected with a clear "Refusing to spawn... via cmd.exe" `SpawnResult.error` rather than spawned with an unsound escape — validated against the *resolved* path (what's actually interpolated into the `/c` line), not just the caller's original command string, so a resolved path itself containing `%`/`!` can't slip through unvalidated either. An unresolvable command now returns a synthesized `ENOENT`-shaped error (message/`code`/`syscall`/`path`) instead of letting cmd.exe report "not recognized" from inside a shell, preserving existing `err.message.includes("ENOENT")`/`err.code === "ENOENT"` tool-not-installed detection at call sites like `sg-runner.ts`. The `chcp 65001` UTF-8 console-code-page mutation is preserved for the tools that needed it (sg/biome/ruff CP850/CP936/CP932 mis-decoding): the `.cmd`/`.bat` wrapper keeps its `chcp` prefix as-is, and a new one-shot `ensureUtf8ConsoleCodePageOnce()` runs the same static, hardcoded `chcp 65001` via a pinned-cmd.exe spawn exactly once per process before the first direct `.exe` spawn (the console code-page mutation persists for the console's lifetime, so once suffices — a strict improvement over today's per-spawn re-run, not a regression). A code-review pass on the first version of this fix found the deprecated synchronous `safeSpawn` (today's only caller: `test-runner-client.ts`'s sync pytest-on-PATH probe) still had the identical `buildWindowsCommand`/`shell:true` sink — its Windows branch (and the now-dead `escapeWindowsArg`/`buildWindowsCommand` helpers it alone used) is reimplemented against the exact same seams (`resolveWindowsCommand`/`findCmdUnsafeValue`/`buildWindowsShellCommand`/`synthesizeEnoentError`) with `spawnSync(..., { shell: false })` throughout; `test-runner-client.ts`'s call site is unchanged (its `detectRunner`/`findTestFile` call chain is synchronous with many sync callers/tests — migrating to async wasn't a trivial, in-scope change here). New coverage in `tests/clients/safe-spawn-windows-command.test.ts` (Windows-conditional, real spawns), mirrored for both the async and sync entry points: direct `.exe` PATH resolution + spawn, a `.cmd` fixture round-tripping an arg with spaces through the pinned wrapper, `%`/`"`-bearing args rejected against a `.cmd` shim with nothing spawned, the same dangerous args passing through literally to a direct `.exe` spawn, an unresolvable command producing the ENOENT-shaped error, and (async-only) resolver-cache reuse/reset. `buildWindowsShellCommand` remains exported (existing #214 regression tests unchanged). Verified end-to-end against the built compiled output for BOTH entry points: a `.cmd` shim (`npx.cmd --version`) and a bare `.exe` (`git --version`) spawn correctly, a `%`-bearing arg to a `.cmd` shim is rejected with nothing spawned, and an unresolvable command yields the ENOENT-shaped error. Related pre-existing cmd.exe-command-line-building patterns found outside `safe-spawn.ts` (`clients/lsp/client.ts`'s PS1→PowerShell fallback shell path, `clients/sg-runner.ts`'s non-bash Windows fallback) are out of scope for this issue (#817 is scoped to `safe-spawn.ts`) and are noted for a follow-up rather than folded in here.
|
|
30
|
+
|
|
31
|
+
- **Multi-server `clientScope: "all"` diagnostic touches now get the same silent-clean confirm #799 gave the single-server hot path** (closes #814) — `lens_diagnostics` mode=full's per-file sweep and `lsp_diagnostics`' `serverScope: "all"` both use `touchFile`'s `clientScope: "all"` path, which #799's clean-confirm gate never covered (it's scoped to `clientScope === "primary" && spawned.length === 1`), so a clean markdown file with marksman (push-only, `silentOnClean`) alongside other servers that all answered still burned marksman's own budget and reported the WHOLE touch `inconclusive`/`diagnosticsTimedOut`, blocking clean-caching and `demonstratedReady`. New `classifyServerWaitTier(serverId, snapshot)` in `clients/lsp/cascade-tier.ts` extracts the per-server classification rule `classifyCascadeWaitTier` (#458) already used for the file's primary server, so both share one classification, not two. `touchFile` (`clients/lsp/index.ts`) now runs a capability-aware aggregate gate after a `clientScope: "all"` touch's diagnostics wait times out: a spawned server counts as "still outstanding" when nothing landed in its per-file diagnostics cache for this touch (`getAllDiagnostics()` — cleared by this touch's own didOpen/didChange, so a present entry can only be a fresh answer, reusing the same #240 doctrine signal `cascade-tier.ts`'s Tier-3 reconcile already trusts); the touch is only resolved as CONFIRMED clean (0 diagnostics from the silent server(s), `diagnosticsTimedOut` cleared, not `inconclusive`) when EVERY still-outstanding server classifies `tier3-silent` (push-only + `silentOnClean`) AND its notify write actually succeeded. One ordinary push-only straggler (not marked `silentOnClean`) or a failed notify write keeps the touch cautious, matching today's behavior exactly. Fails safe (today's timeout/inconclusive behavior) on any classification error. Logs a distinguishable `lsp_silent_clean_confirm` phase with `aggregate: true` and the resolved `serverIds`. The pre-existing single-server `clientScope === "primary"` gate is left unchanged (a documented special case of this more general rule — a future cleanup could fold the two together once both have soaked). New coverage in `tests/clients/lsp/silent-clean-confirm.test.ts`'s "touchFile capability-aware AGGREGATE wait (#814)" describe block: one publisher + one silent server resolves early as confirmed clean with the publisher's findings kept; a failed notify write on the silent server still times out; an ordinary (non-`silentOnClean`) push-only straggler still runs to cap and stays inconclusive; every spawned server silent resolves at the max of their budgets as confirmed clean; and a classification-throwing failure falls back to today's timeout behavior.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **`getWorkspaceTopology` — shared, memoized workspace-topology marker index** (closes #806) — new `clients/workspace-topology.ts` consolidates the per-subsystem per-directory marker walks (`.pi-lens.json`/`pi-lens.json`, `tsconfig.json`, and the workspace-manifest markers `workspace-modules.ts` cares about — `package.json`, `pnpm-workspace.yaml`, `Cargo.toml`, `go.work`) into one shared seam: `getDirectoryMarkers(dir)` collects ALL markers for a directory in a single `readdir` pass, cached and invalidated per-directory by the directory's own mtime; `findNearestDirWithMarker` layers the shared upward-walk discipline on top (`walkUpDirs` + `isAtOrAboveHomeDir` ceiling, an explicit 64-deep cap that logs a `workspace-topology-walk-cap` latency phase instead of truncating silently). Three consumers migrated with zero resolution-semantics change: `project-lens-config.ts`'s `findPiLensConfigInDir` (the #804 nested mutation-control walk's per-dir discovery), `review-graph/tsconfig-paths.ts`'s governing-tsconfig discovery, and `review-graph/workspace-modules.ts`'s `detectWorkspaceType` manifest-presence probes (manifest CONTENT parsing is unchanged/unmoved). New `resetWorkspaceTopology()` is wired into `runtime-session.ts`'s `handleSessionStart` alongside `tsconfig-paths.ts`'s `clearTsconfigPathsCache()` — an added behavior improvement: a `tsconfig.json` edited mid-session is now picked up on the next session/reset instead of never (the matcher cache was previously session-lived with no reset hook at all, noted on #805). Root/language-root resolution is explicitly NOT migrated (#806 lists it as a later follow-up). New `tests/clients/workspace-topology.test.ts` (marker index correctness incl. both `.pi-lens.json` basenames, single-`readdir`-pass collection, mtime invalidation, the reset hook, the home-dir ceiling, and cap-trip logging).
|
|
36
|
+
|
|
37
|
+
- **Root/language-root resolution migrated onto the workspace-topology index — second wave** (closes #807) — `language-profile.ts`'s `resolveLanguageRootForFile` now discovers its per-language-kind root marker (e.g. `tsconfig.json`/`package.json` for `jsts`, `Cargo.toml` for `rust`) via a new `findNearestDirWithAnyBasename(startDir, basenames, homeDir?)` in `clients/workspace-topology.ts` — the generalized, home-guarded/depth-capped/walk-cached counterpart of `findNearestDirWithMarker` for a per-call marker LIST rather than one of `DirectoryMarkers`' typed fields; each `DirectoryMarkers` entry now also carries a raw `entryNames` set (from the same single `readdir`) for this kind of type-agnostic, non-typed-field presence check. `resolveLanguageRootForFile`'s OWN policy is unchanged: it still clamps any discovered root back to `workspaceRoot` via its existing stricter workspace-relative check — the topology service supplies discovery only. `startup-scan.ts`'s `findNearestProjectRoot` now reads per-directory marker presence through `getDirectoryMarkers(dir).entryNames` (one cached `readdir` per directory instead of `PROJECT_ROOT_MARKERS.length` separate `existsSync` stats), but deliberately KEEPS its own hand-written, non-home-guarded walk-UP loop — its callers (`computeStartupScanContext`/`resolveStartupScanContextAsync`) need the actual marker location even at/above `$HOME` to distinguish the `"home-dir"` verdict from `"no-project-root"` (pinned by `tests/clients/startup-scan-home-ceiling.test.ts`); a home-guarded walker would silently misreport that case. A repo-root micro-benchmark (N=1000 resolutions over a synthetic 12-package, 8-levels-deep monorepo fixture) measured the migration as a net win on the hot per-edit path: ~68–75% faster spread across many files, ~79–82% faster for a repeated small working set (the realistic edit-loop pattern) versus the pre-migration hand-rolled `existsSync` loop. Sweep for remaining hand-rolled upward marker probes found several NOT migrated in this pass, each for a documented reason (see PR #807 description): `tool-policy.ts`'s `has*Config` family (already a documented exception, different shape — content-sniffing booleans); `dispatch/runners/{oxlint,vale,shellcheck,shfmt}.ts`, `dispatch/runners/utils/runner-helpers.ts`, `review-graph/git-identity.ts`, `file-utils.ts`, `metrics-history.ts`, `lsp/{config,server}.ts`, and `review-graph/import-resolvers.ts` (all currently un-home-guarded tool-config/VCS-root probes; routing them through the topology service would newly add a home-guard, a behavior change out of scope for an exact-semantics migration); `pipeline.ts`'s/`dispatch/runners/rust-clippy.ts`'s `findNearestContaining` calls for `Cargo.toml`/`pubspec.yaml` (already on the shared `path-utils.ts` primitive, same reasoning); `knip-client.ts`/`dead-code-client.ts` (already on the shared, guarded `findNearestMarkerRoot` primitive); `jscpd-client.ts` (explicitly does not resolve a project root, per its own comment); `package-root.ts` (resolves pi-lens's OWN installed package root from a module URL, a different domain than user-workspace discovery). `clients/project-scale.ts` was left untouched per this PR's constraints (concurrent work) and, on inspection, contains no such probe. Existing tests (`tests/clients/language-profile.test.ts`, `tests/clients/startup-scan-*.test.ts`) pass unmodified; new coverage lives in `tests/clients/workspace-topology.test.ts` for the added `entryNames` field/`findNearestDirWithAnyBasename` surface.
|
|
38
|
+
|
|
39
|
+
- **Empirical monorepo test harness — Track B of the #775 root-resolution audit** (refs #775) — a reusable `makeMonorepo(opts)` fixture builder (`tests/monorepo/fixture.ts`) that builds a temp npm-workspaces repo (root `package.json#workspaces`, N packages with names/deps/files, optional nested `.gitignore`/`.pi-lens.json`, optional file-count padding) plus eight committed regression files (`tests/monorepo/*.test.ts`, 29 tests) covering the audit's test matrix: cross-package graph edges post-#777 (entry + subpath specifiers, non-workspace bare specifiers stay external, `module_report`'s `usedBy`/blast-radius and `computeImpactCascade`'s module-level downstream expansion actually see them); size-cliff behavior (startup-scan's verdict flips exactly at `maxSourceFiles`, sync+async; review-graph bails above its cap and — answering the audit's open question — `getCachedReviewGraph` returns `undefined` after a fresh size-skipped build with no prior warm graph, but keeps serving a now-STALE prior graph once one already existed, and `module_report` degrades identically to a cold cache either way); the post-#779 `maxProjectFiles` scale knob changing effective startup-scan/review-graph budgets end-to-end via `.pi-lens.json`; session-in-package-subdir root-resolution scoping; nested `.gitignore` + `.pi-lens.json` layering (root+package-local gitignore both honored; a package-local `.pi-lens.json`'s `maxProjectFiles` wins for a package-scoped cwd, but its `ignore` field is never consulted — only the root's is, since the ignore matcher always re-anchors to the git root first); diagnostics-scanner `scanTruncated` reaching `lens-engine.ts`'s `projectScan` seam unmodified; `computeImpactCascade`'s module-graph fallback requiring an explicit `cwd` (present on the primary per-edit cascade path, absent — though available in scope — on `runBlastRadiusInBackground`'s call site); and a pnpm-store-shaped symlink layout (junction-based, Windows-safe) extending #777's symlink-safety pinning to `source-filter.ts`'s walker, including `followSymlinks:true` being observably inert for a Windows junction. Two `KNOWN GAP (#775)`-marked tests pin current (unfixed) behavior rather than asserting an ideal outcome. Tests-only — no production code changed.
|
|
40
|
+
|
|
41
|
+
- **New `project_report` registered tool (+ MCP mirror `pilens_project_report`)** (closes #773) — the top of the discovery funnel (`project_report` → `module_report` → `read_symbol`), answering "orient me in this project" purely from data the review graph already computes: a trust header (graph freshness, file coverage vs. the persisted project snapshot's file count, and the exact/import/receiver-type/name-only edge-resolution-quality mix, reusing `module_report`'s existing `resolution` tags), hubs (top fan-in files with `blastRadius` and a one-line role from their most-imported exported symbols), entry points (near-zero fan-in / high fan-out files), a directory-level subsystem map (first-path-segment clustering that collapses to a deeper segment only when one segment dominates the file set, directory-to-directory import edge counts, Tarjan-SCC cycle detection worst-first, and minority-direction layering violations for any directory pair with edges both ways), risk hotspots (fan-in × max per-symbol cyclomatic complexity), and suspected dead weight (zero-importer non-entry-point files, always shipped with a low-confidence disclaimer for dynamic-import/runtime-registration/test-only-reachability false positives). Every ranked list is capped by a single `limit` knob and re-rankable via an optional `focus` hint (module_report's `recommendedReads` ranking pattern). Read-only over the cached graph, mirroring `module_report`'s #256 no-build contract and `symbol_search`'s #348 cold-cache contract: a cold graph kicks off a single deduped background build (`buildOrUpdateGraph`, home-ceiling-guarded) and returns `available: false` with an actionable retry hint, never blocking the call. `view: "compact"` renders a line-oriented text summary instead of JSON, matching `module_report`'s convention. New engine module `clients/project-report.ts` (never imports from `tools/`, per the repo's layering rule) plus a thin `tools/project-report.ts` wrapper; registered as an always-active tool in `index.ts` alongside `module_report`/`symbol_search` (`tests/index-wiring.test.ts` updated), and mirrored in `mcp/server.ts` as `pilens_project_report` (schema/registration covered by `tests/mcp/server.smoke.test.ts`). Middle-man detection is deliberately NOT surfaced (a documented v1 deviation): the review graph doesn't persist that signal on symbol nodes today — `module_report` computes it per-file, on demand, from raw file content — and re-deriving it project-wide would mean re-scanning every class-bearing file on this never-blocks read path. New `tests/clients/project-report.test.ts` (cold path non-blocking + background build, all six warm-path section shapes, the `limit` knob scaling every cap, a synthetic-fixture directory cycle via Tarjan SCC, a synthetic-fixture minority-direction layering violation, the dead-weight disclaimer always present, and `focus` re-ranking hub order) and `tests/tools/project-report.test.ts` (cold/warm/compact-view wrapper behavior).
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- **Incumbent LSP wait policy now has one process-neutral module boundary** (#822) — per-server strategies, tier classification, and capability-snapshot types moved under `clients/lsp/wait-policy/` with compatibility re-exports at the old paths; this is an internal refactor with no behavior change.
|
|
46
|
+
|
|
47
|
+
- **Review-graph file budget now tapers instead of scaling flatly or cliff-dropping above 1,000 files** (refs #775 R1) — `project-scale.ts`'s `getReviewGraphMaxFilesDerived` keeps today's exact 0.5× `maxProjectFiles` ratio (1,000 files at the 2,000 default base) unchanged for every project at or below a 4,000-file `maxProjectFiles` base, then tapers growth asymptotically toward a 5,000-file hard ceiling for larger bases instead of continuing linearly forever. The ceiling is bound FIRST by the review graph's own disk-persist circuit-breaker (`GRAPH_PERSIST_MAX_ELEMENTS_DEFAULT` = 200,000 graph elements in `review-graph/builder.ts` — above it the graph is never persisted, logged + skipped, so a repo over that line pays a full cold build every session instead of once): at ~30 elements/file for a symbol-dense repo, 5,000 files ≈ 150,000 elements, a ~25% margin below the persist cap (a 6,000-file ceiling considered during review left only ~10% margin — too little for a denser-than-average repo). SECOND and independently, it's consistent with the graph's measured per-file build cost (CHANGELOG 3.8.43: "2-4 s" full cold build at the then-1,000-file cap, ~2-4 ms/file; 5,000 files ≈ 10-20 s worst-case, one-time and disk-cached thereafter). New `.pi-lens.json#reviewGraph.maxFiles` knob (R2) lets a monorepo opt into a bigger graph explicitly, bypassing the taper (tolerant numeric parse, clamped to `[100, 20_000]`, warn-once on invalid — mirrors `maxProjectFiles`'s parsing); the pre-existing `PI_LENS_REVIEW_GRAPH_MAX_FILES` env override still wins outright over both. Truncation (files found over the cap) now always logs a `review_graph_size_skip` latency phase directly from `review-graph/builder.ts`'s skip branch (R3), independent of which caller triggered the build, instead of relying on `dispatch/integration.ts`'s cascade-only `graph_build` log — closing a silent-cap gap for the other four `buildOrUpdateGraph` call sites (`lens-map.ts`, `project-report.ts`, `mcp/analyze.ts`, `runtime-session.ts`, `dispatch/runners/tree-sitter.ts`). Documented in `docs/globalconfig.md`'s project-config section. New tests: `taperedReviewGraphMaxFiles` shape (below/at/above the linear boundary, half-gap-at-taper-scale property, ceiling asymptote, monotonicity), the config knob's parse/clamp/warn-once behavior, and the new latency phase.
|
|
48
|
+
|
|
49
|
+
- **Review-graph JS/TS imports now honor `compilerOptions.baseUrl` and `paths` aliases, including relative `extends` chains** (refs #775) — aliased imports resolve to real workspace files before workspace-package/external fallback, with longest-prefix matching and existing extension/index probing.
|
|
50
|
+
|
|
51
|
+
- **Nested mutation controls now layer per edited file** (refs #792) — monorepo package `.pi-lens.json` files use closest-wins resolution independently for `format.enabled`, `autofix.enabled`, and `actionableWarnings.autoFix.enabled`, with explicit CLI disables still highest precedence and mtime-keyed per-directory config caching.
|
|
52
|
+
|
|
53
|
+
- **`symbol_search`/`pilens_symbol_search` gained `paths`/`lang` filters, per-hit `suggestedNext` discovery hints, and warm-review-graph annotations** (closes #771) — three additive ergonomics improvements over the registered tool (`tools/symbol-search.ts` → `clients/lens-engine.ts`'s `symbolSearch`, mirrored in `mcp/server.ts`), all reusing existing machinery: (1) optional `paths` (glob array, same shape/semantics as `ast_grep_search`'s `paths` — a bare directory/file entry scopes its subtree) and `lang` (same identifiers as `ast_grep_search`'s `lang`, mapped to extensions) scope the word index BEFORE ranking via a new `RankOptions.fileFilter` on `searchWordIndex` (`clients/word-index.ts`), so a surviving file's BM25/priors/centrality score is identical to an unfiltered run — omitting both params reproduces prior output byte-for-byte; (2) every hit now carries a `suggestedNext: { tool: "module_report", path }` hint (mirrors `ast-grep-search.ts`'s `suggestedDump` pattern), making the symbol_search → module_report → read_symbol discovery funnel machine-actionable instead of prose-only; (3) hits gain an optional `annotations: { fanIn, complexity }` — `fanIn` reuses the SAME reverse-dependency centrality value already computed for the ranking boost, `complexity` is the file's highest recorded per-symbol cyclomatic complexity from the review graph — populated only when `getCachedReviewGraph` (`clients/review-graph/builder.ts`, dynamic-imported to keep it off the hot path when unused) finds an already-warm graph; this accessor is READ-ONLY and never builds/blocks, so a cold graph simply omits `annotations` with no added latency. `symbolSearch` is now `async` (the dynamic import) — an additive signature change; both call sites (`tools/symbol-search.ts`, `mcp/server.ts`) now `await` it. New/extended coverage: `tests/clients/word-index.test.ts` (`fileFilter` score-preservation + omitted-filter equivalence), `tests/tools/symbol-search.test.ts` (paths/lang filtering, no-filter parity, per-hit `suggestedNext`, graph-cold no-annotation-no-build, graph-warm annotation presence), and `tests/index-wiring.test.ts` (asserts `paths`/`lang` are actually registered on the tool's parameter schema).
|
|
54
|
+
|
|
55
|
+
- **New `lazyEnvNumber(envName, fallback)` factory in `clients/env-utils.ts`** (closes #763) collapses the repeated "lazy + memoized, `toPositiveFinite(process.env[name])`-or-fallback" getter triple — each instance was a module-level memo variable plus a `getX()`/`_resetXForTests()` pair, ~20 lines apiece — into one line per call site while keeping every exported name/signature unchanged so callers and tests don't churn: `clients/startup-scan.ts`'s `getStartupScanVerdictTtlMs`/`_resetStartupScanVerdictTtlForTests` and `getStartupScanMaxEntries`/`_resetStartupScanMaxEntriesForTests` now re-export `lazyEnvNumber(...).get`/`._resetForTests`. `runtime-config.ts`'s `getRunnerTimeoutFloorMs` (also reads a config-file value and takes `Math.max` of the two sources), `slow-fs.ts`'s threshold resolver (memoized per-cwd probe result, not per env-value, and recomputed on every probe rather than cached standalone), and `subagent-mode.ts`'s classification cache (boolean/string shape, not a number) were left as-is — none match the number-only lazy-memoized-getter shape the factory targets. No behavior change. New `describe("lazyEnvNumber", ...)` block in `tests/clients/env-utils.test.ts` covers default/override/non-finite-fallback/memoization-until-reset/no-env-read-at-construction/independent-instance-memo, mirroring `tests/clients/startup-scan-entry-budget.test.ts`'s existing getter tests (left unmodified and still passing).
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- **Windows subprocess arguments no longer pass through Node's `shell:true` concatenation** (CodeQL alert #17 / CWE-78) — `safeSpawnAsync` now invokes `cmd.exe` explicitly with `shell:false` and `windowsVerbatimArguments` so its manually quoted `/c` payload is not escaped a second time by Node. User-controlled paths and arguments remain arguments instead of becoming additional commands; regression coverage includes a shell-metacharacter argument and Windows command paths with spaces.
|
|
60
|
+
|
|
61
|
+
- **CI drift alerts no longer interpolate third-party output into shell code** — compat-smoke passes layer outcomes and versions through environment variables, and install-smoke disables persistent checkout credentials before running package-install tests. Intentional pinned ad-hoc package installs are documented with targeted zizmor suppressions.
|
|
62
|
+
|
|
63
|
+
- **Windows temp-dir teardown no longer fails tests with EPERM/ENOTEMPTY** (closes #810) — root cause of the deterministic `tests/clients/runtime-session.test.ts` failures (2 of 12, reproduced on clean master on this machine): in "full" mode with a `package.json` present, `scheduleStartupScans` (`clients/runtime-session.ts`) fires `todo` and `word-index` as fire-and-forget background tasks (`setImmediate`) that do REAL, unmocked fs reads against the test's temp project dir — unlike knip/jscpd/ast-grep-exports/etc, which the test stubs out entirely. The test helper's `runSessionStart` returned as soon as `handleSessionStart`'s own promise resolved, so the caller's `finally { env.cleanup() }` could run while those two tasks were still mid-read, racing the recursive `rm` against an open file handle inside the temp dir (deterministic here, not a load flake). Fixed by tracking the real in-flight-scan set (via `markStartupScanInFlight`/`clearStartupScanInFlight`, the same primitive production code uses) and having the returned `cleanup` wait for `todo`/`word-index` to settle before deleting the directory — `call-graph`/`codebase-model` are deliberately excluded since they're staggered 5+ seconds out by design and a stale/missing analysisRoot by the time they fire is a harmless no-op. Also added ONE shared hardened temp-dir cleanup helper, `removeTempDirSync` (`tests/clients/test-utils.ts`), implementing the #793 pattern (`maxRetries: 5, retryDelay: 200`) wrapped so a final failure warns via `console.warn` instead of failing the suite — a leftover temp dir is harmless, a red run from teardown is not — and swept every ad-hoc `fs.rmSync(dir, { recursive: true, force: true })` directory teardown across `tests/` onto it (~104 files), including two bespoke retry-and-swallow sites (`tests/clients/lens-map.test.ts`, `tests/clients/ast-grep-rule-precedence-followups.test.ts`) that had hand-rolled the same pattern locally. Single-file (non-recursive) `fs.rmSync` calls that delete one fixture file mid-test were left as-is (bespoke, not directory teardown).
|
|
64
|
+
|
|
65
|
+
- **`lens_diagnostic_mark` no longer trusts a stale caller-supplied `line` verbatim — it verifies/reanchors against current diagnostics before writing anything** (closes #802) — the tool handler (`tools/lens-diagnostic-mark.ts`) previously hashed/wrote at exactly the `line` the agent passed, with no check that the file's CURRENT content still put that finding there. A stale line silently rotted `false-positive`'s strict content-hash anchor (it never matched the real finding again) and, worse, made `suppress` write the inline `pi-lens-ignore` comment above the WRONG statement — the self-inflicted case being a top-down batch-suppress in one file, where each inserted comment shifts every later line down by one and invalidates the batch's own remaining coordinates. New `verifyLine` cross-checks the caller's tool/rule/normalizedMessage against `clients/widget-state.ts`'s live per-file diagnostics (`getFileDiagnostics`, reusing `diagnostic-dispositions.ts`'s own now-exported `normalizeMessage` — not a second normalizer) and reanchors to the diagnostic's CURRENT recorded line when it differs (multiple same-rule/message matches on different lines pick the one closest to the caller's line, the conservative choice for suppress). When widget-state has nothing for this file/finding (e.g. a fresh session), a fuzzy fallback accepts the caller's line if it's in-bounds and non-blank, else searches ± 5 lines for the nearest non-blank line; if nothing plausible is found, the tool returns `isError` instead of guessing. `verifyLine` is internally defensive (never throws), and any unexpected failure degrades safely per-disposition: `false-positive`/`defer`/`flagged` fall back to trusting the caller's line (today's pre-fix worst case — a rotted anchor, not a corrupted write), while `suppress` refuses to write at an unverified position rather than risk a misplaced comment. The tool's description now documents line verification and recommends bottom-up ordering for multi-suppress in one file. New coverage in `tests/tools/lens-diagnostic-mark.test.ts`: stale-line-with-widget-match reanchoring (both for `false-positive`'s stored anchor and `suppress`'s comment placement), unchanged behavior when no widget state exists, out-of-bounds/all-blank-nearby lines erroring without writing, and a top-down two-finding batch-suppress in one file landing both comments correctly via reanchoring.
|
|
66
|
+
|
|
67
|
+
- **Silent-clean workspace-indexing LSP warm-ups no longer pay the generic 20-second cold floor** (refs #832) — `ensureWarmForSweep` now uses the configured short strategy wait when live capability classification identifies a workspace-indexing push-only server that is silent on clean files, while TypeScript-like cold-indexing servers retain the longer budget; ambiguous capability data fails closed.
|
|
68
|
+
|
|
69
|
+
- **Cold `lens_diagnostics` full sweeps no longer burn 2x20s on marksman for a clean markdown project, and no longer re-pay a failed warm-up on every retry sweep** (closes #799) — root cause was two compounding gaps: (1) marksman is push-only and publishes NOTHING on a clean file, so `ensureWarmForSweep`'s `warmupOverride` floor (`clients/lsp/index.ts`) guaranteed the full 20s warm-up budget on both the initial attempt AND its #744 retry, with zero diagnostics either time; (2) that inconclusive result never marked the server `demonstratedReady`, so a follow-up sweep in the same session re-paid the same 2x20s all over again. Fixed both: marksman now carries `silentOnClean: true` (`clients/lsp/server-strategies.ts`), and `touchFile`'s diagnostics wait gained a generic push-only clean-confirm fallback — reusing the existing `classifyCascadeWaitTier` (#458) capability-snapshot classification rather than typescript's TS-specific sync-command race — so a single-server primary-scope touch that times out with a successful notify write and a `silentOnClean` push-only server is now CONFIRMED clean (and marked `demonstratedReady`) instead of inconclusive. `ensureWarmForSweep`'s `warmupOverride` floor is now also scoped to the FIRST attempt only (`LSPTouchFileOptions.warmupAttempt`) — a retry no longer re-floors to the full cold-start budget, respecting the server's own (much shorter) strategy wait instead. Second: a new session-scoped negative cache (`LSPState.demonstratedCold`, keyed identically to `demonstratedReady`) records a server whose warm-up genuinely stayed cold after both attempts; a later sweep this session sees the cache and skips straight to the existing #744 group-skip accounting instead of re-running the warm-up round trip, logging a distinguishable `lsp_sweep_warmup_cached_cold` phase (and `skippedFromCache` metadata on the group-skip log) so this reads differently from a fresh failure. The cache clears automatically the moment a server demonstrates readiness through any path, and is discarded wholesale on `resetLSPService` (a fresh `LSPService` instance starts with an empty set) so a new session always retries fresh. New coverage in `tests/clients/lsp/sweep-warmup.test.ts` and a new `tests/clients/lsp/silent-clean-confirm.test.ts`.
|
|
70
|
+
|
|
71
|
+
- mutation-control config follow-ups (#792): global `autofix.enabled` now honored (docs promised it, code ignored it), the MCP LSP-navigation tool now resolves project config from the request cwd, global+project config share one `{enabled}` parser with warn-once on invalid values, and mutation-skip debug lines name the deciding config source
|
|
72
|
+
|
|
73
|
+
- deferred auto-format queue: records are now owned by the queuing session/turn — a read-only or concurrent-secondary subagent turn no longer flushes and formats another turn's pending files (#791)
|
|
74
|
+
|
|
75
|
+
- compat-smoke behavioral harness: scratch-dir cleanup crash (ENOTEMPTY race) no longer discards assertion results and falsely reports Layer B contract drift (#785)
|
|
76
|
+
|
|
77
|
+
- **A review graph no longer keeps serving a stale pre-cap snapshot once a repo grows past the review-graph file cap, and `project_report` now says so honestly** (closes #782) — the #775 harness pinned a `KNOWN GAP`: once a repo crossed `maxProjectFiles`-derived cap (`clients/project-scale.ts`)/`PI_LENS_REVIEW_GRAPH_MAX_FILES`, `getCachedReviewGraph` (`clients/review-graph/builder.ts`) kept indefinitely returning any graph cached/persisted from BEFORE the crossing — `usedBy`/`blastRadius`/fan-in silently reflected an old, smaller repo — and every consumer (`module_report`, `symbol_search` annotations, `project_report`) had no way to tell "graph disabled because the repo is too big" apart from "cold cache, build in progress"; `project_report`'s cold-path hint told the caller to "retry shortly", which is actively wrong when the build will never succeed at the current cap. The `too_many_files` skip branch now records a 15-minute-TTL'd size-skip verdict per cwd (`getReviewGraphSizeSkipVerdict`, TTL configurable via `PI_LENS_REVIEW_GRAPH_SIZE_SKIP_TTL_MS`, matching `project-report.ts`'s existing `STALE_THRESHOLD_MS`) — chosen over persisting the verdict to disk because every real caller either rebuilds within a session or restarts the process, unlike startup-scan's `too-many-source-files` verdict which is cached across process starts specifically to skip a slow walk. `getCachedReviewGraph` now consults that verdict before serving either cache tier, so a repo confirmed over cap gets a cold read instead of a silently stale one; a repo shrink or a raised cap clears the verdict immediately on the next successful build (or lets it lapse on TTL). `project_report`'s cold-path branches on the verdict to return `"review graph disabled: project has N files, cap is M — raise maxProjectFiles in .pi-lens.json or set PI_LENS_REVIEW_GRAPH_MAX_FILES"` instead of the generic retry hint, and skips kicking off another background build that would just re-hit the same cap. `tests/monorepo/size-cliff.test.ts`'s `KNOWN GAP (#775)` pinning test is flipped to assert the fixed behavior, plus new tests for verdict TTL expiry and the immediate clear-on-success path.
|
|
78
|
+
|
|
79
|
+
- **Package-local `.pi-lens.json` `ignore` patterns are now honored — layered the same way nested `.gitignore`s are** (closes #783) — the walk-wide ignore matcher (`clients/file-utils.ts`'s `getProjectIgnoreMatcher`) re-anchors to the git root before loading `.pi-lens.json`, so a package-local config's `ignore` field was silently never consulted, even for files inside that package (found empirically by the #775 Track B harness and pinned as a KNOWN GAP in `tests/monorepo/nested-ignore-config.test.ts`). `buildProjectIgnoreMatcher`'s per-ancestor-directory pattern collection (`patternsForDir`, the same layering that already merges nested `.gitignore`s) now also loads a `.pi-lens.json`/`pi-lens.json` found directly in each ancestor directory between the git root and the target — via new no-upward-walk helpers `findPiLensConfigInDir`/`loadPiLensConfigInDir` in `clients/project-lens-config.ts` that reuse the existing path+mtime config cache, so no JSON is re-read per file — and merges its `ignore` patterns anchored relative to that config's own directory (same anchoring semantics as a `.gitignore` there), tagged with the `pilens` layer so #703's "tracked files are never rescued from pi-lens-native intent" rule applies unchanged. The per-directory nested cache key now includes the config file's mtime alongside the `.gitignore` mtime, so editing a nested config takes effect without a restart. Root-config `ignore` behavior, precedence (`global` → `gitignore` → `pilens`, closer directory wins), and `maxProjectFiles`' upward-walk discovery are all unchanged; a nested config's patterns never apply outside its own directory subtree. The pinning test is flipped to assert package-local patterns ARE honored, with new coverage for root-config patterns still applying project-wide and for a nested config's identically-named pattern NOT leaking into sibling packages or the repo root; `docs/globalconfig.md`'s `ignore` section documents the layering.
|
|
80
|
+
|
|
81
|
+
- **Two Track B (#775) follow-ups from the empirical monorepo test harness** (closes #781, closes #784): (1) `runBlastRadiusInBackground` (`clients/dispatch/runners/tree-sitter.ts`) now passes `cwd` through to `computeImpactCascade` — `cwd` was already in scope (used the line above for `buildOrUpdateGraph`) but dropped at this call site, so the background blast-radius path's workspace module-graph fallback could never engage in a monorepo, unlike the primary per-edit cascade path (`clients/dispatch/integration.ts`) which already passed it correctly; the `tests/monorepo/impact-cascade-cwd.test.ts` pinning test that previously marked this a `KNOWN GAP` now asserts the fallback engages. (2) `scanTruncated` (the #760 scanner-budget flag) reached `lens-engine.ts`'s `projectScan` seam intact but nothing rendered it, so a capped scan read as a complete clean sweep to the agent/user — new `scanTruncationNotice(snapshot)` helper in `clients/lens-engine.ts` renders a one-line notice ("scan truncated at N file(s) — results are partial; raise maxProjectFiles in .pi-lens.json to scan fully.", matching the #777 warm-skip notify's style) that `mcp/server.ts`'s `pilens_project_scan` tool and `tools/lens-diagnostics.ts`'s `mode=full` renderer now both append; `tests/monorepo/diagnostics-truncation.test.ts` gained a test asserting the notice renders once truncated and stays silent otherwise.
|
|
82
|
+
|
|
83
|
+
- **Three monorepo quick-wins from the #775 root-resolution audit** (refs #775): (1) a size-skipped warm pipeline (`too-many-source-files`/`too-many-entries` startup-scan verdict, `clients/runtime-session.ts`) now fires a single visible `notify` — mirroring the existing slow-FS notice's shape/severity — instead of only a debug-log line, naming the `PI_LENS_STARTUP_SCAN_MAX_ENTRIES` override when that's the tripped budget; fires once per session even though heavy scans, the TODO scan, and the dominant-language LSP pre-warm all skip off the same verdict. (2) the audit's suspected symlink-cycle hang in the startup-scan counting walk (`clients/startup-scan.ts`) is verified a NON-issue and pinned by tests instead of "fixed": `fs.Dirent` reports a symlink-to-directory as `isSymbolicLink()`/not-`isDirectory()` (junctions included), so the visitor's directory branch never traverses symlinked dirs at all and a cycle cannot hang the walk — the misleading "always follows symlinks" doc claim is corrected and new tests pin both boundedness and the symlinked-subtree-not-counted behavior so a future stat-through-symlinks refactor can't silently reintroduce the risk. (3) `resolveJsTs` (`clients/review-graph/import-resolvers.ts`) and the warm graph builder's `localImportToFile` (`clients/review-graph/builder.ts`) now resolve a bare specifier (`@scope/pkg` or `@scope/pkg/subpath`) that names a sibling workspace package — detected via the existing `workspace-modules.ts` npm/pnpm/cargo/go.work scanner, memoized per cwd by `buildModuleGraph` so the workspace scan costs one filesystem pass per graph build, not one per import edge — to that package's entry file (`package.json` `main`/`module`/`exports`-main, falling back to `index.ts`/`.js`/`src/index.ts`) or, for a subpath import, a file within the package resolved with the same source-twin-preferring extension probe the relative resolver uses; a specifier matching no known workspace package still returns `[]` (external), unchanged. New coverage: `tests/clients/runtime-session-warm-skip-notify.test.ts` (notify fires once on an over-budget seeded verdict, names the entries override, stays silent on a normal small project), `tests/clients/startup-scan-symlink-cycle.test.ts` (self-loop and mutual-symlink fixtures complete instead of hanging, sync + async), and workspace-package fixtures in `tests/clients/review-graph/import-resolvers.test.ts` and `tests/clients/review-graph.service.test.ts` (cross-package entry/subpath edges, non-workspace bare specifiers stay external).
|
|
84
|
+
|
|
85
|
+
- **`collectSourceFiles`/`collectSourceFilesAsync` (`clients/source-filter.ts`) now bound the walk by directory entries VISITED, not just source files FOUND** (closes #760 — the #758 escape class at the remaining non-startup-verdict call sites) — the walks' only hard cap was the `maxFiles` RESULTS cap (#250), so a mixed tree with few source files but a huge pile of non-source files (the #758 reporter's shape: ~300 scripts among ~84k game-mod data files) never tripped it and still got a full-tree walk, dominated by one `ignoreMatcher.isIgnored()` call per entry — hitting the review-graph rebuild's per-edit hot path (`clients/review-graph/builder.ts`), the `lens_diagnostics` project scan (`clients/project-diagnostics/scanner.ts`), the SYNC caller-blocking todo scan (`clients/todo-scanner.ts`), and word-index build paths not gated by the #758 startup verdict (`clients/word-index.ts`). Fix mirrors #759's startup-scan shape and `jscpd-client.ts`'s `MAX_ENTRIES` prior art: a new `maxScanEntries` option (named consistently with startup-scan) charges one tick per entry TOUCHED — including ignored/skipped ones, since the per-entry ignore probe is the dominant cost — with a generous `DEFAULT_MAX_SCAN_ENTRIES` (200k: bounds the pathological misrooted/data-dominated case to seconds instead of an unbounded walk, while staying an order of magnitude above any healthy project's entry count so real repos never see truncation); when it trips the walk stops and returns the best-effort list collected so far. The existing `maxFiles` cap is unchanged and independent, exactly as in #758. Exposed non-invasively via new `collectSourceFilesWithBudget`/`collectSourceFilesWithBudgetAsync` cores returning `{ files, entryBudgetExceeded }` that the existing collectors wrap — the `string[]` contract of every current caller is untouched, and the default budget covers ALL callers automatically. Truncation awareness threaded per call site: the review-graph build logs a `review_graph_source_walk_entry_budget` latency phase (a truncated graph degrades like `maxFiles` trimming, so log-only); the project-diagnostics snapshot carries a new `scanTruncated` flag (surfaced like #747/#749's `unsafeRoot` but deliberately NOT a refusal — a truncated analysis is still useful, the flag only stops callers reading a partial result as a complete clean sweep) plus a `maxScanEntries` scan option; todo-scanner and word-index accept the default-bounded truncated list (comment-documented, no behavior change needed). New `tests/source-filter-entry-budget.test.ts` covers budget trip on a deterministic mixed tree, generous-budget completeness (no false positives), invalid-budget coercion to the finite default, sync/async flag agreement (and set-identical complete walks), `maxFiles` independence, and plain-wrapper equivalence; `tests/clients/project-diagnostics/scanner.test.ts` covers `scanTruncated` set-without-refusing and omitted-when-clean.
|
|
86
|
+
|
|
87
|
+
- **The startup source-file count walk now stops after a bounded number of directory entries instead of traversing the entire project tree** (closes #758) — `computeStartupScanContext`/`resolveStartupScanContextAsync` (`clients/startup-scan.ts`) count source files under the project root to decide `canWarmCaches`, and the walk early-exited ONLY once it had seen more than `MAX_STARTUP_SOURCE_FILES` (2000) `.ts/.js/.py/.go/.rs/.rb` files. A repo with FEW source files but a huge pile of non-source files never trips that exit — the reporter's case was a Hearts of Iron IV mod with ~300 scripts among ~84k data/`.txt` entries — so the walk enumerated the WHOLE tree, dominated by one `ignoreMatcher.isIgnored()` call per entry, blocking `session_start` for seconds on the cold-start path. The walk now carries a second, independent bound: a `MAX_STARTUP_SCAN_ENTRIES` (50000, env-tunable via `PI_LENS_STARTUP_SCAN_MAX_ENTRIES`) total-entry ceiling — the same finite-walk-cap pattern already guarding `jscpd-client.ts`'s `hasSourceFilesRecursive`. When a walk hits that ceiling before crossing the source-file limit, the tree is large and dominated by non-source files, so it returns a new `too-many-entries` verdict (`canWarmCaches: false`) rather than trusting the partial count and warming caches on (then re-walking) an enormous tree. `too-many-entries` is TTL'd by `isStartupScanVerdictFresh` exactly like `too-many-source-files` (#699) so a shrunk repo auto-recovers on the next session. The public `countSourceFilesWithinLimit`/`countSourceFilesWithinLimitAsync` wrappers keep their pre-#758 contract (only the source-file limit bounds them); the entry ceiling applies solely to the startup-scan verdict path via the shared `walkSourceCount`/`walkSourceCountAsync` cores. New `tests/clients/startup-scan-entry-budget.test.ts` drives the `too-many-entries` verdict deterministically with a tiny mixed-repo fixture and a small `maxScanEntries`, covers the env-tunable getter, and pins the TTL behavior on both the sync and async paths. `runtime-session.ts`'s `useScanRootForSignals` treats the new verdict exactly like its `too-many-source-files` sibling — both found a real project root, so session signals stay anchored there rather than falling back to cwd — and the sync/async walk loops share a single `visitCountEntry` per-entry step so the two bounds live in one place.
|
|
88
|
+
|
|
89
|
+
- **`writeState` in `clients/diagnostic-dispositions.ts` now writes the disposition store via tmp+rename instead of a plain `fs.writeFileSync`** (#690's rescope — a prerequisite for the cross-process review CLI, which will read/write this store from a second process) — a concurrent reader (`readState`) could previously observe a half-written file mid-`writeFileSync`, fail `JSON.parse`, and have its catch treat that as empty state, silently losing every disposition on that read. Now matches the repo's established `${target}.tmp-${pid}` atomic-write shape (`clients/instance-registry.ts`, `clients/recent-touches.ts`, `clients/review-graph/builder.ts`): `renameSync` replaces the destination atomically on both POSIX and Windows (libuv's `MOVEFILE_REPLACE_EXISTING`), so a reader now always sees either the fully-old or fully-new JSON, never a torn write. Unlike those best-effort writers, a rename failure here still throws (after best-effort tmp-file cleanup) rather than being swallowed — this store's writes are functionally load-bearing (a lost mark is a correctness bug, not a dropped observability sample), matching the pre-existing `writeFileSync`'s own no-catch behavior. No retry loop was added for transient Windows `EPERM`/antivirus contention on rename: none of this repo's other atomic writers retry either, and `renameSync`'s replace semantics already succeed in the common case. `readState`'s corrupt-JSON catch is now genuinely rare (a torn read can no longer trigger it) and its comment was updated to explain why caching `{}` against that stat is still safe rather than a permanent trap. New tests in `tests/clients/diagnostic-dispositions.test.ts` assert no `.tmp-*` file is left behind after a mark, that state round-trips through a cache reset (simulating a fresh process reading purely from disk), and that a 200-entry write is always fully parseable off disk.
|
|
90
|
+
|
|
91
|
+
- **`lens_diagnostic_mark` dispositions now apply to the cached `lens_diagnostics` delta/all modes immediately, without waiting for the file to be re-dispatched** (closes #755) — the #690 disposition filter ran in exactly two places: the per-edit dispatcher (`clients/dispatch/dispatcher.ts`, filtering BEFORE results are cached) and `mode=full`'s merge (`applyInlineSuppressionsToSummaries`, `tools/lens-diagnostics.ts`). But `mode=delta` (the default) and `mode=all` re-served the `actionable-warnings`/`code-quality-warnings` reports, the project-diagnostics delta report, and the widget summaries VERBATIM from dispatch time — before any mark existed — and `lens_diagnostic_mark`'s suppress writes its `pi-lens-ignore` comment via raw `fs.writeFile`, not an agent edit, so no `tool_result` hook fires and no re-dispatch happens: a suppressed/deferred finding reappeared on the next `lens_diagnostics` call until the file was next edited. `formatDeltaMode` and `formatAllMode` now re-apply the disposition filter at read time. A new `applyWeakDispositions` (`clients/diagnostic-dispositions.ts`) drops only the WEAK-anchored dispositions (`suppress`, `defer` — `file|tool|rule|message`, no line-content hash), which by construction needs ZERO file I/O, so the "instant" cache-only modes stay instant: no unconditional reads are added. `false-positive` is deliberately NOT filtered in these modes — it is STRICT-anchored (needs the flagged line's own content to re-derive its hash) and still filters on the next per-edit dispatch and in `mode=full`, both of which already have file content in hand; the store's weak `suppress` entry mirrors every inline comment the mark tool writes, so store filtering already covers marks made through the tool (a hand-written inline-only comment on an un-redispatched file remains the documented corner case, converging at next dispatch / in `mode=full`). Project-delta findings are anchored via `projectDiagnosticToWidget` so the `(tool, rule)` a mark binds to matches `mode=full`'s own project-runner filter. No mark-time cache purge was added: read-time filtering is the single robust choke point that converges every `lens_diagnostics` consumer, whereas purging the two report-cache shapes plus the in-memory widget state at mark time would duplicate that logic across fragile shapes and risk drift. New end-to-end coverage (`tests/tools/lens-diagnostics.test.ts`) marks suppress/defer via the real tool and asserts the finding is gone from delta and all with no re-dispatch (and that `mode=full` is unaffected); unit coverage of `applyWeakDispositions` (`tests/clients/diagnostic-dispositions.test.ts`) proves it drops suppress/defer with no content passed, keeps `flagged`, and leaves `false-positive` for the content-based filter.
|
|
92
|
+
|
|
93
|
+
- **Remaining cwd-rooted scan roots now carry the same `isAtOrAboveHomeDir` ceiling, and the source-file walk is capped structurally** (closes #751) — follow-up to #749, closing out the lower-exposure members of the #250/#253/#462/#747 escape class so no walker or scanner spawn can enumerate `$HOME` from a misrooted cwd. (1) `scanProjectDiagnostics` (`clients/project-diagnostics/scanner.ts`) — the cheap-tier project scan, reached from `lens_diagnostics mode=full` and MCP `pilens_project_scan` (cwd straight from tool args) — refuses to walk a cwd at/above home, returning an honest `unsafeRoot` snapshot (0 files, not "clean") that both callers render as a skip rather than a clean verdict. (2) `clients/source-filter.ts` `resolveCollectionConfig` no longer defaults an omitted `maxFiles` to `Infinity`: the new `DEFAULT_MAX_SOURCE_FILES` (20000) finite cap means no caller (`todo-scanner`, `scan-utils` pass-throughs, warm-path language ranking) can trigger an unbounded collection just by omitting the cap. (3) `ast_grep_search` (`tools/ast-grep-search.ts`) refuses only the *defaulted*-cwd case (`paths` omitted) when that cwd is at/above home — explicit user `paths` are never second-guessed. (4) the LSP workspace sweep (`collectWorkspaceDiagnosticFiles`, `clients/lsp/index.ts`) adds the home ceiling on top of its existing 5000-file cap, with `tools/lens-diagnostics.ts` rendering the refused sweep as a skip. (5) `JscpdClient.scan` (`clients/jscpd-client.ts`) gains an internal `isAtOrAboveHomeDir` guard (belt-and-braces — it resolves no marker root, so previously every caller had to guard for it). (6) `rules-scanner.ts` `findMarkdownFiles` gains a depth-8/500-file cap (it only ever walks `.claude/rules`/`.agents/rules` subtrees, so a depth/file bound is sufficient without the full home ceiling).
|
|
94
|
+
|
|
95
|
+
- **A sweep warm-up that times out now retries once and, if still cold, skips that server's files for the rest of the sweep instead of letting a wedged server drag the whole run** (closes #744) — live dogfooding of a 119-file `lens_diagnostics mode=full` sweep found `ensureWarmForSweep` (`clients/lsp/index.ts`) doing a single one-shot warm-up `touchFile` per representative file bounded by `warmupTimeoutMs()` (20s floor); when that warm-up timed out (observed: marksman, a `workspaceIndexing` server, burned the full 20s and stayed cold) the server was left cold with NO re-warm, NO backoff, and NO skip, so every subsequent per-file touch in the sweep re-paid a full per-file budget against it and timed out again — the sweep launched ~59s after an `lsp_service_reset`, straight into freshly-relaunched servers, exactly the state where warm-up failure is most likely. **What/why/how:** (1) `ensureWarmForSweep` now measures per-non-auxiliary-server warm-up success via the SAME `demonstratedReady` signal `touchFile` already marks on a confirmed (non-inconclusive) diagnostics round trip — a server whose key is still absent afterward never warmed. On a failed first attempt it retries exactly ONCE after a short `warmupRetryBackoffMs` breather (`PI_LENS_LSP_WARMUP_RETRY_BACKOFF_MS`, default 500ms, abort-aware — the failure state is usually a server mid-relaunch/index that just needs a moment, not a second full budget), and returns any still-cold server ids in a new `failedServerIds`. (2) `runWorkspaceDiagnostics`' per-group worker skips a group whose primary server is in `failedServerIds` (the sweep groups by primary server, so that primary is precisely the one whose per-file touches would drag), recording each skipped file as UNCONFIRMED (`timedOut: true` + a new `skippedWarmupFailure: true` result field) — never as confirmed-clean `[]`, so the existing confirmed/unconfirmed reporting (#630/#646, `tools/lens-diagnostics.ts`) and the per-primary-server breakdown surface these files honestly as "NOT the same as 0 diagnostics" rather than "ran clean", with a dedicated `lsp_sweep_warmup_failed`/`lsp_sweep_group_skipped_warmup` latency-log trail in `~/.pi-lens`. **Design notes:** the skip is deliberately SWEEP-SCOPED (the caller discards `failedServerIds` when the call ends), NOT the global `broken` cooldown map — a server that is merely still indexing is not broken and must not be cooldown-banned across the whole session; the `broken` map's 15s-base/exponential cooldown is for genuine spawn/initialize failures, a different failure class. Warm-up stays `clientScope:"primary"` (not the sweep's `"all"`) on purpose: `"all"` would additionally spawn the sweep-EXCLUDED auxiliaries (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`), and because `touchFile`'s `inconclusive` flag is touch-wide, one slow advisory auxiliary would then suppress the `demonstratedReady` marking of a perfectly healthy primary and falsely condemn it — recording per-primary-server outcomes covers the servers the sweep actually gates on without that regression. The issue's optional third bullet (gating/delaying a sweep that starts shortly after an `lsp_service_reset`) is left as a follow-up: it needs a reset-timestamp seam the sweep doesn't have today and didn't fall out of this change. New tests in `tests/clients/lsp/sweep-warmup.test.ts` cover warm-up timeout → one retry; retry-still-failing → server reported in `failedServerIds` and its files skipped as `skippedWarmupFailure`/unconfirmed (not clean); a server that warms on the retry participates normally; and the already-warm no-op path is unchanged.
|
|
96
|
+
|
|
97
|
+
- **A single backpressured LSP server no longer times out the didOpen/didChange notify write for an entire `clientScope:"all"` file touch, zeroing every co-touched healthy server's diagnostics** (closes #743 — observed live during a `lens_diagnostics mode=full` sweep on a 119-file project: writes split 20 ok / 16 timed out, the sweep appeared stuck at 20%, and touched files reported `notifyWriteTimedOut:true collectedDiagnostics:0` even for healthy servers). `LSPService.touchFile` (`clients/lsp/index.ts`) fanned the notify write across ALL spawned servers under one `Promise.all` bounded by a single `notifyWriteBudgetMs()` (2000ms) deadline, so one server that had stopped draining its stdin (backpressure — e.g. tsserver mid-recheck under load) timed out the write for the WHOLE file, flipping the touch to `notifyWriteTimedOut → inconclusive` for every server including perfectly healthy ones — contributing no diagnostics and marking nobody `demonstratedReady`. Fix, mirroring how the diagnostics wait already went per-server (#242/#573 via `perServerTimeout`): (1) each server's notify write now gets its OWN `notifyWriteBudgetMs()` deadline instead of one shared `Promise.all` deadline; (2) `notifyWriteTimedOut` is now per-server — a new `notifyWriteTimedOutServerIds` list records exactly which servers' writes did not land, the file-level `notifyWriteTimedOut` flag means "at least one server timed out" (kept for the conservative touch-wide `inconclusive` merge, since `collected` still merges across all servers), and the `demonstratedReady` gate now skips ONLY the servers whose own write timed out rather than gating the whole loop on the file-level flag — so a healthy server whose sibling's write stalled still becomes `demonstratedReady`; (3) a server whose notify write times out `NOTIFY_BACKPRESSURE_BROKEN_AFTER` (3) times in a row is demoted through the EXISTING `broken`-cooldown map (`recordNotifyWriteBackpressure` sets a `BROKEN_BASE_COOLDOWN_MS` cooldown and evicts the wedged client — an alive client is reused before the broken check in `ensureClientForServer`, so the cooldown only bites once the stale client is gone) so subsequent sweeps stop re-paying its notify budget on every file; a single successful write resets the consecutive-timeout streak. A write that REJECTS (not backpressure) still marks that server inconclusive but does not count toward the demotion streak. New `tests/clients/lsp/service-notify-per-server.test.ts` covers: a stalled sibling write not marking the healthy server timed out while the healthy server still reaches `demonstratedReady`; repeated timeouts tripping the broken cooldown and evicting the client; and a successful write resetting the streak so demotion needs a fresh consecutive run.
|
|
98
|
+
|
|
99
|
+
- **`lens_diagnostics mode=full` no longer launches heavyweight analyzers from `$HOME` or above** (closes #747) — `fetchFreshProjectDiagnostics` (`clients/project-diagnostics/fresh-fetch.ts`) resolved the session cwd as `analysisRoot` with no ceiling, so a `mode=full refreshRunners=cheap` call issued while pi's cwd was the home directory started a whole-home `jscpd .`/knip/madge/gitleaks/govulncheck/trivy/dead-code walk (the explicit `paths` scope only filters *reported* results, never what the analyzers walk). Observed failure: a jscpd scan from a WSL `$HOME` reached 44 GB RSS, exhausted swap, and OOM-killed the entire WSL instance. The fresh fetch now applies the same `isAtOrAboveHomeDir` ceiling as `startup-scan.ts`/`resolveSnapshotRoot`/the review-graph builder (the #250/#253 escape class) and refuses before spawning anything, reporting a dedicated "heavyweight analyzers skipped" note plus a machine-readable `analyzersUnsafeRoot` details flag instead of seven misleading per-analyzer "not applicable" reasons.
|
|
100
|
+
|
|
101
|
+
- **`symbol_search`'s cold word-index build no longer walks an unbounded tree from `$HOME`** (#747 hardening) — `triggerBackgroundWordIndexBuild` (`clients/word-index.ts`) was the one word-index build path with no `canWarmCaches` gate in front of it (cold in-process queries and MCP `pilens_symbol_search`, where cwd can be a raw tool argument), and `collectWordIndexDocs` ran `collectSourceFilesAsync` with no `maxFiles`, so the `WORD_INDEX_MAX_FILES` (6000) cap only trimmed results AFTER the entire tree had been enumerated — the same unbounded-walk-plus-in-memory-accumulation shape as the #747 jscpd OOM. The trigger now applies the `isAtOrAboveHomeDir` ceiling, and the cap is passed into the walk itself so every build path is bounded.
|
|
102
|
+
- **`hasOxlintConfig` now detects `.oxlintrc.jsonc`, `oxlint.config.ts`, and `oxlint.config.mts`** (closes #745) — previously only `.oxlintrc.json`/`oxlint.json` were recognized, so projects using oxlint's TS-based config format (`oxlint.config.ts` + `defineConfig`) or the `.oxlintrc.jsonc` variant were treated as having no oxlint config at all, causing `getPreferredJstsLintRunners` to fall back to `["oxlint", "biome-check-json"]` instead of `["oxlint"]` and run `biome-check-json` as an uninvited fallback linter. New `OXLINT_CONFIGS` list (mirroring the existing `ESLINT_CONFIGS` pattern) covers all four oxlint-documented auto-discovery filenames alongside the legacy `oxlint.json` name.
|
|
103
|
+
|
|
104
|
+
### Changed
|
|
105
|
+
|
|
106
|
+
- **Introduced a shared `writeFileAtomic`/`writeFileAtomicAsync` helper (`clients/atomic-write.ts`) and migrated four of the five hand-rolled `${target}.tmp-${pid}` + `renameSync`/`rename` writers onto it** (closes #762) — `instance-registry.ts`, `session-state-store.ts`, `recent-touches.ts`, `diagnostic-dispositions.ts`'s `writeState`, and `review-graph/builder.ts`'s sync exit-hook persist all independently hand-rolled the same tmp+rename shape, inviting drift (e.g. forgetting the tmp-cleanup-on-failure step); the helper takes a `bestEffort` option (default `true`, matching the majority: swallow write/rename failures after best-effort tmp cleanup) versus `bestEffort: false` (rethrow after the same cleanup — `diagnostic-dispositions.ts`'s #757 load-bearing policy, since a silently lost disposition mark is a correctness bug, not a dropped observability sample, preserved exactly). `session-state-store.ts`'s tmp file is now named `${file}.tmp-${pid}` instead of its former one-off `${file}.${pid}.tmp` — no observable behavior change, since nothing reads the intermediate tmp filename. `review-graph/builder.ts`'s OTHER writer (`writePending`, the debounced async persist) is deliberately left unmigrated: its callback-style `fs.mkdir`/`fs.writeFile`/`fs.rename` logs a distinct `console.error` per failing stage (mkdir vs write vs rename) that the shared helper's single swallow/rethrow outcome can't express without losing that diagnostic granularity or turning a non-blocking fire-and-forget write into an awaited promise chain. New `tests/clients/atomic-write.test.ts` covers both variants directly: successful atomic replace, no tmp file left behind on success or on a swallowed failure, `bestEffort: true` swallowing a rename failure, and `bestEffort: false` rethrowing it — rename failures are induced via a real filesystem obstruction (renaming onto an existing non-empty directory) rather than mocking `fs`, since this module's `import * as fs` style doesn't reliably re-bind under `vi.spyOn`. Existing suites (`tests/clients/instance-registry.test.ts`, `recent-touches.test.ts`, `session-state-store.test.ts`, and `diagnostic-dispositions.test.ts`'s no-tmp-left-behind/torn-read assertions) pass unmodified.
|
|
107
|
+
|
|
108
|
+
- **Consolidated the repo's recursive tree walkers onto a shared bounded-walk engine in `clients/source-walker.ts`** (closes #761) — every walker hardening episode was costing O(number-of-walkers) because each walker re-implemented the same traversal skeleton: the home-ceiling class took #747 then #751/#754 (seven sites), the entry-budget class took #758/#759 then #760/#764 (five consumers), each hand-rolling the `readdir` + for-entry loop, the sync-recursion / async-stack shape, and the `setImmediate` yield cadence. `source-walker.ts` already held the shared `readDirEntriesSafe` + `shouldRecurseIntoDir`; it now also owns the LOOP via three drivers — `walkTreeStackSync`/`walkTreeStackAsync` (depth-first stack, with an optional per-directory `shouldStop` loop guard and, on the async twin, a `yieldEvery` `setImmediate` cadence + a `beforeWalk` hook for `ensureTrackedIndex` priming) and `walkTreeRecursiveSync` (immediate-descent recursion, preserving result-array order) — each parameterized by a pluggable per-entry `WalkVisitor` returning `"recurse"`/`"skip"`/`"stop"`, generalized from #759's `visitCountEntry` and #764's `chargeEntryBudget`/`classifyEntry`. Phase 1 migrates the two count walks (`walkSourceCount`/`walkSourceCountAsync` in `clients/startup-scan.ts`) onto the stack drivers via a shared `makeSourceCountVisitor`, `hasSourceFilesRecursive` in `clients/jscpd-client.ts` (its `MAX_ENTRIES` 6000 budget expressed as the per-directory `shouldStop` guard, its skip-all-symlinks/multi-language-regex policy kept caller-side), and the `collectSourceFilesWithBudget`/`...Async` cores in `clients/source-filter.ts` (sync onto the recursive driver, async onto the stack driver) — every public contract byte-identical: the `string[]` wrappers, `SourceCollectionResult`, `maxFiles`/`maxScanEntries` semantics, the slow-FS sync clamp, the artifact-probe cache, and #703 tracked-index priming. Classification/policy stays pluggable per walker BY DESIGN — the bounds arithmetic genuinely differs by an off-by-one between the count family (post-classify `visited >= max`, the maxEntries-th entry counted) and the collect family (pre-classify `visited > max`, the tripping entry refused), so the budget/results caps stay in each walker's visitor rather than being forced onto one shared comparison that would shift a cliff by one entry; `lsp/index.ts`, `rules-scanner.ts`, review-graph, `language-profile.ts`, and word-index are deliberately NOT migrated (deferred to opportunistic follow-ups per the issue). The existing equivalence suites are the safety net and pass unmodified — `tests/clients/source-walker-equivalence.test.ts`, `tests/source-filter.test.ts`, `tests/source-filter-entry-budget.test.ts`, and the startup-scan entry-budget/home-ceiling/verdict-cache and jscpd suites — plus a new `tests/clients/source-walker-engine.test.ts` pinning the three drivers' traversal order, `stop`/`shouldStop` halting, and async `setImmediate` yield cadence directly.
|
|
109
|
+
|
|
110
|
+
- **Unified the five independently-hardcoded project-size budgets behind one configurable `maxProjectFiles` scale knob** (closes #776) — the project-diagnostics scanner (500 files), review graph (1,000 files), startup scan (2,000 source files), jscpd (6,000 dir entries), and word index (6,000 files) each degraded silently and separately as a repo grew, with no single lever to say "this repo is big; believe in it." New `clients/project-scale.ts` centralizes a `maxProjectFiles` base (default `2,000`) and a `PROJECT_SCALE_RATIOS` table (0.25×/0.5×/1×/3×/3× respectively) that reproduces today's five defaults exactly at the default base — a behavior-neutral refactor, no default value changed. Resolution order: each subsystem's PRE-EXISTING per-subsystem env override (`PI_LENS_REVIEW_GRAPH_MAX_FILES`, `PI_LENS_STARTUP_SCAN_MAX_ENTRIES`, etc.) still wins outright; below that, `getProjectScaleBase(cwd)` resolves `maxProjectFiles` from the project's `.pi-lens.json` (new field, extended schema in `clients/project-lens-config.ts`) → the new `PI_LENS_MAX_PROJECT_FILES` env var (via the house `lazyEnvNumber` pattern, #763) → the 2,000 default. Every call site with a `cwd`/`rootDir` in hand threads it through so a per-project override actually scales that subsystem (`project-diagnostics/scanner.ts`, `review-graph/builder.ts`, `startup-scan.ts`, `jscpd-client.ts`, `word-index.ts`); the old exported constants (`DEFAULT_MAX_FILES`, `MAX_STARTUP_SOURCE_FILES`, `WORD_INDEX_MAX_FILES`, `RUNTIME_CONFIG.reviewGraph.maxFiles`) are kept, deprecated in comments, for callers/tests that still reference the literals. Two entry-COUNT budgets (`startup-scan.ts`'s `MAX_STARTUP_SCAN_ENTRIES`, `source-filter.ts`'s `DEFAULT_MAX_SCAN_ENTRIES`) were deliberately left standalone rather than derived — they bound directory entries VISITED (a raw tree-walk safety valve), a fundamentally different quantity from source files KEPT whose healthy ratio varies wildly by project shape, and coupling them risked changing behavior on exactly the pathological-tree shapes those ceilings protect. New `tests/clients/project-scale.test.ts` covers the ratio table reproducing all five defaults at the default base, a `.pi-lens.json` override scaling all five derived budgets proportionally, per-subsystem env still beating the derived value, and `PI_LENS_MAX_PROJECT_FILES` beating the default but losing to both `.pi-lens.json` and per-subsystem env. Documented in `docs/globalconfig.md`.
|
|
111
|
+
|
|
112
|
+
## [3.8.71] - 2026-07-20
|
|
113
|
+
|
|
114
|
+
### Added
|
|
115
|
+
|
|
116
|
+
- **`ty` (astral-sh/ty) support as an alternative Python language server** (closes #717) — `PythonServer.spawn()` (`clients/lsp/server.ts`) now tries a bare `ty server` on `PATH` after the existing local `pyright-langserver`/`basedpyright-langserver` candidates fail, and before pyright's managed/auto-install fallback. Strictly opt-in and PATH-only: ty is deliberately NOT added to the installer registry (`clients/installer/index.ts`)/`ensureTool`, and the `resolveAndLaunch` call hardcodes `allowInstall: false`, so ty is only ever picked up when a user has already installed it themselves (`uv tool install ty` / `pipx install ty` / `pip install ty`) — it never displaces an already-installed pyright/basedpyright and never becomes the silently auto-installed Python LSP default (that stays pyright). ty's CLI invocation shape differs from pyright/basedpyright's shared `--stdio` flag — it's launched via the `server` subcommand instead — so it gets its own `resolveAndLaunch` call rather than joining the existing `localCandidates` array. No `initializationOptions` payload is sent to ty: it has no stable `pythonPath`-equivalent init option yet (astral-sh/ty#2032) and instead auto-discovers `.venv`/`VIRTUAL_ENV` relative to `cwd`. New tests in `tests/clients/lsp/server-policy.test.ts` cover the ty fallback tier (launched when pyright/basedpyright aren't found locally, `ty server` args, never triggers install, and pyright still wins when found locally).
|
|
117
|
+
- **`/lens-map` command: a human-facing, self-contained interactive HTML project map rendered from the review graph** (closes #679) — a new `clients/lens-map.ts` aggregates the (symbol-level) review graph up to FILE-level nodes and deduped, weighted file→file edges, computes a deterministic force-directed layout (pairwise repulsion + edge-spring attraction + center gravity, seeded from a hash of each file's own path so re-running on an unchanged project reproduces the same map), and renders it to a zero-dependency HTML page — embedded JSON payload + vanilla JS/SVG, no CDN script, no npm deps. Interactions: pan (drag background), zoom (wheel), hover (highlights direct neighbors, dims the rest), click (fixed detail panel: path, symbol count, in/out degree, transitive dependents). Node radius scales with transitive dependents; fill is a neutral brand-blue intensity scale by dependents only (light `#2563eb` family / dark `#60a5fa` family) — complexity-aware coloring is explicitly deferred to #306, noted inline. "external" kind nodes (third-party/stdlib import targets) are excluded from the map; their count is surfaced in the header instead. Test files (per `detectFileRole` from `clients/file-role.ts` — `.test.`/`.spec.`/`__tests__/`/etc. path patterns) are ALSO excluded, along with their edges, and computed BEFORE degree/dependents/truncation ranking so they can never inflate a file's rank or count toward the node cap; their count is surfaced in the header too (`testFileCount`). Compiled twins are merged, not double-rendered: in compile-in-place projects the review graph's import resolution often lands on the compiled `X.js` sibling of an `X.ts` source, so when both exist the compiled node's symbols and ALL its edges remap onto the source node (`.js`→`.ts`/`.tsx`, `.mjs`→`.mts`, `.cjs`→`.cts`; edges that become self-referential after the merge collapse like other same-file edges; compiled files with no source twin — vendored deps, pure-JS projects — are untouched), with the merge count surfaced as `compiledTwinCount` in the header and command output. Untracked-gitignored files are excluded too — the set is computed by asking git itself (`ls-files --others --ignored --exclude-standard`, honoring git's semantic that a TRACKED file is never ignored, so committed vendored files matching an ignore pattern stay on the map; outside a git repo the filter degrades to a no-op), with twin-merge taking precedence over exclusion (an ignored compiled file whose source twin survives is merged, edges preserved, rather than dropped) and the dropped count surfaced as `ignoredFileCount`. The viewer ships four pure client-side interactions (no payload/schema changes, no CSP exceptions): search-to-highlight (case-insensitive substring over file paths, ring + dim-the-rest), a min-edge-weight slider (sub-threshold edges hide; nodes left with zero visible edges fade in place — positions never shift), label culling (labels render only when zoomed past 1.5× or for the top-25 files by transitive dependents), and path tracing (select a node, shift+click — or arm the "trace path" toggle and click — a second node to BFS their connection over UNDIRECTED edges, highlighting the chain; Esc/background-click clears). All four compose through one `recomputeVisibility` pass with documented precedence (trace > search > weight for opacity; culling only affects labels). A graph exceeding `PI_LENS_MAP_MAX_NODES` (default 500) files keeps only the highest-degree ones, with a visible truncation note in the page header. Security: every graph-derived string (file paths) reaches the page ONLY via a `<script type="application/json">` block (with `<`/`>`/`&` escaped to their unicode forms, so no substring can ever break out of the tag) read back and rendered through DOM `textContent`/`createElementNS` client-side — never string-concatenated into HTML markup (the #504-spike XSS-from-repo-content mitigation, since repo file names are attacker-influenceable in cloned repos). Written to `<project data dir>/reports/lens-map.html` (`getProjectDataDir`, never a hardcoded `.pi-lens`), notified via `ctx.ui.notify` with the absolute path — no browser auto-open. Unlike `module_report`'s read-only #256 contract, `/lens-map` DOES build the review graph on a cold cache (`buildOrUpdateGraph`) since the user explicitly asked for a map and a few seconds' build cost is acceptable for this non-hot-path command. Human-only: no agent tool, no MCP mirror. New `tests/clients/lens-map.test.ts` covers aggregation (symbol→file collapse + edge dedup/weighting, external-node exclusion+count, transitive-dependents BFS, degree-ranked truncation), layout (determinism, all-finite coordinates, empty-graph), rendering (script-tag-breakout escaping of a malicious file name, CSP meta + both theme accents present), and an end-to-end `generateLensMap` write against a temp `PILENS_DATA_DIR`.
|
|
118
|
+
- `lens_diagnostic_mark` tool (situational, activate via `pi_lens_activate_tools`): record a disposition for a diagnostics finding — `false-positive` / `suppress` (writes an inline `pi-lens-ignore` comment) / `defer` (session-only) / `flagged` (to fix, tagged in `lens_diagnostics mode=full`). Anchored by content, with the binding strength chosen per disposition — `false-positive` uses a site-specific anchor (rule + normalized message + the flagged line's own content hash, so it re-fires if that line is rewritten); `suppress`/`defer`/`flagged` use an intent-level anchor (rule + normalized message, no line hash) that survives edits elsewhere in the file. Honored by both the per-edit dispatch path and `lens_diagnostics mode=full`. Every mark is also appended to a machine-global NDJSON telemetry log (`~/.pi-lens/dispositions.log`, size-capped via `PI_LENS_DISPOSITION_LOG_MAX_BYTES`, records `previousDisposition` on re-marks and `defer` marks whose in-memory state has no other durable trace — the #181 false-positive rule-tuning signal) and published on pi's shared event bus as `pilens:diagnostic:disposition` (`v: 1`, fire-and-forget, honors the `PI_LENS_BUS_PUBLISH` kill switch) (#690).
|
|
119
|
+
- **`pilens:autofix:start` bus event, symmetric to `pilens:format:start`, so the same in-process listener can also see the `agent_end` actionable-warnings autofix batch before it mutates files** (#684) — follow-up to #673/#674: `clients/runtime-agent-end.ts`'s actionable-warnings autofix batch (gated by `getFlag("lens-actionable-warning-autofix")`) has the identical "runs later, unpredictably" timing shape as deferred formatting (reads a cached report, applies fixes as a batch well after the edit that produced the warnings), but only ever emitted `pilens:files:touched` (`reason: "autofix"`) AFTER the fact. `pilens:autofix:start` (`clients/format-events-publish.ts`, `{v, source, cwd, paths, fileCount, eligibleCount}`) now fires once per batch, right before `applyConservativeActionableWarningFixes` runs, but ONLY when the cached report is confirmed fresh (`checkActionableWarningsReportFresh`) AND has at least one autofix-eligible warning — mirroring `pilens:format:start`'s "only when there's genuine work" gate exactly, so a no-op batch never signals a start. `pipeline.ts`'s synchronous per-edit `runAutofix` (biome/ruff/eslint, awaited inside the `tool_result` hook) isn't deferred and doesn't have this race, so it doesn't get a `start` event.
|
|
120
|
+
- **Two new `pi.events` bus signals so an in-process extension can observe pi-lens's deferred-format queue before it mutates files, not just after** (#673) — `pilens:files:touched` (#482) only reports what changed AFTER deferred formatting at `agent_end` completes; there was no way for a same-process listener (e.g. a review/snapshot controller deriving an immutable candidate tree mid-turn) to know a file was queued for a formatter that hasn't run yet. `pilens:format:queued` (`clients/format-events-publish.ts`) fires once when a file NEWLY enters `RuntimeCoordinator`'s deferred-format pending queue (`{v, source, filePath, cwd, tool}`) — a second edit to an already-queued file before `agent_end` does not re-fire, avoiding spam. `pilens:format:start` fires once at the same moment as the existing `agent_end_deferred_format_start` latency-log phase, with the full pending file list (`{v, source, cwd, paths, fileCount}`), only when there's at least one file queued. Both follow the exact `pilens:files:touched` conventions: versioned frozen-additive payloads, fire-and-forget (bus failures swallowed, `dbg` invoked at most once per event type), logged via `logBusEvent`. This is visibility only — no synchronous flush/barrier API; that remains a separate future feature. `RuntimeCoordinator.deferFormat()` now returns a boolean (new-entry vs re-touch) so its caller (`clients/runtime-tool-result.ts`) knows when to publish.
|
|
121
|
+
- **`lens_diagnostics mode=full` and `lsp_diagnostics`'s batch/directory sweep now cache confirmed per-file LSP results across calls** (#671) — previously `runWorkspaceDiagnostics` re-touched EVERY file through the language server(s) on every call, with zero memory of a prior sweep: calling `mode=full` twice back-to-back with no edits in between paid the full LSP round-trip cost both times. A new persisted cache (`clients/lsp/workspace-diagnostics-cache.ts`, `<project-data-dir>/cache/lsp-workspace-diagnostics.json`) now lets a repeat sweep skip any file that hasn't changed since its last CONFIRMED touch, reusing the same per-file result instead. Invalidation is deliberately layered: (1) the file's own mtime must be unchanged; (2) when the project has a persisted reverse-dependency index (`clients/reverse-deps.ts`, already built by the cascade path), every file it imports must also be unchanged since the entry was recorded — closing the cross-file blind spot where file A's diagnostics can change purely because a dependency B's exported shape changed, with zero edits to A itself (no index available this session falls back to mtime-only, matching the existing cheap-tier `project-diagnostics.json` cache's accepted blind spot). An inconclusive/timed-out/errored touch is NEVER persisted as cacheable — reusing an unconfirmed result as "cached clean" would reintroduce the exact false-clean bug class #571/#630 already fixed for the live path. `runWorkspaceDiagnostics` and `tools/lsp-diagnostics.ts`'s batch/directory sweep (`mapWithConcurrency`) share ONE cache store, keyed by a `scopeKey` fingerprint of what each touch actually covers (client scope + excluded servers) so a file swept by either tool benefits the other's next sweep, without ever cross-serving an entry recorded under a different scope (the workspace sweep excludes opengrep via a separate CLI extractor; `lsp_diagnostics` doesn't). Survives a partial/aborted sweep gracefully — already-completed files' fresh results are still cached, and a corrupt/missing cache file fails open to "touch everything".
|
|
122
|
+
|
|
123
|
+
### Changed
|
|
124
|
+
|
|
125
|
+
- **`release.yml` publishes to npm via OIDC trusted publishing instead of a long-lived `NPM_TOKEN`, and `package.json` declares an `allowScripts` allowlist for npm v12's install-time script review** (closes #737) — follow-up to <https://github.blog/changelog/2026-07-08-npm-install-time-security-and-gat-bypass2fa-deprecation/>. The `publish-npm` job now requests a GitHub Actions OIDC token (`permissions.id-token: write`) and runs a bare `npm publish`, no `NODE_AUTH_TOKEN`/`secrets.NPM_TOKEN` involved — the trusted publisher (repo `apmantza/pi-lens`, workflow `release.yml`) was already registered on npmjs.com. The workflow's `has_npm_token` gate is gone entirely (OIDC needs no secret to check for); release/dry-run/smoke-load steps now gate purely on the existing version-tag-missing check. The publish job pins `npm@11.18.0` before installing (`npm install -g npm@11.18.0 --ignore-scripts`) since OIDC trusted publishing needs npm >= 11.5.1 and the Node 22 runner's bundled npm isn't guaranteed new enough — pinned to the latest 11.x rather than 12.x, whose stricter `engines.node` (`^22.22.2 || ^24.15.0 || >=26.0.0`) isn't guaranteed by the workflow's `node-version: 22` pin. Separately, `package.json` gains an `allowScripts` field (the field npm v12's `npm approve-scripts` manages, verified by reading npm's own `docs/content/commands/npm-approve-scripts.md` and `lib/utils/allow-scripts-writer.js` locally rather than guessed) approving the runtime/dev dependencies confirmed (via `npm view <pkg> scripts` and a real `npm approve-scripts --all` run) to have install-time lifecycle scripts: `@ast-grep/cli` (`postinstall`, a direct runtime dependency) plus `@google/genai`/`protobufjs` (transitive through the `@earendil-works/pi-coding-agent` devDependency, needed so the repo's own CI installs — which build `dist/` via `prepare` — don't get flagged). `@ast-grep/napi`, `web-tree-sitter`, `pidusage`, and `@earendil-works/pi-tui` were also audited and have no install-time scripts, so nothing was added for them. README's install section gains a short note on `npm approve-scripts` and git-dependency approval for npm v12 consumers.
|
|
126
|
+
- **Consolidated the hand-duplicated "walk up directories looking for one of a list of config filenames" loop across 4 sites** (closes #680) — `clients/opengrep-config.ts`'s `findLocalOpengrepConfig`, `clients/typos-config.ts`'s `findLocalTyposConfig`, `clients/zizmor-config.ts`'s `findLocalZizmorConfig`, and `clients/sgconfig.ts`'s `findLocalSgconfig` each independently hand-rolled the identical `walkUpDirs` + nested-filename-check loop. New `findLocalToolConfig(startDir, names)` in `clients/path-utils.ts`, alongside the file's other shared walk-up primitives (`walkUpDirs`, `findNearestContaining`, `findNearestMarkerRoot`), is the single source of truth; all 4 call sites are now one-line wrappers over it with their own filename list, keeping their existing public name/signature (so no caller elsewhere needed to change). Pure internal refactor, zero behavior change — deliberately does NOT touch `clients/tool-policy.ts`'s `has*Config` family (different shape: `boolean` return, tool-specific content-sniffing) or `clients/project-lens-config.ts`'s `discoverPiLensProjectConfig` (adds mtime caching), both out of scope per the issue. New unit tests for `findLocalToolConfig` in `tests/clients/path-utils.test.ts`; all 4 migrated sites' existing test suites pass unmodified.
|
|
127
|
+
- **Extracted `index.ts`'s `tool_call` handler (read-guard preflight: LSP auto-touch, read-range tracking/expansion, oldText autopatch, edit-guard block/relocation, pre-write duplicate-export detection) into `clients/runtime-tool-call.ts`** (closes #678) — pure refactor, no behavior change. `tool_call` was the one lifecycle hook whose logic still lived inline in the entry point while its three siblings (`session_start` → `clients/runtime-session.ts`, `tool_result` → `clients/runtime-tool-result.ts`, `turn_end`/`agent_end` → `clients/runtime-turn.ts`/`clients/runtime-agent-end.ts`) had already been extracted; at ~800 lines it was also the largest, and the most safety-critical (it mutates the agent's own edits — relocation/autopatch — before they reach disk), living in the file that's hardest to unit-test directly. New `handleToolCall(deps)` follows the exact same dependency-injection shape as its siblings; `index.ts`'s `pi.on("tool_call", ...)` registration is now a thin call site. Verified line-by-line against the original (diff of the extracted body vs. the pre-extraction source shows only the necessary renames/import-path fixes, no logic changes) plus the full existing test suite (including the dedicated `tests/index-integration.test.ts` `tool_call` coverage) passing unmodified. New `tests/clients/runtime-tool-call.test.ts` unit-tests `handleToolCall` directly against mocked deps (lensEnabled short-circuit, read-guard read recording + LSP auto-touch, zero-read edit block, write-then-edit read-guard interaction, duplicate-export write block).
|
|
128
|
+
- **Consolidated the hand-duplicated "load a JSON cache file, try/catch, version/shape-validate" boilerplate across 7 sites** (closes #676) — `clients/project-diagnostics/cache.ts` (two load functions), `clients/lsp/workspace-diagnostics-cache.ts`, `clients/cache-manager.ts` (three load sites: `readCache`, `isCacheFresh`, `readTurnState`), `clients/cache/rule-cache.ts`, `clients/project-snapshot.ts`, and `clients/session-state-store.ts` each independently wrote the same `JSON.parse(fs.readFileSync(path, "utf-8"))` → structural/version guard → `undefined`/`null`-on-any-throw shape. New `clients/json-cache-read.ts` extracts only the read/parse/try-catch mechanics into `readJsonCache`/`readJsonCacheAsync` (the latter for `session-state-store.ts`'s `fs.promises` call site), taking a `validate(parsed): T | undefined` callback plus an optional `onError` hook for the one site (`CacheManager.readCache`) that logs a read failure rather than silently swallowing it. Every site's own validation logic — including `rule-cache.ts`'s extra `ruleHash` field check, alongside the version check every other site has — is preserved exactly as its `validate` callback; this is pure boilerplate deduplication, not a behavior change. (`clients/generated-artifacts.ts`, listed in the issue, turned out on inspection to have no JSON-cache-read pattern at all — no code changed there.) New `tests/clients/json-cache-read.test.ts` unit-tests the shared helper directly (valid payload, corrupt JSON, valid-but-wrong-shape, missing file, `validate`/`onError` throwing); all pre-existing tests for the migrated sites pass unmodified.
|
|
129
|
+
|
|
130
|
+
### Fixed
|
|
131
|
+
|
|
132
|
+
- **`pilens_health`'s resource footprint no longer reports dead instances from stale instance-registry entries** (closes #735 — observed live during dogfooding: after a `pi` process was hard-killed, `pilens_health` kept reporting its pid as a live instance at 233MB RSS) — `computeResourceFootprint`/`getResourceFootprint` (`clients/instance-registry.ts`) aggregated whatever `~/.pi-lens/instances.json` entries existed, trusting each entry's heartbeat-cached `rssBytes`/`cpuPercent` with no pid-liveness check at all, so a killed/crashed instance kept inflating the headline instance count/RSS/CPU numbers until its entry aged out (up to `STALE_HEARTBEAT_MS`, 6h). Fix: `computeResourceFootprint` now takes an optional `isPidAlive` predicate and drops any instance whose pid it reports dead BEFORE aggregation (both `perInstance` and the totals — `pilens_health`'s text summary derives directly from `totalRssBytes`/`totalCpuPercent`/`instanceCount`, so no separate text-layer fix was needed); `getResourceFootprint` defaults that predicate to the #449/#525 reaper's own `realIsPidAlive` (`clients/instance-reaper.ts`) — reused rather than reimplemented, per that function's own docstring precedent (`clients/lsp-budget.ts`) — and opportunistically (fire-and-forget, best-effort) prunes newly-dead pids' entries from the on-disk registry too, a bonus cleanup layered on top of, not a replacement for, the scheduled reaper sweep. Dropped rather than flagged `stale: true`: unlike heartbeat staleness (which the reaper deliberately treats as "maybe idle-but-alive" and never uses to hide/remove anything), a pid-confirmed-dead instance is unambiguous, and every existing caller already expects a footprint of currently-live instances. No pid-reuse identity check (unlike the reaper's child-pid `matchProcess`): `InstanceEntry` never recorded the host's own command line to verify against (the same gap #525 documented for the reaper's PARENT pid and deliberately left unfixed there), and a health-report false positive is a far smaller blast radius than the reaper's own mistaken-kill risk. Both parameters are optional and default to prior (unfiltered) pure behavior when omitted, so every pre-#735 caller/test is unaffected. New tests in `tests/clients/instance-registry.test.ts`: `computeResourceFootprint` drops a dead-pid instance from both `perInstance` and the totals when `isPidAlive` is supplied, and applies no filtering when it's omitted; `getResourceFootprint` excludes a dead-pid instance end-to-end and opportunistically prunes it from the registry file, while leaving the registry untouched when every pid is alive.
|
|
133
|
+
- **The shared project ignore matcher is now tracked-aware, so a git-tracked file whose name happens to match a `.gitignore`/global ignore pattern is no longer invisible to every scan and to the review graph** (closes #703 — the residual documented-but-out-of-scope gap left by #694/#701) — `getProjectIgnoreMatcher` (`clients/file-utils.ts`) applied ignore *patterns* with no knowledge of what git actually tracks, but git's own rule is that ignore rules only ever apply to UNTRACKED files. Concretely: pi-lens's own tracked `clients/test-runner-client.ts` matches `.gitignore`'s `test-*.ts` pattern (meant for scratch test files) and was silently dropped from every walk — the review graph never parsed it and instead materialized a phantom `clients/test-runner-client.js` node (0 symbols, no content hash) standing in for its 5 real importers, blacking out `who-uses-this`/`blastRadius` for that file. Fix: each `GitignorePattern` is now tagged with the layer it came from (`global`/`gitignore`/`pilens`, precedence order unchanged). A winning positive match from the `global` or `gitignore` layers — both of which emulate git — now inherits git's "a tracked file is never ignored" rule and is rescued via a new async, `git ls-files`-backed tracked-files set (`clients/git-tracked-ignore.ts`'s new `collectTrackedFiles`, mirroring #701's `collectUntrackedIgnoredIds` shape exactly: memoized per-root with a 30s TTL, fail-open to `undefined` on no-git/spawn-failure/timeout). A winning match from the `pilens` layer (`.pi-lens.json`'s `ignore` field) is pi-lens-native intent, not a git emulation, and keeps excluding regardless of tracked status. Since `isIgnored` must stay synchronous and hot (called per file in every walk), the tracked-set lookup is a synchronous read of a snapshot the async fetch populates as a side effect; a new `ProjectIgnoreMatcher.ensureTrackedIndex(): Promise<void>` lets an async walker prime that snapshot ONCE per walk (not per file) before its loop starts. Wired into every async walk entry point with a real loop: `startup-scan.ts` (source-file counting), `language-profile.ts` (warmup language detection), `source-filter.ts`'s `collectSourceFilesAsync` (the shared collector behind the word-index build, `getSourceFilesAsync`, and most background scanners), `pipeline.ts`'s `snapshotProjectFiles` (autofix change detection), and `lsp/index.ts`'s workspace-diagnostics sweep walk. Sync-only consumers (`collectSourceFiles`, `jscpd-client.ts`, `tree-sitter-client.ts`'s structural search, `review-graph/workspace-modules.ts`, `project-scan-policy.ts`, `dispatch/integration.ts`'s per-edit cascade-neighbor check) are left unprimed and degrade to today's pattern-only behavior by design — the seam is opt-in, never silently forced onto a hot synchronous path. `REVIEW_GRAPH_VERSION` bumped `v5` → `v6`: a persisted v5 graph is missing every tracked-but-pattern-matched file's real node and instead carries a phantom compiled-artifact node in its place, so it's rejected and rebuilt clean rather than merged with newly-walked v6 nodes. New `tests/clients/tracked-aware-ignore-matcher.test.ts` (real-git-repo fixtures, following #701's pattern): a tracked file matching a `.gitignore` pattern is rescued once primed and stays excluded when never primed (documents the fail-open default); a `.pi-lens.json` match still excludes a tracked file; an untracked file matching `.gitignore` is unaffected; negation still wins last-match-wins across layers; a non-git directory degrades to pattern-only without throwing; cross-separator (`/` vs `\`) paths resolve to the same verdict; and an end-to-end `collectSourceFilesAsync` walk over a fixture repo includes the tracked pattern-matched file.
|
|
134
|
+
- **The startup scan-context walk no longer re-runs and discards itself on every single `pi` process start in a large monorepo** (closes #699 — bisected and reported by @aaronkyriesenbach, who traced a consistent ~17s-per-startup cost via a package-toggle bisection down to `resolveStartupScanContextAsync()`) — `resolveStartupScanContext(Async)` (`clients/startup-scan.ts`) already early-exited its counting walk as soon as the running count passed `MAX_STARTUP_SOURCE_FILES` (2000; `if (count > limit) return count;`, predating this issue), so the 17s wasn't a missing early exit — it was that nothing ever survived the walk to inform the NEXT process. The interactive full-mode path (`clients/runtime-session.ts`) only reused a `too-many-source-files` verdict when `project-snapshot.json`'s seq-based freshness matched, but the background warmup timer used by every `-p`/`--print` (quick-mode) invocation — the ONLY place a one-shot process ever reaches scan-context, since quick mode returns before the synchronous path touches it — never persisted the verdict at all, for either outcome; a one-shot process exits right after computing it, so the walk (and, per the reporter's bisection, the ~2s warmup-delay `setTimeout` that keeps the process alive to run it) recomputed and discarded the result on every single startup, `--no-lens` included (that flag never reaches the quick-mode gate). Fix: (1) the background warmup now checks `project-snapshot.json` for a still-fresh persisted verdict BEFORE walking, and unconditionally persists whatever it computes (`canWarmCaches` true or false) afterward via the existing `saveRuntimeProjectSnapshot`/`startupScan` field, mirroring the interactive path instead of duplicating a parallel cache file; (2) a new `computedAt` timestamp on `StartupScanContext` plus `isStartupScanVerdictFresh` (TTL-gated via `PI_LENS_STARTUP_SCAN_VERDICT_TTL_MS`, default 24h, lazy-memoized) covers the one case the existing seq-based freshness check can't: a `too-many-source-files` verdict never advances the snapshot's seq on its own (pi-lens writes nothing while `canWarmCaches` is false), so without a TTL a repo that later shrinks below the threshold would stay stuck on a stale negative verdict forever — the interactive full-mode path now applies the same TTL gate. Corrupt/missing cache files fail open via the existing `readJsonCache` plumbing (re-walk, never throw); cache writes are best-effort via `saveRuntimeProjectSnapshot`'s own try/catch, and never block startup. Measured on this repo (742 source files, `canWarmCaches:true`, so the same walk+persist code path the `too-many-source-files` branch shares): a cold in-process walk costs ~3.7s; a second in-process call hits the existing per-process memo at ~0ms; a genuinely separate process reading the persisted verdict off disk (simulating the next `pi` startup) costs ~0.7ms — the 17s monorepo repro itself wasn't reproduced here (no monorepo of that shape available), but the mechanism is identical. New `tests/clients/startup-scan-verdict-cache.test.ts` (TTL getter + env override + memoization, `isStartupScanVerdictFresh` fresh/expired/missing-`computedAt`/non-TTL'd-reasons, `computedAt` stamping, and a pinning test for the pre-existing early-exit-at-limit+1 behavior) and `tests/clients/runtime-session-scan-cache.test.ts` (full `handleSessionStart` integration: cold walk persists and a second call reuses it without re-invoking `resolveStartupScanContext`, an expired verdict re-walks, a corrupt snapshot fails open).
|
|
135
|
+
- **Review graph import resolution now prefers `.ts`/`.tsx` (and `.mts`/`.cts`) source twins over compiled `.js`/`.mjs`/`.cjs` siblings, and node creation is gated against untracked-AND-gitignored files** (closes #694) — on a compile-in-place repo (pi-lens's own build included) EVERY cross-file import edge was landing on the compiled artifact node instead of the source: measured on pi-lens's own graph, `clients/dispatch/types` had 90 import edges / 84 distinct importer files all on `types.js` and **zero** on `types.ts`, blacking out `module_report`'s `blastRadius`/`usedBy`, the impact cascade, and word-index priors for the dispatch system's central type hub. Root cause: both `resolveJsTs` (`clients/review-graph/import-resolvers.ts`, the cold `module_report` path) and `localImportToFile` (`clients/review-graph/builder.ts`, the warm graph-build path) tried the LITERAL specifier path (`./foo.js`) as their FIRST existence-checked candidate — since Node's ESM resolver requires the runtime `.js` extension even for a `.ts` source, that candidate almost always existed on disk (the compiled sibling) and won before the `.ts`/`.tsx` candidates further down the list were ever tried. Fix: a new shared `jsTsCandidatePaths` (import-resolvers.ts) tries the source twin (`.mts` for an `.mjs` specifier, `.cts` for `.cjs`, else `.ts`/`.tsx`) BEFORE the literal/compiled extension, used by both resolvers so the cold and warm paths can never diverge on which twin wins; falls back to the compiled file unchanged when no source twin exists (pure-JS projects, vendored `.js`). Separately, import-resolution-created file nodes (`addJsTsFile`'s local-import target, the tree-sitter `resolveImportToFiles` loop, and cxx `#include` targets) previously had NO ignore-awareness at all — an untracked-AND-gitignored build artifact with no surviving source twin still became a first-class graph node. New `clients/git-tracked-ignore.ts` (extracted from #679's `/lens-map`, which now re-uses it too) computes the untracked-AND-ignored id set via `git ls-files --others --ignored --exclude-standard` — respecting THE critical git semantic that a tracked file is never "ignored" even when a `.gitignore` pattern matches it (pi-lens's own tracked `clients/deps/*.js` vendored sources match the repo's `*.js` pattern and must stay graph nodes) — memoized per-cwd with a 30s time bound so the hot per-edit/cascade rebuild path shares one `git` spawn instead of paying for one per file/per edit; any resolution failure (non-git cwd, git absent) degrades to `undefined` and every caller skips the filter rather than guessing via a tracked-unaware pattern matcher. `REVIEW_GRAPH_VERSION` bumped `"v4"` → `"v5"` (same safe-rebuild pattern as the v2→v3/#260 and v3→v4/#655 bumps): a persisted v4 graph from a compile-in-place project has edges materialized on compiled artifact nodes throughout, so it's rejected and rebuilt clean rather than merged with newly-built v5 edges. Residual, documented but not fixed here: the INITIAL file walk (`getGraphSourceFiles` → `collectProjectSourceFilesAsync` → the pattern-only `getProjectIgnoreMatcher`) is still tracked-unaware and can wrongly exclude a tracked file whose name matches an ignore pattern (e.g. a tracked `test-*.ts` file matching a `test-*.ts` ignore rule) — fixing that needs the same async tracked-check threaded through a widely-shared, currently-synchronous matcher, which is out of scope for this change. Verified on pi-lens's own graph: `clients/dispatch/types.ts` now carries all 90 import edges / 84 importers (was 0), `types.js` carries 0 (was 90), and `computeTransitiveImpact` from `types.ts` now returns 130 dependents (was an empty/blacked-out result). New tests: `tests/clients/review-graph/import-resolvers.test.ts` (twin preference across `.js`/`.mjs`/`.cjs`, fallback-when-no-twin, extensionless specifiers); `tests/clients/git-tracked-ignore.test.ts` (tracked-vs-ignored set computation, no-git degrade, TTL memoization); `tests/clients/review-graph.service.test.ts` (real-git-repo ignore-gated node creation — an ignored untracked target never becomes a node while a tracked file matching the same pattern does; the v4→v5 migration rebuild).
|
|
136
|
+
- **Scan-provenance labels no longer poison a diagnostic's dedup identity, and the workspace sweep now honors ast-grep's `skipTestFiles` gate like the per-edit path does** (closes #692) — found via live dogfooding (`pi-drykiss`): a `lens_diagnostics mode=full` run reported 726 warnings, largely the same findings twice. Root cause: `convertLspDiagnostics` (`clients/dispatch/utils/lsp-diagnostics.ts`) baked a caller-supplied `source` option straight into `rule` (`${source}:${code}`) — three "reconcile this scan's fresh results into widget state" call sites (`tools/lens-diagnostics.ts`'s footer reconcile, `tools/lsp-diagnostics.ts`'s footer reconcile) passed a scan-provenance label (`"lens_diagnostics_full"`, `"lsp_diagnostics"`) there, silently clobbering the diagnostic's REAL source (e.g. `ast-grep`) in the field that defines its identity. A per-edit write of the same finding kept rule `ast-grep:<id>`, so `normalizeRuleForDedup`'s prefix strip (which only knows about `ast-grep:`) matched one write but not the other, producing two divergent dedup keys for one finding — and the same corruption broke inline `pi-lens-ignore: <id>` suppression for scan-written entries and lost their tool provenance (`tool` stayed `"lsp"` instead of e.g. `"ast-grep"`, since the per-edit-only aux re-tag loop never ran on the scan path). Fix: the `source` override is gone from `rule` derivation entirely (`rule` now always comes from the diagnostic's own `d.source ?? tool`); scan provenance, where still wanted, now lives in a new dedicated `Diagnostic.scanOrigin` field that is purely informational and never read by identity/dedup/suppression logic. The per-edit dispatch runner's (`clients/dispatch/runners/lsp.ts`) aux re-tag logic (tool id + semantic policy + defect class + native-suppression + `skipTestFiles`) is now a shared helper, `retagAuxiliaryDiagnostics` (`clients/dispatch/auxiliary-lsp.ts`), used by both the per-edit path and the two scan-reconcile call sites, so a scan-written aux finding gets identical treatment instead of keeping tool `"lsp"`. Separately, `clients/lsp/index.ts`'s workspace sweep applied `applyAuxiliarySuppressions` to every file's results but never passed a `fileRole`, so ast-grep's `skipTestFiles` gate (#687/#688 — added only to the per-edit merge loop) never applied to a `mode=full` sweep, re-surfacing every ast-grep finding on `*.test.ts` files wholesale; `applyAuxiliarySuppressions` now takes an optional `{fileRole}` third argument (all existing 2-arg callers unaffected), wired at the sweep's call site via `detectFileRole`, and the same gap was found and fixed in `tools/lsp-diagnostics.ts`'s own standalone-query suppression call via grep-siblings. Three `convertLspDiagnostics(..., {source: "cascade"})` call sites in `clients/dispatch/integration.ts` (cascade neighbor diagnostics — ephemeral display output, never reconciled into persisted widget/dedup state) had the override dropped rather than migrated, since it had no remaining purpose once identity no longer reads it. New tests: `tests/clients/dispatch/utils/lsp-diagnostics.test.ts` (dedup-key/rule equality between per-edit and scan-origin conversions of the same finding, `scanOrigin` present only on the scan path and never leaking into `rule`/`id`); `tests/clients/dispatch/auxiliary-lsp.test.ts` gains `retagAuxiliaryDiagnostics` coverage (matches the per-edit runner's prior behavior exactly) and `applyAuxiliarySuppressions`'s new `fileRole` gate; `tests/clients/dispatch/inline-suppressions.test.ts` gains a round-trip suppression test for a scan-written entry.
|
|
137
|
+
- **`oxlint` no longer skips test files** (refs #576) — its `skipTestFiles: true` (bare, no confirmed-FP rationale attached — the original comment was a generic "test files often use patterns that trigger false positives" assumption, unlike the confirmed-FP class that justifies ast-grep's skip, see #687) was inconsistent with every other correctness-oriented runner in the dispatch set: type checkers (mypy/phpstan/detekt/dart-analyze/dotnet-build/javac/etc.) and the primary language servers (typescript-lsp/pyright-lsp/rust-lsp) never skip test files, since a real correctness bug in a test is still a bug. oxlint catches genuine correctness issues, not just style, so it now follows that precedent. New test in `tests/clients/dispatch/runners/oxlint.test.ts` asserts `skipTestFiles: false`.
|
|
138
|
+
- **`no-javascript-url` ast-grep rule no longer flags a `javascript:` string literal used as adversarial test input, and ast-grep's own `skipTestFiles` policy now survives when its LSP surface (not just the in-process napi runner) is active** (closes #687) — two related false-positive fixes found via the same live dogfooding pass on `pi-free` that surfaced #686. (1) `rules/ast-grep-rules/rules/no-javascript-url(-js).yml` matched any string literal starting with `"javascript:`, with no check on how it was used — `expect(openBrowser("javascript:alert(1)")).toBe(false)` (a test asserting the guard REJECTS the URL) matched identically to a real `a.href = "javascript:..."` sink. Fix: exclude literals whose nearest enclosing call is `expect`/`it`/`test`/`describe` (or a `.only`/`.skip`/`.each` variant) — the existing bare `const u = "javascript:..."` invalid fixture (flagged regardless of downstream use) is intentionally preserved, so this narrows only the test-framework-wrapped case, not the rule's broader intent. (2) `clients/dispatch/runners/ast-grep-napi.ts` already declares `skipTestFiles: true`, but that only applies to the in-process napi fallback runner — when the ast-grep LSP binary is installed (the common path), that runner skips entirely in favor of `clients/dispatch/runners/lsp.ts`'s auxiliary-diagnostic merge (`clients/dispatch/auxiliary-lsp.ts`'s `AUXILIARY_LSP_PROFILES`), which had no test-file gating at all — confirmed live: `cline-xml-bridge.test.ts`/`open-browser.test.ts` (pi-free) both got ast-grep findings despite being test files. New `AuxiliaryLspProfile.skipTestFiles` field, set only on the `ast-grep` profile (opengrep/zizmor/typos keep running on test files — a hardcoded secret or typo in a test fixture is still worth flagging), gated in `lsp.ts`'s merge loop on `ctx.fileRole === "test"`. New/updated tests: `no-javascript-url(-js)-test.yml` fixtures for the `expect`/`it`/`test.each` cases; `tests/clients/dispatch/auxiliary-lsp.test.ts` asserts only the ast-grep profile sets `skipTestFiles`; `tests/clients/dispatch/runners/runner-status-semantics.test.ts` covers the merge loop dropping ast-grep (but not opengrep) findings on a `fileRole: "test"` file, and keeping them on a regular source file.
|
|
139
|
+
- **`find-import-file-without-extension` ast-grep rule no longer flags bare/scoped package specifiers with a subpath** — found via live dogfooding on 2026-07-16 (no separate tracking issue; a contained rule fix). The rule's regex only checked whether a string's final path segment lacked a file extension; it never checked whether the string was actually a RELATIVE import (`./`/`../`) at all. A package specifier with a subpath — `@earendil-works/pi-ai/compat`, `lodash/debounce` — has a `/` before its final segment too, so it matched the identical shape as a genuinely broken relative import like `./utils`. The existing test fixture only covered a slash-less bare specifier (`"package"`), never a scoped/deep one, so the gap went unnoticed. Fix: `rules/ast-grep-rules/rules/find-import-file-without-extension.yml` now requires the string to start with `./` or `../` (`^\.\.?/`) before applying the missing-extension check. New fixtures cover scoped (`@scope/pkg/sub`), deep bare (`lodash/debounce`), and parent-relative (`../sibling/util`) cases; all 251 ast-grep rule-test fixtures still pass.
|
|
140
|
+
- **Cold LSP servers now actually get their full warm-up budget instead of being silently capped at their normal per-edit timeout** — found via live dogfooding on 2026-07-15, immediately after #667/#668's `ensureWarmForSweep` shipped (no separate tracking issue filed; this is a direct regression-in-effect follow-up to that PR, referenced here rather than under a new issue number). `ensureWarmForSweep` (`clients/lsp/index.ts`) is supposed to give a genuinely cold server (mid-launch, no index built yet) a generous one-time budget to become ready — it defaults to `warmupTimeoutMs()` (20000ms) and passes that as `touchFile`'s `maxClientWaitMs`/`maxDiagnosticsWaitMs`. But `touchFile`'s `perServerTimeout` resolves that caller cap as a CEILING (`Math.min(callerCap, strategy.aggregateWaitMs)`) — deliberate and correct for the normal per-edit dispatch path (#242, so a slow strategy can't blow past the pipeline's cap), but exactly backwards for a cold warm-up call, which needs MORE time than the server's normal warm-state budget, not less. The effective warm-up deadline silently collapsed to whatever the server's steady-state `aggregateWaitMs` is (e.g. 1000ms for typescript) regardless of the 20000ms actually requested — confirmed against live logs (`lsp_sweep_warmup_start timeoutMs:20000` immediately followed by `lsp_diagnostics_timeout ... timeoutMs:1000 durationMs:1015`), defeating the warm-up feature entirely for any server whose normal per-edit budget is short. Fix: a new `LSPTouchFileOptions.warmupOverride` flag, set only by `ensureWarmForSweep`'s own `touchFile` call, makes `perServerTimeout` treat the caller cap as a FLOOR instead (`Math.max(callerCap, strategyWait)`) — every other caller (per-edit dispatch, cascade touches) leaves it unset and keeps the exact pre-existing ceiling-only semantics. New regression test in `tests/clients/lsp/sweep-warmup.test.ts` asserts a cold server's `waitForDiagnostics` call actually receives the full requested 20000ms budget, not typescript's 1000ms `aggregateWaitMs`.
|
|
141
|
+
|
|
13
142
|
## [3.8.70] - 2026-07-14
|
|
14
143
|
|
|
15
144
|
### Added
|
package/README.md
CHANGED
|
@@ -18,6 +18,10 @@ pi-lens gives AI coding agents fast, language-aware feedback while they write/ed
|
|
|
18
18
|
- Review-graph intelligence for supported languages via bundled tree-sitter WASMs
|
|
19
19
|
- Ranked identifier search (`symbol_search`) over an always-warm word index,
|
|
20
20
|
feeding the discovery funnel (symbol_search → module_report → read_symbol)
|
|
21
|
+
- Diagnostic triage (`lens_diagnostic_mark`): findings can be marked
|
|
22
|
+
false-positive, suppressed in source, deferred, or flagged-to-fix — honored
|
|
23
|
+
across all surfaces
|
|
24
|
+
- `/lens-map` — interactive HTML dependency map of the project
|
|
21
25
|
- Read-guard and edit-autopatch support to reduce bad edits
|
|
22
26
|
- Background security/dependency scans for opted-in projects
|
|
23
27
|
|
|
@@ -33,6 +37,14 @@ Or from git:
|
|
|
33
37
|
pi install git:github.com/apmantza/pi-lens
|
|
34
38
|
```
|
|
35
39
|
|
|
40
|
+
> **npm v12 users:** dependency lifecycle scripts (e.g. `@ast-grep/cli`'s
|
|
41
|
+
> `postinstall`) now require explicit approval — if `npm install` warns about
|
|
42
|
+
> unreviewed install scripts, review and allow them with
|
|
43
|
+
> `npm approve-scripts`, or trust the `allowScripts` entries already declared
|
|
44
|
+
> in this package's `package.json`. Installing from a git source (`pi install
|
|
45
|
+
> git:...` / `pi update --extension git:...`) may similarly prompt for
|
|
46
|
+
> git-dependency approval; accept it to let the `prepare` build step run.
|
|
47
|
+
|
|
36
48
|
## Documentation
|
|
37
49
|
|
|
38
50
|
- [Agent tools](docs/agent-tools.md) — pi tool names, scopes, and arguments
|
|
@@ -42,6 +54,8 @@ pi install git:github.com/apmantza/pi-lens
|
|
|
42
54
|
- [Word index](docs/word-index.md) — identifier search (`symbol_search`) and
|
|
43
55
|
the discovery funnel
|
|
44
56
|
- [Tools and commands](docs/tools.md) — runtime flags and slash commands
|
|
57
|
+
- [Diagnostic dispositions](docs/dispositions.md) — triage: false-positive,
|
|
58
|
+
suppress, defer, flagged-to-fix
|
|
45
59
|
- [Configuration](docs/globalconfig.md) — global and project config files
|
|
46
60
|
- [Environment variables](docs/env_variables.md) — common env vars and full
|
|
47
61
|
reference link
|
|
@@ -170,6 +184,16 @@ Thanks goes to these wonderful people:
|
|
|
170
184
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xificurC"><img src="https://avatars.githubusercontent.com/u/4800719?v=4" width="100px;" alt=""/><br /><sub><b>Peter Nagy</b></sub></a><br /><a href="#bug-xificurC" title="Bug reports">🐛</a></td>
|
|
171
185
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/orest-tokovenko-block"><img src="https://avatars.githubusercontent.com/u/190554467?v=4" width="100px;" alt=""/><br /><sub><b>Orest Tokovenko</b></sub></a><br /><a href="#bug-orest-tokovenko-block" title="Bug reports">🐛</a></td>
|
|
172
186
|
<td align="center" valign="top" width="14.28%"><a href="https://eisterman.dev/"><img src="https://avatars.githubusercontent.com/u/3028953?v=4" width="100px;" alt=""/><br /><sub><b>Federico Pasqua</b></sub></a><br /><a href="#bug-eisterman" title="Bug reports">🐛</a></td>
|
|
187
|
+
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ymek"><img src="https://avatars.githubusercontent.com/u/300569?v=4" width="100px;" alt=""/><br /><sub><b>Myke Stubbs</b></sub></a><br /><a href="#code-ymek" title="Code">💻</a> <a href="#bug-ymek" title="Bug reports">🐛</a></td>
|
|
188
|
+
<td align="center" valign="top" width="14.28%"><a href="https://github.com/black-snow"><img src="https://avatars.githubusercontent.com/u/579733?v=4" width="100px;" alt=""/><br /><sub><b>Ronald</b></sub></a><br /><a href="#bug-black-snow" title="Bug reports">🐛</a></td>
|
|
189
|
+
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JSap0914"><img src="https://avatars.githubusercontent.com/u/116227558?v=4" width="100px;" alt=""/><br /><sub><b>JSap0914</b></sub></a><br /><a href="#code-JSap0914" title="Code">💻</a></td>
|
|
190
|
+
</tr><br />
|
|
191
|
+
<tr>
|
|
192
|
+
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AngriestBird"><img src="https://avatars.githubusercontent.com/u/12663269?v=4" width="100px;" alt=""/><br /><sub><b>Kenneth McCormick</b></sub></a><br /><a href="#bug-AngriestBird" title="Bug reports">🐛</a></td>
|
|
193
|
+
<td align="center" valign="top" width="14.28%"><a href="https://github.com/knthmn"><img src="https://avatars.githubusercontent.com/u/60438008?v=4" width="100px;" alt=""/><br /><sub><b>Kent Hou Man</b></sub></a><br /><a href="#code-knthmn" title="Code">💻</a> <a href="#bug-knthmn" title="Bug reports">🐛</a></td>
|
|
194
|
+
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aaronkyriesenbach"><img src="https://avatars.githubusercontent.com/u/12665860?v=4" width="100px;" alt=""/><br /><sub><b>Aaron Ky-Riesenbach</b></sub></a><br /><a href="#bug-aaronkyriesenbach" title="Bug reports">🐛</a></td>
|
|
195
|
+
<td align="center" valign="top" width="14.28%"><a href="https://github.com/LovelyLoong"><img src="https://avatars.githubusercontent.com/u/54889641?v=4" width="100px;" alt=""/><br /><sub><b>可爱的龙</b></sub></a><br /><a href="#bug-LovelyLoong" title="Bug reports">🐛</a></td>
|
|
196
|
+
<td align="center" valign="top" width="14.28%"><a href="https://github.com/pppobear"><img src="https://avatars.githubusercontent.com/u/21952175?v=4" width="100px;" alt=""/><br /><sub><b>pppobear</b></sub></a><br /><a href="#bug-pppobear" title="Bug reports">🐛</a></td>
|
|
173
197
|
</tr>
|
|
174
198
|
</tbody>
|
|
175
199
|
</table>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared atomic tmp+rename file writer (closes #762).
|
|
3
|
+
*
|
|
4
|
+
* The `${target}.tmp-${process.pid}` + `renameSync` shape was independently
|
|
5
|
+
* hand-rolled in five places (`instance-registry.ts`, `session-state-store.ts`,
|
|
6
|
+
* `recent-touches.ts`, `review-graph/builder.ts`, `diagnostic-dispositions.ts`)
|
|
7
|
+
* as each one picked up the need for a cross-process reader to never observe a
|
|
8
|
+
* partially-written file: `rename()` replaces the destination atomically on
|
|
9
|
+
* both POSIX and Windows (libuv uses `MOVEFILE_REPLACE_EXISTING`), so a
|
|
10
|
+
* concurrent reader always sees either the fully-old or fully-new file, never
|
|
11
|
+
* a torn write. Five independent copies of the same shape invite drift (e.g.
|
|
12
|
+
* forgetting the tmp-file cleanup on the failure path) — this module is the
|
|
13
|
+
* single implementation the rest re-use.
|
|
14
|
+
*
|
|
15
|
+
* Per-write-site error policy varies and is NOT a detail this helper should
|
|
16
|
+
* paper over:
|
|
17
|
+
*
|
|
18
|
+
* - `bestEffort: true` (the default, and the majority of today's sites): a
|
|
19
|
+
* write or rename failure just means this update is lost — swallow it,
|
|
20
|
+
* after a best-effort tmp-file cleanup. Appropriate for observability
|
|
21
|
+
* substrate (instance registry, recent-touches, review-graph cache,
|
|
22
|
+
* session widget state) where a dropped write is, at worst, a stale/
|
|
23
|
+
* missing sample.
|
|
24
|
+
* - `bestEffort: false`: rethrow the failure (after the same best-effort tmp
|
|
25
|
+
* cleanup). This is `clients/diagnostic-dispositions.ts`'s policy (#757):
|
|
26
|
+
* unlike the writers above, a disposition mark is functionally
|
|
27
|
+
* load-bearing — a silently lost mark is a correctness bug, not a dropped
|
|
28
|
+
* observability sample — so callers must see the failure rather than have
|
|
29
|
+
* it swallowed. See the #757 CHANGELOG entry for the full reasoning.
|
|
30
|
+
*/
|
|
31
|
+
import * as fs from "node:fs";
|
|
32
|
+
/**
|
|
33
|
+
* Synchronous atomic write: `fs.writeFileSync` to `${targetPath}.tmp-${pid}`,
|
|
34
|
+
* then `fs.renameSync` over `targetPath`. On any failure (from either step),
|
|
35
|
+
* attempts a best-effort `fs.rmSync(tmp, { force: true })` cleanup, then
|
|
36
|
+
* either swallows (default) or rethrows per `options.bestEffort`.
|
|
37
|
+
*
|
|
38
|
+
* Does not create the parent directory — callers that need one must
|
|
39
|
+
* `mkdirSync` before calling this (matches every existing call site, which
|
|
40
|
+
* already does its own mkdir as a separate step).
|
|
41
|
+
*/
|
|
42
|
+
export function writeFileAtomic(targetPath, data, options) {
|
|
43
|
+
const bestEffort = options?.bestEffort ?? true;
|
|
44
|
+
const tmpPath = `${targetPath}.tmp-${process.pid}`;
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(tmpPath, data, "utf-8");
|
|
47
|
+
fs.renameSync(tmpPath, targetPath);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
try {
|
|
51
|
+
fs.rmSync(tmpPath, { force: true });
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// ignore — best-effort cleanup of our own tmp file
|
|
55
|
+
}
|
|
56
|
+
if (!bestEffort)
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Async counterpart of {@link writeFileAtomic}, built on `fs.promises` instead
|
|
62
|
+
* of the sync `fs` API — for call sites that must not block the event loop
|
|
63
|
+
* (e.g. writes on a hot per-turn/per-touch path). Same tmp-naming, cleanup,
|
|
64
|
+
* and `bestEffort` semantics.
|
|
65
|
+
*/
|
|
66
|
+
export async function writeFileAtomicAsync(targetPath, data, options) {
|
|
67
|
+
const bestEffort = options?.bestEffort ?? true;
|
|
68
|
+
const tmpPath = `${targetPath}.tmp-${process.pid}`;
|
|
69
|
+
try {
|
|
70
|
+
await fs.promises.writeFile(tmpPath, data, "utf-8");
|
|
71
|
+
await fs.promises.rename(tmpPath, targetPath);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
try {
|
|
75
|
+
await fs.promises.rm(tmpPath, { force: true });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// ignore — best-effort cleanup of our own tmp file
|
|
79
|
+
}
|
|
80
|
+
if (!bestEffort)
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Persistent NDJSON trace of `pi.events` bus publish attempts
|
|
3
|
-
* (`pilens:files:touched` #482 / `pilens:diagnostics` #502
|
|
3
|
+
* (`pilens:files:touched` #482 / `pilens:diagnostics` #502 /
|
|
4
|
+
* `pilens:format:queued` + `pilens:format:start` #673).
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
* fire-and-forget: on failure or on a
|
|
7
|
-
* switch off) they only invoke an
|
|
8
|
-
* host and is a documented no-op in
|
|
9
|
-
* `dbg: noop`). That leaves
|
|
10
|
-
*
|
|
11
|
-
* session_start incident this repo
|
|
6
|
+
* All four producers (clients/bus-publish.ts, clients/diagnostics-publish.ts,
|
|
7
|
+
* clients/format-events-publish.ts) are fire-and-forget: on failure or on a
|
|
8
|
+
* structural no-op (never wired, kill switch off) they only invoke an
|
|
9
|
+
* optional `dbg` callback, which varies by host and is a documented no-op in
|
|
10
|
+
* the MCP host (clients/mcp/session.ts's `dbg: noop`). That leaves
|
|
11
|
+
* bus-publish outcomes invisible in exactly the context where they matter
|
|
12
|
+
* most — same failure shape as the #544 MCP session_start incident this repo
|
|
13
|
+
* already fixed once.
|
|
12
14
|
*
|
|
13
15
|
* This module gives bus events the same durable trace every other pi-lens
|
|
14
16
|
* subsystem already has (latency.log, cascade.log, read-guard.log, ...) —
|
|
@@ -8,6 +8,7 @@ import * as crypto from "node:crypto";
|
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import { getProjectDataDir } from "../file-utils.js";
|
|
11
|
+
import { readJsonCache } from "../json-cache-read.js";
|
|
11
12
|
const CACHE_VERSION = "v3";
|
|
12
13
|
export class RuleCache {
|
|
13
14
|
cacheFile;
|
|
@@ -36,12 +37,15 @@ export class RuleCache {
|
|
|
36
37
|
this.ensureCacheDir();
|
|
37
38
|
if (!fs.existsSync(this.cacheFile))
|
|
38
39
|
return null;
|
|
39
|
-
const cached = JSON.parse(fs.readFileSync(this.cacheFile, "utf-8"));
|
|
40
40
|
const currentHash = this.computeRuleHash(ruleFiles);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
const cached = readJsonCache(this.cacheFile, (parsed) => {
|
|
42
|
+
const entry = parsed;
|
|
43
|
+
if (entry.version !== CACHE_VERSION || entry.ruleHash !== currentHash) {
|
|
44
|
+
return undefined; // Cache invalid
|
|
45
|
+
}
|
|
46
|
+
return entry;
|
|
47
|
+
});
|
|
48
|
+
return cached ?? null;
|
|
45
49
|
}
|
|
46
50
|
catch {
|
|
47
51
|
return null;
|