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.
- package/CHANGELOG.md +12 -0
- package/README.md +4 -10
- package/clients/__tests__/file-time.test.js +216 -0
- package/clients/__tests__/format-service.test.js +245 -0
- package/clients/__tests__/formatters.test.js +271 -0
- package/clients/agent-behavior-client.test.js +94 -0
- package/clients/biome-client.test.js +144 -0
- package/clients/cache-manager.test.js +197 -0
- package/clients/complexity-client.test.js +234 -0
- package/clients/dependency-checker.test.js +60 -0
- package/clients/dispatch/__tests__/autofix-integration.test.js +245 -0
- package/clients/dispatch/__tests__/runner-registration.test.js +234 -0
- package/clients/dispatch/__tests__/runner-registration.test.ts +2 -2
- package/clients/dispatch/dispatcher.edge.test.js +82 -0
- package/clients/dispatch/dispatcher.format.test.js +46 -0
- package/clients/dispatch/dispatcher.inline.test.js +74 -0
- package/clients/dispatch/dispatcher.test.js +116 -0
- package/clients/dispatch/runners/architect.test.js +138 -0
- package/clients/dispatch/runners/ast-grep-napi.test.js +106 -0
- package/clients/dispatch/runners/lsp.js +42 -5
- package/clients/dispatch/runners/oxlint.test.js +230 -0
- package/clients/dispatch/runners/pyright.test.js +98 -0
- package/clients/dispatch/runners/python-slop.test.js +203 -0
- package/clients/dispatch/runners/scan_codebase.test.js +89 -0
- package/clients/dispatch/runners/shellcheck.test.js +98 -0
- package/clients/dispatch/runners/spellcheck.test.js +158 -0
- package/clients/dispatch/utils/format-utils.js +1 -6
- package/clients/dispatch/utils/format-utils.ts +1 -6
- package/clients/dogfood.test.js +201 -0
- package/clients/file-kinds.test.js +169 -0
- package/clients/formatters.js +1 -1
- package/clients/go-client.test.js +127 -0
- package/clients/jscpd-client.test.js +127 -0
- package/clients/knip-client.test.js +112 -0
- package/clients/lsp/__tests__/client.test.js +310 -0
- package/clients/lsp/__tests__/client.test.ts +1 -46
- package/clients/lsp/__tests__/config.test.js +167 -0
- package/clients/lsp/__tests__/error-recovery.test.js +213 -0
- package/clients/lsp/__tests__/integration.test.js +127 -0
- package/clients/lsp/__tests__/launch.test.js +313 -0
- package/clients/lsp/__tests__/server.test.js +259 -0
- package/clients/lsp/__tests__/service.test.js +435 -0
- package/clients/lsp/client.js +32 -44
- package/clients/lsp/client.ts +36 -45
- package/clients/lsp/server.js +27 -2
- package/clients/metrics-client.test.js +141 -0
- package/clients/ruff-client.test.js +132 -0
- package/clients/rust-client.test.js +108 -0
- package/clients/sanitize.test.js +177 -0
- package/clients/secrets-scanner.test.js +100 -0
- package/clients/test-runner-client.test.js +192 -0
- package/clients/todo-scanner.test.js +301 -0
- package/clients/type-coverage-client.test.js +105 -0
- package/clients/typescript-client.codefix.test.js +157 -0
- package/clients/typescript-client.test.js +105 -0
- package/commands/rate.test.js +119 -0
- package/index.ts +66 -72
- package/package.json +1 -1
- package/clients/bus/bus.js +0 -191
- package/clients/bus/bus.ts +0 -251
- package/clients/bus/events.js +0 -214
- package/clients/bus/events.ts +0 -279
- package/clients/bus/index.js +0 -8
- package/clients/bus/index.ts +0 -9
- package/clients/bus/integration.js +0 -158
- package/clients/bus/integration.ts +0 -227
- package/clients/dispatch/bus-dispatcher.js +0 -178
- package/clients/dispatch/bus-dispatcher.ts +0 -258
- package/clients/services/__tests__/effect-integration.test.ts +0 -111
- package/clients/services/effect-integration.js +0 -198
- package/clients/services/effect-integration.ts +0 -276
- package/clients/services/index.js +0 -7
- package/clients/services/index.ts +0 -8
- package/clients/services/runner-service.js +0 -134
- 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
|
-
#
|
|
35
|
-
pi --lens-lsp
|
|
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
|
|
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
|
|
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
|
+
});
|