Merge pull request #115 from ludo-technologies/refactor/unify-analyze-command-with-clean-architecture

refactor: unify analyze command with clean architecture
This commit is contained in:
LudoTechnologies
2025-10-02 23:55:41 +09:00
committed by GitHub
4 changed files with 336 additions and 1727 deletions

View File

@@ -9,6 +9,7 @@ import (
"time"
"github.com/ludo-technologies/pyscn/domain"
"github.com/ludo-technologies/pyscn/internal/config"
"github.com/ludo-technologies/pyscn/service"
)
@@ -169,12 +170,18 @@ type AnalysisTask struct {
func (uc *AnalyzeUseCase) Execute(ctx context.Context, config AnalyzeUseCaseConfig, paths []string) (*domain.AnalyzeResponse, error) {
startTime := time.Now()
// Validate and collect files
// Load configuration to get file patterns
includePatterns, excludePatterns, patternErr := uc.getFilePatterns(config.ConfigFile, paths)
if patternErr != nil {
return nil, patternErr
}
// Validate and collect files using configured patterns
files, err := uc.fileReader.CollectPythonFiles(
paths,
true, // recursive
[]string{"*.py", "*.pyi"},
[]string{"test_*.py", "*_test.py"},
includePatterns,
excludePatterns,
)
if err != nil {
return nil, fmt.Errorf("failed to collect Python files: %w", err)
@@ -302,6 +309,7 @@ func (uc *AnalyzeUseCase) createAnalysisTasks(config AnalyzeUseCaseConfig, files
Type2Threshold: defaultReq.Type2Threshold,
Type3Threshold: defaultReq.Type3Threshold,
Type4Threshold: defaultReq.Type4Threshold,
GroupClones: true, // Enable clone grouping (default behavior)
ConfigPath: config.ConfigFile,
}
return uc.cloneUseCase.ExecuteAndReturn(ctx, request)
@@ -518,3 +526,38 @@ func (uc *AnalyzeUseCase) calculateSummary(summary *domain.AnalyzeSummary, respo
summary.Grade = domain.GetGradeFromScore(summary.HealthScore)
}
}
// getFilePatterns loads file patterns from configuration or returns defaults
func (uc *AnalyzeUseCase) getFilePatterns(configPath string, paths []string) ([]string, []string, error) {
// Default patterns
defaultInclude := []string{"*.py", "*.pyi"}
defaultExclude := []string{"test_*.py", "*_test.py"}
// Try to load configuration
targetPath := ""
if len(paths) > 0 {
targetPath = paths[0]
}
cfg, err := config.LoadConfigWithTarget(configPath, targetPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to load configuration for pattern resolution: %w", err)
}
if cfg == nil {
return defaultInclude, defaultExclude, nil
}
// Use configured patterns if available
includePatterns := cfg.Analysis.IncludePatterns
excludePatterns := cfg.Analysis.ExcludePatterns
// Fall back to defaults if not specified
if len(includePatterns) == 0 {
includePatterns = defaultInclude
}
if len(excludePatterns) == 0 {
excludePatterns = defaultExclude
}
return includePatterns, excludePatterns, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,514 +0,0 @@
package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/ludo-technologies/pyscn/app"
"github.com/ludo-technologies/pyscn/domain"
"github.com/ludo-technologies/pyscn/internal/version"
"github.com/ludo-technologies/pyscn/service"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// AnalyzeCommandRefactored represents the comprehensive analysis command (refactored version)
type AnalyzeCommandRefactored struct {
// Output format flags (only one should be true)
html bool
json bool
csv bool
yaml bool
noOpen bool
// Configuration
configFile string
verbose bool
// Analysis selection
skipComplexity bool
skipDeadCode bool
skipClones bool
skipCBO bool
skipSystem bool
selectAnalyses []string // Only run specified analyses
// Quick filters
minComplexity int
minSeverity string
cloneSimilarity float64
minCBO int
// System analysis options
detectCycles bool // Detect circular dependencies
validateArch bool // Validate architecture rules
}
// NewAnalyzeCommandRefactored creates a new analyze command (refactored)
func NewAnalyzeCommandRefactored() *AnalyzeCommandRefactored {
return &AnalyzeCommandRefactored{
html: false,
json: false,
csv: false,
yaml: false,
noOpen: false,
configFile: "",
verbose: false,
skipComplexity: false,
skipDeadCode: false,
skipClones: false,
skipCBO: false,
skipSystem: false,
minComplexity: 5,
minSeverity: "warning",
cloneSimilarity: 0.8,
minCBO: 0,
detectCycles: true,
validateArch: true,
}
}
// CreateCobraCommand creates the cobra command for comprehensive analysis
func (c *AnalyzeCommandRefactored) CreateCobraCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "analyze [files...]",
Short: "Run comprehensive analysis on Python files",
Long: `Run comprehensive analysis including complexity, dead code detection, clone detection, and CBO analysis.
This command performs all available static analyses on Python code:
• Cyclomatic complexity analysis
• Dead code detection using CFG analysis
• Code clone detection using APTED algorithm
• Dependency analysis (class coupling)
• System-level analysis (module dependencies and architecture)
The analyses run concurrently for optimal performance. Results are combined
and presented in a unified format.
Examples:
# Analyze current directory
pyscn analyze .
# Analyze specific files with JSON output
pyscn analyze --json src/myfile.py
# Skip clone detection, focus on complexity, dead code, and dependencies
pyscn analyze --skip-clones src/
# Quick analysis with higher thresholds
pyscn analyze --min-complexity 10 --min-severity critical --min-cbo 5 src/
# Skip dependency analysis
pyscn analyze --skip-cbo src/`,
Args: cobra.MinimumNArgs(1),
RunE: c.runAnalyze,
}
// Output format flags
cmd.Flags().BoolVar(&c.html, "html", false, "Generate HTML report file")
cmd.Flags().BoolVar(&c.json, "json", false, "Generate JSON report file")
cmd.Flags().BoolVar(&c.csv, "csv", false, "Generate CSV report file")
cmd.Flags().BoolVar(&c.yaml, "yaml", false, "Generate YAML report file")
cmd.Flags().BoolVar(&c.noOpen, "no-open", false, "Don't auto-open HTML in browser")
cmd.Flags().StringVarP(&c.configFile, "config", "c", "", "Configuration file path")
// Analysis selection flags
cmd.Flags().BoolVar(&c.skipComplexity, "skip-complexity", false, "Skip complexity analysis")
cmd.Flags().BoolVar(&c.skipDeadCode, "skip-deadcode", false, "Skip dead code detection")
cmd.Flags().BoolVar(&c.skipClones, "skip-clones", false, "Skip clone detection")
cmd.Flags().BoolVar(&c.skipCBO, "skip-cbo", false, "Skip class coupling (CBO) analysis")
cmd.Flags().BoolVar(&c.skipSystem, "skip-deps", false, "Skip module dependencies and architecture analysis")
cmd.Flags().StringSliceVar(&c.selectAnalyses, "select", []string{}, "Only run specified analyses (complexity,deadcode,clones,cbo,deps)")
// Quick filter flags
cmd.Flags().IntVar(&c.minComplexity, "min-complexity", 5, "Minimum complexity to report")
cmd.Flags().StringVar(&c.minSeverity, "min-severity", "warning", "Minimum dead code severity (critical, warning, info)")
cmd.Flags().Float64Var(&c.cloneSimilarity, "clone-threshold", 0.8, "Minimum similarity for clone detection (0.0-1.0)")
cmd.Flags().IntVar(&c.minCBO, "min-cbo", 0, "Minimum CBO to report")
return cmd
}
// runAnalyze executes the comprehensive analysis using the refactored architecture
func (c *AnalyzeCommandRefactored) runAnalyze(cmd *cobra.Command, args []string) error {
// Get verbose flag from parent command
if cmd.Parent() != nil {
c.verbose, _ = cmd.Parent().Flags().GetBool("verbose")
}
// Create use case configuration
config := c.createUseCaseConfig()
// Build the analyze use case
useCase, err := c.buildAnalyzeUseCase(cmd)
if err != nil {
return fmt.Errorf("failed to build analyze use case: %w", err)
}
// Execute analysis
ctx := context.Background()
response, analysisErr := useCase.Execute(ctx, config, args)
// Generate output even if there were partial failures
if response != nil {
// Generate output
if err := c.generateOutput(cmd, response, args); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: Failed to generate output: %v\n", err)
}
// Print summary
c.printSummary(cmd, response)
}
// Return the analysis error so CLI exits with non-zero status
if analysisErr != nil {
return analysisErr
}
return nil
}
// createUseCaseConfig creates the use case configuration from command flags
func (c *AnalyzeCommandRefactored) createUseCaseConfig() app.AnalyzeUseCaseConfig {
config := app.AnalyzeUseCaseConfig{
ConfigFile: c.configFile,
Verbose: c.verbose,
MinComplexity: c.minComplexity,
CloneSimilarity: c.cloneSimilarity,
MinCBO: c.minCBO,
}
// Handle analysis selection
if len(c.selectAnalyses) > 0 {
// If --select is used, only run selected analyses
config.SkipComplexity = !c.containsAnalysis("complexity")
config.SkipDeadCode = !c.containsAnalysis("deadcode")
config.SkipClones = !c.containsAnalysis("clones")
config.SkipCBO = !c.containsAnalysis("cbo")
config.SkipSystem = !c.containsAnalysis("deps")
} else {
// Otherwise use skip flags
config.SkipComplexity = c.skipComplexity
config.SkipDeadCode = c.skipDeadCode
config.SkipClones = c.skipClones
config.SkipCBO = c.skipCBO
config.SkipSystem = c.skipSystem
}
// Parse severity
switch c.minSeverity {
case "critical":
config.MinSeverity = domain.DeadCodeSeverityCritical
case "warning":
config.MinSeverity = domain.DeadCodeSeverityWarning
case "info":
config.MinSeverity = domain.DeadCodeSeverityInfo
default:
config.MinSeverity = domain.DeadCodeSeverityWarning
}
return config
}
// buildAnalyzeUseCase builds the analyze use case with all dependencies
func (c *AnalyzeCommandRefactored) buildAnalyzeUseCase(cmd *cobra.Command) (*app.AnalyzeUseCase, error) {
builder := app.NewAnalyzeUseCaseBuilder()
// Set up file reader
fileReader := service.NewFileReader()
builder.WithFileReader(fileReader)
// Set up formatter
formatter := service.NewAnalyzeFormatter()
builder.WithFormatter(formatter)
// Set up progress manager
progressManager := service.NewProgressManager()
if c.shouldUseProgressBars(cmd) {
progressManager.SetWriter(cmd.ErrOrStderr())
} else {
progressManager.SetWriter(io.Discard)
}
builder.WithProgressManager(progressManager)
// Set up parallel executor
parallelExecutor := service.NewParallelExecutor()
builder.WithParallelExecutor(parallelExecutor)
// Set up error categorizer
errorCategorizer := service.NewErrorCategorizer()
builder.WithErrorCategorizer(errorCategorizer)
// Build individual use cases
if err := c.buildIndividualUseCases(builder); err != nil {
return nil, err
}
return builder.Build()
}
// buildIndividualUseCases builds and sets individual analysis use cases
func (c *AnalyzeCommandRefactored) buildIndividualUseCases(builder *app.AnalyzeUseCaseBuilder) error {
// Complexity use case
complexityService := service.NewComplexityService()
complexityFormatter := service.NewOutputFormatter()
complexityConfigLoader := service.NewConfigurationLoader()
complexityUseCase := app.NewComplexityUseCase(
complexityService,
service.NewFileReader(),
complexityFormatter,
complexityConfigLoader,
)
builder.WithComplexityUseCase(complexityUseCase)
// Dead code use case
deadCodeService := service.NewDeadCodeService()
deadCodeFormatter := service.NewDeadCodeFormatter()
deadCodeConfigLoader := service.NewDeadCodeConfigurationLoader()
deadCodeUseCase := app.NewDeadCodeUseCase(
deadCodeService,
service.NewFileReader(),
deadCodeFormatter,
deadCodeConfigLoader,
)
builder.WithDeadCodeUseCase(deadCodeUseCase)
// Clone use case
cloneService := service.NewCloneService()
cloneFormatter := service.NewCloneOutputFormatter()
cloneConfigLoader := service.NewCloneConfigurationLoader()
cloneUseCase, err := app.NewCloneUseCaseBuilder().
WithService(cloneService).
WithFileReader(service.NewFileReader()).
WithFormatter(cloneFormatter).
WithConfigLoader(cloneConfigLoader).
Build()
if err != nil {
return fmt.Errorf("failed to build clone use case: %w", err)
}
builder.WithCloneUseCase(cloneUseCase)
// CBO use case
cboService := service.NewCBOService()
cboFormatter := service.NewCBOFormatter()
cboUseCase, err := app.NewCBOUseCaseBuilder().
WithService(cboService).
WithFileReader(service.NewFileReader()).
WithFormatter(cboFormatter).
Build()
if err != nil {
return fmt.Errorf("failed to build CBO use case: %w", err)
}
builder.WithCBOUseCase(cboUseCase)
// System analysis use case
systemService := service.NewSystemAnalysisService()
systemFormatter := service.NewSystemAnalysisFormatter()
systemConfigLoader := service.NewSystemAnalysisConfigurationLoader()
systemUseCase, err := app.NewSystemAnalysisUseCaseBuilder().
WithService(systemService).
WithFileReader(service.NewFileReader()).
WithFormatter(systemFormatter).
WithConfigLoader(systemConfigLoader).
Build()
if err != nil {
return fmt.Errorf("failed to build system analysis use case: %w", err)
}
builder.WithSystemUseCase(systemUseCase)
return nil
}
// generateOutput generates the output report
func (c *AnalyzeCommandRefactored) generateOutput(cmd *cobra.Command, response *domain.AnalyzeResponse, args []string) error {
// Determine output format
format, extension, err := c.determineOutputFormat()
if err != nil {
return err
}
// Generate filename with timestamp
targetPath := getTargetPathFromArgs(args)
filename, err := generateOutputFilePath("analyze", extension, targetPath)
if err != nil {
return fmt.Errorf("failed to generate output path: %w", err)
}
// Add version to response
response.Version = version.Version
// Create formatter
formatter := service.NewAnalyzeFormatter()
// Create output file
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("failed to create output file %s: %w", filename, err)
}
defer file.Close()
// Write the unified report
formatType := domain.OutputFormat(format)
if err := formatter.Write(response, formatType, file); err != nil {
return fmt.Errorf("failed to write unified report: %w", err)
}
// Get absolute path for display
absPath, err := filepath.Abs(filename)
if err != nil {
absPath = filename
}
// Handle browser opening for HTML
if format == "html" {
// Auto-open only when explicitly allowed and environment appears interactive
if !c.noOpen && isInteractiveEnvironment() {
fileURL := "file://" + absPath
if err := service.OpenBrowser(fileURL); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: Could not open browser: %v\n", err)
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "📊 Unified HTML report generated and opened: %s\n", absPath)
return nil
}
}
}
// Display success message
formatName := strings.ToUpper(format)
fmt.Fprintf(cmd.ErrOrStderr(), "📊 Unified %s report generated: %s\n", formatName, absPath)
return nil
}
// printSummary prints a summary of the analysis results
func (c *AnalyzeCommandRefactored) printSummary(cmd *cobra.Command, response *domain.AnalyzeResponse) {
fmt.Fprintf(cmd.ErrOrStderr(), "\n📊 Analysis Summary:\n")
fmt.Fprintf(cmd.ErrOrStderr(), "Total time: %dms\n", response.Duration)
fmt.Fprintf(cmd.ErrOrStderr(), "\n")
// Print health score if available
if response.Summary.HealthScore > 0 {
fmt.Fprintf(cmd.ErrOrStderr(), "Health Score: %d/100 (%s)\n", response.Summary.HealthScore, response.Summary.Grade)
}
// Print enabled analyses summary with success indicators
if response.Summary.ComplexityEnabled {
if response.Complexity != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "✅ Complexity Analysis: %d functions analyzed, average complexity: %.1f\n",
response.Summary.TotalFunctions, response.Summary.AverageComplexity)
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "❌ Complexity Analysis: Failed\n")
}
}
if response.Summary.DeadCodeEnabled {
if response.DeadCode != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "✅ Dead Code Detection: %d issues found (%d critical)\n",
response.Summary.DeadCodeCount, response.Summary.CriticalDeadCode)
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "❌ Dead Code Detection: Failed\n")
}
}
if response.Summary.CloneEnabled {
if response.Clone != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "✅ Clone Detection: %d clone groups, %.1f%% duplication\n",
response.Summary.CloneGroups, response.Summary.CodeDuplication)
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "❌ Clone Detection: Failed\n")
}
}
if response.Summary.CBOEnabled {
if response.CBO != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "✅ Class Coupling (CBO): %d classes analyzed, average coupling: %.1f\n",
response.Summary.CBOClasses, response.Summary.AverageCoupling)
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "❌ Class Coupling (CBO): Failed\n")
}
}
if response.Summary.DepsEnabled {
if response.System != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "✅ System Analysis: %d modules analyzed\n",
response.Summary.DepsTotalModules)
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "❌ System Analysis: Failed\n")
}
}
}
// Helper methods
// determineOutputFormat determines the output format based on flags
func (c *AnalyzeCommandRefactored) determineOutputFormat() (string, string, error) {
formatCount := 0
var format string
var extension string
if c.html {
formatCount++
format = "html"
extension = "html"
}
if c.json {
formatCount++
format = "json"
extension = "json"
}
if c.csv {
formatCount++
format = "csv"
extension = "csv"
}
if c.yaml {
formatCount++
format = "yaml"
extension = "yaml"
}
// Check for conflicting flags
if formatCount > 1 {
return "", "", fmt.Errorf("only one output format flag can be specified")
}
// Default to HTML if no format specified
if formatCount == 0 {
return "html", "html", nil
}
return format, extension, nil
}
// shouldUseProgressBars returns true when the session appears to be interactive
func (c *AnalyzeCommandRefactored) shouldUseProgressBars(cmd *cobra.Command) bool {
if !isInteractiveEnvironment() {
return false
}
if errWriter, ok := cmd.ErrOrStderr().(*os.File); ok {
return term.IsTerminal(int(errWriter.Fd()))
}
return false
}
// containsAnalysis checks if the given analysis is in the selectAnalyses list
func (c *AnalyzeCommandRefactored) containsAnalysis(analysis string) bool {
for _, a := range c.selectAnalyses {
if strings.ToLower(a) == analysis {
return true
}
}
return false
}
// NewAnalyzeCmdRefactored creates and returns the refactored analyze cobra command
func NewAnalyzeCmdRefactored() *cobra.Command {
analyzeCommand := NewAnalyzeCommandRefactored()
return analyzeCommand.CreateCobraCommand()
}

View File

@@ -67,3 +67,16 @@ func getTargetPathFromArgs(args []string) string {
}
return ""
}
// isInteractiveEnvironment returns true if the environment appears to be
// an interactive TTY session (and not CI), used to decide auto-open behavior.
func isInteractiveEnvironment() bool {
if os.Getenv("CI") != "" {
return false
}
// Best-effort TTY detection without external deps
if fi, err := os.Stderr.Stat(); err == nil {
return (fi.Mode() & os.ModeCharDevice) != 0
}
return false
}