forked from jason/echo
197 lines
7.0 KiB
Python
197 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
|
"""check_routing.py — keep the routing VIEWS in sync with routing.json.
|
|
|
|
routing.json is the canonical route manifest. SKILL.md ("Where to Write"),
|
|
references/routing-map.md, and references/api-reference.md are hand-maintained
|
|
human views of it, guarded until now only by a prose "routing.json wins"
|
|
convention. This check makes that convention mechanically true:
|
|
|
|
CHECK A (validity, all three docs): every concrete vault path mentioned in the
|
|
docs must match some route OR retired pattern in routing.json. Catches
|
|
a doc pointing at a path the manifest does not define (typo, stale row).
|
|
|
|
CHECK B (coverage, routing-map.md): every route in routing.json must be named
|
|
by at least one path in routing-map.md, which claims to be the complete
|
|
canonical map. Catches a route added to the manifest but never documented.
|
|
|
|
Exit: 0 = in sync, 1 = drift found, 2 = could not read inputs. No network, no vault.
|
|
Run it in CI / from the eval harness; it is pure local file parsing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
for _stream in (sys.stdout, sys.stderr):
|
|
try:
|
|
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
except Exception:
|
|
pass
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
SKILL_DIR = SCRIPT_DIR.parent
|
|
ROUTING_JSON = SCRIPT_DIR / "routing.json"
|
|
DOCS = {
|
|
"SKILL.md": SKILL_DIR / "SKILL.md",
|
|
"routing-map.md": SKILL_DIR / "references" / "routing-map.md",
|
|
"api-reference.md": SKILL_DIR / "references" / "api-reference.md",
|
|
}
|
|
CANONICAL_MAP = "routing-map.md"
|
|
|
|
ROOTS = ("_agent", "inbox", "journal", "projects", "areas", "resources", "decisions")
|
|
FILE_SUFFIXES = (".md", ".lock", ".json")
|
|
|
|
# Order matters: substitute the most specific placeholder first.
|
|
DATE_SUBS = [
|
|
("YYYY-MM-DD", "2026-01-02"),
|
|
("YYYY-Www", "2026-W01"),
|
|
("YYYY-MM", "2026-01"),
|
|
("YYYY-Qn", "2026-Q1"),
|
|
("HHMM", "0900"),
|
|
("YYYY", "2026"),
|
|
]
|
|
|
|
# Named placeholders whose route constrains the value to a fixed vocabulary, so a
|
|
# generic "sample" would not match the pattern.
|
|
NAMED_SUBS = {
|
|
"<lifecycle>": "active",
|
|
"<domain>": "business",
|
|
}
|
|
|
|
|
|
def load_routing():
|
|
import json
|
|
data = json.loads(ROUTING_JSON.read_text(encoding="utf-8"))
|
|
routes = [(r["id"], re.compile(r["pattern"]), stem(r["pattern"])) for r in data.get("routes", [])]
|
|
retired = [re.compile(r["pattern"]) for r in data.get("retired", [])]
|
|
return routes, retired
|
|
|
|
|
|
def stem(pattern: str) -> str:
|
|
"""Literal directory prefix of a regex pattern, e.g. ^projects/active/[^/]+\\.md$ -> projects/active/."""
|
|
body = pattern[1:] if pattern.startswith("^") else pattern
|
|
literal = []
|
|
for ch in body:
|
|
if ch in "\\([{+*?.|^$":
|
|
break
|
|
literal.append(ch)
|
|
text = "".join(literal)
|
|
return text[: text.rfind("/") + 1] if "/" in text else ""
|
|
|
|
|
|
def strip_fenced_blocks(text: str) -> str:
|
|
out, in_fence = [], False
|
|
for line in text.splitlines():
|
|
if line.lstrip().startswith("```"):
|
|
in_fence = not in_fence
|
|
continue
|
|
if not in_fence:
|
|
out.append(line)
|
|
return "\n".join(out)
|
|
|
|
|
|
def expand_braces(token: str) -> list[str]:
|
|
"""Expand a single brace group: a/{x,y}/b -> [a/x/b, a/y/b]. Recurses for nested groups."""
|
|
match = re.search(r"\{([^{}]*)\}", token)
|
|
if not match:
|
|
return [token]
|
|
pre, post = token[: match.start()], token[match.end():]
|
|
results = []
|
|
for part in match.group(1).split(","):
|
|
results.extend(expand_braces(pre + part.strip() + post))
|
|
return results
|
|
|
|
|
|
def concretize(token: str) -> str:
|
|
for placeholder, sample in DATE_SUBS:
|
|
token = token.replace(placeholder, sample)
|
|
for placeholder, sample in NAMED_SUBS.items():
|
|
token = token.replace(placeholder, sample)
|
|
return re.sub(r"<[^>]+>", "sample", token)
|
|
|
|
|
|
def looks_like_path(token: str) -> bool:
|
|
if " " in token or "::" in token or token.count("`"):
|
|
return False
|
|
# README signposts can live under any folder, including placeholder folders.
|
|
if token == "README.md" or token.endswith("/README.md"):
|
|
return True
|
|
# Otherwise must sit under a known vault root directory (root + "/"), which
|
|
# excludes shorthand like `inbox.md` and non-paths like `application/vnd...`.
|
|
if not any(token.startswith(root + "/") for root in ROOTS):
|
|
return False
|
|
return token.endswith("/") or token.endswith(FILE_SUFFIXES) or "<" in token or "{" in token
|
|
|
|
|
|
def extract_tokens(text: str) -> set[str]:
|
|
text = strip_fenced_blocks(text)
|
|
tokens: set[str] = set()
|
|
for raw in re.findall(r"`([^`]+)`", text):
|
|
raw = raw.strip()
|
|
for piece in expand_braces(raw):
|
|
if looks_like_path(piece):
|
|
tokens.add(piece)
|
|
return tokens
|
|
|
|
|
|
def classify(tokens: set[str]):
|
|
files, dirs = set(), set()
|
|
for token in tokens:
|
|
if token.endswith("/"):
|
|
dirs.add(token)
|
|
else:
|
|
files.add(concretize(token))
|
|
return files, dirs
|
|
|
|
|
|
def main() -> int:
|
|
try:
|
|
routes, retired = load_routing()
|
|
except Exception as exc:
|
|
print(f"check-routing: cannot read routing.json ({exc})", file=sys.stderr)
|
|
return 2
|
|
|
|
problems: list[str] = []
|
|
doc_tokens: dict[str, tuple[set[str], set[str]]] = {}
|
|
for name, path in DOCS.items():
|
|
try:
|
|
files, dirs = classify(extract_tokens(path.read_text(encoding="utf-8")))
|
|
except Exception as exc:
|
|
print(f"check-routing: cannot read {name} ({exc})", file=sys.stderr)
|
|
return 2
|
|
doc_tokens[name] = (files, dirs)
|
|
|
|
route_stems = {st for _, _, st in routes if st}
|
|
|
|
# CHECK A — every documented path is valid under the manifest.
|
|
for name, (files, dirs) in doc_tokens.items():
|
|
for f in sorted(files):
|
|
if not (any(rx.match(f) for _, rx, _ in routes) or any(rx.match(f) for rx in retired)):
|
|
problems.append(f"[A] {name}: path `{f}` matches no route or retired pattern in routing.json")
|
|
for d in sorted(dirs):
|
|
ok = (any(rx.match(d) for rx in retired)
|
|
or any(st == d or st.startswith(d) or d.startswith(st) for st in route_stems))
|
|
if not ok:
|
|
problems.append(f"[A] {name}: folder `{d}` matches no route stem or retired pattern")
|
|
|
|
# CHECK B — every route appears in the canonical map.
|
|
map_files, map_dirs = doc_tokens[CANONICAL_MAP]
|
|
for rid, rx, st in routes:
|
|
covered = any(rx.match(f) for f in map_files) or (st and st in map_dirs)
|
|
if not covered:
|
|
problems.append(f"[B] route '{rid}' ({rx.pattern}) is not documented in {CANONICAL_MAP}")
|
|
|
|
if problems:
|
|
print(f"check-routing: {len(problems)} drift issue(s) found\n")
|
|
for p in problems:
|
|
print(f" - {p}")
|
|
return 1
|
|
print(f"check-routing: in sync — {len(routes)} routes documented and all doc paths are valid.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|