pi-lens 3.0.1 → 3.1.1

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 (64) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +5 -4
  3. package/clients/bus/integration.js +6 -6
  4. package/clients/bus/integration.ts +36 -23
  5. package/clients/dispatch/dispatcher.js +114 -1
  6. package/clients/dispatch/dispatcher.ts +170 -1
  7. package/clients/dispatch/integration.js +3 -1
  8. package/clients/dispatch/integration.ts +13 -1
  9. package/clients/dispatch/plan.js +2 -3
  10. package/clients/dispatch/plan.ts +2 -3
  11. package/clients/dispatch/runners/ast-grep-napi.js +158 -59
  12. package/clients/dispatch/runners/ast-grep-napi.ts +244 -97
  13. package/clients/dispatch/runners/ast-grep.js +5 -7
  14. package/clients/dispatch/runners/ast-grep.ts +13 -12
  15. package/clients/dispatch/runners/biome.js +3 -3
  16. package/clients/dispatch/runners/biome.ts +3 -3
  17. package/clients/dispatch/runners/index.js +5 -3
  18. package/clients/dispatch/runners/index.ts +5 -3
  19. package/clients/dispatch/runners/oxlint.js +41 -115
  20. package/clients/dispatch/runners/oxlint.ts +53 -134
  21. package/clients/dispatch/runners/pyright.js +2 -2
  22. package/clients/dispatch/runners/pyright.ts +2 -2
  23. package/clients/dispatch/runners/ruff.js +2 -2
  24. package/clients/dispatch/runners/ruff.ts +2 -2
  25. package/clients/format-service.js +39 -27
  26. package/clients/format-service.ts +52 -34
  27. package/clients/latency-logger.js +40 -0
  28. package/clients/latency-logger.ts +59 -0
  29. package/clients/safe-spawn-async.js +163 -0
  30. package/clients/safe-spawn-async.ts +220 -0
  31. package/clients/safe-spawn.js +170 -27
  32. package/clients/safe-spawn.ts +208 -29
  33. package/clients/services/runner-service.js +36 -7
  34. package/clients/services/runner-service.ts +64 -18
  35. package/clients/symbol-types.js +5 -0
  36. package/clients/symbol-types.ts +77 -0
  37. package/clients/tree-sitter-client.js +4 -0
  38. package/clients/tree-sitter-client.ts +5 -0
  39. package/clients/tree-sitter-symbol-extractor.js +289 -0
  40. package/clients/tree-sitter-symbol-extractor.ts +331 -0
  41. package/index.ts +48 -1
  42. package/package.json +1 -1
  43. package/rules/ts-slop-rules/.sgconfig.yml +0 -4
  44. package/rules/ts-slop-rules/rules/in-correct-optional-input-type.yml +0 -10
  45. package/rules/ts-slop-rules/rules/jwt-no-verify.yml +0 -13
  46. package/rules/ts-slop-rules/rules/no-architecture-violation.yml +0 -10
  47. package/rules/ts-slop-rules/rules/no-case-declarations.yml +0 -10
  48. package/rules/ts-slop-rules/rules/no-dangerously-set-inner-html.yml +0 -10
  49. package/rules/ts-slop-rules/rules/no-debugger.yml +0 -10
  50. package/rules/ts-slop-rules/rules/no-dupe-args.yml +0 -10
  51. package/rules/ts-slop-rules/rules/no-dupe-class-members.yml +0 -10
  52. package/rules/ts-slop-rules/rules/no-dupe-keys.yml +0 -10
  53. package/rules/ts-slop-rules/rules/no-eval.yml +0 -13
  54. package/rules/ts-slop-rules/rules/no-hardcoded-secrets.yml +0 -12
  55. package/rules/ts-slop-rules/rules/no-implied-eval.yml +0 -12
  56. package/rules/ts-slop-rules/rules/no-inner-html.yml +0 -13
  57. package/rules/ts-slop-rules/rules/no-javascript-url.yml +0 -10
  58. package/rules/ts-slop-rules/rules/no-mutable-default.yml +0 -10
  59. package/rules/ts-slop-rules/rules/no-nested-links.yml +0 -12
  60. package/rules/ts-slop-rules/rules/no-new-symbol.yml +0 -10
  61. package/rules/ts-slop-rules/rules/no-new-wrappers.yml +0 -13
  62. package/rules/ts-slop-rules/rules/no-open-redirect.yml +0 -16
  63. package/rules/ts-slop-rules/rules/weak-rsa-key.yml +0 -12
  64. /package/rules/{ts-slop-rules/rules/slop-rules.yml → ast-grep-rules/slop-patterns.yml} +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,111 @@
2
2
 
3
3
  All notable changes to pi-lens will be documented in this file.
4
4
 
