spaghetti-detector 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. spaghetti_detector-0.1.0/PKG-INFO +165 -0
  2. spaghetti_detector-0.1.0/README.md +144 -0
  3. spaghetti_detector-0.1.0/pyproject.toml +67 -0
  4. spaghetti_detector-0.1.0/setup.cfg +4 -0
  5. spaghetti_detector-0.1.0/src/spaghetti/__init__.py +12 -0
  6. spaghetti_detector-0.1.0/src/spaghetti/ast_helpers.py +120 -0
  7. spaghetti_detector-0.1.0/src/spaghetti/checks/__init__.py +38 -0
  8. spaghetti_detector-0.1.0/src/spaghetti/checks/ast_per_file.py +1166 -0
  9. spaghetti_detector-0.1.0/src/spaghetti/checks/package_level.py +331 -0
  10. spaghetti_detector-0.1.0/src/spaghetti/checks/text_per_file.py +48 -0
  11. spaghetti_detector-0.1.0/src/spaghetti/cli.py +395 -0
  12. spaghetti_detector-0.1.0/src/spaghetti/config.py +87 -0
  13. spaghetti_detector-0.1.0/src/spaghetti/detector.py +347 -0
  14. spaghetti_detector-0.1.0/src/spaghetti/models.py +67 -0
  15. spaghetti_detector-0.1.0/src/spaghetti/py.typed +0 -0
  16. spaghetti_detector-0.1.0/src/spaghetti/scanner.py +13 -0
  17. spaghetti_detector-0.1.0/src/spaghetti/scoring.py +205 -0
  18. spaghetti_detector-0.1.0/src/spaghetti/suppression.py +25 -0
  19. spaghetti_detector-0.1.0/src/spaghetti_detector.egg-info/PKG-INFO +165 -0
  20. spaghetti_detector-0.1.0/src/spaghetti_detector.egg-info/SOURCES.txt +23 -0
  21. spaghetti_detector-0.1.0/src/spaghetti_detector.egg-info/dependency_links.txt +1 -0
  22. spaghetti_detector-0.1.0/src/spaghetti_detector.egg-info/entry_points.txt +2 -0
  23. spaghetti_detector-0.1.0/src/spaghetti_detector.egg-info/requires.txt +2 -0
  24. spaghetti_detector-0.1.0/src/spaghetti_detector.egg-info/top_level.txt +1 -0
  25. spaghetti_detector-0.1.0/tests/test_detector.py +1879 -0
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: spaghetti-detector
3
+ Version: 0.1.0
4
+ Summary: Spaghetti-code and architectural-smell detector for Python projects
5
+ Author-email: Luis Valverde <lvalverdeb@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/lvalverdeb/repo-split
8
+ Project-URL: Repository, https://github.com/lvalverdeb/repo-split
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.13
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: boti>=1.0.0
20
+ Requires-Dist: pyyaml>=6.0
21
+
22
+ # spaghetti
23
+
24
+ Spaghetti-code and architectural-smell detector for the Boti workspace.
25
+
26
+ Scans workspace packages for anti-patterns, architectural violations, and structural code smells — from single-function issues (long functions, deep nesting, high cyclomatic complexity) up to whole-package issues that only show up once you can see across files: real circular imports (not just a parent/child heuristic), copy-pasted function bodies, and the sync/async "twin" duplication pattern (`load`/`aload`, `foo`/`foo_async`) where a fix applied to one twin silently never reaches the other.
27
+
28
+ Every requested package is reviewed concurrently — one `boti.core.Agent` (`SpaghettiReviewAgent`) per package running its scan via `asyncio.to_thread` — then folded into a single consolidated report.
29
+
30
+ `spaghetti` is not tied to the Boti workspace: the package registry it scans is generic and configurable via a YAML file, ad-hoc CLI flags, or both — see [Configuring packages](#configuring-packages).
31
+
32
+ ## Usage
33
+
34
+ ```bash
35
+ uv run spaghetti
36
+ uv run spaghetti --packages boti-data boti-dask
37
+ uv run spaghetti --severity error
38
+ uv run spaghetti --top 10 --exclude tests/ examples/
39
+ uv run spaghetti --json > report.json
40
+ uv run spaghetti --plan --top 10
41
+ uv run spaghetti --config spaghetti.yaml
42
+ uv run spaghetti --package my-lib=my-lib/src/my_lib
43
+ ```
44
+
45
+ Exit codes: `0` (clean), `1` (warnings present), `2` (errors present) — safe to wire into CI as a gate.
46
+
47
+ ### Options
48
+
49
+ | Flag | Default | Description |
50
+ | --- | --- | --- |
51
+ | `--config` | none | YAML file with a `packages: {name: path}` mapping (see below); replaces the built-in defaults |
52
+ | `--package` | none | Add or override one package as `NAME=PATH` (repeatable); applied on top of `--config` or the defaults |
53
+ | `--packages` | all resolved packages | Names to scan from the resolved registry |
54
+ | `--severity` | `info` | Minimum severity to display (`info` / `warning` / `error`) |
55
+ | `--json` | off | Output as JSON instead of the console report |
56
+ | `--top` | `5` | Number of worst files to list per package |
57
+ | `--exclude` | none | Path substrings to exclude from scanning |
58
+ | `--min-duplicate-lines` | `5` | Minimum function length to consider for duplicate-body detection |
59
+ | `--twin-similarity` | `0.6` | Minimum text-similarity ratio (0–1) to flag a sync/async twin pair |
60
+ | `--plan` | off | Output a prioritized remediation plan instead of the standard report |
61
+
62
+ Run `uv run spaghetti --help` for the full list.
63
+
64
+ ## Inline Suppression
65
+
66
+ Suppress specific findings on a line with `# spaghetti-ignore[rule]`:
67
+
68
+ ```python
69
+ # Suppress a specific rule
70
+ def f(): # spaghetti-ignore[long-function]: intentionally large
71
+ ...
72
+
73
+ # Suppress all rules on a line
74
+ x: dict = {} # spaghetti-ignore: reviewed, no issue
75
+ ```
76
+
77
+ The marker applies to the line it appears on and the line directly above it (so a marker can sit above a `def` line too long for a trailing comment). Suppressed findings are counted in the report (`suppressed: N` in the header) rather than silently dropped — they remain visible.
78
+
79
+ ## JSON Output
80
+
81
+ With `--json`, the report is a single JSON object to stdout:
82
+
83
+ ```json
84
+ {
85
+ "issues": [
86
+ {
87
+ "file": "src/my_module.py",
88
+ "line": 42,
89
+ "severity": "warning",
90
+ "rule": "long-function",
91
+ "message": "my_func() is 65 lines (max 50)",
92
+ "package": "my-lib"
93
+ }
94
+ ],
95
+ "suppressed": 3
96
+ }
97
+ ```
98
+
99
+ ## Remediation Plan
100
+
101
+ With `--plan`, the detector outputs a prioritized fix order instead of the standard report. Each rule is scored by `severity_weight × fix_effort` and grouped into priority levels (P0–P3):
102
+
103
+ ```bash
104
+ uv run spaghetti --plan --top 10
105
+ ```
106
+
107
+ **Priority levels:**
108
+ - **P0** (score ≥ 12): CRITICAL — fix immediately (e.g., circular imports, god-classes)
109
+ - **P1** (score ≥ 7): HIGH — fix this sprint
110
+ - **P2** (score ≥ 3): MEDIUM — plan for next cycle
111
+ - **P3** (score < 3): LOW — track in backlog
112
+
113
+ The plan groups issues by rule, counts affected files, and lists a recommended fix order. This makes it easy to start a code-quality improvement cycle with the highest-impact fixes first.
114
+
115
+ ## Rules
116
+
117
+ The detector checks **36 rules** across four tiers:
118
+
119
+ **Per-file AST checks (30 rules):** `long-function`, `high-complexity`, `missing-return-type`, `missing-param-type`, `too-many-params`, `excessive-returns`, `boolean-flag-params`, `deep-nesting`, `untyped-dict`, `unused-import`, `swallowed-exception`, `duplicate-branch`, `encapsulation-violation`, `god-class`, `layer-violation`, `transport-in-library`, `potential-circular-import`, `god-module`, `mutable-default`, `bare-except`, `star-import`, `global-mutable`, `scope-mutation`, `dead-code`, `message-chain`, `excessive-decorators`, `magic-number`, `missing-else`, `lazy-class`, `deep-inheritance`.
120
+
121
+ **Per-file source-text checks (2 rules):** `long-file`, `todo-marker`.
122
+
123
+ **Infrastructure checks (1 rule):** `syntax-error` (files that fail `ast.parse()`).
124
+
125
+ **Per-package cross-file checks (3 rules):** `import-cycle`, `duplicate-function-body`, `sync-async-duplication`.
126
+
127
+ See [SDD.md](SDD.md) for the full rule catalog, thresholds, and scoring formula.
128
+
129
+ ## Configuring packages
130
+
131
+ With no flags, `spaghetti` scans this workspace's own `DEFAULT_PACKAGES` (`boti`, `boti-data`, `boti-dask`; see `src/spaghetti/detector.py`). To point it at other packages — in this workspace, another workspace, or any directory on disk — use `--config` and/or `--package`.
132
+
133
+ **Precedence:**
134
+ 1. Neither flag given → the built-in defaults are used as-is.
135
+ 2. `--config` given → its `packages:` mapping **replaces** the defaults entirely, so a config file states the full set explicitly rather than silently inheriting unrelated hardcoded packages.
136
+ 3. `--package NAME=PATH` entries are then overlaid on top of whichever set (1) or (2) produced — adding new names or overriding ones already defined, so a config file plus a quick ad-hoc addition both work together.
137
+
138
+ ### `--config`: YAML file
139
+
140
+ ```yaml
141
+ # spaghetti.yaml
142
+ packages:
143
+ my-lib: my-lib/src/my_lib
144
+ my-service: services/my-service/src/my_service
145
+ ```
146
+
147
+ Paths resolve **relative to the config file's own directory**, not the caller's working directory, so the same config works no matter where you invoke `spaghetti` from.
148
+
149
+ ```bash
150
+ uv run spaghetti --config spaghetti.yaml
151
+ ```
152
+
153
+ ### `--package`: ad-hoc CLI entries
154
+
155
+ ```bash
156
+ uv run spaghetti --package my-lib=my-lib/src/my_lib --package other=../other/src/other
157
+ ```
158
+
159
+ Repeatable; paths resolve relative to the current directory. Combine with `--config` to override or extend a config file for one run without editing it.
160
+
161
+ ## Development
162
+
163
+ ```bash
164
+ uv run pytest spaghetti/tests/
165
+ ```
@@ -0,0 +1,144 @@
1
+ # spaghetti
2
+
3
+ Spaghetti-code and architectural-smell detector for the Boti workspace.
4
+
5
+ Scans workspace packages for anti-patterns, architectural violations, and structural code smells — from single-function issues (long functions, deep nesting, high cyclomatic complexity) up to whole-package issues that only show up once you can see across files: real circular imports (not just a parent/child heuristic), copy-pasted function bodies, and the sync/async "twin" duplication pattern (`load`/`aload`, `foo`/`foo_async`) where a fix applied to one twin silently never reaches the other.
6
+
7
+ Every requested package is reviewed concurrently — one `boti.core.Agent` (`SpaghettiReviewAgent`) per package running its scan via `asyncio.to_thread` — then folded into a single consolidated report.
8
+
9
+ `spaghetti` is not tied to the Boti workspace: the package registry it scans is generic and configurable via a YAML file, ad-hoc CLI flags, or both — see [Configuring packages](#configuring-packages).
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ uv run spaghetti
15
+ uv run spaghetti --packages boti-data boti-dask
16
+ uv run spaghetti --severity error
17
+ uv run spaghetti --top 10 --exclude tests/ examples/
18
+ uv run spaghetti --json > report.json
19
+ uv run spaghetti --plan --top 10
20
+ uv run spaghetti --config spaghetti.yaml
21
+ uv run spaghetti --package my-lib=my-lib/src/my_lib
22
+ ```
23
+
24
+ Exit codes: `0` (clean), `1` (warnings present), `2` (errors present) — safe to wire into CI as a gate.
25
+
26
+ ### Options
27
+
28
+ | Flag | Default | Description |
29
+ | --- | --- | --- |
30
+ | `--config` | none | YAML file with a `packages: {name: path}` mapping (see below); replaces the built-in defaults |
31
+ | `--package` | none | Add or override one package as `NAME=PATH` (repeatable); applied on top of `--config` or the defaults |
32
+ | `--packages` | all resolved packages | Names to scan from the resolved registry |
33
+ | `--severity` | `info` | Minimum severity to display (`info` / `warning` / `error`) |
34
+ | `--json` | off | Output as JSON instead of the console report |
35
+ | `--top` | `5` | Number of worst files to list per package |
36
+ | `--exclude` | none | Path substrings to exclude from scanning |
37
+ | `--min-duplicate-lines` | `5` | Minimum function length to consider for duplicate-body detection |
38
+ | `--twin-similarity` | `0.6` | Minimum text-similarity ratio (0–1) to flag a sync/async twin pair |
39
+ | `--plan` | off | Output a prioritized remediation plan instead of the standard report |
40
+
41
+ Run `uv run spaghetti --help` for the full list.
42
+
43
+ ## Inline Suppression
44
+
45
+ Suppress specific findings on a line with `# spaghetti-ignore[rule]`:
46
+
47
+ ```python
48
+ # Suppress a specific rule
49
+ def f(): # spaghetti-ignore[long-function]: intentionally large
50
+ ...
51
+
52
+ # Suppress all rules on a line
53
+ x: dict = {} # spaghetti-ignore: reviewed, no issue
54
+ ```
55
+
56
+ The marker applies to the line it appears on and the line directly above it (so a marker can sit above a `def` line too long for a trailing comment). Suppressed findings are counted in the report (`suppressed: N` in the header) rather than silently dropped — they remain visible.
57
+
58
+ ## JSON Output
59
+
60
+ With `--json`, the report is a single JSON object to stdout:
61
+
62
+ ```json
63
+ {
64
+ "issues": [
65
+ {
66
+ "file": "src/my_module.py",
67
+ "line": 42,
68
+ "severity": "warning",
69
+ "rule": "long-function",
70
+ "message": "my_func() is 65 lines (max 50)",
71
+ "package": "my-lib"
72
+ }
73
+ ],
74
+ "suppressed": 3
75
+ }
76
+ ```
77
+
78
+ ## Remediation Plan
79
+
80
+ With `--plan`, the detector outputs a prioritized fix order instead of the standard report. Each rule is scored by `severity_weight × fix_effort` and grouped into priority levels (P0–P3):
81
+
82
+ ```bash
83
+ uv run spaghetti --plan --top 10
84
+ ```
85
+
86
+ **Priority levels:**
87
+ - **P0** (score ≥ 12): CRITICAL — fix immediately (e.g., circular imports, god-classes)
88
+ - **P1** (score ≥ 7): HIGH — fix this sprint
89
+ - **P2** (score ≥ 3): MEDIUM — plan for next cycle
90
+ - **P3** (score < 3): LOW — track in backlog
91
+
92
+ The plan groups issues by rule, counts affected files, and lists a recommended fix order. This makes it easy to start a code-quality improvement cycle with the highest-impact fixes first.
93
+
94
+ ## Rules
95
+
96
+ The detector checks **36 rules** across four tiers:
97
+
98
+ **Per-file AST checks (30 rules):** `long-function`, `high-complexity`, `missing-return-type`, `missing-param-type`, `too-many-params`, `excessive-returns`, `boolean-flag-params`, `deep-nesting`, `untyped-dict`, `unused-import`, `swallowed-exception`, `duplicate-branch`, `encapsulation-violation`, `god-class`, `layer-violation`, `transport-in-library`, `potential-circular-import`, `god-module`, `mutable-default`, `bare-except`, `star-import`, `global-mutable`, `scope-mutation`, `dead-code`, `message-chain`, `excessive-decorators`, `magic-number`, `missing-else`, `lazy-class`, `deep-inheritance`.
99
+
100
+ **Per-file source-text checks (2 rules):** `long-file`, `todo-marker`.
101
+
102
+ **Infrastructure checks (1 rule):** `syntax-error` (files that fail `ast.parse()`).
103
+
104
+ **Per-package cross-file checks (3 rules):** `import-cycle`, `duplicate-function-body`, `sync-async-duplication`.
105
+
106
+ See [SDD.md](SDD.md) for the full rule catalog, thresholds, and scoring formula.
107
+
108
+ ## Configuring packages
109
+
110
+ With no flags, `spaghetti` scans this workspace's own `DEFAULT_PACKAGES` (`boti`, `boti-data`, `boti-dask`; see `src/spaghetti/detector.py`). To point it at other packages — in this workspace, another workspace, or any directory on disk — use `--config` and/or `--package`.
111
+
112
+ **Precedence:**
113
+ 1. Neither flag given → the built-in defaults are used as-is.
114
+ 2. `--config` given → its `packages:` mapping **replaces** the defaults entirely, so a config file states the full set explicitly rather than silently inheriting unrelated hardcoded packages.
115
+ 3. `--package NAME=PATH` entries are then overlaid on top of whichever set (1) or (2) produced — adding new names or overriding ones already defined, so a config file plus a quick ad-hoc addition both work together.
116
+
117
+ ### `--config`: YAML file
118
+
119
+ ```yaml
120
+ # spaghetti.yaml
121
+ packages:
122
+ my-lib: my-lib/src/my_lib
123
+ my-service: services/my-service/src/my_service
124
+ ```
125
+
126
+ Paths resolve **relative to the config file's own directory**, not the caller's working directory, so the same config works no matter where you invoke `spaghetti` from.
127
+
128
+ ```bash
129
+ uv run spaghetti --config spaghetti.yaml
130
+ ```
131
+
132
+ ### `--package`: ad-hoc CLI entries
133
+
134
+ ```bash
135
+ uv run spaghetti --package my-lib=my-lib/src/my_lib --package other=../other/src/other
136
+ ```
137
+
138
+ Repeatable; paths resolve relative to the current directory. Combine with `--config` to override or extend a config file for one run without editing it.
139
+
140
+ ## Development
141
+
142
+ ```bash
143
+ uv run pytest spaghetti/tests/
144
+ ```
@@ -0,0 +1,67 @@
1
+ [project]
2
+ name = "spaghetti-detector"
3
+ version = "0.1.0"
4
+ description = "Spaghetti-code and architectural-smell detector for Python projects"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.13"
8
+ authors = [
9
+ {name = "Luis Valverde", email = "lvalverdeb@gmail.com"},
10
+ ]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Software Development :: Quality Assurance",
19
+ "Topic :: Software Development :: Testing",
20
+ ]
21
+ dependencies = [
22
+ "boti>=1.0.0",
23
+ "pyyaml>=6.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/lvalverdeb/repo-split"
28
+ Repository = "https://github.com/lvalverdeb/repo-split"
29
+
30
+ [project.scripts]
31
+ spaghetti = "spaghetti.detector:main"
32
+
33
+ [build-system]
34
+ requires = ["setuptools>=80", "wheel"]
35
+ build-backend = "setuptools.build_meta"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest>=9.0.3",
40
+ "pytest-asyncio>=1.3.0",
41
+ "ruff>=0.11.0",
42
+ ]
43
+
44
+ [tool.setuptools]
45
+ include-package-data = true
46
+
47
+ [tool.setuptools.package-dir]
48
+ "" = "src"
49
+
50
+ [tool.setuptools.packages.find]
51
+ where = ["src"]
52
+ include = ["spaghetti*"]
53
+
54
+ [tool.setuptools.package-data]
55
+ spaghetti = ["py.typed"]
56
+
57
+ [tool.pytest.ini_options]
58
+ asyncio_mode = "auto"
59
+ testpaths = ["tests"]
60
+
61
+ [tool.ruff]
62
+ line-length = 100
63
+ target-version = "py313"
64
+
65
+ [tool.ruff.lint]
66
+ select = ["E", "F", "W", "I", "UP"]
67
+ ignore = ["E501"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]
@@ -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
+ ]