nested_pages: Katalogtiefe 1 durchgesetzt, layout:-dir validiert, move raeumt geleerte Verzeichnisse - die drei #57-Seiten hochgezogen (schliesst #57)
CI / verify (push) Successful in 1m0s
Release / release (push) Successful in 37s

Files changed:
- CHANGES.md
- VERSION
- kb/CONTRACT.md
- kb/entities/projects/kfchou/wiki-skills.md
- kb/entities/projects/llm-wiki-skills.md
- kb/entities/projects/vanillaflava/wiki-skills-vanillaflava.md
- kb/entities/projects/wiki-skills-vanillaflava.md
- kb/entities/projects/wiki-skills.md
- kb/entities/projects/yugasun/llm-wiki-skills.md
- kb/log.md
- tools/CONTRACT.md
- tools/chemenu/commands/index_build.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/kb_scan.py
- tools/chemenu/lint_core.py
- tools/chemenu/tests/test_index_build.py
- tools/chemenu/tests/test_kb_scan.py
- tools/chemenu/tests/test_lint.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 23:19:30 +02:00
parent 9e414319b8
commit 251e597c63
18 changed files with 331 additions and 10 deletions
+12 -1
View File
@@ -28,7 +28,7 @@ from chemenu import config
from chemenu.commands._util import rel_path, success
from chemenu.kb_collections import iter_kb_collections
from chemenu.page import Page
from chemenu.kb_scan import GENERATED_INDEX, load_kb_pages
from chemenu.kb_scan import GENERATED_INDEX, find_nested_pages, load_kb_pages
from chemenu.type_resolver import resolver
app = typer.Typer(help="Manage the generated wiki catalog (kb/index.md + per-collection INDEX.md).")
@@ -302,6 +302,17 @@ def index_rebuild(
False, "--dry-run", help="Print what would be written instead of writing it"
),
):
# Reported, not refused (#57 decision): a nested page still gets a catalog
# written for it, just a wrong one (folded into its area, no distinct
# location of its own) - `wikitool lint`'s `nested_pages` is the hard
# finding this warning previews.
for title, page, depth in find_nested_pages(config.KB_DIR, load_kb_pages(config.KB_DIR)):
typer.echo(
f"WARNING: [[{title}]] is {depth} directories below its collection "
f"({rel_path(page.path.parent)}) - the catalog folds it into the area silently. "
"See `wikitool lint`'s Nested Pages finding."
)
plan = plan_index(config.KB_DIR)
stale = stale_shards(config.KB_DIR, plan)
+30 -2
View File
@@ -381,6 +381,24 @@ def rm_command(
)
def _rmdir_if_emptied(directory: Path) -> bool:
"""Remove `directory` if the move that just vacated it left it empty.
Symmetric with the `target_dir.mkdir(parents=True, exist_ok=True)` a move
does on the way in: without this, a hand-nested directory (#57) survives
its own fix, and a directory-shape test would still find it after every
page under it moved out. Never touches `kb/` itself, and only ever a
directory this move just emptied - never a pre-existing empty one it
happens to pass through.
"""
if directory.resolve() == config.KB_DIR.resolve():
return False
if not directory.is_dir() or any(directory.iterdir()):
return False
directory.rmdir()
return True
def move_command(
page_title: Optional[str] = typer.Option(
None, "--page", help="Exact title of the page to move to its computed location"
@@ -431,7 +449,9 @@ def move_command(
moved: list[str] = []
failed: list[str] = list(collisions)
removed_dirs = 0
for title, page, target_dir, new_path in planned:
old_dir = page.path.parent
try:
target_dir.mkdir(parents=True, exist_ok=True)
page.path.rename(new_path)
@@ -440,13 +460,17 @@ def move_command(
continue
moved.append(title)
typer.echo(f" moved '{title}' -> {rel_path(new_path)}")
if _rmdir_if_emptied(old_dir):
removed_dirs += 1
typer.echo(f" removed empty {rel_path(old_dir)}")
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.")
removed_note = f", removed {removed_dirs} emptied dir(s)" if removed_dirs else ""
success(f"Moved {len(moved)} page(s){removed_note}. Run `wikitool index rebuild` next.")
return
target = pages.get(page_title)
@@ -473,6 +497,10 @@ def move_command(
typer.echo(f"[dry-run] would move {rel_path(target.path)} -> {rel_path(new_path)}")
return
old_dir = target.path.parent
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.")
removed_note = ""
if _rmdir_if_emptied(old_dir):
removed_note = f" Removed emptied {rel_path(old_dir)}."
success(f"Moved '{page_title}' -> {rel_path(new_path)}.{removed_note} Run `wikitool index rebuild` next.")
+26
View File
@@ -120,6 +120,32 @@ def count_wikilinks(body: str) -> Counter[str]:
return Counter(m.group(1).strip() for m in WIKILINK_RE.finditer(strip_code_spans(body)))
def find_nested_pages(kb_dir: Path, pages: dict[str, Page]) -> list[tuple[str, Page, int]]:
"""(title, page, depth) for every page sitting more than one directory
below its collection.
`kb/<collection>/<page>.md` and `kb/<collection>/<area>/<page>.md` are the
only two depths `kb/CONTRACT.md` § Collections describes. A third level is
not merely unconventional - it is invisible to the catalog:
`index_build.group_pages` reads exactly `parts[0]`/`parts[1]` and folds
anything past them into the area's table silently (Gitea #57), so a page
down here renders as if it sat directly in the area, under no name of its
own. `depth` is how many directories separate the page from its
collection root (1 = directly in an area, the deepest that is not this
finding).
"""
found = []
for title, page in sorted(pages.items()):
try:
parts = page.path.relative_to(kb_dir).parts
except ValueError:
continue
depth = len(parts) - 2 # collection + filename are always present
if depth > 1:
found.append((title, page, depth))
return found
def build_link_graph(pages: dict[str, Page]) -> dict[str, set[str]]:
"""Map each page title to the set of titles it links to."""
return {title: extract_wikilinks(page.body) for title, page in pages.items()}
+34
View File
@@ -34,6 +34,7 @@ from chemenu.kb_scan import (
WIKILINK_RE,
build_link_graph,
find_duplicate_title_paths,
find_nested_pages,
inbound_links,
load_kb_pages,
)
@@ -123,6 +124,23 @@ def misplaced_pages(pages: dict[str, Page]) -> list[dict]:
]
def nested_pages(kb_dir: Path, pages: dict[str, Page]) -> list[dict]:
"""Report form of `find_nested_pages`: `{"page", "at", "depth"}` per
finding.
Hard rather than advisory, unlike `misplaced_pages` above: a hand-placed
page in the wrong area is still a real page the catalog lists correctly.
A page nested past an area is not - `group_pages` folds it into the area
silently, so the *generated* catalog itself becomes wrong, which is the
thing invariant 1 does not allow. There is also no version this becomes
wrong at (unlike the migration-gated findings below): a nested page was
always going to be misread by the catalog that reads it today."""
return [
{"page": title, "at": _display(page.path.parent), "depth": depth}
for title, page, depth in find_nested_pages(kb_dir, pages)
]
def run_lint(kb_dir: Path) -> dict:
pages = load_kb_pages(kb_dir)
duplicate_titles = find_duplicate_title_paths(kb_dir, config.ROOT)
@@ -187,6 +205,7 @@ def run_lint(kb_dir: Path) -> dict:
title_mismatches.append({"page": title, "h1": h1})
misplaced = misplaced_pages(pages)
nested = nested_pages(kb_dir, pages)
unmarked_provenance = []
for title, page in sorted(pages.items()):
@@ -368,6 +387,7 @@ def run_lint(kb_dir: Path) -> dict:
"title_mismatches": title_mismatches,
"duplicate_titles": duplicate_titles,
"misplaced_pages": misplaced,
"nested_pages": nested,
"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),
@@ -437,6 +457,13 @@ def render_markdown(report: dict) -> str:
lambda i: f"[[{i['page']}]] is at `{i['at']}`, should be under `{i['should_be']}` "
f"- `wikitool move --page \"{i['page']}\"`",
)
_section(
lines, "Nested Pages (more than one directory below their collection)",
report.get("nested_pages", []),
lambda i: f"[[{i['page']}]] is {i['depth']} directories below `kb/` at `{i['at']}` - "
"the catalog folds this into its area silently; `wikitool move --reconcile` fixes it "
"when the page's type resolves to a shallower directory, otherwise move it up by hand",
)
_section(
lines, "Uncovered Raw Files (no source page)", report["uncovered_raw_files"],
lambda i: f"`{i}`",
@@ -593,6 +620,12 @@ def default_report_path(report: dict) -> Path:
# `malformed_edges` and `unbalanced_markers` are hard from the start: neither
# describes an unconverted page, only a broken one.
#
# `nested_pages` is hard from the start too, and for the same reason as
# `malformed_edges`/`unbalanced_markers` rather than `misplaced_pages`'s: it is
# not a hand-placement habit some instances predate, it is a page the
# generated catalog (`index rebuild`) silently mis-describes today, on every
# instance, at every version - see `nested_pages()` above.
#
# One definition, used by `lint --fail-on-error` and by the eval scorecard: if
# the two disagreed, a run could pass its score while lint refused it.
HARD_ERROR_KEYS = (
@@ -600,6 +633,7 @@ HARD_ERROR_KEYS = (
"broken_links",
"dangling_index_entries",
"duplicate_titles",
"nested_pages",
"broken_raw_refs",
"duplicate_raw_file_owners",
"legacy_source_pages",
+23
View File
@@ -6,6 +6,7 @@ from chemenu.commands.index_build import (
SHARD_THRESHOLD,
_anchor,
build_index,
index_rebuild,
plan_index,
stale_shards,
)
@@ -202,3 +203,25 @@ def test_scanner_ignores_generated_shards_at_any_depth(kb_dir, plan):
def test_rebuild_is_idempotent(kb_dir):
assert plan_index(kb_dir) == plan_index(kb_dir)
def test_rebuild_warns_about_a_nested_page_without_failing(kb_dir, capsys):
"""#57: `group_pages` still folds a nested page into its area silently -
that part is unchanged, reporting is the fix - so `index rebuild` warns
rather than refusing, and must not raise."""
write_page(
kb_dir / "entities/projects/someowner/nested-tool.md",
{"type": "types/entity.md", "entity_type": "project", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25",
"related": [], "sources": [], "confidence": 0.8},
"\n# nested-tool\n",
)
index_rebuild(dry_run=False)
out = capsys.readouterr().out
assert "WARNING" in out
assert "nested-tool" in out
def test_rebuild_is_silent_about_a_healthy_tree(kb_dir, capsys):
index_rebuild(dry_run=False)
assert "WARNING" not in capsys.readouterr().out
+20 -1
View File
@@ -1,4 +1,5 @@
from chemenu.kb_scan import iter_kb_pages, load_kb_pages
from chemenu.frontmatter_io import write_page
from chemenu.kb_scan import find_nested_pages, iter_kb_pages, load_kb_pages
def _names(kb_dir):
@@ -43,3 +44,21 @@ def test_pages_are_keyed_by_filename_stem(kb_dir):
pages = load_kb_pages(kb_dir)
assert "aurora" in pages
assert "Source - Aurora" in pages
def test_find_nested_pages_flags_a_page_below_its_area(kb_dir):
write_page(
kb_dir / "entities/projects/someowner/nested-tool.md",
{"type": "types/entity.md", "entity_type": "project", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25",
"related": [], "sources": [], "confidence": 0.8},
"\n# nested-tool\n",
)
pages = load_kb_pages(kb_dir)
found = find_nested_pages(kb_dir, pages)
assert [title for title, _page, _depth in found] == ["nested-tool"]
assert found[0][2] == 2
def test_find_nested_pages_is_silent_for_pages_directly_in_an_area(kb_dir):
assert find_nested_pages(kb_dir, load_kb_pages(kb_dir)) == []
+23
View File
@@ -213,6 +213,29 @@ def test_lint_is_silent_about_correctly_placed_pages(kb_dir):
assert run_lint(kb_dir)["misplaced_pages"] == []
def test_lint_detects_a_nested_page_as_a_hard_error(kb_dir):
"""Gitea #57: a page sitting a level below its area (like the three real
`kb/entities/projects/<owner>/*.md` pages that prompted this) is not just
misplaced - the generated catalog folds it into the area silently, so
this is hard rather than advisory."""
write_page(
kb_dir / "entities/projects/someowner/nested-tool.md",
{"type": "types/entity.md", "entity_type": "project", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"\n# nested-tool\n",
)
report = run_lint(kb_dir)
entry = next(i for i in report["nested_pages"] if i["page"] == "nested-tool")
assert entry["at"].endswith("entities/projects/someowner")
assert entry["depth"] == 2
assert "nested_pages" in HARD_ERROR_KEYS
assert has_hard_errors(report)
def test_lint_is_silent_about_pages_directly_in_an_area(kb_dir):
assert run_lint(kb_dir)["nested_pages"] == []
def test_lint_flags_legacy_citation_marker_as_hard_error(kb_dir):
write_page(
kb_dir / "concepts/Modbus.md",
+31
View File
@@ -377,4 +377,35 @@ 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()
def test_move_removes_the_directory_it_empties(patched_wiki):
"""The real-world case (Gitea #57): a page nested a level below its area
(`entities/projects/<owner>/*.md`) is also misplaced by `find_misplaced` -
its computed directory is `entities/projects`, one level up from where it
sits - so `move` fixes the nesting as a side effect of fixing the
placement, and must not leave the now-empty `<owner>/` directory behind."""
_write_misplaced(patched_wiki, "entities/projects/someowner", "nested-tool", "project")
page_ops.move_command(page_title="nested-tool", reconcile=False, dry_run=False)
assert (patched_wiki / "entities/projects/nested-tool.md").exists()
assert not (patched_wiki / "entities/projects/someowner").exists()
def test_move_does_not_remove_a_directory_that_still_holds_pages(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 (patched_wiki / "entities/systems").is_dir()
assert (patched_wiki / "entities/systems/aurora.md").exists()
def test_move_reconcile_removes_every_directory_it_empties(patched_wiki):
_write_misplaced(patched_wiki, "entities/projects/kfchou", "wiki-skills", "project")
_write_misplaced(
patched_wiki, "entities/projects/vanillaflava", "wiki-skills-vanillaflava", "project"
)
page_ops.move_command(page_title=None, reconcile=True, dry_run=False)
assert (patched_wiki / "entities/projects/wiki-skills.md").exists()
assert (patched_wiki / "entities/projects/wiki-skills-vanillaflava.md").exists()
assert not (patched_wiki / "entities/projects/kfchou").exists()
assert not (patched_wiki / "entities/projects/vanillaflava").exists()
assert not (patched_wiki / "entities/tools/misplaced-tool.md").exists()
+27
View File
@@ -92,6 +92,33 @@ def test_get_layout_is_none_for_types_without_one():
assert resolver.get_layout("types/source.md") is None
def test_get_layout_rejects_a_dir_with_a_path_separator(tmp_path):
"""A `layout:` `dir:` is the one supported way to place a page below its
collection; a value with a separator would nest a page a level past what
`kb/CONTRACT.md` allows, invisibly to the catalog (Gitea #57). Refused at
load time rather than left for `lint`'s `nested_pages` finding to catch
after a page has already been written there."""
from chemenu.type_resolver import TypeResolver
types_dir = tmp_path / "types"
types_dir.mkdir()
(types_dir / "badtype.md").write_text(
"---\n"
"type: types/badtype.md\n"
"name: badtype\n"
"description: broken layout\n"
"base_dir: badthings\n"
"subtype_field: bad_type\n"
"layout:\n"
" owner: {dir: projects/owner, title: Owner}\n"
"---\n\n# badtype\n",
encoding="utf-8",
)
bad_resolver = TypeResolver(repo_root=tmp_path)
with pytest.raises(ValueError, match="single path segment"):
bad_resolver.get_layout("types/badtype.md")
def test_get_base_dir_is_wiki_root_relative():
"""base_dir is deliberately relative to the wiki root (not the repo
root) so callers resolve it against config.KB_DIR, which tests
+19 -2
View File
@@ -316,10 +316,27 @@ class TypeResolver:
Raises:
ValueError: If the type path cannot be resolved (propagated from
`load_type_spec`)
`load_type_spec`), or a declared `dir:` is not a single path
segment.
"""
type_spec = self.load_type_spec(type_path, source_file)
return type_spec['frontmatter'].get('layout')
layout = type_spec['frontmatter'].get('layout')
if layout:
for subtype_value, entry in layout.items():
directory = entry.get('dir')
# A `dir:` with a separator would place a page a level below
# what `kb/CONTRACT.md` § Collections allows (collection/area,
# nothing deeper) through the one supported path - `layout:` -
# rather than by hand. Caught here, once, rather than left for
# `wikitool lint`'s `nested_pages` to find after the fact.
if directory is not None and (
not directory or directory in ('.', '..') or '/' in directory or '\\' in directory
):
raise ValueError(
f"Type {type_path} layout[{subtype_value!r}].dir is {directory!r} - "
"must be a single path segment, not a nested path (see Gitea #57)"
)
return layout
def get_base_dir(self, type_path: str, source_file: Path = None) -> Optional[str]:
"""Return a type-spec's `base_dir:` frontmatter - the directory where