pi-lens 3.6.7 → 3.7.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,84 @@
2
2
 
3
3
  All notable changes to pi-lens will be documented in this file.
4
4
 
5
+ ## [3.7.1] - 2026-04-05
6
+
7
+ ### Added
8
+ - **ESLint dispatch runner** — Projects with `.eslintrc` / `eslint.config.js` (any variant)
9
+ now run ESLint automatically on every JS/TS file write. Prefers local
10
+ `node_modules/.bin/eslint` over global. Skips silently on projects using Biome/OxLint
11
+ (no ESLint config). ESLint errors (severity 2) are blocking; warnings are non-blocking.
12
+
13
+ - **golangci-lint dispatch runner** — Go projects with `.golangci.yml` / `.golangci.yaml`
14
+ now run golangci-lint on every `.go` file write (in addition to `go-vet`). Parses JSON
15
+ output. Skips when no config is present (avoids default-rule noise on non-opted-in
16
+ projects). 60s timeout.
17
+
18
+ - **RuboCop dispatch runner** — Ruby files (`.rb`, `.rake`, `.gemspec`, `.ru`) now run
19
+ RuboCop in lint-only mode on every write. Prefers `bundle exec rubocop` when a Gemfile
20
+ references rubocop. Fatal/error offenses are blocking; convention/refactor are warnings.
21
+
22
+ - **`ruby` file kind** — `.rb`, `.rake`, `.gemspec`, `.ru` files are now recognised as
23
+ `ruby` kind, enabling file-kind-gated runners and formatter detection.
24
+
25
+ ---
26
+
27
+ ## [3.7.0] - 2026-04-05
28
+
29
+ ### Added
30
+ - **Test runner in pipeline** — After every file write/edit, pi-lens now automatically detects and
31
+ runs the corresponding test file (vitest, jest, pytest). Results surface inline so the agent sees
32
+ failures immediately without a separate test step. Supports TypeScript/JS/Python; file-level
33
+ targeted — only the test for the edited file runs, not the full suite.
34
+
35
+ - **Parallel dispatch groups** — Lint runners now execute in parallel across independent groups
36
+ (e.g. `lsp`, `tree-sitter`, `ast-grep-napi`, `type-safety`, `similarity` all fire at once).
37
+ Typical wall-clock savings: 500–1500ms per file write (`parallelGainMs` logged in latency log).
38
+
39
+ ### Fixed
40
+ - **`semantic: "none"` when 0 diagnostics** — LSP, Pyright, and type-safety runners were returning
41
+ `semantic: "warning"` even when `diagnosticCount` was 0 (clean file). Now correctly returns
42
+ `"none"` when no diagnostics are present, `"warning"` when warnings exist, `"blocking"` on errors.
43
+
44
+ - **`ast_grep_replace` with `apply=true` not writing files** — Replaced tool was silently
45
+ discarding the rewritten content instead of persisting it to disk.
46
+
47
+ - **Pipeline event loop blocked during test execution** — `spawnSync` in the test runner was
48
+ blocking the Node.js event loop for the duration of the test run. Switched to async spawn.
49
+
50
+ - **Formatters: venv/vendor/node_modules awareness** — Formatters now skip files inside virtual
51
+ environments, vendor directories, and `node_modules` instead of attempting to format them.
52
+ CSharpier detection also improved.
53
+
54
+ - **Formatter nearest-wins resolution** — When multiple formatter configs exist at different
55
+ directory levels, the one closest to the edited file is now used (was previously using the
56
+ root-level config regardless of nesting).
57
+
58
+ - **Prettier auto-install** — Prettier is now auto-installed when detected as the project
59
+ formatter but not present, consistent with the Biome/Ruff auto-install behaviour.
60
+
61
+ - **6 missing formatters added** — `clang-format` (C/C++/ObjC), `ktlint` (Kotlin), `scalafmt`
62
+ (Scala), `mix format` (Elixir), `dart format` (Dart), `terraform fmt` (HCL) now detected
63
+ and invoked automatically.
64
+
65
+ - **LSP tier-4 install prompts** — Corrected missing interactive-install prompts for tier-4
66
+ language servers (less common languages). Users now see the install suggestion instead of a
67
+ silent skip.
68
+
69
+ ### Changed
70
+ - **`startedAt` added to latency log runner entries** — Every runner entry now records when it
71
+ started, making wall-clock vs. sequential comparisons accurate. `dispatch_complete` also logs
72
+ `parallelGainMs = sumMs - wallClockMs` to quantify parallelism benefit.
73
+
74
+ - **Dynamic imports removed from hot path** — Dispatch module no longer uses `await import()`
75
+ for runner loading; all imports are static, eliminating ~50ms warm-up latency on first dispatch.
76
+
77
+ ### Tests
78
+ - Added formatter venv/vendor resolution and interactive-install coverage
79
+ - Added LSP lifecycle test suite with mock LSP server (process spawn, open/change/close, shutdown)
80
+
81
+ ---
82
+
5
83
  ## [3.6.7] - 2026-04-04
