docs: ausgelieferte Doku zitiert keine Issue-Nummern mehr, docs verify prueft es (schliesst #77)
CI / verify (push) Successful in 59s
Release / release (push) Successful in 37s

Files changed:
- .gitignore
- CHANGES.md
- EVALS.md
- INSTALL.md
- README.md
- VERSION
- docs/pipeline-rationale.md
- instructions/CONTRACT.md
- instructions/bootstrap.md
- instructions/dev/issue-tracking.md
- instructions/evolve-subtypes.md
- instructions/kb-profiles.md
- instructions/mcp-read-server.md
- instructions/wiki-ingest/SKILL.md
- kb/CONTRACT.md
- kb/concepts/COLLECTION.md
- kb/sources/COLLECTION.md
- raw/CONTRACT.md
- tools/.coveragerc
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/tests/test_docs_verify.py
- types/source.schema.yaml
- types/type-spec.md
This commit is contained in:
2026-09-09 18:52:34 +02:00
parent 5820924ffd
commit a51d7a322f
25 changed files with 388 additions and 68 deletions
+80 -1
View File
@@ -25,6 +25,10 @@ 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.
Everything here is a hard oracle: a set comparison or a regex, no judgment.
Content quality of the contracts themselves stays with the LLM.
"""
@@ -38,6 +42,7 @@ from typing import Optional
import typer
from chemenu import config, conventions, kb_collections, 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.")
@@ -383,6 +388,78 @@ def check_readmes_have_no_command_table() -> list[str]:
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 word
# characters (`](#gates)`), so a link never 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
@@ -587,7 +664,7 @@ def check_breaking_change_for_boundary() -> list[str]:
@app.command("verify")
def verify():
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, and version/changelog agreement."""
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, version/changelog agreement, and issue references in shipped documents."""
issues = (
check_cli_readme()
+ check_readmes_have_no_command_table()
@@ -597,6 +674,7 @@ def verify():
+ check_version_changelog()
+ check_migration_for_boundary()
+ check_breaking_change_for_boundary()
+ check_no_issue_references()
)
if issues:
@@ -607,6 +685,7 @@ def verify():
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"no issue references in {len(shipped_prose())} shipped document(s), "
f"{version_mod.CHANGES_FILENAME} documents version "
f"{(config.ROOT / version_mod.VERSION_FILENAME).read_text(encoding='utf-8').strip()}."
)
+96 -1
View File
@@ -2,7 +2,7 @@ import pytest
import typer
from chemenu import config
from chemenu.commands import docs_verify
from chemenu.commands import dist_cmd, docs_verify
def test_every_registered_command_is_documented():
@@ -348,3 +348,98 @@ def test_verify_raises_when_content_is_ignored(monkeypatch):
monkeypatch.setattr(docs_verify, "check_ignored_content", lambda: ["swallowed"])
with pytest.raises(typer.Exit):
docs_verify.verify()
# --- issue references in shipped documents -----------------------------------
def test_no_shipped_document_cites_an_issue():
"""Forward direction, against the real tree: a `#42` in a file `dist export`
ships points at a board only the origin repo has, and the reader of a
distributed instance can neither resolve it nor tell that it is
unresolvable."""
assert docs_verify.check_no_issue_references() == []
def test_a_cited_issue_number_is_reported(monkeypatch):
monkeypatch.setattr(
docs_verify,
"shipped_prose",
lambda: {"instructions/example.md": "A rule.\nRemoved in Gitea #66.\n"},
)
issues = docs_verify.check_no_issue_references()
assert len(issues) == 1
assert "instructions/example.md:2" in issues[0]
assert "#66" in issues[0]
def test_a_markdown_anchor_is_not_an_issue_reference(monkeypatch):
"""The reason this check can be a bare pattern at all: a link anchor and a
heading are word characters, so neither collides with `#<digits>`."""
monkeypatch.setattr(
docs_verify,
"shipped_prose",
lambda: {
"AGENTS.md": (
"# Heading\n"
"See [Gates](#gates) and [Collections](kb/CONTRACT.md#collections).\n"
"Exit code 42 means a human must look.\n"
)
},
)
assert docs_verify.check_no_issue_references() == []
def test_python_source_is_out_of_scope():
"""The scope decision, pinned: `tools/` ships as runtime machinery, and a
code comment addresses whoever edits that line - which only ever happens in
the origin repo, because `dist export` prunes the `stack-dev` skill with the
rest of `instructions/dev/`. So a `.py` file is in the export plan and out
of the scanned set."""
plan = dist_cmd.build_plan()
scanned = docs_verify.shipped_prose()
assert "tools/chemenu/commands/raw_cmd.py" in plan
assert not [path for path in scanned if path.endswith(".py")]
# ...while the prose beside it is scanned, templates included.
assert "tools/CONTRACT.md" in scanned
assert "instructions/CONTRACT.md" in scanned
assert "types/source.schema.yaml.template" in scanned
def test_a_strip_marked_pointer_never_reaches_the_check():
"""The sanctioned way to keep a pointer that is worth having here and
meaningless anywhere else. `shipped_prose` reads the export plan, whose text
already has its marker regions removed, so a number inside a marker block is
present in the working tree and absent from what ships.
Asserted over whichever files carry a marker today rather than a named one,
so retiring any single passage does not fail this for an unrelated reason -
what is pinned is that the mechanism works, not where it is used.
"""
shipped = docs_verify.shipped_prose()
# Destination paths only coincide with source paths where nothing was
# re-keyed (`types/*.md` ships as `.template`), so the ones that do not
# exist in the tree are simply not this test's subject.
sources = {
path: source.read_text(encoding="utf-8")
for path in shipped
if (source := config.ROOT / path).is_file()
}
hidden = {
path: source
for path, source in sources.items()
if "dist:strip-start" in source and docs_verify.ISSUE_REFERENCE_RE.search(source)
}
assert hidden, "no shipped document keeps an issue number behind a strip marker"
for path, source in hidden.items():
assert docs_verify.ISSUE_REFERENCE_RE.search(source)
assert not docs_verify.ISSUE_REFERENCE_RE.search(shipped[path]), path
def test_verify_raises_when_a_shipped_document_cites_an_issue(monkeypatch):
monkeypatch.setattr(docs_verify, "check_no_issue_references", lambda: ["cited"])
with pytest.raises(typer.Exit):
docs_verify.verify()