Skip to content

Commit f757b81

Browse files
committed
feat: add circular dependency detection
1 parent 219479e commit f757b81

File tree

6 files changed

+290
-26
lines changed

6 files changed

+290
-26
lines changed

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,11 @@ pyscn analyze --select complexity,deps,deadcode . # Multiple analyses
106106
### `pyscn check`
107107
Fast CI-friendly quality gate
108108
```bash
109-
pyscn check . # Quick pass/fail check
110-
pyscn check --max-complexity 15 . # Custom thresholds
109+
pyscn check . # Quick pass/fail check
110+
pyscn check --max-complexity 15 . # Custom thresholds
111+
pyscn check --max-cycles 0 . # Only allow 0 cycle dependency
112+
pyscn check --select deps . # Check only for circular dependencies
113+
pyscn check --allow-circular-deps . # Allow circular dependencies (warning only)
111114
```
112115

113116
### `pyscn init`

cmd/pyscn/check.go

Lines changed: 133 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import (
44
"context"
55
"fmt"
66
"io"
7-
"os"
87
"strings"
98

109
"github.com/ludo-technologies/pyscn/app"
1110
"github.com/ludo-technologies/pyscn/domain"
11+
"github.com/ludo-technologies/pyscn/internal/analyzer"
1212
"github.com/ludo-technologies/pyscn/service"
1313
"github.com/spf13/cobra"
1414
)
@@ -20,9 +20,11 @@ type CheckCommand struct {
2020
quiet bool
2121

2222
// Quick override flags
23-
maxComplexity int
24-
allowDeadCode bool
25-
skipClones bool
23+
maxComplexity int
24+
allowDeadCode bool
25+
skipClones bool
26+
allowCircularDeps bool
27+
maxCycles int
2628

2729
// Select specific analyses to run
2830
selectAnalyses []string
@@ -31,12 +33,14 @@ type CheckCommand struct {
3133
// NewCheckCommand creates a new check command
3234
func NewCheckCommand() *CheckCommand {
3335
return &CheckCommand{
34-
configFile: "",
35-
quiet: false,
36-
maxComplexity: 10, // Fail if complexity > 10
37-
allowDeadCode: false, // Fail on any dead code
38-
skipClones: false,
39-
selectAnalyses: []string{},
36+
configFile: "",
37+
quiet: false,
38+
maxComplexity: 10, // Fail if complexity > 10
39+
allowDeadCode: false, // Fail on any dead code
40+
skipClones: false,
41+
allowCircularDeps: false, // Fail on any circular dependencies
42+
maxCycles: 0, // Fail if more than 0 cycles found
43+
selectAnalyses: []string{},
4044
}
4145
}
4246

@@ -51,8 +55,8 @@ This command performs a fast analysis with predefined thresholds:
5155
• Complexity: Fails if any function has complexity > 10
5256
• Dead Code: Fails if any critical dead code is found
5357
• Clones: Reports clones with similarity > 0.8 (warning only)
54-
55-
By default, all analyses are run. Use --select to choose specific analyses.
58+
• Circular Dependencies: Fails if any cycles are detected
59+
By default, complexity, dead code, and clones analyses are run. Use --select to choose specific analyses.
5660
5761
Exit codes:
5862
• 0: No issues found
@@ -75,13 +79,22 @@ Examples:
7579
# Check complexity and dead code, skip clones
7680
pyscn check --select complexity,deadcode src/
7781
78-
# Check with higher complexity threshold
82+
# Check only for circular dependencies
83+
pyscn check --select deps src/
84+
85+
# Check with higher complexity threshold
7986
pyscn check --max-complexity 15 src/
8087
8188
# Allow dead code, only check complexity
8289
pyscn check --allow-dead-code src/
8390
84-
# Skip clone detection for faster analysis
91+
# Allow circular dependencies (warning only)
92+
pyscn check --allow-circular-deps src/
93+
94+
# Allow up to 3 circular dependency cycles
95+
pyscn check --max-cycles 3 src/
96+
97+
# Skip clone detection for faster analysis
8598
pyscn check --skip-clones src/`,
8699
Args: cobra.ArbitraryArgs,
87100
RunE: c.runCheck,
@@ -95,10 +108,12 @@ Examples:
95108
cmd.Flags().IntVar(&c.maxComplexity, "max-complexity", 10, "Maximum allowed complexity")
96109
cmd.Flags().BoolVar(&c.allowDeadCode, "allow-dead-code", false, "Allow dead code (don't fail)")
97110
cmd.Flags().BoolVar(&c.skipClones, "skip-clones", false, "Skip clone detection")
111+
cmd.Flags().BoolVar(&c.allowCircularDeps, "allow-circular-deps", false, "Allow circular dependencies (warnings only)")
112+
cmd.Flags().IntVar(&c.maxCycles, "max-cycles", 0, "Maximum allowed circular dependency cycles before failing")
98113

99114
// Select specific analyses to run
100115
cmd.Flags().StringSliceVarP(&c.selectAnalyses, "select", "s", []string{},
101-
"Comma-separated list of analyses to run: complexity, deadcode, clones")
116+
"Comma-separated list of analyses to run: complexity, deadcode, clones, deps")
102117

103118
return cmd
104119
}
@@ -118,14 +133,14 @@ func (c *CheckCommand) runCheck(cmd *cobra.Command, args []string) error {
118133
}
119134

120135
// Create use case configuration
121-
skipComplexity, skipDeadCode, skipClones := c.determineEnabledAnalyses()
136+
skipComplexity, skipDeadCode, skipClones, skipDeps := c.determineEnabledAnalyses()
122137

123138
// Count issues found
124139
var issueCount int
125140
var hasErrors bool
126141

127142
if !c.quiet {
128-
fmt.Fprintf(cmd.ErrOrStderr(), "🔍 Running quality check (%s)...\n", strings.Join(c.getEnabledAnalyses(skipComplexity, skipDeadCode, skipClones), ", "))
143+
fmt.Fprintf(cmd.ErrOrStderr(), "🔍 Running quality check (%s)...\n", strings.Join(c.getEnabledAnalyses(skipComplexity, skipDeadCode, skipClones, skipDeps), ", "))
129144
}
130145

131146
// Run complexity check if enabled
@@ -168,14 +183,37 @@ func (c *CheckCommand) runCheck(cmd *cobra.Command, args []string) error {
168183
}
169184
}
170185

186+
// Run circular dependency check if enabled
187+
if !skipDeps {
188+
depsIssues, err := c.checkCircularDependencies(cmd, args)
189+
if err != nil {
190+
fmt.Fprintf(cmd.ErrOrStderr(), "❌ Circular dependency check failed: %v\n", err)
191+
hasErrors = true
192+
} else {
193+
// Handle max-cycles threshold
194+
if depsIssues > c.maxCycles {
195+
if !c.allowCircularDeps {
196+
issueCount += depsIssues
197+
} else if depsIssues > 0 && !c.quiet {
198+
fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Found %d circular dependency cycle(s) (allowed by --allow-circular-deps)\n", depsIssues)
199+
}
200+
} else if depsIssues > 0 && !c.quiet {
201+
// Within max-cycles threshold
202+
fmt.Fprintf(cmd.ErrOrStderr(), "✓ Found %d circular dependency cycle(s) (within allowed limit of %d)\n", depsIssues, c.maxCycles)
203+
}
204+
}
205+
}
206+
171207
// Handle results
172208
if hasErrors {
173209
return fmt.Errorf("analysis failed with errors")
174210
}
175211

212+
// Generic issue handling
176213
if issueCount > 0 {
177-
fmt.Fprintf(cmd.ErrOrStderr(), "❌ Found %d quality issue(s)\n", issueCount)
178-
os.Exit(1) // Exit with code 1 to indicate issues found
214+
fmt.Fprintf(cmd.ErrOrStderr(),
215+
"❌ Found %d quality issue(s)\n", issueCount)
216+
return fmt.Errorf("found %d quality issue(s)", issueCount)
179217
}
180218

181219
if !c.quiet {
@@ -186,33 +224,40 @@ func (c *CheckCommand) runCheck(cmd *cobra.Command, args []string) error {
186224
}
187225

188226
// determineEnabledAnalyses determines which analyses should run based on flags
189-
func (c *CheckCommand) determineEnabledAnalyses() (skipComplexity bool, skipDeadCode bool, skipClones bool) {
227+
func (c *CheckCommand) determineEnabledAnalyses() (skipComplexity bool, skipDeadCode bool, skipClones bool, skipDeps bool) {
190228
if len(c.selectAnalyses) > 0 {
191229
// If --select is used, only run selected analyses
192230
skipComplexity = !c.containsAnalysis("complexity")
193231
skipDeadCode = !c.containsAnalysis("deadcode")
194232
skipClones = !c.containsAnalysis("clones")
233+
skipDeps = !c.containsAnalysis("deps") && !c.containsAnalysis("circular")
195234
} else {
196235
// Otherwise use original behavior (backward compatible)
197236
skipComplexity = false // Always run complexity
198237
skipDeadCode = false // Always run dead code analysis
199238
skipClones = c.skipClones // Only skip clones if explicitly requested
239+
skipDeps = true // Skip deps by default (opt-in via --select)
200240
}
201241
return
202242
}
203243

204244
// containsAnalysis checks if the specified analysis is in the select list
205245
func (c *CheckCommand) containsAnalysis(analysis string) bool {
206246
for _, a := range c.selectAnalyses {
207-
if strings.ToLower(a) == analysis {
247+
lowered := strings.ToLower(a)
248+
if lowered == analysis {
249+
return true
250+
}
251+
// Support both 'deps' and 'circular' for circular dependency analysis
252+
if (analysis == "deps" && lowered == "circular") || (analysis == "circular" && lowered == "deps") {
208253
return true
209254
}
210255
}
211256
return false
212257
}
213258

214259
// getEnabledAnalyses returns a list of enabled analyses for display
215-
func (c *CheckCommand) getEnabledAnalyses(skipComplexity bool, skipDeadCode bool, skipClones bool) []string {
260+
func (c *CheckCommand) getEnabledAnalyses(skipComplexity bool, skipDeadCode bool, skipClones bool, skipDeps bool) []string {
216261
var enabled []string
217262
if !skipComplexity {
218263
enabled = append(enabled, "complexity")
@@ -223,6 +268,9 @@ func (c *CheckCommand) getEnabledAnalyses(skipComplexity bool, skipDeadCode bool
223268
if !skipClones {
224269
enabled = append(enabled, "clones")
225270
}
271+
if !skipDeps {
272+
enabled = append(enabled, "deps")
273+
}
226274
return enabled
227275
}
228276

@@ -232,10 +280,12 @@ func (c *CheckCommand) validateSelectedAnalyses() error {
232280
"complexity": true,
233281
"deadcode": true,
234282
"clones": true,
283+
"deps": true,
284+
"circular": true,
235285
}
236286
for _, analysis := range c.selectAnalyses {
237287
if !validAnalyses[strings.ToLower(analysis)] {
238-
return fmt.Errorf("invalid analysis type: %s. Valid options: complexity, deadcode, clones", analysis)
288+
return fmt.Errorf("invalid analysis type: %s. Valid options: complexity, deadcode, clones, deps", analysis)
239289
}
240290
}
241291
if len(c.selectAnalyses) == 0 {
@@ -451,6 +501,66 @@ func (c *CheckCommand) checkClones(cmd *cobra.Command, args []string) (int, erro
451501
return issueCount, nil
452502
}
453503

504+
// checkCircularDependencies runs circular dependency detection and returns issue count
505+
func (c *CheckCommand) checkCircularDependencies(cmd *cobra.Command, args []string) (int, error) {
506+
// Determine project root (default to current directory if no args)
507+
projectRoot := "."
508+
if len(args) > 0 {
509+
projectRoot = args[0]
510+
}
511+
512+
// Create module analyzer with check-optimized options
513+
opts := &analyzer.ModuleAnalysisOptions{
514+
ProjectRoot: projectRoot,
515+
IncludePatterns: []string{"**/*.py"},
516+
ExcludePatterns: []string{"__pycache__", "*.pyc", ".venv", "venv"},
517+
IncludeStdLib: false, // Exclude standard library for faster analysis
518+
IncludeThirdParty: false, // Exclude third-party for faster analysis
519+
FollowRelative: true, // Follow relative imports
520+
}
521+
522+
moduleAnalyzer, err := analyzer.NewModuleAnalyzer(opts)
523+
if err != nil {
524+
return 0, fmt.Errorf("failed to create module analyzer: %w", err)
525+
}
526+
527+
// Build dependency graph
528+
graph, err := moduleAnalyzer.AnalyzeProject()
529+
if err != nil {
530+
return 0, fmt.Errorf("failed to analyze dependencies: %w", err)
531+
}
532+
533+
// Detect circular dependencies
534+
result := analyzer.DetectCircularDependencies(graph)
535+
536+
if !result.HasCircularDependencies {
537+
return 0, nil
538+
}
539+
540+
// Output circular dependencies in linter format
541+
for _, cycle := range result.CircularDependencies {
542+
if len(cycle.Modules) == 0 {
543+
continue
544+
}
545+
546+
// Get the first module's file path
547+
firstModule := cycle.Modules[0]
548+
node := graph.Nodes[firstModule]
549+
if node == nil {
550+
continue
551+
}
552+
553+
// Format: file:line:col: message
554+
cyclePath := strings.Join(cycle.Modules, " -> ")
555+
if !c.quiet {
556+
fmt.Fprintf(cmd.ErrOrStderr(), "%s:1:1: circular dependency detected: %s\n",
557+
node.FilePath, cyclePath)
558+
}
559+
}
560+
561+
return result.TotalCycles, nil
562+
}
563+
454564
// NewCheckCmd creates and returns the check cobra command
455565
func NewCheckCmd() *cobra.Command {
456566
checkCommand := NewCheckCommand()

0 commit comments

Comments
 (0)