Skip to content

Index

practices

Practice rules — code quality patterns via AST.

One module per rule. Importing this package fires the @register_rule decorators of each submodule.

AntiMirrorRule dataclass

Bases: ProjectRule

Flag integration/e2e tests named after source modules.

Integration and e2e tests are scenario-named by convention. A tests/integration/test_foo.py that mirrors src/<pkg>/foo.py is almost always a misplaced unit test — promote it to tests/unit/ or rename to describe the verified scenario.

Sources matched by [tool.axm-audit.mirror].exempt_paths are excluded: a CLI-wrapper module like commands/data.py exempted by AXM-1666 is legitimately covered by tests/integration/test_data.py.

Scoring: max(0, 100 - len(violations) * 15); passed = score >= 90.

Source code in packages/axm-audit/src/axm_audit/core/rules/practices/anti_mirror.py
Python
@dataclass
@register_rule("practices")
class AntiMirrorRule(ProjectRule):
    """Flag integration/e2e tests named after source modules.

    Integration and e2e tests are scenario-named by convention. A
    ``tests/integration/test_foo.py`` that mirrors ``src/<pkg>/foo.py`` is
    almost always a misplaced unit test — promote it to ``tests/unit/`` or
    rename to describe the verified scenario.

    Sources matched by ``[tool.axm-audit.mirror].exempt_paths`` are excluded:
    a CLI-wrapper module like ``commands/data.py`` exempted by AXM-1666 is
    legitimately covered by ``tests/integration/test_data.py``.

    Scoring: ``max(0, 100 - len(violations) * 15)``; ``passed = score >= 90``.
    """

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

    def check(self, project_path: Path) -> CheckResult:
        """Walk integration/e2e and flag tests named after source modules."""
        early = self.check_src(project_path)
        if early is not None:
            return early

        tests_path = project_path / "tests"
        if (
            not (tests_path / "integration").is_dir()
            and not (tests_path / "e2e").is_dir()
        ):
            return CheckResult(
                rule_id=self.rule_id,
                passed=True,
                message="No integration or e2e tests to check",
                severity=Severity.INFO,
                score=100,
                details={"anti_mirror": []},
            )

        config = _load_mirror_config(project_path)
        if config.error is not None:
            return CheckResult(
                rule_id=self.rule_id,
                passed=False,
                message="Invalid mirror config",
                severity=Severity.WARNING,
                score=0,
                details={"anti_mirror": []},
                fix_hint=config.error,
            )

        src_path = project_path / "src"
        source_basenames = _collect_source_basenames(src_path, config.exempt_paths)
        violations = _collect_anti_mirror_violations(tests_path, source_basenames)
        violations = _drop_k1_canonical_collisions(violations, project_path)

        if not violations:
            return CheckResult(
                rule_id=self.rule_id,
                passed=True,
                message="No anti-mirror naming violations",
                severity=Severity.INFO,
                score=100,
                details={"anti_mirror": []},
            )

        score = max(0, 100 - len(violations) * 15)
        passed = score >= 90  # noqa: PLR2004

        shown = violations[:5]
        tail = (
            f" (+{len(violations) - 5} more)" if len(violations) > 5 else ""  # noqa: PLR2004
        )
        text = "• anti-mirror: " + " ".join(shown) + tail

        first = violations[0]
        fix_hint = (
            f"{first} → rename to test_<scenario>.py describing what the test "
            "verifies (scenario-named, not source-mirrored)"
        )

        return CheckResult(
            rule_id=self.rule_id,
            passed=passed,
            message=(
                f"{len(violations)} integration/e2e test(s) named after source modules"
            ),
            severity=Severity.WARNING if not passed else Severity.INFO,
            score=int(score),
            details={"anti_mirror": violations},
            fix_hint=fix_hint,
            text=text,
        )
rule_id property

Unique identifier for this rule.

check(project_path)

Walk integration/e2e and flag tests named after source modules.

Source code in packages/axm-audit/src/axm_audit/core/rules/practices/anti_mirror.py
Python
def check(self, project_path: Path) -> CheckResult:
    """Walk integration/e2e and flag tests named after source modules."""
    early = self.check_src(project_path)
    if early is not None:
        return early

    tests_path = project_path / "tests"
    if (
        not (tests_path / "integration").is_dir()
        and not (tests_path / "e2e").is_dir()
    ):
        return CheckResult(
            rule_id=self.rule_id,
            passed=True,
            message="No integration or e2e tests to check",
            severity=Severity.INFO,
            score=100,
            details={"anti_mirror": []},
        )

    config = _load_mirror_config(project_path)
    if config.error is not None:
        return CheckResult(
            rule_id=self.rule_id,
            passed=False,
            message="Invalid mirror config",
            severity=Severity.WARNING,
            score=0,
            details={"anti_mirror": []},
            fix_hint=config.error,
        )

    src_path = project_path / "src"
    source_basenames = _collect_source_basenames(src_path, config.exempt_paths)
    violations = _collect_anti_mirror_violations(tests_path, source_basenames)
    violations = _drop_k1_canonical_collisions(violations, project_path)

    if not violations:
        return CheckResult(
            rule_id=self.rule_id,
            passed=True,
            message="No anti-mirror naming violations",
            severity=Severity.INFO,
            score=100,
            details={"anti_mirror": []},
        )

    score = max(0, 100 - len(violations) * 15)
    passed = score >= 90  # noqa: PLR2004

    shown = violations[:5]
    tail = (
        f" (+{len(violations) - 5} more)" if len(violations) > 5 else ""  # noqa: PLR2004
    )
    text = "• anti-mirror: " + " ".join(shown) + tail

    first = violations[0]
    fix_hint = (
        f"{first} → rename to test_<scenario>.py describing what the test "
        "verifies (scenario-named, not source-mirrored)"
    )

    return CheckResult(
        rule_id=self.rule_id,
        passed=passed,
        message=(
            f"{len(violations)} integration/e2e test(s) named after source modules"
        ),
        severity=Severity.WARNING if not passed else Severity.INFO,
        score=int(score),
        details={"anti_mirror": violations},
        fix_hint=fix_hint,
        text=text,
    )

