diff --git a/cmd/audit.go b/cmd/audit.go index df9515d..a0dcd7d 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -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" ) @@ -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], "", "") +} diff --git a/internal/factory/factory_test.go b/internal/factory/factory_test.go index 15a1d6e..4eea504 100644 --- a/internal/factory/factory_test.go +++ b/internal/factory/factory_test.go @@ -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)) + } +} + +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") + } +} diff --git a/internal/factory/health.go b/internal/factory/health.go index e4f6168..d373112 100644 --- a/internal/factory/health.go +++ b/internal/factory/health.go @@ -37,6 +37,42 @@ 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) { + if impact == nil { + return + } + 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] + } + // Re-score and regenerate recommendations with impact data. + r.Status = scoreStatus(r) + r.Recommendations = generateRecommendations(r) +} + // ── helpers ─────────────────────────────────────────────────────────────────── func buildExternalDeps(ir *api.SupermodelIR) []string { @@ -147,6 +183,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 @@ -207,6 +248,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 } diff --git a/internal/factory/render.go b/internal/factory/render.go index cb28e27..d859f64 100644 --- a/internal/factory/render.go +++ b/internal/factory/render.go @@ -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") @@ -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) { diff --git a/internal/factory/types.go b/internal/factory/types.go index 0f965e8..d0154c7 100644 --- a/internal/factory/types.go +++ b/internal/factory/types.go @@ -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 } @@ -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.