#!/usr/bin/env python3 # Copyright 2026 Michał Kopeć # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Deterministic half of the ckb-lint health check: detect, never fix. Usage: python3 lint_report.py [--scope wiki|libs|all] [--today YYYY-MM-DD] [--json] Run from the repository root (the directory containing wiki/ and libs/). This script is strictly READ-ONLY. It writes no files, moves nothing, and runs no git commands — it prints findings for the agent to act on. The mechanical checks (conformance, freshness, retention/decay candidates, orphans, graph consistency, index/log consistency, source.yaml validity) live here; the judgment calls the ckb-lint skill owns (whether two pages genuinely supersede one another, ambiguous orphans, error-book entries, auto-fix vs. report) stay with the model. Decision records get their own structural checks here — status vocabulary, required dates, two-sided supersession links — since those are mechanical; deciding that a new decision *replaces* an old one is not. Exit code is 0 when nothing was found and 1 when there are findings. """ import argparse import json import re import sys from datetime import date, datetime from pathlib import Path REPO_ROOT = Path.cwd() WIKI = REPO_ROOT / "wiki" LIBS = REPO_ROOT / "libs" RESERVED = {"index.md", "log.md"} DEFAULT_REFRESH_DAYS = 30 DECISION_STATUSES = {"proposed", "accepted", "rejected", "superseded", "reversed"} # -------------------------------------------------------------------------- # tiny YAML readers (stdlib only — this template ships without dependencies) # -------------------------------------------------------------------------- def parse_frontmatter(text): """Return (dict, body, error). `error` is a string when the frontmatter is present but unparseable, else None. Flat `key: value` pairs only.""" if not text.startswith("---\n"): return {}, text, None end = text.find("\n---\n", 4) if end == -1: return {}, text, "frontmatter opened with `---` but never closed" raw, body = text[4:end], text[end + 5 :] fm = {} for lineno, line in enumerate(raw.split("\n"), start=2): stripped = line.strip() if not stripped or stripped.startswith("#"): continue if line[:1].isspace(): return {}, body, f"line {lineno}: nested/indented YAML is not supported in page frontmatter" if ":" not in stripped: return {}, body, f"line {lineno}: not a `key: value` pair: {stripped!r}" key, _, value = stripped.partition(":") fm[key.strip()] = unquote(value.strip()) return fm, body, None def unquote(value): if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): return value[1:-1] return value def parse_simple_yaml(text): """Two-level `key: value` / `key:` + indented block YAML, enough for source.yaml and source.local.yaml. Returns a dict; nested blocks become nested dicts.""" out, current = {}, None for line in text.split("\n"): if not line.strip() or line.strip().startswith("#"): continue indented = line[:1].isspace() stripped = line.strip() if ":" not in stripped: continue key, _, value = stripped.partition(":") key, value = key.strip(), unquote(value.split(" #", 1)[0].strip()) if indented: if isinstance(current, dict): current[key] = value continue if value == "": current = {} out[key] = current else: out[key] = value current = None # `key:` with nothing indented under it is an empty scalar, not a block. return {k: ("" if v == {} else v) for k, v in out.items()} def as_float(value): try: return float(value) except (TypeError, ValueError): return None def as_int(value): try: return int(str(value).strip()) except (TypeError, ValueError): return None def as_date(value): try: return datetime.strptime(str(value).strip(), "%Y-%m-%d").date() except (TypeError, ValueError): return None # -------------------------------------------------------------------------- # tree model # -------------------------------------------------------------------------- LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)") WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]") class Doc: def __init__(self, root, path): self.path = path self.rel = path.relative_to(root) text = path.read_text(encoding="utf-8", errors="replace") self.fm, self.body, self.fm_error = parse_frontmatter(text) self.reserved = path.name in RESERVED def links(self): """Every intra-tree link target as a path relative to the tree root.""" out = set() for target in LINK_RE.findall(self.body): target = target.split("#", 1)[0].strip() if not target or re.match(r"^[a-z][a-z0-9+.-]*:", target): continue if target.startswith("/wiki/"): out.add(target[len("/wiki/") :]) elif target.startswith("/"): out.add(target.lstrip("/")) else: try: resolved = (self.rel.parent / target).as_posix() except ValueError: continue parts = [] for part in resolved.split("/"): if part == "..": if parts: parts.pop() elif part not in (".", ""): parts.append(part) out.add("/".join(parts)) return out def wikilinks(self): return {w.split("|", 1)[-1].strip() for w in WIKILINK_RE.findall(self.body)} def read_tree(root): docs = [] for path in sorted(root.rglob("*.md")): if any(part.startswith(".") for part in path.relative_to(root).parts): continue docs.append(Doc(root, path)) return docs # -------------------------------------------------------------------------- # checks (each returns a list of finding strings) # -------------------------------------------------------------------------- def check_conformance(docs): findings = [] for doc in docs: if doc.reserved: continue if doc.fm_error: findings.append(f"{doc.rel}: unparseable frontmatter — {doc.fm_error}") elif not doc.fm: findings.append(f"{doc.rel}: no frontmatter") elif not doc.fm.get("type", "").strip(): findings.append(f"{doc.rel}: missing or empty `type`") return findings def check_freshness(docs, today): findings = [] for doc in docs: if doc.reserved or doc.fm_error: continue window = as_int(doc.fm.get("freshness_window_days")) updated = as_date(doc.fm.get("last_updated")) if window is None or updated is None: if doc.fm and doc.fm.get("last_updated") and updated is None: findings.append(f"{doc.rel}: `last_updated` is not a YYYY-MM-DD date: {doc.fm['last_updated']!r}") continue age = (today - updated).days if age > window: findings.append(f"{doc.rel}: stale — {age}d since last_updated, window is {window}d ({age - window}d over)") return findings def check_confidence(docs): findings = [] for doc in docs: if doc.reserved or doc.fm_error or "confidence" not in doc.fm: continue value = as_float(doc.fm["confidence"]) if value is None: findings.append(f"{doc.rel}: `confidence` is not a number: {doc.fm['confidence']!r}") elif value < 0.3: findings.append(f"{doc.rel}: confidence {value} is below 0.3 — flag for re-review") return findings def check_retention(docs, today): findings = [] for doc in docs: if doc.reserved or doc.fm_error: continue if doc.fm.get("retention", "").strip().lower() != "low": continue window = as_int(doc.fm.get("freshness_window_days")) updated = as_date(doc.fm.get("last_updated")) if window is None or updated is None: continue age = (today - updated).days if age > 2 * window: findings.append( f"{doc.rel}: archive candidate — retention: low, {age}d old, 2x window is {2 * window}d" ) return findings def check_decisions(docs, root, today): """Decision-record specific rules (see the ckb-decide skill).""" findings = [] decisions = [d for d in docs if not d.reserved and not d.fm_error and d.fm.get("type", "").strip().lower() == "decision"] by_path = {d.rel.as_posix(): d for d in decisions} seen_numbers = {} for doc in sorted(decisions, key=lambda d: d.rel.as_posix()): rel = doc.rel.as_posix() status = doc.fm.get("status", "").strip().lower() if not status: findings.append(f"{rel}: decision has no `status`") elif status not in DECISION_STATUSES: findings.append( f"{rel}: `status: {status}` is not one of {', '.join(sorted(DECISION_STATUSES))}" ) decided_on = as_date(doc.fm.get("decided_on")) if status in ("accepted", "rejected", "reversed"): if not doc.fm.get("decided_on"): findings.append(f"{rel}: `status: {status}` but no `decided_on` date") elif decided_on is None: findings.append(f"{rel}: `decided_on` is not a YYYY-MM-DD date: {doc.fm['decided_on']!r}") if decided_on and decided_on > today: findings.append(f"{rel}: `decided_on` is in the future: {decided_on.isoformat()}") if not doc.fm.get("decided_by", "").strip(): findings.append(f"{rel}: no `decided_by` — record `unknown` rather than omitting it") review = doc.fm.get("review_on") if review: review_date = as_date(review) if review_date is None: findings.append(f"{rel}: `review_on` is not a YYYY-MM-DD date: {review!r}") elif review_date <= today: findings.append( f"{rel}: due for review — `review_on: {review_date.isoformat()}` " f"passed {(today - review_date).days}d ago" ) # Supersession links must resolve and must be reciprocal. for field, mirror in (("supersedes", "superseded_by"), ("superseded_by", "supersedes")): target = doc.fm.get(field, "").strip() if not target: continue key = target[len("/wiki/") :] if target.startswith("/wiki/") else target.lstrip("/") other = by_path.get(key) if other is None: if not (root / key).is_file(): findings.append(f"{rel}: `{field}: {target}` does not resolve to a page") continue back = other.fm.get(mirror, "").strip() back_key = back[len("/wiki/") :] if back.startswith("/wiki/") else back.lstrip("/") if back_key != rel: findings.append( f"{rel}: `{field}` points at {key}, but that page's `{mirror}` " f"does not point back ({back or 'unset'}) — supersession must be two-sided" ) if status in ("superseded", "reversed") and not doc.fm.get("superseded_by", "").strip(): findings.append(f"{rel}: `status: {status}` but no `superseded_by` naming what replaced it") if doc.fm.get("superseded_by", "").strip() and status not in ("superseded", "reversed"): findings.append( f"{rel}: has `superseded_by` but `status: {status or 'unset'}` — " "expected `superseded` or `reversed`" ) for target in [t.strip() for t in doc.fm.get("affects", "").split(",") if t.strip()]: key = target[len("/wiki/") :] if target.startswith("/wiki/") else target.lstrip("/") if not (root / key).is_file(): findings.append(f"{rel}: `affects` entry does not resolve to a page: {target}") m = re.match(r"^(\d{4})-", doc.rel.name) if m: seen_numbers.setdefault(m.group(1), []).append(rel) else: findings.append(f"{rel}: decision filename does not start with a four-digit number") for number, paths in sorted(seen_numbers.items()): if len(paths) > 1: findings.append(f"decision number {number} used by more than one page: {', '.join(sorted(paths))}") return findings def check_orphans(docs): inbound = set() titles = {} for doc in docs: rel = doc.rel.as_posix() titles[rel] = rel stem = doc.rel.with_suffix("").as_posix() titles[stem] = rel for doc in docs: for target in doc.links(): if target in titles: inbound.add(titles[target]) for wl in doc.wikilinks(): key = wl[:-3] if wl.endswith(".md") else wl if key in titles: inbound.add(titles[key]) findings = [] for doc in docs: rel = doc.rel.as_posix() if doc.reserved or rel in inbound: continue findings.append(f"{rel}: no inbound links from anywhere in the tree") return findings def check_graph(root, docs): findings = [] edges_path = root / "graph" / "edges.json" if not edges_path.is_file(): return findings try: data = json.loads(edges_path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: return [f"graph/edges.json: invalid JSON — {exc}"] edges = data.get("edges", data) if isinstance(data, dict) else data if not isinstance(edges, list): return ["graph/edges.json: `edges` is not a list"] known = {doc.rel.as_posix() for doc in docs} known |= {doc.rel.with_suffix("").as_posix() for doc in docs} for i, edge in enumerate(edges): if not isinstance(edge, dict): findings.append(f"graph/edges.json: edge {i} is not an object") continue for side in ("from", "to"): value = str(edge.get(side, "")).strip() if not value: findings.append(f"graph/edges.json: edge {i} has no `{side}`") continue # Edge paths are project-root-absolute per Rule C # (`/wiki/entities/foo.md`), while `known` holds paths relative to # wiki/. Strip the prefix the same way the decision-record check # above already does, or every conformant edge reads as broken. stripped = value[len("/wiki/"):] if value.startswith("/wiki/") else value.lstrip("/") candidates = { value, value.removesuffix(".md"), stripped, stripped.removesuffix(".md"), f"entities/{value}", f"entities/{value}".removesuffix(".md"), } if not candidates & known: findings.append(f"graph/edges.json: edge {i} `{side}: {value}` does not resolve to a page") return findings def check_index_and_logs(root, docs): findings = [] by_dir = {} for doc in docs: by_dir.setdefault(doc.rel.parent, []).append(doc) for directory, entries in sorted(by_dir.items()): pages = [d for d in entries if not d.reserved] index = next((d for d in entries if d.path.name == "index.md"), None) if not pages: continue if index is None: findings.append(f"{(directory / 'index.md').as_posix()}: missing — directory holds {len(pages)} page(s)") continue listed = {Path(t).name for t in index.links()} | { (w if w.endswith(".md") else w + ".md").split("/")[-1] for w in index.wikilinks() } for page in sorted(pages, key=lambda d: d.rel.as_posix()): if page.path.name not in listed: findings.append(f"{index.rel.as_posix()}: does not list {page.rel.as_posix()}") # A change must have exactly one home log (Recursive Index & Log Convention). logs = [d for d in docs if d.path.name == "log.md"] root_log = next((d for d in logs if d.rel == Path("log.md")), None) if root_log is not None and len(logs) > 1: root_entries = log_entry_keys(root_log.body) for sub in logs: if sub is root_log: continue for key in sorted(log_entry_keys(sub.body) & root_entries): findings.append( f"log.md and {sub.rel.as_posix()}: same change recorded in both — {key}" ) return findings LOG_HEADER_RE = re.compile(r"^##\s*\[?(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)\]?") def log_entry_keys(body): """Timestamp + affected-files pairs, used to spot a change logged twice.""" keys, stamp = set(), None for line in body.split("\n"): m = LOG_HEADER_RE.match(line.strip()) if m: stamp = m.group(1) continue f = re.match(r"^-\s*\*\*File Affected:?\*\*:?\s*(.+)$", line.strip()) if f and stamp: keys.add(f"{stamp} {f.group(1).strip()}") return keys def check_sources(today): """source.yaml validity plus overdue-index reporting for connector libs.""" findings, connectors = [], [] if not LIBS.is_dir(): return findings, connectors for lib in sorted(p for p in LIBS.iterdir() if p.is_dir()): source = lib / "source.yaml" if not source.is_file(): continue # git-copy lib — never touched by lint connectors.append(lib) cfg = parse_simple_yaml(source.read_text(encoding="utf-8")) name = lib.name for key in ("connector", "location"): if not str(cfg.get(key, "")).strip(): findings.append(f"libs/{name}/source.yaml: `{key}` is missing or empty") index_block = cfg.get("index") if isinstance(index_block, dict): for key in ("store", "location"): if not str(index_block.get(key, "")).strip(): findings.append(f"libs/{name}/source.yaml: `index.{key}` is missing or empty") refresh = cfg.get("refresh_interval_days") interval = DEFAULT_REFRESH_DAYS if refresh is not None and not isinstance(refresh, dict): parsed = as_int(refresh) if parsed is None or parsed <= 0: findings.append(f"libs/{name}/source.yaml: `refresh_interval_days` is not a positive integer: {refresh!r}") else: interval = parsed generated = {"index.md", "log.md", "entities", "graph", "source.yaml", "source.local.yaml"} stray = [ p.name for p in sorted(lib.iterdir()) if p.name not in generated and not p.name.startswith(".") ] if stray: findings.append( f"libs/{name}/: has both a source.yaml and non-index content ({', '.join(stray)}) — " "ambiguous configuration for the user to resolve" ) newest = None for md in lib.rglob("*.md"): fm, _, err = parse_frontmatter(md.read_text(encoding="utf-8", errors="replace")) if err: continue updated = as_date(fm.get("last_updated")) if updated and (newest is None or updated > newest): newest = updated if newest is None: findings.append(f"libs/{name}/: no generated index yet — run 'index external sources'") else: age = (today - newest).days if age > interval: findings.append( f"libs/{name}/: index is {age}d old against a {interval}d refresh interval " f"({age - interval}d overdue) — suggest 'index external sources'" ) return findings, connectors # -------------------------------------------------------------------------- # driver # -------------------------------------------------------------------------- CHECKS = [ ("1 conformance", "conformance"), ("2 freshness", "freshness"), ("3 confidence", "confidence"), ("4 retention", "retention"), ("5 decisions", "decisions"), ("6 orphans", "orphans"), ("7 graph", "graph"), ("8 index/log", "index_log"), ] def run_tree(root, today, label): docs = read_tree(root) return { "label": label, "documents": len(docs), "conformance": check_conformance(docs), "freshness": check_freshness(docs, today), "confidence": check_confidence(docs), "retention": check_retention(docs, today), "decisions": check_decisions(docs, root, today), "orphans": check_orphans(docs), "graph": check_graph(root, docs), "index_log": check_index_and_logs(root, docs), } def main(): parser = argparse.ArgumentParser(description="Read-only mechanical checks for ckb-lint.") parser.add_argument("--scope", choices=("wiki", "libs", "all"), default="all") parser.add_argument("--today", help="override today's date (YYYY-MM-DD) for reproducible runs") parser.add_argument("--json", action="store_true", help="emit machine-readable JSON instead of text") args = parser.parse_args() today = as_date(args.today) if args.today else date.today() if today is None: print(f"error: --today is not a YYYY-MM-DD date: {args.today!r}", file=sys.stderr) return 2 trees, source_findings = [], [] if args.scope in ("wiki", "all"): if not WIKI.is_dir(): print(f"error: no wiki/ directory under {REPO_ROOT} — run from the repository root", file=sys.stderr) return 2 trees.append(run_tree(WIKI, today, "wiki/")) if args.scope in ("libs", "all"): source_findings, connectors = check_sources(today) for lib in connectors: access = "read-only" local = lib / "source.local.yaml" if local.is_file(): cfg = parse_simple_yaml(local.read_text(encoding="utf-8")) if str(cfg.get("access", "")).strip().lower() == "write": access = "write" tree = run_tree(lib, today, f"libs/{lib.name}/ (access: {access})") tree["access"] = access trees.append(tree) total = sum(len(t[key]) for t in trees for _, key in CHECKS) + len(source_findings) if args.json: print(json.dumps({"today": today.isoformat(), "trees": trees, "sources": source_findings, "findings": total}, indent=2)) return 1 if total else 0 for tree in trees: print(f"=== {tree['label']} — {tree['documents']} markdown file(s)") for title, key in CHECKS: items = tree[key] if not items: continue print(f" [{title}] {len(items)} finding(s)") for item in items: print(f" - {item}") if not any(tree[key] for _, key in CHECKS): print(" clean") if args.scope in ("libs", "all"): print("=== external sources (libs/*/source.yaml)") if source_findings: for item in source_findings: print(f" - {item}") else: print(" clean") print(f"total findings: {total}") print("checks NOT covered here (model's job): whether two pages genuinely supersede " "each other, 9 error-book, auto-fix vs. report") return 1 if total else 0 if __name__ == "__main__": sys.exit(main())