|
27 | 27 | FORMAT_CHOICES = click.Choice(["pretty", "compact", "github", "json"]) |
28 | 28 |
|
29 | 29 |
|
| 30 | +def _line_self_contained(text: str) -> bool: |
| 31 | + """Return True if a line's brackets/braces/parens are balanced on that line. |
| 32 | +
|
| 33 | + Used by ``remove`` to decide whether a single reported line can be safely |
| 34 | + blanked. A balanced line is a complete one-liner (``export const X = 1;`` or |
| 35 | + ``.foo { color: red; }``); a line that opens a brace/bracket/paren it never |
| 36 | + closes is the start of a multi-line construct and must not be blanked in |
| 37 | + isolation. String and template-literal contents are ignored so brackets |
| 38 | + inside quotes don't skew the count. |
| 39 | + """ |
| 40 | + depth = 0 |
| 41 | + in_str: str | None = None |
| 42 | + escaped = False |
| 43 | + for ch in text: |
| 44 | + if escaped: |
| 45 | + escaped = False |
| 46 | + continue |
| 47 | + if ch == "\\": |
| 48 | + escaped = True |
| 49 | + continue |
| 50 | + if in_str is not None: |
| 51 | + if ch == in_str: |
| 52 | + in_str = None |
| 53 | + elif ch in ("'", '"', "`"): |
| 54 | + in_str = ch |
| 55 | + elif ch in "([{": |
| 56 | + depth += 1 |
| 57 | + elif ch in ")]}": |
| 58 | + depth -= 1 |
| 59 | + if depth < 0: # closes something opened on an earlier line |
| 60 | + return False |
| 61 | + return depth == 0 and in_str is None |
| 62 | + |
| 63 | + |
30 | 64 | @click.group() |
31 | 65 | @click.option("--project", "-p", default=".", help="Project directory to scan") |
32 | 66 | @click.option( |
@@ -315,24 +349,43 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None: |
315 | 349 | console.print(f"[red]Error reading {rel_file}: {e}[/red]") |
316 | 350 | continue |
317 | 351 |
|
318 | | - # Remove lines in reverse order to preserve line numbers |
319 | | - lines_to_remove = sorted(set(f.line for f in file_findings), reverse=True) |
| 352 | + # Findings carry only a start line, no span. Blanking a single line of a |
| 353 | + # multi-line construct (a multi-line `export { ... }`, a CSS rule, or a |
| 354 | + # component body) leaves dangling, syntactically-broken code — worse than |
| 355 | + # doing nothing. DeadCode is regex-based with no AST, so guard |
| 356 | + # conservatively: only blank a line whose brackets/braces/parens are |
| 357 | + # balanced on that line (it's a self-contained one-liner). Anything that |
| 358 | + # opens an unclosed block is skipped and reported for manual removal. |
| 359 | + candidate_lines = sorted(set(f.line for f in file_findings), reverse=True) |
| 360 | + safe_lines = [ |
| 361 | + n |
| 362 | + for n in candidate_lines |
| 363 | + if 0 < n <= len(lines) and _line_self_contained(lines[n - 1]) |
| 364 | + ] |
| 365 | + skipped_lines = [n for n in candidate_lines if n not in safe_lines] |
320 | 366 |
|
321 | 367 | if dry_run: |
322 | | - for line_num in sorted(lines_to_remove): |
323 | | - content = lines[line_num - 1].rstrip() if line_num <= len(lines) else "" |
| 368 | + for line_num in sorted(safe_lines): |
| 369 | + content = lines[line_num - 1].strip() |
324 | 370 | console.print( |
325 | | - f"[yellow]WOULD REMOVE[/yellow] {rel_file}:{line_num} — {content.strip()[:80]}" |
| 371 | + f"[yellow]WOULD REMOVE[/yellow] {rel_file}:{line_num} — {content[:80]}" |
326 | 372 | ) |
327 | | - removed_count += len(lines_to_remove) |
| 373 | + removed_count += len(safe_lines) |
328 | 374 | else: |
329 | | - for line_num in lines_to_remove: |
330 | | - if 0 < line_num <= len(lines): |
331 | | - lines[line_num - 1] = "" # Blank the line (safer than deleting) |
332 | | - filepath.write_text("".join(lines), encoding="utf-8") |
333 | | - removed_count += len(lines_to_remove) |
| 375 | + for line_num in safe_lines: |
| 376 | + lines[line_num - 1] = "" # Blank the line (safer than deleting) |
| 377 | + if safe_lines: |
| 378 | + filepath.write_text("".join(lines), encoding="utf-8") |
| 379 | + removed_count += len(safe_lines) |
| 380 | + console.print( |
| 381 | + f"[green]✓[/green] Cleaned {rel_file} ({len(safe_lines)} lines)" |
| 382 | + ) |
| 383 | + |
| 384 | + for line_num in sorted(skipped_lines): |
| 385 | + content = lines[line_num - 1].strip() if 0 < line_num <= len(lines) else "" |
334 | 386 | console.print( |
335 | | - f"[green]✓[/green] Cleaned {rel_file} ({len(lines_to_remove)} lines)" |
| 387 | + f"[yellow]⚠ SKIPPED (multi-line — remove manually)[/yellow] " |
| 388 | + f"{rel_file}:{line_num} — {content[:80]}" |
336 | 389 | ) |
337 | 390 |
|
338 | 391 | action = "Would remove" if dry_run else "Removed" |
|
0 commit comments