Files
zero/internal/tools/format_on_write.go
KRATOS faec8ee201 feat(acp): emit structured file changes in tool updates (#999)
* feat(tools): retain bounded structured file diffs

* feat(acp): emit structured file changes in tool updates

* fix(acp): harden structured file diff transport

* fix(tools): reject unsafe rich diff previews

* fix(acp): preserve safe rich diff semantics

* fix(tools): bind structured diffs to committed changes

* fix(acp): preserve structured diff identity

* fix(tools): close rich diff race gaps

* fix(tools): report partial patch paths from workspace

* fix(tools): finalize structured diff evidence

* fix(tools): separate unverified patch writes

* test(tools): cover failed formatter recovery

* fix(acp): serialize file deletions as nullable diffs

* fix(tools): recreate files after formatter failure

* test(acp): require nullable diff fields on wire
2026-09-15 08:31:39 +08:00

250 lines
9.9 KiB
Go

package tools
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/sandbox"
)
// Format-on-write for the mutating file tools. When enabled, a successful
// edit_file/write_file runs the language's standard formatter on the file it
// just wrote, so the model's output always lands in project-canonical style
// and never fails a CI format check it cannot see. Off by default (set
// ZERO_FORMAT_ON_WRITE=1): auto-reformatting changes bytes the model did not
// write, which strict workflows may not want.
//
// Ordering matters: formatting runs BEFORE the FileTracker re-baseline, and
// the caller records the POST-format content. Formatting after the baseline
// would make the very next edit look like an external modification and trip
// the conflict guard.
// formatOnWriteTimeout bounds one formatter run; a wedged formatter must never
// hang a tool call. On timeout the unformatted write stands, and the caller
// says so: see formatOnWriteResult.
//
// A var rather than a const so a test can shorten it. Nothing outside a test
// assigns to it, and the deadline path was previously unreachable in a test at
// any speed, which is part of why it went unnoticed that it reported nothing.
var formatOnWriteTimeout = 10 * time.Second
// formatOnWriteResult is the content after formatting, plus whether the
// formatting that was supposed to happen actually did.
//
// A TIMEOUT IS NOT THE SAME KIND OF MISS AS THE OTHERS. Every other way this
// falls back is a standing fact about the environment: the toggle is off, the
// extension has no formatter, the binary is not installed. Those are silent on
// purpose, because nothing is wrong and saying so on every write would be
// noise. A deadline firing is different: formatting was configured, available
// and expected, and the file was written unformatted anyway, on a machine that
// was merely slow. Left silent, the caller believes it wrote canonical style
// and finds out from a CI format check it cannot see, which is the thing this
// feature exists to prevent.
type formatOnWriteResult struct {
Content string
ContentKnown bool
Info os.FileInfo
// Formatter is the binary that was run, named in the notice so the user can
// tell a slow gofmt from a slow prettier.
Formatter string
TimedOut bool
// RestoreFailed means the file on disk is not known to hold Content.
//
// These formatters edit in place, so one that is killed or fails partway
// can leave the target truncated or half-rewritten: what a dead
// `prettier --write` leaves behind is not the input and not the output.
// Returning the written bytes while disk holds something else would put the
// tracker baseline, the diff preview and the file itself into three
// different states, so the failure paths write the bytes back. When even
// that fails the user has to hear about it: it is their file.
RestoreFailed bool
}
// notice is the line appended to the tool summary when formatting was expected
// and did not happen, and empty in every other case.
func (result formatOnWriteResult) notice(relativePath string) string {
if result.RestoreFailed {
return "\n\nWARNING: " + relativePath + " may not hold what was written. " +
result.Formatter + " was interrupted while rewriting it in place and the " +
"content could not be written back. Re-read the file before trusting it."
}
if !result.TimedOut {
return ""
}
return "\n\nNote: " + relativePath + " was written but not formatted: " +
result.Formatter + " did not finish within " + formatOnWriteTimeout.String() +
". The file holds exactly what was written, so a project format check may still flag it."
}
// formatterCommands maps a file extension to the formatter argv; the file path
// is appended as the final argument. Only in-place, config-respecting,
// community-standard formatters — a missing binary silently skips formatting.
var formatterCommands = map[string][]string{
".go": {"gofmt", "-w"},
".rs": {"rustfmt"},
".py": {"ruff", "format", "--quiet"},
".ts": {"prettier", "--log-level", "silent", "--write"},
".tsx": {"prettier", "--log-level", "silent", "--write"},
".js": {"prettier", "--log-level", "silent", "--write"},
".jsx": {"prettier", "--log-level", "silent", "--write"},
".json": {"prettier", "--log-level", "silent", "--write"},
".css": {"prettier", "--log-level", "silent", "--write"},
".scss": {"prettier", "--log-level", "silent", "--write"},
".html": {"prettier", "--log-level", "silent", "--write"},
".md": {"prettier", "--log-level", "silent", "--write"},
".yaml": {"prettier", "--log-level", "silent", "--write"},
".yml": {"prettier", "--log-level", "silent", "--write"},
".zig": {"zig", "fmt"},
".dart": {"dart", "format"},
".tf": {"terraform", "fmt"},
".gleam": {"gleam", "format"},
".sh": {"shfmt", "-w"},
".bash": {"shfmt", "-w"},
".c": {"clang-format", "-i"},
".h": {"clang-format", "-i"},
".cpp": {"clang-format", "-i"},
".hpp": {"clang-format", "-i"},
".cc": {"clang-format", "-i"},
".kt": {"ktlint", "-F"},
".swift": {"swiftformat"},
".lua": {"stylua"},
}
// formatOnWriteEnabled reports whether the opt-in env toggle is set.
func formatOnWriteEnabled() bool {
value := strings.TrimSpace(os.Getenv("ZERO_FORMAT_ON_WRITE"))
return value != "" && value != "0" && !strings.EqualFold(value, "false")
}
var runFormatOnWriteCommand = func(ctx context.Context, binaryPath string, arguments []string, directory string) error {
formatter := exec.CommandContext(ctx, binaryPath, arguments...)
formatter.Dir = directory
formatter.Stdin = strings.NewReader("")
return formatter.Run()
}
var readFormattedFile = readRootedFile
// maybeFormatWrittenFile is the unscoped test-facing wrapper. Production
// callers use maybeFormatWrittenFileScoped so a formatter cannot redirect the
// final read or recovery write outside the configured write roots.
func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenContent string) formatOnWriteResult {
restoreMode := os.FileMode(0o644)
if info, err := os.Stat(absolutePath); err == nil {
restoreMode = info.Mode().Perm()
}
return maybeFormatWrittenFileScoped(ctx, filepath.Dir(absolutePath), nil, absolutePath, writtenContent, restoreMode)
}
// maybeFormatWrittenFileScoped runs the configured formatter and returns only
// content verified through a descriptor-bound root. Formatter failures restore
// writtenContent through that same root; if restoration or the final read
// fails, ContentKnown is false and callers omit exact diff evidence.
func maybeFormatWrittenFileScoped(ctx context.Context, workspaceRoot string, scope PathScope, absolutePath string, writtenContent string, restoreMode os.FileMode) formatOnWriteResult {
unformatted := formatOnWriteResult{Content: writtenContent, ContentKnown: true}
if !formatOnWriteEnabled() {
return unformatted
}
command, ok := formatterCommands[strings.ToLower(filepath.Ext(absolutePath))]
if !ok {
return unformatted
}
binaryPath, err := exec.LookPath(command[0])
if err != nil {
return unformatted
}
root, relativePath, err := openFormattedFileRoot(workspaceRoot, scope, absolutePath)
if err != nil {
unformatted.ContentKnown = false
return unformatted
}
defer root.Close()
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
defer cancel()
arguments := append(append([]string(nil), command[1:]...), absolutePath)
if err := runFormatOnWriteCommand(formatCtx, binaryPath, arguments, filepath.Dir(absolutePath)); err != nil {
unformatted.Formatter = command[0]
if restoreErr := restoreFormattedFile(root, relativePath, writtenContent, restoreMode); restoreErr != nil {
unformatted.RestoreFailed = true
unformatted.ContentKnown = false
} else if restored, info, readErr := readFormattedFile(root, relativePath); readErr != nil {
unformatted.ContentKnown = false
} else {
unformatted.Content = string(restored)
unformatted.Info = info
}
if errors.Is(formatCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil {
unformatted.TimedOut = true
}
return unformatted
}
formatted, info, err := readFormattedFile(root, relativePath)
if err != nil {
unformatted.ContentKnown = false
return unformatted
}
return formatOnWriteResult{Content: string(formatted), ContentKnown: true, Info: info, Formatter: command[0]}
}
func restoreFormattedFile(root *os.Root, relativePath string, content string, mode os.FileMode) error {
file, err := root.OpenFile(relativePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, mode)
if err != nil {
return err
}
if _, err := io.WriteString(file, content); err != nil {
_ = file.Close()
return err
}
return file.Close()
}
// openFormattedFileRoot opens the write root before the formatter runs and
// computes the target relative to that descriptor-bound root. Atomic in-root
// replacement remains valid; a formatter that swaps the target to an escaping
// symlink is rejected when readFormattedFile opens it through the root.
func openFormattedFileRoot(workspaceRoot string, scope PathScope, absolutePath string) (*os.Root, string, error) {
roots, err := scopedRoots(workspaceRoot, scope)
if err != nil {
return nil, "", err
}
var firstErr error
for _, configuredRoot := range roots {
resolvedRoot, err := filepath.Abs(configuredRoot)
if err == nil {
resolvedRoot, err = filepath.EvalSymlinks(resolvedRoot)
}
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
candidate := sandbox.NormalizePrefixForRoot(absolutePath, resolvedRoot)
relativePath, err := filepath.Rel(resolvedRoot, candidate)
if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) || filepath.IsAbs(relativePath) {
continue
}
root, err := os.OpenRoot(resolvedRoot)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
return root, relativePath, nil
}
if firstErr != nil {
return nil, "", firstErr
}
return nil, "", fmt.Errorf("%s must stay inside the configured write roots", absolutePath)
}