pi-lens 3.8.41 → 3.8.43

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 (62) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +36 -15
  3. package/clients/cache-manager.ts +3 -0
  4. package/clients/dispatch/dispatcher.ts +3 -1
  5. package/clients/dispatch/integration.ts +178 -56
  6. package/clients/dispatch/plan.ts +13 -2
  7. package/clients/dispatch/rules/error-swallowing.ts +2 -2
  8. package/clients/dispatch/rules/quality-rules.ts +14 -4
  9. package/clients/dispatch/rules/sonar-rules.ts +36 -8
  10. package/clients/dispatch/runners/oxlint.ts +56 -14
  11. package/clients/dispatch/runners/tree-sitter.ts +28 -10
  12. package/clients/dispatch/types.ts +6 -0
  13. package/clients/file-utils.ts +32 -0
  14. package/clients/formatters.ts +9 -0
  15. package/clients/installer/index.ts +123 -1
  16. package/clients/knip-client.ts +1 -4
  17. package/clients/lsp/client.ts +16 -1
  18. package/clients/lsp/index.ts +94 -18
  19. package/clients/lsp/server-strategies.ts +7 -0
  20. package/clients/lsp/server.ts +6 -7
  21. package/clients/path-utils.ts +15 -3
  22. package/clients/pipeline.ts +61 -3
  23. package/clients/read-guard-tool-lines.ts +1 -1
  24. package/clients/review-graph/builder.ts +80 -7
  25. package/clients/runtime-config.ts +5 -0
  26. package/clients/runtime-context.ts +3 -3
  27. package/clients/runtime-coordinator.ts +20 -0
  28. package/clients/runtime-session.ts +30 -10
  29. package/clients/runtime-tool-result.ts +9 -1
  30. package/clients/runtime-turn.ts +108 -109
  31. package/clients/source-filter.ts +3 -2
  32. package/clients/test-runner-client.ts +99 -6
  33. package/clients/tool-policy.ts +39 -1
  34. package/clients/tree-sitter-client.ts +84 -4
  35. package/clients/widget-state.ts +1 -1
  36. package/commands/booboo.ts +14 -2
  37. package/index.ts +74 -17
  38. package/package.json +3 -3
  39. package/rules/tree-sitter-queries/go/go-command-injection.yml +2 -2
  40. package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +2 -2
  41. package/rules/tree-sitter-queries/go/go-sql-injection.yml +2 -2
  42. package/rules/tree-sitter-queries/go/go-weak-hash.yml +2 -2
  43. package/rules/tree-sitter-queries/python/python-command-injection.yml +2 -2
  44. package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +1 -1
  45. package/rules/tree-sitter-queries/python/python-insecure-deserialization.yml +2 -2
  46. package/rules/tree-sitter-queries/python/python-sql-injection.yml +2 -2
  47. package/rules/tree-sitter-queries/python/python-weak-hash.yml +2 -2
  48. package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +2 -2
  49. package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +2 -2
  50. package/rules/tree-sitter-queries/typescript/incomplete-assertion.yml +50 -0
  51. package/rules/tree-sitter-queries/typescript/switch-non-case-labels.yml +53 -0
  52. package/rules/tree-sitter-queries/typescript/ts-command-injection.yml +2 -2
  53. package/rules/tree-sitter-queries/typescript/ts-dynamic-require.yml +55 -0
  54. package/rules/tree-sitter-queries/typescript/ts-hallucinated-react-import.yml +1 -1
  55. package/rules/tree-sitter-queries/typescript/ts-nosql-injection.yml +54 -0
  56. package/rules/tree-sitter-queries/typescript/ts-open-redirect.yml +111 -0
  57. package/rules/tree-sitter-queries/typescript/ts-react-antipatterns.yml +6 -3
  58. package/rules/tree-sitter-queries/typescript/ts-ssrf.yml +2 -2
  59. package/rules/tree-sitter-queries/typescript/ts-weak-hash.yml +2 -2
  60. package/rules/tree-sitter-queries/typescript/ts-xss-dom-sink.yml +100 -0
  61. package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +3 -2
  62. package/rules/tree-sitter-queries/typescript-disabled/ts-path-traversal.yml +71 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,95 @@ All notable changes to pi-lens will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.8.43] - 2026-05-10
