architecture_guard.py

Code Hygiene Score: 100

Keine Issues gefunden.

Dependencies 3

Funktionen 6

Code

#!/usr/bin/env python3
"""
Architecture Guard - Pre-Hook (Blocking)

Enforces hard_constraints from architecture-gate-contract v1.1
Blocks file creation on violation. No exceptions.

Rules:
  H1: strict_types_required (all PHP files)
  H2: domain_no_infrastructure (Domain layer)
  H3: db_factory_only (only in Factory classes)
  H4: no_new_repository_in_controller (Controller layer)
  H5: no_new_infrastructure_in_controller (Controller layer)

Trigger: PreToolUse (Write) on *.php
"""

import json
import re
import sys

# Hard rules from architecture-gate-contract v1.1
HARD_RULES = [
    {
        "id": "H1",
        "name": "strict_types_required",
        "pattern": r"declare\s*\(\s*strict_types\s*=\s*1\s*\)",
        "must_match": True,
        "applies_to": "all",
        "message": "Missing declare(strict_types=1). Add at top of file after <?php"
    },
    {
        "id": "H2",
        "name": "domain_no_infrastructure",
        "pattern": r"use\s+Infrastructure\\",
        "must_match": False,
        "applies_to": "/Domain/",
        "message": "Domain must not use Infrastructure. Violates DIP."
    },
    {
        "id": "H3",
        "name": "db_factory_only",
        "pattern": r"DatabaseFactory::",
        "must_match": False,
        "applies_to_not": "/Factory/",
        "message": "DatabaseFactory only allowed in Factory classes. Use DI."
    },
    {
        "id": "H4",
        "name": "no_new_repository_in_controller",
        "pattern": r"new\s+\w+Repository\s*\(",
        "must_match": False,
        "applies_to": "/Controller/",
        "message": "new Repository in Controller not allowed. Use DI via constructor."
    },
    {
        "id": "H5",
        "name": "no_new_infrastructure_in_controller",
        "pattern": r"new\s+Infrastructure\\",
        "must_match": False,
        "applies_to": "/Controller/",
        "message": "new Infrastructure in Controller not allowed. Use DI via constructor."
    }
]

# Paths exempt from all rules
ALLOWED_PATHS = [
    "/Factory/",
    "/Bootstrap/",
    "/tests/",
    "/Test/",
    "/vendor/",
]


def is_allowed_path(file_path: str) -> bool:
    """Check if file is in allowlist."""
    for allowed in ALLOWED_PATHS:
        if allowed in file_path:
            return True
    return False


def rule_applies(rule: dict, file_path: str) -> bool:
    """Check if rule applies to this file path."""
    if "applies_to" in rule:
        if rule["applies_to"] == "all":
            return True
        return rule["applies_to"] in file_path

    if "applies_to_not" in rule:
        return rule["applies_to_not"] not in file_path

    return True


def check_rule(rule: dict, content: str) -> bool:
    """
    Check if content violates the rule.

    Returns True if VIOLATED, False if OK.
    """
    match = re.search(rule["pattern"], content)

    if rule["must_match"]:
        # Pattern MUST be present
        return match is None  # Violated if NOT found
    else:
        # Pattern must NOT be present
        return match is not None  # Violated if found


def check_all_rules(file_path: str, content: str) -> dict:
    """
    Check all rules against file.

    Returns:
        {"allowed": True} if all pass
        {"allowed": False, "message": "..."} on first violation
    """
    # Skip non-PHP files
    if not file_path.endswith(".php"):
        return {"allowed": True}

    # Skip allowlisted paths
    if is_allowed_path(file_path):
        return {"allowed": True}

    for rule in HARD_RULES:
        if not rule_applies(rule, file_path):
            continue

        if check_rule(rule, content):
            return {
                "allowed": False,
                "message": f"ARCHITECTURE VIOLATION [{rule['id']}]: {rule['message']}"
            }

    return {"allowed": True}


def format_output(result: dict) -> str:
    """Format output as JSON for Claude Code hook protocol."""
    return json.dumps(result)


def main():
    try:
        input_data = json.load(sys.stdin)
    except json.JSONDecodeError:
        # Invalid input, allow by default
        print(json.dumps({"allowed": True}))
        sys.exit(0)

    tool_name = input_data.get("tool_name", "")

    # Only check Write operations
    if tool_name != "Write":
        print(json.dumps({"allowed": True}))
        sys.exit(0)

    tool_input = input_data.get("tool_input", {})
    file_path = tool_input.get("file_path", "")
    content = tool_input.get("content", "")

    result = check_all_rules(file_path, content)

    print(format_output(result))

    if result["allowed"]:
        sys.exit(0)
    else:
        sys.exit(0)  # Exit 0 with allowed:false triggers block


if __name__ == "__main__":
    main()
← Übersicht