pi-lens 3.2.0 → 3.3.0

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 (75) hide show
  1. package/CHANGELOG.md +12 -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/server.js +27 -2
  46. package/clients/metrics-client.test.js +141 -0
  47. package/clients/ruff-client.test.js +132 -0
  48. package/clients/rust-client.test.js +108 -0
  49. package/clients/sanitize.test.js +177 -0
  50. package/clients/secrets-scanner.test.js +100 -0
  51. package/clients/test-runner-client.test.js +192 -0
  52. package/clients/todo-scanner.test.js +301 -0
  53. package/clients/type-coverage-client.test.js +105 -0
  54. package/clients/typescript-client.codefix.test.js +157 -0
  55. package/clients/typescript-client.test.js +105 -0
  56. package/commands/rate.test.js +119 -0
  57. package/index.ts +66 -72
  58. package/package.json +1 -1
  59. package/clients/bus/bus.js +0 -191
  60. package/clients/bus/bus.ts +0 -251
  61. package/clients/bus/events.js +0 -214
  62. package/clients/bus/events.ts +0 -279
  63. package/clients/bus/index.js +0 -8
  64. package/clients/bus/index.ts +0 -9
  65. package/clients/bus/integration.js +0 -158
  66. package/clients/bus/integration.ts +0 -227
  67. package/clients/dispatch/bus-dispatcher.js +0 -178
  68. package/clients/dispatch/bus-dispatcher.ts +0 -258
  69. package/clients/services/__tests__/effect-integration.test.ts +0 -111
  70. package/clients/services/effect-integration.js +0 -198
  71. package/clients/services/effect-integration.ts +0 -276
  72. package/clients/services/index.js +0 -7
  73. package/clients/services/index.ts +0 -8
  74. package/clients/services/runner-service.js +0 -134
  75. package/clients/services/runner-service.ts +0 -225
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to pi-lens will be documented in this file.
4
4
 
5
+ ## [3.3.0] - 2026-04-02
6
+
7
+ ### Removed
8
+ - **`--lens-bus`**: Removed the experimental event bus system (Phase 1). The sequential dispatcher has richer features (delta mode, per-runner latency, baseline tracking) that the bus system never had.
9
+ - **`--lens-bus-debug`**: Removed alongside `--lens-bus`.
10
+ - **`--lens-effect`**: Removed the Effect-TS concurrent runner execution system (Phase 2). The sequential `dispatchForFile` is the authoritative implementation — it has delta mode, async `when()` handling, and latency tracking that the effect system lacked.
11
+
12
+ ### Changed
13
+ - **LSP client**: `waitForDiagnostics` in `clients/lsp/client.ts` now uses a local `EventEmitter` scoped to the client instance instead of the global bus for internal diagnostic signalling.
14
+
15
+ ---
16
+
5
17
  ## [3.2.0] - 2026-04-02
6
18
 
7
19
  ### Fixed
package/README.md CHANGED
@@ -31,8 +31,8 @@ pi --no-autoformat
31
31
  # Full LSP mode (31 language servers)
32
32
  pi --lens-lsp
33
33
 
34
- # Fastest mode (LSP + concurrent execution) (Experimental)
35
- pi --lens-lsp --lens-effect
34
+ # LSP mode (recommended for large projects)
35
+ pi --lens-lsp
36
36
  ```
37
37
 
38
38
  ## Install
@@ -136,7 +136,6 @@ Enable full Language Server Protocol support with `--lens-lsp`:
136
136
  **Usage:**
137
137
  ```bash
138
138
  pi --lens-lsp # Enable LSP
