Files
chemenu/tools/chemenu/commands/docs_verify.py
T
torben 18ae28f918
CI / verify (push) Failing after 32s
Release / release (push) Successful in 38s
Chemenu 2.1.0 - deterministischer Wissenskompiler
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki:
raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht
tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren
Grenzen in Code durchgesetzt sind statt im Prompt.

Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige
Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses
Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44
Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist.

Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den
Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er
dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs.

Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte.
Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet -
siehe NOTICE.
2026-09-01 16:26:14 +02:00

500 lines
21 KiB
Python

"""`wikitool docs verify` - machine-check the documentation copies that can be
re-derived from the code and the repo layout.
The wiki's own rule is that a derived copy of recomputable truth must be
checked or absent. Three such copies survive on purpose because they earn
their keep as reading material:
1. `tools/CONTRACT.md`'s command table (re-derivable from the Typer app)
2. the collection and stage contracts (their existence and placement, not
their content)
3. the absence of pre-type-system `type: entity` frontmatter in the
contract docs - the exact drift that left a stale comparison template
sitting in AGENTS.md for months after the type migration
A fourth check has a different shape: `.gitignore` is not documentation, but
it is the one file that can silently un-publish content. A pattern excluding a
file under `raw/` or `kb/` is a data-loss bug - `sources coverage` reads the
filesystem and reports the file as covered, while `publish` (`git add -A`)
never commits it, so a fresh clone has a broken `raw_files:` reference. The
same check runs in reverse over `reports/`, where a *missing* ignore rule would
start committing derived output.
A fifth has the same shape as the fourth: `VERSION` is not documentation
either, but it is the one number a release stamps into every distributed
instance, and a version raised without a changelog entry ships release notes
that describe the previous release.
Everything here is a hard oracle: a set comparison or a regex, no judgment.
Content quality of the contracts themselves stays with the LLM.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Optional
import typer
from chemenu import config, 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.")
# Contracts that are not COLLECTION.md files, because their directories are not
# collections. Each is the authoring contract for one stage or layer.
STAGE_CONTRACTS = (
"raw/CONTRACT.md",
"kb/CONTRACT.md",
"types/type-spec.md",
"reports/CONTRACT.md",
"work/CONTRACT.md",
"tools/CONTRACT.md",
"instructions/CONTRACT.md",
)
# Directories whose contents are the repository's reason to exist, and which
# therefore may never be excluded by an ignore rule. `work/` is here because a
# workshop is the only record of a multi-session run: unlike `reports/`, losing
# it loses judgment that nothing can recompute.
CONTENT_DIRS = ("raw", "kb", "work")
# Paths that must never be ignored. They deliberately do not have to exist:
# `git check-ignore --no-index` answers about the *pattern set*, not the
# filesystem, so these catch a trap before a real file ever falls into it.
# Every entry corresponds to a pattern that was genuinely swallowing content
# before the 2026-08-13 `.gitignore` rewrite.
IGNORE_CANARIES = (
"raw/notes/template.md", # was caught by `*temp*`
"raw/notes/temperature-sensors.md", # was caught by `*temp*`
"raw/notes/scratch.md", # was caught by `*scratch*`
"raw/assets/build.log", # was caught by `*.log`
"raw/documents/go.mod", # was caught by `go.mod`
"raw/assets/bin/tool.txt", # was caught by `bin/`
"raw/assets/diagram.orig", # was caught by `*.orig`
"kb/concepts/Template Method.md", # was caught by `*temp*`
"kb/entities/tools/core.md", # was caught by `core`
"kb/entities/tools/tags.md", # was caught by `tags`
# `work/` is tracked on purpose: unlike `reports/`, a workshop holds
# judgment in progress that nothing can recompute, so an ignore rule
# reaching it would silently discard a multi-session run's only record.
"work/ingest-documents-example/extract-00-architecture.md",
)
# The mirror image of IGNORE_CANARIES. `reports/` holds derived output that must
# stay *out* of git, so an ignore rule going missing there is as much a bug as an
# ignore rule appearing over content - it would start committing a second,
# drifting copy of something `wikitool lint` recomputes on demand. The contract
# is the one file that must survive the rule.
#
# The skill directories are here for a different reason: they are copies of
# `instructions/<name>/SKILL.md`, published by `wikitool instructions sync`.
# Committing them would create exactly the drifting second copy this repo
# refuses to keep anywhere else.
#
# `ENVIRONMENT.md` is a third reason again: it is per-checkout, so committing
# one working copy's harness, MCP servers and remotes would hand every other
# clone a file that is confidently wrong rather than honestly absent. Its
# `.template` sits in REQUIRED_TRACKED_PATHS below, because the obvious
# careless pattern (`ENVIRONMENT.md*`) would swallow both.
#
# The coverage paths are the reports/ argument applied to `pytest --cov`
# output: derived, recomputable, and in the way of `publish`'s `git add -A`.
REQUIRED_IGNORE_CANARIES = (
"reports/Lint Report 2026-01-01.md",
".agents/skills/wiki-query/SKILL.md",
".claude/skills/wiki-query/SKILL.md",
"ENVIRONMENT.md",
"tools/coverage.xml",
"tools/htmlcov/index.html",
)
REQUIRED_TRACKED_PATHS = (
"reports/CONTRACT.md",
"instructions/CONTRACT.md",
"instructions/wiki-query/SKILL.md",
"ENVIRONMENT.md.template",
)
CLI_README = config.ROOT / "tools" / "CONTRACT.md"
# The root README is the "absent" half of the checked-or-absent rule: it used to
# carry its own copy of the command table, which drifted because nothing
# compared it to anything. It now points at tools/CONTRACT.md instead, and this
# check keeps it that way.
ROOT_README = config.ROOT / "README.md"
# `README.md` is for humans, `CONTRACT.md` is the agent-facing contract, and a
# stage may carry both. The split only holds while the README stays prose: the
# first thing that drifted last time was a second copy of the command table, and
# tools/README.md is exactly the file it drifted in. INSTALL.md is here for the
# same reason: it is human-facing prose about installing an instance, and the
# command reference lives exactly once, in tools/CONTRACT.md.
STAGE_READMES = ("tools/README.md", "INSTALL.md")
# Docs that must not re-introduce the pre-migration bare-enum `type:` form.
# The per-collection contracts are appended at call time, since which ones exist
# is a filesystem question rather than a constant.
TYPE_GUARD_DOCS = ("AGENTS.md", "README.md", *STAGE_CONTRACTS)
LEGACY_TYPE_RE = re.compile(r"^type:\s*(entity|concept|source|comparison)\s*$", re.MULTILINE)
# First backticked cell of a markdown table row, e.g. "| `xref add --a ...` | ... |"
TABLE_CELL_RE = re.compile(r"^\|\s*`([^`]+)`", re.MULTILINE)
def registered_commands() -> set[str]:
"""Every command path the CLI exposes, e.g. {'new', 'xref add', ...}.
Imported lazily: `chemenu.cli` imports this module, so a top-level
import would be circular.
"""
from chemenu import cli
paths: set[str] = set()
for command in cli.app.registered_commands:
name = command.name or (command.callback.__name__.replace("_", "-") if command.callback else None)
if name:
paths.add(name)
for group in cli.app.registered_groups:
group_name = group.name
sub_app = group.typer_instance
if not group_name or sub_app is None:
continue
for command in sub_app.registered_commands:
name = command.name or (command.callback.__name__.replace("_", "-") if command.callback else None)
if name:
paths.add(f"{group_name} {name}")
return paths
def top_level_names() -> set[str]:
return {path.split(" ", 1)[0] for path in registered_commands()}
def documented_commands(readme_text: str) -> list[str]:
return [match.group(1).strip() for match in TABLE_CELL_RE.finditer(readme_text)]
def check_cli_readme() -> list[str]:
"""Every registered command must appear in tools/CONTRACT.md's command
table, and every command documented there must exist.
The reverse check matches a documented cell against the full registered
command path (e.g. `xref add`, `confidence init-base`), not just its first
token - checking only the top-level word would let a typo'd or invented
subcommand (`xref frobnicate`) sit undetected next to a real command group
(`xref`) forever.
"""
if not CLI_README.exists():
return [f"{CLI_README.relative_to(config.ROOT)} is missing"]
text = CLI_README.read_text(encoding="utf-8")
cells = documented_commands(text)
issues = []
registered = sorted(registered_commands())
for command_path in registered:
if not any(cell == command_path or cell.startswith(command_path + " ") for cell in cells):
issues.append(f"command `{command_path}` is not documented in tools/CONTRACT.md")
for cell in cells:
if not any(cell == cp or cell.startswith(cp + " ") for cp in registered):
first_token = cell.split(" ", 1)[0]
issues.append(f"tools/CONTRACT.md documents `{cell}`, but `{first_token}` is not a wikitool command")
return issues
def check_collection_contracts() -> list[str]:
"""The three structural rules that define what a collection is.
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".
"""
issues = []
collections = {path.name for path in kb_collections.iter_kb_collections()}
if config.KB_DIR.is_dir():
for child in sorted(config.KB_DIR.iterdir()):
if child.is_dir() and child.name not in collections:
issues.append(
f"kb/{child.name}/ has no COLLECTION.md - every directory under kb/ is a "
"collection and needs its own authoring contract"
)
for stray in kb_collections.stray_collection_contracts():
relative = stray.relative_to(config.ROOT)
if kb_collections.kb_collection_of(stray.parent) is not None:
issues.append(
f"{relative} is nested inside a collection - a subdirectory is an area and "
"inherits the enclosing contract"
)
else:
issues.append(
f"{relative} is outside kb/ - only kb/ holds collections; other directories "
"carry a CONTRACT.md instead"
)
for relative_path in STAGE_CONTRACTS:
if not (config.ROOT / relative_path).exists():
issues.append(f"{relative_path} is missing - it is the authoring contract for its stage")
return issues
def check_legacy_type_blocks() -> list[str]:
issues = []
guarded = [
*TYPE_GUARD_DOCS,
*(
str((path / "COLLECTION.md").relative_to(config.ROOT))
for path in kb_collections.iter_kb_collections()
),
]
for relative_path in guarded:
path = config.ROOT / relative_path
if not path.exists():
continue
for match in LEGACY_TYPE_RE.finditer(path.read_text(encoding="utf-8")):
line_number = path.read_text(encoding="utf-8")[: match.start()].count("\n") + 1
issues.append(
f"{relative_path}:{line_number} uses the pre-migration `type: {match.group(1)}` form "
f"- pages reference types by path (`types/{match.group(1)}.md`)"
)
return issues
def command_table_free_readmes() -> list[Path]:
"""Every README that must not carry a copy of the command table.
Built at call time rather than at import, so a test can point ROOT_README at
a fixture.
"""
return [ROOT_README, *(config.ROOT / relative for relative in STAGE_READMES)]
def check_readmes_have_no_command_table() -> list[str]:
"""No README may re-list wikitool commands in a table.
A derived copy of recomputable truth is either checked or absent. The
command table is checked in tools/CONTRACT.md, so a second copy in a README
has to be absent - otherwise it drifts silently, which is exactly what it
did.
"""
known_top_level = top_level_names()
issues = []
for readme in command_table_free_readmes():
if not readme.exists():
continue
offenders = sorted(
{
cell
for cell in documented_commands(readme.read_text(encoding="utf-8"))
if cell.split(" ", 1)[0] in known_top_level
}
)
issues += [
f"{rel_path(readme)} has a table row for `{cell}` - the command reference lives in "
"tools/CONTRACT.md, which `docs verify` checks; link to it instead of copying it"
for cell in offenders
]
return issues
def _git(args: list[str], stdin: Optional[str] = None) -> Optional[subprocess.CompletedProcess]:
"""Run a git command in the repo root, or return None if git is unavailable
or this is not a checkout. Returning None (rather than raising) keeps
`docs verify` usable in a source tree without git, where the ignore rules
are unknowable rather than wrong."""
try:
return subprocess.run(
["git", *args], cwd=config.ROOT, capture_output=True, text=True, input=stdin
)
except OSError:
return None
def _check_ignore(paths: tuple[str, ...]) -> Optional[list[str]]:
"""The subset of `paths` the repo's ignore rules would exclude, or None if
git cannot answer.
`--no-index` makes this a pure question about the pattern set: it does not
matter whether the path exists or is tracked, only whether a rule would
swallow it. That is what turns a latent trap into a failing check.
None and `[]` have to stay distinguishable. For the forward canaries an
unknowable answer and an empty answer both mean "no finding", but the
reverse canaries assert that a path *is* ignored - so collapsing None into
`[]` would turn a missing git binary into a fabricated failure.
"""
result = _git(["check-ignore", "--no-index", "-z", "--stdin"], stdin="\0".join(paths))
if result is None or result.returncode not in (0, 1):
return None
return [path for path in result.stdout.split("\0") if path]
def ignored_canaries(canaries: tuple[str, ...] = IGNORE_CANARIES) -> list[str]:
"""The subset of `canaries` the ignore rules would exclude; empty if
unknowable."""
return _check_ignore(canaries) or []
def ignored_content_files() -> list[str]:
"""Files that actually exist under a CONTENT_DIRS directory but are ignored,
and so would never be committed by `wikitool publish`."""
result = _git(
["ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--", *CONTENT_DIRS]
)
if result is None or result.returncode != 0:
return []
return [path for path in result.stdout.split("\0") if path]
def check_ignored_content() -> list[str]:
"""No file under `raw/`, `kb/` or `work/` may be excluded by an ignore rule,
and everything under `reports/` except its README must be."""
issues = [
f"`{path}` exists but is gitignored - `wikitool publish` will never commit it"
for path in ignored_content_files()
]
issues += [
f"an ignore rule would swallow `{path}` - anchor the pattern in .gitignore "
"(see its header note) so content cannot be silently un-published"
for path in ignored_canaries()
]
still_ignored = _check_ignore(REQUIRED_IGNORE_CANARIES)
if still_ignored is not None:
issues += [
f"`{path}` is NOT ignored - generated reports must stay out of git, or they become "
"a second copy of what `wikitool lint` recomputes on demand"
for path in REQUIRED_IGNORE_CANARIES
if path not in still_ignored
]
wrongly_ignored = _check_ignore(REQUIRED_TRACKED_PATHS)
if wrongly_ignored is not None:
issues += [
f"`{path}` is ignored - it must survive the reports/ ignore rule"
for path in REQUIRED_TRACKED_PATHS
if path in wrongly_ignored
]
return issues
def check_version_changelog() -> list[str]:
"""`VERSION` must parse, and the newest versioned `CHANGES.md` entry must
name it.
This is the check that makes `version bump` more than a convenience: a
version raised with nothing written about it would ship a release whose
notes describe the previous one. A changelog with *no* versioned entry at
all is fine - that is a fresh distribution, and this repo's own pre-
versioning history, neither of which claims to describe the current
version.
"""
version_path = config.ROOT / version_mod.VERSION_FILENAME
if not version_path.is_file():
return [
f"{version_mod.VERSION_FILENAME} is missing - the stack has no version for "
"`dist export` to stamp or `version check` to compare"
]
try:
declared = version_mod.Version.parse(version_path.read_text(encoding="utf-8"))
except version_mod.VersionError as exc:
return [f"{version_mod.VERSION_FILENAME}: {exc}"]
changes_path = config.ROOT / version_mod.CHANGES_FILENAME
if not changes_path.is_file():
return [f"{version_mod.CHANGES_FILENAME} is missing - a version has nowhere to be explained"]
documented = version_mod.top_changes_version(changes_path.read_text(encoding="utf-8"))
if documented is not None and documented != declared:
return [
f"{version_mod.VERSION_FILENAME} says {declared}, but the newest versioned "
f"{version_mod.CHANGES_FILENAME} entry is {documented} - run "
"`wikitool version bump` (which writes both), or fix whichever is wrong"
]
return []
def _second_changes_version(text: str) -> Optional["version_mod.Version"]:
"""The version named by the second-newest versioned entry, or None."""
seen = [
version_mod.Version.parse(match.group(1))
for match in version_mod._CHANGES_ENTRY_RE.finditer(text)
]
return seen[1] if len(seen) > 1 else None
def check_migration_for_boundary() -> list[str]:
"""A version that crosses the compatibility boundary must say how to cross it.
`version check` tells an instance that it must migrate. Without this, that
is where the trail ends - the instance knows it is behind and nothing tells
it what to do. So a boundary-crossing version needs either a migration
document targeting it, or an explicit statement in its changelog entry that
no content has to change.
Only the newest entry is checked. Older boundaries were either satisfied
when they were written or cannot be fixed retroactively, and re-reporting
them forever would make the check noise.
"""
from chemenu import kb_state
changes_path = config.ROOT / version_mod.CHANGES_FILENAME
version_path = config.ROOT / version_mod.VERSION_FILENAME
if not changes_path.is_file() or not version_path.is_file():
return [] # already reported by check_version_changelog
text = changes_path.read_text(encoding="utf-8")
current = version_mod.top_changes_version(text)
previous = _second_changes_version(text)
if current is None or previous is None:
return [] # the first versioned entry has no predecessor to cross from
if current.compat_key == previous.compat_key:
return []
if version_mod.MIGRATION_NONE_MARKER in (version_mod.changes_section(text, current) or ""):
return []
if any(m.target == current for m in kb_state.load_migrations()):
return []
return [
f"{current} crosses the compatibility boundary from {previous}, so every existing "
f"instance must migrate - but no document under "
f"{rel_path(kb_state.migrations_dir())}/ targets it, and its {version_mod.CHANGES_FILENAME} "
f"entry does not carry `{version_mod.MIGRATION_NONE_MARKER}`. Write the migration "
"(instructions/migrate-corpus.md), or record why none is needed"
]
@app.command("verify")
def verify():
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, and version/changelog agreement."""
issues = (
check_cli_readme()
+ check_readmes_have_no_command_table()
+ check_collection_contracts()
+ check_legacy_type_blocks()
+ check_ignored_content()
+ check_version_changelog()
+ check_migration_for_boundary()
)
if issues:
fail("Documentation issues found:\n" + "\n".join(f"- {i}" for i in issues))
success(
f"Docs verified: {len(registered_commands())} command(s) documented, "
f"{len(kb_collections.iter_kb_collections())} collection(s) and "
f"{len(STAGE_CONTRACTS)} stage contract(s) present, no legacy type blocks, "
f"{len(IGNORE_CANARIES)} ignore canaries clear, "
f"{version_mod.CHANGES_FILENAME} documents version "
f"{(config.ROOT / version_mod.VERSION_FILENAME).read_text(encoding='utf-8').strip()}."
)