stack: docs verify prueft § Commands und § Error contracts in tools/CONTRACT.md getrennt, 10 fehlende Fehlerkontrakt-Zeilen nachgetragen (schliesst #91)
Files changed: - CHANGES.md - VERSION - tools/CONTRACT.md - tools/chemenu/commands/docs_verify.py - tools/chemenu/tests/test_docs_verify.py
This commit is contained in:
1 parent
441a8151ab
commit
203084477f
5 files changed
+266
-31
No files matched your search
@@ -5,7 +5,10 @@ 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)
|
||||
1. `tools/CONTRACT.md`'s two command tables - § Commands and § Error
|
||||
contracts - each re-derivable from the Typer app and checked
|
||||
independently, in both directions, so a row surviving in one table
|
||||
cannot hide its own deletion from the other
|
||||
2. the collection and stage contracts (their existence and placement, not
|
||||
their content)
|
||||
3. the absence of pre-type-system `type: entity` frontmatter in the
|
||||
@@ -178,6 +181,35 @@ LEGACY_TYPE_RE = re.compile(r"^type:\s*(entity|concept|source|comparison)\s*$",
|
||||
# First backticked cell of a markdown table row, e.g. "| `xref add --a ...` | ... |"
|
||||
TABLE_CELL_RE = re.compile(r"^\|\s*`([^`]+)`", re.MULTILINE)
|
||||
|
||||
# tools/CONTRACT.md carries two tables whose first cell is a backticked
|
||||
# command path - § Commands and § Error contracts - and `check_cli_readme`
|
||||
# must not treat them as one pot (Gitea #91): a row deleted from one used to
|
||||
# go unnoticed as long as the same name survived in the other, and the
|
||||
# second table was not enforced against anything at all.
|
||||
COMMANDS_HEADING = "## Commands"
|
||||
ERROR_CONTRACTS_HEADING = "## Error contracts"
|
||||
|
||||
|
||||
def section_text(full_text: str, heading: str) -> str:
|
||||
"""The text of one `##`-level section: from just after `heading`'s own
|
||||
line up to the next `#`- or `##`-level heading, or the end of the
|
||||
document.
|
||||
|
||||
Raises `ValueError` if `heading` is not found verbatim, rather than
|
||||
falling back to scanning the whole document - a renamed heading has to
|
||||
surface as a failure, because silently widening the scope back to
|
||||
"everything" is exactly the bug this function exists to prevent from
|
||||
reappearing under a different name.
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r"^" + re.escape(heading) + r"[ \t]*\n(.*?)(?=^#{1,2}[ \t]|\Z)",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
match = pattern.search(full_text)
|
||||
if match is None:
|
||||
raise ValueError(f"no {heading!r} heading found")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def registered_commands() -> set[str]:
|
||||
"""Every command path the CLI exposes, e.g. {'new', 'xref add', ...}.
|
||||
@@ -213,15 +245,23 @@ def documented_commands(readme_text: str) -> list[str]:
|
||||
|
||||
|
||||
def check_cli_readme() -> list[str]:
|
||||
"""Every registered command must appear in tools/CONTRACT.md, and every
|
||||
command documented there must exist.
|
||||
"""Every registered command must appear in tools/CONTRACT.md's own
|
||||
§ Commands table, and separately in its § Error contracts table - each
|
||||
direction checked per table, independently of the other.
|
||||
|
||||
`TABLE_CELL_RE` scans the whole file for any markdown table row whose
|
||||
first cell is backticked - there is no separate "command table" region it
|
||||
is scoped to, so a match in the error-contract table (or any other table
|
||||
shaped the same way) counts too. It reads only the backticked path itself,
|
||||
never the rest of the cell: a changed flag or a rewritten description in
|
||||
an existing row is invisible to this check.
|
||||
The two tables used to be read as one pot: `TABLE_CELL_RE` matched a
|
||||
backticked first cell anywhere in the file, so a row deleted from
|
||||
§ Commands went unnoticed as long as the same name still had a row in
|
||||
§ Error contracts, and § Error contracts was never itself compared
|
||||
against the registered commands (Gitea #91). `section_text` scopes each
|
||||
table to the text between its own `##` heading and the next one, and
|
||||
raises rather than silently scanning the whole file if a heading has been
|
||||
renamed or removed - a renamed heading must be reported, not read as
|
||||
"table now empty" or "table now everything".
|
||||
|
||||
Within a section, only the first backticked cell of each row is read -
|
||||
a changed flag or a rewritten description in an existing row is invisible
|
||||
to this check, on purpose: it verifies presence, never prose.
|
||||
|
||||
The reverse check matches a documented cell against the full registered
|
||||
command path (e.g. `xref add`, `migrate verify`), not just its first
|
||||
@@ -233,18 +273,36 @@ def check_cli_readme() -> list[str]:
|
||||
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")
|
||||
issues: list[str] = []
|
||||
|
||||
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")
|
||||
for heading, label in (
|
||||
(COMMANDS_HEADING, "§ Commands"),
|
||||
(ERROR_CONTRACTS_HEADING, "§ Error contracts"),
|
||||
):
|
||||
try:
|
||||
section = section_text(text, heading)
|
||||
except ValueError as exc:
|
||||
issues.append(
|
||||
f"tools/CONTRACT.md: {exc} - its {label} table cannot be checked against the CLI"
|
||||
)
|
||||
continue
|
||||
|
||||
cells = documented_commands(section)
|
||||
|
||||
for command_path in registered:
|
||||
if not any(cell == command_path or cell.startswith(command_path + " ") for cell in cells):
|
||||
issues.append(
|
||||
f"command `{command_path}` is not documented in tools/CONTRACT.md's {label} table"
|
||||
)
|
||||
|
||||
for cell in cells:
|
||||
if not any(cell == cp or cell.startswith(cp + " ") for cp in registered):
|
||||
first_token = cell.split(" ", 1)[0]
|
||||
issues.append(
|
||||
f"tools/CONTRACT.md's {label} table documents `{cell}`, but `{first_token}` "
|
||||
"is not a wikitool command"
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
Reference in new issue
Block a user