@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,558 @@
1
+ """Python linting engine - AST-based comprehensive linting without external tools."""
2
+ import ast
3
+ import re
4
+ from pathlib import Path
5
+ from typing import List, Dict, Set, Optional, Any
6
+ from collections import defaultdict
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
+ import builtins
14
+ import sys
15
+
16
+ class LintEngine:
17
+ name = "lint"
18
+ version = "1.0.0"
19
+
20
+ STDLIB_MODULES = {
21
+ "os", "sys", "json", "re", "ast", "typing", "collections", "itertools",
22
+ "functools", "dataclasses", "enum", "pathlib", "datetime", "time",
23
+ "math", "random", "string", "hashlib", "hmac", "secrets", "uuid",
24
+ "subprocess", "threading", "multiprocessing", "asyncio", "socket",
25
+ "urllib", "http", "email", "html", "xml", "csv", "sqlite3", "pickle",
26
+ "copy", "pprint", "textwrap", "unicodedata", "fractions", "decimal",
27
+ "statistics", "inspect", "importlib", "pkgutil", "runpy", "traceback",
28
+ "warnings", "contextlib", "abc", "numbers", "operator", "keyword",
29
+ "tokenize", "token", "parser", "symbol", "ast", "sysconfig", "site",
30
+ "builtins", "__future__", "__main__", "argparse", "getopt", "optparse",
31
+ "shlex", "cmd", "readline", "rlcompleter", "logging", "logging.config",
32
+ "logging.handlers", "configparser", "netrc", "xdrlib", "plistlib",
33
+ "base64", "binascii", "quopri", "uu", "zlib", "gzip", "bz2", "lzma",
34
+ "zipfile", "tarfile", "tomllib", "tomli", "signal", "mmap", "errno",
35
+ "ctypes", "concurrent", "queue", "contextvars"
36
+ }
37
+
38
+ THIRD_PARTY_INDICATORS = {
39
+ "django", "flask", "fastapi", "requests", "httpx", "aiohttp",
40
+ "sqlalchemy", "pydantic", "numpy", "pandas", "scipy", "matplotlib",
41
+ "pytest", "unittest", "celery", "redis", "psycopg2", "pymongo",
42
+ "boto3", "google", "azure", "aws", "kubernetes", "docker",
43
+ "rich", "click", "typer", "jinja2", "pyyaml", "cryptography"
44
+ }
45
+
46
+ async def check_availability(self) -> bool:
47
+ return True
48
+
49
+ def resource_limits(self):
50
+ from orion.engine.config import ResourceLimits
51
+ return ResourceLimits(max_cpu_seconds=60, max_memory_mb=256)
52
+
53
+ def _should_exclude(self, filepath: str, config: EngineConfig) -> bool:
54
+ path = Path(filepath)
55
+ if config.toggles.get("exclude"):
56
+ for pattern in config.toggles["exclude"]:
57
+ if path.match(pattern) or any(p.match(pattern) for p in path.parents):
58
+ return True
59
+ return False
60
+
61
+ class LintVisitor(ast.NodeVisitor):
62
+ def __init__(self, content: str, filepath: str, project_root: str):
63
+ self.content = content
64
+ self.filepath = filepath
65
+ self.project_root = project_root
66
+ self.lines = content.splitlines()
67
+ self.findings = []
68
+ self.scope_stack = [set()] # Stack of variable scopes
69
+ self.imports = [] # List of (line, module, names, is_from)
70
+ self.assignments = defaultdict(list) # var_name -> list of (line, node)
71
+ self.usages = defaultdict(list) # var_name -> list of (line, node)
72
+ self.function_defs = [] # List of (name, line, node, is_public)
73
+ self.class_defs = [] # List of (name, line, node, methods, bases)
74
+ self.current_function = None
75
+ self.current_class = None
76
+ self.builtins = set(dir(builtins)) | {
77
+ "__file__", "__name__", "__doc__", "__package__", "__loader__",
78
+ "__spec__", "__annotations__", "__builtins__", "__all__", "__path__",
79
+ "__class__", "__dict__", "cls", "self"
80
+ }
81
+
82
+ def _get_snippet(self, line_num: int, context: int = 2) -> str:
83
+ start = max(0, line_num - context - 1)
84
+ end = min(len(self.lines), line_num + context)
85
+ return '\n'.join(self.lines[start:end])
86
+
87
+ def _add_finding(self, severity: str, title: str, description: str, line: int, metadata: dict):
88
+ self.findings.append({
89
+ "severity": severity,
90
+ "title": title,
91
+ "description": description,
92
+ "line": line,
93
+ "snippet": self._get_snippet(line),
94
+ "metadata": metadata,
95
+ })
96
+
97
+ def push_scope(self):
98
+ self.scope_stack.append(set())
99
+
100
+ def pop_scope(self):
101
+ if len(self.scope_stack) > 1:
102
+ self.scope_stack.pop()
103
+
104
+ def add_var(self, name: str, line: int, node: ast.AST):
105
+ if name:
106
+ self.scope_stack[-1].add(name)
107
+ self.assignments[name].append((line, node))
108
+
109
+ def use_var(self, name: str, line: int, node: ast.AST):
110
+ if name:
111
+ self.usages[name].append((line, node))
112
+
113
+ def is_defined(self, name: str) -> bool:
114
+ if not name:
115
+ return True
116
+ for scope in reversed(self.scope_stack):
117
+ if name in scope:
118
+ return True
119
+ return name in self.builtins
120
+
121
+ def _extract_target_names(self, node: ast.AST) -> List[str]:
122
+ names = []
123
+ if isinstance(node, ast.Name):
124
+ names.append(node.id)
125
+ elif isinstance(node, (ast.Tuple, ast.List)):
126
+ for elt in node.elts:
127
+ names.extend(self._extract_target_names(elt))
128
+ elif isinstance(node, ast.Starred):
129
+ names.extend(self._extract_target_names(node.value))
130
+ return names
131
+
132
+ def visit_Module(self, node: ast.Module):
133
+ self.generic_visit(node)
134
+
135
+ def visit_FunctionDef(self, node: ast.FunctionDef):
136
+ is_public = not node.name.startswith('_')
137
+ self.function_defs.append((node.name, node.lineno, node, is_public))
138
+ self.add_var(node.name, node.lineno, node)
139
+
140
+ for default in node.args.defaults:
141
+ if isinstance(default, (ast.List, ast.Dict, ast.Set)):
142
+ self._add_finding(
143
+ "medium",
144
+ f"Mutable default argument in function '{node.name}'",
145
+ "Mutable default arguments retain state across invocations and can cause unexpected bugs",
146
+ node.lineno,
147
+ {"rule": "mutable_default_argument", "function": node.name, "type": type(default).__name__},
148
+ )
149
+
150
+ self.push_scope()
151
+ self.current_function = node.name
152
+ for arg in node.args.args:
153
+ self.add_var(arg.arg, arg.lineno, arg)
154
+ for arg in getattr(node.args, 'posonlyargs', []):
155
+ self.add_var(arg.arg, arg.lineno, arg)
156
+ for arg in getattr(node.args, 'kwonlyargs', []):
157
+ self.add_var(arg.arg, arg.lineno, arg)
158
+ if node.args.vararg:
159
+ self.add_var(node.args.vararg.arg, node.args.vararg.lineno, node.args.vararg)
160
+ if node.args.kwarg:
161
+ self.add_var(node.args.kwarg.arg, node.args.kwarg.lineno, node.args.kwarg)
162
+
163
+ self.generic_visit(node)
164
+ self.pop_scope()
165
+ self.current_function = None
166
+
167
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
168
+ self.visit_FunctionDef(node)
169
+
170
+ def visit_ClassDef(self, node: ast.ClassDef):
171
+ is_public = not node.name.startswith('_')
172
+ self.add_var(node.name, node.lineno, node)
173
+ methods = [n for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
174
+ self.class_defs.append((node.name, node.lineno, node, methods, node.bases))
175
+
176
+ self.push_scope()
177
+ self.current_class = node.name
178
+ self.generic_visit(node)
179
+ self.pop_scope()
180
+ self.current_class = None
181
+
182
+ def visit_Assign(self, node: ast.Assign):
183
+ for target in node.targets:
184
+ for name in self._extract_target_names(target):
185
+ self.add_var(name, node.lineno, node)
186
+ self.generic_visit(node)
187
+
188
+ def visit_AnnAssign(self, node: ast.AnnAssign):
189
+ if node.target:
190
+ for name in self._extract_target_names(node.target):
191
+ self.add_var(name, node.lineno, node)
192
+ self.generic_visit(node)
193
+
194
+ def visit_AugAssign(self, node: ast.AugAssign):
195
+ if isinstance(node.target, ast.Name):
196
+ self.use_var(node.target.id, node.lineno, node)
197
+ self.generic_visit(node)
198
+
199
+ def visit_For(self, node: ast.For):
200
+ for name in self._extract_target_names(node.target):
201
+ self.add_var(name, node.lineno, node)
202
+ self.generic_visit(node)
203
+
204
+ def visit_AsyncFor(self, node: ast.AsyncFor):
205
+ for name in self._extract_target_names(node.target):
206
+ self.add_var(name, node.lineno, node)
207
+ self.generic_visit(node)
208
+
209
+ def visit_With(self, node: ast.With):
210
+ for item in node.items:
211
+ if item.optional_vars:
212
+ for name in self._extract_target_names(item.optional_vars):
213
+ self.add_var(name, node.lineno, node)
214
+ self.generic_visit(node)
215
+
216
+ def visit_AsyncWith(self, node: ast.AsyncWith):
217
+ for item in node.items:
218
+ if item.optional_vars:
219
+ for name in self._extract_target_names(item.optional_vars):
220
+ self.add_var(name, node.lineno, node)
221
+ self.generic_visit(node)
222
+
223
+ def visit_ExceptHandler(self, node: ast.ExceptHandler):
224
+ if node.name:
225
+ self.add_var(node.name, node.lineno, node)
226
+ self.generic_visit(node)
227
+
228
+ def visit_comprehension(self, node: ast.comprehension):
229
+ for name in self._extract_target_names(node.target):
230
+ self.add_var(name, getattr(node.target, 'lineno', 1), node)
231
+ self.generic_visit(node)
232
+
233
+ def visit_Name(self, node: ast.Name):
234
+ if isinstance(node.ctx, ast.Load):
235
+ self.use_var(node.id, node.lineno, node)
236
+ if not self.is_defined(node.id):
237
+ self._add_finding(
238
+ "medium",
239
+ f"Undefined name '{node.id}'",
240
+ f"Name '{node.id}' is used but not defined in visible scopes",
241
+ node.lineno,
242
+ {"rule": "undefined_name", "name": node.id, "cwe": "CWE-476"},
243
+ )
244
+ elif isinstance(node.ctx, (ast.Store, ast.Del)):
245
+ self.add_var(node.id, node.lineno, node)
246
+ self.generic_visit(node)
247
+
248
+ def visit_Import(self, node: ast.Import):
249
+ for alias in node.names:
250
+ name = alias.asname or alias.name.split('.')[0]
251
+ self.add_var(name, node.lineno, node)
252
+ self.imports.append((node.lineno, alias.name, [alias.asname or alias.name], False))
253
+ self.generic_visit(node)
254
+
255
+ def visit_ImportFrom(self, node: ast.ImportFrom):
256
+ module = node.module or ""
257
+ names = []
258
+ for alias in node.names:
259
+ if alias.name != "*":
260
+ name = alias.asname or alias.name
261
+ self.add_var(name, node.lineno, node)
262
+ names.append(name)
263
+ else:
264
+ names.append("*")
265
+ self.imports.append((node.lineno, module, names, True))
266
+ if any(n == '*' for n in names):
267
+ self._add_finding(
268
+ "medium",
269
+ f"Wildcard import from '{module}'",
270
+ "Wildcard imports make it unclear what names are available",
271
+ node.lineno,
272
+ {"rule": "wildcard_import", "module": module},
273
+ )
274
+ self.generic_visit(node)
275
+
276
+ def visit_Compare(self, node: ast.Compare):
277
+ # Check for comparison to None using ==/!=
278
+ if len(node.ops) == 1 and len(node.comparators) == 1:
279
+ op = node.ops[0]
280
+ comp = node.comparators[0]
281
+ if isinstance(comp, ast.Constant) and comp.value is None:
282
+ if isinstance(op, (ast.Eq, ast.NotEq)):
283
+ self._add_finding(
284
+ "medium",
285
+ "Comparison to None using ==/!=",
286
+ "Use 'is None' or 'is not None' instead",
287
+ node.lineno,
288
+ {"rule": "comparison_to_none", "operator": "==" if isinstance(op, ast.Eq) else "!="},
289
+ )
290
+ self.generic_visit(node)
291
+
292
+ def visit_Try(self, node: ast.Try):
293
+ for handler in node.handlers:
294
+ if handler.type is None:
295
+ # Bare except:
296
+ self._add_finding(
297
+ "medium",
298
+ "Bare except: clause",
299
+ "Bare except catches all exceptions including SystemExit and KeyboardInterrupt",
300
+ handler.lineno,
301
+ {"rule": "bare_except"},
302
+ )
303
+ self.generic_visit(node)
304
+
305
+ def visit_Call(self, node: ast.Call):
306
+ # Check for eval/exec usage
307
+ if isinstance(node.func, ast.Name):
308
+ if node.func.id in ("eval", "exec"):
309
+ self._add_finding(
310
+ "high",
311
+ f"Use of {node.func.id}()",
312
+ f"{node.func.id}() can execute arbitrary code - security risk",
313
+ node.lineno,
314
+ {"rule": f"{node.func.id}_usage"},
315
+ )
316
+ self.generic_visit(node)
317
+
318
+ def _check_unused_variables(self, visitor: 'LintEngine.LintVisitor') -> List[dict]:
319
+ findings = []
320
+ for var_name, assignments in visitor.assignments.items():
321
+ usages = visitor.usages.get(var_name, [])
322
+ if not usages:
323
+ # Check if it's a legitimate unused (e.g., _ prefix, or in __all__)
324
+ if var_name.startswith('_') and len(var_name) == 1:
325
+ continue # Single underscore is often used as throwaway
326
+ if var_name.startswith('__') and var_name.endswith('__'):
327
+ continue # Dunder methods/vars
328
+ for line, node in assignments:
329
+ findings.append({
330
+ "severity": "low",
331
+ "title": f"Unused variable '{var_name}'",
332
+ "description": f"Variable '{var_name}' is assigned but never used",
333
+ "line": line,
334
+ "snippet": visitor._get_snippet(line),
335
+ "metadata": {"rule": "unused_variable", "variable": var_name},
336
+ })
337
+ return findings
338
+
339
+ def _check_import_order(self, visitor: 'LintEngine.LintVisitor') -> List[dict]:
340
+ findings = []
341
+ if not visitor.imports:
342
+ return findings
343
+
344
+ # Categorize imports
345
+ stdlib_imports = []
346
+ third_party_imports = []
347
+ local_imports = []
348
+
349
+ for line, module, names, is_from in visitor.imports:
350
+ if module.startswith('.'):
351
+ local_imports.append((line, module, names, is_from))
352
+ elif module.split('.')[0] in self.STDLIB_MODULES:
353
+ stdlib_imports.append((line, module, names, is_from))
354
+ else:
355
+ third_party_imports.append((line, module, names, is_from))
356
+
357
+ # Check order: stdlib -> third-party -> local
358
+ all_imports = stdlib_imports + third_party_imports + local_imports
359
+ expected_lines = [imp[0] for imp in all_imports]
360
+ actual_lines = [imp[0] for imp in visitor.imports]
361
+
362
+ # Simple check: stdlib imports should come before third-party, etc.
363
+ last_stdlib = max([imp[0] for imp in stdlib_imports], default=0)
364
+ first_third = min([imp[0] for imp in third_party_imports], default=float('inf'))
365
+ last_third = max([imp[0] for imp in third_party_imports], default=0)
366
+ first_local = min([imp[0] for imp in local_imports], default=float('inf'))
367
+
368
+ if last_stdlib > first_third and first_third != float('inf'):
369
+ findings.append({
370
+ "severity": "low",
371
+ "title": "Import order violation: stdlib after third-party",
372
+ "description": "Standard library imports should come before third-party imports",
373
+ "line": first_third,
374
+ "snippet": visitor._get_snippet(first_third),
375
+ "metadata": {"rule": "import_order", "issue": "stdlib_after_third_party"},
376
+ })
377
+
378
+ if last_third > first_local and first_local != float('inf'):
379
+ findings.append({
380
+ "severity": "low",
381
+ "title": "Import order violation: third-party after local",
382
+ "description": "Third-party imports should come before local imports",
383
+ "line": first_local,
384
+ "snippet": visitor._get_snippet(first_local),
385
+ "metadata": {"rule": "import_order", "issue": "third_party_after_local"},
386
+ })
387
+
388
+ # Check for blank lines between groups
389
+ if stdlib_imports and third_party_imports:
390
+ last_stdlib_line = max(imp[0] for imp in stdlib_imports)
391
+ first_third_line = min(imp[0] for imp in third_party_imports)
392
+ if first_third_line - last_stdlib_line == 1:
393
+ # Check if there's a blank line
394
+ if visitor.lines[last_stdlib_line].strip() != "":
395
+ findings.append({
396
+ "severity": "low",
397
+ "title": "Missing blank line between import groups",
398
+ "description": "Add a blank line between standard library and third-party imports",
399
+ "line": first_third_line,
400
+ "snippet": visitor._get_snippet(first_third_line),
401
+ "metadata": {"rule": "import_order", "issue": "missing_blank_line"},
402
+ })
403
+
404
+ return findings
405
+
406
+ def _check_line_length(self, visitor: 'LintEngine.LintVisitor') -> List[dict]:
407
+ findings = []
408
+ for i, line in enumerate(visitor.lines, 1):
409
+ if len(line) > 100:
410
+ findings.append({
411
+ "severity": "low",
412
+ "title": f"Line too long ({len(line)} > 100)",
413
+ "description": f"Line exceeds maximum recommended length of 100 characters",
414
+ "line": i,
415
+ "snippet": visitor._get_snippet(i, 1),
416
+ "metadata": {"rule": "line_length", "length": len(line)},
417
+ })
418
+ return findings
419
+
420
+ def _check_trailing_whitespace(self, visitor: 'LintEngine.LintVisitor') -> List[dict]:
421
+ findings = []
422
+ for i, line in enumerate(visitor.lines, 1):
423
+ if line.rstrip() != line:
424
+ findings.append({
425
+ "severity": "low",
426
+ "title": "Trailing whitespace",
427
+ "description": "Line has trailing whitespace",
428
+ "line": i,
429
+ "snippet": visitor._get_snippet(i, 1),
430
+ "metadata": {"rule": "trailing_whitespace"},
431
+ })
432
+ return findings
433
+
434
+ def _check_init_not_empty(self, visitor: 'LintEngine.LintVisitor') -> List[dict]:
435
+ findings = []
436
+ if Path(visitor.filepath).name == "__init__.py":
437
+ # Check if it's essentially empty (only comments/docstrings)
438
+ non_empty_lines = [l for l in visitor.lines if l.strip() and not l.strip().startswith('#')]
439
+ if len(non_empty_lines) <= 1: # Allow just a docstring
440
+ # Check if it's a namespace package (has __path__ manipulation)
441
+ has_namespace = any("__path__" in l for l in visitor.lines)
442
+ if not has_namespace:
443
+ findings.append({
444
+ "severity": "low",
445
+ "title": "Empty __init__.py",
446
+ "description": "__init__.py should not be empty unless it's a namespace package",
447
+ "line": 1,
448
+ "snippet": visitor._get_snippet(1),
449
+ "metadata": {"rule": "empty_init"},
450
+ })
451
+ return findings
452
+
453
+ def _check_god_class(self, visitor: 'LintEngine.LintVisitor') -> List[dict]:
454
+ findings = []
455
+ for class_name, line, node, methods, bases in visitor.class_defs:
456
+ if len(methods) > 20:
457
+ findings.append({
458
+ "severity": "medium",
459
+ "title": f"God class: '{class_name}' has {len(methods)} methods",
460
+ "description": f"Class has too many methods ({len(methods)} > 20), consider splitting",
461
+ "line": line,
462
+ "snippet": visitor._get_snippet(line),
463
+ "metadata": {"rule": "god_class", "class": class_name, "method_count": len(methods)},
464
+ })
465
+ # Check lines of code in class
466
+ if hasattr(node, 'end_lineno') and node.end_lineno:
467
+ class_lines = node.end_lineno - node.lineno + 1
468
+ if class_lines > 500:
469
+ findings.append({
470
+ "severity": "medium",
471
+ "title": f"God class: '{class_name}' has {class_lines} lines",
472
+ "description": f"Class is too long ({class_lines} > 500 lines), consider splitting",
473
+ "line": line,
474
+ "snippet": visitor._get_snippet(line),
475
+ "metadata": {"rule": "god_class_lines", "class": class_name, "line_count": class_lines},
476
+ })
477
+ return findings
478
+
479
+ def _check_long_function(self, visitor: 'LintEngine.LintVisitor') -> List[dict]:
480
+ findings = []
481
+ for func_name, line, node, is_public in visitor.function_defs:
482
+ if hasattr(node, 'end_lineno') and node.end_lineno:
483
+ func_lines = node.end_lineno - node.lineno + 1
484
+ if func_lines > 50:
485
+ findings.append({
486
+ "severity": "low",
487
+ "title": f"Long function: '{func_name}' has {func_lines} lines",
488
+ "description": f"Function is too long ({func_lines} > 50 lines), consider refactoring",
489
+ "line": line,
490
+ "snippet": visitor._get_snippet(line),
491
+ "metadata": {"rule": "long_function", "function": func_name, "line_count": func_lines},
492
+ })
493
+ return findings
494
+
495
+ async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
496
+ filepath = ir.metadata.get("path", "unknown")
497
+
498
+ if not filepath.endswith(".py"):
499
+ return EngineResult(engine=self.name, ok=True, metrics={"skipped": "not_python"})
500
+
501
+ if self._should_exclude(filepath, config):
502
+ return EngineResult(engine=self.name, ok=True, metrics={"skipped": "excluded"})
503
+
504
+ try:
505
+ content = ir.content.decode("utf-8", errors="replace")
506
+ except Exception:
507
+ content = str(ir.content)
508
+
509
+ project_root = ir.metadata.get("project_root", ".")
510
+
511
+ visitor = self.LintVisitor(content, filepath, project_root)
512
+ try:
513
+ tree = ast.parse(content)
514
+ visitor.visit(tree)
515
+ except SyntaxError as e:
516
+ return EngineResult(
517
+ engine=self.name,
518
+ findings=[Finding(
519
+ engine=self.name,
520
+ severity="error",
521
+ title="Syntax error in file",
522
+ description=f"Cannot parse file due to syntax error: {e}",
523
+ location=f"{filepath}:{e.lineno}",
524
+ metadata={"rule": "syntax_error"},
525
+ )],
526
+ ok=True,
527
+ )
528
+
529
+ # Run essential code quality checks only
530
+ all_findings = visitor.findings
531
+ all_findings.extend(self._check_unused_variables(visitor))
532
+ all_findings.extend(self._check_god_class(visitor))
533
+
534
+ # Convert to Finding objects
535
+ findings = []
536
+ for f in all_findings:
537
+ findings.append(Finding(
538
+ engine=self.name,
539
+ severity=f["severity"],
540
+ title=f["title"],
541
+ description=f["description"],
542
+ location=f"{filepath}:{f['line']}",
543
+ metadata={**f["metadata"], "file": filepath, "line": f["line"], "snippet": f["snippet"]},
544
+ ))
545
+
546
+ return EngineResult(
547
+ engine=self.name,
548
+ findings=findings,
549
+ ok=True,
550
+ metrics={
551
+ "file": filepath,
552
+ "checks_run": 10,
553
+ "findings_count": len(findings),
554
+ },
555
+ )
556
+
557
+
558
+ Engine = LintEngine