gdcruiser 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. gdcruiser-1.1.0/PKG-INFO +207 -0
  2. gdcruiser-1.1.0/README.md +198 -0
  3. gdcruiser-1.1.0/pyproject.toml +37 -0
  4. gdcruiser-1.1.0/src/gdcruiser/__init__.py +3 -0
  5. gdcruiser-1.1.0/src/gdcruiser/analyzer.py +86 -0
  6. gdcruiser-1.1.0/src/gdcruiser/cli.py +186 -0
  7. gdcruiser-1.1.0/src/gdcruiser/config/__init__.py +17 -0
  8. gdcruiser-1.1.0/src/gdcruiser/config/loader.py +140 -0
  9. gdcruiser-1.1.0/src/gdcruiser/config/models.py +72 -0
  10. gdcruiser-1.1.0/src/gdcruiser/config/validator.py +102 -0
  11. gdcruiser-1.1.0/src/gdcruiser/graph/__init__.py +5 -0
  12. gdcruiser-1.1.0/src/gdcruiser/graph/cycles.py +58 -0
  13. gdcruiser-1.1.0/src/gdcruiser/graph/dependency.py +56 -0
  14. gdcruiser-1.1.0/src/gdcruiser/graph/node.py +46 -0
  15. gdcruiser-1.1.0/src/gdcruiser/output/__init__.py +5 -0
  16. gdcruiser-1.1.0/src/gdcruiser/output/dot.py +83 -0
  17. gdcruiser-1.1.0/src/gdcruiser/output/json.py +19 -0
  18. gdcruiser-1.1.0/src/gdcruiser/output/text.py +76 -0
  19. gdcruiser-1.1.0/src/gdcruiser/output/violations.py +60 -0
  20. gdcruiser-1.1.0/src/gdcruiser/parser/__init__.py +5 -0
  21. gdcruiser-1.1.0/src/gdcruiser/parser/gdscript.py +204 -0
  22. gdcruiser-1.1.0/src/gdcruiser/parser/patterns.py +34 -0
  23. gdcruiser-1.1.0/src/gdcruiser/parser/tscn.py +44 -0
  24. gdcruiser-1.1.0/src/gdcruiser/rules/__init__.py +12 -0
  25. gdcruiser-1.1.0/src/gdcruiser/rules/engine.py +207 -0
  26. gdcruiser-1.1.0/src/gdcruiser/rules/matcher.py +35 -0
  27. gdcruiser-1.1.0/src/gdcruiser/rules/models.py +81 -0
  28. gdcruiser-1.1.0/src/gdcruiser/scanner.py +29 -0
  29. gdcruiser-1.1.0/src/gdcruiser/symbols/__init__.py +3 -0
  30. gdcruiser-1.1.0/src/gdcruiser/symbols/table.py +27 -0
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.3
2
+ Name: gdcruiser
3
+ Version: 1.1.0
4
+ Summary: Dependency analyzer for Godot/GDScript projects
5
+ Author: LeTuR
6
+ Author-email: LeTuR <magicletur@protonmail.com>
7
+ Requires-Python: >=3.13
8
+ Description-Content-Type: text/markdown
9
+
10
+ # gdcruiser
11
+
12
+ A dependency analyzer for Godot/GDScript projects. Scans your project to build a dependency graph, detect circular dependencies, and export visualizations.
13
+
14
+ ## Features
15
+
16
+ - Analyzes `.gd` (GDScript) and `.tscn` (scene) files
17
+ - Detects circular dependencies
18
+ - Resolves `class_name` declarations to map symbolic inheritance
19
+ - Multiple output formats: human-readable text, JSON, and GraphViz DOT
20
+ - Returns non-zero exit code when cycles are detected (CI-friendly)
21
+
22
+ ## Installation
23
+
24
+ Requires Python 3.13+.
25
+
26
+ ```bash
27
+ pip install gdcruiser
28
+ ```
29
+
30
+ Or with [uv](https://docs.astral.sh/uv/):
31
+
32
+ ```bash
33
+ uv tool install gdcruiser
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```
39
+ gdcruiser [-h] [-f {text,json,dot}] [-o FILE] [--no-cycles] [-v] [path]
40
+ ```
41
+
42
+ | Option | Description |
43
+ |--------|-------------|
44
+ | `path` | Godot project path (default: current directory) |
45
+ | `-f, --format` | Output format: `text` (default), `json`, or `dot` |
46
+ | `-o, --output` | Write output to file instead of stdout |
47
+ | `--no-cycles` | Skip cycle detection |
48
+ | `-v, --verbose` | Verbose output |
49
+
50
+ ### Examples
51
+
52
+ Analyze the current directory:
53
+
54
+ ```bash
55
+ gdcruiser .
56
+ ```
57
+
58
+ Analyze a specific project and output JSON:
59
+
60
+ ```bash
61
+ gdcruiser /path/to/godot/project -f json
62
+ ```
63
+
64
+ Generate a GraphViz DOT file:
65
+
66
+ ```bash
67
+ gdcruiser . -f dot -o deps.dot
68
+ dot -Tpng deps.dot -o deps.png
69
+ ```
70
+
71
+ ## Output Formats
72
+
73
+ ### Text (default)
74
+
75
+ ```
76
+ ============================================================
77
+ GDScript Dependency Analysis
78
+ ============================================================
79
+
80
+ Modules: 9
81
+ Dependencies: 8
82
+
83
+ ----------------------------------------
84
+ CIRCULAR DEPENDENCIES (1 found)
85
+ ----------------------------------------
86
+
87
+ Cycle 1:
88
+ -> res://cycle_b.gd
89
+ -> res://cycle_a.gd
90
+ -> res://cycle_b.gd (back to start)
91
+
92
+ ----------------------------------------
93
+ MODULE DEPENDENCIES
94
+ ----------------------------------------
95
+
96
+ res://player.gd
97
+ class_name: Player
98
+ extends_class: res://base_entity.gd:2
99
+ preload: res://inventory.gd:5
100
+ ```
101
+
102
+ ### JSON
103
+
104
+ ```json
105
+ {
106
+ "graph": {
107
+ "modules": {
108
+ "res://player.gd": {
109
+ "path": "res://player.gd",
110
+ "class_name": "Player",
111
+ "dependencies": [
112
+ {
113
+ "target": "res://base_entity.gd",
114
+ "type": "extends_class",
115
+ "line": 2,
116
+ "resolved": true
117
+ }
118
+ ]
119
+ }
120
+ },
121
+ "stats": {
122
+ "module_count": 9,
123
+ "dependency_count": 8
124
+ }
125
+ },
126
+ "cycles": [],
127
+ "symbols": {
128
+ "Player": "res://player.gd"
129
+ },
130
+ "errors": []
131
+ }
132
+ ```
133
+
134
+ ### GraphViz DOT
135
+
136
+ ```dot
137
+ digraph dependencies {
138
+ rankdir=LR;
139
+ node [shape=box, fontname="monospace"];
140
+
141
+ "res://player.gd" [label="Player\nplayer.gd"];
142
+ "res://base_entity.gd" [label="BaseEntity\nbase_entity.gd"];
143
+
144
+ "res://player.gd" -> "res://base_entity.gd" [label="extends"];
145
+ }
146
+ ```
147
+
148
+ Nodes involved in cycles are highlighted in red.
149
+
150
+ ## Supported Dependency Patterns
151
+
152
+ gdcruiser detects the following GDScript patterns:
153
+
154
+ | Pattern | Example |
155
+ |---------|---------|
156
+ | `extends` (path) | `extends "res://path/to/script.gd"` |
157
+ | `extends` (class) | `extends ClassName` |
158
+ | `class_name` | `class_name MyClass` |
159
+ | `preload()` | `preload("res://path/to/file.gd")` |
160
+ | `load()` | `load("res://path/to/file.gd")` |
161
+
162
+ For `.tscn` files, it detects scripts attached to nodes via `[ext_resource]`.
163
+
164
+ ## Development
165
+
166
+ Install dependencies:
167
+
168
+ ```bash
169
+ uv sync
170
+ ```
171
+
172
+ Set up pre-commit hooks:
173
+
174
+ ```bash
175
+ uv run pre-commit install
176
+ ```
177
+
178
+ This installs hooks for `pre-commit`, `commit-msg`, and `post-checkout` stages. On every commit the hooks will:
179
+
180
+ - Fix trailing whitespace and line endings
181
+ - Lint and format with [Ruff](https://docs.astral.sh/ruff/)
182
+ - Run the test suite with pytest
183
+ - Enforce [Conventional Commits](https://www.conventionalcommits.org/) for commit messages
184
+
185
+ Run the CLI:
186
+
187
+ ```bash
188
+ uv run gdcruiser
189
+ ```
190
+
191
+ Run tests:
192
+
193
+ ```bash
194
+ uv run pytest
195
+ ```
196
+
197
+ Run linter:
198
+
199
+ ```bash
200
+ uv run ruff check .
201
+ ```
202
+
203
+ Format code:
204
+
205
+ ```bash
206
+ uv run ruff format .
207
+ ```
@@ -0,0 +1,198 @@
1
+ # gdcruiser
2
+
3
+ A dependency analyzer for Godot/GDScript projects. Scans your project to build a dependency graph, detect circular dependencies, and export visualizations.
4
+
5
+ ## Features
6
+
7
+ - Analyzes `.gd` (GDScript) and `.tscn` (scene) files
8
+ - Detects circular dependencies
9
+ - Resolves `class_name` declarations to map symbolic inheritance
10
+ - Multiple output formats: human-readable text, JSON, and GraphViz DOT
11
+ - Returns non-zero exit code when cycles are detected (CI-friendly)
12
+
13
+ ## Installation
14
+
15
+ Requires Python 3.13+.
16
+
17
+ ```bash
18
+ pip install gdcruiser
19
+ ```
20
+
21
+ Or with [uv](https://docs.astral.sh/uv/):
22
+
23
+ ```bash
24
+ uv tool install gdcruiser
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```
30
+ gdcruiser [-h] [-f {text,json,dot}] [-o FILE] [--no-cycles] [-v] [path]
31
+ ```
32
+
33
+ | Option | Description |
34
+ |--------|-------------|
35
+ | `path` | Godot project path (default: current directory) |
36
+ | `-f, --format` | Output format: `text` (default), `json`, or `dot` |
37
+ | `-o, --output` | Write output to file instead of stdout |
38
+ | `--no-cycles` | Skip cycle detection |
39
+ | `-v, --verbose` | Verbose output |
40
+
41
+ ### Examples
42
+
43
+ Analyze the current directory:
44
+
45
+ ```bash
46
+ gdcruiser .
47
+ ```
48
+
49
+ Analyze a specific project and output JSON:
50
+
51
+ ```bash
52
+ gdcruiser /path/to/godot/project -f json
53
+ ```
54
+
55
+ Generate a GraphViz DOT file:
56
+
57
+ ```bash
58
+ gdcruiser . -f dot -o deps.dot
59
+ dot -Tpng deps.dot -o deps.png
60
+ ```
61
+
62
+ ## Output Formats
63
+
64
+ ### Text (default)
65
+
66
+ ```
67
+ ============================================================
68
+ GDScript Dependency Analysis
69
+ ============================================================
70
+
71
+ Modules: 9
72
+ Dependencies: 8
73
+
74
+ ----------------------------------------
75
+ CIRCULAR DEPENDENCIES (1 found)
76
+ ----------------------------------------
77
+
78
+ Cycle 1:
79
+ -> res://cycle_b.gd
80
+ -> res://cycle_a.gd
81
+ -> res://cycle_b.gd (back to start)
82
+
83
+ ----------------------------------------
84
+ MODULE DEPENDENCIES
85
+ ----------------------------------------
86
+
87
+ res://player.gd
88
+ class_name: Player
89
+ extends_class: res://base_entity.gd:2
90
+ preload: res://inventory.gd:5
91
+ ```
92
+
93
+ ### JSON
94
+
95
+ ```json
96
+ {
97
+ "graph": {
98
+ "modules": {
99
+ "res://player.gd": {
100
+ "path": "res://player.gd",
101
+ "class_name": "Player",
102
+ "dependencies": [
103
+ {
104
+ "target": "res://base_entity.gd",
105
+ "type": "extends_class",
106
+ "line": 2,
107
+ "resolved": true
108
+ }
109
+ ]
110
+ }
111
+ },
112
+ "stats": {
113
+ "module_count": 9,
114
+ "dependency_count": 8
115
+ }
116
+ },
117
+ "cycles": [],
118
+ "symbols": {
119
+ "Player": "res://player.gd"
120
+ },
121
+ "errors": []
122
+ }
123
+ ```
124
+
125
+ ### GraphViz DOT
126
+
127
+ ```dot
128
+ digraph dependencies {
129
+ rankdir=LR;
130
+ node [shape=box, fontname="monospace"];
131
+
132
+ "res://player.gd" [label="Player\nplayer.gd"];
133
+ "res://base_entity.gd" [label="BaseEntity\nbase_entity.gd"];
134
+
135
+ "res://player.gd" -> "res://base_entity.gd" [label="extends"];
136
+ }
137
+ ```
138
+
139
+ Nodes involved in cycles are highlighted in red.
140
+
141
+ ## Supported Dependency Patterns
142
+
143
+ gdcruiser detects the following GDScript patterns:
144
+
145
+ | Pattern | Example |
146
+ |---------|---------|
147
+ | `extends` (path) | `extends "res://path/to/script.gd"` |
148
+ | `extends` (class) | `extends ClassName` |
149
+ | `class_name` | `class_name MyClass` |
150
+ | `preload()` | `preload("res://path/to/file.gd")` |
151
+ | `load()` | `load("res://path/to/file.gd")` |
152
+
153
+ For `.tscn` files, it detects scripts attached to nodes via `[ext_resource]`.
154
+
155
+ ## Development
156
+
157
+ Install dependencies:
158
+
159
+ ```bash
160
+ uv sync
161
+ ```
162
+
163
+ Set up pre-commit hooks:
164
+
165
+ ```bash
166
+ uv run pre-commit install
167
+ ```
168
+
169
+ This installs hooks for `pre-commit`, `commit-msg`, and `post-checkout` stages. On every commit the hooks will:
170
+
171
+ - Fix trailing whitespace and line endings
172
+ - Lint and format with [Ruff](https://docs.astral.sh/ruff/)
173
+ - Run the test suite with pytest
174
+ - Enforce [Conventional Commits](https://www.conventionalcommits.org/) for commit messages
175
+
176
+ Run the CLI:
177
+
178
+ ```bash
179
+ uv run gdcruiser
180
+ ```
181
+
182
+ Run tests:
183
+
184
+ ```bash
185
+ uv run pytest
186
+ ```
187
+
188
+ Run linter:
189
+
190
+ ```bash
191
+ uv run ruff check .
192
+ ```
193
+
194
+ Format code:
195
+
196
+ ```bash
197
+ uv run ruff format .
198
+ ```
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "gdcruiser"
3
+ version = "1.1.0"
4
+ description = "Dependency analyzer for Godot/GDScript projects"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "LeTuR", email = "magicletur@protonmail.com" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ dependencies = []
11
+
12
+ [project.scripts]
13
+ gdcruiser = "gdcruiser:main"
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.9.13,<0.10.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest>=9.0.2",
22
+ "ruff>=0.15.0",
23
+ ]
24
+
25
+ [tool.semantic_release]
26
+ version_toml = []
27
+ branch = "main"
28
+ tag_format = "v{version}"
29
+ commit_message = ""
30
+
31
+ [tool.semantic_release.commit_parser_options]
32
+ allowed_tags = ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "style", "test"]
33
+ minor_tags = ["feat"]
34
+ patch_tags = ["fix", "perf"]
35
+
36
+ [tool.semantic_release.remote]
37
+ type = "github"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,86 @@
1
+ from dataclasses import dataclass, field
2
+ from pathlib import Path
3
+
4
+ from .scanner import Scanner
5
+ from .parser.gdscript import GDScriptParser
6
+ from .parser.tscn import TscnParser
7
+ from .graph.dependency import DependencyGraph
8
+ from .graph.cycles import CycleDetector
9
+ from .symbols.table import SymbolTable
10
+
11
+
12
+ @dataclass
13
+ class AnalysisResult:
14
+ """Result of analyzing a Godot project."""
15
+
16
+ graph: DependencyGraph
17
+ cycles: list[list[str]] = field(default_factory=list)
18
+ symbol_table: SymbolTable = field(default_factory=SymbolTable)
19
+ errors: list[str] = field(default_factory=list)
20
+
21
+ def to_dict(self) -> dict:
22
+ return {
23
+ "graph": self.graph.to_dict(),
24
+ "cycles": self.cycles,
25
+ "symbols": self.symbol_table.all_classes(),
26
+ "errors": self.errors,
27
+ }
28
+
29
+
30
+ class Analyzer:
31
+ """Orchestrates parsing and graph building for a Godot project."""
32
+
33
+ def __init__(self, project_path: Path, verbose: bool = False) -> None:
34
+ self._scanner = Scanner(project_path)
35
+ self._symbol_table = SymbolTable()
36
+ self._gd_parser = GDScriptParser(self._symbol_table)
37
+ self._tscn_parser = TscnParser()
38
+ self._graph = DependencyGraph()
39
+ self._verbose = verbose
40
+ self._errors: list[str] = []
41
+
42
+ def analyze(self, detect_cycles: bool = True) -> AnalysisResult:
43
+ """Analyze the project and return results."""
44
+ gd_files, tscn_files = self._scanner.find_all_files()
45
+ root = self._scanner.root
46
+
47
+ if self._verbose:
48
+ print(f"Found {len(gd_files)} GDScript files")
49
+ print(f"Found {len(tscn_files)} scene files")
50
+
51
+ # First pass: parse all GDScript files to build symbol table
52
+ modules = []
53
+ for gd_file in gd_files:
54
+ try:
55
+ module = self._gd_parser.parse(gd_file, root)
56
+ modules.append(module)
57
+ self._graph.add_module(module)
58
+ except Exception as e:
59
+ self._errors.append(f"Error parsing {gd_file}: {e}")
60
+
61
+ # Second pass: resolve class name dependencies
62
+ for module in modules:
63
+ self._gd_parser.resolve_class_dependencies(module)
64
+
65
+ # Parse scene files
66
+ for tscn_file in tscn_files:
67
+ try:
68
+ module = self._tscn_parser.parse(tscn_file, root)
69
+ self._graph.add_module(module)
70
+ except Exception as e:
71
+ self._errors.append(f"Error parsing {tscn_file}: {e}")
72
+
73
+ # Detect cycles
74
+ cycles: list[list[str]] = []
75
+ if detect_cycles:
76
+ detector = CycleDetector(self._graph)
77
+ cycles = detector.find_cycles()
78
+ if self._verbose:
79
+ print(f"Found {len(cycles)} cycles")
80
+
81
+ return AnalysisResult(
82
+ graph=self._graph,
83
+ cycles=cycles,
84
+ symbol_table=self._symbol_table,
85
+ errors=self._errors,
86
+ )
@@ -0,0 +1,186 @@
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from .analyzer import Analyzer
6
+ from .config import ConfigError, ConfigLoader, ConfigValidator
7
+ from .output.dot import DotFormatter
8
+ from .output.json import JsonFormatter
9
+ from .output.text import TextFormatter
10
+ from .rules import RuleEngine
11
+
12
+
13
+ def create_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="gdcruiser",
16
+ description="Dependency analyzer for Godot/GDScript projects",
17
+ formatter_class=argparse.RawDescriptionHelpFormatter,
18
+ epilog="""
19
+ Examples:
20
+ gdcruiser . Analyze current directory
21
+ gdcruiser /path/to/project Analyze specific project
22
+ gdcruiser . -f json Output as JSON
23
+ gdcruiser . -f dot -o deps.dot Output DOT file for GraphViz
24
+ gdcruiser . --no-cycles Skip cycle detection
25
+ gdcruiser . --config rules.json Use custom config file
26
+ gdcruiser . --validate-config Validate config without analyzing
27
+ """,
28
+ )
29
+
30
+ parser.add_argument(
31
+ "path",
32
+ nargs="?",
33
+ default=".",
34
+ help="Godot project path (default: current directory)",
35
+ )
36
+
37
+ parser.add_argument(
38
+ "-f",
39
+ "--format",
40
+ choices=["text", "json", "dot"],
41
+ default="text",
42
+ help="Output format (default: text)",
43
+ )
44
+
45
+ parser.add_argument(
46
+ "-o",
47
+ "--output",
48
+ metavar="FILE",
49
+ help="Output file (default: stdout)",
50
+ )
51
+
52
+ parser.add_argument(
53
+ "--no-cycles",
54
+ action="store_true",
55
+ help="Skip cycle detection",
56
+ )
57
+
58
+ parser.add_argument(
59
+ "-v",
60
+ "--verbose",
61
+ action="store_true",
62
+ help="Verbose output",
63
+ )
64
+
65
+ parser.add_argument(
66
+ "--config",
67
+ metavar="FILE",
68
+ help="Path to config file (.gdcruiser.json or pyproject.toml)",
69
+ )
70
+
71
+ parser.add_argument(
72
+ "--validate-config",
73
+ action="store_true",
74
+ help="Validate config file and exit",
75
+ )
76
+
77
+ parser.add_argument(
78
+ "--ignore-rules",
79
+ action="store_true",
80
+ help="Skip rule evaluation",
81
+ )
82
+
83
+ return parser
84
+
85
+
86
+ def run(args: argparse.Namespace) -> int:
87
+ project_path = Path(args.path).resolve()
88
+
89
+ if not project_path.exists():
90
+ print(f"Error: Path does not exist: {project_path}", file=sys.stderr)
91
+ return 1
92
+
93
+ if not project_path.is_dir():
94
+ print(f"Error: Path is not a directory: {project_path}", file=sys.stderr)
95
+ return 1
96
+
97
+ # Load configuration
98
+ config_path = Path(args.config) if args.config else None
99
+ loader = ConfigLoader(project_path)
100
+
101
+ try:
102
+ config = loader.load(config_path)
103
+ except ConfigError as e:
104
+ print(f"Error: {e}", file=sys.stderr)
105
+ return 1
106
+
107
+ # Validate config
108
+ if config.has_rules() or args.validate_config:
109
+ validator = ConfigValidator()
110
+ validation = validator.validate(config)
111
+
112
+ if args.verbose or args.validate_config:
113
+ if validation.warnings:
114
+ for w in validation.warnings:
115
+ print(f"Warning: {w.path}: {w.message}", file=sys.stderr)
116
+
117
+ if not validation.is_valid():
118
+ for e in validation.errors:
119
+ print(f"Error: {e.path}: {e.message}", file=sys.stderr)
120
+ return 1
121
+
122
+ if args.validate_config:
123
+ config_file = config_path or loader.discover()
124
+ if config_file:
125
+ print(f"Config valid: {config_file}")
126
+ print(f" Forbidden rules: {len(config.forbidden)}")
127
+ print(f" Allowed rules: {len(config.allowed)}")
128
+ print(f" Required rules: {len(config.required)}")
129
+ else:
130
+ print("No config file found")
131
+ return 0
132
+
133
+ if args.verbose:
134
+ print(f"Analyzing: {project_path}")
135
+ if config.has_rules():
136
+ print(f"Rules loaded: {len(config.all_rules())}")
137
+
138
+ analyzer = Analyzer(project_path, verbose=args.verbose)
139
+ result = analyzer.analyze(detect_cycles=not args.no_cycles)
140
+
141
+ # Evaluate rules
142
+ rule_result = None
143
+ if config.has_rules() and not args.ignore_rules:
144
+ engine = RuleEngine(config, result.graph)
145
+ rule_result = engine.check_all(cycles=result.cycles)
146
+
147
+ if args.verbose:
148
+ print(
149
+ f"Rule violations: {rule_result.error_count()} errors, "
150
+ f"{rule_result.warning_count()} warnings"
151
+ )
152
+
153
+ # Format output
154
+ if args.format == "json":
155
+ formatter = JsonFormatter()
156
+ output = formatter.format(result, rule_result)
157
+ elif args.format == "dot":
158
+ formatter = DotFormatter()
159
+ output = formatter.format(result)
160
+ else:
161
+ formatter = TextFormatter()
162
+ output = formatter.format(result, rule_result)
163
+
164
+ # Write output
165
+ if args.output:
166
+ output_path = Path(args.output)
167
+ output_path.write_text(output, encoding="utf-8")
168
+ if args.verbose:
169
+ print(f"Output written to: {output_path}")
170
+ else:
171
+ print(output)
172
+
173
+ # Return non-zero if rule errors or cycles found
174
+ if rule_result and rule_result.has_errors():
175
+ return 1
176
+
177
+ if result.cycles and not args.no_cycles:
178
+ return 1
179
+
180
+ return 0
181
+
182
+
183
+ def main() -> int:
184
+ parser = create_parser()
185
+ args = parser.parse_args()
186
+ return run(args)