nested_pages: Katalogtiefe 1 durchgesetzt, layout:-dir validiert, move raeumt geleerte Verzeichnisse - die drei #57-Seiten hochgezogen (schliesst #57)
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) == []
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user