Files
playbook/antigravity-awesome-skills/skills/drizzle-migration-conflict/scripts/check_drizzle_migrations.py
T
2026-07-03 16:04:10 +00:00

732 lines
25 KiB
Python
Executable File

#!/usr/bin/env python3
"""Read-only structural checks for Drizzle migration outputs.
This helper never connects to a database, never imports project code, and never writes
files. It only reads migration directories, parses `_journal.json`/snapshot JSON, and
reports structural inconsistencies.
Exit codes:
0 All checked migration directories are clean (no errors or warnings).
1 At least one error or warning issue was found.
2 No migration directories were discovered (pass --config or --migrations-dir).
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
def safe_user_path(path_value, base_dir="."):
"""Resolve a CLI path under the current workspace."""
if base_dir != ".":
raise ValueError("Custom base directories are not supported for CLI paths")
base_path = Path.cwd().resolve()
resolved_path = Path(path_value).expanduser().resolve()
try:
resolved_path.relative_to(base_path)
except ValueError as exc:
raise ValueError(f"Path escapes allowed directory: {path_value}") from exc
return resolved_path
from typing import Any, Iterable
CONFIG_NAME_PATTERN = re.compile(r"^drizzle(?:[.-].+)?\.config\.(?:ts|js|mjs|cjs|mts|cts)$")
COMMON_DIRS = (
"drizzle",
"migrations",
"src/db/migrations",
"db/migrations",
)
SKIP_DIR_NAMES = {
".git",
".hg",
".svn",
"node_modules",
".next",
".nuxt",
"dist",
"build",
"coverage",
"target",
"vendor",
"__pycache__",
}
CONFLICT_MARKERS = ("<<<<<<<", "=======", ">>>>>>>")
TEXT_SUFFIXES = {".sql", ".json", ".ts", ".js", ".mts", ".mjs", ".cts", ".cjs"}
@dataclass
class Issue:
severity: str
code: str
path: str
message: str
@dataclass
class DirectoryReport:
path: str
structure: str
issues: list[Issue]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Check Drizzle migration directories for read-only structural conflicts."
)
parser.add_argument("--root", default=".", help="Repository root or package root. Default: .")
parser.add_argument(
"--config",
action="append",
default=[],
help="Drizzle config file to inspect for an out directory. May be passed more than once.",
)
parser.add_argument(
"--migrations-dir",
action="append",
default=[],
help="Migration output directory. May be passed more than once.",
)
parser.add_argument(
"--allow-outside-root",
action="store_true",
help=(
"Allow explicit config/out or migration directories outside --root. "
"Only use when the user has named the exact path and you have confirmed it "
"contains no sensitive content; the script will still skip known vendored "
"directories but cannot guarantee what lives under an arbitrary root."
),
)
parser.add_argument("--json", action="store_true", help="Print JSON output.")
return parser.parse_args()
def strip_json_comments(text: str) -> str:
text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
text = re.sub(r"(^|\s)//.*$", r"\1", text, flags=re.M)
return text
def read_json(path: Path) -> tuple[Any | None, str | None]:
try:
return json.loads(strip_json_comments(path.read_text(encoding="utf-8"))), None
except Exception as exc: # noqa: BLE001 - error text is reported to the caller.
return None, str(exc)
def path_in_root(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
return True
except ValueError:
return False
def normalize_dir(root: Path, value: str) -> Path:
candidate = Path(value.strip())
if not candidate.is_absolute():
candidate = root / candidate
return candidate.resolve()
def relative(path: Path, root: Path) -> str:
try:
return str(path.relative_to(root))
except ValueError:
return str(path)
def make_issue(severity: str, code: str, path: Path | str, root: Path, message: str) -> Issue:
if isinstance(path, Path):
issue_path = relative(path, root)
else:
issue_path = path
return Issue(severity=severity, code=code, path=issue_path, message=message)
def add_issue(issues: list[Issue], severity: str, code: str, path: Path, root: Path, message: str) -> None:
issues.append(make_issue(severity, code, path, root, message))
def iter_config_files(
root: Path, explicit_configs: Iterable[str], allow_outside_root: bool
) -> tuple[list[Path], list[Issue]]:
issues: list[Issue] = []
configs: list[Path] = []
seen: set[Path] = set()
for value in explicit_configs:
path = normalize_dir(root, value)
if not allow_outside_root and not path_in_root(path, root):
issues.append(
make_issue(
"error",
"config-outside-root",
path,
root,
"Config path is outside --root. Pass --allow-outside-root only after verifying it is intended.",
)
)
continue
if not path.exists():
issues.append(make_issue("error", "missing-config", path, root, "Config file does not exist."))
continue
if path not in seen:
seen.add(path)
configs.append(path)
if explicit_configs:
return configs, issues
for path in safe_user_path(root).rglob("*"):
if not path.is_file() or any(part in SKIP_DIR_NAMES for part in path.parts):
continue
if CONFIG_NAME_PATTERN.match(path.name):
resolved = path.resolve()
if resolved not in seen:
seen.add(resolved)
configs.append(resolved)
return configs, issues
def parse_config_out_dirs(root: Path, configs: list[Path], allow_outside_root: bool) -> tuple[list[Path], list[Issue]]:
dirs: list[Path] = []
issues: list[Issue] = []
seen: set[Path] = set()
for config in configs:
try:
text = config.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
issues.append(make_issue("warning", "unreadable-config", config, root, f"Cannot read config as UTF-8: {exc}"))
continue
matches = list(re.finditer(r'''\bout\s*:\s*['"`]([^'"`]+)['"`]''', text))
if not matches:
issues.append(
make_issue(
"warning",
"config-out-not-found",
config,
root,
"No literal out directory found in config. If `out` is computed "
"(e.g. process.env.MIGRATIONS_DIR), pass --migrations-dir explicitly "
"so the migration directory is not missed.",
)
)
continue
for match in matches:
path = normalize_dir(config.parent, match.group(1))
if not allow_outside_root and not path_in_root(path, root):
issues.append(
make_issue(
"error",
"migrations-dir-outside-root",
path,
root,
"Config out directory is outside --root; refusing to scan it by default.",
)
)
continue
if path not in seen:
seen.add(path)
dirs.append(path)
return dirs, issues
def discover_dirs(args: argparse.Namespace, root: Path) -> tuple[list[Path], list[Issue]]:
issues: list[Issue] = []
dirs: list[Path] = []
seen: set[Path] = set()
for value in args.migrations_dir:
path = normalize_dir(root, value)
if not args.allow_outside_root and not path_in_root(path, root):
issues.append(
make_issue(
"error",
"migrations-dir-outside-root",
path,
root,
"Migration directory is outside --root; refusing to scan it by default.",
)
)
continue
if path not in seen:
seen.add(path)
dirs.append(path)
configs, config_issues = iter_config_files(root, args.config, args.allow_outside_root)
issues.extend(config_issues)
if not args.migrations_dir and configs:
if not args.config and len(configs) > 1:
issue_paths = ", ".join(relative(config, root) for config in configs)
issues.append(
make_issue(
"error",
"multiple-drizzle-configs",
root,
root,
f"Multiple Drizzle config files found ({issue_paths}); pass --config or --migrations-dir explicitly.",
)
)
return [], issues
config_dirs, out_issues = parse_config_out_dirs(root, configs, args.allow_outside_root)
issues.extend(out_issues)
for path in config_dirs:
if path.exists() and path not in seen:
seen.add(path)
dirs.append(path)
if dirs or issues:
return dirs, issues
# Only use common fallbacks when there are no Drizzle configs to disambiguate the output.
for value in COMMON_DIRS:
path = normalize_dir(root, value)
if path.exists() and path not in seen:
seen.add(path)
dirs.append(path)
return dirs, issues
def iter_text_files(directory: Path) -> Iterable[Path]:
for path in safe_user_path(directory).rglob("*"):
if not path.is_file() or any(part in SKIP_DIR_NAMES for part in path.parts):
continue
if path.suffix in TEXT_SUFFIXES:
yield path
def has_conflict_markers(path: Path) -> bool:
try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
if line.startswith(CONFLICT_MARKERS):
return True
except OSError:
return False
return False
def scan_conflict_markers(directory: Path, root: Path, issues: list[Issue]) -> None:
for path in iter_text_files(directory):
if has_conflict_markers(path):
add_issue(
issues,
"error",
"conflict-marker",
path,
root,
"File contains Git conflict markers.",
)
def structure_signals(directory: Path) -> tuple[bool, bool, list[Path]]:
journal = (directory / "meta" / "_journal.json").exists()
root_sql = any(path.is_file() for path in directory.glob("*.sql"))
meta_snapshots = any(path.is_file() for path in (directory / "meta").glob("*_snapshot.json"))
child_dirs = [path for path in directory.iterdir() if path.is_dir() and path.name != "meta"]
child_migration_files = any(
(child / "migration.sql").exists() or (child / "snapshot.json").exists() for child in child_dirs
)
legacy_signal = journal or root_sql or meta_snapshots
folder_signal = child_migration_files or (bool(child_dirs) and not legacy_signal)
return legacy_signal, folder_signal, child_dirs
def detect_structure(directory: Path) -> str:
if not directory.exists():
return "missing"
legacy_signal, folder_signal, _ = structure_signals(directory)
if legacy_signal and folder_signal:
return "mixed"
if legacy_signal:
return "legacy"
if folder_signal:
return "folder-based"
return "unknown"
def migration_number(stem: str) -> str | None:
match = re.match(r"^(\d+)(?:[_-].*)?$", stem)
return match.group(1) if match else None
def snapshot_names_for_entry(entry: dict[str, Any]) -> set[str]:
names: set[str] = set()
idx = entry.get("idx")
tag = entry.get("tag")
if isinstance(idx, int):
names.add(f"{idx:04d}_snapshot.json")
elif isinstance(idx, str) and idx.isdigit():
names.add(f"{int(idx):04d}_snapshot.json")
if isinstance(tag, str):
prefix = tag.split("_", 1)[0].split("-", 1)[0]
if prefix.isdigit():
names.add(f"{int(prefix):04d}_snapshot.json")
names.add(f"{prefix}_snapshot.json")
return names
def check_duplicate_values(
entries: list[dict[str, Any]], key: str, journal: Path, root: Path, issues: list[Issue]
) -> None:
values: dict[Any, int] = {}
for entry in entries:
value = entry.get(key)
if value is None:
continue
values[value] = values.get(value, 0) + 1
for value, count in values.items():
if count > 1:
add_issue(
issues,
"error",
f"duplicate-{key}",
journal,
root,
f"_journal.json contains duplicate {key} value {value!r} ({count} entries).",
)
def check_idx_gap(entries: list[dict[str, Any]], journal: Path, root: Path, issues: list[Issue]) -> None:
"""Warn when journal `idx` values are not contiguous starting from 0."""
idx_values: list[int] = []
for entry in entries:
idx = entry.get("idx")
if isinstance(idx, bool):
continue
if isinstance(idx, int):
idx_values.append(idx)
elif isinstance(idx, str) and idx.isdigit():
idx_values.append(int(idx))
if not idx_values:
return
sorted_idx = sorted(set(idx_values))
expected = list(range(sorted_idx[0], sorted_idx[0] + len(sorted_idx)))
if sorted_idx != expected or sorted_idx[0] != 0:
missing = sorted(set(expected) - set(sorted_idx))
gap_text = f"missing indices {missing}" if missing else f"starts at {sorted_idx[0]} instead of 0"
add_issue(
issues,
"warning",
"idx-gap",
journal,
root,
f"_journal.json idx sequence is not contiguous from 0 ({gap_text}). This can indicate a "
"conflict or a manually deleted migration.",
)
def check_snapshot_chain(
snapshots: list[tuple[Path, Any]], directory: Path, root: Path, issues: list[Issue]
) -> None:
"""Validate that snapshot `prevId` links form a chain over known snapshot `id` values."""
id_to_paths: dict[str, list[Path]] = {}
parsed: list[tuple[Path, str | None, str | None]] = []
for path, data in snapshots:
if not isinstance(data, dict):
continue
snap_id = data.get("id")
prev_id = data.get("prevId")
if isinstance(snap_id, str) and snap_id:
id_to_paths.setdefault(snap_id, []).append(path)
parsed.append((path, snap_id, prev_id if isinstance(prev_id, str) else None))
else:
parsed.append((path, None, prev_id if isinstance(prev_id, str) else None))
for snap_id, paths in id_to_paths.items():
if len(paths) > 1:
joined = ", ".join(relative(path, root) for path in paths)
add_issue(
issues,
"error",
"duplicate-snapshot-id",
paths[0],
root,
f"Multiple snapshot files share id {snap_id!r}: {joined}. Drizzle uses snapshot ids to "
"chain migrations; duplicates usually mean a generated file was copied instead of regenerated.",
)
known_ids = set(id_to_paths.keys())
for path, snap_id, prev_id in parsed:
if prev_id is None or prev_id == "":
continue
if prev_id not in known_ids:
add_issue(
issues,
"warning",
"broken-snapshot-chain",
path,
root,
f"Snapshot prevId {prev_id!r} does not match any snapshot id in {relative(directory, root)}. "
"The migration chain may be broken by a conflict or a partial repair.",
)
def validate_snapshot_json(path: Path, root: Path, issues: list[Issue]) -> Any | None:
data, error = read_json(path)
if error:
add_issue(issues, "error", "invalid-snapshot-json", path, root, f"Cannot parse snapshot JSON: {error}")
return None
return data
def check_legacy(directory: Path, root: Path) -> DirectoryReport:
issues: list[Issue] = []
journal = directory / "meta" / "_journal.json"
data, error = read_json(journal)
if error:
add_issue(issues, "error", "invalid-journal", journal, root, f"Cannot parse _journal.json: {error}")
scan_conflict_markers(directory, root, issues)
return DirectoryReport(str(relative(directory, root)), "legacy", issues)
if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
add_issue(
issues,
"error",
"invalid-journal-shape",
journal,
root,
"_journal.json must be an object with an entries array.",
)
entries: list[dict[str, Any]] = []
else:
entries = [entry for entry in data["entries"] if isinstance(entry, dict)]
check_duplicate_values(entries, "idx", journal, root, issues)
check_duplicate_values(entries, "tag", journal, root, issues)
check_idx_gap(entries, journal, root, issues)
expected_sql: set[str] = set()
expected_snapshots: set[str] = set()
for entry in entries:
tag = entry.get("tag")
if isinstance(tag, str) and tag:
expected_sql.add(f"{tag}.sql")
sql_path = directory / f"{tag}.sql"
if not sql_path.exists():
add_issue(
issues,
"error",
"missing-sql",
sql_path,
root,
f"Journal entry tag {tag!r} does not have a matching SQL file.",
)
snapshots = snapshot_names_for_entry(entry)
expected_snapshots.update(snapshots)
if snapshots and not any((directory / "meta" / name).exists() for name in snapshots):
add_issue(
issues,
"error",
"missing-snapshot",
directory / "meta" / sorted(snapshots)[0],
root,
f"Journal entry {entry!r} does not have a matching snapshot file.",
)
sql_files = sorted(path for path in directory.glob("*.sql") if path.is_file())
by_number: dict[str, list[Path]] = {}
for path in sql_files:
number = migration_number(path.stem)
if number:
by_number.setdefault(number, []).append(path)
if path.name not in expected_sql:
add_issue(
issues,
"warning",
"orphan-sql",
path,
root,
"SQL migration is not referenced by _journal.json.",
)
for number, paths in by_number.items():
if len(paths) > 1:
joined = ", ".join(relative(path, root) for path in paths)
add_issue(
issues,
"error",
"duplicate-migration-number",
paths[0],
root,
f"Multiple SQL migrations share number {number}: {joined}.",
)
snapshot_files = sorted((directory / "meta").glob("*_snapshot.json"))
parsed_snapshots: list[tuple[Path, Any | None]] = []
for path in snapshot_files:
data = validate_snapshot_json(path, root, issues)
parsed_snapshots.append((path, data))
if path.name not in expected_snapshots:
add_issue(
issues,
"warning",
"orphan-snapshot",
path,
root,
"Snapshot file is not referenced by _journal.json.",
)
check_snapshot_chain(parsed_snapshots, directory, root, issues)
scan_conflict_markers(directory, root, issues)
return DirectoryReport(str(relative(directory, root)), "legacy", issues)
def check_folder_based(directory: Path, root: Path) -> DirectoryReport:
issues: list[Issue] = []
names: dict[str, list[Path]] = {}
child_dirs = [path for path in directory.iterdir() if path.is_dir() and path.name != "meta"]
for child in sorted(child_dirs):
names.setdefault(child.name.lower(), []).append(child)
migration_sql = child / "migration.sql"
snapshot_json = child / "snapshot.json"
if not migration_sql.exists():
add_issue(
issues,
"error",
"missing-migration-sql",
migration_sql,
root,
"Folder-based migration is missing migration.sql.",
)
if not snapshot_json.exists():
add_issue(
issues,
"error",
"missing-snapshot-json",
snapshot_json,
root,
"Folder-based migration is missing snapshot.json.",
)
else:
validate_snapshot_json(snapshot_json, root, issues)
for lower_name, paths in names.items():
if len(paths) > 1:
joined = ", ".join(relative(path, root) for path in paths)
add_issue(
issues,
"error",
"duplicate-migration-directory",
paths[0],
root,
f"Migration directory name differs only by case for {lower_name!r}: {joined}.",
)
scan_conflict_markers(directory, root, issues)
return DirectoryReport(str(relative(directory, root)), "folder-based", issues)
def check_mixed(directory: Path, root: Path) -> DirectoryReport:
issues: list[Issue] = []
add_issue(
issues,
"error",
"mixed-structure",
directory,
root,
"Legacy journal/root SQL signals and folder-based migration signals coexist; choose the intended migration structure before repair.",
)
scan_conflict_markers(directory, root, issues)
return DirectoryReport(str(relative(directory, root)), "mixed", issues)
def check_directory(directory: Path, root: Path) -> DirectoryReport:
if not directory.exists():
return DirectoryReport(
str(relative(directory, root)),
"missing",
[
Issue(
severity="error",
code="missing-migrations-dir",
path=relative(directory, root),
message="Migration directory does not exist.",
)
],
)
structure = detect_structure(directory)
if structure == "mixed":
return check_mixed(directory, root)
if structure == "legacy":
return check_legacy(directory, root)
if structure == "folder-based":
return check_folder_based(directory, root)
issues: list[Issue] = []
add_issue(
issues,
"warning",
"unknown-structure",
directory,
root,
"Could not identify a legacy or folder-based Drizzle migration structure; skipping recursive scan.",
)
return DirectoryReport(str(relative(directory, root)), "unknown", issues)
def report_as_json(root: Path, reports: list[DirectoryReport]) -> str:
return json.dumps(
{
"root": str(root),
"checked_dirs": [asdict(report) for report in reports],
"issue_count": sum(len(report.issues) for report in reports),
"note": "This helper is structural only and does not replace drizzle-kit check.",
},
indent=2,
sort_keys=True,
)
def report_as_text(root: Path, reports: list[DirectoryReport]) -> str:
lines = [f"Drizzle migration check root: {root}"]
lines.append("Note: this helper is structural only and does not replace drizzle-kit check.")
if not reports:
lines.append("No migration directories found. Pass --config or --migrations-dir if detection missed one.")
return "\n".join(lines)
for report in reports:
lines.append(f"\nDirectory: {report.path}")
lines.append(f"Structure: {report.structure}")
if not report.issues:
lines.append("Issues: none")
continue
lines.append("Issues:")
for issue in report.issues:
lines.append(f"- [{issue.severity}] {issue.code}: {issue.path} - {issue.message}")
return "\n".join(lines)
def main() -> int:
args = parse_args()
root = safe_user_path(args.root).resolve()
dirs, discovery_issues = discover_dirs(args, root)
reports: list[DirectoryReport] = []
if discovery_issues:
reports.append(DirectoryReport(".", "discovery", discovery_issues))
reports.extend(check_directory(path, root) for path in dirs)
if args.json:
print(report_as_json(root, reports))
else:
print(report_as_text(root, reports))
if not reports:
return 2
if any(issue.severity == "error" for report in reports for issue in report.issues):
return 1
if any(issue.severity == "warning" for report in reports for issue in report.issues):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())