pi-lens 3.8.42 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ 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
+
7
26
  ## [3.8.42] - 2026-05-08
8
27
 
9
28
  ### Added
@@ -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
  }
@@ -1127,6 +1127,7 @@ export async function dispatchLintWithResult(
1127
1127
  fixed: [],
1128
1128
  resolvedCount: 0,
1129
1129
  output: "",
1130
+ blockerOutput: "",
1130
1131
  hasBlockers: false,
1131
1132
  };
1132
1133
  }
@@ -1145,6 +1146,7 @@ export async function dispatchLintWithResult(
1145
1146
  fixed: [],
1146
1147
  resolvedCount: 0,
1147
1148
  output: "",
1149
+ blockerOutput: "",
1148
1150
  hasBlockers: false,
1149
1151
  };
1150
1152
  }
@@ -22,12 +22,10 @@ import type {
22
22
  RunnerResult,
23
23
  } from "../types.js";
24
24
  import {
25
- createAvailabilityChecker,
25
+ resolveToolCommand,
26
26
  resolveToolCommandWithInstallFallback,
27
27
  } from "./utils/runner-helpers.js";
28
28
 
29
- const oxlint = createAvailabilityChecker("oxlint", ".exe");
30
-
31
29
  function resolveLocalVp(cwd: string): string | null {
32
30
  const isWin = process.platform === "win32";
33
31
  let dir = cwd;
@@ -81,11 +79,14 @@ const oxlintRunner: RunnerDefinition = {
81
79
  }
82
80
  if (cmd) {
83
81
  args = ["lint", "--format", "unix", ctx.filePath];
84
- } else if (oxlint.isAvailable(cwd)) {
85
- cmd = oxlint.getCommand(cwd);
86
- args = ["--format", "unix", ctx.filePath];
87
82
  } else {
88
- cmd = await resolveToolCommandWithInstallFallback(cwd, "oxlint");
83
+ // Use ctx.hasTool for async availability check — avoids the synchronous
84
+ // spawnSync probe that blocks the event loop on first call per cwd.
85
+ // FactStore caches the result for the session so subsequent writes are free.
86
+ const oxlintCmd = resolveToolCommand(cwd, "oxlint") ?? "oxlint";
87
+ cmd = (await ctx.hasTool(oxlintCmd))
88
+ ? oxlintCmd
89
+ : await resolveToolCommandWithInstallFallback(cwd, "oxlint");
89
90
  args = ["--format", "unix", ctx.filePath];
90
91
  }
91
92
  if (!cmd) {
@@ -449,6 +449,11 @@ const treeSitterRunner: RunnerDefinition = {
449
449
  const queryResults: Diagnostic[][] = [];
450
450
 
451
451
  for (let i = 0; i < effectiveQueries.length; i += CONCURRENCY_LIMIT) {
452
+ // Yield the event loop between batches so already-resolved promises from
453
+ // other parallel groups (LSP, eslint skip, etc.) can drain. Each wasm query
454
+ // batch is synchronous CPU work that would otherwise pin the event loop for
455
+ // the full runner duration, making other runners' latency measurements wrong.
456
+ if (i > 0) await new Promise<void>((r) => setImmediate(r));
452
457
  const batch = effectiveQueries.slice(i, i + CONCURRENCY_LIMIT);
453
458
  const batchResults = await Promise.all(
454
459
  batch.map(async (query) => {
@@ -519,6 +524,8 @@ const treeSitterRunner: RunnerDefinition = {
519
524
  autoFixAvailable: false,
520
525
  fixKind: hasSuggestedFix ? "suggestion" : undefined,
521
526
  fixSuggestion: suggestion,
527
+ matchedText: match.matchedText || undefined,
528
+ astNodeType: match.nodeType || undefined,
522
529
  });
523
530
  }
524
531
  } catch (err) {
@@ -84,6 +84,10 @@ export interface Diagnostic {
84
84
  fixKind?: "pipeline" | "manual" | "suggestion";
85
85
  /** Auto-fix command/suggestion */
86
86
  fixSuggestion?: string;
87
+ /** Exact matched text from tree-sitter (more precise than the full source line) */
88
+ matchedText?: string;
89
+ /** Tree-sitter AST node type of the match (e.g. "call_expression", "template_string") */
90
+ astNodeType?: string;
87
91
  }
88
92
 
89
93
  export interface DispatchResult {
@@ -101,6 +105,8 @@ export interface DispatchResult {
101
105
  resolvedCount: number;
102
106
  /** Formatted output for display */
103
107
  output: string;
108
+ /** Blocking-only portion of output (without auto-fix section) — for turn_end re-surfacing */
109
+ blockerOutput: string;
104
110
  /** Whether any blockers were found */
105
111
  hasBlockers: boolean;
106
112
  }
@@ -13,6 +13,7 @@ import { EventEmitter } from "node:events";
13
13
  import { existsSync } from "node:fs";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import type { MessageConnection } from "vscode-jsonrpc";
16
+ import { logLatency } from "../latency-logger.js";
16
17
  import {
17
18
  createMessageConnection,
18
19
  StreamMessageReader,
@@ -549,10 +550,24 @@ function setupConnectionLifecycle(state: LSPClientState): void {
549
550
  disposeClientConnection(state);
550
551
  });
551
552
 
552
- state.lspProcess.process.on("exit", (_code) => {
553
+ state.lspProcess.process.on("exit", (code) => {
554
+ const wasConnected = state.isConnected;
553
555
  state.isConnected = false;
554
556
  state.isDestroyed = true;
555
557
  disposeClientConnection(state);
558
+ if (wasConnected) {
559
+ logLatency({
560
+ type: "phase",
561
+ phase: "lsp_server_unexpected_exit",
562
+ filePath: state.root,
563
+ durationMs: 0,
564
+ metadata: {
565
+ serverId: state.serverId,
566
+ pid: state.lspProcess.pid,
567
+ exitCode: code ?? null,
568
+ },
569
+ });
570
+ }
556
571
  });
557
572
  }
558
573
 
@@ -30,6 +30,7 @@ export interface LSPState {
30
30
  servers: Map<string, LSPServerInfo>;
31
31
  broken: Map<string, number>; // servers that failed to initialize with retry-at timestamp
32
32
  inFlight: Map<string, Promise<SpawnedServer | undefined>>; // prevent duplicate spawns
33
+ clientSpawnedAt: Map<string, number>; // key: "serverId:root" → epoch ms of last successful spawn
33
34
  }
34
35
 
35
36
  const BROKEN_BASE_COOLDOWN_MS = 15_000;
@@ -137,6 +138,15 @@ export class LSPService {
137
138
  private readonly optionalDisabled = new Set<string>();
138
139
  /** Consecutive failure counts for exponential backoff circuit breaker */
139
140
  private readonly failureCounts = new Map<string, number>();
141
+ /**
142
+ * Last non-empty diagnostic result per normalized file path.
143
+ * Returned as a fallback when no live LSP clients are available so the
144
+ * widget keeps showing the last known issues rather than going blank.
145
+ */
146
+ private readonly lastKnownDiagnostics = new Map<
147
+ string,
148
+ import("./client.js").LSPDiagnostic[]
149
+ >();
140
150
  private readonly recentTouches = new Map<
141
151
  string,
142
152
  { fingerprint: string; touchedAt: number; clientScope: "primary" | "all" }
@@ -150,6 +160,7 @@ export class LSPService {
150
160
  servers: new Map(),
151
161
  broken: new Map(),
152
162
  inFlight: new Map(),
163
+ clientSpawnedAt: new Map(),
153
164
  };
154
165
  }
155
166
 
@@ -287,6 +298,14 @@ export class LSPService {
287
298
  ]);
288
299
 
289
300
  if (!timeoutResult) {
301
+ // Snapshot known client health — scan by serverId prefix (no root needed)
302
+ const knownHealth = [...this.state.clients.entries()]
303
+ .filter(([k]) => servers.some((s) => k.startsWith(`${s.id}:`)))
304
+ .map(([k, c]) => ({
305
+ serverId: k.split(":")[0],
306
+ alive: c.isAlive(),
307
+ spawnedAt: this.state.clientSpawnedAt.get(k) ?? null,
308
+ }));
290
309
  logLatency({
291
310
  type: "phase",
292
311
  phase: "lsp_client_wait_timeout",
@@ -295,6 +314,8 @@ export class LSPService {
295
314
  metadata: {
296
315
  maxWaitMs: effectiveMaxWaitMs,
297
316
  serverIds: servers.map((s) => s.id),
317
+ // servers absent from knownHealth were never spawned or are still spawning
318
+ knownClientHealth: knownHealth,
298
319
  },
299
320
  });
300
321
  }
@@ -374,12 +395,26 @@ export class LSPService {
374
395
  }
375
396
  return { client: existing, info: server };
376
397
  }
398
+ // Dead client — was previously alive, now needs respawn
399
+ const spawnedAt = this.state.clientSpawnedAt.get(key);
400
+ logLatency({
401
+ type: "phase",
402
+ phase: "lsp_server_respawn",
403
+ filePath,
404
+ durationMs: 0,
405
+ metadata: {
406
+ serverId: server.id,
407
+ root,
408
+ uptimeMs: spawnedAt != null ? Date.now() - spawnedAt : null,
409
+ },
410
+ });
377
411
  try {
378
412
  await existing.shutdown();
379
413
  } catch {
380
414
  /* ignore dead client shutdown errors */
381
415
  }
382
416
  this.state.clients.delete(key);
417
+ this.state.clientSpawnedAt.delete(key);
383
418
  this.state.broken.delete(key);
384
419
  }
385
420
 
@@ -482,6 +517,7 @@ export class LSPService {
482
517
  };
483
518
 
484
519
  this.state.clients.set(key, client);
520
+ this.state.clientSpawnedAt.set(key, Date.now());
485
521
  this.failureCounts.delete(key);
486
522
  if (isOptionalServer) {
487
523
  this.optionalDisabled.delete(key);
@@ -699,6 +735,7 @@ export class LSPService {
699
735
  const normalizedPath = normalizeMapKey(filePath);
700
736
  const { clients: spawned, serverCountAttempted } = await this.getClientsForFile(filePath);
701
737
  if (spawned.length === 0) {
738
+ const stale = this.lastKnownDiagnostics.get(normalizedPath);
702
739
  logLatency({
703
740
  type: "phase",
704
741
  phase: "lsp_diagnostics_aggregate",
@@ -707,14 +744,14 @@ export class LSPService {
707
744
  metadata: {
708
745
  serverCountAttempted,
709
746
  serverCountReady: 0,
710
- mergedCount: 0,
747
+ mergedCount: stale?.length ?? 0,
711
748
  dedupDroppedCount: 0,
712
- failureKind: "no_clients",
749
+ failureKind: stale?.length ? "no_clients_stale" : "no_clients",
713
750
  health: "no_clients",
714
751
  servers: [],
715
752
  },
716
753
  });
717
- return [];
754
+ return stale ?? [];
718
755
  }
719
756
 
720
757
  // Per-server entries produced by client waits. Each promise resolves
@@ -840,6 +877,15 @@ export class LSPService {
840
877
  },
841
878
  });
842
879
 
880
+ // Keep last known so the widget can show stale diagnostics if LSP dies.
881
+ // Live clients returning [] means genuinely no errors — clear the stale
882
+ // entry so the widget doesn't show resolved issues.
883
+ if (merged.length > 0) {
884
+ this.lastKnownDiagnostics.set(normalizedPath, merged);
885
+ } else {
886
+ this.lastKnownDiagnostics.delete(normalizedPath);
887
+ }
888
+
843
889
  return merged;
844
890
  }
845
891
 
@@ -48,6 +48,13 @@ export const SERVER_DIAGNOSTIC_STRATEGIES: Record<string, DiagnosticStrategy> =
48
48
  aggregateWaitMs: 1500,
49
49
  expectSemanticSecondPush: false,
50
50
  },
51
+ "python-jedi": {
52
+ seedFirstPush: true,
53
+ pullRetryBudgetMs: 0,
54
+ debounceMs: 100,
55
+ aggregateWaitMs: 1000,
56
+ expectSemanticSecondPush: false,
57
+ },
51
58
  eslint: {
52
59
  seedFirstPush: true,
53
60
  pullRetryBudgetMs: 0,
@@ -969,9 +969,9 @@ export const PythonServer: LSPServerInfo = {
969
969
  },
970
970
  };
971
971
 
972
- export const PythonPylspServer: LSPServerInfo = {
973
- id: "python-pylsp",
974
- name: "Python LSP Server (pylsp)",
972
+ export const PythonJediServer: LSPServerInfo = {
973
+ id: "python-jedi",
974
+ name: "Jedi Language Server",
975
975
  extensions: KIND_EXTENSIONS["python"],
976
976
  root: RootWithFallback(
977
977
  createRootDetector([
@@ -986,10 +986,10 @@ export const PythonPylspServer: LSPServerInfo = {
986
986
  ),
987
987
  async spawn(root) {
988
988
  try {
989
- const proc = await launchLSP("pylsp", [], { cwd: root });
989
+ const proc = await launchLSP("jedi-language-server", [], { cwd: root });
990
990
  const pythonPath = await detectPythonVenv(root);
991
991
  const initialization: Record<string, unknown> = pythonPath
992
- ? { pylsp: { plugins: { jedi: { environment: pythonPath } } } }
992
+ ? { workspace: { environmentPath: pythonPath } }
993
993
  : {};
994
994
  return { process: proc, source: "direct", initialization };
995
995
  } catch {
@@ -1703,8 +1703,7 @@ export const CssServer: LSPServerInfo = {
1703
1703
  export const LSP_SERVERS: LSPServerInfo[] = [
1704
1704
  TypeScriptServer,
1705
1705
  DenoServer,
1706
- PythonServer,
1707
- PythonPylspServer,
1706
+ PythonJediServer,
1708
1707
  GoServer,
1709
1708
  RustServer,
1710
1709
  RubyServer,
@@ -28,7 +28,7 @@ import {
28
28
  resolveToolCommand,
29
29
  resolveToolCommandWithInstallFallback,
30
30
  } from "./dispatch/runners/utils/runner-helpers.js";
31
- import type { PiAgentAPI } from "./dispatch/types.js";
31
+ import type { Diagnostic, PiAgentAPI } from "./dispatch/types.js";
32
32
  import { detectFileKind, getFileKindLabel } from "./file-kinds.js";
33
33
  import {
34
34
  detectFileChangedAfterCommand,
@@ -178,6 +178,8 @@ export interface PipelineResult {
178
178
  fileModified: boolean;
179
179
  /** Files modified by pi-lens format/autofix, including side-effect files. */
180
180
  changedFiles?: string[];
181
+ /** Blocking-only formatted output for turn_end re-surfacing if agent didn't fix */
182
+ inlineBlockerSummary?: string;
181
183
  }
182
184
 
183
185
  // --- Phase timing helpers ---
@@ -765,6 +767,45 @@ export async function runFormatPhase(
765
767
  return { formatChanged, formattersUsed, formatFailures, fileContent };
766
768
  }
767
769
 
770
+ /**
771
+ * Build the 🔴 STOP blocker output with an inline code snippet for each
772
+ * diagnostic so the agent can see the exact line it wrote without re-reading
773
+ * the file.
774
+ *
775
+ * Example:
776
+ * L4: 'randomInt' is declared but its value is never read.
777
+ * → const randomInt = Math.floor(result);
778
+ */
779
+ function buildEnrichedBlockerOutput(
780
+ blockers: Diagnostic[],
781
+ fileContent: string,
782
+ ): string {
783
+ const fileLines = fileContent.split("\n");
784
+ const MAX_SNIPPET = 120; // chars — keep it tight in context
785
+
786
+ let out = `\n\n🔴 STOP — ${blockers.length} issue(s) must be fixed:\n`;
787
+ const shown = blockers.slice(0, 10);
788
+
789
+ for (const d of shown) {
790
+ const lineNo = d.line ?? 1;
791
+ const nodeCtx = d.astNodeType ? ` (${d.astNodeType})` : "";
792
+ out += ` L${lineNo}: ${d.message}${nodeCtx}\n`;
793
+ // Prefer the exact matched node text (tree-sitter); fall back to the
794
+ // full source line (LSP / other runners).
795
+ const snippet = d.matchedText
796
+ ? d.matchedText.trim().split("\n")[0]?.slice(0, MAX_SNIPPET)
797
+ : fileLines[lineNo - 1]?.trim().slice(0, MAX_SNIPPET);
798
+ if (snippet) out += ` → ${snippet}\n`;
799
+ if (d.fixSuggestion) out += ` 💡 ${d.fixSuggestion}\n`;
800
+ }
801
+
802
+ if (blockers.length > 10) {
803
+ out += ` ... and ${blockers.length - 10} more\n`;
804
+ }
805
+
806
+ return out;
807
+ }
808
+
768
809
  // --- Main Pipeline ---
769
810
 
770
811
  export async function runPipeline(
@@ -942,7 +983,17 @@ export async function runPipeline(
942
983
  getDiagnosticTracker().trackAgentFixed(dispatchResult.resolvedCount);
943
984
 
944
985
  let output = "";
945
- if (dispatchResult.output) output += `\n\n${dispatchResult.output}`;
986
+ if (dispatchResult.hasBlockers && fileContent) {
987
+ // Enrich blocker output with a code snippet so the agent can see the
988
+ // exact line it wrote that caused each violation — no re-read needed.
989
+ output += buildEnrichedBlockerOutput(dispatchResult.blockers, fileContent);
990
+ // Append fixed/coverage parts from the original output (slice off the
991
+ // blocker section we're replacing).
992
+ const rest = dispatchResult.output.slice(dispatchResult.blockerOutput.length);
993
+ if (rest) output += rest;
994
+ } else if (dispatchResult.output) {
995
+ output += `\n\n${dispatchResult.output}`;
996
+ }
946
997
  if (fixedCount > 0) {
947
998
  const detail =
948
999
  autofixTools.length > 0 ? ` (${autofixTools.join(", ")})` : "";
@@ -1005,5 +1056,8 @@ export async function runPipeline(
1005
1056
  isError: false,
1006
1057
  fileModified: formatChanged || fixedCount > 0,
1007
1058
  changedFiles: [...piChangedFiles],
1059
+ inlineBlockerSummary: dispatchResult.hasBlockers
1060
+ ? dispatchResult.blockerOutput.trim() || undefined
1061
+ : undefined,
1008
1062
  };
1009
1063
  }
@@ -174,6 +174,59 @@ function rebuildIndexes(graph: ReviewGraph): void {
174
174
  }
175
175
  }
176
176
 
177
+ const GRAPH_CACHE_REL = path.join(".pi-lens", "cache", "review-graph.json");
178
+
179
+ interface PersistedGraphData {
180
+ version: string;
181
+ builtAt: string;
182
+ signature: string;
183
+ nodes: Array<[string, ReviewGraphNode]>;
184
+ edges: ReviewGraphEdge[];
185
+ }
186
+
187
+ function loadPersistedGraph(
188
+ cwd: string,
189
+ ): { signature: string; graph: ReviewGraph } | null {
190
+ const cachePath = path.join(cwd, GRAPH_CACHE_REL);
191
+ try {
192
+ const raw = fs.readFileSync(cachePath, "utf-8");
193
+ const data = JSON.parse(raw) as PersistedGraphData;
194
+ if (data.version !== REVIEW_GRAPH_VERSION) return null;
195
+ const graph: ReviewGraph = {
196
+ version: data.version,
197
+ builtAt: data.builtAt,
198
+ nodes: new Map(data.nodes),
199
+ edges: data.edges,
200
+ edgesByFrom: new Map(),
201
+ edgesByTo: new Map(),
202
+ fileNodes: new Map(),
203
+ symbolNodesByFile: new Map(),
204
+ changedSymbolsByFile: new Map(),
205
+ };
206
+ rebuildIndexes(graph);
207
+ return { signature: data.signature, graph };
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ function persistGraph(cwd: string, signature: string, graph: ReviewGraph): void {
214
+ const cacheDir = path.join(cwd, ".pi-lens", "cache");
215
+ const cachePath = path.join(cwd, GRAPH_CACHE_REL);
216
+ const data: PersistedGraphData = {
217
+ version: graph.version,
218
+ builtAt: graph.builtAt,
219
+ signature,
220
+ nodes: Array.from(graph.nodes.entries()),
221
+ edges: graph.edges,
222
+ };
223
+ const json = JSON.stringify(data);
224
+ fs.mkdir(cacheDir, { recursive: true }, (mkdirErr) => {
225
+ if (mkdirErr) return;
226
+ fs.writeFile(cachePath, json, "utf-8", () => {});
227
+ });
228
+ }
229
+
177
230
  function localImportToFile(
178
231
  cwd: string,
179
232
  filePath: string,
@@ -456,19 +509,40 @@ async function _doBuildGraph(
456
509
  const normalizedChanged = changedFiles.map((file) => normalizeMapKey(file));
457
510
  const filesToBuild = getGraphSourceFiles(cwd);
458
511
  const signature = sourceSignature(filesToBuild);
459
- const cached = _workspaceGraphCache.get(normalizedCwd);
460
- if (cached?.signature === signature) {
461
- const graph = cloneGraph(cached.graph);
512
+
513
+ // Tier 1: in-memory cache (hot path — same process, already built this session)
514
+ const memCached = _workspaceGraphCache.get(normalizedCwd);
515
+ if (memCached?.signature === signature) {
516
+ const graph = cloneGraph(memCached.graph);
517
+ rebuildIndexes(graph);
518
+ graph.changedSymbolsByFile.clear();
519
+ for (const file of normalizedChanged) {
520
+ upsertChangedSymbols(graph, facts, file);
521
+ }
522
+ _lastGraphBuildInfo = { reused: true, mode: "cached" };
523
+ facts.setSessionFact("session.reviewGraph", graph);
524
+ return graph;
525
+ }
526
+
527
+ // Tier 2: disk cache (cold start — files unchanged since last persist)
528
+ const diskCached = loadPersistedGraph(cwd);
529
+ if (diskCached?.signature === signature) {
530
+ const graph = cloneGraph(diskCached.graph);
462
531
  rebuildIndexes(graph);
463
532
  graph.changedSymbolsByFile.clear();
464
533
  for (const file of normalizedChanged) {
465
534
  upsertChangedSymbols(graph, facts, file);
466
535
  }
536
+ _workspaceGraphCache.set(normalizedCwd, {
537
+ signature,
538
+ graph: cloneGraph(diskCached.graph),
539
+ });
467
540
  _lastGraphBuildInfo = { reused: true, mode: "cached" };
468
541
  facts.setSessionFact("session.reviewGraph", graph);
469
542
  return graph;
470
543
  }
471
544
 
545
+ // Tier 3: full build
472
546
  const graph = createEmptyGraph();
473
547
  for (const file of filesToBuild) {
474
548
  const kind = detectFileKind(file);
@@ -490,10 +564,9 @@ async function _doBuildGraph(
490
564
  resolveDeferredSymbolEdges(graph);
491
565
  graph.version = REVIEW_GRAPH_VERSION;
492
566
  graph.builtAt = new Date().toISOString();
493
- _workspaceGraphCache.set(normalizedCwd, {
494
- signature,
495
- graph: cloneGraph(graph),
496
- });
567
+ const graphSnapshot = cloneGraph(graph);
568
+ _workspaceGraphCache.set(normalizedCwd, { signature, graph: graphSnapshot });
569
+ persistGraph(cwd, signature, graphSnapshot); // fire-and-forget
497
570
  _lastGraphBuildInfo = { reused: false, mode: "full" };
498
571
  facts.setSessionFact("session.reviewGraph", graph);
499
572
  return graph;
@@ -61,6 +61,10 @@ export class RuntimeCoordinator {
61
61
  string,
62
62
  { status: "warming" | "ready"; ts: number }
63
63
  >();
64
+ private readonly _pendingInlineBlockers = new Map<
65
+ string,
66
+ { filePath: string; summary: string }
67
+ >();
64
68
 
65
69
  resetForSession(): void {
66
70
  this._sessionGeneration += 1;
@@ -87,6 +91,7 @@ export class RuntimeCoordinator {
87
91
  this._readGuard = null;
88
92
  this._pendingDeferredFormatFiles.clear();
89
93
  this._lspReadWarmState.clear();
94
+ this._pendingInlineBlockers.clear();
90
95
  }
91
96
 
92
97
  get sessionStartedAt(): number {
@@ -132,6 +137,7 @@ export class RuntimeCoordinator {
132
137
 
133
138
  beginTurn(): void {
134
139
  this._cascadeResults = [];
140
+ this._pendingInlineBlockers.clear();
135
141
  this._turnIndex += 1;
136
142
  this._writeIndex = 0;
137
143
  this._reportedThisTurn.clear();
@@ -266,6 +272,20 @@ export class RuntimeCoordinator {
266
272
  return results;
267
273
  }
268
274
 
275
+ recordInlineBlockers(filePath: string, summary: string): void {
276
+ this._pendingInlineBlockers.set(path.resolve(filePath), { filePath, summary });
277
+ }
278
+
279
+ clearInlineBlockers(filePath: string): void {
280
+ this._pendingInlineBlockers.delete(path.resolve(filePath));
281
+ }
282
+
283
+ consumeInlineBlockers(): Array<{ filePath: string; summary: string }> {
284
+ const entries = [...this._pendingInlineBlockers.values()];
285
+ this._pendingInlineBlockers.clear();
286
+ return entries;
287
+ }
288
+
269
289
  get complexityBaselines(): Map<string, FileComplexity> {
270
290
  return this._complexityBaselines;
271
291
  }
@@ -281,6 +281,7 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
281
281
  isError?: boolean;
282
282
  cascadeResult?: import("./cascade-types.js").CascadeResult;
283
283
  changedFiles?: string[];
284
+ inlineBlockerSummary?: string;
284
285
  };
285
286
  const pipelinePromise = runPipeline(
286
287
  {
@@ -401,6 +402,12 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
401
402
  runtime.appendCascadeResult(result.cascadeResult);
402
403
  }
403
404
 
405
+ if (result.inlineBlockerSummary) {
406
+ runtime.recordInlineBlockers(filePath, result.inlineBlockerSummary);
407
+ } else {
408
+ runtime.clearInlineBlockers(filePath);
409
+ }
410
+
404
411
  if (result.isError) {
405
412
  return {
406
413
  content: [...event.content, { type: "text", text: result.output }],
@@ -129,6 +129,14 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
129
129
  const blockerParts: string[] = [];
130
130
  const advisoryParts: string[] = [];
131
131
 
132
+ // Re-surface inline blockers from this turn that the agent didn't fix.
133
+ // These were shown inline during write/edit but the agent moved on without resolving them.
134
+ const unresolvedBlockers = runtime.consumeInlineBlockers();
135
+ for (const { filePath: bPath, summary } of unresolvedBlockers) {
136
+ const displayPath = toRunnerDisplayPath(cwd, bPath);
137
+ blockerParts.push(`Unresolved from this turn — ${displayPath}:\n${summary}`);
138
+ }
139
+
132
140
  // Merge accumulated cascade results from all pipeline runs this turn.
133
141
  // Two-pass dedup:
134
142
  // 1. Primary-level: dedup by primary file (last writer wins).
@@ -43,6 +43,7 @@ interface TreeSitterNode {
43
43
  type: string;
44
44
  text: string;
45
45
  children: TreeSitterNode[];
46
+ parent?: TreeSitterNode | null;
46
47
  isNamed: boolean;
47
48
  childCount: number;
48
49
  startPosition: { row: number; column: number };
@@ -80,6 +81,8 @@ export interface StructuralMatch {
80
81
  line: number;
81
82
  column: number;
82
83
  matchedText: string;
84
+ /** Tree-sitter node type of the first capture (e.g. "call_expression") */
85
+ nodeType?: string;
83
86
  captures: Record<string, string>;
84
87
  }
85
88
 
@@ -973,6 +976,61 @@ export class TreeSitterClient {
973
976
  }
974
977
  case "ts_detached_async_call":
975
978
  return /(Async$|fetch$|request$)/.test(captures.FN?.text ?? "");
979
+ case "incomplete_assertion": {
980
+ const expectNode = captures.EXPECT;
981
+ if (!expectNode) return false;
982
+ const CHAI_PROPERTY_ASSERTIONS = new Set([
983
+ "true",
984
+ "false",
985
+ "null",
986
+ "undefined",
987
+ "empty",
988
+ "NaN",
989
+ "finite",
990
+ "exist",
991
+ "arguments",
992
+ "extensible",
993
+ "sealed",
994
+ "frozen",
995
+ "locked",
996
+ ]);
997
+ // The expect identifier is inside a call_expression. Walk up past that
998
+ // call_expression to the container that determines if it's a complete
999
+ // assertion or an incomplete one.
1000
+ let current: TreeSitterNode | null | undefined = expectNode.parent;
1001
+ if (!current) return false;
1002
+ current = current.parent; // skip the expect(...) call_expression
1003
+ if (!current) return false;
1004
+ // Bare expect(foo); or return expect(foo);
1005
+ if (
1006
+ current.type === "expression_statement" ||
1007
+ current.type === "return_statement"
1008
+ )
1009
+ return true;
1010
+ let lastPropertyName: string | null = null;
1011
+ while (current && current.type === "member_expression") {
1012
+ const propNode = current.children?.find(
1013
+ (c: any) => c.type === "property_identifier",
1014
+ );
1015
+ if (propNode) lastPropertyName = propNode.text;
1016
+ const parent: TreeSitterNode | null | undefined = current.parent;
1017
+ if (!parent) return false;
1018
+ if (
1019
+ parent.type === "expression_statement" ||
1020
+ parent.type === "return_statement"
1021
+ ) {
1022
+ if (
1023
+ lastPropertyName &&
1024
+ CHAI_PROPERTY_ASSERTIONS.has(lastPropertyName)
1025
+ )
1026
+ return false;
1027
+ return true;
1028
+ }
1029
+ if (parent.type === "call_expression") return false;
1030
+ current = parent;
1031
+ }
1032
+ return false;
1033
+ }
976
1034
  case "py_command_injection_sink": {
977
1035
  const mod = captures.MOD?.text ?? "";
978
1036
  const fn = captures.FN?.text ?? "";
@@ -1119,6 +1177,7 @@ export class TreeSitterClient {
1119
1177
  line: firstNode.startPosition.row + 1,
1120
1178
  column: firstNode.startPosition.column + 1,
1121
1179
  matchedText: firstNode.text,
1180
+ nodeType: firstNode.type as string | undefined,
1122
1181
  captures: textCaptures,
1123
1182
  });
1124
1183
  }
package/index.ts CHANGED
@@ -1339,7 +1339,7 @@ export default function (pi: ExtensionAPI) {
1339
1339
  const isExistingFile =
1340
1340
  typeof readGuard?.isNewFile !== "function" ||
1341
1341
  !readGuard.isNewFile(filePath);
1342
- if (readGuard && isExistingFile) {
1342
+ if (readGuard && isExistingFile && !isExternalOrVendor) {
1343
1343
  const { touchedLines, editRanges, preflightError } =
1344
1344
  getTouchedLinesForGuard(event, filePath, runtime.telemetrySessionId);
1345
1345
  if (preflightError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "3.8.42",
3
+ "version": "3.8.43",
4
4
  "type": "module",
5
5
  "description": "Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo",
6
6
  "repository": {
@@ -0,0 +1,50 @@
1
+ # TypeScript Incomplete Assertion
2
+ # Detects expect() chains that are never called (Jest/Vitest style).
3
+ # Chai property assertions (e.g. expect(foo).to.be.true) are excluded.
4
+ id: ts-incomplete-assertion
5
+ name: Incomplete Test Assertion
6
+ severity: error
7
+ category: testing
8
+ defect_class: correctness
9
+ inline_tier: blocking
10
+ language: typescript
11
+
12
+ message: "Incomplete assertion — expect() chain is not called"
13
+
14
+ description: |
15
+ Jest and Vitest matchers are methods that must be called.
16
+ `expect(foo).toBe` (without parentheses) or `expect(foo)` (without a
17
+ matcher) does not actually assert anything. The test will pass silently.
18
+
19
+ ✅ FIX: call the matcher: `expect(foo).toBe(true)`
20
+
21
+ query: |
22
+ (call_expression
23
+ function: (identifier) @EXPECT
24
+ (#eq? @EXPECT "expect")
25
+ arguments: (arguments)) @EXPR
26
+
27
+ metavars:
28
+ - EXPECT
29
+ - EXPR
30
+
31
+ post_filter: incomplete_assertion
32
+
33
+ has_fix: false
34
+
35
+ tags:
36
+ - typescript
37
+ - testing
38
+ - jest
39
+ - vitest
40
+ - assertions
41
+
42
+ examples:
43
+ bad: |
44
+ expect(foo).toBe; // BAD — matcher not called
45
+ expect(foo).not.toBe; // BAD — matcher not called
46
+ expect(foo); // BAD — no matcher at all
47
+
48
+ good: |
49
+ expect(foo).toBe(true); // OK
50
+ expect(foo).not.toBe(1); // OK
@@ -0,0 +1,53 @@
1
+ # TypeScript Switch Non-Case Labels
2
+ # Detects non-case labels inside switch statements in TypeScript
3
+ id: switch-non-case-labels-ts
4
+ name: Switch Should Not Contain Non-Case Labels
5
+ severity: error
6
+ category: reliability
7
+ defect_class: correctness
8
+ inline_tier: blocking
9
+ language: typescript
10
+
11
+ message: "switch statements should not contain non-case labels"
12
+
13
+ description: |
14
+ Non-case labels in switch statements create confusing control flow.
15
+ This is a MISRA rule violation.
16
+
17
+ ✅ FIX: Remove labels or restructure code
18
+
19
+ query: |
20
+ (switch_statement
21
+ body: (switch_body
22
+ (switch_case
23
+ (labeled_statement
24
+ (statement_identifier) @LABEL) @LABELED)))
25
+
26
+ metavars:
27
+ - LABEL
28
+ - LABELED
29
+
30
+ tags:
31
+ - reliability
32
+ - typescript
33
+ - misra
34
+ - control-flow
35
+
36
+ examples:
37
+ bad: |
38
+ switch (x) {
39
+ case 1:
40
+ break;
41
+ myLabel: // BAD
42
+ doSomething();
43
+ }
44
+
45
+ good: |
46
+ switch (x) {
47
+ case 1:
48
+ break;
49
+ default:
50
+ break;
51
+ }
52
+
53
+ has_fix: false
@@ -0,0 +1,71 @@
1
+ # TypeScript Path Traversal
2
+ # Detects fs and path API calls with potentially user-controlled paths.
3
+ id: ts-path-traversal
4
+ name: Path Traversal Risk
5
+ severity: error
6
+ category: security
7
+ defect_class: injection
8
+ inline_tier: blocking
9
+ language: typescript
10
+
11
+ message: "Potential path traversal sink — avoid filesystem I/O with untrusted input"
12
+
13
+ description: |
14
+ File-system APIs (`fs.readFile`, `fs.writeFile`, `path.join`, etc.) are
15
+ vulnerable when path arguments include untrusted input. An attacker can
16
+ inject `../` sequences to access files outside the intended directory.
17
+
18
+ ✅ FIX: validate and sanitize paths; use allowlists or chroot jails.
19
+
20
+ query: |
21
+ (call_expression
22
+ function: (member_expression
23
+ object: (identifier) @MOD
24
+ property: (property_identifier) @FN)
25
+ arguments: (arguments
26
+ [(identifier) (member_expression) (template_string) (call_expression) (binary_expression)] @PATH)
27
+ (#eq? @MOD "fs"))
28
+ (call_expression
29
+ function: (member_expression
30
+ object: (identifier) @MOD
31
+ property: (property_identifier) @FN)
32
+ arguments: (arguments
33
+ [(identifier) (member_expression) (template_string) (call_expression) (binary_expression)] @PATH)
34
+ (#eq? @MOD "path")
35
+ (#match? @FN "^(join|resolve)$"))
36
+ (call_expression
37
+ function: (member_expression
38
+ object: (member_expression
39
+ object: (identifier) @MOD
40
+ property: (property_identifier) @PROMISES)
41
+ property: (property_identifier) @FN)
42
+ arguments: (arguments
43
+ [(identifier) (member_expression) (template_string) (call_expression) (binary_expression)] @PATH)
44
+ (#eq? @MOD "fs")
45
+ (#eq? @PROMISES "promises"))
46
+
47
+ metavars:
48
+ - MOD
49
+ - PROMISES
50
+ - FN
51
+ - PATH
52
+
53
+ post_filter: ts_path_traversal_sink
54
+
55
+ has_fix: false
56
+
57
+ tags:
58
+ - typescript
59
+ - security
60
+ - path-traversal
61
+ - cwe-22
62
+ - owasp-a01
63
+
64
+ examples:
65
+ bad: |
66
+ fs.readFile(req.query.path); // BAD — user controls path
67
+ path.join(baseDir, userFile); // BAD — user controls segment
68
+
69
+ good: |
70
+ fs.readFile("./safe.txt"); // OK — literal path
71
+ path.join(baseDir, "static"); // OK — literal segment