diff --git a/cmd/blastradius.go b/cmd/blastradius.go index c13bbc6..d42bae9 100644 --- a/cmd/blastradius.go +++ b/cmd/blastradius.go @@ -11,14 +11,20 @@ func init() { var opts blastradius.Options c := &cobra.Command{ - Use: "blast-radius ", - Aliases: []string{"br"}, - Short: "Show files affected by a change to the given file", - Long: `Traverses the reverse import graph to find every file that directly -or transitively depends on the target file. - -Useful before refactoring to understand the full impact of a change.`, - Args: cobra.ExactArgs(1), + Use: "blast-radius [file...]", + Aliases: []string{"br", "impact"}, + Short: "Analyze the impact of changing a file or function", + Long: `Uploads the repository to the Supermodel API and runs impact analysis +using call graph and dependency graph reachability. + +Results include risk scoring, affected files and functions, and entry +points that would be impacted by changes to the target. + +Three usage modes: + + supermodel blast-radius # analyze a specific file + supermodel blast-radius --diff changes.diff # analyze from a git diff + supermodel blast-radius # global coupling map`, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { @@ -27,12 +33,12 @@ Useful before refactoring to understand the full impact of a change.`, if err := cfg.RequireAPIKey(); err != nil { return err } - return blastradius.Run(cmd.Context(), cfg, ".", args[0], opts) + return blastradius.Run(cmd.Context(), cfg, ".", args, opts) }, } c.Flags().BoolVar(&opts.Force, "force", false, "re-analyze even if a cached result exists") - c.Flags().IntVar(&opts.Depth, "depth", 0, "max traversal depth (0 = unlimited)") + c.Flags().StringVar(&opts.Diff, "diff", "", "path to a unified diff file (git diff output)") c.Flags().StringVarP(&opts.Output, "output", "o", "", "output format: human|json") rootCmd.AddCommand(c) diff --git a/cmd/deadcode.go b/cmd/deadcode.go index 7875d45..f109345 100644 --- a/cmd/deadcode.go +++ b/cmd/deadcode.go @@ -43,7 +43,7 @@ explanations for why each function was flagged.`, ctx, cancel = context.WithTimeout(ctx, time.Duration(opts.Timeout)*time.Second) defer cancel() } - if err := deadcode.Run(ctx, cfg, dir, opts); err != nil { + if err := deadcode.Run(ctx, cfg, dir, &opts); err != nil { if ctx.Err() == context.DeadlineExceeded { return fmt.Errorf("analysis timed out after %ds (increase with --timeout)", opts.Timeout) } diff --git a/internal/api/client.go b/internal/api/client.go index 86aadde..f9d4f83 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -191,6 +191,98 @@ func (c *Client) postZipTo(ctx context.Context, zipPath, idempotencyKey, endpoin return &job, nil } +// impactEndpoint is the API path for impact analysis. +const impactEndpoint = "/v1/analysis/impact" + +// Impact uploads a repository ZIP (and optional diff) and runs impact analysis, +// polling until the async job completes and returning the result. +func (c *Client) Impact(ctx context.Context, zipPath, idempotencyKey, targets, diffPath string) (*ImpactResult, error) { + endpoint := impactEndpoint + if targets != "" { + endpoint += "?targets=" + targets + } + + job, err := c.postImpact(ctx, zipPath, diffPath, idempotencyKey, endpoint) + if err != nil { + return nil, err + } + + for job.Status == "pending" || job.Status == "processing" { + wait := time.Duration(job.RetryAfter) * time.Second + if wait <= 0 { + wait = 5 * time.Second + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(wait): + } + job, err = c.postImpact(ctx, zipPath, diffPath, idempotencyKey, endpoint) + if err != nil { + return nil, err + } + } + + if job.Error != nil { + return nil, fmt.Errorf("impact analysis failed: %s", *job.Error) + } + if job.Status != "completed" { + return nil, fmt.Errorf("unexpected job status: %s", job.Status) + } + + var result ImpactResult + if err := json.Unmarshal(job.Result, &result); err != nil { + return nil, fmt.Errorf("decode impact result: %w", err) + } + return &result, nil +} + +// postImpact sends the repo ZIP and optional diff to the impact endpoint. +func (c *Client) postImpact(ctx context.Context, zipPath, diffPath, idempotencyKey, endpoint string) (*JobResponse, error) { + if diffPath == "" { + return c.postZipTo(ctx, zipPath, idempotencyKey, endpoint) + } + + // Multipart with both zip and diff. + zipFile, err := os.Open(zipPath) + if err != nil { + return nil, err + } + defer zipFile.Close() + + diffFile, err := os.Open(diffPath) + if err != nil { + return nil, err + } + defer diffFile.Close() + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + + fw, err := mw.CreateFormFile("file", filepath.Base(zipPath)) + if err != nil { + return nil, err + } + if _, err = io.Copy(fw, zipFile); err != nil { + return nil, err + } + + dw, err := mw.CreateFormFile("diff", filepath.Base(diffPath)) + if err != nil { + return nil, err + } + if _, err = io.Copy(dw, diffFile); err != nil { + return nil, err + } + mw.Close() + + var job JobResponse + if err := c.request(ctx, http.MethodPost, endpoint, mw.FormDataContentType(), &buf, idempotencyKey, &job); err != nil { + return nil, err + } + return &job, nil +} + // DisplayGraph fetches the composed display graph for an already-analyzed repo. func (c *Client) DisplayGraph(ctx context.Context, repoID, idempotencyKey string) (*Graph, error) { var g Graph diff --git a/internal/api/types.go b/internal/api/types.go index 1ce8f71..8b69b97 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -200,6 +200,94 @@ type EntryPoint struct { File string `json:"file"` } +// ImpactResult is the result from /v1/analysis/impact. +type ImpactResult struct { + Metadata ImpactMetadata `json:"metadata"` + Impacts []ImpactTarget `json:"impacts"` + GlobalMetrics ImpactGlobalMetrics `json:"globalMetrics"` +} + +// ImpactMetadata holds summary stats for an impact analysis. +type ImpactMetadata struct { + TotalFiles int `json:"totalFiles"` + TotalFunctions int `json:"totalFunctions"` + TargetsAnalyzed int `json:"targetsAnalyzed"` + AnalysisMethod string `json:"analysisMethod"` + AnalysisStartTime string `json:"analysisStartTime"` + AnalysisEndTime string `json:"analysisEndTime"` +} + +// ImpactTarget is the impact analysis result for a single target. +type ImpactTarget struct { + Target ImpactTargetInfo `json:"target"` + BlastRadius BlastRadius `json:"blastRadius"` + AffectedFunctions []AffectedFunction `json:"affectedFunctions"` + AffectedFiles []AffectedFile `json:"affectedFiles"` + EntryPointsAffected []AffectedEntryPoint `json:"entryPointsAffected"` +} + +// ImpactTargetInfo identifies the file or function being analyzed. +type ImpactTargetInfo struct { + File string `json:"file"` + Name string `json:"name,omitempty"` + Line int `json:"line,omitempty"` + Type string `json:"type"` +} + +// BlastRadius holds blast radius metrics for a target. +type BlastRadius struct { + DirectDependents int `json:"directDependents"` + TransitiveDependents int `json:"transitiveDependents"` + AffectedFiles int `json:"affectedFiles"` + AffectedDomains []string `json:"affectedDomains,omitempty"` + RiskScore string `json:"riskScore"` + RiskFactors []string `json:"riskFactors,omitempty"` +} + +// AffectedFunction is a function affected by changes to the target. +type AffectedFunction struct { + File string `json:"file"` + Name string `json:"name"` + Line int `json:"line,omitempty"` + Type string `json:"type"` + Distance int `json:"distance"` + Relationship string `json:"relationship"` +} + +// AffectedFile is a file affected by changes to the target. +type AffectedFile struct { + File string `json:"file"` + DirectDependencies int `json:"directDependencies"` + TransitiveDependencies int `json:"transitiveDependencies"` +} + +// AffectedEntryPoint is an entry point affected by changes to the target. +type AffectedEntryPoint struct { + File string `json:"file"` + Name string `json:"name"` + Type string `json:"type"` +} + +// ImpactGlobalMetrics holds global metrics across all analyzed targets. +type ImpactGlobalMetrics struct { + MostCriticalFiles []CriticalFileMetric `json:"mostCriticalFiles,omitempty"` + CrossDomainDependencies []CrossDomainDependency `json:"crossDomainDependencies,omitempty"` +} + +// CriticalFileMetric identifies a high-dependent-count file. +type CriticalFileMetric struct { + File string `json:"file"` + DependentCount int `json:"dependentCount"` +} + +// CrossDomainDependency identifies a dependency crossing domain boundaries. +type CrossDomainDependency struct { + Source string `json:"source"` + Target string `json:"target"` + SourceDomain string `json:"sourceDomain"` + TargetDomain string `json:"targetDomain"` +} + // Error represents a non-2xx response from the API. type Error struct { StatusCode int `json:"-"` diff --git a/internal/blastradius/handler.go b/internal/blastradius/handler.go index d2a865d..1e8f7b0 100644 --- a/internal/blastradius/handler.go +++ b/internal/blastradius/handler.go @@ -5,12 +5,10 @@ import ( "fmt" "io" "os" - "path/filepath" - "sort" "strings" - "github.com/supermodeltools/cli/internal/analyze" "github.com/supermodeltools/cli/internal/api" + "github.com/supermodeltools/cli/internal/cache" "github.com/supermodeltools/cli/internal/config" "github.com/supermodeltools/cli/internal/ui" ) @@ -19,128 +17,103 @@ import ( type Options struct { Force bool // bypass cache Output string // "human" | "json" - Depth int // max traversal depth; 0 = unlimited + Diff string // path to a unified diff file (optional) } -// Result is a file affected by a change to the target. -type Result struct { - File string `json:"file"` - Depth int `json:"depth"` // hops from target -} +// Run uploads the repo and runs impact analysis via the dedicated API endpoint. +func Run(ctx context.Context, cfg *config.Config, dir string, targets []string, opts Options) error { + spin := ui.Start("Creating repository archive…") + zipPath, err := createZip(dir) + spin.Stop() + if err != nil { + return fmt.Errorf("create archive: %w", err) + } + defer os.Remove(zipPath) -// Run finds all files transitively affected by a change to target and prints them. -func Run(ctx context.Context, cfg *config.Config, dir, target string, opts Options) error { - g, _, err := analyze.GetGraph(ctx, cfg, dir, opts.Force) + hash, err := cache.HashFile(zipPath) if err != nil { return err } - results, err := findBlastRadius(g, dir, target, opts.Depth) + + idempotencyKey := "impact-" + hash[:16] + targetStr := strings.Join(targets, ",") + if targetStr != "" { + idempotencyKey += "-" + targetStr + } + + client := api.New(cfg) + spin = ui.Start("Analyzing impact…") + result, err := client.Impact(ctx, zipPath, idempotencyKey, targetStr, opts.Diff) + spin.Stop() if err != nil { return err } - return printResults(os.Stdout, target, results, ui.ParseFormat(opts.Output)) + + return printResults(os.Stdout, result, ui.ParseFormat(opts.Output)) } -// findBlastRadius performs a reverse BFS on IMPORTS edges starting from target. -// It returns all File nodes that transitively import the target file, sorted by -// hop distance from the origin. -func findBlastRadius(g *api.Graph, repoDir, target string, maxDepth int) ([]Result, error) { - // Normalise target to a repo-relative slash path for comparison. - targetRel := normalise(repoDir, target) - - // Find seed nodes: File nodes whose path matches the target. - var seeds []string - for _, n := range g.NodesByLabel("File") { - if pathMatches(n.Prop("path", "name", "file"), targetRel) { - seeds = append(seeds, n.ID) - } - } - if len(seeds) == 0 { - return nil, fmt.Errorf("file not found in graph: %s (run `supermodel analyze` to refresh)", target) +// printResults writes the impact analysis result in the requested format. +func printResults(w io.Writer, result *api.ImpactResult, fmt_ ui.Format) error { + if fmt_ == ui.FormatJSON { + return ui.JSON(w, result) } - // Build reverse adjacency: nodeID → set of node IDs that import it. - 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) + if len(result.Impacts) == 0 { + // Global coupling map mode. + if len(result.GlobalMetrics.MostCriticalFiles) > 0 { + fmt.Fprintln(w, "Most critical files (by dependent count):") + rows := make([][]string, len(result.GlobalMetrics.MostCriticalFiles)) + for i, f := range result.GlobalMetrics.MostCriticalFiles { + rows[i] = []string{f.File, fmt.Sprintf("%d", f.DependentCount)} + } + ui.Table(w, []string{"FILE", "DEPENDENTS"}, rows) + return nil } + fmt.Fprintln(w, "No impact detected.") + return nil } - // BFS from seeds following reverse IMPORTS edges. - visited := make(map[string]int) // nodeID → depth first seen - queue := append([]string(nil), seeds...) - for _, s := range seeds { - visited[s] = 0 - } - - var results []Result - for len(queue) > 0 { - cur := queue[0] - queue = queue[1:] - depth := visited[cur] - - if maxDepth > 0 && depth >= maxDepth { - continue + for i := range result.Impacts { + impact := &result.Impacts[i] + br := &impact.BlastRadius + fmt.Fprintf(w, "Target: %s", impact.Target.File) + if impact.Target.Name != "" { + fmt.Fprintf(w, ":%s", impact.Target.Name) } + fmt.Fprintln(w) + fmt.Fprintf(w, "Risk: %s | Direct: %d | Transitive: %d | Files: %d\n", + br.RiskScore, br.DirectDependents, br.TransitiveDependents, br.AffectedFiles) - for _, parent := range importedBy[cur] { - if _, seen := visited[parent]; seen { - continue + if len(br.RiskFactors) > 0 { + for _, rf := range br.RiskFactors { + fmt.Fprintf(w, " → %s\n", rf) } - visited[parent] = depth + 1 - queue = append(queue, parent) + } - n, ok := g.NodeByID(parent) - if !ok { - continue - } - file := n.Prop("path", "name", "file") - if file != "" && !pathMatches(file, targetRel) { - results = append(results, Result{File: file, Depth: depth + 1}) + if len(impact.AffectedFiles) > 0 { + fmt.Fprintln(w) + rows := make([][]string, len(impact.AffectedFiles)) + for i, f := range impact.AffectedFiles { + rows[i] = []string{f.File, fmt.Sprintf("%d", f.DirectDependencies), fmt.Sprintf("%d", f.TransitiveDependencies)} } + ui.Table(w, []string{"AFFECTED FILE", "DIRECT", "TRANSITIVE"}, rows) } - } - sort.Slice(results, func(i, j int) bool { - if results[i].Depth != results[j].Depth { - return results[i].Depth < results[j].Depth + if len(impact.EntryPointsAffected) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "Entry points affected:") + rows := make([][]string, len(impact.EntryPointsAffected)) + for i, ep := range impact.EntryPointsAffected { + rows[i] = []string{ep.File, ep.Name, ep.Type} + } + ui.Table(w, []string{"FILE", "NAME", "TYPE"}, rows) } - return results[i].File < results[j].File - }) - return results, nil -} -func normalise(repoDir, path string) string { - if filepath.IsAbs(path) { - rel, err := filepath.Rel(repoDir, path) - if err == nil { - path = rel - } + fmt.Fprintln(w) } - return filepath.ToSlash(strings.TrimPrefix(path, "./")) -} -func pathMatches(nodePath, target string) bool { - nodePath = filepath.ToSlash(nodePath) - return nodePath == target || - strings.HasSuffix(nodePath, "/"+target) || - strings.HasSuffix(nodePath, target) -} - -func printResults(w io.Writer, target string, results []Result, fmt_ ui.Format) error { - if fmt_ == ui.FormatJSON { - return ui.JSON(w, map[string]any{"target": target, "affected": results}) - } - if len(results) == 0 { - fmt.Fprintf(w, "No files are affected by changes to %s.\n", target) - return nil - } - rows := make([][]string, len(results)) - for i, r := range results { - rows[i] = []string{r.File, fmt.Sprintf("%d", r.Depth)} - } - ui.Table(w, []string{"AFFECTED FILE", "HOPS"}, rows) - fmt.Fprintf(w, "\n%d file(s) affected by changes to %s.\n", len(results), target) + meta := result.Metadata + fmt.Fprintf(w, "%d target(s) analyzed across %d files and %d functions.\n", + meta.TargetsAnalyzed, meta.TotalFiles, meta.TotalFunctions) return nil } diff --git a/internal/blastradius/handler_test.go b/internal/blastradius/handler_test.go index adb225e..fb6ea25 100644 --- a/internal/blastradius/handler_test.go +++ b/internal/blastradius/handler_test.go @@ -1,88 +1,202 @@ package blastradius import ( + "bytes" + "encoding/json" + "strings" "testing" "github.com/supermodeltools/cli/internal/api" + "github.com/supermodeltools/cli/internal/ui" ) -func TestPathMatches(t *testing.T) { - tests := []struct { - nodePath string - target string - want bool - }{ - {"internal/api/client.go", "internal/api/client.go", true}, - {"./internal/api/client.go", "internal/api/client.go", true}, - {"/repo/internal/api/client.go", "internal/api/client.go", true}, - {"internal/auth/handler.go", "internal/api/client.go", false}, - {"internal/apifoo/client.go", "internal/api/client.go", false}, - } - for _, tt := range tests { - t.Run(tt.nodePath+"→"+tt.target, func(t *testing.T) { - got := pathMatches(tt.nodePath, tt.target) - if got != tt.want { - t.Errorf("pathMatches(%q, %q) = %v, want %v", tt.nodePath, tt.target, got, tt.want) - } - }) +func sampleImpact() *api.ImpactResult { + return &api.ImpactResult{ + Metadata: api.ImpactMetadata{ + TotalFiles: 100, + TotalFunctions: 500, + TargetsAnalyzed: 1, + AnalysisMethod: "call_graph + dependency_graph", + }, + Impacts: []api.ImpactTarget{ + { + Target: api.ImpactTargetInfo{File: "src/auth/login.ts", Type: "file"}, + BlastRadius: api.BlastRadius{ + DirectDependents: 3, + TransitiveDependents: 7, + AffectedFiles: 5, + RiskScore: "high", + RiskFactors: []string{"Affects authentication flow"}, + }, + AffectedFiles: []api.AffectedFile{ + {File: "src/api/routes.ts", DirectDependencies: 2, TransitiveDependencies: 0}, + {File: "src/middleware/auth.ts", DirectDependencies: 1, TransitiveDependencies: 3}, + }, + EntryPointsAffected: []api.AffectedEntryPoint{ + {File: "src/api/routes.ts", Name: "/api/login", Type: "route_handler"}, + }, + }, + }, } } -func TestFindBlastRadius(t *testing.T) { - // Dependency chain: c → a → b (a imports b, c imports a) - g := &api.Graph{ - Nodes: []api.Node{ - fileNode("a", "internal/a/a.go"), - fileNode("b", "internal/b/b.go"), - fileNode("c", "internal/c/c.go"), - }, - Relationships: []api.Relationship{ - {ID: "r1", Type: "imports", StartNode: "a", EndNode: "b"}, - {ID: "r2", Type: "imports", StartNode: "c", EndNode: "a"}, - }, +func TestPrintResults_Human(t *testing.T) { + var buf bytes.Buffer + if err := printResults(&buf, sampleImpact(), ui.FormatHuman); err != nil { + t.Fatal(err) } + out := buf.String() - t.Run("blast radius of b", func(t *testing.T) { - results, err := findBlastRadius(g, ".", "internal/b/b.go", 0) - if err != nil { - t.Fatal(err) - } - if len(results) != 2 { - t.Fatalf("expected 2 affected files, got %d: %v", len(results), results) - } - if results[0].File != "internal/a/a.go" || results[0].Depth != 1 { - t.Errorf("expected a at depth 1, got %+v", results[0]) - } - if results[1].File != "internal/c/c.go" || results[1].Depth != 2 { - t.Errorf("expected c at depth 2, got %+v", results[1]) + for _, want := range []string{ + "src/auth/login.ts", + "high", + "Direct: 3", + "Transitive: 7", + "Affects authentication flow", + "src/api/routes.ts", + "/api/login", + "route_handler", + "1 target(s) analyzed", + } { + if !strings.Contains(out, want) { + t.Errorf("expected %q in output, got:\n%s", want, out) } - }) + } +} - t.Run("depth cap", func(t *testing.T) { - results, err := findBlastRadius(g, ".", "internal/b/b.go", 1) - if err != nil { - t.Fatal(err) - } - if len(results) != 1 { - t.Fatalf("expected 1 result at depth 1, got %d", len(results)) - } - if results[0].File != "internal/a/a.go" { - t.Errorf("expected a, got %q", results[0].File) - } - }) +func TestPrintResults_Empty(t *testing.T) { + result := &api.ImpactResult{ + Metadata: api.ImpactMetadata{TotalFiles: 100}, + } + var buf bytes.Buffer + if err := printResults(&buf, result, ui.FormatHuman); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "No impact detected") { + t.Errorf("expected 'No impact detected', got:\n%s", buf.String()) + } +} - t.Run("unknown file", func(t *testing.T) { - _, err := findBlastRadius(g, ".", "internal/z/z.go", 0) - if err == nil { - t.Fatal("expected error for unknown file") - } - }) +func TestPrintResults_JSON(t *testing.T) { + var buf bytes.Buffer + if err := printResults(&buf, sampleImpact(), ui.FormatJSON); err != nil { + t.Fatal(err) + } + var decoded api.ImpactResult + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(decoded.Impacts) != 1 { + t.Errorf("expected 1 impact, got %d", len(decoded.Impacts)) + } + if decoded.Impacts[0].BlastRadius.RiskScore != "high" { + t.Errorf("expected risk=high, got %q", decoded.Impacts[0].BlastRadius.RiskScore) + } } -func fileNode(id, path string) api.Node { - return api.Node{ - ID: id, - Labels: []string{"File"}, - Properties: map[string]any{"path": path}, +func TestPrintResults_GlobalCouplingMap(t *testing.T) { + result := &api.ImpactResult{ + Metadata: api.ImpactMetadata{TotalFiles: 100}, + GlobalMetrics: api.ImpactGlobalMetrics{ + MostCriticalFiles: []api.CriticalFileMetric{ + {File: "src/core/db.ts", DependentCount: 42}, + {File: "src/core/auth.ts", DependentCount: 31}, + }, + }, + } + var buf bytes.Buffer + if err := printResults(&buf, result, ui.FormatHuman); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, "Most critical files") { + t.Errorf("expected global coupling header, got:\n%s", out) + } + if !strings.Contains(out, "42") { + t.Errorf("expected dependent count 42, got:\n%s", out) + } +} + +func TestPrintResults_MultipleTargets(t *testing.T) { + result := &api.ImpactResult{ + Metadata: api.ImpactMetadata{ + TotalFiles: 200, + TotalFunctions: 1000, + TargetsAnalyzed: 2, + }, + Impacts: []api.ImpactTarget{ + { + Target: api.ImpactTargetInfo{File: "src/a.ts", Type: "file"}, + BlastRadius: api.BlastRadius{DirectDependents: 5, TransitiveDependents: 10, AffectedFiles: 3, RiskScore: "medium"}, + }, + { + Target: api.ImpactTargetInfo{File: "src/b.ts", Name: "doStuff", Type: "function"}, + BlastRadius: api.BlastRadius{DirectDependents: 1, TransitiveDependents: 2, AffectedFiles: 1, RiskScore: "low"}, + }, + }, + } + var buf bytes.Buffer + if err := printResults(&buf, result, ui.FormatHuman); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, "src/a.ts") { + t.Error("expected first target in output") + } + if !strings.Contains(out, "src/b.ts:doStuff") { + t.Error("expected second target with function name in output") + } + if !strings.Contains(out, "2 target(s) analyzed") { + t.Error("expected summary with 2 targets") + } +} + +func TestPrintResults_NoEntryPoints(t *testing.T) { + result := &api.ImpactResult{ + Metadata: api.ImpactMetadata{TotalFiles: 50, TotalFunctions: 200, TargetsAnalyzed: 1}, + Impacts: []api.ImpactTarget{ + { + Target: api.ImpactTargetInfo{File: "src/internal.ts", Type: "file"}, + BlastRadius: api.BlastRadius{DirectDependents: 2, TransitiveDependents: 0, AffectedFiles: 1, RiskScore: "low"}, + AffectedFiles: []api.AffectedFile{ + {File: "src/caller.ts", DirectDependencies: 2, TransitiveDependencies: 0}, + }, + }, + }, + } + var buf bytes.Buffer + if err := printResults(&buf, result, ui.FormatHuman); err != nil { + t.Fatal(err) + } + out := buf.String() + if strings.Contains(out, "Entry points") { + t.Error("should not show entry points section when none affected") + } + if !strings.Contains(out, "src/caller.ts") { + t.Error("expected affected file in output") + } +} + +func TestPrintResults_JSONRoundTrip(t *testing.T) { + original := sampleImpact() + var buf bytes.Buffer + if err := printResults(&buf, original, ui.FormatJSON); err != nil { + t.Fatal(err) + } + var decoded api.ImpactResult + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if decoded.Metadata.TotalFiles != 100 { + t.Errorf("totalFiles: got %d, want 100", decoded.Metadata.TotalFiles) + } + if decoded.Metadata.TargetsAnalyzed != 1 { + t.Errorf("targetsAnalyzed: got %d, want 1", decoded.Metadata.TargetsAnalyzed) + } + if len(decoded.Impacts[0].AffectedFiles) != 2 { + t.Errorf("affectedFiles: got %d, want 2", len(decoded.Impacts[0].AffectedFiles)) + } + if len(decoded.Impacts[0].EntryPointsAffected) != 1 { + t.Errorf("entryPoints: got %d, want 1", len(decoded.Impacts[0].EntryPointsAffected)) } } diff --git a/internal/blastradius/integration_test.go b/internal/blastradius/integration_test.go index b5d46ae..4e4d9d9 100644 --- a/internal/blastradius/integration_test.go +++ b/internal/blastradius/integration_test.go @@ -4,17 +4,17 @@ package blastradius_test import ( "context" + "os" "testing" "time" - "github.com/supermodeltools/cli/internal/analyze" + "github.com/supermodeltools/cli/internal/api" "github.com/supermodeltools/cli/internal/blastradius" "github.com/supermodeltools/cli/internal/testutil" ) -// TestIntegration_Run_KnownFile analyzes the minimal repo, picks a File node -// from the returned graph, and runs blast-radius against it. -func TestIntegration_Run_KnownFile(t *testing.T) { +// TestIntegration_Run_TargetFile analyzes the minimal repo via the impact endpoint. +func TestIntegration_Run_TargetFile(t *testing.T) { cfg := testutil.IntegrationConfig(t) dir := testutil.MinimalGoDir(t) t.Setenv("HOME", t.TempDir()) @@ -22,27 +22,8 @@ func TestIntegration_Run_KnownFile(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - // First, get the graph so we know a real file path. - g, _, err := analyze.GetGraph(ctx, cfg, dir, true) - if err != nil { - t.Fatalf("GetGraph: %v", err) - } - files := g.NodesByLabel("File") - if len(files) == 0 { - t.Skip("no File nodes in graph — cannot run blast-radius test") - } - - // Pick any file in the graph. - target := files[0].Prop("path", "name", "file") - if target == "" { - t.Skip("File node has no path property") - } - t.Logf("running blast-radius for target: %s", target) - - // Run blast-radius. Even if nothing imports this file, it should succeed - // (zero results is valid). - err = blastradius.Run(ctx, cfg, dir, target, blastradius.Options{ - Force: false, // use the cached graph from GetGraph above + err := blastradius.Run(ctx, cfg, dir, []string{"main.go"}, blastradius.Options{ + Force: true, Output: "human", }) if err != nil { @@ -59,21 +40,8 @@ func TestIntegration_Run_JSON(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - g, _, err := analyze.GetGraph(ctx, cfg, dir, true) - if err != nil { - t.Fatalf("GetGraph: %v", err) - } - files := g.NodesByLabel("File") - if len(files) == 0 { - t.Skip("no File nodes in graph") - } - target := files[0].Prop("path", "name", "file") - if target == "" { - t.Skip("File node has no path property") - } - - err = blastradius.Run(ctx, cfg, dir, target, blastradius.Options{ - Force: false, + err := blastradius.Run(ctx, cfg, dir, []string{"main.go"}, blastradius.Options{ + Force: true, Output: "json", }) if err != nil { @@ -81,9 +49,8 @@ func TestIntegration_Run_JSON(t *testing.T) { } } -// TestIntegration_Run_UnknownFile verifies that an unknown file returns an -// error with a helpful message. -func TestIntegration_Run_UnknownFile(t *testing.T) { +// TestIntegration_Run_GlobalCoupling runs with no targets for global analysis. +func TestIntegration_Run_GlobalCoupling(t *testing.T) { cfg := testutil.IntegrationConfig(t) dir := testutil.MinimalGoDir(t) t.Setenv("HOME", t.TempDir()) @@ -91,11 +58,35 @@ func TestIntegration_Run_UnknownFile(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - err := blastradius.Run(ctx, cfg, dir, "nonexistent/file.go", blastradius.Options{ - Force: true, + err := blastradius.Run(ctx, cfg, dir, nil, blastradius.Options{ + Force: true, + Output: "human", }) - if err == nil { - t.Fatal("expected error for nonexistent file, got nil") + if err != nil { + t.Fatalf("blastradius.Run global: %v", err) + } +} + +// TestIntegration_API_Impact calls the impact API directly and validates the response. +func TestIntegration_API_Impact(t *testing.T) { + cfg := testutil.IntegrationConfig(t) + zipPath := testutil.MinimalGoZip(t) + defer os.Remove(zipPath) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + client := api.New(cfg) + result, err := client.Impact(ctx, zipPath, "integration-test-impact", "main.go", "") + if err != nil { + t.Fatalf("Impact: %v", err) + } + if result.Metadata.TotalFiles == 0 { + t.Error("expected totalFiles > 0") } - t.Logf("got expected error: %v", err) + t.Logf("targets=%d files=%d functions=%d impacts=%d", + result.Metadata.TargetsAnalyzed, + result.Metadata.TotalFiles, + result.Metadata.TotalFunctions, + len(result.Impacts)) } diff --git a/internal/blastradius/normalise_test.go b/internal/blastradius/normalise_test.go deleted file mode 100644 index 6552542..0000000 --- a/internal/blastradius/normalise_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package blastradius - -import ( - "bytes" - "encoding/json" - "strings" - "testing" -) - -// ── normalise ───────────────────────────────────────────────────────────────── - -func TestNormalise_RelativePath(t *testing.T) { - got := normalise("/repo", "internal/api/client.go") - want := "internal/api/client.go" - if got != want { - t.Errorf("normalise relative: got %q, want %q", got, want) - } -} - -func TestNormalise_AbsolutePath(t *testing.T) { - // Use a real temp dir so the path works on all platforms. - repoDir := t.TempDir() - abs := repoDir + "/internal/api/client.go" - got := normalise(repoDir, abs) - want := "internal/api/client.go" - if got != want { - t.Errorf("normalise absolute: got %q, want %q", got, want) - } -} - -func TestNormalise_DotSlashPrefix(t *testing.T) { - got := normalise("/repo", "./internal/api/client.go") - want := "internal/api/client.go" - if got != want { - t.Errorf("normalise dot-slash: got %q, want %q", got, want) - } -} - -func TestNormalise_SlashSeparators(t *testing.T) { - got := normalise(".", "internal/api/client.go") - if strings.Contains(got, "\\") { - t.Errorf("normalise should use forward slashes: %q", got) - } -} - -// ── printResults ────────────────────────────────────────────────────────────── - -func TestPrintResults_Empty(t *testing.T) { - var buf bytes.Buffer - if err := printResults(&buf, "cmd/main.go", nil, "human"); err != nil { - t.Fatalf("printResults empty: %v", err) - } - out := buf.String() - if !strings.Contains(out, "No files") { - t.Errorf("empty results: should say 'No files':\n%s", out) - } - if !strings.Contains(out, "cmd/main.go") { - t.Errorf("should mention target:\n%s", out) - } -} - -func TestPrintResults_Human(t *testing.T) { - results := []Result{ - {File: "internal/auth/handler.go", Depth: 1}, - {File: "cmd/main.go", Depth: 2}, - } - var buf bytes.Buffer - if err := printResults(&buf, "internal/api/client.go", results, "human"); err != nil { - t.Fatalf("printResults human: %v", err) - } - out := buf.String() - for _, want := range []string{"internal/auth/handler.go", "cmd/main.go", "2 file(s)"} { - if !strings.Contains(out, want) { - t.Errorf("should contain %q:\n%s", want, out) - } - } -} - -func TestPrintResults_JSON(t *testing.T) { - results := []Result{ - {File: "internal/auth/handler.go", Depth: 1}, - } - var buf bytes.Buffer - if err := printResults(&buf, "internal/api/client.go", results, "json"); err != nil { - t.Fatalf("printResults json: %v", err) - } - var decoded map[string]any - if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { - t.Fatalf("invalid JSON: %v\n%s", err, buf.String()) - } - if decoded["target"] != "internal/api/client.go" { - t.Errorf("target: got %v", decoded["target"]) - } - affected, ok := decoded["affected"].([]any) - if !ok || len(affected) != 1 { - t.Errorf("affected: want 1 item, got %v", decoded["affected"]) - } -} - -func TestPrintResults_HumanShowsHops(t *testing.T) { - results := []Result{ - {File: "a.go", Depth: 3}, - } - var buf bytes.Buffer - if err := printResults(&buf, "b.go", results, "human"); err != nil { - t.Fatal(err) - } - out := buf.String() - if !strings.Contains(out, "3") { - t.Errorf("should show depth/hops:\n%s", out) - } -} diff --git a/internal/blastradius/zip.go b/internal/blastradius/zip.go new file mode 100644 index 0000000..4769966 --- /dev/null +++ b/internal/blastradius/zip.go @@ -0,0 +1,111 @@ +package blastradius + +import ( + "archive/zip" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// skipDirs are directory names that should never be included in the archive. +var skipDirs = map[string]bool{ + ".git": true, + "node_modules": true, + "vendor": true, + "__pycache__": true, + ".venv": true, + "venv": true, + "dist": true, + "build": true, + "target": true, + ".next": true, + ".nuxt": true, + "coverage": true, + ".terraform": true, + ".tox": true, +} + +// createZip archives the repository at dir into a temporary ZIP file and +// returns its path. The caller is responsible for removing the file. +// +// Strategy: use git archive when inside a Git repo (respects .gitignore, +// deterministic output). Falls back to a manual directory walk otherwise. +func createZip(dir string) (string, error) { + f, err := os.CreateTemp("", "supermodel-*.zip") + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + dest := f.Name() + f.Close() + + if isGitRepo(dir) { + if err := gitArchive(dir, dest); err == nil { + return dest, nil + } + } + + if err := walkZip(dir, dest); err != nil { + os.Remove(dest) + return "", err + } + return dest, nil +} + +func isGitRepo(dir string) bool { + cmd := exec.Command("git", "-C", dir, "rev-parse", "--git-dir") + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + return cmd.Run() == nil +} + +func gitArchive(dir, dest string) error { + cmd := exec.Command("git", "-C", dir, "archive", "--format=zip", "-o", dest, "HEAD") + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// walkZip creates a ZIP of dir, excluding skipDirs, hidden files, and +// files larger than 10 MB. +func walkZip(dir, dest string) error { + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + + zw := zip.NewWriter(out) + defer zw.Close() + + return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + if info.IsDir() { + if skipDirs[info.Name()] { + return filepath.SkipDir + } + return nil + } + if strings.HasPrefix(info.Name(), ".") || info.Size() > 10<<20 { + return nil + } + w, err := zw.Create(filepath.ToSlash(rel)) + if err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(w, f) + return err + }) +} diff --git a/internal/deadcode/handler.go b/internal/deadcode/handler.go index 53e2389..1fff5bd 100644 --- a/internal/deadcode/handler.go +++ b/internal/deadcode/handler.go @@ -23,7 +23,7 @@ type Options struct { } // Run uploads the repo and runs dead code analysis via the dedicated API endpoint. -func Run(ctx context.Context, cfg *config.Config, dir string, opts Options) error { +func Run(ctx context.Context, cfg *config.Config, dir string, opts *Options) error { spin := ui.Start("Creating repository archive…") zipPath, err := createZip(dir) spin.Stop()