spaghetti-detector 0.1.0__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.
- spaghetti/__init__.py +12 -0
- spaghetti/ast_helpers.py +120 -0
- spaghetti/checks/__init__.py +38 -0
- spaghetti/checks/ast_per_file.py +1166 -0
- spaghetti/checks/package_level.py +331 -0
- spaghetti/checks/text_per_file.py +48 -0
- spaghetti/cli.py +395 -0
- spaghetti/config.py +87 -0
- spaghetti/detector.py +347 -0
- spaghetti/models.py +67 -0
- spaghetti/py.typed +0 -0
- spaghetti/scanner.py +13 -0
- spaghetti/scoring.py +205 -0
- spaghetti/suppression.py +25 -0
- spaghetti_detector-0.1.0.dist-info/METADATA +165 -0
- spaghetti_detector-0.1.0.dist-info/RECORD +19 -0
- spaghetti_detector-0.1.0.dist-info/WHEEL +5 -0
- spaghetti_detector-0.1.0.dist-info/entry_points.txt +2 -0
- spaghetti_detector-0.1.0.dist-info/top_level.txt +1 -0
spaghetti/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Spaghetti-code and architectural-smell detector for the Boti workspace.
|
|
2
|
+
|
|
3
|
+
See :mod:`spaghetti.detector` for the scanning implementation; individual
|
|
4
|
+
check functions (``check_long_functions``, ``check_circular_imports``, etc.)
|
|
5
|
+
are accessed from there directly, e.g. ``from spaghetti import detector``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from spaghetti.detector import main
|
|
11
|
+
|
|
12
|
+
__all__ = ["main"]
|
spaghetti/ast_helpers.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""AST utility functions shared across check modules."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _nesting_depth(node: ast.AST, current: int = 0) -> int:
|
|
9
|
+
"""Compute maximum nesting depth from a node."""
|
|
10
|
+
max_depth = current
|
|
11
|
+
for child in ast.iter_child_nodes(node):
|
|
12
|
+
if isinstance(child, (ast.If, ast.For, ast.While, ast.With, ast.Try, ast.ExceptHandler)):
|
|
13
|
+
max_depth = max(max_depth, _nesting_depth(child, current + 1))
|
|
14
|
+
else:
|
|
15
|
+
max_depth = max(max_depth, _nesting_depth(child, current))
|
|
16
|
+
return max_depth
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cyclomatic_complexity(node: ast.AST) -> int:
|
|
20
|
+
"""Approximate McCabe cyclomatic complexity."""
|
|
21
|
+
complexity = 1
|
|
22
|
+
for child in ast.walk(node):
|
|
23
|
+
if isinstance(child, (ast.If, ast.While, ast.For, ast.AsyncFor)):
|
|
24
|
+
complexity += 1
|
|
25
|
+
elif isinstance(child, ast.BoolOp):
|
|
26
|
+
complexity += len(child.values) - 1
|
|
27
|
+
elif isinstance(child, ast.ExceptHandler):
|
|
28
|
+
complexity += 1
|
|
29
|
+
elif isinstance(child, ast.Assert):
|
|
30
|
+
complexity += 1
|
|
31
|
+
elif isinstance(child, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
|
|
32
|
+
complexity += 1
|
|
33
|
+
return complexity
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _line_count(node: ast.AST) -> int:
|
|
37
|
+
"""End line - start line + 1."""
|
|
38
|
+
if hasattr(node, "end_lineno") and node.end_lineno is not None:
|
|
39
|
+
return node.end_lineno - node.lineno + 1
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _has_return_type_hint(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
44
|
+
return node.returns is not None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _param_has_type_hint(arg: ast.arg) -> bool:
|
|
48
|
+
return arg.annotation is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_private(name: str) -> bool:
|
|
52
|
+
return name.startswith("_")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _file_line_count(source: str) -> int:
|
|
56
|
+
return len(source.splitlines())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _count_own_returns(node: ast.AST) -> int:
|
|
60
|
+
"""Count Return statements belonging to ``node``, not to nested defs."""
|
|
61
|
+
count = 0
|
|
62
|
+
|
|
63
|
+
def visit(n: ast.AST) -> None:
|
|
64
|
+
nonlocal count
|
|
65
|
+
for child in ast.iter_child_nodes(n):
|
|
66
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
67
|
+
continue
|
|
68
|
+
if isinstance(child, ast.Return):
|
|
69
|
+
count += 1
|
|
70
|
+
visit(child)
|
|
71
|
+
|
|
72
|
+
visit(node)
|
|
73
|
+
return count
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _dump_stmts(stmts: list[ast.stmt]) -> str:
|
|
77
|
+
return "\n".join(ast.dump(s, annotate_fields=False) for s in stmts)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_trivial_body(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
81
|
+
"""True for stub-like bodies (pass / docstring-only / ellipsis) that shouldn't
|
|
82
|
+
count as meaningful duplication if repeated."""
|
|
83
|
+
body = node.body
|
|
84
|
+
meaningful = [
|
|
85
|
+
s
|
|
86
|
+
for s in body
|
|
87
|
+
if not (
|
|
88
|
+
isinstance(s, ast.Expr)
|
|
89
|
+
and isinstance(s.value, ast.Constant)
|
|
90
|
+
and isinstance(s.value.value, str)
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
if not meaningful:
|
|
94
|
+
return True
|
|
95
|
+
if len(meaningful) == 1 and isinstance(meaningful[0], ast.Pass):
|
|
96
|
+
return True
|
|
97
|
+
if (
|
|
98
|
+
len(meaningful) == 1
|
|
99
|
+
and isinstance(meaningful[0], ast.Expr)
|
|
100
|
+
and isinstance(meaningful[0].value, ast.Constant)
|
|
101
|
+
and meaningful[0].value.value is Ellipsis
|
|
102
|
+
):
|
|
103
|
+
return True
|
|
104
|
+
if len(meaningful) == 1 and isinstance(meaningful[0], ast.Raise):
|
|
105
|
+
return True
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _walk_with_class_context(tree: ast.AST) -> Iterator[tuple[ast.AST, str | None]]:
|
|
110
|
+
"""Yield (node, enclosing_class_name_or_None) for every node in the tree."""
|
|
111
|
+
|
|
112
|
+
def visit(node: ast.AST, class_name: str | None) -> Iterator[tuple[ast.AST, str | None]]:
|
|
113
|
+
for child in ast.iter_child_nodes(node):
|
|
114
|
+
new_class_name = class_name
|
|
115
|
+
if isinstance(child, ast.ClassDef):
|
|
116
|
+
new_class_name = child.name
|
|
117
|
+
yield child, class_name
|
|
118
|
+
yield from visit(child, new_class_name)
|
|
119
|
+
|
|
120
|
+
yield from visit(tree, None)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Check registries: ALL_CHECKS, SOURCE_CHECKS, PACKAGE_CHECKS."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from spaghetti.checks.ast_per_file import ALL_CHECKS
|
|
9
|
+
from spaghetti.checks.package_level import (
|
|
10
|
+
check_duplicate_functions_pkg,
|
|
11
|
+
check_import_cycles_pkg,
|
|
12
|
+
check_orphan_interfaces_pkg,
|
|
13
|
+
check_sync_async_twins_pkg,
|
|
14
|
+
)
|
|
15
|
+
from spaghetti.checks.text_per_file import check_long_file, check_todo_markers
|
|
16
|
+
from spaghetti.models import Issue
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ALL_CHECKS",
|
|
20
|
+
"SOURCE_CHECKS",
|
|
21
|
+
"PACKAGE_CHECKS",
|
|
22
|
+
"check_duplicate_functions_pkg",
|
|
23
|
+
"check_orphan_interfaces_pkg",
|
|
24
|
+
"check_sync_async_twins_pkg",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
SourceCheck = Callable[[str, Path, str], list[Issue]]
|
|
28
|
+
PackageCheck = Callable[[str, list[tuple[Path, ast.Module]]], list[Issue]]
|
|
29
|
+
|
|
30
|
+
SOURCE_CHECKS: list[SourceCheck] = [
|
|
31
|
+
check_long_file,
|
|
32
|
+
check_todo_markers,
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
PACKAGE_CHECKS: list[PackageCheck] = [
|
|
36
|
+
check_import_cycles_pkg,
|
|
37
|
+
check_orphan_interfaces_pkg,
|
|
38
|
+
]
|