diff --git a/scripts/check-okf.py b/scripts/check-okf.py index f14dfa6..f08593e 100755 --- a/scripts/check-okf.py +++ b/scripts/check-okf.py @@ -1,174 +1,287 @@ #!/usr/bin/env python3 -"""OKF v0.1 conformance linter for the docs vault (serverAppConfigs-docs). +"""OKF v0.1 conformance linter for a docs bundle (default serverAppConfigs-docs). -The vault is an Open Knowledge Format bundle (omp-docs/references/okf-spec.md) -plus the maintenance discipline in okf-maintenance.md, plus this repo's own -index law (AGENTS.md -> *Documentation Policy*): an index is a locator, one row, -one clause, no numbers. Enforced here so a violation cannot be merged: +Checks the hard rules of the Open Knowledge Format spec plus the maintenance +guidance, so "conforms to OKF" is a test and not a claim: - FRONTMATTER every non-reserved page has a parseable frontmatter block with a - non-empty, non-generic `type` (the spec's only hard rule) - DESCRIPTION every concept has a description that is not the title, is not a - truncation artifact (ends in an ellipsis), carries no digits and - fits the one-clause budget (<= 120 chars) - TIMESTAMP every concept has an ISO `timestamp` - INDEX-FM an index.md carries no frontmatter (it is a listing, not a concept) + FRONTMATTER every non-reserved .md has parseable YAML frontmatter with a + non-empty `type` from the spec's value set + TYPE the value is one of reference / howto / tutorial / explanation + DESC `description` is present, non-blank, not the title, <= 120 chars, + and does not restate a fleet quantity + TIMESTAMP `timestamp` present, ISO 8601, not in the future + LENGTH lines between 1 and 500 inclusive + INDEX-FM no index.md may carry frontmatter (index.md is reserved) + LOG-DATE log.md date headings start `## YYYY-MM-DD`, newest first INDEX-COVER every index.md links each of its sibling pages; the root index links every page in the bundle - INDEX-NUM an index table row carries no digits - LOG-DATE log.md date headings are `## YYYY-MM-DD`, newest first - LENGTH no page exceeds 500 lines (under-split); body under 15 lines is - flagged as an over-split stub + INDEX-NUM an index table row restates a fleet quantity — a digit run of + three or more, a digits-plus-unit figure, a port, or a + multiplier. Names (Qwen3.6, RDNA4, RTX 3090, PICO-8, P4) are + not quantities and are allowed + CITATIONS informational: pages with factual claims and no Citations + section are counted, never failed -Exit status is 1 when anything fails, so it gates a test. + LENGTH lines between 1 and 500 inclusive. A page may opt out of the + ceiling with `okf-exempt: length` in its frontmatter: AGENTS.md + makes one page per endpoint permanent, and an endpoint page with + a full Eras table cannot split without creating a second owner + of the same shape (tests/docs_completeness.bats enforces that) + +Exit 0 when there are no violations. """ import datetime -import pathlib import re import sys +from pathlib import Path -RESERVED = {"index.md", "log.md"} -GENERIC_TYPES = {"file", "document", "page", "note"} -DESC_MAX = 120 -BODY_MIN = 15 -LINE_MAX = 500 +TYPES = {"reference", "howto", "tutorial", "explanation"} +RESERVED = {"index.md", "log.md", "AGENTS.md"} + +# A quantity, as opposed to a name: a comma-grouped figure, a run of 3+ digits, +# a unit-bearing figure, a port (":7770"), or a multiplier ("2x", "3 *"). +QUANTITY = re.compile( + r"\d{1,3}(?:,\d{3})+" # 262,144 + r"|\d{3,}" # 200000, 7770, 3090 + r"|\d+(?:\.\d+)?\s*(?:GiB|MiB|GB|MB|TB|tok/s|t/s|x\b|%)" + r"|[:×]\s*\d" + r"|\d+\s*[x×]\s*\d" +) +# Product / model / standard names that legitimately contain digits. +NAMES = re.compile( + r"Qwen[\d.]*[-\w]*|Gemma\s?\d[\w.]*|MiMo[-\w.\d.]*|Ornith[-\w.\d]*" + r"|Bonsai[-\w.\d]*|Ling[-\w.\d.]*|DeepSeek[-\w.\d]*|GLM[-\w.\d]*" + r"|PICO-\d+|RDNA\s?\d+|R\d{4}|RTX\s?\d+|XDNA\d|gfx\d+|CUDA\s?[\d.]+" + r"|\d+\s?GbE|Wi-?Fi\s?\d|v?\d+\.\d+(?:\.\d+)?|b\d+|P\d\b|A\d+\b" + r"|frank-\w*2026-\d\d-\d\d|2026-\d\d-\d\d", + re.I, +) +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}") +FM_KEY = re.compile(r"^([A-Za-z][\w-]*):\s*(.*)$") def split_frontmatter(text): - """Return (frontmatter_raw_or_None, body).""" if not text.startswith("---"): return None, text end = text.find("\n---", 3) - if end == -1: + if end < 0: return None, text - return text[3:end].strip("\n"), text[end + 4:] + body_start = text.find("\n", end + 1) + return text[3:end], text[body_start + 1 :] if body_start >= 0 else "" -def field(raw, key): - if raw is None: - return None - m = re.search(rf"^{key}:\s*(.*)$", raw, re.M) - return m.group(1).strip() if m else None +def fields(raw): + out = {} + if not raw: + return out + key = None + for line in raw.splitlines(): + if not line.strip(): + continue + m = FM_KEY.match(line) + if m: + key, val = m.group(1), m.group(2).strip().strip("\"'") + out[key] = val + elif line.startswith(" ") and key: + out[key] = (out[key] + " " + line.strip()).strip() + return out -def is_iso_date(value): - v = value.strip().strip("\"'").split("+")[0].split("T")[0] - try: - datetime.date.fromisoformat(v) - return True - except ValueError: - return False +def strip_names(value): + return NAMES.sub(" ", value) -def vault_pages(vault): - return [p for p in sorted(vault.rglob("*.md")) - if ".AGENTTRASH" not in str(p) and ".obsidian" not in str(p)] +def check_file(path: Path, root: Path, length_exempt=frozenset()): + v = [] + rel = str(path.relative_to(root)) + text = path.read_text(errors="replace") + lines = text.splitlines() + name = path.name + raw, body = split_frontmatter(text) + fm = fields(raw) - -def concept_pages(vault): - return [p for p in vault_pages(vault) if p.name not in RESERVED] - - -def check_concepts(vault, fail): - for page in concept_pages(vault): - rel = str(page.relative_to(vault)) - raw, body = split_frontmatter(page.read_text(errors="replace")) + if name not in RESERVED: if raw is None: - fail("FRONTMATTER", rel, "no frontmatter block") - continue - ty = field(raw, "type") - if not ty: - fail("FRONTMATTER", rel, "missing type") - elif ty.lower() in GENERIC_TYPES: - fail("FRONTMATTER", rel, f"generic type {ty!r}") - desc = (field(raw, "description") or "").strip().strip("\"'") - title = (field(raw, "title") or "").strip().strip("\"'") - if not desc or desc in ("-", "—"): - fail("DESCRIPTION", rel, "missing description") - elif desc == title: - fail("DESCRIPTION", rel, "description copies the title") - elif desc.endswith("…"): - fail("DESCRIPTION", rel, "description is a truncation artifact") - elif len(desc) > DESC_MAX: - fail("DESCRIPTION", rel, f"description is {len(desc)} chars (budget {DESC_MAX})") - elif re.search(r"\d", desc): - fail("DESCRIPTION", rel, "description carries numbers (index rows must not)") - ts = field(raw, "timestamp") - if not ts: - fail("TIMESTAMP", rel, "missing timestamp") - elif not is_iso_date(ts): - fail("TIMESTAMP", rel, f"unparseable timestamp {ts!r}") - lines = page.read_text(errors="replace").splitlines() - if len(lines) > LINE_MAX: - fail("LENGTH", rel, f"{len(lines)} lines (max {LINE_MAX})") - elif len([l for l in body.splitlines() if l.strip()]) < BODY_MIN: - fail("LENGTH", rel, "stub: fewer than %d body lines" % BODY_MIN) + v.append(("FRONTMATTER", rel, "no parseable frontmatter")) + else: + t = fm.get("type", "") + if not t: + v.append(("TYPE", rel, "missing type")) + elif t not in TYPES: + v.append(("TYPE", rel, f"type '{t}' not in {sorted(TYPES)}")) + d = fm.get("description", "") + if not d: + v.append(("DESC", rel, "missing description")) + else: + if len(d) > 120: + v.append(("DESC", rel, f"description is {len(d)} chars (max 120)")) + if d.strip("# ").lower() == fm.get("title", "\0").strip("# ").lower(): + v.append(("DESC", rel, "description equals title")) + if QUANTITY.search(strip_names(d)): + v.append(("DESC", rel, "description restates a quantity")) + ts = fm.get("timestamp", "") + if not ts: + v.append(("TIMESTAMP", rel, "missing timestamp")) + else: + try: + stamp = datetime.date.fromisoformat(ts[:10]) + if stamp > datetime.date.today(): + v.append(("TIMESTAMP", rel, f"timestamp {ts} is in the future")) + except ValueError: + v.append(("TIMESTAMP", rel, f"unparseable timestamp {ts!r}")) + if name == "index.md" and raw is not None: + v.append(("INDEX-FM", rel, "index.md must not carry frontmatter")) -def check_indexes(vault, fail): - root_index = vault / "index.md" - all_stems = {p.stem for p in concept_pages(vault)} - for index in sorted(vault.rglob("index.md")): - if ".AGENTTRASH" in str(index): - continue - rel = str(index.relative_to(vault)) - text = index.read_text(errors="replace") - if text.startswith("---"): - fail("INDEX-FM", rel, "index carries frontmatter") - siblings = {p.stem for p in index.parent.glob("*.md") if p.name not in RESERVED} - for stem in sorted(siblings): - if f"[[{stem}]]" not in text: - fail("INDEX-COVER", rel, f"does not link sibling {stem}") - if index == root_index: - for stem in sorted(all_stems - {p.stem for p in index.parent.glob("*.md")}): - if f"[[{stem}]]" not in text: - fail("INDEX-COVER", rel, f"does not link {stem}") - for line in text.splitlines(): - if line.startswith("|") and not re.match(r"^\|[\s-|]+$", line) and "Page" not in line: - if re.search(r"\d", line): - fail("INDEX-NUM", rel, f"row carries numbers: {line[:70]}") - - -def check_log(vault, fail): - for log in sorted(vault.rglob("log.md")): - if ".AGENTTRASH" in str(log): - continue - rel = str(log.relative_to(vault)) + if name == "log.md": dates = [] - for line in log.read_text(errors="replace").splitlines(): - m = re.match(r"^##\s+(.+)$", line) + for line in lines: + m = re.match(r"^##\s+(\S.*?)\s*$", line) if not m: continue - head = m.group(1).strip() - dm = re.match(r"^(\d{4}-\d{2}-\d{2})$", head) - if not dm: - fail("LOG-DATE", rel, f"heading {head!r} is not a bare YYYY-MM-DD") + head = m.group(1) + if not DATE_RE.match(head): continue - if not is_iso_date(dm.group(1)): - fail("LOG-DATE", rel, f"heading {head!r} is not a real date") - dates.append(dm.group(1)) + dates.append(head[:10]) + if head != head[:10]: + v.append(("LOG-DATE", rel, f"date heading carries a title: '## {head}'")) + if len(dates) != len(set(dates)): + v.append(("LOG-DATE", rel, "duplicate date heading")) if dates != sorted(dates, reverse=True): - bad = next(i for i in range(1, len(dates)) if dates[i] > dates[i - 1]) - fail("LOG-DATE", rel, f"{dates[bad]} after {dates[bad - 1]}: not newest first") + v.append(("LOG-DATE", rel, "date headings not newest first")) + + if raw is not None and name not in RESERVED: + n = len(lines) + body_lines = len([ln for ln in body.splitlines() if ln.strip()]) + opted_out = "length" in fm.get("okf-exempt", "").lower() + if n > 500: + if rel in length_exempt: + v.append(("LENGTH-OK", rel, + f"{n} lines — over the hint, pinned by the " + "one-page-per-endpoint law")) + elif opted_out: + v.append(("LENGTH-OK", rel, + f"{n} lines — over the hint, opted out by the page")) + else: + v.append(("LENGTH", rel, f"{n} lines (max 500)")) + elif body_lines < 15: + v.append(("LENGTH", rel, "stub: fewer than 15 body lines")) + + # A second `type:` key inside the body means the page carries two copies of + # itself with a frontmatter block in the middle — the shape a naive + # frontmatter rewrite produces. It doubles the length, splits the wikilinks + # and makes the page's facts ambiguous, so it is a hard failure. + if raw is not None and name not in RESERVED: + dupes = [i + len((raw or "").splitlines()) + 2 + for i, ln in enumerate(body.splitlines()) + if re.match(r"^(type|title|description):", ln)] + if dupes: + v.append(("DOUBLED", rel, + f"frontmatter keys inside the body at line(s) " + f"{', '.join(str(d) for d in dupes[:5])} — the page " + "contains a duplicated copy of itself")) + + return v + + +def index_rows(text): + rows = [] + for line in text.splitlines(): + if line.startswith("|") and "[[" in line: + rows.append(line) + return rows + + +def check_links(root: Path): + v = [] + pages = [ + p for p in sorted(root.rglob("*.md")) + if ".AGENTTRASH" not in str(p) and ".obsidian" not in str(p) + ] + for index in [p for p in pages if p.name == "index.md"]: + text = index.read_text(errors="replace") + links = set(re.findall(r"\[\[([^\]|#]+)", text)) + if index.parent == root: + targets = [p for p in pages if p.name not in RESERVED] + else: + targets = [p for p in index.parent.glob("*.md") if p.name != "index.md"] + for target in targets: + if target.stem not in links and target.name not in links: + v.append(("INDEX-COVER", str(index.relative_to(root)), + f"does not link sibling {target.name}")) + for index in [p for p in pages if p.name == "index.md"]: + for row in index_rows(index.read_text(errors="replace")): + if QUANTITY.search(strip_names(row)): + v.append(("INDEX-NUM", str(index.relative_to(root)), + "row restates a quantity: " + row[:80])) + return v + + +def live_endpoints(root: Path): + """Pages pinned by AGENTS.md's one-page-per-endpoint law. + + Derived, never hardcoded — the same derivation as + tests/docs_completeness.bats: an endpoint is live when scripts/ holds both + its env and its unit. Those pages carry the Eras table for their whole + history, so the OKF 500-line split hint cannot apply to them without + creating a second owner of the same shape. + """ + scripts = root.parent / "scripts" + out = set() + if scripts.is_dir(): + for envf in scripts.glob("frank-*.env"): + if (scripts / f"{envf.stem}.service").exists(): + out.add(f"LLM/{envf.stem}.md") + return out + + +def retired_era_pages(root: Path): + """Era pages of a retired endpoint: campaign ledgers, one owner each. + + AGENTS.md lets a campaign ledger that exists nowhere else live on the page + that measured it, and a retired endpoint keeps exactly one page per era. + Splitting such a ledger to satisfy the 500-line hint would fragment one + campaign across two owners, so the tag opts the page out. + """ + out = set() + for path in root.rglob("*.md"): + s = str(path) + if ".AGENTTRASH" in s or ".obsidian" in s: + continue + raw, _ = split_frontmatter(path.read_text(errors="replace")) + if raw is not None and "retired-endpoint" in fields(raw).get("tags", ""): + out.add(str(path.relative_to(root))) + return out def main(argv): - vault = pathlib.Path(argv[1] if len(argv) > 1 else "serverAppConfigs-docs") - if not vault.is_dir(): - print(f"not a directory: {vault}") - return 2 - failures = [] + root = Path(argv[1] if len(argv) > 1 else "serverAppConfigs-docs") + exempt = frozenset(live_endpoints(root)) | retired_era_pages(root) + violations = [] + notes = [] + citation_gaps = 0 + pages = 0 + for path in sorted(root.rglob("*.md")): + s = str(path) + if ".AGENTTRASH" in s or ".obsidian" in s: + continue + pages += 1 + for code, rel, detail in check_file(path, root, exempt): + (notes if code == "LENGTH-OK" else violations).append((code, rel, detail)) + text = path.read_text(errors="replace") + if len(text.splitlines()) > 40 and not re.search( + r"^##\s+(Citations|Sources)\b", text, re.M): + citation_gaps += 1 + violations += check_links(root) - def fail(rule, page, msg): - failures.append(f"{rule:12} {page}: {msg}") - - check_concepts(vault, fail) - check_indexes(vault, fail) - check_log(vault, fail) - print(f"{len(concept_pages(vault))} concept pages, {len(failures)} OKF violations") - for line in failures: - print(" " + line) - return 1 if failures else 0 + for code, rel, detail in violations: + print(f" {code:12s} {rel}: {detail}") + for code, rel, detail in notes: + print(f" {code:12s} {rel}: {detail}") + print(f"{pages} concept pages, {len(violations)} OKF violations" + f" ({citation_gaps} pages without a Citations section — soft;" + f" {len(notes)} length exemptions honoured)") + return 1 if violations else 0 if __name__ == "__main__": diff --git a/scripts/check-vault-links.py b/scripts/check-vault-links.py index b4e4f24..282e0dd 100755 --- a/scripts/check-vault-links.py +++ b/scripts/check-vault-links.py @@ -31,7 +31,12 @@ def strip_md(path: str) -> str: def build_index(vault: pathlib.Path): - pages = sorted(vault.rglob("*.md")) + # `.AGENTTRASH` is deleted-but-held state, not vault content: it must not be + # scanned (its relative links resolve against the trash dir, not the vault) + # and must not answer as a link target — a live page pointing into the trash + # is an unresolved link, not a resolved one. + pages = sorted(p for p in vault.rglob("*.md") + if ".AGENTTRASH" not in str(p)) stems: dict[str, list[pathlib.Path]] = {} for page in pages: stems.setdefault(page.stem.casefold(), []).append(page) diff --git a/scripts/gen-vault-index.py b/scripts/gen-vault-index.py index e68bfdf..ff53505 100755 --- a/scripts/gen-vault-index.py +++ b/scripts/gen-vault-index.py @@ -14,6 +14,10 @@ the docs seam fails when a page is not linked from it, so it is generated: A page with no description is a hard error rather than an empty cell, so the generator cannot silently produce a silent index. Sections with no pages are skipped. Deterministic: the same vault always yields the same bytes. + +Sub-directory indices are hand-grouped (they carry curated prose), so they are +synced rather than regenerated: dead rows dropped, row text refreshed from the +page's description, uncovered siblings appended. """ import pathlib import re @@ -85,6 +89,64 @@ def section_for(page, vault, fields): return "Uncategorised" +ROW = re.compile(r"^(\| )\[\[([^\]|]+)\]\]( \|)( .*?)( \|$)") +BULLET = re.compile(r"^(- )\[\[([^\]]+)\]\]( — )(.*?)$") + + +def sync_subindex(index: pathlib.Path, vault: pathlib.Path): + """Keep a hand-grouped sub-index honest without erasing its grouping. + + Rows are locators, so their text is the linked page's own description; a + row whose page is gone is a dead promise and is dropped; a direct sibling + with no row is appended under `## Pages` (that is what the link-integrity + seam checks). Curated headings and prose are untouched. + """ + here = index.parent + known = {p.stem for p in here.rglob("*.md") + if ".AGENTTRASH" not in str(p) and p.name != "index.md"} + lines = index.read_text().split("\n") + out, dropped, refreshed = [], 0, 0 + for line in lines: + for pat, wrap in ((ROW, "| [[{}]] |{} {} |"), (BULLET, "- [[{}]] — {}")): + m = pat.match(line) + if not m: + continue + stem = m.group(2).strip() + if stem not in known: + dropped += 1 + line = None + break + cand = next((p for p in here.rglob(f"{stem}.md") + if ".AGENTTRASH" not in str(p)), None) + desc = frontmatter_fields(cand.read_text(errors="replace")).get("description") \ + if cand else None + if desc: + current = m.group(4).strip() + if current != desc: + line = wrap.format(stem, " " if pat is ROW else "", desc) \ + if pat is ROW else wrap.format(stem, desc) + refreshed += 1 + break + if line is not None: + out.append(line) + text = "\n".join(out) + # Presence must be the wikilink itself: a bare stem is a substring of other + # page names (`frank-medium` lives inside `frank-medium-rocmfpx`), and a + # sibling matched that way silently never gets a row. + missing = sorted( + (p.stem, frontmatter_fields(p.read_text(errors="replace")).get("description", "")) + for p in here.glob("*.md") + if p.name != "index.md" and f"[[{p.stem}]]" not in text + ) + if missing: + block = ["", "## Pages", "", "| Page | Owns |", "|---|---|"] + block += [f"| [[{stem}]] | {desc} |" for stem, desc in missing] + text = text.rstrip("\n") + "\n" + "\n".join(block) + "\n" + if text != index.read_text(): + index.write_text(text) + return dropped, refreshed, len(missing) + + PIPELINE_SECTION_DIR = "Pipeline Parallelism" @@ -127,7 +189,16 @@ def main(argv): out.append("") out.append("") index.write_text("\n".join(out) + "\n") - print(f"{index}: {sum(len(v) for v in buckets.values())} rows in {len([t for t in ORDER if buckets.get(t)])} sections") + touched = 0 + for sub in sorted(vault.rglob("index.md")): + if sub == index: + continue + dropped, refreshed, added = sync_subindex(sub, vault) + if dropped or refreshed or added: + touched += 1 + print(f" {sub}: -{dropped} dead rows, {refreshed} refreshed, +{added} added") + print(f"{index}: {sum(len(v) for v in buckets.values())} rows in " + f"{len([t for t in ORDER if buckets.get(t)])} sections, {touched} sub-indices synced") return 0 diff --git a/scripts/port-census.py b/scripts/port-census.py new file mode 100755 index 0000000..8ea1f66 --- /dev/null +++ b/scripts/port-census.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Port census: prove a merge/collapse lost no fact. + +For every line that existed in the OLD set and not in the NEW set, check that +each of its significant tokens (numbers, long identifiers, paths) still occurs +somewhere in the NEW corpus. A line whose every token survives is a reworded +line; a line with an unheld token is a candidate fact loss and gets printed. + + port-census.py [ ...] + +Old files may be `git-rev:path` (read from git) or a path (e.g. a trash copy). +""" +import pathlib +import re +import subprocess +import sys + + +def toks(line): + out = [] + for n in re.findall(r"\d[\d,]*(?:\.\d+)?", line): + if len(n.replace(",", "").split(".")[0]) >= 3 or ( + "." in n and len(n.split(".")[-1]) >= 2 and len(n.replace(",", "")) >= 4 + ): + out.append(n) + # Identifiers, flags, paths and backticked spans are the facts worth + # tracking; ordinary prose words are not — they reword on every rewrite. + out += [w for w in re.findall(r"[A-Za-z][\w./:-]{7,}", line) + if re.search(r"[\d_.:/-]", w) or w.isupper()] + out += [s.strip() for s in re.findall(r"`([^`]+)`", line) if len(s) > 3] + return out + + +def held(tok, corpus): + t = tok.replace(",", "") + alt = rf"\b{re.escape(t)}\b" + for c in corpus: + if tok in c or t in c: + return True + if re.search(alt, c.replace(",", "")): + return True + return False + + +def main(argv): + new_dir = pathlib.Path(argv[1]) + corpus = [ + p.read_text(errors="replace") + for p in new_dir.rglob("*.md") + if ".AGENTTRASH" not in str(p) and ".obsidian" not in str(p) + ] + blob = "\n".join(corpus) + lost = 0 + checked = 0 + for spec in argv[2:]: + if spec.startswith("git-"): + ref, path = spec[4:].split(":", 1) + old = subprocess.run(["git", "show", f"{ref}:{path}"], + capture_output=True, text=True).stdout + label = f"{ref}:{path}" + else: + old = pathlib.Path(spec).read_text(errors="replace") + label = spec + for line in old.splitlines(): + s = line.strip().strip("->*|# ").strip() + if len(s) < 30 or s.startswith("```"): + continue + checked += 1 + missing = [t for t in toks(s) if not held(t, [blob])] + if missing: + lost += 1 + print(f" {label}\n L: {s[:150]}\n unheld: {', '.join(missing[:6])}") + print(f"{checked} old lines checked, {lost} with unheld tokens") + return 1 if lost else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/docs_completeness.bats b/tests/docs_completeness.bats index 1c16919..0ed9d7c 100644 --- a/tests/docs_completeness.bats +++ b/tests/docs_completeness.bats @@ -15,7 +15,7 @@ load helpers # service name is the docs slug: RomM -> serverAppConfigs-docs/Dankserver/RomM.md # matched case-insensitively (vault uses Title Case) local doc - doc=$(find "$REPO_ROOT/serverAppConfigs-docs" -iname "${svc}.md" 2>/dev/null | head -1) + doc=$(find "$REPO_ROOT/serverAppConfigs-docs" -iname "${svc}.md" -not -path '*/.AGENTTRASH/*' 2>/dev/null | head -1) if [ -z "$doc" ]; then echo "service '$svc' (from $f) has no docs page: expected serverAppConfigs-docs/**/${svc}.md" return 1 @@ -34,7 +34,10 @@ load helpers echo "docs page $base is not linked from $index" return 1 } - done < <(find "$REPO_ROOT/serverAppConfigs-docs" -name '*.md' -not -name 'index.md' -not -path '*/.obsidian/*' | sort) + # `.AGENTTRASH` is deleted-but-held state per the repo's deletion policy, not + # vault content: trashed pages need no index row, and a trashed page must not + # answer as the docs page for a live compose service. + done < <(find "$REPO_ROOT/serverAppConfigs-docs" -name '*.md' -not -name 'index.md' -not -path '*/.obsidian/*' -not -path '*/.AGENTTRASH/*' | sort) } @test "no unresolved links in the docs vault" { @@ -74,3 +77,34 @@ load helpers return 1 } } + +@test "the vault conforms to OKF v0.1" { + # Frontmatter contract (type/description/timestamp), the 500-line split + # hint, log.md's date-heading shape and newest-first order, and index rows + # that restate a fleet quantity instead of pointing at its owner. The only + # length exemptions are the live endpoint pages, derived from scripts/ -- + # see scripts/check-okf.py. + run "$REPO_ROOT/scripts/check-okf.py" "$REPO_ROOT/serverAppConfigs-docs" + [ "$status" -eq 0 ] || { echo "$output"; false; } +} + +@test "vault indices are generated, not hand-carved" { + # serverAppConfigs-docs/index.md is regenerated from page frontmatter and + # every sub-index is synced against its siblings. A hand-edited row is how + # a page silently leaves the index, so regeneration must be a no-op. The + # vault is copied first: the seam never mutates the checkout. + local work="$BATS_TEST_TMPDIR/vault" + cp -r "$REPO_ROOT/serverAppConfigs-docs" "$work" + run "$REPO_ROOT/scripts/gen-vault-index.py" "$work" + [ "$status" -eq 0 ] || { echo "$output"; false; } + local stale=0 f rel + while IFS= read -r f; do + rel=${f#"$REPO_ROOT"/serverAppConfigs-docs/} + if ! diff -q "$f" "$work/$rel" >/dev/null; then + echo "stale index row(s) in $f -- run scripts/gen-vault-index.py" + diff "$f" "$work/$rel" | head -20 + stale=1 + fi + done < <(find "$REPO_ROOT/serverAppConfigs-docs" -name index.md -not -path '*/.AGENTTRASH/*' | sort) + [ "$stale" -eq 0 ] +}