@gotgenes/pi-autoformat 0.1.0 → 4.0.3

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/.github/workflows/ci.yml +1 -3
  2. package/.github/workflows/release-please.yml +29 -0
  3. package/.markdownlint-cli2.yaml +14 -2
  4. package/.pi/extensions/pi-autoformat/config.json +3 -6
  5. package/.pi/prompts/README.md +59 -0
  6. package/.pi/prompts/plan-issue.md +64 -0
  7. package/.pi/prompts/retro.md +144 -0
  8. package/.pi/prompts/ship-issue.md +77 -0
  9. package/.pi/prompts/tdd-plan.md +67 -0
  10. package/.pi/skills/pi-extension-lifecycle/SKILL.md +256 -0
  11. package/.release-please-manifest.json +1 -1
  12. package/AGENTS.md +39 -0
  13. package/CHANGELOG.md +365 -0
  14. package/README.md +42 -109
  15. package/biome.json +1 -1
  16. package/docs/assets/logo.png +0 -0
  17. package/docs/assets/logo.svg +533 -0
  18. package/docs/configuration.md +358 -38
  19. package/docs/plans/0001-initial-implementation-plan.md +17 -9
  20. package/docs/plans/0002-richer-tui-formatter-summaries.md +220 -0
  21. package/docs/plans/0003-additional-pi-mutation-tools.md +273 -0
  22. package/docs/plans/0004-shell-driven-mutation-coverage.md +296 -0
  23. package/docs/plans/0010-acceptance-test-coverage.md +240 -0
  24. package/docs/plans/0012-remove-unused-formatter-extensions-field.md +152 -0
  25. package/docs/plans/0013-fallback-chain-step-type.md +280 -0
  26. package/docs/plans/0014-batch-by-default-formatter-dispatch.md +195 -0
  27. package/docs/plans/0015-builtin-treefmt-and-treefmt-nix-support.md +290 -0
  28. package/docs/plans/0016-detailed-formatter-output-on-failure.md +245 -0
  29. package/docs/plans/0022-pi-coding-agent-types.md +201 -0
  30. package/docs/plans/0027-format-before-agent-exit-follow-up-turn.md +355 -0
  31. package/docs/plans/0031-turn-end-flush-with-change-detection.md +365 -0
  32. package/docs/retro/0002-richer-tui-formatter-summaries.md +47 -0
  33. package/docs/retro/0013-fallback-chain-step-type.md +67 -0
  34. package/docs/retro/0015-builtin-treefmt-and-treefmt-nix-support.md +56 -0
  35. package/docs/retro/0016-detailed-formatter-output-on-failure.md +60 -0
  36. package/docs/retro/0022-pi-coding-agent-types.md +62 -0
  37. package/docs/testing.md +95 -0
  38. package/package.json +30 -11
  39. package/prek.toml +2 -2
  40. package/schemas/pi-autoformat.schema.json +145 -21
  41. package/src/builtin-formatters.ts +205 -0
  42. package/src/command-probe.ts +66 -0
  43. package/src/config-loader.ts +829 -90
  44. package/src/custom-mutation-tools.ts +125 -0
  45. package/src/extension.ts +469 -82
  46. package/src/format-scope.ts +118 -0
  47. package/src/formatter-config.ts +73 -36
  48. package/src/formatter-executor.ts +230 -34
  49. package/src/formatter-output-report.ts +149 -0
  50. package/src/formatter-registry.ts +139 -30
  51. package/src/index.ts +26 -5
  52. package/src/prompt-autoformatter.ts +148 -23
  53. package/src/shell-mutation-detector.ts +572 -0
  54. package/src/touched-files-queue.ts +72 -11
  55. package/test/acceptance-event-bus.test.ts +138 -0
  56. package/test/acceptance.test.ts +69 -0
  57. package/test/builtin-formatters.test.ts +382 -0
  58. package/test/command-probe.test.ts +79 -0
  59. package/test/config-loader.test.ts +640 -21
  60. package/test/custom-mutation-tools.test.ts +190 -0
  61. package/test/extension.test.ts +1535 -158
  62. package/test/fallback-acceptance.test.ts +98 -0
  63. package/test/fixtures/event-bus-emitter.ts +26 -0
  64. package/test/fixtures/formatter-recorder.mjs +25 -0
  65. package/test/format-scope.test.ts +139 -0
  66. package/test/formatter-config.test.ts +56 -5
  67. package/test/formatter-executor.test.ts +555 -35
  68. package/test/formatter-output-report.test.ts +178 -0
  69. package/test/formatter-registry.test.ts +330 -37
  70. package/test/helpers/rpc.ts +146 -0
  71. package/test/prompt-autoformatter.test.ts +315 -22
  72. package/test/schema.test.ts +149 -0
  73. package/test/shell-mutation-detector.test.ts +221 -0
  74. package/test/touched-files-queue.test.ts +40 -1
  75. package/test/types/theme-stub.test-d.ts +42 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Acceptance test: the production extension subscribes to Pi's real