BareExceptRule dataclass

Bases: ProjectRule

Detect bare except clauses (except: without type).

Source code in packages/axm-audit/src/axm_audit/core/rules/practices/bare_except.py
Python
@dataclass
@register_rule("practices")
class BareExceptRule(ProjectRule):
    """Detect bare except clauses (except: without type)."""

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

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

        src_path = project_path / "src"
        bare_excepts = self._collect_bare_excepts(src_path)

        count = len(bare_excepts)
        passed = count == 0
        score = max(0, 100 - count * 20)

        text_lines = [
            f"     • {_short_path(str(loc['file']))}:{loc['line']}"
            for loc in bare_excepts
        ]

        return CheckResult(
            rule_id=self.rule_id,
            passed=passed,
            message=f"{count} bare except(s) found",
            severity=Severity.WARNING if not passed else Severity.INFO,
            score=int(score),
            details={
                "bare_except_count": count,
                "locations": bare_excepts,
            },
            text="\n".join(text_lines) if text_lines else None,
            fix_hint="Use specific exception types (e.g., except ValueError:)"
            if not passed
            else None,
        )

    def _collect_bare_excepts(
        self,
        src_path: Path,
    ) -> list[dict[str, str | int]]:
        """Parse every ``.py`` file under *src_path* and gather bare excepts."""
        bare_excepts: list[dict[str, str | int]] = []
        for path in get_python_files(src_path):
            cache = get_ast_cache()
            tree = cache.get_or_parse(path) if cache else parse_file_safe(path)
            if tree is None:
                continue
            self._find_bare_excepts(tree, path, src_path, bare_excepts)
        return bare_excepts

    def _find_bare_excepts(
        self,
        tree: ast.Module,
        path: Path,
        src_path: Path,
        bare_excepts: list[dict[str, str | int]],
    ) -> None:
        """Find bare except clauses in a syntax tree."""
        for node in ast.walk(tree):
            if isinstance(node, ast.ExceptHandler):
                if node.type is None:
                    bare_excepts.append(
                        {
                            "file": str(path.relative_to(src_path)),
                            "line": node.lineno,
                        }
                    )
rule_id property

Unique identifier for this rule.

check(project_path)

Check for bare except clauses in the project.

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

    src_path = project_path / "src"
    bare_excepts = self._collect_bare_excepts(src_path)

    count = len(bare_excepts)
    passed = count == 0
    score = max(0, 100 - count * 20)

    text_lines = [
        f"     • {_short_path(str(loc['file']))}:{loc['line']}"
        for loc in bare_excepts
    ]

    return CheckResult(
        rule_id=self.rule_id,
        passed=passed,
        message=f"{count} bare except(s) found",
        severity=Severity.WARNING if not passed else Severity.INFO,
        score=int(score),
        details={
            "bare_except_count": count,
            "locations": bare_excepts,
        },
        text="\n".join(text_lines) if text_lines else None,
        fix_hint="Use specific exception types (e.g., except ValueError:)"
        if not passed
        else None,
    )

BlockingIORule dataclass

Bases: ProjectRule

Detect blocking I/O anti-patterns.

