Files
Devour/cmd/quality.go
Tomas Dvorak 55885a0e8f first commit
2026-02-22 10:42:17 +01:00

737 lines
21 KiB
Go

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 <status> <id>",
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> <id>")
}
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 <file> 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
}