3
+ * EventBus and runs configured formatters on paths emitted via the
4
+ * `autoformat:touched` channel.
5
+ *
6
+ * Drives the `pi` CLI in `--mode rpc` with two extensions loaded:
7
+ *
8
+ * 1. `src/extension.ts` — the production autoformatter.
9
+ * 2. `test/fixtures/event-bus-emitter.ts` — exposes `/emit-touched` so
10
+ * the test can stage a real EventBus emit without an LLM.
11
+ *
12
+ * The "formatter" configured in the temp project is
13
+ * `test/fixtures/formatter-recorder.mjs`, which appends one JSON line
14
+ * per invocation to a log file. That gives us a deterministic ground
15
+ * truth for what Pi actually invoked, without mocking anything inside
16
+ * the extension.
17
+ */
18
+
19
+ import {
20
+ mkdirSync,
21
+ mkdtempSync,
22
+ readFileSync,
23
+ realpathSync,
24
+ rmSync,
25
+ writeFileSync,
26
+ } from "node:fs";
27
+ import { tmpdir } from "node:os";
28
+ import { join, resolve } from "node:path";
29
+
30
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
31
+
32
+ import { piAvailable, runRpcSession } from "./helpers/rpc.js";
33
+
34
+ const FIXTURE_EXTENSION = resolve(
35
+ __dirname,
36
+ "fixtures",
37
+ "event-bus-emitter.ts",
38
+ );
39
+ const RECORDER_PATH = resolve(__dirname, "fixtures", "formatter-recorder.mjs");
40
+
41
+ const describeIfPi = piAvailable ? describe : describe.skip;
42
+
43
+ type RecorderEntry = {
44
+ argv: string[];
45
+ cwd: string;
46
+ };
47
+
48
+ function readRecorderLog(logPath: string): RecorderEntry[] {
49
+ const contents = readFileSync(logPath, "utf-8");
50
+ return contents
51
+ .split("\n")
52
+ .map((line) => line.trim())
53
+ .filter((line) => line.length > 0)
54
+ .map((line) => JSON.parse(line) as RecorderEntry);
55
+ }
56
+
57
+ describeIfPi("acceptance: autoformat:touched event bus", () => {
58
+ let workDir: string;
59
+ let logPath: string;
60
+
61
+ beforeEach(() => {
62
+ // realpathSync resolves macOS's /var → /private/var symlink so the
63
+ // recorder's process.cwd() (which Pi spawns from a realpath'd cwd)
64
+ // matches what the test expects.
65
+ workDir = realpathSync(
66
+ mkdtempSync(join(tmpdir(), "pi-autoformat-eventbus-")),
67
+ );
68
+ logPath = join(workDir, "recorder.log");
69
+
70
+ // Project config: formatter writes to the recorder; flush on session
71
+ // shutdown so closing stdin is enough to drain the queue.
72
+ const configDir = join(workDir, ".pi", "extensions", "pi-autoformat");
73
+ mkdirSync(configDir, { recursive: true });
74
+ writeFileSync(
75
+ join(configDir, "config.json"),
76
+ JSON.stringify({
77
+ eventBusMutationChannel: {
78
+ enabled: true,
79
+ channel: "autoformat:touched",
80
+ },
81
+ formatters: {
82
+ recorder: {
83
+ command: ["node", RECORDER_PATH],
84
+ environment: {
85
+ PI_AUTOFORMAT_RECORDER_LOG: logPath,
86
+ },
87
+ },
88
+ },
89
+ chains: {
90
+ ".ts": ["recorder"],
91
+ },
92
+ }),
93
+ );
94
+
95
+ // The file the test will tell pi about. The formatter is invoked
96
+ // against this absolute path; the file does not actually need to be
97
+ // formatted, but it must exist for in-scope filtering.
98
+ writeFileSync(join(workDir, "out.ts"), "export const x = 1;\n");
99
+ });
100
+
101
+ afterEach(() => {
102
+ if (workDir) {
103
+ rmSync(workDir, { recursive: true, force: true });
104
+ }
105
+ });
106
+
107
+ it("runs the configured formatter on paths emitted via pi.events", async () => {
108
+ const targetPath = join(workDir, "out.ts");
109
+
110
+ const { exitCode, stderr, responses } = await runRpcSession({
111
+ cwd: workDir,
112
+ extraExtensions: [FIXTURE_EXTENSION],
113
+ commands: [
114
+ { id: "1", type: "prompt", message: `/emit-touched ${targetPath}` },
115
+ ],
116
+ timeoutMs: 15_000,
117
+ });
118
+
119
+ expect(exitCode).toBe(0);
120
+ expect(stderr).not.toMatch(/Extension .* error/i);
121
+
122
+ const promptResponse = responses.find((r) => r.id === "1");
123
+ expect(promptResponse?.success).toBe(true);
124
+
125
+ const entries = readRecorderLog(logPath);
126
+ expect(entries).toHaveLength(1);
127
+ expect(entries[0].argv).toEqual([targetPath]);
128
+ expect(entries[0].cwd).toBe(workDir);
129
+ }, 20_000);
130
+ });
131
+
132
+ if (!piAvailable) {
133
+ describe.skip("acceptance: autoformat:touched event bus", () => {
134
+ it("skipped because node_modules/.bin/pi is not present", () => {
135
+ // Intentionally empty.
136
+ });
137
+ });
138
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Acceptance test: verify the extension actually loads under the real
3
+ * `pi` CLI without errors.
4
+ *
5
+ * This is a smoke test, not a full end-to-end suite. It catches regressions
6
+ * that pure unit tests cannot:
7
+ *
8
+ * - the extension entrypoint exports the right shape for Pi's loader
9
+ * - module resolution works in the shipped TypeScript
10
+ * - `session_start` does not throw against a real ExtensionContext
11
+ *
12
+ * It deliberately uses Pi's `--mode rpc` with `get_state`, which avoids any
13
+ * LLM call (so no API keys, no cost, no flakiness).
14
+ *
15
+ * Skipped when `node_modules/.bin/pi` is missing (i.e. `pnpm install`
16
+ * has not been run). See `test/helpers/rpc.ts`.
17
+ */
18
+
19
+ import { mkdtempSync, rmSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
24
+
25
+ import { piAvailable, runRpcSession } from "./helpers/rpc.js";
26
+
27
+ const describeIfPi = piAvailable ? describe : describe.skip;
28
+
29
+ describeIfPi("acceptance: extension loads under real pi CLI", () => {
30
+ let workDir: string;
31
+
32
+ beforeAll(() => {
33
+ workDir = mkdtempSync(join(tmpdir(), "pi-autoformat-acceptance-"));
34
+ });
35
+
36
+ afterAll(() => {
37
+ if (workDir) {
38
+ rmSync(workDir, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+ it("loads the extension and answers an rpc get_state command", async () => {
43
+ const { responses, stderr, exitCode } = await runRpcSession({
44
+ cwd: workDir,
45
+ commands: [{ id: "1", type: "get_state" }],
46
+ });
47
+
48
+ // Pi must exit cleanly after stdin closes.
49
+ expect(exitCode).toBe(0);
50
+
51
+ // No "Extension load error" or stack trace from our entrypoint should
52
+ // appear on stderr. We allow Pi's own informational lines to pass.
53
+ expect(stderr).not.toMatch(/pi-autoformat/i);
54
+ expect(stderr).not.toMatch(/Extension .* error/i);
55
+
56
+ const stateResponse = responses.find((r) => r.id === "1");
57
+ expect(stateResponse).toBeDefined();
58
+ expect(stateResponse?.success).toBe(true);
59
+ expect(stateResponse?.command).toBe("get_state");
60
+ });
61
+ });
62
+
63
+ if (!piAvailable) {
64
+ describe.skip("acceptance suite", () => {
65
+ it("skipped because node_modules/.bin/pi is not present", () => {
66
+ // Intentionally empty.
67
+ });
68
+ });
69
+ }
@@ -0,0 +1,382 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
6
+
7
+ import {
8
+ BUILTIN_FORMATTERS,
9
+ type DiscoveryCache,
10
+ isBuiltinFormatterName,
11
+ } from "../src/builtin-formatters.js";
12
+ import {
13
+ type FormatterConfig,
14
+ resolveChainSteps,
15
+ } from "../src/formatter-registry.js";
16
+
17
+ describe("builtin formatter registry", () => {
18
+ it("registers treefmt and treefmt-nix as built-ins", () => {
19
+ expect(BUILTIN_FORMATTERS.treefmt).toBeDefined();
20
+ expect(BUILTIN_FORMATTERS["treefmt-nix"]).toBeDefined();
21
+ expect(BUILTIN_FORMATTERS.treefmt.name).toBe("treefmt");
22
+ expect(BUILTIN_FORMATTERS["treefmt-nix"].name).toBe("treefmt-nix");
23
+ });
24
+
25
+ it("recognizes the canonical built-in names", () => {
26
+ expect(isBuiltinFormatterName("treefmt")).toBe(true);
27
+ expect(isBuiltinFormatterName("treefmt-nix")).toBe(true);
28
+ expect(isBuiltinFormatterName("prettier")).toBe(false);
29
+ expect(isBuiltinFormatterName("nope")).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe("resolveChainSteps with built-ins", () => {
34
+ it("resolves treefmt without a formatters entry", () => {
35
+ const config: FormatterConfig = { formatters: {}, chains: {} };
36
+ const resolved = resolveChainSteps(["treefmt"], config);
37
+ expect(resolved).toHaveLength(1);
38
+ if (resolved[0]?.kind === "single") {
39
+ expect(resolved[0].formatter.name).toBe("treefmt");
40
+ expect(resolved[0].formatter.builtin).toBeDefined();
41
+ expect(resolved[0].formatter.builtin?.name).toBe("treefmt");
42
+ } else {
43
+ throw new Error("expected single step");
44
+ }
45
+ });
46
+
47
+ it("resolves treefmt-nix without a formatters entry", () => {
48
+ const config: FormatterConfig = { formatters: {}, chains: {} };
49
+ const resolved = resolveChainSteps(["treefmt-nix"], config);
50
+ expect(resolved).toHaveLength(1);
51
+ if (resolved[0]?.kind === "single") {
52
+ expect(resolved[0].formatter.builtin?.name).toBe("treefmt-nix");
53
+ }
54
+ });
55
+
56
+ it("resolves built-ins inside a fallback group", () => {
57
+ const config: FormatterConfig = { formatters: {}, chains: {} };
58
+ const resolved = resolveChainSteps(
59
+ [{ fallback: ["treefmt-nix", "treefmt"] }],
60
+ config,
61
+ );
62
+ expect(resolved).toHaveLength(1);
63
+ if (resolved[0]?.kind === "fallback") {
64
+ expect(resolved[0].alternatives.map((a) => a.name)).toEqual([
65
+ "treefmt-nix",
66
+ "treefmt",
67
+ ]);
68
+ expect(resolved[0].alternatives.every((a) => a.builtin)).toBe(true);
69
+ }
70
+ });
71
+
72
+ it("prefers a user-declared formatter over the built-in (shadow allowed)", () => {
73
+ const config: FormatterConfig = {
74
+ formatters: {
75
+ treefmt: { command: ["treefmt", "--ci"] },
76
+ },
77
+ chains: {},
78
+ };
79
+ const resolved = resolveChainSteps(["treefmt"], config);
80
+ expect(resolved).toHaveLength(1);
81
+ if (resolved[0]?.kind === "single") {
82
+ expect(resolved[0].formatter.command).toEqual(["treefmt", "--ci"]);
83
+ expect(resolved[0].formatter.builtin).toBeUndefined();
84
+ }
85
+ });
86
+ });
87
+
88
+ describe("treefmt discovery", () => {
89
+ let tmp: string;
90
+ let repoRoot: string;
91
+ let nestedFile: string;
92
+ let outsideFile: string;
93
+
94
+ beforeAll(() => {
95
+ tmp = mkdtempSync(path.join(tmpdir(), "pi-autofmt-discovery-"));
96
+ repoRoot = path.join(tmp, "repo");
97
+ mkdirSync(path.join(repoRoot, "src", "a"), { recursive: true });
98
+ writeFileSync(path.join(repoRoot, "treefmt.toml"), "");
99
+ nestedFile = path.join(repoRoot, "src", "a", "x.ts");
100
+ writeFileSync(nestedFile, "");
101
+
102
+ const outsideRoot = path.join(tmp, "elsewhere");
103
+ mkdirSync(outsideRoot, { recursive: true });
104
+ outsideFile = path.join(outsideRoot, "y.ts");
105
+ writeFileSync(outsideFile, "");
106
+ });
107
+
108
+ afterAll(() => {
109
+ rmSync(tmp, { recursive: true, force: true });
110
+ });
111
+
112
+ it("walks up to find treefmt.toml", async () => {
113
+ const root = await BUILTIN_FORMATTERS.treefmt.discoverRoot([nestedFile]);
114
+ expect(root).toBe(repoRoot);
115
+ });
116
+
117
+ it("finds .treefmt.toml as well", async () => {
118
+ const dotRoot = path.join(tmp, "dot-repo");
119
+ mkdirSync(path.join(dotRoot, "sub"), { recursive: true });
120
+ writeFileSync(path.join(dotRoot, ".treefmt.toml"), "");
121
+ const file = path.join(dotRoot, "sub", "a.ts");
122
+ writeFileSync(file, "");
123
+ const root = await BUILTIN_FORMATTERS.treefmt.discoverRoot([file]);
124
+ expect(root).toBe(dotRoot);
125
+ });
126
+
127
+ it("prefers treefmt.toml when both exist at the same root", async () => {
128
+ const both = path.join(tmp, "both");
129
+ mkdirSync(both, { recursive: true });
130
+ writeFileSync(path.join(both, "treefmt.toml"), "");
131
+ writeFileSync(path.join(both, ".treefmt.toml"), "");
132
+ const file = path.join(both, "a.ts");
133
+ writeFileSync(file, "");
134
+ const root = await BUILTIN_FORMATTERS.treefmt.discoverRoot([file]);
135
+ expect(root).toBe(both);
136
+ });
137
+
138
+ it("returns undefined when no config is found", async () => {
139
+ const root = await BUILTIN_FORMATTERS.treefmt.discoverRoot([outsideFile]);
140
+ expect(root).toBeUndefined();
141
+ });
142
+
143
+ it("reuses the discovery cache for already-walked directories", async () => {
144
+ const cache: DiscoveryCache = new Map();
145
+ const root1 = await BUILTIN_FORMATTERS.treefmt.discoverRoot([nestedFile], {
146
+ cache,
147
+ });
148
+ expect(root1).toBe(repoRoot);
149
+ expect(cache.size).toBeGreaterThan(0);
150
+ // Mutate one of the cached entries to a sentinel so we can prove the
151
+ // second call uses the cache rather than re-walking.
152
+ const dir = path.dirname(nestedFile);
153
+ cache.set(dir, "/sentinel");
154
+ const root2 = await BUILTIN_FORMATTERS.treefmt.discoverRoot([nestedFile], {
155
+ cache,
156
+ });
157
+ expect(root2).toBe("/sentinel");
158
+ });
159
+ });
160
+
161
+ describe("treefmt command builder", () => {
162
+ it("invokes treefmt with --config-file at the discovered root", () => {
163
+ const root = "/repo";
164
+ const built = BUILTIN_FORMATTERS.treefmt.buildCommand(root, [
165
+ "/repo/a.ts",
166
+ "/repo/b.md",
167
+ ]);
168
+ expect(built.cwd).toBe(root);
169
+ expect(built.command).toEqual([
170
+ "treefmt",
171
+ "--config-file",
172
+ path.join(root, "treefmt.toml"),
173
+ "--",
174
+ "/repo/a.ts",
175
+ "/repo/b.md",
176
+ ]);
177
+ });
178
+
179
+ it("prefers treefmt.toml over .treefmt.toml when both exist", () => {
180
+ const dir = mkdtempSync(path.join(tmpdir(), "pi-autofmt-tfb-"));
181
+ writeFileSync(path.join(dir, "treefmt.toml"), "");
182
+ writeFileSync(path.join(dir, ".treefmt.toml"), "");
183
+ try {
184
+ const built = BUILTIN_FORMATTERS.treefmt.buildCommand(dir, [
185
+ path.join(dir, "a.ts"),
186
+ ]);
187
+ expect(built.command[2]).toBe(path.join(dir, "treefmt.toml"));
188
+ } finally {
189
+ rmSync(dir, { recursive: true, force: true });
190
+ }
191
+ });
192
+
193
+ it("falls back to .treefmt.toml when only the dotfile exists", () => {
194
+ const dir = mkdtempSync(path.join(tmpdir(), "pi-autofmt-tfd-"));
195
+ writeFileSync(path.join(dir, ".treefmt.toml"), "");
196
+ try {
197
+ const built = BUILTIN_FORMATTERS.treefmt.buildCommand(dir, [
198
+ path.join(dir, "a.ts"),
199
+ ]);
200
+ expect(built.command[2]).toBe(path.join(dir, ".treefmt.toml"));
201
+ } finally {
202
+ rmSync(dir, { recursive: true, force: true });
203
+ }
204
+ });
205
+ });
206
+
207
+ describe("treefmt-nix command builder", () => {
208
+ it("invokes nix fmt with no-update/no-write flags from the flake root", () => {
209
+ const root = "/repo";
210
+ const built = BUILTIN_FORMATTERS["treefmt-nix"].buildCommand(root, [
211
+ "/repo/a.ts",
212
+ ]);
213
+ expect(built.cwd).toBe(root);
214
+ expect(built.command).toEqual([
215
+ "nix",
216
+ "fmt",
217
+ "--no-update-lock-file",
218
+ "--no-write-lock-file",
219
+ "--",
220
+ "/repo/a.ts",
221
+ ]);
222
+ });
223
+ });
224
+
225
+ describe("treefmt partitionUnhandled", () => {
226
+ const builtin = BUILTIN_FORMATTERS.treefmt;
227
+
228
+ it("marks files reported with 'no formatter for path' as unhandled", () => {
229
+ const files = ["/repo/a.ts", "/repo/b.unknown", "/repo/c.md"];
230
+ const stderr = [
231
+ "treefmt: ran 3 formatters",
232
+ "WARN no formatter for path: /repo/b.unknown",
233
+ ].join("\n");
234
+ const part = builtin.partitionUnhandled(
235
+ {
236
+ formatterName: "treefmt",
237
+ command: ["treefmt"],
238
+ files,
239
+ success: true,
240
+ exitCode: 0,
241
+ stderr,
242
+ },
243
+ files,
244
+ );
245
+ expect(part.unhandled).toEqual(["/repo/b.unknown"]);
246
+ expect(part.handled).toEqual(["/repo/a.ts", "/repo/c.md"]);
247
+ expect(part.treatAsSkip).toBe(false);
248
+ });
249
+
250
+ it("treats exit-0 with all files unhandled as skip", () => {
251
+ const files = ["/repo/a.bin"];
252
+ const stderr = "WARN no formatter for path: /repo/a.bin";
253
+ const part = builtin.partitionUnhandled(
254
+ {
255
+ formatterName: "treefmt",
256
+ command: ["treefmt"],
257
+ files,
258
+ success: true,
259
+ exitCode: 0,
260
+ stderr,
261
+ },
262
+ files,
263
+ );
264
+ expect(part.treatAsSkip).toBe(true);
265
+ });
266
+
267
+ it("reports a non-zero exit with no skip patterns as a real failure", () => {
268
+ const files = ["/repo/a.ts"];
269
+ const part = builtin.partitionUnhandled(
270
+ {
271
+ formatterName: "treefmt",
272
+ command: ["treefmt"],
273
+ files,
274
+ success: false,
275
+ exitCode: 2,
276
+ stderr: "syntax error in /repo/a.ts",
277
+ },
278
+ files,
279
+ );
280
+ expect(part.treatAsSkip).toBe(false);
281
+ expect(part.handled).toEqual(files);
282
+ expect(part.unhandled).toEqual([]);
283
+ });
284
+ });
285
+
286
+ describe("treefmt-nix partitionUnhandled", () => {
287
+ const builtin = BUILTIN_FORMATTERS["treefmt-nix"];
288
+
289
+ it("treats 'emitted 0 files for processing' as skip", () => {
290
+ const files = ["/repo/a.ts", "/repo/b.bin"];
291
+ const part = builtin.partitionUnhandled(
292
+ {
293
+ formatterName: "treefmt-nix",
294
+ command: ["nix", "fmt"],
295
+ files,
296
+ success: true,
297
+ exitCode: 0,
298
+ stderr: "emitted 0 files for processing",
299
+ },
300
+ files,
301
+ );
302
+ expect(part.treatAsSkip).toBe(true);
303
+ });
304
+
305
+ it("treats transient nix daemon errors as skip", () => {
306
+ const files = ["/repo/a.ts"];
307
+ const part = builtin.partitionUnhandled(
308
+ {
309
+ formatterName: "treefmt-nix",
310
+ command: ["nix", "fmt"],
311
+ files,
312
+ success: false,
313
+ exitCode: 1,
314
+ stderr:
315
+ "error: cannot connect to socket at /nix/var/nix/daemon-socket/socket",
316
+ },
317
+ files,
318
+ );
319
+ expect(part.treatAsSkip).toBe(true);
320
+ });
321
+
322
+ it("treats unrelated non-zero exits as real failures", () => {
323
+ const files = ["/repo/a.ts"];
324
+ const part = builtin.partitionUnhandled(
325
+ {
326
+ formatterName: "treefmt-nix",
327
+ command: ["nix", "fmt"],
328
+ files,
329
+ success: false,
330
+ exitCode: 2,
331
+ stderr: "some formatter blew up",
332
+ },
333
+ files,
334
+ );
335
+ expect(part.treatAsSkip).toBe(false);
336
+ });
337
+ });
338
+
339
+ describe("treefmt-nix discovery", () => {
340
+ let tmp: string;
341
+
342
+ beforeAll(() => {
343
+ tmp = mkdtempSync(path.join(tmpdir(), "pi-autofmt-nix-"));
344
+ });
345
+
346
+ afterAll(() => {
347
+ rmSync(tmp, { recursive: true, force: true });
348
+ });
349
+
350
+ it("finds flake.nix + treefmt.nix at the root", async () => {
351
+ const root = path.join(tmp, "flake-a");
352
+ mkdirSync(path.join(root, "src"), { recursive: true });
353
+ writeFileSync(path.join(root, "flake.nix"), "");
354
+ writeFileSync(path.join(root, "treefmt.nix"), "");
355
+ const file = path.join(root, "src", "a.ts");
356
+ writeFileSync(file, "");
357
+ const found = await BUILTIN_FORMATTERS["treefmt-nix"].discoverRoot([file]);
358
+ expect(found).toBe(root);
359
+ });
360
+
361
+ it("finds flake.nix + nix/treefmt.nix at the root", async () => {
362
+ const root = path.join(tmp, "flake-b");
363
+ mkdirSync(path.join(root, "nix"), { recursive: true });
364
+ mkdirSync(path.join(root, "src"), { recursive: true });
365
+ writeFileSync(path.join(root, "flake.nix"), "");
366
+ writeFileSync(path.join(root, "nix", "treefmt.nix"), "");
367
+ const file = path.join(root, "src", "a.ts");
368
+ writeFileSync(file, "");
369
+ const found = await BUILTIN_FORMATTERS["treefmt-nix"].discoverRoot([file]);
370
+ expect(found).toBe(root);
371
+ });
372
+
373
+ it("requires both flake.nix and a treefmt.nix to match", async () => {
374
+ const root = path.join(tmp, "flake-only");
375
+ mkdirSync(root, { recursive: true });
376
+ writeFileSync(path.join(root, "flake.nix"), "");
377
+ const file = path.join(root, "a.ts");
378
+ writeFileSync(file, "");
379
+ const found = await BUILTIN_FORMATTERS["treefmt-nix"].discoverRoot([file]);
380
+ expect(found).toBeUndefined();
381
+ });
382
+ });
@@ -0,0 +1,79 @@
1
+ import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import {
8
+ createCachedCommandProbe,
9
+ defaultCommandProbe,
10
+ } from "../src/command-probe.js";
11
+
12
+ const previousPath = process.env.PATH;
13
+
14
+ afterEach(() => {
15
+ process.env.PATH = previousPath;
16
+ });
17
+
18
+ describe("defaultCommandProbe", () => {
19
+ it("finds an executable on PATH", () => {
20
+ const root = mkdtempSync(join(tmpdir(), "pi-autoformat-probe-"));
21
+ const bin = join(root, "bin");
22
+ mkdirSync(bin, { recursive: true });
23
+ const exe = join(bin, "fakefmt");
24
+ writeFileSync(exe, "#!/bin/sh\nexit 0\n");
25
+ chmodSync(exe, 0o755);
26
+
27
+ process.env.PATH = bin;
28
+ expect(defaultCommandProbe("fakefmt")).toBe(true);
29
+ });
30
+
31
+ it("returns false for a command not on PATH", () => {
32
+ const root = mkdtempSync(join(tmpdir(), "pi-autoformat-probe-"));
33
+ const bin = join(root, "bin");
34
+ mkdirSync(bin, { recursive: true });
35
+ process.env.PATH = bin;
36
+ expect(defaultCommandProbe("definitely-not-installed-xyz")).toBe(false);
37
+ });
38
+
39
+ it("accepts an absolute path that points at an executable", () => {
40
+ const root = mkdtempSync(join(tmpdir(), "pi-autoformat-probe-"));
41
+ const exe = join(root, "fakefmt");
42
+ writeFileSync(exe, "#!/bin/sh\nexit 0\n");
43
+ chmodSync(exe, 0o755);
44
+
45
+ expect(defaultCommandProbe(exe)).toBe(true);
46
+ });
47
+
48
+ it("returns false for an absolute path that does not exist", () => {
49
+ expect(defaultCommandProbe("/no/such/binary/please")).toBe(false);
50
+ });
51
+
52
+ it("returns false for a non-executable file on PATH", () => {
53
+ const root = mkdtempSync(join(tmpdir(), "pi-autoformat-probe-"));
54
+ const bin = join(root, "bin");
55
+ mkdirSync(bin, { recursive: true });
56
+ const file = join(bin, "notexec");
57
+ writeFileSync(file, "");
58
+ chmodSync(file, 0o644);
59
+ process.env.PATH = bin;
60
+
61
+ expect(defaultCommandProbe("notexec")).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe("createCachedCommandProbe", () => {
66
+ it("caches per-command results across calls", () => {
67
+ let calls = 0;
68
+ const cached = createCachedCommandProbe((command) => {
69
+ calls += 1;
70
+ return command === "yes";
71
+ });
72
+
73
+ expect(cached("yes")).toBe(true);
74
+ expect(cached("yes")).toBe(true);
75
+ expect(cached("no")).toBe(false);
76
+ expect(cached("no")).toBe(false);
77
+ expect(calls).toBe(2);
78
+ });
79
+ });