Source code in packages/axm-audit/src/axm_audit/core/rules/practices/blocking_io.py
Python
@dataclass
@register_rule("practices")
class BlockingIORule(ProjectRule):
    """Detect blocking I/O anti-patterns."""

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

    def check(self, project_path: Path) -> CheckResult:
        """Check for blocking I/O patterns in the project."""
        early = self.check_src(project_path)
        if early is not None:
            return early

        src_path = project_path / "src"

        violations: list[dict[str, str | int]] = []

        for path in get_python_files(src_path):
            cache = get_ast_cache()
            tree = cache.get_or_parse(path) if cache else parse_file_safe(path)
            if tree is None:
                continue
            rel = str(path.relative_to(src_path))
            self._check_async_sleep(tree, rel, violations)
            self._check_http_no_timeout(tree, rel, violations)

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

        text_lines = [f"• {v['file']}:{v['line']}: {v['issue']}" for v in violations]

        return CheckResult(
            rule_id=self.rule_id,
            passed=passed,
            message=f"{count} blocking-IO violation(s) found",
            severity=Severity.WARNING if not passed else Severity.INFO,
            score=int(score),
            details={"violations": violations},
            text="\n".join(text_lines) if text_lines else None,
            fix_hint=(
                "Use asyncio.sleep() instead of time.sleep() in async context; "
                "add timeout= to HTTP calls"
            )
            if not passed
            else None,
        )

    @staticmethod
    def _check_async_sleep(
        tree: ast.Module,
        rel: str,
        violations: list[dict[str, str | int]],
    ) -> None:
        """Find ``time.sleep()`` inside ``async def`` bodies."""
        for node in ast.walk(tree):
            if not isinstance(node, ast.AsyncFunctionDef):
                continue
            for child in ast.walk(node):
                if (
                    isinstance(child, ast.Call)
                    and isinstance(child.func, ast.Attribute)
                    and child.func.attr == "sleep"
                    and isinstance(child.func.value, ast.Name)
                    and child.func.value.id == "time"
                ):
                    violations.append(
                        {
                            "file": rel,
                            "line": child.lineno,
                            "issue": "time.sleep in async",
                        }
                    )

    @staticmethod
    def _check_http_no_timeout(
        tree: ast.Module,
        rel: str,
        violations: list[dict[str, str | int]],
    ) -> None:
        """Find HTTP calls without ``timeout=`` keyword argument."""
        for node in ast.walk(tree):
            if not (
                isinstance(node, ast.Call)
                and isinstance(node.func, ast.Attribute)
                and node.func.attr in _HTTP_METHODS
            ):
                continue

            if not _is_http_call(node.func.value):
                continue

            has_timeout = any(kw.arg == "timeout" for kw in node.keywords)
            if not has_timeout:
                violations.append(
                    {
                        "file": rel,
                        "line": node.lineno,
                        "issue": "HTTP call without timeout",
                    }
                )
rule_id property

Unique identifier for this rule.

check(project_path)

Check for blocking I/O patterns in the project.

Source code in packages/axm-audit/src/axm_audit/core/rules/practices/blocking_io.py
Python
def check(self, project_path: Path) -> CheckResult:
    """Check for blocking I/O patterns in the project."""
    early = self.check_src(project_path)
    if early is not None:
        return early

    src_path = project_path / "src"

    violations: list[dict[str, str | int]] = []

    for path in get_python_files(src_path):
        cache = get_ast_cache()
        tree = cache.get_or_parse(path) if cache else parse_file_safe(path)
        if tree is None:
            continue
        rel = str(path.relative_to(src_path))
        self._check_async_sleep(tree, rel, violations)
        self._check_http_no_timeout(tree, rel, violations)

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

    text_lines = [f"• {v['file']}:{v['line']}: {v['issue']}" for v in violations]

    return CheckResult(
        rule_id=self.rule_id,
        passed=passed,
        message=f"{count} blocking-IO violation(s) found",
        severity=Severity.WARNING if not passed else Severity.INFO,
        score=int(score),
        details={"violations": violations},
        text="\n".join(text_lines) if text_lines else None,
        fix_hint=(
            "Use asyncio.sleep() instead of time.sleep() in async context; "
            "add timeout= to HTTP calls"
        )
        if not passed
        else None,
    )

DocstringCoverageRule dataclass

Bases: ProjectRule

Calculate docstring coverage for public functions.

Public functions are those not starting with underscore.

