Skip to content

Architecture

architecture

Architecture rules — AST-based structural analysis.

CircularImportRule dataclass

Bases: ProjectRule

Detect circular imports via import graph + Tarjan's SCC algorithm.

Source code in packages/axm-audit/src/axm_audit/core/rules/architecture.py
@dataclass
@register_rule("architecture")
class CircularImportRule(ProjectRule):
    """Detect circular imports via import graph + Tarjan's SCC algorithm."""

    @property
    def rule_id(self) -> str:
        """Unique identifier for this rule."""
        return "ARCH_CIRCULAR"

    def check(self, project_path: Path) -> CheckResult:
        """Check for circular imports in the project."""
        early = self.check_src(project_path)
        if early is not None:
            return early

        src_path = project_path / "src"
        cycles, score = self._analyze_cycles(src_path)
        passed = len(cycles) == 0

        return CheckResult(
            rule_id=self.rule_id,
            passed=passed,
            message=f"{len(cycles)} circular import(s) found",
            severity=Severity.ERROR if not passed else Severity.INFO,
            details={"cycles": cycles, "score": score},
            fix_hint="Break cycles by using lazy imports or restructuring"
            if cycles
            else None,
        )

    def _analyze_cycles(self, src_path: Path) -> tuple[list[list[str]], int]:
        """Build import graph and detect cycles."""
        graph = self._build_import_graph(src_path)
        cycles = _tarjan_scc(graph)
        score = max(0, 100 - len(cycles) * 20)
        return cycles, score

    def _build_import_graph(self, src_path: Path) -> dict[str, set[str]]:
        """Build the module import graph from source files."""
        graph: dict[str, set[str]] = defaultdict(set)

        for path in get_python_files(src_path):
            if path.name == "__init__.py":
                continue
            cache = get_ast_cache()
            tree = cache.get_or_parse(path) if cache else parse_file_safe(path)
            if tree is None:
                continue
            module_name = _get_module_name(path, src_path)
            if not module_name:
                continue
            for imp in _extract_imports(tree):
                graph[module_name].add(imp)
            if module_name not in graph:
                graph[module_name] = set()

        return dict(graph)
rule_id property

Unique identifier for this rule.

check(project_path)

Check for circular imports in the project.

Source code in packages/axm-audit/src/axm_audit/core/rules/architecture.py
def check(self, project_path: Path) -> CheckResult:
    """Check for circular imports in the project."""
    early = self.check_src(project_path)
    if early is not None:
        return early

    src_path = project_path / "src"
    cycles, score = self._analyze_cycles(src_path)
    passed = len(cycles) == 0

    return CheckResult(
        rule_id=self.rule_id,
        passed=passed,
        message=f"{len(cycles)} circular import(s) found",
        severity=Severity.ERROR if not passed else Severity.INFO,
        details={"cycles": cycles, "score": score},
        fix_hint="Break cycles by using lazy imports or restructuring"
        if cycles
        else None,
    )

CouplingMetricRule dataclass

Bases: ProjectRule

Measure module coupling via fan-in/fan-out analysis.

Scores based on the number of modules whose fan-out exceeds the threshold: score = 100 - N(over) * 5.

Source code in packages/axm-audit/src/axm_audit/core/rules/architecture.py
@dataclass
@register_rule("architecture")
class CouplingMetricRule(ProjectRule):
    """Measure module coupling via fan-in/fan-out analysis.

    Scores based on the number of modules whose fan-out exceeds
    the threshold: ``score = 100 - N(over) * 5``.
    """

    fan_out_threshold: int = 10

    @property
    def rule_id(self) -> str:
        """Unique identifier for this rule."""
        return "ARCH_COUPLING"

    def check(self, project_path: Path) -> CheckResult:
        """Check coupling metrics for the project."""
        early = self.check_src(project_path)
        if early is not None:
            return early

        src_path = project_path / "src"

        metrics = _compute_coupling_metrics(src_path, self.fan_out_threshold)
        n_over: int = metrics["n_over_threshold"]
        over: list[dict[str, Any]] = metrics["over_threshold"]
        avg: float = metrics["avg_coupling"]
        score = max(0, 100 - n_over * 5)

        if n_over:
            penalty = n_over * 5
            msg = f"Coupling: {n_over} module(s) above threshold (-{penalty} pts)"
        else:
            max_fo = metrics["max_fan_out"]
            msg = f"Coupling: 0 modules above threshold (max fan-out: {max_fo})"

        # Build fix_hint with module listing
        hint = None
        if over:
            lines = [f"  \u2022 {m['module']} (fan-out: {m['fan_out']})" for m in over]
            hint = "Reduce imports in:\n" + "\n".join(lines)

        return CheckResult(
            rule_id=self.rule_id,
            passed=n_over == 0,
            message=msg,
            severity=Severity.WARNING if n_over else Severity.INFO,
            details={
                "max_fan_out": metrics["max_fan_out"],
                "max_fan_in": metrics["max_fan_in"],
                "avg_coupling": round(avg, 2),
                "score": score,
                "n_over_threshold": n_over,
                "over_threshold": over,
            },
            fix_hint=hint,
        )