5
+ ## [3.1.1] - 2026-04-01
6
+
7
+ ### Added
8
+ - **File-based latency logging** — Performance analysis via `~/.pi-lens/latency.log`
9
+ - New `latency-logger.ts` module for centralized logging
10
+ - Logs every runner's timing (ts-lsp, ast-grep-napi, biome, test-runner, etc.)
11
+ - Logs tool_result overall timing with result status (completed/blocked/no_output)
12
+ - JSON Lines format for easy analysis with `jq`
13
+ - Read with: `cat ~/.pi-lens/latency.log | jq -s '.[] | select(.type=="runner")'`
14
+
15
+ ---
16
+
17
+ ## [3.1.0] - 2026-04-01
18
+
19
+ ### Changed
20
+ - **Consolidated ast-grep runners** — Unified CLI and NAPI runners with shared rule set
21
+ - NAPI runner now primary for dispatch (100x faster than CLI spawn)
22
+ - Merged ts-slop-rules (21 files) into ast-grep-rules/slop-patterns.yml (33 patterns)
23
+ - Removed 20 duplicate rule files with conflicting IDs (e.g., `ts-jwt-no-verify` vs `jwt-no-verify`)
24
+ - Total: 104 unified rules (71 security/architecture + 33 slop patterns)
25
+ - CLI ast-grep kept only for `ast_grep_search` / `ast_grep_replace` tools
26
+
27
+ ### Fixed
28
+ - **ast-grep-napi stability** — Fixed stack overflow crashes in AST traversal
29
+ - Added `_MAX_AST_DEPTH = 50` depth limit to `findByKind()` and `getAllNodes()`
30
+ - Added `_MAX_RULE_DEPTH = 5` recursion limit for structured rules
31
+ - Added `MAX_MATCHES_PER_RULE = 10` to prevent false positive explosions
32
+ - Added `MAX_TOTAL_DIAGNOSTICS = 50` to prevent output spam
33
+ - NAPI runner now safely handles deeply nested TypeScript files
34
+
35
+ ---
36
+
37
+ ## [3.0.1] - 2026-03-31
38
+
39
+ ### Changed
40
+ - **Documentation refresh**: Updated npm and README descriptions for v3.0.0 features
41
+ - New tagline: "pi extension for real-time code quality"
42
+ - Highlights 31 LSP servers, tree-sitter analysis, auto-install capability
43
+ - Clarified blockers vs warnings split (inline vs `/lens-booboo`)
44
+
45
+ ### Fixed
46
+ - **Entropy threshold**: Increased from 3.5 → 5.5 bits to reduce false positives
47
+ - Previous threshold was too sensitive for tooling codebases
48
+ - Eliminates ~70-80% of "High entropy" warnings on legitimate complex code
49
+
50
+ ---
51
+
52
+ ## [3.0.0] - 2026-03-31
53
+
54
+ ### Breaking Changes
55
+
56
+ #### Removed - Deprecated Commands
57
+ The following deprecated commands have been removed:
58
+ - `/lens-booboo-fix` → Use `/lens-booboo` with autofix capability
59
+ - `/lens-booboo-delta` → Delta mode now automatic
60
+ - `/lens-booboo-refactor` → Use `/lens-booboo` findings
61
+ - `/lens-metrics` → Metrics now in `/lens-booboo` report
62
+ - `/lens-rate` → Use `/lens-booboo` quality scoring
63
+
64
+ #### Changed - Blockers vs Warnings Architecture
65
+ - **🔴 Blockers** (type errors, secrets, empty catch blocks) → Appear **inline** and stop the agent
66
+ - **🟡 Warnings** (complexity, code smells) → Go to **`/lens-booboo`** only (not inline)
67
+ - Tree-sitter rules with `severity: error` now properly block inline
68
+ - Dispatcher checks individual diagnostic semantic, not just group default
69
+
70
+ ### Added - Tree-Sitter Runner
71
+ New structural analysis runner at priority 14:
72
+ - **18 YAML query files** for TypeScript and Python patterns
73
+ - TypeScript: empty-catch, eval, debugger, console-statement, hardcoded-secrets, deep-nesting, deep-promise-chain, mixed-async-styles, nested-ternary, long-parameter-list, await-in-loop, dangerously-set-inner-html
74
+ - Python: bare-except, eval-exec, wildcard-import, is-vs-equals, mutable-default-arg, unreachable-except
75
+ - Blockers appear inline (severity: error), warnings go to `/lens-booboo` (severity: warning)
76
+
77
+ ### Added - Auto-Install for Core Tools
78
+ Four tools now auto-install on first use (no manual setup required):
79
+ 1. **TypeScript Language Server** (`typescript-language-server`) — TS/JS type checking
80
+ 2. **Pyright** — Python type checking (`pip install pyright`)
81
+ 3. **Ruff** — Python linting (`pip install ruff`)
82
+ 4. **Biome** — JS/TS/JSON linting and formatting
83
+
84
+ Installs to `.pi-lens/tools/` with verification step (`--version` check).
85
+
86
+ ### Added - NAPI Security Rules
87
+ Migrated 20 critical security rules to NAPI (fast native execution):
88
+ - Rules with `weight >= 4` are **blocking** (stop the agent)
89
+ - Includes: no-eval, no-hardcoded-secrets, no-implied-eval, no-inner-html, no-dangerously-set-inner-html, no-debugger, no-javascript-url, no-open-redirect, no-mutable-default, weak-rsa-key, jwt-no-verify, and more
90
+ - NAPI runs at priority 15 (after tree-sitter, before slop rules)
91
+
92
+ ### Fixed
93
+ - **Tree-sitter query loading**: Added missing `loadQueries()` call before `getAllQueries()`
94
+ - **Windows path handling**: Changed from `lastIndexOf("/")` to `path.dirname()` for cross-platform compatibility
95
+ - **Dispatcher blocker detection**: Now checks if any individual diagnostic has `semantic === "blocking"`
96
+ - **Biome runner npx fallback**: Uses `npx biome` when `biome` not in PATH directly
97
+ - **LSP ENOENT crashes**: Added `_attachErrorHandler()` to all 23 manual-install LSP servers
98
+ - **LSP initialization timeout**: Increased to 120s (was 45s)
99
+ - **ESLint scope reduction**: Removed `.ts/.tsx` from ESLint LSP (now JS/framework files only)
100
+ - **Biome/Prettier race**: Biome is now default (priority 10), Prettier is fallback only
101
+
102
+ ### Changed
103
+ - **README reorganization**: Removed redundant sections (Architecture, Language Support, Rules, Delta-mode, Slop Detection)
104
+ - **Consolidated Additional Safeguards** into Features section with Runners table
105
+ - **Updated .gitignore**: Local tracking files stay out of repo
106
+ - **Tuned thresholds**: 70-80% false positive reduction in booboo reports
107
+
108
+ ---
109
+
5
110
  ## [2.7.0] - 2026-03-31
