stack: Typ project und Collection kb/gtd/ (#123)
CI / verify (push) Successful in 45s
Release / release (push) Successful in 36s

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:
torben committed 2026-09-19 21:13:24 +02:00
1 parent 3c9d669729
commit ee24b6e5b8
20 files changed
+507 -38

No files matched your search

+29 -13
View File
@@ -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):
+8 -4
View File
@@ -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) == []
+71
View File
@@ -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() == []
+36 -1
View File
@@ -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()
+27
View File
@@ -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
+2 -2
View File
@@ -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",
}
+2 -2
View File
@@ -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",
}