Rendered/generated documentation no longer lives in git -- it's regenerated by a pre-commit hook (tools/hooks/pre-commit, installed via `make install-hooks`) and shipped inside release artifacts (tools/package.sh) instead. A compact api/manifest.json (signatures + docstring hashes + carried-forward lifecycle metadata: since/modified per object and member) is the one exception and stays committed, refreshed by the same hook, so API changes remain trackable by commit/tag via `git show <ref>:api/manifest.json` and `tools/api_delta.py REF1 REF2` without needing a full rebuild at old refs. - tools/generate_api_manifest.py: engine-side introspection, run headless; emits api/manifest.json (committed) and docs/generated/api_full.json (gitignored, full docstrings -- the docs-site generator's data source). - tools/api_delta.py: stdlib-only diff CLI (md/json/gitea-checklist formats), with --site-dir to cross-reference affected docs-site pages via their mcrf frontmatter. - tools/hooks/pre-commit: incremental build + frozen-docstring gate + doc regen + manifest refresh, gated on staged src/ changes. - .gitignore covers the rendered docs/stubs plus personal working-notes docs (plans, audits, research write-ups) that were never McRogueFace-user-facing. - docs/api-stability.md and docs/threading-model.md removed: their content is now the wiki's "API Stability Contract" and "Threading Model" system pages (ratified 2026-07-02, migration verified 2026-07-11); the repo continues to enforce them via tests/unit/api_surface_snapshot_test.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
397 lines
13 KiB
Python
Executable file
397 lines
13 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""
|
|
api_delta.py -- diff two McRogueFace API manifests.
|
|
|
|
Plain python3, standard library only. Loads api/manifest.json from two git
|
|
refs (or the working tree via ".") and reports what changed in the API surface.
|
|
|
|
Usage:
|
|
api_delta.py REF1 REF2 [--format md|json|gitea] [--site-dir PATH]
|
|
|
|
REF1 / REF2:
|
|
A git ref (branch, tag, commit) whose api/manifest.json is read via
|
|
`git show REF:api/manifest.json`, or "." to read the working-tree file
|
|
at api/manifest.json.
|
|
|
|
--format:
|
|
md (default) human-readable Markdown tables
|
|
json machine-readable delta structure
|
|
gitea a checklist-style issue body grouped by object ("- [ ]" items)
|
|
|
|
--site-dir PATH:
|
|
Scan a Jekyll site for pages whose YAML frontmatter carries an
|
|
`mcrf.objects` list; affected pages are attached to each object entry.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import json
|
|
import argparse
|
|
import subprocess
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manifest loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def git_output(args, cwd):
|
|
proc = subprocess.run(
|
|
["git"] + args, cwd=cwd,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
return proc.returncode, proc.stdout.decode("utf-8", "replace"), \
|
|
proc.stderr.decode("utf-8", "replace")
|
|
|
|
|
|
def repo_root():
|
|
rc, out, _ = git_output(["rev-parse", "--show-toplevel"], os.getcwd())
|
|
if rc == 0 and out.strip():
|
|
return out.strip()
|
|
return os.getcwd()
|
|
|
|
|
|
def load_manifest(ref, repo):
|
|
"""Load api/manifest.json for a ref, or from the working tree when ref == '.'."""
|
|
if ref == ".":
|
|
path = os.path.join(repo, "api", "manifest.json")
|
|
with open(path, "r") as f:
|
|
return json.load(f)
|
|
rc, out, err = git_output(["show", "%s:api/manifest.json" % ref], repo)
|
|
if rc != 0:
|
|
raise SystemExit("error: cannot read api/manifest.json at ref '%s': %s"
|
|
% (ref, err.strip()))
|
|
return json.loads(out)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Jekyll frontmatter scan (minimal, no deps)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def frontmatter_lines(raw):
|
|
lines = raw.splitlines()
|
|
if not lines or lines[0].strip() != "---":
|
|
return None
|
|
for i in range(1, len(lines)):
|
|
if lines[i].strip() == "---":
|
|
return lines[1:i]
|
|
return None
|
|
|
|
|
|
def extract_objects_list(hay):
|
|
names = []
|
|
# inline form: objects: [A, B, "C"]
|
|
m = re.search(r"objects:\s*\[([^\]]*)\]", hay)
|
|
if m:
|
|
for part in m.group(1).split(","):
|
|
p = part.strip().strip("'\"")
|
|
if p:
|
|
names.append(p)
|
|
return names
|
|
# block form: objects:\n - A\n - B
|
|
m = re.search(r"(?m)^\s*objects:\s*$", hay)
|
|
if m:
|
|
for line in hay[m.end():].splitlines():
|
|
if line.strip() == "":
|
|
continue
|
|
lm = re.match(r"^\s*-\s*(.+?)\s*$", line)
|
|
if lm:
|
|
names.append(lm.group(1).strip().strip("'\""))
|
|
continue
|
|
# a non-list, non-blank line ends the list
|
|
break
|
|
return names
|
|
|
|
|
|
def objects_from_frontmatter(raw):
|
|
fm = frontmatter_lines(raw)
|
|
if fm is None:
|
|
return []
|
|
text = "\n".join(fm)
|
|
m = re.search(r"(?m)^mcrf:[ \t]*(.*)$", text)
|
|
if not m:
|
|
return []
|
|
inline_after = m.group(1).strip()
|
|
block = []
|
|
for line in text[m.end():].splitlines():
|
|
if line.strip() == "":
|
|
block.append(line)
|
|
continue
|
|
if re.match(r"^\s", line):
|
|
block.append(line)
|
|
else:
|
|
break
|
|
hay = inline_after + "\n" + "\n".join(block)
|
|
return extract_objects_list(hay)
|
|
|
|
|
|
def scan_site(site_dir):
|
|
"""Return {object_name: [relative page paths]}."""
|
|
mapping = {}
|
|
exts = (".md", ".markdown", ".html", ".htm")
|
|
for root, _dirs, files in os.walk(site_dir):
|
|
for fname in files:
|
|
if not fname.lower().endswith(exts):
|
|
continue
|
|
path = os.path.join(root, fname)
|
|
try:
|
|
with open(path, "r", errors="replace") as f:
|
|
raw = f.read(8192)
|
|
except Exception:
|
|
continue
|
|
objs = objects_from_frontmatter(raw)
|
|
if not objs:
|
|
continue
|
|
rel = os.path.relpath(path, site_dir)
|
|
for name in objs:
|
|
mapping.setdefault(name, set()).add(rel)
|
|
return {k: sorted(v) for k, v in mapping.items()}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Delta computation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def member_changed(a, b):
|
|
"""Classify a member change: 'signature' or 'doc' or None."""
|
|
if a.get("kind") != b.get("kind"):
|
|
return "signature"
|
|
if a.get("signature", "") != b.get("signature", ""):
|
|
return "signature"
|
|
if a.get("doc_sha") != b.get("doc_sha"):
|
|
return "doc"
|
|
return None
|
|
|
|
|
|
def compute_delta(a, b, object_pages):
|
|
a_objs = a.get("objects", {})
|
|
b_objs = b.get("objects", {})
|
|
a_fns = a.get("functions", {})
|
|
b_fns = b.get("functions", {})
|
|
|
|
def pages_for(name):
|
|
return object_pages.get(name, []) if object_pages else []
|
|
|
|
objects_added = sorted(set(b_objs) - set(a_objs))
|
|
objects_removed = sorted(set(a_objs) - set(b_objs))
|
|
|
|
objects_changed = {}
|
|
for name in sorted(set(a_objs) & set(b_objs)):
|
|
a_mem = a_objs[name].get("members", {})
|
|
b_mem = b_objs[name].get("members", {})
|
|
added = sorted(set(b_mem) - set(a_mem))
|
|
removed = sorted(set(a_mem) - set(b_mem))
|
|
sig_changed = []
|
|
doc_changed = []
|
|
for m in sorted(set(a_mem) & set(b_mem)):
|
|
kind = member_changed(a_mem[m], b_mem[m])
|
|
if kind == "signature":
|
|
sig_changed.append(m)
|
|
elif kind == "doc":
|
|
doc_changed.append(m)
|
|
obj_doc_changed = a_objs[name].get("doc_sha") != b_objs[name].get("doc_sha")
|
|
if added or removed or sig_changed or doc_changed or obj_doc_changed:
|
|
objects_changed[name] = {
|
|
"members_added": added,
|
|
"members_removed": removed,
|
|
"members_signature_changed": sig_changed,
|
|
"members_doc_changed": doc_changed,
|
|
"object_doc_changed": obj_doc_changed,
|
|
"pages": pages_for(name),
|
|
}
|
|
|
|
functions_added = sorted(set(b_fns) - set(a_fns))
|
|
functions_removed = sorted(set(a_fns) - set(b_fns))
|
|
functions_sig_changed = []
|
|
functions_doc_changed = []
|
|
for m in sorted(set(a_fns) & set(b_fns)):
|
|
kind = member_changed(a_fns[m], b_fns[m])
|
|
if kind == "signature":
|
|
functions_sig_changed.append(m)
|
|
elif kind == "doc":
|
|
functions_doc_changed.append(m)
|
|
|
|
return {
|
|
"ref1": None,
|
|
"ref2": None,
|
|
"objects_added": [{"name": n, "pages": pages_for(n)} for n in objects_added],
|
|
"objects_removed": [{"name": n, "pages": pages_for(n)} for n in objects_removed],
|
|
"objects_changed": objects_changed,
|
|
"functions_added": functions_added,
|
|
"functions_removed": functions_removed,
|
|
"functions_signature_changed": functions_sig_changed,
|
|
"functions_doc_changed": functions_doc_changed,
|
|
}
|
|
|
|
|
|
def delta_is_empty(d):
|
|
return not (d["objects_added"] or d["objects_removed"] or d["objects_changed"]
|
|
or d["functions_added"] or d["functions_removed"]
|
|
or d["functions_signature_changed"] or d["functions_doc_changed"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rendering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def render_json(d):
|
|
return json.dumps(d, sort_keys=True, indent=2)
|
|
|
|
|
|
def _pages_suffix(pages):
|
|
return (" (pages: %s)" % ", ".join(pages)) if pages else ""
|
|
|
|
|
|
def render_md(d):
|
|
out = []
|
|
out.append("# API delta: %s -> %s" % (d["ref1"], d["ref2"]))
|
|
out.append("")
|
|
if delta_is_empty(d):
|
|
out.append("No API changes.")
|
|
out.append("")
|
|
return "\n".join(out)
|
|
|
|
if d["objects_added"]:
|
|
out.append("## Objects added")
|
|
out.append("")
|
|
out.append("| object | pages |")
|
|
out.append("| --- | --- |")
|
|
for o in d["objects_added"]:
|
|
out.append("| `%s` | %s |" % (o["name"], ", ".join(o["pages"]) or "-"))
|
|
out.append("")
|
|
|
|
if d["objects_removed"]:
|
|
out.append("## Objects removed")
|
|
out.append("")
|
|
out.append("| object | pages |")
|
|
out.append("| --- | --- |")
|
|
for o in d["objects_removed"]:
|
|
out.append("| `%s` | %s |" % (o["name"], ", ".join(o["pages"]) or "-"))
|
|
out.append("")
|
|
|
|
if d["objects_changed"]:
|
|
out.append("## Objects changed")
|
|
out.append("")
|
|
for name in sorted(d["objects_changed"]):
|
|
info = d["objects_changed"][name]
|
|
out.append("### %s%s" % (name, _pages_suffix(info["pages"])))
|
|
out.append("")
|
|
if info.get("object_doc_changed"):
|
|
out.append("- class docstring changed")
|
|
out.append("| change | members |")
|
|
out.append("| --- | --- |")
|
|
rows = [
|
|
("added", info["members_added"]),
|
|
("removed", info["members_removed"]),
|
|
("signature changed", info["members_signature_changed"]),
|
|
("doc changed", info["members_doc_changed"]),
|
|
]
|
|
for label, members in rows:
|
|
if members:
|
|
out.append("| %s | %s |"
|
|
% (label, ", ".join("`%s`" % m for m in members)))
|
|
out.append("")
|
|
|
|
fn_rows = [
|
|
("added", d["functions_added"]),
|
|
("removed", d["functions_removed"]),
|
|
("signature changed", d["functions_signature_changed"]),
|
|
("doc changed", d["functions_doc_changed"]),
|
|
]
|
|
if any(members for _, members in fn_rows):
|
|
out.append("## Functions")
|
|
out.append("")
|
|
out.append("| change | functions |")
|
|
out.append("| --- | --- |")
|
|
for label, members in fn_rows:
|
|
if members:
|
|
out.append("| %s | %s |"
|
|
% (label, ", ".join("`%s`" % m for m in members)))
|
|
out.append("")
|
|
|
|
return "\n".join(out)
|
|
|
|
|
|
def render_gitea(d):
|
|
out = []
|
|
out.append("## API changes: %s..%s" % (d["ref1"], d["ref2"]))
|
|
out.append("")
|
|
if delta_is_empty(d):
|
|
out.append("No API changes.")
|
|
out.append("")
|
|
return "\n".join(out)
|
|
|
|
for o in d["objects_added"]:
|
|
out.append("### %s (new object)%s" % (o["name"], _pages_suffix(o["pages"])))
|
|
out.append("- [ ] document new object `%s`" % o["name"])
|
|
out.append("")
|
|
for o in d["objects_removed"]:
|
|
out.append("### %s (removed object)%s" % (o["name"], _pages_suffix(o["pages"])))
|
|
out.append("- [ ] remove references to `%s`" % o["name"])
|
|
out.append("")
|
|
|
|
for name in sorted(d["objects_changed"]):
|
|
info = d["objects_changed"][name]
|
|
out.append("### %s%s" % (name, _pages_suffix(info["pages"])))
|
|
if info.get("object_doc_changed"):
|
|
out.append("- [ ] review class docstring change")
|
|
for m in info["members_added"]:
|
|
out.append("- [ ] member added: `%s`" % m)
|
|
for m in info["members_removed"]:
|
|
out.append("- [ ] member removed: `%s`" % m)
|
|
for m in info["members_signature_changed"]:
|
|
out.append("- [ ] signature changed: `%s`" % m)
|
|
for m in info["members_doc_changed"]:
|
|
out.append("- [ ] doc changed: `%s`" % m)
|
|
out.append("")
|
|
|
|
fn_items = (
|
|
[("added", m) for m in d["functions_added"]]
|
|
+ [("removed", m) for m in d["functions_removed"]]
|
|
+ [("signature changed", m) for m in d["functions_signature_changed"]]
|
|
+ [("doc changed", m) for m in d["functions_doc_changed"]]
|
|
)
|
|
if fn_items:
|
|
out.append("### Functions")
|
|
for label, m in fn_items:
|
|
out.append("- [ ] %s: `%s`" % (label, m))
|
|
out.append("")
|
|
|
|
return "\n".join(out)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(
|
|
description="Diff two McRogueFace API manifests.")
|
|
parser.add_argument("ref1", help="first git ref, or '.' for the working tree")
|
|
parser.add_argument("ref2", help="second git ref, or '.' for the working tree")
|
|
parser.add_argument("--format", choices=["md", "json", "gitea"], default="md")
|
|
parser.add_argument("--site-dir", default=None,
|
|
help="Jekyll site dir to scan for mcrf.objects frontmatter")
|
|
args = parser.parse_args(argv)
|
|
|
|
repo = repo_root()
|
|
a = load_manifest(args.ref1, repo)
|
|
b = load_manifest(args.ref2, repo)
|
|
|
|
object_pages = scan_site(args.site_dir) if args.site_dir else None
|
|
|
|
delta = compute_delta(a, b, object_pages)
|
|
delta["ref1"] = args.ref1
|
|
delta["ref2"] = args.ref2
|
|
|
|
if args.format == "json":
|
|
print(render_json(delta))
|
|
elif args.format == "gitea":
|
|
print(render_gitea(delta))
|
|
else:
|
|
print(render_md(delta))
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|