Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions cmd/blastradius.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@ func init() {
var opts blastradius.Options

c := &cobra.Command{
Use: "blast-radius <file>",
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 <file> # 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 {
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/deadcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
92 changes: 92 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +199 to +203
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

URL encoding missing for targets query param — could break with special characters

Hey, so if someone passes a file path with spaces or special characters (like src/my file.ts or src/foo&bar.ts), this will produce a malformed URL. The targets string is concatenated directly without escaping.

Quick example of what goes wrong:

  • Input: targets = "src/my file.ts"
  • Result: /v1/analysis/impact?targets=src/my file.ts (broken URL)

You'll want to use url.QueryEscape(targets) or build the URL properly with url.Values.

🐛 Proposed fix
+import "net/url"
+
 func (c *Client) Impact(ctx context.Context, zipPath, idempotencyKey, targets, diffPath string) (*ImpactResult, error) {
 	endpoint := impactEndpoint
 	if targets != "" {
-		endpoint += "?targets=" + targets
+		endpoint += "?targets=" + url.QueryEscape(targets)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/client.go` around lines 199 - 203, The Impact method is building
the request URL by concatenating the targets query param raw, which breaks on
spaces/special chars; update Client.Impact to URL-encode the targets (e.g., use
url.QueryEscape or build query with url.Values) before appending so endpoint
becomes impactEndpoint + "?" + encoded query; ensure you reference the targets
string and the Impact function when making the change and preserve existing
logic when 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
Expand Down
88 changes: 88 additions & 0 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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:"-"`
Expand Down
Loading
Loading