Skip to main content

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}