feat: a subtype gets its own page skeleton from types/<type>.<value>.md (#117)
CI / verify (push) Successful in 5m17s
CI / pwsh (push) Successful in 2m1s
Release / release (push) Successful in 34s

Files changed:
- AGENTS.md
- CHANGES.md
- README.md
- VERSION
- docs/ownership-and-templates.md
- instructions/evolve-subtypes.md
- instructions/setup-instance.md
- instructions/subtype-templates.md
- instructions/upgrade-instance.md
- tools/CONTRACT.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_toc.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/toc.py
- tools/chemenu/type_resolver.py
- types/concept.decision.md
- types/entity.guidance.md
- types/entity.md
- types/entity.person.md
- types/type-spec.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Opus 5.5 committed 2026-10-03 15:58:15 +02:00
1 parent c261b8f4ca
commit d8cb494d58
25 files changed
+747 -51

No files matched your search

+34 -2
View File
@@ -94,6 +94,9 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"---\n\n# Entity Guidance\n",
encoding="utf-8",
)
# entity's subtype template (Gitea #117): page material of an instance-owned
# type, so it crosses as `.template` like the type-spec beside it.
(types_dir / "entity.person.md").write_text("# {name}\n\n## Rolle\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",
@@ -347,6 +350,8 @@ def test_find_leaks_is_silent_on_a_clean_plan(repo):
# 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",
# Its subtype template, likewise (Gitea #117).
"types/entity.person.md",
],
)
def test_find_leaks_catches_one_instance_own_data(repo, relative):
@@ -425,6 +430,30 @@ def test_guidance_file_ships_verbatim_beside_a_templated_type_spec(repo, monkeyp
assert "types/entity.guidance.md.template" not in plan
def test_subtype_template_ships_only_as_template_and_upgrade_never_writes_it(repo, monkeypatch):
"""Gitea #117: no export carries a subtype template under its unsuffixed
name, so no `dist upgrade` - which writes only what the new stamp lists -
can ever write over one an instance has adopted. A guidance file sharing
the dotted shape still ships verbatim."""
from chemenu.type_resolver import resolver
monkeypatch.setattr(resolver, "_repo_root", config.ROOT)
plan = dist_cmd.build_plan()
assert "types/entity.person.md.template" in plan
assert "types/entity.person.md" not in plan
assert "types/entity.guidance.md" in plan
assert dist_cmd.find_leaks(plan) == []
assert "types/entity.person.md" not in dist_cmd._write_candidates(plan)
def test_this_repos_export_plan_carries_both_example_subtype_templates():
plan = dist_cmd.build_plan()
for name in ("types/entity.person.md", "types/concept.decision.md"):
assert f"{name}.template" in plan
assert name not in plan
def test_plan_creates_empty_raw_and_incoming_not_real_content(repo):
"""Both flat since Gitea #67: `raw/` addresses a file by its accept date,
never by a hand-picked type, so there is nothing left to seed per type."""
@@ -612,7 +641,7 @@ def instance(repo):
(repo / "kb" / "entities" / "COLLECTION.md").rename(
repo / "kb" / "entities" / "COLLECTION.md.template"
)
for name in ("entity.md", "entity.schema.yaml"):
for name in ("entity.md", "entity.schema.yaml", "entity.person.md"):
(repo / "types" / name).rename(repo / "types" / f"{name}.template")
return repo
@@ -620,7 +649,10 @@ def instance(repo):
def test_adopt_without_a_path_copies_every_collection_and_type_template(instance):
dist_cmd.run_adopt([])
for relative in ("kb/entities/COLLECTION.md", "types/entity.md", "types/entity.schema.yaml"):
for relative in (
"kb/entities/COLLECTION.md", "types/entity.md", "types/entity.schema.yaml",
"types/entity.person.md",
):
adopted = instance / relative
template = instance / f"{relative}.template"
assert adopted.read_bytes() == template.read_bytes()
+57
View File
@@ -910,3 +910,60 @@ def test_verify_raises_when_a_shipped_document_cites_an_issue(monkeypatch):
monkeypatch.setattr(docs_verify, "check_no_issue_references", lambda: ["cited"])
with pytest.raises(typer.Exit):
docs_verify.verify()
# --- subtype templates (Gitea #117) ---------------------------------------------
def test_this_repos_subtype_templates_are_all_reachable():
assert docs_verify.check_subtype_templates() == []
def _subtype_tree(tmp_path, monkeypatch, files: dict):
"""A copy of the shipped `types/`, plus `files` written into it."""
import shutil
types_dir = tmp_path / "types"
shutil.copytree(config.ROOT / "types", types_dir)
for name, text in files.items():
(types_dir / name).write_text(text, encoding="utf-8")
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "TYPES_DIR", types_dir)
monkeypatch.setattr(type_resolver, "resolver", TypeResolver(repo_root=tmp_path))
return docs_verify.check_subtype_templates()
def test_an_orphaned_subtype_template_is_reported(tmp_path, monkeypatch):
issues = _subtype_tree(tmp_path, monkeypatch, {
"gadget.big.md": "# {name}\n", # no such type-spec
"comparison.wide.md": "# {name}\n", # a type-spec, but no subtype_field
})
assert any("types/gadget.big.md" in i and "`big`" in i for i in issues)
assert any("types/comparison.wide.md" in i and "`wide`" in i for i in issues)
assert len(issues) == 2
def test_a_subtype_template_outside_the_enum_is_reported(tmp_path, monkeypatch):
issues = _subtype_tree(tmp_path, monkeypatch, {"entity.persn.md": "# {name}\n"})
assert len(issues) == 1
assert "types/entity.persn.md" in issues[0] and "`persn`" in issues[0]
assert "person" in issues[0] # names what the enum does allow
def test_a_subtype_template_with_frontmatter_is_reported(tmp_path, monkeypatch):
issues = _subtype_tree(tmp_path, monkeypatch, {
"entity.tool.md": "---\ntitle: x\n---\n# {name}\n",
})
assert len(issues) == 1
assert "types/entity.tool.md" in issues[0] and "frontmatter" in issues[0]
def test_a_guidance_file_is_never_taken_for_a_subtype_template(tmp_path, monkeypatch):
"""`<stem>.guidance.md` is reserved - neither the orphan check nor the enum
check fires on one, even beside a type-spec without `subtype_field:` and
for a value no enum lists."""
issues = _subtype_tree(tmp_path, monkeypatch, {
"widget.guidance.md": "---\ntype: types/type-guidance.md\nname: widget\n"
"description: d\n---\n\n# G\n",
})
assert issues == []
+61
View File
@@ -1095,3 +1095,64 @@ def test_new_project_over_the_path_budget_never_reaches_the_tracker(monkeypatch,
assert result.exit_code == 1, result.output
assert "160" in result.output
assert handler_cls.posted is False
# --- subtype templates (Gitea #117) ---------------------------------------------
def _rendered(type_name: str, field: str, value: str, name: str) -> str:
"""What the shipped subtype file renders to for one page, computed from
the file itself rather than restated here."""
import datetime
from chemenu import config
text = (config.TYPES_DIR / f"{type_name}.{value}.md").read_text(encoding="utf-8").strip()
return (
text.replace("{name}", name)
.replace(f"{{{field}|capitalize}}", value.capitalize())
.replace("{today}", datetime.date.today().isoformat())
)
def test_new_person_scaffolds_the_person_template(monkeypatch, kb_dir):
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "Grace Hopper", "--set", "entity_type=person",
])
assert result.exit_code == 0, result.output
_fm, body = read_page(kb_dir / "entities/people/Grace Hopper.md")
for codebase_only in ("Version", "Sprache/Technik", "Repository"):
assert codebase_only not in body
assert body.strip() == _rendered("entity", "entity_type", "person", "Grace Hopper")
assert "{" not in body
def test_new_decision_scaffolds_the_decision_template(monkeypatch, kb_dir):
result = _invoke_new(monkeypatch, kb_dir, [
"new", "concept", "--name", "Flat Subtype Files", "--set", "concept_type=decision",
])
assert result.exit_code == 0, result.output
_fm, body = read_page(kb_dir / "concepts/decisions/Flat Subtype Files.md")
assert body.strip() == _rendered("concept", "concept_type", "decision", "Flat Subtype Files")
for heading in ("## Kontext", "## Entscheidung", "## Alternativen", "## Konsequenzen"):
assert heading in body
def test_a_subtype_without_its_own_file_keeps_the_base_template(monkeypatch, kb_dir):
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "libfoo", "--set", "entity_type=codebase",
])
assert result.exit_code == 0, result.output
_fm, body = read_page(kb_dir / "entities/codebases/libfoo.md")
assert "**Repository:**" in body
def test_an_invalid_subtype_value_is_refused_before_any_template_is_read(monkeypatch, kb_dir):
"""The value names a file under types/, so it passes the schema's enum
first - `guidance` is not a valid entity_type and never gets that far."""
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "Nope", "--set", "entity_type=guidance",
])
assert result.exit_code == 1
assert "entity_type" in result.output
assert not list(kb_dir.rglob("Nope.md"))
+32
View File
@@ -229,3 +229,35 @@ def test_strip_region_removes_what_types_describe_would_otherwise_echo():
def test_strip_region_leaves_a_body_that_never_had_one_alone():
body = "# A type\n\n## X\n\nProse.\n"
assert toc.strip_region(body) == body
def test_target_files_leaves_out_subtype_templates(tmp_path, monkeypatch):
"""Gitea #117: `new` copies a subtype template into every page it
scaffolds, so a region written into one would land in each of them. Out of
scope whatever its length - a guidance file, which shares the dotted
shape, stays in."""
from chemenu import config
monkeypatch.setattr(config, "ROOT", tmp_path)
types_dir = tmp_path / "types"
types_dir.mkdir()
long_body = _body(150, 6)
for name in ("entity.md", "entity.guidance.md", "entity.person.md", "entity.person.md.template"):
(types_dir / name).write_text(long_body, encoding="utf-8")
relatives = {f.relative_to(tmp_path).as_posix() for f in toc.target_files()}
assert relatives == {"types/entity.md", "types/entity.guidance.md"}
def test_no_shipped_subtype_template_carries_a_toc_marker():
from chemenu import config
from chemenu.type_resolver import split_subtype_template_name
templates = [
path for path in config.TYPES_DIR.iterdir()
if split_subtype_template_name(path.name) is not None
]
assert templates
for path in templates:
assert "wikitool:toc" not in path.read_text(encoding="utf-8")
+111
View File
@@ -374,3 +374,114 @@ def test_compute_target_dir_resolves_against_repo_root_for_root_repo_types():
def test_compute_target_dir_rejects_a_type_with_no_base_dir():
with pytest.raises(ValueError, match="base_dir"):
resolver.compute_target_dir("types/type-spec.md", {})
# --- subtype templates (Gitea #117) ---------------------------------------------
@pytest.mark.parametrize(
"name, expected",
[
("entity.person.md", ("entity", "person")),
("concept.decision.md", ("concept", "decision")),
("entity.guidance.md", None), # reserved: always the guidance file
("entity.md", None),
("entity.schema.yaml", None),
("entity.person.md.template", None),
("entity.a.b.md", None),
(".person.md", None),
],
)
def test_split_subtype_template_name_reads_the_shape_only(name, expected):
from chemenu.type_resolver import split_subtype_template_name
assert split_subtype_template_name(name) == expected
def test_every_shipped_type_scaffolds_its_base_template_where_no_subtype_file_exists():
"""The byte-identity criterion of #117, for every type under `types/`:
`new` renders whatever `page_template` returns, so where it returns
exactly what `extract_template` returned before, the scaffold is
byte-identical. Checked for every enum value without a file of its own,
for no value at all, and for every type without a `subtype_field:`."""
checked = 0
for type_path, frontmatter in resolver.list_type_specs():
if not frontmatter.get("base_dir"):
continue
spec = resolver.load_type_spec(type_path)
base = resolver.extract_template(spec)
assert resolver.page_template(spec, None) == base
field = frontmatter.get("subtype_field")
if not field:
assert resolver.page_template(spec, "person") == base
checked += 1
continue
for value in resolver.get_enum(type_path, field):
stem = type_path[len("types/"):-len(".md")]
if (config.TYPES_DIR / f"{stem}.{value}.md").is_file():
continue
assert resolver.page_template(spec, value) == base, (type_path, value)
checked += 1
assert checked > 10
def test_page_template_takes_the_shipped_subtype_files():
for type_path, value in (("types/entity.md", "person"), ("types/concept.md", "decision")):
spec = resolver.load_type_spec(type_path)
stem = type_path[len("types/"):-len(".md")]
expected = (config.TYPES_DIR / f"{stem}.{value}.md").read_text(encoding="utf-8").strip()
assert resolver.page_template(spec, value) == expected
assert resolver.page_template(spec, value) != resolver.extract_template(spec)
def _widget_types(tmp_path, *, subtype_field: bool):
from chemenu.type_resolver import TypeResolver
types_dir = tmp_path / "types"
types_dir.mkdir()
(types_dir / "widget.md").write_text(
"---\ntype: types/widget.md\nname: widget\ndescription: A widget type.\n"
"schema: null\nbase_dir: widgets\n"
+ ("subtype_field: widget_type\n" if subtype_field else "")
+ "---\n\n# Widget\n\n## Template\n\n```markdown\n# {name}\n```\n",
encoding="utf-8",
)
(types_dir / "widget.big.md").write_text("# {name}\n\n## Big\n", encoding="utf-8")
(types_dir / "widget.guidance.md").write_text(
"---\ntype: types/type-guidance.md\nname: widget\ndescription: d\n---\n\n# G\n",
encoding="utf-8",
)
own = TypeResolver(repo_root=tmp_path)
return own, own.load_type_spec("types/widget.md")
def test_a_subtype_file_replaces_the_base_whole(tmp_path):
own, spec = _widget_types(tmp_path, subtype_field=True)
assert own.page_template(spec, "big") == "# {name}\n\n## Big"
assert own.page_template(spec, "small") == "# {name}"
def test_a_subtype_file_is_ignored_for_a_type_without_subtype_field(tmp_path):
own, spec = _widget_types(tmp_path, subtype_field=False)
assert own.page_template(spec, "big") == "# {name}"
@pytest.mark.parametrize("value", ["guidance", "../widget", "big.md", "", None, 3])
def test_a_value_that_cannot_name_a_subtype_file_takes_the_base(tmp_path, value):
"""`guidance` is reserved for the guidance file, and a value that is not a
single dot-free file-name segment never reaches the file system."""
own, spec = _widget_types(tmp_path, subtype_field=True)
assert own.page_template(spec, value) == "# {name}"
def test_list_type_specs_counts_no_subtype_template():
"""The same set as before #117: a subtype template carries no frontmatter,
so it is never a type-spec."""
paths = {path for path, _ in resolver.list_type_specs()}
assert "types/entity.person.md" not in paths
assert "types/concept.decision.md" not in paths
assert paths == {
"types/comparison.md", "types/concept.md", "types/entity.md", "types/instruction.md",
"types/lint-report.md", "types/project.md", "types/source.md", "types/type-guidance.md",
"types/type-spec.md",
}