commit 2401663b4b40c4e7168f39a5214516e6ccabc150 Author: Mikkeli Date: Tue Oct 13 00:36:20 2026 +0900 crosscheck: mechanical consistency across the yukart repos Decisions, topics, doc references, deployed binaries and front matter, each checked against the repo that owns the fact. A reusable workflow runs it from every repo on push. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz diff --git a/.gitea/workflows/crosscheck.yml b/.gitea/workflows/crosscheck.yml new file mode 100644 index 0000000..6f7fcd8 --- /dev/null +++ b/.gitea/workflows/crosscheck.yml @@ -0,0 +1,45 @@ +# The reusable half. Every yukart repo calls this on push (see caller/crosscheck.yml): +# it clones every repo of the org at master, puts the caller at the commit that was +# pushed, and runs crosscheck.py over the lot. A finding fails the caller's run, on +# the commit that introduced it. +on: + workflow_call: + inputs: + repo: + description: "The calling repository's name" + required: true + type: string + sha: + description: "The commit pushed to it" + required: true + type: string + +jobs: + crosscheck: + # The yukart org's runner on halogen (host mode). Needs only git, curl and python3. + runs-on: flutter-android + steps: + - name: Clone every yukart repo + env: + # YUKART_READ_TOKEN (org secret, read-only) reaches the private repos; without it + # only the public ones are checked, and the run says so. + TOKEN: ${{ secrets.YUKART_READ_TOKEN }} + SERVER: ${{ gitea.server_url }} + run: | + set -euo pipefail + rm -rf ws && mkdir ws + host="${SERVER#*://}"; scheme="${SERVER%%://*}" + auth=(); [ -n "$TOKEN" ] && auth=(-H "Authorization: token $TOKEN") + repos=$(curl -fsS "${auth[@]}" "$SERVER/api/v1/orgs/yukart/repos?limit=50" \ + | python3 -c 'import json,sys; print(" ".join(r["name"] for r in json.load(sys.stdin) if not r["archived"]))') + [ -n "$TOKEN" ] || echo "::warning::no YUKART_READ_TOKEN: private repos are not checked" + for r in $repos; do + url="$scheme://${TOKEN:+crosscheck:$TOKEN@}$host/yukart/$r.git" + git clone -q --depth 1 "$url" "ws/$r" + done + git -C "ws/${{ inputs.repo }}" fetch -q --depth 1 origin "${{ inputs.sha }}" + git -C "ws/${{ inputs.repo }}" checkout -q FETCH_HEAD + echo "checking: $repos" + + - name: Crosscheck + run: python3 ws/tool-crosscheck/crosscheck.py ws diff --git a/README.md b/README.md new file mode 100644 index 0000000..147592b --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +--- +type: reference +--- +# Crosscheck + +Part of **Project Yukart**, making an old 2008 Mitsubishi Z27AG a bit smarter with modern technologies. + +Mechanical consistency checks across every yukart repository. A fact stated in +more than one repo (a decision's status, a topic, a service list, a path in +another repo's docs) is checked against the one place that owns it. No model, +no network: plain Python over the tracked files. + +| Check | Source of truth | Catches | +|---|---|---| +| `decision` | project-yukart `docs/ARCHITECTURE.md`, decisions table | a decision number that does not exist; a line calling an Accepted decision proposed | +| `topic` | contracts `schema/topics.json`, `schema/signals.json` | a `yukart/...` topic matching no pattern; a vehicle or host signal that is not registered, or has the wrong source | +| `docref` | the repos' own trees | `` `FILE.md` `` naming a file that does not exist; relative markdown links that do not resolve | +| `binaries` | each repo's `Cargo.toml` and `deploy/*.service` | project-yukart `REPOS.md` missing a deployed binary; `ORIN-RUNTIME.md` missing a deployed service | +| `frontmatter` | the docs convention | a `docs/**/*.md` without front matter, or with an unknown `type` | + +## Run it + +```bash +python3 crosscheck.py ~/dev/yukart # the directory holding the repos side by side +``` + +Findings print as `repo/path:line: [check] message`; exit status 1 if any. + +## In CI: every repo, on every push + +`.gitea/workflows/crosscheck.yml` is a reusable workflow. Each yukart repo carries +[`caller/crosscheck.yml`](caller/crosscheck.yml) as its own +`.gitea/workflows/crosscheck.yml`. On a push it clones every repo of the org at +`master`, puts the pushed repo at the pushed commit, and runs the script. A finding +fails that push's run: the commit that made two repos disagree is the one marked. + +A new repo is checked as soon as it exists in the org; it needs the caller only to +trigger checks on its own pushes. + +## Adding to it + +- **A false positive** is fixed in the rule, not silenced per line. Shapes that look + like topics but are not (Flutter channel names, systemd directories) are listed in + `NOT_TOPIC`; test-only topic roots in `IGNORED_TOPIC_ROOTS`. +- **A new check** reads a source of truth that already exists in some repo. If the + fact has no single owner yet, give it one first; a check between two copies only + says that they differ, not which is right. diff --git a/caller/crosscheck.yml b/caller/crosscheck.yml new file mode 100644 index 0000000..9ca0641 --- /dev/null +++ b/caller/crosscheck.yml @@ -0,0 +1,14 @@ +# Cross-repo consistency (yukart/tool-crosscheck): decisions, topics, doc references, +# service lists and front matter checked against their source in every yukart repo. +on: + push: + branches: [ master ] + workflow_dispatch: + +jobs: + crosscheck: + uses: yukart/tool-crosscheck/.gitea/workflows/crosscheck.yml@master + with: + repo: ${{ github.event.repository.name }} + sha: ${{ github.sha }} + secrets: inherit diff --git a/crosscheck.py b/crosscheck.py new file mode 100644 index 0000000..1c4e1bc --- /dev/null +++ b/crosscheck.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""crosscheck — mechanical consistency checks across the yukart repositories. + +Facts in yukart are stated in more than one repo: a decision's status, a topic, a +service list, a path in another repo's docs. This finds the places where those +copies no longer agree with their source. No model, no network: it reads the +tracked files of every repo under ROOT and reports file:line findings. + + crosscheck.py [ROOT] ROOT holds the repos side by side (default: .) + +Exit status 1 when anything is found. Checks, and the source each one trusts: + + decision "decision N" names a row of project-yukart's decisions table, and a + line calling it proposed does not contradict an Accepted row + topic every yukart/<...> topic matches contracts' topics.json; concrete + vehicle and host signals exist in signals.json with that source + docref " `path.md`" names a file that exists in that repo; relative + markdown links resolve + binaries REPOS.md's row for a repo names every deployed [[bin]] in its Cargo.toml; + ORIN-RUNTIME.md names every service a repo deploys + frontmatter every docs/**/*.md starts with front matter whose type is known +""" + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +DOC_TYPES = {"guidelines", "plan", "report", "reference"} +# Test-only topic roots, deliberately outside contracts (svc-core bus-bench). +IGNORED_TOPIC_ROOTS = {"bench"} +TEXT_SUFFIXES = {".md", ".rs", ".dart", ".py", ".json", ".service", ".sh", ".yaml", ".yml", ".toml"} + +findings: list[str] = [] + + +def find(path: str, line: int, check: str, msg: str) -> None: + findings.append(f"{path}:{line}: [{check}] {msg}") + + +def tracked(repo: Path) -> list[str]: + out = subprocess.run(["git", "-C", str(repo), "ls-files"], capture_output=True, text=True, check=True) + return [f for f in out.stdout.splitlines() if Path(f).suffix in TEXT_SUFFIXES] + + +def lines_outside_fences(text: str): + """(line number, line) for prose lines; fenced code blocks are skipped.""" + fenced = False + for n, line in enumerate(text.splitlines(), 1): + if line.lstrip().startswith("```"): + fenced = not fenced + continue + if not fenced: + yield n, line + + +class Workspace: + def __init__(self, root: Path): + self.root = root + self.repos = sorted(p.name for p in root.iterdir() if (p / ".git").exists()) + self.files: dict[str, list[str]] = {r: tracked(root / r) for r in self.repos} + self.texts: dict[tuple[str, str], str] = {} + + def text(self, repo: str, rel: str) -> str: + key = (repo, rel) + if key not in self.texts: + try: + self.texts[key] = (self.root / repo / rel).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + self.texts[key] = "" + return self.texts[key] + + def each(self, suffixes=None): + for repo in self.repos: + for rel in self.files[repo]: + if suffixes is None or Path(rel).suffix in suffixes: + yield repo, rel, self.text(repo, rel) + + +# ── decisions ──────────────────────────────────────────────────────────────── + +DECISION_ROW = re.compile(r"^\|\s*(\d+)\s*\|.*\|\s*\**(Accepted|Proposed|Rejected|Superseded)[^|]*\|", re.I) +DECISION_REF = re.compile(r"\b[Dd]ecisions?\s+(\d+(?:\s*(?:,|and|or)\s*\d+)*)") + + +def check_decisions(ws: Workspace) -> None: + arch = ("project-yukart", "docs/ARCHITECTURE.md") + table = {} + for n, line in enumerate(ws.text(*arch).splitlines(), 1): + m = DECISION_ROW.match(line) + if m: + table[int(m.group(1))] = m.group(2).capitalize() + if not table: + find("/".join(arch), 1, "decision", "no decisions table found") + return + for repo, rel, text in ws.each({".md", ".rs", ".dart", ".py"}): + for n, line in enumerate(text.splitlines(), 1): + if DECISION_ROW.match(line): + continue + for m in DECISION_REF.finditer(line): + for num in map(int, re.findall(r"\d+", m.group(1))): + status = table.get(num) + if status is None: + find(f"{repo}/{rel}", n, "decision", f"decision {num} is not in the decisions table") + elif status == "Accepted" and re.search(r"\bproposed\b", line, re.I): + find(f"{repo}/{rel}", n, "decision", f"says proposed, but decision {num} is Accepted") + + +# ── topics ─────────────────────────────────────────────────────────────────── + +TOPIC = re.compile(r"(?+#*.-]*)*)") +# Same shape, not a topic: Flutter platform channel names, systemd directory names. +NOT_TOPIC = re.compile(r"MethodChannel\(|EventChannel\(|Directory=") + + +def segment_ok(mention: str, pattern: str) -> bool: + if pattern.startswith("<"): + return True + return mention == pattern or mention == "+" or mention.startswith("<") or "*" in mention + + +def topic_matches(segs: list[str], pattern: list[str]) -> bool: + for i, s in enumerate(segs): + if s == "#": + return True + if s == "": # a prefix mention, "yukart/vehicle/" + return i == len(segs) - 1 and len(pattern) > i + if i >= len(pattern) or not segment_ok(s, pattern[i]): + return False + return len(segs) == len(pattern) + + +def check_topics(ws: Workspace) -> None: + topics = json.loads(ws.text("contracts", "schema/topics.json"))["topics"] + signals = json.loads(ws.text("contracts", "schema/signals.json"))["signals"] + patterns = [t.split("/") for t in topics] + sources = {"vehicle": {"obd2", "z27ag"}, "host": {"host"}} + for repo, rel, text in ws.each(): + if (repo, rel) == ("contracts", "schema/topics.json"): + continue + for n, line in enumerate(text.splitlines(), 1): + if NOT_TOPIC.search(line): + continue + for m in TOPIC.finditer(line): + root, rest = m.group(1), m.group(2).rstrip(".") + if root in ws.repos or root in IGNORED_TOPIC_ROOTS: + continue + segs = ["yukart", root] + rest.split("/")[1:] + if rest == "": + segs.append("") + if not any(topic_matches(segs, p) for p in patterns): + find(f"{repo}/{rel}", n, "topic", f"'{m.group(0)}' matches no topic in contracts topics.json") + continue + if root in sources and len(segs) == 3 and "*" in segs[2]: + glob = re.compile(re.escape(segs[2]).replace(r"\*", ".*")) + if not any(glob.fullmatch(k) and v.get("source") in sources[root] for k, v in signals.items()): + find(f"{repo}/{rel}", n, "topic", f"no {root} signal in contracts signals.json matches '{segs[2]}'") + elif root in sources and len(segs) == 3 and re.fullmatch(r"[a-z0-9_]+", segs[2]): + sig = signals.get(segs[2]) + if sig is None: + find(f"{repo}/{rel}", n, "topic", f"signal '{segs[2]}' is not in contracts signals.json") + elif sig.get("source") not in sources[root]: + find(f"{repo}/{rel}", n, "topic", + f"signal '{segs[2]}' has source '{sig.get('source')}', not a {root} source") + + +# ── document references ────────────────────────────────────────────────────── + +LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)") + + +def check_docrefs(ws: Workspace) -> None: + names = "|".join(map(re.escape, ws.repos)) + xref = re.compile(rf"\b({names})\b[^`\n]{{0,3}}`([^`\s]+\.md)(?:#[^`]*)?`") + basenames = {r: {Path(f).name for f in ws.files[r]} for r in ws.repos} + for repo, rel, text in ws.each({".md"}): + for n, line in lines_outside_fences(text): + for m in xref.finditer(line): + target, path = m.group(1), m.group(2) + ok = path in ws.files[target] if "/" in path else path in basenames[target] + if not ok: + find(f"{repo}/{rel}", n, "docref", f"{target} has no {path}") + for m in LINK.finditer(line): + href = m.group(1) + if re.match(r"^[a-z][a-z0-9+.-]*:", href) or href.startswith("#"): + continue # a URL (http:, gitea:, mailto:) or an anchor on this page + path = href.split("#", 1)[0] + resolved = os.path.normpath(os.path.join(os.path.dirname(rel), path)) + if resolved.startswith(".."): + continue # leaves the repo: not checkable from here + if not (ws.root / repo / resolved).exists(): + find(f"{repo}/{rel}", n, "docref", f"link target {href} does not exist") + + +# ── binaries and services ──────────────────────────────────────────────────── + + +def check_binaries(ws: Workspace) -> None: + repos_md = ("project-yukart", "docs/REPOS.md") + runtime = ws.text("project-yukart", "docs/ORIN-RUNTIME.md") + rows = {} + for n, line in enumerate(ws.text(*repos_md).splitlines(), 1): + m = re.match(r"^\|\s*\[([\w-]+)\]", line) + if m: + rows[m.group(1)] = (n, line) + for repo in ws.repos: + if "Cargo.toml" in ws.files[repo]: + bins = re.findall(r'\[\[bin\]\]\s*\nname\s*=\s*"([^"]+)"', ws.text(repo, "Cargo.toml")) + if bins and repo not in rows: + find("/".join(repos_md), 1, "binaries", f"no row for {repo}") + elif bins: + n, line = rows[repo] + # Only what is installed somewhere: a test tool needs no place in the overview. + deployed = [b for b in bins if f"deploy/yukart-{b}.service" in ws.files[repo]] + missing = [b for b in deployed if f"`{b}`" not in line] + if missing: + find("/".join(repos_md), n, "binaries", + f"{repo} row does not name {', '.join(missing)} (from its Cargo.toml)") + for rel in ws.files[repo]: + if rel.startswith("deploy/") and rel.endswith(".service"): + unit = Path(rel).stem + service = unit.removeprefix("yukart-") + if service not in runtime and repo not in runtime: + find("project-yukart/docs/ORIN-RUNTIME.md", 1, "binaries", + f"{repo} deploys {unit}, which the startup tiers do not mention") + + +# ── front matter ───────────────────────────────────────────────────────────── + + +def check_frontmatter(ws: Workspace) -> None: + for repo, rel, text in ws.each({".md"}): + if not rel.startswith("docs/"): + continue + m = re.match(r"---\n(.*?)\n---\n", text, re.S) + if not m: + find(f"{repo}/{rel}", 1, "frontmatter", "no front matter") + continue + t = re.search(r"^type:\s*(\S+)", m.group(1), re.M) + if not t: + find(f"{repo}/{rel}", 1, "frontmatter", "front matter has no type") + elif t.group(1) not in DOC_TYPES: + find(f"{repo}/{rel}", 1, "frontmatter", f"type '{t.group(1)}' is not one of {sorted(DOC_TYPES)}") + + +def main() -> int: + root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve() + ws = Workspace(root) + missing = {"project-yukart", "contracts"} - set(ws.repos) + if missing: + print(f"crosscheck: {root} lacks {', '.join(sorted(missing))}; nothing to check against", file=sys.stderr) + return 2 + for check in (check_decisions, check_topics, check_docrefs, check_binaries, check_frontmatter): + check(ws) + for f in findings: + print(f) + print(f"crosscheck: {len(ws.repos)} repos, {len(findings)} finding(s)", file=sys.stderr) + return 1 if findings else 0 + + +if __name__ == "__main__": + sys.exit(main())