@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.
- package/.github/workflows/ci.yml +1 -3
- package/.github/workflows/release-please.yml +29 -0
- package/.markdownlint-cli2.yaml +14 -2
- package/.pi/extensions/pi-autoformat/config.json +3 -6
- package/.pi/prompts/README.md +59 -0
- package/.pi/prompts/plan-issue.md +64 -0
- package/.pi/prompts/retro.md +144 -0
- package/.pi/prompts/ship-issue.md +77 -0
- package/.pi/prompts/tdd-plan.md +67 -0
- package/.pi/skills/pi-extension-lifecycle/SKILL.md +256 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +39 -0
- package/CHANGELOG.md +365 -0
- package/README.md +42 -109
- package/biome.json +1 -1
- package/docs/assets/logo.png +0 -0
- package/docs/assets/logo.svg +533 -0
- package/docs/configuration.md +358 -38
- package/docs/plans/0001-initial-implementation-plan.md +17 -9
- package/docs/plans/0002-richer-tui-formatter-summaries.md +220 -0
- package/docs/plans/0003-additional-pi-mutation-tools.md +273 -0
- package/docs/plans/0004-shell-driven-mutation-coverage.md +296 -0
- package/docs/plans/0010-acceptance-test-coverage.md +240 -0
- package/docs/plans/0012-remove-unused-formatter-extensions-field.md +152 -0
- package/docs/plans/0013-fallback-chain-step-type.md +280 -0
- package/docs/plans/0014-batch-by-default-formatter-dispatch.md +195 -0
- package/docs/plans/0015-builtin-treefmt-and-treefmt-nix-support.md +290 -0
- package/docs/plans/0016-detailed-formatter-output-on-failure.md +245 -0
- package/docs/plans/0022-pi-coding-agent-types.md +201 -0
- package/docs/plans/0027-format-before-agent-exit-follow-up-turn.md +355 -0
- package/docs/plans/0031-turn-end-flush-with-change-detection.md +365 -0
- package/docs/retro/0002-richer-tui-formatter-summaries.md +47 -0
- package/docs/retro/0013-fallback-chain-step-type.md +67 -0
- package/docs/retro/0015-builtin-treefmt-and-treefmt-nix-support.md +56 -0
- package/docs/retro/0016-detailed-formatter-output-on-failure.md +60 -0
- package/docs/retro/0022-pi-coding-agent-types.md +62 -0
- package/docs/testing.md +95 -0
- package/package.json +30 -11
- package/prek.toml +2 -2
- package/schemas/pi-autoformat.schema.json +145 -21
- package/src/builtin-formatters.ts +205 -0
- package/src/command-probe.ts +66 -0
- package/src/config-loader.ts +829 -90
- package/src/custom-mutation-tools.ts +125 -0
- package/src/extension.ts +469 -82
- package/src/format-scope.ts +118 -0
- package/src/formatter-config.ts +73 -36
- package/src/formatter-executor.ts +230 -34
- package/src/formatter-output-report.ts +149 -0
- package/src/formatter-registry.ts +139 -30
- package/src/index.ts +26 -5
- package/src/prompt-autoformatter.ts +148 -23
- package/src/shell-mutation-detector.ts +572 -0
- package/src/touched-files-queue.ts +72 -11
- package/test/acceptance-event-bus.test.ts +138 -0
- package/test/acceptance.test.ts +69 -0
- package/test/builtin-formatters.test.ts +382 -0
- package/test/command-probe.test.ts +79 -0
- package/test/config-loader.test.ts +640 -21
- package/test/custom-mutation-tools.test.ts +190 -0
- package/test/extension.test.ts +1535 -158
- package/test/fallback-acceptance.test.ts +98 -0
- package/test/fixtures/event-bus-emitter.ts +26 -0
- package/test/fixtures/formatter-recorder.mjs +25 -0
- package/test/format-scope.test.ts +139 -0
- package/test/formatter-config.test.ts +56 -5
- package/test/formatter-executor.test.ts +555 -35
- package/test/formatter-output-report.test.ts +178 -0
- package/test/formatter-registry.test.ts +330 -37
- package/test/helpers/rpc.ts +146 -0
- package/test/prompt-autoformatter.test.ts +315 -22
- package/test/schema.test.ts +149 -0
- package/test/shell-mutation-detector.test.ts +221 -0
- package/test/touched-files-queue.test.ts +40 -1
- 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
|
-
|
|
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(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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 };
|