@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,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type CommandRunner,
|
|
5
|
+
executeFormatterChain,
|
|
6
|
+
} from "../src/formatter-executor.js";
|
|
7
|
+
import type { ResolvedFormatter } from "../src/formatter-registry.js";
|
|
8
|
+
|
|
9
|
+
describe("executeFormatterChain", () => {
|
|
10
|
+
const chain: ResolvedFormatter[] = [
|
|
11
|
+
{
|
|
12
|
+
name: "prettier",
|
|
13
|
+
command: ["prettier", "--write", "/repo/docs/readme.md"],
|
|
14
|
+
environment: {
|
|
15
|
+
PRETTIERD_DEFAULT_CONFIG: "./.prettierrc",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "markdownlint",
|
|
20
|
+
command: ["markdownlint-cli2", "--fix", "/repo/docs/readme.md"],
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
it("executes formatters in order", async () => {
|
|
25
|
+
const calls: string[] = [];
|
|
26
|
+
const runner: CommandRunner = async (command, args) => {
|
|
27
|
+
calls.push([command, ...args].join(" "));
|
|
28
|
+
return { exitCode: 0 };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const result = await executeFormatterChain(chain, runner);
|
|
32
|
+
|
|
33
|
+
expect(calls).toEqual([
|
|
34
|
+
"prettier --write /repo/docs/readme.md",
|
|
35
|
+
"markdownlint-cli2 --fix /repo/docs/readme.md",
|
|
36
|
+
]);
|
|
37
|
+
expect(result.every((entry) => entry.success)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("continues running remaining formatters after a failure", async () => {
|
|
41
|
+
const calls: string[] = [];
|
|
42
|
+
const runner: CommandRunner = async (command, _args) => {
|
|
43
|
+
calls.push(command);
|
|
44
|
+
if (command === "prettier") {
|
|
45
|
+
return {
|
|
46
|
+
exitCode: 2,
|
|
47
|
+
stderr: "syntax error",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { exitCode: 0 };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const result = await executeFormatterChain(chain, runner);
|
|
55
|
+
|
|
56
|
+
expect(calls).toEqual(["prettier", "markdownlint-cli2"]);
|
|
57
|
+
expect(result[0]).toMatchObject({
|
|
58
|
+
formatterName: "prettier",
|
|
59
|
+
success: false,
|
|
60
|
+
exitCode: 2,
|
|
61
|
+
});
|
|
62
|
+
expect(result[1]).toMatchObject({
|
|
63
|
+
formatterName: "markdownlint",
|
|
64
|
+
success: true,
|
|
65
|
+
exitCode: 0,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("passes formatter environment overrides to the runner", async () => {
|
|
70
|
+
let capturedEnv: Record<string, string> | undefined;
|
|
71
|
+
const runner: CommandRunner = async (_command, _args, options) => {
|
|
72
|
+
capturedEnv = options?.env;
|
|
73
|
+
return { exitCode: 0 };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
await executeFormatterChain([chain[0]], runner);
|
|
77
|
+
|
|
78
|
+
expect(capturedEnv).toMatchObject({
|
|
79
|
+
PRETTIERD_DEFAULT_CONFIG: "./.prettierrc",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type FormatterConfig,
|
|
5
|
+
resolveFormatterChainForFile,
|
|
6
|
+
} from "../src/formatter-registry.js";
|
|
7
|
+
|
|
8
|
+
describe("resolveFormatterChainForFile", () => {
|
|
9
|
+
const config: FormatterConfig = {
|
|
10
|
+
formatters: {
|
|
11
|
+
prettier: {
|
|
12
|
+
command: ["prettier", "--write", "$FILE"],
|
|
13
|
+
extensions: [".ts", ".md"],
|
|
14
|
+
},
|
|
15
|
+
markdownlint: {
|
|
16
|
+
command: ["markdownlint-cli2", "--fix", "$FILE"],
|
|
17
|
+
extensions: [".md"],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
chains: {
|
|
21
|
+
".md": ["prettier", "markdownlint"],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
it("resolves explicit chains in declared order", () => {
|
|
26
|
+
const chain = resolveFormatterChainForFile("/repo/docs/readme.md", config);
|
|
27
|
+
|
|
28
|
+
expect(chain.map((entry) => entry.name)).toEqual([
|
|
29
|
+
"prettier",
|
|
30
|
+
"markdownlint",
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns an empty chain when no explicit chain exists for the extension", () => {
|
|
35
|
+
const chain = resolveFormatterChainForFile("/repo/src/index.ts", config);
|
|
36
|
+
|
|
37
|
+
expect(chain).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("substitutes $FILE in formatter commands", () => {
|
|
41
|
+
const chain = resolveFormatterChainForFile("/repo/docs/readme.md", config);
|
|
42
|
+
|
|
43
|
+
expect(chain[0]?.command).toEqual([
|
|
44
|
+
"prettier",
|
|
45
|
+
"--write",
|
|
46
|
+
"/repo/docs/readme.md",
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("skips disabled formatters", () => {
|
|
51
|
+
const withDisabled: FormatterConfig = {
|
|
52
|
+
...config,
|
|
53
|
+
formatters: {
|
|
54
|
+
...config.formatters,
|
|
55
|
+
markdownlint: {
|
|
56
|
+
...config.formatters.markdownlint,
|
|
57
|
+
disabled: true,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const chain = resolveFormatterChainForFile(
|
|
63
|
+
"/repo/docs/readme.md",
|
|
64
|
+
withDisabled,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(chain.map((entry) => entry.name)).toEqual(["prettier"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns an empty chain when no formatter matches", () => {
|
|
71
|
+
const chain = resolveFormatterChainForFile("/repo/assets/logo.png", config);
|
|
72
|
+
|
|
73
|
+
expect(chain).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { CommandRunner } from "../src/formatter-executor.js";
|
|
3
|
+
import type { FormatterConfig } from "../src/formatter-registry.js";
|
|
4
|
+
import {
|
|
5
|
+
PromptAutoformatter,
|
|
6
|
+
type PromptAutoformatterResult,
|
|
7
|
+
} from "../src/prompt-autoformatter.js";
|
|
8
|
+
|
|
9
|
+
describe("PromptAutoformatter", () => {
|
|
10
|
+
const config: FormatterConfig = {
|
|
11
|
+
formatters: {
|
|
12
|
+
prettier: {
|
|
13
|
+
command: ["prettier", "--write", "$FILE"],
|
|
14
|
+
extensions: [".ts", ".md"],
|
|
15
|
+
},
|
|
16
|
+
markdownlint: {
|
|
17
|
+
command: ["markdownlint-cli2", "--fix", "$FILE"],
|
|
18
|
+
extensions: [".md"],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
chains: {
|
|
22
|
+
".md": ["prettier", "markdownlint"],
|
|
23
|
+
".ts": ["prettier"],
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
it("is a no-op when no formatter matches touched files", async () => {
|
|
28
|
+
const calls: string[] = [];
|
|
29
|
+
const runner: CommandRunner = async (command, args) => {
|
|
30
|
+
calls.push([command, ...args].join(" "));
|
|
31
|
+
return { exitCode: 0 };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const formatter = new PromptAutoformatter("/repo", config, runner);
|
|
35
|
+
formatter.recordToolResult("write", { path: "assets/logo.png" });
|
|
36
|
+
|
|
37
|
+
const result = await formatter.flushPrompt();
|
|
38
|
+
|
|
39
|
+
expect(calls).toEqual([]);
|
|
40
|
+
expect(result).toEqual({ files: [] });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("dedupes touched files across prompt tool results", async () => {
|
|
44
|
+
const calls: string[] = [];
|
|
45
|
+
const runner: CommandRunner = async (command, args) => {
|
|
46
|
+
calls.push([command, ...args].join(" "));
|
|
47
|
+
return { exitCode: 0 };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const formatter = new PromptAutoformatter("/repo", config, runner);
|
|
51
|
+
formatter.recordToolResult("write", { path: "docs/readme.md" });
|
|
52
|
+
formatter.recordToolResult("edit", { path: "./docs/readme.md" });
|
|
53
|
+
|
|
54
|
+
const result = await formatter.flushPrompt();
|
|
55
|
+
|
|
56
|
+
expect(calls).toEqual([
|
|
57
|
+
"prettier --write /repo/docs/readme.md",
|
|
58
|
+
"markdownlint-cli2 --fix /repo/docs/readme.md",
|
|
59
|
+
]);
|
|
60
|
+
expect(result.files).toHaveLength(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns formatter failures without throwing", async () => {
|
|
64
|
+
const runner: CommandRunner = async (command) => {
|
|
65
|
+
if (command === "prettier") {
|
|
66
|
+
return {
|
|
67
|
+
exitCode: 2,
|
|
68
|
+
stderr: "parse error",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { exitCode: 0 };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const formatter = new PromptAutoformatter("/repo", config, runner);
|
|
75
|
+
formatter.recordToolResult("write", { path: "docs/readme.md" });
|
|
76
|
+
|
|
77
|
+
const result = await formatter.flushPrompt();
|
|
78
|
+
const firstFile = result
|
|
79
|
+
.files[0] as PromptAutoformatterResult["files"][number];
|
|
80
|
+
|
|
81
|
+
expect(firstFile.path).toBe("/repo/docs/readme.md");
|
|
82
|
+
expect(firstFile.runs[0]).toMatchObject({
|
|
83
|
+
formatterName: "prettier",
|
|
84
|
+
success: false,
|
|
85
|
+
exitCode: 2,
|
|
86
|
+
});
|
|
87
|
+
expect(firstFile.runs[1]).toMatchObject({
|
|
88
|
+
formatterName: "markdownlint",
|
|
89
|
+
success: true,
|
|
90
|
+
exitCode: 0,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { TouchedFilesQueue } from "../src/touched-files-queue.js";
|
|
4
|
+
|
|
5
|
+
describe("TouchedFilesQueue", () => {
|
|
6
|
+
it("collects paths from write and edit tool results", () => {
|
|
7
|
+
const queue = new TouchedFilesQueue("/repo");
|
|
8
|
+
|
|
9
|
+
queue.recordToolResult("write", { path: "src/index.ts" });
|
|
10
|
+
queue.recordToolResult("edit", { path: "docs/readme.md" });
|
|
11
|
+
|
|
12
|
+
expect(queue.flush()).toEqual([
|
|
13
|
+
"/repo/src/index.ts",
|
|
14
|
+
"/repo/docs/readme.md",
|
|
15
|
+
]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("dedupes repeated file touches in a prompt", () => {
|
|
19
|
+
const queue = new TouchedFilesQueue("/repo");
|
|
20
|
+
|
|
21
|
+
queue.recordToolResult("write", { path: "src/index.ts" });
|
|
22
|
+
queue.recordToolResult("edit", { path: "./src/index.ts" });
|
|
23
|
+
queue.recordToolResult("edit", { path: "/repo/src/index.ts" });
|
|
24
|
+
|
|
25
|
+
expect(queue.flush()).toEqual(["/repo/src/index.ts"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("ignores non-mutation tools and invalid payloads", () => {
|
|
29
|
+
const queue = new TouchedFilesQueue("/repo");
|
|
30
|
+
|
|
31
|
+
queue.recordToolResult("bash", { path: "src/index.ts" });
|
|
32
|
+
queue.recordToolResult("write", { foo: "bar" });
|
|
33
|
+
queue.recordToolResult("edit", null);
|
|
34
|
+
|
|
35
|
+
expect(queue.flush()).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("clears collected state after flush", () => {
|
|
39
|
+
const queue = new TouchedFilesQueue("/repo");
|
|
40
|
+
|
|
41
|
+
queue.recordToolResult("write", { path: "src/index.ts" });
|
|
42
|
+
|
|
43
|
+
expect(queue.flush()).toEqual(["/repo/src/index.ts"]);
|
|
44
|
+
expect(queue.flush()).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"types": ["node", "vitest/globals"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src", "test", "vitest.config.ts"]
|
|
13
|
+
}
|