@claudiu-ceia/spatch 0.2.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/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # spatch
2
+
3
+ `spatch` applies structural rewrites using a patch document.
4
+
5
+ A patch document is a text block where:
6
+ - `-` lines define what to match
7
+ - `+` lines define what to insert
8
+ - other lines are context shared by both sides
9
+
10
+ You can pass the patch document either:
11
+ - inline as a string
12
+ - as a file path
13
+
14
+ ## CLI
15
+
16
+ ```bash
17
+ spatch <patch-input> [scope] [--cwd <path>] [--dry-run] [--json] [--no-color] [--interactive]
18
+ # or:
19
+ astkit patch <patch-input> [scope] [--cwd <path>] [--dry-run] [--json] [--no-color] [--interactive]
20
+ ```
21
+
22
+ Examples:
23
+
24
+ ```bash
25
+ # patch document from file
26
+ spatch rules/const-to-let.spatch src --cwd /repo
27
+
28
+ # inline patch document
29
+ spatch $'-const :[name] = :[value];\n+let :[name] = :[value];' src
30
+
31
+ # preview only
32
+ spatch rules/const-to-let.spatch src --dry-run
33
+
34
+ # structured JSON output
35
+ spatch rules/const-to-let.spatch src --json
36
+
37
+ # interactive apply mode
38
+ spatch rules/const-to-let.spatch src --interactive
39
+ ```
40
+
41
+ ## API
42
+
43
+ ```ts
44
+ import { patchProject } from "@claudiu-ceia/spatch";
45
+
46
+ await patchProject(patchInput, {
47
+ cwd: "/repo", // optional, default process.cwd()
48
+ scope: "src", // file or directory, default "."
49
+ dryRun: false, // default false
50
+ encoding: "utf8", // default utf8
51
+ });
52
+ ```
53
+
54
+ `patchInput` can be:
55
+ - a patch document string
56
+ - a path to a patch file (resolved from `cwd`)
57
+
58
+ ## Patch Document Grammar
59
+
60
+ ### Line kinds
61
+
62
+ - `-...`: deletion line (belongs to match pattern only)
63
+ - `+...`: addition line (belongs to replacement only)
64
+ - ` ...`: context line (belongs to both pattern and replacement)
65
+ - `\-...` and `\+...`: escaped marker lines, treated as literal context starting with `-` or `+`
66
+
67
+ ### Minimum change rule
68
+
69
+ A patch document must contain at least one `-` line or one `+` line.
70
+
71
+ ### Newline behavior
72
+
73
+ If the patch document ends with a trailing newline, both generated `pattern` and `replacement` preserve it.
74
+
75
+ ## Metavariables
76
+
77
+ Inside pattern/replacement text, holes use this syntax:
78
+
79
+ - `:[name]`
80
+ - `:[_]` (anonymous hole, not captured)
81
+ - `:[name~regex]` (capture must satisfy regex)
82
+ - `...` (variadic wildcard; captured and reusable in replacement)
83
+
84
+ Examples:
85
+
86
+ ```text
87
+ -const :[name] = :[value];
88
+ +let :[name] = :[value];
89
+ ```
90
+
91
+ ```text
92
+ -const :[name~[a-z]+] = :[value~\d+];
93
+ +let :[name] = Number(:[value]);
94
+ ```
95
+
96
+ Repeated holes enforce equality:
97
+
98
+ ```text
99
+ -:[x] + :[x];
100
+ +double(:[x]);
101
+ ```
102
+
103
+ `foo + foo` matches, `foo + bar` does not.
104
+
105
+ Variadic example:
106
+
107
+ ```text
108
+ -foo(:[x], ...);
109
+ +bar(:[x], ...);
110
+ ```
111
+
112
+ Rewrites the callee and preserves remaining arguments.
113
+
114
+ ## How It Works
115
+
116
+ ### 1) Parse phase
117
+
118
+ `patchProject` resolves `patchInput` into a patch document and parses it into:
119
+ - `pattern`
120
+ - `replacement`
121
+
122
+ ### 2) Rewrite phase
123
+
124
+ For each scoped file:
125
+ - compile template tokens from `pattern`
126
+ - find all structural matches
127
+ - render `replacement` with captures
128
+ - apply replacements
129
+ - optionally write file (skipped in `dryRun`)
130
+
131
+ ### 3) Output phase
132
+
133
+ Return an aggregate result:
134
+ - files scanned/matched/changed
135
+ - match and replacement counts
136
+ - elapsed time
137
+ - per-file occurrences with spans and captures
138
+
139
+ ## Structural Balancing
140
+
141
+ Hole captures are checked for structural balance to avoid malformed partial captures.
142
+
143
+ Balanced constructs supported in capture chunks:
144
+ - parentheses `(...)`
145
+ - brackets `[...]`
146
+ - braces `{...}`
147
+ - single/double/template strings
148
+ - line and block comments
149
+
150
+ ## End-to-End Example
151
+
152
+ Patch document:
153
+
154
+ ```text
155
+ function wrap() {
156
+ - const value = :[value];
157
+ + let value = :[value];
158
+ return value;
159
+ }
160
+ ```
161
+
162
+ Call:
163
+
164
+ ```ts
165
+ await patchProject("rules/wrap.spatch", { cwd: "/repo", scope: "src" });
166
+ ```
167
+
168
+ ## Flow Diagram
169
+
170
+ ```text
171
+ patchProject(patchInput, options)
172
+ -> parsePatchInvocation
173
+ -> resolve patch text (inline or file)
174
+ -> parsePatchDocument (+/-/context)
175
+ -> rewriteProject
176
+ -> compileTemplate(pattern)
177
+ -> collect files
178
+ -> for each file: match -> render -> apply -> (write)
179
+ -> buildSpatchResult
180
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ import { formatPatchOutput, runPatchCommand } from "./command.js";
3
+ function parsePositiveInteger(name, raw) {
4
+ const value = Number(raw);
5
+ if (!Number.isFinite(value) || value <= 0) {
6
+ throw new Error(`${name} must be a positive number`);
7
+ }
8
+ return Math.floor(value);
9
+ }
10
+ function readFlagValue(argv, index) {
11
+ const token = argv[index];
12
+ if (!token) {
13
+ throw new Error("Missing flag token");
14
+ }
15
+ const eqIndex = token.indexOf("=");
16
+ if (eqIndex >= 0) {
17
+ const value = token.slice(eqIndex + 1);
18
+ if (value.length === 0) {
19
+ throw new Error(`Missing value for ${token.slice(0, eqIndex)}`);
20
+ }
21
+ return { value, consumed: 1 };
22
+ }
23
+ const next = argv[index + 1];
24
+ if (!next) {
25
+ throw new Error(`Missing value for ${token}`);
26
+ }
27
+ return { value: next, consumed: 2 };
28
+ }
29
+ function printHelp() {
30
+ process.stdout.write([
31
+ "spatch - structural patch for TS/JS",
32
+ "",
33
+ "Usage:",
34
+ " spatch [--dry-run] [--interactive] [--json] [--no-color] [--cwd <path>] <patch-input> [scope]",
35
+ "",
36
+ "Flags:",
37
+ " --dry-run Preview changes without writing files",
38
+ " --interactive Interactively select which matches to apply",
39
+ " --json Output structured JSON",
40
+ " --no-color Disable colored output",
41
+ " --cwd <path> Working directory for resolving patch and scope",
42
+ " --concurrency <n> Max files processed concurrently (default: 8)",
43
+ " --verbose <level> Perf tracing to stderr (1=summary, 2=slow files)",
44
+ "",
45
+ ].join("\n"));
46
+ }
47
+ const argv = process.argv.slice(2);
48
+ if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
49
+ printHelp();
50
+ process.exit(0);
51
+ }
52
+ const flags = {};
53
+ const positional = [];
54
+ for (let i = 0; i < argv.length; i += 1) {
55
+ const token = argv[i];
56
+ if (!token)
57
+ continue;
58
+ if (!token.startsWith("-")) {
59
+ positional.push(token);
60
+ continue;
61
+ }
62
+ if (token === "--dry-run") {
63
+ flags["dry-run"] = true;
64
+ continue;
65
+ }
66
+ if (token === "--interactive") {
67
+ flags.interactive = true;
68
+ continue;
69
+ }
70
+ if (token === "--json") {
71
+ flags.json = true;
72
+ continue;
73
+ }
74
+ if (token === "--no-color") {
75
+ flags["no-color"] = true;
76
+ continue;
77
+ }
78
+ if (token === "--cwd" || token.startsWith("--cwd=")) {
79
+ const { value, consumed } = readFlagValue(argv, i);
80
+ flags.cwd = value;
81
+ i += consumed - 1;
82
+ continue;
83
+ }
84
+ if (token === "--concurrency" || token.startsWith("--concurrency=")) {
85
+ const { value, consumed } = readFlagValue(argv, i);
86
+ flags.concurrency = parsePositiveInteger("--concurrency", value);
87
+ i += consumed - 1;
88
+ continue;
89
+ }
90
+ if (token === "--verbose" || token.startsWith("--verbose=")) {
91
+ const { value, consumed } = readFlagValue(argv, i);
92
+ const level = Number(value);
93
+ if (!Number.isFinite(level) || level < 0) {
94
+ throw new Error("--verbose must be a non-negative number");
95
+ }
96
+ flags.verbose = Math.floor(level);
97
+ i += consumed - 1;
98
+ continue;
99
+ }
100
+ throw new Error(`Unknown flag: ${token}`);
101
+ }
102
+ const patchInput = positional[0];
103
+ const scope = positional[1];
104
+ if (!patchInput) {
105
+ printHelp();
106
+ process.exit(1);
107
+ }
108
+ if (positional.length > 2) {
109
+ throw new Error("Too many positional arguments.");
110
+ }
111
+ const result = await runPatchCommand(patchInput, scope, flags);
112
+ if (flags.json ?? false) {
113
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
114
+ }
115
+ else {
116
+ const output = formatPatchOutput(result, {
117
+ color: Boolean(process.stdout.isTTY) && !(flags["no-color"] ?? false),
118
+ });
119
+ process.stdout.write(`${output}\n`);
120
+ }
@@ -0,0 +1,29 @@
1
+ import { type ChalkInstance } from "chalk";
2
+ import type { SpatchOccurrence, SpatchResult } from "./types.ts";
3
+ export type PatchCommandFlags = {
4
+ "dry-run"?: boolean;
5
+ interactive?: boolean;
6
+ json?: boolean;
7
+ "no-color"?: boolean;
8
+ cwd?: string;
9
+ concurrency?: number;
10
+ verbose?: number;
11
+ };
12
+ type InteractiveChoice = "yes" | "no" | "all" | "quit";
13
+ export type InteractiveContext = {
14
+ file: string;
15
+ occurrence: SpatchOccurrence;
16
+ changeNumber: number;
17
+ totalChanges: number;
18
+ };
19
+ export type RunPatchCommandOptions = {
20
+ interactiveDecider?: (ctx: InteractiveContext) => Promise<InteractiveChoice>;
21
+ };
22
+ export declare function runPatchCommand(patchInput: string, scope: string | undefined, flags: PatchCommandFlags, options?: RunPatchCommandOptions): Promise<SpatchResult>;
23
+ type FormatPatchOutputOptions = {
24
+ color?: boolean;
25
+ chalkInstance?: ChalkInstance;
26
+ };
27
+ export declare function formatPatchOutput(result: SpatchResult, options?: FormatPatchOutputOptions): string;
28
+ export declare const patchCommand: import("@stricli/core").Command<import("@stricli/core").CommandContext>;
29
+ export {};
@@ -0,0 +1,374 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { stderr as processStderr, stdin as processStdin, stdout as processStdout, } from "node:process";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { buildCommand } from "@stricli/core";
6
+ import chalk, { Chalk } from "chalk";
7
+ import { patchProject } from "./spatch.js";
8
+ export async function runPatchCommand(patchInput, scope, flags, options = {}) {
9
+ const patchScope = scope ?? ".";
10
+ const patchCwd = flags.cwd;
11
+ if (flags.interactive ?? false) {
12
+ if (flags["dry-run"] ?? false) {
13
+ throw new Error("Cannot combine --interactive with --dry-run.");
14
+ }
15
+ return runInteractivePatchCommand(patchInput, patchScope, patchCwd, flags["no-color"] ?? false, options.interactiveDecider);
16
+ }
17
+ return patchProject(patchInput, {
18
+ concurrency: flags.concurrency,
19
+ cwd: patchCwd,
20
+ dryRun: flags["dry-run"] ?? false,
21
+ scope: patchScope,
22
+ verbose: flags.verbose,
23
+ logger: flags.verbose ? (line) => processStderr.write(`${line}\n`) : undefined,
24
+ });
25
+ }
26
+ export function formatPatchOutput(result, options = {}) {
27
+ const chalkInstance = buildChalk(options);
28
+ const useColor = chalkInstance.level > 0;
29
+ const lines = [];
30
+ const changedFiles = result.files.filter((file) => file.replacementCount > 0);
31
+ for (const file of changedFiles) {
32
+ const headerPrefix = useColor ? chalkInstance.bold : (value) => value;
33
+ lines.push(headerPrefix(`diff --git a/${file.file} b/${file.file}`));
34
+ lines.push(useColor ? chalkInstance.gray(`--- a/${file.file}`) : `--- a/${file.file}`);
35
+ lines.push(useColor ? chalkInstance.gray(`+++ b/${file.file}`) : `+++ b/${file.file}`);
36
+ for (const occurrence of file.occurrences) {
37
+ if (occurrence.matched === occurrence.replacement) {
38
+ continue;
39
+ }
40
+ const oldCount = countLines(occurrence.matched);
41
+ const newCount = countLines(occurrence.replacement);
42
+ const hunkHeader = `@@ -${occurrence.line},${oldCount} +${occurrence.line},${newCount} @@`;
43
+ lines.push(useColor ? chalkInstance.cyan(hunkHeader) : hunkHeader);
44
+ for (const oldLine of splitDiffLines(occurrence.matched)) {
45
+ const line = `-${oldLine}`;
46
+ lines.push(useColor ? chalkInstance.red(line) : line);
47
+ }
48
+ for (const newLine of splitDiffLines(occurrence.replacement)) {
49
+ const line = `+${newLine}`;
50
+ lines.push(useColor ? chalkInstance.green(line) : line);
51
+ }
52
+ }
53
+ }
54
+ if (changedFiles.length === 0) {
55
+ lines.push(useColor ? chalkInstance.gray("No changes.") : "No changes.");
56
+ }
57
+ const summary = [
58
+ `${result.filesChanged} ${pluralize("file", result.filesChanged)} changed`,
59
+ `${result.totalReplacements} ${pluralize("replacement", result.totalReplacements)}`,
60
+ result.dryRun ? "(dry-run)" : null,
61
+ ]
62
+ .filter((part) => part !== null)
63
+ .join(", ");
64
+ lines.push(useColor ? chalkInstance.gray(summary) : summary);
65
+ return lines.join("\n");
66
+ }
67
+ export const patchCommand = buildCommand({
68
+ async func(flags, patchInput, scope) {
69
+ const result = await runPatchCommand(patchInput, scope, flags);
70
+ if (flags.json ?? false) {
71
+ this.process.stdout.write(JSON.stringify(result, null, 2) + "\n");
72
+ return;
73
+ }
74
+ const output = formatPatchOutput(result, {
75
+ color: Boolean(processStdout.isTTY) && !(flags["no-color"] ?? false),
76
+ });
77
+ this.process.stdout.write(`${output}\n`);
78
+ },
79
+ parameters: {
80
+ flags: {
81
+ concurrency: {
82
+ kind: "parsed",
83
+ optional: true,
84
+ brief: "Max files processed concurrently (default: 8)",
85
+ placeholder: "n",
86
+ parse: (input) => {
87
+ const value = Number(input);
88
+ if (!Number.isFinite(value) || value <= 0) {
89
+ throw new Error("--concurrency must be a positive number");
90
+ }
91
+ return Math.floor(value);
92
+ },
93
+ },
94
+ verbose: {
95
+ kind: "parsed",
96
+ optional: true,
97
+ brief: "Print perf tracing (1=summary, 2=includes slow files)",
98
+ placeholder: "level",
99
+ parse: (input) => {
100
+ const value = Number(input);
101
+ if (!Number.isFinite(value) || value < 0) {
102
+ throw new Error("--verbose must be a non-negative number");
103
+ }
104
+ return Math.floor(value);
105
+ },
106
+ },
107
+ interactive: {
108
+ kind: "boolean",
109
+ optional: true,
110
+ brief: "Interactively select which matches to apply",
111
+ },
112
+ json: {
113
+ kind: "boolean",
114
+ optional: true,
115
+ brief: "Output structured JSON instead of compact diff-style text",
116
+ },
117
+ "no-color": {
118
+ kind: "boolean",
119
+ optional: true,
120
+ brief: "Disable colored output",
121
+ },
122
+ "dry-run": {
123
+ kind: "boolean",
124
+ optional: true,
125
+ brief: "Preview changes without writing files",
126
+ },
127
+ cwd: {
128
+ kind: "parsed",
129
+ optional: true,
130
+ brief: "Working directory for resolving patch file and scope",
131
+ placeholder: "path",
132
+ parse: (input) => input,
133
+ },
134
+ },
135
+ positional: {
136
+ kind: "tuple",
137
+ parameters: [
138
+ {
139
+ brief: "Patch document text or path to patch document file",
140
+ placeholder: "patch",
141
+ parse: (input) => input,
142
+ },
143
+ {
144
+ brief: "Scope file or directory (defaults to current directory)",
145
+ placeholder: "scope",
146
+ parse: (input) => input,
147
+ optional: true,
148
+ },
149
+ ],
150
+ },
151
+ },
152
+ docs: {
153
+ brief: "Apply structural rewrite from a patch document",
154
+ },
155
+ });
156
+ function buildChalk(options) {
157
+ if (options.chalkInstance) {
158
+ return options.chalkInstance;
159
+ }
160
+ const shouldColor = options.color ?? false;
161
+ if (!shouldColor) {
162
+ return new Chalk({ level: 0 });
163
+ }
164
+ const level = chalk.level > 0 ? chalk.level : 1;
165
+ return new Chalk({ level });
166
+ }
167
+ function splitDiffLines(text) {
168
+ const normalized = text.replaceAll("\r\n", "\n");
169
+ if (normalized.length === 0) {
170
+ return [""];
171
+ }
172
+ return normalized.split("\n");
173
+ }
174
+ function countLines(text) {
175
+ const normalized = text.replaceAll("\r\n", "\n");
176
+ if (normalized.length === 0) {
177
+ return 0;
178
+ }
179
+ return normalized.split("\n").length;
180
+ }
181
+ function pluralize(word, count) {
182
+ return count === 1 ? word : `${word}s`;
183
+ }
184
+ async function runInteractivePatchCommand(patchInput, scope, cwd, noColor, interactiveDecider) {
185
+ if (!interactiveDecider && (!processStdin.isTTY || !processStdout.isTTY)) {
186
+ throw new Error("Interactive mode requires a TTY stdin/stdout.");
187
+ }
188
+ const startedAt = Date.now();
189
+ const dryResult = await patchProject(patchInput, {
190
+ cwd,
191
+ dryRun: true,
192
+ scope,
193
+ });
194
+ const totalChanges = dryResult.files.reduce((count, file) => count +
195
+ file.occurrences.filter((occurrence) => occurrence.matched !== occurrence.replacement).length, 0);
196
+ let interactivePrompt = null;
197
+ const decider = interactiveDecider ??
198
+ ((interactivePrompt = await createTerminalInteractiveDecider(noColor)),
199
+ interactivePrompt.decider);
200
+ const selectedByFile = new Map();
201
+ let applyAll = false;
202
+ let stop = false;
203
+ let changeNumber = 0;
204
+ try {
205
+ for (const file of dryResult.files) {
206
+ const selected = [];
207
+ for (const occurrence of file.occurrences) {
208
+ if (occurrence.matched === occurrence.replacement) {
209
+ continue;
210
+ }
211
+ changeNumber += 1;
212
+ if (applyAll) {
213
+ selected.push(occurrence);
214
+ continue;
215
+ }
216
+ const choice = await decider({
217
+ file: file.file,
218
+ occurrence,
219
+ changeNumber,
220
+ totalChanges,
221
+ });
222
+ if (choice === "yes") {
223
+ selected.push(occurrence);
224
+ continue;
225
+ }
226
+ if (choice === "all") {
227
+ applyAll = true;
228
+ selected.push(occurrence);
229
+ continue;
230
+ }
231
+ if (choice === "quit") {
232
+ stop = true;
233
+ break;
234
+ }
235
+ }
236
+ selectedByFile.set(file.file, selected);
237
+ if (stop) {
238
+ break;
239
+ }
240
+ }
241
+ }
242
+ finally {
243
+ interactivePrompt?.close();
244
+ }
245
+ const fileResults = [];
246
+ let filesChanged = 0;
247
+ let totalReplacements = 0;
248
+ for (const file of dryResult.files) {
249
+ const selected = selectedByFile.get(file.file) ?? [];
250
+ if (selected.length === 0) {
251
+ fileResults.push({
252
+ ...file,
253
+ replacementCount: 0,
254
+ changed: false,
255
+ byteDelta: 0,
256
+ occurrences: [],
257
+ });
258
+ continue;
259
+ }
260
+ const absolutePath = path.resolve(cwd ?? process.cwd(), file.file);
261
+ const originalText = await readFile(absolutePath, "utf8");
262
+ const rewrittenText = applySelectedOccurrences(originalText, selected);
263
+ const changed = rewrittenText !== originalText;
264
+ if (changed) {
265
+ await writeFile(absolutePath, rewrittenText, "utf8");
266
+ }
267
+ const replacementCount = selected.filter((occurrence) => occurrence.matched !== occurrence.replacement).length;
268
+ totalReplacements += replacementCount;
269
+ if (changed) {
270
+ filesChanged += 1;
271
+ }
272
+ fileResults.push({
273
+ ...file,
274
+ replacementCount,
275
+ changed,
276
+ byteDelta: changed
277
+ ? Buffer.byteLength(rewrittenText, "utf8") -
278
+ Buffer.byteLength(originalText, "utf8")
279
+ : 0,
280
+ occurrences: selected,
281
+ });
282
+ }
283
+ return {
284
+ ...dryResult,
285
+ dryRun: false,
286
+ filesChanged,
287
+ totalReplacements,
288
+ elapsedMs: Date.now() - startedAt,
289
+ files: fileResults,
290
+ };
291
+ }
292
+ function applySelectedOccurrences(source, occurrences) {
293
+ if (occurrences.length === 0) {
294
+ return source;
295
+ }
296
+ const sorted = [...occurrences].sort((left, right) => left.start - right.start);
297
+ const parts = [];
298
+ let cursor = 0;
299
+ for (const occurrence of sorted) {
300
+ parts.push(source.slice(cursor, occurrence.start));
301
+ parts.push(occurrence.replacement);
302
+ cursor = occurrence.end;
303
+ }
304
+ parts.push(source.slice(cursor));
305
+ return parts.join("");
306
+ }
307
+ async function createTerminalInteractiveDecider(noColor) {
308
+ const chalkInstance = buildChalk({
309
+ color: processStdout.isTTY && !noColor,
310
+ });
311
+ const useColor = chalkInstance.level > 0;
312
+ const rl = createInterface({
313
+ input: processStdin,
314
+ output: processStdout,
315
+ });
316
+ return {
317
+ decider: async ({ file, occurrence, changeNumber, totalChanges }) => {
318
+ processStdout.write(`\n${formatInteractiveChangeBlock({ file, occurrence, changeNumber, totalChanges }, {
319
+ chalkInstance,
320
+ color: useColor,
321
+ })}\n`);
322
+ while (true) {
323
+ const answer = await rl.question(useColor
324
+ ? chalkInstance.bold("Choice [y/n/a/q] (default: n): ")
325
+ : "Choice [y/n/a/q] (default: n): ");
326
+ const parsed = parseInteractiveChoice(answer);
327
+ if (parsed) {
328
+ return parsed;
329
+ }
330
+ processStdout.write(useColor
331
+ ? `${chalkInstance.yellow("Invalid choice.")} Use y, n, a, or q.\n`
332
+ : "Invalid choice. Use y, n, a, or q.\n");
333
+ }
334
+ },
335
+ close: () => rl.close(),
336
+ };
337
+ }
338
+ function formatInteractiveChangeBlock(ctx, options = {}) {
339
+ const chalkInstance = buildChalk(options);
340
+ const useColor = chalkInstance.level > 0;
341
+ const divider = "─".repeat(72);
342
+ const oldCount = countLines(ctx.occurrence.matched);
343
+ const newCount = countLines(ctx.occurrence.replacement);
344
+ const hunkHeader = `@@ -${ctx.occurrence.line},${oldCount} +${ctx.occurrence.line},${newCount} @@`;
345
+ const lines = [
346
+ useColor ? chalkInstance.gray(divider) : divider,
347
+ useColor
348
+ ? chalkInstance.bold(`Change ${ctx.changeNumber}/${ctx.totalChanges} · ${ctx.file}:${ctx.occurrence.line}:${ctx.occurrence.character}`)
349
+ : `Change ${ctx.changeNumber}/${ctx.totalChanges} · ${ctx.file}:${ctx.occurrence.line}:${ctx.occurrence.character}`,
350
+ useColor ? chalkInstance.cyan(hunkHeader) : hunkHeader,
351
+ ...splitDiffLines(ctx.occurrence.matched).map((line) => useColor ? chalkInstance.red(`-${line}`) : `-${line}`),
352
+ ...splitDiffLines(ctx.occurrence.replacement).map((line) => useColor ? chalkInstance.green(`+${line}`) : `+${line}`),
353
+ useColor
354
+ ? chalkInstance.gray("Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit")
355
+ : "Actions: [y] apply · [n] skip · [a] apply remaining · [q] quit",
356
+ ];
357
+ return lines.join("\n");
358
+ }
359
+ function parseInteractiveChoice(answer) {
360
+ const normalized = answer.trim().toLowerCase();
361
+ if (normalized.length === 0 || normalized === "n" || normalized === "no") {
362
+ return "no";
363
+ }
364
+ if (normalized === "y" || normalized === "yes") {
365
+ return "yes";
366
+ }
367
+ if (normalized === "a" || normalized === "all") {
368
+ return "all";
369
+ }
370
+ if (normalized === "q" || normalized === "quit") {
371
+ return "quit";
372
+ }
373
+ return null;
374
+ }