The relationship between wiki/, the generators, systems.db and the runtime is
a directed graph with two edges running opposite to the obvious direction and
one running backwards into its own producer. Prose renders that badly: every
document that has described it states a single ownership direction and is
therefore wrong about part of the tree. D-262 makes the diagram the source of
truth and points CLAUDE.md, Skill(wiki), project-structure.md and
wiki/GOVERNANCE.md at it.
The correction that matters most: body pages were described everywhere as
machine-owned and reverted on sync. They are not. scaffold_bodies.py writes
one once and never overwrites it, and import_economics then reads that
frontmatter directly as input — so a hand-edit is not reverted, it is obeyed,
and silently changes world generation. Worse than being overwritten, and the
actual reason GOVERNANCE.md forbids the edit.
New: tooling/check-dataflow-graph.py, wired into the Makefile and the pre-push
hook. It asserts every repo path named in a hand-authored diagram still
resolves — and its docstring states plainly what it cannot do: verify that an
edge still MEANS what it says. If wiki_sync.py stopped writing body pages
tomorrow, every path would still exist and the check would still pass. Edge
semantics stay a human check against the tool's source, so nobody reads a green
gate as a verified map.
Verified by breaking it: pointing one label at a moved path fails with exit 1
naming that path; restoring it passes. Building the checker also caught two
real vaguenesses in the diagram — "GJ-*/index.md" and "bodies/{id}/index.md"
were written without their wiki/star-systems/ prefix, which is precisely the
ambiguity this map exists to remove. Generated star-map .d2 files are excluded
by name; their correctness belongs to their generator under D-223.
Also files Q-124 + T-1246 (tooling): whether the 123 Python files under
tooling/ should become one Rust CLI of pql's calibre. The friction is real and
mostly not about the language — the permission gate prefix-matches whole
command strings and a blanket Bash(python3 *) grant is forbidden, so each tool
prompts near-individually, while a single binary is one allowlist entry. The
record requires pricing the cheap alternative (a Python dispatcher entrypoint)
before recommending Rust, and flags the hard constraint: import_economics is
stamped by source SHA, so any port must keep that contract intact through the
transition rather than disabled during it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
150 lines
5.1 KiB
Python
150 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Assert that every repo path named in a hand-authored data-flow diagram exists.
|
|
|
|
A diagram that names files goes stale SILENTLY — nothing fails when a path
|
|
moves, so the map keeps asserting a layout that is no longer true. This closes
|
|
the cheap half of that gap.
|
|
|
|
WHAT IT CAN CHECK: that each path mentioned in a `.d2` node label still resolves
|
|
on disk (allowing `*`/`{...}` globs and `·`-separated lists).
|
|
|
|
WHAT IT CANNOT CHECK: whether an EDGE still means what it says. If
|
|
`wiki_sync.py` stops writing body pages tomorrow, every path here still exists
|
|
and this script still passes. Edge semantics are verified by reading the tool's
|
|
source, which is a human job — see D-262.
|
|
|
|
Generated `.d2` files are skipped: their correctness is the generator's problem
|
|
(the same source-canonical rule as .claude/rules/asset-pipeline.md), and they
|
|
name star-system ids rather than repo paths.
|
|
|
|
Usage:
|
|
python3 tooling/check-dataflow-graph.py [--verbose]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
DIAGRAM_ROOT = REPO_ROOT / "docs" / "diagrams"
|
|
|
|
# Hand-authored diagrams whose labels name real repo paths. Add a diagram here
|
|
# when it starts naming files; a diagram absent from this list is not checked.
|
|
CHECKED_DIAGRAMS = [
|
|
"data-flow/wiki-generator-flow.d2",
|
|
]
|
|
|
|
# Generated sources — skipped even if listed above. See .claude/rules/diagrams.md.
|
|
GENERATED_PREFIXES = ("design/star-map-",)
|
|
|
|
# A token looks like a path if it contains a slash and a plausible name char.
|
|
# Node labels in these diagrams carry paths such as:
|
|
# "wiki/economics/\nTOMLs + 37 commodity pages"
|
|
# "heightmap · reliefmap · globe\nterrain.npz · markers.json"
|
|
# "tooling/atlas add-body\nTHE bodies catalog origin"
|
|
PATH_TOKEN = re.compile(r"[A-Za-z0-9_.\-*{}/]*/[A-Za-z0-9_.\-*{}/]+")
|
|
|
|
# A token only counts as a path if its first segment is a real top-level entry
|
|
# in the repo. Without this, legend prose such as "dashed one-time or
|
|
# bootstrap" yields the token `one-time/bootstrap` and fails the check.
|
|
TOP_LEVEL = {p.name for p in REPO_ROOT.iterdir()}
|
|
|
|
|
|
def label_strings(d2_text: str) -> list[str]:
|
|
"""Every double-quoted label in the d2 source."""
|
|
return re.findall(r'"((?:[^"\\]|\\.)*)"', d2_text)
|
|
|
|
|
|
def candidate_paths(label: str) -> list[str]:
|
|
"""Extract path-looking tokens from one label."""
|
|
# Labels use \n for line breaks and · to separate sibling files.
|
|
flat = label.replace("\\n", " ").replace("·", " ")
|
|
out = []
|
|
for tok in PATH_TOKEN.findall(flat):
|
|
tok = tok.strip(".,;:")
|
|
if not tok or tok.split("/", 1)[0] not in TOP_LEVEL:
|
|
continue
|
|
out.append(tok)
|
|
return out
|
|
|
|
|
|
def resolves(token: str) -> bool:
|
|
"""True if the token resolves on disk, treating * and {..} as wildcards."""
|
|
direct = REPO_ROOT / token
|
|
if direct.exists():
|
|
return True
|
|
|
|
# `bodies/{id}/index.md` -> `bodies/*/index.md`; then glob it.
|
|
pattern = re.sub(r"\{[^}]*\}", "*", token)
|
|
if "*" in pattern:
|
|
try:
|
|
return any(REPO_ROOT.glob(pattern))
|
|
except (ValueError, OSError):
|
|
return False
|
|
|
|
# A bare filename inside a directory that was named elsewhere in the
|
|
# diagram (e.g. `terrain.npz` under a body dir) — search narrowly.
|
|
return False
|
|
|
|
|
|
def check(diagram: str, verbose: bool) -> list[str]:
|
|
path = DIAGRAM_ROOT / diagram
|
|
if not path.exists():
|
|
return [f"{diagram}: diagram not found"]
|
|
|
|
failures = []
|
|
checked = 0
|
|
for label in label_strings(path.read_text(encoding="utf-8")):
|
|
for token in candidate_paths(label):
|
|
checked += 1
|
|
if resolves(token):
|
|
if verbose:
|
|
print(f" ok {token}")
|
|
else:
|
|
failures.append(f"{diagram}: path does not resolve: {token}")
|
|
|
|
if checked == 0:
|
|
# A diagram listed for checking that yields no paths means the label
|
|
# format changed and this script silently stopped checking anything.
|
|
failures.append(
|
|
f"{diagram}: no path-like tokens found — the checker is not "
|
|
f"actually checking this diagram"
|
|
)
|
|
elif verbose:
|
|
print(f" {checked} path tokens checked in {diagram}")
|
|
|
|
return failures
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--verbose", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
failures: list[str] = []
|
|
for diagram in CHECKED_DIAGRAMS:
|
|
if diagram.startswith(GENERATED_PREFIXES):
|
|
continue
|
|
failures.extend(check(diagram, args.verbose))
|
|
|
|
if failures:
|
|
print("check-dataflow-graph: FAILED", file=sys.stderr)
|
|
for f in failures:
|
|
print(f" {f}", file=sys.stderr)
|
|
print(
|
|
"\nA path named in a diagram no longer exists. Either the path "
|
|
"moved (update the diagram) or the diagram was always wrong.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
print(f"check-dataflow-graph: OK — {len(CHECKED_DIAGRAMS)} diagram(s)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|