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

+15 -9
View File
@@ -219,7 +219,7 @@ stack is built the way it is - see [File naming](#file-naming)), and this file.
| `kb/` | [kb/CONTRACT.md](kb/CONTRACT.md) + `kb/CONVENTIONS.md` | What the stack enforces about a page (collections, linking, provenance), and beside it what this instance decided (language, naming, tone, labels, hedging) | | `kb/` | [kb/CONTRACT.md](kb/CONTRACT.md) + `kb/CONVENTIONS.md` | What the stack enforces about a page (collections, linking, provenance), and beside it what this instance decided (language, naming, tone, labels, hedging) |
| `reports/` | [reports/CONTRACT.md](reports/CONTRACT.md) | Why reports and traces are generated, gitignored, and carried into `kb/log.md` | | `reports/` | [reports/CONTRACT.md](reports/CONTRACT.md) | Why reports and traces are generated, gitignored, and carried into `kb/log.md` |
| `work/` | [work/CONTRACT.md](work/CONTRACT.md) | Workshop runs: run keys, required files, why they are tracked, how a run closes | | `work/` | [work/CONTRACT.md](work/CONTRACT.md) | Workshop runs: run keys, required files, why they are tracked, how a run closes |
| `tools/` | [tools/CONTRACT.md](tools/CONTRACT.md) | Command reference and per-command error contracts, one row per command in each of two tables - a file to look a row up in rather than read through, as its own opening paragraph says - plus the maintenance schedule | | `tools/` | [tools/CONTRACT.md](tools/CONTRACT.md) | Command reference: one generated data record per command (name, synopsis, properties, exit status, retry policy), plus an index and the maintenance schedule - a command to look up (`wikitool <cmd> -h`, or a `grep` here) rather than a file to read through, as its own opening paragraph says |
| `instructions/` | [instructions/CONTRACT.md](instructions/CONTRACT.md) | Instruction vs. skill, publishing, writing standard | | `instructions/` | [instructions/CONTRACT.md](instructions/CONTRACT.md) | Instruction vs. skill, publishing, writing standard |
**By collection** - then read the contract for the collection you are writing in. **By collection** - then read the contract for the collection you are writing in.
@@ -295,12 +295,18 @@ Every `tools/wikitool` call has exactly four outcomes:
3. **User clearance required (exit 42).** Not an error and not yours to resolve: show the 3. **User clearance required (exit 42).** Not an error and not yours to resolve: show the
command's output to the user verbatim and stop. See [Gates](#gates). command's output to the user verbatim and stop. See [Gates](#gates).
4. **Unexpected error (timeout, crash, interrupted process).** Do not guess whether it 4. **Unexpected error (timeout, crash, interrupted process).** Do not guess whether it
worked, do not retry more than once, and never hand-write what the tool would have worked, do not retry more than once - or, when the command is non-idempotent, not at
produced. all: an unclear outcome plus a blind retry is how a non-idempotent call takes effect
twice. This is narrower than case 2's own "fix the cause, retry once": a validation error
is a known cause with a known fix, so it always gets that one retry regardless of
idempotency, and a command's own retry-policy text (`wikitool <cmd> -h`) is the one to
follow for it.
After the single allowed retry - or immediately, for the non-idempotent commands `new`, After the single allowed retry - or immediately, for case 4 on a non-idempotent command -
`log append`, `publish`, and `upstream merge` - stop and report the exact command and error stop and report the exact command and error text to the user. Which commands those are is
text to the user. not a list here to drift behind the code: `wikitool -h | grep non-idempotent` reads it from
each command's own
`cli_contract` record, the same property `tools/CONTRACT.md`'s generated index prints.
Per-command detail (what exit 1 means, whether the command is atomic, whether a retry is Per-command detail (what exit 1 means, whether the command is atomic, whether a retry is
safe) is in [tools/CONTRACT.md](tools/CONTRACT.md). A gate refusal is not a validation error - safe) is in [tools/CONTRACT.md](tools/CONTRACT.md). A gate refusal is not a validation error -
@@ -338,9 +344,9 @@ READMEs go in [CHANGES.md](CHANGES.md) - never in an inline version-history tabl
change that introduced a stage, a command or a workflow, not follow-up work: nobody comes back change that introduced a stage, a command or a workflow, not follow-up work: nobody comes back
for them, and a document that describes a repo which no longer exists is worse than none. What for them, and a document that describes a repo which no longer exists is worse than none. What
`tools/wikitool docs verify` mechanically checks is exactly what its own `docs verify` row in `tools/wikitool docs verify` mechanically checks is exactly what its own `docs verify` row in
[tools/CONTRACT.md](tools/CONTRACT.md) lists - no more. **Every cell's text is outside that [tools/CONTRACT.md](tools/CONTRACT.md) lists - no more. **Every prose field is outside that
check** - a command table entry's description, an error contract's wording, a stage contract's check** - a command's own summary, notes or retry-policy text, a stage contract's prose - and is
prose - and is therefore session work, the same as the three README-shaped files. therefore session work, the same as the three README-shaped files.
`docs/` pages are held to a different clock than those three. A README goes stale on every new `docs/` pages are held to a different clock than those three. A README goes stale on every new
flag; a `docs/` page goes stale only when the reasoning it wrote down stops holding - a gate flag; a `docs/` page goes stale only when the reasoning it wrote down stops holding - a gate
+41 -1
View File
@@ -59,11 +59,14 @@ concern - readable here, never shipped as something to parse.
--- ---
## 7.1.0-beta.3 - 2026-09-25 - stack-close: wait for CI through the authenticated Gitea connection, with timings and a give-up point ## 7.1.0-beta.4 - 2026-09-25 - wikitool: one data record per command - `-h`, index and CONTRACT.md render from cli_contract (Gitea #121 Phase 1)
**Author:** Torben Nehmer **Author:** Torben Nehmer
<!-- wikitool:bumps --> <!-- wikitool:bumps -->
**High impact**
- wikitool: one data record per command - `-h`, index and CONTRACT.md render from cli_contract (Gitea #121 Phase 1)
**Medium impact** **Medium impact**
- CalDAV task-tracker provider (Nextcloud Tasks, iOS Reminders); review reports unknown-value findings instead of skipping them - CalDAV task-tracker provider (Nextcloud Tasks, iOS Reminders); review reports unknown-value findings instead of skipping them
@@ -111,6 +114,43 @@ check after about 4 minutes (5 with a release job), then once a minute, and hand
after 15. Any shell loop that polls instead must end on the first unexpected response. Dev-only; after 15. Any shell loop that polls instead must end on the first unexpected response. Dev-only;
nothing here ships to an instance. nothing here ships to an instance.
### wikitool: one data record per command - `-h`, index and CONTRACT.md render from cli_contract (Gitea #121 Phase 1)
`--help`/`-h`, the `tools/CONTRACT.md` command reference, and the run-time budget exemption list
used to be four hand-maintained copies of the same facts about a command - a docstring, two
tables in `tools/CONTRACT.md` (§ Commands, § Error contracts), and `run_budget.py`'s own
`SKIP_COMMANDS`/`SKIP_COMMAND_PATHS` sets - and they had already drifted (`xref add`/
`xref link-source` were missing `--dry-run` from their documented synopsis; the tool error
contract's non-idempotent list disagreed with the per-command retry-policy cells it stood next
to). All 61 commands (60 existing, plus the new `docs contract`) now carry one
`cli_contract.CommandRecord` - name, synopsis, properties (effect, idempotency, atomicity,
budget, network, gates), exit status and retry policy - attached to the command function by a
`@cli_contract.record(...)` decorator in its own module. Three views render from that one
source: `wikitool <cmd> -h` (the full record, plain text), `wikitool -h` (an index line per
command, fixed-width and `grep`-stable), and `tools/CONTRACT.md`'s generated
`<!-- wikitool:commands -->` region (`wikitool docs contract [--apply]`), which replaces the two
old tables. `run_budget.is_exempt` now reads a command's `budget:` property directly instead of
carrying its own list, which is what `wikitool -h | grep non-idempotent`/`budget:exempt` now
answers for `AGENTS.md`'s tool error contract instead of a hand-written enumeration.
Help itself changed shape: Rich's boxed panels are off (`typer.core.HAS_RICH = False`) for both
`--help` and a usage error, so the output is the same plain, GNU-style text with or without a
TTY - byte-identical, which a new test pins by comparing a real run against one with `isatty`
patched. `-h` is now a recognised alias for `--help` on every command (no command used the flag
for anything else). `wikitool docs verify` grew three checks to hold the new machinery to the
same "checked or absent" rule as everything else it enforces: every registered command has
exactly one `cli_contract` record and appears exactly once in `cli_contract.GROUPS`, every
command's non-hidden flags match its record's SYNOPSIS in both directions, and no command's
rendered help - a docstring above its `\f` marker, or an option's own `help=` text - cites an
issue number (a distributed instance has no tracker to resolve one against, the same reasoning
`check_no_issue_references` already applied to shipped `.md` files).
Redactional work - examples, an explicit "never do this" section per command, and pulling the
"why" out of a record's NOTES into its own section - is Phase 2 (Gitea #142), deliberately not
part of this change: this pass moved the existing table cells' text into records mechanically,
without editing it beyond the one documented fix (the `--dry-run` synopsis gap above) and the
docstring/option-text rewording needed to stop citing issue numbers in rendered help.
--- ---
## 7.0.0 - 2026-09-22 - Task-Tracker-Anbindung: Vorhaben als Seitenart, Verpflichtungsschicht, Weekly Review als Read-Time-Join ## 7.0.0 - 2026-09-22 - Task-Tracker-Anbindung: Vorhaben als Seitenart, Verpflichtungsschicht, Weekly Review als Read-Time-Join
+1 -1
View File
@@ -1 +1 @@
7.1.0-beta.3 7.1.0-beta.4
+1 -1
View File
@@ -33,7 +33,7 @@ touched; a row that does not apply needs no action.
| Touched surface | Document(s) that make a claim about it | | Touched surface | Document(s) that make a claim about it |
|---|---| |---|---|
| A `wikitool` command's behaviour, flags, or interface | Both tables in [tools/CONTRACT.md](../../tools/CONTRACT.md): the command reference row, and its per-command error contract (exit codes, atomicity, retry-safety) | | A `wikitool` command's behaviour, flags, or interface | Its `cli_contract.CommandRecord` (name, synopsis, properties, exit status, retry policy - `tools/chemenu/cli_contract.py`), then `wikitool docs contract --apply` to regenerate its copy in [tools/CONTRACT.md](../../tools/CONTRACT.md) |
| A stage's authoring rules (`raw/`, `kb/`, `types/`, `reports/`, `work/`, `tools/`, `instructions/`) | The touched `<stage>/CONTRACT.md` | | A stage's authoring rules (`raw/`, `kb/`, `types/`, `reports/`, `work/`, `tools/`, `instructions/`) | The touched `<stage>/CONTRACT.md` |
| A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) | | A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) |
| A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `EVALS.md`, `tools/README.md`, `INSTALL.md`, `DEVELOPMENT.md` names it - AGENTS.md § File naming says which document is for which reader | | A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `EVALS.md`, `tools/README.md`, `INSTALL.md`, `DEVELOPMENT.md` names it - AGENTS.md § File naming says which document is for which reader |
+3 -3
View File
@@ -96,9 +96,9 @@ and a fresh subagent starts without the session's context).
3. **Check whether a `docs/` page, a contract, or a new human doc went stale.** A `docs/` page 3. **Check whether a `docs/` page, a contract, or a new human doc went stale.** A `docs/` page
carries no normative sentence, so nothing verifies it by construction (AGENTS.md § File carries no normative sentence, so nothing verifies it by construction (AGENTS.md § File
naming) - the same is true of `tools/CONTRACT.md`'s two tables and any touched naming) - the same is true of `tools/CONTRACT.md`'s generated command records and any touched
`<stage>/CONTRACT.md`, whose prose `docs verify` checks only for presence and table-row `<stage>/CONTRACT.md`, whose prose `docs verify` checks only for presence and record
membership, never for what a cell or a section actually says membership, never for what a field or a section actually says
(`instructions/dev/doc-pull-through.md`); of `README.md`/`INSTALL.md`/`DEVELOPMENT.md` (`instructions/dev/doc-pull-through.md`); of `README.md`/`INSTALL.md`/`DEVELOPMENT.md`
prose; and of a new instruction's own wording, which `instructions verify` checks structurally prose; and of a new instruction's own wording, which `instructions verify` checks structurally
but never for what it claims. If the change this package shipped moved the reasoning or the but never for what it claims. If the change this package shipped moved the reasoning or the
+1805 -265
View File
File diff suppressed because it is too large. Load diff
+24 -14
View File
@@ -4,9 +4,9 @@ Developer documentation for `wikitool` - how the CLI is built, how to change it,
and how to run its tests. and how to run its tests.
**This is not the command reference.** That is [CONTRACT.md](CONTRACT.md), which **This is not the command reference.** That is [CONTRACT.md](CONTRACT.md), which
`wikitool docs verify` checks against the registered commands. Copying the `wikitool docs verify` checks against the registered commands. Copying its
command table here would create a second copy that drifts, so this file generated command records here would create a second copy that drifts, so this
deliberately has none - and `docs verify` now enforces that. file deliberately has none - and `docs verify` now enforces that.
| Document | Audience | | Document | Audience |
|---|---| |---|---|
@@ -33,7 +33,8 @@ install command rather than degrading silently.
tools/ tools/
wikitool entry point wikitool entry point
chemenu/ chemenu/
cli.py Typer app: registers every command, runs the budget gate cli.py Typer app: registers every command, runs the budget gate, renders `-h`/`--help` from cli_contract
cli_contract.py one data record per command (name, synopsis, properties, exit status) - the source `-h`, the index and CONTRACT.md's generated region render from
config.py repo layout: root resolution and every path under it config.py repo layout: root resolution and every path under it
api.py the in-process entry point - point Chemenu at a corpus and read it api.py the in-process entry point - point Chemenu at a corpus and read it
errors.py ChemenuError / ValidationError / BackendError errors.py ChemenuError / ValidationError / BackendError
@@ -79,13 +80,21 @@ bound at import time - `KB_DIR` and friends follow whatever `ROOT` currently is.
1. Write the module under `chemenu/commands/`. A group is a `typer.Typer()` 1. Write the module under `chemenu/commands/`. A group is a `typer.Typer()`
app; a single command is a plain function. app; a single command is a plain function.
2. Register it in `cli.py` (`app.add_typer(...)` or `app.command(...)`). 2. Register it in `cli.py` (`app.add_typer(...)` or `app.command(...)`).
3. Add a row to [CONTRACT.md](CONTRACT.md)'s command table **and** to its error 3. Attach a `@cli_contract.record(cli_contract.CommandRecord(...))` decorator to
contract table. `docs verify` fails in both directions - an undocumented the command function, in its own module, and add its path to the matching
command and a documented non-command are equally reported. Write the rows group in `cli_contract.GROUPS`. That one record is the source `wikitool
without citing an issue number: `docs verify` also refuses any `.md` or <cmd> -h`, the index (`wikitool -h`, and the top of
`.template` `dist export` ships that carries one, because the tracker exists [CONTRACT.md](CONTRACT.md)), and CONTRACT.md's generated `#### <path>`
only in this repo (`instructions/dev/issue-tracking.md` § Citing an issue in section all render from - see `cli_contract.py`'s own module docstring for
the repo). the record's shape. `docs verify` fails in both directions - a command with
no record and a `GROUPS` entry naming no real command are equally reported -
and also checks that every non-hidden flag appears in the record's SYNOPSIS.
Then regenerate the copy: `wikitool docs contract --apply`. Write the
record's prose without citing an issue number: `docs verify` also refuses
one in a command's rendered `--help` text (a docstring above its `\f`
marker, or an option's `help=`) and in any `.md`/`.template` `dist export`
ships, because the tracker exists only in this repo
(`instructions/dev/issue-tracking.md` § Citing an issue in the repo).
4. Add tests. Pure logic belongs in a function separate from the Typer callback 4. Add tests. Pure logic belongs in a function separate from the Typer callback
(see `mass_update_gate_message`, `derive_run_key`, `run_export`), so a test (see `mass_update_gate_message`, `derive_run_key`, `run_export`), so a test
does not need a CLI runner - and a Typer callback called directly from a test does not need a CLI runner - and a Typer callback called directly from a test
@@ -107,9 +116,10 @@ bound at import time - `KB_DIR` and friends follow whatever `ROOT` currently is.
A new command reaches every future instance, and CI's version gate refuses a A new command reaches every future instance, and CI's version gate refuses a
stack change that moved no version. stack change that moved no version.
Every command is counted against the iteration budget unless it is listed in Every command is counted against the iteration budget unless its `cli_contract`
`run_budget.SKIP_COMMANDS` / `SKIP_COMMAND_PATHS`. Only read-only retrieval record's `budget:` property says `exempt` (or, for `version regrade`'s own
belongs there. shape, `exempt_without_args`) - `run_budget.is_exempt` reads it from there,
not from a list of its own. Only read-only retrieval earns it.
## Design notes ## Design notes
+108
View File
@@ -9,6 +9,18 @@ import sys
import time import time
import typer 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: try:
from chemenu.commands import ( from chemenu.commands import (
@@ -133,6 +145,11 @@ def _pacify_real_fd(stream) -> None:
app = typer.Typer( app = typer.Typer(
help="wikitool - deterministic operations for Chemenu (see AGENTS.md).", help="wikitool - deterministic operations for Chemenu (see AGENTS.md).",
no_args_is_help=True, 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") app.add_typer(xref.app, name="xref")
@@ -167,6 +184,97 @@ app.command("sync")(git_publish.sync_command)
app.command("doctor")(doctor.doctor_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: def main() -> None:
# Iteration Budget Gate / Loop-Breaker (see the tooling contract's # Iteration Budget Gate / Loop-Breaker (see the tooling contract's
# "Iteration and Cost Limits"): recorded and enforced here, once per # "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 import typer
from chemenu import config from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success from chemenu.commands._util import fail, rel_path, success
from chemenu.frontmatter_io import write_page from chemenu.frontmatter_io import write_page
from chemenu.page import Page from chemenu.page import Page
@@ -43,6 +43,20 @@ def _find_page(pages: dict[str, Page], title: str) -> Page:
@app.command("id") @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( def cite_id_command(
title: str = typer.Option(..., "--title", help="Source page title, e.g. 'Source - Docker Cheatsheet'"), 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'"), 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") @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( def cite_add(
page_title: str = typer.Option(..., "--page", help="Exact title of the page to add a citation on"), 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'"), 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") @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( def cite_sync(
page_title: Optional[str] = typer.Option(None, "--page", help="Sync just this page"), 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/"), 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 ( from chemenu import (
blocks, blocks,
cli_contract,
config, config,
conventions, conventions,
kb_collections, 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) 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") @app.command("export")
def export_command( def export_command(
target: Path = typer.Argument( 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`.") 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") @app.command("upgrade")
def upgrade_command( def upgrade_command(
source: Path = typer.Argument( 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", 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 """Apply a stack update `dist export` produced - the write half of `version check`.
`version check`. Never downloads anything: `source` is an already-fetched \f
Never downloads anything: `source` is an already-fetched
export directory or `.tar.gz` archive. Writes exactly the new release export directory or `.tar.gz` archive. Writes exactly the new release
stamp's `files` block, minus what an export re-seeds every time 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 (`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 checked or absent. Three such copies survive on purpose because they earn
their keep as reading material: their keep as reading material:
1. `tools/CONTRACT.md`'s two command tables - § Commands and § Error 1. `tools/CONTRACT.md`'s generated `<!-- wikitool:commands -->` region - one
contracts - each re-derivable from the Typer app and checked `cli_contract.CommandRecord` per registered command, re-derivable from
independently, in both directions, so a row surviving in one table the Typer app and checked in both directions, so a command dropped from
cannot hide its own deletion from the other `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 2. the collection and stage contracts (their existence and placement, not
their content) their content)
3. the absence of pre-type-system `type: entity` frontmatter in the 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 from __future__ import annotations
import functools
import inspect
import re import re
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -65,7 +68,7 @@ from typing import Optional
import typer 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 import dist_cmd
from chemenu.commands._util import fail, rel_path, success 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 ...` | ... |" # First backticked cell of a markdown table row, e.g. "| `xref add --a ...` | ... |"
TABLE_CELL_RE = re.compile(r"^\|\s*`([^`]+)`", re.MULTILINE) 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]: def registered_commands() -> set[str]:
"""Every command path the CLI exposes, e.g. {'new', 'xref add', ...}. """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)] return [match.group(1).strip() for match in TABLE_CELL_RE.finditer(readme_text)]
def check_cli_readme() -> list[str]: def check_command_contracts() -> list[str]:
"""Every registered command must appear in tools/CONTRACT.md's own """Every registered command has exactly one `cli_contract` record, and
§ Commands table, and separately in its § Error contracts table - each `cli_contract.GROUPS` lists exactly the registered commands - no more, no
direction checked per table, independently of the other. less, and no path twice.
The two tables used to be read as one pot: `TABLE_CELL_RE` matched a Gitea #121 phase 1 replaced the two hand-read tables (§ Commands,
backticked first cell anywhere in the file, so a row deleted from § Error contracts) with one data record per command, attached to its
§ Commands went unnoticed as long as the same name still had a row in function by `@cli_contract.record(...)`. A record with no matching
§ Error contracts, and § Error contracts was never itself compared command, a command with no record, or a `GROUPS` entry appearing twice
against the registered commands (Gitea #91). `section_text` scopes each are the three ways that pairing can drift; each is its own issue so a
table to the text between its own `##` heading and the next one, and session sees exactly which command needs attention.
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.
""" """
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()) registered = sorted(registered_commands())
issues: list[str] = [] issues: list[str] = []
for heading, label in ( seen: set[str] = set()
(COMMANDS_HEADING, "§ Commands"), for path in cli_contract.grouped_paths(cli_contract.GROUPS):
(ERROR_CONTRACTS_HEADING, "§ Error contracts"), if path in seen:
): issues.append(f"cli_contract.GROUPS lists `{path}` more than once")
try: seen.add(path)
section = section_text(text, heading)
except ValueError as exc: grouped = set(cli_contract.grouped_paths(cli_contract.GROUPS))
for command_path in registered:
if cli_contract.get(command_path) is None:
issues.append( 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: return issues
if not any(cell == command_path or cell.startswith(command_path + " ") for cell in cells):
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( 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: for flag in sorted(documented - all_flags):
if not any(cell == cp or cell.startswith(cp + " ") for cp in registered): issues.append(
first_token = cell.split(" ", 1)[0] 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( issues.append(
f"tools/CONTRACT.md's {label} table documents `{cell}`, but `{first_token}` " f"`{path}` option `{flag}` help text cites `{match.group()}` - the tracker "
"is not a wikitool command" "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 return issues
@@ -901,10 +1034,65 @@ def check_breaking_change_for_boundary() -> list[str]:
@app.command("verify") @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(): 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 = ( 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_readmes_have_no_command_table()
+ check_collection_contracts() + check_collection_contracts()
+ check_type_spec_frontmatter() + check_type_spec_frontmatter()
@@ -924,12 +1112,12 @@ def verify():
from chemenu.type_resolver import resolver from chemenu.type_resolver import resolver
success( 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(kb_collections.iter_kb_collections())} collection(s) and "
f"{len(STAGE_CONTRACTS)} stage contract(s) present, no legacy type blocks, " 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(resolver.list_type_specs())} type-spec(s) validating against their own schema, "
f"{len(IGNORE_CANARIES)} ignore canaries clear, " 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"tables of contents current and every link resolving on "
f"{len(toc.target_files())} reference file(s), " f"{len(toc.target_files())} reference file(s), "
f"{version_mod.CHANGES_FILENAME} documents version " f"{version_mod.CHANGES_FILENAME} documents version "
@@ -938,12 +1126,52 @@ def verify():
@app.command("toc") @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( def toc_command(
apply: bool = typer.Option(False, "--apply", help="Write changes; default is dry-run (preview only)"), 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 """Create, refresh or remove the generated table-of-contents region.
every reference file `toc.target_files()` covers - AGENTS.md, the stage \f
and collection contracts, and every flat `instructions/**.md` file.""" On every reference file `toc.target_files()` covers - AGENTS.md, the
stage and collection contracts, and every flat `instructions/**.md`
file."""
changed = [] changed = []
for path in toc.target_files(): for path in toc.target_files():
before = path.read_text(encoding="utf-8") 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).") success(f"Refreshed the table of contents on {len(changed)} file(s).")
else: else:
typer.echo(f"\n{len(changed)} file(s) would change. Re-run with --apply to write.") 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 import typer
from rich.console import Console 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 import git_publish, instructions_cmd
from chemenu.commands._util import rel_path from chemenu.commands._util import rel_path
from chemenu.session import ENV_VAR as SESSION_ENV_VAR from chemenu.session import ENV_VAR as SESSION_ENV_VAR
@@ -617,15 +617,60 @@ def run_doctor() -> list[Check]:
return checks 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( def doctor_command(
json_out: bool = typer.Option(False, "--json", help="Print the checks as JSON"), json_out: bool = typer.Option(False, "--json", help="Print the checks as JSON"),
): ):
"""Check that this instance is correctly configured: dependencies, author, """Check that this instance is correctly configured.
git identity/remote, published skills, structure, personalization, KB \f
conventions, generated files, session scoping, telemetry state, whether Dependencies, author, git identity/remote, published skills, structure,
the MCP `submit` tool is armed, and which task-tracker provider (if any) personalization, KB conventions, generated files, session scoping,
is configured for the GTD review. Read-only. Exits 1 only if a check telemetry state, whether the MCP `submit` tool is armed, and which
FAILs.""" task-tracker provider (if any) is configured for the GTD review.
Read-only. Exits 1 only if a check FAILs."""
checks = run_doctor() checks = run_doctor()
if json_out: if json_out:
+42 -1
View File
@@ -13,7 +13,7 @@ from typing import Optional
import typer import typer
from chemenu import config from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success from chemenu.commands._util import fail, rel_path, success
from chemenu.evals import scorecard from chemenu.evals import scorecard
from chemenu.session import session_id as current_session_id from chemenu.session import session_id as current_session_id
@@ -26,6 +26,20 @@ EVALS_DIR = config.REPORTS_DIR / "evals"
@app.command("sessions") @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( def sessions_command(
json_out: bool = typer.Option(False, "--json", help="Print the list as JSON"), json_out: bool = typer.Option(False, "--json", help="Print the list as JSON"),
): ):
@@ -44,6 +58,33 @@ def sessions_command(
@app.command("score") @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( def score_command(
session: Optional[str] = typer.Option( session: Optional[str] = typer.Option(
None, "--session", None, "--session",
+110 -1
View File
@@ -56,7 +56,7 @@ from typing import NamedTuple, Optional
import typer import typer
from chemenu import config from chemenu import cli_contract, config
from chemenu.commands._util import fail, needs_clearance, success from chemenu.commands._util import fail, needs_clearance, success
from chemenu.telemetry import emit 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) 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( def sync_command(
remote: str = typer.Option("origin", "--remote", help="Git remote to reconcile against"), remote: str = typer.Option("origin", "--remote", help="Git remote to reconcile against"),
branch: str = typer.Option("main", "--branch", help="Branch to reconcile"), 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}.") 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( def publish_command(
message: str = typer.Option(..., "--message", help="Commit message summary, e.g. 'ingest: docker-cheatsheet'"), message: str = typer.Option(..., "--message", help="Commit message summary, e.g. 'ingest: docker-cheatsheet'"),
push: bool = typer.Option(True, "--push/--no-push"), push: bool = typer.Option(True, "--push/--no-push"),
+25 -1
View File
@@ -23,7 +23,7 @@ from pathlib import Path
import typer import typer
from chemenu import config from chemenu import cli_contract, config
from chemenu.catalog import SHARD_THRESHOLD, Area, Collection, group_pages from chemenu.catalog import SHARD_THRESHOLD, Area, Collection, group_pages
from chemenu.commands._util import rel_path, success from chemenu.commands._util import rel_path, success
from chemenu.page import Page from chemenu.page import Page
@@ -217,11 +217,35 @@ def build_index(kb_dir: Path) -> str:
@app.command("rebuild") @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( def index_rebuild(
dry_run: bool = typer.Option( dry_run: bool = typer.Option(
False, "--dry-run", help="Print what would be written instead of writing it" 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 # 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 # 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 # 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 typer
import yaml 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 import dist_cmd, docs_verify
from chemenu.commands._util import fail, rel_path, success from chemenu.commands._util import fail, rel_path, success
from chemenu.type_resolver import resolver from chemenu.type_resolver import resolver
@@ -366,6 +366,32 @@ def check_skill_reference_paths() -> list[str]:
@app.command("sync") @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( def sync(
force: bool = typer.Option( force: bool = typer.Option(
False, False,
@@ -402,6 +428,38 @@ def sync(
@app.command("verify") @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(): def verify():
"""Check instructions/ against its type, that no skill carries a relative markdown link, and every published copy against its source.""" """Check instructions/ against its type, that no skill carries a relative markdown link, and every published copy against its source."""
sources = skill_dirs() sources = skill_dirs()
@@ -528,6 +586,20 @@ def verify():
@app.command("list") @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( def list_instructions(
json_out: bool = typer.Option(False, "--json", help="Print the listing as JSON"), 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 import typer
from chemenu import config, links from chemenu import cli_contract, config, links
from chemenu.commands._util import console, fail from chemenu.commands._util import console, fail
from chemenu.kb_scan import load_kb_pages from chemenu.kb_scan import load_kb_pages
from chemenu.page import Page from chemenu.page import Page
@@ -60,6 +60,28 @@ def inbound(pages: dict[str, Page], title: str) -> list[dict]:
@app.command("show") @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( def links_show(
page: str = typer.Option(..., "--page", help="Exact page title"), page: str = typer.Option(..., "--page", help="Exact page title"),
json_out: bool = typer.Option(False, "--json", help="Print the edges as JSON"), 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 import typer
from chemenu import config from chemenu import cli_contract, config
from chemenu.commands._util import rel_path, success from chemenu.commands._util import rel_path, success
from chemenu.lint_core import ( from chemenu.lint_core import (
HARD_ERROR_KEYS, 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( def lint_command(
json_out: bool = typer.Option(False, "--json", help="Print the raw findings as JSON and write no report"), 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"), 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"), 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/. """Run structural lint checks against kb/.
\f
Unless `--json` is given, the full report is always written to a file and 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 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 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 import typer
from chemenu import config from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success, today_iso from chemenu.commands._util import fail, rel_path, success, today_iso
app = typer.Typer(help="Manage kb/log.md.") 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") @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( def log_append(
op: str = typer.Option(..., "--op", help="|".join(VALID_OPS)), op: str = typer.Option(..., "--op", help="|".join(VALID_OPS)),
title: str = typer.Option(..., "--title", help="Brief description, e.g. a source path"), title: str = typer.Option(..., "--title", help="Brief description, e.g. a source path"),
body: str = typer.Option("", "--body", help="Optional multi-line details"), 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"), 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: if op not in VALID_OPS:
fail(f"--op must be one of {VALID_OPS}") fail(f"--op must be one of {VALID_OPS}")
text = body text = body
@@ -69,6 +91,21 @@ def log_append(
@app.command("status") @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(): def log_status():
"""Report how many `ingest` operations have been logged since the last """Report how many `ingest` operations have been logged since the last
`lint` - the deterministic trigger for the Maintenance Schedule's "every `lint` - the deterministic trigger for the Maintenance Schedule's "every
+124 -1
View File
@@ -21,7 +21,7 @@ from typing import Optional
import typer 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.commands._util import console, fail, rel_path, success, today_iso
from chemenu.frontmatter_io import read_page from chemenu.frontmatter_io import read_page
from chemenu.page import Page from chemenu.page import Page
@@ -49,6 +49,20 @@ def _versions() -> tuple[Version, Optional[Version]]:
# --- migrate list ---------------------------------------------------------- # --- 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") @app.command("list")
def list_command( def list_command(
json_out: bool = typer.Option(False, "--json", help="Print the migrations as JSON"), 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") @app.command("status")
def status_command( def status_command(
json_out: bool = typer.Option(False, "--json", help="Print the chain as JSON"), json_out: bool = typer.Option(False, "--json", help="Print the chain as JSON"),
@@ -212,6 +254,32 @@ def status_command(
# --- migrate done / baseline ---------------------------------------------- # --- 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") @app.command("done")
def done_command( def done_command(
version: str = typer.Argument(..., help="The migration's target version, e.g. 1.4.0"), 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") @app.command("baseline")
def baseline_command( def baseline_command(
version: str = typer.Argument(..., help="The shape this instance's content is already in"), 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 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") @app.command("verify")
def verify_command( def verify_command(
from_rev: str = typer.Option(..., "--from", help="Git revision to compare against, e.g. HEAD"), 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 import typer
from chemenu import config, tasks from chemenu import cli_contract, config, tasks
from chemenu.commands._util import ( from chemenu.commands._util import (
check_collision, check_collision,
check_raw_files_exist, 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" 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( def new_page_command(
type_name: str = typer.Argument( type_name: str = typer.Argument(
..., ...,
@@ -344,11 +446,11 @@ def new_page_command(
"--resume", "--resume",
help="`project` only: confirm a human has completed the manual tracker step an earlier " 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 " "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. """Scaffold a new wiki page of any type.
\f
The type's own type-spec drives everything: which frontmatter fields The type's own type-spec drives everything: which frontmatter fields
exist and are required (its `.schema.yaml`), their scaffold defaults exist and are required (its `.schema.yaml`), their scaffold defaults
(schema `default:`), where the page is written (`base_dir` + `layout`), (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. type-spec's template). Adding a new type therefore needs no change here.
For `type_name == "project"` specifically, this also ensures a For `type_name == "project"` specifically, this also ensures a
same-named tracker project exists (Gitea #126, #119 D8/D31) before the same-named tracker project exists before the page is written - see
page is written - see `_ensure_tracker_project`. `_ensure_tracker_project`.
""" """
type_path = type_path_override or resolver.find_type_by_name(type_name) type_path = type_path_override or resolver.find_type_by_name(type_name)
if not type_path: if not type_path:
+82 -1
View File
@@ -25,7 +25,7 @@ from typing import Optional
import typer 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.commands._util import check_collision, fail, rel_path, success
from chemenu.frontmatter_io import write_page from chemenu.frontmatter_io import write_page
from chemenu.lint_core import find_misplaced 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) 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( def rename_command(
old: str = typer.Option(..., "--from", help="Current page title, exactly as it appears"), old: str = typer.Option(..., "--from", help="Current page title, exactly as it appears"),
new: str = typer.Option(..., "--to", help="New page title"), 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( def rm_command(
page_title: str = typer.Option(..., "--page", help="Exact title of the page to delete"), page_title: str = typer.Option(..., "--page", help="Exact title of the page to delete"),
yes: bool = typer.Option( yes: bool = typer.Option(
@@ -399,6 +446,40 @@ def _rmdir_if_emptied(directory: Path) -> bool:
return True 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( def move_command(
page_title: Optional[str] = typer.Option( page_title: Optional[str] = typer.Option(
None, "--page", help="Exact title of the page to move to its computed location" 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 import typer
from chemenu import config from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success from chemenu.commands._util import fail, rel_path, success
from chemenu.provenance import ( from chemenu.provenance import (
broken_raw_refs, broken_raw_refs,
@@ -49,6 +49,21 @@ def _normalize_raw_path(raw: str) -> str:
@app.command("coverage") @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")): 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 """Report raw files with no source page, broken raw_files: references, and
source pages still using a legacy directory/URL-only `source:` field.""" 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") @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( def trace(
raw: Optional[str] = typer.Option(None, "--raw", help="Raw file path to trace forward from"), 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"), 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") @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( def rebuild_index(
dry_run: bool = typer.Option(False, "--dry-run", help="Print the result instead of writing kb/provenance.md"), 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) content = build_provenance_index(config.KB_DIR, config.RAW_DIR)
provenance_file = config.KB_DIR / "provenance.md" provenance_file = config.KB_DIR / "provenance.md"
if dry_run: if dry_run:
+85 -1
View File
@@ -71,7 +71,7 @@ from typing import Optional
import typer import typer
from chemenu import config from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success from chemenu.commands._util import fail, rel_path, success
from chemenu.frontmatter_io import write_page from chemenu.frontmatter_io import write_page
from chemenu.kb_scan import load_kb_pages 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.") 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") @app.command("accept")
def raw_accept_command( def raw_accept_command(
files: list[Path] = typer.Argument( files: list[Path] = typer.Argument(
+56 -5
View File
@@ -10,7 +10,7 @@ import json
import typer import typer
from chemenu import config from chemenu import cli_contract, config
from chemenu.commands._util import fail from chemenu.commands._util import fail
from chemenu.errors import ValidationError from chemenu.errors import ValidationError
from chemenu.review import ALL_CHECKS, ReviewReport, run_review 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( def review_command(
json_out: bool = typer.Option(False, "--json", help="Print the findings as JSON."), 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 """Run the weekly GTD review over the task tracker and `kb/gtd/` pages.
over the project name and report the five staleness/mismatch checks \f
(#119 D10/D26). Read-only - stores nothing, not even a reports/ file Joins the task tracker against kb/gtd/ pages over the project name and
(#119 D3), and is exempt from the Iteration Budget Gate like `search`.""" 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: try:
report = run_review(config.ROOT) report = run_review(config.ROOT)
except ValidationError as exc: except ValidationError as exc:
+73 -60
View File
@@ -27,7 +27,7 @@ from pathlib import Path
import typer import typer
from chemenu import config from chemenu import cli_contract, config
from chemenu.commands._util import fail, success from chemenu.commands._util import fail, success
from chemenu.session import session_id as _shared_session_id from chemenu.session import session_id as _shared_session_id
from chemenu.session import session_id_source as _shared_session_id_source 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. # the whole gate a formality an agent could step around by resetting first.
# It is gated on `--yes` instead, the same way `publish` is. # 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 # Which commands are exempt is no longer a list here at all (Gitea #121 D5,
# process. Reading back what a session already did is not iteration on the wiki, # B8) - it is each command's own `cli_contract` record, the same `budget:`
# and charging for it would discourage checking one's own work. # property `wikitool -h`'s index prints. Two commands used to need their own
# `version show`/`check`/`notes` only read - `VERSION`, the release stamp, the # branch below because a hand-maintained pair-set cannot express them:
# changelog, or a remote release feed. `version bump` writes two files and # `search`/`doctor`/`review` are flat commands whose first argument is a query
# stays counted like every other mutation. # or an option, not a subcommand (`_resolve_record` tries the bare command
SKIP_COMMAND_PATHS = { # path before ever treating `args[0]` as one), and bare `wikitool version`
("budget", "status"), # aliases `version show`. `version regrade`'s `budget: exempt_without_args`
("eval", "score"), # is the third shape a plain exempt/counted split cannot express: the bare
("eval", "sessions"), # listing reads, but any index writes `CHANGES.md` and must stay counted like
("cite", "id"), # `version bump`.
# 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"),
}
# Commands exempt regardless of their first argument, because that argument is
# a query rather than a subcommand. `search` is here because retrieval is def _resolve_record(command: str, args: list[str]) -> tuple["cli_contract.CommandRecord | None", bool]:
# reading, not iterating: the budget exists to stop an agent looping over the """The `cli_contract` record this invocation maps to, and whether it was
# wiki's *state*, and charging for a search would penalise the one habit that matched *without* consuming `args[0]` as a subcommand (a flat command, or
# lowers cost - looking before reading. `doctor` is here for the same reason: the bare `version` alias) - which is what `is_exempt` needs to answer
# it only reads and reports, never mutates anything. `review` (#125) joins the `version regrade`'s own arguments-dependent question correctly."""
# task tracker against kb/gtd/ pages and stores nothing either (#119 D3) - the direct = cli_contract.get(command)
# same read-only argument as `search`, just over a different pair of sources. if direct is not None:
# Every command that mutates anything stays counted. return direct, True
SKIP_COMMANDS = {"search", "doctor", "review"} 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: def is_exempt(command: str, args: list[str]) -> bool:
"""Whether this invocation is outside the budget entirely.""" """Whether this invocation is outside the budget entirely, read from the
if command in SKIP_COMMANDS: 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 return True
subcommand = args[0] if args and not args[0].startswith("-") else "" if budget == cli_contract.Budget.EXEMPT_WITHOUT_ARGS:
if (command, subcommand) in SKIP_COMMAND_PATHS: # `version regrade`'s own shape: exempt only for the bare listing.
return True # `bare` is only True here for a flat command with no group at all,
# `version regrade` only reads when called with no further arguments at # which `version regrade` is not, so the subcommand token itself is
# all - the bare listing. Any index (with `--impact`) writes CHANGES.md # the one argument the bare listing consumes.
# and stays counted like `version bump`, so this cannot join consumed = 0 if bare else 1
# SKIP_COMMAND_PATHS, which only ever looks at the subcommand slot. return len(args) == consumed
if command == "version" and subcommand == "regrade":
return len(args) == 1
return False return False
@@ -342,6 +323,20 @@ def refund() -> None:
_save_state(state) _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(): def status_command():
"""Show the current session's call count and recent command history.""" """Show the current session's call count and recent command history."""
state = _load_state() 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( def reset_command(
all_sessions: bool = typer.Option( all_sessions: bool = typer.Option(
False, "--all", help="Clear every session's budget, not just the current one." False, "--all", help="Clear every session's budget, not just the current one."
+47
View File
@@ -24,6 +24,7 @@ import json
import typer import typer
from chemenu import cli_contract
from chemenu.commands._util import fail, today_iso from chemenu.commands._util import fail, today_iso
from chemenu.search import filters from chemenu.search import filters
from chemenu.search.filters import PredicateError from chemenu.search.filters import PredicateError
@@ -122,6 +123,52 @@ def render_table(result: SearchResult, show_matches: bool) -> str:
return "\n".join(lines) 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( def search_command(
text: str = typer.Argument( text: str = typer.Argument(
None, None,
+123 -17
View File
@@ -31,14 +31,14 @@ from typing import Optional
import typer import typer
from chemenu import config, tasks from chemenu import cli_contract, config, tasks
from chemenu.commands._util import fail, success from chemenu.commands._util import fail, success
from chemenu.errors import ValidationError from chemenu.errors import ValidationError
from chemenu.tasks import config as tasks_config from chemenu.tasks import config as tasks_config
app = typer.Typer( app = typer.Typer(
help="Create, list, and close items in the task tracker (Gitea #132, #138) - " help="Create, list, and close items in the task tracker - never a kb/ page, see "
"never a kb/ page, see `new project` for that pairing." "`new project` for that pairing."
) )
@@ -50,42 +50,91 @@ def _parse_follow_up_at(text: str) -> datetime.date:
@app.command("new") @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( def task_new_command(
title: str = typer.Option(..., "--title", help="The item's title. Stored verbatim, never parsed."), title: str = typer.Option(..., "--title", help="The item's title. Stored verbatim, never parsed."),
project: Optional[str] = typer.Option( project: Optional[str] = typer.Option(
None, None,
"--project", "--project",
help="An existing tracker project's name (matched case-insensitively). This command " 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 " "`wikitool new project` first if it does not exist yet. Exactly one of --project/--inbox "
"is required.", "is required.",
), ),
inbox: bool = typer.Option( inbox: bool = typer.Option(
False, False,
"--inbox", "--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 " "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 " "--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.", "there is reached through a project name and the inbox has none.",
), ),
waiting: bool = typer.Option( 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( follow_up_at: Optional[str] = typer.Option(
None, None,
"--follow-up-at", "--follow-up-at",
help="YYYY-MM-DD. Only meaningful together with --waiting - it is never a due date " 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( notes: Optional[str] = typer.Option(
None, None,
"--notes", "--notes",
help="A freetext backref, e.g. to the kb/ source page this item came from (Gitea #132 " help="A freetext backref, e.g. to the kb/ source page this item came from. "
"D5). Stored verbatim, never parsed.", "Stored verbatim, never parsed.",
), ),
): ):
"""Create one open item in the configured task tracker - no kb/ page. """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 Tracker-only by design (#132 D1): a source that carries both knowledge
and a commitment gets this command for the commitment and the normal and a commitment gets this command for the commitment and the normal
page-creation commands for the knowledge, run as two separate steps by page-creation commands for the knowledge, run as two separate steps by
@@ -132,15 +181,41 @@ def task_new_command(
@app.command("list") @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( def task_list_command(
project: str = typer.Option( project: str = typer.Option(
..., "--project", help="An existing tracker project's name (matched case-insensitively)." ..., "--project", help="An existing tracker project's name (matched case-insensitively)."
), ),
): ):
"""List a project's open items - id, title, and WAITING status (Gitea """List a project's open items - id, title, and whether each carries the WAITING status.
#138) - so a caller can get an item's id for `task close` without first \f
running `wikitool review`. Read-only; works against either access mode a So a caller can get an item's id for `task close` without first running
provider offers.""" `wikitool review` (Gitea #138). Read-only; works against either access
mode a provider offers."""
cfg = tasks_config.read_config(config.ROOT) cfg = tasks_config.read_config(config.ROOT)
if cfg is None: if cfg is None:
fail( fail(
@@ -164,16 +239,47 @@ def task_list_command(
@app.command("close") @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( def task_close_command(
item_id: str = typer.Option( item_id: str = typer.Option(
..., ...,
"--id", "--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.", "review`'s waiting_overdue/someday_stale findings - never a title.",
), ),
): ):
"""Mark one tracker item done (Gitea #138) - never delete it. The only """Mark one tracker item done - never delete it.
closing write this stack makes; see \f
The only closing write this stack makes (Gitea #138); see
`chemenu.tasks.protocol.TaskWriter.close_item` and `chemenu.tasks.protocol.TaskWriter.close_item` and
`docs/knowledge-and-commitment.md` for why.""" `docs/knowledge-and-commitment.md` for why."""
cfg = tasks_config.read_config(config.ROOT) 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. # Hard, non-optional dependency - see type_resolver.py's import comment.
from jsonschema import Draft202012Validator, FormatChecker from jsonschema import Draft202012Validator, FormatChecker
from chemenu import config from chemenu import cli_contract, config
from chemenu.commands._util import ( from chemenu.commands._util import (
check_raw_files_exist, check_raw_files_exist,
fail, 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)}" 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( def touch_command(
page_title: str = typer.Option(..., "--page", help="Exact page title, e.g. 'Docker Cheatsheet'"), 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"), 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"), 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. """Bump a page's `modified:` date and optionally rewrite its other frontmatter fields.
\f
`--summary`/`--provenance` are shorthands for the two fields worth their `--summary`/`--provenance` are shorthands for the two fields worth their
own flag; `--set`/`--add`/`--remove` reach every other field the page's own flag; `--set`/`--add`/`--remove` reach every other field the page's
type declares. Before they existed, a field `new` wrote type declares. Before they existed, a field `new` wrote
+48 -8
View File
@@ -17,7 +17,7 @@ import json
import typer import typer
from chemenu import toc from chemenu import cli_contract, toc
from chemenu.commands._util import fail from chemenu.commands._util import fail
from chemenu.types_core import UnknownType, describe_type, list_types 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") @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( def list_types_command(
json_out: bool = typer.Option(False, "--json", help="Print raw findings as JSON") json_out: bool = typer.Option(False, "--json", help="Print raw findings as JSON")
): ):
@@ -48,17 +62,43 @@ def list_types_command(
@app.command("describe") @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( def describe_type_command(
name: str = typer.Argument(..., help="Type name, e.g. 'entity' (see `types list`)"), 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"), json_out: bool = typer.Option(False, "--json", help="Print raw findings as JSON"),
): ):
"""Print one type's full contract: frontmatter fields (required/optional, """Print one type's full contract.
with enums where declared), its subtype field if any, and its authoring \f
body - the same information an LLM would otherwise gather by reading the Frontmatter fields (required/optional, with enums where declared), its
raw type-spec and `.schema.yaml` files directly. Where the type-spec subtype field if any, and its authoring body - the same information an
declares `guidance:`, that stack-owned file's prose is composed in ahead LLM would otherwise gather by reading the raw type-spec and
of the type-spec's own body, so a `root: kb` type's contract still reads `.schema.yaml` files directly. Where the type-spec declares `guidance:`,
as one answer even though it lives in two files (Gitea #104).""" 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: try:
described = describe_type(name) described = describe_type(name)
except UnknownType as exc: except UnknownType as exc:
+88 -1
View File
@@ -18,7 +18,7 @@ from typing import Optional
import typer 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.commands._util import fail, needs_clearance, rel_path, success
from chemenu.errors import ValidationError from chemenu.errors import ValidationError
@@ -33,6 +33,22 @@ def _call(fn, *args, **kwargs):
@app.command("list") @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( def upload_list_command(
json_out: bool = typer.Option(False, "--json", help="Print every waiting submission as JSON"), 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") @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( def upload_show_command(
submission_id: str = typer.Argument(..., help="A submission id from `upload list`"), submission_id: str = typer.Argument(..., help="A submission id from `upload list`"),
json_out: bool = typer.Option(False, "--json"), 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") @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( def upload_accept_command(
submission_id: str = typer.Argument(..., help="A submission id from `upload list`"), submission_id: str = typer.Argument(..., help="A submission id from `upload list`"),
confirm: Optional[str] = typer.Option( confirm: Optional[str] = typer.Option(
@@ -113,6 +179,27 @@ def upload_accept_command(
@app.command("reject") @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( def upload_reject_command(
submission_id: str = typer.Argument(..., help="A submission id from `upload list`"), submission_id: str = typer.Argument(..., help="A submission id from `upload list`"),
reason: str = typer.Option(..., "--reason", help="Why this submission was declined"), 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 import typer
from chemenu import config, ownership from chemenu import cli_contract, config, ownership
from chemenu.commands import git_publish from chemenu.commands import git_publish
from chemenu.commands._util import console, fail, success from chemenu.commands._util import console, fail, success
@@ -238,6 +238,55 @@ def _merge_success_message(
return "\n".join(lines) 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") @app.command("merge")
def merge_command( def merge_command(
remote: str = typer.Option("upstream", "--remote", help="Remote to merge from"), 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") @app.command("verify")
def verify_command( def verify_command(
since: str = typer.Option(..., "--since", help="Git revision to compare from"), 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 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.commands._util import console, fail, rel_path, success, today_iso
from chemenu.version import Version, VersionError 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" 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") @app.command("show")
def show_command( def show_command(
json_out: bool = typer.Option(False, "--json", help="Print the version and stamp as JSON"), 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']}") 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") @app.command("check")
def check_command( def check_command(
url: Optional[str] = typer.Option( 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") @app.command("notes")
def notes_command( def notes_command(
version: Optional[str] = typer.Option( 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") @app.command("bump")
def bump_command( def bump_command(
major: bool = typer.Option(False, "--major", help="Bump MAJOR (resets MINOR and PATCH)"), 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 """Raise or continue the running candidate, and open or update its
`CHANGES.md` entry. `CHANGES.md` entry.
\f
Between two releases the stack carries **one** candidate, not a fresh Between two releases the stack carries **one** candidate, not a fresh
number per bump: `--patch/--minor/--major` is max-wins escalation against 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 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") @app.command("release")
def release_command( def release_command(
title: Optional[str] = typer.Option( title: Optional[str] = typer.Option(
@@ -499,7 +685,7 @@ def release_command(
): ):
"""Fix the running candidate: strip its `-beta.N` suffix and close its """Fix the running candidate: strip its `-beta.N` suffix and close its
`CHANGES.md` entry. `CHANGES.md` entry.
\f
Ends the pre-release phase this checkout has been in since its last Ends the pre-release phase this checkout has been in since its last
`version bump` - the candidate's base becomes the release. Without `version bump` - the candidate's base becomes the release. Without
`--title` the heading keeps whichever bump last set it; with it, the `--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") @app.command("regrade")
def regrade_command( def regrade_command(
indices: Optional[list[int]] = typer.Argument( 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 """List the running candidate's bump titles with their impact grade, or
change one or more of them in a single call. change one or more of them in a single call.
\f
Positions are `version_mod.bump_entries`'s own rendered order - grouped Positions are `version_mod.bump_entries`'s own rendered order - grouped
High before Medium before Low, chronological within a grade - as it High before Medium before Low, chronological within a grade - as it
stands *before* this call: `wikitool version regrade 3 7 --impact high` 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 regrades both against today's list in one read, not position 3 first and
against whatever regrading #3 produced. Run the bare command again then position 7 against whatever regrading position 3 produced. Run the
afterwards to see the result and its new numbering. 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 The bare listing is read-only and, like `version notes`, exempt from the
Iteration Budget Gate; passing indices writes `CHANGES.md` and is counted 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 import typer
from chemenu import config from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success, today_iso from chemenu.commands._util import fail, rel_path, success, today_iso
app = typer.Typer(help="Workshop runs under work/ (see work/CONTRACT.md).") 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") @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( def new_command(
input_path: Optional[str] = typer.Option( input_path: Optional[str] = typer.Option(
None, None,
@@ -211,6 +237,25 @@ def new_command(
@app.command("close") @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( def close_command(
run_key: str = typer.Option(..., "--run-key", help="The workshop directory name"), run_key: str = typer.Option(..., "--run-key", help="The workshop directory name"),
yes: bool = typer.Option( yes: bool = typer.Option(
+82 -1
View File
@@ -25,7 +25,7 @@ from pathlib import Path
import typer 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._util import fail, parse_list, success
from chemenu.commands.page_ops import strip_frontmatter_ref from chemenu.commands.page_ops import strip_frontmatter_ref
from chemenu.frontmatter_io import write_page from chemenu.frontmatter_io import write_page
@@ -146,6 +146,34 @@ def _check_authorised(source: Page, target: Page, label: str) -> None:
@app.command("add") @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( def xref_add(
a: str = typer.Option(..., "--a", help="Exact title of the page that asserts the edge"), 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"), 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") @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( def xref_remove(
a: str = typer.Option(..., "--a", help="Exact title of page A (must exist)"), 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"), 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") @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( def xref_link_source(
source: str = typer.Option(..., "--source", help="Exact source page title, e.g. 'Source - Docker Cheatsheet'"), 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"), 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"), 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) pages = load_kb_pages(config.KB_DIR)
source_page = _find_page(pages, source) source_page = _find_page(pages, source)
names = parse_list(entities) 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 pytest
import typer 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.commands import dist_cmd, docs_verify
from chemenu.type_resolver import TypeResolver from chemenu.type_resolver import TypeResolver
def test_every_registered_command_is_documented(): def test_every_registered_command_has_a_contract_record():
"""Forward direction: a command added to the CLI without a README row is """Forward direction: a command added to the CLI with no `@cli_contract.record`
exactly the drift this check exists to catch.""" (or missing from `cli_contract.GROUPS`) is exactly the drift this check
assert docs_verify.check_cli_readme() == [] exists to catch (Gitea #121 B7)."""
assert docs_verify.check_command_contracts() == []
def test_registered_commands_include_groups_and_top_level(): 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 assert "docs verify" in commands
def test_undocumented_command_is_reported(monkeypatch): def test_a_command_with_no_record_is_reported(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"new", "frobnicate"})
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)
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_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, "registered_commands", lambda: set())
monkeypatch.setattr(docs_verify, "top_level_names", lambda: set()) issues = docs_verify.check_command_contracts()
issues = docs_verify.check_cli_readme() assert any("is not a registered wikitool command" in issue for issue in issues)
assert any("is not a wikitool command" in issue for issue in issues)
def test_invented_subcommand_under_a_real_group_is_caught(tmp_path, monkeypatch): def test_a_duplicate_groups_entry_is_reported(monkeypatch):
"""Regression guard: checking only the first token (`xref`) let a typo'd groups = (("Fixture", ("new", "new")),)
or invented subcommand sit undetected forever next to a real command monkeypatch.setattr(cli_contract, "GROUPS", groups)
group. The reverse check must match the full registered path, not just issues = docs_verify.check_command_contracts()
the top-level word.""" assert any("more than once" in issue for issue in issues)
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)
# --- section-scoped § Commands vs. § Error contracts (Gitea #91) ------------ def _fixture_record(path: str) -> cli_contract.CommandRecord:
return cli_contract.CommandRecord(
path=path,
def test_section_text_extracts_between_headings(): summary="Does a thing.",
text = "# T\n\n## A\n\nfoo\n\n## B\n\nbar\n" synopsis=(cli_contract.Variant(usage=f"{path} --flag <v>"),),
assert docs_verify.section_text(text, "## A").strip() == "foo" properties=cli_contract.Properties(
effect=cli_contract.Effect.WRITE,
idempotent=cli_contract.Idempotent.NO,
def test_section_text_extends_to_end_of_file_when_last(): atomic="Yes",
text = "# T\n\n## A\n\nfoo\nbar\n" budget=cli_contract.Budget.COUNTED,
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"
), ),
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, "CLI_README", fake)
monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"alpha", "beta"}) monkeypatch.setattr(
issues = docs_verify.check_cli_readme() cli_contract, "all_records", lambda: {"frobnicate": _fixture_record("frobnicate")}
assert any(
"beta" in issue and "§ Commands" in issue and "not documented" in issue
for issue in issues
) )
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(): 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): 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): with pytest.raises(typer.Exit):
docs_verify.verify() docs_verify.verify()
+40 -5
View File
@@ -6,7 +6,9 @@ import sys
import pytest import pytest
import typer 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 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(): def test_version_regrade_bare_listing_is_exempt():
"""Gitea #95: `version regrade` only reads when called with no further """`version regrade` only reads when called with no further arguments at
arguments at all - the listing form. It cannot join SKIP_COMMAND_PATHS all - the listing form - which is exactly `budget: exempt_without_args`
(that dict only ever looks at the subcommand slot), so it is its own in its `cli_contract` record."""
branch in `is_exempt`."""
assert run_budget.is_exempt("version", ["regrade"]) 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"]) 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(): def test_reset_command_requires_yes():
run_budget.record_and_check("new", ["entity", "--name", "X"], override=False) run_budget.record_and_check("new", ["entity", "--name", "X"], override=False)
with pytest.raises(typer.Exit): with pytest.raises(typer.Exit):