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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ venv/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
@@ -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"