#!/usr/bin/env python3 """Validate the Coulomb Research paper registry and its referenced artifacts. Checks, per docs/ResearchArtifactConvention.md: 1. research/registry/papers.yaml parses as YAML and each entry has the required fields (id, title, program, stage, version, status, path). 2. Paper ids are unique within the registry. 3. Each entry's `path` exists and contains a publication.yaml. 4. Each entry's stage/version/status agree with the paper's own publication.yaml (paper_id, publication_stage, version, publication_state). 5. Markdown links of the form [text](relative/path) inside the paper's own files (README.md, manuscript.md, claims.md, evidence.md, criticism.md, revision-log.md) that point at a relative file resolve to an existing file. No external dependencies beyond PyYAML (`import yaml`), which is already relied on by this facility's tooling. This is intentionally a single script, not a test framework or package, because the artifact set it validates is a handful of small, hand-authored files -- see the "smallest repeatable validation tool" note in workplans/CR-WP-0002-research-facility-skeleton.md task T03. Usage: python3 scripts/validate_research_registry.py Exit status is non-zero if any check fails. """ import re import sys from pathlib import Path import yaml REPO_ROOT = Path(__file__).resolve().parent.parent REGISTRY_PATH = REPO_ROOT / "research" / "registry" / "papers.yaml" REQUIRED_REGISTRY_FIELDS = ["id", "title", "program", "stage", "version", "status", "path"] PAPER_STAGE_STATE_MAP = { "P1": "internal-circulation", } MD_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)") PAPER_MD_FILES = [ "README.md", "manuscript.md", "claims.md", "evidence.md", "criticism.md", "revision-log.md", ] def fail(errors, message): errors.append(message) def check_registry(errors): if not REGISTRY_PATH.exists(): fail(errors, f"missing registry file: {REGISTRY_PATH}") return [] with REGISTRY_PATH.open() as f: try: data = yaml.safe_load(f) except yaml.YAMLError as exc: fail(errors, f"registry YAML did not parse: {exc}") return [] papers = (data or {}).get("papers") if not isinstance(papers, list) or not papers: fail(errors, "registry has no non-empty 'papers' list") return [] seen_ids = set() for entry in papers: missing = [f for f in REQUIRED_REGISTRY_FIELDS if f not in entry] if missing: fail(errors, f"registry entry {entry.get('id', '')} missing fields: {missing}") continue pid = entry["id"] if pid in seen_ids: fail(errors, f"duplicate paper id in registry: {pid}") seen_ids.add(pid) return papers def check_paper(errors, entry): pid = entry["id"] paper_dir = REPO_ROOT / entry["path"] if not paper_dir.is_dir(): fail(errors, f"{pid}: registry path does not exist or is not a directory: {entry['path']}") return pub_path = paper_dir / "publication.yaml" if not pub_path.exists(): fail(errors, f"{pid}: missing publication.yaml at {pub_path}") return with pub_path.open() as f: try: pub = yaml.safe_load(f) except yaml.YAMLError as exc: fail(errors, f"{pid}: publication.yaml did not parse: {exc}") return if pub.get("paper_id") != pid: fail(errors, f"{pid}: publication.yaml paper_id {pub.get('paper_id')!r} != registry id {pid!r}") if pub.get("publication_stage") != entry["stage"]: fail( errors, f"{pid}: publication_stage {pub.get('publication_stage')!r} != registry stage {entry['stage']!r}", ) if str(pub.get("version")) != str(entry["version"]): fail(errors, f"{pid}: version {pub.get('version')!r} != registry version {entry['version']!r}") if pub.get("publication_state") != entry["status"]: fail( errors, f"{pid}: publication_state {pub.get('publication_state')!r} != registry status {entry['status']!r}", ) expected_state = PAPER_STAGE_STATE_MAP.get(pub.get("publication_stage")) if expected_state and pub.get("publication_state") != expected_state: fail( errors, f"{pid}: publication_state {pub.get('publication_state')!r} does not match " f"expected state {expected_state!r} for stage {pub.get('publication_stage')!r}", ) for name in PAPER_MD_FILES: md_path = paper_dir / name if not md_path.exists(): fail(errors, f"{pid}: missing required artifact {name}") continue check_markdown_links(errors, pid, md_path) def check_markdown_links(errors, pid, md_path): text = md_path.read_text() for match in MD_LINK_RE.finditer(text): target = match.group(1).strip() if target.startswith(("http://", "https://", "#", "mailto:")): continue target_path = (md_path.parent / target).resolve() if not target_path.exists(): fail(errors, f"{pid}: {md_path.relative_to(REPO_ROOT)} has broken link: {target}") def main(): errors = [] papers = check_registry(errors) for entry in papers: if all(f in entry for f in REQUIRED_REGISTRY_FIELDS): check_paper(errors, entry) if errors: print(f"FAIL: {len(errors)} issue(s):") for e in errors: print(f" - {e}") return 1 print(f"OK: registry valid, {len(papers)} paper(s) checked.") return 0 if __name__ == "__main__": sys.exit(main())