feat: Autorenkonventionen nach Eigentum geschnitten - kb/CONVENTIONS.md, deklarierte Collections (3.0.0)
CI / verify (push) Successful in 53s
Release / release (push) Successful in 38s

Files changed:
- .gitea/workflows/ci.yml
- .wikitool-kb.json
- AGENTS.md
- CHANGES.md
- INSTALL.md
- README.md
- VERSION
- instructions/CONTRACT.md
- instructions/dev/testing-conventions.md
- instructions/german-terminology.md
- instructions/kb-profiles.md
- instructions/migrations/3.0.0-authoring-conventions.md
- instructions/private-instance.md
- instructions/setup-instance.md
- instructions/wiki-ingest/SKILL.md
- instructions/wiki-manage/SKILL.md
- kb/CONTRACT.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- kb/comparisons/COLLECTION.md
- kb/concepts/COLLECTION.md
- kb/entities/COLLECTION.md
- kb/sources/COLLECTION.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/conventions.py
- tools/chemenu/kb_collections.py
- tools/chemenu/kb_scan.py
- tools/chemenu/provenance.py
- tools/chemenu/sections.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_conventions.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_types_cmd.py
- types/comparison.md
- types/concept.md
- types/entity.md
- types/source.md
- types/type-spec.md
This commit is contained in:
2026-09-02 15:02:10 +02:00
parent 9843df99d3
commit 502971d147
45 changed files with 1817 additions and 232 deletions
+3 -3
View File
@@ -67,10 +67,10 @@ 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, 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 here (and vice versa), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every stage contract present, no pre-migration `type: entity` blocks left in the contracts, and the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, everything ignored under `reports/` and the published skill directories) |
| `docs verify` | Check the docs that mirror the code: every CLI command documented here (and vice versa), 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, no pre-migration `type: entity` blocks left in the contracts, and the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, everything ignored under `reports/` and the published skill directories) |
| `eval sessions [--json]` | List the sessions that have a trace under `reports/telemetry/`, most recent first. Read-only and exempt from the Iteration Budget Gate |
| `eval score [--session <id>] [--json] [--markdown out.md] [--save] [--fail-on-error]` | Score one traced session: structural state from `lint`'s own checks (L1) plus trajectory rules over the trace (L2) - was a refused call repeated unchanged, was a gate flag passed without that gate having refused anything, did a publish of `kb/` pages go unlogged. Defaults to the current session. `--save` writes `reports/evals/<date>/<session>.{json,md}`. Read-only over `kb/` and exempt from the budget; see [../EVALS.md](../EVALS.md) |
| `dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U]` | Write a contentless, distributable copy of this repo's machinery into an empty `<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any `<!-- dist:strip-start -->...<!-- dist:strip-end -->` region removed, `instructions/` (minus `instructions/dev/`), `types/`, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, every `kb/*/COLLECTION.md` (no pages, no areas), empty `raw/{articles,documents,notes,assets}/`, `VERSION`, `USER.md.template`/`SOUL.md.template` (the templates ship; a filled `USER.md`/`SOUL.md` never does - the root allowlist is what makes that automatic), and a generated `.wikitool-release.json` stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: `export` never calls git and cannot discover them. Refuses a non-empty target, and a tree with no `VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead |
| `dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U]` | Write a contentless, distributable copy of this repo's machinery into an empty `<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any `<!-- dist:strip-start -->...<!-- dist:strip-end -->` region removed, `instructions/` (minus `instructions/dev/`), `types/`, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), empty `raw/{articles,documents,notes,assets}/`, `VERSION`, `USER.md.template`/`SOUL.md.template` plus `kb/CONVENTIONS.md.template` and each collection's contract re-keyed as `kb/<name>/COLLECTION.md.template` (the templates ship; the filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md` never do - all four bind their instance and none of them are the stack's to decide, and `find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: `export` never calls git and cannot discover them. Refuses a non-empty target, and a tree with no `VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead |
| `version show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** |
| `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate |
| `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate |
@@ -80,7 +80,7 @@ tools/wikitool <command> --help
| `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision on the invariants a content migration must not change: wikilink and citation **counts** (not sets), footnote definitions, H1, and structural frontmatter. Reports added/removed pages without failing on them. `--expect-body-change` additionally flags a page whose body did not change at all. Not migration-specific - worth running after any bulk rewrite, and the one question `lint` cannot answer, since it reads a single revision and so cannot see that something went missing. Read-only and exempt from the budget gate |
| `migrate done <version> [--pages N] [--dry-run]` | Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json` to its target. **Refuses any version that is not the next link in the chain** - skipping one leaves the corpus in a shape no version describes, and an interrupted multi-step upgrade has to be resumable rather than guessable |
| `migrate baseline <version> [--force]` | Declare `kb_version` once, for an instance predating `.wikitool-kb.json`. Refuses to overwrite an existing declaration without `--force`: advancing after a migration is `done`, which checks the chain, and this command must not become the quiet way around it |
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, and `WIKITOOL_SESSION_ID`. Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, and `WIKITOOL_SESSION_ID`. Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
## Design notes
+19 -10
View File
@@ -39,7 +39,8 @@ tools/
errors.py ChemenuError / ValidationError / BackendError
corpus_cache.py one parsed corpus per commit, never cached while the tree is dirty
kb_scan.py page iteration/loading over kb/
kb_collections.py collection discovery (a directory with COLLECTION.md)
kb_collections.py collection discovery (a directory with COLLECTION.md), and what one declares about itself
conventions.py kb/CONVENTIONS.md: what this instance decided about authoring, as opposed to what the stack enforces
type_resolver.py type-spec loading and schema resolution
lint_core.py the lint checks and the report, with no CLI attached
types_core.py type-spec listing/description, with no CLI attached
@@ -110,15 +111,23 @@ procedure written down in advance is one an agent can complete alone. Whether
a human *actually* saw it is not enforced here - that question is answered in
the eval layer (`evals/trajectory.py`, `clearance-ended-the-turn`).
**Section names are a vocabulary, not literals.** `xref add` writes into Relationships and See
Also, and `cite add` owns the trailing Footnotes block, so those three headings are structure the
tool matches on. They are named once in `sections.py`, and each has one canonical spelling - what
the tool writes - plus aliases it still recognizes. That asymmetry is what let the wiki be
translated page by page instead of atomically: an untranslated `## Relationships` is still found
and appended to. Dropping an alias is therefore a breaking change for any page not yet converted,
not a cleanup. Renaming a heading is a migration's job; no other command may do it as a side
effect (see `cite_block_heading` in `provenance.py`, which exists solely so `cite sync` stays a
no-op on an untranslated page).
**Section names are a vocabulary, not literals - and not the stack's.** `xref add` writes into
Relationships and See Also, and `cite add` owns the trailing Footnotes block, so those three
headings are structure the tool matches on. *Which words they are* is the corpus's own answer:
`conventions.py` reads them from `kb/CONVENTIONS.md`, `sections.py` resolves them on access
(PEP 562, the way `config` resolves its paths), and no heading text is written down in Python
except the pre-conventions fallback for an instance that has not declared one yet.
Each slot has one canonical spelling - what the tool writes - plus aliases it still recognizes.
That asymmetry is what let the wiki be translated page by page instead of atomically: an
untranslated `## Relationships` is still found and appended to. Dropping an alias is therefore a
breaking change for any page not yet converted, not a cleanup. Renaming a heading is a
migration's job; no other command may do it as a side effect (see `cite_block_heading` in
`provenance.py`, which exists solely so `cite sync` stays a no-op on an untranslated page).
Because the value is resolved rather than bound, nothing may capture it at import time - not a
module constant, not an evaluated default argument. That is why `provenance.CITE_BLOCK_HEADING`
is a module `__getattr__` and `render_cite_block(heading=None)` resolves inside the call.
**Generated output is never committed.** `reports/`, `.agents/skills/` and
`.claude/skills/` are build output; `docs verify` carries canaries in both
+48 -8
View File
@@ -2,9 +2,13 @@
repo's machinery.
`export` copies the pipeline's schema/compiler/control-plane layers (types/,
tools/, instructions/, the stage contracts, every kb/*/COLLECTION.md) into an
empty target, with no kb/ pages, no raw/ content, and no git history - see
instructions/setup-instance.md for what happens after. It never calls git.
tools/, instructions/, the stage contracts) into an empty target, with no kb/
pages, no raw/ content, and no git history - see instructions/setup-instance.md
for what happens after. It never calls git.
The two binding-but-instance-owned documents under kb/ - each collection's
COLLECTION.md and kb/CONVENTIONS.md - cross as `.template` and are adopted by a
rename, the same split USER.md/SOUL.md use at the repo root.
Three independent exclusion mechanisms feed the plan, for three different
shapes of "does not belong in someone else's instance":
@@ -35,7 +39,7 @@ from typing import Callable, NamedTuple, Optional, Union
import typer
from chemenu import config, kb_collections, kb_state, version as version_mod
from chemenu import config, conventions, kb_collections, kb_state, version as version_mod
from chemenu.commands._util import fail, rel_path, success, today_iso
app = typer.Typer(help="Build a distributable copy of the wiki machinery.")
@@ -289,12 +293,32 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
for hook_dir in HOOK_DIRS:
plan.update(_copy_tree(config.ROOT / hook_dir, hook_dir, frozenset()))
# `kb/CONTRACT.md` is stack-owned and ships verbatim; everything beside it
# under `kb/` is the instance's own and ships only as a `.template`. That is
# the personalization split (`USER.md`/`SOUL.md`) one directory down, and
# the reason for it is the same: a distribution can say what the file
# decides, never what this instance decided.
kb_contract = config.KB_DIR / "CONTRACT.md"
if kb_contract.is_file():
plan["kb/CONTRACT.md"] = _read_planned_file(kb_contract, "kb/CONTRACT.md")
conventions_template = config.KB_DIR / conventions.CONVENTIONS_TEMPLATE
if conventions_template.is_file():
rel = f"kb/{conventions.CONVENTIONS_TEMPLATE}"
plan[rel] = _read_planned_file(conventions_template, rel)
# A collection contract is instance-owned too, but unlike `USER.md` the
# shipped content is not *wrong* for the receiver - it is the profile this
# repo's own collections adopted, and a fine starting point. So the file
# itself ships, under the template name: one source of truth here, and a
# receiving instance that has to rename it before it counts. Keeping a
# separate `.template` beside each contract would have meant maintaining two
# near-identical copies of the same text, which is the drift AGENTS.md
# invariant 8 exists to prevent.
for collection in kb_collections.iter_kb_collections():
rel = f"kb/{collection.name}/COLLECTION.md"
plan[rel] = _read_planned_file(collection / "COLLECTION.md", rel)
source = collection / kb_collections.CONTRACT_NAME
rel = f"kb/{collection.name}/{kb_collections.CONTRACT_NAME}.template"
plan[rel] = _read_planned_file(source, rel)
for relative in CONTRACT_ONLY_STAGES:
source = config.ROOT / relative
@@ -339,8 +363,21 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
# IP literals) was considered and rejected - the project's own host legitimately
# appears in INSTALL.md and version.py, so such a scan would either whitelist
# the very string it is looking for or cry wolf on every export.
#
# `COLLECTION.md` and `CONVENTIONS.md` are deliberately *not* on the allowed
# list any more. Both bind, and both are the instance's to write, so they cross
# the boundary as `.template` and are adopted by a rename - a plan carrying the
# filled name would hand a new instance this one's authoring conventions as
# though they were the stack's.
_CONTENT_PREFIXES = ("kb/", "raw/")
_CONTENT_ALLOWED_NAMES = ("CONTRACT.md", "COLLECTION.md", "log.md", ".gitkeep")
_CONTENT_ALLOWED_NAMES = (
"CONTRACT.md",
f"{kb_collections.CONTRACT_NAME}.template",
conventions.CONVENTIONS_TEMPLATE,
"log.md",
".gitkeep",
)
_INSTANCE_OWNED_KB_FILES = (kb_collections.CONTRACT_NAME, conventions.CONVENTIONS_FILENAME)
def find_leaks(plan: dict[str, PlannedFile]) -> list[str]:
@@ -350,6 +387,8 @@ def find_leaks(plan: dict[str, PlannedFile]) -> list[str]:
name = relative.rsplit("/", 1)[-1]
if name in config.PERSONALIZATION_FILES or name == config.ENVIRONMENT_FILE:
leaks.append(f"{relative} (one instance's own personalization)")
elif relative.startswith("kb/") and name in _INSTANCE_OWNED_KB_FILES:
leaks.append(f"{relative} (this instance's authoring conventions; ship the .template)")
elif relative.startswith("instructions/dev/"):
leaks.append(f"{relative} (stack-development only)")
elif relative.startswith(_CONTENT_PREFIXES) and name not in _CONTENT_ALLOWED_NAMES:
@@ -394,7 +433,8 @@ def export_command(
AGENTS.md/README.md (dev-instance-only marker blocks removed),
instructions/ (no instructions/dev/), types/, tools/ (no venv/caches),
the .github/hooks/+.vibe session-tracing config plus .claude/settings.json,
every kb/*/COLLECTION.md (no pages, no areas), empty
kb/CONTRACT.md plus a COLLECTION.md.template per collection and
kb/CONVENTIONS.md.template (no pages, no areas), empty
raw/{articles,documents,notes,assets}/, VERSION, the USER.md/SOUL.md
personalization templates (never the filled files), and a
.wikitool-release.json stamp. The --source-*/--release-url/--update-url
+20 -2
View File
@@ -37,7 +37,7 @@ from typing import Optional
import typer
from chemenu import config, kb_collections, version as version_mod
from chemenu import config, conventions, kb_collections, version as version_mod
from chemenu.commands._util import fail, rel_path, success
app = typer.Typer(help="Verify documentation that mirrors the code or repo layout.")
@@ -114,6 +114,12 @@ REQUIRED_TRACKED_PATHS = (
"instructions/CONTRACT.md",
"instructions/wiki-query/SKILL.md",
"ENVIRONMENT.md.template",
# The one `.template` that lives under a content directory. It is what a
# distribution ships in place of this instance's own `kb/CONVENTIONS.md`, so
# an ignore rule reaching it would produce exports whose receiving instance
# has nothing to fill in - and `find_leaks` refuses to substitute the filled
# file, correctly, so the export would simply be missing it.
"kb/CONVENTIONS.md.template",
)
CLI_README = config.ROOT / "tools" / "CONTRACT.md"
@@ -207,13 +213,22 @@ def check_cli_readme() -> list[str]:
def check_collection_contracts() -> list[str]:
"""The three structural rules that define what a collection is.
"""The structural rules that define what a collection is, plus what each one
has to declare about itself.
Collections are discovered by contract presence rather than listed here, so
`mkdir kb/<name>` + a COLLECTION.md is all it takes to add one. That only
works if the inverse is also checked: a directory under kb/ *without* a
contract is an unclaimed subtree whose pages obey no local rules, and a
contract outside kb/ quietly widens "collection" back out to "any directory".
Presence alone stopped being enough once the contracts became
instance-owned. A `COLLECTION.md` an instance wrote can be about anything,
so the two facts the stack still needs from it - which profile it adopted,
and whether the stack resolves against it by name - are declared in its
frontmatter and checked here (`kb_collections.declaration_issues`), together
with the shape of `kb/CONVENTIONS.md`, whose section names the compiler
reads.
"""
issues = []
@@ -243,6 +258,9 @@ def check_collection_contracts() -> list[str]:
if not (config.ROOT / relative_path).exists():
issues.append(f"{relative_path} is missing - it is the authoring contract for its stage")
issues += kb_collections.declaration_issues()
issues += conventions.declaration_issues()
return issues
+46 -4
View File
@@ -20,7 +20,7 @@ from typing import Optional
import typer
from rich.console import Console
from chemenu import config, kb_collections, version as version_mod
from chemenu import config, conventions, kb_collections, version as version_mod
from chemenu.commands import git_publish, instructions_cmd
from chemenu.commands._util import rel_path
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
@@ -213,6 +213,47 @@ def check_personalization() -> Check:
return Check("personalization", "OK", f"{', '.join(config.PERSONALIZATION_FILES)} present and filled")
def check_conventions() -> Check:
"""Whether this instance has said how its own pages are written.
`kb/CONVENTIONS.md` carries the decisions `kb/CONTRACT.md` deliberately no
longer makes: the KB language and its three tool-owned section headings, the
relationship-label vocabulary, the tone examples, the confidence rubric, the
ADR prefix. The compiler reads the section names out of it, so an instance
without one is not merely undocumented - `xref add` and `cite add` fall back
to the names this stack hardcoded before the file existed, which is right
only for a corpus that was written under them.
Hence `FAIL` rather than `WARN`, and hence the same two failure modes the
personalization pair has: the distribution can ship the template but never
the filled file, so a template renamed and left unanswered looks present and
decides nothing.
"""
path = conventions.conventions_file()
fix = (
"Copy kb/CONVENTIONS.md.template to kb/CONVENTIONS.md and answer it - the KB-language "
"step of instructions/setup-instance.md walks it, and instructions/kb-profiles.md has "
"the ready-made profiles to adopt"
)
if not path.is_file():
return Check(
"conventions", "FAIL",
f"kb/{conventions.CONVENTIONS_FILENAME} is missing - this instance has not "
"declared how its pages are written",
fix,
)
issues = conventions.declaration_issues()
if issues:
return Check("conventions", "FAIL", "; ".join(issues), fix)
declared = conventions.language() or "unspecified"
headings = ", ".join(conventions.canonical(slot) for slot in conventions.SLOTS)
return Check(
"conventions", "OK",
f"kb/{conventions.CONVENTIONS_FILENAME} present, language {declared}, "
f"sections {headings}",
)
def check_environment() -> Check:
"""Whether this checkout records the environment it works through.
@@ -399,6 +440,7 @@ def run_doctor() -> list[Check]:
check_skills(),
check_structure(),
check_personalization(),
check_conventions(),
check_environment(),
check_publish_remotes(),
check_generated_files(),
@@ -411,9 +453,9 @@ def doctor_command(
json_out: bool = typer.Option(False, "--json", help="Print the checks as JSON"),
):
"""Check that this instance is correctly configured: dependencies, author,
git identity/remote, published skills, structure, personalization,
generated files, and session scoping. Read-only. Exits 1 only if a check
FAILs."""
git identity/remote, published skills, structure, personalization, KB
conventions, generated files, and session scoping. Read-only. Exits 1 only
if a check FAILs."""
checks = run_doctor()
if json_out:
+13 -3
View File
@@ -24,7 +24,7 @@ import re
import typer
from chemenu import config
from chemenu import config, conventions
from chemenu.commands._util import (
check_collision,
check_raw_files_exist,
@@ -166,7 +166,11 @@ def _apply_template_variables(template: str, variables: Dict[str, Any]) -> str:
"""Apply variable substitutions to a template string.
Supports:
- `{field}` - plain substitution from `variables[field]`
- `{field}` - plain substitution from `variables[field]`, including the
`{section.<slot>}` names this instance gave the three tool-owned
headings (see chemenu.conventions). Those are what took the KB language
out of `types/*.md`: a template writes `## {section.relationships}`, so
scaffolding a page in another language needs no edit under `types/`
- `{field|filter}` - apply a named filter (bullets, join, capitalize)
to `variables[field]`'s value, so templates can render list/enum
frontmatter fields directly instead of the caller precomputing a
@@ -324,7 +328,13 @@ def new_page_command(
path = target_dir / f"{page_title}.md"
body = _apply_template_variables(
template, {**frontmatter, "name": name, "today": today.isoformat()}
template,
{
**frontmatter,
"name": name,
"today": today.isoformat(),
**conventions.section_variables(),
},
)
write_page(path, frontmatter, body)
+224
View File
@@ -0,0 +1,224 @@
"""What this instance decided, read from `kb/CONVENTIONS.md`.
`kb/CONTRACT.md` and this file answer two different questions. The contract
holds what the code enforces - what a collection is, which files are generated,
how `provenance:` and `confidence_base` work - and is identical in every
instance, so `dist export` ships it verbatim. `kb/CONVENTIONS.md` holds what
each instance decides for itself: the language its pages are written in, the
relationship-label vocabulary, the tone examples, the confidence rubric, the
ADR prefix. The distribution ships only `kb/CONVENTIONS.md.template`, exactly
the split `USER.md`/`SOUL.md` already use one directory up.
Only one part of it is machine-read, and it is the part that used to be Python:
the three section headings `xref add` and `cite add` write. While
`RELATIONSHIPS = "Beziehungen"` sat in `sections.py`, an instance writing its
pages in any other language had to edit the compiler to say so - which made the
KB language a stack property in code while every document called it an instance
decision.
**A missing conventions file is not an error here.** It is the state an
instance is in between installing this machinery and running the migration that
writes the file, and every command has to keep working through it. The fallback
is `PRE_CONVENTIONS_NAMES` - not "the stack's language", but *what this stack
hardcoded before the file existed*, which is by construction what any corpus
reaching that state was written with. `wikitool doctor` is what says the file is
missing; degrading loudly here would take out `doctor` itself.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
from chemenu import config
from chemenu.frontmatter_io import read_page
CONVENTIONS_FILENAME = "CONVENTIONS.md"
CONVENTIONS_TEMPLATE = f"{CONVENTIONS_FILENAME}.template"
# The three tool-owned headings, by slot name. The slot is the stable
# identifier - it is what code, the type-spec templates and the conventions
# file all key on - while the heading text itself is the instance's to choose.
RELATIONSHIPS = "relationships"
SEE_ALSO = "see_also"
FOOTNOTES = "footnotes"
SLOTS = (RELATIONSHIPS, SEE_ALSO, FOOTNOTES)
# Frontmatter keys read out of kb/CONVENTIONS.md.
SECTIONS_KEY = "sections"
SECTION_ALIASES_KEY = "section_aliases"
LANGUAGE_KEY = "language"
# Every heading name this stack has ever written as canonical, newest first.
# Two jobs, and they are separate: the first entry is the fallback for an
# instance that has no conventions file yet, and the whole tuple is an implicit
# alias set that every instance recognizes regardless of what it declares. The
# second is what makes a corpus translatable page by page - a page still
# carrying `## Footnotes` is untranslated, not broken, and `cite sync` has to
# stay a no-op on it.
PRE_CONVENTIONS_NAMES: dict[str, tuple[str, ...]] = {
RELATIONSHIPS: ("Beziehungen", "Relationships"),
SEE_ALSO: ("Siehe auch", "See Also"),
FOOTNOTES: ("Fußnoten", "Footnotes"),
}
def conventions_file() -> Path:
return config.KB_DIR / CONVENTIONS_FILENAME
# (path, mtime_ns, size) -> frontmatter. `heading_re()` is called once per page
# per lint run, so re-reading the file each time would put a stat+parse on a
# per-page path for a document that changes about once per instance. Keyed on
# the stat rather than on the path alone, so a test that rewrites the file
# inside one process is not answered out of the cache.
_CACHE: dict[tuple[str, int, int], dict[str, Any]] = {}
def read_conventions() -> dict[str, Any]:
"""`kb/CONVENTIONS.md`'s frontmatter, or `{}` if the file is absent.
Permissive on purpose, like `read_page` itself: a conventions file with
broken YAML degrades to the pre-conventions defaults rather than taking
every command down with it. `doctor` and `docs verify` are where that
surfaces as a finding.
"""
path = conventions_file()
if not path.is_file():
return {}
stat = path.stat()
key = (str(path), stat.st_mtime_ns, stat.st_size)
if key not in _CACHE:
frontmatter, _ = read_page(path)
_CACHE.clear()
_CACHE[key] = frontmatter
return _CACHE[key]
def reset_cache() -> None:
"""Drop the parsed conventions. For a caller that rewrote the file and has
to see the new value within the same stat resolution."""
_CACHE.clear()
def _mapping(key: str) -> dict[str, Any]:
value = read_conventions().get(key)
return value if isinstance(value, dict) else {}
def language() -> Optional[str]:
"""The declared KB language tag (e.g. `de`), or None if undeclared.
Nothing in the compiler branches on it - the language is carried by the
prose the instance writes, not by a switch. It is here because the
conventions file is where a human and an agent look the answer up, and
because `doctor` reports it.
"""
value = read_conventions().get(LANGUAGE_KEY)
if value is None:
return None
return str(value).strip() or None
def canonical(slot: str) -> str:
"""The heading name this instance writes for `slot`."""
declared = _mapping(SECTIONS_KEY).get(slot)
if isinstance(declared, str) and declared.strip():
return declared.strip()
return PRE_CONVENTIONS_NAMES[slot][0]
def names(slot: str) -> tuple[str, ...]:
"""Every heading name `slot` is recognized under, canonical first.
The canonical name, then any `section_aliases:` the instance declared, then
the names this stack wrote before the conventions file existed. Deduplicated
while preserving that order, so an instance declaring English does not end
up with `Relationships` listed twice.
"""
declared_aliases = _mapping(SECTION_ALIASES_KEY).get(slot)
extra = declared_aliases if isinstance(declared_aliases, list) else []
ordered = [
canonical(slot),
*(str(name).strip() for name in extra if str(name).strip()),
*PRE_CONVENTIONS_NAMES[slot],
]
seen: dict[str, None] = {}
for name in ordered:
seen.setdefault(name, None)
return tuple(seen)
def section_variables() -> dict[str, str]:
"""The `{section.<slot>}` substitutions a type-spec template can use.
This is what took the three German headings out of `types/*.md`: a template
writes `## {section.relationships}` and the instance's own conventions fill
it in, so scaffolding a page in another language needs no edit under
`types/`.
"""
return {f"section.{slot}": canonical(slot) for slot in SLOTS}
def declaration_issues() -> list[str]:
"""What is wrong with this instance's conventions file, if anything.
Shared by `doctor` (which FAILs on it) and `docs verify` (which refuses a
tree with it), so the two cannot disagree about what a valid declaration
looks like. An absent file is *not* reported here - that is a separate
finding with a separate fix, and only `doctor` makes it one.
"""
path = conventions_file()
if not path.is_file():
return []
issues: list[str] = []
frontmatter, _ = read_page(path)
if not frontmatter:
return [
f"kb/{CONVENTIONS_FILENAME} has no readable frontmatter - it must declare "
f"`{SECTIONS_KEY}:` with the heading names this instance writes"
]
declared = frontmatter.get(SECTIONS_KEY)
if not isinstance(declared, dict):
return [
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}:` must be a mapping of "
f"{'/'.join(SLOTS)} to the heading text this instance writes"
]
for slot in SLOTS:
value = declared.get(slot)
if not isinstance(value, str) or not value.strip():
issues.append(
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{slot}` is missing or empty - "
"`xref add` and `cite add` write into a heading this instance has not named"
)
for slot in sorted(set(declared) - set(SLOTS)):
issues.append(
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{slot}` is not a section the tool "
f"owns; the slots are {', '.join(SLOTS)}"
)
aliases = frontmatter.get(SECTION_ALIASES_KEY, {})
if not isinstance(aliases, dict):
issues.append(
f"kb/{CONVENTIONS_FILENAME}: `{SECTION_ALIASES_KEY}:` must be a mapping of a "
"slot to the list of headings still recognized under it"
)
else:
for slot, value in sorted(aliases.items()):
if slot not in SLOTS:
issues.append(
f"kb/{CONVENTIONS_FILENAME}: `{SECTION_ALIASES_KEY}.{slot}` is not a "
f"section the tool owns; the slots are {', '.join(SLOTS)}"
)
elif not isinstance(value, list):
issues.append(
f"kb/{CONVENTIONS_FILENAME}: `{SECTION_ALIASES_KEY}.{slot}` must be a list"
)
if config.TEMPLATE_SENTINEL in path.read_text(encoding="utf-8"):
issues.append(
f"kb/{CONVENTIONS_FILENAME} still carries the `{config.TEMPLATE_SENTINEL}` line - "
"a renamed template is not a filled one"
)
return issues
+98
View File
@@ -20,11 +20,32 @@ Two corollaries are enforced rather than documented:
from __future__ import annotations
from pathlib import Path
from typing import Any
from chemenu import config
CONTRACT_NAME = "COLLECTION.md"
# What a collection declares about itself, in `COLLECTION.md`'s frontmatter.
#
# Presence on the filesystem says a collection *exists*; it cannot say who owns
# the rules inside it. A `COLLECTION.md` is instance-owned - the distribution
# ships a `.template` per default collection and the instance writes the real
# one - so the two facts the stack still needs from it have to be declared
# rather than inferred from the directory name, which an instance is free to
# choose.
PROFILE_FIELD = "profile"
REQUIRED_BY_STACK_FIELD = "required_by_stack"
# Collections `wikitool` itself depends on by name, as opposed to ones that
# merely hold pages. `sources` is here because three parts of the stack resolve
# against it rather than against a page's type: `sources coverage` asks which
# raw files no source page claims, every `[^cite-id]` footnote resolves to a
# page in it, and `sources rebuild-index` writes `kb/provenance.md` from it. An
# instance may add, rename or drop any collection that is not on this list;
# renaming one that is leaves those three with nothing to resolve against.
STACK_REQUIRED_COLLECTIONS = ("sources",)
def iter_kb_collections(kb_dir: Path | None = None) -> list[Path]:
"""Return every collection directory under kb/, sorted by name.
@@ -82,6 +103,83 @@ def stray_collection_contracts(root: Path | None = None, kb_dir: Path | None = N
return sorted(stray)
def collection_declaration(collection: Path) -> dict[str, Any]:
"""A collection's own `COLLECTION.md` frontmatter, or `{}` if it has none.
Permissive like every other frontmatter read in this package: an unreadable
declaration degrades to empty here and is reported by `docs verify`, rather
than taking down the discovery every command starts with.
"""
from chemenu.frontmatter_io import read_page
contract = collection / CONTRACT_NAME
if not contract.is_file():
return {}
frontmatter, _ = read_page(contract)
return frontmatter
def declaration_issues(kb_dir: Path | None = None) -> list[str]:
"""What each `COLLECTION.md` fails to declare about itself.
Two fields, for two questions the filesystem cannot answer. `profile:`
names the entry in `instructions/kb-profiles.md` this collection adopted -
free text, because the profile catalogue is a palette rather than an enum,
and a collection an instance invented has no entry there to name.
`required_by_stack:` is not the instance's to choose at all: it must agree
with `STACK_REQUIRED_COLLECTIONS`, so a collection whose contract claims the
stack depends on it - or one the stack does depend on and that says it does
not - is a finding rather than a preference.
"""
root = kb_dir if kb_dir is not None else config.KB_DIR
issues: list[str] = []
present = {path.name for path in iter_kb_collections(root)}
for name in STACK_REQUIRED_COLLECTIONS:
if name not in present:
issues.append(
f"kb/{name}/ is missing - `sources coverage`, `[^cite-id]` resolution and "
f"`kb/provenance.md` all resolve against it by name"
)
for collection in iter_kb_collections(root):
relative = f"kb/{collection.name}/{CONTRACT_NAME}"
declared = collection_declaration(collection)
if not declared:
issues.append(
f"{relative} has no frontmatter - it must declare `{PROFILE_FIELD}:` and "
f"`{REQUIRED_BY_STACK_FIELD}:` (see instructions/kb-profiles.md)"
)
continue
profile = declared.get(PROFILE_FIELD)
if not isinstance(profile, str) or not profile.strip():
issues.append(
f"{relative}: `{PROFILE_FIELD}:` is missing or empty - name the profile from "
f"instructions/kb-profiles.md this collection adopted, or `none`"
)
required = declared.get(REQUIRED_BY_STACK_FIELD)
expected = collection.name in STACK_REQUIRED_COLLECTIONS
if not isinstance(required, bool):
issues.append(
f"{relative}: `{REQUIRED_BY_STACK_FIELD}:` is missing or not a boolean - "
f"it must be {str(expected).lower()} for this collection"
)
elif required != expected:
issues.append(
f"{relative}: `{REQUIRED_BY_STACK_FIELD}: {str(required).lower()}` contradicts "
f"the stack, which "
+ (
"does depend on this collection by name"
if expected
else "depends on no collection of this name"
)
+ f" - it must be {str(expected).lower()}"
)
return issues
def _is_vendored(path: Path, repo_root: Path) -> bool:
try:
relative = path.relative_to(repo_root)
+3 -3
View File
@@ -14,9 +14,9 @@ WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)")
# Root-level files under kb/ that are not pages: the generated catalog map, log
# and provenance index, plus the contract that constrains the tree rather than
# living in it.
_KB_META_FILES = {"index.md", "log.md", "provenance.md", "CONTRACT.md"}
# and provenance index, plus the two documents that constrain the tree rather
# than living in it - the stack's contract and this instance's own conventions.
_KB_META_FILES = {"index.md", "log.md", "provenance.md", "CONTRACT.md", "CONVENTIONS.md"}
# The per-collection authoring contract. Unlike the meta files above it is never
# at the kb root - it sits one level down, in every collection - so it has to be
+26 -6
View File
@@ -64,7 +64,22 @@ LEGACY_CITE_RE = re.compile(r"\^\[\[([^\]|#]+)(?:\|([^\]]+))?\]\]")
# Written under the canonical name, but split_cite_block() matches the aliases
# too - a page whose block still says "## Footnotes" keeps working until it is
# translated. See chemenu/sections.py.
CITE_BLOCK_HEADING = f"## {sections.FOOTNOTES}"
#
# Resolved on access rather than bound at import (PEP 562), because the
# canonical name is now this instance's own - `kb/CONVENTIONS.md`, via
# chemenu.conventions - and a module constant would freeze whichever corpus the
# process started in. The functions below take it as a default the same way, via
# None rather than an evaluated default argument.
def __getattr__(name: str) -> str:
if name == "CITE_BLOCK_HEADING":
return f"## {sections.FOOTNOTES}"
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def cite_block_heading_default() -> str:
"""The Footnotes heading this instance writes, `## ` included."""
return f"## {sections.FOOTNOTES}"
_SOURCE_TITLE_PREFIX = "Source - "
@@ -193,18 +208,23 @@ def cite_block_heading(body: str) -> str:
alias is untranslated, not broken, and `cite sync` has to stay a no-op on
it. Translating the heading is the migration's job, not the tool's."""
match = sections.heading_re(sections.FOOTNOTES).search(body)
return match.group(0).strip() if match else CITE_BLOCK_HEADING
return match.group(0).strip() if match else cite_block_heading_default()
def render_cite_block(
definitions: dict[str, tuple[str, Optional[str]]], heading: str = CITE_BLOCK_HEADING
definitions: dict[str, tuple[str, Optional[str]]], heading: Optional[str] = None
) -> str:
"""Render the Footnotes block for `definitions` (cite_id -> (title,
qualifier)), preserving dict order. Empty dict renders "" - a page with
no citations carries no block at all."""
no citations carries no block at all.
`heading=None` means this instance's canonical Footnotes heading, resolved
at call time. It cannot be an evaluated default: the name comes from
`kb/CONVENTIONS.md`, so a default bound at import would answer for whichever
corpus the process started in."""
if not definitions:
return ""
lines = [heading, ""]
lines = [heading or cite_block_heading_default(), ""]
for cid, (title, qualifier) in definitions.items():
target = f"{title}|{qualifier}" if qualifier else title
lines.append(f"[^{cid}]: [[{target}]]")
@@ -214,7 +234,7 @@ def render_cite_block(
def render_page_body(
head: str,
definitions: dict[str, tuple[str, Optional[str]]],
heading: str = CITE_BLOCK_HEADING,
heading: Optional[str] = None,
) -> str:
"""Reassemble a page body from its non-Footnotes content and citation
definitions - the inverse of split_cite_block(). Pass the original body's
+50 -13
View File
@@ -5,10 +5,12 @@ See Also by name, and `cite add` owns the trailing Footnotes block. An author
may add any other heading they like - only the ones named here are matched by
the tool, and only these have to stay predictable.
kb/CONTRACT.md's Language rule puts page prose in the KB language. That used to
force these three to stay English, because a translated heading did not error -
it made `xref add` append a *second* section, silently. This module removes that
constraint by making the vocabulary explicit in one place.
**Which words they are is the instance's decision, not the stack's.** They
follow the KB language, and the KB language is declared in `kb/CONVENTIONS.md`
(see `chemenu.conventions`). This module used to hold `RELATIONSHIPS =
"Beziehungen"` as a Python constant, which made an instance writing its pages
in any other language edit the compiler to say so - the one place a documented
instance convention had leaked into code.
Each heading has one **canonical** name - what the tool writes - and any number
of **aliases** it still recognizes. That asymmetry is what lets a corpus migrate
@@ -16,24 +18,59 @@ page by page instead of all at once: a page still carrying `## Relationships` is
found and appended to correctly, and only takes the canonical name when the page
itself is translated. Removing an alias is therefore a breaking change for every
page not yet converted, not a cleanup.
The three module attributes below resolve on access (PEP 562), the same way
`config`'s paths do and for the same reason: a caller that repoints `KB_DIR`
must not be answered out of a value bound at import time by whichever tree the
process started in.
"""
from __future__ import annotations
import re
RELATIONSHIPS = "Beziehungen"
SEE_ALSO = "Siehe auch"
FOOTNOTES = "Fußnoten"
from chemenu import conventions
ALIASES: dict[str, tuple[str, ...]] = {
RELATIONSHIPS: ("Relationships",),
SEE_ALSO: ("See Also",),
FOOTNOTES: ("Footnotes",),
# The slots, re-exported so a caller keeps using `sections.RELATIONSHIPS` as an
# opaque handle. The value it resolves to is the heading text; the name it is
# looked up under is stable.
_SLOT_ATTRS = {
"RELATIONSHIPS": conventions.RELATIONSHIPS,
"SEE_ALSO": conventions.SEE_ALSO,
"FOOTNOTES": conventions.FOOTNOTES,
}
def __getattr__(name: str) -> str:
slot = _SLOT_ATTRS.get(name)
if slot is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
return conventions.canonical(slot)
def __dir__() -> list[str]:
return sorted([*globals(), *_SLOT_ATTRS])
def _slot_of(canonical: str) -> str:
"""The slot whose current canonical name is `canonical`.
Callers hold on to the resolved heading text (`sections.FOOTNOTES`), not to
the slot, so the lookup has to go back the other way. Falls back to matching
against every name a slot is recognized under, so a caller that resolved the
attribute before the conventions file changed still lands on the right slot.
"""
for slot in conventions.SLOTS:
if canonical == conventions.canonical(slot):
return slot
for slot in conventions.SLOTS:
if canonical in conventions.names(slot):
return slot
raise ValueError(f"{canonical!r} is not a tool-owned section heading")
def names(canonical: str) -> tuple[str, ...]:
"""Every name `canonical` is recognized under, canonical first."""
return (canonical, *ALIASES.get(canonical, ()))
return conventions.names(_slot_of(canonical))
def heading_re(canonical: str) -> re.Pattern[str]:
@@ -44,4 +81,4 @@ def heading_re(canonical: str) -> re.Pattern[str]:
def is_known(heading: str) -> bool:
"""True if `heading` is a canonical name or an alias of one."""
return any(heading in names(canonical) for canonical in ALIASES)
return any(heading in conventions.names(slot) for slot in conventions.SLOTS)
+9 -1
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import pytest
from chemenu import config
from chemenu import config, conventions
from chemenu.frontmatter_io import write_page
from chemenu.type_resolver import resolver
@@ -77,9 +77,17 @@ def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
# attribute. That binding outlives the test and hands the next one a
# corpus directory belonging to the previous tree. Cleared on both sides,
# so neither a leak from before nor one from this test can be inherited.
# One layer further in again: `conventions` parses `kb/CONVENTIONS.md` once
# and keys the result on the file's own path and stat, so a repointed
# `KB_DIR` cannot be answered out of it. Cleared here anyway, on both sides,
# for the same reason `config.reset()` is - a fixture that leaves state
# behind is the hole this file exists to close, and the cost of proving it
# cannot leak is one function call per test.
config.reset()
conventions.reset_cache()
yield home
config.reset()
conventions.reset_cache()
def use_shipped_type_specs(monkeypatch: pytest.MonkeyPatch) -> None:
+167
View File
@@ -0,0 +1,167 @@
"""Tests for `kb/CONVENTIONS.md` - the instance-owned half of the authoring rules.
Two things are under test here, and they are the two the split exists for: the
compiler reads its section headings from the corpus rather than from Python, and
a collection declares who owns its rules rather than having it inferred from the
directory name.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from chemenu import config, conventions, kb_collections, sections
GERMAN = (
"---\n"
"language: de\n"
"profile: german\n"
"sections:\n"
" relationships: Beziehungen\n"
" see_also: Siehe auch\n"
" footnotes: Fußnoten\n"
"---\n\n# conventions\n"
)
FRENCH = (
"---\n"
"language: fr\n"
"profile: none\n"
"sections:\n"
" relationships: Relations\n"
" see_also: Voir aussi\n"
" footnotes: Notes\n"
"section_aliases:\n"
" relationships: [Beziehungen]\n"
"---\n\n# conventions\n"
)
@pytest.fixture
def kb_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
kb = tmp_path / "kb"
kb.mkdir()
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "KB_DIR", kb)
conventions.reset_cache()
yield kb
conventions.reset_cache()
def _write(kb: Path, text: str) -> None:
(kb / conventions.CONVENTIONS_FILENAME).write_text(text, encoding="utf-8")
conventions.reset_cache()
def _collection(kb: Path, name: str, profile: str = "none", required: bool = False) -> Path:
directory = kb / name
directory.mkdir(parents=True, exist_ok=True)
(directory / kb_collections.CONTRACT_NAME).write_text(
f"---\nprofile: {profile}\nrequired_by_stack: {str(required).lower()}\n---\n\n# {name}\n",
encoding="utf-8",
)
return directory
def test_missing_file_falls_back_to_what_the_stack_used_to_hardcode(kb_root):
"""The state between installing this machinery and running the migration
that writes the file. Every command has to keep working through it, and the
only corpus that can be in it was written under these names."""
assert conventions.canonical(conventions.FOOTNOTES) == "Fußnoten"
assert sections.FOOTNOTES == "Fußnoten"
def test_the_compiler_writes_the_headings_the_instance_declared(kb_root):
_write(kb_root, FRENCH)
assert sections.RELATIONSHIPS == "Relations"
assert sections.SEE_ALSO == "Voir aussi"
assert sections.FOOTNOTES == "Notes"
def test_declared_aliases_and_the_pre_conventions_names_are_both_recognized(kb_root):
"""The translation path. A page still carrying the old heading has to be
found and appended to, or a language change would silently split every page
into two Relationships sections."""
_write(kb_root, FRENCH)
pattern = sections.heading_re(sections.RELATIONSHIPS)
for heading in ("## Relations", "## Beziehungen", "## Relationships"):
assert pattern.search(f"# Page\n\n{heading}\n\n- x\n"), heading
def test_the_canonical_name_is_not_duplicated_among_its_aliases(kb_root):
"""An instance declaring the pre-conventions name gets it once, not twice -
otherwise `heading_re`'s alternation carries a redundant branch and
`names()` misreports what a page could be carrying."""
_write(
kb_root,
"---\nsections:\n relationships: Relationships\n"
" see_also: See Also\n footnotes: Footnotes\n---\n",
)
names = conventions.names(conventions.RELATIONSHIPS)
assert names[0] == "Relationships"
assert len(names) == len(set(names))
def test_section_variables_are_what_a_type_spec_template_substitutes(kb_root):
_write(kb_root, GERMAN)
assert conventions.section_variables() == {
"section.relationships": "Beziehungen",
"section.see_also": "Siehe auch",
"section.footnotes": "Fußnoten",
}
def test_a_rewritten_file_is_not_answered_out_of_the_cache(kb_root):
_write(kb_root, GERMAN)
assert sections.FOOTNOTES == "Fußnoten"
_write(kb_root, FRENCH)
assert sections.FOOTNOTES == "Notes"
def test_an_incomplete_sections_block_is_reported(kb_root):
_write(kb_root, "---\nlanguage: de\nsections:\n relationships: Beziehungen\n---\n")
issues = conventions.declaration_issues()
assert any("sections.see_also" in issue for issue in issues)
assert any("sections.footnotes" in issue for issue in issues)
def test_an_unfilled_template_is_reported_like_a_missing_one(kb_root):
_write(kb_root, GERMAN.replace("language: de", f"# {config.TEMPLATE_SENTINEL}\nlanguage: de"))
assert any(config.TEMPLATE_SENTINEL in issue for issue in conventions.declaration_issues())
def test_an_absent_file_is_not_a_declaration_issue(kb_root):
"""`doctor` FAILs on absence; `docs verify` must not, or a fresh export
would be unverifiable before the setup step that writes the file."""
assert conventions.declaration_issues() == []
def test_a_collection_must_declare_its_profile_and_stack_dependence(kb_root):
_collection(kb_root, "sources", profile="sources", required=True)
(kb_root / "notes").mkdir()
(kb_root / "notes" / kb_collections.CONTRACT_NAME).write_text("# notes\n", encoding="utf-8")
issues = kb_collections.declaration_issues(kb_root)
assert any("kb/notes/COLLECTION.md has no frontmatter" in issue for issue in issues)
def test_required_by_stack_is_checked_against_the_stack_not_taken_on_trust(kb_root):
"""The one field an instance may not choose. A collection claiming the stack
depends on it would make a rename look unsafe when it is not - and, worse,
`sources` claiming otherwise would make one look safe when it is not."""
_collection(kb_root, "sources", required=False)
_collection(kb_root, "entities", required=True)
issues = kb_collections.declaration_issues(kb_root)
assert any("kb/sources/COLLECTION.md" in issue and "must be true" in issue for issue in issues)
assert any("kb/entities/COLLECTION.md" in issue and "must be false" in issue for issue in issues)
def test_a_missing_stack_required_collection_is_reported(kb_root):
_collection(kb_root, "entities")
assert any("kb/sources/ is missing" in issue for issue in kb_collections.declaration_issues(kb_root))
def test_a_correct_declaration_reports_nothing(kb_root):
_collection(kb_root, "sources", profile="sources", required=True)
_collection(kb_root, "entities", profile="entities")
assert kb_collections.declaration_issues(kb_root) == []
+31 -3
View File
@@ -113,6 +113,14 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
(kb / "entities" / "COLLECTION.md").write_text("# entities collection\n", encoding="utf-8")
(kb / "entities" / "aurora.md").write_text("---\ntype: types/entity.md\n---\n", encoding="utf-8")
(kb / "CONTRACT.md").write_text("# kb contract\n", encoding="utf-8")
(kb / "CONVENTIONS.md").write_text(
"---\nlanguage: de\nsections:\n relationships: Beziehungen\n"
" see_also: Siehe auch\n footnotes: Fußnoten\n---\n\n# this instance\n",
encoding="utf-8",
)
(kb / "CONVENTIONS.md.template").write_text(
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# conventions template\n", encoding="utf-8"
)
for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md"):
path = root / relative
@@ -262,6 +270,10 @@ def test_find_leaks_is_silent_on_a_clean_plan(repo):
"instructions/dev/commonplace-kb.md",
"kb/entities/aurora.md",
"raw/notes/personal-note.md",
# Both bind every page and both are the instance's to write, so the
# filled name must never cross - only the `.template` beside it does.
"kb/CONVENTIONS.md",
"kb/entities/COLLECTION.md",
],
)
def test_find_leaks_catches_one_instance_own_data(repo, relative):
@@ -283,12 +295,28 @@ def test_export_refuses_a_plan_that_leaks(repo, tmp_path, monkeypatch):
assert not target.exists()
def test_plan_copies_collection_contracts_not_pages(repo):
def test_plan_ships_collection_contracts_as_templates_not_pages(repo):
"""The stack's own contract crosses verbatim; the instance-owned ones cross
under the template name and are adopted by a rename. Shipping
`kb/entities/COLLECTION.md` would hand a new instance this one's authoring
conventions as though the stack had decided them."""
plan = dist_cmd.build_plan()
assert "kb/entities/COLLECTION.md" in plan
assert "kb/entities/COLLECTION.md.template" in plan
assert "kb/entities/COLLECTION.md" not in plan
assert "kb/CONTRACT.md" in plan
assert not any(relative.endswith("aurora.md") for relative in plan)
# The shipped template is the contract's own text - one source of truth in
# the origin repo, renamed across the boundary. A second file kept beside
# each contract would be a near-identical copy, maintained by hand.
assert plan["kb/entities/COLLECTION.md.template"].content == "# entities collection\n"
def test_plan_ships_the_conventions_template_and_not_the_filled_file(repo):
plan = dist_cmd.build_plan()
assert "kb/CONVENTIONS.md.template" in plan
assert "kb/CONVENTIONS.md" not in plan
def test_plan_creates_empty_raw_subdirs_not_real_content(repo):
plan = dist_cmd.build_plan()
@@ -397,7 +425,7 @@ def test_export_into_a_fresh_directory_works(repo, tmp_path):
target = tmp_path / "dist"
dist_cmd.run_export(target, dry_run=False)
assert (target / "AGENTS.md").is_file()
assert (target / "kb" / "entities" / "COLLECTION.md").is_file()
assert (target / "kb" / "entities" / "COLLECTION.md.template").is_file()
assert (target / "raw" / "notes" / ".gitkeep").is_file()
+33 -1
View File
@@ -8,7 +8,7 @@ from pathlib import Path
import pytest
from chemenu import config
from chemenu import config, conventions
from chemenu.commands import doctor, instructions_cmd
@@ -30,6 +30,12 @@ def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
(kb / "log.md").write_text("# Log\n", encoding="utf-8")
(kb / "provenance.md").write_text("# Provenance\n", encoding="utf-8")
(kb / "CONTRACT.md").write_text("# kb contract\n", encoding="utf-8")
(kb / "CONVENTIONS.md").write_text(
"---\nlanguage: en\nprofile: none\nsections:\n"
" relationships: Relationships\n see_also: See Also\n footnotes: Footnotes\n"
"---\n\n# conventions\n",
encoding="utf-8",
)
(root / "VERSION").write_text("0.1.0\n", encoding="utf-8")
(root / "USER.md").write_text("# USER.md - Fixture\n", encoding="utf-8")
(root / "SOUL.md").write_text("# SOUL.md - Fixture\n", encoding="utf-8")
@@ -194,6 +200,32 @@ def test_a_renamed_but_unfilled_template_fails(instance):
assert "template" in next(c.detail for c in checks if c.name == "personalization")
def test_conventions_are_ok_when_declared(instance):
checks = doctor.run_doctor()
assert _status(checks, "conventions") == "OK"
assert "Relationships" in next(c.detail for c in checks if c.name == "conventions")
def test_missing_conventions_fail(instance):
"""Unlike `ENVIRONMENT.md`, this one is not optional: `xref add` and
`cite add` write headings out of it, so an instance without it is being
answered by whatever the stack hardcoded before the file existed."""
(config.KB_DIR / conventions.CONVENTIONS_FILENAME).unlink()
checks = doctor.run_doctor()
assert _status(checks, "conventions") == "FAIL"
assert "missing" in next(c.detail for c in checks if c.name == "conventions")
def test_conventions_with_an_incomplete_sections_block_fail(instance):
"""Present and deciding nothing - the same failure mode the personalization
sentinel check exists for, one directory down."""
(config.KB_DIR / conventions.CONVENTIONS_FILENAME).write_text(
"---\nlanguage: en\nsections:\n relationships: Relationships\n---\n", encoding="utf-8"
)
conventions.reset_cache()
assert _status(doctor.run_doctor(), "conventions") == "FAIL"
def test_environment_is_ok_when_absent(instance):
"""The file is optional, so absence is a healthy end state - a FAIL here
would make it mandatory through the back door."""
+25
View File
@@ -75,6 +75,31 @@ def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
assert "# gateway.example.net" in body
def test_scaffolded_body_carries_the_headings_this_instance_declared(monkeypatch, kb_dir):
"""The type-spec writes `## {section.relationships}`, not a heading text, so
an instance in another language scaffolds its own headings without editing
anything under `types/`. This is that path end to end."""
from chemenu import conventions
(kb_dir / conventions.CONVENTIONS_FILENAME).write_text(
"---\nlanguage: fr\nprofile: none\nsections:\n relationships: Relations\n"
" see_also: Voir aussi\n footnotes: Notes\n---\n\n# conventions\n",
encoding="utf-8",
)
conventions.reset_cache()
try:
result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "passerelle", "--set", "entity_type=system",
])
assert result.exit_code == 0, result.output
_fm, body = read_page(kb_dir / "entities/systems/passerelle.md")
assert "## Relations" in body
assert "## Voir aussi" in body
assert "{section." not in body
finally:
conventions.reset_cache()
def test_new_entity_applies_schema_declared_defaults(monkeypatch, kb_dir):
"""provenance and confidence are no longer Typer flag defaults - they
come from the schema's own `default:`, so omitting them still yields a
+5 -4
View File
@@ -1,6 +1,6 @@
from typer.testing import CliRunner
from chemenu import sections
from chemenu import conventions
from chemenu.cli import app
runner = CliRunner()
@@ -45,9 +45,10 @@ def test_types_describe_entity_reports_schema_and_body():
]
assert fields_by_name["tags"]["required"] is False
# The body must carry the page skeleton an authoring LLM works from. Anchored on the
# tool-owned section vocabulary rather than a literal, so that translating the spec - or
# the section names themselves - does not turn this into a tripwire.
assert f"## {sections.RELATIONSHIPS}" in data["body"]
# template *variable* rather than on any heading text: the spec no longer names the
# tool-owned sections at all - `kb/CONVENTIONS.md` does, and `new` substitutes it - so a
# literal here would assert the very coupling that was removed.
assert f"## {{section.{conventions.RELATIONSHIPS}}}" in data["body"]
def test_types_describe_unknown_name_fails_cleanly():