Skip to content

Commit f18bd44

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

File tree

8 files changed

+471
-10
lines changed

8 files changed

+471
-10
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: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,26 @@ type CheckCommand struct {
2626

2727
// Select specific analyses to run
2828
selectAnalyses []string
29+
30+
// Check circular dependency related fields
31+
allowCircularDeps bool
32+
circularIssueCount int // Number of circular dependency issues found in the current run
33+
maxCycles int
34+
desiredStart string // Optional node name used to rotate detected cycles to start with this node
35+
prefix string // Path prefix used to filter modules during circular dependency detection
2936
}
3037

3138
// NewCheckCommand creates a new check command
3239
func NewCheckCommand() *CheckCommand {
3340
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{},
41+
configFile: "",
42+
quiet: false,
43+
maxComplexity: 10, // Fail if complexity > 10
44+
allowDeadCode: false, // Fail on any dead code
45+
skipClones: false,
46+
selectAnalyses: []string{},
47+
allowCircularDeps: false,
48+
maxCycles: 0,
4049
}
4150
}
4251

@@ -100,6 +109,10 @@ Examples:
100109
cmd.Flags().StringSliceVarP(&c.selectAnalyses, "select", "s", []string{},
101110
"Comma-separated list of analyses to run: complexity, deadcode, clones")
102111

112+
// Check circular dependency
113+
cmd.Flags().BoolVar(&c.allowCircularDeps, "allow-circular-deps", false, "Allow circular dependencies (warnings only)")
114+
cmd.Flags().IntVar(&c.maxCycles, "max-cycles", 0, "Maximum allowed circular dependency cycles before failing")
115+
103116
return cmd
104117
}
105118

@@ -117,8 +130,26 @@ func (c *CheckCommand) runCheck(cmd *cobra.Command, args []string) error {
117130
}
118131
}
119132

133+
// Dynamically generate the prefix using the first path as the base, ensuring it ends with a slash
134+
if len(args) > 0 {
135+
if args[0] == "." {
136+
c.prefix = "" // Avoid prefixes starting with "./"; set to an empty string to facilitate matching absolute paths
137+
} else {
138+
c.prefix = strings.TrimSuffix(args[0], "/") + "/"
139+
}
140+
} else {
141+
c.prefix = ""
142+
}
143+
120144
// Create use case configuration
121145
skipComplexity, skipDeadCode, skipClones := c.determineEnabledAnalyses()
146+
// Check circular dependencies
147+
skipCircular := true
148+
if len(c.selectAnalyses) > 0 {
149+
skipCircular = !c.containsAnalysis("deps")
150+
} else {
151+
skipCircular = false
152+
}
122153

123154
// Count issues found
124155
var issueCount int
@@ -168,14 +199,33 @@ func (c *CheckCommand) runCheck(cmd *cobra.Command, args []string) error {
168199
}
169200
}
170201

202+
// Pass prefix when calling loop detection
203+
if !skipCircular {
204+
circularIssues, err := c.checkCircularDependencies(cmd, args, c.prefix)
205+
if err != nil {
206+
// Print error message
207+
fmt.Fprintf(cmd.ErrOrStderr(), "❌ Circular dependency check failed: %v\n", err)
208+
// Return an error, terminate the process, and the CLI will exit with an error code of 1.
209+
return err
210+
}
211+
c.circularIssueCount = circularIssues
212+
issueCount += circularIssues
213+
}
214+
171215
// Handle results
172216
if hasErrors {
173217
return fmt.Errorf("analysis failed with errors")
174218
}
175219

176220
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
221+
if c.allowCircularDeps && issueCount == c.circularIssueCount {
222+
if !c.quiet {
223+
fmt.Fprintf(cmd.ErrOrStderr(), "! Found %d circular dependency warning(s) (allowed by flag)\n", issueCount)
224+
}
225+
} else {
226+
fmt.Fprintf(cmd.ErrOrStderr(), "❌ Found %d quality issue(s)\n", issueCount)
227+
os.Exit(1) // Exit with code 1 to indicate issues found
228+
}
179229
}
180230

181231
if !c.quiet {
@@ -232,6 +282,8 @@ func (c *CheckCommand) validateSelectedAnalyses() error {
232282
"complexity": true,
233283
"deadcode": true,
234284
"clones": true,
285+
"circular": true,
286+
"deps": true,
235287
}
236288
for _, analysis := range c.selectAnalyses {
237289
if !validAnalyses[strings.ToLower(analysis)] {
@@ -456,3 +508,40 @@ func NewCheckCmd() *cobra.Command {
456508
checkCommand := NewCheckCommand()
457509
return checkCommand.CreateCobraCommand()
458510
}
511+
512+
// checkCircularDependencies performs circular dependency detection on the provided paths,
513+
// using the CircularDependencyService to build a dependency graph and detect cycles.
514+
func (c *CheckCommand) checkCircularDependencies(cmd *cobra.Command, args []string, prefix string) (int, error) {
515+
// Establish and utilize services for dependency graph construction and loop detection
516+
service := service.NewCircularDependencyService()
517+
518+
// Detect the loop cycles from given paths, with start node and prefix filtering
519+
cycles, err := service.DetectCycles(args, c.desiredStart, prefix)
520+
if err != nil {
521+
return 0, err
522+
}
523+
524+
importPositions := service.GetImportPositions()
525+
526+
issueCount := len(cycles)
527+
528+
// Iterate detected cycles and output formatted circular dependency messages
529+
for _, cycle := range cycles {
530+
pos := importPositions[cycle[0]][cycle[1]]
531+
if pos.Line == 0 {
532+
pos.Line = 1
533+
}
534+
if pos.Column == 0 {
535+
pos.Column = 1
536+
}
537+
fmt.Fprintf(cmd.ErrOrStderr(), "%s:%d:%d: circular dependency detected: %s\n",
538+
cycle[0], pos.Line, pos.Column, strings.Join(cycle, " -> "))
539+
}
540+
541+
// Enforce maxCycles limit and allowCircularDeps flag, return error if limit exceeded
542+
if issueCount > c.maxCycles && !c.allowCircularDeps {
543+
return issueCount, fmt.Errorf("too many circular dependencies")
544+
}
545+
546+
return issueCount, nil
547+
}

cmd/pyscn/new_commands_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"bytes"
55
"os"
6+
"os/exec"
67
"path/filepath"
78
"strings"
89
"testing"
@@ -394,3 +395,16 @@ func TestCommandHelpOutput(t *testing.T) {
394395
})
395396
}
396397
}
398+
399+
func TestCheckCommand_CircularDep(test *testing.T) {
400+
cmd := exec.Command("pyscn", "check", "--select", "deps", "../../testdata/python")
401+
output, err := cmd.CombinedOutput()
402+
403+
if err == nil {
404+
test.Fatal("Expected error because of circular dependency, got none")
405+
}
406+
407+
if !strings.Contains(string(output), "circular dependency detected") {
408+
test.Errorf("Expected circular dependency warning, got: %s", output)
409+
}
410+
}

0 commit comments

Comments
 (0)