diff --git a/cmd/audit.go b/cmd/audit.go index a0dcd7d..aadb895 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -4,14 +4,15 @@ import ( "context" "fmt" "os" + "path/filepath" "time" "github.com/spf13/cobra" "github.com/supermodeltools/cli/internal/api" + "github.com/supermodeltools/cli/internal/audit" "github.com/supermodeltools/cli/internal/cache" "github.com/supermodeltools/cli/internal/config" - "github.com/supermodeltools/cli/internal/factory" ) func init() { @@ -29,9 +30,6 @@ Markdown health report covering: - High blast-radius files - Prioritised recommendations -The report is also used internally by 'supermodel factory run' and -'supermodel factory improve' as the Phase 8 health gate. - Example: supermodel audit @@ -46,41 +44,80 @@ Example: rootCmd.AddCommand(c) } -// runAudit is the shared implementation used by both 'supermodel audit' and -// 'supermodel factory health'. func runAudit(cmd *cobra.Command, dir string) error { - rootDir, projectName, err := resolveFactoryDir(dir) + rootDir, projectName, err := resolveAuditDir(dir) if err != nil { return err } - ir, err := factoryAnalyze(cmd, rootDir, projectName) + ir, err := auditAnalyze(cmd, rootDir, projectName) if err != nil { return err } - report := factory.Analyze(ir, projectName) + report := audit.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) + audit.EnrichWithImpact(report, impact) } - factory.RenderHealth(cmd.OutOrStdout(), report) + audit.RenderHealth(cmd.OutOrStdout(), report) return nil } -// runImpactForAudit runs global impact analysis for the audit report. +func resolveAuditDir(dir string) (rootDir, projectName string, err error) { + if dir == "" { + dir, err = os.Getwd() + if err != nil { + return "", "", fmt.Errorf("get working directory: %w", err) + } + } + rootDir = findGitRoot(dir) + projectName = filepath.Base(rootDir) + return rootDir, projectName, nil +} + +func auditAnalyze(cmd *cobra.Command, rootDir, projectName string) (*api.SupermodelIR, error) { + cfg, err := config.Load() + if err != nil { + return nil, err + } + if err := cfg.RequireAPIKey(); err != nil { + return nil, err + } + + fmt.Fprintln(cmd.ErrOrStderr(), "Creating repository archive…") + zipPath, err := audit.CreateZip(rootDir) + if err != nil { + return nil, fmt.Errorf("create archive: %w", err) + } + defer func() { _ = os.Remove(zipPath) }() + + hash, err := cache.HashFile(zipPath) + if err != nil { + return nil, fmt.Errorf("hash archive: %w", err) + } + + client := api.New(cfg) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + fmt.Fprintf(cmd.ErrOrStderr(), "Analyzing %s…\n", projectName) + return client.AnalyzeDomains(ctx, zipPath, "audit-"+hash[:16]) +} + +// runImpactForAudit runs global impact analysis to enrich the health 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) + zipPath, err := audit.CreateZip(rootDir) if err != nil { return nil, err } diff --git a/cmd/factory.go b/cmd/factory.go deleted file mode 100644 index 0b98542..0000000 --- a/cmd/factory.go +++ /dev/null @@ -1,215 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - "path/filepath" - "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" -) - -func init() { - factoryCmd := &cobra.Command{ - Use: "factory", - Short: "AI-native SDLC orchestration via graph intelligence", - Long: `Factory is an AI-native SDLC system that uses the Supermodel code graph API -to power graph-first development workflows. - -Inspired by Big Iron (github.com/supermodeltools/bigiron), factory provides -three commands: - - health — Analyse codebase health: circular deps, domain coupling, blast radius - run — Generate a graph-enriched 8-phase SDLC execution plan for a goal - improve — Generate a prioritised, graph-driven improvement plan - -All commands require an API key (run 'supermodel login' to configure). - -Examples: - - supermodel factory health - supermodel factory run "Add rate limiting to the order API" - supermodel factory improve`, - SilenceUsage: true, - } - - // ── health ──────────────────────────────────────────────────────────────── - var healthDir string - healthCmd := &cobra.Command{ - Use: "health", - Short: "Alias for 'supermodel audit'", - Long: "Health is an alias for 'supermodel audit'. See 'supermodel audit --help' for full documentation.", - RunE: func(cmd *cobra.Command, _ []string) error { - return runAudit(cmd, healthDir) - }, - SilenceUsage: true, - } - healthCmd.Flags().StringVar(&healthDir, "dir", "", "project directory (default: current working directory)") - - // ── run ─────────────────────────────────────────────────────────────────── - var runDir string - runCmd := &cobra.Command{ - Use: "run ", - Short: "Generate a graph-enriched SDLC execution plan for a goal", - Long: `Run analyses the codebase and generates a graph-enriched 8-phase SDLC -execution plan tailored to the supplied goal. - -The output is a Markdown prompt designed to be consumed by Claude Code or any -AI agent. Pipe it directly into an agent session: - - supermodel factory run "Add rate limiting to the order API" | claude --print - -The plan includes codebase context (domains, key files, tech stack), the goal, -and phase-by-phase instructions with graph-aware quality gates.`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runFactoryRun(cmd, runDir, args[0]) - }, - SilenceUsage: true, - } - runCmd.Flags().StringVar(&runDir, "dir", "", "project directory (default: current working directory)") - - // ── improve ─────────────────────────────────────────────────────────────── - var improveDir string - improveCmd := &cobra.Command{ - Use: "improve", - Short: "Generate a graph-driven improvement plan", - Long: `Improve runs a health analysis and generates a prioritised improvement plan -using the Supermodel code graph. - -The output is a Markdown prompt that guides an AI agent through: - - 1. Scoring improvement targets (circular deps, coupling, dead code, depth) - 2. Executing refactors in bottom-up topological order - 3. Running quality gates after each change - 4. A final dead code sweep and health check - -Pipe it into an agent session: - - supermodel factory improve | claude --print`, - RunE: func(cmd *cobra.Command, _ []string) error { - return runFactoryImprove(cmd, improveDir) - }, - SilenceUsage: true, - } - improveCmd.Flags().StringVar(&improveDir, "dir", "", "project directory (default: current working directory)") - - factoryCmd.AddCommand(healthCmd, runCmd, improveCmd) - rootCmd.AddCommand(factoryCmd) -} - -// ── run ─────────────────────────────────────────────────────────────────────── - -func runFactoryRun(cmd *cobra.Command, dir, goal string) error { - rootDir, projectName, err := resolveFactoryDir(dir) - if err != nil { - return err - } - - ir, err := factoryAnalyze(cmd, rootDir, projectName) - if err != nil { - return err - } - - report := factory.Analyze(ir, projectName) - data := factoryPromptData(report, goal) - factory.RenderRunPrompt(cmd.OutOrStdout(), data) - return nil -} - -// ── improve ─────────────────────────────────────────────────────────────────── - -func runFactoryImprove(cmd *cobra.Command, dir string) error { - rootDir, projectName, err := resolveFactoryDir(dir) - if err != nil { - return err - } - - ir, err := factoryAnalyze(cmd, rootDir, projectName) - if err != nil { - return err - } - - report := factory.Analyze(ir, projectName) - data := factoryPromptData(report, "") - factory.RenderImprovePrompt(cmd.OutOrStdout(), data) - return nil -} - -// ── shared helpers ──────────────────────────────────────────────────────────── - -func resolveFactoryDir(dir string) (rootDir, projectName string, err error) { - if dir == "" { - dir, err = os.Getwd() - if err != nil { - return "", "", fmt.Errorf("get working directory: %w", err) - } - } - rootDir = findGitRoot(dir) - projectName = filepath.Base(rootDir) - return rootDir, projectName, nil -} - -func factoryAnalyze(cmd *cobra.Command, rootDir, projectName string) (*api.SupermodelIR, error) { - cfg, err := config.Load() - if err != nil { - return nil, err - } - if cfg.APIKey == "" { - return nil, fmt.Errorf("no API key configured — run 'supermodel login' first") - } - - fmt.Fprintln(cmd.ErrOrStderr(), "Creating repository archive…") - zipPath, err := factory.CreateZip(rootDir) - if err != nil { - return nil, fmt.Errorf("create archive: %w", err) - } - defer func() { _ = os.Remove(zipPath) }() - - hash, err := cache.HashFile(zipPath) - if err != nil { - return nil, fmt.Errorf("hash archive: %w", err) - } - - client := api.New(cfg) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer cancel() - - fmt.Fprintf(cmd.ErrOrStderr(), "Analyzing %s…\n", projectName) - ir, err := client.AnalyzeDomains(ctx, zipPath, "factory-"+hash[:16]) - if err != nil { - return nil, err - } - return ir, nil -} - -func factoryPromptData(report *factory.HealthReport, goal string) *factory.SDLCPromptData { - domains := make([]factory.DomainHealth, len(report.Domains)) - copy(domains, report.Domains) - - criticalFiles := make([]factory.CriticalFile, len(report.CriticalFiles)) - copy(criticalFiles, report.CriticalFiles) - - data := &factory.SDLCPromptData{ - ProjectName: report.ProjectName, - Language: report.Language, - TotalFiles: report.TotalFiles, - TotalFunctions: report.TotalFunctions, - ExternalDeps: report.ExternalDeps, - Domains: domains, - CriticalFiles: criticalFiles, - CircularDeps: report.CircularDeps, - Goal: goal, - GeneratedAt: report.AnalyzedAt.Format("2006-01-02 15:04:05 UTC"), - } - if goal == "" { - data.HealthReport = report - } - return data -} diff --git a/internal/factory/health.go b/internal/audit/health.go similarity index 99% rename from internal/factory/health.go rename to internal/audit/health.go index d373112..f5f6e46 100644 --- a/internal/factory/health.go +++ b/internal/audit/health.go @@ -1,4 +1,4 @@ -package factory +package audit import ( "fmt" diff --git a/internal/factory/render.go b/internal/audit/render.go similarity index 99% rename from internal/factory/render.go rename to internal/audit/render.go index d859f64..bb812cb 100644 --- a/internal/factory/render.go +++ b/internal/audit/render.go @@ -1,4 +1,4 @@ -package factory +package audit import ( "fmt" diff --git a/internal/factory/types.go b/internal/audit/types.go similarity index 99% rename from internal/factory/types.go rename to internal/audit/types.go index d0154c7..89f40dd 100644 --- a/internal/factory/types.go +++ b/internal/audit/types.go @@ -1,4 +1,4 @@ -package factory +package audit import "time" diff --git a/internal/factory/zip.go b/internal/audit/zip.go similarity index 99% rename from internal/factory/zip.go rename to internal/audit/zip.go index 32eed79..678378f 100644 --- a/internal/factory/zip.go +++ b/internal/audit/zip.go @@ -1,4 +1,4 @@ -package factory +package audit import ( "archive/zip" diff --git a/internal/factory/doc.go b/internal/factory/doc.go deleted file mode 100644 index c968a75..0000000 --- a/internal/factory/doc.go +++ /dev/null @@ -1,13 +0,0 @@ -// Package factory implements the supermodel factory command: an AI-native -// SDLC orchestration system that uses the Supermodel code graph API to -// provide health analysis, graph-enriched execution plans, and prioritised -// improvement prompts. -// -// Three sub-commands are exposed: -// -// - health — analyse codebase health (circular deps, coupling, blast radius) -// - run — generate a graph-enriched 8-phase SDLC prompt for a given goal -// - improve — generate a prioritised improvement plan from health data -// -// The design is inspired by the Big Iron project (github.com/supermodeltools/bigiron). -package factory diff --git a/internal/factory/factory_test.go b/internal/factory/factory_test.go deleted file mode 100644 index 4eea504..0000000 --- a/internal/factory/factory_test.go +++ /dev/null @@ -1,1108 +0,0 @@ -package factory - -import ( - "bytes" - "fmt" - "strings" - "testing" - - "github.com/supermodeltools/cli/internal/api" -) - -// ── summaryInt ──────────────────────────────────────────────────────────────── - -func TestSummaryInt_MissingKey(t *testing.T) { - if got := summaryInt(map[string]any{}, "files"); got != 0 { - t.Errorf("missing key: want 0, got %d", got) - } -} - -func TestSummaryInt_Float64(t *testing.T) { - m := map[string]any{"files": float64(42)} - if got := summaryInt(m, "files"); got != 42 { - t.Errorf("float64: want 42, got %d", got) - } -} - -func TestSummaryInt_WrongType(t *testing.T) { - m := map[string]any{"files": "42"} // string, not float64 - if got := summaryInt(m, "files"); got != 0 { - t.Errorf("wrong type: want 0, got %d", got) - } -} - -func TestSummaryInt_NilMap(t *testing.T) { - if got := summaryInt(nil, "files"); got != 0 { - t.Errorf("nil map: want 0, got %d", got) - } -} - -// ── pluralf ─────────────────────────────────────────────────────────────────── - -func TestPluralf_One(t *testing.T) { - got := pluralf("Resolve %d cycle%s.", 1) - if got != "Resolve 1 cycle." { - t.Errorf("n=1: got %q", got) - } -} - -func TestPluralf_Many(t *testing.T) { - got := pluralf("Resolve %d cycle%s.", 3) - if got != "Resolve 3 cycles." { - t.Errorf("n=3: got %q", got) - } -} - -// ── buildExternalDeps ───────────────────────────────────────────────────────── - -func TestBuildExternalDeps_Empty(t *testing.T) { - ir := &api.SupermodelIR{} - if deps := buildExternalDeps(ir); len(deps) != 0 { - t.Errorf("empty IR: want [], got %v", deps) - } -} - -func TestBuildExternalDeps_Sorted(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Nodes: []api.IRNode{ - {Type: "ExternalDependency", Name: "zlib"}, - {Type: "ExternalDependency", Name: "axios"}, - {Type: "ExternalDependency", Name: "cobra"}, - }}, - } - deps := buildExternalDeps(ir) - if len(deps) != 3 { - t.Fatalf("want 3, got %d: %v", len(deps), deps) - } - if deps[0] != "axios" || deps[1] != "cobra" || deps[2] != "zlib" { - t.Errorf("not sorted: %v", deps) - } -} - -func TestBuildExternalDeps_Dedup(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Nodes: []api.IRNode{ - {Type: "ExternalDependency", Name: "cobra"}, - {Type: "ExternalDependency", Name: "cobra"}, - }}, - } - deps := buildExternalDeps(ir) - if len(deps) != 1 { - t.Errorf("dedup: want 1, got %d: %v", len(deps), deps) - } -} - -func TestBuildExternalDeps_IgnoresNonExternal(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Nodes: []api.IRNode{ - {Type: "Domain", Name: "auth"}, - {Type: "Function", Name: "handleLogin"}, - {Type: "ExternalDependency", Name: "cobra"}, - }}, - } - deps := buildExternalDeps(ir) - if len(deps) != 1 || deps[0] != "cobra" { - t.Errorf("want [cobra], got %v", deps) - } -} - -func TestBuildExternalDeps_IgnoresEmptyName(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Nodes: []api.IRNode{ - {Type: "ExternalDependency", Name: ""}, - {Type: "ExternalDependency", Name: "cobra"}, - }}, - } - deps := buildExternalDeps(ir) - if len(deps) != 1 || deps[0] != "cobra" { - t.Errorf("want [cobra], got %v", deps) - } -} - -// ── buildCouplingMaps ───────────────────────────────────────────────────────── - -func TestBuildCouplingMaps_Empty(t *testing.T) { - ir := &api.SupermodelIR{} - incoming, outgoing := buildCouplingMaps(ir) - if len(incoming) != 0 || len(outgoing) != 0 { - t.Error("empty IR: expected empty maps") - } -} - -func TestBuildCouplingMaps_IgnoresNonDomainRelates(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Relationships: []api.IRRelationship{ - {Type: "IMPORTS", Source: "a", Target: "b"}, - {Type: "CIRCULAR_DEPENDENCY", Source: "c", Target: "d"}, - }}, - } - incoming, outgoing := buildCouplingMaps(ir) - if len(incoming) != 0 || len(outgoing) != 0 { - t.Error("non-DOMAIN_RELATES edges should be ignored") - } -} - -func TestBuildCouplingMaps_SingleEdge(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Relationships: []api.IRRelationship{ - {Type: "DOMAIN_RELATES", Source: "auth", Target: "api"}, - }}, - } - incoming, outgoing := buildCouplingMaps(ir) - if len(outgoing["auth"]) != 1 || outgoing["auth"][0] != "api" { - t.Errorf("outgoing auth: want [api], got %v", outgoing["auth"]) - } - if len(incoming["api"]) != 1 || incoming["api"][0] != "auth" { - t.Errorf("incoming api: want [auth], got %v", incoming["api"]) - } -} - -func TestBuildCouplingMaps_DedupDuplicateEdges(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Relationships: []api.IRRelationship{ - {Type: "DOMAIN_RELATES", Source: "auth", Target: "api"}, - {Type: "DOMAIN_RELATES", Source: "auth", Target: "api"}, - {Type: "DOMAIN_RELATES", Source: "auth", Target: "api"}, - }}, - } - incoming, outgoing := buildCouplingMaps(ir) - if len(outgoing["auth"]) != 1 { - t.Errorf("dedup failed: outgoing auth %v", outgoing["auth"]) - } - if len(incoming["api"]) != 1 { - t.Errorf("dedup failed: incoming api %v", incoming["api"]) - } -} - -func TestBuildCouplingMaps_MultipleEdges(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Relationships: []api.IRRelationship{ - {Type: "DOMAIN_RELATES", Source: "auth", Target: "api"}, - {Type: "DOMAIN_RELATES", Source: "billing", Target: "api"}, - {Type: "DOMAIN_RELATES", Source: "auth", Target: "storage"}, - }}, - } - incoming, outgoing := buildCouplingMaps(ir) - if len(incoming["api"]) != 2 { - t.Errorf("api should have 2 incoming, got %v", incoming["api"]) - } - if len(outgoing["auth"]) != 2 { - t.Errorf("auth should have 2 outgoing, got %v", outgoing["auth"]) - } -} - -func TestBuildCouplingMaps_IgnoresEmptySourceTarget(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Relationships: []api.IRRelationship{ - {Type: "DOMAIN_RELATES", Source: "", Target: "api"}, - {Type: "DOMAIN_RELATES", Source: "auth", Target: ""}, - }}, - } - incoming, outgoing := buildCouplingMaps(ir) - if len(incoming) != 0 || len(outgoing) != 0 { - t.Error("empty source/target should be ignored") - } -} - -// ── buildCriticalFiles ──────────────────────────────────────────────────────── - -func TestBuildCriticalFiles_Empty(t *testing.T) { - ir := &api.SupermodelIR{} - if files := buildCriticalFiles(ir); len(files) != 0 { - t.Errorf("empty IR: want [], got %v", files) - } -} - -func TestBuildCriticalFiles_SingleDomainNoCritical(t *testing.T) { - ir := &api.SupermodelIR{ - Domains: []api.IRDomain{ - {Name: "auth", KeyFiles: []string{"auth/handler.go", "auth/service.go"}}, - }, - } - if files := buildCriticalFiles(ir); len(files) != 0 { - t.Errorf("single domain should produce no critical files, got %v", files) - } -} - -func TestBuildCriticalFiles_CrossDomain(t *testing.T) { - ir := &api.SupermodelIR{ - Domains: []api.IRDomain{ - {Name: "auth", KeyFiles: []string{"internal/api/client.go", "auth/handler.go"}}, - {Name: "billing", KeyFiles: []string{"internal/api/client.go", "billing/service.go"}}, - }, - } - files := buildCriticalFiles(ir) - if len(files) != 1 { - t.Fatalf("want 1 critical file, got %d: %v", len(files), files) - } - if files[0].Path != "internal/api/client.go" { - t.Errorf("want internal/api/client.go, got %q", files[0].Path) - } - if files[0].RelationshipCount != 2 { - t.Errorf("want count=2, got %d", files[0].RelationshipCount) - } -} - -func TestBuildCriticalFiles_SortedByCountDescThenPathAsc(t *testing.T) { - ir := &api.SupermodelIR{ - Domains: []api.IRDomain{ - {Name: "d1", KeyFiles: []string{"shared.go", "common.go"}}, - {Name: "d2", KeyFiles: []string{"shared.go", "common.go"}}, - {Name: "d3", KeyFiles: []string{"shared.go"}}, - }, - } - files := buildCriticalFiles(ir) - if len(files) != 2 { - t.Fatalf("want 2, got %d: %v", len(files), files) - } - // shared.go in 3 domains, common.go in 2 - if files[0].Path != "shared.go" || files[0].RelationshipCount != 3 { - t.Errorf("first should be shared.go×3, got %+v", files[0]) - } - if files[1].Path != "common.go" || files[1].RelationshipCount != 2 { - t.Errorf("second should be common.go×2, got %+v", files[1]) - } -} - -func TestBuildCriticalFiles_SortedAlphaOnTie(t *testing.T) { - // Both files referenced by exactly 2 domains — sort by path asc - ir := &api.SupermodelIR{ - Domains: []api.IRDomain{ - {Name: "d1", KeyFiles: []string{"z_file.go", "a_file.go"}}, - {Name: "d2", KeyFiles: []string{"z_file.go", "a_file.go"}}, - }, - } - files := buildCriticalFiles(ir) - if len(files) != 2 { - t.Fatalf("want 2, got %d", len(files)) - } - if files[0].Path != "a_file.go" { - t.Errorf("alpha tie: first should be a_file.go, got %q", files[0].Path) - } -} - -func TestBuildCriticalFiles_CapAt10(t *testing.T) { - // 12 files each referenced by exactly 2 domains - doms := make([]api.IRDomain, 24) - for i := 0; i < 12; i++ { - f := fmt.Sprintf("shared%02d.go", i) - doms[i*2] = api.IRDomain{Name: fmt.Sprintf("a%d", i), KeyFiles: []string{f}} - doms[i*2+1] = api.IRDomain{Name: fmt.Sprintf("b%d", i), KeyFiles: []string{f}} - } - files := buildCriticalFiles(&api.SupermodelIR{Domains: doms}) - if len(files) != 10 { - t.Errorf("cap at 10: want 10, got %d", len(files)) - } -} - -func TestBuildCriticalFiles_DedupWithinDomain(t *testing.T) { - // Same file listed twice in one domain — should count as 1 - ir := &api.SupermodelIR{ - Domains: []api.IRDomain{ - {Name: "auth", KeyFiles: []string{"shared.go", "shared.go"}}, - {Name: "billing", KeyFiles: []string{"shared.go"}}, - }, - } - files := buildCriticalFiles(ir) - if len(files) != 1 { - t.Fatalf("want 1 critical file, got %d: %v", len(files), files) - } - if files[0].RelationshipCount != 2 { - t.Errorf("dedup within domain: want count=2, got %d", files[0].RelationshipCount) - } -} - -// ── buildDomainHealthList ───────────────────────────────────────────────────── - -func TestBuildDomainHealthList_Empty(t *testing.T) { - ir := &api.SupermodelIR{} - domains := buildDomainHealthList(ir, map[string][]string{}, map[string][]string{}) - if len(domains) != 0 { - t.Errorf("want [], got %v", domains) - } -} - -func TestBuildDomainHealthList_Basic(t *testing.T) { - ir := &api.SupermodelIR{ - Domains: []api.IRDomain{ - { - Name: "Authentication", - DescriptionSummary: "Handles auth flows", - KeyFiles: []string{"auth/handler.go", "auth/service.go"}, - Responsibilities: []string{"login", "logout", "refresh"}, - Subdomains: []api.IRSubdomain{ - {Name: "OAuth"}, - }, - }, - }, - } - incoming := map[string][]string{"Authentication": {"billing", "api"}} - outgoing := map[string][]string{"Authentication": {"storage"}} - - domains := buildDomainHealthList(ir, incoming, outgoing) - if len(domains) != 1 { - t.Fatalf("want 1 domain, got %d", len(domains)) - } - d := domains[0] - if d.Name != "Authentication" { - t.Errorf("name: got %q", d.Name) - } - if d.Description != "Handles auth flows" { - t.Errorf("description: got %q", d.Description) - } - if d.KeyFileCount != 2 { - t.Errorf("key file count: want 2, got %d", d.KeyFileCount) - } - if d.Responsibilities != 3 { - t.Errorf("responsibilities: want 3, got %d", d.Responsibilities) - } - if d.Subdomains != 1 { - t.Errorf("subdomains: want 1, got %d", d.Subdomains) - } -} - -func TestBuildDomainHealthList_IncomingOutgoingSorted(t *testing.T) { - ir := &api.SupermodelIR{ - Domains: []api.IRDomain{{Name: "api"}}, - } - incoming := map[string][]string{"api": {"zebra", "alpha", "mango"}} - outgoing := map[string][]string{"api": {"zz", "aa"}} - - domains := buildDomainHealthList(ir, incoming, outgoing) - d := domains[0] - if d.IncomingDeps[0] != "alpha" || d.IncomingDeps[1] != "mango" || d.IncomingDeps[2] != "zebra" { - t.Errorf("incoming not sorted: %v", d.IncomingDeps) - } - if d.OutgoingDeps[0] != "aa" || d.OutgoingDeps[1] != "zz" { - t.Errorf("outgoing not sorted: %v", d.OutgoingDeps) - } -} - -func TestBuildDomainHealthList_NoDepsForDomain(t *testing.T) { - ir := &api.SupermodelIR{ - Domains: []api.IRDomain{{Name: "isolated"}}, - } - domains := buildDomainHealthList(ir, map[string][]string{}, map[string][]string{}) - if len(domains[0].IncomingDeps) != 0 || len(domains[0].OutgoingDeps) != 0 { - t.Errorf("isolated domain should have no deps, got in=%v out=%v", - domains[0].IncomingDeps, domains[0].OutgoingDeps) - } -} - -// ── detectCircularDeps ──────────────────────────────────────────────────────── - -func TestDetectCircularDeps_Empty(t *testing.T) { - count, cycles := detectCircularDeps(&api.SupermodelIR{}) - if count != 0 || len(cycles) != 0 { - t.Errorf("empty: want count=0, got count=%d cycles=%v", count, cycles) - } -} - -func TestDetectCircularDeps_TypeCIRCULAR_DEPENDENCY(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Relationships: []api.IRRelationship{ - {Type: "CIRCULAR_DEPENDENCY", Source: "auth", Target: "billing"}, - }}, - } - count, cycles := detectCircularDeps(ir) - if count != 1 { - t.Errorf("want 1, got %d", count) - } - if cycles[0][0] != "auth" || cycles[0][1] != "billing" { - t.Errorf("unexpected cycle: %v", cycles[0]) - } -} - -func TestDetectCircularDeps_TypeCIRCULAR_DEP(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Relationships: []api.IRRelationship{ - {Type: "CIRCULAR_DEP", Source: "a", Target: "b"}, - }}, - } - count, _ := detectCircularDeps(ir) - if count != 1 { - t.Errorf("CIRCULAR_DEP should be detected, got count=%d", count) - } -} - -func TestDetectCircularDeps_BothTypes(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Relationships: []api.IRRelationship{ - {Type: "CIRCULAR_DEPENDENCY", Source: "a", Target: "b"}, - {Type: "CIRCULAR_DEP", Source: "c", Target: "d"}, - {Type: "IMPORTS", Source: "e", Target: "f"}, - }}, - } - count, _ := detectCircularDeps(ir) - if count != 2 { - t.Errorf("want 2, got %d", count) - } -} - -func TestDetectCircularDeps_Dedup(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Relationships: []api.IRRelationship{ - {Type: "CIRCULAR_DEPENDENCY", Source: "auth", Target: "billing"}, - {Type: "CIRCULAR_DEPENDENCY", Source: "auth", Target: "billing"}, - }}, - } - count, _ := detectCircularDeps(ir) - if count != 1 { - t.Errorf("dedup: want 1, got %d", count) - } -} - -func TestDetectCircularDeps_IgnoresNonCircular(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Relationships: []api.IRRelationship{ - {Type: "DOMAIN_RELATES", Source: "auth", Target: "api"}, - {Type: "IMPORTS", Source: "x", Target: "y"}, - }}, - } - count, _ := detectCircularDeps(ir) - if count != 0 { - t.Errorf("non-circular types should be ignored, got %d", count) - } -} - -// ── scoreStatus ─────────────────────────────────────────────────────────────── - -func TestScoreStatus_Healthy(t *testing.T) { - r := &HealthReport{ - CircularDeps: 0, - Domains: []DomainHealth{{IncomingDeps: []string{"a", "b"}}}, - } - if got := scoreStatus(r); got != StatusHealthy { - t.Errorf("want HEALTHY, got %s", got) - } -} - -func TestScoreStatus_CriticalOnCircularDeps(t *testing.T) { - r := &HealthReport{CircularDeps: 1} - if got := scoreStatus(r); got != StatusCritical { - t.Errorf("want CRITICAL, got %s", got) - } -} - -func TestScoreStatus_DegradedOnHighIncoming(t *testing.T) { - r := &HealthReport{ - CircularDeps: 0, - Domains: []DomainHealth{{IncomingDeps: []string{"a", "b", "c", "d", "e"}}}, - } - if got := scoreStatus(r); got != StatusDegraded { - t.Errorf("want DEGRADED for 5 incoming, got %s", got) - } -} - -func TestScoreStatus_FourIncomingIsHealthy(t *testing.T) { - r := &HealthReport{ - Domains: []DomainHealth{{IncomingDeps: []string{"a", "b", "c", "d"}}}, - } - if got := scoreStatus(r); got != StatusHealthy { - t.Errorf("4 incoming should still be HEALTHY, got %s", got) - } -} - -func TestScoreStatus_CriticalBeatsDegraded(t *testing.T) { - r := &HealthReport{ - CircularDeps: 1, - Domains: []DomainHealth{{IncomingDeps: []string{"a", "b", "c", "d", "e"}}}, - } - if got := scoreStatus(r); got != StatusCritical { - t.Errorf("CRITICAL should beat DEGRADED, got %s", got) - } -} - -// ── generateRecommendations ─────────────────────────────────────────────────── - -func TestGenerateRecommendations_Empty(t *testing.T) { - if recs := generateRecommendations(&HealthReport{}); len(recs) != 0 { - t.Errorf("clean report: want no recs, got %v", recs) - } -} - -func TestGenerateRecommendations_CircularDeps(t *testing.T) { - r := &HealthReport{CircularDeps: 3} - recs := generateRecommendations(r) - if len(recs) != 1 { - t.Fatalf("want 1 rec, got %d", len(recs)) - } - if recs[0].Priority != 1 { - t.Errorf("circular dep rec should be priority 1, got %d", recs[0].Priority) - } - if !strings.Contains(recs[0].Message, "3") { - t.Errorf("message should mention count 3: %q", recs[0].Message) - } -} - -func TestGenerateRecommendations_CircularDepsSingular(t *testing.T) { - r := &HealthReport{CircularDeps: 1} - recs := generateRecommendations(r) - // Pluralisation: "cycle" not "cycles" - if !strings.Contains(recs[0].Message, "cycle") { - t.Errorf("singular message should say 'cycle': %q", recs[0].Message) - } - if strings.Contains(recs[0].Message, "cycles") { - t.Errorf("singular message should not say 'cycles': %q", recs[0].Message) - } -} - -func TestGenerateRecommendations_HighCouplingThreshold(t *testing.T) { - // 2 incoming → below threshold, no rec - r := &HealthReport{ - Domains: []DomainHealth{{Name: "api", KeyFileCount: 1, IncomingDeps: []string{"a", "b"}}}, - } - if recs := generateRecommendations(r); len(recs) != 0 { - t.Errorf("2 incoming should not trigger rec, got %v", recs) - } -} - -func TestGenerateRecommendations_HighCoupling(t *testing.T) { - r := &HealthReport{ - Domains: []DomainHealth{ - {Name: "api", KeyFileCount: 1, IncomingDeps: []string{"a", "b", "c"}}, // 3 >= threshold - }, - } - recs := generateRecommendations(r) - if len(recs) != 1 || recs[0].Priority != 2 { - t.Fatalf("want 1 priority-2 rec, got %v", recs) - } - if !strings.Contains(recs[0].Message, "api") { - t.Errorf("should mention domain name: %q", recs[0].Message) - } -} - -func TestGenerateRecommendations_NoKeyFiles(t *testing.T) { - r := &HealthReport{ - Domains: []DomainHealth{{Name: "orphan", KeyFileCount: 0}}, - } - recs := generateRecommendations(r) - if len(recs) != 1 || recs[0].Priority != 3 { - t.Fatalf("want 1 priority-3 rec, got %v", recs) - } - if !strings.Contains(recs[0].Message, "orphan") { - t.Errorf("should mention domain name: %q", recs[0].Message) - } -} - -func TestGenerateRecommendations_BlastRadius(t *testing.T) { - r := &HealthReport{ - CriticalFiles: []CriticalFile{ - {Path: "shared.go", RelationshipCount: 4}, - }, - } - recs := generateRecommendations(r) - if len(recs) != 1 || recs[0].Priority != 2 { - t.Fatalf("want 1 priority-2 rec for blast radius, got %v", recs) - } - if !strings.Contains(recs[0].Message, "shared.go") { - t.Errorf("should mention file: %q", recs[0].Message) - } -} - -func TestGenerateRecommendations_BlastRadiusThreshold(t *testing.T) { - // 3 relationships → below threshold - r := &HealthReport{ - CriticalFiles: []CriticalFile{ - {Path: "shared.go", RelationshipCount: 3}, - }, - } - if recs := generateRecommendations(r); len(recs) != 0 { - t.Errorf("3 relationships should not trigger rec, got %v", recs) - } -} - -func TestGenerateRecommendations_SortedByPriority(t *testing.T) { - r := &HealthReport{ - CircularDeps: 1, // priority 1 - Domains: []DomainHealth{ - {Name: "api", IncomingDeps: []string{"a", "b", "c"}}, // priority 2 - {Name: "orphan", KeyFileCount: 0}, // priority 3 - }, - } - recs := generateRecommendations(r) - if len(recs) < 3 { - t.Fatalf("want >=3 recs, got %d", len(recs)) - } - for i := 1; i < len(recs); i++ { - if recs[i].Priority < recs[i-1].Priority { - t.Errorf("recs not sorted by priority at index %d: %v", i, recs) - break - } - } - if recs[0].Priority != 1 { - t.Errorf("first rec should be priority 1, got %d", recs[0].Priority) - } -} - -// ── CouplingStatus ──────────────────────────────────────────────────────────── - -func TestCouplingStatus(t *testing.T) { - tests := []struct { - incoming []string - want string - }{ - {nil, "✅ OK"}, - {[]string{"a"}, "✅ OK"}, - {[]string{"a", "b"}, "✅ OK"}, - {[]string{"a", "b", "c"}, "⚠️ WARN"}, - {[]string{"a", "b", "c", "d"}, "⚠️ WARN"}, - {[]string{"a", "b", "c", "d", "e"}, "⛔ HIGH"}, - {[]string{"a", "b", "c", "d", "e", "f"}, "⛔ HIGH"}, - } - for _, tt := range tests { - d := &DomainHealth{IncomingDeps: tt.incoming} - if got := d.CouplingStatus(); got != tt.want { - t.Errorf("CouplingStatus(%d incoming) = %q, want %q", - len(tt.incoming), got, tt.want) - } - } -} - -// ── Analyze ─────────────────────────────────────────────────────────────────── - -func TestAnalyze_Basic(t *testing.T) { - ir := &api.SupermodelIR{ - Summary: map[string]any{ - "filesProcessed": float64(50), - "functions": float64(200), - }, - Metadata: api.IRMetadata{Languages: []string{"Go", "JavaScript"}}, - Domains: []api.IRDomain{ - { - Name: "Authentication", - DescriptionSummary: "Handles auth", - KeyFiles: []string{"auth/handler.go"}, - Responsibilities: []string{"Login", "Logout"}, - }, - }, - Graph: api.IRGraph{ - Nodes: []api.IRNode{{Type: "ExternalDependency", Name: "cobra"}}, - }, - } - - r := Analyze(ir, "myproject") - - if r.ProjectName != "myproject" { - t.Errorf("project name: got %q", r.ProjectName) - } - if r.TotalFiles != 50 { - t.Errorf("total files: got %d", r.TotalFiles) - } - if r.TotalFunctions != 200 { - t.Errorf("total functions: got %d", r.TotalFunctions) - } - if r.Language != "Go" { - t.Errorf("language: got %q", r.Language) - } - if len(r.ExternalDeps) != 1 || r.ExternalDeps[0] != "cobra" { - t.Errorf("external deps: got %v", r.ExternalDeps) - } - if len(r.Domains) != 1 || r.Domains[0].Name != "Authentication" { - t.Errorf("domains: got %v", r.Domains) - } - if r.Status != StatusHealthy { - t.Errorf("status: got %s", r.Status) - } -} - -func TestAnalyze_PrimaryLanguageFromSummaryOverridesMetadata(t *testing.T) { - ir := &api.SupermodelIR{ - Summary: map[string]any{"primaryLanguage": "TypeScript"}, - Metadata: api.IRMetadata{Languages: []string{"Go"}}, - } - r := Analyze(ir, "proj") - if r.Language != "TypeScript" { - t.Errorf("Summary primaryLanguage should override Metadata: got %q", r.Language) - } -} - -func TestAnalyze_PrimaryLanguageFromMetadataWhenNoSummary(t *testing.T) { - ir := &api.SupermodelIR{ - Metadata: api.IRMetadata{Languages: []string{"Rust", "C"}}, - } - r := Analyze(ir, "proj") - if r.Language != "Rust" { - t.Errorf("first Metadata language: got %q", r.Language) - } -} - -func TestAnalyze_CircularDepsCauseCritical(t *testing.T) { - ir := &api.SupermodelIR{ - Graph: api.IRGraph{Relationships: []api.IRRelationship{ - {Type: "CIRCULAR_DEPENDENCY", Source: "auth", Target: "billing"}, - }}, - } - r := Analyze(ir, "proj") - if r.Status != StatusCritical { - t.Errorf("want CRITICAL, got %s", r.Status) - } - if r.CircularDeps != 1 { - t.Errorf("want 1 circular dep, got %d", r.CircularDeps) - } - if len(r.Recommendations) == 0 { - t.Error("should have recommendations for circular deps") - } -} - -func TestAnalyze_EmptyIR(t *testing.T) { - r := Analyze(&api.SupermodelIR{}, "empty") - if r == nil { - t.Fatal("Analyze returned nil") - } - if r.Status != StatusHealthy { - t.Errorf("empty IR should be HEALTHY, got %s", r.Status) - } - if r.ProjectName != "empty" { - t.Errorf("project name: got %q", r.ProjectName) - } -} - -func TestAnalyze_DegradedHighCoupling(t *testing.T) { - ir := &api.SupermodelIR{ - Domains: []api.IRDomain{{Name: "api"}}, - Graph: api.IRGraph{Relationships: []api.IRRelationship{ - {Type: "DOMAIN_RELATES", Source: "a", Target: "api"}, - {Type: "DOMAIN_RELATES", Source: "b", Target: "api"}, - {Type: "DOMAIN_RELATES", Source: "c", Target: "api"}, - {Type: "DOMAIN_RELATES", Source: "d", Target: "api"}, - {Type: "DOMAIN_RELATES", Source: "e", Target: "api"}, - }}, - } - r := Analyze(ir, "proj") - if r.Status != StatusDegraded { - t.Errorf("5 incoming → want DEGRADED, got %s", r.Status) - } -} - -// ── RenderHealth ────────────────────────────────────────────────────────────── - -func TestRenderHealth_ContainsProjectName(t *testing.T) { - r := &HealthReport{ProjectName: "myawesomeapp", Status: StatusHealthy} - var buf bytes.Buffer - RenderHealth(&buf, r) - if !strings.Contains(buf.String(), "myawesomeapp") { - t.Errorf("output should contain project name, got:\n%s", buf.String()) - } -} - -func TestRenderHealth_HealthyStatus(t *testing.T) { - r := &HealthReport{ProjectName: "proj", Status: StatusHealthy} - var buf bytes.Buffer - RenderHealth(&buf, r) - if !strings.Contains(buf.String(), "HEALTHY") { - t.Errorf("should contain HEALTHY status, got:\n%s", buf.String()) - } -} - -func TestRenderHealth_CriticalStatus(t *testing.T) { - r := &HealthReport{ - ProjectName: "proj", - Status: StatusCritical, - CircularDeps: 2, - CircularCycles: [][]string{ - {"auth", "billing"}, - {"api", "storage"}, - }, - } - var buf bytes.Buffer - RenderHealth(&buf, r) - out := buf.String() - if !strings.Contains(out, "CRITICAL") { - t.Error("should contain CRITICAL") - } - if !strings.Contains(out, "auth") { - t.Error("should list circular cycle members") - } -} - -func TestRenderHealth_NoRecommendations(t *testing.T) { - r := &HealthReport{ProjectName: "clean", Status: StatusHealthy} - var buf bytes.Buffer - RenderHealth(&buf, r) - if !strings.Contains(buf.String(), "No issues found") { - t.Errorf("should say 'No issues found', got:\n%s", buf.String()) - } -} - -func TestRenderHealth_MetricsTable(t *testing.T) { - r := &HealthReport{ - ProjectName: "proj", - TotalFiles: 99, - TotalFunctions: 333, - } - var buf bytes.Buffer - RenderHealth(&buf, r) - out := buf.String() - if !strings.Contains(out, "99") { - t.Error("should contain file count 99") - } - if !strings.Contains(out, "333") { - t.Error("should contain function count 333") - } -} - -func TestRenderHealth_CriticalFilesSection(t *testing.T) { - r := &HealthReport{ - ProjectName: "proj", - CriticalFiles: []CriticalFile{ - {Path: "internal/api/client.go", RelationshipCount: 5}, - }, - } - var buf bytes.Buffer - RenderHealth(&buf, r) - if !strings.Contains(buf.String(), "internal/api/client.go") { - t.Errorf("should list critical file, got:\n%s", buf.String()) - } -} - -func TestRenderHealth_RecommendationPriority(t *testing.T) { - r := &HealthReport{ - ProjectName: "proj", - Recommendations: []Recommendation{{Priority: 1, Message: "Fix cycles NOW"}}, - } - var buf bytes.Buffer - RenderHealth(&buf, r) - if !strings.Contains(buf.String(), "Fix cycles NOW") { - t.Errorf("should contain recommendation message, got:\n%s", buf.String()) - } -} - -// ── RenderRunPrompt ─────────────────────────────────────────────────────────── - -func TestRenderRunPrompt_ContainsGoalAndProject(t *testing.T) { - d := &SDLCPromptData{ - ProjectName: "myapp", - Language: "Go", - Goal: "Add rate limiting to the order API", - GeneratedAt: "2025-01-01 00:00:00 UTC", - } - var buf bytes.Buffer - RenderRunPrompt(&buf, d) - out := buf.String() - if !strings.Contains(out, "Add rate limiting to the order API") { - t.Error("should contain goal") - } - if !strings.Contains(out, "myapp") { - t.Error("should contain project name") - } -} - -func TestRenderRunPrompt_Contains8Phases(t *testing.T) { - d := &SDLCPromptData{ProjectName: "app", Goal: "test goal"} - var buf bytes.Buffer - RenderRunPrompt(&buf, d) - out := buf.String() - for i := 1; i <= 8; i++ { - phase := fmt.Sprintf("Phase %d", i) - if !strings.Contains(out, phase) { - t.Errorf("missing %s in run prompt", phase) - } - } -} - -func TestRenderRunPrompt_CircularDepWarning(t *testing.T) { - d := &SDLCPromptData{ - ProjectName: "app", - Goal: "fix stuff", - CircularDeps: 2, - } - var buf bytes.Buffer - RenderRunPrompt(&buf, d) - out := buf.String() - if !strings.Contains(out, "circular") { - t.Error("should warn about circular deps when present") - } -} - -func TestRenderRunPrompt_ContainsGuardrails(t *testing.T) { - d := &SDLCPromptData{ProjectName: "app", Goal: "do stuff"} - var buf bytes.Buffer - RenderRunPrompt(&buf, d) - // Guardrails section should exist somewhere in the output - out := buf.String() - if !strings.Contains(out, "guardrail") && !strings.Contains(out, "Guardrail") { - t.Error("should contain guardrails section") - } -} - -// ── RenderImprovePrompt ─────────────────────────────────────────────────────── - -func TestRenderImprovePrompt_ContainsHealthStatus(t *testing.T) { - report := &HealthReport{ - Status: StatusDegraded, - Recommendations: []Recommendation{ - {Priority: 2, Message: "Fix coupling in auth domain"}, - }, - } - d := &SDLCPromptData{ProjectName: "myapp", HealthReport: report} - var buf bytes.Buffer - RenderImprovePrompt(&buf, d) - out := buf.String() - if !strings.Contains(out, "DEGRADED") { - t.Error("should contain DEGRADED status") - } - if !strings.Contains(out, "Fix coupling in auth domain") { - t.Error("should contain recommendation") - } -} - -func TestRenderImprovePrompt_ContainsScoringModel(t *testing.T) { - d := &SDLCPromptData{ - ProjectName: "app", - HealthReport: &HealthReport{Status: StatusHealthy}, - } - var buf bytes.Buffer - RenderImprovePrompt(&buf, d) - out := buf.String() - // The improve prompt includes a scoring model - if !strings.Contains(out, "circular") { - t.Error("improve prompt should reference circular deps in scoring model") - } -} - -func TestRenderImprovePrompt_NoHealthReport(t *testing.T) { - d := &SDLCPromptData{ProjectName: "app"} // nil HealthReport - var buf bytes.Buffer - // Should not panic - RenderImprovePrompt(&buf, d) - if buf.Len() == 0 { - 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/zip_test.go b/internal/factory/zip_test.go deleted file mode 100644 index 5d2ebc2..0000000 --- a/internal/factory/zip_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package factory - -import ( - "archive/zip" - "os" - "path/filepath" - "strings" - "testing" -) - -// ── isGitRepo ───────────────────────────────────────────────────────────────── - -func TestIsGitRepo_WithDotGit(t *testing.T) { - dir := t.TempDir() - if err := os.Mkdir(filepath.Join(dir, ".git"), 0750); err != nil { - t.Fatal(err) - } - if !isGitRepo(dir) { - t.Error("dir with .git should be detected as git repo") - } -} - -func TestIsGitRepo_WithoutDotGit(t *testing.T) { - dir := t.TempDir() - if isGitRepo(dir) { - t.Error("dir without .git should not be detected as git repo") - } -} - -// ── walkZip ─────────────────────────────────────────────────────────────────── - -func TestWalkZip_IncludesRegularFiles(t *testing.T) { - src := t.TempDir() - writeFile(t, filepath.Join(src, "main.go"), "package main") - writeFile(t, filepath.Join(src, "README.md"), "# readme") - - dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { - t.Fatalf("walkZip: %v", err) - } - - entries := zipEntries(t, dest) - if _, ok := entries["main.go"]; !ok { - t.Error("zip should contain main.go") - } - if _, ok := entries["README.md"]; !ok { - t.Error("zip should contain README.md") - } -} - -func TestWalkZip_SkipsHiddenFiles(t *testing.T) { - src := t.TempDir() - writeFile(t, filepath.Join(src, ".env"), "SECRET=123") - writeFile(t, filepath.Join(src, "main.go"), "package main") - - dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { - t.Fatalf("walkZip: %v", err) - } - - entries := zipEntries(t, dest) - if _, ok := entries[".env"]; ok { - t.Error("zip should not contain hidden file .env") - } - if _, ok := entries["main.go"]; !ok { - t.Error("zip should contain main.go") - } -} - -func TestWalkZip_SkipsNodeModules(t *testing.T) { - src := t.TempDir() - nmDir := filepath.Join(src, "node_modules") - if err := os.Mkdir(nmDir, 0750); err != nil { - t.Fatal(err) - } - writeFile(t, filepath.Join(nmDir, "pkg.js"), "module.exports = {}") - writeFile(t, filepath.Join(src, "index.js"), "console.log('hi')") - - dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { - t.Fatalf("walkZip: %v", err) - } - - entries := zipEntries(t, dest) - for name := range entries { - if strings.HasPrefix(name, "node_modules/") || name == "node_modules" { - t.Errorf("zip should not contain node_modules entry: %s", name) - } - } - if _, ok := entries["index.js"]; !ok { - t.Error("zip should contain index.js") - } -} - -func TestWalkZip_SkipsAllSkipDirs(t *testing.T) { - src := t.TempDir() - for dir := range skipDirs { - d := filepath.Join(src, dir) - if err := os.Mkdir(d, 0750); err != nil { - t.Fatal(err) - } - writeFile(t, filepath.Join(d, "file.go"), "package x") - } - writeFile(t, filepath.Join(src, "real.go"), "package main") - - dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { - t.Fatalf("walkZip: %v", err) - } - - entries := zipEntries(t, dest) - if len(entries) != 1 { - t.Errorf("should only contain 1 file (real.go), got %d: %v", len(entries), entries) - } - if _, ok := entries["real.go"]; !ok { - t.Error("zip should contain real.go") - } -} - -func TestWalkZip_EmptyDir(t *testing.T) { - src := t.TempDir() - dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { - t.Fatalf("walkZip on empty dir: %v", err) - } - entries := zipEntries(t, dest) - if len(entries) != 0 { - t.Errorf("empty dir: want 0 entries, got %d", len(entries)) - } -} - -func TestWalkZip_NestedFiles(t *testing.T) { - src := t.TempDir() - sub := filepath.Join(src, "internal", "api") - if err := os.MkdirAll(sub, 0750); err != nil { - t.Fatal(err) - } - writeFile(t, filepath.Join(sub, "client.go"), "package api") - - dest := filepath.Join(t.TempDir(), "out.zip") - if err := walkZip(src, dest); err != nil { - t.Fatalf("walkZip: %v", err) - } - - entries := zipEntries(t, dest) - want := filepath.ToSlash(filepath.Join("internal", "api", "client.go")) - if _, ok := entries[want]; !ok { - t.Errorf("zip should contain %q, got entries: %v", want, entries) - } -} - -// ── CreateZip ───────────────────────────────────────────────────────────────── - -func TestCreateZip_NonGitDir(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "main.go"), "package main") - - path, err := CreateZip(dir) - if err != nil { - t.Fatalf("CreateZip: %v", err) - } - defer os.Remove(path) - - if path == "" { - t.Fatal("CreateZip returned empty path") - } - if _, err := os.Stat(path); err != nil { - t.Errorf("zip file not created: %v", err) - } - - entries := zipEntries(t, path) - if _, ok := entries["main.go"]; !ok { - t.Error("zip should contain main.go") - } -} - -// ── helpers ─────────────────────────────────────────────────────────────────── - -func writeFile(t *testing.T, path, content string) { - t.Helper() - if err := os.WriteFile(path, []byte(content), 0600); err != nil { - t.Fatal(err) - } -} - -func zipEntries(t *testing.T, path string) map[string]bool { - t.Helper() - r, err := zip.OpenReader(path) - if err != nil { - t.Fatalf("open zip %s: %v", path, err) - } - defer r.Close() - m := make(map[string]bool, len(r.File)) - for _, f := range r.File { - m[f.Name] = true - } - return m -}