6
84
 
7
85
  ### Fixed
@@ -96,21 +96,43 @@ export class AstGrepClient {
96
96
  paths: string[],
97
97
  apply = false,
98
98
  ): Promise<{ matches: AstGrepMatch[]; applied: boolean; error?: string }> {
99
- const args = [
99
+ const baseArgs = ["run", "-p", pattern, "-r", rewrite, "--lang", lang];
100
+
101
+ if (!apply) {
102
+ // Dry-run: --json=compact shows what would change without writing
103
+ const result = await this.runner.exec([
104
+ ...baseArgs,
105
+ "--json=compact",
106
+ ...paths,
107
+ ]);
108
+ return { matches: result.matches, applied: false, error: result.error };
109
+ }
110
+
111
+ // Apply: --update-all and --json are MUTUALLY EXCLUSIVE in sg.
112
+ // Run twice:
113
+ // 1. --update-all to actually write the files
114
+ // 2. --json=compact (without rewrite) to collect matches for display
115
+ const applyResult = await this.runner.exec([
116
+ ...baseArgs,
117
+ "--update-all",
118
+ ...paths,
119
+ ]);
120
+ if (applyResult.error) {
121
+ return { matches: [], applied: false, error: applyResult.error };
122
+ }
123
+
124
+ // Search for what was changed (pattern no longer matches after rewrite,
125
+ // so search for the rewrite pattern to show what was applied)
126
+ const searchResult = await this.runner.exec([
100
127
  "run",
101
128
  "-p",
102
- pattern,
103
- "-r",
104
129
  rewrite,
105
130
  "--lang",
106
131
  lang,
107
132
  "--json=compact",
108
- ];
109
- if (apply) args.push("--update-all");
110
- args.push(...paths);
111
-
112
- const result = await this.runner.exec(args);
113
- return { matches: result.matches, applied: apply, error: result.error };
133
+ ...paths,
134
+ ]);
135
+ return { matches: searchResult.matches, applied: true, error: undefined };
114
136
  }
115
137
 
