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
539 lines
22 KiB
Python
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"
|
|
)
|