#!/usr/bin/env python """ RichChoiceField Enforcement Lint Script This script checks for prohibited choice patterns in the codebase. Exit code 0 = no violations found, exit code 1 = violations found. Usage: python scripts/lint_choices.py python scripts/lint_choices.py --fix # Show fix suggestions Add to CI: python scripts/lint_choices.py || exit 1 """ import argparse import re import sys from pathlib import Path # Patterns to detect prohibited choice usage PROHIBITED_PATTERNS = [ # TextChoices / IntegerChoices class definitions (r'class\s+\w+\s*\(\s*models\.(TextChoices|IntegerChoices)\s*\)', 'models.TextChoices/IntegerChoices class definition'), # Inline tuple choices in CharField (r'choices\s*=\s*\[\s*\(\s*["\']', 'Inline tuple choices'), # Direct reference to .choices attribute (r'choices\s*=\s*\w+\.choices', 'Reference to inner TextChoices.choices'), ] # Directories/files to exclude EXCLUDE_PATTERNS = [ '*/migrations/*', '*/.venv/*', '*/node_modules/*', '*/__pycache__/*', '*.pyc', 'lint_choices.py', # Exclude this script ] # Files allowed to define TextChoices (infrastructure only) ALLOWED_EXCEPTION_FILES = [ # Core choice infrastructure files can reference these patterns 'apps/core/choices/', ] def should_exclude(path: Path) -> bool: """Check if path should be excluded from linting.""" path_str = str(path) for pattern in EXCLUDE_PATTERNS: if pattern.startswith('*/'): if pattern[2:].rstrip('/*') in path_str: return True elif pattern.endswith('/*'): if path_str.startswith(pattern[:-2]): return True elif pattern in path_str: return True return False def is_exception_file(path: Path) -> bool: """Check if file is an allowed exception.""" path_str = str(path) for exception in ALLOWED_EXCEPTION_FILES: if exception in path_str: return True return False def scan_file(filepath: Path) -> list[tuple[int, str, str]]: """Scan a file for prohibited patterns. Returns list of (line_num, line, pattern_name).""" violations = [] if should_exclude(filepath) or is_exception_file(filepath): return violations try: content = filepath.read_text(encoding='utf-8') lines = content.split('\n') for i, line in enumerate(lines, 1): # Skip comments stripped = line.strip() if stripped.startswith('#'): continue for pattern, description in PROHIBITED_PATTERNS: if re.search(pattern, line): violations.append((i, line.strip(), description)) break # Only report one violation per line except Exception as e: print(f"Warning: Could not read {filepath}: {e}", file=sys.stderr) return violations def scan_directory(root_dir: Path) -> dict[Path, list]: """Scan all Python files in directory for violations.""" all_violations = {} for filepath in root_dir.rglob('*.py'): violations = scan_file(filepath) if violations: all_violations[filepath] = violations return all_violations def print_violations(violations: dict, show_fix: bool = False): """Print violations in a readable format.""" total = 0 for filepath, file_violations in sorted(violations.items()): print(f"\n\033[1;31m{filepath}\033[0m") for line_num, line_content, description in file_violations: print(f" Line {line_num}: [{description}]") print(f" {line_content[:80]}{'...' if len(line_content) > 80 else ''}") total += 1 if show_fix: print(f" \033[1;33mFix:\033[0m Use RichChoiceField(choice_group='...', domain='...')") return total def main(): parser = argparse.ArgumentParser(description='Lint for prohibited choice patterns') parser.add_argument('--fix', action='store_true', help='Show fix suggestions') parser.add_argument('path', nargs='?', default='apps', help='Path to scan (default: apps)') args = parser.parse_args() # Find backend directory script_dir = Path(__file__).parent backend_dir = script_dir.parent if (backend_dir / 'apps').exists(): scan_path = backend_dir / args.path elif (backend_dir / 'backend' / 'apps').exists(): scan_path = backend_dir / 'backend' / args.path else: print("Error: Could not find apps directory", file=sys.stderr) sys.exit(1) print(f"Scanning {scan_path} for prohibited choice patterns...") print("=" * 60) violations = scan_directory(scan_path) if violations: total = print_violations(violations, show_fix=args.fix) print("\n" + "=" * 60) print(f"\033[1;31mFound {total} violation(s) in {len(violations)} file(s)\033[0m") print("\nProhibited patterns:") print(" - models.TextChoices / models.IntegerChoices classes") print(" - Inline choices=[(value, label), ...]") print(" - References to InnerClass.choices") print("\nRequired pattern:") print(" RichChoiceField(choice_group='group_name', domain='domain_name')") print("\nSee: .agent/workflows/enforce-richchoice.md for migration guide") sys.exit(1) else: print("\n\033[1;32m✓ No prohibited choice patterns found!\033[0m") sys.exit(0) if __name__ == '__main__': main()