116
138
  /**
@@ -254,6 +254,135 @@ export function formatLatencyReport(report: DispatchLatencyReport): string {
254
254
  return lines.join("\n");
255
255
  }
256
256
 
257
+ // --- Group runner (used by dispatchForFile for parallel execution) ---
258
+
259
+ interface GroupResult {
260
+ diagnostics: Diagnostic[];
261
+ latencies: RunnerLatency[];
262
+ hadBlocker: boolean;
263
+ }
264
+
265
+ /**
266
+ * Execute all runners in a single group.
267
+ *
268
+ * - mode "fallback": run runners sequentially and stop at the first
269
+ * one that succeeds (returns status !== "skipped").
270
+ * - mode "all" (default): run all runners in the group sequentially
271
+ * and collect every diagnostic.
272
+ *
273
+ * Groups themselves are run in parallel by dispatchForFile, so this
274
+ * function must NOT mutate shared state.
275
+ */
276
+ async function runGroup(
277
+ ctx: DispatchContext,
278
+ group: RunnerGroup,
279
+ ): Promise<GroupResult> {
280
+ const diagnostics: Diagnostic[] = [];
281
+ const latencies: RunnerLatency[] = [];
282
+ let hadBlocker = false;
283
+
284
+ // Filter runners by kind if specified
285
+ const runnerIds = group.filterKinds
286
+ ? group.runnerIds.filter((id) => {
287
+ const runner = getRunner(id);
288
+ return runner && ctx.kind && group.filterKinds?.includes(ctx.kind);
289
+ })
290
+ : group.runnerIds;
291
+
292
+ const semantic = group.semantic ?? "warning";
293
+
294
+ for (const runnerId of runnerIds) {
295
+ const runnerStart = Date.now();
296
+ const runner = getRunner(runnerId);
297
+
298
+ if (!runner) {
299
+ latencies.push({
300
+ runnerId,
301
+ startTime: runnerStart,
302
+ endTime: Date.now(),
303
+ durationMs: 0,
304
+ status: "skipped",
305
+ diagnosticCount: 0,
306
+ semantic: "unknown",
307
+ });
308
+ logLatency({
309
+ type: "runner",
310
+ filePath: ctx.filePath,
311
+ runnerId,
312
+ durationMs: 0,
313
+ status: "not_registered",
314
+ diagnosticCount: 0,
315
+ semantic: "unknown",
316
+ });
317
+ continue;
318
+ }
319
+
320
+ // Check preconditions
321
+ if (runner.when && !(await runner.when(ctx))) {
322
+ latencies.push({
323
+ runnerId,
324
+ startTime: runnerStart,
325
+ endTime: Date.now(),
326
+ durationMs: Date.now() - runnerStart,
327
+ status: "when_skipped",
328
+ diagnosticCount: 0,
329
+ semantic: runner.id,
330
+ });
331
+ logLatency({
332
+ type: "runner",
333
+ filePath: ctx.filePath,
334
+ runnerId,
335
+ durationMs: 0,
336
+ status: "when_skipped",
337
+ diagnosticCount: 0,
338
+ semantic: "when_condition",
339
+ });
340
+ continue;
341
+ }
342
+
343
+ const result = await runRunner(ctx, runner, semantic);
344
+ const runnerEnd = Date.now();
345
+ const duration = runnerEnd - runnerStart;
346
+
347
+ latencies.push({
348
+ runnerId,
349
+ startTime: runnerStart,
350
+ endTime: runnerEnd,
351
+ durationMs: duration,
352
+ status: result.status,
353
+ diagnosticCount: result.diagnostics.length,
354
+ semantic: result.semantic ?? semantic,
355
+ });
356
+ logLatency({
357
+ type: "runner",
358
+ filePath: ctx.filePath,
359
+ runnerId,
360
+ startedAt: new Date(runnerStart).toISOString(),
361
+ durationMs: duration,
362
+ status: result.status,
363
+ diagnosticCount: result.diagnostics.length,
364
+ semantic: result.semantic ?? semantic,
365
+ });
366
+
367
+ diagnostics.push(...result.diagnostics);
368
+
369
+ const resultSemantic = result.semantic ?? semantic;
370
+ if (
371
+ (resultSemantic === "blocking" && result.diagnostics.length > 0) ||
372
+ result.diagnostics.some((d) => d.semantic === "blocking")
373
+ ) {
374
+ hadBlocker = true;
375
+ }
376
+
377
+ // mode:"fallback" — stop at first runner that produced results
378
+ if (group.mode === "fallback" && result.status !== "skipped") {
379
+ break;
380
+ }
381
+ }
382
+
383
+ return { diagnostics, latencies, hadBlocker };
384
+ }
385
+
257
386
  // --- Main Dispatch Function ---
258
387
 
259
388
  export async function dispatchForFile(
@@ -280,124 +409,38 @@ export async function dispatchForFile(
280
409
  },
281
410
  });
282
411
 
