mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 17:57:54 -10:00
* 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
250 lines
9.9 KiB
Go
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)
|
|
}
|