types/: Seiten-Type-Spec-Anleitungsprosa in stackeigene guidance-Datei ausgelagert (schliesst #104)
CI / verify (push) Failing after 43s
Release / release (push) Successful in 34s

Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- docs/ownership-and-templates.md
- instructions/migrations/6.0.0-type-guidance-split.md
- instructions/setup-instance.md
- tools/CONTRACT.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/types_cmd.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_dist_upgrade.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/tests/test_types_cmd.py
- tools/chemenu/type_resolver.py
- tools/chemenu/types_core.py
- types/comparison.guidance.md
- types/comparison.md
- types/concept.guidance.md
- types/concept.md
- types/entity.guidance.md
- types/entity.md
- types/source.guidance.md
- types/source.md
- types/type-guidance.md
- types/type-guidance.schema.yaml
- types/type-spec.md
This commit is contained in:
2026-09-15 18:23:38 +02:00
parent 90ce41964f
commit d49513bda6
26 changed files with 789 additions and 238 deletions
+1 -1
View File
@@ -166,7 +166,7 @@ tools/wikitool <command> --help
| Command | Purpose |
|---------|---------|
| `types list [--json]` | List every type-spec under `types/` (name, schema path, subtype field, description) - discover what page types exist without reading `types/*.md` directly |
| `types describe <name> [--json]` | Print one type's full contract: required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body. A type-spec over the `docs toc` threshold carries a generated table-of-contents region; it is stripped from this output rather than echoed, since the whole body is being handed over and a navigation aid into it would be noise |
| `types describe <name> [--json]` | Print one type's full contract: required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body - composed with the stack-owned `types/<name>.guidance.md` where the type-spec declares `guidance:` (`--json` reports it separately as `guidance`/`guidance_path`, absent for a type with none), so a `root: kb` type's contract reads as one answer even though it may live in two files. A type-spec (or its guidance file) over the `docs toc` threshold carries a generated table-of-contents region; it is stripped from this output rather than echoed, since the whole body is being handed over and a navigation aid into it would be noise |
| `instructions sync [--force]` | Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and delete published skills whose source is gone. Both targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. Re-running is also how a drifted copy is repaired: the source always wins. `--force` is required only to replace a target directory that is not a published skill at all (no `SKILL.md` in it) |
| `instructions verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, no `SKILL.md` carries a relative markdown link (`sync` copies it to a different depth than the source, so a `SKILL.md` references a target as a repo-root-relative plain path instead - see [instructions/CONTRACT.md](../instructions/CONTRACT.md) § "A skill's outbound reference is a plain path, not a link"), every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see [instructions/CONTRACT.md](../instructions/CONTRACT.md)). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout |
| `instructions list [--json]` | List the flat instructions with their descriptions. This is how the layer is discovered; `search` deliberately covers `kb/` only |
+35 -4
View File
@@ -338,6 +338,32 @@ def instance_owned_type_stems() -> set[str]:
return stems
# The suffix a type-spec's own two files carry - `<stem>.md` and
# `<stem>.schema.yaml` - as opposed to a sibling file that merely starts with
# the same stem, such as `<stem>.guidance.md` (Gitea #104). Checked as an
# exact suffix rather than by splitting on the first `.`, which is what let
# `entity.guidance.md` be mistaken for the `entity` type-spec's own file
# before this existed - a stack-owned file re-keyed as though it were the
# instance's `.template` to adopt, and flagged as a leak by the other call
# site for not being one.
_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)."""
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"):
return name[: -len(".md")]
return None
def _plan_types() -> dict[str, PlannedFile]:
"""`types/`, with the page type-specs re-keyed as templates.
@@ -347,6 +373,11 @@ def _plan_types() -> dict[str, PlannedFile]:
to be adopted before it counts. A type-spec's `.schema.yaml` travels with
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.
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
of this re-keying despite sharing the type-spec's own stem.
"""
plan = _copy_tree(config.TYPES_DIR, "types", frozenset())
stems = instance_owned_type_stems()
@@ -355,9 +386,8 @@ def _plan_types() -> dict[str, PlannedFile]:
rekeyed: dict[str, PlannedFile] = {}
for relative, planned in plan.items():
name = relative.rsplit("/", 1)[-1]
stem = name.split(".", 1)[0]
if stem in stems:
stem = _owned_type_stem(relative)
if stem is not None and stem in stems:
rekeyed[f"{relative}.template"] = planned
else:
rekeyed[relative] = planned
@@ -512,7 +542,8 @@ def find_leaks(plan: dict[str, PlannedFile]) -> list[str]:
elif (
relative.startswith("types/")
and not relative.endswith(".template")
and name.split(".", 1)[0] in owned_types
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)")
elif relative.startswith("instructions/dev/"):
+13 -6
View File
@@ -55,7 +55,10 @@ def describe_type_command(
"""Print one type's full contract: frontmatter fields (required/optional,
with enums where declared), its subtype field if any, and its authoring
body - the same information an LLM would otherwise gather by reading the
raw type-spec and `.schema.yaml` files directly."""
raw type-spec and `.schema.yaml` files directly. Where the type-spec
declares `guidance:`, that stack-owned file's prose is composed in ahead
of the type-spec's own body, so a `root: kb` type's contract still reads
as one answer even though it lives in two files (Gitea #104)."""
try:
described = describe_type(name)
except UnknownType as exc:
@@ -89,9 +92,13 @@ def describe_type_command(
typer.echo("")
typer.echo("## Authoring guidance")
# A type-spec over 100 lines carries a generated table-of-contents region
# (`chemenu/toc.py`), which serves whoever opens the file. Here it would be
# noise: this command already hands over the whole body, so there is
# nothing left for a navigation aid to navigate - only markers and a list
# of headings the reader is about to see anyway.
# A type-spec (and its guidance file) over 100 lines carries a generated
# table-of-contents region (`chemenu/toc.py`), which serves whoever opens
# the file directly. Here it would be noise: this command already hands
# over the whole body, so there is nothing left for a navigation aid to
# navigate - only markers and a list of headings the reader is about to
# see anyway.
if described["guidance"]:
typer.echo(toc.strip_region(described["guidance"]))
typer.echo("")
typer.echo(toc.strip_region(described["body"]))
+24 -1
View File
@@ -81,7 +81,16 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
# the instance's; `instruction` writes into the repo and is the stack's.
(types_dir / "entity.md").write_text(
"---\ntype: types/type-spec.md\nname: entity\ndescription: d\n"
"schema: types/entity.schema.yaml\nbase_dir: entities\n---\n\n# Entity\n",
"schema: types/entity.schema.yaml\nbase_dir: entities\n"
"guidance: types/entity.guidance.md\n---\n\n# Entity\n",
encoding="utf-8",
)
# entity's guidance: stack-owned even though entity.md itself is
# instance-owned - the file `_owned_type_stem` must not mistake for
# entity's own `.md`/`.schema.yaml` despite sharing its stem (Gitea #104).
(types_dir / "entity.guidance.md").write_text(
"---\ntype: types/type-guidance.md\nname: entity\ndescription: When to use entity.\n"
"---\n\n# Entity Guidance\n",
encoding="utf-8",
)
(types_dir / "instruction.md").write_text(
@@ -394,6 +403,20 @@ def test_page_type_specs_ship_as_templates_and_stack_types_do_not(repo, monkeypa
assert "types/instruction.md.template" not in plan
def test_guidance_file_ships_verbatim_beside_a_templated_type_spec(repo, monkeypatch):
"""Gitea #104: `types/entity.guidance.md` is stack-owned even though
`types/entity.md` (same stem) is instance-owned - it must cross like
`types/instruction.md` above, never re-keyed as though it were the
type-spec's own `.template`."""
from chemenu.type_resolver import resolver
monkeypatch.setattr(resolver, "_repo_root", config.ROOT)
plan = dist_cmd.build_plan()
assert "types/entity.guidance.md" in plan
assert "types/entity.guidance.md.template" 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."""
+50
View File
@@ -223,6 +223,56 @@ def test_seeded_once_paths_are_never_written_even_if_the_release_stamp_lists_the
assert not (instance / preserved).exists()
# --- a root:kb type-spec's guidance half upgrades like any other file -------
#
# Gitea #104: before the split, `types/<name>.md` carried both the
# instance-owned template and the stack-owned authoring prose in one file, so
# an instance that had adopted it (renamed the `.template`) never received a
# prose improvement again - `dist upgrade` only ever wrote the `.template`
# beside the adopted file, never the file itself. Splitting the prose into a
# sibling `.guidance.md` that ships verbatim (never `.template`-sourced) means
# it upgrades through the ordinary unchanged/new path below, even though the
# type-spec it documents is never in the stamp at all and therefore never
# touched.
def test_upgrade_writes_improved_guidance_prose_over_an_adopted_type_spec(instance, tmp_path):
(instance / "types").mkdir()
(instance / "types" / "entity.md").write_text(
# Adopted from `types/entity.md.template` at some earlier setup - this
# file was never part of any release stamp and `dist upgrade` must
# never touch it.
"---\ntype: types/type-spec.md\nname: entity\ndescription: d\n"
"schema: types/entity.schema.yaml\nbase_dir: entities\n"
"guidance: types/entity.guidance.md\n---\n\n# Entity\n",
encoding="utf-8",
)
(instance / "types" / "entity.guidance.md").write_text("old guidance\n", encoding="utf-8")
stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
stamp["files"]["types/entity.guidance.md"] = _digest("old guidance\n")
(instance / version_mod.RELEASE_STAMP_FILENAME).write_text(json.dumps(stamp), encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0",
{
"AGENTS.md": "core\n",
"tools/wikitool": "#!/bin/sh\n",
"types/entity.guidance.md": "improved guidance\n",
},
)
dist_cmd.run_upgrade(release)
assert (instance / "types" / "entity.guidance.md").read_text(encoding="utf-8") == (
"improved guidance\n"
)
# The adopted type-spec itself was never in either stamp, so it is
# completely untouched by the upgrade.
assert "guidance: types/entity.guidance.md" in (
instance / "types" / "entity.md"
).read_text(encoding="utf-8")
# --- migration chain: reported, never run -----------------------------------
+49
View File
@@ -64,6 +64,54 @@ def test_get_page_ref_fields_defaults_to_empty():
assert resolver.get_page_ref_fields("types/type-spec.md") == []
def test_get_guidance_reads_the_linked_type_guidance_file():
"""Gitea #104: a root:kb type-spec's generic authoring prose lives in a
separate, stack-owned `<name>.guidance.md`, linked via `guidance:` -
unlike `schema:`, this one's absence is the common case (an instance
typed for itself), not an error."""
guidance = resolver.get_guidance("types/entity.md")
assert guidance is not None
assert "When to use" in guidance or "When NOT to use" in guidance
def test_get_guidance_is_none_when_the_type_spec_declares_none():
"""`instruction` and `lint-report` describe stack artifacts and have
never carried a `guidance:` field - this is the type with no linked
guidance at all, not a broken link."""
assert resolver.get_guidance("types/instruction.md") is None
assert resolver.get_guidance("types/lint-report.md") is None
def test_extract_template_reads_only_the_type_spec_never_the_guidance_file(tmp_path):
"""`wikitool new` must keep exactly one load path for its scaffold - the
first ```markdown block of `types/<name>.md` itself - even though the
type-spec now optionally links a second file. A ```markdown block placed
in the guidance file instead must never be picked up."""
from chemenu.type_resolver import TypeResolver
types_dir = tmp_path / "types"
types_dir.mkdir()
(types_dir / "widget.md").write_text(
# Self-referential `type:` (like the badtype fixture above), purely so
# this narrow fixture needs no real `types/type-spec.md` on disk -
# `extract_template` never reads the `type:` field at all.
"---\ntype: types/widget.md\nname: widget\ndescription: A widget type.\n"
"schema: null\nbase_dir: widgets\nguidance: types/widget.guidance.md\n---\n\n"
"# Widget\n\n## Template\n\n```markdown\n# {name}\n```\n",
encoding="utf-8",
)
(types_dir / "widget.guidance.md").write_text(
"---\ntype: types/type-guidance.md\nname: widget\ndescription: Guidance for widget.\n"
"---\n\n# Widget Guidance\n\n```markdown\nTHIS MUST NEVER BE THE SCAFFOLD\n```\n",
encoding="utf-8",
)
bad_resolver = TypeResolver(repo_root=tmp_path)
type_spec = bad_resolver.load_type_spec("types/widget.md")
template = bad_resolver.extract_template(type_spec)
assert template == "# {name}"
assert "THIS MUST NEVER BE THE SCAFFOLD" not in template
def test_get_capture_fields_reads_the_type_spec():
"""`fidelity`/`authority` are fixed once, at capture time (Gitea #67) -
`raw accept`, `new source` and `touch` all read the field list from here
@@ -221,6 +269,7 @@ def test_list_type_specs_finds_every_type_spec():
names = {fm.get("name") for _, fm in resolver.list_type_specs()}
assert names == {
"type-spec", "entity", "concept", "source", "comparison", "lint-report", "instruction",
"type-guidance",
}
+37
View File
@@ -16,6 +16,7 @@ def test_types_list_finds_all_current_type_specs():
names = {row["name"] for row in rows}
assert names == {
"type-spec", "entity", "concept", "source", "comparison", "lint-report", "instruction",
"type-guidance",
}
@@ -52,6 +53,42 @@ def test_types_describe_entity_reports_schema_and_body():
assert "wikitool:footnotes" not in data["body"]
def test_types_describe_entity_composes_guidance_and_body_separately():
"""Gitea #104: the generic authoring prose (When to use / When NOT to
use) now lives in the stack-owned `entity.guidance.md`, reported under
its own JSON keys, while `body` stays exactly what it was - the
instance-owned type-spec's own text (frontmatter table + template)."""
result = runner.invoke(app, ["types", "describe", "entity", "--json"])
assert result.exit_code == 0, result.output
import json
data = json.loads(result.output)
assert data["guidance_path"] == "types/entity.guidance.md"
assert data["guidance"] is not None
assert "## When to use" in data["guidance"]
assert "## When to use" not in data["body"]
assert "## Kerndaten" in data["body"]
def test_types_describe_composes_guidance_ahead_of_body_in_text_output():
result = runner.invoke(app, ["types", "describe", "entity"])
assert result.exit_code == 0, result.output
guidance_at = result.output.index("## When to use")
template_at = result.output.index("## Kerndaten")
assert guidance_at < template_at
def test_types_describe_a_type_with_no_guidance_omits_it_cleanly():
"""`instruction` describes a stack artifact and has never carried a
`guidance:` field - this must read exactly as it did before the split
existed, not print an empty section."""
result = runner.invoke(app, ["types", "describe", "instruction", "--json"])
assert result.exit_code == 0, result.output
import json
data = json.loads(result.output)
assert data["guidance"] is None
assert data["guidance_path"] is None
def test_types_describe_unknown_name_fails_cleanly():
result = runner.invoke(app, ["types", "describe", "bogus"])
assert result.exit_code != 0
+27
View File
@@ -436,6 +436,33 @@ class TypeResolver:
type_spec = self.load_type_spec(type_path, source_file)
return type_spec['frontmatter'].get('title_prefix') or ""
def get_guidance(self, type_path: str, source_file: Path = None) -> Optional[str]:
"""Return the stack-owned guidance body a type-spec's `guidance:`
field points to, or None if it declares none - the common case for a
type an instance writes entirely for itself (Gitea #104).
The linked file is loaded as a type-spec-shaped document (`type:
types/type-guidance.md`, `name:`, `description:`) the same way
`schema:` is resolved, so a broken link or a malformed guidance file
fails the same way a broken `schema:` would rather than silently
returning nothing.
Args:
type_path: The type path to resolve, e.g. 'types/entity.md'
source_file: The source file path (for relative type resolution)
Raises:
ValueError: If the type path cannot be resolved, or `guidance:`
names a path that cannot be resolved or does not validate as
a type-guidance document.
"""
type_spec = self.load_type_spec(type_path, source_file)
guidance_path = type_spec['frontmatter'].get('guidance')
if not guidance_path:
return None
guidance_spec = self.load_type_spec(guidance_path, type_spec['path'])
return guidance_spec['body']
def get_page_ref_fields(self, type_path: str, source_file: Path = None) -> list:
"""Return the frontmatter fields whose entries are wiki page titles
(e.g. `['related', 'sources']` for an entity), as declared by the
+7
View File
@@ -59,6 +59,8 @@ def describe_type(name: str) -> Dict[str, Any]:
type_spec = resolver.load_type_spec(type_path)
frontmatter = type_spec["frontmatter"]
schema = resolver.get_schema(type_path)
guidance_path = frontmatter.get("guidance")
guidance_body = resolver.get_guidance(type_path) if guidance_path else None
fields: list[Dict[str, Any]] = []
if schema is not None:
@@ -86,5 +88,10 @@ def describe_type(name: str) -> Dict[str, Any]:
"base_dir": frontmatter.get("base_dir"),
"title_prefix": frontmatter.get("title_prefix"),
"fields": fields,
# `body` stays the type-spec's own body, unchanged - additive fields
# below it keep the MCP wire contract readable for an older client
# that has never heard of the guidance split (Gitea #104).
"body": type_spec["body"].strip(),
"guidance_path": guidance_path,
"guidance": guidance_body.strip() if guidance_body is not None else None,
}