Files
chemenu/tools/chemenu/conventions.py
T
torben 502971d147
CI / verify (push) Successful in 53s
Release / release (push) Successful in 38s
feat: Autorenkonventionen nach Eigentum geschnitten - kb/CONVENTIONS.md, deklarierte Collections (3.0.0)
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
2026-09-02 15:02:10 +02:00

225 lines
9.0 KiB
Python

"""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