#!/usr/bin/env python3 # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later """Verification checks for the docs/manual SSOT pipeline.""" import importlib.util import re import sys import tempfile from pathlib import Path import manualtools as mt # docs/build-manual.py follows this repo's hyphenated CLI-script naming # convention (matching verify-manual.py), which means it # cannot satisfy a plain `import build_manual` on its own — Python's import # statement never treats a hyphen as an underscore. Load it explicitly under # the name the tests expect and register it in sys.modules; every later # `import build_manual` (including the one inside test_concat_roundtrips_ # original below) then finds the cached module instead of touching the path # finder. _build_manual_path = Path(__file__).parent / "build-manual.py" _spec = importlib.util.spec_from_file_location("build_manual", _build_manual_path) _build_manual = importlib.util.module_from_spec(_spec) sys.modules["build_manual"] = _build_manual _spec.loader.exec_module(_build_manual) def test_parse_roundtrip(): fm = {"title": "Git", "sidebar": {"order": 4}, "helpKeywords": ["git", "gi"]} body = "## gitig\n\nManages ignore files." text = mt.serialize(fm, body) with tempfile.TemporaryDirectory() as d: p = Path(d) / "t.md" p.write_text(text) got_fm, got_body = mt.parse(p) assert got_fm == fm, f"frontmatter mismatch: {got_fm!r}" assert got_body == body, f"body mismatch: {got_body!r}" def test_parse_no_frontmatter(): with tempfile.TemporaryDirectory() as d: p = Path(d) / "t.md" p.write_text("# Plain\n\ntext\n") fm, body = mt.parse(p) assert fm == {}, f"expected empty frontmatter, got {fm!r}" assert body == "# Plain\n\ntext", f"body mismatch: {body!r}" def test_shift_headings(): body = "## a\n\ntext\n\n### b" assert mt.shift_headings(body, 1) == "### a\n\ntext\n\n#### b" def test_shift_headings_skips_code_fences(): body = "## a\n\n```\n# not a heading\n```\n\n## b" got = mt.shift_headings(body, 1) assert "# not a heading" in got, "code fence content was modified" assert got.startswith("### a"), f"heading not shifted: {got[:10]!r}" def test_walk_orders_by_sidebar_order_then_filename(): with tempfile.TemporaryDirectory() as d: root = Path(d) (root / "b.md").write_text(mt.serialize({"title": "B", "sidebar": {"order": 1}}, "")) (root / "a.md").write_text(mt.serialize({"title": "A", "sidebar": {"order": 2}}, "")) (root / "c.md").write_text(mt.serialize({"title": "C"}, "")) got = [p.name for p, _ in mt.walk(root)] assert got == ["b.md", "a.md", "c.md"], f"wrong order: {got}" def test_walk_nests_directory_after_its_index(): with tempfile.TemporaryDirectory() as d: root = Path(d) (root / "01-first.md").write_text(mt.serialize({"title": "First"}, "")) sub = root / "02-group" sub.mkdir() (sub / "index.md").write_text(mt.serialize({"title": "Group"}, "")) (sub / "01-child.md").write_text(mt.serialize({"title": "Child"}, "")) (root / "03-last.md").write_text(mt.serialize({"title": "Last"}, "")) got = [(p.name, depth) for p, depth in mt.walk(root)] expected = [ ("01-first.md", 0), ("index.md", 0), ("01-child.md", 1), ("03-last.md", 0), ] assert got == expected, f"wrong nesting: {got}" def test_parse_roundtrip_body_with_leading_blank_line(): fm = {"title": "Test"} body = "\nContent starts after blank line." text = mt.serialize(fm, body) with tempfile.TemporaryDirectory() as d: p = Path(d) / "t.md" p.write_text(text) got_fm, got_body = mt.parse(p) assert got_fm == fm, f"frontmatter mismatch: {got_fm!r}" assert got_body == body, f"body mismatch: expected {body!r}, got {got_body!r}" def test_manual_tree_exists(): root = Path(__file__).parent / "manual" assert root.is_dir(), "docs/manual/ not generated" assert (root / "index.md").exists(), "docs/manual/index.md missing" fn = root / "05-functions" assert fn.is_dir(), "docs/manual/05-functions/ missing" cats = sorted(p.name for p in fn.glob("*.md") if p.name != "index.md") assert len(cats) == 14, f"expected 14 function categories, got {len(cats)}: {cats}" def test_function_stubs_carry_no_entries(): """Category files are stubs: entries come from functions/*.fish headers. An authored `##` entry here would be a second copy of a function's documentation — exactly the duplication the header-SSOT migration removed — and the generator would emit its own entry alongside it. """ root = Path(__file__).parent / "manual" / "05-functions" for path in root.glob("*.md"): if path.name == "index.md": continue _, body = mt.parse(path) stray = [ln for ln in body.split("\n") if ln.startswith(("## ", "### "))] assert not stray, f"{path.name} has authored entries: {stray}" def _parsed_functions() -> dict[str, dict[str, list[str]]]: return mt.parse_functions(Path(__file__).parent.parent / "functions") def test_every_categorised_function_produces_one_entry(): import build_manual functions = _parsed_functions() entries = build_manual.build_entries(functions) got = [name for names in entries.values() for name, _ in names] assert sorted(got) == sorted(functions), ( f"entry/function mismatch: " f"{sorted(set(functions) ^ set(got))}" ) assert len(got) == len(set(got)), "a function produced more than one entry" def test_entries_carry_the_required_sections(): missing = [] for name, fn in _parsed_functions().items(): absent = [s for s in ("SYNOPSIS", "DESCRIPTION", "EXAMPLE") if not fn.get(s)] if absent: missing.append(f"{name}: {', '.join(absent)}") assert not missing, "headers missing required sections:\n " + "\n ".join(missing) def test_every_category_resolves_to_a_stub(): root = Path(__file__).parent / "manual" / "05-functions" stubs = {p.stem for p in root.glob("*.md") if p.name != "index.md"} used = {} for name, fn in _parsed_functions().items(): used.setdefault(" ".join(fn.get("CATEGORY", [])).strip(), []).append(name) unknown = {c: v for c, v in used.items() if c not in stubs} assert not unknown, f"# CATEGORY values with no stub: {unknown}" empty = sorted(stubs - set(used)) assert not empty, f"category stubs generating zero entries: {empty}" def test_dependencies_resolve(): """Every declared # DEPENDENCIES name must be a real function or binary. Catches typos, and catches stale entries when a dependency is renamed or deleted. External binaries are accepted when some file in the tree guards them with `type -q`, which is this repo's convention. """ repo = Path(__file__).parent.parent functions = _parsed_functions() known = {p.stem for p in (repo / "functions").glob("*.fish")} | set(functions) for path in list(repo.glob("conf.d/*.fish")) + list((repo / "functions").glob("*.fish")): known |= set(re.findall(r"type -q\s+(\S+)", path.read_text(encoding="utf-8"))) dangling = [] for name, fn in functions.items(): for dep in (d for d in re.split(r"[,\s]+", " ".join(fn.get("DEPENDENCIES", []))) if d): if dep not in known: dangling.append(f"{name} -> {dep}") assert not dangling, "unresolvable # DEPENDENCIES:\n " + "\n ".join(dangling) def warn_public_functions_without_category(): """Warn — never fail — on a public function carrying no `# CATEGORY`. Bundled plugin and prompt internals will always lack one, so this cannot be a hard failure; a genuinely new user-facing function going undocumented still needs to be visible in CI output. """ repo = Path(__file__).parent.parent documented = set(_parsed_functions()) orphans = sorted( p.stem for p in (repo / "functions").glob("*.fish") if not p.stem.startswith("_") and p.stem not in documented and "# SYNOPSIS" in p.read_text(encoding="utf-8") ) if orphans: print(f" WARN {len(orphans)} documented function(s) lack # CATEGORY:") print(" " + ", ".join(orphans)) def _without_section_5(text: str) -> str: """Drop `# 5. FUNCTIONS REFERENCE` through the start of section 6. Section 5 is generated from `functions/*.fish` headers, so it legitimately differs from the pre-migration snapshot. The round-trip guard covers the authored sections either side of it. """ start = text.find("\n# 5. ") if start == -1: return text end = text.find("\n# 6. ", start) return text[:start] + (text[end:] if end != -1 else "") def _normalise(text: str) -> str: """Collapse whitespace so only content differences survive.""" lines = [ln.rstrip() for ln in text.strip().split("\n")] return "\n".join(ln for ln in lines if ln != "") def test_concat_roundtrips_original(): """The concat of manual/ must reproduce the original fish-config.md exactly. Section 5 is excluded: it is generated from `functions/*.fish` headers and so legitimately differs from the snapshot. This test guarded the *format* migration; the header-SSOT change is a *content* migration, covered instead by the structural checks above. Prefers docs/fish-config.md.orig (a snapshot of the pre-migration file) when present. Once that snapshot is deleted post-migration, docs/fish-config.md IS the concat output regenerated in Step 5, so falling back to it turns this into an idempotency regression check instead of going red for a missing file. The pass/fail decision is an exact (raw-text) comparison, not a whitespace-normalised one. Blank lines are load-bearing for pandoc (`blank_before_header` is on by default): losing them merges paragraphs and stops headings being headings, so a test that tolerated blank-line or line-joining drift would stay green while the man page silently broke. `_normalise` is used only afterwards, to build a readable diff. """ import build_manual docs = Path(__file__).parent original = docs / "fish-config.md.orig" label = "original" if not original.exists(): original = docs / "fish-config.md" label = "fish-config.md" got = _without_section_5(build_manual.build_concat(docs / "manual")) want = _without_section_5(original.read_text()) if got != want: norm_got = _normalise(got) norm_want = _normalise(want) if norm_got == norm_want: raise AssertionError( "concat differs from original only in whitespace/blank lines " "(exact comparison failed, normalised comparison passed) — " "blank lines are load-bearing for pandoc, this is a real regression" ) import difflib diff = list( difflib.unified_diff( norm_want.split("\n"), norm_got.split("\n"), label, "concat", lineterm="", n=1 ) )[:40] raise AssertionError("concat differs from original:\n" + "\n".join(diff)) def test_every_index_keyword_resolves(): """Every keyword in fish-config.index must match a heading in the concat.""" import build_manual docs = Path(__file__).parent index = docs / "fish-config.index" if not index.exists(): print(" SKIP test_every_index_keyword_resolves (no index file)") return concat = build_manual.build_concat(docs / "manual") headings = {ln.strip() for ln in concat.split("\n") if ln.startswith("#")} missing = [] for line in index.read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue keyword, heading = line.split("=", 1) if heading.strip() not in headings: missing.append(f"{keyword.strip()} -> {heading.strip()}") assert not missing, "unresolvable index keywords:\n " + "\n ".join(missing) def test_site_build_produces_function_pages(): import tempfile import build_manual docs = Path(__file__).parent with tempfile.TemporaryDirectory() as d: out = Path(d) sidebar = build_manual.build_site(docs / "manual", out) pages = list(out.rglob("*.md*")) assert (out / "index.md").exists() or (out / "index.mdx").exists(), "landing page missing" fn_pages = [p for p in pages if "reference" in p.parts and p.name != "index.mdx"] assert len(fn_pages) > 80, f"expected >80 function pages, got {len(fn_pages)}" assert not (out / "00-name.md").exists(), "site:false page was published" assert sidebar, "sidebar structure is empty" for page in pages: fm, _ = mt.parse(page) assert "manTitle" not in fm, f"{page.name} leaked manTitle into site output" assert "title" in fm, f"{page.name} has no title" def test_prettify_splits_an_entry_block(): """A man-page-style entry becomes fenced code, prose, and a table.""" import build_manual body = "\n".join( [ " Synopsis: rm [-e | args...]", " Safe rm wrapper routing to trash:", "", " (no args) List current trash contents", " -e/--empty Empty the trash", "", " Falls back to /usr/bin/rm when trash is unavailable.", "", " Example:", " rm file.txt # moves to trash", " rm -e # empty trash", ] ) out = build_manual.prettify(body, "rm") assert '```fish title="Usage"\nrm [-e | args...]\n```' in out, ( "synopsis was not fenced as fish with a Usage title" ) assert '```fish title="Examples"\nrm file.txt' in out, ( "examples were not fenced as fish with an Examples title" ) assert out.count("```") == 4, f"expected exactly two fences, got:\n{out}" assert "\nSafe rm wrapper routing to trash:" in out, "description stayed indented" assert "| `(no args)` | List current trash contents |" in out, ( "option table was not converted to a markdown table" ) assert ( "\nFalls back to /usr/bin/rm when trash is unavailable." in out ), "trailing prose stayed indented" def test_as_table_converts_option_blocks(): """A labelled, column-aligned block becomes a table; wrapped rows fold in.""" import build_manual out = build_manual._as_table( [ "Options:", " -a/--aggressive Also removes node_modules, logs,", " and IDE dirs", " -d/--dry-run Print what would be removed", "Pass neither to run interactively.", ] ) assert out is not None, "a plain option table was rejected" assert out.splitlines()[0] == "Options:", "the label line was dropped" assert out.splitlines()[-1] == "Pass neither to run interactively.", ( "the trailing sentence was dropped" ) assert ( "| `-a/--aggressive` | Also removes node_modules, logs, and IDE dirs |" in out ), "a wrapped description did not fold into the row above" def test_as_table_escapes_pipes(): """`|` splits table cells even inside a code span, so it must be escaped.""" import build_manual out = build_manual._as_table( [" -r/-R Recurse into it", " -e|-E Empty it"] ) assert out is not None and r"`-e\|-E`" in out, f"pipe was not escaped:\n{out}" def test_as_table_rejects_non_tables(): """Returning None is always safe, so every ambiguous shape must return it.""" import build_manual cases = { "single row": [" -f/--force Force-delete unmerged branches too"], "numbered list": [ " 1. git+cargo source build (fish shell itself)", " 2. cargo (Rust tools — gets latest crate version)", ], "misaligned rows": [ " -e/--empty Empty the trash", " -S/--secure Permanently delete (single space, not a column)", ], "synopsis continuation": [ " auto-pull add [PATH]", " auto-pull status", ], "unlabelled head": [ "Routes to the best tool by context.", " --disk force duf", " --dir force dust", ], "live markdown in prose column": [ " add Register 's git root", " remove Unregister by basename", ], } for label, para in cases.items(): assert build_manual._as_table(para) is None, f"{label} was wrongly tabled" def test_as_ruled_table_converts_header_rule_rows(): """A header + dashed-rule + rows block (Component Reference style) tables.""" import build_manual out = build_manual._as_ruled_table( [ "Component Requires", "─────────────────────────────────────────", "spwin Kitty or WezTerm", "hist fzf + wl-copy (Wayland clipboard)", ] ) assert out is not None, "a header+rule+rows block was rejected" assert out.splitlines()[0] == "| Component | Requires |" assert "| `spwin` | Kitty or WezTerm |" in out def test_as_ruled_table_converts_per_column_dash_rule(): """RST-style rule (one dash run per column, gapped like the header) tables too.""" import build_manual out = build_manual._as_ruled_table( [ "Variable Disables", "------------------------------ ------------------------------------", "__fish_config_op_aliases Command shadows and flag injection", "__fish_config_op_autoexec Startup side-effects", ] ) assert out is not None, "a per-column dashed rule was rejected" assert out.splitlines()[0] == "| Variable | Disables |" assert "| `__fish_config_op_aliases` | Command shadows and flag injection |" in out def test_as_ruled_table_folds_wrapped_continuations(): """A row that splits into one cell continues the previous row's last column.""" import build_manual out = build_manual._as_ruled_table( [ "Component What it captures", "─────────────────────────────────────────", "Scrollback capture Terminal output saved to:", " ~/.terminal_history/scrollback.log", "tmux pane capture Streamed via pipe-pane", ] ) assert out is not None assert ( "| `Scrollback capture` | Terminal output saved to: ~/.terminal_history/scrollback.log |" in out ), "a wrapped continuation line did not fold into the row above" def test_as_ruled_table_code_spans_angle_brackets_and_braces(): """ / brace-glob cells get backtick-protected, not rejected.""" import build_manual out = build_manual._as_ruled_table( [ "Component What it captures", "─────────────────────────────────────────", "tmux pane capture saved to tmux_-w.log", "Autopair auto-close to (), [], {}", ] ) assert out is not None, "angle brackets/braces caused the table to be rejected" assert "`saved to tmux_-w.log`" in out assert "`auto-close to (), [], {}`" in out def test_as_ruled_table_rejects_ambiguous_columns(): """A row with fewer delimited columns than the header is a source bug, not a guess.""" import build_manual out = build_manual._as_ruled_table( [ "Command Active behavior Disabled fallback", "─────────────────────────────────────────────────────────", "ls eza -l -a --icons system ls", "du duf (disk overview) system du", ] ) assert out is None, "an under-delimited row should fall back to a code block" def test_prettify_leaves_reference_tables_alone(): """Column-aligned blocks are data, not shell, and must not be fenced.""" import build_manual table = " XDG_CONFIG_HOME ~/.config\n XDG_CACHE_HOME ~/.cache" assert "```" not in build_manual.prettify(table), "a reference table got fenced" binds = " n / nv / neovim nvim\n e edit" assert "```" not in build_manual.prettify(binds), "an abbreviation table got fenced" shell = " set -U __fish_user_dots_path /path/to/dots" assert "```fish" in build_manual.prettify(shell), "a shell block was not fenced" def test_prettify_titles_paths_and_commented_examples(): """A bare file path or a leading '# in x.fish' comment become a title.""" import build_manual path = " $__fish_user_dots_path/local.fish" assert '```fish title="local.fish"\n$__fish_user_dots_path/local.fish\n```' in ( build_manual.prettify(path) ), "a bare file path was not titled" commented = "\n".join( [" # in local.fish", " set -gx SCROLLBACK_HISTORY_MAX_FILES 200"] ) out = build_manual.prettify(commented) assert '```fish title="local.fish"\nset -gx SCROLLBACK_HISTORY_MAX_FILES 200\n```' in out, ( f"a filename comment was not promoted to the fence title:\n{out}" ) def test_as_aside_converts_a_single_line_label(): """A `LABEL: text` line becomes a titled