@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
@@ -1,82 +1,602 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
+ import type { BuiltinFormatter } from "../src/builtin-formatters.js";
3
4
  import {
4
5
  type CommandRunner,
5
- executeFormatterChain,
6
+ executeChainGroup,
7
+ executeChainGroupWithPartition,
6
8
  } from "../src/formatter-executor.js";
7
- import type { ResolvedFormatter } from "../src/formatter-registry.js";
9
+ import type {
10
+ ResolvedChainStep,
11
+ ResolvedFormatter,
12
+ } from "../src/formatter-registry.js";
8
13
 
9
- describe("executeFormatterChain", () => {
10
- const chain: ResolvedFormatter[] = [
11
- {
12
- name: "prettier",
13
- command: ["prettier", "--write", "/repo/docs/readme.md"],
14
- environment: {
15
- PRETTIERD_DEFAULT_CONFIG: "./.prettierrc",
16
- },
17
- },
18
- {
19
- name: "markdownlint",
20
- command: ["markdownlint-cli2", "--fix", "/repo/docs/readme.md"],
21
- },
22
- ];
14
+ const prettier: ResolvedFormatter = {
15
+ name: "prettier",
16
+ command: ["prettier", "--write"],
17
+ environment: { PRETTIERD_DEFAULT_CONFIG: "./.prettierrc" },
18
+ };
23
19
 
24
- it("executes formatters in order", async () => {
25
- const calls: string[] = [];
20
+ const markdownlint: ResolvedFormatter = {
21
+ name: "markdownlint",
22
+ command: ["markdownlint-cli2", "--fix"],
23
+ };
24
+
25
+ const biome: ResolvedFormatter = {
26
+ name: "biome",
27
+ command: ["biome", "format", "--write"],
28
+ };
29
+
30
+ function singleStep(formatter: ResolvedFormatter): ResolvedChainStep {
31
+ return { kind: "single", formatter };
32
+ }
33
+
34
+ function fallbackStep(alternatives: ResolvedFormatter[]): ResolvedChainStep {
35
+ return { kind: "fallback", alternatives };
36
+ }
37
+
38
+ const chain: ResolvedChainStep[] = [
39
+ singleStep(prettier),
40
+ singleStep(markdownlint),
41
+ ];
42
+
43
+ describe("executeChainGroup (single steps)", () => {
44
+ it("runs each step once with all files appended as trailing args", async () => {
45
+ const calls: Array<{ command: string; args: string[] }> = [];
26
46
  const runner: CommandRunner = async (command, args) => {
27
- calls.push([command, ...args].join(" "));
47
+ calls.push({ command, args });
28
48
  return { exitCode: 0 };
29
49
  };
30
50
 
31
- const result = await executeFormatterChain(chain, runner);
51
+ const runs = await executeChainGroup(
52
+ { chain, files: ["/repo/a.md", "/repo/b.md"] },
53
+ runner,
54
+ );
32
55
 
33
56
  expect(calls).toEqual([
34
- "prettier --write /repo/docs/readme.md",
35
- "markdownlint-cli2 --fix /repo/docs/readme.md",
57
+ {
58
+ command: "prettier",
59
+ args: ["--write", "/repo/a.md", "/repo/b.md"],
60
+ },
61
+ {
62
+ command: "markdownlint-cli2",
63
+ args: ["--fix", "/repo/a.md", "/repo/b.md"],
64
+ },
65
+ ]);
66
+ expect(runs).toEqual([
67
+ {
68
+ formatterName: "prettier",
69
+ command: ["prettier", "--write", "/repo/a.md", "/repo/b.md"],
70
+ files: ["/repo/a.md", "/repo/b.md"],
71
+ success: true,
72
+ exitCode: 0,
73
+ stdout: undefined,
74
+ stderr: undefined,
75
+ },
76
+ {
77
+ formatterName: "markdownlint",
78
+ command: ["markdownlint-cli2", "--fix", "/repo/a.md", "/repo/b.md"],
79
+ files: ["/repo/a.md", "/repo/b.md"],
80
+ success: true,
81
+ exitCode: 0,
82
+ stdout: undefined,
83
+ stderr: undefined,
84
+ },
36
85
  ]);
37
- expect(result.every((entry) => entry.success)).toBe(true);
38
86
  });
39
87
 
40
- it("continues running remaining formatters after a failure", async () => {
88
+ it("works with a single-file batch", async () => {
89
+ const calls: string[][] = [];
90
+ const runner: CommandRunner = async (command, args) => {
91
+ calls.push([command, ...args]);
92
+ return { exitCode: 0 };
93
+ };
94
+
95
+ await executeChainGroup(
96
+ { chain: [singleStep(prettier)], files: ["/repo/only.md"] },
97
+ runner,
98
+ );
99
+
100
+ expect(calls).toEqual([["prettier", "--write", "/repo/only.md"]]);
101
+ });
102
+
103
+ it("continues running remaining steps after a step fails", async () => {
41
104
  const calls: string[] = [];
42
- const runner: CommandRunner = async (command, _args) => {
105
+ const runner: CommandRunner = async (command) => {
43
106
  calls.push(command);
44
107
  if (command === "prettier") {
45
- return {
46
- exitCode: 2,
47
- stderr: "syntax error",
48
- };
108
+ return { exitCode: 2, stderr: "boom" };
49
109
  }
50
-
51
110
  return { exitCode: 0 };
52
111
  };
53
112
 
54
- const result = await executeFormatterChain(chain, runner);
113
+ const runs = await executeChainGroup(
114
+ { chain, files: ["/repo/a.md"] },
115
+ runner,
116
+ );
55
117
 
56
118
  expect(calls).toEqual(["prettier", "markdownlint-cli2"]);
57
- expect(result[0]).toMatchObject({
119
+ expect(runs[0]).toMatchObject({
58
120
  formatterName: "prettier",
59
121
  success: false,
60
122
  exitCode: 2,
123
+ stderr: "boom",
124
+ files: ["/repo/a.md"],
61
125
  });
62
- expect(result[1]).toMatchObject({
126
+ expect(runs[1]).toMatchObject({
63
127
  formatterName: "markdownlint",
64
128
  success: true,
65
129
  exitCode: 0,
66
130
  });
67
131
  });
68
132
 
69
- it("passes formatter environment overrides to the runner", async () => {
133
+ it("propagates formatter environment overrides", async () => {
70
134
  let capturedEnv: Record<string, string> | undefined;
71
135
  const runner: CommandRunner = async (_command, _args, options) => {
72
136
  capturedEnv = options?.env;
73
137
  return { exitCode: 0 };
74
138
  };
75
139
 
76
- await executeFormatterChain([chain[0]], runner);
140
+ await executeChainGroup(
141
+ { chain: [singleStep(prettier)], files: ["/repo/a.md"] },
142
+ runner,
143
+ );
77
144
 
78
145
  expect(capturedEnv).toMatchObject({
79
146
  PRETTIERD_DEFAULT_CONFIG: "./.prettierrc",
80
147
  });
81
148
  });
149
+
150
+ it("forwards cwd to the runner", async () => {
151
+ let capturedCwd: string | undefined;
152
+ const runner: CommandRunner = async (_command, _args, options) => {
153
+ capturedCwd = options?.cwd;
154
+ return { exitCode: 0 };
155
+ };
156
+
157
+ await executeChainGroup(
158
+ { chain: [singleStep(prettier)], files: ["/repo/a.md"] },
159
+ runner,
160
+ { cwd: "/repo" },
161
+ );
162
+
163
+ expect(capturedCwd).toBe("/repo");
164
+ });
165
+
166
+ it("marks a step as failed with exit 1 when its command is empty", async () => {
167
+ const runner: CommandRunner = async () => {
168
+ throw new Error("should not be called");
169
+ };
170
+
171
+ const runs = await executeChainGroup(
172
+ {
173
+ chain: [singleStep({ name: "broken", command: [] })],
174
+ files: ["/repo/a.md"],
175
+ },
176
+ runner,
177
+ );
178
+
179
+ expect(runs[0]).toMatchObject({
180
+ formatterName: "broken",
181
+ success: false,
182
+ exitCode: 1,
183
+ files: ["/repo/a.md"],
184
+ });
185
+ expect(runs[0].stderr).toMatch(/empty/i);
186
+ });
187
+
188
+ it("returns no runs when files is empty", async () => {
189
+ const runner: CommandRunner = async () => {
190
+ throw new Error("should not be called");
191
+ };
192
+
193
+ const runs = await executeChainGroup({ chain, files: [] }, runner);
194
+
195
+ expect(runs).toEqual([]);
196
+ });
197
+ });
198
+
199
+ describe("executeChainGroup (fallback steps)", () => {
200
+ it("runs the first alternative when its command is on PATH and emits no fallbackContext", async () => {
201
+ const calls: string[] = [];
202
+ const runner: CommandRunner = async (command) => {
203
+ calls.push(command);
204
+ return { exitCode: 0 };
205
+ };
206
+
207
+ const runs = await executeChainGroup(
208
+ {
209
+ chain: [fallbackStep([biome, prettier])],
210
+ files: ["/repo/a.ts"],
211
+ },
212
+ runner,
213
+ { commandProbe: (cmd) => cmd === "biome" },
214
+ );
215
+
216
+ expect(calls).toEqual(["biome"]);
217
+ expect(runs).toHaveLength(1);
218
+ expect(runs[0]).toMatchObject({
219
+ formatterName: "biome",
220
+ success: true,
221
+ exitCode: 0,
222
+ });
223
+ expect(runs[0].fallbackContext).toBeUndefined();
224
+ });
225
+
226
+ it("falls through when an alternative is missing and reports skipped names", async () => {
227
+ const calls: string[] = [];
228
+ const runner: CommandRunner = async (command) => {
229
+ calls.push(command);
230
+ return { exitCode: 0 };
231
+ };
232
+
233
+ const runs = await executeChainGroup(
234
+ {
235
+ chain: [fallbackStep([biome, prettier])],
236
+ files: ["/repo/a.ts"],
237
+ },
238
+ runner,
239
+ { commandProbe: (cmd) => cmd === "prettier" },
240
+ );
241
+
242
+ expect(calls).toEqual(["prettier"]);
243
+ expect(runs).toHaveLength(1);
244
+ expect(runs[0]).toMatchObject({
245
+ formatterName: "prettier",
246
+ success: true,
247
+ exitCode: 0,
248
+ });
249
+ expect(runs[0].fallbackContext).toEqual({ skipped: ["biome"] });
250
+ });
251
+
252
+ it("does NOT fall through on a non-zero exit code", async () => {
253
+ const calls: string[] = [];
254
+ const runner: CommandRunner = async (command) => {
255
+ calls.push(command);
256
+ return { exitCode: 1, stderr: "syntax error" };
257
+ };
258
+
259
+ const runs = await executeChainGroup(
260
+ {
261
+ chain: [fallbackStep([biome, prettier])],
262
+ files: ["/repo/a.ts"],
263
+ },
264
+ runner,
265
+ { commandProbe: () => true },
266
+ );
267
+
268
+ expect(calls).toEqual(["biome"]);
269
+ expect(runs).toHaveLength(1);
270
+ expect(runs[0]).toMatchObject({
271
+ formatterName: "biome",
272
+ success: false,
273
+ exitCode: 1,
274
+ stderr: "syntax error",
275
+ });
276
+ });
277
+
278
+ it("emits no run when all alternatives are missing from PATH", async () => {
279
+ const calls: string[] = [];
280
+ const runner: CommandRunner = async (command) => {
281
+ calls.push(command);
282
+ return { exitCode: 0 };
283
+ };
284
+
285
+ const runs = await executeChainGroup(
286
+ {
287
+ chain: [fallbackStep([biome, prettier])],
288
+ files: ["/repo/a.ts"],
289
+ },
290
+ runner,
291
+ { commandProbe: () => false },
292
+ );
293
+
294
+ expect(calls).toEqual([]);
295
+ expect(runs).toEqual([]);
296
+ });
297
+
298
+ it("runs subsequent single steps even when the fallback group is a no-op", async () => {
299
+ const calls: string[] = [];
300
+ const runner: CommandRunner = async (command) => {
301
+ calls.push(command);
302
+ return { exitCode: 0 };
303
+ };
304
+
305
+ const runs = await executeChainGroup(
306
+ {
307
+ chain: [fallbackStep([biome]), singleStep(markdownlint)],
308
+ files: ["/repo/a.md"],
309
+ },
310
+ runner,
311
+ { commandProbe: (cmd) => cmd !== "biome" },
312
+ );
313
+
314
+ expect(calls).toEqual(["markdownlint-cli2"]);
315
+ expect(runs).toHaveLength(1);
316
+ expect(runs[0]?.formatterName).toBe("markdownlint");
317
+ });
318
+ });
319
+
320
+ describe("executeChainGroupWithPartition (built-in steps)", () => {
321
+ const fakeBuiltin: BuiltinFormatter = {
322
+ name: "treefmt",
323
+ async discoverRoot() {
324
+ return "/repo";
325
+ },
326
+ buildCommand(root, files) {
327
+ return {
328
+ command: [
329
+ "treefmt",
330
+ "--config-file",
331
+ `${root}/treefmt.toml`,
332
+ "--",
333
+ ...files,
334
+ ],
335
+ cwd: root,
336
+ };
337
+ },
338
+ partitionUnhandled(_run, files) {
339
+ // Mark every file ending in .bin as unhandled.
340
+ const unhandled = files.filter((f) => f.endsWith(".bin"));
341
+ const handled = files.filter((f) => !f.endsWith(".bin"));
342
+ return { handled, unhandled, treatAsSkip: false };
343
+ },
344
+ };
345
+
346
+ const builtinFormatter: ResolvedFormatter = {
347
+ name: "treefmt",
348
+ command: ["treefmt"],
349
+ builtin: fakeBuiltin,
350
+ };
351
+
352
+ it("invokes the discovered command and returns unhandled files", async () => {
353
+ const calls: Array<{ command: string; args: string[]; cwd?: string }> = [];
354
+ const runner: CommandRunner = async (command, args, options) => {
355
+ calls.push({ command, args, cwd: options?.cwd });
356
+ return { exitCode: 0, stderr: "no formatter for path: /repo/b.bin" };
357
+ };
358
+
359
+ const result = await executeChainGroupWithPartition(
360
+ {
361
+ chain: [{ kind: "single", formatter: builtinFormatter }],
362
+ files: ["/repo/a.ts", "/repo/b.bin"],
363
+ },
364
+ runner,
365
+ );
366
+
367
+ expect(calls).toEqual([
368
+ {
369
+ command: "treefmt",
370
+ args: [
371
+ "--config-file",
372
+ "/repo/treefmt.toml",
373
+ "--",
374
+ "/repo/a.ts",
375
+ "/repo/b.bin",
376
+ ],
377
+ cwd: "/repo",
378
+ },
379
+ ]);
380
+ expect(result.runs).toHaveLength(1);
381
+ expect(result.runs[0]?.formatterName).toBe("treefmt");
382
+ expect(result.unhandled).toEqual(["/repo/b.bin"]);
383
+ });
384
+
385
+ it("drops the run and treats every file as unhandled when treatAsSkip is true", async () => {
386
+ const skipBuiltin: BuiltinFormatter = {
387
+ ...fakeBuiltin,
388
+ partitionUnhandled(_run, files) {
389
+ return { handled: [], unhandled: [...files], treatAsSkip: true };
390
+ },
391
+ };
392
+ const formatter: ResolvedFormatter = {
393
+ name: "treefmt",
394
+ command: ["treefmt"],
395
+ builtin: skipBuiltin,
396
+ };
397
+ const runner: CommandRunner = async () => ({ exitCode: 0 });
398
+
399
+ const result = await executeChainGroupWithPartition(
400
+ {
401
+ chain: [{ kind: "single", formatter }],
402
+ files: ["/repo/a.ts"],
403
+ },
404
+ runner,
405
+ );
406
+ expect(result.runs).toEqual([]);
407
+ expect(result.unhandled).toEqual(["/repo/a.ts"]);
408
+ });
409
+
410
+ it("records non-skip non-zero exits as failed runs", async () => {
411
+ const runner: CommandRunner = async () => ({
412
+ exitCode: 2,
413
+ stderr: "real failure",
414
+ });
415
+
416
+ const result = await executeChainGroupWithPartition(
417
+ {
418
+ chain: [{ kind: "single", formatter: builtinFormatter }],
419
+ files: ["/repo/a.ts"],
420
+ },
421
+ runner,
422
+ );
423
+ expect(result.runs).toHaveLength(1);
424
+ expect(result.runs[0]).toMatchObject({
425
+ success: false,
426
+ exitCode: 2,
427
+ stderr: "real failure",
428
+ });
429
+ });
430
+
431
+ it("threads unhandled files into subsequent steps within the same chain", async () => {
432
+ const calls: Array<{ command: string; args: string[] }> = [];
433
+ const runner: CommandRunner = async (command, args) => {
434
+ calls.push({ command, args });
435
+ if (command === "treefmt") {
436
+ return { exitCode: 0, stderr: "no formatter for path: /repo/b.bin" };
437
+ }
438
+ return { exitCode: 0 };
439
+ };
440
+
441
+ await executeChainGroupWithPartition(
442
+ {
443
+ chain: [
444
+ { kind: "single", formatter: builtinFormatter },
445
+ { kind: "single", formatter: prettier },
446
+ ],
447
+ files: ["/repo/a.ts", "/repo/b.bin"],
448
+ },
449
+ runner,
450
+ );
451
+
452
+ // prettier should only see /repo/b.bin (the unhandled remainder).
453
+ expect(calls[1]).toEqual({
454
+ command: "prettier",
455
+ args: ["--write", "/repo/b.bin"],
456
+ });
457
+ });
458
+
459
+ it("prefers treefmt-nix over treefmt at the same root inside a fallback group regardless of declaration order", async () => {
460
+ const sharedRoot = "/repo";
461
+ const treefmtNix: ResolvedFormatter = {
462
+ name: "treefmt-nix",
463
+ command: ["treefmt-nix"],
464
+ builtin: {
465
+ name: "treefmt-nix",
466
+ async discoverRoot() {
467
+ return sharedRoot;
468
+ },
469
+ buildCommand(root, files) {
470
+ return { command: ["nix", "fmt", "--", ...files], cwd: root };
471
+ },
472
+ partitionUnhandled(_run, files) {
473
+ return { handled: [...files], unhandled: [], treatAsSkip: false };
474
+ },
475
+ },
476
+ };
477
+ const treefmtBuiltin: ResolvedFormatter = {
478
+ name: "treefmt",
479
+ command: ["treefmt"],
480
+ builtin: {
481
+ name: "treefmt",
482
+ async discoverRoot() {
483
+ return sharedRoot;
484
+ },
485
+ buildCommand(root, files) {
486
+ return { command: ["treefmt", "--", ...files], cwd: root };
487
+ },
488
+ partitionUnhandled(_run, files) {
489
+ return { handled: [...files], unhandled: [], treatAsSkip: false };
490
+ },
491
+ },
492
+ };
493
+ const calls: string[] = [];
494
+ const runner: CommandRunner = async (command) => {
495
+ calls.push(command);
496
+ return { exitCode: 0 };
497
+ };
498
+
499
+ const result = await executeChainGroupWithPartition(
500
+ {
501
+ // User listed treefmt before treefmt-nix; precedence rule should
502
+ // still pick treefmt-nix when both PATH-probe true and resolve to the
503
+ // same root.
504
+ chain: [
505
+ { kind: "fallback", alternatives: [treefmtBuiltin, treefmtNix] },
506
+ ],
507
+ files: ["/repo/a.ts"],
508
+ },
509
+ runner,
510
+ { commandProbe: () => true },
511
+ );
512
+
513
+ expect(calls).toEqual(["nix"]);
514
+ expect(result.runs[0]?.formatterName).toBe("treefmt-nix");
515
+ });
516
+
517
+ it("keeps user order when the two built-ins resolve to different roots", async () => {
518
+ const treefmtNix: ResolvedFormatter = {
519
+ name: "treefmt-nix",
520
+ command: ["treefmt-nix"],
521
+ builtin: {
522
+ name: "treefmt-nix",
523
+ async discoverRoot() {
524
+ return "/other";
525
+ },
526
+ buildCommand(root, files) {
527
+ return { command: ["nix", "fmt", "--", ...files], cwd: root };
528
+ },
529
+ partitionUnhandled(_run, files) {
530
+ return { handled: [...files], unhandled: [], treatAsSkip: false };
531
+ },
532
+ },
533
+ };
534
+ const treefmtBuiltin: ResolvedFormatter = {
535
+ name: "treefmt",
536
+ command: ["treefmt"],
537
+ builtin: {
538
+ name: "treefmt",
539
+ async discoverRoot() {
540
+ return "/repo";
541
+ },
542
+ buildCommand(root, files) {
543
+ return { command: ["treefmt", "--", ...files], cwd: root };
544
+ },
545
+ partitionUnhandled(_run, files) {
546
+ return { handled: [...files], unhandled: [], treatAsSkip: false };
547
+ },
548
+ },
549
+ };
550
+ const calls: string[] = [];
551
+ const runner: CommandRunner = async (command) => {
552
+ calls.push(command);
553
+ return { exitCode: 0 };
554
+ };
555
+
556
+ await executeChainGroupWithPartition(
557
+ {
558
+ chain: [
559
+ { kind: "fallback", alternatives: [treefmtBuiltin, treefmtNix] },
560
+ ],
561
+ files: ["/repo/a.ts"],
562
+ },
563
+ runner,
564
+ { commandProbe: () => true },
565
+ );
566
+
567
+ expect(calls).toEqual(["treefmt"]);
568
+ });
569
+
570
+ it("skips the built-in step when discoverRoot returns undefined", async () => {
571
+ const noRootBuiltin: BuiltinFormatter = {
572
+ ...fakeBuiltin,
573
+ async discoverRoot() {
574
+ return undefined;
575
+ },
576
+ };
577
+ const formatter: ResolvedFormatter = {
578
+ name: "treefmt",
579
+ command: ["treefmt"],
580
+ builtin: noRootBuiltin,
581
+ };
582
+ const calls: string[] = [];
583
+ const runner: CommandRunner = async (command) => {
584
+ calls.push(command);
585
+ return { exitCode: 0 };
586
+ };
587
+
588
+ const result = await executeChainGroupWithPartition(
589
+ {
590
+ chain: [
591
+ { kind: "single", formatter },
592
+ { kind: "single", formatter: prettier },
593
+ ],
594
+ files: ["/repo/a.ts"],
595
+ },
596
+ runner,
597
+ );
598
+
599
+ expect(calls).toEqual(["prettier"]);
600
+ expect(result.unhandled).toEqual(["/repo/a.ts"]);
601
+ });
82
602
  });