54d9540c08
Files changed: - .wikitool-kb.json - AGENTS.md - CHANGES.md - INSTALL-MCP.md - INSTALL.md - README.md - VERSION - instructions/capture-session.md - instructions/dev/issue-tracking.md - instructions/german-terminology.md - instructions/kb-profiles.md - instructions/migrate-corpus.md - instructions/migrations/5.0.0-confidence-removal.md - instructions/private-instance.md - instructions/setup-instance.md - instructions/wiki-lint/SKILL.md - instructions/wiki-manage/SKILL.md - instructions/wiki-query/SKILL.md - kb/CONTRACT.md - kb/CONVENTIONS.md - kb/CONVENTIONS.md.template - kb/concepts/architectures/Consolidation Tiers.md - kb/concepts/architectures/Context Isolation.md - kb/concepts/architectures/Cross-platform Agent Skills.md - kb/concepts/architectures/Episodic Memory.md - kb/concepts/architectures/Hybrid Search.md - kb/concepts/architectures/Implementation Spectrum.md - kb/concepts/architectures/Knowledge Graph.md - kb/concepts/architectures/LLM Wiki Pattern.md - kb/concepts/architectures/MCP-Leseserver.md - kb/concepts/architectures/Memory Lifecycle.md - kb/concepts/architectures/OKF Compatibility.md - kb/concepts/architectures/Optional Instance Context File.md - kb/concepts/architectures/Personalization Plane.md - kb/concepts/architectures/Procedural Memory.md - kb/concepts/architectures/RAG.md - kb/concepts/architectures/Scale Ceiling.md - kb/concepts/architectures/Semantic Memory.md - kb/concepts/architectures/Three-Layer Architecture.md - kb/concepts/architectures/Token Economics.md - kb/concepts/architectures/Working Memory.md - kb/concepts/decisions/Delete Rather Than Anonymize.md - kb/concepts/decisions/Denylist over Allowlist.md - kb/concepts/decisions/Diff-Reviewable Agent Edits.md - kb/concepts/decisions/Dual Licensing by File Plan.md - kb/concepts/decisions/Issue Label Scheme.md - kb/concepts/decisions/KB Stack Versioning.md - kb/concepts/decisions/Structural Enforcement over Documented Rule.md - kb/concepts/patterns/Audit Trail.md - kb/concepts/patterns/BM25.md - kb/concepts/patterns/Command Round-Trip Integrity.md - kb/concepts/patterns/Confidence Scoring.md - kb/concepts/patterns/Contradiction Resolution.md - kb/concepts/patterns/Entity Extraction.md - kb/concepts/patterns/Filter on Ingest.md - kb/concepts/patterns/Forgetting.md - kb/concepts/patterns/Graph Traversal.md - kb/concepts/patterns/Mesh Sync.md - kb/concepts/patterns/Quality Scoring.md - kb/concepts/patterns/Reciprocal Rank Fusion.md - kb/concepts/patterns/Self-Healing.md - kb/concepts/patterns/Shared vs Private.md - kb/concepts/patterns/Typed Relationships.md - kb/concepts/patterns/Vector Search.md - kb/concepts/patterns/Work Coordination.md - kb/concepts/problems/Ambient Environment Dependency.md - kb/concepts/problems/Detect-Repair Asymmetry.md - kb/concepts/problems/Green Suite Blind Spot.md - kb/concepts/problems/Naming Convention Conflict.md - kb/concepts/problems/Write-Once Frontmatter Fields.md - kb/concepts/protocols/CPPC.md - kb/concepts/protocols/Modbus.md - kb/concepts/protocols/SSD TRIM.md - kb/concepts/workflows/Anti-Cramming Heuristic.md - kb/concepts/workflows/Bulk Operations.md - kb/concepts/workflows/CI Integration.md - kb/concepts/workflows/Checkpoint Audit.md - kb/concepts/workflows/Claude Code Auto Mode.md - kb/concepts/workflows/Content Quality Control.md - kb/concepts/workflows/Crystallization.md - kb/concepts/workflows/Event-Driven Automation.md - kb/concepts/workflows/Hooks.md - kb/concepts/workflows/Index Scaling.md - kb/concepts/workflows/Iteration and Cost Limits.md - kb/concepts/workflows/KB Migration.md - kb/concepts/workflows/Knowledge Compounding.md - kb/concepts/workflows/Lint Workflow.md - kb/concepts/workflows/Mass-Update Gate.md - kb/concepts/workflows/Multi-Agent Collaboration.md - kb/concepts/workflows/Privacy and Governance.md - kb/concepts/workflows/Publish-Remote Gate.md - kb/concepts/workflows/Quality and Self-Correction.md - kb/concepts/workflows/Semantic Lint Automation.md - kb/concepts/workflows/Session Orientation.md - kb/concepts/workflows/Split Merge Reclassify.md - kb/concepts/workflows/Split Threshold.md - kb/concepts/workflows/Stub Threshold.md - kb/concepts/workflows/Supersession.md - kb/concepts/workflows/User Management.md - kb/concepts/workflows/Workflow Extraction.md - kb/concepts/workflows/Workflow Orchestration.md - kb/entities/people/Andrej Karpathy.md - kb/entities/people/E3DC GmbH.md - kb/entities/people/Rohit Gupta.md - kb/entities/people/Vannevar Bush.md - kb/entities/projects/BCDModule.md - kb/entities/projects/Chemenu.md - kb/entities/projects/andybalholm-edl.md - kb/entities/projects/goresponsiveness.md - kb/entities/projects/ha-core.md - kb/entities/projects/hacs-e3dc.md - kb/entities/projects/hacs-integration-blueprint.md - kb/entities/projects/llm-wiki-skills.md - kb/entities/projects/plugnburn-edl.md - kb/entities/projects/wiki-skills-vanillaflava.md - kb/entities/projects/wiki-skills.md - kb/entities/systems/AGENTS.md.md - kb/entities/systems/CLAUDE.md.md - kb/entities/systems/E3DC.md - kb/entities/systems/ENVIRONMENT.md.md - kb/entities/systems/Memex.md - kb/entities/systems/Tolkien Gateway.md - kb/entities/technologies/Arch Linux.md - kb/entities/technologies/Disk Encryption.md - kb/entities/technologies/Docker.md - kb/entities/technologies/GRUB.md - kb/entities/technologies/Gitea Actions.md - kb/entities/technologies/Gitea.md - kb/entities/technologies/Go.md - kb/entities/technologies/Home Assistant.md - kb/entities/technologies/Kernel PM Governors.md - kb/entities/technologies/LVM.md - kb/entities/technologies/Linux Kernel.md - kb/entities/technologies/MQTT.md - kb/entities/technologies/OPC UA.md - kb/entities/technologies/Python.md - kb/entities/technologies/Rust.md - kb/entities/technologies/Wine GE.md - kb/entities/technologies/Wine-Staging.md - kb/entities/technologies/acpi-cpufreq.md - kb/entities/technologies/amd-pstate.md - kb/entities/technologies/iii Engine.md - kb/entities/tools/AUR.md - kb/entities/tools/Act Runner.md - kb/entities/tools/Agent Memory.md - kb/entities/tools/Aura.md - kb/entities/tools/Bottles.md - kb/entities/tools/ChatGPT.md - kb/entities/tools/Claude Code.md - kb/entities/tools/Codex CLI.md - kb/entities/tools/Dataview.md - kb/entities/tools/GPG.md - kb/entities/tools/GitHub Copilot.md - kb/entities/tools/Gitea MCP Server.md - kb/entities/tools/Lutris.md - kb/entities/tools/Marp.md - kb/entities/tools/Mistral Vibe.md - kb/entities/tools/NotebookLM.md - kb/entities/tools/Obsidian Web Clipper.md - kb/entities/tools/Obsidian.md - kb/entities/tools/OpenAI Codex.md - kb/entities/tools/OpenCode.md - kb/entities/tools/Pi.md - kb/entities/tools/Proton.md - kb/entities/tools/Steam.md - kb/entities/tools/Wine.md - kb/entities/tools/awesome-llm-wiki.md - kb/entities/tools/farzaa gist.md - kb/entities/tools/gdeploy.md - kb/entities/tools/makepkg.md - kb/entities/tools/pascalandy schema.md - kb/entities/tools/qmd.md - kb/entities/tools/wikitool.md - kb/index.md - kb/log.md - raw/CONTRACT.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/api.py - tools/chemenu/cli.py - tools/chemenu/commands/confidence_decay.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/index_build.py - tools/chemenu/commands/new_page.py - tools/chemenu/commands/search.py - tools/chemenu/commands/touch.py - tools/chemenu/commands/version_cmd.py - tools/chemenu/conventions.py - tools/chemenu/corpus_diff.py - tools/chemenu/frontmatter_io.py - tools/chemenu/lint_core.py - tools/chemenu/mcp/server.py - tools/chemenu/page.py - tools/chemenu/search/base.py - tools/chemenu/search/filters.py - tools/chemenu/search/ripgrep.py - tools/chemenu/search/service.py - tools/chemenu/search/types.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_api.py - tools/chemenu/tests/test_confidence_decay.py - tools/chemenu/tests/test_corpus_diff.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_frontmatter_io.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_mcp_server.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_page_ops.py - tools/chemenu/tests/test_provenance.py - tools/chemenu/tests/test_raw_cmd.py - tools/chemenu/tests/test_search.py - tools/chemenu/tests/test_touch.py - tools/chemenu/tests/test_type_resolver.py - tools/chemenu/tests/test_version_cmd.py - tools/chemenu/tests/test_xref.py - tools/chemenu/version.py - types/concept.md - types/concept.schema.yaml - types/entity.md - types/entity.schema.yaml - types/instruction.md - types/type-spec.md
250 lines
9.1 KiB
Python
250 lines
9.1 KiB
Python
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from chemenu.commands.index_build import (
|
|
SHARD_THRESHOLD,
|
|
_anchor,
|
|
build_index,
|
|
index_rebuild,
|
|
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, type_path: str = "types/entity.md") -> str:
|
|
"""The display title `index rebuild` will use for a subtype's area.
|
|
|
|
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(type_path)[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 "[[Borealis]]" 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("[[Borealis]]")
|
|
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_anchor_keeps_non_ascii_letters():
|
|
"""An area title follows the KB language, so it may carry a letter outside
|
|
`a-z`. Deleting it - which is what the old `[^a-z0-9\\s-]` did - produced a
|
|
map link (`#ablufe`) that pointed at no heading in the shard it named."""
|
|
assert _anchor("Abläufe") == "abläufe"
|
|
assert _anchor("Größere Muster") == "größere-muster"
|
|
# Punctuation is still dropped and any run of whitespace still collapses to
|
|
# a single hyphen.
|
|
assert _anchor("Tools & Utilities (v2)") == "tools-utilities-v2"
|
|
|
|
|
|
def test_area_titles_are_read_from_every_type_spec_not_only_entity(plan, kb_dir):
|
|
"""Gitea #59: the title lookup used to resolve `entity` by name and read
|
|
only its `layout:`, so a second type declaring one got `.title()` on its
|
|
directory name (`Protocols`) instead of the title it declared. The concept
|
|
areas are the first case; nothing about them is special."""
|
|
concepts = _shard(plan, kb_dir, "concepts")
|
|
declared = _area_title("protocol", "types/concept.md")
|
|
assert f"## {declared}" in concepts
|
|
assert "## Protocols" not in concepts
|
|
|
|
|
|
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)
|
|
|
|
|
|
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": []},
|
|
"\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
|