Files
chemenu/tools/chemenu/tests/test_dist_cmd.py
T
torben d29d400dd3
CI / verify (push) Successful in 47s
Release / release (push) Successful in 35s
feat: Versionskandidat statt Bump-pro-Release - VERSION traegt -beta.N, version release fixiert (4.4.0, #42)
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
2026-09-03 22:19:41 +02:00

539 lines
22 KiB
Python

"""Tests for `wikitool dist export`: the allowlist copies exactly the
machinery, marker-delimited dev-only regions are stripped, instructions/dev/
is pruned wholesale, and the command never touches git or writes into a
non-empty target."""
from __future__ import annotations
import hashlib
import json
import stat
from pathlib import Path
import pytest
import typer
from chemenu import config, version as version_mod
from chemenu.commands import dist_cmd
@pytest.fixture
def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A minimal source tree with the same shape as the real repo: markers
in AGENTS.md/README.md, a vendored commonplace/ that must never be
copied, build artifacts under tools/ that must be excluded, and one kb/
collection with a page that must not survive the export."""
root = tmp_path / "source"
root.mkdir()
(root / "AGENTS.md").write_text(
"# AGENTS\n\nCore rules.\n\n"
"<!-- dist:strip-start -->\n"
"## Knowledge base (vendored)\n\ncommonplace/ lives here.\n"
"<!-- dist:strip-end -->\n\n"
"## Changelog\n",
encoding="utf-8",
)
(root / "README.md").write_text(
"# README\n\n```\n└── tools/\n```\n\n"
"<!-- dist:strip-start -->\n```\n└── commonplace/\n```\n<!-- dist:strip-end -->\n",
encoding="utf-8",
)
(root / "EVALS.md").write_text("# EVALS\n", encoding="utf-8")
(root / "CLAUDE.md").write_text("# CLAUDE\n\n@AGENTS.md\n", encoding="utf-8")
(root / ".gitignore").write_text("*.pyc\n", encoding="utf-8")
(root / "VERSION").write_text("0.3.1\n", encoding="utf-8")
for name in config.LICENSE_FILES:
(root / name).write_text(f"# {name}\n", encoding="utf-8")
for name in config.PERSONALIZATION_TEMPLATES:
(root / name).write_text(
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# {name}\n", encoding="utf-8"
)
for name in config.PERSONALIZATION_FILES:
(root / name).write_text(f"# {name} - Torben's own\n", encoding="utf-8")
(root / config.ENVIRONMENT_TEMPLATE).write_text(
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# {config.ENVIRONMENT_TEMPLATE}\n", encoding="utf-8"
)
(root / config.ENVIRONMENT_FILE).write_text(
"# ENVIRONMENT.md - Torben's own laptop\n", encoding="utf-8"
)
(root / "commonplace" / "kb").mkdir(parents=True)
(root / "commonplace" / "kb" / "notes.md").write_text("vendored\n", encoding="utf-8")
instructions = root / "instructions"
instructions.mkdir()
(instructions / "bootstrap.md").write_text("---\nname: bootstrap\n---\n", encoding="utf-8")
dev_dir = instructions / "dev"
dev_dir.mkdir()
(dev_dir / "commonplace-kb.md").write_text("---\nname: commonplace-kb\n---\n", encoding="utf-8")
dev_skill = dev_dir / "stack-dev"
dev_skill.mkdir()
(dev_skill / "SKILL.md").write_text("---\nname: stack-dev\n---\n", encoding="utf-8")
types_dir = root / "types"
types_dir.mkdir()
(types_dir / "entity.schema.yaml").write_text("type: object\n", encoding="utf-8")
# Two real type-specs, one on each side of the `root:` line, so the export's
# split has something to split. `entity` writes into kb/ and is therefore
# the instance's; `instruction` writes into the repo and is the stack's.
(types_dir / "entity.md").write_text(
"---\ntype: types/type-spec.md\nname: entity\ndescription: d\n"
"schema: types/entity.schema.yaml\nbase_dir: entities\n---\n\n# Entity\n",
encoding="utf-8",
)
(types_dir / "instruction.md").write_text(
"---\ntype: types/type-spec.md\nname: instruction\ndescription: d\n"
"schema: null\nbase_dir: instructions\nroot: repo\n---\n\n# Instruction\n",
encoding="utf-8",
)
tools_dir = root / "tools"
(tools_dir / "chemenu").mkdir(parents=True)
wikitool_script = tools_dir / "wikitool"
wikitool_script.write_text("#!/usr/bin/env python3\nprint('hi')\n", encoding="utf-8")
wikitool_script.chmod(wikitool_script.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
(tools_dir / "chemenu" / "config.py").write_text("ROOT = None\n", encoding="utf-8")
(tools_dir / "CONTRACT.md").write_text("# tools contract\n", encoding="utf-8")
venv_dir = tools_dir / ".venv" / "lib"
venv_dir.mkdir(parents=True)
(venv_dir / "some_package.py").write_text("junk\n", encoding="utf-8")
pycache = tools_dir / "chemenu" / "__pycache__"
pycache.mkdir()
(pycache / "config.cpython-312.pyc").write_bytes(b"\x00\x01")
hooks_dir = root / ".github" / "hooks"
hooks_dir.mkdir(parents=True)
(hooks_dir / "wiki-trace.json").write_text("{}\n", encoding="utf-8")
vibe_dir = root / ".vibe"
vibe_dir.mkdir()
(vibe_dir / "hooks.toml").write_text("[[hooks]]\n", encoding="utf-8")
claude_dir = root / ".claude"
claude_dir.mkdir()
(claude_dir / "settings.json").write_text('{"hooks": {}}\n', encoding="utf-8")
(claude_dir / "settings.local.json").write_text('{"personal": true}\n', encoding="utf-8")
(claude_dir / "skills" / "wiki-query").mkdir(parents=True)
(claude_dir / "skills" / "wiki-query" / "SKILL.md").write_text(
"---\nname: wiki-query\n---\n", encoding="utf-8"
)
kb = root / "kb"
(kb / "entities").mkdir(parents=True)
(kb / "entities" / "COLLECTION.md").write_text("# entities collection\n", encoding="utf-8")
(kb / "entities" / "aurora.md").write_text("---\ntype: types/entity.md\n---\n", encoding="utf-8")
(kb / "CONTRACT.md").write_text("# kb contract\n", encoding="utf-8")
(kb / "CONVENTIONS.md").write_text(
"---\nlanguage: de\nsections:\n relationships: Beziehungen\n"
" see_also: Siehe auch\n footnotes: Fußnoten\n---\n\n# this instance\n",
encoding="utf-8",
)
(kb / "CONVENTIONS.md.template").write_text(
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# conventions template\n", encoding="utf-8"
)
docs_dir = root / "docs"
docs_dir.mkdir()
(docs_dir / "why-gates-are-code.md").write_text("# Why gates are code\n", encoding="utf-8")
for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md"):
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"# {relative}\n", encoding="utf-8")
(root / "raw" / "notes").mkdir(parents=True, exist_ok=True)
(root / "raw" / "notes" / "personal-note.md").write_text("private\n", encoding="utf-8")
monkeypatch.setattr(config, "ROOT", root)
monkeypatch.setattr(config, "KB_DIR", kb)
monkeypatch.setattr(config, "INSTRUCTIONS_DIR", instructions)
monkeypatch.setattr(config, "TYPES_DIR", types_dir)
return root
def test_plan_never_includes_commonplace(repo):
plan = dist_cmd.build_plan()
assert not any("commonplace" in relative for relative in plan)
combined = "\n".join(p.content for p in plan.values() if isinstance(p.content, str))
assert "commonplace" not in combined
def test_plan_ships_docs_verbatim(repo):
plan = dist_cmd.build_plan()
assert plan["docs/why-gates-are-code.md"].content == "# Why gates are code\n"
def test_plan_never_includes_instructions_dev(repo):
"""instructions/dev/ - flat dev-only instructions and the nested skill
that switches a session into tool-development mode - is pruned
wholesale, one-way: there is no command that reconstructs it."""
plan = dist_cmd.build_plan()
assert not any(relative.startswith("instructions/dev/") for relative in plan)
assert "instructions/bootstrap.md" in plan # sibling flat instructions still copy
def test_plan_strips_markers_but_keeps_surrounding_content(repo):
plan = dist_cmd.build_plan()
agents = plan["AGENTS.md"].content
assert "dist:strip" not in agents
assert "Core rules." in agents
assert "## Changelog" in agents
assert "Knowledge base (vendored)" not in agents
def test_plan_excludes_venv_and_pycache(repo):
plan = dist_cmd.build_plan()
assert not any(".venv" in relative for relative in plan)
assert not any("__pycache__" in relative for relative in plan)
assert "tools/wikitool" in plan
assert "tools/chemenu/config.py" in plan
def test_plan_includes_hook_configs(repo):
plan = dist_cmd.build_plan()
assert ".github/hooks/wiki-trace.json" in plan
assert ".vibe/hooks.toml" in plan
def test_plan_includes_claude_settings_but_not_skills_or_local_settings(repo):
"""`.claude/` mixes tracked machinery with generated/personal state that
must never ship: only settings.json is a single-file copy, not the whole
directory (which would also sweep in .claude/skills/)."""
plan = dist_cmd.build_plan()
assert ".claude/settings.json" in plan
assert ".claude/settings.local.json" not in plan
assert not any(relative.startswith(".claude/skills/") for relative in plan)
def test_plan_ships_the_personalization_templates_but_not_the_filled_files(repo):
"""`USER.md`/`SOUL.md` are an operating requirement whose *content* belongs
to one person: the templates ship so setup can fill them in, the filled
files never do."""
plan = dist_cmd.build_plan()
for name in config.PERSONALIZATION_TEMPLATES:
assert name in plan
assert config.TEMPLATE_SENTINEL in plan[name].content
for name in config.PERSONALIZATION_FILES:
assert name not in plan
combined = "\n".join(p.content for p in plan.values() if isinstance(p.content, str))
assert "Torben's own" not in combined
def test_plan_excludes_coverage_output(repo):
"""Coverage output lands beside the code (`.coverage`, `coverage.xml`) and
in `htmlcov/`, so a directory prune alone misses two thirds of it - and an
export once carried the whole HTML report into the distribution."""
tools_dir = repo / "tools"
(tools_dir / ".coverage").write_text("binary-ish\n", encoding="utf-8")
(tools_dir / ".coverage.host.4242").write_text("parallel run\n", encoding="utf-8")
(tools_dir / "coverage.xml").write_text("<coverage/>\n", encoding="utf-8")
(tools_dir / "htmlcov").mkdir()
(tools_dir / "htmlcov" / "index.html").write_text("<html/>\n", encoding="utf-8")
(tools_dir / ".coveragerc").write_text("[run]\nsource = chemenu\n", encoding="utf-8")
plan = dist_cmd.build_plan()
assert "tools/.coverage" not in plan
assert "tools/.coverage.host.4242" not in plan
assert "tools/coverage.xml" not in plan
assert not any(relative.startswith("tools/htmlcov/") for relative in plan)
# Configuration is machinery and ships, the way pytest.ini does.
assert "tools/.coveragerc" in plan
def test_plan_ships_the_environment_template_but_not_the_filled_file(repo):
"""Same split as the personalization pair, for the same reason: a
distribution can say what the file is for, never what one checkout's
harness, MCP servers and remotes are."""
plan = dist_cmd.build_plan()
assert config.ENVIRONMENT_TEMPLATE in plan
assert config.TEMPLATE_SENTINEL in plan[config.ENVIRONMENT_TEMPLATE].content
assert config.ENVIRONMENT_FILE not in plan
def test_plan_ships_the_claude_harness_shim(repo):
"""Claude Code loads `CLAUDE.md` and not `AGENTS.md`, so a distributed
instance running that harness would start every session without the
control plane if this were left behind."""
plan = dist_cmd.build_plan()
assert "CLAUDE.md" in plan
assert "@AGENTS.md" in plan["CLAUDE.md"].content
def test_plan_ships_both_licences_and_the_notice(repo):
"""The stack is AGPL and travels into every instance, so the licence text
has to travel with it: an instance holding tools/ without LICENSE is a
violation the moment it is pushed anywhere public."""
plan = dist_cmd.build_plan()
for name in config.LICENSE_FILES:
assert name in plan
def test_export_refuses_a_tree_with_no_licence(repo, tmp_path):
"""Unlike every other ROOT_FILES entry, a missing licence is not a tree
that simply predates the file - it is a broken export, and shipping it
quietly is the failure this check exists to prevent."""
(repo / "LICENSE").unlink()
with pytest.raises(typer.Exit):
dist_cmd.run_export(tmp_path / "out")
def test_find_leaks_is_silent_on_a_clean_plan(repo):
assert dist_cmd.find_leaks(dist_cmd.build_plan()) == []
@pytest.mark.parametrize(
"relative",
[
"USER.md",
"SOUL.md",
"ENVIRONMENT.md",
"instructions/dev/commonplace-kb.md",
"kb/entities/aurora.md",
"raw/notes/personal-note.md",
# Both bind every page and both are the instance's to write, so the
# filled name must never cross - only the `.template` beside it does.
"kb/CONVENTIONS.md",
"kb/entities/COLLECTION.md",
# A page type-spec under its filled name: the instance's, not the
# stack's, so shipping it would hand a new instance this one's
# authoring language as though the stack had decided it.
"types/entity.md",
],
)
def test_find_leaks_catches_one_instance_own_data(repo, relative):
"""Three separate allowlists decide what `build_plan` copies, and each one
holds only because whoever last edited it remembered the rule. This is the
check that notices when one of them stops holding."""
plan = dist_cmd.build_plan()
plan[relative] = dist_cmd.PlannedFile("leaked\n")
assert any(relative in leak for leak in dist_cmd.find_leaks(plan))
def test_export_refuses_a_plan_that_leaks(repo, tmp_path, monkeypatch):
monkeypatch.setattr(
dist_cmd, "find_leaks", lambda plan: ["USER.md (one instance's own personalization)"]
)
target = tmp_path / "out"
with pytest.raises(typer.Exit):
dist_cmd.run_export(target)
assert not target.exists()
def test_plan_ships_collection_contracts_as_templates_not_pages(repo):
"""The stack's own contract crosses verbatim; the instance-owned ones cross
under the template name and are adopted by a rename. Shipping
`kb/entities/COLLECTION.md` would hand a new instance this one's authoring
conventions as though the stack had decided them."""
plan = dist_cmd.build_plan()
assert "kb/entities/COLLECTION.md.template" in plan
assert "kb/entities/COLLECTION.md" not in plan
assert "kb/CONTRACT.md" in plan
assert not any(relative.endswith("aurora.md") for relative in plan)
# The shipped template is the contract's own text - one source of truth in
# the origin repo, renamed across the boundary. A second file kept beside
# each contract would be a near-identical copy, maintained by hand.
assert plan["kb/entities/COLLECTION.md.template"].content == "# entities collection\n"
def test_plan_ships_the_conventions_template_and_not_the_filled_file(repo):
plan = dist_cmd.build_plan()
assert "kb/CONVENTIONS.md.template" in plan
assert "kb/CONVENTIONS.md" not in plan
def test_page_type_specs_ship_as_templates_and_stack_types_do_not(repo, monkeypatch):
"""The `root:` line, applied. A type-spec whose instances are pages under
`kb/` describes what this instance writes, so its prose, template and
language are the instance's; one whose instances are stack artifacts ships
verbatim. The `.schema.yaml` travels with its spec - the two are one type,
and adopting half would leave a spec validated by a file it does not own."""
from chemenu.type_resolver import resolver
monkeypatch.setattr(resolver, "_repo_root", config.ROOT)
plan = dist_cmd.build_plan()
assert "types/entity.md.template" in plan
assert "types/entity.schema.yaml.template" in plan
assert "types/entity.md" not in plan
assert "types/entity.schema.yaml" not in plan
assert "types/instruction.md" in plan
assert "types/instruction.md.template" not in plan
def test_plan_creates_empty_raw_subdirs_not_real_content(repo):
plan = dist_cmd.build_plan()
for sub in ("articles", "documents", "notes", "assets"):
assert f"raw/{sub}/.gitkeep" in plan
assert not any("personal-note" in relative for relative in plan)
def test_plan_seeds_log_and_changes_from_templates(repo):
plan = dist_cmd.build_plan()
assert "Wiki Log" in plan["kb/log.md"].content
assert "## [YYYY-MM-DD]" in plan["kb/log.md"].content # format doc, not a real entry
assert "## [20" not in plan["kb/log.md"].content # no actual dated entries
assert "Changelog" in plan["CHANGES.md"].content
def test_plan_ships_the_version_and_a_stamp_describing_it(repo):
"""A distribution that does not carry its own version cannot answer
`version check` - it has nothing to compare against."""
plan = dist_cmd.build_plan()
assert plan["VERSION"].content.strip() == "0.3.1"
stamp = json.loads(plan[version_mod.RELEASE_STAMP_FILENAME].content)
assert stamp["version"] == "0.3.1"
assert stamp["schema"] == version_mod.STAMP_SCHEMA
assert stamp["exported_at"]
assert stamp["update_url"] == version_mod.DEFAULT_UPDATE_URL
def test_stamp_records_the_origin_the_caller_supplies(repo):
"""`export` never calls git, so the commit and release URL can only come
from the caller - the release workflow, which knows both."""
plan = dist_cmd.build_plan(
dist_cmd.Origin(
source_repo="https://example/torben/wiki",
source_commit="a" * 40,
release_url="https://example/torben/wiki/releases/tag/v0.3.1",
update_url="https://example/api/latest",
)
)
stamp = json.loads(plan[version_mod.RELEASE_STAMP_FILENAME].content)
assert stamp["source_commit"] == "a" * 40
assert stamp["release_url"].endswith("v0.3.1")
assert stamp["update_url"] == "https://example/api/latest"
def test_stamp_digests_every_other_planned_file(repo):
"""The digests are the base a later upgrade compares against: without
them nothing can tell a file the instance edited from one it received."""
plan = dist_cmd.build_plan()
stamp = json.loads(plan[version_mod.RELEASE_STAMP_FILENAME].content)
assert set(stamp["files"]) == set(plan) - {version_mod.RELEASE_STAMP_FILENAME}
expected = hashlib.sha256(plan["AGENTS.md"].content.encode("utf-8")).hexdigest()
assert stamp["files"]["AGENTS.md"] == f"sha256:{expected}"
def test_plan_declares_the_fresh_instance_content_version(repo):
"""A fresh instance's content is empty and therefore trivially in the
current shape - which is what makes declaring it here safe, and what keeps
`migrate baseline` for the one case that really is unknowable."""
from chemenu import kb_state
plan = dist_cmd.build_plan()
state = json.loads(plan[kb_state.KB_STATE_FILENAME].content)
assert state["kb_version"] == "0.3.1"
assert state["applied"] == []
def test_kb_version_is_the_candidates_base_while_the_stamp_stays_honest(repo):
"""Exporting mid-candidate answers two different questions: the stamp says
what was actually exported (suffix included - "an export says what it
is"), the KB version says what shape the content is built for. A content
shape has no beta channel, so it must be the base."""
from chemenu import kb_state
(repo / "VERSION").write_text("0.4.0-beta.2\n", encoding="utf-8")
plan = dist_cmd.build_plan()
stamp = json.loads(plan[version_mod.RELEASE_STAMP_FILENAME].content)
state = json.loads(plan[kb_state.KB_STATE_FILENAME].content)
assert stamp["version"] == "0.4.0-beta.2"
assert plan["VERSION"].content.strip() == "0.4.0-beta.2"
assert state["kb_version"] == "0.4.0"
def test_export_refuses_a_tree_with_no_version(repo, tmp_path):
(repo / "VERSION").unlink()
target = tmp_path / "dist"
with pytest.raises(typer.Exit):
dist_cmd.run_export(target, dry_run=False)
assert not target.exists() or not any(target.iterdir())
def test_export_preserves_the_executable_bit(repo, tmp_path):
target = tmp_path / "dist"
dist_cmd.run_export(target, dry_run=False)
mode = (target / "tools" / "wikitool").stat().st_mode
assert mode & stat.S_IXUSR
def test_dry_run_writes_nothing(repo, tmp_path):
target = tmp_path / "dist"
dist_cmd.run_export(target, dry_run=True)
assert not target.exists() or not any(target.iterdir())
def test_export_refuses_a_nonempty_target(repo, tmp_path):
target = tmp_path / "dist"
target.mkdir()
(target / "existing.txt").write_text("x\n", encoding="utf-8")
with pytest.raises(typer.Exit):
dist_cmd.run_export(target, dry_run=False)
assert list(target.iterdir()) == [target / "existing.txt"]
def test_export_refuses_a_target_that_is_a_file(repo, tmp_path):
target = tmp_path / "dist-file"
target.write_text("x\n", encoding="utf-8")
with pytest.raises(typer.Exit):
dist_cmd.run_export(target, dry_run=False)
def test_export_into_a_fresh_directory_works(repo, tmp_path):
target = tmp_path / "dist"
dist_cmd.run_export(target, dry_run=False)
assert (target / "AGENTS.md").is_file()
assert (target / "kb" / "entities" / "COLLECTION.md.template").is_file()
assert (target / "raw" / "notes" / ".gitkeep").is_file()
def test_unbalanced_markers_fail_loudly(repo):
(repo / "AGENTS.md").write_text(
"# AGENTS\n<!-- dist:strip-start -->\nno end marker\n", encoding="utf-8"
)
with pytest.raises(typer.Exit):
dist_cmd.build_plan()
def test_marker_strings_inside_python_source_are_left_alone(repo, tmp_path):
"""The bug this guards: dist_cmd.py's own source contains the marker
strings as string literals. Marker processing must be scoped to .md
files, or copying tools/ eats its own implementation."""
source_like = repo / "tools" / "chemenu" / "commands"
source_like.mkdir(parents=True)
(source_like / "example.py").write_text(
'START = "<!-- dist:strip-start -->"\nEND = "<!-- dist:strip-end -->"\n'
"def f():\n return 1\n",
encoding="utf-8",
)
plan = dist_cmd.build_plan()
content = plan["tools/chemenu/commands/example.py"].content
assert "END = " in content
assert "return 1" in content
def test_strip_markers_removes_multiple_regions():
"""Markers sit as their own paragraph (blank line on each side) -
stripping must collapse that back to a single blank line, not leave two."""
text = (
"a\n\n<!-- dist:strip-start -->x<!-- dist:strip-end -->\n\n"
"b\n\n<!-- dist:strip-start -->y<!-- dist:strip-end -->\n\nc"
)
assert dist_cmd.strip_markers(text) == "a\n\nb\n\nc"
def test_validate_markers_rejects_end_before_start():
with pytest.raises(typer.Exit):
dist_cmd._validate_markers("<!-- dist:strip-end -->\n<!-- dist:strip-start -->", "x")
def test_validate_markers_rejects_nested_starts():
with pytest.raises(typer.Exit):
dist_cmd._validate_markers(
"<!-- dist:strip-start --><!-- dist:strip-start --><!-- dist:strip-end -->", "x"
)