feat: a subtype gets its own page skeleton from types/<type>.<value>.md (#117)
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:
1 parent
c261b8f4ca
commit
d8cb494d58
25 files changed
+747
-51
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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"))
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
}
|
||||
Reference in new issue
Block a user