"""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:` works - 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 hedging rule, 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