mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* chore: remove code unreachable on every supported platform The Security and code health job's advisory deadcode step reports 152 unreachable functions on main. Running the same check under GOOS=linux, windows, and darwin shows 13 of those are false positives whose callers live in windows build-tagged files, so only functions unreachable on all three platforms are touched. Functions with no references anywhere are deleted, including the internal/reasoning package, which nothing imports. Functions that only tests reference move into per-package export_test.go files so tests keep their seams while the production binary sheds the code. Test call sites of deleted convenience wrappers are ported to the live APIs (scoped tool constructors, tools.DeferredSource, FinishReason checks). Kept despite the report: cron.Store.Runs (only reader of runs.jsonl, which production writes), and agenteval.TraceEventKeys and MissingTraceEvents (used across packages by the trace contract test). * docs(config): correct ValidateFile's errors.As claim Message is a flattened string (.Error() already called on the parse error), not a wrapped error, so callers can't recover the concrete json error type via errors.As. Fix the comment to match. Co-Authored-By: Claude Sonnet 5 <[email protected]> * chore(trace,skills): drop obsolete Sink adapters and fix stale doc references The dead-code pass removed the Emit methods but left Sink, NDJSONSink, and TextSink exported with a contract nothing implements; remove the types with their contract. Also remove the stray blank line at EOF of trace.go flagged by gofmt, and update skills package docs and HOW_ZERO_WORKS.md to stop directing callers to DiscoveryRoots/Duplicates, which are now test seams. Co-Authored-By: Claude Fable 5 <[email protected]> * chore: clean up remaining review nits on dead code removal Replace a pointless fmt.Errorf(...%w...).Error() with fmt.Sprintf using %v, since the wrapped error was discarded immediately anyway. Remove a comment block that was duplicated verbatim. * docs(skills): drop remaining references to test-only List/Get Load and MergedSkills comments still named List/Get as production entry points after they moved to export_test.go. Point callers at Load and ListFromRoots instead. * chore: remove now-unreferenced export_test.go seams execSessionManager.len() and likelySandboxDenied/sandboxDenialKind had no remaining callers after the sandbox unify-command-execution rebase; the production code they wrapped for tests no longer exists. --------- Co-authored-by: Claude Sonnet 5 <[email protected]>
172 lines
7.7 KiB
Go
172 lines
7.7 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
type editFileTool struct {
|
|
baseTool
|
|
workspaceRoot string
|
|
scope PathScope
|
|
}
|
|
|
|
func NewScopedEditFileTool(workspaceRoot string, scope PathScope) Tool {
|
|
return editFileTool{
|
|
baseTool: baseTool{
|
|
name: "edit_file",
|
|
description: "Replace an exact string in an existing file with uniqueness protection by default.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"path": {Type: "string", Description: "Path of the file to edit."},
|
|
"old_string": {Type: "string", Description: "Exact string to replace. Must match byte-for-byte."},
|
|
"new_string": {Type: "string", Description: "Replacement string. May be empty."},
|
|
"replace_all": {Type: "boolean", Description: "Replace every occurrence instead of requiring uniqueness.", Default: false},
|
|
},
|
|
Required: []string{"path", "old_string", "new_string"},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: promptSafety(SideEffectWrite, "Edits files in place."),
|
|
capabilities: ToolCapabilities{Effect: EffectWorkspaceWrite, ThreadSafe: false, ResourceKeys: fileResourceKeys},
|
|
},
|
|
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
|
scope: scope,
|
|
}
|
|
}
|
|
|
|
func (tool editFileTool) Run(ctx context.Context, args map[string]any) Result {
|
|
return tool.RunWithOptions(ctx, args, RunOptions{})
|
|
}
|
|
|
|
func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
|
|
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filename"}, "", true, false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
|
}
|
|
oldString, err := aliasedStringArg(args, []string{"old_string", "old", "search", "find", "old_str"}, "", true, false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
|
}
|
|
newString, err := aliasedStringArg(args, []string{"new_string", "new", "replace", "replacement", "new_str"}, "", true, true)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
|
}
|
|
replaceAll, err := boolArg(args, "replace_all", false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
|
}
|
|
|
|
absolutePath, relativePath, err := resolveScopedPath(tool.workspaceRoot, tool.scope, requestedPath)
|
|
if err != nil {
|
|
return errorResult("Error reading " + requestedPath + ": " + err.Error())
|
|
}
|
|
contentBytes, err := os.ReadFile(absolutePath)
|
|
if err != nil {
|
|
return errorResult("Error reading " + relativePath + ": " + err.Error())
|
|
}
|
|
// Refuse to edit a file that changed on disk outside Zero since it was last
|
|
// read: the model's old_string was formed against a stale view, so applying it
|
|
// now could silently corrupt the newer content.
|
|
if cerr := options.FileTracker.CheckConflict(absolutePath, contentBytes); cerr != nil {
|
|
return errorResult(fileConflictMessage(relativePath))
|
|
}
|
|
content := string(contentBytes)
|
|
occurrences := strings.Count(content, oldString)
|
|
|
|
// CRLF fallback: read_file normalizes \r\n → \n before presenting content to
|
|
// the model, so the model's old_string will use \n line endings. When the raw
|
|
// file uses \r\n (common on Windows), the \n-based old_string won't match.
|
|
// Detect this and transparently normalize: if the direct match fails, translate
|
|
// old_string's line endings to \r\n and search again. If found, use the CRLF-translated
|
|
// old_string and new_string for the replacement to preserve the file's existing EOL style
|
|
// without rewriting EOLs in unrelated parts of the file.
|
|
if occurrences == 0 && strings.Contains(content, "\r\n") && !strings.Contains(oldString, "\r\n") {
|
|
crlfOldString := strings.ReplaceAll(oldString, "\n", "\r\n")
|
|
normalizedOccurrences := strings.Count(content, crlfOldString)
|
|
if normalizedOccurrences > 0 {
|
|
occurrences = normalizedOccurrences
|
|
oldString = crlfOldString
|
|
if !strings.Contains(newString, "\r\n") {
|
|
newString = strings.ReplaceAll(newString, "\n", "\r\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fuzzy fallback: when the exact string (and its CRLF translation) is absent,
|
|
// run a cascade of tolerant matchers (trimmed lines, block anchors, collapsed
|
|
// whitespace, indentation drift, escape normalization) to locate the span the
|
|
// model intended. Only a span that occurs literally in the file is accepted,
|
|
// so the replacement applied below is still exact.
|
|
if occurrences == 0 {
|
|
findOld, findNew := oldString, newString
|
|
if strings.Contains(content, "\r\n") && !strings.Contains(findOld, "\r\n") {
|
|
findOld = strings.ReplaceAll(findOld, "\n", "\r\n")
|
|
findNew = strings.ReplaceAll(findNew, "\n", "\r\n")
|
|
}
|
|
search, ferr := fuzzyEditMatch(content, findOld, replaceAll)
|
|
switch {
|
|
case ferr == nil:
|
|
oldString = search
|
|
// The model's new_string was written at old_string's (mismatched)
|
|
// indentation; re-shape it to the span actually being replaced so a
|
|
// tolerant match never strips indentation or a trailing CR.
|
|
newString = adaptReplacementToSpan(search, findOld, findNew)
|
|
occurrences = strings.Count(content, search)
|
|
case errors.Is(ferr, errEditFuzzyAmbiguous):
|
|
return errorResult("Error: old_string matches multiple locations in " + relativePath + " even after fuzzy matching. Provide more surrounding context to make the match unique, or pass replace_all: true.")
|
|
case errors.Is(ferr, errEditFuzzyNotFound):
|
|
// Fall through to the exact-match error below.
|
|
default:
|
|
return errorResult("Error editing " + relativePath + ": " + ferr.Error())
|
|
}
|
|
}
|
|
|
|
if occurrences == 0 {
|
|
return errorResult("Error: Could not find the exact string to replace in " + relativePath + ". The old_string must match the file byte-for-byte.")
|
|
}
|
|
if !replaceAll && occurrences > 1 {
|
|
return errorResult(fmt.Sprintf("Error: old_string matches %d locations in %s. Either make old_string more specific, or pass replace_all: true to replace every occurrence.", occurrences, relativePath))
|
|
}
|
|
|
|
updated := strings.Replace(content, oldString, newString, 1)
|
|
replacedCount := 1
|
|
if replaceAll {
|
|
updated = strings.ReplaceAll(content, oldString, newString)
|
|
replacedCount = occurrences
|
|
}
|
|
if updated == content {
|
|
return okResult("No changes: new_string is identical to old_string.")
|
|
}
|
|
if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil {
|
|
return errorResult("Error writing " + relativePath + ": " + err.Error())
|
|
}
|
|
if err := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil {
|
|
return errorResult("Error writing " + relativePath + ": " + err.Error())
|
|
}
|
|
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
|
|
// FileTracker re-baseline: recording pre-format content would make the very
|
|
// next edit look like an external modification and trip the conflict guard.
|
|
updated = maybeFormatWrittenFile(ctx, absolutePath, updated)
|
|
// Re-baseline to the content we just wrote so subsequent edits in this session
|
|
// compare against the current on-disk state, not the pre-edit version.
|
|
newInfo, _ := os.Stat(absolutePath)
|
|
options.FileTracker.Record(absolutePath, []byte(updated), newInfo)
|
|
|
|
suffix := ""
|
|
if replacedCount != 1 {
|
|
suffix = "s"
|
|
}
|
|
summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix)
|
|
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
|
|
result := okResult(summary)
|
|
result.ChangedFiles = []string{relativePath}
|
|
// Card-only preview (Display.Preview): the model's Output stays the one-line
|
|
// summary, so the red/green diff costs zero model tokens.
|
|
result.Display = Display{Summary: fmt.Sprintf("Edited %s", relativePath), Kind: "diff", Preview: boundedUnifiedDiff(relativePath, content, updated)}
|
|
return result
|
|
}
|