ghostbuster-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ghostbuster/__init__.py +3 -0
- ghostbuster/__main__.py +5 -0
- ghostbuster/cli/__init__.py +1 -0
- ghostbuster/cli/app.py +76 -0
- ghostbuster/cli/bust.py +116 -0
- ghostbuster/cli/display.py +276 -0
- ghostbuster/cli/scan.py +141 -0
- ghostbuster/config.py +116 -0
- ghostbuster/core/__init__.py +1 -0
- ghostbuster/core/dead_imports.py +273 -0
- ghostbuster/core/models.py +140 -0
- ghostbuster/core/orphan_files.py +298 -0
- ghostbuster/core/phantom_env.py +196 -0
- ghostbuster/core/scanner.py +112 -0
- ghostbuster/core/scoring.py +83 -0
- ghostbuster/core/zombie_code.py +345 -0
- ghostbuster/fixers/__init__.py +1 -0
- ghostbuster/fixers/import_fixer.py +133 -0
- ghostbuster_cli-0.1.0.dist-info/METADATA +239 -0
- ghostbuster_cli-0.1.0.dist-info/RECORD +23 -0
- ghostbuster_cli-0.1.0.dist-info/WHEEL +4 -0
- ghostbuster_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ghostbuster_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Zombie Code Scanner — detects functions and classes that are never called.
|
|
2
|
+
|
|
3
|
+
Uses AST analysis to find definitions and cross-reference them against
|
|
4
|
+
all name references in the codebase.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ghostbuster.core.models import Ghost, GhostCategory, Severity
|
|
14
|
+
|
|
15
|
+
# Decorators that indicate a function is used externally (not dead)
|
|
16
|
+
FRAMEWORK_DECORATORS: set[str] = {
|
|
17
|
+
# Web frameworks
|
|
18
|
+
"route",
|
|
19
|
+
"get",
|
|
20
|
+
"post",
|
|
21
|
+
"put",
|
|
22
|
+
"delete",
|
|
23
|
+
"patch",
|
|
24
|
+
"head",
|
|
25
|
+
"options",
|
|
26
|
+
"api_view",
|
|
27
|
+
"action",
|
|
28
|
+
"app_route",
|
|
29
|
+
# Testing
|
|
30
|
+
"fixture",
|
|
31
|
+
"pytest_fixture",
|
|
32
|
+
"parametrize",
|
|
33
|
+
"override_settings",
|
|
34
|
+
"mock_patch",
|
|
35
|
+
# General
|
|
36
|
+
"property",
|
|
37
|
+
"staticmethod",
|
|
38
|
+
"classmethod",
|
|
39
|
+
"abstractmethod",
|
|
40
|
+
"cached_property",
|
|
41
|
+
"lru_cache",
|
|
42
|
+
"cache",
|
|
43
|
+
"register",
|
|
44
|
+
"receiver",
|
|
45
|
+
"hook",
|
|
46
|
+
"hookimpl",
|
|
47
|
+
"validator",
|
|
48
|
+
"field_validator",
|
|
49
|
+
"model_validator",
|
|
50
|
+
"event",
|
|
51
|
+
"listener",
|
|
52
|
+
"subscriber",
|
|
53
|
+
"handler",
|
|
54
|
+
"task",
|
|
55
|
+
"shared_task",
|
|
56
|
+
"periodic_task",
|
|
57
|
+
"command",
|
|
58
|
+
"group",
|
|
59
|
+
"callback",
|
|
60
|
+
"click_command",
|
|
61
|
+
"click_group",
|
|
62
|
+
"app_command",
|
|
63
|
+
"app_group",
|
|
64
|
+
"overload",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Method names that are expected to exist by convention (dunder, lifecycle, etc.)
|
|
68
|
+
CONVENTIONAL_NAMES: set[str] = {
|
|
69
|
+
# Dunder methods
|
|
70
|
+
"__init__",
|
|
71
|
+
"__new__",
|
|
72
|
+
"__del__",
|
|
73
|
+
"__repr__",
|
|
74
|
+
"__str__",
|
|
75
|
+
"__bytes__",
|
|
76
|
+
"__format__",
|
|
77
|
+
"__hash__",
|
|
78
|
+
"__bool__",
|
|
79
|
+
"__len__",
|
|
80
|
+
"__length_hint__",
|
|
81
|
+
"__getitem__",
|
|
82
|
+
"__setitem__",
|
|
83
|
+
"__delitem__",
|
|
84
|
+
"__iter__",
|
|
85
|
+
"__next__",
|
|
86
|
+
"__reversed__",
|
|
87
|
+
"__contains__",
|
|
88
|
+
"__add__",
|
|
89
|
+
"__radd__",
|
|
90
|
+
"__iadd__",
|
|
91
|
+
"__sub__",
|
|
92
|
+
"__mul__",
|
|
93
|
+
"__truediv__",
|
|
94
|
+
"__floordiv__",
|
|
95
|
+
"__mod__",
|
|
96
|
+
"__pow__",
|
|
97
|
+
"__lshift__",
|
|
98
|
+
"__rshift__",
|
|
99
|
+
"__and__",
|
|
100
|
+
"__xor__",
|
|
101
|
+
"__or__",
|
|
102
|
+
"__neg__",
|
|
103
|
+
"__pos__",
|
|
104
|
+
"__abs__",
|
|
105
|
+
"__invert__",
|
|
106
|
+
"__complex__",
|
|
107
|
+
"__int__",
|
|
108
|
+
"__float__",
|
|
109
|
+
"__index__",
|
|
110
|
+
"__enter__",
|
|
111
|
+
"__exit__",
|
|
112
|
+
"__await__",
|
|
113
|
+
"__aiter__",
|
|
114
|
+
"__anext__",
|
|
115
|
+
"__aenter__",
|
|
116
|
+
"__aexit__",
|
|
117
|
+
"__call__",
|
|
118
|
+
"__get__",
|
|
119
|
+
"__set__",
|
|
120
|
+
"__delete__",
|
|
121
|
+
"__set_name__",
|
|
122
|
+
"__init_subclass__",
|
|
123
|
+
"__class_getitem__",
|
|
124
|
+
"__eq__",
|
|
125
|
+
"__ne__",
|
|
126
|
+
"__lt__",
|
|
127
|
+
"__le__",
|
|
128
|
+
"__gt__",
|
|
129
|
+
"__ge__",
|
|
130
|
+
"__getattr__",
|
|
131
|
+
"__getattribute__",
|
|
132
|
+
"__setattr__",
|
|
133
|
+
"__delattr__",
|
|
134
|
+
"__dir__",
|
|
135
|
+
"__slots__",
|
|
136
|
+
"__dict__",
|
|
137
|
+
"__weakref__",
|
|
138
|
+
"__reduce__",
|
|
139
|
+
"__reduce_ex__",
|
|
140
|
+
"__getstate__",
|
|
141
|
+
"__setstate__",
|
|
142
|
+
"__copy__",
|
|
143
|
+
"__deepcopy__",
|
|
144
|
+
"__sizeof__",
|
|
145
|
+
"__fspath__",
|
|
146
|
+
"__missing__",
|
|
147
|
+
"__post_init__",
|
|
148
|
+
# Lifecycle / Framework hooks
|
|
149
|
+
"setUp",
|
|
150
|
+
"tearDown",
|
|
151
|
+
"setUpClass",
|
|
152
|
+
"tearDownClass",
|
|
153
|
+
"setUpModule",
|
|
154
|
+
"tearDownModule",
|
|
155
|
+
"setup_method",
|
|
156
|
+
"teardown_method",
|
|
157
|
+
"main",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class _Definition:
|
|
163
|
+
"""A function or class definition found in the codebase."""
|
|
164
|
+
|
|
165
|
+
name: str
|
|
166
|
+
file_path: Path
|
|
167
|
+
line_number: int
|
|
168
|
+
is_method: bool = False
|
|
169
|
+
is_class: bool = False
|
|
170
|
+
has_framework_decorator: bool = False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ZombieCodeScanner:
|
|
174
|
+
"""Detects functions and classes that are never called/referenced."""
|
|
175
|
+
|
|
176
|
+
name = "zombie-code"
|
|
177
|
+
|
|
178
|
+
def scan(self, path: Path) -> list[Ghost]:
|
|
179
|
+
"""Scan a project directory for zombie code."""
|
|
180
|
+
definitions: list[_Definition] = []
|
|
181
|
+
references: set[str] = set()
|
|
182
|
+
|
|
183
|
+
for py_file in path.rglob("*.py"):
|
|
184
|
+
# Skip non-source directories
|
|
185
|
+
parts = py_file.relative_to(path).parts
|
|
186
|
+
if any(
|
|
187
|
+
p
|
|
188
|
+
in {
|
|
189
|
+
"venv",
|
|
190
|
+
".venv",
|
|
191
|
+
"node_modules",
|
|
192
|
+
".git",
|
|
193
|
+
"__pycache__",
|
|
194
|
+
".tox",
|
|
195
|
+
".nox",
|
|
196
|
+
"build",
|
|
197
|
+
"dist",
|
|
198
|
+
"migrations",
|
|
199
|
+
".eggs",
|
|
200
|
+
}
|
|
201
|
+
for p in parts
|
|
202
|
+
):
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
source = py_file.read_text(encoding="utf-8", errors="ignore")
|
|
207
|
+
tree = ast.parse(source, filename=str(py_file))
|
|
208
|
+
except (SyntaxError, OSError):
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
# Collect definitions and references from this file
|
|
212
|
+
file_defs, file_refs = self._analyze_file(tree, py_file)
|
|
213
|
+
definitions.extend(file_defs)
|
|
214
|
+
references.update(file_refs)
|
|
215
|
+
|
|
216
|
+
# Find zombies: defined but never referenced
|
|
217
|
+
ghosts: list[Ghost] = []
|
|
218
|
+
for defn in definitions:
|
|
219
|
+
if self._is_zombie(defn, references):
|
|
220
|
+
rel_path = (
|
|
221
|
+
defn.file_path.relative_to(path)
|
|
222
|
+
if path in defn.file_path.parents or path == defn.file_path.parent
|
|
223
|
+
else defn.file_path
|
|
224
|
+
)
|
|
225
|
+
kind = "Class" if defn.is_class else "Function"
|
|
226
|
+
action = "referenced" if defn.is_class else "called"
|
|
227
|
+
ghosts.append(
|
|
228
|
+
Ghost(
|
|
229
|
+
category=GhostCategory.ZOMBIE_CODE,
|
|
230
|
+
name=defn.name,
|
|
231
|
+
message=f"{kind} '{defn.name}' is defined at {rel_path}:{defn.line_number} but never {action}",
|
|
232
|
+
file_path=defn.file_path,
|
|
233
|
+
line_number=defn.line_number,
|
|
234
|
+
severity=Severity.LOW,
|
|
235
|
+
fixable=False,
|
|
236
|
+
suggestion=f"Remove '{defn.name}' if it's truly unused, or add '# noqa: ghostbuster' to suppress",
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return ghosts
|
|
241
|
+
|
|
242
|
+
def _analyze_file(
|
|
243
|
+
self, tree: ast.Module, file_path: Path
|
|
244
|
+
) -> tuple[list[_Definition], set[str]]:
|
|
245
|
+
"""Extract function/class definitions and name references from a file."""
|
|
246
|
+
definitions: list[_Definition] = []
|
|
247
|
+
references: set[str] = set()
|
|
248
|
+
|
|
249
|
+
# Check if this is a test file (test files are excluded from zombie detection)
|
|
250
|
+
is_test_file = file_path.name.startswith("test_") or file_path.name.endswith("_test.py")
|
|
251
|
+
|
|
252
|
+
for node in ast.walk(tree):
|
|
253
|
+
# Collect function definitions
|
|
254
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
255
|
+
# Skip test functions entirely
|
|
256
|
+
if is_test_file or node.name.startswith("test_"):
|
|
257
|
+
references.add(node.name)
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
is_method = self._is_method(node, tree)
|
|
261
|
+
has_decorator = self._has_framework_decorator(node)
|
|
262
|
+
|
|
263
|
+
definitions.append(
|
|
264
|
+
_Definition(
|
|
265
|
+
name=node.name,
|
|
266
|
+
file_path=file_path,
|
|
267
|
+
line_number=node.lineno,
|
|
268
|
+
is_method=is_method,
|
|
269
|
+
has_framework_decorator=has_decorator,
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Collect class definitions
|
|
274
|
+
elif isinstance(node, ast.ClassDef):
|
|
275
|
+
if not is_test_file:
|
|
276
|
+
definitions.append(
|
|
277
|
+
_Definition(
|
|
278
|
+
name=node.name,
|
|
279
|
+
file_path=file_path,
|
|
280
|
+
line_number=node.lineno,
|
|
281
|
+
is_class=True,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Collect references (names being used)
|
|
286
|
+
elif isinstance(node, ast.Name):
|
|
287
|
+
references.add(node.id)
|
|
288
|
+
elif isinstance(node, ast.Attribute):
|
|
289
|
+
references.add(node.attr)
|
|
290
|
+
elif isinstance(node, ast.Call):
|
|
291
|
+
if isinstance(node.func, ast.Name):
|
|
292
|
+
references.add(node.func.id)
|
|
293
|
+
elif isinstance(node.func, ast.Attribute):
|
|
294
|
+
references.add(node.func.attr)
|
|
295
|
+
|
|
296
|
+
return definitions, references
|
|
297
|
+
|
|
298
|
+
def _is_method(self, node: ast.FunctionDef | ast.AsyncFunctionDef, tree: ast.Module) -> bool:
|
|
299
|
+
"""Check if a function is defined inside a class (i.e., is a method)."""
|
|
300
|
+
for parent in ast.walk(tree):
|
|
301
|
+
if isinstance(parent, ast.ClassDef):
|
|
302
|
+
for child in parent.body:
|
|
303
|
+
if child is node:
|
|
304
|
+
return True
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
def _has_framework_decorator(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
308
|
+
"""Check if a function has a decorator that indicates external usage."""
|
|
309
|
+
for decorator in node.decorator_list:
|
|
310
|
+
dec_name = self._get_decorator_name(decorator)
|
|
311
|
+
if dec_name and dec_name.lower() in FRAMEWORK_DECORATORS:
|
|
312
|
+
return True
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
def _get_decorator_name(self, node: ast.expr) -> str | None:
|
|
316
|
+
"""Extract the base name from a decorator node."""
|
|
317
|
+
if isinstance(node, ast.Name):
|
|
318
|
+
return node.id
|
|
319
|
+
elif isinstance(node, ast.Attribute):
|
|
320
|
+
return node.attr
|
|
321
|
+
elif isinstance(node, ast.Call):
|
|
322
|
+
return self._get_decorator_name(node.func)
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
def _is_zombie(self, defn: _Definition, references: set[str]) -> bool:
|
|
326
|
+
"""Determine if a definition is truly a zombie (unreferenced)."""
|
|
327
|
+
# Skip conventional names (dunders, lifecycle hooks)
|
|
328
|
+
if defn.name in CONVENTIONAL_NAMES:
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
# Skip names starting with underscore in test patterns
|
|
332
|
+
if defn.name.startswith("test_"):
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
# Skip if it has a framework decorator
|
|
336
|
+
if defn.has_framework_decorator:
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
# Skip private methods with a single underscore (often used by framework)
|
|
340
|
+
# But flag double-underscore non-dunder names
|
|
341
|
+
if defn.is_method and defn.name.startswith("_") and not defn.name.startswith("__"):
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
# The core check: is the name referenced anywhere?
|
|
345
|
+
return defn.name not in references
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Auto-fix modules for resolving ghost findings."""
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Import Fixer — removes unused import statements from Python files.
|
|
2
|
+
|
|
3
|
+
Works with the dead-import scanner findings to automatically clean up
|
|
4
|
+
import statements that reference packages no longer needed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ghostbuster.core.models import Ghost, GhostCategory
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ImportFixer:
|
|
16
|
+
"""Removes unused import statements from Python files.
|
|
17
|
+
|
|
18
|
+
This fixer works on a per-file basis, removing import lines that
|
|
19
|
+
reference packages identified as dead imports.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def preview(self, ghosts: list[Ghost], path: Path) -> list[str]:
|
|
23
|
+
"""Preview what changes would be made (dry-run mode).
|
|
24
|
+
|
|
25
|
+
Returns a list of human-readable descriptions of changes.
|
|
26
|
+
"""
|
|
27
|
+
changes: list[str] = []
|
|
28
|
+
dead_packages = self._get_dead_package_names(ghosts)
|
|
29
|
+
|
|
30
|
+
for py_file in path.rglob("*.py"):
|
|
31
|
+
parts = py_file.relative_to(path).parts
|
|
32
|
+
if any(p in {"venv", ".venv", "node_modules", ".git", "__pycache__"} for p in parts):
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
file_changes = self._find_removable_imports(py_file, dead_packages)
|
|
36
|
+
changes.extend(file_changes)
|
|
37
|
+
|
|
38
|
+
return changes
|
|
39
|
+
|
|
40
|
+
def fix(self, ghosts: list[Ghost], path: Path) -> list[str]:
|
|
41
|
+
"""Actually apply fixes: remove unused import statements.
|
|
42
|
+
|
|
43
|
+
Returns a list of applied changes.
|
|
44
|
+
"""
|
|
45
|
+
applied: list[str] = []
|
|
46
|
+
dead_packages = self._get_dead_package_names(ghosts)
|
|
47
|
+
|
|
48
|
+
for py_file in path.rglob("*.py"):
|
|
49
|
+
parts = py_file.relative_to(path).parts
|
|
50
|
+
if any(p in {"venv", ".venv", "node_modules", ".git", "__pycache__"} for p in parts):
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
result = self._remove_imports_from_file(py_file, dead_packages)
|
|
54
|
+
applied.extend(result)
|
|
55
|
+
|
|
56
|
+
return applied
|
|
57
|
+
|
|
58
|
+
def _get_dead_package_names(self, ghosts: list[Ghost]) -> set[str]:
|
|
59
|
+
"""Extract package names from dead-import ghost findings."""
|
|
60
|
+
return {
|
|
61
|
+
g.name.lower().replace("-", "_")
|
|
62
|
+
for g in ghosts
|
|
63
|
+
if g.category == GhostCategory.DEAD_IMPORT
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def _find_removable_imports(self, filepath: Path, dead_packages: set[str]) -> list[str]:
|
|
67
|
+
"""Find import statements that can be removed from a file."""
|
|
68
|
+
changes: list[str] = []
|
|
69
|
+
try:
|
|
70
|
+
source = filepath.read_text(encoding="utf-8", errors="ignore")
|
|
71
|
+
tree = ast.parse(source, filename=str(filepath))
|
|
72
|
+
except (SyntaxError, OSError):
|
|
73
|
+
return changes
|
|
74
|
+
|
|
75
|
+
for node in ast.walk(tree):
|
|
76
|
+
if isinstance(node, ast.Import):
|
|
77
|
+
for alias in node.names:
|
|
78
|
+
pkg = alias.name.split(".")[0].lower().replace("-", "_")
|
|
79
|
+
if pkg in dead_packages:
|
|
80
|
+
changes.append(
|
|
81
|
+
f" Would remove: 'import {alias.name}' from {filepath.name}:{node.lineno}"
|
|
82
|
+
)
|
|
83
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
84
|
+
pkg = node.module.split(".")[0].lower().replace("-", "_")
|
|
85
|
+
if pkg in dead_packages:
|
|
86
|
+
names = ", ".join(a.name for a in node.names)
|
|
87
|
+
changes.append(
|
|
88
|
+
f" Would remove: 'from {node.module} import {names}' from {filepath.name}:{node.lineno}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return changes
|
|
92
|
+
|
|
93
|
+
def _remove_imports_from_file(self, filepath: Path, dead_packages: set[str]) -> list[str]:
|
|
94
|
+
"""Remove import statements for dead packages from a single file."""
|
|
95
|
+
applied: list[str] = []
|
|
96
|
+
try:
|
|
97
|
+
source = filepath.read_text(encoding="utf-8", errors="ignore")
|
|
98
|
+
tree = ast.parse(source, filename=str(filepath))
|
|
99
|
+
except (SyntaxError, OSError):
|
|
100
|
+
return applied
|
|
101
|
+
|
|
102
|
+
lines_to_remove: set[int] = set()
|
|
103
|
+
|
|
104
|
+
for node in ast.walk(tree):
|
|
105
|
+
if isinstance(node, ast.Import):
|
|
106
|
+
for alias in node.names:
|
|
107
|
+
pkg = alias.name.split(".")[0].lower().replace("-", "_")
|
|
108
|
+
if pkg in dead_packages:
|
|
109
|
+
lines_to_remove.add(node.lineno)
|
|
110
|
+
applied.append(
|
|
111
|
+
f" Removed: 'import {alias.name}' from {filepath.name}:{node.lineno}"
|
|
112
|
+
)
|
|
113
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
114
|
+
pkg = node.module.split(".")[0].lower().replace("-", "_")
|
|
115
|
+
if pkg in dead_packages:
|
|
116
|
+
lines_to_remove.add(node.lineno)
|
|
117
|
+
# Handle multi-line imports
|
|
118
|
+
if node.end_lineno and node.end_lineno > node.lineno:
|
|
119
|
+
for ln in range(node.lineno, node.end_lineno + 1):
|
|
120
|
+
lines_to_remove.add(ln)
|
|
121
|
+
names = ", ".join(a.name for a in node.names)
|
|
122
|
+
applied.append(
|
|
123
|
+
f" Removed: 'from {node.module} import {names}' from {filepath.name}:{node.lineno}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if lines_to_remove:
|
|
127
|
+
source_lines = source.splitlines(keepends=True)
|
|
128
|
+
new_lines = [
|
|
129
|
+
line for i, line in enumerate(source_lines, start=1) if i not in lines_to_remove
|
|
130
|
+
]
|
|
131
|
+
filepath.write_text("".join(new_lines), encoding="utf-8")
|
|
132
|
+
|
|
133
|
+
return applied
|