139
- pi --lens-lsp --lens-effect # LSP + concurrent execution
140
139
  ```
141
140
 
142
141
  ### `pi` vs `pi --lens-lsp`
@@ -162,7 +161,6 @@ See [docs/LSP_CONFIG.md](docs/LSP_CONFIG.md) for configuration options.
162
161
  | Mode | Flag | Description |
163
162
  |------|------|-------------|
164
163
  | **Sequential** | (default) | Runners execute one at a time |
165
- | **Concurrent** | `--lens-effect` | All runners in parallel via Effect-TS (Experimental) |
166
164
 
167
165
  ---
168
166
 
@@ -177,8 +175,6 @@ Every file write/edit triggers multiple analysis phases:
177
175
  4. **Runners execute** by priority (lower = earlier). See [Runners](#runners) section for full list.
178
176
  5. **Test runner detection** (post-write) — Detects Jest/Vitest/Pytest and runs relevant tests
179
177
 
180
- **With `--lens-effect`:** Dispatch runners execute concurrently via Effect-TS. Test runner remains sequential (step 5).
181
-
182
178
  **Delta mode behavior:**
183
179
  - **First write:** All issues tracked and stored in baseline
184
180
  - **Subsequent edits:** Only **NEW** issues shown (pre-existing issues filtered out)
@@ -480,7 +476,7 @@ pi-lens works out of the box for TypeScript/JavaScript. For full language suppor
480
476
  |------|---------|--------------|
481
477
  | **Standard** (default) | `pi` | Auto-formatting, TS/Python type-checking, sequential execution |
482
478
  | **Full LSP** | `pi --lens-lsp` | Real LSP servers (31 languages), sequential execution |
483
- | **Fastest** | `pi --lens-lsp --lens-effect` | Real LSP + concurrent execution (all runners in parallel) |
479
+ | **Fastest** | `pi --lens-lsp` | Real LSP + full runner suite |
484
480
 
485
481
 
486
482
  ### Flag Reference
@@ -488,7 +484,6 @@ pi-lens works out of the box for TypeScript/JavaScript. For full language suppor
488
484
  | Flag | Description |
489
485
  |------|-------------|
490
486
  | `--lens-lsp` | Use real Language Server Protocol servers instead of built-in type-checking |
491
- | `--lens-effect` | Run all runners **concurrently** (faster) instead of sequentially (Experimental) |
492
487
  | `--lens-verbose` | Enable detailed console logging |
493
488
  | `--no-autoformat` | Disable automatic formatting (formatting is **enabled by default**) |
494
489
  | `--no-autofix` | Disable all auto-fixing (Biome safe fixes + Ruff autofix **enabled by default**). Unsafe fixes (e.g. removing unused vars) are never applied automatically — use `/lens-booboo` with explicit confirmation. |
@@ -507,7 +502,7 @@ pi-lens works out of the box for TypeScript/JavaScript. For full language suppor
507
502
  ```bash
508
503
  pi # Default: auto-format, auto-fix, built-in type-checking
509
504
  pi --lens-lsp # LSP type-checking (31 languages)
