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
664 lines
24 KiB
Go
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
|
|
}
|