@@ -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
3234func 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
5761Exit 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
205245func (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
455565func NewCheckCmd () * cobra.Command {
456566 checkCommand := NewCheckCommand ()
0 commit comments