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(<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:
+26
-13
@@ -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(<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
|
||||
|
||||
@@ -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(<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
|
||||
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
|
||||
|
||||
|
||||
+66
-8
@@ -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(<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.
|
||||
|
||||
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(<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]] = {}
|
||||
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 = {}
|
||||
|
||||
@@ -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(<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)
|
||||
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(<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
|
||||
|
||||
|
||||
@@ -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(<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_")]
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user