@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,178 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { FormatterOutputReportingConfig } from "../src/formatter-config.js";
3
+ import type { BatchRun } from "../src/formatter-executor.js";
4
+ import { formatRunOutputBlock } from "../src/formatter-output-report.js";
5
+
6
+ const ENABLED_STDERR: FormatterOutputReportingConfig = {
7
+ onFailure: "stderr",
8
+ maxBytes: 4096,
9
+ maxLines: 40,
10
+ };
11
+
12
+ const ENABLED_BOTH: FormatterOutputReportingConfig = {
13
+ onFailure: "both",
14
+ maxBytes: 4096,
15
+ maxLines: 40,
16
+ };
17
+
18
+ const DISABLED: FormatterOutputReportingConfig = {
19
+ onFailure: "none",
20
+ maxBytes: 4096,
21
+ maxLines: 40,
22
+ };
23
+
24
+ function failedRun(overrides: Partial<BatchRun> = {}): BatchRun {
25
+ return {
26
+ formatterName: "prettier",
27
+ command: ["prettier", "--write", "src/foo.ts"],
28
+ files: ["src/foo.ts"],
29
+ success: false,
30
+ exitCode: 2,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ describe("formatRunOutputBlock", () => {
36
+ it("returns undefined when onFailure is none", () => {
37
+ const block = formatRunOutputBlock(
38
+ failedRun({ stderr: "boom\n" }),
39
+ DISABLED,
40
+ );
41
+ expect(block).toBeUndefined();
42
+ });
43
+
44
+ it("returns undefined for successful runs even when enabled", () => {
45
+ const block = formatRunOutputBlock(
46
+ failedRun({ success: true, exitCode: 0, stderr: "noisy success" }),
47
+ ENABLED_BOTH,
48
+ );
49
+ expect(block).toBeUndefined();
50
+ });
51
+
52
+ it("returns undefined when both streams are empty / whitespace", () => {
53
+ const block = formatRunOutputBlock(
54
+ failedRun({ stdout: " \n ", stderr: "" }),
55
+ ENABLED_BOTH,
56
+ );
57
+ expect(block).toBeUndefined();
58
+ });
59
+
60
+ it("returns undefined when stderr-only mode has no stderr", () => {
61
+ const block = formatRunOutputBlock(
62
+ failedRun({ stdout: "lots of stdout" }),
63
+ ENABLED_STDERR,
64
+ );
65
+ expect(block).toBeUndefined();
66
+ });
67
+
68
+ it("renders only stderr under onFailure: stderr", () => {
69
+ const block = formatRunOutputBlock(
70
+ failedRun({ stdout: "ignored", stderr: "line one\nline two" }),
71
+ ENABLED_STDERR,
72
+ );
73
+ expect(block).toBe(
74
+ [" stderr:", " line one", " line two"].join("\n"),
75
+ );
76
+ });
77
+
78
+ it("renders stdout above stderr under onFailure: both", () => {
79
+ const block = formatRunOutputBlock(
80
+ failedRun({ stdout: "out one\nout two", stderr: "err one" }),
81
+ ENABLED_BOTH,
82
+ );
83
+ expect(block).toBe(
84
+ [
85
+ " stdout:",
86
+ " out one",
87
+ " out two",
88
+ " stderr:",
89
+ " err one",
90
+ ].join("\n"),
91
+ );
92
+ });
93
+
94
+ it("omits the stdout block when stdout is empty under onFailure: both", () => {
95
+ const block = formatRunOutputBlock(
96
+ failedRun({ stdout: "", stderr: "err only" }),
97
+ ENABLED_BOTH,
98
+ );
99
+ expect(block).toBe([" stderr:", " err only"].join("\n"));
100
+ });
101
+
102
+ it("strips trailing whitespace before rendering", () => {
103
+ const block = formatRunOutputBlock(
104
+ failedRun({ stderr: "useful line\n\n \n" }),
105
+ ENABLED_STDERR,
106
+ );
107
+ expect(block).toBe([" stderr:", " useful line"].join("\n"));
108
+ });
109
+
110
+ it("truncates by line count and reports how many earlier lines were dropped", () => {
111
+ const stderr = Array.from({ length: 50 }, (_, i) => `line${i + 1}`).join(
112
+ "\n",
113
+ );
114
+ const block = formatRunOutputBlock(failedRun({ stderr }), {
115
+ onFailure: "stderr",
116
+ maxBytes: 65536,
117
+ maxLines: 5,
118
+ });
119
+ expect(block).toBeDefined();
120
+ const lines = (block ?? "").split("\n");
121
+ expect(lines[0]).toBe(" stderr:");
122
+ expect(lines[1]).toBe(" ... (truncated, 45 earlier lines)");
123
+ expect(lines.slice(2)).toEqual([
124
+ " line46",
125
+ " line47",
126
+ " line48",
127
+ " line49",
128
+ " line50",
129
+ ]);
130
+ });
131
+
132
+ it("truncates by byte cap and snaps forward to a newline boundary", () => {
133
+ // 10 lines, each 100 bytes -> ~1000 bytes total; cap at 250 bytes.
134
+ const stderr = Array.from(
135
+ { length: 10 },
136
+ (_, i) => `${String(i).padStart(2, "0")}-${"x".repeat(96)}`,
137
+ ).join("\n");
138
+ const block = formatRunOutputBlock(failedRun({ stderr }), {
139
+ onFailure: "stderr",
140
+ maxBytes: 250,
141
+ maxLines: 100,
142
+ });
143
+ expect(block).toBeDefined();
144
+ const lines = (block ?? "").split("\n");
145
+ expect(lines[0]).toBe(" stderr:");
146
+ expect(lines[1]).toMatch(/^ {4}\.\.\. \(truncated, \d+ earlier bytes\)$/);
147
+ // Only the tail lines survive; the first line ("00-…") must be gone.
148
+ expect(block).not.toContain("00-x");
149
+ expect(block).toContain("09-x");
150
+ });
151
+
152
+ it("does not bisect a multibyte UTF-8 character at the byte boundary", () => {
153
+ // Each emoji is 4 bytes in UTF-8. Keep last ~10 bytes after a long prefix.
154
+ const head = "x".repeat(100);
155
+ const tail = "🌟🌟🌟"; // 12 bytes
156
+ const stderr = `${head}\n${tail}`;
157
+ const block = formatRunOutputBlock(failedRun({ stderr }), {
158
+ onFailure: "stderr",
159
+ maxBytes: 14,
160
+ maxLines: 100,
161
+ });
162
+ expect(block).toBeDefined();
163
+ // Whatever survived must be valid UTF-8 (round-tripping through Buffer
164
+ // preserves bytes; a bisected sequence would render as U+FFFD).
165
+ expect(block).not.toContain("\uFFFD");
166
+ expect(block).toContain("🌟");
167
+ });
168
+
169
+ it("leaves output unchanged when both caps are well above the content size", () => {
170
+ const stderr = "short and tidy";
171
+ const block = formatRunOutputBlock(failedRun({ stderr }), {
172
+ onFailure: "stderr",
173
+ maxBytes: 10_000,
174
+ maxLines: 100,
175
+ });
176
+ expect(block).toBe([" stderr:", " short and tidy"].join("\n"));
177
+ });
178
+ });
@@ -2,74 +2,367 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import {
4
4
  type FormatterConfig,
5
- resolveFormatterChainForFile,
5
+ groupFilesByChain,
6
+ resolveChain,
7
+ resolveChainSteps,
6
8
  } from "../src/formatter-registry.js";
7
9
 
8
- describe("resolveFormatterChainForFile", () => {
10
+ describe("groupFilesByChain", () => {
11
+ const config: FormatterConfig = {
12
+ formatters: {
13
+ prettier: { command: ["prettier", "--write"] },
14
+ markdownlint: { command: ["markdownlint-cli2", "--fix"] },
15
+ biome: { command: ["biome", "format", "--write"] },
16
+ },
17
+ chains: {
18
+ ".md": ["prettier", "markdownlint"],
19
+ ".markdown": ["prettier", "markdownlint"],
20
+ ".ts": ["prettier"],
21
+ ".js": ["prettier"],
22
+ ".rs": ["biome"],
23
+ },
24
+ };
25
+
26
+ it("groups files that share a chain into one group", () => {
27
+ const groups = groupFilesByChain(
28
+ ["/repo/a.md", "/repo/b.md", "/repo/c.md"],
29
+ config,
30
+ );
31
+
32
+ expect(groups).toEqual([
33
+ {
34
+ chain: ["prettier", "markdownlint"],
35
+ files: ["/repo/a.md", "/repo/b.md", "/repo/c.md"],
36
+ },
37
+ ]);
38
+ });
39
+
40
+ it("creates separate groups for distinct chains", () => {
41
+ const groups = groupFilesByChain(
42
+ ["/repo/a.md", "/repo/b.ts", "/repo/c.rs"],
43
+ config,
44
+ );
45
+
46
+ expect(groups).toEqual([
47
+ { chain: ["prettier", "markdownlint"], files: ["/repo/a.md"] },
48
+ { chain: ["prettier"], files: ["/repo/b.ts"] },
49
+ { chain: ["biome"], files: ["/repo/c.rs"] },
50
+ ]);
51
+ });
52
+
53
+ it("merges different extensions that resolve to the same chain", () => {
54
+ const groups = groupFilesByChain(
55
+ ["/repo/a.md", "/repo/b.markdown", "/repo/c.ts", "/repo/d.js"],
56
+ config,
57
+ );
58
+
59
+ expect(groups).toEqual([
60
+ {
61
+ chain: ["prettier", "markdownlint"],
62
+ files: ["/repo/a.md", "/repo/b.markdown"],
63
+ },
64
+ { chain: ["prettier"], files: ["/repo/c.ts", "/repo/d.js"] },
65
+ ]);
66
+ });
67
+
68
+ it("drops files with no chain", () => {
69
+ const groups = groupFilesByChain(
70
+ ["/repo/a.md", "/repo/logo.png", "/repo/notes.txt"],
71
+ config,
72
+ );
73
+
74
+ expect(groups).toEqual([
75
+ { chain: ["prettier", "markdownlint"], files: ["/repo/a.md"] },
76
+ ]);
77
+ });
78
+
79
+ it("preserves first-seen group order and within-group file order", () => {
80
+ const groups = groupFilesByChain(
81
+ ["/repo/x.ts", "/repo/a.md", "/repo/y.ts", "/repo/b.md"],
82
+ config,
83
+ );
84
+
85
+ expect(groups).toEqual([
86
+ { chain: ["prettier"], files: ["/repo/x.ts", "/repo/y.ts"] },
87
+ {
88
+ chain: ["prettier", "markdownlint"],
89
+ files: ["/repo/a.md", "/repo/b.md"],
90
+ },
91
+ ]);
92
+ });
93
+
94
+ it("returns no groups when given no files", () => {
95
+ expect(groupFilesByChain([], config)).toEqual([]);
96
+ });
97
+
98
+ describe("with a wildcard chain", () => {
99
+ const wildcardConfig: FormatterConfig = {
100
+ formatters: {
101
+ prettier: { command: ["prettier", "--write"] },
102
+ markdownlint: { command: ["markdownlint-cli2", "--fix"] },
103
+ },
104
+ chains: {
105
+ "*": ["treefmt"],
106
+ ".md": ["prettier", "markdownlint"],
107
+ ".ts": ["prettier"],
108
+ },
109
+ };
110
+
111
+ it("emits the wildcard group first with every touched file", () => {
112
+ const groups = groupFilesByChain(
113
+ ["/repo/a.md", "/repo/b.ts", "/repo/Makefile"],
114
+ wildcardConfig,
115
+ );
116
+ expect(groups[0]).toEqual({
117
+ chain: ["treefmt"],
118
+ files: ["/repo/a.md", "/repo/b.ts", "/repo/Makefile"],
119
+ });
120
+ });
121
+
122
+ it("keeps per-extension groups after the wildcard group", () => {
123
+ const groups = groupFilesByChain(
124
+ ["/repo/a.md", "/repo/b.ts"],
125
+ wildcardConfig,
126
+ );
127
+ expect(groups).toEqual([
128
+ {
129
+ chain: ["treefmt"],
130
+ files: ["/repo/a.md", "/repo/b.ts"],
131
+ },
132
+ {
133
+ chain: ["prettier", "markdownlint"],
134
+ files: ["/repo/a.md"],
135
+ },
136
+ {
137
+ chain: ["prettier"],
138
+ files: ["/repo/b.ts"],
139
+ },
140
+ ]);
141
+ });
142
+
143
+ it("includes extensionless files in the wildcard group only", () => {
144
+ const groups = groupFilesByChain(
145
+ ["/repo/Makefile", "/repo/notes"],
146
+ wildcardConfig,
147
+ );
148
+ expect(groups).toEqual([
149
+ {
150
+ chain: ["treefmt"],
151
+ files: ["/repo/Makefile", "/repo/notes"],
152
+ },
153
+ ]);
154
+ });
155
+
156
+ it('emits no wildcard group when chains["*"] is absent', () => {
157
+ const groups = groupFilesByChain(["/repo/a.md"], {
158
+ formatters: wildcardConfig.formatters,
159
+ chains: {
160
+ ".md": ["prettier"],
161
+ },
162
+ });
163
+ expect(groups).toEqual([{ chain: ["prettier"], files: ["/repo/a.md"] }]);
164
+ });
165
+ });
166
+
167
+ describe("with fallback steps", () => {
168
+ const fallbackConfig: FormatterConfig = {
169
+ formatters: {
170
+ biome: { command: ["biome", "format", "--write"] },
171
+ prettier: { command: ["prettier", "--write"] },
172
+ "markdownlint-cli2": {
173
+ command: ["markdownlint-cli2", "--fix"],
174
+ },
175
+ },
176
+ chains: {
177
+ ".ts": [{ fallback: ["biome", "prettier"] }],
178
+ ".tsx": [{ fallback: ["biome", "prettier"] }],
179
+ ".md": [{ fallback: ["biome", "prettier"] }, "markdownlint-cli2"],
180
+ },
181
+ };
182
+
183
+ it("keeps the original chain step shape in the returned group", () => {
184
+ const groups = groupFilesByChain(["/repo/a.ts"], fallbackConfig);
185
+ expect(groups).toEqual([
186
+ {
187
+ chain: [{ fallback: ["biome", "prettier"] }],
188
+ files: ["/repo/a.ts"],
189
+ },
190
+ ]);
191
+ });
192
+
193
+ it("groups identical fallback chains across extensions", () => {
194
+ const groups = groupFilesByChain(
195
+ ["/repo/a.ts", "/repo/b.tsx"],
196
+ fallbackConfig,
197
+ );
198
+ expect(groups).toEqual([
199
+ {
200
+ chain: [{ fallback: ["biome", "prettier"] }],
201
+ files: ["/repo/a.ts", "/repo/b.tsx"],
202
+ },
203
+ ]);
204
+ });
205
+
206
+ it("groups mixed string + fallback chains", () => {
207
+ const groups = groupFilesByChain(
208
+ ["/repo/a.ts", "/repo/b.md", "/repo/c.md"],
209
+ fallbackConfig,
210
+ );
211
+ expect(groups).toHaveLength(2);
212
+ const md = groups.find((g) => g.files.includes("/repo/b.md"));
213
+ expect(md?.chain).toEqual([
214
+ { fallback: ["biome", "prettier"] },
215
+ "markdownlint-cli2",
216
+ ]);
217
+ expect(md?.files).toEqual(["/repo/b.md", "/repo/c.md"]);
218
+ });
219
+
220
+ it("separates groups whose fallback ordering differs", () => {
221
+ const reorderedConfig: FormatterConfig = {
222
+ formatters: fallbackConfig.formatters,
223
+ chains: {
224
+ ".ts": [{ fallback: ["biome", "prettier"] }],
225
+ ".js": [{ fallback: ["prettier", "biome"] }],
226
+ },
227
+ };
228
+ const groups = groupFilesByChain(
229
+ ["/repo/a.ts", "/repo/b.js"],
230
+ reorderedConfig,
231
+ );
232
+ expect(groups).toHaveLength(2);
233
+ });
234
+ });
235
+ });
236
+
237
+ describe("resolveChain", () => {
9
238
  const config: FormatterConfig = {
10
239
  formatters: {
11
240
  prettier: {
12
- command: ["prettier", "--write", "$FILE"],
13
- extensions: [".ts", ".md"],
241
+ command: ["prettier", "--write"],
242
+ environment: { PRETTIERD_DEFAULT_CONFIG: "./.prettierrc" },
14
243
  },
15
244
  markdownlint: {
16
- command: ["markdownlint-cli2", "--fix", "$FILE"],
17
- extensions: [".md"],
245
+ command: ["markdownlint-cli2", "--fix"],
246
+ },
247
+ disabled: {
248
+ command: ["never"],
249
+ disabled: true,
18
250
  },
19
251
  },
20
- chains: {
21
- ".md": ["prettier", "markdownlint"],
22
- },
252
+ chains: {},
23
253
  };
24
254
 
25
- it("resolves explicit chains in declared order", () => {
26
- const chain = resolveFormatterChainForFile("/repo/docs/readme.md", config);
255
+ it("resolves formatters in declared order", () => {
256
+ const resolved = resolveChain(["prettier", "markdownlint"], config);
27
257
 
28
- expect(chain.map((entry) => entry.name)).toEqual([
258
+ expect(resolved.map((entry) => entry.name)).toEqual([
29
259
  "prettier",
30
260
  "markdownlint",
31
261
  ]);
32
262
  });
33
263
 
34
- it("returns an empty chain when no explicit chain exists for the extension", () => {
35
- const chain = resolveFormatterChainForFile("/repo/src/index.ts", config);
264
+ it("returns the configured command verbatim (no $FILE substitution)", () => {
265
+ const resolved = resolveChain(["prettier"], config);
266
+
267
+ expect(resolved[0]?.command).toEqual(["prettier", "--write"]);
268
+ });
269
+
270
+ it("propagates the formatter environment", () => {
271
+ const resolved = resolveChain(["prettier"], config);
36
272
 
37
- expect(chain).toEqual([]);
273
+ expect(resolved[0]?.environment).toEqual({
274
+ PRETTIERD_DEFAULT_CONFIG: "./.prettierrc",
275
+ });
38
276
  });
39
277
 
40
- it("substitutes $FILE in formatter commands", () => {
41
- const chain = resolveFormatterChainForFile("/repo/docs/readme.md", config);
278
+ it("skips disabled formatters", () => {
279
+ const resolved = resolveChain(
280
+ ["prettier", "disabled", "markdownlint"],
281
+ config,
282
+ );
42
283
 
43
- expect(chain[0]?.command).toEqual([
284
+ expect(resolved.map((entry) => entry.name)).toEqual([
44
285
  "prettier",
45
- "--write",
46
- "/repo/docs/readme.md",
286
+ "markdownlint",
47
287
  ]);
48
288
  });
49
289
 
50
- it("skips disabled formatters", () => {
51
- const withDisabled: FormatterConfig = {
52
- ...config,
53
- formatters: {
54
- ...config.formatters,
55
- markdownlint: {
56
- ...config.formatters.markdownlint,
57
- disabled: true,
58
- },
290
+ it("skips unknown formatter names", () => {
291
+ const resolved = resolveChain(["prettier", "nonexistent"], config);
292
+
293
+ expect(resolved.map((entry) => entry.name)).toEqual(["prettier"]);
294
+ });
295
+
296
+ it("returns an empty array for an empty chain", () => {
297
+ expect(resolveChain([], config)).toEqual([]);
298
+ });
299
+ });
300
+
301
+ describe("resolveChainSteps", () => {
302
+ const config: FormatterConfig = {
303
+ formatters: {
304
+ prettier: { command: ["prettier", "--write"] },
305
+ biome: { command: ["biome", "format", "--write"] },
306
+ "markdownlint-cli2": {
307
+ command: ["markdownlint-cli2", "--fix"],
59
308
  },
60
- };
309
+ off: { command: ["never"], disabled: true },
310
+ },
311
+ chains: {},
312
+ };
313
+
314
+ it("resolves a single string step to kind 'single'", () => {
315
+ const resolved = resolveChainSteps(["prettier"], config);
316
+ expect(resolved).toHaveLength(1);
317
+ expect(resolved[0]?.kind).toBe("single");
318
+ if (resolved[0]?.kind === "single") {
319
+ expect(resolved[0].formatter.name).toBe("prettier");
320
+ expect(resolved[0].formatter.command).toEqual(["prettier", "--write"]);
321
+ }
322
+ });
61
323
 
62
- const chain = resolveFormatterChainForFile(
63
- "/repo/docs/readme.md",
64
- withDisabled,
324
+ it("drops a single step that names an unknown or disabled formatter", () => {
325
+ expect(resolveChainSteps(["nope"], config)).toEqual([]);
326
+ expect(resolveChainSteps(["off"], config)).toEqual([]);
327
+ });
328
+
329
+ it("resolves a fallback step to kind 'fallback' with alternatives in order", () => {
330
+ const resolved = resolveChainSteps(
331
+ [{ fallback: ["biome", "prettier"] }],
332
+ config,
65
333
  );
334
+ expect(resolved).toHaveLength(1);
335
+ expect(resolved[0]?.kind).toBe("fallback");
336
+ if (resolved[0]?.kind === "fallback") {
337
+ expect(resolved[0].alternatives.map((a) => a.name)).toEqual([
338
+ "biome",
339
+ "prettier",
340
+ ]);
341
+ }
342
+ });
66
343
 
67
- expect(chain.map((entry) => entry.name)).toEqual(["prettier"]);
344
+ it("drops disabled and unknown alternatives within a fallback step", () => {
345
+ const resolved = resolveChainSteps(
346
+ [{ fallback: ["off", "unknown", "prettier"] }],
347
+ config,
348
+ );
349
+ expect(resolved).toHaveLength(1);
350
+ if (resolved[0]?.kind === "fallback") {
351
+ expect(resolved[0].alternatives.map((a) => a.name)).toEqual(["prettier"]);
352
+ }
68
353
  });
69
354
 
70
- it("returns an empty chain when no formatter matches", () => {
71
- const chain = resolveFormatterChainForFile("/repo/assets/logo.png", config);
355
+ it("drops a fallback step whose alternatives all reduce away", () => {
356
+ expect(
357
+ resolveChainSteps([{ fallback: ["off", "unknown"] }], config),
358
+ ).toEqual([]);
359
+ });
72
360
 
73
- expect(chain).toEqual([]);
361
+ it("resolves mixed string + fallback chains preserving order", () => {
362
+ const resolved = resolveChainSteps(
363
+ [{ fallback: ["biome", "prettier"] }, "markdownlint-cli2"],
364
+ config,
365
+ );
366
+ expect(resolved.map((s) => s.kind)).toEqual(["fallback", "single"]);
74
367
  });
75
368
  });