@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,118 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export type FormatScopeSetting = "repoRoot" | "cwd" | string[];
6
+
7
+ export type FormatScope = {
8
+ /** Absolute, normalized roots. A path is in scope if it falls under any root. */
9
+ roots: string[];
10
+ /** Whether comparisons should be case-insensitive (darwin/win32). */
11
+ caseInsensitive: boolean;
12
+ };
13
+
14
+ export type ResolveFormatScopeOptions = {
15
+ cwd: string;
16
+ setting?: FormatScopeSetting;
17
+ /** Override for tests. Default: detect via `git rev-parse --show-toplevel`. */
18
+ detectGitRoot?: (cwd: string) => string | undefined;
19
+ platform?: NodeJS.Platform;
20
+ };
21
+
22
+ function defaultDetectGitRoot(cwd: string): string | undefined {
23
+ try {
24
+ const stdout = execFileSync("git", ["rev-parse", "--show-toplevel"], {
25
+ cwd,
26
+ encoding: "utf-8",
27
+ stdio: ["ignore", "pipe", "ignore"],
28
+ });
29
+ const trimmed = stdout.trim();
30
+ return trimmed.length > 0 ? trimmed : undefined;
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ function safeRealpath(absPath: string): string {
37
+ try {
38
+ return realpathSync(absPath);
39
+ } catch {
40
+ return absPath;
41
+ }
42
+ }
43
+
44
+ export function resolveFormatScope(
45
+ options: ResolveFormatScopeOptions,
46
+ ): FormatScope {
47
+ const setting: FormatScopeSetting = options.setting ?? "repoRoot";
48
+ const platform = options.platform ?? process.platform;
49
+ const caseInsensitive = platform === "darwin" || platform === "win32";
50
+
51
+ const detectGitRoot = options.detectGitRoot ?? defaultDetectGitRoot;
52
+
53
+ const rawRoots: string[] = (() => {
54
+ if (setting === "cwd") {
55
+ return [options.cwd];
56
+ }
57
+ if (setting === "repoRoot") {
58
+ const gitRoot = detectGitRoot(options.cwd);
59
+ return [gitRoot ?? options.cwd];
60
+ }
61
+ if (Array.isArray(setting)) {
62
+ if (setting.length === 0) {
63
+ return [options.cwd];
64
+ }
65
+ return setting.map((entry) =>
66
+ path.isAbsolute(entry) ? entry : path.resolve(options.cwd, entry),
67
+ );
68
+ }
69
+ return [options.cwd];
70
+ })();
71
+
72
+ const roots = rawRoots
73
+ .map((root) => safeRealpath(path.normalize(root)))
74
+ .filter((root, index, arr) => arr.indexOf(root) === index);
75
+
76
+ return { roots, caseInsensitive };
77
+ }
78
+
79
+ function caseFold(value: string, caseInsensitive: boolean): string {
80
+ return caseInsensitive ? value.toLowerCase() : value;
81
+ }
82
+
83
+ function isUnder(
84
+ candidate: string,
85
+ root: string,
86
+ caseInsensitive: boolean,
87
+ ): boolean {
88
+ const rel = path.relative(
89
+ caseFold(root, caseInsensitive),
90
+ caseFold(candidate, caseInsensitive),
91
+ );
92
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
93
+ return false;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ /**
99
+ * Returns true if `absCandidate` is inside any scope root.
100
+ *
101
+ * The candidate is realpath'd so that symlinked workspace deps that escape
102
+ * the root are correctly excluded, and intentional symlinks within the root
103
+ * are correctly included. If the candidate does not exist (e.g., a `mv`
104
+ * source after a move), `safeRealpath` returns the normalized absolute form,
105
+ * so the same check handles both cases.
106
+ */
107
+ export function isInFormatScope(
108
+ absCandidate: string,
109
+ scope: FormatScope,
110
+ ): boolean {
111
+ const resolvedCandidate = safeRealpath(path.normalize(absCandidate));
112
+ for (const root of scope.roots) {
113
+ if (isUnder(resolvedCandidate, root, scope.caseInsensitive)) {
114
+ return true;
115
+ }
116
+ }
117
+ return false;
118
+ }
@@ -1,80 +1,117 @@
1
+ import type { CustomMutationToolSpec } from "./custom-mutation-tools.js";
2
+ import type { FormatScopeSetting } from "./format-scope.js";
1
3
  import type {
4
+ ChainStep,
2
5
  FormatterConfig,
3
6
  FormatterDefinition,
4
7
  } from "./formatter-registry.js";
8
+ import {
9
+ DEFAULT_SHELL_MUTATION_DETECTION,
10
+ type ShellMutationDetectionConfig,
11
+ } from "./shell-mutation-detector.js";
5
12
 
6
- export type FormatMode = "tool" | "prompt" | "session";
13
+ export type FormatterOutputOnFailure = "none" | "stderr" | "both";
14
+
15
+ export type FormatterOutputReportingConfig = {
16
+ /** Which streams to include for *failed* runs. Default: "none". */
17
+ onFailure: FormatterOutputOnFailure;
18
+ /** Hard byte cap per stream per run (UTF-8 byte length). */
19
+ maxBytes: number;
20
+ /** Hard line cap per stream per run, applied after byte trimming. */
21
+ maxLines: number;
22
+ };
23
+
24
+ export const DEFAULT_FORMATTER_OUTPUT_REPORTING: FormatterOutputReportingConfig =
25
+ {
26
+ onFailure: "none",
27
+ maxBytes: 4096,
28
+ maxLines: 40,
29
+ };
30
+
31
+ export type EventBusMutationChannelConfig = {
32
+ enabled: boolean;
33
+ channel: string;
34
+ };
35
+
36
+ export const DEFAULT_EVENT_BUS_MUTATION_CHANNEL: EventBusMutationChannelConfig =
37
+ {
38
+ enabled: true,
39
+ channel: "autoformat:touched",
40
+ };
7
41
 
8
42
  export type UserFormatterConfig = {
9
- formatMode?: FormatMode;
10
43
  commandTimeoutMs?: number;
11
44
  hideSummariesInTui?: boolean;
45
+ formatScope?: FormatScopeSetting;
46
+ shellMutationDetection?: Partial<ShellMutationDetectionConfig>;
47
+ customMutationTools?: CustomMutationToolSpec[];
48
+ eventBusMutationChannel?: Partial<EventBusMutationChannelConfig>;
12
49
  formatters?: Record<string, FormatterDefinition>;
13
- chains?: Record<string, string[]>;
50
+ chains?: Record<string, ChainStep[]>;
51
+ formatterOutput?: Partial<FormatterOutputReportingConfig>;
14
52
  };
15
53
 
16
54
  export type AutoformatConfig = FormatterConfig & {
17
- formatMode: FormatMode;
18
55
  commandTimeoutMs: number;
19
56
  hideSummariesInTui: boolean;
57
+ formatScope: FormatScopeSetting;
58
+ shellMutationDetection: ShellMutationDetectionConfig;
59
+ customMutationTools: CustomMutationToolSpec[];
60
+ eventBusMutationChannel: EventBusMutationChannelConfig;
61
+ formatters: Record<string, FormatterDefinition>;
62
+ chains: Record<string, ChainStep[]>;
63
+ formatterOutput: FormatterOutputReportingConfig;
20
64
  };
21
65
 
22
66
  export const DEFAULT_FORMATTER_CONFIG: AutoformatConfig = {
23
- formatMode: "prompt",
24
67
  commandTimeoutMs: 10000,
25
68
  hideSummariesInTui: false,
69
+ formatScope: "repoRoot",
70
+ shellMutationDetection: DEFAULT_SHELL_MUTATION_DETECTION,
71
+ customMutationTools: [],
72
+ eventBusMutationChannel: DEFAULT_EVENT_BUS_MUTATION_CHANNEL,
26
73
  formatters: {
27
74
  prettier: {
28
- command: ["prettier", "--write", "$FILE"],
29
- extensions: [
30
- ".js",
31
- ".cjs",
32
- ".mjs",
33
- ".jsx",
34
- ".ts",
35
- ".tsx",
36
- ".json",
37
- ".md",
38
- ".yaml",
39
- ".yml",
40
- ],
75
+ command: ["prettier", "--write"],
41
76
  },
42
77
  "markdownlint-cli2": {
43
- command: ["markdownlint-cli2", "--fix", "$FILE"],
44
- extensions: [".md"],
78
+ command: ["markdownlint-cli2", "--fix"],
45
79
  },
46
80
  },
47
- chains: {
48
- ".md": ["prettier", "markdownlint-cli2"],
49
- ".js": ["prettier"],
50
- ".cjs": ["prettier"],
51
- ".mjs": ["prettier"],
52
- ".jsx": ["prettier"],
53
- ".ts": ["prettier"],
54
- ".tsx": ["prettier"],
55
- ".json": ["prettier"],
56
- ".yaml": ["prettier"],
57
- ".yml": ["prettier"],
58
- },
81
+ formatterOutput: DEFAULT_FORMATTER_OUTPUT_REPORTING,
82
+ chains: {},
59
83
  };
60
84
 
61
85
  export function createFormatterConfig(
62
86
  userConfig?: UserFormatterConfig,
63
87
  ): AutoformatConfig {
64
88
  return {
65
- formatMode: userConfig?.formatMode ?? DEFAULT_FORMATTER_CONFIG.formatMode,
66
89
  commandTimeoutMs:
67
90
  userConfig?.commandTimeoutMs ?? DEFAULT_FORMATTER_CONFIG.commandTimeoutMs,
68
91
  hideSummariesInTui:
69
92
  userConfig?.hideSummariesInTui ??
70
93
  DEFAULT_FORMATTER_CONFIG.hideSummariesInTui,
94
+ formatScope:
95
+ userConfig?.formatScope ?? DEFAULT_FORMATTER_CONFIG.formatScope,
96
+ shellMutationDetection: {
97
+ ...DEFAULT_FORMATTER_CONFIG.shellMutationDetection,
98
+ ...userConfig?.shellMutationDetection,
99
+ },
100
+ customMutationTools:
101
+ userConfig?.customMutationTools ??
102
+ DEFAULT_FORMATTER_CONFIG.customMutationTools,
103
+ eventBusMutationChannel: {
104
+ ...DEFAULT_FORMATTER_CONFIG.eventBusMutationChannel,
105
+ ...userConfig?.eventBusMutationChannel,
106
+ },
71
107
  formatters: {
72
108
  ...DEFAULT_FORMATTER_CONFIG.formatters,
73
109
  ...userConfig?.formatters,
74
110
  },
75
- chains: {
76
- ...DEFAULT_FORMATTER_CONFIG.chains,
77
- ...userConfig?.chains,
111
+ chains: userConfig?.chains ?? {},
112
+ formatterOutput: {
113
+ ...DEFAULT_FORMATTER_CONFIG.formatterOutput,
114
+ ...userConfig?.formatterOutput,
78
115
  },
79
116
  };
80
117
  }
@@ -1,4 +1,10 @@
1
- import type { ResolvedFormatter } from "./formatter-registry.js";
1
+ import type { BuiltinFormatter } from "./builtin-formatters.js";
2
+ import type { CommandProbe } from "./command-probe.js";
3
+ import { defaultCommandProbe } from "./command-probe.js";
4
+ import type {
5
+ ResolvedChainStep,
6
+ ResolvedFormatter,
7
+ } from "./formatter-registry.js";
2
8
 
3
9
  export type CommandRunResult = {
4
10
  exitCode: number;
@@ -17,52 +23,242 @@ export type CommandRunner = (
17
23
  options?: CommandRunnerOptions,
18
24
  ) => Promise<CommandRunResult>;
19
25
 
20
- export type FormatterExecutionResult = {
26
+ export type FallbackContext = {
27
+ skipped: string[];
28
+ };
29
+
30
+ export type BatchRun = {
21
31
  formatterName: string;
22
32
  command: string[];
33
+ files: string[];
23
34
  success: boolean;
24
35
  exitCode: number;
25
36
  stdout?: string;
26
37
  stderr?: string;
38
+ fallbackContext?: FallbackContext;
39
+ };
40
+
41
+ export type ChainGroupInput = {
42
+ chain: ResolvedChainStep[];
43
+ files: string[];
27
44
  };
28
45
 
29
- export async function executeFormatterChain(
30
- chain: ResolvedFormatter[],
46
+ export type ExecuteChainGroupOptions = {
47
+ cwd?: string;
48
+ commandProbe?: CommandProbe;
49
+ /** Optional discovery context passed to built-in formatters. */
50
+ builtinContext?: { cache?: Map<string, string | null> };
51
+ };
52
+
53
+ export type ChainGroupExecution = {
54
+ runs: BatchRun[];
55
+ /** Files not yet handled by any built-in step in this chain. */
56
+ unhandled: string[];
57
+ };
58
+
59
+ async function runOrdinaryFormatter(
60
+ formatter: ResolvedFormatter,
61
+ files: string[],
62
+ runner: CommandRunner,
63
+ cwd: string | undefined,
64
+ fallbackContext?: FallbackContext,
65
+ ): Promise<BatchRun> {
66
+ const [command, ...args] = formatter.command;
67
+
68
+ if (!command) {
69
+ return {
70
+ formatterName: formatter.name,
71
+ command: [...formatter.command],
72
+ files: [...files],
73
+ success: false,
74
+ exitCode: 1,
75
+ stderr: "Formatter command is empty",
76
+ ...(fallbackContext ? { fallbackContext } : {}),
77
+ };
78
+ }
79
+
80
+ const fullArgs = [...args, ...files];
81
+ const runResult = await runner(command, fullArgs, {
82
+ cwd,
83
+ env: formatter.environment,
84
+ });
85
+
86
+ return {
87
+ formatterName: formatter.name,
88
+ command: [command, ...fullArgs],
89
+ files: [...files],
90
+ success: runResult.exitCode === 0,
91
+ exitCode: runResult.exitCode,
92
+ stdout: runResult.stdout,
93
+ stderr: runResult.stderr,
94
+ ...(fallbackContext ? { fallbackContext } : {}),
95
+ };
96
+ }
97
+
98
+ type BuiltinRunResult = {
99
+ /** undefined when the built-in is skipped entirely (no root, treatAsSkip). */
100
+ run?: BatchRun;
101
+ handled: string[];
102
+ unhandled: string[];
103
+ };
104
+
105
+ async function runBuiltinFormatter(
106
+ formatter: ResolvedFormatter,
107
+ builtin: BuiltinFormatter,
108
+ files: string[],
109
+ runner: CommandRunner,
110
+ options: ExecuteChainGroupOptions | undefined,
111
+ fallbackContext?: FallbackContext,
112
+ ): Promise<BuiltinRunResult> {
113
+ const root = await builtin.discoverRoot(files, options?.builtinContext);
114
+ if (!root) {
115
+ // No applicable config; treat as a clean no-op so the entire batch falls
116
+ // through to subsequent steps / per-extension chains.
117
+ return { handled: [], unhandled: [...files] };
118
+ }
119
+ const built = builtin.buildCommand(root, files);
120
+ const [command, ...args] = built.command;
121
+ if (!command) {
122
+ return { handled: [], unhandled: [...files] };
123
+ }
124
+ const runResult = await runner(command, args, {
125
+ cwd: built.cwd,
126
+ env: formatter.environment,
127
+ });
128
+ const run: BatchRun = {
129
+ formatterName: formatter.name,
130
+ command: [command, ...args],
131
+ files: [...files],
132
+ success: runResult.exitCode === 0,
133
+ exitCode: runResult.exitCode,
134
+ stdout: runResult.stdout,
135
+ stderr: runResult.stderr,
136
+ ...(fallbackContext ? { fallbackContext } : {}),
137
+ };
138
+ const partition = builtin.partitionUnhandled(run, files);
139
+ if (partition.treatAsSkip) {
140
+ return { handled: [], unhandled: [...files] };
141
+ }
142
+ return {
143
+ run,
144
+ handled: partition.handled,
145
+ unhandled: partition.unhandled,
146
+ };
147
+ }
148
+
149
+ export async function executeChainGroupWithPartition(
150
+ group: ChainGroupInput,
31
151
  runner: CommandRunner,
32
- options?: {
33
- cwd?: string;
34
- },
35
- ): Promise<FormatterExecutionResult[]> {
36
- const results: FormatterExecutionResult[] = [];
37
-
38
- for (const formatter of chain) {
39
- const [command, ...args] = formatter.command;
40
-
41
- if (!command) {
42
- results.push({
43
- formatterName: formatter.name,
44
- command: formatter.command,
45
- success: false,
46
- exitCode: 1,
47
- stderr: "Formatter command is empty",
48
- });
152
+ options?: ExecuteChainGroupOptions,
153
+ ): Promise<ChainGroupExecution> {
154
+ if (group.files.length === 0) {
155
+ return { runs: [], unhandled: [] };
156
+ }
157
+
158
+ const probe = options?.commandProbe ?? defaultCommandProbe;
159
+ const runs: BatchRun[] = [];
160
+ // Working set: files that have not yet been claimed by a built-in step.
161
+ let working: string[] = [...group.files];
162
+
163
+ for (const step of group.chain) {
164
+ if (working.length === 0) {
165
+ break;
166
+ }
167
+
168
+ if (step.kind === "single") {
169
+ const formatter = step.formatter;
170
+ if (formatter.builtin) {
171
+ const result = await runBuiltinFormatter(
172
+ formatter,
173
+ formatter.builtin,
174
+ working,
175
+ runner,
176
+ options,
177
+ );
178
+ if (result.run) runs.push(result.run);
179
+ working = result.unhandled;
180
+ continue;
181
+ }
182
+ runs.push(
183
+ await runOrdinaryFormatter(formatter, working, runner, options?.cwd),
184
+ );
49
185
  continue;
50
186
  }
51
187
 
52
- const runResult = await runner(command, args, {
53
- cwd: options?.cwd,
54
- env: formatter.environment,
55
- });
188
+ const skipped: string[] = [];
189
+ let chosen: ResolvedFormatter | undefined;
190
+ for (const alternative of step.alternatives) {
191
+ const command = alternative.command[0];
192
+ if (command && probe(command)) {
193
+ chosen = alternative;
194
+ break;
195
+ }
196
+ skipped.push(alternative.name);
197
+ }
56
198
 
57
- results.push({
58
- formatterName: formatter.name,
59
- command: formatter.command,
60
- success: runResult.exitCode === 0,
61
- exitCode: runResult.exitCode,
62
- stdout: runResult.stdout,
63
- stderr: runResult.stderr,
64
- });
199
+ if (!chosen) {
200
+ // All alternatives missing from PATH — group is a no-op as specified.
201
+ continue;
202
+ }
203
+
204
+ // Precedence rule: when treefmt would win and treefmt-nix is also viable
205
+ // and resolves to a config at the same root, prefer treefmt-nix.
206
+ if (chosen.builtin?.name === "treefmt") {
207
+ const nixAlt = step.alternatives.find(
208
+ (a) => a.builtin?.name === "treefmt-nix" && probe(a.command[0] ?? ""),
209
+ );
210
+ if (nixAlt && chosen.builtin && nixAlt.builtin) {
211
+ const treefmtRoot = await chosen.builtin.discoverRoot(
212
+ working,
213
+ options?.builtinContext,
214
+ );
215
+ const nixRoot = await nixAlt.builtin.discoverRoot(
216
+ working,
217
+ options?.builtinContext,
218
+ );
219
+ if (treefmtRoot && nixRoot && treefmtRoot === nixRoot) {
220
+ // Bump treefmt into skipped (so the user sees fallback annotation)
221
+ // and switch to treefmt-nix.
222
+ skipped.push(chosen.name);
223
+ chosen = nixAlt;
224
+ }
225
+ }
226
+ }
227
+
228
+ const fallbackContext: FallbackContext | undefined =
229
+ skipped.length > 0 ? { skipped } : undefined;
230
+ if (chosen.builtin) {
231
+ const result = await runBuiltinFormatter(
232
+ chosen,
233
+ chosen.builtin,
234
+ working,
235
+ runner,
236
+ options,
237
+ fallbackContext,
238
+ );
239
+ if (result.run) runs.push(result.run);
240
+ working = result.unhandled;
241
+ continue;
242
+ }
243
+ runs.push(
244
+ await runOrdinaryFormatter(
245
+ chosen,
246
+ working,
247
+ runner,
248
+ options?.cwd,
249
+ fallbackContext,
250
+ ),
251
+ );
65
252
  }
66
253
 
67
- return results;
254
+ return { runs, unhandled: working };
255
+ }
256
+
257
+ export async function executeChainGroup(
258
+ group: ChainGroupInput,
259
+ runner: CommandRunner,
260
+ options?: ExecuteChainGroupOptions,
261
+ ): Promise<BatchRun[]> {
262
+ const result = await executeChainGroupWithPartition(group, runner, options);
263
+ return result.runs;
68
264
  }
@@ -0,0 +1,149 @@
1
+ import type { FormatterOutputReportingConfig } from "./formatter-config.js";
2
+ import type { BatchRun } from "./formatter-executor.js";
3
+
4
+ const HEADER_INDENT = " ";
5
+ const BODY_INDENT = " ";
6
+
7
+ type TrimmedStream = {
8
+ /** Lines to render (already byte-trimmed and line-trimmed). */
9
+ lines: string[];
10
+ /** Truncation marker prefix line, or undefined if no truncation happened. */
11
+ marker?: string;
12
+ };
13
+
14
+ function rstripWhitespace(text: string): string {
15
+ // Strip trailing whitespace, including blank trailing lines, but preserve
16
+ // interior empty lines that the formatter intentionally emitted.
17
+ return text.replace(/[\s\uFEFF\xA0]+$/u, "");
18
+ }
19
+
20
+ function snapToNewlineBoundary(buffer: Buffer): Buffer {
21
+ // After tail-slicing on a byte boundary we may sit mid-line and (on
22
+ // multibyte input) mid-character. Advance forward to the first newline so
23
+ // we always start cleanly. If there is no newline, decode-and-cleanup will
24
+ // strip any U+FFFD replacements introduced by a partial UTF-8 sequence at
25
+ // the head.
26
+ const newlineIndex = buffer.indexOf(0x0a);
27
+ if (newlineIndex >= 0) {
28
+ return buffer.subarray(newlineIndex + 1);
29
+ }
30
+ return buffer;
31
+ }
32
+
33
+ function decodeAndCleanup(buffer: Buffer): string {
34
+ const decoded = buffer.toString("utf8");
35
+ // Drop any replacement characters at the head caused by a bisected
36
+ // multibyte sequence. Interior replacements (rare but possible from the
37
+ // formatter itself) are left intact.
38
+ return decoded.replace(/^\uFFFD+/u, "");
39
+ }
40
+
41
+ function trimByBytes(
42
+ text: string,
43
+ maxBytes: number,
44
+ ): { text: string; droppedBytes: number } {
45
+ const buffer = Buffer.from(text, "utf8");
46
+ if (buffer.byteLength <= maxBytes) {
47
+ return { text, droppedBytes: 0 };
48
+ }
49
+ const tail = buffer.subarray(buffer.byteLength - maxBytes);
50
+ const snapped = snapToNewlineBoundary(tail);
51
+ const droppedBytes = buffer.byteLength - snapped.byteLength;
52
+ return { text: decodeAndCleanup(snapped), droppedBytes };
53
+ }
54
+
55
+ function trimByLines(
56
+ text: string,
57
+ maxLines: number,
58
+ ): { lines: string[]; droppedLines: number } {
59
+ const allLines = text.split("\n");
60
+ if (allLines.length <= maxLines) {
61
+ return { lines: allLines, droppedLines: 0 };
62
+ }
63
+ const kept = allLines.slice(allLines.length - maxLines);
64
+ const droppedLines = allLines.length - kept.length;
65
+ return { lines: kept, droppedLines };
66
+ }
67
+
68
+ function trimStream(
69
+ raw: string,
70
+ config: FormatterOutputReportingConfig,
71
+ ): TrimmedStream | undefined {
72
+ const stripped = rstripWhitespace(raw);
73
+ if (stripped.length === 0) {
74
+ return undefined;
75
+ }
76
+
77
+ const byteResult = trimByBytes(stripped, config.maxBytes);
78
+ const lineResult = trimByLines(byteResult.text, config.maxLines);
79
+
80
+ let marker: string | undefined;
81
+ if (lineResult.droppedLines > 0) {
82
+ marker = `... (truncated, ${lineResult.droppedLines} earlier lines)`;
83
+ } else if (byteResult.droppedBytes > 0) {
84
+ marker = `... (truncated, ${byteResult.droppedBytes} earlier bytes)`;
85
+ }
86
+
87
+ // Lines may include leading empties if the byte-trimmed prefix was a
88
+ // newline. Drop a single leading empty for tidiness.
89
+ const lines =
90
+ lineResult.lines.length > 0 && lineResult.lines[0] === ""
91
+ ? lineResult.lines.slice(1)
92
+ : lineResult.lines;
93
+
94
+ if (lines.length === 0 && !marker) {
95
+ return undefined;
96
+ }
97
+
98
+ if (marker) {
99
+ return { lines, marker };
100
+ }
101
+ return { lines };
102
+ }
103
+
104
+ function renderBlock(
105
+ label: "stdout" | "stderr",
106
+ stream: TrimmedStream,
107
+ ): string {
108
+ const lines = [`${HEADER_INDENT}${label}:`];
109
+ if (stream.marker) {
110
+ lines.push(`${BODY_INDENT}${stream.marker}`);
111
+ }
112
+ for (const line of stream.lines) {
113
+ lines.push(`${BODY_INDENT}${line}`);
114
+ }
115
+ return lines.join("\n");
116
+ }
117
+
118
+ export function formatRunOutputBlock(
119
+ run: Pick<BatchRun, "success" | "stdout" | "stderr">,
120
+ config: FormatterOutputReportingConfig,
121
+ ): string | undefined {
122
+ if (config.onFailure === "none") {
123
+ return undefined;
124
+ }
125
+ if (run.success) {
126
+ return undefined;
127
+ }
128
+
129
+ const blocks: string[] = [];
130
+
131
+ if (config.onFailure === "both" && run.stdout !== undefined) {
132
+ const trimmed = trimStream(run.stdout, config);
133
+ if (trimmed) {
134
+ blocks.push(renderBlock("stdout", trimmed));
135
+ }
136
+ }
137
+
138
+ if (run.stderr !== undefined) {
139
+ const trimmed = trimStream(run.stderr, config);
140
+ if (trimmed) {
141
+ blocks.push(renderBlock("stderr", trimmed));
142
+ }
143
+ }
144
+
145
+ if (blocks.length === 0) {
146
+ return undefined;
147
+ }
148
+ return blocks.join("\n");
149
+ }