72b2b4424f
Files changed: - .gitea/workflows/release.yml - CHANGES.md - VERSION - instructions/dev/stack-close/SKILL.md - tools/CONTRACT.md - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/git_publish.py - tools/chemenu/tests/test_docs_verify.py
606 lines
27 KiB
Python
606 lines
27 KiB
Python
"""`wikitool docs verify` - machine-check the documentation copies that can be
|
|
re-derived from the code and the repo layout.
|
|
|
|
The wiki's own rule is that a derived copy of recomputable truth must be
|
|
checked or absent. Three such copies survive on purpose because they earn
|
|
their keep as reading material:
|
|
|
|
1. `tools/CONTRACT.md`'s command table (re-derivable from the Typer app)
|
|
2. the collection and stage contracts (their existence and placement, not
|
|
their content)
|
|
3. the absence of pre-type-system `type: entity` frontmatter in the
|
|
contract docs - the exact drift that left a stale comparison template
|
|
sitting in AGENTS.md for months after the type migration
|
|
|
|
A fourth check has a different shape: `.gitignore` is not documentation, but
|
|
it is the one file that can silently un-publish content. A pattern excluding a
|
|
file under `raw/` or `kb/` is a data-loss bug - `sources coverage` reads the
|
|
filesystem and reports the file as covered, while `publish` (`git add -A`)
|
|
never commits it, so a fresh clone has a broken `raw_files:` reference. The
|
|
same check runs in reverse over `reports/`, where a *missing* ignore rule would
|
|
start committing derived output.
|
|
|
|
A fifth has the same shape as the fourth: `VERSION` is not documentation
|
|
either, but it is the one number a release stamps into every distributed
|
|
instance, and a version raised without a changelog entry ships release notes
|
|
that describe the previous release.
|
|
|
|
Everything here is a hard oracle: a set comparison or a regex, no judgment.
|
|
Content quality of the contracts themselves stays with the LLM.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import typer
|
|
|
|
from chemenu import config, 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/<name>/SKILL.md`, published by `wikitool instructions sync`.
|
|
# Committing them would create exactly the drifting second copy this repo
|
|
# refuses to keep anywhere else.
|
|
#
|
|
# `ENVIRONMENT.md` is a third reason again: it is per-checkout, so committing
|
|
# one working copy's harness, MCP servers and remotes would hand every other
|
|
# clone a file that is confidently wrong rather than honestly absent. Its
|
|
# `.template` sits in REQUIRED_TRACKED_PATHS below, because the obvious
|
|
# careless pattern (`ENVIRONMENT.md*`) would swallow both.
|
|
#
|
|
# The coverage paths are the reports/ argument applied to `pytest --cov`
|
|
# output: derived, recomputable, and in the way of `publish`'s `git add -A`.
|
|
REQUIRED_IGNORE_CANARIES = (
|
|
"reports/Lint Report 2026-01-01.md",
|
|
".agents/skills/wiki-query/SKILL.md",
|
|
".claude/skills/wiki-query/SKILL.md",
|
|
"ENVIRONMENT.md",
|
|
"tools/coverage.xml",
|
|
"tools/htmlcov/index.html",
|
|
)
|
|
REQUIRED_TRACKED_PATHS = (
|
|
"reports/CONTRACT.md",
|
|
"instructions/CONTRACT.md",
|
|
"instructions/wiki-query/SKILL.md",
|
|
"ENVIRONMENT.md.template",
|
|
# 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/<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_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()}."
|
|
)
|