mirror of
https://github.com/expo/code-review-cli.git
synced 2026-11-13 20:28:06 -10:00
Retrofit of https://github.com/ccheever/llp. Ten LLP docs capture the design rationale (trust model, engine pipeline, runtimes, config, verification, sources/reporters, CLI, templates); 161 @ref comments link code to the sections that explain it. ref-check (copied from upstream, dependency-free) validates refs and metadata on every push and PR. Every rationale claim is tagged [observed] with its evidence.
277 lines
11 KiB
Python
Executable File
277 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# @ref https://github.com/ccheever/llp/blob/main/llp/0000-linked-literate-programming.explainer.md#6-validation-ref-check — implements the checker specified there
|
|
"""ref-check — deterministic validator for LLP corpora and @ref annotations.
|
|
|
|
Usage:
|
|
ref-check [--root DIR] [--verbose]
|
|
|
|
Checks (broken = exit 1):
|
|
* @ref targets resolve: LLP numbers, anchors, repo paths. URLs are
|
|
shape-validated only (never fetched); shorthands are listed as
|
|
unchecked unless the project defines a mapping.
|
|
* LLP metadata headers parse; filename type matches **Type:**;
|
|
LLP numbers are unique across the tree (tombstones included).
|
|
* No [inferred] claim survives in an Accepted/Active document.
|
|
|
|
Judgment calls (stale glosses, drift, orphaned annotations) are
|
|
deliberately not gated — that is interactive work for llp-maintain.
|
|
Directories containing their own llp/ are separate corpus roots and are
|
|
skipped; check them with --root. llp/tombstones/ and llp/reviews/ are
|
|
exempt from ref validation.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
SKIP_DIRS = {".git", ".hg", "node_modules", "__pycache__", ".venv", "venv",
|
|
"dist", "build", "target", "coverage", ".cache", ".next",
|
|
".pytest_cache", ".turbo", ".idea", ".vscode"}
|
|
# `ref-check` is a source/document validator, not a generic text crawler.
|
|
# Keeping the scan surface explicit prevents machine-local build products and
|
|
# source maps from changing counts or inventing refs while retaining every
|
|
# source/config format used by this repository.
|
|
SCAN_EXTENSIONS = {
|
|
".c", ".cc", ".contract", ".cpp", ".css", ".go", ".h", ".hpp", ".html",
|
|
".java", ".js", ".json", ".jsonc", ".jsonl", ".kt", ".md", ".mjs", ".mts",
|
|
".py", ".rb", ".rs", ".sh", ".sql", ".swift", ".toml", ".ts", ".tsx",
|
|
".txt", ".webmanifest", ".yaml", ".yml",
|
|
}
|
|
SCAN_BASENAMES = {
|
|
".gitattributes", ".gitignore", ".gitmodules", "Dockerfile", "Makefile",
|
|
}
|
|
SCAN_EXTENSIONLESS_DIRS = {"scripts", ".githooks"}
|
|
REQUIRED_FIELDS = ("Type", "Status", "Systems", "Author", "Date")
|
|
GATED_STATUSES = {"accepted", "active"}
|
|
MAX_BYTES = 2_000_000
|
|
|
|
# Built by concatenation so this file doesn't match its own scan patterns.
|
|
REF_MARK = "@" + "ref"
|
|
CODE_REF_RE = re.compile(re.escape(REF_MARK) + r"\s+(.+)")
|
|
MD_REF_RE = re.compile(r"<!--\s*" + re.escape(REF_MARK) + r"\s+(.+?)-->", re.DOTALL)
|
|
LLP_TARGET_RE = re.compile(r"^LLP\s+(\d{1,4})(?:#(\S+))?$")
|
|
SHORTHAND_RE = re.compile(r"^[A-Z][A-Z0-9_]*(?:#\S+)?$")
|
|
URL_RE = re.compile(r"^https?://[^\s/]+\.[^\s/]+\S*$")
|
|
HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
|
LLP_FILE_RE = re.compile(r"^(\d{4})-[a-z0-9-]+\.([a-z0-9]+)\.md$")
|
|
FIELD_RE = re.compile(r"^\*\*(\w+):\*\*\s*(.+)$")
|
|
INFERRED = "[" + "inferred]"
|
|
|
|
|
|
def slugify(text):
|
|
"""GitHub-style heading slug: lowercase; spaces -> hyphens; keep
|
|
alphanumerics, hyphens, underscores; drop everything else."""
|
|
text = text.replace("`", "")
|
|
out = []
|
|
for ch in text.strip().lower():
|
|
if ch.isalnum() or ch in "-_":
|
|
out.append(ch)
|
|
elif ch in " \t":
|
|
out.append("-")
|
|
return "".join(out)
|
|
|
|
|
|
def read_text(path):
|
|
try:
|
|
if os.path.getsize(path) > MAX_BYTES:
|
|
return None
|
|
with open(path, encoding="utf-8") as f:
|
|
return f.read()
|
|
except (UnicodeDecodeError, OSError):
|
|
return None
|
|
|
|
|
|
def is_scan_candidate(relpath):
|
|
"""Whether a repository-relative path is source/config/document text."""
|
|
normalized = relpath.replace(os.sep, "/")
|
|
name = os.path.basename(normalized)
|
|
if name in SCAN_BASENAMES:
|
|
return True
|
|
_, extension = os.path.splitext(name)
|
|
if extension.lower() in SCAN_EXTENSIONS:
|
|
return True
|
|
return not extension and normalized.split("/", 1)[0] in SCAN_EXTENSIONLESS_DIRS
|
|
|
|
|
|
def md_headings(text):
|
|
"""Heading texts outside fenced code blocks."""
|
|
headings, fenced = [], False
|
|
for line in text.splitlines():
|
|
if line.lstrip().startswith("```"):
|
|
fenced = not fenced
|
|
continue
|
|
if fenced:
|
|
continue
|
|
m = HEADING_RE.match(line)
|
|
if m:
|
|
headings.append(m.group(2))
|
|
return headings
|
|
|
|
|
|
def anchor_resolves(anchor, headings):
|
|
anchor = anchor.lstrip("#")
|
|
slugs = {slugify(h) for h in headings}
|
|
if anchor.lower() in slugs:
|
|
return True
|
|
if re.fullmatch(r"\d+(\.\d+)*", anchor): # numbered-section form: #3, #3.2
|
|
return any(re.match(rf"^{re.escape(anchor)}([.)\s]|$)", h) for h in headings)
|
|
return False
|
|
|
|
|
|
class Report:
|
|
def __init__(self):
|
|
self.errors, self.warnings, self.infos = [], [], []
|
|
|
|
def error(self, msg):
|
|
self.errors.append(msg)
|
|
|
|
def info(self, msg):
|
|
self.infos.append(msg)
|
|
|
|
|
|
def parse_target(rest):
|
|
"""Split an annotation body into (kind, target, anchor)."""
|
|
rest = rest.strip()
|
|
rest = re.split(r"\s+(?:[—–]|--|-)(?:\s+|$)", rest)[0].strip() # drop gloss
|
|
rest = re.sub(r"\s*\[[a-z-]+\]\s*$", "", rest) # drop [relation]
|
|
m = LLP_TARGET_RE.match(rest)
|
|
if m:
|
|
return "llp", m.group(1), m.group(2)
|
|
token = rest.split()[0] if rest.split() else ""
|
|
if token.startswith(("http://", "https://")):
|
|
return "url", token, None
|
|
if SHORTHAND_RE.match(token):
|
|
return "shorthand", token, None
|
|
path, _, anchor = token.partition("#")
|
|
return "path", path, anchor or None
|
|
|
|
|
|
def collect_refs(path, text):
|
|
refs = [] # (lineno, rest)
|
|
if path.endswith(".md"):
|
|
for m in MD_REF_RE.finditer(text):
|
|
lineno = text.count("\n", 0, m.start()) + 1
|
|
refs.append((lineno, m.group(1)))
|
|
else:
|
|
for i, line in enumerate(text.splitlines(), 1):
|
|
m = CODE_REF_RE.search(line)
|
|
if m:
|
|
refs.append((i, m.group(1)))
|
|
return refs
|
|
|
|
|
|
def check(root):
|
|
rep = Report()
|
|
llp_dir = os.path.join(root, "llp")
|
|
|
|
# --- corpus: collect LLP docs, check metadata ---
|
|
docs = {} # number -> relpath
|
|
headings = {} # number -> [heading, ...]
|
|
if os.path.isdir(llp_dir):
|
|
for dirpath, dirnames, filenames in os.walk(llp_dir):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and d != "reviews"]
|
|
for name in sorted(filenames):
|
|
m = LLP_FILE_RE.match(name)
|
|
if not m:
|
|
continue
|
|
num, ftype = m.group(1), m.group(2)
|
|
rel = os.path.relpath(os.path.join(dirpath, name), root)
|
|
text = read_text(os.path.join(dirpath, name))
|
|
if text is None:
|
|
continue
|
|
if num in docs:
|
|
rep.error(f"{rel}: duplicate LLP number {num} (also {docs[num]})")
|
|
else:
|
|
docs[num] = rel
|
|
headings[num] = md_headings(text)
|
|
fields = {}
|
|
for line in text.splitlines()[1:40]:
|
|
fm = FIELD_RE.match(line.strip())
|
|
if fm:
|
|
fields.setdefault(fm.group(1), fm.group(2).strip())
|
|
for req in REQUIRED_FIELDS:
|
|
if req not in fields:
|
|
rep.error(f"{rel}: missing required metadata field **{req}:**")
|
|
doc_type = fields.get("Type", "").split()[0].lower() if fields.get("Type") else ""
|
|
if doc_type and doc_type != ftype:
|
|
rep.error(f"{rel}: filename type '.{ftype}.md' != **Type:** {fields['Type']}")
|
|
status = fields.get("Status", "").split()[0].lower()
|
|
if status in GATED_STATUSES:
|
|
bare = text.count(INFERRED) - text.count("`" + INFERRED)
|
|
if bare > 0:
|
|
rep.error(f"{rel}: {INFERRED} claim in {fields.get('Status')} document "
|
|
f"(ratify to [confirmed] or remove before promotion)")
|
|
|
|
# --- refs: scan the tree ---
|
|
n_files = n_refs = 0
|
|
exempt = (os.path.join("llp", "tombstones"), os.path.join("llp", "reviews"))
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
if dirpath != root and "llp" in dirnames:
|
|
# a nested llp/ marks a separate corpus root (fixture, vendored
|
|
# project): its refs resolve against its own tree, not this one
|
|
dirnames[:] = []
|
|
continue
|
|
for name in sorted(filenames):
|
|
full = os.path.join(dirpath, name)
|
|
rel = os.path.relpath(full, root)
|
|
if rel.startswith(exempt) or os.path.basename(rel) == "ref-check":
|
|
continue
|
|
if not is_scan_candidate(rel):
|
|
continue
|
|
text = read_text(full)
|
|
if text is None:
|
|
continue
|
|
n_files += 1
|
|
for lineno, rest in collect_refs(rel, text):
|
|
n_refs += 1
|
|
kind, target, anchor = parse_target(rest)
|
|
where = f"{rel}:{lineno}"
|
|
if kind == "llp":
|
|
num = target.zfill(4)
|
|
if num not in docs:
|
|
rep.error(f"{where}: broken ref: LLP {num} not found under llp/")
|
|
elif anchor and not anchor_resolves(anchor, headings[num]):
|
|
rep.error(f"{where}: broken ref: no anchor #{anchor} in {docs[num]}")
|
|
elif kind == "url":
|
|
if not URL_RE.match(target):
|
|
rep.error(f"{where}: malformed URL target: {target}")
|
|
elif kind == "shorthand":
|
|
rep.info(f"{where}: unchecked shorthand: {target} (no mapping defined)")
|
|
else: # path
|
|
tpath = os.path.join(root, target)
|
|
if not os.path.exists(tpath):
|
|
rep.error(f"{where}: broken ref: path not found: {target}")
|
|
elif anchor:
|
|
if target.endswith(".md"):
|
|
ttext = read_text(tpath) or ""
|
|
if not anchor_resolves(anchor, md_headings(ttext)):
|
|
rep.error(f"{where}: broken ref: no anchor #{anchor} in {target}")
|
|
else:
|
|
rep.info(f"{where}: anchor #{anchor} unchecked (non-markdown target)")
|
|
return rep, n_files, n_refs, len(docs)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(prog="ref-check", description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--root", default=".", help="project root to check (default: .)")
|
|
ap.add_argument("--verbose", action="store_true", help="also print informational notes")
|
|
args = ap.parse_args()
|
|
|
|
rep, n_files, n_refs, n_docs = check(os.path.abspath(args.root))
|
|
for e in rep.errors:
|
|
print(f"ERROR {e}")
|
|
if args.verbose:
|
|
for i in rep.infos:
|
|
print(f"INFO {i}")
|
|
status = "FAIL" if rep.errors else "ok"
|
|
print(f"ref-check: {status} — {n_docs} LLP docs, {n_refs} refs in {n_files} files, "
|
|
f"{len(rep.errors)} errors, {len(rep.infos)} unchecked")
|
|
sys.exit(1 if rep.errors else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|