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

Files changed:
- CHANGES.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/migrate_cmd.py
This commit is contained in:
torben committed 2026-09-26 09:26:39 +02:00
1 parent 71efdbe01b
commit 243db66134
4 files changed
+261 -79

No files matched your search

+149 -64
View File
@@ -59,9 +59,19 @@ def _versions() -> tuple[Version, Optional[Version]]:
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.",
notes=(
"Lists every migration document under `instructions/migrations/`, oldest target first, "
"with its kind and obligation.",
"Never fails. Read-only and **exempt from the Iteration Budget Gate**.",
),
failures=(),
examples=(
"tools/wikitool migrate list",
),
see_also=(
"`wikitool migrate status` - which of them this instance still owes",
"`instructions/migrate-corpus.md` - how a migration is run",
),
))
@app.command("list")
def list_command(
@@ -158,23 +168,39 @@ def _report_offers(
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="",
cause="`.wikitool-kb.json` is missing (content version undeclared), or `VERSION` is "
"unreadable",
reaction="For a missing declaration: run `migrate baseline <version>` once, then retry. "
"Safe to retry freely otherwise",
),),
notes=(
"Shows every **required** migration whose `migrates_to` lies in "
"`(kb_version, VERSION]`, in the order it must run.",
"`offered` migrations are listed separately above the chain: they never block, never "
"count as owed, and are bounded by the applied ledger rather than by `kb_version` - "
"taking one does not move the version.",
"With a release stamp present, also reports which shipped files this instance has "
"since edited (from the per-file sha256 in `.wikitool-release.json`) - which 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 the content version is undeclared (`.wikitool-kb.json` missing) or "
"`VERSION` is unreadable; it never guesses the content's shape.",
"Read-only, safe to retry freely, and exempt from the Iteration Budget Gate.",
),
failures=(
cli_contract.Failure(
cause="`.wikitool-kb.json` is missing - the content version is undeclared",
reaction="Run `migrate baseline <version>` once, then retry",
),
cli_contract.Failure(
cause="`VERSION` is unreadable",
reaction="Fix `VERSION`, then retry",
),
),
examples=(
"tools/wikitool migrate status",
"tools/wikitool migrate status --json",
),
see_also=(
"`wikitool migrate done` - records one as applied",
"`instructions/migrate-corpus.md` - how a migration is run",
"`instructions/upgrade-instance.md` - where an upgrade checks this",
),
))
@app.command("status")
def status_command(
@@ -264,21 +290,38 @@ def status_command(
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="",
cause="Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a *required* "
"version that is not the next link in the chain",
reaction="**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",
),),
notes=(
"Records one migration as applied, advancing `kb_version` in `.wikitool-kb.json` to its "
"target.",
"**Refuses any required version that is not the next link in the chain.**",
"An `offered` migration is recorded in the applied ledger *without* moving "
"`kb_version` and with no ordering rule applied. Re-recording one already in the ledger "
"is a no-op, not an error - idempotent and safe to repeat.",
"Not idempotent for a required migration: it advances the chain.",
"`--dry-run` reports without writing.",
),
failures=(
cli_contract.Failure(
cause="Unknown version, no `.wikitool-kb.json`, or nothing outstanding",
reaction="Check `migrate status`, fix the argument, then retry once",
),
cli_contract.Failure(
cause="A *required* version that is not the next link in the chain",
reaction="Run `migrate status` and apply the migrations in the order it prints",
),
),
examples=(
"tools/wikitool migrate done 7.0.0 --pages 42 --dry-run",
"tools/wikitool migrate done 7.0.0 --pages 42",
),
never=(
"Never force the order of required migrations.",
"Never hand-edit `.wikitool-kb.json` to advance the version.",
),
see_also=(
"`wikitool migrate status` - the order to apply them in",
"`instructions/migrate-corpus.md` - the migration procedure",
),
))
@app.command("done")
def done_command(
@@ -293,7 +336,9 @@ def done_command(
interrupted multi-step upgrade has to be resumable rather than guessable.
An `offered` migration is recorded but does not move the version, and no
ordering rule applies to it - it is not a link in the chain. The record is
ordering rule applies to it - 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. The record is
the only thing that distinguishes an offer someone took from one they
ignored, precisely because the version stays put."""
stack, kb_version = _versions()
@@ -386,16 +431,32 @@ def done_command(
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="",
cause="Unparseable version, or a declaration already exists and `--force` was not "
"passed",
reaction="Safe to re-run with the same version. If a declaration exists, it is almost "
"always `migrate done` that was wanted",
),),
notes=(
"Declares `kb_version` once, for an instance predating `.wikitool-kb.json`.",
"Refuses to overwrite an existing declaration without `--force`. Advancing the version "
"after a migration is `migrate done`, which checks the chain; this command does not.",
"Safe to re-run with the same version.",
),
failures=(
cli_contract.Failure(
cause="Unparseable version",
reaction="Fix the version and retry",
),
cli_contract.Failure(
cause="A declaration already exists and `--force` was not passed",
reaction="It is almost always `migrate done` that was wanted",
),
),
examples=(
"tools/wikitool migrate baseline 6.2.0",
),
never=(
"Never use `--force` to advance the version past a migration - that is `migrate done`.",
),
see_also=(
"`wikitool migrate done` - advances the version after a migration",
"`wikitool migrate status` - what is owed from the declared version",
),
))
@app.command("baseline")
def baseline_command(
@@ -537,25 +598,42 @@ def _shapes_now(wanted: set[str]) -> dict[str, corpus_diff.PageShape]:
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="",
cause="Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is "
"not a revision in this repository",
reaction="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",
),),
notes=(
"Compares `kb/` against the revision `--from` on the invariants a content migration "
"must not change: wikilink and citation **counts** (not sets), footnote definitions, "
"H1, structural frontmatter, and the **count of generated-region marker pairs**.",
"Pages are matched by **title**, not path, so a page `move` (or `move --reconcile`) "
"relocated compares as itself - reported separately as `moved` - rather than as a "
"removed-and-added pair.",
"Reports added and 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.",
"Exits 0 whatever it finds unless `--fail-on-error` is passed.",
"Read-only and exempt from the Iteration Budget Gate.",
),
failures=(
cli_contract.Failure(
cause="Only with `--fail-on-error`: an invariant changed",
reaction="Act on the findings - exit 1 here means \"act on the findings\", not \"the "
"tool is broken\". A finding names a page and what changed on it; it is never fixed "
"by re-running",
),
cli_contract.Failure(
cause="`--from` is not a revision in this repository",
reaction="Fix the revision and retry",
),
),
examples=(
"tools/wikitool migrate verify --from HEAD",
"tools/wikitool migrate verify --from HEAD --path kb/concepts --expect-body-change",
),
never=(
"Never re-run to make a finding go away - fix the page it names.",
),
see_also=(
"`instructions/migrate-corpus.md` - where a migration runs this",
"`wikitool lint` - the single-revision checks",
),
))
@app.command("verify")
def verify_command(
@@ -575,6 +653,13 @@ def verify_command(
must not change: wikilink and citation *counts*, footnote definitions, H1,
and structural frontmatter.
Marker pairs are compared by *count*, not by the set of region names: a
page that went from one links region to two has the same set and a
different count, and a lost marker turns a generated region into prose
the next write appends a second one beside. This is the one question
`lint` cannot answer - it reads a single revision, so it cannot see that
something went missing.
Pages are matched by title, not path, so a page that only moved directory
(see `wikitool move`) compares as itself rather than as a
removed-and-added pair - its path change is reported separately, as