Skip to content

Commit 9ba4e97

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

File tree

8 files changed

+484
-11
lines changed

8 files changed

+484
-11
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: 102 additions & 9 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,27 @@ 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+
for _, analyses := range c.selectAnalyses {
149+
if strings.ToLower(analyses) == "circular" || strings.ToLower(analyses) == "deps" {
150+
skipCircular = false
151+
break
152+
}
153+
}
122154

123155
// Count issues found
124156
var issueCount int
@@ -168,14 +200,33 @@ func (c *CheckCommand) runCheck(cmd *cobra.Command, args []string) error {
168200
}
169201
}
170202

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

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

181232
if !c.quiet {
@@ -204,7 +255,10 @@ func (c *CheckCommand) determineEnabledAnalyses() (skipComplexity bool, skipDead
204255
// containsAnalysis checks if the specified analysis is in the select list
205256
func (c *CheckCommand) containsAnalysis(analysis string) bool {
206257
for _, a := range c.selectAnalyses {
207-
if strings.ToLower(a) == analysis {
258+
lowered := strings.ToLower(a)
259+
if lowered == analysis ||
260+
(analysis == "circular" && lowered == "deps") ||
261+
(analysis == "deps" && lowered == "circular") {
208262
return true
209263
}
210264
}
@@ -232,6 +286,8 @@ func (c *CheckCommand) validateSelectedAnalyses() error {
232286
"complexity": true,
233287
"deadcode": true,
234288
"clones": true,
289+
"circular": true,
290+
"deps": true,
235291
}
236292
for _, analysis := range c.selectAnalyses {
237293
if !validAnalyses[strings.ToLower(analysis)] {
@@ -456,3 +512,40 @@ func NewCheckCmd() *cobra.Command {
456512
checkCommand := NewCheckCommand()
457513
return checkCommand.CreateCobraCommand()
458514
}
515+
516+
// checkCircularDependencies performs circular dependency detection on the provided paths,
517+
// using the CircularDependencyService to build a dependency graph and detect cycles.
518+
func (c *CheckCommand) checkCircularDependencies(cmd *cobra.Command, args []string, prefix string) (int, error) {
519+
// Establish and utilize services for dependency graph construction and loop detection
520+
service := service.NewCircularDependencyService()
521+
522+
// Detect the loop cycles from given paths, with start node and prefix filtering
523+
cycles, err := service.DetectCycles(args, c.desiredStart, prefix)
524+
if err != nil {
525+
return 0, err
526+
}
527+
528+
importPositions := service.GetImportPositions()
529+
530+
issueCount := len(cycles)
531+
532+
// Iterate detected cycles and output formatted circular dependency messages
533+
for _, cycle := range cycles {
534+
pos := importPositions[cycle[0]][cycle[1]]
535+
if pos.Line == 0 {
536+
pos.Line = 1
537+
}
538+
if pos.Column == 0 {
539+
pos.Column = 1
540+
}
541+
fmt.Fprintf(cmd.ErrOrStderr(), "%s:%d:%d: circular dependency detected: %s\n",
542+
cycle[0], pos.Line, pos.Column, strings.Join(cycle, " -> "))
543+
}
544+
545+
// Enforce maxCycles limit and allowCircularDeps flag, return error if limit exceeded
546+
if issueCount > c.maxCycles && !c.allowCircularDeps {
547+
return issueCount, fmt.Errorf("too many circular dependencies")
548+
}
549+
550+
return issueCount, nil
551+
}

cmd/pyscn/new_commands_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,3 +394,25 @@ func TestCommandHelpOutput(t *testing.T) {
394394
})
395395
}
396396
}
397+
398+
func TestCheckCommand_CircularDep(test *testing.T) {
399+
checkCmd := NewCheckCommand()
400+
cobraCmd := checkCmd.CreateCobraCommand()
401+
402+
var stdout, stderr bytes.Buffer
403+
cobraCmd.SetOut(&stdout)
404+
cobraCmd.SetErr(&stderr)
405+
406+
cobraCmd.SetArgs([]string{"--select", "deps", "testdata/python"})
407+
408+
err := cobraCmd.Execute()
409+
output := stdout.String() + stderr.String()
410+
411+
if err == nil {
412+
test.Fatalf("Expected error because of circular dependency, got none, output: %s", output)
413+
}
414+
415+
if !strings.Contains(output, "circular dependency detected") {
416+
test.Errorf("Expected circular dependency warning, got: %s", output)
417+
}
418+
}

0 commit comments

Comments
 (0)