283
- for (const group of groups) {
284
- if (stopped && ctx.pi.getFlag("stop-on-error")) {
285
- break;
286
- }
287
-
288
- // Filter runners by kind if specified
289
- const runnerIds = group.filterKinds
290
- ? group.runnerIds.filter((id) => {
291
- const runner = getRunner(id);
292
- return runner && ctx.kind && group.filterKinds?.includes(ctx.kind);
293
- })
294
- : group.runnerIds;
295
-
296
- const semantic = group.semantic ?? "warning";
297
-
298
- for (const runnerId of runnerIds) {
299
- const runnerStart = Date.now();
300
- const runner = getRunner(runnerId);
301
- if (!runner) {
302
- runnerLatencies.push({
303
- runnerId,
304
- startTime: runnerStart,
305
- endTime: Date.now(),
306
- durationMs: 0,
307
- status: "skipped",
308
- diagnosticCount: 0,
309
- semantic: "unknown",
310
- });
311
- logLatency({
312
- type: "runner",
313
- filePath: ctx.filePath,
314
- runnerId,
315
- durationMs: 0,
316
- status: "not_registered",
317
- diagnosticCount: 0,
318
- semantic: "unknown",
319
- });
320
- continue;
321
- }
322
-
323
- // Check preconditions
324
- if (runner.when && !(await runner.when(ctx))) {
325
- runnerLatencies.push({
326
- runnerId,
327
- startTime: runnerStart,
328
- endTime: Date.now(),
329
- durationMs: Date.now() - runnerStart,
330
- status: "when_skipped",
331
- diagnosticCount: 0,
332
- semantic: runner.id,
333
- });
334
- logLatency({
335
- type: "runner",
336
- filePath: ctx.filePath,
337
- runnerId,
338
- durationMs: 0,
339
- status: "when_skipped",
340
- diagnosticCount: 0,
341
- semantic: "when_condition",
342
- });
343
- continue;
344
- }
345
-
346
- const result = await runRunner(ctx, runner, semantic);
347
- const runnerEnd = Date.now();
348
- const duration = runnerEnd - runnerStart;
349
-
350
- // Track latency for this runner
351
- runnerLatencies.push({
352
- runnerId,
353
- startTime: runnerStart,
354
- endTime: runnerEnd,
355
- durationMs: duration,
356
- status: result.status,
357
- diagnosticCount: result.diagnostics.length,
358
- semantic: result.semantic ?? semantic,
359
- });
360
-
361
- // IMMEDIATE LOG: Each runner result (for debugging)
362
- logLatency({
363
- type: "runner",
364
- filePath: ctx.filePath,
365
- runnerId,
366
- durationMs: duration,
367
- status: result.status,
368
- diagnosticCount: result.diagnostics.length,
369
- semantic: result.semantic ?? semantic,
370
- });
371
-
372
- // Apply delta mode filtering
373
- let diagnostics = result.diagnostics;
374
- if (ctx.deltaMode && result.semantic !== "silent") {
375
- const before = ctx.baselines.get(ctx.filePath);
376
- if (before) {
377
- const filtered = filterDelta(
378
- diagnostics,
379
- before as Diagnostic[],
380
- (d) => d.id,
381
- );
382
- diagnostics = filtered.new;
383
- // TODO: Track fixed diagnostics
384
- }
385
- // Update baseline
386
- ctx.baselines.set(ctx.filePath, [...allDiagnostics, ...diagnostics]);
387
- }
388
-
389
- allDiagnostics.push(...diagnostics);
412
+ // Run all groups in parallel — they are independent and don't depend on
413
+ // each other's results. Within each group, mode:"fallback" semantics are
414
+ // preserved (sequential first-success). Results are merged in original
415
+ // group order so output is deterministic.
416
+ const groupResults = await Promise.all(
417
+ groups.map((group) => runGroup(ctx, group)),
418
+ );
390
419
 
391
- // Check for blockers - use result semantic (not group default) and check individual diagnostics
392
- const resultSemantic = result.semantic ?? semantic;
393
- if (resultSemantic === "blocking" && diagnostics.length > 0) {
394
- stopped = true;
395
- }
396
- // Also check if any individual diagnostic is blocking
397
- if (diagnostics.some((d) => d.semantic === "blocking")) {
398
- stopped = true;
420
+ for (const {
421
+ diagnostics: groupDiags,
422
+ latencies,
423
+ hadBlocker,
424
+ } of groupResults) {
425
+ runnerLatencies.push(...latencies);
426
+
427
+ // Apply delta mode filtering across the accumulated set
428
+ let diagnostics = groupDiags;
429
+ if (ctx.deltaMode) {
430
+ const before = ctx.baselines.get(ctx.filePath);
431
+ if (before) {
432
+ const filtered = filterDelta(
433
+ diagnostics,
434
+ before as Diagnostic[],
435
+ (d) => d.id,
436
+ );
437
+ diagnostics = filtered.new;
399
438
  }
439
+ ctx.baselines.set(ctx.filePath, [...allDiagnostics, ...diagnostics]);
400
440
  }
441
+
442
+ allDiagnostics.push(...diagnostics);
443
+ if (hadBlocker) stopped = true;
401
444
  }
402
445
 
403
446
  // Categorize results
@@ -440,14 +483,20 @@ export async function dispatchForFile(
440
483
  // No need to log again here - would create duplicates in the log
441
484
 
442
485
  // Log summary to latency log only (not console - avoid noise)
486
+ const sumMs = runnerLatencies.reduce((s, r) => s + r.durationMs, 0);
487
+ const wallClockMs = latencyReport.totalDurationMs;
443
488
  logLatency({
444
489
  type: "tool_result",
445
490
  filePath: ctx.filePath,
446
- durationMs: latencyReport.totalDurationMs,
491
+ durationMs: wallClockMs,
492
+ wallClockMs,
493
+ sumMs,
494
+ parallelGainMs: Math.max(0, sumMs - wallClockMs),
447
495
  result: "dispatch_complete",
448
496
  metadata: {
449
497
  runners: runnerLatencies.map((r) => ({
450
498
  id: r.runnerId,
499
+ startedAt: new Date(r.startTime).toISOString(),
451
500
  duration: r.durationMs,
452
501
  status: r.status,
453
502
  })),
@@ -11,10 +11,13 @@ import {
11
11
  createBaselineStore,
12
12
  createDispatchContext,
13
13
  type DispatchLatencyReport,
14
+ dispatchForFile,
14
15
  formatLatencyReport,
15
16
  getLatencyReports,
17
+ getRunnersForKind,
16
18
  type RunnerLatency,
17
19
  } from "./dispatcher.js";
20
+ import { TOOL_PLANS } from "./plan.js";
18
21
  import type { BaselineStore, DispatchResult, PiAgentAPI } from "./types.js";
19
22
 
20
23
  export type { DispatchLatencyReport, RunnerLatency };
@@ -57,11 +60,6 @@ export async function dispatchLint(
57
60
  // pre-existing issues after the first write.
58
61
  const ctx = createDispatchContext(filePath, cwd, pi, sessionBaselines, true);
59
62
 
60
- // Import dispatchForFile dynamically to avoid circular deps
61
- const { dispatchForFile } = await import("./dispatcher.js");
62
- const { getRunnersForKind } = await import("./dispatcher.js");
63
- const { TOOL_PLANS } = await import("./plan.js");
64
-
65
63
  const kind = ctx.kind;
66
64
  if (!kind) return "";
67
65
 
@@ -82,10 +80,6 @@ export async function dispatchLintWithResult(
82
80
  ): Promise<DispatchResult> {
83
81
  const ctx = createDispatchContext(filePath, cwd, pi, sessionBaselines, true);
84
82
 
85
- const { dispatchForFile } = await import("./dispatcher.js");
86
- const { getRunnersForKind } = await import("./dispatcher.js");
87
- const { TOOL_PLANS } = await import("./plan.js");
88
-
89
83
  const kind = ctx.kind;
90
84
  if (!kind) {
91
85
  return {
@@ -110,7 +104,7 @@ export async function dispatchLintWithResult(
110
104
  };
111
105
  }
112
106
 
113
- return await dispatchForFile(ctx, plan.groups);
107
+ return dispatchForFile(ctx, plan.groups);
114
108
  }
115
109
 
116
110
  /**
@@ -136,7 +130,6 @@ export async function getAvailableRunners(filePath: string): Promise<string[]> {
136
130
  const kind = detectFileKind(filePath);
137
131
  if (!kind) return [];
138
132
 
139
- const { getRunnersForKind } = await import("./dispatcher.js");
140
133
  const runners = getRunnersForKind(kind);
141
134
  return runners.map((r) => r.id);
142
135
  }
@@ -41,6 +41,8 @@ export const TOOL_PLANS: Record<string, ToolPlan> = {
41
41
  { mode: "fallback", runnerIds: ["type-safety"], filterKinds: ["jsts"] },
42
42
  // Similarity detection — warns about duplicated/reusable code
43
43
  { mode: "fallback", runnerIds: ["similarity"], filterKinds: ["jsts"] },
44
+ // ESLint: only fires when project has eslint config (skips Biome/OxLint projects)
45
+ { mode: "fallback", runnerIds: ["eslint"], filterKinds: ["jsts"] },
44
46
  // Note: ast-grep CLI kept for ast_grep_search/ast_grep_replace tools only
45
47
  // Note: biome, oxlint handled by direct auto-fix calls in index.ts (not in dispatch)
46
48
  // Architectural rules (guidance only, not blocking) - runs via /lens-booboo only
@@ -71,8 +73,10 @@ export const TOOL_PLANS: Record<string, ToolPlan> = {
71
73
  groups: [
72
74
  // LSP type checking (gopls)
73
75
  { mode: "all", runnerIds: ["lsp"], filterKinds: ["go"] },
74
- // Go vet for additional checks (warning only, but low cost)
76
+ // Go vet for additional checks
75
77
  { mode: "fallback", runnerIds: ["go-vet"], filterKinds: ["go"] },
78
+ // golangci-lint: only fires when project has .golangci.yml config
79
+ { mode: "fallback", runnerIds: ["golangci-lint"], filterKinds: ["go"] },
76
80
  // Architectural rules (guidance only, not blocking) - runs via /lens-booboo only
77
81
  ],
78
82
  },
@@ -91,6 +95,16 @@ export const TOOL_PLANS: Record<string, ToolPlan> = {
91
95
  ],
92
96
  },
93
97
 
98
+ /**
99
+ * Ruby linting
100
+ */
101
+ ruby: {
102
+ name: "Ruby Linting",
103
+ groups: [
104
+ { mode: "fallback", runnerIds: ["rubocop"], filterKinds: ["ruby"] },
105
+ ],
106
+ },
107
+
94
108
  /**
95
109
  * C/C++ linting tools
96
110
  */
@@ -182,6 +196,7 @@ export const FULL_LINT_PLANS: Record<string, ToolPlan> = {
182
196
  { mode: "fallback", runnerIds: ["type-safety"], filterKinds: ["jsts"] },
183
197
  { mode: "fallback", runnerIds: ["similarity"], filterKinds: ["jsts"] },
184
198
  { mode: "fallback", runnerIds: ["architect"], filterKinds: ["jsts"] },
199
+ { mode: "fallback", runnerIds: ["eslint"], filterKinds: ["jsts"] },
185
200
  ],
186
201
  },
187
202
  // Override python to include warning-only tools
@@ -0,0 +1,157 @@
1
+ /**
2
+ * ESLint runner for dispatch system
3
+ *
4
+ * Runs ESLint on JS/TS files when an ESLint config is present in the project.
5
+ * Prefers the local node_modules installation over global.
6
+ *
7
+ * Gate: skips when no ESLint config is detected (project uses Biome/OxLint instead).
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { safeSpawnAsync } from "../../safe-spawn.js";
13
+ import type {
14
+ Diagnostic,
15
+ DispatchContext,
16
+ RunnerDefinition,
17
+ RunnerResult,
18
+ } from "../types.js";
19
+
20
+ const ESLINT_CONFIGS = [
21
+ ".eslintrc",
22
+ ".eslintrc.js",
23
+ ".eslintrc.cjs",
24
+ ".eslintrc.json",
25
+ ".eslintrc.yaml",
26
+ ".eslintrc.yml",
27
+ "eslint.config.js",
28
+ "eslint.config.mjs",
29
+ "eslint.config.cjs",
30
+ ];
31
+
32
+ function hasEslintConfig(cwd: string): boolean {
33
+ for (const cfg of ESLINT_CONFIGS) {
34
+ if (fs.existsSync(path.join(cwd, cfg))) return true;
35
+ }
36
+ try {
37
+ const pkg = JSON.parse(
38
+ fs.readFileSync(path.join(cwd, "package.json"), "utf-8"),
39
+ );
40
+ if (pkg.eslintConfig) return true;
41
+ } catch {}
42
+ return false;
43
+ }
44
+
45
+ function findEslint(cwd: string): string {
46
+ const isWin = process.platform === "win32";
47
+ const local = path.join(
48
+ cwd,
49
+ "node_modules",
50
+ ".bin",
51
+ isWin ? "eslint.cmd" : "eslint",
52
+ );
53
+ if (fs.existsSync(local)) return local;
54
+ // fall back to global
55
+ return "eslint";
56
+ }
57
+
58
+ interface EslintMessage {
59
+ ruleId: string | null;
60
+ severity: 1 | 2;
61
+ message: string;
62
+ line: number;
63
+ column: number;
64
+ fix?: unknown;
65
+ }
66
+
67
+ interface EslintFileResult {
68
+ filePath: string;
69
+ messages: EslintMessage[];
70
+ }
71
+
72
+ function parseEslintJson(raw: string, filePath: string): Diagnostic[] {
73
+ try {
74
+ const results: EslintFileResult[] = JSON.parse(raw);
75
+ const diagnostics: Diagnostic[] = [];
76
+
77
+ for (const fileResult of results) {
78
+ for (const msg of fileResult.messages) {
79
+ const severity = msg.severity === 2 ? "error" : "warning";
80
+ diagnostics.push({
81
+ id: `eslint:${msg.ruleId ?? "unknown"}:${msg.line}`,
82
+ message: msg.ruleId ? `${msg.ruleId}: ${msg.message}` : msg.message,
83
+ filePath,
84
+ line: msg.line ?? 1,
85
+ column: msg.column ?? 1,
86
+ severity,
87
+ semantic: severity === "error" ? "blocking" : "warning",
88
+ tool: "eslint",
89
+ rule: msg.ruleId ?? undefined,
90
+ fixable: !!msg.fix,
91
+ });
92
+ }
93
+ }
94
+
95
+ return diagnostics;
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+
101
+ const eslintRunner: RunnerDefinition = {
102
+ id: "eslint",
103
+ appliesTo: ["jsts"],
104
+ priority: 12,
105
+ enabledByDefault: true,
106
+
107
+ async run(ctx: DispatchContext): Promise<RunnerResult> {
108
+ const cwd = ctx.cwd || process.cwd();
109
+
110
+ // Only run if project has an ESLint config
111
+ if (!hasEslintConfig(cwd)) {
112
+ return { status: "skipped", diagnostics: [], semantic: "none" };
113
+ }
114
+
115
+ const cmd = findEslint(cwd);
116
+
117
+ // Verify ESLint is actually executable
118
+ const versionCheck = await safeSpawnAsync(cmd, ["--version"], {
119
+ timeout: 5000,
120
+ cwd,
121
+ });
122
+ if (versionCheck.error || versionCheck.status !== 0) {
123
+ return { status: "skipped", diagnostics: [], semantic: "none" };
124
+ }
125
+
126
+ const result = await safeSpawnAsync(
127
+ cmd,
128
+ ["--format", "json", "--no-error-on-unmatched-pattern", ctx.filePath],
129
+ { timeout: 30000, cwd },
130
+ );
131
+
132
+ // ESLint exits 1 when there are lint errors, 2 on fatal/config error
133
+ if (result.status === 2) {
134
+ return { status: "skipped", diagnostics: [], semantic: "none" };
135
+ }
136
+
137
+ const raw = result.stdout || result.stderr || "";
138
+
139
+ if (result.status === 0) {
140
+ return { status: "succeeded", diagnostics: [], semantic: "none" };
141
+ }
142
+
143
+ const diagnostics = parseEslintJson(raw, ctx.filePath);
144
+ if (diagnostics.length === 0) {
145
+ return { status: "succeeded", diagnostics: [], semantic: "none" };
146
+ }
147
+
148
+ const hasErrors = diagnostics.some((d) => d.semantic === "blocking");
149
+ return {
150
+ status: "failed",
151
+ diagnostics,
152
+ semantic: hasErrors ? "blocking" : "warning",
153
+ };
154
+ },
155
+ };
156
+
157
+ export default eslintRunner;