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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) == []
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user