Files
digital-psychology/scripts/repo-governance-check.py
T
jackyu66gitandCursor ad45d17dff chore(repo): 仓侧治理门禁 — ECR/migration 身份唯一
将编号与 schema 完整性留在 Repository Governance,不扩 ESS/Loop;TRACEABILITY 锚定 Next ECR=041、Max Migration=000050。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 14:26:30 +08:00

219 lines
6.7 KiB
Python

#!/usr/bin/env python3
"""Repository governance checks — outside ESS Loop.
Validates:
1. Migration stem uniqueness under apps/api/migrations/
2. ECR identity uniqueness (H1 / Canonical-ID) under docs/ECR/
Usage:
python scripts/repo-governance-check.py
python scripts/repo-governance-check.py --print-anchors
Exit 0 = PASS; non-zero = FAIL (merge/gate blocker).
"""
from __future__ import annotations
import argparse
import re
import sys
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MIG_DIR = ROOT / "apps" / "api" / "migrations"
ECR_DIR = ROOT / "docs" / "ECR"
# Full stem: 000018_admin_rbac (with or without .up/.down)
MIG_STEM_RE = re.compile(r"^(\d{6}_.+?)(?:\.(?:up|down))?\.sql$", re.I)
# Numeric prefix only (collision surface across branches)
MIG_NUM_RE = re.compile(r"^(\d{6})_")
ECR_ID_RE = re.compile(r"^ECR-\d+[A-Z]?(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?$", re.I)
H1_RE = re.compile(r"^#\s+(ECR-\S+)", re.M)
CANON_RE = re.compile(r"(?im)^\*\*?Canonical-ID:\*\*?\s*`?(ECR-[A-Za-z0-9-]+)`?\s*$")
def migration_stems(paths: list[Path]) -> dict[str, list[Path]]:
by_stem: dict[str, list[Path]] = defaultdict(list)
for p in paths:
m = MIG_STEM_RE.match(p.name)
if not m:
continue
by_stem[m.group(1)].append(p)
return by_stem
def migration_numbers(paths: list[Path]) -> dict[str, list[str]]:
"""Map 6-digit version → distinct full stems (duplicate numbers = FAIL)."""
by_num: dict[str, set[str]] = defaultdict(set)
for p in paths:
m = MIG_STEM_RE.match(p.name)
if not m:
continue
stem = m.group(1)
num_m = MIG_NUM_RE.match(stem)
if not num_m:
continue
by_num[num_m.group(1)].add(stem)
return {k: sorted(v) for k, v in by_num.items()}
def ecr_identity(path: Path) -> str | None:
text = path.read_text(encoding="utf-8")
canon = CANON_RE.search(text)
if canon:
return canon.group(1)
h1 = H1_RE.search(text)
if h1:
# Strip trailing punctuation from H1 token
return h1.group(1).rstrip(".:)")
return None
def max_migration_num(paths: list[Path]) -> int:
n = 0
for p in paths:
m = MIG_STEM_RE.match(p.name)
if not m:
continue
num_m = MIG_NUM_RE.match(m.group(1))
if num_m:
n = max(n, int(num_m.group(1)))
return n
def next_ecr_suggestion(identities: list[str]) -> str:
"""Next unused bare ECR-NNN (ignores letter/suffix variants for suggestion only)."""
nums: list[int] = []
for i in identities:
m = re.match(r"ECR-(\d+)", i, re.I)
if m:
nums.append(int(m.group(1)))
nxt = (max(nums) + 1) if nums else 1
return f"ECR-{nxt:03d}"
def check() -> int:
errors: list[str] = []
warnings: list[str] = []
if not MIG_DIR.is_dir():
errors.append(f"missing migrations dir: {MIG_DIR}")
mig_files: list[Path] = []
else:
mig_files = sorted(MIG_DIR.glob("*.sql"))
if not ECR_DIR.is_dir():
errors.append(f"missing ECR dir: {ECR_DIR}")
ecr_files: list[Path] = []
else:
ecr_files = sorted(ECR_DIR.glob("ECR-*.md"))
# --- migrations ---
by_stem = migration_stems(mig_files)
for stem, files in sorted(by_stem.items()):
# Same stem with both .up/.down is OK if only those; identical duplicate names shouldn't happen
names = [f.name for f in files]
if len(files) > 2 or (len(files) == 2 and not _up_down_pair(names)):
if len(set(names)) != len(names):
errors.append(f"migration duplicate filename: {names}")
elif len(files) > 2:
errors.append(f"migration stem over-shared ({stem}): {names}")
by_num = migration_numbers(mig_files)
for num, stems in sorted(by_num.items()):
if len(stems) > 1:
errors.append(
f"migration number collision {num}: {', '.join(stems)} "
f"(allocate next from TRACEABILITY Max Migration; never reuse)"
)
# --- ECR identity ---
by_id: dict[str, list[Path]] = defaultdict(list)
missing: list[Path] = []
invalid: list[tuple[Path, str]] = []
for p in ecr_files:
ident = ecr_identity(p)
if not ident:
missing.append(p)
continue
if not ECR_ID_RE.match(ident):
invalid.append((p, ident))
continue
by_id[ident].append(p)
for p in missing:
errors.append(f"ECR missing identity (H1 or Canonical-ID): {p.name}")
for p, ident in invalid:
errors.append(f"ECR invalid identity {ident!r} in {p.name}")
for ident, files in sorted(by_id.items()):
if len(files) > 1:
errors.append(
f"ECR identity collision {ident}: "
+ ", ".join(f.name for f in files)
+ " (ECR-NNN is globally unique; use ECR-NNN-suffix or ECR-NNNA)"
)
# Soft: bare number reserved — if ECR-012 and ECR-012-star both exist, OK;
# if two different files both declare ECR-012, caught above.
identities = list(by_id.keys())
max_mig = max_migration_num(mig_files)
next_ecr = next_ecr_suggestion(identities)
if errors:
print("REPO GOVERNANCE: FAIL")
for e in errors:
print(f" ERROR: {e}")
for w in warnings:
print(f" WARN: {w}")
return 1
print("REPO GOVERNANCE: PASS")
print(f" migrations: {len(by_num)} unique numbers (max {max_mig:06d})")
print(f" ECR identities: {len(by_id)} unique")
print(f" suggested Next ECR: {next_ecr}")
print(f" suggested Max Migration: {max_mig:06d}")
for w in warnings:
print(f" WARN: {w}")
return 0
def _up_down_pair(names: list[str]) -> bool:
if len(names) != 2:
return False
a, b = sorted(names)
return a.endswith(".up.sql") and b.endswith(".down.sql") and a[: -len(".up.sql")] == b[: -len(".down.sql")]
def print_anchors() -> int:
mig_files = sorted(MIG_DIR.glob("*.sql")) if MIG_DIR.is_dir() else []
ecr_files = sorted(ECR_DIR.glob("ECR-*.md")) if ECR_DIR.is_dir() else []
identities = []
for p in ecr_files:
i = ecr_identity(p)
if i:
identities.append(i)
max_mig = max_migration_num(mig_files)
print(f"Next ECR: {next_ecr_suggestion(identities)}")
print(f"Max Migration: {max_mig:06d}")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--print-anchors",
action="store_true",
help="Print Next ECR / Max Migration suggestions and exit 0",
)
args = ap.parse_args()
if args.print_anchors:
return print_anchors()
return check()
if __name__ == "__main__":
sys.exit(main())