18ae28f918
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.
271 lines
10 KiB
Python
271 lines
10 KiB
Python
"""Tests for `wikitool doctor`: a healthy instance reports all OK/WARN and
|
|
never FAIL, and each check independently reports FAIL when its precondition
|
|
is missing."""
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from chemenu import config
|
|
from chemenu.commands import doctor, instructions_cmd
|
|
|
|
|
|
def _git(root: Path, *args: str) -> None:
|
|
subprocess.run(["git", *args], cwd=root, check=True, capture_output=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""A minimal, fully-configured wiki instance: a git repo with identity,
|
|
the required stage contracts, one kb collection, generated files, and a
|
|
published skill."""
|
|
root = tmp_path
|
|
kb = root / "kb"
|
|
for sub in ("entities", "concepts", "sources", "comparisons"):
|
|
(kb / sub).mkdir(parents=True)
|
|
(kb / sub / "COLLECTION.md").write_text(f"# {sub}\n", encoding="utf-8")
|
|
(kb / "index.md").write_text("# Index\n", encoding="utf-8")
|
|
(kb / "log.md").write_text("# Log\n", encoding="utf-8")
|
|
(kb / "provenance.md").write_text("# Provenance\n", encoding="utf-8")
|
|
(kb / "CONTRACT.md").write_text("# kb contract\n", encoding="utf-8")
|
|
(root / "VERSION").write_text("0.1.0\n", encoding="utf-8")
|
|
(root / "USER.md").write_text("# USER.md - Fixture\n", encoding="utf-8")
|
|
(root / "SOUL.md").write_text("# SOUL.md - Fixture\n", encoding="utf-8")
|
|
|
|
for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md",
|
|
"instructions/CONTRACT.md", "types/type-spec.md"):
|
|
path = root / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("# contract\n", encoding="utf-8")
|
|
|
|
instructions = root / "instructions"
|
|
(instructions / "wiki-demo").mkdir(parents=True)
|
|
(instructions / "wiki-demo" / "SKILL.md").write_text(
|
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n", encoding="utf-8"
|
|
)
|
|
|
|
monkeypatch.setattr(config, "ROOT", root)
|
|
monkeypatch.setattr(config, "KB_DIR", kb)
|
|
monkeypatch.setattr(config, "INDEX_FILE", kb / "index.md")
|
|
monkeypatch.setattr(config, "LOG_FILE", kb / "log.md")
|
|
monkeypatch.setattr(config, "PROVENANCE_FILE", kb / "provenance.md")
|
|
monkeypatch.setattr(config, "INSTRUCTIONS_DIR", instructions)
|
|
monkeypatch.setattr(config, "AGENTS_SKILLS_DIR", root / ".agents" / "skills")
|
|
monkeypatch.setattr(config, "CLAUDE_SKILLS_DIR", root / ".claude" / "skills")
|
|
monkeypatch.setenv("WIKI_AUTHOR", "Fixture Author")
|
|
monkeypatch.delenv("WIKITOOL_SESSION_ID", raising=False)
|
|
|
|
_git(root, "init", "-b", "main")
|
|
_git(root, "config", "user.name", "Fixture Author")
|
|
_git(root, "config", "user.email", "fixture@example.com")
|
|
|
|
instructions_cmd.sync(force=False)
|
|
return root
|
|
|
|
|
|
def _status(checks, name):
|
|
return next(c.status for c in checks if c.name == name)
|
|
|
|
|
|
def test_healthy_instance_has_no_fail(instance):
|
|
checks = doctor.run_doctor()
|
|
assert not any(c.status == "FAIL" for c in checks)
|
|
assert _status(checks, "structure") == "OK"
|
|
assert _status(checks, "generated-files") == "OK"
|
|
assert _status(checks, "skills") == "OK"
|
|
assert _status(checks, "author") == "OK"
|
|
assert _status(checks, "git-identity") == "OK"
|
|
|
|
|
|
def test_healthy_instance_warns_on_missing_remote_and_session_id(instance):
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "git-remote") == "WARN"
|
|
assert _status(checks, "session-id") == "WARN"
|
|
|
|
|
|
def test_stack_version_is_reported(instance):
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "stack-version") == "OK"
|
|
detail = next(c.detail for c in checks if c.name == "stack-version")
|
|
assert "0.1.0" in detail and "development tree" in detail
|
|
|
|
|
|
def test_a_stamped_instance_reports_itself_as_a_distribution(instance):
|
|
(config.ROOT / ".wikitool-release.json").write_text(
|
|
'{"version": "0.1.0", "exported_at": "2026-08-29"}', encoding="utf-8"
|
|
)
|
|
detail = next(c.detail for c in doctor.run_doctor() if c.name == "stack-version")
|
|
assert "distribution" in detail and "2026-08-29" in detail
|
|
|
|
|
|
def test_a_missing_version_warns_rather_than_fails(instance):
|
|
"""Instances exported before the stack was versioned are still perfectly
|
|
functional - they just cannot answer `version check`."""
|
|
(config.ROOT / "VERSION").unlink()
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "stack-version") == "WARN"
|
|
assert not any(c.status == "FAIL" for c in checks)
|
|
|
|
|
|
def test_a_malformed_version_fails(instance):
|
|
(config.ROOT / "VERSION").write_text("v1\n", encoding="utf-8")
|
|
assert _status(doctor.run_doctor(), "stack-version") == "FAIL"
|
|
|
|
|
|
def test_kb_version_warns_when_the_content_is_undeclared(instance):
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "kb-version") == "WARN"
|
|
assert not any(c.status == "FAIL" for c in checks)
|
|
|
|
|
|
def test_kb_version_is_ok_when_it_matches_the_machinery(instance):
|
|
(config.ROOT / ".wikitool-kb.json").write_text(
|
|
'{"schema": 1, "kb_version": "0.1.0", "applied": []}', encoding="utf-8"
|
|
)
|
|
assert _status(doctor.run_doctor(), "kb-version") == "OK"
|
|
|
|
|
|
def test_kb_version_warns_while_a_migration_is_outstanding(instance):
|
|
"""The normal, transient state in the middle of an upgrade - a WARN that
|
|
names the chain, not a fault."""
|
|
(config.ROOT / "VERSION").write_text("2.0.0\n", encoding="utf-8")
|
|
(config.ROOT / ".wikitool-kb.json").write_text(
|
|
'{"schema": 1, "kb_version": "1.0.0", "applied": []}', encoding="utf-8"
|
|
)
|
|
migrations = config.INSTRUCTIONS_DIR / "migrations"
|
|
migrations.mkdir(parents=True, exist_ok=True)
|
|
(migrations / "2.0.0-retype.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
|
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "kb-version") == "WARN"
|
|
assert "outstanding" in next(c.detail for c in checks if c.name == "kb-version")
|
|
assert not any(c.status == "FAIL" for c in checks)
|
|
|
|
|
|
def test_an_unreadable_kb_state_fails(instance):
|
|
(config.ROOT / ".wikitool-kb.json").write_text("{broken", encoding="utf-8")
|
|
assert _status(doctor.run_doctor(), "kb-version") == "FAIL"
|
|
|
|
|
|
def test_no_collections_at_all_fails_structure(instance):
|
|
"""Removing one collection is a legitimate state - collections are
|
|
discovered by COLLECTION.md presence, not a fixed list (kb/CONTRACT.md).
|
|
Having none at all means the kb layer never got its Areas populated."""
|
|
import shutil
|
|
|
|
for sub in ("entities", "concepts", "sources", "comparisons"):
|
|
shutil.rmtree(config.KB_DIR / sub)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "structure") == "FAIL"
|
|
|
|
|
|
def test_missing_stage_contract_fails_structure(instance):
|
|
(config.ROOT / "raw" / "CONTRACT.md").unlink()
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "structure") == "FAIL"
|
|
|
|
|
|
def test_personalization_is_ok_when_both_files_are_filled(instance):
|
|
assert _status(doctor.run_doctor(), "personalization") == "OK"
|
|
|
|
|
|
def test_missing_personalization_fails(instance):
|
|
"""`USER.md`/`SOUL.md` are read every session, so an instance without them
|
|
runs a generic agent against a wiki built for one person."""
|
|
(config.ROOT / "SOUL.md").unlink()
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "personalization") == "FAIL"
|
|
assert "SOUL.md" in next(c.detail for c in checks if c.name == "personalization")
|
|
|
|
|
|
def test_a_renamed_but_unfilled_template_fails(instance):
|
|
"""The failure mode a plain existence check would miss: the file is
|
|
present and answers nothing."""
|
|
(config.ROOT / "USER.md").write_text(
|
|
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# USER.md - <Name>\n", encoding="utf-8"
|
|
)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "personalization") == "FAIL"
|
|
assert "template" in next(c.detail for c in checks if c.name == "personalization")
|
|
|
|
|
|
def test_environment_is_ok_when_absent(instance):
|
|
"""The file is optional, so absence is a healthy end state - a FAIL here
|
|
would make it mandatory through the back door."""
|
|
assert not (config.ROOT / config.ENVIRONMENT_FILE).exists()
|
|
assert _status(doctor.run_doctor(), "environment") == "OK"
|
|
|
|
|
|
def test_environment_is_ok_when_filled(instance):
|
|
(config.ROOT / config.ENVIRONMENT_FILE).write_text(
|
|
"# ENVIRONMENT.md - Fixture\n\n- Harness: none\n", encoding="utf-8"
|
|
)
|
|
assert _status(doctor.run_doctor(), "environment") == "OK"
|
|
|
|
|
|
def test_a_renamed_but_unfilled_environment_template_warns(instance):
|
|
"""Present, loaded into every session, and answering nothing - worse than
|
|
absent, which is at least honest. A WARN, not a FAIL: the fix may well be
|
|
to delete the file again."""
|
|
(config.ROOT / config.ENVIRONMENT_FILE).write_text(
|
|
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# ENVIRONMENT.md - <Instanz>\n", encoding="utf-8"
|
|
)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "environment") == "WARN"
|
|
assert "template" in next(c.detail for c in checks if c.name == "environment")
|
|
|
|
|
|
def test_missing_generated_file_fails(instance):
|
|
config.LOG_FILE.unlink()
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "generated-files") == "FAIL"
|
|
|
|
|
|
def test_unpublished_skills_fail(instance):
|
|
import shutil
|
|
|
|
shutil.rmtree(config.AGENTS_SKILLS_DIR)
|
|
shutil.rmtree(config.CLAUDE_SKILLS_DIR)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "skills") == "FAIL"
|
|
|
|
|
|
def test_no_author_fails(instance, monkeypatch):
|
|
monkeypatch.delenv("WIKI_AUTHOR", raising=False)
|
|
monkeypatch.setattr(config, "default_author", lambda: None)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "author") == "FAIL"
|
|
|
|
|
|
def test_not_a_git_repo_fails(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "git-repo") == "FAIL"
|
|
|
|
|
|
def test_doctor_command_exits_nonzero_only_on_fail(instance, capsys):
|
|
import typer
|
|
|
|
doctor.doctor_command(json_out=False) # no FAIL - must not raise
|
|
out = capsys.readouterr().out
|
|
assert "OK" in out
|
|
|
|
with pytest.raises(typer.Exit) as excinfo:
|
|
config.LOG_FILE.unlink()
|
|
doctor.doctor_command(json_out=False)
|
|
assert excinfo.value.exit_code == 1
|
|
|
|
|
|
def test_doctor_json_is_machine_readable(instance, capsys):
|
|
import json
|
|
|
|
doctor.doctor_command(json_out=True)
|
|
rows = json.loads(capsys.readouterr().out)
|
|
assert any(row["name"] == "structure" for row in rows)
|
|
assert all({"name", "status", "detail", "fix"} <= row.keys() for row in rows)
|