@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,572 @@
1
+ import { statSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export type WrapperOutputFormat = "lines";
5
+
6
+ export type WrapperConfig = {
7
+ prefix: string;
8
+ outputFormat?: WrapperOutputFormat;
9
+ };
10
+
11
+ export type ShellMutationDetectionConfig = {
12
+ enabled: boolean;
13
+ argumentParsing: boolean;
14
+ snapshotGlobs: string[];
15
+ wrappers: WrapperConfig[];
16
+ };
17
+
18
+ export const DEFAULT_SHELL_MUTATION_DETECTION: ShellMutationDetectionConfig = {
19
+ enabled: false,
20
+ argumentParsing: true,
21
+ snapshotGlobs: [],
22
+ wrappers: [],
23
+ };
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Tokenizer
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Tokenize a simple shell command into raw tokens, tracking redirections.
31
+ *
32
+ * Bails (returns `undefined`) on any construct that breaks the
33
+ * "single simple command" assumption: pipes, logical operators, command
34
+ * substitution, backticks, subshells, sequencing, environment assignments
35
+ * before the command, etc. The conservative bail keeps the parser auditable.
36
+ */
37
+ type ParsedCommand = {
38
+ argv: string[];
39
+ redirects: Array<{ op: ">" | ">>"; target: string }>;
40
+ };
41
+
42
+ function isMetaChar(ch: string): boolean {
43
+ return ch === "|" || ch === "&" || ch === ";" || ch === "(" || ch === ")";
44
+ }
45
+
46
+ function tokenizeSimpleCommand(input: string): ParsedCommand | undefined {
47
+ const argv: string[] = [];
48
+ const redirects: ParsedCommand["redirects"] = [];
49
+
50
+ let i = 0;
51
+ const n = input.length;
52
+ let current = "";
53
+ let inSingle = false;
54
+ let inDouble = false;
55
+ let pendingRedirect: ">" | ">>" | undefined;
56
+
57
+ const flush = (): boolean => {
58
+ if (current.length === 0) {
59
+ return true;
60
+ }
61
+ if (pendingRedirect) {
62
+ redirects.push({ op: pendingRedirect, target: current });
63
+ pendingRedirect = undefined;
64
+ } else {
65
+ argv.push(current);
66
+ }
67
+ current = "";
68
+ return true;
69
+ };
70
+
71
+ while (i < n) {
72
+ const ch = input[i];
73
+
74
+ if (inSingle) {
75
+ if (ch === "'") {
76
+ inSingle = false;
77
+ i += 1;
78
+ continue;
79
+ }
80
+ current += ch;
81
+ i += 1;
82
+ continue;
83
+ }
84
+
85
+ if (inDouble) {
86
+ if (ch === "\\" && i + 1 < n) {
87
+ const next = input[i + 1];
88
+ if (next === '"' || next === "\\" || next === "$" || next === "`") {
89
+ current += next;
90
+ i += 2;
91
+ continue;
92
+ }
93
+ }
94
+ if (ch === "$" || ch === "`") {
95
+ return undefined; // command substitution / variable expansion in dquotes
96
+ }
97
+ if (ch === '"') {
98
+ inDouble = false;
99
+ i += 1;
100
+ continue;
101
+ }
102
+ current += ch;
103
+ i += 1;
104
+ continue;
105
+ }
106
+
107
+ if (ch === "'") {
108
+ inSingle = true;
109
+ i += 1;
110
+ continue;
111
+ }
112
+ if (ch === '"') {
113
+ inDouble = true;
114
+ i += 1;
115
+ continue;
116
+ }
117
+
118
+ if (ch === "\\" && i + 1 < n) {
119
+ current += input[i + 1];
120
+ i += 2;
121
+ continue;
122
+ }
123
+
124
+ if (ch === "$" || ch === "`") {
125
+ return undefined; // command substitution
126
+ }
127
+
128
+ if (isMetaChar(ch)) {
129
+ return undefined; // pipeline / sequencing / subshell
130
+ }
131
+
132
+ if (ch === "<") {
133
+ return undefined; // input redirect — bail conservatively
134
+ }
135
+
136
+ if (ch === ">") {
137
+ flush();
138
+ if (input[i + 1] === ">") {
139
+ pendingRedirect = ">>";
140
+ i += 2;
141
+ } else {
142
+ pendingRedirect = ">";
143
+ i += 1;
144
+ }
145
+ continue;
146
+ }
147
+
148
+ if (ch === " " || ch === "\t" || ch === "\n") {
149
+ flush();
150
+ i += 1;
151
+ continue;
152
+ }
153
+
154
+ current += ch;
155
+ i += 1;
156
+ }
157
+
158
+ if (inSingle || inDouble) {
159
+ return undefined;
160
+ }
161
+ flush();
162
+
163
+ if (pendingRedirect) {
164
+ return undefined; // dangling redirect target
165
+ }
166
+
167
+ return { argv, redirects };
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Strategy 1: argument parsing for known mutating commands
172
+ // ---------------------------------------------------------------------------
173
+
174
+ function stripSedBackupExt(flag: string): string | undefined {
175
+ // -i -> ""
176
+ // -i.bak -> ".bak" (we ignore the backup file)
177
+ // -i '' -> handled at argv level
178
+ if (flag === "-i") {
179
+ return "";
180
+ }
181
+ if (flag.startsWith("-i") && flag.length > 2) {
182
+ return flag.slice(2);
183
+ }
184
+ return undefined;
185
+ }
186
+
187
+ function parseSed(argv: string[]): string[] | undefined {
188
+ // Recognize: sed -i[ext] [-e SCRIPT|-f FILE|SCRIPT] FILE...
189
+ // We only act when -i is present. We do not interpret the script.
190
+ let i = 1;
191
+ let sawInPlace = false;
192
+ while (i < argv.length) {
193
+ const tok = argv[i];
194
+ if (!tok.startsWith("-") || tok === "-") {
195
+ break;
196
+ }
197
+ if (tok === "--") {
198
+ i += 1;
199
+ break;
200
+ }
201
+ const sed = stripSedBackupExt(tok);
202
+ if (sed !== undefined) {
203
+ sawInPlace = true;
204
+ i += 1;
205
+ // Some forms take an empty next arg as the backup suffix: `sed -i '' ...`
206
+ if (tok === "-i" && i < argv.length && argv[i] === "") {
207
+ i += 1;
208
+ }
209
+ continue;
210
+ }
211
+ if (tok === "-e" || tok === "-f") {
212
+ // skip the next arg (script or script-file)
213
+ i += 2;
214
+ continue;
215
+ }
216
+ // Unknown flag → bail
217
+ return undefined;
218
+ }
219
+
220
+ if (!sawInPlace) {
221
+ return [];
222
+ }
223
+
224
+ // First non-flag is the script unless -e/-f provided one. We can't reliably
225
+ // distinguish, so assume the first remaining token is the script and the
226
+ // rest are files. This matches `sed -i 's/a/b/' foo.txt` and similar.
227
+ if (i >= argv.length) {
228
+ return undefined;
229
+ }
230
+ const files = argv.slice(i + 1);
231
+ if (files.length === 0) {
232
+ return undefined;
233
+ }
234
+ return files;
235
+ }
236
+
237
+ function parseMv(argv: string[]): string[] | undefined {
238
+ // Conservative: only single-source single-dest form, no flags we don't know.
239
+ const positional: string[] = [];
240
+ for (let i = 1; i < argv.length; i += 1) {
241
+ const tok = argv[i];
242
+ if (tok === "--") {
243
+ positional.push(...argv.slice(i + 1));
244
+ break;
245
+ }
246
+ if (tok.startsWith("-")) {
247
+ // Allow common safe-ish flags; bail otherwise.
248
+ if (tok === "-f" || tok === "-v" || tok === "-n") {
249
+ continue;
250
+ }
251
+ return undefined;
252
+ }
253
+ positional.push(tok);
254
+ }
255
+ if (positional.length !== 2) {
256
+ return undefined;
257
+ }
258
+ return [positional[1]];
259
+ }
260
+
261
+ function parseCp(argv: string[]): string[] | undefined {
262
+ const positional: string[] = [];
263
+ for (let i = 1; i < argv.length; i += 1) {
264
+ const tok = argv[i];
265
+ if (tok === "--") {
266
+ positional.push(...argv.slice(i + 1));
267
+ break;
268
+ }
269
+ if (tok.startsWith("-")) {
270
+ if (tok === "-f" || tok === "-v" || tok === "-p") {
271
+ continue;
272
+ }
273
+ return undefined;
274
+ }
275
+ positional.push(tok);
276
+ }
277
+ if (positional.length !== 2) {
278
+ return undefined;
279
+ }
280
+ return [positional[1]];
281
+ }
282
+
283
+ function parseTouch(argv: string[]): string[] | undefined {
284
+ const files: string[] = [];
285
+ for (let i = 1; i < argv.length; i += 1) {
286
+ const tok = argv[i];
287
+ if (tok === "--") {
288
+ files.push(...argv.slice(i + 1));
289
+ break;
290
+ }
291
+ if (tok.startsWith("-")) {
292
+ // touch has -a, -m, -c, -r FILE, -t TIME, -d DATE — bail on anything
293
+ // not in our minimal allowlist to keep the surface auditable.
294
+ if (tok === "-a" || tok === "-m" || tok === "-c") {
295
+ continue;
296
+ }
297
+ return undefined;
298
+ }
299
+ files.push(tok);
300
+ }
301
+ if (files.length === 0) {
302
+ return undefined;
303
+ }
304
+ return files;
305
+ }
306
+
307
+ function parseTee(argv: string[]): string[] | undefined {
308
+ const files: string[] = [];
309
+ for (let i = 1; i < argv.length; i += 1) {
310
+ const tok = argv[i];
311
+ if (tok === "--") {
312
+ files.push(...argv.slice(i + 1));
313
+ break;
314
+ }
315
+ if (tok.startsWith("-")) {
316
+ if (tok === "-a" || tok === "--append") {
317
+ continue;
318
+ }
319
+ return undefined;
320
+ }
321
+ files.push(tok);
322
+ }
323
+ if (files.length === 0) {
324
+ return undefined;
325
+ }
326
+ return files;
327
+ }
328
+
329
+ /**
330
+ * Parse a bash command string and return any files the command is known to
331
+ * mutate. Returns an empty array if the command shape is recognized but
332
+ * touches no files; returns an empty array (with no error) if the command
333
+ * shape is unknown or too complex to reason about.
334
+ */
335
+ export function parseKnownCommand(input: string): string[] {
336
+ const parsed = tokenizeSimpleCommand(input);
337
+ if (!parsed) {
338
+ return [];
339
+ }
340
+ const { argv, redirects } = parsed;
341
+
342
+ const results: string[] = [];
343
+
344
+ // Recognized command shapes contribute their argument-derived files.
345
+ // Any unrecognized command bails out — even if a redirection is present —
346
+ // because the command may have unmodelled side effects.
347
+ if (argv.length > 0) {
348
+ const cmd = argv[0];
349
+ let parsedArgs: string[] | undefined;
350
+ switch (cmd) {
351
+ case "sed":
352
+ parsedArgs = parseSed(argv);
353
+ break;
354
+ case "mv":
355
+ parsedArgs = parseMv(argv);
356
+ break;
357
+ case "cp":
358
+ parsedArgs = parseCp(argv);
359
+ break;
360
+ case "touch":
361
+ parsedArgs = parseTouch(argv);
362
+ break;
363
+ case "tee":
364
+ parsedArgs = parseTee(argv);
365
+ break;
366
+ case "echo":
367
+ case "printf":
368
+ case "cat":
369
+ // Stdout-producing builtins are safe partners for redirections.
370
+ parsedArgs = [];
371
+ break;
372
+ default:
373
+ return [];
374
+ }
375
+
376
+ if (parsedArgs === undefined) {
377
+ return [];
378
+ }
379
+ results.push(...parsedArgs);
380
+ }
381
+
382
+ for (const r of redirects) {
383
+ results.push(r.target);
384
+ }
385
+
386
+ return results;
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // Strategy 3: user-declared shell wrappers
391
+ // ---------------------------------------------------------------------------
392
+
393
+ /**
394
+ * If `input` starts with any configured wrapper prefix, return the file paths
395
+ * the wrapper printed on stdout (one per line). Empty lines and lines that
396
+ * are clearly not paths (start with `[`, contain `:` followed by space) are
397
+ * skipped.
398
+ */
399
+ export function matchWrapper(
400
+ input: string,
401
+ output: string,
402
+ wrappers: WrapperConfig[],
403
+ ): string[] {
404
+ const trimmed = input.trimStart();
405
+ for (const wrapper of wrappers) {
406
+ if (!wrapper.prefix) {
407
+ continue;
408
+ }
409
+ if (
410
+ trimmed === wrapper.prefix ||
411
+ trimmed.startsWith(`${wrapper.prefix} `) ||
412
+ trimmed.startsWith(`${wrapper.prefix}\t`) ||
413
+ trimmed.startsWith(`${wrapper.prefix}\n`)
414
+ ) {
415
+ const format = wrapper.outputFormat ?? "lines";
416
+ if (format === "lines") {
417
+ return parseLinesOutput(output);
418
+ }
419
+ }
420
+ }
421
+ return [];
422
+ }
423
+
424
+ function parseLinesOutput(output: string): string[] {
425
+ const out: string[] = [];
426
+ for (const rawLine of output.split(/\r?\n/)) {
427
+ const line = rawLine.trim();
428
+ if (line.length === 0) {
429
+ continue;
430
+ }
431
+ out.push(line);
432
+ }
433
+ return out;
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Strategy 2: pre/post snapshot of explicit globs
438
+ // ---------------------------------------------------------------------------
439
+
440
+ export type SnapshotEntry = { path: string; mtimeMs: number };
441
+
442
+ export type SnapshotTrackerOptions = {
443
+ cwd: string;
444
+ globs: string[];
445
+ /** Resolve globs to absolute file paths. Injected for tests. */
446
+ resolveGlobs?: (cwd: string, globs: string[]) => string[];
447
+ /** stat function — injected for tests. */
448
+ stat?: (absPath: string) => { mtimeMs: number } | undefined;
449
+ /** Maximum entries to track before warning + truncating. */
450
+ maxEntries?: number;
451
+ /** Receives a single message string when the cap is exceeded. */
452
+ onWarn?: (message: string) => void;
453
+ };
454
+
455
+ const DEFAULT_MAX_ENTRIES = 5000;
456
+
457
+ function defaultStat(absPath: string): { mtimeMs: number } | undefined {
458
+ try {
459
+ const s = statSync(absPath);
460
+ if (!s.isFile()) {
461
+ return undefined;
462
+ }
463
+ return { mtimeMs: s.mtimeMs };
464
+ } catch {
465
+ return undefined;
466
+ }
467
+ }
468
+
469
+ function defaultResolveGlobs(_cwd: string, _globs: string[]): string[] {
470
+ // Real glob resolution lives at the wiring layer (and may be deferred until
471
+ // we add a glob library). The default is a no-op so the tracker is testable
472
+ // in isolation via the injectable resolver.
473
+ return [];
474
+ }
475
+
476
+ /**
477
+ * Pre/post mtime snapshot tracker for explicit globs.
478
+ *
479
+ * `before()` records mtimes for all matched files. `after()` re-stats and
480
+ * returns the absolute paths whose mtime advanced. Files that did not exist
481
+ * before but exist after are also reported.
482
+ */
483
+ export class SnapshotTracker {
484
+ private readonly options: Required<
485
+ Pick<SnapshotTrackerOptions, "cwd" | "globs">
486
+ > &
487
+ Required<
488
+ Pick<SnapshotTrackerOptions, "resolveGlobs" | "stat" | "maxEntries">
489
+ > & {
490
+ onWarn: (message: string) => void;
491
+ };
492
+
493
+ private snapshot: Map<string, number> | undefined;
494
+
495
+ constructor(options: SnapshotTrackerOptions) {
496
+ this.options = {
497
+ cwd: options.cwd,
498
+ globs: options.globs,
499
+ resolveGlobs: options.resolveGlobs ?? defaultResolveGlobs,
500
+ stat: options.stat ?? defaultStat,
501
+ maxEntries: options.maxEntries ?? DEFAULT_MAX_ENTRIES,
502
+ onWarn: options.onWarn ?? (() => {}),
503
+ };
504
+ }
505
+
506
+ before(): void {
507
+ if (this.options.globs.length === 0) {
508
+ this.snapshot = new Map();
509
+ return;
510
+ }
511
+ const files = this.options.resolveGlobs(
512
+ this.options.cwd,
513
+ this.options.globs,
514
+ );
515
+ const snapshot = new Map<string, number>();
516
+ let truncated = false;
517
+ for (const file of files) {
518
+ if (snapshot.size >= this.options.maxEntries) {
519
+ truncated = true;
520
+ break;
521
+ }
522
+ const abs = path.isAbsolute(file)
523
+ ? file
524
+ : path.resolve(this.options.cwd, file);
525
+ const s = this.options.stat(abs);
526
+ if (s) {
527
+ snapshot.set(abs, s.mtimeMs);
528
+ }
529
+ }
530
+ if (truncated) {
531
+ this.options.onWarn(
532
+ `pi-autoformat: snapshotGlobs matched more than ${this.options.maxEntries} files; tracking truncated.`,
533
+ );
534
+ }
535
+ this.snapshot = snapshot;
536
+ }
537
+
538
+ after(): string[] {
539
+ const before = this.snapshot;
540
+ this.snapshot = undefined;
541
+ if (!before) {
542
+ return [];
543
+ }
544
+ if (this.options.globs.length === 0) {
545
+ return [];
546
+ }
547
+ const files = this.options.resolveGlobs(
548
+ this.options.cwd,
549
+ this.options.globs,
550
+ );
551
+ const touched: string[] = [];
552
+ let count = 0;
553
+ for (const file of files) {
554
+ if (count >= this.options.maxEntries) {
555
+ break;
556
+ }
557
+ count += 1;
558
+ const abs = path.isAbsolute(file)
559
+ ? file
560
+ : path.resolve(this.options.cwd, file);
561
+ const s = this.options.stat(abs);
562
+ if (!s) {
563
+ continue;
564
+ }
565
+ const prior = before.get(abs);
566
+ if (prior === undefined || s.mtimeMs > prior) {
567
+ touched.push(abs);
568
+ }
569
+ }
570
+ return touched;
571
+ }
572
+ }
@@ -1,29 +1,74 @@
1
1
  import path from "node:path";
2
2
 
3
+ import type { FormatScope } from "./format-scope.js";
4
+ import { isInFormatScope } from "./format-scope.js";
5
+
3
6
  type ToolResultPayload = {
4
7
  path?: unknown;
5
8
  };
6
9
 
7
- const MUTATION_TOOLS = new Set(["write", "edit"]);
10
+ /**
11
+ * A mutation-source handler turns a tool result event into zero or more
12
+ * candidate file paths. Paths may be relative (resolved against `cwd` by
13
+ * the queue) or absolute. The queue handles dedupe and scope filtering.
14
+ */
15
+ export type MutationSourceHandler = (
16
+ toolName: string,
17
+ payload: unknown,
18
+ output: string,
19
+ ) => string[];
20
+
21
+ const writeOrEditHandler: MutationSourceHandler = (toolName, payload) => {
22
+ if (toolName !== "write" && toolName !== "edit") {
23
+ return [];
24
+ }
25
+ if (!isToolResultPayload(payload) || typeof payload.path !== "string") {
26
+ return [];
27
+ }
28
+ return [payload.path];
29
+ };
30
+
31
+ export type TouchedFilesQueueOptions = {
32
+ cwd: string;
33
+ scope?: FormatScope;
34
+ /** Defaults to the built-in write/edit handler only. */
35
+ handlers?: MutationSourceHandler[];
36
+ };
8
37
 
9
38
  export class TouchedFilesQueue {
10
39
  private readonly cwd: string;
40
+ private readonly handlers: MutationSourceHandler[];
41
+ private readonly scope: FormatScope | undefined;
11
42
  private readonly touchedFiles = new Set<string>();
12
43
 
13
- constructor(cwd: string) {
14
- this.cwd = cwd;
15
- }
16
-
17
- recordToolResult(toolName: string, payload: unknown): void {
18
- if (!MUTATION_TOOLS.has(toolName)) {
44
+ constructor(cwdOrOptions: string | TouchedFilesQueueOptions) {
45
+ if (typeof cwdOrOptions === "string") {
46
+ this.cwd = cwdOrOptions;
47
+ this.handlers = [writeOrEditHandler];
48
+ this.scope = undefined;
19
49
  return;
20
50
  }
51
+ this.cwd = cwdOrOptions.cwd;
52
+ this.handlers = cwdOrOptions.handlers ?? [writeOrEditHandler];
53
+ this.scope = cwdOrOptions.scope;
54
+ }
21
55
 
22
- if (!isToolResultPayload(payload) || typeof payload.path !== "string") {
23
- return;
56
+ /**
57
+ * Record a tool result. `output` is the textual stdout/stderr from the
58
+ * tool (used by shell wrapper handlers); pass `""` if unavailable.
59
+ */
60
+ recordToolResult(toolName: string, payload: unknown, output = ""): void {
61
+ for (const handler of this.handlers) {
62
+ const candidates = handler(toolName, payload, output);
63
+ for (const candidate of candidates) {
64
+ this.add(candidate);
65
+ }
24
66
  }
67
+ }
25
68
 
26
- this.touchedFiles.add(normalizePath(this.cwd, payload.path));
69
+ /** Add an externally produced candidate path (e.g., from a snapshot tracker). */
70
+ addPath(filePath: string): void {
71
+ this.add(filePath);
27
72
  }
28
73
 
29
74
  flush(): string[] {
@@ -31,6 +76,17 @@ export class TouchedFilesQueue {
31
76
  this.touchedFiles.clear();
32
77
  return files;
33
78
  }
79
+
80
+ private add(filePath: string): void {
81
+ if (typeof filePath !== "string" || filePath.length === 0) {
82
+ return;
83
+ }
84
+ const normalized = normalizePath(this.cwd, filePath);
85
+ if (this.scope && !isInFormatScope(normalized, this.scope)) {
86
+ return;
87
+ }
88
+ this.touchedFiles.add(normalized);
89
+ }
34
90
  }
35
91
 
36
92
  function isToolResultPayload(value: unknown): value is ToolResultPayload {
@@ -41,6 +97,11 @@ function normalizePath(cwd: string, filePath: string): string {
41
97
  if (path.isAbsolute(filePath)) {
42
98
  return path.normalize(filePath);
43
99
  }
44
-
45
100
  return path.normalize(path.resolve(cwd, filePath));
46
101
  }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Built-in handlers used by the extension wiring.
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export { writeOrEditHandler };