8
+
9
+ ### Added
10
+
11
+ - **Unresolved inline blocker re-surfacing at turn_end** — when the agent ignores a blocking diagnostic shown during a write/edit and moves to the next turn without fixing it, the blocker now reappears in the turn_end injection framed as `"Unresolved from this turn — <file>: 🔴 STOP…"`. Previously, unresolved inline blockers were silently lost until cascade happened to re-touch the same file via an importer. `RuntimeCoordinator` tracks the last-seen blocking output per file (`_pendingInlineBlockers`); a subsequent write that produces no blockers clears the entry, so only genuinely unresolved issues resurface. The map is cleared at `beginTurn` to prevent cross-turn contamination.
12
+ - **S1219 (switch non-case labels) and S2970 (incomplete assertions) blocking tree-sitter rules** — S1219 detects labeled statements inside switch cases in TypeScript (SonarCloud S1219); S2970 detects Jest/Vitest `expect()` chains that are never called (e.g. `expect(x).toBe(y)` without `await`), with Chai property assertion exclusion. S2083 (path traversal) moved to disabled — regex heuristics on tree-sitter syntax are the wrong layer; needs taint/data-flow analysis. Adds `parent?` field to `TreeSitterNode` interface.
13
+ - **Inline code snippets in blocker output** — each 🔴 STOP diagnostic now includes the exact source line the agent wrote that caused the violation, so the agent can identify and fix the issue without re-reading the file. `fixSuggestion` is also surfaced inline when present. Snippet capped at 120 chars.
14
+ - **AST node type and matched text in blocker output** — tree-sitter diagnostics now carry `matchedText` (the exact matched node, more precise than the full source line) and `astNodeType` (e.g. `call_expression`, `template_string`). The agent sees: `L12: SQL query built with string interpolation (template_string) → db.query(...)`.
15
+ - **Persist review graph to disk** — `_workspaceGraphCache` is now backed by `.pi-lens/cache/review-graph.json`. On cold start, if source file signatures match the stored cache, the full 2–4 s tree-sitter + import-fact build is skipped (~20 ms JSON parse + `rebuildIndexes` instead). Write is fire-and-forget, never blocks dispatch.
16
+ - **Preserve last known LSP diagnostics when LSP goes inactive** — when no live clients are available (dead client respawning, circuit-breaker cooldown), `getDiagnostics` now returns the last non-empty result for that file instead of `[]`. The widget keeps showing the last known issues rather than going blank mid-session. Live clients returning `[]` clears the stale entry. Stale hits are logged as `failureKind: "no_clients_stale"`.
17
+
18
+ ### Fixed
19
+
20
+ - **Read-guard false-positive block on files outside the project root** — edits to files outside `projectRoot` (e.g. `C:/llama/*.bat`, scripts in arbitrary directories) were always blocked with `zero_read` because reads for external files are intentionally not recorded (`isExternalOrVendor` gate in the read handler), but the `checkEdit` call had no matching guard. Added `!isExternalOrVendor` to the `checkEdit` condition so external files bypass the read-guard entirely, consistent with how reads are handled.
21
+
22
+ ### Changed
23
+
24
+ - **Replace pyright-langserver and pylsp with jedi-language-server for Python LSP** — `PythonServer` (pyright-langserver) and `PythonPylspServer` (pylsp) removed from `LSP_SERVERS`; replaced by `PythonJediServer` which spawns `jedi-language-server`. pyright-langserver was causing 5–14 s cold-start delays on large Python projects (e.g. tinygrad) because it performs full workspace analysis on startup; jedi starts in ~200–500 ms via lazy per-file analysis. pylsp was removed because it consistently returned 0 diagnostics (no venv → jedi can't resolve imports; 1500 ms aggregate timeout hit on warm runs). Deep type checking is unaffected — the standalone `pyright` CLI runner and `mypy` runner continue to run in parallel. Added `"python-jedi"` strategy entry (`seedFirstPush: true`, `aggregateWaitMs: 1000`). Wall-clock gate for Python dispatch shifts from LSP (~5–14 s) to mypy (~3.5 s).
25
+
26
+ ## [3.8.42] - 2026-05-08
27
+
28
+ ### Added
29
+
30
+ - **Fact-rules wired into all language dispatch plans** — the `fact-rules` runner was registered but never listed in any `RunnerGroup`; 20 TypeScript FactRule instances (`corsWildcardRule`, `jwtWithoutVerifyRule`, `dynamicRegexpRule`, `errorObscuringRule`, `highComplexityRule`, etc.) were never executing. Added `mode:all fact-rules` group to jsts, python, go, rust, ruby, cmake, and shell write plans.
31
+ - **3 fact-rules promoted to blocking (inline at write time):** `cors-wildcard` (CORS `*` origin — no ast-grep/tree-sitter equivalent), `error-swallowing` (empty catch — smarter than the disabled tree-sitter `empty-catch`, skips fs-boundary and documented fallbacks), `no-commented-credentials` (credentials in commented code — complementary to ast-grep which covers live code). `high-entropy-string` was already blocking.
32
+ - **Fact-rule false-positive reductions:** `no-boolean-params` now exempts names with `*Only`/`*Enabled`/`*Disabled` suffixes, `allow*`/`skip*`/`needs*`/`auto*` prefixes, and `_`-prefixed params. `duplicate-string-literal` SKIP_STRINGS expanded with DSL discriminators (`types`, `fallback`, `direct`, `all`, `mode`, `source`) and infrastructure strings (`github`, `rubocop`, `arm64`). `high-import-coupling` threshold raised 10→15 and exempts `index.ts`/`integration.ts` registry/hub files. `no-commented-credentials` exempts scanner/fixture files.
33
+ - **Severity alignment for 3 existing TS tree-sitter blocking rules** — `ts-command-injection`, `ts-ssrf`, `unsafe-regex` had `inline_tier: blocking` but `severity: warning`, producing `semantic: "warning"` which is never shown inline. Fixed to `severity: error` → `semantic: "blocking"` → actually surfaces to the agent.
34
+ - **Fixed `inline_tier: error` typo** on `ts-hallucinated-react-import` and `python-hallucinated-import` (→ `blocking`).
35
+ - **13 new high-confidence blocking promotions across 5 languages** (all `severity: error`, `inline_tier: blocking`):
36
+ - *TypeScript:* `ts-weak-hash` (`createHash("md5"/"sha1")` — confidence: high)
37
+ - *Python:* `python-command-injection`, `python-sql-injection`, `python-insecure-deserialization`, `python-weak-hash`
38
+ - *Go:* `go-command-injection`, `go-sql-injection`, `go-shared-map-write-goroutine`, `go-weak-hash`
39
+ - *Ruby:* `ruby-weak-hash`
40
+ - *Rust:* `rust-lock-held-across-await`
41
+ - **4 new blocking tree-sitter rules (SonarCloud BLOCKER equivalents)**:
42
+ - `ts-xss-dom-sink` (S5696) — flags dynamic values assigned to `innerHTML`/`outerHTML` or passed to `document.write()` / `document.writeln()`
43
+ - `ts-dynamic-require` (S5335) — flags `require()` called with a non-string-literal argument (arbitrary module loading)
44
+ - `ts-open-redirect` (S6105) — flags `res.redirect(variable)` / `response.redirect` / `ctx.redirect` with dynamic URL, and `window.location.href = variable`
45
+ - `ts-nosql-injection` (S5147) — flags any MongoDB `$where` key (JS-execution sink, dangerous regardless of value)
46
+ - **2 existing security rules promoted to `inline_tier: blocking`** — `ts-command-injection` (maps to SonarCloud S2076) and `ts-ssrf` (maps to S5146) were previously `warning`; now block the agent turn on detection.
47
+
48
+ ### Fixed
49
+
50
+ - **`fact-rules` `RuleCache` blind to built-in rule changes** — the cache hash only covered project-local rule files; for any project with no local `rules/` directory the hash was a constant, so new pi-lens built-in rules were silently ignored after the first run. Fixed by including both project-local files and `resolvePackagePath()`-resolved built-in files in the hash, with a `Set` to deduplicate when pi-lens analyzes itself.
51
+
52
+ ### Changed
53
+
54
+ - **`max-switch-cases` threshold raised 30→40** — `applyPostFilter` dispatch table now has 31 cases and is expected to grow; the old threshold triggered a false positive on pi-lens itself.
55
+ - **Package scope migration** — all `@mariozechner/*` import references updated to `@earendil-works/*` following the repo move to `earendil-works/pi-mono`. `@earendil-works/pi-tui` dependency bumped to `^0.74.0`.
56
+ - **Startup: `lsp-config` phase is now fully fire-and-forget** — `loadLSPConfig` and `igniteWarmFiles` no longer block the interactive path, removing ~1s from session start on Windows (previously dominated by sequential ENOENT `readFile` calls walking the directory tree to find a config file).
57
+ - **Startup: persistent tool probe cache** — `ensureTool` now checks `~/.pi-lens/probe-cache.json` before falling back to the full `verifyToolBinary` process spawn. Cache entries are validated with `fs.access` + mtime check and expire after 24 h; stale or missing entries fall through to the full probe and update the cache on success.
58
+
59
+ ### Added
60
+
61
+ - **Startup observability** — `checkProbeCache` now logs the reason for each cache miss (`ttl expired`, `gone`, `mtime changed`); the lsp-config fire-and-forget callback logs how many warm files were configured once the config resolves asynchronously.
62
+
63
+
64
+
65
+ ### Added
66
+
67
+ - **Test runner: import-based fallback discovery** — when basename pattern lookup finds no test file for a modified source file (e.g. `cline.test.ts` for `cline-auth.ts`), the runner now scans `tests/`, `__tests__/`, and the source file's own directory for any `*.test.*` file whose content references the source basename in an import path. Fixes the silent `no test file found` for files whose test is named after a module rather than the source file.
68
+ - **Test runner: prefer local `node_modules/.bin` binary over `npx`** — `vitest` and `jest` now resolve the project-local binary (`node_modules/.bin/vitest.cmd` on Windows, `node_modules/.bin/vitest` on Unix) before falling back to `npx`, saving ~150ms of startup overhead per test run.
69
+ - **Turn-end test runner logging** — `turn_end` now logs the outcome of every test run: `turn_end: test vitest util.test.ts → PASS 8p/0f (412ms)` or `FAIL 2p/8f (930ms)`. Stale results (turn advanced while tests ran) are logged with a `[stale]` prefix instead of being silently discarded. All-pass turns are no longer silent.
70
+ - **Per-file test target logging** — `turn_end` now logs which test file was resolved for each modified source file, or `no test file found` when none matched. Previously silent; impossible to distinguish "runner disabled" from "no test found".
71
+ - **Session-scoped turn-end dedup** — `turn-end-findings-last` now stores the current session ID alongside the content signature. Identical findings from a previous session are no longer suppressed — each new session sees its blockers fresh. Same-session dedup continues to work as before.
72
+ - **Cross-session turn state eviction** — turn state (modified file ranges) now carries the session ID set at first edit. If `turn_end` reads a turn state written by a different session, it evicts it immediately and logs `turn_end: evicting stale turn state (session X ≠ current Y)`, preventing stale cross-session file lists from triggering jscpd, madge, or test runs.
73
+
74
+ ### Changed
75
+
76
+ - **Context injections framed as automated checks** — all three `consume*` injections (`turn-end findings`, `test findings`, `session guidance`) now prefix their content with `[pi-lens automated check — not a user request]` so the agent cannot mistake a hook-injected message for a direct user command. Advisory sections additionally carry `ℹ️ Advisory — no action required this turn:` before their content; blockers (🔴) continue to require action.
77
+
78
+ - **`/lens-widget-toggle` command** — toggles the pi-lens diagnostics widget below the editor on/off for the current session, so users can reclaim footer/editor space without disabling pi-lens analysis.
79
+
80
+ ### Changed
81
+
82
+ - **Removed per-turn jscpd scans** — jscpd remains in the session-start project scan, but no longer runs unconditionally at `turn_end`; inline structural-similarity checks cover the high-value duplicate-code signal during active edits without the repeated multi-second clone scan.
83
+ - **Cascade avoids low-value work** — unsupported graph kinds now skip review-graph construction and go straight to passive LSP fallback diagnostics, and neighbor files that recently returned clean can skip repeated active LSP touches for a few turns unless the passive snapshot already contains fresh errors.
84
+ - **Knip now surfaces unused-export regressions** — newly unused exports in modified files are shown as advisory end-of-turn findings when they were absent from the previous Knip cache.
85
+
86
+ ### Fixed
87
+
88
+ - **Knip latency log now includes result metadata** — the `turn_end` Knip phase previously logged only duration with empty `metadata: {}`, making it impossible to distinguish a clean run from a silent failure. It now logs `success`, `totalIssues`, `newIssues`, `blockerIssues`, and `skipped` when the startup scan is still in flight.
89
+
90
+ - **LSP timeout log now includes `serverIds`** — `lsp_client_wait_timeout` previously only recorded `maxWaitMs`, making it impossible to identify which server consistently failed to respond within the budget. The event now includes the array of server IDs that were being waited on.
91
+
92
+ - **Vendor/third-party files excluded from cascade neighbor analysis** — `isExternalOrVendorFile()` previously only checked `node_modules`; it now checks every path segment against `vendor`, `vendors`, `third_party`, and `third-party` as well. Cascade neighbor discovery and fallback neighbor injection both skip files inside these directories, preventing vendored dependency diagnostics from surfacing in cascade output.
93
+
94
+ - **`lens-booboo` hangs on repos with large vendored trees (fixes #57)** — `collectSourceFiles` and the `sg scan` runner in `lens-booboo` now exclude `vendor/`, `third_party/`, `third-party/`, and `vendors/` by default (added to `EXCLUDED_DIRS`). Additionally, `readGitignoreDirs()` reads the root `.gitignore` and extracts simple directory-name entries (bare names and `name/` patterns — no wildcards, negations, or internal slashes), merging them into the exclusion list for `collectSourceFiles` and the `sg scan` glob arguments. This covers project-specific large dirs (e.g. `my-upstream/`) without requiring full gitignore-spec compliance.
95
+
7
96
  ## [3.8.41] - 2026-05-05
8
97
 
9
98
  ### Fixed
package/README.md CHANGED
@@ -124,25 +124,45 @@ Supported: TypeScript, TSX, JavaScript, JSX, Python, Go, Rust, Ruby.
124
124
 
125
125
  ### Fact Rules Pipeline
126
126
 
127
- Covers JavaScript/TypeScript, Python, Go, Rust, Ruby, Shell, and CMake. Dispatch includes a fact-rule engine that extracts function-level metrics (cyclomatic complexity, nesting depth, outgoing calls) and evaluates quality rules inline:
128
-
129
- - **high-complexity** flags functions exceeding configurable CC thresholds
130
- - **unsafe-boundary** — detects dangerous boundary crossings (unvalidated user input → trusted context)
131
- - **high-fan-out** — flags excessive outgoing call count (default threshold: 20)
132
- - **comment-facts** — classifies comment quality (TODO density, doc coverage)
133
- - **try-catch-facts** — flags empty/obscuring catch blocks
134
- - **import-facts** — detects circular/star/unused imports
135
- - **file-role** classifies files as source/test/config/vendor and adjusts severity
127
+ Covers JavaScript/TypeScript, Python, Go, Rust, Ruby, Shell, and CMake. A TypeScript AST-based fact-rule engine extracts function-level metrics and evaluates quality and security rules inline. Blocking rules surface immediately at write time; advisory rules are available via `/lens-booboo`.
128
+
129
+ **Blocking (surface inline at write time):**
130
+ - **cors-wildcard** — `Access-Control-Allow-Origin: *` in server-side code
131
+ - **error-swallowing** — empty catch block (skips documented local fallbacks and fs-boundary catches)
132
+ - **no-commented-credentials** — password/token/secret in commented-out code
133
+ - **high-entropy-string** — string literals with suspiciously high Shannon entropy (possible hardcoded secret)
134
+
135
+ **Advisory (accessible via `/lens-booboo`):**
136
+ - **high-complexity** / **no-complex-conditionals** — cyclomatic complexity and deeply nested conditions
137
+ - **high-fan-out** — function calls too many distinct functions (coordination smell)
138
+ - **unsafe-boundary** — dangerous `any` casts at API boundaries
139
+ - **async-noise** / **async-unnecessary-wrapper** — async functions with no await; wrappers that add no value
140
+ - **pass-through-wrappers** — trivial wrapper functions
141
+ - **dynamic-regexp** — `new RegExp(variable)` (potential ReDoS; complements tree-sitter `unsafe-regex`)
142
+ - **jwt-without-verify** — `jwt.sign()` without `jwt.verify()` in the same file
143
+ - **missing-error-propagation** — catch blocks that log but don't rethrow
144
+ - **error-obscuring** — catch blocks that wrap errors in a different type
145
+ - **duplicate-string-literal** / **no-boolean-params** / **high-import-coupling** — code-quality signals
136
146
 
137
147
  ### Tree-sitter Rules
138
148
 
139
- Structural rules are organized by language in `rules/tree-sitter-queries/`:
149
+ Structural rules organized by language in `rules/tree-sitter-queries/`. Rules marked **🔴** block the agent inline at write time (only for lines in the current edit); others are advisory.
150
+
151
+ **TypeScript (23 rules):**
152
+ 🔴 `eval`, `sql-injection`, `ts-command-injection`, `ts-ssrf`, `ts-xss-dom-sink`, `ts-dynamic-require`, `ts-open-redirect`, `ts-nosql-injection`, `ts-weak-hash`, `ts-hallucinated-react-import`, `unsafe-regex`, `debugger`, `default-not-last`, `duplicate-function-arg`, `empty-switch-case`, `infinite-loop`, `self-assignment`, `switch-case-termination`
153
+ ⚠️ `console-statement`, `deep-promise-chain`, `mixed-async-styles`, `ts-insecure-random`, `ts-detached-async-call`, `ts-react-antipatterns`, `ts-weak-hash`, `variable-shadowing`
154
+
155
+ **Python:** 🔴 `python-command-injection`, `python-sql-injection`, `python-insecure-deserialization`, `python-weak-hash`, `python-hallucinated-import` + 20 advisory rules
156
+
157
+ **Go:** 🔴 `go-command-injection`, `go-sql-injection`, `go-shared-map-write-goroutine`, `go-weak-hash` + 13 advisory rules
158
+
159
+ **Rust:** 🔴 `rust-lock-held-across-await` + 3 advisory rules (`rust-unsafe-block`, `rust-expect`, `rust-clone-in-loop`)
160
+
161
+ **Ruby:** 🔴 `ruby-weak-hash` + 14 advisory rules
162
+
163
+ **Suppressing a finding:** add `// pi-lens-ignore: rule-id` on the flagged line or the line above (JS/TS), or `# pi-lens-ignore: rule-id` for Python/Ruby/Shell. This suppresses that specific rule at that location only.
140
164
 
141
- - **TypeScript** (18 rules): console-statement, debugger, deep-nesting, eval, sql-injection, ssrf, weak-hash, unsafe-regex, variable-shadowing, and more
142
- - **Python** (26 rules): debug statements, hardcoded secrets, mutable class attrs, unsafe regex, empty except, and more
143
- - **Go** (17 rules): defer-in-loop, hardcoded secrets, unchecked errors, and more
144
- - **Rust** (6 rules): unsafe blocks, unwrap outside tests, and more
145
- - **Ruby** (15 rules): empty rescue, rescue Exception, debugger, hardcoded secrets, and more
165
+ **Project-wide disabling** is not currently supported through config — there is no `.pi-lens/disabled-rules` file. Use inline suppression for per-occurrence overrides. When editing pi-lens itself, move a rule file to the `<language>-disabled/` directory to prevent it from running.
146
166
 
147
167
  ### Ast-Grep Rules
148
168
 
@@ -268,6 +288,7 @@ pi --lens-semgrep-config p/ci # Explicit Semgrep config for dispatch (requires
268
288
  ## Key Commands
269
289
 
270
290
  - `/lens-toggle` — toggle pi-lens on/off for the current session without restarting
291
+ - `/lens-widget-toggle` — show/hide the pi-lens diagnostics widget below the editor
271
292
  - `/lens-booboo` — full quality report for current project state
272
293
  - `/lens-health` — runtime health, latency, and diagnostic telemetry
273
294
  - `/lens-tools` — tool installation status: globally installed, auto-installed, or npx fallback
@@ -43,6 +43,7 @@ export interface TurnState {
43
43
  turnCycles: number;
44
44
  maxCycles: number;
45
45
  lastUpdated: string;
46
+ sessionId?: string;
46
47
  }
47
48
 
48
49
  // --- Defaults ---
@@ -250,8 +251,10 @@ export class CacheManager {
250
251
  range: ModifiedRange,
251
252
  importsChanged: boolean,
252
253
  cwd: string,
254
+ sessionId?: string,
253
255
  ): TurnState {
254
256
  const state = this.readTurnState(cwd);
257
+ if (sessionId) state.sessionId = sessionId;
255
258
  const normalizedPath = this.toTurnStateKey(filePath, cwd);
256
259
 
257
260
  const existing = state.files[normalizedPath];
@@ -742,7 +742,8 @@ export async function dispatchForFile(
742
742
 
743
743
  // Format output — only blocking issues shown inline
744
744
  // Warnings tracked but not shown (noise) — surfaced via /lens-booboo
745
- let output = formatDiagnostics(inlineBlockers, "blocking");
745
+ const blockerOutput = formatDiagnostics(inlineBlockers, "blocking");
746
+ let output = blockerOutput;
746
747
  output += formatDiagnostics(inlineFixed, "fixed");
747
748
  if (coverageNotice) {
748
749
  output += formatDiagnostics([coverageNotice], "warning", 1);
@@ -807,6 +808,7 @@ export async function dispatchForFile(
807
808
  fixed: fixedItems,
808
809
  resolvedCount,
809
810
  output,
811
+ blockerOutput,
810
812
  hasBlockers: blockers.length > 0,
811
813
  };
812
814
  }
@@ -48,7 +48,7 @@ import type { CascadeResult } from "../cascade-types.js";
48
48
  import { getDiagnosticTracker } from "../diagnostic-tracker.js";
49
49
  import { getServersForFileWithConfig } from "../lsp/config.js";
50
50
  import { getLSPService } from "../lsp/index.js";
51
- import { normalizeMapKey } from "../path-utils.js";
51
+ import { isExternalOrVendorFile, normalizeMapKey } from "../path-utils.js";
52
52
  import {
53
53
  clearReviewGraphWorkspaceCache,
54
54
  getLastGraphBuildInfo,
@@ -325,7 +325,8 @@ function withSemgrepGroup(
325
325
  config: ctx.pi.getFlag("lens-semgrep-config"),
326
326
  });
327
327
  if (!config.enabled) return groups;
328
- if (groups.some((group) => group.runnerIds.includes("semgrep"))) return groups;
328
+ if (groups.some((group) => group.runnerIds.includes("semgrep")))
329
+ return groups;
329
330
  return [
330
331
  ...groups,
331
332
  {
@@ -402,16 +403,29 @@ export function resetDispatchBaselines(): void {
402
403
  clearCoverageNoticeState();
403
404
  clearReviewGraphWorkspaceCache();
404
405
  neighborTouchCache.clear();
406
+ recentlyCleanNeighborCache.clear();
405
407
  primaryFilesThisTurn.clear();
406
408
  cascadeDiagnosticBaselines.clear();
407
- cascadeSessionStats = { runs: 0, diagnosticsSurfaced: 0, coldSnapshotTouches: 0 };
409
+ cascadeSessionStats = {
410
+ runs: 0,
411
+ diagnosticsSurfaced: 0,
412
+ coldSnapshotTouches: 0,
413
+ };
408
414
  for (const timer of astGrepWarnDebounceTimers.values()) clearTimeout(timer);
409
415
  astGrepWarnDebounceTimers.clear();
410
416
  }
411
417
 
412
- let cascadeSessionStats = { runs: 0, diagnosticsSurfaced: 0, coldSnapshotTouches: 0 };
418
+ let cascadeSessionStats = {
419
+ runs: 0,
420
+ diagnosticsSurfaced: 0,
421
+ coldSnapshotTouches: 0,
422
+ };
413
423
 
414
- export function getCascadeSessionStats(): { runs: number; diagnosticsSurfaced: number; coldSnapshotTouches: number } {
424
+ export function getCascadeSessionStats(): {
425
+ runs: number;
426
+ diagnosticsSurfaced: number;
427
+ coldSnapshotTouches: number;
428
+ } {
415
429
  return { ...cascadeSessionStats };
416
430
  }
417
431
 
@@ -425,6 +439,16 @@ type NeighborCacheEntry = {
425
439
  };
426
440
  const neighborTouchCache = new Map<string, NeighborCacheEntry>();
427
441
 
442
+ // Cross-turn clean cache: neighbor touches that recently returned no errors can
443
+ // be skipped for a few turns. LSP servers push diagnostics proactively when a
444
+ // file becomes unhealthy, so repeatedly re-opening known-clean neighbors is low value.
445
+ type RecentlyCleanNeighborEntry = { turnSeq: number; checkedAt: number };
446
+ const recentlyCleanNeighborCache = new Map<
447
+ string,
448
+ RecentlyCleanNeighborEntry
449
+ >();
450
+ const RECENTLY_CLEAN_TTL_TURNS = 5;
451
+
428
452
  // B10: tracks files that were the *primary* edited file this turn.
429
453
  // These are excluded from cascade neighbor results — their own pipeline run
430
454
  // already reported their diagnostics authoritatively.
@@ -436,11 +460,17 @@ function ensureCascadeTurnScope(turnSeq: number): void {
436
460
  cascadeTurnScope = turnSeq;
437
461
  primaryFilesThisTurn.clear();
438
462
  neighborTouchCache.clear();
463
+ for (const [key, entry] of recentlyCleanNeighborCache) {
464
+ if (turnSeq - entry.turnSeq > RECENTLY_CLEAN_TTL_TURNS) {
465
+ recentlyCleanNeighborCache.delete(key);
466
+ }
467
+ }
439
468
  }
440
469
 
441
470
  const CASCADE_TTL_MS = 240_000;
442
471
  const MAX_PER_FILE = RUNTIME_CONFIG.pipeline.cascadeMaxDiagnosticsPerFile;
443
472
  const MAX_FILES = RUNTIME_CONFIG.pipeline.cascadeMaxFiles;
473
+ const CASCADE_GRAPH_KINDS = new Set(["jsts", "python", "go", "rust", "ruby"]);
444
474
 
445
475
  /**
446
476
  * Unified cascade orchestration — builds graph, discovers neighbors, and
@@ -477,7 +507,8 @@ export async function computeCascadeForFile(
477
507
  return undefined;
478
508
  }
479
509
 
480
- if (!detectFileKind(filePath)) {
510
+ const fileKind = detectFileKind(filePath);
511
+ if (!fileKind) {
481
512
  logCascade({ phase: "cascade_skip", filePath, reason: "non_code_file" });
482
513
  return undefined;
483
514
  }
@@ -489,54 +520,87 @@ export async function computeCascadeForFile(
489
520
  // turn won't show it as a neighbor.
490
521
  primaryFilesThisTurn.add(normalizedFileKey);
491
522
 
492
- const graphStart = Date.now();
493
- const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts);
494
- const graphMs = Date.now() - graphStart;
495
-
496
- // Count files represented in the graph (nodes with a filePath).
497
- const graphFileCount = new Set(
498
- [...graph.nodes.values()].flatMap((n) => (n.filePath ? [n.filePath] : [])),
499
- ).size;
523
+ let impact: ReturnType<typeof computeImpactCascade> = {
524
+ filePath: normalizedFile,
525
+ changedSymbols: [],
526
+ directImporters: [],
527
+ directCallers: [],
528
+ neighborFiles: [],
529
+ riskFlags: [],
530
+ };
531
+ let sortedNeighbors: string[] = [];
532
+ let importerSet = new Set<string>();
533
+ let callerSet = new Set<string>();
534
+ let referenceCount = 0;
535
+
536
+ if (CASCADE_GRAPH_KINDS.has(fileKind)) {
537
+ const graphStart = Date.now();
538
+ const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts);
539
+ const graphMs = Date.now() - graphStart;
540
+
541
+ // Count files represented in the graph (nodes with a filePath).
542
+ const graphFileCount = new Set(
543
+ [...graph.nodes.values()].flatMap((n) =>
544
+ n.filePath ? [n.filePath] : [],
545
+ ),
546
+ ).size;
500
547
 
501
- const graphBuildInfo = getLastGraphBuildInfo();
502
- logCascade({
503
- phase: "graph_build",
504
- filePath,
505
- graphBuiltMs: graphMs,
506
- graphReused: graphBuildInfo.reused,
507
- graphNodeCount: graph.nodes.size,
508
- graphFileCount,
509
- graphChangedSymbolCount: (
510
- graph.changedSymbolsByFile.get(normalizedFileKey) ?? []
511
- ).length,
512
- metadata: { graphBuildMode: graphBuildInfo.mode },
513
- });
548
+ const graphBuildInfo = getLastGraphBuildInfo();
549
+ logCascade({
550
+ phase: "graph_build",
551
+ filePath,
552
+ graphBuiltMs: graphMs,
553
+ graphReused: graphBuildInfo.reused,
554
+ graphNodeCount: graph.nodes.size,
555
+ graphFileCount,
556
+ graphChangedSymbolCount: (
557
+ graph.changedSymbolsByFile.get(normalizedFileKey) ?? []
558
+ ).length,
559
+ metadata: { graphBuildMode: graphBuildInfo.mode },
560
+ });
514
561
 
515
- const impact = computeImpactCascade(graph, normalizedFile);
516
-
517
- // Sort by relationship strength (B6) then cap to MAX_FILES.
518
- // directImporters are most impactful, then callers, then reference edges.
519
- const importerSet = new Set(impact.directImporters);
520
- const callerSet = new Set(impact.directCallers);
521
- // neighbors that are neither direct importers nor callers are reference-edge neighbors
522
- const importerOrCallerSet = new Set([
523
- ...impact.directImporters,
524
- ...impact.directCallers,
525
- ]);
526
- const referenceCount = impact.neighborFiles.filter(
527
- (n) => !importerOrCallerSet.has(n),
528
- ).length;
529
- const sortedNeighbors = [...impact.neighborFiles]
530
- .filter((n) => nodeFs.existsSync(n))
531
- // B10: exclude files already edited as primary this turn — their own pipeline
532
- // run is the authoritative diagnostic source; showing them as neighbors is noise.
533
- .filter((n) => !primaryFilesThisTurn.has(normalizeMapKey(n)))
534
- .sort((a, b) => {
535
- const rank = (p: string) =>
536
- importerSet.has(p) ? 0 : (callerSet.has(p) ? 1 : 2);
537
- return rank(a) - rank(b);
538
- })
539
- .slice(0, MAX_FILES);
562
+ impact = computeImpactCascade(graph, normalizedFile);
563
+
564
+ // Sort by relationship strength (B6) then cap to MAX_FILES.
565
+ // directImporters are most impactful, then callers, then reference edges.
566
+ importerSet = new Set(impact.directImporters);
567
+ callerSet = new Set(impact.directCallers);
568
+ // neighbors that are neither direct importers nor callers are reference-edge neighbors
569
+ const importerOrCallerSet = new Set([
570
+ ...impact.directImporters,
571
+ ...impact.directCallers,
572
+ ]);
573
+ referenceCount = impact.neighborFiles.filter(
574
+ (n) => !importerOrCallerSet.has(n),
575
+ ).length;
576
+ sortedNeighbors = [...impact.neighborFiles]
577
+ .filter((n) => nodeFs.existsSync(n))
578
+ .filter((n) => !isExternalOrVendorFile(n, cwd))
579
+ // B10: exclude files already edited as primary this turn their own pipeline
580
+ // run is the authoritative diagnostic source; showing them as neighbors is noise.
581
+ .filter((n) => !primaryFilesThisTurn.has(normalizeMapKey(n)))
582
+ .sort((a, b) => {
583
+ const rank = (p: string) =>
584
+ importerSet.has(p) ? 0 : callerSet.has(p) ? 1 : 2;
585
+ return rank(a) - rank(b);
586
+ })
587
+ .slice(0, MAX_FILES);
588
+ } else {
589
+ logCascade({
590
+ phase: "graph_build",
591
+ filePath,
592
+ graphBuiltMs: 0,
593
+ graphReused: false,
594
+ graphNodeCount: 0,
595
+ graphFileCount: 0,
596
+ graphChangedSymbolCount: 0,
597
+ metadata: {
598
+ graphBuildMode: "skipped",
599
+ reason: "unsupported_kind",
600
+ fileKind,
601
+ },
602
+ });
603
+ }
540
604
 
541
605
  logCascade({
542
606
  phase: "neighbors_computed",
@@ -634,6 +698,41 @@ export async function computeCascadeForFile(
634
698
  const neighborStart = Date.now();
635
699
  const cacheKey = normalizeMapKey(neighborPath);
636
700
 
701
+ const passiveEntry = allDiags.get(cacheKey);
702
+ const hasFreshPassiveErrors =
703
+ passiveEntry != null &&
704
+ Date.now() - passiveEntry.ts < CASCADE_TTL_MS &&
705
+ passiveEntry.diags.some((d) => d.severity === 1);
706
+ const recentlyClean = recentlyCleanNeighborCache.get(cacheKey);
707
+ if (
708
+ recentlyClean &&
709
+ turnSeq - recentlyClean.turnSeq <= RECENTLY_CLEAN_TTL_TURNS &&
710
+ !hasFreshPassiveErrors
711
+ ) {
712
+ producedLspData = true;
713
+ const durationMs = Date.now() - neighborStart;
714
+ logCascade({
715
+ phase: "neighbor_snapshot",
716
+ filePath,
717
+ neighborFile: neighborPath,
718
+ diagnosticCount: 0,
719
+ durationMs,
720
+ autoPropagate: false,
721
+ snapshotMissing: false,
722
+ metadata: {
723
+ recentlyClean: true,
724
+ cleanTurnSeq: recentlyClean.turnSeq,
725
+ },
726
+ });
727
+ return {
728
+ filePath: neighborPath,
729
+ reason: neighborReason(importerSet, callerSet, neighborPath),
730
+ diagnostics: [],
731
+ lspTouched: false,
732
+ durationMs,
733
+ } satisfies CascadeResult["neighbors"][number];
734
+ }
735
+
637
736
  // A5: skip re-touch if this neighbor was already diagnosed at the current
638
737
  // write sequence. A new write (higher writeSeq) invalidates the cache entry.
639
738
  const cached =
@@ -704,6 +803,14 @@ export async function computeCascadeForFile(
704
803
  diagnostics: diags,
705
804
  });
706
805
  }
806
+ if (diags.length === 0) {
807
+ recentlyCleanNeighborCache.set(cacheKey, {
808
+ turnSeq,
809
+ checkedAt: Date.now(),
810
+ });
811
+ } else {
812
+ recentlyCleanNeighborCache.delete(cacheKey);
813
+ }
707
814
  producedLspData = true;
708
815
 
709
816
  logCascade({
@@ -769,7 +876,7 @@ export async function computeCascadeForFile(
769
876
  // CR-3/A2: degraded fallback when no neighbor produced trustworthy LSP data —
770
877
  // not merely when the graph returned zero neighbors.
771
878
  if (!producedLspData) {
772
- appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey);
879
+ appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd);
773
880
  if (neighbors.some((n) => n.reason === "fallback")) {
774
881
  logCascade({
775
882
  phase: "neighbor_fallback",
@@ -867,6 +974,7 @@ function appendFallbackNeighbors(
867
974
  { diags: import("../lsp/client.js").LSPDiagnostic[]; ts: number }
868
975
  >,
869
976
  normalizedFileKey: string,
977
+ cwd: string,
870
978
  ): void {
871
979
  const now = Date.now();
872
980
  const seen = new Set(neighbors.map((n) => normalizeMapKey(n.filePath)));
@@ -874,6 +982,7 @@ function appendFallbackNeighbors(
874
982
  const diagKey = normalizeMapKey(diagPath);
875
983
  if (diagKey === normalizedFileKey || seen.has(diagKey)) continue;
876
984
  if (primaryFilesThisTurn.has(diagKey)) continue;
985
+ if (isExternalOrVendorFile(diagPath, cwd)) continue;
877
986
  if (!nodeFs.existsSync(diagPath)) continue;
878
987
  if (now - ts > CASCADE_TTL_MS) continue;
879
988
  const errors = convertLspDiagnostics(
@@ -921,7 +1030,10 @@ function formatCascadeResult(
921
1030
  });
922
1031
  if (!diagnosticsBlock) return "";
923
1032
 
924
- const impactHeader = formatImpactCascade(impact, RUNTIME_CONFIG.pipeline.cascadeMaxFiles);
1033
+ const impactHeader = formatImpactCascade(
1034
+ impact,
1035
+ RUNTIME_CONFIG.pipeline.cascadeMaxFiles,
1036
+ );
925
1037
  let out = impactHeader
926
1038
  ? `${impactHeader}\n${diagnosticsBlock}`
927
1039
  : diagnosticsBlock;
@@ -972,7 +1084,11 @@ export async function dispatchLint(
972
1084
  const kind = ctx.kind;
973
1085
  if (!kind) return "";
974
1086
 
975
- const groups = withSemgrepGroup(kind, getDispatchGroupsForKind(kind, pi), ctx);
1087
+ const groups = withSemgrepGroup(
1088
+ kind,
1089
+ getDispatchGroupsForKind(kind, pi),
1090
+ ctx,
1091
+ );
976
1092
  if (groups.length === 0) return "";
977
1093
 
978
1094
  await runProviders(ctx);
@@ -1011,11 +1127,16 @@ export async function dispatchLintWithResult(
1011
1127
  fixed: [],
1012
1128
  resolvedCount: 0,
1013
1129
  output: "",
1130
+ blockerOutput: "",
1014
1131
  hasBlockers: false,
1015
1132
  };
1016
1133
  }
1017
1134
 
1018
- const groups = withSemgrepGroup(kind, getDispatchGroupsForKind(kind, pi), ctx);
1135
+ const groups = withSemgrepGroup(
1136
+ kind,
1137
+ getDispatchGroupsForKind(kind, pi),
1138
+ ctx,
1139
+ );
1019
1140
  if (groups.length === 0) {
1020
1141
  return {
1021
1142
  diagnostics: [],
@@ -1025,6 +1146,7 @@ export async function dispatchLintWithResult(
1025
1146
  fixed: [],
1026
1147
  resolvedCount: 0,
1027
1148
  output: "",
1149
+ blockerOutput: "",
1028
1150
  hasBlockers: false,
1029
1151
  };
1030
1152
  }
@@ -35,6 +35,7 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
35
35
  writeGroups: [
36
36
  primary("jsts"),
37
37
  { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["jsts"] },
38
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["jsts"] },
38
39
  { mode: "all", runnerIds: ["ast-grep-napi"], filterKinds: ["jsts"] },
39
40
  { mode: "fallback", runnerIds: ["type-safety"], filterKinds: ["jsts"] },
40
41
  {
@@ -59,6 +60,7 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
59
60
  { mode: "fallback", runnerIds: ["ruff-lint"], filterKinds: ["python"] },
60
61
  { mode: "fallback", runnerIds: ["mypy"], filterKinds: ["python"] },
61
62
  { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["python"] },
63
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["python"] },
62
64
  ],
63
65
  fullOnlyGroups: [
64
66
  { mode: "fallback", runnerIds: ["python-slop"], filterKinds: ["python"] },
@@ -72,6 +74,7 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
72
74
  { mode: "fallback", runnerIds: ["go-vet"], filterKinds: ["go"] },
73
75
  { mode: "fallback", runnerIds: ["golangci-lint"], filterKinds: ["go"] },
74
76
  { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["go"] },
77
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["go"] },
75
78
  ],
76
79
  },
77
80
  rust: {
@@ -81,6 +84,7 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
81
84
  primary("rust"),
82
85
  { mode: "fallback", runnerIds: ["rust-clippy"], filterKinds: ["rust"] },
83
86
  { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["rust"] },
87
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["rust"] },
84
88
  ],
85
89
  },
86
90
  ruby: {
@@ -90,6 +94,7 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
90
94
  primary("ruby"),
91
95
  { mode: "fallback", runnerIds: ["rubocop"], filterKinds: ["ruby"] },
92
96
  { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["ruby"] },
97
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["ruby"] },
93
98
  ],
94
99
  },
95
100
  cxx: {
@@ -100,12 +105,18 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
100
105
  cmake: {
101
106
  name: "CMake Processing",
102
107
  capabilities: ["lint"],
103
- writeGroups: [primary("cmake")],
108
+ writeGroups: [
109
+ primary("cmake"),
110
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["cmake"] },
111
+ ],
104
112
  },
105
113
  shell: {
106
114
  name: "Shell Script Linting",
107
115
  capabilities: ["lint", "security"],
108
- writeGroups: [primary("shell")],
116
+ writeGroups: [
117
+ primary("shell"),
118
+ { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["shell"] },
119
+ ],
109
120
  },
110
121
  json: {
111
122
  name: "JSON Processing",
@@ -24,8 +24,8 @@ export const errorSwallowingRule: FactRule = {
24
24
  filePath: ctx.filePath,
25
25
  line: s.line,
26
26
  column: s.column,
27
- severity: "warning",
28
- semantic: "warning",
27
+ severity: "error",
28
+ semantic: "blocking",
29
29
  message: `Empty catch block silently swallows errors`,
30
30
  });
31
31
  }