@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
|
@@ -15,13 +15,11 @@ describe("validateUserFormatterConfig", () => {
|
|
|
15
15
|
it("accepts $schema and known config fields", () => {
|
|
16
16
|
const result = validateUserFormatterConfig({
|
|
17
17
|
$schema: "https://example.com/schema.json",
|
|
18
|
-
formatMode: "prompt",
|
|
19
18
|
commandTimeoutMs: 5000,
|
|
20
19
|
hideSummariesInTui: true,
|
|
21
20
|
formatters: {
|
|
22
21
|
prettier: {
|
|
23
|
-
command: ["prettier", "--write"
|
|
24
|
-
extensions: [".TS", ".md"],
|
|
22
|
+
command: ["prettier", "--write"],
|
|
25
23
|
},
|
|
26
24
|
},
|
|
27
25
|
chains: {
|
|
@@ -31,13 +29,11 @@ describe("validateUserFormatterConfig", () => {
|
|
|
31
29
|
|
|
32
30
|
expect(result.issues).toEqual([]);
|
|
33
31
|
expect(result.config).toEqual({
|
|
34
|
-
formatMode: "prompt",
|
|
35
32
|
commandTimeoutMs: 5000,
|
|
36
33
|
hideSummariesInTui: true,
|
|
37
34
|
formatters: {
|
|
38
35
|
prettier: {
|
|
39
|
-
command: ["prettier", "--write"
|
|
40
|
-
extensions: [".ts", ".md"],
|
|
36
|
+
command: ["prettier", "--write"],
|
|
41
37
|
},
|
|
42
38
|
},
|
|
43
39
|
chains: {
|
|
@@ -46,26 +42,275 @@ describe("validateUserFormatterConfig", () => {
|
|
|
46
42
|
});
|
|
47
43
|
});
|
|
48
44
|
|
|
45
|
+
it("rejects formatter commands containing the legacy $FILE token", () => {
|
|
46
|
+
const result = validateUserFormatterConfig({
|
|
47
|
+
formatters: {
|
|
48
|
+
prettier: {
|
|
49
|
+
command: ["prettier", "--write", "$FILE"],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(result.issues).toEqual([
|
|
55
|
+
expect.objectContaining({
|
|
56
|
+
path: "formatters.prettier.command",
|
|
57
|
+
message: expect.stringContaining("$FILE"),
|
|
58
|
+
}),
|
|
59
|
+
]);
|
|
60
|
+
expect(result.config.formatters).toEqual({});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects $FILE embedded inside a command argument", () => {
|
|
64
|
+
const result = validateUserFormatterConfig({
|
|
65
|
+
formatters: {
|
|
66
|
+
prettier: {
|
|
67
|
+
command: ["prettier", "--stdin-filepath=$FILE"],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(result.issues[0]?.message).toMatch(/\$FILE/);
|
|
73
|
+
expect(result.config.formatters).toEqual({});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("accepts a deprecated extensions field on a formatter and drops it with a notice", () => {
|
|
77
|
+
const result = validateUserFormatterConfig({
|
|
78
|
+
formatters: {
|
|
79
|
+
prettier: {
|
|
80
|
+
command: ["prettier", "--write"],
|
|
81
|
+
extensions: [".ts"],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result.config.formatters).toEqual({
|
|
87
|
+
prettier: {
|
|
88
|
+
command: ["prettier", "--write"],
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
expect(result.config.formatters?.prettier).not.toHaveProperty("extensions");
|
|
92
|
+
const extensionsIssues = result.issues.filter(
|
|
93
|
+
(issue) => issue.path === "formatters.prettier.extensions",
|
|
94
|
+
);
|
|
95
|
+
expect(extensionsIssues).toHaveLength(1);
|
|
96
|
+
expect(extensionsIssues[0]?.message).toMatch(/[Dd]eprecat/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("chains: fallback step shape", () => {
|
|
100
|
+
it("accepts a string step (current behavior)", () => {
|
|
101
|
+
const result = validateUserFormatterConfig({
|
|
102
|
+
formatters: { prettier: { command: ["prettier", "--write"] } },
|
|
103
|
+
chains: { ".ts": ["prettier"] },
|
|
104
|
+
});
|
|
105
|
+
expect(result.issues).toEqual([]);
|
|
106
|
+
expect(result.config.chains).toEqual({ ".ts": ["prettier"] });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("accepts a fallback object step", () => {
|
|
110
|
+
const result = validateUserFormatterConfig({
|
|
111
|
+
formatters: {
|
|
112
|
+
biome: { command: ["biome", "format", "--write"] },
|
|
113
|
+
prettier: { command: ["prettier", "--write"] },
|
|
114
|
+
},
|
|
115
|
+
chains: {
|
|
116
|
+
".ts": [{ fallback: ["biome", "prettier"] }],
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
expect(result.issues).toEqual([]);
|
|
120
|
+
expect(result.config.chains).toEqual({
|
|
121
|
+
".ts": [{ fallback: ["biome", "prettier"] }],
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("accepts a chain mixing string and fallback steps", () => {
|
|
126
|
+
const result = validateUserFormatterConfig({
|
|
127
|
+
formatters: {
|
|
128
|
+
biome: { command: ["biome", "format", "--write"] },
|
|
129
|
+
prettier: { command: ["prettier", "--write"] },
|
|
130
|
+
"markdownlint-cli2": {
|
|
131
|
+
command: ["markdownlint-cli2", "--fix"],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
chains: {
|
|
135
|
+
".md": [{ fallback: ["biome", "prettier"] }, "markdownlint-cli2"],
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
expect(result.issues).toEqual([]);
|
|
139
|
+
expect(result.config.chains?.[".md"]).toEqual([
|
|
140
|
+
{ fallback: ["biome", "prettier"] },
|
|
141
|
+
"markdownlint-cli2",
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("rejects an empty fallback array", () => {
|
|
146
|
+
const result = validateUserFormatterConfig({
|
|
147
|
+
formatters: { prettier: { command: ["prettier", "--write"] } },
|
|
148
|
+
chains: { ".ts": [{ fallback: [] }] },
|
|
149
|
+
});
|
|
150
|
+
expect(result.issues).toEqual([
|
|
151
|
+
expect.objectContaining({
|
|
152
|
+
path: "chains..ts[0].fallback",
|
|
153
|
+
}),
|
|
154
|
+
]);
|
|
155
|
+
expect(result.config.chains?.[".ts"]).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("rejects a fallback object with unknown sibling keys", () => {
|
|
159
|
+
const result = validateUserFormatterConfig({
|
|
160
|
+
formatters: { prettier: { command: ["prettier", "--write"] } },
|
|
161
|
+
chains: {
|
|
162
|
+
".ts": [{ fallback: ["prettier"], when: "never" }],
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
expect(result.issues.some((i) => i.path === "chains..ts[0].when")).toBe(
|
|
166
|
+
true,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("rejects a step that is neither string nor fallback object", () => {
|
|
171
|
+
const result = validateUserFormatterConfig({
|
|
172
|
+
formatters: { prettier: { command: ["prettier", "--write"] } },
|
|
173
|
+
chains: { ".ts": [42 as unknown as string] },
|
|
174
|
+
});
|
|
175
|
+
expect(result.issues.some((i) => i.path === "chains..ts[0]")).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("warns when a fallback alternative names an unknown formatter and drops the step", () => {
|
|
179
|
+
const result = validateUserFormatterConfig({
|
|
180
|
+
formatters: { prettier: { command: ["prettier", "--write"] } },
|
|
181
|
+
chains: {
|
|
182
|
+
".ts": [{ fallback: ["zogzog", "prettier"] }],
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
const unknown = result.issues.filter(
|
|
186
|
+
(i) => i.path === "chains..ts[0].fallback[0]",
|
|
187
|
+
);
|
|
188
|
+
expect(unknown).toHaveLength(1);
|
|
189
|
+
expect(unknown[0]?.message).toMatch(/unknown|not found/i);
|
|
190
|
+
expect(result.config.chains?.[".ts"]).toBeUndefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("warns when a single string step references an unknown formatter and drops the step", () => {
|
|
194
|
+
const result = validateUserFormatterConfig({
|
|
195
|
+
formatters: {},
|
|
196
|
+
chains: { ".ts": ["zogzog"] },
|
|
197
|
+
});
|
|
198
|
+
const unknown = result.issues.filter((i) => i.path === "chains..ts[0]");
|
|
199
|
+
expect(unknown).toHaveLength(1);
|
|
200
|
+
expect(unknown[0]?.message).toMatch(/unknown|not found/i);
|
|
201
|
+
expect(result.config.chains?.[".ts"]).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("does not warn when a chain references a built-in default formatter not redeclared locally", () => {
|
|
205
|
+
const result = validateUserFormatterConfig({
|
|
206
|
+
chains: { ".md": ["prettier", "markdownlint-cli2"] },
|
|
207
|
+
});
|
|
208
|
+
expect(result.issues).toEqual([]);
|
|
209
|
+
expect(result.config.chains?.[".md"]).toEqual([
|
|
210
|
+
"prettier",
|
|
211
|
+
"markdownlint-cli2",
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("accepts the literal '*' wildcard chain key", () => {
|
|
216
|
+
const result = validateUserFormatterConfig({
|
|
217
|
+
chains: { "*": ["treefmt"] },
|
|
218
|
+
});
|
|
219
|
+
expect(result.issues).toEqual([]);
|
|
220
|
+
expect(result.config.chains?.["*"]).toEqual(["treefmt"]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("accepts built-in formatter names without a formatters entry", () => {
|
|
224
|
+
const result = validateUserFormatterConfig({
|
|
225
|
+
chains: {
|
|
226
|
+
"*": [{ fallback: ["treefmt-nix", "treefmt"] }],
|
|
227
|
+
".ts": ["treefmt"],
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
expect(result.issues).toEqual([]);
|
|
231
|
+
expect(result.config.chains?.["*"]).toEqual([
|
|
232
|
+
{ fallback: ["treefmt-nix", "treefmt"] },
|
|
233
|
+
]);
|
|
234
|
+
expect(result.config.chains?.[".ts"]).toEqual(["treefmt"]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("emits a non-fatal config issue when a user shadows a built-in name in formatters", () => {
|
|
238
|
+
const result = validateUserFormatterConfig({
|
|
239
|
+
formatters: {
|
|
240
|
+
treefmt: { command: ["treefmt", "--ci"] },
|
|
241
|
+
},
|
|
242
|
+
chains: { "*": ["treefmt"] },
|
|
243
|
+
});
|
|
244
|
+
const shadow = result.issues.filter((i) =>
|
|
245
|
+
i.path.startsWith("formatters.treefmt"),
|
|
246
|
+
);
|
|
247
|
+
expect(shadow).toHaveLength(1);
|
|
248
|
+
expect(shadow[0]?.message).toMatch(/built-in|builtin/i);
|
|
249
|
+
// The user's definition still wins (escape hatch).
|
|
250
|
+
expect(result.config.formatters?.treefmt?.command).toEqual([
|
|
251
|
+
"treefmt",
|
|
252
|
+
"--ci",
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("rejects a non-string entry inside fallback", () => {
|
|
257
|
+
const result = validateUserFormatterConfig({
|
|
258
|
+
formatters: { prettier: { command: ["prettier", "--write"] } },
|
|
259
|
+
chains: {
|
|
260
|
+
".ts": [{ fallback: ["prettier", 7 as unknown as string] }],
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
expect(
|
|
264
|
+
result.issues.some((i) => i.path === "chains..ts[0].fallback[1]"),
|
|
265
|
+
).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("emits a config issue for the legacy formatMode key", () => {
|
|
270
|
+
const result = validateUserFormatterConfig({
|
|
271
|
+
formatMode: "prompt",
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(result.issues).toEqual([
|
|
275
|
+
expect.objectContaining({
|
|
276
|
+
path: "formatMode",
|
|
277
|
+
message: expect.stringContaining("removed"),
|
|
278
|
+
}),
|
|
279
|
+
]);
|
|
280
|
+
expect(result.config).not.toHaveProperty("formatMode");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("emits a config issue for any formatMode value including invalid ones", () => {
|
|
284
|
+
const result = validateUserFormatterConfig({
|
|
285
|
+
formatMode: "tool",
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(result.issues).toHaveLength(1);
|
|
289
|
+
expect(result.issues[0]?.path).toBe("formatMode");
|
|
290
|
+
expect(result.config).not.toHaveProperty("formatMode");
|
|
291
|
+
});
|
|
292
|
+
|
|
49
293
|
it("reports invalid fields and returns only valid fragments", () => {
|
|
50
294
|
const result = validateUserFormatterConfig({
|
|
51
|
-
formatMode: "later",
|
|
52
295
|
commandTimeoutMs: 0,
|
|
53
296
|
unexpected: true,
|
|
54
297
|
formatters: {
|
|
55
298
|
prettier: {
|
|
56
|
-
command: ["prettier", "--write"
|
|
299
|
+
command: ["prettier", "--write"],
|
|
57
300
|
},
|
|
58
301
|
},
|
|
59
302
|
});
|
|
60
303
|
|
|
61
304
|
expect(result.config).toEqual({
|
|
62
|
-
formatters: {
|
|
305
|
+
formatters: {
|
|
306
|
+
prettier: {
|
|
307
|
+
command: ["prettier", "--write"],
|
|
308
|
+
},
|
|
309
|
+
},
|
|
63
310
|
});
|
|
64
311
|
expect(result.issues.map((issue) => issue.path)).toEqual([
|
|
65
|
-
"formatMode",
|
|
66
312
|
"commandTimeoutMs",
|
|
67
313
|
"unexpected",
|
|
68
|
-
"formatters.prettier.extensions",
|
|
69
314
|
]);
|
|
70
315
|
});
|
|
71
316
|
});
|
|
@@ -80,7 +325,8 @@ describe("loadAutoformatConfig", () => {
|
|
|
80
325
|
|
|
81
326
|
const result = loadAutoformatConfig({ cwd, agentDir });
|
|
82
327
|
|
|
83
|
-
expect(result.config
|
|
328
|
+
expect(result.config).not.toHaveProperty("formatMode");
|
|
329
|
+
expect(result.config).not.toHaveProperty("notifyAgent");
|
|
84
330
|
expect(result.config.commandTimeoutMs).toBe(10000);
|
|
85
331
|
expect(result.config.hideSummariesInTui).toBe(false);
|
|
86
332
|
expect(result.issues).toEqual([]);
|
|
@@ -100,12 +346,10 @@ describe("loadAutoformatConfig", () => {
|
|
|
100
346
|
getGlobalConfigPath(agentDir),
|
|
101
347
|
JSON.stringify(
|
|
102
348
|
{
|
|
103
|
-
formatMode: "tool",
|
|
104
349
|
commandTimeoutMs: 5000,
|
|
105
350
|
formatters: {
|
|
106
351
|
prettier: {
|
|
107
|
-
command: ["pnpm", "exec", "prettier", "--write"
|
|
108
|
-
extensions: [".ts", ".md"],
|
|
352
|
+
command: ["pnpm", "exec", "prettier", "--write"],
|
|
109
353
|
},
|
|
110
354
|
},
|
|
111
355
|
chains: {
|
|
@@ -124,12 +368,10 @@ describe("loadAutoformatConfig", () => {
|
|
|
124
368
|
getProjectConfigPath(cwd),
|
|
125
369
|
JSON.stringify(
|
|
126
370
|
{
|
|
127
|
-
formatMode: "prompt",
|
|
128
371
|
hideSummariesInTui: true,
|
|
129
372
|
formatters: {
|
|
130
373
|
"markdownlint-cli2": {
|
|
131
|
-
command: ["pnpm", "exec", "markdownlint-cli2", "--fix"
|
|
132
|
-
extensions: [".md"],
|
|
374
|
+
command: ["pnpm", "exec", "markdownlint-cli2", "--fix"],
|
|
133
375
|
},
|
|
134
376
|
},
|
|
135
377
|
chains: {
|
|
@@ -143,7 +385,6 @@ describe("loadAutoformatConfig", () => {
|
|
|
143
385
|
|
|
144
386
|
const result = loadAutoformatConfig({ cwd, agentDir });
|
|
145
387
|
|
|
146
|
-
expect(result.config.formatMode).toBe("prompt");
|
|
147
388
|
expect(result.config.commandTimeoutMs).toBe(5000);
|
|
148
389
|
expect(result.config.hideSummariesInTui).toBe(true);
|
|
149
390
|
expect(result.config.formatters.prettier?.command).toEqual([
|
|
@@ -151,14 +392,12 @@ describe("loadAutoformatConfig", () => {
|
|
|
151
392
|
"exec",
|
|
152
393
|
"prettier",
|
|
153
394
|
"--write",
|
|
154
|
-
"$FILE",
|
|
155
395
|
]);
|
|
156
396
|
expect(result.config.formatters["markdownlint-cli2"]?.command).toEqual([
|
|
157
397
|
"pnpm",
|
|
158
398
|
"exec",
|
|
159
399
|
"markdownlint-cli2",
|
|
160
400
|
"--fix",
|
|
161
|
-
"$FILE",
|
|
162
401
|
]);
|
|
163
402
|
expect(result.config.chains[".md"]).toEqual([
|
|
164
403
|
"prettier",
|
|
@@ -167,6 +406,133 @@ describe("loadAutoformatConfig", () => {
|
|
|
167
406
|
expect(result.issues).toEqual([]);
|
|
168
407
|
});
|
|
169
408
|
|
|
409
|
+
it("loads formatScope and shellMutationDetection settings", () => {
|
|
410
|
+
const root = mkdtempSync(join(tmpdir(), "pi-autoformat-config-"));
|
|
411
|
+
const cwd = join(root, "project");
|
|
412
|
+
const agentDir = join(root, "agent");
|
|
413
|
+
mkdirSync(cwd, { recursive: true });
|
|
414
|
+
mkdirSync(agentDir, { recursive: true });
|
|
415
|
+
|
|
416
|
+
mkdirSync(join(agentDir, "extensions", "pi-autoformat"), {
|
|
417
|
+
recursive: true,
|
|
418
|
+
});
|
|
419
|
+
writeFileSync(
|
|
420
|
+
getGlobalConfigPath(agentDir),
|
|
421
|
+
JSON.stringify({
|
|
422
|
+
formatScope: "cwd",
|
|
423
|
+
shellMutationDetection: {
|
|
424
|
+
enabled: true,
|
|
425
|
+
snapshotGlobs: ["src/**/*.ts"],
|
|
426
|
+
},
|
|
427
|
+
}),
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
mkdirSync(join(cwd, ".pi", "extensions", "pi-autoformat"), {
|
|
431
|
+
recursive: true,
|
|
432
|
+
});
|
|
433
|
+
writeFileSync(
|
|
434
|
+
getProjectConfigPath(cwd),
|
|
435
|
+
JSON.stringify({
|
|
436
|
+
formatScope: ["packages/a"],
|
|
437
|
+
shellMutationDetection: {
|
|
438
|
+
snapshotGlobs: ["docs/**/*.md"],
|
|
439
|
+
wrappers: [{ prefix: "pnpm codegen", outputFormat: "lines" }],
|
|
440
|
+
},
|
|
441
|
+
}),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const result = loadAutoformatConfig({ cwd, agentDir });
|
|
445
|
+
|
|
446
|
+
expect(result.issues).toEqual([]);
|
|
447
|
+
expect(result.config.formatScope).toEqual(["packages/a"]);
|
|
448
|
+
expect(result.config.shellMutationDetection).toEqual({
|
|
449
|
+
enabled: true,
|
|
450
|
+
argumentParsing: true,
|
|
451
|
+
snapshotGlobs: ["docs/**/*.md"],
|
|
452
|
+
wrappers: [{ prefix: "pnpm codegen", outputFormat: "lines" }],
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("defaults shellMutationDetection to disabled with formatScope=repoRoot", () => {
|
|
457
|
+
const root = mkdtempSync(join(tmpdir(), "pi-autoformat-config-"));
|
|
458
|
+
const cwd = join(root, "project");
|
|
459
|
+
const agentDir = join(root, "agent");
|
|
460
|
+
mkdirSync(cwd, { recursive: true });
|
|
461
|
+
mkdirSync(agentDir, { recursive: true });
|
|
462
|
+
|
|
463
|
+
const result = loadAutoformatConfig({ cwd, agentDir });
|
|
464
|
+
expect(result.config.formatScope).toBe("repoRoot");
|
|
465
|
+
expect(result.config.shellMutationDetection).toEqual({
|
|
466
|
+
enabled: false,
|
|
467
|
+
argumentParsing: true,
|
|
468
|
+
snapshotGlobs: [],
|
|
469
|
+
wrappers: [],
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("loads customMutationTools and eventBusMutationChannel settings", () => {
|
|
474
|
+
const root = mkdtempSync(join(tmpdir(), "pi-autoformat-config-"));
|
|
475
|
+
const cwd = join(root, "project");
|
|
476
|
+
const agentDir = join(root, "agent");
|
|
477
|
+
mkdirSync(cwd, { recursive: true });
|
|
478
|
+
mkdirSync(agentDir, { recursive: true });
|
|
479
|
+
|
|
480
|
+
mkdirSync(join(agentDir, "extensions", "pi-autoformat"), {
|
|
481
|
+
recursive: true,
|
|
482
|
+
});
|
|
483
|
+
writeFileSync(
|
|
484
|
+
getGlobalConfigPath(agentDir),
|
|
485
|
+
JSON.stringify({
|
|
486
|
+
customMutationTools: [{ toolName: "global_tool", pathField: "path" }],
|
|
487
|
+
eventBusMutationChannel: { enabled: false },
|
|
488
|
+
}),
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
mkdirSync(join(cwd, ".pi", "extensions", "pi-autoformat"), {
|
|
492
|
+
recursive: true,
|
|
493
|
+
});
|
|
494
|
+
writeFileSync(
|
|
495
|
+
getProjectConfigPath(cwd),
|
|
496
|
+
JSON.stringify({
|
|
497
|
+
customMutationTools: [
|
|
498
|
+
{ toolName: "mcp_files_write", pathField: "path" },
|
|
499
|
+
{ toolName: "codemod", pathFields: ["target", "extras"] },
|
|
500
|
+
],
|
|
501
|
+
eventBusMutationChannel: { channel: "custom:channel" },
|
|
502
|
+
}),
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const result = loadAutoformatConfig({ cwd, agentDir });
|
|
506
|
+
|
|
507
|
+
expect(result.issues).toEqual([]);
|
|
508
|
+
// Project replaces global wholesale for the array.
|
|
509
|
+
expect(result.config.customMutationTools).toEqual([
|
|
510
|
+
{ toolName: "mcp_files_write", pathField: "path" },
|
|
511
|
+
{ toolName: "codemod", pathFields: ["target", "extras"] },
|
|
512
|
+
]);
|
|
513
|
+
// Scalar fields merge: project channel wins, global enabled persists.
|
|
514
|
+
expect(result.config.eventBusMutationChannel).toEqual({
|
|
515
|
+
enabled: false,
|
|
516
|
+
channel: "custom:channel",
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("defaults customMutationTools to [] and eventBusMutationChannel to enabled", () => {
|
|
521
|
+
const root = mkdtempSync(join(tmpdir(), "pi-autoformat-config-"));
|
|
522
|
+
const cwd = join(root, "project");
|
|
523
|
+
const agentDir = join(root, "agent");
|
|
524
|
+
mkdirSync(cwd, { recursive: true });
|
|
525
|
+
mkdirSync(agentDir, { recursive: true });
|
|
526
|
+
|
|
527
|
+
const result = loadAutoformatConfig({ cwd, agentDir });
|
|
528
|
+
|
|
529
|
+
expect(result.config.customMutationTools).toEqual([]);
|
|
530
|
+
expect(result.config.eventBusMutationChannel).toEqual({
|
|
531
|
+
enabled: true,
|
|
532
|
+
channel: "autoformat:touched",
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
170
536
|
it("reports parse and validation errors without throwing", () => {
|
|
171
537
|
const root = mkdtempSync(join(tmpdir(), "pi-autoformat-config-"));
|
|
172
538
|
const cwd = join(root, "project");
|
|
@@ -197,3 +563,256 @@ describe("loadAutoformatConfig", () => {
|
|
|
197
563
|
expect(result.issues[1]?.path).toBe("hideSummariesInTui");
|
|
198
564
|
});
|
|
199
565
|
});
|
|
566
|
+
|
|
567
|
+
describe("validateUserFormatterConfig: customMutationTools", () => {
|
|
568
|
+
it("accepts entries with pathField or pathFields", () => {
|
|
569
|
+
const result = validateUserFormatterConfig({
|
|
570
|
+
customMutationTools: [
|
|
571
|
+
{ toolName: "a", pathField: "path" },
|
|
572
|
+
{ toolName: "b", pathFields: ["target", "args.extra"] },
|
|
573
|
+
],
|
|
574
|
+
});
|
|
575
|
+
expect(result.issues).toEqual([]);
|
|
576
|
+
expect(result.config.customMutationTools).toEqual([
|
|
577
|
+
{ toolName: "a", pathField: "path" },
|
|
578
|
+
{ toolName: "b", pathFields: ["target", "args.extra"] },
|
|
579
|
+
]);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("rejects built-in tool names", () => {
|
|
583
|
+
const result = validateUserFormatterConfig({
|
|
584
|
+
customMutationTools: [
|
|
585
|
+
{ toolName: "write", pathField: "path" },
|
|
586
|
+
{ toolName: "bash", pathField: "path" },
|
|
587
|
+
{ toolName: "grep", pathField: "pattern" },
|
|
588
|
+
],
|
|
589
|
+
});
|
|
590
|
+
expect(result.issues.map((i) => i.path)).toEqual([
|
|
591
|
+
"customMutationTools[0].toolName",
|
|
592
|
+
"customMutationTools[1].toolName",
|
|
593
|
+
"customMutationTools[2].toolName",
|
|
594
|
+
]);
|
|
595
|
+
expect(result.config.customMutationTools).toBeUndefined();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("rejects duplicate toolName entries", () => {
|
|
599
|
+
const result = validateUserFormatterConfig({
|
|
600
|
+
customMutationTools: [
|
|
601
|
+
{ toolName: "dup", pathField: "a" },
|
|
602
|
+
{ toolName: "dup", pathField: "b" },
|
|
603
|
+
],
|
|
604
|
+
});
|
|
605
|
+
expect(result.issues.map((i) => i.path)).toEqual([
|
|
606
|
+
"customMutationTools[1].toolName",
|
|
607
|
+
]);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("rejects entries with both pathField and pathFields", () => {
|
|
611
|
+
const result = validateUserFormatterConfig({
|
|
612
|
+
customMutationTools: [
|
|
613
|
+
{ toolName: "x", pathField: "a", pathFields: ["b"] },
|
|
614
|
+
],
|
|
615
|
+
});
|
|
616
|
+
expect(result.issues.map((i) => i.path)).toEqual([
|
|
617
|
+
"customMutationTools[0]",
|
|
618
|
+
]);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("rejects entries with neither pathField nor pathFields", () => {
|
|
622
|
+
const result = validateUserFormatterConfig({
|
|
623
|
+
customMutationTools: [{ toolName: "x" }],
|
|
624
|
+
});
|
|
625
|
+
expect(result.issues.map((i) => i.path)).toEqual([
|
|
626
|
+
"customMutationTools[0]",
|
|
627
|
+
]);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("rejects empty or non-string dotted paths", () => {
|
|
631
|
+
const result = validateUserFormatterConfig({
|
|
632
|
+
customMutationTools: [
|
|
633
|
+
{ toolName: "a", pathField: "" },
|
|
634
|
+
{ toolName: "b", pathFields: ["valid", ""] },
|
|
635
|
+
{ toolName: "c", pathFields: [42] },
|
|
636
|
+
],
|
|
637
|
+
});
|
|
638
|
+
expect(result.issues.map((i) => i.path)).toEqual([
|
|
639
|
+
"customMutationTools[0].pathField",
|
|
640
|
+
"customMutationTools[1].pathFields[1]",
|
|
641
|
+
"customMutationTools[2].pathFields[0]",
|
|
642
|
+
]);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("rejects non-array customMutationTools", () => {
|
|
646
|
+
const result = validateUserFormatterConfig({
|
|
647
|
+
customMutationTools: { toolName: "x", pathField: "path" },
|
|
648
|
+
});
|
|
649
|
+
expect(result.issues.map((i) => i.path)).toEqual(["customMutationTools"]);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("rejects empty toolName", () => {
|
|
653
|
+
const result = validateUserFormatterConfig({
|
|
654
|
+
customMutationTools: [{ toolName: "", pathField: "path" }],
|
|
655
|
+
});
|
|
656
|
+
expect(result.issues.map((i) => i.path)).toEqual([
|
|
657
|
+
"customMutationTools[0].toolName",
|
|
658
|
+
]);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("rejects unknown properties on entries", () => {
|
|
662
|
+
const result = validateUserFormatterConfig({
|
|
663
|
+
customMutationTools: [{ toolName: "x", pathField: "path", weird: true }],
|
|
664
|
+
});
|
|
665
|
+
expect(result.issues.map((i) => i.path)).toEqual([
|
|
666
|
+
"customMutationTools[0].weird",
|
|
667
|
+
]);
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
describe("validateUserFormatterConfig: formatterOutput", () => {
|
|
672
|
+
it("accepts a fully specified formatterOutput object", () => {
|
|
673
|
+
const result = validateUserFormatterConfig({
|
|
674
|
+
formatterOutput: {
|
|
675
|
+
onFailure: "stderr",
|
|
676
|
+
maxBytes: 2048,
|
|
677
|
+
maxLines: 20,
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
expect(result.issues).toEqual([]);
|
|
681
|
+
expect(result.config.formatterOutput).toEqual({
|
|
682
|
+
onFailure: "stderr",
|
|
683
|
+
maxBytes: 2048,
|
|
684
|
+
maxLines: 20,
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("accepts a partial formatterOutput (just onFailure)", () => {
|
|
689
|
+
const result = validateUserFormatterConfig({
|
|
690
|
+
formatterOutput: { onFailure: "both" },
|
|
691
|
+
});
|
|
692
|
+
expect(result.issues).toEqual([]);
|
|
693
|
+
expect(result.config.formatterOutput).toEqual({ onFailure: "both" });
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("rejects an invalid onFailure value and falls back to default", () => {
|
|
697
|
+
const result = validateUserFormatterConfig({
|
|
698
|
+
formatterOutput: { onFailure: "verbose" },
|
|
699
|
+
});
|
|
700
|
+
expect(result.issues.map((i) => i.path)).toEqual([
|
|
701
|
+
"formatterOutput.onFailure",
|
|
702
|
+
]);
|
|
703
|
+
// No formatterOutput key written when its only field was invalid; the
|
|
704
|
+
// user config falls back to defaults via createFormatterConfig.
|
|
705
|
+
expect(result.config.formatterOutput).toBeUndefined();
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it("rejects negative or non-integer caps", () => {
|
|
709
|
+
const result = validateUserFormatterConfig({
|
|
710
|
+
formatterOutput: { maxBytes: -1, maxLines: 1.5 },
|
|
711
|
+
});
|
|
712
|
+
const paths = result.issues.map((i) => i.path).sort();
|
|
713
|
+
expect(paths).toEqual([
|
|
714
|
+
"formatterOutput.maxBytes",
|
|
715
|
+
"formatterOutput.maxLines",
|
|
716
|
+
]);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("rejects non-object values", () => {
|
|
720
|
+
const result = validateUserFormatterConfig({
|
|
721
|
+
formatterOutput: "yes",
|
|
722
|
+
});
|
|
723
|
+
expect(result.issues.map((i) => i.path)).toEqual(["formatterOutput"]);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it("rejects unknown sub-keys", () => {
|
|
727
|
+
const result = validateUserFormatterConfig({
|
|
728
|
+
formatterOutput: { onFailure: "none", weird: 1 },
|
|
729
|
+
});
|
|
730
|
+
expect(result.issues.map((i) => i.path)).toEqual(["formatterOutput.weird"]);
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
describe("validateUserFormatterConfig: eventBusMutationChannel", () => {
|
|
735
|
+
it("accepts enabled and channel fields", () => {
|
|
736
|
+
const result = validateUserFormatterConfig({
|
|
737
|
+
eventBusMutationChannel: {
|
|
738
|
+
enabled: false,
|
|
739
|
+
channel: "my:channel",
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
expect(result.issues).toEqual([]);
|
|
743
|
+
expect(result.config.eventBusMutationChannel).toEqual({
|
|
744
|
+
enabled: false,
|
|
745
|
+
channel: "my:channel",
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("accepts a partial config (just enabled)", () => {
|
|
750
|
+
const result = validateUserFormatterConfig({
|
|
751
|
+
eventBusMutationChannel: { enabled: true },
|
|
752
|
+
});
|
|
753
|
+
expect(result.issues).toEqual([]);
|
|
754
|
+
expect(result.config.eventBusMutationChannel).toEqual({ enabled: true });
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("rejects non-object values", () => {
|
|
758
|
+
const result = validateUserFormatterConfig({
|
|
759
|
+
eventBusMutationChannel: "yes",
|
|
760
|
+
});
|
|
761
|
+
expect(result.issues.map((i) => i.path)).toEqual([
|
|
762
|
+
"eventBusMutationChannel",
|
|
763
|
+
]);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it("rejects non-boolean enabled", () => {
|
|
767
|
+
const result = validateUserFormatterConfig({
|
|
768
|
+
eventBusMutationChannel: { enabled: "yes" },
|
|
769
|
+
});
|
|
770
|
+
expect(result.issues.map((i) => i.path)).toEqual([
|
|
771
|
+
"eventBusMutationChannel.enabled",
|
|
772
|
+
]);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it("rejects empty / non-string channel", () => {
|
|
776
|
+
const result = validateUserFormatterConfig({
|
|
777
|
+
eventBusMutationChannel: { channel: "" },
|
|
778
|
+
});
|
|
779
|
+
expect(result.issues.map((i) => i.path)).toEqual([
|
|
780
|
+
"eventBusMutationChannel.channel",
|
|
781
|
+
]);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("rejects unknown properties", () => {
|
|
785
|
+
const result = validateUserFormatterConfig({
|
|
786
|
+
eventBusMutationChannel: { enabled: true, weird: 1 },
|
|
787
|
+
});
|
|
788
|
+
expect(result.issues.map((i) => i.path)).toEqual([
|
|
789
|
+
"eventBusMutationChannel.weird",
|
|
790
|
+
]);
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
describe("validateUserFormatterConfig: notifyAgent (removed)", () => {
|
|
795
|
+
it("emits a deprecation config issue when notifyAgent is present", () => {
|
|
796
|
+
const result = validateUserFormatterConfig({
|
|
797
|
+
notifyAgent: true,
|
|
798
|
+
});
|
|
799
|
+
expect(result.issues).toHaveLength(1);
|
|
800
|
+
expect(result.issues[0].path).toBe("notifyAgent");
|
|
801
|
+
expect(result.issues[0].message).toContain("removed");
|
|
802
|
+
expect(result.config).not.toHaveProperty("notifyAgent");
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("emits a deprecation issue even for notifyAgent: false", () => {
|
|
806
|
+
const result = validateUserFormatterConfig({
|
|
807
|
+
notifyAgent: false,
|
|
808
|
+
});
|
|
809
|
+
expect(result.issues).toHaveLength(1);
|
|
810
|
+
expect(result.issues[0].path).toBe("notifyAgent");
|
|
811
|
+
expect(result.issues[0].message).toContain("removed");
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it("does not include notifyAgent in the validated config", () => {
|
|
815
|
+
const result = validateUserFormatterConfig({});
|
|
816
|
+
expect(result.config).not.toHaveProperty("notifyAgent");
|
|
817
|
+
});
|
|
818
|
+
});
|