@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,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 22
|
|
3
|
+
issue_title: "Depend on @mariozechner/pi-coding-agent for runtime types instead of duck-typing"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #22 — Depend on `@mariozechner/pi-coding-agent` for runtime types instead of duck-typing
|
|
7
|
+
|
|
8
|
+
## Final Retrospective (2026-05-02T02:45:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Planned and shipped issue #22 — replaced the duck-typed `*Like` aliases in `src/extension.ts` with real types imported from `@mariozechner/pi-coding-agent@^0.72.0` (added as a `devDependency`).
|
|
13
|
+
Four implementation commits (`2d93a5e`, `304bd68`, `f9ef728`, `261bd05`), one biome fixup amended into the test commit, CI green, issue closed, release-please PR `#21` (pre-existing 2.3.1) merged.
|
|
14
|
+
The planned `ExtensionAPIWithEvents` workaround turned out to be unnecessary — Pi 0.72.0's `ExtensionAPI` already declares `events: EventBus` — and was dropped in the implementation commit.
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
#### What went well
|
|
19
|
+
|
|
20
|
+
- The planning gate caught one real ambiguity (full `ExtensionContext` vs `Pick<...>` at internal helpers) and used `ask-user` once with three concrete options.
|
|
21
|
+
The chosen narrow-`Pick` shape kept the test-stub diff tractable.
|
|
22
|
+
- `pnpm exec tsc --noEmit` filled in for vitest as the red/green signal on a typing-only refactor — vitest's esbuild transform strips types, so runtime tests passed even when `src/extension.ts` and `test/extension.test.ts` had real type errors mid-step.
|
|
23
|
+
Splitting the work into `refactor: import Pi types from pi-coding-agent` (`f9ef728`, intentional broken `tsc`) and `test: adopt class-based Theme stubs and Pi event types` (`261bd05`, green) preserved a coherent commit-by-commit story even though one intermediate commit didn't typecheck.
|
|
24
|
+
Worth promoting as a project pattern: add a `typecheck` script so future type-only refactors don't have to remember the incantation.
|
|
25
|
+
- The user's mid-flight question — "We're confident that's the latest release of the npm package, right?" — was a clean trust gate.
|
|
26
|
+
I had checked `npm view @mariozechner/pi-coding-agent version` during planning, but hadn't restated it.
|
|
27
|
+
A 30-second re-verify (`dist-tags.latest = 0.72.0`, last modified the same day) closed the loop with no rework.
|
|
28
|
+
|
|
29
|
+
#### What caused friction (agent side)
|
|
30
|
+
|
|
31
|
+
- `missing-context` (self-identified, mid-execution) — The plan claimed "Pi's `events` channel is **not** part of `ExtensionAPI` today" and prescribed an `ExtensionAPIWithEvents` intersection alias as the workaround.
|
|
32
|
+
In implementation I grepped `node_modules/.../types.d.ts` more carefully and found `events: EventBus` declared right under the `on(...)` overloads.
|
|
33
|
+
Impact: dropped the workaround in the same `f9ef728` commit, simplified `subscribeToEventBus`, recorded it as a deviation in the close comment.
|
|
34
|
+
No rework cycles, but the plan was wrong about a load-bearing design detail.
|
|
35
|
+
Root cause: during plan-phase research I read `dist/index.d.ts`'s re-export line and `dist/core/extensions/index.d.ts`'s `export type` list, then jumped to interface bodies via `grep -A 30 "interface ExtensionAPI"` — but the `events` declaration sits below the long `on(...)` overload block, outside the 30-line window.
|
|
36
|
+
The lesson: when a plan asserts something *isn't* in an upstream type, grep the full interface body, not the first N lines.
|
|
37
|
+
- `premature-convergence` (self-identified, low-impact) — Plan step 2 prescribed a `// @ts-expect-error` block that "currently red-flags against the duck type" and "goes green in step 4".
|
|
38
|
+
In practice the typecheck-only file (`test/types/theme-stub.test-d.ts`) imports `Theme` directly from `@mariozechner/pi-coding-agent`, so its assertion is independent of `src/extension.ts` typing — green from the moment it's written.
|
|
39
|
+
Impact: a slightly misleading TDD-ordering note in the plan; no commit churn.
|
|
40
|
+
- `wrong-abstraction` (small, self-identified) — Plan claimed `TestPi.on` could be typed as `ExtensionAPI["on"]`.
|
|
41
|
+
In practice, `ExtensionAPI["on"]` is a 26-overload signature that `TestPi`'s narrow harness can't structurally satisfy without modelling every event union.
|
|
42
|
+
Resolution: cast through `unknown` once on `TestPi.on`, expose an `asExtensionAPI()` helper, and `sed` 28 call sites to use it.
|
|
43
|
+
Impact: one mechanical bulk substitution; the test-side type fidelity is weaker than the plan implied (we're casting at the boundary, not type-anchored).
|
|
44
|
+
Acceptable per the plan's stated goal — what matters is `ctx.ui.theme: Theme` catches plain-arrow stubs, which still works.
|
|
45
|
+
|
|
46
|
+
#### What caused friction (user side)
|
|
47
|
+
|
|
48
|
+
- None observed.
|
|
49
|
+
The trust-gate question on the npm version was useful, not friction.
|
|
50
|
+
No premature corrections, no scope changes mid-flight.
|
|
51
|
+
|
|
52
|
+
### Novel wins
|
|
53
|
+
|
|
54
|
+
- First time on this repo a typing-only refactor was structured around `tsc --noEmit` as the test signal.
|
|
55
|
+
Pattern worked cleanly; the only friction was that no `typecheck` script exists in `package.json`, so each red/green cycle required `pnpm exec tsc --noEmit` typed by hand.
|
|
56
|
+
|
|
57
|
+
### Changes made
|
|
58
|
+
|
|
59
|
+
1. Added `"typecheck": "tsc --noEmit"` to `package.json` `scripts`.
|
|
60
|
+
2. Added a one-line `AGENTS.md` § Testing note pointing future type-only changes at `pnpm run typecheck`.
|
|
61
|
+
3. Updated `.github/workflows/ci.yml` to call `pnpm run typecheck` instead of inlining `pnpm exec tsc --noEmit`.
|
|
62
|
+
4. Created `docs/retro/0022-pi-coding-agent-types.md`.
|
package/docs/testing.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
`pi-autoformat` ships three layers of tests:
|
|
4
|
+
|
|
5
|
+
1. Unit tests under `test/*.test.ts` — fast, hermetic, cover individual modules (config loader, formatter executor, prompt autoformatter, shell mutation detector, etc.).
|
|
6
|
+
2. Acceptance tests that spawn the real `pi` CLI in `--mode rpc` — covered below.
|
|
7
|
+
3. (Future) LLM-gated acceptance tests — designed but not yet implemented.
|
|
8
|
+
|
|
9
|
+
Run everything with:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm test
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Vitest does not type-check.
|
|
16
|
+
For type-only changes use:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm run typecheck
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Acceptance tests
|
|
23
|
+
|
|
24
|
+
Files: `test/acceptance.test.ts`, `test/acceptance-event-bus.test.ts`, `test/fallback-acceptance.test.ts`.
|
|
25
|
+
|
|
26
|
+
Acceptance tests spawn the real `pi` binary in `--mode rpc` and assert behavior against actual Pi runtime events.
|
|
27
|
+
They catch regressions that pure unit tests cannot: extension load failures, payload-shape drift on real Pi events, and EventBus contract drift.
|
|
28
|
+
|
|
29
|
+
### Resolving the `pi` binary
|
|
30
|
+
|
|
31
|
+
`@mariozechner/pi-coding-agent` is a `devDependency` of this package, and `pnpm install` produces a working `node_modules/.bin/pi`.
|
|
32
|
+
The shared harness in `test/helpers/rpc.ts` resolves `pi` from that path explicitly rather than relying on the global `PATH`:
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
export const PI_BIN = resolve("node_modules/.bin/pi");
|
|
36
|
+
export const piAvailable = existsSync(PI_BIN);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Benefits:
|
|
40
|
+
|
|
41
|
+
- CI runs the acceptance suite under the existing `pnpm install --frozen-lockfile` step — no workflow changes needed.
|
|
42
|
+
- The Pi version is pinned by `pnpm-lock.yaml`, so CI and local runs use the same binary.
|
|
43
|
+
- Contributors who ran `pnpm install` get the acceptance suite for free; no separate global install.
|
|
44
|
+
|
|
45
|
+
### Skip semantics
|
|
46
|
+
|
|
47
|
+
`describeIfPi` is a safety net: it skips when `node_modules/.bin/pi` is missing (for example, after a partial checkout without `pnpm install`).
|
|
48
|
+
In normal usage every contributor runs the full suite.
|
|
49
|
+
|
|
50
|
+
### Test fixtures
|
|
51
|
+
|
|
52
|
+
`test/fixtures/` holds small companion extensions and helpers used by the acceptance suite:
|
|
53
|
+
|
|
54
|
+
- `event-bus-emitter.ts` — registers `/emit-touched <path...>` and forwards the paths onto the `autoformat:touched` channel via `pi.events.emit`.
|
|
55
|
+
- `formatter-recorder.mjs` — a stand-in "formatter" that appends `{ argv, cwd }` to the file pointed at by the `PI_AUTOFORMAT_RECORDER_LOG` env var.
|
|
56
|
+
Tests configure this script as the formatter command and read the log to assert what Pi actually invoked.
|
|
57
|
+
|
|
58
|
+
### EventBus acceptance test
|
|
59
|
+
|
|
60
|
+
`test/acceptance-event-bus.test.ts` exercises the production extension's `pi.events` subscription end to end:
|
|
61
|
+
|
|
62
|
+
1. Writes a project config that wires the recorder formatter to `.ts` files and sets `formatMode: "session"` so the flush runs on session shutdown.
|
|
63
|
+
2. Loads `src/extension.ts` and `event-bus-emitter.ts` together via `-e` flags.
|
|
64
|
+
3. Sends `{ type: "prompt", message: "/emit-touched <path>" }` over RPC.
|
|
65
|
+
4. Closes stdin; `session_shutdown` triggers the flush.
|
|
66
|
+
5. Reads the recorder log and asserts the formatter ran on the emitted absolute path.
|
|
67
|
+
|
|
68
|
+
This test is the primary safeguard against EventBus payload-shape drift between this extension and Pi.
|
|
69
|
+
|
|
70
|
+
## What is *not* covered by the default suite
|
|
71
|
+
|
|
72
|
+
Several scenarios named in issue #10 are not in the default acceptance suite because Pi's RPC mode does not surface the events they depend on:
|
|
73
|
+
|
|
74
|
+
- **`bash` shell mutation.** Pi's RPC `bash` command stores a `BashExecutionMessage` for the next prompt's LLM context but does not emit `tool_call` or `tool_result` events.
|
|
75
|
+
Our extension's snapshot tracker is wired to those events, so an RPC `bash` command does not drive the shell-mutation path.
|
|
76
|
+
Coverage today: `test/shell-mutation-detector.test.ts` (unit).
|
|
77
|
+
- **`write` / `edit` payload-shape validation.** These tools are only invoked by the LLM; there is no documented non-LLM trigger.
|
|
78
|
+
Coverage today: `test/extension.test.ts` (integration with a stubbed `ExtensionAPI`).
|
|
79
|
+
- **`customMutationTools` real `tool_result` events.** Same constraint as `write`/`edit`: the registered tool only fires when an LLM calls it.
|
|
80
|
+
Coverage today: `test/custom-mutation-tools.test.ts` (unit).
|
|
81
|
+
|
|
82
|
+
These gaps are real but bounded.
|
|
83
|
+
The unit / integration tests pin the handler logic; the EventBus acceptance test pins the runtime wiring; LLM-gated tests (below) will eventually pin the tool-result payload contract.
|
|
84
|
+
|
|
85
|
+
## LLM-gated acceptance suite (future)
|
|
86
|
+
|
|
87
|
+
The natural way to drive `bash` / `write` / `edit` / `customMutationTools` end to end is to send a real `prompt` and let an LLM call the tool.
|
|
88
|
+
That is intentionally out of scope for default `pnpm test`:
|
|
89
|
+
|
|
90
|
+
- requires a provider API key,
|
|
91
|
+
- costs money,
|
|
92
|
+
- is non-deterministic.
|
|
93
|
+
|
|
94
|
+
When implemented, these tests will live under `test/acceptance-llm/` and skip unless `PI_AUTOFORMAT_LLM_TESTS=1` is set in the environment plus the relevant provider credential (e.g. `ANTHROPIC_API_KEY`).
|
|
95
|
+
They will not run in default CI; an explicit, manually triggered workflow may run them on a schedule.
|
package/package.json
CHANGED
|
@@ -1,18 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-autoformat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "4.0.3",
|
|
4
4
|
"description": "Pi extension package for prompt-end auto-formatting",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi-coding-agent",
|
|
9
|
+
"autoformat",
|
|
10
|
+
"formatter",
|
|
11
|
+
"prompt-end",
|
|
12
|
+
"biome",
|
|
13
|
+
"prettier",
|
|
14
|
+
"typescript"
|
|
15
|
+
],
|
|
5
16
|
"author": {
|
|
6
17
|
"name": "Chris Lasher"
|
|
7
18
|
},
|
|
8
19
|
"license": "MIT",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/gotgenes/pi-autoformat"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
9
27
|
"type": "module",
|
|
10
28
|
"pi": {
|
|
11
29
|
"extensions": [
|
|
12
30
|
"./src/extension.ts"
|
|
13
31
|
]
|
|
14
32
|
},
|
|
15
|
-
"
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@biomejs/biome": "^2.4.14",
|
|
35
|
+
"@mariozechner/pi-coding-agent": "^0.73.0",
|
|
36
|
+
"@types/node": "^25.6.0",
|
|
37
|
+
"markdownlint-cli2": "^0.22.1",
|
|
38
|
+
"typescript": "^6.0.3",
|
|
39
|
+
"vitest": "^4.1.5"
|
|
40
|
+
},
|
|
16
41
|
"scripts": {
|
|
17
42
|
"lint": "pnpm exec biome check . && pnpm run lint:md",
|
|
18
43
|
"lint:fix": "pnpm exec biome check --write --files-ignore-unknown=true . && pnpm run lint:md:fix",
|
|
@@ -20,13 +45,7 @@
|
|
|
20
45
|
"lint:md:fix": "pnpm exec markdownlint-cli2 --fix '*.md' 'docs/**/*.md'",
|
|
21
46
|
"format": "pnpm exec biome check --write --files-ignore-unknown=true .",
|
|
22
47
|
"test": "vitest run",
|
|
23
|
-
"test:watch": "vitest"
|
|
24
|
-
|
|
25
|
-
"devDependencies": {
|
|
26
|
-
"@biomejs/biome": "^2.4.13",
|
|
27
|
-
"@types/node": "^25.6.0",
|
|
28
|
-
"markdownlint-cli2": "^0.22.1",
|
|
29
|
-
"typescript": "^6.0.3",
|
|
30
|
-
"vitest": "^4.1.5"
|
|
48
|
+
"test:watch": "vitest",
|
|
49
|
+
"typecheck": "tsc --noEmit"
|
|
31
50
|
}
|
|
32
|
-
}
|
|
51
|
+
}
|
package/prek.toml
CHANGED
|
@@ -13,12 +13,12 @@ hooks = [
|
|
|
13
13
|
[[repos]]
|
|
14
14
|
repo = "local"
|
|
15
15
|
hooks = [
|
|
16
|
-
{ id = "biome", name = "biome", entry = "pnpm exec biome check --
|
|
16
|
+
{ id = "biome", name = "biome", entry = "pnpm exec biome check --files-ignore-unknown=true", language = "system", types_or = ["javascript", "jsx", "ts", "tsx", "json"] },
|
|
17
17
|
]
|
|
18
18
|
|
|
19
19
|
[[repos]]
|
|
20
20
|
repo = "https://github.com/DavidAnson/markdownlint-cli2"
|
|
21
21
|
rev = "v0.22.1"
|
|
22
22
|
hooks = [
|
|
23
|
-
{ id = "markdownlint-cli2"
|
|
23
|
+
{ id = "markdownlint-cli2" },
|
|
24
24
|
]
|
|
@@ -10,11 +10,6 @@
|
|
|
10
10
|
"type": "string",
|
|
11
11
|
"description": "Optional schema URL for editor validation and autocomplete."
|
|
12
12
|
},
|
|
13
|
-
"formatMode": {
|
|
14
|
-
"type": "string",
|
|
15
|
-
"enum": ["tool", "prompt", "session"],
|
|
16
|
-
"description": "When formatting should run."
|
|
17
|
-
},
|
|
18
13
|
"commandTimeoutMs": {
|
|
19
14
|
"type": "integer",
|
|
20
15
|
"minimum": 1,
|
|
@@ -24,6 +19,71 @@
|
|
|
24
19
|
"type": "boolean",
|
|
25
20
|
"description": "Hide formatter summaries in the interactive TUI."
|
|
26
21
|
},
|
|
22
|
+
"formatScope": {
|
|
23
|
+
"description": "Boundary for files eligible for formatting. 'repoRoot' (default) detects the Git toplevel and falls back to cwd; 'cwd' restricts to the strict cwd subtree; an array of paths is an explicit allowlist of roots resolved relative to cwd.",
|
|
24
|
+
"oneOf": [
|
|
25
|
+
{ "type": "string", "enum": ["repoRoot", "cwd"] },
|
|
26
|
+
{
|
|
27
|
+
"type": "array",
|
|
28
|
+
"items": { "type": "string", "minLength": 1 },
|
|
29
|
+
"minItems": 1
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"shellMutationDetection": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"description": "Opt-in detection of file mutations performed by shell commands.",
|
|
36
|
+
"additionalProperties": false,
|
|
37
|
+
"properties": {
|
|
38
|
+
"enabled": {
|
|
39
|
+
"type": "boolean",
|
|
40
|
+
"description": "Master switch for shell mutation detection. Defaults to false."
|
|
41
|
+
},
|
|
42
|
+
"argumentParsing": {
|
|
43
|
+
"type": "boolean",
|
|
44
|
+
"description": "Parse known mutating commands (sed -i, mv, cp, touch, tee, redirections). Defaults to true once detection is enabled."
|
|
45
|
+
},
|
|
46
|
+
"snapshotGlobs": {
|
|
47
|
+
"type": "array",
|
|
48
|
+
"description": "Globs whose mtimes are snapshotted before/after each bash invocation. Files whose mtime advanced are treated as touched.",
|
|
49
|
+
"items": { "type": "string", "minLength": 1 }
|
|
50
|
+
},
|
|
51
|
+
"wrappers": {
|
|
52
|
+
"type": "array",
|
|
53
|
+
"description": "Shell command prefixes that print touched files on stdout.",
|
|
54
|
+
"items": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"additionalProperties": false,
|
|
57
|
+
"required": ["prefix"],
|
|
58
|
+
"properties": {
|
|
59
|
+
"prefix": { "type": "string", "minLength": 1 },
|
|
60
|
+
"outputFormat": { "type": "string", "enum": ["lines"] }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"customMutationTools": {
|
|
67
|
+
"type": "array",
|
|
68
|
+
"description": "Declare non-built-in tools that mutate files. Each entry maps a tool name to the field(s) on the tool's input payload that contain the touched path(s). Use for extension- or MCP-provided tools (e.g. mcp_files_write). Built-in tool names (write, edit, bash, read, grep, find, ls) are rejected.",
|
|
69
|
+
"items": { "$ref": "#/$defs/customMutationToolSpec" }
|
|
70
|
+
},
|
|
71
|
+
"eventBusMutationChannel": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"description": "Subscribe to a Pi EventBus channel for touched-file notifications from peer extensions.",
|
|
74
|
+
"additionalProperties": false,
|
|
75
|
+
"properties": {
|
|
76
|
+
"enabled": {
|
|
77
|
+
"type": "boolean",
|
|
78
|
+
"description": "Whether to subscribe to the channel. Default: true."
|
|
79
|
+
},
|
|
80
|
+
"channel": {
|
|
81
|
+
"type": "string",
|
|
82
|
+
"minLength": 1,
|
|
83
|
+
"description": "Channel name. Default: \"autoformat:touched\". Payload: { path: string } or { paths: string[] }."
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
27
87
|
"formatters": {
|
|
28
88
|
"type": "object",
|
|
29
89
|
"description": "Formatter registry keyed by formatter name.",
|
|
@@ -31,44 +91,108 @@
|
|
|
31
91
|
"$ref": "#/$defs/formatterDefinition"
|
|
32
92
|
}
|
|
33
93
|
},
|
|
94
|
+
"formatterOutput": {
|
|
95
|
+
"$ref": "#/$defs/formatterOutputReportingConfig"
|
|
96
|
+
},
|
|
34
97
|
"chains": {
|
|
35
98
|
"type": "object",
|
|
36
|
-
"description": "Ordered formatter chains keyed by file extension.",
|
|
99
|
+
"description": "Ordered formatter chains keyed by file extension or the literal wildcard '*'. The wildcard chain runs first against the full batch of touched files; files reported as unhandled by built-in dispatchers (treefmt, treefmt-nix) fall through to their per-extension chain. Each step is either a formatter name (string) or a fallback group ({ \"fallback\": [name, ...] }) that runs the first listed formatter whose command is found on PATH.",
|
|
37
100
|
"propertyNames": {
|
|
38
101
|
"type": "string",
|
|
39
|
-
"pattern": "
|
|
102
|
+
"pattern": "^(\\..+|\\*)$"
|
|
40
103
|
},
|
|
41
104
|
"additionalProperties": {
|
|
42
105
|
"type": "array",
|
|
43
106
|
"items": {
|
|
44
|
-
"
|
|
45
|
-
|
|
107
|
+
"oneOf": [
|
|
108
|
+
{
|
|
109
|
+
"type": "string",
|
|
110
|
+
"minLength": 1,
|
|
111
|
+
"description": "Single formatter name."
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"type": "object",
|
|
115
|
+
"additionalProperties": false,
|
|
116
|
+
"required": ["fallback"],
|
|
117
|
+
"description": "Fallback group: the first formatter whose command is on PATH runs. Non-zero exit codes are not masked by falling through.",
|
|
118
|
+
"properties": {
|
|
119
|
+
"fallback": {
|
|
120
|
+
"type": "array",
|
|
121
|
+
"minItems": 1,
|
|
122
|
+
"items": {
|
|
123
|
+
"type": "string",
|
|
124
|
+
"minLength": 1
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
]
|
|
46
130
|
}
|
|
47
131
|
}
|
|
48
132
|
}
|
|
49
133
|
},
|
|
50
134
|
"$defs": {
|
|
135
|
+
"formatterOutputReportingConfig": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"description": "Optional surfacing of formatter stdout/stderr in failure reports. Successful runs are never annotated. Defaults preserve concise reporting.",
|
|
138
|
+
"additionalProperties": false,
|
|
139
|
+
"properties": {
|
|
140
|
+
"onFailure": {
|
|
141
|
+
"type": "string",
|
|
142
|
+
"enum": ["none", "stderr", "both"],
|
|
143
|
+
"description": "Which streams of a failed run to include in the failure report. Default: \"none\"."
|
|
144
|
+
},
|
|
145
|
+
"maxBytes": {
|
|
146
|
+
"type": "integer",
|
|
147
|
+
"minimum": 0,
|
|
148
|
+
"description": "Hard byte cap per stream per failed run (UTF-8). Tail is preserved. Default: 4096."
|
|
149
|
+
},
|
|
150
|
+
"maxLines": {
|
|
151
|
+
"type": "integer",
|
|
152
|
+
"minimum": 0,
|
|
153
|
+
"description": "Hard line cap per stream per failed run, applied after byte trimming. Default: 40."
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
"customMutationToolSpec": {
|
|
158
|
+
"type": "object",
|
|
159
|
+
"additionalProperties": false,
|
|
160
|
+
"required": ["toolName"],
|
|
161
|
+
"properties": {
|
|
162
|
+
"toolName": {
|
|
163
|
+
"type": "string",
|
|
164
|
+
"minLength": 1,
|
|
165
|
+
"description": "Exact match against the tool's name. Cannot be a Pi built-in tool."
|
|
166
|
+
},
|
|
167
|
+
"pathField": {
|
|
168
|
+
"type": "string",
|
|
169
|
+
"minLength": 1,
|
|
170
|
+
"description": "Single dotted path into the tool's input payload (e.g. 'path' or 'args.target'). The resolved value may be a string or string array."
|
|
171
|
+
},
|
|
172
|
+
"pathFields": {
|
|
173
|
+
"type": "array",
|
|
174
|
+
"minItems": 1,
|
|
175
|
+
"items": { "type": "string", "minLength": 1 },
|
|
176
|
+
"description": "Multiple dotted paths into the tool's input payload. Each resolved value may be a string or string array; arrays are flattened."
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
"oneOf": [
|
|
180
|
+
{ "required": ["pathField"], "not": { "required": ["pathFields"] } },
|
|
181
|
+
{ "required": ["pathFields"], "not": { "required": ["pathField"] } }
|
|
182
|
+
]
|
|
183
|
+
},
|
|
51
184
|
"formatterDefinition": {
|
|
52
185
|
"type": "object",
|
|
53
186
|
"additionalProperties": false,
|
|
54
187
|
"properties": {
|
|
55
188
|
"command": {
|
|
56
189
|
"type": "array",
|
|
57
|
-
"description": "Command argv used to format
|
|
58
|
-
"items": {
|
|
59
|
-
"type": "string"
|
|
60
|
-
},
|
|
61
|
-
"minItems": 1
|
|
62
|
-
},
|
|
63
|
-
"extensions": {
|
|
64
|
-
"type": "array",
|
|
65
|
-
"description": "File extensions this formatter applies to.",
|
|
190
|
+
"description": "Command argv used to format files. The executor appends touched file paths to the end of this command, so do not include file paths or the legacy $FILE token here.",
|
|
66
191
|
"items": {
|
|
67
192
|
"type": "string",
|
|
68
|
-
"pattern": "
|
|
193
|
+
"not": { "pattern": "\\$FILE" }
|
|
69
194
|
},
|
|
70
|
-
"minItems": 1
|
|
71
|
-
"uniqueItems": true
|
|
195
|
+
"minItems": 1
|
|
72
196
|
},
|
|
73
197
|
"environment": {
|
|
74
198
|
"type": "object",
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { BatchRun } from "./formatter-executor.js";
|
|
5
|
+
|
|
6
|
+
export type DiscoveryCache = Map<string, string | null>;
|
|
7
|
+
|
|
8
|
+
export type BuiltinPartition = {
|
|
9
|
+
handled: string[];
|
|
10
|
+
unhandled: string[];
|
|
11
|
+
/** When true, the run is treated as a no-op skip (not a failed run). */
|
|
12
|
+
treatAsSkip: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type BuiltinDiscoveryContext = {
|
|
16
|
+
cache?: DiscoveryCache;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type BuiltinFormatter = {
|
|
20
|
+
name: string;
|
|
21
|
+
/**
|
|
22
|
+
* Discover the dispatcher's config root by walking up from each touched
|
|
23
|
+
* file's directory. Returns undefined when no config applies, in which
|
|
24
|
+
* case the built-in is skipped for that flush. Cached per session via the
|
|
25
|
+
* provided cache.
|
|
26
|
+
*/
|
|
27
|
+
discoverRoot(
|
|
28
|
+
files: string[],
|
|
29
|
+
context?: BuiltinDiscoveryContext,
|
|
30
|
+
): Promise<string | undefined>;
|
|
31
|
+
/** Build the argv to invoke given the discovered root and the file batch. */
|
|
32
|
+
buildCommand(
|
|
33
|
+
root: string,
|
|
34
|
+
files: string[],
|
|
35
|
+
): { command: string[]; cwd: string };
|
|
36
|
+
/**
|
|
37
|
+
* Inspect a completed BatchRun and return the subset of input files the
|
|
38
|
+
* dispatcher reported as "no formatter matched". Those files fall through
|
|
39
|
+
* to subsequent chain steps / per-extension chains.
|
|
40
|
+
*/
|
|
41
|
+
partitionUnhandled(run: BatchRun, files: string[]): BuiltinPartition;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Walk up from each file's directory looking for the first directory whose
|
|
46
|
+
* `match(dir)` returns true. Memoizes results in the optional cache, keyed
|
|
47
|
+
* by visited directory, so repeated calls within a session avoid redundant
|
|
48
|
+
* stat work.
|
|
49
|
+
*/
|
|
50
|
+
function walkUp(
|
|
51
|
+
files: string[],
|
|
52
|
+
match: (dir: string) => boolean,
|
|
53
|
+
cache?: DiscoveryCache,
|
|
54
|
+
): string | undefined {
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
let dir = path.dirname(path.resolve(file));
|
|
57
|
+
const visited: string[] = [];
|
|
58
|
+
while (true) {
|
|
59
|
+
if (cache?.has(dir)) {
|
|
60
|
+
const cached = cache.get(dir) ?? null;
|
|
61
|
+
for (const v of visited) {
|
|
62
|
+
cache.set(v, cached);
|
|
63
|
+
}
|
|
64
|
+
if (cached) return cached;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
visited.push(dir);
|
|
68
|
+
if (match(dir)) {
|
|
69
|
+
if (cache) {
|
|
70
|
+
for (const v of visited) cache.set(v, dir);
|
|
71
|
+
}
|
|
72
|
+
return dir;
|
|
73
|
+
}
|
|
74
|
+
const parent = path.dirname(dir);
|
|
75
|
+
if (parent === dir) {
|
|
76
|
+
if (cache) {
|
|
77
|
+
for (const v of visited) cache.set(v, null);
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
dir = parent;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hasTreefmtConfig(dir: string): boolean {
|
|
88
|
+
return (
|
|
89
|
+
existsSync(path.join(dir, "treefmt.toml")) ||
|
|
90
|
+
existsSync(path.join(dir, ".treefmt.toml"))
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hasTreefmtNixConfig(dir: string): boolean {
|
|
95
|
+
if (!existsSync(path.join(dir, "flake.nix"))) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return (
|
|
99
|
+
existsSync(path.join(dir, "treefmt.nix")) ||
|
|
100
|
+
existsSync(path.join(dir, "nix", "treefmt.nix"))
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function treefmtConfigPath(root: string): string {
|
|
105
|
+
// treefmt itself prefers treefmt.toml over .treefmt.toml. Default to the
|
|
106
|
+
// canonical name when both/neither exist so callers that haven't created
|
|
107
|
+
// the file on disk still get a sensible argv.
|
|
108
|
+
const canonical = path.join(root, "treefmt.toml");
|
|
109
|
+
const dotted = path.join(root, ".treefmt.toml");
|
|
110
|
+
if (!existsSync(canonical) && existsSync(dotted)) {
|
|
111
|
+
return dotted;
|
|
112
|
+
}
|
|
113
|
+
return canonical;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const treefmt: BuiltinFormatter = {
|
|
117
|
+
name: "treefmt",
|
|
118
|
+
async discoverRoot(files, context) {
|
|
119
|
+
return walkUp(files, hasTreefmtConfig, context?.cache);
|
|
120
|
+
},
|
|
121
|
+
buildCommand(root, files) {
|
|
122
|
+
return {
|
|
123
|
+
command: [
|
|
124
|
+
"treefmt",
|
|
125
|
+
"--config-file",
|
|
126
|
+
treefmtConfigPath(root),
|
|
127
|
+
"--",
|
|
128
|
+
...files,
|
|
129
|
+
],
|
|
130
|
+
cwd: root,
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
partitionUnhandled(run, files) {
|
|
134
|
+
const stderr = run.stderr ?? "";
|
|
135
|
+
const unhandled = new Set<string>();
|
|
136
|
+
// treefmt logs lines like "WARN no formatter for path: /repo/x.bin".
|
|
137
|
+
const re = /no formatter for path[: ]+(\S+)/g;
|
|
138
|
+
for (const match of stderr.matchAll(re)) {
|
|
139
|
+
unhandled.add(match[1]);
|
|
140
|
+
}
|
|
141
|
+
const handled = files.filter((f) => !unhandled.has(f));
|
|
142
|
+
const treatAsSkip =
|
|
143
|
+
run.exitCode === 0 && unhandled.size > 0 && handled.length === 0;
|
|
144
|
+
return {
|
|
145
|
+
handled,
|
|
146
|
+
unhandled: files.filter((f) => unhandled.has(f)),
|
|
147
|
+
treatAsSkip,
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const NIX_TRANSIENT_PATTERNS: readonly string[] = [
|
|
153
|
+
"cannot connect to socket",
|
|
154
|
+
"error: build of",
|
|
155
|
+
"error: unable to start any build",
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
function looksLikeNixTransient(stderr: string): boolean {
|
|
159
|
+
return NIX_TRANSIENT_PATTERNS.some((p) => stderr.includes(p));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const treefmtNix: BuiltinFormatter = {
|
|
163
|
+
name: "treefmt-nix",
|
|
164
|
+
async discoverRoot(files, context) {
|
|
165
|
+
return walkUp(files, hasTreefmtNixConfig, context?.cache);
|
|
166
|
+
},
|
|
167
|
+
buildCommand(root, files) {
|
|
168
|
+
return {
|
|
169
|
+
command: [
|
|
170
|
+
"nix",
|
|
171
|
+
"fmt",
|
|
172
|
+
"--no-update-lock-file",
|
|
173
|
+
"--no-write-lock-file",
|
|
174
|
+
"--",
|
|
175
|
+
...files,
|
|
176
|
+
],
|
|
177
|
+
cwd: root,
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
partitionUnhandled(run, files) {
|
|
181
|
+
const stderr = run.stderr ?? "";
|
|
182
|
+
if (
|
|
183
|
+
stderr.includes("emitted 0 files for processing") ||
|
|
184
|
+
looksLikeNixTransient(stderr)
|
|
185
|
+
) {
|
|
186
|
+
return { handled: [], unhandled: [...files], treatAsSkip: true };
|
|
187
|
+
}
|
|
188
|
+
return { handled: [...files], unhandled: [], treatAsSkip: false };
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export const BUILTIN_FORMATTERS: Record<string, BuiltinFormatter> = {
|
|
193
|
+
treefmt,
|
|
194
|
+
"treefmt-nix": treefmtNix,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export function isBuiltinFormatterName(name: string): boolean {
|
|
198
|
+
return Object.hasOwn(BUILTIN_FORMATTERS, name);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function getBuiltinFormatter(
|
|
202
|
+
name: string,
|
|
203
|
+
): BuiltinFormatter | undefined {
|
|
204
|
+
return BUILTIN_FORMATTERS[name];
|
|
205
|
+
}
|