types/: Seiten-Type-Spec-Anleitungsprosa in stackeigene guidance-Datei ausgelagert (schliesst #104)
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:
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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/"):
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 -----------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user