6
111
 
7
112
  ### Added - New Lint Runners
package/README.md CHANGED
@@ -206,12 +206,11 @@ pi-lens uses a **dispatcher-runner architecture** for extensible multi-language
206
206
  | **ruff** | Python | 10 | Warning | Python linting (delta-tracked) |
207
207
  | **oxlint** | TS/JS | 12 | Warning | Fast Rust-based JS/TS linter |
208
208
  | **tree-sitter** | TS/JS, Python | 14 | Mixed | AST-based structural analysis (17 patterns) |
209
- | **ast-grep-napi** | TS/JS | 15 | Warning | **100x faster** structural analysis |
209
+ | **ast-grep-napi** | TS/JS | 15 | Warning | **Unified structural analysis** (104 rules) |
210
210
  | **type-safety** | TS | 20 | Mixed | Switch exhaustiveness (blocking), other (warning) |
211
211
  | **shellcheck** | Shell | 20 | Warning | Bash/sh/zsh/fish linting |
212
212
  | **python-slop** | Python | 25 | Warning | AI slop detection (~40 patterns) |
213
213
  | **spellcheck** | Markdown | 30 | Warning | Typo detection in docs |
214
- | **ast-grep** | Go, Rust, Python, etc. | 30 | Warning | Structural analysis via CLI (fallback for non-TS/JS) |
215
214
  | **similarity** | TS | 35 | Silent | Semantic duplicate detection (metrics only) |
216
215
  | **architect** | All | 40 | Warning | Architectural rule violations |
217
216
  | **go-vet** | Go | 50 | Warning | Go static analysis |
@@ -229,7 +228,7 @@ pi-lens uses a **dispatcher-runner architecture** for extensible multi-language
229
228
  - **Warning** — Shown in `/lens-booboo`, not inline (noise reduction)
230
229
  - **Silent** — Tracked in metrics only, never shown
231
230
 
232
- **Disabled runners:** `ts-slop` (merged into `ast-grep-napi`)
231
+ **Consolidated runners:** `ast-grep` (CLI) and `ts-slop` merged into `ast-grep-napi` — unified 104-rule set
233
232
 
234
233
  **Tree-sitter runner patterns** (priority 14, AST-based structural analysis):
235
234
 
@@ -243,7 +242,9 @@ Python (6 patterns):
243
242
 
244
243
  **Custom tree-sitter queries:** Add `.yml` files to `.pi-lens/rules/tree-sitter-queries/{typescript,python}/`
245
244
 
246
- **AI Slop Detection:** The `python-slop` runner (priority 25) detects low-quality patterns in Python code (~40 patterns). TypeScript/JavaScript slop detection is integrated into `ast-grep-napi` runner.
245
+ **AI Slop Detection:**
246
+ - `python-slop` runner (priority 25): ~40 patterns for Python code quality
247
+ - `ast-grep-napi` runner (priority 15): 33 slop patterns + 71 security/architecture rules for TypeScript/JavaScript
247
248
 
