Skip to content

File naming

file_naming

TEST_QUALITY_FILE_NAMING rule.

For every integration / e2e test file, derive the canonical test_*.py filename from the top-K=2 tuple of (first-party symbols | CLI invocations) and compare with the current basename. Three verdicts:

  • NAME_MISMATCH (INFO) — the file uses a name that diverges from the canonical tuple. Surfaced as signal, not as a defect: cohesive packages often pick human scenario names that beat the canonical tuple.
  • SPLIT (WARNING) — the file holds tests that map to multiple distinct canonical tuples. A structural problem of the file boundary, regardless of the chosen name.
  • COLLIDE (WARNING) — two or more files in the same tier emit the same canonical name. Same structural-boundary problem viewed in the other direction.

The rule auto-skips tests/unit/ (handled by PRACTICE_TEST_MIRROR) and respects a file-level / per-test pytest.mark.scenario_name_ok marker that suppresses NAME_MISMATCH (only).

FileNamingRule

Bases: ProjectRule

Surface integration / e2e test files whose name diverges from the canonical tuple, or whose tests structurally belong in several files.

Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/file_naming.py
Python
@register_rule(category="test_quality")
class FileNamingRule(ProjectRule):
    """Surface integration / e2e test files whose name diverges from the
    canonical tuple, or whose tests structurally belong in several files.
    """

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

    def check(self, project_path: Path) -> CheckResult:
        """Scan integration/e2e tests and emit naming findings."""
        early = self.check_src(project_path)
        if early is not None:
            return early
        tests_dir = project_path / "tests"
        if not tests_dir.exists():
            return CheckResult(
                rule_id=self.rule_id,
                passed=True,
                message="no tests/ directory",
                severity=Severity.INFO,
                score=100,
            )
        ctx = _build_scan_context(project_path)
        verdicts: list[_FileVerdict] = []
        for test_file, tree in iter_test_files(project_path):
            if tree is None:
                continue
            tier = current_level_from_path(test_file, tests_dir)
            if tier not in {"integration", "e2e"}:
                continue
            verdict = _aggregate_file(test_file, tree, tier, ctx)
            if verdict is not None:
                verdicts.append(verdict)
        findings = self._collect_findings(verdicts, project_path, ctx)
        return self._build_check_result(findings)

    @staticmethod
    def _collect_findings(
        verdicts: list[_FileVerdict], root: Path, ctx: _ScanContext
    ) -> list[Finding]:
        out: list[Finding] = []
        for v in verdicts:
            mismatch = _mismatch_finding(v, root)
            if mismatch is not None:
                out.append(mismatch)
            split = _split_finding(v, root, ctx)
            if split is not None:
                out.append(split)
        out.extend(_collide_findings(verdicts, root))
        return out

    def _build_check_result(self, findings: list[Finding]) -> CheckResult:
        n_info = sum(1 for f in findings if f.severity == Severity.INFO)
        n_warning = sum(1 for f in findings if f.severity == Severity.WARNING)
        score = max(0, 100 - _INFO_PENALTY * n_info - _WARNING_PENALTY * n_warning)
        passed = n_warning == 0
        message = (
            f"{len(findings)} naming finding(s): {n_info} INFO + {n_warning} WARNING"
            if findings
            else "every integration/e2e file matches its canonical tuple"
        )
        return CheckResult(
            rule_id=self.rule_id,
            passed=passed,
            message=message,
            severity=Severity.WARNING if n_warning else Severity.INFO,
            score=score,
            details={"findings": [f.as_dict() for f in findings]},
            text=render_findings_text(findings),
        )
rule_id property

Stable identifier for this rule.

check(project_path)

Scan integration/e2e tests and emit naming findings.

Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/file_naming.py
Python
def check(self, project_path: Path) -> CheckResult:
    """Scan integration/e2e tests and emit naming findings."""
    early = self.check_src(project_path)
    if early is not None:
        return early
    tests_dir = project_path / "tests"
    if not tests_dir.exists():
        return CheckResult(
            rule_id=self.rule_id,
            passed=True,
            message="no tests/ directory",
            severity=Severity.INFO,
            score=100,
        )
    ctx = _build_scan_context(project_path)
    verdicts: list[_FileVerdict] = []
    for test_file, tree in iter_test_files(project_path):
        if tree is None:
            continue
        tier = current_level_from_path(test_file, tests_dir)
        if tier not in {"integration", "e2e"}:
            continue
        verdict = _aggregate_file(test_file, tree, tier, ctx)
        if verdict is not None:
            verdicts.append(verdict)
    findings = self._collect_findings(verdicts, project_path, ctx)
    return self._build_check_result(findings)

compute_canonical_name(test_file, project_path)

Public canonical-name helper for one integration/e2e test file.

Returns None when the file is not in an integration/e2e tier, has no tests, or has no first-party symbol coverage. Otherwise returns the canonical test_*.py basename FILE_NAMING would emit.

Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/file_naming.py
Python
def compute_canonical_name(test_file: Path, project_path: Path) -> str | None:
    """Public canonical-name helper for one integration/e2e test file.

    Returns ``None`` when the file is not in an integration/e2e tier,
    has no tests, or has no first-party symbol coverage. Otherwise
    returns the canonical ``test_*.py`` basename FILE_NAMING would emit.
    """
    verdict = _verdict_for_file(test_file, project_path)
    return verdict.canonical if verdict is not None else None

render_findings_text(findings)

Render top-N findings as a compact bullet list.

Caps at _MAX_TEXT_WARNINGS warnings + _MAX_TEXT_INFOS infos. Returns None for empty input, matching the convention used by render_clusters_text for the passing case.

Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/file_naming.py
Python
def render_findings_text(findings: list[Finding]) -> str | None:
    """Render top-N findings as a compact bullet list.

    Caps at ``_MAX_TEXT_WARNINGS`` warnings + ``_MAX_TEXT_INFOS`` infos.
    Returns ``None`` for empty input, matching the convention used by
    ``render_clusters_text`` for the passing case.
    """
    if not findings:
        return None
    warnings = [f for f in findings if f.severity == Severity.WARNING]
    infos = [f for f in findings if f.severity == Severity.INFO]
    shown_warnings = warnings[:_MAX_TEXT_WARNINGS]
    shown_infos = infos[:_MAX_TEXT_INFOS]
    lines = [
        f"• [{f.severity.name}] {f.path}{f.proposed_name}"
        for f in (*shown_warnings, *shown_infos)
    ]
    truncated_warnings = len(warnings) - len(shown_warnings)
    truncated_infos = len(infos) - len(shown_infos)
    extra = truncated_warnings + truncated_infos
    if extra:
        lines.append(
            f"(+{extra} more findings: "
            f"{truncated_warnings} WARNING, {truncated_infos} INFO)"
        )
    return "\n".join(lines)