@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.
- package/.github/workflows/ci.yml +1 -3
- package/.github/workflows/release-please.yml +29 -0
- package/.markdownlint-cli2.yaml +14 -2
- package/.pi/extensions/pi-autoformat/config.json +3 -6
- package/.pi/prompts/README.md +59 -0
- package/.pi/prompts/plan-issue.md +64 -0
- package/.pi/prompts/retro.md +144 -0
- package/.pi/prompts/ship-issue.md +77 -0
- package/.pi/prompts/tdd-plan.md +67 -0
- package/.pi/skills/pi-extension-lifecycle/SKILL.md +256 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +39 -0
- package/CHANGELOG.md +365 -0
- package/README.md +42 -109
- package/biome.json +1 -1
- package/docs/assets/logo.png +0 -0
- package/docs/assets/logo.svg +533 -0
- package/docs/configuration.md +358 -38
- package/docs/plans/0001-initial-implementation-plan.md +17 -9
- package/docs/plans/0002-richer-tui-formatter-summaries.md +220 -0
- package/docs/plans/0003-additional-pi-mutation-tools.md +273 -0
- package/docs/plans/0004-shell-driven-mutation-coverage.md +296 -0
- package/docs/plans/0010-acceptance-test-coverage.md +240 -0
- package/docs/plans/0012-remove-unused-formatter-extensions-field.md +152 -0
- package/docs/plans/0013-fallback-chain-step-type.md +280 -0
- package/docs/plans/0014-batch-by-default-formatter-dispatch.md +195 -0
- package/docs/plans/0015-builtin-treefmt-and-treefmt-nix-support.md +290 -0
- package/docs/plans/0016-detailed-formatter-output-on-failure.md +245 -0
- package/docs/plans/0022-pi-coding-agent-types.md +201 -0
- package/docs/plans/0027-format-before-agent-exit-follow-up-turn.md +355 -0
- package/docs/plans/0031-turn-end-flush-with-change-detection.md +365 -0
- package/docs/retro/0002-richer-tui-formatter-summaries.md +47 -0
- package/docs/retro/0013-fallback-chain-step-type.md +67 -0
- package/docs/retro/0015-builtin-treefmt-and-treefmt-nix-support.md +56 -0
- package/docs/retro/0016-detailed-formatter-output-on-failure.md +60 -0
- package/docs/retro/0022-pi-coding-agent-types.md +62 -0
- package/docs/testing.md +95 -0
- package/package.json +30 -11
- package/prek.toml +2 -2
- package/schemas/pi-autoformat.schema.json +145 -21
- package/src/builtin-formatters.ts +205 -0
- package/src/command-probe.ts +66 -0
- package/src/config-loader.ts +829 -90
- package/src/custom-mutation-tools.ts +125 -0
- package/src/extension.ts +469 -82
- package/src/format-scope.ts +118 -0
- package/src/formatter-config.ts +73 -36
- package/src/formatter-executor.ts +230 -34
- package/src/formatter-output-report.ts +149 -0
- package/src/formatter-registry.ts +139 -30
- package/src/index.ts +26 -5
- package/src/prompt-autoformatter.ts +148 -23
- package/src/shell-mutation-detector.ts +572 -0
- package/src/touched-files-queue.ts +72 -11
- package/test/acceptance-event-bus.test.ts +138 -0
- package/test/acceptance.test.ts +69 -0
- package/test/builtin-formatters.test.ts +382 -0
- package/test/command-probe.test.ts +79 -0
- package/test/config-loader.test.ts +640 -21
- package/test/custom-mutation-tools.test.ts +190 -0
- package/test/extension.test.ts +1535 -158
- package/test/fallback-acceptance.test.ts +98 -0
- package/test/fixtures/event-bus-emitter.ts +26 -0
- package/test/fixtures/formatter-recorder.mjs +25 -0
- package/test/format-scope.test.ts +139 -0
- package/test/formatter-config.test.ts +56 -5
- package/test/formatter-executor.test.ts +555 -35
- package/test/formatter-output-report.test.ts +178 -0
- package/test/formatter-registry.test.ts +330 -37
- package/test/helpers/rpc.ts +146 -0
- package/test/prompt-autoformatter.test.ts +315 -22
- package/test/schema.test.ts +149 -0
- package/test/shell-mutation-detector.test.ts +221 -0
- package/test/touched-files-queue.test.ts +40 -1
- 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
|
-
|
|
6
|
+
executeChainGroup,
|
|
7
|
+
executeChainGroupWithPartition,
|
|
6
8
|
} from "../src/formatter-executor.js";
|
|
7
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
ResolvedChainStep,
|
|
11
|
+
ResolvedFormatter,
|
|
12
|
+
} from "../src/formatter-registry.js";
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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(
|
|
47
|
+
calls.push({ command, args });
|
|
28
48
|
return { exitCode: 0 };
|
|
29
49
|
};
|
|
30
50
|
|
|
31
|
-
const
|
|
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
|
-
|
|
35
|
-
|
|
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("
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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("
|
|
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
|
|
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
|
});
|