pi-lens 3.8.70 → 3.8.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +16 -0
  3. package/dist/clients/bus-events-logger.js +10 -8
  4. package/dist/clients/cache/rule-cache.js +9 -5
  5. package/dist/clients/cache-manager.js +19 -13
  6. package/dist/clients/diagnostic-dispositions.js +298 -0
  7. package/dist/clients/dispatch/auxiliary-lsp.js +85 -3
  8. package/dist/clients/dispatch/dispatcher.js +6 -1
  9. package/dist/clients/dispatch/integration.js +19 -4
  10. package/dist/clients/dispatch/runners/lsp.js +5 -32
  11. package/dist/clients/dispatch/runners/oxlint.js +1 -1
  12. package/dist/clients/dispatch/suppress-writer.js +68 -0
  13. package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
  14. package/dist/clients/disposition-logger.js +39 -0
  15. package/dist/clients/disposition-publish.js +109 -0
  16. package/dist/clients/file-utils.js +94 -33
  17. package/dist/clients/format-events-publish.js +299 -0
  18. package/dist/clients/git-tracked-ignore.js +208 -0
  19. package/dist/clients/instance-registry.js +74 -4
  20. package/dist/clients/json-cache-read.js +65 -0
  21. package/dist/clients/language-profile.js +4 -0
  22. package/dist/clients/lens-map.js +1079 -0
  23. package/dist/clients/lsp/aggregation.js +69 -2
  24. package/dist/clients/lsp/client.js +72 -7
  25. package/dist/clients/lsp/index.js +486 -14
  26. package/dist/clients/lsp/server.js +17 -1
  27. package/dist/clients/lsp/tsserver-sync.js +145 -0
  28. package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
  29. package/dist/clients/mcp/session.js +8 -0
  30. package/dist/clients/opengrep-config.js +2 -10
  31. package/dist/clients/path-utils.js +26 -0
  32. package/dist/clients/pipeline.js +4 -0
  33. package/dist/clients/project-diagnostics/cache.js +5 -12
  34. package/dist/clients/project-snapshot.js +3 -6
  35. package/dist/clients/read-guard.js +1 -1
  36. package/dist/clients/review-graph/builder.js +78 -52
  37. package/dist/clients/review-graph/import-resolvers.js +53 -19
  38. package/dist/clients/runtime-agent-end.js +26 -0
  39. package/dist/clients/runtime-coordinator.js +9 -1
  40. package/dist/clients/runtime-session.js +53 -11
  41. package/dist/clients/runtime-tool-call.js +789 -0
  42. package/dist/clients/runtime-tool-result.js +15 -2
  43. package/dist/clients/runtime-turn.js +9 -2
  44. package/dist/clients/session-state-store.js +6 -9
  45. package/dist/clients/sgconfig.js +2 -9
  46. package/dist/clients/source-filter.js +5 -0
  47. package/dist/clients/startup-scan.js +68 -1
  48. package/dist/clients/typos-config.js +2 -11
  49. package/dist/clients/widget-state.js +22 -0
  50. package/dist/clients/zizmor-config.js +2 -10
  51. package/dist/index.js +21089 -18685
  52. package/dist/tools/lens-diagnostic-mark.js +158 -0
  53. package/dist/tools/lens-diagnostics.js +41 -7
  54. package/dist/tools/lsp-diagnostics.js +99 -119
  55. package/docs/agent-tools.md +11 -4
  56. package/docs/dispositions.md +85 -0
  57. package/docs/environment-variables.md +16 -0
  58. package/docs/fable.md +10 -9
  59. package/docs/servercapabilities.md +1 -1
  60. package/docs/tools.md +3 -0
  61. package/package.json +6 -1
  62. package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
  63. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
  64. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
  65. package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
  66. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +12 -0
  67. package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
  68. package/docs/agent-tooling-improvement-plan.md +0 -660
  69. package/docs/inspiration_gograph.md +0 -601
  70. package/docs/inspiration_neovim.md +0 -1795
  71. package/docs/inspiration_neovim2.md +0 -402
  72. package/docs/inspiration_tree_sitter_rules.md +0 -369
  73. package/docs/inspiration_tree_sitter_rules_investigation.md +0 -328
  74. package/docs/inspiration_tree_sitter_rules_round2.md +0 -470
  75. package/docs/lsp-bridge-analysis.md +0 -444
  76. package/docs/newinspiration.md +0 -338
  77. package/grammars/tree-sitter-c.wasm +0 -0
  78. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  79. package/grammars/tree-sitter-cpp.wasm +0 -0
  80. package/grammars/tree-sitter-dart.wasm +0 -0
  81. package/grammars/tree-sitter-elixir.wasm +0 -0
  82. package/grammars/tree-sitter-kotlin.wasm +0 -0
  83. package/grammars/tree-sitter-lua.wasm +0 -0
  84. package/grammars/tree-sitter-ocaml.wasm +0 -0
  85. package/grammars/tree-sitter-php.wasm +0 -0
  86. package/grammars/tree-sitter-ruby.wasm +0 -0
  87. package/grammars/tree-sitter-swift.wasm +0 -0
  88. package/grammars/tree-sitter-toml.wasm +0 -0
  89. package/grammars/tree-sitter-vue.wasm +0 -0
  90. package/grammars/tree-sitter-zig.wasm +0 -0
