55f65c1ab1
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
962 lines
44 KiB
Python
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.")
|