248
249
  ---
249
250
 
@@ -7,8 +7,8 @@
7
7
  * - Real-time progress tracking
8
8
  * - Hook integration for tool_result handler
9
9
  */
10
- import { RunnerStarted, RunnerCompleted, ReportReady, FileModified, DiagnosticAggregator, } from "./events.js";
11
10
  import { enableDebug } from "./bus.js";
11
+ import { DiagnosticAggregator, FileModified, ReportReady, RunnerCompleted, RunnerStarted, } from "./events.js";
12
12
  const state = {
13
13
  aggregator: new DiagnosticAggregator(),
14
14
  runnerInProgress: new Set(),
@@ -21,7 +21,7 @@ let unsubscribers = [];
21
21
  * Initialize the bus integration
22
22
  * Call this from session_start handler
23
23
  */
24
- export function initBusIntegration(pi, options) {
24
+ export function initBusIntegration(_pi, options) {
25
25
  if (state.isEnabled)
26
26
  return; // Already initialized
27
27
  if (options?.debug) {
@@ -37,13 +37,13 @@ export function initBusIntegration(pi, options) {
37
37
  const unsubRunnerCompleted = RunnerCompleted.subscribe((event) => {
38
38
  const { runnerId, filePath, durationMs, diagnosticCount } = event.properties;
39
39
  state.runnerInProgress.delete(`${runnerId}:${filePath}`);
40
- // Log slow runners in debug mode
40
+ // Log slow runners in debug mode only
41
41
  if (options?.debug && durationMs > 5000) {
42
42
  console.error(`[bus] Slow runner: ${runnerId} took ${durationMs}ms for ${filePath}`);
43
43
  }
44
- // Log runners that found issues
45
- if (diagnosticCount > 0) {
46
- console.error(`[bus] ${runnerId} found ${diagnosticCount} issues in ${filePath}`);
44
+ // Log runners that found excessive issues (indicates rule misconfiguration)
45
+ if (diagnosticCount > 100) {
46
+ console.error(`[bus] ${runnerId} found ${diagnosticCount} issues in ${filePath} (rules may be too broad)`);
47
47
  }
48
48
  });
49
49
  // Cache reports for quick retrieval
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Bus Integration for pi-lens
3
- *
3
+ *
4
4
  * Connects the event bus system to the existing pi-lens architecture.
5
5
  * This provides:
6
6
  * - Event aggregation for diagnostic collection
@@ -8,19 +8,16 @@
8
8
  * - Hook integration for tool_result handler
9
9
  */
10
10
 
11
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
+ import { enableDebug } from "./bus.js";
11
13
  import {
12
- DiagnosticFound,
13
- RunnerStarted,
14
- RunnerCompleted,
15
- ReportReady,
16
- FileModified,
17
- SessionStarted,
18
- TurnEnded,
19
- DiagnosticAggregator,
20
14
  type Diagnostic,
15
+ DiagnosticAggregator,
16
+ FileModified,
17
+ ReportReady,
18
+ RunnerCompleted,
19
+ RunnerStarted,
21
20
  } from "./events.js";
22
- import { subscribe, enableDebug } from "./bus.js";
23
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
24
21
 
25
22
  // --- Integration State ---
26
23
 
@@ -46,7 +43,10 @@ let unsubscribers: Array<() => void> = [];
46
43
  * Initialize the bus integration
47
44
  * Call this from session_start handler
48
45
  */
49
- export function initBusIntegration(pi: ExtensionAPI, options?: { debug?: boolean }): void {
46
+ export function initBusIntegration(
47
+ _pi: ExtensionAPI,
48
+ options?: { debug?: boolean },
49
+ ): void {
50
50
  if (state.isEnabled) return; // Already initialized
51
51
 
52
52
  if (options?.debug) {
@@ -63,24 +63,29 @@ export function initBusIntegration(pi: ExtensionAPI, options?: { debug?: boolean
63
63
  });
64
64
 
65
65
  const unsubRunnerCompleted = RunnerCompleted.subscribe((event) => {
66
- const { runnerId, filePath, durationMs, diagnosticCount } = event.properties;
66
+ const { runnerId, filePath, durationMs, diagnosticCount } =
67
+ event.properties;
67
68
  state.runnerInProgress.delete(`${runnerId}:${filePath}`);
68
69
 
69
- // Log slow runners in debug mode
70
+ // Log slow runners in debug mode only
70
71
  if (options?.debug && durationMs > 5000) {
71
- console.error(`[bus] Slow runner: ${runnerId} took ${durationMs}ms for ${filePath}`);
72
+ console.error(
73
+ `[bus] Slow runner: ${runnerId} took ${durationMs}ms for ${filePath}`,
74
+ );
72
75
  }
73
76
 
74
- // Log runners that found issues
75
- if (diagnosticCount > 0) {
76
- console.error(`[bus] ${runnerId} found ${diagnosticCount} issues in ${filePath}`);
77
+ // Log runners that found excessive issues (indicates rule misconfiguration)
78
+ if (diagnosticCount > 100) {
79
+ console.error(
80
+ `[bus] ${runnerId} found ${diagnosticCount} issues in ${filePath} (rules may be too broad)`,
81
+ );
77
82
  }
78
83
  });
79
84
 
80
85
  // Cache reports for quick retrieval
81
86
  const unsubReportReady = ReportReady.subscribe((event) => {
82
87
  const { filePath, report, durationMs } = event.properties;
83
-
88
+
84
89
  // Store the report
85
90
  state.lastReport.set(filePath, {
86
91
  output: formatReport(report, durationMs),
@@ -91,10 +96,10 @@ export function initBusIntegration(pi: ExtensionAPI, options?: { debug?: boolean
91
96
  // Track file modifications to clear stale data
92
97
  const unsubFileModified = FileModified.subscribe((event) => {
93
98
  const { filePath } = event.properties;
94
-
99
+
95
100
  // Clear cached report for modified file
96
101
  state.lastReport.delete(filePath);
97
-
102
+
98
103
  // Clear diagnostics aggregator for this file (will be repopulated)
99
104
  state.aggregator.clear(filePath);
100
105
  });
@@ -139,7 +144,12 @@ export function shutdownBusIntegration(): void {
139
144
  // --- Helper Functions ---
140
145
 
141
146
  function formatReport(
142
- report: { blockers: Diagnostic[]; warnings: Diagnostic[]; fixed: Diagnostic[]; silent: Diagnostic[] },
147
+ report: {
148
+ blockers: Diagnostic[];
149
+ warnings: Diagnostic[];
150
+ fixed: Diagnostic[];
151
+ silent: Diagnostic[];
152
+ },
143
153
  durationMs: number,
144
154
  ): string {
145
155
  const lines: string[] = [];
@@ -198,7 +208,10 @@ export function hasRunnersInProgress(filePath?: string): boolean {
198
208
  /**
199
209
  * Get list of runners in progress
200
210
  */
201
- export function getRunnersInProgress(): Array<{ runnerId: string; filePath: string }> {
211
+ export function getRunnersInProgress(): Array<{
212
+ runnerId: string;
213
+ filePath: string;
214
+ }> {
202
215
  return Array.from(state.runnerInProgress).map((key) => {
203
216
  const [runnerId, filePath] = key.split(":");
204
217
  return { runnerId, filePath };
@@ -16,6 +16,7 @@
16
16
  import { detectFileKind } from "../file-kinds.js";
17
17
  import { isTestFile } from "../file-utils.js";
18
18
  import { safeSpawn } from "../safe-spawn.js";
19
+ import { logLatency } from "../latency-logger.js";
19
20
  import { formatDiagnostics } from "./utils/format-utils.js";
20
21
  // --- In-Memory Baseline Store ---
21
22
  export function createBaselineStore() {
@@ -117,11 +118,53 @@ function filterDelta(after, before, keyFn) {
117
118
  const newItems = after.filter((d) => !beforeSet.has(keyFn(d)));
118
119
  return { new: newItems, fixed };
119
120
  }
121
+ const latencyReports = [];
122
+ export function getLatencyReports() {
123
+ return [...latencyReports];
124
+ }
125
+ export function clearLatencyReports() {
126
+ latencyReports.length = 0;
127
+ }
128
+ export function formatLatencyReport(report) {
129
+ const lines = [];
130
+ lines.push(`\n═══════════════════════════════════════════════════════════════`);
131
+ lines.push(`📊 DISPATCH LATENCY REPORT: ${report.filePath.split("/").pop()}`);
132
+ lines.push(` Kind: ${report.fileKind || "unknown"} | Total: ${report.totalDurationMs}ms`);
133
+ lines.push(`───────────────────────────────────────────────────────────────`);
134
+ lines.push(`Runner Duration Status Issues Semantic`);
135
+ lines.push(`───────────────────────────────────────────────────────────────`);
136
+ for (const r of report.runners) {
137
+ const name = r.runnerId.padEnd(30);
138
+ const dur = `${r.durationMs}ms`.padStart(8);
139
+ const status = r.status.padStart(9);
140
+ const issues = String(r.diagnosticCount).padStart(6);
141
+ const sem = r.semantic.padStart(8);
142
+ const slowMarker = r.durationMs > 500 ? " 🔥" : r.durationMs > 100 ? " ⚡" : "";
143
+ lines.push(`${name}${dur}${status}${issues}${sem}${slowMarker}`);
144
+ }
145
+ lines.push(`───────────────────────────────────────────────────────────────`);
146
+ lines.push(`Total: ${report.runners.length} runners | Stopped early: ${report.stoppedEarly}`);
147
+ lines.push(`Diagnostics: ${report.totalDiagnostics} (${report.blockers} blockers, ${report.warnings} warnings)`);
148
+ // Show top 3 slowest
149
+ const sorted = [...report.runners].sort((a, b) => b.durationMs - a.durationMs);
150
+ if (sorted.length > 0 && sorted[0].durationMs > 100) {
151
+ lines.push(`\n🐌 Slowest runners:`);
152
+ for (const r of sorted.slice(0, 3)) {
153
+ if (r.durationMs > 50) {
154
+ lines.push(` ${r.runnerId}: ${r.durationMs}ms (${r.status})`);
155
+ }
156
+ }
157
+ }
158
+ lines.push(`═══════════════════════════════════════════════════════════════`);
159
+ return lines.join("\n");
160
+ }
120
161
  // --- Main Dispatch Function ---
121
162
  export async function dispatchForFile(ctx, groups) {
163
+ const _overallStart = Date.now();
122
164
  const allDiagnostics = [];
123
165
  const _fixed = [];
124
166
  let stopped = false;
167
+ const runnerLatencies = [];
125
168
  for (const group of groups) {
126
169
  if (stopped && ctx.pi.getFlag("stop-on-error")) {
127
170
  break;
@@ -135,14 +178,50 @@ export async function dispatchForFile(ctx, groups) {
135
178
  : group.runnerIds;
136
179
  const semantic = group.semantic ?? "warning";
137
180
  for (const runnerId of runnerIds) {
181
+ const runnerStart = Date.now();
138
182
  const runner = getRunner(runnerId);
139
- if (!runner)
183
+ if (!runner) {
184
+ runnerLatencies.push({
185
+ runnerId,
186
+ startTime: runnerStart,
187
+ endTime: Date.now(),
188
+ durationMs: 0,
189
+ status: "skipped",
190
+ diagnosticCount: 0,
191
+ semantic: "unknown",
192
+ });
140
193
  continue;
194
+ }
141
195
  // Check preconditions
142
196
  if (runner.when && !(await runner.when(ctx))) {
197
+ runnerLatencies.push({
198
+ runnerId,
199
+ startTime: runnerStart,
200
+ endTime: Date.now(),
201
+ durationMs: Date.now() - runnerStart,
202
+ status: "when_skipped",
203
+ diagnosticCount: 0,
204
+ semantic: runner.id,
205
+ });
143
206
  continue;
144
207
  }
145
208
  const result = await runRunner(ctx, runner, semantic);
209
+ const runnerEnd = Date.now();
210
+ const duration = runnerEnd - runnerStart;
211
+ // Track latency for this runner
212
+ runnerLatencies.push({
213
+ runnerId,
214
+ startTime: runnerStart,
215
+ endTime: runnerEnd,
216
+ durationMs: duration,
217
+ status: result.status,
218
+ diagnosticCount: result.diagnostics.length,
219
+ semantic: result.semantic ?? semantic,
220
+ });
221
+ // Log slow runners immediately for real-time debugging
222
+ if (duration > 500) {
223
+ ctx.log(`⚠️ SLOW RUNNER: ${runnerId} took ${duration}ms (${result.status}, ${result.diagnostics.length} issues)`);
224
+ }
146
225
  // Apply delta mode filtering
147
226
  let diagnostics = result.diagnostics;
148
227
  if (ctx.deltaMode && result.semantic !== "silent") {
@@ -175,6 +254,40 @@ export async function dispatchForFile(ctx, groups) {
175
254
  // Warnings tracked but not shown (noise) — surfaced via /lens-booboo
176
255
  let output = formatDiagnostics(blockers, "blocking");
177
256
  output += formatDiagnostics(fixedItems, "fixed");
257
+ // Generate and store latency report
258
+ const overallEnd = Date.now();
259
+ const latencyReport = {
260
+ filePath: ctx.filePath,
261
+ fileKind: ctx.kind,
262
+ overallStartMs: _overallStart,
263
+ overallEndMs: overallEnd,
264
+ totalDurationMs: overallEnd - _overallStart,
265
+ runners: runnerLatencies,
266
+ stoppedEarly: stopped,
267
+ totalDiagnostics: allDiagnostics.length,
268
+ blockers: blockers.length,
269
+ warnings: warnings.length,
270
+ };
271
+ // Store for later analysis
272
+ latencyReports.push(latencyReport);
273
+ // Keep only last 100 reports to prevent memory bloat
274
+ if (latencyReports.length > 100) {
275
+ latencyReports.shift();
276
+ }
277
+ // Log each runner as separate entry for detailed analysis
278
+ for (const runner of runnerLatencies) {
279
+ logLatency({
280
+ type: "runner",
281
+ filePath: ctx.filePath,
282
+ runnerId: runner.runnerId,
283
+ durationMs: runner.durationMs,
284
+ status: runner.status,
285
+ diagnosticCount: runner.diagnosticCount,
286
+ semantic: runner.semantic,
287
+ });
288
+ }
289
+ // Log summary to stderr for real-time monitoring
290
+ console.error(formatLatencyReport(latencyReport));
178
291
  return {
179
292
  diagnostics: allDiagnostics,
180
293
  blockers,
@@ -18,6 +18,7 @@ import type { FileKind } from "../file-kinds.js";
18
18
  import { detectFileKind } from "../file-kinds.js";
19
19
  import { isTestFile } from "../file-utils.js";
20
20
  import { safeSpawn } from "../safe-spawn.js";
21
+ import { logLatency, type LatencyEntry } from "../latency-logger.js";
21
22
  import type {
22
23
  BaselineStore,
23
24
  Diagnostic,
@@ -164,15 +165,103 @@ function filterDelta<T extends { id: string }>(
164
165
  return { new: newItems, fixed };
165
166
  }
166
167
 
168
+ // --- Latency Logger ---
169
+
170
+ export interface RunnerLatency {
171
+ runnerId: string;
172
+ startTime: number;
173
+ endTime: number;
174
+ durationMs: number;
175
+ status: "succeeded" | "failed" | "skipped" | "when_skipped";
176
+ diagnosticCount: number;
177
+ semantic: string;
178
+ }
179
+
180
+ export interface DispatchLatencyReport {
181
+ filePath: string;
182
+ fileKind: string | undefined;
183
+ overallStartMs: number;
184
+ overallEndMs: number;
185
+ totalDurationMs: number;
186
+ runners: RunnerLatency[];
187
+ stoppedEarly: boolean;
188
+ totalDiagnostics: number;
189
+ blockers: number;
190
+ warnings: number;
191
+ }
192
+
193
+ const latencyReports: DispatchLatencyReport[] = [];
194
+
195
+ export function getLatencyReports(): DispatchLatencyReport[] {
196
+ return [...latencyReports];
197
+ }
198
+
199
+ export function clearLatencyReports(): void {
200
+ latencyReports.length = 0;
201
+ }
202
+
203
+ export function formatLatencyReport(report: DispatchLatencyReport): string {
204
+ const lines: string[] = [];
205
+ lines.push(
206
+ `\n═══════════════════════════════════════════════════════════════`,
207
+ );
208
+ lines.push(`📊 DISPATCH LATENCY REPORT: ${report.filePath.split("/").pop()}`);
209
+ lines.push(
210
+ ` Kind: ${report.fileKind || "unknown"} | Total: ${report.totalDurationMs}ms`,
211
+ );
212
+ lines.push(`───────────────────────────────────────────────────────────────`);
213
+ lines.push(
214
+ `Runner Duration Status Issues Semantic`,
215
+ );
216
+ lines.push(`───────────────────────────────────────────────────────────────`);
217
+
218
+ for (const r of report.runners) {
219
+ const name = r.runnerId.padEnd(30);
220
+ const dur = `${r.durationMs}ms`.padStart(8);
221
+ const status = r.status.padStart(9);
222
+ const issues = String(r.diagnosticCount).padStart(6);
223
+ const sem = r.semantic.padStart(8);
224
+ const slowMarker =
225
+ r.durationMs > 500 ? " 🔥" : r.durationMs > 100 ? " ⚡" : "";
226
+ lines.push(`${name}${dur}${status}${issues}${sem}${slowMarker}`);
227
+ }
228
+
229
+ lines.push(`───────────────────────────────────────────────────────────────`);
230
+ lines.push(
231
+ `Total: ${report.runners.length} runners | Stopped early: ${report.stoppedEarly}`,
232
+ );
233
+ lines.push(
234
+ `Diagnostics: ${report.totalDiagnostics} (${report.blockers} blockers, ${report.warnings} warnings)`,
235
+ );
236
+
237
+ // Show top 3 slowest
238
+ const sorted = [...report.runners].sort(
239
+ (a, b) => b.durationMs - a.durationMs,
240
+ );
241
+ if (sorted.length > 0 && sorted[0].durationMs > 100) {
242
+ lines.push(`\n🐌 Slowest runners:`);
243
+ for (const r of sorted.slice(0, 3)) {
244
+ if (r.durationMs > 50) {
245
+ lines.push(` ${r.runnerId}: ${r.durationMs}ms (${r.status})`);
246
+ }
247
+ }
248
+ }
249
+
250
+ lines.push(`═══════════════════════════════════════════════════════════════`);
251
+ return lines.join("\n");
252
+ }
253
+
167
254
  // --- Main Dispatch Function ---
168
255
 
169
256
  export async function dispatchForFile(
170
257
  ctx: DispatchContext,
171
258
  groups: RunnerGroup[],
172
259
  ): Promise<DispatchResult> {
260
+ const _overallStart = Date.now();
173
261
  const allDiagnostics: Diagnostic[] = [];
174
262
  const _fixed: Diagnostic[] = [];
175
263
  let stopped = false;
264
+ const runnerLatencies: RunnerLatency[] = [];
176
265
 
177
266
  for (const group of groups) {
178
267
  if (stopped && ctx.pi.getFlag("stop-on-error")) {
@@ -190,15 +279,56 @@ export async function dispatchForFile(
190
279
  const semantic = group.semantic ?? "warning";
191
280
 
192
281
  for (const runnerId of runnerIds) {
282
+ const runnerStart = Date.now();
193
283
  const runner = getRunner(runnerId);
194
- if (!runner) continue;
284
+ if (!runner) {
285
+ runnerLatencies.push({
286
+ runnerId,
287
+ startTime: runnerStart,
288
+ endTime: Date.now(),
289
+ durationMs: 0,
290
+ status: "skipped",
291
+ diagnosticCount: 0,
292
+ semantic: "unknown",
293
+ });
294
+ continue;
295
+ }
195
296
 
196
297
  // Check preconditions
197
298
  if (runner.when && !(await runner.when(ctx))) {
299
+ runnerLatencies.push({
300
+ runnerId,
301
+ startTime: runnerStart,
302
+ endTime: Date.now(),
303
+ durationMs: Date.now() - runnerStart,
304
+ status: "when_skipped",
305
+ diagnosticCount: 0,
306
+ semantic: runner.id,
307
+ });
198
308
  continue;
199
309
  }
200
310
 
201
311
  const result = await runRunner(ctx, runner, semantic);
312
+ const runnerEnd = Date.now();
313
+ const duration = runnerEnd - runnerStart;
314
+
315
+ // Track latency for this runner
316
+ runnerLatencies.push({
317
+ runnerId,
318
+ startTime: runnerStart,
319
+ endTime: runnerEnd,
320
+ durationMs: duration,
321
+ status: result.status,
322
+ diagnosticCount: result.diagnostics.length,
323
+ semantic: result.semantic ?? semantic,
324
+ });
325
+
326
+ // Log slow runners immediately for real-time debugging
327
+ if (duration > 500) {
328
+ ctx.log(
329
+ `⚠️ SLOW RUNNER: ${runnerId} took ${duration}ms (${result.status}, ${result.diagnostics.length} issues)`,
330
+ );
331
+ }
202
332
 
203
333
  // Apply delta mode filtering
204
334
  let diagnostics = result.diagnostics;
@@ -243,6 +373,45 @@ export async function dispatchForFile(
243
373
  let output = formatDiagnostics(blockers, "blocking");
244
374
  output += formatDiagnostics(fixedItems, "fixed");
245
375
 
376
+ // Generate and store latency report
377
+ const overallEnd = Date.now();
378
+ const latencyReport: DispatchLatencyReport = {
379
+ filePath: ctx.filePath,
380
+ fileKind: ctx.kind,
381
+ overallStartMs: _overallStart,
382
+ overallEndMs: overallEnd,
383
+ totalDurationMs: overallEnd - _overallStart,
384
+ runners: runnerLatencies,
385
+ stoppedEarly: stopped,
386
+ totalDiagnostics: allDiagnostics.length,
387
+ blockers: blockers.length,
388
+ warnings: warnings.length,
389
+ };
390
+
391
+ // Store for later analysis
392
+ latencyReports.push(latencyReport);
393
+
394
+ // Keep only last 100 reports to prevent memory bloat
395
+ if (latencyReports.length > 100) {
396
+ latencyReports.shift();
397
+ }
398
+
399
+ // Log each runner as separate entry for detailed analysis
400
+ for (const runner of runnerLatencies) {
401
+ logLatency({
402
+ type: "runner",
403
+ filePath: ctx.filePath,
404
+ runnerId: runner.runnerId,
405
+ durationMs: runner.durationMs,
406
+ status: runner.status,
407
+ diagnosticCount: runner.diagnosticCount,
408
+ semantic: runner.semantic,
409
+ });
410
+ }
411
+
412
+ // Log summary to stderr for real-time monitoring
413
+ console.error(formatLatencyReport(latencyReport));
414
+
246
415
  return {
247
416
  diagnostics: allDiagnostics,
248
417
  blockers,
@@ -5,7 +5,9 @@
5
5
  * with the existing index.ts tool_result handler.
6
6
  */
7
7
  import { detectFileKind } from "../file-kinds.js";
8
- import { createBaselineStore, createDispatchContext } from "./dispatcher.js";
8
+ import { clearLatencyReports, createBaselineStore, createDispatchContext, formatLatencyReport, getLatencyReports, } from "./dispatcher.js";
9
+ // Re-export latency tracking types and functions
10
+ export { clearLatencyReports, formatLatencyReport, getLatencyReports };
9
11
  // Import runners to register them
10
12
  import "./runners/index.js";
11
13
  /**