@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
package/src/config-loader.ts
CHANGED
|
@@ -2,13 +2,41 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
+
import { BUILTIN_FORMATTERS } from "./builtin-formatters.js";
|
|
6
|
+
import type { CustomMutationToolSpec } from "./custom-mutation-tools.js";
|
|
7
|
+
import type { FormatScopeSetting } from "./format-scope.js";
|
|
5
8
|
import {
|
|
6
9
|
type AutoformatConfig,
|
|
7
10
|
createFormatterConfig,
|
|
8
|
-
|
|
11
|
+
DEFAULT_FORMATTER_CONFIG,
|
|
12
|
+
type EventBusMutationChannelConfig,
|
|
13
|
+
type FormatterOutputOnFailure,
|
|
14
|
+
type FormatterOutputReportingConfig,
|
|
9
15
|
type UserFormatterConfig,
|
|
10
16
|
} from "./formatter-config.js";
|
|
11
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
ChainStep,
|
|
19
|
+
FallbackChainStep,
|
|
20
|
+
FormatterDefinition,
|
|
21
|
+
} from "./formatter-registry.js";
|
|
22
|
+
|
|
23
|
+
// Pi's built-in tool names. Declaring any of these in customMutationTools is
|
|
24
|
+
// a configuration mistake: write/edit are already covered, bash has its own
|
|
25
|
+
// detection path (see plan 0004), and the rest do not mutate files.
|
|
26
|
+
const BUILTIN_TOOL_NAMES = new Set([
|
|
27
|
+
"bash",
|
|
28
|
+
"edit",
|
|
29
|
+
"write",
|
|
30
|
+
"read",
|
|
31
|
+
"grep",
|
|
32
|
+
"find",
|
|
33
|
+
"ls",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
import type {
|
|
37
|
+
ShellMutationDetectionConfig,
|
|
38
|
+
WrapperConfig,
|
|
39
|
+
} from "./shell-mutation-detector.js";
|
|
12
40
|
|
|
13
41
|
export const AUTOFORMAT_EXTENSION_ID = "pi-autoformat";
|
|
14
42
|
export const AUTOFORMAT_CONFIG_FILE_NAME = "config.json";
|
|
@@ -48,24 +76,6 @@ function pushIssue(
|
|
|
48
76
|
issues.push({ path, message, sourcePath });
|
|
49
77
|
}
|
|
50
78
|
|
|
51
|
-
function validateFormatMode(
|
|
52
|
-
value: unknown,
|
|
53
|
-
issues: ConfigValidationIssue[],
|
|
54
|
-
sourcePath?: string,
|
|
55
|
-
): FormatMode | undefined {
|
|
56
|
-
if (value === "tool" || value === "prompt" || value === "session") {
|
|
57
|
-
return value;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
pushIssue(
|
|
61
|
-
issues,
|
|
62
|
-
"formatMode",
|
|
63
|
-
'Expected one of "tool", "prompt", or "session".',
|
|
64
|
-
sourcePath,
|
|
65
|
-
);
|
|
66
|
-
return undefined;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
79
|
function validateCommandTimeoutMs(
|
|
70
80
|
value: unknown,
|
|
71
81
|
issues: ConfigValidationIssue[],
|
|
@@ -132,39 +142,6 @@ function validateStringArray(
|
|
|
132
142
|
return normalized;
|
|
133
143
|
}
|
|
134
144
|
|
|
135
|
-
function validateExtensionArray(
|
|
136
|
-
fieldPath: string,
|
|
137
|
-
value: unknown,
|
|
138
|
-
issues: ConfigValidationIssue[],
|
|
139
|
-
sourcePath?: string,
|
|
140
|
-
): string[] | undefined {
|
|
141
|
-
const extensions = validateStringArray(fieldPath, value, issues, sourcePath);
|
|
142
|
-
if (!extensions) {
|
|
143
|
-
return undefined;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const normalized: string[] = [];
|
|
147
|
-
for (let index = 0; index < extensions.length; index += 1) {
|
|
148
|
-
const extension = extensions[index];
|
|
149
|
-
if (!extension.startsWith(".")) {
|
|
150
|
-
pushIssue(
|
|
151
|
-
issues,
|
|
152
|
-
`${fieldPath}[${index}]`,
|
|
153
|
-
'Expected a file extension beginning with ".".',
|
|
154
|
-
sourcePath,
|
|
155
|
-
);
|
|
156
|
-
return undefined;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const lowercased = extension.toLowerCase();
|
|
160
|
-
if (!normalized.includes(lowercased)) {
|
|
161
|
-
normalized.push(lowercased);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return normalized;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
145
|
function validateEnvironment(
|
|
169
146
|
fieldPath: string,
|
|
170
147
|
value: unknown,
|
|
@@ -211,23 +188,40 @@ function validateFormatterDefinition(
|
|
|
211
188
|
}
|
|
212
189
|
|
|
213
190
|
const definition: Partial<FormatterDefinition> = {};
|
|
191
|
+
let commandProvided = false;
|
|
214
192
|
|
|
215
193
|
for (const [key, entry] of Object.entries(value)) {
|
|
216
194
|
if (key === "command") {
|
|
217
|
-
|
|
195
|
+
commandProvided = true;
|
|
196
|
+
const command = validateStringArray(
|
|
218
197
|
`${fieldPath}.command`,
|
|
219
198
|
entry,
|
|
220
199
|
issues,
|
|
221
200
|
sourcePath,
|
|
222
201
|
);
|
|
202
|
+
if (command) {
|
|
203
|
+
const offendingIndex = command.findIndex((arg) =>
|
|
204
|
+
arg.includes("$FILE"),
|
|
205
|
+
);
|
|
206
|
+
if (offendingIndex >= 0) {
|
|
207
|
+
pushIssue(
|
|
208
|
+
issues,
|
|
209
|
+
`${fieldPath}.command`,
|
|
210
|
+
"$FILE substitution is no longer supported. Remove $FILE; file paths are appended to the command automatically. See docs/configuration.md.",
|
|
211
|
+
sourcePath,
|
|
212
|
+
);
|
|
213
|
+
} else {
|
|
214
|
+
definition.command = command;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
223
217
|
continue;
|
|
224
218
|
}
|
|
225
219
|
|
|
226
220
|
if (key === "extensions") {
|
|
227
|
-
|
|
228
|
-
`${fieldPath}.extensions`,
|
|
229
|
-
entry,
|
|
221
|
+
pushIssue(
|
|
230
222
|
issues,
|
|
223
|
+
`${fieldPath}.extensions`,
|
|
224
|
+
"Deprecated. Remove this field; dispatch is driven by `chains`. The value is ignored.",
|
|
231
225
|
sourcePath,
|
|
232
226
|
);
|
|
233
227
|
continue;
|
|
@@ -261,8 +255,8 @@ function validateFormatterDefinition(
|
|
|
261
255
|
);
|
|
262
256
|
}
|
|
263
257
|
|
|
264
|
-
if (!definition.command
|
|
265
|
-
if (!
|
|
258
|
+
if (!definition.command) {
|
|
259
|
+
if (!commandProvided) {
|
|
266
260
|
pushIssue(
|
|
267
261
|
issues,
|
|
268
262
|
`${fieldPath}.command`,
|
|
@@ -270,23 +264,19 @@ function validateFormatterDefinition(
|
|
|
270
264
|
sourcePath,
|
|
271
265
|
);
|
|
272
266
|
}
|
|
273
|
-
if (!definition.extensions) {
|
|
274
|
-
pushIssue(
|
|
275
|
-
issues,
|
|
276
|
-
`${fieldPath}.extensions`,
|
|
277
|
-
"Missing required property.",
|
|
278
|
-
sourcePath,
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
267
|
return undefined;
|
|
282
268
|
}
|
|
283
269
|
|
|
284
|
-
|
|
270
|
+
const resolved: FormatterDefinition = {
|
|
285
271
|
command: definition.command,
|
|
286
|
-
extensions: definition.extensions,
|
|
287
|
-
environment: definition.environment,
|
|
288
|
-
disabled: definition.disabled,
|
|
289
272
|
};
|
|
273
|
+
if (definition.environment !== undefined) {
|
|
274
|
+
resolved.environment = definition.environment;
|
|
275
|
+
}
|
|
276
|
+
if (definition.disabled !== undefined) {
|
|
277
|
+
resolved.disabled = definition.disabled;
|
|
278
|
+
}
|
|
279
|
+
return resolved;
|
|
290
280
|
}
|
|
291
281
|
|
|
292
282
|
function validateFormatters(
|
|
@@ -301,6 +291,14 @@ function validateFormatters(
|
|
|
301
291
|
|
|
302
292
|
const formatters: Record<string, FormatterDefinition> = {};
|
|
303
293
|
for (const [formatterName, formatterValue] of Object.entries(value)) {
|
|
294
|
+
if (Object.hasOwn(BUILTIN_FORMATTERS, formatterName)) {
|
|
295
|
+
pushIssue(
|
|
296
|
+
issues,
|
|
297
|
+
`formatters.${formatterName}`,
|
|
298
|
+
`Shadows the built-in "${formatterName}" formatter. The user-declared definition will be used; remove this entry to fall back to the built-in.`,
|
|
299
|
+
sourcePath,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
304
302
|
const definition = validateFormatterDefinition(
|
|
305
303
|
formatterName,
|
|
306
304
|
formatterValue,
|
|
@@ -315,40 +313,651 @@ function validateFormatters(
|
|
|
315
313
|
return formatters;
|
|
316
314
|
}
|
|
317
315
|
|
|
318
|
-
function
|
|
316
|
+
function validateFallbackStep(
|
|
317
|
+
fieldPath: string,
|
|
318
|
+
value: Record<string, unknown>,
|
|
319
|
+
issues: ConfigValidationIssue[],
|
|
320
|
+
sourcePath?: string,
|
|
321
|
+
knownFormatterNames?: Set<string>,
|
|
322
|
+
): FallbackChainStep | undefined {
|
|
323
|
+
let fallbackValue: unknown;
|
|
324
|
+
let hasUnknown = false;
|
|
325
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
326
|
+
if (key === "fallback") {
|
|
327
|
+
fallbackValue = entry;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
pushIssue(
|
|
331
|
+
issues,
|
|
332
|
+
`${fieldPath}.${key}`,
|
|
333
|
+
"Unknown fallback step property.",
|
|
334
|
+
sourcePath,
|
|
335
|
+
);
|
|
336
|
+
hasUnknown = true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!Array.isArray(fallbackValue) || fallbackValue.length === 0) {
|
|
340
|
+
pushIssue(
|
|
341
|
+
issues,
|
|
342
|
+
`${fieldPath}.fallback`,
|
|
343
|
+
"Expected a non-empty array of formatter names.",
|
|
344
|
+
sourcePath,
|
|
345
|
+
);
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const names: string[] = [];
|
|
350
|
+
let nameError = false;
|
|
351
|
+
for (let index = 0; index < fallbackValue.length; index += 1) {
|
|
352
|
+
const entry = fallbackValue[index];
|
|
353
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
354
|
+
pushIssue(
|
|
355
|
+
issues,
|
|
356
|
+
`${fieldPath}.fallback[${index}]`,
|
|
357
|
+
"Expected a non-empty string.",
|
|
358
|
+
sourcePath,
|
|
359
|
+
);
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
if (knownFormatterNames && !knownFormatterNames.has(entry)) {
|
|
363
|
+
pushIssue(
|
|
364
|
+
issues,
|
|
365
|
+
`${fieldPath}.fallback[${index}]`,
|
|
366
|
+
`Unknown formatter name "${entry}". Declare it in \`formatters\` or remove it from the fallback group.`,
|
|
367
|
+
sourcePath,
|
|
368
|
+
);
|
|
369
|
+
nameError = true;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
names.push(entry);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (hasUnknown || nameError) {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return { fallback: names };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function validateChainStep(
|
|
383
|
+
fieldPath: string,
|
|
319
384
|
value: unknown,
|
|
320
385
|
issues: ConfigValidationIssue[],
|
|
321
386
|
sourcePath?: string,
|
|
322
|
-
|
|
387
|
+
knownFormatterNames?: Set<string>,
|
|
388
|
+
): ChainStep | undefined {
|
|
389
|
+
if (typeof value === "string") {
|
|
390
|
+
if (value.length === 0) {
|
|
391
|
+
pushIssue(
|
|
392
|
+
issues,
|
|
393
|
+
fieldPath,
|
|
394
|
+
"Expected a non-empty formatter name.",
|
|
395
|
+
sourcePath,
|
|
396
|
+
);
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
if (knownFormatterNames && !knownFormatterNames.has(value)) {
|
|
400
|
+
pushIssue(
|
|
401
|
+
issues,
|
|
402
|
+
fieldPath,
|
|
403
|
+
`Unknown formatter name "${value}". Declare it in \`formatters\` or remove it from \`chains\`.`,
|
|
404
|
+
sourcePath,
|
|
405
|
+
);
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
return value;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (isRecord(value)) {
|
|
412
|
+
return validateFallbackStep(
|
|
413
|
+
fieldPath,
|
|
414
|
+
value,
|
|
415
|
+
issues,
|
|
416
|
+
sourcePath,
|
|
417
|
+
knownFormatterNames,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
pushIssue(
|
|
422
|
+
issues,
|
|
423
|
+
fieldPath,
|
|
424
|
+
'Expected a formatter name (string) or a fallback group ({ "fallback": [name, ...] }).',
|
|
425
|
+
sourcePath,
|
|
426
|
+
);
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function validateChains(
|
|
431
|
+
value: unknown,
|
|
432
|
+
issues: ConfigValidationIssue[],
|
|
433
|
+
sourcePath: string | undefined,
|
|
434
|
+
knownFormatterNames: Set<string>,
|
|
435
|
+
): Record<string, ChainStep[]> | undefined {
|
|
323
436
|
if (!isRecord(value)) {
|
|
324
437
|
pushIssue(issues, "chains", "Expected an object.", sourcePath);
|
|
325
438
|
return undefined;
|
|
326
439
|
}
|
|
327
440
|
|
|
328
|
-
const chains: Record<string,
|
|
441
|
+
const chains: Record<string, ChainStep[]> = {};
|
|
329
442
|
for (const [extension, chainValue] of Object.entries(value)) {
|
|
330
|
-
if (!extension.startsWith(".")) {
|
|
443
|
+
if (extension !== "*" && !extension.startsWith(".")) {
|
|
444
|
+
pushIssue(
|
|
445
|
+
issues,
|
|
446
|
+
`chains.${extension}`,
|
|
447
|
+
'Expected a file extension key beginning with "." or the wildcard "*".',
|
|
448
|
+
sourcePath,
|
|
449
|
+
);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (!Array.isArray(chainValue) || chainValue.length === 0) {
|
|
331
454
|
pushIssue(
|
|
332
455
|
issues,
|
|
333
456
|
`chains.${extension}`,
|
|
334
|
-
|
|
457
|
+
"Expected a non-empty array of chain steps.",
|
|
458
|
+
sourcePath,
|
|
459
|
+
);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const steps: ChainStep[] = [];
|
|
464
|
+
let stepError = false;
|
|
465
|
+
for (let index = 0; index < chainValue.length; index += 1) {
|
|
466
|
+
const step = validateChainStep(
|
|
467
|
+
`chains.${extension}[${index}]`,
|
|
468
|
+
chainValue[index],
|
|
469
|
+
issues,
|
|
335
470
|
sourcePath,
|
|
471
|
+
knownFormatterNames,
|
|
336
472
|
);
|
|
473
|
+
if (!step) {
|
|
474
|
+
stepError = true;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
steps.push(step);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (stepError) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
// The wildcard key is preserved verbatim; only extensions are lowercased.
|
|
484
|
+
chains[extension === "*" ? "*" : extension.toLowerCase()] = steps;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return chains;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function validateFormatScope(
|
|
491
|
+
value: unknown,
|
|
492
|
+
issues: ConfigValidationIssue[],
|
|
493
|
+
sourcePath?: string,
|
|
494
|
+
): FormatScopeSetting | undefined {
|
|
495
|
+
if (value === "repoRoot" || value === "cwd") {
|
|
496
|
+
return value;
|
|
497
|
+
}
|
|
498
|
+
if (Array.isArray(value)) {
|
|
499
|
+
const result: string[] = [];
|
|
500
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
501
|
+
const entry = value[index];
|
|
502
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
503
|
+
pushIssue(
|
|
504
|
+
issues,
|
|
505
|
+
`formatScope[${index}]`,
|
|
506
|
+
"Expected a non-empty string.",
|
|
507
|
+
sourcePath,
|
|
508
|
+
);
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
result.push(entry);
|
|
512
|
+
}
|
|
513
|
+
return result;
|
|
514
|
+
}
|
|
515
|
+
pushIssue(
|
|
516
|
+
issues,
|
|
517
|
+
"formatScope",
|
|
518
|
+
'Expected "repoRoot", "cwd", or an array of paths.',
|
|
519
|
+
sourcePath,
|
|
520
|
+
);
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function validateWrapper(
|
|
525
|
+
fieldPath: string,
|
|
526
|
+
value: unknown,
|
|
527
|
+
issues: ConfigValidationIssue[],
|
|
528
|
+
sourcePath?: string,
|
|
529
|
+
): WrapperConfig | undefined {
|
|
530
|
+
if (!isRecord(value)) {
|
|
531
|
+
pushIssue(issues, fieldPath, "Expected an object.", sourcePath);
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
const wrapper: Partial<WrapperConfig> = {};
|
|
535
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
536
|
+
if (key === "prefix") {
|
|
537
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
538
|
+
pushIssue(
|
|
539
|
+
issues,
|
|
540
|
+
`${fieldPath}.prefix`,
|
|
541
|
+
"Expected a non-empty string.",
|
|
542
|
+
sourcePath,
|
|
543
|
+
);
|
|
544
|
+
return undefined;
|
|
545
|
+
}
|
|
546
|
+
wrapper.prefix = entry;
|
|
337
547
|
continue;
|
|
338
548
|
}
|
|
549
|
+
if (key === "outputFormat") {
|
|
550
|
+
if (entry !== "lines") {
|
|
551
|
+
pushIssue(
|
|
552
|
+
issues,
|
|
553
|
+
`${fieldPath}.outputFormat`,
|
|
554
|
+
'Expected "lines".',
|
|
555
|
+
sourcePath,
|
|
556
|
+
);
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
wrapper.outputFormat = entry;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
pushIssue(
|
|
563
|
+
issues,
|
|
564
|
+
`${fieldPath}.${key}`,
|
|
565
|
+
"Unknown wrapper property.",
|
|
566
|
+
sourcePath,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
if (!wrapper.prefix) {
|
|
570
|
+
pushIssue(
|
|
571
|
+
issues,
|
|
572
|
+
`${fieldPath}.prefix`,
|
|
573
|
+
"Missing required property.",
|
|
574
|
+
sourcePath,
|
|
575
|
+
);
|
|
576
|
+
return undefined;
|
|
577
|
+
}
|
|
578
|
+
return { prefix: wrapper.prefix, outputFormat: wrapper.outputFormat };
|
|
579
|
+
}
|
|
339
580
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
581
|
+
function validateShellMutationDetection(
|
|
582
|
+
value: unknown,
|
|
583
|
+
issues: ConfigValidationIssue[],
|
|
584
|
+
sourcePath?: string,
|
|
585
|
+
): Partial<ShellMutationDetectionConfig> | undefined {
|
|
586
|
+
if (!isRecord(value)) {
|
|
587
|
+
pushIssue(
|
|
343
588
|
issues,
|
|
589
|
+
"shellMutationDetection",
|
|
590
|
+
"Expected an object.",
|
|
344
591
|
sourcePath,
|
|
345
592
|
);
|
|
346
|
-
|
|
347
|
-
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
const result: Partial<ShellMutationDetectionConfig> = {};
|
|
596
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
597
|
+
if (key === "enabled") {
|
|
598
|
+
const enabled = validateBooleanField(
|
|
599
|
+
"shellMutationDetection.enabled",
|
|
600
|
+
entry,
|
|
601
|
+
issues,
|
|
602
|
+
sourcePath,
|
|
603
|
+
);
|
|
604
|
+
if (enabled !== undefined) {
|
|
605
|
+
result.enabled = enabled;
|
|
606
|
+
}
|
|
607
|
+
continue;
|
|
348
608
|
}
|
|
609
|
+
if (key === "argumentParsing") {
|
|
610
|
+
const argumentParsing = validateBooleanField(
|
|
611
|
+
"shellMutationDetection.argumentParsing",
|
|
612
|
+
entry,
|
|
613
|
+
issues,
|
|
614
|
+
sourcePath,
|
|
615
|
+
);
|
|
616
|
+
if (argumentParsing !== undefined) {
|
|
617
|
+
result.argumentParsing = argumentParsing;
|
|
618
|
+
}
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (key === "snapshotGlobs") {
|
|
622
|
+
if (!Array.isArray(entry)) {
|
|
623
|
+
pushIssue(
|
|
624
|
+
issues,
|
|
625
|
+
"shellMutationDetection.snapshotGlobs",
|
|
626
|
+
"Expected an array of strings.",
|
|
627
|
+
sourcePath,
|
|
628
|
+
);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const globs: string[] = [];
|
|
632
|
+
let valid = true;
|
|
633
|
+
for (let index = 0; index < entry.length; index += 1) {
|
|
634
|
+
const glob = entry[index];
|
|
635
|
+
if (typeof glob !== "string" || glob.length === 0) {
|
|
636
|
+
pushIssue(
|
|
637
|
+
issues,
|
|
638
|
+
`shellMutationDetection.snapshotGlobs[${index}]`,
|
|
639
|
+
"Expected a non-empty string.",
|
|
640
|
+
sourcePath,
|
|
641
|
+
);
|
|
642
|
+
valid = false;
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
globs.push(glob);
|
|
646
|
+
}
|
|
647
|
+
if (valid) {
|
|
648
|
+
result.snapshotGlobs = globs;
|
|
649
|
+
}
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (key === "wrappers") {
|
|
653
|
+
if (!Array.isArray(entry)) {
|
|
654
|
+
pushIssue(
|
|
655
|
+
issues,
|
|
656
|
+
"shellMutationDetection.wrappers",
|
|
657
|
+
"Expected an array.",
|
|
658
|
+
sourcePath,
|
|
659
|
+
);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
const wrappers: WrapperConfig[] = [];
|
|
663
|
+
let valid = true;
|
|
664
|
+
for (let index = 0; index < entry.length; index += 1) {
|
|
665
|
+
const wrapper = validateWrapper(
|
|
666
|
+
`shellMutationDetection.wrappers[${index}]`,
|
|
667
|
+
entry[index],
|
|
668
|
+
issues,
|
|
669
|
+
sourcePath,
|
|
670
|
+
);
|
|
671
|
+
if (!wrapper) {
|
|
672
|
+
valid = false;
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
wrappers.push(wrapper);
|
|
676
|
+
}
|
|
677
|
+
if (valid) {
|
|
678
|
+
result.wrappers = wrappers;
|
|
679
|
+
}
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
pushIssue(
|
|
683
|
+
issues,
|
|
684
|
+
`shellMutationDetection.${key}`,
|
|
685
|
+
"Unknown property.",
|
|
686
|
+
sourcePath,
|
|
687
|
+
);
|
|
349
688
|
}
|
|
689
|
+
return result;
|
|
690
|
+
}
|
|
350
691
|
|
|
351
|
-
|
|
692
|
+
function validateCustomMutationToolEntry(
|
|
693
|
+
fieldPath: string,
|
|
694
|
+
value: unknown,
|
|
695
|
+
issues: ConfigValidationIssue[],
|
|
696
|
+
sourcePath: string | undefined,
|
|
697
|
+
seenToolNames: Set<string>,
|
|
698
|
+
): CustomMutationToolSpec | undefined {
|
|
699
|
+
if (!isRecord(value)) {
|
|
700
|
+
pushIssue(issues, fieldPath, "Expected an object.", sourcePath);
|
|
701
|
+
return undefined;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
let toolName: string | undefined;
|
|
705
|
+
let pathField: string | undefined;
|
|
706
|
+
let pathFields: string[] | undefined;
|
|
707
|
+
let hasUnknown = false;
|
|
708
|
+
|
|
709
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
710
|
+
if (key === "toolName") {
|
|
711
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
712
|
+
pushIssue(
|
|
713
|
+
issues,
|
|
714
|
+
`${fieldPath}.toolName`,
|
|
715
|
+
"Expected a non-empty string.",
|
|
716
|
+
sourcePath,
|
|
717
|
+
);
|
|
718
|
+
return undefined;
|
|
719
|
+
}
|
|
720
|
+
if (BUILTIN_TOOL_NAMES.has(entry)) {
|
|
721
|
+
pushIssue(
|
|
722
|
+
issues,
|
|
723
|
+
`${fieldPath}.toolName`,
|
|
724
|
+
`"${entry}" is a Pi built-in tool and cannot be declared as a custom mutation tool. Built-in mutating tools (write, edit) are already covered; others do not mutate files.`,
|
|
725
|
+
sourcePath,
|
|
726
|
+
);
|
|
727
|
+
return undefined;
|
|
728
|
+
}
|
|
729
|
+
if (seenToolNames.has(entry)) {
|
|
730
|
+
pushIssue(
|
|
731
|
+
issues,
|
|
732
|
+
`${fieldPath}.toolName`,
|
|
733
|
+
`Duplicate toolName "${entry}". Each tool may only be declared once.`,
|
|
734
|
+
sourcePath,
|
|
735
|
+
);
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
toolName = entry;
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (key === "pathField") {
|
|
743
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
744
|
+
pushIssue(
|
|
745
|
+
issues,
|
|
746
|
+
`${fieldPath}.pathField`,
|
|
747
|
+
"Expected a non-empty string.",
|
|
748
|
+
sourcePath,
|
|
749
|
+
);
|
|
750
|
+
return undefined;
|
|
751
|
+
}
|
|
752
|
+
pathField = entry;
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (key === "pathFields") {
|
|
757
|
+
if (!Array.isArray(entry) || entry.length === 0) {
|
|
758
|
+
pushIssue(
|
|
759
|
+
issues,
|
|
760
|
+
`${fieldPath}.pathFields`,
|
|
761
|
+
"Expected a non-empty array of strings.",
|
|
762
|
+
sourcePath,
|
|
763
|
+
);
|
|
764
|
+
return undefined;
|
|
765
|
+
}
|
|
766
|
+
const collected: string[] = [];
|
|
767
|
+
for (let index = 0; index < entry.length; index += 1) {
|
|
768
|
+
const item = entry[index];
|
|
769
|
+
if (typeof item !== "string" || item.length === 0) {
|
|
770
|
+
pushIssue(
|
|
771
|
+
issues,
|
|
772
|
+
`${fieldPath}.pathFields[${index}]`,
|
|
773
|
+
"Expected a non-empty string.",
|
|
774
|
+
sourcePath,
|
|
775
|
+
);
|
|
776
|
+
return undefined;
|
|
777
|
+
}
|
|
778
|
+
collected.push(item);
|
|
779
|
+
}
|
|
780
|
+
pathFields = collected;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
pushIssue(issues, `${fieldPath}.${key}`, "Unknown property.", sourcePath);
|
|
785
|
+
hasUnknown = true;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (hasUnknown) {
|
|
789
|
+
return undefined;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (!toolName) {
|
|
793
|
+
pushIssue(
|
|
794
|
+
issues,
|
|
795
|
+
`${fieldPath}.toolName`,
|
|
796
|
+
"Missing required property.",
|
|
797
|
+
sourcePath,
|
|
798
|
+
);
|
|
799
|
+
return undefined;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const hasField = pathField !== undefined;
|
|
803
|
+
const hasFields = pathFields !== undefined;
|
|
804
|
+
if (hasField === hasFields) {
|
|
805
|
+
pushIssue(
|
|
806
|
+
issues,
|
|
807
|
+
fieldPath,
|
|
808
|
+
"Expected exactly one of `pathField` or `pathFields`.",
|
|
809
|
+
sourcePath,
|
|
810
|
+
);
|
|
811
|
+
return undefined;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
seenToolNames.add(toolName);
|
|
815
|
+
return pathField !== undefined
|
|
816
|
+
? { toolName, pathField }
|
|
817
|
+
: { toolName, pathFields: pathFields as string[] };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function validateCustomMutationTools(
|
|
821
|
+
value: unknown,
|
|
822
|
+
issues: ConfigValidationIssue[],
|
|
823
|
+
sourcePath?: string,
|
|
824
|
+
): CustomMutationToolSpec[] | undefined {
|
|
825
|
+
if (!Array.isArray(value)) {
|
|
826
|
+
pushIssue(
|
|
827
|
+
issues,
|
|
828
|
+
"customMutationTools",
|
|
829
|
+
"Expected an array of mutation tool specs.",
|
|
830
|
+
sourcePath,
|
|
831
|
+
);
|
|
832
|
+
return undefined;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const seenToolNames = new Set<string>();
|
|
836
|
+
const collected: CustomMutationToolSpec[] = [];
|
|
837
|
+
let hasError = false;
|
|
838
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
839
|
+
const entry = validateCustomMutationToolEntry(
|
|
840
|
+
`customMutationTools[${index}]`,
|
|
841
|
+
value[index],
|
|
842
|
+
issues,
|
|
843
|
+
sourcePath,
|
|
844
|
+
seenToolNames,
|
|
845
|
+
);
|
|
846
|
+
if (!entry) {
|
|
847
|
+
hasError = true;
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
collected.push(entry);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (hasError) {
|
|
854
|
+
return undefined;
|
|
855
|
+
}
|
|
856
|
+
return collected;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function validateFormatterOutput(
|
|
860
|
+
value: unknown,
|
|
861
|
+
issues: ConfigValidationIssue[],
|
|
862
|
+
sourcePath?: string,
|
|
863
|
+
): Partial<FormatterOutputReportingConfig> | undefined {
|
|
864
|
+
if (!isRecord(value)) {
|
|
865
|
+
pushIssue(issues, "formatterOutput", "Expected an object.", sourcePath);
|
|
866
|
+
return undefined;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const result: Partial<FormatterOutputReportingConfig> = {};
|
|
870
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
871
|
+
if (key === "onFailure") {
|
|
872
|
+
if (entry === "none" || entry === "stderr" || entry === "both") {
|
|
873
|
+
result.onFailure = entry as FormatterOutputOnFailure;
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
pushIssue(
|
|
877
|
+
issues,
|
|
878
|
+
"formatterOutput.onFailure",
|
|
879
|
+
'Expected one of "none", "stderr", or "both".',
|
|
880
|
+
sourcePath,
|
|
881
|
+
);
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
if (key === "maxBytes" || key === "maxLines") {
|
|
885
|
+
if (typeof entry === "number" && Number.isInteger(entry) && entry >= 0) {
|
|
886
|
+
result[key] = entry;
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
pushIssue(
|
|
890
|
+
issues,
|
|
891
|
+
`formatterOutput.${key}`,
|
|
892
|
+
"Expected a non-negative integer.",
|
|
893
|
+
sourcePath,
|
|
894
|
+
);
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
pushIssue(
|
|
898
|
+
issues,
|
|
899
|
+
`formatterOutput.${key}`,
|
|
900
|
+
"Unknown property.",
|
|
901
|
+
sourcePath,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (Object.keys(result).length === 0) {
|
|
906
|
+
return undefined;
|
|
907
|
+
}
|
|
908
|
+
return result;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function validateEventBusMutationChannel(
|
|
912
|
+
value: unknown,
|
|
913
|
+
issues: ConfigValidationIssue[],
|
|
914
|
+
sourcePath?: string,
|
|
915
|
+
): Partial<EventBusMutationChannelConfig> | undefined {
|
|
916
|
+
if (!isRecord(value)) {
|
|
917
|
+
pushIssue(
|
|
918
|
+
issues,
|
|
919
|
+
"eventBusMutationChannel",
|
|
920
|
+
"Expected an object.",
|
|
921
|
+
sourcePath,
|
|
922
|
+
);
|
|
923
|
+
return undefined;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const result: Partial<EventBusMutationChannelConfig> = {};
|
|
927
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
928
|
+
if (key === "enabled") {
|
|
929
|
+
const enabled = validateBooleanField(
|
|
930
|
+
"eventBusMutationChannel.enabled",
|
|
931
|
+
entry,
|
|
932
|
+
issues,
|
|
933
|
+
sourcePath,
|
|
934
|
+
);
|
|
935
|
+
if (enabled !== undefined) {
|
|
936
|
+
result.enabled = enabled;
|
|
937
|
+
}
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
if (key === "channel") {
|
|
941
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
942
|
+
pushIssue(
|
|
943
|
+
issues,
|
|
944
|
+
"eventBusMutationChannel.channel",
|
|
945
|
+
"Expected a non-empty string.",
|
|
946
|
+
sourcePath,
|
|
947
|
+
);
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
result.channel = entry;
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
pushIssue(
|
|
954
|
+
issues,
|
|
955
|
+
`eventBusMutationChannel.${key}`,
|
|
956
|
+
"Unknown property.",
|
|
957
|
+
sourcePath,
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
return result;
|
|
352
961
|
}
|
|
353
962
|
|
|
354
963
|
function validateConfigObject(
|
|
@@ -363,6 +972,9 @@ function validateConfigObject(
|
|
|
363
972
|
return { config, issues };
|
|
364
973
|
}
|
|
365
974
|
|
|
975
|
+
// Two passes: validate everything except chains first so we can build the
|
|
976
|
+
// known-formatter-name set (built-ins + this file's formatters) before
|
|
977
|
+
// validating chains' formatter references.
|
|
366
978
|
for (const [key, entry] of Object.entries(value)) {
|
|
367
979
|
if (key === "$schema") {
|
|
368
980
|
if (typeof entry !== "string") {
|
|
@@ -372,10 +984,22 @@ function validateConfigObject(
|
|
|
372
984
|
}
|
|
373
985
|
|
|
374
986
|
if (key === "formatMode") {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
987
|
+
pushIssue(
|
|
988
|
+
issues,
|
|
989
|
+
"formatMode",
|
|
990
|
+
"formatMode has been removed; prompt-end formatting is now the only mode.",
|
|
991
|
+
sourcePath,
|
|
992
|
+
);
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (key === "notifyAgent") {
|
|
997
|
+
pushIssue(
|
|
998
|
+
issues,
|
|
999
|
+
"notifyAgent",
|
|
1000
|
+
"notifyAgent has been removed; the extension now notifies via steering messages at turn end.",
|
|
1001
|
+
sourcePath,
|
|
1002
|
+
);
|
|
379
1003
|
continue;
|
|
380
1004
|
}
|
|
381
1005
|
|
|
@@ -413,9 +1037,58 @@ function validateConfigObject(
|
|
|
413
1037
|
}
|
|
414
1038
|
|
|
415
1039
|
if (key === "chains") {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
1040
|
+
// Defer chains until we know which formatter names are valid in this file.
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (key === "formatScope") {
|
|
1045
|
+
const formatScope = validateFormatScope(entry, issues, sourcePath);
|
|
1046
|
+
if (formatScope !== undefined) {
|
|
1047
|
+
config.formatScope = formatScope;
|
|
1048
|
+
}
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (key === "shellMutationDetection") {
|
|
1053
|
+
const detection = validateShellMutationDetection(
|
|
1054
|
+
entry,
|
|
1055
|
+
issues,
|
|
1056
|
+
sourcePath,
|
|
1057
|
+
);
|
|
1058
|
+
if (detection !== undefined) {
|
|
1059
|
+
config.shellMutationDetection = detection;
|
|
1060
|
+
}
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if (key === "customMutationTools") {
|
|
1065
|
+
const tools = validateCustomMutationTools(entry, issues, sourcePath);
|
|
1066
|
+
if (tools !== undefined) {
|
|
1067
|
+
config.customMutationTools = tools;
|
|
1068
|
+
}
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (key === "formatterOutput") {
|
|
1073
|
+
const formatterOutput = validateFormatterOutput(
|
|
1074
|
+
entry,
|
|
1075
|
+
issues,
|
|
1076
|
+
sourcePath,
|
|
1077
|
+
);
|
|
1078
|
+
if (formatterOutput !== undefined) {
|
|
1079
|
+
config.formatterOutput = formatterOutput;
|
|
1080
|
+
}
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (key === "eventBusMutationChannel") {
|
|
1085
|
+
const channel = validateEventBusMutationChannel(
|
|
1086
|
+
entry,
|
|
1087
|
+
issues,
|
|
1088
|
+
sourcePath,
|
|
1089
|
+
);
|
|
1090
|
+
if (channel !== undefined) {
|
|
1091
|
+
config.eventBusMutationChannel = channel;
|
|
419
1092
|
}
|
|
420
1093
|
continue;
|
|
421
1094
|
}
|
|
@@ -423,6 +1096,23 @@ function validateConfigObject(
|
|
|
423
1096
|
pushIssue(issues, key, "Unknown top-level property.", sourcePath);
|
|
424
1097
|
}
|
|
425
1098
|
|
|
1099
|
+
if ("chains" in value) {
|
|
1100
|
+
const knownFormatterNames = new Set<string>([
|
|
1101
|
+
...Object.keys(DEFAULT_FORMATTER_CONFIG.formatters),
|
|
1102
|
+
...Object.keys(config.formatters ?? {}),
|
|
1103
|
+
...Object.keys(BUILTIN_FORMATTERS),
|
|
1104
|
+
]);
|
|
1105
|
+
const chains = validateChains(
|
|
1106
|
+
(value as Record<string, unknown>).chains,
|
|
1107
|
+
issues,
|
|
1108
|
+
sourcePath,
|
|
1109
|
+
knownFormatterNames,
|
|
1110
|
+
);
|
|
1111
|
+
if (chains) {
|
|
1112
|
+
config.chains = chains;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
426
1116
|
return { config, issues };
|
|
427
1117
|
}
|
|
428
1118
|
|
|
@@ -446,9 +1136,24 @@ function mergeUserConfigs(
|
|
|
446
1136
|
overrides: UserFormatterConfig,
|
|
447
1137
|
): UserFormatterConfig {
|
|
448
1138
|
return {
|
|
449
|
-
formatMode: overrides.formatMode ?? base.formatMode,
|
|
450
1139
|
commandTimeoutMs: overrides.commandTimeoutMs ?? base.commandTimeoutMs,
|
|
451
1140
|
hideSummariesInTui: overrides.hideSummariesInTui ?? base.hideSummariesInTui,
|
|
1141
|
+
formatScope: overrides.formatScope ?? base.formatScope,
|
|
1142
|
+
shellMutationDetection: mergeShellMutationDetection(
|
|
1143
|
+
base.shellMutationDetection,
|
|
1144
|
+
overrides.shellMutationDetection,
|
|
1145
|
+
),
|
|
1146
|
+
// Arrays replace wholesale (consistent with formatScope, snapshotGlobs).
|
|
1147
|
+
customMutationTools:
|
|
1148
|
+
overrides.customMutationTools ?? base.customMutationTools,
|
|
1149
|
+
eventBusMutationChannel: mergeEventBusMutationChannel(
|
|
1150
|
+
base.eventBusMutationChannel,
|
|
1151
|
+
overrides.eventBusMutationChannel,
|
|
1152
|
+
),
|
|
1153
|
+
formatterOutput: mergeFormatterOutput(
|
|
1154
|
+
base.formatterOutput,
|
|
1155
|
+
overrides.formatterOutput,
|
|
1156
|
+
),
|
|
452
1157
|
formatters: {
|
|
453
1158
|
...base.formatters,
|
|
454
1159
|
...overrides.formatters,
|
|
@@ -460,6 +1165,40 @@ function mergeUserConfigs(
|
|
|
460
1165
|
};
|
|
461
1166
|
}
|
|
462
1167
|
|
|
1168
|
+
function mergeFormatterOutput(
|
|
1169
|
+
base: Partial<FormatterOutputReportingConfig> | undefined,
|
|
1170
|
+
overrides: Partial<FormatterOutputReportingConfig> | undefined,
|
|
1171
|
+
): Partial<FormatterOutputReportingConfig> | undefined {
|
|
1172
|
+
if (!base && !overrides) {
|
|
1173
|
+
return undefined;
|
|
1174
|
+
}
|
|
1175
|
+
return { ...(base ?? {}), ...(overrides ?? {}) };
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function mergeEventBusMutationChannel(
|
|
1179
|
+
base: Partial<EventBusMutationChannelConfig> | undefined,
|
|
1180
|
+
overrides: Partial<EventBusMutationChannelConfig> | undefined,
|
|
1181
|
+
): Partial<EventBusMutationChannelConfig> | undefined {
|
|
1182
|
+
if (!base && !overrides) {
|
|
1183
|
+
return undefined;
|
|
1184
|
+
}
|
|
1185
|
+
return { ...(base ?? {}), ...(overrides ?? {}) };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function mergeShellMutationDetection(
|
|
1189
|
+
base: Partial<ShellMutationDetectionConfig> | undefined,
|
|
1190
|
+
overrides: Partial<ShellMutationDetectionConfig> | undefined,
|
|
1191
|
+
): Partial<ShellMutationDetectionConfig> | undefined {
|
|
1192
|
+
if (!base && !overrides) {
|
|
1193
|
+
return undefined;
|
|
1194
|
+
}
|
|
1195
|
+
// Per AGENTS.md / plan: arrays replace, scalars override.
|
|
1196
|
+
return {
|
|
1197
|
+
...(base ?? {}),
|
|
1198
|
+
...(overrides ?? {}),
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
463
1202
|
export function getGlobalConfigPath(agentDir = defaultAgentDir()): string {
|
|
464
1203
|
return join(
|
|
465
1204
|
agentDir,
|