diff --git a/AGENTS.md b/AGENTS.md index b64604b..db82736 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//SKILL.md` | Agents | By the harness, once published | | `types/.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/.guidance.md` | Agents + validator | Via `tools/wikitool types describe`, composed with the `types/.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/..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/.md` | Agents and humans | By link, or on explicit request - never automatically, and never as instruction | | `INDEX.md` | Both | Generated - never hand-edited | diff --git a/CHANGES.md b/CHANGES.md index 2b63faa..f6331f5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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/..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/..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 +### new: a subtype gets its own page skeleton from types/..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/..md` beside the type-spec is the skeleton for pages whose +subtype field holds ``: 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 +`.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/.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 ` 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 diff --git a/README.md b/README.md index a74b1ed..86f2be9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/VERSION b/VERSION index 0bad119..f0d46d3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.0.0-beta.30 +8.0.0-beta.31 diff --git a/docs/ownership-and-templates.md b/docs/ownership-and-templates.md index f374c8e..9b83feb 100644 --- a/docs/ownership-and-templates.md +++ b/docs/ownership-and-templates.md @@ -127,8 +127,8 @@ stack-owned file (`types/.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 ` 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 ` 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/.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//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//COLLECTION.md`, `ENVIRONMENT.md`, the `root: kb` type-specs and their subtype + templates `types/..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 diff --git a/instructions/evolve-subtypes.md b/instructions/evolve-subtypes.md index ddaa39f..e688b60 100644 --- a/instructions/evolve-subtypes.md +++ b/instructions/evolve-subtypes.md @@ -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 diff --git a/instructions/setup-instance.md b/instructions/setup-instance.md index 2bbd1b9..ebcfed8 100644 --- a/instructions/setup-instance.md +++ b/instructions/setup-instance.md @@ -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/.guidance.md` diff --git a/instructions/subtype-templates.md b/instructions/subtype-templates.md new file mode 100644 index 0000000..420465b --- /dev/null +++ b/instructions/subtype-templates.md @@ -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/..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/..md`, gives that subtype its own skeleton: `wikitool new` takes it instead of +the block whenever the page's subtype field holds ``. 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 + ls types/ + ``` + + A value scaffolds from `types/..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 = + ``` + + 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/..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/..md.template # only where step 4 took the shipped one unchanged + tools/wikitool docs verify + ``` + + Otherwise write `types/..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/.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. diff --git a/instructions/upgrade-instance.md b/instructions/upgrade-instance.md index ddab579..e9ab380 100644 --- a/instructions/upgrade-instance.md +++ b/instructions/upgrade-instance.md @@ -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/..md.template` beside a type this instance has already adopted. Adopt it + (`tools/wikitool dist adopt types/..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.** diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index dae8ac2..68d8196 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -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/..md` when that file exists, `` 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 `` region of `tools/CONTRACT.md` is stale - 1 A type-spec's own frontmatter fails its schema +- 1 A subtype template `types/..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 `` 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/..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 `` 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 `` 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/..md` (any `` 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 `` 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 `.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/..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 `` 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/..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//COLLECTION.md.template`. The filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb//COLLECTION.md`/`types/.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//COLLECTION.md.template`. The filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb//COLLECTION.md`/`types/.md`/`types/..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 `.template` to `` byte for byte; the template stays where it is, as the base the next `dist upgrade` compares against. -- Without a path: every `kb//COLLECTION.md.template` and every `types/*.template` (the `root: kb` page type-specs and their schemas). +- Without a path: every `kb//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. diff --git a/tools/chemenu/commands/dist_cmd.py b/tools/chemenu/commands/dist_cmd.py index f15939c..4b9dbdd 100644 --- a/tools/chemenu/commands/dist_cmd.py +++ b/tools/chemenu/commands/dist_cmd.py @@ -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 `.md` or - `.schema.yaml` - `None` for anything else under `types/`, - including a `.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 `.md` or + `.schema.yaml`, or one of its subtype templates `..md` + (Gitea #117) - `None` for anything else under `types/`, including a + `.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 `..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 `.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 `` 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/..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//COLLECTION.md.template`. The filled " "`USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb//COLLECTION.md`/" - "`types/.md` bind their instance; `find_leaks` refuses a plan carrying one.", + "`types/.md`/`types/..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 `.template` to `` byte for byte; the template stays where it is, as " "the base the next `dist upgrade` compares against.", "Without a path: every `kb//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 " diff --git a/tools/chemenu/commands/docs_verify.py b/tools/chemenu/commands/docs_verify.py index b159b35..482de24 100644 --- a/tools/chemenu/commands/docs_verify.py +++ b/tools/chemenu/commands/docs_verify.py @@ -602,6 +602,63 @@ def check_type_spec_frontmatter() -> list[str]: return issues +def check_subtype_templates() -> list[str]: + """Every subtype template `types/..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/.md` is no type-spec declaring a + `subtype_field:` (an orphan), `` 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 `.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/..md` (any `` 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/..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(): "`.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/..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 `##` " diff --git a/tools/chemenu/commands/new_page.py b/tools/chemenu/commands/new_page.py index 7483215..c0efe12 100644 --- a/tools/chemenu/commands/new_page.py +++ b/tools/chemenu/commands/new_page.py @@ -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/..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/..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/..md` when that file exists, `` 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"]) diff --git a/tools/chemenu/tests/test_dist_cmd.py b/tools/chemenu/tests/test_dist_cmd.py index eb7eafb..7dd3388 100644 --- a/tools/chemenu/tests/test_dist_cmd.py +++ b/tools/chemenu/tests/test_dist_cmd.py @@ -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() diff --git a/tools/chemenu/tests/test_docs_verify.py b/tools/chemenu/tests/test_docs_verify.py index 767dbcd..3e99158 100644 --- a/tools/chemenu/tests/test_docs_verify.py +++ b/tools/chemenu/tests/test_docs_verify.py @@ -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): + """`.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 == [] diff --git a/tools/chemenu/tests/test_new_page.py b/tools/chemenu/tests/test_new_page.py index 0a7867d..a6a6fc6 100644 --- a/tools/chemenu/tests/test_new_page.py +++ b/tools/chemenu/tests/test_new_page.py @@ -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")) diff --git a/tools/chemenu/tests/test_toc.py b/tools/chemenu/tests/test_toc.py index 34ca57d..030b520 100644 --- a/tools/chemenu/tests/test_toc.py +++ b/tools/chemenu/tests/test_toc.py @@ -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") diff --git a/tools/chemenu/tests/test_type_resolver.py b/tools/chemenu/tests/test_type_resolver.py index 04549e6..48d3261 100644 --- a/tools/chemenu/tests/test_type_resolver.py +++ b/tools/chemenu/tests/test_type_resolver.py @@ -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", + } diff --git a/tools/chemenu/toc.py b/tools/chemenu/toc.py index 3d2b8de..e6e019b 100644 --- a/tools/chemenu/toc.py +++ b/tools/chemenu/toc.py @@ -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 `.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/..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 ( diff --git a/tools/chemenu/type_resolver.py b/tools/chemenu/type_resolver.py index cdb23f4..0fb53f5 100644 --- a/tools/chemenu/type_resolver.py +++ b/tools/chemenu/type_resolver.py @@ -22,6 +22,38 @@ from chemenu import config from chemenu.frontmatter_io import normalize_dates, read_page, write_page +# The middle part of `types/.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/..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, `..md` with `` 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/.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/..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 diff --git a/types/concept.decision.md b/types/concept.decision.md new file mode 100644 index 0000000..f9ed817 --- /dev/null +++ b/types/concept.decision.md @@ -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 diff --git a/types/entity.guidance.md b/types/entity.guidance.md index fcc8e74..fc5cf9a 100644 --- a/types/entity.guidance.md +++ b/types/entity.guidance.md @@ -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 diff --git a/types/entity.md b/types/entity.md index 8cff927..469afcb 100644 --- a/types/entity.md +++ b/types/entity.md @@ -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 diff --git a/types/entity.person.md b/types/entity.person.md new file mode 100644 index 0000000..5849918 --- /dev/null +++ b/types/entity.person.md @@ -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 diff --git a/types/type-spec.md b/types/type-spec.md index f8f961d..d3a6fd9 100644 --- a/types/type-spec.md +++ b/types/type-spec.md @@ -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/.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/.md.template` plus its `.schema.yaml.template` and any subtype template's `types/..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/.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/.schema.yaml` | The machine-checkable half: fields, types, enums, defaults, required-ness, `additionalProperties: false` | | `types/.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/..md` (optional, any number, only for a type with `subtype_field:`) | A **subtype template**: the page skeleton for pages whose subtype field holds ``, 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/.md` beside it is a +type-spec declaring `subtype_field:`, `` 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/.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 ` 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 ` 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/..md` when the page's subtype +field holds `` 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`.