Files
2026-07-13 13:12:33 +08:00

151 lines
5.3 KiB
Python

"""Hygiene lint over migrations/*.py.
Guards three invariants of the migrations directory:
* Version prefixes are unique — except the grandfathered V010 pair
(``V010__meta_skill_runs`` / ``V010__transcript_turn_usage``), which has
distinct yoyo ids and is safe today but must never gain company.
* Every migration declares ``__depends__`` so yoyo orders it explicitly.
* Every migration is reachable from the head of the dependency graph, so
dependency-aware partial-apply tooling cannot silently skip a leaf
(the V010 transcript-usage migration was such a leaf until V018
reconnected it).
Prefixes are NOT required to be strictly increasing or gap-free.
``__depends__`` is parsed with ``ast`` instead of importing the modules:
migration files call yoyo's ``step()`` at import time, which only works
inside yoyo's collector machinery.
"""
from __future__ import annotations
import ast
import re
from pathlib import Path
MIGRATIONS_DIR = Path(__file__).resolve().parents[2] / "migrations"
# The single allowed duplicate-prefix exception. Any NEW duplicate fails.
GRANDFATHERED_DUPLICATE_PREFIX = "V010"
GRANDFATHERED_V010_IDS = {
"V010__meta_skill_runs",
"V010__transcript_turn_usage",
}
_NAME_RE = re.compile(r"^(V(\d+))__.+\.py$")
def _migration_files() -> list[Path]:
files = sorted(p for p in MIGRATIONS_DIR.glob("V*.py") if p.is_file())
assert files, f"no migration files found under {MIGRATIONS_DIR}"
return files
def _parse_depends(path: Path) -> set[str] | None:
"""Return the module-level ``__depends__`` value, or None when absent."""
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in tree.body:
target: str | None = None
value: ast.expr | None = None
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
target, value = node.target.id, node.value
elif (
isinstance(node, ast.Assign)
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
):
target, value = node.targets[0].id, node.value
if target != "__depends__" or value is None:
continue
if (
isinstance(value, ast.Call)
and isinstance(value.func, ast.Name)
and value.func.id == "set"
and not value.args
):
return set()
deps = ast.literal_eval(value)
return {str(dep) for dep in deps}
return None
def test_migration_filenames_match_convention() -> None:
bad = [p.name for p in _migration_files() if not _NAME_RE.match(p.name)]
assert not bad, f"migration files must be named Vnnn__name.py: {bad}"
def test_version_prefixes_unique_except_grandfathered_v010() -> None:
by_prefix: dict[str, list[str]] = {}
for path in _migration_files():
match = _NAME_RE.match(path.name)
assert match is not None, path.name
by_prefix.setdefault(match.group(1), []).append(path.stem)
for prefix, ids in sorted(by_prefix.items()):
if prefix == GRANDFATHERED_DUPLICATE_PREFIX:
assert set(ids) == GRANDFATHERED_V010_IDS, (
f"the {prefix} prefix is grandfathered ONLY for "
f"{sorted(GRANDFATHERED_V010_IDS)}; found {sorted(ids)}"
)
continue
assert len(ids) == 1, (
f"duplicate migration version prefix {prefix}: {sorted(ids)}. "
"Pick the next unused Vnnn prefix — duplicate prefixes are a "
"hygiene hazard (only the historical V010 pair is allowed)."
)
def test_every_migration_declares_depends() -> None:
missing = [
path.name for path in _migration_files() if _parse_depends(path) is None
]
assert not missing, (
f"migrations without a module-level __depends__: {missing}. "
"Every migration must declare its dependencies explicitly."
)
def test_all_migrations_reachable_from_dependency_head() -> None:
"""The max-prefix migration's __depends__ closure must cover every file.
A migration outside the closure is a dangling leaf: dependency-aware
partial-apply tooling targeting the head can skip it entirely. New
migrations must name any current leaves in __depends__ (V018 did this
for V010__transcript_turn_usage).
"""
depends: dict[str, set[str]] = {}
prefix_num: dict[str, int] = {}
for path in _migration_files():
match = _NAME_RE.match(path.name)
assert match is not None, path.name
deps = _parse_depends(path)
assert deps is not None, path.name
depends[path.stem] = deps
prefix_num[path.stem] = int(match.group(2))
unknown = {
(mig, dep)
for mig, deps in depends.items()
for dep in deps
if dep not in depends
}
assert not unknown, f"__depends__ references unknown migrations: {unknown}"
head = max(depends, key=lambda mig: (prefix_num[mig], mig))
reachable: set[str] = set()
todo = [head]
while todo:
mig = todo.pop()
if mig in reachable:
continue
reachable.add(mig)
todo.extend(depends[mig])
dangling = sorted(set(depends) - reachable)
assert not dangling, (
f"migrations unreachable from dependency head {head}: {dangling}. "
"Add them to a newer migration's __depends__ so partial-apply "
"tooling cannot skip them."
)