pi-lens 3.7.1 → 3.8.2

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 (135) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/README.md +706 -619
  3. package/clients/architect-client.ts +7 -2
  4. package/clients/ast-grep-client.ts +7 -1
  5. package/clients/dispatch/plan.ts +10 -4
  6. package/clients/dispatch/runners/architect.ts +20 -7
  7. package/clients/dispatch/runners/ast-grep-napi.ts +5 -2
  8. package/clients/dispatch/runners/ast-grep.ts +29 -18
  9. package/clients/dispatch/runners/biome.ts +4 -4
  10. package/clients/dispatch/runners/python-slop.ts +17 -7
  11. package/clients/dispatch/runners/ruff.ts +4 -4
  12. package/clients/dispatch/runners/tree-sitter.ts +30 -19
  13. package/clients/dispatch/runners/ts-slop.ts +17 -7
  14. package/clients/dispatch/runners/utils/runner-helpers.ts +76 -8
  15. package/clients/dispatch/utils/format-utils.ts +2 -1
  16. package/clients/fix-scanners.ts +8 -8
  17. package/clients/installer/index.ts +19 -1
  18. package/clients/lsp/index.ts +0 -40
  19. package/clients/lsp/launch.ts +5 -2
  20. package/clients/package-root.ts +44 -0
  21. package/clients/pipeline.ts +179 -8
  22. package/clients/scan-utils.ts +20 -32
  23. package/clients/sg-runner.ts +7 -5
  24. package/clients/source-filter.ts +222 -0
  25. package/clients/startup-scan.ts +142 -0
  26. package/clients/todo-scanner.ts +44 -55
  27. package/clients/tree-sitter-cache.ts +315 -0
  28. package/clients/tree-sitter-client.ts +208 -52
  29. package/clients/tree-sitter-fixer.ts +217 -0
  30. package/clients/tree-sitter-navigator.ts +329 -0
  31. package/clients/tree-sitter-query-loader.ts +55 -32
  32. package/commands/booboo.ts +47 -35
  33. package/default-architect.yaml +76 -87
  34. package/docs/ARCHITECTURE.md +74 -0
  35. package/docs/AST_GREP_RULES.md +266 -0
  36. package/docs/COMPLEXITY_METRICS.md +120 -0
  37. package/docs/EXCLUSIONS.md +83 -0
  38. package/docs/LSP_CONFIG.md +240 -0
  39. package/docs/TREE_SITTER_RULES.md +340 -0
  40. package/docs/WRITING_NEW_AST_GREP_RULES.md +200 -0
  41. package/index.ts +209 -86
  42. package/package.json +13 -4
  43. package/rules/ast-grep-rules/rules/array-callback-return-js.yml +33 -0
  44. package/rules/ast-grep-rules/rules/array-callback-return.yml +1 -1
  45. package/rules/ast-grep-rules/rules/constructor-super-js.yml +22 -0
  46. package/rules/ast-grep-rules/rules/empty-catch-js.yml +45 -0
  47. package/rules/ast-grep-rules/rules/empty-catch.yml +1 -1
  48. package/rules/ast-grep-rules/rules/getter-return-js.yml +59 -0
  49. package/rules/ast-grep-rules/rules/getter-return.yml +1 -1
  50. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +12 -0
  51. package/rules/ast-grep-rules/rules/jsx-boolean-short-circuit.yml +1 -1
  52. package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +14 -0
  53. package/rules/ast-grep-rules/rules/missed-concurrency-js.yml +25 -0
  54. package/rules/ast-grep-rules/rules/nested-ternary-js.yml +10 -0
  55. package/rules/ast-grep-rules/rules/no-alert-js.yml +6 -0
  56. package/rules/ast-grep-rules/rules/no-architecture-violation.yml +21 -18
  57. package/rules/ast-grep-rules/rules/no-array-constructor-js.yml +10 -0
  58. package/rules/ast-grep-rules/rules/no-async-promise-executor-js.yml +15 -0
  59. package/rules/ast-grep-rules/rules/no-async-promise-executor.yml +1 -1
  60. package/rules/ast-grep-rules/rules/no-await-in-loop-js.yml +30 -0
  61. package/rules/ast-grep-rules/rules/no-await-in-promise-all-js.yml +20 -0
  62. package/rules/ast-grep-rules/rules/no-await-in-promise-all.yml +1 -1
  63. package/rules/ast-grep-rules/rules/no-bare-except.yml +1 -1
  64. package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +16 -0
  65. package/rules/ast-grep-rules/rules/no-compare-neg-zero-js.yml +13 -0
  66. package/rules/ast-grep-rules/rules/no-compare-neg-zero.yml +1 -1
  67. package/rules/ast-grep-rules/rules/no-comparison-to-none.yml +1 -1
  68. package/rules/ast-grep-rules/rules/no-cond-assign-js.yml +36 -0
  69. package/rules/ast-grep-rules/rules/no-cond-assign.yml +1 -1
  70. package/rules/ast-grep-rules/rules/no-constant-condition-js.yml +25 -0
  71. package/rules/ast-grep-rules/rules/no-constant-condition.yml +1 -1
  72. package/rules/ast-grep-rules/rules/no-constructor-return-js.yml +28 -0
  73. package/rules/ast-grep-rules/rules/no-constructor-return.yml +1 -1
  74. package/rules/ast-grep-rules/rules/no-discarded-error-js.yml +25 -0
  75. package/rules/ast-grep-rules/rules/no-discarded-error.yml +25 -0
  76. package/rules/ast-grep-rules/rules/no-dupe-args-js.yml +15 -0
  77. package/rules/ast-grep-rules/rules/no-dupe-keys-js.yml +73 -0
  78. package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +25 -0
  79. package/rules/ast-grep-rules/rules/no-hardcoded-secrets-js.yml +17 -0
  80. package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +15 -0
  81. package/rules/ast-grep-rules/rules/no-inner-html-js.yml +13 -0
  82. package/rules/ast-grep-rules/rules/no-insecure-randomness-js.yml +20 -0
  83. package/rules/ast-grep-rules/rules/no-insecure-randomness.yml +1 -1
  84. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +11 -0
  85. package/rules/ast-grep-rules/rules/no-nan-comparison-js.yml +22 -0
  86. package/rules/ast-grep-rules/rules/no-nan-comparison.yml +22 -0
  87. package/rules/ast-grep-rules/rules/no-new-symbol-js.yml +8 -0
  88. package/rules/ast-grep-rules/rules/no-new-wrappers-js.yml +13 -0
  89. package/rules/ast-grep-rules/rules/no-open-redirect-js.yml +15 -0
  90. package/rules/ast-grep-rules/rules/no-prototype-builtins-js.yml +15 -0
  91. package/rules/ast-grep-rules/rules/no-prototype-builtins.yml +1 -1
  92. package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +13 -0
  93. package/rules/ast-grep-rules/rules/no-sql-in-code.yml +1 -1
  94. package/rules/ast-grep-rules/rules/no-throw-string-js.yml +12 -0
  95. package/rules/ast-grep-rules/rules/no-throw-string.yml +1 -1
  96. package/rules/ast-grep-rules/rules/strict-equality-js.yml +10 -0
  97. package/rules/ast-grep-rules/rules/strict-inequality-js.yml +10 -0
  98. package/rules/ast-grep-rules/rules/toctou-js.yml +112 -0
  99. package/rules/ast-grep-rules/rules/toctou.yml +1 -1
  100. package/rules/ast-grep-rules/rules/unchecked-sync-fs-js.yml +44 -0
  101. package/rules/ast-grep-rules/rules/unchecked-sync-fs.yml +44 -0
  102. package/rules/ast-grep-rules/rules/unchecked-throwing-call-js.yml +31 -0
  103. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +48 -0
  104. package/rules/ast-grep-rules/rules/unchecked-throwing-call-ruby.yml +47 -0
  105. package/rules/ast-grep-rules/rules/unchecked-throwing-call.yml +31 -0
  106. package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +15 -0
  107. package/rules/tree-sitter-queries/go/go-bare-error.yml +47 -0
  108. package/rules/tree-sitter-queries/go/go-defer-in-loop.yml +47 -0
  109. package/rules/tree-sitter-queries/go/go-hardcoded-secrets.yml +54 -0
  110. package/rules/tree-sitter-queries/python/is-vs-equals.yml +1 -1
  111. package/rules/tree-sitter-queries/python/python-debugger.yml +46 -0
  112. package/rules/tree-sitter-queries/python/python-empty-except.yml +48 -0
  113. package/rules/tree-sitter-queries/python/python-hardcoded-secrets.yml +44 -0
  114. package/rules/tree-sitter-queries/python/python-mutable-class-attr.yml +57 -0
  115. package/rules/tree-sitter-queries/python/python-print-statement.yml +53 -0
  116. package/rules/tree-sitter-queries/python/python-raise-string.yml +38 -0
  117. package/rules/tree-sitter-queries/python/python-unsafe-regex.yml +58 -0
  118. package/rules/tree-sitter-queries/ruby/ruby-debugger.yml +44 -0
  119. package/rules/tree-sitter-queries/ruby/ruby-empty-rescue.yml +47 -0
  120. package/rules/tree-sitter-queries/ruby/ruby-eval.yml +43 -0
  121. package/rules/tree-sitter-queries/ruby/ruby-hardcoded-secrets.yml +40 -0
  122. package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +48 -0
  123. package/rules/tree-sitter-queries/ruby/ruby-puts-statement.yml +52 -0
  124. package/rules/tree-sitter-queries/ruby/ruby-rescue-exception.yml +51 -0
  125. package/rules/tree-sitter-queries/ruby/ruby-unsafe-regex.yml +49 -0
  126. package/rules/tree-sitter-queries/rust/rust-clone-in-loop.yml +49 -0
  127. package/rules/tree-sitter-queries/rust/rust-unwrap.yml +45 -0
  128. package/rules/tree-sitter-queries/typescript/console-statement.yml +3 -3
  129. package/rules/tree-sitter-queries/typescript/hardcoded-secrets.yml +13 -27
  130. package/rules/tree-sitter-queries/typescript/injections.scm +40 -0
  131. package/rules/tree-sitter-queries/typescript/no-console-in-tests.yml +52 -0
  132. package/rules/tree-sitter-queries/typescript/sql-injection.yml +55 -0
  133. package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +71 -0
  134. package/rules/tree-sitter-queries/typescript/variable-shadowing.yml +51 -0
  135. package/scripts/download-grammars.ts +78 -0
