From d5165cffdd2fb469aa99a0061306d3a586dc0d67 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 3 Apr 2026 12:21:41 -0400 Subject: [PATCH 1/4] feat: wire MCP server dead_code and blast_radius to dedicated API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace inline graph traversal (findDeadFunctions, findAffected) with calls to client.DeadCode() and client.Impact(). MCP tools now return the same rich results as the CLI commands — confidence levels, risk scores, line numbers, reasons, entry points. Removed ~100 lines of inline helpers (findDeadFunctions, findAffected, isEntryPoint, pathMatches, deadFn, affected types). Updated tool schemas to match new params (min_confidence, limit for dead_code; file optional for blast_radius). Closes #27 --- internal/mcp/server.go | 318 ++++++++++++++++++++--------------------- 1 file changed, 157 insertions(+), 161 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index de9f694..437d337 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -81,25 +81,22 @@ var tools = []tool{ }, { Name: "dead_code", - Description: "List functions in the repository that have no callers. Returns function names and their source files.", + Description: "Find unreachable functions using multi-phase static analysis. Returns candidates with confidence levels (high/medium/low), line numbers, and explanations.", InputSchema: toolSchema{ Type: "object", Properties: map[string]schemaProp{ - "include_exports": {Type: "boolean", Description: "Include exported (public) functions, which may be called by external packages."}, - "force": {Type: "boolean", Description: "Re-analyze even if a cached result exists."}, + "min_confidence": {Type: "string", Description: "Minimum confidence level: high, medium, or low."}, + "limit": {Type: "integer", Description: "Maximum number of candidates to return. 0 = all."}, }, }, }, { Name: "blast_radius", - Description: "Given a file path, return all files that transitively import it — i.e., the set of files that would be affected by a change to that file.", + Description: "Analyze the impact of changing a file or function. Returns risk score, affected files and functions, entry points impacted, and risk factors.", InputSchema: toolSchema{ - Type: "object", - Required: []string{"file"}, + Type: "object", Properties: map[string]schemaProp{ - "file": {Type: "string", Description: "Repo-relative path to the file (e.g. internal/api/client.go)."}, - "depth": {Type: "integer", Description: "Maximum traversal depth. 0 = unlimited."}, - "force": {Type: "boolean", Description: "Re-analyze even if a cached result exists."}, + "file": {Type: "string", Description: "Repo-relative path to the file (e.g. internal/api/client.go). Omit for global coupling map."}, }, }, }, @@ -110,7 +107,7 @@ var tools = []tool{ Type: "object", Properties: map[string]schemaProp{ "label": {Type: "string", Description: "Filter nodes by label: File, Function, Class, etc."}, - "rel_type": {Type: "string", Description: "Filter relationships by type: IMPORTS, CALLS, DEFINES_FUNCTION, etc."}, + "rel_type": {Type: "string", Description: "Filter relationships by type: imports, calls, defines_function, etc."}, "force": {Type: "boolean", Description: "Re-analyze even if a cached result exists."}, }, }, @@ -217,80 +214,152 @@ func (s *server) handleToolCall(ctx context.Context, params json.RawMessage) (an } func (s *server) callTool(ctx context.Context, name string, args map[string]any) (string, error) { - force := boolArg(args, "force") - switch name { case "analyze": - g, hash, err := s.getOrAnalyze(ctx, force) - if err != nil { - return "", err - } - s.graph = g - s.hash = hash - return fmt.Sprintf("Analysis complete.\nRepo ID: %s\nFiles: %d\nFunctions: %d\nRelationships: %d", - g.RepoID(), - len(g.NodesByLabel("File")), - len(g.NodesByLabel("Function")), - len(g.Rels()), - ), nil - + return s.toolAnalyze(ctx, args) case "dead_code": - g, _, err := s.getOrAnalyze(ctx, force) - if err != nil { - return "", err - } - includeExports := boolArg(args, "include_exports") - results := findDeadFunctions(g, includeExports) - if len(results) == 0 { - return "No dead code detected.", nil - } - var sb strings.Builder - fmt.Fprintf(&sb, "%d unreachable function(s):\n\n", len(results)) - for _, r := range results { - fmt.Fprintf(&sb, "- %s (%s)\n", r.name, r.file) - } - return sb.String(), nil - + return s.toolDeadCode(ctx, args) case "blast_radius": - fileArg, _ := args["file"].(string) - if fileArg == "" { - return "", fmt.Errorf("required argument 'file' is missing") - } - g, _, err := s.getOrAnalyze(ctx, force) - if err != nil { - return "", err + return s.toolBlastRadius(ctx, args) + case "get_graph": + return s.toolGetGraph(ctx, args) + default: + return "", fmt.Errorf("unknown tool: %s", name) + } +} + +// toolAnalyze uploads the repo and runs the full analysis pipeline. +func (s *server) toolAnalyze(ctx context.Context, args map[string]any) (string, error) { + force := boolArg(args, "force") + g, hash, err := s.getOrAnalyze(ctx, force) + if err != nil { + return "", err + } + s.graph = g + s.hash = hash + return fmt.Sprintf("Analysis complete.\nRepo ID: %s\nFiles: %d\nFunctions: %d\nRelationships: %d", + g.RepoID(), + len(g.NodesByLabel("File")), + len(g.NodesByLabel("Function")), + len(g.Rels()), + ), nil +} + +// toolDeadCode calls the dedicated /v1/analysis/dead-code endpoint. +func (s *server) toolDeadCode(ctx context.Context, args map[string]any) (string, error) { + zipPath, hash, err := s.ensureZip() + if err != nil { + return "", err + } + defer os.Remove(zipPath) + + minConfidence, _ := args["min_confidence"].(string) + limit := intArg(args, "limit") + + client := api.New(s.cfg) + result, err := client.DeadCode(ctx, zipPath, "mcp-dc-"+hash[:16], minConfidence, limit) + if err != nil { + return "", err + } + + if len(result.DeadCodeCandidates) == 0 { + return "No dead code detected.", nil + } + + var sb strings.Builder + fmt.Fprintf(&sb, "%d dead code candidate(s) out of %d total declarations:\n\n", + result.Metadata.DeadCodeCandidates, result.Metadata.TotalDeclarations) + for i := range result.DeadCodeCandidates { + c := &result.DeadCodeCandidates[i] + fmt.Fprintf(&sb, "- [%s] %s:%d %s — %s\n", c.Confidence, c.File, c.Line, c.Name, c.Reason) + } + return sb.String(), nil +} + +// toolBlastRadius calls the dedicated /v1/analysis/impact endpoint. +func (s *server) toolBlastRadius(ctx context.Context, args map[string]any) (string, error) { + zipPath, hash, err := s.ensureZip() + if err != nil { + return "", err + } + defer os.Remove(zipPath) + + target, _ := args["file"].(string) + idempotencyKey := "mcp-impact-" + hash[:16] + if target != "" { + idempotencyKey += "-" + target + } + + client := api.New(s.cfg) + result, err := client.Impact(ctx, zipPath, idempotencyKey, target, "") + if err != nil { + return "", err + } + + if len(result.Impacts) == 0 { + if len(result.GlobalMetrics.MostCriticalFiles) > 0 { + var sb strings.Builder + sb.WriteString("Most critical files (by dependent count):\n\n") + for i := range result.GlobalMetrics.MostCriticalFiles { + f := &result.GlobalMetrics.MostCriticalFiles[i] + fmt.Fprintf(&sb, "- %s (%d dependents)\n", f.File, f.DependentCount) + } + return sb.String(), nil } - affected := findAffected(g, fileArg) - if len(affected) == 0 { - return fmt.Sprintf("No files are affected by changes to %s.", fileArg), nil + return "No impact detected.", nil + } + + var sb strings.Builder + for i := range result.Impacts { + imp := &result.Impacts[i] + br := &imp.BlastRadius + fmt.Fprintf(&sb, "Target: %s\n", imp.Target.File) + fmt.Fprintf(&sb, "Risk: %s | Direct: %d | Transitive: %d | Files: %d\n", + br.RiskScore, br.DirectDependents, br.TransitiveDependents, br.AffectedFiles) + for _, rf := range br.RiskFactors { + fmt.Fprintf(&sb, " → %s\n", rf) } - var sb strings.Builder - fmt.Fprintf(&sb, "%d file(s) affected by changes to %s:\n\n", len(affected), fileArg) - for _, f := range affected { - fmt.Fprintf(&sb, "- %s (depth %d)\n", f.file, f.depth) + if len(imp.AffectedFiles) > 0 { + sb.WriteString("\nAffected files:\n") + for j := range imp.AffectedFiles { + f := &imp.AffectedFiles[j] + fmt.Fprintf(&sb, "- %s (direct: %d, transitive: %d)\n", f.File, f.DirectDependencies, f.TransitiveDependencies) + } } - return sb.String(), nil - - case "get_graph": - g, _, err := s.getOrAnalyze(ctx, force) - if err != nil { - return "", err + if len(imp.EntryPointsAffected) > 0 { + sb.WriteString("\nEntry points affected:\n") + for j := range imp.EntryPointsAffected { + ep := &imp.EntryPointsAffected[j] + fmt.Fprintf(&sb, "- %s %s (%s)\n", ep.File, ep.Name, ep.Type) + } } - label, _ := args["label"].(string) - relType, _ := args["rel_type"].(string) + sb.WriteString("\n") + } + fmt.Fprintf(&sb, "%d target(s) analyzed across %d files and %d functions.\n", + result.Metadata.TargetsAnalyzed, result.Metadata.TotalFiles, result.Metadata.TotalFunctions) + return sb.String(), nil +} - out := filterGraph(g, label, relType) - data, err := json.MarshalIndent(out, "", " ") - if err != nil { - return "", err - } - return string(data), nil +// toolGetGraph returns a filtered graph slice. +func (s *server) toolGetGraph(ctx context.Context, args map[string]any) (string, error) { + force := boolArg(args, "force") + g, _, err := s.getOrAnalyze(ctx, force) + if err != nil { + return "", err + } + label, _ := args["label"].(string) + relType, _ := args["rel_type"].(string) - default: - return "", fmt.Errorf("unknown tool: %s", name) + out := filterGraph(g, label, relType) + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return "", err } + return string(data), nil } +// --- Shared helpers ---------------------------------------------------------- + // getOrAnalyze returns the cached graph or runs a fresh analysis. func (s *server) getOrAnalyze(ctx context.Context, force bool) (*api.Graph, string, error) { if !force && s.graph != nil { @@ -332,77 +401,24 @@ func (s *server) getOrAnalyze(ctx context.Context, force bool) (*api.Graph, stri return g, hash, nil } -// --- Inline helpers (duplicated from slices to preserve VSA) ----------------- - -type deadFn struct{ name, file string } - -func findDeadFunctions(g *api.Graph, includeExports bool) []deadFn { - called := make(map[string]bool) - for _, rel := range g.Rels() { - if rel.Type == "calls" || rel.Type == "contains_call" { - called[rel.EndNode] = true - } - } - var out []deadFn - for _, n := range g.NodesByLabel("Function") { - if called[n.ID] { - continue - } - name := n.Prop("name", "qualifiedName") - file := n.Prop("file", "path") - if isEntryPoint(name, file, includeExports) { - continue - } - out = append(out, deadFn{name, file}) +// ensureZip creates a repo zip and returns its path and hash. +// The caller is responsible for removing the zip file. +func (s *server) ensureZip() (string, string, error) { + if err := s.cfg.RequireAPIKey(); err != nil { + return "", "", err } - return out -} - -type affected struct { - file string - depth int -} -func findAffected(g *api.Graph, target string) []affected { - importedBy := make(map[string][]string) - for _, rel := range g.Rels() { - if rel.Type == "imports" || rel.Type == "wildcard_imports" { - importedBy[rel.EndNode] = append(importedBy[rel.EndNode], rel.StartNode) - } - } - var seeds []string - for _, n := range g.NodesByLabel("File") { - if pathMatches(n.Prop("path", "name", "file"), target) { - seeds = append(seeds, n.ID) - } - } - visited := make(map[string]int) - queue := append([]string(nil), seeds...) - for _, s := range seeds { - visited[s] = 0 + zipPath, err := createZip(s.dir) + if err != nil { + return "", "", err } - var results []affected - for len(queue) > 0 { - cur := queue[0] - queue = queue[1:] - for _, parent := range importedBy[cur] { - if _, seen := visited[parent]; seen { - continue - } - d := visited[cur] + 1 - visited[parent] = d - queue = append(queue, parent) - n, ok := g.NodeByID(parent) - if !ok { - continue - } - f := n.Prop("path", "name", "file") - if f != "" && !pathMatches(f, target) { - results = append(results, affected{f, d}) - } - } + + hash, err := cache.HashFile(zipPath) + if err != nil { + os.Remove(zipPath) + return "", "", err } - return results + return zipPath, hash, nil } type graphSlice struct { @@ -433,27 +449,7 @@ func boolArg(args map[string]any, key string) bool { return v } -func isEntryPoint(name, file string, includeExports bool) bool { - bare := name - if idx := strings.LastIndex(name, "."); idx >= 0 { - bare = name[idx+1:] - } - if bare == "main" || bare == "init" { - return true - } - for _, prefix := range []string{"Test", "Benchmark", "Fuzz", "Example"} { - if strings.HasPrefix(bare, prefix) { - return true - } - } - if !includeExports && bare != "" && bare[0] >= 'A' && bare[0] <= 'Z' { - return true - } - return strings.HasSuffix(file, "_test.go") -} - -func pathMatches(nodePath, target string) bool { - target = strings.TrimPrefix(target, "./") - nodePath = strings.TrimPrefix(nodePath, "./") - return nodePath == target || strings.HasSuffix(nodePath, "/"+target) +func intArg(args map[string]any, key string) int { + v, _ := args[key].(float64) + return int(v) } From 3372c34bf3e8897964a703d0c4fe84b636d1b6d7 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 3 Apr 2026 12:40:03 -0400 Subject: [PATCH 2/4] fix(lint): name ensureZip return values, use = instead of := --- internal/mcp/server.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 437d337..c84f1df 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -403,17 +403,17 @@ func (s *server) getOrAnalyze(ctx context.Context, force bool) (*api.Graph, stri // ensureZip creates a repo zip and returns its path and hash. // The caller is responsible for removing the zip file. -func (s *server) ensureZip() (string, string, error) { - if err := s.cfg.RequireAPIKey(); err != nil { +func (s *server) ensureZip() (zipPath string, hash string, err error) { + if err = s.cfg.RequireAPIKey(); err != nil { return "", "", err } - zipPath, err := createZip(s.dir) + zipPath, err = createZip(s.dir) if err != nil { return "", "", err } - hash, err := cache.HashFile(zipPath) + hash, err = cache.HashFile(zipPath) if err != nil { os.Remove(zipPath) return "", "", err From 38ea79b40e509d5be7e9ff3ef5585ec9f03c4461 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 3 Apr 2026 13:04:56 -0400 Subject: [PATCH 3/4] fix(lint): combine param types, use := for first assignment --- internal/mcp/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index c84f1df..7a06485 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -403,8 +403,8 @@ func (s *server) getOrAnalyze(ctx context.Context, force bool) (*api.Graph, stri // ensureZip creates a repo zip and returns its path and hash. // The caller is responsible for removing the zip file. -func (s *server) ensureZip() (zipPath string, hash string, err error) { - if err = s.cfg.RequireAPIKey(); err != nil { +func (s *server) ensureZip() (zipPath, hash string, err error) { + if err := s.cfg.RequireAPIKey(); err != nil { return "", "", err } From ba5eb35c31ed5cacc1050ffd779a6e694cce3e62 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 3 Apr 2026 13:17:21 -0400 Subject: [PATCH 4/4] test: add unit tests for MCP dead_code and blast_radius formatting --- internal/mcp/server.go | 77 +++++++++++++----------- internal/mcp/server_test.go | 113 ++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 33 deletions(-) create mode 100644 internal/mcp/server_test.go diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 7a06485..d365f6a 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -262,18 +262,7 @@ func (s *server) toolDeadCode(ctx context.Context, args map[string]any) (string, return "", err } - if len(result.DeadCodeCandidates) == 0 { - return "No dead code detected.", nil - } - - var sb strings.Builder - fmt.Fprintf(&sb, "%d dead code candidate(s) out of %d total declarations:\n\n", - result.Metadata.DeadCodeCandidates, result.Metadata.TotalDeclarations) - for i := range result.DeadCodeCandidates { - c := &result.DeadCodeCandidates[i] - fmt.Fprintf(&sb, "- [%s] %s:%d %s — %s\n", c.Confidence, c.File, c.Line, c.Name, c.Reason) - } - return sb.String(), nil + return formatDeadCode(result), nil } // toolBlastRadius calls the dedicated /v1/analysis/impact endpoint. @@ -296,6 +285,46 @@ func (s *server) toolBlastRadius(ctx context.Context, args map[string]any) (stri return "", err } + return formatImpact(result), nil +} + +// toolGetGraph returns a filtered graph slice. +func (s *server) toolGetGraph(ctx context.Context, args map[string]any) (string, error) { + force := boolArg(args, "force") + g, _, err := s.getOrAnalyze(ctx, force) + if err != nil { + return "", err + } + label, _ := args["label"].(string) + relType, _ := args["rel_type"].(string) + + out := filterGraph(g, label, relType) + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} + +// --- Formatting helpers ------------------------------------------------------ + +// formatDeadCode formats a DeadCodeResult as human-readable text. +func formatDeadCode(result *api.DeadCodeResult) string { + if len(result.DeadCodeCandidates) == 0 { + return "No dead code detected." + } + var sb strings.Builder + fmt.Fprintf(&sb, "%d dead code candidate(s) out of %d total declarations:\n\n", + result.Metadata.DeadCodeCandidates, result.Metadata.TotalDeclarations) + for i := range result.DeadCodeCandidates { + c := &result.DeadCodeCandidates[i] + fmt.Fprintf(&sb, "- [%s] %s:%d %s — %s\n", c.Confidence, c.File, c.Line, c.Name, c.Reason) + } + return sb.String() +} + +// formatImpact formats an ImpactResult as human-readable text. +func formatImpact(result *api.ImpactResult) string { if len(result.Impacts) == 0 { if len(result.GlobalMetrics.MostCriticalFiles) > 0 { var sb strings.Builder @@ -304,9 +333,9 @@ func (s *server) toolBlastRadius(ctx context.Context, args map[string]any) (stri f := &result.GlobalMetrics.MostCriticalFiles[i] fmt.Fprintf(&sb, "- %s (%d dependents)\n", f.File, f.DependentCount) } - return sb.String(), nil + return sb.String() } - return "No impact detected.", nil + return "No impact detected." } var sb strings.Builder @@ -337,25 +366,7 @@ func (s *server) toolBlastRadius(ctx context.Context, args map[string]any) (stri } fmt.Fprintf(&sb, "%d target(s) analyzed across %d files and %d functions.\n", result.Metadata.TargetsAnalyzed, result.Metadata.TotalFiles, result.Metadata.TotalFunctions) - return sb.String(), nil -} - -// toolGetGraph returns a filtered graph slice. -func (s *server) toolGetGraph(ctx context.Context, args map[string]any) (string, error) { - force := boolArg(args, "force") - g, _, err := s.getOrAnalyze(ctx, force) - if err != nil { - return "", err - } - label, _ := args["label"].(string) - relType, _ := args["rel_type"].(string) - - out := filterGraph(g, label, relType) - data, err := json.MarshalIndent(out, "", " ") - if err != nil { - return "", err - } - return string(data), nil + return sb.String() } // --- Shared helpers ---------------------------------------------------------- diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go new file mode 100644 index 0000000..8babb7b --- /dev/null +++ b/internal/mcp/server_test.go @@ -0,0 +1,113 @@ +package mcp + +import ( + "strings" + "testing" + + "github.com/supermodeltools/cli/internal/api" +) + +func TestFormatDeadCode_Empty(t *testing.T) { + result := &api.DeadCodeResult{} + got := formatDeadCode(result) + if got != "No dead code detected." { + t.Errorf("expected 'No dead code detected.', got: %s", got) + } +} + +func TestFormatDeadCode_WithCandidates(t *testing.T) { + result := &api.DeadCodeResult{ + Metadata: api.DeadCodeMetadata{TotalDeclarations: 100, DeadCodeCandidates: 2}, + DeadCodeCandidates: []api.DeadCodeCandidate{ + {File: "src/a.ts", Line: 10, Name: "unused", Confidence: "high", Reason: "No callers"}, + {File: "src/b.ts", Line: 42, Name: "old", Confidence: "medium", Reason: "Transitively dead"}, + }, + } + got := formatDeadCode(result) + for _, want := range []string{ + "2 dead code candidate(s) out of 100 total declarations", + "[high] src/a.ts:10 unused — No callers", + "[medium] src/b.ts:42 old — Transitively dead", + } { + if !strings.Contains(got, want) { + t.Errorf("expected %q in output, got:\n%s", want, got) + } + } +} + +func TestFormatImpact_Empty(t *testing.T) { + result := &api.ImpactResult{} + got := formatImpact(result) + if got != "No impact detected." { + t.Errorf("expected 'No impact detected.', got: %s", got) + } +} + +func TestFormatImpact_GlobalCouplingMap(t *testing.T) { + result := &api.ImpactResult{ + GlobalMetrics: api.ImpactGlobalMetrics{ + MostCriticalFiles: []api.CriticalFileMetric{ + {File: "src/db.ts", DependentCount: 42}, + }, + }, + } + got := formatImpact(result) + if !strings.Contains(got, "Most critical files") { + t.Errorf("expected global coupling header, got:\n%s", got) + } + if !strings.Contains(got, "src/db.ts (42 dependents)") { + t.Errorf("expected file with count, got:\n%s", got) + } +} + +func TestFormatImpact_WithTarget(t *testing.T) { + result := &api.ImpactResult{ + Metadata: api.ImpactMetadata{TargetsAnalyzed: 1, TotalFiles: 100, TotalFunctions: 500}, + Impacts: []api.ImpactTarget{ + { + Target: api.ImpactTargetInfo{File: "src/auth.ts", Type: "file"}, + BlastRadius: api.BlastRadius{ + DirectDependents: 10, TransitiveDependents: 30, AffectedFiles: 5, + RiskScore: "high", RiskFactors: []string{"High fan-in"}, + }, + AffectedFiles: []api.AffectedFile{ + {File: "src/routes.ts", DirectDependencies: 3, TransitiveDependencies: 7}, + }, + EntryPointsAffected: []api.AffectedEntryPoint{ + {File: "src/routes.ts", Name: "/login", Type: "route_handler"}, + }, + }, + }, + } + got := formatImpact(result) + for _, want := range []string{ + "Target: src/auth.ts", + "Risk: high", + "Direct: 10", + "Transitive: 30", + "High fan-in", + "src/routes.ts (direct: 3, transitive: 7)", + "/login (route_handler)", + "1 target(s) analyzed across 100 files and 500 functions", + } { + if !strings.Contains(got, want) { + t.Errorf("expected %q in output, got:\n%s", want, got) + } + } +} + +func TestFormatImpact_NoEntryPoints(t *testing.T) { + result := &api.ImpactResult{ + Metadata: api.ImpactMetadata{TargetsAnalyzed: 1, TotalFiles: 50, TotalFunctions: 200}, + Impacts: []api.ImpactTarget{ + { + Target: api.ImpactTargetInfo{File: "src/util.ts", Type: "file"}, + BlastRadius: api.BlastRadius{DirectDependents: 2, RiskScore: "low"}, + }, + }, + } + got := formatImpact(result) + if strings.Contains(got, "Entry points") { + t.Error("should not contain entry points section when none affected") + } +}