Source code in packages/axm-audit/src/axm_audit/core/rules/practices/docstring_coverage.py
Python
@dataclass
@register_rule("practices")
class DocstringCoverageRule(ProjectRule):
    """Calculate docstring coverage for public functions.

    Public functions are those not starting with underscore.
    """

    min_coverage: float = 0.80

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

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

        src_path = project_path / "src"
        documented, missing = self._analyze_docstrings(src_path)
        return self._build_result(documented, missing)

    def _build_result(
        self,
        documented: int,
        missing: list[str],
    ) -> CheckResult:
        """Build CheckResult from docstring analysis."""
        total = documented + len(missing)
        coverage = documented / total if total > 0 else 1.0
        passed = coverage >= self.min_coverage
        score = int(coverage * 100)

        text: str | None = None
        if missing:
            groups: dict[str, list[str]] = {}
            for item in missing:
                file_part, _, func_name = item.rpartition(":")
                groups.setdefault(file_part, []).append(func_name)
            text_lines = [
                f"     • {path}: {', '.join(funcs)}" for path, funcs in groups.items()
            ]
            text = "\n".join(text_lines)

        return CheckResult(
            rule_id=self.rule_id,
            passed=passed,
            message=f"Docstring coverage: {coverage:.0%} ({documented}/{total})",
            severity=Severity.WARNING if not passed else Severity.INFO,
            score=int(score),
            details={
                "coverage": round(coverage, 2),
                "total": total,
                "documented": documented,
                "missing": missing,
            },
            text=text,
            fix_hint="Add docstrings to public functions" if missing else None,
        )

    def _analyze_docstrings(self, src_path: Path) -> tuple[int, list[str]]:
        """Analyze docstring coverage in source files."""
        documented = 0
        missing: list[str] = []

        file_trees: dict[Path, ast.Module] = {}
        for path in get_python_files(src_path):
            cache = get_ast_cache()
            tree = cache.get_or_parse(path) if cache else parse_file_safe(path)
            if tree is not None:
                file_trees[path] = tree

        global_classes: dict[str, list[ast.ClassDef]] = {}
        for tree in file_trees.values():
            for node in ast.walk(tree):
                if isinstance(node, ast.ClassDef):
                    global_classes.setdefault(node.name, []).append(node)

        for path, tree in file_trees.items():
            rel_path = path.relative_to(src_path)
            class_map = self._build_class_map(tree)
            doc, mis = self._check_file_docstrings(
                tree,
                rel_path,
                class_map,
                global_classes,
            )
            documented += doc
            missing.extend(mis)

        return documented, missing

    def _check_file_docstrings(
        self,
        tree: ast.Module,
        rel_path: Path,
        class_map: dict[str, ast.ClassDef],
        global_classes: dict[str, list[ast.ClassDef]],
    ) -> tuple[int, list[str]]:
        """Check docstring coverage for public functions in a single file."""
        documented = 0
        missing: list[str] = []
        for node in ast.walk(tree):
            if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
                continue
            if node.name.startswith("_"):
                continue
            if self._is_setter_or_deleter(node):
                continue
            if self.is_abstract_stub(node):
                continue
            if self._is_abstract_override(node, class_map, global_classes):
                continue

            if self._has_docstring(node):
                documented += 1
            else:
                missing.append(f"{rel_path}:{node.name}")
        return documented, missing

    @staticmethod
    def has_abstractmethod_decorator(
        node: ast.FunctionDef | ast.AsyncFunctionDef,
    ) -> bool:
        """Return *True* if *node* has an ``@abstractmethod`` decorator."""
        return any(
            (isinstance(d, ast.Name) and d.id == "abstractmethod")
            or (isinstance(d, ast.Attribute) and d.attr == "abstractmethod")
            for d in node.decorator_list
        )

    @staticmethod
    def is_stub_body(
        node: ast.FunctionDef | ast.AsyncFunctionDef,
    ) -> bool:
        """Return *True* if *node*'s body is just ``...`` or ``pass``."""
        if len(node.body) != 1:
            return False
        stmt = node.body[0]
        if isinstance(stmt, ast.Pass):
            return True
        return (
            isinstance(stmt, ast.Expr)
            and isinstance(stmt.value, ast.Constant)
            and stmt.value.value is ...
        )

    @staticmethod
    def is_abstract_stub(
        node: ast.FunctionDef | ast.AsyncFunctionDef,
    ) -> bool:
        """Check if node is an abstract method stub (body is ``...`` or ``pass``)."""
        return DocstringCoverageRule.has_abstractmethod_decorator(
            node
        ) and DocstringCoverageRule.is_stub_body(node)

    @staticmethod
    def _is_setter_or_deleter(
        node: ast.FunctionDef | ast.AsyncFunctionDef,
    ) -> bool:
        """Check if node is a property setter or deleter."""
        for dec in node.decorator_list:
            if isinstance(dec, ast.Attribute) and dec.attr in ("setter", "deleter"):
                return True
        return False

    @staticmethod
    def _build_class_map(
        tree: ast.Module,
    ) -> dict[str, ast.ClassDef]:
        """Build a name -> ClassDef map for all classes in the module."""
        return {
            node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
        }

    def _find_enclosing_class(
        self,
        node: ast.FunctionDef | ast.AsyncFunctionDef,
        class_map: dict[str, ast.ClassDef],
    ) -> ast.ClassDef | None:
        """Return the class whose body contains *node*, or None."""
        for cls in class_map.values():
            for item in cls.body:
                if item is node:
                    return cls
        return None

    def _resolve_base_class(
        self,
        base_name: str,
        class_map: dict[str, ast.ClassDef],
        global_classes: dict[str, list[ast.ClassDef]] | None,
    ) -> ast.ClassDef | None:
        """Resolve *base_name* to a ClassDef via same-file or cross-file lookup."""
        if base_name in class_map:
            return class_map[base_name]
        if global_classes and base_name in global_classes:
            definitions = global_classes[base_name]
            if len(definitions) == 1:
                return definitions[0]
        return None

    def _is_abstract_override(
        self,
        node: ast.FunctionDef | ast.AsyncFunctionDef,
        class_map: dict[str, ast.ClassDef],
        global_classes: dict[str, list[ast.ClassDef]] | None = None,
    ) -> bool:
        """Check if node overrides a documented abstractmethod."""
        enclosing = self._find_enclosing_class(node, class_map)
        if enclosing is None:
            return False

        for base in enclosing.bases:
            base_name = base.id if isinstance(base, ast.Name) else None
            if base_name is None:
                continue
            base_cls = self._resolve_base_class(base_name, class_map, global_classes)
            if base_cls is not None and self._check_abstract_parent(
                base_cls, node.name
            ):
                return True

        return False

    def _check_abstract_parent(
        self,
        base_cls: ast.ClassDef,
        method_name: str,
    ) -> bool:
        """Check if base_cls has a documented @abstractmethod named *method_name*."""
        for item in base_cls.body:
            if not isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef):
                continue
            if item.name != method_name:
                continue
            if self.has_abstractmethod_decorator(item) and self._has_docstring(item):
                return True
        return False

    def _has_docstring(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
        """Check if a function node has a docstring."""
        if not node.body:
            return False
        first = node.body[0]
        return (
            isinstance(first, ast.Expr)
            and isinstance(first.value, ast.Constant)
            and isinstance(first.value.value, str)
        )
rule_id property

Unique identifier for this rule.

check(project_path)

Check docstring coverage in the project.

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

    src_path = project_path / "src"
    documented, missing = self._analyze_docstrings(src_path)
    return self._build_result(documented, missing)
has_abstractmethod_decorator(node) staticmethod

Return True if node has an @abstractmethod decorator.

Source code in packages/axm-audit/src/axm_audit/core/rules/practices/docstring_coverage.py
Python
@staticmethod
def has_abstractmethod_decorator(
    node: ast.FunctionDef | ast.AsyncFunctionDef,
) -> bool:
    """Return *True* if *node* has an ``@abstractmethod`` decorator."""
    return any(
        (isinstance(d, ast.Name) and d.id == "abstractmethod")
        or (isinstance(d, ast.Attribute) and d.attr == "abstractmethod")
        for d in node.decorator_list
    )
is_abstract_stub(node) staticmethod

Check if node is an abstract method stub (body is ... or pass).

Source code in packages/axm-audit/src/axm_audit/core/rules/practices/docstring_coverage.py
Python
@staticmethod
def is_abstract_stub(
    node: ast.FunctionDef | ast.AsyncFunctionDef,
) -> bool:
    """Check if node is an abstract method stub (body is ``...`` or ``pass``)."""
    return DocstringCoverageRule.has_abstractmethod_decorator(
        node
    ) and DocstringCoverageRule.is_stub_body(node)
is_stub_body(node) staticmethod

Return True if node's body is just ... or pass.

Source code in packages/axm-audit/src/axm_audit/core/rules/practices/docstring_coverage.py
Python
@staticmethod
def is_stub_body(
    node: ast.FunctionDef | ast.AsyncFunctionDef,
) -> bool:
    """Return *True* if *node*'s body is just ``...`` or ``pass``."""
    if len(node.body) != 1:
        return False
    stmt = node.body[0]
    if isinstance(stmt, ast.Pass):
        return True
    return (
        isinstance(stmt, ast.Expr)
        and isinstance(stmt.value, ast.Constant)
        and stmt.value.value is ...
    )

MirrorRule dataclass

Bases: ProjectRule

Check the bidirectional 1:1 mapping between source modules and unit tests.

Forward direction — for each src/<pkg>/foo.py, looks for a test_foo.py under tests/ (or tests/unit/ when present).

Reverse direction (orphan check) — for each tests/unit/<rel>/test_<name>.py, requires that some package exposes src/<pkg>/<rel>/<name>.py. Tests at the wrong nesting level or pointing to nonexistent source modules are flagged as orphans. Reverse check only walks tests/unit/tests/integration/ and tests/e2e/ are scenario-named and never flagged.

Private modules (leading underscores) are matched with the prefix stripped: _facade.py matches test_facade.py or test__facade.py.

Exempt filenames (no test required): __init__.py, __main__.py, _version.py, conftest.py, py.typed.

Forward-mirror exemptions can additionally be declared in pyproject.toml (path globs anchored at src/<top_pkg>/)::

Text Only
[tool.axm-audit.mirror]
exempt_paths = ["commands/*.py", "schemas/*.py", "**/_facade.py"]

Exempted modules do not need a matching test and surface in details["exempt"]. Reverse (orphan) checks ignore exemptions.

Scoring: 100 - (len(missing) + len(orphan)) * 15, min 0.

Source code in packages/axm-audit/src/axm_audit/core/rules/practices/mirror.py
Python
@dataclass
@register_rule("practices")
class MirrorRule(ProjectRule):
    """Check the bidirectional 1:1 mapping between source modules and unit tests.

    Forward direction — for each ``src/<pkg>/foo.py``, looks for a
    ``test_foo.py`` under ``tests/`` (or ``tests/unit/`` when present).

    Reverse direction (orphan check) — for each ``tests/unit/<rel>/test_<name>.py``,
    requires that some package exposes ``src/<pkg>/<rel>/<name>.py``. Tests at the
    wrong nesting level or pointing to nonexistent source modules are flagged
    as orphans. Reverse check only walks ``tests/unit/`` — ``tests/integration/``
    and ``tests/e2e/`` are scenario-named and never flagged.

    Private modules (leading underscores) are matched with the prefix
    stripped: ``_facade.py`` matches ``test_facade.py`` or
    ``test__facade.py``.

    Exempt filenames (no test required): ``__init__.py``, ``__main__.py``,
    ``_version.py``, ``conftest.py``, ``py.typed``.

    Forward-mirror exemptions can additionally be declared in
    ``pyproject.toml`` (path globs anchored at ``src/<top_pkg>/``)::

        [tool.axm-audit.mirror]
        exempt_paths = ["commands/*.py", "schemas/*.py", "**/_facade.py"]

    Exempted modules do not need a matching test and surface in
    ``details["exempt"]``. Reverse (orphan) checks ignore exemptions.

    Scoring: ``100 - (len(missing) + len(orphan)) * 15``, min 0.
    """

    @property
    def rule_id(self) -> str:
        """Unique identifier for this rule (bidirectional mirror check)."""
        return "PRACTICE_TEST_MIRROR"

    def check(self, project_path: Path) -> CheckResult:
        """Check forward + reverse test/source mapping."""
        early = self.check_src(project_path)
        if early is not None:
            return early

        config = _load_mirror_config(project_path)
        if config.error is not None:
            return CheckResult(
                rule_id=self.rule_id,
                passed=False,
                message="Invalid mirror config",
                severity=Severity.WARNING,
                score=0,
                details={"missing": [], "orphan": [], "exempt": []},
                fix_hint=config.error,
            )

        src_path = project_path / "src"
        tests_path = project_path / "tests"
        missing, exempt = self._find_untested_modules(
            src_path, tests_path, config.exempt_paths
        )
        orphan = self._collect_orphan_tests(src_path, tests_path)

        if not missing and not orphan:
            return CheckResult(
                rule_id=self.rule_id,
                passed=True,
                message="All source modules have test files",
                severity=Severity.INFO,
                score=100,
                details={"missing": [], "orphan": [], "exempt": exempt},
            )

        violations = len(missing) + len(orphan)
        score = max(0, 100 - violations * 15)
        passed = score >= 90  # noqa: PLR2004

        hint = self._build_fix_hint(src_path, missing, orphan)
        text = self._build_text(missing, orphan)
        message = self._build_message(missing, orphan)

        return CheckResult(
            rule_id=self.rule_id,
            passed=passed,
            message=message,
            severity=Severity.WARNING if not passed else Severity.INFO,
            score=int(score),
            details={"missing": missing, "orphan": orphan, "exempt": exempt},
            fix_hint=hint,
            text=text,
        )

    @staticmethod
    def _build_message(missing: list[str], orphan: list[str]) -> str:
        """Build the summary message line."""
        parts = []
        if missing:
            parts.append(f"{len(missing)} source module(s) without tests")
        if orphan:
            parts.append(f"{len(orphan)} orphan test(s)")
        return "; ".join(parts) if parts else "All source modules have test files"

    @staticmethod
    def _build_text(missing: list[str], orphan: list[str]) -> str | None:
        """Build the multi-line text block (untested + orphan bullets)."""
        lines: list[str] = []
        if missing:
            shown = missing[:5]
            tail = f" (+{len(missing) - 5} more)" if len(missing) > 5 else ""  # noqa: PLR2004
            lines.append("• untested: " + " ".join(shown) + tail)
        if orphan:
            shown_o = orphan[:5]
            tail_o = f" (+{len(orphan) - 5} more)" if len(orphan) > 5 else ""  # noqa: PLR2004
            lines.append("• orphan: " + " ".join(shown_o) + tail_o)
        return "\n".join(lines) if lines else None

    @classmethod
    def _build_fix_hint(
        cls,
        src_path: Path,
        missing: list[str],
        orphan: list[str],
    ) -> str | None:
        """Build the fix hint covering both missing tests and orphan suggestions."""
        parts: list[str] = []
        if missing:
            files = ", ".join(f"tests/test_{m}" for m in missing[:5])
            if len(missing) > 5:  # noqa: PLR2004
                files += f" (+{len(missing) - 5} more)"
            parts.append(f"Create test files: {files}")
        if orphan:
            source_stems = sorted(
                {
                    Path(n).stem.lstrip("_")
                    for n in cls._collect_source_modules(src_path)
                }
            )
            for orphan_path in orphan[:5]:
                stem = Path(orphan_path).stem[len("test_") :]
                close = difflib.get_close_matches(stem, source_stems, n=1, cutoff=0.6)
                if close:
                    suggested = f"test_{close[0]}.py"
                    parts.append(
                        f"{orphan_path} → rename to {suggested} or merge into "
                        f"tests/unit/{suggested}"
                    )
                else:
                    parts.append(
                        f"{orphan_path} → delete or rename to a real source module"
                    )
            if len(orphan) > 5:  # noqa: PLR2004
                parts.append(f"(+{len(orphan) - 5} more orphans)")
        return "; ".join(parts) if parts else None

    @staticmethod
    def _collect_source_modules(src_path: Path) -> list[str]:
        """Collect non-exempt Python module basenames from ``src/``."""
        pkg_dirs = [
            d for d in src_path.iterdir() if d.is_dir() and d.name != "__pycache__"
        ]
        modules: list[str] = []
        for pkg_dir in pkg_dirs:
            for py_file in pkg_dir.rglob("*.py"):
                if py_file.name not in _TEST_MIRROR_EXEMPT:
                    modules.append(py_file.name)
        return modules

    @staticmethod
    def _collect_test_basenames(tests_path: Path) -> set[str]:
        """Collect ``test_*.py`` basenames eligible to satisfy the mirror rule.

        Mirror naming (1:1 with source modules) is a unit-level convention.
        Integration and e2e tests are scenario-named, so they must not count
        toward mirror coverage. If ``tests/unit/`` exists and contains test
        files, search there; otherwise fall back to the whole ``tests/`` tree
        (legacy flat layout) while still excluding ``integration/`` and
        ``e2e/`` subdirs.
        """
        if not tests_path.exists():
            return set()
        unit_path = tests_path / "unit"
        if unit_path.exists():
            unit_tests = {f.name for f in unit_path.rglob("test_*.py")}
            if unit_tests:
                return unit_tests
        return {
            f.name
            for f in tests_path.rglob("test_*.py")
            if "integration" not in f.parts and "e2e" not in f.parts
        }

    @staticmethod
    def _collect_unit_test_index(tests_path: Path) -> dict[str, set[str]]:
        """Index unit tests by rel directory; the ``test_`` prefix is stripped."""
        unit_path = tests_path / "unit"
        if not unit_path.is_dir():
            return {}
        index: dict[str, set[str]] = {}
        for test_file in unit_path.rglob("test_*.py"):
            if not test_file.is_file():
                continue
            rel_dir = test_file.parent.relative_to(unit_path).as_posix()
            rel_dir = "" if rel_dir == "." else rel_dir
            stem = test_file.stem[len("test_") :]
            bucket = index.setdefault(rel_dir, set())
            bucket.add(stem)
            bucket.add(stem.lstrip("_"))
        return index

    @staticmethod
    def _is_exempt_path(rel_posix: str, exempt_paths: list[str]) -> bool:
        """Return True if ``rel_posix`` matches any glob in ``exempt_paths``.

        Patterns are anchored at ``src/<pkg>/`` and matched segment-by-segment
        with ``fnmatch.fnmatchcase`` (so ``*`` and ``?`` never cross ``/``).
        A literal ``**`` segment matches zero or more path segments.
        """
        rel_parts = rel_posix.split("/")
        return any(
            _glob_segments_match(pattern.split("/"), rel_parts)
            for pattern in exempt_paths
        )

    @classmethod
    def _find_untested_modules(
        cls,
        src_path: Path,
        tests_path: Path,
        exempt_paths: list[str] | None = None,
    ) -> tuple[list[str], list[str]]:
        """Find source modules without corresponding test files.

        When ``tests/unit/`` is populated, the mirror is arborescence-aware:
        ``src/<pkg>/<rel>/<name>.py`` requires ``tests/unit/<rel>/test_<name>.py``.
        Otherwise (legacy flat layout, or empty ``tests/unit/``), basename
        matching is used.

        Returns ``(missing, exempt)`` where ``exempt`` is the list of
        basenames matched by ``exempt_paths`` globs (relative to
        ``src/<pkg>/``) — they are excluded from ``missing``.
        """
        if not src_path.is_dir():
            return [], []
        exempt_paths = exempt_paths or []
        unit_index = cls._collect_unit_test_index(tests_path)
        test_basenames = None if unit_index else cls._collect_test_basenames(tests_path)
        missing: list[str] = []
        exempt: list[str] = []
        for pkg_dir, py_file in cls._iter_source_modules(src_path):
            label = cls._classify_py_file(
                py_file, pkg_dir, unit_index, test_basenames, exempt_paths
            )
            if label == "exempt":
                exempt.append(py_file.name)
            elif label == "missing":
                missing.append(py_file.name)
        return sorted(set(missing)), sorted(set(exempt))

    @classmethod
    def _iter_source_modules(cls, src_path: Path) -> Iterator[tuple[Path, Path]]:
        """Yield ``(pkg_dir, py_file)`` for each non-exempt source module.

        Skips ``__pycache__``, files in ``_TEST_MIRROR_EXEMPT``, and
        deduplicates by basename across package directories.
        """
        seen: set[str] = set()
        for pkg_dir in sorted(src_path.iterdir()):
            if not pkg_dir.is_dir() or pkg_dir.name == "__pycache__":
                continue
            for py_file in sorted(pkg_dir.rglob("*.py")):
                if py_file.name in _TEST_MIRROR_EXEMPT or py_file.name in seen:
                    continue
                seen.add(py_file.name)
                yield pkg_dir, py_file

    @classmethod
    def _classify_py_file(
        cls,
        py_file: Path,
        pkg_dir: Path,
        unit_index: dict[str, set[str]] | None,
        test_basenames: set[str] | None,
        exempt_paths: list[str],
    ) -> str:
        """Return ``"exempt"``, ``"missing"``, or ``"covered"`` for ``py_file``."""
        rel_to_pkg = py_file.relative_to(pkg_dir).as_posix()
        if exempt_paths and cls._is_exempt_path(rel_to_pkg, exempt_paths):
            return "exempt"
        if not cls._has_matching_test(py_file, pkg_dir, unit_index, test_basenames):
            return "missing"
        return "covered"

    @staticmethod
    def _has_matching_test(
        py_file: Path,
        pkg_dir: Path,
        unit_index: dict[str, set[str]] | None,
        test_basenames: set[str] | None,
    ) -> bool:
        """Return True iff ``py_file`` has a matching test file."""
        stem = py_file.stem
        if unit_index:
            rel_dir = py_file.parent.relative_to(pkg_dir).as_posix()
            rel_dir = "" if rel_dir == "." else rel_dir
            available = unit_index.get(rel_dir, set())
            return bool({stem, stem.lstrip("_")} & available)
        candidates = {f"test_{stem.lstrip('_')}.py", f"test_{stem}.py"}
        return bool(candidates & (test_basenames or set()))

    @staticmethod
    def _collect_source_index(src_path: Path) -> dict[str, set[str]]:
        """Index source modules as ``{rel_dir: {stem variants}}``.

        Each non-exempt ``src/<pkg>/<rel>/<basename>.py`` contributes one entry
        per package: key = ``<rel>`` (POSIX, empty for package root), value
        contains both the original stem and the underscore-stripped stem so
        that ``_facade.py`` matches a ``test_facade.py`` test.
        """
        if not src_path.is_dir():
            return {}
        index: dict[str, set[str]] = {}
        for pkg_dir in src_path.iterdir():
            if not pkg_dir.is_dir() or pkg_dir.name == "__pycache__":
                continue
            for py_file in pkg_dir.rglob("*.py"):
                if py_file.name in _TEST_MIRROR_EXEMPT:
                    continue
                rel_dir = py_file.parent.relative_to(pkg_dir).as_posix()
                rel_dir = "" if rel_dir == "." else rel_dir
                stem = py_file.stem
                bucket = index.setdefault(rel_dir, set())
                bucket.add(stem)
                bucket.add(stem.lstrip("_"))
        return index

    @classmethod
    def _collect_orphan_tests(
        cls,
        src_path: Path,
        tests_path: Path,
    ) -> list[str]:
        """List ``tests/unit/`` test files with no matching source module.

        Walks ``tests/unit/**/test_*.py`` and flags each whose
        ``(rel_dir, stem)`` does not correspond to any source module under
        ``src/<pkg>/<rel_dir>/<stem>.py`` (with optional leading underscores).
        Returns POSIX ``tests/unit``-rooted paths sorted for determinism.
        Always returns ``[]`` when ``tests/unit/`` is absent.
        """
        unit_path = tests_path / "unit"
        if not unit_path.is_dir():
            return []
        src_index = cls._collect_source_index(src_path)
        orphans: list[str] = []
        for test_file in unit_path.rglob("test_*.py"):
            if not test_file.is_file():
                continue
            rel_dir = test_file.parent.relative_to(unit_path).as_posix()
            rel_dir = "" if rel_dir == "." else rel_dir
            test_stem = test_file.stem[len("test_") :]
            candidates = {test_stem, test_stem.lstrip("_")}
            available = src_index.get(rel_dir, set())
            if not candidates & available:
                rel_to_unit = test_file.relative_to(tests_path).as_posix()
                orphans.append(f"tests/{rel_to_unit}")
        return sorted(orphans)
rule_id property

Unique identifier for this rule (bidirectional mirror check).

check(project_path)

Check forward + reverse test/source mapping.

Source code in packages/axm-audit/src/axm_audit/core/rules/practices/mirror.py
Python
def check(self, project_path: Path) -> CheckResult:
    """Check forward + reverse test/source mapping."""
    early = self.check_src(project_path)
    if early is not None:
        return early

    config = _load_mirror_config(project_path)
    if config.error is not None:
        return CheckResult(
            rule_id=self.rule_id,
            passed=False,
            message="Invalid mirror config",
            severity=Severity.WARNING,
            score=0,
            details={"missing": [], "orphan": [], "exempt": []},
            fix_hint=config.error,
        )

    src_path = project_path / "src"
    tests_path = project_path / "tests"
    missing, exempt = self._find_untested_modules(
        src_path, tests_path, config.exempt_paths
    )
    orphan = self._collect_orphan_tests(src_path, tests_path)

    if not missing and not orphan:
        return CheckResult(
            rule_id=self.rule_id,
            passed=True,
            message="All source modules have test files",
            severity=Severity.INFO,
            score=100,
            details={"missing": [], "orphan": [], "exempt": exempt},
        )

    violations = len(missing) + len(orphan)
    score = max(0, 100 - violations * 15)
    passed = score >= 90  # noqa: PLR2004

    hint = self._build_fix_hint(src_path, missing, orphan)
    text = self._build_text(missing, orphan)
    message = self._build_message(missing, orphan)

    return CheckResult(
        rule_id=self.rule_id,
        passed=passed,
        message=message,
        severity=Severity.WARNING if not passed else Severity.INFO,
        score=int(score),
        details={"missing": missing, "orphan": orphan, "exempt": exempt},
        fix_hint=hint,
        text=text,
    )