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}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::tools::WriteObserver;
308
309    fn tmp(tag: &str) -> PathBuf {
310        let dir = std::env::temp_dir().join(format!(
311            "supercode-formatters-test-{tag}-{}-{}",
312            std::process::id(),
313            std::time::SystemTime::now()
314                .duration_since(std::time::UNIX_EPOCH)
315                .unwrap()
316                .as_nanos()
317        ));
318        std::fs::create_dir_all(&dir).unwrap();
319        dir
320    }
321
322    /// A deterministic, harmless fake formatter: uppercases its stdin —
323    /// never a real formatter binary, never network access (live-agent-
324    /// safety, build brief).
325    fn uppercase_spec() -> FormatterSpec {
326        FormatterSpec {
327            command: "sh".to_string(),
328            args: vec!["-c".to_string(), "tr 'a-z' 'A-Z'".to_string()],
329            extensions: vec![".txt".to_string()],
330        }
331    }
332
333    /// A fake formatter that always exits non-zero without touching stdout
334    /// — proves a failing formatter never corrupts the file.
335    fn failing_spec() -> FormatterSpec {
336        FormatterSpec {
337            command: "sh".to_string(),
338            args: vec!["-c".to_string(), "exit 1".to_string()],
339            extensions: vec![".txt".to_string()],
340        }
341    }
342
343    /// A fake formatter that never exits — proves the timeout bound.
344    fn hanging_spec() -> FormatterSpec {
345        FormatterSpec {
346            command: "sh".to_string(),
347            args: vec!["-c".to_string(), "cat >/dev/null; sleep 3600".to_string()],
348            extensions: vec![".txt".to_string()],
349        }
350    }
351
352    #[tokio::test]
353    async fn observer_for_config_is_none_when_disabled_default_off_byte_identity() {
354        let config = crate::Config::builder().model("m").build();
355        assert!(!config.formatters_enabled);
356        assert!(observer_for_config(&config).is_none());
357    }
358
359    /// C10 diff-back proof: the model writes unformatted content, the
360    /// formatter reformats it, and the annotation the tool result carries
361    /// reflects the FORMATTED content (not the model's raw input).
362    #[tokio::test]
363    async fn diff_back_true_surfaces_the_formatted_content_in_the_annotation() {
364        let project = tmp("diffback-on");
365        let file = project.join("f.txt");
366        std::fs::write(&file, "hello world\n").unwrap();
367        let observer = FormatObserver::new(
368            project.clone(),
369            vec![("upper".to_string(), uppercase_spec())],
370            Duration::from_secs(5),
371            true, // diff_back
372        );
373        let note = observer.after_write(&file).await;
374        let note = note.expect("diff_back=true must annotate a formatting change");
375        assert!(note.contains("upper"), "{note}");
376        assert!(
377            note.contains("HELLO WORLD"),
378            "annotation must reflect the FORMATTED content, not the raw model input: {note}"
379        );
380        assert!(
381            note.contains("-hello world") && note.contains("+HELLO WORLD"),
382            "diff must show the raw input removed and the formatted output added: {note}"
383        );
384        let on_disk = std::fs::read_to_string(&file).unwrap();
385        assert_eq!(
386            on_disk, "HELLO WORLD\n",
387            "the file itself must be reformatted"
388        );
389        std::fs::remove_dir_all(&project).ok();
390    }
391
392    /// The `diff_back = false` (C10-unsafe, non-default) mode: the file is
393    /// still reformatted, but no annotation is returned.
394    #[tokio::test]
395    async fn diff_back_false_reformats_silently() {
396        let project = tmp("diffback-off");
397        let file = project.join("f.txt");
398        std::fs::write(&file, "hello world\n").unwrap();
399        let observer = FormatObserver::new(
400            project.clone(),
401            vec![("upper".to_string(), uppercase_spec())],
402            Duration::from_secs(5),
403            false, // diff_back
404        );
405        let note = observer.after_write(&file).await;
406        assert!(
407            note.is_none(),
408            "diff_back=false must not annotate, even though the file changed: {note:?}"
409        );
410        let on_disk = std::fs::read_to_string(&file).unwrap();
411        assert_eq!(
412            on_disk, "HELLO WORLD\n",
413            "the formatter must still have run and rewritten the file"
414        );
415        std::fs::remove_dir_all(&project).ok();
416    }
417
418    #[tokio::test]
419    async fn an_already_formatted_file_produces_no_annotation_or_rewrite() {
420        let project = tmp("idempotent");
421        let file = project.join("f.txt");
422        std::fs::write(&file, "HELLO WORLD\n").unwrap();
423        let observer = FormatObserver::new(
424            project.clone(),
425            vec![("upper".to_string(), uppercase_spec())],
426            Duration::from_secs(5),
427            true,
428        );
429        let mtime_before = std::fs::metadata(&file).unwrap().modified().unwrap();
430        std::thread::sleep(Duration::from_millis(10));
431        let note = observer.after_write(&file).await;
432        assert!(note.is_none());
433        let mtime_after = std::fs::metadata(&file).unwrap().modified().unwrap();
434        assert_eq!(
435            mtime_before, mtime_after,
436            "an already-formatted file must not be rewritten"
437        );
438        std::fs::remove_dir_all(&project).ok();
439    }
440
441    #[tokio::test]
442    async fn a_failing_formatter_never_corrupts_the_file() {
443        let project = tmp("failing");
444        let file = project.join("f.txt");
445        std::fs::write(&file, "hello world\n").unwrap();
446        let observer = FormatObserver::new(
447            project.clone(),
448            vec![("broken".to_string(), failing_spec())],
449            Duration::from_secs(5),
450            true,
451        );
452        let note = observer.after_write(&file).await;
453        assert!(note.is_none());
454        let on_disk = std::fs::read_to_string(&file).unwrap();
455        assert_eq!(
456            on_disk, "hello world\n",
457            "a failing formatter must leave the file untouched"
458        );
459        std::fs::remove_dir_all(&project).ok();
460    }
461
462    /// Bounded: a hanging formatter must degrade within the configured
463    /// timeout, never hang the write path.
464    #[tokio::test]
465    async fn a_hanging_formatter_degrades_within_the_timeout_bound() {
466        let project = tmp("hanging");
467        let file = project.join("f.txt");
468        std::fs::write(&file, "hello world\n").unwrap();
469        let observer = FormatObserver::new(
470            project.clone(),
471            vec![("hangs".to_string(), hanging_spec())],
472            Duration::from_millis(500),
473            true,
474        );
475        let started = std::time::Instant::now();
476        let note = tokio::time::timeout(Duration::from_secs(10), observer.after_write(&file))
477            .await
478            .expect("must not hang past the configured formatter timeout");
479        assert!(note.is_none());
480        assert!(
481            started.elapsed() < Duration::from_secs(5),
482            "took {:?}, expected to bail out near the 500ms configured timeout",
483            started.elapsed()
484        );
485        let on_disk = std::fs::read_to_string(&file).unwrap();
486        assert_eq!(
487            on_disk, "hello world\n",
488            "a timed-out formatter must leave the file untouched"
489        );
490        std::fs::remove_dir_all(&project).ok();
491    }
492
493    #[tokio::test]
494    async fn an_unconfigured_extension_is_a_true_noop() {
495        let project = tmp("unconfigured");
496        let file = project.join("f.py");
497        std::fs::write(&file, "hello world\n").unwrap();
498        let observer = FormatObserver::new(
499            project.clone(),
500            vec![("upper".to_string(), uppercase_spec())], // only .txt
501            Duration::from_secs(5),
502            true,
503        );
504        let note = observer.after_write(&file).await;
505        assert!(note.is_none());
506        let on_disk = std::fs::read_to_string(&file).unwrap();
507        assert_eq!(on_disk, "hello world\n");
508        std::fs::remove_dir_all(&project).ok();
509    }
510
511    #[tokio::test]
512    async fn a_path_outside_the_root_is_refused() {
513        let project = tmp("outside-project");
514        let outside = tmp("outside-elsewhere");
515        let victim = outside.join("victim.txt");
516        std::fs::write(&victim, "hello world\n").unwrap();
517        let observer = FormatObserver::new(
518            project.clone(),
519            vec![("upper".to_string(), uppercase_spec())],
520            Duration::from_secs(5),
521            true,
522        );
523        let note = observer.after_write(&victim).await;
524        assert!(note.is_none());
525        let on_disk = std::fs::read_to_string(&victim).unwrap();
526        assert_eq!(
527            on_disk, "hello world\n",
528            "must never touch a path outside root"
529        );
530        std::fs::remove_dir_all(&project).ok();
531        std::fs::remove_dir_all(&outside).ok();
532    }
533}