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:
2026-09-04 22:50:22 +02:00
parent 8e25a7865f
commit 9e414319b8
18 changed files with 615 additions and 54 deletions
+57 -1
View File
@@ -35,12 +35,13 @@ dev-checkout concern - readable here, never shipped as something to parse.
---
## 4.7.5-beta.1 - 2026-09-04 - status/incoming: menschliche Stubs werden ausgearbeitet, nie so umgesetzt
## 4.8.0-beta.1 - 2026-09-04 - page move: eine kb-Seite folgt ihrem Subtype ins Verzeichnis, das ihr Type-Spec berechnet
**Author:** Torben Nehmer
<!-- wikitool:bumps -->
- status/incoming: menschliche Stubs werden ausgearbeitet, nie so umgesetzt
- page move: eine kb-Seite folgt ihrem Subtype ins Verzeichnis, das ihr Type-Spec berechnet
<!-- /wikitool:bumps -->
Das Label `status/incoming` gibt es seit heute in Gitea: der Mensch legt einen
@@ -95,6 +96,61 @@ Kernpunkt dazu, dass dieses Flag die Vier-Achsen-Pflicht aussetzt.
Offen bleibt die erste tatsächliche Anwendung der Regel: #60 und #61 sind
weiterhin unausgearbeitete Stubs.
**`wikitool move`** (#56): Nichts im Stack bewegte bisher eine Seite über eine
Verzeichnisgrenze — `rename` schreibt laut eigenem Docstring nie über
`target.path.parent` hinaus, und `new_page._target_dir` rechnete die
Platzierung nur beim Anlegen. Ändert sich `entity_type` später, blieb die
Datei still am alten Ort liegen, und nichts prüfte das nach: weder
`lint_core.py` noch `doctor.py` verglichen den Ist-Ort einer Seite mit dem,
was ihr Type-Spec berechnen würde.
Die Platzierungslogik selbst gibt es jetzt genau einmal:
`TypeResolver.compute_target_dir` (plus `TypeResolver.subtype_dir` für den
`layout:`-Teil), `new_page._target_dir` delegiert nur noch dorthin. Darauf
aufbauend zwei neue Stücke:
- `wikitool move --page "<Titel>"` bewegt eine Seite an den berechneten Ort;
`--reconcile` wendet dieselbe Regel auf den ganzen Bestand an und ist
idempotent (ein zweiter Lauf meldet nichts mehr zu tun). Beide Modi fassen
weder Body noch Frontmatter an, und der Titel — die einzige Identität einer
Seite — ändert sich nie, also folgt kein Referenz-Update. Ein bereits
belegtes Ziel (ein alter Stem-Kollisionsrest) wird verweigert statt still
überschrieben. Top-level registriert, wie `rename`/`rm`, nicht unter einem
`page`-Unterbefehl.
- `lint` bekommt einen neuen Befund, **Misplaced Pages**, mit Ist- und
Soll-Pfad. Bewusst nicht in `HARD_ERROR_KEYS`: ein von Hand platzierter
Bestand ist keine kaputte Seite, nur eine, die `move` aufräumen könnte — auf
dieser Instanz sind das aktuell die drei `kb/entities/projects/*/`-Seiten,
die #57 separat behandelt.
Ein Fund unterwegs, der ohne #56 unsichtbar geblieben wäre: `migrate verify`
schlüsselte Seiten über den repo-relativen **Pfad**. Ein reiner Move ergab
„N removed, N added, 0 compared" und lief grün durch — der eine mechanische
Check, für den `instructions/migrate-corpus.md` existiert, hätte bei genau der
Operation nichts geprüft, die dieses Issue einführt. Behoben: `PageShape`
trägt jetzt zusätzlich den Pfad, `_shapes_at_revision`/`_shapes_now`
schlüsseln über den Titel (die einzige Identität einer Seite), und
`CorpusDiff.moved` meldet einen reinen Ortswechsel separat — informativ,
niemals als Finding. Regressionstest deckt drei verschobene Seiten mit
`compared == 3, added == 0, removed == 0` ab.
Bewusst nicht Teil dieses Pakets: die drei realen Seiten aus #57 bleiben
liegen (kein Korpus-Publish hier, nur Stack), und `raw rename` (#16) — das
`git mv`-mit-`mv`-Fallback aus #56s Entwurf war für Rohdateien gedacht;
`rename`/`rm`/`move` bewegen kb-Seiten über ein einfaches `Path.rename`, weil
`publish` ohnehin über `git add -A` staged.
Geändert: `tools/chemenu/type_resolver.py` (`compute_target_dir`,
`subtype_dir`), `tools/chemenu/commands/new_page.py` (delegiert),
`tools/chemenu/commands/page_ops.py` (`move_command`),
`tools/chemenu/lint_core.py` (`find_misplaced`, `misplaced_pages`),
`tools/chemenu/corpus_diff.py` und `tools/chemenu/commands/migrate_cmd.py`
(Titel-Schlüsselung, `moved`), `tools/chemenu/commands/log_append.py` (`--op
move`), `tools/CONTRACT.md`, `instructions/page-lifecycle.md`,
`instructions/publish-cycle.md`. MINOR: rückwärts liest ein älterer Stack eine
verschobene Seite unverändert (Identität ist der Titel, nicht der Ort),
vorwärts reines Überkopieren.
---
## 4.7.4 - 2026-09-04 - bootstrap.md nennt den session-id-WARN nach frischem Bootstrap explizit als erwartet
+1 -1
View File
@@ -1 +1 @@
4.7.5-beta.1
4.8.0-beta.1
+29 -3
View File
@@ -43,6 +43,29 @@ It strips reference-array entries and bare `- [[Title]]` / `- **label:** [[Title
leaves prose mentions and inline citations in place and reports them - those are an editorial
fix afterwards, not a reason to retry the command.
## Move
```bash
tools/wikitool move --page "<Title>" --dry-run # see where it would go first
tools/wikitool move --page "<Title>"
tools/wikitool move --reconcile --dry-run # every misplaced page at once
tools/wikitool move --reconcile
```
Moves the page's file 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 title (the wiki's only identity for a page) never changes, so no
reference anywhere in the wiki needs updating.
`--reconcile` applies the same rule corpus-wide in one call; a second run reports nothing left
to do. `wikitool lint`'s **Misplaced Pages** finding is the advisory this fixes - it is not a
hard error, so an unreconciled corpus is not a broken one, only one `move` would tidy.
A destination that already holds a file with the page's name is refused, not silently
overwritten - that only happens on a pre-existing duplicate-title collision, which `lint`'s
**Duplicate Titles** finding reports separately.
## Drop a single reference
```bash
@@ -55,14 +78,17 @@ hand-edit gets cleared. Idempotent.
## Afterwards
Always close out with [publish-cycle.md](publish-cycle.md), using
`--op rename` or `--op delete`. Then confirm nothing was left dangling:
Always close out with [publish-cycle.md](publish-cycle.md), using `--op rename`, `--op delete`,
or `--op move`. A move changed no reference, so run `wikitool index rebuild` rather than
`sources rebuild-index` - the catalog is built from where a page's file sits, and nothing else
about it moved. Then confirm nothing was left dangling:
```bash
tools/wikitool lint
```
`lint` reports every reference still pointing at nothing.
`lint` reports every reference still pointing at nothing, and every page still not at its
computed location.
## Scope
+1 -1
View File
@@ -30,7 +30,7 @@ consistent.
3. **Append the audit entry** - one per operation:
```bash
tools/wikitool log append --op ingest|query|lint|create|update|delete|rename \
tools/wikitool log append --op ingest|query|lint|create|update|delete|rename|move \
--title "<what>" --body "<outcome>"
```
+4 -2
View File
@@ -40,6 +40,7 @@ tools/wikitool <command> --help
| `touch --page "<Title>" [--summary "..."] [--provenance <v>] [--confidence-base <n>] [--date YYYY-MM-DD] [--set field=value ...] [--add field=value ...] [--remove field=value ...] [--no-date] [--dry-run]` | Update a page's own frontmatter: bump `modified:` and optionally rewrite any field its type declares. `--summary`/`--provenance`/`--confidence-base` 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), `confidence:` (derived - set `--confidence-base`), 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. |
| `rename --from "<Old>" --to "<New>" [--dry-run]` | 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` |
| `rm --page "<Title>" [--yes] [--dry-run]` | Delete a page and mechanically de-link it. 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 |
| `move --page "<Title>"` \| `move --reconcile` `[--dry-run]` | 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 - 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 destination already occupied (a pre-existing duplicate-stem collision) is refused rather than silently skipped |
| `xref add --a "<A>" --b "<B>" --rel <label>` | 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` |
| `xref remove --a "<A>" --b "<B>" [--dry-run]` | 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. |
| `xref link-source --source "Source - X" --entities A,B,C` | Batch-link a source page to every entity/concept it mentions: 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 |
@@ -48,7 +49,7 @@ tools/wikitool <command> --help
| `cite add --page "<Title>" --source "Source - X" [--file <qualifier>] [--dry-run]` | 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 |
| `cite sync [--page "<Title>" \| --all] [--dry-run]` | Reconcile each page's footnotes region against its actual `[^id]` references: 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 |
| `index rebuild [--dry-run]` | Regenerate the catalog from every page's frontmatter: `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 |
| `log append --op ingest\|query\|lint\|create\|update\|delete\|rename --title "..." [--body "..."\|--body-file path]` | Append a formatted entry to `kb/log.md` |
| `log append --op ingest\|query\|lint\|create\|update\|delete\|rename\|move --title "..." [--body "..."\|--body-file path]` | Append a formatted entry to `kb/log.md` |
| `log status` | Read-only: count `ingest` entries logged since the last `lint` entry - the deterministic trigger behind the Maintenance Schedule's "every 10 sources" full-lint cadence |
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, 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), 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 |
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | 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. Results carry kind/summary/confidence so a hit can be judged without opening the page. 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** |
@@ -80,7 +81,7 @@ tools/wikitool <command> --help
| `version release [--title "<...>"] [--dry-run]` | Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry, ending 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. 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` |
| `migrate list [--json]` | List every migration document under `instructions/migrations/`, oldest target first, with its kind and obligation. Read-only and **exempt from the Iteration Budget Gate** |
| `migrate status [--json]` | Show the migrations this instance still owes, in the order they must run: 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 |
| `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision 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** - 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. 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 |
| `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision 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** - 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 |
| `migrate done <version> [--pages N] [--dry-run]` | Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json` to its target. **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 |
| `migrate baseline <version> [--force]` | Declare `kb_version` once, for an instance predating `.wikitool-kb.json`. 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 |
| `upstream merge [--remote upstream] [--branch main] [--no-fetch]` | Take a stack update into a private instance's branch, machinery only - 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 |
@@ -173,6 +174,7 @@ is atomic, and whether a retry is safe.
| `touch` | Page not found; an invalid value for a field it writes; a field owned by another command (`type:`, `confidence:`, 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 | Yes - single file write, and every refusal happens before it | 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 |
| `rename` | Neither `--from` nor `--to` is a page, target title already taken, or `--from` equals `--to` | No - one write per referencing page, then the file move | 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 |
| `rm` | Page not found, **or** other pages still reference it and `--yes` was not passed | No - one write per referencing page, then the delete | 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 |
| `move` | 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 | `--page`: yes, a single file move. `--reconcile`: no - one file move per page, each idempotent | 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 |
| `xref add` | Page A or B not found, or a page's type declares no `related:` field | No - writes A then B, but both edits are idempotent, and both refusals happen before either write | 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 |
| `xref remove` | Page A not found (B is allowed not to exist) | No - writes A then B, both idempotent | Safe to retry freely; removing an absent link is a no-op |
| `xref link-source` | Source page not found, an entity in `--entities` doesn't exist, or the source page itself could not be written after its targets were | No - one write per entity plus one for the source page, idempotent per page | Use `--dry-run` first; safe to retry. `sources trace --page "<Title>"` shows who was already linked |
+1
View File
@@ -75,6 +75,7 @@ app.command("new")(new_page.new_page_command)
app.command("touch")(touch_module.touch_command)
app.command("rename")(page_ops.rename_command)
app.command("rm")(page_ops.rm_command)
app.command("move")(page_ops.move_command)
app.command("lint")(lint_module.lint_command)
app.command("search")(search_module.search_command)
app.command("publish")(git_publish.publish_command)
+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.")
+30 -14
View File
@@ -54,6 +54,7 @@ class PageShape:
"""Everything about a page that a content migration must preserve."""
title: str
path: str
h1: Optional[str]
wikilinks: Counter
cite_refs: Counter
@@ -77,6 +78,7 @@ class PageShape:
fields[subtype_field] = page.frontmatter.get(subtype_field)
return cls(
title=page.title,
path=str(page.path),
h1=page.h1_title,
wikilinks=kb_scan.count_wikilinks(head),
cite_refs=Counter(m.group(1) for m in provenance.CITE_REF_RE.finditer(head)),
@@ -109,7 +111,7 @@ def _subtype_field(page: Page) -> Optional[str]:
@dataclass
class PageFinding:
path: str
path: str # the page's title (`compare()`'s key), kept named `path` for JSON stability
kind: str # "h1" | "wikilinks" | "cite-refs" | "cite-defs" | "frontmatter" | "unchanged"
detail: str
@@ -122,6 +124,7 @@ class CorpusDiff:
findings: list[PageFinding] = field(default_factory=list)
added: list[str] = field(default_factory=list)
removed: list[str] = field(default_factory=list)
moved: list[tuple[str, str, str]] = field(default_factory=list)
compared: int = 0
@property
@@ -143,19 +146,19 @@ def _counter_delta(before: Counter, after: Counter) -> str:
return ", ".join(parts)
def compare_page(path: str, before: PageShape, after: PageShape) -> list[PageFinding]:
def compare_page(title: str, before: PageShape, after: PageShape) -> list[PageFinding]:
findings: list[PageFinding] = []
if before.h1 != after.h1:
findings.append(
PageFinding(path, "h1", f"{before.h1!r} -> {after.h1!r} (the title is the page's only identifier)")
PageFinding(title, "h1", f"{before.h1!r} -> {after.h1!r} (the title is the page's only identifier)")
)
if before.wikilinks != after.wikilinks:
findings.append(PageFinding(path, "wikilinks", _counter_delta(before.wikilinks, after.wikilinks)))
findings.append(PageFinding(title, "wikilinks", _counter_delta(before.wikilinks, after.wikilinks)))
if before.cite_refs != after.cite_refs:
findings.append(PageFinding(path, "cite-refs", _counter_delta(before.cite_refs, after.cite_refs)))
findings.append(PageFinding(title, "cite-refs", _counter_delta(before.cite_refs, after.cite_refs)))
if before.cite_defs != after.cite_defs:
changed = []
@@ -163,7 +166,7 @@ def compare_page(path: str, before: PageShape, after: PageShape) -> list[PageFin
was, now = before.cite_defs.get(cite_id), after.cite_defs.get(cite_id)
if was != now:
changed.append(f"[^{cite_id}] {was!r} -> {now!r}")
findings.append(PageFinding(path, "cite-defs", ", ".join(changed)))
findings.append(PageFinding(title, "cite-defs", ", ".join(changed)))
# A generated region that lost or gained a marker is the failure mode the
# delimiters were introduced against, and it is silent: a lost opening
@@ -180,7 +183,7 @@ def compare_page(path: str, before: PageShape, after: PageShape) -> list[PageFin
was, now = before.markers.get(name, 0), after.markers.get(name, 0)
if was != now:
changed_regions.append(f"{name}: {was} -> {now}")
findings.append(PageFinding(path, "markers", ", ".join(changed_regions)))
findings.append(PageFinding(title, "markers", ", ".join(changed_regions)))
changed_fields = []
for name in sorted(set(before.fields) | set(after.fields)):
@@ -188,7 +191,7 @@ def compare_page(path: str, before: PageShape, after: PageShape) -> list[PageFin
if was != now:
changed_fields.append(f"{name}: {was!r} -> {now!r}")
if changed_fields:
findings.append(PageFinding(path, "frontmatter", "; ".join(changed_fields)))
findings.append(PageFinding(title, "frontmatter", "; ".join(changed_fields)))
return findings
@@ -198,7 +201,12 @@ def compare(
after: dict[str, PageShape],
expect_body_change: bool = False,
) -> CorpusDiff:
"""Compare two revisions' page shapes, keyed by repo-relative path.
"""Compare two revisions' page shapes, keyed by page title (the wiki's
only identity for a page - see AGENTS.md invariant 2), not by path. A page
that only changed directory therefore compares as itself rather than as a
removed-and-added pair; its path change is reported separately, in
`moved`, and is never a finding on its own - a migration is allowed to
relocate a page, only not to change what it says.
`expect_body_change` turns the opposite question on: report a page whose
body is byte-identical. A migration unit that reports no such page did
@@ -208,12 +216,14 @@ def compare(
diff.added = sorted(set(after) - set(before))
diff.removed = sorted(set(before) - set(after))
for path in sorted(set(before) & set(after)):
for title in sorted(set(before) & set(after)):
diff.compared += 1
diff.findings.extend(compare_page(path, before[path], after[path]))
if expect_body_change and before[path].body == after[path].body:
diff.findings.extend(compare_page(title, before[title], after[title]))
if before[title].path != after[title].path:
diff.moved.append((title, before[title].path, after[title].path))
if expect_body_change and before[title].body == after[title].body:
diff.findings.append(
PageFinding(path, "unchanged", "body is byte-identical, but this unit claimed to rewrite it")
PageFinding(title, "unchanged", "body is byte-identical, but this unit claimed to rewrite it")
)
return diff
@@ -223,7 +233,7 @@ def render_report(diff: CorpusDiff, from_rev: str) -> str:
lines = [f"# Corpus diff against {from_rev}", ""]
lines.append(
f"{diff.compared} page(s) compared, {len(diff.added)} added, "
f"{len(diff.removed)} removed, {len(diff.findings)} finding(s)."
f"{len(diff.removed)} removed, {len(diff.moved)} moved, {len(diff.findings)} finding(s)."
)
lines.append("")
@@ -243,4 +253,10 @@ def render_report(diff: CorpusDiff, from_rev: str) -> str:
lines += [f"- {path}" for path in paths]
lines.append("")
if diff.moved:
lines.append("## Moved pages")
lines.append("")
lines += [f"- {title}: `{old}` -> `{new}`" for title, old, new in diff.moved]
lines.append("")
return "\n".join(lines)
+61
View File
@@ -27,6 +27,7 @@ from chemenu.provenance import legacy_source_pages as find_legacy_source_pages
from chemenu.provenance import orphan_footnote_defs as find_orphan_footnote_defs
from chemenu.provenance import uncovered_raw_files as find_uncovered_raw_files
from chemenu.provenance import undefined_footnote_refs as find_undefined_footnote_refs
from chemenu.page import Page
from chemenu.version import Version
from chemenu.kb_scan import (
GENERATED_INDEX,
@@ -76,6 +77,52 @@ def count_quote_blocks(body: str) -> int:
return count
def _display(path: Path) -> str:
"""A path for a report line: repo-root-relative if possible, the raw path
otherwise (a fixture tree in a test, or any tree `config.ROOT` does not
contain)."""
try:
return str(path.relative_to(config.ROOT))
except ValueError:
return str(path)
def find_misplaced(pages: dict[str, Page]) -> list[tuple[str, Page, Path]]:
"""(title, page, target_dir) for every page whose type resolves and whose
current directory differs from the one `TypeResolver.compute_target_dir`
would place it under - the same rule `new` places a page by. Shared by
`misplaced_pages()` below and `wikitool move --reconcile`, which is the
fix for what this finds.
A page with no `type:` or an unresolvable one is skipped - each is
reported separately, as `frontmatter_errors`/`type_resolution_errors`."""
found = []
for title, page in sorted(pages.items()):
type_path = page.frontmatter.get("type")
if not type_path:
continue
try:
target_dir = resolver.compute_target_dir(type_path, page.frontmatter, page.path)
except ValueError:
continue
if page.path.parent.resolve() != target_dir.resolve():
found.append((title, page, target_dir))
return found
def misplaced_pages(pages: dict[str, Page]) -> list[dict]:
"""Report form of `find_misplaced`: `{"page", "at", "should_be"}` per
finding, for the lint report.
Advisory rather than a hard error (see `HARD_ERROR_KEYS` below): plenty of
existing instances predate `move`, and a hand-placed page is not a
broken one - only one `wikitool move --page "<Title>"` would relocate."""
return [
{"page": title, "at": _display(page.path.parent), "should_be": _display(target_dir)}
for title, page, target_dir in find_misplaced(pages)
]
def run_lint(kb_dir: Path) -> dict:
pages = load_kb_pages(kb_dir)
duplicate_titles = find_duplicate_title_paths(kb_dir, config.ROOT)
@@ -139,6 +186,8 @@ def run_lint(kb_dir: Path) -> dict:
if h1 is not None and h1 != title:
title_mismatches.append({"page": title, "h1": h1})
misplaced = misplaced_pages(pages)
unmarked_provenance = []
for title, page in sorted(pages.items()):
if page.kind not in ("entity", "concept"):
@@ -318,6 +367,7 @@ def run_lint(kb_dir: Path) -> dict:
"dangling_index_entries": dangling_index_entries,
"title_mismatches": title_mismatches,
"duplicate_titles": duplicate_titles,
"misplaced_pages": misplaced,
"uncovered_raw_files": find_uncovered_raw_files(config.RAW_DIR, pages),
"broken_raw_refs": find_broken_raw_refs(pages),
"duplicate_raw_file_owners": find_duplicate_raw_file_owners(pages),
@@ -381,6 +431,12 @@ def render_markdown(report: dict) -> str:
lines, "Filename / H1 Title Mismatches", report["title_mismatches"],
lambda i: f"[[{i['page']}]] H1 is '{i['h1']}'",
)
_section(
lines, "Misplaced Pages (not under their type-spec's computed directory)",
report.get("misplaced_pages", []),
lambda i: f"[[{i['page']}]] is at `{i['at']}`, should be under `{i['should_be']}` "
f"- `wikitool move --page \"{i['page']}\"`",
)
_section(
lines, "Uncovered Raw Files (no source page)", report["uncovered_raw_files"],
lambda i: f"`{i}`",
@@ -529,6 +585,11 @@ def default_report_path(report: dict) -> Path:
# is no version at which the redundancy becomes an error, only a sweep someone
# does or does not get to.
#
# `misplaced_pages` is the same shape again: it arrived long after most
# instances' corpora were hand-placed, a hand-placed page is not a broken one,
# and there is no version at which "not under the computed directory" becomes
# wrong - only `wikitool move` someone does or does not get to run.
#
# `malformed_edges` and `unbalanced_markers` are hard from the start: neither
# describes an unconverted page, only a broken one.
#
+30
View File
@@ -147,3 +147,33 @@ def test_report_renders_a_clean_run_explicitly():
report = corpus_diff.render_report(corpus_diff.compare(*shapes(BODY, BODY)), "HEAD")
assert "1 page(s) compared" in report
assert "No invariant changed" in report
def test_a_page_that_only_moved_directory_compares_as_itself():
"""A move is not a rewrite: same title, same body, different path. It must
show up as `moved`, never as a removed-and-added pair, and never as a
finding on its own."""
before = Page(Path("kb/entities/systems/Aurora.md"), page(BODY).frontmatter, BODY)
after = Page(Path("kb/entities/technologies/Aurora.md"), page(BODY).frontmatter, BODY)
diff = corpus_diff.compare(
{"Aurora": corpus_diff.PageShape.of(before)},
{"Aurora": corpus_diff.PageShape.of(after)},
)
assert diff.added == []
assert diff.removed == []
assert diff.compared == 1
assert diff.findings == []
assert diff.moved == [("Aurora", "kb/entities/systems/Aurora.md", "kb/entities/technologies/Aurora.md")]
def test_moved_pages_render_in_the_report_without_being_a_finding():
before = Page(Path("kb/entities/systems/Aurora.md"), page(BODY).frontmatter, BODY)
after = Page(Path("kb/entities/technologies/Aurora.md"), page(BODY).frontmatter, BODY)
diff = corpus_diff.compare(
{"Aurora": corpus_diff.PageShape.of(before)},
{"Aurora": corpus_diff.PageShape.of(after)},
)
report = corpus_diff.render_report(diff, "HEAD")
assert "1 moved" in report
assert "## Moved pages" in report
assert "No invariant changed" in report
+29
View File
@@ -184,6 +184,35 @@ def test_clean_wiki_has_no_hard_errors(kb_dir):
assert report["duplicate_titles"] == []
def test_lint_detects_a_misplaced_page(kb_dir):
write_page(
kb_dir / "entities/systems/misplaced-tool.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"\n# misplaced-tool\n",
)
report = run_lint(kb_dir)
entry = next(i for i in report["misplaced_pages"] if i["page"] == "misplaced-tool")
assert entry["at"].endswith("entities/systems")
assert entry["should_be"].endswith("entities/tools")
# Advisory, not a hard error - a corpus whose only finding is this one
# must stay green (see test_a_lint_run_with_only_a_misplaced_page_is_green).
assert "misplaced_pages" not in HARD_ERROR_KEYS
def test_a_report_with_only_a_misplaced_page_stays_green():
report = {key: [] for key in HARD_ERROR_KEYS}
report["misplaced_pages"] = [{"page": "x", "at": "a", "should_be": "b"}]
assert not has_hard_errors(report)
def test_lint_is_silent_about_correctly_placed_pages(kb_dir):
"""The fixture wiki's own pages (aurora under entities/systems/,
gdeploy under entities/tools/, ...) are all at their computed
location - the check must not fire on them."""
assert run_lint(kb_dir)["misplaced_pages"] == []
def test_lint_flags_legacy_citation_marker_as_hard_error(kb_dir):
write_page(
kb_dir / "concepts/Modbus.md",
+57
View File
@@ -280,6 +280,63 @@ def test_verify_does_not_mistake_routing_files_for_removed_pages(git_instance, c
assert result["compared"] == 1
def test_verify_treats_a_moved_page_as_the_same_page(git_instance, capsys):
"""Regression for Gitea #56: before the stem-keying fix, a pure `page
move` reported `1 removed, 1 added, 0 compared` and the one mechanical
check a migration has never actually ran."""
moved_dir = git_instance / "kb" / "entities" / "moved"
moved_dir.mkdir()
src = git_instance / "kb" / "entities" / "Aurora.md"
dst = moved_dir / "Aurora.md"
src.rename(dst)
migrate_cmd.verify_command(
from_rev="HEAD", path=None, expect_body_change=False, json_out=True, fail_on_error=True
)
result = json.loads(capsys.readouterr().out)
assert result["compared"] == 1
assert result["added"] == []
assert result["removed"] == []
assert result["findings"] == []
assert result["moved"] == [
{"page": "Aurora", "from": "kb/entities/Aurora.md", "to": "kb/entities/moved/Aurora.md"}
]
def test_verify_confirms_three_moved_pages_compare_as_themselves(tmp_path, monkeypatch, capsys):
"""Acceptance regression for #56: move 3 pages, `verify` runs with
`compared == 3, added == 0, removed == 0`."""
kb = tmp_path / "kb" / "entities"
kb.mkdir(parents=True)
(tmp_path / "kb" / "entities" / "COLLECTION.md").write_text("# c\n", encoding="utf-8")
for name in ("Alpha", "Beta", "Gamma"):
(kb / f"{name}.md").write_text(PAGE.replace("Aurora", name), encoding="utf-8")
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.name", "T"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.email", "t@e.invalid"], cwd=tmp_path, check=True)
subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True)
subprocess.run(
["git", "commit", "-q", "-m", "seed"], cwd=tmp_path, check=True, capture_output=True
)
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "KB_DIR", tmp_path / "kb")
moved_dir = kb / "moved"
moved_dir.mkdir()
for name in ("Alpha", "Beta", "Gamma"):
(kb / f"{name}.md").rename(moved_dir / f"{name}.md")
migrate_cmd.verify_command(
from_rev="HEAD", path=None, expect_body_change=False, json_out=True, fail_on_error=True
)
result = json.loads(capsys.readouterr().out)
assert result["compared"] == 3
assert result["added"] == []
assert result["removed"] == []
assert {m["page"] for m in result["moved"]} == {"Alpha", "Beta", "Gamma"}
def test_verify_reports_an_unknown_revision(git_instance):
with pytest.raises(typer.Exit):
migrate_cmd.verify_command(
+92
View File
@@ -1,3 +1,5 @@
from pathlib import Path
import pytest
import typer
@@ -286,3 +288,93 @@ def test_inbound_pages_sees_frontmatter_only_references(patched_wiki):
"\n# gdeploy\n\n## Description\n\nNo body link at all.\n",
)
assert "gdeploy" in page_ops.inbound_pages(load_kb_pages(patched_wiki), "Modbus")
# --- move --------------------------------------------------------------
def _write_misplaced(kb: Path, relative: str, title: str, entity_type: str) -> None:
write_page(
kb / relative / f"{title}.md",
{
"type": "types/entity.md", "entity_type": entity_type, "tags": [],
"created": "2026-07-25", "modified": "2026-07-25",
"related": [], "sources": [], "confidence": 0.8,
},
f"\n# {title}\n",
)
def test_move_relocates_a_page_to_its_computed_directory(patched_wiki):
_write_misplaced(patched_wiki, "entities/systems", "misplaced-tool", "tool")
page_ops.move_command(page_title="misplaced-tool", reconcile=False, dry_run=False)
assert not (patched_wiki / "entities/systems/misplaced-tool.md").exists()
assert (patched_wiki / "entities/tools/misplaced-tool.md").exists()
frontmatter, body = read_page(patched_wiki / "entities/tools/misplaced-tool.md")
assert frontmatter["entity_type"] == "tool"
assert body.strip() == "# misplaced-tool"
def test_move_dry_run_writes_nothing(patched_wiki):
_write_misplaced(patched_wiki, "entities/systems", "misplaced-tool", "tool")
page_ops.move_command(page_title="misplaced-tool", reconcile=False, dry_run=True)
assert (patched_wiki / "entities/systems/misplaced-tool.md").exists()
assert not (patched_wiki / "entities/tools/misplaced-tool.md").exists()
def test_move_is_a_noop_when_already_at_its_computed_location(patched_wiki, capsys):
page_ops.move_command(page_title="aurora", reconcile=False, dry_run=False)
assert "already at its computed location" in capsys.readouterr().out
assert (patched_wiki / "entities/systems/aurora.md").exists()
def test_move_rejects_missing_page(patched_wiki):
with pytest.raises(typer.Exit):
page_ops.move_command(page_title="No Such Page", reconcile=False, dry_run=False)
def test_move_requires_exactly_one_of_page_or_reconcile(patched_wiki):
with pytest.raises(typer.Exit):
page_ops.move_command(page_title=None, reconcile=False, dry_run=False)
with pytest.raises(typer.Exit):
page_ops.move_command(page_title="aurora", reconcile=True, dry_run=False)
def test_move_refuses_when_the_destination_already_exists(patched_wiki):
"""A pre-existing duplicate-title situation: two files share the stem
`dup`, so `load_kb_pages` (see its own docstring) surfaces only the one
that sorts last by path - `entities/zzz-wrong/dup.md` - as the tracked
page `dup`. Its computed destination, `entities/tools/dup.md`, is
occupied by the other, untracked half of the collision. `move` must
refuse rather than silently overwrite it."""
_write_misplaced(patched_wiki, "entities/tools", "dup", "tool")
(patched_wiki / "entities/zzz-wrong").mkdir()
_write_misplaced(patched_wiki, "entities/zzz-wrong", "dup", "tool")
with pytest.raises(typer.Exit):
page_ops.move_command(page_title="dup", reconcile=False, dry_run=False)
assert (patched_wiki / "entities/tools/dup.md").exists()
assert (patched_wiki / "entities/zzz-wrong/dup.md").exists()
def test_move_reconcile_moves_every_misplaced_page(patched_wiki, capsys):
_write_misplaced(patched_wiki, "entities/systems", "misplaced-tool", "tool")
_write_misplaced(patched_wiki, "entities/systems", "misplaced-person", "person")
page_ops.move_command(page_title=None, reconcile=True, dry_run=False)
assert (patched_wiki / "entities/tools/misplaced-tool.md").exists()
assert (patched_wiki / "entities/people/misplaced-person.md").exists()
assert not (patched_wiki / "entities/systems/misplaced-tool.md").exists()
assert not (patched_wiki / "entities/systems/misplaced-person.md").exists()
# Already-correct pages are left alone.
assert (patched_wiki / "entities/systems/aurora.md").exists()
capsys.readouterr()
page_ops.move_command(page_title=None, reconcile=True, dry_run=False)
assert "Nothing to move" in capsys.readouterr().out
def test_move_reconcile_dry_run_writes_nothing(patched_wiki):
_write_misplaced(patched_wiki, "entities/systems", "misplaced-tool", "tool")
page_ops.move_command(page_title=None, reconcile=True, dry_run=True)
assert (patched_wiki / "entities/systems/misplaced-tool.md").exists()
assert not (patched_wiki / "entities/tools/misplaced-tool.md").exists()
+41
View File
@@ -1,5 +1,6 @@
import pytest
from chemenu import config
from chemenu.type_resolver import resolver
@@ -191,3 +192,43 @@ def test_validate_frontmatter_reports_wrong_field_type():
},
"types/entity.md",
)
# --- compute_target_dir / subtype_dir: the one placement rule --------------
#
# Shared by `new` (writes here), `move` and `lint`'s misplaced-page
# finding (both check a page already is here) - see AGENTS.md invariant 8.
def test_subtype_dir_reads_layout_from_type_spec():
assert resolver.subtype_dir("types/entity.md", "tool") == "tools"
assert resolver.subtype_dir("types/entity.md", "technology") == "technologies"
def test_subtype_dir_falls_back_for_unmapped_subtype():
assert resolver.subtype_dir("types/entity.md", "gadget") == "gadgets"
def test_subtype_dir_is_none_without_layout_or_subtype():
assert resolver.subtype_dir("types/concept.md", "workflow") is None
assert resolver.subtype_dir("types/entity.md", None) is None
def test_compute_target_dir_applies_layout_subdirectory():
target = resolver.compute_target_dir("types/entity.md", {"entity_type": "tool"})
assert target == config.KB_DIR / "entities" / "tools"
def test_compute_target_dir_is_flat_for_a_type_without_layout():
target = resolver.compute_target_dir("types/concept.md", {"concept_type": "workflow"})
assert target == config.KB_DIR / "concepts"
def test_compute_target_dir_resolves_against_repo_root_for_root_repo_types():
target = resolver.compute_target_dir("types/instruction.md", {})
assert target == config.ROOT / "instructions"
def test_compute_target_dir_rejects_a_type_with_no_base_dir():
with pytest.raises(ValueError, match="base_dir"):
resolver.compute_target_dir("types/type-spec.md", {})
+48
View File
@@ -359,6 +359,54 @@ class TypeResolver:
)
return root
def subtype_dir(self, type_path: str, subtype: Optional[str], source_file: Path = None) -> Optional[str]:
"""The subtype-driven subdirectory `layout:` assigns to `subtype`, or
None for a type with no `layout:` (flat directory) or a `subtype` of
None. A subtype absent from the layout still gets a directory, by
pluralizing its own name (`gadget` -> `gadgets`) - the fallback `new`
used before `layout:` existed, kept so an unlisted subtype value
doesn't refuse placement outright.
Raises:
ValueError: If the type path cannot be resolved.
"""
if subtype is None:
return None
try:
layout = self.get_layout(type_path, source_file)
except ValueError:
layout = None
if layout is None:
return None
return layout.get(subtype, {}).get('dir', subtype + 's')
def compute_target_dir(self, type_path: str, frontmatter: Dict[str, Any], source_file: Path = None) -> Path:
"""Where an instance of this type belongs on disk: `<root>/<base_dir>`,
plus a subtype subdirectory when the type declares `layout:`.
The single placement rule behind `new` (which writes here), `move`
and `lint`'s misplaced-page finding (which check a page already is
here) - one rule, computed once, per AGENTS.md invariant 8.
Raises:
ValueError: The type declares no `base_dir:`, or its type path
cannot be resolved (propagated from the underlying `get_*`
calls).
"""
base_dir = self.get_base_dir(type_path, source_file)
if not base_dir:
raise ValueError(
f"Type {type_path} declares no `base_dir:` and cannot be instantiated as a page"
)
root = self.get_root(type_path, source_file)
target = (config.ROOT if root == 'repo' else config.KB_DIR) / base_dir
subtype_field = self.get_subtype_field(type_path, source_file)
if subtype_field:
subdir = self.subtype_dir(type_path, frontmatter.get(subtype_field), source_file)
if subdir:
target = target / subdir
return target
def get_title_prefix(self, type_path: str, source_file: Path = None) -> str:
"""Return a type-spec's `title_prefix:` frontmatter (e.g. 'Source - '
for source pages), or an empty string if it declares none - always a