Files
chemenu/tools/chemenu/commands/docs_verify.py
T
torben 55f65c1ab1
CI / verify (push) Failing after 45s
Release / release (push) Successful in 38s
fix: types/type-spec.schema.yaml enforced against real type-spec frontmatter, doc-pull-through.md docs/-page count corrected (closes #105)
Files changed:
- CHANGES.md
- VERSION
- instructions/dev/doc-pull-through.md
- tools/CONTRACT.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/tests/test_docs_verify.py
- types/type-spec.md
- types/type-spec.schema.yaml
2026-09-15 19:45:33 +02:00

962 lines
44 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 two command tables - § Commands and § Error
contracts - each re-derivable from the Typer app and checked
independently, in both directions, so a row surviving in one table
cannot hide its own deletion from the other
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.
A sixth checks a *reference* rather than a copy: no document `dist export`
ships may cite an issue number, because the board those numbers live on
exists only in the origin repo.
A seventh checks the other half of the same reference problem: every relative
markdown link in a reference file - `toc.target_files()`'s scope, the same one
the table-of-contents check uses - must resolve to a file that actually
exists. A link with the wrong `../` count is invisible to every check above:
it is present, it names an existing command or contract by title, and nothing
renders it to notice the target is unreachable. The complementary half - that
`instructions/<name>/SKILL.md` never carries a relative markdown link at all,
because `instructions sync` copies it to a different depth than its links
assume - is `instructions verify`'s job, not this one, since that module
already owns the Skill/Instruction split (`skill_dirs()` vs
`instruction_files()`).
An eighth checks the type layer against its own schema: every file under
`types/` declaring `type: types/type-spec.md` must validate against
`types/type-spec.schema.yaml`. Before this check existed the schema had
already drifted behind two fields real type-specs carry (`root:`,
`capture_fields:`) while `additionalProperties: false` sat there describing a
contract nothing enforced - the exact "checked or absent" failure this file's
opening paragraph names, just one level up, for the schema that describes the
type layer instead of a copy the type layer's code produces (Gitea #105).
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, markdown_code, toc, version as version_mod
from chemenu.commands import dist_cmd
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",
# The ingest inbox (Gitea #58, raw/CONTRACT.md "Getting a file in"). Unlike
# raw/ itself, a file here must never be committed - promotion via
# `wikitool raw accept` is what makes it immutable, not the drop - so this
# is the one canary in this tuple asserting the *opposite* of raw/'s own
# backstop a few lines above. Flat since Gitea #67 - incoming/ no longer
# has type subdirectories, so the probe sits directly in it.
"incoming/probe.pdf",
# The MCP `submit` tool's quarantine (Gitea #32) - stronger than
# `incoming/` above: read by no command in the ordinary pipeline, not
# only uncommitted. No type subdirectory either, for the same reason.
"mcp-upload/probe.pdf",
# The `submit` tool's opt-in, same shape as `.wikitool-remotes.json`/
# `.wikitool-telemetry.json` a few lines below - per-checkout, never
# committed.
".wikitool-upload.json",
)
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",
# The ingest inbox's one anchor file (Gitea #88). `/incoming/*` ignores
# everything else dropped there, same as the old `/incoming/` did - but
# unlike that form, a directory pattern, this one lets a negation actually
# re-include a single file, so a fresh clone gets the directory without
# `instructions/bootstrap.md` recreating it by hand. A future return to
# the directory form would silently drop this file again; this canary is
# what makes that regression fail loudly instead.
"incoming/.gitkeep",
)
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)
# tools/CONTRACT.md carries two tables whose first cell is a backticked
# command path - § Commands and § Error contracts - and `check_cli_readme`
# must not treat them as one pot (Gitea #91): a row deleted from one used to
# go unnoticed as long as the same name survived in the other, and the
# second table was not enforced against anything at all.
COMMANDS_HEADING = "## Commands"
ERROR_CONTRACTS_HEADING = "## Error contracts"
def section_text(full_text: str, heading: str) -> str:
"""The text of one `##`-level section: from just after `heading`'s own
line up to the next `#`- or `##`-level heading, or the end of the
document.
Raises `ValueError` if `heading` is not found verbatim, rather than
falling back to scanning the whole document - a renamed heading has to
surface as a failure, because silently widening the scope back to
"everything" is exactly the bug this function exists to prevent from
reappearing under a different name.
"""
pattern = re.compile(
r"^" + re.escape(heading) + r"[ \t]*\n(.*?)(?=^#{1,2}[ \t]|\Z)",
re.MULTILINE | re.DOTALL,
)
match = pattern.search(full_text)
if match is None:
raise ValueError(f"no {heading!r} heading found")
return match.group(1)
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 own
§ Commands table, and separately in its § Error contracts table - each
direction checked per table, independently of the other.
The two tables used to be read as one pot: `TABLE_CELL_RE` matched a
backticked first cell anywhere in the file, so a row deleted from
§ Commands went unnoticed as long as the same name still had a row in
§ Error contracts, and § Error contracts was never itself compared
against the registered commands (Gitea #91). `section_text` scopes each
table to the text between its own `##` heading and the next one, and
raises rather than silently scanning the whole file if a heading has been
renamed or removed - a renamed heading must be reported, not read as
"table now empty" or "table now everything".
Within a section, only the first backticked cell of each row is read -
a changed flag or a rewritten description in an existing row is invisible
to this check, on purpose: it verifies presence, never prose.
The reverse check matches a documented cell against the full registered
command path (e.g. `xref add`, `migrate verify`), 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")
registered = sorted(registered_commands())
issues: list[str] = []
for heading, label in (
(COMMANDS_HEADING, "§ Commands"),
(ERROR_CONTRACTS_HEADING, "§ Error contracts"),
):
try:
section = section_text(text, heading)
except ValueError as exc:
issues.append(
f"tools/CONTRACT.md: {exc} - its {label} table cannot be checked against the CLI"
)
continue
cells = documented_commands(section)
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's {label} table"
)
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's {label} table 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/<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 = []
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_type_spec_frontmatter() -> list[str]:
"""Every type-spec's own frontmatter must validate against
`types/type-spec.schema.yaml` - the schema that describes the type layer
gets the same enforcement any other type's schema gets (Gitea #105).
Before this check nothing ever called `validate_frontmatter` against a
type-spec's own frontmatter, so the schema had quietly drifted behind two
fields real type-specs actually carry (`root:`, `capture_fields:`)
without anything failing - `additionalProperties: false` described a
contract that bound nothing. `resolver.list_type_specs()` already reads
every file's frontmatter once for `wikitool types list`; reusing it here
means this check costs no second parse pass.
"""
from chemenu.type_resolver import resolver
issues: list[str] = []
for type_path, frontmatter in resolver.list_type_specs():
try:
resolver.validate_frontmatter(
frontmatter, "types/type-spec.md", source_file=config.ROOT / type_path
)
except ValueError as exc:
# `validate_frontmatter`'s own message names the type path it
# validated *against* (always `types/type-spec.md` here, since
# every type-spec is validated against the same schema) rather
# than the specific file that failed - prefix that file's own
# path so two failures in one run stay distinguishable.
issues.append(f"{type_path}: {exc}")
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 check_toc_regions() -> list[str]:
"""Every reference file over the line threshold carries a current TOC.
`toc.upsert` is idempotent (`toc.py`'s own docstring), so comparing its
output against the file on disk catches both a missing region and a
stale one - a heading added, renamed or reordered without re-running
`wikitool docs toc --apply` - in one check, the same way `docs verify`
checks every other generated-from-code copy.
"""
issues = []
for path in toc.target_files():
text = path.read_text(encoding="utf-8")
if toc.upsert(text) != text:
issues.append(
f"{rel_path(path)} needs a table-of-contents region refreshed - "
"run `wikitool docs toc --apply`"
)
return issues
# A markdown link, `[text](target)`. The target excludes `)` and whitespace -
# the same restriction every link in this repo's own instructions already
# follows; a target needing either would need CommonMark's <angle-bracket>
# escaping, which nothing here uses.
MARKDOWN_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
# The suffix `dist export` re-keys an instance-owned file to, and the one
# `setup-instance.md` renames away again. Spelled here rather than imported
# from `ownership`, whose own `.template` handling answers a different
# question (which side an upstream merge keeps) over a narrower scope
# (paths under a content stage).
TEMPLATE_SUFFIX = ".template"
def is_external_or_anchor(target: str) -> bool:
"""A link this check does not resolve as a filesystem path: an absolute
URL, a `mailto:`, or a pure in-page `#anchor`.
Public (not `_`-prefixed): `instructions_cmd.check_skill_reference_paths`
imports this alongside `MARKDOWN_LINK_RE` rather than keeping a second
copy - the two checks classify the same link shape, just over different
file sets (AGENTS.md invariant 8)."""
return target.startswith(("http://", "https://", "mailto:", "#"))
def check_reference_targets() -> list[str]:
"""Every relative markdown link in a reference file resolves to a real file.
Scoped to `toc.target_files()` - AGENTS.md, the stage and collection
contracts, and every flat `instructions/**.md` file - the same scope the
table-of-contents check uses. That scope already excludes `SKILL.md`
(banned from carrying a markdown link at all - `instructions verify`'s
`check_skill_reference_paths`), `commonplace/` (vendored, not stack
material) and `raw/`/`kb/` page content (data, not documentation) beyond
the two files that are themselves reference material.
A target's `#anchor` suffix is stripped before resolving - CommonMark
anchors are not filesystem paths, and nothing here renders one to notice
a stale one anyway. Code fences and inline code spans are masked first
(`markdown_code.strip_code_spans`), so a passage that shows link syntax
as an example is not mistaken for a real reference.
**A target the stack ships only as a `.template` counts as resolving.**
`kb/CONVENTIONS.md` and every `kb/<name>/COLLECTION.md` are instance-owned:
a distribution carries `<name>.template` and the instance adopts it by
renaming, during `instructions/setup-instance.md`'s personalization step.
Between `dist export` and that step the real file legitimately does not
exist yet - while `kb/CONTRACT.md` and three flat instructions link to it
by its adopted name, correctly, because that is the name it will have.
Reporting those as dead links would fail a fresh export for doing exactly
what it is supposed to do, and would describe "not personalized yet" as a
broken link when `doctor`'s `conventions` check already says it precisely.
"""
issues = []
for path in toc.target_files():
text = path.read_text(encoding="utf-8")
masked = markdown_code.strip_code_spans(text)
for line_number, masked_line in enumerate(masked.splitlines(), start=1):
for match in MARKDOWN_LINK_RE.finditer(masked_line):
target = match.group(1)
if is_external_or_anchor(target):
continue
target_path = target.split("#", 1)[0]
if not target_path:
continue
resolved = (path.parent / target_path).resolve()
if resolved.exists():
continue
if resolved.with_name(resolved.name + TEMPLATE_SUFFIX).exists():
continue
issues.append(
f"{rel_path(path)}:{line_number} links to `{target}`, which does not "
"resolve to an existing file"
)
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
# An issue-number citation in a shipped document points at a board no
# distributed instance can reach. The tracker lives in the origin repo, and
# `instructions/dev/issue-tracking.md` - the only file that says so - is pruned
# by `dist export` along with the rest of `instructions/dev/`, so the receiving
# reader gets a reference they can neither resolve nor recognise as unresolvable.
# The fix a session applies is to say what was decided instead of pointing at
# where it was decided; `git blame` -> commit message keeps the number reachable
# for whoever is standing in the repo that has one.
#
# The pattern knows nothing about Gitea - no client, no URL, no issue state -
# which is what keeps `instructions/dev/issue-tracking.md` § "What no tool
# checks" intact. It is a character pattern over shipped text, and `wikitool`
# stays as ignorant of the board as it was. Markdown anchors are usually word
# characters (`](#gates)`), but a numbered step's TOC entry is not
# (`](#2-fix-the-fidelity-before-writing-a-word)`) - the lookbehind excludes
# exactly the `](#...` link-fragment shape, not `#\d+` generally, so a real
# citation immediately after other punctuation still matches.
ISSUE_REFERENCE_RE = re.compile(r"(?<!\]\()#\d+")
# What counts as shipped prose: Markdown, plus the `.template` files an instance
# renames into place during setup. `tools/**/*.py` is deliberately outside it.
# A code comment addresses whoever edits that line, and that only ever happens
# in the origin repo - `dist export` prunes the `stack-dev` skill together with
# the rest of `instructions/dev/`, so a distributed `tools/` tree is runtime
# machinery, not reading material. `.gitignore` and `tools/.coveragerc` are out
# for the same reason: config, not documentation.
SHIPPED_PROSE_SUFFIXES = (".md", ".template")
def shipped_prose() -> dict[str, str]:
"""Destination path -> the text `dist export` would write, for every prose
file in the export.
Read off the export plan rather than the working tree on purpose. The plan
is where `ROOT_FILES`, the `instructions/dev/` exclusion and the `.template`
re-keying already live, so this check cannot drift from what actually
ships - and the plan's text has its `<!-- dist:strip-start/end -->` regions
already removed, which is what makes a marker the sanctioned way to keep a
pointer that is worth having here and meaningless anywhere else.
"""
plan = dist_cmd.build_plan()
return {
path: planned.content
for path, planned in plan.items()
if path.endswith(SHIPPED_PROSE_SUFFIXES) and isinstance(planned.content, str)
}
def check_no_issue_references() -> list[str]:
"""No shipped document may cite an issue number."""
try:
prose = shipped_prose()
except typer.Exit:
# `build_plan` refuses outright when the export would ship code without
# its licence. That is a real defect and `dist export` reports it in
# full; here it only means this one check could not run, and saying so
# beats letting another command's error end the whole verify run.
return [
"the export plan could not be built, so shipped documents were not checked for "
"issue references - run `tools/wikitool dist export --dry-run` for the reason"
]
issues = []
for path in sorted(prose):
for line_number, line in enumerate(prose[path].splitlines(), start=1):
for match in ISSUE_REFERENCE_RE.finditer(line):
issues.append(
f"{path}:{line_number} cites `{match.group()}`, but `dist export` ships this "
"file to instances that have no issue tracker - say what was decided instead "
"of pointing at where, or keep the pointer behind a "
"`<!-- dist:strip-start/end -->` block"
)
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, every type-spec's frontmatter against its own schema, ignore rules, version/changelog agreement, issue references, and link targets in shipped documents."""
issues = (
check_cli_readme()
+ check_readmes_have_no_command_table()
+ check_collection_contracts()
+ check_type_spec_frontmatter()
+ check_legacy_type_blocks()
+ check_ignored_content()
+ check_version_changelog()
+ check_migration_for_boundary()
+ check_breaking_change_for_boundary()
+ check_no_issue_references()
+ check_toc_regions()
+ check_reference_targets()
)
if issues:
fail("Documentation issues found:\n" + "\n".join(f"- {i}" for i in issues))
from chemenu.type_resolver import resolver
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(resolver.list_type_specs())} type-spec(s) validating against their own schema, "
f"{len(IGNORE_CANARIES)} ignore canaries clear, "
f"no issue references in {len(shipped_prose())} shipped document(s), "
f"tables of contents current and every link resolving on "
f"{len(toc.target_files())} reference file(s), "
f"{version_mod.CHANGES_FILENAME} documents version "
f"{(config.ROOT / version_mod.VERSION_FILENAME).read_text(encoding='utf-8').strip()}."
)
@app.command("toc")
def toc_command(
apply: bool = typer.Option(False, "--apply", help="Write changes; default is dry-run (preview only)"),
):
"""Create, refresh or remove the generated table-of-contents region on
every reference file `toc.target_files()` covers - AGENTS.md, the stage
and collection contracts, and every flat `instructions/**.md` file."""
changed = []
for path in toc.target_files():
before = path.read_text(encoding="utf-8")
after = toc.upsert(before)
if after != before:
changed.append((path, after))
if not changed:
success("Every table of contents is already current.")
return
for path, after in changed:
typer.echo(rel_path(path))
if apply:
path.write_text(after, encoding="utf-8")
if apply:
success(f"Refreshed the table of contents on {len(changed)} file(s).")
else:
typer.echo(f"\n{len(changed)} file(s) would change. Re-run with --apply to write.")