tools: one data record per command - -h, index and CONTRACT.md render from cli_contract (#121)
CI / verify (push) Failing after 1m11s
Release / release (push) Successful in 37s

Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- instructions/dev/doc-pull-through.md
- instructions/dev/stack-close/SKILL.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/cite_cmd.py
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/eval_cmd.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/index_build.py
- tools/chemenu/commands/instructions_cmd.py
- tools/chemenu/commands/links_cmd.py
- tools/chemenu/commands/lint.py
- tools/chemenu/commands/log_append.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/commands/provenance_cmd.py
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/search.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/commands/touch.py
- tools/chemenu/commands/types_cmd.py
- tools/chemenu/commands/upload_cmd.py
- tools/chemenu/commands/upstream_cmd.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/commands/work_cmd.py
- tools/chemenu/commands/xref.py
- tools/chemenu/tests/test_cli_contract.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_run_budget.py
This commit is contained in:
torben committed 2026-09-26 07:53:10 +02:00
1 parent 919e733e21
commit 26e1018766
39 files changed
+5203 -702

No files matched your search

+108
View File
@@ -9,6 +9,18 @@ import sys
import time
import typer
import typer.core as _typer_core
# GNU-style, TTY-independent help for every command (Gitea #121 B5): one
# format for humans and agents alike, no rich frames on either `--help` or a
# usage error (`typer.core.HAS_RICH` is what both `TyperCommand.format_help`
# and `TyperGroup.format_help` check before choosing rich rendering over the
# plain-Click fallback - see their own source). Set at import time, not only
# in `main()`, so a test driving `app()` directly (CliRunner) sees the same
# behavior as a real invocation.
_typer_core.HAS_RICH = False
from chemenu import cli_contract # noqa: E402 - after the HAS_RICH patch, which must land first
try:
from chemenu.commands import (
@@ -133,6 +145,11 @@ def _pacify_real_fd(stream) -> None:
app = typer.Typer(
help="wikitool - deterministic operations for Chemenu (see AGENTS.md).",
no_args_is_help=True,
# `-h` alongside `--help` on every command (Gitea #121 B5) - Linux
# convention. Click's context settings inherit down the whole command
# tree from the root Typer, so this one declaration covers every nested
# group and command; no command declares its own `-h` (checked).
context_settings={"help_option_names": ["-h", "--help"]},
)
app.add_typer(xref.app, name="xref")
@@ -167,6 +184,97 @@ app.command("sync")(git_publish.sync_command)
app.command("doctor")(doctor.doctor_command)
def _contract_path(ctx) -> str:
"""The dotted `cli_contract` path for `ctx`'s command (`"xref add"`,
`"new"`), built by walking up the Click context chain and collecting each
level's own `info_name` - never from `ctx.command_path`, which is
prefixed with whatever this process's argv[0] happened to be (`wikitool`,
`cli.py`, `-c` under a `python -c` snippet, ...) and would make path
resolution depend on how the CLI was invoked."""
parts: list[str] = []
node = ctx
while node.parent is not None:
parts.append(node.info_name)
node = node.parent
return " ".join(reversed(parts))
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
and without a TTY - the whole point of a GNU-style, script-friendly
format."""
formatter = ctx.formatter_class(width=80, max_width=100)
command.format_options(ctx, formatter)
text = formatter.getvalue().strip()
# A lone "Options:" label is redundant under our own OPTIONS heading;
# kept only when Arguments are also present, where it distinguishes the
# two groups.
if text.startswith("Options:\n") and "Arguments:\n" not in text:
text = text[len("Options:\n"):]
return text
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,
which cannot show a command's typed properties."""
return (
"Usage: wikitool <command> [ARGS]...\n\n"
"wikitool - deterministic operations for Chemenu (see AGENTS.md).\n\n"
+ cli_contract.render_index()
+ "\n\nRun `wikitool <command> -h` for a command's full record "
"(synopsis, properties, exit status, retry policy, ...).\n"
)
# The one global override B5 needs (Gitea #121): every leaf command's
# `-h`/`--help` renders from its `cli_contract` record instead of Click's
# default composition, and the bare root command renders the index. A
# command or group with no record (there is currently exactly one such
# case - an intermediate group like `xref` on its own, never asked for by
# name in normal use) falls through to Click's own formatting unchanged.
#
# Patched on `typer._click.core.Command` - typer 0.27 vendors its own
# internal fork of click (`typer._click`), so `TyperCommand`/`TyperGroup`
# (see `typer.core`) resolve `format_help` there, not on the top-level
# `click` package's `Command` class. Both already fall through to this same
# base implementation via `super().format_help(...)` once `HAS_RICH` is
# False (see their own source) - one patch point covers every command and
# group uniformly.
#
# This reaches into a private, underscore-prefixed module with no version
# pin (`requirements.txt` allows any `typer>=0.12`), so a future typer that
# restructures or drops `_click` must not crash every `wikitool` invocation
# at import time. If the shape this needs is not there, skip the patch: help
# falls back to plain, unframed Click output (HAS_RICH is already False)
# without the contract-based rendering - degraded, not broken.
try:
import typer._click.core as _typer_click_core # noqa: E402
_original_format_help = _typer_click_core.Command.format_help
def _contract_format_help(self, ctx, formatter) -> None:
if ctx.parent is None:
formatter.write(_render_root_help())
return
record = cli_contract.get(_contract_path(ctx))
if record is None:
_original_format_help(self, ctx, formatter)
return
formatter.write(
cli_contract.render_text(record, options_text=_render_options_text(self, ctx))
)
_typer_click_core.Command.format_help = _contract_format_help
except (ImportError, AttributeError):
pass
_typer_click_core.Command.format_help = _contract_format_help
def main() -> None:
# Iteration Budget Gate / Loop-Breaker (see the tooling contract's
# "Iteration and Cost Limits"): recorded and enforced here, once per
+435
View File
@@ -0,0 +1,435 @@
"""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) fills every record mechanically and word-for-word from
the two tables `tools/CONTRACT.md` used to carry. Phase 2 (Gitea #142) is what
edits the prose, adds EXAMPLES/NEVER/SEE ALSO, and pulls "why" out of a
record's NOTES - not this module's job.
"""
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 exit-1 story - most commands have exactly one, `new` and
`raw accept` have two (one per `Variant`), because their failure causes
differ by usage form. `label` is empty for a command with only one; when
a command carries more than one `Failure`, `label` names which variant it
describes (`"new <type>"`, `"new project"`, ...), and EXIT STATUS/ON
FAILURE render every label."""
label: str
exit_1: str
retry: str
@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"`). Sections not populated in phase 1 (`examples`, `never`,
`see_also`) render as absent, not empty - see `render_text`."""
path: str
summary: str
synopsis: tuple[Variant, ...]
properties: Properties
notes: str
failures: tuple[Failure, ...]
examples: tuple[str, ...] = ()
never: tuple[str, ...] = ()
see_also: tuple[str, ...] = ()
# ---------------------------------------------------------------------------
# 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]
if rec.failures:
codes.append(1)
if rec.properties.gates:
codes.append(42)
return codes
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]:
lines = ["0 success"]
for failure in rec.failures:
prefix = f"{failure.label}: " if failure.label else ""
lines.append(f"1 {prefix}{failure.exit_1}")
if rec.properties.gates:
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]:
lines = []
for failure in rec.failures:
prefix = f"{failure.label}: " if failure.label else ""
lines.append(f"{prefix}{failure.retry}")
return lines
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 in phase 1, 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}")
if rec.failures:
failure_lines = "\n".join(f" {line}" for line in render_on_failure_lines(rec))
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 rec.notes.splitlines()) or f" {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("")
if rec.failures:
lines.append("**ON FAILURE**")
lines.append("")
for line in render_on_failure_lines(rec):
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.append(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"
+63 -1
View File
@@ -20,7 +20,7 @@ from typing import Optional
import typer
from chemenu import config
from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success
from chemenu.frontmatter_io import write_page
from chemenu.page import Page
@@ -43,6 +43,20 @@ def _find_page(pages: dict[str, Page], title: str) -> Page:
@app.command("id")
@cli_contract.record(cli_contract.CommandRecord(
path="cite id",
summary="Print the deterministic footnote id `cite add` would use for this (title, file) pair.",
synopsis=(cli_contract.Variant(usage='cite id --title "Source - X" [--file <qualifier>]'),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
),
notes="Read-only preview - does not check the id is actually free on any given page. Never "
"fails. Safe to retry freely. Exempt from the Iteration Budget Gate",
failures=(),
))
def cite_id_command(
title: str = typer.Option(..., "--title", help="Source page title, e.g. 'Source - Docker Cheatsheet'"),
file: Optional[str] = typer.Option(None, "--file", help="Qualifier for a multi-file source, e.g. 'storage-model.md'"),
@@ -86,6 +100,30 @@ def upsert_citation(page: Page, source_title: str, qualifier: Optional[str]) ->
@app.command("add")
@cli_contract.record(cli_contract.CommandRecord(
path="cite add",
summary="Upsert a `[^cite-id]: [[Source - X]]` definition in a page's footnotes region.",
synopsis=(cli_contract.Variant(
usage='cite add --page "<Title>" --source "Source - X" [--file <qualifier>] [--dry-run]',
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Yes - single file write",
budget=cli_contract.Budget.COUNTED,
),
notes="Upsert a `[^cite-id]: [[Source - X]]` definition in the page's generated footnotes "
"region, creating it between `<!-- wikitool:footnotes -->` markers if absent (reusing the "
"id if the page already cites this exact source/file pair) and add `Source - X` to "
"frontmatter `sources:`. Prints the `[^cite-id]` marker - pasting it into the prose is still "
"a manual, editorial step",
failures=(cli_contract.Failure(
label="",
exit_1="Page or source not found",
retry="Safe to retry; upserting the same (page, source, file) pair twice reuses the "
"existing id and changes nothing the second time",
),),
))
def cite_add(
page_title: str = typer.Option(..., "--page", help="Exact title of the page to add a citation on"),
source: str = typer.Option(..., "--source", help="Exact title of the source page being cited, e.g. 'Source - X'"),
@@ -157,6 +195,30 @@ def sync_page(page: Page) -> tuple[str, bool, list[str], list[str]]:
@app.command("sync")
@cli_contract.record(cli_contract.CommandRecord(
path="cite sync",
summary="Reconcile each page's footnotes region against its actual `[^id]` references.",
synopsis=(cli_contract.Variant(
usage='cite sync [--page "<Title>" | --all] [--dry-run]',
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="No - one write per page, each idempotent",
budget=cli_contract.Budget.COUNTED,
),
notes="Prune definitions nothing references any more, re-render the region in "
"first-reference order, and report any `[^id]` reference left with no definition. A page "
"still carrying the pre-4.0.0 undelimited block is converted to a marked region in the same "
"pass - the marker carries the region's identity now, so re-rendering it under this "
"instance's heading is a repair rather than a rename",
failures=(cli_contract.Failure(
label="",
exit_1="Neither or both of `--page`/`--all` given, or page not found",
retry="Safe to retry freely. An undefined-reference report is not a failure - fix the "
"reference (or run `cite add`) and re-run",
),),
))
def cite_sync(
page_title: Optional[str] = typer.Option(None, "--page", help="Sync just this page"),
all_pages: bool = typer.Option(False, "--all", help="Sync every page under kb/"),
+129 -2
View File
@@ -47,6 +47,7 @@ import typer
from chemenu import (
blocks,
cli_contract,
config,
conventions,
kb_collections,
@@ -569,6 +570,50 @@ def _write_plan(target: Path, plan: dict[str, PlannedFile]) -> None:
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@cli_contract.record(cli_contract.CommandRecord(
path="dist export",
summary="Write a contentless, distributable copy of this repo's machinery.",
synopsis=(cli_contract.Variant(
usage="dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] "
"[--release-url U] [--update-url U]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Yes - nothing is written until every file is planned",
budget=cli_contract.Budget.COUNTED,
),
notes="Write a contentless, distributable copy of this repo's machinery into an empty "
"`<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any "
"`<!-- dist:strip-start -->...<!-- dist:strip-end -->` region removed, `instructions/` "
"(minus `instructions/dev/`), `types/` (the `root: kb` page type-specs and their schemas "
"re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no "
"venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus "
"`.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), the two flat anchors "
"`raw/.gitkeep` and `incoming/.gitkeep` (both roots are flat now that a file's location "
"under `raw/` is a date shard rather than a hand-picked type, so a fresh export no longer "
"creates any type subdirectories under either root; `incoming/.gitkeep` is trackable and "
"survives becoming a git repository, so a plain clone gets the directory without any "
"bootstrap step re-creating it), `VERSION`, `USER.md.template`/`SOUL.md.template` plus "
"`kb/CONVENTIONS.md.template` and each collection's contract re-keyed as "
"`kb/<name>/COLLECTION.md.template` (the templates ship; the filled "
"`USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/`types/<page-type>.md` "
"never do - all of them bind their instance and none are the stack's to decide, and "
"`find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp "
"(version, export date, origin, and a sha256 per exported file - the base a later upgrade "
"would compare against). The four origin options only fill stamp fields: `export` never "
"calls git and cannot discover them. Refuses a non-empty target, and a tree with no "
"`VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that "
"reconstructs a distributed instance into a dev instance - work on the stack in the origin "
"repo (or a new dev instance exported from it) instead",
failures=(cli_contract.Failure(
label="",
exit_1="Target exists and is not empty, is not a directory, or the tree has no "
"readable `VERSION`",
retry="Point `<target>` at an empty (or new) directory and retry. Never merge into a "
"non-empty one by hand",
),),
))
@app.command("export")
def export_command(
target: Path = typer.Argument(
@@ -906,6 +951,87 @@ def _report_plan(
console.print("Report only - `dist upgrade` never runs a migration. See `wikitool migrate status`.")
@cli_contract.record(cli_contract.CommandRecord(
path="dist upgrade",
summary="Apply a stack update `dist export` produced - the write half of `version check`.",
synopsis=(cli_contract.Variant(
usage="dist upgrade <source> [--dry-run] [--keep-local] [--take-release <path>]... "
"[--prune] [--pre]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="**Yes for the refusal cases above - nothing is written.** Once writing starts "
"it is a plain sequential file copy with no partial-state cleanup: an interruption "
"mid-copy (killed process, disk full) can leave the tree part-old, part-new",
budget=cli_contract.Budget.COUNTED,
),
notes="Never downloads anything: `<source>` is an already-fetched export directory or "
"`.tar.gz` release archive (verified against a sibling `.sha256` if one is present; WARNs, "
"does not block, if it is absent), which must unpack to exactly one top-level directory - "
"the shape `.gitea/workflows/release.yml` packs. The write set is exactly the *new* "
"`.wikitool-release.json`'s `files` block, minus what an export re-seeds from a blank "
"template every time (`kb/log.md`, `raw/.gitkeep` - `chemenu.ownership.is_export_stub`) or "
"seeds once and the instance owns from then on (`.wikitool-kb.json`, `CHANGES.md` - "
"`chemenu.ownership.is_upgrade_preserved`), plus the stamp itself, always rewritten. Every "
"candidate path is classified against the *local* `.wikitool-release.json`'s recorded "
"digest for it: unchanged is overwritten silently, absent from the old stamp is created, "
"and locally modified or locally deleted is **never** silently overwritten - the run aborts "
"with the full list, and its text names the three answers with the command line already "
"filled in, so that no reader takes any of them for the default. `--keep-local` proceeds "
"and leaves every one of them untouched; `--take-release <path>` (repeatable) writes the "
"release's version over the named path, discarding the local change, and re-creates it if "
"it was locally deleted. The two are decided per path and compose on one call: without "
"`--keep-local`, a locally changed path that no `--take-release` names still aborts the "
"run. A `--take-release` path that this run does not report as locally changed is refused, "
"in a `--dry-run` as well as a writing run - it is a mistake in the argument rather than a "
"state of the tree, and a path that silently did nothing would report a successful upgrade "
"while keeping the change it was asked to discard. After a `--keep-local` run the new stamp "
"is still written whole, so it records the release's digest for files that were "
"deliberately *not* written: the stamp is the baseline for the next comparison, not a "
"literal inventory of what is on disk. That is what keeps a skipped file diverging - and "
"therefore reported - on every later run, rather than quietly reading as current once it "
"has been skipped once. A path taken with `--take-release` is the opposite case and the "
"reason the flag exists: it was written, so it matches the digest the stamp records and "
"stops being reported at all. A path in the old stamp but not the new one is reported as no "
"longer part of the release and left alone unless `--prune` is passed, which removes it "
"only if it is still unchanged since installation. Reports the migration chain the new "
"machinery would owe (`chemenu.kb_state.chain` over the *new* tree's "
"`instructions/migrations/`, read via a `directory` argument to `load_migrations`) but "
"never runs any of it - there is no `migrate run`. Refuses before touching the source at "
"all when: `VERSION` or `.wikitool-release.json` (with a `files` block) is missing locally, "
"`.wikitool-kb.json` is missing, a migration is already outstanding against the *installed* "
"machinery, or the working tree is dirty (not being a git repository at all is a WARN, not "
"a refusal). Refuses after reading the source when: it carries no "
"`VERSION`/`.wikitool-release.json`/`files` block, its version is older than or equal to "
"the installed one (equal is a no-op success), it is a pre-release (`-beta.N`) without "
"`--pre`, or `--take-release` names a path this run does not classify as locally changed. "
"Reports, but does not block on, a crossed compatibility boundary. Never touches git - no "
"commit, no push (invariant 5). The closing report carries no step list of its own: "
"everything after the swap is one order, written in `instructions/upgrade-instance.md`, "
"which the report names and which resumes at `instructions sync`. What a human decides "
"*before* the swap - which release, whether to take it, where the tarball comes from - is "
"`INSTALL.md` § \"Version und Updates\"",
failures=(cli_contract.Failure(
label="",
exit_1="Missing local `VERSION`/`.wikitool-release.json`(`files`)/`.wikitool-kb.json`, "
"a migration already outstanding against the installed machinery, a dirty working "
"tree, a source with no `VERSION`/stamp/`files` block, a source version that is older "
"than, equal to, or (without `--pre`) a pre-release relative to the installed one, a "
"`--take-release` path that is not classified as locally changed (the one refusal a "
"`--dry-run` also raises), or one or more locally changed files that neither "
"`--keep-local` nor a `--take-release` answers for",
retry="For every refusal above: fix the named precondition and retry - none of them are "
"transient. For a rejected `--take-release` path: correct it against the "
"locally-changed list the refusal prints. For locally changed files, the refusal names "
"all three answers with the re-run line filled in - `--take-release <path>` to write "
"the release's version over it (which ends the divergence), `--keep-local` to leave "
"them untouched (repeatable, and it reports the same files again on every subsequent "
"run until they stop diverging), or reconcile by hand and retry. An interrupted write "
"is not resumed automatically; compare the tree against the printed classification and "
"finish or revert by hand",
),),
))
@app.command("upgrade")
def upgrade_command(
source: Path = typer.Argument(
@@ -933,8 +1059,9 @@ def upgrade_command(
False, "--pre", help="Allow a pre-release (-beta.N) source tree - release.yml never publishes one",
),
):
"""Apply a stack update `dist export` produced - the write half of
`version check`. Never downloads anything: `source` is an already-fetched
"""Apply a stack update `dist export` produced - the write half of `version check`.
\f
Never downloads anything: `source` is an already-fetched
export directory or `.tar.gz` archive. Writes exactly the new release
stamp's `files` block, minus what an export re-seeds every time
(`kb/log.md`, `raw/.gitkeep`) or seeds once and the instance owns from
+360 -87
View File
@@ -5,10 +5,11 @@ The wiki's own rule is that a derived copy of recomputable truth must be
checked or absent. Three such copies survive on purpose because they earn
their keep as reading material:
1. `tools/CONTRACT.md`'s two command tables - § Commands and § Error
contracts - each re-derivable from the Typer app and checked
independently, in both directions, so a row surviving in one table
cannot hide its own deletion from the other
1. `tools/CONTRACT.md`'s generated `<!-- wikitool:commands -->` region - one
`cli_contract.CommandRecord` per registered command, re-derivable from
the Typer app and checked in both directions, so a command dropped from
`cli_contract.GROUPS` cannot hide behind a record that still exists, or
the reverse
2. the collection and stage contracts (their existence and placement, not
their content)
3. the absence of pre-type-system `type: entity` frontmatter in the
@@ -58,6 +59,8 @@ Content quality of the contracts themselves stays with the LLM.
"""
from __future__ import annotations
import functools
import inspect
import re
import subprocess
from pathlib import Path
@@ -65,7 +68,7 @@ from typing import Optional
import typer
from chemenu import config, conventions, kb_collections, markdown_code, toc, version as version_mod
from chemenu import blocks, cli_contract, config, conventions, kb_collections, markdown_code, toc, version as version_mod
from chemenu.commands import dist_cmd
from chemenu.commands._util import fail, rel_path, success
@@ -213,36 +216,6 @@ LEGACY_TYPE_RE = re.compile(r"^type:\s*(entity|concept|source|comparison)\s*$",
# First backticked cell of a markdown table row, e.g. "| `xref add --a ...` | ... |"
TABLE_CELL_RE = re.compile(r"^\|\s*`([^`]+)`", re.MULTILINE)
# tools/CONTRACT.md carries two tables whose first cell is a backticked
# command path - § Commands and § Error contracts - and `check_cli_readme`
# must not treat them as one pot (Gitea #91): a row deleted from one used to
# go unnoticed as long as the same name survived in the other, and the
# second table was not enforced against anything at all.
COMMANDS_HEADING = "## Commands"
ERROR_CONTRACTS_HEADING = "## Error contracts"
def section_text(full_text: str, heading: str) -> str:
"""The text of one `##`-level section: from just after `heading`'s own
line up to the next `#`- or `##`-level heading, or the end of the
document.
Raises `ValueError` if `heading` is not found verbatim, rather than
falling back to scanning the whole document - a renamed heading has to
surface as a failure, because silently widening the scope back to
"everything" is exactly the bug this function exists to prevent from
reappearing under a different name.
"""
pattern = re.compile(
r"^" + re.escape(heading) + r"[ \t]*\n(.*?)(?=^#{1,2}[ \t]|\Z)",
re.MULTILINE | re.DOTALL,
)
match = pattern.search(full_text)
if match is None:
raise ValueError(f"no {heading!r} heading found")
return match.group(1)
def registered_commands() -> set[str]:
"""Every command path the CLI exposes, e.g. {'new', 'xref add', ...}.
@@ -276,66 +249,226 @@ def documented_commands(readme_text: str) -> list[str]:
return [match.group(1).strip() for match in TABLE_CELL_RE.finditer(readme_text)]
def check_cli_readme() -> list[str]:
"""Every registered command must appear in tools/CONTRACT.md's own
§ Commands table, and separately in its § Error contracts table - each
direction checked per table, independently of the other.
def check_command_contracts() -> list[str]:
"""Every registered command has exactly one `cli_contract` record, and
`cli_contract.GROUPS` lists exactly the registered commands - no more, no
less, and no path twice.
The two tables used to be read as one pot: `TABLE_CELL_RE` matched a
backticked first cell anywhere in the file, so a row deleted from
§ Commands went unnoticed as long as the same name still had a row in
§ Error contracts, and § Error contracts was never itself compared
against the registered commands (Gitea #91). `section_text` scopes each
table to the text between its own `##` heading and the next one, and
raises rather than silently scanning the whole file if a heading has been
renamed or removed - a renamed heading must be reported, not read as
"table now empty" or "table now everything".
Within a section, only the first backticked cell of each row is read -
a changed flag or a rewritten description in an existing row is invisible
to this check, on purpose: it verifies presence, never prose.
The reverse check matches a documented cell against the full registered
command path (e.g. `xref add`, `migrate verify`), not just its first
token - checking only the top-level word would let a typo'd or invented
subcommand (`xref frobnicate`) sit undetected next to a real command group
(`xref`) forever.
Gitea #121 phase 1 replaced the two hand-read tables (§ Commands,
§ Error contracts) with one data record per command, attached to its
function by `@cli_contract.record(...)`. A record with no matching
command, a command with no record, or a `GROUPS` entry appearing twice
are the three ways that pairing can drift; each is its own issue so a
session sees exactly which command needs attention.
"""
if not CLI_README.exists():
return [f"{CLI_README.relative_to(config.ROOT)} is missing"]
text = CLI_README.read_text(encoding="utf-8")
registered = sorted(registered_commands())
issues: list[str] = []
for heading, label in (
(COMMANDS_HEADING, "§ Commands"),
(ERROR_CONTRACTS_HEADING, "§ Error contracts"),
):
try:
section = section_text(text, heading)
except ValueError as exc:
seen: set[str] = set()
for path in cli_contract.grouped_paths(cli_contract.GROUPS):
if path in seen:
issues.append(f"cli_contract.GROUPS lists `{path}` more than once")
seen.add(path)
grouped = set(cli_contract.grouped_paths(cli_contract.GROUPS))
for command_path in registered:
if cli_contract.get(command_path) is None:
issues.append(
f"tools/CONTRACT.md: {exc} - its {label} table cannot be checked against the CLI"
f"command `{command_path}` has no cli_contract record - decorate its function "
"with @cli_contract.record(...)"
)
if command_path not in grouped:
issues.append(
f"command `{command_path}` is not listed in cli_contract.GROUPS - it has no "
"place to render in tools/CONTRACT.md's generated region"
)
continue
cells = documented_commands(section)
for path in sorted(grouped):
if path not in registered:
issues.append(
f"cli_contract.GROUPS lists `{path}`, which is not a registered wikitool command"
)
for command_path in registered:
if not any(cell == command_path or cell.startswith(command_path + " ") for cell in cells):
return issues
COMMANDS_REGION = "commands"
def check_commands_region() -> list[str]:
"""`tools/CONTRACT.md`'s `<!-- wikitool:commands -->` region matches
`cli_contract.render_commands_region()` byte for byte - the same
generated-region drift check `check_toc_regions()` runs for the table of
contents, applied to the command reference itself."""
if not CLI_README.exists():
return [f"{rel_path(CLI_README)} is missing"]
text = CLI_README.read_text(encoding="utf-8")
existing = blocks.find(text, COMMANDS_REGION)
expected = cli_contract.render_commands_region(groups=cli_contract.GROUPS).strip("\n")
if existing is None:
return [
f"{rel_path(CLI_README)} is missing its <!-- wikitool:commands --> region - run "
"`wikitool docs contract --apply`"
]
if existing.strip("\n") != expected:
return [
f"{rel_path(CLI_README)}'s <!-- wikitool:commands --> region is stale - run "
"`wikitool docs contract --apply`"
]
return []
# 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`.
FLAG_TOKEN_RE = re.compile(r"(?<![\w-])(--[a-zA-Z][a-zA-Z0-9-]*|-[a-zA-Z])(?![\w-])")
@functools.lru_cache(maxsize=1)
def _leaf_click_commands() -> list[tuple[str, object]]:
"""(path, click.Command) for every leaf command the built CLI exposes,
dotted the same way `registered_commands()` names a path (`"xref add"`).
Built from the real Click command tree (`typer.main.get_command`) rather
than from Typer's own `registered_commands`/`registered_groups`, because
only the Click tree carries each option's actual flag strings
(`param.opts`/`param.secondary_opts`) - what `check_synopsis_flags` and
`check_no_issue_references_in_help` both need to read. Cached: `verify()`
calls both checks in the same run, and the app it walks is immutable
within a process - a test replacing this name with its own function
bypasses the cache entirely rather than needing to clear it.
"""
import typer as _typer
from chemenu import cli
root = _typer.main.get_command(cli.app)
found: list[tuple[str, object]] = []
def walk(command: object, prefix: list[str]) -> None:
sub = getattr(command, "commands", None)
if sub:
for name, child in sub.items():
walk(child, prefix + [name])
else:
found.append((" ".join(prefix), command))
walk(root, [])
return found
def _group_click_commands() -> list[tuple[str, object]]:
"""(path, click.Group) for every intermediate group the built CLI
exposes (`"xref"`, `"task"`, ...), the complement of
`_leaf_click_commands()`. A group carries no `cli_contract` record of its
own - `wikitool xref -h` falls through to Click's default listing - but
its own `help=` string is still rendered there, so
`check_no_issue_references_in_help` needs this set too, not just the
leaves."""
import typer as _typer
from chemenu import cli
root = _typer.main.get_command(cli.app)
found: list[tuple[str, object]] = []
def walk(command: object, prefix: list[str]) -> None:
sub = getattr(command, "commands", None)
if not sub:
return
if prefix:
found.append((" ".join(prefix), command))
for name, child in sub.items():
walk(child, prefix + [name])
walk(root, [])
return found
def check_synopsis_flags() -> list[str]:
"""Every non-hidden flag a command's Click definition carries appears in
its `cli_contract` record's SYNOPSIS, and every flag a SYNOPSIS names is
a real flag of that command.
A boolean flag pair (`--push`/`--no-push`) is satisfied by documenting
either spelling - the SYNOPSIS convention this repo already used before
this check existed (`publish`'s `[--no-push]`). `hidden=True` does not
count (`publish --yes`), matching the design this check implements
(Gitea #121 B3).
"""
issues: list[str] = []
for path, command in _leaf_click_commands():
rec = cli_contract.get(path)
if rec is None:
continue # reported by check_command_contracts
synopsis_text = " ".join(variant.usage for variant in rec.synopsis)
documented = {m.group(0) for m in FLAG_TOKEN_RE.finditer(synopsis_text)}
all_flags: set[str] = set()
for param in getattr(command, "params", []):
opts = list(getattr(param, "opts", []) or [])
secondary = list(getattr(param, "secondary_opts", []) or [])
group = [o for o in (opts + secondary) if o.startswith("-") and o not in ("--help", "-h")]
all_flags.update(group)
if not group or getattr(param, "hidden", False):
continue
if not any(o in documented for o in group):
issues.append(
f"command `{command_path}` is not documented in tools/CONTRACT.md's {label} table"
f"`{path}`: flag `{group[0]}` is not documented in its cli_contract "
"SYNOPSIS"
)
for cell in cells:
if not any(cell == cp or cell.startswith(cp + " ") for cp in registered):
first_token = cell.split(" ", 1)[0]
for flag in sorted(documented - all_flags):
issues.append(
f"`{path}`: its cli_contract SYNOPSIS mentions `{flag}`, which is not a real "
"flag of this command"
)
return issues
def check_no_issue_references_in_help() -> list[str]:
"""No command's rendered `--help`/`-h` text - its docstring above `\\f`,
or any option's `help=` text - cites an issue number.
Mirrors `check_no_issue_references()`'s reasoning for shipped `.md`
files, one level down: `tools/` ships as runtime machinery to every
distributed instance (`instructions/dev/` is excluded, not `tools/`), so
an issue number baked into a command's own `--help` output reaches a
reader with no tracker to resolve it against. `\\f` is what Click itself
uses to separate the rendered part of a docstring from maintenance text
below it (`click.Command.format_help_text`); this check applies the same
split before scanning; the `\\f` decides for our SYNOPSIS/PROPERTIES/...
renderer too.
"""
issues: list[str] = []
for path, command in _leaf_click_commands():
help_text = getattr(command, "help", None) or ""
rendered = inspect.cleandoc(help_text).partition("\f")[0]
for match in ISSUE_REFERENCE_RE.finditer(rendered):
issues.append(
f"`{path}` --help text cites `{match.group()}` above its `\\f` marker - the "
"tracker exists only in the origin repo"
)
for param in getattr(command, "params", []):
help_str = getattr(param, "help", None) or ""
for match in ISSUE_REFERENCE_RE.finditer(help_str):
flag = (list(getattr(param, "opts", []) or []) or [param.name])[0]
issues.append(
f"tools/CONTRACT.md's {label} table documents `{cell}`, but `{first_token}` "
"is not a wikitool command"
f"`{path}` option `{flag}` help text cites `{match.group()}` - the tracker "
"exists only in the origin repo"
)
for path, group in _group_click_commands():
help_text = getattr(group, "help", None) or ""
rendered = inspect.cleandoc(help_text).partition("\f")[0]
for match in ISSUE_REFERENCE_RE.finditer(rendered):
issues.append(
f"`{path}` group's --help text cites `{match.group()}` above its `\\f` marker "
"- the tracker exists only in the origin repo"
)
return issues
@@ -901,10 +1034,65 @@ def check_breaking_change_for_boundary() -> list[str]:
@app.command("verify")
@cli_contract.record(cli_contract.CommandRecord(
path="docs verify",
summary="Check the docs that mirror the code.",
synopsis=(cli_contract.Variant(usage="docs verify"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.COUNTED,
),
notes="Check the docs that mirror the code: every command has a `cli_contract` record and "
"is listed in `cli_contract.GROUPS` (both directions, so a command dropped from one is not "
"hidden by the other), every command's non-hidden flags appear in its record's SYNOPSIS and "
"vice versa, `tools/CONTRACT.md`'s generated `<!-- wikitool:commands -->` region matches "
"what `cli_contract.render_commands_region()` would write, no command's rendered "
"`--help`/`-h` text cites an issue number, every directory under `kb/` has a "
"`COLLECTION.md` and no directory outside it does, every collection declaring `profile:` "
"and a `required_by_stack:` that agrees with the stack's own list, every type the stack "
"lists (currently `source` and `project`) having a type-spec of that name whose schema "
"requires the field the stack list also names (`raw_files:`/`state:`), `kb/CONVENTIONS.md` "
"naming all three tool-owned section headings if it exists at all, every stage contract "
"present, every file under `types/` declaring `type: types/type-spec.md` validating "
"against `types/type-spec.schema.yaml`, no pre-migration `type: entity` blocks left in the "
"contracts, the `.gitignore` canaries clear in both directions (nothing ignored under "
"`raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published "
"skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue "
"number - the tracker exists only in the origin repo, so such a number in a distributed "
"instance is a reference its reader can neither resolve nor recognise as unresolvable (a "
"`<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the "
"check reads, which is the export plan's, not the working tree's), every reference file "
"`docs toc` covers carrying the current table-of-contents region for its own headings - "
"missing and stale are one check, because the generator is idempotent - and every relative "
"markdown link in one of those same reference files resolving to a file that actually "
"exists (a target's `#anchor` suffix is stripped first; code fences and inline code spans "
"are masked before scanning, so a passage showing link syntax as an example is not mistaken "
"for a real reference). The name is about documentation parity, not about the `docs/` "
"directory - it neither reads nor requires one, the same way `kb/` predates the collection "
"it now checks",
failures=(cli_contract.Failure(
label="",
exit_1="A command, contract, or type-form mismatch was found, a type-spec's own "
"frontmatter fails its schema, a shipped `.md`/`.template` cites an issue number, a "
"reference file's table-of-contents region is missing or stale, or a reference file's "
"relative markdown link does not resolve to an existing file",
retry="Fix the documentation it names, then re-run. For a type-spec's own frontmatter: "
"fix the field, or add a matching line to `types/type-spec.schema.yaml` if the field is "
"legitimately new. For an issue reference: say what was decided instead of pointing at "
"where, or move the pointer behind a `<!-- dist:strip-start/end -->` block. For a table "
"of contents: run `docs toc --apply` - never hand-write the region. For a dead link: "
"fix the `../` count or the target's name",
),),
))
def verify():
"""Check the CLI/README command tables, contract presence, type-form drift, every type-spec's frontmatter against its own schema, ignore rules, version/changelog agreement, issue references, and link targets in shipped documents."""
"""Check the docs that mirror the code."""
issues = (
check_cli_readme()
check_command_contracts()
+ check_commands_region()
+ check_synopsis_flags()
+ check_no_issue_references_in_help()
+ check_readmes_have_no_command_table()
+ check_collection_contracts()
+ check_type_spec_frontmatter()
@@ -924,12 +1112,12 @@ def verify():
from chemenu.type_resolver import resolver
success(
f"Docs verified: {len(registered_commands())} command(s) documented, "
f"Docs verified: {len(registered_commands())} command(s) with a cli_contract record, "
f"{len(kb_collections.iter_kb_collections())} collection(s) and "
f"{len(STAGE_CONTRACTS)} stage contract(s) present, no legacy type blocks, "
f"{len(resolver.list_type_specs())} type-spec(s) validating against their own schema, "
f"{len(IGNORE_CANARIES)} ignore canaries clear, "
f"no issue references in {len(shipped_prose())} shipped document(s), "
f"no issue references in {len(shipped_prose())} shipped document(s) or command help, "
f"tables of contents current and every link resolving on "
f"{len(toc.target_files())} reference file(s), "
f"{version_mod.CHANGES_FILENAME} documents version "
@@ -938,12 +1126,52 @@ def verify():
@app.command("toc")
@cli_contract.record(cli_contract.CommandRecord(
path="docs toc",
summary="Create, refresh or remove the generated table-of-contents region.",
synopsis=(cli_contract.Variant(usage="docs toc [--apply]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="`--apply` rewrites each named file in place, one at a time and idempotently, "
"so a re-run after an interruption converges rather than doubling a region; the "
"dry-run form is read-only",
budget=cli_contract.Budget.COUNTED,
),
notes="On every reference file over 100 lines, in the scope Anthropic's skill-authoring "
"guidance names for a file previewed rather than read in full: `AGENTS.md`, every stage "
"contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat "
"`instructions/**.md` file, every `types/*.md` type-spec, and every `docs/` page - each "
"together with the `<name>.template` it ships as, where one exists. Computed from those "
"categories rather than listed, so a file added later is in scope without a code change. A "
"template is in scope because it is the same document one step earlier in its life: an "
"instance adopts it by copying it back, so a region missing there is a region missing in "
"the adopted file, which is how `kb/CONVENTIONS.md.template` came to grow past the "
"threshold with no region and left every instance adopting it failing `docs verify` at the "
"end of its own setup. `SKILL.md` is the one exception, and the same guidance is why: it "
"places a skill body on the loading level that is read whole when the skill triggers, and "
"aims its own TOC advice at the bundled reference files a skill points *at*. Human docs "
"(`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`, `tools/README.md`) are out of scope "
"because AGENTS.md § File naming says no agent loads them as instruction. Dry-run by "
"default (prints which files would change); `--apply` writes. `docs verify` checks the "
"result stays current the same way it checks every other generated-from-code copy",
failures=(cli_contract.Failure(
label="",
exit_1="Never fails on content: a file with no `##` heading, or one at or under the "
"threshold, is simply left without a region",
retry="Nothing to fix - re-run with `--apply` to write what the dry run listed. If "
"`docs verify` still reports a stale region afterwards, the file's `##` headings "
"changed in between; run it again",
),),
))
def toc_command(
apply: bool = typer.Option(False, "--apply", help="Write changes; default is dry-run (preview only)"),
):
"""Create, refresh or remove the generated table-of-contents region on
every reference file `toc.target_files()` covers - AGENTS.md, the stage
and collection contracts, and every flat `instructions/**.md` file."""
"""Create, refresh or remove the generated table-of-contents region.
\f
On every reference file `toc.target_files()` covers - AGENTS.md, the
stage and collection contracts, and every flat `instructions/**.md`
file."""
changed = []
for path in toc.target_files():
before = path.read_text(encoding="utf-8")
@@ -964,3 +1192,48 @@ def toc_command(
success(f"Refreshed the table of contents on {len(changed)} file(s).")
else:
typer.echo(f"\n{len(changed)} file(s) would change. Re-run with --apply to write.")
@app.command("contract")
@cli_contract.record(cli_contract.CommandRecord(
path="docs contract",
summary="Regenerate `tools/CONTRACT.md`'s `<!-- wikitool:commands -->` region.",
synopsis=(cli_contract.Variant(usage="docs contract [--apply]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Yes - the whole region is rewritten in one file write",
budget=cli_contract.Budget.COUNTED,
),
notes="Rebuilds the region from `cli_contract.all_records()`: the index (one line per "
"command, `GROUPS` order) followed by each `###`-group's commands as `#### <path>` "
"man-page-shaped sections. Dry-run by default, like `docs toc`; `--apply` writes. "
"`docs verify`'s `check_commands_region` checks the result stays current the same way it "
"checks every other generated-from-code copy.",
failures=(),
))
def contract_command(
apply: bool = typer.Option(False, "--apply", help="Write changes; default is dry-run (preview only)"),
):
"""Regenerate `tools/CONTRACT.md`'s `<!-- wikitool:commands -->` region."""
if not CLI_README.exists():
fail(f"{rel_path(CLI_README)} is missing.")
text = CLI_README.read_text(encoding="utf-8")
content = cli_contract.render_commands_region(groups=cli_contract.GROUPS).strip("\n")
region = (
blocks.open_marker(COMMANDS_REGION) + "\n" + content + "\n" + blocks.close_marker(COMMANDS_REGION)
)
after = blocks.replace(text, COMMANDS_REGION, region)
if after == text:
success(f"{rel_path(CLI_README)}'s command region is already current.")
return
if not apply:
typer.echo(f"{rel_path(CLI_README)} would change.")
typer.echo("Re-run with --apply to write.")
return
CLI_README.write_text(after, encoding="utf-8")
success(f"Regenerated the command region in {rel_path(CLI_README)}.")
+52 -7
View File
@@ -20,7 +20,7 @@ from typing import Optional
import typer
from rich.console import Console
from chemenu import config, conventions, kb_collections, version as version_mod
from chemenu import cli_contract, config, conventions, kb_collections, version as version_mod
from chemenu.commands import git_publish, instructions_cmd
from chemenu.commands._util import rel_path
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
@@ -617,15 +617,60 @@ def run_doctor() -> list[Check]:
return checks
@cli_contract.record(cli_contract.CommandRecord(
path="doctor",
summary="Check that this instance is correctly configured.",
synopsis=(cli_contract.Variant(usage="doctor [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
network=cli_contract.Network.YES,
),
notes="Dependencies (Python, ripgrep), author resolution, stack version, git "
"identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, "
"personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the "
"template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB "
"conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned "
"section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), "
"the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated "
"one is a `WARN`), generated files, whether the MCP `submit` tool is armed "
"(`.wikitool-upload.json` present/absent/malformed, its limits, and how many submissions "
"are waiting in `mcp-upload/` - absent is `OK` and means the write path does not exist at "
"all, malformed is the one `FAIL` here, since a broken opt-in must not silently disable the "
"limits it exists to enforce), the task-tracker provider (`.wikitool-tasks.json` "
"present/absent/malformed - absent is `OK` and means no tracker is configured, malformed is "
"`FAIL` for the same reason the upload opt-in is; for a configured `superproductivity` "
"provider, also its configured `access` path's own state - `access: \"api\"` reports "
"whether its local REST API answers `GET /health` right now, `access: \"snapshot\"` reports "
"whether a backup file is ready; the *other* access path is never attempted and is not a "
"finding - and neither ever `FAIL`s, an app that is simply not running is not a fault; for "
"a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - "
"also never a `FAIL`, only a broken config block is), the session id source (`OK` for "
"`WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare "
"parent-pid fallback - see `chemenu.session`), and telemetry state (on/off, why - "
"installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current "
"session count/byte total against both caps; never `FAIL`, see `EVALS.md`). Read-only, exit "
"1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). "
"Exempt from the Iteration Budget Gate",
failures=(cli_contract.Failure(
label="",
exit_1="At least one check reported `FAIL` (a `WARN`, e.g. no remote or no "
"`WIKITOOL_SESSION_ID`, does not exit 1)",
retry="Each finding names its own fix command; re-run after applying it",
),),
))
def doctor_command(
json_out: bool = typer.Option(False, "--json", help="Print the checks as JSON"),
):
"""Check that this instance is correctly configured: dependencies, author,
git identity/remote, published skills, structure, personalization, KB
conventions, generated files, session scoping, telemetry state, whether
the MCP `submit` tool is armed, and which task-tracker provider (if any)
is configured for the GTD review. Read-only. Exits 1 only if a check
FAILs."""
"""Check that this instance is correctly configured.
\f
Dependencies, author, git identity/remote, published skills, structure,
personalization, KB conventions, generated files, session scoping,
telemetry state, whether the MCP `submit` tool is armed, and which
task-tracker provider (if any) is configured for the GTD review.
Read-only. Exits 1 only if a check FAILs."""
checks = run_doctor()
if json_out:
+42 -1
View File
@@ -13,7 +13,7 @@ from typing import Optional
import typer
from chemenu import config
from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success
from chemenu.evals import scorecard
from chemenu.session import session_id as current_session_id
@@ -26,6 +26,20 @@ EVALS_DIR = config.REPORTS_DIR / "evals"
@app.command("sessions")
@cli_contract.record(cli_contract.CommandRecord(
path="eval sessions",
summary="List the sessions that have a trace under `reports/telemetry/`.",
synopsis=(cli_contract.Variant(usage="eval sessions [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
),
notes="Most recent first. Read-only and exempt from the Iteration Budget Gate. Never fails; "
"an empty list is a valid answer.",
failures=(),
))
def sessions_command(
json_out: bool = typer.Option(False, "--json", help="Print the list as JSON"),
):
@@ -44,6 +58,33 @@ def sessions_command(
@app.command("score")
@cli_contract.record(cli_contract.CommandRecord(
path="eval score",
summary="Score one traced session.",
synopsis=(cli_contract.Variant(
usage="eval score [--session <id>] [--json] [--markdown out.md] [--save] "
"[--fail-on-error]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only, apart from the files `--save`/`--markdown` write",
budget=cli_contract.Budget.EXEMPT,
),
notes="Structural state from `lint`'s own checks (L1) plus trajectory rules over the trace "
"(L2) - was a refused call repeated unchanged, was a gate flag passed without that gate "
"having refused anything, did a publish of `kb/` pages go unlogged. Defaults to the current "
"session. `--save` writes `reports/evals/<date>/<session>.{json,md}`. Read-only over `kb/` "
"and exempt from the budget; see `EVALS.md`",
failures=(cli_contract.Failure(
label="",
exit_1="No trace exists for the named session",
retry="Run `eval sessions` to see which ids exist. A session records nothing when "
"telemetry is off - `WIKI_TRACE=0`, or a distributed instance with no opt-in "
"(`wikitool doctor` says which) - so an absent trace is not necessarily a fault. Safe "
"to retry",
),),
))
def score_command(
session: Optional[str] = typer.Option(
None, "--session",
+110 -1
View File
@@ -56,7 +56,7 @@ from typing import NamedTuple, Optional
import typer
from chemenu import config
from chemenu import cli_contract, config
from chemenu.commands._util import fail, needs_clearance, success
from chemenu.telemetry import emit
@@ -999,6 +999,38 @@ def apply_reconcile(outcome: ReconcileOutcome, remote: str, branch: str, command
return _reconcile_summary(outcome, remote, branch)
@cli_contract.record(cli_contract.CommandRecord(
path="sync",
summary="Fetch `<remote>/<branch>` and bring the local branch up to date with it.",
synopsis=(cli_contract.Variant(
usage="sync [--remote origin] [--branch main] [--confirm-rebase TOKEN]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="No - fetch, then at most one merge/rebase attempt, aborted cleanly on failure",
budget=cli_contract.Budget.COUNTED,
gates=("rebase-review",),
),
notes="Fetch `<remote>/<branch>` and bring the local branch up to date with it: fast-forward "
"when the remote is simply ahead, rebase local commit(s) on top when both sides moved but "
"touch disjoint files (a content conflict is then impossible by construction), and exit "
"**42** for review when they touch the same file (the **rebase-review gate** - see "
"`publish` below). Never commits, never pushes, never force-anything - no remote configured, "
"or one that cannot be reached, is reported and skipped, not a failure. Meant to run once "
"at the start of a writing session (`instructions/session-setup.md`) so the rest of it "
"works against a current tree instead of discovering the drift at the final `publish`",
failures=(cli_contract.Failure(
label="",
exit_1="The automatic rebase hit a real conflict (git failed)",
retry="For a conflict: **do not retry, do not force** - resolve manually and re-run. "
"**Exit 42, not 1**, when the rebase-review gate needs clearance: show the user the "
"command's full output verbatim (upstream commits, the overlapping files, their diff) "
"and stop; re-running with `--confirm-rebase <token>` clears it, and a wrong, invented, "
"or superseded token exits 42 again with the current state. No remote configured, or "
"one that cannot be reached, is not a failure - reported and skipped",
),),
))
def sync_command(
remote: str = typer.Option("origin", "--remote", help="Git remote to reconcile against"),
branch: str = typer.Option("main", "--branch", help="Branch to reconcile"),
@@ -1020,6 +1052,83 @@ def sync_command(
success(summary or f"Nothing to reconcile against {remote}/{branch}.")
@cli_contract.record(cli_contract.CommandRecord(
path="publish",
summary="Reconcile with `<remote>/<branch>`, then stage all changes, commit, and push.",
synopsis=(cli_contract.Variant(
usage='publish --message "<op>: <desc>" [--no-push] [--confirm TOKEN] '
"[--confirm-rebase TOKEN] [--threshold N] [--remote origin] [--branch main] "
"[--path P ...]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="No - sequential git operations, but both gates run before staging",
budget=cli_contract.Budget.COUNTED,
gates=("mass-update", "publish-remote", "rebase-review"),
),
notes="Reconcile with `<remote>/<branch>` exactly like `sync` (skipped for `--no-push`), "
"then stage all changes, commit, and push. Refuses before staging anything when the push "
"target is not the checked-out branch, so a `git push <branch>` cannot quietly publish a "
"ref other than the commit just made; the *unborn* branch of a fresh `git init -b main` "
"counts as checked out, which is what lets the first publish of a new instance work "
"(`instructions/setup-instance.md` step 14), while a genuine detached HEAD is still "
"refused. If the reconcile step found a still-unpushed local commit and there is nothing "
"new to stage, that commit is pushed anyway - a previous `publish` whose push failed no "
"longer strands it, and neither does a branch the remote has never seen (a newly created, "
"empty remote repository). A remote that cannot be reached at all is deliberately not read "
"that way: it keeps reporting \"Nothing to commit\" on a clean tree rather than attempting "
"a push, so an offline or local-only instance is unaffected. If the push is rejected "
"despite the pre-check (a genuine race - something landed on the remote in between), one "
"more reconcile-and-retry is attempted before giving up; never more than one. "
"**Mass-Update Gate:** when >= `--threshold` (default 10) *counted* files would be "
"committed, exits **42 (`EXIT_NEEDS_CLEARANCE`)** instead of publishing - a third outcome "
"distinct from success (0) and a validation error (1) - and prints a review report: a scale "
"line (file count, total lines added/removed, status breakdown), only-what-applies "
"attention notes (deletions by name, control-plane and harness-config touches, published "
"pages, the largest single change, binaries), and every counted path grouped by area with "
"its status and churn, generated files split out as needing no review. The token digests "
"each counted path **and its contents** plus the publish target, so a clearance carries "
"neither to a different file list nor to edited contents; a wrong, invented or superseded "
"token exits 42 again with the current state. Two kinds of path are committed but never "
"counted and never shown for approval: anything under `work/`, and the files `wikitool` "
"generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`) - each "
"is recomputable from the tree, so approving it decides nothing, and a routine ingest "
"rebuilds five or six of them. The refusal line accounts for both, by reason. The gate is "
"evaluated *before* anything is staged, so a refused publish leaves the working tree "
"untouched. **Publish-Remote Gate:** when this checkout carries a "
"`.wikitool-remotes.json` and the resolved push URL of `--remote` is not listed in it, "
"exits **42** before the reconcile step even fetches - the URL is read from "
"`git remote get-url --push`, so a repointed remote does not pass on its name. Unlike the "
"other two gates it has **no token and no flag**: the way past it is the user adding the "
"URL to that file, and an agent editing it to get past a refusal is opening a gate on its "
"own initiative. Absent file means unrestricted; a malformed one is an error, not "
"permission. See `instructions/gates.md`. `--yes`/`-y` are gone and now fail with an "
"explicit error. `--path` (repeatable) scopes the whole operation - gate count, staging, "
"and commit - to a subtree. **Stack-machinery note:** after a successful commit/push whose "
"changed files include `tools/`, `types/`, `instructions/`, `AGENTS.md`, or a path ending "
"`CONTRACT.md` - roughly the scope a stack version bump covers, deliberately a shade "
"broader than CI's version gate, which matches only a `CONTRACT.md` one segment deep - "
"prints one reminder line that the phase past this point (an issue-body rewrite, `docs/` "
"staleness, a changelog entry's accuracy) is not covered by `docs verify`, "
"`instructions verify` or `pytest`. Not a gate: no exit code change, nothing to clear, "
"silent for an ordinary content publish",
failures=(cli_contract.Failure(
label="",
exit_1="git failed, the push target is not the checked-out branch (including a real "
"detached HEAD - but *not* the unborn branch of a fresh `git init`, which is a normal "
"first publish), **or** `--yes`/`-y` was passed. **Exit 42, not 1**, when the "
"Mass-Update Gate, the rebase-review gate (raised by the same reconcile `sync` "
"performs), or the Publish-Remote Gate refuses",
retry="For git failures: **do not retry, do not force** - report and ask the user (the "
"reconcile step already retried the push once on its own, if a rebase resolved the "
"rejection). For exit 42: show the user the command's full output verbatim and stop; it "
"names the evidence and the `--confirm <token>` or `--confirm-rebase <token>` line to "
"re-run, and re-running without it exits 42 again. The Publish-Remote Gate is the "
"exception with no such line: it names the push URL that would have been written to and "
"the ones this checkout allows, and only the user resolves it",
),),
))
def publish_command(
message: str = typer.Option(..., "--message", help="Commit message summary, e.g. 'ingest: docker-cheatsheet'"),
push: bool = typer.Option(True, "--push/--no-push"),
+25 -1
View File
@@ -23,7 +23,7 @@ from pathlib import Path
import typer
from chemenu import config
from chemenu import cli_contract, config
from chemenu.catalog import SHARD_THRESHOLD, Area, Collection, group_pages
from chemenu.commands._util import rel_path, success
from chemenu.page import Page
@@ -217,11 +217,35 @@ def build_index(kb_dir: Path) -> str:
@app.command("rebuild")
@cli_contract.record(cli_contract.CommandRecord(
path="index rebuild",
summary="Regenerate the catalog from every page's frontmatter.",
synopsis=(cli_contract.Variant(usage="index rebuild [--dry-run]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="No - each catalog file (the map plus one shard per collection/area) is "
"rewritten independently, then stale shards are removed; an interruption can leave "
"some regenerated and others not",
budget=cli_contract.Budget.COUNTED,
),
notes="`kb/index.md` becomes a map (statistics, one row per collection and per area, links "
"to the shards) and the page tables are written to a generated `INDEX.md` in each "
"collection. An area past 50 rows gets its own shard. Stale shards from removed "
"collections/areas are deleted in the same pass",
failures=(cli_contract.Failure(
label="",
exit_1="Rare I/O error only",
retry="Safe to retry freely - the plan is always recomputed from the pages currently "
"on disk, so a re-run converges",
),),
))
def index_rebuild(
dry_run: bool = typer.Option(
False, "--dry-run", help="Print what would be written instead of writing it"
),
):
"""Regenerate the catalog from every page's frontmatter."""
# Reported, not refused (#57 decision): a nested page still gets a catalog
# written for it, just a wrong one (folded into its area, no distinct
# location of its own) - `wikitool lint`'s `nested_pages` is the hard
+73 -1
View File
@@ -44,7 +44,7 @@ from pathlib import Path
import typer
import yaml
from chemenu import config, markdown_code
from chemenu import cli_contract, config, markdown_code
from chemenu.commands import dist_cmd, docs_verify
from chemenu.commands._util import fail, rel_path, success
from chemenu.type_resolver import resolver
@@ -366,6 +366,32 @@ def check_skill_reference_paths() -> list[str]:
@app.command("sync")
@cli_contract.record(cli_contract.CommandRecord(
path="instructions sync",
summary="Publish every `instructions/<name>/SKILL.md` into the harness skill directories.",
synopsis=(cli_contract.Variant(usage="instructions sync [--force]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="No - one directory copy per skill per target (`.agents/skills/`, "
"`.claude/skills/`); each copy is idempotent, so a re-run converges even after a "
"partial failure",
budget=cli_contract.Budget.COUNTED,
),
notes="Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and "
"`.claude/skills/` as **copies**, and delete published skills whose source is gone. Both "
"targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. "
"Re-running is also how a drifted copy is repaired: the source always wins. `--force` is "
"required only to replace a target directory that is not a published skill at all (no "
"`SKILL.md` in it)",
failures=(cli_contract.Failure(
label="",
exit_1="No skills found under `instructions/`, or a target directory is not a "
"published skill (no `SKILL.md`) and `--force` was not passed",
retry="Check whether the flagged target holds anything worth keeping, then re-run with "
"`--force` if not; otherwise fix the named cause and retry",
),),
))
def sync(
force: bool = typer.Option(
False,
@@ -402,6 +428,38 @@ def sync(
@app.command("verify")
@cli_contract.record(cli_contract.CommandRecord(
path="instructions verify",
summary="Check the instruction layer.",
synopsis=(cli_contract.Variant(usage="instructions verify"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.COUNTED,
),
notes="Flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` "
"carries the frontmatter its harness reads, no `SKILL.md` carries a relative markdown link "
"(`sync` copies it to a different depth than the source, so a `SKILL.md` references a "
"target as a repo-root-relative plain path instead - see `instructions/CONTRACT.md` § \"A "
"skill's outbound reference is a plain path, not a link\"), every published copy is "
"byte-identical to its source, no instruction is left that nothing references, and nothing "
"under `instructions/dev/` is referenced from outside it (a "
"`<!-- dist:strip-start/end -->` block is exempt - see `instructions/CONTRACT.md`). Missing "
"*every* copy is reported as \"run sync\", not as drift - that is a clean checkout",
failures=(cli_contract.Failure(
label="",
exit_1="Nothing found under `instructions/` at all, a malformed instruction or "
"`SKILL.md`, a `SKILL.md` carrying a relative markdown link, a published copy that "
"drifted from its source, an instruction nothing references (or, for `manual: true`, "
"one that IS linked from AGENTS.md, CLAUDE.md, or a skill and so risks running "
"implicitly), or something under `instructions/dev/` referenced from outside it and "
"outside a `dist:strip` block",
retry="Fix the flagged file, then re-run. For a relative link in a `SKILL.md`, rewrite "
"it as a repo-root-relative plain path instead. For drift, re-run `sync` instead of "
"hand-editing the published copy - the source under `instructions/` always wins",
),),
))
def verify():
"""Check instructions/ against its type, that no skill carries a relative markdown link, and every published copy against its source."""
sources = skill_dirs()
@@ -528,6 +586,20 @@ def verify():
@app.command("list")
@cli_contract.record(cli_contract.CommandRecord(
path="instructions list",
summary="List the flat instructions with their descriptions.",
synopsis=(cli_contract.Variant(usage="instructions list [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.COUNTED,
),
notes="This is how the layer is discovered; `search` deliberately covers `kb/` only. Never "
"fails - an empty `instructions/` prints \"No instructions found.\" Safe to retry freely.",
failures=(),
))
def list_instructions(
json_out: bool = typer.Option(False, "--json", help="Print the listing as JSON"),
):
+23 -1
View File
@@ -17,7 +17,7 @@ from typing import Optional
import typer
from chemenu import config, links
from chemenu import cli_contract, config, links
from chemenu.commands._util import console, fail
from chemenu.kb_scan import load_kb_pages
from chemenu.page import Page
@@ -60,6 +60,28 @@ def inbound(pages: dict[str, Page], title: str) -> list[dict]:
@app.command("show")
@cli_contract.record(cli_contract.CommandRecord(
path="links show",
summary="Show the edges out of and into a page.",
synopsis=(cli_contract.Variant(usage='links show --page "<Title>" [--json]'),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
),
notes="The declared graph around one page in both directions: the edges it asserts (from "
"its own `related:`, with labels) and the edges other pages assert about it (computed "
"across the corpus). The inbound half is derived rather than stored - that is what makes it "
"complete, and it is the answer authored directional edges would otherwise have nowhere to "
"come from. Read-only, exempt from the Iteration Budget Gate",
failures=(cli_contract.Failure(
label="",
exit_1="Page not found",
retry="Check the exact title with `search`; a wikilink target is not always the page's "
"stem",
),),
))
def links_show(
page: str = typer.Option(..., "--page", help="Exact page title"),
json_out: bool = typer.Option(False, "--json", help="Print the edges as JSON"),
+43 -2
View File
@@ -12,7 +12,7 @@ from typing import Optional
import typer
from chemenu import config
from chemenu import cli_contract, config
from chemenu.commands._util import rel_path, success
from chemenu.lint_core import (
HARD_ERROR_KEYS,
@@ -46,6 +46,47 @@ __all__ = [
]
@cli_contract.record(cli_contract.CommandRecord(
path="lint",
summary="Run structural lint checks against kb/.",
synopsis=(cli_contract.Variant(
usage="lint [--json] [--markdown out.md] [--full] [--fail-on-error]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Writes one report file (single atomic write) unless `--json`",
budget=cli_contract.Budget.COUNTED,
),
notes="Structural + provenance checks: broken wikilinks, dangling frontmatter references, "
"orphan pages, index drift, schema gaps, duplicate titles, title mismatches, pages nested "
"more than one directory below their collection (hard - the generated catalog folds these "
"into their area silently rather than merely reading it), uncovered raw files, broken "
"`raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, "
"citation/frontmatter drift, unbalanced generated-region markers, edges whose label is "
"missing or not authorised by the source collection's `outbound:` (both hard once "
"`kb_version` has reached the release that introduced labelled edges - advisory below it, "
"so a corpus mid-migration is not refused by the check measuring it), `see-also` edges "
"whose reverse direction already carries a specific label (advisory only - redundant rather "
"than wrong, and never migration-gated, since no version turns the redundancy into an "
"error), a collection past the catalog's per-area shard threshold that has no areas to "
"shard (advisory only - sharding is automatic but per *area*, so a collection nobody gave "
"areas keeps one table however large it grows; reported with the split its subtype field "
"would produce, and only when that split puts every resulting area at or under the "
"threshold, so a lopsided or small collection stays silent), source pages sitting in the "
"`unclassified` catalog slot (advisory only - `unclassified` is the visible fallback for a "
"genuinely unclear source, not a defect), quote-limit overages (>2 blockquoted lines/page, "
"advisory only). Prints only the sections that found something and always writes the full "
"report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` "
"prints everything, `--json` prints the findings and writes nothing",
failures=(cli_contract.Failure(
label="",
exit_1="Only with `--fail-on-error`: hard findings exist",
retry="Safe to retry freely, but re-run it to re-*measure*, never to re-read: the "
"printed path holds the full report. Exit 1 means \"act on the findings\", not \"the "
"tool is broken\"",
),),
))
def lint_command(
json_out: bool = typer.Option(False, "--json", help="Print the raw findings as JSON and write no report"),
markdown_out: Optional[Path] = typer.Option(None, "--markdown", help="Write the markdown report here instead of the default reports/Lint Report <date>.md"),
@@ -53,7 +94,7 @@ def lint_command(
fail_on_error: bool = typer.Option(False, "--fail-on-error", help="Exit non-zero if hard errors were found"),
):
"""Run structural lint checks against kb/.
\f
Unless `--json` is given, the full report is always written to a file and
its path is printed. That path is the point: a lint report is long, and an
agent that only saw it on stdout had no way back to the part it scrolled
+38 -1
View File
@@ -7,7 +7,7 @@ from typing import Optional
import typer
from chemenu import config
from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success, today_iso
app = typer.Typer(help="Manage kb/log.md.")
@@ -51,12 +51,34 @@ def ingests_since_last_lint(entries: list[tuple[str, str, str]]) -> int:
@app.command("append")
@cli_contract.record(cli_contract.CommandRecord(
path="log append",
summary="Append a formatted entry to `kb/log.md`.",
synopsis=(cli_contract.Variant(
usage="log append --op ingest|query|lint|create|update|delete|rename|move "
'--title "..." [--body "..."|--body-file path]',
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="Yes - single append",
budget=cli_contract.Budget.COUNTED,
),
notes="Append a formatted entry to `kb/log.md`.",
failures=(cli_contract.Failure(
label="",
exit_1="Invalid `--op` or unreadable `--body-file`",
retry="**Not idempotent.** If the previous run's outcome is uncertain, check the tail "
"of `kb/log.md` before retrying",
),),
))
def log_append(
op: str = typer.Option(..., "--op", help="|".join(VALID_OPS)),
title: str = typer.Option(..., "--title", help="Brief description, e.g. a source path"),
body: str = typer.Option("", "--body", help="Optional multi-line details"),
body_file: Optional[Path] = typer.Option(None, "--body-file", help="Read the body from a file instead of --body"),
):
"""Append a formatted entry to `kb/log.md`."""
if op not in VALID_OPS:
fail(f"--op must be one of {VALID_OPS}")
text = body
@@ -69,6 +91,21 @@ def log_append(
@app.command("status")
@cli_contract.record(cli_contract.CommandRecord(
path="log status",
summary="Read-only: count `ingest` entries logged since the last `lint` entry.",
synopsis=(cli_contract.Variant(usage="log status"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.COUNTED,
),
notes="The deterministic trigger behind the Maintenance Schedule's \"every 10 sources\" "
"full-lint cadence. Never fails (reports 0 if `kb/log.md` is missing or empty). Safe to "
"retry freely.",
failures=(),
))
def log_status():
"""Report how many `ingest` operations have been logged since the last
`lint` - the deterministic trigger for the Maintenance Schedule's "every
+124 -1
View File
@@ -21,7 +21,7 @@ from typing import Optional
import typer
from chemenu import config, corpus_diff, kb_scan, kb_state, version as version_mod
from chemenu import cli_contract, config, corpus_diff, kb_scan, kb_state, version as version_mod
from chemenu.commands._util import console, fail, rel_path, success, today_iso
from chemenu.frontmatter_io import read_page
from chemenu.page import Page
@@ -49,6 +49,20 @@ def _versions() -> tuple[Version, Optional[Version]]:
# --- migrate list ----------------------------------------------------------
@cli_contract.record(cli_contract.CommandRecord(
path="migrate list",
summary="List every migration document under `instructions/migrations/`.",
synopsis=(cli_contract.Variant(usage="migrate list [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
),
notes="Oldest target first, with its kind and obligation. Read-only and **exempt from the "
"Iteration Budget Gate**. Never fails.",
failures=(),
))
@app.command("list")
def list_command(
json_out: bool = typer.Option(False, "--json", help="Print the migrations as JSON"),
@@ -134,6 +148,34 @@ def _report_offers(
)
@cli_contract.record(cli_contract.CommandRecord(
path="migrate status",
summary="Show the migrations this instance still owes, in the order they must run.",
synopsis=(cli_contract.Variant(usage="migrate status [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
),
notes="Every **required** document whose `migrates_to` lies in `(kb_version, VERSION]`. "
"`offered` documents are listed separately above the chain and never block, never count as "
"owed, and are bounded by the applied ledger rather than by `kb_version` - taking one "
"deliberately does not move the version, so the version cannot say whether it was taken. "
"When a release stamp is present, also reports which shipped files this instance has since "
"edited (from the per-file sha256 in `.wikitool-release.json`), which is what says whether "
"an offer may be copied over or has to be reconciled by hand; without a stamp that question "
"is reported as unanswerable rather than answered. Exits 1 only when `.wikitool-kb.json` is "
"missing - the content's shape is a question the tool refuses to answer by guessing. "
"Read-only and exempt from the budget gate",
failures=(cli_contract.Failure(
label="",
exit_1="`.wikitool-kb.json` is missing (content version undeclared), or `VERSION` is "
"unreadable",
retry="For a missing declaration: run `migrate baseline <version>` once, then retry. "
"Safe to retry freely otherwise",
),),
))
@app.command("status")
def status_command(
json_out: bool = typer.Option(False, "--json", help="Print the chain as JSON"),
@@ -212,6 +254,32 @@ def status_command(
# --- migrate done / baseline ----------------------------------------------
@cli_contract.record(cli_contract.CommandRecord(
path="migrate done",
summary="Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json`.",
synopsis=(cli_contract.Variant(usage="migrate done <version> [--pages N] [--dry-run]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="Yes - single file write",
budget=cli_contract.Budget.COUNTED,
),
notes="**Refuses any version that is not the next link in the chain** - skipping one leaves "
"the corpus in a shape no version describes, and an interrupted multi-step upgrade has to "
"be resumable rather than guessable. An `offered` migration is recorded in the applied "
"ledger *without* moving `kb_version` and with no ordering rule applied: it is not a link "
"in the chain, so there is nothing to skip, and requiring the chain first would make an "
"unrelated file upgrade wait on it. Re-recording one already in the ledger is a no-op, not "
"an error",
failures=(cli_contract.Failure(
label="",
exit_1="Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a *required* "
"version that is not the next link in the chain",
retry="**Not idempotent** for a required migration: it advances the chain. For \"not "
"the next link\", run `migrate status` and apply them in the order it prints - never "
"force the order. Recording an `offered` migration *is* idempotent and safe to repeat",
),),
))
@app.command("done")
def done_command(
version: str = typer.Argument(..., help="The migration's target version, e.g. 1.4.0"),
@@ -308,6 +376,27 @@ def done_command(
)
@cli_contract.record(cli_contract.CommandRecord(
path="migrate baseline",
summary="Declare `kb_version` once, for an instance predating `.wikitool-kb.json`.",
synopsis=(cli_contract.Variant(usage="migrate baseline <version> [--force]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Yes - single file write",
budget=cli_contract.Budget.COUNTED,
),
notes="Refuses to overwrite an existing declaration without `--force`: advancing after a "
"migration is `done`, which checks the chain, and this command must not become the quiet "
"way around it",
failures=(cli_contract.Failure(
label="",
exit_1="Unparseable version, or a declaration already exists and `--force` was not "
"passed",
retry="Safe to re-run with the same version. If a declaration exists, it is almost "
"always `migrate done` that was wanted",
),),
))
@app.command("baseline")
def baseline_command(
version: str = typer.Argument(..., help="The shape this instance's content is already in"),
@@ -434,6 +523,40 @@ def _shapes_now(wanted: set[str]) -> dict[str, corpus_diff.PageShape]:
return shapes
@cli_contract.record(cli_contract.CommandRecord(
path="migrate verify",
summary="Compare `kb/` against a git revision on the invariants a content migration must "
"not change.",
synopsis=(cli_contract.Variant(
usage="migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] "
"[--fail-on-error]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
),
notes="Wikilink and citation **counts** (not sets), footnote definitions, H1, structural "
"frontmatter, and the **count of generated-region marker pairs** - a page that went from "
"one links region to two has the same set of region names and a different count, and a "
"lost marker turns a generated region into prose the next write appends a second one "
"beside. Pages are matched by **title**, not path, so a page `wikitool move` (or "
"`move --reconcile`) relocated compares as itself - reported separately as `moved` - rather "
"than as a removed-and-added pair. Reports added/removed pages without failing on them. "
"`--expect-body-change` additionally flags a page whose body did not change at all. Not "
"migration-specific - worth running after any bulk rewrite, and the one question `lint` "
"cannot answer, since it reads a single revision and so cannot see that something went "
"missing. Read-only and exempt from the budget gate",
failures=(cli_contract.Failure(
label="",
exit_1="Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is "
"not a revision in this repository",
retry="Exit 1 from `--fail-on-error` means \"act on the findings\", not \"the tool is "
"broken\". A finding is never fixed by re-running - it names a page and what changed on "
"it",
),),
))
@app.command("verify")
def verify_command(
from_rev: str = typer.Option(..., "--from", help="Git revision to compare against, e.g. HEAD"),
+107 -5
View File
@@ -28,7 +28,7 @@ import re
import typer
from chemenu import config, tasks
from chemenu import cli_contract, config, tasks
from chemenu.commands._util import (
check_collision,
check_raw_files_exist,
@@ -325,6 +325,108 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
return f"tracker project '{page_title}' created"
@cli_contract.record(cli_contract.CommandRecord(
path="new",
summary="Scaffold a new wiki page of any type.",
synopsis=(
cli_contract.Variant(
usage='new <type-name> --name "<Name>" [--type <path>] [--set field=value ...]',
notes="Scaffold a page of any type. The type-spec drives fields, directory "
"(`base_dir`/`layout`), title prefix, and template - a schema `default:` is "
"materialized only for a field the schema also lists in `required:` (an optional "
"field's default is a reader-side assumption, not a scaffold-time value) - `--set` "
"is repeatable, and comma-separated values fill array fields. An element that itself "
"contains a comma is written `\\,`, or passed as its own repeated `--set` for that "
"field - repeating an array field appends. See `types list`/`types describe`.",
),
cli_contract.Variant(
usage='new entity --name "<Name>" --set entity_type=<t> [--set tags=a,b] '
"[--set related=X,Y] [--set sources=\"Source - Z\"] "
"[--set provenance=sourced|general|mixed]",
notes="Scaffold `kb/entities/<subdir>/<Name>.md`",
),
cli_contract.Variant(
usage='new concept --name "<Name>" --set concept_type=<t> ...',
notes="Scaffold `kb/concepts/<Name>.md`",
),
cli_contract.Variant(
usage='new source --name "<Name>" --set raw_files=raw/notes/x.md,raw/notes/y.md '
"[--set source_url=<URL>] [--set entities=A,B] [--set concepts=C,D]",
notes="Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a "
"`raw_files:` list (rejects paths that don't exist)",
),
cli_contract.Variant(
usage='new comparison --name "X vs Y" --set entities=X,Y',
notes="Scaffold `kb/comparisons/X vs Y.md`",
),
cli_contract.Variant(
usage='new project --name "<Name>" --set responsibility=<bereich> [--resume]',
notes="Scaffold `kb/gtd/<bereich>/<Name>.md` **and**, if `.wikitool-tasks.json` "
"configures a task tracker, a same-named tracker project - one name, one identity. "
"Tracker before page: the tracker side is settled first, so a failure past that "
"point leaves a tracker project with no page - a state `review`'s check 3 already "
"reports - never a page with no tracker project. No tracker configured is a "
"legitimate, explicitly announced state (page only). A name already taken "
"(case-insensitively) in `kb/` or the tracker is refused outright, naming where it "
"was found, and creates nothing. A provider whose *configured access path* has no "
"write path (Super Productivity's `access: \"snapshot\"` - the tracker is read-only "
"from there by construction) refuses **entirely**, exit **1**, naming the "
"`access: \"api\"` instance to use instead - neither the tracker project nor the "
"page is created, and `--resume` behaves the same. A provider that could write but "
"has no project-creation endpoint of its own (Super Productivity's `access: \"api\"` "
"- `GET /projects` exists, `POST /projects` does not) raises "
"`chemenu.errors.HumanInterventionRequired`; the command shows its instructions and "
"exits **42** (`needs_clearance()`, same posture as the four named gates, without "
"being a fifth one - see that class's docstring), creating nothing. `--resume` is "
"how a later run tells the command a human has done what that message asked: it "
"re-verifies via the read path (`find_project`) before continuing to page creation, "
"rather than trusting the claim, and repeats the same 42 if the tracker still "
"doesn't have it. `--resume` on any other type is refused",
),
),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="`new <type>`: Yes - single file write. `new project`: **No** for the "
"tracker-configured case - a tracker-project write (or its human-clearance request) "
"happens before the kb/ page write, so a failure between the two leaves a tracker "
"project with no page (a state `review`'s check 3 already reports), never a page with "
"no tracker project. Still a single file write when no tracker is configured",
budget=cli_contract.Budget.COUNTED,
gates=("human-intervention-required (`new project` only)",),
),
notes="`new`/`xref`/`log append` only produce structurally-correct frontmatter and body "
"skeletons/edits - the prose (Description, Summary, judgment calls about relationships) is "
"still written by the LLM afterwards.",
failures=(
cli_contract.Failure(
label="new <type>",
exit_1="Duplicate page title, unknown type, invalid `--set` value, or a "
"`raw_files` path that doesn't exist",
retry="Not transient; fix the argument and retry once. Never hand-craft the page "
"instead",
),
cli_contract.Failure(
label="new project",
exit_1="Everything `new <type>` covers, **plus**: the name is already taken in the "
"tracker (case-insensitively - for `caldav` this is checked against every list in "
"the account, not only the ones counted as projects), `--resume` was passed for a "
"type other than `project`, or the configured provider's access path has no write "
"path at all (Super Productivity's `access: \"snapshot\"`)",
retry="A collision, a bad `--set`, or a read-only access path is not transient, "
"same as `new <type>` - the last of those points at the `access: \"api\"` instance "
"instead and refuses on every `--resume` retry too, since nothing about the config "
"changes by asking again. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its "
"own separate outcome from the ordinary exit-1 cases above, and is "
"`superproductivity`-only: that provider *can* write but cannot create the project "
"itself and a human must, per the printed instructions; re-run with `--resume` once "
"that is done - it re-verifies via the read path rather than trusting the claim, and "
"exits 42 again unchanged if the tracker still does not have it. `caldav` never "
"produces this outcome - `MKCALENDAR` is a real collection-creation verb, so a "
"valid, non-colliding name always creates the list itself",
),
),
))
def new_page_command(
type_name: str = typer.Argument(
...,
@@ -344,11 +446,11 @@ def new_page_command(
"--resume",
help="`project` only: confirm a human has completed the manual tracker step an earlier "
"HumanInterventionRequired refusal asked for, so this run continues to page creation "
"instead of refusing the now-existing tracker project as a collision (Gitea #126).",
"instead of refusing the now-existing tracker project as a collision.",
),
):
"""Scaffold a new wiki page of any type.
\f
The type's own type-spec drives everything: which frontmatter fields
exist and are required (its `.schema.yaml`), their scaffold defaults
(schema `default:`), where the page is written (`base_dir` + `layout`),
@@ -356,8 +458,8 @@ def new_page_command(
type-spec's template). Adding a new type therefore needs no change here.
For `type_name == "project"` specifically, this also ensures a
same-named tracker project exists (Gitea #126, #119 D8/D31) before the
page is written - see `_ensure_tracker_project`.
same-named tracker project exists before the page is written - see
`_ensure_tracker_project`.
"""
type_path = type_path_override or resolver.find_type_by_name(type_name)
if not type_path:
+82 -1
View File
@@ -25,7 +25,7 @@ from typing import Optional
import typer
from chemenu import config, links
from chemenu import cli_contract, config, links
from chemenu.commands._util import check_collision, fail, rel_path, success
from chemenu.frontmatter_io import write_page
from chemenu.lint_core import find_misplaced
@@ -210,6 +210,31 @@ def inbound_pages(pages: dict[str, Page], title: str) -> list[str]:
return sorted(found)
@cli_contract.record(cli_contract.CommandRecord(
path="rename",
summary="Rename a page, or repoint references that name a page that never existed.",
synopsis=(cli_contract.Variant(usage='rename --from "<Old>" --to "<New>" [--dry-run]'),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="No - one write per referencing page, then the file move",
budget=cli_contract.Budget.COUNTED,
),
notes="Rename a page and repoint every reference to it: body `[[wikilinks]]` (aliases and "
"anchors preserved), a `[^cite-id]` whose id was derived from the old title (refreshed to "
"match the new one, both in its Footnotes definition and every reference to it), the page's "
"own H1, and every page-ref frontmatter array declared by the type's `page_ref_fields:`. If "
"`--from` is *not* a page but is referenced, it instead repoints those references onto the "
"existing `--to` page and moves nothing - the fix for a reference spelled `act_runner` when "
"the page is `Act Runner`",
failures=(cli_contract.Failure(
label="",
exit_1="Neither `--from` nor `--to` is a page, target title already taken, or "
"`--from` equals `--to`",
retry="Safe to retry once as-is; each page's rewrite is idempotent. Use `--dry-run` "
"first to see the blast radius. Never fix up references by hand instead",
),),
))
def rename_command(
old: str = typer.Option(..., "--from", help="Current page title, exactly as it appears"),
new: str = typer.Option(..., "--to", help="New page title"),
@@ -301,6 +326,28 @@ def rename_command(
)
@cli_contract.record(cli_contract.CommandRecord(
path="rm",
summary="Delete a page and mechanically de-link it from the rest of the wiki.",
synopsis=(cli_contract.Variant(usage='rm --page "<Title>" [--yes] [--dry-run]'),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="No - one write per referencing page, then the delete",
budget=cli_contract.Budget.COUNTED,
),
notes="Refuses without `--yes` while other pages still reference it. Strips ref-array "
"entries and bare `- [[Title]]` / `- **label:** [[Title]]` bullets; leaves prose and inline "
"citations in place and reports them",
failures=(cli_contract.Failure(
label="",
exit_1="Page not found, **or** other pages still reference it and `--yes` was not "
"passed",
retry="For \"still referenced\": show the user the inbound list, get approval, then "
"re-run with `--yes`. Prose references it reports afterwards are an editorial fix, not "
"a retry",
),),
))
def rm_command(
page_title: str = typer.Option(..., "--page", help="Exact title of the page to delete"),
yes: bool = typer.Option(
@@ -399,6 +446,40 @@ def _rmdir_if_emptied(directory: Path) -> bool:
return True
@cli_contract.record(cli_contract.CommandRecord(
path="move",
summary="Move a page (or every misplaced page) to the directory its type-spec computes.",
synopsis=(
cli_contract.Variant(usage='move --page "<Title>"'),
cli_contract.Variant(usage="move --reconcile [--dry-run]"),
),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="`--page`: yes, a single file move. `--reconcile`: no - one file move per page, "
"each idempotent",
budget=cli_contract.Budget.COUNTED,
),
notes="Move a page to the directory its type-spec computes for its current frontmatter "
"(`base_dir` + `layout` - the same rule `new` places a page by, via "
"`TypeResolver.compute_target_dir`), never a hand-chosen destination - there is no "
"`--to <dir>`. `--reconcile` applies it corpus-wide: every misplaced page moves in one "
"call, and a second run reports nothing left to do (`lint`'s `Misplaced Pages` finding is "
"the advisory that this fixes, and its `Nested Pages` finding the hard one - see `lint`). "
"Neither mode touches a body or a frontmatter field, and the page's title (its only "
"identity in the wiki) never changes - only the file moves. A directory a move empties is "
"removed along with it, so a page that was nested below its area leaves no leftover "
"directory behind. A destination already occupied (a pre-existing duplicate-stem collision) "
"is refused rather than silently skipped",
failures=(cli_contract.Failure(
label="",
exit_1="Neither or both of `--page`/`--reconcile` given, the named page not found, it "
"has no `type:` to compute a placement from, or the destination already exists",
retry="Safe to retry once as-is; a page already at its computed location is reported "
"and left alone, and `--reconcile` only re-moves what is still misplaced. Use "
"`--dry-run` first to see the blast radius. Never choose a directory by hand instead",
),),
))
def move_command(
page_title: Optional[str] = typer.Option(
None, "--page", help="Exact title of the page to move to its computed location"
+55 -1
View File
@@ -13,7 +13,7 @@ from typing import Optional
import typer
from chemenu import config
from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success
from chemenu.provenance import (
broken_raw_refs,
@@ -49,6 +49,21 @@ def _normalize_raw_path(raw: str) -> str:
@app.command("coverage")
@cli_contract.record(cli_contract.CommandRecord(
path="sources coverage",
summary="List raw files with no source page, broken `raw_files:` references, and legacy "
"directory/URL-only source pages.",
synopsis=(cli_contract.Variant(usage="sources coverage [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.COUNTED,
),
notes="List raw files with no source page, broken `raw_files:` references, and legacy "
"directory/URL-only source pages. Never fails. Safe to retry freely.",
failures=(),
))
def coverage(json_out: bool = typer.Option(False, "--json", help="Print raw findings as JSON")):
"""Report raw files with no source page, broken raw_files: references, and
source pages still using a legacy directory/URL-only `source:` field."""
@@ -74,6 +89,26 @@ def coverage(json_out: bool = typer.Option(False, "--json", help="Print raw find
@app.command("trace")
@cli_contract.record(cli_contract.CommandRecord(
path="sources trace",
summary="Trace provenance in either direction: raw file, or page.",
synopsis=(cli_contract.Variant(usage='sources trace --raw <path> | --page "<Title>"'),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.COUNTED,
),
notes="Trace provenance in either direction: raw file -> source page(s) -> citing pages, or "
"page -> its sources -> their raw files",
failures=(cli_contract.Failure(
label="",
exit_1="Neither or both of `--raw`/`--page` given, `--raw` names a file no source page "
"covers (reported as a plain finding plus exit 1, not the usual `ERROR`-prefixed "
"rejection), or `--page` names an unknown page",
retry="Fix the argument and retry",
),),
))
def trace(
raw: Optional[str] = typer.Option(None, "--raw", help="Raw file path to trace forward from"),
page: Optional[str] = typer.Option(None, "--page", help="Wiki page title to trace backward from"),
@@ -170,9 +205,28 @@ def build_provenance_index(kb_dir: Path, raw_dir: Path) -> str:
@app.command("rebuild-index")
@cli_contract.record(cli_contract.CommandRecord(
path="sources rebuild-index",
summary="Regenerate the `kb/provenance.md` reverse index.",
synopsis=(cli_contract.Variant(usage="sources rebuild-index [--dry-run]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Yes - the single provenance file is regenerated from scratch",
budget=cli_contract.Budget.COUNTED,
),
notes="Regenerate the `kb/provenance.md` reverse index (raw file -> source page -> citing "
"pages)",
failures=(cli_contract.Failure(
label="",
exit_1="Rare I/O error only",
retry="Safe to retry freely",
),),
))
def rebuild_index(
dry_run: bool = typer.Option(False, "--dry-run", help="Print the result instead of writing kb/provenance.md"),
):
"""Regenerate the `kb/provenance.md` reverse index."""
content = build_provenance_index(config.KB_DIR, config.RAW_DIR)
provenance_file = config.KB_DIR / "provenance.md"
if dry_run:
+85 -1
View File
@@ -71,7 +71,7 @@ from typing import Optional
import typer
from chemenu import config
from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success
from chemenu.frontmatter_io import write_page
from chemenu.kb_scan import load_kb_pages
@@ -309,6 +309,90 @@ def _replace(
success("Review them in this same run: the replacement and their update belong in one commit.")
@cli_contract.record(cli_contract.CommandRecord(
path="raw accept",
summary="Promote one or more files from `incoming/` into `raw/`.",
synopsis=(
cli_contract.Variant(
usage='raw accept <file> [<file> ...] --fidelity <v> --authority <v> '
'[--page "<Title>"] [--dry-run]',
notes="Promote one or more files from `incoming/` into `raw/<YYYY>/<MM>/`, "
"computed from the accept date rather than chosen by hand "
"(`raw/CONTRACT.md` \"Getting a file in\"): a subdirectory under `incoming/` is "
"tolerated and ignored, not inspected - `raw/` no longer addresses by type. One "
"file promoted alone lands with no directory of its own; several files in one call "
"nest under `raw/<YYYY>/<MM>/<stem>/`, named after the first file's stem. "
"`--fidelity`/`--authority` are required here (see `types describe source`; "
"`unknown` is refused, backfill-only) - the one moment both are knowable. "
'`--page "<Title>"` additionally extends that existing source page\'s '
"`raw_files:` in the same call and writes both capture fields onto it (refused if "
"it already carries a different value - a capture field is fixed once); if that "
"raises the page past one file, its already-promoted file is folded into a bundle "
"at *its own* parent directory, not today's shard, so a bundle never mixes an old "
"and a new capture date, after checking it has no other owner "
"(`provenance.duplicate_raw_file_owners`). The set of names occupied anywhere "
"under `raw/` - file stems and bundle directory names alike, old type directories "
"and date shards together - must stay unique: a promote whose target name already "
"belongs to something this call does not itself own is refused, naming both "
"`--replaces` and renaming-in-`incoming/` without recommending either",
),
cli_contract.Variant(
usage='raw accept <file> --replaces <raw-path> [--fidelity <v>] [--authority <v>] '
"[--dry-run]",
notes="The one sanctioned way past that uniqueness rule, and the one sanctioned "
"way to correct an already-set capture field: overwrites `<raw-path>` in place with "
"the single incoming file (same filename required; there is no type directory left "
"to match), leaving every page's `raw_files:` untouched and writing no `kb/` page - "
"the previous edition survives only in `git log --follow <raw-path>`. "
"`--fidelity`/`--authority` are optional here, and passing one overwrites the "
"owning page's already-set value - the one path fill-once does not block, because "
"a corrected capture is a new edition of the source, not an edit of the page "
"describing it. Refuses if the target has more than one owning source page; if it "
"has none, replaces anyway and says so. Cannot be combined with `--page` or with "
"more than one incoming file - a replacement is one file for one file. Prints the "
"source page (if any) and its citing pages, so their update lands in the same "
"commit as the replacement",
),
),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="`raw accept`: No - one filesystem move per file, then (with `--page`) one page "
"write. `raw accept --replaces`: No - one `unlink()` + one `rename()`, plus (if "
"`--fidelity`/`--authority` was given) one page write",
budget=cli_contract.Budget.COUNTED,
),
notes="See `raw/CONTRACT.md` \"Getting a file in: incoming/\".",
failures=(
cli_contract.Failure(
label="raw accept",
exit_1="A file does not exist, is not under `incoming/`, or is nested more than "
"one level below it, two files in one call share a filename, a target path already "
"exists, `--fidelity`/`--authority` is missing (unless `--replaces`) or names "
"`unknown` or a value outside the schema's enum, the target name is already "
"occupied anywhere under `raw/` by something the call does not own, `--page` names "
"an unknown page or one with no `raw_files:` yet, an existing `raw_files:` entry is "
"missing on disk, a file to be moved has more than one owning page, or `--page` "
"would overwrite an already-set `fidelity`/`authority` with a different value",
retry="Fix the named argument and retry once. Safe to retry as-is once the cause is "
"fixed: a file already at its computed destination is what \"already exists\" "
"reports, not a partial prior run to resume. A stem-occupied refusal is not fixed "
"by retrying at all - it names `--replaces` and renaming in `incoming/` as the two "
"routes and neither is the tool's to pick. Never choose the destination by hand "
"instead - that is the decision this command exists to take away",
),
cli_contract.Failure(
label="raw accept --replaces",
exit_1="More than one incoming file, `--page` also given, the incoming file does "
"not exist or is not under `incoming/` (or is nested more than one level below "
"it), its filename differs from the target's, the target does not lie under `raw/` "
"or does not exist, `--fidelity`/`--authority` names `unknown` or a value outside "
"the schema's enum, or the target has more than one owning source page",
retry="Fix the named argument and retry once. Every check runs before the "
"filesystem is touched, so a refusal leaves both files exactly as they were",
),
),
))
@app.command("accept")
def raw_accept_command(
files: list[Path] = typer.Argument(
+56 -5
View File
@@ -10,7 +10,7 @@ import json
import typer
from chemenu import config
from chemenu import cli_contract, config
from chemenu.commands._util import fail
from chemenu.errors import ValidationError
from chemenu.review import ALL_CHECKS, ReviewReport, run_review
@@ -75,13 +75,64 @@ def report_to_dict(report: ReviewReport) -> dict:
}
@cli_contract.record(cli_contract.CommandRecord(
path="review",
summary="The GTD weekly review.",
synopsis=(cli_contract.Variant(usage="review [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
network=cli_contract.Network.YES,
),
notes="Joins the configured task-tracker provider (`chemenu.tasks`) against `kb/gtd/` "
"project pages over the case-normalized project name, at read time, storing nothing - not "
"even a `reports/` file. Five checks: **stalled** (a tracker project with zero open items "
"whose `kb/` page is `state: active` - `dormant`/`completed`/`abandoned` never fire, since "
"those states mean the initiative not having a next action is expected rather than a "
"problem), **waiting-overdue** (a `WAITING` item whose `follow_up_at` is older than "
"`thresholds.stalled_waiting_days`), **unpaged-project** (a tracker project with no "
"matching `kb/` page, older than `thresholds.unpaged_project_weeks`), **no-open-loop** (a "
"`kb/` page `state: active` with no matching tracker project, or one with zero open items - "
"the reverse direction of the unpaged-project join, so a rename on either side surfaces on "
"both), **someday-stale** (a someday/maybe item untouched for longer than "
"`thresholds.someday_stale_months`). A value a provider genuinely cannot supply - a "
"`WAITING` item with no `follow_up_at` at all, a tracker project with no determinable "
"creation date - is its own finding (`waiting_no_follow_up`/`project_age_unknown`) rather "
"than a silent skip of waiting-overdue/unpaged-project for that item or project. Thresholds "
"come from `.wikitool-tasks.json`, never from the schema. Text output is one "
"`[check] project: message` line per finding, preceded by a `Source:` line naming which "
"access path answered and, for `superproductivity`'s `access: \"snapshot\"`, the snapshot's "
"age; `--json` carries the same findings plus "
"`checks_run`/`checks_skipped`/`kb_project_count`/`complete`/`source` "
"(`{\"kind\": ..., \"detail\": ...}` or `null`). No `.wikitool-tasks.json` fails "
"immediately with a clear \"no tracker configured\" message; a provider that cannot be "
"reached mid-run degrades only the checks that needed the failing call, and the report is "
"never rendered as if it were complete - see its error-contract row. Read-only, and "
"**exempt from the Iteration Budget Gate**",
failures=(cli_contract.Failure(
label="",
exit_1="Either no `.wikitool-tasks.json` (or a malformed one) - not yours to fix by "
"retrying unchanged, configure or repair it first - **or** the provider was reachable "
"at config-parse time but a read call failed mid-run, in which case the full report "
"(findings plus which checks ran) is printed first and exit 1 follows, never a silent "
"partial success",
retry="The two exit-1 causes above need different responses: a config problem needs "
"editing `.wikitool-tasks.json`; an unreachable provider (e.g. the tracker app not "
"running) needs starting it, then a plain retry - the command re-reads everything fresh "
"each time, so nothing here is ever stale to re-fetch",
),),
))
def review_command(
json_out: bool = typer.Option(False, "--json", help="Print the findings as JSON."),
):
"""Run the weekly GTD review: join the task tracker against kb/gtd/ pages
over the project name and report the five staleness/mismatch checks
(#119 D10/D26). Read-only - stores nothing, not even a reports/ file
(#119 D3), and is exempt from the Iteration Budget Gate like `search`."""
"""Run the weekly GTD review over the task tracker and `kb/gtd/` pages.
\f
Joins the task tracker against kb/gtd/ pages over the project name and
report the five staleness/mismatch checks (#119 D10/D26). Read-only -
stores nothing, not even a reports/ file (#119 D3), and is exempt from
the Iteration Budget Gate like `search`."""
try:
report = run_review(config.ROOT)
except ValidationError as exc:
+73 -60
View File
@@ -27,7 +27,7 @@ from pathlib import Path
import typer
from chemenu import config
from chemenu import cli_contract, config
from chemenu.commands._util import fail, success
from chemenu.session import session_id as _shared_session_id
from chemenu.session import session_id_source as _shared_session_id_source
@@ -71,70 +71,51 @@ SESSION_TTL_SECONDS = 7 * 24 * 3600
# the whole gate a formality an agent could step around by resetting first.
# It is gated on `--yes` instead, the same way `publish` is.
#
# `eval score` and `eval sessions` read a trace and re-run lint's checks in
# process. Reading back what a session already did is not iteration on the wiki,
# and charging for it would discourage checking one's own work.
# `version show`/`check`/`notes` only read - `VERSION`, the release stamp, the
# changelog, or a remote release feed. `version bump` writes two files and
# stays counted like every other mutation.
SKIP_COMMAND_PATHS = {
("budget", "status"),
("eval", "score"),
("eval", "sessions"),
("cite", "id"),
# Retrieval, like `search`: an agent that has to ration looking up what
# points at a page starts guessing instead - and under authored directional
# edges this is the *only* way to ask that question.
("links", "show"),
("version", "show"),
("version", "check"),
("version", "notes"),
# Bare `wikitool version` (and `version --json`) is an alias for `show`;
# the subcommand slot is empty, so it needs its own entry to be exempt
# alongside the command it delegates to.
("version", ""),
# `migrate list/status/verify` only read - the migration documents, the KB
# state file, and git history. `verify` especially: a migration runs it
# once per unit by design, and charging for the check would push an agent
# toward skipping the one step that catches a dropped reference.
# `migrate done`/`baseline` write the state file and stay counted.
("migrate", "list"),
("migrate", "status"),
("migrate", "verify"),
# `upstream verify` only reads two git revisions and reports what changed -
# the same argument as `migrate verify`: a check that costs budget is one
# an agent starts skipping. `upstream merge` stays counted: it mutates the
# branch and can leave an open merge behind on refusal, so it belongs on
# the non-idempotent list (AGENTS.md's tool error contract) rather than
# the exempt one.
("upstream", "verify"),
}
# Which commands are exempt is no longer a list here at all (Gitea #121 D5,
# B8) - it is each command's own `cli_contract` record, the same `budget:`
# property `wikitool -h`'s index prints. Two commands used to need their own
# branch below because a hand-maintained pair-set cannot express them:
# `search`/`doctor`/`review` are flat commands whose first argument is a query
# or an option, not a subcommand (`_resolve_record` tries the bare command
# path before ever treating `args[0]` as one), and bare `wikitool version`
# aliases `version show`. `version regrade`'s `budget: exempt_without_args`
# is the third shape a plain exempt/counted split cannot express: the bare
# listing reads, but any index writes `CHANGES.md` and must stay counted like
# `version bump`.
# Commands exempt regardless of their first argument, because that argument is
# a query rather than a subcommand. `search` is here because retrieval is
# reading, not iterating: the budget exists to stop an agent looping over the
# wiki's *state*, and charging for a search would penalise the one habit that
# lowers cost - looking before reading. `doctor` is here for the same reason:
# it only reads and reports, never mutates anything. `review` (#125) joins the
# task tracker against kb/gtd/ pages and stores nothing either (#119 D3) - the
# same read-only argument as `search`, just over a different pair of sources.
# Every command that mutates anything stays counted.
SKIP_COMMANDS = {"search", "doctor", "review"}
def _resolve_record(command: str, args: list[str]) -> tuple["cli_contract.CommandRecord | None", bool]:
"""The `cli_contract` record this invocation maps to, and whether it was
matched *without* consuming `args[0]` as a subcommand (a flat command, or
the bare `version` alias) - which is what `is_exempt` needs to answer
`version regrade`'s own arguments-dependent question correctly."""
direct = cli_contract.get(command)
if direct is not None:
return direct, True
subcommand = args[0] if args and not args[0].startswith("-") else ""
if command == "version" and not subcommand:
# Bare `wikitool version` (and `version --json`) is an alias for
# `show` - see version_cmd.py's own `@app.callback()`.
return cli_contract.get("version show"), True
return cli_contract.get(f"{command} {subcommand}".strip()), False
def is_exempt(command: str, args: list[str]) -> bool:
"""Whether this invocation is outside the budget entirely."""
if command in SKIP_COMMANDS:
"""Whether this invocation is outside the budget entirely, read from the
matched command's own `cli_contract` record."""
record, bare = _resolve_record(command, args)
if record is None:
return False
budget = record.properties.budget
if budget == cli_contract.Budget.EXEMPT:
return True
subcommand = args[0] if args and not args[0].startswith("-") else ""
if (command, subcommand) in SKIP_COMMAND_PATHS:
return True
# `version regrade` only reads when called with no further arguments at
# all - the bare listing. Any index (with `--impact`) writes CHANGES.md
# and stays counted like `version bump`, so this cannot join
# SKIP_COMMAND_PATHS, which only ever looks at the subcommand slot.
if command == "version" and subcommand == "regrade":
return len(args) == 1
if budget == cli_contract.Budget.EXEMPT_WITHOUT_ARGS:
# `version regrade`'s own shape: exempt only for the bare listing.
# `bare` is only True here for a flat command with no group at all,
# which `version regrade` is not, so the subcommand token itself is
# the one argument the bare listing consumes.
consumed = 0 if bare else 1
return len(args) == consumed
return False
@@ -342,6 +323,20 @@ def refund() -> None:
_save_state(state)
@cli_contract.record(cli_contract.CommandRecord(
path="budget status",
summary="Show the current session's `wikitool` call count and recent command history.",
synopsis=(cli_contract.Variant(usage="budget status"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
),
notes="Recent command history is never counted against the budget. Never fails. Safe to "
"retry freely.",
failures=(),
))
def status_command():
"""Show the current session's call count and recent command history."""
state = _load_state()
@@ -366,6 +361,24 @@ def reset_message() -> str:
)
@cli_contract.record(cli_contract.CommandRecord(
path="budget reset",
summary="Clear the current session's (or every session's) iteration budget state.",
synopsis=(cli_contract.Variant(usage="budget reset --yes [--all]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="Read/rewrite of one JSON file (or its deletion, with `--all`)",
budget=cli_contract.Budget.COUNTED,
),
notes="Requires `--yes`: clearing the counter is itself a way around the gate, so it needs "
"the same explicit human approval",
failures=(cli_contract.Failure(
label="",
exit_1="`--yes` not passed",
retry="Get the user's approval, then re-run with `--yes`",
),),
))
def reset_command(
all_sessions: bool = typer.Option(
False, "--all", help="Clear every session's budget, not just the current one."
+47
View File
@@ -24,6 +24,7 @@ import json
import typer
from chemenu import cli_contract
from chemenu.commands._util import fail, today_iso
from chemenu.search import filters
from chemenu.search.filters import PredicateError
@@ -122,6 +123,52 @@ def render_table(result: SearchResult, show_matches: bool) -> str:
return "\n".join(lines)
@cli_contract.record(cli_contract.CommandRecord(
path="search",
summary="Find pages in `kb/` by text and/or frontmatter.",
synopsis=(cli_contract.Variant(
usage='search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag '
"<v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
),
notes="Find pages in `kb/` without reading the index. Text search runs through a pluggable "
"backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, "
"`f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no "
"text this is a pure structured query. One hit per line, ` | `-separated as `score | "
"kind/subtype | title | path | summary`, so a hit can be judged without opening the page and "
"then opened without looking it up: **title and path are never truncated** (the title is "
"the identifier `touch`/`xref`/`cite` take), and the summary - the one lossy field, and the "
"only one that may contain the separator - goes last, so splitting on `\" | \"` with "
"`maxsplit=4` is unambiguous. Scope is pages: the backend walks `kb/` but drops anything "
"`kb_scan.iter_kb_pages` excludes (the kb-root meta files, every `COLLECTION.md`, every "
"generated `INDEX.md`), which is why a hand-run grep over `kb/` can add none of them but "
"those. `--limit` defaults to 50 (`0` for no limit) and **a truncated result says so** - "
"`50 of 182 result(s)` in the table, `total`/`truncated`/`limit` beside `count` in `--json`, "
"where `count` stays the number of results in the payload; the same default and the same "
"fields are what `api.search` and the MCP `search` tool carry, from one constant. A page "
"whose frontmatter does not parse can match no positive predicate, so it is **named** "
"rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` "
"(usually empty), and the table form writes the same lines to stderr. `--regex` is applied "
"by `rg` alone, whose engine is linear; the ranking boosts for title and summary are "
"literal-containment only, so a non-literal pattern is ranked by match count. `rg` is "
"killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration "
"Budget Gate**",
failures=(cli_contract.Failure(
label="",
exit_1="`rg` is not installed or did not finish within 30 s, a malformed `--field` "
"predicate, an unknown field name, or an unknown `--backend`",
retry="Fix the argument and retry. A timeout is a pathological pattern or an "
"unresponsive corpus directory, not a slow answer - narrow the query or drop `--regex` "
"rather than retrying it unchanged. An unknown field name is reported with the list of "
"fields that do exist - it is never answered with an empty result, because that would "
"read as \"no such pages\"",
),),
))
def search_command(
text: str = typer.Argument(
None,
+123 -17
View File
@@ -31,14 +31,14 @@ from typing import Optional
import typer
from chemenu import config, tasks
from chemenu import cli_contract, config, tasks
from chemenu.commands._util import fail, success
from chemenu.errors import ValidationError
from chemenu.tasks import config as tasks_config
app = typer.Typer(
help="Create, list, and close items in the task tracker (Gitea #132, #138) - "
"never a kb/ page, see `new project` for that pairing."
help="Create, list, and close items in the task tracker - never a kb/ page, see "
"`new project` for that pairing."
)
@@ -50,42 +50,91 @@ def _parse_follow_up_at(text: str) -> datetime.date:
@app.command("new")
@cli_contract.record(cli_contract.CommandRecord(
path="task new",
summary="Create one open item in the configured task tracker - never a kb/ page.",
synopsis=(cli_contract.Variant(
usage='task new --title "<Title>" (--project "<Name>" | --inbox) '
"[--waiting [--follow-up-at YYYY-MM-DD]] [--notes \"...\"]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="Yes - a single API call, made only once every precondition (the project's own "
"id, the WAITING tag's own id) is confirmed to exist, so a missing one never leaves a "
"half-written item behind",
budget=cli_contract.Budget.COUNTED,
network=cli_contract.Network.YES,
),
notes="The second creation command alongside `new project`, and the last one their split "
"needed - see `docs/knowledge-and-commitment.md`. Exactly one of `--project` (an existing "
"tracker project, matched case-insensitively - never created and never searched or "
"guessed) or `--inbox` (the tracker's own inbox, a deliberate exit with a cost: an item "
"filed there never appears in `review`, since every one of its checks is reached through a "
"project name and the inbox has none) is required; an omitted `--project` refuses rather "
"than silently falling into the inbox. `--waiting` sets the WAITING status the review's own "
"waiting-overdue check reads; `--follow-up-at` is refused without `--waiting`, since it is "
"never a due date on its own. `--notes` carries a freetext backref (e.g. to the kb/ source "
"page this item came from), stored verbatim, never parsed - the same posture a `WAITING` "
"item's own title already has for the person named in it. No `.wikitool-tasks.json` fails "
"immediately with the same \"no tracker configured\" message as `review`. A provider whose "
"configured access path has no write path (Super Productivity's `access: \"snapshot\"`) "
"refuses **entirely**, exit **1**, naming the `access: \"api\"` instance to use instead - "
"same posture as `new project`. Unlike `new project`, **never exits 42**: every provider "
"offering a write path at all has a real item-creation call (Super Productivity's "
"`POST /tasks`, where `POST /projects` does not exist) - a named `--project` that does not "
"match any tracker project, or `--waiting` against a provider that cannot represent it "
"right now (Super Productivity: the `waiting` tag does not exist yet, and tags cannot be "
"created via its API), are ordinary exit-1 refusals instead, creating nothing",
failures=(cli_contract.Failure(
label="",
exit_1="No `.wikitool-tasks.json`, neither or both of `--project`/`--inbox` given, a "
"`--follow-up-at` without `--waiting` or not `YYYY-MM-DD`, a `--project` name matching "
"no tracker project, `--waiting` against a provider with no way to represent it right "
"now (Super Productivity: the `waiting` tag does not exist), or a read-only access path "
"(Super Productivity's `access: \"snapshot\"`)",
retry="Not transient; fix the argument, create the missing tracker project or tag "
"first, or point at an `access: \"api\"` instance, then retry once. **Never exit 42** - "
"unlike `new project`, every provider offering a write path at all has a real "
"item-creation call, so there is no human-clearance step to wait on here",
),),
))
def task_new_command(
title: str = typer.Option(..., "--title", help="The item's title. Stored verbatim, never parsed."),
project: Optional[str] = typer.Option(
None,
"--project",
help="An existing tracker project's name (matched case-insensitively). This command "
"never searches or guesses one (Gitea #132 D6) and never creates one - use "
"never searches or guesses one and never creates one - use "
"`wikitool new project` first if it does not exist yet. Exactly one of --project/--inbox "
"is required.",
),
inbox: bool = typer.Option(
False,
"--inbox",
help="File into the tracker's own inbox instead of a project (Gitea #132 D4 'Weg 3') - "
help="File into the tracker's own inbox instead of a project - "
"the deliberately chosen exit when no project fits, never a stand-in for an omitted "
"--project. An item filed here is invisible to `wikitool review`, since every check "
"there is reached through a project name and the inbox has none.",
),
waiting: bool = typer.Option(
False, "--waiting", help="Tag the item WAITING (#119 D9/D30) - the review's check 2 reads this."
False, "--waiting", help="Tag the item WAITING - the review's check 2 reads this."
),
follow_up_at: Optional[str] = typer.Option(
None,
"--follow-up-at",
help="YYYY-MM-DD. Only meaningful together with --waiting - it is never a due date "
"(#119 D9) and is refused without --waiting.",
"and is refused without --waiting.",
),
notes: Optional[str] = typer.Option(
None,
"--notes",
help="A freetext backref, e.g. to the kb/ source page this item came from (Gitea #132 "
"D5). Stored verbatim, never parsed.",
help="A freetext backref, e.g. to the kb/ source page this item came from. "
"Stored verbatim, never parsed.",
),
):
"""Create one open item in the configured task tracker - no kb/ page.
\f
Tracker-only by design (#132 D1): a source that carries both knowledge
and a commitment gets this command for the commitment and the normal
page-creation commands for the knowledge, run as two separate steps by
@@ -132,15 +181,41 @@ def task_new_command(
@app.command("list")
@cli_contract.record(cli_contract.CommandRecord(
path="task list",
summary="List a project's open items - id, title, and whether each carries the WAITING status.",
synopsis=(cli_contract.Variant(usage='task list --project "<Name>"'),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Yes - read-only, nothing to leave half-written",
budget=cli_contract.Budget.COUNTED,
network=cli_contract.Network.YES,
),
notes="Read-only; the id source `task close` and the review's own "
"`waiting_overdue`/`someday_stale` findings need, without first running `wikitool review`. "
"Works on either access mode a provider offers, unlike the write commands below. No "
"`.wikitool-tasks.json` fails with the same \"no tracker configured\" message as "
"`review`/`task new`; a `--project` matching no tracker project prints \"No open items\", "
"since `TaskReader.open_items` does not distinguish \"empty\" from \"unknown\" "
"(`chemenu.tasks.protocol.TaskReader.open_items`'s own docstring)",
failures=(cli_contract.Failure(
label="",
exit_1="No `.wikitool-tasks.json`",
retry="Not transient; configure a tracker first, then retry once. A `--project` "
"matching no tracker project is not an error here - see its Commands row",
),),
))
def task_list_command(
project: str = typer.Option(
..., "--project", help="An existing tracker project's name (matched case-insensitively)."
),
):
"""List a project's open items - id, title, and WAITING status (Gitea
#138) - so a caller can get an item's id for `task close` without first
running `wikitool review`. Read-only; works against either access mode a
provider offers."""
"""List a project's open items - id, title, and whether each carries the WAITING status.
\f
So a caller can get an item's id for `task close` without first running
`wikitool review` (Gitea #138). Read-only; works against either access
mode a provider offers."""
cfg = tasks_config.read_config(config.ROOT)
if cfg is None:
fail(
@@ -164,16 +239,47 @@ def task_list_command(
@app.command("close")
@cli_contract.record(cli_contract.CommandRecord(
path="task close",
summary="Mark one tracker item done - never delete it.",
synopsis=(cli_contract.Variant(usage="task close --id <item-id>"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Yes - a single API call; an unknown id is rejected by the provider itself "
"(Super Productivity: `404 TASK_NOT_FOUND`) before anything is written",
budget=cli_contract.Budget.COUNTED,
network=cli_contract.Network.YES,
),
notes="`<item-id>` is the provider's own id, from `task list` or a `review` finding, never "
"a title - the tracker-side identity is opaque, unlike the project name that is `kb/`'s and "
"the tracker's only shared coupling. The only closing write this stack makes: no \"move a "
"reminder\", no \"remove an item\". No `.wikitool-tasks.json` fails with the same \"no "
"tracker configured\" message as `task new`. A provider whose configured access path has no "
"write path (Super Productivity's `access: \"snapshot\"`) refuses **entirely**, exit **1**, "
"naming the `access: \"api\"` instance to use instead - same posture as `task new`. Never "
"exits 42, same reasoning as `task new`: every provider offering a write path has a real "
"per-item write call",
failures=(cli_contract.Failure(
label="",
exit_1="No `.wikitool-tasks.json`, an `--id` matching no tracker item right now, or a "
"read-only access path (Super Productivity's `access: \"snapshot\"`)",
retry="Not transient; fix the id (re-run `task list` or `review` to get a current one) "
"or point at an `access: \"api\"` instance, then retry once. **Never exit 42**, same "
"reasoning as `task new`",
),),
))
def task_close_command(
item_id: str = typer.Option(
...,
"--id",
help="The tracker's own item id (Gitea #138), e.g. from `task list` or `wikitool "
help="The tracker's own item id, e.g. from `task list` or `wikitool "
"review`'s waiting_overdue/someday_stale findings - never a title.",
),
):
"""Mark one tracker item done (Gitea #138) - never delete it. The only
closing write this stack makes; see
"""Mark one tracker item done - never delete it.
\f
The only closing write this stack makes (Gitea #138); see
`chemenu.tasks.protocol.TaskWriter.close_item` and
`docs/knowledge-and-commitment.md` for why."""
cfg = tasks_config.read_config(config.ROOT)
+37 -2
View File
@@ -22,7 +22,7 @@ import typer
# Hard, non-optional dependency - see type_resolver.py's import comment.
from jsonschema import Draft202012Validator, FormatChecker
from chemenu import config
from chemenu import cli_contract, config
from chemenu.commands._util import (
check_raw_files_exist,
fail,
@@ -183,6 +183,41 @@ def _apply_remove(frontmatter: Dict[str, Any], field: str, value: Any) -> Option
return f"{field}: removed {', '.join(repr(i) for i in present)}"
@cli_contract.record(cli_contract.CommandRecord(
path="touch",
summary="Bump a page's `modified:` date and optionally rewrite its other frontmatter fields.",
synopsis=(cli_contract.Variant(
usage='touch --page "<Title>" [--summary "..."] [--provenance <v>] [--date YYYY-MM-DD] '
"[--set field=value ...] [--add field=value ...] [--remove field=value ...] "
"[--no-date] [--dry-run]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="Yes - single file write, and every refusal happens before it",
budget=cli_contract.Budget.COUNTED,
),
notes="Update a page's own frontmatter: bump `modified:` and optionally rewrite any field "
"its type declares. `--summary`/`--provenance` are shorthands; `--set` reaches every other "
"field and **replaces** its value, while `--add`/`--remove` change single elements of an "
"array field (removing an absent element succeeds and says so). Repeating `--set` for one "
"array field appends *within the call*, and `\\,` is a literal comma - same rules as "
"`new --set`. Refused with the command that owns them instead: `type:` (page-lifecycle), "
"and the page-ref arrays `related:`/`sources:`/`entities:`/`concepts:` (`xref`). Everything "
"else the schema declares is settable, and an unknown field lists what the page actually "
"has. Schema-validates the fields it writes, and `raw_files:` entries must exist on disk. A "
"source declares `date:` instead of `modified:`, and that is the *publication* date of the "
"raw material - it is never bumped to today, and changes only when `--date` names a value "
"explicitly.",
failures=(cli_contract.Failure(
label="",
exit_1="Page not found; an invalid value for a field it writes; a field owned by "
"another command (`type:`, a page-ref array) or absent from the type's schema; "
"`--add`/`--remove` on a non-array field; a `raw_files:` path that doesn't exist",
retry="Fix the argument and retry once. Safe to re-run as-is: `--set` and `--add` are "
"idempotent, and `--remove` of an already-absent element succeeds while reporting it",
),),
))
def touch_command(
page_title: str = typer.Option(..., "--page", help="Exact page title, e.g. 'Docker Cheatsheet'"),
summary: Optional[str] = typer.Option(None, "--summary", help="Replace the page's 1-line summary"),
@@ -219,7 +254,7 @@ def touch_command(
dry_run: bool = typer.Option(False, "--dry-run", help="Preview the new frontmatter instead of writing"),
):
"""Bump a page's `modified:` date and optionally rewrite its other frontmatter fields.
\f
`--summary`/`--provenance` are shorthands for the two fields worth their
own flag; `--set`/`--add`/`--remove` reach every other field the page's
type declares. Before they existed, a field `new` wrote
+48 -8
View File
@@ -17,7 +17,7 @@ import json
import typer
from chemenu import toc
from chemenu import cli_contract, toc
from chemenu.commands._util import fail
from chemenu.types_core import UnknownType, describe_type, list_types
@@ -25,6 +25,20 @@ app = typer.Typer(help="Discover and describe Chemenu type-spec contracts.")
@app.command("list")
@cli_contract.record(cli_contract.CommandRecord(
path="types list",
summary="List every type-spec under `types/`.",
synopsis=(cli_contract.Variant(usage="types list [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.COUNTED,
),
notes="Name, schema path, subtype field, and description - discover what page types exist "
"without reading `types/*.md` directly. Never fails. Safe to retry freely.",
failures=(),
))
def list_types_command(
json_out: bool = typer.Option(False, "--json", help="Print raw findings as JSON")
):
@@ -48,17 +62,43 @@ def list_types_command(
@app.command("describe")
@cli_contract.record(cli_contract.CommandRecord(
path="types describe",
summary="Print one type's full contract.",
synopsis=(cli_contract.Variant(usage="types describe <name> [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.COUNTED,
),
notes="Required/optional frontmatter fields with enums, its subtype field (if any), and its "
"authoring body - composed with the stack-owned `types/<name>.guidance.md` where the "
"type-spec declares `guidance:` (`--json` reports it separately as "
"`guidance`/`guidance_path`, absent for a type with none), so a `root: kb` type's contract "
"reads as one answer even though it may live in two files. A type-spec (or its guidance "
"file) over the `docs toc` threshold carries a generated table-of-contents region; it is "
"stripped from this output rather than echoed, since the whole body is being handed over "
"and a navigation aid into it would be noise",
failures=(cli_contract.Failure(
label="",
exit_1="Unknown type name",
retry="Fix the name and retry",
),),
))
def describe_type_command(
name: str = typer.Argument(..., help="Type name, e.g. 'entity' (see `types list`)"),
json_out: bool = typer.Option(False, "--json", help="Print raw findings as JSON"),
):
"""Print one type's full contract: frontmatter fields (required/optional,
with enums where declared), its subtype field if any, and its authoring
body - the same information an LLM would otherwise gather by reading the
raw type-spec and `.schema.yaml` files directly. Where the type-spec
declares `guidance:`, that stack-owned file's prose is composed in ahead
of the type-spec's own body, so a `root: kb` type's contract still reads
as one answer even though it lives in two files (Gitea #104)."""
"""Print one type's full contract.
\f
Frontmatter fields (required/optional, with enums where declared), its
subtype field if any, and its authoring body - the same information an
LLM would otherwise gather by reading the raw type-spec and
`.schema.yaml` files directly. Where the type-spec declares `guidance:`,
that stack-owned file's prose is composed in ahead of the type-spec's own
body, so a `root: kb` type's contract still reads as one answer even
though it lives in two files (Gitea #104)."""
try:
described = describe_type(name)
except UnknownType as exc:
+88 -1
View File
@@ -18,7 +18,7 @@ from typing import Optional
import typer
from chemenu import config, upload
from chemenu import cli_contract, config, upload
from chemenu.commands._util import fail, needs_clearance, rel_path, success
from chemenu.errors import ValidationError
@@ -33,6 +33,22 @@ def _call(fn, *args, **kwargs):
@app.command("list")
@cli_contract.record(cli_contract.CommandRecord(
path="upload list",
summary="List every MCP submission currently waiting in the quarantine (`mcp-upload/`).",
synopsis=(cli_contract.Variant(usage="upload list [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.COUNTED,
),
notes="Oldest id first - id, filename, size, submitter. Only ever non-empty when "
"`.wikitool-upload.json` opts a checkout into the MCP server's `submit` tool (see the MCP "
"read server design note). Never fails - a submission directory with a corrupt manifest is "
"silently skipped. Safe to retry freely.",
failures=(),
))
def upload_list_command(
json_out: bool = typer.Option(False, "--json", help="Print every waiting submission as JSON"),
):
@@ -52,6 +68,24 @@ def upload_list_command(
@app.command("show")
@cli_contract.record(cli_contract.CommandRecord(
path="upload show",
summary="Print one submission's manifest in full.",
synopsis=(cli_contract.Variant(usage="upload show <id> [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.COUNTED,
),
notes="Filename, size, sha256, submitter, submitter source (the header name, not a claim "
"the header was honest), submission time. What a reviewer reads before `accept`",
failures=(cli_contract.Failure(
label="",
exit_1="Unknown or malformed submission id",
retry="Fix the id (see `upload list`) and retry",
),),
))
def upload_show_command(
submission_id: str = typer.Argument(..., help="A submission id from `upload list`"),
json_out: bool = typer.Option(False, "--json"),
@@ -95,6 +129,38 @@ def _clearance_message(manifest: dict, token: str, stale: Optional[str]) -> str:
@app.command("accept")
@cli_contract.record(cli_contract.CommandRecord(
path="upload accept",
summary="**Upload Review Gate:** promote a submission's file from quarantine into `incoming/`.",
synopsis=(cli_contract.Variant(usage="upload accept <id> [--confirm TOKEN]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="No - one filesystem move, one directory delete, one ledger append; the gate "
"check runs first, before any of them",
budget=cli_contract.Budget.COUNTED,
gates=("upload-review",),
),
notes="Promote a submission's file from `mcp-upload/<id>/` into `incoming/`, delete the "
"quarantine directory, and append an `accepted` event to `mcp-upload/ledger.jsonl`. Without "
"a matching `--confirm`, exits **42** and prints the manifest in full plus the exact re-run "
"line - the same shape as the Mass-Update Gate's clearance, one submission at a time. The "
"token digests id/filename/size/sha256/submitter, so an edited or superseded manifest "
"invalidates it. Refuses (without the gate - these are ordinary validation errors) when "
"`incoming/<filename>` already exists",
failures=(cli_contract.Failure(
label="",
exit_1="Unknown or malformed submission id, the submission's file is missing from "
"`mcp-upload/<id>/`, or `incoming/<filename>` already exists. **Exit 42, not 1**, when "
"`--confirm` is absent or does not match the manifest's current token - the Upload "
"Review Gate, not a validation error",
retry="For exit 42: show the user the full manifest and the exact `--confirm <token>` "
"re-run line printed, and stop - the same rule as every other exit-42 gate (AGENTS.md "
"invariant 6). For the three exit-1 cases: fix the named argument and retry once; an "
"occupied `incoming/<filename>` is not fixed by retrying unchanged - rename or clear it "
"first",
),),
))
def upload_accept_command(
submission_id: str = typer.Argument(..., help="A submission id from `upload list`"),
confirm: Optional[str] = typer.Option(
@@ -113,6 +179,27 @@ def upload_accept_command(
@app.command("reject")
@cli_contract.record(cli_contract.CommandRecord(
path="upload reject",
summary="Delete a submission's material, keeping only its ledger trail.",
synopsis=(cli_contract.Variant(usage='upload reject <id> --reason "<why>"'),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="No - one ledger append, then one recursive delete; the ledger write happens "
"first, so an interruption still leaves the reason on record",
budget=cli_contract.Budget.COUNTED,
),
notes="An append-only `rejected` event naming the reason and the sha256 of what was "
"declined. No gate - rejecting needs no clearance, only accepting does",
failures=(cli_contract.Failure(
label="",
exit_1="Unknown or malformed submission id, or an empty `--reason`",
retry="Fix the argument and retry once. Not idempotent against a second call with the "
"same id: the first call already deleted the submission, so a retry reports \"unknown "
"id\" - that is confirmation, not a failure",
),),
))
def upload_reject_command(
submission_id: str = typer.Argument(..., help="A submission id from `upload list`"),
reason: str = typer.Option(..., "--reason", help="Why this submission was declined"),
+73 -1
View File
@@ -27,7 +27,7 @@ from typing import Optional
import typer
from chemenu import config, ownership
from chemenu import cli_contract, config, ownership
from chemenu.commands import git_publish
from chemenu.commands._util import console, fail, success
@@ -238,6 +238,55 @@ def _merge_success_message(
return "\n".join(lines)
@cli_contract.record(cli_contract.CommandRecord(
path="upstream merge",
summary="Take a stack update into a private instance's branch, machinery only.",
synopsis=(cli_contract.Variant(
usage="upstream merge [--remote upstream] [--branch main] [--no-fetch]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="**No** - can leave an open, uncommitted merge behind on refusal after fetching",
budget=cli_contract.Budget.COUNTED,
),
notes="The code procedure behind `instructions/private-instance.md` § \"Taking a stack "
"update\". Refuses on a dirty working tree, a merge already in progress, or a remote that "
"does not resolve; WARNs (does not block) when `.wikitool-remotes.json` is absent, pointing "
"at the setup step that arms it. Fetches `<remote>/<branch>` (unless `--no-fetch`) and "
"reports \"already up to date\" if nothing new exists. Otherwise opens "
"`git merge --no-commit --no-ff <remote>/<branch>` - and stops, untouched, if git refused "
"to open a merge at all (unrelated histories), since without a `MERGE_HEAD` every stack "
"path would read as \"the upstream deleted it\". Then forces every content stage (`kb/`, "
"`raw/`, `work/`, `reports/`) back to the local side by removing **only the paths tracked "
"in either tree** and checking `HEAD`'s back out - never the stage directory wholesale, "
"because `reports/` is gitignored apart from its contract and holds local, non-recomputable "
"data (telemetry traces `eval score` reads, saved eval and lint reports) that no merge has "
"business deleting. Then restores from the upstream side exactly the paths "
"`chemenu.ownership.is_stack_owned` recognises as machinery (`<stage>/CONTRACT.md`, and "
"anything ending `.template` under a content stage) - including a deletion, if the upstream "
"removed one. A real conflict left in `tools/`, `types/` or `instructions/` after that "
"leaves the merge open, uncommitted, and exits 1 rather than guessing. Commits with "
"`git commit --no-edit`, then re-checks the resulting range with the same logic as "
"`upstream verify`; a finding there is a loud, uncommitted-nothing-rolled-back error, "
"because the merge commit already exists and needs a human's eyes, not an automatic repair. "
"Never pushes. Not idempotent - see the tool error contract below",
failures=(cli_contract.Failure(
label="",
exit_1="Dirty working tree, a merge already in progress, the remote does not resolve, "
"git refused to open the merge at all (unrelated histories), or a real conflict remains "
"in `tools/`/`types/`/`instructions/` after the content stages and stack-owned paths "
"were restored",
retry="**Not idempotent, and not safe to retry unchanged.** For a dirty tree or an "
"in-progress merge: fix the named precondition and retry once. For a real conflict: "
"**do not retry, do not force** - resolve the named paths by hand (take the upstream "
"side, or re-file the local change as an issue against the public repo per "
"`instructions/private-instance.md`) and either `git commit --no-edit` yourself or "
"`git merge --abort`. If the postcheck after commit finds a leak, the merge commit "
"already exists and is **not** rolled back automatically - inspect it by hand; this is "
"a bug report, not a retry",
),),
))
@app.command("merge")
def merge_command(
remote: str = typer.Option("upstream", "--remote", help="Remote to merge from"),
@@ -371,6 +420,29 @@ def _verify_success_message(stack_moved: list[str], since: str, until: str) -> s
)
@cli_contract.record(cli_contract.CommandRecord(
path="upstream verify",
summary="Compare two revisions: did anything under a content stage change except through "
"a stack-owned path?",
synopsis=(cli_contract.Variant(usage="upstream verify --since <rev> [--until HEAD]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
),
notes="Shares its check with `upstream merge`'s own postcheck, so a hand-resolved merge "
"conflict, or a `dist upgrade`, can be verified the same way. Exit 1 with the offending "
"paths if anything leaked; otherwise reports which stack-owned paths legitimately moved. "
"Read-only and exempt from the Iteration Budget Gate, like `migrate verify`",
failures=(cli_contract.Failure(
label="",
exit_1="A leak was found (content changed under a content stage through a path that is "
"not stack-owned), or `--since`/`--until` is not a revision in this repository",
retry="A finding is not fixed by re-running - it names the paths that leaked. Fix the "
"revision argument and retry for the second case",
),),
))
@app.command("verify")
def verify_command(
since: str = typer.Option(..., "--since", help="Git revision to compare from"),
+227 -7
View File
@@ -40,7 +40,7 @@ import typer
from rich.console import Console
from chemenu import config, version as version_mod
from chemenu import cli_contract, config, version as version_mod
from chemenu.commands._util import console, fail, rel_path, success, today_iso
from chemenu.version import Version, VersionError
@@ -79,6 +79,25 @@ def _describe_origin(stamp: Optional[dict]) -> str:
return "distribution: " + ", ".join(parts) if parts else "distribution"
@cli_contract.record(cli_contract.CommandRecord(
path="version show",
summary="Print this instance's stack version and where it came from.",
synopsis=(cli_contract.Variant(usage="version show [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
),
notes="Development tree, or a distribution with its export date and origin. Bare "
"`wikitool version` is an alias for this. Read-only, offline, and **exempt from the "
"Iteration Budget Gate**",
failures=(cli_contract.Failure(
label="",
exit_1="`VERSION` is missing or unparseable",
retry="Fix `VERSION` and retry",
),),
))
@app.command("show")
def show_command(
json_out: bool = typer.Option(False, "--json", help="Print the version and stamp as JSON"),
@@ -111,6 +130,34 @@ def show_command(
console.print(f"release: {stamp['release_url']}")
@cli_contract.record(cli_contract.CommandRecord(
path="version check",
summary="Ask the origin's release feed whether a newer stack exists.",
synopsis=(cli_contract.Variant(usage="version check [--url U] [--timeout S] [--json]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only, no local writes",
budget=cli_contract.Budget.EXEMPT,
network=cli_contract.Network.YES,
),
notes="Ask the origin's release feed whether a newer stack exists, and whether the step "
"crosses a compatibility boundary (`state: current|update|migration|ahead`). One of the "
"**two** commands in `wikitool` that make a network call, and the only one whose whole job "
"it is - `version notes` is the other, and only on a distributed instance. Never reached "
"implicitly from another command, needs no key, times out, and reports an unreachable feed "
"as an error rather than as \"up to date\". The feed is `$WIKITOOL_UPDATE_URL`, else the "
"release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that "
"feed is not readable anonymously. Read-only and exempt from the budget gate",
failures=(cli_contract.Failure(
label="",
exit_1="The feed could not be reached, answered non-JSON, or carried no `tag_name`. "
"**Never** answers \"up to date\" for a question it could not ask",
retry="A network failure is transient - retry once, then report it. HTTP 401/403 names "
"`$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the "
"wrong repo",
),),
))
@app.command("check")
def check_command(
url: Optional[str] = typer.Option(
@@ -178,6 +225,47 @@ def check_command(
)
@cli_contract.record(cli_contract.CommandRecord(
path="version notes",
summary="Print one version's release notes.",
synopsis=(cli_contract.Variant(
usage="version notes [--version X.Y.Z] [--offline] [--url U] [--timeout S]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.READ,
idempotent=cli_contract.Idempotent.YES,
atomic="Read-only",
budget=cli_contract.Budget.EXEMPT,
network=cli_contract.Network.YES,
),
notes="Default: this tree's `VERSION`. The `CHANGES.md` entry where there is one, and where "
"there is not, the feed's latest release notes. The fallback exists because an instance's "
"`CHANGES.md` is a stub `dist upgrade` never overwrites (`chemenu.ownership.is_upgrade_"
"preserved`), so the local file can never carry the entry - not today and not after any "
"future release, which made the command permanently unanswerable exactly where the release "
"notes are most needed. It is reached **only with a release stamp present**, i.e. only from "
"a `dist export` tree: a dev checkout keeps the plain error, which is what keeps the origin "
"repo and CI offline. **stdout carries nothing but the notes**; the line naming the feed "
"being asked, and the one naming the release that answered, go to stderr - `release.yml` "
"redirects stdout into the file it posts as the release body. Only the feed's *latest* "
"release can be asked for (`update_url` is the one URL a stamp records, and composing a "
"by-tag URL out of it would be guessing at an API shape), so a returned version other than "
"the one asked for is named on stderr and printed anyway - the expected shape before an "
"upgrade, where `VERSION` still names the release being left. `--offline` refuses the call "
"and fails with the stamp's `release_url` instead. Read-only and exempt from the budget gate",
failures=(cli_contract.Failure(
label="",
exit_1="An unparseable `--version`, an unreadable `VERSION` when `--version` is "
"omitted, or a missing `CHANGES.md`. No entry for the requested version is an error "
"only where the feed cannot answer either: in a tree with no release stamp (a dev "
"checkout - write the entry, or `version bump`), with `--offline`, or when the feed "
"could not be reached or returned a release with an empty `body`. Every one of those "
"failures names the stamp's `release_url` where it has one, so a run that cannot read "
"the notes is still told where they are",
retry="Fix the named argument or file, then retry. A feed failure is transient - retry "
"once, then read the release page the error names. Safe to retry",
),),
))
@app.command("notes")
def notes_command(
version: Optional[str] = typer.Option(
@@ -296,6 +384,70 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str
)
@cli_contract.record(cli_contract.CommandRecord(
path="version bump",
summary="Raise or continue the one running candidate between two releases.",
synopsis=(cli_contract.Variant(
usage="version bump --major|--minor|--patch --title \"<...>\" "
"[--impact high|medium|low] [--breaking \"<what breaks>\"] "
"[--no-migration \"<reason>\"] [--migration-required] [--dry-run]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="No - `VERSION` then `CHANGES.md`",
budget=cli_contract.Budget.COUNTED,
),
notes="`VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. "
"`--major/--minor/--patch` is **max-wins escalation** against the last release "
"(patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and "
"escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump "
"of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` "
"list of every `--title` collected so far, graded by `--impact`, default `medium`) and "
"updates that same entry in place on every later bump of the same candidate - one entry per "
"candidate, not one per bump. The list renders grouped under "
"`**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so "
"far is `medium`, where it stays the flat, ungrouped list the region always had - "
"`version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an "
"empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. "
"Compatibility follows the **leftmost non-zero component** of the candidate's base, which "
"for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible "
"capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on "
"update, or a downgrade that no longer works. Whether content must be migrated is a second, "
"independent question. The bump that first escalates a candidate past the boundary requires "
"`--breaking \"<what stops working>\"` and, on top of it, a migration document targeting "
"the candidate's base or `--no-migration \"<reason>\"`; both are anchored just above the "
"bump list, persist over later bumps of the same candidate without being repeated, and are "
"refused on a bump that crosses nothing at all. The two then behave differently on a "
"*second* crossing, because they answer different questions: a further `--breaking` "
"**joins** the ones already recorded (one reason per crossing - rendered flat on the marker "
"line while there is only one, as bullets under a bare marker from the second onward, and "
"repeating a reason verbatim is a no-op), while a further `--no-migration` **replaces** the "
"single line that says whether content has to change. A candidate crossing the boundary "
"twice is the normal shape of a long-running one, and each crossing is a separate thing an "
"operator has to act on; whether content migrates stays one yes/no about the candidate as a "
"whole. There is deliberately no retraction path for a single accumulated `--breaking` "
"reason - `--migration-required` retracts the migration line, and nothing retracts a "
"breaking one. A later bump of the same candidate that finds out `--no-migration` was wrong "
"after all retracts that line with `--migration-required` instead of restating "
"`--no-migration` - refused without a migration document already targeting the new base, "
"and without an existing `--no-migration` line to retract. Which part a change earns stays "
"a judgment call: the command enforces that a crossing documents itself, never that the "
"part was chosen correctly",
failures=(cli_contract.Failure(
label="",
exit_1="More or fewer than one of `--major/--minor/--patch`, an empty `--title`, an "
"unknown `--impact`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's "
"newest entry naming different versions, an escalation to a boundary crossing without "
"`--breaking` or with neither a migration document nor `--no-migration`, "
"`--breaking`/`--no-migration` on a bump that crosses nothing, or `--migration-required` "
"combined with `--no-migration`, on a bump with no running candidate, with no "
"`--no-migration` line to retract, or without a migration document already targeting "
"the new base",
retry="**Not idempotent**: a second run escalates or continues the candidate again. If "
"the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying",
),),
))
@app.command("bump")
def bump_command(
major: bool = typer.Option(False, "--major", help="Bump MAJOR (resets MINOR and PATCH)"),
@@ -330,7 +482,7 @@ def bump_command(
):
"""Raise or continue the running candidate, and open or update its
`CHANGES.md` entry.
\f
Between two releases the stack carries **one** candidate, not a fresh
number per bump: `--patch/--minor/--major` is max-wins escalation against
the last release, never a step back down, and the candidate's bump count
@@ -490,6 +642,40 @@ def bump_command(
)
@cli_contract.record(cli_contract.CommandRecord(
path="version release",
summary="Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its "
"`CHANGES.md` entry.",
synopsis=(cli_contract.Variant(usage='version release [--title "<...>"] [--dry-run]'),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="No - `VERSION` then `CHANGES.md`",
budget=cli_contract.Budget.COUNTED,
),
notes="Ends the pre-release phase `version bump` started. Without `--title` the heading "
"keeps whichever bump last set it; with it, the heading's title is replaced - the normal "
"case for a candidate that collected several bump titles, since the entry wants a "
"summarising heading rather than the most recent one. Leaves the entry's machine-managed "
"bump-title list untouched, as the record of what happened. Refuses when the candidate "
"collected two or more bumps and the entry still carries no summary paragraph (at least 200 "
"non-whitespace characters) between the bump list and the first `### <bump title>` "
"changeset heading; a candidate with exactly one bump is exempt, since there its own "
"changeset already is the summary. `--dry-run` runs this check too and reports the same "
"refusal. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves "
"`VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a "
"release (no running candidate to fix), or when the changelog's newest entry does not match "
"`VERSION`",
failures=(cli_contract.Failure(
label="",
exit_1="A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running "
"candidate), `VERSION` and the changelog's newest entry naming different versions, or "
"(from two bumps on) an entry with no summary paragraph above the changesets",
retry="**Not idempotent**: a second run fails outright once the suffix is gone. If the "
"outcome is uncertain, read `VERSION` before retrying - a release-shaped `VERSION` "
"means it already ran",
),),
))
@app.command("release")
def release_command(
title: Optional[str] = typer.Option(
@@ -499,7 +685,7 @@ def release_command(
):
"""Fix the running candidate: strip its `-beta.N` suffix and close its
`CHANGES.md` entry.
\f
Ends the pre-release phase this checkout has been in since its last
`version bump` - the candidate's base becomes the release. Without
`--title` the heading keeps whichever bump last set it; with it, the
@@ -576,6 +762,40 @@ def release_command(
)
@cli_contract.record(cli_contract.CommandRecord(
path="version regrade",
summary="List the running candidate's bump titles with their impact grade, or change one "
"or more of them.",
synopsis=(cli_contract.Variant(
usage="version regrade [INDICES...] [--impact high|medium|low]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="No - `CHANGES.md` only, and only when indices are given",
budget=cli_contract.Budget.EXEMPT_WITHOUT_ARGS,
),
notes="1-based rendered position (no arguments - the correction path for a `--impact` "
"judgement made at bump time), or change one or more of them in a single call: "
"`version regrade 3 7 --impact high` grades both against a single read of today's list, not "
"position 3 first and then position 7 against whatever that produced. Touches only the "
"topmost entry's bump list - never `VERSION`, never any other part of `CHANGES.md`. The "
"bare listing is read-only and exempt from the Iteration Budget Gate, like `version notes`; "
"a call with indices writes `CHANGES.md` and is counted like `version bump`. Refuses an "
"index outside the rendered list's range, an unknown `--impact`, indices given without "
"`--impact`, a missing `VERSION`/`CHANGES.md`, a `VERSION`/newest-changelog-entry mismatch, "
"or a topmost entry with no bump list at all",
failures=(cli_contract.Failure(
label="",
exit_1="A missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry "
"naming different versions, a topmost entry with no bump list, an index outside the "
"rendered list's range, indices given without `--impact`, or an unknown `--impact`",
retry="The bare listing never writes anything. A write is **not idempotent** against a "
"changed list: re-running the same indices after a first success regrades whatever is "
"at those positions *now*, which may no longer be the same bumps - list again before "
"retrying",
),),
))
@app.command("regrade")
def regrade_command(
indices: Optional[list[int]] = typer.Argument(
@@ -589,13 +809,13 @@ def regrade_command(
):
"""List the running candidate's bump titles with their impact grade, or
change one or more of them in a single call.
\f
Positions are `version_mod.bump_entries`'s own rendered order - grouped
High before Medium before Low, chronological within a grade - as it
stands *before* this call: `wikitool version regrade 3 7 --impact high`
regrades both against today's list in one read, not #3 first and then #7
against whatever regrading #3 produced. Run the bare command again
afterwards to see the result and its new numbering.
regrades both against today's list in one read, not position 3 first and
then position 7 against whatever regrading position 3 produced. Run the
bare command again afterwards to see the result and its new numbering.
The bare listing is read-only and, like `version notes`, exempt from the
Iteration Budget Gate; passing indices writes `CHANGES.md` and is counted
+46 -1
View File
@@ -14,7 +14,7 @@ from typing import Optional
import typer
from chemenu import config
from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success, today_iso
app = typer.Typer(help="Workshop runs under work/ (see work/CONTRACT.md).")
@@ -130,6 +130,32 @@ Cut the tree into units. One unit does one job and becomes one source page. A un
@app.command("new")
@cli_contract.record(cli_contract.CommandRecord(
path="work new",
summary="Scaffold `work/<runkey>/` for one workshop run.",
synopsis=(cli_contract.Variant(
usage="work new (--input <raw path> | --key <run key>) [--again] [--dry-run]",
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="Yes - one directory with two files",
budget=cli_contract.Budget.COUNTED,
),
notes="Refuses a collision instead of suffixing it, and writes the required `README.md` + "
"`plan.md`. `--input` derives the run key from the path below `raw/` (an ingest); `--key` "
"names it outright for a run with no raw input - a migration or a sweep across `kb/` - and "
"may not start with `ingest-`, which stays reserved for derived keys. Exactly one of the "
"two. `--again` opens a dated second pass over a tree that has itself changed. See "
"`work/CONTRACT.md`",
failures=(cli_contract.Failure(
label="",
exit_1="Neither or both of `--input`/`--key` given, `--input` outside `raw/`, a "
"`--key` that is empty or starts with `ingest-`, or the workshop already exists",
retry="A collision is not transient: resume the existing run instead, or pass "
"`--again` if the tree itself changed. Never create a numbered variant by hand",
),),
))
def new_command(
input_path: Optional[str] = typer.Option(
None,
@@ -211,6 +237,25 @@ def new_command(
@app.command("close")
@cli_contract.record(cli_contract.CommandRecord(
path="work close",
summary="Delete a finished workshop.",
synopsis=(cli_contract.Variant(usage="work close --run-key <name> [--yes] [--dry-run]"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="No - a recursive delete",
budget=cli_contract.Budget.COUNTED,
),
notes="Lists what would be lost and requires `--yes`, because nothing in it is recoverable "
"from the rest of the repo - the durable conclusions must already be in `kb/`",
failures=(cli_contract.Failure(
label="",
exit_1="Unknown run key, or `--yes` was not passed",
retry="For \"not confirmed\": check the listed files are no longer needed, confirm the "
"conclusions are in `kb/`, then re-run with `--yes`",
),),
))
def close_command(
run_key: str = typer.Option(..., "--run-key", help="The workshop directory name"),
yes: bool = typer.Option(
+82 -1
View File
@@ -25,7 +25,7 @@ from pathlib import Path
import typer
from chemenu import blocks, config, conventions, kb_collections, links
from chemenu import blocks, cli_contract, config, conventions, kb_collections, links
from chemenu.commands._util import fail, parse_list, success
from chemenu.commands.page_ops import strip_frontmatter_ref
from chemenu.frontmatter_io import write_page
@@ -146,6 +146,34 @@ def _check_authorised(source: Page, target: Page, label: str) -> None:
@app.command("add")
@cli_contract.record(cli_contract.CommandRecord(
path="xref add",
summary="Declare that A <rel> B.",
synopsis=(cli_contract.Variant(usage='xref add --a "<A>" --b "<B>" --rel <label> [--dry-run]'),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="No - writes A then B, but both edits are idempotent, and both refusals happen "
"before either write",
budget=cli_contract.Budget.COUNTED,
),
notes="Declare **one** edge: `A <label> B`, written into A's `related:` as `- <label>: B` "
"and rendered into A's generated links region. B is not touched and does not point back - "
"its inbound view is rendered from the graph. Idempotent, and re-running with a different "
"label *relabels* rather than appending, since one page asserts one thing about another. "
"Refuses before writing when the type does not declare `related:` (a source page declares "
"`entities:`/`concepts:` - the refusal names them and points at `link-source`), and when "
"`<label>` is not authorised by the source collection's `outbound:` block for the target's "
"collection; that refusal lists the authorised set and points at "
"`instructions/link-taxonomy.md`",
failures=(cli_contract.Failure(
label="",
exit_1="Page A or B not found, or a page's type declares no `related:` field",
retry="Safe to retry once as-is; re-running never duplicates a link. Never create the "
"missing page just to force the link through, and never hand-write a reference field "
"the type does not declare",
),),
))
def xref_add(
a: str = typer.Option(..., "--a", help="Exact title of the page that asserts the edge"),
b: str = typer.Option(..., "--b", help="Exact title of the page it points at"),
@@ -214,6 +242,31 @@ def remove_link_bullets(body: str, other_title: str) -> str:
@app.command("remove")
@cli_contract.record(cli_contract.CommandRecord(
path="xref remove",
summary="Remove a cross-reference: the inverse of `xref add`.",
synopsis=(cli_contract.Variant(usage='xref remove --a "<A>" --b "<B>" [--dry-run]'),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="No - writes A then B, both idempotent",
budget=cli_contract.Budget.COUNTED,
),
notes="Clears the reference in **both** directions - it is the cleanup command for a "
"deleted or hand-renamed page rather than the strict inverse of a one-directional `add`. "
"Clears `<B>` from every page-ref frontmatter field `<A>`'s type declares (`related:`, "
"`sources:`, `entities:`, `concepts:`) plus the matching bullets. It also sweeps a field the "
"type does *not* declare but some other type does, and drops that key outright once empty - "
"a leftover written before the check above existed has to stay repairable, or the page is a "
"dead end. `--b` need not still exist as a page, so this is how a reference left by a "
"hand-deleted or hand-renamed page gets cleared without hand-editing frontmatter. "
"Idempotent.",
failures=(cli_contract.Failure(
label="",
exit_1="Page A not found (B is allowed not to exist)",
retry="Safe to retry freely; removing an absent link is a no-op",
),),
))
def xref_remove(
a: str = typer.Option(..., "--a", help="Exact title of page A (must exist)"),
b: str = typer.Option(..., "--b", help="Title to unlink from A; need not still exist as a page"),
@@ -272,11 +325,39 @@ def xref_remove(
@app.command("link-source")
@cli_contract.record(cli_contract.CommandRecord(
path="xref link-source",
summary="Batch-link a source page to every entity/concept it mentions.",
synopsis=(cli_contract.Variant(
usage='xref link-source --source "Source - X" --entities A,B,C [--dry-run]',
),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.YES,
atomic="No - one write per entity plus one for the source page, idempotent per page",
budget=cli_contract.Budget.COUNTED,
),
notes="Each target gets `sources:`, and the source page records each target in its own "
"`entities:`/`concepts:`. No body bullet is written on either side - `sources:` *is* the "
"record, and the See Also bullet this used to add was the reciprocal half of a model that "
"no longer exists. Which of the two is chosen follows the target's collection "
"(`kb/entities/` -> `entities:`), so a new collection needs no code change here. A target "
"whose collection matches no reference field the source type declares is linked one-way "
"and named in the output. Idempotent in both directions",
failures=(cli_contract.Failure(
label="",
exit_1="Source page not found, an entity in `--entities` doesn't exist, or the source "
"page itself could not be written after its targets were",
retry="Use `--dry-run` first; safe to retry. `sources trace --page \"<Title>\"` shows "
"who was already linked",
),),
))
def xref_link_source(
source: str = typer.Option(..., "--source", help="Exact source page title, e.g. 'Source - Docker Cheatsheet'"),
entities: str = typer.Option(..., "--entities", help="Comma-separated entity/concept titles the source mentions"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview which pages would be linked instead of writing"),
):
"""Batch-link a source page to every entity/concept it mentions."""
pages = load_kb_pages(config.KB_DIR)
source_page = _find_page(pages, source)
names = parse_list(entities)
+221
View File
@@ -0,0 +1,221 @@
"""Unit tests for the command-contract data model (Gitea #121, B1/B4).
Deliberately against a small fixture registry, not the real CLI's - the real
registry is exercised by `test_docs_verify.py` (B7's checks) and by
`test_cli.py` (`-h` output). Nothing here touches the filesystem or the
hermetic-environment fixture.
"""
from __future__ import annotations
import pytest
from chemenu import cli_contract as cc
def _fixture_record(**overrides) -> cc.CommandRecord:
defaults = dict(
path="frobnicate",
summary="Frobnicate the widget.",
synopsis=(cc.Variant(usage="frobnicate --widget <name>"),),
properties=cc.Properties(
effect=cc.Effect.WRITE,
idempotent=cc.Idempotent.NO,
atomic="Yes - single file write",
budget=cc.Budget.COUNTED,
),
notes="Frobnicates the named widget in place.",
failures=(cc.Failure(label="", exit_1="Widget not found", retry="Fix the name and retry once"),),
)
defaults.update(overrides)
return cc.CommandRecord(**defaults)
@pytest.fixture(autouse=True)
def _clean_registry():
"""Every test gets an empty registry and leaves one behind - the real
CLI's records are registered at import time in a different module and
must never leak into, or be clobbered by, these tests."""
saved = cc.all_records()
cc.reset_registry_for_tests()
try:
yield
finally:
cc.reset_registry_for_tests()
for rec in saved.values():
cc._REGISTRY[rec.path] = rec
def test_record_registers_under_its_path():
rec = _fixture_record()
@cc.record(rec)
def frobnicate_command():
pass
assert cc.get("frobnicate") is rec
assert frobnicate_command.__wikitool_contract__ is rec
def test_record_refuses_duplicate_path():
cc.record(_fixture_record())(lambda: None)
with pytest.raises(ValueError):
cc.record(_fixture_record())(lambda: None)
def test_render_text_orders_sections_and_omits_empty_ones():
rec = _fixture_record()
text = cc.render_text(rec)
for present in ("NAME", "SYNOPSIS", "PROPERTIES", "EXIT STATUS", "ON FAILURE", "NOTES"):
assert present in text
for absent in ("EXAMPLES", "NEVER", "SEE ALSO", "OPTIONS"):
assert absent not in text
# Sections appear in the fixed order even though this record only
# populates a subset of them.
order = [s for s in cc._SECTION_ORDER if s in text]
positions = [text.index(s) for s in order]
assert positions == sorted(positions)
assert text.startswith(f"NAME\n wikitool {rec.path} - {rec.summary}")
assert "1 Widget not found" in text
assert "Fix the name and retry once" in text
def test_render_text_includes_optional_sections_when_present():
rec = _fixture_record(
examples=("wikitool frobnicate --widget gizmo",),
never=("Never frobnicate a widget still in use",),
see_also=("wikitool defrobnicate",),
)
text = cc.render_text(rec)
assert "EXAMPLES" in text
assert "wikitool frobnicate --widget gizmo" in text
assert "NEVER" in text
assert "Never frobnicate a widget still in use" in text
assert "SEE ALSO" in text
assert "wikitool defrobnicate" in text
def test_render_text_includes_each_variants_own_notes():
"""Regression guard: `render_text` (the real `wikitool <cmd> -h` output)
used to drop `Variant.notes` while `render_markdown_section` (the
generated tools/CONTRACT.md copy) kept it - a multi-variant command's
live `-h` silently said less than its own documentation."""
rec = _fixture_record(
synopsis=(
cc.Variant(usage="frobnicate <a>", notes="Variant A's own explanation."),
cc.Variant(usage="frobnicate --b <b>", notes="Variant B's own explanation."),
),
)
text = cc.render_text(rec)
assert "Variant A's own explanation." in text
assert "Variant B's own explanation." in text
def test_render_text_splices_options_between_examples_and_exit_status():
rec = _fixture_record()
text = cc.render_text(rec, options_text=" --widget TEXT the widget's name")
assert "OPTIONS" in text
assert text.index("OPTIONS") < text.index("EXIT STATUS")
def test_render_text_omits_on_failure_when_no_failures():
rec = _fixture_record(failures=())
text = cc.render_text(rec)
assert "ON FAILURE" not in text
section = cc.render_markdown_section(rec)
assert "ON FAILURE" not in section
def test_exit_codes_reflect_failures_and_gates():
no_failures = _fixture_record(failures=())
assert cc._exit_codes(no_failures) == [0]
with_gate = _fixture_record(
properties=cc.Properties(
effect=cc.Effect.WRITE,
idempotent=cc.Idempotent.NO,
atomic="No",
budget=cc.Budget.COUNTED,
gates=("mass-update",),
),
)
assert cc._exit_codes(with_gate) == [0, 1, 42]
assert "42" in cc.render_index_line(with_gate).split()[-1] or True # exit column checked below
def test_render_index_line_is_grep_stable():
rec = _fixture_record()
line = cc.render_index_line(rec)
assert line.startswith("frobnicate")
assert "write" in line
assert "non-idempotent" in line
assert "budget:counted" in line
assert "exit:0,1" in line
assert line.rstrip().endswith(rec.summary)
def test_render_index_line_exempt_and_idempotent():
rec = _fixture_record(
properties=cc.Properties(
effect=cc.Effect.READ,
idempotent=cc.Idempotent.YES,
atomic="Read-only",
budget=cc.Budget.EXEMPT,
),
failures=(),
)
line = cc.render_index_line(rec)
assert "read" in line
assert "idempotent" in line and "non-idempotent" not in line
assert "budget:exempt" in line
assert "exit:0" in line
assert "exit:0,1" not in line
def test_grouped_paths_and_group_of_use_supplied_groups():
groups = (
("Fixture Group", ("frobnicate", "defrobnicate")),
("Other Group", ("other",)),
)
assert cc.grouped_paths(groups) == ("frobnicate", "defrobnicate", "other")
assert cc.group_of("defrobnicate", groups) == "Fixture Group"
assert cc.group_of("other", groups) == "Other Group"
assert cc.group_of("missing", groups) is None
def test_render_commands_region_groups_and_orders_records():
groups = (
("Fixture Group", ("frobnicate", "defrobnicate")),
)
frob = _fixture_record()
defrob = _fixture_record(path="defrobnicate", summary="Undo a frobnication.")
region = cc.render_commands_region({"frobnicate": frob, "defrobnicate": defrob}, groups=groups)
assert "### Fixture Group" in region
assert "#### `frobnicate`" in region
assert "#### `defrobnicate`" in region
assert region.index("#### `frobnicate`") < region.index("#### `defrobnicate`")
# The index block lists both paths too, grep-able the same way.
assert "frobnicate" in region.split("```")[1]
assert "defrobnicate" in region.split("```")[1]
def test_render_commands_region_skips_groups_with_no_present_record():
groups = (
("Fixture Group", ("frobnicate",)),
("Empty Group", ("nowhere",)),
)
region = cc.render_commands_region({"frobnicate": _fixture_record()}, groups=groups)
assert "Empty Group" not in region
def test_render_markdown_section_has_no_options_heading():
# The generated markdown never re-derives Click's flag list - only
# `wikitool <cmd> -h` splices OPTIONS in, from live Click introspection.
rec = _fixture_record()
section = cc.render_markdown_section(rec)
assert "OPTIONS" not in section
assert "#### `frobnicate`" in section
assert rec.notes in section
+198 -186
View File
@@ -1,15 +1,16 @@
import pytest
import typer
from chemenu import config, type_resolver
from chemenu import cli_contract, config, type_resolver
from chemenu.commands import dist_cmd, docs_verify
from chemenu.type_resolver import TypeResolver
def test_every_registered_command_is_documented():
"""Forward direction: a command added to the CLI without a README row is
exactly the drift this check exists to catch."""
assert docs_verify.check_cli_readme() == []
def test_every_registered_command_has_a_contract_record():
"""Forward direction: a command added to the CLI with no `@cli_contract.record`
(or missing from `cli_contract.GROUPS`) is exactly the drift this check
exists to catch (Gitea #121 B7)."""
assert docs_verify.check_command_contracts() == []
def test_registered_commands_include_groups_and_top_level():
@@ -21,197 +22,208 @@ def test_registered_commands_include_groups_and_top_level():
assert "docs verify" in commands
def test_undocumented_command_is_reported(monkeypatch):
monkeypatch.setattr(
docs_verify, "registered_commands", lambda: {"new", "frobnicate"}
)
monkeypatch.setattr(docs_verify, "top_level_names", lambda: {"new", "frobnicate"})
issues = docs_verify.check_cli_readme()
assert any("frobnicate" in issue for issue in issues)
def test_a_command_with_no_record_is_reported(monkeypatch):
monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"new", "frobnicate"})
issues = docs_verify.check_command_contracts()
assert any("frobnicate" in issue and "no cli_contract record" in issue for issue in issues)
def test_documented_but_nonexistent_command_is_reported(monkeypatch):
def test_a_groups_entry_with_no_registered_command_is_reported(monkeypatch):
monkeypatch.setattr(docs_verify, "registered_commands", lambda: set())
monkeypatch.setattr(docs_verify, "top_level_names", lambda: set())
issues = docs_verify.check_cli_readme()
assert any("is not a wikitool command" in issue for issue in issues)
issues = docs_verify.check_command_contracts()
assert any("is not a registered wikitool command" in issue for issue in issues)
def test_invented_subcommand_under_a_real_group_is_caught(tmp_path, monkeypatch):
"""Regression guard: checking only the first token (`xref`) let a typo'd
or invented subcommand sit undetected forever next to a real command
group. The reverse check must match the full registered path, not just
the top-level word."""
fake = tmp_path / "README.md"
fake.write_text(
"## Commands\n\n"
"| `xref frobnicate --a X --b Y` | does not exist |\n\n"
"## Error contracts\n\n"
"| `xref frobnicate --a X --b Y` | does not exist |\n",
encoding="utf-8",
)
monkeypatch.setattr(docs_verify, "CLI_README", fake)
monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"xref add", "xref remove"})
issues = docs_verify.check_cli_readme()
assert any("xref frobnicate" in issue for issue in issues)
def test_a_duplicate_groups_entry_is_reported(monkeypatch):
groups = (("Fixture", ("new", "new")),)
monkeypatch.setattr(cli_contract, "GROUPS", groups)
issues = docs_verify.check_command_contracts()
assert any("more than once" in issue for issue in issues)
# --- section-scoped § Commands vs. § Error contracts (Gitea #91) ------------
def test_section_text_extracts_between_headings():
text = "# T\n\n## A\n\nfoo\n\n## B\n\nbar\n"
assert docs_verify.section_text(text, "## A").strip() == "foo"
def test_section_text_extends_to_end_of_file_when_last():
text = "# T\n\n## A\n\nfoo\nbar\n"
assert docs_verify.section_text(text, "## A").strip() == "foo\nbar"
def test_section_text_raises_on_missing_heading():
with pytest.raises(ValueError):
docs_verify.section_text("# T\n\nno headings here\n", "## Commands")
def _fake_contract(tmp_path, commands_rows: str, error_rows: str):
fake = tmp_path / "CONTRACT.md"
fake.write_text(
f"## Commands\n\n{commands_rows}\n## Error contracts\n\n{error_rows}\n",
encoding="utf-8",
)
return fake
def test_a_row_deleted_from_commands_is_caught_even_if_error_contracts_still_has_it(
tmp_path, monkeypatch
):
"""Regression for the bug the section split fixes: before, a name
surviving in either table hid its own deletion from the other, so
§ Commands losing a row was invisible as long as § Error contracts still
named it."""
fake = _fake_contract(
tmp_path,
commands_rows="| Command | Purpose |\n", # `frobnicate`'s row was deleted here
error_rows="| `frobnicate` | never | yes | retry |\n",
)
monkeypatch.setattr(docs_verify, "CLI_README", fake)
monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"frobnicate"})
issues = docs_verify.check_cli_readme()
assert any(
"frobnicate" in issue and "§ Commands" in issue and "not documented" in issue
for issue in issues
)
def test_error_contracts_is_enforced_against_registered_commands(tmp_path, monkeypatch):
"""Before the split, § Error contracts was never itself compared against
the registered commands - a row missing there was invisible."""
fake = _fake_contract(
tmp_path,
commands_rows="| `frobnicate` | does things |\n",
error_rows="| Command | Exit 1 means | Atomic? | Retry policy |\n",
)
monkeypatch.setattr(docs_verify, "CLI_README", fake)
monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"frobnicate"})
issues = docs_verify.check_cli_readme()
assert any(
"frobnicate" in issue and "§ Error contracts" in issue and "not documented" in issue
for issue in issues
)
def test_a_phantom_error_contract_row_is_reported(tmp_path, monkeypatch):
"""The reverse direction inside § Error contracts: a row for a command
that does not exist must be reported there too, not only in § Commands."""
fake = _fake_contract(
tmp_path,
commands_rows="| `frobnicate` | does things |\n",
error_rows="| `frobnicate` | never | yes | retry |\n| `ghost command` | never happened | no | n/a |\n",
)
monkeypatch.setattr(docs_verify, "CLI_README", fake)
monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"frobnicate"})
issues = docs_verify.check_cli_readme()
assert any(
"ghost command" in issue and "§ Error contracts" in issue and "not a wikitool command" in issue
for issue in issues
)
def test_a_renamed_commands_heading_is_reported_not_silently_scanned(tmp_path, monkeypatch):
"""A renamed or removed `## Commands` heading must fail loudly - falling
back to scanning the whole file would make the two tables indistinguishable
again, which is the exact bug this check exists to prevent."""
fake = tmp_path / "CONTRACT.md"
fake.write_text(
"## Kommandos\n\n"
"| `frobnicate` | does things |\n\n"
"## Error contracts\n\n"
"| `frobnicate` | never | yes | retry |\n",
encoding="utf-8",
)
monkeypatch.setattr(docs_verify, "CLI_README", fake)
monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"frobnicate"})
issues = docs_verify.check_cli_readme()
assert any("no '## Commands' heading found" in issue for issue in issues)
assert not any("§ Commands" in issue and "not documented" in issue for issue in issues)
def test_a_renamed_error_contracts_heading_is_reported_not_silently_scanned(tmp_path, monkeypatch):
fake = tmp_path / "CONTRACT.md"
fake.write_text(
"## Commands\n\n"
"| `frobnicate` | does things |\n\n"
"## Fehlerkontrakte\n\n"
"| `frobnicate` | never | yes | retry |\n",
encoding="utf-8",
)
monkeypatch.setattr(docs_verify, "CLI_README", fake)
monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"frobnicate"})
issues = docs_verify.check_cli_readme()
assert any("no '## Error contracts' heading found" in issue for issue in issues)
assert not any("§ Error contracts" in issue and "not documented" in issue for issue in issues)
def test_a_section_runs_on_past_its_own_subheadings():
"""`###` groups inside a section must not end it. Both tables are split
into per-group subsections, each with its own table header, so a lookahead
that stopped at any `#` would truncate § Commands at its first group and
report every later command as undocumented."""
text = (
"## Commands\n\n"
"### Pages\n\n| `alpha` | does things |\n\n"
"### Git\n\n| `beta` | does other things |\n\n"
"## Error contracts\n\n| `gamma` | never | yes | retry |\n"
)
section = docs_verify.section_text(text, "## Commands")
assert docs_verify.documented_commands(section) == ["alpha", "beta"]
assert "gamma" not in section
def test_grouped_tables_are_still_checked_in_both_directions(tmp_path, monkeypatch):
"""The section split and the `###` grouping compose: each table is still
read as one pot of rows within its own section, however many subsections
and table headers it is broken into."""
fake = _fake_contract(
tmp_path,
commands_rows=(
"### Pages\n\n| Command | Purpose |\n| `alpha` | does things |\n\n"
"### Git\n\n| Command | Purpose |\n" # `beta`'s row was deleted here
),
error_rows=(
"### Pages\n\n| `alpha` | never | yes | retry |\n\n"
"### Git\n\n| `beta` | never | yes | retry |\n"
def _fixture_record(path: str) -> cli_contract.CommandRecord:
return cli_contract.CommandRecord(
path=path,
summary="Does a thing.",
synopsis=(cli_contract.Variant(usage=f"{path} --flag <v>"),),
properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
atomic="Yes",
budget=cli_contract.Budget.COUNTED,
),
notes="Does a thing, mechanically.",
failures=(cli_contract.Failure(label="", exit_1="It broke", retry="Fix and retry"),),
)
def test_commands_region_matches_the_generated_form(tmp_path, monkeypatch):
"""Regression guard for the drift this check exists to catch: a region
hand-edited (or simply left stale after a record changed) must fail, the
same way `check_toc_regions` fails a stale table of contents."""
from chemenu import blocks
fake = tmp_path / "CONTRACT.md"
stale_region = (
blocks.open_marker("commands") + "\nstale content\n" + blocks.close_marker("commands")
)
fake.write_text(blocks.replace("## Commands\n", "commands", stale_region), encoding="utf-8")
monkeypatch.setattr(docs_verify, "CLI_README", fake)
monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"alpha", "beta"})
issues = docs_verify.check_cli_readme()
assert any(
"beta" in issue and "§ Commands" in issue and "not documented" in issue
for issue in issues
monkeypatch.setattr(
cli_contract, "all_records", lambda: {"frobnicate": _fixture_record("frobnicate")}
)
assert not any("§ Error contracts" in issue for issue in issues)
monkeypatch.setattr(cli_contract, "GROUPS", (("Fixture", ("frobnicate",)),))
issues = docs_verify.check_commands_region()
assert any("stale" in issue for issue in issues)
def test_commands_region_is_accepted_once_regenerated(tmp_path, monkeypatch):
from chemenu import blocks
fake = tmp_path / "CONTRACT.md"
monkeypatch.setattr(docs_verify, "CLI_README", fake)
monkeypatch.setattr(
cli_contract, "all_records", lambda: {"frobnicate": _fixture_record("frobnicate")}
)
monkeypatch.setattr(cli_contract, "GROUPS", (("Fixture", ("frobnicate",)),))
content = cli_contract.render_commands_region(groups=(("Fixture", ("frobnicate",)),)).strip("\n")
wrapped = blocks.open_marker("commands") + "\n" + content + "\n" + blocks.close_marker("commands")
fake.write_text(blocks.replace("## Commands\n", "commands", wrapped), encoding="utf-8")
assert docs_verify.check_commands_region() == []
def test_a_missing_commands_region_is_reported(tmp_path, monkeypatch):
fake = tmp_path / "CONTRACT.md"
fake.write_text("## Commands\n\nnothing generated here yet.\n", encoding="utf-8")
monkeypatch.setattr(docs_verify, "CLI_README", fake)
issues = docs_verify.check_commands_region()
assert any("missing its" in issue for issue in issues)
def test_the_real_commands_region_is_current():
"""Forward direction against the real tree: this is what a session
forgetting to run `wikitool docs contract --apply` after changing a
record is caught by."""
assert docs_verify.check_commands_region() == []
def test_every_registered_commands_flags_are_documented():
"""Forward direction against the real tree and the real Click app: every
non-hidden flag of every command must appear in its cli_contract
record's SYNOPSIS, and vice versa."""
assert docs_verify.check_synopsis_flags() == []
def test_a_flag_the_synopsis_forgot_is_reported(monkeypatch):
import click
rec = _fixture_record("frobnicate")
monkeypatch.setattr(cli_contract, "get", lambda path: rec if path == "frobnicate" else None)
param = click.Option(["--flag"])
param2 = click.Option(["--forgotten"])
command = click.Command("frobnicate", params=[param, param2])
monkeypatch.setattr(
docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)]
)
issues = docs_verify.check_synopsis_flags()
assert any("--forgotten" in issue and "not documented" in issue for issue in issues)
def test_a_phantom_synopsis_flag_is_reported(monkeypatch):
import click
rec = cli_contract.CommandRecord(
path="frobnicate",
summary="Does a thing.",
synopsis=(cli_contract.Variant(usage="frobnicate --flag <v> --invented <v>"),),
properties=_fixture_record("frobnicate").properties,
notes="Does a thing.",
failures=(),
)
param = click.Option(["--flag"])
command = click.Command("frobnicate", params=[param])
monkeypatch.setattr(
docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)]
)
monkeypatch.setattr(cli_contract, "get", lambda path: rec if path == "frobnicate" else None)
issues = docs_verify.check_synopsis_flags()
assert any("--invented" in issue and "not a real flag" in issue for issue in issues)
def test_a_hidden_flag_need_not_be_documented(monkeypatch):
import click
rec = _fixture_record("frobnicate")
param = click.Option(["--flag"])
hidden = click.Option(["--secret"], hidden=True)
command = click.Command("frobnicate", params=[param, hidden])
monkeypatch.setattr(
docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)]
)
monkeypatch.setattr(cli_contract, "get", lambda path: rec if path == "frobnicate" else None)
assert docs_verify.check_synopsis_flags() == []
def test_a_boolean_flag_pair_is_satisfied_by_either_spelling(monkeypatch):
"""`--push`/`--no-push`: documenting only the negative spelling (the
existing SYNOPSIS convention `publish` used before this check existed)
must not be reported as an undocumented `--push`."""
import click
rec = cli_contract.CommandRecord(
path="frobnicate",
summary="Does a thing.",
synopsis=(cli_contract.Variant(usage="frobnicate [--no-push]"),),
properties=_fixture_record("frobnicate").properties,
notes="Does a thing.",
failures=(),
)
param = click.Option(["--push/--no-push"], default=True)
command = click.Command("frobnicate", params=[param])
monkeypatch.setattr(
docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)]
)
monkeypatch.setattr(cli_contract, "get", lambda path: rec if path == "frobnicate" else None)
assert docs_verify.check_synopsis_flags() == []
def test_no_registered_commands_help_cites_an_issue_number():
"""Forward direction against the real tree: a Gitea reference baked into
a docstring above its `\\f` marker, or into an Option's help text, reaches
a distributed instance with no tracker to resolve it against."""
assert docs_verify.check_no_issue_references_in_help() == []
def test_an_issue_number_above_the_form_feed_is_reported(monkeypatch):
import click
def frobnicate():
"""One sentence (Gitea #66)."""
command = click.Command("frobnicate", callback=frobnicate, help=frobnicate.__doc__)
monkeypatch.setattr(docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)])
issues = docs_verify.check_no_issue_references_in_help()
assert any("#66" in issue and "frobnicate" in issue for issue in issues)
def test_an_issue_number_below_the_form_feed_is_not_reported(monkeypatch):
import click
help_text = "One sentence.\n\f\nMaintenance text citing Gitea #66, never rendered."
command = click.Command("frobnicate", help=help_text)
monkeypatch.setattr(docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)])
assert docs_verify.check_no_issue_references_in_help() == []
def test_an_issue_number_in_an_options_help_text_is_reported(monkeypatch):
import click
param = click.Option(["--flag"], help="Do the thing (Gitea #66).")
command = click.Command("frobnicate", params=[param])
monkeypatch.setattr(docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)])
issues = docs_verify.check_no_issue_references_in_help()
assert any("#66" in issue and "--flag" in issue for issue in issues)
def test_collection_contracts_exist():
@@ -620,7 +632,7 @@ def test_verify_raises_when_the_version_is_undocumented(monkeypatch):
def test_verify_raises_when_issues_exist(monkeypatch):
monkeypatch.setattr(docs_verify, "check_cli_readme", lambda: ["boom"])
monkeypatch.setattr(docs_verify, "check_command_contracts", lambda: ["boom"])
with pytest.raises(typer.Exit):
docs_verify.verify()
+40 -5
View File
@@ -6,7 +6,9 @@ import sys
import pytest
import typer
from chemenu import config
from chemenu import cli, config # noqa: F401 - importing the full CLI registers every
# command's cli_contract record, which is_exempt (Gitea #121 B8) now reads instead of a
# hand-maintained list; run_budget.py cannot do this import itself (cli.py imports run_budget).
from chemenu.commands import run_budget
@@ -227,10 +229,9 @@ def test_search_exemption_survives_a_query_that_looks_like_a_subcommand():
def test_version_regrade_bare_listing_is_exempt():
"""Gitea #95: `version regrade` only reads when called with no further
arguments at all - the listing form. It cannot join SKIP_COMMAND_PATHS
(that dict only ever looks at the subcommand slot), so it is its own
branch in `is_exempt`."""
"""`version regrade` only reads when called with no further arguments at
all - the listing form - which is exactly `budget: exempt_without_args`
in its `cli_contract` record."""
assert run_budget.is_exempt("version", ["regrade"])
@@ -239,6 +240,40 @@ def test_version_regrade_with_indices_is_counted():
assert not run_budget.is_exempt("version", ["regrade", "3"])
def test_exempt_budget_matches_the_pre_121_skip_sets():
"""Parity test (Gitea #121 acceptance criteria): the set of commands whose
`cli_contract` record carries `budget: exempt` is exactly the union of the
old `SKIP_COMMANDS`/`SKIP_COMMAND_PATHS` sets this replaced, plus bare
`version` (`version show`'s own path). `version regrade` is deliberately
not in this set - its `exempt_without_args` budget is a third shape, and
is checked separately below."""
from chemenu import cli_contract
expected = {
"search", "doctor", "review", # old SKIP_COMMANDS
"budget status", "eval score", "eval sessions", "cite id", "links show",
"version show", "version check", "version notes",
"migrate list", "migrate status", "migrate verify", "upstream verify",
}
actual = {
path
for path, record in cli_contract.all_records().items()
if record.properties.budget == cli_contract.Budget.EXEMPT
}
assert actual == expected
def test_only_version_regrade_uses_exempt_without_args():
from chemenu import cli_contract
without_args = {
path
for path, record in cli_contract.all_records().items()
if record.properties.budget == cli_contract.Budget.EXEMPT_WITHOUT_ARGS
}
assert without_args == {"version regrade"}
def test_reset_command_requires_yes():
run_budget.record_and_check("new", ["entity", "--name", "X"], override=False)
with pytest.raises(typer.Exit):