Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

333 lines
8.8 KiB
Go

package canonical
import (
"regexp"
"strconv"
"strings"
)
var toolUnifiedDiffHunkPattern = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?:.*)$`)
// NormalizeToolFileChanges is the shared fileChanges contract used by tool
// payloads and Agent Session Replay final-state compare. Invalid unified-diff
// bodies on added or modified files become newString; only real unified diffs
// keep diff / unifiedDiff. Callers should run both recorded and live graphs
// through this before equality checks so older cassettes stay portable.
func NormalizeToolFileChanges(value any) map[string]any {
return normalizeToolFileChanges(value)
}
func normalizeToolFileChanges(value any) map[string]any {
body := toolMap(value)
if body == nil {
return nil
}
filesByPath := make(map[string]map[string]any)
order := make([]string, 0)
for _, raw := range toolFileChangeMaps(body["files"]) {
file := normalizeToolFileChange(raw)
if file == nil {
continue
}
path := toolString(file["path"])
if current, exists := filesByPath[path]; exists {
filesByPath[path] = mergeToolFileChangeValues(current, file)
continue
}
order = append(order, path)
filesByPath[path] = file
}
files := make([]any, 0, len(order))
for _, path := range order {
if file := filesByPath[path]; file != nil {
files = append(files, file)
}
}
if len(files) == 0 {
return nil
}
result := map[string]any{"files": files}
if coverage := toolString(body["coverage"]); coverage != "" {
result["coverage"] = coverage
}
return result
}
func toolFileChangeMaps(value any) []map[string]any {
result := make([]map[string]any, 0)
switch typed := value.(type) {
case []any:
for _, raw := range typed {
if file := toolMap(raw); file != nil {
result = append(result, file)
}
}
case []map[string]any:
for _, raw := range typed {
if file := toolMap(raw); file != nil {
result = append(result, file)
}
}
}
return result
}
func normalizeToolFileChange(value map[string]any) map[string]any {
if value == nil {
return nil
}
path := firstToolString(value["path"], value["filePath"], value["file_path"], value["relativePath"])
if path == "" {
return nil
}
validDiff, hasValidDiff, rawDiff, hasRawDiff := selectToolFileDiff(value["diff"], value["patch"], value["unifiedDiff"], value["unified_diff"])
diff := validDiff
hasDiff := hasValidDiff
oldString, hasOld := firstPresentToolString(value["oldString"], value["old_string"], value["oldText"])
newString, hasNew := firstPresentToolString(value["newString"], value["new_string"], value["newText"])
content, hasContent := firstPresentToolString(value["content"])
change := firstToolChange(value["change"], value["status"], value["kind"], value["type"])
if change == "" && hasContent {
change = "added"
}
// Prefer newString over content for non-deleted bodies so obsolete cassette
// shapes (bare content, or invalid diff folded below) match live projection.
if change != "deleted" && !hasNew && hasContent {
newString, hasNew = content, true
hasContent = false
}
if !hasValidDiff && hasRawDiff {
switch {
case change == "added" && !hasNew:
newString, hasNew = rawDiff, true
case change == "deleted" && !hasOld:
oldString, hasOld = rawDiff, true
case !hasOld && !hasNew && !hasContent:
// modified / unspecified: fold invalid diff bodies into newString,
// not content — live rematerialization already uses newString.
newString, hasNew = rawDiff, true
}
}
if change == "" {
switch {
case hasOld && !hasNew:
change = "deleted"
case hasNew && !hasOld:
change = "added"
case hasOld || hasNew || hasDiff:
change = "modified"
case hasContent:
change = "modified"
}
}
if change == "" {
return nil
}
file := map[string]any{"path": path, "change": change}
if hasOld {
file["oldString"] = oldString
}
if hasNew {
file["newString"] = newString
}
if hasDiff {
file["diff"] = diff
file["unifiedDiff"] = diff
}
if hasContent {
file["content"] = content
}
return file
}
func looksLikeUnifiedDiff(value string) bool {
normalized := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n"))
if normalized == "" {
return false
}
lines := strings.Split(normalized, "\n")
isApplyPatch := strings.Contains(normalized, "*** Begin Patch") &&
(strings.Contains(normalized, "*** Add File:") || strings.Contains(normalized, "*** Delete File:") || strings.Contains(normalized, "*** Update File:"))
hasHunk := false
hunkHasChange := false
oldCount := 0
newCount := 0
expectedOld := 0
expectedNew := 0
hunkActive := false
hunkHasCounts := false
finishHunk := func() bool {
if !hunkActive {
return true
}
if !hunkHasCounts {
return isApplyPatch && hunkHasChange
}
return oldCount == expectedOld && newCount == expectedNew && hunkHasChange
}
for _, line := range lines {
if match := toolUnifiedDiffHunkPattern.FindStringSubmatch(line); match != nil {
if !finishHunk() {
return false
}
hasHunk = true
hunkActive = true
hunkHasCounts = true
oldCount = 0
newCount = 0
hunkHasChange = false
expectedOld = toolUnifiedDiffHunkCount(match[2])
expectedNew = toolUnifiedDiffHunkCount(match[4])
continue
}
if line == "@@" && isApplyPatch {
if !finishHunk() {
return false
}
hasHunk = true
hunkActive = true
hunkHasCounts = false
hunkHasChange = false
continue
}
if hunkActive && strings.HasPrefix(line, "diff --git ") {
if !finishHunk() {
return false
}
hunkActive = false
continue
}
if !hunkActive && (strings.HasPrefix(line, "diff --git ") || strings.HasPrefix(line, "index ") ||
strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") ||
strings.HasPrefix(line, "new file mode ") || strings.HasPrefix(line, "deleted file mode ") ||
strings.HasPrefix(line, "old mode ") || strings.HasPrefix(line, "new mode ") ||
strings.HasPrefix(line, "similarity index ") || strings.HasPrefix(line, "rename from ") ||
strings.HasPrefix(line, "rename to ") || strings.HasPrefix(line, "*** ")) {
continue
}
if hunkActive && isApplyPatch && strings.HasPrefix(line, "*** ") {
continue
}
if line == "\\ No newline at end of file" {
continue
}
if !hasHunk {
continue
}
if !hunkHasCounts && isApplyPatch {
switch {
case strings.HasPrefix(line, "+"), strings.HasPrefix(line, "-"):
hunkHasChange = true
case strings.HasPrefix(line, " "):
default:
return false
}
continue
}
switch {
case strings.HasPrefix(line, "+"):
newCount++
hunkHasChange = true
case strings.HasPrefix(line, "-"):
oldCount++
hunkHasChange = true
case strings.HasPrefix(line, " "):
oldCount++
newCount++
default:
return false
}
}
return hasHunk && finishHunk()
}
func toolUnifiedDiffHunkCount(value string) int {
if value == "" {
return 1
}
count, err := strconv.Atoi(value)
if err != nil {
return -1
}
return count
}
func selectToolFileDiff(values ...any) (valid string, hasValid bool, raw string, hasRaw bool) {
for _, value := range values {
text, ok := value.(string)
if !ok {
continue
}
if !hasRaw {
raw, hasRaw = text, true
}
if !hasValid && looksLikeUnifiedDiff(text) {
valid, hasValid = text, true
}
}
return
}
func firstToolChange(values ...any) string {
for _, value := range values {
if change := normalizeToolChangeValue(value); change != "" {
return change
}
}
return ""
}
func normalizeToolChangeValue(value any) string {
if text, ok := value.(string); ok {
return normalizeToolChange(text)
}
if nested, ok := value.(map[string]any); ok {
for _, key := range []string{"type", "change", "status", "kind"} {
if change := normalizeToolChangeValue(nested[key]); change != "" {
return change
}
}
}
return ""
}
func mergeToolFileChangeValues(existing map[string]any, next map[string]any) map[string]any {
if existing == nil {
return cloneToolMap(next)
}
if next == nil {
return cloneToolMap(existing)
}
previousKind := normalizeToolChangeValue(existing["change"])
nextKind := normalizeToolChangeValue(next["change"])
if previousKind == "added" && nextKind == "deleted" {
return nil
}
merged := cloneToolMap(existing)
for key, value := range next {
if key == "path" || key == "change" || key == "oldString" {
continue
}
merged[key] = cloneToolValue(value)
}
if _, exists := merged["oldString"]; !exists {
if value, present := next["oldString"]; present {
merged["oldString"] = cloneToolValue(value)
}
}
switch {
case previousKind == "added" && nextKind == "modified":
merged["change"] = "added"
case previousKind == "deleted" && nextKind == "added":
merged["change"] = "modified"
case previousKind == "modified" && nextKind == "deleted":
merged["change"] = "deleted"
case nextKind != "":
merged["change"] = nextKind
case previousKind != "":
merged["change"] = previousKind
}
return merged
}