510
- pi --lens-lsp --lens-effect # LSP + concurrent execution (fastest)
505
+ pi --lens-lsp # LSP mode (recommended)
511
506
  ```
512
507
 
513
508
  ---
@@ -705,7 +700,6 @@ See [CHANGELOG.md](CHANGELOG.md) for full history.
705
700
  ### Latest Highlights
706
701
 
707
702
  - **LSP Support:** 31 Language Server Protocol clients (4 core auto-installed, others via npx or manual)
708
- - **Concurrent Execution:** Effect-TS-based parallel runner execution with `--lens-effect`
709
703
  - **NAPI Runner:** 100x faster TypeScript/JavaScript structural analysis (~9ms vs ~1200ms) — currently disabled in realtime due to stability
710
704
  - **Slop Detection:** 33+ TypeScript and 40+ Python patterns for AI-generated code quality issues
711
705
 
@@ -0,0 +1,216 @@
1
+ /**
2
+ * FileTime Tracking Tests
3
+ *
4
+ * Tests the safety mechanism that prevents race conditions
5
+ * between auto-formatting and agent edits.
6
+ */
7
+ import { describe, it, expect, beforeEach } from "vitest";
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { FileTime, FileTimeError, createFileTime, clearAllSessions } from "../file-time.js";
11
+ import { fileURLToPath } from "url";
12
+ import { dirname } from "path";
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+ const TEST_DIR = path.join(__dirname, "..", "..", "test-filetime");
16
+ describe("FileTime", () => {
17
+ let fileTime;
18
+ const sessionID = "test-session";
19
+ beforeEach(() => {
20
+ clearAllSessions();
21
+ fileTime = new FileTime(sessionID);
22
+ // Ensure test directory exists
23
+ if (!fs.existsSync(TEST_DIR)) {
24
+ fs.mkdirSync(TEST_DIR, { recursive: true });
25
+ }
26
+ });
27
+ describe("read()", () => {
28
+ it("should record file stamp with mtime/ctime/size", () => {
29
+ const testFile = path.join(TEST_DIR, "test1.txt");
30
+ fs.writeFileSync(testFile, "hello");
31
+ const stamp = fileTime.read(testFile);
32
+ expect(stamp.readAt).toBeInstanceOf(Date);
33
+ expect(stamp.mtime).toBeDefined();
34
+ expect(stamp.ctime).toBeDefined();
35
+ expect(stamp.size).toBe(5);
36
+ });
37
+ it("should handle non-existent files gracefully", () => {
38
+ const testFile = path.join(TEST_DIR, "nonexistent.txt");
39
+ const stamp = fileTime.read(testFile);
40
+ expect(stamp.readAt).toBeInstanceOf(Date);
41
+ expect(stamp.mtime).toBeUndefined();
42
+ expect(stamp.ctime).toBeUndefined();
43
+ expect(stamp.size).toBeUndefined();
44
+ });
45
+ it("should track multiple files per session", () => {
46
+ const file1 = path.join(TEST_DIR, "file1.txt");
47
+ const file2 = path.join(TEST_DIR, "file2.txt");
48
+ fs.writeFileSync(file1, "content1");
49
+ fs.writeFileSync(file2, "content2");
50
+ fileTime.read(file1);
51
+ fileTime.read(file2);
52
+ expect(fileTime.get(file1)).toBeDefined();
53
+ expect(fileTime.get(file2)).toBeDefined();
54
+ });
55
+ });
56
+ describe("get()", () => {
57
+ it("should return undefined for unread files", () => {
58
+ const testFile = path.join(TEST_DIR, "unread.txt");
59
+ const stamp = fileTime.get(testFile);
60
+ expect(stamp).toBeUndefined();
61
+ });
62
+ it("should return recorded stamp for read files", () => {
63
+ const testFile = path.join(TEST_DIR, "test2.txt");
64
+ fs.writeFileSync(testFile, "content");
65
+ const recorded = fileTime.read(testFile);
66
+ const retrieved = fileTime.get(testFile);
67
+ expect(retrieved?.mtime).toBe(recorded.mtime);
68
+ expect(retrieved?.ctime).toBe(recorded.ctime);
69
+ expect(retrieved?.size).toBe(recorded.size);
70
+ });
71
+ });
72
+ describe("assert()", () => {
73
+ it("should throw FileTimeError for unread files", () => {
74
+ const testFile = path.join(TEST_DIR, "never-read.txt");
75
+ fs.writeFileSync(testFile, "content");
76
+ expect(() => fileTime.assert(testFile)).toThrow(FileTimeError);
77
+ expect(() => fileTime.assert(testFile)).toThrow(/must read file/);
78
+ });
79
+ it("should not throw for unchanged files", () => {
80
+ const testFile = path.join(TEST_DIR, "unchanged.txt");
81
+ fs.writeFileSync(testFile, "content");
82
+ fileTime.read(testFile);
83
+ expect(() => fileTime.assert(testFile)).not.toThrow();
84
+ });
85
+ it("should throw FileTimeError when file modified externally", () => {
86
+ const testFile = path.join(TEST_DIR, "modified.txt");
87
+ fs.writeFileSync(testFile, "original");
88
+ fileTime.read(testFile);
89
+ // Simulate external modification
90
+ fs.writeFileSync(testFile, "modified content");
91
+ expect(() => fileTime.assert(testFile)).toThrow(FileTimeError);
92
+ expect(() => fileTime.assert(testFile)).toThrow(/modified since it was last read/);
93
+ });
94
+ it("should detect size changes", () => {
95
+ const testFile = path.join(TEST_DIR, "size-change.txt");
96
+ fs.writeFileSync(testFile, "original content");
97
+ fileTime.read(testFile);
98
+ // Truncate file (size change, mtime change)
99
+ fs.writeFileSync(testFile, "x");
100
+ expect(() => fileTime.assert(testFile)).toThrow(FileTimeError);
101
+ });
102
+ });
103
+ describe("hasChanged()", () => {
104
+ it("should return true for unread files", () => {
105
+ const testFile = path.join(TEST_DIR, "unread-check.txt");
106
+ fs.writeFileSync(testFile, "content");
107
+ expect(fileTime.hasChanged(testFile)).toBe(true);
108
+ });
109
+ it("should return false for unchanged files", () => {
110
+ const testFile = path.join(TEST_DIR, "unchanged-check.txt");
111
+ fs.writeFileSync(testFile, "content");
112
+ fileTime.read(testFile);
113
+ expect(fileTime.hasChanged(testFile)).toBe(false);
114
+ });
115
+ it("should return true when file modified", () => {
116
+ const testFile = path.join(TEST_DIR, "changed-check.txt");
117
+ fs.writeFileSync(testFile, "original");
118
+ fileTime.read(testFile);
119
+ fs.writeFileSync(testFile, "changed");
120
+ expect(fileTime.hasChanged(testFile)).toBe(true);
121
+ });
122
+ });
123
+ describe("withLock()", () => {
124
+ it("should execute function exclusively", async () => {
125
+ const testFile = path.join(TEST_DIR, "locked.txt");
126
+ const executionOrder = [];
127
+ const fn1 = async () => {
128
+ executionOrder.push("start1");
129
+ await new Promise(r => setTimeout(r, 50));
130
+ executionOrder.push("end1");
131
+ return "result1";
132
+ };
133
+ const fn2 = async () => {
134
+ executionOrder.push("start2");
135
+ await new Promise(r => setTimeout(r, 50));
136
+ executionOrder.push("end2");
137
+ return "result2";
138
+ };
139
+ // Start both, but they should execute sequentially
140
+ const promise1 = fileTime.withLock(testFile, fn1);
141
+ const promise2 = fileTime.withLock(testFile, fn2);
142
+ await Promise.all([promise1, promise2]);
143
+ // Should be sequential, not interleaved
144
+ expect(executionOrder).toEqual(["start1", "end1", "start2", "end2"]);
145
+ });
146
+ it("should return function result", async () => {
147
+ const testFile = path.join(TEST_DIR, "lock-result.txt");
148
+ const result = await fileTime.withLock(testFile, async () => {
149
+ return "success";
150
+ });
151
+ expect(result).toBe("success");
152
+ });
153
+ });
154
+ describe("clear()", () => {
155
+ it("should clear all tracked files for session", () => {
156
+ const file1 = path.join(TEST_DIR, "clear1.txt");
157
+ const file2 = path.join(TEST_DIR, "clear2.txt");
158
+ fs.writeFileSync(file1, "a");
159
+ fs.writeFileSync(file2, "b");
160
+ fileTime.read(file1);
161
+ fileTime.read(file2);
162
+ fileTime.clear();
163
+ expect(fileTime.get(file1)).toBeUndefined();
164
+ expect(fileTime.get(file2)).toBeUndefined();
165
+ });
166
+ });
167
+ describe("cross-session isolation", () => {
168
+ it("should isolate file tracking between sessions", () => {
169
+ const testFile = path.join(TEST_DIR, "isolated.txt");
170
+ fs.writeFileSync(testFile, "content");
171
+ const session1 = new FileTime("session1");
172
+ const session2 = new FileTime("session2");
173
+ session1.read(testFile);
174
+ // session2 should not see session1's reads
175
+ expect(() => session2.assert(testFile)).toThrow(FileTimeError);
176
+ expect(() => session2.assert(testFile)).toThrow(/must read file/);
177
+ });
178
+ });
179
+ describe("FileTimeError", () => {
180
+ it("should have correct error properties", () => {
181
+ const testFile = path.join(TEST_DIR, "error.txt");
182
+ fs.writeFileSync(testFile, "content");
183
+ try {
184
+ fileTime.assert(testFile);
185
+ }
186
+ catch (error) {
187
+ expect(error).toBeInstanceOf(FileTimeError);
188
+ expect(error.name).toBe("FileTimeError");
189
+ expect(error.filePath).toBe(path.resolve(testFile));
190
+ expect(error.reason).toBe("not-read");
191
+ }
192
+ });
193
+ });
194
+ });
195
+ describe("createFileTime helper", () => {
196
+ it("should create FileTime instance with session ID", () => {
197
+ const ft = createFileTime("my-session");
198
+ expect(ft).toBeInstanceOf(FileTime);
199
+ });
200
+ });
201
+ describe("clearAllSessions helper", () => {
202
+ it("should clear all session tracking", () => {
203
+ const ft1 = createFileTime("session1");
204
+ const ft2 = createFileTime("session2");
205
+ const testFile = path.join(TEST_DIR, "clearall.txt");
206
+ fs.writeFileSync(testFile, "x");
207
+ ft1.read(testFile);
208
+ ft2.read(testFile);
209
+ clearAllSessions();
210
+ // After clearing, both should throw "not read"
211
+ const ft1New = createFileTime("session1");
212
+ const ft2New = createFileTime("session2");
213
+ expect(() => ft1New.assert(testFile)).toThrow(/must read file/);
214
+ expect(() => ft2New.assert(testFile)).toThrow(/must read file/);
215
+ });
216
+ });
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Format Service Tests
3
+ *
4
+ * Tests concurrent formatter execution via Effect-TS
5
+ * and FileTime integration for safety.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
12
+ import { FileTimeError } from "../file-time.js";
13
+ import { FormatService, getFormatService, resetFormatService, } from "../format-service.js";
14
+ import { clearAllSessions } from "../file-time.js";
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+ const TEST_DIR = path.join(__dirname, "..", "..", "test-format-service");
18
+ describe("FormatService", () => {
19
+ let formatService;
20
+ const sessionID = "test-format-session";
21
+ beforeEach(() => {
22
+ resetFormatService();
23
+ clearAllSessions(); // Clear FileTime global state for test isolation
24
+ formatService = new FormatService(sessionID, true);
25
+ if (fs.existsSync(TEST_DIR)) {
26
+ fs.rmSync(TEST_DIR, { recursive: true });
27
+ }
28
+ fs.mkdirSync(TEST_DIR, { recursive: true });
29
+ });
30
+ afterEach(() => {
31
+ resetFormatService();
32
+ clearAllSessions(); // Clear FileTime global state for test isolation
33
+ if (fs.existsSync(TEST_DIR)) {
34
+ fs.rmSync(TEST_DIR, { recursive: true });
35
+ }
36
+ });
37
+ describe("formatFile()", () => {
38
+ it("should skip formatting when disabled", async () => {
39
+ const disabledService = new FormatService(sessionID, false);
40
+ const testFile = path.join(TEST_DIR, "disabled.txt");
41
+ fs.writeFileSync(testFile, "content");
42
+ const result = await disabledService.formatFile(testFile);
43
+ expect(result.formatters).toEqual([]);
44
+ expect(result.anyChanged).toBe(false);
45
+ expect(result.allSucceeded).toBe(true);
46
+ });
47
+ it("should skip formatting with skip option", async () => {
48
+ const testFile = path.join(TEST_DIR, "skipped.txt");
49
+ fs.writeFileSync(testFile, "content");
50
+ const result = await formatService.formatFile(testFile, { skip: true });
51
+ expect(result.formatters).toEqual([]);
52
+ expect(result.anyChanged).toBe(false);
53
+ expect(result.allSucceeded).toBe(true);
54
+ });
55
+ it("should skip when file modified externally", async () => {
56
+ const testFile = path.join(TEST_DIR, "external.txt");
57
+ fs.writeFileSync(testFile, "original");
58
+ // Record read
59
+ formatService.recordRead(testFile);
60
+ // Modify externally
61
+ fs.writeFileSync(testFile, "modified");
62
+ const result = await formatService.formatFile(testFile);
63
+ expect(result.formatters).toEqual([]);
64
+ expect(result.anyChanged).toBe(false);
65
+ expect(result.allSucceeded).toBe(false);
66
+ });
67
+ it("should format TypeScript file with biome config", async () => {
68
+ fs.writeFileSync(path.join(TEST_DIR, "biome.json"), '{"formatter": {}}');
69
+ const tsFile = path.join(TEST_DIR, "test.ts");
70
+ fs.writeFileSync(tsFile, "const x=1;");
71
+ // Record read so format service knows initial state
72
+ formatService.recordRead(tsFile);
73
+ const result = await formatService.formatFile(tsFile);
74
+ expect(result.filePath).toBe(tsFile);
75
+ expect(result.formatters.some((f) => f.name === "biome")).toBe(true);
76
+ });
77
+ it("should format Python file with ruff config", async () => {
78
+ fs.writeFileSync(path.join(TEST_DIR, "pyproject.toml"), "[tool.ruff]\nline-length = 100");
79
+ const pyFile = path.join(TEST_DIR, "test.py");
80
+ fs.writeFileSync(pyFile, "x=1");
81
+ // Record read so format service knows initial state
82
+ formatService.recordRead(pyFile);
83
+ const result = await formatService.formatFile(pyFile);
84
+ expect(result.filePath).toBe(pyFile);
85
+ expect(result.formatters.some((f) => f.name === "ruff")).toBe(true);
86
+ });
87
+ it("should run multiple formatters for same file", async () => {
88
+ fs.writeFileSync(path.join(TEST_DIR, "biome.json"), "{}");
89
+ fs.writeFileSync(path.join(TEST_DIR, "package.json"), JSON.stringify({ devDependencies: { prettier: "^3.0.0" } }));
90
+ const tsFile = path.join(TEST_DIR, "test.ts");
91
+ fs.writeFileSync(tsFile, "const x = 1;");
92
+ formatService.recordRead(tsFile);
93
+ const result = await formatService.formatFile(tsFile);
94
+ const names = result.formatters.map((f) => f.name);
95
+ expect(names).toContain("biome");
96
+ });
97
+ it("should return empty result for unsupported file", async () => {
98
+ const txtFile = path.join(TEST_DIR, "test.txt");
99
+ fs.writeFileSync(txtFile, "content");
100
+ formatService.recordRead(txtFile);
101
+ const result = await formatService.formatFile(txtFile);
102
+ expect(result.formatters).toEqual([]);
103
+ expect(result.anyChanged).toBe(false);
104
+ expect(result.allSucceeded).toBe(true);
105
+ });
106
+ it("should record FileTime after formatting", async () => {
107
+ fs.writeFileSync(path.join(TEST_DIR, "biome.json"), "{}");
108
+ const tsFile = path.join(TEST_DIR, "test.ts");
109
+ fs.writeFileSync(tsFile, "const x = 1;");
110
+ formatService.recordRead(tsFile);
111
+ await formatService.formatFile(tsFile);
112
+ expect(() => formatService.assertUnchanged(tsFile)).not.toThrow();
113
+ });
114
+ it("should report success/failure for each formatter", async () => {
115
+ fs.writeFileSync(path.join(TEST_DIR, "biome.json"), "{}");
116
+ const tsFile = path.join(TEST_DIR, "test.ts");
117
+ fs.writeFileSync(tsFile, "const x = 1;");
118
+ formatService.recordRead(tsFile);
119
+ const result = await formatService.formatFile(tsFile);
120
+ for (const formatter of result.formatters) {
121
+ expect(formatter).toHaveProperty("name");
122
+ expect(formatter).toHaveProperty("success");
123
+ expect(formatter).toHaveProperty("changed");
124
+ expect(typeof formatter.success).toBe("boolean");
125
+ expect(typeof formatter.changed).toBe("boolean");
126
+ }
127
+ });
128
+ it("should re-read file content after formatting", async () => {
129
+ fs.writeFileSync(path.join(TEST_DIR, "biome.json"), "{}");
130
+ const tsFile = path.join(TEST_DIR, "test.ts");
131
+ fs.writeFileSync(tsFile, "const x = 1;");
132
+ formatService.recordRead(tsFile);
133
+ const _result = await formatService.formatFile(tsFile);
134
+ const after = fs.readFileSync(tsFile, "utf-8");
135
+ expect(after).toBeDefined();
136
+ });
137
+ });
138
+ describe("assertUnchanged()", () => {
139
+ it("should not throw for unchanged files", async () => {
140
+ const testFile = path.join(TEST_DIR, "unchanged.txt");
141
+ fs.writeFileSync(testFile, "content");
142
+ formatService.recordRead(testFile);
143
+ expect(() => formatService.assertUnchanged(testFile)).not.toThrow();
144
+ });
145
+ it("should throw FileTimeError when file modified", async () => {
146
+ const testFile = path.join(TEST_DIR, "modified.txt");
147
+ fs.writeFileSync(testFile, "original");
148
+ formatService.recordRead(testFile);
149
+ fs.writeFileSync(testFile, "changed");
150
+ expect(() => formatService.assertUnchanged(testFile)).toThrow(FileTimeError);
151
+ });
152
+ });
153
+ describe("hasChanged()", () => {
154
+ it("should return false for unchanged files", async () => {
155
+ const testFile = path.join(TEST_DIR, "unchanged-check.txt");
156
+ fs.writeFileSync(testFile, "content");
157
+ formatService.recordRead(testFile);
158
+ expect(formatService.hasChanged(testFile)).toBe(false);
159
+ });
160
+ it("should return true when file modified", async () => {
161
+ const testFile = path.join(TEST_DIR, "changed-check.txt");
162
+ fs.writeFileSync(testFile, "original");
163
+ formatService.recordRead(testFile);
164
+ // Small delay to ensure different mtime (Windows has ~16ms resolution)
165
+ await new Promise((r) => setTimeout(r, 50));
166
+ fs.writeFileSync(testFile, "modified");
167
+ expect(formatService.hasChanged(testFile)).toBe(true);
168
+ });
169
+ it("should return true for unread files", async () => {
170
+ const testFile = path.join(TEST_DIR, "never-read.txt");
171
+ fs.writeFileSync(testFile, "content");
172
+ expect(formatService.hasChanged(testFile)).toBe(true);
173
+ });
174
+ });
175
+ describe("recordRead()", () => {
176
+ it("should record file read for tracking", async () => {
177
+ const testFile = path.join(TEST_DIR, "tracked.txt");
178
+ fs.writeFileSync(testFile, "content");
179
+ formatService.recordRead(testFile);
180
+ expect(formatService.hasChanged(testFile)).toBe(false);
181
+ });
182
+ });
183
+ describe("clearCache()", () => {
184
+ it("should clear formatter detection cache", async () => {
185
+ fs.writeFileSync(path.join(TEST_DIR, "biome.json"), "{}");
186
+ const tsFile = path.join(TEST_DIR, "test.ts");
187
+ fs.writeFileSync(tsFile, "const x = 1;");
188
+ formatService.recordRead(tsFile);
189
+ await formatService.formatFile(tsFile);
190
+ formatService.clearCache();
191
+ await formatService.formatFile(tsFile);
192
+ });
193
+ });
194
+ describe("Concurrency", () => {
195
+ it("should handle multiple files concurrently", async () => {
196
+ fs.writeFileSync(path.join(TEST_DIR, "biome.json"), "{}");
197
+ const files = [
198
+ path.join(TEST_DIR, "file1.ts"),
199
+ path.join(TEST_DIR, "file2.ts"),
200
+ path.join(TEST_DIR, "file3.ts"),
201
+ ];
202
+ for (const file of files) {
203
+ fs.writeFileSync(file, "const x = 1;");
204
+ formatService.recordRead(file);
205
+ }
206
+ const results = await Promise.all(files.map((f) => formatService.formatFile(f)));
207
+ expect(results).toHaveLength(3);
208
+ for (const result of results) {
209
+ expect(result.filePath).toBeDefined();
210
+ expect(result.formatters.length).toBeGreaterThan(0);
211
+ }
212
+ });
213
+ });
214
+ });
215
+ describe("getFormatService singleton", () => {
216
+ beforeEach(() => {
217
+ resetFormatService();
218
+ });
219
+ afterEach(() => {
220
+ resetFormatService();
221
+ });
222
+ it("should return singleton instance", () => {
223
+ const instance1 = getFormatService();
224
+ const instance2 = getFormatService();
225
+ expect(instance1).toBe(instance2);
226
+ });
227
+ it("should create new instance when session ID provided", () => {
228
+ const instance1 = getFormatService("session1");
229
+ const instance2 = getFormatService("session2");
230
+ expect(instance1).not.toBe(instance2);
231
+ });
232
+ it("should use cached instance regardless of enabled flag changes", () => {
233
+ const first = getFormatService("test", true);
234
+ const second = getFormatService("test", false);
235
+ expect(first).toBe(second);
236
+ });
237
+ });
238
+ describe("resetFormatService", () => {
239
+ it("should reset singleton instance", () => {
240
+ const instance1 = getFormatService();
241
+ resetFormatService();
242
+ const instance2 = getFormatService();
243
+ expect(instance1).not.toBe(instance2);
244
+ });
245
+ });