supercode_harness/formatters.rs
1//! §2 module 29 `formatters` (COMPOSABLE-HARNESS-DESIGN.md line 479):
2//! "D10/oc§10 format-on-write" — reuses the EXACT [`crate::tools::WriteObserver`]
3//! seam P5-9 built for `checkpoint` (D-5: "write-path interception seam
4//! shared with checkpoint"), rather than a second interception point.
5//!
6//! # C10 (design line 534) — the critical correctness rule
7//! "Post-write formatting invalidates the model's file memory; formatter
8//! must diff-back into the result (oc§10)." Concretely: once a formatter
9//! rewrites a file the model just wrote/edited, the model's IN-CONTEXT
10//! belief about that file's bytes is stale. `FormatObserver::after_write`
11//! (when `[capabilities.formatters] diff_back = true`, the default) returns
12//! a unified diff of exactly what the formatter changed, appended to the
13//! calling tool's result — so the model's next action is informed by the
14//! ACTUAL on-disk bytes, not its own pre-format draft. `diff_back = false`
15//! still runs the formatter (the file changes) but withholds the
16//! annotation — the C10-UNSAFE mode, legal but never the default.
17//!
18//! # Wire model — stdin -> stdout filter
19//! A configured formatter is invoked as `command args...` with the
20//! JUST-WRITTEN file's bytes piped to its stdin; its stdout (bounded,
21//! [`MAX_FORMATTER_OUTPUT_BYTES`]) becomes the new file content IF the
22//! process exits `0` and produces non-empty output that differs from the
23//! input. This is the standard "formatter as filter" contract real tools
24//! already support in this mode (`gofmt` reads stdin/writes stdout by
25//! default; `rustfmt --emit stdout`; `prettier --stdin-filepath <name>`;
26//! `black -`), and it needs no `%f`-style path-templating in the config
27//! schema — the weakest form that still composes with arbitrary real
28//! formatters. A non-zero exit, a timeout, or empty output is treated as
29//! "formatter had nothing useful to say" and never corrupts the file: the
30//! on-disk content from the write/edit tool is left exactly as that tool
31//! produced it.
32//!
33//! # Ordering (composes with `checkpoint` + `lsp` on the shared seam)
34//! `crate::agent::build_tool_context` installs observers in the order
35//! `checkpoint -> formatters -> lsp` via
36//! [`crate::tools::WriteObserverChain`]: checkpoint's `before_write`
37//! captures the pre-image before ANY mutation; this module's `after_write`
38//! reformats the just-written file; `lsp`'s `after_write` (running AFTER
39//! this one in the same chain) then reads the FINAL, formatted file for
40//! diagnostics — never the model's pre-format draft.
41
42use std::path::{Path, PathBuf};
43use std::time::Duration;
44
45use tokio::io::{AsyncReadExt, AsyncWriteExt};
46
47/// Bound on a single formatter invocation's stdout — a hostile or broken
48/// configured formatter can't force unbounded memory growth (same
49/// rationale as `crate::mcp::MCP_MAX_RESPONSE_BYTES`).
50pub const MAX_FORMATTER_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
51
52/// Bound on the unified diff text appended to a tool result — a formatter
53/// that rewrites a huge file can't blow the model's context with a huge
54/// diff either (same bounded-annotation posture as `crate::lsp`'s
55/// diagnostics cap).
56pub const MAX_DIFF_CHARS: usize = 6000;
57
58/// Default per-invocation timeout — a hanging formatter can't hang the
59/// write-path loop (build brief: "timeout + kill like hooks").
60pub const DEFAULT_FORMATTER_TIMEOUT_SECS: u64 = 10;
61
62/// One `[capabilities.formatters.<name>]` entry — a user-configured
63/// formatter command, invoked as a stdin->stdout filter (see module doc
64/// comment). `command`/`args` are config-borne code execution (D-10) —
65/// stripped from an untrusted project layer exactly like
66/// `[capabilities.lsp.servers.*]`/`hooks`/`mcp.servers`.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct FormatterSpec {
69 /// The executable to spawn.
70 pub command: String,
71 /// Extra arguments passed to `command`.
72 pub args: Vec<String>,
73 /// File extensions (with or without a leading `.`, matched case-
74 /// insensitively) this formatter handles.
75 pub extensions: Vec<String>,
76}
77
78fn spec_for_extension<'a>(
79 specs: &'a [(String, FormatterSpec)],
80 path: &Path,
81) -> Option<&'a (String, FormatterSpec)> {
82 let ext = path.extension()?.to_str()?.to_ascii_lowercase();
83 specs.iter().find(|(_, s)| {
84 s.extensions
85 .iter()
86 .any(|e| e.trim_start_matches('.').to_ascii_lowercase() == ext)
87 })
88}
89
90/// Run `spec` as a stdin->stdout filter over `input`, bounded by `timeout`
91/// (wall clock) and [`MAX_FORMATTER_OUTPUT_BYTES`] (output size). Returns
92/// `Ok(None)` for any "formatter had nothing useful to say" outcome (never
93/// an `Err` the caller has to specially handle to stay safe) — `Err` is
94/// reserved for a spawn failure, which the caller logs then also treats as
95/// "leave the file alone".
96async fn run_formatter(
97 spec: &FormatterSpec,
98 input: &[u8],
99 timeout: Duration,
100) -> crate::error::Result<Option<Vec<u8>>> {
101 // Same grandchild-orphan posture as `crate::lsp::LspClient::spawn`
102 // (P5-11 review): put the formatter in its OWN process group so a
103 // timed-out/hung formatter that has already spawned a helper process
104 // can be group-killed below, not just its direct pid. Lower risk here
105 // than LSP (a formatter is a short-lived stdin->stdout filter, not a
106 // persistent server with its own worker subprocesses), but the primitive
107 // is nearly free to apply consistently.
108 let mut cmd = tokio::process::Command::new(&spec.command);
109 cmd.args(&spec.args)
110 .stdin(std::process::Stdio::piped())
111 .stdout(std::process::Stdio::piped())
112 .stderr(std::process::Stdio::null())
113 .kill_on_drop(true);
114 #[cfg(unix)]
115 cmd.process_group(0);
116 let mut child = cmd.spawn().map_err(|e| {
117 crate::error::Error::tool("formatters", format!("spawn {}: {e}", spec.command))
118 })?;
119 #[cfg(unix)]
120 let child_pid = child.id();
121
122 let mut stdin = child
123 .stdin
124 .take()
125 .ok_or_else(|| crate::error::Error::tool("formatters", "no stdin"))?;
126 let mut stdout = child
127 .stdout
128 .take()
129 .ok_or_else(|| crate::error::Error::tool("formatters", "no stdout"))?;
130
131 let owned_input = input.to_vec();
132 // Write on a separate task so a formatter that starts emitting output
133 // before it has consumed all of stdin can never deadlock this process
134 // against a full OS pipe buffer in either direction.
135 let writer = tokio::spawn(async move {
136 let _ = stdin.write_all(&owned_input).await;
137 // `stdin` drops here, closing the pipe — EOF for the child.
138 });
139 let reader = tokio::spawn(async move {
140 let mut buf = Vec::new();
141 let mut limited = (&mut stdout).take(MAX_FORMATTER_OUTPUT_BYTES as u64);
142 let _ = limited.read_to_end(&mut buf).await;
143 buf
144 });
145
146 let wait_result = tokio::time::timeout(timeout, child.wait()).await;
147 match wait_result {
148 Ok(Ok(status)) => {
149 writer.abort();
150 let output = reader.await.unwrap_or_default();
151 if !status.success() {
152 return Ok(None); // non-zero exit: leave the file untouched
153 }
154 if output.is_empty() {
155 return Ok(None); // no output: nothing to apply
156 }
157 Ok(Some(output))
158 }
159 Ok(Err(e)) => Err(crate::error::Error::tool(
160 "formatters",
161 format!("wait failed: {e}"),
162 )),
163 Err(_elapsed) => {
164 // Timeout: explicitly SIGKILL the formatter's WHOLE process
165 // group (unix) — same mechanism as `crate::lsp::LspClient::kill`
166 // / `crate::agent::kill_job_process_group` — so a hung
167 // formatter that already spawned a helper process doesn't leave
168 // it running past this timeout. `child` itself (still owned by
169 // this function's stack, never moved into the timed-out
170 // `child.wait()` future) is then dropped when this function
171 // returns — `.kill_on_drop(true)` reaps the direct pid as a
172 // second, independent backstop. Abort the reader/writer tasks
173 // too so they don't linger against a since-killed process's
174 // now-closed pipes.
175 #[cfg(unix)]
176 if let Some(pid) = child_pid {
177 crate::lsp::kill_process_group(pid);
178 }
179 writer.abort();
180 reader.abort();
181 Err(crate::error::Error::tool(
182 "formatters",
183 format!("timed out after {:?}", timeout),
184 ))
185 }
186 }
187}
188
189/// The [`crate::tools::WriteObserver`] `[capabilities.formatters]`
190/// installs. `before_write` is a true no-op (format-on-write only ever
191/// acts AFTER a mutation). `after_write` runs the configured formatter (if
192/// any matches `path`'s extension), rewrites the file when the formatter's
193/// output differs from what was just written, and — when
194/// `Self::diff_back` is `true` — returns a unified diff annotation so
195/// the calling tool's result stays truthful about the file's final bytes
196/// (C10).
197#[derive(Debug)]
198pub struct FormatObserver {
199 specs: Vec<(String, FormatterSpec)>,
200 root: PathBuf,
201 timeout: Duration,
202 diff_back: bool,
203}
204
205impl FormatObserver {
206 /// Build an observer over `specs` (name -> formatter definition),
207 /// rooted at `root` (the containment floor every touched path is
208 /// checked against via `crate::safe_path::contained`).
209 pub fn new(
210 root: PathBuf,
211 specs: Vec<(String, FormatterSpec)>,
212 timeout: Duration,
213 diff_back: bool,
214 ) -> Self {
215 FormatObserver {
216 specs,
217 root,
218 timeout,
219 diff_back,
220 }
221 }
222}
223
224#[async_trait::async_trait]
225impl crate::tools::WriteObserver for FormatObserver {
226 async fn before_write(&self, _path: &Path) {}
227
228 async fn after_write(&self, path: &Path) -> Option<String> {
229 if !crate::safe_path::contained(&self.root, path) {
230 return None; // out of this module's scope — never touch outside the project
231 }
232 let (name, spec) = spec_for_extension(&self.specs, path)?;
233 let original = match tokio::fs::read(path).await {
234 Ok(b) => b,
235 Err(_) => return None, // deleted/unreadable — nothing to format
236 };
237 let formatted = match run_formatter(spec, &original, self.timeout).await {
238 Ok(Some(bytes)) => bytes,
239 Ok(None) => return None, // no-op outcome (failure/timeout/empty/unchanged)
240 Err(e) => {
241 tracing::warn!(formatter = %name, "formatters: {e} — leaving file untouched");
242 return None;
243 }
244 };
245 if formatted == original {
246 return None; // already-formatted — nothing to report
247 }
248 // Re-check containment on write-back too — defense in depth,
249 // mirrors `crate::lsp`'s symmetric posture (the path hasn't
250 // changed since the check above, but the cost of re-checking is
251 // negligible and it keeps this function's own invariant local
252 // rather than relying solely on the caller).
253 if !crate::safe_path::contained(&self.root, path) {
254 return None;
255 }
256 if tokio::fs::write(path, &formatted).await.is_err() {
257 tracing::warn!(formatter = %name, path = %path.display(), "formatters: failed to write formatted output");
258 return None;
259 }
260 if !self.diff_back {
261 // C10-unsafe mode: the file changed, but the model isn't told
262 // — legal (build brief: "allowed but it's the non-default"),
263 // never the default (`diff_back = true`).
264 return None;
265 }
266 let original_text = String::from_utf8_lossy(&original);
267 let formatted_text = String::from_utf8_lossy(&formatted);
268 let mut diff = diffy::create_patch(&original_text, &formatted_text).to_string();
269 if diff.chars().count() > MAX_DIFF_CHARS {
270 diff = diff.chars().take(MAX_DIFF_CHARS).collect::<String>();
271 diff.push_str("\n... (diff truncated)");
272 }
273 let display_path = path.strip_prefix(&self.root).unwrap_or(path);
274 Some(format!(
275 "Formatter `{name}` reformatted {} — diff:\n{diff}",
276 display_path.display()
277 ))
278 }
279}
280
281/// Build the [`FormatObserver`] a fresh [`crate::Agent`] should install,
282/// given a resolved [`crate::Config`] — called once, from
283/// `crate::agent::build_tool_context`. `Config::formatters_enabled` is the
284/// ONE gate: `false` (the default) returns `None` — no formatter ever
285/// runs, byte-identical to before this module existed.
286pub fn observer_for_config(config: &crate::Config) -> Option<std::sync::Arc<FormatObserver>> {
287 if !config.formatters_enabled {
288 return None;
289 }
290 if config.formatters.is_empty() {
291 eprintln!(
292 "warning: [capabilities.formatters] is enabled but no formatters are configured \
293 under [capabilities.formatters.<name>] — nothing will ever be reformatted"
294 );
295 }
296 Some(std::sync::Arc::new(FormatObserver::new(
297 config.cwd.clone(),
298 config.formatters.clone(),
299 Duration::from_secs(config.formatters_timeout_secs.max(1)),
300 config.formatters_diff_back,
301 )))
302}