文件预览

syntax_check.py

查看 Code QC 技能包中的文件内容。

文件内容

scripts/syntax_check.py

#!/usr/bin/env python3
"""Check all Python files for syntax errors using py_compile.

Usage:
    python syntax_check.py <directory> [--exclude dir1,dir2] [--json]

Example:
    python syntax_check.py src/ --exclude vendor,generated
"""
from __future__ import annotations

import argparse
import json
import logging
import os
import py_compile
import sys
from dataclasses import dataclass, field

# Configure logging to stderr
logging.basicConfig(
    level=logging.INFO,
    format="%(message)s",
    stream=sys.stderr,
)
logger = logging.getLogger(__name__)


@dataclass
class SyntaxIssue:
    """Record of a syntax error."""

    file: str
    error: str
    line: int | None = None
    column: int | None = None

    def to_dict(self) -> dict:
        result: dict = {"file": self.file, "error": self.error}
        if self.line is not None:
            result["line"] = self.line
        if self.column is not None:
            result["column"] = self.column
        return result


@dataclass
class SyntaxResults:
    """Results of a syntax check run."""

    total: int = 0
    passed: int = 0
    errors: list[SyntaxIssue] = field(default_factory=list)

    def to_dict(self) -> dict:
        return {
            "total": self.total,
            "passed": self.passed,
            "failed": len(self.errors),
            "errors": [e.to_dict() for e in self.errors],
        }


def check_syntax(root: str, exclude: list[str] | None = None) -> SyntaxResults:
    """Check all Python files in a directory for syntax errors.

    Args:
        root: Directory to check
        exclude: List of directory names to skip

    Returns:
        SyntaxResults with pass/fail counts and error details
    """
    exclude = exclude or []
    results = SyntaxResults()

    skip_dirs = exclude + ["__pycache__", ".git", ".venv", "venv", "node_modules"]

    for dirpath, dirnames, filenames in os.walk(root):
        rel = os.path.relpath(dirpath, root)

        # Skip excluded directories
        if any(ex in rel.split(os.sep) for ex in skip_dirs):
            continue

        # Filter dirnames to prevent descending into excluded dirs
        dirnames[:] = [d for d in dirnames if d not in skip_dirs]

        for fname in filenames:
            if not fname.endswith(".py"):
                continue

            filepath = os.path.join(dirpath, fname)
            results.total += 1

            try:
                py_compile.compile(filepath, doraise=True)
                results.passed += 1
            except py_compile.PyCompileError as e:
                # Extract line/column if available
                line = None
                column = None
                if hasattr(e, "exc_value") and e.exc_value:
                    exc = e.exc_value
                    if hasattr(exc, "lineno"):
                        line = exc.lineno
                    if hasattr(exc, "offset"):
                        column = exc.offset

                results.errors.append(
                    SyntaxIssue(
                        file=os.path.relpath(filepath, root),
                        error=str(e.msg) if hasattr(e, "msg") else str(e),
                        line=line,
                        column=column,
                    )
                )

    return results


def main() -> None:
    """CLI entry point."""
    parser = argparse.ArgumentParser(
        description="Syntax check for Python files",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python syntax_check.py src/
  python syntax_check.py . --exclude vendor,migrations --json
        """,
    )
    parser.add_argument("directory", help="Root directory to check")
    parser.add_argument(
        "--exclude", default="", help="Comma-separated dirs to exclude"
    )
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    args = parser.parse_args()

    exclude = [x.strip() for x in args.exclude.split(",") if x.strip()]
    results = check_syntax(args.directory, exclude)

    if args.json:
        print(json.dumps(results.to_dict(), indent=2))
    else:
        logger.info(f"Syntax Check: {args.directory}")
        logger.info(f"  Total:  {results.total}")
        logger.info(f"  Passed: {results.passed}")
        logger.info(f"  Errors: {len(results.errors)}")

        if results.errors:
            logger.info("\n❌ Syntax Errors:")
            for e in results.errors:
                loc = f":{e.line}" if e.line else ""
                logger.info(f"  {e.file}{loc}: {e.error}")

    sys.exit(1 if results.errors else 0)


if __name__ == "__main__":
    main()