#!/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())