@@ -281,6 +281,16 @@ async function verifyToolBinary(binPath: string): Promise<boolean> {
281
281
  /**
282
282
  * Install an npm package tool
283
283
  */
284
+ /**
285
+ * Packages that require postinstall scripts to download native binaries.
286
+ * All others get --ignore-scripts to prevent arbitrary code execution during install.
287
+ */
288
+ const NEEDS_POSTINSTALL = new Set([
289
+ "@biomejs/biome",
290
+ "@ast-grep/napi",
291
+ "esbuild",
292
+ ]);
293
+
284
294
  async function installNpmTool(
285
295
  packageName: string,
286
296
  binaryName: string,
@@ -311,7 +321,15 @@ async function installNpmTool(
311
321
  : isWindows
312
322
  ? "npm.cmd"
313
323
  : "npm";
314
- const proc = spawn(pm, ["install", packageName], {
324
+ // Use --ignore-scripts unless the package explicitly needs postinstall
325
+ // (e.g. biome downloads a platform-specific native binary via postinstall).
326
+ const needsScripts = NEEDS_POSTINSTALL.has(
327
+ packageName.split("@")[0] ?? packageName,
328
+ );
329
+ const installArgs = needsScripts
330
+ ? ["install", packageName]
331
+ : ["install", "--ignore-scripts", packageName];
332
+ const proc = spawn(pm, installArgs, {
315
333
  cwd: TOOLS_DIR,
316
334
  stdio: ["ignore", "pipe", "pipe"],
317
335
  shell: isWindows, // Required for .cmd files on Windows
@@ -8,7 +8,6 @@
8
8
  * - Resource cleanup
9
9
  */
10
10
 
11
- import { Effect } from "effect";
12
11
  import type { LSPClientInfo } from "./client.js";
13
12
  import { createLSPClient } from "./client.js";
14
13
  import { getServersForFileWithConfig } from "./config.js";
@@ -339,45 +338,6 @@ export class LSPService {
339
338
  }
340
339
  }
341
340
 
342
- // --- Effect Integration ---
343
-
344
- /**
345
- * Effect wrapper for LSP operations
346
- */
347
- export function lspEffect(service: LSPService) {
348
- return {
349
- openFile: (filePath: string, content: string) =>
350
- Effect.tryPromise({
351
- try: () => service.openFile(filePath, content),
352
- catch: (err) => err as Error,
353
- }),
354
-
355
- updateFile: (filePath: string, content: string) =>
356
- Effect.tryPromise({
357
- try: () => service.updateFile(filePath, content),
358
- catch: (err) => err as Error,
359
- }),
360
-
361
- getDiagnostics: (filePath: string) =>
362
- Effect.tryPromise({
363
- try: () => service.getDiagnostics(filePath),
364
- catch: (err) => err as Error,
365
- }),
366
-
367
- hasLSP: (filePath: string) =>
368
- Effect.tryPromise({
369
- try: () => service.hasLSP(filePath),
370
- catch: (err) => err as Error,
371
- }),
372
-
373
- shutdown: () =>
374
- Effect.tryPromise({
375
- try: () => service.shutdown(),
376
- catch: (err) => err as Error,
377
- }),
378
- };
379
- }
380
-
381
341
  // --- Singleton Instance ---
382
342
 
383
343
  let globalLSPService: LSPService | null = null;
@@ -328,7 +328,8 @@ export async function launchViaPackageManager(
328
328
  // For npx on Windows, use shell mode with the full command string
329
329
  if (isWin) {
330
330
  const argsStr = args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ");
331
- const shellCommand = `npx -y ${packageName}${argsStr ? ` ${argsStr}` : ""}`;
331
+ // --no prevents silent download of uncached packages
332
+ const shellCommand = `npx --no ${packageName}${argsStr ? ` ${argsStr}` : ""}`;
332
333
 
333
334
  const cwd = String(options.cwd ?? process.cwd());
334
335
  const env = { ...process.env, ...options.env };
@@ -393,7 +394,9 @@ export async function launchViaPackageManager(
393
394
  };
394
395
  }
395
396
 
396
- return launchLSP("npx", ["-y", packageName, ...args], options);
397
+ // --no prevents silent download of uncached packages; user must have
398
+ // already installed the LSP server via the interactive-install flow.
399
+ return launchLSP("npx", ["--no", packageName, ...args], options);
397
400
  }
398
401
 
399
402
  /**
@@ -0,0 +1,44 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const packageRootCache = new Map<string, string>();
6
+
7
+ /**
8
+ * Resolve the installed package root for the current module.
9
+ * Walks upward from the caller until it finds the nearest package.json.
10
+ *
11
+ * This is the correct alternative to process.cwd() for resolving
12
+ * pi-lens's own assets (rules, grammars, configs) when installed
13
+ * globally — cwd is the user's project, not the extension root.
14
+ *
15
+ * Credit: alexx-ftw (PR #1)
16
+ */
17
+ export function getPackageRoot(importMetaUrl: string): string {
18
+ const cached = packageRootCache.get(importMetaUrl);
19
+ if (cached) return cached;
20
+
21
+ let current = path.dirname(fileURLToPath(importMetaUrl));
22
+ while (true) {
23
+ if (fs.existsSync(path.join(current, "package.json"))) {
24
+ packageRootCache.set(importMetaUrl, current);
25
+ return current;
26
+ }
27
+ const parent = path.dirname(current);
28
+ if (parent === current) {
29
+ packageRootCache.set(importMetaUrl, current);
30
+ return current;
31
+ }
32
+ current = parent;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Resolve a path relative to the installed package root.
38
+ */
39
+ export function resolvePackagePath(
40
+ importMetaUrl: string,
41
+ ...segments: string[]
42
+ ): string {
43
+ return path.join(getPackageRoot(importMetaUrl), ...segments);
44
+ }
@@ -5,7 +5,7 @@
5
5
  * Runs sequentially on every file write/edit:
6
6
  * 1. Secrets scan (blocking — early exit)
7
7
  * 2. Auto-format (Biome, Prettier, Ruff, gofmt, etc.)
8
- * 3. Auto-fix (Biome --write, Ruff --fix)
8
+ * 3. Auto-fix (Biome --write, Ruff --fix, ESLint --fix)
9
9
  * 4. LSP file sync (open/update in LSP servers)
10
10
  * 5. Dispatch lint (type errors, security rules)
11
11
  * 6. Test runner (run corresponding test file)
@@ -17,11 +17,13 @@ import * as path from "node:path";
17
17
  import type { BiomeClient } from "./biome-client.js";
18
18
  import { dispatchLintWithResult } from "./dispatch/integration.js";
19
19
  import type { PiAgentAPI } from "./dispatch/types.js";
20
+ import { detectFileKind, getFileKindLabel } from "./file-kinds.js";
20
21
  import type { FormatService } from "./format-service.js";
21
22
  import { logLatency } from "./latency-logger.js";
22
23
  import { getLSPService } from "./lsp/index.js";
23
24
  import type { MetricsClient } from "./metrics-client.js";
24
25
  import type { RuffClient } from "./ruff-client.js";
26
+ import { safeSpawnAsync } from "./safe-spawn.js";
25
27
  import { formatSecrets, scanForSecrets } from "./secrets-scanner.js";
26
28
  import type { TestRunnerClient } from "./test-runner-client.js";
27
29
 
@@ -49,6 +51,12 @@ export interface PipelineDeps {
49
51
  export interface PipelineResult {
50
52
  /** Text to append to tool_result content */
51
53
  output: string;
54
+ /**
55
+ * Cascade diagnostics (errors in OTHER files caused by this edit).
56
+ * Intentionally NOT included in output — surfaced at turn_end instead
57
+ * so mid-refactor intermediate errors don't derail the agent.
58
+ */
59
+ cascadeOutput?: string;
52
60
  /** True if secrets found — block the agent */
53
61
  isError: boolean;
54
62
  /** True if file was modified by format/autofix */
@@ -90,6 +98,109 @@ function createPhaseTracker(toolName: string, filePath: string): PhaseTracker {
90
98
  };
91
99
  }
92
100
 
101
+ // --- ESLint autofix helpers ---
102
+
103
+ const ESLINT_CONFIGS = [
104
+ ".eslintrc",
105
+ ".eslintrc.js",
106
+ ".eslintrc.cjs",
107
+ ".eslintrc.json",
108
+ ".eslintrc.yaml",
109
+ ".eslintrc.yml",
110
+ "eslint.config.js",
111
+ "eslint.config.mjs",
112
+ "eslint.config.cjs",
113
+ ];
114
+
115
+ const JSTS_EXTS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
116
+
117
+ function isJsTs(filePath: string): boolean {
118
+ return JSTS_EXTS.has(path.extname(filePath).toLowerCase());
119
+ }
120
+
121
+ function hasEslintConfig(cwd: string): boolean {
122
+ for (const cfg of ESLINT_CONFIGS) {
123
+ if (nodeFs.existsSync(path.join(cwd, cfg))) return true;
124
+ }
125
+ try {
126
+ const pkg = JSON.parse(
127
+ nodeFs.readFileSync(path.join(cwd, "package.json"), "utf-8"),
128
+ );
129
+ if (pkg.eslintConfig) return true;
130
+ } catch {}
131
+ return false;
132
+ }
133
+
134
+ let _eslintAvailable: boolean | null = null;
135
+ let _eslintBin: string | null = null;
136
+
137
+ function findEslintBin(cwd: string): string {
138
+ const isWin = process.platform === "win32";
139
+ const local = path.join(
140
+ cwd,
141
+ "node_modules",
142
+ ".bin",
143
+ isWin ? "eslint.cmd" : "eslint",
144
+ );
145
+ if (nodeFs.existsSync(local)) return local;
146
+ return "eslint";
147
+ }
148
+
149
+ /**
150
+ * Run eslint --fix on a file. Returns number of fixable issues resolved,
151
+ * or 0 if ESLint is not configured / not available.
152
+ */
153
+ async function tryEslintFix(filePath: string, cwd: string): Promise<number> {
154
+ if (!hasEslintConfig(cwd)) return 0;
155
+ if (_eslintAvailable === false) return 0;
156
+ if (_eslintAvailable === null) {
157
+ const candidate = findEslintBin(cwd);
158
+ const check = await safeSpawnAsync(candidate, ["--version"], {
159
+ timeout: 5000,
160
+ cwd,
161
+ });
162
+ _eslintAvailable = !check.error && check.status === 0;
163
+ if (_eslintAvailable) _eslintBin = candidate;
164
+ }
165
+ if (!_eslintAvailable || !_eslintBin) return 0;
166
+ const cmd = _eslintBin;
167
+ // --fix-dry-run returns JSON with fixable counts without writing to disk.
168
+ // Use it to get the real count, then apply with --fix only if needed.
169
+ const dry = await safeSpawnAsync(
170
+ cmd,
171
+ [
172
+ "--fix-dry-run",
173
+ "--format",
174
+ "json",
175
+ "--no-error-on-unmatched-pattern",
176
+ filePath,
177
+ ],
178
+ { timeout: 30000, cwd },
179
+ );
180
+ if (dry.status === 2) return 0;
181
+ let fixableCount = 0;
182
+ try {
183
+ const results: Array<{
184
+ fixableErrorCount?: number;
185
+ fixableWarningCount?: number;
186
+ }> = JSON.parse(dry.stdout);
187
+ fixableCount = results.reduce(
188
+ (sum, r) =>
189
+ sum + (r.fixableErrorCount ?? 0) + (r.fixableWarningCount ?? 0),
190
+ 0,
191
+ );
192
+ } catch {}
193
+ if (fixableCount === 0) return 0;
194
+ // Apply the fixes
195
+ const fix = await safeSpawnAsync(
196
+ cmd,
197
+ ["--fix", "--no-error-on-unmatched-pattern", filePath],
198
+ { timeout: 30000, cwd },
199
+ );
200
+ if (fix.status === 2) return 0;
201
+ return fixableCount;
202
+ }
203
+
93
204
  // --- Main Pipeline ---
94
205
 
95
206
  export async function runPipeline(
@@ -184,6 +295,9 @@ export async function runPipeline(
184
295
  }
185
296
 
186
297
  let output = "";
298
+ const autofixTools: string[] = []; // track which tools fixed something
299
+ let testSummary: { passed: number; total: number; failed: number } | null =
300
+ null;
187
301
 
188
302
  // --- 4. Auto-fix ---
189
303
  // Biome (TS/JS) and Ruff (Python) never touch the same file, so their
@@ -208,6 +322,7 @@ export async function runPipeline(
208
322
  const result = ruffClient.fixFile(filePath);
209
323
  if (result.success && result.fixed > 0) {
210
324
  fixedCount += result.fixed;
325
+ autofixTools.push(`ruff:${result.fixed}`);
211
326
  fixedThisTurn.add(filePath);
212
327
  dbg(`autofix: ruff fixed ${result.fixed} issue(s) in ${filePath}`);
213
328
  }
@@ -217,12 +332,24 @@ export async function runPipeline(
217
332
  const result = biomeClient.fixFile(filePath);
218
333
  if (result.success && result.fixed > 0) {
219
334
  fixedCount += result.fixed;
335
+ autofixTools.push(`biome:${result.fixed}`);
220
336
  fixedThisTurn.add(filePath);
221
337
  dbg(`autofix: biome fixed ${result.fixed} issue(s) in ${filePath}`);
222
338
  }
223
339
  }
224
340
  }
225
- phase.end("autofix", { fixedCount, tools: ["ruff", "biome"] });
341
+ // ESLint --fix: only for jsts files in projects that use ESLint
342
+ if (!noAutofix && isJsTs(filePath)) {
343
+ const eslintFixed = await tryEslintFix(filePath, cwd);
344
+ if (eslintFixed > 0) {
345
+ fixedCount += eslintFixed;
346
+ autofixTools.push(`eslint:${eslintFixed}`);
347
+ fixedThisTurn.add(filePath);
348
+ dbg(`autofix: eslint fixed ${eslintFixed} issue(s) in ${filePath}`);
349
+ }
350
+ }
351
+
352
+ phase.end("autofix", { fixedCount, tools: ["ruff", "biome", "eslint"] });
226
353
 
227
354
  // --- 5. Dispatch lint ---
228
355
  phase.start("dispatch_lint");
@@ -239,7 +366,9 @@ export async function runPipeline(
239
366
  }
240
367
 
241
368
  if (fixedCount > 0) {
242
- output += `\n\n✅ Auto-fixed ${fixedCount} issue(s) in ${path.basename(filePath)}`;
369
+ const detail =
370
+ autofixTools.length > 0 ? ` (${autofixTools.join(", ")})` : "";
371
+ output += `\n\n✅ Auto-fixed ${fixedCount} issue(s)${detail}`;
243
372
  }
244
373
 
245
374
  if (formatChanged || fixedCount > 0) {
@@ -285,6 +414,11 @@ export async function runPipeline(
285
414
  },
286
415
  });
287
416
  if (testResult && !testResult.error) {
417
+ testSummary = {
418
+ passed: testResult.passed,
419
+ total: testResult.passed + testResult.failed + testResult.skipped,
420
+ failed: testResult.failed,
421
+ };
288
422
  const testOutput = testRunnerClient.formatResult(testResult);
289
423
  if (testOutput) {
290
424
  output += `\n\n${testOutput}`;
@@ -296,6 +430,11 @@ export async function runPipeline(
296
430
  phase.end("test_runner", { found: testInfoFound, ran: testRunnerRan });
297
431
 
298
432
  // --- 7. Cascade diagnostics (LSP only) ---
433
+ // Deferred: cascade errors are errors in OTHER files caused by this edit.
434
+ // They are NOT shown inline (mid-refactor they are always noisy — agent is
435
+ // still editing the other files). Returned in cascadeOutput so index.ts can
436
+ // surface the LAST snapshot at turn_end once all edits in the turn are done.
437
+ let cascadeOutput: string | undefined;
299
438
  if (getFlag("lens-lsp") && !getFlag("no-lsp")) {
300
439
  const MAX_CASCADE_FILES = 5;
301
440
  const MAX_DIAGNOSTICS_PER_FILE = 20;
@@ -319,7 +458,7 @@ export async function runPipeline(
319
458
  }
320
459
 
321
460
  if (otherFileErrors.length > 0) {
322
- output += `\n\n📐 Cascade errors detected in ${otherFileErrors.length} other file(s):`;
461
+ let c = `📐 Cascade errors in ${otherFileErrors.length} other file(s) — fix before finishing turn:`;
323
462
  for (const { file, errors } of otherFileErrors.slice(
324
463
  0,
325
464
  MAX_CASCADE_FILES,
@@ -329,18 +468,19 @@ export async function runPipeline(
329
468
  errors.length > MAX_DIAGNOSTICS_PER_FILE
330
469
  ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more`
331
470
  : "";
332
- output += `\n<diagnostics file="${file}">`;
471
+ c += `\n<diagnostics file="${file}">`;
333
472
  for (const e of limited) {
334
473
  const line = (e.range?.start?.line ?? 0) + 1;
335
474
  const col = (e.range?.start?.character ?? 0) + 1;
336
475
  const code = e.code ? ` [${e.code}]` : "";
337
- output += `\n ${code} (${line}:${col}) ${e.message.split("\n")[0].slice(0, 100)}`;
476
+ c += `\n ${code} (${line}:${col}) ${e.message.split("\n")[0].slice(0, 100)}`;
338
477
  }
339
- output += `${suffix}\n</diagnostics>`;
478
+ c += `${suffix}\n</diagnostics>`;
340
479
  }
341
480
  if (otherFileErrors.length > MAX_CASCADE_FILES) {
342
- output += `\n... and ${otherFileErrors.length - MAX_CASCADE_FILES} more files with errors`;
481
+ c += `\n... and ${otherFileErrors.length - MAX_CASCADE_FILES} more files with errors`;
343
482
  }
483
+ cascadeOutput = c;
344
484
  }
345
485
 
346
486
  logLatency({
@@ -358,6 +498,36 @@ export async function runPipeline(
358
498
 
359
499
  // --- Final timing ---
360
500
  const elapsed = Date.now() - pipelineStart;
501
+
502
+ // --- All-clear / warnings notice ---
503
+ // When no blocking output exists, emit a one-liner so the agent knows
504
+ // checks actually ran and what the result was.
505
+ if (!output) {
506
+ const kind = detectFileKind(filePath);
507
+ const langLabel = kind ? getFileKindLabel(kind) : path.extname(filePath);
508
+ const parts: string[] = [];
509
+
510
+ if (dispatchResult.warnings.length > 0) {
511
+ // Has non-blocking warnings — tell agent to run booboo
512
+ parts.push(`no blockers`);
513
+ parts.push(
514
+ `${dispatchResult.warnings.length} warning(s) -> /lens-booboo`,
515
+ );
516
+ } else if (kind) {
517
+ parts.push(`${langLabel} clean`);
518
+ }
519
+
520
+ if (testSummary) {
521
+ if (testSummary.failed === 0) {
522
+ parts.push(`${testSummary.passed}/${testSummary.total} tests`);
523
+ }
524
+ // failing tests already have their own output above — skip here
525
+ }
526
+
527
+ parts.push(`${elapsed}ms`);
528
+ output = `checkmark ${parts.join(" · ")}`.replace("checkmark", "\u2713");
529
+ }
530
+
361
531
  phase.end("total", { hasOutput: !!output });
362
532
 
363
533
  logLatency({
@@ -370,6 +540,7 @@ export async function runPipeline(
370
540
 
371
541
  return {
372
542
  output,
543
+ cascadeOutput,
373
544
  isError: false,
374
545
  fileModified: formatChanged || fixedCount > 0,
375
546
  };
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { EXCLUDED_DIRS, isTestFile } from "./file-utils.js";
4
+ import { collectSourceFiles, isBuildArtifact } from "./source-filter.js";
4
5
 
5
6
  /**
6
7
  * Common parsing logic for ast-grep JSON output (handles both array and NDJSON).
@@ -27,6 +28,10 @@ export function parseAstGrepJson(raw: string): any[] {
27
28
 
28
29
  /**
29
30
  * Check if a file should be ignored based on project type and common patterns.
31
+ *
32
+ * @deprecated Use `isBuildArtifact()` from `source-filter.js` instead for artifact
33
+ * detection, or compose your own filter using `collectSourceFiles()`. This function
34
+ * is kept for backward compatibility.
30
35
  */
31
36
  export function shouldIgnoreFile(
32
37
  filePath: string,
@@ -35,7 +40,10 @@ export function shouldIgnoreFile(
35
40
  const relPath = filePath.replace(/\\/g, "/");
36
41
  const _basename = path.basename(relPath);
37
42
 
38
- // Ignore compiled JS in TS projects
43
+ // Use new source-filter module for artifact detection
44
+ if (isTsProject && isBuildArtifact(filePath)) return true;
45
+
46
+ // Legacy: simple JS check for non-TS projects (hand-written JS)
39
47
  const isJs =
40
48
  relPath.endsWith(".js") ||
41
49
  relPath.endsWith(".mjs") ||
@@ -53,36 +61,16 @@ export function shouldIgnoreFile(
53
61
 
54
62
  /**
55
63
  * Recursively find source files in a directory, respecting common excludes.
64
+ *
65
+ * This function now delegates to `collectSourceFiles()` from the `source-filter`
66
+ * module for unified artifact detection across all scanners.
67
+ *
68
+ * @param dir - Directory to scan
69
+ * @param isTsProject - Deprecated parameter (kept for backward compatibility, not used)
70
+ * @returns Array of absolute file paths that are source files (not build artifacts)
56
71
  */
57
- export function getSourceFiles(dir: string, isTsProject: boolean): string[] {
58
- const files: string[] = [];
59
- if (!fs.existsSync(dir)) return files;
60
-
61
- const scan = (d: string) => {
62
- let entries: fs.Dirent[] = [];
63
- try {
64
- entries = fs.readdirSync(d, { withFileTypes: true });
65
- } catch {
66
- return;
67
- }
68
-
69
- for (const entry of entries) {
70
- const full = path.join(d, entry.name);
71
- if (entry.isDirectory()) {
72
- if (EXCLUDED_DIRS.includes(entry.name)) continue;
73
- scan(full);
74
- } else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
75
- // Skip compiled JS if it's a TS project
76
- if (
77
- isTsProject &&
78
- entry.name.endsWith(".js") &&
79
- fs.existsSync(full.replace(/\.js$/, ".ts"))
80
- )
81
- continue;
82
- files.push(full);
83
- }
84
- }
85
- };
86
- scan(dir);
87
- return files;
72
+ export function getSourceFiles(dir: string, _isTsProject?: boolean): string[] {
73
+ // Delegate to the unified source-filter module
74
+ // isTsProject parameter is no longer needed — artifact detection is automatic
75
+ return collectSourceFiles(dir);
88
76
  }
@@ -9,6 +9,10 @@ import { spawn } from "node:child_process";
9
9
  import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
+ import {
13
+ getSgCommand,
14
+ isSgAvailable,
15
+ } from "./dispatch/runners/utils/runner-helpers.js";
12
16
  import { safeSpawn } from "./safe-spawn.js";
13
17
 
14
18
  /**
@@ -90,10 +94,7 @@ export class SgRunner {
90
94
  isAvailable(): boolean {
91
95
  if (this.available !== null) return this.available;
92
96
 
93
- const result = safeSpawn("npx", ["sg", "--version"], {
94
- timeout: 10000,
95
- });
96
- this.available = !result.error && result.status === 0;
97
+ this.available = isSgAvailable();
97
98
  return this.available;
98
99
  }
99
100
 
@@ -214,7 +215,8 @@ export class SgRunner {
214
215
  * Run ast-grep synchronously (for simple scans)
215
216
  */
216
217
  execSync(args: string[]): { output: string; error?: string } {
217
- const result = safeSpawn("npx", ["sg", ...args], {
218
+ const { cmd: sgCmd, args: sgPre } = getSgCommand();
219
+ const result = safeSpawn(sgCmd, [...sgPre, "sg", ...args], {
218
220
  timeout: 30000,
219
221
  });
220
222