Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
43 changes: 43 additions & 0 deletions cmd/audit.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
package cmd

import (
"context"
"fmt"
"os"
"time"

"github.com/spf13/cobra"

"github.com/supermodeltools/cli/internal/api"
"github.com/supermodeltools/cli/internal/cache"
"github.com/supermodeltools/cli/internal/config"
"github.com/supermodeltools/cli/internal/factory"
)

Expand Down Expand Up @@ -52,6 +60,41 @@ func runAudit(cmd *cobra.Command, dir string) error {
}

report := factory.Analyze(ir, projectName)

// Run impact analysis (global mode) to enrich the health report.
impact, err := runImpactForAudit(cmd, rootDir)
if err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: impact analysis unavailable: %v\n", err)
} else {
factory.EnrichWithImpact(report, impact)
}

factory.RenderHealth(cmd.OutOrStdout(), report)
return nil
}

// runImpactForAudit runs global impact analysis for the audit report.
func runImpactForAudit(cmd *cobra.Command, rootDir string) (*api.ImpactResult, error) {
cfg, err := config.Load()
if err != nil {
return nil, err
}

zipPath, err := factory.CreateZip(rootDir)
if err != nil {
return nil, err
}
defer func() { _ = os.Remove(zipPath) }()

hash, err := cache.HashFile(zipPath)
if err != nil {
return nil, err
}

client := api.New(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()

fmt.Fprintln(cmd.ErrOrStderr(), "Running impact analysis…")
return client.Impact(ctx, zipPath, "audit-impact-"+hash[:16], "", "")
}
136 changes: 136 additions & 0 deletions internal/factory/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -970,3 +970,139 @@ func TestRenderImprovePrompt_NoHealthReport(t *testing.T) {
t.Error("should produce output even without HealthReport")
}
}

// ── EnrichWithImpact ─────────────────────────────────────────────────────────

func TestEnrichWithImpact_AddsFiles(t *testing.T) {
r := &HealthReport{Status: StatusHealthy}
impact := &api.ImpactResult{
Impacts: []api.ImpactTarget{
{
Target: api.ImpactTargetInfo{File: "src/db.ts", Type: "file"},
BlastRadius: api.BlastRadius{DirectDependents: 50, TransitiveDependents: 100, AffectedFiles: 10, RiskScore: "high"},
},
},
}
EnrichWithImpact(r, impact)
if len(r.ImpactFiles) != 1 {
t.Fatalf("expected 1 impact file, got %d", len(r.ImpactFiles))
}
if r.ImpactFiles[0].Path != "src/db.ts" {
t.Errorf("expected src/db.ts, got %s", r.ImpactFiles[0].Path)
}
if r.ImpactFiles[0].Direct != 50 {
t.Errorf("expected 50 direct, got %d", r.ImpactFiles[0].Direct)
}
}

func TestEnrichWithImpact_CriticalDegrades(t *testing.T) {
r := &HealthReport{Status: StatusHealthy}
impact := &api.ImpactResult{
Impacts: []api.ImpactTarget{
{
Target: api.ImpactTargetInfo{File: "src/core.ts", Type: "file"},
BlastRadius: api.BlastRadius{DirectDependents: 200, TransitiveDependents: 500, AffectedFiles: 30, RiskScore: "critical"},
},
},
}
EnrichWithImpact(r, impact)
if r.Status != StatusDegraded {
t.Errorf("expected DEGRADED, got %s", r.Status)
}
}

func TestEnrichWithImpact_NonCriticalStaysHealthy(t *testing.T) {
r := &HealthReport{Status: StatusHealthy}
impact := &api.ImpactResult{
Impacts: []api.ImpactTarget{
{
Target: api.ImpactTargetInfo{File: "src/util.ts", Type: "file"},
BlastRadius: api.BlastRadius{DirectDependents: 5, TransitiveDependents: 10, AffectedFiles: 2, RiskScore: "low"},
},
},
}
EnrichWithImpact(r, impact)
if r.Status != StatusHealthy {
t.Errorf("expected HEALTHY, got %s", r.Status)
}
}

