[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/Coding-Dev-Tools/deadcode/master/src/deadcode/cli.py [Back]  [Original]

"""DeadCode CLI  Detect and remove unused code in TS/React/Next.js projects."""

from __future__ import annotations

import json
import sys
from pathlib import Path

import click
from rich.console import Console
from rich.table import Table

from . import __version__
from .config import DeadCodeConfig
from .scanner import DeadCodeScanner, Finding

console = Console()
err_console = Console(stderr=True)

FORMAT_HELP = "Output format: pretty (default), compact, github, or json"
ALL_CATEGORIES = [
    "unused_export",
    "dead_route",
    "orphaned_css",
    "unreferenced_component",
]
FORMAT_CHOICES = click.Choice(["pretty", "compact", "github", "json"])


def _line_self_contained(text: str) -> bool:
    """Return True if a line's brackets/braces/parens are balanced on that line.

    Used by ``remove`` to decide whether a single reported line can be safely
    blanked. A balanced line is a complete one-liner (``export const X = 1;`` or
    ``.foo { color: red; }``); a line that opens a brace/bracket/paren it never
    closes is the start of a multi-line construct and must not be blanked in
    isolation. String and template-literal contents are ignored so brackets
    inside quotes don't skew the count.
    """
    depth = 0
    in_str: str | None = None
    escaped = False
    for ch in text:
        if escaped:
            escaped = False
            continue
        if ch == "\\":
            escaped = True
            continue
        if in_str is not None:
            if ch == in_str:
                in_str = None
        elif ch in ("'", '"', "`"):
            in_str = ch
        elif ch in "([{":
            depth += 1
        elif ch in ")]}":
            depth -= 1
            if depth < 0:  # closes something opened on an earlier line
                return False
    return depth == 0 and in_str is None


@click.group()
@click.option("--project", "-p", default=".", help="Project directory to scan")
@click.option(
    "--ignore", "-i", multiple=True, help="Additional ignore patterns (gitignore-style)"
)
@click.option(
    "--include",
    multiple=True,
    help="Include only matching files (gitignore-style whitelist)",
)
@click.version_option(__version__, prog_name="deadcode")
@click.pass_context
def cli(
    ctx: click.Context, project: str, ignore: tuple[str, ...], include: tuple[str, ...]
) -> None:
    """DeadCode  Find and remove dead code in TS/React/Next.js projects.

    Scans for unused exports, dead routes, orphaned CSS classes,
    and unreferenced components.
    """
    ctx.ensure_object(dict)
    ctx.obj["project"] = project
    ctx.obj["ignore"] = list(ignore) if ignore else None
    ctx.obj["include"] = list(include) if include else None
    # Load .deadcode.yml config
    ctx.obj["config"] = DeadCodeConfig.load(project)


def _merge_config_ignore(ctx: click.Context) -> list[str] | None:
    """Merge CLI --ignore flags with .deadcode.yml ignore patterns."""
    cli_ignore = ctx.obj.get("ignore")
    config = ctx.obj.get("config")
    config_ignore = config.ignore if config else []

    if cli_ignore and config_ignore:
        return config_ignore + cli_ignore
    if cli_ignore:
        return cli_ignore
    if config_ignore:
        return config_ignore
    return None


def _get_fail_threshold(ctx: click.Context) -> int:
    """Get fail threshold from config."""
    config = ctx.obj.get("config")
    return config.fail_threshold if config else -1


#  scan 


@cli.command()
@click.option(
    "--json-output", "-j", is_flag=True, help="Alias for --format=json (deprecated)"
)
@click.option("--format", type=FORMAT_CHOICES, default="pretty", help=FORMAT_HELP)
@click.option(
    "--category",
    "-c",
    type=click.Choice(ALL_CATEGORIES),
    default=None,
    help="Filter by category",
)
@click.option(
    "--fail",
    "fail_threshold",
    type=int,
    default=None,
    help="Exit code 1 if findings >= threshold (overrides .deadcode.yml)",
)
@click.pass_context
def scan(
    ctx: click.Context,
    json_output: bool,
    format: str | None,
    category: str | None,
    fail_threshold: int | None,
) -> None:
    """Scan project for dead code."""
    project = ctx.obj["project"]
    ignore = _merge_config_ignore(ctx)

    if not Path(project).exists():
        err_console.print(f"[red]Project directory '{project}' not found.[/red]")
        sys.exit(1)

    include_patterns = ctx.obj.get("include")
    scanner = DeadCodeScanner(
        project, ignore_patterns=ignore, include_patterns=include_patterns
    )
    result = scanner.scan()

    # Filter by category
    findings = result.findings
    if category:
        findings = [f for f in findings if f.category == category]

    # Also respect config-level category filter if no CLI override
    config = ctx.obj.get("config")
    if not category and config and config.categories:
        findings = [f for f in findings if f.category in config.categories]

    # Determine effective format (legacy --json-output maps to json)
    effective_format = "json" if json_output else (format or "pretty")

    if effective_format == "json":
        output = {
            "files_scanned": result.files_scanned,
            "findings": [
                {
                    "file": f.file,
                    "line": f.line,
                    "name": f.name,
                    "category": f.category,
                    "detail": f.detail,
                    "removable": f.removable,
                }
                for f in findings
            ],
            "errors": result.errors,
        }
        console.print(json.dumps(output, indent=2, default=str))
    elif effective_format == "compact":
        if not findings:
            console.print("OK  0 findings")
        else:
            for f in findings:
                console.print(f"{f.file}:{f.line} \u2014 {f.category}: {f.name}")
            console.print(f"\n{len(findings)} findings")
    elif effective_format == "github":
        # GitHub Actions annotation syntax
        # ::warning file={name},line={line},endLine={line}::{message}
        if not findings:
            console.print("deadcode: 0 findings")
        else:
            for f in findings:
                level = "error" if f.removable else "warning"
                msg = f"{f.category}: {f.name}"
                if f.detail:
                    msg += f" ({f.detail[:120]})"
                console.print(f"::{level} file={f.file},line={f.line}::{msg}")
            console.print(f"\n::notice::deadcode: {len(findings)} findings")
    else:
        # Summary
        console.print(
            f"\n[bold]DeadCode Scan[/bold]  {result.files_scanned} files scanned\n"
        )

        if not findings:
            console.print("[green] No dead code found![/green]")
        else:
            # Group by category
            by_category: dict[str, list[Finding]] = {}
            for f in findings:
                by_category.setdefault(f.category, []).append(f)

            category_labels = {
                "unused_export": "Unused Exports",
                "dead_route": "Dead Routes",
                "orphaned_css": "Orphaned CSS",
                "unreferenced_component": "Unreferenced Components",
            }

            for cat, cat_findings in by_category.items():
                label = category_labels.get(cat, cat)
                console.print(
                    f"\n[bold yellow]{label}[/bold yellow] ({len(cat_findings)})"
                )

                table = Table(show_header=True)
                table.add_column("File", )
                table.add_column("Line", , justify="right")
                table.add_column("Name", )
                table.add_column("Detail")

                for f in cat_findings[:50]:  # Limit display
                    table.add_row(f.file, str(f.line), f.name, f.detail[:60])

                console.print(table)
                if len(cat_findings) > 50:
                    console.print(f"  [dim]... and {len(cat_findings) - 50} more[/dim]")

            # Total
            removable = sum(1 for f in findings if f.removable)
            console.print(
                f"\n[bold]Total:[/bold] {len(findings)} findings ({removable} removable)"
            )

        if result.errors:
            console.print(
                f"\n[yellow]{len(result.errors)} scan errors (use --json-output to see)[/yellow]"
            )

    # CI fail threshold
    effective_threshold = (
        fail_threshold if fail_threshold is not None else _get_fail_threshold(ctx)
    )
    if effective_threshold >= 0 and len(findings) >= effective_threshold:
        if effective_format not in ("json", "github"):
            console.print(
                f"\n[red]FAIL: {len(findings)} findings >= threshold {effective_threshold}[/red]"
            )
        sys.exit(1)


#  remove 


@cli.command()
@click.option(
    "--dry-run",
    is_flag=True,
    help="Preview what would be removed without making changes",
)
@click.option(
    "--category",
    "-c",
    type=click.Choice(ALL_CATEGORIES),
    default=None,
    help="Only remove findings in this category",
)
@click.pass_context
def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
    """Remove dead code (with --dry-run for preview).

    WARNING: This modifies files. Always use --dry-run first and
    commit your code before running without it.
    """
    project = ctx.obj["project"]
    ignore = _merge_config_ignore(ctx)

    if not Path(project).exists():
        err_console.print(f"[red]Project directory '{project}' not found.[/red]")
        sys.exit(1)

    if not dry_run:
        console.print(
            "[red]WARNING: This will modify files. Use --dry-run first![/red]"
        )
        console.print("[dim]Press Ctrl+C to abort. Running in 3 seconds...[/dim]")
        import time

        time.sleep(3)

    include_patterns = ctx.obj.get("include")
    scanner = DeadCodeScanner(
        project, ignore_patterns=ignore, include_patterns=include_patterns
    )
    result = scanner.scan()

    findings = result.findings
    if category:
        findings = [f for f in findings if f.category == category]

    # Also respect config-level category filter if no CLI override
    config = ctx.obj.get("config")
    if not category and config and config.categories:
        findings = [f for f in findings if f.category in config.categories]

    # Only remove removable findings
    removable = [f for f in findings if f.removable]

    if not removable:
        console.print("[green] Nothing removable found.[/green]")
        return

    # Group by file
    by_file: dict[str, list[Finding]] = {}
    for f in removable:
        by_file.setdefault(f.file, []).append(f)

    removed_count = 0
    project_path = Path(project).resolve()

    for rel_file, file_findings in sorted(by_file.items()):
        filepath = project_path / rel_file
        if not filepath.exists():
            continue

        try:
            lines = filepath.read_text(encoding="utf-8", errors="replace").splitlines(
                keepends=True
            )
        except Exception as e:
            console.print(f"[red]Error reading {rel_file}: {e}[/red]")
            continue

        # Findings carry only a start line, no span. Blanking a single line of a
        # multi-line construct (a multi-line `export { ... }`, a CSS rule, or a
        # component body) leaves dangling, syntactically-broken code  worse than
        # doing nothing. DeadCode is regex-based with no AST, so guard
        # conservatively: only blank a line whose brackets/braces/parens are
        # balanced on that line (it's a self-contained one-liner). Anything that
        # opens an unclosed block is skipped and reported for manual removal.
        candidate_lines = sorted(set(f.line for f in file_findings), reverse=True)
        safe_lines = [
            n
            for n in candidate_lines
            if 0 < n 

Web Proxy Viewer  |  New URL  |  Original Page