18ae28f918
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.
500 lines
21 KiB
Python
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()}."
|
|
)
|