@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
@@ -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", "$FILE"],
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", "$FILE"],
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", "$FILE"],
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.formatMode).toBe("prompt");
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", "$FILE"],
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", "$FILE"],
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
+ });