@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,146 @@
1
+ /**
2
+ * Shared harness for acceptance tests that drive the real `pi` CLI in
3
+ * `--mode rpc`.
4
+ *
5
+ * Resolves the `pi` binary from the locally-installed
6
+ * `@mariozechner/pi-coding-agent` devDependency rather than the global
7
+ * PATH so the suite runs whenever `pnpm install` has been done. Tests
8
+ * that depend on this harness use the `piAvailable` flag to skip when
9
+ * the binary is missing (e.g. stale `node_modules`).
10
+ */
11
+
12
+ import { spawn } from "node:child_process";
13
+ import { existsSync } from "node:fs";
14
+ import { resolve } from "node:path";
15
+
16
+ export const PI_BIN = resolve("node_modules/.bin/pi");
17
+ export const piAvailable = existsSync(PI_BIN);
18
+
19
+ export type RpcResponse = {
20
+ id?: string;
21
+ type: string;
22
+ command?: string;
23
+ success?: boolean;
24
+ data?: unknown;
25
+ };
26
+
27
+ export type RpcEvent = {
28
+ type: string;
29
+ [key: string]: unknown;
30
+ };
31
+
32
+ export type RpcSessionOptions = {
33
+ cwd: string;
34
+ commands: object[];
35
+ /**
36
+ * Additional `-e <path>` extension entrypoints loaded alongside the
37
+ * production `src/extension.ts`. Useful for mounting fixture extensions
38
+ * that drive specific code paths from RPC.
39
+ */
40
+ extraExtensions?: string[];
41
+ timeoutMs?: number;
42
+ env?: NodeJS.ProcessEnv;
43
+ /**
44
+ * Override the production extension path. Defaults to the resolved
45
+ * absolute path of `src/extension.ts`.
46
+ */
47
+ productionExtension?: string;
48
+ };
49
+
50
+ export type RpcSessionResult = {
51
+ responses: RpcResponse[];
52
+ events: RpcEvent[];
53
+ stderr: string;
54
+ exitCode: number | null;
55
+ };
56
+
57
+ const DEFAULT_PRODUCTION_EXTENSION = resolve("src", "extension.ts");
58
+
59
+ export async function runRpcSession(
60
+ options: RpcSessionOptions,
61
+ ): Promise<RpcSessionResult> {
62
+ const {
63
+ cwd,
64
+ commands,
65
+ extraExtensions = [],
66
+ timeoutMs = 10_000,
67
+ env,
68
+ productionExtension = DEFAULT_PRODUCTION_EXTENSION,
69
+ } = options;
70
+
71
+ const extensionArgs: string[] = [];
72
+ for (const path of [productionExtension, ...extraExtensions]) {
73
+ extensionArgs.push("-e", path);
74
+ }
75
+
76
+ return new Promise((resolvePromise, rejectPromise) => {
77
+ const child = spawn(
78
+ PI_BIN,
79
+ [
80
+ "--mode",
81
+ "rpc",
82
+ "--no-tools",
83
+ "--no-extensions",
84
+ "--no-session",
85
+ ...extensionArgs,
86
+ ],
87
+ {
88
+ cwd,
89
+ stdio: ["pipe", "pipe", "pipe"],
90
+ env: env ? { ...process.env, ...env } : process.env,
91
+ },
92
+ );
93
+
94
+ let stdout = "";
95
+ let stderr = "";
96
+
97
+ child.stdout.on("data", (chunk: Buffer) => {
98
+ stdout += chunk.toString("utf-8");
99
+ });
100
+ child.stderr.on("data", (chunk: Buffer) => {
101
+ stderr += chunk.toString("utf-8");
102
+ });
103
+
104
+ const timer = setTimeout(() => {
105
+ child.kill("SIGKILL");
106
+ rejectPromise(
107
+ new Error(
108
+ `pi rpc session timed out after ${timeoutMs}ms\nstdout: ${stdout}\nstderr: ${stderr}`,
109
+ ),
110
+ );
111
+ }, timeoutMs);
112
+
113
+ child.on("error", (error) => {
114
+ clearTimeout(timer);
115
+ rejectPromise(error);
116
+ });
117
+
118
+ child.on("close", (code) => {
119
+ clearTimeout(timer);
120
+ const messages = stdout
121
+ .split("\n")
122
+ .map((line) => line.trim())
123
+ .filter((line) => line.length > 0)
124
+ .map(
125
+ (line) =>
126
+ JSON.parse(line) as { type: string } & Record<string, unknown>,
127
+ );
128
+
129
+ const responses: RpcResponse[] = [];
130
+ const events: RpcEvent[] = [];
131
+ for (const message of messages) {
132
+ if (message.type === "response") {
133
+ responses.push(message as RpcResponse);
134
+ } else {
135
+ events.push(message as RpcEvent);
136
+ }
137
+ }
138
+ resolvePromise({ responses, events, stderr, exitCode: code });
139
+ });
140
+
141
+ for (const command of commands) {
142
+ child.stdin.write(`${JSON.stringify(command)}\n`);
143
+ }
144
+ child.stdin.end();
145
+ });
146
+ }
@@ -1,21 +1,21 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import type { BuiltinFormatter } from "../src/builtin-formatters.js";
2
7
  import type { CommandRunner } from "../src/formatter-executor.js";
