Chemenu 2.1.0 - deterministischer Wissenskompiler
CI / verify (push) Failing after 32s
Release / release (push) Successful in 38s

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:
2026-09-01 16:24:34 +02:00
commit 18ae28f918
368 changed files with 50628 additions and 0 deletions
+474
View File
@@ -0,0 +1,474 @@
import json
from datetime import date
from chemenu import config
from chemenu.commands.lint import (
has_hard_errors,
lint_command,
render_markdown,
render_summary,
run_lint,
)
from chemenu.frontmatter_io import write_page
from chemenu.provenance import cite_id, render_cite_block
def test_lint_detects_unparsable_frontmatter(kb_dir):
"""A page whose YAML is malformed reads back as `{}` everywhere else, so
without this check it would slip past every frontmatter-driven finding
and only surface as an 'Other / Unclassified' index entry."""
(kb_dir / "entities/tools/broken.md").write_text(
"---\ntype: types/entity.md\ntags: [unclosed\n---\n\n# broken\n",
encoding="utf-8",
)
report = run_lint(kb_dir)
issue = next(i for i in report["frontmatter_errors"] if i["page"] == "broken")
assert "invalid YAML" in issue["error"]
def test_lint_detects_missing_frontmatter_block(kb_dir):
(kb_dir / "concepts/No Frontmatter.md").write_text(
"# No Frontmatter\n\nJust prose.\n", encoding="utf-8"
)
report = run_lint(kb_dir)
issue = next(i for i in report["frontmatter_errors"] if i["page"] == "No Frontmatter")
assert "frontmatter block" in issue["error"]
def test_lint_reports_most_linked_pages(kb_dir):
"""wiki-status reads this instead of re-deriving the link graph."""
report = run_lint(kb_dir)
assert report["inbound_counts"]["aurora"] >= 1
hubs = {entry["page"] for entry in report["most_linked"]}
assert "aurora" in hubs
assert all(entry["inbound"] > 0 for entry in report["most_linked"])
def test_lint_detects_broken_wikilink(kb_dir):
path = kb_dir / "entities/tools/gdeploy.md"
write_page(
path,
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"\n# gdeploy\n\n## Description\n\nSee [[Nonexistent Page]] for details.\n",
)
report = run_lint(kb_dir)
assert {"page": "gdeploy", "target": "Nonexistent Page"} in report["broken_links"]
def test_lint_fixture_has_no_dangling_frontmatter_refs(kb_dir):
assert run_lint(kb_dir)["dangling_frontmatter_refs"] == []
def test_lint_detects_dangling_related_ref(kb_dir):
"""`related:` names a page title, but `broken_links` only walks body
wikilinks - so before this check a rename left the array pointing at
nothing and every lint still came back clean."""
write_page(
kb_dir / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": ["Renamed Away"], "sources": [], "confidence": 0.8},
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
)
report = run_lint(kb_dir)
assert {"page": "gdeploy", "field": "related", "target": "Renamed Away"} in report[
"dangling_frontmatter_refs"
]
def test_lint_detects_url_pasted_into_sources(kb_dir):
"""A URL is not a page title; it belongs in the source page's `source_url:`."""
write_page(
kb_dir / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["https://example.com/x/"],
"confidence": 0.8},
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
)
targets = {i["target"] for i in run_lint(kb_dir)["dangling_frontmatter_refs"]}
assert "https://example.com/x/" in targets
def test_lint_detects_dangling_source_page_entity_ref(kb_dir):
"""Source pages declare `entities:`/`concepts:`, not `related:`/`sources:` -
the field list comes from each type-spec's own `page_ref_fields:`."""
write_page(
kb_dir / "sources/Source - Aurora.md",
{"type": "types/source.md", "source_type": "notes", "author": "Torben",
"source": "raw/notes/Aurora.md", "date": "2026-08-02", "tags": [],
"entities": ["aurora", "ghost-entity"], "concepts": []},
"\n# Source: Aurora\n\n## Summary\n\nNotes.\n",
)
report = run_lint(kb_dir)
assert {
"page": "Source - Aurora", "field": "entities", "target": "ghost-entity"
} in report["dangling_frontmatter_refs"]
def test_lint_ignores_tags_and_raw_files_as_page_refs(kb_dir):
"""`tags` are free-form labels and `raw_files` are filesystem paths; only
`raw_files` has its own check (broken_raw_refs)."""
write_page(
kb_dir / "entities/tools/gdeploy.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": ["not-a-page"],
"created": "2026-07-25", "modified": "2026-07-25", "related": [], "sources": [],
"confidence": 0.8},
"\n# gdeploy\n\n## Description\n\nDeploy tool.\n",
)
targets = {i["target"] for i in run_lint(kb_dir)["dangling_frontmatter_refs"]}
assert "not-a-page" not in targets
def test_lint_detects_orphan_page(kb_dir):
write_page(
kb_dir / "entities/tools/isolated.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"\n# isolated\n\n## Description\n\nNothing links here.\n",
)
report = run_lint(kb_dir)
assert "isolated" in report["orphan_pages"]
# aurora and Nathan link to each other, so they must not be reported as orphans.
assert "aurora" not in report["orphan_pages"]
assert "Nathan" not in report["orphan_pages"]
def test_lint_detects_missing_frontmatter_fields(kb_dir):
"""Missing-field detection now comes solely from the type's schema (its
`required:` list), not a separately hand-maintained REQUIRED_FIELDS dict -
so only fields the schema actually requires (created/modified/provenance/
summary) are flagged, not schema-optional ones like tags/confidence."""
write_page(
kb_dir / "entities/tools/incomplete.md",
{"type": "types/entity.md", "entity_type": "tool"},
"\n# incomplete\n",
)
report = run_lint(kb_dir)
issue = next(i for i in report["schema_validation_errors"] if i["page"] == "incomplete")
assert "created" in issue["error"]
assert "summary" in issue["error"]
def test_lint_detects_duplicate_titles(kb_dir):
write_page(
kb_dir / "concepts/gdeploy.md",
{"type": "types/concept.md", "concept_type": "pattern", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.5},
"\n# gdeploy\n\nDuplicate stem with the tool page.\n",
)
report = run_lint(kb_dir)
entry = next(i for i in report["duplicate_titles"] if i["stem"] == "gdeploy")
assert len(entry["paths"]) == 2
def test_lint_detects_title_mismatch(kb_dir):
write_page(
kb_dir / "entities/tools/mismatched.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"\n# Totally Different Title\n",
)
report = run_lint(kb_dir)
entry = next(i for i in report["title_mismatches"] if i["page"] == "mismatched")
assert entry["h1"] == "Totally Different Title"
def test_clean_wiki_has_no_hard_errors(kb_dir):
report = run_lint(kb_dir)
assert report["broken_links"] == []
assert report["duplicate_titles"] == []
def test_lint_flags_legacy_citation_marker_as_hard_error(kb_dir):
write_page(
kb_dir / "concepts/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"], "confidence": 0.7},
"\n# Modbus\n\n## Definition\n\nUses port 502 ^[[Source - Aurora]].\n",
)
report = run_lint(kb_dir)
assert {"page": "Modbus", "marker": "^[[Source - Aurora]]"} in report["legacy_citation_markers"]
assert has_hard_errors(report) is True
def test_lint_flags_undefined_footnote_ref_as_hard_error(kb_dir):
write_page(
kb_dir / "concepts/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.7},
"\n# Modbus\n\n## Definition\n\nUses port 502 [^s-ghost].\n",
)
report = run_lint(kb_dir)
assert {"page": "Modbus", "ref": "s-ghost"} in report["undefined_footnote_refs"]
assert has_hard_errors(report) is True
def test_lint_flags_orphan_footnote_def_as_hard_error(kb_dir):
cid = cite_id("Source - Aurora")
block = render_cite_block({cid: ("Source - Aurora", None)})
write_page(
kb_dir / "concepts/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"], "confidence": 0.7},
f"\n# Modbus\n\n## Definition\n\nIndustrial protocol, no citation here.\n\n{block}",
)
report = run_lint(kb_dir)
assert {"page": "Modbus", "id": cid, "source": "Source - Aurora"} in report["orphan_footnote_defs"]
assert has_hard_errors(report) is True
def test_lint_clean_footnote_citation_has_no_hard_errors(kb_dir):
cid = cite_id("Source - Aurora")
block = render_cite_block({cid: ("Source - Aurora", None)})
write_page(
kb_dir / "concepts/Modbus.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": ["Source - Aurora"], "confidence": 0.7},
f"\n# Modbus\n\n## Definition\n\nUses port 502 [^{cid}].\n\n{block}",
)
report = run_lint(kb_dir)
assert report["legacy_citation_markers"] == []
assert report["undefined_footnote_refs"] == []
assert report["orphan_footnote_defs"] == []
def test_lint_detects_quote_limit_violation(kb_dir):
write_page(
kb_dir / "entities/tools/quotey.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"\n# quotey\n\n> First quote\n\n> Second quote\n\n> Third quote\n",
)
report = run_lint(kb_dir)
entry = next(i for i in report["quote_limit_violations"] if i["page"] == "quotey")
assert entry["quote_count"] == 3
# Quote-limit overages are advisory, not a hard error.
assert "quotey" not in [i["page"] for i in report.get("broken_links", [])]
def test_collection_contracts_are_not_scanned_as_pages(kb_dir):
"""COLLECTION.md sits one level below the kb root, where the meta-file guard
does not reach. It carries no page frontmatter, so scanning it would report a
frontmatter error per collection and a duplicate-title collision across them."""
report = run_lint(kb_dir)
assert report["frontmatter_errors"] == []
assert not any(d["stem"] == "COLLECTION" for d in report["duplicate_titles"])
def test_lint_flags_invalid_type_path(kb_dir):
write_page(
kb_dir / "entities/tools/bad-type.md",
{"type": "not-a-path", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"\n# bad-type\n",
)
report = run_lint(kb_dir)
entry = next(i for i in report["invalid_type_paths"] if i["page"] == "bad-type")
assert entry["type"] == "not-a-path"
def test_lint_flags_unresolvable_type_path(kb_dir):
write_page(
kb_dir / "entities/tools/unresolvable-type.md",
{"type": "types/does-not-exist.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"\n# unresolvable-type\n",
)
report = run_lint(kb_dir)
entry = next(i for i in report["type_resolution_errors"] if i["page"] == "unresolvable-type")
assert entry["type"] == "types/does-not-exist.md"
def test_lint_flags_schema_validation_error(kb_dir):
write_page(
kb_dir / "entities/tools/bad-schema.md",
{
"type": "types/entity.md", "entity_type": "not-a-real-entity-type", "tags": [],
"created": "2026-07-25", "modified": "2026-07-25", "related": [], "sources": [],
"confidence": 0.8, "provenance": "general", "summary": "x",
},
"\n# bad-schema\n",
)
report = run_lint(kb_dir)
entry = next(i for i in report["schema_validation_errors"] if i["page"] == "bad-schema")
assert "entity_type" in entry["error"]
def test_render_summary_drops_empty_and_informational_sections(kb_dir):
"""The full report is over 90% "None found." on a healthy corpus, which is
what pushed an agent to page through it with head/tail and then re-run
lint to see another part."""
report = run_lint(kb_dir)
summary = render_summary(report)
assert "None found." not in summary
assert "Most-Linked Pages" not in summary
assert "## Semantic Review (LLM to complete)" in summary
assert len(summary) < len(render_markdown(report))
def test_render_summary_keeps_sections_that_found_something(kb_dir):
write_page(
kb_dir / "entities/tools/dangling.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-07-25",
"modified": "2026-07-25", "related": [], "sources": [], "confidence": 0.8},
"\n# dangling\n\nPoints at [[No Such Page]].\n",
)
summary = render_summary(run_lint(kb_dir))
assert "Broken Wikilinks" in summary
assert "No Such Page" in summary
def test_render_summary_says_so_when_there_is_nothing(kb_dir):
report = run_lint(kb_dir)
for key in report:
if isinstance(report[key], list):
report[key] = []
assert "No structural findings." in render_summary(report)
def test_lint_writes_a_report_and_names_its_path(kb_dir, raw_dir, tmp_path, monkeypatch, capsys):
"""Without a printed path the only way back to the full report is a second
lint call - the double-charge this default exists to remove."""
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "KB_DIR", kb_dir)
monkeypatch.setattr(config, "RAW_DIR", raw_dir)
lint_command(json_out=False, markdown_out=None, full=False, fail_on_error=False)
written = tmp_path / "reports" / f"Lint Report {date.today().isoformat()}.md"
assert written.exists()
assert written.read_text(encoding="utf-8").startswith("---\ntype: types/lint-report.md\n")
out = capsys.readouterr().out
assert "reports/Lint Report" in out
assert "None found." not in out
def test_lint_full_prints_the_whole_report(kb_dir, raw_dir, tmp_path, monkeypatch, capsys):
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "KB_DIR", kb_dir)
monkeypatch.setattr(config, "RAW_DIR", raw_dir)
lint_command(json_out=False, markdown_out=None, full=True, fail_on_error=False)
assert "None found." in capsys.readouterr().out
def test_lint_json_writes_no_report(kb_dir, raw_dir, tmp_path, monkeypatch, capsys):
"""`--json` is a machine-readable dump of the same findings; writing a
second copy to reports/ would be noise the caller never asked for."""
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "KB_DIR", kb_dir)
monkeypatch.setattr(config, "RAW_DIR", raw_dir)
lint_command(json_out=True, markdown_out=None, full=False, fail_on_error=False)
assert not (tmp_path / "reports").exists()
assert json.loads(capsys.readouterr().out)["page_count"] > 0
def test_lint_markdown_option_overrides_the_default_path(kb_dir, raw_dir, tmp_path, monkeypatch):
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "KB_DIR", kb_dir)
monkeypatch.setattr(config, "RAW_DIR", raw_dir)
target = tmp_path / "elsewhere" / "report.md"
lint_command(json_out=False, markdown_out=target, full=False, fail_on_error=False)
assert target.exists()
assert not (tmp_path / "reports").exists()
def test_lint_ignores_citation_syntax_shown_as_code(kb_dir):
"""A page that documents the citation mechanism writes the notation
instead of using it. Before code was masked out, both the backticked
marker and the fenced definition line counted as real references, and
`undefined_footnote_refs` is a hard error - so the wiki could not hold a
page about its own syntax. The only way out was to describe the notation
without writing it, which is invisible to whoever reads the page later."""
write_page(
kb_dir / "concepts/Citation Mechanism.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-08-31",
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
"provenance": "general", "summary": "Notation shown as code, not used."},
"\n# Citation Mechanism\n\n## Definition\n\n"
"`cite add` prints a `[^cite-id]` marker to paste at the fact, and upserts\n"
"its definition into the trailing block:\n\n"
"```markdown\n[^s-beispiel]: [[Source - Beispiel]]\n```\n",
)
report = run_lint(kb_dir)
assert [i for i in report["undefined_footnote_refs"] if i["page"] == "Citation Mechanism"] == []
assert [i for i in report["orphan_footnote_defs"] if i["page"] == "Citation Mechanism"] == []
def test_lint_still_sees_a_real_citation_beside_a_mentioned_one(kb_dir):
"""The masking must not overshoot: a page may cite a source in the same
sentence in which it names the notation."""
cid = cite_id("Source - Aurora")
block = render_cite_block({cid: ("Source - Aurora", None)})
write_page(
kb_dir / "concepts/Mixed Citation.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-08-31",
"modified": "2026-08-31", "related": [], "sources": ["Source - Aurora"], "confidence": 0.8,
"provenance": "sourced", "summary": "A real citation beside a mentioned one."},
f"\n# Mixed Citation\n\n## Definition\n\nThe marker `[^s-mentioned]` is written like "
f"this one [^{cid}].\n\n{block}",
)
report = run_lint(kb_dir)
assert [i for i in report["undefined_footnote_refs"] if i["page"] == "Mixed Citation"] == []
assert [i for i in report["orphan_footnote_defs"] if i["page"] == "Mixed Citation"] == []
assert [i for i in report["citation_frontmatter_drift"] if i["page"] == "Mixed Citation"] == []
def test_lint_ignores_wikilink_examples_in_code(kb_dir):
"""The same blindness, at the second place it mattered: an example
`[[wikilink]]` in a fenced block is notation, not a link, and a link is a
hard error when its target does not exist."""
write_page(
kb_dir / "concepts/Wikilink Syntax.md",
{"type": "types/concept.md", "concept_type": "protocol", "tags": [], "created": "2026-08-31",
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
"provenance": "general", "summary": "Notation shown as code, not used."},
"\n# Wikilink Syntax\n\n## Definition\n\nA link is written `[[Page Title]]`:\n\n"
"```markdown\nSee [[Some Page That Does Not Exist]] for details.\n```\n",
)
report = run_lint(kb_dir)
assert [i for i in report["broken_links"] if i["page"] == "Wikilink Syntax"] == []
def test_lint_counts_a_wrapped_quote_once(kb_dir):
"""The limit is about how much borrowed wording a page carries, which the
line count measured wrong: the same quotation counted 1 written long and 4
wrapped at the width the rest of the repo keeps."""
write_page(
kb_dir / "entities/tools/wrapped.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-08-31",
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
"provenance": "general", "summary": "One wrapped quotation."},
"\n# wrapped\n\n> One quotation, wrapped across four lines,\n> which is how the rest of\n"
"> this repository wraps its prose, and\n> therefore not four quotations.\n",
)
report = run_lint(kb_dir)
assert [i for i in report["quote_limit_violations"] if i["page"] == "wrapped"] == []
def test_lint_counts_separated_quotes_separately(kb_dir):
"""Three blocks, two of them wrapped: the boundary case the line count and
the block count disagree on most."""
write_page(
kb_dir / "entities/tools/blocky.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-08-31",
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
"provenance": "general", "summary": "Three quotations, two wrapped."},
"\n# blocky\n\n> First quote, wrapped\n> over two lines.\n\n> Second quote.\n\n"
"> Third quote, also\n> wrapped.\n",
)
report = run_lint(kb_dir)
entry = next(i for i in report["quote_limit_violations"] if i["page"] == "blocky")
assert entry["quote_count"] == 3
def test_lint_does_not_count_a_shell_prompt_as_a_quote(kb_dir):
"""`>` inside a fenced block is a continuation prompt or redirection, not
a quotation."""
write_page(
kb_dir / "entities/tools/shelly.md",
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-08-31",
"modified": "2026-08-31", "related": [], "sources": [], "confidence": 0.8,
"provenance": "general", "summary": "A shell transcript, not a quotation."},
"\n# shelly\n\n```bash\n> line one\n\n> line two\n\n> line three\n```\n",
)
report = run_lint(kb_dir)
assert [i for i in report["quote_limit_violations"] if i["page"] == "shelly"] == []