@gotgenes/pi-autoformat 0.1.0
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 +35 -0
- package/.github/workflows/release-please.yml +22 -0
- package/.markdownlint-cli2.yaml +3 -0
- package/.pi/extensions/pi-autoformat/config.json +28 -0
- package/.release-please-manifest.json +3 -0
- package/AGENTS.md +71 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/biome.json +17 -0
- package/docs/configuration.md +177 -0
- package/docs/plans/0001-initial-implementation-plan.md +402 -0
- package/package.json +32 -0
- package/prek.toml +24 -0
- package/release-please-config.json +22 -0
- package/schemas/pi-autoformat.schema.json +87 -0
- package/src/config-loader.ts +520 -0
- package/src/extension.ts +374 -0
- package/src/formatter-config.ts +80 -0
- package/src/formatter-executor.ts +68 -0
- package/src/formatter-registry.ts +61 -0
- package/src/index.ts +42 -0
- package/src/prompt-autoformatter.ts +58 -0
- package/src/touched-files-queue.ts +46 -0
- package/test/config-loader.test.ts +199 -0
- package/test/extension.test.ts +364 -0
- package/test/formatter-config.test.ts +64 -0
- package/test/formatter-executor.test.ts +82 -0
- package/test/formatter-registry.test.ts +75 -0
- package/test/prompt-autoformatter.test.ts +93 -0
- package/test/smoke.test.ts +9 -0
- package/test/touched-files-queue.test.ts +46 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
type ToolResultPayload = {
|
|
4
|
+
path?: unknown;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const MUTATION_TOOLS = new Set(["write", "edit"]);
|
|
8
|
+
|
|
9
|
+
export class TouchedFilesQueue {
|
|
10
|
+
private readonly cwd: string;
|
|
11
|
+
private readonly touchedFiles = new Set<string>();
|
|
12
|
+
|
|
13
|
+
constructor(cwd: string) {
|
|
14
|
+
this.cwd = cwd;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
recordToolResult(toolName: string, payload: unknown): void {
|
|
18
|
+
if (!MUTATION_TOOLS.has(toolName)) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!isToolResultPayload(payload) || typeof payload.path !== "string") {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.touchedFiles.add(normalizePath(this.cwd, payload.path));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
flush(): string[] {
|
|
30
|
+
const files = [...this.touchedFiles];
|
|
31
|
+
this.touchedFiles.clear();
|
|
32
|
+
return files;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isToolResultPayload(value: unknown): value is ToolResultPayload {
|
|
37
|
+
return typeof value === "object" && value !== null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizePath(cwd: string, filePath: string): string {
|
|
41
|
+
if (path.isAbsolute(filePath)) {
|
|
42
|
+
return path.normalize(filePath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return path.normalize(path.resolve(cwd, filePath));
|
|
46
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
getGlobalConfigPath,
|
|
9
|
+
getProjectConfigPath,
|
|
10
|
+
loadAutoformatConfig,
|
|
11
|
+
validateUserFormatterConfig,
|
|
12
|
+
} from "../src/config-loader.js";
|
|
13
|
+
|
|
14
|
+
describe("validateUserFormatterConfig", () => {
|
|
15
|
+
it("accepts $schema and known config fields", () => {
|
|
16
|
+
const result = validateUserFormatterConfig({
|
|
17
|
+
$schema: "https://example.com/schema.json",
|
|
18
|
+
formatMode: "prompt",
|
|
19
|
+
commandTimeoutMs: 5000,
|
|
20
|
+
hideSummariesInTui: true,
|
|
21
|
+
formatters: {
|
|
22
|
+
prettier: {
|
|
23
|
+
command: ["prettier", "--write", "$FILE"],
|
|
24
|
+
extensions: [".TS", ".md"],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
chains: {
|
|
28
|
+
".MD": ["prettier"],
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(result.issues).toEqual([]);
|
|
33
|
+
expect(result.config).toEqual({
|
|
34
|
+
formatMode: "prompt",
|
|
35
|
+
commandTimeoutMs: 5000,
|
|
36
|
+
hideSummariesInTui: true,
|
|
37
|
+
formatters: {
|
|
38
|
+
prettier: {
|
|
39
|
+
command: ["prettier", "--write", "$FILE"],
|
|
40
|
+
extensions: [".ts", ".md"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
chains: {
|
|
44
|
+
".md": ["prettier"],
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("reports invalid fields and returns only valid fragments", () => {
|
|
50
|
+
const result = validateUserFormatterConfig({
|
|
51
|
+
formatMode: "later",
|
|
52
|
+
commandTimeoutMs: 0,
|
|
53
|
+
unexpected: true,
|
|
54
|
+
formatters: {
|
|
55
|
+
prettier: {
|
|
56
|
+
command: ["prettier", "--write", "$FILE"],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(result.config).toEqual({
|
|
62
|
+
formatters: {},
|
|
63
|
+
});
|
|
64
|
+
expect(result.issues.map((issue) => issue.path)).toEqual([
|
|
65
|
+
"formatMode",
|
|
66
|
+
"commandTimeoutMs",
|
|
67
|
+
"unexpected",
|
|
68
|
+
"formatters.prettier.extensions",
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("loadAutoformatConfig", () => {
|
|
74
|
+
it("uses default config when no files exist", () => {
|
|
75
|
+
const root = mkdtempSync(join(tmpdir(), "pi-autoformat-config-"));
|
|
76
|
+
const cwd = join(root, "project");
|
|
77
|
+
const agentDir = join(root, "agent");
|
|
78
|
+
mkdirSync(cwd, { recursive: true });
|
|
79
|
+
mkdirSync(agentDir, { recursive: true });
|
|
80
|
+
|
|
81
|
+
const result = loadAutoformatConfig({ cwd, agentDir });
|
|
82
|
+
|
|
83
|
+
expect(result.config.formatMode).toBe("prompt");
|
|
84
|
+
expect(result.config.commandTimeoutMs).toBe(10000);
|
|
85
|
+
expect(result.config.hideSummariesInTui).toBe(false);
|
|
86
|
+
expect(result.issues).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("merges global and project config with project precedence", () => {
|
|
90
|
+
const root = mkdtempSync(join(tmpdir(), "pi-autoformat-config-"));
|
|
91
|
+
const cwd = join(root, "project");
|
|
92
|
+
const agentDir = join(root, "agent");
|
|
93
|
+
mkdirSync(cwd, { recursive: true });
|
|
94
|
+
mkdirSync(agentDir, { recursive: true });
|
|
95
|
+
|
|
96
|
+
mkdirSync(join(agentDir, "extensions", "pi-autoformat"), {
|
|
97
|
+
recursive: true,
|
|
98
|
+
});
|
|
99
|
+
writeFileSync(
|
|
100
|
+
getGlobalConfigPath(agentDir),
|
|
101
|
+
JSON.stringify(
|
|
102
|
+
{
|
|
103
|
+
formatMode: "tool",
|
|
104
|
+
commandTimeoutMs: 5000,
|
|
105
|
+
formatters: {
|
|
106
|
+
prettier: {
|
|
107
|
+
command: ["pnpm", "exec", "prettier", "--write", "$FILE"],
|
|
108
|
+
extensions: [".ts", ".md"],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
chains: {
|
|
112
|
+
".md": ["prettier"],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
null,
|
|
116
|
+
2,
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
mkdirSync(join(cwd, ".pi", "extensions", "pi-autoformat"), {
|
|
121
|
+
recursive: true,
|
|
122
|
+
});
|
|
123
|
+
writeFileSync(
|
|
124
|
+
getProjectConfigPath(cwd),
|
|
125
|
+
JSON.stringify(
|
|
126
|
+
{
|
|
127
|
+
formatMode: "prompt",
|
|
128
|
+
hideSummariesInTui: true,
|
|
129
|
+
formatters: {
|
|
130
|
+
"markdownlint-cli2": {
|
|
131
|
+
command: ["pnpm", "exec", "markdownlint-cli2", "--fix", "$FILE"],
|
|
132
|
+
extensions: [".md"],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
chains: {
|
|
136
|
+
".md": ["prettier", "markdownlint-cli2"],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
null,
|
|
140
|
+
2,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const result = loadAutoformatConfig({ cwd, agentDir });
|
|
145
|
+
|
|
146
|
+
expect(result.config.formatMode).toBe("prompt");
|
|
147
|
+
expect(result.config.commandTimeoutMs).toBe(5000);
|
|
148
|
+
expect(result.config.hideSummariesInTui).toBe(true);
|
|
149
|
+
expect(result.config.formatters.prettier?.command).toEqual([
|
|
150
|
+
"pnpm",
|
|
151
|
+
"exec",
|
|
152
|
+
"prettier",
|
|
153
|
+
"--write",
|
|
154
|
+
"$FILE",
|
|
155
|
+
]);
|
|
156
|
+
expect(result.config.formatters["markdownlint-cli2"]?.command).toEqual([
|
|
157
|
+
"pnpm",
|
|
158
|
+
"exec",
|
|
159
|
+
"markdownlint-cli2",
|
|
160
|
+
"--fix",
|
|
161
|
+
"$FILE",
|
|
162
|
+
]);
|
|
163
|
+
expect(result.config.chains[".md"]).toEqual([
|
|
164
|
+
"prettier",
|
|
165
|
+
"markdownlint-cli2",
|
|
166
|
+
]);
|
|
167
|
+
expect(result.issues).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("reports parse and validation errors without throwing", () => {
|
|
171
|
+
const root = mkdtempSync(join(tmpdir(), "pi-autoformat-config-"));
|
|
172
|
+
const cwd = join(root, "project");
|
|
173
|
+
const agentDir = join(root, "agent");
|
|
174
|
+
mkdirSync(cwd, { recursive: true });
|
|
175
|
+
mkdirSync(agentDir, { recursive: true });
|
|
176
|
+
|
|
177
|
+
mkdirSync(join(agentDir, "extensions", "pi-autoformat"), {
|
|
178
|
+
recursive: true,
|
|
179
|
+
});
|
|
180
|
+
writeFileSync(getGlobalConfigPath(agentDir), "{not json\n");
|
|
181
|
+
|
|
182
|
+
mkdirSync(join(cwd, ".pi", "extensions", "pi-autoformat"), {
|
|
183
|
+
recursive: true,
|
|
184
|
+
});
|
|
185
|
+
writeFileSync(
|
|
186
|
+
getProjectConfigPath(cwd),
|
|
187
|
+
JSON.stringify({
|
|
188
|
+
hideSummariesInTui: "yes",
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const result = loadAutoformatConfig({ cwd, agentDir });
|
|
193
|
+
|
|
194
|
+
expect(result.config.hideSummariesInTui).toBe(false);
|
|
195
|
+
expect(result.issues).toHaveLength(2);
|
|
196
|
+
expect(result.issues[0]?.sourcePath).toBe(getGlobalConfigPath(agentDir));
|
|
197
|
+
expect(result.issues[1]?.path).toBe("hideSummariesInTui");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { LoadConfigResult } from "../src/config-loader.js";
|
|
4
|
+
import { createAutoformatExtension } from "../src/extension.js";
|
|
5
|
+
import { createFormatterConfig } from "../src/formatter-config.js";
|
|
6
|
+
import type { PromptAutoformatterResult } from "../src/prompt-autoformatter.js";
|
|
7
|
+
|
|
8
|
+
type Handler = (event: unknown, ctx: TestContext) => void | Promise<void>;
|
|
9
|
+
|
|
10
|
+
type EventName =
|
|
11
|
+
| "session_start"
|
|
12
|
+
| "tool_result"
|
|
13
|
+
| "agent_end"
|
|
14
|
+
| "session_shutdown";
|
|
15
|
+
|
|
16
|
+
type TestContext = {
|
|
17
|
+
cwd: string;
|
|
18
|
+
hasUI: boolean;
|
|
19
|
+
ui: {
|
|
20
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
class TestPi {
|
|
25
|
+
private readonly handlers = new Map<EventName, Handler[]>();
|
|
26
|
+
|
|
27
|
+
on(eventName: EventName, handler: Handler): void {
|
|
28
|
+
const existing = this.handlers.get(eventName) ?? [];
|
|
29
|
+
existing.push(handler);
|
|
30
|
+
this.handlers.set(eventName, existing);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async emit(
|
|
34
|
+
eventName: EventName,
|
|
35
|
+
event: unknown,
|
|
36
|
+
ctx: TestContext,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
for (const handler of this.handlers.get(eventName) ?? []) {
|
|
39
|
+
await handler(event, ctx);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createLoadResult(
|
|
45
|
+
formatMode: "tool" | "prompt" | "session",
|
|
46
|
+
): LoadConfigResult {
|
|
47
|
+
return {
|
|
48
|
+
config: createFormatterConfig({ formatMode }),
|
|
49
|
+
globalConfigPath: "/global/config.json",
|
|
50
|
+
projectConfigPath: "/project/config.json",
|
|
51
|
+
issues: [],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createContext(overrides?: Partial<TestContext>): TestContext {
|
|
56
|
+
return {
|
|
57
|
+
cwd: "/repo",
|
|
58
|
+
hasUI: true,
|
|
59
|
+
ui: {
|
|
60
|
+
notify: vi.fn(),
|
|
61
|
+
},
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createFlushResult(): PromptAutoformatterResult {
|
|
67
|
+
return {
|
|
68
|
+
files: [
|
|
69
|
+
{
|
|
70
|
+
path: "/repo/src/example.ts",
|
|
71
|
+
runs: [],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("createAutoformatExtension", () => {
|
|
78
|
+
it("reports interactive success summaries with touched file paths", async () => {
|
|
79
|
+
const pi = new TestPi();
|
|
80
|
+
const notify = vi.fn();
|
|
81
|
+
const ctx = createContext({
|
|
82
|
+
ui: {
|
|
83
|
+
notify,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
createAutoformatExtension(pi, {
|
|
88
|
+
loadConfig: vi.fn().mockReturnValue(createLoadResult("prompt")),
|
|
89
|
+
createAutoformatter: vi.fn().mockReturnValue({
|
|
90
|
+
recordToolResult: vi.fn(),
|
|
91
|
+
flushPrompt: vi.fn().mockResolvedValue({
|
|
92
|
+
files: [
|
|
93
|
+
{
|
|
94
|
+
path: "/repo/src/example.ts",
|
|
95
|
+
runs: [
|
|
96
|
+
{
|
|
97
|
+
formatterName: "prettier",
|
|
98
|
+
command: [],
|
|
99
|
+
success: true,
|
|
100
|
+
exitCode: 0,
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
path: "/repo/README.md",
|
|
106
|
+
runs: [
|
|
107
|
+
{
|
|
108
|
+
formatterName: "prettier",
|
|
109
|
+
command: [],
|
|
110
|
+
success: true,
|
|
111
|
+
exitCode: 0,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
}),
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await pi.emit("session_start", {}, ctx);
|
|
121
|
+
await pi.emit("agent_end", {}, ctx);
|
|
122
|
+
|
|
123
|
+
expect(notify).toHaveBeenCalledWith(
|
|
124
|
+
"Autoformatted 2 files: /repo/src/example.ts, /repo/README.md",
|
|
125
|
+
"info",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("hides interactive success summaries when configured", async () => {
|
|
130
|
+
const pi = new TestPi();
|
|
131
|
+
const notify = vi.fn();
|
|
132
|
+
const ctx = createContext({
|
|
133
|
+
ui: {
|
|
134
|
+
notify,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
createAutoformatExtension(pi, {
|
|
139
|
+
loadConfig: vi.fn().mockReturnValue({
|
|
140
|
+
...createLoadResult("prompt"),
|
|
141
|
+
config: createFormatterConfig({
|
|
142
|
+
formatMode: "prompt",
|
|
143
|
+
hideSummariesInTui: true,
|
|
144
|
+
}),
|
|
145
|
+
}),
|
|
146
|
+
createAutoformatter: vi.fn().mockReturnValue({
|
|
147
|
+
recordToolResult: vi.fn(),
|
|
148
|
+
flushPrompt: vi.fn().mockResolvedValue(createFlushResult()),
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await pi.emit("session_start", {}, ctx);
|
|
153
|
+
await pi.emit("agent_end", {}, ctx);
|
|
154
|
+
|
|
155
|
+
expect(notify).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("reports non-interactive formatter failures via console warnings", async () => {
|
|
159
|
+
const pi = new TestPi();
|
|
160
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
161
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
162
|
+
const ctx = createContext({ hasUI: false });
|
|
163
|
+
|
|
164
|
+
createAutoformatExtension(pi, {
|
|
165
|
+
loadConfig: vi.fn().mockReturnValue(createLoadResult("prompt")),
|
|
166
|
+
createAutoformatter: vi.fn().mockReturnValue({
|
|
167
|
+
recordToolResult: vi.fn(),
|
|
168
|
+
flushPrompt: vi.fn().mockResolvedValue({
|
|
169
|
+
files: [
|
|
170
|
+
{
|
|
171
|
+
path: "/repo/README.md",
|
|
172
|
+
runs: [
|
|
173
|
+
{
|
|
174
|
+
formatterName: "prettier",
|
|
175
|
+
command: ["prettier", "--write", "/repo/README.md"],
|
|
176
|
+
success: false,
|
|
177
|
+
exitCode: 2,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
formatterName: "markdownlint-cli2",
|
|
181
|
+
command: ["markdownlint-cli2", "--fix", "/repo/README.md"],
|
|
182
|
+
success: false,
|
|
183
|
+
exitCode: 1,
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
}),
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await pi.emit("session_start", {}, ctx);
|
|
193
|
+
await pi.emit("agent_end", {}, ctx);
|
|
194
|
+
|
|
195
|
+
expect(warn).toHaveBeenCalledWith(
|
|
196
|
+
"[pi-autoformat] Formatter failures in 1 file (2 failed runs):\n/repo/README.md: prettier (exit 2), markdownlint-cli2 (exit 1)",
|
|
197
|
+
);
|
|
198
|
+
expect(log).not.toHaveBeenCalled();
|
|
199
|
+
|
|
200
|
+
warn.mockRestore();
|
|
201
|
+
log.mockRestore();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("reports non-interactive config issues via console warnings", async () => {
|
|
205
|
+
const pi = new TestPi();
|
|
206
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
207
|
+
const ctx = createContext({ hasUI: false });
|
|
208
|
+
|
|
209
|
+
createAutoformatExtension(pi, {
|
|
210
|
+
loadConfig: vi.fn().mockReturnValue({
|
|
211
|
+
...createLoadResult("prompt"),
|
|
212
|
+
issues: [
|
|
213
|
+
{
|
|
214
|
+
path: "formatMode",
|
|
215
|
+
message: "Expected a valid mode.",
|
|
216
|
+
sourcePath: "/repo/.pi/extensions/pi-autoformat/config.json",
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
}),
|
|
220
|
+
createAutoformatter: vi.fn().mockReturnValue({
|
|
221
|
+
recordToolResult: vi.fn(),
|
|
222
|
+
flushPrompt: vi.fn().mockResolvedValue({ files: [] }),
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await pi.emit("session_start", {}, ctx);
|
|
227
|
+
|
|
228
|
+
expect(warn).toHaveBeenCalledWith(
|
|
229
|
+
"[pi-autoformat] Configuration issues detected:\n/repo/.pi/extensions/pi-autoformat/config.json formatMode: Expected a valid mode.",
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
warn.mockRestore();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("records successful tool results and flushes at prompt end in prompt mode", async () => {
|
|
236
|
+
const pi = new TestPi();
|
|
237
|
+
const ctx = createContext();
|
|
238
|
+
const autoformatter = {
|
|
239
|
+
recordToolResult: vi.fn(),
|
|
240
|
+
flushPrompt: vi.fn().mockResolvedValue(createFlushResult()),
|
|
241
|
+
};
|
|
242
|
+
const reportFlushResult = vi.fn();
|
|
243
|
+
|
|
244
|
+
createAutoformatExtension(pi, {
|
|
245
|
+
loadConfig: vi.fn().mockReturnValue(createLoadResult("prompt")),
|
|
246
|
+
createAutoformatter: vi.fn().mockReturnValue(autoformatter),
|
|
247
|
+
reportFlushResult,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await pi.emit("session_start", {}, ctx);
|
|
251
|
+
await pi.emit(
|
|
252
|
+
"tool_result",
|
|
253
|
+
{
|
|
254
|
+
toolName: "write",
|
|
255
|
+
input: { path: "src/example.ts", content: "export {};" },
|
|
256
|
+
isError: false,
|
|
257
|
+
},
|
|
258
|
+
ctx,
|
|
259
|
+
);
|
|
260
|
+
await pi.emit("agent_end", {}, ctx);
|
|
261
|
+
|
|
262
|
+
expect(autoformatter.recordToolResult).toHaveBeenCalledWith("write", {
|
|
263
|
+
path: "src/example.ts",
|
|
264
|
+
content: "export {};",
|
|
265
|
+
});
|
|
266
|
+
expect(autoformatter.flushPrompt).toHaveBeenCalledTimes(1);
|
|
267
|
+
expect(reportFlushResult).toHaveBeenCalledTimes(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("flushes immediately in tool mode", async () => {
|
|
271
|
+
const pi = new TestPi();
|
|
272
|
+
const ctx = createContext();
|
|
273
|
+
const autoformatter = {
|
|
274
|
+
recordToolResult: vi.fn(),
|
|
275
|
+
flushPrompt: vi.fn().mockResolvedValue({ files: [] }),
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
createAutoformatExtension(pi, {
|
|
279
|
+
loadConfig: vi.fn().mockReturnValue(createLoadResult("tool")),
|
|
280
|
+
createAutoformatter: vi.fn().mockReturnValue(autoformatter),
|
|
281
|
+
reportFlushResult: vi.fn(),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await pi.emit(
|
|
285
|
+
"tool_result",
|
|
286
|
+
{
|
|
287
|
+
toolName: "edit",
|
|
288
|
+
input: { path: "src/example.ts", edits: [] },
|
|
289
|
+
isError: false,
|
|
290
|
+
},
|
|
291
|
+
ctx,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
expect(autoformatter.recordToolResult).toHaveBeenCalledTimes(1);
|
|
295
|
+
expect(autoformatter.flushPrompt).toHaveBeenCalledTimes(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("flushes on session shutdown in session mode and ignores failed tool results", async () => {
|
|
299
|
+
const pi = new TestPi();
|
|
300
|
+
const ctx = createContext();
|
|
301
|
+
const autoformatter = {
|
|
302
|
+
recordToolResult: vi.fn(),
|
|
303
|
+
flushPrompt: vi.fn().mockResolvedValue({ files: [] }),
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
createAutoformatExtension(pi, {
|
|
307
|
+
loadConfig: vi.fn().mockReturnValue(createLoadResult("session")),
|
|
308
|
+
createAutoformatter: vi.fn().mockReturnValue(autoformatter),
|
|
309
|
+
reportFlushResult: vi.fn(),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await pi.emit("session_start", {}, ctx);
|
|
313
|
+
await pi.emit(
|
|
314
|
+
"tool_result",
|
|
315
|
+
{
|
|
316
|
+
toolName: "write",
|
|
317
|
+
input: { path: "src/example.ts", content: "" },
|
|
318
|
+
isError: true,
|
|
319
|
+
},
|
|
320
|
+
ctx,
|
|
321
|
+
);
|
|
322
|
+
await pi.emit("session_shutdown", {}, ctx);
|
|
323
|
+
|
|
324
|
+
expect(autoformatter.recordToolResult).not.toHaveBeenCalled();
|
|
325
|
+
expect(autoformatter.flushPrompt).toHaveBeenCalledTimes(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("reports config issues on session start", async () => {
|
|
329
|
+
const pi = new TestPi();
|
|
330
|
+
const ctx = createContext();
|
|
331
|
+
const reportConfigIssues = vi.fn();
|
|
332
|
+
|
|
333
|
+
createAutoformatExtension(pi, {
|
|
334
|
+
loadConfig: vi.fn().mockReturnValue({
|
|
335
|
+
...createLoadResult("prompt"),
|
|
336
|
+
issues: [
|
|
337
|
+
{
|
|
338
|
+
path: "formatMode",
|
|
339
|
+
message: "Expected a valid mode.",
|
|
340
|
+
sourcePath: "/repo/.pi/extensions/pi-autoformat/config.json",
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
}),
|
|
344
|
+
createAutoformatter: vi.fn().mockReturnValue({
|
|
345
|
+
recordToolResult: vi.fn(),
|
|
346
|
+
flushPrompt: vi.fn().mockResolvedValue({ files: [] }),
|
|
347
|
+
}),
|
|
348
|
+
reportConfigIssues,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await pi.emit("session_start", {}, ctx);
|
|
352
|
+
|
|
353
|
+
expect(reportConfigIssues).toHaveBeenCalledWith(
|
|
354
|
+
[
|
|
355
|
+
{
|
|
356
|
+
path: "formatMode",
|
|
357
|
+
message: "Expected a valid mode.",
|
|
358
|
+
sourcePath: "/repo/.pi/extensions/pi-autoformat/config.json",
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
{ ctx },
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createFormatterConfig,
|
|
5
|
+
DEFAULT_FORMATTER_CONFIG,
|
|
6
|
+
type UserFormatterConfig,
|
|
7
|
+
} from "../src/formatter-config.js";
|
|
8
|
+
|
|
9
|
+
describe("createFormatterConfig", () => {
|
|
10
|
+
it("includes default formatters by default", () => {
|
|
11
|
+
const config = createFormatterConfig();
|
|
12
|
+
|
|
13
|
+
expect(Object.keys(config.formatters)).toContain("prettier");
|
|
14
|
+
expect(Object.keys(config.formatters)).toContain("markdownlint-cli2");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("allows overriding builtin formatter commands", () => {
|
|
18
|
+
const userConfig: UserFormatterConfig = {
|
|
19
|
+
formatters: {
|
|
20
|
+
prettier: {
|
|
21
|
+
command: ["pnpm", "exec", "prettier", "--write", "$FILE"],
|
|
22
|
+
extensions: [".ts", ".md"],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const config = createFormatterConfig(userConfig);
|
|
28
|
+
|
|
29
|
+
expect(config.formatters.prettier?.command).toEqual([
|
|
30
|
+
"pnpm",
|
|
31
|
+
"exec",
|
|
32
|
+
"prettier",
|
|
33
|
+
"--write",
|
|
34
|
+
"$FILE",
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("allows disabling builtin formatters", () => {
|
|
39
|
+
const userConfig: UserFormatterConfig = {
|
|
40
|
+
formatters: {
|
|
41
|
+
prettier: {
|
|
42
|
+
...DEFAULT_FORMATTER_CONFIG.formatters.prettier,
|
|
43
|
+
disabled: true,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const config = createFormatterConfig(userConfig);
|
|
49
|
+
|
|
50
|
+
expect(config.formatters.prettier?.disabled).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("merges chain configuration while preserving user order", () => {
|
|
54
|
+
const userConfig: UserFormatterConfig = {
|
|
55
|
+
chains: {
|
|
56
|
+
".md": ["markdownlint-cli2", "prettier"],
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const config = createFormatterConfig(userConfig);
|
|
61
|
+
|
|
62
|
+
expect(config.chains[".md"]).toEqual(["markdownlint-cli2", "prettier"]);
|
|
63
|
+
});
|
|
64
|
+
});
|