131 lines
4.2 KiB
Python
131 lines
4.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Lines-of-Code (LOC) Quality Guard (EPIC R10 - CASAN Gate S).
|
|
|
|
Enforces the Single Responsibility Principle by ensuring that no production
|
|
Python file in Clean Architecture packages exceeds the configured limit (400 LOC).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List, Tuple
|
|
|
|
# Ensure stdout handles UTF-8 on Windows consoles without codec crash
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
try:
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
except Exception:
|
|
pass
|
|
|
|
# Default target directories strictly subjected to the 400 LOC constraint
|
|
DEFAULT_TARGET_DIRS = ["domain", "application", "infrastructure", "presentation"]
|
|
DEFAULT_MAX_LINES = 400
|
|
|
|
|
|
def count_file_lines(file_path: Path) -> int:
|
|
"""Read a python file and return total physical line count."""
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
return len(content.splitlines())
|
|
except Exception as exc:
|
|
print(f"[WARN] Failed to read {file_path}: {exc}", file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
def scan_directories(
|
|
root_dir: Path, target_dirs: List[str], max_lines: int, verbose: bool = False
|
|
) -> Tuple[int, List[Tuple[str, int]]]:
|
|
"""Recursively scan target packages for files exceeding the maximum LOC limit.
|
|
|
|
Returns:
|
|
A tuple of (total_files_scanned, list_of_violations_as_(relative_path, line_count))
|
|
"""
|
|
total_files = 0
|
|
violations: List[Tuple[str, int]] = []
|
|
|
|
for target in target_dirs:
|
|
dir_path = root_dir / target
|
|
if not dir_path.is_dir():
|
|
if verbose:
|
|
print(f"[INFO] Skipping missing directory: {target}")
|
|
continue
|
|
|
|
for current_root, _, files in os.walk(dir_path):
|
|
for file_name in files:
|
|
if not file_name.endswith(".py"):
|
|
continue
|
|
|
|
full_path = Path(current_root) / file_name
|
|
rel_path = full_path.relative_to(root_dir).as_posix()
|
|
lines = count_file_lines(full_path)
|
|
total_files += 1
|
|
|
|
if verbose:
|
|
print(f" {rel_path}: {lines} lines")
|
|
|
|
if lines > max_lines:
|
|
violations.append((rel_path, lines))
|
|
|
|
return total_files, violations
|
|
|
|
|
|
def main() -> int:
|
|
"""CLI entry point for the LOC guard script."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Verify that production source files do not exceed the LOC ceiling."
|
|
)
|
|
parser.add_argument(
|
|
"--max-lines",
|
|
type=int,
|
|
default=DEFAULT_MAX_LINES,
|
|
help=f"Maximum allowed lines per file (default: {DEFAULT_MAX_LINES})",
|
|
)
|
|
parser.add_argument(
|
|
"--dirs",
|
|
nargs="+",
|
|
default=DEFAULT_TARGET_DIRS,
|
|
help=f"Target directories to scan (default: {' '.join(DEFAULT_TARGET_DIRS)})",
|
|
)
|
|
parser.add_argument(
|
|
"--root",
|
|
type=str,
|
|
default=str(Path(__file__).resolve().parent.parent),
|
|
help="Root repository directory",
|
|
)
|
|
parser.add_argument(
|
|
"-v", "--verbose",
|
|
action="store_true",
|
|
help="Enable verbose output listing all scanned files",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
root_dir = Path(args.root).resolve()
|
|
|
|
print("=" * 70)
|
|
print(f"CASAN Guard 'S' (Single Responsibility): Checking file length <= {args.max_lines} LOC")
|
|
print(f"Scanning target directories: {args.dirs}")
|
|
print("=" * 70)
|
|
|
|
total_files, violations = scan_directories(
|
|
root_dir=root_dir,
|
|
target_dirs=args.dirs,
|
|
max_lines=args.max_lines,
|
|
verbose=args.verbose,
|
|
)
|
|
|
|
if violations:
|
|
print(f"\n[FAIL] Found {len(violations)} oversized file(s) (> {args.max_lines} LOC):")
|
|
for file_path, lines in sorted(violations, key=lambda x: x[1], reverse=True):
|
|
print(f" ❌ {file_path}: {lines} lines (exceeds limit by {lines - args.max_lines})")
|
|
print("\nAction Required: Refactor oversized files into smaller single-responsibility modules.")
|
|
return 1
|
|
|
|
print(f"\n[PASS] All {total_files} production files in {args.dirs} satisfy <= {args.max_lines} LOC limit.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|