safe-fix-mcp 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.
- safe_fix_mcp-0.1.0/.gitignore +8 -0
- safe_fix_mcp-0.1.0/LICENSE +21 -0
- safe_fix_mcp-0.1.0/PKG-INFO +90 -0
- safe_fix_mcp-0.1.0/README.md +64 -0
- safe_fix_mcp-0.1.0/pyproject.toml +69 -0
- safe_fix_mcp-0.1.0/scripts/verify_real_client.py +32 -0
- safe_fix_mcp-0.1.0/server.json +20 -0
- safe_fix_mcp-0.1.0/src/safe_fix_mcp/__init__.py +0 -0
- safe_fix_mcp-0.1.0/src/safe_fix_mcp/bloat_check.py +143 -0
- safe_fix_mcp-0.1.0/src/safe_fix_mcp/bloat_pr.py +217 -0
- safe_fix_mcp-0.1.0/src/safe_fix_mcp/server.py +112 -0
- safe_fix_mcp-0.1.0/tests/test_bloat_check.py +91 -0
- safe_fix_mcp-0.1.0/tests/test_bloat_pr.py +145 -0
- safe_fix_mcp-0.1.0/tests/test_server.py +97 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pamela-0
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: safe-fix-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server: finds real dead code (vulture + deptry) and proposes a branch+PR for the one class safe to auto-remove -- unused imports on a single-name line -- gated on a real full test-suite pass. A human still merges.
|
|
5
|
+
Project-URL: Homepage, https://github.com/pamela-0/safe-fix-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/pamela-0/safe-fix-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/pamela-0/safe-fix-mcp/issues
|
|
8
|
+
Author-email: pamela-0 <154478423+pamela-0@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: automated-pr,claude,claude-code,code-quality,dead-code,deptry,mcp,model-context-protocol,static-analysis,vulture
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: deptry>=0.20
|
|
20
|
+
Requires-Dist: mcp[cli]>=1.26
|
|
21
|
+
Requires-Dist: vulture>=2.11
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# safe-fix-mcp
|
|
28
|
+
|
|
29
|
+
A real MCP server that finds dead code in a Python repository and proposes a **branch + PR** for the one class of finding that's actually safe to auto-remove: unused imports on a single-name import line. Everything else it finds — unused functions, classes, unused dependencies — stays report-only. A human always merges. This tool never merges anything itself.
|
|
30
|
+
|
|
31
|
+
## Why this exists
|
|
32
|
+
|
|
33
|
+
Most "dead code" tools stop at reporting. The ones that go further usually delete with no safety net. This does neither: it drafts a real, minimal, reviewable PR, gated on the target repo's own full test suite actually passing after the removal — not a heuristic, a real `pytest` run. If the suite fails, nothing is committed or pushed; the repo is left exactly as it started.
|
|
34
|
+
|
|
35
|
+
## Tools
|
|
36
|
+
|
|
37
|
+
### `scan_dead_code(repo_path=".", min_confidence=60)`
|
|
38
|
+
|
|
39
|
+
Read-only. Runs [vulture](https://github.com/jendrikseipp/vulture) (dead code) and [deptry](https://github.com/fpgmaas/deptry) (dependency issues) and returns a human-readable list of findings. Never modifies anything.
|
|
40
|
+
|
|
41
|
+
### `propose_removal_pr(repo_path=".")`
|
|
42
|
+
|
|
43
|
+
- Refuses on a dirty working tree — never edits on top of uncommitted work.
|
|
44
|
+
- Filters to unused imports at ≥90% vulture confidence, on single-name import lines only (`from x import y, z` is skipped — removing the whole line would silently remove `z` too).
|
|
45
|
+
- Creates a real branch, removes the qualifying imports, runs the repo's real full test suite.
|
|
46
|
+
- Only on a real pass: commits, pushes, and tries to open a PR via `gh pr create`.
|
|
47
|
+
- If `gh` isn't installed/authenticated, the branch is still committed and pushed for real — only PR creation degrades, with the real error returned so you can open it manually.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install -e .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Add to your MCP client config (e.g. Claude Code):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
claude mcp add safe-fix-mcp -- safe-fix-mcp
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or run directly for local testing:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
python -m safe_fix_mcp.server
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Requirements
|
|
68
|
+
|
|
69
|
+
- Python ≥ 3.10
|
|
70
|
+
- `git` on PATH
|
|
71
|
+
- `gh` (GitHub CLI) on PATH and authenticated, if you want `propose_removal_pr` to actually open the PR — without it, the branch still gets pushed for real, and the tool tells you to open the PR by hand.
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install -e ".[dev]"
|
|
77
|
+
pytest
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Known limitation
|
|
81
|
+
|
|
82
|
+
`vulture` flags `scan_dead_code`/`propose_removal_pr` themselves as "unused" — a known false-positive class, not a real bug: they're dispatched by the `@mcp.tool()` decorator at runtime, not called directly anywhere in the source, so static call-graph analysis can't see the real caller (the MCP framework itself).
|
|
83
|
+
|
|
84
|
+
## Verifying it for real
|
|
85
|
+
|
|
86
|
+
`scripts/verify_real_client.py` launches the packaged server as a real subprocess and talks to it with the real `mcp.client.ClientSession` — the same path a real MCP client uses. Useful as a smoke test after any change:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
python scripts/verify_real_client.py
|
|
90
|
+
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# safe-fix-mcp
|
|
2
|
+
|
|
3
|
+
A real MCP server that finds dead code in a Python repository and proposes a **branch + PR** for the one class of finding that's actually safe to auto-remove: unused imports on a single-name import line. Everything else it finds — unused functions, classes, unused dependencies — stays report-only. A human always merges. This tool never merges anything itself.
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
Most "dead code" tools stop at reporting. The ones that go further usually delete with no safety net. This does neither: it drafts a real, minimal, reviewable PR, gated on the target repo's own full test suite actually passing after the removal — not a heuristic, a real `pytest` run. If the suite fails, nothing is committed or pushed; the repo is left exactly as it started.
|
|
8
|
+
|
|
9
|
+
## Tools
|
|
10
|
+
|
|
11
|
+
### `scan_dead_code(repo_path=".", min_confidence=60)`
|
|
12
|
+
|
|
13
|
+
Read-only. Runs [vulture](https://github.com/jendrikseipp/vulture) (dead code) and [deptry](https://github.com/fpgmaas/deptry) (dependency issues) and returns a human-readable list of findings. Never modifies anything.
|
|
14
|
+
|
|
15
|
+
### `propose_removal_pr(repo_path=".")`
|
|
16
|
+
|
|
17
|
+
- Refuses on a dirty working tree — never edits on top of uncommitted work.
|
|
18
|
+
- Filters to unused imports at ≥90% vulture confidence, on single-name import lines only (`from x import y, z` is skipped — removing the whole line would silently remove `z` too).
|
|
19
|
+
- Creates a real branch, removes the qualifying imports, runs the repo's real full test suite.
|
|
20
|
+
- Only on a real pass: commits, pushes, and tries to open a PR via `gh pr create`.
|
|
21
|
+
- If `gh` isn't installed/authenticated, the branch is still committed and pushed for real — only PR creation degrades, with the real error returned so you can open it manually.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Add to your MCP client config (e.g. Claude Code):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
claude mcp add safe-fix-mcp -- safe-fix-mcp
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or run directly for local testing:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python -m safe_fix_mcp.server
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
- Python ≥ 3.10
|
|
44
|
+
- `git` on PATH
|
|
45
|
+
- `gh` (GitHub CLI) on PATH and authenticated, if you want `propose_removal_pr` to actually open the PR — without it, the branch still gets pushed for real, and the tool tells you to open the PR by hand.
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install -e ".[dev]"
|
|
51
|
+
pytest
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Known limitation
|
|
55
|
+
|
|
56
|
+
`vulture` flags `scan_dead_code`/`propose_removal_pr` themselves as "unused" — a known false-positive class, not a real bug: they're dispatched by the `@mcp.tool()` decorator at runtime, not called directly anywhere in the source, so static call-graph analysis can't see the real caller (the MCP framework itself).
|
|
57
|
+
|
|
58
|
+
## Verifying it for real
|
|
59
|
+
|
|
60
|
+
`scripts/verify_real_client.py` launches the packaged server as a real subprocess and talks to it with the real `mcp.client.ClientSession` — the same path a real MCP client uses. Useful as a smoke test after any change:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python scripts/verify_real_client.py
|
|
64
|
+
```
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "safe-fix-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server: finds real dead code (vulture + deptry) and proposes a branch+PR for the one class safe to auto-remove -- unused imports on a single-name line -- gated on a real full test-suite pass. A human still merges."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "pamela-0", email = "154478423+pamela-0@users.noreply.github.com" }]
|
|
9
|
+
keywords = [
|
|
10
|
+
"mcp",
|
|
11
|
+
"model-context-protocol",
|
|
12
|
+
"claude",
|
|
13
|
+
"claude-code",
|
|
14
|
+
"dead-code",
|
|
15
|
+
"vulture",
|
|
16
|
+
"deptry",
|
|
17
|
+
"code-quality",
|
|
18
|
+
"automated-pr",
|
|
19
|
+
"static-analysis",
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 4 - Beta",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
27
|
+
"Topic :: Software Development :: Testing",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"mcp[cli]>=1.26",
|
|
31
|
+
"vulture>=2.11",
|
|
32
|
+
"deptry>=0.20",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/pamela-0/safe-fix-mcp"
|
|
37
|
+
Repository = "https://github.com/pamela-0/safe-fix-mcp"
|
|
38
|
+
Issues = "https://github.com/pamela-0/safe-fix-mcp/issues"
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.24"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
asyncio_mode = "strict"
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
safe-fix-mcp = "safe_fix_mcp.server:main"
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["hatchling"]
|
|
51
|
+
build-backend = "hatchling.build"
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.wheel]
|
|
54
|
+
packages = ["src/safe_fix_mcp"]
|
|
55
|
+
|
|
56
|
+
[tool.deptry]
|
|
57
|
+
exclude = [".venv", "tests"]
|
|
58
|
+
|
|
59
|
+
[tool.deptry.per_rule_ignores]
|
|
60
|
+
# DEP003 "imported but it is a transitive dependency": deptry's real,
|
|
61
|
+
# known limitation with src-layout packages -- it doesn't recognize
|
|
62
|
+
# `safe_fix_mcp` importing its own submodules as the package importing
|
|
63
|
+
# itself, not a real external transitive-dependency risk.
|
|
64
|
+
DEP003 = ["safe_fix_mcp"]
|
|
65
|
+
# DEP002 "defined as a dependency but not used": vulture/deptry are real,
|
|
66
|
+
# load-bearing dependencies invoked as subprocesses (`python -m vulture`),
|
|
67
|
+
# never imported directly, so deptry's static import scan can't see the
|
|
68
|
+
# usage. pytest is a real dev dependency used only from tests/.
|
|
69
|
+
DEP002 = ["vulture", "deptry", "pytest", "pytest-asyncio"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Throwaway real-client verification, not a deliverable: launches
|
|
2
|
+
safe-fix-mcp as a REAL subprocess over stdio and talks to it with the
|
|
3
|
+
real mcp.client.ClientSession -- the exact path a real MCP client
|
|
4
|
+
(Claude Code, Claude Desktop, Cursor) uses. Proves the packaged
|
|
5
|
+
`safe-fix-mcp` console script actually works as a standalone server
|
|
6
|
+
process, not just as in-process Python calls.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from mcp import ClientSession
|
|
13
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def main() -> None:
|
|
17
|
+
params = StdioServerParameters(command=sys.executable, args=["-m", "safe_fix_mcp.server"])
|
|
18
|
+
async with stdio_client(params) as (read, write):
|
|
19
|
+
async with ClientSession(read, write) as session:
|
|
20
|
+
init = await session.initialize()
|
|
21
|
+
print(f"Real handshake OK -- server: {init.server_info.name} v{init.server_info.version}")
|
|
22
|
+
|
|
23
|
+
tools = await session.list_tools()
|
|
24
|
+
print(f"Real tools advertised: {[t.name for t in tools.tools]}")
|
|
25
|
+
|
|
26
|
+
result = await session.call_tool("scan_dead_code", {"repo_path": "."})
|
|
27
|
+
print("Real scan_dead_code call result:")
|
|
28
|
+
print(result.content[0].text)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
|
|
3
|
+
"name": "io.github.pamela-0/safe-fix-mcp",
|
|
4
|
+
"description": "Finds real dead code (vulture + deptry) in a Python repo and proposes a branch+PR for the one class safe to auto-remove: unused imports on a single-name line, gated on the target repo's own real full test-suite pass. A human always merges.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/pamela-0/safe-fix-mcp",
|
|
8
|
+
"source": "github"
|
|
9
|
+
},
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registry_type": "pypi",
|
|
13
|
+
"identifier": "safe-fix-mcp",
|
|
14
|
+
"version": "0.1.0",
|
|
15
|
+
"transport": {
|
|
16
|
+
"type": "stdio"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Real dead-code/dependency-bloat detection — runs vulture and deptry
|
|
2
|
+
(real static-analysis tools, not a hand-rolled heuristic) and returns a
|
|
3
|
+
structured report of real candidates. Deliberately a *report*, not an
|
|
4
|
+
autonomous deleter — see bloat_pr.py for the narrow, safety-gated subset
|
|
5
|
+
that IS auto-proposed as a branch+PR.
|
|
6
|
+
|
|
7
|
+
Both tools are invoked as real subprocesses (`python -m vulture`/`python
|
|
8
|
+
-m deptry`), not imported — so this module's own imports of them never
|
|
9
|
+
count against it.
|
|
10
|
+
|
|
11
|
+
Unlike a project with a fixed `src`/`tests` layout, this is a
|
|
12
|
+
general-purpose tool meant to scan an arbitrary caller's repository —
|
|
13
|
+
`paths` defaults to `["."]` (the whole target repo) rather than assuming
|
|
14
|
+
any particular directory convention.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
_VULTURE_LINE_RE = re.compile(
|
|
26
|
+
r"^(?P<file>.+):(?P<line>\d+): (?P<message>.+) \((?P<confidence>\d+)% confidence\)$"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Real bug found live scanning this project's own repo: paths=None -> ["."]
|
|
30
|
+
# with no exclusions walks straight into .venv/site-packages, turning a
|
|
31
|
+
# handful of real findings into 30,000+ noise findings from third-party
|
|
32
|
+
# packages that were never this repo's own code. Every general-purpose
|
|
33
|
+
# Python project layout puts one of these inside the repo root, so
|
|
34
|
+
# excluding them isn't optional for a tool meant to scan "." by default.
|
|
35
|
+
DEFAULT_EXCLUDE = ".venv,venv,.git,__pycache__,build,dist,*.egg-info,node_modules"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class BloatFinding:
|
|
40
|
+
tool: str # "vulture" | "deptry"
|
|
41
|
+
file: str
|
|
42
|
+
line: int | None
|
|
43
|
+
message: str
|
|
44
|
+
confidence: int | None = None # vulture only; deptry findings have no confidence score
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_vulture(
|
|
48
|
+
paths: list[str] | None = None,
|
|
49
|
+
min_confidence: int = 60,
|
|
50
|
+
cwd: str = ".",
|
|
51
|
+
exclude: str = DEFAULT_EXCLUDE,
|
|
52
|
+
) -> list[BloatFinding]:
|
|
53
|
+
"""Real dead-code candidates: unused functions, classes, variables,
|
|
54
|
+
imports. `paths` defaults to `["."]` — the whole target repo, since
|
|
55
|
+
this tool can't assume any particular directory layout — with
|
|
56
|
+
`exclude` defaulting to skip `.venv`/`.git`/build artifacts, which are
|
|
57
|
+
routinely inside a real project's repo root and are never the
|
|
58
|
+
caller's own code (see DEFAULT_EXCLUDE for why this isn't optional)."""
|
|
59
|
+
targets = paths or ["."]
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
[
|
|
62
|
+
sys.executable,
|
|
63
|
+
"-m",
|
|
64
|
+
"vulture",
|
|
65
|
+
*targets,
|
|
66
|
+
"--min-confidence",
|
|
67
|
+
str(min_confidence),
|
|
68
|
+
*(["--exclude", exclude] if exclude else []),
|
|
69
|
+
],
|
|
70
|
+
cwd=cwd,
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
)
|
|
74
|
+
findings = []
|
|
75
|
+
for line in result.stdout.strip().splitlines():
|
|
76
|
+
match = _VULTURE_LINE_RE.match(line)
|
|
77
|
+
if not match:
|
|
78
|
+
continue # vulture prints nothing else on stdout in normal operation, but don't crash if it ever does
|
|
79
|
+
findings.append(
|
|
80
|
+
BloatFinding(
|
|
81
|
+
tool="vulture",
|
|
82
|
+
file=match.group("file"),
|
|
83
|
+
line=int(match.group("line")),
|
|
84
|
+
message=match.group("message"),
|
|
85
|
+
confidence=int(match.group("confidence")),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return findings
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_deptry(cwd: str = ".") -> list[BloatFinding]:
|
|
92
|
+
"""Real unused/missing/transitive dependency issues, via deptry's own
|
|
93
|
+
`--json-output` (structured, not text-scraped). Returns an empty list,
|
|
94
|
+
not an error, when the target repo has no `pyproject.toml` at all —
|
|
95
|
+
deptry can't run without one, and a repo with no Python packaging
|
|
96
|
+
metadata simply has nothing for it to check."""
|
|
97
|
+
import json
|
|
98
|
+
import tempfile
|
|
99
|
+
|
|
100
|
+
if not (Path(cwd) / "pyproject.toml").exists():
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
|
|
104
|
+
output_path = tmp.name
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
subprocess.run(
|
|
108
|
+
[sys.executable, "-m", "deptry", ".", "--json-output", output_path],
|
|
109
|
+
cwd=cwd,
|
|
110
|
+
capture_output=True,
|
|
111
|
+
text=True,
|
|
112
|
+
)
|
|
113
|
+
raw = Path(cwd, output_path).read_text(encoding="utf-8")
|
|
114
|
+
issues = json.loads(raw) if raw.strip() else []
|
|
115
|
+
finally:
|
|
116
|
+
Path(output_path).unlink(missing_ok=True)
|
|
117
|
+
|
|
118
|
+
findings = []
|
|
119
|
+
for issue in issues:
|
|
120
|
+
location = issue.get("location", {})
|
|
121
|
+
findings.append(
|
|
122
|
+
BloatFinding(
|
|
123
|
+
tool="deptry",
|
|
124
|
+
file=location.get("file", "pyproject.toml"),
|
|
125
|
+
line=location.get("line"),
|
|
126
|
+
message=f"{issue.get('error', {}).get('code', '?')}: "
|
|
127
|
+
f"{issue.get('error', {}).get('message', issue)}",
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
return findings
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_bloat_check(
|
|
134
|
+
cwd: str = ".",
|
|
135
|
+
min_confidence: int = 60,
|
|
136
|
+
vulture_paths: list[str] | None = None,
|
|
137
|
+
vulture_exclude: str = DEFAULT_EXCLUDE,
|
|
138
|
+
) -> list[BloatFinding]:
|
|
139
|
+
"""Both tools together, as one flat list of real candidates — the
|
|
140
|
+
single entry point the MCP server (and any other caller) should use."""
|
|
141
|
+
return run_vulture(
|
|
142
|
+
paths=vulture_paths, min_confidence=min_confidence, cwd=cwd, exclude=vulture_exclude
|
|
143
|
+
) + run_deptry(cwd=cwd)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Autonomous branch+PR proposal for bloat_check's SAFEST finding class
|
|
2
|
+
only: unused imports on a single-name import line. A human still clicks
|
|
3
|
+
merge — this automates *drafting* the removal, not *approving* it.
|
|
4
|
+
|
|
5
|
+
Deliberately does NOT auto-remove anything else bloat_check finds:
|
|
6
|
+
|
|
7
|
+
- Unused functions/classes/variables/attributes (vulture): a well-known
|
|
8
|
+
false-positive class lives here, not in unused imports — Pydantic/
|
|
9
|
+
dataclass field declarations, framework fixtures, and anything accessed
|
|
10
|
+
via reflection/dynamic attribute access can't be traced by vulture's
|
|
11
|
+
static analysis. An unused *import* has no equivalent escape hatch: if
|
|
12
|
+
nothing in the scanned tree references the name, removing the import
|
|
13
|
+
line is safe by construction.
|
|
14
|
+
- Unused dependencies (deptry DEP002): real removal means editing
|
|
15
|
+
`pyproject.toml`'s dependency arrays correctly (project deps vs.
|
|
16
|
+
optional extras vs. tool-specific tables) — that needs a real
|
|
17
|
+
TOML-structure-aware rewrite, not blind line deletion.
|
|
18
|
+
|
|
19
|
+
A multi-name import line (`from x import y, z` where only `y` is
|
|
20
|
+
unused) is also excluded even though vulture flags it: deleting the
|
|
21
|
+
whole line would silently remove `z` too. `_is_safe_single_import_line`
|
|
22
|
+
gates on this before a finding is ever added to the auto-remove set.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import uuid
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Callable
|
|
34
|
+
|
|
35
|
+
from safe_fix_mcp.bloat_check import BloatFinding, run_bloat_check
|
|
36
|
+
|
|
37
|
+
AUTO_PR_MIN_CONFIDENCE = 90 # higher bar than run_bloat_check's own 60% default report threshold — this path commits and pushes without a per-finding human review, so the bar for what qualifies is deliberately stricter.
|
|
38
|
+
|
|
39
|
+
_SIMPLE_IMPORT_RE = re.compile(
|
|
40
|
+
r"^\s*(import\s+[\w.]+(\s+as\s+\w+)?|from\s+[\w.]+\s+import\s+\**\w+(\s+as\s+\w+)?)\s*$"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class BloatPrResult:
|
|
46
|
+
status: str # "nothing_to_propose" | "dirty_worktree" | "tests_failed" | "opened" | "pr_creation_failed"
|
|
47
|
+
branch: str | None = None
|
|
48
|
+
pr_url: str | None = None
|
|
49
|
+
removed: tuple[BloatFinding, ...] = field(default_factory=tuple)
|
|
50
|
+
needs_manual_review: tuple[BloatFinding, ...] = field(default_factory=tuple)
|
|
51
|
+
detail: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run_git(args: list[str], cwd: str) -> subprocess.CompletedProcess:
|
|
55
|
+
return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_safe_single_import_line(file_path: Path, line_no: int) -> bool:
|
|
59
|
+
try:
|
|
60
|
+
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
61
|
+
except (OSError, UnicodeDecodeError):
|
|
62
|
+
return False
|
|
63
|
+
if not (1 <= line_no <= len(lines)):
|
|
64
|
+
return False
|
|
65
|
+
return bool(_SIMPLE_IMPORT_RE.match(lines[line_no - 1]))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _auto_removable_imports(findings: list[BloatFinding], cwd: str) -> list[BloatFinding]:
|
|
69
|
+
return [
|
|
70
|
+
f
|
|
71
|
+
for f in findings
|
|
72
|
+
if f.tool == "vulture"
|
|
73
|
+
and f.message.startswith("unused import ")
|
|
74
|
+
and f.confidence is not None
|
|
75
|
+
and f.confidence >= AUTO_PR_MIN_CONFIDENCE
|
|
76
|
+
and _is_safe_single_import_line(Path(cwd, f.file), f.line)
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _remove_import_lines(removals: list[BloatFinding], cwd: str) -> None:
|
|
81
|
+
"""Groups by file, removes highest line number first per file so an
|
|
82
|
+
earlier removal never shifts the line numbers of ones still pending."""
|
|
83
|
+
by_file: dict[str, list[int]] = {}
|
|
84
|
+
for finding in removals:
|
|
85
|
+
by_file.setdefault(finding.file, []).append(finding.line)
|
|
86
|
+
|
|
87
|
+
for rel_path, line_numbers in by_file.items():
|
|
88
|
+
target = Path(cwd, rel_path)
|
|
89
|
+
lines = target.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
90
|
+
for line_no in sorted(line_numbers, reverse=True):
|
|
91
|
+
del lines[line_no - 1]
|
|
92
|
+
target.write_text("".join(lines), encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _default_run_tests(cwd: str) -> bool:
|
|
96
|
+
result = subprocess.run([sys.executable, "-m", "pytest", "-q"], cwd=cwd)
|
|
97
|
+
return result.returncode == 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _default_push_branch(branch: str, cwd: str) -> None:
|
|
101
|
+
result = _run_git(["push", "-u", "origin", branch], cwd)
|
|
102
|
+
if result.returncode != 0:
|
|
103
|
+
raise RuntimeError(result.stderr.strip() or "git push failed")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _default_open_pr(branch: str, title: str, body: str, cwd: str) -> str:
|
|
107
|
+
result = subprocess.run(
|
|
108
|
+
["gh", "pr", "create", "--title", title, "--body", body, "--head", branch],
|
|
109
|
+
cwd=cwd,
|
|
110
|
+
capture_output=True,
|
|
111
|
+
text=True,
|
|
112
|
+
)
|
|
113
|
+
if result.returncode != 0:
|
|
114
|
+
raise RuntimeError(result.stderr.strip() or "gh pr create failed")
|
|
115
|
+
return result.stdout.strip()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _pr_body(removed: list[BloatFinding], manual: list[BloatFinding]) -> str:
|
|
119
|
+
lines = [
|
|
120
|
+
"Automated by `safe-fix-mcp` — removes only unused imports at "
|
|
121
|
+
f">= {AUTO_PR_MIN_CONFIDENCE}% vulture confidence, on single-name "
|
|
122
|
+
"import lines only. Nothing else was touched.",
|
|
123
|
+
"",
|
|
124
|
+
"## Removed",
|
|
125
|
+
]
|
|
126
|
+
lines += [f"- `{f.file}:{f.line}` — {f.message} ({f.confidence}%)" for f in removed]
|
|
127
|
+
if manual:
|
|
128
|
+
lines += [
|
|
129
|
+
"",
|
|
130
|
+
"## Found but NOT auto-removed — needs manual review",
|
|
131
|
+
]
|
|
132
|
+
lines += [f"- `{f.file}:{f.line}` ({f.tool}) — {f.message}" for f in manual]
|
|
133
|
+
return "\n".join(lines)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def propose_bloat_removal_pr(
|
|
137
|
+
cwd: str = ".",
|
|
138
|
+
vulture_paths: list[str] | None = None,
|
|
139
|
+
run_tests: Callable[[str], bool] = _default_run_tests,
|
|
140
|
+
push_branch: Callable[[str, str], None] = _default_push_branch,
|
|
141
|
+
open_pr: Callable[[str, str, str, str], str] = _default_open_pr,
|
|
142
|
+
) -> BloatPrResult:
|
|
143
|
+
"""The real workflow: scan -> filter to the safe subset -> branch ->
|
|
144
|
+
remove -> run the REAL full test suite as the safety gate -> only
|
|
145
|
+
commit/push/PR if it still passes. Any failure at any step leaves the
|
|
146
|
+
working tree exactly as it started."""
|
|
147
|
+
status = _run_git(["status", "--porcelain"], cwd)
|
|
148
|
+
if status.stdout.strip():
|
|
149
|
+
return BloatPrResult(status="dirty_worktree", detail="uncommitted changes present — commit or stash first")
|
|
150
|
+
|
|
151
|
+
findings = run_bloat_check(cwd=cwd, vulture_paths=vulture_paths)
|
|
152
|
+
removable = _auto_removable_imports(findings, cwd)
|
|
153
|
+
manual = [f for f in findings if f not in removable]
|
|
154
|
+
|
|
155
|
+
if not removable:
|
|
156
|
+
return BloatPrResult(status="nothing_to_propose", needs_manual_review=tuple(manual))
|
|
157
|
+
|
|
158
|
+
original_branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd).stdout.strip()
|
|
159
|
+
branch = f"safe-fix-mcp/auto-unused-imports-{uuid.uuid4().hex[:8]}"
|
|
160
|
+
_run_git(["checkout", "-b", branch], cwd)
|
|
161
|
+
|
|
162
|
+
_remove_import_lines(removable, cwd)
|
|
163
|
+
|
|
164
|
+
if not run_tests(cwd):
|
|
165
|
+
_run_git(["checkout", "--", "."], cwd)
|
|
166
|
+
_run_git(["checkout", original_branch], cwd)
|
|
167
|
+
_run_git(["branch", "-D", branch], cwd)
|
|
168
|
+
return BloatPrResult(
|
|
169
|
+
status="tests_failed",
|
|
170
|
+
branch=branch,
|
|
171
|
+
removed=tuple(removable),
|
|
172
|
+
needs_manual_review=tuple(manual),
|
|
173
|
+
detail="full test suite failed after removing the flagged imports — nothing committed or pushed",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
commit_lines = [f"- {f.file}:{f.line} — {f.message} ({f.confidence}%)" for f in removable]
|
|
177
|
+
_run_git(["add", "-A"], cwd)
|
|
178
|
+
_run_git(
|
|
179
|
+
[
|
|
180
|
+
"commit",
|
|
181
|
+
"-m",
|
|
182
|
+
"chore: remove unused imports flagged by vulture (auto-proposed)\n\n" + "\n".join(commit_lines),
|
|
183
|
+
],
|
|
184
|
+
cwd,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
push_branch(branch, cwd)
|
|
189
|
+
except Exception as exc: # noqa: BLE001 — real network/auth/remote failure, not a code bug
|
|
190
|
+
return BloatPrResult(
|
|
191
|
+
status="pr_creation_failed",
|
|
192
|
+
branch=branch,
|
|
193
|
+
removed=tuple(removable),
|
|
194
|
+
needs_manual_review=tuple(manual),
|
|
195
|
+
detail=f"committed to {branch!r} locally, but push failed: {exc}",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
title = f"chore: remove {len(removable)} unused import(s) flagged by vulture"
|
|
199
|
+
body = _pr_body(removable, manual)
|
|
200
|
+
try:
|
|
201
|
+
pr_url = open_pr(branch, title, body, cwd)
|
|
202
|
+
except Exception as exc: # noqa: BLE001 — e.g. `gh` not installed/authenticated
|
|
203
|
+
return BloatPrResult(
|
|
204
|
+
status="pr_creation_failed",
|
|
205
|
+
branch=branch,
|
|
206
|
+
removed=tuple(removable),
|
|
207
|
+
needs_manual_review=tuple(manual),
|
|
208
|
+
detail=f"pushed {branch!r}, but PR creation failed: {exc} — open it manually",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return BloatPrResult(
|
|
212
|
+
status="opened",
|
|
213
|
+
branch=branch,
|
|
214
|
+
pr_url=pr_url,
|
|
215
|
+
removed=tuple(removable),
|
|
216
|
+
needs_manual_review=tuple(manual),
|
|
217
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""safe-fix-mcp: real MCP server exposing bloat_check/bloat_pr as tools
|
|
2
|
+
any MCP client (Claude Code, Claude Desktop, Cursor, etc.) can call
|
|
3
|
+
against any target repository — not tied to any one project.
|
|
4
|
+
|
|
5
|
+
safe-fix-mcp # stdio, the real entry point clients use
|
|
6
|
+
python -m safe_fix_mcp.server # same, for local testing
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from mcp.server.mcpserver import MCPServer
|
|
12
|
+
|
|
13
|
+
from safe_fix_mcp.bloat_check import run_bloat_check
|
|
14
|
+
from safe_fix_mcp.bloat_pr import propose_bloat_removal_pr
|
|
15
|
+
|
|
16
|
+
mcp = MCPServer(
|
|
17
|
+
name="safe-fix-mcp",
|
|
18
|
+
title="Safe Fix",
|
|
19
|
+
version="0.1.0",
|
|
20
|
+
instructions=(
|
|
21
|
+
"Finds real dead code (vulture) and dependency issues (deptry) in a "
|
|
22
|
+
"Python repository. `scan_dead_code` is read-only and always safe to "
|
|
23
|
+
"call. `propose_removal_pr` goes further: it creates a real git "
|
|
24
|
+
"branch, removes only unused imports on single-name import lines at "
|
|
25
|
+
">= 90% vulture confidence, runs the target repo's own full test "
|
|
26
|
+
"suite as a safety gate, and only commits/pushes/opens a PR if that "
|
|
27
|
+
"suite still passes. A human still has to merge the PR — this tool "
|
|
28
|
+
"never merges anything itself."
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _format_findings(findings) -> str:
|
|
34
|
+
if not findings:
|
|
35
|
+
return "No real candidates found."
|
|
36
|
+
lines = [f"{len(findings)} real candidate(s):"]
|
|
37
|
+
for f in findings:
|
|
38
|
+
confidence = f" ({f.confidence}% confidence)" if f.confidence is not None else ""
|
|
39
|
+
location = f"{f.file}:{f.line}" if f.line else f.file
|
|
40
|
+
lines.append(f"- [{f.tool}] {location} — {f.message}{confidence}")
|
|
41
|
+
return "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@mcp.tool()
|
|
45
|
+
def scan_dead_code(repo_path: str = ".", min_confidence: int = 60) -> str:
|
|
46
|
+
"""Scan a Python repository for real dead code (vulture) and dependency
|
|
47
|
+
issues (deptry). Read-only — never modifies anything. Returns a
|
|
48
|
+
human-readable list of findings, or "No real candidates found."
|
|
49
|
+
|
|
50
|
+
repo_path: path to the repository to scan (must contain a
|
|
51
|
+
pyproject.toml for the dependency checks to run; dead-code scanning
|
|
52
|
+
works regardless).
|
|
53
|
+
min_confidence: vulture's own confidence threshold (0-100). Lower
|
|
54
|
+
values surface more candidates but more false positives.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
findings = run_bloat_check(cwd=repo_path, min_confidence=min_confidence)
|
|
58
|
+
except FileNotFoundError as exc:
|
|
59
|
+
return f"vulture/deptry not available in this environment: {exc}"
|
|
60
|
+
return _format_findings(findings)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@mcp.tool()
|
|
64
|
+
def propose_removal_pr(repo_path: str = ".") -> str:
|
|
65
|
+
"""Propose a real branch+PR removing only unused imports (single-name
|
|
66
|
+
import lines, >= 90% vulture confidence) from a Python repository. Runs
|
|
67
|
+
the repo's own full test suite as a safety gate before ever committing
|
|
68
|
+
or pushing — a failure there reverts everything and nothing is
|
|
69
|
+
committed or pushed. If `gh` isn't available or PR creation otherwise
|
|
70
|
+
fails, the branch is still committed and pushed for real; only the PR
|
|
71
|
+
itself needs opening manually. A human always merges — this never
|
|
72
|
+
merges anything itself.
|
|
73
|
+
|
|
74
|
+
repo_path: path to a real git repository with a clean working tree
|
|
75
|
+
(uncommitted changes are refused, not overwritten).
|
|
76
|
+
"""
|
|
77
|
+
result = propose_bloat_removal_pr(cwd=repo_path)
|
|
78
|
+
|
|
79
|
+
if result.status == "dirty_worktree":
|
|
80
|
+
return f"Refused: {result.detail}"
|
|
81
|
+
if result.status == "nothing_to_propose":
|
|
82
|
+
extra = f" ({len(result.needs_manual_review)} other finding(s) need manual review.)" if result.needs_manual_review else ""
|
|
83
|
+
return "No unused imports qualified for automatic removal." + extra
|
|
84
|
+
if result.status == "tests_failed":
|
|
85
|
+
return (
|
|
86
|
+
f"Removed {len(result.removed)} import(s), but the real test suite "
|
|
87
|
+
f"failed afterward — reverted, nothing committed or pushed. {result.detail}"
|
|
88
|
+
)
|
|
89
|
+
if result.status == "pr_creation_failed":
|
|
90
|
+
return (
|
|
91
|
+
f"Branch {result.branch!r} committed and pushed, but PR creation "
|
|
92
|
+
f"failed — open it manually. {result.detail}"
|
|
93
|
+
)
|
|
94
|
+
# "opened"
|
|
95
|
+
manual_note = (
|
|
96
|
+
f" {len(result.needs_manual_review)} other finding(s) need manual review."
|
|
97
|
+
if result.needs_manual_review
|
|
98
|
+
else ""
|
|
99
|
+
)
|
|
100
|
+
return (
|
|
101
|
+
f"Opened: {result.pr_url}\n"
|
|
102
|
+
f"Removed {len(result.removed)} unused import(s) on branch {result.branch!r}."
|
|
103
|
+
f"{manual_note}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main() -> None:
|
|
108
|
+
mcp.run(transport="stdio")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Real tests against the actual vulture/deptry subprocesses, not mocked
|
|
2
|
+
output.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from safe_fix_mcp.bloat_check import (
|
|
6
|
+
_VULTURE_LINE_RE,
|
|
7
|
+
BloatFinding,
|
|
8
|
+
run_bloat_check,
|
|
9
|
+
run_deptry,
|
|
10
|
+
run_vulture,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestVultureLineParsing:
|
|
15
|
+
def test_parses_a_real_vulture_output_line(self):
|
|
16
|
+
line = "src/app.py:12: unused function 'helper' (60% confidence)"
|
|
17
|
+
|
|
18
|
+
match = _VULTURE_LINE_RE.match(line)
|
|
19
|
+
|
|
20
|
+
assert match.group("file") == "src/app.py"
|
|
21
|
+
assert match.group("line") == "12"
|
|
22
|
+
assert match.group("message") == "unused function 'helper'"
|
|
23
|
+
assert match.group("confidence") == "60"
|
|
24
|
+
|
|
25
|
+
def test_does_not_match_an_unrelated_line(self):
|
|
26
|
+
assert _VULTURE_LINE_RE.match("Scanning 82 files...") is None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestRunVultureReal:
|
|
30
|
+
def test_finds_a_real_genuinely_unused_function(self, tmp_path):
|
|
31
|
+
(tmp_path / "sample.py").write_text(
|
|
32
|
+
"def used():\n"
|
|
33
|
+
" return 1\n"
|
|
34
|
+
"\n"
|
|
35
|
+
"\n"
|
|
36
|
+
"def totally_unused_helper():\n"
|
|
37
|
+
" return 2\n"
|
|
38
|
+
"\n"
|
|
39
|
+
"\n"
|
|
40
|
+
"used()\n",
|
|
41
|
+
encoding="utf-8",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
findings = run_vulture(paths=["sample.py"], min_confidence=60, cwd=str(tmp_path))
|
|
45
|
+
|
|
46
|
+
assert any(f.tool == "vulture" and "totally_unused_helper" in f.message for f in findings)
|
|
47
|
+
assert not any("'used'" in f.message for f in findings)
|
|
48
|
+
|
|
49
|
+
def test_finds_nothing_when_everything_is_used(self, tmp_path):
|
|
50
|
+
(tmp_path / "sample.py").write_text("def used():\n return 1\n\n\nused()\n", encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
findings = run_vulture(paths=["sample.py"], min_confidence=60, cwd=str(tmp_path))
|
|
53
|
+
|
|
54
|
+
assert findings == []
|
|
55
|
+
|
|
56
|
+
def test_defaults_to_scanning_the_whole_target_repo(self, tmp_path):
|
|
57
|
+
# No fixed src/tests layout assumed -- paths=None must scan "."
|
|
58
|
+
(tmp_path / "sample.py").write_text(
|
|
59
|
+
"def totally_unused_here():\n return 1\n", encoding="utf-8"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
findings = run_vulture(paths=None, min_confidence=60, cwd=str(tmp_path))
|
|
63
|
+
|
|
64
|
+
assert any("totally_unused_here" in f.message for f in findings)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestRunDeptryReal:
|
|
68
|
+
def test_returns_empty_when_no_pyproject_toml_exists(self, tmp_path):
|
|
69
|
+
assert run_deptry(cwd=str(tmp_path)) == []
|
|
70
|
+
|
|
71
|
+
def test_returns_a_list_of_findings_or_empty_for_a_clean_project(self, tmp_path):
|
|
72
|
+
(tmp_path / "pyproject.toml").write_text(
|
|
73
|
+
'[project]\nname = "tmp"\nversion = "0.0.0"\ndependencies = []\n', encoding="utf-8"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
findings = run_deptry(cwd=str(tmp_path))
|
|
77
|
+
|
|
78
|
+
assert isinstance(findings, list)
|
|
79
|
+
assert all(isinstance(f, BloatFinding) and f.tool == "deptry" for f in findings)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestRunBloatCheck:
|
|
83
|
+
def test_combines_both_tools_real_output(self, tmp_path):
|
|
84
|
+
(tmp_path / "sample.py").write_text("def totally_unused_again():\n return 1\n", encoding="utf-8")
|
|
85
|
+
(tmp_path / "pyproject.toml").write_text(
|
|
86
|
+
'[project]\nname = "tmp"\nversion = "0.0.0"\ndependencies = []\n', encoding="utf-8"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
findings = run_bloat_check(cwd=str(tmp_path), vulture_paths=["sample.py"])
|
|
90
|
+
|
|
91
|
+
assert any(f.tool == "vulture" for f in findings)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Real tests: unit coverage for the safe-removal filtering/editing logic,
|
|
2
|
+
plus a full end-to-end run against a real git repo (real branch, real
|
|
3
|
+
commit, real push to a local bare remote) with only `gh pr create` faked
|
|
4
|
+
-- there's no way to test against a real GitHub repo locally.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from safe_fix_mcp.bloat_check import BloatFinding
|
|
12
|
+
from safe_fix_mcp.bloat_pr import (
|
|
13
|
+
AUTO_PR_MIN_CONFIDENCE,
|
|
14
|
+
_auto_removable_imports,
|
|
15
|
+
_is_safe_single_import_line,
|
|
16
|
+
_remove_import_lines,
|
|
17
|
+
propose_bloat_removal_pr,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestIsSafeSingleImportLine:
|
|
22
|
+
def test_plain_import_is_safe(self, tmp_path):
|
|
23
|
+
f = tmp_path / "m.py"
|
|
24
|
+
f.write_text("import os\n")
|
|
25
|
+
assert _is_safe_single_import_line(f, 1) is True
|
|
26
|
+
|
|
27
|
+
def test_multi_name_import_is_not_safe(self, tmp_path):
|
|
28
|
+
f = tmp_path / "m.py"
|
|
29
|
+
f.write_text("from pathlib import Path, PurePath\n")
|
|
30
|
+
assert _is_safe_single_import_line(f, 1) is False
|
|
31
|
+
|
|
32
|
+
def test_non_import_line_is_not_safe(self, tmp_path):
|
|
33
|
+
f = tmp_path / "m.py"
|
|
34
|
+
f.write_text("x = 1\n")
|
|
35
|
+
assert _is_safe_single_import_line(f, 1) is False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestAutoRemovableImports:
|
|
39
|
+
def _finding(self, **overrides):
|
|
40
|
+
defaults = dict(tool="vulture", file="m.py", line=1, message="unused import 'os'", confidence=95)
|
|
41
|
+
return BloatFinding(**{**defaults, **overrides})
|
|
42
|
+
|
|
43
|
+
def test_includes_a_qualifying_finding(self, tmp_path):
|
|
44
|
+
(tmp_path / "m.py").write_text("import os\n")
|
|
45
|
+
findings = [self._finding()]
|
|
46
|
+
|
|
47
|
+
assert _auto_removable_imports(findings, str(tmp_path)) == findings
|
|
48
|
+
|
|
49
|
+
def test_excludes_low_confidence(self, tmp_path):
|
|
50
|
+
(tmp_path / "m.py").write_text("import os\n")
|
|
51
|
+
findings = [self._finding(confidence=AUTO_PR_MIN_CONFIDENCE - 1)]
|
|
52
|
+
|
|
53
|
+
assert _auto_removable_imports(findings, str(tmp_path)) == []
|
|
54
|
+
|
|
55
|
+
def test_excludes_multi_name_import_lines(self, tmp_path):
|
|
56
|
+
(tmp_path / "m.py").write_text("from pathlib import Path, PurePath\n")
|
|
57
|
+
findings = [self._finding()]
|
|
58
|
+
|
|
59
|
+
assert _auto_removable_imports(findings, str(tmp_path)) == []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestRemoveImportLines:
|
|
63
|
+
def test_removes_the_exact_line(self, tmp_path):
|
|
64
|
+
f = tmp_path / "m.py"
|
|
65
|
+
f.write_text("import os\nx = 1\n")
|
|
66
|
+
finding = BloatFinding(tool="vulture", file="m.py", line=1, message="unused import 'os'", confidence=95)
|
|
67
|
+
|
|
68
|
+
_remove_import_lines([finding], str(tmp_path))
|
|
69
|
+
|
|
70
|
+
assert f.read_text() == "x = 1\n"
|
|
71
|
+
|
|
72
|
+
def test_a_removal_above_does_not_corrupt_a_kept_line_below(self, tmp_path):
|
|
73
|
+
f = tmp_path / "m.py"
|
|
74
|
+
f.write_text("import os\ny = 2\nimport sys\n")
|
|
75
|
+
findings = [BloatFinding(tool="vulture", file="m.py", line=1, message="unused import 'os'", confidence=95)]
|
|
76
|
+
|
|
77
|
+
_remove_import_lines(findings, str(tmp_path))
|
|
78
|
+
|
|
79
|
+
assert f.read_text() == "y = 2\nimport sys\n"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _run_git(args, cwd, **kw):
|
|
83
|
+
return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True, **kw)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.fixture
|
|
87
|
+
def bloat_repo(tmp_path):
|
|
88
|
+
repo = tmp_path / "repo"
|
|
89
|
+
repo.mkdir()
|
|
90
|
+
_run_git(["init"], repo)
|
|
91
|
+
_run_git(["config", "user.email", "test@example.com"], repo)
|
|
92
|
+
_run_git(["config", "user.name", "Test"], repo)
|
|
93
|
+
|
|
94
|
+
(repo / "pyproject.toml").write_text(
|
|
95
|
+
'[project]\nname = "tmp"\nversion = "0.0.0"\ndependencies = []\n', encoding="utf-8"
|
|
96
|
+
)
|
|
97
|
+
(repo / "mod.py").write_text("import os\n\n\ndef used():\n return 1\n\n\nused()\n", encoding="utf-8")
|
|
98
|
+
(repo / "test_mod.py").write_text("def test_ok():\n assert True\n", encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
_run_git(["add", "-A"], repo)
|
|
101
|
+
_run_git(["commit", "-m", "initial commit"], repo)
|
|
102
|
+
return repo
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TestProposeBloatRemovalPr:
|
|
106
|
+
def test_dirty_worktree_is_refused_without_touching_anything(self, bloat_repo):
|
|
107
|
+
(bloat_repo / "mod.py").write_text("import os\nos.getcwd()\n", encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
result = propose_bloat_removal_pr(cwd=str(bloat_repo))
|
|
110
|
+
|
|
111
|
+
assert result.status == "dirty_worktree"
|
|
112
|
+
|
|
113
|
+
def test_opens_a_real_pr_when_tests_pass(self, bloat_repo, tmp_path):
|
|
114
|
+
bare_remote = tmp_path / "bare_remote.git"
|
|
115
|
+
_run_git(["init", "--bare", str(bare_remote)], bloat_repo)
|
|
116
|
+
_run_git(["remote", "add", "origin", str(bare_remote)], bloat_repo)
|
|
117
|
+
|
|
118
|
+
opened_prs = []
|
|
119
|
+
|
|
120
|
+
def fake_open_pr(branch, title, body, cwd):
|
|
121
|
+
opened_prs.append((branch, title, body))
|
|
122
|
+
return "https://github.com/fake/fake/pull/1"
|
|
123
|
+
|
|
124
|
+
result = propose_bloat_removal_pr(
|
|
125
|
+
cwd=str(bloat_repo),
|
|
126
|
+
run_tests=lambda cwd: True,
|
|
127
|
+
open_pr=fake_open_pr,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
assert result.status == "opened"
|
|
131
|
+
assert result.pr_url == "https://github.com/fake/fake/pull/1"
|
|
132
|
+
assert len(result.removed) == 1
|
|
133
|
+
remote_branches = _run_git(["ls-remote", "--heads", "origin"], bloat_repo).stdout
|
|
134
|
+
assert result.branch in remote_branches
|
|
135
|
+
assert (bloat_repo / "mod.py").read_text() == "\n\ndef used():\n return 1\n\n\nused()\n"
|
|
136
|
+
|
|
137
|
+
def test_aborts_cleanly_and_leaves_no_branch_when_tests_fail(self, bloat_repo):
|
|
138
|
+
original_branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], bloat_repo).stdout.strip()
|
|
139
|
+
|
|
140
|
+
result = propose_bloat_removal_pr(cwd=str(bloat_repo), run_tests=lambda cwd: False)
|
|
141
|
+
|
|
142
|
+
assert result.status == "tests_failed"
|
|
143
|
+
assert _run_git(["rev-parse", "--abbrev-ref", "HEAD"], bloat_repo).stdout.strip() == original_branch
|
|
144
|
+
assert _run_git(["status", "--porcelain"], bloat_repo).stdout.strip() == ""
|
|
145
|
+
assert result.branch not in _run_git(["branch"], bloat_repo).stdout
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Real MCP-protocol-level tests: calls go through `mcp.call_tool()` --
|
|
2
|
+
the actual dispatch path a real MCP client uses -- not the underlying
|
|
3
|
+
Python functions directly. Proves the MCP wrapping itself works, not
|
|
4
|
+
just the ported logic (already covered in test_bloat_check.py/
|
|
5
|
+
test_bloat_pr.py).
|
|
6
|
+
|
|
7
|
+
`propose_removal_pr`'s "opened" (real PR) happy path can't be tested
|
|
8
|
+
through the public tool interface -- it has no injection point by
|
|
9
|
+
design, and there's no real GitHub repo to test against locally. The
|
|
10
|
+
"pr_creation_failed but branch really pushed" path IS fully real and
|
|
11
|
+
testable here: `gh` genuinely isn't installed in this environment
|
|
12
|
+
(confirmed live), so a real scratch repo with a real local bare remote
|
|
13
|
+
exercises the complete real chain -- vulture scan, branch, removal, real
|
|
14
|
+
pytest run, commit, real push -- through nothing but the public MCP tool
|
|
15
|
+
call, with only the final `gh pr create` failing for a real reason.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import subprocess
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from safe_fix_mcp.server import mcp
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _text(result) -> str:
|
|
26
|
+
return result.content[0].text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestScanDeadCodeTool:
|
|
30
|
+
@pytest.mark.asyncio
|
|
31
|
+
async def test_reports_no_candidates_for_a_clean_repo(self, tmp_path):
|
|
32
|
+
(tmp_path / "clean.py").write_text("def used():\n return 1\n\n\nused()\n", encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
result = await mcp.call_tool("scan_dead_code", {"repo_path": str(tmp_path)})
|
|
35
|
+
|
|
36
|
+
assert _text(result) == "No real candidates found."
|
|
37
|
+
|
|
38
|
+
@pytest.mark.asyncio
|
|
39
|
+
async def test_reports_a_real_finding(self, tmp_path):
|
|
40
|
+
(tmp_path / "dirty.py").write_text("def totally_unused_xyz():\n return 1\n", encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
result = await mcp.call_tool("scan_dead_code", {"repo_path": str(tmp_path)})
|
|
43
|
+
|
|
44
|
+
assert "totally_unused_xyz" in _text(result)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _run_git(args, cwd, **kw):
|
|
48
|
+
return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True, **kw)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.fixture
|
|
52
|
+
def bloat_repo(tmp_path):
|
|
53
|
+
repo = tmp_path / "repo"
|
|
54
|
+
repo.mkdir()
|
|
55
|
+
_run_git(["init"], repo)
|
|
56
|
+
_run_git(["config", "user.email", "test@example.com"], repo)
|
|
57
|
+
_run_git(["config", "user.name", "Test"], repo)
|
|
58
|
+
(repo / "pyproject.toml").write_text(
|
|
59
|
+
'[project]\nname = "tmp"\nversion = "0.0.0"\ndependencies = []\n', encoding="utf-8"
|
|
60
|
+
)
|
|
61
|
+
(repo / "mod.py").write_text("import os\n\n\ndef used():\n return 1\n\n\nused()\n", encoding="utf-8")
|
|
62
|
+
(repo / "test_mod.py").write_text("def test_ok():\n assert True\n", encoding="utf-8")
|
|
63
|
+
_run_git(["add", "-A"], repo)
|
|
64
|
+
_run_git(["commit", "-m", "initial commit"], repo)
|
|
65
|
+
return repo
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestProposeRemovalPrTool:
|
|
69
|
+
@pytest.mark.asyncio
|
|
70
|
+
async def test_refuses_a_dirty_worktree_through_the_real_tool_call(self, bloat_repo):
|
|
71
|
+
(bloat_repo / "mod.py").write_text("import os\nos.getcwd()\n", encoding="utf-8")
|
|
72
|
+
|
|
73
|
+
result = await mcp.call_tool("propose_removal_pr", {"repo_path": str(bloat_repo)})
|
|
74
|
+
|
|
75
|
+
assert _text(result).startswith("Refused:")
|
|
76
|
+
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_real_end_to_end_through_the_public_tool_gh_missing_in_this_environment(
|
|
79
|
+
self, bloat_repo, tmp_path
|
|
80
|
+
):
|
|
81
|
+
# No mocks anywhere in this test: real vulture scan, real branch,
|
|
82
|
+
# real file edit, real pytest run against the scratch repo's own
|
|
83
|
+
# test_mod.py, real commit, real push to a real local bare
|
|
84
|
+
# remote -- only gh pr create fails, because gh really isn't
|
|
85
|
+
# installed here (confirmed live before writing this test).
|
|
86
|
+
bare_remote = tmp_path / "bare_remote.git"
|
|
87
|
+
_run_git(["init", "--bare", str(bare_remote)], bloat_repo)
|
|
88
|
+
_run_git(["remote", "add", "origin", str(bare_remote)], bloat_repo)
|
|
89
|
+
|
|
90
|
+
result = await mcp.call_tool("propose_removal_pr", {"repo_path": str(bloat_repo)})
|
|
91
|
+
|
|
92
|
+
text = _text(result)
|
|
93
|
+
assert "committed and pushed" in text
|
|
94
|
+
assert "PR creation failed" in text
|
|
95
|
+
remote_branches = _run_git(["ls-remote", "--heads", "origin"], bloat_repo).stdout
|
|
96
|
+
assert remote_branches.strip() != ""
|
|
97
|
+
assert (bloat_repo / "mod.py").read_text() == "\n\ndef used():\n return 1\n\n\nused()\n"
|