d29d400dd3
Files changed: - .gitea/workflows/release.yml - AGENTS.md - CHANGES.md - DEVELOPMENT.md - README.md - VERSION - docs/version-model.md - instructions/dev/version-parts.md - tools/CONTRACT.md - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/commands/version_cmd.py - tools/chemenu/kb_state.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_migrate_cmd.py - tools/chemenu/tests/test_version_cmd.py - tools/chemenu/version.py
368 lines
15 KiB
Python
368 lines
15 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, conventions
|
|
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"---\nprofile: {sub}\nrequired_by_stack: "
|
|
f"{'true' if sub == 'sources' else 'false'}\n---\n\n# {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")
|
|
(kb / "CONVENTIONS.md").write_text(
|
|
"---\nlanguage: en\nprofile: none\nsections:\n"
|
|
" links: Relationships\n footnotes: Footnotes\n"
|
|
"---\n\n# conventions\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_a_running_candidate_is_named_as_such(instance):
|
|
(config.ROOT / "VERSION").write_text("0.2.0-beta.1\n", encoding="utf-8")
|
|
detail = next(c.detail for c in doctor.run_doctor() if c.name == "stack-version")
|
|
assert "0.2.0-beta.1" in detail
|
|
assert "candidate" in detail
|
|
|
|
|
|
def test_kb_version_chain_still_reaches_a_target_matching_a_running_candidate(instance):
|
|
"""Regression for finding #3: comparing the chain against the raw
|
|
candidate would sort `2.0.0` (the migration's target) *before*
|
|
`2.0.0-beta.1` (what is installed), dropping it out of the owed range."""
|
|
(config.ROOT / "VERSION").write_text("2.0.0-beta.1\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")
|
|
|
|
|
|
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_conventions_are_ok_when_declared(instance):
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "conventions") == "OK"
|
|
assert "Relationships" in next(c.detail for c in checks if c.name == "conventions")
|
|
|
|
|
|
def test_missing_conventions_fail(instance):
|
|
"""Unlike `ENVIRONMENT.md`, this one is not optional: `xref add` and
|
|
`cite add` write headings out of it, so an instance without it is being
|
|
answered by whatever the stack hardcoded before the file existed."""
|
|
(config.KB_DIR / conventions.CONVENTIONS_FILENAME).unlink()
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "conventions") == "FAIL"
|
|
assert "missing" in next(c.detail for c in checks if c.name == "conventions")
|
|
|
|
|
|
def test_conventions_with_an_incomplete_sections_block_fail(instance):
|
|
"""Present and deciding nothing - the same failure mode the personalization
|
|
sentinel check exists for, one directory down."""
|
|
(config.KB_DIR / conventions.CONVENTIONS_FILENAME).write_text(
|
|
"---\nlanguage: en\nsections:\n links: Relationships\n---\n", encoding="utf-8"
|
|
)
|
|
conventions.reset_cache()
|
|
assert _status(doctor.run_doctor(), "conventions") == "FAIL"
|
|
|
|
|
|
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 _remotes_detail(checks) -> str:
|
|
return next(c.detail for c in checks if c.name == "publish-remotes")
|
|
|
|
|
|
def test_publish_remotes_says_not_armed_when_the_file_is_absent(instance):
|
|
"""Absence stays OK - a single-remote checkout has nothing to protect - but
|
|
the line has to say the gate is off. AGENTS.md lists it among the limits
|
|
enforced in code, so "no .wikitool-remotes.json" alone leaves a reader
|
|
trusting a safeguard that is not running."""
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "publish-remotes") == "OK"
|
|
assert "not armed" in _remotes_detail(checks)
|
|
|
|
|
|
def test_publish_remotes_says_armed_when_the_file_lists_a_target(instance):
|
|
(config.ROOT / config.PUBLISH_REMOTES_FILENAME).write_text(
|
|
'{ "schema": 1, "allowed_push_urls": ["ssh://git@example.net/one.git"] }',
|
|
encoding="utf-8",
|
|
)
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "publish-remotes") == "OK"
|
|
detail = _remotes_detail(checks)
|
|
assert "armed" in detail and "not armed" not in detail
|
|
|
|
|
|
def test_publish_remotes_warns_on_several_remotes_without_an_allowlist(instance):
|
|
"""The shape a private instance has once it adds the public upstream."""
|
|
_git(config.ROOT, "remote", "add", "origin", "ssh://git@example.net/mine.git")
|
|
_git(config.ROOT, "remote", "add", "upstream", "ssh://git@example.net/theirs.git")
|
|
checks = doctor.run_doctor()
|
|
assert _status(checks, "publish-remotes") == "WARN"
|
|
assert "not armed" in _remotes_detail(checks)
|
|
|
|
|
|
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)
|