Skip to content

Layout and move

layout_and_move

Tier-layout reshape + safe move wrapper.

Splits into four concerns:

  • relocate_non_canonical_tiers — Stage 0.5 (B4 fix): move tests/functional/ and any other non-canonical tier subdir into tests/integration/ so the rest of the pipeline only ever sees canonical tiers (unit / integration / e2e).
  • flatten_tier_layout + _flatten_single_tier + _prune_empty_test_subdirs — Stage 1.5: collapse nested tests/integration/<subdir>/ to flat layout the AXM convention requires.
  • _rewrite_cross_test_imports — when a file moves and changes its dotted module path, rewrite every from <old_module> import ... in the project so importers don't break.
  • _safe_move_units + _resolve_helper_conflicts + _resolve_conftest_shadowing — wrap axm-anvil's move_symbols with collision detection, helper-body conflict resolution, and conftest shadowing guards. The bulk of the proto's "magic" lives here.

flatten_tier_layout(project_path)

Flatten tests/integration/ and tests/e2e/ subdirectories.

The AXM convention (CLAUDE.md) requires integration and e2e tests to live directly under their tier directory — no nested tests/integration/hooks/test_x.py. This stage moves every nested test_*.py up to the tier root, renames on collision by prefixing the subdirectory name (hooks/test_x.pytest_hooks_x.py), rewrites importers via _rewrite_cross_test_imports, and removes the now-empty subdirectories (preserving __init__.py / conftest.py by skipping the prune if those remain).

Runs AFTER Stage 1 (RELOCATE) so it acts on the final tier classification, and BEFORE Stages 2-4 (SPLIT/MERGE/RENAME) which assume a flat layout.

Unit tests intentionally MIRROR the source layout — nested subdirectories are correct there, so this stage skips tests/unit.

Source code in packages/axm-audit/src/axm_audit/core/fix/layout_and_move.py
Python
def flatten_tier_layout(project_path: Path) -> list[str]:
    """Flatten ``tests/integration/`` and ``tests/e2e/`` subdirectories.

    The AXM convention (CLAUDE.md) requires integration and e2e tests
    to live *directly* under their tier directory — no nested
    ``tests/integration/hooks/test_x.py``. This stage moves every
    nested ``test_*.py`` up to the tier root, renames on collision
    by prefixing the subdirectory name (``hooks/test_x.py`` →
    ``test_hooks_x.py``), rewrites importers via
    ``_rewrite_cross_test_imports``, and removes the now-empty
    subdirectories (preserving ``__init__.py`` / ``conftest.py`` by
    skipping the prune if those remain).

    Runs AFTER Stage 1 (RELOCATE) so it acts on the final tier
    classification, and BEFORE Stages 2-4 (SPLIT/MERGE/RENAME) which
    assume a flat layout.

    Unit tests intentionally MIRROR the source layout — nested
    subdirectories are correct there, so this stage skips ``tests/unit``.
    """
    msgs: list[str] = []
    tests_root = project_path / "tests"
    if not tests_root.is_dir():
        return msgs
    for tier in ("integration", "e2e"):
        tier_dir = tests_root / tier
        if not tier_dir.is_dir():
            continue
        msgs.extend(_flatten_single_tier(project_path, tier_dir))
    return msgs

relocate_non_canonical_tiers(project_path)

Move tests/<non_canonical>/test_*.py into tests/integration/.

Tiers unit/, integration/, e2e/ are the only canonical pyramid directories (per CLAUDE.md). Files living under any other direct child of tests/ — e.g. tests/functional/, tests/hooks/, tests/tools/ — cannot be processed by SPLIT / MERGE / RENAME because tier_for_path returns None for them.

Default destination is tests/integration/ (the tier for real I/O + first-party import), which is the most common landing for legacy functional/ tests. Stage 1 RELOCATE will subsequently re-tier each file to its correct level based on PYRAMID_LEVEL findings.

Runs BEFORE Stage 1 so RELOCATE sees only canonical-tier paths.

Source code in packages/axm-audit/src/axm_audit/core/fix/layout_and_move.py
Python
def relocate_non_canonical_tiers(project_path: Path) -> list[str]:
    """Move ``tests/<non_canonical>/test_*.py`` into ``tests/integration/``.

    Tiers ``unit/``, ``integration/``, ``e2e/`` are the only canonical
    pyramid directories (per CLAUDE.md). Files living under any other
    direct child of ``tests/`` — e.g. ``tests/functional/``,
    ``tests/hooks/``, ``tests/tools/`` — cannot be processed by SPLIT /
    MERGE / RENAME because ``tier_for_path`` returns ``None`` for them.

    Default destination is ``tests/integration/`` (the tier for real I/O
    + first-party import), which is the most common landing for legacy
    ``functional/`` tests. Stage 1 RELOCATE will subsequently re-tier
    each file to its correct level based on PYRAMID_LEVEL findings.

    Runs BEFORE Stage 1 so RELOCATE sees only canonical-tier paths.
    """
    msgs: list[str] = []
    tests_root = project_path / "tests"
    if not tests_root.is_dir():
        return msgs
    integration = tests_root / "integration"
    for child in _iter_non_canonical_tier_dirs(tests_root):
        nested_tests = sorted(p for p in child.rglob("test_*.py") if p.is_file())
        if not nested_tests:
            continue
        _ensure_integration_pkg(integration, child)
        for src in nested_tests:
            target = _unique_integration_target(src, integration)
            msgs.extend(_relocate_single_file(src, target, project_path))
        _prune_empty_test_subdirs(child)
        if child.exists() and not any(child.iterdir()):
            child.rmdir()
    return msgs