package cmd import ( "context" "encoding/json" "fmt" "os" "path/filepath" "time" "github.com/spf13/cobra" "github.com/yourorg/devour/internal/quality" "github.com/yourorg/devour/internal/quality/detectors" "github.com/yourorg/devour/internal/quality/plugins" "github.com/yourorg/devour/internal/quality/plugins/go/fixers" "github.com/yourorg/devour/internal/quality/review" "github.com/yourorg/devour/internal/quality/scorecard" _ "github.com/yourorg/devour/internal/quality/plugins/go" ) // qualityCmd represents the quality command var qualityCmd = &cobra.Command{ Use: "quality", Short: "Code quality analysis and technical debt tracking", Long: `Analyze code quality issues like complexity, duplication, naming inconsistencies, and more. Supports multiple languages including Go, Python, TypeScript, Java, and Rust. Examples: devour quality scan ./src # Scan current directory devour quality scan --lang go ./src # Scan with explicit language devour quality status # Show current status devour quality next # Show next priority issue devour quality resolve fixed 123 --note "Refactored" # Mark issue as fixed`, } // scanCmd represents the scan subcommand var scanCmd = &cobra.Command{ Use: "scan [path]", Short: "Run code quality analysis", Long: `Scan the given path for code quality issues. Automatically detects language unless specified with --lang flag. The scan will detect: - Complexity issues (nested loops, excessive function calls) - Code duplication and near-duplicates - Naming inconsistencies across directories - Large files and god classes - Orphaned code and unused exports`, RunE: runQualityScan, } // qualityStatusCmd represents the quality status subcommand var qualityStatusCmd = &cobra.Command{ Use: "status", Short: "Show code quality status and scorecard", Long: `Display the current code quality status including: - Overall health score and grade - Findings broken down by type and severity - Progress metrics and next steps`, RunE: runQualityStatus, } // nextCmd represents the next subcommand var nextCmd = &cobra.Command{ Use: "next", Short: "Show the next highest priority issue to fix", Long: `Display the next highest priority finding based on severity and score. This helps you focus on the most impactful improvements first.`, RunE: runQualityNext, } // resolveCmd represents the resolve subcommand var resolveCmd = &cobra.Command{ Use: "resolve ", Short: "Mark a finding as resolved", Long: `Mark a finding with a specific status: - fixed: Issue has been resolved - wontfix: Issue won't be fixed (valid reason required) - false_positive: Finding is incorrect - ignore: Temporarily ignore the finding Examples: devour quality resolve fixed abc123 --note "Refactored complex function" devour quality resolve wontfix def456 --note "Legacy code, planned replacement"`, RunE: runQualityResolve, } // Quality flags var ( qualityPath string qualityLanguage string qualityExclude []string qualityThreshold int qualityMinLOC int qualityTargetScore int qualityFormat string qualityResetSubjective bool qualityNoBadge bool qualityBadgePath string ) var explain bool var tier int var resolveNote string var attest string var qualityUseAST bool var statusNarrative bool var fixDryRun bool var fixAll bool var reviewPrepare bool var reviewImport string var fixCmd = &cobra.Command{ Use: "fix [id]", Short: "Auto-fix code quality issues", Long: `Automatically fix T1 (auto-fixable) issues. Examples: devour quality fix unused_import::file.go::fmt # Fix specific issue devour quality fix --all --dry-run # Preview all fixes devour quality fix --all # Fix all auto-fixable issues`, RunE: runQualityFix, } var reviewCmd = &cobra.Command{ Use: "review", Short: "Generate or import AI review packets", Long: `Generate a review packet for AI analysis, or import responses. Examples: devour quality review --prepare # Generate review packet devour quality review --import responses.json # Import AI responses`, RunE: runQualityReview, } func init() { rootCmd.AddCommand(qualityCmd) qualityCmd.AddCommand(scanCmd, qualityStatusCmd, nextCmd, resolveCmd, fixCmd, reviewCmd) // Scan flags scanCmd.Flags().StringVar(&qualityPath, "path", ".", "Path to scan") scanCmd.Flags().StringVar(&qualityLanguage, "lang", "", "Language (auto-detected if not specified)") scanCmd.Flags().StringSliceVar(&qualityExclude, "exclude", []string{}, "Exclude patterns") scanCmd.Flags().IntVar(&qualityThreshold, "threshold", 15, "Minimum score to flag an issue") scanCmd.Flags().IntVar(&qualityMinLOC, "min-loc", 50, "Minimum lines of code to analyze") scanCmd.Flags().IntVar(&qualityTargetScore, "target-score", 95, "Target health score") scanCmd.Flags().StringVar(&qualityFormat, "format", "text", "Output format (text, json)") scanCmd.Flags().BoolVar(&qualityResetSubjective, "reset-subjective", false, "Reset subjective baseline") scanCmd.Flags().BoolVar(&qualityNoBadge, "no-badge", false, "Skip badge generation") scanCmd.Flags().StringVar(&qualityBadgePath, "badge-path", "scorecard.png", "Badge output path") // Status flags qualityStatusCmd.Flags().StringVar(&qualityFormat, "format", "text", "Output format (text, json)") qualityStatusCmd.Flags().BoolVar(&statusNarrative, "narrative", false, "Include narrative analysis") // Next flags nextCmd.Flags().BoolVar(&explain, "explain", false, "Show detailed explanation") nextCmd.Flags().IntVar(&tier, "tier", 0, "Filter by severity tier (1-4)") // Resolve flags resolveCmd.Flags().StringVar(&resolveNote, "note", "", "Note explaining the resolution (required)") resolveCmd.Flags().StringVar(&attest, "attest", "", "Attestation of improvement") // Fix flags fixCmd.Flags().BoolVar(&fixDryRun, "dry-run", false, "Show what would be fixed without making changes") fixCmd.Flags().BoolVar(&fixAll, "all", false, "Fix all auto-fixable issues") // Review flags reviewCmd.Flags().BoolVar(&reviewPrepare, "prepare", false, "Generate review packet for AI analysis") reviewCmd.Flags().StringVar(&reviewImport, "import", "", "Import review responses from file") } func runQualityScan(cmd *cobra.Command, args []string) error { path := qualityPath if len(args) > 0 { path = args[0] } if _, err := os.Stat(path); os.IsNotExist(err) { return fmt.Errorf("path does not exist: %s", path) } config := &quality.Config{ Path: path, Language: qualityLanguage, Exclude: qualityExclude, Threshold: qualityThreshold, MinLOC: qualityMinLOC, TargetScore: qualityTargetScore, ResetSubjective: qualityResetSubjective, NoBadge: qualityNoBadge, BadgePath: qualityBadgePath, } scanner := quality.NewScanner(config) finder := quality.NewDefaultFileFinder() scanner.SetFileFinder(finder) lang := qualityLanguage if lang == "" { lang = quality.DetectLanguage(path) fmt.Printf("Auto-detected language: %s\n", lang) } plugin, ok := plugins.Get(lang) if ok { fmt.Printf("Using %s plugin with AST analysis\n", lang) for _, detector := range plugin.CreateDetectors(finder) { scanner.RegisterDetector(detector) } } else { scanner.RegisterDetector(detectors.NewComplexityDetector(finder)) scanner.RegisterDetector(detectors.NewDuplicationDetector(finder)) scanner.RegisterDetector(detectors.NewNamingDetector(finder)) } ctx := context.Background() result, err := scanner.Scan(ctx) if err != nil { return fmt.Errorf("scan failed: %w", err) } return outputScanResult(result, qualityFormat) } func runQualityStatus(cmd *cobra.Command, args []string) error { // Load previous scan results dataDir := filepath.Join(".", "devour_data", "quality") statusFile := filepath.Join(dataDir, "status.json") var findings []quality.Finding var lastScan time.Time if data, err := os.ReadFile(statusFile); err == nil { var status struct { Findings []quality.Finding `json:"findings"` Timestamp time.Time `json:"timestamp"` } if err := json.Unmarshal(data, &status); err == nil { findings = status.Findings lastScan = status.Timestamp } } if len(findings) == 0 { fmt.Println("No previous scan results found. Run 'devour quality scan' first.") return nil } // Generate scorecard scorer := quality.NewScorer(qualityTargetScore) scorecard := scorer.GenerateScorecard(findings, lastScan) // Output based on format switch qualityFormat { case "json": return json.NewEncoder(os.Stdout).Encode(scorecard) default: fmt.Println(scorer.FormatScorecard(scorecard)) return nil } } func runQualityNext(cmd *cobra.Command, args []string) error { // Load previous scan results dataDir := filepath.Join(".", "devour_data", "quality") statusFile := filepath.Join(dataDir, "status.json") var findings []quality.Finding if data, err := os.ReadFile(statusFile); err == nil { var status struct { Findings []quality.Finding `json:"findings"` } if err := json.Unmarshal(data, &status); err == nil { findings = status.Findings } } if len(findings) == 0 { fmt.Println("No findings found. Run 'devour quality scan' first.") return nil } // Get next priority finding scorer := quality.NewScorer(qualityTargetScore) next := scorer.GetNextPriority(findings) if next == nil { fmt.Println("🎉 No open issues to fix!") return nil } // Filter by tier if specified if tier > 0 { if int(next.Severity) != tier { // Find next in specified tier for _, finding := range findings { if finding.Status == quality.StatusOpen && int(finding.Severity) == tier { next = &finding break } } if next == nil || int(next.Severity) != tier { fmt.Printf("No open issues found in tier %d.\n", tier) return nil } } } // Display finding fmt.Printf("Next Priority Issue (T%d)\n", int(next.Severity)) fmt.Println("=======================================") fmt.Printf("File: %s:%d\n", next.File, next.Line) fmt.Printf("Title: %s\n", next.Title) fmt.Printf("Score: %d\n", next.Score) fmt.Printf("ID: %s\n", next.ID) fmt.Printf("\nDescription:\n%s\n", next.Description) if explain { fmt.Printf("\nExplanation:\n") fmt.Printf("This is a T%d severity issue. ", int(next.Severity)) switch next.Severity { case quality.SeverityT1: fmt.Println("T1 issues are typically auto-fixable like unused imports or debug logs.") case quality.SeverityT2: fmt.Println("T2 issues require quick manual fixes like unused variables or dead exports.") case quality.SeverityT3: fmt.Println("T3 issues need judgment calls like near-duplicates or single-use abstractions.") case quality.SeverityT4: fmt.Println("T4 issues require major refactoring like god components or mixed concerns.") } fmt.Printf("\nTo fix: devour quality resolve fixed %s --note \"Describe what you fixed\"\n", next.ID) } return nil } func runQualityResolve(cmd *cobra.Command, args []string) error { if len(args) < 2 { return fmt.Errorf("usage: devour quality resolve ") } status := quality.Status(args[0]) id := args[1] // Validate status validStatuses := map[quality.Status]bool{ quality.StatusFixed: true, quality.StatusWontfix: true, quality.StatusFalsePositive: true, quality.StatusIgnored: true, } if !validStatuses[status] { return fmt.Errorf("invalid status: %s", status) } if resolveNote == "" { return fmt.Errorf("--note is required when resolving findings") } // Load current findings dataDir := filepath.Join(".", "devour_data", "quality") statusFile := filepath.Join(dataDir, "status.json") var findings []quality.Finding if data, err := os.ReadFile(statusFile); err == nil { var statusData struct { Findings []quality.Finding `json:"findings"` } if err := json.Unmarshal(data, &statusData); err == nil { findings = statusData.Findings } } // Find and update the finding found := false for i, finding := range findings { if finding.ID == id { findings[i].Status = status findings[i].UpdatedAt = time.Now() if finding.Metadata == nil { findings[i].Metadata = make(map[string]string) } findings[i].Metadata["resolution_note"] = resolveNote if attest != "" { findings[i].Metadata["attestation"] = attest } found = true break } } if !found { return fmt.Errorf("finding not found: %s", id) } // Save updated findings if err := os.MkdirAll(dataDir, 0755); err != nil { return fmt.Errorf("failed to create data directory: %w", err) } statusData := struct { Findings []quality.Finding `json:"findings"` Timestamp time.Time `json:"timestamp"` }{ Findings: findings, Timestamp: time.Now(), } data, err := json.MarshalIndent(statusData, "", " ") if err != nil { return fmt.Errorf("failed to marshal status: %w", err) } if err := os.WriteFile(statusFile, data, 0644); err != nil { return fmt.Errorf("failed to save status: %w", err) } fmt.Printf("Resolved: %s as %s\n", id, status) if resolveNote != "" { fmt.Printf("Note: %s\n", resolveNote) } return nil } func outputScanResult(result *quality.ScanResult, format string) error { // Save results to data directory dataDir := filepath.Join(".", "devour_data", "quality") if err := os.MkdirAll(dataDir, 0755); err != nil { return fmt.Errorf("failed to create data directory: %w", err) } statusFile := filepath.Join(dataDir, "status.json") statusData := struct { Findings []quality.Finding `json:"findings"` Timestamp time.Time `json:"timestamp"` }{ Findings: result.Findings, Timestamp: result.Timestamp, } data, err := json.MarshalIndent(statusData, "", " ") if err != nil { return fmt.Errorf("failed to marshal results: %w", err) } if err := os.WriteFile(statusFile, data, 0644); err != nil { return fmt.Errorf("failed to save results: %w", err) } // Generate scorecard badge if not disabled if !qualityNoBadge && qualityBadgePath != "" { if err := generateScorecardBadge(result, qualityBadgePath); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to generate scorecard badge: %v\n", err) } else { fmt.Printf("Scorecard badge generated: %s\n", qualityBadgePath) } } // Output based on format switch format { case "json": return json.NewEncoder(os.Stdout).Encode(result) default: return formatScanResultText(result) } } func generateScorecardBadge(result *quality.ScanResult, outputPath string) error { // Calculate grade from score scorer := quality.NewScorer(qualityTargetScore) grade := scorer.GetHealthGrade(result.StrictScore) // Group findings by type and tier findByType := make(map[string]int) findByTier := make(map[string]int) for _, f := range result.Findings { findByType[f.Type]++ tierName := fmt.Sprintf("T%d", int(f.Severity)) findByTier[tierName]++ } // Get project name from current directory projectName := "devour" if dir, err := os.Getwd(); err == nil { projectName = filepath.Base(dir) } // Prepare scorecard data scoreData := &scorecard.ScorecardData{ ProjectName: projectName, Version: "", // Could be extracted from version info OverallScore: float64(result.Score), StrictScore: float64(result.StrictScore), Grade: grade, FindingsTotal: len(result.Findings), FindingsOpen: len(result.Findings), // All findings are open initially LastScan: result.Timestamp, FindByType: findByType, FindByTier: findByTier, } return scorecard.Generate(scoreData, outputPath) } func formatScanResultText(result *quality.ScanResult) error { fmt.Println("Code Quality Scan Results") fmt.Println("=======================================") fmt.Printf("Files checked: %d\n", result.FilesChecked) fmt.Printf("Duration: %s\n", result.Duration) fmt.Printf("Score: %d (strict: %d)\n", result.Score, result.StrictScore) fmt.Printf("Findings: %d\n\n", len(result.Findings)) if len(result.Findings) == 0 { fmt.Println("No code quality issues found.") return nil } bySeverity := make(map[quality.Severity][]quality.Finding) for _, finding := range result.Findings { bySeverity[finding.Severity] = append(bySeverity[finding.Severity], finding) } tiers := []quality.Severity{quality.SeverityT4, quality.SeverityT3, quality.SeverityT2, quality.SeverityT1} tierNames := map[quality.Severity]string{ quality.SeverityT1: "T1 (Auto-fixable)", quality.SeverityT2: "T2 (Quick manual)", quality.SeverityT3: "T3 (Needs judgment)", quality.SeverityT4: "T4 (Major refactor)", } for _, severity := range tiers { findings := bySeverity[severity] if len(findings) == 0 { continue } fmt.Printf("[%s] %d issues\n", tierNames[severity], len(findings)) for _, finding := range findings { fmt.Printf(" - %s:%d - %s (score: %d)\n", filepath.Base(finding.File), finding.Line, finding.Title, finding.Score) } fmt.Println() } fmt.Println("Run 'devour quality next' to see the highest priority issue to fix.") fmt.Println("Run 'devour quality status' for detailed scorecard.") return nil } func runQualityFix(cmd *cobra.Command, args []string) error { dataDir := filepath.Join(".", "devour_data", "quality") statusFile := filepath.Join(dataDir, "status.json") var findings []quality.Finding if data, err := os.ReadFile(statusFile); err == nil { var status struct { Findings []quality.Finding `json:"findings"` } if err := json.Unmarshal(data, &status); err == nil { findings = status.Findings } } if len(findings) == 0 { fmt.Println("No findings found. Run 'devour quality scan' first.") return nil } availableFixers := []plugins.Fixer{ fixers.NewUnusedImportFixer(), fixers.NewFormattingFixer(), } ctx := context.Background() fixed := 0 var errors []string if fixAll { for _, finding := range findings { if finding.Status != quality.StatusOpen || finding.Severity != quality.SeverityT1 { continue } for _, fixer := range availableFixers { if fixer.CanFix(finding) { result, err := fixer.Fix(ctx, finding, fixDryRun) if err != nil { errors = append(errors, fmt.Sprintf("%s: %v", finding.ID, err)) continue } if result.Success { fixed++ fmt.Printf("[OK] %s\n", result.Message) } break } } } } else { if len(args) < 1 { return fmt.Errorf("specify a finding ID or use --all") } targetID := args[0] var target *quality.Finding for i := range findings { if findings[i].ID == targetID { target = &findings[i] break } } if target == nil { return fmt.Errorf("finding not found: %s", targetID) } for _, fixer := range availableFixers { if fixer.CanFix(*target) { result, err := fixer.Fix(ctx, *target, fixDryRun) if err != nil { return fmt.Errorf("fix failed: %w", err) } fmt.Printf("[OK] %s\n", result.Message) fixed = 1 break } } } if fixDryRun { fmt.Printf("\nDry run complete. %d issues would be fixed.\n", fixed) } else { fmt.Printf("\nFixed %d issues.\n", fixed) } if len(errors) > 0 { fmt.Printf("\nErrors:\n") for _, e := range errors { fmt.Printf(" • %s\n", e) } } return nil } func runQualityReview(cmd *cobra.Command, args []string) error { dataDir := filepath.Join(".", "devour_data") if reviewPrepare { return prepareReviewPacket(dataDir) } if reviewImport != "" { return importReviewResponses(dataDir, reviewImport) } return fmt.Errorf("use --prepare to generate a review packet or --import to import responses") } func prepareReviewPacket(dataDir string) error { statusFile := filepath.Join(dataDir, "quality", "status.json") var findings []quality.Finding var lastScan time.Time if data, err := os.ReadFile(statusFile); err == nil { var status struct { Findings []quality.Finding `json:"findings"` Timestamp time.Time `json:"timestamp"` } if err := json.Unmarshal(data, &status); err == nil { findings = status.Findings lastScan = status.Timestamp } } scorer := quality.NewScorer(qualityTargetScore) scorecard := scorer.GenerateScorecard(findings, lastScan) gen := review.NewPacketGenerator(dataDir) packet, err := gen.Generate(findings, scorecard, "go") if err != nil { return fmt.Errorf("failed to generate review packet: %w", err) } filename := fmt.Sprintf("review-%s.json", time.Now().Format("20060102-150405")) if err := gen.Save(packet, filename); err != nil { return fmt.Errorf("failed to save review packet: %w", err) } fmt.Printf("Review packet generated: %s/review/%s\n", dataDir, filename) fmt.Printf("Findings to review: %d\n", len(packet.Findings)) fmt.Printf("Questions: %d\n", len(packet.Questions)) return nil } func importReviewResponses(dataDir string, filename string) error { gen := review.NewPacketGenerator(dataDir) responses := make(map[string]string) data, err := os.ReadFile(filename) if err != nil { return fmt.Errorf("failed to read responses file: %w", err) } var respData struct { Responses map[string]string `json:"responses"` } if err := json.Unmarshal(data, &respData); err == nil { responses = respData.Responses } else { var simpleResponses map[string]string if err := json.Unmarshal(data, &simpleResponses); err != nil { return fmt.Errorf("failed to parse responses: %w", err) } responses = simpleResponses } if err := gen.ImportReview(filepath.Base(filename), responses); err != nil { return fmt.Errorf("failed to import responses: %w", err) } fmt.Printf("Imported %d review responses\n", len(responses)) return nil }