"""`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, 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.") # 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//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", # 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" # 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. # # DEVELOPMENT.md joined them after it drifted the same way (Gitea #47): it grew # a table describing what each verify command checks, which had to be removed by # hand because nothing compared it to anything. It is not shipped - dist_cmd # .ROOT_FILES excludes it - and that is not an argument against listing it here: # `check_readmes_have_no_command_table` skips a file that does not exist, so in # a distributed instance this entry is simply inert, while in the dev checkout # (the only place the file exists, and the only place it can drift) it is # checked. The name is now narrower than the tuple - these are the human-facing # prose docs that must not re-list commands, stage README or not. STAGE_READMES = ("tools/README.md", "INSTALL.md", "DEVELOPMENT.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 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/` + 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 = [] 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") issues += kb_collections.declaration_issues() issues += conventions.declaration_issues() issues += check_stack_required_types() return issues def check_stack_required_types() -> list[str]: """The minimum the stack asks of the type layer, and nothing beyond it. The four page type-specs belong to the instance: it may translate them, rewrite their templates, add sections. What it may not do is remove the one type the provenance path is built on, or drop the field that path reads. Everything else about `types/source.md` - its prose, its template, its title prefix, its directory - is the instance's, and is deliberately not checked here. """ from chemenu.type_resolver import resolver issues: list[str] = [] for type_name in kb_collections.STACK_REQUIRED_TYPES: try: type_path = resolver.find_type_by_name(type_name) except (ValueError, OSError) as exc: issues.append(f"types/ could not be read to find the `{type_name}` type: {exc}") continue if not type_path: issues.append( f"no type-spec declares `name: {type_name}` - `sources coverage`, `[^cite-id]` " f"resolution and `kb/provenance.md` all ask `page.kind == \"{type_name}\"`, so " f"without it the whole raw/ -> kb/ provenance path resolves against nothing" ) continue try: schema = resolver.get_schema(type_path) or {} except (ValueError, OSError) as exc: issues.append(f"{type_path}: its schema could not be read: {exc}") continue declared = set(schema.get("required") or []) for field in kb_collections.STACK_REQUIRED_TYPE_FIELDS.get(type_name, ()): if field not in declared: issues.append( f"{type_path}: its schema must require `{field}` - it is what the " f"provenance path reads, and a `{type_name}` page without it claims no " f"raw material at all" ) 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. `VERSION` may name a running candidate (`-beta.N`) rather than a release - `Version.parse`/equality read the suffix like any other component, so a candidate is compared exactly like a release here. 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 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, against the **last release** rather than the entry beneath it - between two candidates of the same running upgrade (`4.4.0-beta.2` above `4.4.0-beta.1`) there is no boundary at all, and comparing to the entry beneath would find none even when the candidate genuinely crosses one relative to what is actually installed anywhere. See instructions/dev/version-parts.md. """ 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 = version_mod.last_release(text) if current is None or previous is None: return [] # no release recorded yet to cross from (fresh distribution) 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.base 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" ] def check_breaking_change_for_boundary() -> list[str]: """A version that crosses the compatibility boundary must say what breaks. Separate from `check_migration_for_boundary`, because the two answer different questions: that one asks whether the *content* has to move, this one whether the operator was told the swap is not drop-in at all. A boundary crossing with an untouched corpus - a renamed feed, artefact, import name or flag - satisfies that check and still leaves every existing instance with something to do by hand. Only the newest entry is checked, against the **last release** - see `check_migration_for_boundary` for why the entry beneath it is the wrong comparison once a candidate can span more than one bump. """ 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 = version_mod.last_release(text) if current is None or previous is None: return [] # no release recorded yet to cross from (fresh distribution) if current.compat_key == previous.compat_key: return [] if version_mod.BREAKING_CHANGE_MARKER in (version_mod.changes_section(text, current) or ""): return [] return [ f"{current} crosses the compatibility boundary from {previous}, so it is not a drop-in " f"replacement - but its {version_mod.CHANGES_FILENAME} entry carries no " f"`{version_mod.BREAKING_CHANGE_MARKER}` line saying what stops working. Add it " "(`version bump --breaking` writes it; see instructions/dev/version-parts.md)" ] @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() + check_breaking_change_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()}." )