Files
zzet--gortex/internal/mcp/safe_delete_propagate.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

159 lines
5.2 KiB
Go

package mcp
import (
"fmt"
"os"
"sort"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/graph"
)
// SRN-7: transitive propagate-delete. computeCascadeClosure already cascades
// orphaned symbols; the missing half is the surviving callers that still
// reference the deleted symbol. buildPropagationPlan reads each call site and
// classifies it — a standalone statement call can be removed outright; an
// embedded reference is flagged for manual patching — and applyRemoveLinePatches
// deletes the removable lines (parse-gate validated) so the delete leaves no
// dangling reference behind.
type callerPatch struct {
File string `json:"file"`
CallerName string `json:"caller_name"`
Line int `json:"line"`
CurrentText string `json:"current_text"`
Action string `json:"action"` // remove_line | manual
Reason string `json:"reason"`
abs string // resolved absolute path (internal)
}
// buildPropagationPlan walks the referencing call sites of a symbol and returns
// the per-caller patch each one needs.
func (s *Server) buildPropagationPlan(target *graph.Node) []callerPatch {
var plan []callerPatch
seen := make(map[string]bool)
for _, e := range s.graph.GetInEdges(target.ID) {
if !isReferencingEdgeKind(e.Kind) || e.Line == 0 {
continue
}
caller := s.graph.GetNode(e.From)
if caller == nil {
continue
}
key := fmt.Sprintf("%s:%d", caller.FilePath, e.Line)
if seen[key] {
continue
}
seen[key] = true
abs, err := s.resolveNodePath(caller)
if err != nil {
continue
}
text := readSingleLineAt(abs, e.Line)
action, reason := classifyCallSite(text, target.Name)
plan = append(plan, callerPatch{
File: caller.FilePath,
CallerName: caller.Name,
Line: e.Line,
CurrentText: strings.TrimSpace(text),
Action: action,
Reason: reason,
abs: abs,
})
}
sort.Slice(plan, func(a, b int) bool {
if plan[a].File != plan[b].File {
return plan[a].File < plan[b].File
}
return plan[a].Line < plan[b].Line
})
return plan
}
// classifyCallSite decides whether a reference line is a standalone statement
// call (safe to delete whole) or an embedded reference (manual). Conservative:
// anything ambiguous is manual.
func classifyCallSite(line, target string) (action, reason string) {
t := strings.TrimSpace(line)
if t == "" || !strings.Contains(t, target) {
return "manual", "reference not found on the recorded line — patch by hand"
}
body := strings.TrimRight(t, ";,")
// An assignment or comparison means the call's value is consumed.
if strings.Contains(body, "=") && !strings.Contains(body, "==") {
return "manual", "reference feeds an assignment — removing it would drop a value"
}
for _, frag := range []string{"return ", "return\t", "if ", "for ", "while ", "switch ", "&&", "||", " ? ", "+ ", " +", "<-"} {
if strings.Contains(body, frag) {
return "manual", "reference is embedded in a larger expression"
}
}
core := strings.TrimPrefix(strings.TrimPrefix(body, "defer "), "go ")
if strings.HasSuffix(core, ")") &&
(strings.HasPrefix(core, target+"(") || strings.Contains(core, "."+target+"(")) {
return "remove_line", "standalone statement call — the line can be removed"
}
return "manual", "could not confirm a standalone call — patch by hand"
}
// applyRemoveLinePatches deletes the remove_line patches from their files,
// grouped per file and applied bottom-up so earlier line numbers stay valid.
// A patch whose removal would introduce new parse errors is not applied and is
// returned in failed. Manual patches are ignored here.
func (s *Server) applyRemoveLinePatches(plan []callerPatch) (applied, failed []callerPatch, err error) {
byFile := make(map[string][]callerPatch)
for _, p := range plan {
if p.Action == "remove_line" {
byFile[p.abs] = append(byFile[p.abs], p)
}
}
for abs, patches := range byFile {
content, readErr := os.ReadFile(abs)
if readErr != nil {
return applied, failed, fmt.Errorf("read %s: %w", abs, readErr)
}
lines := strings.Split(string(content), "\n")
// Apply bottom-up so deletions don't shift later indices.
sort.Slice(patches, func(a, b int) bool { return patches[a].Line > patches[b].Line })
kept := patches[:0]
newLines := append([]string{}, lines...)
for _, p := range patches {
if p.Line < 1 || p.Line > len(newLines) {
failed = append(failed, p)
continue
}
newLines = append(newLines[:p.Line-1], newLines[p.Line:]...)
kept = append(kept, p)
}
newContent := []byte(strings.Join(newLines, "\n"))
// Parse gate: never let a propagated removal corrupt a file.
relPath := patches[0].File
if gate := checkParseGate(relPath, content, newContent); gate.Blocked {
failed = append(failed, patches...)
continue
}
perm := os.FileMode(0o644)
if info, statErr := os.Stat(abs); statErr == nil {
perm = info.Mode().Perm()
}
if writeErr := agents.AtomicWriteFile(abs, newContent, perm); writeErr != nil {
return applied, failed, fmt.Errorf("write %s: %w", abs, writeErr)
}
s.reindexFile(abs)
applied = append(applied, kept...)
}
return applied, failed, nil
}
func countManual(plan []callerPatch) int {
n := 0
for _, p := range plan {
if p.Action == "manual" {
n++
}
}
return n
}