func TestEnrichWithImpact_CapsAt10(t *testing.T) {
r := &HealthReport{Status: StatusHealthy}
var impacts []api.ImpactTarget
for i := 0; i < 15; i++ {
impacts = append(impacts, api.ImpactTarget{
Target: api.ImpactTargetInfo{File: fmt.Sprintf("src/file%d.ts", i), Type: "file"},
BlastRadius: api.BlastRadius{DirectDependents: 100 - i, RiskScore: "high"},
})
}
EnrichWithImpact(r, &api.ImpactResult{Impacts: impacts})
if len(r.ImpactFiles) != 10 {
t.Errorf("expected 10 impact files (capped), got %d", len(r.ImpactFiles))
}
}

func TestEnrichWithImpact_GeneratesRecommendations(t *testing.T) {
r := &HealthReport{Status: StatusHealthy}
impact := &api.ImpactResult{
Impacts: []api.ImpactTarget{
{
Target: api.ImpactTargetInfo{File: "src/auth.ts", Type: "file"},
BlastRadius: api.BlastRadius{DirectDependents: 100, TransitiveDependents: 200, AffectedFiles: 20, RiskScore: "critical"},
},
},
}
EnrichWithImpact(r, impact)
found := false
for _, rec := range r.Recommendations {
if strings.Contains(rec.Message, "src/auth.ts") && rec.Priority == 1 {
found = true
break
}
}
if !found {
t.Error("expected critical recommendation for src/auth.ts")
}
}

func TestEnrichWithImpact_EmptyImpact(t *testing.T) {
r := &HealthReport{Status: StatusHealthy}
EnrichWithImpact(r, &api.ImpactResult{})
if r.Status != StatusHealthy {
t.Errorf("expected HEALTHY with empty impact, got %s", r.Status)
}
if len(r.ImpactFiles) != 0 {
t.Errorf("expected 0 impact files, got %d", len(r.ImpactFiles))
}
}
Comment on lines +1068 to +1077
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if EnrichWithImpact has any nil-guard for the impact parameter
ast-grep --pattern $'func EnrichWithImpact($_, $_) {
  $$$
}'

Repository: supermodeltools/cli

Length of output: 2016


Add a nil guard or clarify the contract.

The EnrichWithImpact function accesses impact.Impacts and impact.GlobalMetrics without checking if impact is nil first. If someone calls EnrichWithImpact(r, nil), it'll panic right away on that first for-loop.

