#!/usr/bin/env bash # kb-lint.sh — OKF v0.1 conformance + link-integrity lint for a knowledge bundle. # Usage: scripts/kb-lint.sh (default: omp-docs) # Exits 0 when the KB is conformant; prints one line per violation otherwise. set -uo pipefail KB="$(cd "${1:-omp-docs}" 2>/dev/null && pwd)" || { echo "kb-lint: no such dir: ${1:-omp-docs}" >&2; exit 2; } python3 - "$KB" <<'PY' import re, sys import yaml from pathlib import Path kb = Path(sys.argv[1]) violations = [] def pages(): """Markdown pages that belong to the bundle. `.AGENTTRASH/` is a quarantine, not content: linting a deleted-but-not-yet-pruned page would demand its destruction to make the gate go green, which is the wrong incentive.""" for p in kb.rglob("*.md"): if ".AGENTTRASH" not in p.parts: yield p # Names that a wikilink may point at: exactly the pages pages() reports. Deriving this # once keeps linting and link resolution from disagreeing about what exists. live = {p.stem for p in pages()} for p in pages(): rel = p.relative_to(kb) text = p.read_text(errors="replace") lines = text.splitlines() if p.name == "index.md": # OKF rule: index files are directory listings and carry NO frontmatter. if text.startswith("---"): violations.append(f"{rel}: index.md must not have frontmatter (OKF spec)") continue if not text.startswith("---"): violations.append(f"{rel}: missing frontmatter") continue # Close the block at the first line that is exactly --- or ...; anything # else (including fenced `---` deep in the body) is body, not a delimiter. end = next((i for i in range(1, len(lines)) if lines[i].strip() in ("---", "...")), None) if end is None: violations.append(f"{rel}: frontmatter block is not closed (no '---' delimiter line)") continue try: fm = yaml.safe_load("\n".join(lines[1:end])) or {} except yaml.YAMLError: violations.append(f"{rel}: frontmatter block is malformed YAML or not closed") continue if not isinstance(fm, dict) or not str(fm.get("type", "")).strip(): violations.append(f"{rel}: frontmatter lacks required 'type:'") # One work log per bundle, at the bundle root. A `log.md` is a reserved name, but # reserved does not mean "allowed in every directory": per-directory logs are how a # bundle ends up with fifty of them, the entry never where the next agent looks, and # each copy dating the same event differently. A bundle with no log at all is left to # the maintenance page to nag about; this gate's job is sprawl. logs = sorted(str(p.relative_to(kb)) for p in pages() if p.name == "log.md") if len(logs) > 1 or (logs and logs[0] != "log.md"): violations.append( "a bundle has one work log, log.md, at the bundle root; found: " + ", ".join(logs) ) # link integrity: fenced code blocks hold example paths, not live links — strip first. # Vendored spec is exempt: its example paths are illustrations, not live links. for p in pages(): if str(p.relative_to(kb)) == "references/okf-spec.md": continue text = re.sub(r"^```[^\n]*$^```[^\n]*$", "", p.read_text(errors="replace"), flags=re.M) rel = p.relative_to(kb) for m in re.finditer(r"\]\((?!https?:|#)([^)#\s]+\.md)", text): target = (p.parent / m.group(1)).resolve() if not m.group(1).startswith("/") else (kb / m.group(1).lstrip("/")) if not Path(target).is_file(): violations.append(f"{rel}: broken relative link -> {m.group(1)}") for m in re.finditer(r"\[\[([^\]|#]+)", text): # wikilinks resolve by filename anywhere in the KB (Obsidian semantics) — but only # among pages that BELONG to the bundle. A raw rglob also walked .AGENTTRASH/, so a # link to a quarantined page stayed "valid" here while pointing at nothing on every # other machine (the trash is gitignored). Same exclusion as pages(), one source. name = m.group(1).strip().rsplit("/", 1)[-1] if name not in live: violations.append(f"{rel}: broken wikilink -> [[{m.group(1).strip()}]]") for v in violations: print(v) print(f"kb-lint: {len(violations)} violation(s)") sys.exit(1 if violations else 0) PY