feat: docs reference generates a human command reference under docs/reference/ from the command records, one page per group plus exit codes and gates; toc never lands inside another generated region (#188)
CI / verify (push) Successful in 6m11s
CI / pwsh (push) Successful in 2m0s
Release / release (push) Successful in 35s

Files changed:
- CHANGES.md
- README.md
- VERSION
- docs/reference/README.md
- docs/reference/commands/README.md
- docs/reference/commands/catalog-and-log.md
- docs/reference/commands/content-migrations.md
- docs/reference/commands/distribution-and-versioning.md
- docs/reference/commands/finding-and-checking.md
- docs/reference/commands/git.md
- docs/reference/commands/guideline-export.md
- docs/reference/commands/instance-health.md
- docs/reference/commands/links-and-citations.md
- docs/reference/commands/pages.md
- docs/reference/commands/provenance.md
- docs/reference/commands/raw-material-and-uploads.md
- docs/reference/commands/telemetry.md
- docs/reference/commands/types-instructions-and-docs.md
- docs/reference/commands/workshop-runs-and-session-budget.md
- docs/reference/exit-codes.md
- instructions/dev/doc-pull-through.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/reference_doc.py
- tools/chemenu/tests/test_reference_doc.py
- tools/chemenu/toc.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Opus 5.5 committed 2026-10-06 21:29:01 +02:00
1 parent 01c888d3cd
commit 53383e7e9b
29 files changed
+5079 -29

No files matched your search

+24 -2
View File
@@ -193,7 +193,7 @@ app.command("sync")(git_publish.sync_command)
app.command("doctor")(doctor.doctor_command)
def _render_options_text(command, ctx) -> str:
def render_options_text(command, ctx) -> str:
"""Click's own Arguments/Options sections, plain-formatted, for splicing
into a `cli_contract` record's OPTIONS section. A fixed width (not the
real terminal's) is what keeps `wikitool <cmd> -h` byte-identical with
@@ -210,6 +210,28 @@ def _render_options_text(command, ctx) -> str:
return text
def options_texts(paths) -> dict[str, str]:
"""`render_options_text` for each command path in `paths`, outside a
help request - for the generated reference under `docs/reference/`.
Each context is built down the real chain from the root with
`resilient_parsing`, as Click does for `--help`, so the result carries the
root's `-h` alias and matches the OPTIONS section of `wikitool <path> -h`
byte for byte."""
import typer.main
root = typer.main.get_command(app)
texts: dict[str, str] = {}
for path in paths:
ctx = root.make_context("wikitool", [], resilient_parsing=True)
command = root
for name in path.split():
command = command.commands[name]
ctx = command.make_context(name, [], parent=ctx, resilient_parsing=True)
texts[path] = render_options_text(command, ctx)
return texts
def _render_root_help() -> str:
"""`wikitool -h`/`--help`/no-args: usage, the full index, and where the
per-command record lives - never Click's default subcommand listing,
@@ -258,7 +280,7 @@ try:
_original_format_help(self, ctx, formatter)
return
formatter.write(
cli_contract.render_text(record, options_text=_render_options_text(self, ctx))
cli_contract.render_text(record, options_text=render_options_text(self, ctx))
)
_typer_click_core.Command.format_help = _contract_format_help
+19 -12
View File
@@ -1,7 +1,9 @@
"""One data record per `wikitool` command - the single source three views are
"""One data record per `wikitool` command - the single source four views are
rendered from: `wikitool <cmd> -h` (the full record, plain text), the index
line (`wikitool -h` and the top of `tools/CONTRACT.md`), and the generated
`<!-- wikitool:commands -->` region of `tools/CONTRACT.md` itself.
line (`wikitool -h` and the top of `tools/CONTRACT.md`), the generated
`<!-- wikitool:commands -->` region of `tools/CONTRACT.md` itself, and the
human command reference under `docs/reference/` (`render_reference_*`), which
leaves out what is addressed to an agent - the ON FAILURE reactions and NEVER.
A record is attached to its command function, in that function's own module,
via the `@record(...)` decorator - never centralised, so the contract sits
@@ -274,7 +276,7 @@ GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Types, instructions and docs", (
"types list", "types describe",
"instructions sync", "instructions verify", "instructions list",
"docs verify", "docs toc", "docs prerequisites", "docs contract",
"docs verify", "docs toc", "docs prerequisites", "docs contract", "docs reference",
)),
("Telemetry", (
"eval sessions", "eval score",
@@ -359,17 +361,22 @@ def render_properties_lines(props: Properties) -> list[str]:
return lines
def render_exit_status_lines(rec: CommandRecord) -> list[str]:
"""`0 success` first, then one line per `Failure` in code order (stable
within a code). A command with gates but no explicit exit-42 cause gets
one generic 42 line naming them."""
lines = ["0 success"]
def exit_status_entries(rec: CommandRecord) -> list[tuple[int, str]]:
"""`(0, "success")` first, then one `(code, cause)` per `Failure` in code
order (stable within a code). A command with gates but no explicit exit-42
cause gets one generic 42 entry naming them."""
entries = [(0, "success")]
for failure in sorted(rec.failures, key=lambda f: f.code):
lines.append(f"{str(failure.code).ljust(4)} {_labelled(failure, failure.cause)}")
entries.append((failure.code, _labelled(failure, failure.cause)))
if rec.properties.gates and not any(f.code == 42 for f in rec.failures):
gate_list = ", ".join(rec.properties.gates)
lines.append(f"42 needs clearance - {gate_list} (see AGENTS.md § Gates)")
return lines
entries.append((42, f"needs clearance - {gate_list} (see AGENTS.md § Gates)"))
return entries
def render_exit_status_lines(rec: CommandRecord) -> list[str]:
"""EXIT STATUS as `<code> <cause>` lines - see `exit_status_entries`."""
return [f"{str(code).ljust(4)} {cause}" for code, cause in exit_status_entries(rec)]
def render_on_failure_lines(rec: CommandRecord) -> list[str]:
+158 -3
View File
@@ -76,7 +76,7 @@ from typing import Optional
import typer
from chemenu import blocks, cli_contract, config, conventions, install_doc, kb_collections, markdown_code, toc, toolpaths, version as version_mod
from chemenu import blocks, cli_contract, config, conventions, install_doc, kb_collections, markdown_code, reference_doc, toc, toolpaths, version as version_mod
from chemenu.commands import dist_cmd
from chemenu.commands._util import fail, rel_path, success
@@ -336,6 +336,47 @@ def check_commands_region() -> list[str]:
return []
def check_reference_regions() -> list[str]:
"""Every generated region of the human command reference under
`docs/reference/` matches what `docs reference` would write, every
`GROUPS` entry has its page, and no page is left over from a group that
is gone.
A tree without `docs/` passes, as for `check_docs_layout`: `docs verify`
says what a present `docs/` must hold, not that one must exist. With
`docs/` present, every page is required - a deleted one is drift too.
"""
if not (config.ROOT / "docs").is_dir():
return []
issues = []
for region in reference_doc.regions():
path = config.ROOT / region.path
if not path.is_file():
issues.append(
f"{region.path} is missing - create it with a title, a one-sentence head and the "
f"marker pair `{blocks.open_marker(region.name)}` / "
f"`{blocks.close_marker(region.name)}`, then run `wikitool docs reference --apply`"
)
continue
existing = blocks.find(path.read_text(encoding="utf-8"), region.name)
if existing is None:
issues.append(
f"{region.path} has no `{blocks.open_marker(region.name)}` region - add the marker "
"pair below the page's head, then run `wikitool docs reference --apply`"
)
elif existing.strip("\n") != region.render().strip("\n"):
issues.append(
f"{region.path}'s `{blocks.open_marker(region.name)}` region is stale - run "
"`wikitool docs reference --apply`"
)
issues += [
f"{rel_path(page)} belongs to no group in cli_contract.GROUPS - `git mv` it to the page "
"of the group it was renamed to, or `git rm` it"
for page in reference_doc.orphan_pages()
]
return issues
# A `--flag` or `-f` token in a SYNOPSIS usage string. Excludes anything that
# is not immediately preceded/followed by a word character or hyphen, so
# `--set field=value` matches only `--set`, never `field` or `value`.
@@ -831,6 +872,10 @@ def command_table_free_readmes() -> list[Path]:
Built at call time rather than at import, so a test can point ROOT_README at
a fixture.
`docs/reference/commands/README.md` is deliberately not one of them: its
overview of every command is a generated region `check_reference_regions`
holds to the records - the checked kind of copy, not the drifting kind.
"""
return [ROOT_README, *(config.ROOT / relative for relative in STAGE_READMES)]
@@ -1208,7 +1253,8 @@ def check_breaking_change_for_boundary() -> list[str]:
),
notes=(
"Checks the docs that mirror the code. The name is about documentation parity, not the "
"`docs/` directory - it reads that only for its layout, and never requires one.",
"`docs/` directory - it reads that only for its layout and its generated reference "
"pages, and never requires one.",
"Commands: every command has a `cli_contract` record and is listed in "
"`cli_contract.GROUPS`, in both directions; every command's non-hidden flags appear in "
"its record's SYNOPSIS and vice versa; `tools/CONTRACT.md`'s generated "
@@ -1242,6 +1288,10 @@ def check_breaking_change_for_boundary() -> list[str]:
"Every file under `docs/` sits in one of its areas - `tutorials/`, `how-to/`, "
"`reference/`, `explanation/` or `dev/` - never directly in `docs/` or in another "
"directory.",
"If `docs/` exists: every generated region of the human command reference under "
"`docs/reference/` matches what `docs reference` would write, every `cli_contract.GROUPS` "
"entry has its page under `docs/reference/commands/`, and no page there belongs to no "
"group.",
"`INSTALL.md` carries one generated `<!-- wikitool:prerequisites -->` region per "
"platform value of `tools/prerequisites.txt` (`prerequisites-<platform>` for a "
"platform-specific one), each current; and the `<!-- setup-question: <key> -->` markers "
@@ -1294,6 +1344,17 @@ def check_breaking_change_for_boundary() -> list[str]:
reaction="Say what was decided instead of pointing at where, or move the pointer "
"behind a `<!-- dist:strip-start/end -->` block",
),
cli_contract.Failure(
cause="A region of the human command reference under `docs/reference/` is stale",
reaction="Run `docs reference --apply`, then re-run",
),
cli_contract.Failure(
cause="A page of the human command reference is missing or lacks its marker pair, or "
"a page under `docs/reference/commands/` belongs to no group",
reaction="Create the page with a title, a one-sentence head and the marker pair the "
"message names (or add the pair), then run `docs reference --apply`; `git mv` or "
"`git rm` a page left over from a renamed or removed group",
),
cli_contract.Failure(
cause="A reference file's table-of-contents region is missing or stale",
reaction="Run `docs toc --apply`, then re-run",
@@ -1330,11 +1391,13 @@ def check_breaking_change_for_boundary() -> list[str]:
"tools/wikitool docs verify",
),
never=(
"Never hand-write a table-of-contents region or the commands region - regenerate it.",
"Never hand-write a table-of-contents region, the commands region or a reference region "
"- regenerate it.",
),
see_also=(
"`wikitool docs toc` - regenerates tables of contents",
"`wikitool docs contract` - regenerates the commands region",
"`wikitool docs reference` - regenerates the human command reference",
"`wikitool docs prerequisites` - regenerates `INSTALL.md`'s prerequisites lists",
"`wikitool instructions verify` - the same kind of check for `instructions/`",
),
@@ -1359,6 +1422,7 @@ def verify():
+ check_changelog_budget()
+ check_no_issue_references()
+ check_docs_layout()
+ check_reference_regions()
+ check_toc_regions()
+ check_reference_targets()
+ install_doc.check_prerequisite_regions()
@@ -1378,6 +1442,7 @@ def verify():
f"{len(IGNORE_CANARIES)} ignore canaries clear, "
f"no issue references in {len(shipped_prose())} shipped document(s) or command help, "
f"every docs/ file in one of its {len(DOCS_AREAS)} areas, "
f"the command reference under {reference_doc.REFERENCE_DIR}/ current, "
f"tables of contents current and every link resolving on "
f"{len(toc.target_files())} reference file(s), "
f"{install_doc.INSTALL_DOC} in step with tools/prerequisites.txt and "
@@ -1569,6 +1634,8 @@ def prerequisites_command(
),
see_also=(
"`tools/README.md` § Adding a command - how a command gets its record",
"`wikitool docs reference` - the same records, rendered for a person under "
"`docs/reference/`",
"`wikitool docs verify` - checks the region is current",
),
))
@@ -1597,3 +1664,91 @@ def contract_command(
CLI_README.write_text(after, encoding="utf-8", newline="\n")
success(f"Regenerated the command region in {rel_path(CLI_README)}.")
@app.command("reference")
@cli_contract.record(cli_contract.CommandRecord(
path="docs reference",
summary="Regenerate the human command reference under `docs/reference/`.",
synopsis=(cli_contract.Variant(usage="docs reference [--apply]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="`--apply` rewrites each page in place, one at a time and idempotently, so a "
"re-run after an interruption converges; the dry-run form is read-only",
budget=cli_contract.Budget.COUNTED,
),
notes=(
"Rewrites the generated region of every page of the human command reference from the "
"`cli_contract` records: one `docs/reference/commands/<group>.md` per `cli_contract.GROUPS` "
"entry (`<!-- wikitool:reference-commands -->`), the overview in "
"`docs/reference/commands/README.md` (`<!-- wikitool:reference-commands-index -->`), and "
"the gates and exit-42 causes in `docs/reference/exit-codes.md` "
"(`<!-- wikitool:reference-exit-codes -->`).",
"Renders each command's summary, synopsis, options, examples, notes, properties in words, "
"exit codes with their causes and see-also - but not the ON FAILURE reactions or NEVER, "
"which address an agent; `tools/CONTRACT.md` keeps those.",
"Refreshes the table of contents of each page it writes in the same step.",
"Never creates a page or places a region: a page's title and one-sentence head are "
"written by hand. A group's page is named after the group - lower case, every run of "
"other characters one hyphen.",
"Dry-run by default (lists the pages that would change); `--apply` writes.",
"`docs verify` checks the result stays current.",
),
failures=(cli_contract.Failure(
cause="A reference page is missing, or lacks its region's marker pair",
reaction="Not transient - create the page the message names with a title, a one-sentence "
"head and the marker pair, or add the marker pair below its head, then retry",
),),
examples=(
"tools/wikitool docs reference",
"tools/wikitool docs reference --apply",
),
never=(
"Never hand-edit a reference region - change the record in code and re-run this.",
),
see_also=(
"`wikitool docs contract` - the same records, rendered for an agent into `tools/CONTRACT.md`",
"`wikitool docs verify` - checks every reference region is current",
"`tools/README.md` § Adding a command - how a command gets its record",
),
))
def reference_command(
apply: bool = typer.Option(False, "--apply", help="Write changes; default is dry-run (preview only)"),
):
"""Regenerate the human command reference under `docs/reference/`."""
missing = []
changed = []
for region in reference_doc.regions():
path = config.ROOT / region.path
if not path.is_file():
missing.append(f"{region.path} (create it with `{blocks.open_marker(region.name)}`)")
continue
before = path.read_text(encoding="utf-8")
after = reference_doc.refresh(before, region)
if after is None:
missing.append(f"{region.path} (add `{blocks.open_marker(region.name)}`)")
elif after != before:
changed.append((path, after))
if missing:
fail(
"These reference pages cannot be filled:\n"
+ "\n".join(f"- {entry}" for entry in missing)
+ "\nGive each a title, a one-sentence head and the marker pair "
"(opening and closing), then re-run."
)
if not changed:
success("Every reference page is already current.")
return
for path, after in changed:
typer.echo(rel_path(path))
if apply:
path.write_text(after, encoding="utf-8", newline="\n")
if apply:
success(f"Regenerated {len(changed)} reference page(s).")
else:
typer.echo(f"\n{len(changed)} page(s) would change. Re-run with --apply to write.")
+253
View File
@@ -0,0 +1,253 @@
"""The human command reference under `docs/reference/`, generated from the records.
`cli_contract` is the one source of what a command does; `tools/CONTRACT.md`
renders it for an agent, and this module renders it for a person. The two
differ on purpose: a record's ON FAILURE reactions and its NEVER lines tell an
agent what to do ("show the output verbatim and stop"), not a person what
happens, so they stay out here - only each failure's cause is kept, under the
exit code it produces. Properties are spelled out in words rather than as the
index's `budget:counted` tokens.
Every file here is half human, half generated, like `INSTALL.md`'s
prerequisites lists: a title and a one-sentence head someone wrote, then one
`<!-- wikitool:reference-... -->` region `docs reference --apply` rewrites. The
tool never creates a page or places a region - a missing one is reported - so
a group added to `cli_contract.GROUPS` gets its page, and its head, from a
person.
`regions()` is the one list both `docs reference` and `docs verify` walk; a
further generated reference page is one more entry in it.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Callable, NamedTuple, Optional
from chemenu import blocks, cli_contract, config, toc
REFERENCE_DIR = "docs/reference"
COMMANDS_DIR = f"{REFERENCE_DIR}/commands"
COMMANDS_INDEX = f"{COMMANDS_DIR}/README.md"
EXIT_CODES = f"{REFERENCE_DIR}/exit-codes.md"
COMMANDS_REGION = "reference-commands"
INDEX_REGION = "reference-commands-index"
EXIT_CODES_REGION = "reference-exit-codes"
_SLUG_RE = re.compile(r"[^a-z0-9]+")
class Region(NamedTuple):
"""One generated region: the file it lives in (root-relative), the region's
name, and what renders its content."""
path: str
name: str
render: Callable[[], str]
def group_slug(title: str) -> str:
"""The page name of a `GROUPS` entry: lower case, every run of anything
else than a letter or digit one hyphen (`Types, instructions and docs` ->
`types-instructions-and-docs`)."""
return _SLUG_RE.sub("-", title.lower()).strip("-")
def group_page(title: str) -> str:
return f"{COMMANDS_DIR}/{group_slug(title)}.md"
def command_anchor(path: str) -> str:
"""The anchor of a command's `## `<path>`` heading on its group page."""
return toc.anchor(f"`{path}`")
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
_BUDGET_WORDS = {
cli_contract.Budget.COUNTED: "yes",
cli_contract.Budget.EXEMPT: "no",
cli_contract.Budget.EXEMPT_WITHOUT_ARGS: "only when called with arguments",
}
def _yes_no(value: bool) -> str:
return "yes" if value else "no"
def properties_lines(props: cli_contract.Properties) -> list[str]:
"""A record's properties in words, one list item each."""
lines = [
f"- Changes files: {_yes_no(props.effect == cli_contract.Effect.WRITE)}",
f"- Idempotent: {_yes_no(props.idempotent == cli_contract.Idempotent.YES)}",
f"- Atomic: {props.atomic}",
f"- Counts toward the session call budget: {_BUDGET_WORDS[props.budget]}",
f"- Can reach the network: {_yes_no(props.network == cli_contract.Network.YES)}",
]
if props.gates:
lines.append(
f"- Gates: {', '.join(props.gates)} - see [exit codes and gates](../exit-codes.md)"
)
return lines
def render_command(rec: cli_contract.CommandRecord, options_text: str = "") -> str:
"""One command's section on its group page."""
lines = [f"## `{rec.path}`", "", rec.summary, ""]
lines += ["**Synopsis**", ""]
for variant in rec.synopsis:
note = f" - {variant.notes}" if variant.notes else ""
lines.append(f"- `wikitool {variant.usage}`{note}")
lines.append("")
if options_text.strip():
lines += ["**Options**", "", "```text", options_text.rstrip(), "```", ""]
if rec.examples:
lines += ["**Examples**", "", "```bash", *rec.examples, "```", ""]
lines += ["**Behaviour**", ""]
lines += [f"- {note}" for note in rec.notes]
lines.append("")
lines += ["**Properties**", "", *properties_lines(rec.properties), ""]
lines += ["**Exit codes**", ""]
lines += [f"- `{code}` - {cause}" for code, cause in cli_contract.exit_status_entries(rec)]
lines.append("")
if rec.see_also:
lines += ["**See also**", ""]
lines += [f"- {item}" for item in rec.see_also]
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def render_group(
paths: tuple[str, ...],
records: dict[str, cli_contract.CommandRecord],
options: dict[str, str],
) -> str:
"""The region body of one group page: its commands, in `GROUPS` order."""
return "\n".join(
render_command(records[path], options.get(path, "")) for path in paths if path in records
).rstrip() + "\n"
def render_index(
records: dict[str, cli_contract.CommandRecord],
groups: cli_contract.GroupsType,
) -> str:
"""The region body of the commands README: every group, every command,
its one-sentence summary, linked to its section."""
lines: list[str] = []
for title, paths in groups:
page = f"{group_slug(title)}.md"
lines += [f"## {title}", "", f"Full records: [{page}]({page})", ""]
for path in paths:
if path in records:
lines.append(
f"- [`{path}`]({page}#{command_anchor(path)}) - {records[path].summary}"
)
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def _command_link(path: str, groups: cli_contract.GroupsType) -> str:
title = cli_contract.group_of(path, groups)
if title is None:
return f"`{path}`"
return f"[`{path}`](commands/{group_slug(title)}.md#{command_anchor(path)})"
def render_exit_codes(
records: dict[str, cli_contract.CommandRecord],
groups: cli_contract.GroupsType,
) -> str:
"""The region body of `exit-codes.md`: which command declares which gate,
and every cause of an exit 42. What an exit code means is linked, not
repeated - `AGENTS.md` is where that rule lives."""
ordered = [path for path in cli_contract.grouped_paths(groups) if path in records]
lines = [
"What each exit code means, and how a caller reacts to it, is "
"[AGENTS.md § Tool error contract](../../AGENTS.md#tool-error-contract). What a gate is "
"and why it is enforced in code is [AGENTS.md § Gates](../../AGENTS.md#gates). This page "
"lists which commands declare a gate, and every cause for which a command exits 42.",
"",
"## Gates by command",
"",
]
gates: dict[str, list[str]] = {}
for path in ordered:
for gate in records[path].properties.gates:
gates.setdefault(gate, []).append(path)
for gate in sorted(gates):
lines += [f"### {gate}", ""]
lines += [f"- {_command_link(path, groups)}" for path in gates[gate]]
lines.append("")
lines += ["## Exit 42 causes", ""]
for path in ordered:
causes = [cause for code, cause in cli_contract.exit_status_entries(records[path]) if code == 42]
lines += [f"- {_command_link(path, groups)}: {cause}" for cause in causes]
return "\n".join(lines).rstrip() + "\n"
# ---------------------------------------------------------------------------
# The generated regions, and writing one
# ---------------------------------------------------------------------------
def regions(
records: Optional[dict[str, cli_contract.CommandRecord]] = None,
groups: Optional[cli_contract.GroupsType] = None,
options: Optional[dict[str, str]] = None,
) -> list[Region]:
"""Every generated region under `docs/reference/`, in a stable order.
`options` defaults to the real CLI's rendered OPTIONS blocks; a test
passing its own records passes its own options too (or `{}`)."""
records = records if records is not None else cli_contract.all_records()
groups = groups if groups is not None else cli_contract.GROUPS
if options is None:
from chemenu import cli # deferred: `cli` imports every command module, this one's caller included
options = cli.options_texts([p for p in cli_contract.grouped_paths(groups) if p in records])
found = [
Region(group_page(title), COMMANDS_REGION,
lambda paths=paths: render_group(paths, records, options))
for title, paths in groups
]
found.append(Region(COMMANDS_INDEX, INDEX_REGION, lambda: render_index(records, groups)))
found.append(Region(EXIT_CODES, EXIT_CODES_REGION, lambda: render_exit_codes(records, groups)))
return found
def refresh(text: str, region: Region) -> Optional[str]:
"""`text` with `region` regenerated and its table of contents brought in
line, or None when the file carries no such region to fill.
The TOC is refreshed in the same step because the generated `##` headings
are what it lists - without it, every regeneration that added a command
would leave `docs verify` reporting a stale TOC until `docs toc --apply`
ran as well."""
if blocks.find(text, region.name) is None:
return None
content = region.render().strip("\n")
wrapped = blocks.open_marker(region.name) + "\n" + content + "\n" + blocks.close_marker(region.name)
return toc.upsert(blocks.replace(text, region.name, wrapped))
def orphan_pages(groups: Optional[cli_contract.GroupsType] = None) -> list[Path]:
"""Pages under `docs/reference/commands/` no group in `groups` names."""
groups = groups if groups is not None else cli_contract.GROUPS
directory = config.ROOT / COMMANDS_DIR
if not directory.is_dir():
return []
expected = {Path(group_page(title)).name for title, _ in groups} | {Path(COMMANDS_INDEX).name}
return sorted(p for p in directory.glob("*.md") if p.name not in expected)
+288
View File
@@ -0,0 +1,288 @@
import re
import pytest
from typer.testing import CliRunner
from chemenu import blocks, cli, cli_contract, config, reference_doc, toc
from chemenu.cli import app
from chemenu.commands import dist_cmd, docs_verify
runner = CliRunner()
# --- the real tree --------------------------------------------------------
def _group_pages() -> dict[str, str]:
return {
title: (config.ROOT / reference_doc.group_page(title)).read_text(encoding="utf-8")
for title, _ in cli_contract.GROUPS
}
def test_the_real_reference_is_current():
"""What a session that changed a record and forgot `docs reference --apply`
is caught by."""
assert docs_verify.check_reference_regions() == []
def test_every_group_has_one_page_and_every_command_one_section():
pages = _group_pages()
for path in cli_contract.all_records():
heading = f"## `{path}`\n"
hits = [title for title, text in pages.items() if text.count(heading) == 1]
assert len(hits) == 1, path
assert sum(text.count(heading) for text in pages.values()) == 1, path
def test_group_slugs_are_unique():
slugs = [reference_doc.group_slug(title) for title, _ in cli_contract.GROUPS]
assert len(slugs) == len(set(slugs))
def test_no_page_carries_an_agent_addressed_field():
"""ON FAILURE reactions and NEVER tell an agent what to do; the human
reference keeps only the cause of each failure."""
text = "\n".join(_group_pages().values())
text += (config.ROOT / reference_doc.EXIT_CODES).read_text(encoding="utf-8")
for rec in cli_contract.all_records().values():
for failure in rec.failures:
if failure.reaction:
assert failure.reaction not in text, (rec.path, failure.reaction)
for line in rec.never:
assert line not in text, (rec.path, line)
def test_options_match_the_options_section_of_h():
texts = cli.options_texts(sorted(cli_contract.all_records()))
for path, options in texts.items():
result = runner.invoke(app, [*path.split(" "), "-h"])
assert result.exit_code == 0, (path, result.output)
if options.strip():
assert f"OPTIONS\n{options.rstrip()}\n\n" in result.output, path
else:
assert "\nOPTIONS\n" not in result.output, path
def _section(markdown: str, heading: str) -> str:
start = markdown.index(f"## {heading}\n")
end = markdown.find("\n## ", start + 1)
return markdown[start:end if end != -1 else None]
def test_exit_codes_names_every_gate_and_links_rather_than_repeats_agents_md():
page = (config.ROOT / reference_doc.EXIT_CODES).read_text(encoding="utf-8")
for rec in cli_contract.all_records().values():
for gate in rec.properties.gates:
assert f"### {gate}\n" in page, gate
assert "(../../AGENTS.md#tool-error-contract)" in page
assert "(../../AGENTS.md#gates)" in page
agents = (config.ROOT / "AGENTS.md").read_text(encoding="utf-8")
for heading in ("Tool error contract", "Gates"):
for sentence in re.split(r"(?<=[.:])\s+", _section(agents, heading)):
sentence = sentence.strip()
if len(sentence) >= 30:
assert sentence not in page, sentence
def test_the_reference_pages_survive_dist_export_unchanged():
"""`dist export` strips `dist:strip` spans from every shipped text file; a
record quoting the marker pair verbatim would be cut in the exported page
and the fresh instance's `docs verify` would call it stale."""
plan = dist_cmd.build_plan()
for region in reference_doc.regions():
assert plan[region.path].content == (config.ROOT / region.path).read_text(encoding="utf-8")
# --- a fixture tree -------------------------------------------------------
GROUPS = (("Fixture things", ("frobnicate",)), ("Other", ("gate keep",)))
def _record(path: str, gates: tuple[str, ...] = (), summary: str = "Does a thing.") -> cli_contract.CommandRecord:
failures = [cli_contract.Failure(cause="It broke", reaction="Show the user and stop")]
if gates:
failures.append(cli_contract.Failure(cause="Needs a person", reaction="Wait", code=42))
return cli_contract.CommandRecord(
path=path,
summary=summary,
synopsis=(cli_contract.Variant(usage=f"{path} [--flag]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="Yes",
budget=cli_contract.Budget.EXEMPT_WITHOUT_ARGS,
gates=gates,
),
notes=("Does a thing, mechanically.",),
failures=tuple(failures),
examples=("tools/wikitool frobnicate --flag",),
never=("Never frobnicate twice.",),
see_also=("`wikitool other` - a neighbour",),
)
HEADS = {
"docs/reference/commands/fixture-things.md": "# Fixture things\n\nThings, by hand.\n",
"docs/reference/commands/other.md": "# Other\n\nThe other one.\n",
"docs/reference/commands/README.md": "# Command reference\n\nAll of it.\n",
"docs/reference/exit-codes.md": "# Exit codes and gates\n\nWhere it stops.\n",
}
REGION_OF = {
"docs/reference/commands/fixture-things.md": reference_doc.COMMANDS_REGION,
"docs/reference/commands/other.md": reference_doc.COMMANDS_REGION,
"docs/reference/commands/README.md": reference_doc.INDEX_REGION,
"docs/reference/exit-codes.md": reference_doc.EXIT_CODES_REGION,
}
@pytest.fixture
def tree(tmp_path, monkeypatch):
monkeypatch.setattr(config, "ROOT", tmp_path)
records = {"frobnicate": _record("frobnicate"), "gate keep": _record("gate keep", ("keep",))}
monkeypatch.setattr(cli_contract, "all_records", lambda: dict(records))
monkeypatch.setattr(cli_contract, "GROUPS", GROUPS)
monkeypatch.setattr(cli, "options_texts", lambda paths: {p: " --flag A flag." for p in paths})
for relative, head in HEADS.items():
path = tmp_path / relative
path.parent.mkdir(parents=True, exist_ok=True)
name = REGION_OF[relative]
path.write_text(
f"{head}\n{blocks.open_marker(name)}\n{blocks.close_marker(name)}\n", encoding="utf-8"
)
return tmp_path, records
def _snapshot(root) -> dict[str, str]:
return {
p.relative_to(root).as_posix(): p.read_text(encoding="utf-8")
for p in sorted(root.rglob("*.md"))
}
def test_a_fresh_tree_is_reported_stale_until_applied(tree):
root, _ = tree
issues = docs_verify.check_reference_regions()
assert len(issues) == len(HEADS)
assert all("stale" in issue and "docs reference --apply" in issue for issue in issues)
dry = runner.invoke(app, ["docs", "reference"])
assert dry.exit_code == 0, dry.output
assert "would change" in dry.output
assert docs_verify.check_reference_regions() != []
result = runner.invoke(app, ["docs", "reference", "--apply"])
assert result.exit_code == 0, result.output
assert docs_verify.check_reference_regions() == []
def test_apply_twice_changes_nothing_the_second_time(tree):
root, _ = tree
runner.invoke(app, ["docs", "reference", "--apply"])
first = _snapshot(root)
second_run = runner.invoke(app, ["docs", "reference", "--apply"])
assert second_run.exit_code == 0
assert "already current" in second_run.output
assert _snapshot(root) == first
def test_the_head_survives_apply_byte_for_byte(tree):
root, _ = tree
runner.invoke(app, ["docs", "reference", "--apply"])
for relative, head in HEADS.items():
text = (root / relative).read_text(encoding="utf-8")
assert text.startswith(head), relative
def test_a_changed_record_makes_verify_name_the_page_and_the_command(tree, monkeypatch):
root, records = tree
runner.invoke(app, ["docs", "reference", "--apply"])
records["frobnicate"] = _record("frobnicate", summary="Does a different thing.")
monkeypatch.setattr(cli_contract, "all_records", lambda: dict(records))
issues = docs_verify.check_reference_regions()
assert any(
"docs/reference/commands/fixture-things.md" in issue and "docs reference --apply" in issue
for issue in issues
)
def test_a_generated_page_leaves_out_reactions_and_never(tree):
root, _ = tree
runner.invoke(app, ["docs", "reference", "--apply"])
text = (root / "docs/reference/commands/fixture-things.md").read_text(encoding="utf-8")
assert "It broke" in text
assert "Show the user and stop" not in text
assert "Never frobnicate twice." not in text
assert "- Counts toward the session call budget: only when called with arguments" in text
assert "```text\n --flag A flag.\n```" in text
def test_a_long_page_gets_its_toc_outside_the_generated_region(tree, monkeypatch):
"""The first `##` of a group page is generated; a TOC inserted before it
would sit inside the region and be wiped by the next regeneration."""
root, records = tree
many = tuple(f"cmd{i}" for i in range(12))
for path in many:
records[path] = _record(path)
monkeypatch.setattr(cli_contract, "all_records", lambda: dict(records))
monkeypatch.setattr(cli_contract, "GROUPS", (("Fixture things", many), GROUPS[1]))
assert runner.invoke(app, ["docs", "reference", "--apply"]).exit_code == 0
page = root / "docs/reference/commands/fixture-things.md"
text = page.read_text(encoding="utf-8")
assert toc.needs_toc(text)
assert text.index(blocks.close_marker(toc.REGION_NAME)) < text.index(
blocks.open_marker(reference_doc.COMMANDS_REGION)
)
assert toc.upsert(text) == text
assert docs_verify.check_reference_regions() == []
def test_a_group_without_a_page_fails_and_nothing_is_created(tree, monkeypatch):
root, _ = tree
monkeypatch.setattr(cli_contract, "GROUPS", GROUPS + (("New group", ()),))
issues = docs_verify.check_reference_regions()
assert any("docs/reference/commands/new-group.md is missing" in issue for issue in issues)
result = runner.invoke(app, ["docs", "reference", "--apply"])
assert result.exit_code == 1
assert "docs/reference/commands/new-group.md" in result.output
assert not (root / "docs/reference/commands/new-group.md").exists()
def test_a_page_without_its_marker_pair_fails_and_is_left_alone(tree):
root, _ = tree
page = root / "docs/reference/exit-codes.md"
page.write_text("# Exit codes and gates\n\nNo region here.\n", encoding="utf-8")
issues = docs_verify.check_reference_regions()
assert any("exit-codes.md has no" in issue for issue in issues)
result = runner.invoke(app, ["docs", "reference", "--apply"])
assert result.exit_code == 1
assert page.read_text(encoding="utf-8") == "# Exit codes and gates\n\nNo region here.\n"
def test_a_page_without_a_group_fails(tree):
root, _ = tree
(root / "docs/reference/commands/gone.md").write_text("# Gone\n", encoding="utf-8")
issues = docs_verify.check_reference_regions()
assert any("gone.md belongs to no group" in issue for issue in issues)
def test_a_tree_without_docs_passes(tmp_path, monkeypatch):
monkeypatch.setattr(config, "ROOT", tmp_path)
assert docs_verify.check_reference_regions() == []
def test_exit_codes_lists_each_gate_and_its_exit_42_causes(tree):
root, _ = tree
runner.invoke(app, ["docs", "reference", "--apply"])
page = (root / "docs/reference/exit-codes.md").read_text(encoding="utf-8")
assert "### keep\n\n- [`gate keep`](commands/other.md#gate-keep)" in page
assert "- [`gate keep`](commands/other.md#gate-keep): Needs a person" in page
assert "frobnicate" not in page
def test_the_index_links_every_command_to_its_section(tree):
root, _ = tree
runner.invoke(app, ["docs", "reference", "--apply"])
index = (root / "docs/reference/commands/README.md").read_text(encoding="utf-8")
assert "- [`frobnicate`](fixture-things.md#frobnicate) - Does a thing." in index
assert "- [`gate keep`](other.md#gate-keep) - Does a thing." in index
+22 -1
View File
@@ -276,6 +276,12 @@ def _slugify(text: str, seen: dict[str, int]) -> str:
return base if count == 0 else f"{base}-{count}"
def anchor(text: str) -> str:
"""The anchor a heading with this text gets, the first time it occurs on
a page - for a link written from outside that page."""
return _slugify(text, {})
def render(headings: list[Heading]) -> str:
"""The TOC region ready to place into a body, or `""` for no headings."""
if not headings:
@@ -289,12 +295,27 @@ def render(headings: list[Heading]) -> str:
return blocks.render(REGION_NAME, HEADING_TEXT, lines)
# Any generated region other than the TOC itself, markers included.
_OTHER_REGION_RE = re.compile(
r"<!-- wikitool:([a-z][a-z0-9-]*) -->.*?<!-- /wikitool:\1 -->", re.DOTALL
)
def _first_level2_offset(body: str) -> int | None:
"""Character offset of the first real (non-fenced) `## ` heading, or None."""
"""Character offset of the first real (non-fenced) `## ` heading, or None.
A heading inside another generated region moves the offset to that
region's opening marker: a page under `docs/reference/` can open with a
generated region whose first line is a `##` heading, and a TOC inserted
there would sit inside a region the next regeneration replaces wholesale.
"""
masked = markdown_code.strip_code_spans(body)
pos = 0
for masked_line in masked.split("\n"):
if masked_line.startswith("## "):
for region in _OTHER_REGION_RE.finditer(masked):
if region.start() < pos < region.end():
return region.start()
return pos
pos += len(masked_line) + 1
return None