rule_id property

Unique identifier for this rule.

check(project_path)

Check coupling metrics for the project.

Source code in packages/axm-audit/src/axm_audit/core/rules/architecture.py
def check(self, project_path: Path) -> CheckResult:
    """Check coupling metrics for the project."""
    early = self.check_src(project_path)
    if early is not None:
        return early

    src_path = project_path / "src"

    metrics = _compute_coupling_metrics(src_path, self.fan_out_threshold)
    n_over: int = metrics["n_over_threshold"]
    over: list[dict[str, Any]] = metrics["over_threshold"]
    avg: float = metrics["avg_coupling"]
    score = max(0, 100 - n_over * 5)

    if n_over:
        penalty = n_over * 5
        msg = f"Coupling: {n_over} module(s) above threshold (-{penalty} pts)"
    else:
        max_fo = metrics["max_fan_out"]
        msg = f"Coupling: 0 modules above threshold (max fan-out: {max_fo})"

    # Build fix_hint with module listing
    hint = None
    if over:
        lines = [f"  \u2022 {m['module']} (fan-out: {m['fan_out']})" for m in over]
        hint = "Reduce imports in:\n" + "\n".join(lines)

    return CheckResult(
        rule_id=self.rule_id,
        passed=n_over == 0,
        message=msg,
        severity=Severity.WARNING if n_over else Severity.INFO,
        details={
            "max_fan_out": metrics["max_fan_out"],
            "max_fan_in": metrics["max_fan_in"],
            "avg_coupling": round(avg, 2),
            "score": score,
            "n_over_threshold": n_over,
            "over_threshold": over,
        },
        fix_hint=hint,
    )

GodClassRule dataclass

Bases: ProjectRule

Detect god classes (too many lines or methods).

Source code in packages/axm-audit/src/axm_audit/core/rules/architecture.py
@dataclass
@register_rule("architecture")
class GodClassRule(ProjectRule):
    """Detect god classes (too many lines or methods)."""

    max_lines: int = 500
    max_methods: int = 15

    @property
    def rule_id(self) -> str:
        """Unique identifier for this rule."""
        return "ARCH_GOD_CLASS"

    def check(self, project_path: Path) -> CheckResult:
        """Check for god classes in the project."""
        early = self.check_src(project_path)
        if early is not None:
            return early

        src_path = project_path / "src"

        god_classes = self._find_god_classes(src_path)

        score = max(0, 100 - len(god_classes) * 15)
        passed = len(god_classes) == 0

        return CheckResult(
            rule_id=self.rule_id,
            passed=passed,
            message=f"{len(god_classes)} god class(es) found",
            severity=Severity.WARNING if not passed else Severity.INFO,
            details={"god_classes": god_classes, "score": score},
            fix_hint="Split large classes into smaller, focused classes"
            if god_classes
            else None,
        )

    def _find_god_classes(self, src_path: Path) -> list[dict[str, str | int]]:
        """Identify god classes in the source directory."""
        god_classes: list[dict[str, str | int]] = []
        py_files = get_python_files(src_path)

        for path in py_files:
            cache = get_ast_cache()
            tree = cache.get_or_parse(path) if cache else parse_file_safe(path)
            if tree is None:
                continue

            for node in ast.walk(tree):
                if isinstance(node, ast.ClassDef):
                    self._check_class_node(node, path, src_path, god_classes)

        return god_classes

    def _check_class_node(
        self,
        node: ast.ClassDef,
        file_path: Path,
        src_root: Path,
        results: list[dict[str, str | int]],
    ) -> None:
        """Analyze a single class node for god class metrics."""
        # Count lines
        if hasattr(node, "end_lineno") and node.end_lineno:
            lines = node.end_lineno - node.lineno + 1
        else:
            lines = 0

        # Count methods
        methods = sum(
            1
            for child in node.body
            if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef)
        )

        if lines > self.max_lines or methods > self.max_methods:
            results.append(
                {
                    "name": node.name,
                    "file": str(file_path.relative_to(src_root)),
                    "lines": lines,
                    "methods": methods,
                }
            )
rule_id property

Unique identifier for this rule.

check(project_path)

Check for god classes in the project.

Source code in packages/axm-audit/src/axm_audit/core/rules/architecture.py
def check(self, project_path: Path) -> CheckResult:
    """Check for god classes in the project."""
    early = self.check_src(project_path)
    if early is not None:
        return early

    src_path = project_path / "src"

    god_classes = self._find_god_classes(src_path)

    score = max(0, 100 - len(god_classes) * 15)
    passed = len(god_classes) == 0

    return CheckResult(
        rule_id=self.rule_id,
        passed=passed,
        message=f"{len(god_classes)} god class(es) found",
        severity=Severity.WARNING if not passed else Severity.INFO,
        details={"god_classes": god_classes, "score": score},
        fix_hint="Split large classes into smaller, focused classes"
        if god_classes
        else None,
    )