Files
chemenu/tools/chemenu/conventions.py
T
torben 177c7e9ce8
CI / verify (push) Successful in 55s
Release / release (push) Successful in 38s
feat: Prosa ist kein Identifier - Link-Taxonomie als Enum, generierte Regionen mit Markern (4.0.0)
Files changed:
- .gitea/workflows/ci.yml
- AGENTS.md
- CHANGES.md
- VERSION
- instructions/CONTRACT.md
- instructions/link-taxonomy.md
- instructions/migrations/4.0.0-link-taxonomy.md
- instructions/setup-instance.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/blocks.py
- tools/chemenu/cli.py
- tools/chemenu/commands/cite_cmd.py
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/links_cmd.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/xref.py
- tools/chemenu/conventions.py
- tools/chemenu/corpus_diff.py
- tools/chemenu/frontmatter_io.py
- tools/chemenu/kb_collections.py
- tools/chemenu/kb_state.py
- tools/chemenu/links.py
- tools/chemenu/lint_core.py
- tools/chemenu/provenance.py
- tools/chemenu/sections.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_blocks.py
- tools/chemenu/tests/test_cite_cmd.py
- tools/chemenu/tests/test_conventions.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_migrate_cmd.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_pipeline_l0.py
- tools/chemenu/tests/test_types_cmd.py
- tools/chemenu/tests/test_xref.py
- types/concept.schema.yaml
- types/entity.md
- types/entity.schema.yaml
- types/instruction.schema.yaml
- types/type-spec.md
- work/link-taxonomy-migration/README.md
- work/link-taxonomy-migration/plan.md
2026-09-02 18:39:22 +02:00

178 lines
7.3 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 two tool-owned regions, keyed by the block name in `chemenu.blocks`. The
# block name is the identifier - it is what the marker pair carries and what the
# tool locates the region by - while the heading text below it is prose the
# instance chooses.
#
# `see_also` is gone as a section: it was the reciprocal half of the old
# bidirectional `xref add`, and under authored directional edges it is a *label*
# inside the links block rather than a region of its own.
SECTIONS_KEY = "sections"
LANGUAGE_KEY = "language"
# What a heading renders as when the instance has not said. Purely cosmetic, and
# that is a genuine change from before: while the tool located a region by
# matching this text, a wrong default silently split a page into two sections and
# `xref add` appended to the wrong one. Now the marker pair carries the identity,
# so a region rendered under the wrong words is a *display* fault that the next
# write repairs by itself once `kb/CONVENTIONS.md` says otherwise.
#
# So this is a fallback for the window between installing the machinery and
# writing the conventions file - `doctor` is what makes that window loud - and
# not a language the compiler has an opinion about.
DEFAULT_HEADINGS: dict[str, str] = {
"links": "Relationships",
"footnotes": "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 heading(block: str) -> str:
"""The heading this instance renders above `block`'s generated region."""
declared = _mapping(SECTIONS_KEY).get(block)
if isinstance(declared, str) and declared.strip():
return declared.strip()
return DEFAULT_HEADINGS.get(block, block.title())
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.
"""
from chemenu import blocks
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 headings this instance renders"
]
declared = frontmatter.get(SECTIONS_KEY)
if not isinstance(declared, dict):
return [
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}:` must be a mapping of "
f"{'/'.join(blocks.BLOCKS)} to the heading this instance renders above it"
]
for block in blocks.BLOCKS:
value = declared.get(block)
if not isinstance(value, str) or not value.strip():
issues.append(
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{block}` is missing or empty - "
f"the generated `{block}` region would render under a default heading rather "
"than this instance's own"
)
for block in sorted(set(declared) - set(blocks.BLOCKS)):
issues.append(
f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{block}` is not a region the tool "
f"generates; the regions are {', '.join(blocks.BLOCKS)}"
)
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