feat(docs): generate Section 5 from function comment headers

The man-page-style comment header above each function in functions/*.fish
becomes the SSOT for that function's documentation. Writing a new function
and documenting it are now the same act.

- manualtools.parse_functions() parses every header carrying a # CATEGORY;
  absence of one is the opt-in, keeping bundled-plugin and prompt internals
  out of the manual with no exclusion list to maintain.
- build-manual.py generates entries for both --concat and --site, with
  **Dependencies:** rendered as links and a **Used by:** reverse index
  computed in one pass. Cross-category links are the navigation win.
- docs/manual/05-functions/*.md reduced to frontmatter-only stubs. Every
  intro measured zero words, so the category files were pure entry
  containers; ordering, titles, and helpKeywords routing are untouched.
- _first_sentence() unwraps the leading hard-wrapped paragraph and skips
  the whole Synopsis block, not just its label line. Site cards no longer
  truncate mid-clause or show a synopsis as their description.

Verification, per the design spec:
- test_concat_roundtrips_original scoped to sections 0-4 and 6-11. It
  guarded a format migration; this is a content migration.
- replaced by structural checks: one entry per categorised function, the
  required sections present, every category resolving to a stub with no
  stub empty, and every declared dependency resolving to a real function
  or a type -q-guarded binary.
- public functions lacking # CATEGORY warn rather than fail, so a new
  user-facing function going undocumented stays visible in CI.

24/24 checks pass. 94 entries generated from 94 parsed headers.

Also drops a stale claim from open-url's NOTES: config-help --html calls
xdg-open directly and has never called open-url.
This commit is contained in:
2026-07-26 04:12:48 -04:00
parent 00f70e8558
commit a65e05b661
24 changed files with 1594 additions and 1601 deletions
+111 -5
View File
@@ -4,6 +4,7 @@
"""Verification checks for the docs/manual SSOT pipeline."""
import importlib.util
import re
import sys
import tempfile
from pathlib import Path
@@ -109,14 +110,113 @@ def test_manual_tree_exists():
assert len(cats) == 14, f"expected 14 function categories, got {len(cats)}: {cats}"
def test_function_entries_promoted_to_h2():
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)
assert "\n### " not in f"\n{body}", f"{path.name} still has H3 entries"
assert "\n## " in f"\n{body}", f"{path.name} has no H2 function entries"
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:
@@ -128,6 +228,11 @@ def _normalise(text: str) -> str:
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
@@ -149,8 +254,8 @@ def test_concat_roundtrips_original():
if not original.exists():
original = docs / "fish-config.md"
label = "fish-config.md"
got = build_manual.build_concat(docs / "manual")
want = original.read_text()
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)
@@ -393,6 +498,7 @@ def main() -> int:
except AssertionError as e:
print(f" FAIL {t.__name__}: {e}", file=sys.stderr)
failed += 1
warn_public_functions_without_category()
print(f"\n{len(TESTS) - failed}/{len(TESTS)} passed")
return 1 if failed else 0