stack: Typ project und Collection kb/gtd/ (#123)
Files changed: - CHANGES.md - README.md - VERSION - kb/CONTRACT.md - kb/CONVENTIONS.md - kb/entities/COLLECTION.md - kb/gtd/COLLECTION.md - kb/gtd/INDEX.md - kb/index.md - tools/CONTRACT.md - tools/chemenu/kb_collections.py - tools/chemenu/tests/test_conventions.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_kb_collections.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_type_resolver.py - tools/chemenu/tests/test_types_cmd.py - types/project.md - types/project.schema.yaml - types/type-spec.md
This commit is contained in:
1 parent
3c9d669729
commit
ee24b6e5b8
20 files changed
+507
-38
No files matched your search
+1
-1
@@ -170,7 +170,7 @@ tools/wikitool <command> --help
|
||||
| `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 |
|
||||
| `docs verify` | Check the docs that mirror the code: every CLI command documented in this file's own § Commands table and, separately, in its § Error contracts table (both directions, checked per table, so a row dropped from one is not hidden by the same name surviving in the other, and only a name's presence in a row is checked, never the rest of that row's text), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, every file under `types/` declaring `type: types/type-spec.md` validating against `types/type-spec.schema.yaml`, no pre-migration `type: entity` blocks left in the contracts, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent - and every relative markdown link in one of those same reference files resolving to a file that actually exists (a target's `#anchor` suffix is stripped first; code fences and inline code spans are masked before scanning, so a passage showing link syntax as an example is not mistaken for a real reference). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks |
|
||||
| `docs verify` | Check the docs that mirror the code: every CLI command documented in this file's own § Commands table and, separately, in its § Error contracts table (both directions, checked per table, so a row dropped from one is not hidden by the same name surviving in the other, and only a name's presence in a row is checked, never the rest of that row's text), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, every type the stack lists (currently `source` and `project`) having a type-spec of that name whose schema requires the field the stack list also names (`raw_files:`/`state:`), `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, every file under `types/` declaring `type: types/type-spec.md` validating against `types/type-spec.schema.yaml`, no pre-migration `type: entity` blocks left in the contracts, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent - and every relative markdown link in one of those same reference files resolving to a file that actually exists (a target's `#anchor` suffix is stripped first; code fences and inline code spans are masked before scanning, so a passage showing link syntax as an example is not mistaken for a real reference). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks |
|
||||
| `docs toc [--apply]` | Create, refresh or remove the generated table-of-contents region (`<!-- wikitool:toc -->` ... `<!-- /wikitool:toc -->`, placed after the title and before the first `##`) on every reference file over 100 lines, in the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full: `AGENTS.md`, every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md` file, every `types/*.md` type-spec, and every `docs/` page - each together with the `<name>.template` it ships as, where one exists. Computed from those categories rather than listed, so a file added later is in scope without a code change. A template is in scope because it is the same document one step earlier in its life: an instance adopts it by copying it back, so a region missing there is a region missing in the adopted file, which is how `kb/CONVENTIONS.md.template` came to grow past the threshold with no region and left every instance adopting it failing `docs verify` at the end of its own setup. `SKILL.md` is the one exception, and the same guidance is why: it places a skill body on the loading level that is read whole when the skill triggers, and aims its own TOC advice at the bundled reference files a skill points *at*. Human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`, `tools/README.md`) are out of scope because AGENTS.md § File naming says no agent loads them as instruction. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy |
|
||||
|
||||
### Telemetry
|
||||
|
||||
@@ -58,25 +58,35 @@ ANY_DESTINATION = "any"
|
||||
# and its schema requiring `raw_files:` - not the directory, not the title
|
||||
# prefix, and not a word of its prose or its template.
|
||||
#
|
||||
# That is the whole anchor, and it is deliberately this small: the four page
|
||||
# `project` is here for the same reason, one layer up: the weekly review
|
||||
# (`wikitool review`) asks `page.kind == "project"` and reads `state:` to tell
|
||||
# an ongoing initiative with no next action ("stalled") from one that is
|
||||
# `dormant`, `completed` or `abandoned` on purpose. Without a `project` type
|
||||
# declaring `state:` the review has nothing to join a tracker project against.
|
||||
#
|
||||
# That is the whole anchor, and it is deliberately this small: the page
|
||||
# type-specs belong to the instance (see types/type-spec.md), so anything more
|
||||
# would be the stack reaching into a file it does not own.
|
||||
STACK_REQUIRED_TYPES = ("source",)
|
||||
STACK_REQUIRED_TYPE_FIELDS = {"source": ("raw_files",)}
|
||||
STACK_REQUIRED_TYPES = ("source", "project")
|
||||
STACK_REQUIRED_TYPE_FIELDS = {"source": ("raw_files",), "project": ("state",)}
|
||||
|
||||
|
||||
def stack_required_collections() -> tuple[str, ...]:
|
||||
"""Collection names an instance may not rename or drop.
|
||||
def stack_required_collection_owners() -> dict[str, str]:
|
||||
"""Which stack-required type writes into each stack-required collection -
|
||||
`{collection name: type name}`.
|
||||
|
||||
**Derived, not listed.** The required collection is whichever one the
|
||||
required type writes into - so an instance that legitimately renames
|
||||
`kb/sources/` to something else, and says so in the type-spec's `base_dir:`,
|
||||
stays consistent instead of tripping a constant that hardcoded the old name.
|
||||
A second literal list would only be a copy that drifts.
|
||||
A second literal list would only be a copy that drifts. The one-to-many
|
||||
direction (several required types sharing a collection) picks the first
|
||||
type in `STACK_REQUIRED_TYPES` that claims it - two required types
|
||||
legitimately sharing one `base_dir:` is not a case that has come up.
|
||||
"""
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
names: list[str] = []
|
||||
owners: dict[str, str] = {}
|
||||
for type_name in STACK_REQUIRED_TYPES:
|
||||
try:
|
||||
type_path = resolver.find_type_by_name(type_name)
|
||||
@@ -88,8 +98,14 @@ def stack_required_collections() -> tuple[str, ...]:
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if base_dir:
|
||||
names.append(str(base_dir).strip("/"))
|
||||
return tuple(dict.fromkeys(names))
|
||||
owners.setdefault(str(base_dir).strip("/"), type_name)
|
||||
return owners
|
||||
|
||||
|
||||
def stack_required_collections() -> tuple[str, ...]:
|
||||
"""Collection names an instance may not rename or drop - see
|
||||
`stack_required_collection_owners()`, which this derives from."""
|
||||
return tuple(stack_required_collection_owners().keys())
|
||||
|
||||
|
||||
def iter_kb_collections(kb_dir: Path | None = None) -> list[Path]:
|
||||
@@ -226,15 +242,15 @@ def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
||||
root = kb_dir if kb_dir is not None else config.KB_DIR
|
||||
issues: list[str] = []
|
||||
|
||||
required = stack_required_collections()
|
||||
owners = stack_required_collection_owners()
|
||||
required = tuple(owners.keys())
|
||||
can_label = collections_that_can_carry_labelled_edges()
|
||||
present = {path.name for path in iter_kb_collections(root)}
|
||||
for name in required:
|
||||
if name not in present:
|
||||
issues.append(
|
||||
f"kb/{name}/ is missing - it is where the stack-required `source` type writes, "
|
||||
f"and `sources coverage`, `[^cite-id]` resolution and `kb/provenance.md` all "
|
||||
f"depend on those pages existing"
|
||||
f"kb/{name}/ is missing - it is where the stack-required `{owners[name]}` type "
|
||||
f"writes, and wikitool depends on that collection existing by name"
|
||||
)
|
||||
|
||||
for collection in iter_kb_collections(root):
|
||||
|
||||
@@ -41,10 +41,11 @@ def kb_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
kb.mkdir()
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||
monkeypatch.setattr(config, "KB_DIR", kb)
|
||||
# Which collection the stack requires is *derived* from where the required
|
||||
# `source` type writes, so these tests need the shipped `types/` reachable -
|
||||
# a fixture tree without one derives an empty requirement and would assert
|
||||
# against a rule that is not running. See conftest.use_shipped_type_specs.
|
||||
# Which collections the stack requires are *derived* from where each
|
||||
# required type (`source`, `project`) writes, so these tests need the
|
||||
# shipped `types/` reachable - a fixture tree without one derives an empty
|
||||
# requirement and would assert against a rule that is not running. See
|
||||
# conftest.use_shipped_type_specs.
|
||||
use_shipped_type_specs(monkeypatch)
|
||||
conventions.reset_cache()
|
||||
yield kb
|
||||
@@ -153,6 +154,7 @@ def test_a_missing_stack_required_collection_is_reported(kb_root):
|
||||
|
||||
def test_a_correct_declaration_reports_nothing(kb_root):
|
||||
_collection(kb_root, "sources", profile="sources", required=True)
|
||||
_collection(kb_root, "gtd", profile="none", required=True)
|
||||
_collection(kb_root, "entities", profile="entities")
|
||||
assert kb_collections.declaration_issues(kb_root) == []
|
||||
|
||||
@@ -210,6 +212,7 @@ def test_outbound_on_a_collection_that_cannot_carry_labels_is_a_finding(kb_root)
|
||||
|
||||
def test_outbound_is_fine_on_a_collection_whose_type_offers_related(kb_root):
|
||||
_collection(kb_root, "sources", profile="sources", required=True)
|
||||
_collection(kb_root, "gtd", profile="none", required=True)
|
||||
_authorising(kb_root, "entities", " any: [uses]")
|
||||
assert kb_collections.declaration_issues(kb_root) == []
|
||||
|
||||
@@ -217,4 +220,5 @@ def test_outbound_is_fine_on_a_collection_whose_type_offers_related(kb_root):
|
||||
def test_a_collection_without_outbound_is_not_a_finding(kb_root):
|
||||
"""Absence is the declaration `kb/sources/` makes: no authored edges here."""
|
||||
_collection(kb_root, "sources", profile="sources", required=True)
|
||||
_collection(kb_root, "gtd", profile="none", required=True)
|
||||
assert kb_collections.declaration_issues(kb_root) == []
|
||||
@@ -335,6 +335,77 @@ def test_an_unknown_type_spec_field_is_reported(tmp_path, monkeypatch):
|
||||
assert any("widget.md" in issue and "not_a_real_field" in issue for issue in issues)
|
||||
|
||||
|
||||
def _stack_required_type_tree(tmp_path, monkeypatch, project_md: str = "", project_schema: str = ""):
|
||||
"""A `types/` fixture carrying a valid `source` type-spec (copied from the
|
||||
real repo, so it never drifts from what `docs verify` actually enforces)
|
||||
plus whatever `project.md`/`project.schema.yaml` the caller supplies -
|
||||
empty strings mean "write nothing", so a caller can exercise the
|
||||
type-missing case. `check_stack_required_types()` (Gitea #123) has no
|
||||
dedicated coverage otherwise: it is only ever exercised indirectly, via a
|
||||
full `verify()` run against the real repo tree."""
|
||||
types_dir = tmp_path / "types"
|
||||
types_dir.mkdir()
|
||||
for name in ("type-spec.md", "type-spec.schema.yaml", "source.md", "source.schema.yaml"):
|
||||
(types_dir / name).write_text(
|
||||
(config.ROOT / "types" / name).read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
if project_md:
|
||||
(types_dir / "project.md").write_text(project_md, encoding="utf-8")
|
||||
if project_schema:
|
||||
(types_dir / "project.schema.yaml").write_text(project_schema, 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))
|
||||
|
||||
|
||||
_PROJECT_MD = (
|
||||
"---\n"
|
||||
"type: types/type-spec.md\n"
|
||||
"name: project\n"
|
||||
"description: Fixture project type\n"
|
||||
"schema: types/project.schema.yaml\n"
|
||||
"---\n"
|
||||
)
|
||||
|
||||
|
||||
def test_check_stack_required_types_reports_a_missing_project_type(tmp_path, monkeypatch):
|
||||
"""No `project.md` at all - the type-missing case, worded to name the
|
||||
type that is missing."""
|
||||
_stack_required_type_tree(tmp_path, monkeypatch)
|
||||
issues = docs_verify.check_stack_required_types()
|
||||
assert any("name: project" in issue for issue in issues)
|
||||
|
||||
|
||||
def test_check_stack_required_types_reports_project_schema_missing_state(tmp_path, monkeypatch):
|
||||
"""A `project` type-spec exists, but its schema does not require `state:`
|
||||
- the field-missing case, distinct from the type-missing one above."""
|
||||
schema = (
|
||||
"type: object\n"
|
||||
"properties:\n"
|
||||
" type: {type: string, const: 'types/project.md'}\n"
|
||||
" state: {type: string, enum: [active, dormant, completed, abandoned]}\n"
|
||||
"required: [type]\n"
|
||||
"additionalProperties: false\n"
|
||||
)
|
||||
_stack_required_type_tree(tmp_path, monkeypatch, project_md=_PROJECT_MD, project_schema=schema)
|
||||
issues = docs_verify.check_stack_required_types()
|
||||
assert any("state" in issue and "project.md" in issue for issue in issues)
|
||||
|
||||
|
||||
def test_check_stack_required_types_passes_when_both_types_satisfy_their_field(tmp_path, monkeypatch):
|
||||
"""`source`/`raw_files:` and `project`/`state:` both satisfied - no issues."""
|
||||
schema = (
|
||||
"type: object\n"
|
||||
"properties:\n"
|
||||
" type: {type: string, const: 'types/project.md'}\n"
|
||||
" state: {type: string, enum: [active, dormant, completed, abandoned]}\n"
|
||||
"required: [type, state]\n"
|
||||
"additionalProperties: false\n"
|
||||
)
|
||||
_stack_required_type_tree(tmp_path, monkeypatch, project_md=_PROJECT_MD, project_schema=schema)
|
||||
assert docs_verify.check_stack_required_types() == []
|
||||
|
||||
|
||||
def test_legacy_type_blocks_are_absent():
|
||||
assert docs_verify.check_legacy_type_blocks() == []
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import config, kb_collections
|
||||
from chemenu import config, kb_collections, type_resolver
|
||||
from chemenu.type_resolver import TypeResolver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -65,3 +66,37 @@ def test_vendored_commonplace_contracts_are_ignored(repo):
|
||||
|
||||
def test_a_clean_tree_has_no_strays(repo):
|
||||
assert kb_collections.stray_collection_contracts() == []
|
||||
|
||||
|
||||
def test_stack_required_collections_includes_gtd_once_project_type_exists(repo, monkeypatch):
|
||||
"""`stack_required_collections()` derives from wherever the required types
|
||||
write, so `project`'s `base_dir: gtd` makes `gtd` required the moment that
|
||||
type-spec exists - the same derivation `source` already gets from writing
|
||||
to `sources` (Gitea #123)."""
|
||||
real_types_dir = Path(__file__).resolve().parents[3] / "types"
|
||||
types_dir = repo / "types"
|
||||
for name in ("type-spec.md", "type-spec.schema.yaml"):
|
||||
(types_dir / name).write_text(
|
||||
(real_types_dir / name).read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
(types_dir / "project.md").write_text(
|
||||
"---\n"
|
||||
"type: types/type-spec.md\n"
|
||||
"name: project\n"
|
||||
"description: Fixture project type\n"
|
||||
"schema: null\n"
|
||||
"base_dir: gtd\n"
|
||||
"---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(config, "TYPES_DIR", types_dir)
|
||||
monkeypatch.setattr(type_resolver, "resolver", TypeResolver(repo_root=repo))
|
||||
assert "gtd" in kb_collections.stack_required_collections()
|
||||
|
||||
|
||||
def test_stack_required_collections_omits_a_type_with_no_type_spec(repo, monkeypatch):
|
||||
"""No `project.md` at all - the required-types derivation must tolerate a
|
||||
missing type rather than raising, so a corpus mid-adoption still lints."""
|
||||
monkeypatch.setattr(config, "TYPES_DIR", repo / "types")
|
||||
monkeypatch.setattr(type_resolver, "resolver", TypeResolver(repo_root=repo))
|
||||
assert "gtd" not in kb_collections.stack_required_collections()
|
||||
@@ -77,6 +77,33 @@ def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
|
||||
assert "# gateway.example.net" in body
|
||||
|
||||
|
||||
def test_new_project_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
|
||||
"""Gitea #123: `state:` is required with a schema `default: active`, so it
|
||||
must materialize even though the caller never sets it - the same rule
|
||||
`--set entity_type=...`'s required fields already follow."""
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Testvorhaben", "--set", "responsibility=haus",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
path = kb_dir / "gtd/haus/Testvorhaben.md"
|
||||
assert path.exists()
|
||||
fm, body = read_page(path)
|
||||
assert fm["type"] == "types/project.md"
|
||||
assert fm["state"] == "active"
|
||||
assert fm["responsibility"] == "haus"
|
||||
assert "# Testvorhaben" in body
|
||||
for heading in ("Ziel", "Kontext", "Beteiligte", "Status", "Entscheidungen", "Gelerntes"):
|
||||
assert f"## {heading}" in body
|
||||
|
||||
|
||||
def test_new_project_refuses_a_responsibility_outside_the_enum(monkeypatch, kb_dir):
|
||||
result = _invoke_new(monkeypatch, kb_dir, [
|
||||
"new", "project", "--name", "Badvorhaben", "--set", "responsibility=nichtexistent",
|
||||
])
|
||||
assert result.exit_code == 1
|
||||
assert not list(kb_dir.rglob("Badvorhaben.md"))
|
||||
|
||||
|
||||
def test_new_entity_still_materializes_empty_arrays_for_unset_optional_fields(monkeypatch, kb_dir):
|
||||
"""Gitea #109 stops materializing an optional field's schema `default:`,
|
||||
but `tags`/`related`/`sources` are optional arrays with no `default:` at
|
||||
|
||||
@@ -268,8 +268,8 @@ def test_find_type_by_name_resolves_short_names():
|
||||
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",
|
||||
"type-spec", "entity", "concept", "source", "comparison", "project", "lint-report",
|
||||
"instruction", "type-guidance",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ def test_types_list_finds_all_current_type_specs():
|
||||
rows = json.loads(result.output)
|
||||
names = {row["name"] for row in rows}
|
||||
assert names == {
|
||||
"type-spec", "entity", "concept", "source", "comparison", "lint-report", "instruction",
|
||||
"type-guidance",
|
||||
"type-spec", "entity", "concept", "source", "comparison", "project", "lint-report",
|
||||
"instruction", "type-guidance",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user