feat: wikitool move - eine kb-Seite folgt ihrem Subtype ins berechnete Verzeichnis (#56)
CI / verify (push) Successful in 58s
Release / release (push) Successful in 35s

Files changed:
- CHANGES.md
- VERSION
- instructions/page-lifecycle.md
- instructions/publish-cycle.md
- tools/CONTRACT.md
- tools/chemenu/cli.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/corpus_diff.py
- tools/chemenu/lint_core.py
- tools/chemenu/tests/test_corpus_diff.py
- tools/chemenu/tests/test_lint.py
- tools/chemenu/tests/test_migrate_cmd.py
- tools/chemenu/tests/test_page_ops.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/type_resolver.py
This commit is contained in:
torben committed 2026-09-04 22:50:22 +02:00
1 parent 8e25a7865f
commit 9e414319b8
18 files changed
+615 -54

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ from chemenu.commands._util import fail, rel_path, success, today_iso
app = typer.Typer(help="Manage wiki/log.md.")
VALID_OPS = ["ingest", "query", "lint", "create", "update", "delete", "rename"]
VALID_OPS = ["ingest", "query", "lint", "create", "update", "delete", "rename", "move"]
# Matches the "## [YYYY-MM-DD] op | title" heading `format_log_entry` writes,
# in file order (oldest first, since entries are appended).
+26 -5
View File
@@ -378,7 +378,15 @@ def _paths_at(rev: str) -> Optional[list[str]]:
def _shapes_at_revision(rev: str, wanted: set[str]) -> dict[str, corpus_diff.PageShape]:
"""Page shapes as of `rev`, keyed by repo-relative path."""
"""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] = {}
@@ -388,7 +396,7 @@ def _shapes_at_revision(rev: str, wanted: set[str]) -> dict[str, corpus_diff.Pag
return shapes
with tempfile.TemporaryDirectory() as tmp:
for relative in paths:
for relative in sorted(paths):
if wanted and not any(relative.startswith(prefix) for prefix in wanted):
continue
text = _git_show(rev, relative)
@@ -403,13 +411,14 @@ def _shapes_at_revision(rev: str, wanted: set[str]) -> dict[str, corpus_diff.Pag
frontmatter, body = read_page(scratch)
except Exception: # noqa: BLE001 - an unparseable historical page is not this tool's error
continue
shapes[relative] = corpus_diff.PageShape.of(Page(Path(relative), frontmatter, body))
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 kb_scan.iter_kb_pages(config.KB_DIR):
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
@@ -417,7 +426,11 @@ def _shapes_now(wanted: set[str]) -> dict[str, corpus_diff.PageShape]:
frontmatter, body = read_page(path)
except Exception: # noqa: BLE001 - lint reports unreadable frontmatter
continue
shapes[relative] = corpus_diff.PageShape.of(Page(path, frontmatter, body))
# 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
@@ -439,6 +452,11 @@ def verify_command(
must not change: wikilink and citation *counts*, footnote definitions, H1,
and structural frontmatter.
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."""
@@ -455,6 +473,9 @@ def verify_command(
"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
],
+9 -26
View File
@@ -194,46 +194,29 @@ def _apply_template_variables(template: str, variables: Dict[str, Any]) -> str:
def _page_subdir(subtype: Optional[str], type_path: str) -> Optional[str]:
"""Return the subtype-driven subdirectory under a type's `base_dir`, from
the type-spec's own `layout:` frontmatter. Returns None for types with no
`layout:` (flat directory). Falls back to `<subtype>s` for a subtype the
layout doesn't list, matching the previous hand-maintained behavior."""
if subtype is None:
return None
try:
layout = resolver.get_layout(type_path)
except ValueError:
layout = None
if layout is None:
return None
return layout.get(subtype, {}).get("dir", subtype + "s")
the type-spec's own `layout:` frontmatter. Thin wrapper over
`TypeResolver.subtype_dir` - the one place this computation lives, shared
with `move` and `lint`'s misplaced-page finding."""
return resolver.subtype_dir(type_path, subtype)
def _target_dir(type_path: str, frontmatter: Dict[str, Any]) -> Path:
"""Resolve where an instance of this type is written: `<root>/<base_dir>`,
plus a subtype subdirectory when the type declares a `layout:`.
Thin wrapper over `TypeResolver.compute_target_dir` - the one placement
rule, also used by `move` and `lint`'s misplaced-page finding -
converting its `ValueError` into the CLI's normal friendly-failure path.
`base_dir` is resolved against `config.KB_DIR` by default, so tests that
point KB_DIR at a temporary fixture wiki can never write into the real
`kb/`. A type-spec declaring `root: repo` resolves against `config.ROOT`
instead - for artifacts that are agent-directed material rather than
knowledge, and so live outside the knowledge layer."""
base_dir = resolver.get_base_dir(type_path)
if not base_dir:
fail(
f"Type {type_path} declares no `base_dir:` and cannot be "
f"instantiated as a page"
)
try:
root = resolver.get_root(type_path)
return resolver.compute_target_dir(type_path, frontmatter)
except ValueError as exc:
fail(str(exc))
target = (config.ROOT if root == "repo" else config.KB_DIR) / base_dir
subtype_field = resolver.get_subtype_field(type_path)
if subtype_field:
subdir = _page_subdir(frontmatter.get(subtype_field), type_path)
if subdir:
target = target / subdir
return target
def _validate_or_fail(frontmatter: Dict[str, Any], type_path: str, source_dir: Path) -> None:
+98
View File
@@ -28,6 +28,7 @@ import typer
from chemenu import 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
from chemenu.page import Page
from chemenu.kb_scan import load_kb_pages
from chemenu.provenance import (
@@ -378,3 +379,100 @@ def rm_command(
f"Deleted '{page_title}' ({rel_path(target.path)}); de-linked {len(touched)} page(s). "
"Run `wikitool index rebuild` and `wikitool sources rebuild-index` next."
)
def move_command(
page_title: Optional[str] = typer.Option(
None, "--page", help="Exact title of the page to move to its computed location"
),
reconcile: bool = typer.Option(
False, "--reconcile", help="Move every page under wiki/ that is not at its computed location"
),
dry_run: bool = typer.Option(False, "--dry-run", help="List what would move without writing"),
):
"""Move a page (or every misplaced page) to the directory its type-spec
computes for its current frontmatter - `base_dir` + `layout`, the same
rule `new` places a page by when it is first created.
The destination is never chosen by hand: there is no `--to <dir>`. Only
the file moves - no body, no frontmatter field, and the page's title (its
only identity in the wiki) never changes.
"""
if bool(page_title) == bool(reconcile):
fail('Pass exactly one of --page "<Title>" or --reconcile.')
pages = load_kb_pages(config.KB_DIR)
if reconcile:
candidates = find_misplaced(pages)
if not candidates:
success("Nothing to move; every page is already at its computed location.")
return
planned: list[tuple[str, Page, Path, Path]] = []
collisions: list[str] = []
for title, page, target_dir in candidates:
new_path = target_dir / f"{title}.md"
if new_path.exists():
collisions.append(f"{title} (target {rel_path(new_path)} already exists)")
continue
planned.append((title, page, target_dir, new_path))
if dry_run:
for title, page, _target_dir, new_path in planned:
typer.echo(f"[dry-run] would move {rel_path(page.path)} -> {rel_path(new_path)}")
if collisions:
typer.echo("")
typer.echo("Skipped (target already exists) - resolve with `wikitool rename` first:")
for collision in collisions:
typer.echo(f" - {collision}")
typer.echo(f"[dry-run] would move {len(planned)} page(s). No files written.")
return
moved: list[str] = []
failed: list[str] = list(collisions)
for title, page, target_dir, new_path in planned:
try:
target_dir.mkdir(parents=True, exist_ok=True)
page.path.rename(new_path)
except OSError as exc:
failed.append(f"{title} ({exc})")
continue
moved.append(title)
typer.echo(f" moved '{title}' -> {rel_path(new_path)}")
if failed:
fail(
f"Moved {len(moved)}/{len(candidates)} page(s) before a failure: {', '.join(failed)}. "
"Safe to retry - `move --reconcile` only re-moves what is still misplaced."
)
success(f"Moved {len(moved)} page(s). Run `wikitool index rebuild` next.")
return
target = pages.get(page_title)
if target is None:
fail(f"No page titled '{page_title}' found under wiki/.")
type_path = target.frontmatter.get("type")
if not type_path:
fail(f"'{page_title}' has no `type:` field, so no placement can be computed for it.")
try:
target_dir = resolver.compute_target_dir(type_path, target.frontmatter, target.path)
except ValueError as exc:
fail(str(exc))
if target_dir.resolve() == target.path.parent.resolve():
success(f"'{page_title}' is already at its computed location ({rel_path(target.path)}); nothing to move.")
return
new_path = target_dir / f"{page_title}.md"
if new_path.exists():
fail(f"Cannot move '{page_title}': {rel_path(new_path)} already exists.")
if dry_run:
typer.echo(f"[dry-run] would move {rel_path(target.path)} -> {rel_path(new_path)}")
return
target_dir.mkdir(parents=True, exist_ok=True)
target.path.rename(new_path)
success(f"Moved '{page_title}' -> {rel_path(new_path)}. Run `wikitool index rebuild` next.")