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