package/CHANGELOG.md CHANGED
@@ -4,12 +4,36 @@ All notable changes to pi-lens will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.8.71] - 2026-07-20
8
+
7
9
  ### Added
8
10
 
11
+ - **`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).
12
+ - **`/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`.
13
+ - `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).
14
+ - **`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.
15
+ - **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.
16
+ - **`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".
17
+
9
18
  ### Changed
10
19
 
20
+ - **`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.
21
+ - **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.
22
+ - **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).
23
+ - **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.
24
+
11
25
  ### Fixed
12
26
 
27
+ - **`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.
28
+ - **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.
29
+ - **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).
30
+ - **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).
31
+ - **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.
32
+ - **`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`.
33
+ - **`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.
34
+ - **`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.
35
+ - **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`.
36
+
13
37
  ## [3.8.70] - 2026-07-14
14
38
 
15
39
  ### 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,8 @@ 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>
173
189
  </tr>
174
190
  </tbody>
175
191
  </table>
@@ -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
- * Both producers (clients/bus-publish.ts, clients/diagnostics-publish.ts) are
6
- * fire-and-forget: on failure or on a structural no-op (never wired, kill
7
- * switch off) they only invoke an optional `dbg` callback, which varies by
8
- * host and is a documented no-op in the MCP host (clients/mcp/session.ts's
9
- * `dbg: noop`). That leaves bus-publish outcomes invisible in exactly the
10
- * context where they matter most — same failure shape as the #544 MCP
11
- * session_start incident this repo already fixed once.
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
- if (cached.version !== CACHE_VERSION || cached.ruleHash !== currentHash) {
42
- return null; // Cache invalid
43
- }
44
- return cached;
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;
@@ -11,6 +11,7 @@
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { getProjectDataDir } from "./file-utils.js";
14
+ import { readJsonCache } from "./json-cache-read.js";
14
15
  import { normalizeMapKey } from "./path-utils.js";
15
16
  // --- Defaults ---
16
17
  const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
@@ -72,13 +73,20 @@ export class CacheManager {
72
73
  return null;
73
74
  }
74
75
  try {
75
- const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
76
+ const onReadError = (err) => {
77
+ this.log(`Cache read error: ${scanner} — ${err}`);
78
+ };
79
+ const meta = readJsonCache(metaPath, (parsed) => parsed, onReadError);
80
+ if (meta === undefined)
81
+ return null;
76
82
  const age = Date.now() - new Date(meta.timestamp).getTime();
77
83
  if (age > maxAgeMs) {
78
84
  this.log(`Cache stale: ${scanner} (age: ${Math.round(age / 1000)}s, max: ${maxAgeMs / 1000}s)`);
79
85
  return null;
80
86
  }
81
- const data = JSON.parse(fs.readFileSync(cachePath, "utf-8"));
87
+ const data = readJsonCache(cachePath, (parsed) => parsed, onReadError);
88
+ if (data === undefined)
89
+ return null;
82
90
  this.log(`Cache hit: ${scanner} (age: ${Math.round(age / 1000)}s)`);
83
91
  return { data, meta };
84
92
  }
@@ -111,7 +119,9 @@ export class CacheManager {
111
119
  if (!fs.existsSync(metaPath))
112
120
  return false;
113
121
  try {
114
- const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
122
+ const meta = readJsonCache(metaPath, (parsed) => parsed);
123
+ if (meta === undefined)
124
+ return false;
115
125
  const age = Date.now() - new Date(meta.timestamp).getTime();
116
126
  return age <= maxAgeMs;
117
127
  }
@@ -150,16 +160,12 @@ export class CacheManager {
150
160
  lastUpdated: new Date().toISOString(),
151
161
  };
152
162
  }
153
- try {
154
- return JSON.parse(fs.readFileSync(statePath, "utf-8"));
155
- }
156
- catch {
157
- return {
158
- ...DEFAULT_TURN_STATE,
159
- files: {},
160
- lastUpdated: new Date().toISOString(),
161
- };
162
- }
163
+ const state = readJsonCache(statePath, (parsed) => parsed);
164
+ return (state ?? {
165
+ ...DEFAULT_TURN_STATE,
166
+ files: {},
167
+ lastUpdated: new Date().toISOString(),
168
+ });
163
169
  }
164
170
  /**
165
171
  * Write turn state.
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Agent+user disposition layer over dispatch diagnostics (#690, unifying #181/
3
+ * #503/#504's discussion). Four dispositions:
4
+ *
5
+ * false-positive — the rule misfired. Project-persistent, routed nowhere
6
+ * special yet (telemetry hookup is a fast-follow).
7
+ * suppress — real finding, deliberate policy not to fix. Persistent,
8
+ * but the mechanism is an inline `pi-lens-ignore` comment
9
+ * written into the source (see suppress-writer.ts), not
10
+ * just a store entry — portable, git-visible, discoverable
11
+ * without pi-lens's own store. The store entry here is an
12
+ * audit-trail mirror, not the enforcement point.
13
+ * defer — fix later, not now. Session-ephemeral: held in memory
14
+ * only, so it naturally resurfaces on process restart —
15
+ * never persisted, never needs pruning.
16
+ * flagged — user wants the agent to fix this. Persistent until
17
+ * resolved; surfaced through the existing lens_diagnostics
18
+ * query (tagged), not a separate file/tool the agent has
19
+ * to separately poll.
20
+ *
21
+ * Anchoring: TWO flavors, chosen per-disposition because each one binds to a
22
+ * different thing conceptually:
23
+ *
24
+ * STRICT ("dd:" prefix) — relativeFile|tool|rule|normalizedMessage|
25
+ * lineContentHash(diagnostic's own line). Used ONLY for false-positive: a
26
+ * false-positive judgment is about THIS specific piece of code — if the
27
+ * line is rewritten, the rule earned a fresh chance to fire on the new
28
+ * content, so the mark should NOT follow it. Reuses read-guard's
29
+ * lineContentHash so a no-op formatter/whitespace pass doesn't rot the
30
+ * anchor, while a semantic edit to the flagged line correctly invalidates
31
+ * it.
32
+ * WEAK ("ddw:" prefix) — relativeFile|tool|rule|normalizedMessage, no line
33
+ * hash at all. Used for defer, flagged, and suppress: these are
34
+ * intent-level judgments ("I'll get to this", "fix this", "policy says
35
+ * don't") about a finding identity, not about one exact line's bytes —
36
+ * they must survive incidental edits elsewhere on the flagged line
37
+ * (reformatting, a nearby rename) without silently dropping the mark.
38
+ * suppress's real enforcement is the inline comment (see
39
+ * suppress-writer.ts) which travels with the code by construction; the
40
+ * weak-anchored store entry is just an audit mirror plus a second,
41
+ * belt-and-braces filter.
42
+ *
43
+ * Distinct prefixes ("dd:" vs "ddw:") keep the two id spaces from ever
44
+ * colliding in the same store.
45
+ *
46
+ * Content is hashed only from the diagnostic's own line (for the strict
47
+ * anchor), not a surrounding window as #181's original sketch considered.
48
+ * Two diagnostics on the same file/tool/rule/message whose flagged line
49
+ * happens to have identical content collide on the SAME strict anchor —
50
+ * deliberately: identical content at the same rule/message is a semantically
51
+ * equivalent finding, so marking one intentionally marks all of them (e.g. a
52
+ * copy-pasted line repeated a few times in the same file). If that
53
+ * assumption proves wrong in practice, a surrounding-window hash can be
54
+ * layered on later without changing the store shape.
55
+ */
56
+ import { createHash } from "node:crypto";
57
+ import * as fs from "node:fs";
58
+ import * as path from "node:path";
59
+ import { logDispositionEvent } from "./disposition-logger.js";
60
+ import { publishDisposition } from "./disposition-publish.js";
61
+ import { getProjectDataDir } from "./file-utils.js";
62
+ import { normalizeMapKey } from "./path-utils.js";
63
+ import { lineContentHash } from "./read-guard.js";
64
+ // "defer" is session-ephemeral by design (#690) — held only in memory so it
65
+ // resurfaces for free on the next process run, with no expiry/pruning logic
66
+ // needed. Stores WEAK anchors (see module doc) so a deferred finding stays
67
+ // hidden all session even if the flagged line itself is edited.
68
+ const deferredThisSession = new Set();
69
+ function normalizeMessage(message) {
70
+ return message.replace(/\s+/g, " ").trim().toLowerCase();
71
+ }
72
+ function relativeFile(filePath, cwd) {
73
+ const rel = path.relative(cwd, filePath).replace(/\\/g, "/");
74
+ return rel && !rel.startsWith("..") ? rel : normalizeMapKey(filePath);
75
+ }
76
+ /** Site-specific anchor — see module doc. Used only for false-positive. */
77
+ export function computeStrictAnchor(args) {
78
+ const lines = args.content?.split(/\r?\n/);
79
+ const lineText = args.line !== undefined && lines ? (lines[args.line - 1] ?? "") : "";
80
+ const parts = [
81
+ relativeFile(args.filePath, args.cwd),
82
+ args.tool ?? "",
83
+ args.rule ?? "",
84
+ normalizeMessage(args.message),
85
+ lineContentHash(lineText),
86
+ ];
87
+ return `dd:${createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 12)}`;
88
+ }
89
+ /** Intent-level anchor — see module doc. Used for defer/flagged/suppress. */
90
+ export function computeWeakAnchor(args) {
91
+ const parts = [
92
+ relativeFile(args.filePath, args.cwd),
93
+ args.tool ?? "",
94
+ args.rule ?? "",
95
+ normalizeMessage(args.message),
96
+ ];
97
+ return `ddw:${createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 12)}`;
98
+ }
99
+ /** Both anchors a stored/filtered diagnostic would compute — the one shared
100
+ * derivation both the dispatch-pipeline filter and lens-diagnostics' flagged
101
+ * tag lookup must use so a mark and a fresh diagnostic converge on the same
102
+ * ids. */
103
+ export function anchorsForDiagnostic(cwd, filePath, diagnostic, content) {
104
+ const args = {
105
+ cwd,
106
+ filePath,
107
+ tool: diagnostic.tool,
108
+ rule: diagnostic.rule,
109
+ message: diagnostic.message,
110
+ line: diagnostic.line,
111
+ content,
112
+ };
113
+ return { strict: computeStrictAnchor(args), weak: computeWeakAnchor(args) };
114
+ }
115
+ function statePath(cwd) {
116
+ return path.join(getProjectDataDir(cwd), "cache", "diagnostic-dispositions.json");
117
+ }
118
+ let stateCache = null;
119
+ function readState(cwd) {
120
+ const p = statePath(cwd);
121
+ let stat;
122
+ try {
123
+ stat = fs.statSync(p);
124
+ }
125
+ catch {
126
+ if (stateCache && stateCache.path === p && stateCache.missing) {
127
+ return stateCache.state;
128
+ }
129
+ const empty = {};
130
+ stateCache = { path: p, missing: true, mtimeMs: -1, size: -1, state: empty };
131
+ return empty;
132
+ }
133
+ if (stateCache &&
134
+ stateCache.path === p &&
135
+ !stateCache.missing &&
136
+ stateCache.mtimeMs === stat.mtimeMs &&
137
+ stateCache.size === stat.size) {
138
+ return stateCache.state;
139
+ }
140
+ let state;
141
+ try {
142
+ const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
143
+ state =
144
+ parsed && typeof parsed === "object" ? parsed : {};
145
+ }
146
+ catch {
147
+ state = {};
148
+ }
149
+ stateCache = {
150
+ path: p,
151
+ missing: false,
152
+ mtimeMs: stat.mtimeMs,
153
+ size: stat.size,
154
+ state,
155
+ };
156
+ return state;
157
+ }
158
+ function writeState(cwd, state) {
159
+ const p = statePath(cwd);
160
+ fs.mkdirSync(path.dirname(p), { recursive: true });
161
+ fs.writeFileSync(p, JSON.stringify(state, null, 2));
162
+ // Refresh the cache from the write we just did instead of invalidating it —
163
+ // avoids an immediate re-stat+re-parse of the file we already have in hand,
164
+ // and guards against coarse filesystem mtime granularity making a
165
+ // read-immediately-after-write look like a cache hit on stale data.
166
+ const stat = fs.statSync(p);
167
+ stateCache = {
168
+ path: p,
169
+ missing: false,
170
+ mtimeMs: stat.mtimeMs,
171
+ size: stat.size,
172
+ state,
173
+ };
174
+ }
175
+ /** Test-only escape hatch — the state cache is module-level, so tests that
176
+ * write the store file out-of-band (or across separate cwds sharing a stat
177
+ * coincidence) need to reset it between cases. */
178
+ export function _resetStateCacheForTests() {
179
+ stateCache = null;
180
+ }
181
+ /** Fire-and-forget mark telemetry (see markDisposition's doc): the NDJSON log
182
+ * entry (project-relative path — rule-tuning data, not machine layout) and
183
+ * the bus event (absolute normalized path — in-process consumers navigate).
184
+ * Neither can throw into the mark path; both are already internally
185
+ * fail-safe, but the try/catch keeps a future regression in either from
186
+ * breaking a mark. */
187
+ function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing) {
188
+ try {
189
+ logDispositionEvent({
190
+ event: "mark",
191
+ disposition,
192
+ tool: target.tool,
193
+ rule: target.rule,
194
+ filePath: relativeFile(target.filePath, cwd),
195
+ line: target.line,
196
+ reason,
197
+ anchor,
198
+ previousDisposition: existing?.disposition,
199
+ });
200
+ publishDisposition({
201
+ cwd,
202
+ filePath: target.filePath,
203
+ disposition,
204
+ tool: target.tool,
205
+ rule: target.rule,
206
+ line: target.line,
207
+ anchor,
208
+ reason,
209
+ });
210
+ }
211
+ catch {
212
+ // never let telemetry break a mark
213
+ }
214
+ }
215
+ /**
216
+ * Record a disposition. Picks the anchor flavor per-disposition (see module
217
+ * doc): strict for false-positive, weak for everything else. Returns the
218
+ * anchor actually used, so callers (the mark tool) can report/verify it.
219
+ *
220
+ * This is THE single choke point for mark telemetry — the NDJSON log
221
+ * (disposition-logger.ts, #181's FP-rule-tuning signal) and the
222
+ * `pilens:diagnostic:disposition` bus event (disposition-publish.ts) both
223
+ * hang off it, so the agent tool and any future UI caller are covered without
224
+ * per-caller wiring.
225
+ */
226
+ export function markDisposition(cwd, target, disposition, reason) {
227
+ const anchor = disposition === "false-positive"
228
+ ? computeStrictAnchor(target)
229
+ : computeWeakAnchor(target);
230
+ // Captured for BOTH branches: a defer never writes the store, but a store
231
+ // entry can already exist at the same weak anchor (a prior flagged/suppress
232
+ // mark) — the log should record what this mark shadowed either way.
233
+ const existing = readState(cwd).dispositions?.[anchor];
234
+ emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing);
235
+ if (disposition === "defer") {
236
+ deferredThisSession.add(anchor);
237
+ return anchor;
238
+ }
239
+ const state = readState(cwd);
240
+ state.dispositions ??= {};
241
+ const now = new Date().toISOString();
242
+ const capturesFixContext = disposition === "flagged";
243
+ const lineText = capturesFixContext
244
+ ? (target.content?.split(/\r?\n/)[target.line !== undefined ? target.line - 1 : -1] ?? existing?.lineText)?.trim()
245
+ : existing?.lineText;
246
+ state.dispositions[anchor] = {
247
+ disposition,
248
+ reason: reason ?? existing?.reason,
249
+ createdAt: existing?.createdAt ?? now,
250
+ lastSeenAt: now,
251
+ line: capturesFixContext ? (target.line ?? existing?.line) : existing?.line,
252
+ lineText,
253
+ };
254
+ writeState(cwd, state);
255
+ return anchor;
256
+ }
257
+ export function getDisposition(cwd, anchor) {
258
+ return readState(cwd).dispositions?.[anchor];
259
+ }
260
+ export function isDeferredThisSession(anchor) {
261
+ return deferredThisSession.has(anchor);
262
+ }
263
+ /** Test-only escape hatch — defer state is module-level (one process = one
264
+ * session), so tests need to reset it between cases. */
265
+ export function _resetDeferredForTests() {
266
+ deferredThisSession.clear();
267
+ }
268
+ /**
269
+ * Drop diagnostics disposed false-positive/suppress, or deferred this session,
270
+ * from `diagnostics`. `flagged` diagnostics are kept as-is — callers that want
271
+ * to surface the flag (e.g. lens_diagnostics' rendering) look it up separately
272
+ * via getDisposition on the WEAK anchor (anchorsForDiagnostic(...).weak).
273
+ *
274
+ * Computes both anchors per diagnostic (cheap — same hash primitive, twice)
275
+ * since false-positive is keyed strict while defer/suppress are keyed weak;
276
+ * see module doc for why each disposition binds the way it does.
277
+ */
278
+ export function applyDispositions(diagnostics, cwd, filePath, content) {
279
+ if (!diagnostics.length)
280
+ return diagnostics;
281
+ const dispositions = readState(cwd).dispositions;
282
+ if (!dispositions && deferredThisSession.size === 0)
283
+ return diagnostics;
284
+ return diagnostics.filter((d) => {
285
+ const { strict, weak } = anchorsForDiagnostic(cwd, filePath, d, content);
286
+ if (deferredThisSession.has(weak))
287
+ return false;
288
+ if (dispositions?.[strict]?.disposition === "false-positive")
289
+ return false;
290
+ // Belt-and-braces: the inline `pi-lens-ignore` comment is the real
291
+ // suppress enforcement (see suppress-writer.ts) and normally already
292
+ // dropped this finding upstream via applyInlineSuppressions. This is a
293
+ // harmless second cover for the store-only audit trail case.
294
+ if (dispositions?.[weak]?.disposition === "suppress")
295
+ return false;
296
+ return true;
297
+ });
298
+ }