docs: ausgelieferte Doku zitiert keine Issue-Nummern mehr, docs verify prueft es (schliesst #77)
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:
@@ -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()}."
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user