ver 0.9
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user