The bundle had two work logs. `core/log.md` was created "for tracking core directory modifications" and holds two entries of pure migration bookkeeping (file moved, directory created, log created) — every one recoverable from `git log` — plus a date for the OKF conversion (2026-07-15) that matches neither the bundle log (2026-01-15) nor git history, where the OKF rebuild lands 2026-08-28. `core/index.md` was still linking it, which is exactly how a stale page stays in circulation. kb-lint now reports any `log.md` that is not the bundle root's; the page moved to omp-docs/core/.AGENTTRASH/ per the trash convention, and the core index points one level up. Both gates also walked `.AGENTTRASH/`, so trashing a page turned them red: kb-lint demanded frontmatter from a quarantined page and kb-coverage demanded an index for the trash directory. A gate that only goes green once the quarantined copy is deleted is a gate that deletes history, so both skip any path containing `.AGENTTRASH` — pinned by a test in each suite, and kb-coverage finally gets its own test file.
83 lines
3.7 KiB
Bash
Executable File
83 lines
3.7 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
|
|
|
|
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)
|
|
name = m.group(1).strip().rsplit("/", 1)[-1]
|
|
if not list(kb.rglob(name + ".md")):
|
|
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
|