Files
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

664 lines
24 KiB
Go

package mcp
import (
"context"
"fmt"
"os"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
)
// registerSafeDeleteSymbolTool wires safe_delete_symbol — atomic
// dead-code removal with a graph-aware safety gate. Before touching
// disk, the tool checks for referencing edges (calls, implements,
// extends, references); a non-zero count rejects the delete unless
// the caller passes force=true.
//
// Default is dry_run=true: returns the planned delete (line range +
// preview of the bytes that would disappear) without writing. The
// agent gets one round-trip to inspect, then flips dry_run=false to
// commit.
//
// Cascade (orphan propagation). When `cascade` is "preview" or
// "apply", the tool computes the transitive closure of symbols that
// would become orphaned by the delete — every symbol whose only
// referencing in-edges originate from the target itself or another
// symbol already in the closure. Cross-workspace references and
// test-only references (unless `cascade_into_tests` is true)
// disqualify a candidate. Closure is reported on every response;
// only `cascade: "apply"` actually deletes the orphan tail.
func (s *Server) registerSafeDeleteSymbolTool() {
s.addTool(
mcp.NewTool("safe_delete_symbol",
mcp.WithDescription("Atomically delete a symbol from the file system, with a graph-aware safety gate. Computes referencing edges first (calls / implements / extends / references); if any exist, the delete is REJECTED unless force=true. Default dry_run=true returns the preview without writing — flip dry_run=false to commit. The deleted range covers the symbol body plus any leading doc-comment block. The graph is re-indexed on commit so subsequent queries see the new state. Pass cascade=\"preview\" to also compute the transitive orphan closure (symbols that would become dead code once the target is gone) without deleting them, or cascade=\"apply\" to delete the closure in the same operation. Cross-workspace references and test-only references (unless cascade_into_tests is true) disqualify a candidate from the closure."),
mcp.WithString("id", mcp.Description("Symbol ID (e.g. pkg/foo.go::Bar).")),
mcp.WithBoolean("dry_run", mcp.Description("When true (default), returns the planned delete without writing. Set false to commit.")),
mcp.WithBoolean("force", mcp.Description("Bypass the referencing-edge check. Use when you've already removed every caller in the same change set. Default false.")),
mcp.WithString("cascade", mcp.Description("Orphan propagation mode: \"off\" (default — single-symbol delete only), \"preview\" (compute the transitive orphan closure and return it without deleting), or \"apply\" (compute the closure and delete every symbol in it together with the target).")),
mcp.WithBoolean("cascade_into_tests", mcp.Description("When true, symbols referenced only from test files are eligible for the cascade closure. Default false — test-only references disqualify a candidate so the cascade never deletes a symbol just because production stopped using it but tests still do.")),
mcp.WithBoolean("propagate", mcp.Description("Instead of rejecting on referencing edges, build a per-caller delete-and-patch plan: standalone statement calls are removed outright (parse-gate validated), embedded references are flagged for manual patching. dry_run returns the plan; dry_run=false applies the removable patches then deletes the symbol (force=true to delete even with manual / parse-blocked sites remaining).")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleSafeDeleteSymbol,
)
}
// safeDeleteReference describes a single referencing edge — enough
// information for the caller to navigate to it and remove it before
// retrying the delete.
type safeDeleteReference struct {
FromID string `json:"from_id"`
Kind string `json:"kind"`
FromName string `json:"from_name,omitempty"`
FilePath string `json:"file_path,omitempty"`
}
// cascadeClosureEntry describes a symbol that the cascade pass added
// to the deletion closure. Carries enough context for the caller to
// understand why the symbol was selected and locate it.
type cascadeClosureEntry struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Kind string `json:"kind,omitempty"`
Path string `json:"path,omitempty"`
Line int `json:"line,omitempty"`
Reason string `json:"reason,omitempty"`
}
// cascadeMode enumerates the accepted values of the `cascade`
// parameter. Anything else is treated as cascadeModeOff so callers
// that supply an unknown value get the conservative behaviour.
const (
cascadeModeOff = "off"
cascadeModePreview = "preview"
cascadeModeApply = "apply"
)
// cascadeIterationCap bounds the fixed-point loop defensively. The
// algorithm converges by construction (D only grows), but a runaway
// graph or a future bug should not be able to spin forever.
const cascadeIterationCap = 50
func (s *Server) handleSafeDeleteSymbol(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := s.symbolIDArg(ctx, req)
if err != nil {
return mcp.NewToolResultError("id is required"), nil
}
dryRun := requestBoolDefault(req, "dry_run", true)
force := req.GetBool("force", false)
cascadeMode := normaliseCascadeMode(req.GetString("cascade", cascadeModeOff))
cascadeIntoTests := req.GetBool("cascade_into_tests", false)
node := s.graph.GetNode(id)
if node == nil {
return mcp.NewToolResultError("symbol not found: " + id), nil
}
if node.StartLine == 0 || node.EndLine == 0 {
return mcp.NewToolResultError("symbol has no line range: " + id), nil
}
// Safety check: referencing edges. We keep the four edge kinds
// that signal actual code-level use; structural edges like
// EdgeDefines / EdgeMemberOf are skipped (they don't represent
// "someone calls this").
refs := collectReferencingEdges(s.graph, id)
// SRN-7: propagate-delete — patch the surviving call sites instead of
// refusing. Builds a per-caller plan, applies the removable standalone
// calls (parse-gate validated), and flags embedded references as manual.
var appliedPatches, failedPatches []callerPatch
if req.GetBool("propagate", false) && len(refs) > 0 {
plan := s.buildPropagationPlan(node)
manual := countManual(plan)
if dryRun {
return s.respondJSONOrTOON(ctx, req, map[string]any{
"status": "propagation_plan",
"symbol": id,
"file": node.FilePath,
"caller_patches": plan,
"removable": len(plan) - manual,
"manual": manual,
"reference_count": len(refs),
"dry_run": true,
"hint": "re-run with dry_run=false to apply the remove_line patches and delete the symbol; resolve manual patches first or pass force=true",
})
}
if manual > 0 && !force {
return s.respondJSONOrTOON(ctx, req, map[string]any{
"status": "propagation_blocked_manual",
"symbol": id,
"caller_patches": plan,
"manual": manual,
"hint": fmt.Sprintf("%d call site(s) need manual patching; resolve them or pass force=true to delete anyway", manual),
})
}
applied, failed, perr := s.applyRemoveLinePatches(plan)
if perr != nil {
return mcp.NewToolResultError(perr.Error()), nil
}
appliedPatches, failedPatches = applied, failed
if len(failed) > 0 && !force {
return s.respondJSONOrTOON(ctx, req, map[string]any{
"status": "propagation_partial",
"symbol": id,
"patches_applied": applied,
"patches_failed": failed,
"hint": "some removals would break their file's syntax and were skipped; patch them by hand or pass force=true to delete the symbol anyway",
})
}
// Callers patched — proceed with the deletion. Re-fetch the node in
// case a same-file removal shifted its line range.
force = true
node = s.graph.GetNode(id)
if node == nil {
return s.respondJSONOrTOON(ctx, req, map[string]any{
"status": "deleted_by_propagation",
"symbol": id,
"patches_applied": appliedPatches,
"note": "the symbol's definition was removed as a side effect of patching (it no longer resolves in the graph)",
})
}
if node.StartLine == 0 || node.EndLine == 0 {
return mcp.NewToolResultError("symbol has no line range after propagation: " + id), nil
}
}
if len(refs) > 0 && !force {
return s.respondJSONOrTOON(ctx, req, map[string]any{
"status": "rejected_has_references",
"symbol": id,
"file": node.FilePath,
"references": refs,
"reference_count": len(refs),
"dry_run": dryRun,
"force": force,
"cascade_mode": cascadeMode,
"cascade_into_tests": cascadeIntoTests,
"hint": "remove every referencing edge first, or pass force=true to override",
})
}
// Compute the cascade closure first so both preview and apply
// branches surface the same list to the caller. Off mode skips
// the work entirely.
var (
closure []cascadeClosureEntry
cascadeTruncated bool
)
if cascadeMode != cascadeModeOff {
closure, cascadeTruncated = computeCascadeClosure(s.graph, node, cascadeIntoTests)
}
absPath, err := s.resolveNodePath(node)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
content, err := os.ReadFile(absPath)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("could not read file: %v", err)), nil
}
lines := strings.Split(string(content), "\n")
if node.StartLine > len(lines) || node.EndLine > len(lines) {
return mcp.NewToolResultError("symbol line range exceeds file length"), nil
}
deleteStart, deleteEnd := expandDeleteRange(node, lines)
deletedChunk := strings.Join(lines[deleteStart-1:deleteEnd], "\n")
linesDeleted := deleteEnd - deleteStart + 1
result := map[string]any{
"symbol": id,
"file": node.FilePath,
"start_line": deleteStart,
"end_line": deleteEnd,
"lines_deleted": linesDeleted,
"reference_count": len(refs),
"references": refs,
"preview": deletedChunk,
"dry_run": dryRun,
"force": force,
"cascade_mode": cascadeMode,
"cascade_into_tests": cascadeIntoTests,
}
if cascadeMode != cascadeModeOff {
result["cascade_closure"] = closure
result["cascade_truncated"] = cascadeTruncated
}
if dryRun {
result["status"] = "preview"
return s.respondJSONOrTOON(ctx, req, result)
}
// Commit phase. cascadeModeApply expands the work to every entry
// in the closure; off / preview only ever touch the target.
pending := []*pendingDelete{{
node: node,
abs: absPath,
start: deleteStart,
end: deleteEnd,
}}
if cascadeMode == cascadeModeApply {
for _, entry := range closure {
cn := s.graph.GetNode(entry.ID)
if cn == nil {
return mcp.NewToolResultError(fmt.Sprintf("cascade target disappeared from graph: %s", entry.ID)), nil
}
if cn.StartLine == 0 || cn.EndLine == 0 {
return mcp.NewToolResultError(fmt.Sprintf("cascade target has no line range: %s", entry.ID)), nil
}
cAbs, err := s.resolveNodePath(cn)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("resolve cascade target %s: %v", entry.ID, err)), nil
}
pending = append(pending, &pendingDelete{
node: cn,
abs: cAbs,
start: 0,
end: 0,
})
}
}
deletedIDs, err := applyPendingDeletes(pending)
if err != nil {
// Fail-fast: surface what was done up to this point so the
// caller can recover. Treat the failure as a tool error.
result["status"] = "partial_failure"
result["cascade_deleted"] = deletedIDs
result["error"] = err.Error()
return s.respondJSONOrTOON(ctx, req, result)
}
// Persist session state and re-index every touched file.
sess := s.sessionFor(ctx)
touchedFiles := make(map[string]struct{}, len(pending))
for _, p := range pending {
sess.recordModified(p.node.FilePath)
sess.recordSymbol(p.node.ID)
if s.symHistory != nil {
s.symHistory.Record(p.node.ID, true)
}
touchedFiles[p.abs] = struct{}{}
}
for abs := range touchedFiles {
s.reindexFile(abs)
}
result["status"] = "deleted"
if cascadeMode == cascadeModeApply {
result["cascade_deleted"] = deletedIDs
}
if len(appliedPatches) > 0 {
result["patches_applied"] = appliedPatches
}
if len(failedPatches) > 0 {
result["patches_failed"] = failedPatches
}
return s.respondJSONOrTOON(ctx, req, result)
}
// normaliseCascadeMode coerces the cascade parameter to a known
// value, defaulting to "off" so unknown / empty input preserves the
// legacy behaviour.
func normaliseCascadeMode(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case cascadeModePreview:
return cascadeModePreview
case cascadeModeApply:
return cascadeModeApply
default:
return cascadeModeOff
}
}
// pendingDelete buffers everything needed to delete one symbol from
// its file. start/end of zero mean "compute lazily from the node and
// the file contents" — only the original target arrives pre-computed
// because the dry_run preview already needed those numbers.
type pendingDelete struct {
node *graph.Node
abs string
start int
end int
}
// applyPendingDeletes groups pending deletes by absolute file path
// and applies them in descending line order so earlier deletes do
// not shift the line numbers of later ones. Each file is read once,
// rewritten once. Returns the IDs of symbols whose bytes were
// removed; on first error, the partial list rides alongside the
// error.
func applyPendingDeletes(pending []*pendingDelete) ([]string, error) {
byFile := map[string][]*pendingDelete{}
order := []string{}
for _, p := range pending {
if _, ok := byFile[p.abs]; !ok {
order = append(order, p.abs)
}
byFile[p.abs] = append(byFile[p.abs], p)
}
deleted := make([]string, 0, len(pending))
for _, abs := range order {
bucket := byFile[abs]
content, err := os.ReadFile(abs)
if err != nil {
return deleted, fmt.Errorf("could not read %s: %v", abs, err)
}
lines := strings.Split(string(content), "\n")
// Materialise ranges for entries that arrived with zero
// start/end (the cascade tail; the original target arrives
// pre-computed for parity with the dry_run preview).
for _, p := range bucket {
if p.start == 0 || p.end == 0 {
if p.node.StartLine > len(lines) || p.node.EndLine > len(lines) {
return deleted, fmt.Errorf("symbol %s line range exceeds file %s", p.node.ID, abs)
}
p.start, p.end = expandDeleteRange(p.node, lines)
}
}
// Sort descending so deletions earlier in the file don't
// shift the line indexes of later ones.
sort.Slice(bucket, func(i, j int) bool {
return bucket[i].start > bucket[j].start
})
// Detect overlap — two symbols whose expanded ranges
// intersect cannot both be deleted as separate slices
// without losing bytes. The graph shouldn't produce that
// for distinct symbols, but a defensive check keeps the
// failure surface clean.
for i := 0; i < len(bucket)-1; i++ {
if bucket[i].start <= bucket[i+1].end {
return deleted, fmt.Errorf("overlapping delete ranges in %s for %s and %s",
abs, bucket[i].node.ID, bucket[i+1].node.ID)
}
}
for _, p := range bucket {
lines = append(lines[:p.start-1], lines[p.end:]...)
deleted = append(deleted, p.node.ID)
}
newContent := strings.Join(lines, "\n")
if err := os.WriteFile(abs, []byte(newContent), 0o644); err != nil {
return deleted, fmt.Errorf("could not write %s: %v", abs, err)
}
}
return deleted, nil
}
// expandDeleteRange grows a symbol's [StartLine, EndLine] range to
// also consume any leading doc-comment block and one trailing blank
// line, exactly the way the single-symbol path did before the
// cascade refactor.
func expandDeleteRange(node *graph.Node, lines []string) (int, int) {
deleteStart := node.StartLine
for deleteStart > 1 {
trimmed := strings.TrimSpace(lines[deleteStart-2])
if isCommentLine(trimmed) {
deleteStart--
continue
}
break
}
deleteEnd := node.EndLine
if deleteEnd < len(lines) && strings.TrimSpace(lines[deleteEnd]) == "" {
deleteEnd++
}
return deleteStart, deleteEnd
}
// computeCascadeClosure runs the fixed-point orphan-propagation
// algorithm. Starting from {target}, it repeatedly adds any symbol
// whose every referencing in-edge originates from the current
// closure (and who is in the same workspace as the target).
//
// External-reference rules a candidate must satisfy:
// - it must not have a referencing in-edge from outside the
// current closure (self-references inside D never disqualify);
// - its in-edges must not include a cross-workspace caller
// (different WorkspaceID — falling back to RepoPrefix when
// unset);
// - test-only callers disqualify by default; cascadeIntoTests
// inverts that.
//
// The candidate itself must also be in the same workspace as the
// target. Iteration is bounded by cascadeIterationCap; if hit, the
// caller surfaces cascade_truncated so the agent knows the closure
// may be incomplete.
func computeCascadeClosure(g graph.Store, target *graph.Node, cascadeIntoTests bool) ([]cascadeClosureEntry, bool) {
closure := []cascadeClosureEntry{}
inClosure := map[string]bool{target.ID: true}
reasons := map[string]string{}
targetWS := workspaceKey(target)
truncated := false
for iter := 0; iter < cascadeIterationCap; iter++ {
// Candidate set: every node that an in-closure node points
// at (the closure's downstream reachability via referencing
// edges). We don't walk EdgeDefines or EdgeMemberOf — those
// are structural and don't represent "use".
candidates := collectCascadeCandidates(g, inClosure)
added := 0
for _, cid := range candidates {
if inClosure[cid] {
continue
}
cn := g.GetNode(cid)
if cn == nil {
continue
}
if cn.StartLine == 0 || cn.EndLine == 0 {
// Synthetic / structural nodes have no on-disk
// range; deleting them makes no sense.
continue
}
if workspaceKey(cn) != targetWS {
continue
}
reason, ok := candidateQualifies(g, cn, inClosure, cascadeIntoTests)
if !ok {
continue
}
inClosure[cid] = true
reasons[cid] = reason
closure = append(closure, cascadeClosureEntry{
ID: cn.ID,
Name: cn.Name,
Kind: string(cn.Kind),
Path: cn.FilePath,
Line: cn.StartLine,
Reason: reason,
})
added++
}
if added == 0 {
return closure, false
}
if iter == cascadeIterationCap-1 {
truncated = true
}
}
return closure, truncated
}
// collectCascadeCandidates returns every distinct node ID that an
// in-closure node points at via a referencing edge — the only
// possible new entrants to the closure on this iteration.
func collectCascadeCandidates(g graph.Store, inClosure map[string]bool) []string {
seen := map[string]bool{}
out := []string{}
for from := range inClosure {
for _, e := range g.GetOutEdges(from) {
if !isReferencingEdgeKind(e.Kind) {
continue
}
if seen[e.To] || inClosure[e.To] {
continue
}
seen[e.To] = true
out = append(out, e.To)
}
}
// Stable iteration order so the closure list is deterministic
// for tests; map iteration above is not.
sort.Strings(out)
return out
}
// candidateQualifies inspects every referencing in-edge of cn and
// reports whether the node has no caller outside the current
// closure. Returns a human-readable reason string when the node
// qualifies (used for the response payload).
func candidateQualifies(g graph.Store, cn *graph.Node, inClosure map[string]bool, cascadeIntoTests bool) (string, bool) {
targetWS := ""
// Build an "in-closure caller" list so the reason string can
// name the symbol(s) that are the only ones still calling this
// candidate.
closureCallers := map[string]bool{}
hasAnyIn := false
for _, e := range g.GetInEdges(cn.ID) {
if !isReferencingEdgeKind(e.Kind) {
continue
}
hasAnyIn = true
if inClosure[e.From] {
closureCallers[e.From] = true
continue
}
// External caller — examine it.
from := g.GetNode(e.From)
if from == nil {
// Defensive: treat unknown caller as external.
return "", false
}
// Establish target workspace lazily from one of the
// in-closure callers' WorkspaceID so the comparison is
// consistent across iterations.
if targetWS == "" {
targetWS = workspaceKey(cn)
}
if workspaceKey(from) != targetWS {
// Cross-workspace caller disqualifies.
return "", false
}
isTestCaller := indexer.IsTestFile(from.FilePath)
if isTestCaller && !cascadeIntoTests {
// Test-only caller and the agent did not opt in — the
// candidate stays alive because production tests still
// depend on it.
return "", false
}
if !isTestCaller {
// Same-workspace, non-test, out-of-closure caller — this
// is a real production user; the candidate is not
// orphaned.
return "", false
}
// At this point: test caller + cascadeIntoTests=true. The
// caller is acceptable; record it as if it were inside the
// closure so the reason string can attribute the cascade.
closureCallers[e.From] = true
}
if !hasAnyIn {
// No referencing edges at all — already dead code, qualifies.
return "no referencing edges; already orphaned", true
}
// All referencing in-edges came from the closure.
if len(closureCallers) == 1 {
var only string
for k := range closureCallers {
only = k
}
return "only referenced by " + only, true
}
callers := make([]string, 0, len(closureCallers))
for k := range closureCallers {
callers = append(callers, k)
}
sort.Strings(callers)
return "only referenced by " + strings.Join(callers, ", "), true
}
// workspaceKey returns the node's workspace identity for the
// cross-workspace check. Prefers WorkspaceID; falls back to
// RepoPrefix when WorkspaceID is empty (matches the convention
// elsewhere in the graph package). An empty result is still
// comparable — two nodes with empty workspace keys are treated as
// belonging to the same notional workspace.
func workspaceKey(n *graph.Node) string {
if n == nil {
return ""
}
if n.WorkspaceID != "" {
return n.WorkspaceID
}
return n.RepoPrefix
}
// collectReferencingEdges returns every in-edge to id whose kind
// represents real use (someone calls, implements, extends, or
// references this symbol). Structural edges (defines, member_of)
// are excluded because they don't block a delete.
func collectReferencingEdges(g graph.Store, id string) []safeDeleteReference {
out := make([]safeDeleteReference, 0)
seen := map[string]bool{}
for _, e := range g.GetInEdges(id) {
if !isReferencingEdgeKind(e.Kind) {
continue
}
key := e.From + "|" + string(e.Kind)
if seen[key] {
continue
}
seen[key] = true
row := safeDeleteReference{FromID: e.From, Kind: string(e.Kind)}
if from := g.GetNode(e.From); from != nil {
row.FromName = from.Name
row.FilePath = from.FilePath
}
out = append(out, row)
}
return out
}
// isReferencingEdgeKind reports whether an in-edge of this kind
// counts as "real use" that should block a delete.
func isReferencingEdgeKind(k graph.EdgeKind) bool {
switch k {
case graph.EdgeCalls,
graph.EdgeImplements,
graph.EdgeExtends,
graph.EdgeReferences,
graph.EdgeInstantiates,
graph.EdgeCrossRepoCalls,
graph.EdgeCrossRepoImplements,
graph.EdgeCrossRepoExtends:
return true
}
return false
}
// isCommentLine recognises every block- and line-comment leader the
// extractors emit. Used by the doc-comment expansion above.
func isCommentLine(trimmed string) bool {
switch {
case strings.HasPrefix(trimmed, "//"),
strings.HasPrefix(trimmed, "/*"),
strings.HasPrefix(trimmed, "*"),
strings.HasPrefix(trimmed, "#"),
strings.HasPrefix(trimmed, "///"),
strings.HasPrefix(trimmed, "--"):
return true
}
return false
}