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

+8 -5
View File
@@ -220,6 +220,7 @@ Scaffold a new wiki page of any type.
- The target's path below the instance root may be at most 160 characters, counted in UTF-16 code units the way Windows counts MAX_PATH, so a Windows checkout without long paths keeps working. A longer one is refused, for every root, naming the length and how much shorter it has to get.
- `new` never overwrites: a file already at the target - or one a case-insensitive file system would treat as the same file - is refused for every root, `instructions/` included.
- The type-spec drives everything: fields, directory (`base_dir`/`layout`), title prefix, and template. `types list`/`types describe` show what a type requires.
- The body skeleton is `types/<type>.<value>.md` when that file exists, `<value>` being the page's subtype field value (e.g. `types/entity.person.md` for `entity_type=person`) - it replaces the type-spec's `## Template` block whole. Without such a file, and for a type with no subtype field, the `## Template` block is used as before.
- A schema `default:` is materialized only for a field the schema also lists in `required:`.
- `--set` is repeatable, and comma-separated values fill array fields. An element that itself contains a comma is written `\,`, or passed as its own repeated `--set` for that field - repeating an array field appends.
- A capture field the type-spec requires (a source's `fidelity`/`authority`) must be passed with `--set`; `new` never guesses it and refuses `unknown` for it.
@@ -2292,6 +2293,7 @@ Check the docs that mirror the code.
- 1 A command, contract, or type-form mismatch
- 1 The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale
- 1 A type-spec's own frontmatter fails its schema
- 1 A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter
- 1 A shipped `.md`/`.template` cites an issue number
- 1 A reference file's table-of-contents region is missing or stale
- 1 A reference file's relative markdown link does not resolve to an existing file
@@ -2304,6 +2306,7 @@ Check the docs that mirror the code.
- A command, contract, or type-form mismatch -> Fix the documentation it names, then re-run
- The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale -> Run `docs contract --apply`, then re-run
- A type-spec's own frontmatter fails its schema -> Fix the field, or add a matching line to `types/type-spec.schema.yaml` if the field is legitimately new
- A subtype template `types/<type>.<value>.md` has no type-spec with a `subtype_field:` beside it, names a value outside that field's enum, or carries frontmatter -> Rename the file to the type and value it was meant for, delete it, or remove its frontmatter block
- A shipped `.md`/`.template` cites an issue number -> Say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block
- A reference file's table-of-contents region is missing or stale -> Run `docs toc --apply`, then re-run
- A reference file's relative markdown link does not resolve to an existing file -> Fix the `../` count or the target's name
@@ -2320,7 +2323,7 @@ Check the docs that mirror the code.
- Checks the docs that mirror the code. The name is about documentation parity, not the `docs/` directory - it neither reads nor requires one.
- Commands: every command has a `cli_contract` record and is listed in `cli_contract.GROUPS`, in both directions; every command's non-hidden flags appear in its record's SYNOPSIS and vice versa; `tools/CONTRACT.md`'s generated `<!-- wikitool:commands -->` region matches what `docs contract` would write; no command's rendered `--help`/`-h` text cites an issue number.
- Collections: every directory under `kb/` has a `COLLECTION.md` and no directory outside it does; every collection declares `profile:` and a `required_by_stack:` that agrees with the stack's own list.
- Types: every type the stack lists (currently `source` and `project`) has a type-spec of that name whose schema requires the field the stack list names (`raw_files:`/`state:`); every file under `types/` declaring `type: types/type-spec.md` validates against `types/type-spec.schema.yaml`; no pre-migration `type: entity` blocks are left in the contracts.
- Types: every type the stack lists (currently `source` and `project`) has a type-spec of that name whose schema requires the field the stack list names (`raw_files:`/`state:`); every file under `types/` declaring `type: types/type-spec.md` validates against `types/type-spec.schema.yaml`; every subtype template `types/<type>.<value>.md` (any `<value>` but `guidance`) sits beside a type-spec declaring `subtype_field:`, names a value that field's schema enum allows, and carries no frontmatter; no pre-migration `type: entity` blocks are left in the contracts.
- `kb/CONVENTIONS.md`, if it exists at all, names all three tool-owned section headings; every stage contract is present.
- The `.gitignore` canaries clear in both directions: nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories.
- No `.md`/`.template` file `dist export` would ship cites an issue number. A `<!-- dist:strip-start/end -->` region is exempt: the check reads the export plan's text, from which it is already gone.
@@ -2370,7 +2373,7 @@ Create, refresh or remove the generated table-of-contents region.
- Creates, refreshes or removes the generated table-of-contents region on every reference file over 100 lines: `AGENTS.md`, every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md` file, every `types/*.md` type-spec, and every `docs/` page - each together with the `<name>.template` it ships as, where one exists.
- The scope is computed from those categories rather than listed, so a file added later is in scope without a code change.
- Out of scope: every `SKILL.md`, and the human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`, `tools/README.md`).
- Out of scope: every `SKILL.md`, the human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`, `tools/README.md`), and every subtype template `types/<type>.<value>.md` - `new` copies it into a page, so it never carries a region whatever its length.
- Dry-run by default (prints which files would change); `--apply` writes. Idempotent: a re-run after an interruption converges rather than doubling a region.
- If `docs verify` still reports a stale region after `--apply`, the file's `##` headings changed in between; run it again.
@@ -2596,9 +2599,9 @@ Write a contentless, distributable copy of this repo's machinery.
**NOTES**
- Writes a contentless, distributable copy of this repo's machinery into an empty or new `<target>` directory.
- Ships `AGENTS.md`/`README.md`/`EVALS.md` with any `dist:strip-start`...`dist:strip-end` marker region removed, `instructions/` (minus `instructions/dev/`), `types/` (the `root: kb` page type-specs and their schemas re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas) and `VERSION`.
- Ships `AGENTS.md`/`README.md`/`EVALS.md` with any `dist:strip-start`...`dist:strip-end` marker region removed, `instructions/` (minus `instructions/dev/`), `types/` (the `root: kb` page type-specs, their schemas and their subtype templates `types/<type>.<value>.md` re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas) and `VERSION`.
- Ships `raw/` and `incoming/` as flat roots, each with a `.gitkeep` and no subdirectories. `incoming/.gitkeep` is trackable and survives becoming a git repository, so a plain clone gets the directory without any bootstrap step.
- Ships templates, never the filled files: `USER.md.template`/`SOUL.md.template`, `kb/CONVENTIONS.md.template`, and each collection's contract re-keyed as `kb/<name>/COLLECTION.md.template`. The filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/`types/<page-type>.md` bind their instance; `find_leaks` refuses a plan carrying one.
- Ships templates, never the filled files: `USER.md.template`/`SOUL.md.template`, `kb/CONVENTIONS.md.template`, and each collection's contract re-keyed as `kb/<name>/COLLECTION.md.template`. The filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/`types/<page-type>.md`/`types/<page-type>.<value>.md` bind their instance; `find_leaks` refuses a plan carrying one.
- Writes a generated `.wikitool-release.json` stamp: version, export date, origin, and a sha256 per exported file - the base a later upgrade compares against.
- The four origin options only fill stamp fields: `export` never calls git and cannot discover them.
- A build and test tool: every release is an export packed as a tarball, and an instance is installed from such a release, never from an export directly.
@@ -2649,7 +2652,7 @@ Take shipped templates as this instance's own: copy each to its unsuffixed name.
**NOTES**
- Copies `<name>.template` to `<name>` byte for byte; the template stays where it is, as the base the next `dist upgrade` compares against.
- Without a path: every `kb/<collection>/COLLECTION.md.template` and every `types/*.template` (the `root: kb` page type-specs and their schemas).
- Without a path: every `kb/<collection>/COLLECTION.md.template` and every `types/*.template` (the `root: kb` page type-specs, their schemas and their subtype templates).
- With paths: exactly those templates, each of which has to be one of the set above.
- Never overwrites: a target that already exists is reported as kept and left untouched.
- Not for `kb/CONVENTIONS.md.template` or the personalization templates - those carry a sentinel and are filled in, not copied.
+31 -11
View File
@@ -353,15 +353,26 @@ _TYPE_SCHEMA_SUFFIX = ".schema.yaml"
def _owned_type_stem(relative: str) -> Optional[str]:
"""The type stem `relative` (a path under `types/`, no `.template`
suffix) names, if it is exactly that type-spec's own `<stem>.md` or
`<stem>.schema.yaml` - `None` for anything else under `types/`,
including a `<stem>.guidance.md` file. `_plan_types()` and `find_leaks()`
both ask this instead of computing their own stem, so the two answer the
same question about the same path (AGENTS.md invariant 8)."""
suffix) names, if it is that type-spec's own `<stem>.md` or
`<stem>.schema.yaml`, or one of its subtype templates `<stem>.<value>.md`
(Gitea #117) - `None` for anything else under `types/`, including a
`<stem>.guidance.md` file. `_plan_types()` and `find_leaks()` both ask this
instead of computing their own stem, so the two answer the same question
about the same path (AGENTS.md invariant 8).
A subtype template is page material of its type-spec, so it belongs to
whoever owns that type-spec: re-keyed as `.template` beside a `root: kb`
one. Missing it here would ship `entity.person.md` verbatim - a file the
next `dist upgrade` overwrites in an instance that has adopted and
rewritten it."""
from chemenu.type_resolver import split_subtype_template_name
name = relative.rsplit("/", 1)[-1]
if name.endswith(_TYPE_SCHEMA_SUFFIX):
return name[: -len(_TYPE_SCHEMA_SUFFIX)]
if name.endswith(".md") and not name.endswith(".guidance.md"):
if (split := split_subtype_template_name(name)) is not None:
return split[0]
if name.endswith(".md") and "." not in name[: -len(".md")]:
return name[: -len(".md")]
return None
@@ -376,6 +387,10 @@ def _plan_types() -> dict[str, PlannedFile]:
it, because the two are one type (see types/type-spec.md § Anatomy) and
adopting half of it would leave a spec validated by a file it does not own.
Its subtype templates `<name>.<value>.md` (Gitea #117) travel the same way,
each as its own `.template`: page material of the type, adopted or left
lying independently of the type-spec beside it.
A type-spec's optional `<name>.guidance.md` (Gitea #104) is the opposite:
stack-owned even where the type-spec itself is instance-owned, and ships
verbatim beside the `.template` - `_owned_type_stem` is what keeps it out
@@ -546,7 +561,10 @@ def find_leaks(plan: dict[str, PlannedFile]) -> list[str]:
and (owned_stem := _owned_type_stem(relative)) is not None
and owned_stem in owned_types
):
leaks.append(f"{relative} (this instance's page type-spec; ship the .template)")
leaks.append(
f"{relative} (this instance's page type-spec or subtype template; "
"ship the .template)"
)
elif relative.startswith("instructions/dev/"):
leaks.append(f"{relative} (stack-development only)")
elif (
@@ -588,8 +606,8 @@ def _write_plan(target: Path, plan: dict[str, PlannedFile]) -> None:
"new `<target>` directory.",
"Ships `AGENTS.md`/`README.md`/`EVALS.md` with any `dist:strip-start`...`dist:strip-end` "
"marker region removed, `instructions/` (minus `instructions/dev/`), `types/` (the "
"`root: kb` page type-specs and their schemas re-keyed as `.template`, the stack's own "
"verbatim), `docs/` verbatim, `tools/` (no venv/caches), the "
"`root: kb` page type-specs, their schemas and their subtype templates "
"`types/<type>.<value>.md` re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no venv/caches), the "
"`.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, "
"`kb/CONTRACT.md` (no pages, no areas) and `VERSION`.",
"Ships `raw/` and `incoming/` as flat roots, each with a `.gitkeep` and no "
@@ -599,7 +617,8 @@ def _write_plan(target: Path, plan: dict[str, PlannedFile]) -> None:
"`kb/CONVENTIONS.md.template`, and each collection's contract re-keyed as "
"`kb/<name>/COLLECTION.md.template`. The filled "
"`USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/"
"`types/<page-type>.md` bind their instance; `find_leaks` refuses a plan carrying one.",
"`types/<page-type>.md`/`types/<page-type>.<value>.md` bind their instance; `find_leaks` "
"refuses a plan carrying one.",
"Writes a generated `.wikitool-release.json` stamp: version, export date, origin, and "
"a sha256 per exported file - the base a later upgrade compares against.",
"The four origin options only fill stamp fields: `export` never calls git and cannot "
@@ -762,7 +781,8 @@ def adoptable_templates() -> list[Path]:
"Copies `<name>.template` to `<name>` byte for byte; the template stays where it is, as "
"the base the next `dist upgrade` compares against.",
"Without a path: every `kb/<collection>/COLLECTION.md.template` and every "
"`types/*.template` (the `root: kb` page type-specs and their schemas).",
"`types/*.template` (the `root: kb` page type-specs, their schemas and their subtype "
"templates).",
"With paths: exactly those templates, each of which has to be one of the set above.",
"Never overwrites: a target that already exists is reported as kept and left untouched.",
"Not for `kb/CONVENTIONS.md.template` or the personalization templates - those carry a "
+74 -4
View File
@@ -602,6 +602,63 @@ def check_type_spec_frontmatter() -> list[str]:
return issues
def check_subtype_templates() -> list[str]:
"""Every subtype template `types/<stem>.<value>.md` is one `new` can
actually reach (Gitea #117).
The file's name is its only declaration, so a typo in it is a template
nothing ever scaffolds from and nobody notices - `new` silently falls back
to the `## Template` block. Three ways that happens, each reported with
the file and the value: `types/<stem>.md` is no type-spec declaring a
`subtype_field:` (an orphan), `<value>` is not one the schema's enum allows
for that field, or the file carries a frontmatter block, which `new` would
copy into the page body as text. Which files count is
`split_subtype_template_name`'s answer - the same one `toc` and
`dist export` use - so `<stem>.guidance.md` is never one of them.
"""
from chemenu.frontmatter_io import FRONTMATTER_RE, read_page
from chemenu.type_resolver import resolver, split_subtype_template_name
issues: list[str] = []
if not config.TYPES_DIR.is_dir():
return issues
for path in sorted(config.TYPES_DIR.glob("*.md")):
split = split_subtype_template_name(path.name)
if split is None:
continue
stem, value = split
relative = rel_path(path)
spec_file = config.TYPES_DIR / f"{stem}.md"
spec_frontmatter = read_page(spec_file)[0] if spec_file.is_file() else {}
subtype_field = spec_frontmatter.get("subtype_field")
if spec_frontmatter.get("type") != "types/type-spec.md" or not subtype_field:
issues.append(
f"{relative}: subtype template for value `{value}`, but types/{stem}.md is no "
"type-spec declaring `subtype_field:` - `new` never scaffolds from it"
)
continue
type_path = rel_path(spec_file)
try:
allowed = resolver.get_enum(type_path, subtype_field)
except (ValueError, OSError):
# No enum to check against (a free-form subtype field, or no
# schema): any value is one a page can carry.
allowed = None
if allowed is not None and value not in allowed:
issues.append(
f"{relative}: `{value}` is not a value {type_path}'s schema allows for "
f"`{subtype_field}` ({', '.join(map(str, allowed))}) - `new` never scaffolds "
"from it"
)
if FRONTMATTER_RE.match(path.read_text(encoding="utf-8")):
issues.append(
f"{relative}: subtype template for value `{value}` carries a frontmatter block - "
"the whole file is the page body `new` scaffolds, so the block would land in "
"every page as text"
)
return issues
def check_legacy_type_blocks() -> list[str]:
issues = []
guarded = [
@@ -1062,8 +1119,11 @@ def check_breaking_change_for_boundary() -> list[str]:
"Types: every type the stack lists (currently `source` and `project`) has a type-spec "
"of that name whose schema requires the field the stack list names "
"(`raw_files:`/`state:`); every file under `types/` declaring `type: "
"types/type-spec.md` validates against `types/type-spec.schema.yaml`; no "
"pre-migration `type: entity` blocks are left in the contracts.",
"types/type-spec.md` validates against `types/type-spec.schema.yaml`; every subtype "
"template `types/<type>.<value>.md` (any `<value>` but `guidance`) sits beside a "
"type-spec declaring `subtype_field:`, names a value that field's schema enum allows, "
"and carries no frontmatter; no pre-migration `type: entity` blocks are left in the "
"contracts.",
"`kb/CONVENTIONS.md`, if it exists at all, names all three tool-owned section headings; "
"every stage contract is present.",
"The `.gitignore` canaries clear in both directions: nothing ignored under "
@@ -1099,6 +1159,13 @@ def check_breaking_change_for_boundary() -> list[str]:
reaction="Fix the field, or add a matching line to `types/type-spec.schema.yaml` if "
"the field is legitimately new",
),
cli_contract.Failure(
cause="A subtype template `types/<type>.<value>.md` has no type-spec with a "
"`subtype_field:` beside it, names a value outside that field's enum, or carries "
"frontmatter",
reaction="Rename the file to the type and value it was meant for, delete it, or "
"remove its frontmatter block",
),
cli_contract.Failure(
cause="A shipped `.md`/`.template` cites an issue number",
reaction="Say what was decided instead of pointing at where, or move the pointer "
@@ -1153,6 +1220,7 @@ def verify():
+ check_readmes_have_no_command_table()
+ check_collection_contracts()
+ check_type_spec_frontmatter()
+ check_subtype_templates()
+ check_legacy_type_blocks()
+ check_ignored_content()
+ check_version_changelog()
@@ -1207,8 +1275,10 @@ def verify():
"`<name>.template` it ships as, where one exists.",
"The scope is computed from those categories rather than listed, so a file added later "
"is in scope without a code change.",
"Out of scope: every `SKILL.md`, and the human docs (`README.md`, `CHANGES.md`, "
"`EVALS.md`, `INSTALL.md`, `tools/README.md`).",
"Out of scope: every `SKILL.md`, the human docs (`README.md`, `CHANGES.md`, "
"`EVALS.md`, `INSTALL.md`, `tools/README.md`), and every subtype template "
"`types/<type>.<value>.md` - `new` copies it into a page, so it never carries a region "
"whatever its length.",
"Dry-run by default (prints which files would change); `--apply` writes. Idempotent: a "
"re-run after an interruption converges rather than doubling a region.",
"If `docs verify` still reports a stale region after `--apply`, the file's `##` "
+22 -7
View File
@@ -17,7 +17,9 @@ in `required:` - an optional field's default is a reader-side assumption
would turn that assumption into a stated claim instead (Gitea #109).
Directory placement for subtype-driven types (in the shipped specs: entity,
concept, source and project) also comes from the type-spec, via its `layout:` frontmatter (see
`TypeResolver.get_layout`) - not a hand-maintained Python dict.
`TypeResolver.get_layout`) - not a hand-maintained Python dict. A subtype whose pages need a
different skeleton gets it from a file beside the type-spec, `types/<type>.<value>.md`
(`TypeResolver.page_template`), again without a code change here.
"""
from __future__ import annotations
@@ -63,10 +65,12 @@ def _default_summary(summary: str) -> str:
return summary.strip() or "TODO: add summary"
def _resolve_type_and_get_template(type_path: str, source_dir: Path = None):
"""Resolve a type path, load the type-spec, and extract its template."""
def _resolve_type_and_get_template(type_path: str, source_dir: Path = None, subtype: Any = None):
"""Resolve a type path, load the type-spec, and pick its template: the
subtype template `types/<stem>.<subtype>.md` where one exists, otherwise
the type-spec's own `## Template` block (Gitea #117)."""
type_spec = resolver.load_type_spec(type_path, source_dir)
template = resolver.extract_template(type_spec)
template = resolver.page_template(type_spec, subtype)
return type_spec, template
@@ -260,11 +264,11 @@ def _validate_or_fail(frontmatter: Dict[str, Any], type_path: str, source_dir: P
fail(str(exc))
def _load_type_or_fail(type_path: str, source_dir: Path):
def _load_type_or_fail(type_path: str, source_dir: Path, subtype: Any = None):
"""Resolve a type path and load its type-spec + template, converting an
unresolvable/invalid `--type` into the CLI's normal friendly-failure path."""
try:
return _resolve_type_and_get_template(type_path, source_dir)
return _resolve_type_and_get_template(type_path, source_dir, subtype)
except ValueError as exc:
fail(str(exc))
@@ -391,6 +395,10 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
"included.",
"The type-spec drives everything: fields, directory (`base_dir`/`layout`), title "
"prefix, and template. `types list`/`types describe` show what a type requires.",
"The body skeleton is `types/<type>.<value>.md` when that file exists, `<value>` being "
"the page's subtype field value (e.g. `types/entity.person.md` for `entity_type=person`) "
"- it replaces the type-spec's `## Template` block whole. Without such a file, and for a "
"type with no subtype field, the `## Template` block is used as before.",
"A schema `default:` is materialized only for a field the schema also lists in "
"`required:`.",
"`--set` is repeatable, and comma-separated values fill array fields. An element that "
@@ -608,8 +616,15 @@ def new_page_command(
)
target_dir = _target_dir(type_path, frontmatter)
_type_spec, template = _load_type_or_fail(type_path, target_dir)
# Validated before the template is picked: the subtype value names a file
# under types/, so it has passed the schema's enum before it is used as one.
_validate_or_fail(frontmatter, type_path, target_dir)
try:
subtype_field = resolver.get_subtype_field(type_path)
except ValueError as exc:
fail(str(exc))
subtype = frontmatter.get(subtype_field) if subtype_field else None
_type_spec, template = _load_type_or_fail(type_path, target_dir, subtype)
if "raw_files" in frontmatter:
check_raw_files_exist(frontmatter["raw_files"])
+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",
}
+16 -2
View File
@@ -23,7 +23,7 @@ precedent first.
governs `wikitool` itself. `target_files()` walks every file-naming category
AGENTS.md's own table calls agent-loaded: `AGENTS.md`, every stage contract,
`kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md`
file, every `types/*.md` type-spec, and every `docs/` page - each together with
file, every `types/*.md` type-spec and guidance file, and every `docs/` page - each together with
the `<name>.template` it ships as, where one exists. One rule, and
exactly one exception below it - which is the whole point, because a scope
carrying several unexplained absences reads as an accident rather than a
@@ -61,6 +61,15 @@ other reference file, and `types_cmd` strips the region back out of what
`describe` prints: the command already hands over the whole body, so a
navigation aid into it would be noise in the output and nothing else.
**A subtype template under `types/` is not a reference file at all**, which is
why it is out of scope rather than a second exception to the rule above.
`types/<stem>.<value>.md` is the body skeleton `wikitool new` copies into a page
(Gitea #117) - a region written into it would be written into every page
scaffolded from it, as an author-facing section nobody may edit. So no file
`new` reads as a scaffold ever carries a `wikitool:toc` marker, whatever its
length; `type_resolver.split_subtype_template_name` decides which files those
are, the same way for this scope as for `dist export` and `docs verify`.
The threshold's own provenance is worth recording, because the two sources
disagree: Codex says 100 lines, Claude Code says 300. This stack took the
stricter number.
@@ -72,6 +81,7 @@ from pathlib import Path
from typing import NamedTuple
from chemenu import blocks, config, kb_collections, markdown_code
from chemenu.type_resolver import split_subtype_template_name
REGION_NAME = "toc"
HEADING_TEXT = "Contents"
@@ -159,7 +169,11 @@ def target_files() -> list[Path]:
for subdir in ("types", "docs"):
directory = config.ROOT / subdir
if directory.is_dir():
files += sorted(directory.rglob("*.md"))
files += sorted(
path
for path in directory.rglob("*.md")
if subdir != "types" or split_subtype_template_name(path.name) is None
)
files += [
template
for template in (
+67 -1
View File
@@ -22,6 +22,38 @@ from chemenu import config
from chemenu.frontmatter_io import normalize_dates, read_page, write_page
# The middle part of `types/<stem>.guidance.md`, reserved for a type-spec's
# stack-owned authoring prose - never a subtype value with a template of its own.
GUIDANCE_PART = "guidance"
# What a subtype value must look like to name a file `types/<stem>.<value>.md`:
# one path segment, no dot (the dot is what separates it from the stem).
SUBTYPE_VALUE_RE = re.compile(r"[A-Za-z0-9_-]+")
def split_subtype_template_name(name: str) -> Optional[Tuple[str, str]]:
"""`(stem, value)` when a file name under `types/` has the shape of a
subtype template, `<stem>.<value>.md` with `<value>` not `guidance`;
None for anything else - a type-spec, a schema, a guidance file, a
`.template`.
The stem ends at the first dot: type names match `^[a-z][a-z0-9-]*$`, so
they never contain one. Shape only - whether `types/<stem>.md` exists and
declares a `subtype_field:` is `docs verify`'s question, not this one's, so
`toc`, `dist export` and `docs verify` all classify the same file the
same way whether or not it is valid (AGENTS.md invariant 8).
"""
if not name.endswith(".md"):
return None
parts = name[: -len(".md")].split(".")
if len(parts) != 2:
return None
stem, value = parts
if not stem or value == GUIDANCE_PART or not SUBTYPE_VALUE_RE.fullmatch(value):
return None
return stem, value
class TypeResolver:
"""Resolves and validates type paths against type-spec files."""
@@ -205,7 +237,41 @@ class TypeResolver:
return template
raise ValueError(f"No template block found in type-spec: {type_spec['path']}")
def subtype_template_path(self, type_spec: Dict[str, Any], subtype: Any) -> Optional[Path]:
"""The subtype template `types/<stem>.<subtype>.md` beside a loaded
type-spec, or None when there is none - no `subtype_field:`, no value,
the reserved value `guidance`, or no such file (Gitea #117).
The file's name is its only declaration: nothing in the type-spec
lists which subtypes have one, so a template is added or dropped by
adding or dropping the file. `subtype` is the frontmatter value; one
that could not be a file-name segment simply has no template.
"""
if not type_spec['frontmatter'].get('subtype_field'):
return None
if not isinstance(subtype, str) or not SUBTYPE_VALUE_RE.fullmatch(subtype):
return None
if subtype == GUIDANCE_PART:
return None
spec_path = Path(type_spec['path'])
candidate = spec_path.with_name(f"{spec_path.stem}.{subtype}.md")
return candidate if candidate.is_file() else None
def page_template(self, type_spec: Dict[str, Any], subtype: Any = None) -> str:
"""The body skeleton `new` scaffolds for one page: the subtype
template for `subtype` where one exists, otherwise the type-spec's own
`## Template` block (`extract_template`, unchanged).
A subtype template replaces the base whole - no merging - and is read
as plain markdown, stripped the same way an extracted block is, so
both sources yield a skeleton of the same shape.
"""
path = self.subtype_template_path(type_spec, subtype)
if path is None:
return self.extract_template(type_spec)
return path.read_text(encoding='utf-8').strip()
def _extract_code_block(self, text: str, language: str = None) -> Optional[str]:
"""Extract the first code block with the specified language."""
# Pattern for fenced code blocks