diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 96ab36d1512ec..736f3fff51c4f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1014,6 +1014,20 @@ repos: ^scripts/ci/prek/generate_agent_skills\.py$ pass_filenames: false additional_dependencies: ['pyyaml', 'rich>=13.6.0'] + - id: check-documented-commands + name: Check documented breeze and prek commands exist + entry: ./scripts/ci/prek/check_documented_commands.py + language: python + files: > + (?x) + ^AGENTS\.md$| + ^contributing-docs/.*\.rst$| + ^dev/breeze/src/airflow_breeze/commands/| + ^(?:.*/)?\.pre-commit-config\.yaml$| + ^scripts/ci/prek/check_documented_commands\.py$ + pass_filenames: false + require_serial: true + additional_dependencies: ['pyyaml', 'rich>=13.6.0'] - id: run-skill-eval name: Run skill-eval against AGENTS.md and cases entry: ./dev/skill-evals/eval.py diff --git a/contributing-docs/03_contributors_quick_start.rst b/contributing-docs/03_contributors_quick_start.rst index 1b71b9bba9643..4212a85abe6db 100644 --- a/contributing-docs/03_contributors_quick_start.rst +++ b/contributing-docs/03_contributors_quick_start.rst @@ -669,8 +669,8 @@ You can also switch terminal multiplexer via breeze config: .. code-block:: bash - breeze setup config --terminal_multiplexer tmux - breeze setup config --terminal_multiplexer mprocs + breeze setup config --terminal-multiplexer tmux + breeze setup config --terminal-multiplexer mprocs ** Benefits of using tmux:** diff --git a/contributing-docs/27_cli_implementation_guide.rst b/contributing-docs/27_cli_implementation_guide.rst index 50b96ec59b273..0081bca329ea0 100644 --- a/contributing-docs/27_cli_implementation_guide.rst +++ b/contributing-docs/27_cli_implementation_guide.rst @@ -101,7 +101,7 @@ HTTP client and operations: ``airflow-ctl/src/airflowctl/api/`` (``client.py``, .. code-block:: bash - breeze testing airflow-ctl-integration-test + breeze testing airflow-ctl-integration-tests Rewiring an Existing ``airflow`` CLI Command --------------------------------------------- diff --git a/contributing-docs/testing/airflow_ctl_tests.rst b/contributing-docs/testing/airflow_ctl_tests.rst index 64a8d436ad580..4a6786362102a 100644 --- a/contributing-docs/testing/airflow_ctl_tests.rst +++ b/contributing-docs/testing/airflow_ctl_tests.rst @@ -39,7 +39,7 @@ Then, you can run the tests using the following command: .. code-block:: bash - breeze testing airflow-ctl-integration-test + breeze testing airflow-ctl-integration-tests .. note:: diff --git a/contributing-docs/testing/integration_tests.rst b/contributing-docs/testing/integration_tests.rst index e44da0b70e784..4b0b617d93765 100644 --- a/contributing-docs/testing/integration_tests.rst +++ b/contributing-docs/testing/integration_tests.rst @@ -188,7 +188,7 @@ Running Integration Tests from the Host You can also run integration tests using Breeze from the host. Depending on the type of integration, you can rum "providers" or "core" integration tests. You can consult the table above to see which integration is "core" and which is "provider" one, also by running the -``breeze providers-integration-tests --help`` or ``breeze core-integration-tests --help`` command +``breeze testing providers-integration-tests --help`` or ``breeze testing core-integration-tests --help`` command you can see the list of available integrations for each type of test. Runs all core integration tests: diff --git a/contributing-docs/testing/task_sdk_integration_tests.rst b/contributing-docs/testing/task_sdk_integration_tests.rst index 75ff8962320e4..6eda7d10e9166 100644 --- a/contributing-docs/testing/task_sdk_integration_tests.rst +++ b/contributing-docs/testing/task_sdk_integration_tests.rst @@ -100,7 +100,7 @@ If you use ``breeze`` to run the integration tests and you do not have the image if you do not answer ``no``. This will build the right image ``ghcr.io/apache/airflow/main/prod/python3.10.latest`` (with the right -Python version) that will be used to run the tests. The ``breeze prod image build`` command by default - +Python version) that will be used to run the tests. The ``breeze prod-image build`` command by default - when run from sources of airflow - will use the local sources and build the image using ``uv`` to speed up the build process. Also, when building from sources it will check if the assets are built and will error if they are not. However it will not check if the assets are up to date - so make sure @@ -113,7 +113,7 @@ and did not build your assets after that. because the docker-compose setup we use in tests will automatically mount the local Python sources into the container, so you can iterate quickly without rebuilding the image. However, if you want to test changes that require new image (like modifying dependencies, system packages, rebuilding UI etc.) you will need - to rebuild the image with the ``breeze prod image build`` command. + to rebuild the image with the ``breeze prod-image build`` command. After you build the image, there are several ways to run Task SDK Integration Tests, depending based on your preferences. The ways are listed below. diff --git a/contributing-docs/testing/unit_tests.rst b/contributing-docs/testing/unit_tests.rst index d3f58af6cae98..faa145bb7a471 100644 --- a/contributing-docs/testing/unit_tests.rst +++ b/contributing-docs/testing/unit_tests.rst @@ -133,7 +133,7 @@ Airflow unit test types Airflow tests in the CI environment are split into several test types. You can narrow down which test types you want to use in various ``breeze testing`` sub-commands in three ways: -* By specifying the ``--test-type`` when running a single test type in ``breeze testing core-tests``, ``breeze testing providers-tests``, or ``breeze testing integration-tests`` commands. +* By specifying the ``--test-type`` when running a single test type in ``breeze testing core-tests``, ``breeze testing providers-tests``, or ``breeze testing core-integration-tests`` commands. * By specifying a space-separated list of test types via the ``--parallel-test-types`` or ``--excluded-parallel-test-types`` options when running tests in parallel. The defined test types are: @@ -154,7 +154,7 @@ We also have types that run "all" tests (ignoring folders, but looking at ``pyte * ``All-Quarantined`` - Tests that are flaky and need to be fixed (``quarantined`` marker). * ``All`` - All tests are run (this is the default). -We also have ``Integration`` tests that run with external software via the ``--integration`` flag in the ``breeze`` environment (via ``breeze testing integration-tests``). +We also have ``Integration`` tests that run with external software via the ``--integration`` flag in the ``breeze`` environment (via ``breeze testing core-integration-tests`` and ``breeze testing providers-integration-tests``). * ``Integration`` - Tests that require external integration images running in docker-compose. diff --git a/scripts/ci/prek/breeze_command_walker.py b/scripts/ci/prek/breeze_command_walker.py new file mode 100644 index 0000000000000..78b394a70ad05 --- /dev/null +++ b/scripts/ci/prek/breeze_command_walker.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Introspect Breeze's click command tree and print it as structured JSON. + +Runs inside Breeze's virtualenv (``uv run --project dev/breeze``) because the +consumer, ``check_documented_commands.py``, runs in the prek hook venv where +``airflow_breeze`` cannot be imported. + +Emits one ``REGISTRY_JSON:`` line per command path with its group / passthrough +/ options. Walks the live click objects rather than ``Context.to_info_dict()``, +which drops the ``context_settings`` that ``breeze k8s deploy-cluster`` relies +on to mark itself passthrough. +""" + +from __future__ import annotations + +import json + +import click +from airflow_breeze.breeze import main + + +def is_passthrough(cmd: click.Command) -> bool: + """True when trailing words are forwarded verbatim, so options can't be validated.""" + if (cmd.context_settings or {}).get("ignore_unknown_options"): + return True + return any(isinstance(param, click.Argument) and param.nargs == -1 for param in cmd.params) + + +def option_strings(cmd: click.Command) -> list[str]: + strings: set[str] = set() + for param in cmd.params: + if isinstance(param, click.Option): + strings.update(param.opts) + strings.update(param.secondary_opts) + return sorted(strings) + + +def walk(cmd: click.Command, path: str) -> dict[str, dict]: + is_group = isinstance(cmd, click.Group) + tree: dict[str, dict] = { + path: {"group": is_group, "passthrough": is_passthrough(cmd), "opts": option_strings(cmd)} + } + if isinstance(cmd, click.Group): + for name, sub in cmd.commands.items(): + tree.update(walk(sub, f"{path} {name}")) + return tree + + +if __name__ == "__main__": + print("REGISTRY_JSON:" + json.dumps(walk(main, "breeze"))) diff --git a/scripts/ci/prek/check_documented_commands.py b/scripts/ci/prek/check_documented_commands.py new file mode 100755 index 0000000000000..3337e87745de0 --- /dev/null +++ b/scripts/ci/prek/check_documented_commands.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Check that every breeze/prek command the docs mention actually exists. + +Documentation can drift from reality in two directions: someone documents a +command that never existed (apache/airflow#69861 shipped +``breeze selective-checks`` — the registered command is +``breeze ci selective-check``), or a real command or option gets renamed +while the docs stay put (apache/airflow#43979 renamed +``breeze testing integration-tests`` and the same commit left a doc line +pointing at the old name). The sync pipeline (generate-agent-skills, #68204) +only copies the generated Commands section of AGENTS.md from +contributing-docs; this hook is the sole validator, checking the rendered +docs — generated and hand-written alike — against what the CLIs actually +register. It scans AGENTS.md and the whole ``contributing-docs/`` tree, and +also triggers on changes under ``dev/breeze/src/airflow_breeze/commands/`` +and the prek configs, so a rename re-validates the docs without any doc +change. + +Two families are validated (precision over recall — a checker that cries wolf +gets SKIPped): + +- ``breeze …`` — against Breeze's click tree, introspected at runtime via + ``breeze_command_walker.py``: subcommands and ``--long`` options both, + except the forwarded tail of a ``passthrough`` command. +- ``prek run `` — against the hook ids in every + ``.pre-commit-config.yaml`` (root and nested). + +A ```` word stops validation rather than guessing. +""" + +from __future__ import annotations + +import difflib +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import yaml + +sys.path.insert(0, str(Path(__file__).parent.resolve())) +from common_prek_utils import AIRFLOW_ROOT_PATH, check_uv_version, console + +BREEZE_PROJECT_PATH = AIRFLOW_ROOT_PATH / "dev" / "breeze" +BREEZE_WALKER_PATH = Path(__file__).parent.resolve() / "breeze_command_walker.py" + +# Words that end a command path: options, placeholders, shell syntax, paths. +_COMMAND_WORD_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") + +# ``double`` = RST literal, `single` = Markdown code. The lookahead drops a +# single-backtick span closed by ``_`` — an RST hyperlink (`text `_), not code. +_INLINE_CODE_RE = re.compile(r"``([^`]+)``|`([^`\n]+)`(?!_)") +_FENCE_RE = re.compile(r"^(```|~~~)") +_HEREDOC_START_RE = re.compile(r"<<-?\s*'?\"?(\w+)") + +# RST literal blocks are indented, not fenced: a ``.. code-block::`` or a +# paragraph ending ``::`` starts one, until the indentation drops back. Other +# ``.. directive::`` forms (``.. note::`` …) aren't literal blocks. +_RST_CODE_DIRECTIVE_RE = re.compile(r"^\.\.\s+(?:code-block|code|sourcecode)::") +_RST_DIRECTIVE_RE = re.compile(r"^\.\.\s+\S+::") + + +class BreezeIntrospectionError(RuntimeError): + """Breeze's command tree could not be introspected (broken env, import error…).""" + + +@dataclass(frozen=True) +class BreezeCommand: + """One node of Breeze's click command tree.""" + + group: bool + passthrough: bool + opts: frozenset[str] + + +@dataclass(frozen=True) +class DocumentedCommand: + """A command candidate extracted from documentation text.""" + + family: str # "breeze" | "prek" + text: str # the command path, e.g. "breeze ci selective-check" (for messages) + raw: str # the original snippet — validated in full (path + options) + + +@dataclass(frozen=True) +class CommandViolation: + command: DocumentedCommand + reason: str + + +def _cut_command_words(snippet: str) -> list[str]: + """Keep leading command-path words, stopping at options/placeholders/args.""" + words = [] + for word in snippet.split(): + if not _COMMAND_WORD_RE.match(word) or "<" in word: + break + words.append(word) + return words + + +def _extract_from_snippet(snippet: str) -> DocumentedCommand | None: + snippet = snippet.strip() + if snippet.startswith("breeze "): + words = _cut_command_words(snippet) + if len(words) >= 2: + return DocumentedCommand(family="breeze", text=" ".join(words), raw=snippet) + elif snippet.startswith("prek run "): + parts = snippet.split() + hook_id = parts[2] if len(parts) > 2 else "" + # No hook id (bare `prek run --from-ref …`) or a placeholder id + # (`mypy-`) cannot be validated — skip. + if hook_id and not hook_id.startswith("-") and "<" not in hook_id: + return DocumentedCommand(family="prek", text=hook_id, raw=snippet) + return None + + +def _iter_inline_code(line: str) -> list[str]: + """Yield the code text of every inline span on a line (double- or single-backtick).""" + return [double or single for double, single in _INLINE_CODE_RE.findall(line)] + + +def extract_documented_commands(text: str) -> list[DocumentedCommand]: + """Extract breeze/prek command candidates from Markdown/RST text. + + Scans inline code spans, fenced (```) blocks, and RST indented literal + blocks. Heredoc bodies inside fences are skipped — their content is data, + not commands. + """ + candidates: list[DocumentedCommand] = [] + in_fence = False + heredoc_terminator: str | None = None + literal_indent: int | None = None + for line in text.splitlines(): + stripped = line.strip() + if _FENCE_RE.match(stripped): + in_fence = not in_fence + heredoc_terminator = None + literal_indent = None + continue + if in_fence: + if heredoc_terminator: + if stripped == heredoc_terminator: + heredoc_terminator = None + continue + heredoc_match = _HEREDOC_START_RE.search(line) + if heredoc_match: + heredoc_terminator = heredoc_match.group(1) + candidate = _extract_from_snippet(line) + if candidate: + candidates.append(candidate) + continue + if literal_indent is not None: + if stripped == "": + continue + if len(line) - len(line.lstrip()) > literal_indent: + candidate = _extract_from_snippet(line) + if candidate: + candidates.append(candidate) + continue + literal_indent = None # dedented out of the literal block; process normally + for span in _iter_inline_code(line): + candidate = _extract_from_snippet(span) + if candidate: + candidates.append(candidate) + if _RST_CODE_DIRECTIVE_RE.match(stripped): + literal_indent = len(line) - len(line.lstrip()) + elif not _RST_DIRECTIVE_RE.match(stripped) and stripped.endswith("::"): + literal_indent = len(line) - len(line.lstrip()) + return candidates + + +def build_breeze_registry() -> dict[str, BreezeCommand]: + """Introspect Breeze's click command tree, keyed by command path.""" + check_uv_version() + result = subprocess.run( + ["uv", "run", "--project", str(BREEZE_PROJECT_PATH), "python", str(BREEZE_WALKER_PATH)], + capture_output=True, + text=True, + check=False, + env={**os.environ, "SKIP_BREEZE_SELF_UPGRADE_CHECK": "true"}, + ) + for line in result.stdout.splitlines(): + if line.startswith("REGISTRY_JSON:"): + raw_tree = json.loads(line[len("REGISTRY_JSON:") :]) + return { + path: BreezeCommand( + group=node["group"], + passthrough=node["passthrough"], + opts=frozenset(node["opts"]), + ) + for path, node in raw_tree.items() + } + raise BreezeIntrospectionError( + f"Could not introspect the breeze command tree (exit {result.returncode}):\n" + f"{result.stderr.strip()[:2000]}" + ) + + +def build_prek_hook_registry() -> frozenset[str]: + """Collect hook ids from the root and nested .pre-commit-config.yaml files.""" + hook_ids: set[str] = set() + for config_path in AIRFLOW_ROOT_PATH.rglob(".pre-commit-config.yaml"): + if any(part in (".venv", "node_modules", ".build") for part in config_path.parts): + continue + config = yaml.safe_load(config_path.read_text()) or {} + for repo in config.get("repos", []): + for hook in repo.get("hooks", []) or []: + if isinstance(hook, dict) and hook.get("id"): + hook_ids.add(str(hook["id"])) + return frozenset(hook_ids) + + +def _suggest(word: str, options: frozenset[str] | set[str]) -> str: + close = difflib.get_close_matches(word, sorted(options), n=2, cutoff=0.6) + return f" — did you mean {' or '.join(f'`{c}`' for c in close)}?" if close else "" + + +def _validate_breeze(raw: str, registry: dict[str, BreezeCommand]) -> str | None: + """Walk a breeze snippet against the tree; return a violation reason or None.""" + words = raw.split() + node = words[0] + if node not in registry: + return "not a registered breeze command (breeze --help)" + index = 1 + while index < len(words): + word = words[index] + if word.startswith("<"): + return None # documented placeholder — cannot descend further + if word.startswith("-") or not _COMMAND_WORD_RE.match(word): + break # options begin, or a positional argument / path + if not registry[node].group: + break # leaf reached; trailing words are its arguments + nxt = f"{node} {word}" + if nxt not in registry: + subcommands = {p.rsplit(" ", 1)[1] for p in registry if p.startswith(f"{node} ")} + return f"`{word}` is not a subcommand of `{node}`{_suggest(word, subcommands)}" + node, index = nxt, index + 1 + command = registry[node] + if command.passthrough: + return None # tail is forwarded to another program; options are not ours to check + for word in words[index:]: + if word.startswith("--") and word != "--help": # click adds --help to every command + option = word.split("=", 1)[0] + if option not in command.opts: + return f"`{option}` is not an option of `{node}`{_suggest(option, command.opts)}" + return None + + +def find_violations( + candidates: list[DocumentedCommand], + breeze_registry: dict[str, BreezeCommand], + prek_registry: frozenset[str], +) -> list[CommandViolation]: + """Return candidates that match no registered command or option.""" + violations = [] + for candidate in candidates: + if candidate.family == "breeze": + reason = _validate_breeze(candidate.raw, breeze_registry) + if reason: + violations.append(CommandViolation(candidate, reason)) + elif candidate.family == "prek" and candidate.text not in prek_registry: + reason = f"not a hook id in any .pre-commit-config.yaml{_suggest(candidate.text, prek_registry)}" + violations.append(CommandViolation(candidate, reason)) + return violations + + +def _doc_paths() -> list[Path]: + return [ + AIRFLOW_ROOT_PATH / "AGENTS.md", + *sorted((AIRFLOW_ROOT_PATH / "contributing-docs").rglob("*.rst")), + ] + + +def main() -> int: + per_file = [(path, extract_documented_commands(path.read_text())) for path in _doc_paths()] + total = sum(len(candidates) for _, candidates in per_file) + if not total: + console.print("[yellow]No breeze/prek commands found in the docs — nothing to check.[/yellow]") + return 0 + try: + breeze_registry = build_breeze_registry() + except BreezeIntrospectionError as error: + # Don't block a doc commit on a broken breeze env; a real missing + # command still fails once introspection works again. + console.print( + f"[yellow]Skipping documented-command check — breeze introspection failed:[/yellow]\n{error}" + ) + return 0 + prek_registry = build_prek_hook_registry() + violations = [ + (path, violation) + for path, candidates in per_file + for violation in find_violations(candidates, breeze_registry, prek_registry) + ] + if not violations: + console.print(f"[green]All {total} documented commands across {len(per_file)} files exist.[/green]") + return 0 + console.print(f"[red]The docs mention {len(violations)} command(s) that do not exist:[/red]") + for path, violation in violations: + console.print( + f" [red]✗[/red] `{violation.command.raw}` — {violation.reason}\n" + f" in {path.relative_to(AIRFLOW_ROOT_PATH)}" + ) + console.print( + "\nFix the command in the doc file shown. For the generated Commands" + " section of AGENTS.md, fix the AGENT-SKILL source block in contributing-docs" + " instead (see generate_agent_skills.py). If a command was renamed, update" + " the docs to the new name." + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/ci/prek/test_check_documented_commands.py b/scripts/tests/ci/prek/test_check_documented_commands.py new file mode 100644 index 0000000000000..506f79020bd8a --- /dev/null +++ b/scripts/tests/ci/prek/test_check_documented_commands.py @@ -0,0 +1,212 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import shutil +import subprocess + +import pytest +from ci.prek import check_documented_commands +from ci.prek.check_documented_commands import ( + BreezeCommand, + BreezeIntrospectionError, + build_breeze_registry, + build_prek_hook_registry, + extract_documented_commands, + find_violations, +) + + +def _cmd(*, group: bool = False, passthrough: bool = False, opts: set[str] | None = None) -> BreezeCommand: + return BreezeCommand(group=group, passthrough=passthrough, opts=frozenset(opts or ())) + + +# A miniature stand-in for the real click tree, exercising every shape the +# validator distinguishes: groups, a plain leaf with options, and passthrough +# leaves that forward their tail (so their options are not validated). +BREEZE_REGISTRY = { + "breeze": _cmd(group=True), + "breeze ci": _cmd(group=True), + "breeze ci selective-check": _cmd(opts={"--commit-ref"}), + "breeze testing": _cmd(group=True), + "breeze testing core-tests": _cmd(passthrough=True), + "breeze setup": _cmd(group=True), + "breeze setup config": _cmd(opts={"--terminal-multiplexer", "--backend"}), + "breeze run": _cmd(passthrough=True), +} +PREK_REGISTRY = frozenset({"ruff", "ruff-format", "mypy-dev"}) + + +@pytest.mark.parametrize( + ("snippet", "expected"), + [ + ("`breeze ci selective-check --commit-ref `", "breeze ci selective-check"), + ("`breeze run pytest path/to/test.py -xvs`", "breeze run pytest"), + ("`breeze testing core-tests --run-in-parallel`", "breeze testing core-tests"), + ("Run `prek run ruff --from-ref main` locally", "ruff"), + ("the ``breeze ci selective-check`` command", "breeze ci selective-check"), + ], + ids=["placeholder-truncated", "args-kept", "subcommand", "prek-hook-id", "rst-double-backtick"], +) +def test_extract_finds_command_in_inline_span(snippet: str, expected: str): + candidates = extract_documented_commands(snippet) + + assert [c.text for c in candidates] == [expected] + + +@pytest.mark.parametrize( + "snippet", + [ + "`prek run --from-ref main --stage pre-commit`", + "`prek run mypy- --all-files`", + "`git commit -m 'breeze ci selective-check'`", + "plain prose mentioning breeze testing without backticks", + "see `breeze documentation <../dev/breeze/doc/03_developer_tasks.rst>`_ for details", + ], + ids=["no-hook-id", "placeholder-hook-id", "other-family", "no-code-span", "rst-hyperlink"], +) +def test_extract_skips_unvalidatable_snippets(snippet: str): + assert extract_documented_commands(snippet) == [] + + +def test_extract_scans_fenced_blocks_and_skips_heredoc_bodies(): + text = "\n".join( + [ + "```bash", + "breeze ci selective-check --commit-ref HEAD", + "gh pr create --body \"$(cat <<'EOF'", + "breeze not-a-real-command inside heredoc data", + "EOF", + ')"', + "prek run ruff", + "```", + ] + ) + candidates = extract_documented_commands(text) + + assert [c.text for c in candidates] == ["breeze ci selective-check", "ruff"] + + +def test_extract_scans_rst_indented_literal_block(): + text = "\n".join( + [ + "Run the config command:", + "", + ".. code-block:: bash", + "", + " breeze setup config --terminal_multiplexer tmux", + "", + "back to prose mentioning breeze testing without backticks", + ] + ) + candidates = extract_documented_commands(text) + + assert [c.raw for c in candidates] == ["breeze setup config --terminal_multiplexer tmux"] + + +@pytest.mark.parametrize( + ("snippet", "reason_contains"), + [ + ("`breeze selective-checks --commit-ref `", "not a subcommand of `breeze`"), + ("`breeze ci selective-checks`", "not a subcommand of `breeze ci`"), + ("`breeze testing core-testz`", "not a subcommand of `breeze testing`"), + ("`breeze setup config --terminal_multiplexer tmux`", "not an option of `breeze setup config`"), + ("`prek run ruffff`", "not a hook id"), + ], + ids=["unknown-top", "unknown-subcommand", "typo-subcommand", "typo-option", "unknown-hook"], +) +def test_find_violations_flags_drift(snippet: str, reason_contains: str): + violations = find_violations(extract_documented_commands(snippet), BREEZE_REGISTRY, PREK_REGISTRY) + + assert len(violations) == 1 + assert reason_contains in violations[0].reason + + +@pytest.mark.parametrize( + "snippet", + [ + "`breeze ci selective-check --commit-ref `", + "`breeze run pytest path/to/test.py -xvs`", + "`breeze run airflow dags list`", + "`breeze testing core-tests --any-forwarded-flag`", + "`breeze testing --run-in-parallel`", + "`breeze setup config --terminal-multiplexer tmux`", + "`breeze testing --help`", + "`prek run ruff --from-ref main`", + ], + ids=[ + "valid-option", + "passthrough-args", + "passthrough-nested-words", + "passthrough-skips-option-check", + "placeholder-stops-descent", + "correct-option-spelling", + "help-always-valid", + "valid-hook", + ], +) +def test_find_violations_accepts_valid_commands(snippet: str): + assert find_violations(extract_documented_commands(snippet), BREEZE_REGISTRY, PREK_REGISTRY) == [] + + +def test_find_violations_suggests_close_match(): + violations = find_violations( + extract_documented_commands("`breeze ci selective-checks`"), BREEZE_REGISTRY, PREK_REGISTRY + ) + + assert "did you mean `selective-check`?" in violations[0].reason + + +def test_prek_registry_includes_root_and_nested_hook_ids(): + registry = build_prek_hook_registry() + + assert "ruff" in registry + assert "generate-agent-skills" in registry + # defined in dev/.pre-commit-config.yaml, not the root config + assert "mypy-dev" in registry + + +def test_build_breeze_registry_raises_clean_error_when_introspection_fails(monkeypatch): + failed = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="ImportError: boom") + monkeypatch.setattr(check_documented_commands, "check_uv_version", lambda *a, **k: None) + monkeypatch.setattr(check_documented_commands.subprocess, "run", lambda *a, **k: failed) + + with pytest.raises(BreezeIntrospectionError, match="boom"): + build_breeze_registry() + + +def test_main_skips_without_failing_when_breeze_introspection_fails(monkeypatch): + """A broken breeze env must warn and skip (0), not block the commit (1).""" + + def boom() -> dict: + raise BreezeIntrospectionError("broken breeze env") + + monkeypatch.setattr(check_documented_commands, "build_breeze_registry", boom) + + assert check_documented_commands.main() == 0 + + +@pytest.mark.skipif(shutil.which("uv") is None, reason="needs uv to introspect breeze") +def test_breeze_registry_matches_real_click_tree(): + registry = build_breeze_registry() + + assert "breeze ci selective-check" in registry + assert registry["breeze ci selective-check"].group is False + # `breeze run` forwards its tail to another program, so it is passthrough + assert registry["breeze run"].passthrough is True + # the #69861 bug: this was documented but never registered + assert "breeze selective-checks" not in registry