feat: docs reference generates a skill catalogue and a page-type reference under docs/reference/ from the shipped defaults, checked and written in the origin repo only (#189)
CI / verify (push) Successful in 6m10s
CI / pwsh (push) Successful in 1m59s
Release / release (push) Successful in 34s

Files changed:
- CHANGES.md
- README.md
- VERSION
- docs/reference/README.md
- docs/reference/commands/README.md
- docs/reference/commands/types-instructions-and-docs.md
- docs/reference/page-types.md
- docs/reference/skills.md
- instructions/dev/doc-pull-through.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/reference_doc.py
- tools/chemenu/tests/test_reference_doc.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 22:10:09 +02:00
1 parent d644a1d4f7
commit 070cae6aad
14 files changed
+606 -65

No files matched your search

+9 -8
View File
@@ -145,7 +145,7 @@ docs verify read idempotent budget:counted exit:0,1
docs toc write idempotent budget:counted exit:0 Create, refresh or remove the generated table-of-contents region.
docs prerequisites write idempotent budget:counted exit:0,1 Regenerate `INSTALL.md`'s prerequisites lists from `tools/prerequisites.txt`.
docs contract write idempotent budget:counted exit:0,1 Regenerate `tools/CONTRACT.md`'s `<!-- wikitool:commands -->` region.
docs reference write idempotent budget:counted exit:0,1 Regenerate the human command reference under `docs/reference/`.
docs reference write idempotent budget:counted exit:0,1 Regenerate the human reference under `docs/reference/`.
eval sessions read idempotent budget:exempt exit:0 List the sessions that have a trace under `reports/telemetry/`.
eval score read idempotent budget:exempt exit:0,1 Score one traced session.
dist export write idempotent budget:counted exit:0,1 Write a contentless, distributable copy of this repo's machinery.
@@ -2516,8 +2516,8 @@ Check the docs that mirror the code.
- 1 A type-spec's own frontmatter fails its schema
- 1 A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter
- 1 A shipped `.md`/`.template` cites an issue number
- 1 A region of the human command reference under `docs/reference/` is stale
- 1 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
- 1 A region of the human reference under `docs/reference/` is stale
- 1 A page of the human reference is missing or lacks its marker pair, or a page under `docs/reference/commands/` belongs to no group
- 1 A reference file's table-of-contents region is missing or stale
- 1 A file sits directly in `docs/`, or in a directory there that is not one of its five areas
- 1 A reference file's relative markdown link does not resolve to an existing file
@@ -2534,8 +2534,8 @@ Check the docs that mirror the code.
- A type-spec's own frontmatter fails its schema -> Fix the field, or add a matching line to `types/type-spec.schema.yaml` if the field is legitimately new
- A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter -> Rename the file to the type and value it was meant for, delete it, or remove its frontmatter block
- A shipped `.md`/`.template` cites an issue number -> Say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block
- A region of the human command reference under `docs/reference/` is stale -> Run `docs reference --apply`, then re-run
- 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 -> 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
- A region of the human reference under `docs/reference/` is stale -> Run `docs reference --apply`, then re-run
- A page of the human reference is missing or lacks its marker pair, or a page under `docs/reference/commands/` belongs to no group -> 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
- A reference file's table-of-contents region is missing or stale -> Run `docs toc --apply`, then re-run
- A file sits directly in `docs/`, or in a directory there that is not one of its five areas -> Move it (`git mv`) into the area its kind of documentation belongs to, and fix the links that pointed at the old path
- A reference file's relative markdown link does not resolve to an existing file -> Fix the `../` count or the target's name
@@ -2559,7 +2559,7 @@ Check the docs that mirror the code.
- Every reference file `docs toc` covers carries the current table-of-contents region for its own headings - missing and stale are one check.
- Every relative markdown link in one of those reference files resolves to an existing file. A target's `#anchor` suffix is stripped first, and code fences and inline code spans are masked before scanning, so link syntax shown as an example is not mistaken for a real reference.
- 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.
- If `docs/` exists: every generated region of the human 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. The skill catalogue and the page types are checked only in the origin repo (no `.wikitool-release.json`): they describe the stack's shipped defaults, which an instance's own type-specs and instructions may differ from.
- `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 in `instructions/setup-instance.md` and `INSTALL.md` name the same set of keys, so a question the agent asks is never one the human guide leaves out, nor the reverse.
- `VERSION` parses and the newest versioned `CHANGES.md` entry names it. An entry that crosses the compatibility boundary from the last release carries `**Breaking Change:**`, has a migration document or `**Migration:** none required`, and its `**Migration:**` line names every required migration document targeting it.
- The newest versioned `CHANGES.md` entry is at most 32 000 bytes (UTF-8) as `version notes` prints it, and each of its `###` sections is exactly one paragraph of at most 1 000 bytes, heading line excluded. Older entries and a changelog with no versioned entry are not checked.
@@ -2569,7 +2569,7 @@ Check the docs that mirror the code.
- `wikitool docs toc` - regenerates tables of contents
- `wikitool docs contract` - regenerates the commands region
- `wikitool docs reference` - regenerates the human command reference
- `wikitool docs reference` - regenerates the human reference
- `wikitool docs prerequisites` - regenerates `INSTALL.md`'s prerequisites lists
- `wikitool instructions verify` - the same kind of check for `instructions/`
@@ -2708,7 +2708,7 @@ Regenerate `tools/CONTRACT.md`'s `<!-- wikitool:commands -->` region.
#### `docs reference`
Regenerate the human command reference under `docs/reference/`.
Regenerate the human reference under `docs/reference/`.
**SYNOPSIS**
@@ -2743,6 +2743,7 @@ Regenerate the human command reference under `docs/reference/`.
**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 -->`).
- Also, in the origin repo only, the skill catalogue `docs/reference/skills.md` (`<!-- wikitool:reference-skills -->`: one row per shipped `instructions/<name>/SKILL.md`, its description split at the sentence starting "Use when") and the page types `docs/reference/page-types.md` (`<!-- wikitool:reference-page-types -->`: one section per `root: kb` type-spec, from what `types describe` reads). Both describe the stack's shipped defaults. In an instance - a tree with `.wikitool-release.json` - they are left alone and the output says so: an instance's adopted type-specs and own instructions may differ, and rewriting a shipped page there would block the next `dist upgrade`.
- 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.
+1 -1
View File
@@ -89,7 +89,7 @@ tools/
filelock.py an exclusive lock on an open file, flock on POSIX and msvcrt on Windows - the only module that imports either
prerequisites.py prerequisites.txt read from Python, plus the platform and long-path questions `doctor` asks
install_doc.py INSTALL.md held to the instructions: its prerequisites lists generated from prerequisites.txt, its setup questions matched to setup-instance.md's markers
reference_doc.py the human command reference under docs/reference/: one page per command group, an overview and the exit-42 causes, rendered from the records without what addresses an agent
reference_doc.py the human reference under docs/reference/: one page per command group, an overview and the exit-42 causes, rendered from the records without what addresses an agent; and, in the origin repo only, the skill catalogue and the page types, rendered from the shipped SKILL.md files and type-specs
corpus_cache.py one parsed corpus per commit, never cached while the tree is dirty
kb_scan.py page iteration/loading over kb/
blocks.py generated regions in a page body, found by marker rather than by heading
+41 -14
View File
@@ -337,19 +337,24 @@ def check_commands_region() -> list[str]:
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.
"""Every generated region of the human 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.
In an instance the skill catalogue and the page types are not checked at
all: they describe the stack's shipped defaults, which an instance's own
adopted type-specs and instructions are free to differ from
(`reference_doc.applicable`).
"""
if not (config.ROOT / "docs").is_dir():
return []
issues = []
for region in reference_doc.regions():
checked, _ = reference_doc.applicable(reference_doc.regions())
for region in checked:
path = config.ROOT / region.path
if not path.is_file():
issues.append(
@@ -1288,10 +1293,12 @@ 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 "
"If `docs/` exists: every generated region of the human 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.",
"group. The skill catalogue and the page types are checked only in the origin repo "
"(no `.wikitool-release.json`): they describe the stack's shipped defaults, which an "
"instance's own type-specs and instructions may differ from.",
"`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 "
@@ -1345,11 +1352,11 @@ def check_breaking_change_for_boundary() -> list[str]:
"behind a `<!-- dist:strip-start/end -->` block",
),
cli_contract.Failure(
cause="A region of the human command reference under `docs/reference/` is stale",
cause="A region of the human 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 "
cause="A page of the human 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 "
@@ -1397,7 +1404,7 @@ def check_breaking_change_for_boundary() -> list[str]:
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 reference` - regenerates the human reference",
"`wikitool docs prerequisites` - regenerates `INSTALL.md`'s prerequisites lists",
"`wikitool instructions verify` - the same kind of check for `instructions/`",
),
@@ -1434,6 +1441,10 @@ def verify():
from chemenu.type_resolver import resolver
reference_scope = (
" (skill catalogue and page types are checked in the origin repo)"
if reference_doc.in_instance() else ""
)
success(
f"Docs verified: {len(registered_commands())} command(s) with a cli_contract record, "
f"{len(kb_collections.iter_kb_collections())} collection(s) and "
@@ -1442,7 +1453,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"the reference under {reference_doc.REFERENCE_DIR}/ current{reference_scope}, "
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 "
@@ -1669,7 +1680,7 @@ def contract_command(
@app.command("reference")
@cli_contract.record(cli_contract.CommandRecord(
path="docs reference",
summary="Regenerate the human command reference under `docs/reference/`.",
summary="Regenerate the human reference under `docs/reference/`.",
synopsis=(cli_contract.Variant(usage="docs reference [--apply]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
@@ -1685,6 +1696,14 @@ def contract_command(
"`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 -->`).",
"Also, in the origin repo only, the skill catalogue `docs/reference/skills.md` "
"(`<!-- wikitool:reference-skills -->`: one row per shipped `instructions/<name>/SKILL.md`, "
"its description split at the sentence starting \"Use when\") and the page types "
"`docs/reference/page-types.md` (`<!-- wikitool:reference-page-types -->`: one section per "
"`root: kb` type-spec, from what `types describe` reads). Both describe the stack's "
"shipped defaults. In an instance - a tree with `.wikitool-release.json` - they are left "
"alone and the output says so: an instance's adopted type-specs and own instructions may "
"differ, and rewriting a shipped page there would block the next `dist upgrade`.",
"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.",
@@ -1716,10 +1735,18 @@ def contract_command(
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/`."""
"""Regenerate the human reference under `docs/reference/`."""
missing = []
changed = []
for region in reference_doc.regions():
generated, left_alone = reference_doc.applicable(reference_doc.regions())
if left_alone:
typer.echo(
"Left alone in this instance: "
+ ", ".join(region.path for region in left_alone)
+ " - they describe the stack's shipped defaults; `wikitool types describe <type>` "
"and `wikitool instructions list` show this instance's own."
)
for region in generated:
path = config.ROOT / region.path
if not path.is_file():
missing.append(f"{region.path} (create it with `{blocks.open_marker(region.name)}`)")
+184 -1
View File
@@ -17,6 +17,16 @@ person.
`regions()` is the one list both `docs reference` and `docs verify` walk; a
further generated reference page is one more entry in it.
Two of its pages - the skill catalogue and the page types - render material an
instance may change: it adopts the page type-specs from their `.template` and
edits them, and it may add instructions of its own. Shown as the instance's
state, `docs reference --apply` would have to rewrite a shipped file there, and
the next `dist upgrade` would then block on it as locally modified. So both
pages describe the stack's shipped defaults and are `origin_only`: generated
and checked in the origin repo, where `types/*.md` is exactly what ships as
`.template`, and left alone in an instance - one carrying a release stamp,
the same line the telemetry default draws.
"""
from __future__ import annotations
@@ -25,25 +35,46 @@ from pathlib import Path
from typing import Callable, NamedTuple, Optional
from chemenu import blocks, cli_contract, config, toc
from chemenu import version as version_mod
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"
SKILLS = f"{REFERENCE_DIR}/skills.md"
PAGE_TYPES = f"{REFERENCE_DIR}/page-types.md"
COMMANDS_REGION = "reference-commands"
INDEX_REGION = "reference-commands-index"
EXIT_CODES_REGION = "reference-exit-codes"
SKILLS_REGION = "reference-skills"
PAGE_TYPES_REGION = "reference-page-types"
_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."""
name, what renders its content, and whether it is generated and checked
only in the origin repo (see the module docstring)."""
path: str
name: str
render: Callable[[], str]
origin_only: bool = False
def in_instance() -> bool:
"""Whether this tree is an installed instance rather than the origin repo:
`dist export` writes the release stamp, the origin never carries one."""
return version_mod.stamp_file().is_file()
def applicable(found: list[Region]) -> tuple[list[Region], list[Region]]:
"""`found` split into the regions this tree generates and checks, and the
`origin_only` ones it leaves alone because it is an instance."""
if not in_instance():
return list(found), []
return [r for r in found if not r.origin_only], [r for r in found if r.origin_only]
def group_slug(title: str) -> str:
@@ -197,6 +228,154 @@ def render_exit_codes(
return "\n".join(lines).rstrip() + "\n"
# ---------------------------------------------------------------------------
# The skill catalogue and the page types - the stack's shipped defaults
# ---------------------------------------------------------------------------
class Skill(NamedTuple):
name: str
description: str
def shipped_skills() -> list[Skill]:
"""Every skill `dist export` ships: `instructions/<name>/SKILL.md`, not the
dev-only ones under `instructions/dev/`. Name and description are the
frontmatter the harness itself reads."""
from chemenu.commands import instructions_cmd # deferred: it imports docs_verify, which imports this
from chemenu.frontmatter_io import read_page
skills = []
for directory in instructions_cmd.skill_dirs():
if instructions_cmd.is_dev_only(directory):
continue
frontmatter, _ = read_page(directory / instructions_cmd.SKILL_FILE)
skills.append(Skill(
str(frontmatter.get("name") or directory.name),
" ".join(str(frontmatter.get("description") or "").split()),
))
return sorted(skills)
# A skill description states what the skill does, then when to use it, in one
# sentence starting "Use when" - the part a harness matches a request against.
_TRIGGER_RE = re.compile(r"\s*\bUse when\b\s*")
def split_description(description: str) -> tuple[str, str]:
"""A skill description as (what it does, when to use it). A description
with no "Use when" sentence is a skill nobody's request triggers: it runs
only when asked for by name."""
parts = _TRIGGER_RE.split(description, maxsplit=1)
if len(parts) == 1:
return description, "only when asked for by name"
return parts[0], parts[1]
def _cell(text: str) -> str:
"""`text` safe inside a table cell: a pipe would end the cell, and outside
inline code an `<file>` placeholder would be read as an HTML tag and
vanish from the rendered page."""
parts = text.replace("|", "\\|").split("`")
for i in range(0, len(parts), 2):
parts[i] = parts[i].replace("<", "&lt;").replace(">", "&gt;")
return "`".join(parts)
def render_skills(skills: list[Skill]) -> str:
"""The region body of `skills.md`: one table row per shipped skill."""
lines = ["| Skill | What it does | Use it when |", "|---|---|---|"]
for skill in skills:
does, when = split_description(skill.description)
lines.append(f"| `{skill.name}` | {_cell(does)} | {_cell(when)} |")
return "\n".join(lines) + "\n"
class Subtype(NamedTuple):
value: str
directory: str
index_title: Optional[str]
class PageType(NamedTuple):
"""What `page-types.md` says about one type: `described` is exactly what
`types describe --json` prints for it, plus where each subtype goes."""
described: dict
subtypes: tuple[Subtype, ...]
def page_types() -> list[PageType]:
"""Every page type-spec - the `root: kb` ones `dist export` ships as
`.template` - as `types describe` describes it."""
from chemenu.commands.dist_cmd import instance_owned_type_stems # deferred: a command module
from chemenu.type_resolver import resolver
from chemenu.types_core import describe_type
stems = instance_owned_type_stems()
found = []
for type_path, frontmatter in resolver.list_type_specs():
if Path(type_path).stem not in stems:
continue
described = describe_type(frontmatter["name"])
subtypes: tuple[Subtype, ...] = ()
field = described["subtype_field"]
if field:
values = next((f["enum"] for f in described["fields"] if f["field"] == field), None)
layout = resolver.get_layout(type_path) or {}
subtypes = tuple(
Subtype(
value,
resolver.subtype_dir(type_path, value) or "",
layout.get(value, {}).get("title"),
)
for value in (values or list(layout))
)
found.append(PageType(described, subtypes))
return found
def _type_dir(described: dict) -> str:
return f"{described['root']}/{described['base_dir']}"
def render_page_type(page_type: PageType) -> str:
"""One type's section on `page-types.md`."""
described = page_type.described
base = _type_dir(described)
lines = [f"## `{described['name']}`", "", described["description"] or "", ""]
if page_type.subtypes:
lines.append(f"- Written to: `{base}/<subtype directory>/`")
else:
lines.append(f"- Written to: `{base}/`")
if described["title_prefix"]:
lines.append(f"- Title prefix: `{described['title_prefix']}`")
lines.append("")
if page_type.subtypes:
lines += [f"**Subtypes** (field `{described['subtype_field']}`)", ""]
lines += ["| Value | Written to | Index section |", "|---|---|---|"]
for subtype in page_type.subtypes:
lines.append(
f"| `{subtype.value}` | `{base}/{subtype.directory}/` | {subtype.index_title or ''} |"
)
lines.append("")
lines += ["**Required fields**", ""]
for field in described["fields"]:
if not field["required"]:
continue
kind = field["type"] or "any"
if field["enum"]:
kind += ", one of " + ", ".join(f"`{value}`" for value in field["enum"])
lines.append(f"- `{field['field']}` - {kind}")
return "\n".join(lines).rstrip() + "\n"
def render_page_types(types: list[PageType]) -> str:
"""The region body of `page-types.md`: one section per page type."""
return "\n".join(render_page_type(t) for t in types).rstrip() + "\n"
# ---------------------------------------------------------------------------
# The generated regions, and writing one
# ---------------------------------------------------------------------------
@@ -225,6 +404,10 @@ def regions(
]
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)))
# Looked up at render time, not bound here, so a fixture tree can stand in
# its own skills and types for the real ones.
found.append(Region(SKILLS, SKILLS_REGION, lambda: render_skills(shipped_skills()), origin_only=True))
found.append(Region(PAGE_TYPES, PAGE_TYPES_REGION, lambda: render_page_types(page_types()), origin_only=True))
return found
+175 -1
View File
@@ -1,11 +1,14 @@
import json
import re
import pytest
from typer.testing import CliRunner
from chemenu import blocks, cli, cli_contract, config, reference_doc, toc
from chemenu import version as version_mod
from chemenu.cli import app
from chemenu.commands import dist_cmd, docs_verify
from chemenu.commands import dist_cmd, docs_verify, instructions_cmd
from chemenu.type_resolver import resolver
runner = CliRunner()
@@ -94,6 +97,112 @@ def test_the_reference_pages_survive_dist_export_unchanged():
assert plan[region.path].content == (config.ROOT / region.path).read_text(encoding="utf-8")
def _type_sections(page: str) -> dict[str, str]:
region = blocks.find(page, reference_doc.PAGE_TYPES_REGION)
return {
match.group(1): match.group(2)
for match in re.finditer(r"^## `([^`]+)`\n(.*?)(?=^## |\Z)", region, re.M | re.S)
}
def test_the_skill_catalogue_has_one_row_per_shipped_skill():
shipped = {
d.name for d in instructions_cmd.skill_dirs() if not instructions_cmd.is_dev_only(d)
}
assert shipped
region = blocks.find(
(config.ROOT / reference_doc.SKILLS).read_text(encoding="utf-8"), reference_doc.SKILLS_REGION
)
rows = re.findall(r"^\| `([^`]+)` \|", region, re.M)
assert sorted(rows) == sorted(shipped)
for directory in instructions_cmd.skill_dirs():
if instructions_cmd.is_dev_only(directory):
assert f"`{directory.name}`" not in region
def test_page_types_has_one_section_per_page_type_matching_types_describe():
"""Required fields and subtypes are what `types describe --json` says, and
each subtype's directory is where `new` would write it."""
sections = _type_sections((config.ROOT / reference_doc.PAGE_TYPES).read_text(encoding="utf-8"))
names = {
fm["name"] for path, fm in resolver.list_type_specs()
if path.rsplit("/", 1)[-1][:-3] in dist_cmd.instance_owned_type_stems()
}
assert names and set(sections) == names
for name, section in sections.items():
result = runner.invoke(app, ["types", "describe", name, "--json"])
assert result.exit_code == 0, result.output
described = json.loads(result.output)
required = [f["field"] for f in described["fields"] if f["required"]]
listed = re.findall(r"^- `([^`]+)` - ", section.split("**Required fields**")[1], re.M)
assert listed == required, name
field = described["subtype_field"]
rows = re.findall(r"^\| `([^`]+)` \| `([^`]+)` \|", section, re.M)
if not field:
assert rows == [], name
continue
enum = next(f["enum"] for f in described["fields"] if f["field"] == field)
assert [value for value, _ in rows] == enum, name
for value, written_to in rows:
target = resolver.compute_target_dir(described["type_path"], {field: value})
assert written_to == target.relative_to(config.ROOT).as_posix() + "/", (name, value)
def test_a_description_is_split_at_its_use_when_sentence():
assert reference_doc.split_description("Does X. Use when Y happens.") == ("Does X.", "Y happens.")
assert reference_doc.split_description("Does X.") == ("Does X.", "only when asked for by name")
def test_a_table_cell_escapes_pipes_and_tags_but_not_inside_code():
cell = reference_doc._cell('says "ingest <file>" or `a <b> | c` | d')
assert cell == 'says "ingest &lt;file&gt;" or `a <b> \\| c` \\| d'
@pytest.fixture
def exported_instance(tmp_path, monkeypatch):
"""A `dist export` of this tree, adopted and then changed the way an
instance may: a type-spec of its own making, and a skill of its own."""
target = tmp_path / "instance"
dist_cmd.run_export(target)
monkeypatch.setattr(config, "ROOT", target)
monkeypatch.setattr(resolver, "_repo_root", target)
for template in sorted((target / "types").glob("*.template")):
template.rename(template.with_suffix(""))
entity = target / "types" / "entity.md"
entity.write_text(
entity.read_text(encoding="utf-8").replace(
"description: Base type for entity pages", "description: This instance's entities"
),
encoding="utf-8",
)
own = target / "instructions" / "own-skill"
own.mkdir()
(own / "SKILL.md").write_text(
"---\nname: own-skill\ndescription: Does this instance's thing. Use when asked.\n---\n# Own\n",
encoding="utf-8",
)
return target
def test_an_adapted_instance_leaves_both_pages_alone(exported_instance):
root = exported_instance
assert version_mod.stamp_file().is_file()
pages = {
relative: (root / relative).read_text(encoding="utf-8")
for relative in (reference_doc.SKILLS, reference_doc.PAGE_TYPES)
}
assert docs_verify.check_reference_regions() == []
result = runner.invoke(app, ["docs", "reference", "--apply"])
assert result.exit_code == 0, result.output
assert "Left alone in this instance" in result.output
for relative, before in pages.items():
assert (root / relative).read_text(encoding="utf-8") == before, relative
assert docs_verify.check_reference_regions() == []
# --- a fixture tree -------------------------------------------------------
GROUPS = (("Fixture things", ("frobnicate",)), ("Other", ("gate keep",)))
@@ -127,15 +236,34 @@ HEADS = {
"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",
"docs/reference/skills.md": "# Skill catalogue\n\nThe skills.\n",
"docs/reference/page-types.md": "# Page types\n\nThe types.\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,
"docs/reference/skills.md": reference_doc.SKILLS_REGION,
"docs/reference/page-types.md": reference_doc.PAGE_TYPES_REGION,
}
def _page_type(description: str = "A fixture type.") -> reference_doc.PageType:
return reference_doc.PageType(
described={
"name": "gadget", "description": description, "subtype_field": "gadget_type",
"root": "kb", "base_dir": "gadgets", "title_prefix": None,
"fields": [
{"field": "type", "required": True, "type": "string", "enum": None},
{"field": "gadget_type", "required": True, "type": "string", "enum": ["widget"]},
{"field": "tags", "required": False, "type": "array", "enum": None},
],
},
subtypes=(reference_doc.Subtype("widget", "widgets", "Widgets"),),
)
@pytest.fixture
def tree(tmp_path, monkeypatch):
monkeypatch.setattr(config, "ROOT", tmp_path)
@@ -143,6 +271,11 @@ def tree(tmp_path, monkeypatch):
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})
monkeypatch.setattr(
reference_doc, "shipped_skills",
lambda: [reference_doc.Skill("fixture-skill", "Does a thing. Use when asked.")],
)
monkeypatch.setattr(reference_doc, "page_types", lambda: [_page_type()])
for relative, head in HEADS.items():
path = tmp_path / relative
path.parent.mkdir(parents=True, exist_ok=True)
@@ -286,3 +419,44 @@ def test_the_index_links_every_command_to_its_section(tree):
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
def test_a_changed_skill_or_type_makes_verify_name_its_page(tree, monkeypatch):
runner.invoke(app, ["docs", "reference", "--apply"])
assert docs_verify.check_reference_regions() == []
monkeypatch.setattr(
reference_doc, "shipped_skills",
lambda: [reference_doc.Skill("fixture-skill", "Does another thing. Use when asked.")],
)
monkeypatch.setattr(reference_doc, "page_types", lambda: [_page_type("A changed type.")])
issues = docs_verify.check_reference_regions()
for page in ("docs/reference/skills.md", "docs/reference/page-types.md"):
assert any(page in issue and "docs reference --apply" in issue for issue in issues), page
def test_the_fixture_skill_and_type_render(tree):
root, _ = tree
runner.invoke(app, ["docs", "reference", "--apply"])
skills = (root / "docs/reference/skills.md").read_text(encoding="utf-8")
assert "| `fixture-skill` | Does a thing. | asked. |" in skills
types = (root / "docs/reference/page-types.md").read_text(encoding="utf-8")
assert "- Written to: `kb/gadgets/<subtype directory>/`" in types
assert "| `widget` | `kb/gadgets/widgets/` | Widgets |" in types
assert "- `gadget_type` - string, one of `widget`" in types
assert "`tags`" not in types
def test_an_instance_skips_a_missing_skill_or_type_page(tree):
"""In an instance the two pages are not the tree's to check - not even
whether they exist."""
root, _ = tree
runner.invoke(app, ["docs", "reference", "--apply"])
(root / version_mod.RELEASE_STAMP_FILENAME).write_text("{}", encoding="utf-8")
(root / "docs/reference/skills.md").unlink()
(root / "docs/reference/page-types.md").write_text("# Page types\n", encoding="utf-8")
assert docs_verify.check_reference_regions() == []
result = runner.invoke(app, ["docs", "reference", "--apply"])
assert result.exit_code == 0, result.output
assert "Left alone in this instance: docs/reference/skills.md, docs/reference/page-types.md" in result.output
assert not (root / "docs/reference/skills.md").exists()