@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
|
@@ -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 {
|
|
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"
|
|
14
|
-
extensions: [".ts", ".md"],
|
|
15
|
+
command: ["prettier", "--write"],
|
|
15
16
|
},
|
|
16
17
|
markdownlint: {
|
|
17
|
-
command: ["markdownlint-cli2", "--fix"
|
|
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({
|
|
40
|
+
expect(result).toEqual({ groups: [] });
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
it("dedupes touched files
|
|
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.
|
|
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("
|
|
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
|
|
79
|
-
.files[0] as PromptAutoformatterResult["files"][number];
|
|
122
|
+
const group = result.groups[0];
|
|
80
123
|
|
|
81
|
-
expect(
|
|
82
|
-
expect(
|
|
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(
|
|
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
|
+
});
|