tools: command records, Distribution and versioning group - one line per cause, examples, prohibitions (#142)
CI / verify (push) Successful in 1m20s
Release / release (push) Successful in 37s

Files changed:
- CHANGES.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/version_cmd.py
This commit is contained in:
torben committed 2026-09-26 08:35:56 +02:00
1 parent df8ff2fa22
commit 9d6ca6b193
5 files changed
+710 -265

No files matched your search

+180 -102
View File
@@ -467,12 +467,14 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
if source.is_file():
plan[relative] = _read_planned_file(source, relative)
# `raw/` and `incoming/` are both flat now (Gitea #67 removes type
# `raw/` and `incoming/` are both flat (Gitea #67 removed type
# subdirectories from the addressing scheme entirely - a file's location
# under `raw/` is a date shard computed by `raw accept`, never a hand-picked
# type). `.gitignore` (also exported, see ROOT_FILES) excludes `incoming/`
# again once the instance is a git repo, which is why bootstrap.md
# re-creates it for a plain clone that never had this export step at all.
# type), so an export creates no subdirectories under either root. The
# exported `.gitignore` excludes what lands in `incoming/` but not its
# `.gitkeep` (`/incoming/*` plus `!/incoming/.gitkeep`, Gitea #88), so the
# anchor is tracked and a plain clone of the instance has the directory
# without any bootstrap step re-creating it.
plan["raw/.gitkeep"] = PlannedFile("")
plan["incoming/.gitkeep"] = PlannedFile("")
@@ -583,36 +585,63 @@ def _write_plan(target: Path, plan: dict[str, PlannedFile]) -> None:
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` marker 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="",
cause="Target exists and is not empty, is not a directory, or the tree has no "
"readable `VERSION`",
reaction="Point `<target>` at an empty (or new) directory and retry. Never merge into a "
"non-empty one by hand",
),),
notes=(
"Writes a contentless, distributable copy of this repo's machinery into an empty or "
"new `<target>` directory.",
"Ships `AGENTS.md`/`README.md`/`EVALS.md` with any `dist:strip-start`...`dist:strip-end` "
"marker 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) and `VERSION`.",
"Ships `raw/` and `incoming/` as flat roots, each with a `.gitkeep` and no "
"subdirectories. `incoming/.gitkeep` is trackable and survives becoming a git "
"repository, so a plain clone gets the directory without any bootstrap step.",
"Ships templates, never the filled files: `USER.md.template`/`SOUL.md.template`, "
"`kb/CONVENTIONS.md.template`, and each collection's contract re-keyed as "
"`kb/<name>/COLLECTION.md.template`. The filled "
"`USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/"
"`types/<page-type>.md` bind their instance; `find_leaks` refuses a plan carrying one.",
"Writes a generated `.wikitool-release.json` stamp: version, export date, origin, and "
"a sha256 per exported file - the base a later upgrade compares against.",
"The four origin options only fill stamp fields: `export` never calls git and cannot "
"discover them.",
"One-way: no command reconstructs a distributed instance into a dev instance - work on "
"the stack in the origin repo, or in a new dev instance exported from it.",
"`--dry-run` lists every file it would write, and writes nothing.",
),
failures=(
cli_contract.Failure(
cause="`<target>` exists and is not empty, or is not a directory",
reaction="Point `<target>` at an empty (or new) directory and retry",
),
cli_contract.Failure(
cause="The tree has no readable `VERSION`",
reaction="Fix `VERSION`, then retry",
),
cli_contract.Failure(
cause="A licence file is missing from the source tree",
reaction="Restore it, then retry",
),
cli_contract.Failure(
cause="The plan would carry this instance's own data (a filled personalization, "
"conventions or page type-spec file)",
reaction="Report it: it is an allowlist bug in `dist export`, not something to "
"work around by deleting files from the target",
),
),
examples=(
"tools/wikitool dist export ../my-wiki --dry-run",
"tools/wikitool dist export ../my-wiki",
),
never=(
"Never merge an export into a non-empty directory by hand.",
),
see_also=(
"`instructions/setup-instance.md` - what comes after the export",
"`wikitool dist upgrade` - applies a later export to an existing instance",
"`wikitool version show` - reads the stamp this writes",
),
))
@app.command("export")
def export_command(
@@ -961,76 +990,119 @@ def _report_plan(
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",
atomic="Yes for every refusal - 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="",
cause="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",
reaction="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",
),),
notes=(
"Never downloads anything: `<source>` is an already-fetched export directory or "
"`.tar.gz` release archive. An archive is verified against a sibling `.sha256` if one "
"is present (a missing one is a WARN, not a block) and 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`) or seeds once and the instance owns from then on "
"(`.wikitool-kb.json`, `CHANGES.md`), plus the stamp itself, always rewritten.",
"Every candidate path is classified against the *local* `.wikitool-release.json`'s "
"recorded digest: unchanged is overwritten silently, absent from the old stamp is "
"created, and locally modified or locally deleted is **never** silently overwritten.",
"Locally changed files abort the run with the full list; the abort text names the "
"three answers with the command line filled in, and none of them is the default.",
"`--keep-local` proceeds and leaves every locally changed file untouched. The new "
"stamp is still written whole, recording the release's digest for files that were "
"not written, so a skipped file keeps diverging and is reported again on every later "
"run.",
"`--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 "
"path then matches the stamp and stops being reported.",
"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 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, but never runs any of it - "
"there is no `migrate run`.",
"Reports, but does not block on, a crossed compatibility boundary.",
"The local preconditions - `VERSION`, the release stamp, `.wikitool-kb.json`, no "
"outstanding migration, a clean working tree - are checked before the source is read. "
"Not being a git repository at all is a WARN, not a refusal.",
"Never touches git - no commit, no push.",
"`--dry-run` classifies and reports without writing; a pre-release (`-beta.N`) source "
"needs `--pre`.",
"An interrupted write is not resumed automatically: compare the tree against the "
"printed classification and finish or revert by hand.",
"The closing report names `instructions/upgrade-instance.md`, which carries the order "
"for everything after the swap and resumes at `instructions sync`.",
),
failures=(
cli_contract.Failure(
cause="The source's version equals the installed one - a no-op success",
reaction="",
code=0,
),
cli_contract.Failure(
cause="Local `VERSION` missing, or no local `.wikitool-release.json` with a `files` "
"block",
reaction="Not transient - fix the named precondition and retry. A checkout with "
"shared git history takes stack updates with `wikitool upstream merge` instead",
),
cli_contract.Failure(
cause="`.wikitool-kb.json` is missing",
reaction="Run `wikitool migrate baseline <version>`, then retry",
),
cli_contract.Failure(
cause="A migration is already outstanding against the *installed* machinery",
reaction="Finish it first - `wikitool migrate status` names it - then retry",
),
cli_contract.Failure(
cause="The working tree is dirty",
reaction="Commit or stash first, then retry",
),
cli_contract.Failure(
cause="`<source>` does not exist, fails its `.sha256`, or does not unpack to exactly "
"one top-level directory",
reaction="Fix the path or re-download the release archive, then retry",
),
cli_contract.Failure(
cause="The source carries no `VERSION`, `.wikitool-release.json` or `files` block",
reaction="Point `<source>` at a distribution export, then retry",
),
cli_contract.Failure(
cause="The source's version is older than the installed one, or a pre-release "
"without `--pre`",
reaction="Not transient - choose another source, or pass `--pre` for a pre-release",
),
cli_contract.Failure(
cause="A `--take-release` path this run does not classify as locally changed - the "
"one refusal a `--dry-run` also raises",
reaction="Correct it against the locally-changed list the refusal prints, then retry",
),
cli_contract.Failure(
cause="Locally changed files that neither `--keep-local` nor a `--take-release` "
"answers for; nothing was written",
reaction="Choose one of the three answers the refusal names, re-run lines filled in: "
"`--take-release <path>` to write the release's version over it (ends the "
"divergence), `--keep-local` to leave them untouched (reported again on every "
"later run until they stop diverging), or reconcile by hand and retry",
),
),
examples=(
"tools/wikitool dist upgrade ../chemenu-7.1.0.tar.gz --dry-run",
"tools/wikitool dist upgrade ../chemenu-7.1.0.tar.gz",
"tools/wikitool dist upgrade ../chemenu-7.1.0.tar.gz --take-release tools/README.md",
),
never=(
"Never treat any of the three answers to locally changed files as the default.",
),
see_also=(
"`wikitool version check` - finds out whether an update exists",
"`instructions/upgrade-instance.md` - the order after the swap",
"`INSTALL.md` § \"Version und Updates\" - which release, whether to take it, where "
"the tarball comes from",
"`wikitool upstream merge` - the update path for a checkout with shared git history",
"`wikitool migrate status` - the migrations the report names",
),
))
@app.command("upgrade")
def upgrade_command(
@@ -1226,6 +1298,12 @@ def run_upgrade(
dst = config.ROOT / relative
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
# The new stamp is written whole even after `--keep-local` skipped files: it 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
# `--take-release` path is the opposite case: it was written, so it matches the
# digest recorded here and stops being reported at all.
shutil.copy2(stamp_path, config.ROOT / version_mod.RELEASE_STAMP_FILENAME)
pruned: list[str] = []
+272 -138
View File
@@ -89,14 +89,26 @@ def _describe_origin(stamp: Optional[dict]) -> str:
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**",
notes=(
"Prints `VERSION` and where this tree came from: \"development tree\" without a "
"release stamp, otherwise the export date, source commit and repository from "
"`.wikitool-release.json`, plus the release page when the stamp records one.",
"`--json` prints the version, its compatibility key, the stamp and the update URL.",
"Bare `wikitool version` is an alias for this.",
"Read-only and offline; exempt from the Iteration Budget Gate.",
),
failures=(cli_contract.Failure(
label="",
cause="`VERSION` is missing or unparseable",
reaction="Fix `VERSION` and retry",
),),
examples=(
"tools/wikitool version show",
"tools/wikitool version show --json",
),
see_also=(
"`wikitool version check` - asks whether a newer stack exists",
"`wikitool version notes` - prints a version's release notes",
),
))
@app.command("show")
def show_command(
@@ -141,22 +153,47 @@ def show_command(
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="",
cause="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",
reaction="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",
),),
notes=(
"Asks the release feed for its latest release and compares it with `VERSION`: `state` "
"is `current`, `update`, `migration` (the step crosses a compatibility boundary) or "
"`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, and times out after "
"`--timeout` seconds (default 10).",
"The feed is `--url`, else `$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.",
"For `update` or `migration` it prints that applying the release is a separate, manual "
"step (`INSTALL.md` § \"Eine Instanz aktualisieren\").",
"Read-only; exempt from the Iteration Budget Gate.",
),
failures=(
cli_contract.Failure(
cause="`VERSION` is missing or unparseable",
reaction="Fix `VERSION` and retry",
),
cli_contract.Failure(
cause="The feed could not be reached, answered non-JSON, or carried no `tag_name`",
reaction="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",
),
),
examples=(
"tools/wikitool version check",
"tools/wikitool version check --json",
),
never=(
"Never report \"up to date\" for a check that failed - an unreachable feed is not "
"that answer.",
),
see_also=(
"`wikitool dist upgrade` - applies the update this reports",
"`wikitool version notes` - prints the release notes",
"`INSTALL.md` § \"Version und Updates\" - what the operator decides before an update",
),
))
@app.command("check")
def check_command(
@@ -238,33 +275,53 @@ def check_command(
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="",
cause="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",
reaction="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",
),),
notes=(
"Prints the `CHANGES.md` entry for `--version` (default: this tree's `VERSION`).",
"With no such entry, in a tree with a release stamp (a `dist export` tree) and without "
"`--offline`, it prints the release feed's latest release notes instead. A tree "
"without a stamp (a dev checkout) never asks the feed.",
"Only the feed's latest release can be asked for. When that is a different version "
"than requested, stderr names it and the notes are printed anyway - the expected case "
"before an upgrade, where `VERSION` still names the release being left.",
"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.",
"`--offline` never asks the feed and fails with the stamp's `release_url` instead.",
"Every failure names the stamp's `release_url` where it has one, so a run that cannot "
"read the notes is still told where they are.",
"Read-only, safe to retry, and exempt from the Iteration Budget Gate.",
),
failures=(
cli_contract.Failure(
cause="`--version` is unparseable, `VERSION` is unreadable when `--version` is "
"omitted, or `CHANGES.md` is missing",
reaction="Fix the named argument or file, then retry",
),
cli_contract.Failure(
cause="No entry for the requested version in a tree with no release stamp (a dev "
"checkout)",
reaction="Write the entry, or run `version bump`",
),
cli_contract.Failure(
cause="No entry for the requested version, and `--offline` was passed",
reaction="Read the release page the error names",
),
cli_contract.Failure(
cause="No entry for the requested version, and the feed could not be reached or "
"returned a release with an empty `body`",
reaction="A feed failure is transient - retry once, then read the release page the "
"error names",
),
),
examples=(
"tools/wikitool version notes",
"tools/wikitool version notes --version 7.0.0",
"tools/wikitool version notes --offline",
),
see_also=(
"`wikitool version check` - asks the same feed whether a newer stack exists",
"`wikitool version bump` - writes the entry this prints",
),
))
@app.command("notes")
def notes_command(
@@ -398,55 +455,90 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str
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="",
cause="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",
reaction="**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",
),),
notes=(
"`VERSION` gets a `-beta.N` suffix: one running candidate between two releases, never "
"a 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.",
"The first bump of a candidate opens its `CHANGES.md` entry - heading, date, author, "
"and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so "
"far, graded by `--impact` (default `medium`). Every later bump of the same candidate "
"updates that entry in place: one entry per candidate, not one per bump.",
"The bump list renders grouped under `**High/Medium/Low impact**` headings, empty "
"groups omitted - except while every bump is `medium`, where it stays one flat list. "
"`version regrade` corrects a grade after the fact.",
"Writes the heading, the bump list and the breaking/migration lines; the entry's prose "
"is left to the author.",
"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.",
"On a later crossing of the same candidate, a further `--breaking` **joins** the "
"reasons already recorded (flat on the marker line while there is one, bullets under a "
"bare marker from the second on; repeating a reason verbatim is a no-op), while a "
"further `--no-migration` **replaces** the single migration line.",
"`--migration-required` retracts the running candidate's `--no-migration` line; it "
"needs a migration document already targeting the new base. Nothing retracts a "
"recorded `--breaking` reason.",
"Enforces that a crossing documents itself, never that the part was chosen correctly.",
"Not idempotent: every successful run escalates or continues the candidate again.",
"`--dry-run` reports the step without writing.",
),
failures=(
cli_contract.Failure(
cause="Not exactly one of `--major`/`--minor`/`--patch`, an empty `--title`, or an "
"unknown `--impact`",
reaction="Nothing was written - fix the argument and retry once",
),
cli_contract.Failure(
cause="`VERSION` or `CHANGES.md` is missing, or `VERSION` and the changelog's "
"newest entry name different versions",
reaction="Nothing was written - fix whichever is wrong, then retry",
),
cli_contract.Failure(
cause="An escalation to a boundary crossing without `--breaking`, or with neither a "
"migration document targeting the new base nor `--no-migration`",
reaction="Nothing was written - add what the error asks for, or, if nothing actually "
"breaks, choose a smaller part",
),
cli_contract.Failure(
cause="`--breaking` or `--no-migration` on a bump that crosses nothing",
reaction="Nothing was written - drop the flag and retry",
),
cli_contract.Failure(
cause="`--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",
reaction="Nothing was written - write the migration document or fix the "
"combination, then retry",
),
),
examples=(
'tools/wikitool version bump --patch --title "Fix the lint report path" --impact low',
'tools/wikitool version bump --minor --title "New command: wikitool review" --dry-run',
'tools/wikitool version bump --major --title "Rename --gate-file to --remotes-file" '
'--breaking "--gate-file is gone; scripts must pass --remotes-file" '
'--no-migration "no content changes"',
),
never=(
"Never re-run after an uncertain outcome without first reading `VERSION` and the top "
"of `CHANGES.md` - a second run escalates or continues the candidate again.",
"Never hand-edit `VERSION` or the machine-written parts of the entry (heading, bump "
"list, breaking and migration lines); `--migration-required` is the only way to take "
"the migration line back.",
),
see_also=(
"`wikitool version regrade` - corrects an `--impact` grade",
"`wikitool version release` - fixes the candidate into a release",
"`instructions/migrate-corpus.md` - how a migration document is written",
),
))
@app.command("bump")
def bump_command(
@@ -653,28 +745,51 @@ def bump_command(
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="",
cause="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",
reaction="**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",
),),
notes=(
"Strips `VERSION`'s `-beta.N` suffix - the candidate's base becomes the release - and "
"closes the candidate's `CHANGES.md` entry.",
"Without `--title` the heading keeps whichever bump last set it; `--title` replaces it "
"- the normal case for a candidate that collected several bumps, whose entry wants a "
"summarising heading rather than the most recent one.",
"Leaves the entry's machine-managed bump list untouched, as the record of what "
"happened.",
"From two bumps on, requires a 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.",
"Commits nothing and pushes nothing. The following `publish` moves `VERSION` onto "
"`main`, which `release.yml` reacts to.",
"Not idempotent: a second run fails once the suffix is gone.",
),
failures=(
cli_contract.Failure(
cause="`VERSION` is already a release - there is no running candidate to fix",
reaction="After an uncertain run this means it already ran; otherwise there is "
"nothing to release",
),
cli_contract.Failure(
cause="`VERSION` or `CHANGES.md` is missing, or `VERSION` and the changelog's newest "
"entry name different versions",
reaction="Fix whichever is wrong, then retry",
),
cli_contract.Failure(
cause="Two or more bumps and no summary paragraph above the changesets",
reaction="Write a short summary paragraph right below the bump list, then retry",
),
),
examples=(
"tools/wikitool version release --dry-run",
'tools/wikitool version release --title "Command records rewritten for agents"',
),
never=(
"Never retry after an uncertain outcome without reading `VERSION` first - a "
"release-shaped `VERSION` means it already ran.",
),
see_also=(
"`wikitool version bump` - opens and continues the candidate",
"`wikitool version regrade` - shows the bump list the summary sits under",
"`wikitool publish` - moves the released `VERSION` onto `main`",
),
))
@app.command("release")
def release_command(
@@ -775,26 +890,45 @@ def release_command(
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="",
cause="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`",
reaction="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",
),),
notes=(
"Without arguments: lists the running candidate's bump titles with their grade, "
"numbered by 1-based position in the rendered list (High before Medium before Low, "
"chronological within a grade). Read-only and exempt from the Iteration Budget Gate.",
"With indices and `--impact`: sets the grade of every named position in one call, all "
"resolved against a single read of the current list - `version regrade 3 7 --impact "
"high` grades what is at 3 and 7 now, not 7 after 3 has moved. Writes `CHANGES.md` and "
"is counted by the Iteration Budget Gate.",
"The correction path for an `--impact` grade judged at bump time.",
"Touches only the topmost entry's bump list - never `VERSION`, never any other part of "
"`CHANGES.md`.",
"A write is not idempotent against a changed list: after a first success, the same "
"indices may name different bumps.",
),
failures=(
cli_contract.Failure(
cause="`VERSION` or `CHANGES.md` is missing, `VERSION` and the changelog's newest "
"entry name different versions, or the topmost entry has no bump list",
reaction="Nothing was written - fix whichever is wrong, then retry",
),
cli_contract.Failure(
cause="An index outside the rendered list's range, indices given without "
"`--impact`, or an unknown `--impact`",
reaction="Nothing was written - list again with the bare command, then retry once "
"with corrected arguments",
),
),
examples=(
"tools/wikitool version regrade",
"tools/wikitool version regrade 3 7 --impact high",
),
never=(
"Never re-run the same indices after a write without listing again first - the "
"positions may have moved.",
),
see_also=(
"`wikitool version bump` - sets the grade in the first place",
"`wikitool version release` - refuses without a summary above this list",
),
))
@app.command("regrade")
def regrade_command(