Files changed: - AGENTS.md - CHANGES.md - VERSION - instructions/dev/doc-pull-through.md - instructions/dev/stack-close/SKILL.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/cli.py - tools/chemenu/cli_contract.py - tools/chemenu/commands/cite_cmd.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/eval_cmd.py - tools/chemenu/commands/git_publish.py - tools/chemenu/commands/index_build.py - tools/chemenu/commands/instructions_cmd.py - tools/chemenu/commands/links_cmd.py - tools/chemenu/commands/lint.py - tools/chemenu/commands/log_append.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/commands/new_page.py - tools/chemenu/commands/page_ops.py - tools/chemenu/commands/provenance_cmd.py - tools/chemenu/commands/raw_cmd.py - tools/chemenu/commands/review_cmd.py - tools/chemenu/commands/run_budget.py - tools/chemenu/commands/search.py - tools/chemenu/commands/task_cmd.py - tools/chemenu/commands/touch.py - tools/chemenu/commands/types_cmd.py - tools/chemenu/commands/upload_cmd.py - tools/chemenu/commands/upstream_cmd.py - tools/chemenu/commands/version_cmd.py - tools/chemenu/commands/work_cmd.py - tools/chemenu/commands/xref.py - tools/chemenu/tests/test_cli_contract.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_run_budget.py
901 lines
39 KiB
Python
901 lines
39 KiB
Python
import pytest
|
|
import typer
|
|
|
|
from chemenu import cli_contract, config, type_resolver
|
|
from chemenu.commands import dist_cmd, docs_verify
|
|
from chemenu.type_resolver import TypeResolver
|
|
|
|
|
|
def test_every_registered_command_has_a_contract_record():
|
|
"""Forward direction: a command added to the CLI with no `@cli_contract.record`
|
|
(or missing from `cli_contract.GROUPS`) is exactly the drift this check
|
|
exists to catch (Gitea #121 B7)."""
|
|
assert docs_verify.check_command_contracts() == []
|
|
|
|
|
|
def test_registered_commands_include_groups_and_top_level():
|
|
commands = docs_verify.registered_commands()
|
|
assert "new" in commands
|
|
assert "touch" in commands
|
|
assert "xref add" in commands
|
|
assert "migrate verify" in commands
|
|
assert "docs verify" in commands
|
|
|
|
|
|
def test_a_command_with_no_record_is_reported(monkeypatch):
|
|
monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"new", "frobnicate"})
|
|
issues = docs_verify.check_command_contracts()
|
|
assert any("frobnicate" in issue and "no cli_contract record" in issue for issue in issues)
|
|
|
|
|
|
def test_a_groups_entry_with_no_registered_command_is_reported(monkeypatch):
|
|
monkeypatch.setattr(docs_verify, "registered_commands", lambda: set())
|
|
issues = docs_verify.check_command_contracts()
|
|
assert any("is not a registered wikitool command" in issue for issue in issues)
|
|
|
|
|
|
def test_a_duplicate_groups_entry_is_reported(monkeypatch):
|
|
groups = (("Fixture", ("new", "new")),)
|
|
monkeypatch.setattr(cli_contract, "GROUPS", groups)
|
|
issues = docs_verify.check_command_contracts()
|
|
assert any("more than once" in issue for issue in issues)
|
|
|
|
|
|
def _fixture_record(path: str) -> cli_contract.CommandRecord:
|
|
return cli_contract.CommandRecord(
|
|
path=path,
|
|
summary="Does a thing.",
|
|
synopsis=(cli_contract.Variant(usage=f"{path} --flag <v>"),),
|
|
properties=cli_contract.Properties(
|
|
effect=cli_contract.Effect.WRITE,
|
|
idempotent=cli_contract.Idempotent.NO,
|
|
atomic="Yes",
|
|
budget=cli_contract.Budget.COUNTED,
|
|
),
|
|
notes="Does a thing, mechanically.",
|
|
failures=(cli_contract.Failure(label="", exit_1="It broke", retry="Fix and retry"),),
|
|
)
|
|
|
|
|
|
def test_commands_region_matches_the_generated_form(tmp_path, monkeypatch):
|
|
"""Regression guard for the drift this check exists to catch: a region
|
|
hand-edited (or simply left stale after a record changed) must fail, the
|
|
same way `check_toc_regions` fails a stale table of contents."""
|
|
from chemenu import blocks
|
|
|
|
fake = tmp_path / "CONTRACT.md"
|
|
stale_region = (
|
|
blocks.open_marker("commands") + "\nstale content\n" + blocks.close_marker("commands")
|
|
)
|
|
fake.write_text(blocks.replace("## Commands\n", "commands", stale_region), encoding="utf-8")
|
|
monkeypatch.setattr(docs_verify, "CLI_README", fake)
|
|
monkeypatch.setattr(
|
|
cli_contract, "all_records", lambda: {"frobnicate": _fixture_record("frobnicate")}
|
|
)
|
|
monkeypatch.setattr(cli_contract, "GROUPS", (("Fixture", ("frobnicate",)),))
|
|
issues = docs_verify.check_commands_region()
|
|
assert any("stale" in issue for issue in issues)
|
|
|
|
|
|
def test_commands_region_is_accepted_once_regenerated(tmp_path, monkeypatch):
|
|
from chemenu import blocks
|
|
|
|
fake = tmp_path / "CONTRACT.md"
|
|
monkeypatch.setattr(docs_verify, "CLI_README", fake)
|
|
monkeypatch.setattr(
|
|
cli_contract, "all_records", lambda: {"frobnicate": _fixture_record("frobnicate")}
|
|
)
|
|
monkeypatch.setattr(cli_contract, "GROUPS", (("Fixture", ("frobnicate",)),))
|
|
content = cli_contract.render_commands_region(groups=(("Fixture", ("frobnicate",)),)).strip("\n")
|
|
wrapped = blocks.open_marker("commands") + "\n" + content + "\n" + blocks.close_marker("commands")
|
|
fake.write_text(blocks.replace("## Commands\n", "commands", wrapped), encoding="utf-8")
|
|
assert docs_verify.check_commands_region() == []
|
|
|
|
|
|
def test_a_missing_commands_region_is_reported(tmp_path, monkeypatch):
|
|
fake = tmp_path / "CONTRACT.md"
|
|
fake.write_text("## Commands\n\nnothing generated here yet.\n", encoding="utf-8")
|
|
monkeypatch.setattr(docs_verify, "CLI_README", fake)
|
|
issues = docs_verify.check_commands_region()
|
|
assert any("missing its" in issue for issue in issues)
|
|
|
|
|
|
def test_the_real_commands_region_is_current():
|
|
"""Forward direction against the real tree: this is what a session
|
|
forgetting to run `wikitool docs contract --apply` after changing a
|
|
record is caught by."""
|
|
assert docs_verify.check_commands_region() == []
|
|
|
|
|
|
def test_every_registered_commands_flags_are_documented():
|
|
"""Forward direction against the real tree and the real Click app: every
|
|
non-hidden flag of every command must appear in its cli_contract
|
|
record's SYNOPSIS, and vice versa."""
|
|
assert docs_verify.check_synopsis_flags() == []
|
|
|
|
|
|
def test_a_flag_the_synopsis_forgot_is_reported(monkeypatch):
|
|
import click
|
|
|
|
rec = _fixture_record("frobnicate")
|
|
monkeypatch.setattr(cli_contract, "get", lambda path: rec if path == "frobnicate" else None)
|
|
|
|
param = click.Option(["--flag"])
|
|
param2 = click.Option(["--forgotten"])
|
|
command = click.Command("frobnicate", params=[param, param2])
|
|
monkeypatch.setattr(
|
|
docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)]
|
|
)
|
|
issues = docs_verify.check_synopsis_flags()
|
|
assert any("--forgotten" in issue and "not documented" in issue for issue in issues)
|
|
|
|
|
|
def test_a_phantom_synopsis_flag_is_reported(monkeypatch):
|
|
import click
|
|
|
|
rec = cli_contract.CommandRecord(
|
|
path="frobnicate",
|
|
summary="Does a thing.",
|
|
synopsis=(cli_contract.Variant(usage="frobnicate --flag <v> --invented <v>"),),
|
|
properties=_fixture_record("frobnicate").properties,
|
|
notes="Does a thing.",
|
|
failures=(),
|
|
)
|
|
param = click.Option(["--flag"])
|
|
command = click.Command("frobnicate", params=[param])
|
|
monkeypatch.setattr(
|
|
docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)]
|
|
)
|
|
monkeypatch.setattr(cli_contract, "get", lambda path: rec if path == "frobnicate" else None)
|
|
issues = docs_verify.check_synopsis_flags()
|
|
assert any("--invented" in issue and "not a real flag" in issue for issue in issues)
|
|
|
|
|
|
def test_a_hidden_flag_need_not_be_documented(monkeypatch):
|
|
import click
|
|
|
|
rec = _fixture_record("frobnicate")
|
|
param = click.Option(["--flag"])
|
|
hidden = click.Option(["--secret"], hidden=True)
|
|
command = click.Command("frobnicate", params=[param, hidden])
|
|
monkeypatch.setattr(
|
|
docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)]
|
|
)
|
|
monkeypatch.setattr(cli_contract, "get", lambda path: rec if path == "frobnicate" else None)
|
|
assert docs_verify.check_synopsis_flags() == []
|
|
|
|
|
|
def test_a_boolean_flag_pair_is_satisfied_by_either_spelling(monkeypatch):
|
|
"""`--push`/`--no-push`: documenting only the negative spelling (the
|
|
existing SYNOPSIS convention `publish` used before this check existed)
|
|
must not be reported as an undocumented `--push`."""
|
|
import click
|
|
|
|
rec = cli_contract.CommandRecord(
|
|
path="frobnicate",
|
|
summary="Does a thing.",
|
|
synopsis=(cli_contract.Variant(usage="frobnicate [--no-push]"),),
|
|
properties=_fixture_record("frobnicate").properties,
|
|
notes="Does a thing.",
|
|
failures=(),
|
|
)
|
|
param = click.Option(["--push/--no-push"], default=True)
|
|
command = click.Command("frobnicate", params=[param])
|
|
monkeypatch.setattr(
|
|
docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)]
|
|
)
|
|
monkeypatch.setattr(cli_contract, "get", lambda path: rec if path == "frobnicate" else None)
|
|
assert docs_verify.check_synopsis_flags() == []
|
|
|
|
|
|
def test_no_registered_commands_help_cites_an_issue_number():
|
|
"""Forward direction against the real tree: a Gitea reference baked into
|
|
a docstring above its `\\f` marker, or into an Option's help text, reaches
|
|
a distributed instance with no tracker to resolve it against."""
|
|
assert docs_verify.check_no_issue_references_in_help() == []
|
|
|
|
|
|
def test_an_issue_number_above_the_form_feed_is_reported(monkeypatch):
|
|
import click
|
|
|
|
def frobnicate():
|
|
"""One sentence (Gitea #66)."""
|
|
|
|
command = click.Command("frobnicate", callback=frobnicate, help=frobnicate.__doc__)
|
|
monkeypatch.setattr(docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)])
|
|
issues = docs_verify.check_no_issue_references_in_help()
|
|
assert any("#66" in issue and "frobnicate" in issue for issue in issues)
|
|
|
|
|
|
def test_an_issue_number_below_the_form_feed_is_not_reported(monkeypatch):
|
|
import click
|
|
|
|
help_text = "One sentence.\n\f\nMaintenance text citing Gitea #66, never rendered."
|
|
command = click.Command("frobnicate", help=help_text)
|
|
monkeypatch.setattr(docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)])
|
|
assert docs_verify.check_no_issue_references_in_help() == []
|
|
|
|
|
|
def test_an_issue_number_in_an_options_help_text_is_reported(monkeypatch):
|
|
import click
|
|
|
|
param = click.Option(["--flag"], help="Do the thing (Gitea #66).")
|
|
command = click.Command("frobnicate", params=[param])
|
|
monkeypatch.setattr(docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)])
|
|
issues = docs_verify.check_no_issue_references_in_help()
|
|
assert any("#66" in issue and "--flag" in issue for issue in issues)
|
|
|
|
|
|
def test_collection_contracts_exist():
|
|
assert docs_verify.check_collection_contracts() == []
|
|
|
|
|
|
def test_readmes_carry_no_command_table():
|
|
"""A derived copy is checked or absent: the command table is checked in
|
|
tools/CONTRACT.md, so no README may hold a second one."""
|
|
assert docs_verify.check_readmes_have_no_command_table() == []
|
|
|
|
|
|
def test_a_command_table_in_the_root_readme_is_reported(tmp_path, monkeypatch):
|
|
fake = tmp_path / "README.md"
|
|
fake.write_text("| Command | Purpose |\n| `lint` | does things |\n", encoding="utf-8")
|
|
monkeypatch.setattr(docs_verify, "ROOT_README", fake)
|
|
issues = docs_verify.check_readmes_have_no_command_table()
|
|
assert any("`lint`" in issue for issue in issues)
|
|
|
|
|
|
def test_non_command_tables_in_the_root_readme_are_allowed(tmp_path, monkeypatch):
|
|
fake = tmp_path / "README.md"
|
|
fake.write_text("| Skill | Purpose |\n| `wiki-ingest` | ingests |\n", encoding="utf-8")
|
|
monkeypatch.setattr(docs_verify, "ROOT_README", fake)
|
|
assert docs_verify.check_readmes_have_no_command_table() == []
|
|
|
|
|
|
def test_stage_readmes_are_checked_too(tmp_path, monkeypatch):
|
|
"""tools/README.md is the file the command table actually drifted in - a
|
|
stage README is allowed to exist, but not to hold a second copy."""
|
|
root = tmp_path
|
|
(root / "tools").mkdir()
|
|
(root / "tools" / "README.md").write_text(
|
|
"| Command | Purpose |\n| `publish` | pushes |\n", encoding="utf-8"
|
|
)
|
|
monkeypatch.setattr(docs_verify.config, "ROOT", root)
|
|
monkeypatch.setattr(docs_verify, "ROOT_README", root / "README.md")
|
|
issues = docs_verify.check_readmes_have_no_command_table()
|
|
assert any("`publish`" in issue for issue in issues)
|
|
|
|
|
|
def test_install_md_is_checked_too(tmp_path, monkeypatch):
|
|
"""INSTALL.md is human-facing prose about installing an instance - the
|
|
command reference lives exactly once, in tools/CONTRACT.md."""
|
|
root = tmp_path
|
|
(root / "INSTALL.md").write_text(
|
|
"| Command | Purpose |\n| `doctor` | checks things |\n", encoding="utf-8"
|
|
)
|
|
monkeypatch.setattr(docs_verify.config, "ROOT", root)
|
|
monkeypatch.setattr(docs_verify, "ROOT_README", root / "README.md") # doesn't exist here
|
|
issues = docs_verify.check_readmes_have_no_command_table()
|
|
assert any("`doctor`" in issue for issue in issues)
|
|
|
|
|
|
def test_development_md_is_checked_too(tmp_path, monkeypatch):
|
|
"""DEVELOPMENT.md drifted exactly this way once (Gitea #47): a table
|
|
describing what each verify command checks, removed by hand because nothing
|
|
compared it to anything."""
|
|
root = tmp_path
|
|
(root / "DEVELOPMENT.md").write_text(
|
|
"| Command | Purpose |\n| `docs verify` | checks docs |\n", encoding="utf-8"
|
|
)
|
|
monkeypatch.setattr(docs_verify.config, "ROOT", root)
|
|
monkeypatch.setattr(docs_verify, "ROOT_README", root / "README.md") # doesn't exist here
|
|
issues = docs_verify.check_readmes_have_no_command_table()
|
|
assert any("`docs verify`" in issue for issue in issues)
|
|
|
|
|
|
def test_an_absent_listed_doc_is_skipped_not_reported(tmp_path, monkeypatch):
|
|
"""The distributed-instance case: DEVELOPMENT.md is not shipped, so listing
|
|
it must stay inert where the file does not exist rather than failing a tree
|
|
that is correct."""
|
|
root = tmp_path
|
|
monkeypatch.setattr(docs_verify.config, "ROOT", root)
|
|
monkeypatch.setattr(docs_verify, "ROOT_README", root / "README.md")
|
|
assert docs_verify.check_readmes_have_no_command_table() == []
|
|
|
|
|
|
def test_this_repos_type_specs_validate_against_their_own_schema():
|
|
"""Regression guard for Gitea #105: types/type-spec.schema.yaml declared
|
|
`additionalProperties: false` while real type-specs already carried
|
|
`root:` and `capture_fields:`, and nothing ever validated a type-spec's
|
|
own frontmatter against it - so the mismatch shipped silently."""
|
|
assert docs_verify.check_type_spec_frontmatter() == []
|
|
|
|
|
|
def test_an_unknown_type_spec_field_is_reported(tmp_path, monkeypatch):
|
|
"""Once the schema is enforced, a type-spec frontmatter field its own
|
|
schema does not know about must fail loudly rather than validating
|
|
silently - the other direction of the #105 regression guard above."""
|
|
types_dir = tmp_path / "types"
|
|
types_dir.mkdir()
|
|
(types_dir / "type-spec.md").write_text(
|
|
"---\n"
|
|
"type: types/type-spec.md\n"
|
|
"name: type-spec\n"
|
|
"description: Authoring and validation contract for type specs\n"
|
|
"schema: types/type-spec.schema.yaml\n"
|
|
"---\n",
|
|
encoding="utf-8",
|
|
)
|
|
(types_dir / "type-spec.schema.yaml").write_text(
|
|
(config.ROOT / "types" / "type-spec.schema.yaml").read_text(encoding="utf-8"),
|
|
encoding="utf-8",
|
|
)
|
|
(types_dir / "widget.md").write_text(
|
|
"---\n"
|
|
"type: types/type-spec.md\n"
|
|
"name: widget\n"
|
|
"description: A type-spec with a field its own schema does not know\n"
|
|
"schema: null\n"
|
|
"not_a_real_field: true\n"
|
|
"---\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
|
monkeypatch.setattr(config, "TYPES_DIR", types_dir)
|
|
monkeypatch.setattr(type_resolver, "resolver", TypeResolver(repo_root=tmp_path))
|
|
|
|
issues = docs_verify.check_type_spec_frontmatter()
|
|
assert any("widget.md" in issue and "not_a_real_field" in issue for issue in issues)
|
|
|
|
|
|
def _stack_required_type_tree(tmp_path, monkeypatch, project_md: str = "", project_schema: str = ""):
|
|
"""A `types/` fixture carrying a valid `source` type-spec (copied from the
|
|
real repo, so it never drifts from what `docs verify` actually enforces)
|
|
plus whatever `project.md`/`project.schema.yaml` the caller supplies -
|
|
empty strings mean "write nothing", so a caller can exercise the
|
|
type-missing case. `check_stack_required_types()` (Gitea #123) has no
|
|
dedicated coverage otherwise: it is only ever exercised indirectly, via a
|
|
full `verify()` run against the real repo tree."""
|
|
types_dir = tmp_path / "types"
|
|
types_dir.mkdir()
|
|
for name in ("type-spec.md", "type-spec.schema.yaml", "source.md", "source.schema.yaml"):
|
|
(types_dir / name).write_text(
|
|
(config.ROOT / "types" / name).read_text(encoding="utf-8"), encoding="utf-8"
|
|
)
|
|
if project_md:
|
|
(types_dir / "project.md").write_text(project_md, encoding="utf-8")
|
|
if project_schema:
|
|
(types_dir / "project.schema.yaml").write_text(project_schema, encoding="utf-8")
|
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
|
monkeypatch.setattr(config, "TYPES_DIR", types_dir)
|
|
monkeypatch.setattr(type_resolver, "resolver", TypeResolver(repo_root=tmp_path))
|
|
|
|
|
|
_PROJECT_MD = (
|
|
"---\n"
|
|
"type: types/type-spec.md\n"
|
|
"name: project\n"
|
|
"description: Fixture project type\n"
|
|
"schema: types/project.schema.yaml\n"
|
|
"---\n"
|
|
)
|
|
|
|
|
|
def test_check_stack_required_types_reports_a_missing_project_type(tmp_path, monkeypatch):
|
|
"""No `project.md` at all - the type-missing case, worded to name the
|
|
type that is missing."""
|
|
_stack_required_type_tree(tmp_path, monkeypatch)
|
|
issues = docs_verify.check_stack_required_types()
|
|
assert any("name: project" in issue for issue in issues)
|
|
|
|
|
|
def test_check_stack_required_types_reports_project_schema_missing_state(tmp_path, monkeypatch):
|
|
"""A `project` type-spec exists, but its schema does not require `state:`
|
|
- the field-missing case, distinct from the type-missing one above."""
|
|
schema = (
|
|
"type: object\n"
|
|
"properties:\n"
|
|
" type: {type: string, const: 'types/project.md'}\n"
|
|
" state: {type: string, enum: [active, dormant, completed, abandoned]}\n"
|
|
"required: [type]\n"
|
|
"additionalProperties: false\n"
|
|
)
|
|
_stack_required_type_tree(tmp_path, monkeypatch, project_md=_PROJECT_MD, project_schema=schema)
|
|
issues = docs_verify.check_stack_required_types()
|
|
assert any("state" in issue and "project.md" in issue for issue in issues)
|
|
|
|
|
|
def test_check_stack_required_types_passes_when_both_types_satisfy_their_field(tmp_path, monkeypatch):
|
|
"""`source`/`raw_files:` and `project`/`state:` both satisfied - no issues."""
|
|
schema = (
|
|
"type: object\n"
|
|
"properties:\n"
|
|
" type: {type: string, const: 'types/project.md'}\n"
|
|
" state: {type: string, enum: [active, dormant, completed, abandoned]}\n"
|
|
"required: [type, state]\n"
|
|
"additionalProperties: false\n"
|
|
)
|
|
_stack_required_type_tree(tmp_path, monkeypatch, project_md=_PROJECT_MD, project_schema=schema)
|
|
assert docs_verify.check_stack_required_types() == []
|
|
|
|
|
|
def test_legacy_type_blocks_are_absent():
|
|
assert docs_verify.check_legacy_type_blocks() == []
|
|
|
|
|
|
def test_legacy_type_regex_matches_pre_migration_form():
|
|
assert docs_verify.LEGACY_TYPE_RE.search("---\ntype: comparison\ntags: []\n---")
|
|
assert not docs_verify.LEGACY_TYPE_RE.search("---\ntype: types/comparison.md\n---")
|
|
|
|
|
|
def test_no_content_is_gitignored():
|
|
"""The regression guard for the 2026-08-13 `.gitignore` rewrite: patterns
|
|
like `*temp*` and `bin/` were silently excluding files under raw/, so the
|
|
wiki reported them as covered while `publish` never committed them."""
|
|
assert docs_verify.check_ignored_content() == []
|
|
|
|
|
|
def test_ignore_canaries_are_clear():
|
|
assert docs_verify.ignored_canaries() == []
|
|
|
|
|
|
def test_a_swallowed_canary_is_reported():
|
|
"""`tools/.wikitool_session/` is legitimately ignored, so it stands in for
|
|
a content path that a bad pattern would swallow."""
|
|
swallowed = docs_verify.ignored_canaries(("tools/.wikitool_session/budget.json",))
|
|
assert swallowed == ["tools/.wikitool_session/budget.json"]
|
|
|
|
|
|
def test_incoming_inbox_is_ignored():
|
|
"""Unlike raw/, a file under incoming/ must never be committed - promotion
|
|
via `wikitool raw accept` is what makes it immutable, not the drop."""
|
|
assert docs_verify.ignored_canaries(("incoming/documents/probe.pdf",)) == [
|
|
"incoming/documents/probe.pdf"
|
|
]
|
|
|
|
|
|
def test_the_environment_note_is_ignored_but_its_template_is_not():
|
|
"""The pattern has to split a file from its own template. `ENVIRONMENT.md`
|
|
describes one checkout and must never be committed; `ENVIRONMENT.md.template`
|
|
is tracked machinery that `dist export` ships, and the careless pattern
|
|
(`ENVIRONMENT.md*`) would swallow both."""
|
|
assert docs_verify.ignored_canaries(("ENVIRONMENT.md",)) == ["ENVIRONMENT.md"]
|
|
assert docs_verify.ignored_canaries(("ENVIRONMENT.md.template",)) == []
|
|
|
|
|
|
def test_coverage_output_is_ignored():
|
|
"""`pytest --cov` writes into tools/, and `publish` runs `git add -A`."""
|
|
paths = ("tools/coverage.xml", "tools/htmlcov/index.html", "tools/.coverage")
|
|
assert docs_verify.ignored_canaries(paths) == list(paths)
|
|
|
|
|
|
def test_ignore_checks_degrade_when_git_is_unavailable(monkeypatch):
|
|
"""Without git the ignore rules are unknowable, not wrong - `docs verify`
|
|
must stay usable rather than reporting a false positive."""
|
|
monkeypatch.setattr(docs_verify, "_git", lambda *a, **k: None)
|
|
assert docs_verify.check_ignored_content() == []
|
|
|
|
|
|
def test_this_repos_version_and_changelog_agree():
|
|
assert docs_verify.check_version_changelog() == []
|
|
|
|
|
|
def _versioned_tree(tmp_path, monkeypatch, version: str, changes: str):
|
|
(tmp_path / "VERSION").write_text(version, encoding="utf-8")
|
|
(tmp_path / "CHANGES.md").write_text(changes, encoding="utf-8")
|
|
monkeypatch.setattr(docs_verify.config, "ROOT", tmp_path)
|
|
|
|
|
|
def test_a_bump_with_no_changelog_entry_is_reported(tmp_path, monkeypatch):
|
|
"""The check that gives `version bump` its teeth: a version raised with
|
|
nothing written about it would ship release notes describing the
|
|
previous release."""
|
|
_versioned_tree(tmp_path, monkeypatch, "0.2.0\n", "# Changelog\n\n## 0.1.0 - 2026-08-29 - Old\n")
|
|
issues = docs_verify.check_version_changelog()
|
|
assert any("0.2.0" in issue and "0.1.0" in issue for issue in issues)
|
|
|
|
|
|
def test_a_changelog_with_no_versioned_entry_is_accepted(tmp_path, monkeypatch):
|
|
"""A fresh distribution ships an empty changelog, and this repo's own
|
|
pre-versioning entries are dated rather than versioned. Neither claims to
|
|
describe the current version."""
|
|
_versioned_tree(
|
|
tmp_path, monkeypatch, "0.1.0\n", "# Changelog\n\n## 2026-08-01 - Before versioning\n"
|
|
)
|
|
assert docs_verify.check_version_changelog() == []
|
|
|
|
|
|
def test_a_missing_or_malformed_version_is_reported(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(docs_verify.config, "ROOT", tmp_path)
|
|
assert any("VERSION" in issue for issue in docs_verify.check_version_changelog())
|
|
_versioned_tree(tmp_path, monkeypatch, "not-a-version\n", "# Changelog\n")
|
|
assert any("semantic version" in issue for issue in docs_verify.check_version_changelog())
|
|
|
|
|
|
def test_this_repos_boundary_is_accounted_for():
|
|
assert docs_verify.check_migration_for_boundary() == []
|
|
|
|
|
|
def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: str = ""):
|
|
"""A changelog with `current` as the topmost entry and `previous` as the
|
|
last release beneath it. `current` is normally an open candidate
|
|
(`2.0.0-beta.1`) - the checks compare the newest entry against the **last
|
|
release** (`version_mod.last_release`), which skips right past a topmost
|
|
entry that is itself already a release (that one's crossing, if any, was
|
|
already checked while it was still the open candidate)."""
|
|
(tmp_path / "VERSION").write_text(f"{current}\n", encoding="utf-8")
|
|
(tmp_path / "CHANGES.md").write_text(
|
|
"# Changelog\n\n---\n\n"
|
|
f"## {current} - 2026-09-01 - New\n\n{marker}Body.\n\n---\n\n"
|
|
f"## {previous} - 2026-08-30 - Old\n\nBody.\n",
|
|
encoding="utf-8",
|
|
)
|
|
instructions = tmp_path / "instructions"
|
|
(instructions / "migrations").mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.setattr(docs_verify.config, "ROOT", tmp_path)
|
|
monkeypatch.setattr(docs_verify.config, "INSTRUCTIONS_DIR", instructions)
|
|
return tmp_path
|
|
|
|
|
|
def test_a_breaking_release_without_a_migration_is_reported(tmp_path, monkeypatch):
|
|
"""`version check` tells an instance it must migrate; without this, that is
|
|
where the trail ends."""
|
|
_boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
|
|
issues = docs_verify.check_migration_for_boundary()
|
|
assert any("2.0.0-beta.1" in issue and "must migrate" in issue for issue in issues)
|
|
|
|
|
|
def test_a_compatible_release_needs_no_migration(tmp_path, monkeypatch):
|
|
_boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
|
|
assert docs_verify.check_migration_for_boundary() == []
|
|
|
|
|
|
def test_a_fixed_release_is_never_re_checked_against_its_own_crossing(tmp_path, monkeypatch):
|
|
"""Regression for finding #2: comparing against the entry *beneath* the
|
|
newest one (rather than the last release) would find no boundary between
|
|
two betas of the same candidate - and would also, wrongly, re-flag an
|
|
already-fixed release forever. Once `current` is itself a release,
|
|
`last_release` returns it directly, so there is nothing left to compare."""
|
|
_boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
|
|
assert docs_verify.check_migration_for_boundary() == []
|
|
assert docs_verify.check_breaking_change_for_boundary() == []
|
|
|
|
|
|
def test_an_explicit_none_required_marker_satisfies_the_check(tmp_path, monkeypatch):
|
|
from chemenu import version as version_mod
|
|
|
|
_boundary_tree(
|
|
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
|
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
|
)
|
|
assert docs_verify.check_migration_for_boundary() == []
|
|
|
|
|
|
def test_a_migration_document_satisfies_the_check(tmp_path, monkeypatch):
|
|
"""The document targets the candidate's *base* (`2.0.0`), not its full
|
|
pre-release form - matching what `version bump` looks for."""
|
|
root = _boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
|
|
(root / "instructions" / "migrations" / "2.0.0-retype.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
|
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert docs_verify.check_migration_for_boundary() == []
|
|
|
|
|
|
def test_a_breaking_release_without_a_breaking_note_is_reported(tmp_path, monkeypatch):
|
|
"""A crossing that migrates nothing still leaves hand-work behind, so the
|
|
migration check passing is not evidence that anyone was told."""
|
|
from chemenu import version as version_mod
|
|
|
|
_boundary_tree(
|
|
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
|
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
|
)
|
|
assert docs_verify.check_migration_for_boundary() == []
|
|
issues = docs_verify.check_breaking_change_for_boundary()
|
|
assert any("2.0.0-beta.1" in issue and "drop-in" in issue for issue in issues)
|
|
|
|
|
|
def test_a_compatible_release_needs_no_breaking_note(tmp_path, monkeypatch):
|
|
_boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
|
|
assert docs_verify.check_breaking_change_for_boundary() == []
|
|
|
|
|
|
def test_a_breaking_change_marker_satisfies_the_check(tmp_path, monkeypatch):
|
|
from chemenu import version as version_mod
|
|
|
|
_boundary_tree(
|
|
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
|
marker=f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved.\n\n",
|
|
)
|
|
assert docs_verify.check_breaking_change_for_boundary() == []
|
|
|
|
|
|
def test_verify_raises_when_a_boundary_has_no_breaking_note(monkeypatch):
|
|
monkeypatch.setattr(
|
|
docs_verify, "check_breaking_change_for_boundary", lambda: ["unannounced"]
|
|
)
|
|
with pytest.raises(typer.Exit):
|
|
docs_verify.verify()
|
|
|
|
|
|
def test_verify_raises_when_a_boundary_has_no_migration(monkeypatch):
|
|
monkeypatch.setattr(docs_verify, "check_migration_for_boundary", lambda: ["unbridged"])
|
|
with pytest.raises(typer.Exit):
|
|
docs_verify.verify()
|
|
|
|
|
|
def test_verify_raises_when_the_version_is_undocumented(monkeypatch):
|
|
monkeypatch.setattr(docs_verify, "check_version_changelog", lambda: ["undocumented"])
|
|
with pytest.raises(typer.Exit):
|
|
docs_verify.verify()
|
|
|
|
|
|
def test_verify_raises_when_issues_exist(monkeypatch):
|
|
monkeypatch.setattr(docs_verify, "check_command_contracts", lambda: ["boom"])
|
|
with pytest.raises(typer.Exit):
|
|
docs_verify.verify()
|
|
|
|
|
|
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_every_reference_files_toc_is_current():
|
|
"""Forward direction, against the real tree: every file `toc.target_files()`
|
|
covers must already carry the region `wikitool docs toc --apply` would
|
|
write - this is what a session forgetting to re-run it after adding a
|
|
heading is caught by."""
|
|
assert docs_verify.check_toc_regions() == []
|
|
|
|
|
|
def test_a_shipped_template_over_the_threshold_without_a_region_is_reported(tmp_path, monkeypatch):
|
|
"""Regression guard for the defect this scope extension fixes: the template
|
|
an instance adopts was maintained by nothing and checked by nothing, so
|
|
`kb/CONVENTIONS.md.template` grew past the threshold carrying no region -
|
|
and every instance that adopted it got a `kb/CONVENTIONS.md` that fails
|
|
`docs verify` at the end of `setup-instance.md`, the one command that step
|
|
ends with. Before the template entered `toc.target_files()`, this check
|
|
returned nothing here."""
|
|
from chemenu import config, toc as toc_mod
|
|
|
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
|
(tmp_path / "kb").mkdir()
|
|
long_body = "# Conventions\n\nIntro.\n" + "".join(
|
|
f"\n## Section {i}\n\n" + "Body line.\n" * 12 for i in range(8)
|
|
)
|
|
assert toc_mod.needs_toc(long_body)
|
|
# The adopted file is current; only the template it was adopted from is not.
|
|
(tmp_path / "kb" / "CONVENTIONS.md").write_text(toc_mod.upsert(long_body), encoding="utf-8")
|
|
(tmp_path / "kb" / "CONVENTIONS.md.template").write_text(long_body, encoding="utf-8")
|
|
|
|
issues = docs_verify.check_toc_regions()
|
|
|
|
assert len(issues) == 1
|
|
assert "kb/CONVENTIONS.md.template" in issues[0]
|
|
assert "docs toc --apply" in issues[0]
|
|
|
|
|
|
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_dead_relative_link_is_reported(tmp_path, monkeypatch):
|
|
"""Regression guard for the bug this check exists to catch: a `../` count
|
|
wrong for the file's own depth is invisible to every other check - the
|
|
name it links to is real, the text renders, and nothing resolves the
|
|
target to notice it lands nowhere."""
|
|
fake = tmp_path / "example.md"
|
|
fake.write_text("See [tools/CONTRACT.md](../tools/CONTRACT.md) for the command table.\n", encoding="utf-8")
|
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
|
issues = docs_verify.check_reference_targets()
|
|
assert len(issues) == 1
|
|
assert "example.md:1" in issues[0]
|
|
assert "../tools/CONTRACT.md" in issues[0]
|
|
|
|
|
|
def test_a_resolving_relative_link_is_not_reported(tmp_path, monkeypatch):
|
|
(tmp_path / "tools").mkdir()
|
|
(tmp_path / "tools" / "CONTRACT.md").write_text("# Contract\n", encoding="utf-8")
|
|
fake = tmp_path / "example.md"
|
|
fake.write_text("See [tools/CONTRACT.md](tools/CONTRACT.md) for the command table.\n", encoding="utf-8")
|
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
|
assert docs_verify.check_reference_targets() == []
|
|
|
|
|
|
def test_an_absolute_url_is_not_resolved_as_a_path(tmp_path, monkeypatch):
|
|
fake = tmp_path / "example.md"
|
|
fake.write_text("See [Anthropic](https://www.anthropic.com).\n", encoding="utf-8")
|
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
|
assert docs_verify.check_reference_targets() == []
|
|
|
|
|
|
def test_a_section_anchor_is_stripped_before_resolving(tmp_path, monkeypatch):
|
|
"""CommonMark anchors are not filesystem paths - only the path part of
|
|
`target#anchor` is checked for existence."""
|
|
(tmp_path / "kb").mkdir()
|
|
(tmp_path / "kb" / "CONVENTIONS.md").write_text("## Tone\n", encoding="utf-8")
|
|
fake = tmp_path / "example.md"
|
|
fake.write_text("See [kb/CONVENTIONS.md § Tone](kb/CONVENTIONS.md#tone).\n", encoding="utf-8")
|
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
|
assert docs_verify.check_reference_targets() == []
|
|
|
|
|
|
def test_link_syntax_shown_as_an_example_in_a_fence_is_not_flagged(tmp_path, monkeypatch):
|
|
"""A passage documenting bad link syntax must not be mistaken for a real
|
|
reference - code fences are masked before scanning, mirroring `toc.py`."""
|
|
fake = tmp_path / "example.md"
|
|
fake.write_text(
|
|
"Do not write it like this:\n\n```markdown\n[gates.md](../nonexistent.md)\n```\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
|
assert docs_verify.check_reference_targets() == []
|
|
|
|
|
|
def test_a_target_shipped_only_as_a_template_is_not_dead(tmp_path, monkeypatch):
|
|
"""Regression guard for a defect this check shipped with. A fresh
|
|
`dist export` carries `kb/CONVENTIONS.md.template`, not
|
|
`kb/CONVENTIONS.md` - the instance adopts it by renaming, during
|
|
`setup-instance.md`'s personalization step. `kb/CONTRACT.md` and three
|
|
flat instructions link to the adopted name, correctly. Before this
|
|
exemption the check reported 13 dead links on a just-exported tree, for
|
|
doing exactly what a fresh export is supposed to do."""
|
|
(tmp_path / "kb").mkdir()
|
|
(tmp_path / "kb" / "CONVENTIONS.md.template").write_text("# Conventions\n", encoding="utf-8")
|
|
fake = tmp_path / "kb" / "CONTRACT.md"
|
|
fake.write_text("What this instance decided: [kb/CONVENTIONS.md](CONVENTIONS.md).\n", encoding="utf-8")
|
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
|
assert docs_verify.check_reference_targets() == []
|
|
|
|
|
|
def test_a_target_with_neither_the_file_nor_a_template_is_still_dead(tmp_path, monkeypatch):
|
|
"""The exemption is narrow: it covers a file the stack ships as a
|
|
template, not any missing target."""
|
|
(tmp_path / "kb").mkdir()
|
|
fake = tmp_path / "kb" / "CONTRACT.md"
|
|
fake.write_text("See [kb/CONVENTIONS.md](CONVENTIONS.md).\n", encoding="utf-8")
|
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
|
issues = docs_verify.check_reference_targets()
|
|
assert len(issues) == 1
|
|
assert "CONVENTIONS.md" in issues[0]
|
|
|
|
|
|
def test_every_reference_files_link_targets_resolve():
|
|
"""Forward direction, against the real tree: every relative link in a file
|
|
`toc.target_files()` covers must resolve - this is what a `../` count
|
|
wrong for the file's own depth is caught by."""
|
|
assert docs_verify.check_reference_targets() == []
|
|
|
|
|
|
def test_verify_raises_when_a_reference_target_is_dead(monkeypatch):
|
|
monkeypatch.setattr(docs_verify, "check_reference_targets", lambda: ["dangling"])
|
|
with pytest.raises(typer.Exit):
|
|
docs_verify.verify()
|
|
|
|
|
|
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):
|
|
"""An ordinary heading's slug is word characters, so it never collides
|
|
with `#<digits>` in the first place - this pins that the lookbehind
|
|
exclusion does not accidentally start matching it either."""
|
|
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_a_numbered_heading_anchor_is_not_an_issue_reference(monkeypatch):
|
|
"""A table-of-contents entry for a numbered step (`toc.py`) anchors on
|
|
the number itself - `#2-fix-the-fidelity-before-writing-a-word` - unlike
|
|
an ordinary heading's slug, which starts with a letter. The bare
|
|
`#\\d+` pattern would flag that as citing issue #2; the lookbehind
|
|
excludes exactly the `](#...` link-fragment shape it appears in."""
|
|
monkeypatch.setattr(
|
|
docs_verify,
|
|
"shipped_prose",
|
|
lambda: {
|
|
"instructions/example.md": (
|
|
"# Heading\n"
|
|
"- [2. Fix the fidelity before writing a word](#2-fix-the-fidelity-before-writing-a-word)\n"
|
|
)
|
|
},
|
|
)
|
|
assert docs_verify.check_no_issue_references() == []
|
|
|
|
|
|
def test_a_parenthesized_issue_number_is_still_reported(monkeypatch):
|
|
"""The lookbehind excludes `](#...`, not bare `(#...` - a real citation
|
|
written as a plain parenthetical must still be caught."""
|
|
monkeypatch.setattr(
|
|
docs_verify,
|
|
"shipped_prose",
|
|
lambda: {"instructions/example.md": "A rule (#66).\n"},
|
|
)
|
|
issues = docs_verify.check_no_issue_references()
|
|
assert len(issues) == 1
|
|
assert "#66" in issues[0]
|
|
|
|
|
|
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()
|