pi-lens 3.2.0 → 3.3.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 (77) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +4 -10
  3. package/clients/__tests__/file-time.test.js +216 -0
  4. package/clients/__tests__/format-service.test.js +245 -0
  5. package/clients/__tests__/formatters.test.js +271 -0
  6. package/clients/agent-behavior-client.test.js +94 -0
  7. package/clients/biome-client.test.js +144 -0
  8. package/clients/cache-manager.test.js +197 -0
  9. package/clients/complexity-client.test.js +234 -0
  10. package/clients/dependency-checker.test.js +60 -0
  11. package/clients/dispatch/__tests__/autofix-integration.test.js +245 -0
  12. package/clients/dispatch/__tests__/runner-registration.test.js +234 -0
  13. package/clients/dispatch/__tests__/runner-registration.test.ts +2 -2
  14. package/clients/dispatch/dispatcher.edge.test.js +82 -0
  15. package/clients/dispatch/dispatcher.format.test.js +46 -0
  16. package/clients/dispatch/dispatcher.inline.test.js +74 -0
  17. package/clients/dispatch/dispatcher.test.js +116 -0
  18. package/clients/dispatch/runners/architect.test.js +138 -0
  19. package/clients/dispatch/runners/ast-grep-napi.test.js +106 -0
  20. package/clients/dispatch/runners/lsp.js +42 -5
  21. package/clients/dispatch/runners/oxlint.test.js +230 -0
  22. package/clients/dispatch/runners/pyright.test.js +98 -0
  23. package/clients/dispatch/runners/python-slop.test.js +203 -0
  24. package/clients/dispatch/runners/scan_codebase.test.js +89 -0
  25. package/clients/dispatch/runners/shellcheck.test.js +98 -0
  26. package/clients/dispatch/runners/spellcheck.test.js +158 -0
  27. package/clients/dispatch/utils/format-utils.js +1 -6
  28. package/clients/dispatch/utils/format-utils.ts +1 -6
  29. package/clients/dogfood.test.js +201 -0
  30. package/clients/file-kinds.test.js +169 -0
  31. package/clients/formatters.js +1 -1
  32. package/clients/go-client.test.js +127 -0
  33. package/clients/jscpd-client.test.js +127 -0
  34. package/clients/knip-client.test.js +112 -0
  35. package/clients/lsp/__tests__/client.test.js +310 -0
  36. package/clients/lsp/__tests__/client.test.ts +1 -46
  37. package/clients/lsp/__tests__/config.test.js +167 -0
  38. package/clients/lsp/__tests__/error-recovery.test.js +213 -0
  39. package/clients/lsp/__tests__/integration.test.js +127 -0
  40. package/clients/lsp/__tests__/launch.test.js +313 -0
  41. package/clients/lsp/__tests__/server.test.js +259 -0
  42. package/clients/lsp/__tests__/service.test.js +435 -0
  43. package/clients/lsp/client.js +32 -44
  44. package/clients/lsp/client.ts +36 -45
  45. package/clients/lsp/launch.js +11 -6
  46. package/clients/lsp/launch.ts +11 -6
  47. package/clients/lsp/server.js +27 -2
  48. package/clients/metrics-client.test.js +141 -0
  49. package/clients/ruff-client.test.js +132 -0
  50. package/clients/rust-client.test.js +108 -0
  51. package/clients/sanitize.test.js +177 -0
  52. package/clients/secrets-scanner.test.js +100 -0
  53. package/clients/test-runner-client.test.js +192 -0
  54. package/clients/todo-scanner.test.js +301 -0
  55. package/clients/type-coverage-client.test.js +105 -0
  56. package/clients/typescript-client.codefix.test.js +157 -0
  57. package/clients/typescript-client.test.js +105 -0
  58. package/commands/rate.test.js +119 -0
  59. package/index.ts +66 -72
  60. package/package.json +1 -1
  61. package/clients/bus/bus.js +0 -191
  62. package/clients/bus/bus.ts +0 -251
  63. package/clients/bus/events.js +0 -214
  64. package/clients/bus/events.ts +0 -279
  65. package/clients/bus/index.js +0 -8
  66. package/clients/bus/index.ts +0 -9
  67. package/clients/bus/integration.js +0 -158
  68. package/clients/bus/integration.ts +0 -227
  69. package/clients/dispatch/bus-dispatcher.js +0 -178
  70. package/clients/dispatch/bus-dispatcher.ts +0 -258
  71. package/clients/services/__tests__/effect-integration.test.ts +0 -111
  72. package/clients/services/effect-integration.js +0 -198
  73. package/clients/services/effect-integration.ts +0 -276
  74. package/clients/services/index.js +0 -7
  75. package/clients/services/index.ts +0 -8
  76. package/clients/services/runner-service.js +0 -134
  77. package/clients/services/runner-service.ts +0 -225
