codesnake 1.2.1__py3-none-any.whl

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.
codesnake/checker.py ADDED
@@ -0,0 +1,2755 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CodeSnake - Semantic Code Checker for Python 3
4
+ A comprehensive tool to detect coding issues, anti-patterns, and potential bugs.
5
+
6
+ 🐍 CodeSnake strikes at code problems before they bite!
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import builtins
13
+ import concurrent.futures
14
+ import json
15
+ import os
16
+ import re
17
+ import shutil
18
+ import subprocess as _subprocess
19
+ import sys
20
+ from dataclasses import asdict, dataclass, fields
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Set, TextIO, Tuple
23
+
24
+ try:
25
+ import tomllib # Python 3.11+
26
+ except ImportError: # pragma: no cover - Python 3.10
27
+ tomllib = None # type: ignore[assignment]
28
+
29
+ from ._version import __version__
30
+ from .banner import print_snake_banner
31
+
32
+ PROJECT_URL = 'https://github.com/bitWarrior/codesnake'
33
+ RULES_URL = PROJECT_URL + '#what-it-checks'
34
+
35
+ # Below this many files a process pool costs more than it saves.
36
+ PARALLEL_MIN_FILES = 8
37
+
38
+
39
+ SEVERITY_RANK = {'error': 3, 'warning': 2, 'info': 1}
40
+
41
+ CATEGORY_FLAGS = {
42
+ 'security': 'check_security',
43
+ 'bugs': 'check_bugs',
44
+ 'exceptions': 'check_exceptions',
45
+ 'complexity': 'check_complexity',
46
+ 'performance': 'check_performance',
47
+ 'imports': 'check_imports',
48
+ 'style': 'check_style',
49
+ 'unused': 'check_unused',
50
+ 'reliability': 'check_reliability',
51
+ }
52
+
53
+ # Always surface these even when report_errors is false (fail closed).
54
+ ALWAYS_SHOW_CODES = frozenset({'IO001', 'SYN001'})
55
+
56
+ EVAL_EXEC_NAMES = frozenset({
57
+ 'eval',
58
+ 'exec',
59
+ 'builtins.eval',
60
+ 'builtins.exec',
61
+ '__builtins__.eval',
62
+ '__builtins__.exec',
63
+ })
64
+
65
+ PICKLE_LOAD_NAMES = frozenset({
66
+ 'pickle.loads', 'pickle.load',
67
+ '_pickle.loads', '_pickle.load',
68
+ 'dill.loads', 'dill.load',
69
+ 'cloudpickle.loads', 'cloudpickle.load',
70
+ 'jsonpickle.decode',
71
+ 'marshal.loads', 'marshal.load',
72
+ 'shelve.open',
73
+ 'yaml.unsafe_load', 'yaml.unsafe_load_all',
74
+ })
75
+
76
+ UNPICKLER_NAMES = frozenset({'pickle.Unpickler', '_pickle.Unpickler', 'dill.Unpickler'})
77
+
78
+ # yaml.load needs an explicit safe Loader; these loaders build arbitrary objects.
79
+ YAML_LOAD_NAMES = frozenset({'yaml.load', 'yaml.load_all'})
80
+ YAML_UNSAFE_LOADERS = frozenset({'yaml.Loader', 'yaml.UnsafeLoader'})
81
+
82
+ SUBPROCESS_SHELL_NAMES = frozenset({
83
+ 'subprocess.call',
84
+ 'subprocess.run',
85
+ 'subprocess.Popen',
86
+ 'subprocess.check_call',
87
+ 'subprocess.check_output',
88
+ })
89
+
90
+ # These always run their argument through a shell.
91
+ ALWAYS_SHELL_NAMES = frozenset({
92
+ 'os.system',
93
+ 'os.popen',
94
+ 'subprocess.getoutput',
95
+ 'subprocess.getstatusoutput',
96
+ })
97
+
98
+ MUTABLE_CTOR_NAMES = frozenset({
99
+ 'list',
100
+ 'dict',
101
+ 'set',
102
+ 'builtins.list',
103
+ 'builtins.dict',
104
+ 'builtins.set',
105
+ })
106
+
107
+ OPEN_NAMES = frozenset({'open', 'builtins.open', 'io.open'})
108
+
109
+ TAINT_CALL_NAMES = frozenset({
110
+ 'input',
111
+ 'builtins.input',
112
+ 'os.getenv',
113
+ 'os.environ.get',
114
+ })
115
+
116
+ TAINT_ATTR_NAMES = frozenset({
117
+ 'sys.argv',
118
+ 'os.environ',
119
+ 'sys.stdin',
120
+ })
121
+
122
+ REQUEST_TAINT_ATTRS = frozenset({
123
+ 'args', 'GET', 'POST', 'json', 'data', 'form', 'cookies', 'headers',
124
+ 'values', 'query_params', 'query', 'params', 'body', 'files',
125
+ })
126
+
127
+ # REQUEST_TAINT_ATTRS only count when read from something that looks like an
128
+ # HTTP request object (``request.args``, ``req.json``, ``self.request.GET``).
129
+ REQUEST_RECEIVER_NAMES = frozenset({'request', 'req', 'flask_request', 'http_request'})
130
+
131
+ # Calls that neutralize their input for the sinks we check (shell / eval).
132
+ SANITIZER_NAMES = frozenset({
133
+ 'int', 'float', 'bool', 'len',
134
+ 'builtins.int', 'builtins.float', 'builtins.bool', 'builtins.len',
135
+ 'shlex.quote', 'shlex.join',
136
+ 're.escape', 'html.escape',
137
+ 'urllib.parse.quote', 'urllib.parse.quote_plus',
138
+ })
139
+
140
+ # Wrappers that take ownership of a file handle's lifetime.
141
+ HANDLE_OWNER_NAMES = frozenset({'contextlib.closing'})
142
+ HANDLE_OWNER_METHODS = frozenset({'enter_context', 'enter_async_context'})
143
+
144
+ _TEST_FILE_DIRS = frozenset({'test', 'tests', 'testing'})
145
+
146
+ _BROAD_EXCEPTION_NAMES = frozenset({'Exception', 'builtins.Exception'})
147
+
148
+ ABSTRACT_DECORATORS = frozenset({
149
+ 'abstractmethod',
150
+ 'abc.abstractmethod',
151
+ 'overload',
152
+ 'typing.overload',
153
+ })
154
+
155
+ ISSUE_SUGGESTIONS = {
156
+ 'SEC001': 'Do not evaluate untrusted strings; use ast.literal_eval or a real parser.',
157
+ 'SEC002': 'Avoid pickle/marshal/unsafe YAML for untrusted data; use json, yaml.safe_load, '
158
+ 'or a dedicated serializer.',
159
+ 'SEC003': 'Pass a sequence of arguments with shell=False.',
160
+ 'SEC004': 'Pass a fixed executable and argument list, not a user-built command string.',
161
+ 'BUG001': 'Use None as the default and create the mutable object inside the function.',
162
+ 'BUG002': 'Remove or rename the duplicate key.',
163
+ 'EXC001': "Catch specific exceptions, or use 'except Exception:' if you must.",
164
+ 'EXC003': 'Log, re-raise, or handle the error; do not use a bare pass.',
165
+ 'EXC005': "Use 'raise NewError(...) from exc' to chain the original exception.",
166
+ 'IMP001': 'Import only the names you need.',
167
+ 'IMP002': 'Remove the unused import.',
168
+ 'IMP003': 'Import a name that the sibling module actually defines, or add that name.',
169
+ 'RES001': "Use 'with open(...) as handle:'.",
170
+ 'ASY001': 'Await a coroutine, or make the function synchronous.',
171
+ 'VAR001': 'Remove the unused name, or prefix it with _ if it is intentional.',
172
+ 'VAR002': 'Remove the unused argument, or prefix it with _ if it is required by an API.',
173
+ 'PERF001': 'Use enumerate() to get both index and value.',
174
+ 'STYLE001': "Write 'if flag:' or 'if not flag:'.",
175
+ }
176
+
177
+ NESTED_SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
178
+ _TRY_NODES: Tuple[type, ...] = tuple(
179
+ node_type for node_type in (ast.Try, getattr(ast, 'TryStar', None)) if node_type is not None
180
+ )
181
+
182
+ SKIP_UNUSED_NAMES = frozenset({'self', 'cls', 'mcs', 'mcls'})
183
+ _BUILTIN_NAMES = frozenset(name for name in dir(builtins) if not name.startswith('_'))
184
+
185
+ SKIP_DIR_NAMES = frozenset({
186
+ '.git', '.hg', '.svn', '.tox', '.nox',
187
+ '.mypy_cache', '.pytest_cache', '.ruff_cache', '.coverage',
188
+ '__pycache__', 'site-packages', 'dist', 'build', 'htmlcov',
189
+ 'node_modules', '.venv', 'venv', 'codesnake-venv', 'env', '.env',
190
+ })
191
+
192
+ _NOQA_RE = re.compile(
193
+ r'#\s*(?:noqa|codesnake:\s*ignore)\b(?:\s*[:=]\s*([^\n#]+))?',
194
+ re.IGNORECASE,
195
+ )
196
+ _CODING_COOKIE_RE = re.compile(
197
+ br'^[ \t\f]*#.*?coding[:=][ \t]*([-\w.]+)',
198
+ re.IGNORECASE,
199
+ )
200
+ _TYPE_CHECKING_NAMES = frozenset({'TYPE_CHECKING', 'typing.TYPE_CHECKING'})
201
+
202
+
203
+ # Binding kinds that participate in VAR003 shadow detection.
204
+ _SHADOW_CHECKED_KINDS = frozenset({
205
+ 'assign', 'arg', 'vararg', 'function', 'decorated_function',
206
+ 'loop', 'unpack', 'annotation',
207
+ })
208
+ # Binding kinds reported as unused locals (VAR001). Loop targets, tuple
209
+ # unpacking, bare annotations, and decorated nested functions are exempt.
210
+ _REPORTED_LOCAL_KINDS = frozenset({'assign', 'function', 'class'})
211
+
212
+
213
+ class ConfigError(Exception):
214
+ """Raised when a configuration file cannot be loaded."""
215
+
216
+
217
+ @dataclass
218
+ class _Binding:
219
+ name: str
220
+ line: int
221
+ col: int
222
+ kind: str # import, arg, assign, function, class
223
+
224
+
225
+ class _Scope:
226
+ __slots__ = ('kind', 'name', 'bindings', 'used', 'global_names',
227
+ 'nonlocal_names', 'constants', 'tainted')
228
+
229
+ def __init__(self, kind: str, name: str = ''):
230
+ self.kind = kind
231
+ self.name = name
232
+ self.bindings: Dict[str, _Binding] = {}
233
+ self.used: Set[str] = set()
234
+ self.global_names: Set[str] = set()
235
+ self.nonlocal_names: Set[str] = set()
236
+ self.constants: Dict[str, Any] = {}
237
+ self.tainted: Set[str] = set()
238
+
239
+
240
+ @dataclass
241
+ class _GitIgnorePattern:
242
+ negated: bool
243
+ dir_only: bool
244
+ regex: Pattern[str]
245
+
246
+ def matches(self, rel: str, is_dir: bool) -> bool:
247
+ if self.dir_only and not is_dir:
248
+ return False
249
+ return self.regex.search(rel) is not None
250
+
251
+
252
+ def _gitignore_glob_to_regex(pattern: str, anchored: bool) -> Pattern[str]:
253
+ parts: List[str] = []
254
+ i = 0
255
+ while i < len(pattern):
256
+ if pattern.startswith('**/', i):
257
+ parts.append('(?:.*/)?')
258
+ i += 3
259
+ elif pattern.startswith('**', i):
260
+ parts.append('.*')
261
+ i += 2
262
+ elif pattern[i] == '*':
263
+ parts.append('[^/]*')
264
+ i += 1
265
+ elif pattern[i] == '?':
266
+ parts.append('[^/]')
267
+ i += 1
268
+ else:
269
+ parts.append(re.escape(pattern[i]))
270
+ i += 1
271
+ body = ''.join(parts)
272
+ if anchored:
273
+ regex = '^' + body + '(?:/.*)?$'
274
+ else:
275
+ regex = r'(?:^|/)' + body + r'(?:/.*)?$'
276
+ return re.compile(regex)
277
+
278
+
279
+ def _parse_gitignore_text(text: str) -> List[_GitIgnorePattern]:
280
+ parsed: List[_GitIgnorePattern] = []
281
+ for raw in text.splitlines():
282
+ line = raw.rstrip()
283
+ if not line or line.startswith('#'):
284
+ continue
285
+ negated = line.startswith('!')
286
+ if negated:
287
+ line = line[1:]
288
+ dir_only = line.endswith('/')
289
+ if dir_only:
290
+ line = line[:-1]
291
+ if line.startswith('/'):
292
+ line = line[1:]
293
+ if not line:
294
+ continue
295
+ # git anchors a pattern to the .gitignore's own directory when it holds
296
+ # a separator anywhere but the end; only a bare name matches at any depth.
297
+ anchored = '/' in line
298
+ parsed.append(_GitIgnorePattern(
299
+ negated=negated,
300
+ dir_only=dir_only,
301
+ regex=_gitignore_glob_to_regex(line, anchored),
302
+ ))
303
+ return parsed
304
+
305
+
306
+ _DirectoryView = List[Tuple[str, List[_GitIgnorePattern]]]
307
+
308
+
309
+ class _IgnoreStack:
310
+ """Every .gitignore that applies to the walk, keyed by the directory it lives in.
311
+
312
+ Paths handed in must already be resolved (they come from ``os.walk`` over a
313
+ resolved root), so no per-file ``resolve()`` syscalls are needed.
314
+ """
315
+
316
+ def __init__(self) -> None:
317
+ self._entries: List[Tuple[Path, List[_GitIgnorePattern]]] = []
318
+ self._loaded: Set[Path] = set()
319
+
320
+ def add_gitignore(self, gi_path: Path) -> None:
321
+ if gi_path in self._loaded:
322
+ return
323
+ self._loaded.add(gi_path)
324
+ try:
325
+ text = gi_path.read_text(encoding='utf-8')
326
+ except OSError:
327
+ return
328
+ self._entries.append((gi_path.parent, _parse_gitignore_text(text)))
329
+
330
+ def view(self, directory: Path) -> _DirectoryView:
331
+ """Per-.gitignore relative prefixes for ``directory``, computed once per directory."""
332
+ views: _DirectoryView = []
333
+ for base, patterns in self._entries:
334
+ try:
335
+ rel = directory.relative_to(base).as_posix()
336
+ except ValueError:
337
+ continue
338
+ views.append(('' if rel == '.' else rel + '/', patterns))
339
+ return views
340
+
341
+ @staticmethod
342
+ def ignored(views: _DirectoryView, name: str, is_dir: bool) -> bool:
343
+ ignored = False
344
+ for prefix, patterns in views:
345
+ rel = prefix + name
346
+ for pattern in patterns:
347
+ if pattern.matches(rel, is_dir):
348
+ ignored = not pattern.negated
349
+ return ignored
350
+
351
+
352
+ _REPO_MARKERS = ('.git', '.hg', '.svn')
353
+
354
+
355
+ def find_repo_root(start: Path) -> Optional[Path]:
356
+ """Nearest directory at or above ``start`` that contains a VCS marker."""
357
+ for candidate in (start, *start.parents):
358
+ if any((candidate / marker).exists() for marker in _REPO_MARKERS):
359
+ return candidate
360
+ return None
361
+
362
+
363
+ def detect_source_encoding(data: bytes) -> str:
364
+ """PEP 263 encoding cookie, else UTF-8 (with BOM)."""
365
+ if data.startswith(b'\xef\xbb\xbf'):
366
+ return 'utf-8-sig'
367
+ chunks = data.split(b'\n', 2)[:2]
368
+ for raw_line in chunks:
369
+ match = _CODING_COOKIE_RE.match(raw_line.replace(b'\r', b''))
370
+ if match:
371
+ encoding = match.group(1).decode('ascii', errors='replace')
372
+ if encoding.lower() in ('utf-8', 'utf8'):
373
+ return 'utf-8'
374
+ return encoding
375
+ return 'utf-8'
376
+
377
+
378
+ def read_python_source(path: Path) -> str:
379
+ data = path.read_bytes()
380
+ encoding = detect_source_encoding(data)
381
+ return data.decode(encoding)
382
+
383
+
384
+ def ignored_codes_on_line(line: str) -> Optional[Set[str]]:
385
+ """None = no pragma; empty set = ignore all codes; otherwise specific codes."""
386
+ match = _NOQA_RE.search(line)
387
+ if not match:
388
+ return None
389
+ spec = match.group(1)
390
+ if not spec:
391
+ return set()
392
+ return {part.strip().upper() for part in spec.replace(',', ' ').split() if part.strip()}
393
+
394
+
395
+ def issue_ignored_by_pragma(code: str, line: int, source_lines: Sequence[str]) -> bool:
396
+ """True if a ``# noqa`` / ``# codesnake: ignore`` pragma on ``line`` covers ``code``."""
397
+ if line <= 0 or line > len(source_lines):
398
+ return False
399
+ codes = ignored_codes_on_line(source_lines[line - 1])
400
+ if codes is None:
401
+ return False
402
+ if not codes:
403
+ return True
404
+ return code.upper() in codes
405
+
406
+
407
+ def iter_python_files(root: Path) -> Iterable[Path]:
408
+ """Yield .py files under root, skipping venvs, caches, and .gitignore matches."""
409
+ try:
410
+ root_resolved = root.resolve()
411
+ except OSError:
412
+ root_resolved = root
413
+ ignore = _IgnoreStack()
414
+ # .gitignore files between the repository root and the target apply to
415
+ # everything below them, so load them first (top-most first).
416
+ chain = [root_resolved]
417
+ repo_root = find_repo_root(root_resolved)
418
+ if repo_root is not None and repo_root != root_resolved:
419
+ for parent in root_resolved.parents:
420
+ chain.append(parent)
421
+ if parent == repo_root:
422
+ break
423
+ for directory in reversed(chain):
424
+ gi_path = directory / '.gitignore'
425
+ if gi_path.is_file():
426
+ ignore.add_gitignore(gi_path)
427
+
428
+ for dirpath, dirnames, filenames in os.walk(root_resolved):
429
+ current = Path(dirpath)
430
+ nested_gi = current / '.gitignore'
431
+ if nested_gi.is_file():
432
+ ignore.add_gitignore(nested_gi)
433
+ views = ignore.view(current)
434
+
435
+ dirnames[:] = [
436
+ name for name in dirnames
437
+ if name not in SKIP_DIR_NAMES
438
+ and not name.endswith('.egg-info')
439
+ and not _IgnoreStack.ignored(views, name, is_dir=True)
440
+ ]
441
+
442
+ for name in filenames:
443
+ if name.endswith('.py') and not _IgnoreStack.ignored(views, name, is_dir=False):
444
+ yield current / name
445
+
446
+
447
+ @dataclass
448
+ class Issue:
449
+ """Represents a code issue found during analysis."""
450
+ severity: str # 'error', 'warning', 'info'
451
+ category: str
452
+ message: str
453
+ line: int
454
+ col: int
455
+ code: str # Issue code like 'SEC001', 'PERF001', etc.
456
+ filename: str = ''
457
+ source: str = 'codesnake'
458
+ end_line: int = 0
459
+ end_col: int = 0
460
+ suggestion: str = ''
461
+
462
+
463
+ @dataclass
464
+ class CheckerConfig:
465
+ """Analysis thresholds and category toggles. JSON is the source of truth."""
466
+ max_function_length: int = 50
467
+ max_function_params: int = 7
468
+ max_complexity: int = 10
469
+ max_class_methods: int = 20
470
+ max_instance_vars: int = 10
471
+ check_security: bool = True
472
+ check_bugs: bool = True
473
+ check_exceptions: bool = True
474
+ check_complexity: bool = True
475
+ check_performance: bool = True
476
+ check_imports: bool = True
477
+ check_style: bool = True
478
+ check_unused: bool = True
479
+ check_reliability: bool = True
480
+ use_bandit: bool = False
481
+ report_errors: bool = True
482
+ report_warnings: bool = True
483
+ report_info: bool = True
484
+
485
+ @classmethod
486
+ def from_file(cls, path: str) -> 'CheckerConfig':
487
+ """Load ``.codesnake.json`` or a ``pyproject.toml`` with a ``[tool.codesnake]`` table."""
488
+ config_path = Path(path)
489
+ if config_path.suffix.lower() == '.toml':
490
+ if not config_path.is_file():
491
+ raise ConfigError(f"Config file '{path}' not found")
492
+ table = _pyproject_config(config_path)
493
+ if table is None:
494
+ raise ConfigError(f"'{path}' has no [tool.codesnake] table")
495
+ return cls.from_mapping(table, path)
496
+
497
+ try:
498
+ raw = config_path.read_text(encoding='utf-8')
499
+ except FileNotFoundError:
500
+ raise ConfigError(f"Config file '{path}' not found") from None
501
+ except OSError as exc:
502
+ raise ConfigError(f"Could not read config file '{path}': {exc}") from exc
503
+
504
+ try:
505
+ data = json.loads(raw)
506
+ except json.JSONDecodeError as exc:
507
+ raise ConfigError(f"Invalid JSON in '{path}': {exc}") from exc
508
+
509
+ if not isinstance(data, dict):
510
+ raise ConfigError(f"Config file '{path}' must contain a JSON object")
511
+ return cls.from_mapping(data, path)
512
+
513
+ @classmethod
514
+ def from_mapping(cls, data: Dict[str, Any], source: str = '<config>') -> 'CheckerConfig':
515
+ """Validate key names and value types; ``source`` names the origin in messages."""
516
+ path = source
517
+ known = {item.name: item for item in fields(cls)}
518
+ unknown = sorted(key for key in data if key not in known)
519
+ if unknown:
520
+ print(
521
+ f"Warning: unknown config key(s) in '{path}': {', '.join(unknown)}",
522
+ file=sys.stderr,
523
+ )
524
+
525
+ kwargs: Dict[str, Any] = {}
526
+ problems: List[str] = []
527
+ for key, value in data.items():
528
+ field = known.get(key)
529
+ if field is None:
530
+ continue
531
+ expected = type(field.default)
532
+ if expected is bool:
533
+ valid = isinstance(value, bool)
534
+ else:
535
+ valid = isinstance(value, int) and not isinstance(value, bool)
536
+ if not valid:
537
+ problems.append(
538
+ f"'{key}' must be {expected.__name__}, got {type(value).__name__}"
539
+ )
540
+ continue
541
+ # A threshold below 1 flags every function in the tree, which reads
542
+ # as a broken tool rather than a broken config.
543
+ if expected is int and value < 1:
544
+ problems.append(f"'{key}' must be 1 or greater, got {value}")
545
+ continue
546
+ kwargs[key] = value
547
+ if problems:
548
+ raise ConfigError(f"Invalid config values in '{path}': " + '; '.join(problems))
549
+ return cls(**kwargs)
550
+
551
+ def to_file(self, path: str) -> None:
552
+ output = Path(path)
553
+ output.write_text(json.dumps(asdict(self), indent=2) + '\n', encoding='utf-8')
554
+
555
+ def allows_severity(self, severity: str) -> bool:
556
+ if severity == 'error':
557
+ return self.report_errors
558
+ if severity == 'warning':
559
+ return self.report_warnings
560
+ if severity == 'info':
561
+ return self.report_info
562
+ return True
563
+
564
+ def allows_category(self, category: str) -> bool:
565
+ flag = CATEGORY_FLAGS.get(category)
566
+ if flag is None:
567
+ return True
568
+ return bool(getattr(self, flag))
569
+
570
+
571
+ CONFIG_FILENAME = '.codesnake.json'
572
+ PYPROJECT_FILENAME = 'pyproject.toml'
573
+ _TOOL_TABLE_RE = re.compile(rb'^\s*\[tool\.codesnake\]', re.MULTILINE)
574
+
575
+
576
+ def _pyproject_config(pyproject: Path) -> Optional[Dict[str, Any]]:
577
+ """The ``[tool.codesnake]`` table of ``pyproject`` or None when absent."""
578
+ try:
579
+ raw = pyproject.read_bytes()
580
+ except OSError:
581
+ return None
582
+ if tomllib is None: # pragma: no cover - Python 3.10 only
583
+ if _TOOL_TABLE_RE.search(raw):
584
+ print(
585
+ f"Warning: '{pyproject}' has a [tool.codesnake] table but reading it "
586
+ "needs Python 3.11+; using defaults",
587
+ file=sys.stderr,
588
+ )
589
+ return None
590
+ try:
591
+ data = tomllib.loads(raw.decode('utf-8'))
592
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
593
+ raise ConfigError(f"Invalid TOML in '{pyproject}': {exc}") from exc
594
+ tool = data.get('tool')
595
+ if not isinstance(tool, dict) or 'codesnake' not in tool:
596
+ return None
597
+ table = tool['codesnake']
598
+ if not isinstance(table, dict):
599
+ raise ConfigError(f"[tool.codesnake] in '{pyproject}' must be a table")
600
+ return table
601
+
602
+
603
+ def discover_config_file(start: Optional[Path] = None) -> Optional[Path]:
604
+ """Find the config that applies to ``start`` (default: cwd).
605
+
606
+ Walks upward, stopping after the repository root. In each directory a
607
+ ``.codesnake.json`` wins over a ``pyproject.toml`` ``[tool.codesnake]`` table.
608
+ """
609
+ here = (start or Path.cwd()).resolve()
610
+ repo_root = find_repo_root(here)
611
+ for directory in (here, *here.parents):
612
+ json_path = directory / CONFIG_FILENAME
613
+ if json_path.is_file():
614
+ return json_path
615
+ pyproject = directory / PYPROJECT_FILENAME
616
+ if pyproject.is_file() and _pyproject_config(pyproject) is not None:
617
+ return pyproject
618
+ if repo_root is not None and directory == repo_root:
619
+ break
620
+ return None
621
+
622
+
623
+ def load_config(path: Optional[str] = None, start: Optional[Path] = None) -> CheckerConfig:
624
+ """Explicit ``path`` if given; else the nearest discovered config; else defaults."""
625
+ if path:
626
+ return CheckerConfig.from_file(path)
627
+ found = discover_config_file(start)
628
+ if found is None:
629
+ return CheckerConfig()
630
+ return CheckerConfig.from_file(str(found))
631
+
632
+
633
+ class SemanticChecker(ast.NodeVisitor):
634
+ """Main semantic checker that analyzes Python AST for issues."""
635
+
636
+ def __init__(
637
+ self,
638
+ source_code: str,
639
+ filename: str = '<string>',
640
+ config: Optional[CheckerConfig] = None,
641
+ known_exports: Optional[Dict[str, Set[str]]] = None,
642
+ ):
643
+ self.source_code = source_code
644
+ self.filename = filename
645
+ self.config = config or CheckerConfig()
646
+ self.issues: List[Issue] = []
647
+ self.source_lines = source_code.split('\n')
648
+
649
+ self.aliases: Dict[str, str] = {}
650
+ # Cyclomatic complexity per function name (nested scopes not charged).
651
+ self.function_complexity: Dict[str, int] = {}
652
+ self.scopes: List[_Scope] = []
653
+ self._in_type_checking = False
654
+ self._with_expr_ids: Set[int] = set()
655
+ # id(Name node) -> binding kind for stores that are not plain assignments
656
+ # (loop targets, tuple unpacking, bare annotations).
657
+ self._store_kinds: Dict[int, str] = {}
658
+ # Function bodies are analyzed after the enclosing scope is fully bound,
659
+ # so closures may reference names assigned later in that scope.
660
+ self._deferred: List[Tuple[ast.AST, List[_Scope], bool]] = []
661
+ self.known_exports = known_exports or {}
662
+
663
+ def add_issue(
664
+ self,
665
+ severity: str,
666
+ category: str,
667
+ message: str,
668
+ node: ast.AST,
669
+ code: str,
670
+ ):
671
+ """Add an issue at ``node``'s location if its category is enabled."""
672
+ self._record_issue(
673
+ severity,
674
+ category,
675
+ message,
676
+ getattr(node, 'lineno', 0) or 0,
677
+ getattr(node, 'col_offset', 0) or 0,
678
+ code,
679
+ end_line=getattr(node, 'end_lineno', None) or getattr(node, 'lineno', 0) or 0,
680
+ end_col=getattr(node, 'end_col_offset', None) or getattr(node, 'col_offset', 0) or 0,
681
+ )
682
+
683
+ def _record_issue(
684
+ self,
685
+ severity: str,
686
+ category: str,
687
+ message: str,
688
+ line: int,
689
+ col: int,
690
+ code: str,
691
+ source: str = 'codesnake',
692
+ end_line: int = 0,
693
+ end_col: int = 0,
694
+ suggestion: str = '',
695
+ ) -> None:
696
+ if not self.config.allows_category(category):
697
+ return
698
+ end_line = end_line or line
699
+ col = self._char_col(line, col)
700
+ end_col = self._char_col(end_line, end_col) if end_col else col
701
+ self.issues.append(Issue(
702
+ severity=severity,
703
+ category=category,
704
+ message=message,
705
+ line=line,
706
+ col=col,
707
+ code=code,
708
+ filename=self.filename,
709
+ source=source,
710
+ end_line=end_line,
711
+ end_col=end_col,
712
+ suggestion=suggestion or ISSUE_SUGGESTIONS.get(code, ''),
713
+ ))
714
+
715
+ def _char_col(self, line: int, col: int) -> int:
716
+ """Convert an AST UTF-8 byte offset into a 0-based character offset."""
717
+ if col <= 0 or line <= 0 or line > len(self.source_lines):
718
+ return max(col, 0)
719
+ text = self.source_lines[line - 1]
720
+ if text.isascii():
721
+ return col
722
+ return len(text.encode('utf-8')[:col].decode('utf-8', errors='ignore'))
723
+
724
+ def _current_scope(self) -> Optional[_Scope]:
725
+ return self.scopes[-1] if self.scopes else None
726
+
727
+ def _push_scope(self, kind: str, name: str = '') -> _Scope:
728
+ scope = _Scope(kind, name)
729
+ self.scopes.append(scope)
730
+ return scope
731
+
732
+ def _pop_scope(self) -> Optional[_Scope]:
733
+ if not self.scopes:
734
+ return None
735
+ scope = self.scopes.pop()
736
+ if scope.kind == 'function':
737
+ self._report_unused_locals(scope)
738
+ elif scope.kind == 'module':
739
+ self._report_unused_imports(scope)
740
+ return scope
741
+
742
+ def _bind(self, name: str, node: ast.AST, kind: str) -> None:
743
+ scope = self._current_scope()
744
+ if scope is None:
745
+ return
746
+ if name in scope.global_names:
747
+ if len(self.scopes) > 1:
748
+ self.scopes[0].bindings.setdefault(name, _Binding(
749
+ name,
750
+ getattr(node, 'lineno', 0) or 0,
751
+ getattr(node, 'col_offset', 0) or 0,
752
+ kind,
753
+ ))
754
+ return
755
+ if name in scope.nonlocal_names:
756
+ return
757
+ if name not in scope.bindings:
758
+ if kind in _SHADOW_CHECKED_KINDS and scope.kind == 'function':
759
+ self._maybe_shadow(name, node)
760
+ scope.bindings[name] = _Binding(
761
+ name,
762
+ getattr(node, 'lineno', 0) or 0,
763
+ getattr(node, 'col_offset', 0) or 0,
764
+ kind,
765
+ )
766
+
767
+ def _maybe_shadow(self, name: str, node: ast.AST) -> None:
768
+ if name.startswith('_') or name in SKIP_UNUSED_NAMES or name in _BUILTIN_NAMES:
769
+ return
770
+ for scope in reversed(self.scopes[:-1]):
771
+ if scope.kind == 'class' or scope.kind == 'comprehension':
772
+ continue
773
+ if scope.kind == 'module':
774
+ return
775
+ if name in scope.bindings:
776
+ self.add_issue(
777
+ 'info',
778
+ 'unused',
779
+ f"Name '{name}' shadows a name from an enclosing function",
780
+ node,
781
+ 'VAR003',
782
+ )
783
+ return
784
+
785
+ def _mark_used(self, name: str) -> None:
786
+ for scope in reversed(self.scopes):
787
+ if name in scope.nonlocal_names:
788
+ continue
789
+ if name in scope.bindings or name in scope.global_names:
790
+ scope.used.add(name)
791
+ if name in scope.global_names and self.scopes:
792
+ self.scopes[0].used.add(name)
793
+ return
794
+ if self.scopes and name in self.scopes[0].bindings:
795
+ self.scopes[0].used.add(name)
796
+
797
+ def _lookup_const(self, name: str) -> Any:
798
+ for scope in reversed(self.scopes):
799
+ if name in scope.constants:
800
+ return scope.constants[name]
801
+ return None
802
+
803
+ def _set_const(self, name: str, value: Any) -> None:
804
+ scope = self._current_scope()
805
+ if scope is not None:
806
+ scope.constants[name] = value
807
+
808
+ def _mark_name_tainted(self, name: str) -> None:
809
+ for scope in reversed(self.scopes):
810
+ if name in scope.global_names:
811
+ self.scopes[0].tainted.add(name)
812
+ return
813
+ if name in scope.bindings:
814
+ scope.tainted.add(name)
815
+ return
816
+ if self.scopes:
817
+ self.scopes[-1].tainted.add(name)
818
+
819
+ def _name_is_tainted(self, name: str) -> bool:
820
+ for scope in reversed(self.scopes):
821
+ if name in scope.tainted:
822
+ return True
823
+ if name in scope.bindings:
824
+ return False
825
+ return False
826
+
827
+ def _mark_tainted_target(self, target: ast.AST) -> None:
828
+ if isinstance(target, ast.Name):
829
+ self._mark_name_tainted(target.id)
830
+ elif isinstance(target, (ast.Tuple, ast.List)):
831
+ for elt in target.elts:
832
+ self._mark_tainted_target(elt)
833
+ elif isinstance(target, ast.Starred):
834
+ self._mark_tainted_target(target.value)
835
+
836
+ def _is_tainted_expr(self, node: Optional[ast.AST]) -> bool:
837
+ if node is None:
838
+ return False
839
+ if isinstance(node, ast.Constant):
840
+ return False
841
+ if isinstance(node, ast.Name):
842
+ return self._name_is_tainted(node.id)
843
+ if isinstance(node, ast.JoinedStr):
844
+ return any(self._is_tainted_expr(value) for value in node.values)
845
+ if isinstance(node, ast.FormattedValue):
846
+ return self._is_tainted_expr(node.value)
847
+ if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
848
+ return self._is_tainted_expr(node.left) or self._is_tainted_expr(node.right)
849
+ if isinstance(node, ast.Call):
850
+ resolved = self._resolve_name(node.func)
851
+ if resolved in TAINT_CALL_NAMES:
852
+ return True
853
+ if resolved in SANITIZER_NAMES:
854
+ return False
855
+ if isinstance(node.func, ast.Attribute) and node.func.attr in ('get', 'format'):
856
+ if self._is_tainted_expr(node.func.value):
857
+ return True
858
+ if self._call_args_tainted(node):
859
+ return True
860
+ return self._call_args_tainted(node)
861
+ if isinstance(node, ast.Attribute):
862
+ resolved = self._resolve_name(node)
863
+ if resolved in TAINT_ATTR_NAMES:
864
+ return True
865
+ if node.attr in REQUEST_TAINT_ATTRS and self._is_request_like(node.value):
866
+ return True
867
+ return self._is_tainted_expr(node.value)
868
+ if isinstance(node, ast.Subscript):
869
+ return self._is_tainted_expr(node.value)
870
+ if isinstance(node, ast.Starred):
871
+ return self._is_tainted_expr(node.value)
872
+ if isinstance(node, (ast.List, ast.Tuple)):
873
+ return any(self._is_tainted_expr(elt) for elt in node.elts)
874
+ return False
875
+
876
+ @staticmethod
877
+ def _first_arg(node: ast.Call, keyword: Optional[str] = None) -> Optional[ast.AST]:
878
+ """The call's first argument, positional or passed by ``keyword``."""
879
+ if node.args:
880
+ first = node.args[0]
881
+ # ``f(*seq)`` says nothing about what lands in position 0.
882
+ return None if isinstance(first, ast.Starred) else first
883
+ if keyword:
884
+ for kw in node.keywords:
885
+ if kw.arg == keyword:
886
+ return kw.value
887
+ return None
888
+
889
+ def _call_args_tainted(self, node: ast.Call) -> bool:
890
+ """Taint reaches a call through keywords as readily as positionals."""
891
+ if any(self._is_tainted_expr(arg) for arg in node.args):
892
+ return True
893
+ return any(self._is_tainted_expr(kw.value) for kw in node.keywords)
894
+
895
+ @staticmethod
896
+ def _is_request_like(node: ast.AST) -> bool:
897
+ if isinstance(node, ast.Name):
898
+ return node.id in REQUEST_RECEIVER_NAMES
899
+ if isinstance(node, ast.Attribute):
900
+ return node.attr in REQUEST_RECEIVER_NAMES
901
+ return False
902
+
903
+ def _is_literal_expr(self, node: Optional[ast.AST]) -> bool:
904
+ if node is None:
905
+ return False
906
+ if isinstance(node, ast.Constant):
907
+ return True
908
+ if isinstance(node, ast.Name):
909
+ if self._name_is_tainted(node.id):
910
+ return False
911
+ for scope in reversed(self.scopes):
912
+ if node.id in scope.constants:
913
+ return True
914
+ return False
915
+ if isinstance(node, ast.JoinedStr):
916
+ return all(
917
+ isinstance(value, ast.Constant) or (
918
+ isinstance(value, ast.FormattedValue)
919
+ and self._is_literal_expr(value.value)
920
+ )
921
+ for value in node.values
922
+ )
923
+ return False
924
+
925
+ def _report_unused_imports(self, scope: _Scope) -> None:
926
+ for name, binding in scope.bindings.items():
927
+ if binding.kind != 'import':
928
+ continue
929
+ if name in scope.used:
930
+ continue
931
+ self._record_issue(
932
+ 'warning',
933
+ 'imports',
934
+ f"Imported name '{name}' is unused",
935
+ binding.line,
936
+ binding.col,
937
+ 'IMP002',
938
+ )
939
+
940
+ def _report_unused_locals(self, scope: _Scope) -> None:
941
+ for name, binding in scope.bindings.items():
942
+ if name in scope.used:
943
+ continue
944
+ if name in SKIP_UNUSED_NAMES or name.startswith('_'):
945
+ continue
946
+ if binding.kind == 'import':
947
+ self._record_issue(
948
+ 'warning',
949
+ 'imports',
950
+ f"Imported name '{name}' is unused",
951
+ binding.line,
952
+ binding.col,
953
+ 'IMP002',
954
+ )
955
+ elif binding.kind == 'arg':
956
+ self._record_issue(
957
+ 'warning',
958
+ 'unused',
959
+ f"Unused argument '{name}'",
960
+ binding.line,
961
+ binding.col,
962
+ 'VAR002',
963
+ )
964
+ elif binding.kind in _REPORTED_LOCAL_KINDS:
965
+ label = 'nested function' if binding.kind == 'function' else (
966
+ 'nested class' if binding.kind == 'class' else 'local variable'
967
+ )
968
+ self._record_issue(
969
+ 'warning',
970
+ 'unused',
971
+ f"Unused {label} '{name}'",
972
+ binding.line,
973
+ binding.col,
974
+ 'VAR001',
975
+ )
976
+
977
+ def _mark_store_kind(self, target: ast.AST, kind: str) -> None:
978
+ """Tag every Name inside ``target`` so visit_Name binds it as ``kind``."""
979
+ if isinstance(target, ast.Name):
980
+ self._store_kinds[id(target)] = kind
981
+ elif isinstance(target, (ast.Tuple, ast.List)):
982
+ for elt in target.elts:
983
+ self._mark_store_kind(elt, kind)
984
+ elif isinstance(target, ast.Starred):
985
+ self._mark_store_kind(target.value, kind)
986
+
987
+ def _is_test_file(self) -> bool:
988
+ path = Path(self.filename)
989
+ name = path.name
990
+ if name.startswith('test_') or name.endswith('_test.py') or name == 'conftest.py':
991
+ return True
992
+ return any(part in _TEST_FILE_DIRS for part in path.parts[:-1])
993
+
994
+ def _is_ignored(self, issue: Issue) -> bool:
995
+ return issue_ignored_by_pragma(issue.code, issue.line, self.source_lines)
996
+
997
+ def _is_type_checking_test(self, test: ast.AST) -> bool:
998
+ resolved = self._resolve_name(test)
999
+ return resolved in _TYPE_CHECKING_NAMES
1000
+
1001
+ def _mark_exported(self, value: Optional[ast.AST]) -> None:
1002
+ """Treat the string literals in an ``__all__`` value as module-level uses."""
1003
+ if not isinstance(value, (ast.List, ast.Tuple, ast.Set)):
1004
+ return
1005
+ for elt in value.elts:
1006
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
1007
+ self.scopes[0].used.add(elt.value)
1008
+
1009
+ def _at_module_scope(self) -> bool:
1010
+ return bool(self.scopes) and self.scopes[-1].kind == 'module'
1011
+
1012
+ def _record_dunder_all(self, node: ast.Assign) -> None:
1013
+ if not self._at_module_scope():
1014
+ return
1015
+ for target in node.targets:
1016
+ if isinstance(target, ast.Name) and target.id == '__all__':
1017
+ self._mark_exported(node.value)
1018
+
1019
+ def _record_dunder_all_augmented(self, node: ast.AugAssign) -> None:
1020
+ """``__all__ += [...]``, the usual way a package re-exports submodules."""
1021
+ if not self._at_module_scope():
1022
+ return
1023
+ if isinstance(node.target, ast.Name) and node.target.id == '__all__':
1024
+ self._mark_exported(node.value)
1025
+
1026
+ def _record_dunder_all_call(self, node: ast.Call) -> None:
1027
+ """``__all__.extend([...])`` / ``__all__.append('name')``."""
1028
+ if not self._at_module_scope():
1029
+ return
1030
+ func = node.func
1031
+ if not isinstance(func, ast.Attribute):
1032
+ return
1033
+ if not isinstance(func.value, ast.Name) or func.value.id != '__all__':
1034
+ return
1035
+ if func.attr == 'extend' and node.args:
1036
+ self._mark_exported(node.args[0])
1037
+ elif func.attr == 'append':
1038
+ for arg in node.args:
1039
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
1040
+ self.scopes[0].used.add(arg.value)
1041
+
1042
+ def _record_constant_assign(self, targets: Sequence[ast.AST], value: ast.AST) -> None:
1043
+ if not isinstance(value, ast.Constant):
1044
+ return
1045
+ for target in targets:
1046
+ if isinstance(target, ast.Name):
1047
+ self._set_const(target.id, value.value)
1048
+
1049
+ def _collect_imports(self, tree: ast.AST) -> None:
1050
+ """Build a name -> qualified-name map from import statements."""
1051
+ for node in ast.walk(tree):
1052
+ if isinstance(node, ast.Import):
1053
+ for alias in node.names:
1054
+ if alias.asname:
1055
+ self.aliases[alias.asname] = alias.name
1056
+ else:
1057
+ root = alias.name.split('.')[0]
1058
+ self.aliases[root] = root
1059
+ elif isinstance(node, ast.ImportFrom):
1060
+ module = node.module or ''
1061
+ for alias in node.names:
1062
+ if alias.name == '*':
1063
+ continue
1064
+ local = alias.asname or alias.name
1065
+ if module:
1066
+ self.aliases[local] = f'{module}.{alias.name}'
1067
+ else:
1068
+ self.aliases[local] = alias.name
1069
+
1070
+ def _resolve_name(self, node: Optional[ast.AST]) -> Optional[str]:
1071
+ """Return a dotted name for a Call target, using the import map."""
1072
+ if node is None:
1073
+ return None
1074
+ if isinstance(node, ast.Name):
1075
+ return self.aliases.get(node.id, node.id)
1076
+ if isinstance(node, ast.Attribute):
1077
+ base = self._resolve_name(node.value)
1078
+ if base:
1079
+ return f'{base}.{node.attr}'
1080
+ return node.attr
1081
+ return None
1082
+
1083
+ def _is_abstract(self, node: ast.AST) -> bool:
1084
+ for decorator in getattr(node, 'decorator_list', []):
1085
+ target = decorator.func if isinstance(decorator, ast.Call) else decorator
1086
+ resolved = self._resolve_name(target) or ''
1087
+ if resolved.rsplit('.', 1)[-1] in {'abstractmethod', 'overload'}:
1088
+ return True
1089
+ if resolved in ABSTRACT_DECORATORS:
1090
+ return True
1091
+ return False
1092
+
1093
+ def _is_stub_body(self, node: ast.AST) -> bool:
1094
+ body = list(getattr(node, 'body', []))
1095
+ if (
1096
+ body
1097
+ and isinstance(body[0], ast.Expr)
1098
+ and isinstance(body[0].value, ast.Constant)
1099
+ and isinstance(body[0].value.value, str)
1100
+ ):
1101
+ body = body[1:]
1102
+ if not body:
1103
+ return True
1104
+ if len(body) == 1 and isinstance(body[0], ast.Pass):
1105
+ return True
1106
+ if (
1107
+ len(body) == 1
1108
+ and isinstance(body[0], ast.Expr)
1109
+ and isinstance(body[0].value, ast.Constant)
1110
+ and body[0].value.value is ...
1111
+ ):
1112
+ return True
1113
+ return False
1114
+
1115
+ def _async_body_has_await(self, node: ast.AST) -> bool:
1116
+ for child in ast.iter_child_nodes(node):
1117
+ if self._subtree_has_await(child):
1118
+ return True
1119
+ return False
1120
+
1121
+ def _subtree_has_await(self, node: ast.AST) -> bool:
1122
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
1123
+ return False
1124
+ if isinstance(node, (ast.Await, ast.AsyncFor, ast.AsyncWith)):
1125
+ return True
1126
+ return any(self._subtree_has_await(child) for child in ast.iter_child_nodes(node))
1127
+
1128
+ def _iter_raises(self, stmts: Sequence[ast.AST]) -> Iterable[ast.Raise]:
1129
+ for stmt in stmts:
1130
+ if isinstance(stmt, ast.Raise):
1131
+ yield stmt
1132
+ elif isinstance(stmt, ast.If):
1133
+ yield from self._iter_raises(stmt.body)
1134
+ yield from self._iter_raises(stmt.orelse)
1135
+ elif isinstance(stmt, (ast.For, ast.AsyncFor, ast.While, ast.With, ast.AsyncWith)):
1136
+ yield from self._iter_raises(stmt.body)
1137
+ yield from self._iter_raises(getattr(stmt, 'orelse', []))
1138
+ elif isinstance(stmt, _TRY_NODES):
1139
+ # A nested handler's own body is checked when visit_Try reaches
1140
+ # that Try, so descending into it here would report twice.
1141
+ yield from self._iter_raises(stmt.body)
1142
+ yield from self._iter_raises(stmt.orelse)
1143
+ yield from self._iter_raises(stmt.finalbody)
1144
+ elif isinstance(stmt, ast.Match):
1145
+ for case in stmt.cases:
1146
+ yield from self._iter_raises(case.body)
1147
+
1148
+ def _check_lossy_raises(self, handler: ast.ExceptHandler) -> None:
1149
+ for raise_node in self._iter_raises(handler.body):
1150
+ if raise_node.exc is None:
1151
+ continue
1152
+ if raise_node.cause is not None:
1153
+ continue
1154
+ if (
1155
+ isinstance(raise_node.exc, ast.Name)
1156
+ and handler.name
1157
+ and raise_node.exc.id == handler.name
1158
+ ):
1159
+ continue
1160
+ if isinstance(raise_node.exc, (ast.Call, ast.Name)):
1161
+ self.add_issue(
1162
+ 'warning',
1163
+ 'exceptions',
1164
+ "Raising a new exception in 'except' hides the original - use 'raise ... from'",
1165
+ raise_node,
1166
+ 'EXC005',
1167
+ )
1168
+
1169
+ def _keyword_true(self, node: ast.Call, name: str) -> bool:
1170
+ for keyword in node.keywords:
1171
+ if keyword.arg != name:
1172
+ continue
1173
+ value = keyword.value
1174
+ # Identity on purpose: 1 == True but ``shell=1`` is not what we match.
1175
+ if isinstance(value, ast.Constant) and value.value is True: # noqa: STYLE001
1176
+ return True
1177
+ if isinstance(value, ast.Name) and self._lookup_const(value.id) is True: # noqa: STYLE001
1178
+ return True
1179
+ return False
1180
+
1181
+ # Security Checks
1182
+
1183
+ def visit_Call(self, node: ast.Call):
1184
+ """Check for security issues in function calls."""
1185
+ self._record_dunder_all_call(node)
1186
+ resolved = self._resolve_name(node.func)
1187
+
1188
+ if resolved in EVAL_EXEC_NAMES:
1189
+ called = resolved.rsplit('.', 1)[-1]
1190
+ arg0 = self._first_arg(node)
1191
+ if arg0 is not None and self._is_tainted_expr(arg0):
1192
+ self.add_issue(
1193
+ 'error',
1194
+ 'security',
1195
+ f"Dangerous use of '{called}()' on untrusted input",
1196
+ node,
1197
+ 'SEC001',
1198
+ )
1199
+ elif arg0 is not None and self._is_literal_expr(arg0):
1200
+ self.add_issue(
1201
+ 'info',
1202
+ 'security',
1203
+ f"Use of '{called}()' on a constant - avoid eval/exec",
1204
+ node,
1205
+ 'SEC001',
1206
+ )
1207
+ else:
1208
+ self.add_issue(
1209
+ 'error',
1210
+ 'security',
1211
+ f"Dangerous use of '{called}()' - can execute arbitrary code",
1212
+ node,
1213
+ 'SEC001',
1214
+ )
1215
+
1216
+ if resolved in PICKLE_LOAD_NAMES:
1217
+ self.add_issue(
1218
+ 'warning',
1219
+ 'security',
1220
+ f"{resolved}() can execute arbitrary code - use with caution",
1221
+ node,
1222
+ 'SEC002',
1223
+ )
1224
+ elif (
1225
+ isinstance(node.func, ast.Attribute)
1226
+ and node.func.attr == 'load'
1227
+ and isinstance(node.func.value, ast.Call)
1228
+ and self._resolve_name(node.func.value.func) in UNPICKLER_NAMES
1229
+ ):
1230
+ unpickler = self._resolve_name(node.func.value.func)
1231
+ self.add_issue(
1232
+ 'warning',
1233
+ 'security',
1234
+ f"{unpickler}(...).load() can execute arbitrary code - use with caution",
1235
+ node,
1236
+ 'SEC002',
1237
+ )
1238
+ elif resolved in YAML_LOAD_NAMES:
1239
+ loader = next((kw.value for kw in node.keywords if kw.arg == 'Loader'), None)
1240
+ if loader is None and len(node.args) >= 2:
1241
+ loader = node.args[1]
1242
+ loader_name = self._resolve_name(loader) if loader is not None else None
1243
+ if loader is None or loader_name in YAML_UNSAFE_LOADERS:
1244
+ self.add_issue(
1245
+ 'warning',
1246
+ 'security',
1247
+ f"{resolved}() without a safe Loader can execute arbitrary code - "
1248
+ "use yaml.safe_load()",
1249
+ node,
1250
+ 'SEC002',
1251
+ )
1252
+
1253
+ if resolved in SUBPROCESS_SHELL_NAMES:
1254
+ cmd = self._first_arg(node, 'args')
1255
+ tainted_cmd = cmd is not None and self._is_tainted_expr(cmd)
1256
+ if self._keyword_true(node, 'shell'):
1257
+ if tainted_cmd:
1258
+ self.add_issue(
1259
+ 'error',
1260
+ 'security',
1261
+ "subprocess with shell=True and untrusted input is command injection",
1262
+ node,
1263
+ 'SEC003',
1264
+ )
1265
+ else:
1266
+ self.add_issue(
1267
+ 'warning',
1268
+ 'security',
1269
+ "subprocess with shell=True is a security risk - use shell=False",
1270
+ node,
1271
+ 'SEC003',
1272
+ )
1273
+ elif tainted_cmd:
1274
+ self.add_issue(
1275
+ 'warning',
1276
+ 'security',
1277
+ "subprocess command built from untrusted input",
1278
+ node,
1279
+ 'SEC004',
1280
+ )
1281
+
1282
+ if resolved in ALWAYS_SHELL_NAMES:
1283
+ cmd = self._first_arg(node, 'cmd')
1284
+ if cmd is not None and self._is_tainted_expr(cmd):
1285
+ self.add_issue(
1286
+ 'error',
1287
+ 'security',
1288
+ f"{resolved}() with untrusted input is command injection",
1289
+ node,
1290
+ 'SEC003',
1291
+ )
1292
+ else:
1293
+ self.add_issue(
1294
+ 'warning',
1295
+ 'security',
1296
+ f"{resolved}() runs its argument through a shell - "
1297
+ "use subprocess with shell=False and an argument list",
1298
+ node,
1299
+ 'SEC003',
1300
+ )
1301
+
1302
+ if resolved in HANDLE_OWNER_NAMES or (
1303
+ isinstance(node.func, ast.Attribute) and node.func.attr in HANDLE_OWNER_METHODS
1304
+ ):
1305
+ for arg in node.args:
1306
+ self._with_expr_ids.add(id(arg))
1307
+
1308
+ if resolved in OPEN_NAMES and id(node) not in self._with_expr_ids:
1309
+ self.add_issue(
1310
+ 'warning',
1311
+ 'bugs',
1312
+ "open() should be used as a context manager (with open(...) as ...)",
1313
+ node,
1314
+ 'RES001',
1315
+ )
1316
+
1317
+ self.generic_visit(node)
1318
+
1319
+ def _enter_with(self, node: ast.AST) -> None:
1320
+ for item in node.items: # type: ignore[attr-defined]
1321
+ # Any open() anywhere in the context expression (including inside
1322
+ # closing(...) or a helper) is owned by the with statement.
1323
+ for sub in ast.walk(item.context_expr):
1324
+ if isinstance(sub, ast.Call):
1325
+ self._with_expr_ids.add(id(sub))
1326
+ if item.optional_vars is not None:
1327
+ self._mark_store_kind(item.optional_vars, 'unpack')
1328
+ self.generic_visit(node)
1329
+
1330
+ def visit_With(self, node: ast.With):
1331
+ self._enter_with(node)
1332
+
1333
+ def visit_AsyncWith(self, node: ast.AsyncWith):
1334
+ self._enter_with(node)
1335
+
1336
+ _MISSING = object()
1337
+
1338
+ @classmethod
1339
+ def _literal_key(cls, node: Optional[ast.AST]) -> Any:
1340
+ """The hashable value of a literal key, or ``_MISSING`` if it is not one.
1341
+
1342
+ NaN is treated as non-literal: it never equals itself, so two NaN keys
1343
+ are not a duplicate.
1344
+ """
1345
+ if isinstance(node, ast.Constant):
1346
+ value = node.value
1347
+ if isinstance(value, float) and value != value:
1348
+ return cls._MISSING
1349
+ if isinstance(value, (str, int, float, bool, bytes, type(None))):
1350
+ return value
1351
+ return cls._MISSING
1352
+ if isinstance(node, ast.Tuple):
1353
+ items = []
1354
+ for elt in node.elts:
1355
+ item = cls._literal_key(elt)
1356
+ if item is cls._MISSING:
1357
+ return cls._MISSING
1358
+ items.append(item)
1359
+ return tuple(items)
1360
+ return cls._MISSING
1361
+
1362
+ def visit_Dict(self, node: ast.Dict):
1363
+ seen: Dict[Any, ast.AST] = {}
1364
+ for key in node.keys:
1365
+ if key is None: # ``**spread``
1366
+ continue
1367
+ value = self._literal_key(key)
1368
+ if value is self._MISSING:
1369
+ continue
1370
+ if value in seen:
1371
+ self.add_issue(
1372
+ 'warning',
1373
+ 'bugs',
1374
+ f"Duplicate dictionary key {value!r}",
1375
+ key,
1376
+ 'BUG002',
1377
+ )
1378
+ else:
1379
+ seen[value] = key
1380
+ self.generic_visit(node)
1381
+
1382
+ def visit_Assert(self, node: ast.Assert):
1383
+ """Check assert statements (skipped in test files, where assert is the API)."""
1384
+ if self._is_test_file():
1385
+ self.generic_visit(node)
1386
+ return
1387
+ self.add_issue(
1388
+ 'info',
1389
+ 'reliability',
1390
+ "Assert statements are removed when optimization is enabled (-O flag)",
1391
+ node,
1392
+ 'REL002',
1393
+ )
1394
+ self.generic_visit(node)
1395
+
1396
+ # Function / lambda analysis (shared)
1397
+
1398
+ def visit_FunctionDef(self, node: ast.FunctionDef):
1399
+ self._bind(node.name, node, self._function_kind(node))
1400
+ self._check_function(node)
1401
+
1402
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
1403
+ self._bind(node.name, node, self._function_kind(node))
1404
+ if not self._is_abstract(node) and not self._is_stub_body(node):
1405
+ if not self._async_body_has_await(node):
1406
+ self.add_issue(
1407
+ 'warning',
1408
+ 'reliability',
1409
+ f"Async function '{node.name}' never awaits - it will not yield to the event loop",
1410
+ node,
1411
+ 'ASY001',
1412
+ )
1413
+ self._check_function(node)
1414
+
1415
+ @staticmethod
1416
+ def _function_kind(node: ast.AST) -> str:
1417
+ # A decorator (route registration, signal hook, ...) is a use.
1418
+ return 'decorated_function' if getattr(node, 'decorator_list', None) else 'function'
1419
+
1420
+ def visit_Lambda(self, node: ast.Lambda):
1421
+ self._check_function(node, is_lambda=True)
1422
+
1423
+ def _count_params(self, args: ast.arguments) -> int:
1424
+ return (
1425
+ len(args.posonlyargs)
1426
+ + len(args.args)
1427
+ + len(args.kwonlyargs)
1428
+ + (1 if args.vararg else 0)
1429
+ + (1 if args.kwarg else 0)
1430
+ )
1431
+
1432
+ def _is_mutable_default(self, node: ast.AST) -> bool:
1433
+ if isinstance(node, (ast.List, ast.Dict, ast.Set)):
1434
+ return True
1435
+ if isinstance(node, (ast.ListComp, ast.DictComp, ast.SetComp)):
1436
+ return True
1437
+ if isinstance(node, ast.Call):
1438
+ resolved = self._resolve_name(node.func)
1439
+ return resolved in MUTABLE_CTOR_NAMES
1440
+ return False
1441
+
1442
+ def _check_mutable_defaults(self, args: ast.arguments) -> None:
1443
+ for default in args.defaults:
1444
+ if default is not None and self._is_mutable_default(default):
1445
+ self.add_issue(
1446
+ 'error',
1447
+ 'bugs',
1448
+ "Mutable default argument - use None and initialize in function body",
1449
+ default,
1450
+ 'BUG001',
1451
+ )
1452
+ for default in args.kw_defaults:
1453
+ if default is not None and self._is_mutable_default(default):
1454
+ self.add_issue(
1455
+ 'error',
1456
+ 'bugs',
1457
+ "Mutable default argument - use None and initialize in function body",
1458
+ default,
1459
+ 'BUG001',
1460
+ )
1461
+
1462
+ def _check_function(self, node: ast.AST, is_lambda: bool = False) -> None:
1463
+ name = getattr(node, 'name', '<lambda>')
1464
+ args = node.args # type: ignore[attr-defined]
1465
+ total_args = self._count_params(args)
1466
+ if total_args > self.config.max_function_params:
1467
+ self.add_issue(
1468
+ 'warning',
1469
+ 'complexity',
1470
+ f"Function has {total_args} parameters "
1471
+ f"(max recommended: {self.config.max_function_params})",
1472
+ node,
1473
+ 'COMP001',
1474
+ )
1475
+
1476
+ self._check_mutable_defaults(args)
1477
+
1478
+ if not is_lambda:
1479
+ complexity = self._calculate_complexity(node)
1480
+ self.function_complexity[name] = complexity
1481
+ if complexity > self.config.max_complexity:
1482
+ self.add_issue(
1483
+ 'warning',
1484
+ 'complexity',
1485
+ f"Function has cyclomatic complexity of {complexity} "
1486
+ f"(max recommended: {self.config.max_complexity})",
1487
+ node,
1488
+ 'COMP002',
1489
+ )
1490
+
1491
+ end_lineno = getattr(node, 'end_lineno', None)
1492
+ if end_lineno is not None:
1493
+ func_length = end_lineno - node.lineno + 1
1494
+ if func_length > self.config.max_function_length:
1495
+ self.add_issue(
1496
+ 'warning',
1497
+ 'complexity',
1498
+ f"Function is {func_length} lines long "
1499
+ f"(max recommended: {self.config.max_function_length})",
1500
+ node,
1501
+ 'COMP003',
1502
+ )
1503
+
1504
+ # Decorators, defaults, annotations, and type parameters are evaluated in
1505
+ # the enclosing scope when the def statement runs, so visit them now.
1506
+ for field_name in ('decorator_list', 'type_params'):
1507
+ for child in getattr(node, field_name, None) or []:
1508
+ self.visit(child)
1509
+ returns = getattr(node, 'returns', None)
1510
+ if returns is not None:
1511
+ self.visit(returns)
1512
+ self.visit(args)
1513
+
1514
+ # The body runs later; defer it so names bound after this def are visible.
1515
+ self._deferred.append((node, list(self.scopes), self._in_type_checking))
1516
+
1517
+ def _visit_function_body(self, node: ast.AST) -> None:
1518
+ name = getattr(node, 'name', '<lambda>')
1519
+ args = node.args # type: ignore[attr-defined]
1520
+
1521
+ scope = self._push_scope('function', name)
1522
+ for arg in list(args.posonlyargs) + list(args.args) + list(args.kwonlyargs):
1523
+ self._bind(arg.arg, arg, 'arg')
1524
+ # *args / **kwargs usually exist for signature compatibility.
1525
+ if args.vararg is not None:
1526
+ self._bind(args.vararg.arg, args.vararg, 'vararg')
1527
+ if args.kwarg is not None:
1528
+ self._bind(args.kwarg.arg, args.kwarg, 'vararg')
1529
+
1530
+ is_lambda = isinstance(node, ast.Lambda)
1531
+ if (
1532
+ is_lambda # arity is dictated by the caller
1533
+ or (name.startswith('__') and name.endswith('__')) # protocol methods
1534
+ or self._is_abstract(node)
1535
+ or (not is_lambda and self._is_stub_body(node))
1536
+ ):
1537
+ scope.used.update(scope.bindings)
1538
+
1539
+ body = node.body # type: ignore[attr-defined]
1540
+ queued = len(self._deferred)
1541
+ if isinstance(body, list):
1542
+ for stmt in body:
1543
+ self.visit(stmt)
1544
+ else:
1545
+ self.visit(body)
1546
+ # Nested functions queued by this body must run before this scope is
1547
+ # popped, so their uses of our locals count and we report accurately.
1548
+ self._run_deferred(queued)
1549
+ self._pop_scope()
1550
+
1551
+ def _run_deferred(self, start: int) -> None:
1552
+ """Analyze function bodies deferred since ``start`` (they may defer more)."""
1553
+ saved_scopes = self.scopes
1554
+ saved_flag = self._in_type_checking
1555
+ while len(self._deferred) > start:
1556
+ node, scopes, in_type_checking = self._deferred.pop()
1557
+ self.scopes = scopes
1558
+ self._in_type_checking = in_type_checking
1559
+ self._visit_function_body(node)
1560
+ self.scopes = saved_scopes
1561
+ self._in_type_checking = saved_flag
1562
+
1563
+ def visit_ClassDef(self, node: ast.ClassDef):
1564
+ """Check class definitions."""
1565
+ self._bind(node.name, node, 'class')
1566
+ self._push_scope('class', node.name)
1567
+
1568
+ methods = [
1569
+ n for n in node.body
1570
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
1571
+ ]
1572
+ if len(methods) > self.config.max_class_methods:
1573
+ self.add_issue(
1574
+ 'warning',
1575
+ 'complexity',
1576
+ f"Class has {len(methods)} methods "
1577
+ f"(max recommended: {self.config.max_class_methods})",
1578
+ node,
1579
+ 'COMP004',
1580
+ )
1581
+
1582
+ init_method = next((m for m in methods if m.name == '__init__'), None)
1583
+ if init_method:
1584
+ instance_vars = set()
1585
+ for stmt in ast.walk(init_method):
1586
+ if isinstance(stmt, ast.Attribute) and isinstance(stmt.ctx, ast.Store):
1587
+ if isinstance(stmt.value, ast.Name) and stmt.value.id == 'self':
1588
+ instance_vars.add(stmt.attr)
1589
+
1590
+ if len(instance_vars) > self.config.max_instance_vars:
1591
+ self.add_issue(
1592
+ 'warning',
1593
+ 'complexity',
1594
+ f"Class has {len(instance_vars)} instance variables "
1595
+ f"(max recommended: {self.config.max_instance_vars})",
1596
+ node,
1597
+ 'COMP005',
1598
+ )
1599
+
1600
+ self.generic_visit(node)
1601
+ self._pop_scope()
1602
+
1603
+ # Exception Handling Checks
1604
+
1605
+ def visit_Try(self, node: ast.Try):
1606
+ """Check exception handling."""
1607
+ for handler in node.handlers:
1608
+ if handler.type is None:
1609
+ self.add_issue(
1610
+ 'warning',
1611
+ 'exceptions',
1612
+ "Bare 'except:' catches all exceptions including SystemExit and KeyboardInterrupt",
1613
+ handler,
1614
+ 'EXC001',
1615
+ )
1616
+ elif self._catches_bare_exception(handler.type):
1617
+ self.add_issue(
1618
+ 'info',
1619
+ 'exceptions',
1620
+ "Catching 'Exception' is very broad - consider catching specific exceptions",
1621
+ handler,
1622
+ 'EXC002',
1623
+ )
1624
+
1625
+ if len(handler.body) == 1 and isinstance(handler.body[0], ast.Pass):
1626
+ self.add_issue(
1627
+ 'warning',
1628
+ 'exceptions',
1629
+ "Empty except block silently ignores errors",
1630
+ handler,
1631
+ 'EXC003',
1632
+ )
1633
+
1634
+ self._check_lossy_raises(handler)
1635
+
1636
+ self.generic_visit(node)
1637
+
1638
+ visit_TryStar = visit_Try
1639
+
1640
+ def _catches_bare_exception(self, node: Optional[ast.AST]) -> bool:
1641
+ """True for ``Exception``, ``builtins.Exception``, or a tuple holding one."""
1642
+ if node is None:
1643
+ return False
1644
+ if isinstance(node, ast.Tuple):
1645
+ return any(self._catches_bare_exception(elt) for elt in node.elts)
1646
+ return self._resolve_name(node) in _BROAD_EXCEPTION_NAMES
1647
+
1648
+ def visit_Raise(self, node: ast.Raise):
1649
+ """Check raise statements."""
1650
+ if node.exc and isinstance(node.exc, ast.Call):
1651
+ if isinstance(node.exc.func, ast.Name) and node.exc.func.id == 'Exception':
1652
+ if not node.exc.args:
1653
+ self.add_issue(
1654
+ 'warning',
1655
+ 'exceptions',
1656
+ "Raising Exception without a message - provide descriptive error message",
1657
+ node,
1658
+ 'EXC004',
1659
+ )
1660
+
1661
+ self.generic_visit(node)
1662
+
1663
+ # Performance Checks
1664
+
1665
+ def visit_AsyncFor(self, node: ast.AsyncFor):
1666
+ self._mark_store_kind(node.target, 'loop')
1667
+ self.generic_visit(node)
1668
+
1669
+ def visit_For(self, node: ast.For):
1670
+ """Check for loops for performance issues."""
1671
+ self._mark_store_kind(node.target, 'loop')
1672
+ if isinstance(node.iter, ast.Call):
1673
+ if isinstance(node.iter.func, ast.Name) and node.iter.func.id == 'range':
1674
+ if node.iter.args and isinstance(node.iter.args[0], ast.Call):
1675
+ if isinstance(node.iter.args[0].func, ast.Name):
1676
+ if node.iter.args[0].func.id == 'len':
1677
+ self.add_issue(
1678
+ 'info',
1679
+ 'performance',
1680
+ "Use 'enumerate()' instead of 'range(len())' for better readability",
1681
+ node,
1682
+ 'PERF001',
1683
+ )
1684
+
1685
+ self.generic_visit(node)
1686
+
1687
+ def visit_Compare(self, node: ast.Compare):
1688
+ """Check comparison operations."""
1689
+ if isinstance(node.ops[0], (ast.Is, ast.IsNot)):
1690
+ if node.comparators and isinstance(node.comparators[0], ast.Constant):
1691
+ if isinstance(node.comparators[0].value, bool):
1692
+ self.add_issue(
1693
+ 'info',
1694
+ 'style',
1695
+ "Don't use 'is True/False' - use the boolean value directly",
1696
+ node,
1697
+ 'STYLE001',
1698
+ )
1699
+
1700
+ self.generic_visit(node)
1701
+
1702
+ # Import Checks
1703
+
1704
+ def visit_Import(self, node: ast.Import):
1705
+ """Check import statements."""
1706
+ for alias in node.names:
1707
+ bound = alias.asname if alias.asname else alias.name.split('.')[0]
1708
+ self._bind(bound, node, 'import')
1709
+ if self._in_type_checking:
1710
+ scope = self._current_scope()
1711
+ if scope is not None:
1712
+ scope.used.add(bound)
1713
+ self.generic_visit(node)
1714
+
1715
+ def visit_ImportFrom(self, node: ast.ImportFrom):
1716
+ """Check from...import statements."""
1717
+ if node.module == '__future__':
1718
+ self.generic_visit(node)
1719
+ return
1720
+ for alias in node.names:
1721
+ if alias.name == '*':
1722
+ module = node.module if node.module is not None else '.'
1723
+ self.add_issue(
1724
+ 'warning',
1725
+ 'imports',
1726
+ f"Wildcard import from '{module}' pollutes namespace",
1727
+ node,
1728
+ 'IMP001',
1729
+ )
1730
+ else:
1731
+ bound = alias.asname if alias.asname else alias.name
1732
+ self._bind(bound, node, 'import')
1733
+ if self._in_type_checking:
1734
+ scope = self._current_scope()
1735
+ if scope is not None:
1736
+ scope.used.add(bound)
1737
+
1738
+ if node.level and node.level >= 1 and self.known_exports:
1739
+ self._check_relative_import(node)
1740
+
1741
+ self.generic_visit(node)
1742
+
1743
+ def _check_relative_import(self, node: ast.ImportFrom) -> None:
1744
+ target = resolve_relative_module(self.filename, node.level, node.module)
1745
+ if target is None:
1746
+ return
1747
+ defined = self.known_exports.get(str(target))
1748
+ if defined is None:
1749
+ return
1750
+ module_label = node.module or '.'
1751
+ for alias in node.names:
1752
+ if alias.name == '*':
1753
+ continue
1754
+ if alias.name not in defined:
1755
+ self.add_issue(
1756
+ 'error',
1757
+ 'imports',
1758
+ f"Imported name '{alias.name}' is not defined in relative module '{module_label}'",
1759
+ node,
1760
+ 'IMP003',
1761
+ )
1762
+
1763
+ def visit_Name(self, node: ast.Name):
1764
+ """Track variable usage."""
1765
+ if isinstance(node.ctx, ast.Load):
1766
+ self._mark_used(node.id)
1767
+ elif isinstance(node.ctx, ast.Store):
1768
+ scope = self._current_scope()
1769
+ if scope is not None:
1770
+ # Any rebinding invalidates a previously recorded constant.
1771
+ scope.constants.pop(node.id, None)
1772
+ self._bind(node.id, node, self._store_kinds.pop(id(node), 'assign'))
1773
+ elif isinstance(node.ctx, ast.Del):
1774
+ self._mark_used(node.id)
1775
+
1776
+ self.generic_visit(node)
1777
+
1778
+ def visit_Assign(self, node: ast.Assign):
1779
+ self._record_dunder_all(node)
1780
+ for target in node.targets:
1781
+ if isinstance(target, (ast.Tuple, ast.List)):
1782
+ self._mark_store_kind(target, 'unpack')
1783
+ self.generic_visit(node)
1784
+ # Record after visiting targets, which clears any stale constant.
1785
+ self._record_constant_assign(node.targets, node.value)
1786
+ if self._is_tainted_expr(node.value):
1787
+ for target in node.targets:
1788
+ self._mark_tainted_target(target)
1789
+
1790
+ def visit_AnnAssign(self, node: ast.AnnAssign):
1791
+ if node.value is None:
1792
+ self._mark_store_kind(node.target, 'annotation')
1793
+ self.generic_visit(node)
1794
+ if node.value is not None:
1795
+ self._record_constant_assign([node.target], node.value)
1796
+ if self._is_tainted_expr(node.value):
1797
+ self._mark_tainted_target(node.target)
1798
+
1799
+ def visit_AugAssign(self, node: ast.AugAssign):
1800
+ self._record_dunder_all_augmented(node)
1801
+ self.generic_visit(node)
1802
+ if isinstance(node.target, ast.Name):
1803
+ self._mark_used(node.target.id) # ``x += 1`` reads x
1804
+ if self._is_tainted_expr(node.value):
1805
+ self._mark_tainted_target(node.target)
1806
+
1807
+ def visit_If(self, node: ast.If):
1808
+ type_checking = self._is_type_checking_test(node.test)
1809
+ self.visit(node.test)
1810
+ previous = self._in_type_checking
1811
+ if type_checking:
1812
+ self._in_type_checking = True
1813
+ for stmt in node.body:
1814
+ self.visit(stmt)
1815
+ self._in_type_checking = previous
1816
+ for stmt in node.orelse:
1817
+ self.visit(stmt)
1818
+
1819
+ def visit_Global(self, node: ast.Global):
1820
+ scope = self._current_scope()
1821
+ if scope is not None:
1822
+ for name in node.names:
1823
+ scope.global_names.add(name)
1824
+ self.generic_visit(node)
1825
+
1826
+ def visit_Nonlocal(self, node: ast.Nonlocal):
1827
+ scope = self._current_scope()
1828
+ if scope is not None:
1829
+ for name in node.names:
1830
+ scope.nonlocal_names.add(name)
1831
+ self._mark_used(name)
1832
+ self.generic_visit(node)
1833
+
1834
+ def visit_ExceptHandler(self, node: ast.ExceptHandler):
1835
+ if node.name:
1836
+ scope = self._current_scope()
1837
+ if scope is not None:
1838
+ scope.used.add(node.name)
1839
+ self.generic_visit(node)
1840
+
1841
+ def _visit_comprehension(self, node: ast.AST) -> None:
1842
+ self._push_scope('comprehension')
1843
+ self.generic_visit(node)
1844
+ self._pop_scope()
1845
+
1846
+ def visit_ListComp(self, node: ast.ListComp):
1847
+ self._visit_comprehension(node)
1848
+
1849
+ def visit_SetComp(self, node: ast.SetComp):
1850
+ self._visit_comprehension(node)
1851
+
1852
+ def visit_DictComp(self, node: ast.DictComp):
1853
+ self._visit_comprehension(node)
1854
+
1855
+ def visit_GeneratorExp(self, node: ast.GeneratorExp):
1856
+ self._visit_comprehension(node)
1857
+
1858
+ def _complexity_contrib(self, node: ast.AST) -> int:
1859
+ """Contribution of this node and its descendants, skipping nested scopes."""
1860
+ if isinstance(node, NESTED_SCOPE_NODES):
1861
+ return 0
1862
+
1863
+ contrib = 0
1864
+ if isinstance(node, (ast.If, ast.While, ast.For, ast.AsyncFor, ast.ExceptHandler)):
1865
+ contrib = 1
1866
+ elif isinstance(node, ast.IfExp):
1867
+ contrib = 1
1868
+ elif isinstance(node, ast.BoolOp):
1869
+ contrib = max(len(node.values) - 1, 0)
1870
+ elif hasattr(ast, 'Match') and isinstance(node, ast.Match):
1871
+ contrib = len(node.cases)
1872
+
1873
+ for child in ast.iter_child_nodes(node):
1874
+ contrib += self._complexity_contrib(child)
1875
+ return contrib
1876
+
1877
+ def _calculate_complexity(self, node: ast.AST) -> int:
1878
+ """Cyclomatic complexity of a function, excluding nested functions/classes."""
1879
+ complexity = 1
1880
+ for child in ast.iter_child_nodes(node):
1881
+ complexity += self._complexity_contrib(child)
1882
+ return complexity
1883
+
1884
+ def analyze(self) -> List[Issue]:
1885
+ """Perform the complete analysis."""
1886
+ self.issues = []
1887
+ self.aliases = {}
1888
+ self.function_complexity = {}
1889
+ self._deferred = []
1890
+ try:
1891
+ tree = ast.parse(self.source_code, filename=self.filename)
1892
+ self._collect_imports(tree)
1893
+ self.scopes = []
1894
+ self._in_type_checking = False
1895
+ self._with_expr_ids = set()
1896
+ self._store_kinds = {}
1897
+ self._push_scope('module')
1898
+ self.visit(tree)
1899
+ self._run_deferred(0)
1900
+ self._pop_scope()
1901
+ self.issues = [issue for issue in self.issues if not self._is_ignored(issue)]
1902
+ return sorted(self.issues, key=lambda x: (x.line, x.col, x.code))
1903
+ except SyntaxError as exc:
1904
+ return [Issue(
1905
+ severity='error',
1906
+ category='syntax',
1907
+ message=f"Syntax error: {exc.msg}",
1908
+ line=exc.lineno or 0,
1909
+ col=exc.offset or 0,
1910
+ code='SYN001',
1911
+ filename=self.filename,
1912
+ )]
1913
+
1914
+
1915
+ class EnhancedSemanticChecker(SemanticChecker):
1916
+ """Backward-compatible alias; configuration lives on SemanticChecker."""
1917
+
1918
+
1919
+ def _io_issue(filepath: str, message: str) -> Issue:
1920
+ return Issue(
1921
+ severity='error',
1922
+ category='io',
1923
+ message=message,
1924
+ line=0,
1925
+ col=0,
1926
+ code='IO001',
1927
+ filename=filepath,
1928
+ )
1929
+
1930
+
1931
+ def collect_module_exports(source: str) -> Set[str]:
1932
+ """Module-level names defined or re-exported in a file."""
1933
+ try:
1934
+ tree = ast.parse(source)
1935
+ except (SyntaxError, RecursionError):
1936
+ return set()
1937
+ names: Set[str] = set()
1938
+ for node in tree.body:
1939
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
1940
+ names.add(node.name)
1941
+ elif isinstance(node, ast.Assign):
1942
+ for target in node.targets:
1943
+ if isinstance(target, ast.Name):
1944
+ names.add(target.id)
1945
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
1946
+ names.add(node.target.id)
1947
+ elif isinstance(node, ast.ImportFrom):
1948
+ for alias in node.names:
1949
+ if alias.name != '*':
1950
+ names.add(alias.asname or alias.name)
1951
+ elif isinstance(node, ast.Import):
1952
+ for alias in node.names:
1953
+ names.add(alias.asname or alias.name.split('.')[0])
1954
+ return names
1955
+
1956
+
1957
+ def resolve_relative_module(
1958
+ importer: str,
1959
+ level: int,
1960
+ module: Optional[str],
1961
+ ) -> Optional[Path]:
1962
+ """Resolve a relative import to a .py file or package __init__.py."""
1963
+ if level < 1 or not importer or importer == '<string>':
1964
+ return None
1965
+ try:
1966
+ base = Path(importer).resolve().parent
1967
+ except OSError:
1968
+ return None
1969
+ for _ in range(level - 1):
1970
+ parent = base.parent
1971
+ if parent == base:
1972
+ return None
1973
+ base = parent
1974
+ candidate = base.joinpath(*module.split('.')) if module else base
1975
+ pyfile = Path(str(candidate) + '.py')
1976
+ init = candidate / '__init__.py'
1977
+ if pyfile.is_file():
1978
+ return pyfile.resolve()
1979
+ if init.is_file():
1980
+ return init.resolve()
1981
+ return None
1982
+
1983
+
1984
+ def _normalize_issue_path(filename: str) -> str:
1985
+ if not filename:
1986
+ return ''
1987
+ path = Path(filename)
1988
+ try:
1989
+ return path.resolve().relative_to(Path.cwd().resolve()).as_posix()
1990
+ except ValueError:
1991
+ return path.as_posix()
1992
+
1993
+
1994
+ _DIGITS_RE = re.compile(r'\d+')
1995
+ BASELINE_VERSION = 2
1996
+
1997
+
1998
+ def normalize_issue_message(message: str) -> str:
1999
+ """Replace numbers so 'Function is 52 lines long' and '... 53 lines long' match."""
2000
+ return _DIGITS_RE.sub('#', message)
2001
+
2002
+
2003
+ def _fingerprint_key(filename: str, code: str, message: str) -> str:
2004
+ return f"{_normalize_issue_path(filename)}|{code}|{normalize_issue_message(message)}"
2005
+
2006
+
2007
+ def issue_fingerprint(issue: Issue, occurrence: int = 0) -> str:
2008
+ """``path|code|normalized message|occurrence``.
2009
+
2010
+ ``occurrence`` numbers repeated identical findings within one file, so a
2011
+ baseline holding one ``eval(input())`` does not also hide a second one.
2012
+ """
2013
+ return f"{_fingerprint_key(issue.filename, issue.code, issue.message)}|{occurrence}"
2014
+
2015
+
2016
+ def issue_fingerprints(issues: Sequence[Issue]) -> List[str]:
2017
+ """Fingerprints for a batch, numbering repeats in (file, line, col) order."""
2018
+ order = sorted(
2019
+ range(len(issues)),
2020
+ key=lambda i: (_normalize_issue_path(issues[i].filename), issues[i].line, issues[i].col),
2021
+ )
2022
+ counts: Dict[str, int] = {}
2023
+ result = [''] * len(issues)
2024
+ for index in order:
2025
+ issue = issues[index]
2026
+ key = _fingerprint_key(issue.filename, issue.code, issue.message)
2027
+ occurrence = counts.get(key, 0)
2028
+ counts[key] = occurrence + 1
2029
+ result[index] = f"{key}|{occurrence}"
2030
+ return result
2031
+
2032
+
2033
+ def load_baseline(path: str) -> Set[str]:
2034
+ baseline_path = Path(path)
2035
+ try:
2036
+ raw = baseline_path.read_text(encoding='utf-8')
2037
+ data = json.loads(raw)
2038
+ except FileNotFoundError:
2039
+ raise ConfigError(f"Baseline file '{path}' not found") from None
2040
+ except (OSError, json.JSONDecodeError) as exc:
2041
+ raise ConfigError(f"Could not read baseline '{path}': {exc}") from exc
2042
+ if not isinstance(data, dict):
2043
+ raise ConfigError(f"Baseline '{path}' must contain a JSON object")
2044
+ items = [item for item in data.get('issues') or [] if isinstance(item, dict)]
2045
+ version = data.get('version') or 1
2046
+
2047
+ fingerprints: Set[str] = set()
2048
+ if isinstance(version, int) and version >= 2:
2049
+ for item in items:
2050
+ stored = item.get('fingerprint')
2051
+ if stored:
2052
+ fingerprints.add(str(stored))
2053
+ return fingerprints
2054
+
2055
+ # Version 1 stored ``path|code|raw message`` with no occurrence index.
2056
+ # Rebuild version-2 fingerprints; entries were written in (file, line) order.
2057
+ counts: Dict[str, int] = {}
2058
+ for item in items:
2059
+ code = item.get('code')
2060
+ message = item.get('message')
2061
+ if code is not None and message is not None:
2062
+ key = _fingerprint_key(str(item.get('filename') or ''), str(code), str(message))
2063
+ else:
2064
+ stored = str(item.get('fingerprint') or '')
2065
+ if stored.count('|') < 2:
2066
+ continue
2067
+ stored_path, stored_code, stored_message = stored.split('|', 2)
2068
+ key = f"{stored_path}|{stored_code}|{normalize_issue_message(stored_message)}"
2069
+ occurrence = counts.get(key, 0)
2070
+ counts[key] = occurrence + 1
2071
+ fingerprints.add(f"{key}|{occurrence}")
2072
+ return fingerprints
2073
+
2074
+
2075
+ def write_baseline(issues: Sequence[Issue], path: str) -> None:
2076
+ payload = {
2077
+ 'version': BASELINE_VERSION,
2078
+ 'issues': [
2079
+ {
2080
+ 'filename': _normalize_issue_path(issue.filename),
2081
+ 'code': issue.code,
2082
+ 'message': issue.message,
2083
+ 'line': issue.line,
2084
+ 'fingerprint': fingerprint,
2085
+ }
2086
+ for issue, fingerprint in zip(issues, issue_fingerprints(issues))
2087
+ ],
2088
+ }
2089
+ Path(path).write_text(json.dumps(payload, indent=2) + '\n', encoding='utf-8')
2090
+
2091
+
2092
+ def _display_path(path: Path) -> str:
2093
+ """Path relative to cwd when it is underneath it, else absolute."""
2094
+ try:
2095
+ rel = os.path.relpath(path, Path.cwd())
2096
+ except ValueError:
2097
+ return str(path)
2098
+ if rel.startswith('..'):
2099
+ return str(path)
2100
+ return rel
2101
+
2102
+
2103
+ def git_staged_python_files() -> Tuple[List[str], Optional[str]]:
2104
+ """Return staged *.py paths (relative to cwd), or an error message.
2105
+
2106
+ ``git diff --name-only`` prints paths relative to the repository root, not
2107
+ the current directory, so the root is resolved first.
2108
+ """
2109
+ try:
2110
+ toplevel = _subprocess.run(
2111
+ ['git', 'rev-parse', '--show-toplevel'],
2112
+ capture_output=True,
2113
+ text=True,
2114
+ timeout=30,
2115
+ )
2116
+ if toplevel.returncode != 0:
2117
+ detail = (toplevel.stderr or toplevel.stdout or 'not a git repository').strip()
2118
+ return [], detail
2119
+ completed = _subprocess.run(
2120
+ ['git', 'diff', '--cached', '--name-only', '--diff-filter=ACMR'],
2121
+ capture_output=True,
2122
+ text=True,
2123
+ timeout=30,
2124
+ )
2125
+ except (OSError, _subprocess.TimeoutExpired) as exc:
2126
+ return [], f"Could not run git: {exc}"
2127
+ if completed.returncode != 0:
2128
+ detail = (completed.stderr or completed.stdout or 'git diff failed').strip()
2129
+ return [], detail
2130
+ root = Path(toplevel.stdout.strip() or '.')
2131
+ files: List[str] = []
2132
+ for line in completed.stdout.splitlines():
2133
+ name = line.strip()
2134
+ if not name.endswith('.py'):
2135
+ continue
2136
+ full = root / name
2137
+ if full.is_file():
2138
+ files.append(_display_path(full))
2139
+ return files, None
2140
+
2141
+
2142
+ def expand_python_targets(paths: Sequence[str]) -> Tuple[List[str], List[Issue]]:
2143
+ """Expand directories to .py files. Explicit files are kept as given."""
2144
+ targets: List[str] = []
2145
+ extras: List[Issue] = []
2146
+ seen: Set[str] = set()
2147
+
2148
+ def _add(item: str) -> None:
2149
+ # Explicit files keep the spelling that was passed, while directory
2150
+ # expansion yields resolved paths, so dedupe on identity rather than
2151
+ # on the string: 'pkg/a.py pkg/' is one file, not two.
2152
+ try:
2153
+ key = str(Path(item).resolve())
2154
+ except OSError:
2155
+ key = item
2156
+ if key not in seen:
2157
+ seen.add(key)
2158
+ targets.append(item)
2159
+
2160
+ for raw in paths:
2161
+ path = Path(raw)
2162
+ if path.is_dir():
2163
+ found = [str(candidate) for candidate in iter_python_files(path)]
2164
+ if not found:
2165
+ extras.append(_io_issue(raw, f"No Python files found in '{raw}'"))
2166
+ continue
2167
+ for filepath in found:
2168
+ _add(filepath)
2169
+ else:
2170
+ _add(raw)
2171
+ return targets, extras
2172
+
2173
+
2174
+ def check_file(
2175
+ filepath: str,
2176
+ config: Optional[CheckerConfig] = None,
2177
+ known_exports: Optional[Dict[str, Set[str]]] = None,
2178
+ source: Optional[str] = None,
2179
+ ) -> List[Issue]:
2180
+ """Check a Python file for semantic issues. I/O failures become IO001 errors.
2181
+
2182
+ Pass ``source`` to analyze already-read text without touching the disk again.
2183
+ """
2184
+ path = Path(filepath)
2185
+ if not path.exists():
2186
+ return [_io_issue(filepath, f"File '{filepath}' not found")]
2187
+ if not path.is_file():
2188
+ return [_io_issue(filepath, f"'{filepath}' is not a file")]
2189
+
2190
+ try:
2191
+ source_code = source if source is not None else read_python_source(path)
2192
+ except LookupError as exc:
2193
+ return [_io_issue(filepath, f"Unknown encoding in '{filepath}': {exc}")]
2194
+ except UnicodeDecodeError as exc:
2195
+ return [_io_issue(filepath, f"Could not decode '{filepath}': {exc}")]
2196
+ except OSError as exc:
2197
+ return [_io_issue(filepath, f"Could not read '{filepath}': {exc}")]
2198
+
2199
+ checker = SemanticChecker(
2200
+ source_code,
2201
+ str(path),
2202
+ config=config,
2203
+ known_exports=known_exports,
2204
+ )
2205
+ try:
2206
+ return checker.analyze()
2207
+ except RecursionError:
2208
+ # Analysis recurses per AST node, so source CPython parses happily can
2209
+ # still exhaust the stack (long chained expressions, generated tables).
2210
+ # Contain it here: one unanalyzable file must not sink the whole run.
2211
+ return [_io_issue(
2212
+ filepath,
2213
+ f"'{filepath}' is nested too deeply to analyze - skipped",
2214
+ )]
2215
+
2216
+
2217
+ # Per-worker state for the process pool (set once by the initializer so the
2218
+ # config and export map are not re-pickled for every file).
2219
+ _WORKER_CONFIG: Optional[CheckerConfig] = None
2220
+ _WORKER_EXPORTS: Dict[str, Set[str]] = {}
2221
+
2222
+
2223
+ def _init_worker(config: CheckerConfig, known_exports: Dict[str, Set[str]]) -> None:
2224
+ global _WORKER_CONFIG, _WORKER_EXPORTS
2225
+ _WORKER_CONFIG = config
2226
+ _WORKER_EXPORTS = known_exports
2227
+
2228
+
2229
+ def _check_in_worker(item: Tuple[str, Optional[str]]) -> List[Issue]:
2230
+ filepath, source = item
2231
+ return check_file(filepath, config=_WORKER_CONFIG, known_exports=_WORKER_EXPORTS, source=source)
2232
+
2233
+
2234
+ def resolve_jobs(jobs: Optional[int], file_count: int) -> int:
2235
+ """Worker count. ``None``/``0`` = auto: one per CPU once there are enough files."""
2236
+ if file_count <= 1:
2237
+ return 1
2238
+ if jobs is None or jobs <= 0:
2239
+ if file_count < PARALLEL_MIN_FILES:
2240
+ return 1
2241
+ return max(1, min(os.cpu_count() or 1, file_count))
2242
+ return min(jobs, file_count)
2243
+
2244
+
2245
+ def analyze_files(
2246
+ targets: Sequence[str],
2247
+ config: CheckerConfig,
2248
+ known_exports: Dict[str, Set[str]],
2249
+ sources: Dict[str, str],
2250
+ jobs: Optional[int] = None,
2251
+ ) -> List[List[Issue]]:
2252
+ """``check_file`` for every target, in order, using a process pool when worthwhile."""
2253
+ items = [(filepath, sources.get(filepath)) for filepath in targets]
2254
+ workers = resolve_jobs(jobs, len(items))
2255
+ if workers > 1:
2256
+ try:
2257
+ with concurrent.futures.ProcessPoolExecutor(
2258
+ max_workers=workers,
2259
+ initializer=_init_worker,
2260
+ initargs=(config, known_exports),
2261
+ ) as pool:
2262
+ chunksize = max(1, len(items) // (workers * 4))
2263
+ return list(pool.map(_check_in_worker, items, chunksize=chunksize))
2264
+ except Exception: # noqa: EXC002 - any pool failure: redo sequentially, which surfaces real bugs
2265
+ pass
2266
+ return [
2267
+ check_file(filepath, config=config, known_exports=known_exports, source=source)
2268
+ for filepath, source in items
2269
+ ]
2270
+
2271
+
2272
+ _BANDIT_SEVERITY = {
2273
+ 'HIGH': 'error',
2274
+ 'MEDIUM': 'warning',
2275
+ 'LOW': 'info',
2276
+ }
2277
+
2278
+
2279
+ def collect_bandit_issues(filepaths: Sequence[str]) -> List[Issue]:
2280
+ """Run bandit if installed and convert findings to Issue objects."""
2281
+ bandit_bin = shutil.which('bandit')
2282
+ if not bandit_bin or not filepaths:
2283
+ return []
2284
+ try:
2285
+ completed = _subprocess.run(
2286
+ [bandit_bin, '-f', 'json', *filepaths],
2287
+ capture_output=True,
2288
+ text=True,
2289
+ timeout=120,
2290
+ )
2291
+ except (OSError, _subprocess.TimeoutExpired):
2292
+ return []
2293
+ payload = completed.stdout.strip()
2294
+ if not payload:
2295
+ return []
2296
+ try:
2297
+ data = json.loads(payload)
2298
+ except json.JSONDecodeError:
2299
+ return []
2300
+ issues: List[Issue] = []
2301
+ for result in data.get('results') or []:
2302
+ severity = _BANDIT_SEVERITY.get(
2303
+ str(result.get('issue_severity', '')).upper(),
2304
+ 'warning',
2305
+ )
2306
+ test_id = str(result.get('test_id') or 'BANDIT')
2307
+ issues.append(Issue(
2308
+ severity=severity,
2309
+ category='security',
2310
+ message=str(result.get('issue_text') or test_id),
2311
+ line=int(result.get('line_number') or 0),
2312
+ col=int(result.get('col_offset') or 0),
2313
+ code=test_id,
2314
+ filename=str(result.get('filename') or ''),
2315
+ source='bandit',
2316
+ ))
2317
+ return issues
2318
+
2319
+
2320
+ def _read_source_line(source: Optional[str], line: int) -> str:
2321
+ if not source or line <= 0:
2322
+ return ''
2323
+ return _source_line_from(source.splitlines(), line)
2324
+
2325
+
2326
+ def _source_line_from(lines: Sequence[str], line: int) -> str:
2327
+ """``line`` (1-based) out of an already-split source, or '' when out of range."""
2328
+ if line <= 0 or line > len(lines):
2329
+ return ''
2330
+ return lines[line - 1]
2331
+
2332
+
2333
+ def format_issue(issue: Issue, source_line: str = '', use_color: bool = True) -> str:
2334
+ """Format an issue for display."""
2335
+ severity_colors = {
2336
+ 'error': '\033[91m',
2337
+ 'warning': '\033[93m',
2338
+ 'info': '\033[94m',
2339
+ }
2340
+ reset = '\033[0m'
2341
+ color = severity_colors.get(issue.severity, '') if use_color else ''
2342
+ color_reset = reset if use_color else ''
2343
+ severity_str = issue.severity.upper()
2344
+
2345
+ origin = f" ({issue.source})" if issue.source and issue.source != 'codesnake' else ''
2346
+ output = (
2347
+ f"{color}{severity_str}{color_reset} [{issue.code}] "
2348
+ f"{issue.category}{origin}: {issue.message}\n"
2349
+ )
2350
+ # Issue.col is a 0-based character offset; humans expect 1-based columns.
2351
+ location = f"Line {issue.line}, Column {issue.col + 1}" if issue.line > 0 else ''
2352
+ if issue.filename and location:
2353
+ output += f" {issue.filename}: {location}\n"
2354
+ elif issue.filename:
2355
+ output += f" {issue.filename}\n"
2356
+ elif location:
2357
+ output += f" {location}\n"
2358
+
2359
+ if source_line:
2360
+ display = source_line.rstrip('\n')
2361
+ output += f" {display}\n"
2362
+ if issue.col >= 0:
2363
+ output += f" {' ' * issue.col}^\n"
2364
+ if issue.suggestion:
2365
+ output += f" Suggestion: {issue.suggestion}\n"
2366
+
2367
+ return output
2368
+
2369
+
2370
+ def filter_issues(
2371
+ issues: Sequence[Issue],
2372
+ config: CheckerConfig,
2373
+ min_severity: Optional[str] = None,
2374
+ ) -> List[Issue]:
2375
+ """Apply config report_* flags and optional --severity minimum."""
2376
+ min_rank = SEVERITY_RANK.get(min_severity or 'info', 1)
2377
+ filtered: List[Issue] = []
2378
+ for issue in issues:
2379
+ if issue.code in ALWAYS_SHOW_CODES:
2380
+ filtered.append(issue)
2381
+ continue
2382
+ if SEVERITY_RANK.get(issue.severity, 0) < min_rank:
2383
+ continue
2384
+ if not config.allows_severity(issue.severity):
2385
+ continue
2386
+ filtered.append(issue)
2387
+ return filtered
2388
+
2389
+
2390
+ def _count_by_severity(issues: Iterable[Issue]) -> Tuple[int, int, int]:
2391
+ errors = warnings = infos = 0
2392
+ for issue in issues:
2393
+ if issue.severity == 'error':
2394
+ errors += 1
2395
+ elif issue.severity == 'warning':
2396
+ warnings += 1
2397
+ elif issue.severity == 'info':
2398
+ infos += 1
2399
+ return errors, warnings, infos
2400
+
2401
+
2402
+ def _issue_to_dict(issue: Issue) -> dict:
2403
+ return {
2404
+ 'severity': issue.severity,
2405
+ 'category': issue.category,
2406
+ 'code': issue.code,
2407
+ 'message': issue.message,
2408
+ 'line': issue.line,
2409
+ 'col': issue.col,
2410
+ 'filename': issue.filename,
2411
+ 'source': issue.source,
2412
+ 'end_line': issue.end_line,
2413
+ 'end_col': issue.end_col,
2414
+ 'suggestion': issue.suggestion,
2415
+ }
2416
+
2417
+
2418
+ def format_json_report(
2419
+ file_issues: Sequence[Tuple[str, List[Issue]]],
2420
+ ) -> str:
2421
+ files_out = []
2422
+ all_issues: List[Issue] = []
2423
+ for path, issues in file_issues:
2424
+ all_issues.extend(issues)
2425
+ files_out.append({
2426
+ 'path': path,
2427
+ 'issues': [_issue_to_dict(issue) for issue in issues],
2428
+ })
2429
+ errors, warnings, infos = _count_by_severity(all_issues)
2430
+ payload = {
2431
+ 'files': files_out,
2432
+ 'summary': {
2433
+ 'files': len(file_issues),
2434
+ 'errors': errors,
2435
+ 'warnings': warnings,
2436
+ 'info': infos,
2437
+ },
2438
+ }
2439
+ return json.dumps(payload, indent=2) + '\n'
2440
+
2441
+
2442
+ def _gh_escape_data(value: str) -> str:
2443
+ """Escape a workflow-command message (GitHub's documented rules)."""
2444
+ return value.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A')
2445
+
2446
+
2447
+ def _gh_escape_property(value: str) -> str:
2448
+ """Escape a workflow-command property value (``file=``, ``title=`` ...)."""
2449
+ return _gh_escape_data(value).replace(':', '%3A').replace(',', '%2C')
2450
+
2451
+
2452
+ def format_github_report(issues: Sequence[Issue]) -> str:
2453
+ lines = []
2454
+ level_map = {'error': 'error', 'warning': 'warning', 'info': 'notice'}
2455
+ for issue in issues:
2456
+ level = level_map.get(issue.severity, 'warning')
2457
+ props = [f"file={_gh_escape_property(_gh_path(issue.filename))}", f"line={issue.line}"]
2458
+ col = issue.col + 1 if issue.col >= 0 else 1
2459
+ props.append(f"col={col}")
2460
+ if issue.end_line and issue.end_line != issue.line:
2461
+ props.append(f"endLine={issue.end_line}")
2462
+ elif issue.end_col > issue.col:
2463
+ props.append(f"endColumn={issue.end_col + 1}")
2464
+ props.append(f"title={_gh_escape_property(f'{issue.code} {issue.category}')}")
2465
+ message = f"[{issue.code}] {issue.message}"
2466
+ if issue.suggestion:
2467
+ message += f" {issue.suggestion}"
2468
+ lines.append(f"::{level} {','.join(props)}::{_gh_escape_data(message)}")
2469
+ return '\n'.join(lines) + ('\n' if lines else '')
2470
+
2471
+
2472
+ def _relative_to_cwd(filename: str) -> Optional[str]:
2473
+ """POSIX path relative to the current directory, or ``None`` if outside it.
2474
+
2475
+ Directory arguments are walked resolved (see :func:`iter_python_files`), so
2476
+ issue filenames are absolute even when the user passed a relative path.
2477
+ Report formats that are consumed relative to a workspace root have to undo
2478
+ that before emitting a location.
2479
+ """
2480
+ if not filename:
2481
+ return None
2482
+ try:
2483
+ resolved = Path(filename).resolve()
2484
+ except OSError:
2485
+ return None
2486
+ try:
2487
+ return resolved.relative_to(Path.cwd().resolve()).as_posix()
2488
+ except ValueError:
2489
+ return None
2490
+
2491
+
2492
+ def _gh_path(filename: str) -> str:
2493
+ """Workspace-relative POSIX path for a ``file=`` annotation property.
2494
+
2495
+ GitHub resolves ``file=`` against the workspace root and silently drops
2496
+ annotations whose path does not match a file there, so an absolute path
2497
+ would never attach to the diff.
2498
+ """
2499
+ if not filename:
2500
+ return ''
2501
+ relative = _relative_to_cwd(filename)
2502
+ if relative is not None:
2503
+ return relative
2504
+ return Path(filename).as_posix()
2505
+
2506
+
2507
+ def _sarif_uri(filename: str) -> str:
2508
+ """Relative POSIX path under cwd, otherwise an absolute ``file://`` URI."""
2509
+ if not filename:
2510
+ return ''
2511
+ path = Path(filename)
2512
+ relative = _relative_to_cwd(filename)
2513
+ if relative is not None:
2514
+ return relative
2515
+ try:
2516
+ return path.resolve().as_uri()
2517
+ except (OSError, ValueError):
2518
+ return path.as_posix()
2519
+
2520
+
2521
+ def format_sarif_report(issues: Sequence[Issue], tool_version: str) -> str:
2522
+ level_map = {'error': 'error', 'warning': 'warning', 'info': 'note'}
2523
+ rules = []
2524
+ seen_codes = set()
2525
+ for issue in issues:
2526
+ if issue.code in seen_codes:
2527
+ continue
2528
+ seen_codes.add(issue.code)
2529
+ rule: Dict[str, Any] = {
2530
+ 'id': issue.code,
2531
+ 'name': issue.code,
2532
+ 'shortDescription': {'text': f"{issue.category}: {issue.code}"},
2533
+ 'helpUri': RULES_URL,
2534
+ 'defaultConfiguration': {'level': level_map.get(issue.severity, 'warning')},
2535
+ 'properties': {'category': issue.category},
2536
+ }
2537
+ suggestion = issue.suggestion or ISSUE_SUGGESTIONS.get(issue.code)
2538
+ if suggestion:
2539
+ rule['fullDescription'] = {'text': suggestion}
2540
+ rules.append(rule)
2541
+
2542
+ results = []
2543
+ for issue in issues:
2544
+ # SARIF columns are 1-based; Issue.col/end_col are 0-based offsets.
2545
+ start_line = issue.line if issue.line > 0 else 1
2546
+ start_col = issue.col + 1 if issue.col >= 0 else 1
2547
+ end_line = issue.end_line if issue.end_line > 0 else start_line
2548
+ end_col = issue.end_col + 1 if issue.end_col > 0 else start_col
2549
+ message_text = issue.message
2550
+ if issue.suggestion:
2551
+ message_text = f"{issue.message} Suggestion: {issue.suggestion}"
2552
+ results.append({
2553
+ 'ruleId': issue.code,
2554
+ 'level': level_map.get(issue.severity, 'warning'),
2555
+ 'message': {'text': message_text},
2556
+ 'locations': [{
2557
+ 'physicalLocation': {
2558
+ 'artifactLocation': {'uri': _sarif_uri(issue.filename)},
2559
+ 'region': {
2560
+ 'startLine': start_line,
2561
+ 'startColumn': start_col,
2562
+ 'endLine': end_line,
2563
+ 'endColumn': max(end_col, 1),
2564
+ },
2565
+ },
2566
+ }],
2567
+ })
2568
+
2569
+ payload = {
2570
+ '$schema': 'https://json.schemastore.org/sarif-2.1.0.json',
2571
+ 'version': '2.1.0',
2572
+ 'runs': [{
2573
+ 'tool': {
2574
+ 'driver': {
2575
+ 'name': 'CodeSnake',
2576
+ 'version': tool_version,
2577
+ 'informationUri': PROJECT_URL,
2578
+ 'rules': rules,
2579
+ },
2580
+ },
2581
+ 'results': results,
2582
+ }],
2583
+ }
2584
+ return json.dumps(payload, indent=2) + '\n'
2585
+
2586
+
2587
+ def _should_use_color(color: Optional[bool], stream: TextIO) -> bool:
2588
+ if color is False: # noqa: STYLE001 - tri-state Optional[bool]
2589
+ return False
2590
+ if color is True: # noqa: STYLE001
2591
+ return True
2592
+ if os.environ.get('NO_COLOR'):
2593
+ return False
2594
+ return hasattr(stream, 'isatty') and stream.isatty()
2595
+
2596
+
2597
+ def run_check(
2598
+ files: Sequence[str],
2599
+ *,
2600
+ config: Optional[CheckerConfig] = None,
2601
+ config_path: Optional[str] = None,
2602
+ output_format: str = 'text',
2603
+ min_severity: Optional[str] = None,
2604
+ show_banner: bool = False,
2605
+ color: Optional[bool] = None,
2606
+ stream: Optional[TextIO] = None,
2607
+ use_bandit: Optional[bool] = None,
2608
+ staged: bool = False,
2609
+ baseline_path: Optional[str] = None,
2610
+ update_baseline: Optional[str] = None,
2611
+ jobs: Optional[int] = None,
2612
+ ) -> int:
2613
+ """Analyze one or more files. Returns 1 if any error-severity issue exists.
2614
+
2615
+ ``jobs``: worker processes (``None``/``0`` = auto, ``1`` = sequential).
2616
+ """
2617
+ out = stream if stream is not None else sys.stdout
2618
+
2619
+ try:
2620
+ if config is None:
2621
+ config = load_config(config_path)
2622
+ except ConfigError as exc:
2623
+ print(f"Error: {exc}", file=sys.stderr)
2624
+ return 1
2625
+
2626
+ file_list = list(files)
2627
+ if staged:
2628
+ staged_files, git_error = git_staged_python_files()
2629
+ if git_error:
2630
+ print(f"Error: {git_error}", file=sys.stderr)
2631
+ return 1
2632
+ if not staged_files:
2633
+ print("No staged Python files.", file=sys.stderr)
2634
+ return 0
2635
+ file_list = staged_files
2636
+
2637
+ if not file_list:
2638
+ print("Error: no files to check", file=sys.stderr)
2639
+ return 1
2640
+
2641
+ use_color = _should_use_color(color, out) and output_format == 'text'
2642
+
2643
+ if show_banner and output_format == 'text':
2644
+ print_snake_banner(use_color=use_color)
2645
+
2646
+ targets, extra_issues = expand_python_targets(file_list)
2647
+
2648
+ file_reports: List[Tuple[str, List[Issue], Optional[str]]] = []
2649
+ for extra in extra_issues:
2650
+ filtered = filter_issues([extra], config, min_severity=min_severity)
2651
+ file_reports.append((extra.filename or '', filtered, None))
2652
+
2653
+ sources_by_file: Dict[str, str] = {}
2654
+ known_exports: Dict[str, Set[str]] = {}
2655
+ for filepath in targets:
2656
+ path = Path(filepath)
2657
+ if not path.is_file():
2658
+ continue
2659
+ try:
2660
+ source = read_python_source(path)
2661
+ except (OSError, UnicodeDecodeError, LookupError):
2662
+ continue
2663
+ sources_by_file[filepath] = source
2664
+ try:
2665
+ known_exports[str(path.resolve())] = collect_module_exports(source)
2666
+ except OSError:
2667
+ known_exports[filepath] = collect_module_exports(source)
2668
+
2669
+ results = analyze_files(targets, config, known_exports, sources_by_file, jobs=jobs)
2670
+ for filepath, issues in zip(targets, results):
2671
+ issues = filter_issues(issues, config, min_severity=min_severity)
2672
+ file_reports.append((filepath, issues, sources_by_file.get(filepath)))
2673
+
2674
+ run_bandit = config.use_bandit if use_bandit is None else use_bandit
2675
+ if run_bandit and targets:
2676
+ if shutil.which('bandit') is None:
2677
+ print(
2678
+ "Warning: --bandit requested but the 'bandit' executable was not found",
2679
+ file=sys.stderr,
2680
+ )
2681
+ else:
2682
+ bandit_issues = collect_bandit_issues(targets)
2683
+ by_file: Dict[str, List[Issue]] = {}
2684
+ for issue in bandit_issues:
2685
+ by_file.setdefault(os.path.abspath(issue.filename), []).append(issue)
2686
+ for index, (filepath, issues, source) in enumerate(file_reports):
2687
+ extra = by_file.get(os.path.abspath(filepath), [])
2688
+ if source:
2689
+ lines = source.split('\n')
2690
+ extra = [
2691
+ issue for issue in extra
2692
+ if not issue_ignored_by_pragma(issue.code, issue.line, lines)
2693
+ ]
2694
+ extra = filter_issues(extra, config, min_severity=min_severity)
2695
+ if extra:
2696
+ file_reports[index] = (filepath, issues + extra, source)
2697
+
2698
+ if update_baseline:
2699
+ snapshot = [issue for _, issues, _ in file_reports for issue in issues]
2700
+ try:
2701
+ write_baseline(snapshot, update_baseline)
2702
+ except OSError as exc:
2703
+ print(f"Error: could not write baseline '{update_baseline}': {exc}", file=sys.stderr)
2704
+ return 1
2705
+
2706
+ if baseline_path:
2707
+ try:
2708
+ fingerprints = load_baseline(baseline_path)
2709
+ except ConfigError as exc:
2710
+ print(f"Error: {exc}", file=sys.stderr)
2711
+ return 1
2712
+ filtered_reports: List[Tuple[str, List[Issue], Optional[str]]] = []
2713
+ for filepath, issues, source in file_reports:
2714
+ keep = [
2715
+ issue for issue, fingerprint in zip(issues, issue_fingerprints(issues))
2716
+ if fingerprint not in fingerprints
2717
+ ]
2718
+ filtered_reports.append((filepath, keep, source))
2719
+ file_reports = filtered_reports
2720
+
2721
+ displayed: List[Tuple[str, List[Issue]]] = [
2722
+ (path, issues) for path, issues, _ in file_reports
2723
+ ]
2724
+ all_displayed = [issue for _, issues in displayed for issue in issues]
2725
+ errors, warnings, infos = _count_by_severity(all_displayed)
2726
+ if output_format == 'json':
2727
+ out.write(format_json_report(displayed))
2728
+ elif output_format == 'github':
2729
+ out.write(format_github_report(all_displayed))
2730
+ elif output_format == 'sarif':
2731
+ out.write(format_sarif_report(all_displayed, __version__))
2732
+ else:
2733
+ any_issues = False
2734
+ for filepath, issues, source in file_reports:
2735
+ if not issues:
2736
+ out.write(f"✓ No issues found in {filepath}\n")
2737
+ continue
2738
+ any_issues = True
2739
+ out.write(f"\nAnalysis of {filepath}:\n")
2740
+ out.write(f"{'=' * 60}\n\n")
2741
+ # Split once per file, not once per issue.
2742
+ source_lines = source.splitlines() if source else []
2743
+ for issue in issues:
2744
+ source_line = _source_line_from(source_lines, issue.line)
2745
+ rendered = format_issue(issue, source_line, use_color=use_color)
2746
+ out.write(rendered)
2747
+ if not rendered.endswith('\n'):
2748
+ out.write('\n')
2749
+ out.write('\n')
2750
+ if any_issues or len(file_reports) > 1:
2751
+ out.write(
2752
+ f"\nSummary: {errors} errors, {warnings} warnings, {infos} info\n"
2753
+ )
2754
+
2755
+ return 1 if errors else 0