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.
- spaghetti_detector-0.1.0/PKG-INFO +165 -0
- spaghetti_detector-0.1.0/README.md +144 -0
- spaghetti_detector-0.1.0/pyproject.toml +67 -0
- spaghetti_detector-0.1.0/setup.cfg +4 -0
- spaghetti_detector-0.1.0/src/spaghetti/__init__.py +12 -0
- spaghetti_detector-0.1.0/src/spaghetti/ast_helpers.py +120 -0
- spaghetti_detector-0.1.0/src/spaghetti/checks/__init__.py +38 -0
- spaghetti_detector-0.1.0/src/spaghetti/checks/ast_per_file.py +1166 -0
- spaghetti_detector-0.1.0/src/spaghetti/checks/package_level.py +331 -0
- spaghetti_detector-0.1.0/src/spaghetti/checks/text_per_file.py +48 -0
- spaghetti_detector-0.1.0/src/spaghetti/cli.py +395 -0
- spaghetti_detector-0.1.0/src/spaghetti/config.py +87 -0
- spaghetti_detector-0.1.0/src/spaghetti/detector.py +347 -0
- spaghetti_detector-0.1.0/src/spaghetti/models.py +67 -0
- spaghetti_detector-0.1.0/src/spaghetti/py.typed +0 -0
- spaghetti_detector-0.1.0/src/spaghetti/scanner.py +13 -0
- spaghetti_detector-0.1.0/src/spaghetti/scoring.py +205 -0
- spaghetti_detector-0.1.0/src/spaghetti/suppression.py +25 -0
- spaghetti_detector-0.1.0/src/spaghetti_detector.egg-info/PKG-INFO +165 -0
- spaghetti_detector-0.1.0/src/spaghetti_detector.egg-info/SOURCES.txt +23 -0
- spaghetti_detector-0.1.0/src/spaghetti_detector.egg-info/dependency_links.txt +1 -0
- spaghetti_detector-0.1.0/src/spaghetti_detector.egg-info/entry_points.txt +2 -0
- spaghetti_detector-0.1.0/src/spaghetti_detector.egg-info/requires.txt +2 -0
- spaghetti_detector-0.1.0/src/spaghetti_detector.egg-info/top_level.txt +1 -0
- 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,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
|
+
]
|