Files
portfolio-hub/scripts/check_docs_sync.py
T

178 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Guardrail check for implementation-to-spec synchronization.
Rules:
- If files under app/ change, docs/portfoliohub-spec.md must be part of the same change set.
- If files under app/api/ or app/schemas/ change, docs/api-examples.md must be part of the same change set.
- If files under migrations/versions/ change, docs/db-schema-spec.md must be part of the same change set.
- If migrations/versions/ files change, revision IDs found in changed migration files
must appear in docs/db-schema-spec.md.
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GENERAL_SPEC = "docs/portfoliohub-spec.md"
DB_SPEC = "docs/db-schema-spec.md"
API_EXAMPLES_SPEC = "docs/api-examples.md"
APP_PREFIX = "app/"
API_PREFIXES = ("app/api/", "app/schemas/")
MIGRATION_PREFIX = "migrations/versions/"
class CheckError(Exception):
pass
def _run_git(args: list[str]) -> str:
result = subprocess.run(
["git", *args],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return result.stdout
def _ref_exists(ref: str) -> bool:
result = subprocess.run(
["git", "rev-parse", "--verify", "--quiet", ref],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
)
return result.returncode == 0
def _get_changed_files(*, staged: bool, against: str | None, head: str) -> list[str]:
if staged:
output = _run_git(["diff", "--name-only", "--cached", "--diff-filter=ACMR"])
elif against:
if not _ref_exists(against):
raise CheckError(f"base ref not found: {against}")
if not _ref_exists(head):
raise CheckError(f"head ref not found: {head}")
output = _run_git(["diff", "--name-only", "--diff-filter=ACMR", f"{against}...{head}"])
else:
if not _ref_exists("HEAD~1"):
# For a repository with a single commit, there is nothing to compare against.
return []
output = _run_git(["diff", "--name-only", "--diff-filter=ACMR", "HEAD~1", "HEAD"])
return [line.strip() for line in output.splitlines() if line.strip()]
def _extract_revision_id(path: Path) -> str | None:
text = path.read_text(encoding="utf-8")
match = re.search(r'^revision\s*=\s*["\']([^"\']+)["\']', text, flags=re.MULTILINE)
return match.group(1) if match else None
def _collect_changed_revision_ids(changed_files: list[str]) -> list[str]:
revision_ids: list[str] = []
for file in changed_files:
if not file.startswith(MIGRATION_PREFIX) or not file.endswith(".py"):
continue
migration_path = ROOT / file
if not migration_path.exists():
continue
revision_id = _extract_revision_id(migration_path)
if revision_id:
revision_ids.append(revision_id)
return revision_ids
def run_check(*, staged: bool, against: str | None, head: str) -> int:
try:
changed_files = _get_changed_files(staged=staged, against=against, head=head)
except CheckError as exc:
print(f"docs-sync-check: {exc}")
return 2
except subprocess.CalledProcessError as exc:
print("docs-sync-check: failed to read git diff")
print(exc.stderr.strip())
return 2
if not changed_files:
print("docs-sync-check: no changes detected")
return 0
needs_general_spec = any(path.startswith(APP_PREFIX) for path in changed_files)
needs_api_examples = any(path.startswith(prefix) for prefix in API_PREFIXES for path in changed_files)
needs_db_spec = any(path.startswith(MIGRATION_PREFIX) for path in changed_files)
errors: list[str] = []
if needs_general_spec and GENERAL_SPEC not in changed_files:
errors.append(
"Application code changed but docs/portfoliohub-spec.md was not updated in the same commit/PR."
)
if needs_api_examples and API_EXAMPLES_SPEC not in changed_files:
errors.append(
"API/schema code changed but docs/api-examples.md was not updated in the same commit/PR."
)
if needs_db_spec and DB_SPEC not in changed_files:
errors.append(
"Migration changed but docs/db-schema-spec.md was not updated in the same commit/PR."
)
if needs_db_spec:
db_spec_path = ROOT / DB_SPEC
if not db_spec_path.exists():
errors.append("docs/db-schema-spec.md is missing.")
else:
db_spec_text = db_spec_path.read_text(encoding="utf-8")
changed_revisions = _collect_changed_revision_ids(changed_files)
missing_revisions = [rev for rev in changed_revisions if rev not in db_spec_text]
if missing_revisions:
errors.append(
"Migration revision IDs missing in docs/db-schema-spec.md: "
+ ", ".join(sorted(set(missing_revisions)))
)
if errors:
print("docs-sync-check: FAILED")
for error in errors:
print(f"- {error}")
print("\nChanged files:")
for file in changed_files:
print(f"- {file}")
return 1
print("docs-sync-check: OK")
return 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Verify implementation and docs stay in sync.")
parser.add_argument(
"--staged",
action="store_true",
help="Check staged files (for pre-commit).",
)
parser.add_argument(
"--against",
default=None,
help="Git base ref/sha to diff against (for CI).",
)
parser.add_argument(
"--head",
default="HEAD",
help="Git head ref/sha used with --against.",
)
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
sys.exit(run_check(staged=args.staged, against=args.against, head=args.head))