3
8
  import type { FormatterConfig } from "../src/formatter-registry.js";
4
- import {
5
- PromptAutoformatter,
6
- type PromptAutoformatterResult,
7
- } from "../src/prompt-autoformatter.js";
9
+ import { PromptAutoformatter } from "../src/prompt-autoformatter.js";
8
10
 
9
11
  describe("PromptAutoformatter", () => {
10
12
  const config: FormatterConfig = {
11
13
  formatters: {
12
14
  prettier: {
13
- command: ["prettier", "--write", "$FILE"],
14
- extensions: [".ts", ".md"],
15
+ command: ["prettier", "--write"],
15
16
  },
16
17
  markdownlint: {
17
- command: ["markdownlint-cli2", "--fix", "$FILE"],
18
- extensions: [".md"],
18
+ command: ["markdownlint-cli2", "--fix"],
19
19
  },
20
20
  },
21
21
  chains: {
@@ -37,10 +37,10 @@ describe("PromptAutoformatter", () => {
37
37
  const result = await formatter.flushPrompt();
38
38
 
39
39
  expect(calls).toEqual([]);
40
- expect(result).toEqual({ files: [] });
40
+ expect(result).toEqual({ groups: [] });
41
41
  });
42
42
 
43
- it("dedupes touched files across prompt tool results", async () => {
43
+ it("dedupes touched files and runs each chain step once per group", async () => {
44
44
  const calls: string[] = [];
45
45
  const runner: CommandRunner = async (command, args) => {
46
46
  calls.push([command, ...args].join(" "));
@@ -57,16 +57,60 @@ describe("PromptAutoformatter", () => {
57
57
  "prettier --write /repo/docs/readme.md",
58
58
  "markdownlint-cli2 --fix /repo/docs/readme.md",
59
59
  ]);
60
- expect(result.files).toHaveLength(1);
60
+ expect(result.groups).toHaveLength(1);
61
+ expect(result.groups[0].files).toEqual(["/repo/docs/readme.md"]);
62
+ expect(result.groups[0].chain).toEqual(["prettier", "markdownlint"]);
61
63
  });
62
64
 
63
- it("returns formatter failures without throwing", async () => {
65
+ it("batches multiple files that share a chain into a single invocation per step", async () => {
66
+ const calls: Array<{ command: string; args: string[] }> = [];
67
+ const runner: CommandRunner = async (command, args) => {
68
+ calls.push({ command, args });
69
+ return { exitCode: 0 };
70
+ };
71
+
72
+ const formatter = new PromptAutoformatter("/repo", config, runner);
73
+ formatter.recordToolResult("write", { path: "docs/a.md" });
74
+ formatter.recordToolResult("write", { path: "docs/b.md" });
75
+
76
+ const result = await formatter.flushPrompt();
77
+
78
+ expect(calls).toEqual([
79
+ {
80
+ command: "prettier",
81
+ args: ["--write", "/repo/docs/a.md", "/repo/docs/b.md"],
82
+ },
83
+ {
84
+ command: "markdownlint-cli2",
85
+ args: ["--fix", "/repo/docs/a.md", "/repo/docs/b.md"],
86
+ },
87
+ ]);
88
+ expect(result.groups).toHaveLength(1);
89
+ expect(result.groups[0].files).toEqual([
90
+ "/repo/docs/a.md",
91
+ "/repo/docs/b.md",
92
+ ]);
93
+ });
94
+
95
+ it("produces one group per distinct chain", async () => {
96
+ const runner: CommandRunner = async () => ({ exitCode: 0 });
97
+
98
+ const formatter = new PromptAutoformatter("/repo", config, runner);
99
+ formatter.recordToolResult("write", { path: "src/index.ts" });
100
+ formatter.recordToolResult("write", { path: "docs/readme.md" });
101
+
102
+ const result = await formatter.flushPrompt();
103
+
104
+ expect(result.groups.map((g) => g.chain)).toEqual([
105
+ ["prettier"],
106
+ ["prettier", "markdownlint"],
107
+ ]);
108
+ });
109
+
110
+ it("returns formatter failures per batch without throwing", async () => {
64
111
  const runner: CommandRunner = async (command) => {
65
112
  if (command === "prettier") {
66
- return {
67
- exitCode: 2,
68
- stderr: "parse error",
69
- };
113
+ return { exitCode: 2, stderr: "parse error" };
70
114
  }
71
115
  return { exitCode: 0 };
72
116
  };
@@ -75,19 +119,268 @@ describe("PromptAutoformatter", () => {
75
119
  formatter.recordToolResult("write", { path: "docs/readme.md" });
76
120
 
77
121
  const result = await formatter.flushPrompt();
78
- const firstFile = result
79
- .files[0] as PromptAutoformatterResult["files"][number];
122
+ const group = result.groups[0];
80
123
 
81
- expect(firstFile.path).toBe("/repo/docs/readme.md");
82
- expect(firstFile.runs[0]).toMatchObject({
124
+ expect(group.files).toEqual(["/repo/docs/readme.md"]);
125
+ expect(group.runs[0]).toMatchObject({
83
126
  formatterName: "prettier",
84
127
  success: false,
85
128
  exitCode: 2,
129
+ files: ["/repo/docs/readme.md"],
86
130
  });
87
- expect(firstFile.runs[1]).toMatchObject({
131
+ expect(group.runs[1]).toMatchObject({
88
132
  formatterName: "markdownlint",
89
133
  success: true,
90
134
  exitCode: 0,
91
135
  });
92
136
  });
137
+
138
+ it("shares the PATH probe cache across all chain groups in a single flush", async () => {
139
+ const fallbackConfig: FormatterConfig = {
140
+ formatters: {
141
+ biome: { command: ["biome", "format", "--write"] },
142
+ prettier: { command: ["prettier", "--write"] },
143
+ },
144
+ chains: {
145
+ ".ts": [{ fallback: ["biome", "prettier"] }],
146
+ ".tsx": [{ fallback: ["biome", "prettier"] }],
147
+ // Distinct chain so a second group is created.
148
+ ".js": [{ fallback: ["biome", "prettier"] }, "prettier"],
149
+ },
150
+ };
151
+ const runner: CommandRunner = async () => ({ exitCode: 0 });
152
+ const probeCalls: string[] = [];
153
+ const probe = (cmd: string): boolean => {
154
+ probeCalls.push(cmd);
155
+ return cmd === "prettier";
156
+ };
157
+
158
+ const formatter = new PromptAutoformatter("/repo", fallbackConfig, runner, {
159
+ commandProbe: probe,
160
+ });
161
+ formatter.addTouchedPath("/repo/a.ts");
162
+ formatter.addTouchedPath("/repo/b.tsx");
163
+ formatter.addTouchedPath("/repo/c.js");
164
+
165
+ const result = await formatter.flushPrompt();
166
+ expect(result.groups.length).toBeGreaterThanOrEqual(2);
167
+ // Each unique command name probed at most once across the whole flush.
168
+ const counts = probeCalls.reduce<Record<string, number>>((acc, cmd) => {
169
+ acc[cmd] = (acc[cmd] ?? 0) + 1;
170
+ return acc;
171
+ }, {});
172
+ expect(counts.biome ?? 0).toBeLessThanOrEqual(1);
173
+ expect(counts.prettier ?? 0).toBeLessThanOrEqual(1);
174
+ });
175
+
176
+ describe("with a wildcard chain", () => {
177
+ function fakeTreefmt(
178
+ unhandledPredicate: (file: string) => boolean,
179
+ ): BuiltinFormatter {
180
+ return {
181
+ name: "treefmt",
182
+ async discoverRoot() {
183
+ return "/repo";
184
+ },
185
+ buildCommand(root, files) {
186
+ return {
187
+ command: ["treefmt", "--", ...files],
188
+ cwd: root,
189
+ };
190
+ },
191
+ partitionUnhandled(_run, files) {
192
+ const unhandled = files.filter(unhandledPredicate);
193
+ const handled = files.filter((f) => !unhandledPredicate(f));
194
+ return { handled, unhandled, treatAsSkip: false };
195
+ },
196
+ };
197
+ }
198
+
199
+ it("runs the wildcard chain first across all touched files and skips per-extension chains for handled files", async () => {
200
+ const calls: Array<{ command: string; args: string[] }> = [];
201
+ const runner: CommandRunner = async (command, args) => {
202
+ calls.push({ command, args });
203
+ return { exitCode: 0 };
204
+ };
205
+ const builtinConfig: FormatterConfig = {
206
+ formatters: {
207
+ prettier: { command: ["prettier", "--write"] },
208
+ },
209
+ chains: {
210
+ "*": ["treefmt"],
211
+ ".ts": ["prettier"],
212
+ ".bin": ["prettier"],
213
+ },
214
+ };
215
+ // Patch the global treefmt built-in's hooks for this test.
216
+ const { BUILTIN_FORMATTERS } = await import(
217
+ "../src/builtin-formatters.js"
218
+ );
219
+ const original = { ...BUILTIN_FORMATTERS.treefmt };
220
+ const fake = fakeTreefmt((f) => f.endsWith(".bin"));
221
+ BUILTIN_FORMATTERS.treefmt.discoverRoot = fake.discoverRoot;
222
+ BUILTIN_FORMATTERS.treefmt.buildCommand = fake.buildCommand;
223
+ BUILTIN_FORMATTERS.treefmt.partitionUnhandled = fake.partitionUnhandled;
224
+ try {
225
+ const formatter = new PromptAutoformatter(
226
+ "/repo",
227
+ builtinConfig,
228
+ runner,
229
+ );
230
+ formatter.addTouchedPath("/repo/a.ts");
231
+ formatter.addTouchedPath("/repo/b.bin");
232
+
233
+ const result = await formatter.flushPrompt();
234
+
235
+ // treefmt invoked once with both files; prettier runs only on the
236
+ // unhandled .bin file (per-ext chain backstops the wildcard skip).
237
+ expect(calls[0]).toEqual({
238
+ command: "treefmt",
239
+ args: ["--", "/repo/a.ts", "/repo/b.bin"],
240
+ });
241
+ const prettierCalls = calls.filter((c) => c.command === "prettier");
242
+ expect(prettierCalls).toHaveLength(1);
243
+ expect(prettierCalls[0]?.args).toEqual(["--write", "/repo/b.bin"]);
244
+ // Sanity: groups recorded.
245
+ expect(result.groups[0].chain).toEqual(["treefmt"]);
246
+ } finally {
247
+ Object.assign(BUILTIN_FORMATTERS.treefmt, original);
248
+ }
249
+ });
250
+
251
+ it("removes wildcard-handled files from per-extension groups entirely", async () => {
252
+ const calls: Array<{ command: string; args: string[] }> = [];
253
+ const runner: CommandRunner = async (command, args) => {
254
+ calls.push({ command, args });
255
+ return { exitCode: 0 };
256
+ };
257
+ const builtinConfig: FormatterConfig = {
258
+ formatters: {
259
+ prettier: { command: ["prettier", "--write"] },
260
+ },
261
+ chains: {
262
+ "*": ["treefmt"],
263
+ ".ts": ["prettier"],
264
+ },
265
+ };
266
+ const { BUILTIN_FORMATTERS } = await import(
267
+ "../src/builtin-formatters.js"
268
+ );
269
+ const original = { ...BUILTIN_FORMATTERS.treefmt };
270
+ // Mark every .ts file as handled.
271
+ const fake = fakeTreefmt(() => false);
272
+ BUILTIN_FORMATTERS.treefmt.discoverRoot = fake.discoverRoot;
273
+ BUILTIN_FORMATTERS.treefmt.buildCommand = fake.buildCommand;
274
+ BUILTIN_FORMATTERS.treefmt.partitionUnhandled = fake.partitionUnhandled;
275
+ try {
276
+ const formatter = new PromptAutoformatter(
277
+ "/repo",
278
+ builtinConfig,
279
+ runner,
280
+ );
281
+ formatter.addTouchedPath("/repo/a.ts");
282
+ formatter.addTouchedPath("/repo/b.ts");
283
+
284
+ await formatter.flushPrompt();
285
+
286
+ // treefmt runs once. prettier should NOT run because the wildcard
287
+ // claimed all files.
288
+ expect(calls.map((c) => c.command)).toEqual(["treefmt"]);
289
+ } finally {
290
+ Object.assign(BUILTIN_FORMATTERS.treefmt, original);
291
+ }
292
+ });
293
+ });
294
+
295
+ describe("changedFiles detection", () => {
296
+ let workDir: string;
297
+
298
+ beforeEach(() => {
299
+ workDir = mkdtempSync(join(tmpdir(), "pi-autoformat-change-"));
300
+ });
301
+
302
+ afterEach(() => {
303
+ rmSync(workDir, { recursive: true, force: true });
304
+ });
305
+
306
+ it("populates changedFiles when the formatter modifies file content", async () => {
307
+ const filePath = join(workDir, "a.ts");
308
+ writeFileSync(filePath, "const x=1;");
309
+
310
+ const runner: CommandRunner = async (_command, args) => {
311
+ // Simulate a formatter that rewrites the file
312
+ for (const arg of args) {
313
+ if (arg.endsWith(".ts")) {
314
+ writeFileSync(arg, "const x = 1;");
315
+ }
316
+ }
317
+ return { exitCode: 0 };
318
+ };
319
+
320
+ const cfg: FormatterConfig = {
321
+ formatters: { fmt: { command: ["fmt"] } },
322
+ chains: { ".ts": ["fmt"] },
323
+ };
324
+
325
+ const formatter = new PromptAutoformatter(workDir, cfg, runner);
326
+ formatter.addTouchedPath(filePath);
327
+
328
+ const result = await formatter.flushPrompt();
329
+
330
+ expect(result.groups).toHaveLength(1);
331
+ expect(result.groups[0].changedFiles).toEqual([filePath]);
332
+ });
333
+
334
+ it("leaves changedFiles empty when the formatter does not change content", async () => {
335
+ const filePath = join(workDir, "b.ts");
336
+ writeFileSync(filePath, "const x = 1;");
337
+
338
+ const runner: CommandRunner = async () => {
339
+ // Formatter is a no-op
340
+ return { exitCode: 0 };
341
+ };
342
+
343
+ const cfg: FormatterConfig = {
344
+ formatters: { fmt: { command: ["fmt"] } },
345
+ chains: { ".ts": ["fmt"] },
346
+ };
347
+
348
+ const formatter = new PromptAutoformatter(workDir, cfg, runner);
349
+ formatter.addTouchedPath(filePath);
350
+
351
+ const result = await formatter.flushPrompt();
352
+
353
+ expect(result.groups).toHaveLength(1);
354
+ expect(result.groups[0].changedFiles).toEqual([]);
355
+ });
356
+
357
+ it("excludes deleted files from changedFiles", async () => {
358
+ const filePath = join(workDir, "c.ts");
359
+ writeFileSync(filePath, "delete me");
360
+
361
+ const runner: CommandRunner = async (_command, args) => {
362
+ // Simulate formatter that deletes the file
363
+ const { unlinkSync } = await import("node:fs");
364
+ for (const arg of args) {
365
+ if (arg.endsWith(".ts")) {
366
+ unlinkSync(arg);
367
+ }
368
+ }
369
+ return { exitCode: 0 };
370
+ };
371
+
372
+ const cfg: FormatterConfig = {
373
+ formatters: { fmt: { command: ["fmt"] } },
374
+ chains: { ".ts": ["fmt"] },
375
+ };
376
+
377
+ const formatter = new PromptAutoformatter(workDir, cfg, runner);
378
+ formatter.addTouchedPath(filePath);
379
+
380
+ const result = await formatter.flushPrompt();
381
+
382
+ expect(result.groups).toHaveLength(1);
383
+ expect(result.groups[0].changedFiles).toEqual([]);
384
+ });
385
+ });
93
386
  });
@@ -0,0 +1,149 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ const schemaPath = join(process.cwd(), "schemas", "pi-autoformat.schema.json");
7
+
8
+ type FormatterOutputSchema = {
9
+ type?: string;
10
+ additionalProperties?: boolean;
11
+ properties?: {
12
+ onFailure?: { type?: string; enum?: string[] };
13
+ maxBytes?: { type?: string; minimum?: number };
14
+ maxLines?: { type?: string; minimum?: number };
15
+ };
16
+ };
17
+
18
+ type SchemaShape = {
19
+ $defs?: {
20
+ formatterDefinition?: {
21
+ properties?: Record<string, unknown>;
22
+ };
23
+ chainStep?: unknown;
24
+ formatterOutputReportingConfig?: FormatterOutputSchema;
25
+ };
26
+ additionalProperties?: boolean;
27
+ properties?: {
28
+ chains?: {
29
+ propertyNames?: { pattern?: string };
30
+ additionalProperties?: {
31
+ type?: string;
32
+ items?: unknown;
33
+ };
34
+ };
35
+ formatterOutput?: FormatterOutputSchema | { $ref?: string };
36
+ };
37
+ };
38
+
39
+ describe("pi-autoformat.schema.json", () => {
40
+ const schema: SchemaShape = JSON.parse(readFileSync(schemaPath, "utf8"));
41
+
42
+ it("does not declare a notifyAgent property", () => {
43
+ expect(schema.properties).not.toHaveProperty("notifyAgent");
44
+ });
45
+
46
+ it("does not declare an extensions property on formatterDefinition", () => {
47
+ const properties = schema.$defs?.formatterDefinition?.properties ?? {};
48
+ expect(properties).not.toHaveProperty("extensions");
49
+ });
50
+
51
+ it("still declares command on formatterDefinition", () => {
52
+ const properties = schema.$defs?.formatterDefinition?.properties ?? {};
53
+ expect(properties).toHaveProperty("command");
54
+ });
55
+
56
+ describe("chains key pattern", () => {
57
+ it("accepts dotted extension keys and the literal '*' wildcard", () => {
58
+ const pattern = schema.properties?.chains?.propertyNames?.pattern;
59
+ expect(pattern).toBeDefined();
60
+ const re = new RegExp(pattern as string);
61
+ expect(re.test(".md")).toBe(true);
62
+ expect(re.test(".tsx")).toBe(true);
63
+ expect(re.test("*")).toBe(true);
64
+ });
65
+
66
+ it("rejects bare names and empty strings", () => {
67
+ const pattern = schema.properties?.chains?.propertyNames?.pattern;
68
+ const re = new RegExp(pattern as string);
69
+ expect(re.test("md")).toBe(false);
70
+ expect(re.test("")).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe("chains step shape", () => {
75
+ it("declares chains items as a oneOf of string and fallback object", () => {
76
+ const items = schema.properties?.chains?.additionalProperties?.items as
77
+ | { oneOf?: unknown[] }
78
+ | undefined;
79
+ expect(items).toBeDefined();
80
+ expect(Array.isArray(items?.oneOf)).toBe(true);
81
+ expect(items?.oneOf?.length).toBe(2);
82
+ });
83
+
84
+ it("includes a string variant for chain steps", () => {
85
+ const items = schema.properties?.chains?.additionalProperties?.items as
86
+ | { oneOf?: Array<{ type?: string; minLength?: number }> }
87
+ | undefined;
88
+ const stringVariant = items?.oneOf?.find((v) => v?.type === "string");
89
+ expect(stringVariant).toBeDefined();
90
+ expect(stringVariant?.minLength).toBe(1);
91
+ });
92
+
93
+ it("includes a fallback object variant with a non-empty string array", () => {
94
+ const items = schema.properties?.chains?.additionalProperties?.items as
95
+ | { oneOf?: Array<Record<string, unknown>> }
96
+ | undefined;
97
+ const fallbackVariant = items?.oneOf?.find((v) => v?.type === "object") as
98
+ | {
99
+ type?: string;
100
+ additionalProperties?: boolean;
101
+ required?: string[];
102
+ properties?: {
103
+ fallback?: {
104
+ type?: string;
105
+ minItems?: number;
106
+ items?: { type?: string; minLength?: number };
107
+ };
108
+ };
109
+ }
110
+ | undefined;
111
+ expect(fallbackVariant).toBeDefined();
112
+ expect(fallbackVariant?.additionalProperties).toBe(false);
113
+ expect(fallbackVariant?.required).toEqual(["fallback"]);
114
+ const fallback = fallbackVariant?.properties?.fallback;
115
+ expect(fallback?.type).toBe("array");
116
+ expect(fallback?.minItems).toBe(1);
117
+ expect(fallback?.items?.type).toBe("string");
118
+ expect(fallback?.items?.minLength).toBe(1);
119
+ });
120
+ });
121
+
122
+ describe("formatterOutput", () => {
123
+ it("declares formatterOutput as a top-level property", () => {
124
+ expect(schema.properties).toHaveProperty("formatterOutput");
125
+ });
126
+
127
+ it("forbids unknown sub-keys on formatterOutput", () => {
128
+ const def = schema.$defs?.formatterOutputReportingConfig;
129
+ expect(def?.type).toBe("object");
130
+ expect(def?.additionalProperties).toBe(false);
131
+ });
132
+
133
+ it("restricts onFailure to none/stderr/both", () => {
134
+ const onFailure =
135
+ schema.$defs?.formatterOutputReportingConfig?.properties?.onFailure;
136
+ expect(onFailure?.type).toBe("string");
137
+ expect(onFailure?.enum).toEqual(["none", "stderr", "both"]);
138
+ });
139
+
140
+ it("requires non-negative integer caps for maxBytes and maxLines", () => {
141
+ const props =
142
+ schema.$defs?.formatterOutputReportingConfig?.properties ?? {};
143
+ expect(props.maxBytes?.type).toBe("integer");
144
+ expect(props.maxBytes?.minimum).toBe(0);
145
+ expect(props.maxLines?.type).toBe("integer");
146
+ expect(props.maxLines?.minimum).toBe(0);
147
+ });
148
+ });
149
+ });