@orionscanner/cli 0.1.0 → 0.1.1

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 (129) hide show
  1. package/README.md +135 -248
  2. package/bin/orion.js +30 -29
  3. package/dist/orion.exe +0 -0
  4. package/package.json +8 -10
  5. package/docs/API_REFERENCE.md +0 -211
  6. package/docs/ARCHITECTURE.md +0 -183
  7. package/docs/CHANGELOG.md +0 -112
  8. package/docs/CONTRIBUTING.md +0 -157
  9. package/docs/ENGINE_CATALOG.md +0 -87
  10. package/docs/GETTING_STARTED.md +0 -226
  11. package/pyproject.toml +0 -16
  12. package/src/orion/__init__.py +0 -0
  13. package/src/orion/__pycache__/__init__.cpython-313.pyc +0 -0
  14. package/src/orion/engine/__init__.py +0 -17
  15. package/src/orion/engine/__pycache__/__init__.cpython-313.pyc +0 -0
  16. package/src/orion/engine/__pycache__/config.cpython-313.pyc +0 -0
  17. package/src/orion/engine/__pycache__/core.cpython-313.pyc +0 -0
  18. package/src/orion/engine/__pycache__/ir.cpython-313.pyc +0 -0
  19. package/src/orion/engine/__pycache__/languages.cpython-313.pyc +0 -0
  20. package/src/orion/engine/__pycache__/registry.cpython-313.pyc +0 -0
  21. package/src/orion/engine/__pycache__/results.cpython-313.pyc +0 -0
  22. package/src/orion/engine/__pycache__/runner.cpython-313.pyc +0 -0
  23. package/src/orion/engine/api_security/__pycache__/engine.cpython-313.pyc +0 -0
  24. package/src/orion/engine/api_security/engine.py +0 -125
  25. package/src/orion/engine/architecture/__pycache__/engine.cpython-313.pyc +0 -0
  26. package/src/orion/engine/architecture/engine.py +0 -463
  27. package/src/orion/engine/authorization/__pycache__/engine.cpython-313.pyc +0 -0
  28. package/src/orion/engine/authorization/engine.py +0 -337
  29. package/src/orion/engine/callgraph/__pycache__/engine.cpython-313.pyc +0 -0
  30. package/src/orion/engine/callgraph/engine.py +0 -236
  31. package/src/orion/engine/cfg/__pycache__/engine.cpython-313.pyc +0 -0
  32. package/src/orion/engine/cfg/engine.py +0 -509
  33. package/src/orion/engine/chaos/__pycache__/engine.cpython-313.pyc +0 -0
  34. package/src/orion/engine/chaos/engine.py +0 -268
  35. package/src/orion/engine/chaos_config/__pycache__/engine.cpython-313.pyc +0 -0
  36. package/src/orion/engine/chaos_config/engine.py +0 -322
  37. package/src/orion/engine/complexity/__pycache__/engine.cpython-313.pyc +0 -0
  38. package/src/orion/engine/complexity/engine.py +0 -144
  39. package/src/orion/engine/compliance/__pycache__/engine.cpython-313.pyc +0 -0
  40. package/src/orion/engine/compliance/engine.py +0 -325
  41. package/src/orion/engine/config.py +0 -41
  42. package/src/orion/engine/config_engine/__pycache__/engine.cpython-313.pyc +0 -0
  43. package/src/orion/engine/config_engine/engine.py +0 -364
  44. package/src/orion/engine/config_validation/__pycache__/engine.cpython-313.pyc +0 -0
  45. package/src/orion/engine/config_validation/engine.py +0 -313
  46. package/src/orion/engine/container/__pycache__/engine.cpython-313.pyc +0 -0
  47. package/src/orion/engine/container/engine.py +0 -546
  48. package/src/orion/engine/contract_probe/__pycache__/engine.cpython-313.pyc +0 -0
  49. package/src/orion/engine/contract_probe/engine.py +0 -316
  50. package/src/orion/engine/core.py +0 -75
  51. package/src/orion/engine/crypto_guard/__pycache__/engine.cpython-313.pyc +0 -0
  52. package/src/orion/engine/crypto_guard/engine.py +0 -134
  53. package/src/orion/engine/data_residency/__pycache__/engine.cpython-313.pyc +0 -0
  54. package/src/orion/engine/data_residency/engine.py +0 -238
  55. package/src/orion/engine/dataflow/__pycache__/engine.cpython-313.pyc +0 -0
  56. package/src/orion/engine/dataflow/engine.py +0 -398
  57. package/src/orion/engine/dead_import/__pycache__/engine.cpython-313.pyc +0 -0
  58. package/src/orion/engine/dead_import/engine.py +0 -293
  59. package/src/orion/engine/deps/__pycache__/engine.cpython-313.pyc +0 -0
  60. package/src/orion/engine/deps/engine.py +0 -244
  61. package/src/orion/engine/dispatcher.py +0 -350
  62. package/src/orion/engine/docs/AUDIT_PROMPT.md +0 -1248
  63. package/src/orion/engine/duplication/__pycache__/engine.cpython-313.pyc +0 -0
  64. package/src/orion/engine/duplication/engine.py +0 -120
  65. package/src/orion/engine/error_handling/__pycache__/engine.cpython-313.pyc +0 -0
  66. package/src/orion/engine/error_handling/engine.py +0 -256
  67. package/src/orion/engine/fp_learn/__pycache__/engine.cpython-313.pyc +0 -0
  68. package/src/orion/engine/fp_learn/engine.py +0 -248
  69. package/src/orion/engine/hunter/__pycache__/engine.cpython-313.pyc +0 -0
  70. package/src/orion/engine/hunter/engine.py +0 -324
  71. package/src/orion/engine/iac_security/__pycache__/engine.cpython-313.pyc +0 -0
  72. package/src/orion/engine/iac_security/engine.py +0 -218
  73. package/src/orion/engine/ir.py +0 -22
  74. package/src/orion/engine/languages.py +0 -454
  75. package/src/orion/engine/license/__pycache__/engine.cpython-313.pyc +0 -0
  76. package/src/orion/engine/license/engine.py +0 -172
  77. package/src/orion/engine/lint/__pycache__/engine.cpython-313.pyc +0 -0
  78. package/src/orion/engine/lint/engine.py +0 -558
  79. package/src/orion/engine/llm_security/__pycache__/engine.cpython-313.pyc +0 -0
  80. package/src/orion/engine/llm_security/engine.py +0 -125
  81. package/src/orion/engine/logging_c/__pycache__/engine.cpython-313.pyc +0 -0
  82. package/src/orion/engine/logging_c/engine.py +0 -164
  83. package/src/orion/engine/noise/__pycache__/engine.cpython-313.pyc +0 -0
  84. package/src/orion/engine/noise/engine.py +0 -140
  85. package/src/orion/engine/orphan/__pycache__/engine.cpython-313.pyc +0 -0
  86. package/src/orion/engine/orphan/engine.py +0 -244
  87. package/src/orion/engine/perf_guard/__pycache__/engine.cpython-313.pyc +0 -0
  88. package/src/orion/engine/perf_guard/engine.py +0 -345
  89. package/src/orion/engine/prompt_security/__pycache__/engine.cpython-313.pyc +0 -0
  90. package/src/orion/engine/prompt_security/detectors/__init__.py +0 -4
  91. package/src/orion/engine/prompt_security/engine.py +0 -170
  92. package/src/orion/engine/provenance/__pycache__/engine.cpython-313.pyc +0 -0
  93. package/src/orion/engine/provenance/engine.py +0 -242
  94. package/src/orion/engine/registry.py +0 -55
  95. package/src/orion/engine/regression/__pycache__/engine.cpython-313.pyc +0 -0
  96. package/src/orion/engine/regression/engine.py +0 -213
  97. package/src/orion/engine/results.py +0 -21
  98. package/src/orion/engine/runner.py +0 -1310
  99. package/src/orion/engine/sandbox_escape/__pycache__/engine.cpython-313.pyc +0 -0
  100. package/src/orion/engine/sandbox_escape/engine.py +0 -284
  101. package/src/orion/engine/sast/__pycache__/engine.cpython-313.pyc +0 -0
  102. package/src/orion/engine/sast/engine.py +0 -217
  103. package/src/orion/engine/sast/patterns/__init__.py +0 -513
  104. package/src/orion/engine/sast/patterns/__pycache__/__init__.cpython-313.pyc +0 -0
  105. package/src/orion/engine/secrets/__pycache__/engine.cpython-313.pyc +0 -0
  106. package/src/orion/engine/secrets/engine.py +0 -77
  107. package/src/orion/engine/secrets/patterns/__init__.py +0 -74
  108. package/src/orion/engine/secrets/patterns/__pycache__/__init__.cpython-313.pyc +0 -0
  109. package/src/orion/engine/sensitive_data/__pycache__/engine.cpython-313.pyc +0 -0
  110. package/src/orion/engine/sensitive_data/engine.py +0 -143
  111. package/src/orion/engine/state/__pycache__/engine.cpython-313.pyc +0 -0
  112. package/src/orion/engine/state/engine.py +0 -218
  113. package/src/orion/engine/supply_chain_sbom/__pycache__/engine.cpython-313.pyc +0 -0
  114. package/src/orion/engine/supply_chain_sbom/engine.py +0 -290
  115. package/src/orion/engine/symbol/__pycache__/engine.cpython-313.pyc +0 -0
  116. package/src/orion/engine/symbol/engine.py +0 -463
  117. package/src/orion/engine/taint/__pycache__/engine.cpython-313.pyc +0 -0
  118. package/src/orion/engine/taint/engine.py +0 -464
  119. package/src/orion/engine/test_coverage/__pycache__/engine.cpython-313.pyc +0 -0
  120. package/src/orion/engine/test_coverage/engine.py +0 -445
  121. package/src/orion/engine/threat_model/__pycache__/engine.cpython-313.pyc +0 -0
  122. package/src/orion/engine/threat_model/engine.py +0 -126
  123. package/src/orion/engine/wiring_trace/__pycache__/engine.cpython-313.pyc +0 -0
  124. package/src/orion/engine/wiring_trace/engine.py +0 -200
  125. package/src/orion_engine.egg-info/PKG-INFO +0 -7
  126. package/src/orion_engine.egg-info/SOURCES.txt +0 -65
  127. package/src/orion_engine.egg-info/dependency_links.txt +0 -1
  128. package/src/orion_engine.egg-info/entry_points.txt +0 -2
  129. package/src/orion_engine.egg-info/top_level.txt +0 -1
@@ -1,463 +0,0 @@
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