Pyramid-level soft-signal rule — R1+R2+R3 core.
Classifies every tests/**/test_*.py function into unit /
integration / e2e based on attr-IO, fixture arguments, taint of
tmp_path, and import provenance (public vs internal). Mismatches
between the classified level and the folder the test lives in are
reported as findings.
R4 (conftest fixture-IO resolution) and R5 (mock neutralisation) are
stubbed here as identities — ticket #4b replaces the two placeholders.
Finding
Bases: BaseModel
One classification verdict for a single test function.
Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/pyramid_level.py
| Python |
|---|
| class Finding(BaseModel): # type: ignore[explicit-any] # pydantic synthesizes __init__(**data: Any)
"""One classification verdict for a single test function."""
path: str
function: str
level: str
reason: str
current_level: str
has_real_io: bool
has_subprocess: bool
io_signals: list[str] = Field(default_factory=list)
imports_public: list[str] = Field(default_factory=list)
imports_internal: list[str] = Field(default_factory=list)
suggested_file: str = ""
severity: Severity = Severity.WARNING
model_config = {"extra": "forbid"}
|
PyramidCheckResult
Bases: CheckResult
:class:CheckResult subclass exposing findings and score.
Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/pyramid_level.py
| Python |
|---|
| class PyramidCheckResult(CheckResult): # type: ignore[explicit-any] # pydantic synthesizes __init__(**data: Any)
""":class:`CheckResult` subclass exposing ``findings`` and ``score``."""
findings: list[Finding] = Field(default_factory=list)
score: int = 100
model_config = {"extra": "forbid"}
|
PyramidLevelRule
dataclass
Bases: ProjectRule
Report tests whose classified pyramid level mismatches their folder.
Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/pyramid_level.py
| Python |
|---|
| @register_rule("test_quality")
@dataclass
class PyramidLevelRule(ProjectRule):
"""Report tests whose classified pyramid level mismatches their folder."""
strict_mismatches: bool = True
@property
def rule_id(self) -> str:
"""Stable identifier for this rule."""
return "TEST_QUALITY_PYRAMID_LEVEL"
def check(self, project_path: Path) -> PyramidCheckResult:
"""Classify tests in ``project_path`` against their pyramid folder."""
tests_dir = project_path / "tests"
if not tests_dir.exists():
return PyramidCheckResult(
rule_id=self.rule_id,
passed=True,
message="no tests/ directory",
severity=Severity.INFO,
score=100,
)
all_findings = scan_package(project_path)
mismatches = _filter_mismatches(all_findings, tests_dir)
count = len(mismatches) if self.strict_mismatches else 0
score = max(0, 100 - count * _SCORE_PENALTY)
passed = count == 0
message = (
"pyramid levels match folder layout"
if passed
else f"{count} test(s) mis-located vs. classified pyramid level"
)
details: dict[str, object] = {
"mismatches": [f.model_dump() for f in mismatches],
"total": len(mismatches),
}
text = render_mismatch_text(mismatches, project_path) if mismatches else None
fix_hint = (
None
if passed
else "Move tests to matching pyramid dir (use /pyramid-relocate)"
)
return PyramidCheckResult(
rule_id=self.rule_id,
passed=passed,
message=message,
severity=Severity.WARNING,
findings=all_findings,
score=score,
details=details,
text=text,
fix_hint=fix_hint,
)
|
rule_id
property
Stable identifier for this rule.
check(project_path)
Classify tests in project_path against their pyramid folder.
Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/pyramid_level.py
| Python |
|---|
| def check(self, project_path: Path) -> PyramidCheckResult:
"""Classify tests in ``project_path`` against their pyramid folder."""
tests_dir = project_path / "tests"
if not tests_dir.exists():
return PyramidCheckResult(
rule_id=self.rule_id,
passed=True,
message="no tests/ directory",
severity=Severity.INFO,
score=100,
)
all_findings = scan_package(project_path)
mismatches = _filter_mismatches(all_findings, tests_dir)
count = len(mismatches) if self.strict_mismatches else 0
score = max(0, 100 - count * _SCORE_PENALTY)
passed = count == 0
message = (
"pyramid levels match folder layout"
if passed
else f"{count} test(s) mis-located vs. classified pyramid level"
)
details: dict[str, object] = {
"mismatches": [f.model_dump() for f in mismatches],
"total": len(mismatches),
}
text = render_mismatch_text(mismatches, project_path) if mismatches else None
fix_hint = (
None
if passed
else "Move tests to matching pyramid dir (use /pyramid-relocate)"
)
return PyramidCheckResult(
rule_id=self.rule_id,
passed=passed,
message=message,
severity=Severity.WARNING,
findings=all_findings,
score=score,
details=details,
text=text,
fix_hint=fix_hint,
)
|
classify_level(*, has_real_io, has_subprocess, has_in_package_subprocess, imports_public, imports_internal)
Return (level, reason) for the given soft signals.
has_subprocess preserves raw subprocess diagnostics, while
has_in_package_subprocess is the narrower signal that promotes a test
to e2e. R2 public-only rescue MUST fire before the generic
has_public → integration branch; otherwise pure-function unit tests at
integration/ would be mis-classified.
Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/pyramid_level.py
| Python |
|---|
| def classify_level(
*,
has_real_io: bool,
has_subprocess: bool,
has_in_package_subprocess: bool,
imports_public: bool,
imports_internal: bool,
) -> tuple[str, str]:
"""Return ``(level, reason)`` for the given soft signals.
``has_subprocess`` preserves raw subprocess diagnostics, while
``has_in_package_subprocess`` is the narrower signal that promotes a test
to e2e. R2 public-only rescue MUST fire before the generic
``has_public \u2192 integration`` branch; otherwise pure-function unit tests at
integration/ would be mis-classified.
"""
if has_in_package_subprocess:
return "e2e", "in-package CLI invocation via subprocess (end-to-end)"
if not has_real_io and imports_public and not imports_internal:
return "unit", "public API import, no real I/O (pure function unit test)"
if has_real_io:
if imports_public:
detail = " + public import"
elif imports_internal:
detail = " + internal import"
else:
detail = " without package import"
return "integration", f"real I/O{detail} (integration)"
if imports_internal:
return "unit", "internal import, no real I/O (unit)"
return "unit", "no real I/O, no package import (unit)"
|
has_in_package_subprocess_invocation(*, call, module_ast, project_scripts)
Return true when call invokes a declared package script.
Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/_shared.py
| Python |
|---|
| def has_in_package_subprocess_invocation(
*,
call: ast.Call,
module_ast: ast.Module,
project_scripts: set[str],
) -> bool:
"""Return true when *call* invokes a declared package script."""
if not project_scripts:
return False
argv = _argv_from_call(call, module_ast)
if argv is None:
return False
return _argv_contains_package_entrypoint(argv, project_scripts)
|
load_project_scripts(pkg_root)
Return scripts declared by [project.scripts] in pyproject.toml.
Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/_shared.py
| Python |
|---|
| def load_project_scripts(pkg_root: Path) -> set[str]:
"""Return scripts declared by ``[project.scripts]`` in pyproject.toml."""
pyproject = pkg_root / "pyproject.toml"
if not pyproject.exists():
return set()
with pyproject.open("rb") as handle:
data = tomllib.load(handle)
scripts = data.get("project", {}).get("scripts", {})
if not isinstance(scripts, dict):
return set()
return {name for name in scripts if isinstance(name, str)}
|
relpath(path, project_path)
Return path relative to project_path if possible, else absolute.
Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/pyramid_level.py
| Python |
|---|
| def relpath(path: str, project_path: Path) -> str:
"""Return *path* relative to *project_path* if possible, else absolute."""
try:
return str(Path(path).relative_to(project_path))
except ValueError:
return path
|
render_mismatch_text(mismatches, project_path)
Render top-N mismatched findings as a compact bullet list.
Paths are relativized to project_path to keep the text dense; falls
back to the absolute path if the finding lives outside the project root.
Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/pyramid_level.py
| Python |
|---|
| def render_mismatch_text(mismatches: list[Finding], project_path: Path) -> str:
"""Render top-N mismatched findings as a compact bullet list.
Paths are relativized to *project_path* to keep the text dense; falls
back to the absolute path if the finding lives outside the project root.
"""
lines = [
f"• {relpath(f.path, project_path)}:{f.function} "
f"{f.current_level}→{f.level} ({f.reason})"
for f in mismatches[:_MAX_TEXT_ITEMS]
]
if len(mismatches) > _MAX_TEXT_ITEMS:
lines.append(f"(+{len(mismatches) - _MAX_TEXT_ITEMS} more)")
return "\n".join(lines)
|
scan_package(pkg_root)
Scan every test file under <pkg_root>/tests and return findings.
Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/pyramid_level.py
| Python |
|---|
| def scan_package(pkg_root: Path) -> list[Finding]:
"""Scan every test file under ``<pkg_root>/tests`` and return findings."""
tests_dir = pkg_root / "tests"
if not tests_dir.exists():
return []
pkg_prefixes = get_pkg_prefixes(pkg_root)
init_all = get_init_all(pkg_root)
findings: list[Finding] = []
for test_file, tree in iter_test_files(pkg_root):
if tree is None:
continue
findings.extend(
scan_test_file(test_file, tree, pkg_root, pkg_prefixes, init_all, tests_dir)
)
return findings
|
scan_test_file(test_file, tree, pkg_root, pkg_prefixes, init_all, tests_dir)
Classify every test_* function in tree and return findings.
Emits one :class:Finding per function whose classified level differs
from its folder-derived current level.
Source code in packages/axm-audit/src/axm_audit/core/rules/test_quality/pyramid_level.py
| Python |
|---|
| def scan_test_file( # noqa: PLR0913
test_file: Path,
tree: ast.Module,
pkg_root: Path,
pkg_prefixes: set[str],
init_all: set[str] | None,
tests_dir: Path,
) -> list[Finding]:
"""Classify every ``test_*`` function in *tree* and return findings.
Emits one :class:`Finding` per function whose classified level differs
from its folder-derived current level.
"""
ctx = _build_scan_context(
test_file, tree, pkg_root, pkg_prefixes, init_all, tests_dir
)
return [
_classify_test_function(ctx, node)
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name.startswith("test_")
]
|