@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,98 @@
1
+ /**
2
+ * End-to-end fallback chain test.
3
+ *
4
+ * Exercises the full resolution path: config loader → grouping →
5
+ * resolveChainSteps → executeChainGroup with a stubbed PATH probe and a
6
+ * stubbed runner. Validates the issue #13 acceptance scenario: a global
7
+ * `[{ fallback: ["biome", "prettier"] }]` chain in a repo where biome is
8
+ * absent and prettier is present produces a single prettier batch run
9
+ * with fallbackContext.skipped = ["biome"].
10
+ *
11
+ * This is the "acceptance" anchor for the fallback feature. It does not
12
+ * use the real Pi CLI (see acceptance.test.ts for that surface).
13
+ */
14
+
15
+ import { describe, expect, it } from "vitest";
16
+
17
+ import { validateUserFormatterConfig } from "../src/config-loader.js";
18
+ import { createFormatterConfig } from "../src/formatter-config.js";
19
+ import type { CommandRunner } from "../src/formatter-executor.js";
20
+ import { PromptAutoformatter } from "../src/prompt-autoformatter.js";
21
+
22
+ describe("fallback chain end-to-end", () => {
23
+ it("falls through from biome to prettier when biome is missing on PATH", async () => {
24
+ const validated = validateUserFormatterConfig({
25
+ formatters: {
26
+ biome: { command: ["biome", "format", "--write"] },
27
+ prettier: { command: ["prettier", "--write"] },
28
+ },
29
+ chains: {
30
+ ".ts": [{ fallback: ["biome", "prettier"] }],
31
+ },
32
+ });
33
+ expect(validated.issues).toEqual([]);
34
+
35
+ const config = createFormatterConfig(validated.config);
36
+
37
+ const calls: Array<{ command: string; args: string[] }> = [];
38
+ const runner: CommandRunner = async (command, args) => {
39
+ calls.push({ command, args });
40
+ return { exitCode: 0 };
41
+ };
42
+
43
+ const formatter = new PromptAutoformatter("/repo", config, runner, {
44
+ // biome absent, prettier present.
45
+ commandProbe: (cmd) => cmd === "prettier",
46
+ });
47
+ formatter.addTouchedPath("/repo/src/a.ts");
48
+ formatter.addTouchedPath("/repo/src/b.ts");
49
+
50
+ const result = await formatter.flushPrompt();
51
+
52
+ expect(calls).toEqual([
53
+ {
54
+ command: "prettier",
55
+ args: ["--write", "/repo/src/a.ts", "/repo/src/b.ts"],
56
+ },
57
+ ]);
58
+ expect(result.groups).toHaveLength(1);
59
+ expect(result.groups[0].runs).toHaveLength(1);
60
+ expect(result.groups[0].runs[0]).toMatchObject({
61
+ formatterName: "prettier",
62
+ success: true,
63
+ exitCode: 0,
64
+ files: ["/repo/src/a.ts", "/repo/src/b.ts"],
65
+ fallbackContext: { skipped: ["biome"] },
66
+ });
67
+ });
68
+
69
+ it("emits no run when every fallback alternative is missing from PATH", async () => {
70
+ const validated = validateUserFormatterConfig({
71
+ formatters: {
72
+ biome: { command: ["biome", "format", "--write"] },
73
+ prettier: { command: ["prettier", "--write"] },
74
+ },
75
+ chains: {
76
+ ".ts": [{ fallback: ["biome", "prettier"] }],
77
+ },
78
+ });
79
+ const config = createFormatterConfig(validated.config);
80
+
81
+ const calls: string[] = [];
82
+ const runner: CommandRunner = async (command) => {
83
+ calls.push(command);
84
+ return { exitCode: 0 };
85
+ };
86
+
87
+ const formatter = new PromptAutoformatter("/repo", config, runner, {
88
+ commandProbe: () => false,
89
+ });
90
+ formatter.addTouchedPath("/repo/src/a.ts");
91
+
92
+ const result = await formatter.flushPrompt();
93
+
94
+ expect(calls).toEqual([]);
95
+ // Group is dropped entirely: no runs and no group emitted.
96
+ expect(result.groups).toEqual([]);
97
+ });
98
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Fixture extension for the EventBus acceptance test.
3
+ *
4
+ * Registers `/emit-touched <path...>` which forwards the supplied paths
5
+ * onto the `autoformat:touched` channel. Drives the production
6
+ * extension's pi.events subscription end-to-end without requiring an
7
+ * LLM-driven tool call.
8
+ */
9
+
10
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
+
12
+ export default function eventBusEmitter(pi: ExtensionAPI): void {
13
+ pi.registerCommand("emit-touched", {
14
+ description: "Emit autoformat:touched for the supplied paths",
15
+ handler: async (args) => {
16
+ const paths = args
17
+ .split(/\s+/)
18
+ .map((segment) => segment.trim())
19
+ .filter((segment) => segment.length > 0);
20
+ if (paths.length === 0) {
21
+ return;
22
+ }
23
+ pi.events.emit("autoformat:touched", { paths });
24
+ },
25
+ });
26
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Test-only "formatter" that records every invocation to a JSONL log
4
+ * file pointed at by the PI_AUTOFORMAT_RECORDER_LOG env var.
5
+ *
6
+ * Each line of the log is a JSON object describing one invocation:
7
+ * { argv: string[], cwd: string, env: { ... } }
8
+ *
9
+ * `argv` contains the arguments after the script path itself, which —
10
+ * for our chain config — is the list of files the formatter was told
11
+ * to process.
12
+ */
13
+ import { appendFileSync } from "node:fs";
14
+
15
+ const logFile = process.env.PI_AUTOFORMAT_RECORDER_LOG;
16
+ if (!logFile) {
17
+ console.error("PI_AUTOFORMAT_RECORDER_LOG is not set");
18
+ process.exit(2);
19
+ }
20
+
21
+ const entry = {
22
+ argv: process.argv.slice(2),
23
+ cwd: process.cwd(),
24
+ };
25
+ appendFileSync(logFile, `${JSON.stringify(entry)}\n`);
@@ -0,0 +1,139 @@
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ realpathSync,
5
+ symlinkSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ import { describe, expect, it } from "vitest";
12
+
13
+ import { isInFormatScope, resolveFormatScope } from "../src/format-scope.js";
14
+
15
+ describe("resolveFormatScope", () => {
16
+ it("uses repo root when detected", () => {
17
+ const scope = resolveFormatScope({
18
+ cwd: "/repo/sub",
19
+ setting: "repoRoot",
20
+ detectGitRoot: () => "/repo",
21
+ platform: "linux",
22
+ });
23
+ expect(scope.roots).toEqual(["/repo"]);
24
+ expect(scope.caseInsensitive).toBe(false);
25
+ });
26
+
27
+ it("falls back to cwd when git fails", () => {
28
+ const scope = resolveFormatScope({
29
+ cwd: "/repo/sub",
30
+ setting: "repoRoot",
31
+ detectGitRoot: () => undefined,
32
+ platform: "linux",
33
+ });
34
+ expect(scope.roots).toEqual(["/repo/sub"]);
35
+ });
36
+
37
+ it("respects 'cwd' setting", () => {
38
+ const scope = resolveFormatScope({
39
+ cwd: "/repo/sub",
40
+ setting: "cwd",
41
+ detectGitRoot: () => "/repo",
42
+ platform: "linux",
43
+ });
44
+ expect(scope.roots).toEqual(["/repo/sub"]);
45
+ });
46
+
47
+ it("resolves array roots relative to cwd", () => {
48
+ const scope = resolveFormatScope({
49
+ cwd: "/repo",
50
+ setting: ["packages/a", "/abs/path"],
51
+ platform: "linux",
52
+ });
53
+ expect(scope.roots).toEqual(["/repo/packages/a", "/abs/path"]);
54
+ });
55
+
56
+ it("marks darwin/win32 as case-insensitive", () => {
57
+ expect(
58
+ resolveFormatScope({ cwd: "/x", setting: "cwd", platform: "darwin" })
59
+ .caseInsensitive,
60
+ ).toBe(true);
61
+ expect(
62
+ resolveFormatScope({ cwd: "/x", setting: "cwd", platform: "win32" })
63
+ .caseInsensitive,
64
+ ).toBe(true);
65
+ expect(
66
+ resolveFormatScope({ cwd: "/x", setting: "cwd", platform: "linux" })
67
+ .caseInsensitive,
68
+ ).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe("isInFormatScope", () => {
73
+ it("accepts paths inside the root", () => {
74
+ const scope = { roots: ["/repo"], caseInsensitive: false };
75
+ expect(isInFormatScope("/repo/src/index.ts", scope)).toBe(true);
76
+ });
77
+
78
+ it("rejects paths outside the root", () => {
79
+ const scope = { roots: ["/repo"], caseInsensitive: false };
80
+ expect(isInFormatScope("/other/file.ts", scope)).toBe(false);
81
+ expect(isInFormatScope("/tmp/scratch.ts", scope)).toBe(false);
82
+ });
83
+
84
+ it("rejects the root itself", () => {
85
+ const scope = { roots: ["/repo"], caseInsensitive: false };
86
+ expect(isInFormatScope("/repo", scope)).toBe(false);
87
+ });
88
+
89
+ it("accepts a candidate under any of multiple roots", () => {
90
+ const scope = {
91
+ roots: ["/repo/a", "/repo/b"],
92
+ caseInsensitive: false,
93
+ };
94
+ expect(isInFormatScope("/repo/b/src/x.ts", scope)).toBe(true);
95
+ expect(isInFormatScope("/repo/c/src/x.ts", scope)).toBe(false);
96
+ });
97
+
98
+ it("honors case-insensitive comparison on darwin/win32", () => {
99
+ const scope = { roots: ["/Repo"], caseInsensitive: true };
100
+ expect(isInFormatScope("/repo/src/x.ts", scope)).toBe(true);
101
+ });
102
+
103
+ it("uses realpath to drop symlinked workspace deps that escape the root", () => {
104
+ // Resolve tmpdir realpath up front so this test exercises the symlink
105
+ // escape check identically on Linux (where /tmp is real) and macOS
106
+ // (where /tmp is a symlink to /private/tmp).
107
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "pi-fmtscope-")));
108
+ const repo = join(root, "repo");
109
+ const external = join(root, "external");
110
+ mkdirSync(join(repo, "node_modules"), { recursive: true });
111
+ mkdirSync(external, { recursive: true });
112
+ writeFileSync(join(external, "lib.js"), "");
113
+ symlinkSync(external, join(repo, "node_modules", "lib"));
114
+
115
+ const scope = resolveFormatScope({
116
+ cwd: repo,
117
+ setting: "cwd",
118
+ platform: "linux",
119
+ });
120
+ expect(
121
+ isInFormatScope(join(repo, "node_modules", "lib", "lib.js"), scope),
122
+ ).toBe(false);
123
+ });
124
+
125
+ it("includes a symlink whose realpath lands inside the root", () => {
126
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "pi-fmtscope-")));
127
+ const repo = join(root, "repo");
128
+ mkdirSync(join(repo, "real"), { recursive: true });
129
+ writeFileSync(join(repo, "real", "x.ts"), "");
130
+ symlinkSync(join(repo, "real"), join(repo, "link"));
131
+
132
+ const scope = resolveFormatScope({
133
+ cwd: repo,
134
+ setting: "cwd",
135
+ platform: "linux",
136
+ });
137
+ expect(isInFormatScope(join(repo, "link", "x.ts"), scope)).toBe(true);
138
+ });
139
+ });
@@ -7,19 +7,19 @@ import {
7
7
  } from "../src/formatter-config.js";
8
8
 
9
9
  describe("createFormatterConfig", () => {
10
- it("includes default formatters by default", () => {
10
+ it("includes default formatter definitions but no default chains", () => {
11
11
  const config = createFormatterConfig();
12
12
 
13
13
  expect(Object.keys(config.formatters)).toContain("prettier");
14
14
  expect(Object.keys(config.formatters)).toContain("markdownlint-cli2");
15
+ expect(Object.keys(config.chains)).toHaveLength(0);
15
16
  });
16
17
 
17
18
  it("allows overriding builtin formatter commands", () => {
18
19
  const userConfig: UserFormatterConfig = {
19
20
  formatters: {
20
21
  prettier: {
21
- command: ["pnpm", "exec", "prettier", "--write", "$FILE"],
22
- extensions: [".ts", ".md"],
22
+ command: ["pnpm", "exec", "prettier", "--write"],
23
23
  },
24
24
  },
25
25
  };
@@ -31,7 +31,6 @@ describe("createFormatterConfig", () => {
31
31
  "exec",
32
32
  "prettier",
33
33
  "--write",
34
- "$FILE",
35
34
  ]);
36
35
  });
37
36
 
@@ -50,7 +49,59 @@ describe("createFormatterConfig", () => {
50
49
  expect(config.formatters.prettier?.disabled).toBe(true);
51
50
  });
52
51
 
53
- it("merges chain configuration while preserving user order", () => {
52
+ it("does not include notifyAgent in the default config", () => {
53
+ const config = createFormatterConfig();
54
+
55
+ expect(config).not.toHaveProperty("notifyAgent");
56
+ });
57
+
58
+ it("defaults formatterOutput to disabled with safe truncation caps", () => {
59
+ const config = createFormatterConfig();
60
+
61
+ expect(config.formatterOutput).toEqual({
62
+ onFailure: "none",
63
+ maxBytes: 4096,
64
+ maxLines: 40,
65
+ });
66
+ });
67
+
68
+ it("exposes formatterOutput defaults on DEFAULT_FORMATTER_CONFIG", () => {
69
+ expect(DEFAULT_FORMATTER_CONFIG.formatterOutput).toEqual({
70
+ onFailure: "none",
71
+ maxBytes: 4096,
72
+ maxLines: 40,
73
+ });
74
+ });
75
+
76
+ it("merges a partial formatterOutput user object field-by-field", () => {
77
+ const userConfig: UserFormatterConfig = {
78
+ formatterOutput: { onFailure: "stderr" },
79
+ };
80
+
81
+ const config = createFormatterConfig(userConfig);
82
+
83
+ expect(config.formatterOutput).toEqual({
84
+ onFailure: "stderr",
85
+ maxBytes: 4096,
86
+ maxLines: 40,
87
+ });
88
+ });
89
+
90
+ it("allows overriding individual formatterOutput caps", () => {
91
+ const userConfig: UserFormatterConfig = {
92
+ formatterOutput: { onFailure: "both", maxBytes: 256, maxLines: 5 },
93
+ };
94
+
95
+ const config = createFormatterConfig(userConfig);
96
+
97
+ expect(config.formatterOutput).toEqual({
98
+ onFailure: "both",
99
+ maxBytes: 256,
100
+ maxLines: 5,
101
+ });
102
+ });
103
+
104
+ it("preserves user-declared chain order", () => {
54
105
  const userConfig: UserFormatterConfig = {
55
106
  chains: {
56
107
  ".md": ["markdownlint-cli2", "prettier"],