@orionscanner/cli 0.1.0

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 (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +248 -0
  3. package/assets/logo.png +0 -0
  4. package/bin/orion.js +48 -0
  5. package/docs/API_REFERENCE.md +211 -0
  6. package/docs/ARCHITECTURE.md +183 -0
  7. package/docs/CHANGELOG.md +112 -0
  8. package/docs/CONTRIBUTING.md +157 -0
  9. package/docs/ENGINE_CATALOG.md +87 -0
  10. package/docs/GETTING_STARTED.md +226 -0
  11. package/package.json +48 -0
  12. package/pyproject.toml +16 -0
  13. package/src/orion/__init__.py +0 -0
  14. package/src/orion/__pycache__/__init__.cpython-313.pyc +0 -0
  15. package/src/orion/engine/__init__.py +17 -0
  16. package/src/orion/engine/__pycache__/__init__.cpython-313.pyc +0 -0
  17. package/src/orion/engine/__pycache__/config.cpython-313.pyc +0 -0
  18. package/src/orion/engine/__pycache__/core.cpython-313.pyc +0 -0
  19. package/src/orion/engine/__pycache__/ir.cpython-313.pyc +0 -0
  20. package/src/orion/engine/__pycache__/languages.cpython-313.pyc +0 -0
  21. package/src/orion/engine/__pycache__/registry.cpython-313.pyc +0 -0
  22. package/src/orion/engine/__pycache__/results.cpython-313.pyc +0 -0
  23. package/src/orion/engine/__pycache__/runner.cpython-313.pyc +0 -0
  24. package/src/orion/engine/api_security/__pycache__/engine.cpython-313.pyc +0 -0
  25. package/src/orion/engine/api_security/engine.py +125 -0
  26. package/src/orion/engine/architecture/__pycache__/engine.cpython-313.pyc +0 -0
  27. package/src/orion/engine/architecture/engine.py +463 -0
  28. package/src/orion/engine/authorization/__pycache__/engine.cpython-313.pyc +0 -0
  29. package/src/orion/engine/authorization/engine.py +337 -0
  30. package/src/orion/engine/callgraph/__pycache__/engine.cpython-313.pyc +0 -0
  31. package/src/orion/engine/callgraph/engine.py +236 -0
  32. package/src/orion/engine/cfg/__pycache__/engine.cpython-313.pyc +0 -0
  33. package/src/orion/engine/cfg/engine.py +509 -0
  34. package/src/orion/engine/chaos/__pycache__/engine.cpython-313.pyc +0 -0
  35. package/src/orion/engine/chaos/engine.py +268 -0
  36. package/src/orion/engine/chaos_config/__pycache__/engine.cpython-313.pyc +0 -0
  37. package/src/orion/engine/chaos_config/engine.py +322 -0
  38. package/src/orion/engine/complexity/__pycache__/engine.cpython-313.pyc +0 -0
  39. package/src/orion/engine/complexity/engine.py +144 -0
  40. package/src/orion/engine/compliance/__pycache__/engine.cpython-313.pyc +0 -0
  41. package/src/orion/engine/compliance/engine.py +325 -0
  42. package/src/orion/engine/config.py +41 -0
  43. package/src/orion/engine/config_engine/__pycache__/engine.cpython-313.pyc +0 -0
  44. package/src/orion/engine/config_engine/engine.py +364 -0
  45. package/src/orion/engine/config_validation/__pycache__/engine.cpython-313.pyc +0 -0
  46. package/src/orion/engine/config_validation/engine.py +313 -0
  47. package/src/orion/engine/container/__pycache__/engine.cpython-313.pyc +0 -0
  48. package/src/orion/engine/container/engine.py +546 -0
  49. package/src/orion/engine/contract_probe/__pycache__/engine.cpython-313.pyc +0 -0
  50. package/src/orion/engine/contract_probe/engine.py +316 -0
  51. package/src/orion/engine/core.py +75 -0
  52. package/src/orion/engine/crypto_guard/__pycache__/engine.cpython-313.pyc +0 -0
  53. package/src/orion/engine/crypto_guard/engine.py +134 -0
  54. package/src/orion/engine/data_residency/__pycache__/engine.cpython-313.pyc +0 -0
  55. package/src/orion/engine/data_residency/engine.py +238 -0
  56. package/src/orion/engine/dataflow/__pycache__/engine.cpython-313.pyc +0 -0
  57. package/src/orion/engine/dataflow/engine.py +398 -0
  58. package/src/orion/engine/dead_import/__pycache__/engine.cpython-313.pyc +0 -0
  59. package/src/orion/engine/dead_import/engine.py +293 -0
  60. package/src/orion/engine/deps/__pycache__/engine.cpython-313.pyc +0 -0
  61. package/src/orion/engine/deps/engine.py +244 -0
  62. package/src/orion/engine/dispatcher.py +350 -0
  63. package/src/orion/engine/docs/AUDIT_PROMPT.md +1248 -0
  64. package/src/orion/engine/duplication/__pycache__/engine.cpython-313.pyc +0 -0
  65. package/src/orion/engine/duplication/engine.py +120 -0
  66. package/src/orion/engine/error_handling/__pycache__/engine.cpython-313.pyc +0 -0
  67. package/src/orion/engine/error_handling/engine.py +256 -0
  68. package/src/orion/engine/fp_learn/__pycache__/engine.cpython-313.pyc +0 -0
  69. package/src/orion/engine/fp_learn/engine.py +248 -0
  70. package/src/orion/engine/hunter/__pycache__/engine.cpython-313.pyc +0 -0
  71. package/src/orion/engine/hunter/engine.py +324 -0
  72. package/src/orion/engine/iac_security/__pycache__/engine.cpython-313.pyc +0 -0
  73. package/src/orion/engine/iac_security/engine.py +218 -0
  74. package/src/orion/engine/ir.py +22 -0
  75. package/src/orion/engine/languages.py +454 -0
  76. package/src/orion/engine/license/__pycache__/engine.cpython-313.pyc +0 -0
  77. package/src/orion/engine/license/engine.py +172 -0
  78. package/src/orion/engine/lint/__pycache__/engine.cpython-313.pyc +0 -0
  79. package/src/orion/engine/lint/engine.py +558 -0
  80. package/src/orion/engine/llm_security/__pycache__/engine.cpython-313.pyc +0 -0
  81. package/src/orion/engine/llm_security/engine.py +125 -0
  82. package/src/orion/engine/logging_c/__pycache__/engine.cpython-313.pyc +0 -0
  83. package/src/orion/engine/logging_c/engine.py +164 -0
  84. package/src/orion/engine/noise/__pycache__/engine.cpython-313.pyc +0 -0
  85. package/src/orion/engine/noise/engine.py +140 -0
  86. package/src/orion/engine/orphan/__pycache__/engine.cpython-313.pyc +0 -0
  87. package/src/orion/engine/orphan/engine.py +244 -0
  88. package/src/orion/engine/perf_guard/__pycache__/engine.cpython-313.pyc +0 -0
  89. package/src/orion/engine/perf_guard/engine.py +345 -0
  90. package/src/orion/engine/prompt_security/__pycache__/engine.cpython-313.pyc +0 -0
  91. package/src/orion/engine/prompt_security/detectors/__init__.py +4 -0
  92. package/src/orion/engine/prompt_security/engine.py +170 -0
  93. package/src/orion/engine/provenance/__pycache__/engine.cpython-313.pyc +0 -0
  94. package/src/orion/engine/provenance/engine.py +242 -0
  95. package/src/orion/engine/registry.py +55 -0
  96. package/src/orion/engine/regression/__pycache__/engine.cpython-313.pyc +0 -0
  97. package/src/orion/engine/regression/engine.py +213 -0
  98. package/src/orion/engine/results.py +21 -0
  99. package/src/orion/engine/runner.py +1310 -0
  100. package/src/orion/engine/sandbox_escape/__pycache__/engine.cpython-313.pyc +0 -0
  101. package/src/orion/engine/sandbox_escape/engine.py +284 -0
  102. package/src/orion/engine/sast/__pycache__/engine.cpython-313.pyc +0 -0
  103. package/src/orion/engine/sast/engine.py +217 -0
  104. package/src/orion/engine/sast/patterns/__init__.py +513 -0
  105. package/src/orion/engine/sast/patterns/__pycache__/__init__.cpython-313.pyc +0 -0
  106. package/src/orion/engine/secrets/__pycache__/engine.cpython-313.pyc +0 -0
  107. package/src/orion/engine/secrets/engine.py +77 -0
  108. package/src/orion/engine/secrets/patterns/__init__.py +74 -0
  109. package/src/orion/engine/secrets/patterns/__pycache__/__init__.cpython-313.pyc +0 -0
  110. package/src/orion/engine/sensitive_data/__pycache__/engine.cpython-313.pyc +0 -0
  111. package/src/orion/engine/sensitive_data/engine.py +143 -0
  112. package/src/orion/engine/state/__pycache__/engine.cpython-313.pyc +0 -0
  113. package/src/orion/engine/state/engine.py +218 -0
  114. package/src/orion/engine/supply_chain_sbom/__pycache__/engine.cpython-313.pyc +0 -0
  115. package/src/orion/engine/supply_chain_sbom/engine.py +290 -0
  116. package/src/orion/engine/symbol/__pycache__/engine.cpython-313.pyc +0 -0
  117. package/src/orion/engine/symbol/engine.py +463 -0
  118. package/src/orion/engine/taint/__pycache__/engine.cpython-313.pyc +0 -0
  119. package/src/orion/engine/taint/engine.py +464 -0
  120. package/src/orion/engine/test_coverage/__pycache__/engine.cpython-313.pyc +0 -0
  121. package/src/orion/engine/test_coverage/engine.py +445 -0
  122. package/src/orion/engine/threat_model/__pycache__/engine.cpython-313.pyc +0 -0
  123. package/src/orion/engine/threat_model/engine.py +126 -0
  124. package/src/orion/engine/wiring_trace/__pycache__/engine.cpython-313.pyc +0 -0
  125. package/src/orion/engine/wiring_trace/engine.py +200 -0
  126. package/src/orion_engine.egg-info/PKG-INFO +7 -0
  127. package/src/orion_engine.egg-info/SOURCES.txt +65 -0
  128. package/src/orion_engine.egg-info/dependency_links.txt +1 -0
  129. package/src/orion_engine.egg-info/entry_points.txt +2 -0
  130. package/src/orion_engine.egg-info/top_level.txt +1 -0
@@ -0,0 +1,463 @@
1
+ """Architecture violations engine - detects structural issues in codebase."""
2
+ import ast
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Dict, List, Set, Optional, Tuple
6
+ from collections import defaultdict, deque
7
+
8
+ from orion.engine.config import EngineConfig
9
+ from orion.engine.ir import OrionIR
10
+ from orion.engine.results import EngineResult, Finding
11
+
12
+
13
+ class ArchitectureEngine:
14
+ name = "architecture"
15
+ version = "1.0.0"
16
+
17
+ LAYER_ORDER = ["domain", "application", "infrastructure", "presentation"]
18
+ LAYER_PATTERNS = {
19
+ "domain": ["domain", "entities", "value_objects", "aggregates", "events", "specifications"],
20
+ "application": ["application", "use_cases", "services", "commands", "queries", "dtos", "ports"],
21
+ "infrastructure": ["infrastructure", "repositories", "persistence", "external", "adapters", "gateways", "orm", "database", "migrations"],
22
+ "presentation": ["presentation", "api", "controllers", "views", "handlers", "endpoints", "routes", "graphql", "rest", "cli", "web", "ui"],
23
+ }
24
+
25
+ EXCLUDED_DIRS = {"__pycache__", ".git", ".venv", "venv", "env", "node_modules", "dist", "build", "tests", "test", "scripts", "migrations"}
26
+
27
+ async def check_availability(self) -> bool:
28
+ return True
29
+
30
+ def resource_limits(self):
31
+ from orion.engine.config import ResourceLimits
32
+ return ResourceLimits(max_cpu_seconds=60, max_memory_mb=256)
33
+
34
+ def _should_exclude(self, filepath: str, config: EngineConfig) -> bool:
35
+ path = Path(filepath)
36
+ for part in path.parts:
37
+ if part in self.EXCLUDED_DIRS:
38
+ return True
39
+ if config.toggles.get("exclude"):
40
+ for pattern in config.toggles["exclude"]:
41
+ if path.match(pattern) or any(p.match(pattern) for p in path.parents):
42
+ return True
43
+ return False
44
+
45
+ def _is_python_file(self, filepath: str) -> bool:
46
+ return Path(filepath).suffix == ".py"
47
+
48
+ def _get_layer(self, filepath: str) -> Optional[str]:
49
+ """Determine architectural layer from file path."""
50
+ path_str = str(Path(filepath)).replace('\\', '/').lower()
51
+ for layer, patterns in self.LAYER_PATTERNS.items():
52
+ for pattern in patterns:
53
+ if f"/{pattern}/" in path_str or path_str.startswith(f"{pattern}/"):
54
+ return layer
55
+ return None
56
+
57
+ def _get_module_name(self, filepath: str, project_root: str) -> Optional[str]:
58
+ try:
59
+ path = Path(filepath).relative_to(Path(project_root))
60
+ except ValueError:
61
+ return None
62
+
63
+ if path.suffix == ".py":
64
+ path = path.with_suffix("")
65
+
66
+ parts = list(path.parts)
67
+ if parts and parts[-1] == "__init__":
68
+ parts = parts[:-1]
69
+
70
+ return ".".join(parts) if parts else None
71
+
72
+ def _extract_imports(self, content: str) -> List[str]:
73
+ imports = []
74
+ try:
75
+ tree = ast.parse(content)
76
+ for node in ast.walk(tree):
77
+ if isinstance(node, ast.Import):
78
+ for alias in node.names:
79
+ imports.append(alias.name.split('.')[0])
80
+ elif isinstance(node, ast.ImportFrom):
81
+ if node.module:
82
+ imports.append(node.module.split('.')[0])
83
+ except SyntaxError:
84
+ pass
85
+ return imports
86
+
87
+ def _detect_cycles(self, graph: Dict[str, Set[str]]) -> List[List[str]]:
88
+ """Detect cycles in directed graph using Tarjan's algorithm."""
89
+ index = 0
90
+ stack = []
91
+ on_stack = set()
92
+ indices = {}
93
+ lowlinks = {}
94
+ cycles = []
95
+
96
+ def strongconnect(v: str):
97
+ nonlocal index
98
+ indices[v] = index
99
+ lowlinks[v] = index
100
+ index += 1
101
+ stack.append(v)
102
+ on_stack.add(v)
103
+
104
+ for w in graph.get(v, set()):
105
+ if w not in indices:
106
+ strongconnect(w)
107
+ lowlinks[v] = min(lowlinks[v], lowlinks[w])
108
+ elif w in on_stack:
109
+ lowlinks[v] = min(lowlinks[v], indices[w])
110
+
111
+ if lowlinks[v] == indices[v]:
112
+ scc = []
113
+ while True:
114
+ w = stack.pop()
115
+ on_stack.remove(w)
116
+ scc.append(w)
117
+ if w == v:
118
+ break
119
+ if len(scc) > 1:
120
+ cycles.append(scc)
121
+
122
+ for v in graph:
123
+ if v not in indices:
124
+ strongconnect(v)
125
+
126
+ return cycles
127
+
128
+ def _find_cycle_paths(self, graph: Dict[str, Set[str]], cycles: List[List[str]]) -> List[List[str]]:
129
+ """Find actual paths for each cycle."""
130
+ paths = []
131
+ for cycle in cycles:
132
+ # Find a path that forms the cycle
133
+ start = cycle[0]
134
+ visited = set()
135
+ path = []
136
+
137
+ def dfs(node, target):
138
+ if node in visited:
139
+ return False
140
+ visited.add(node)
141
+ path.append(node)
142
+ if node == target and len(path) > 1:
143
+ return True
144
+ for neighbor in graph.get(node, set()):
145
+ if neighbor in cycle:
146
+ if dfs(neighbor, target):
147
+ return True
148
+ path.pop()
149
+ return False
150
+
151
+ dfs(start, start)
152
+ if path:
153
+ paths.append(path)
154
+ return paths
155
+
156
+ def _check_layer_violations(self, module_layers: Dict[str, str], imports_map: Dict[str, Set[str]]) -> List[Finding]:
157
+ findings = []
158
+ layer_index = {layer: i for i, layer in enumerate(self.LAYER_ORDER)}
159
+
160
+ for importer, imported_set in imports_map.items():
161
+ importer_layer = module_layers.get(importer)
162
+ if not importer_layer:
163
+ continue
164
+
165
+ importer_idx = layer_index.get(importer_layer, 999)
166
+
167
+ for imported in imported_set:
168
+ imported_layer = module_layers.get(imported)
169
+ if not imported_layer:
170
+ continue
171
+
172
+ imported_idx = layer_index.get(imported_layer, 999)
173
+
174
+ # Check for violations: LOWER layer importing HIGHER layer
175
+ # domain (0) should not import application (1), infrastructure (2), presentation (3)
176
+ # application (1) should not import infrastructure (2), presentation (3)
177
+ # infrastructure (2) should not import presentation (3)
178
+ if importer_idx < imported_idx:
179
+ # VIOLATION: lower layer importing higher layer
180
+ violation_type = ""
181
+ if importer_layer == "domain" and imported_layer in ("application", "infrastructure", "presentation"):
182
+ violation_type = "domain_imports_higher_layer"
183
+ elif importer_layer == "application" and imported_layer in ("infrastructure", "presentation"):
184
+ violation_type = "application_imports_higher_layer"
185
+ elif importer_layer == "infrastructure" and imported_layer == "presentation":
186
+ violation_type = "infrastructure_imports_presentation"
187
+ else:
188
+ violation_type = "layer_violation"
189
+
190
+ findings.append(Finding(
191
+ engine=self.name,
192
+ severity="high",
193
+ title=f"Layer violation: {importer_layer} imports {imported_layer}",
194
+ description=f"Module '{importer}' (layer: {importer_layer}) imports '{imported}' (layer: {imported_layer}) - violates layered architecture",
195
+ location=importer,
196
+ metadata={
197
+ "violation": violation_type,
198
+ "importer": importer,
199
+ "importer_layer": importer_layer,
200
+ "imported": imported,
201
+ "imported_layer": imported_layer,
202
+ },
203
+ ))
204
+ return findings
205
+
206
+ def _check_god_modules(self, module_to_file: Dict[str, str], all_files: Dict[str, OrionIR]) -> List[Finding]:
207
+ findings = []
208
+ for module, filepath in module_to_file.items():
209
+ file_ir = all_files.get(filepath)
210
+ if not file_ir:
211
+ continue
212
+ try:
213
+ content = file_ir.content.decode("utf-8", errors="replace")
214
+ lines = content.splitlines()
215
+ if len(lines) > 1000:
216
+ findings.append(Finding(
217
+ engine=self.name,
218
+ severity="medium",
219
+ title=f"God module: {module} has {len(lines)} lines",
220
+ description=f"Module exceeds 1000 lines ({len(lines)}), consider splitting",
221
+ location=filepath,
222
+ metadata={
223
+ "violation": "god_module",
224
+ "module": module,
225
+ "file": filepath,
226
+ "line_count": len(lines),
227
+ },
228
+ ))
229
+ except Exception:
230
+ pass
231
+ return findings
232
+
233
+ def _check_feature_envy(self, all_files: Dict[str, OrionIR], module_to_file: Dict[str, str], project_root: str) -> List[Finding]:
234
+ findings = []
235
+ # Feature envy: method uses more attributes of another class than its own
236
+ # This is a simplified check - we look for methods that heavily use another class
237
+ for module, filepath in module_to_file.items():
238
+ file_ir = all_files.get(filepath)
239
+ if not file_ir:
240
+ continue
241
+ try:
242
+ content = file_ir.content.decode("utf-8", errors="replace")
243
+ tree = ast.parse(content)
244
+ except Exception:
245
+ continue
246
+
247
+ for node in ast.walk(tree):
248
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
249
+ # Count attribute accesses
250
+ attr_accesses = defaultdict(int)
251
+ for n in ast.walk(node):
252
+ if isinstance(n, ast.Attribute) and isinstance(n.ctx, ast.Load):
253
+ # Get the base object name
254
+ if isinstance(n.value, ast.Name):
255
+ attr_accesses[n.value.id] += 1
256
+
257
+ if attr_accesses:
258
+ # Find the most accessed object
259
+ max_obj = max(attr_accesses, key=attr_accesses.get)
260
+ max_count = attr_accesses[max_obj]
261
+ total = sum(attr_accesses.values())
262
+
263
+ # If more than 60% of attribute accesses are to another object
264
+ if max_count > 3 and max_count / total > 0.6 and max_obj != "self":
265
+ findings.append(Finding(
266
+ engine=self.name,
267
+ severity="low",
268
+ title=f"Possible feature envy in {node.name}",
269
+ description=f"Method '{node.name}' accesses '{max_obj}' {max_count} times out of {total} total attribute accesses",
270
+ location=f"{filepath}:{node.lineno}",
271
+ metadata={
272
+ "violation": "feature_envy",
273
+ "function": node.name,
274
+ "file": filepath,
275
+ "envied_object": max_obj,
276
+ "access_count": max_count,
277
+ "total_accesses": total,
278
+ },
279
+ ))
280
+ return findings
281
+
282
+ def _check_data_class_behavior(self, all_files: Dict[str, OrionIR], module_to_file: Dict[str, str]) -> List[Finding]:
283
+ findings = []
284
+ for module, filepath in module_to_file.items():
285
+ file_ir = all_files.get(filepath)
286
+ if not file_ir:
287
+ continue
288
+ try:
289
+ content = file_ir.content.decode("utf-8", errors="replace")
290
+ tree = ast.parse(content)
291
+ except Exception:
292
+ continue
293
+
294
+ for node in ast.walk(tree):
295
+ if isinstance(node, ast.ClassDef):
296
+ # Check if it looks like a data class (has __dataclass_fields__ or @dataclass)
297
+ is_dataclass = False
298
+ for decorator in node.decorator_list:
299
+ if isinstance(decorator, ast.Name) and decorator.id == "dataclass":
300
+ is_dataclass = True
301
+ elif isinstance(decorator, ast.Attribute) and decorator.attr == "dataclass":
302
+ is_dataclass = True
303
+
304
+ if is_dataclass:
305
+ methods = [n for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
306
+ allowed = {"__init__", "__post_init__", "__repr__", "__eq__", "__hash__", "__str__", "__lt__", "__le__", "__gt__", "__ge__"}
307
+ extra_methods = [m.name for m in methods if m.name not in allowed and not m.name.startswith('_')]
308
+ if extra_methods:
309
+ findings.append(Finding(
310
+ engine=self.name,
311
+ severity="low",
312
+ title=f"Data class with behavior: {node.name}",
313
+ description=f"Data class has methods beyond data protocols: {', '.join(extra_methods)}",
314
+ location=f"{filepath}:{node.lineno}",
315
+ metadata={
316
+ "violation": "data_class_with_behavior",
317
+ "class": node.name,
318
+ "file": filepath,
319
+ "methods": extra_methods,
320
+ },
321
+ ))
322
+ return findings
323
+
324
+ def _check_circular_package_deps(self, module_to_file: Dict[str, str], imports_map: Dict[str, Set[str]]) -> List[Finding]:
325
+ """Check for circular dependencies at package level."""
326
+ findings = []
327
+ # Group modules by package (top-level)
328
+ package_map = defaultdict(set)
329
+ for module in module_to_file:
330
+ top_level = module.split('.')[0] if '.' in module else module
331
+ package_map[top_level].add(module)
332
+
333
+ # Build package-level graph
334
+ package_graph = defaultdict(set)
335
+ for importer, imported_set in imports_map.items():
336
+ importer_pkg = importer.split('.')[0] if '.' in importer else importer
337
+ for imported in imported_set:
338
+ imported_pkg = imported.split('.')[0] if '.' in imported else imported
339
+ if importer_pkg != imported_pkg:
340
+ package_graph[importer_pkg].add(imported_pkg)
341
+
342
+ # Detect cycles at package level
343
+ cycles = self._detect_cycles(package_graph)
344
+ for cycle in cycles:
345
+ findings.append(Finding(
346
+ engine=self.name,
347
+ severity="high",
348
+ title=f"Circular package dependency: {' -> '.join(cycle)}",
349
+ description=f"Packages form a circular dependency",
350
+ location=cycle[0],
351
+ metadata={
352
+ "violation": "circular_package_dependency",
353
+ "packages": cycle,
354
+ },
355
+ ))
356
+ return findings
357
+
358
+ async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
359
+ project_root = ir.metadata.get("project_root", ".")
360
+ all_files = ir.metadata.get("all_files", {})
361
+
362
+ if not all_files:
363
+ return EngineResult(engine=self.name, ok=True, metrics={"mode": "single_file"})
364
+
365
+ # Build module map
366
+ module_to_file = {}
367
+ file_to_module = {}
368
+ module_layers = {}
369
+
370
+ for filepath, file_ir in all_files.items():
371
+ if not self._is_python_file(filepath):
372
+ continue
373
+ if self._should_exclude(filepath, config):
374
+ continue
375
+
376
+ module_name = self._get_module_name(filepath, project_root)
377
+ if not module_name:
378
+ continue
379
+
380
+ module_to_file[module_name] = filepath
381
+ file_to_module[filepath] = module_name
382
+ layer = self._get_layer(filepath)
383
+ if layer:
384
+ module_layers[module_name] = layer
385
+
386
+ # Build import graph
387
+ imports_map = defaultdict(set)
388
+ for filepath, file_ir in all_files.items():
389
+ if not self._is_python_file(filepath):
390
+ continue
391
+ if self._should_exclude(filepath, config):
392
+ continue
393
+
394
+ module_name = file_to_module.get(filepath)
395
+ if not module_name:
396
+ continue
397
+
398
+ try:
399
+ content = file_ir.content.decode("utf-8", errors="replace")
400
+ except Exception:
401
+ continue
402
+
403
+ imports = self._extract_imports(content)
404
+ for imp in imports:
405
+ # Resolve to known modules
406
+ if imp in module_to_file:
407
+ imports_map[module_name].add(imp)
408
+ else:
409
+ # Try to match partial
410
+ for known_module in module_to_file:
411
+ if known_module.startswith(imp + ".") or imp.startswith(known_module + "."):
412
+ imports_map[module_name].add(known_module)
413
+ break
414
+
415
+ all_findings = []
416
+
417
+ # 1. Cyclic imports (module level)
418
+ cycles = self._detect_cycles(imports_map)
419
+ cycle_paths = self._find_cycle_paths(imports_map, cycles)
420
+ for path in cycle_paths:
421
+ all_findings.append(Finding(
422
+ engine=self.name,
423
+ severity="high",
424
+ title=f"Cyclic import: {' -> '.join(path)}",
425
+ description=f"Modules form a circular import dependency",
426
+ location=path[0],
427
+ metadata={
428
+ "violation": "cyclic_import",
429
+ "modules": path,
430
+ "files": [module_to_file.get(m, "") for m in path],
431
+ },
432
+ ))
433
+
434
+ # 2. Layer violations
435
+ all_findings.extend(self._check_layer_violations(module_layers, imports_map))
436
+
437
+ # 3. God modules
438
+ all_findings.extend(self._check_god_modules(module_to_file, all_files))
439
+
440
+ # 4. Feature envy
441
+ all_findings.extend(self._check_feature_envy(all_files, module_to_file, project_root))
442
+
443
+ # 5. Data class with behavior
444
+ all_findings.extend(self._check_data_class_behavior(all_files, module_to_file))
445
+
446
+ # 6. Circular package dependencies
447
+ all_findings.extend(self._check_circular_package_deps(module_to_file, imports_map))
448
+
449
+ return EngineResult(
450
+ engine=self.name,
451
+ findings=all_findings,
452
+ ok=True,
453
+ metrics={
454
+ "modules_analyzed": len(module_to_file),
455
+ "import_edges": sum(len(v) for v in imports_map.values()),
456
+ "cycles_found": len(cycles),
457
+ "layers_detected": len(set(module_layers.values())),
458
+ "findings_count": len(all_findings),
459
+ },
460
+ )
461
+
462
+
463
+ Engine = ArchitectureEngine