feat(docs): add manual-section CLASSIFICATION tag, wire it into the build
CI / github-mirror (pull_request) Skipped
CI / test (pull_request) Successful in 2m26s
CI / docs (pull_request) Successful in 1m17s

A function with a dedicated manual section (docs/manual/16-agent-tooling.md,
so far) now carries manual-section(<slug>) 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(<slug>) 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.
This commit is contained in:
2026-09-24 00:50:36 -04:00
parent 1d714a84d0
commit 911c6e95ed
6 changed files with 229 additions and 23 deletions
+26 -13
View File
@@ -391,7 +391,7 @@ all optional except where noted:
| `CATEGORY` | **Required to appear in the manual at all** — see below. | | `CATEGORY` | **Required to appear in the manual at all** — see below. |
| `COMPONENT` | Only for functions gated by the [opinionated-component system](#opinionated-components). | | `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. | | `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. | | `SYNOPSIS` | One-line usage form. |
| `DESCRIPTION` | Prose description; can span multiple paragraphs. | | `DESCRIPTION` | Prose description; can span multiple paragraphs. |
| `ARGUMENTS` | Flags/positional args, one per line. | | `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 `docs/manual/05-functions/NN-your-category.md` stub (with frontmatter
matching its siblings) rather than force-fitting it into an existing one. matching its siblings) rather than force-fitting it into an existing one.
**`CLASSIFICATION` flags hazards and shadow interactions, optional and **`CLASSIFICATION` is the general-purpose tag field, optional and omitted
omitted when nothing applies:** whether the function calls a 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) [C1-shadowed command](docs/manual/08-components-reference/01-c1-command-shadows.md)
bare wanting the override (`uses-shadow(ls)`) or bypasses it deliberately bare wanting the override (`uses-shadow(ls)`) or bypasses it deliberately
via `command`/`builtin` (`bypasses-shadow(cat)`), and general hazards — via `command`/`builtin` (`bypasses-shadow(cat)`), and general hazards —
`destructive`, `network`, `blocking-prompt`. Full tag definitions and `destructive`, `network`, `blocking-prompt` — but not exclusively: a
placement rule: [`docs/function-classification-schema.md`](docs/function-classification-schema.md). function with its own dedicated manual section (below) carries
`manual-section(<slug>)` 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 ### 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 stay short and point there for the full picture, the same way this
document points at other reference files rather than repeating them. document points at other reference files rather than repeating them.
A function with a dedicated section carries `manual-section(<slug>)` 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 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 truth" above. **Whenever you change what one of these functions does,
enforce for you: nothing checks that a dedicated section still describes update its dedicated section in the same commit or pull request** — not
the function's *current* behavior. **Whenever you change what one of as a follow-up.
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.
### Private/internal helper functions ### Private/internal helper functions
+66 -8
View File
@@ -202,7 +202,7 @@ def build_concat(root: Path) -> str:
Only bodies are passed: the pandoc metadata block above is not prose Only bodies are passed: the pandoc metadata block above is not prose
and must survive byte-for-byte. 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] = [] chunks: list[str] = []
pandoc_path = root / "_pandoc.yml" pandoc_path = root / "_pandoc.yml"
if pandoc_path.exists(): if pandoc_path.exists():
@@ -726,7 +726,44 @@ def _classification_tags(raw: list[str]) -> list[str]:
return [t for t in tags if t] 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(<slug>)` 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(<slug>) tag to (display label, site link, doc-relative path).
Looks for <slug>.md (a top-level single-file section) or <slug>/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
<root> 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. """Render one parsed function header as a manual entry body.
Emits the same man-page shape Section 5 was authored in — one 4-space 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]: def names(raw: list[str]) -> list[str]:
return [n for n in re.split(r"[,\s]+", " ".join(raw)) if n] return [n for n in re.split(r"[,\s]+", " ".join(raw)) if n]
classification = _classification_tags(fn.get("CLASSIFICATION", []))
refs = [] refs = []
for label, values in ( for label, values in (
("Dependencies", names(fn.get("DEPENDENCIES", []))), ("Dependencies", names(fn.get("DEPENDENCIES", []))),
("Classification", _classification_tags(fn.get("CLASSIFICATION", []))), ("Classification", classification),
("Used by", sorted(used_by)), ("Used by", sorted(used_by)),
): ):
if values: if values:
rendered = ", ".join(link(v) if link else f"`{v}`" for v in values) rendered = ", ".join(link(v) if link else f"`{v}`" for v in values)
refs.append(f"**{label}:** {rendered}") 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: if refs:
block += "\n\n" + "\n\n".join(refs) block += "\n\n" + "\n\n".join(refs)
return block 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. """Render one parsed function header as a manual entry body for the site.
Unlike `render_entry` (the single indented man-page block pandoc wants, 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]: def names(raw: list[str]) -> list[str]:
return [n for n in re.split(r"[,\s]+", " ".join(raw)) if n] return [n for n in re.split(r"[,\s]+", " ".join(raw)) if n]
classification = _classification_tags(fn.get("CLASSIFICATION", []))
refs = [] refs = []
for label, values in ( for label, values in (
("Dependencies", names(fn.get("DEPENDENCIES", []))), ("Dependencies", names(fn.get("DEPENDENCIES", []))),
("Classification", _classification_tags(fn.get("CLASSIFICATION", []))), ("Classification", classification),
("Used by", sorted(used_by)), ("Used by", sorted(used_by)),
): ):
if values: if values:
rendered = ", ".join(link(v) if link else f"`{v}`" for v in values) rendered = ", ".join(link(v) if link else f"`{v}`" for v in values)
refs.append(f"**{label}:** {rendered}") 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: if refs:
parts.append("\n\n".join(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( 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]]]: ) -> dict[str, list[tuple[str, str]]]:
"""Group rendered entries by category stem, ordered by function name. """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 authored: a bidirectional link maintained by hand drifts the moment one
side is edited. `site` selects `render_entry_site` (headings + tables) side is edited. `site` selects `render_entry_site` (headings + tables)
over `render_entry` (the man-page indented block `build_concat` needs). over `render_entry` (the man-page indented block `build_concat` needs).
`root` (docs/manual/) resolves any `manual-section(<slug>)`
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]] = {} used_by: dict[str, list[str]] = {}
for name, fn in functions.items(): for name, fn in functions.items():
@@ -940,7 +996,7 @@ def build_entries(
out: dict[str, list[tuple[str, str]]] = {} out: dict[str, list[tuple[str, str]]] = {}
for name in sorted(functions): for name in sorted(functions):
fn = functions[name] 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)) out.setdefault(fn["CATEGORY"][0], []).append((name, body))
return out return out
@@ -1041,7 +1097,9 @@ def build_site(root: Path, out: Path) -> list[dict]:
out.mkdir(parents=True) out.mkdir(parents=True)
functions = mt.parse_functions(FUNCTIONS) 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": "/"}] sidebar: list[dict] = [{"label": "Home", "link": "/"}]
standard_groups: dict = {} standard_groups: dict = {}
+24
View File
@@ -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 link here, not to anything under `AGENTS/` (that tree is git-ignored local
agent state, not part of the repo). 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(<slug>)`
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) See [Public function documentation header](../CONTRIBUTING.md#public-function-documentation-header)
in `CONTRIBUTING.md` for where `CLASSIFICATION` fits among the other 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) 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 handler, an fzf-driven picker) — the hazard this tag exists for is a
script or another function calling it unexpectedly, not a human running script or another function calling it unexpectedly, not a human running
it themselves. it themselves.
- **`manual-section(<slug>)`** — this function has a dedicated manual
section beyond its own header; `<slug>` 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 ## Placement
+111
View File
@@ -1674,6 +1674,117 @@ def test_concat_section_five_stays_verbatim():
assert not offenders, f"backticks inside verbatim entries: {offenders[:3]}" assert not offenders, f"backticks inside verbatim entries: {offenders[:3]}"
def test_manual_section_slug_extracts_tag():
"""`manual-section(<slug>)` 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(<slug>) 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_")] TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
+1 -1
View File
@@ -8,7 +8,7 @@
# _agents_init_sync_instructions, _agents_repo_install_tools, _agents_repo_sync, _agents_init_ensure_gitignore # _agents_init_sync_instructions, _agents_repo_install_tools, _agents_repo_sync, _agents_init_ensure_gitignore
# #
# CLASSIFICATION # CLASSIFICATION
# self-limiting(rm,mkdir,grep), bypasses-shadow(mv) # self-limiting(rm,mkdir,grep), bypasses-shadow(mv), manual-section(16-agent-tooling)
# #
# SYNOPSIS # SYNOPSIS
# agents-init [-a | --agents] [-p | --plugins] [-v | --verbose] # agents-init [-a | --agents] [-p | --plugins] [-v | --verbose]
+1 -1
View File
@@ -10,7 +10,7 @@
# _agents_repo_install_tools, git, hostname # _agents_repo_install_tools, git, hostname
# #
# CLASSIFICATION # CLASSIFICATION
# self-limiting(rm,mkdir) # self-limiting(rm,mkdir), manual-section(16-agent-tooling)
# #
# SYNOPSIS # SYNOPSIS
# agents-vault [--link] [--push] [--restore] [--status] # agents-vault [--link] [--push] [--restore] [--status]