Files changed: - .gitea/workflows/ci.yml - .gitea/workflows/release.yml - AGENTS.md - CHANGES.md - DEVELOPMENT.md - EVALS.md - INSTALL.md - README.md - VERSION - docs/ownership-and-templates.md - instructions/CONTRACT.md - instructions/bootstrap.md - instructions/dev/dev-setup.md - instructions/dev/stack-dev/SKILL.md - instructions/gates.md - instructions/ingest-large-tree.md - instructions/kb-profiles.md - instructions/mcp-read-server.md - instructions/migrations/3.0.0-authoring-conventions.md - instructions/preflight.md - instructions/private-instance.md - instructions/session-setup.md - instructions/setup-instance.md - instructions/upgrade-instance.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/cli.py - tools/chemenu/cli_contract.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/git_publish.py - tools/chemenu/commands/upstream_cmd.py - tools/chemenu/commands/work_cmd.py - tools/chemenu/config.py - tools/chemenu/ownership.py - tools/chemenu/tests/test_cli.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_instructions_shell.py - tools/chemenu/tests/test_preflight.py - tools/chemenu/tests/test_preflight_pwsh.py - tools/chemenu/tests/test_run_budget.py - tools/chemenu/tests/test_upstream_cmd.py - tools/chemenu/toc.py - tools/preflight.ps1 - tools/preflight.sh
1298 lines
59 KiB
Python
1298 lines
59 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 generated `<!-- wikitool:commands -->` region - one
|
|
`cli_contract.CommandRecord` per registered command, re-derivable from
|
|
the Typer app and checked in both directions, so a command dropped from
|
|
`cli_contract.GROUPS` cannot hide behind a record that still exists, or
|
|
the reverse
|
|
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 functools
|
|
import inspect
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import typer
|
|
|
|
from chemenu import blocks, cli_contract, config, conventions, kb_collections, markdown_code, toc, toolpaths, 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",
|
|
# The task-tracker provider opt-in (Gitea #124) - same shape again:
|
|
# per-checkout, never committed, once a credential lands in it.
|
|
".wikitool-tasks.json",
|
|
# The live suite's tracker profiles (Gitea #156) - credentials again, one file per tracker.
|
|
".wikitool-tasks.d/probe.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)
|
|
|
|
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_command_contracts() -> list[str]:
|
|
"""Every registered command has exactly one `cli_contract` record, and
|
|
`cli_contract.GROUPS` lists exactly the registered commands - no more, no
|
|
less, and no path twice.
|
|
|
|
Gitea #121 phase 1 replaced the two hand-read tables (§ Commands,
|
|
§ Error contracts) with one data record per command, attached to its
|
|
function by `@cli_contract.record(...)`. A record with no matching
|
|
command, a command with no record, or a `GROUPS` entry appearing twice
|
|
are the three ways that pairing can drift; each is its own issue so a
|
|
session sees exactly which command needs attention. Both directions are
|
|
checked so that a command dropped from one side is not hidden by the
|
|
other.
|
|
"""
|
|
registered = sorted(registered_commands())
|
|
issues: list[str] = []
|
|
|
|
seen: set[str] = set()
|
|
for path in cli_contract.grouped_paths(cli_contract.GROUPS):
|
|
if path in seen:
|
|
issues.append(f"cli_contract.GROUPS lists `{path}` more than once")
|
|
seen.add(path)
|
|
|
|
grouped = set(cli_contract.grouped_paths(cli_contract.GROUPS))
|
|
for command_path in registered:
|
|
if cli_contract.get(command_path) is None:
|
|
issues.append(
|
|
f"command `{command_path}` has no cli_contract record - decorate its function "
|
|
"with @cli_contract.record(...)"
|
|
)
|
|
if command_path not in grouped:
|
|
issues.append(
|
|
f"command `{command_path}` is not listed in cli_contract.GROUPS - it has no "
|
|
"place to render in tools/CONTRACT.md's generated region"
|
|
)
|
|
|
|
for path in sorted(grouped):
|
|
if path not in registered:
|
|
issues.append(
|
|
f"cli_contract.GROUPS lists `{path}`, which is not a registered wikitool command"
|
|
)
|
|
|
|
return issues
|
|
|
|
|
|
COMMANDS_REGION = "commands"
|
|
|
|
|
|
def check_commands_region() -> list[str]:
|
|
"""`tools/CONTRACT.md`'s `<!-- wikitool:commands -->` region matches
|
|
`cli_contract.render_commands_region()` byte for byte - the same
|
|
generated-region drift check `check_toc_regions()` runs for the table of
|
|
contents, applied to the command reference itself."""
|
|
if not CLI_README.exists():
|
|
return [f"{rel_path(CLI_README)} is missing"]
|
|
|
|
text = CLI_README.read_text(encoding="utf-8")
|
|
existing = blocks.find(text, COMMANDS_REGION)
|
|
expected = cli_contract.render_commands_region(groups=cli_contract.GROUPS).strip("\n")
|
|
|
|
if existing is None:
|
|
return [
|
|
f"{rel_path(CLI_README)} is missing its <!-- wikitool:commands --> region - run "
|
|
"`wikitool docs contract --apply`"
|
|
]
|
|
if existing.strip("\n") != expected:
|
|
return [
|
|
f"{rel_path(CLI_README)}'s <!-- wikitool:commands --> region is stale - run "
|
|
"`wikitool docs contract --apply`"
|
|
]
|
|
return []
|
|
|
|
|
|
# A `--flag` or `-f` token in a SYNOPSIS usage string. Excludes anything that
|
|
# is not immediately preceded/followed by a word character or hyphen, so
|
|
# `--set field=value` matches only `--set`, never `field` or `value`.
|
|
FLAG_TOKEN_RE = re.compile(r"(?<![\w-])(--[a-zA-Z][a-zA-Z0-9-]*|-[a-zA-Z])(?![\w-])")
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _leaf_click_commands() -> list[tuple[str, object]]:
|
|
"""(path, click.Command) for every leaf command the built CLI exposes,
|
|
dotted the same way `registered_commands()` names a path (`"xref add"`).
|
|
|
|
Built from the real Click command tree (`typer.main.get_command`) rather
|
|
than from Typer's own `registered_commands`/`registered_groups`, because
|
|
only the Click tree carries each option's actual flag strings
|
|
(`param.opts`/`param.secondary_opts`) - what `check_synopsis_flags` and
|
|
`check_no_issue_references_in_help` both need to read. Cached: `verify()`
|
|
calls both checks in the same run, and the app it walks is immutable
|
|
within a process - a test replacing this name with its own function
|
|
bypasses the cache entirely rather than needing to clear it.
|
|
"""
|
|
import typer as _typer
|
|
|
|
from chemenu import cli
|
|
|
|
root = _typer.main.get_command(cli.app)
|
|
found: list[tuple[str, object]] = []
|
|
|
|
def walk(command: object, prefix: list[str]) -> None:
|
|
sub = getattr(command, "commands", None)
|
|
if sub:
|
|
for name, child in sub.items():
|
|
walk(child, prefix + [name])
|
|
else:
|
|
found.append((" ".join(prefix), command))
|
|
|
|
walk(root, [])
|
|
return found
|
|
|
|
|
|
def _group_click_commands() -> list[tuple[str, object]]:
|
|
"""(path, click.Group) for every intermediate group the built CLI
|
|
exposes (`"xref"`, `"task"`, ...), the complement of
|
|
`_leaf_click_commands()`. A group carries no `cli_contract` record of its
|
|
own - `wikitool xref -h` falls through to Click's default listing - but
|
|
its own `help=` string is still rendered there, so
|
|
`check_no_issue_references_in_help` needs this set too, not just the
|
|
leaves."""
|
|
import typer as _typer
|
|
|
|
from chemenu import cli
|
|
|
|
root = _typer.main.get_command(cli.app)
|
|
found: list[tuple[str, object]] = []
|
|
|
|
def walk(command: object, prefix: list[str]) -> None:
|
|
sub = getattr(command, "commands", None)
|
|
if not sub:
|
|
return
|
|
if prefix:
|
|
found.append((" ".join(prefix), command))
|
|
for name, child in sub.items():
|
|
walk(child, prefix + [name])
|
|
|
|
walk(root, [])
|
|
return found
|
|
|
|
|
|
def check_synopsis_flags() -> list[str]:
|
|
"""Every non-hidden flag a command's Click definition carries appears in
|
|
its `cli_contract` record's SYNOPSIS, and every flag a SYNOPSIS names is
|
|
a real flag of that command.
|
|
|
|
A boolean flag pair (`--push`/`--no-push`) is satisfied by documenting
|
|
either spelling - the SYNOPSIS convention this repo already used before
|
|
this check existed (`publish`'s `[--no-push]`). `hidden=True` does not
|
|
count (`publish --yes`), matching the design this check implements
|
|
(Gitea #121 B3).
|
|
"""
|
|
issues: list[str] = []
|
|
for path, command in _leaf_click_commands():
|
|
rec = cli_contract.get(path)
|
|
if rec is None:
|
|
continue # reported by check_command_contracts
|
|
|
|
synopsis_text = " ".join(variant.usage for variant in rec.synopsis)
|
|
documented = {m.group(0) for m in FLAG_TOKEN_RE.finditer(synopsis_text)}
|
|
|
|
all_flags: set[str] = set()
|
|
for param in getattr(command, "params", []):
|
|
opts = list(getattr(param, "opts", []) or [])
|
|
secondary = list(getattr(param, "secondary_opts", []) or [])
|
|
group = [o for o in (opts + secondary) if o.startswith("-") and o not in ("--help", "-h")]
|
|
all_flags.update(group)
|
|
if not group or getattr(param, "hidden", False):
|
|
continue
|
|
if not any(o in documented for o in group):
|
|
issues.append(
|
|
f"`{path}`: flag `{group[0]}` is not documented in its cli_contract "
|
|
"SYNOPSIS"
|
|
)
|
|
|
|
for flag in sorted(documented - all_flags):
|
|
issues.append(
|
|
f"`{path}`: its cli_contract SYNOPSIS mentions `{flag}`, which is not a real "
|
|
"flag of this command"
|
|
)
|
|
return issues
|
|
|
|
|
|
def check_no_issue_references_in_help() -> list[str]:
|
|
"""No command's rendered `--help`/`-h` text - its docstring above `\\f`,
|
|
or any option's `help=` text - cites an issue number.
|
|
|
|
Mirrors `check_no_issue_references()`'s reasoning for shipped `.md`
|
|
files, one level down: `tools/` ships as runtime machinery to every
|
|
distributed instance (`instructions/dev/` is excluded, not `tools/`), so
|
|
an issue number baked into a command's own `--help` output reaches a
|
|
reader with no tracker to resolve it against. `\\f` is what Click itself
|
|
uses to separate the rendered part of a docstring from maintenance text
|
|
below it (`click.Command.format_help_text`); this check applies the same
|
|
split before scanning; the `\\f` decides for our SYNOPSIS/PROPERTIES/...
|
|
renderer too.
|
|
"""
|
|
issues: list[str] = []
|
|
for path, command in _leaf_click_commands():
|
|
help_text = getattr(command, "help", None) or ""
|
|
rendered = inspect.cleandoc(help_text).partition("\f")[0]
|
|
for match in ISSUE_REFERENCE_RE.finditer(rendered):
|
|
issues.append(
|
|
f"`{path}` --help text cites `{match.group()}` above its `\\f` marker - the "
|
|
"tracker exists only in the origin repo"
|
|
)
|
|
for param in getattr(command, "params", []):
|
|
help_str = getattr(param, "help", None) or ""
|
|
for match in ISSUE_REFERENCE_RE.finditer(help_str):
|
|
flag = (list(getattr(param, "opts", []) or []) or [param.name])[0]
|
|
issues.append(
|
|
f"`{path}` option `{flag}` help text cites `{match.group()}` - the tracker "
|
|
"exists only in the origin repo"
|
|
)
|
|
|
|
for path, group in _group_click_commands():
|
|
help_text = getattr(group, "help", None) or ""
|
|
rendered = inspect.cleandoc(help_text).partition("\f")[0]
|
|
for match in ISSUE_REFERENCE_RE.finditer(rendered):
|
|
issues.append(
|
|
f"`{path}` group's --help text cites `{match.group()}` above its `\\f` marker "
|
|
"- the tracker exists only in the origin repo"
|
|
)
|
|
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).as_posix()
|
|
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,
|
|
*(
|
|
(path / "COLLECTION.md").relative_to(config.ROOT).as_posix()
|
|
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. Imported from `toc` rather than
|
|
# spelled again here: that module already decides which files are reference
|
|
# material in both their forms, and this check runs over its scope. Not from
|
|
# `ownership`, whose own `.template` handling answers a different question
|
|
# (which path a release replaces) over a narrower scope (paths under a
|
|
# content stage).
|
|
TEMPLATE_SUFFIX = toc.TEMPLATE_SUFFIX
|
|
|
|
|
|
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(
|
|
[toolpaths.git(), *args], cwd=config.ROOT, capture_output=True, text=True, encoding="utf-8", 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")
|
|
@cli_contract.record(cli_contract.CommandRecord(
|
|
path="docs verify",
|
|
summary="Check the docs that mirror the code.",
|
|
synopsis=(cli_contract.Variant(usage="docs verify"),),
|
|
properties=cli_contract.Properties(
|
|
effect=cli_contract.Effect.READ,
|
|
idempotent=cli_contract.Idempotent.YES,
|
|
atomic="Read-only",
|
|
budget=cli_contract.Budget.COUNTED,
|
|
),
|
|
notes=(
|
|
"Checks the docs that mirror the code. The name is about documentation parity, not the "
|
|
"`docs/` directory - it neither reads nor requires one.",
|
|
"Commands: every command has a `cli_contract` record and is listed in "
|
|
"`cli_contract.GROUPS`, in both directions; every command's non-hidden flags appear in "
|
|
"its record's SYNOPSIS and vice versa; `tools/CONTRACT.md`'s generated "
|
|
"`<!-- wikitool:commands -->` region matches what `docs contract` would write; no "
|
|
"command's rendered `--help`/`-h` text cites an issue number.",
|
|
"Collections: every directory under `kb/` has a `COLLECTION.md` and no directory "
|
|
"outside it does; every collection declares `profile:` and a `required_by_stack:` that "
|
|
"agrees with the stack's own list.",
|
|
"Types: every type the stack lists (currently `source` and `project`) has a type-spec "
|
|
"of that name whose schema requires the field the stack list names "
|
|
"(`raw_files:`/`state:`); every file under `types/` declaring `type: "
|
|
"types/type-spec.md` validates against `types/type-spec.schema.yaml`; no "
|
|
"pre-migration `type: entity` blocks are left in the contracts.",
|
|
"`kb/CONVENTIONS.md`, if it exists at all, names all three tool-owned section headings; "
|
|
"every stage contract is present.",
|
|
"The `.gitignore` canaries clear in both directions: nothing ignored under "
|
|
"`raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the "
|
|
"published skill directories.",
|
|
"No `.md`/`.template` file `dist export` would ship cites an issue number. A "
|
|
"`<!-- dist:strip-start/end -->` region is exempt: the check reads the export plan's "
|
|
"text, from which it is already gone.",
|
|
"Every reference file `docs toc` covers carries the current table-of-contents region "
|
|
"for its own headings - missing and stale are one check.",
|
|
"Every relative markdown link in one of those reference files resolves to an existing "
|
|
"file. A target's `#anchor` suffix is stripped first, and code fences and inline code "
|
|
"spans are masked before scanning, so link syntax shown as an example is not mistaken "
|
|
"for a real reference.",
|
|
"Read-only.",
|
|
),
|
|
failures=(
|
|
cli_contract.Failure(
|
|
cause="A command, contract, or type-form mismatch",
|
|
reaction="Fix the documentation it names, then re-run",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="The `<!-- wikitool:commands -->` region of `tools/CONTRACT.md` is stale",
|
|
reaction="Run `docs contract --apply`, then re-run",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="A type-spec's own frontmatter fails its schema",
|
|
reaction="Fix the field, or add a matching line to `types/type-spec.schema.yaml` if "
|
|
"the field is legitimately new",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="A shipped `.md`/`.template` cites an issue number",
|
|
reaction="Say what was decided instead of pointing at where, or move the pointer "
|
|
"behind a `<!-- dist:strip-start/end -->` block",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="A reference file's table-of-contents region is missing or stale",
|
|
reaction="Run `docs toc --apply`, then re-run",
|
|
),
|
|
cli_contract.Failure(
|
|
cause="A reference file's relative markdown link does not resolve to an existing "
|
|
"file",
|
|
reaction="Fix the `../` count or the target's name",
|
|
),
|
|
),
|
|
examples=(
|
|
"tools/wikitool docs verify",
|
|
),
|
|
never=(
|
|
"Never hand-write a table-of-contents region or the commands region - regenerate it.",
|
|
),
|
|
see_also=(
|
|
"`wikitool docs toc` - regenerates tables of contents",
|
|
"`wikitool docs contract` - regenerates the commands region",
|
|
"`wikitool instructions verify` - the same kind of check for `instructions/`",
|
|
),
|
|
))
|
|
def verify():
|
|
"""Check the docs that mirror the code."""
|
|
issues = (
|
|
check_command_contracts()
|
|
+ check_commands_region()
|
|
+ check_synopsis_flags()
|
|
+ check_no_issue_references_in_help()
|
|
+ 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) with a cli_contract record, "
|
|
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) or command help, "
|
|
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")
|
|
@cli_contract.record(cli_contract.CommandRecord(
|
|
path="docs toc",
|
|
summary="Create, refresh or remove the generated table-of-contents region.",
|
|
synopsis=(cli_contract.Variant(usage="docs toc [--apply]"),),
|
|
properties=cli_contract.Properties(
|
|
effect=cli_contract.Effect.WRITE,
|
|
idempotent=cli_contract.Idempotent.YES,
|
|
atomic="`--apply` rewrites each named file in place, one at a time and idempotently, "
|
|
"so a re-run after an interruption converges rather than doubling a region; the "
|
|
"dry-run form is read-only",
|
|
budget=cli_contract.Budget.COUNTED,
|
|
),
|
|
notes=(
|
|
"Creates, refreshes or removes the generated table-of-contents region on every "
|
|
"reference file over 100 lines: `AGENTS.md`, every stage contract, "
|
|
"`kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md` file, "
|
|
"every `types/*.md` type-spec, and every `docs/` page - each together with the "
|
|
"`<name>.template` it ships as, where one exists.",
|
|
"The scope is computed from those categories rather than listed, so a file added later "
|
|
"is in scope without a code change.",
|
|
"Out of scope: every `SKILL.md`, and the human docs (`README.md`, `CHANGES.md`, "
|
|
"`EVALS.md`, `INSTALL.md`, `tools/README.md`).",
|
|
"Dry-run by default (prints which files would change); `--apply` writes. Idempotent: a "
|
|
"re-run after an interruption converges rather than doubling a region.",
|
|
"If `docs verify` still reports a stale region after `--apply`, the file's `##` "
|
|
"headings changed in between; run it again.",
|
|
),
|
|
failures=(cli_contract.Failure(
|
|
cause="Never fails on content: a file with no `##` heading, or one at or under the "
|
|
"threshold, is simply left without a region",
|
|
reaction="",
|
|
code=0,
|
|
),),
|
|
examples=(
|
|
"tools/wikitool docs toc",
|
|
"tools/wikitool docs toc --apply",
|
|
),
|
|
never=(
|
|
"Never hand-write or hand-edit a table-of-contents region.",
|
|
),
|
|
see_also=(
|
|
"`wikitool docs verify` - checks every region is current",
|
|
),
|
|
))
|
|
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.
|
|
\f
|
|
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", newline="\n")
|
|
|
|
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.")
|
|
|
|
|
|
@app.command("contract")
|
|
@cli_contract.record(cli_contract.CommandRecord(
|
|
path="docs contract",
|
|
summary="Regenerate `tools/CONTRACT.md`'s `<!-- wikitool:commands -->` region.",
|
|
synopsis=(cli_contract.Variant(usage="docs contract [--apply]"),),
|
|
properties=cli_contract.Properties(
|
|
effect=cli_contract.Effect.WRITE,
|
|
idempotent=cli_contract.Idempotent.YES,
|
|
atomic="Yes - the whole region is rewritten in one file write",
|
|
budget=cli_contract.Budget.COUNTED,
|
|
),
|
|
notes=(
|
|
"Rebuilds the region from every `cli_contract` record: the index (one line per "
|
|
"command, `GROUPS` order) followed by each `###` group's commands as `#### <path>` "
|
|
"man-page-shaped sections.",
|
|
"Dry-run by default (says whether the file would change); `--apply` writes.",
|
|
"`docs verify` checks the result stays current.",
|
|
),
|
|
failures=(cli_contract.Failure(
|
|
cause="`tools/CONTRACT.md` is missing",
|
|
reaction="Not transient - restore the file, which carries hand-written prose around "
|
|
"the region this command does not generate, then retry",
|
|
),),
|
|
examples=(
|
|
"tools/wikitool docs contract",
|
|
"tools/wikitool docs contract --apply",
|
|
),
|
|
never=(
|
|
"Never hand-edit the region - change the record in code and re-run this.",
|
|
),
|
|
see_also=(
|
|
"`tools/README.md` § Adding a command - how a command gets its record",
|
|
"`wikitool docs verify` - checks the region is current",
|
|
),
|
|
))
|
|
def contract_command(
|
|
apply: bool = typer.Option(False, "--apply", help="Write changes; default is dry-run (preview only)"),
|
|
):
|
|
"""Regenerate `tools/CONTRACT.md`'s `<!-- wikitool:commands -->` region."""
|
|
if not CLI_README.exists():
|
|
fail(f"{rel_path(CLI_README)} is missing.")
|
|
|
|
text = CLI_README.read_text(encoding="utf-8")
|
|
content = cli_contract.render_commands_region(groups=cli_contract.GROUPS).strip("\n")
|
|
region = (
|
|
blocks.open_marker(COMMANDS_REGION) + "\n" + content + "\n" + blocks.close_marker(COMMANDS_REGION)
|
|
)
|
|
after = blocks.replace(text, COMMANDS_REGION, region)
|
|
|
|
if after == text:
|
|
success(f"{rel_path(CLI_README)}'s command region is already current.")
|
|
return
|
|
|
|
if not apply:
|
|
typer.echo(f"{rel_path(CLI_README)} would change.")
|
|
typer.echo("Re-run with --apply to write.")
|
|
return
|
|
|
|
CLI_README.write_text(after, encoding="utf-8", newline="\n")
|
|
success(f"Regenerated the command region in {rel_path(CLI_README)}.")
|