Files changed: - CHANGES.md - VERSION - tools/chemenu/cli_contract.py - tools/chemenu/tests/test_cli.py - tools/chemenu/tests/test_cli_contract.py - tools/chemenu/tests/test_docs_verify.py
509 lines
18 KiB
Python
509 lines
18 KiB
Python
"""One data record per `wikitool` command - the single source three 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.
|
|
|
|
A record is attached to its command function, in that function's own module,
|
|
via the `@record(...)` decorator - never centralised, so the contract sits
|
|
next to the code it describes. `GROUPS` is the one thing that stays central:
|
|
the `###`-level grouping and rendering order, unchanged from what
|
|
`tools/CONTRACT.md` carried before this module existed.
|
|
|
|
Phase 1 (Gitea #121) filled every record mechanically and word-for-word from
|
|
the two tables `tools/CONTRACT.md` used to carry. Phase 2 (Gitea #142)
|
|
rewrote them: NOTES as present-tense bullets, one `Failure` per cause,
|
|
EXAMPLES/NEVER/SEE ALSO filled, and "why" moved out to a code comment where
|
|
the behaviour is implemented. How a record is written is the `CommandRecord`
|
|
docstring's job. A sentence several records carry verbatim lives here once
|
|
(`token_gate_reaction`, ...), so the output repeats it and the source does
|
|
not.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Callable, Optional, TypeVar
|
|
|
|
_F = TypeVar("_F", bound=Callable)
|
|
|
|
|
|
class Effect(str, Enum):
|
|
READ = "read"
|
|
WRITE = "write"
|
|
|
|
|
|
class Idempotent(str, Enum):
|
|
YES = "yes"
|
|
NO = "no"
|
|
|
|
|
|
class Budget(str, Enum):
|
|
"""What the Iteration Budget Gate does with a call to this command.
|
|
|
|
`EXEMPT_WITHOUT_ARGS` is `version regrade`'s own shape: the bare listing
|
|
only reads, but any index argument writes `CHANGES.md` and is counted like
|
|
`version bump` - one command, two answers, depending on whether it was
|
|
called with arguments at all (see `run_budget.is_exempt`).
|
|
"""
|
|
COUNTED = "counted"
|
|
EXEMPT = "exempt"
|
|
EXEMPT_WITHOUT_ARGS = "exempt_without_args"
|
|
|
|
|
|
class Network(str, Enum):
|
|
YES = "yes"
|
|
NO = "no"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Variant:
|
|
"""One usage form of a command that takes more than one shape - `new`'s
|
|
`new <type-name>` vs `new entity` vs `new project`, `raw accept`'s plain
|
|
form vs `--replaces`. `notes` is empty unless the variant needs a sentence
|
|
of its own beyond what NOTES already says for the command as a whole."""
|
|
usage: str
|
|
notes: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Failure:
|
|
"""One cause of a non-success exit, and what the caller does about it.
|
|
|
|
EXIT STATUS renders one `<code> <cause>` line per entry, ON FAILURE one
|
|
`<cause> -> <reaction>` line - the cause is repeated on purpose, so each
|
|
ON FAILURE line reads on its own. `code` is 1 (validation error) or 42
|
|
(a gate needs clearance; only on a command whose `Properties.gates` is
|
|
non-empty), or 0 for an outcome a caller could mistake for a failure but
|
|
that is not one (an unreachable remote reported and skipped). An empty
|
|
`reaction` renders the EXIT STATUS line only.
|
|
|
|
`label` names the usage form a cause belongs to (`"new project"`) and is
|
|
rendered as a `<label>: ` prefix on both lines; empty when the command
|
|
has one form, or when the cause already says it."""
|
|
cause: str
|
|
reaction: str
|
|
code: int = 1
|
|
label: str = ""
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.code not in (0, 1, 42):
|
|
raise ValueError(f"cli_contract: Failure.code must be 0, 1 or 42, not {self.code}")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Properties:
|
|
effect: Effect
|
|
idempotent: Idempotent
|
|
atomic: str
|
|
budget: Budget
|
|
network: Network = Network.NO
|
|
gates: tuple[str, ...] = ()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CommandRecord:
|
|
"""The man-page-shaped record for one command path (e.g. `"publish"`,
|
|
`"xref add"`). Empty `examples`, `never` and `see_also` render as absent,
|
|
not empty - see `render_text`.
|
|
|
|
How the prose is written - a record is read on its own, by an agent that
|
|
asked for exactly this command:
|
|
|
|
- `notes`: one bullet per behaviour, present tense. What the command does,
|
|
not why it was built that way - a "why" goes into a comment where the
|
|
behaviour is implemented, and history into `CHANGES.md` or nowhere.
|
|
- `failures`: one entry per cause, each with its own reaction. A rule
|
|
("do not retry", "show the output and stop") belongs in the reaction or
|
|
in `never`, never only in `notes`.
|
|
- `examples`: one to three copyable calls, the most common first; a gated
|
|
command also shows its re-run after exit 42.
|
|
- `never`: the prohibitions for the caller, one per line.
|
|
- `see_also`: related commands and the instruction that uses this one.
|
|
It is the only place another command may be named for context - a
|
|
behaviour this command shares with another is stated here in full,
|
|
not as "same as `X`"."""
|
|
path: str
|
|
summary: str
|
|
synopsis: tuple[Variant, ...]
|
|
properties: Properties
|
|
notes: tuple[str, ...]
|
|
failures: tuple[Failure, ...]
|
|
examples: tuple[str, ...] = ()
|
|
never: tuple[str, ...] = ()
|
|
see_also: tuple[str, ...] = ()
|
|
|
|
def __post_init__(self) -> None:
|
|
if isinstance(self.notes, str):
|
|
raise ValueError(
|
|
f"cli_contract: {self.path!r} notes must be a tuple of bullets, not one string"
|
|
)
|
|
if not self.properties.gates and any(f.code == 42 for f in self.failures):
|
|
raise ValueError(
|
|
f"cli_contract: {self.path!r} lists an exit-42 cause but declares no gate"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared sentences - text more than one record carries word for word.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def token_gate_reaction(flag: str) -> str:
|
|
"""The ON FAILURE reaction to an exit-42 gate that is cleared by a token
|
|
(`--confirm`, `--confirm-rebase`): every such gate prints its evidence and
|
|
the exact re-run line, and refuses a token that does not match the state
|
|
it was issued for."""
|
|
return (
|
|
"Show the user the command's full output verbatim and stop. Once they have approved "
|
|
f"it, run the re-run line the output prints, which carries `{flag} <token>`. Without "
|
|
"that token, or with a wrong, invented or superseded one, it exits 42 again with the "
|
|
"current state"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_REGISTRY: dict[str, CommandRecord] = {}
|
|
|
|
|
|
def record(rec: CommandRecord) -> Callable[[_F], _F]:
|
|
"""Attach `rec` to a command function and register it under `rec.path`.
|
|
|
|
Registering twice under the same path is refused rather than silently
|
|
overwritten - two decorators claiming the same command path is a copy-
|
|
paste mistake, not a legitimate case (a command with more than one usage
|
|
form gets more than one `Variant`/`Failure` *inside* one record, not two
|
|
records)."""
|
|
if rec.path in _REGISTRY:
|
|
raise ValueError(f"cli_contract: duplicate record for {rec.path!r}")
|
|
_REGISTRY[rec.path] = rec
|
|
|
|
def decorator(fn: _F) -> _F:
|
|
fn.__wikitool_contract__ = rec # type: ignore[attr-defined]
|
|
return fn
|
|
|
|
return decorator
|
|
|
|
|
|
def get(path: str) -> Optional[CommandRecord]:
|
|
return _REGISTRY.get(path)
|
|
|
|
|
|
def all_records() -> dict[str, CommandRecord]:
|
|
"""A copy of the registry, keyed by command path."""
|
|
return dict(_REGISTRY)
|
|
|
|
|
|
def reset_registry_for_tests() -> None:
|
|
"""Test-only escape hatch: clear the registry so a fixture module can
|
|
register its own records without colliding with the real CLI's. Nothing
|
|
in the shipped CLI calls this."""
|
|
_REGISTRY.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Groups - the `###`-level sections `tools/CONTRACT.md` renders, in order.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
("Pages", (
|
|
"new", "task new", "task list", "task close",
|
|
"touch", "rename", "rm", "move",
|
|
)),
|
|
("Links and citations", (
|
|
"xref add", "xref remove", "xref link-source",
|
|
"links show", "cite id", "cite add", "cite sync",
|
|
)),
|
|
("Catalog and log", (
|
|
"index rebuild", "log append", "log status",
|
|
)),
|
|
("Finding and checking", (
|
|
"lint", "search", "review",
|
|
)),
|
|
("Provenance", (
|
|
"sources coverage", "sources trace", "sources rebuild-index",
|
|
)),
|
|
("Raw material and uploads", (
|
|
"raw accept", "upload list", "upload show", "upload accept", "upload reject",
|
|
)),
|
|
("Git", (
|
|
"sync", "publish",
|
|
)),
|
|
("Workshop runs and session budget", (
|
|
"work new", "work close", "budget status", "budget reset",
|
|
)),
|
|
("Types, instructions and docs", (
|
|
"types list", "types describe",
|
|
"instructions sync", "instructions verify", "instructions list",
|
|
"docs verify", "docs toc", "docs contract",
|
|
)),
|
|
("Telemetry", (
|
|
"eval sessions", "eval score",
|
|
)),
|
|
("Distribution and versioning", (
|
|
"dist export", "dist upgrade",
|
|
"version show", "version check", "version notes",
|
|
"version bump", "version regrade", "version release",
|
|
)),
|
|
("Content migrations", (
|
|
"migrate list", "migrate status", "migrate verify", "migrate done", "migrate baseline",
|
|
)),
|
|
("Private instances", (
|
|
"upstream merge", "upstream verify",
|
|
)),
|
|
("Instance health", (
|
|
"doctor",
|
|
)),
|
|
)
|
|
|
|
|
|
GroupsType = tuple[tuple[str, tuple[str, ...]], ...]
|
|
|
|
|
|
def grouped_paths(groups: GroupsType = GROUPS) -> tuple[str, ...]:
|
|
"""Every command path named by `groups`, in rendering order."""
|
|
return tuple(path for _, paths in groups for path in paths)
|
|
|
|
|
|
def group_of(path: str, groups: GroupsType = GROUPS) -> Optional[str]:
|
|
for title, paths in groups:
|
|
if path in paths:
|
|
return title
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rendering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_SECTION_ORDER = (
|
|
"NAME", "SYNOPSIS", "PROPERTIES", "EXAMPLES", "OPTIONS",
|
|
"EXIT STATUS", "ON FAILURE", "NEVER", "NOTES", "SEE ALSO",
|
|
)
|
|
|
|
|
|
def _exit_codes(rec: CommandRecord) -> list[int]:
|
|
codes = {0} | {failure.code for failure in rec.failures}
|
|
if rec.properties.gates:
|
|
codes.add(42)
|
|
return sorted(codes)
|
|
|
|
|
|
def _labelled(failure: Failure, text: str) -> str:
|
|
return f"{failure.label}: {text}" if failure.label else text
|
|
|
|
|
|
def _notes_lines(notes: tuple[str, ...]) -> list[str]:
|
|
"""NOTES as rendered lines: one `- ` bullet per entry."""
|
|
return [f"- {note}" for note in notes]
|
|
|
|
|
|
def _idempotent_text(idempotent: Idempotent) -> str:
|
|
return "idempotent" if idempotent == Idempotent.YES else "non-idempotent"
|
|
|
|
|
|
def _budget_text(budget: Budget) -> str:
|
|
return {
|
|
Budget.COUNTED: "budget:counted",
|
|
Budget.EXEMPT: "budget:exempt",
|
|
Budget.EXEMPT_WITHOUT_ARGS: "budget:exempt_without_args",
|
|
}[budget]
|
|
|
|
|
|
def render_properties_lines(props: Properties) -> list[str]:
|
|
lines = [
|
|
f"effect {props.effect.value}",
|
|
f"idempotent {props.idempotent.value}",
|
|
f"atomic {props.atomic}",
|
|
f"budget {props.budget.value}",
|
|
f"network {props.network.value}",
|
|
]
|
|
if props.gates:
|
|
lines.append(f"gates {', '.join(props.gates)}")
|
|
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"]
|
|
for failure in sorted(rec.failures, key=lambda f: f.code):
|
|
lines.append(f"{str(failure.code).ljust(4)} {_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
|
|
|
|
|
|
def render_on_failure_lines(rec: CommandRecord) -> list[str]:
|
|
return [
|
|
_labelled(failure, f"{failure.cause} -> {failure.reaction}")
|
|
for failure in sorted(rec.failures, key=lambda f: f.code)
|
|
if failure.reaction
|
|
]
|
|
|
|
|
|
def render_text(rec: CommandRecord, options_text: str = "") -> str:
|
|
"""The full plain-text record, in NAME/SYNOPSIS/.../SEE ALSO order, for
|
|
`wikitool <path> -h`. `options_text` is Click's own rendered Options
|
|
block (already flag-formatted) for this command, spliced in between
|
|
EXAMPLES and EXIT STATUS - see `chemenu.cli` for how it is obtained.
|
|
Empty sections (EXAMPLES/NEVER/SEE ALSO, ON FAILURE with no reaction to
|
|
give, OPTIONS for a command with none) are omitted entirely rather than
|
|
printed empty."""
|
|
blocks: list[str] = []
|
|
|
|
blocks.append(f"NAME\n wikitool {rec.path} - {rec.summary}")
|
|
|
|
synopsis_lines = "\n".join(
|
|
f" wikitool {variant.usage}" + (f"\n {variant.notes}" if variant.notes else "")
|
|
for variant in rec.synopsis
|
|
)
|
|
blocks.append(f"SYNOPSIS\n{synopsis_lines}")
|
|
|
|
props_lines = "\n".join(f" {line}" for line in render_properties_lines(rec.properties))
|
|
blocks.append(f"PROPERTIES\n{props_lines}")
|
|
|
|
if rec.examples:
|
|
example_lines = "\n".join(f" {example}" for example in rec.examples)
|
|
blocks.append(f"EXAMPLES\n{example_lines}")
|
|
|
|
if options_text.strip():
|
|
blocks.append(f"OPTIONS\n{options_text.rstrip()}")
|
|
|
|
exit_lines = "\n".join(f" {line}" for line in render_exit_status_lines(rec))
|
|
blocks.append(f"EXIT STATUS\n{exit_lines}")
|
|
|
|
on_failure = render_on_failure_lines(rec)
|
|
if on_failure:
|
|
failure_lines = "\n".join(f" {line}" for line in on_failure)
|
|
blocks.append(f"ON FAILURE\n{failure_lines}")
|
|
|
|
if rec.never:
|
|
never_lines = "\n".join(f" - {n}" for n in rec.never)
|
|
blocks.append(f"NEVER\n{never_lines}")
|
|
|
|
notes_lines = "\n".join(f" {line}" for line in _notes_lines(rec.notes))
|
|
blocks.append(f"NOTES\n{notes_lines}")
|
|
|
|
if rec.see_also:
|
|
see_also_lines = "\n".join(f" - {s}" for s in rec.see_also)
|
|
blocks.append(f"SEE ALSO\n{see_also_lines}")
|
|
|
|
return "\n\n".join(blocks) + "\n"
|
|
|
|
|
|
def render_index_line(rec: CommandRecord, name_width: int = 15) -> str:
|
|
"""One `wikitool -h`/index line: name, typed properties, one-sentence
|
|
purpose - fixed-width columns so a `grep` and a human's eyes both work.
|
|
"""
|
|
exit_text = "exit:" + ",".join(str(code) for code in _exit_codes(rec))
|
|
columns = [
|
|
rec.path.ljust(name_width),
|
|
rec.properties.effect.value.ljust(6),
|
|
_idempotent_text(rec.properties.idempotent).ljust(15),
|
|
_budget_text(rec.properties.budget).ljust(28),
|
|
exit_text.ljust(12),
|
|
]
|
|
return "".join(columns) + rec.summary
|
|
|
|
|
|
def render_index(
|
|
records: Optional[dict[str, CommandRecord]] = None, groups: GroupsType = GROUPS
|
|
) -> str:
|
|
"""The full index, one line per command, in `groups` order."""
|
|
records = records if records is not None else all_records()
|
|
width = max((len(path) for path in records), default=15) + 1
|
|
lines = []
|
|
for path in grouped_paths(groups):
|
|
rec = records.get(path)
|
|
if rec is None:
|
|
continue
|
|
lines.append(render_index_line(rec, name_width=width))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def render_markdown_section(rec: CommandRecord) -> str:
|
|
"""The `#### <path>` markdown form of one record, for the generated
|
|
region of `tools/CONTRACT.md`. Same section order and content as
|
|
`render_text`, minus OPTIONS (Click's own `--help` already carries the
|
|
flags; the generated markdown does not re-derive them)."""
|
|
lines = [f"#### `{rec.path}`", "", rec.summary, ""]
|
|
|
|
lines.append("**SYNOPSIS**")
|
|
lines.append("")
|
|
for variant in rec.synopsis:
|
|
note = f" - {variant.notes}" if variant.notes else ""
|
|
lines.append(f"- `wikitool {variant.usage}`{note}")
|
|
lines.append("")
|
|
|
|
lines.append("**PROPERTIES**")
|
|
lines.append("")
|
|
for line in render_properties_lines(rec.properties):
|
|
key, _, value = line.partition(" ")
|
|
lines.append(f"- {key}: {value.strip()}")
|
|
lines.append("")
|
|
|
|
if rec.examples:
|
|
lines.append("**EXAMPLES**")
|
|
lines.append("")
|
|
for example in rec.examples:
|
|
lines.append(f"- `{example}`")
|
|
lines.append("")
|
|
|
|
lines.append("**EXIT STATUS**")
|
|
lines.append("")
|
|
for line in render_exit_status_lines(rec):
|
|
lines.append(f"- {line}")
|
|
lines.append("")
|
|
|
|
on_failure = render_on_failure_lines(rec)
|
|
if on_failure:
|
|
lines.append("**ON FAILURE**")
|
|
lines.append("")
|
|
for line in on_failure:
|
|
lines.append(f"- {line}")
|
|
lines.append("")
|
|
|
|
if rec.never:
|
|
lines.append("**NEVER**")
|
|
lines.append("")
|
|
for n in rec.never:
|
|
lines.append(f"- {n}")
|
|
lines.append("")
|
|
|
|
lines.append("**NOTES**")
|
|
lines.append("")
|
|
lines.extend(_notes_lines(rec.notes))
|
|
lines.append("")
|
|
|
|
if rec.see_also:
|
|
lines.append("**SEE ALSO**")
|
|
lines.append("")
|
|
for s in rec.see_also:
|
|
lines.append(f"- {s}")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
def render_commands_region(
|
|
records: Optional[dict[str, CommandRecord]] = None, groups: GroupsType = GROUPS
|
|
) -> str:
|
|
"""The full `<!-- wikitool:commands -->` region body: the index, then
|
|
each `###` group with its commands' `#### <path>` records."""
|
|
records = records if records is not None else all_records()
|
|
parts = ["```", render_index(records, groups), "```", ""]
|
|
for title, paths in groups:
|
|
present = [p for p in paths if p in records]
|
|
if not present:
|
|
continue
|
|
parts.append(f"### {title}")
|
|
parts.append("")
|
|
for path in present:
|
|
parts.append(render_markdown_section(records[path]))
|
|
return "\n".join(parts).rstrip() + "\n"
|