Chemenu 2.1.0 - deterministischer Wissenskompiler
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki: raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren Grenzen in Code durchgesetzt sind statt im Prompt. Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44 Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist. Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs. Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte. Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet - siehe NOTICE.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu.commands.index_build import (
|
||||
SHARD_THRESHOLD,
|
||||
_anchor,
|
||||
build_index,
|
||||
plan_index,
|
||||
stale_shards,
|
||||
)
|
||||
from chemenu.frontmatter_io import write_page
|
||||
from chemenu.kb_scan import GENERATED_INDEX, iter_kb_pages
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
|
||||
def _area_title(subtype: str) -> str:
|
||||
"""The display title `index rebuild` will use for an entity subtype.
|
||||
|
||||
Read from the type-spec rather than written out, because these titles follow
|
||||
the KB language: hard-coding them made translating the wiki fail tests that
|
||||
are not about wording at all.
|
||||
"""
|
||||
return resolver.get_layout("types/entity.md")[subtype]["title"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plan(kb_dir: Path):
|
||||
return plan_index(kb_dir)
|
||||
|
||||
|
||||
def _shard(plan: dict, kb_dir: Path, *parts: str) -> str:
|
||||
return plan[kb_dir.joinpath(*parts, GENERATED_INDEX)]
|
||||
|
||||
|
||||
def _section(content: str, heading: str) -> str:
|
||||
"""Text from a heading line up to the next heading of any level."""
|
||||
start = content.index(heading) + len(heading)
|
||||
rest = content[start:]
|
||||
for line in rest.splitlines(keepends=True):
|
||||
if line.startswith("#"):
|
||||
return rest[: rest.index(line)]
|
||||
return rest
|
||||
|
||||
|
||||
# --- the map ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_map_reports_totals_per_collection(kb_dir):
|
||||
content = build_index(kb_dir)
|
||||
assert "**Total Pages:** 5" in content
|
||||
assert "**Entities:** 3" in content
|
||||
assert "**Concepts:** 1" in content
|
||||
assert "**Sources:** 1" in content
|
||||
|
||||
|
||||
def test_map_lists_an_empty_collection_rather_than_hiding_it(kb_dir):
|
||||
"""comparisons/ holds no pages but is a real collection - a reader must
|
||||
still be able to see that it exists."""
|
||||
assert "| `comparisons/` | 0 |" in build_index(kb_dir)
|
||||
|
||||
|
||||
def test_map_lists_every_area_with_its_count(kb_dir):
|
||||
content = build_index(kb_dir)
|
||||
assert f"| {_area_title('system')} | 2 |" in content
|
||||
assert f"| {_area_title('tool')} | 1 |" in content
|
||||
# Areas that exist as directories but hold no pages are simply absent.
|
||||
assert f"| {_area_title('person')} |" not in content
|
||||
|
||||
|
||||
def test_map_carries_no_page_rows(kb_dir):
|
||||
"""The whole point of the map: reading it must not cost one row per page."""
|
||||
content = build_index(kb_dir)
|
||||
assert "[[aurora]]" not in content
|
||||
assert "[[Modbus]]" not in content
|
||||
assert len(content.splitlines()) < 60
|
||||
|
||||
|
||||
def test_map_points_at_search_first(kb_dir):
|
||||
assert "wikitool search" in build_index(kb_dir)
|
||||
|
||||
|
||||
def test_map_links_to_each_collection_shard(kb_dir):
|
||||
content = build_index(kb_dir)
|
||||
assert f"[entities/{GENERATED_INDEX}](entities/{GENERATED_INDEX})" in content
|
||||
|
||||
|
||||
def test_map_deep_links_an_inlined_area_by_anchor(kb_dir):
|
||||
# The anchor is derived from the area's display title, so it follows the KB
|
||||
# language along with it. Both sides of the link are generated in the same
|
||||
# run, so they stay consistent; only a bookmark to an old anchor would break.
|
||||
anchor = _anchor(_area_title("system"))
|
||||
assert f"entities/{GENERATED_INDEX}#{anchor}" in build_index(kb_dir)
|
||||
|
||||
|
||||
# --- collection shards ------------------------------------------------------
|
||||
|
||||
|
||||
def test_collection_shard_holds_the_page_rows(plan, kb_dir):
|
||||
entities = _shard(plan, kb_dir, "entities")
|
||||
assert "[[aurora]]" in entities
|
||||
assert "[[Nathan]]" in entities
|
||||
assert "[[gdeploy]]" in entities
|
||||
|
||||
|
||||
def test_pages_are_grouped_by_area_and_sorted_case_insensitively(plan, kb_dir):
|
||||
systems = _section(_shard(plan, kb_dir, "entities"), f"## {_area_title('system')}")
|
||||
assert systems.index("[[aurora]]") < systems.index("[[Nathan]]")
|
||||
assert "[[gdeploy]]" not in systems
|
||||
|
||||
|
||||
def test_area_titles_come_from_the_entity_type_spec_layout(plan, kb_dir):
|
||||
entities = _shard(plan, kb_dir, "entities")
|
||||
assert f"## {_area_title('system')}" in entities
|
||||
assert f"## {_area_title('tool')}" in entities
|
||||
|
||||
|
||||
def test_summary_prefers_frontmatter_then_falls_back_to_body(plan, kb_dir):
|
||||
entities = _shard(plan, kb_dir, "entities")
|
||||
assert "Server hosting DocStore with ZFS storage" in entities # frontmatter
|
||||
assert "Deploy tool." in entities # first line of ## Description
|
||||
|
||||
|
||||
def test_shards_are_marked_generated(plan, kb_dir):
|
||||
assert all(content.startswith("<!-- Generated by") for content in plan.values())
|
||||
|
||||
|
||||
# --- sharding threshold -----------------------------------------------------
|
||||
|
||||
|
||||
def test_area_over_the_threshold_gets_its_own_shard(kb_dir):
|
||||
for n in range(SHARD_THRESHOLD + 1):
|
||||
write_page(
|
||||
kb_dir / f"entities/tools/tool-{n:03d}.md",
|
||||
{
|
||||
"type": "types/entity.md", "entity_type": "tool",
|
||||
"created": "2026-08-01", "modified": "2026-08-01",
|
||||
"summary": f"Tool {n}",
|
||||
},
|
||||
f"\n# tool-{n:03d}\n",
|
||||
)
|
||||
plan = plan_index(kb_dir)
|
||||
|
||||
tools_shard = kb_dir / "entities" / "tools" / GENERATED_INDEX
|
||||
assert tools_shard in plan
|
||||
assert "[[tool-000]]" in plan[tools_shard]
|
||||
|
||||
# The collection shard links to it instead of inlining the rows again.
|
||||
entities = _shard(plan, kb_dir, "entities")
|
||||
assert "[[tool-000]]" not in entities
|
||||
assert f"tools/{GENERATED_INDEX}" in entities
|
||||
# ...and the map points straight at the area's own shard.
|
||||
assert f"entities/tools/{GENERATED_INDEX}" in plan[kb_dir / "index.md"]
|
||||
|
||||
|
||||
def test_area_at_the_threshold_stays_inlined(kb_dir):
|
||||
for n in range(SHARD_THRESHOLD - 1): # +1 existing tool = exactly threshold
|
||||
write_page(
|
||||
kb_dir / f"entities/tools/tool-{n:03d}.md",
|
||||
{
|
||||
"type": "types/entity.md", "entity_type": "tool",
|
||||
"created": "2026-08-01", "modified": "2026-08-01", "summary": "x",
|
||||
},
|
||||
f"\n# tool-{n:03d}\n",
|
||||
)
|
||||
plan = plan_index(kb_dir)
|
||||
assert kb_dir / "entities" / "tools" / GENERATED_INDEX not in plan
|
||||
assert "[[tool-000]]" in _shard(plan, kb_dir, "entities")
|
||||
|
||||
|
||||
# --- stale shards -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_stale_shard_is_detected(kb_dir, plan):
|
||||
orphan = kb_dir / "entities" / "people" / GENERATED_INDEX
|
||||
orphan.write_text("# leftover\n", encoding="utf-8")
|
||||
assert stale_shards(kb_dir, plan) == [orphan]
|
||||
|
||||
|
||||
def test_planned_shards_are_not_stale(kb_dir, plan):
|
||||
for path, content in plan.items():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
assert stale_shards(kb_dir, plan_index(kb_dir)) == []
|
||||
|
||||
|
||||
# --- interaction with the scanner -------------------------------------------
|
||||
|
||||
|
||||
def test_scanner_ignores_generated_shards_at_any_depth(kb_dir, plan):
|
||||
"""A shard lists every page in its subtree as a wikilink. Counted as a page
|
||||
it would make every page look linked-to and silence the orphan check."""
|
||||
for path, content in plan.items():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
(kb_dir / "entities" / "systems" / GENERATED_INDEX).write_text("# x\n", encoding="utf-8")
|
||||
|
||||
scanned = {p.name for p in iter_kb_pages(kb_dir)}
|
||||
assert GENERATED_INDEX not in scanned
|
||||
assert "COLLECTION.md" not in scanned
|
||||
|
||||
|
||||
def test_rebuild_is_idempotent(kb_dir):
|
||||
assert plan_index(kb_dir) == plan_index(kb_dir)
|
||||
Reference in New Issue
Block a user