From 911c6e95edd71f324d54d72419aa4a2d5fced2ac Mon Sep 17 00:00:00 2001 From: Rootiest Date: Thu, 24 Sep 2026 00:50:36 -0400 Subject: [PATCH] feat(docs): add manual-section CLASSIFICATION tag, wire it into the build A function with a dedicated manual section (docs/manual/16-agent-tooling.md, so far) now carries manual-section() in its own CLASSIFICATION line instead of relying on a NOTES pointer nobody can grep for. Applied to agents-init and agents-vault, both pointing at 16-agent-tooling. docs/build-manual.py: _resolve_manual_section reads the target page's own manTitle/title fresh at build time rather than duplicating a section number into the tag, so a renumbered section (like this one, twice already) never requires touching the tag -- only the slug (the filename) does, and only if the page itself is renamed. render_entry and render_entry_site both gained an optional root parameter and now emit a 'See also' line (plain text + relative path for the man page, a real markdown link on the site) whenever the tag resolves; omitted silently when it doesn't (a build isn't the place to fail on a bad slug). docs/verify-manual.py: unit tests for the new resolver and both renderers, plus a real-data scan (test_real_manual_section_tags_resolve) that fails the suite if any function's manual-section() tag points nowhere -- the actual enforcement half of the convention, since the build stays silent about it. docs/function-classification-schema.md, CONTRIBUTING.md: documents the tag, and widens CLASSIFICATION's own framing from strictly hazard/shadow tags to general-purpose (the user's call, not mine to make unilaterally -- scope-broadening an existing convention). The 'Dedicated manual sections' subsection (added earlier this branch) now names the tag as the machine-checked half of that convention, with NOTES demoted to a nice-to-have for a header-only reader. --- CONTRIBUTING.md | 39 ++++++--- docs/build-manual.py | 74 +++++++++++++++-- docs/function-classification-schema.md | 24 ++++++ docs/verify-manual.py | 111 +++++++++++++++++++++++++ functions/agents-init.fish | 2 +- functions/agents-vault.fish | 2 +- 6 files changed, 229 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a019c4d..0144ee1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -391,7 +391,7 @@ all optional except where noted: | `CATEGORY` | **Required to appear in the manual at all** — see below. | | `COMPONENT` | Only for functions gated by the [opinionated-component system](#opinionated-components). | | `DEPENDENCIES` | Other functions, and external CLI tools, this one needs for full functionality — required or optional-with-fallback alike. | -| `CLASSIFICATION` | Hazard/shadow-interaction tags — see below. | +| `CLASSIFICATION` | Hazard/shadow-interaction tags, plus a couple of general-purpose ones (`manual-section`) — see below. | | `SYNOPSIS` | One-line usage form. | | `DESCRIPTION` | Prose description; can span multiple paragraphs. | | `ARGUMENTS` | Flags/positional args, one per line. | @@ -447,13 +447,17 @@ If your function genuinely doesn't fit any of these, add a new `docs/manual/05-functions/NN-your-category.md` stub (with frontmatter matching its siblings) rather than force-fitting it into an existing one. -**`CLASSIFICATION` flags hazards and shadow interactions, optional and -omitted when nothing applies:** whether the function calls a +**`CLASSIFICATION` is the general-purpose tag field, optional and omitted +when nothing applies:** mostly hazards and shadow interactions — whether +the function calls a [C1-shadowed command](docs/manual/08-components-reference/01-c1-command-shadows.md) bare wanting the override (`uses-shadow(ls)`) or bypasses it deliberately via `command`/`builtin` (`bypasses-shadow(cat)`), and general hazards — -`destructive`, `network`, `blocking-prompt`. Full tag definitions and -placement rule: [`docs/function-classification-schema.md`](docs/function-classification-schema.md). +`destructive`, `network`, `blocking-prompt` — but not exclusively: a +function with its own dedicated manual section (below) carries +`manual-section()` here too, so that fact is grep-able without +reading every `NOTES` field. Full tag definitions and placement rule: +[`docs/function-classification-schema.md`](docs/function-classification-schema.md). ### Dedicated manual sections for complex subsystems @@ -470,15 +474,24 @@ subsystem its own numbered top-level section under `docs/manual/` stay short and point there for the full picture, the same way this document points at other reference files rather than repeating them. +A function with a dedicated section carries `manual-section()` in +its own `# CLASSIFICATION` (see `functions/agents-init.fish`; full tag +definition in +[`docs/function-classification-schema.md`](docs/function-classification-schema.md)) +— that's what makes the section discoverable without reading every +function's `NOTES` by hand, and it's what `docs/build-manual.py` reads to +render the "See also" line on the function's generated entry. +`docs/verify-manual.py` fails the build if the slug doesn't resolve to a +real page, so a typo or a renamed file can't go unnoticed — but it cannot +check the *content* is current. A `# NOTES` line pointing at the same page +(see the existing example) is worth adding too, for a reader who only +reads the header text rather than the generated docs, but the tag is the +part something else actually verifies. + This is a genuine exception to "the doc-header is the single source of -truth" above, and it comes with an obligation `verify-manual.py` cannot -enforce for you: nothing checks that a dedicated section still describes -the function's *current* behavior. **Whenever you change what one of -these functions does, update its dedicated section in the same commit or -pull request** — not as a follow-up. A function with a dedicated section -should say so in its own `# NOTES` (see `functions/agents-init.fish` for -the pattern), so a later reader of just the header still finds the fuller -page. +truth" above. **Whenever you change what one of these functions does, +update its dedicated section in the same commit or pull request** — not +as a follow-up. ### Private/internal helper functions diff --git a/docs/build-manual.py b/docs/build-manual.py index cb7ac5f..cd1435a 100644 --- a/docs/build-manual.py +++ b/docs/build-manual.py @@ -202,7 +202,7 @@ def build_concat(root: Path) -> str: Only bodies are passed: the pandoc metadata block above is not prose and must survive byte-for-byte. """ - entries = build_entries(mt.parse_functions(FUNCTIONS)) + entries = build_entries(mt.parse_functions(FUNCTIONS), root=root) chunks: list[str] = [] pandoc_path = root / "_pandoc.yml" if pandoc_path.exists(): @@ -726,7 +726,44 @@ def _classification_tags(raw: list[str]) -> list[str]: return [t for t in tags if t] -def render_entry(fn: dict[str, list[str]], used_by: list[str], link=None) -> str: +MANUAL_SECTION_RE = re.compile(r"^manual-section\(([\w./-]+)\)$") + + +def _manual_section_slug(tags: list[str]) -> str | None: + """Pull the slug out of a `manual-section()` CLASSIFICATION tag, if present.""" + for tag in tags: + m = MANUAL_SECTION_RE.match(tag) + if m: + return m.group(1) + return None + + +def _resolve_manual_section(root: Path | None, slug: str) -> tuple[str, str, str] | None: + """Resolve a manual-section() tag to (display label, site link, doc-relative path). + + Looks for .md (a top-level single-file section) or /index.md + (a directory-based section), matching the two shapes docs/manual/ + actually uses. The display label is read fresh from the target's own + frontmatter (manTitle, falling back to title) rather than duplicated in + the tag, so a renumbered section never needs its tag updated -- only + the slug (the filename) does, and that only changes if the page itself + is renamed. Returns None -- silently, this is a build, not a check; + verify-manual.py is where a dangling slug is a real failure -- when + is unset or neither candidate exists. + """ + if root is None: + return None + for relpath in (f"{slug}.md", f"{slug}/index.md"): + if (root / relpath).exists(): + fm, _ = mt.parse(root / relpath) + label = fm.get("manTitle") or fm.get("title", slug) + return label, f"/{slug}/", relpath + return None + + +def render_entry( + fn: dict[str, list[str]], used_by: list[str], link=None, root: Path | None = None +) -> str: """Render one parsed function header as a manual entry body. Emits the same man-page shape Section 5 was authored in — one 4-space @@ -759,15 +796,22 @@ def render_entry(fn: dict[str, list[str]], used_by: list[str], link=None) -> str def names(raw: list[str]) -> list[str]: return [n for n in re.split(r"[,\s]+", " ".join(raw)) if n] + classification = _classification_tags(fn.get("CLASSIFICATION", [])) refs = [] for label, values in ( ("Dependencies", names(fn.get("DEPENDENCIES", []))), - ("Classification", _classification_tags(fn.get("CLASSIFICATION", []))), + ("Classification", classification), ("Used by", sorted(used_by)), ): if values: rendered = ", ".join(link(v) if link else f"`{v}`" for v in values) refs.append(f"**{label}:** {rendered}") + slug = _manual_section_slug(classification) + if slug: + resolved = _resolve_manual_section(root, slug) + if resolved: + label, _href, relpath = resolved + refs.append(f"**See also:** {label} (`docs/manual/{relpath}`)") if refs: block += "\n\n" + "\n\n".join(refs) return block @@ -871,7 +915,9 @@ SITE_SECTIONS = ( ) -def render_entry_site(fn: dict[str, list[str]], used_by: list[str], link=None) -> str: +def render_entry_site( + fn: dict[str, list[str]], used_by: list[str], link=None, root: Path | None = None +) -> str: """Render one parsed function header as a manual entry body for the site. Unlike `render_entry` (the single indented man-page block pandoc wants, @@ -905,15 +951,22 @@ def render_entry_site(fn: dict[str, list[str]], used_by: list[str], link=None) - def names(raw: list[str]) -> list[str]: return [n for n in re.split(r"[,\s]+", " ".join(raw)) if n] + classification = _classification_tags(fn.get("CLASSIFICATION", [])) refs = [] for label, values in ( ("Dependencies", names(fn.get("DEPENDENCIES", []))), - ("Classification", _classification_tags(fn.get("CLASSIFICATION", []))), + ("Classification", classification), ("Used by", sorted(used_by)), ): if values: rendered = ", ".join(link(v) if link else f"`{v}`" for v in values) refs.append(f"**{label}:** {rendered}") + slug = _manual_section_slug(classification) + if slug: + resolved = _resolve_manual_section(root, slug) + if resolved: + label, href, _relpath = resolved + refs.append(f"**See also:** [{label}]({href})") if refs: parts.append("\n\n".join(refs)) @@ -921,7 +974,7 @@ def render_entry_site(fn: dict[str, list[str]], used_by: list[str], link=None) - def build_entries( - functions: dict[str, dict], link=None, site: bool = False + functions: dict[str, dict], link=None, site: bool = False, root: Path | None = None ) -> dict[str, list[tuple[str, str]]]: """Group rendered entries by category stem, ordered by function name. @@ -929,6 +982,9 @@ def build_entries( authored: a bidirectional link maintained by hand drifts the moment one side is edited. `site` selects `render_entry_site` (headings + tables) over `render_entry` (the man-page indented block `build_concat` needs). + `root` (docs/manual/) resolves any `manual-section()` + CLASSIFICATION tag to that page's own title -- omitted, the default, + an entry with the tag just gets no "See also" line rather than failing. """ used_by: dict[str, list[str]] = {} for name, fn in functions.items(): @@ -940,7 +996,7 @@ def build_entries( out: dict[str, list[tuple[str, str]]] = {} for name in sorted(functions): fn = functions[name] - body = render(fn, used_by.get(name, []), link) + body = render(fn, used_by.get(name, []), link, root=root) out.setdefault(fn["CATEGORY"][0], []).append((name, body)) return out @@ -1041,7 +1097,9 @@ def build_site(root: Path, out: Path) -> list[dict]: out.mkdir(parents=True) functions = mt.parse_functions(FUNCTIONS) - entries = build_entries(functions, link=lambda n: _entry_link(n, functions), site=True) + entries = build_entries( + functions, link=lambda n: _entry_link(n, functions), site=True, root=root + ) sidebar: list[dict] = [{"label": "Home", "link": "/"}] standard_groups: dict = {} diff --git a/docs/function-classification-schema.md b/docs/function-classification-schema.md index 9631b44..18414b0 100644 --- a/docs/function-classification-schema.md +++ b/docs/function-classification-schema.md @@ -5,6 +5,14 @@ doc-header label. It's referenced from code comments and commit messages — link here, not to anything under `AGENTS/` (that tree is git-ignored local agent state, not part of the repo). +`CLASSIFICATION` isn't limited to command-safety hazards, even though most +of the closed set below is exactly that — it's the general-purpose place to +tag what a function touches or how it behaves, whenever that's worth +surfacing without reading the function's own body. `manual-section()` +is the one tag in the set that isn't a hazard at all: it marks a function +that has its own dedicated manual section beyond this header (see +[Dedicated manual sections for complex subsystems](../CONTRIBUTING.md#dedicated-manual-sections-for-complex-subsystems)). + See [Public function documentation header](../CONTRIBUTING.md#public-function-documentation-header) in `CONTRIBUTING.md` for where `CLASSIFICATION` fits among the other header labels, and [C1 — Command Shadows](manual/08-components-reference/01-c1-command-shadows.md) @@ -72,6 +80,22 @@ it empty as a placeholder. handler, an fzf-driven picker) — the hazard this tag exists for is a script or another function calling it unexpectedly, not a human running it themselves. +- **`manual-section()`** — this function has a dedicated manual + section beyond its own header; `` is that page's filename under + `docs/manual/` with the extension dropped (`16-agent-tooling` for + `docs/manual/16-agent-tooling.md`; a directory-based section like + `08-components-reference` uses its directory name the same way, resolved + against its `index.md`). `docs/build-manual.py` reads the target page's + own `manTitle`/`title` at build time and renders a **See also** line on + the function's generated entry — the tag only needs to keep pointing at + the right *file*; the displayed section number is never duplicated into + the tag, so it can't go stale on its own if the manual gets renumbered. + `docs/verify-manual.py` fails if the slug doesn't resolve to a real page. + Multiple functions may carry the same slug (`agents-init` and + `agents-vault` both point at `16-agent-tooling`, one section covering + both). See [Dedicated manual sections for complex subsystems](../CONTRIBUTING.md#dedicated-manual-sections-for-complex-subsystems) + in `CONTRIBUTING.md` for when a function's behavior has outgrown its + header and belongs in one of these instead. ## Placement diff --git a/docs/verify-manual.py b/docs/verify-manual.py index 4500fb7..9f7d479 100644 --- a/docs/verify-manual.py +++ b/docs/verify-manual.py @@ -1674,6 +1674,117 @@ def test_concat_section_five_stays_verbatim(): assert not offenders, f"backticks inside verbatim entries: {offenders[:3]}" +def test_manual_section_slug_extracts_tag(): + """`manual-section()` is found among other CLASSIFICATION tags, or not at all.""" + import build_manual + + assert build_manual._manual_section_slug(["destructive", "manual-section(foo-bar)"]) == "foo-bar" + assert build_manual._manual_section_slug(["network"]) is None + assert build_manual._manual_section_slug([]) is None + + +def test_resolve_manual_section_reads_target_frontmatter(): + """Resolves both page shapes (top-level file, directory index) and reports None cleanly.""" + import build_manual + + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "solo.md").write_text( + "---\ntitle: Solo\nmanTitle: 9. SOLO\n---\nbody\n" + ) + (root / "grouped").mkdir() + (root / "grouped" / "index.md").write_text( + "---\ntitle: Grouped\nmanTitle: 10. GROUPED\n---\nbody\n" + ) + + label, href, relpath = build_manual._resolve_manual_section(root, "solo") + assert label == "9. SOLO", label + assert href == "/solo/", href + assert relpath == "solo.md", relpath + + label, href, relpath = build_manual._resolve_manual_section(root, "grouped") + assert label == "10. GROUPED", label + assert href == "/grouped/", href + assert relpath == "grouped/index.md", relpath + + assert build_manual._resolve_manual_section(root, "missing") is None + assert build_manual._resolve_manual_section(None, "solo") is None + + +def test_render_entry_see_also_appears_only_when_root_resolves(): + """The man-page See-also line needs both the tag and a root that resolves it.""" + import build_manual + + fn = { + "SYNOPSIS": ["thing"], + "DESCRIPTION": ["Does a thing."], + "CLASSIFICATION": ["manual-section(deep-dive)"], + } + + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "deep-dive.md").write_text( + "---\ntitle: Deep Dive\nmanTitle: 20. DEEP DIVE\n---\nbody\n" + ) + + out = build_manual.render_entry(fn, [], root=root) + assert "**See also:** 20. DEEP DIVE (`docs/manual/deep-dive.md`)" in out, out + + # No root at all -- same as every other existing caller that never + # passes one -- silently omits the line rather than raising. + out_no_root = build_manual.render_entry(fn, []) + assert "See also" not in out_no_root, out_no_root + + # A root that exists but doesn't have the target page: also silent. + with tempfile.TemporaryDirectory() as empty: + out_missing = build_manual.render_entry(fn, [], root=Path(empty)) + assert "See also" not in out_missing, out_missing + + +def test_render_entry_site_see_also_is_a_real_link(): + """The site's See-also line is a markdown link to the resolved page's site path.""" + import build_manual + + fn = { + "SYNOPSIS": ["thing"], + "DESCRIPTION": ["Does a thing."], + "CLASSIFICATION": ["manual-section(deep-dive)"], + } + + with tempfile.TemporaryDirectory() as d: + root = Path(d) + (root / "deep-dive.md").write_text( + "---\ntitle: Deep Dive\nmanTitle: 20. DEEP DIVE\n---\nbody\n" + ) + out = build_manual.render_entry_site(fn, [], root=root) + assert "**See also:** [20. DEEP DIVE](/deep-dive/)" in out, out + + +def test_real_manual_section_tags_resolve(): + """Every manual-section() tag on a real function points at a real page. + + This is the enforcement half of the convention: build-manual.py stays + silent about a dangling slug (it just skips the See-also line), so this + is the only thing that turns a typo'd or stale slug into a failure. + """ + import build_manual + + functions = mt.parse_functions(build_manual.FUNCTIONS) + checked = 0 + for name, fn in functions.items(): + tags = build_manual._classification_tags(fn.get("CLASSIFICATION", [])) + slug = build_manual._manual_section_slug(tags) + if slug is None: + continue + checked += 1 + resolved = build_manual._resolve_manual_section(build_manual.MANUAL, slug) + assert resolved is not None, ( + f"{name}'s manual-section({slug}) tag doesn't resolve to " + f"docs/manual/{slug}.md or docs/manual/{slug}/index.md" + ) + assert checked > 0, "expected at least one real function to carry manual-section(...)" + + TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")] diff --git a/functions/agents-init.fish b/functions/agents-init.fish index 6688ad9..bf0dfcb 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -8,7 +8,7 @@ # _agents_init_sync_instructions, _agents_repo_install_tools, _agents_repo_sync, _agents_init_ensure_gitignore # # CLASSIFICATION -# self-limiting(rm,mkdir,grep), bypasses-shadow(mv) +# self-limiting(rm,mkdir,grep), bypasses-shadow(mv), manual-section(16-agent-tooling) # # SYNOPSIS # agents-init [-a | --agents] [-p | --plugins] [-v | --verbose] diff --git a/functions/agents-vault.fish b/functions/agents-vault.fish index a5183a4..a6b0cc0 100644 --- a/functions/agents-vault.fish +++ b/functions/agents-vault.fish @@ -10,7 +10,7 @@ # _agents_repo_install_tools, git, hostname # # CLASSIFICATION -# self-limiting(rm,mkdir) +# self-limiting(rm,mkdir), manual-section(16-agent-tooling) # # SYNOPSIS # agents-vault [--link] [--push] [--restore] [--status]