Files
command-center/scripts/kb-lint.sh
T
Frank 66fc40de65 fix(kb-lint): a wikilink must not resolve into the quarantine
The resolver copied Obsidian's filename-anywhere semantics with a raw rglob, which
walked .AGENTTRASH/ as well. [[log]] therefore stayed "valid" by landing on the
quarantined core/log.md — a page that is out of circulation until a pruning session
and, being gitignored, does not exist on any other machine. The gate reported a
green bundle while pointing readers at a file git does not carry: exactly the #57
failure, hidden by the check that exists to catch it.

Resolution now uses the same filtered set linting uses (pages()), so the two can
never disagree about what exists. A companion test pins that filename-anywhere
resolution for live pages still works, so the narrowing costs nothing; mutating
the resolver back to rglob turns the new test red.

Refs #60
2026-10-21 00:59:51 -07:00

90 lines
4.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# kb-lint.sh — OKF v0.1 conformance + link-integrity lint for a knowledge bundle.
# Usage: scripts/kb-lint.sh <kb-dir> (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