167 lines
5.7 KiB
Python
167 lines
5.7 KiB
Python
"""AST-based Static Analysis Guard for Clean Architecture Enforcement.
|
|
|
|
Scans designated Python packages (such as `domain/` and `application/`) to ensure
|
|
they remain 100% Pure Python and do not import presentation/GUI frameworks (PySide6, PyQt)
|
|
or concrete application shells.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import io
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List, NamedTuple, Set
|
|
|
|
# Ensure UTF-8 output on standard console streams across diverse Windows locales (CP932, etc.)
|
|
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
|
|
try:
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class ImportViolation(NamedTuple):
|
|
file_path: Path
|
|
line_number: int
|
|
imported_module: str
|
|
rule_description: str
|
|
|
|
|
|
# Disallowed top-level package names in pure business/domain layers
|
|
FORBIDDEN_MODULE_PREFIXES: Set[str] = {
|
|
"PySide6",
|
|
"PySide2",
|
|
"PyQt6",
|
|
"PyQt5",
|
|
"ui",
|
|
"app",
|
|
}
|
|
|
|
# Default directories that must strictly adhere to Clean Architecture
|
|
DEFAULT_SCAN_DIRS: List[str] = [
|
|
"domain",
|
|
"application",
|
|
]
|
|
|
|
|
|
class ArchitectureImportVisitor(ast.NodeVisitor):
|
|
"""AST visitor that checks all Import and ImportFrom statements against forbidden prefixes."""
|
|
|
|
def __init__(self, file_path: Path, forbidden: Set[str]) -> None:
|
|
self.file_path = file_path
|
|
self.forbidden = forbidden
|
|
self.violations: List[ImportViolation] = []
|
|
|
|
def visit_Import(self, node: ast.Import) -> None:
|
|
# Check direct `import x, y` statements
|
|
for alias in node.names:
|
|
root_module = alias.name.split(".")[0]
|
|
if root_module in self.forbidden:
|
|
self.violations.append(
|
|
ImportViolation(
|
|
file_path=self.file_path,
|
|
line_number=node.lineno,
|
|
imported_module=alias.name,
|
|
rule_description=f"Direct import of GUI/shell module '{alias.name}' is prohibited.",
|
|
)
|
|
)
|
|
self.generic_visit(node)
|
|
|
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
# Check `from x import y` statements
|
|
if node.module:
|
|
root_module = node.module.split(".")[0]
|
|
if root_module in self.forbidden:
|
|
self.violations.append(
|
|
ImportViolation(
|
|
file_path=self.file_path,
|
|
line_number=node.lineno,
|
|
imported_module=node.module,
|
|
rule_description=f"Import from GUI/shell module '{node.module}' is prohibited.",
|
|
)
|
|
)
|
|
self.generic_visit(node)
|
|
|
|
|
|
def scan_file(file_path: Path, forbidden: Set[str]) -> List[ImportViolation]:
|
|
"""Parse a single Python file into AST and return all detected architecture import violations."""
|
|
try:
|
|
source_code = file_path.read_text(encoding="utf-8")
|
|
tree = ast.parse(source_code, filename=str(file_path))
|
|
except (SyntaxError, UnicodeDecodeError) as exc:
|
|
print(f"[Syntax/Read Warning] Could not parse {file_path}: {exc}", file=sys.stderr)
|
|
return []
|
|
|
|
visitor = ArchitectureImportVisitor(file_path, forbidden)
|
|
visitor.visit(tree)
|
|
return visitor.violations
|
|
|
|
|
|
def scan_directory(dir_path: Path, forbidden: Set[str]) -> List[ImportViolation]:
|
|
"""Recursively scan all Python files in a directory."""
|
|
violations: List[ImportViolation] = []
|
|
if not dir_path.exists():
|
|
return violations
|
|
|
|
for py_file in dir_path.rglob("*.py"):
|
|
if py_file.is_file() and "__pycache__" not in py_file.parts:
|
|
violations.extend(scan_file(py_file, forbidden))
|
|
|
|
return violations
|
|
|
|
|
|
def main() -> int:
|
|
"""CLI entry point for CI/pre-commit quality gate checks."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Clean Architecture Import Guard: Verifies zero GUI/Qt dependencies in domain/app layers."
|
|
)
|
|
parser.add_argument(
|
|
"--paths",
|
|
nargs="*",
|
|
default=DEFAULT_SCAN_DIRS,
|
|
help="Paths or directories to scan (defaults to 'domain' and 'application')",
|
|
)
|
|
parser.add_argument(
|
|
"--root",
|
|
default=".",
|
|
help="Root workspace directory",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
root_dir = Path(args.root).resolve()
|
|
all_violations: List[ImportViolation] = []
|
|
|
|
print(f"[Clean Arch Guard] Scanning root: {root_dir}")
|
|
|
|
for target in args.paths:
|
|
target_path = (root_dir / target).resolve()
|
|
if not target_path.exists():
|
|
# If the layer directory does not exist yet (during early migration), skip cleanly
|
|
print(f"[Clean Arch Guard] Directory '{target}' does not exist yet (skipped).")
|
|
continue
|
|
|
|
if target_path.is_file():
|
|
all_violations.extend(scan_file(target_path, FORBIDDEN_MODULE_PREFIXES))
|
|
else:
|
|
all_violations.extend(scan_directory(target_path, FORBIDDEN_MODULE_PREFIXES))
|
|
|
|
if all_violations:
|
|
print("\n[FAIL] CLEAN ARCHITECTURE VIOLATIONS DETECTED:")
|
|
print("=" * 70)
|
|
for v in all_violations:
|
|
rel_path = v.file_path.relative_to(root_dir) if v.file_path.is_relative_to(root_dir) else v.file_path
|
|
print(f" • {rel_path}:{v.line_number} -> Forbidden import: '{v.imported_module}'")
|
|
print(f" Reason: {v.rule_description}")
|
|
print("=" * 70)
|
|
print(f"Total Violations: {len(all_violations)}")
|
|
return 1
|
|
|
|
print("\n[PASS] CLEAN ARCHITECTURE CHECK: 0 forbidden imports detected.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|