Files
code-review-cli/ref-check
Philippe Loulidi 6bbc1fbd7a Adopt LLP: design docs in llp/, @ref annotations, ref-check in CI (#38)
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.
2026-07-31 01:20:51 -06:00

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()