diff --git a/cmd/audit.go b/cmd/audit.go index aadb895..09be46a 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -11,6 +11,7 @@ import ( "github.com/supermodeltools/cli/internal/api" "github.com/supermodeltools/cli/internal/audit" + "github.com/supermodeltools/cli/internal/build" "github.com/supermodeltools/cli/internal/cache" "github.com/supermodeltools/cli/internal/config" ) @@ -50,7 +51,21 @@ func runAudit(cmd *cobra.Command, dir string) error { return err } - ir, err := auditAnalyze(cmd, rootDir, projectName) + cfg, err := config.Load() + if err != nil { + return err + } + if err := cfg.RequireAPIKey(); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + // Fingerprint for caching — best-effort; empty string means no caching. + fp, _ := cache.RepoFingerprint(rootDir) + + ir, err := auditAnalyze(ctx, cmd, cfg, rootDir, projectName, fp) if err != nil { return err } @@ -58,7 +73,7 @@ func runAudit(cmd *cobra.Command, dir string) error { report := audit.Analyze(ir, projectName) // Run impact analysis (global mode) to enrich the health report. - impact, err := runImpactForAudit(cmd, rootDir) + impact, err := runImpactForAudit(ctx, cmd, cfg, rootDir, fp) if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: impact analysis unavailable: %v\n", err) } else { @@ -81,13 +96,13 @@ func resolveAuditDir(dir string) (rootDir, projectName string, err error) { 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 +func auditAnalyze(ctx context.Context, cmd *cobra.Command, cfg *config.Config, rootDir, projectName, fp string) (*api.SupermodelIR, error) { + if fp != "" { + key := cache.AnalysisKey(fp, "audit-domains", build.Version) + var cached api.SupermodelIR + if hit, _ := cache.GetJSON(key, &cached); hit { + return &cached, nil + } } fmt.Fprintln(cmd.ErrOrStderr(), "Creating repository archive…") @@ -103,18 +118,27 @@ func auditAnalyze(cmd *cobra.Command, rootDir, projectName string) (*api.Supermo } 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]) + ir, err := client.AnalyzeDomains(ctx, zipPath, "audit-"+hash[:16]) + if err != nil { + return nil, err + } + + if fp != "" { + key := cache.AnalysisKey(fp, "audit-domains", build.Version) + _ = cache.PutJSON(key, ir) + } + return ir, nil } // 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 +func runImpactForAudit(ctx context.Context, cmd *cobra.Command, cfg *config.Config, rootDir, fp string) (*api.ImpactResult, error) { + if fp != "" { + key := cache.AnalysisKey(fp, "impact", build.Version) + var cached api.ImpactResult + if hit, _ := cache.GetJSON(key, &cached); hit { + return &cached, nil + } } zipPath, err := audit.CreateZip(rootDir) @@ -129,9 +153,15 @@ func runImpactForAudit(cmd *cobra.Command, rootDir string) (*api.ImpactResult, e } 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], "", "") + result, err := client.Impact(ctx, zipPath, "audit-impact-"+hash[:16], "", "") + if err != nil { + return nil, err + } + + if fp != "" { + key := cache.AnalysisKey(fp, "impact", build.Version) + _ = cache.PutJSON(key, result) + } + return result, nil } diff --git a/internal/archdocs/handler.go b/internal/archdocs/handler.go index f57cd2b..b1c6672 100644 --- a/internal/archdocs/handler.go +++ b/internal/archdocs/handler.go @@ -13,10 +13,13 @@ import ( "strconv" "strings" + "encoding/json" + "github.com/supermodeltools/cli/internal/api" "github.com/supermodeltools/cli/internal/archdocs/graph2md" pssgbuild "github.com/supermodeltools/cli/internal/archdocs/pssg/build" pssgconfig "github.com/supermodeltools/cli/internal/archdocs/pssg/config" + "github.com/supermodeltools/cli/internal/build" "github.com/supermodeltools/cli/internal/cache" "github.com/supermodeltools/cli/internal/config" "github.com/supermodeltools/cli/internal/ui" @@ -282,28 +285,11 @@ func Run(ctx context.Context, cfg *config.Config, dir string, opts Options) erro opts.MaxEntities = 12000 } - ui.Step("Creating repository archive…") - zipPath, err := createZip(absDir) - if err != nil { - return fmt.Errorf("create archive: %w", err) - } - defer os.Remove(zipPath) - - // Use zip hash as idempotency key (matches existing CLI cache key style) - hash, err := cache.HashFile(zipPath) + rawResult, err := analyzeOrCachedRaw(ctx, cfg, absDir, opts.Force) if err != nil { - return fmt.Errorf("hash archive: %w", err) + return err } - client := api.New(cfg) - spin := ui.Start("Uploading and analyzing repository…") - rawResult, err := client.AnalyzeRaw(ctx, zipPath, "archdocs-"+hash[:16]) - spin.Stop() - if err != nil { - return fmt.Errorf("API analysis: %w", err) - } - ui.Success("Analysis complete") - // Write raw graph JSON to a temp file for graph2md tmpDir, err := os.MkdirTemp("", "supermodel-archdocs-*") if err != nil { @@ -491,3 +477,45 @@ func countFiles(dir, ext string) int { }) return count } + +// analyzeOrCachedRaw returns the raw JSON result from a repository analysis, +// hitting the fingerprint cache first to avoid re-uploading unchanged repos. +func analyzeOrCachedRaw(ctx context.Context, cfg *config.Config, repoDir string, force bool) (json.RawMessage, error) { + if !force { + if fp, err := cache.RepoFingerprint(repoDir); err == nil { + key := cache.AnalysisKey(fp, "archdocs", build.Version) + var cached json.RawMessage + if hit, _ := cache.GetJSON(key, &cached); hit { + ui.Success("Using cached analysis") + return cached, nil + } + } + } + + ui.Step("Creating repository archive…") + zipPath, err := createZip(repoDir) + if err != nil { + return nil, fmt.Errorf("create archive: %w", err) + } + defer os.Remove(zipPath) + + hash, err := cache.HashFile(zipPath) + if err != nil { + return nil, fmt.Errorf("hash archive: %w", err) + } + + client := api.New(cfg) + spin := ui.Start("Uploading and analyzing repository…") + raw, err := client.AnalyzeRaw(ctx, zipPath, "archdocs-"+hash[:16]) + spin.Stop() + if err != nil { + return nil, fmt.Errorf("API analysis: %w", err) + } + ui.Success("Analysis complete") + + if fp, err := cache.RepoFingerprint(repoDir); err == nil { + key := cache.AnalysisKey(fp, "archdocs", build.Version) + _ = cache.PutJSON(key, raw) + } + return raw, nil +}