Files
chemenu/tools/chemenu/commands/migrate_cmd.py
T
torben 243db66134
CI / verify (push) Successful in 1m14s
Release / release (push) Successful in 37s
tools: command records, Content migrations group - one line per cause, examples, prohibitions (#142)
Files changed:
- CHANGES.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/migrate_cmd.py
2026-09-26 09:26:39 +02:00

699 lines
28 KiB
Python

"""`wikitool migrate` - content migrations: what this instance still owes, and
whether a bulk rewrite broke anything.
Five commands around two facts. `.wikitool-kb.json` (see `chemenu/kb_state.py`)
records what shape the content is in, so the chain of outstanding migrations is
computed rather than guessed. `migrate verify` compares the corpus against a git
revision on the invariants a migration must not change (see
`chemenu/corpus_diff.py`).
**There is no `migrate run`.** An `assisted` migration is a procedure an agent
carries out page by page; the tool keeps the books and checks the result. A
`run` would claim an ability that does not exist - it arrives when mechanical
primitives do.
"""
from __future__ import annotations
import json as _json
import subprocess
from pathlib import Path
from typing import Optional
import typer
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
from chemenu.version import Version, VersionError
app = typer.Typer(help="Content migrations: outstanding chain, bookkeeping, and verification.")
# --- shared state loading --------------------------------------------------
def _versions() -> tuple[Version, Optional[Version]]:
"""(stack version, kb version).
An unreadable VERSION or a corrupt state file exits 1 here (via `fail`,
which raises); a *missing* kb version returns None, because that is a
state each command explains in its own words rather than an error."""
try:
return version_mod.read_version(), kb_state.read_kb_version()
except VersionError as exc:
fail(str(exc))
raise # unreachable: fail() raises typer.Exit
# --- 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=(
"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(
json_out: bool = typer.Option(False, "--json", help="Print the migrations as JSON"),
):
"""List every migration document, oldest target first. Read-only."""
migrations = kb_state.load_migrations()
if json_out:
typer.echo(
_json.dumps(
[
{
"name": m.name,
"migrates_to": str(m.target),
"migration_kind": m.kind,
"obligation": m.obligation,
"description": m.description,
"path": m.relative_path,
}
for m in migrations
],
indent=2,
)
)
return
if not migrations:
success(f"No migration documents under {rel_path(kb_state.migrations_dir())}.")
return
for migration in migrations:
console.print(
f"[bold]{migration.target}[/bold] {migration.name} "
f"({migration.kind}, {migration.obligation})"
)
if migration.description:
console.print(f" {migration.description}")
# --- migrate status --------------------------------------------------------
def _report_offers(
offered: list["kb_state.Migration"], divergent: Optional[list[str]]
) -> None:
"""Print the optional half of `status`, above the outstanding chain.
Deliberately never affects the exit code and never says "outstanding". An
offer is the stack proposing a better default for a file the instance owns;
an instance that keeps its own version is in a correct state, not a late
one. Mixing the two is how the message that actually matters - your content
no longer fits your machinery - stops being read.
"""
if not offered:
return
console.print(
f"[cyan]{len(offered)} optional upgrade(s) available[/cyan] - none of them block:"
)
for migration in offered:
console.print(f" {migration.target} {migration.name} ({migration.kind})")
if migration.description:
console.print(f" {migration.description}")
console.print(f" {migration.relative_path}")
if divergent is None:
console.print(
" [dim]This tree carries no release stamp, so which of your files still match "
"what you were given cannot be answered here.[/dim]"
)
return
if divergent:
console.print(
f" [dim]{len(divergent)} file(s) differ from the release you installed - those are "
"yours to reconcile by hand rather than overwrite:[/dim]"
)
for relative in divergent[:10]:
console.print(f" [dim]{relative}[/dim]")
if len(divergent) > 10:
console.print(f" [dim]... and {len(divergent) - 10} more[/dim]")
else:
console.print(
" [dim]No file differs from the release you installed, so an offer can be taken "
"by copying.[/dim]"
)
@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=(
"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(
json_out: bool = typer.Option(False, "--json", help="Print the chain as JSON"),
):
"""Show the migrations this instance still owes, in the order they run.
Read-only. Exit 1 only when the KB version is undeclared - that is a
question the tool refuses to answer by guessing."""
stack, kb_version = _versions()
migrations = kb_state.load_migrations()
if kb_version is None:
if json_out:
typer.echo(
_json.dumps(
{"stack_version": str(stack), "kb_version": None, "pending": None}, indent=2
)
)
fail(
f"{kb_state.KB_STATE_FILENAME} is missing - this instance has never declared what "
f"shape its content is in, and guessing would be wrong exactly when it matters.\n"
f"Declare it once: `wikitool migrate baseline <version>` (use {stack} if this "
f"instance's content has never been migrated behind its machinery)."
)
return
pending = kb_state.chain(migrations, kb_version, stack.base)
offered = kb_state.offers(migrations, kb_state.applied_names(kb_state.read_kb_state()))
divergent = kb_state.divergent_files()
if json_out:
typer.echo(
_json.dumps(
{
"stack_version": str(stack),
"kb_version": str(kb_version),
"pending": [
{"name": m.name, "migrates_to": str(m.target), "migration_kind": m.kind}
for m in pending
],
"offered": [
{"name": m.name, "migrates_to": str(m.target), "migration_kind": m.kind}
for m in offered
],
"divergent_files": divergent,
},
indent=2,
)
)
return
console.print(f"stack {stack}, content {kb_version}")
_report_offers(offered, divergent)
if not pending:
if kb_version < stack:
console.print(
f"[green]Nothing outstanding[/green] - no migration targets the range "
f"({kb_version}, {stack}]."
)
else:
console.print("[green]Nothing outstanding[/green] - content matches the machinery.")
return
console.print(f"[cyan]{len(pending)} migration(s) outstanding, in this order:[/cyan]")
for position, migration in enumerate(pending, start=1):
console.print(f" {position}. {migration.target} {migration.name} ({migration.kind})")
if migration.description:
console.print(f" {migration.description}")
console.print(f" {migration.relative_path}")
console.print(
f"\nRun the first one, then record it: `wikitool migrate done {pending[0].target}`.\n"
"The procedure is instructions/migrate-corpus.md."
)
# --- 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=(
"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(
version: str = typer.Argument(..., help="The migration's target version, e.g. 1.4.0"),
pages: Optional[int] = typer.Option(None, "--pages", help="How many pages it touched"),
dry_run: bool = typer.Option(False, "--dry-run", help="Report without writing"),
):
"""Record one migration as applied, advancing the KB version to its target.
Refuses any version that is not the *next* link in the chain: skipping a
migration is how a corpus ends up in a shape no version describes, and an
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, 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()
if kb_version is None:
fail(
f"{kb_state.KB_STATE_FILENAME} is missing - run `wikitool migrate baseline <version>` "
"before recording a migration."
)
return
try:
target = Version.parse(version)
except VersionError as exc:
fail(str(exc))
return
migrations = kb_state.load_migrations()
state = kb_state.read_kb_state() or {}
# An offer is recorded but does not advance the version: it is not a link in
# the chain, so there is no ordering rule to check and nothing to skip. The
# ledger is what makes it stop being offered - without that record there
# would be no way to tell a taken offer from an ignored one, because
# `kb_version` deliberately does not move.
offered = {m.name: m for m in migrations if not m.is_required}
taken = next((m for m in offered.values() if str(m.target) == version), None)
if taken is not None:
if taken.name in kb_state.applied_names(state):
success(f"{taken.name} is already recorded as taken. Nothing to do.")
return
if dry_run:
success(f"Dry run: would record the optional {taken.name}. Nothing written.")
return
applied = list(state.get("applied") or [])
entry = {"migration": taken.name, "at": today_iso(), "obligation": kb_state.OFFERED}
if pages is not None:
entry["pages"] = pages
applied.append(entry)
kb_state.write_kb_state(kb_version, applied)
success(
f"Recorded the optional {taken.name}. Content stays at {kb_version} - an offer "
"changes a file you own, not the shape of your content."
)
return
expected = kb_state.next_link(migrations, kb_version, stack.base)
if expected is None:
fail(
f"Nothing is outstanding: content is at {kb_version}, machinery at {stack}, and no "
f"migration targets the range in between."
)
return
if expected.target != target:
fail(
f"{target} is not the next migration. The chain from {kb_version} continues with "
f"{expected.target} ({expected.name}) - applying them out of order leaves the corpus "
f"in a shape no version describes.\nRun `wikitool migrate status` to see the order."
)
return
applied = list(state.get("applied") or [])
entry = {"migration": expected.name, "at": today_iso()}
if pages is not None:
entry["pages"] = pages
applied.append(entry)
if dry_run:
success(f"Dry run: content {kb_version} -> {target} ({expected.name}). Nothing written.")
return
kb_state.write_kb_state(target, applied)
remaining = kb_state.chain(migrations, target, stack.base)
success(
f"Content is now {target} ({expected.name}). "
+ (
f"{len(remaining)} migration(s) still outstanding - next is {remaining[0].target}."
if remaining
else "Nothing outstanding."
)
)
@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=(
"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(
version: str = typer.Argument(..., help="The shape this instance's content is already in"),
force: bool = typer.Option(
False, "--force", help="Overwrite an existing declaration (not a substitute for `done`)"
),
):
"""Declare the KB version once, for an instance that never had one.
Only for a tree predating `.wikitool-kb.json`. Advancing the version after
a migration is `migrate done`, which checks the chain; this command does
not, which is why it refuses to overwrite silently."""
try:
target = Version.parse(version)
except VersionError as exc:
fail(str(exc))
return
try:
existing = kb_state.read_kb_version()
except VersionError as exc:
fail(str(exc))
return
if existing is not None and not force:
fail(
f"This instance already declares content version {existing}. Use "
f"`wikitool migrate done <version>` to advance it after a migration, or --force "
f"if the declaration itself is wrong."
)
return
state = kb_state.read_kb_state() or {}
kb_state.write_kb_state(target, list(state.get("applied") or []))
success(f"Content version declared as {target}.")
# --- migrate verify --------------------------------------------------------
def _git_show(rev: str, relative: str) -> Optional[str]:
result = subprocess.run(
["git", "show", f"{rev}:{relative}"],
cwd=config.ROOT,
capture_output=True,
text=True,
)
return result.stdout if result.returncode == 0 else None
def _paths_at(rev: str) -> Optional[list[str]]:
result = subprocess.run(
["git", "ls-tree", "-r", "--name-only", "-z", rev, "--", "kb"],
cwd=config.ROOT,
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
# The same page/not-a-page rule the working tree is read with. Answering it
# differently on the two sides reported every COLLECTION.md and INDEX.md as
# a page that had since disappeared.
return [
path
for path in result.stdout.split("\0")
if path.startswith("kb/") and kb_scan.is_page_path(path[len("kb/"):])
]
def _shapes_at_revision(rev: str, wanted: set[str]) -> dict[str, corpus_diff.PageShape]:
"""Page shapes as of `rev`, keyed by page title (the wiki's only identity
for a page), not by path - a page that only moved directory between `rev`
and now must compare as itself, not as one revision's delete plus the
other's add. `PageShape.path` still carries the path, for `moved`.
Sorted path order, matching `kb_scan.load_kb_pages`: if two paths share a
stem (a naming collision `lint` already reports as `duplicate_titles`),
the later one wins here too, rather than raising mid-comparison.
"""
import tempfile
shapes: dict[str, corpus_diff.PageShape] = {}
paths = _paths_at(rev)
if paths is None:
fail(f"`git show {rev}` failed - is {rev} a revision in this repository?")
return shapes
with tempfile.TemporaryDirectory() as tmp:
for relative in sorted(paths):
if wanted and not any(relative.startswith(prefix) for prefix in wanted):
continue
text = _git_show(rev, relative)
if text is None:
continue
# read_page owns frontmatter parsing (and its error contract), so the
# historical blob is materialised under its real filename - the stem
# is the page title, which PageShape compares.
scratch = Path(tmp) / Path(relative).name
scratch.write_text(text, encoding="utf-8")
try:
frontmatter, body = read_page(scratch)
except Exception: # noqa: BLE001 - an unparseable historical page is not this tool's error
continue
shape = corpus_diff.PageShape.of(Page(Path(relative), frontmatter, body))
shapes[shape.title] = shape
return shapes
def _shapes_now(wanted: set[str]) -> dict[str, corpus_diff.PageShape]:
shapes: dict[str, corpus_diff.PageShape] = {}
for path in sorted(kb_scan.iter_kb_pages(config.KB_DIR)):
relative = path.relative_to(config.ROOT).as_posix()
if wanted and not any(relative.startswith(prefix) for prefix in wanted):
continue
try:
frontmatter, body = read_page(path)
except Exception: # noqa: BLE001 - lint reports unreadable frontmatter
continue
# The relative path, not `path` itself, so `PageShape.path` is
# comparable to the historical side's - both repo-relative - and a
# `moved` entry names a path rather than this machine's tmp_path.
shape = corpus_diff.PageShape.of(Page(Path(relative), frontmatter, body))
shapes[shape.title] = shape
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=(
"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(
from_rev: str = typer.Option(..., "--from", help="Git revision to compare against, e.g. HEAD"),
path: Optional[list[str]] = typer.Option(
None, "--path", help="Limit to a subtree, repeatable (e.g. kb/concepts)"
),
expect_body_change: bool = typer.Option(
False, "--expect-body-change", help="Also report pages whose body did not change at all"
),
json_out: bool = typer.Option(False, "--json", help="Print the diff as JSON"),
fail_on_error: bool = typer.Option(
False, "--fail-on-error", help="Exit 1 if any invariant changed"
),
):
"""Compare kb/ against a git revision on the invariants a content migration
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
`moved`, and never counted as a finding on its own.
Not migration-specific - worth running after any bulk rewrite. `lint` cannot
answer this: it reads one revision, so a reference that went missing is
invisible to it."""
wanted = {p.rstrip("/") for p in (path or [])}
before = _shapes_at_revision(from_rev, wanted)
after = _shapes_now(wanted)
diff = corpus_diff.compare(before, after, expect_body_change=expect_body_change)
if json_out:
typer.echo(
_json.dumps(
{
"from": from_rev,
"compared": diff.compared,
"added": diff.added,
"removed": diff.removed,
"moved": [
{"page": title, "from": old, "to": new} for title, old, new in diff.moved
],
"findings": [
{"path": f.path, "kind": f.kind, "detail": f.detail} for f in diff.findings
],
},
indent=2,
)
)
else:
typer.echo(corpus_diff.render_report(diff, from_rev))
if diff.findings and fail_on_error:
raise typer.Exit(code=1)