Either add a simple nil check at the start (like if impact == nil { return }), or add a comment explaining that the caller must always pass a non-nil api.ImpactResult. Also worth adding a test that documents the actual behavior—right now the test only covers the empty-but-valid case.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/factory/factory_test.go` around lines 1068 - 1077, EnrichWithImpact
currently dereferences impact.Impacts and impact.GlobalMetrics without guarding
for a nil impact; add a nil-check at the top of EnrichWithImpact (e.g., if
impact == nil { return }) so passing nil won't panic, and update/add a unit test
(e.g., extend TestEnrichWithImpact_EmptyImpact or add
TestEnrichWithImpact_NilImpact) to call EnrichWithImpact(r, nil) and assert the
HealthReport (Status and ImpactFiles) remains unchanged; alternatively, if you
prefer a contract, add a clear comment on EnrichWithImpact requiring non-nil
api.ImpactResult and ensure callers adhere to it, but prefer the nil-guard for
robustness.


func TestRenderHealth_ImpactSection(t *testing.T) {
r := &HealthReport{
ProjectName: "test",
Status: StatusDegraded,
ImpactFiles: []ImpactFile{
{Path: "src/core.ts", RiskScore: "critical", Direct: 100, Transitive: 200, Files: 15},
},
}
var buf bytes.Buffer
RenderHealth(&buf, r)
out := buf.String()
if !strings.Contains(out, "## Impact Analysis") {
t.Error("expected Impact Analysis section")
}
if !strings.Contains(out, "src/core.ts") {
t.Error("expected file path in impact table")
}
if !strings.Contains(out, "critical") {
t.Error("expected risk score in impact table")
}
}

func TestRenderHealth_NoImpactSection(t *testing.T) {
r := &HealthReport{ProjectName: "test", Status: StatusHealthy}
var buf bytes.Buffer
RenderHealth(&buf, r)
if strings.Contains(buf.String(), "## Impact Analysis") {
t.Error("should not render Impact Analysis when no impact files")
}
}
48 changes: 48 additions & 0 deletions internal/factory/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,39 @@ func Analyze(ir *api.SupermodelIR, projectName string) *HealthReport {
return r
}

// EnrichWithImpact adds impact analysis results to an existing HealthReport
// and re-scores status and recommendations.
func EnrichWithImpact(r *HealthReport, impact *api.ImpactResult) {
for i := range impact.Impacts {
imp := &impact.Impacts[i]
r.ImpactFiles = append(r.ImpactFiles, ImpactFile{
Path: imp.Target.File,
RiskScore: imp.BlastRadius.RiskScore,
Direct: imp.BlastRadius.DirectDependents,
Transitive: imp.BlastRadius.TransitiveDependents,
Files: imp.BlastRadius.AffectedFiles,
})
}
// Also pull in global critical files if the API returned them.
for i := range impact.GlobalMetrics.MostCriticalFiles {
cf := &impact.GlobalMetrics.MostCriticalFiles[i]
r.ImpactFiles = append(r.ImpactFiles, ImpactFile{
Path: cf.File,
Direct: cf.DependentCount,
})
}
// Cap to top 10 by direct dependents.
sort.Slice(r.ImpactFiles, func(i, j int) bool {
return r.ImpactFiles[i].Direct > r.ImpactFiles[j].Direct
})
if len(r.ImpactFiles) > 10 {
r.ImpactFiles = r.ImpactFiles[:10]
}
Comment on lines +42 to +70
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 | 🟡 Minor

Potential duplicate files if the same path appears in both sources.

So here's what's happening: you're pulling files from two places:

  1. impact.Impacts (line 43-51) — blast radius results
  2. impact.GlobalMetrics.MostCriticalFiles (line 54-60) — global critical files

If the same file path appears in both lists, you'll end up with duplicates in r.ImpactFiles. After sorting by Direct count, both entries could make it into the top 10, which might confuse users seeing the same file twice in the report.

Example scenario:

  • foo.go appears in Impacts with Direct=50
  • foo.go also appears in MostCriticalFiles with DependentCount=50
  • Both get added → duplicate rows in the rendered table

Consider deduplicating by path:

🔧 Suggested fix using a map
 func EnrichWithImpact(r *HealthReport, impact *api.ImpactResult) {
+	seen := make(map[string]bool)
 	for i := range impact.Impacts {
 		imp := &impact.Impacts[i]
+		if seen[imp.Target.File] {
+			continue
+		}
+		seen[imp.Target.File] = true
 		r.ImpactFiles = append(r.ImpactFiles, ImpactFile{
 			Path:       imp.Target.File,
 			RiskScore:  imp.BlastRadius.RiskScore,
 			Direct:     imp.BlastRadius.DirectDependents,
 			Transitive: imp.BlastRadius.TransitiveDependents,
 			Files:      imp.BlastRadius.AffectedFiles,
 		})
 	}
 	// Also pull in global critical files if the API returned them.
 	for i := range impact.GlobalMetrics.MostCriticalFiles {
 		cf := &impact.GlobalMetrics.MostCriticalFiles[i]
+		if seen[cf.File] {
+			continue
+		}
+		seen[cf.File] = true
 		r.ImpactFiles = append(r.ImpactFiles, ImpactFile{
 			Path:   cf.File,
 			Direct: cf.DependentCount,
 		})
 	}

// Re-score and regenerate recommendations with impact data.
r.Status = scoreStatus(r)
r.Recommendations = generateRecommendations(r)
}

// ── helpers ───────────────────────────────────────────────────────────────────

func buildExternalDeps(ir *api.SupermodelIR) []string {
Expand Down Expand Up @@ -147,6 +180,11 @@ func scoreStatus(r *HealthReport) HealthStatus {
if r.CircularDeps > 0 {
return StatusCritical
}
for i := range r.ImpactFiles {
if r.ImpactFiles[i].RiskScore == "critical" {
return StatusDegraded
}
}
for i := range r.Domains {
if len(r.Domains[i].IncomingDeps) >= 5 {
return StatusDegraded
Expand Down Expand Up @@ -207,6 +245,16 @@ func generateRecommendations(r *HealthReport) []Recommendation {
}
}

for i := range r.ImpactFiles {
f := &r.ImpactFiles[i]
if f.RiskScore == "critical" {
recs = append(recs, Recommendation{
Priority: 1,
Message: fmt.Sprintf("File %q has critical blast radius (%d direct, %d transitive dependents) — changes here affect %d files.", f.Path, f.Direct, f.Transitive, f.Files),
})
}
}

sort.Slice(recs, func(i, j int) bool { return recs[i].Priority < recs[j].Priority })
return recs
}
Expand Down
21 changes: 21 additions & 0 deletions internal/factory/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ func RenderHealth(w io.Writer, r *HealthReport) {
}
}

// Impact analysis
renderImpactSection(w, r)

// Domain health
if len(r.Domains) > 0 {
fmt.Fprint(w, "\n## Domain Health\n\n")
Expand Down Expand Up @@ -109,6 +112,24 @@ func RenderHealth(w io.Writer, r *HealthReport) {
fmt.Fprintln(w, "*Generated by [supermodel factory](https://supermodeltools.com)*")
}

// renderImpactSection writes the impact analysis table if data is available.
func renderImpactSection(w io.Writer, r *HealthReport) {
if len(r.ImpactFiles) == 0 {
return
}
fmt.Fprint(w, "\n## Impact Analysis\n\n")
fmt.Fprintln(w, "| File | Risk | Direct | Transitive | Affected Files |")
fmt.Fprintln(w, "|------|------|--------|------------|----------------|")
for i := range r.ImpactFiles {
f := &r.ImpactFiles[i]
risk := f.RiskScore
if risk == "" {
risk = "-"
}
fmt.Fprintf(w, "| %s | %s | %d | %d | %d |\n", f.Path, risk, f.Direct, f.Transitive, f.Files)
}
}

// RenderRunPrompt writes a graph-enriched SDLC execution prompt to w.
// The AI agent receiving this output should follow the phases sequentially.
func RenderRunPrompt(w io.Writer, d *SDLCPromptData) {
Expand Down
14 changes: 13 additions & 1 deletion internal/factory/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ type HealthReport struct {
// Per-domain health
Domains []DomainHealth

// Highest blast-radius files
// Highest blast-radius files (from domain key file overlap)
CriticalFiles []CriticalFile

// Impact analysis results (from /v1/analysis/impact)
ImpactFiles []ImpactFile

// Prioritised action items
Recommendations []Recommendation
}
Expand Down Expand Up @@ -71,6 +74,15 @@ type CriticalFile struct {
RelationshipCount int
}

// ImpactFile is a file with its blast radius risk from impact analysis.
type ImpactFile struct {
Path string
RiskScore string
Direct int
Transitive int
Files int
}

// Recommendation is a prioritised actionable finding.
type Recommendation struct {
// Priority: 1=critical, 2=high, 3=medium.
Expand Down
Loading