@@ -8,6 +8,7 @@
8
8
  * - Request/response handling
9
9
  */
10
10
 
11
+ import { EventEmitter } from "node:events";
11
12
  import { pathToFileURL } from "node:url";
12
13
  import type { MessageConnection } from "vscode-jsonrpc";
13
14
  import {
@@ -15,7 +16,7 @@ import {
15
16
  StreamMessageReader,
16
17
  StreamMessageWriter,
17
18
  } from "vscode-jsonrpc/node.js";
18
- import { DiagnosticFound } from "../bus/events.js";
19
+
19
20
  import type { LSPProcess } from "./launch.js";
20
21
  import { normalizeMapKey, uriToPath } from "./path-utils.js";
21
22
 
@@ -104,7 +105,7 @@ export interface LSPClientInfo {
104
105
 
105
106
  // --- Constants ---
106
107
 
107
- const DIAGNOSTICS_DEBOUNCE_MS = 150;
108
+ const DIAGNOSTICS_DEBOUNCE_MS = 150; // ms — waits for follow-up semantic diagnostics
108
109
  const INITIALIZE_TIMEOUT_MS = 120_000; // 2 minutes (was 45s) - allows time for npx to download packages
109
110
 
110
111
  // --- Client Factory ---
@@ -127,6 +128,12 @@ export async function createLSPClient(options: {
127
128
  const diagnostics = new Map<string, LSPDiagnostic[]>();
128
129
  const pendingDiagnostics = new Map<string, ReturnType<typeof setTimeout>>();
129
130
 
131
+ // Local event emitter — signals waitForDiagnostics when new diagnostics arrive.
132
+ // Scoped to this client instance; replaces global bus pub/sub.
133
+ // setMaxListeners guards against Node.js warning for concurrent waitForDiagnostics calls.
134
+ const diagnosticEmitter = new EventEmitter();
135
+ diagnosticEmitter.setMaxListeners(50);
136
+
130
137
  // Handle incoming diagnostics with debouncing
131
138
  connection.onNotification(
132
139
  "textDocument/publishDiagnostics",
@@ -142,31 +149,8 @@ export async function createLSPClient(options: {
142
149
  diagnostics.set(filePath, newDiags);
143
150
  pendingDiagnostics.delete(filePath);
144
151
 
145
- // Publish to bus
146
- // Defensive: filter out malformed diagnostics that may lack range
147
- const validDiags = newDiags.filter(
148
- (d) => d.range?.start?.line !== undefined,
149
- );
150
- DiagnosticFound.publish({
151
- runnerId: serverId,
152
- filePath,
153
- diagnostics: validDiags.map((d) => ({
154
- id: `${serverId}:${d.code ?? "unknown"}:${d.range.start.line}`,
155
- message: d.message,
156
- filePath,
157
- line: d.range.start.line + 1,
158
- column: d.range.start.character + 1,
159
- severity: severityFromNumber(d.severity),
160
- semantic:
161
- d.severity === 1
162
- ? "blocking"
163
- : d.severity === 2
164
- ? "warning"
165
- : "silent",
166
- tool: serverId,
167
- })),
168
- durationMs: 0,
169
- });
152
+ // Signal any active waitForDiagnostics calls for this file.
153
+ diagnosticEmitter.emit("diagnostics", filePath);
170
154
  }, DIAGNOSTICS_DEBOUNCE_MS);
171
155
 
172
156
  pendingDiagnostics.set(filePath, timer);
@@ -301,31 +285,35 @@ export async function createLSPClient(options: {
301
285
 
302
286
  async waitForDiagnostics(filePath, timeoutMs = 10000) {
303
287
  const normalizedPath = normalizeMapKey(filePath);
288
+
289
+ // Fast path: diagnostics already available
304
290
  if (diagnostics.has(normalizedPath)) return;
305
291
 
306
- // Use bus subscription like OpenCode - more reliable than polling
307
- return new Promise((resolve) => {
292
+ return new Promise<void>((resolve) => {
308
293
  let debounceTimer: ReturnType<typeof setTimeout> | undefined;
309
294
 
310
- // Subscribe to diagnostic events from this server
311
- const unsub = DiagnosticFound.subscribe((event) => {
312
- if (
313
- event.properties.filePath === normalizedPath &&
314
- event.properties.runnerId === serverId
315
- ) {
316
- // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
317
- if (debounceTimer) clearTimeout(debounceTimer);
318
- debounceTimer = setTimeout(() => {
319
- unsub();
320
- clearTimeout(timeout);
321
- resolve();
322
- }, DIAGNOSTICS_DEBOUNCE_MS);
323
- }
324
- });
295
+ // Listen on the local emitter for this client's diagnostic notifications.
296
+ // No runnerId filter needed — this emitter is scoped to this client instance.
297
+ const onDiagnostics = (fp: string) => {
298
+ if (normalizeMapKey(fp) !== normalizedPath) return;
299
+
300
+ // Debounce: reset on each event to catch follow-up semantic diagnostics
301
+ // (LSP often sends syntax diagnostics first, semantic ones shortly after).
302
+ if (debounceTimer) clearTimeout(debounceTimer);
303
+ debounceTimer = setTimeout(() => {
304
+ diagnosticEmitter.off("diagnostics", onDiagnostics);
305
+ clearTimeout(timeout);
306
+ resolve();
307
+ }, DIAGNOSTICS_DEBOUNCE_MS);
308
+ };
325
309
 
310
+ diagnosticEmitter.on("diagnostics", onDiagnostics);
311
+
312
+ // Timeout fallback: resolve even if no diagnostics arrive
313
+ // (some files have no errors, or the server may be slow)
326
314
  const timeout = setTimeout(() => {
327
315
  if (debounceTimer) clearTimeout(debounceTimer);
328
- unsub();
316
+ diagnosticEmitter.off("diagnostics", onDiagnostics);
329
317
  resolve();
330
318
  }, timeoutMs);
331
319
  });
@@ -421,6 +409,9 @@ export async function createLSPClient(options: {
421
409
  }
422
410
  pendingDiagnostics.clear();
423
411
 
412
+ // Remove all diagnostic listeners (cancels any in-flight waitForDiagnostics)
413
+ diagnosticEmitter.removeAllListeners();
414
+
424
415
  // Graceful shutdown
425
416
  try {
426
417
  await connection.sendRequest("shutdown");
@@ -149,8 +149,11 @@ export async function launchLSP(command, args = [], options = {}) {
149
149
  if (npmGlobalPath) {
150
150
  spawnCommand = npmGlobalPath;
151
151
  // Recompute needsShell for npm global path
152
- const globalHasExt = /\.(exe|cmd|bat)$/i.test(spawnCommand);
153
- needsShell = isWindows && (spawnCommand.includes(" ") || !globalHasExt);
152
+ needsShell =
153
+ isWindows &&
154
+ (spawnCommand.includes(" ") ||
155
+ /\.(cmd|bat)$/i.test(spawnCommand) ||
156
+ !/\.(exe|cmd|bat)$/i.test(spawnCommand));
154
157
  }
155
158
  }
156
159
  let proc;
@@ -166,8 +169,10 @@ export async function launchLSP(command, args = [], options = {}) {
166
169
  if (npmGlobalPath && npmGlobalPath !== spawnCommand) {
167
170
  console.error(`[lsp] Trying npm global: ${npmGlobalPath}`);
168
171
  // Recompute needsShell for npm global path
169
- const globalHasExt = /\.(exe|cmd|bat)$/i.test(npmGlobalPath);
170
- const needsShellGlobal = isWindows && (npmGlobalPath.includes(" ") || !globalHasExt);
172
+ const needsShellGlobal = isWindows &&
173
+ (npmGlobalPath.includes(" ") ||
174
+ /\.(cmd|bat)$/i.test(npmGlobalPath) ||
175
+ !/\.(exe|cmd|bat)$/i.test(npmGlobalPath));
171
176
  proc = trySpawn(npmGlobalPath, args, cwd, env, needsShellGlobal);
172
177
  }
173
178
  else {
@@ -192,7 +197,7 @@ export async function launchLSP(command, args = [], options = {}) {
192
197
  let settled = false;
193
198
  // Attach error handler that can reject for immediate errors
194
199
  proc.on("error", (err) => {
195
- if (!settled && err.code === "ENOENT") {
200
+ if (!settled && (err.code === "ENOENT" || err.code === "EINVAL")) {
196
201
  settled = true;
197
202
  reject(new Error(`LSP server binary not found: ${command}. ` +
198
203
  `Install it or check your PATH.`));
@@ -254,7 +259,7 @@ export async function launchViaPackageManager(packageName, args = [], options =
254
259
  await new Promise((resolve, reject) => {
255
260
  let settled = false;
256
261
  proc.on("error", (err) => {
257
- if (!settled && err.code === "ENOENT") {
262
+ if (!settled && (err.code === "ENOENT" || err.code === "EINVAL")) {
258
263
  settled = true;
259
264
  reject(new Error(`Package manager not found for: ${packageName}. ` +
260
265
  `Install Node.js or check your PATH.`));
@@ -205,8 +205,11 @@ export async function launchLSP(
205
205
  if (npmGlobalPath) {
206
206
  spawnCommand = npmGlobalPath;
207
207
  // Recompute needsShell for npm global path
208
- const globalHasExt = /\.(exe|cmd|bat)$/i.test(spawnCommand);
209
- needsShell = isWindows && (spawnCommand.includes(" ") || !globalHasExt);
208
+ needsShell =
209
+ isWindows &&
210
+ (spawnCommand.includes(" ") ||
211
+ /\.(cmd|bat)$/i.test(spawnCommand) ||
212
+ !/\.(exe|cmd|bat)$/i.test(spawnCommand));
210
213
  }
211
214
  }
212
215
 
@@ -225,9 +228,11 @@ export async function launchLSP(
225
228
  if (npmGlobalPath && npmGlobalPath !== spawnCommand) {
226
229
  console.error(`[lsp] Trying npm global: ${npmGlobalPath}`);
227
230
  // Recompute needsShell for npm global path
228
- const globalHasExt = /\.(exe|cmd|bat)$/i.test(npmGlobalPath);
229
231
  const needsShellGlobal =
230
- isWindows && (npmGlobalPath.includes(" ") || !globalHasExt);
232
+ isWindows &&
233
+ (npmGlobalPath.includes(" ") ||
234
+ /\.(cmd|bat)$/i.test(npmGlobalPath) ||
235
+ !/\.(exe|cmd|bat)$/i.test(npmGlobalPath));
231
236
  proc = trySpawn(npmGlobalPath, args, cwd, env, needsShellGlobal);
232
237
  } else {
233
238
  throw err;
@@ -256,7 +261,7 @@ export async function launchLSP(
256
261
 
257
262
  // Attach error handler that can reject for immediate errors
258
263
  proc.on("error", (err: Error & { code?: string }) => {
259
- if (!settled && err.code === "ENOENT") {
264
+ if (!settled && (err.code === "ENOENT" || err.code === "EINVAL")) {
260
265
  settled = true;
261
266
  reject(
262
267
  new Error(
@@ -346,7 +351,7 @@ export async function launchViaPackageManager(
346
351
  let settled = false;
347
352
 
348
353
  proc.on("error", (err: Error & { code?: string }) => {
349
- if (!settled && err.code === "ENOENT") {
354
+ if (!settled && (err.code === "ENOENT" || err.code === "EINVAL")) {
350
355
  settled = true;
351
356
  reject(
352
357
  new Error(
@@ -233,7 +233,19 @@ export const GoServer = {
233
233
  root: createRootDetector(["go.mod", "go.sum"]),
234
234
  async spawn(root) {
235
235
  const proc = await spawnWithInteractiveInstall("go", "gopls", [], { cwd: root }, async () => await launchLSP("gopls", [], { cwd: root }));
236
- return proc ? { process: proc } : undefined;
236
+ // gopls works best with minimal initialization options
237
+ // The client capabilities fix (workspaceFolders: true) is the key fix
238
+ return proc
239
+ ? {
240
+ process: proc,
241
+ initialization: {
242
+ // Disable experimental features that may cause issues
243
+ ui: {
244
+ semanticTokens: true,
245
+ },
246
+ },
247
+ }
248
+ : undefined;
237
249
  },
238
250
  };
239
251
  export const RustServer = {
@@ -243,7 +255,20 @@ export const RustServer = {
243
255
  root: createRootDetector(["Cargo.toml", "Cargo.lock"]),
244
256
  async spawn(root) {
245
257
  const proc = await spawnWithInteractiveInstall("rust", "rust-analyzer", [], { cwd: root }, async () => await launchLSP("rust-analyzer", [], { cwd: root }));
246
- return proc ? { process: proc } : undefined;
258
+ // rust-analyzer needs minimal initialization to avoid capability mismatches
259
+ return proc
260
+ ? {
261
+ process: proc,
262
+ initialization: {
263
+ // Disable features that may conflict with our client capabilities
264
+ cargo: {
265
+ buildScripts: { enable: true },
266
+ },
267
+ procMacro: { enable: true },
268
+ diagnostics: { enable: true },
269
+ },
270
+ }
271
+ : undefined;
247
272
  },
248
273
  };
249
274
  export const RubyServer = {
@@ -0,0 +1,141 @@
1
+ import * as fs from "node:fs";
2
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
+ import { MetricsClient } from "./metrics-client.js";
4
+ import { createTempFile, setupTestEnvironment } from "./test-utils.js";
5
+ describe("MetricsClient", () => {
6
+ let client;
7
+ let tmpDir;
8
+ let cleanup;
9
+ beforeEach(() => {
10
+ client = new MetricsClient();
11
+ ({ tmpDir, cleanup } = setupTestEnvironment("pi-lens-metrics-test-"));
12
+ });
13
+ afterEach(() => {
14
+ cleanup();
15
+ });
16
+ afterEach(() => {
17
+ cleanup();
18
+ });
19
+ describe("calculateEntropy", () => {
20
+ it("should return 0 for empty string", () => {
21
+ expect(client.calculateEntropy("")).toBe(0);
22
+ });
23
+ it("should return 0 for single repeated character", () => {
24
+ expect(client.calculateEntropy("aaaaaa")).toBe(0);
25
+ });
26
+ it("should return 1 for two equally likely characters", () => {
27
+ expect(client.calculateEntropy("ababab")).toBe(1);
28
+ });
29
+ it("should return higher entropy for more diverse content", () => {
30
+ const lowEntropy = "aaaaaaaaaaaaaaaaaaaaaaaaaa";
31
+ const highEntropy = "abcdefghijklmnopqrstuvwxyz";
32
+ expect(client.calculateEntropy(highEntropy)).toBeGreaterThan(client.calculateEntropy(lowEntropy));
33
+ });
34
+ it("should match expected Shannon entropy for known input", () => {
35
+ // "aabb" has p(a)=0.5, p(b)=0.5, entropy = -2*(0.5*log2(0.5)) = 1
36
+ expect(client.calculateEntropy("aabb")).toBeCloseTo(1, 5);
37
+ });
38
+ });
39
+ describe("recordBaseline", () => {
40
+ it("should record baseline for existing file", () => {
41
+ const content = "const x = 1;\nconst y = 2;";
42
+ const filePath = createTempFile(tmpDir, "test.ts", content);
43
+ client.recordBaseline(filePath);
44
+ const metrics = client.getFileMetrics(filePath);
45
+ expect(metrics).not.toBeNull();
46
+ expect(metrics?.totalLines).toBeGreaterThanOrEqual(2);
47
+ });
48
+ it("should not record baseline for non-existent file", () => {
49
+ client.recordBaseline("/nonexistent/file.ts");
50
+ // Should not throw, just silently skip
51
+ });
52
+ it("should not overwrite existing baseline", () => {
53
+ const content1 = "const x = 1;\n";
54
+ const content2 = "const x = 1;\nconst y = 2;\nconst z = 3;\n";
55
+ const filePath = createTempFile(tmpDir, "test.ts", content1);
56
+ client.recordBaseline(filePath);
57
+ // Modify file
58
+ fs.writeFileSync(filePath, content2);
59
+ // Record again - should not update baseline
60
+ client.recordBaseline(filePath);
61
+ const metrics = client.getFileMetrics(filePath);
62
+ expect(metrics?.entropyStart).toBe(client.calculateEntropy(content1));
63
+ });
64
+ });
65
+ describe("recordWrite", () => {
66
+ it("should track agent-written lines", () => {
67
+ const original = "const x = 1;\n";
68
+ const filePath = createTempFile(tmpDir, "test.ts", original);
69
+ client.recordBaseline(filePath);
70
+ const modified = "const x = 1;\nconst y = 2;\nconst z = 3;\n";
71
+ fs.writeFileSync(filePath, modified);
72
+ client.recordWrite(filePath, modified);
73
+ const aiRatio = client.getAICodeRatio();
74
+ expect(aiRatio.agentLines).toBeGreaterThan(0);
75
+ });
76
+ it("should calculate AI code ratio", () => {
77
+ const file1 = createTempFile(tmpDir, "file1.ts", "original content line 1\noriginal content line 2\n");
78
+ const file2 = createTempFile(tmpDir, "file2.ts", "original\n");
79
+ client.recordBaseline(file1);
80
+ client.recordBaseline(file2);
81
+ // Simulate agent writing new content
82
+ const newContent1 = "original content line 1\noriginal content line 2\nagent line 3\nagent line 4\n";
83
+ fs.writeFileSync(file1, newContent1);
84
+ client.recordWrite(file1, newContent1);
85
+ const aiRatio = client.getAICodeRatio();
86
+ expect(aiRatio.fileCount).toBe(2);
87
+ expect(aiRatio.ratio).toBeGreaterThanOrEqual(0);
88
+ expect(aiRatio.ratio).toBeLessThanOrEqual(1);
89
+ });
90
+ });
91
+ describe("getEntropyDeltas", () => {
92
+ it("should track entropy changes", () => {
93
+ const simple = "const x = 1;\n";
94
+ const filePath = createTempFile(tmpDir, "test.ts", simple);
95
+ client.recordBaseline(filePath);
96
+ // Make file more complex
97
+ const complex = `
98
+ function complex(a: number, b: number, c: number): number {
99
+ if (a > 0) {
100
+ if (b > 0) {
101
+ if (c > 0) {
102
+ return a + b + c;
103
+ }
104
+ }
105
+ }
106
+ return 0;
107
+ }
108
+ `;
109
+ fs.writeFileSync(filePath, complex);
110
+ client.recordWrite(filePath, complex);
111
+ const deltas = client.getEntropyDeltas();
112
+ expect(deltas.length).toBe(1);
113
+ expect(deltas[0].delta).not.toBe(0);
114
+ });
115
+ });
116
+ describe("formatSessionSummary", () => {
117
+ it("should return empty string when no files touched", () => {
118
+ expect(client.formatSessionSummary()).toBe("");
119
+ });
120
+ it("should format AI code ratio when files are modified", () => {
121
+ const filePath = createTempFile(tmpDir, "test.ts", "original\n");
122
+ client.recordBaseline(filePath);
123
+ const modified = "original\nnew line 1\nnew line 2\n";
124
+ fs.writeFileSync(filePath, modified);
125
+ client.recordWrite(filePath, modified);
126
+ const summary = client.formatSessionSummary();
127
+ expect(summary).toContain("AI Code");
128
+ expect(summary).toContain("file(s)");
129
+ });
130
+ });
131
+ describe("reset", () => {
132
+ it("should clear all tracked data", () => {
133
+ const filePath = createTempFile(tmpDir, "test.ts", "content\n");
134
+ client.recordBaseline(filePath);
135
+ client.reset();
136
+ const aiRatio = client.getAICodeRatio();
137
+ expect(aiRatio.fileCount).toBe(0);
138
+ expect(client.formatSessionSummary()).toBe("");
139
+ });
140
+ });
141
+ });
@@ -0,0 +1,132 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { RuffClient } from "./ruff-client.js";
3
+ import { createTempFile, setupTestEnvironment } from "./test-utils.js";
4
+ describe("RuffClient", () => {
5
+ let client;
6
+ let tmpDir;
7
+ let cleanup;
8
+ beforeEach(() => {
9
+ client = new RuffClient();
10
+ ({ tmpDir, cleanup } = setupTestEnvironment("pi-lens-ruff-test-"));
11
+ });
12
+ afterEach(() => {
13
+ cleanup();
14
+ });
15
+ afterEach(() => {
16
+ cleanup();
17
+ });
18
+ describe("isPythonFile", () => {
19
+ it("should recognize Python files", () => {
20
+ expect(client.isPythonFile("test.py")).toBe(true);
21
+ expect(client.isPythonFile("module.py")).toBe(true);
22
+ });
23
+ it("should not recognize non-Python files", () => {
24
+ expect(client.isPythonFile("test.ts")).toBe(false);
25
+ expect(client.isPythonFile("test.js")).toBe(false);
26
+ expect(client.isPythonFile("test.txt")).toBe(false);
27
+ });
28
+ });
29
+ describe("isAvailable", () => {
30
+ it("should check ruff availability", () => {
31
+ const available = client.isAvailable();
32
+ expect(typeof available).toBe("boolean");
33
+ });
34
+ });
35
+ describe("checkFile", () => {
36
+ it("should return empty array for non-existent files", () => {
37
+ if (!client.isAvailable())
38
+ return;
39
+ const result = client.checkFile("/nonexistent/file.py");
40
+ expect(result).toEqual([]);
41
+ });
42
+ it("should detect lint issues in Python code", () => {
43
+ if (!client.isAvailable())
44
+ return;
45
+ const content = `
46
+ import os
47
+ import sys
48
+
49
+ x = 1
50
+ `;
51
+ const filePath = createTempFile(tmpDir, "test.py", content);
52
+ const result = client.checkFile(filePath);
53
+ // Should detect unused imports
54
+ expect(result.some((d) => d.rule === "F401" || d.message.includes("unused"))).toBe(true);
55
+ });
56
+ it("should return array of diagnostics", () => {
57
+ if (!client.isAvailable())
58
+ return;
59
+ const content = `
60
+ def foo():
61
+ x = undefined_variable
62
+ return x
63
+ `;
64
+ const filePath = createTempFile(tmpDir, "test.py", content);
65
+ const result = client.checkFile(filePath);
66
+ // Should return an array
67
+ expect(Array.isArray(result)).toBe(true);
68
+ });
69
+ });
70
+ describe("formatDiagnostics", () => {
71
+ it("should format diagnostics for display", () => {
72
+ const diags = [
73
+ {
74
+ line: 1,
75
+ column: 0,
76
+ endLine: 1,
77
+ endColumn: 10,
78
+ severity: "error",
79
+ message: "Undefined name 'x'",
80
+ rule: "F821",
81
+ file: "test.py",
82
+ fixable: false,
83
+ },
84
+ ];
85
+ const formatted = client.formatDiagnostics(diags);
86
+ expect(formatted).toContain("Ruff");
87
+ expect(formatted).toContain("F821");
88
+ expect(formatted).toContain("Undefined name");
89
+ });
90
+ it("should show fixable count", () => {
91
+ const diags = [
92
+ {
93
+ line: 1,
94
+ column: 0,
95
+ endLine: 1,
96
+ endColumn: 10,
97
+ severity: "warning",
98
+ message: "Unused import",
99
+ rule: "F401",
100
+ file: "test.py",
101
+ fixable: true,
102
+ },
103
+ ];
104
+ const formatted = client.formatDiagnostics(diags);
105
+ expect(formatted).toContain("fixable");
106
+ });
107
+ });
108
+ describe("checkFormatting", () => {
109
+ it("should detect formatting issues", () => {
110
+ if (!client.isAvailable())
111
+ return;
112
+ const content = `x=1
113
+ y=2
114
+ `;
115
+ const filePath = createTempFile(tmpDir, "test.py", content);
116
+ const result = client.checkFormatting(filePath);
117
+ // Should suggest formatting (missing spaces around =)
118
+ expect(typeof result).toBe("string");
119
+ });
120
+ it("should return empty string for well-formatted code", () => {
121
+ if (!client.isAvailable())
122
+ return;
123
+ const content = `x = 1
124
+ y = 2
125
+ `;
126
+ const filePath = createTempFile(tmpDir, "test.py", content);
127
+ const result = client.checkFormatting(filePath);
128
+ // Well-formatted code should return empty or minimal output
129
+ expect(result).toBe("");
130
+ });
131
+ });
132
+ });
@@ -0,0 +1,108 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
4
+ import { RustClient } from "./rust-client.js";
5
+ import { setupTestEnvironment } from "./test-utils.js";
6
+ describe("RustClient", () => {
7
+ let client;
8
+ let tmpDir;
9
+ let cleanup;
10
+ beforeEach(() => {
11
+ client = new RustClient();
12
+ ({ tmpDir, cleanup } = setupTestEnvironment("pi-lens-rust-test-"));
13
+ });
14
+ afterEach(() => {
15
+ cleanup();
16
+ });
17
+ afterEach(() => {
18
+ cleanup();
19
+ });
20
+ describe("isRustFile", () => {
21
+ it("should recognize Rust files", () => {
22
+ expect(client.isRustFile("main.rs")).toBe(true);
23
+ expect(client.isRustFile("lib.rs")).toBe(true);
24
+ });
25
+ it("should not recognize non-Rust files", () => {
26
+ expect(client.isRustFile("main.ts")).toBe(false);
27
+ expect(client.isRustFile("main.py")).toBe(false);
28
+ });
29
+ });
30
+ describe("isAvailable", () => {
31
+ it("should check cargo availability", () => {
32
+ const available = client.isAvailable();
33
+ expect(typeof available).toBe("boolean");
34
+ });
35
+ });
36
+ describe("checkFile", () => {
37
+ it("should return empty array for non-existent files", () => {
38
+ if (!client.isAvailable())
39
+ return;
40
+ const result = client.checkFile("/nonexistent/file.rs", tmpDir);
41
+ expect(result).toEqual([]);
42
+ });
43
+ it("should return array for valid Rust files", () => {
44
+ if (!client.isAvailable())
45
+ return;
46
+ // Need a Cargo.toml for cargo to work
47
+ fs.writeFileSync(path.join(tmpDir, "Cargo.toml"), `
48
+ [package]
49
+ name = "test"
50
+ version = "0.1.0"
51
+ edition = "2021"
52
+ `);
53
+ fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
54
+ fs.writeFileSync(path.join(tmpDir, "src", "main.rs"), `
55
+ fn main() {
56
+ println!("Hello, world!");
57
+ }
58
+ `);
59
+ const result = client.checkFile(path.join(tmpDir, "src", "main.rs"), tmpDir);
60
+ expect(Array.isArray(result)).toBe(true);
61
+ });
62
+ });
63
+ describe("formatDiagnostics", () => {
64
+ it("should format diagnostics for display", () => {
65
+ const diags = [
66
+ {
67
+ line: 3,
68
+ column: 0,
69
+ endLine: 3,
70
+ endColumn: 10,
71
+ severity: "error",
72
+ message: "cannot find value `x` in this scope",
73
+ code: "E0425",
74
+ file: "main.rs",
75
+ },
76
+ ];
77
+ const formatted = client.formatDiagnostics(diags);
78
+ expect(formatted).toContain("Rust");
79
+ expect(formatted).toContain("1 issue");
80
+ expect(formatted).toContain("E0425");
81
+ });
82
+ it("should show error and warning counts", () => {
83
+ const diags = [
84
+ {
85
+ line: 1,
86
+ column: 0,
87
+ endLine: 1,
88
+ endColumn: 10,
89
+ severity: "error",
90
+ message: "Error",
91
+ file: "test.rs",
92
+ },
93
+ {
94
+ line: 2,
95
+ column: 0,
96
+ endLine: 2,
97
+ endColumn: 10,
98
+ severity: "warning",
99
+ message: "Warning",
100
+ file: "test.rs",
101
+ },
102
+ ];
103
+ const formatted = client.formatDiagnostics(diags);
104
+ expect(formatted).toContain("1 error(s)");
105
+ expect(formatted).toContain("1 warning(s)");
106
+ });
107
+ });
108
+ });