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

+1
View File
@@ -102,6 +102,7 @@ What a file is called says who it is for and how it is loaded. This is a rule, n
| `instructions/<name>/SKILL.md` | Agents | By the harness, once published |
| `types/<name>.md` | Agents + validator | Via `tools/wikitool types describe`. Split by `root:`: a page type-spec (`root: kb`) belongs to the instance and ships as `.template`; one describing a stack artifact ships verbatim |
| `types/<name>.guidance.md` | Agents + validator | Via `tools/wikitool types describe`, composed with the `types/<name>.md` it documents. Stack-owned regardless of the type-spec's own `root:` - it ships verbatim and is optional, present only where the type-spec declares `guidance:` |
| `types/<name>.<subtype>.md` | Agents + `new` | Never as instruction: `wikitool new` copies it as the page skeleton for that one subtype value, in place of the type-spec's `## Template` block. Page material in the KB language, no frontmatter; its name is its only declaration, and `guidance` is reserved. Owned like the type-spec beside it, so it ships as `.template` |
| `docs/<name>.md` | Agents and humans | By link, or on explicit request - never automatically, and never as instruction |
| `INDEX.md` | Both | Generated - never hand-edited |
+31 -1
View File
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
---
## 8.0.0-beta.30 - 2026-10-03 - lint: a wikilink wrapped across a line break is its own finding; rename and rm see it
## 8.0.0-beta.31 - 2026-10-03 - new: a subtype gets its own page skeleton from types/<type>.<value>.md
**Author:** Torben Nehmer
@@ -102,6 +102,7 @@ concern - readable here, never shipped as something to parse.
- publish keeps a closing trailer block of --message last, so git reads Co-Authored-By again (#149)
- Stack-Entwicklung in drei Phasen: stack-dev (Design), stack-build, stack-close - Übergabe über den Tracker, kein Modellwechsel in der Sitzung
- raw fetch: a sanctioned intake for a URL into incoming/
- new: a subtype gets its own page skeleton from types/<type>.<value>.md
**Low impact**
- version bump no longer points at version release in its output
@@ -143,6 +144,35 @@ concern - readable here, never shipped as something to parse.
- lint: a wikilink wrapped across a line break is its own finding; rename and rm see it
<!-- /wikitool:bumps -->
### new: a subtype gets its own page skeleton from types/<type>.<value>.md
A type-spec carried one `## Template` block for every value of its subtype field, so `wikitool
new` scaffolded a person with `Version`, `Sprache/Technik` and `Repository`, and a decision with
"Wann zu verwenden" instead of context and consequences - and authors rebuilt those pages by
hand. Now a file `types/<type>.<value>.md` beside the type-spec is the skeleton for pages whose
subtype field holds `<value>`: plain markdown in the KB language, no frontmatter, its name the
only declaration. It replaces the block whole; every subtype without such a file, and every type
without `subtype_field:`, scaffolds byte-identically to before. `guidance` is reserved for
`<type>.guidance.md`. The stack ships two: `types/entity.person.md` (Rolle, Zugehörigkeit,
Wirkungszeitraum, Beiträge) and `types/concept.decision.md` (Kontext, Entscheidung, Alternativen,
Konsequenzen, Status).
`docs verify` reports a subtype template whose `types/<type>.md` is no type-spec with
`subtype_field:`, whose value the field's enum does not allow, or which carries frontmatter -
each would otherwise be a file nothing reads. Subtype templates never get a table-of-contents
region, since `new` copies them into every page. They are page material of an instance-owned type,
so `dist export` ships them as `.template`, `find_leaks` refuses one under its own name, and
`dist adopt` takes them; no export carries one unsuffixed, so no `dist upgrade` writes over an
adopted one. A new manual instruction, `instructions/subtype-templates.md`, is the interview that
finds subtypes whose pages depart from their template (at least three pages, the same way) and
writes the template the user accepts. `types/entity.md` and its guidance no longer speak of
"projects" since the subtype became `codebase`.
**For an existing instance:** nothing changes until it adopts a template - `dist upgrade` lays
`types/entity.person.md.template` and `types/concept.decision.md.template` beside the adopted
type-specs; `tools/wikitool dist adopt <path>` takes one, leaving it takes none. Both are valid.
No page changes (Gitea #117).
### lint: a wikilink wrapped across a line break is its own finding; rename and rm see it
A `[[...]]` written across a line break - prose wrapped at a fixed column, with the break
+3 -2
View File
@@ -103,7 +103,8 @@ chemenu/
│ ├── type-guidance.md # Contract for the *.guidance.md files below
│ ├── entity.md # Entity type config + template (+ .schema.yaml)
│ ├── entity.guidance.md # Its stack-owned authoring prose, shipped verbatim
│ ├── concept.md # Concept type config + template (+ .guidance.md)
│ ├── entity.person.md # Subtype template: what `new` scaffolds for entity_type=person
│ ├── concept.md # Concept type config + template (+ .guidance.md, + concept.decision.md)
│ ├── source.md # Source type config + template (+ .guidance.md)
│ ├── comparison.md # Comparison type config + template (+ .guidance.md)
│ ├── project.md # Project (Vorhaben) type config + template, no guidance file
@@ -523,7 +524,7 @@ This wiki is tailored for IT work with:
- **Entity types** specific to software development and systems
- **Relationship types** like `hängt ab von`, `verwendet`, `implementiert` - the vocabulary is in
[kb/CONVENTIONS.md](kb/CONVENTIONS.md), because it is this instance's rather than the stack's
- **Templates** for projects, systems, tools, technologies, ADRs
- **Templates** per page type, and per subtype where its pages need a shape of their own - a person, a decision record (ADR)
- **Guidelines** for documenting technical decisions
- **Cross-reference patterns** for code and architecture
+1 -1
View File
@@ -1 +1 @@
8.0.0-beta.30
8.0.0-beta.31
+8 -4
View File
@@ -127,8 +127,8 @@ stack-owned file (`types/<name>.guidance.md`) holding exactly the half that used
when to use the type, when not to, and mechanism-level advice that holds for every instance. That
file ships verbatim and upgrades like any other machinery file, whether or not the type-spec that
links it has ever been adopted. `types/type-spec.md` §§ "Who owns a type-spec" and "Anatomy of a
type" hold the current shape; `tools/wikitool types describe <name>` composes both files into one
answer, so an agent asking for a type's contract never needs to know it comes from more than one
type" hold the current shape; `tools/wikitool types describe <name>` composes the type-spec and its
guidance into one answer, so an agent asking for a type's contract never needs to know it comes from more than one
file. An instance that adopted its type-specs before this split existed takes it as an *offered*
migration rather than something an upgrade applies on its own - the same reasoning as any other
instance-owned file in the middle category below, spelled out for this one case because it is the
@@ -149,8 +149,12 @@ becomes visible once an upgrade is a command rather than a hand-run copy:
kb` type-spec's optional `types/<name>.guidance.md` sits: verbatim, even though the type-spec
it documents (below) is not.
- **`.template`-sourced files** - `USER.md`, `SOUL.md`, `kb/CONVENTIONS.md`, each
`kb/<name>/COLLECTION.md`, `ENVIRONMENT.md`, the `root: kb` type-specs - are never written by
an upgrade at all. The distribution ships only the `.template` beside them, so the filled file
`kb/<name>/COLLECTION.md`, `ENVIRONMENT.md`, the `root: kb` type-specs and their subtype
templates `types/<name>.<value>.md` - are never written by an upgrade at all. A subtype
template is the guidance split run the other way: the file boundary cut once more, this time
to give page material a file of its own, and page material belongs to the instance, so it
lands here rather than among the verbatim files - and because it is its own file, a release
can ship a new one without touching an adopted type-spec. The distribution ships only the `.template` beside them, so the filled file
is out of reach by construction rather than by a rule someone has to remember. The same
property has a second face on the way in: when a release ships a `.template` for a type or
collection the instance does not have *yet*, the upgrade writes the template and stops - it
+5
View File
@@ -102,6 +102,11 @@ choice of an instance's starting vocabulary - that is
4. **Record it** with `tools/wikitool log append`, describing what moved and why, the same way
any other corpus change is logged.
5. **A newly added value scaffolds with the type's `## Template` block** until it has a subtype
template of its own. Whether its pages want a different skeleton is a separate judgment, made
against the pages once they exist, by the same ≥3-page rule as above:
[subtype-templates.md](subtype-templates.md).
## Decision points
- **The catch-all is empty and lint reports nothing?** Nothing to do - an empty catch-all is the
+6
View File
@@ -146,6 +146,12 @@ one it needs before that.
*this* instance writes, so they belong to it: frontmatter, template and language may all
be rewritten. `instruction`, `lint-report`, `type-spec` and `type-guidance` describe stack
artifacts and arrive unchanged - none of them ships as a `.template` in the first place.
The subtype templates beside them (`types/entity.person.md`, `types/concept.decision.md`:
the page skeleton `wikitool new` uses for that one subtype instead of the type's
`## Template` block) are adopted the same way and are page material like that block, so
they are translated with it. Whether this instance wants further ones is a question for
later, once pages exist to show it - [subtype-templates.md](subtype-templates.md), not
part of this setup.
A `root: kb` type-spec's generic authoring guidance (when to use the type, when not to)
is not part of this adoption at all: it lives in a sibling `types/<name>.guidance.md`
+100
View File
@@ -0,0 +1,100 @@
---
type: types/instruction.md
name: subtype-templates
description: Interview the corpus and the user for page skeletons per subtype - find subtypes whose pages systematically depart from their type's template, propose a types/<type>.<value>.md for each, and write the ones the user accepts.
manual: true
---
# Find the subtypes that need a page skeleton of their own
A page type carries one `## Template` block for every value of its subtype field, and for most
values that is enough. Where a subtype needs a different page shape - a person is not described by
a version and a repository, a decision wants its context, alternatives and consequences - authors
rebuild every scaffolded page by hand, and the corpus shows it. A **subtype template**,
`types/<type>.<value>.md`, gives that subtype its own skeleton: `wikitool new` takes it instead of
the block whenever the page's subtype field holds `<value>`. The file's shape and the checks on it
are [types/type-spec.md](../types/type-spec.md) § "Anatomy of a type".
This instruction is the interview that decides which subtypes get one. It reads the pages first
and proposes from them, because a template written ahead of the material is a guess every later
page is scaffolded into.
## When to run
- The user asks for it, by name or by describing the symptom: pages of one kind keep being
rebuilt after `wikitool new`.
- After [evolve-subtypes.md](evolve-subtypes.md) added a value and its pages have accumulated.
- After an upgrade delivered a subtype template as `.template` beside a type already adopted, and
the user wants to know whether to take it.
Not for changing the `## Template` block every subtype shares - that is an edit to the type-spec
itself. Not for adding a subtype value - that is [evolve-subtypes.md](evolve-subtypes.md).
## Steps
1. **List what there is to examine.** Every type-spec with a `subtype_field:`, every value its
schema allows, and which skeleton each value scaffolds today:
```bash
tools/wikitool types list
tools/wikitool types describe <type>
ls types/
```
A value scaffolds from `types/<type>.<value>.md` if that file exists, otherwise from the
type-spec's `## Template` block.
2. **Hold each value's pages against the skeleton they were scaffolded from.** Find them and read
their `##` headings:
```bash
tools/wikitool search --field <subtype_field>=<value>
```
What counts is a departure several pages share: the same template section emptied or deleted,
the same section added under the same or an equivalent name, the same section replaced by
another. One page's own extra section is that page's business.
3. **Apply the admission threshold of [evolve-subtypes.md](evolve-subtypes.md): at least three
pages of one subtype departing the same way.** A template is admitted after the material has
shown its shape, never in expectation of it. A smaller count is only ever an explicit exception
the user names, never a reason to lower the threshold.
4. **Offer a shipped template as the starting point where one is lying ready.** A
`types/<type>.<value>.md.template` that was never adopted is the stack's proposal for that
subtype. Compare it with what the pages actually do, and propose it unchanged, adapted, or not
at all.
5. **Put each candidate to the user, one at a time:** which pages, what they share, and a draft
of the template in the KB language (`kb/CONVENTIONS.md` `language:`) - the same variables and
filters the `## Template` block uses ([types/type-spec.md](../types/type-spec.md) § "Template
variables"), no frontmatter, no fence, no tool-owned section. The user decides per candidate:
accept, change, or reject.
6. **Write each accepted template, then check it:**
```bash
tools/wikitool dist adopt types/<type>.<value>.md.template # only where step 4 took the shipped one unchanged
tools/wikitool docs verify
```
Otherwise write `types/<type>.<value>.md` directly. `docs verify` refuses a file whose type has
no `subtype_field:`, whose value the schema does not allow, or which carries frontmatter.
7. **Leave the existing pages as they are.** A template acts only on the next `wikitool new`;
reshaping existing pages to match it is ordinary page editing, decided per page, and not part
of this procedure.
## Decision points
- **The departures differ from page to page?** Then no template is warranted: a skeleton that
fits none of the pages well is not better than the one they already rebuild.
- **All subtypes of a type depart the same way?** The `## Template` block itself is wrong, and
editing it is the fix - not one subtype template per value.
- **The value is `guidance`?** It cannot have a template: `types/<type>.guidance.md` is always the
type's guidance file. Rename the value instead, through [evolve-subtypes.md](evolve-subtypes.md).
## Scope
Covers every page type that declares `subtype_field:` - in the shipped specs `entity`, `concept`,
`source` and `project`. A type without one, such as `comparison`, has a single skeleton by
construction. Does not move, rename or rewrite a page.
+6 -1
View File
@@ -110,7 +110,12 @@ a further checkout of this one ([bootstrap.md](bootstrap.md)).
- adopting it (copying it to the unsuffixed name) is the instance's own act, and where the
stack *requires* that type the omission is what step 9's `docs verify` refuses. Step 2's
**Breaking Change:** line says when a release is in that shape; step 9 has the repair.
`locally changed` is step 6. `removed` matters only if `--prune` is wanted, which is optional
One more shape of `new` needs no decision at all: a subtype template
`types/<type>.<value>.md.template` beside a type this instance has already adopted. Adopt it
(`tools/wikitool dist adopt types/<type>.<value>.md.template`) and `wikitool new` scaffolds
pages of that subtype from it; leave it lying and they keep the type's `## Template` block.
Both are valid - [subtype-templates.md](subtype-templates.md) is how to judge whether the
corpus wants it. `locally changed` is step 6. `removed` matters only if `--prune` is wanted, which is optional
and never required.
6. **Only if a file is reported as locally changed: decide whose file it is, then reconcile it.**
+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
+27
View File
@@ -0,0 +1,27 @@
# {name}
**Typ:** {concept_type|capitalize}
## Definition
TODO: Die Entscheidung in einem Satz - was gilt, und wofür.
## Kontext
TODO: Die Lage, die eine Entscheidung verlangte: das Problem, die Zwänge, wer entschieden hat
## Entscheidung
TODO: Was entschieden wurde, und das Argument, das den Ausschlag gab
## Alternativen
- TODO: Verworfene Alternative - und warum sie verworfen wurde
## Konsequenzen
- TODO: Was aus der Entscheidung folgt, einschließlich ihrer Kosten
## Status
TODO: gilt, abgelöst durch [[Nachfolgende Entscheidung]], zurückgenommen usw. - mit Datum
+2 -2
View File
@@ -6,12 +6,12 @@ description: When to write an entity page instead of a neighboring type, and how
# Entity Guidance
`entity` is the type for concrete things: projects, systems, tools, technologies or people.
`entity` is the type for concrete things: codebases, systems, tools, technologies or people.
Entities are the primary building blocks of the knowledge graph.
## When to use
- Representing a software project or codebase
- Representing a codebase - a repository, library or application as code
- Documenting a running system, service or infrastructure component
- Describing a CLI tool, utility or software library
- Recording information about a language, a framework or a protocol
+1 -1
View File
@@ -1,7 +1,7 @@
---
type: types/type-spec.md
name: entity
description: Base type for entity pages - projects, systems, tools, technologies or people
description: Base type for entity pages - codebases, systems, tools, technologies or people
schema: types/entity.schema.yaml
subtype_field: entity_type
base_dir: entities
+21
View File
@@ -0,0 +1,21 @@
# {name}
**Typ:** {entity_type|capitalize}
## Beschreibung
TODO: 1-2 Absätze dazu, wer diese Person oder Organisation ist und wofür sie in diesem Wiki steht.
## Kerndaten
- **Rolle:** TODO (falls zutreffend)
- **Zugehörigkeit:** TODO (falls zutreffend)
- **Wirkungszeitraum:** TODO (falls zutreffend)
## Beiträge
TODO: Was diese Person oder Organisation geschaffen, vertreten oder beeinflusst hat - mit Verweisen auf die betreffenden Seiten
## Historie
- [{today}] - Page created via wikitool
+22 -7
View File
@@ -67,7 +67,7 @@ frontmatter before anyone drew it:
| Type-spec | Describes | Owned by | Ships as |
|---|---|---|---|
| `root: kb` (`entity`, `concept`, `source`, `comparison`, `project`) | A page **this instance** writes | The instance | `types/<name>.md.template` plus its `.schema.yaml.template`, adopted by a rename |
| `root: kb` (`entity`, `concept`, `source`, `comparison`, `project`) | A page **this instance** writes | The instance | `types/<name>.md.template` plus its `.schema.yaml.template` and any subtype template's `types/<name>.<value>.md.template`, adopted by a rename |
| `root: repo` (`instruction`), no `base_dir` (`lint-report`), and `type-spec`/`type-guidance` themselves | A stack artifact | The stack | Verbatim |
A page type-spec's frontmatter configuration and its `## Template` body are therefore the
@@ -124,13 +124,23 @@ Python. Adding a type must require no code change.
### Anatomy of a type
Each type is at least two files, and a `root: kb` type may be three:
Each type is at least two files, and a `root: kb` type may have more:
| File | Owns |
|------|------|
| `types/<name>.md` | This instance's configuration: its frontmatter fields as this schema requires them, and the `## Template` block used to scaffold new pages. For a type with no `guidance:` (below), its own prose also carries the authoring contract - when to use the type, when not to |
| `types/<name>.schema.yaml` | The machine-checkable half: fields, types, enums, defaults, required-ness, `additionalProperties: false` |
| `types/<name>.guidance.md` (optional, `root: kb` only) | The stack-owned authoring contract: when to use the type, when not to, and mechanism-level advice that holds regardless of this instance's own enum values or template text - linked from the type-spec's own `guidance:` field. `types/type-guidance.md` is its contract |
| `types/<name>.<value>.md` (optional, any number, only for a type with `subtype_field:`) | A **subtype template**: the page skeleton for pages whose subtype field holds `<value>`, where that subtype needs a different page shape than the `## Template` block gives - a person is not described by a version and a repository. Plain markdown in the KB language, no frontmatter, no fence: the whole file is the skeleton. Its name is its only declaration - nothing in the type-spec lists it. Owned like the type-spec it sits beside, so a `root: kb` type's subtype templates ship as `.template` too |
`docs verify` holds a subtype template to three things: `types/<name>.md` beside it is a
type-spec declaring `subtype_field:`, `<value>` is one that field's schema enum allows (where it
has an enum), and the file carries no frontmatter. A misspelt name would otherwise be a template
nothing ever reads, and nobody would notice. **`guidance` is reserved** - `types/<name>.guidance.md`
is always the guidance file, so a subtype value `guidance` can have no template of its own. A
subtype template never gets a table-of-contents region, whatever its length: it is copied into
every page scaffolded from it. When an instance needs one is
[instructions/subtype-templates.md](../instructions/subtype-templates.md).
**A `default:` is materialized by `wikitool new` only for a field the schema also lists in
`required:`.** On an optional field, `default:` documents what a reader should assume when the
@@ -141,9 +151,9 @@ newly scaffolded instruction regardless.
This file is the self-referential root contract every type-spec is validated against, and
`type-guidance.md` is validated against it the same way `lint-report.md` is - itself a
non-instantiable, contract-only type. `tools/wikitool types describe <name>` composes all of a
type's files into one answer regardless of how many there are; an agent asking for a type's
contract never needs to know it came from more than one file.
non-instantiable, contract-only type. `tools/wikitool types describe <name>` composes the type-spec, its schema and its guidance into
one answer; an agent asking for a type's contract never needs to know it came from more than one
file. Subtype templates are not part of that answer - only `wikitool new` reads them.
### Placement frontmatter
@@ -206,13 +216,18 @@ No Python change is needed at any step; `wikitool` discovers types by scanning t
### Template variables
The `## Template` block is filled from the page's own frontmatter, plus `{name}` and
`wikitool new` picks one skeleton per page: `types/<name>.<value>.md` when the page's subtype
field holds `<value>` and that file exists, otherwise the `## Template` block. A subtype template
replaces the block whole - nothing is merged - so every subtype without a file of its own, and
every type without a `subtype_field:`, scaffolds exactly what the block says.
Either skeleton is filled from the page's own frontmatter, plus `{name}` and
`{today}`. Filters render structured fields: `{entities|bullets}`, `{tags|join}`,
`{entity_type|capitalize}`, `{entities|table_header}`, `{entities|table_sep}`,
`{entities|table_cells}`. `{field|literal text}` falls back to the literal when the field is
absent.
**A template never contains a tool-owned region.** The links and footnotes regions are generated
**A template - block or subtype file - never contains a tool-owned region.** The links and footnotes regions are generated
between markers by `xref` and `cite`, rendered from frontmatter, and re-rendered on every write -
so scaffolding them would create a section an author is forbidden to edit and the tool would
replace anyway. See `tools/chemenu/blocks.py`.