From 2b55974ee9a2684e5664fc0ab77f3079b6fd4d39 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 05:28:43 -0400 Subject: [PATCH 01/13] plan(beehave-v2): author contract surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source .pyi stubs (7): __init__, step, gherkin (8 parse-model shapes + parse_feature, Q2 collapse — no models.pyi), generate, check, status, cli. Test contract (9 pairs .pyi/.py + 2 conftests): - tests/integration/: step_cm, title_derivation, idempotency, roundtrip, strategy_inference, parsing (55 falsifiable claims, @pytest.mark.pending) - tests/e2e/: check, generate, status (pytester-driven CLI surface) docs/glossary.md: 3 contexts, 23 terms. Build-entry setup (v1 deletion, mypy/stubtest/taskipy wiring, py.typed) follows on feature/beehave-v2. --- beehave/__init__.pyi | 10 + beehave/check.pyi | 8 + beehave/cli.pyi | 6 + beehave/generate.pyi | 9 + beehave/gherkin.pyi | 49 +++++ beehave/status.pyi | 8 + beehave/step.pyi | 18 ++ docs/glossary.md | 126 +++++++++++ tests/e2e/check_test.py | 199 ++++++++++++++++++ tests/e2e/check_test.pyi | 26 +++ tests/e2e/conftest.py | 22 ++ tests/e2e/generate_test.py | 185 ++++++++++++++++ tests/e2e/generate_test.pyi | 49 +++++ tests/e2e/status_test.py | 56 +++++ tests/e2e/status_test.pyi | 22 ++ tests/integration/conftest.py | 20 ++ tests/integration/idempotency_test.py | 87 ++++++++ tests/integration/idempotency_test.pyi | 21 ++ tests/integration/parsing_test.py | 41 ++++ tests/integration/parsing_test.pyi | 19 ++ tests/integration/roundtrip_test.py | 73 +++++++ tests/integration/roundtrip_test.pyi | 23 ++ tests/integration/step_cm_test.py | 86 ++++++++ tests/integration/step_cm_test.pyi | 23 ++ tests/integration/strategy_inference_test.py | 130 ++++++++++++ tests/integration/strategy_inference_test.pyi | 27 +++ tests/integration/title_derivation_test.py | 74 +++++++ tests/integration/title_derivation_test.pyi | 27 +++ 28 files changed, 1444 insertions(+) create mode 100644 beehave/__init__.pyi create mode 100644 beehave/check.pyi create mode 100644 beehave/cli.pyi create mode 100644 beehave/generate.pyi create mode 100644 beehave/gherkin.pyi create mode 100644 beehave/status.pyi create mode 100644 beehave/step.pyi create mode 100644 docs/glossary.md create mode 100644 tests/e2e/check_test.py create mode 100644 tests/e2e/check_test.pyi create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/generate_test.py create mode 100644 tests/e2e/generate_test.pyi create mode 100644 tests/e2e/status_test.py create mode 100644 tests/e2e/status_test.pyi create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/idempotency_test.py create mode 100644 tests/integration/idempotency_test.pyi create mode 100644 tests/integration/parsing_test.py create mode 100644 tests/integration/parsing_test.pyi create mode 100644 tests/integration/roundtrip_test.py create mode 100644 tests/integration/roundtrip_test.pyi create mode 100644 tests/integration/step_cm_test.py create mode 100644 tests/integration/step_cm_test.pyi create mode 100644 tests/integration/strategy_inference_test.py create mode 100644 tests/integration/strategy_inference_test.pyi create mode 100644 tests/integration/title_derivation_test.py create mode 100644 tests/integration/title_derivation_test.pyi diff --git a/beehave/__init__.pyi b/beehave/__init__.pyi new file mode 100644 index 0000000..28ab9b1 --- /dev/null +++ b/beehave/__init__.pyi @@ -0,0 +1,10 @@ +# Public package surface for beehave v2. +# +# - `step` is the executable `with`-block CM (re-exported; lives in +# `beehave.step`). +# - `__version__` is the single source of truth (interview L1 Constraint 3; +# the `== "2.0.0"` assertion is deferred to deliver — only the +# declaration lives here). +from beehave.step import step as step + +__version__: str diff --git a/beehave/check.pyi b/beehave/check.pyi new file mode 100644 index 0000000..9d758ea --- /dev/null +++ b/beehave/check.pyi @@ -0,0 +1,8 @@ +# Structural binding check (L2 *Validation (`check`)*): the +# `with step(...)` blocks in `test_py_text` are matched one-to-one against +# the steps parsed from `feature_text` on the triple +# (keyword-case-insensitively, text, placeholder-name-set). Returns True if +# every block matches its step; False on any count, keyword, text, or +# placeholder-name-set mismatch. Does NOT inspect the body for literals or +# placeholder AST nodes (v2 drops that layer entirely — Q5). +def check(feature_text: str, test_py_text: str) -> bool: ... diff --git a/beehave/cli.pyi b/beehave/cli.pyi new file mode 100644 index 0000000..92c4c1e --- /dev/null +++ b/beehave/cli.pyi @@ -0,0 +1,6 @@ +from collections.abc import Sequence + +# CLI entry: `beehave generate|check|status`. Returns the process exit code +# (0 success; 2 missing features dir on `status`; non-zero on `check` +# structural-binding failure). `argv` defaults to `sys.argv[1:]` when None. +def main(argv: Sequence[str] | None = None) -> int: ... diff --git a/beehave/generate.pyi b/beehave/generate.pyi new file mode 100644 index 0000000..1400f92 --- /dev/null +++ b/beehave/generate.pyi @@ -0,0 +1,9 @@ +from pathlib import Path + +# Emits `_default_test.py{i,}` plus one +# `__test.py{i,}` per Rule into `/tests/`, +# reading `/docs/features/*.feature`. Always emits `.pyi`; emits `.py` +# skeleton only when absent (idempotent — never clobbers consumer bodies). +# Background steps (Feature-level and Rule-level) are prepended to the +# relevant scenarios' `with step(...)` block lists in the emitted `.py`. +def generate(root: Path) -> None: ... diff --git a/beehave/gherkin.pyi b/beehave/gherkin.pyi new file mode 100644 index 0000000..fc90bae --- /dev/null +++ b/beehave/gherkin.pyi @@ -0,0 +1,49 @@ +# The parser + its in-memory parse model (collapsed here per Q2-resolution: +# no separate `models` shared kernel — the shapes are returned by exactly +# one public entry point, `parse_feature`, and consumed by intra-package +# collaborators in the same bounded context; tests never import them as a +# `beehave.models` module). Field shapes are binding per data-model.md §2. + +class Placeholder: + name: str + +class DataTable: + headers: list[str] | None + rows: list[list[str]] + +class Step: + keyword: str + text: str + placeholders: list[Placeholder] + docstring: str | None + data_table: DataTable | None + +class Examples: + headers: list[str] + rows: list[dict[str, str]] + +class Background: + steps: list[Step] + +class Scenario: + title: str + slug: str + function_name: str + tags: list[str] + keyword: str + steps: list[Step] + examples: Examples | None + +class Rule: + name: str + tags: list[str] + background: Background | None + children: list[Scenario] + +class Feature: + name: str + tags: list[str] + background: Background | None + children: list[Rule | Scenario] + +def parse_feature(source: str) -> Feature: ... diff --git a/beehave/status.pyi b/beehave/status.pyi new file mode 100644 index 0000000..73eed86 --- /dev/null +++ b/beehave/status.pyi @@ -0,0 +1,8 @@ +from pathlib import Path + +# Minimal `status` (journal Q3): prints `.feature` count under +# `/docs/features/` and `*_test.pyi` count under `/tests/` to +# stdout; returns 0 when the features directory exists, 2 when it is +# missing (filesystem error). v1's rich stage taxonomy / `--json` / tree +# output / unmapped-directory reporting are all dropped. +def status(root: Path) -> int: ... diff --git a/beehave/step.pyi b/beehave/step.pyi new file mode 100644 index 0000000..cf3d66f --- /dev/null +++ b/beehave/step.pyi @@ -0,0 +1,18 @@ +from collections.abc import Iterator +from contextlib import contextmanager + +# The v2 runtime core: `step(keyword, text, /, **placeholders)` is the +# executable `with` block that replaces v1's step-definition registry. +# `keyword` is data (covers all Gherkin keywords incl. localized); `assert` +# inside a `Then` block propagates; on exception the CM appends +# `f"{keyword} {text}"` via `add_note` so `__notes__ == [" "]`; +# no note on clean exit. `keyword`/`text` are positional-only; placeholder +# values are consumer-supplied scalars (strategy-inferred int/float/bool/str +# in emitted tests), so the kwarg type is `object`. +@contextmanager +def step( + keyword: str, + text: str, + /, + **placeholders: object, +) -> Iterator[None]: ... diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..217656a --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,126 @@ +# Glossary: beehave + +> The ubiquitous language for this project — terms shared across conversation, +> code, and documentation (Evans, 2003). Curated from the interview for the +> IMPORTANT domain concepts, not every code symbol. Grouped by bounded context, +> where each term has one meaning. The tests are the source of truth for +> behaviour; this glossary is the source of truth for names. Extend or revise +> entries as understanding shifts. + +## Context: Design-time (the CLI tool) + +### `.feature` +A source artifact that holds Gherkin-syntax feature definitions and is the single source of truth for generation. +*Aliases: feature file · Source: interview 2026-07-18* + +### Feature +A Gherkin structural element that is the top-level container in a `.feature` file. +*Aliases: none · Source: interview 2026-07-18* + +### Rule +A Gherkin structural element that groups scenarios within a Feature and is the unit of per-rule test-file generation. +*Aliases: none · Source: interview 2026-07-18* + +### Background +A Gherkin structural element whose steps are merged into every scenario's step list and may not contain placeholders. +*Aliases: none · Source: interview 2026-07-18* + +### Scenario +A Gherkin structural element that maps one-to-one to a generated `test_` function whose name is its identity. +*Aliases: none · Source: interview 2026-07-18* + +### Examples +A tabular value source whose columns drive Hypothesis `@given` strategy inference and whose rows drive `@example`. +*Aliases: Examples table · Source: interview 2026-07-18* + +### step +A Gherkin structural element that is the unit matched one-to-one (`block[i]`↔`step[i]`) against `with step(...)` blocks in the test body. +*Aliases: Gherkin step · Source: interview 2026-07-18* + +### placeholder +A parameterisation token of the form `` that binds Examples-table columns to step-text positions and whose name-set is one of the three structural-binding fields. +*Aliases: none · Source: interview 2026-07-18* + +### title +An identifier that derives the `test_` function name and is constrained by uniqueness (case-insensitive), a 2–6 word-count bound, and a Unicode-letters/digits/spaces charset. +*Aliases: none · Source: interview 2026-07-18* + +### slug +A normalised identifier that is the lowercased, whitespace-collapsed form of a title and becomes the suffix of the `test_` function name. +*Aliases: none · Source: interview 2026-07-18* + +### Full Gherkin +A grammar-coverage claim meaning v2 parses everything `gherkin-official` emits, including `@tags`, docstrings, and data-tables. +*Aliases: none · Source: interview 2026-07-18* + +### noise loophole +A spec-value-fidelity failure in which a placeholder or literal "appears" in a test body as an AST node while testing nothing about the step's actual behaviour — the v1 incident that motivates v2. +*Aliases: none · Source: interview 2026-07-18 (CIT)* + +### `generate` +A CLI command that emits the `.pyi` typed-stub contract always and the `*_test.py` skeleton only if the `.py` is absent (idempotent — never clobbers existing bodies). +*Aliases: none · Source: interview 2026-07-18* + +### `check` +A CLI command that validates the `with step(...)` structural binding against the `.feature` and enforces title rules. +*Aliases: none · Source: interview 2026-07-18* + +### `status` +A CLI command that reports generation/check progress (detailed behaviour deferred to plan). +*Aliases: none · Source: interview 2026-07-18* + +### parse model +The in-memory typed shapes (`Feature` / `Rule` / `Scenario` / `Step` / `Placeholder` / `Examples` / `Background` / `DataTable`) carried by `beehave/models.py` as the shared kernel between `gherkin.py`, `generate.py`, and `check.py`. Distinguished from *persistence model* — v2 has none (the parse model is the binding data contract). +*Aliases: none · Source: data-model 2026-07-18* + +### shared kernel +A bounded-context pattern (Evans, 2003) applied to `beehave/models.py`: a small explicitly-shared vocabulary consumed by `gherkin.py`, `generate.py`, and `check.py`. The seam is justified by the three-consumer access pattern; whether it stays its own module or collapses into `gherkin.py` is an internal source-structure choice, not a data-model concern. +*Aliases: none · Source: interview 2026-07-18 (L3) + data-model 2026-07-18* + +### structural binding +The `block[i]`↔`step[i]` match on exactly `(keyword, text, placeholder-name-set)` — with `keyword` compared case-insensitively — that `beehave check` enforces by walking the test body's `with step(...)` blocks in source order. Body fidelity is deferred to the review gate; this is the structural-only check that replaces v1's appearance enforcement (the noise loophole). +*Aliases: none · Source: interview 2026-07-18 (L1 Success) + plan design decision 2026-07-18 (keyword case)* + +### default group +The emission group for scenarios NOT under a Rule; emitted to `_default_test.py{i,}` alongside one `__test.py{i,}` per Rule. +*Aliases: none · Source: plan design decision 2026-07-18* + +### skeleton +The `*_test.py` body emitted by `beehave generate` ONLY IF the `.py` is absent; a scaffold of `with step(...)` blocks the consumer fills. Re-running `generate` never clobbers an existing skeleton (idempotent) — only the `.pyi` is rewritten. +*Aliases: test skeleton · Source: interview 2026-07-18 (Constraint 1)* + +## Context: Runtime (beehave-the-import) + +### `step` +A context manager imported `from beehave import step` that wraps a step's executable test code as `with step(keyword, text, **placeholders)` and attributes failures to its step via `add_note`. +*Aliases: step context manager · Source: interview 2026-07-18* + +### keyword +A positional argument to the `step` context manager that is a STRING (data, not a method name) so it covers all Gherkin step keywords including localized variants without reserved-word clashes. +*Aliases: none · Source: interview 2026-07-18* + +### `Then`-asserts +A runtime contract that the `Then` step block is where the outcome assertion executes — the step block RUNS code, it does not merely declare it. +*Aliases: none · Source: interview 2026-07-18* + +### `add_note` +A pytest mechanism that the `step` context manager uses to attribute a failure to its specific step by name. +*Aliases: none · Source: interview 2026-07-18* + +## Context: Typing contract + +### `.pyi` +A type-stub file that is the typed contract surface `generate` always emits and that consumer type-checkers read in preference to the `.py`. +*Aliases: stub · Source: interview 2026-07-18* + +### `py.typed` +A PEP 561 package-marker file (empty) that signals to type-checkers that the package ships typed stubs, on which the consumer-side mypy gate depends. +*Aliases: none · Source: interview 2026-07-18* + +### drift +A contract violation in which a generated `*_test.py` body diverges from its `.pyi`. +*Aliases: stub drift · Source: interview 2026-07-18* + +### `mypy.stubtest` +A tool that is the SOLE `.py`↔`.pyi` drift detector in v2's gate (pyright/mypy read only the `.pyi`). +*Aliases: stubtest · Source: interview 2026-07-18* diff --git a/tests/e2e/check_test.py b/tests/e2e/check_test.py new file mode 100644 index 0000000..97e6514 --- /dev/null +++ b/tests/e2e/check_test.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +HIVE_ACTIVITY_FEATURE = "hive_activity.feature" +COMB_CONSTRUCTION_FEATURE = "comb_construction.feature" + + +def copy_feature_into_pytester(pytester, basename: str) -> str: + src = Path("docs") / "features" / basename + dst = pytester.path / "docs" / "features" / basename + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(src, dst) + return str(dst) + + +def write_feature_text(pytester, basename: str, text: str) -> str: + dst = pytester.path / "docs" / "features" / basename + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(text) + return str(dst) + + +def write_test_py(pytester, stem: str, body: str) -> str: + dst = pytester.path / "tests" / f"{stem}_test.py" + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(body) + return str(dst) + + +def run_beehave_check(pytester, *args: str) -> int: + return pytester.run("beehave", "check", *args).ret + + +@pytest.mark.pending +def test_passes_when_blocks_match_steps(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + pytester.run("beehave", "generate") + assert run_beehave_check(pytester) == 0 + + +@pytest.mark.pending +def test_fails_when_block_count_differs_from_step_count(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario: minimal scenario\n" + "Given first step\n" + "When second step\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + short_body = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario():\n" + ' with step("Given", "first step"):\n' + " pass\n" + ) + write_test_py(pytester, "minimal_default", short_body) + assert run_beehave_check(pytester) != 0 + + +@pytest.mark.pending +def test_fails_when_step_keyword_structurally_mismatches(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario: minimal scenario\n" + "Given first step\n" + "When second step\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + mismatched_body = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario():\n" + ' with step("Given", "first step"):\n' + " pass\n" + ' with step("Given", "second step"):\n' + " pass\n" + ) + write_test_py(pytester, "minimal_default", mismatched_body) + assert run_beehave_check(pytester) != 0 + + +@pytest.mark.pending +def test_fails_when_step_text_mismatches(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario: minimal scenario\n" + "Given first step\n" + "When second step\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + mismatched_body = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario():\n" + ' with step("Given", "first step"):\n' + " pass\n" + ' with step("When", "different text"):\n' + " pass\n" + ) + write_test_py(pytester, "minimal_default", mismatched_body) + assert run_beehave_check(pytester) != 0 + + +@pytest.mark.pending +def test_fails_when_placeholder_name_set_mismatches(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario Outline: minimal scenario\n" + "Given a value of \n" + "When anything\n" + "\n" + "Examples:\n" + " | amount |\n" + " | 1 |\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + mismatched_body = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario(amount):\n" + ' with step("Given", "a value of ", renamed=1):\n' + " pass\n" + ' with step("When", "anything"):\n' + " pass\n" + ) + write_test_py(pytester, "minimal_default", mismatched_body) + assert run_beehave_check(pytester) != 0 + + +@pytest.mark.pending +def test_passes_when_keyword_case_differs(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario: minimal scenario\n" + "Given first step\n" + "When second step\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + case_body = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario():\n" + ' with step("given", "first step"):\n' + " pass\n" + ' with step("when", "second step"):\n' + " pass\n" + ) + write_test_py(pytester, "minimal_default", case_body) + assert run_beehave_check(pytester) == 0 + + +@pytest.mark.pending +def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario: minimal scenario\n" + "Given first step\n" + "When second step\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + body_with_content = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario():\n" + ' with step("Given", "first step"):\n' + " x = 1 + 1\n" + " y = x * 2\n" + ' with step("When", "second step"):\n' + " z = y + 1\n" + ) + write_test_py(pytester, "minimal_default", body_with_content) + assert run_beehave_check(pytester) == 0 + + +@pytest.mark.pending +def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: + feature_text = ( + "Feature: Minimal\n" + "Scenario: minimal scenario\n" + "Given first step\n" + "When second step\n" + ) + write_feature_text(pytester, "minimal.feature", feature_text) + body_without_literals = ( + "from beehave import step\n" + "\n" + "def test_minimal_scenario():\n" + ' with step("Given", "first step"):\n' + " pass\n" + ' with step("When", "second step"):\n' + " pass\n" + ) + write_test_py(pytester, "minimal_default", body_without_literals) + assert run_beehave_check(pytester) == 0 diff --git a/tests/e2e/check_test.pyi b/tests/e2e/check_test.pyi new file mode 100644 index 0000000..b6f1b01 --- /dev/null +++ b/tests/e2e/check_test.pyi @@ -0,0 +1,26 @@ +# E2E contract for the `beehave check` CLI command. +# +# Drives the CLI through the pytester subprocess. Asserts the structural-binding +# gate (the v2 CIT-rooted contract): the test body's `with step(...)` blocks in +# source order match the parsed `.feature` steps `block[i] <-> step[i]` on +# exactly three fields - `(keyword, text, placeholder-name-set)` - and NOTHING +# else. Body content is deferred to the review gate. Keyword matching is +# case-insensitive. Literal/placeholder AST appearance is NOT inspected +# (v1 noise loophole closed). + +# Fixture feature basenames available under docs/features/. +HIVE_ACTIVITY_FEATURE: str +COMB_CONSTRUCTION_FEATURE: str + +def copy_feature_into_pytester(pytester, basename: str) -> str: ... +def write_feature_text(pytester, basename: str, text: str) -> str: ... +def write_test_py(pytester, stem: str, body: str) -> str: ... +def run_beehave_check(pytester, *args: str) -> int: ... +def test_passes_when_blocks_match_steps(pytester) -> None: ... +def test_fails_when_block_count_differs_from_step_count(pytester) -> None: ... +def test_fails_when_step_keyword_structurally_mismatches(pytester) -> None: ... +def test_fails_when_step_text_mismatches(pytester) -> None: ... +def test_fails_when_placeholder_name_set_mismatches(pytester) -> None: ... +def test_passes_when_keyword_case_differs(pytester) -> None: ... +def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: ... +def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: ... diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..f05be32 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import pytest + +pytest_plugins = ["pytester"] + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line( + "markers", + "pending: v2 source not yet built; skipped until the build subflow removes the marker", + ) + + +def pytest_collection_modifyitems( + config: pytest.Config, + items: list[pytest.Item], +) -> None: + skip = pytest.mark.skip(reason="pending v2 source") + for item in items: + if "pending" in item.keywords: + item.add_marker(skip) diff --git a/tests/e2e/generate_test.py b/tests/e2e/generate_test.py new file mode 100644 index 0000000..e86075b --- /dev/null +++ b/tests/e2e/generate_test.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +HIVE_ACTIVITY_FEATURE = "hive_activity.feature" +COMB_CONSTRUCTION_FEATURE = "comb_construction.feature" +TITLE_VALIDATION_FEATURE = "title_validation.feature" +STATUS_COMMAND_FEATURE = "status_command.feature" +CASE_INSENSITIVE_MATCHING_FEATURE = "case_insensitive_matching.feature" + +DEFAULT_GROUP_SUFFIX = "default" +EMISSION_DIR = "tests" + + +def copy_feature_into_pytester(pytester, basename: str) -> str: + src = Path("docs") / "features" / basename + dst = pytester.path / "docs" / "features" / basename + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(src, dst) + return str(dst) + + +def write_feature_text(pytester, basename: str, text: str) -> str: + dst = pytester.path / "docs" / "features" / basename + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(text) + return str(dst) + + +def run_beehave_generate(pytester, *args: str) -> int: + return pytester.run("beehave", "generate", *args).ret + + +def read_emitted_pyi(pytester, stem: str) -> str: + path = pytester.path / EMISSION_DIR / f"{stem}_test.pyi" + if not path.exists(): + return "" + return path.read_text() + + +def read_emitted_py(pytester, stem: str) -> str: + path = pytester.path / EMISSION_DIR / f"{stem}_test.py" + if not path.exists(): + return "" + return path.read_text() + + +def list_emitted_stems(pytester) -> list[str]: + emission = pytester.path / EMISSION_DIR + if not emission.exists(): + return [] + stems: list[str] = [] + for path in emission.glob("*_test.pyi"): + name = path.name + if name.endswith("_test.pyi"): + stems.append(name[: -len("_test.pyi")]) + return sorted(stems) + + +@pytest.mark.pending +def test_emits_pyi_for_default_group_and_each_rule(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + run_beehave_generate(pytester) + stems = list_emitted_stems(pytester) + assert "hive_activity_default" in stems + assert "hive_activity_hive_defense" in stems + assert "hive_activity_hive_foraging" in stems + + +@pytest.mark.pending +def test_always_emits_pyi_file_for_every_rule_and_default(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + exit_code = run_beehave_generate(pytester) + assert exit_code == 0 + stems = list_emitted_stems(pytester) + assert len(stems) >= 3 + for stem in stems: + assert read_emitted_pyi(pytester, stem) != "" + + +@pytest.mark.pending +def test_emits_py_skeleton_only_when_py_absent(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + run_beehave_generate(pytester) + first_emission = read_emitted_py(pytester, "hive_activity_default") + pytester.run("beehave", "generate") + second_emission = read_emitted_py(pytester, "hive_activity_default") + assert first_emission == second_emission + + +@pytest.mark.pending +def test_scenario_title_emits_test_underscore_slug_function(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + run_beehave_generate(pytester) + pyi = read_emitted_pyi(pytester, "hive_activity_hive_defense") + assert "def test_guard_bee_inspects_visitor" in pyi + + +@pytest.mark.pending +def test_function_name_carries_no_uppercase_and_collapses_whitespace(pytester) -> None: + feature_text = ( + "Feature: Whitespace\n" + "Scenario: MixedCase Title With Spaces\n" + "Given anything\n" + ) + write_feature_text(pytester, "whitespace.feature", feature_text) + run_beehave_generate(pytester) + pyi = read_emitted_pyi(pytester, "whitespace_default") + assert "def test_mixedcase_title_with_spaces" in pyi + + +@pytest.mark.pending +def test_feature_background_steps_appear_in_every_emitted_scenario(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + run_beehave_generate(pytester) + default_py = read_emitted_py(pytester, "hive_activity_default") + defense_py = read_emitted_py(pytester, "hive_activity_hive_defense") + foraging_py = read_emitted_py(pytester, "hive_activity_hive_foraging") + background_text = "the hive is active" + assert background_text in default_py + assert background_text in defense_py + assert background_text in foraging_py + + +@pytest.mark.pending +def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + run_beehave_generate(pytester) + defense_py = read_emitted_py(pytester, "hive_activity_hive_defense") + foraging_py = read_emitted_py(pytester, "hive_activity_hive_foraging") + default_py = read_emitted_py(pytester, "hive_activity_default") + rule_background_text = "entrance has 2 guards" + assert rule_background_text in defense_py + assert rule_background_text not in foraging_py + assert rule_background_text not in default_py + + +@pytest.mark.pending +def test_tags_do_not_surface_in_emitted_pyi_or_step_blocks(pytester) -> None: + feature_text = ( + "@unique_tag_marker\n" + "Feature: Tagged\n" + "Scenario: tagged scenario\n" + "Given anything\n" + ) + write_feature_text(pytester, "tagged.feature", feature_text) + run_beehave_generate(pytester) + pyi = read_emitted_pyi(pytester, "tagged_default") + py_text = read_emitted_py(pytester, "tagged_default") + assert "unique_tag_marker" not in pyi + assert "unique_tag_marker" not in py_text + + +@pytest.mark.pending +def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: + feature_text = ( + "Feature: Docstring\n" + "Scenario: scenario with docstring\n" + "Given anything\n" + '"""\n' + "unique docstring marker text\n" + '"""\n' + ) + write_feature_text(pytester, "docstring.feature", feature_text) + run_beehave_generate(pytester) + pyi = read_emitted_pyi(pytester, "docstring_default") + assert "unique docstring marker text" not in pyi + + +@pytest.mark.pending +def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: + feature_text = ( + "Feature: DataTable\n" + "Scenario: scenario with data table\n" + "Given anything\n" + " | unique_col | value |\n" + " | marker | 1 |\n" + ) + write_feature_text(pytester, "datatable.feature", feature_text) + run_beehave_generate(pytester) + pyi = read_emitted_pyi(pytester, "datatable_default") + assert "unique_col" not in pyi diff --git a/tests/e2e/generate_test.pyi b/tests/e2e/generate_test.pyi new file mode 100644 index 0000000..5052946 --- /dev/null +++ b/tests/e2e/generate_test.pyi @@ -0,0 +1,49 @@ +# E2E contract for the `beehave generate` CLI command. +# +# Drives the CLI through the pytester subprocess against `docs/features/*.feature` +# and asserts the observable emission surface: +# - `_default_test.pyi` plus one `__test.pyi` per Rule; +# - the `.pyi` is always emitted (Constraint 1); the `.py` skeleton is emitted +# only when absent (idempotency lives at the integration layer); +# - Scenario title -> `test_` function name (slug = lowercased title, +# whitespace runs collapsed to a single underscore); +# - Feature and Rule Background steps are prepended to every relevant +# scenario's `with step(...)` block list in the emitted `.py`; +# - tags, step-docstrings, and step-data-tables are parsed (Full Gherkin) but +# do NOT surface in the emitted `.pyi` signature or `with step(...)` block +# shape (minimal-surface preference). + +# Fixture feature basenames available under docs/features/ (the v2 E2E inputs). +HIVE_ACTIVITY_FEATURE: str +COMB_CONSTRUCTION_FEATURE: str +TITLE_VALIDATION_FEATURE: str +STATUS_COMMAND_FEATURE: str +CASE_INSENSITIVE_MATCHING_FEATURE: str + +# Default-group suffix in the per-rule emission convention +# `__test.py{i,}`. +DEFAULT_GROUP_SUFFIX: str + +# The directory `generate` emits `*_test.py{i,}` into (pyproject testpaths). +EMISSION_DIR: str + +def copy_feature_into_pytester(pytester, basename: str) -> str: ... +def write_feature_text(pytester, basename: str, text: str) -> str: ... +def run_beehave_generate(pytester, *args: str) -> int: ... +def read_emitted_pyi(pytester, stem: str) -> str: ... +def read_emitted_py(pytester, stem: str) -> str: ... +def list_emitted_stems(pytester) -> list[str]: ... +def test_emits_pyi_for_default_group_and_each_rule(pytester) -> None: ... +def test_always_emits_pyi_file_for_every_rule_and_default(pytester) -> None: ... +def test_emits_py_skeleton_only_when_py_absent(pytester) -> None: ... +def test_scenario_title_emits_test_underscore_slug_function(pytester) -> None: ... +def test_function_name_carries_no_uppercase_and_collapses_whitespace( + pytester, +) -> None: ... +def test_feature_background_steps_appear_in_every_emitted_scenario( + pytester, +) -> None: ... +def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> None: ... +def test_tags_do_not_surface_in_emitted_pyi_or_step_blocks(pytester) -> None: ... +def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: ... +def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: ... diff --git a/tests/e2e/status_test.py b/tests/e2e/status_test.py new file mode 100644 index 0000000..d9b1cd9 --- /dev/null +++ b/tests/e2e/status_test.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import pytest + + +def write_feature_text(pytester, basename: str, text: str) -> str: + dst = pytester.path / "docs" / "features" / basename + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(text) + return str(dst) + + +def write_pyi_stub(pytester, stem: str) -> str: + dst = pytester.path / "tests" / f"{stem}_test.pyi" + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text("") + return str(dst) + + +def run_beehave_status(pytester, *args: str) -> int: + return pytester.run("beehave", "status", *args).ret + + +def status_stdout(pytester) -> str: + result = pytester.run("beehave", "status") + return "\n".join(result.outlines) + + +@pytest.mark.pending +def test_status_exits_zero_when_features_dir_exists(pytester) -> None: + write_feature_text(pytester, "a.feature", "Feature: A\nScenario: aaaaa\nGiven x\n") + assert run_beehave_status(pytester) == 0 + + +@pytest.mark.pending +def test_status_reports_feature_file_count(pytester) -> None: + write_feature_text(pytester, "a.feature", "Feature: A\nScenario: aaaaa\nGiven x\n") + write_feature_text(pytester, "b.feature", "Feature: B\nScenario: bbbbb\nGiven x\n") + stdout = status_stdout(pytester) + assert "2" in stdout + assert "feature" in stdout.lower() + + +@pytest.mark.pending +def test_status_reports_emitted_stub_count(pytester) -> None: + write_feature_text(pytester, "a.feature", "Feature: A\nScenario: aaaaa\nGiven x\n") + write_pyi_stub(pytester, "a_default") + write_pyi_stub(pytester, "a_other") + stdout = status_stdout(pytester) + assert "2" in stdout + assert "stub" in stdout.lower() + + +@pytest.mark.pending +def test_status_exits_two_when_features_dir_missing(pytester) -> None: + assert run_beehave_status(pytester) == 2 diff --git a/tests/e2e/status_test.pyi b/tests/e2e/status_test.pyi new file mode 100644 index 0000000..4293bbe --- /dev/null +++ b/tests/e2e/status_test.pyi @@ -0,0 +1,22 @@ +# E2E contract for the `beehave status` CLI command. +# +# The interview named `status` as one of three CLI commands but flagged its +# detailed behaviour as SOFT (no CIT incident, no laddered constraint grounded +# what it reports). The minimal falsifiable claim - asserted here - is that +# `status` derives a small, deterministic report from disk state without +# stored mutable state of its own: +# - exit 0 when the features directory exists; +# - stdout includes the count of `.feature` files discovered; +# - stdout includes the count of emitted `*_test.pyi` stubs; +# - exit 2 when the features directory is missing (filesystem error). +# The rich v1 stage taxonomy (broken / needs-tests / needs-bodies / needs-fixes +# / ok) is DROPPED - ungrounded by v2 CIT/laddering, and not asserted here. + +def write_feature_text(pytester, basename: str, text: str) -> str: ... +def write_pyi_stub(pytester, stem: str) -> str: ... +def run_beehave_status(pytester, *args: str) -> int: ... +def status_stdout(pytester) -> str: ... +def test_status_exits_zero_when_features_dir_exists(pytester) -> None: ... +def test_status_reports_feature_file_count(pytester) -> None: ... +def test_status_reports_emitted_stub_count(pytester) -> None: ... +def test_status_exits_two_when_features_dir_missing(pytester) -> None: ... diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..245aacd --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import pytest + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line( + "markers", + "pending: v2 source not yet built; skipped until the build subflow removes the marker", + ) + + +def pytest_collection_modifyitems( + config: pytest.Config, + items: list[pytest.Item], +) -> None: + skip = pytest.mark.skip(reason="pending v2 source") + for item in items: + if "pending" in item.keywords: + item.add_marker(skip) diff --git a/tests/integration/idempotency_test.py b/tests/integration/idempotency_test.py new file mode 100644 index 0000000..9890372 --- /dev/null +++ b/tests/integration/idempotency_test.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +BASE_FEATURE = """\ +Feature: Input +Scenario: first scenario +Given anything +""" + +EXTENDED_FEATURE = """\ +Feature: Input +Scenario: first scenario +Given anything + +Scenario: second scenario +Given anything +""" + + +def emit_test_py_for(feature_text: str) -> str: + from beehave.generate import generate + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + features = root / "docs" / "features" + features.mkdir(parents=True) + (features / "input.feature").write_text(feature_text) + generate(root) + return (root / "tests" / "input_default_test.py").read_text() + + +def emit_test_pyi_for(feature_text: str) -> str: + from beehave.generate import generate + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + features = root / "docs" / "features" + features.mkdir(parents=True) + (features / "input.feature").write_text(feature_text) + generate(root) + return (root / "tests" / "input_default_test.pyi").read_text() + + +def regenerate_over_body(feature_text: str, existing_py_body: str) -> str: + from beehave.generate import generate + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + features = root / "docs" / "features" + features.mkdir(parents=True) + (features / "input.feature").write_text(feature_text) + tests_dir = root / "tests" + tests_dir.mkdir(parents=True) + py_path = tests_dir / "input_default_test.py" + py_path.write_text(existing_py_body) + generate(root) + return py_path.read_text() + + +@pytest.mark.pending +def test_regenerate_preserves_existing_consumer_py_body() -> None: + consumer_marker = "# consumer-authored marker line" + regenerated = regenerate_over_body(BASE_FEATURE, consumer_marker) + assert consumer_marker in regenerated + + +@pytest.mark.pending +def test_regenerate_does_not_emit_py_when_py_present() -> None: + consumer_body = ( + "from beehave import step\n" + "\n" + "def test_first_scenario():\n" + ' with step("Given", "anything"):\n' + " pass\n" + ) + regenerated = regenerate_over_body(BASE_FEATURE, consumer_body) + assert regenerated == consumer_body + + +@pytest.mark.pending +def test_regenerate_rewrites_pyi_when_feature_gains_scenario() -> None: + pyi = emit_test_pyi_for(EXTENDED_FEATURE) + assert "test_second_scenario" in pyi diff --git a/tests/integration/idempotency_test.pyi b/tests/integration/idempotency_test.pyi new file mode 100644 index 0000000..4cb322b --- /dev/null +++ b/tests/integration/idempotency_test.pyi @@ -0,0 +1,21 @@ +# Integration contract for `generate` idempotency at the generation unit. +# +# `generate` re-run on a feature whose `*_test.py` already carries consumer +# bodies preserves those bodies - only the `.pyi` is rewritten (interview L2 +# Modifiability / idempotency). This is the cycle's defining property and +# the protection for the consumer-authored seam. +# +# These tests drive `generate` in-process; the SUT imports live in each body +# (deferred), so the `.pyi` does not import beehave. + +# A minimal feature used as the idempotency input. +BASE_FEATURE: str +# The same feature with one additional scenario (drives `.pyi` re-emission). +EXTENDED_FEATURE: str + +def emit_test_py_for(feature_text: str) -> str: ... +def emit_test_pyi_for(feature_text: str) -> str: ... +def regenerate_over_body(feature_text: str, existing_py_body: str) -> str: ... +def test_regenerate_preserves_existing_consumer_py_body() -> None: ... +def test_regenerate_does_not_emit_py_when_py_present() -> None: ... +def test_regenerate_rewrites_pyi_when_feature_gains_scenario() -> None: ... diff --git a/tests/integration/parsing_test.py b/tests/integration/parsing_test.py new file mode 100644 index 0000000..31881ff --- /dev/null +++ b/tests/integration/parsing_test.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import pytest + +BACKGROUND_WITH_PLACEHOLDER_FEATURE = """\ +Feature: Parsing +Background: + Given a background step with token + +Scenario: scenario +Given anything +""" + +BACKGROUND_CLEAN_FEATURE = """\ +Feature: Parsing +Background: + Given a background step without placeholders + +Scenario: scenario +Given anything +""" + + +def parse_feature_raises(feature_text: str) -> bool: + from beehave.gherkin import parse_feature + + try: + parse_feature(feature_text) + except Exception: + return True + return False + + +@pytest.mark.pending +def test_background_step_with_placeholder_is_parse_error() -> None: + assert parse_feature_raises(BACKGROUND_WITH_PLACEHOLDER_FEATURE) + + +@pytest.mark.pending +def test_background_step_without_placeholder_parses_cleanly() -> None: + assert not parse_feature_raises(BACKGROUND_CLEAN_FEATURE) diff --git a/tests/integration/parsing_test.pyi b/tests/integration/parsing_test.pyi new file mode 100644 index 0000000..71f1a7b --- /dev/null +++ b/tests/integration/parsing_test.pyi @@ -0,0 +1,19 @@ +# Integration contract for `beehave.gherkin` parser-level invariants. +# +# Closes the traceability gap on interview L1: "no placeholders are allowed in +# Background steps" (data-model §2.7 - binding parse-time invariant). A +# `` token inside a Background step is a parse error, not a silent +# acceptance - this is the only parser-level rejection the interview names +# beyond the title rules (which live in `title_derivation_test.pyi`). +# +# These tests drive `beehave.gherkin.parse_feature` in-process; the SUT import +# lives in each body (deferred), so the `.pyi` does not import beehave. + +# A minimal feature whose Background step carries a `` token. +BACKGROUND_WITH_PLACEHOLDER_FEATURE: str +# A minimal feature whose Background step is placeholder-free (the control). +BACKGROUND_CLEAN_FEATURE: str + +def parse_feature_raises(feature_text: str) -> bool: ... +def test_background_step_with_placeholder_is_parse_error() -> None: ... +def test_background_step_without_placeholder_parses_cleanly() -> None: ... diff --git a/tests/integration/roundtrip_test.py b/tests/integration/roundtrip_test.py new file mode 100644 index 0000000..5b5d41c --- /dev/null +++ b/tests/integration/roundtrip_test.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +ROUND_TRIP_FEATURE = """\ +Feature: Round Trip +Scenario: round trip +Given first step +When second step +Then third step +""" + + +def emit_test_py_for(feature_text: str) -> str: + from beehave.generate import generate + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + features = root / "docs" / "features" + features.mkdir(parents=True) + (features / "input.feature").write_text(feature_text) + generate(root) + return (root / "tests" / "input_default_test.py").read_text() + + +def check_passes_for(feature_text: str, test_py_text: str) -> bool: + from beehave.check import check + + return check(feature_text, test_py_text) + + +@pytest.mark.pending +def test_check_passes_on_freshly_generated_py() -> None: + py_text = emit_test_py_for(ROUND_TRIP_FEATURE) + assert check_passes_for(ROUND_TRIP_FEATURE, py_text) + + +@pytest.mark.pending +def test_check_fails_after_consumer_edits_step_text() -> None: + py_text = emit_test_py_for(ROUND_TRIP_FEATURE) + edited = py_text.replace("first step", "edited step text") + assert not check_passes_for(ROUND_TRIP_FEATURE, edited) + + +@pytest.mark.pending +def test_check_fails_after_consumer_removes_step_block() -> None: + shorter_body = ( + "from beehave import step\n" + "\n" + "def test_round_trip():\n" + ' with step("Given", "first step"):\n' + " pass\n" + ) + assert not check_passes_for(ROUND_TRIP_FEATURE, shorter_body) + + +@pytest.mark.pending +def test_check_passes_after_consumer_adds_body_content() -> None: + body_with_extra = ( + "from beehave import step\n" + "\n" + "def test_round_trip():\n" + ' with step("Given", "first step"):\n' + " x = 1 + 1\n" + ' with step("When", "second step"):\n' + " pass\n" + ' with step("Then", "third step"):\n' + " pass\n" + ) + assert check_passes_for(ROUND_TRIP_FEATURE, body_with_extra) diff --git a/tests/integration/roundtrip_test.pyi b/tests/integration/roundtrip_test.pyi new file mode 100644 index 0000000..eb5e6eb --- /dev/null +++ b/tests/integration/roundtrip_test.pyi @@ -0,0 +1,23 @@ +# Integration contract for the generate -> check round-trip. +# +# `generate` emits a `.pyi` (always) and a `.py` skeleton (when absent); +# `check` walks the emitted `.py` body's `with step(...)` blocks against the +# parsed `.feature` and enforces structural binding. The round-trip is the +# load-bearing claim that generator and checker agree on the contract - +# a freshly generated body must pass `check`, and only consumer edits that +# change the structural binding fields (keyword/text/placeholder-name-set) +# may fail it. +# +# These tests drive the generate+check module path in-process (no subprocess); +# the SUT imports live in each body (deferred), so the `.pyi` does not +# import beehave. + +# A minimal feature used as the round-trip input. +ROUND_TRIP_FEATURE: str + +def emit_test_py_for(feature_text: str) -> str: ... +def check_passes_for(feature_text: str, test_py_text: str) -> bool: ... +def test_check_passes_on_freshly_generated_py() -> None: ... +def test_check_fails_after_consumer_edits_step_text() -> None: ... +def test_check_fails_after_consumer_removes_step_block() -> None: ... +def test_check_passes_after_consumer_adds_body_content() -> None: ... diff --git a/tests/integration/step_cm_test.py b/tests/integration/step_cm_test.py new file mode 100644 index 0000000..a89b918 --- /dev/null +++ b/tests/integration/step_cm_test.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import pytest + +NOTE_FORMAT = "{keyword} {text}" + + +@pytest.mark.pending +def test_block_body_executes_when_entered() -> None: + from beehave import step + + side_effect: list[str] = [] + with step("Given", "the hive is active"): + side_effect.append("entered") + assert side_effect == ["entered"] + + +@pytest.mark.pending +def test_assertion_inside_then_step_propagates_failure() -> None: + from beehave import step + + with pytest.raises(AssertionError), step("Then", "the hive has honey"): + raise AssertionError + + +@pytest.mark.pending +def test_assertion_inside_then_step_passes_when_truthy() -> None: + from beehave import step + + with step("Then", "the hive has honey"): + assert True + + +@pytest.mark.pending +def test_exception_attributed_to_step_via_add_note() -> None: + from beehave import step + + keyword = "Then" + text = "the hive has honey" + with pytest.raises(AssertionError) as exc_info, step(keyword, text): + raise AssertionError + assert exc_info.value.__notes__ == [ + NOTE_FORMAT.format(keyword=keyword, text=text), + ] + + +@pytest.mark.pending +def test_clean_exit_does_not_add_attribution_note() -> None: + from beehave import step + + with step("Given", "the hive is active"): + pass + + +@pytest.mark.pending +def test_keyword_and_text_are_positional_only() -> None: + from beehave import step + + with pytest.raises(TypeError): + step(keyword="Then", text="the hive has honey") + + +@pytest.mark.pending +def test_placeholders_accepted_as_keyword_arguments() -> None: + from beehave import step + + with step("Given", "the hive has grams", nectar=100): + pass + + +@pytest.mark.pending +def test_all_gherkin_step_keywords_accepted() -> None: + from beehave import step + + keywords = ["Given", "When", "Then", "And", "But", "*"] + for keyword in keywords: + with step(keyword, "any step text"): + pass + + +@pytest.mark.pending +def test_localized_keyword_accepted() -> None: + from beehave import step + + with step("Допустим", "улей активен"): + pass diff --git a/tests/integration/step_cm_test.pyi b/tests/integration/step_cm_test.pyi new file mode 100644 index 0000000..a1ae4c0 --- /dev/null +++ b/tests/integration/step_cm_test.pyi @@ -0,0 +1,23 @@ +# Integration contract for the `step` context manager - the v2 runtime core. +# +# `from beehave import step` - `step(keyword: str, text: str, /, **placeholders)` +# is the executable `with` block that replaces v1's step-definition registry. +# The block RUNS real test code; the `Then` block asserts the outcome; the +# context manager attributes any exception to its step via +# `e.add_note(f"{keyword} {text}")` on `__exit__`, then propagates the exception. +# +# These tests drive the CM in-process; the SUT import lives in each body +# (deferred), so the `.pyi` does not import beehave. + +# The exact `add_note` format the CM appends on `__exit__` exception. +NOTE_FORMAT: str + +def test_block_body_executes_when_entered() -> None: ... +def test_assertion_inside_then_step_propagates_failure() -> None: ... +def test_assertion_inside_then_step_passes_when_truthy() -> None: ... +def test_exception_attributed_to_step_via_add_note() -> None: ... +def test_clean_exit_does_not_add_attribution_note() -> None: ... +def test_keyword_and_text_are_positional_only() -> None: ... +def test_placeholders_accepted_as_keyword_arguments() -> None: ... +def test_all_gherkin_step_keywords_accepted() -> None: ... +def test_localized_keyword_accepted() -> None: ... diff --git a/tests/integration/strategy_inference_test.py b/tests/integration/strategy_inference_test.py new file mode 100644 index 0000000..151419f --- /dev/null +++ b/tests/integration/strategy_inference_test.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +INT_COLUMN_FEATURE = """\ +Feature: Strategy Inference +Scenario Outline: int column +Given a value of +Then anything + +Examples: + | amount | + | 1 | + | 2 | +""" + +FLOAT_COLUMN_FEATURE = """\ +Feature: Strategy Inference +Scenario Outline: float column +Given a value of +Then anything + +Examples: + | amount | + | 1.5 | + | 2.5 | +""" + +BOOL_COLUMN_FEATURE = """\ +Feature: Strategy Inference +Scenario Outline: bool column +Given a flag of +Then anything + +Examples: + | flag | + | true | + | false | +""" + +MIXED_COLUMN_FEATURE = """\ +Feature: Strategy Inference +Scenario Outline: mixed column +Given a value of +Then anything + +Examples: + | amount | + | 1 | + | 2.5 | + | hello | +""" + +TEXT_COLUMN_FEATURE = """\ +Feature: Strategy Inference +Scenario Outline: text column +Given a value of +Then anything + +Examples: + | name | + | alice | + | bob | +""" + +NO_EXAMPLES_FEATURE = """\ +Feature: Strategy Inference +Scenario: no examples +Given a value of +Then anything +""" + + +def emitted_function_signature(feature_text: str, scenario_slug: str) -> str: + from beehave.generate import generate + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + features = root / "docs" / "features" + features.mkdir(parents=True) + (features / "input.feature").write_text(feature_text) + generate(root) + pyi = (root / "tests" / "input_default_test.pyi").read_text() + needle = f"def test_{scenario_slug}" + start = pyi.find(needle) + if start == -1: + return "" + end = pyi.find("...", start) + if end == -1: + return pyi[start:] + return pyi[start : end + 3] + + +@pytest.mark.pending +def test_all_int_column_infers_int_parameter() -> None: + signature = emitted_function_signature(INT_COLUMN_FEATURE, "int_column") + assert "amount: int" in signature + + +@pytest.mark.pending +def test_all_float_column_infers_float_parameter() -> None: + signature = emitted_function_signature(FLOAT_COLUMN_FEATURE, "float_column") + assert "amount: float" in signature + + +@pytest.mark.pending +def test_all_bool_column_infers_bool_parameter() -> None: + signature = emitted_function_signature(BOOL_COLUMN_FEATURE, "bool_column") + assert "flag: bool" in signature + + +@pytest.mark.pending +def test_mixed_type_column_infers_str_parameter() -> None: + signature = emitted_function_signature(MIXED_COLUMN_FEATURE, "mixed_column") + assert "amount: str" in signature + + +@pytest.mark.pending +def test_text_column_infers_str_parameter() -> None: + signature = emitted_function_signature(TEXT_COLUMN_FEATURE, "text_column") + assert "name: str" in signature + + +@pytest.mark.pending +def test_no_examples_table_infers_str_parameter() -> None: + signature = emitted_function_signature(NO_EXAMPLES_FEATURE, "no_examples") + assert "name: str" in signature diff --git a/tests/integration/strategy_inference_test.pyi b/tests/integration/strategy_inference_test.pyi new file mode 100644 index 0000000..9784795 --- /dev/null +++ b/tests/integration/strategy_inference_test.pyi @@ -0,0 +1,27 @@ +# Integration contract for Examples-table -> Hypothesis strategy -> `.pyi` type. +# +# The generation rule (interview L1): for each Examples column, the strategy is +# inferred from cell values across all rows - all-parseable int -> int, +# all-parseable float -> float, all-parseable bool -> bool, otherwise -> str. +# When a Scenario has no Examples table, placeholders default to `str` +# (decision Q4 - simplest sound default; matches the "otherwise -> str" branch). +# +# These tests drive `beehave.gherkin.parse_feature` + `beehave.generate`'s +# emission path in-process; the SUT imports live in each body (deferred), so +# the `.pyi` does not import beehave. + +# Minimal feature snippets exercising each strategy branch. +INT_COLUMN_FEATURE: str +FLOAT_COLUMN_FEATURE: str +BOOL_COLUMN_FEATURE: str +MIXED_COLUMN_FEATURE: str +TEXT_COLUMN_FEATURE: str +NO_EXAMPLES_FEATURE: str + +def emitted_function_signature(feature_text: str, scenario_slug: str) -> str: ... +def test_all_int_column_infers_int_parameter() -> None: ... +def test_all_float_column_infers_float_parameter() -> None: ... +def test_all_bool_column_infers_bool_parameter() -> None: ... +def test_mixed_type_column_infers_str_parameter() -> None: ... +def test_text_column_infers_str_parameter() -> None: ... +def test_no_examples_table_infers_str_parameter() -> None: ... diff --git a/tests/integration/title_derivation_test.py b/tests/integration/title_derivation_test.py new file mode 100644 index 0000000..6e5b3fe --- /dev/null +++ b/tests/integration/title_derivation_test.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import pytest + +MIN_WORD_COUNT = 2 +MAX_WORD_COUNT = 6 + + +def function_name_for_title(title: str) -> str: + from beehave.gherkin import parse_feature + + feature_text = f"Feature: T\nScenario: {title}\nGiven any step\n" + feature = parse_feature(feature_text) + return feature.children[0].function_name + + +def title_is_valid_in_feature(*titles: str) -> bool: + from beehave.gherkin import parse_feature + + scenarios = "\n".join(f"Scenario: {t}\nGiven any step\n" for t in titles) + feature_text = f"Feature: T\n{scenarios}" + try: + parse_feature(feature_text) + except Exception: + return False + return True + + +@pytest.mark.pending +def test_title_lowered_to_slug() -> None: + assert function_name_for_title("Honey Production") == "test_honey_production" + + +@pytest.mark.pending +def test_whitespace_runs_collapse_to_single_underscore() -> None: + assert function_name_for_title("Honey Production") == "test_honey_production" + + +@pytest.mark.pending +def test_function_name_is_test_underscore_slug() -> None: + assert ( + function_name_for_title("forager returns with nectar") + == "test_forager_returns_with_nectar" + ) + + +@pytest.mark.pending +def test_two_word_title_is_valid() -> None: + assert title_is_valid_in_feature("Honey Production") + + +@pytest.mark.pending +def test_six_word_title_is_valid() -> None: + assert title_is_valid_in_feature("the forager returns to the hive") + + +@pytest.mark.pending +def test_one_word_title_rejected() -> None: + assert not title_is_valid_in_feature("Honey") + + +@pytest.mark.pending +def test_seven_word_title_rejected() -> None: + assert not title_is_valid_in_feature("the forager returns to the busy hive") + + +@pytest.mark.pending +def test_title_with_hyphen_rejected() -> None: + assert not title_is_valid_in_feature("Honey-Production") + + +@pytest.mark.pending +def test_duplicate_titles_case_insensitive_rejected() -> None: + assert not title_is_valid_in_feature("Hive Activity", "hive activity") diff --git a/tests/integration/title_derivation_test.pyi b/tests/integration/title_derivation_test.pyi new file mode 100644 index 0000000..283b1c6 --- /dev/null +++ b/tests/integration/title_derivation_test.pyi @@ -0,0 +1,27 @@ +# Integration contract for title -> slug -> function_name derivation. +# +# The function name IS the scenario's identity (interview L1) - title -> slug +# (lowercased, whitespace runs collapsed to a single underscore) -> +# `test_`. Title rules kept from v1: case-insensitive uniqueness across +# feature/rule/scenario, a 2-6 word-count bound, and a Unicode +# letters/digits/spaces charset. +# +# These tests drive `beehave.gherkin` (parser + title-rule enforcement) +# in-process; the SUT imports live in each body (deferred), so the `.pyi` +# does not import beehave. + +# Title-rule bounds (locked here so each assertion carries the domain value). +MIN_WORD_COUNT: int +MAX_WORD_COUNT: int + +def function_name_for_title(title: str) -> str: ... +def title_is_valid_in_feature(*titles: str) -> bool: ... +def test_title_lowered_to_slug() -> None: ... +def test_whitespace_runs_collapse_to_single_underscore() -> None: ... +def test_function_name_is_test_underscore_slug() -> None: ... +def test_two_word_title_is_valid() -> None: ... +def test_six_word_title_is_valid() -> None: ... +def test_one_word_title_rejected() -> None: ... +def test_seven_word_title_rejected() -> None: ... +def test_title_with_hyphen_rejected() -> None: ... +def test_duplicate_titles_case_insensitive_rejected() -> None: ... From 9bd497edc1532df0a5fb488e004038484b24197d Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 10:16:23 -0400 Subject: [PATCH 02/13] feat(beehave-v2)!: rewrite as thin Hypothesis layer with Gherkin BDD pipeline Squash-merges the seven-contract v2 build cycle into dev. Modules: - beehave/step.py: step context manager wiring Gherkin steps to Hypothesis - beehave/gherkin.py: Gherkin parse model - beehave/generate.py: test-file generator - beehave/check.py: contract verifier - beehave/status.py: status command - beehave/cli.py: CLI surface - beehave/__init__.py: __version__ = 2.0.0 Removes clean.py, config.py, discover.py, models.py (consolidation to thin layer). Adds .stubtest_allowlist, beehave/py.typed; activates integration + e2e test bodies. BREAKING CHANGE: beehave 2.0.0 is a rewrite; 1.x modules removed. --- .github/workflows/ci.yml | 4 + .stubtest_allowlist | 13 + beehave/__init__.py | 4 +- beehave/check.py | 444 ++------- beehave/clean.py | 78 -- beehave/cli.py | 218 +---- beehave/config.py | 51 -- beehave/discover.py | 157 ---- beehave/generate.py | 401 +++----- beehave/gherkin.py | 863 ++++++------------ beehave/models.py | 83 -- .../__init__.py => beehave/py.typed | 0 beehave/status.py | 399 +------- beehave/step.py | 16 + pyproject.toml | 18 +- tests/conftest.py | 61 -- tests/e2e/check_test.py | 12 +- tests/e2e/generate_test.py | 14 +- tests/e2e/status_test.py | 6 - ...cket_notation_preserved_as_literal_test.py | 1 - .../literal_matching_case_insensitive_test.py | 48 - .../negative_numbers_visible_in_body_test.py | 48 - ...ceholder_matching_case_insensitive_test.py | 59 -- ...ed_placeholder_not_double_captured_test.py | 86 -- .../stub_tests_skip_all_checks_test.py | 5 - .../true_and_one_never_collide_test.py | 1 - tests/features/comb_construction/__init__.py | 0 .../brood_management_test.py | 15 - .../comb_construction/comb_building_test.py | 8 - tests/features/hive_activity/__init__.py | 0 tests/features/hive_activity/default_test.py | 13 - tests/features/hive_activity/foraging_test.py | 8 - .../hive_activity/hive_defense_test.py | 8 - .../hive_activity/hive_foraging_test.py | 9 - tests/features/status_command/__init__.py | 0 .../all_passing_derives_ok_test.py | 63 -- .../all_stubs_derive_stage_test.py | 121 --- .../cross_feature_collisions_detected_test.py | 148 --- ...empty_features_report_no_scenarios_test.py | 65 -- .../exit_codes_reflect_overall_status_test.py | 177 ---- .../json_output_is_machine_readable_test.py | 250 ----- .../ok_feature_collapses_in_output_test.py | 126 --- .../parse_error_captured_as_stage_test.py | 74 -- .../rules_without_scenarios_detected_test.py | 37 - ...rio_statuses_derive_from_discovery_test.py | 164 ---- ...scovery_failure_yields_needs_tests_test.py | 92 -- .../tree_output_shows_rule_hierarchy_test.py | 214 ----- ..._directories_reported_when_flagged_test.py | 104 --- .../unmapped_scenarios_derive_stage_test.py | 109 --- .../violations_derive_stage_test.py | 117 --- .../worst_scenario_wins_test.py | 113 --- tests/features/title_validation/__init__.py | 1 - .../duplicate_titles_are_detected_test.py | 261 ------ .../title_charset_is_validated_test.py | 124 --- ...title_validation_blocks_generation_test.py | 73 -- ...title_violations_included_in_check_test.py | 63 -- .../title_word_count_is_validated_test.py | 108 --- ...valid_titles_produce_no_violations_test.py | 120 --- tests/integration/idempotency_test.py | 5 - tests/integration/parsing_test.py | 8 +- tests/integration/roundtrip_test.py | 8 +- tests/integration/step_cm_test.py | 11 +- tests/integration/strategy_inference_test.py | 8 - tests/integration/title_derivation_test.py | 15 +- tests/test_check.py | 406 -------- tests/test_clean.py | 137 --- tests/test_cli.py | 221 ----- tests/test_config.py | 69 -- tests/test_discover.py | 192 ---- tests/test_generate.py | 287 ------ tests/test_gherkin.py | 476 ---------- uv.lock | 215 ++++- 72 files changed, 785 insertions(+), 7148 deletions(-) create mode 100644 .stubtest_allowlist delete mode 100644 beehave/clean.py delete mode 100644 beehave/config.py delete mode 100644 beehave/discover.py delete mode 100644 beehave/models.py rename tests/features/case_insensitive_matching/__init__.py => beehave/py.typed (100%) create mode 100644 beehave/step.py delete mode 100644 tests/conftest.py delete mode 100644 tests/features/case_insensitive_matching/bracket_notation_preserved_as_literal_test.py delete mode 100644 tests/features/case_insensitive_matching/literal_matching_case_insensitive_test.py delete mode 100644 tests/features/case_insensitive_matching/negative_numbers_visible_in_body_test.py delete mode 100644 tests/features/case_insensitive_matching/placeholder_matching_case_insensitive_test.py delete mode 100644 tests/features/case_insensitive_matching/quoted_placeholder_not_double_captured_test.py delete mode 100644 tests/features/case_insensitive_matching/stub_tests_skip_all_checks_test.py delete mode 100644 tests/features/case_insensitive_matching/true_and_one_never_collide_test.py delete mode 100644 tests/features/comb_construction/__init__.py delete mode 100644 tests/features/comb_construction/brood_management_test.py delete mode 100644 tests/features/comb_construction/comb_building_test.py delete mode 100644 tests/features/hive_activity/__init__.py delete mode 100644 tests/features/hive_activity/default_test.py delete mode 100644 tests/features/hive_activity/foraging_test.py delete mode 100644 tests/features/hive_activity/hive_defense_test.py delete mode 100644 tests/features/hive_activity/hive_foraging_test.py delete mode 100644 tests/features/status_command/__init__.py delete mode 100644 tests/features/status_command/all_passing_derives_ok_test.py delete mode 100644 tests/features/status_command/all_stubs_derive_stage_test.py delete mode 100644 tests/features/status_command/cross_feature_collisions_detected_test.py delete mode 100644 tests/features/status_command/empty_features_report_no_scenarios_test.py delete mode 100644 tests/features/status_command/exit_codes_reflect_overall_status_test.py delete mode 100644 tests/features/status_command/json_output_is_machine_readable_test.py delete mode 100644 tests/features/status_command/ok_feature_collapses_in_output_test.py delete mode 100644 tests/features/status_command/parse_error_captured_as_stage_test.py delete mode 100644 tests/features/status_command/rules_without_scenarios_detected_test.py delete mode 100644 tests/features/status_command/scenario_statuses_derive_from_discovery_test.py delete mode 100644 tests/features/status_command/test_discovery_failure_yields_needs_tests_test.py delete mode 100644 tests/features/status_command/tree_output_shows_rule_hierarchy_test.py delete mode 100644 tests/features/status_command/unmapped_directories_reported_when_flagged_test.py delete mode 100644 tests/features/status_command/unmapped_scenarios_derive_stage_test.py delete mode 100644 tests/features/status_command/violations_derive_stage_test.py delete mode 100644 tests/features/status_command/worst_scenario_wins_test.py delete mode 100644 tests/features/title_validation/__init__.py delete mode 100644 tests/features/title_validation/duplicate_titles_are_detected_test.py delete mode 100644 tests/features/title_validation/title_charset_is_validated_test.py delete mode 100644 tests/features/title_validation/title_validation_blocks_generation_test.py delete mode 100644 tests/features/title_validation/title_violations_included_in_check_test.py delete mode 100644 tests/features/title_validation/title_word_count_is_validated_test.py delete mode 100644 tests/features/title_validation/valid_titles_produce_no_violations_test.py delete mode 100644 tests/test_check.py delete mode 100644 tests/test_clean.py delete mode 100644 tests/test_cli.py delete mode 100644 tests/test_config.py delete mode 100644 tests/test_discover.py delete mode 100644 tests/test_generate.py delete mode 100644 tests/test_gherkin.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e2b86d..3556267 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,10 @@ jobs: - run: uv run ruff format --check . + - run: uv run mypy beehave + + - run: uv run python -m mypy.stubtest beehave --allowlist .stubtest_allowlist + test: name: Test runs-on: ubuntu-latest diff --git a/.stubtest_allowlist b/.stubtest_allowlist new file mode 100644 index 0000000..2216d5c --- /dev/null +++ b/.stubtest_allowlist @@ -0,0 +1,13 @@ +# stubtest allowlist — documented false positives. +# Each line is a regex matched against the error's object path. +# +# beehave.step.step — mypy 2.3.0's @contextmanager inference erases +# positional-only parameter names to None; stubtest then synthesizes +# __arg0/__arg1, which fail to match the runtime-preserved keyword/text. +# The .pyi and .py are faithful (both declare positional-only keyword, text); +# this is a tool quirk, not contract drift. Tracked in +# .cache/beehave-v2/journal.md (build cycle 1 green). Backstopped by the 9 +# tests in tests/integration/step_cm_test.py (behavioral coverage) and +# `mypy --strict beehave` (.pyi-internal consistency). Re-verify manually if +# step's signature changes. +beehave.step.step diff --git a/beehave/__init__.py b/beehave/__init__.py index 9e26b91..dfebabc 100644 --- a/beehave/__init__.py +++ b/beehave/__init__.py @@ -1,3 +1,3 @@ -"""beehave — BDD living documentation in sync.""" +from beehave.step import step as step -__version__ = "0.3.1" +__version__ = "2.0.0" diff --git a/beehave/check.py b/beehave/check.py index 47feeb3..24edd1c 100644 --- a/beehave/check.py +++ b/beehave/check.py @@ -1,369 +1,91 @@ -"""Feature-to-test consistency checker. - -Verifies that every scenario has a matching test function with the correct -placeholders, literals, and example-table bijection. Also runs global title -validation across all ``.feature`` files. -""" - from __future__ import annotations -import sys -from pathlib import Path - -from beehave.config import Config -from beehave.discover import ( - DiscoverError, - discover_tests, - discover_tests_dir_with_paths, -) -from beehave.gherkin import GherkinError, parse_feature, validate_all_titles -from beehave.models import ScenarioInfo, TestInfo, Violation, coerce_example_value - - -def _check_placeholders( - si: ScenarioInfo, - ti: TestInfo, - test_path: str, -) -> list[Violation]: - """Verify every Gherkin placeholder appears in the test body. - - Placeholders are matched case-insensitively (```` matches - ``prefix`` in the test body). - - Args: - si: Parsed scenario information. - ti: Discovered test info. - test_path: Path to the test file for error reporting. - - Returns: - A list of ``Violation`` objects for each missing placeholder. - - """ - if ti.is_stub: - return [] - - violations: list[Violation] = [] - for ph in si.placeholders: - if ph.name.lower() not in {n.lower() for n in ti.body_name_nodes}: - violations.append( - Violation( - path=test_path, - line=ti.line, - error_type="missing-placeholder", - message=f"'{ph.name}' not found in function body", - ) - ) - return violations - - -def _check_literals( - si: ScenarioInfo, - ti: TestInfo, - test_path: str, -) -> list[Violation]: - """Verify every Gherkin step literal appears in the test body. - - Literal values are matched case-insensitively (``"Hello"`` matches - ``"hello"`` in the test body). Numeric literals are converted to - strings before comparison. - - Args: - si: Parsed scenario information. - ti: Discovered test info. - test_path: Path to the test file for error reporting. - - Returns: - A list of ``Violation`` objects for each missing literal. - - """ - if ti.is_stub: - return [] - - violations: list[Violation] = [] - for lit in si.literals: - lowered_constants = {str(c).lower() for c in ti.body_constant_nodes} - if str(lit.value).lower() not in lowered_constants: - violations.append( - Violation( - path=test_path, - line=ti.line, - error_type="missing-literal", - message=f"literal {lit.raw!r} not found in function body", - ) - ) - return violations - - -def _check_examples_bijection( - si: ScenarioInfo, - ti: TestInfo, - test_path: str, - feature_path: str, -) -> list[Violation]: - if not si.is_outline or si.examples is None or ti.is_stub: - return [] - - feature_rows: list[dict[str, object]] = [] - for row in si.examples.rows: - row_dict: dict[str, object] = {} - for i, header in enumerate(si.examples.headers): - row_dict[header] = coerce_example_value(row[i]) - feature_rows.append(row_dict) - - test_rows = list(ti.example_rows) - matched_test: set[int] = set() - matched_feature: set[int] = set() - - for fi, frow in enumerate(feature_rows): - for ti_idx, trow in enumerate(test_rows): - if ti_idx in matched_test: - continue - if frow == trow: - matched_feature.add(fi) - matched_test.add(ti_idx) - break - - violations: list[Violation] = [] - for fi in range(len(feature_rows)): - if fi not in matched_feature: - violations.append( - Violation( - path=feature_path, - line=0, - error_type="example-mismatch", - message=( - f"Examples row {fi + 1} has no matching @example() decorator" - ), - ) - ) - - for ti_idx in range(len(test_rows)): - if ti_idx not in matched_test: - violations.append( - Violation( - path=test_path, - line=0, - error_type="example-mismatch", - message="@example() decorator has no matching Examples row", - ) - ) - - return violations - - -def check_pair( - si: ScenarioInfo, - ti: TestInfo | None, - test_path: str, - feature_path: str, -) -> list[Violation]: - """Check a single scenario against its test function. - - Validates that the test exists, that every Gherkin placeholder appears in - the test body, that every string/numeric literal in the test has a - corresponding Gherkin step token, and that ``@example`` decorators match - Examples table rows. - - Args: - si: Parsed scenario information. - ti: Discovered test info, or ``None`` if no test was found. - test_path: Path to the test file (for error reporting). - feature_path: Path to the feature file (for error reporting). +import ast +from typing import TYPE_CHECKING - Returns: - A list of ``Violation`` objects (empty if everything matches). +from beehave.gherkin import Rule, parse_feature - """ - violations: list[Violation] = [] +if TYPE_CHECKING: + from beehave.gherkin import Scenario, Step - if ti is None: - violations.append( - Violation( - path=feature_path, - line=si.line, - error_type="unmapped-scenario", - message=f"scenario '{si.title}' has no test function", - ) - ) - return violations - violations.extend(_check_placeholders(si, ti, test_path)) - violations.extend(_check_literals(si, ti, test_path)) - violations.extend(_check_examples_bijection(si, ti, test_path, feature_path)) - return violations - - -def _scan_unmapped_tests( - all_test_files: dict[str, dict[str, TestInfo]], - scenarios: dict[str, ScenarioInfo], - test_dir: Path, -) -> list[Violation]: - violations: list[Violation] = [] - for rp, tests in all_test_files.items(): - test_file = test_dir / f"{rp}.py" - for fn, ti in tests.items(): - si = scenarios.get(fn) - if si is None: - violations.append( - Violation( - path=str(test_file), - line=ti.line, - error_type="unmapped-test", - message=f"'{fn}' has no matching scenario", - ) - ) - elif si.rule_path != rp: - violations.append( - Violation( - path=str(test_file), - line=ti.line, - error_type="misplaced-test", - message=( - f"'{fn}' is in {rp}.py but should be in {si.rule_path}.py" - ), - is_warning=True, - ) - ) - return violations - - -def check_single( - feature_path: Path, - config: Config, -) -> list[Violation]: - """Check a single feature file against its test directory. - - Parses the feature, discovers the corresponding test files, and runs - ``check_pair`` for every scenario. - - Args: - feature_path: Path to a ``.feature`` file. - config: The project configuration. - - Returns: - A list of ``Violation`` objects. - - """ +def _step_block_from_item( + item: ast.withitem, +) -> tuple[str, str, set[str]] | None: + call = item.context_expr + if not isinstance(call, ast.Call): + return None + callee = call.func + if not isinstance(callee, ast.Name) or callee.id != "step": + return None + if len(call.args) < 2: + return None try: - scenarios = parse_feature(feature_path, config) - except GherkinError as e: - print(f"Error: {e}", file=sys.stderr) - return [] - - if not scenarios: - return [] - - first = next(iter(scenarios.values())) - feature_dir = first.feature_path - feature_rel = str(feature_path) - - scenario_by_rule: dict[str, dict[str, ScenarioInfo]] = {} - for fn, si in scenarios.items(): - scenario_by_rule.setdefault(si.rule_path, {})[fn] = si - - test_dir = Path(config.tests_dir) / feature_dir - all_test_files: dict[str, dict[str, TestInfo]] = {} - if test_dir.exists(): - for py_file in sorted(test_dir.glob("*_test.py")): - rp = py_file.stem - try: - all_test_files[rp] = discover_tests(py_file) - except DiscoverError as e: - print(f"Error: {e}", file=sys.stderr) - all_test_files[rp] = {} - - violations: list[Violation] = [] - violations.extend(_scan_unmapped_tests(all_test_files, scenarios, test_dir)) - - for fn, si in scenarios.items(): - test_file = test_dir / f"{si.rule_path}.py" - rp_tests = all_test_files.get(si.rule_path, {}) - ti = rp_tests.get(fn) - violations.extend(check_pair(si, ti, str(test_file), feature_rel)) - - return violations - - -def check_all(config: Config) -> list[Violation]: - """Run the full project-wide consistency check. - - Parses every ``.feature`` file, discovers every test function, and then - for each scenario verifies placeholder coverage, literal mapping, example - bijection, file placement, and unmapped tests. Finally runs - ``validate_all_titles`` to catch title-level problems. - - Args: - config: The project configuration. - - Returns: - A consolidated list of every ``Violation`` found. - - """ - features_dir = Path(config.features_dir) - if not features_dir.exists(): - print( - f"Error: features directory '{config.features_dir}' not found", - file=sys.stderr, - ) - return [] - - all_scenarios: dict[str, ScenarioInfo] = {} - feature_paths: dict[str, Path] = {} - - seen_fn: dict[str, str] = {} - for feature_file in sorted(features_dir.rglob("*.feature")): - try: - scenarios = parse_feature( - feature_file, - config, - seen_function_names=seen_fn, - skip_title_validation=True, - ) - except GherkinError as e: - print(f"Error: {e}", file=sys.stderr) + keyword = ast.literal_eval(call.args[0]) + text = ast.literal_eval(call.args[1]) + except ValueError, SyntaxError: + return None + if not isinstance(keyword, str) or not isinstance(text, str): + return None + names = {kw.arg for kw in call.keywords if kw.arg is not None} + return (keyword, text, names) + + +def _step_blocks( + function: ast.FunctionDef, +) -> list[tuple[str, str, set[str]]]: + blocks: list[tuple[str, str, set[str]]] = [] + for stmt in function.body: + if not isinstance(stmt, ast.With): continue - for fn in scenarios: - feature_paths[fn] = feature_file - all_scenarios.update(scenarios) - - tests_dir = Path(config.tests_dir) - test_file_map = discover_tests_dir_with_paths(tests_dir) - - violations: list[Violation] = [] - for fn, si in all_scenarios.items(): - feature_rel = str(feature_paths[fn]) - test_file = Path(config.tests_dir) / si.feature_path / f"{si.rule_path}.py" - entry = test_file_map.get(fn) - ti = entry[0] if entry else None - violations.extend(check_pair(si, ti, str(test_file), feature_rel)) - - for fn, (ti, test_file) in test_file_map.items(): - si = all_scenarios.get(fn) - if si is None: - violations.append( - Violation( - path=str(test_file), - line=ti.line, - error_type="unmapped-test", - message=f"'{fn}' has no matching scenario", - ) - ) - else: - expected = Path(config.tests_dir) / si.feature_path / f"{si.rule_path}.py" - if test_file.resolve() != expected.resolve(): - violations.append( - Violation( - path=str(test_file), - line=ti.line, - error_type="misplaced-test", - message=( - f"'{fn}' is in {test_file.name} " - f"but should be in {expected.name}" - ), - is_warning=True, - ) - ) - - violations.extend(validate_all_titles(config)) - - return violations + for item in stmt.items: + block = _step_block_from_item(item) + if block is not None: + blocks.append(block) + return blocks + + +def _step_matches( + block: tuple[str, str, set[str]], + step: Step, +) -> bool: + keyword, text, names = block + if keyword.lower() != step.keyword.lower(): + return False + if text != step.text: + return False + if names != {p.name for p in step.placeholders}: + return False + return True + + +def _scenario_matches( + scenario: Scenario, + blocks_by_function: dict[str, list[tuple[str, str, set[str]]]], +) -> bool: + blocks = blocks_by_function.get(scenario.function_name, []) + if len(blocks) != len(scenario.steps): + return False + return all( + _step_matches(block, step) + for block, step in zip(blocks, scenario.steps, strict=True) + ) + + +def check(feature_text: str, test_py_text: str) -> bool: + feature = parse_feature(feature_text) + try: + tree = ast.parse(test_py_text) + except SyntaxError: + return False + blocks_by_function = { + node.name: _step_blocks(node) + for node in tree.body + if isinstance(node, ast.FunctionDef) + } + for child in feature.children: + scenarios = child.children if isinstance(child, Rule) else [child] + for scenario in scenarios: + if not _scenario_matches(scenario, blocks_by_function): + return False + return True diff --git a/beehave/clean.py b/beehave/clean.py deleted file mode 100644 index b64cdf8..0000000 --- a/beehave/clean.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import ast -import sys -from pathlib import Path - -from beehave.config import Config -from beehave.discover import discover_tests -from beehave.gherkin import GherkinError, parse_feature - - -def clean_unmapped( - feature_path: str, - config: Config, - force: bool = False, -) -> None: - fpath = Path(config.features_dir) / f"{feature_path}.feature" - if not fpath.exists(): - print(f"Error: Feature file not found: {fpath}", file=sys.stderr) - raise SystemExit(1) from None - - try: - scenarios = parse_feature(fpath, config) - except GherkinError as e: - print(f"Error: {e}", file=sys.stderr) - raise SystemExit(1) from None - - if not scenarios: - return - - feature_dir = next(iter(scenarios.values())).feature_path - - rule_paths = {si.rule_path for si in scenarios.values()} - for rp in rule_paths: - test_file = Path(config.tests_dir) / feature_dir / f"{rp}.py" - if not test_file.exists(): - continue - - rp_scenarios = {fn: si for fn, si in scenarios.items() if si.rule_path == rp} - tests = discover_tests(test_file) - unmapped_fns = [fn for fn in tests if fn not in rp_scenarios] - - if not unmapped_fns: - continue - - non_stub = [fn for fn in unmapped_fns if not tests[fn].is_stub] - if non_stub and not force: - for fn in non_stub: - print( - f"Warning: '{fn}' is not a stub. " - f"Use --force to remove non-stub functions.", - ) - continue - - source = test_file.read_text(encoding="utf-8") - tree = ast.parse(source, filename=str(test_file)) - - removed: set[str] = set() - tree.body = [ - node - for node in tree.body - if not ( - isinstance(node, ast.FunctionDef) - and node.name in unmapped_fns - and not removed.add(node.name) - ) - ] - - if not removed: - continue - - new_source = ast.unparse(tree) - test_file.write_text(new_source + "\n", encoding="utf-8") - names = ", ".join(sorted(removed)) - print( - f"Removed {len(removed)} unmapped function(s) " - f"from {test_file.name}: {names}" - ) diff --git a/beehave/cli.py b/beehave/cli.py index cc18b62..e8a2739 100644 --- a/beehave/cli.py +++ b/beehave/cli.py @@ -1,187 +1,41 @@ from __future__ import annotations -import argparse import sys +from collections.abc import Sequence from pathlib import Path -from beehave.check import check_all, check_single -from beehave.clean import clean_unmapped -from beehave.config import load_config -from beehave.discover import discover_tests -from beehave.generate import generate_stubs -from beehave.gherkin import GherkinError, parse_feature -from beehave.status import compute_status - - -def cmd_generate(args: argparse.Namespace) -> None: - config = load_config() - generate_stubs(args.feature, config) - - -def cmd_check(args: argparse.Namespace) -> None: - config = load_config() - if args.feature: - fpath = Path(config.features_dir) / f"{args.feature}.feature" - violations = check_single(fpath, config) - else: - violations = check_all(config) - - for v in violations: - print(v) - - if any(not v.is_warning for v in violations): - raise SystemExit(1) - - -def cmd_clean(args: argparse.Namespace) -> None: - config = load_config() - clean_unmapped(args.feature, config, force=args.force) - - -def cmd_list(args: argparse.Namespace) -> None: - config = load_config() - features_dir = Path(config.features_dir) - if not features_dir.exists(): - return - - for feature_file in sorted(features_dir.rglob("*.feature")): - try: - scenarios = parse_feature(feature_file, config) - except GherkinError as e: - print(f"Error: {e}", file=sys.stderr) - continue - - if not scenarios: - continue - - feature_path = str(feature_file.relative_to(features_dir).with_suffix("")) - title = next(iter(scenarios.values())).feature_title - print(f"{feature_path}: {title}") - - if not args.verbose: - continue - - print(f" path: {feature_file}") - print(f" scenarios: {len(scenarios)}") - - rule_groups: dict[str, list[str]] = {} - top_level: list[str] = [] - for fn, si in scenarios.items(): - if si.rule_path == "default_test": - top_level.append(fn) - else: - rule_name = si.rule_path.removesuffix("_test") - rule_groups.setdefault(rule_name, []).append(fn) - - if top_level: - print(f" top-level: {len(top_level)}") - for rule_name, fns in rule_groups.items(): - print(f" rules: {rule_name} ({len(fns)})") - - test_dir = Path(config.tests_dir) / next(iter(scenarios.values())).feature_path - stub_count = 0 - impl_count = 0 - for si in scenarios.values(): - tf = test_dir / f"{si.rule_path}.py" - tests = discover_tests(tf) - ti = tests.get(si.function_name) - if ti is not None and not ti.is_stub: - impl_count += 1 - else: - stub_count += 1 - total = stub_count + impl_count - if impl_count == 0: - print(f" stubs: {total}/{total} (all stubs)") - elif stub_count == 0: - print(f" stubs: 0/{total} (all implemented)") - else: - print(f" stubs: {stub_count}/{total} ({impl_count} implemented)") - - -def cmd_status(args: argparse.Namespace) -> None: - config = load_config() - compute_status( - config, - json_output=args.json, - include_unmapped=args.include_unmapped, - ) - - -def main(argv: list[str] | None = None) -> None: - parser = argparse.ArgumentParser( - prog="beehave", - description="BDD living documentation in sync", - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - gen = subparsers.add_parser( - "generate", - help="Generate test stubs from a feature file", - ) - gen.add_argument( - "feature", - help=("Feature path without extension, relative to features_dir"), - ) - gen.set_defaults(func=cmd_generate) - - chk = subparsers.add_parser( - "check", - help="Check consistency between features and tests", - ) - chk.add_argument( - "feature", - nargs="?", - default=None, - help=("Feature path (optional; checks all if omitted)"), - ) - chk.set_defaults(func=cmd_check) - - cln = subparsers.add_parser( - "clean", - help="Remove unmapped test functions", - ) - cln.add_argument( - "feature", - help="Feature path without extension", - ) - cln.add_argument( - "--force", - action="store_true", - help="Remove non-stub functions without warning", - ) - cln.set_defaults(func=cmd_clean) - - lst = subparsers.add_parser( - "list", - help="List all features with their paths", - ) - lst.add_argument( - "--verbose", - "-v", - action="store_true", - help="Show scenario counts, rules, and stub status", - ) - lst.set_defaults(func=cmd_list) - - sts = subparsers.add_parser( - "status", - help="Show development status of all features", - ) - sts.add_argument( - "--json", - action="store_true", - help="Output status report as JSON", - ) - sts.add_argument( - "--include-unmapped", - action="store_true", - help="Report test directories with no matching feature file", - ) - sts.set_defaults(func=cmd_status) - - args = parser.parse_args(argv) - args.func(args) - - -if __name__ == "__main__": - main() +from beehave.check import check +from beehave.generate import generate +from beehave.status import status + + +def main(argv: Sequence[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if not args: + return 2 + cmd = args[0] + if cmd == "generate": + generate(Path.cwd()) + return 0 + if cmd == "status": + return status(Path.cwd()) + if cmd == "check": + return _check_all(Path.cwd()) + return 2 + + +def _check_all(root: Path) -> int: + features_dir = root / "docs" / "features" + if not features_dir.is_dir(): + return 1 + test_py_text = _read_test_py(root / "tests") + for feature_path in sorted(features_dir.glob("*.feature")): + if not check(feature_path.read_text(), test_py_text): + return 1 + return 0 + + +def _read_test_py(tests_dir: Path) -> str: + if not tests_dir.is_dir(): + return "" + return "\n".join(path.read_text() for path in sorted(tests_dir.glob("*_test.py"))) diff --git a/beehave/config.py b/beehave/config.py deleted file mode 100644 index 82b61d9..0000000 --- a/beehave/config.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -import tomllib -from dataclasses import dataclass -from pathlib import Path - -VALID_STRATEGIES = ("text", "integers", "floats", "booleans") - -_STRATEGY_MAP = { - "text": "st.text()", - "integers": "st.integers()", - "floats": "st.floats()", - "booleans": "st.booleans()", -} - - -@dataclass(frozen=True) -class Config: - features_dir: str = "docs/features" - tests_dir: str = "tests/features" - default_strategy: str = "text" - background_check_numeric: bool = True - background_check_string: bool = True - - def __post_init__(self) -> None: - if self.default_strategy not in VALID_STRATEGIES: - raise ValueError( - f"Invalid default_strategy '{self.default_strategy}'. " - f"Valid options: {', '.join(VALID_STRATEGIES)}" - ) - - @property - def default_strategy_expr(self) -> str: - return _STRATEGY_MAP[self.default_strategy] - - -def load_config(project_root: Path | None = None) -> Config: - root = project_root or Path.cwd() - pyproject = root / "pyproject.toml" - if not pyproject.exists(): - return Config() - with open(pyproject, "rb") as f: - data = tomllib.load(f) - tool = data.get("tool", {}).get("beehave", {}) - return Config( - features_dir=tool.get("features_dir", "docs/features"), - tests_dir=tool.get("tests_dir", "tests/features"), - default_strategy=tool.get("default_strategy", "text"), - background_check_numeric=tool.get("background_check_numeric", True), - background_check_string=tool.get("background_check_string", True), - ) diff --git a/beehave/discover.py b/beehave/discover.py deleted file mode 100644 index 82ccb96..0000000 --- a/beehave/discover.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -import ast -from pathlib import Path - -from beehave.models import TestInfo - - -class DiscoverError(Exception): - pass - - -def _is_stub_body(body: list[ast.stmt]) -> bool: - if len(body) == 1: - node = body[0] - if isinstance(node, ast.Pass): - return True - if ( - isinstance(node, ast.Expr) - and isinstance(node.value, ast.Constant) - and node.value.value is ... - ): - return True - return False - - -def _extract_body_nodes( - body: list[ast.stmt], -) -> tuple[tuple[str, ...], tuple[object, ...]]: - """Walk a test function body and collect all names and constants. - - The first statement in a multi-statement body is skipped if it is a - docstring. ``UnaryOp`` nodes wrapping constants are folded (e.g. - ``-5`` becomes the constant ``-5``, not ``5`` with a negation flag). - - Args: - body: The list of AST statements in the function body. - - Returns: - A 2-tuple of ``(names, constants)``, each sorted. - - """ - check_nodes: list[ast.stmt] = [] - for node in body: - if ( - not check_nodes - and isinstance(node, ast.Expr) - and isinstance(node.value, ast.Constant) - and isinstance(node.value.value, str) - and len(body) > 1 - ): - continue - check_nodes.append(node) - - names: set[str] = set() - constants: set[object] = set() - - for node in ast.walk(ast.Module(body=check_nodes, type_ignores=[])): - if isinstance(node, ast.Name): - names.add(node.id) - elif isinstance(node, ast.Constant): - constants.add(node.value) - elif isinstance(node, ast.UnaryOp) and isinstance(node.operand, ast.Constant): - if isinstance(node.op, ast.USub): - constants.add(-node.operand.value) - elif isinstance(node.op, ast.UAdd): - constants.add(node.operand.value) - - return tuple(sorted(names)), tuple(sorted(constants, key=repr)) - - -def _extract_given_kwargs( - decorators: list[ast.expr], -) -> tuple[str, ...]: - kwargs: list[str] = [] - for dec in decorators: - if not isinstance(dec, ast.Call): - continue - is_given = ( - isinstance(dec.func, ast.Attribute) and dec.func.attr == "given" - ) or (isinstance(dec.func, ast.Name) and dec.func.id == "given") - if is_given: - for kw in dec.keywords: - if kw.arg: - kwargs.append(kw.arg) - return tuple(kwargs) - - -def _extract_example_rows( - decorators: list[ast.expr], -) -> tuple[dict[str, object], ...]: - rows: list[dict[str, object]] = [] - for dec in decorators: - if ( - isinstance(dec, ast.Call) - and isinstance(dec.func, ast.Name) - and dec.func.id == "example" - ): - row: dict[str, object] = {} - for kw in dec.keywords: - if kw.arg and isinstance(kw.value, ast.Constant): - row[kw.arg] = kw.value.value - rows.append(row) - return tuple(rows) - - -def discover_tests(test_file: Path) -> dict[str, TestInfo]: - if not test_file.exists(): - return {} - - try: - source = test_file.read_text(encoding="utf-8") - tree = ast.parse(source, filename=str(test_file)) - except SyntaxError as e: - raise DiscoverError(f"{test_file}:{e.lineno}: {e.msg}") from e - - result: dict[str, TestInfo] = {} - - for node in tree.body: - if not isinstance(node, ast.FunctionDef): - continue - if not node.name.startswith("test_"): - continue - - decorators = list(node.decorator_list) - given_kwargs = _extract_given_kwargs(decorators) - example_rows = _extract_example_rows(decorators) - is_stub = _is_stub_body(node.body) - body_names, body_constants = _extract_body_nodes(node.body) - - result[node.name] = TestInfo( - function_name=node.name, - given_kwargs=given_kwargs, - example_rows=example_rows, - body_name_nodes=body_names, - body_constant_nodes=body_constants, - is_stub=is_stub, - line=node.lineno, - ) - - return result - - -def discover_tests_dir_with_paths( - tests_dir: Path, -) -> dict[str, tuple[TestInfo, Path]]: - all_tests: dict[str, tuple[TestInfo, Path]] = {} - if not tests_dir.exists(): - return all_tests - for py_file in tests_dir.rglob("*_test.py"): - try: - tests = discover_tests(py_file) - for fn, ti in tests.items(): - all_tests[fn] = (ti, py_file) - except DiscoverError: - continue - return all_tests diff --git a/beehave/generate.py b/beehave/generate.py index 89eaeb0..55b0cfa 100644 --- a/beehave/generate.py +++ b/beehave/generate.py @@ -1,297 +1,138 @@ -"""Test stub generator. - -Reads a ``.feature`` file and produces corresponding pytest-beehave test stubs, -complete with Hypothesis strategies inferred from Examples tables and Gherkin -placeholders. -""" - from __future__ import annotations -import ast -import contextlib -import re -import sys from pathlib import Path +from typing import TYPE_CHECKING -from beehave.config import Config -from beehave.discover import discover_tests -from beehave.gherkin import GherkinError, parse_feature, validate_all_titles -from beehave.models import ExamplesTable, ScenarioInfo, coerce_example_value - - -def _infer_strategy_from_examples( - header: str, - examples: object, -) -> str: - table: ExamplesTable = examples - col_idx = table.headers.index(header) - values = [row[col_idx] for row in table.rows] - - types: set[str] = set() - for v in values: - if re.match(r"^-?\d+$", v): - types.add("integers") - elif re.match(r"^-?\d+\.\d+$", v): - types.add("floats") - elif v.lower() in ("true", "false"): - types.add("booleans") - else: - types.add("text") - - if types == {"integers"}: - return "st.integers()" - if types == {"floats"}: - return "st.floats()" - if types == {"booleans"}: - return "st.booleans()" - return "st.text()" - - -def _resolve_strategy( - placeholder_name: str, - scenario: ScenarioInfo, - existing_strategies: set[str], - config: Config, -) -> str: - if placeholder_name in existing_strategies: - return placeholder_name - - if ( - scenario.is_outline - and scenario.examples - and placeholder_name in scenario.examples.headers - ): - return _infer_strategy_from_examples(placeholder_name, scenario.examples) - - return config.default_strategy_expr - - -def _generate_function( - scenario: ScenarioInfo, - existing_strategies: set[str], - config: Config, -) -> str: - lines: list[str] = [] - - has_params = bool(scenario.placeholders) - - if has_params: - given_kwargs = [] - for ph in scenario.placeholders: - strategy = _resolve_strategy(ph.name, scenario, existing_strategies, config) - given_kwargs.append(f"{ph.name}={strategy}") +from beehave.gherkin import Rule, parse_feature - if scenario.is_outline and scenario.examples: - for row in scenario.examples.rows: - row_parts = [] - for i, header in enumerate(scenario.examples.headers): - val = coerce_example_value(row[i]) - if isinstance(val, str): - row_parts.append(f'{header}="{val}"') - elif isinstance(val, bool): - row_parts.append(f"{header}={val}") - else: - row_parts.append(f"{header}={val}") - lines.append(f"@example({', '.join(row_parts)})") +if TYPE_CHECKING: + from beehave.gherkin import Examples, Scenario, Step - lines.append(f"@given({', '.join(given_kwargs)})") - params = ", ".join(ph.name for ph in scenario.placeholders) - lines.append(f"def {scenario.function_name}({params}):") - else: - lines.append(f"def {scenario.function_name}():") +def _slug_from(title: str) -> str: + return "_".join(title.split()).lower() - lines.append(" ...") - lines.append("") - return "\n".join(lines) +def _is_int(value: str) -> bool: + try: + int(value) + except ValueError: + return False + return True -def _build_import_block( - scenarios: dict[str, ScenarioInfo], -) -> list[str]: - needs_given = any(s.placeholders for s in scenarios.values()) - needs_example = any(s.is_outline for s in scenarios.values()) - needs_st = needs_given +def _is_float(value: str) -> bool: + try: + float(value) + except ValueError: + return False + return True + + +def _is_bool(value: str) -> bool: + return value.lower() in ("true", "false") + + +def _placeholder_names(steps: list[Step]) -> list[str]: + seen: set[str] = set() + names: list[str] = [] + for step in steps: + for placeholder in step.placeholders: + if placeholder.name not in seen: + seen.add(placeholder.name) + names.append(placeholder.name) + return names + + +def _infer_param_type(name: str, examples: Examples | None) -> str: + if examples is None: + return "str" + values = [row[name] for row in examples.rows if name in row] + if not values: + return "str" + if all(_is_int(v) for v in values): + return "int" + if all(_is_float(v) for v in values): + return "float" + if all(_is_bool(v) for v in values): + return "bool" + return "str" + + +def _signature_params(scenario: Scenario) -> str: parts: list[str] = [] - if needs_given: - parts.append("given") - if needs_example: - parts.append("example") - if needs_st: - parts.append("strategies as st") + for name in _placeholder_names(scenario.steps): + parts.append(f"{name}: {_infer_param_type(name, scenario.examples)}") + return ", ".join(parts) - if not parts: - return [] +def _render_pyi(scenarios: list[Scenario]) -> str: lines: list[str] = [] - lines.append(f"from hypothesis import {', '.join(parts)}") - lines.append("") - return lines - - -def _parse_existing_imports(source: str) -> set[str]: - try: - tree = ast.parse(source) - except SyntaxError: - return set() - - imported: set[str] = set() - for node in tree.body: - if ( - isinstance(node, ast.ImportFrom) - and node.module - and "hypothesis" in node.module - ): - for alias in node.names: - imported.add(alias.asname or alias.name) - return imported - - -def _update_import_line( - source: str, - needed: set[str], -) -> str: - lines = source.split("\n") - for i, line in enumerate(lines): - if not line.startswith("from hypothesis import"): - continue - - current = line.replace("from hypothesis import", "").strip() - current_set = {p.strip() for p in current.split(",")} - current_set.update(needed) - - ordered: list[str] = [] - for name in ["given", "example", "settings"]: - if name in current_set: - ordered.append(name) - if "strategies as st" in current_set: - ordered.append("strategies as st") - - lines[i] = f"from hypothesis import {', '.join(ordered)}" - break - - return "\n".join(lines) - - -def _write_file( - test_file: Path, - scenarios: dict[str, ScenarioInfo], - config: Config, + for scenario in scenarios: + params = _signature_params(scenario) + lines.append(f"def {scenario.function_name}({params}) -> None: ...") + return "\n".join(lines) + "\n" + + +def _step_block(step: Step) -> str: + kwargs = "".join(f", {p.name}={p.name}" for p in step.placeholders) + return f" with step({step.keyword!r}, {step.text!r}{kwargs}):" + + +def _render_py(scenarios: list[Scenario]) -> str: + lines: list[str] = ["from beehave import step"] + for scenario in scenarios: + params = _signature_params(scenario) + lines.append("") + lines.append("") + lines.append(f"def {scenario.function_name}({params}) -> None:") + for step in scenario.steps: + lines.append(_step_block(step)) + lines.append(" pass") + if not scenario.steps: + lines.append(" pass") + return "\n".join(lines) + "\n" + + +def _emit_group( + *, + tests_dir: Path, + stem: str, + scenarios: list[Scenario], ) -> None: - test_file.parent.mkdir(parents=True, exist_ok=True) - init_file = test_file.parent / "__init__.py" - if not init_file.exists(): - init_file.touch() - - existing_functions: set[str] = set() - existing_strategies: set[str] = set() - existing_imports: set[str] = set() - - if test_file.exists(): - with contextlib.suppress(Exception): - existing_functions = set(discover_tests(test_file).keys()) - - try: - source = test_file.read_text(encoding="utf-8") - existing_imports = _parse_existing_imports(source) - tree = ast.parse(source) - for node in tree.body: - if ( - isinstance(node, ast.Assign) - and len(node.targets) == 1 - and isinstance(node.targets[0], ast.Name) - ): - existing_strategies.add(node.targets[0].id) - except SyntaxError: - pass - - new_functions: list[str] = [] - for fn, scenario in scenarios.items(): - if fn not in existing_functions: - new_functions.append( - _generate_function(scenario, existing_strategies, config) + pyi_path = tests_dir / f"{stem}_test.pyi" + py_path = tests_dir / f"{stem}_test.py" + pyi_path.write_text(_render_pyi(scenarios)) + if not py_path.exists(): + py_path.write_text(_render_py(scenarios)) + + +def generate(root: Path) -> None: + features_dir = root / "docs" / "features" + tests_dir = root / "tests" + tests_dir.mkdir(parents=True, exist_ok=True) + + for feature_path in sorted(features_dir.glob("*.feature")): + feature = parse_feature(feature_path.read_text()) + feature_slug = _slug_from(feature_path.stem) + + default_scenarios: list[Scenario] = [] + rules: list[Rule] = [] + for child in feature.children: + if isinstance(child, Rule): + rules.append(child) + else: + default_scenarios.append(child) + + if default_scenarios: + _emit_group( + tests_dir=tests_dir, + stem=f"{feature_slug}_default", + scenarios=default_scenarios, + ) + for rule in rules: + _emit_group( + tests_dir=tests_dir, + stem=f"{feature_slug}_{_slug_from(rule.name)}", + scenarios=rule.children, ) - - if not new_functions: - return - - needed: set[str] = set() - if any(s.placeholders for s in scenarios.values()): - needed.add("given") - needed.add("strategies as st") - if any(s.is_outline for s in scenarios.values()): - needed.add("example") - - if test_file.exists(): - source = test_file.read_text(encoding="utf-8") - missing = needed - existing_imports - if missing: - source = _update_import_line(source, missing) - - with open(test_file, "w", encoding="utf-8") as f: - f.write(source.rstrip("\n") + "\n\n") - for func in new_functions: - f.write(func + "\n") - else: - import_block = _build_import_block(scenarios) - with open(test_file, "w", encoding="utf-8") as f: - for line in import_block: - f.write(line + "\n") - for func in new_functions: - f.write(func + "\n") - - -def generate_stubs( - feature_path: str, - config: Config, -) -> None: - """Generate test stubs for a feature file. - - Runs ``validate_all_titles`` as a pre-flight gate: if any title in the - project is invalid or duplicated, generation is refused. Then parses the - requested feature, builds import blocks, infers Hypothesis strategies from - Examples tables and Gherkin placeholders, and writes the test stubs to - ``tests/features//``. - - Args: - feature_path: The feature file stem (e.g. ``"hive_activity"``). - config: The project configuration. - - Raises: - SystemExit: When pre-flight title validation fails or the feature file - does not exist. - - """ - violations = validate_all_titles(config) - if violations: - for v in violations: - print(str(v), file=sys.stderr) - raise SystemExit(1) - - fpath = Path(config.features_dir) / f"{feature_path}.feature" - if not fpath.exists(): - print(f"Error: Feature file not found: {fpath}", file=sys.stderr) - raise SystemExit(1) from None - - try: - scenarios = parse_feature(fpath, config) - except GherkinError as e: - print(f"Error: {e}", file=sys.stderr) - raise SystemExit(1) from None - - if not scenarios: - return - - feature_dir = next(iter(scenarios.values())).feature_path - - rule_groups: dict[str, dict[str, ScenarioInfo]] = {} - for fn, si in scenarios.items(): - rp = si.rule_path - if rp not in rule_groups: - rule_groups[rp] = {} - rule_groups[rp][fn] = si - - for rp, group in rule_groups.items(): - test_file = Path(config.tests_dir) / feature_dir / f"{rp}.py" - _write_file(test_file, group, config) diff --git a/beehave/gherkin.py b/beehave/gherkin.py index 33d7675..e215099 100644 --- a/beehave/gherkin.py +++ b/beehave/gherkin.py @@ -1,644 +1,335 @@ -"""Gherkin feature file parser and title validation. - -Parses .feature files into ScenarioInfo objects, extracting steps, placeholders, -literals, and examples tables. Also provides global title validation that checks -all feature, rule, and scenario titles for uniqueness, character set, and word -count constraints. -""" - from __future__ import annotations -import builtins -import keyword import re -from pathlib import Path +from typing import Any, cast -from gherkin import Parser +from gherkin.parser import Parser -from beehave.config import Config -from beehave.models import ( - ExamplesTable, - Literal, - ParsedStep, - Placeholder, - ScenarioInfo, - Violation, -) -_TITLE_RE = re.compile(r"^[\w\s]+$") -_PLACEHOLDER_RE = re.compile(r"<([^>]+)>") -_NUMERIC_LITERAL_RE = re.compile(r"^-?\d+$") -_QUOTED_STRING_RE = re.compile(r"""(?:"([^"]*)"|'([^']*)')""") +class Placeholder: + name: str -class GherkinError(Exception): - """Raised when a feature file is missing, malformed, or has invalid titles.""" +class DataTable: + headers: list[str] | None + rows: list[list[str]] -def _validate_title(title: str, kind: str, context: str = "") -> None: - if not title or not title.strip(): - raise GherkinError(f"{kind} title must be non-empty. {context}") - if not _TITLE_RE.match(title): - raise GherkinError( - f"{kind} title '{title}' contains invalid characters. " - f"Only Unicode letters, digits, and spaces are allowed. " - f"{context}" - ) +class Step: + keyword: str + text: str + placeholders: list[Placeholder] + docstring: str | None + data_table: DataTable | None -def _derive_path_slug(title: str) -> str: - return re.sub(r"\s+", "_", title.strip()).lower() +class Examples: + headers: list[str] + rows: list[dict[str, str]] -def _derive_function_name(title: str) -> str: - trimmed = title.strip() - collapsed = re.sub(r"\s+", "_", trimmed).lower() - name = f"test_{collapsed}" - if not name.isidentifier(): - raise GherkinError( - f"Derived function name '{name}' is not a valid Python " - f"identifier from scenario title '{title}'" - ) - return name +class Background: + steps: list[Step] -_derive_feature_path = _derive_rule_path = _derive_path_slug +class Scenario: + title: str + slug: str + function_name: str + tags: list[str] + keyword: str + steps: list[Step] + examples: Examples | None -def _extract_placeholders(text: str) -> tuple[Placeholder, ...]: - seen: set[str] = set() - result: list[Placeholder] = [] - for match in _PLACEHOLDER_RE.finditer(text): - name = match.group(1) - if name in seen: - continue - if not name.isidentifier(): - raise GherkinError( - f"Placeholder '<{name}>' is not a valid Python identifier" - ) - if keyword.iskeyword(name): - raise GherkinError(f"Placeholder '<{name}>' is a Python keyword") - if hasattr(builtins, name): - raise GherkinError(f"Placeholder '<{name}>' shadows a Python builtin") - seen.add(name) - result.append(Placeholder(name=name)) - return tuple(result) +class Rule: + name: str + tags: list[str] + background: Background | None + children: list[Scenario] -def _extract_literals(text: str) -> tuple[Literal, ...]: - """Extract numeric and quoted-string literals from step text. +class Feature: + name: str + tags: list[str] + background: Background | None + children: list[Rule | Scenario] - Quoted strings that match placeholder syntax (e.g. ``""``) are - skipped — they represent quotes around a placeholder, not a literal - value. - Args: - text: The text of a Gherkin step. +_PLACEHOLDER_RE = re.compile(r"<([^>]+)>") +_WHITESPACE_RE = re.compile(r"\s+") - Returns: - A tuple of ``Literal`` objects (may be empty). +_MIN_WORD_COUNT = 2 +_MAX_WORD_COUNT = 6 - """ - result: list[Literal] = [] - for token in text.split(): - if _NUMERIC_LITERAL_RE.match(token): - result.append(Literal(value=int(token), raw=token)) - for match in _QUOTED_STRING_RE.finditer(text): - value = match.group(1) if match.group(1) is not None else match.group(2) - if _PLACEHOLDER_RE.match(value): - continue - result.append(Literal(value=value, raw=match.group(0))) - return tuple(result) +def _make_placeholder(name: str) -> Placeholder: + p = Placeholder() + p.name = name + return p -def _parse_step(step_data: dict) -> ParsedStep: - text = step_data["text"] - return ParsedStep( - keyword=step_data["keyword"].strip(), - text=text, - placeholders=_extract_placeholders(text), - literals=_extract_literals(text), - line=step_data["location"]["line"], - ) +def _make_data_table(headers: list[str] | None, rows: list[list[str]]) -> DataTable: + dt = DataTable() + dt.headers = headers + dt.rows = rows + return dt -def _parse_examples( - examples_list: list[dict], -) -> ExamplesTable | None: - if not examples_list: - return None - ex = examples_list[0] - headers = tuple(h["value"] for h in ex.get("tableHeader", {}).get("cells", [])) - rows: list[tuple[str, ...]] = [] - for row in ex.get("tableBody", []): - rows.append(tuple(cell["value"] for cell in row["cells"])) - if not rows: - return None - return ExamplesTable(headers=headers, rows=tuple(rows)) +def _make_step( + *, + keyword: str, + text: str, + placeholders: list[Placeholder], + docstring: str | None, + data_table: DataTable | None, +) -> Step: + s = Step() + s.keyword = keyword + s.text = text + s.placeholders = placeholders + s.docstring = docstring + s.data_table = data_table + return s + + +def _make_examples(headers: list[str], rows: list[dict[str, str]]) -> Examples: + e = Examples() + e.headers = headers + e.rows = rows + return e -def _check_no_placeholders(steps: list[ParsedStep]) -> None: - for step in steps: - if step.placeholders: - raise GherkinError( - f"Background step '{step.text}' contains placeholder " - f"'<{step.placeholders[0].name}>'. " - f"Background steps must contain no placeholders." - ) +def _make_background(steps: list[Step]) -> Background: + b = Background() + b.steps = steps + return b -def _collect_placeholders( - steps: list[ParsedStep], -) -> tuple[Placeholder, ...]: - all_ph: list[Placeholder] = [] + +def _make_scenario( + *, + title: str, + slug: str, + function_name: str, + tags: list[str], + keyword: str, + steps: list[Step], + examples: Examples | None, +) -> Scenario: + s = Scenario() + s.title = title + s.slug = slug + s.function_name = function_name + s.tags = tags + s.keyword = keyword + s.steps = steps + s.examples = examples + return s + + +def _make_rule( + *, + name: str, + tags: list[str], + background: Background | None, + children: list[Scenario], +) -> Rule: + r = Rule() + r.name = name + r.tags = tags + r.background = background + r.children = children + return r + + +def _make_feature( + *, + name: str, + tags: list[str], + background: Background | None, + children: list[Rule | Scenario], +) -> Feature: + f = Feature() + f.name = name + f.tags = tags + f.background = background + f.children = children + return f + + +def _placeholders_from(text: str) -> list[Placeholder]: seen: set[str] = set() - for step in steps: - for ph in step.placeholders: - if ph.name not in seen: - seen.add(ph.name) - all_ph.append(ph) - return tuple(all_ph) - - -def _collect_literals( - scenario_steps: list[ParsedStep], - bg_steps: list[ParsedStep], - check_numeric: bool, - check_string: bool, -) -> tuple[Literal, ...]: - literals: list[Literal] = [] - for step in scenario_steps: - literals.extend(step.literals) - for step in bg_steps: - for lit in step.literals: - if (isinstance(lit.value, str) and check_string) or ( - isinstance(lit.value, int) and check_numeric - ): - literals.append(lit) - return tuple(literals) - - -def _build_scenario( - sc: dict, - feature_title: str, - feature_path: str, - rule_path: str, - feature_bg: list[ParsedStep], - rule_bg: list[ParsedStep], - check_numeric: bool, - check_string: bool, - seen_fn: dict[str, str], - skip_title_validation: bool = False, -) -> ScenarioInfo: - title = sc["name"] - if not skip_title_validation: - _validate_title(title, "Scenario", f"Feature: {feature_title}") - - function_name = _derive_function_name(title) - if function_name in seen_fn: - raise GherkinError( - f"Scenario title '{title}' produces function name " - f"'{function_name}' which collides with scenario in " - f"feature '{seen_fn[function_name]}'" - ) - seen_fn[function_name] = feature_title + result: list[Placeholder] = [] + for match in _PLACEHOLDER_RE.finditer(text): + name = match.group(1) + if name in seen: + continue + seen.add(name) + result.append(_make_placeholder(name)) + return result - steps = [_parse_step(s) for s in sc.get("steps", [])] - merged = feature_bg + rule_bg + steps - examples = _parse_examples(sc.get("examples", [])) - is_outline = bool(examples) +def _data_table_from(data: dict[str, Any]) -> DataTable: + rows_raw = data.get("rows") or [] + cells = [[c["value"] for c in row.get("cells", [])] for row in rows_raw] + if not cells: + return _make_data_table(headers=None, rows=[]) + return _make_data_table(headers=cells[0], rows=cells[1:]) - if is_outline and not examples.rows: - raise GherkinError( - f"Scenario Outline '{title}' must have at least one " - f"Examples table with at least one data row" - ) - return ScenarioInfo( - title=title, - function_name=function_name, - steps=tuple(merged), - placeholders=_collect_placeholders(merged), - literals=_collect_literals( - steps, feature_bg + rule_bg, check_numeric, check_string - ), - examples=examples, - is_outline=is_outline, - feature_title=feature_title, - feature_path=feature_path, - rule_path=rule_path, - line=sc["location"]["line"], +def _step_from(data: dict[str, Any]) -> Step: + text = data["text"] + dt = data.get("dataTable") + doc_string = data.get("docString") + return _make_step( + keyword=data["keyword"].strip(), + text=text, + placeholders=_placeholders_from(text), + docstring=doc_string.get("content") if doc_string else None, + data_table=_data_table_from(dt) if dt else None, ) -def _collect_scenarios_from_children( - children: list[dict], - feature_title: str, - feature_path: str, - feature_bg: list[ParsedStep], - check_numeric: bool, - check_string: bool, - seen_fn: dict[str, str], - skip_title_validation: bool = False, -) -> list[ScenarioInfo]: - scenarios: list[ScenarioInfo] = [] - for child in children: - if "background" in child: - continue +def _background_from(data: dict[str, Any]) -> Background: + return _make_background(steps=[_step_from(s) for s in data.get("steps", [])]) - if "scenario" in child: - sc = _build_scenario( - sc=child["scenario"], - feature_title=feature_title, - feature_path=feature_path, - rule_path="default_test", - feature_bg=feature_bg, - rule_bg=[], - check_numeric=check_numeric, - check_string=check_string, - seen_fn=seen_fn, - skip_title_validation=skip_title_validation, - ) - scenarios.append(sc) - - if "rule" in child: - rule = child["rule"] - rule_title = rule["name"] - if not skip_title_validation: - _validate_title(rule_title, "Rule", f"Feature: {feature_title}") - rp = _derive_rule_path(rule_title) + "_test" - - rule_bg: list[ParsedStep] = [] - for rc in rule.get("children", []): - if "background" in rc: - bg_steps = [ - _parse_step(s) for s in rc["background"].get("steps", []) - ] - _check_no_placeholders(bg_steps) - rule_bg = bg_steps - break - - for rc in rule.get("children", []): - if "scenario" in rc: - sc = _build_scenario( - sc=rc["scenario"], - feature_title=feature_title, - feature_path=feature_path, - rule_path=rp, - feature_bg=feature_bg, - rule_bg=rule_bg, - check_numeric=check_numeric, - check_string=check_string, - seen_fn=seen_fn, - skip_title_validation=skip_title_validation, - ) - scenarios.append(sc) - - return scenarios - - -def parse_feature( - feature_path: Path, - config: Config, - seen_function_names: dict[str, str] | None = None, - skip_title_validation: bool = False, -) -> dict[str, ScenarioInfo]: - """Parse a single .feature file into a dict of ScenarioInfo objects. - - Extracts steps, placeholders, literals, background steps, and examples - tables from every scenario and rule-scoped scenario. Title validation - runs by default but can be skipped for the inner parse pass used by - ``check_all`` (which already calls ``validate_all_titles`` globally). - - Args: - feature_path: Path to the ``.feature`` file. - config: The project configuration. - seen_function_names: Accumulator that tracks function-name collisions - across multiple parse calls. - skip_title_validation: When ``True``, per-title validation and duplicate - detection inside this parse call are suppressed. - - Returns: - A dict mapping function names to ``ScenarioInfo``. - - Raises: - GherkinError: If the file does not exist, cannot be parsed, or contains - no ``Feature:`` header. - - """ - if not feature_path.exists(): - raise GherkinError(f"Feature file not found: {feature_path}") - - try: - content = feature_path.read_text(encoding="utf-8") - doc = Parser().parse(content) - except Exception as e: - line = getattr(e, "line", 0) or 0 - raise GherkinError(f"{feature_path}:{line}: {e}") from e - - feature = doc.get("feature") - if not feature: - raise GherkinError(f"No feature found in {feature_path}") - - feature_title = feature["name"] - if not skip_title_validation: - _validate_title(feature_title, "Feature") - feature_path_str = _derive_feature_path(feature_title) - - if seen_function_names is None: - seen_function_names = {} - - feature_bg: list[ParsedStep] = [] - children = feature.get("children", []) - - for child in children: - if "background" in child: - bg_steps = [_parse_step(s) for s in child["background"].get("steps", [])] - _check_no_placeholders(bg_steps) - feature_bg = bg_steps - break - scenarios = _collect_scenarios_from_children( - children=children, - feature_title=feature_title, - feature_path=feature_path_str, - feature_bg=feature_bg, - check_numeric=config.background_check_numeric, - check_string=config.background_check_string, - seen_fn=seen_function_names, - skip_title_validation=skip_title_validation, - ) +def _examples_from(ex_list: list[dict[str, Any]]) -> Examples | None: + if not ex_list: + return None + ex = ex_list[0] + header_row = ex.get("tableHeader") or {} + headers = [c["value"] for c in header_row.get("cells", [])] + rows: list[dict[str, str]] = [] + for row in ex.get("tableBody", []): + cells = [c["value"] for c in row.get("cells", [])] + rows.append(dict(zip(headers, cells, strict=False))) + return _make_examples(headers=headers, rows=rows) - return {s.function_name: s for s in scenarios} +def _slug_from(title: str) -> str: + return _WHITESPACE_RE.sub("_", title.strip()).lower() -def _validate_single_title( - title: str, - kind: str, - path: str, - line: int, - violations: list[Violation], -) -> bool: - """Validate one title against the project rules. - - Rules enforced: - 1. Title must be non-empty after stripping. - 2. Title must match ``_TITLE_RE`` (Unicode letters, digits, spaces only). - 3. Title must contain 2-6 words. - - Args: - title: The raw title string from the feature file. - kind: One of ``"feature"``, ``"rule"``, ``"scenario"``. - path: The feature file path (for error reporting). - line: The source line number (for error reporting). - violations: Mutable list that receives ``Violation`` objects for - every rule that fails. - - Returns: - ``True`` if the title passes all checks, ``False`` otherwise. - - """ - error_type = f"invalid-{kind}-title" +def _validate_single_title(title: str, kind: str) -> None: if not title or not title.strip(): - violations.append( - Violation( - path=path, - line=line, - error_type=error_type, - message=f"{kind.capitalize()} title must be non-empty.", + raise ValueError(f"{kind} title must be non-empty") + for ch in title: + if not (ch.isspace() or ch.isalpha() or ch.isdigit()): + raise ValueError( + f"{kind} title {title!r} contains invalid character {ch!r}; " + f"only Unicode letters, digits, and spaces are allowed" ) - ) - return False - - if not _TITLE_RE.match(title): - violations.append( - Violation( - path=path, - line=line, - error_type=error_type, - message=( - f"{kind.capitalize()} title '{title}' contains " - f"invalid characters. Only Unicode letters, digits, " - f"and spaces are allowed." - ), - ) - ) - return False - words = title.split() - if len(words) < 2: - violations.append( - Violation( - path=path, - line=line, - error_type=error_type, - message=( - f"{kind.capitalize()} title '{title}' has " - f"{len(words)} word(s). Minimum 2 words required." - ), - ) + if len(words) < _MIN_WORD_COUNT or len(words) > _MAX_WORD_COUNT: + raise ValueError( + f"{kind} title {title!r} has {len(words)} word(s); " + f"must be {_MIN_WORD_COUNT}-{_MAX_WORD_COUNT}" ) - return False - if len(words) > 6: - violations.append( - Violation( - path=path, - line=line, - error_type=error_type, - message=( - f"{kind.capitalize()} title '{title}' has " - f"{len(words)} words. Maximum 6 words allowed." - ), + + +def _reject_duplicate(title: str, kind: str, seen: set[str]) -> None: + key = title.strip().lower() + if key in seen: + raise ValueError(f"Duplicate {kind} title {title!r} (case-insensitive match)") + seen.add(key) + + +def _reject_background_placeholders(background: Background) -> None: + for step in background.steps: + if step.placeholders: + raise ValueError( + f"Background step {step.text!r} contains placeholder " + f"<{step.placeholders[0].name}>; " + f"background steps must be placeholder-free" ) - ) - return False - return True + +def _first_background_from(children: list[dict[str, Any]]) -> Background | None: + for child in children: + if "background" in child: + bg = _background_from(child["background"]) + _reject_background_placeholders(bg) + return bg + return None + + +def _scenario_from( + data: dict[str, Any], + merged_steps: list[Step], + seen: set[str], +) -> Scenario: + title = data["name"] + _validate_single_title(title, "scenario") + _reject_duplicate(title, "scenario", seen) + slug = _slug_from(title) + own_steps = [_step_from(s) for s in data.get("steps", [])] + return _make_scenario( + title=title, + slug=slug, + function_name=f"test_{slug}", + tags=[t["name"] for t in data.get("tags", [])], + keyword=data["keyword"], + steps=[*merged_steps, *own_steps], + examples=_examples_from(data.get("examples", [])), + ) -def _register_title( - title: str, - kind: str, - path: str, - line: int, - seen: dict[str, list[tuple[str, str, str, int]]], -) -> None: - """Record a validated title in the case-insensitive duplicate tracker. - - Titles are normalised to lower case for comparison so ``"Hive Activity"`` - and ``"hive activity"`` map to the same key. - - Args: - title: The validated title string. - kind: One of ``"feature"``, ``"rule"``, ``"scenario"``. - path: The feature file path. - line: The source line number. - seen: Mutable dict that accumulates title entries keyed by lower-cased - title. - - """ - key = title.strip().lower() - seen.setdefault(key, []).append((title.strip(), kind, path, line)) - - -def _emit_duplicates( - seen: dict[str, list[tuple[str, str, str, int]]], - violations: list[Violation], -) -> None: - """Emit duplicate-title violations from the accumulated registry. - - When the same title key is used by more than one entity, a violation is - reported for the *lowest-priority* kind (scenario preferred over rule, - rule over feature) so that one logical "owner" is considered the - original and the others are flagged as duplicates. - - Args: - seen: Accumulator dict from ``_register_title``. - violations: Mutable list that receives ``Violation`` objects. - - """ - kind_priority = {"scenario": 0, "rule": 1, "feature": 2} - - for entries in seen.values(): - if len(entries) > 1: - kinds = {kind for _, kind, _, _ in entries} - report_kind = min(kinds, key=lambda k: kind_priority[k]) - - for title, kind, path, line in entries: - if kind != report_kind: - continue - violations.append( - Violation( - path=path, - line=line, - error_type=f"duplicate-{kind}-title", - message=( - f"Duplicate {kind} title '{title}' " - f"(case-insensitive match with " - f"'{entries[0][0]}')." - ), - ) +def _rule_from( + data: dict[str, Any], + feature_bg_steps: list[Step], + seen: set[str], +) -> Rule: + name = data["name"] + _reject_duplicate(name, "rule", seen) + rule_bg = _first_background_from(data.get("children", [])) + rule_bg_steps = rule_bg.steps if rule_bg else [] + children: list[Scenario] = [] + for rc in data.get("children", []): + if "scenario" in rc: + children.append( + _scenario_from( + rc["scenario"], + [*feature_bg_steps, *rule_bg_steps], + seen, ) + ) + return _make_rule( + name=name, + tags=[t["name"] for t in data.get("tags", [])], + background=rule_bg, + children=children, + ) -def detect_empty_rules(doc: dict) -> bool: - """Return True if the feature document has any rules with no scenarios. - - A rule is "empty" when it has no scenario children. Features with no - rules at all return ``False`` — only rules that exist but lack scenarios - are considered empty. - - Args: - doc: The parsed feature document dict from ``Parser().parse()``. - - Returns: - ``True`` if at least one rule exists with zero scenario children. - - """ - feature = doc.get("feature") - if not feature: - return False - for child in feature.get("children", []): - if "rule" in child: - rule = child["rule"] - rule_children = rule.get("children", []) - if not any("scenario" in rc for rc in rule_children): - return True - return False - - -def validate_all_titles(config: Config) -> list[Violation]: - """Validate all titles across every ``.feature`` file in the project. - - Scans the features directory, extracts every Feature, Rule, and Scenario - title, and checks: - - Character-set validity (``_TITLE_RE``). - - Non-empty requirement. - - Word-count bounds (2-6 words). - - Case-insensitive uniqueness across the whole project. - - Used as a pre-flight gate in ``generate_stubs`` and as the final step in - ``check_all``. - - Args: - config: The project configuration. - - Returns: - A (possibly empty) list of ``Violation`` objects. Each violation - carries an ``error_type`` of ``invalid-{kind}-title`` or - ``duplicate-{kind}-title``. - - """ - features_dir = Path(config.features_dir) - if not features_dir.is_dir(): - return [] - - violations: list[Violation] = [] - seen: dict[str, list[tuple[str, str, str, int]]] = {} - - for feature_path in sorted(features_dir.rglob("*.feature")): - try: - content = feature_path.read_text(encoding="utf-8") - doc = Parser().parse(content) - except Exception as e: - line = getattr(e, "line", 0) or 0 - raise GherkinError(f"{feature_path}:{line}: {e}") from e - - feature = doc.get("feature") - if not feature: - raise GherkinError(f"No feature found in {feature_path}") - - feature_title = feature["name"] - feature_line = feature.get("location", {}).get("line", 1) - if _validate_single_title( - feature_title, "feature", str(feature_path), feature_line, violations - ): - _register_title( - feature_title, "feature", str(feature_path), feature_line, seen - ) +def parse_feature(source: str) -> Feature: + doc = cast(dict[str, Any], Parser().parse(source)) + feature_data = cast(dict[str, Any], doc.get("feature") or {}) - for child in feature.get("children", []): - if "rule" in child: - rule = child["rule"] - rule_title = rule["name"] - rule_line = rule.get("location", {}).get("line", 1) - if _validate_single_title( - rule_title, "rule", str(feature_path), rule_line, violations - ): - _register_title( - rule_title, "rule", str(feature_path), rule_line, seen - ) - for rc in rule.get("children", []): - if "scenario" in rc: - sc = rc["scenario"] - sc_title = sc["name"] - sc_line = sc.get("location", {}).get("line", 1) - if _validate_single_title( - sc_title, - "scenario", - str(feature_path), - sc_line, - violations, - ): - _register_title( - sc_title, - "scenario", - str(feature_path), - sc_line, - seen, - ) - elif "scenario" in child: - sc = child["scenario"] - sc_title = sc["name"] - sc_line = sc.get("location", {}).get("line", 1) - if _validate_single_title( - sc_title, "scenario", str(feature_path), sc_line, violations - ): - _register_title( - sc_title, "scenario", str(feature_path), sc_line, seen - ) - - _emit_duplicates(seen, violations) - return violations + background = _first_background_from(feature_data.get("children", [])) + feature_bg_steps = background.steps if background else [] + + # Title rules enforce at parse time on every scenario title (charset + + # 2-6 word count + case-insensitive uniqueness). Feature and Rule names + # join the case-insensitive uniqueness set (data-model §2.1/§2.2 carry + # only a non-empty constraint, so charset/word-count do not apply to + # them — `Feature: Parsing` and `Feature: T` in the test fixtures are + # both one word). Background placeholder rejection is independent. + seen: set[str] = set() + feature_name = feature_data.get("name", "") + if feature_name: + seen.add(feature_name.strip().lower()) + + children: list[Rule | Scenario] = [] + for child in feature_data.get("children", []): + if "scenario" in child: + children.append(_scenario_from(child["scenario"], feature_bg_steps, seen)) + elif "rule" in child: + children.append(_rule_from(child["rule"], feature_bg_steps, seen)) + + return _make_feature( + name=feature_name, + tags=[t["name"] for t in feature_data.get("tags", [])], + background=background, + children=children, + ) diff --git a/beehave/models.py b/beehave/models.py deleted file mode 100644 index 73a8870..0000000 --- a/beehave/models.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -import re -from dataclasses import dataclass - - -@dataclass(frozen=True) -class Placeholder: - name: str - - -@dataclass(frozen=True) -class Literal: - value: str | int | float | bool - raw: str - - -@dataclass(frozen=True) -class ParsedStep: - keyword: str - text: str - placeholders: tuple[Placeholder, ...] = () - literals: tuple[Literal, ...] = () - line: int = 0 - - -@dataclass(frozen=True) -class ExamplesTable: - headers: tuple[str, ...] - rows: tuple[tuple[str, ...], ...] - - -@dataclass(frozen=True) -class ScenarioInfo: - title: str - function_name: str - steps: tuple[ParsedStep, ...] - placeholders: tuple[Placeholder, ...] - literals: tuple[Literal, ...] - examples: ExamplesTable | None - is_outline: bool - feature_title: str - feature_path: str - rule_path: str - line: int = 0 - - -@dataclass(frozen=True) -class TestInfo: - function_name: str - given_kwargs: tuple[str, ...] = () - example_rows: tuple[dict[str, object], ...] = () - body_name_nodes: tuple[str, ...] = () - body_constant_nodes: tuple[object, ...] = () - is_stub: bool = False - line: int = 0 - - -@dataclass(frozen=True) -class Violation: - path: str - line: int - error_type: str - message: str - is_warning: bool = False - - def __str__(self) -> str: - prefix = "warning" if self.is_warning else "error" - return f"{self.path}:{self.line}: {self.error_type}: {self.message} ({prefix})" - - -def coerce_example_value(cell: str) -> object: - if re.match(r"^-?\d+$", cell): - return int(cell) - if re.match(r"^-?\d+\.\d+$", cell): - return float(cell) - if cell.lower() == "true": - return True - if cell.lower() == "false": - return False - if cell.startswith('"') and cell.endswith('"'): - return cell[1:-1] - return cell diff --git a/tests/features/case_insensitive_matching/__init__.py b/beehave/py.typed similarity index 100% rename from tests/features/case_insensitive_matching/__init__.py rename to beehave/py.typed diff --git a/beehave/status.py b/beehave/status.py index 31ff762..b6c7aa2 100644 --- a/beehave/status.py +++ b/beehave/status.py @@ -1,394 +1,13 @@ from pathlib import Path -from gherkin import Parser -from beehave.check import check_pair -from beehave.config import Config -from beehave.discover import DiscoverError, discover_tests -from beehave.gherkin import detect_empty_rules, parse_feature - - -def compute_status( - config: Config, include_unmapped: bool = False, json_output: bool = False -) -> None: - features_dir = Path(config.features_dir) +def status(root: Path) -> int: + features_dir = root / "docs" / "features" if not features_dir.is_dir(): - if json_output: - import json as _json - - print( - _json.dumps( - {"error": f"features directory '{config.features_dir}' not found"} - ) - ) - else: - print( - f"Error: features directory '{config.features_dir}' not found", - file=__import__("sys").stderr, - ) - raise SystemExit(2) - - features = sorted(features_dir.rglob("*.feature")) - feature_slugs = {f.stem for f in features} - any_not_ok = False - - # Track function names across features for collision detection - all_fn_sources: dict[str, list[str]] = {} - - # JSON data collection - feature_data: list[dict] = [] - stage_counts: dict[str, int] = { - "ok": 0, - "broken": 0, - "no_scenarios": 0, - "needs_scenarios": 0, - "needs_tests": 0, - "needs_bodies": 0, - "needs_fixes": 0, - } - - def _emit(*args, **kwargs): - """Print or discard based on json_output mode.""" - if not json_output: - print(*args, **kwargs) - - first_feature = True - for feature_file in features: - slug = feature_file.stem - content = feature_file.read_text(encoding="utf-8") - try: - doc = Parser().parse(content) - except Exception as e: - any_not_ok = True - line = getattr(e, "line", 0) or 0 - if not first_feature and not json_output: - print() - first_feature = False - _emit(f"{slug} (Bad Scenario) broken") - _emit(f" {feature_file}:{line}: {e}") - if json_output: - feature_data.append( - { - "slug": slug, - "title": "Bad Scenario", - "stage": "broken", - "parse_error_message": str(e), - "scenarios": [], - } - ) - stage_counts["broken"] += 1 - continue - - feature_title = doc["feature"]["name"] - scenarios = parse_feature(feature_file, config) - - if not scenarios and not detect_empty_rules(doc): - any_not_ok = True - if not first_feature and not json_output: - print() - first_feature = False - _emit(f"{slug} ({feature_title}) no scenarios") - if json_output: - feature_data.append( - { - "slug": slug, - "title": feature_title, - "stage": "no scenarios", - "scenarios": [], - } - ) - stage_counts["no_scenarios"] += 1 - continue - elif not scenarios and detect_empty_rules(doc): - any_not_ok = True - if not first_feature and not json_output: - print() - first_feature = False - _emit(f"{slug} ({feature_title}) needs scenarios") - if json_output: - feature_data.append( - { - "slug": slug, - "title": feature_title, - "stage": "needs scenarios", - "scenarios": [], - } - ) - stage_counts["needs_scenarios"] += 1 - continue - - rule_paths = {si.rule_path for si in scenarios.values()} - feature_path = next(iter(scenarios.values())).feature_path - test_dir = Path(config.tests_dir) / feature_path - - # Extract rule titles from the Gherkin doc - rule_titles: dict[str, str] = {} - for child in doc["feature"].get("children", []): - if "rule" in child: - rule_title = child["rule"]["name"] - slug_key = rule_title.strip().lower().replace(" ", "_") - rule_titles[slug_key + "_test"] = rule_title - - # Collect scenario-level statuses and violations - scenario_statuses: dict[str, str] = {} - scenario_violations: dict[str, list] = {} - for rp in rule_paths: - test_file = test_dir / f"{rp}.py" - tests: dict = {} - if test_file.exists(): - try: - tests = discover_tests(test_file) - except DiscoverError: - tests = {} - for fn in tests: - all_fn_sources.setdefault(fn, []).append(slug) - for fn, si in scenarios.items(): - if si.rule_path != rp: - continue - ti = tests.get(fn) - if ti is None: - scenario_statuses[fn] = "no test" - elif ti.is_stub: - scenario_statuses[fn] = "no body" - else: - violations = check_pair(si, ti, str(test_file), feature_file.name) - if violations: - scenario_violations[fn] = violations - scenario_statuses[fn] = f"{len(violations)} errors" - else: - scenario_statuses[fn] = "ok" - - unmapped_count = sum(1 for s in scenario_statuses.values() if s == "no test") - all_stubs = all(s == "no body" for s in scenario_statuses.values()) - has_violation = any("errors" in s for s in scenario_statuses.values()) - - if unmapped_count > 0: - status_label = "needs tests" - elif all_stubs: - status_label = "needs bodies" - elif has_violation: - status_label = "needs fixes" - else: - status_label = "ok" - - if status_label != "ok": - any_not_ok = True - - if not first_feature and not json_output: - print() - first_feature = False - _emit(f"{slug} ({feature_title}) {status_label}") - - # Tree output for non-ok features (text mode only) - if status_label != "ok" and not json_output: - _print_tree( - scenarios, - scenario_statuses, - scenario_violations, - rule_paths, - rule_titles, - ) - - if json_output: - sc_list: list[dict] = [] - for fn, si in scenarios.items(): - sc_status = scenario_statuses.get(fn, "unknown") - entry = { - "title": si.title, - "function_name": fn, - "status": sc_status, - } - if fn in scenario_violations: - entry["violations"] = [ - {"type": v.error_type, "message": v.message} - for v in scenario_violations[fn] - ] - sc_list.append(entry) - - feature_data.append( - { - "slug": slug, - "title": feature_title, - "stage": status_label, - "scenarios": sc_list, - } - ) - stage_key = status_label.replace(" ", "_") - stage_counts[stage_key] = stage_counts.get(stage_key, 0) + 1 - - # Report unmapped test directories if flagged - unmapped_dirs: list[str] = [] - if include_unmapped: - tests_dir = Path(config.tests_dir) - if tests_dir.is_dir(): - for test_subdir in sorted(tests_dir.iterdir()): - if test_subdir.is_dir() and test_subdir.name not in feature_slugs: - unmapped_dirs.append(test_subdir.name) - - # Report collisions - collisions = {fn: slugs for fn, slugs in all_fn_sources.items() if len(slugs) > 1} - - if json_output: - result = { - "features": feature_data, - "summary": { - "total_features": len(feature_data), - **{ - k: stage_counts.get(k, 0) - for k in [ - "ok", - "broken", - "no_scenarios", - "needs_scenarios", - "needs_tests", - "needs_bodies", - "needs_fixes", - ] - }, - }, - "unmapped_directories": unmapped_dirs, - "collisions": [ - {"function_name": fn, "feature_slugs": slugs} - for fn, slugs in sorted(collisions.items()) - ], - } - import json as _json - - print(_json.dumps(result)) - else: - if unmapped_dirs: - if not first_feature: - print() - for name in unmapped_dirs: - print(f"unmapped: {name}") - - if collisions: - if not first_feature or unmapped_dirs: - print() - for fn, slugs in sorted(collisions.items()): - print(f"collision: {fn} appears in {', '.join(slugs)}") - - raise SystemExit(1 if any_not_ok else 0) - - -def _rule_aggregation(rule_statuses: dict[str, str]) -> str: - """Build comma-joined aggregation string for a rule's scenario statuses.""" - counts: dict[str, int] = {} - total_errors = 0 - for status in rule_statuses.values(): - if status == "ok": - continue - if "errors" in status: - parts = status.split() - try: - total_errors += int(parts[0]) - except ValueError, IndexError: - counts[status] = counts.get(status, 0) + 1 - else: - counts[status] = counts.get(status, 0) + 1 - - parts: list[str] = [] - for status in sorted(counts): - parts.append(f"{counts[status]} {status}") - if total_errors > 0: - word = "error" if total_errors == 1 else "errors" - parts.append(f"{total_errors} {word}") - return ", ".join(parts) - - -def _print_tree( - scenarios: dict, - scenario_statuses: dict[str, str], - scenario_violations: dict[str, list], - rule_paths: set[str], - rule_titles: dict[str, str], -) -> None: - # Group scenarios by rule_path - by_rule: dict[str, dict[str, str]] = {} - for fn, si in scenarios.items(): - by_rule.setdefault(si.rule_path, {})[fn] = scenario_statuses[fn] - - sorted_rules = sorted(by_rule.keys()) - - # If all scenarios are under "default_test", just show them directly - if sorted_rules == ["default_test"]: - scenarios_in_rule = by_rule["default_test"] - sorted_fns = sorted(scenarios_in_rule.keys()) - for idx, fn in enumerate(sorted_fns): - sn = scenarios[fn] - status = scenario_statuses[fn] - extra = _violation_codes(scenario_violations.get(fn)) - is_last = idx == len(sorted_fns) - 1 - prefix = " └──" if is_last else " ├──" - line = f"{prefix} Scenario: {sn.title} {status}" - if extra: - line += f" {extra}" - print(line) - return - - # Multiple rules or single non-default rule: show rule nodes - for rule_idx, rp in enumerate(sorted_rules): - scenarios_in_rule = by_rule[rp] - - # Get rule title from doc or derive from rule_path - rule_title = rule_titles.get(rp) - if rule_title is None: - if rp.endswith("_test"): - rule_title = rp[:-5].replace("_", " ").title() - else: - rule_title = rp.replace("_", " ").title() - - # Build aggregation for this rule - rule_statuses: dict[str, str] = {} - for fn in scenarios_in_rule: - if fn in scenario_statuses: - rule_statuses[fn] = scenario_statuses[fn] - - agg = _rule_aggregation(rule_statuses) - is_last_rule = rule_idx == len(sorted_rules) - 1 - rule_prefix = " └──" if is_last_rule else " ├──" - - if agg: - print(f"{rule_prefix} Rule: {rule_title} ({agg})") - else: - print(f"{rule_prefix} Rule: {rule_title}") - - sorted_fns = sorted(scenarios_in_rule.keys()) - for sc_idx, fn in enumerate(sorted_fns): - sn = scenarios[fn] - status = scenario_statuses[fn] - extra = _violation_codes(scenario_violations.get(fn)) - is_last_sc = sc_idx == len(sorted_fns) - 1 - - if is_last_rule: - sc_prefix = " " + ("└──" if is_last_sc else "├──") - else: - sc_prefix = " │ " + ("└──" if is_last_sc else "├──") - - line = f"{sc_prefix} Scenario: {sn.title} {status}" - if extra: - line += f" {extra}" - print(line) - - -def _violation_codes(violations: list | None) -> str: - """Extract comma-joined violation identifiers for inline display.""" - if not violations: - return "" - codes: list[str] = [] - for v in violations: - error_type = v.error_type - if error_type == "missing-placeholder": - # Message format: "'name' not found in function body" - msg = v.message - if "'" in msg: - name = msg.split("'")[1] - codes.append(name) - elif error_type == "missing-literal": - # Message format: "literal 'value' not found..." - msg = v.message - if msg.startswith("literal "): - rest = msg[len("literal ") :] - name = rest.split()[0].strip("'\"") - codes.append(name) - return ", ".join(codes) + return 2 + feature_count = len(list(features_dir.glob("*.feature"))) + tests_dir = root / "tests" + stub_count = len(list(tests_dir.glob("*_test.pyi"))) if tests_dir.is_dir() else 0 + print(f"{feature_count} feature file(s)") + print(f"{stub_count} stub file(s)") + return 0 diff --git a/beehave/step.py b/beehave/step.py new file mode 100644 index 0000000..826204e --- /dev/null +++ b/beehave/step.py @@ -0,0 +1,16 @@ +from collections.abc import Iterator +from contextlib import contextmanager + + +@contextmanager +def step( + keyword: str, + text: str, + /, + **placeholders: object, +) -> Iterator[None]: + try: + yield + except Exception as e: + e.add_note(f"{keyword} {text}") + raise diff --git a/pyproject.toml b/pyproject.toml index d2aaf91..29c0022 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "beehave" -version = "1.0.0" +version = "2.0.0" description = "A thin layer on Hypothesis for Gherkin-style BDD testing with vocabulary enforcement" readme = "README.md" requires-python = ">=3.14" @@ -25,13 +25,14 @@ dev = [ "beehave", "flowr[viz]>=1.1.0", "hypothesis>=6.152.7", + "mypy>=2.3.0", "pytest>=8.0", - "pytest-beehave>=0.2.2", "ruff>=0.11", + "taskipy>=1.14.1", ] [tool.ruff.lint] -select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"] +select = ["E", "F", "W", "I", "N", "UP", "B"] preview = true [tool.ruff.lint.per-file-ignores] @@ -44,3 +45,14 @@ python_functions = ["test_*"] [tool.uv.sources] beehave = { workspace = true } + +[tool.mypy] +python_version = "3.14" +strict = true + +[tool.taskipy.tasks] +test = "pytest" +test-fast = "pytest -x -q" +lint = "ruff check ." +lint-merge = "ruff check --select E,F,W,I,N,UP,B,SIM,RUF --preview . && ruff format --check ." +stubtest = "python -m mypy.stubtest beehave --allowlist .stubtest_allowlist" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 2c2d487..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -import os -import textwrap -from collections.abc import Generator -from pathlib import Path - -import pytest - -from beehave.config import Config - - -@pytest.fixture -def tmp_project(tmp_path: Path) -> Generator[Path]: - features = tmp_path / "docs" / "features" - tests = tmp_path / "tests" / "features" - features.mkdir(parents=True) - tests.mkdir(parents=True) - old = os.getcwd() - os.chdir(tmp_path) - yield tmp_path - os.chdir(old) - - -@pytest.fixture -def config(tmp_project: Path) -> Config: - return Config( - features_dir=str(tmp_project / "docs" / "features"), - tests_dir=str(tmp_project / "tests" / "features"), - ) - - -def write_feature( - tmp_project: Path, - name: str, - content: str, -) -> Path: - features_dir = tmp_project / "docs" / "features" - parts = name.rsplit("/", 1) - if len(parts) == 2: - features_dir = features_dir / parts[0] - features_dir.mkdir(parents=True, exist_ok=True) - fname = parts[1] - else: - fname = parts[0] - p = features_dir / f"{fname}.feature" - p.write_text(textwrap.dedent(content), encoding="utf-8") - return p - - -def write_test( - tmp_project: Path, - feature_dir: str, - filename: str, - source: str, -) -> Path: - d = tmp_project / "tests" / "features" / feature_dir - d.mkdir(parents=True, exist_ok=True) - p = d / filename - p.write_text(textwrap.dedent(source), encoding="utf-8") - return p diff --git a/tests/e2e/check_test.py b/tests/e2e/check_test.py index 97e6514..ad49a7b 100644 --- a/tests/e2e/check_test.py +++ b/tests/e2e/check_test.py @@ -3,14 +3,12 @@ import shutil from pathlib import Path -import pytest - HIVE_ACTIVITY_FEATURE = "hive_activity.feature" COMB_CONSTRUCTION_FEATURE = "comb_construction.feature" def copy_feature_into_pytester(pytester, basename: str) -> str: - src = Path("docs") / "features" / basename + src = Path(__file__).resolve().parents[2] / "docs" / "features" / basename dst = pytester.path / "docs" / "features" / basename dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy(src, dst) @@ -35,14 +33,12 @@ def run_beehave_check(pytester, *args: str) -> int: return pytester.run("beehave", "check", *args).ret -@pytest.mark.pending def test_passes_when_blocks_match_steps(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) pytester.run("beehave", "generate") assert run_beehave_check(pytester) == 0 -@pytest.mark.pending def test_fails_when_block_count_differs_from_step_count(pytester) -> None: feature_text = ( "Feature: Minimal\n" @@ -62,7 +58,6 @@ def test_fails_when_block_count_differs_from_step_count(pytester) -> None: assert run_beehave_check(pytester) != 0 -@pytest.mark.pending def test_fails_when_step_keyword_structurally_mismatches(pytester) -> None: feature_text = ( "Feature: Minimal\n" @@ -84,7 +79,6 @@ def test_fails_when_step_keyword_structurally_mismatches(pytester) -> None: assert run_beehave_check(pytester) != 0 -@pytest.mark.pending def test_fails_when_step_text_mismatches(pytester) -> None: feature_text = ( "Feature: Minimal\n" @@ -106,7 +100,6 @@ def test_fails_when_step_text_mismatches(pytester) -> None: assert run_beehave_check(pytester) != 0 -@pytest.mark.pending def test_fails_when_placeholder_name_set_mismatches(pytester) -> None: feature_text = ( "Feature: Minimal\n" @@ -132,7 +125,6 @@ def test_fails_when_placeholder_name_set_mismatches(pytester) -> None: assert run_beehave_check(pytester) != 0 -@pytest.mark.pending def test_passes_when_keyword_case_differs(pytester) -> None: feature_text = ( "Feature: Minimal\n" @@ -154,7 +146,6 @@ def test_passes_when_keyword_case_differs(pytester) -> None: assert run_beehave_check(pytester) == 0 -@pytest.mark.pending def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: feature_text = ( "Feature: Minimal\n" @@ -177,7 +168,6 @@ def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: assert run_beehave_check(pytester) == 0 -@pytest.mark.pending def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: feature_text = ( "Feature: Minimal\n" diff --git a/tests/e2e/generate_test.py b/tests/e2e/generate_test.py index e86075b..5523ed7 100644 --- a/tests/e2e/generate_test.py +++ b/tests/e2e/generate_test.py @@ -3,8 +3,6 @@ import shutil from pathlib import Path -import pytest - HIVE_ACTIVITY_FEATURE = "hive_activity.feature" COMB_CONSTRUCTION_FEATURE = "comb_construction.feature" TITLE_VALIDATION_FEATURE = "title_validation.feature" @@ -16,7 +14,7 @@ def copy_feature_into_pytester(pytester, basename: str) -> str: - src = Path("docs") / "features" / basename + src = Path(__file__).resolve().parents[2] / "docs" / "features" / basename dst = pytester.path / "docs" / "features" / basename dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy(src, dst) @@ -60,7 +58,6 @@ def list_emitted_stems(pytester) -> list[str]: return sorted(stems) -@pytest.mark.pending def test_emits_pyi_for_default_group_and_each_rule(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) run_beehave_generate(pytester) @@ -70,7 +67,6 @@ def test_emits_pyi_for_default_group_and_each_rule(pytester) -> None: assert "hive_activity_hive_foraging" in stems -@pytest.mark.pending def test_always_emits_pyi_file_for_every_rule_and_default(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) exit_code = run_beehave_generate(pytester) @@ -81,7 +77,6 @@ def test_always_emits_pyi_file_for_every_rule_and_default(pytester) -> None: assert read_emitted_pyi(pytester, stem) != "" -@pytest.mark.pending def test_emits_py_skeleton_only_when_py_absent(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) run_beehave_generate(pytester) @@ -91,7 +86,6 @@ def test_emits_py_skeleton_only_when_py_absent(pytester) -> None: assert first_emission == second_emission -@pytest.mark.pending def test_scenario_title_emits_test_underscore_slug_function(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) run_beehave_generate(pytester) @@ -99,7 +93,6 @@ def test_scenario_title_emits_test_underscore_slug_function(pytester) -> None: assert "def test_guard_bee_inspects_visitor" in pyi -@pytest.mark.pending def test_function_name_carries_no_uppercase_and_collapses_whitespace(pytester) -> None: feature_text = ( "Feature: Whitespace\n" @@ -112,7 +105,6 @@ def test_function_name_carries_no_uppercase_and_collapses_whitespace(pytester) - assert "def test_mixedcase_title_with_spaces" in pyi -@pytest.mark.pending def test_feature_background_steps_appear_in_every_emitted_scenario(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) run_beehave_generate(pytester) @@ -125,7 +117,6 @@ def test_feature_background_steps_appear_in_every_emitted_scenario(pytester) -> assert background_text in foraging_py -@pytest.mark.pending def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) run_beehave_generate(pytester) @@ -138,7 +129,6 @@ def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> N assert rule_background_text not in default_py -@pytest.mark.pending def test_tags_do_not_surface_in_emitted_pyi_or_step_blocks(pytester) -> None: feature_text = ( "@unique_tag_marker\n" @@ -154,7 +144,6 @@ def test_tags_do_not_surface_in_emitted_pyi_or_step_blocks(pytester) -> None: assert "unique_tag_marker" not in py_text -@pytest.mark.pending def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: feature_text = ( "Feature: Docstring\n" @@ -170,7 +159,6 @@ def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: assert "unique docstring marker text" not in pyi -@pytest.mark.pending def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: feature_text = ( "Feature: DataTable\n" diff --git a/tests/e2e/status_test.py b/tests/e2e/status_test.py index d9b1cd9..88ae30b 100644 --- a/tests/e2e/status_test.py +++ b/tests/e2e/status_test.py @@ -1,7 +1,5 @@ from __future__ import annotations -import pytest - def write_feature_text(pytester, basename: str, text: str) -> str: dst = pytester.path / "docs" / "features" / basename @@ -26,13 +24,11 @@ def status_stdout(pytester) -> str: return "\n".join(result.outlines) -@pytest.mark.pending def test_status_exits_zero_when_features_dir_exists(pytester) -> None: write_feature_text(pytester, "a.feature", "Feature: A\nScenario: aaaaa\nGiven x\n") assert run_beehave_status(pytester) == 0 -@pytest.mark.pending def test_status_reports_feature_file_count(pytester) -> None: write_feature_text(pytester, "a.feature", "Feature: A\nScenario: aaaaa\nGiven x\n") write_feature_text(pytester, "b.feature", "Feature: B\nScenario: bbbbb\nGiven x\n") @@ -41,7 +37,6 @@ def test_status_reports_feature_file_count(pytester) -> None: assert "feature" in stdout.lower() -@pytest.mark.pending def test_status_reports_emitted_stub_count(pytester) -> None: write_feature_text(pytester, "a.feature", "Feature: A\nScenario: aaaaa\nGiven x\n") write_pyi_stub(pytester, "a_default") @@ -51,6 +46,5 @@ def test_status_reports_emitted_stub_count(pytester) -> None: assert "stub" in stdout.lower() -@pytest.mark.pending def test_status_exits_two_when_features_dir_missing(pytester) -> None: assert run_beehave_status(pytester) == 2 diff --git a/tests/features/case_insensitive_matching/bracket_notation_preserved_as_literal_test.py b/tests/features/case_insensitive_matching/bracket_notation_preserved_as_literal_test.py deleted file mode 100644 index 038db3d..0000000 --- a/tests/features/case_insensitive_matching/bracket_notation_preserved_as_literal_test.py +++ /dev/null @@ -1 +0,0 @@ -def test_bracket_notation_captured_verbatim(): ... diff --git a/tests/features/case_insensitive_matching/literal_matching_case_insensitive_test.py b/tests/features/case_insensitive_matching/literal_matching_case_insensitive_test.py deleted file mode 100644 index af098da..0000000 --- a/tests/features/case_insensitive_matching/literal_matching_case_insensitive_test.py +++ /dev/null @@ -1,48 +0,0 @@ -from pathlib import Path - -from conftest import write_feature, write_test # noqa: E402 - -from beehave.check import check_all -from beehave.config import Config - - -def test_string_literal_matches_lowercase_constant( - tmp_project: Path, config: Config -) -> None: - write_feature( - tmp_project, - "case_insensitive_matching/case_insensitive_matching", - """\ - Feature: Case Insensitive Matching - - Rule: Literal Matching Case Insensitive - - Scenario: string literal matches lowercase constant - Given a dog named "Rex" - """, - ) - - write_test( - tmp_project, - "case_insensitive_matching", - "literal_matching_case_insensitive_test.py", - """\ - def test_string_literal_matches_lowercase_constant(): - "rex" - """, - ) - - violations = check_all(config) - - ml_violations = [v for v in violations if v.error_type == "missing-literal"] - assert len(ml_violations) == 0, ( - f"expected 0 missing-literal violations (case-insensitive match: " - f"'Rex' should match 'rex'), " - f"got {len(ml_violations)}: {[(v.error_type, v.message) for v in violations]}" - ) - - -def test_string_literal_matches_uppercase_constant(): ... - - -def test_numeric_literal_matches_stringified_decimal(): ... diff --git a/tests/features/case_insensitive_matching/negative_numbers_visible_in_body_test.py b/tests/features/case_insensitive_matching/negative_numbers_visible_in_body_test.py deleted file mode 100644 index 5a9817a..0000000 --- a/tests/features/case_insensitive_matching/negative_numbers_visible_in_body_test.py +++ /dev/null @@ -1,48 +0,0 @@ -from pathlib import Path - -from conftest import write_feature, write_test # noqa: E402 - -from beehave.check import check_all -from beehave.config import Config - - -def test_negative_integer_literal_matches_body_constant( - tmp_project: Path, config: Config -) -> None: - write_feature( - tmp_project, - "case_insensitive_matching/case_insensitive_matching", - """\ - Feature: Case Insensitive Matching - - Rule: Negative Numbers Visible In Body - - Scenario: negative integer literal matches body constant - Given the balance is -2010 - """, - ) - - write_test( - tmp_project, - "case_insensitive_matching", - "negative_numbers_visible_in_body_test.py", - """\ - def test_negative_integer_literal_matches_body_constant(): - balance = -2010 - """, - ) - - violations = check_all(config) - - ml_violations = [v for v in violations if v.error_type == "missing-literal"] - assert len(ml_violations) == 0, ( - f"expected 0 missing-literal violations (-2010 from Gherkin should match " - f"-2010 in body), " - f"got {len(ml_violations)}: {[(v.error_type, v.message) for v in violations]}" - ) - - -def test_negative_float_literal_matches_body_constant(): ... - - -def test_positive_integer_still_works(): ... diff --git a/tests/features/case_insensitive_matching/placeholder_matching_case_insensitive_test.py b/tests/features/case_insensitive_matching/placeholder_matching_case_insensitive_test.py deleted file mode 100644 index bbeb378..0000000 --- a/tests/features/case_insensitive_matching/placeholder_matching_case_insensitive_test.py +++ /dev/null @@ -1,59 +0,0 @@ -from pathlib import Path - -from conftest import write_feature, write_test # noqa: E402 -from hypothesis import HealthCheck, given, settings, strategies as st - -from beehave.check import check_all -from beehave.config import Config - - -@given(Dog=st.text()) -@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) -def test_placeholder_matches_lowercase_body_name( - Dog: str, tmp_project: Path, config: Config -) -> None: - write_feature( - tmp_project, - "case_insensitive_matching/case_insensitive_matching", - """\ - Feature: Case Insensitive Matching - - Rule: Placeholder Matching Case Insensitive - - Scenario: placeholder matches lowercase body name - Given a barks - """, - ) - - write_test( - tmp_project, - "case_insensitive_matching", - "placeholder_matching_case_insensitive_test.py", - """\ - from hypothesis import given, strategies as st - - @given(Dog=st.text()) - def test_placeholder_matches_lowercase_body_name(Dog): - dog - """, - ) - - violations = check_all(config) - - mp_violations = [v for v in violations if v.error_type == "missing-placeholder"] - assert len(mp_violations) == 0, ( - f"expected 0 missing-placeholder violations (case-insensitive), " - f"got {len(mp_violations)}: {[(v.error_type, v.message) for v in violations]}" - ) - - -@given(Dog=st.text()) -def test_placeholder_matches_uppercase_body_name(Dog): ... - - -@given(Dog=st.text()) -def test_placeholder_matches_mixed_case_body_name(Dog): ... - - -@given(Dog=st.text()) -def test_placeholder_does_not_match_different_identifier(Dog): ... diff --git a/tests/features/case_insensitive_matching/quoted_placeholder_not_double_captured_test.py b/tests/features/case_insensitive_matching/quoted_placeholder_not_double_captured_test.py deleted file mode 100644 index 85d3c44..0000000 --- a/tests/features/case_insensitive_matching/quoted_placeholder_not_double_captured_test.py +++ /dev/null @@ -1,86 +0,0 @@ -from pathlib import Path - -from conftest import write_feature, write_test # noqa: E402 - -from beehave.check import check_all -from beehave.config import Config - - -def test_quoted_placeholder_not_captured_as_literal( - tmp_project: Path, config: Config -) -> None: - write_feature( - tmp_project, - "case_insensitive_matching/case_insensitive_matching", - """\ - Feature: Case Insensitive Matching - - Rule: Quoted Placeholder Not Double Captured - - Scenario: quoted placeholder not captured as literal - Given a dog named "" - """, - ) - - write_test( - tmp_project, - "case_insensitive_matching", - "quoted_placeholder_not_double_captured_test.py", - """\ - def test_quoted_placeholder_not_captured_as_literal(): - name - """, - ) - - violations = check_all(config) - - ml_violations = [v for v in violations if v.error_type == "missing-literal"] - assert len(ml_violations) == 0, ( - f"expected 0 missing-literal violations " - f"('' inside quotes should not be captured as a literal), " - f"got {len(ml_violations)}: {[(v.error_type, v.message) for v in violations]}" - ) - - mp_violations = [v for v in violations if v.error_type == "missing-placeholder"] - assert len(mp_violations) == 0, ( - f"expected 0 missing-placeholder violations " - f"(placeholder matched case-insensitively by body 'name'), " - f"got {len(mp_violations)}: {[(v.error_type, v.message) for v in violations]}" - ) - - -def test_non_placeholder_quoted_content_captured( - tmp_project: Path, config: Config -) -> None: - write_feature( - tmp_project, - "case_insensitive_matching/case_insensitive_matching", - """\ - Feature: Case Insensitive Matching - - Rule: Quoted Placeholder Not Double Captured - - Scenario: non placeholder quoted content captured - Given a phone number "[PHONE]" - """, - ) - - write_test( - tmp_project, - "case_insensitive_matching", - "quoted_placeholder_not_double_captured_test.py", - """\ - def test_non_placeholder_quoted_content_captured(): - "[PHONE]" - """, - ) - - violations = check_all(config) - - ml_violations = [v for v in violations if v.error_type == "missing-literal"] - assert len(ml_violations) == 0, ( - f"expected 0 missing-literal violations " - f"('[PHONE]' inside quotes should be captured as a literal and " - f"match the body's '[PHONE]'), " - f"got {len(ml_violations)}: {[(v.error_type, v.message) for v in violations]}" - ) diff --git a/tests/features/case_insensitive_matching/stub_tests_skip_all_checks_test.py b/tests/features/case_insensitive_matching/stub_tests_skip_all_checks_test.py deleted file mode 100644 index 2fd09a4..0000000 --- a/tests/features/case_insensitive_matching/stub_tests_skip_all_checks_test.py +++ /dev/null @@ -1,5 +0,0 @@ -from hypothesis import given, strategies as st - - -@given(Dog=st.text()) -def test_stub_test_produces_no_violations(Dog): ... diff --git a/tests/features/case_insensitive_matching/true_and_one_never_collide_test.py b/tests/features/case_insensitive_matching/true_and_one_never_collide_test.py deleted file mode 100644 index d2f541a..0000000 --- a/tests/features/case_insensitive_matching/true_and_one_never_collide_test.py +++ /dev/null @@ -1 +0,0 @@ -def test_integer_one_not_match_true_boolean(): ... diff --git a/tests/features/comb_construction/__init__.py b/tests/features/comb_construction/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/features/comb_construction/brood_management_test.py b/tests/features/comb_construction/brood_management_test.py deleted file mode 100644 index 249155a..0000000 --- a/tests/features/comb_construction/brood_management_test.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest - -from hypothesis import given, example, strategies as st - - -@pytest.mark.skip(reason="not implemented") -@given(kind=st.text()) -def test_queen_lays_egg_in_cell(kind): ... - - -@pytest.mark.skip(reason="not implemented") -@example(ambient=28, workers=20, target=35) -@example(ambient=40, workers=15, target=35) -@given(ambient=st.integers(), workers=st.integers(), target=st.integers()) -def test_nursery_temperature_regulation(ambient, workers, target): ... diff --git a/tests/features/comb_construction/comb_building_test.py b/tests/features/comb_construction/comb_building_test.py deleted file mode 100644 index 50692af..0000000 --- a/tests/features/comb_construction/comb_building_test.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -from hypothesis import given, strategies as st - - -@pytest.mark.skip(reason="not implemented") -@given(amount=st.text()) -def test_worker_builds_a_hexagonal_cell(amount): ... diff --git a/tests/features/hive_activity/__init__.py b/tests/features/hive_activity/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/features/hive_activity/default_test.py b/tests/features/hive_activity/default_test.py deleted file mode 100644 index c669a6f..0000000 --- a/tests/features/hive_activity/default_test.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest - -from hypothesis import given, example, strategies as st - - -@pytest.mark.skip(reason="not implemented") -@example(nectar=100, rate=20, hours=8, honey=80) -@example(nectar=200, rate=25, hours=12, honey=150) -@example(nectar=50, rate=30, hours=6, honey=35) -@given( - nectar=st.integers(), rate=st.integers(), hours=st.integers(), honey=st.integers() -) -def test_honey_production_from_nectar(nectar, rate, hours, honey): ... diff --git a/tests/features/hive_activity/foraging_test.py b/tests/features/hive_activity/foraging_test.py deleted file mode 100644 index 170fe8d..0000000 --- a/tests/features/hive_activity/foraging_test.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -from hypothesis import given, strategies as st - - -@pytest.mark.skip(reason="not implemented") -@given(name=st.text(), volume=st.text()) -def test_forager_returns_with_nectar(name, volume): ... diff --git a/tests/features/hive_activity/hive_defense_test.py b/tests/features/hive_activity/hive_defense_test.py deleted file mode 100644 index 2c95b0f..0000000 --- a/tests/features/hive_activity/hive_defense_test.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -from hypothesis import given, strategies as st - - -@pytest.mark.skip(reason="not implemented") -@given(scent=st.text(), outcome=st.text()) -def test_guard_bee_inspects_visitor(scent, outcome): ... diff --git a/tests/features/hive_activity/hive_foraging_test.py b/tests/features/hive_activity/hive_foraging_test.py deleted file mode 100644 index 494986d..0000000 --- a/tests/features/hive_activity/hive_foraging_test.py +++ /dev/null @@ -1,9 +0,0 @@ -import pytest - -from hypothesis import given, strategies as st - -@pytest.mark.skip(reason="not implemented") -@given(name=st.text(), volume=st.text()) -def test_forager_returns_with_nectar(name, volume): - ... - diff --git a/tests/features/status_command/__init__.py b/tests/features/status_command/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/features/status_command/all_passing_derives_ok_test.py b/tests/features/status_command/all_passing_derives_ok_test.py deleted file mode 100644 index 8a3ad6c..0000000 --- a/tests/features/status_command/all_passing_derives_ok_test.py +++ /dev/null @@ -1,63 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_feature_with_all_scenarios_passing(tmp_project, config, capsys): - """Feature where all scenarios have non-stub tests with zero violations → 'ok'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 3 - _feature_ref = "docs/features/fully_implemented.feature" - - write_feature( - tmp_project, - "passing", - """\ - Feature: Passing Feature - Scenario: Login Succeeds - Given a user named "Alice" with password "secret" is registered - When the user logs in with "Alice" and "secret" - Then the user sees "Welcome" - - Scenario: Logout Succeeds - Given the user "Alice" is logged in - When the user clicks logout - Then the user sees "Goodbye" - """, - ) - # assert features_dir path exists - assert (tmp_project / features_dir).exists() - - write_test( - tmp_project, - "passing_feature", - "default_test.py", - """\ - def test_login_succeeds(): - assert "Alice" == "Alice" - assert "secret" == "secret" - assert "Welcome" == "Welcome" - - def test_logout_succeeds(): - assert "Alice" == "Alice" - assert "Goodbye" == "Goodbye" - """, - ) - assert (tmp_project / tests_dir).exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "ok" in captured.out - # scenarios_ok is 3 - ok_count = 3 - assert ok_count == 3 - # scenarios_errors is 0 - error_count = 0 - assert error_count == 0 - assert n_scenarios == 3 diff --git a/tests/features/status_command/all_stubs_derive_stage_test.py b/tests/features/status_command/all_stubs_derive_stage_test.py deleted file mode 100644 index 316cea6..0000000 --- a/tests/features/status_command/all_stubs_derive_stage_test.py +++ /dev/null @@ -1,121 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_feature_scenarios_all_mapped_to_stubs(tmp_project, config, capsys): - """Feature where all scenarios are mapped to stub tests → 'needs bodies'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 3 - stub_body = "..." - - write_feature( - tmp_project, - "stub_feature", - """\ - Feature: Stub Feature - Scenario: First Scenario - Given a thing - When action - Then result - - Scenario: Second Scenario - Given another thing - When action - Then result - """, - ) - # BDD step references "docs/features/stub_all.feature" — verify features dir - _feature_ref = "docs/features/stub_all.feature" - assert (tmp_project / "docs" / "features").exists() - - write_test( - tmp_project, - "stub_feature", - "default_test.py", - """\ - def test_first_scenario(): - pass - - def test_second_scenario(): - pass - """, - ) - # BDD step references "tests/features/stub_all/default_test.py" - _test_ref = "tests/features/stub_all/default_test.py" - assert (tmp_project / "tests" / "features").exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs bodies" in captured.out - # all scenario statuses are "no body" - assert "no body" in captured.out - assert n_scenarios == 3 - no_body = 3 - assert no_body == 3 - ok_count = 0 - assert ok_count == 0 - # every matching test function body is "..." - assert stub_body == "..." - - -def test_feature_with_stub_and_non_stub(tmp_project, config, capsys): - """Feature where one scenario is non-stub ok, one is stub pass → 'needs bodies'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 2 - _feature_ref = "docs/features/stub_mix.feature" - - write_feature( - tmp_project, - "mixed", - """\ - Feature: Mixed Feature - Scenario: Simple Action - Given a step literal "hello" - When perform action - Then result is achieved - """, - ) - - # test "test_implemented" is non-stub with zero violations - _test_name = "test_implemented" - # test "test_not_implemented" is a stub with body "pass" - _stub_name = "test_not_implemented" - _pass_body = "pass" - - write_test( - tmp_project, - "mixed_feature", - "default_test.py", - """\ - def test_simple_action(): - assert "hello" == "hello" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - # scenario "test_implemented" status is "ok" - assert "ok" in captured.out - assert _test_name == "test_implemented" - # scenario "test_not_implemented" status is "no body" - assert _stub_name == "test_not_implemented" - # the feature stage is "needs bodies" (BDD literal for traceability) - _stage = "needs bodies" - # literal "no body" for traceability - _no_body = "no body" - # body "pass" for traceability - assert _pass_body == "pass" - assert n_scenarios == 2 diff --git a/tests/features/status_command/cross_feature_collisions_detected_test.py b/tests/features/status_command/cross_feature_collisions_detected_test.py deleted file mode 100644 index 70d44ce..0000000 --- a/tests/features/status_command/cross_feature_collisions_detected_test.py +++ /dev/null @@ -1,148 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_two_features_produce_same_function_name(tmp_project, config, capsys): - """Duplicate function names across features → collision reported.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - collision_scenario = "login" - auth_test_path = "tests/features/auth/default_test.py" - sso_test_path = "tests/features/sso/default_test.py" - - write_feature( - tmp_project, - "auth", - """\ - Feature: Auth Feature - Scenario: Login - Given a login page - When the user logs in - Then the user is authenticated - """, - ) - write_test( - tmp_project, - "auth_feature", - "default_test.py", - """\ - def test_login(): - pass - """, - ) - - write_feature( - tmp_project, - "sso", - """\ - Feature: SSO Feature - Scenario: Login - Given an sso page - When the user logs in via sso - Then the user is authenticated - """, - ) - write_test( - tmp_project, - "sso_feature", - "default_test.py", - """\ - def test_login(): - pass - """, - ) - - assert collision_scenario == "login" - assert auth_test_path == "tests/features/auth/default_test.py" - assert sso_test_path == "tests/features/sso/default_test.py" - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - # Collision should be reported - assert "test_login" in captured.out - assert "collision" in captured.out - - -def test_no_collisions_unique_function_names(tmp_project, config, capsys): - """Unique function names → no collisions reported.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - auth_functions = ["test_login", "test_logout"] - payment_functions = ["test_charge", "test_refund"] - - write_feature( - tmp_project, - "auth", - """\ - Feature: Auth Feature - Scenario: Login - Given a login page - When the user logs in - Then the user is authenticated - Scenario: Logout - Given a logged in user - When the user logs out - Then the user is logged out - """, - ) - write_test( - tmp_project, - "auth_feature", - "default_test.py", - """\ - def test_login(): - pass - - def test_logout(): - pass - """, - ) - - write_feature( - tmp_project, - "payment", - """\ - Feature: Payment Feature - Scenario: Charge - Given a payment method - When the charge occurs - Then the charge is successful - Scenario: Refund - Given a previous charge - When the refund occurs - Then the refund is successful - """, - ) - write_test( - tmp_project, - "payment_feature", - "default_test.py", - """\ - def test_charge(): - pass - - def test_refund(): - pass - """, - ) - - assert auth_functions[0] == "test_login" - assert auth_functions[1] == "test_logout" - assert payment_functions[0] == "test_charge" - assert payment_functions[1] == "test_refund" - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "collision" not in captured.out diff --git a/tests/features/status_command/empty_features_report_no_scenarios_test.py b/tests/features/status_command/empty_features_report_no_scenarios_test.py deleted file mode 100644 index bfae7bc..0000000 --- a/tests/features/status_command/empty_features_report_no_scenarios_test.py +++ /dev/null @@ -1,65 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature - - -def test_feature_with_title_only_and_comment(tmp_project, config, capsys): - """Feature with only a title and comment (no scenarios) reports 'no scenarios'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - write_feature( - tmp_project, - "placeholder", - """\ - Feature: Placeholder - # Work in progress — no scenarios yet - """, - ) - assert (tmp_project / "docs/features/placeholder.feature").exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "placeholder" in captured.out - assert "(Placeholder)" in captured.out - assert "no scenarios" in captured.out - # scenarios_total is 0, scenarios_ok is 0, scenarios_no_test is 0 - features_with_scenarios = [l for l in captured.out.split("\n") if "Scenario" in l] - total = len(features_with_scenarios) - assert total == 0 - ok_count = 0 - assert ok_count == 0 - no_test_count = 0 - assert no_test_count == 0 - - -def test_feature_with_background_but_no_scenarios(tmp_project, config, capsys): - """Feature with a Background but no Scenarios reports 'no scenarios'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - write_feature( - tmp_project, - "bg_only", - """\ - Feature: Background Only - Background: - Given the system is initialized - """, - ) - assert (tmp_project / "docs/features/bg_only.feature").exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "bg_only" in captured.out - assert "(Background Only)" in captured.out - assert "no scenarios" in captured.out diff --git a/tests/features/status_command/exit_codes_reflect_overall_status_test.py b/tests/features/status_command/exit_codes_reflect_overall_status_test.py deleted file mode 100644 index fcaf91e..0000000 --- a/tests/features/status_command/exit_codes_reflect_overall_status_test.py +++ /dev/null @@ -1,177 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_all_features_ok_exits_zero(tmp_project, config): - """All features ok → SystemExit with code 0.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_features = 2 - ok_stage = "ok" - - write_feature( - tmp_project, - "login", - """\ - Feature: Login - Scenario: User Logs In - Given a user with credentials "alice" and "secret" - When the user logs in - Then the user sees "Welcome" - """, - ) - write_test( - tmp_project, - "login", - "default_test.py", - """\ - def test_user_logs_in(): - assert "alice" == "alice" - assert "secret" == "secret" - assert "Welcome" == "Welcome" - """, - ) - - write_feature( - tmp_project, - "signup", - """\ - Feature: Signup - Scenario: User Signs Up - Given a registration form - When the user submits with "bob" and "pass" - Then the user is registered - """, - ) - write_test( - tmp_project, - "signup", - "default_test.py", - """\ - def test_user_signs_up(): - assert "bob" == "bob" - assert "pass" == "pass" - """, - ) - - with pytest.raises(SystemExit) as exc_info: - compute_status(config) - assert exc_info.value.code == 0 - assert n_features == 2 - assert ok_stage == "ok" - - -def test_any_feature_not_ok_exits_one(tmp_project, config): - """Any feature not ok → SystemExit with code 1.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_features = 2 - ok_stage = "ok" - needs_tests_stage = "needs tests" - - write_feature( - tmp_project, - "login", - """\ - Feature: Login - Scenario: User Logs In - Given a user with credentials "alice" and "secret" - When the user logs in - Then the user sees "Welcome" - """, - ) - write_test( - tmp_project, - "login", - "default_test.py", - """\ - def test_user_logs_in(): - assert "alice" == "alice" - assert "secret" == "secret" - assert "Welcome" == "Welcome" - """, - ) - - write_feature( - tmp_project, - "signup", - """\ - Feature: Signup - Scenario: User Signs Up - Given a registration form - When the user submits with "bob" and "pass" - Then the user is registered - """, - ) - # No test file for signup → needs tests - - with pytest.raises(SystemExit) as exc_info: - compute_status(config) - assert exc_info.value.code == 1 - assert n_features == 2 - assert ok_stage == "ok" - assert needs_tests_stage == "needs tests" - - -def test_broken_feature_exits_with_code_one(tmp_project, config): - """Broken feature → SystemExit with code 1.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - parse_error_msg = "No feature found" - broken_stage = "broken" - - write_feature( - tmp_project, - "broken_feature", - """\ - Feature: Broken - Scenario missing colon - Given something - """, - ) - assert parse_error_msg == "No feature found" - assert broken_stage == "broken" - - with pytest.raises(SystemExit) as exc_info: - compute_status(config) - assert exc_info.value.code == 1 - - -def test_features_directory_missing_exits_two(config): - """Missing features directory → SystemExit with code 2.""" - from pathlib import Path - from beehave.config import Config - - features_dir = "docs/features" - config = Config( - features_dir="nonexistent_dir", - tests_dir="tests/features", - ) - _check_val = features_dir == "docs/features" - with pytest.raises(SystemExit) as exc_info: - compute_status(config) - assert exc_info.value.code == 2 - - -def test_project_no_feature_files_exits_zero(tmp_project, config): - """Zero feature files → SystemExit with code 0.""" - features_dir = "docs/features" - tests_dir = "tests/features" - # features directory "docs/features" exists but contains zero .feature files - assert features_dir == "docs/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - with pytest.raises(SystemExit) as exc_info: - compute_status(config) - assert exc_info.value.code == 0 diff --git a/tests/features/status_command/json_output_is_machine_readable_test.py b/tests/features/status_command/json_output_is_machine_readable_test.py deleted file mode 100644 index a040292..0000000 --- a/tests/features/status_command/json_output_is_machine_readable_test.py +++ /dev/null @@ -1,250 +0,0 @@ -import json -import pytest - -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_json_output_includes_full_feature_hierarchy(tmp_project, config, capsys): - """JSON output has features array with full hierarchy and summary.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_features = 2 - needs_fixes_stage = "needs fixes" - auth_scenarios = 3 - - write_feature( - tmp_project, - "auth", - """\ - Feature: Auth Feature - Scenario: Login Works - Given a user with credentials "alice" and "secret" - When the user logs in - Then the user sees "Welcome" - Scenario: Logout Works - Given a logged in user - When the user logs out - Then the user sees "Goodbye" - Scenario: Signup Works - Given a registration form - When the user submits email "alice@example.com" - Then the user is registered - """, - ) - write_test( - tmp_project, - "auth_feature", - "default_test.py", - """\ - def test_login_works(): - assert "alice" == "alice" - assert "secret" == "secret" - assert "Welcome" == "Welcome" - - def test_logout_works(): - assert "Goodbye" == "Goodbye" - - def test_signup_works(): - assert "alice@example.com" == "alice@example.com" - """, - ) - - write_feature( - tmp_project, - "payment", - """\ - Feature: Payment Feature - Scenario: Charge Works - Given a payment method - When the charge is "100" - Then the charge is successful - Scenario: Refund Works - Given a previous charge - When the refund is "50" - Then the refund is successful - """, - ) - write_test( - tmp_project, - "payment_feature", - "default_test.py", - """\ - def test_charge_works(): - assert 1 == 1 - # missing "100" literal - - def test_refund_works(): - assert 2 == 2 - # missing "50" literal - """, - ) - - with pytest.raises(SystemExit): - compute_status(config, json_output=True) - - captured = capsys.readouterr() - data = json.loads(captured.out) - assert "features" in data - assert len(data["features"]) == 2 - assert "summary" in data - assert data["summary"]["total_features"] == 2 - assert data["summary"]["ok"] == 1 - assert data["summary"]["needs_fixes"] == 1 - assert n_features == 2 - assert needs_fixes_stage == "needs fixes" - assert auth_scenarios == 3 - # Each feature has scenarios array - for feat in data["features"]: - assert "scenarios" in feat - - -def test_json_includes_summary_stage_counts(tmp_project, config, capsys): - """JSON summary has correct stage counts.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_features = 3 - needs_bodies_stage = "needs bodies" - zero_count = 0 - - write_feature( - tmp_project, - "ok_feature", - """\ - Feature: Ok Feature - Scenario: Test Passes - Given a step literal "hello" - When action occurs - Then result is "world" - """, - ) - write_test( - tmp_project, - "ok_feature", - "default_test.py", - """\ - def test_test_passes(): - assert "hello" == "hello" - assert "world" == "world" - """, - ) - - write_feature( - tmp_project, - "broken_feature", - """\ - Feature: Broken Feature - @invalid tag whitespace - Given something - """, - ) - - write_feature( - tmp_project, - "stub_feature", - """\ - Feature: Stub Feature - Scenario: Stub Test - Given a step - When action - Then result - """, - ) - write_test( - tmp_project, - "stub_feature", - "default_test.py", - """\ - def test_stub_test(): - pass - """, - ) - - with pytest.raises(SystemExit): - compute_status(config, json_output=True) - - captured = capsys.readouterr() - data = json.loads(captured.out) - s = data["summary"] - assert s["broken"] == 1 - assert s["needs_bodies"] == 1 - assert s["ok"] == 1 - assert n_features == 3 - assert needs_bodies_stage == "needs bodies" - assert zero_count == 0 - - -def test_json_has_collision_and_unmapped_entries(tmp_project, config, capsys): - """JSON output includes unmapped_directories and collisions.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - unmapped_dir = "tests/features/old_feature" - collision_fn = "test_login" - - # Unmapped directory - test_dir = tmp_project / "tests" / "features" / "old_feature" - test_dir.mkdir(parents=True, exist_ok=True) - (test_dir / "default_test.py").write_text("def test_old(): pass\n") - assert unmapped_dir == "tests/features/old_feature" - - # Two features with collision - write_feature( - tmp_project, - "auth", - """\ - Feature: Auth - Scenario: Login - Given a login page - When the user logs in - Then the user is authenticated - """, - ) - write_test( - tmp_project, - "auth", - "default_test.py", - """\ - def test_login(): - pass - """, - ) - - write_feature( - tmp_project, - "sso", - """\ - Feature: SSO - Scenario: Login - Given an sso page - When the user logs in - Then the user is authenticated - """, - ) - write_test( - tmp_project, - "sso", - "default_test.py", - """\ - def test_login(): - pass - """, - ) - - assert collision_fn == "test_login" - - with pytest.raises(SystemExit): - compute_status(config, json_output=True, include_unmapped=True) - - captured = capsys.readouterr() - data = json.loads(captured.out) - assert len(data.get("unmapped_directories", [])) > 0 - assert len(data.get("collisions", [])) > 0 diff --git a/tests/features/status_command/ok_feature_collapses_in_output_test.py b/tests/features/status_command/ok_feature_collapses_in_output_test.py deleted file mode 100644 index 4abdb90..0000000 --- a/tests/features/status_command/ok_feature_collapses_in_output_test.py +++ /dev/null @@ -1,126 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_ok_feature_shown_as_tree_line(tmp_project, config, capsys): - """ok feature displayed as single collapsed line without scenario expansion.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - feature_title = "Fully Implemented" - expected_line = "ok fully_implemented (Fully Implemented)" - - write_feature( - tmp_project, - "fully_implemented", - """\ - Feature: Fully Implemented - Scenario: Login Works - Given a user named "Alice" with password "secret" is registered - When the user logs in with "Alice" and "secret" - Then the user sees "Welcome" - - Scenario: Logout Works - Given the user "Alice" is logged in - When the user clicks logout - Then the user sees "Goodbye" - """, - ) - - write_test( - tmp_project, - "fully_implemented", - "default_test.py", - """\ - def test_login_works(): - assert "Alice" == "Alice" - assert "secret" == "secret" - assert "Welcome" == "Welcome" - - def test_logout_works(): - assert "Alice" == "Alice" - assert "Goodbye" == "Goodbye" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - lines = captured.out.strip().split("\n") - assert len(lines) == 1 - # the line matches "ok fully_implemented (Fully Implemented)" - assert "fully_implemented (Fully Implemented)" in lines[0] - assert feature_title == "Fully Implemented" - assert "ok" in lines[0] - assert expected_line == "ok fully_implemented (Fully Implemented)" - - -def test_two_ok_features_with_blank_separator(tmp_project, config, capsys): - """Two ok features shown separated by blank line, no scenario expansion.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - # Feature 1: auth with stage "ok" - _ok_stage = "ok" - write_feature( - tmp_project, - "auth", - """\ - Feature: Authentication - Scenario: Login Succeeds - Given a user with credentials "alice" and "secret" - When the user logs in - Then the user is authenticated - """, - ) - write_test( - tmp_project, - "authentication", - "default_test.py", - """\ - def test_login_succeeds(): - assert "alice" == "alice" - assert "secret" == "secret" - """, - ) - - # Feature 2: payment with stage "ok" - write_feature( - tmp_project, - "payment", - """\ - Feature: Payment - Scenario: Charge Works - Given a valid payment method "card" - When the charge is made - Then the charge is successful - """, - ) - write_test( - tmp_project, - "payment", - "default_test.py", - """\ - def test_charge_works(): - assert "card" == "card" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - out = captured.out - assert "auth (Authentication)" in out - assert "payment (Payment)" in out - # Blank line separates the two features - assert "\n\n" in out - assert "├──" not in out - # features have stage "ok" - assert "ok" in out diff --git a/tests/features/status_command/parse_error_captured_as_stage_test.py b/tests/features/status_command/parse_error_captured_as_stage_test.py deleted file mode 100644 index 12aae4d..0000000 --- a/tests/features/status_command/parse_error_captured_as_stage_test.py +++ /dev/null @@ -1,74 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature - - -def test_feature_missing_colon_after_scenario(tmp_project, config, capsys): - """Feature file with missing colon after Scenario keyword produces broken stage.""" - # NOTE: spec gap — the BDD scenario at status_command.feature:39-50 - # uses "Scenario bad title" (no colon) which gherkin-official treats - # as description text, not invalid syntax. The content below triggers - # a genuine CompositeParserException. - features_dir = "docs/features" - tests_dir = "tests/features" - feature_path = "docs/features/bad_scenario.feature" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - write_feature( - tmp_project, - "bad_scenario", - """\ - Feature - Scenario: bad title - Given something - """, - ) - assert (tmp_project / feature_path).exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - # Tree output shows the feature with label "broken" - assert "broken" in captured.out - assert "bad_scenario" in captured.out - # parse_error_message contains expected Gherkin error substring - assert "expected:" in captured.out - assert "FeatureLine" in captured.out - # BDD step literals for traceability - _expected_error = "expected: #TagLine, #FeatureLine, #RuleLine, #Comment, #Empty" - zero_count = 0 - assert zero_count == 0 - - -def test_feature_with_unrecognized_gherkin_keyword(tmp_project, config, capsys): - """Feature containing invalid Gherkin syntax produces broken stage.""" - # NOTE: spec gap — the BDD scenario at status_command.feature:52-62 - # uses "Situation: misnamed step" which gherkin-official treats as - # description text. The content below triggers a genuine parse error. - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - write_feature( - tmp_project, - "unknown_keyword", - """\ - Feature: Unknown Keyword - @invalid scenario - Given something - """, - ) - assert (tmp_project / "docs/features/unknown_keyword.feature").exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "broken" in captured.out - assert "unknown_keyword" in captured.out - assert "no scenarios" not in captured.out - assert "A tag may not contain whitespace" in captured.out diff --git a/tests/features/status_command/rules_without_scenarios_detected_test.py b/tests/features/status_command/rules_without_scenarios_detected_test.py deleted file mode 100644 index d3053ec..0000000 --- a/tests/features/status_command/rules_without_scenarios_detected_test.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature - - -def test_feature_with_rules_and_no_scenarios(tmp_project, config, capsys): - """Feature with Rule nodes but no Scenario children reports 'needs scenarios'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - write_feature( - tmp_project, - "draft_rules", - """\ - Feature: Draft Rules - Rule: Authentication rules - Rule: Authorization rules - """, - ) - assert (tmp_project / "docs/features/draft_rules.feature").exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "draft_rules" in captured.out - assert "(Draft Rules)" in captured.out - assert "needs scenarios" in captured.out - # BDD: detect_empty_rules returns rule_titles with these - _rule1 = "Authentication rules" - _rule2 = "Authorization rules" - # scenarios_total is 0 - zero = 0 - assert zero == 0 diff --git a/tests/features/status_command/scenario_statuses_derive_from_discovery_test.py b/tests/features/status_command/scenario_statuses_derive_from_discovery_test.py deleted file mode 100644 index c89eb46..0000000 --- a/tests/features/status_command/scenario_statuses_derive_from_discovery_test.py +++ /dev/null @@ -1,164 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_scenario_with_no_matching_test_function(tmp_project, config, capsys): - """Scenario with no matching test function → 'no test'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - scenario_name = "test_delete_item" - - write_feature( - tmp_project, - "unmapped_single", - """\ - Feature: Unmapped Feature - Scenario: Delete Item - Given an item exists - When the user deletes the item - Then the item is gone - """, - ) - assert scenario_name == "test_delete_item" - - # No test file written — all scenarios unmapped - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "no test" in captured.out - assert "needs tests" in captured.out - - -def test_scenario_with_matching_stub_test(tmp_project, config, capsys): - """Scenario with stub test → 'no body'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - scenario_name = "test_create_item" - - write_feature( - tmp_project, - "stub_single", - """\ - Feature: Stub Feature - Scenario: Create Item - Given a form is open - When the user submits the form - Then a new item is created - """, - ) - assert scenario_name == "test_create_item" - - write_test( - tmp_project, - "stub_feature", - "default_test.py", - """\ - def test_create_item(): - pass - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "no body" in captured.out - assert "needs bodies" in captured.out - - -def test_scenario_non_stub_test_violations(tmp_project, config, capsys): - """Scenario with non-stub test and missing-literal → '1 error'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - scenario_name = "test_update_item" - literal_value = "new" - violation_count = 1 - - write_feature( - tmp_project, - "missing", - """\ - Feature: Missing Literal Feature - Scenario: Update Item - Given an item with value "new" - When the user updates the item - Then the item is updated - """, - ) - assert scenario_name == "test_update_item" - assert literal_value == "new" - # missing-literal violation for "new" - _ = literal_value # second occurrence of "new" - - write_test( - tmp_project, - "missing_literal_feature", - "default_test.py", - """\ - def test_update_item(): - assert 1 == 1 - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "1 error" in captured.out - assert "needs fixes" in captured.out - assert violation_count == 1 - - -def test_scenario_non_stub_test_zero_violations(tmp_project, config, capsys): - """Scenario with non-stub test and zero violations → 'ok'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - scenario_name = "test_list_items" - placeholder_val = "page" - - write_feature( - tmp_project, - "passing", - """\ - Feature: Passing Feature - Scenario: List Items - Given the page is - When the user lists items - Then items are shown - """, - ) - assert scenario_name == "test_list_items" - assert placeholder_val == "page" - # body_name_nodes contains "page" - _ = placeholder_val # second occurrence of "page" - - write_test( - tmp_project, - "passing_feature", - "default_test.py", - """\ - def test_list_items(): - page = 1 - assert page == 1 - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "ok" in captured.out diff --git a/tests/features/status_command/test_discovery_failure_yields_needs_tests_test.py b/tests/features/status_command/test_discovery_failure_yields_needs_tests_test.py deleted file mode 100644 index be16202..0000000 --- a/tests/features/status_command/test_discovery_failure_yields_needs_tests_test.py +++ /dev/null @@ -1,92 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_syntax_error_test_file_unmaps_scenarios(tmp_project, config, capsys): - """Python syntax error in test file → all scenarios unmapped → 'needs tests'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 2 - _feature_ref = "docs/features/syntax_error.feature" - - write_feature( - tmp_project, - "syntax_error", - """\ - Feature: Syntax Error Feature - Scenario: First Scenario - Given a step - When action occurs - Then result happens - Scenario: Second Scenario - Given another step - When action occurs - Then result happens - """, - ) - - # Write a test file with a Python syntax error - bad_test_path = "tests/features/syntax_error/default_test.py" - test_file = ( - tmp_project / "tests" / "features" / "syntax_error_feature" / "default_test.py" - ) - test_file.parent.mkdir(parents=True, exist_ok=True) - test_file.write_text("def test_first_scenario(\n pass\n") - assert bad_test_path == "tests/features/syntax_error/default_test.py" - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs tests" in captured.out - assert "no test" in captured.out - assert n_scenarios == 2 - no_test_count = 2 - assert no_test_count == 2 - - -def test_empty_test_file_unmaps_all_scenarios(tmp_project, config, capsys): - """Empty test file → all scenarios unmapped → 'needs tests'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 1 - _feature_ref = "docs/features/empty_test.feature" - - write_feature( - tmp_project, - "empty_test", - """\ - Feature: Empty Test Feature - Scenario: Only Scenario - Given a step - When action occurs - Then result happens - """, - ) - - # Write an empty test file - empty_test_path = "tests/features/empty_test/default_test.py" - test_file = ( - tmp_project / "tests" / "features" / "empty_test_feature" / "default_test.py" - ) - test_file.parent.mkdir(parents=True, exist_ok=True) - test_file.write_text("") - assert empty_test_path == "tests/features/empty_test/default_test.py" - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs tests" in captured.out - assert "no test" in captured.out - assert n_scenarios == 1 - no_test_count = 1 - assert no_test_count == 1 diff --git a/tests/features/status_command/tree_output_shows_rule_hierarchy_test.py b/tests/features/status_command/tree_output_shows_rule_hierarchy_test.py deleted file mode 100644 index 851320b..0000000 --- a/tests/features/status_command/tree_output_shows_rule_hierarchy_test.py +++ /dev/null @@ -1,214 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_rule_with_mixed_status_joins_counts(tmp_project, config, capsys): - """Rule shows aggregated non-ok counts: '1 no body, 2 errors'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_rules = 1 - _feature_ref = "docs/features/mixed.feature" - rule_title = "Mixed status" - n_no_body = 2 # BDD: 1 no body - n_errors = 2 # BDD: 2 errors - - write_feature( - tmp_project, - "mixed", - """\ - Feature: Mixed Status Rules - Rule: Mixed status - Scenario: Scenario Ok - Given a step literal "hello" - When action occurs - Then result is "world" - - Scenario: Scenario No Body - Given a step literal "alpha" - When action occurs - Then result is "beta" - - Scenario: Scenario Two Errors - Given a step literal "gamma" - When action occurs - Then result placeholder is - """, - ) - - write_test( - tmp_project, - "mixed_status_rules", - "mixed_status_test.py", - """\ - def test_scenario_ok(): - assert "hello" == "hello" - assert "world" == "world" - - def test_scenario_no_body(): - pass - - def test_scenario_two_errors(): - assert 1 == 1 - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs fixes" in captured.out - # Rule "Mixed status" shows aggregation "1 no body, 2 errors" - assert "1 no body, 2 errors" in captured.out - assert rule_title == "Mixed status" - assert n_rules == 1 - assert n_errors == 2 - - -def test_feature_rules_shown_in_tree_output(tmp_project, config, capsys): - """Feature with 2 Rules shown with tree characters.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_rules = 2 - _feature_ref = "docs/features/ecommerce.feature" - cart_rule = "Cart operations" - checkout_rule = "Checkout flow" - cart_agg = "1 no body" - checkout_agg = "2 errors" - tree_branch = "├──" - tree_last = "└──" - - write_feature( - tmp_project, - "ecommerce", - """\ - Feature: Ecommerce Features - Rule: Cart operations - Scenario: Add Item - Given a step literal "alpha" - When action occurs - Then result is "beta" - Scenario: Remove Item - Given another literal "gamma" - When action happens - Then result is "delta" - Scenario: View Cart - Given a step literal "hello" - When action occurs - Then result is "world" - - Rule: Checkout flow - Scenario: Pay Now - Given a step literal "first" - When action occurs - Then result is - Scenario: Confirm Order - Given a step literal "second" - When action occurs - Then result is "done" - """, - ) - - write_test( - tmp_project, - "ecommerce_features", - "cart_operations_test.py", - """\ - def test_add_item(): - assert "alpha" == "alpha" - assert "beta" == "beta" - - def test_remove_item(): - pass - - def test_view_cart(): - assert "hello" == "hello" - assert "world" == "world" - """, - ) - write_test( - tmp_project, - "ecommerce_features", - "checkout_flow_test.py", - """\ - def test_pay_now(): - assert "first" == "first" - # missing outcome variable → violation - - def test_confirm_order(): - assert "second" == "second" - assert "done" == "done" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs fixes" in captured.out - # Rule "Cart operations" shows aggregation "1 no body" - assert cart_agg in captured.out - # Rule "Checkout flow" shows aggregation — BDD literal "2 errors" for traceability - _bdd_checkout_agg = "2 errors" - assert "1 error" in captured.out - # Rule "Cart operations" is connected with tree character "├──" - assert tree_branch in captured.out - assert cart_rule in captured.out - # Rule "Checkout flow" is connected with tree character "└──" - assert tree_last in captured.out - assert checkout_rule in captured.out - assert "├── Rule: Cart operations" in captured.out - assert "└── Rule: Checkout flow" in captured.out - assert n_rules == 2 - assert "│" in captured.out # continuation char for non-last rule - - -def test_failing_scenario_shows_violation_codes_inline(tmp_project, config, capsys): - """Scenario with violations shows missing identifiers inline.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - scenario_title = "checkout with valid payment" - missing_identifiers = "price, tax" - tax_literal = "tax" - - write_feature( - tmp_project, - "checkout", - """\ - Feature: Checkout Feature - Scenario: checkout with valid payment - Given a step with price - When the user pays with tax "99" - Then payment completes - """, - ) - - write_test( - tmp_project, - "checkout_feature", - "default_test.py", - """\ - def test_checkout_with_valid_payment(): - assert 1 == 1 - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - out = captured.out - assert "price" in out - assert "99" in out - assert scenario_title == "checkout with valid payment" - assert missing_identifiers == "price, tax" - assert tax_literal == "tax" diff --git a/tests/features/status_command/unmapped_directories_reported_when_flagged_test.py b/tests/features/status_command/unmapped_directories_reported_when_flagged_test.py deleted file mode 100644 index 3bdecc7..0000000 --- a/tests/features/status_command/unmapped_directories_reported_when_flagged_test.py +++ /dev/null @@ -1,104 +0,0 @@ -import os -import pytest - -from beehave.status import compute_status -from beehave.config import Config -from conftest import write_feature, write_test - - -def test_unmapped_directory_shown_with_flag(tmp_project, config, capsys): - """Test directory with no matching feature → reported when --include-unmapped.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - unmapped_path = "tests/features/removed_feature" - missing_feature = "docs/features/removed_feature.feature" - - # Create a test directory with a test file, but no feature file - test_dir = tmp_project / "tests" / "features" / "removed_feature" - test_dir.mkdir(parents=True, exist_ok=True) - (test_dir / "default_test.py").write_text("def test_something(): pass\n") - - # Verify unmapped path exists and feature file does not - assert unmapped_path == "tests/features/removed_feature" - assert not (tmp_project / missing_feature).exists() - - # Create a feature so compute_status doesn't exit 0 with no features - write_feature( - tmp_project, - "exists_only", - """\ - Feature: Exists Only - Scenario: Existing Scenario - Given a step literal "hello" - When action occurs - Then result is "world" - """, - ) - write_test( - tmp_project, - "exists_only", - "default_test.py", - """\ - def test_existing_scenario(): - assert "hello" == "hello" - assert "world" == "world" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config, include_unmapped=True) - - captured = capsys.readouterr() - assert "removed_feature" in captured.out - - -def test_unmapped_directory_not_shown_without_flag(tmp_project, config, capsys): - """Without --include-unmapped, unmapped directories are not reported.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - unmapped_path = "tests/features/removed_feature" - missing_feature = "docs/features/removed_feature.feature" - - # Create a test directory with a test file, but no feature file - test_dir = tmp_project / "tests" / "features" / "removed_feature" - test_dir.mkdir(parents=True, exist_ok=True) - (test_dir / "default_test.py").write_text("def test_something(): pass\n") - - # Verify unmapped path exists and feature file does not - assert unmapped_path == "tests/features/removed_feature" - assert not (tmp_project / missing_feature).exists() - - # Create a feature so compute_status doesn't exit 0 with no features - write_feature( - tmp_project, - "exists_only", - """\ - Feature: Exists Only - Scenario: Existing Scenario - Given a step literal "hello" - When action occurs - Then result is "world" - """, - ) - write_test( - tmp_project, - "exists_only", - "default_test.py", - """\ - def test_existing_scenario(): - assert "hello" == "hello" - assert "world" == "world" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config, include_unmapped=False) - - captured = capsys.readouterr() - assert "removed_feature" not in captured.out diff --git a/tests/features/status_command/unmapped_scenarios_derive_stage_test.py b/tests/features/status_command/unmapped_scenarios_derive_stage_test.py deleted file mode 100644 index 8c61476..0000000 --- a/tests/features/status_command/unmapped_scenarios_derive_stage_test.py +++ /dev/null @@ -1,109 +0,0 @@ -import pytest - -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_feature_with_three_scenarios_one_unmapped(tmp_project, config, capsys): - """Feature with 3 scenarios where 1 has no matching test function → 'needs tests'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - total_scenarios = 3 - mapped_tests = 2 - unmapped_count = 1 - feature_file = "docs/features/partial.feature" - test_file = "tests/features/partial/default_test.py" - - write_feature( - tmp_project, - "partial", - """\ - Feature: Partial Feature Coverage - Scenario: Scenario One Runs - Given a step - When an action happens - Then a result occurs - - Scenario: Scenario Two Passes - Given another step - When an action happens - Then a result occurs - - Scenario: Scenario Three Exists - Given a third step - When an action happens - Then a result occurs - """, - ) - assert (tmp_project / feature_file).exists() - - # Test file matches 2 of 3 scenarios — "Scenario Three Exists" is unmapped - write_test( - tmp_project, - "partial_feature_coverage", - "default_test.py", - """\ - def test_scenario_one_runs(): - pass - - def test_scenario_two_passes(): - pass - """, - ) - _bdd_test_path = test_file # BDD: "tests/features/partial/default_test.py" - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs tests" in captured.out - - # scenario "test_scenario_three" has no matching function - _ = "test_scenario_three" - assert captured.out is not None # test_scenario_three literal present - - # scenario status of "test_scenario_three" is "no test" - assert "no test" in captured.out - - # scenarios_total is 3, scenarios_no_test is 1 - assert mapped_tests == 2 - assert total_scenarios == 3 - assert unmapped_count == 1 - - -def test_feature_with_all_scenarios_unmapped(tmp_project, config, capsys): - """Feature where no test file exists → all scenarios unmapped → 'needs tests'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - write_feature( - tmp_project, - "unmapped", - """\ - Feature: Unmapped Feature - Scenario: First Scenario - Given a step - When action occurs - Then result happens - Scenario: Second Scenario - Given another step - When action occurs - Then result happens - """, - ) - assert (tmp_project / "docs/features/unmapped.feature").exists() - # no test file at tests/features/unmapped/default_test.py - _no_test_path = "tests/features/unmapped/default_test.py" - assert not (tmp_project / _no_test_path).exists() - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs tests" in captured.out - assert captured.out.count("no test") == 2 diff --git a/tests/features/status_command/violations_derive_stage_test.py b/tests/features/status_command/violations_derive_stage_test.py deleted file mode 100644 index 7f97c29..0000000 --- a/tests/features/status_command/violations_derive_stage_test.py +++ /dev/null @@ -1,117 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_feature_scenario_with_missing_literal(tmp_project, config, capsys): - """Non-stub test missing a Gherkin literal → violations → 'needs fixes'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - _feature_ref = "docs/features/missing_literal.feature" - total_scenarios = 3 - ok_scenarios = 2 - error_scenarios = 1 - missing_literal_val = "approved" - - write_feature( - tmp_project, - "missing_literal", - """\ - Feature: Missing Literal - Scenario: Users Can Search - Given the search page is open - When the user types "hello" in the search box - Then the user sees "world" - """, - ) - - # test "test_payment_approval" has body constant nodes missing literal "approved" - _test_name = "test_payment_approval" - assert missing_literal_val == "approved" - - write_test( - tmp_project, - "missing_literal", - "default_test.py", - """\ - def test_users_can_search(): - search_page = "open" - assert "hello" == "hello" - assert True - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs fixes" in captured.out - # scenario "test_payment_approval" status is "1 error" - assert "1 error" in captured.out - assert _test_name == "test_payment_approval" - # violations include missing-literal for "approved" - assert ok_scenarios == 2 - assert total_scenarios == 3 - assert error_scenarios == 1 - - -def test_feature_with_multiple_scenarios_having_violations(tmp_project, config, capsys): - """Feature with multiple non-stub scenarios, all having violations → 'needs fixes'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - _feature_ref = "docs/features/multi_viol.feature" - total_scenarios = 2 - error_scenarios = 2 - login_literal = "username" - logout_literal = "session" - - write_feature( - tmp_project, - "multi_violation", - """\ - Feature: Multi Violation - Scenario: First Action - Given a value of "alpha" - When action occurs - Then result is "beta" - - Scenario: Second Action - Given a value of "gamma" - When action occurs - Then result is "delta" - """, - ) - - # test "test_login" has missing-placeholder violation for "username" - _login_test = "test_login" - assert login_literal == "username" - # test "test_logout" has missing-literal violation for "session" - _logout_test = "test_logout" - assert logout_literal == "session" - - write_test( - tmp_project, - "multi_violation", - "default_test.py", - """\ - def test_first_action(): - assert 1 == 1 - - def test_second_action(): - assert 2 == 2 - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs fixes" in captured.out - assert total_scenarios == 2 - assert error_scenarios == 2 diff --git a/tests/features/status_command/worst_scenario_wins_test.py b/tests/features/status_command/worst_scenario_wins_test.py deleted file mode 100644 index 9d6e84c..0000000 --- a/tests/features/status_command/worst_scenario_wins_test.py +++ /dev/null @@ -1,113 +0,0 @@ -import pytest -from beehave.status import compute_status -from conftest import write_feature, write_test - - -def test_mixed_feature_with_all_scenario_statuses(tmp_project, config, capsys): - """Feature with ok stub and unmapped scenarios → stage derived from worst scenario.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 3 - _feature_ref = "docs/features/mixed.feature" - - write_feature( - tmp_project, - "mixed", - """\ - Feature: Mixed Status Feature - Scenario: Scenario A Ok - Given a step literal "hello" - When action occurs - Then result is "world" - - Scenario: Scenario B Stub - Given a step literal "alpha" - When action occurs - Then result is "beta" - - Scenario: Scenario C Unmapped - Given a step literal "gamma" - When action occurs - Then result is "delta" - """, - ) - - write_test( - tmp_project, - "mixed_status_feature", - "default_test.py", - """\ - def test_scenario_a_ok(): - assert "hello" == "hello" - assert "world" == "world" - - def test_scenario_b_stub(): - pass - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs tests" in captured.out - assert "no test" in captured.out - assert "no body" in captured.out - # scenario A status is "ok" - assert "ok" in captured.out - assert n_scenarios == 3 - - -def test_mixed_feature_ok_and_error_scens(tmp_project, config, capsys): - """Feature with ok and error scenarios → stage 'needs fixes'.""" - features_dir = "docs/features" - tests_dir = "tests/features" - assert (tmp_project / features_dir).exists() - assert (tmp_project / tests_dir).exists() - - n_scenarios = 2 - _feature_ref = "docs/features/ok_plus_errors.feature" - - write_feature( - tmp_project, - "ok_plus_errors", - """\ - Feature: Ok Plus Errors - Scenario: Scenario A Ok - Given a step literal "first" - When action occurs - Then result is - - Scenario: Scenario B Error - Given a step with placeholder - When action occurs - Then result is "done" - """, - ) - - write_test( - tmp_project, - "ok_plus_errors", - "default_test.py", - """\ - def test_scenario_a_ok(): - outcome = "success" - assert "first" == "first" - - def test_scenario_b_error(): - assert "done" == "done" - """, - ) - - with pytest.raises(SystemExit): - compute_status(config) - - captured = capsys.readouterr() - assert "needs fixes" in captured.out - assert "1 error" in captured.out - # scenario A status is "ok" - assert "ok" in captured.out - assert n_scenarios == 2 diff --git a/tests/features/title_validation/__init__.py b/tests/features/title_validation/__init__.py deleted file mode 100644 index aac365f..0000000 --- a/tests/features/title_validation/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Feature tests for global title validation.""" diff --git a/tests/features/title_validation/duplicate_titles_are_detected_test.py b/tests/features/title_validation/duplicate_titles_are_detected_test.py deleted file mode 100644 index 4c48291..0000000 --- a/tests/features/title_validation/duplicate_titles_are_detected_test.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Tests for duplicate title detection across feature files.""" - -from pathlib import Path - -from conftest import write_feature - -from beehave.config import Config -from beehave.gherkin import validate_all_titles - - -def test_duplicate_feature_titles(tmp_project: Path, config: Config) -> None: - """Two feature files with same title (case-insensitive) produce violations.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file_1 = tmp_project / "docs/features/hive_activity.feature" - feature_title_1 = "Hive Activity" - scenario_title_1 = "guard bee inspects visitor" - - write_feature( - tmp_project, - "hive_activity", - f"""\ - Feature: {feature_title_1} - - Scenario: {scenario_title_1} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file_1.exists() - - feature_file_2 = tmp_project / "docs/features/hive_dup.feature" - feature_title_2 = "hive activity" - scenario_title_2 = "forager returns with nectar" - - write_feature( - tmp_project, - "hive_dup", - f"""\ - Feature: {feature_title_2} - - Scenario: {scenario_title_2} - Given a forager bee - When it returns with nectar - Then the nectar is deposited in a cell - """, - ) - assert feature_file_2.exists() - - result = validate_all_titles(config) - assert len(result) == 2 - - error_types = {v.error_type for v in result} - assert error_types == {"duplicate-feature-title"} - - paths = {v.path for v in result} - assert len(paths) == 2 - assert any("hive_activity.feature" in p for p in paths) - assert any("hive_dup.feature" in p for p in paths) - - -def test_rule_matches_feature_title(tmp_project: Path, config: Config) -> None: - """A Rule title matching a Feature title is flagged as duplicate-rule-title.""" - feature_file = tmp_project / "docs/features/rule_v_feature.feature" - assert feature_file.parent.exists() - - feature_title = "Swarm Detection" - rule_title = "swarm detection" - scenario_title = "temperature rise triggers alert" - - write_feature( - tmp_project, - "rule_v_feature", - f"""\ - Feature: {feature_title} - - Rule: {rule_title} - - Scenario: {scenario_title} - Given a hive temperature sensor - When the temperature rises above 40 degrees - Then a swarm alert is triggered - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - - error_types = {v.error_type for v in result} - assert error_types == {"duplicate-rule-title"} - - paths = {v.path for v in result} - assert len(paths) == 1 - assert any("rule_v_feature.feature" in p for p in paths) - - -def test_scenario_matches_feature_title(tmp_project: Path, config: Config) -> None: - """A Scenario title matching a Feature title is flagged as duplicate-scenario-title.""" - feature_file = tmp_project / "docs/features/scenario_feat.feature" - assert feature_file.parent.exists() - - feature_title = "Guard Inspection" - scenario_title = "guard inspection" - - write_feature( - tmp_project, - "scenario_feat", - f"""\ - Feature: {feature_title} - - Scenario: {scenario_title} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - - error_types = {v.error_type for v in result} - assert error_types == {"duplicate-scenario-title"} - - paths = {v.path for v in result} - assert len(paths) == 1 - assert any("scenario_feat.feature" in p for p in paths) - - -def test_scenario_matches_rule_title(tmp_project: Path, config: Config) -> None: - """A Scenario title matching a Rule title is flagged as duplicate-scenario-title.""" - feature_file = tmp_project / "docs/features/scenario_rule.feature" - assert feature_file.parent.exists() - - feature_title = "Hive Activity" - rule_title = "Foraging Patterns" - scenario_title = "Foraging Patterns" - - write_feature( - tmp_project, - "scenario_rule", - f"""\ - Feature: {feature_title} - - Rule: {rule_title} - - Scenario: {scenario_title} - Given a forager bee - When it returns with pollen - Then the pollen is deposited - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - - error_types = {v.error_type for v in result} - assert error_types == {"duplicate-scenario-title"} - - paths = {v.path for v in result} - assert len(paths) == 1 - assert any("scenario_rule.feature" in p for p in paths) - - -def test_duplicate_scenarios(tmp_project: Path, config: Config) -> None: - """Two scenarios in the same file with case-insensitive-equal titles.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/dup_scenarios.feature" - feature_title = "Hive Activity" - scenario_title_1 = "guard bee inspects visitor" - scenario_title_2 = "Guard Bee Inspects Visitor" - - write_feature( - tmp_project, - "dup_scenarios", - f"""\ - Feature: {feature_title} - - Scenario: {scenario_title_1} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - - Scenario: {scenario_title_2} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 2 - - error_types = {v.error_type for v in result} - assert error_types == {"duplicate-scenario-title"} - - paths = {v.path for v in result} - assert len(paths) == 1 - assert any("dup_scenarios.feature" in p for p in paths) - - -def test_mixed_violation_types(tmp_project: Path, config: Config) -> None: - """One file yields both an invalid-title and a duplicate-title violation.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - mixed_file = tmp_project / "docs/features/mixed.feature" - mixed_feature_title = "Hive-Activity" - mixed_rule_title = "Hive Activity" - mixed_scenario_title = "forager returns with nectar" - - write_feature( - tmp_project, - "mixed", - f"""\ - Feature: {mixed_feature_title} - - Rule: {mixed_rule_title} - - Scenario: {mixed_scenario_title} - Given a forager bee - When it returns with nectar - Then the nectar is deposited - """, - ) - assert mixed_file.exists() - - other_file = tmp_project / "docs/features/other.feature" - other_feature_title = "Hive Activity" - other_scenario_title = "other scenario" - - write_feature( - tmp_project, - "other", - f"""\ - Feature: {other_feature_title} - - Scenario: {other_scenario_title} - Given a bee - When it does something - Then it happens - """, - ) - assert other_file.exists() - - result = validate_all_titles(config) - assert len(result) == 2 - - error_types = {v.error_type for v in result} - assert error_types == {"invalid-feature-title", "duplicate-rule-title"} - - paths = {v.path for v in result} - assert len(paths) == 1 - assert any("mixed.feature" in p for p in paths) diff --git a/tests/features/title_validation/title_charset_is_validated_test.py b/tests/features/title_validation/title_charset_is_validated_test.py deleted file mode 100644 index 2cfbc23..0000000 --- a/tests/features/title_validation/title_charset_is_validated_test.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Tests for title character-set validation.""" - -from pathlib import Path - -from conftest import write_feature - -from beehave.config import Config -from beehave.gherkin import validate_all_titles - - -def test_feature_title_with_hyphen(tmp_project: Path, config: Config) -> None: - """A Feature title containing a hyphen is flagged as invalid-feature-title.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/bad_title.feature" - feature_title = "Hive-Activity" - - write_feature( - tmp_project, - "bad_title", - f"""\ - Feature: {feature_title} - - Scenario: simple scenario - Given a bee - When it flies - Then it lands - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - violation = result[0] - assert violation.error_type == "invalid-feature-title" - assert "invalid" in violation.message.lower() - - -def test_rule_title_with_period(tmp_project: Path, config: Config) -> None: - """A Rule title containing a period is flagged as invalid-rule-title.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/bad_rule.feature" - feature_title = "Period Rule" - rule_title = "Guard.Inspection" - - write_feature( - tmp_project, - "bad_rule", - f"""\ - Feature: {feature_title} - - Rule: {rule_title} - Scenario: simple scenario - Given a bee - When it flies - Then it lands - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - violation = result[0] - assert violation.error_type == "invalid-rule-title" - assert "invalid" in violation.message.lower() - - -def test_scenario_title_with_slash(tmp_project: Path, config: Config) -> None: - """A Scenario title containing a slash is flagged as invalid-scenario-title.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/bad_scenario.feature" - feature_title = "Forward Slash Scenario" - scenario_title = "guard/bee/inspects" - - write_feature( - tmp_project, - "bad_scenario", - f"""\ - Feature: {feature_title} - - Scenario: {scenario_title} - Given a bee - When it flies - Then it lands - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - violation = result[0] - assert violation.error_type == "invalid-scenario-title" - assert "invalid" in violation.message.lower() - - -def test_underscore_is_valid_charset(tmp_project: Path, config: Config) -> None: - r"""Underscores are valid characters in titles (matching ``\w``).""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/underscore.feature" - feature_title = "Login_Flow Authentication" - - write_feature( - tmp_project, - "underscore", - f"""\ - Feature: {feature_title} - - Scenario: user signs in with email - Given a user - When they sign in with email - Then they are authenticated - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 0 diff --git a/tests/features/title_validation/title_validation_blocks_generation_test.py b/tests/features/title_validation/title_validation_blocks_generation_test.py deleted file mode 100644 index bf0ce05..0000000 --- a/tests/features/title_validation/title_validation_blocks_generation_test.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Tests that title validation acts as a pre-flight gate during generation.""" - -import sys -from io import StringIO -from pathlib import Path - -import pytest -from conftest import write_feature - -from beehave.config import Config -from beehave.generate import generate_stubs - - -def test_preflight_blocks_generation(tmp_project: Path, config: Config) -> None: - """generate_stubs refuses to run when title validation finds violations.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - # Given: a valid feature file - write_feature( - tmp_project, - "hive_activity", - """\ - Feature: Hive Activity - - Scenario: guard bee inspects visitor - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - - # And: a feature file with an invalid title ("Invalid" has 1 word, - # failing the 2-6 word count rule; validate_all_titles will flag it) - write_feature( - tmp_project, - "bad_title", - """\ - Feature: Invalid - - Scenario: some scenario - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - - # Ensure no stale test output from prior runs - tests_root = tmp_project / "tests" / "features" - hive_test_dir = tests_root / "hive_activity" - - captured = StringIO() - saved_stderr = sys.stderr - sys.stderr = captured - - try: - with pytest.raises(SystemExit) as exc_info: - generate_stubs("hive_activity", config) - assert exc_info.value.code == 1, ( - "generate_stubs must exit with code 1 when pre-flight title " - "validation fails" - ) - finally: - sys.stderr = saved_stderr - - # Then: the output contains a violation for the invalid title - output = captured.getvalue() - assert "Invalid" in output, "expected violation for 'Invalid' in stderr output" - - # And: no test files or directories are created for hive_activity.feature - assert not hive_test_dir.exists(), ( - "no test files or directories must be created when pre-flight fails" - ) diff --git a/tests/features/title_validation/title_violations_included_in_check_test.py b/tests/features/title_validation/title_violations_included_in_check_test.py deleted file mode 100644 index af7be51..0000000 --- a/tests/features/title_validation/title_violations_included_in_check_test.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Tests that check_all includes title validation violations.""" - -from pathlib import Path - -from conftest import write_feature - -from beehave.check import check_all -from beehave.config import Config - - -def test_check_includes_title_and_scenario_violations( - tmp_project: Path, config: Config -) -> None: - """check_all returns both title violations and unmapped-scenario violations.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/bad_title.feature" - feature_title = "Bad-Title" - scenario_title = "simple scenario" - - write_feature( - tmp_project, - "bad_title", - f"""\ - Feature: {feature_title} - - Scenario: {scenario_title} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file.exists() - - # No matching test file is created; check_all will also - # produce an unmapped-scenario violation. - - result = check_all(config) - - error_types = {v.error_type for v in result} - assert "invalid-feature-title" in error_types, ( - f"expected invalid-feature-title, got {error_types}" - ) - assert "unmapped-scenario" in error_types, ( - f"expected unmapped-scenario, got {error_types}" - ) - - title_violations = [ - v - for v in result - if v.error_type.startswith("invalid-") or v.error_type.startswith("duplicate-") - ] - for tv in title_violations: - assert not tv.is_warning, ( - f"title violation '{tv.error_type}' should be an error, not a warning" - ) - - bad_title_violations = [ - v for v in result if v.error_type == "invalid-feature-title" - ] - assert len(bad_title_violations) == 1 - assert "Bad-Title" in bad_title_violations[0].message diff --git a/tests/features/title_validation/title_word_count_is_validated_test.py b/tests/features/title_validation/title_word_count_is_validated_test.py deleted file mode 100644 index fab0961..0000000 --- a/tests/features/title_validation/title_word_count_is_validated_test.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Tests for title word-count validation (2-6 words).""" - -from pathlib import Path - -import pytest -from conftest import write_feature - -from beehave.config import Config -from beehave.gherkin import validate_all_titles - - -def test_feature_title_has_one_word(tmp_project: Path, config: Config) -> None: - """A one-word Feature title is flagged as invalid-feature-title.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/single_word.feature" - feature_title = "Activity" - - write_feature( - tmp_project, - "single_word", - f"""\ - Feature: {feature_title} - - Scenario: simple scenario - Given a bee - When it flies - Then it lands - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - violation = result[0] - assert violation.error_type == "invalid-feature-title" - assert "word" in violation.message.lower() - - -def test_seven_word_title(tmp_project: Path, config: Config) -> None: - """A seven-word Feature title is flagged as invalid-feature-title.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/seven_word_title.feature" - feature_title = "My Seven Word Feature Title Is Here" - - write_feature( - tmp_project, - "seven_word_title", - f"""\ - Feature: {feature_title} - - Scenario: simple scenario - Given a bee - When it flies - Then it lands - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - violation = result[0] - assert violation.error_type == "invalid-feature-title" - assert "word" in violation.message.lower() - - -@pytest.mark.skip(reason="not implemented") -def test_rule_title_has_seven_words() -> None: - """Rule title with seven words.""" - ... - - -@pytest.mark.skip(reason="not implemented") -def test_scenario_title_is_empty_string() -> None: - """Scenario title consisting of an empty string.""" - ... - - -def test_empty_title_after_strip(tmp_project: Path, config: Config) -> None: - """A whitespace-only title is equivalent to empty and is flagged.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/whitespace_title.feature" - feature_title = " " - - write_feature( - tmp_project, - "whitespace_title", - f"""\ - Feature: {feature_title} - - Scenario: simple scenario - Given a bee - When it flies - Then it lands - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert len(result) == 1 - violation = result[0] - assert violation.error_type == "invalid-feature-title" - assert "non-empty" in violation.message.lower() diff --git a/tests/features/title_validation/valid_titles_produce_no_violations_test.py b/tests/features/title_validation/valid_titles_produce_no_violations_test.py deleted file mode 100644 index 74e03c2..0000000 --- a/tests/features/title_validation/valid_titles_produce_no_violations_test.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Tests that valid titles produce no violations.""" - -from pathlib import Path - -import pytest -from conftest import write_feature - -from beehave.config import Config -from beehave.gherkin import validate_all_titles - - -def test_single_valid_file(tmp_project: Path, config: Config) -> None: - """A single file with valid unique titles produces an empty violations list.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/hive_activity.feature" - feature_title = "Hive Activity" - rule_title = "Hive defense" - scenario_title = "guard bee inspects visitor" - - write_feature( - tmp_project, - "hive_activity", - f"""\ - Feature: {feature_title} - - Rule: {rule_title} - Scenario: {scenario_title} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert result == [] - - -def test_two_files_with_valid_unique_titles(tmp_project: Path, config: Config) -> None: - """Two files with distinct valid titles produce no violations.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file_1 = tmp_project / "docs/features/hive_activity.feature" - feature_title_1 = "Hive Activity" - rule_title_1 = "Hive defense" - scenario_title_1 = "guard bee inspects visitor" - - write_feature( - tmp_project, - "hive_activity", - f"""\ - Feature: {feature_title_1} - - Rule: {rule_title_1} - Scenario: {scenario_title_1} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file_1.exists() - - feature_file_2 = tmp_project / "docs/features/comb_construction.feature" - feature_title_2 = "Comb Construction" - rule_title_2 = "Wax Production" - scenario_title_2 = "worker builds hexagonal cells" - - write_feature( - tmp_project, - "comb_construction", - f"""\ - Feature: {feature_title_2} - - Rule: {rule_title_2} - Scenario: {scenario_title_2} - Given a worker bee - When it builds a cell - Then the cell is hexagonal - """, - ) - assert feature_file_2.exists() - - result = validate_all_titles(config) - assert result == [] - - -def test_minimum_word_count_title(tmp_project: Path, config: Config) -> None: - """A two-word title (minimum allowed) produces no violations.""" - features_root = tmp_project / "docs/features" - assert features_root.exists() - - feature_file = tmp_project / "docs/features/minimal.feature" - feature_title = "Minimal Title" - scenario_title = "simple test" - - write_feature( - tmp_project, - "minimal", - f"""\ - Feature: {feature_title} - - Scenario: {scenario_title} - Given a guard bee at the hive entrance - When a visitor bee approaches - Then the guard bee inspects the visitor - """, - ) - assert feature_file.exists() - - result = validate_all_titles(config) - assert result == [] - - -@pytest.mark.skip(reason="not implemented") -def test_maximum_word_count_title() -> None: - """A title with exactly six words passes validation.""" - ... diff --git a/tests/integration/idempotency_test.py b/tests/integration/idempotency_test.py index 9890372..91d34ae 100644 --- a/tests/integration/idempotency_test.py +++ b/tests/integration/idempotency_test.py @@ -3,8 +3,6 @@ import tempfile from pathlib import Path -import pytest - BASE_FEATURE = """\ Feature: Input Scenario: first scenario @@ -61,14 +59,12 @@ def regenerate_over_body(feature_text: str, existing_py_body: str) -> str: return py_path.read_text() -@pytest.mark.pending def test_regenerate_preserves_existing_consumer_py_body() -> None: consumer_marker = "# consumer-authored marker line" regenerated = regenerate_over_body(BASE_FEATURE, consumer_marker) assert consumer_marker in regenerated -@pytest.mark.pending def test_regenerate_does_not_emit_py_when_py_present() -> None: consumer_body = ( "from beehave import step\n" @@ -81,7 +77,6 @@ def test_regenerate_does_not_emit_py_when_py_present() -> None: assert regenerated == consumer_body -@pytest.mark.pending def test_regenerate_rewrites_pyi_when_feature_gains_scenario() -> None: pyi = emit_test_pyi_for(EXTENDED_FEATURE) assert "test_second_scenario" in pyi diff --git a/tests/integration/parsing_test.py b/tests/integration/parsing_test.py index 31881ff..9f0589a 100644 --- a/tests/integration/parsing_test.py +++ b/tests/integration/parsing_test.py @@ -1,13 +1,11 @@ from __future__ import annotations -import pytest - BACKGROUND_WITH_PLACEHOLDER_FEATURE = """\ Feature: Parsing Background: Given a background step with token -Scenario: scenario +Scenario: Hive stays idle Given anything """ @@ -16,7 +14,7 @@ Background: Given a background step without placeholders -Scenario: scenario +Scenario: Hive stays idle Given anything """ @@ -31,11 +29,9 @@ def parse_feature_raises(feature_text: str) -> bool: return False -@pytest.mark.pending def test_background_step_with_placeholder_is_parse_error() -> None: assert parse_feature_raises(BACKGROUND_WITH_PLACEHOLDER_FEATURE) -@pytest.mark.pending def test_background_step_without_placeholder_parses_cleanly() -> None: assert not parse_feature_raises(BACKGROUND_CLEAN_FEATURE) diff --git a/tests/integration/roundtrip_test.py b/tests/integration/roundtrip_test.py index 5b5d41c..9aa1be0 100644 --- a/tests/integration/roundtrip_test.py +++ b/tests/integration/roundtrip_test.py @@ -3,10 +3,8 @@ import tempfile from pathlib import Path -import pytest - ROUND_TRIP_FEATURE = """\ -Feature: Round Trip +Feature: Roundtrip Contract Scenario: round trip Given first step When second step @@ -32,20 +30,17 @@ def check_passes_for(feature_text: str, test_py_text: str) -> bool: return check(feature_text, test_py_text) -@pytest.mark.pending def test_check_passes_on_freshly_generated_py() -> None: py_text = emit_test_py_for(ROUND_TRIP_FEATURE) assert check_passes_for(ROUND_TRIP_FEATURE, py_text) -@pytest.mark.pending def test_check_fails_after_consumer_edits_step_text() -> None: py_text = emit_test_py_for(ROUND_TRIP_FEATURE) edited = py_text.replace("first step", "edited step text") assert not check_passes_for(ROUND_TRIP_FEATURE, edited) -@pytest.mark.pending def test_check_fails_after_consumer_removes_step_block() -> None: shorter_body = ( "from beehave import step\n" @@ -57,7 +52,6 @@ def test_check_fails_after_consumer_removes_step_block() -> None: assert not check_passes_for(ROUND_TRIP_FEATURE, shorter_body) -@pytest.mark.pending def test_check_passes_after_consumer_adds_body_content() -> None: body_with_extra = ( "from beehave import step\n" diff --git a/tests/integration/step_cm_test.py b/tests/integration/step_cm_test.py index a89b918..950ff31 100644 --- a/tests/integration/step_cm_test.py +++ b/tests/integration/step_cm_test.py @@ -5,7 +5,6 @@ NOTE_FORMAT = "{keyword} {text}" -@pytest.mark.pending def test_block_body_executes_when_entered() -> None: from beehave import step @@ -15,7 +14,6 @@ def test_block_body_executes_when_entered() -> None: assert side_effect == ["entered"] -@pytest.mark.pending def test_assertion_inside_then_step_propagates_failure() -> None: from beehave import step @@ -23,7 +21,6 @@ def test_assertion_inside_then_step_propagates_failure() -> None: raise AssertionError -@pytest.mark.pending def test_assertion_inside_then_step_passes_when_truthy() -> None: from beehave import step @@ -31,7 +28,6 @@ def test_assertion_inside_then_step_passes_when_truthy() -> None: assert True -@pytest.mark.pending def test_exception_attributed_to_step_via_add_note() -> None: from beehave import step @@ -44,7 +40,6 @@ def test_exception_attributed_to_step_via_add_note() -> None: ] -@pytest.mark.pending def test_clean_exit_does_not_add_attribution_note() -> None: from beehave import step @@ -52,15 +47,13 @@ def test_clean_exit_does_not_add_attribution_note() -> None: pass -@pytest.mark.pending def test_keyword_and_text_are_positional_only() -> None: from beehave import step with pytest.raises(TypeError): - step(keyword="Then", text="the hive has honey") + step(keyword="Then", text="the hive has honey") # type: ignore[call-arg] -@pytest.mark.pending def test_placeholders_accepted_as_keyword_arguments() -> None: from beehave import step @@ -68,7 +61,6 @@ def test_placeholders_accepted_as_keyword_arguments() -> None: pass -@pytest.mark.pending def test_all_gherkin_step_keywords_accepted() -> None: from beehave import step @@ -78,7 +70,6 @@ def test_all_gherkin_step_keywords_accepted() -> None: pass -@pytest.mark.pending def test_localized_keyword_accepted() -> None: from beehave import step diff --git a/tests/integration/strategy_inference_test.py b/tests/integration/strategy_inference_test.py index 151419f..4b251ab 100644 --- a/tests/integration/strategy_inference_test.py +++ b/tests/integration/strategy_inference_test.py @@ -3,8 +3,6 @@ import tempfile from pathlib import Path -import pytest - INT_COLUMN_FEATURE = """\ Feature: Strategy Inference Scenario Outline: int column @@ -94,37 +92,31 @@ def emitted_function_signature(feature_text: str, scenario_slug: str) -> str: return pyi[start : end + 3] -@pytest.mark.pending def test_all_int_column_infers_int_parameter() -> None: signature = emitted_function_signature(INT_COLUMN_FEATURE, "int_column") assert "amount: int" in signature -@pytest.mark.pending def test_all_float_column_infers_float_parameter() -> None: signature = emitted_function_signature(FLOAT_COLUMN_FEATURE, "float_column") assert "amount: float" in signature -@pytest.mark.pending def test_all_bool_column_infers_bool_parameter() -> None: signature = emitted_function_signature(BOOL_COLUMN_FEATURE, "bool_column") assert "flag: bool" in signature -@pytest.mark.pending def test_mixed_type_column_infers_str_parameter() -> None: signature = emitted_function_signature(MIXED_COLUMN_FEATURE, "mixed_column") assert "amount: str" in signature -@pytest.mark.pending def test_text_column_infers_str_parameter() -> None: signature = emitted_function_signature(TEXT_COLUMN_FEATURE, "text_column") assert "name: str" in signature -@pytest.mark.pending def test_no_examples_table_infers_str_parameter() -> None: signature = emitted_function_signature(NO_EXAMPLES_FEATURE, "no_examples") assert "name: str" in signature diff --git a/tests/integration/title_derivation_test.py b/tests/integration/title_derivation_test.py index 6e5b3fe..6ec7198 100644 --- a/tests/integration/title_derivation_test.py +++ b/tests/integration/title_derivation_test.py @@ -1,17 +1,17 @@ from __future__ import annotations -import pytest +from typing import cast MIN_WORD_COUNT = 2 MAX_WORD_COUNT = 6 def function_name_for_title(title: str) -> str: - from beehave.gherkin import parse_feature + from beehave.gherkin import Scenario, parse_feature feature_text = f"Feature: T\nScenario: {title}\nGiven any step\n" feature = parse_feature(feature_text) - return feature.children[0].function_name + return cast(Scenario, feature.children[0]).function_name def title_is_valid_in_feature(*titles: str) -> bool: @@ -26,17 +26,14 @@ def title_is_valid_in_feature(*titles: str) -> bool: return True -@pytest.mark.pending def test_title_lowered_to_slug() -> None: assert function_name_for_title("Honey Production") == "test_honey_production" -@pytest.mark.pending def test_whitespace_runs_collapse_to_single_underscore() -> None: assert function_name_for_title("Honey Production") == "test_honey_production" -@pytest.mark.pending def test_function_name_is_test_underscore_slug() -> None: assert ( function_name_for_title("forager returns with nectar") @@ -44,31 +41,25 @@ def test_function_name_is_test_underscore_slug() -> None: ) -@pytest.mark.pending def test_two_word_title_is_valid() -> None: assert title_is_valid_in_feature("Honey Production") -@pytest.mark.pending def test_six_word_title_is_valid() -> None: assert title_is_valid_in_feature("the forager returns to the hive") -@pytest.mark.pending def test_one_word_title_rejected() -> None: assert not title_is_valid_in_feature("Honey") -@pytest.mark.pending def test_seven_word_title_rejected() -> None: assert not title_is_valid_in_feature("the forager returns to the busy hive") -@pytest.mark.pending def test_title_with_hyphen_rejected() -> None: assert not title_is_valid_in_feature("Honey-Production") -@pytest.mark.pending def test_duplicate_titles_case_insensitive_rejected() -> None: assert not title_is_valid_in_feature("Hive Activity", "hive activity") diff --git a/tests/test_check.py b/tests/test_check.py deleted file mode 100644 index 8a2a045..0000000 --- a/tests/test_check.py +++ /dev/null @@ -1,406 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from conftest import write_feature, write_test - -from beehave.check import ( - _check_examples_bijection, - _check_literals, - _check_placeholders, - check_all, - check_pair, - check_single, -) -from beehave.config import Config -from beehave.generate import generate_stubs -from beehave.models import ( - ExamplesTable, - Literal, - Placeholder, - ScenarioInfo, - TestInfo, -) - - -def _make_si( - title: str = "test scenario", - function_name: str = "test_test_scenario", - placeholders: tuple[Placeholder, ...] = (), - literals: tuple[Literal, ...] = (), - examples: ExamplesTable | None = None, - is_outline: bool = False, - feature_path: str = "feat", - rule_path: str = "default_test", - feature_title: str = "Feat", - line: int = 1, -) -> ScenarioInfo: - return ScenarioInfo( - title=title, - function_name=function_name, - steps=(), - placeholders=placeholders, - literals=literals, - examples=examples, - is_outline=is_outline, - feature_title=feature_title, - feature_path=feature_path, - rule_path=rule_path, - line=line, - ) - - -def _make_ti( - function_name: str = "test_test_scenario", - body_name_nodes: tuple[str, ...] = (), - body_constant_nodes: tuple[object, ...] = (), - is_stub: bool = False, - example_rows: tuple[dict[str, object], ...] = (), - line: int = 4, -) -> TestInfo: - return TestInfo( - function_name=function_name, - body_name_nodes=body_name_nodes, - body_constant_nodes=body_constant_nodes, - is_stub=is_stub, - example_rows=example_rows, - line=line, - ) - - -class TestCheckPlaceholders: - def test_pass_when_present(self) -> None: - si = _make_si(placeholders=(Placeholder("x"),)) - ti = _make_ti(body_name_nodes=("x",)) - assert _check_placeholders(si, ti, "test.py") == [] - - def test_fail_when_missing(self) -> None: - si = _make_si(placeholders=(Placeholder("x"),)) - ti = _make_ti(body_name_nodes=()) - v = _check_placeholders(si, ti, "test.py") - assert len(v) == 1 - assert v[0].error_type == "missing-placeholder" - - def test_stub_skips_check(self) -> None: - si = _make_si(placeholders=(Placeholder("x"),)) - ti = _make_ti(body_name_nodes=(), is_stub=True) - assert _check_placeholders(si, ti, "test.py") == [] - - def test_only_body_checked_not_given_kwargs(self) -> None: - si = _make_si(placeholders=(Placeholder("x"),)) - ti = TestInfo( - function_name="test_test_scenario", - given_kwargs=("x",), - body_name_nodes=(), - is_stub=False, - line=4, - ) - v = _check_placeholders(si, ti, "test.py") - assert len(v) == 1 - assert v[0].error_type == "missing-placeholder" - - -class TestCheckLiterals: - def test_pass_when_present(self) -> None: - si = _make_si(literals=(Literal("hello", '"hello"'),)) - ti = _make_ti(body_constant_nodes=("hello",)) - assert _check_literals(si, ti, "test.py") == [] - - def test_fail_when_missing(self) -> None: - si = _make_si(literals=(Literal("hello", '"hello"'),)) - ti = _make_ti(body_constant_nodes=()) - v = _check_literals(si, ti, "test.py") - assert len(v) == 1 - assert v[0].error_type == "missing-literal" - - def test_stub_skips_check(self) -> None: - si = _make_si(literals=(Literal("hello", '"hello"'),)) - ti = _make_ti(body_constant_nodes=(), is_stub=True) - assert _check_literals(si, ti, "test.py") == [] - - -class TestCheckExamplesBijection: - def test_matching_rows_pass(self) -> None: - si = _make_si( - is_outline=True, - examples=ExamplesTable( - headers=("a", "b"), - rows=(("1", "2"), ("3", "4")), - ), - ) - ti = _make_ti( - example_rows=({"a": 1, "b": 2}, {"a": 3, "b": 4}), - ) - v = _check_examples_bijection(si, ti, "test.py", "feat.feature") - assert v == [] - - def test_missing_example_row(self) -> None: - si = _make_si( - is_outline=True, - examples=ExamplesTable( - headers=("a",), - rows=(("1",), ("2",)), - ), - ) - ti = _make_ti(example_rows=({"a": 1},)) - v = _check_examples_bijection(si, ti, "test.py", "feat.feature") - assert any("Examples row 2" in x.message for x in v) - - def test_extra_example_decorator(self) -> None: - si = _make_si( - is_outline=True, - examples=ExamplesTable( - headers=("a",), - rows=(("1",),), - ), - ) - ti = _make_ti(example_rows=({"a": 1}, {"a": 2})) - v = _check_examples_bijection(si, ti, "test.py", "feat.feature") - assert any("has no matching Examples row" in x.message for x in v) - - def test_non_outline_skips(self) -> None: - si = _make_si(is_outline=False) - ti = _make_ti() - assert _check_examples_bijection(si, ti, "test.py", "feat.feature") == [] - - -class TestCheckPair: - def test_unmapped_scenario(self) -> None: - si = _make_si() - v = check_pair(si, None, "test.py", "feat.feature") - assert len(v) == 1 - assert v[0].error_type == "unmapped-scenario" - - def test_matching_pair_clean(self) -> None: - si = _make_si(placeholders=(Placeholder("x"),)) - ti = _make_ti(body_name_nodes=("x",)) - assert check_pair(si, ti, "test.py", "feat.feature") == [] - - -class TestCheckSingle: - def test_clean_stub_passes(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "clean", - """\ - Feature: Clean Check - Scenario: hello world - Given stuff - """, - ) - generate_stubs("clean", config) - violations = check_single(fp, config) - assert violations == [] - - def test_unmapped_test_detected(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "unmapped", - """\ - Feature: Unmapped Check - Scenario: exists now - Given stuff - """, - ) - generate_stubs("unmapped", config) - write_test( - tmp_project, - "unmapped_check", - "default_test.py", - """\ - def test_exists_now(): - ... - - def test_unmapped_function(): - ... - """, - ) - violations = check_single(fp, config) - types = [v.error_type for v in violations] - assert "unmapped-test" in types - - def test_missing_placeholder_detected( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "ph", - """\ - Feature: PH Check - Scenario: check ph - Given the hive has nectar - """, - ) - generate_stubs("ph", config) - write_test( - tmp_project, - "ph_check", - "default_test.py", - """\ - from hypothesis import given, strategies as st - - @given(amount=st.integers()) - def test_check_ph(amount): - assert True - """, - ) - violations = check_single(fp, config) - types = [v.error_type for v in violations] - assert "missing-placeholder" in types - - def test_missing_literal_detected(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "lit", - """\ - Feature: Lit Check - Scenario: check lit - Given the bee smells "rose" - """, - ) - generate_stubs("lit", config) - write_test( - tmp_project, - "lit_check", - "default_test.py", - """\ - def test_check_lit(): - assert True - """, - ) - violations = check_single(fp, config) - types = [v.error_type for v in violations] - assert "missing-literal" in types - - def test_misplaced_test_after_rule_removal( - self, tmp_project: Path, config: Config - ) -> None: - write_feature( - tmp_project, - "mv", - """\ - Feature: Move Check - Scenario: top level - Given stuff - - Rule: Sub Rule - Scenario: sub scenario - Given things - """, - ) - generate_stubs("mv", config) - write_feature( - tmp_project, - "mv", - """\ - Feature: Move Check - Scenario: top level - Given stuff - - Scenario: sub scenario - Given things - """, - ) - generate_stubs("mv", config) - fp = tmp_project / "docs" / "features" / "mv.feature" - violations = check_single(fp, config) - warnings = [v for v in violations if v.error_type == "misplaced-test"] - assert len(warnings) >= 1 - assert all(v.is_warning for v in warnings) - - def test_rule_based_files_checked(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "rules", - """\ - Feature: Rules Check - Rule: Alpha Rule - Scenario: alpha one - Given stuff - """, - ) - generate_stubs("rules", config) - violations = check_single(fp, config) - assert violations == [] - - -class TestCheckAll: - def test_checks_all_features(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "f1", - """\ - Feature: Feature One - Scenario: scenario one - Given stuff - """, - ) - write_feature( - tmp_project, - "f2", - """\ - Feature: Feature Two - Scenario: scenario two - Given stuff - """, - ) - generate_stubs("f1", config) - generate_stubs("f2", config) - violations = check_all(config) - assert violations == [] - - def test_detects_cross_feature_unmapped( - self, tmp_project: Path, config: Config - ) -> None: - write_feature( - tmp_project, - "cross", - """\ - Feature: Cross Check - Scenario: cross scenario - Given stuff - """, - ) - generate_stubs("cross", config) - write_test( - tmp_project, - "cross_check", - "default_test.py", - """\ - def test_cross_scenario(): - ... - - def test_unmapped(): - ... - """, - ) - violations = check_all(config) - types = [v.error_type for v in violations] - assert "unmapped-test" in types - - def test_subdirectory_features_found( - self, tmp_project: Path, config: Config - ) -> None: - write_feature( - tmp_project, - "cart/shopping", - """\ - Feature: Cart Shopping - Scenario: add item - Given stuff - """, - ) - write_feature( - tmp_project, - "smoke", - """\ - Feature: Smoke Test - Scenario: everything is fine - Given stuff - """, - ) - - generate_stubs("cart/shopping", config) - generate_stubs("smoke", config) - violations = check_all(config) - assert violations == [] diff --git a/tests/test_clean.py b/tests/test_clean.py deleted file mode 100644 index 3a2236c..0000000 --- a/tests/test_clean.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from conftest import write_feature, write_test - -from beehave.clean import clean_unmapped -from beehave.config import Config -from beehave.generate import generate_stubs - - -class TestCleanUnmapped: - def test_removes_stub_silently(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "cln1", - """\ - Feature: Cln One - Scenario: keep me - Given stuff - """, - ) - generate_stubs("cln1", config) - write_test( - tmp_project, - "cln_one", - "default_test.py", - """\ - def test_keep_me(): - ... - - def test_unmapped(): - ... - """, - ) - clean_unmapped("cln1", config) - content = ( - tmp_project / "tests" / "features" / "cln_one" / "default_test.py" - ).read_text() - assert "test_keep_me" in content - assert "test_unmapped" not in content - - def test_warns_on_non_stub( - self, tmp_project: Path, config: Config, capsys: pytest.CaptureFixture[str] - ) -> None: - write_feature( - tmp_project, - "cln2", - """\ - Feature: Cln Two - Scenario: keep me - Given stuff - """, - ) - generate_stubs("cln2", config) - write_test( - tmp_project, - "cln_two", - "default_test.py", - """\ - def test_keep_me(): - ... - - def test_real(): - assert True - """, - ) - clean_unmapped("cln2", config) - captured = capsys.readouterr() - assert "Warning" in captured.out - assert "test_real" in captured.out - test_dir = tmp_project / "tests" / "features" / "cln_two" / "default_test.py" - content = test_dir.read_text() - assert "test_real" in content - - def test_force_removes_non_stub(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "cln3", - """\ - Feature: Cln Three - Scenario: keep me - Given stuff - """, - ) - generate_stubs("cln3", config) - write_test( - tmp_project, - "cln_three", - "default_test.py", - """\ - def test_keep_me(): - ... - - def test_real(): - assert True - """, - ) - clean_unmapped("cln3", config, force=True) - content = ( - tmp_project / "tests" / "features" / "cln_three" / "default_test.py" - ).read_text() - assert "test_keep_me" in content - assert "test_real" not in content - - def test_nonexistent_feature_exits(self, tmp_project: Path, config: Config) -> None: - with pytest.raises(SystemExit): - clean_unmapped("nonexistent", config) - - def test_rule_based_clean(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "clnr", - """\ - Feature: Cln Rule - Rule: Alpha Rule - Scenario: keep alpha - Given stuff - """, - ) - generate_stubs("clnr", config) - test_file = ( - tmp_project / "tests" / "features" / "cln_rule" / "alpha_rule_test.py" - ) - content = ( - test_file.read_text() - + """ -def test_unmapped(): - ... -""" - ) - test_file.write_text(content) - clean_unmapped("clnr", config) - content = test_file.read_text() - assert "test_keep_alpha" in content - assert "test_unmapped" not in content diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 9983d69..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,221 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from conftest import write_feature, write_test - -from beehave.cli import main -from beehave.config import Config -from beehave.generate import generate_stubs - - -class TestCliGenerate: - def test_generate_creates_files(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "cli_gen", - """\ - Feature: CLI Gen - Scenario: hello world - Given stuff - """, - ) - main(["generate", "cli_gen"]) - test_file = tmp_project / "tests" / "features" / "cli_gen" / "default_test.py" - assert test_file.exists() - - def test_generate_missing_feature_exits(self, tmp_project: Path) -> None: - with pytest.raises(SystemExit): - main(["generate", "nonexistent"]) - - -class TestCliCheck: - def test_check_clean_exits_zero(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "cli_chk", - """\ - Feature: CLI Chk - Scenario: hello world - Given stuff - """, - ) - generate_stubs("cli_chk", config) - main(["check", "cli_chk"]) - - def test_check_violation_exits_one( - self, tmp_project: Path, config: Config, capsys: pytest.CaptureFixture[str] - ) -> None: - write_feature( - tmp_project, - "cli_bad", - """\ - Feature: CLI Bad - Scenario: broken test - Given the hive has nectar - """, - ) - generate_stubs("cli_bad", config) - write_test( - tmp_project, - "cli_bad", - "default_test.py", - """\ - from hypothesis import given, strategies as st - - @given(amount=st.integers()) - def test_broken_test(amount): - assert True - """, - ) - with pytest.raises(SystemExit) as exc_info: - main(["check", "cli_bad"]) - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "missing-placeholder" in captured.out - - def test_check_all_no_args(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "cli_all", - """\ - Feature: CLI All - Scenario: hello world - Given stuff - """, - ) - generate_stubs("cli_all", config) - main(["check"]) - - def test_check_warning_only_exits_zero( - self, tmp_project: Path, config: Config - ) -> None: - write_feature( - tmp_project, - "cli_warn", - """\ - Feature: CLI Warn - Scenario: top level - - Rule: Sub Rule - Scenario: sub level - Given stuff - """, - ) - generate_stubs("cli_warn", config) - write_feature( - tmp_project, - "cli_warn", - """\ - Feature: CLI Warn - Scenario: top level - - Scenario: sub level - Given stuff - """, - ) - generate_stubs("cli_warn", config) - main(["check", "cli_warn"]) - - -class TestCliClean: - def test_clean_removes_stub( - self, tmp_project: Path, config: Config, capsys: pytest.CaptureFixture[str] - ) -> None: - write_feature( - tmp_project, - "cli_cln", - """\ - Feature: CLI Cln - Scenario: keep me - Given stuff - """, - ) - generate_stubs("cli_cln", config) - write_test( - tmp_project, - "cli_cln", - "default_test.py", - """\ - def test_keep_me(): - ... - - def test_unmapped(): - ... - """, - ) - main(["clean", "cli_cln"]) - content = ( - tmp_project / "tests" / "features" / "cli_cln" / "default_test.py" - ).read_text() - assert "test_unmapped" not in content - - -class TestCliList: - def test_list_shows_features( - self, tmp_project: Path, config: Config, capsys: pytest.CaptureFixture[str] - ) -> None: - write_feature( - tmp_project, - "list1", - """\ - Feature: List One - Scenario: hello world - Given stuff - """, - ) - write_feature( - tmp_project, - "list2", - """\ - Feature: List Two - Scenario: hello world - Given stuff - """, - ) - main(["list"]) - captured = capsys.readouterr() - assert "list1: List One" in captured.out - assert "list2: List Two" in captured.out - - def test_list_verbose( - self, tmp_project: Path, config: Config, capsys: pytest.CaptureFixture[str] - ) -> None: - write_feature( - tmp_project, - "lv", - """\ - Feature: List Verbose - Scenario: hello world - Given stuff - """, - ) - generate_stubs("lv", config) - main(["list", "--verbose"]) - captured = capsys.readouterr() - assert "scenarios:" in captured.out - assert "stubs:" in captured.out - - def test_list_empty_exits_zero( - self, tmp_project: Path, capsys: pytest.CaptureFixture[str] - ) -> None: - main(["list"]) - captured = capsys.readouterr() - assert captured.out == "" - - def test_list_nested_feature( - self, tmp_project: Path, config: Config, capsys: pytest.CaptureFixture[str] - ) -> None: - write_feature( - tmp_project, - "nested/inner", - """\ - Feature: Nested Inner - Scenario: hello world - Given stuff - """, - ) - main(["list"]) - captured = capsys.readouterr() - assert "nested/inner: Nested Inner" in captured.out diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 6e8db46..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,69 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from hypothesis import given, settings -from hypothesis import strategies as st - -from beehave.config import VALID_STRATEGIES, Config, load_config - - -class TestConfig: - def test_defaults(self) -> None: - c = Config() - assert c.features_dir == "docs/features" - assert c.tests_dir == "tests/features" - assert c.default_strategy == "text" - assert c.background_check_numeric is True - assert c.background_check_string is True - - def test_invalid_strategy_raises(self) -> None: - with pytest.raises(ValueError, match="Invalid default_strategy"): - Config(default_strategy="bad") - - def test_strategy_expr(self) -> None: - assert Config(default_strategy="text").default_strategy_expr == "st.text()" - assert ( - Config(default_strategy="integers").default_strategy_expr == "st.integers()" - ) - assert Config(default_strategy="floats").default_strategy_expr == "st.floats()" - assert ( - Config(default_strategy="booleans").default_strategy_expr == "st.booleans()" - ) - - @given(st.sampled_from(list(VALID_STRATEGIES))) - @settings(max_examples=10) - def test_all_valid_strategies_accepted(self, strategy: str) -> None: - c = Config(default_strategy=strategy) - assert c.default_strategy == strategy - - def test_load_config_no_pyproject(self, tmp_path: Path) -> None: - c = load_config(tmp_path) - assert c.features_dir == "docs/features" - - def test_load_config_with_pyproject(self, tmp_path: Path) -> None: - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text( - '[tool.beehave]\nfeatures_dir = "custom/features"\n' - 'tests_dir = "custom/tests"\n' - 'default_strategy = "integers"\n' - "background_check_numeric = false\n", - encoding="utf-8", - ) - c = load_config(tmp_path) - assert c.features_dir == "custom/features" - assert c.tests_dir == "custom/tests" - assert c.default_strategy == "integers" - assert c.background_check_numeric is False - - def test_load_config_partial_override(self, tmp_path: Path) -> None: - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text( - '[tool.beehave]\nfeatures_dir = "my/features"\n', - encoding="utf-8", - ) - c = load_config(tmp_path) - assert c.features_dir == "my/features" - assert c.tests_dir == "tests/features" - assert c.background_check_numeric is True diff --git a/tests/test_discover.py b/tests/test_discover.py deleted file mode 100644 index 2516f9a..0000000 --- a/tests/test_discover.py +++ /dev/null @@ -1,192 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from conftest import write_test - -from beehave.discover import ( - DiscoverError, - _extract_body_nodes, - _is_stub_body, - discover_tests, -) - - -class TestIsStubBody: - def test_pass_is_stub(self) -> None: - import ast - - body = ast.parse("pass").body - assert _is_stub_body(body) is True - - def test_ellipsis_is_stub(self) -> None: - import ast - - body = ast.parse("...").body - assert _is_stub_body(body) is True - - def test_real_code_not_stub(self) -> None: - import ast - - body = ast.parse("x = 1\ny = 2").body - assert _is_stub_body(body) is False - - def test_docstring_plus_pass_not_stub(self) -> None: - import ast - - body = ast.parse('"docstring"\npass').body - assert _is_stub_body(body) is False - - -class TestExtractBodyNodes: - def test_names_extracted(self) -> None: - import ast - - body = ast.parse("x = y + z").body - names, _ = _extract_body_nodes(body) - assert "x" in names - assert "y" in names - assert "z" in names - - def test_constants_extracted(self) -> None: - import ast - - body = ast.parse('x = 42\ny = "hello"').body - _names, constants = _extract_body_nodes(body) - assert 42 in constants - assert "hello" in constants - - def test_leading_docstring_skipped(self) -> None: - import ast - - body = ast.parse('"This is a docstring"\nx = 1').body - _names, constants = _extract_body_nodes(body) - assert 1 in constants - - def test_empty_body(self) -> None: - - body: list = [] - names, constants = _extract_body_nodes(body) - assert names == () - assert constants == () - - -class TestDiscoverTests: - def test_discovers_test_functions(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "myfeat", - "default_test.py", - """\ - from hypothesis import given, strategies as st - - @given(x=st.integers()) - def test_something(x): - assert x == x - - def test_plain(): - pass - """, - ) - result = discover_tests(p) - assert "test_something" in result - assert "test_plain" in result - assert result["test_something"].given_kwargs == ("x",) - assert result["test_plain"].given_kwargs == () - - def test_stub_detection(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "stubs", - "default_test.py", - """\ - def test_stub(): - ... - def test_real(): - assert True - """, - ) - result = discover_tests(p) - assert result["test_stub"].is_stub is True - assert result["test_real"].is_stub is False - - def test_example_rows_extracted(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "ex", - "default_test.py", - """\ - from hypothesis import example, given, strategies as st - - @example(x=1, y=2) - @example(x=3, y=4) - @given(x=st.integers(), y=st.integers()) - def test_outline(x, y): - ... - """, - ) - result = discover_tests(p) - ti = result["test_outline"] - assert len(ti.example_rows) == 2 - assert ti.example_rows[0] == {"x": 1, "y": 2} - - def test_body_name_nodes(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "names", - "default_test.py", - """\ - def test_names(amount): - result = process(amount) - assert result - """, - ) - result = discover_tests(p) - ti = result["test_names"] - assert "amount" in ti.body_name_nodes - assert "result" in ti.body_name_nodes - assert "process" in ti.body_name_nodes - - def test_body_constant_nodes(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "consts", - "default_test.py", - """\ - def test_consts(): - assert "floral" in scents - assert 42 == count - """, - ) - result = discover_tests(p) - ti = result["test_consts"] - assert "floral" in ti.body_constant_nodes - assert 42 in ti.body_constant_nodes - - def test_non_test_functions_ignored(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "non", - "default_test.py", - """\ - def helper(): - pass - """, - ) - result = discover_tests(p) - assert "helper" not in result - - def test_nonexistent_file_returns_empty(self) -> None: - result = discover_tests(Path("/nonexistent/file.py")) - assert result == {} - - def test_syntax_error_raises(self, tmp_project: Path) -> None: - p = write_test( - tmp_project, - "bad", - "default_test.py", - "def test_broken(\n", - ) - with pytest.raises(DiscoverError): - discover_tests(p) diff --git a/tests/test_generate.py b/tests/test_generate.py deleted file mode 100644 index a13a915..0000000 --- a/tests/test_generate.py +++ /dev/null @@ -1,287 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from conftest import write_feature -from hypothesis import given, settings -from hypothesis import strategies as st - -from beehave.config import Config -from beehave.generate import ( - _build_import_block, - _generate_function, - _infer_strategy_from_examples, - _parse_existing_imports, - generate_stubs, -) -from beehave.models import ( - ExamplesTable, - Placeholder, - ScenarioInfo, - coerce_example_value, -) - - -def _make_si( - title: str = "test scenario", - function_name: str = "test_test_scenario", - placeholders: tuple[Placeholder, ...] = (), - examples: ExamplesTable | None = None, - is_outline: bool = False, - feature_path: str = "feat", - rule_path: str = "default_test", - feature_title: str = "Feat", -) -> ScenarioInfo: - return ScenarioInfo( - title=title, - function_name=function_name, - steps=(), - placeholders=placeholders, - literals=(), - examples=examples, - is_outline=is_outline, - feature_title=feature_title, - feature_path=feature_path, - rule_path=rule_path, - ) - - -class TestCoerceExampleValue: - def test_integer(self) -> None: - assert coerce_example_value("42") == 42 - - def test_negative_integer(self) -> None: - assert coerce_example_value("-5") == -5 - - def test_float(self) -> None: - result = coerce_example_value("3.14") - assert isinstance(result, float) - assert abs(result - 3.14) < 1e-10 - - def test_true(self) -> None: - assert coerce_example_value("true") is True - - def test_false(self) -> None: - assert coerce_example_value("false") is False - - def test_quoted_string(self) -> None: - assert coerce_example_value('"hello"') == "hello" - - def test_plain_text(self) -> None: - assert coerce_example_value("hello") == "hello" - - @given(st.integers(min_value=-1000, max_value=1000)) - @settings(max_examples=50) - def test_integer_roundtrip(self, n: int) -> None: - assert coerce_example_value(str(n)) == n - - @given(st.from_regex(r"-?\d+\.\d+", fullmatch=True)) - @settings(max_examples=30) - def test_float_detection(self, s: str) -> None: - result = coerce_example_value(s) - assert isinstance(result, float) - - -class TestInferStrategy: - def test_all_integers(self) -> None: - table = ExamplesTable(headers=("x",), rows=(("1",), ("2",), ("3",))) - assert _infer_strategy_from_examples("x", table) == "st.integers()" - - def test_all_floats(self) -> None: - table = ExamplesTable(headers=("x",), rows=(("1.0",), ("2.5",))) - assert _infer_strategy_from_examples("x", table) == "st.floats()" - - def test_all_booleans(self) -> None: - table = ExamplesTable(headers=("x",), rows=(("true",), ("false",))) - assert _infer_strategy_from_examples("x", table) == "st.booleans()" - - def test_mixed_defaults_to_text(self) -> None: - table = ExamplesTable(headers=("x",), rows=(("1",), ("hello",))) - assert _infer_strategy_from_examples("x", table) == "st.text()" - - -class TestBuildImportBlock: - def test_no_placeholders_no_import(self) -> None: - scenarios = {"test_a": _make_si(placeholders=())} - assert _build_import_block(scenarios) == [] - - def test_with_placeholders(self) -> None: - scenarios = {"test_a": _make_si(placeholders=(Placeholder("x"),))} - block = _build_import_block(scenarios) - assert len(block) == 2 - assert "from hypothesis import given, strategies as st" in block[0] - - def test_with_outline(self) -> None: - scenarios = { - "test_a": _make_si( - is_outline=True, - examples=ExamplesTable(headers=("x",), rows=(("1",),)), - ) - } - block = _build_import_block(scenarios) - assert any("example" in line for line in block) - - -class TestGenerateFunction: - def test_simple_function(self) -> None: - si = _make_si( - function_name="test_simple", - placeholders=(Placeholder("x"),), - ) - result = _generate_function(si, set(), Config()) - assert "def test_simple(x):" in result - assert "@given(x=st.text())" in result - assert " ..." in result - - def test_no_params(self) -> None: - si = _make_si(function_name="test_plain", placeholders=()) - result = _generate_function(si, set(), Config()) - assert "def test_plain():" in result - assert "@given" not in result - - def test_outline_generates_examples(self) -> None: - si = _make_si( - function_name="test_outline", - placeholders=(Placeholder("a"), Placeholder("b")), - is_outline=True, - examples=ExamplesTable( - headers=("a", "b"), - rows=(("1", "2"), ("3", "4")), - ), - ) - result = _generate_function(si, set(), Config()) - assert "@example(a=1, b=2)" in result - assert "@example(a=3, b=4)" in result - - -class TestParseExistingImports: - def test_extracts_hypothesis_imports(self) -> None: - source = "from hypothesis import given, strategies as st\n" - result = _parse_existing_imports(source) - assert "given" in result - assert "st" in result - - def test_non_hypothesis_ignored(self) -> None: - source = "from os import path\n" - result = _parse_existing_imports(source) - assert result == set() - - -class TestGenerateStubs: - def test_creates_default_test(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "gen1", - """\ - Feature: Gen One - Scenario: hello world - Given stuff - """, - ) - generate_stubs("gen1", config) - test_file = tmp_project / "tests" / "features" / "gen_one" / "default_test.py" - assert test_file.exists() - content = test_file.read_text() - assert "def test_hello_world():" in content - - def test_creates_rule_files(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "gen2", - """\ - Feature: Gen Two - Scenario: top level - - Rule: My Rule - Scenario: in rule - Given stuff - """, - ) - generate_stubs("gen2", config) - default = tmp_project / "tests" / "features" / "gen_two" / "default_test.py" - rule = tmp_project / "tests" / "features" / "gen_two" / "my_rule_test.py" - assert default.exists() - assert rule.exists() - assert "def test_top_level():" in default.read_text() - assert "def test_in_rule():" in rule.read_text() - - def test_idempotent(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "idem", - """\ - Feature: Idem Feature - Scenario: once only - Given stuff - """, - ) - generate_stubs("idem", config) - first = ( - tmp_project / "tests" / "features" / "idem_feature" / "default_test.py" - ).read_text() - generate_stubs("idem", config) - second = ( - tmp_project / "tests" / "features" / "idem_feature" / "default_test.py" - ).read_text() - assert first == second - - def test_scenario_outline_with_examples( - self, tmp_project: Path, config: Config - ) -> None: - write_feature( - tmp_project, - "outline", - """\ - Feature: Outline Feature - Scenario Outline: addition test - Given and - - Examples: - | a | b | - | 1 | 2 | - """, - ) - generate_stubs("outline", config) - content = ( - tmp_project / "tests" / "features" / "outline_feature" / "default_test.py" - ).read_text() - assert "@example(a=1, b=2)" in content - assert "@given(a=st.integers(), b=st.integers())" in content - - def test_nonexistent_feature_exits(self, tmp_project: Path, config: Config) -> None: - with pytest.raises(SystemExit): - generate_stubs("nonexistent", config) - - def test_creates_init_py(self, tmp_project: Path, config: Config) -> None: - write_feature( - tmp_project, - "initcheck", - """\ - Feature: Init Check - Scenario: hello world - Given stuff - """, - ) - generate_stubs("initcheck", config) - init_file = tmp_project / "tests" / "features" / "init_check" / "__init__.py" - assert init_file.exists() - - def test_does_not_overwrite_existing_init_py( - self, tmp_project: Path, config: Config - ) -> None: - write_feature( - tmp_project, - "initexist", - """\ - Feature: Init Exist - Scenario: hello world - Given stuff - """, - ) - test_dir = tmp_project / "tests" / "features" / "init_exist" - test_dir.mkdir(parents=True, exist_ok=True) - init_file = test_dir / "__init__.py" - init_file.write_text("# custom init\n", encoding="utf-8") - generate_stubs("initexist", config) - assert init_file.read_text() == "# custom init\n" diff --git a/tests/test_gherkin.py b/tests/test_gherkin.py deleted file mode 100644 index ce98e35..0000000 --- a/tests/test_gherkin.py +++ /dev/null @@ -1,476 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from conftest import write_feature -from hypothesis import given, settings -from hypothesis import strategies as st - -from beehave.config import Config -from beehave.gherkin import ( - GherkinError, - _derive_feature_path, - _derive_function_name, - _derive_rule_path, - _extract_literals, - _extract_placeholders, - _validate_title, - parse_feature, -) - - -class TestValidateTitle: - def test_empty_raises(self) -> None: - with pytest.raises(GherkinError, match="non-empty"): - _validate_title("", "Scenario") - - def test_whitespace_only_raises(self) -> None: - with pytest.raises(GherkinError, match="non-empty"): - _validate_title(" ", "Scenario") - - def test_special_chars_raises(self) -> None: - with pytest.raises(GherkinError, match="invalid characters"): - _validate_title("hello@world", "Scenario") - - def test_valid_passes(self) -> None: - _validate_title("simple scenario", "Scenario") - - def test_unicode_letters_pass(self) -> None: - _validate_title("café résumé", "Scenario") - - @given(st.from_regex(r"[a-zA-Z]\w*", fullmatch=True)) - @settings(max_examples=50) - def test_valid_titles_never_raise(self, title: str) -> None: - _validate_title(title, "Scenario") - - -class TestDeriveFunctionName: - def test_simple(self) -> None: - assert _derive_function_name("hello world") == "test_hello_world" - - def test_extra_spaces_collapsed(self) -> None: - assert _derive_function_name("hello world") == "test_hello_world" - - def test_leading_trimming(self) -> None: - assert _derive_function_name(" hello ") == "test_hello" - - def test_invalid_identifier_raises(self) -> None: - with pytest.raises(GherkinError, match="not a valid Python identifier"): - _derive_function_name("hello-world") - - def test_uppercase_lowered(self) -> None: - assert _derive_function_name("Add Single Item") == "test_add_single_item" - - def test_mixed_case_lowered(self) -> None: - assert _derive_function_name("EvErYtHiNg") == "test_everything" - - def test_case_insensitive_collision(self) -> None: - assert _derive_function_name("Test") == _derive_function_name("tEsT") - - @given(st.from_regex(r"[a-zA-Z_][a-zA-Z0-9_]*", fullmatch=True)) - @settings(max_examples=50) - def test_single_word_always_valid(self, word: str) -> None: - result = _derive_function_name(word) - assert result.startswith("test_") - assert result.isidentifier() - assert result == result.lower() - - -class TestDeriveFeaturePath: - def test_simple(self) -> None: - assert _derive_feature_path("Bank Account") == "bank_account" - - def test_extra_spaces(self) -> None: - assert _derive_feature_path("Bank Account") == "bank_account" - - def test_already_lowercase(self) -> None: - assert _derive_feature_path("bank") == "bank" - - -class TestDeriveRulePath: - def test_simple(self) -> None: - assert _derive_rule_path("Hive Defense") == "hive_defense" - - def test_matches_feature_path_algorithm(self) -> None: - assert _derive_rule_path("A B C") == _derive_feature_path("A B C") - - -class TestExtractPlaceholders: - def test_no_placeholders(self) -> None: - assert _extract_placeholders("hello world") == () - - def test_single(self) -> None: - result = _extract_placeholders("has grams") - assert len(result) == 1 - assert result[0].name == "amount" - - def test_duplicate_deduped(self) -> None: - result = _extract_placeholders(" and again") - assert len(result) == 1 - - def test_invalid_identifier_raises(self) -> None: - with pytest.raises(GherkinError, match="not a valid Python identifier"): - _extract_placeholders("<123bad>") - - def test_keyword_raises(self) -> None: - with pytest.raises(GherkinError, match="Python keyword"): - _extract_placeholders("") - - def test_builtin_raises(self) -> None: - with pytest.raises(GherkinError, match="shadows a Python builtin"): - _extract_placeholders("") - - @given( - st.from_regex(r"[a-zA-Z_][a-zA-Z0-9_]*", fullmatch=True).filter( - lambda s: ( - s - not in ( - "len", - "str", - "int", - "float", - "bool", - "list", - "dict", - "set", - "tuple", - "type", - "print", - "input", - "range", - "enumerate", - "zip", - "map", - "filter", - "sorted", - "reversed", - "open", - "isinstance", - "issubclass", - "hasattr", - "getattr", - "setattr", - "delattr", - "property", - "staticmethod", - "classmethod", - "super", - "object", - "Exception", - "BaseException", - "True", - "False", - "None", - ) - ) - ) - ) - @settings(max_examples=30) - def test_valid_identifiers_extracted(self, name: str) -> None: - import keyword - - if keyword.iskeyword(name): - with pytest.raises(GherkinError): - _extract_placeholders(f"<{name}>") - else: - result = _extract_placeholders(f"<{name}>") - assert len(result) == 1 - assert result[0].name == name - - -class TestExtractLiterals: - def test_numeric(self) -> None: - result = _extract_literals("has 100 items") - assert len(result) == 1 - assert result[0].value == 100 - - def test_double_quoted_string(self) -> None: - result = _extract_literals('named "Alice"') - assert len(result) == 1 - assert result[0].value == "Alice" - assert result[0].raw == '"Alice"' - - def test_single_quoted_string(self) -> None: - result = _extract_literals("named 'Bob'") - assert len(result) == 1 - assert result[0].value == "Bob" - assert result[0].raw == "'Bob'" - - def test_both_quote_styles(self) -> None: - result = _extract_literals("""from 'home' to "work" """) - assert len(result) == 2 - values = [lit.value for lit in result] - assert "home" in values - assert "work" in values - - def test_no_literals(self) -> None: - assert _extract_literals("plain text here") == () - - def test_numeric_and_string(self) -> None: - result = _extract_literals('has 3 items named "foo"') - assert len(result) == 2 - - -class TestParseFeature: - def test_simple_scenario(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "simple", - """\ - Feature: Simple - Scenario: hello world - Given something - """, - ) - result = parse_feature(fp, config) - assert "test_hello_world" in result - si = result["test_hello_world"] - assert si.title == "hello world" - assert si.feature_title == "Simple" - assert si.feature_path == "simple" - assert si.rule_path == "default_test" - - def test_scenario_outline(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "outline", - """\ - Feature: Outline Test - Scenario Outline: addition - Given and - Then result is - - Examples: - | a | b | c | - | 1 | 2 | 3 | - """, - ) - result = parse_feature(fp, config) - si = result["test_addition"] - assert si.is_outline is True - assert si.examples is not None - assert si.examples.rows == (("1", "2", "3"),) - - def test_background_merged(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "bg", - """\ - Feature: Background Test - Background: - Given the system is ready - - Scenario: do something - Given a user exists - """, - ) - result = parse_feature(fp, config) - si = result["test_do_something"] - names = [ph.name for ph in si.placeholders] - assert "system" not in names - - def test_background_no_placeholders( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "bgph", - """\ - Feature: Bad Background - Background: - Given the hive has nectar - - Scenario: something - Given stuff - """, - ) - with pytest.raises(GherkinError, match="placeholder"): - parse_feature(fp, config) - - def test_rule_creates_separate_rule_path( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "rules", - """\ - Feature: Rule Test - Scenario: top level - - Rule: My Rule - Scenario: inside rule - """, - ) - result = parse_feature(fp, config) - assert result["test_top_level"].rule_path == "default_test" - assert result["test_inside_rule"].rule_path == "my_rule_test" - - def test_global_function_name_collision( - self, tmp_project: Path, config: Config - ) -> None: - fp1 = write_feature( - tmp_project, - "f1", - """\ - Feature: Feature One - Scenario: same name - Given stuff - """, - ) - fp2 = write_feature( - tmp_project, - "f2", - """\ - Feature: Feature Two - Scenario: same name - Given stuff - """, - ) - parse_feature(fp1, config) - with pytest.raises(GherkinError, match="collides"): - parse_feature( - fp2, config, seen_function_names={"test_same_name": "Feature One"} - ) - - def test_feature_not_found(self, tmp_project: Path, config: Config) -> None: - with pytest.raises(GherkinError, match="not found"): - parse_feature(tmp_project / "nonexistent.feature", config) - - def test_literal_extraction_from_steps( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "lits", - """\ - Feature: Literal Test - Scenario: literal check - Given the bee has 3 wings and "honey" scent - """, - ) - result = parse_feature(fp, config) - si = result["test_literal_check"] - values = [lit.value for lit in si.literals] - assert 3 in values - assert "honey" in values - - def test_background_literals_enforced_by_default( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "bglit", - """\ - Feature: BG Literals - Background: - Given the entrance has 2 guards - And the hive is "active" - - Scenario: check guards - Given a visitor arrives - """, - ) - result = parse_feature(fp, config) - si = result["test_check_guards"] - values = [lit.value for lit in si.literals] - assert 2 in values - assert "active" in values - - def test_background_literals_configurable(self, tmp_project: Path) -> None: - cfg = Config( - features_dir=str(tmp_project / "docs" / "features"), - tests_dir=str(tmp_project / "tests" / "features"), - background_check_numeric=False, - background_check_string=False, - ) - fp = write_feature( - tmp_project, - "bgconf", - """\ - Feature: BG Config - Background: - Given the entrance has 2 guards - And the hive is "active" - - Scenario: check - Given a visitor - """, - ) - result = parse_feature(fp, cfg) - si = result["test_check"] - assert all(not isinstance(lit.value, int) for lit in si.literals) - assert all(not isinstance(lit.value, str) for lit in si.literals) - - def test_background_steps_included_in_scenario_info( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "bgsteps", - """\ - Feature: BG Steps - Background: - Given a user exists - And the user has an empty cart - - Scenario: add item - When the user adds "Widget" to the cart - Then the cart contains 1 item - """, - ) - result = parse_feature(fp, config) - si = result["test_add_item"] - assert len(si.steps) == 4 - assert si.steps[0].text == "a user exists" - assert si.steps[1].text == "the user has an empty cart" - assert si.steps[2].text == 'the user adds "Widget" to the cart' - assert si.steps[3].text == "the cart contains 1 item" - - def test_rule_background_steps_included_in_scenario_info( - self, tmp_project: Path, config: Config - ) -> None: - fp = write_feature( - tmp_project, - "rulebgsteps", - """\ - Feature: Rule BG Steps - Background: - Given feature bg step - - Rule: Sub - Background: - Given rule bg step - - Scenario: combined - Given scenario step - """, - ) - result = parse_feature(fp, config) - si = result["test_combined"] - assert len(si.steps) == 3 - assert si.steps[0].text == "feature bg step" - assert si.steps[1].text == "rule bg step" - assert si.steps[2].text == "scenario step" - - def test_rule_background_composes(self, tmp_project: Path, config: Config) -> None: - fp = write_feature( - tmp_project, - "rulebg", - """\ - Feature: Rule BG - Background: - Given feature bg - - Rule: Sub - Background: - Given rule bg with "special" - - Scenario: combined - Given scenario step - """, - ) - result = parse_feature(fp, config) - si = result["test_combined"] - values = [lit.value for lit in si.literals] - assert "special" in values diff --git a/uv.lock b/uv.lock index 526d242..1ed849d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.14" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] [[package]] name = "agents-smith" @@ -44,9 +48,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + [[package]] name = "beehave" -version = "0.4.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "gherkin-official" }, @@ -58,9 +103,10 @@ dev = [ { name = "beehave" }, { name = "flowr", extra = ["viz"] }, { name = "hypothesis" }, + { name = "mypy" }, { name = "pytest" }, - { name = "pytest-beehave" }, { name = "ruff" }, + { name = "taskipy" }, ] [package.metadata] @@ -72,9 +118,10 @@ dev = [ { name = "beehave", editable = "." }, { name = "flowr", extras = ["viz"], specifier = ">=1.1.0" }, { name = "hypothesis", specifier = ">=6.152.7" }, + { name = "mypy", specifier = ">=2.3.0" }, { name = "pytest", specifier = ">=8.0" }, - { name = "pytest-beehave", specifier = ">=0.2.2" }, { name = "ruff", specifier = ">=0.11" }, + { name = "taskipy", specifier = ">=1.14.1" }, ] [[package]] @@ -233,6 +280,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "mslex" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/97/7022667073c99a0fe028f2e34b9bf76b49a611afd21b02527fbfd92d4cd5/mslex-1.3.0.tar.gz", hash = "sha256:641c887d1d3db610eee2af37a8e5abda3f70b3006cdfd2d0d29dc0d1ae28a85d", size = 11583, upload-time = "2024-10-16T13:16:18.523Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/f2/66bd65ca0139675a0d7b18f0bada6e12b51a984e41a76dbe44761bf1b3ee/mslex-1.3.0-py3-none-any.whl", hash = "sha256:c7074b347201b3466fc077c5692fbce9b5f62a63a51f537a53fbbd02eff2eea4", size = 7820, upload-time = "2024-10-16T13:16:17.566Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -242,6 +373,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -251,6 +391,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psutil" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/5a/07871137bb752428aa4b659f910b399ba6f291156bdea939be3e96cae7cb/psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5", size = 508502, upload-time = "2024-12-19T18:21:20.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/99/ca79d302be46f7bdd8321089762dd4476ee725fce16fc2b2e1dbba8cac17/psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8", size = 247511, upload-time = "2024-12-19T18:21:45.163Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6b/73dbde0dd38f3782905d4587049b9be64d76671042fdcaf60e2430c6796d/psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377", size = 248985, upload-time = "2024-12-19T18:21:49.254Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/c319d31a1d3f88c5b79c68b3116c129e5133f1822157dd6da34043e32ed6/psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003", size = 284488, upload-time = "2024-12-19T18:21:51.638Z" }, + { url = "https://files.pythonhosted.org/packages/9c/39/0f88a830a1c8a3aba27fededc642da37613c57cbff143412e3536f89784f/psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160", size = 287477, upload-time = "2024-12-19T18:21:55.306Z" }, + { url = "https://files.pythonhosted.org/packages/47/da/99f4345d4ddf2845cb5b5bd0d93d554e84542d116934fde07a0c50bd4e9f/psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3", size = 289017, upload-time = "2024-12-19T18:21:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/38/53/bd755c2896f4461fd4f36fa6a6dcb66a88a9e4b9fd4e5b66a77cf9d4a584/psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53", size = 250602, upload-time = "2024-12-19T18:22:08.808Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d7/7831438e6c3ebbfa6e01a927127a6cb42ad3ab844247f3c5b96bea25d73d/psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649", size = 254444, upload-time = "2024-12-19T18:22:11.335Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -332,18 +487,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] -[[package]] -name = "pytest-beehave" -version = "0.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beehave" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/ea/6ac241d175e595f79403a4d1d8b8413c3cfb21dedfe81e6cd29a7d5b854c/pytest_beehave-0.2.2.tar.gz", hash = "sha256:e90a165c8b6853c545bbea971536ff4c81710623fe89bc0cd3e4f7c29a5f3406", size = 12896, upload-time = "2026-05-13T18:53:27.801Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/56/3d3cc2130d8cf800be084549f7c32351cb3ea5817ca865dae4098bee7fec/pytest_beehave-0.2.2-py3-none-any.whl", hash = "sha256:f0e9ab4bb5cc3d0abf5ad52d553b3762f42172db89fae40425bfb6882147ea12", size = 9973, upload-time = "2026-05-13T18:53:26.618Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -431,6 +574,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] +[[package]] +name = "taskipy" +version = "1.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "mslex", marker = "sys_platform == 'win32'" }, + { name = "psutil" }, + { name = "tomli", marker = "python_full_version < '4'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/44/572261df3db9c6c3332f8618fafeb07a578fd18b06673c73f000f3586749/taskipy-1.14.1.tar.gz", hash = "sha256:410fbcf89692dfd4b9f39c2b49e1750b0a7b81affd0e2d7ea8c35f9d6a4774ed", size = 14475, upload-time = "2024-11-26T16:37:46.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/97/4e4cfb1391c81e926bebe3d68d5231b5dbc3bb41c6ba48349e68a881462d/taskipy-1.14.1-py3-none-any.whl", hash = "sha256:6e361520f29a0fd2159848e953599f9c75b1d0b047461e4965069caeb94908f1", size = 13052, upload-time = "2024-11-26T16:37:44.546Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 90b66ef8193a11316824db887d7d27295f3a7c70 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 10:25:30 -0400 Subject: [PATCH 03/13] docs(beehave-v2): regenerate docstrings + format --- beehave/__init__.py | 2 ++ beehave/check.py | 12 +++++++++--- beehave/cli.py | 8 ++++++++ beehave/generate.py | 8 ++++++++ beehave/gherkin.py | 24 ++++++++++++++++++++++++ beehave/status.py | 6 ++++++ beehave/step.py | 3 +++ 7 files changed, 60 insertions(+), 3 deletions(-) diff --git a/beehave/__init__.py b/beehave/__init__.py index dfebabc..7dec3f4 100644 --- a/beehave/__init__.py +++ b/beehave/__init__.py @@ -1,3 +1,5 @@ +"""beehave — a thin Gherkin-style BDD layer on Hypothesis.""" + from beehave.step import step as step __version__ = "2.0.0" diff --git a/beehave/check.py b/beehave/check.py index 24edd1c..d00d8f2 100644 --- a/beehave/check.py +++ b/beehave/check.py @@ -1,3 +1,5 @@ +"""Verify a generated `_test.py` still matches its `.feature` source.""" + from __future__ import annotations import ast @@ -54,9 +56,7 @@ def _step_matches( return False if text != step.text: return False - if names != {p.name for p in step.placeholders}: - return False - return True + return names == {p.name for p in step.placeholders} def _scenario_matches( @@ -73,6 +73,12 @@ def _scenario_matches( def check(feature_text: str, test_py_text: str) -> bool: + """Return True iff every scenario matches a `def` one-to-one. + + Each scenario in `feature_text` must have a `def` in `test_py_text` whose + `with step(...)` blocks line up on keyword, text, and placeholder names. + A syntactically invalid test_py_text returns False rather than raising. + """ feature = parse_feature(feature_text) try: tree = ast.parse(test_py_text) diff --git a/beehave/cli.py b/beehave/cli.py index e8a2739..1025973 100644 --- a/beehave/cli.py +++ b/beehave/cli.py @@ -1,3 +1,5 @@ +"""Command-line entry point dispatching to generate, status, and check.""" + from __future__ import annotations import sys @@ -10,6 +12,12 @@ def main(argv: Sequence[str] | None = None) -> int: + """Dispatch the first argument to `generate`, `status`, or `check`. + + `generate` exits 0; `status` exits from status; `check` exits 1 if any + feature drifts from its generated test. Returns 2 for a missing or + unknown command. + """ args = list(sys.argv[1:] if argv is None else argv) if not args: return 2 diff --git a/beehave/generate.py b/beehave/generate.py index 55b0cfa..82e43ed 100644 --- a/beehave/generate.py +++ b/beehave/generate.py @@ -1,3 +1,5 @@ +"""Generate `_test.pyi` / `_test.py` pairs from `.feature` files.""" + from __future__ import annotations from pathlib import Path @@ -108,6 +110,12 @@ def _emit_group( def generate(root: Path) -> None: + """Read every `docs/features/*.feature` under `root` and emit test pairs. + + Writes a `{stem}_test.pyi` always and a `{stem}_test.py` only when absent, + per top-level scenario group and per `Rule:` group. Placeholder types are + inferred from each scenario's `Examples` values. + """ features_dir = root / "docs" / "features" tests_dir = root / "tests" tests_dir.mkdir(parents=True, exist_ok=True) diff --git a/beehave/gherkin.py b/beehave/gherkin.py index e215099..b07edb7 100644 --- a/beehave/gherkin.py +++ b/beehave/gherkin.py @@ -1,3 +1,5 @@ +"""Gherkin parse model — typed tree returned by `parse_feature`.""" + from __future__ import annotations import re @@ -7,15 +9,21 @@ class Placeholder: + """A captured `` placeholder found inside a step's text.""" + name: str class DataTable: + """Optional table on a step; `headers` is None when header-less.""" + headers: list[str] | None rows: list[list[str]] class Step: + """One Gherkin step: keyword, text, placeholders, optional docstring/table.""" + keyword: str text: str placeholders: list[Placeholder] @@ -24,15 +32,21 @@ class Step: class Examples: + """The `Examples:` block of a Scenario Outline.""" + headers: list[str] rows: list[dict[str, str]] class Background: + """Shared steps prepended to every scenario in the enclosing feature or rule.""" + steps: list[Step] class Scenario: + """A scenario with merged background + own steps and optional examples.""" + title: str slug: str function_name: str @@ -43,6 +57,8 @@ class Scenario: class Rule: + """A `Rule:` block — name, tags, optional background, and child scenarios.""" + name: str tags: list[str] background: Background | None @@ -50,6 +66,8 @@ class Rule: class Feature: + """The parsed feature — top-level scenarios and rules.""" + name: str tags: list[str] background: Background | None @@ -303,6 +321,12 @@ def _rule_from( def parse_feature(source: str) -> Feature: + """Parse `.feature` source into a `Feature`, enforcing title rules. + + Scenario titles must use Unicode letters/digits/spaces only, span two + to six words, and be case-insensitively unique within the feature. + Placeholders in any background step are rejected at parse time. + """ doc = cast(dict[str, Any], Parser().parse(source)) feature_data = cast(dict[str, Any], doc.get("feature") or {}) diff --git a/beehave/status.py b/beehave/status.py index b6c7aa2..8d6b467 100644 --- a/beehave/status.py +++ b/beehave/status.py @@ -1,7 +1,13 @@ +"""Report counts of feature files and generated stub files under a project root.""" + from pathlib import Path def status(root: Path) -> int: + """Print counts of `docs/features/*.feature` files and `tests/*_test.pyi` stubs. + + Returns 2 when the features directory is absent, else 0. + """ features_dir = root / "docs" / "features" if not features_dir.is_dir(): return 2 diff --git a/beehave/step.py b/beehave/step.py index 826204e..03eda45 100644 --- a/beehave/step.py +++ b/beehave/step.py @@ -1,3 +1,5 @@ +"""Step context manager for Gherkin steps.""" + from collections.abc import Iterator from contextlib import contextmanager @@ -9,6 +11,7 @@ def step( /, **placeholders: object, ) -> Iterator[None]: + """Attach the step's keyword and text as a note to any exception raised inside.""" try: yield except Exception as e: From d61425017b03beb4c601a219a09ad027480f8485 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 10:25:30 -0400 Subject: [PATCH 04/13] chore(beehave-v2): sync uv.lock to 2.0.0 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 1ed849d..a8d7b3f 100644 --- a/uv.lock +++ b/uv.lock @@ -91,7 +91,7 @@ wheels = [ [[package]] name = "beehave" -version = "1.0.0" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "gherkin-official" }, From dbc770e91c85e10e38f6c044e379f5f295df3743 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 10:44:56 -0400 Subject: [PATCH 05/13] docs: regenerate living spec for 2.0.0 Authored by refresh-cycle from the 55-test green suite (33 integration + 22 e2e) plus the source stubs, glossary, and absence of cassettes/migrations. Derived view per the simplicity-discipline rule; never hand-edited. --- docs/state.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/state.md diff --git a/docs/state.md b/docs/state.md new file mode 100644 index 0000000..05afeed --- /dev/null +++ b/docs/state.md @@ -0,0 +1,85 @@ +# Specification (current state) + +> The living specification of this project's current state — what it is and +> where it is in its build. Regenerated each pipeline cycle by the refresh step +> from the truth: test `.pyi`, pending marks, cassettes, migrations, and the +> glossary. Never hand-edited; hand-authoring creates a second source of truth +> that drifts. Tests are the source of truth for behaviour — this file is a +> derived view. When prose and a test disagree, the test wins. + +## Snapshot + +- **Project:** beehave +- **Version:** 2.0.0 +- **Generated:** 2026-07-18 by flowr session `beehave-v2` +- **Suite:** green · **Contracts:** 7/7 built (0 pending) +- **Purpose:** → see `README.md` + +## Entry points & boundaries + +**Entry points:** +- CLI: `beehave generate|check|status` (console-script `beehave = beehave.cli:main`). +- Import: `from beehave import step` — the runtime context manager used inside consumer-authored `*_test.py` bodies. + +**Boundary:** +- *Internal (ours):* `beehave.__init__`, `beehave.step`, `beehave.gherkin`, `beehave.generate`, `beehave.check`, `beehave.status`, `beehave.cli` (7 modules; parse-model shapes live in `beehave.gherkin` per Q2-resolution — no separate `models.py`). +- *External (depends on):* `gherkin-official` (parser; Full Gherkin coverage); Hypothesis (imported by generated `*_test.py`, NOT by the package); pytest (test runner, hosts the `step` CM); consumer-side mypy + `mypy.stubtest` (the gate — runs in consumer CI, not in-package). See Dependencies. + +## Contract index + +The derived map of what this system does. Each row points at the test (the +behavioural truth) and the source `.pyi` (the type surface). Intent is +regenerator-authored from the test body and self-corrects each cycle. + +| Contract | Module | Test | Intent (one line) | Status | +|---|---|---|---|---| +| `step` | `beehave.step` | `tests/integration/step_cm_test.py` | `step(keyword, text, /, **placeholders)` CM runs the block, attributes failures via `add_note`, positional-only keyword/text. | built | +| parse model + `parse_feature` | `beehave.gherkin` | `tests/integration/parsing_test.py`, `tests/integration/title_derivation_test.py` | Parse `.feature` into `Feature`/`Rule`/`Scenario`/`Step`/`Placeholder`/`Examples`/`Background`/`DataTable`; enforce title rules (case-insensitive uniqueness, 2–6 words, charset) and reject placeholders in Background. | built | +| `generate` | `beehave.generate` | `tests/integration/idempotency_test.py`, `tests/integration/strategy_inference_test.py` | Emit `_default_test.py{i,}` + one `__test.py{i,}` per Rule; `.pyi` always, `.py` skeleton only if absent; Examples-column → strategy inference (int/float/bool/str; no-Examples → str). | built | +| `check` | `beehave.check` | `tests/integration/roundtrip_test.py`, `tests/e2e/check_test.py` | Structural binding: `block[i]`↔`step[i]` on (keyword case-insensitive, text, placeholder-name-set); body-content NOT inspected (noise loophole closed). | built | +| `status` | `beehave.status` | `tests/e2e/status_test.py` | Print `.feature` count under `/docs/features/` and `*_test.pyi` count under `/tests/`; exit 0 if dir exists, 2 if missing. | built | +| `main` (CLI dispatch) | `beehave.cli` | `tests/e2e/{check,generate,status}_test.py` | Dispatch `beehave generate|check|status` and return each subcommand's exit code; `argv` defaults to `sys.argv[1:]`. | built | +| `__version__` + `step` re-export | `beehave.__init__` | (no test — Q10 deferral) | Single source of truth for `__version__ = "2.0.0"`; re-export `step` for `from beehave import step`. | built | + +## Composition & data flow + +How the contracts assemble into the e2e path, and the data that flows through +it. Entity names follow `docs/glossary.md`. + +``` +author → docs/features/.feature + → parse_feature(source: str) -> Feature + → generate(root: Path) -> None writes tests/_default_test.py{i,} + one __test.py{i,} per Rule + → consumer fills *_test.py bodies with `with step(keyword, text, **placeholders): ...` + → check(feature_text: str, test_py_text: str) -> bool walks with-step blocks in source order + → pytest runs the consumer's *_test.py; the step CM attributes any AssertionError to its step via add_note + → status(root: Path) -> int reports .feature count + *_test.pyi count + → consumer CI: mypy on beehave.* + mypy.stubtest (the gate — out of package) +``` + +**Data flow:** `str` (.feature text) → `Feature` (parse model) → filesystem write (`.pyi` always, `.py` skeleton-if-absent) → `bool` (check) / `int` (status, cli). No persistence layer — the cycle is stateless file emission (data-model §1). + +## Dependencies + +**External services** — wire shapes live in the cassettes (the authoritative +external contract); this table points at them and never restates the shape. + +| Service | Purpose | Protocol | Cassette | Env vars | +|---|---|---|---|---| +| `gherkin-official` | Parse Full Gherkin (`.feature` → typed tree) | in-process Python lib | N/A — explore pass-through; no HTTP | none | +| Hypothesis | Property-based strategies (`@given`/`@example`) for generated tests | in-process Python lib (consumer-side) | N/A — imported by generated `*_test.py`, not by beehave | none | +| pytest | Test runner hosting the `step` CM inside consumer `*_test.py` | in-process Python lib | N/A | none | +| mypy + `mypy.stubtest` | Consumer-side type gate + `.py`↔`.pyi` drift detector | CLI (consumer CI) | N/A — out-of-package per L3 non-blocks | none | + +**Persistence** — schema lives in the migrations (the migration IS the schema +spec); this table points at them and never restates the DDL. + +| Entity | Store | Migration | +|---|---|---| +| (none) | N/A — beehave v2 has no persistence layer (stateless file emission per data-model §1) | N/A | + +## Status & last cycle + +- **Built:** 7 · **Pending:** 0 — backlog: none +- **Last cycle:** the beehave-v2 rewrite — 5 build cycles (`step`, `gherkin`, `generate`, `check`, `status`+`cli`) shipped green at `2.0.0`; 55 tests across integration (33) and e2e (22); 3 build-escalations resolved (`parsing_test` 1-word filler, `roundtrip_test` feature-vs-scenario collision, `tests/e2e` pytester-chdir path bug). +- **Next:** none — shipped. v3 spec exploration lives under `docs/spec/v3/` for a future cycle's discovery pass. From 47f629f1782f4469f99f4052693520b6da6bd259 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 10:45:02 -0400 Subject: [PATCH 06/13] chore: strip docstrings for next dev cycle Module/class/function docstrings stripped from the 7 source .py modules (21 docstrings total) via AST line-range removal. Tests already docstring- free. ruff format applied; lint-merge + 55-test suite + whole-package stubtest all clean. --- beehave/__init__.py | 2 -- beehave/check.py | 8 -------- beehave/cli.py | 8 -------- beehave/generate.py | 8 -------- beehave/gherkin.py | 24 ------------------------ beehave/status.py | 6 ------ beehave/step.py | 3 --- 7 files changed, 59 deletions(-) diff --git a/beehave/__init__.py b/beehave/__init__.py index 7dec3f4..dfebabc 100644 --- a/beehave/__init__.py +++ b/beehave/__init__.py @@ -1,5 +1,3 @@ -"""beehave — a thin Gherkin-style BDD layer on Hypothesis.""" - from beehave.step import step as step __version__ = "2.0.0" diff --git a/beehave/check.py b/beehave/check.py index d00d8f2..9769b85 100644 --- a/beehave/check.py +++ b/beehave/check.py @@ -1,5 +1,3 @@ -"""Verify a generated `_test.py` still matches its `.feature` source.""" - from __future__ import annotations import ast @@ -73,12 +71,6 @@ def _scenario_matches( def check(feature_text: str, test_py_text: str) -> bool: - """Return True iff every scenario matches a `def` one-to-one. - - Each scenario in `feature_text` must have a `def` in `test_py_text` whose - `with step(...)` blocks line up on keyword, text, and placeholder names. - A syntactically invalid test_py_text returns False rather than raising. - """ feature = parse_feature(feature_text) try: tree = ast.parse(test_py_text) diff --git a/beehave/cli.py b/beehave/cli.py index 1025973..e8a2739 100644 --- a/beehave/cli.py +++ b/beehave/cli.py @@ -1,5 +1,3 @@ -"""Command-line entry point dispatching to generate, status, and check.""" - from __future__ import annotations import sys @@ -12,12 +10,6 @@ def main(argv: Sequence[str] | None = None) -> int: - """Dispatch the first argument to `generate`, `status`, or `check`. - - `generate` exits 0; `status` exits from status; `check` exits 1 if any - feature drifts from its generated test. Returns 2 for a missing or - unknown command. - """ args = list(sys.argv[1:] if argv is None else argv) if not args: return 2 diff --git a/beehave/generate.py b/beehave/generate.py index 82e43ed..55b0cfa 100644 --- a/beehave/generate.py +++ b/beehave/generate.py @@ -1,5 +1,3 @@ -"""Generate `_test.pyi` / `_test.py` pairs from `.feature` files.""" - from __future__ import annotations from pathlib import Path @@ -110,12 +108,6 @@ def _emit_group( def generate(root: Path) -> None: - """Read every `docs/features/*.feature` under `root` and emit test pairs. - - Writes a `{stem}_test.pyi` always and a `{stem}_test.py` only when absent, - per top-level scenario group and per `Rule:` group. Placeholder types are - inferred from each scenario's `Examples` values. - """ features_dir = root / "docs" / "features" tests_dir = root / "tests" tests_dir.mkdir(parents=True, exist_ok=True) diff --git a/beehave/gherkin.py b/beehave/gherkin.py index b07edb7..e215099 100644 --- a/beehave/gherkin.py +++ b/beehave/gherkin.py @@ -1,5 +1,3 @@ -"""Gherkin parse model — typed tree returned by `parse_feature`.""" - from __future__ import annotations import re @@ -9,21 +7,15 @@ class Placeholder: - """A captured `` placeholder found inside a step's text.""" - name: str class DataTable: - """Optional table on a step; `headers` is None when header-less.""" - headers: list[str] | None rows: list[list[str]] class Step: - """One Gherkin step: keyword, text, placeholders, optional docstring/table.""" - keyword: str text: str placeholders: list[Placeholder] @@ -32,21 +24,15 @@ class Step: class Examples: - """The `Examples:` block of a Scenario Outline.""" - headers: list[str] rows: list[dict[str, str]] class Background: - """Shared steps prepended to every scenario in the enclosing feature or rule.""" - steps: list[Step] class Scenario: - """A scenario with merged background + own steps and optional examples.""" - title: str slug: str function_name: str @@ -57,8 +43,6 @@ class Scenario: class Rule: - """A `Rule:` block — name, tags, optional background, and child scenarios.""" - name: str tags: list[str] background: Background | None @@ -66,8 +50,6 @@ class Rule: class Feature: - """The parsed feature — top-level scenarios and rules.""" - name: str tags: list[str] background: Background | None @@ -321,12 +303,6 @@ def _rule_from( def parse_feature(source: str) -> Feature: - """Parse `.feature` source into a `Feature`, enforcing title rules. - - Scenario titles must use Unicode letters/digits/spaces only, span two - to six words, and be case-insensitively unique within the feature. - Placeholders in any background step are rejected at parse time. - """ doc = cast(dict[str, Any], Parser().parse(source)) feature_data = cast(dict[str, Any], doc.get("feature") or {}) diff --git a/beehave/status.py b/beehave/status.py index 8d6b467..b6c7aa2 100644 --- a/beehave/status.py +++ b/beehave/status.py @@ -1,13 +1,7 @@ -"""Report counts of feature files and generated stub files under a project root.""" - from pathlib import Path def status(root: Path) -> int: - """Print counts of `docs/features/*.feature` files and `tests/*_test.pyi` stubs. - - Returns 2 when the features directory is absent, else 0. - """ features_dir = root / "docs" / "features" if not features_dir.is_dir(): return 2 diff --git a/beehave/step.py b/beehave/step.py index 03eda45..826204e 100644 --- a/beehave/step.py +++ b/beehave/step.py @@ -1,5 +1,3 @@ -"""Step context manager for Gherkin steps.""" - from collections.abc import Iterator from contextlib import contextmanager @@ -11,7 +9,6 @@ def step( /, **placeholders: object, ) -> Iterator[None]: - """Attach the step's keyword and text as a note to any exception raised inside.""" try: yield except Exception as e: From 64543293b566d98048a1739e02b026693b52e8b7 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 10:48:01 -0400 Subject: [PATCH 07/13] fix(beehave-v2): move pytest_plugins to root conftest pytest 9.0.3 forbids `pytest_plugins` in non-top-level conftests; the declaration in tests/e2e/conftest.py broke bare `task test` / `uv run pytest` collection (ERROR: Defining 'pytest_plugins' in a non-top-level conftest is no longer supported). Move it to a top-level conftest.py at the repo rootdir as pytest's deprecation notice recommends. The e2e conftest keeps its pending-marker hooks (nested hooks remain allowed). Verified: `task test`, `task lint`, `task lint-merge`, `task stubtest` all green; 55 passed. --- conftest.py | 1 + tests/e2e/conftest.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) create mode 100644 conftest.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..c6481d5 --- /dev/null +++ b/conftest.py @@ -0,0 +1 @@ +pytest_plugins = ["pytester"] diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index f05be32..245aacd 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -2,8 +2,6 @@ import pytest -pytest_plugins = ["pytester"] - def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line( From efa3d511f05bd2d5717f95eccf1ccd3b4f5dce57 Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 13:31:44 -0400 Subject: [PATCH 08/13] feat(beehave-v3): scope generated tests to tests/features/ Emit beehave-generated *_test.{pyi,py} into tests/features/ (parallel to docs/features/) so the subdirectory is the ownership marker: anything inside is beehave-owned (wiped/regenerated), anything outside is consumer-owned (untouched). generate: emit into tests/features/, mkdir on demand, and wipe stale *_test.pyi in that subdir before each run so deleted scenarios/features stop orphaning files. check: _check_all now scans tests/features/ for *_test.py without a .pyi sibling (orphans) and fails with one stderr line per orphan before the AST binding loop. Hand-written tests outside tests/features/ are never inspected. status: counts *_test.pyi under tests/features/ (was tests/). Bumps version to 2.1.0. Signatures in the .pyi files are unchanged; the change is behavior + path scope only. Migration: move existing generated files from tests/ to tests/features/ (e.g. `mkdir -p tests/features && git mv tests/*_test.py{i,} tests/features/`) before re-running `beehave generate`. --- beehave/__init__.py | 2 +- beehave/cli.py | 13 +++++++++++- beehave/cli.pyi | 4 +++- beehave/generate.py | 4 +++- beehave/generate.pyi | 11 +++++----- beehave/status.py | 2 +- beehave/status.pyi | 4 ++-- pyproject.toml | 2 +- tests/e2e/check_test.py | 22 +++++++++++++++++++- tests/e2e/check_test.pyi | 2 ++ tests/e2e/generate_test.py | 18 +++++++++++++++- tests/e2e/generate_test.pyi | 2 ++ tests/e2e/status_test.py | 2 +- tests/integration/idempotency_test.py | 6 +++--- tests/integration/roundtrip_test.py | 2 +- tests/integration/strategy_inference_test.py | 2 +- uv.lock | 2 +- 17 files changed, 78 insertions(+), 22 deletions(-) diff --git a/beehave/__init__.py b/beehave/__init__.py index dfebabc..cc1f035 100644 --- a/beehave/__init__.py +++ b/beehave/__init__.py @@ -1,3 +1,3 @@ from beehave.step import step as step -__version__ = "2.0.0" +__version__ = "2.1.0" diff --git a/beehave/cli.py b/beehave/cli.py index e8a2739..41d812f 100644 --- a/beehave/cli.py +++ b/beehave/cli.py @@ -28,7 +28,18 @@ def _check_all(root: Path) -> int: features_dir = root / "docs" / "features" if not features_dir.is_dir(): return 1 - test_py_text = _read_test_py(root / "tests") + tests_features_dir = root / "tests" / "features" + if tests_features_dir.is_dir(): + orphans = [ + p + for p in sorted(tests_features_dir.glob("*_test.py")) + if not p.with_suffix(".pyi").exists() + ] + if orphans: + for orphan in orphans: + print(f"orphan: {orphan.name}", file=sys.stderr) + return 1 + test_py_text = _read_test_py(tests_features_dir) for feature_path in sorted(features_dir.glob("*.feature")): if not check(feature_path.read_text(), test_py_text): return 1 diff --git a/beehave/cli.pyi b/beehave/cli.pyi index 92c4c1e..65c7ea1 100644 --- a/beehave/cli.pyi +++ b/beehave/cli.pyi @@ -2,5 +2,7 @@ from collections.abc import Sequence # CLI entry: `beehave generate|check|status`. Returns the process exit code # (0 success; 2 missing features dir on `status`; non-zero on `check` -# structural-binding failure). `argv` defaults to `sys.argv[1:]` when None. +# structural-binding failure or when orphan `*_test.py` files without `.pyi` +# siblings are found in `/tests/features/`). `argv` defaults to +# `sys.argv[1:]` when None. def main(argv: Sequence[str] | None = None) -> int: ... diff --git a/beehave/generate.py b/beehave/generate.py index 55b0cfa..ea1967e 100644 --- a/beehave/generate.py +++ b/beehave/generate.py @@ -109,8 +109,10 @@ def _emit_group( def generate(root: Path) -> None: features_dir = root / "docs" / "features" - tests_dir = root / "tests" + tests_dir = root / "tests" / "features" tests_dir.mkdir(parents=True, exist_ok=True) + for stale in tests_dir.glob("*_test.pyi"): + stale.unlink() for feature_path in sorted(features_dir.glob("*.feature")): feature = parse_feature(feature_path.read_text()) diff --git a/beehave/generate.pyi b/beehave/generate.pyi index 1400f92..c3c4a30 100644 --- a/beehave/generate.pyi +++ b/beehave/generate.pyi @@ -1,9 +1,10 @@ from pathlib import Path # Emits `_default_test.py{i,}` plus one -# `__test.py{i,}` per Rule into `/tests/`, -# reading `/docs/features/*.feature`. Always emits `.pyi`; emits `.py` -# skeleton only when absent (idempotent — never clobbers consumer bodies). -# Background steps (Feature-level and Rule-level) are prepended to the -# relevant scenarios' `with step(...)` block lists in the emitted `.py`. +# `__test.py{i,}` per Rule into `/tests/features/`, +# reading `/docs/features/*.feature`. Wipes stale `*_test.pyi` in the emit +# dir before writing. Always emits `.pyi`; emits `.py` skeleton only when absent +# (idempotent — never clobbers consumer bodies). Background steps (Feature-level +# and Rule-level) are prepended to the relevant scenarios' `with step(...)` +# block lists in the emitted `.py`. def generate(root: Path) -> None: ... diff --git a/beehave/status.py b/beehave/status.py index b6c7aa2..8e5bcb2 100644 --- a/beehave/status.py +++ b/beehave/status.py @@ -6,7 +6,7 @@ def status(root: Path) -> int: if not features_dir.is_dir(): return 2 feature_count = len(list(features_dir.glob("*.feature"))) - tests_dir = root / "tests" + tests_dir = root / "tests" / "features" stub_count = len(list(tests_dir.glob("*_test.pyi"))) if tests_dir.is_dir() else 0 print(f"{feature_count} feature file(s)") print(f"{stub_count} stub file(s)") diff --git a/beehave/status.pyi b/beehave/status.pyi index 73eed86..84e7016 100644 --- a/beehave/status.pyi +++ b/beehave/status.pyi @@ -1,8 +1,8 @@ from pathlib import Path # Minimal `status` (journal Q3): prints `.feature` count under -# `/docs/features/` and `*_test.pyi` count under `/tests/` to -# stdout; returns 0 when the features directory exists, 2 when it is +# `/docs/features/` and `*_test.pyi` count under `/tests/features/` +# to stdout; returns 0 when the features directory exists, 2 when it is # missing (filesystem error). v1's rich stage taxonomy / `--json` / tree # output / unmapped-directory reporting are all dropped. def status(root: Path) -> int: ... diff --git a/pyproject.toml b/pyproject.toml index 29c0022..7f9de97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "beehave" -version = "2.0.0" +version = "2.1.0" description = "A thin layer on Hypothesis for Gherkin-style BDD testing with vocabulary enforcement" readme = "README.md" requires-python = ">=3.14" diff --git a/tests/e2e/check_test.py b/tests/e2e/check_test.py index ad49a7b..3b45269 100644 --- a/tests/e2e/check_test.py +++ b/tests/e2e/check_test.py @@ -23,9 +23,10 @@ def write_feature_text(pytester, basename: str, text: str) -> str: def write_test_py(pytester, stem: str, body: str) -> str: - dst = pytester.path / "tests" / f"{stem}_test.py" + dst = pytester.path / "tests" / "features" / f"{stem}_test.py" dst.parent.mkdir(parents=True, exist_ok=True) dst.write_text(body) + dst.with_suffix(".pyi").write_text("") return str(dst) @@ -187,3 +188,22 @@ def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: ) write_test_py(pytester, "minimal_default", body_without_literals) assert run_beehave_check(pytester) == 0 + + +def test_check_fails_on_orphan_py_in_tests_features(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + pytester.run("beehave", "generate") + orphan_path = pytester.path / "tests" / "features" / "orphan_default_test.py" + orphan_path.write_text("# orphan") + result = pytester.run("beehave", "check") + assert result.ret != 0 + assert "orphan" in "\n".join(result.errlines) + + +def test_check_ignores_handwritten_py_outside_tests_features(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + pytester.run("beehave", "generate") + handwritten = pytester.path / "tests" / "handwritten_test.py" + handwritten.parent.mkdir(parents=True, exist_ok=True) + handwritten.write_text("# handwritten") + assert run_beehave_check(pytester) == 0 diff --git a/tests/e2e/check_test.pyi b/tests/e2e/check_test.pyi index b6f1b01..51556d1 100644 --- a/tests/e2e/check_test.pyi +++ b/tests/e2e/check_test.pyi @@ -24,3 +24,5 @@ def test_fails_when_placeholder_name_set_mismatches(pytester) -> None: ... def test_passes_when_keyword_case_differs(pytester) -> None: ... def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: ... def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: ... +def test_check_fails_on_orphan_py_in_tests_features(pytester) -> None: ... +def test_check_ignores_handwritten_py_outside_tests_features(pytester) -> None: ... diff --git a/tests/e2e/generate_test.py b/tests/e2e/generate_test.py index 5523ed7..c9ee8eb 100644 --- a/tests/e2e/generate_test.py +++ b/tests/e2e/generate_test.py @@ -10,7 +10,7 @@ CASE_INSENSITIVE_MATCHING_FEATURE = "case_insensitive_matching.feature" DEFAULT_GROUP_SUFFIX = "default" -EMISSION_DIR = "tests" +EMISSION_DIR = "tests/features" def copy_feature_into_pytester(pytester, basename: str) -> str: @@ -171,3 +171,19 @@ def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: run_beehave_generate(pytester) pyi = read_emitted_pyi(pytester, "datatable_default") assert "unique_col" not in pyi + + +def test_generate_wipes_stale_pyi_in_tests_features(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + stale_path = pytester.path / "tests" / "features" / "stale_default_test.pyi" + stale_path.parent.mkdir(parents=True, exist_ok=True) + stale_path.write_text("# stale") + run_beehave_generate(pytester) + assert not stale_path.exists() + + +def test_generate_creates_tests_features_dir_if_absent(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + assert not (pytester.path / "tests" / "features").exists() + run_beehave_generate(pytester) + assert (pytester.path / "tests" / "features").is_dir() diff --git a/tests/e2e/generate_test.pyi b/tests/e2e/generate_test.pyi index 5052946..3131d42 100644 --- a/tests/e2e/generate_test.pyi +++ b/tests/e2e/generate_test.pyi @@ -47,3 +47,5 @@ def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> N def test_tags_do_not_surface_in_emitted_pyi_or_step_blocks(pytester) -> None: ... def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: ... def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: ... +def test_generate_wipes_stale_pyi_in_tests_features(pytester) -> None: ... +def test_generate_creates_tests_features_dir_if_absent(pytester) -> None: ... diff --git a/tests/e2e/status_test.py b/tests/e2e/status_test.py index 88ae30b..e55df43 100644 --- a/tests/e2e/status_test.py +++ b/tests/e2e/status_test.py @@ -9,7 +9,7 @@ def write_feature_text(pytester, basename: str, text: str) -> str: def write_pyi_stub(pytester, stem: str) -> str: - dst = pytester.path / "tests" / f"{stem}_test.pyi" + dst = pytester.path / "tests" / "features" / f"{stem}_test.pyi" dst.parent.mkdir(parents=True, exist_ok=True) dst.write_text("") return str(dst) diff --git a/tests/integration/idempotency_test.py b/tests/integration/idempotency_test.py index 91d34ae..4e911e1 100644 --- a/tests/integration/idempotency_test.py +++ b/tests/integration/idempotency_test.py @@ -28,7 +28,7 @@ def emit_test_py_for(feature_text: str) -> str: features.mkdir(parents=True) (features / "input.feature").write_text(feature_text) generate(root) - return (root / "tests" / "input_default_test.py").read_text() + return (root / "tests" / "features" / "input_default_test.py").read_text() def emit_test_pyi_for(feature_text: str) -> str: @@ -40,7 +40,7 @@ def emit_test_pyi_for(feature_text: str) -> str: features.mkdir(parents=True) (features / "input.feature").write_text(feature_text) generate(root) - return (root / "tests" / "input_default_test.pyi").read_text() + return (root / "tests" / "features" / "input_default_test.pyi").read_text() def regenerate_over_body(feature_text: str, existing_py_body: str) -> str: @@ -51,7 +51,7 @@ def regenerate_over_body(feature_text: str, existing_py_body: str) -> str: features = root / "docs" / "features" features.mkdir(parents=True) (features / "input.feature").write_text(feature_text) - tests_dir = root / "tests" + tests_dir = root / "tests" / "features" tests_dir.mkdir(parents=True) py_path = tests_dir / "input_default_test.py" py_path.write_text(existing_py_body) diff --git a/tests/integration/roundtrip_test.py b/tests/integration/roundtrip_test.py index 9aa1be0..1b378ad 100644 --- a/tests/integration/roundtrip_test.py +++ b/tests/integration/roundtrip_test.py @@ -21,7 +21,7 @@ def emit_test_py_for(feature_text: str) -> str: features.mkdir(parents=True) (features / "input.feature").write_text(feature_text) generate(root) - return (root / "tests" / "input_default_test.py").read_text() + return (root / "tests" / "features" / "input_default_test.py").read_text() def check_passes_for(feature_text: str, test_py_text: str) -> bool: diff --git a/tests/integration/strategy_inference_test.py b/tests/integration/strategy_inference_test.py index 4b251ab..02fdf5d 100644 --- a/tests/integration/strategy_inference_test.py +++ b/tests/integration/strategy_inference_test.py @@ -81,7 +81,7 @@ def emitted_function_signature(feature_text: str, scenario_slug: str) -> str: features.mkdir(parents=True) (features / "input.feature").write_text(feature_text) generate(root) - pyi = (root / "tests" / "input_default_test.pyi").read_text() + pyi = (root / "tests" / "features" / "input_default_test.pyi").read_text() needle = f"def test_{scenario_slug}" start = pyi.find(needle) if start == -1: diff --git a/uv.lock b/uv.lock index a8d7b3f..5fe3a7c 100644 --- a/uv.lock +++ b/uv.lock @@ -91,7 +91,7 @@ wheels = [ [[package]] name = "beehave" -version = "2.0.0" +version = "2.1.0" source = { editable = "." } dependencies = [ { name = "gherkin-official" }, From 8a4bce0a0ea40934209981eb21226c7b2b9ec65d Mon Sep 17 00:00:00 2001 From: nullhack Date: Sat, 18 Jul 2026 15:01:15 -0400 Subject: [PATCH 09/13] feat(beehave-v4): emit parametrize for Examples, verify rows in check Replace v2's Hypothesis-strategy-inference path with @pytest.mark.parametrize for Scenario Outlines. The generator emits a parametrize decorator over each Outline's test function whose arg-names are the Examples headers and whose rows are the Examples cells (string tuples). All parameters are now typed `str`; the strategy-inference helpers (_is_int/_is_float/_is_bool/_infer_param_type) are removed. Plain scenarios (no Examples) emit no parametrize and no `import pytest`. Extend check.py to parse @pytest.mark.parametrize from each test function's decorator list and verify the arg-names and rows round-trip the feature's Examples table. Examples present without a matching parametrize now fails check (closing the Examples-row binding gap). Step-template binding (count/keyword/text/placeholder-names) is checked first; parametrize verification runs only for scenarios that have Examples. Bumps version to 2.2.0. .pyi signatures unchanged in shape (still flat typed signatures); comment prose updated to describe parametrize. Drops tests/integration/strategy_inference_test (the concept is gone) and replaces it with tests/integration/parametrize_test covering emission + check roundtrip. Adds one e2e test each in generate_test (parametrize emission via the CLI) and check_test (check catches edited parametrize rows via the CLI). --- beehave/__init__.py | 2 +- beehave/check.py | 75 +++++++++-- beehave/check.pyi | 5 +- beehave/generate.py | 59 +++------ beehave/generate.pyi | 4 +- pyproject.toml | 2 +- tests/e2e/check_test.py | 11 ++ tests/e2e/check_test.pyi | 1 + tests/e2e/generate_test.py | 9 ++ tests/e2e/generate_test.pyi | 1 + tests/integration/parametrize_test.py | 121 +++++++++++++++++ tests/integration/parametrize_test.pyi | 20 +++ tests/integration/strategy_inference_test.py | 122 ------------------ tests/integration/strategy_inference_test.pyi | 27 ---- uv.lock | 2 +- 15 files changed, 259 insertions(+), 202 deletions(-) create mode 100644 tests/integration/parametrize_test.py create mode 100644 tests/integration/parametrize_test.pyi delete mode 100644 tests/integration/strategy_inference_test.py delete mode 100644 tests/integration/strategy_inference_test.pyi diff --git a/beehave/__init__.py b/beehave/__init__.py index cc1f035..cb5a299 100644 --- a/beehave/__init__.py +++ b/beehave/__init__.py @@ -1,3 +1,3 @@ from beehave.step import step as step -__version__ = "2.1.0" +__version__ = "2.2.0" diff --git a/beehave/check.py b/beehave/check.py index 9769b85..73408a5 100644 --- a/beehave/check.py +++ b/beehave/check.py @@ -6,7 +6,7 @@ from beehave.gherkin import Rule, parse_feature if TYPE_CHECKING: - from beehave.gherkin import Scenario, Step + from beehave.gherkin import Examples, Scenario, Step def _step_block_from_item( @@ -45,6 +45,46 @@ def _step_blocks( return blocks +def _parametrize_of( + function: ast.FunctionDef, +) -> tuple[list[str], list[tuple[str, ...]]] | None: + for decorator in function.decorator_list: + if not isinstance(decorator, ast.Call): + continue + func = decorator.func + if ( + not isinstance(func, ast.Attribute) + or func.attr != "parametrize" + or not isinstance(func.value, ast.Attribute) + or func.value.attr != "mark" + or not isinstance(func.value.value, ast.Name) + or func.value.value.id != "pytest" + ): + continue + if len(decorator.args) < 2: + continue + try: + arg_names = ast.literal_eval(decorator.args[0]) + rows = ast.literal_eval(decorator.args[1]) + except ValueError, SyntaxError: + continue + if not isinstance(arg_names, tuple) or not isinstance(rows, list): + continue + return (list(arg_names), [tuple(r) for r in rows]) + return None + + +def _examples_rows( + scenario: Scenario, +) -> tuple[list[str], list[tuple[str, ...]]] | None: + examples: Examples | None = scenario.examples + if examples is None: + return None + headers = list(examples.headers) + rows = [tuple(row[h] for h in headers) for row in examples.rows] + return (headers, rows) + + def _step_matches( block: tuple[str, str, set[str]], step: Step, @@ -60,14 +100,23 @@ def _step_matches( def _scenario_matches( scenario: Scenario, blocks_by_function: dict[str, list[tuple[str, str, set[str]]]], + parametrize_by_function: dict[str, tuple[list[str], list[tuple[str, ...]]] | None], ) -> bool: - blocks = blocks_by_function.get(scenario.function_name, []) + name = scenario.function_name + blocks = blocks_by_function.get(name, []) if len(blocks) != len(scenario.steps): return False - return all( + if not all( _step_matches(block, step) for block, step in zip(blocks, scenario.steps, strict=True) - ) + ): + return False + expected = _examples_rows(scenario) + if expected is not None: + actual = parametrize_by_function.get(name) + if actual is None or actual != expected: + return False + return True def check(feature_text: str, test_py_text: str) -> bool: @@ -76,14 +125,20 @@ def check(feature_text: str, test_py_text: str) -> bool: tree = ast.parse(test_py_text) except SyntaxError: return False - blocks_by_function = { - node.name: _step_blocks(node) - for node in tree.body - if isinstance(node, ast.FunctionDef) - } + blocks_by_function: dict[str, list[tuple[str, str, set[str]]]] = {} + parametrize_by_function: dict[ + str, tuple[list[str], list[tuple[str, ...]]] | None + ] = {} + for node in tree.body: + if not isinstance(node, ast.FunctionDef): + continue + blocks_by_function[node.name] = _step_blocks(node) + parametrize_by_function[node.name] = _parametrize_of(node) for child in feature.children: scenarios = child.children if isinstance(child, Rule) else [child] for scenario in scenarios: - if not _scenario_matches(scenario, blocks_by_function): + if not _scenario_matches( + scenario, blocks_by_function, parametrize_by_function + ): return False return True diff --git a/beehave/check.pyi b/beehave/check.pyi index 9d758ea..5ff4382 100644 --- a/beehave/check.pyi +++ b/beehave/check.pyi @@ -4,5 +4,8 @@ # (keyword-case-insensitively, text, placeholder-name-set). Returns True if # every block matches its step; False on any count, keyword, text, or # placeholder-name-set mismatch. Does NOT inspect the body for literals or -# placeholder AST nodes (v2 drops that layer entirely — Q5). +# placeholder AST nodes (v2 drops that layer entirely — Q5). For Scenario +# Outlines, additionally requires a `@pytest.mark.parametrize(...)` decorator +# whose arg-names and rows round-trip the feature's Examples table; missing or +# mismatched parametrize returns False. def check(feature_text: str, test_py_text: str) -> bool: ... diff --git a/beehave/generate.py b/beehave/generate.py index ea1967e..5f27c32 100644 --- a/beehave/generate.py +++ b/beehave/generate.py @@ -13,26 +13,6 @@ def _slug_from(title: str) -> str: return "_".join(title.split()).lower() -def _is_int(value: str) -> bool: - try: - int(value) - except ValueError: - return False - return True - - -def _is_float(value: str) -> bool: - try: - float(value) - except ValueError: - return False - return True - - -def _is_bool(value: str) -> bool: - return value.lower() in ("true", "false") - - def _placeholder_names(steps: list[Step]) -> list[str]: seen: set[str] = set() names: list[str] = [] @@ -44,26 +24,26 @@ def _placeholder_names(steps: list[Step]) -> list[str]: return names -def _infer_param_type(name: str, examples: Examples | None) -> str: - if examples is None: - return "str" - values = [row[name] for row in examples.rows if name in row] - if not values: - return "str" - if all(_is_int(v) for v in values): - return "int" - if all(_is_float(v) for v in values): - return "float" - if all(_is_bool(v) for v in values): - return "bool" - return "str" +def _signature_params(scenario: Scenario) -> str: + return ", ".join(f"{name}: str" for name in _placeholder_names(scenario.steps)) -def _signature_params(scenario: Scenario) -> str: - parts: list[str] = [] - for name in _placeholder_names(scenario.steps): - parts.append(f"{name}: {_infer_param_type(name, scenario.examples)}") - return ", ".join(parts) +def _parametrize_lines(scenario: Scenario) -> list[str]: + examples: Examples | None = scenario.examples + if examples is None: + return [] + arg_names = ", ".join(repr(h) for h in examples.headers) + lines = [ + "@pytest.mark.parametrize(", + f" ({arg_names}),", + " [", + ] + for row in examples.rows: + cells = ", ".join(repr(row[h]) for h in examples.headers) + lines.append(f" ({cells}),") + lines.append(" ],") + lines.append(")") + return lines def _render_pyi(scenarios: list[Scenario]) -> str: @@ -81,10 +61,13 @@ def _step_block(step: Step) -> str: def _render_py(scenarios: list[Scenario]) -> str: lines: list[str] = ["from beehave import step"] + if any(s.examples is not None for s in scenarios): + lines.append("import pytest") for scenario in scenarios: params = _signature_params(scenario) lines.append("") lines.append("") + lines.extend(_parametrize_lines(scenario)) lines.append(f"def {scenario.function_name}({params}) -> None:") for step in scenario.steps: lines.append(_step_block(step)) diff --git a/beehave/generate.pyi b/beehave/generate.pyi index c3c4a30..5b5cd77 100644 --- a/beehave/generate.pyi +++ b/beehave/generate.pyi @@ -6,5 +6,7 @@ from pathlib import Path # dir before writing. Always emits `.pyi`; emits `.py` skeleton only when absent # (idempotent — never clobbers consumer bodies). Background steps (Feature-level # and Rule-level) are prepended to the relevant scenarios' `with step(...)` -# block lists in the emitted `.py`. +# block lists in the emitted `.py`. Scenario Outline Examples are emitted as a +# `@pytest.mark.parametrize(...)` decorator over the test function (string rows, +# all params typed `str`); the `.pyi` carries only the flat typed signature. def generate(root: Path) -> None: ... diff --git a/pyproject.toml b/pyproject.toml index 7f9de97..55d67ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "beehave" -version = "2.1.0" +version = "2.2.0" description = "A thin layer on Hypothesis for Gherkin-style BDD testing with vocabulary enforcement" readme = "README.md" requires-python = ">=3.14" diff --git a/tests/e2e/check_test.py b/tests/e2e/check_test.py index 3b45269..d237f24 100644 --- a/tests/e2e/check_test.py +++ b/tests/e2e/check_test.py @@ -207,3 +207,14 @@ def test_check_ignores_handwritten_py_outside_tests_features(pytester) -> None: handwritten.parent.mkdir(parents=True, exist_ok=True) handwritten.write_text("# handwritten") assert run_beehave_check(pytester) == 0 + + +def test_check_fails_when_parametrize_rows_edited(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + pytester.run("beehave", "generate") + py_path = pytester.path / "tests" / "features" / "hive_activity_default_test.py" + edited = py_path.read_text().replace( + "('100', '20', '8', '80'),", "('999', '20', '8', '80')," + ) + py_path.write_text(edited) + assert run_beehave_check(pytester) != 0 diff --git a/tests/e2e/check_test.pyi b/tests/e2e/check_test.pyi index 51556d1..da00760 100644 --- a/tests/e2e/check_test.pyi +++ b/tests/e2e/check_test.pyi @@ -26,3 +26,4 @@ def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: ... def test_check_fails_on_orphan_py_in_tests_features(pytester) -> None: ... def test_check_ignores_handwritten_py_outside_tests_features(pytester) -> None: ... +def test_check_fails_when_parametrize_rows_edited(pytester) -> None: ... diff --git a/tests/e2e/generate_test.py b/tests/e2e/generate_test.py index c9ee8eb..6b9c066 100644 --- a/tests/e2e/generate_test.py +++ b/tests/e2e/generate_test.py @@ -187,3 +187,12 @@ def test_generate_creates_tests_features_dir_if_absent(pytester) -> None: assert not (pytester.path / "tests" / "features").exists() run_beehave_generate(pytester) assert (pytester.path / "tests" / "features").is_dir() + + +def test_outline_scenario_emits_parametrize_in_py(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + run_beehave_generate(pytester) + py = read_emitted_py(pytester, "hive_activity_default") + assert "@pytest.mark.parametrize(" in py + assert "'nectar'" in py and "'honey'" in py + assert "('100', '20', '8', '80')," in py diff --git a/tests/e2e/generate_test.pyi b/tests/e2e/generate_test.pyi index 3131d42..e52beb1 100644 --- a/tests/e2e/generate_test.pyi +++ b/tests/e2e/generate_test.pyi @@ -49,3 +49,4 @@ def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: ... def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: ... def test_generate_wipes_stale_pyi_in_tests_features(pytester) -> None: ... def test_generate_creates_tests_features_dir_if_absent(pytester) -> None: ... +def test_outline_scenario_emits_parametrize_in_py(pytester) -> None: ... diff --git a/tests/integration/parametrize_test.py b/tests/integration/parametrize_test.py new file mode 100644 index 0000000..80c32e4 --- /dev/null +++ b/tests/integration/parametrize_test.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +OUTLINE_FEATURE = """\ +Feature: Parametrize Emission +Scenario Outline: honey from nectar +Given the hive has grams +When the bees fan for hours +Then the hive produces grams + +Examples: + | nectar | hours | honey | + | 100 | 8 | 80 | + | 200 | 12 | 150 | +""" + +PLAIN_FEATURE = """\ +Feature: Parametrize Emission +Scenario: plain scenario +Given a step +Then anything +""" + + +def _emit(root: Path, feature_text: str) -> tuple[str, str]: + from beehave.generate import generate + + features = root / "docs" / "features" + features.mkdir(parents=True) + (features / "input.feature").write_text(feature_text) + generate(root) + py = (root / "tests" / "features" / "input_default_test.py").read_text() + pyi = (root / "tests" / "features" / "input_default_test.pyi").read_text() + return py, pyi + + +def _emitted_py(feature_text: str) -> str: + with tempfile.TemporaryDirectory() as tmp: + py, _pyi = _emit(Path(tmp), feature_text) + return py + + +def _emitted_pyi(feature_text: str) -> str: + with tempfile.TemporaryDirectory() as tmp: + _py, pyi = _emit(Path(tmp), feature_text) + return pyi + + +def _check_result(feature_text: str, test_py_text: str) -> bool: + from beehave.check import check + + return check(feature_text, test_py_text) + + +def test_examples_scenario_emits_parametrize_decorator() -> None: + py = _emitted_py(OUTLINE_FEATURE) + assert "@pytest.mark.parametrize(" in py + + +def test_parametrize_arg_names_match_examples_headers() -> None: + py = _emitted_py(OUTLINE_FEATURE) + needle = py[py.find("@pytest.mark.parametrize(") :] + assert "'nectar'" in needle + assert "'hours'" in needle + assert "'honey'" in needle + + +def test_parametrize_rows_match_examples_rows() -> None: + py = _emitted_py(OUTLINE_FEATURE) + assert "('100', '8', '80')," in py + assert "('200', '12', '150')," in py + + +def test_no_examples_scenario_emits_no_parametrize() -> None: + py = _emitted_py(PLAIN_FEATURE) + assert "parametrize" not in py + assert "import pytest" not in py + + +def test_pyi_signature_carries_str_params_for_outline() -> None: + pyi = _emitted_pyi(OUTLINE_FEATURE) + assert ( + "def test_honey_from_nectar(nectar: str, hours: str, honey: str) -> None:" + in pyi + ) + + +def test_check_passes_when_parametrize_matches_examples() -> None: + py = _emitted_py(OUTLINE_FEATURE) + assert _check_result(OUTLINE_FEATURE, py) + + +def test_check_fails_when_examples_present_but_no_parametrize() -> None: + body_without_parametrize = ( + "from beehave import step\n" + "\n" + "def test_honey_from_nectar(nectar: str, hours: str, honey: str) -> None:\n" + ' with step("Given", "the hive has grams", nectar=nectar):\n' + " pass\n" + ' with step("When", "the bees fan for hours", hours=hours):\n' + " pass\n" + ' with step("Then", "the hive produces grams", honey=honey):\n' + " pass\n" + ) + assert not _check_result(OUTLINE_FEATURE, body_without_parametrize) + + +def test_check_fails_when_parametrize_rows_differ() -> None: + py = _emitted_py(OUTLINE_FEATURE) + edited = py.replace("('100', '8', '80'),", "('999', '8', '80'),") + assert edited != py + assert not _check_result(OUTLINE_FEATURE, edited) + + +def test_check_fails_when_parametrize_arg_names_differ() -> None: + py = _emitted_py(OUTLINE_FEATURE) + edited = py.replace("'nectar'", "'renamed'", 1) + assert edited != py + assert not _check_result(OUTLINE_FEATURE, edited) diff --git a/tests/integration/parametrize_test.pyi b/tests/integration/parametrize_test.pyi new file mode 100644 index 0000000..2c48fa0 --- /dev/null +++ b/tests/integration/parametrize_test.pyi @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pathlib import Path + +OUTLINE_FEATURE: str +PLAIN_FEATURE: str + +def _emit(root: Path, feature_text: str) -> tuple[str, str]: ... +def _emitted_py(feature_text: str) -> str: ... +def _emitted_pyi(feature_text: str) -> str: ... +def _check_result(feature_text: str, test_py_text: str) -> bool: ... +def test_examples_scenario_emits_parametrize_decorator() -> None: ... +def test_parametrize_arg_names_match_examples_headers() -> None: ... +def test_parametrize_rows_match_examples_rows() -> None: ... +def test_no_examples_scenario_emits_no_parametrize() -> None: ... +def test_pyi_signature_carries_str_params_for_outline() -> None: ... +def test_check_passes_when_parametrize_matches_examples() -> None: ... +def test_check_fails_when_examples_present_but_no_parametrize() -> None: ... +def test_check_fails_when_parametrize_rows_differ() -> None: ... +def test_check_fails_when_parametrize_arg_names_differ() -> None: ... diff --git a/tests/integration/strategy_inference_test.py b/tests/integration/strategy_inference_test.py deleted file mode 100644 index 02fdf5d..0000000 --- a/tests/integration/strategy_inference_test.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import annotations - -import tempfile -from pathlib import Path - -INT_COLUMN_FEATURE = """\ -Feature: Strategy Inference -Scenario Outline: int column -Given a value of -Then anything - -Examples: - | amount | - | 1 | - | 2 | -""" - -FLOAT_COLUMN_FEATURE = """\ -Feature: Strategy Inference -Scenario Outline: float column -Given a value of -Then anything - -Examples: - | amount | - | 1.5 | - | 2.5 | -""" - -BOOL_COLUMN_FEATURE = """\ -Feature: Strategy Inference -Scenario Outline: bool column -Given a flag of -Then anything - -Examples: - | flag | - | true | - | false | -""" - -MIXED_COLUMN_FEATURE = """\ -Feature: Strategy Inference -Scenario Outline: mixed column -Given a value of -Then anything - -Examples: - | amount | - | 1 | - | 2.5 | - | hello | -""" - -TEXT_COLUMN_FEATURE = """\ -Feature: Strategy Inference -Scenario Outline: text column -Given a value of -Then anything - -Examples: - | name | - | alice | - | bob | -""" - -NO_EXAMPLES_FEATURE = """\ -Feature: Strategy Inference -Scenario: no examples -Given a value of -Then anything -""" - - -def emitted_function_signature(feature_text: str, scenario_slug: str) -> str: - from beehave.generate import generate - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - features = root / "docs" / "features" - features.mkdir(parents=True) - (features / "input.feature").write_text(feature_text) - generate(root) - pyi = (root / "tests" / "features" / "input_default_test.pyi").read_text() - needle = f"def test_{scenario_slug}" - start = pyi.find(needle) - if start == -1: - return "" - end = pyi.find("...", start) - if end == -1: - return pyi[start:] - return pyi[start : end + 3] - - -def test_all_int_column_infers_int_parameter() -> None: - signature = emitted_function_signature(INT_COLUMN_FEATURE, "int_column") - assert "amount: int" in signature - - -def test_all_float_column_infers_float_parameter() -> None: - signature = emitted_function_signature(FLOAT_COLUMN_FEATURE, "float_column") - assert "amount: float" in signature - - -def test_all_bool_column_infers_bool_parameter() -> None: - signature = emitted_function_signature(BOOL_COLUMN_FEATURE, "bool_column") - assert "flag: bool" in signature - - -def test_mixed_type_column_infers_str_parameter() -> None: - signature = emitted_function_signature(MIXED_COLUMN_FEATURE, "mixed_column") - assert "amount: str" in signature - - -def test_text_column_infers_str_parameter() -> None: - signature = emitted_function_signature(TEXT_COLUMN_FEATURE, "text_column") - assert "name: str" in signature - - -def test_no_examples_table_infers_str_parameter() -> None: - signature = emitted_function_signature(NO_EXAMPLES_FEATURE, "no_examples") - assert "name: str" in signature diff --git a/tests/integration/strategy_inference_test.pyi b/tests/integration/strategy_inference_test.pyi deleted file mode 100644 index 9784795..0000000 --- a/tests/integration/strategy_inference_test.pyi +++ /dev/null @@ -1,27 +0,0 @@ -# Integration contract for Examples-table -> Hypothesis strategy -> `.pyi` type. -# -# The generation rule (interview L1): for each Examples column, the strategy is -# inferred from cell values across all rows - all-parseable int -> int, -# all-parseable float -> float, all-parseable bool -> bool, otherwise -> str. -# When a Scenario has no Examples table, placeholders default to `str` -# (decision Q4 - simplest sound default; matches the "otherwise -> str" branch). -# -# These tests drive `beehave.gherkin.parse_feature` + `beehave.generate`'s -# emission path in-process; the SUT imports live in each body (deferred), so -# the `.pyi` does not import beehave. - -# Minimal feature snippets exercising each strategy branch. -INT_COLUMN_FEATURE: str -FLOAT_COLUMN_FEATURE: str -BOOL_COLUMN_FEATURE: str -MIXED_COLUMN_FEATURE: str -TEXT_COLUMN_FEATURE: str -NO_EXAMPLES_FEATURE: str - -def emitted_function_signature(feature_text: str, scenario_slug: str) -> str: ... -def test_all_int_column_infers_int_parameter() -> None: ... -def test_all_float_column_infers_float_parameter() -> None: ... -def test_all_bool_column_infers_bool_parameter() -> None: ... -def test_mixed_type_column_infers_str_parameter() -> None: ... -def test_text_column_infers_str_parameter() -> None: ... -def test_no_examples_table_infers_str_parameter() -> None: ... diff --git a/uv.lock b/uv.lock index 5fe3a7c..ac0d82a 100644 --- a/uv.lock +++ b/uv.lock @@ -91,7 +91,7 @@ wheels = [ [[package]] name = "beehave" -version = "2.1.0" +version = "2.2.0" source = { editable = "." } dependencies = [ { name = "gherkin-official" }, From caf3d6eb2b850954b5c0e591c82aa3a7d45b17ea Mon Sep 17 00:00:00 2001 From: nullhack Date: Sun, 19 Jul 2026 10:20:18 -0400 Subject: [PATCH 10/13] feat(beehave-v6)!: dual-mode enforcement + strict Gherkin subset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-layer enforcement: `beehave check` (always-on signature gate via mypy.stubtest + regenerate-and-diff) and opt-in `step` runtime (step block + parametrize row verification against the parsed feature). v2.3.0 — dual-mode rearchitecture: - check.py rewritten to signature-only regenerate-and-diff (zero AST); the .pyi is the sole binding surface; `beehave check` runs stubtest internally (mypy is now a runtime dependency). - step.py rewritten for Mode B: frame walk-up finds the test_ caller, a frame-keyed counter tracks step position, each block verifies (keyword, text, placeholder-name-set) at position N against the scenario's parsed steps, and @parametrize rows are verified against Examples. Unknown callers raise NoActiveScenarioError. - _index.py (new): lazy module-level scenario index keyed by function name, built once per process from docs/features/*.feature. - gherkin.py: _reject_duplicate now keys on the slug (stripped word sequence), so titles differing only in whitespace runs collide. - cli.py: _check_all runs stubtest (subprocess with MYPYPATH + PYTHONPATH = tests/features), regenerate-and-diff, and the orphan pass (.py without .pyi sibling in tests/features/). v2.3.1 — strict Gherkin subset: - Placeholder extraction is now Examples-aware: in a plain Scenario is literal text, only matched in Scenario Outlines where the name appears in an Examples header (closes the footgun). - Multiple Examples tables are merged (was: only first kept); per-row tags tracked for conditional pytest.param emission. - Tags surface as pytest marks: Feature/Rule tags -> module-level pytestmark; Scenario tags -> @pytest.mark. decorator. - Docstrings surface as body-local `docstring = "..."` inside the step block (not passed to the step CM). - Data tables surface as body-local `data_table = [{'col': ...}]` inside the step block. - @parametrize uses pytest.param(..., marks=...) ONLY when Examples tables have differing tags; plain tuples otherwise. 60 tests green; ruff/format/mypy/stubtest(8+22 modules)/lint-merge clean. BREAKING CHANGE: check.py no longer inspects step bodies; step runtime verification is opt-in via `from beehave import step`. Hypothesis strategies removed; all params typed str with @parametrize rows. --- beehave/__init__.py | 4 +- beehave/__init__.pyi | 13 +- beehave/_index.py | 48 +++++++ beehave/check.py | 141 +++---------------- beehave/check.pyi | 19 ++- beehave/cli.py | 49 ++++++- beehave/generate.py | 53 +++++-- beehave/gherkin.py | 64 ++++++--- beehave/gherkin.pyi | 1 + beehave/step.py | 88 ++++++++++++ beehave/step.pyi | 21 +-- pyproject.toml | 3 +- tests/e2e/check_test.py | 186 ++++--------------------- tests/e2e/check_test.pyi | 30 ++-- tests/e2e/generate_test.py | 27 +++- tests/e2e/generate_test.pyi | 7 +- tests/integration/parametrize_test.py | 100 ++++++++----- tests/integration/parametrize_test.pyi | 12 +- tests/integration/roundtrip_test.py | 49 ++----- tests/integration/roundtrip_test.pyi | 28 ++-- tests/integration/step_cm_test.py | 149 +++++++++++++++----- tests/integration/step_cm_test.pyi | 49 ++++--- uv.lock | 8 +- 23 files changed, 623 insertions(+), 526 deletions(-) create mode 100644 beehave/_index.py diff --git a/beehave/__init__.py b/beehave/__init__.py index cb5a299..e0fc6d0 100644 --- a/beehave/__init__.py +++ b/beehave/__init__.py @@ -1,3 +1,5 @@ +from beehave._index import NoActiveScenarioError as NoActiveScenarioError +from beehave.step import StepError as StepError from beehave.step import step as step -__version__ = "2.2.0" +__version__ = "2.3.1" diff --git a/beehave/__init__.pyi b/beehave/__init__.pyi index 28ab9b1..335642e 100644 --- a/beehave/__init__.pyi +++ b/beehave/__init__.pyi @@ -1,10 +1,11 @@ -# Public package surface for beehave v2. +# Public package surface for beehave v2.3. # -# - `step` is the executable `with`-block CM (re-exported; lives in -# `beehave.step`). -# - `__version__` is the single source of truth (interview L1 Constraint 3; -# the `== "2.0.0"` assertion is deferred to deliver — only the -# declaration lives here). +# - `step` is the Mode B executable `with`-block CM (lives in `beehave.step`). +# - `StepError` is raised on step/parametrize verification failure. +# - `NoActiveScenarioError` is raised when `step()` runs outside a known scenario. +# - `__version__` is the single source of truth. +from beehave._index import NoActiveScenarioError as NoActiveScenarioError +from beehave.step import StepError as StepError from beehave.step import step as step __version__: str diff --git a/beehave/_index.py b/beehave/_index.py new file mode 100644 index 0000000..30d2572 --- /dev/null +++ b/beehave/_index.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from beehave.gherkin import Scenario + + +class NoActiveScenarioError(Exception): + """Raised when a step() call's calling function is not a known scenario.""" + + +_index: dict[str, Scenario] | None = None + + +def _build_index() -> dict[str, Scenario]: + from beehave.gherkin import Rule, parse_feature + + features_dir = Path.cwd() / "docs" / "features" + index: dict[str, Scenario] = {} + if not features_dir.is_dir(): + return index + for feature_path in sorted(features_dir.glob("*.feature")): + feature = parse_feature(feature_path.read_text()) + for child in feature.children: + scenarios = child.children if isinstance(child, Rule) else [child] + for scenario in scenarios: + index[scenario.function_name] = scenario + return index + + +def get(function_name: str) -> Scenario: + global _index + if _index is None: + _index = _build_index() + if function_name not in _index: + raise NoActiveScenarioError( + f"{function_name!r} is not a known scenario function; " + f"step() may only be called inside a generated test body" + ) + return _index[function_name] + + +def _reset() -> None: + """Drop the cached index (test hook; not for consumer use).""" + global _index + _index = None diff --git a/beehave/check.py b/beehave/check.py index 73408a5..d7a5156 100644 --- a/beehave/check.py +++ b/beehave/check.py @@ -1,144 +1,35 @@ from __future__ import annotations -import ast from typing import TYPE_CHECKING from beehave.gherkin import Rule, parse_feature if TYPE_CHECKING: - from beehave.gherkin import Examples, Scenario, Step + from beehave.gherkin import Scenario, Step -def _step_block_from_item( - item: ast.withitem, -) -> tuple[str, str, set[str]] | None: - call = item.context_expr - if not isinstance(call, ast.Call): - return None - callee = call.func - if not isinstance(callee, ast.Name) or callee.id != "step": - return None - if len(call.args) < 2: - return None - try: - keyword = ast.literal_eval(call.args[0]) - text = ast.literal_eval(call.args[1]) - except ValueError, SyntaxError: - return None - if not isinstance(keyword, str) or not isinstance(text, str): - return None - names = {kw.arg for kw in call.keywords if kw.arg is not None} - return (keyword, text, names) +def _placeholder_names(steps: list[Step]) -> list[str]: + seen: set[str] = set() + names: list[str] = [] + for step in steps: + for placeholder in step.placeholders: + if placeholder.name not in seen: + seen.add(placeholder.name) + names.append(placeholder.name) + return names -def _step_blocks( - function: ast.FunctionDef, -) -> list[tuple[str, str, set[str]]]: - blocks: list[tuple[str, str, set[str]]] = [] - for stmt in function.body: - if not isinstance(stmt, ast.With): - continue - for item in stmt.items: - block = _step_block_from_item(item) - if block is not None: - blocks.append(block) - return blocks +def _signature_line(scenario: Scenario) -> str: + names = _placeholder_names(scenario.steps) + params = ", ".join(f"{name}: str" for name in names) + return f"def {scenario.function_name}({params}) -> None: ..." -def _parametrize_of( - function: ast.FunctionDef, -) -> tuple[list[str], list[tuple[str, ...]]] | None: - for decorator in function.decorator_list: - if not isinstance(decorator, ast.Call): - continue - func = decorator.func - if ( - not isinstance(func, ast.Attribute) - or func.attr != "parametrize" - or not isinstance(func.value, ast.Attribute) - or func.value.attr != "mark" - or not isinstance(func.value.value, ast.Name) - or func.value.value.id != "pytest" - ): - continue - if len(decorator.args) < 2: - continue - try: - arg_names = ast.literal_eval(decorator.args[0]) - rows = ast.literal_eval(decorator.args[1]) - except ValueError, SyntaxError: - continue - if not isinstance(arg_names, tuple) or not isinstance(rows, list): - continue - return (list(arg_names), [tuple(r) for r in rows]) - return None - - -def _examples_rows( - scenario: Scenario, -) -> tuple[list[str], list[tuple[str, ...]]] | None: - examples: Examples | None = scenario.examples - if examples is None: - return None - headers = list(examples.headers) - rows = [tuple(row[h] for h in headers) for row in examples.rows] - return (headers, rows) - - -def _step_matches( - block: tuple[str, str, set[str]], - step: Step, -) -> bool: - keyword, text, names = block - if keyword.lower() != step.keyword.lower(): - return False - if text != step.text: - return False - return names == {p.name for p in step.placeholders} - - -def _scenario_matches( - scenario: Scenario, - blocks_by_function: dict[str, list[tuple[str, str, set[str]]]], - parametrize_by_function: dict[str, tuple[list[str], list[tuple[str, ...]]] | None], -) -> bool: - name = scenario.function_name - blocks = blocks_by_function.get(name, []) - if len(blocks) != len(scenario.steps): - return False - if not all( - _step_matches(block, step) - for block, step in zip(blocks, scenario.steps, strict=True) - ): - return False - expected = _examples_rows(scenario) - if expected is not None: - actual = parametrize_by_function.get(name) - if actual is None or actual != expected: - return False - return True - - -def check(feature_text: str, test_py_text: str) -> bool: +def check(feature_text: str, stub_text: str) -> bool: feature = parse_feature(feature_text) - try: - tree = ast.parse(test_py_text) - except SyntaxError: - return False - blocks_by_function: dict[str, list[tuple[str, str, set[str]]]] = {} - parametrize_by_function: dict[ - str, tuple[list[str], list[tuple[str, ...]]] | None - ] = {} - for node in tree.body: - if not isinstance(node, ast.FunctionDef): - continue - blocks_by_function[node.name] = _step_blocks(node) - parametrize_by_function[node.name] = _parametrize_of(node) for child in feature.children: scenarios = child.children if isinstance(child, Rule) else [child] for scenario in scenarios: - if not _scenario_matches( - scenario, blocks_by_function, parametrize_by_function - ): + if _signature_line(scenario) not in stub_text: return False return True diff --git a/beehave/check.pyi b/beehave/check.pyi index 5ff4382..e35d4e8 100644 --- a/beehave/check.pyi +++ b/beehave/check.pyi @@ -1,11 +1,8 @@ -# Structural binding check (L2 *Validation (`check`)*): the -# `with step(...)` blocks in `test_py_text` are matched one-to-one against -# the steps parsed from `feature_text` on the triple -# (keyword-case-insensitively, text, placeholder-name-set). Returns True if -# every block matches its step; False on any count, keyword, text, or -# placeholder-name-set mismatch. Does NOT inspect the body for literals or -# placeholder AST nodes (v2 drops that layer entirely — Q5). For Scenario -# Outlines, additionally requires a `@pytest.mark.parametrize(...)` decorator -# whose arg-names and rows round-trip the feature's Examples table; missing or -# mismatched parametrize returns False. -def check(feature_text: str, test_py_text: str) -> bool: ... +# v2.3 signature check: regenerates expected `def test_(...) -> None: ...` +# lines from the feature source and verifies each appears in `stub_text` +# (the concatenated content of `tests/features/*_test.pyi`). Returns True iff +# every scenario signature is present; False on any missing signature (stale +# `.pyi`). Does NOT inspect the `.py` body — step/parametrize verification is +# the runtime `step()` CM's job (Mode B). The CLI additionally runs +# `mypy.stubtest` on the consumer test modules for `.py` ↔ `.pyi` drift. +def check(feature_text: str, stub_text: str) -> bool: ... diff --git a/beehave/cli.py b/beehave/cli.py index 41d812f..1cf643d 100644 --- a/beehave/cli.py +++ b/beehave/cli.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import subprocess import sys from collections.abc import Sequence from pathlib import Path @@ -24,6 +26,43 @@ def main(argv: Sequence[str] | None = None) -> int: return 2 +def _run_stubtest(root: Path) -> bool: + tests_features_dir = root / "tests" / "features" + if not tests_features_dir.is_dir(): + return True + modules = [p.stem for p in sorted(tests_features_dir.glob("*_test.py"))] + if not modules: + return True + env = dict(os.environ) + existing = env.get("PYTHONPATH", "") + env["PYTHONPATH"] = str(tests_features_dir) + ( + os.pathsep + existing if existing else "" + ) + existing_mypy = env.get("MYPYPATH", "") + env["MYPYPATH"] = str(tests_features_dir) + ( + os.pathsep + existing_mypy if existing_mypy else "" + ) + result = subprocess.run( + [ + sys.executable, + "-m", + "mypy.stubtest", + "--ignore-missing-stub", + *modules, + ], + env=env, + cwd=root, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + sys.stdout.write(result.stdout) + sys.stderr.write(result.stderr) + return False + return True + + def _check_all(root: Path) -> int: features_dir = root / "docs" / "features" if not features_dir.is_dir(): @@ -39,14 +78,16 @@ def _check_all(root: Path) -> int: for orphan in orphans: print(f"orphan: {orphan.name}", file=sys.stderr) return 1 - test_py_text = _read_test_py(tests_features_dir) + if not _run_stubtest(root): + return 1 + stub_text = _read_stub_text(tests_features_dir) for feature_path in sorted(features_dir.glob("*.feature")): - if not check(feature_path.read_text(), test_py_text): + if not check(feature_path.read_text(), stub_text): return 1 return 0 -def _read_test_py(tests_dir: Path) -> str: +def _read_stub_text(tests_dir: Path) -> str: if not tests_dir.is_dir(): return "" - return "\n".join(path.read_text() for path in sorted(tests_dir.glob("*_test.py"))) + return "\n".join(path.read_text() for path in sorted(tests_dir.glob("*_test.pyi"))) diff --git a/beehave/generate.py b/beehave/generate.py index 5f27c32..3def7d8 100644 --- a/beehave/generate.py +++ b/beehave/generate.py @@ -6,7 +6,7 @@ from beehave.gherkin import Rule, parse_feature if TYPE_CHECKING: - from beehave.gherkin import Examples, Scenario, Step + from beehave.gherkin import DataTable, Examples, Scenario, Step def _slug_from(title: str) -> str: @@ -33,14 +33,20 @@ def _parametrize_lines(scenario: Scenario) -> list[str]: if examples is None: return [] arg_names = ", ".join(repr(h) for h in examples.headers) + distinct_tags = {tuple(tags) for tags in examples.row_tags} + use_param = len(distinct_tags) > 1 lines = [ "@pytest.mark.parametrize(", f" ({arg_names}),", " [", ] - for row in examples.rows: + for row, tags in zip(examples.rows, examples.row_tags, strict=False): cells = ", ".join(repr(row[h]) for h in examples.headers) - lines.append(f" ({cells}),") + if use_param and tags: + marks = ", ".join(f"pytest.mark.{t}" for t in tags) + lines.append(f" pytest.param({cells}, marks={marks}),") + else: + lines.append(f" ({cells}),") lines.append(" ],") lines.append(")") return lines @@ -59,19 +65,45 @@ def _step_block(step: Step) -> str: return f" with step({step.keyword!r}, {step.text!r}{kwargs}):" -def _render_py(scenarios: list[Scenario]) -> str: - lines: list[str] = ["from beehave import step"] - if any(s.examples is not None for s in scenarios): - lines.append("import pytest") +def _data_table_repr(dt: DataTable) -> str: + if dt.headers is None: + return repr(dt.rows) + items: list[str] = [] + for row in dt.rows: + pairs = ", ".join( + f"{h!r}: {v!r}" for h, v in zip(dt.headers, row, strict=False) + ) + items.append("{" + pairs + "}") + return "[" + ", ".join(items) + "]" + + +def _step_body_lines(step: Step) -> list[str]: + lines: list[str] = [] + if step.docstring is not None: + lines.append(f" docstring = {step.docstring!r}") + if step.data_table is not None: + lines.append(f" data_table = {_data_table_repr(step.data_table)}") + if not lines: + lines.append(" pass") + return lines + + +def _render_py(scenarios: list[Scenario], module_tags: list[str]) -> str: + lines: list[str] = ["from beehave import step", "import pytest"] + if module_tags: + marks = ", ".join(f"pytest.mark.{t}" for t in module_tags) + lines.append(f"pytestmark = [{marks}]") for scenario in scenarios: params = _signature_params(scenario) lines.append("") lines.append("") + for tag in scenario.tags: + lines.append(f"@pytest.mark.{tag}") lines.extend(_parametrize_lines(scenario)) lines.append(f"def {scenario.function_name}({params}) -> None:") for step in scenario.steps: lines.append(_step_block(step)) - lines.append(" pass") + lines.extend(_step_body_lines(step)) if not scenario.steps: lines.append(" pass") return "\n".join(lines) + "\n" @@ -82,12 +114,13 @@ def _emit_group( tests_dir: Path, stem: str, scenarios: list[Scenario], + module_tags: list[str], ) -> None: pyi_path = tests_dir / f"{stem}_test.pyi" py_path = tests_dir / f"{stem}_test.py" pyi_path.write_text(_render_pyi(scenarios)) if not py_path.exists(): - py_path.write_text(_render_py(scenarios)) + py_path.write_text(_render_py(scenarios, module_tags)) def generate(root: Path) -> None: @@ -114,10 +147,12 @@ def generate(root: Path) -> None: tests_dir=tests_dir, stem=f"{feature_slug}_default", scenarios=default_scenarios, + module_tags=feature.tags, ) for rule in rules: _emit_group( tests_dir=tests_dir, stem=f"{feature_slug}_{_slug_from(rule.name)}", scenarios=rule.children, + module_tags=[*feature.tags, *rule.tags], ) diff --git a/beehave/gherkin.py b/beehave/gherkin.py index e215099..a72684e 100644 --- a/beehave/gherkin.py +++ b/beehave/gherkin.py @@ -26,6 +26,7 @@ class Step: class Examples: headers: list[str] rows: list[dict[str, str]] + row_tags: list[list[str]] class Background: @@ -93,10 +94,15 @@ def _make_step( return s -def _make_examples(headers: list[str], rows: list[dict[str, str]]) -> Examples: +def _make_examples( + headers: list[str], + rows: list[dict[str, str]], + row_tags: list[list[str]], +) -> Examples: e = Examples() e.headers = headers e.rows = rows + e.row_tags = row_tags return e @@ -157,13 +163,22 @@ def _make_feature( return f -def _placeholders_from(text: str) -> list[Placeholder]: +def _tag_names_from(data: dict[str, Any]) -> list[str]: + return [t["name"].lstrip("@") for t in data.get("tags", [])] + + +def _placeholders_from( + text: str, + valid_names: set[str] | None = None, +) -> list[Placeholder]: seen: set[str] = set() result: list[Placeholder] = [] for match in _PLACEHOLDER_RE.finditer(text): name = match.group(1) if name in seen: continue + if valid_names is not None and name not in valid_names: + continue seen.add(name) result.append(_make_placeholder(name)) return result @@ -177,14 +192,17 @@ def _data_table_from(data: dict[str, Any]) -> DataTable: return _make_data_table(headers=cells[0], rows=cells[1:]) -def _step_from(data: dict[str, Any]) -> Step: +def _step_from( + data: dict[str, Any], + valid_names: set[str] | None = None, +) -> Step: text = data["text"] dt = data.get("dataTable") doc_string = data.get("docString") return _make_step( keyword=data["keyword"].strip(), text=text, - placeholders=_placeholders_from(text), + placeholders=_placeholders_from(text, valid_names), docstring=doc_string.get("content") if doc_string else None, data_table=_data_table_from(dt) if dt else None, ) @@ -197,14 +215,20 @@ def _background_from(data: dict[str, Any]) -> Background: def _examples_from(ex_list: list[dict[str, Any]]) -> Examples | None: if not ex_list: return None - ex = ex_list[0] - header_row = ex.get("tableHeader") or {} - headers = [c["value"] for c in header_row.get("cells", [])] + headers: list[str] = [] rows: list[dict[str, str]] = [] - for row in ex.get("tableBody", []): - cells = [c["value"] for c in row.get("cells", [])] - rows.append(dict(zip(headers, cells, strict=False))) - return _make_examples(headers=headers, rows=rows) + row_tags: list[list[str]] = [] + for ex in ex_list: + header_row = ex.get("tableHeader") or {} + table_headers = [c["value"] for c in header_row.get("cells", [])] + if not headers: + headers = table_headers + tags = _tag_names_from(ex) + for row in ex.get("tableBody", []): + cells = [c["value"] for c in row.get("cells", [])] + rows.append(dict(zip(headers, cells, strict=False))) + row_tags.append(tags) + return _make_examples(headers=headers, rows=rows, row_tags=row_tags) def _slug_from(title: str) -> str: @@ -229,9 +253,9 @@ def _validate_single_title(title: str, kind: str) -> None: def _reject_duplicate(title: str, kind: str, seen: set[str]) -> None: - key = title.strip().lower() + key = _slug_from(title) if key in seen: - raise ValueError(f"Duplicate {kind} title {title!r} (case-insensitive match)") + raise ValueError(f"Duplicate {kind} title {title!r} (slug match)") seen.add(key) @@ -263,15 +287,17 @@ def _scenario_from( _validate_single_title(title, "scenario") _reject_duplicate(title, "scenario", seen) slug = _slug_from(title) - own_steps = [_step_from(s) for s in data.get("steps", [])] + examples = _examples_from(data.get("examples", [])) + valid_names: set[str] = set(examples.headers) if examples else set() + own_steps = [_step_from(s, valid_names) for s in data.get("steps", [])] return _make_scenario( title=title, slug=slug, function_name=f"test_{slug}", - tags=[t["name"] for t in data.get("tags", [])], + tags=_tag_names_from(data), keyword=data["keyword"], steps=[*merged_steps, *own_steps], - examples=_examples_from(data.get("examples", [])), + examples=examples, ) @@ -296,7 +322,7 @@ def _rule_from( ) return _make_rule( name=name, - tags=[t["name"] for t in data.get("tags", [])], + tags=_tag_names_from(data), background=rule_bg, children=children, ) @@ -318,7 +344,7 @@ def parse_feature(source: str) -> Feature: seen: set[str] = set() feature_name = feature_data.get("name", "") if feature_name: - seen.add(feature_name.strip().lower()) + seen.add(_slug_from(feature_name)) children: list[Rule | Scenario] = [] for child in feature_data.get("children", []): @@ -329,7 +355,7 @@ def parse_feature(source: str) -> Feature: return _make_feature( name=feature_name, - tags=[t["name"] for t in feature_data.get("tags", [])], + tags=_tag_names_from(feature_data), background=background, children=children, ) diff --git a/beehave/gherkin.pyi b/beehave/gherkin.pyi index fc90bae..712f0bb 100644 --- a/beehave/gherkin.pyi +++ b/beehave/gherkin.pyi @@ -21,6 +21,7 @@ class Step: class Examples: headers: list[str] rows: list[dict[str, str]] + row_tags: list[list[str]] class Background: steps: list[Step] diff --git a/beehave/step.py b/beehave/step.py index 826204e..e3f74b5 100644 --- a/beehave/step.py +++ b/beehave/step.py @@ -1,5 +1,58 @@ +from __future__ import annotations + +import sys from collections.abc import Iterator from contextlib import contextmanager +from types import FrameType +from typing import TYPE_CHECKING + +from beehave._index import NoActiveScenarioError, get + +if TYPE_CHECKING: + from beehave.gherkin import Scenario + + +class StepError(Exception): + """Raised when a step() block fails runtime verification.""" + + +_counters: dict[int, int] = {} + + +def _row_values(row: object) -> tuple[object, ...]: + if hasattr(row, "values"): + return tuple(row.values) # type: ignore[attr-defined] + return tuple(row) # type: ignore[arg-type] + + +def _verify_parametrize(frame: FrameType, func_name: str, scenario: Scenario) -> None: + examples = scenario.examples + if examples is None: + return + func = frame.f_globals.get(func_name) + if func is None: + return + marks = getattr(func, "pytestmark", []) + expected_names = tuple(examples.headers) + expected_rows = [tuple(row[h] for h in examples.headers) for row in examples.rows] + for mark in marks: + if getattr(mark, "name", None) != "parametrize": + continue + args = mark.args + if len(args) < 2: + raise StepError(f"{func_name}: @parametrize has too few args") + actual_names = tuple(args[0]) + actual_rows = [_row_values(r) for r in args[1]] + if actual_names != expected_names or actual_rows != expected_rows: + raise StepError( + f"{func_name}: @parametrize does not match Examples " + f"(expected names={expected_names}, rows={expected_rows}; " + f"got names={actual_names}, rows={actual_rows})" + ) + return + raise StepError( + f"{func_name}: scenario has Examples but function lacks @parametrize" + ) @contextmanager @@ -9,6 +62,41 @@ def step( /, **placeholders: object, ) -> Iterator[None]: + frame = sys._getframe(1) + while frame is not None and not frame.f_code.co_name.startswith("test_"): + frame = frame.f_back + if frame is None: + raise NoActiveScenarioError( + "step() must be called from a test_ function body" + ) + func_name = frame.f_code.co_name + scenario = get(func_name) + position = _counters.get(id(frame), 0) + if position >= len(scenario.steps): + raise StepError( + f"{func_name}: too many step() calls (expected {len(scenario.steps)})" + ) + expected = scenario.steps[position] + next_position = position + 1 + if next_position >= len(scenario.steps): + _counters.pop(id(frame), None) + else: + _counters[id(frame)] = next_position + expected_names = {p.name for p in expected.placeholders} + actual_names = set(placeholders.keys()) + if ( + keyword.lower() != expected.keyword.lower() + or text != expected.text + or actual_names != expected_names + ): + raise StepError( + f"{func_name}: step {position} mismatch " + f"(expected keyword={expected.keyword!r}, text={expected.text!r}, " + f"placeholders={expected_names}; " + f"got keyword={keyword!r}, text={text!r}, placeholders={actual_names})" + ) + if position == 0: + _verify_parametrize(frame, func_name, scenario) try: yield except Exception as e: diff --git a/beehave/step.pyi b/beehave/step.pyi index cf3d66f..47f1faa 100644 --- a/beehave/step.pyi +++ b/beehave/step.pyi @@ -1,14 +1,19 @@ from collections.abc import Iterator from contextlib import contextmanager -# The v2 runtime core: `step(keyword, text, /, **placeholders)` is the -# executable `with` block that replaces v1's step-definition registry. -# `keyword` is data (covers all Gherkin keywords incl. localized); `assert` -# inside a `Then` block propagates; on exception the CM appends -# `f"{keyword} {text}"` via `add_note` so `__notes__ == [" "]`; -# no note on clean exit. `keyword`/`text` are positional-only; placeholder -# values are consumer-supplied scalars (strategy-inferred int/float/bool/str -# in emitted tests), so the kwarg type is `object`. +# v2.3 Mode B runtime enforcement: `step(keyword, text, /, **placeholders)` +# is the executable `with`-block CM. On entry it resolves the calling function +# (`sys._getframe(1).f_code.co_name` → `test_`) to a scenario in the +# cached feature index, tracks position via a frame-keyed counter, and verifies +# the block matches `scenario.steps[position]` on +# (keyword-case-insensitively, text, placeholder-name-set). On the first step +# of a scenario it also verifies any `@pytest.mark.parametrize(...)` decorator +# against the feature's Examples. On exception the CM appends +# `f"{keyword} {text}"` via PEP 678 `add_note` so failures attribute to their +# step. `keyword`/`text` are positional-only; placeholder values are +# consumer-supplied scalars, hence `object`. +class StepError(Exception): ... + @contextmanager def step( keyword: str, diff --git a/pyproject.toml b/pyproject.toml index 55d67ce..a6b69d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "beehave" -version = "2.2.0" +version = "2.3.1" description = "A thin layer on Hypothesis for Gherkin-style BDD testing with vocabulary enforcement" readme = "README.md" requires-python = ">=3.14" @@ -14,6 +14,7 @@ authors = [ ] dependencies = [ "gherkin-official>=39.0", + "mypy>=2.3.0", ] [project.scripts] diff --git a/tests/e2e/check_test.py b/tests/e2e/check_test.py index d237f24..0dfef70 100644 --- a/tests/e2e/check_test.py +++ b/tests/e2e/check_test.py @@ -4,7 +4,6 @@ from pathlib import Path HIVE_ACTIVITY_FEATURE = "hive_activity.feature" -COMB_CONSTRUCTION_FEATURE = "comb_construction.feature" def copy_feature_into_pytester(pytester, basename: str) -> str: @@ -22,174 +21,25 @@ def write_feature_text(pytester, basename: str, text: str) -> str: return str(dst) -def write_test_py(pytester, stem: str, body: str) -> str: - dst = pytester.path / "tests" / "features" / f"{stem}_test.py" - dst.parent.mkdir(parents=True, exist_ok=True) - dst.write_text(body) - dst.with_suffix(".pyi").write_text("") - return str(dst) - - def run_beehave_check(pytester, *args: str) -> int: return pytester.run("beehave", "check", *args).ret -def test_passes_when_blocks_match_steps(pytester) -> None: +def test_check_passes_on_freshly_generated_project(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) pytester.run("beehave", "generate") assert run_beehave_check(pytester) == 0 -def test_fails_when_block_count_differs_from_step_count(pytester) -> None: - feature_text = ( - "Feature: Minimal\n" - "Scenario: minimal scenario\n" - "Given first step\n" - "When second step\n" - ) - write_feature_text(pytester, "minimal.feature", feature_text) - short_body = ( - "from beehave import step\n" - "\n" - "def test_minimal_scenario():\n" - ' with step("Given", "first step"):\n' - " pass\n" - ) - write_test_py(pytester, "minimal_default", short_body) - assert run_beehave_check(pytester) != 0 - - -def test_fails_when_step_keyword_structurally_mismatches(pytester) -> None: - feature_text = ( - "Feature: Minimal\n" - "Scenario: minimal scenario\n" - "Given first step\n" - "When second step\n" - ) - write_feature_text(pytester, "minimal.feature", feature_text) - mismatched_body = ( - "from beehave import step\n" - "\n" - "def test_minimal_scenario():\n" - ' with step("Given", "first step"):\n' - " pass\n" - ' with step("Given", "second step"):\n' - " pass\n" - ) - write_test_py(pytester, "minimal_default", mismatched_body) - assert run_beehave_check(pytester) != 0 - - -def test_fails_when_step_text_mismatches(pytester) -> None: - feature_text = ( - "Feature: Minimal\n" - "Scenario: minimal scenario\n" - "Given first step\n" - "When second step\n" - ) - write_feature_text(pytester, "minimal.feature", feature_text) - mismatched_body = ( - "from beehave import step\n" - "\n" - "def test_minimal_scenario():\n" - ' with step("Given", "first step"):\n' - " pass\n" - ' with step("When", "different text"):\n' - " pass\n" - ) - write_test_py(pytester, "minimal_default", mismatched_body) - assert run_beehave_check(pytester) != 0 - - -def test_fails_when_placeholder_name_set_mismatches(pytester) -> None: - feature_text = ( - "Feature: Minimal\n" - "Scenario Outline: minimal scenario\n" - "Given a value of \n" - "When anything\n" - "\n" - "Examples:\n" - " | amount |\n" - " | 1 |\n" - ) - write_feature_text(pytester, "minimal.feature", feature_text) - mismatched_body = ( - "from beehave import step\n" - "\n" - "def test_minimal_scenario(amount):\n" - ' with step("Given", "a value of ", renamed=1):\n' - " pass\n" - ' with step("When", "anything"):\n' - " pass\n" - ) - write_test_py(pytester, "minimal_default", mismatched_body) +def test_check_fails_when_pyi_stale_after_feature_adds_scenario(pytester) -> None: + initial = "Feature: Stale Check\nScenario: first scenario\nGiven a step\n" + write_feature_text(pytester, "stale.feature", initial) + pytester.run("beehave", "generate") + updated = initial + "Scenario: second scenario\nGiven another step\n" + write_feature_text(pytester, "stale.feature", updated) assert run_beehave_check(pytester) != 0 -def test_passes_when_keyword_case_differs(pytester) -> None: - feature_text = ( - "Feature: Minimal\n" - "Scenario: minimal scenario\n" - "Given first step\n" - "When second step\n" - ) - write_feature_text(pytester, "minimal.feature", feature_text) - case_body = ( - "from beehave import step\n" - "\n" - "def test_minimal_scenario():\n" - ' with step("given", "first step"):\n' - " pass\n" - ' with step("when", "second step"):\n' - " pass\n" - ) - write_test_py(pytester, "minimal_default", case_body) - assert run_beehave_check(pytester) == 0 - - -def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: - feature_text = ( - "Feature: Minimal\n" - "Scenario: minimal scenario\n" - "Given first step\n" - "When second step\n" - ) - write_feature_text(pytester, "minimal.feature", feature_text) - body_with_content = ( - "from beehave import step\n" - "\n" - "def test_minimal_scenario():\n" - ' with step("Given", "first step"):\n' - " x = 1 + 1\n" - " y = x * 2\n" - ' with step("When", "second step"):\n' - " z = y + 1\n" - ) - write_test_py(pytester, "minimal_default", body_with_content) - assert run_beehave_check(pytester) == 0 - - -def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: - feature_text = ( - "Feature: Minimal\n" - "Scenario: minimal scenario\n" - "Given first step\n" - "When second step\n" - ) - write_feature_text(pytester, "minimal.feature", feature_text) - body_without_literals = ( - "from beehave import step\n" - "\n" - "def test_minimal_scenario():\n" - ' with step("Given", "first step"):\n' - " pass\n" - ' with step("When", "second step"):\n' - " pass\n" - ) - write_test_py(pytester, "minimal_default", body_without_literals) - assert run_beehave_check(pytester) == 0 - - def test_check_fails_on_orphan_py_in_tests_features(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) pytester.run("beehave", "generate") @@ -209,12 +59,24 @@ def test_check_ignores_handwritten_py_outside_tests_features(pytester) -> None: assert run_beehave_check(pytester) == 0 -def test_check_fails_when_parametrize_rows_edited(pytester) -> None: - copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) +def test_check_fails_when_py_drifts_from_pyi(pytester) -> None: + outline = ( + "Feature: Drift Check\n" + "Scenario Outline: drift scenario\n" + "Given a value of \n" + "\n" + "Examples:\n" + " | amount |\n" + " | 1 |\n" + ) + write_feature_text(pytester, "drift.feature", outline) pytester.run("beehave", "generate") - py_path = pytester.path / "tests" / "features" / "hive_activity_default_test.py" - edited = py_path.read_text().replace( - "('100', '20', '8', '80'),", "('999', '20', '8', '80')," + py_path = pytester.path / "tests" / "features" / "drift_default_test.py" + py_text = py_path.read_text() + edited = py_text.replace( + "def test_drift_scenario(amount: str)", + "def test_drift_scenario(renamed: str)", ) + assert edited != py_text py_path.write_text(edited) assert run_beehave_check(pytester) != 0 diff --git a/tests/e2e/check_test.pyi b/tests/e2e/check_test.pyi index da00760..e8f8022 100644 --- a/tests/e2e/check_test.pyi +++ b/tests/e2e/check_test.pyi @@ -1,29 +1,19 @@ -# E2E contract for the `beehave check` CLI command. +# E2E contract for the `beehave check` CLI command (v2.3.0). # -# Drives the CLI through the pytester subprocess. Asserts the structural-binding -# gate (the v2 CIT-rooted contract): the test body's `with step(...)` blocks in -# source order match the parsed `.feature` steps `block[i] <-> step[i]` on -# exactly three fields - `(keyword, text, placeholder-name-set)` - and NOTHING -# else. Body content is deferred to the review gate. Keyword matching is -# case-insensitive. Literal/placeholder AST appearance is NOT inspected -# (v1 noise loophole closed). +# `beehave check` is a two-layer static gate: +# 1. stubtest on tests/features/*_test.py (catches .py ↔ .pyi signature drift) +# 2. regenerate-and-diff (regenerates expected .pyi signatures from the +# feature, confirms they appear in the on-disk stub text) +# Plus an orphan pass: .py without .pyi sibling in tests/features/ → exit 1. +# Step-body / parametrize-row enforcement is the runtime `step` CM's job. -# Fixture feature basenames available under docs/features/. HIVE_ACTIVITY_FEATURE: str -COMB_CONSTRUCTION_FEATURE: str def copy_feature_into_pytester(pytester, basename: str) -> str: ... def write_feature_text(pytester, basename: str, text: str) -> str: ... -def write_test_py(pytester, stem: str, body: str) -> str: ... def run_beehave_check(pytester, *args: str) -> int: ... -def test_passes_when_blocks_match_steps(pytester) -> None: ... -def test_fails_when_block_count_differs_from_step_count(pytester) -> None: ... -def test_fails_when_step_keyword_structurally_mismatches(pytester) -> None: ... -def test_fails_when_step_text_mismatches(pytester) -> None: ... -def test_fails_when_placeholder_name_set_mismatches(pytester) -> None: ... -def test_passes_when_keyword_case_differs(pytester) -> None: ... -def test_passes_with_arbitrary_body_content_inside_step_block(pytester) -> None: ... -def test_does_not_inspect_body_for_literals_or_placeholders(pytester) -> None: ... +def test_check_passes_on_freshly_generated_project(pytester) -> None: ... +def test_check_fails_when_pyi_stale_after_feature_adds_scenario(pytester) -> None: ... def test_check_fails_on_orphan_py_in_tests_features(pytester) -> None: ... def test_check_ignores_handwritten_py_outside_tests_features(pytester) -> None: ... -def test_check_fails_when_parametrize_rows_edited(pytester) -> None: ... +def test_check_fails_when_py_drifts_from_pyi(pytester) -> None: ... diff --git a/tests/e2e/generate_test.py b/tests/e2e/generate_test.py index 6b9c066..a40638b 100644 --- a/tests/e2e/generate_test.py +++ b/tests/e2e/generate_test.py @@ -129,7 +129,7 @@ def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> N assert rule_background_text not in default_py -def test_tags_do_not_surface_in_emitted_pyi_or_step_blocks(pytester) -> None: +def test_feature_tags_surface_as_pytestmark_in_py_not_pyi(pytester) -> None: feature_text = ( "@unique_tag_marker\n" "Feature: Tagged\n" @@ -140,13 +140,23 @@ def test_tags_do_not_surface_in_emitted_pyi_or_step_blocks(pytester) -> None: run_beehave_generate(pytester) pyi = read_emitted_pyi(pytester, "tagged_default") py_text = read_emitted_py(pytester, "tagged_default") + assert "pytestmark = [pytest.mark.unique_tag_marker]" in py_text assert "unique_tag_marker" not in pyi - assert "unique_tag_marker" not in py_text -def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: +def test_scenario_tags_surface_as_decorator_marks(pytester) -> None: feature_text = ( - "Feature: Docstring\n" + "Feature: Scenario Tags\n@fast\nScenario: tagged scenario\nGiven anything\n" + ) + write_feature_text(pytester, "sctags.feature", feature_text) + run_beehave_generate(pytester) + py_text = read_emitted_py(pytester, "sctags_default") + assert "@pytest.mark.fast" in py_text + + +def test_step_docstring_surfaces_as_body_local_var(pytester) -> None: + feature_text = ( + "Feature: Docstring Surfacing\n" "Scenario: scenario with docstring\n" "Given anything\n" '"""\n' @@ -156,12 +166,14 @@ def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: write_feature_text(pytester, "docstring.feature", feature_text) run_beehave_generate(pytester) pyi = read_emitted_pyi(pytester, "docstring_default") + py_text = read_emitted_py(pytester, "docstring_default") + assert "docstring = 'unique docstring marker text'" in py_text assert "unique docstring marker text" not in pyi -def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: +def test_step_data_table_surfaces_as_body_local_var(pytester) -> None: feature_text = ( - "Feature: DataTable\n" + "Feature: DataTable Surfacing\n" "Scenario: scenario with data table\n" "Given anything\n" " | unique_col | value |\n" @@ -170,6 +182,9 @@ def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: write_feature_text(pytester, "datatable.feature", feature_text) run_beehave_generate(pytester) pyi = read_emitted_pyi(pytester, "datatable_default") + py_text = read_emitted_py(pytester, "datatable_default") + assert "'unique_col': 'marker'" in py_text + assert "data_table = " in py_text assert "unique_col" not in pyi diff --git a/tests/e2e/generate_test.pyi b/tests/e2e/generate_test.pyi index e52beb1..56bc8a0 100644 --- a/tests/e2e/generate_test.pyi +++ b/tests/e2e/generate_test.pyi @@ -44,9 +44,10 @@ def test_feature_background_steps_appear_in_every_emitted_scenario( pytester, ) -> None: ... def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> None: ... -def test_tags_do_not_surface_in_emitted_pyi_or_step_blocks(pytester) -> None: ... -def test_step_docstring_does_not_surface_in_emitted_pyi(pytester) -> None: ... -def test_step_data_table_does_not_surface_in_emitted_pyi(pytester) -> None: ... +def test_feature_tags_surface_as_pytestmark_in_py_not_pyi(pytester) -> None: ... +def test_scenario_tags_surface_as_decorator_marks(pytester) -> None: ... +def test_step_docstring_surfaces_as_body_local_var(pytester) -> None: ... +def test_step_data_table_surfaces_as_body_local_var(pytester) -> None: ... def test_generate_wipes_stale_pyi_in_tests_features(pytester) -> None: ... def test_generate_creates_tests_features_dir_if_absent(pytester) -> None: ... def test_outline_scenario_emits_parametrize_in_py(pytester) -> None: ... diff --git a/tests/integration/parametrize_test.py b/tests/integration/parametrize_test.py index 80c32e4..24d24e2 100644 --- a/tests/integration/parametrize_test.py +++ b/tests/integration/parametrize_test.py @@ -23,6 +23,45 @@ Then anything """ +PLAIN_FEATURE_WITH_ANGLE = """\ +Feature: Parametrize Emission +Scenario: plain angles +Given a step with in text +Then anything +""" + +MULTI_TABLE_FEATURE = """\ +Feature: Parametrize Emission +Scenario Outline: honey from nectar +Given the hive has grams +Then the hive produces grams + +Examples: + | nectar | honey | + | 100 | 80 | + +Examples: + | nectar | honey | + | 200 | 150 | +""" + +DIFFERENT_TAG_TABLES_FEATURE = """\ +Feature: Parametrize Emission +Scenario Outline: honey from nectar +Given the hive has grams +Then the hive produces grams + +@slow +Examples: + | nectar | honey | + | 1000 | 800 | + +@fast +Examples: + | nectar | honey | + | 10 | 8 | +""" + def _emit(root: Path, feature_text: str) -> tuple[str, str]: from beehave.generate import generate @@ -48,12 +87,6 @@ def _emitted_pyi(feature_text: str) -> str: return pyi -def _check_result(feature_text: str, test_py_text: str) -> bool: - from beehave.check import check - - return check(feature_text, test_py_text) - - def test_examples_scenario_emits_parametrize_decorator() -> None: py = _emitted_py(OUTLINE_FEATURE) assert "@pytest.mark.parametrize(" in py @@ -76,7 +109,25 @@ def test_parametrize_rows_match_examples_rows() -> None: def test_no_examples_scenario_emits_no_parametrize() -> None: py = _emitted_py(PLAIN_FEATURE) assert "parametrize" not in py - assert "import pytest" not in py + + +def test_plain_scenario_treats_angle_as_literal_text() -> None: + py = _emitted_py(PLAIN_FEATURE_WITH_ANGLE) + assert "" in py + pyi = _emitted_pyi(PLAIN_FEATURE_WITH_ANGLE) + assert "def test_plain_angles() -> None:" in pyi + + +def test_multiple_examples_tables_are_merged() -> None: + py = _emitted_py(MULTI_TABLE_FEATURE) + assert "('100', '80')," in py + assert "('200', '150')," in py + + +def test_different_tagged_tables_emit_pytest_param() -> None: + py = _emitted_py(DIFFERENT_TAG_TABLES_FEATURE) + assert "pytest.param('1000', '800', marks=pytest.mark.slow)" in py + assert "pytest.param('10', '8', marks=pytest.mark.fast)" in py def test_pyi_signature_carries_str_params_for_outline() -> None: @@ -87,35 +138,8 @@ def test_pyi_signature_carries_str_params_for_outline() -> None: ) -def test_check_passes_when_parametrize_matches_examples() -> None: - py = _emitted_py(OUTLINE_FEATURE) - assert _check_result(OUTLINE_FEATURE, py) - - -def test_check_fails_when_examples_present_but_no_parametrize() -> None: - body_without_parametrize = ( - "from beehave import step\n" - "\n" - "def test_honey_from_nectar(nectar: str, hours: str, honey: str) -> None:\n" - ' with step("Given", "the hive has grams", nectar=nectar):\n' - " pass\n" - ' with step("When", "the bees fan for hours", hours=hours):\n' - " pass\n" - ' with step("Then", "the hive produces grams", honey=honey):\n' - " pass\n" - ) - assert not _check_result(OUTLINE_FEATURE, body_without_parametrize) - - -def test_check_fails_when_parametrize_rows_differ() -> None: - py = _emitted_py(OUTLINE_FEATURE) - edited = py.replace("('100', '8', '80'),", "('999', '8', '80'),") - assert edited != py - assert not _check_result(OUTLINE_FEATURE, edited) - +def test_check_passes_on_freshly_generated_pyi() -> None: + from beehave.check import check -def test_check_fails_when_parametrize_arg_names_differ() -> None: - py = _emitted_py(OUTLINE_FEATURE) - edited = py.replace("'nectar'", "'renamed'", 1) - assert edited != py - assert not _check_result(OUTLINE_FEATURE, edited) + pyi = _emitted_pyi(OUTLINE_FEATURE) + assert check(OUTLINE_FEATURE, pyi) diff --git a/tests/integration/parametrize_test.pyi b/tests/integration/parametrize_test.pyi index 2c48fa0..ce60606 100644 --- a/tests/integration/parametrize_test.pyi +++ b/tests/integration/parametrize_test.pyi @@ -4,17 +4,19 @@ from pathlib import Path OUTLINE_FEATURE: str PLAIN_FEATURE: str +PLAIN_FEATURE_WITH_ANGLE: str +MULTI_TABLE_FEATURE: str +DIFFERENT_TAG_TABLES_FEATURE: str def _emit(root: Path, feature_text: str) -> tuple[str, str]: ... def _emitted_py(feature_text: str) -> str: ... def _emitted_pyi(feature_text: str) -> str: ... -def _check_result(feature_text: str, test_py_text: str) -> bool: ... def test_examples_scenario_emits_parametrize_decorator() -> None: ... def test_parametrize_arg_names_match_examples_headers() -> None: ... def test_parametrize_rows_match_examples_rows() -> None: ... def test_no_examples_scenario_emits_no_parametrize() -> None: ... +def test_plain_scenario_treats_angle_as_literal_text() -> None: ... +def test_multiple_examples_tables_are_merged() -> None: ... +def test_different_tagged_tables_emit_pytest_param() -> None: ... def test_pyi_signature_carries_str_params_for_outline() -> None: ... -def test_check_passes_when_parametrize_matches_examples() -> None: ... -def test_check_fails_when_examples_present_but_no_parametrize() -> None: ... -def test_check_fails_when_parametrize_rows_differ() -> None: ... -def test_check_fails_when_parametrize_arg_names_differ() -> None: ... +def test_check_passes_on_freshly_generated_pyi() -> None: ... diff --git a/tests/integration/roundtrip_test.py b/tests/integration/roundtrip_test.py index 1b378ad..88c463e 100644 --- a/tests/integration/roundtrip_test.py +++ b/tests/integration/roundtrip_test.py @@ -12,7 +12,7 @@ """ -def emit_test_py_for(feature_text: str) -> str: +def emit_test_pyi_for(feature_text: str) -> str: from beehave.generate import generate with tempfile.TemporaryDirectory() as tmp: @@ -21,47 +21,26 @@ def emit_test_py_for(feature_text: str) -> str: features.mkdir(parents=True) (features / "input.feature").write_text(feature_text) generate(root) - return (root / "tests" / "features" / "input_default_test.py").read_text() + return (root / "tests" / "features" / "input_default_test.pyi").read_text() -def check_passes_for(feature_text: str, test_py_text: str) -> bool: +def check_passes_for(feature_text: str, stub_text: str) -> bool: from beehave.check import check - return check(feature_text, test_py_text) + return check(feature_text, stub_text) -def test_check_passes_on_freshly_generated_py() -> None: - py_text = emit_test_py_for(ROUND_TRIP_FEATURE) - assert check_passes_for(ROUND_TRIP_FEATURE, py_text) +def test_check_passes_on_freshly_generated_pyi() -> None: + pyi_text = emit_test_pyi_for(ROUND_TRIP_FEATURE) + assert check_passes_for(ROUND_TRIP_FEATURE, pyi_text) -def test_check_fails_after_consumer_edits_step_text() -> None: - py_text = emit_test_py_for(ROUND_TRIP_FEATURE) - edited = py_text.replace("first step", "edited step text") - assert not check_passes_for(ROUND_TRIP_FEATURE, edited) - +def test_check_fails_when_pyi_missing_signature() -> None: + assert not check_passes_for(ROUND_TRIP_FEATURE, "") -def test_check_fails_after_consumer_removes_step_block() -> None: - shorter_body = ( - "from beehave import step\n" - "\n" - "def test_round_trip():\n" - ' with step("Given", "first step"):\n' - " pass\n" - ) - assert not check_passes_for(ROUND_TRIP_FEATURE, shorter_body) - -def test_check_passes_after_consumer_adds_body_content() -> None: - body_with_extra = ( - "from beehave import step\n" - "\n" - "def test_round_trip():\n" - ' with step("Given", "first step"):\n' - " x = 1 + 1\n" - ' with step("When", "second step"):\n' - " pass\n" - ' with step("Then", "third step"):\n' - " pass\n" - ) - assert check_passes_for(ROUND_TRIP_FEATURE, body_with_extra) +def test_check_fails_when_pyi_signature_renamed() -> None: + pyi_text = emit_test_pyi_for(ROUND_TRIP_FEATURE) + edited = pyi_text.replace("test_round_trip", "test_renamed") + assert edited != pyi_text + assert not check_passes_for(ROUND_TRIP_FEATURE, edited) diff --git a/tests/integration/roundtrip_test.pyi b/tests/integration/roundtrip_test.pyi index eb5e6eb..94a4132 100644 --- a/tests/integration/roundtrip_test.pyi +++ b/tests/integration/roundtrip_test.pyi @@ -1,23 +1,15 @@ -# Integration contract for the generate -> check round-trip. +# Integration contract for the generate -> check round-trip (v2.3.0). # # `generate` emits a `.pyi` (always) and a `.py` skeleton (when absent); -# `check` walks the emitted `.py` body's `with step(...)` blocks against the -# parsed `.feature` and enforces structural binding. The round-trip is the -# load-bearing claim that generator and checker agree on the contract - -# a freshly generated body must pass `check`, and only consumer edits that -# change the structural binding fields (keyword/text/placeholder-name-set) -# may fail it. -# -# These tests drive the generate+check module path in-process (no subprocess); -# the SUT imports live in each body (deferred), so the `.pyi` does not -# import beehave. +# `check` is signature-only — it regenerates expected `.pyi` signatures from +# the feature in-memory and confirms they appear in the on-disk stub text. +# No AST walk of the `.py` body; step/parametrize enforcement is the runtime +# `step` CM's job (Mode B). -# A minimal feature used as the round-trip input. ROUND_TRIP_FEATURE: str -def emit_test_py_for(feature_text: str) -> str: ... -def check_passes_for(feature_text: str, test_py_text: str) -> bool: ... -def test_check_passes_on_freshly_generated_py() -> None: ... -def test_check_fails_after_consumer_edits_step_text() -> None: ... -def test_check_fails_after_consumer_removes_step_block() -> None: ... -def test_check_passes_after_consumer_adds_body_content() -> None: ... +def emit_test_pyi_for(feature_text: str) -> str: ... +def check_passes_for(feature_text: str, stub_text: str) -> bool: ... +def test_check_passes_on_freshly_generated_pyi() -> None: ... +def test_check_fails_when_pyi_missing_signature() -> None: ... +def test_check_fails_when_pyi_signature_renamed() -> None: ... diff --git a/tests/integration/step_cm_test.py b/tests/integration/step_cm_test.py index 950ff31..4ca6ed9 100644 --- a/tests/integration/step_cm_test.py +++ b/tests/integration/step_cm_test.py @@ -2,10 +2,62 @@ import pytest -NOTE_FORMAT = "{keyword} {text}" +STEP_RUNTIME_FEATURE = """\ +Feature: Step Runtime +Scenario: block executes + Given the hive is active -def test_block_body_executes_when_entered() -> None: +Scenario: attribution note added + Then the hive has honey + +Scenario: wrong keyword fails + Given the hive is active + +Scenario: wrong text fails + Given the hive is active + +Scenario: wrong placeholders fail + Given the hive has grams + +Scenario: too many steps fail + Given the only step + +Scenario Outline: parametrize verifies ok + Given nectar of + When hours of + Then honey of + + Examples: + | amount | duration | honey | + | 100 | 8 | 80 | + | 200 | 12 | 150 | + +Scenario Outline: parametrize mismatch fails + Given nectar of + + Examples: + | amount | + | 100 | +""" + + +@pytest.fixture +def step_project(tmp_path: str, monkeypatch: pytest.MonkeyPatch) -> None: + from pathlib import Path + + from beehave import _index + + features = Path(tmp_path) / "docs" / "features" + features.mkdir(parents=True) + (features / "step_runtime.feature").write_text(STEP_RUNTIME_FEATURE) + monkeypatch.chdir(Path(tmp_path)) + _index._reset() + yield + _index._reset() + + +def test_block_executes(step_project: None) -> None: from beehave import step side_effect: list[str] = [] @@ -14,64 +66,91 @@ def test_block_body_executes_when_entered() -> None: assert side_effect == ["entered"] -def test_assertion_inside_then_step_propagates_failure() -> None: +def test_attribution_note_added(step_project: None) -> None: from beehave import step - with pytest.raises(AssertionError), step("Then", "the hive has honey"): + keyword = "Then" + text = "the hive has honey" + with pytest.raises(AssertionError) as exc_info, step(keyword, text): raise AssertionError + assert exc_info.value.__notes__ == [f"{keyword} {text}"] -def test_assertion_inside_then_step_passes_when_truthy() -> None: - from beehave import step +def test_wrong_keyword_fails(step_project: None) -> None: + from beehave import StepError, step - with step("Then", "the hive has honey"): - assert True + with pytest.raises(StepError), step("When", "the hive is active"): + pass -def test_exception_attributed_to_step_via_add_note() -> None: - from beehave import step +def test_wrong_text_fails(step_project: None) -> None: + from beehave import StepError, step - keyword = "Then" - text = "the hive has honey" - with pytest.raises(AssertionError) as exc_info, step(keyword, text): - raise AssertionError - assert exc_info.value.__notes__ == [ - NOTE_FORMAT.format(keyword=keyword, text=text), - ] + with pytest.raises(StepError), step("Given", "different text"): + pass -def test_clean_exit_does_not_add_attribution_note() -> None: - from beehave import step +def test_wrong_placeholders_fail(step_project: None) -> None: + from beehave import StepError, step - with step("Given", "the hive is active"): + with ( + pytest.raises(StepError), + step("Given", "the hive has grams", wrong=1), + ): pass -def test_keyword_and_text_are_positional_only() -> None: - from beehave import step +def test_too_many_steps_fail(step_project: None) -> None: + from beehave import StepError, step - with pytest.raises(TypeError): - step(keyword="Then", text="the hive has honey") # type: ignore[call-arg] + with step("Given", "the only step"): + pass + with pytest.raises(StepError), step("When", "second step"): + pass -def test_placeholders_accepted_as_keyword_arguments() -> None: - from beehave import step +def test_unknown_function_raises_no_active_scenario( + step_project: None, +) -> None: + from beehave import NoActiveScenarioError, step - with step("Given", "the hive has grams", nectar=100): + with pytest.raises(NoActiveScenarioError), step("Given", "anything"): pass -def test_all_gherkin_step_keywords_accepted() -> None: +@pytest.mark.parametrize( + ("amount", "duration", "honey"), + [("100", "8", "80"), ("200", "12", "150")], +) +def test_parametrize_verifies_ok( + step_project: None, + amount: str, + duration: str, + honey: str, +) -> None: from beehave import step - keywords = ["Given", "When", "Then", "And", "But", "*"] - for keyword in keywords: - with step(keyword, "any step text"): - pass + with step("Given", "nectar of ", amount=amount): + pass + with step("When", "hours of ", duration=duration): + pass + with step("Then", "honey of ", honey=honey): + pass -def test_localized_keyword_accepted() -> None: - from beehave import step +@pytest.mark.parametrize(("amount",), [("999",)]) +def test_parametrize_mismatch_fails( + step_project: None, + amount: str, +) -> None: + from beehave import StepError, step - with step("Допустим", "улей активен"): + with pytest.raises(StepError), step("Given", "nectar of ", amount=amount): pass + + +def test_keyword_and_text_are_positional_only() -> None: + from beehave import step + + with pytest.raises(TypeError): + step(keyword="Then", text="the hive has honey") # type: ignore[call-arg] diff --git a/tests/integration/step_cm_test.pyi b/tests/integration/step_cm_test.pyi index a1ae4c0..6566651 100644 --- a/tests/integration/step_cm_test.pyi +++ b/tests/integration/step_cm_test.pyi @@ -1,23 +1,36 @@ -# Integration contract for the `step` context manager - the v2 runtime core. +# Integration contract for the v2.3 `step` context manager (Mode B runtime). # -# `from beehave import step` - `step(keyword: str, text: str, /, **placeholders)` -# is the executable `with` block that replaces v1's step-definition registry. -# The block RUNS real test code; the `Then` block asserts the outcome; the -# context manager attributes any exception to its step via -# `e.add_note(f"{keyword} {text}")` on `__exit__`, then propagates the exception. +# `step()` resolves the calling function (`sys._getframe(1).f_code.co_name`) +# against the lazy feature index (`beehave._index`), tracks step position via a +# frame-keyed counter, and verifies each block against +# `scenario.steps[position]` on (keyword-case-insensitively, text, +# placeholder-name-set). On the first step it also verifies any +# `@pytest.mark.parametrize(...)` against the scenario's Examples. On +# exception the CM appends `f"{keyword} {text}"` via PEP 678 `add_note`. # -# These tests drive the CM in-process; the SUT import lives in each body -# (deferred), so the `.pyi` does not import beehave. +# The `step_project` fixture seeds a temp project with STEP_RUNTIME_FEATURE, +# chdirs into it, and resets the index cache so each test rebuilds from the +# temp cwd. Test function names match scenario slugs so `step()` finds them. -# The exact `add_note` format the CM appends on `__exit__` exception. -NOTE_FORMAT: str +import pytest -def test_block_body_executes_when_entered() -> None: ... -def test_assertion_inside_then_step_propagates_failure() -> None: ... -def test_assertion_inside_then_step_passes_when_truthy() -> None: ... -def test_exception_attributed_to_step_via_add_note() -> None: ... -def test_clean_exit_does_not_add_attribution_note() -> None: ... +STEP_RUNTIME_FEATURE: str + +@pytest.fixture +def step_project(tmp_path: str, monkeypatch: pytest.MonkeyPatch) -> None: ... +def test_block_executes(step_project: None) -> None: ... +def test_attribution_note_added(step_project: None) -> None: ... +def test_wrong_keyword_fails(step_project: None) -> None: ... +def test_wrong_text_fails(step_project: None) -> None: ... +def test_wrong_placeholders_fail(step_project: None) -> None: ... +def test_too_many_steps_fail(step_project: None) -> None: ... +def test_unknown_function_raises_no_active_scenario(step_project: None) -> None: ... +@pytest.mark.parametrize( + ("amount", "duration", "honey"), [("100", "8", "80"), ("200", "12", "150")] +) +def test_parametrize_verifies_ok( + step_project: None, amount: str, duration: str, honey: str +) -> None: ... +@pytest.mark.parametrize(("amount",), [("999",)]) +def test_parametrize_mismatch_fails(step_project: None, amount: str) -> None: ... def test_keyword_and_text_are_positional_only() -> None: ... -def test_placeholders_accepted_as_keyword_arguments() -> None: ... -def test_all_gherkin_step_keywords_accepted() -> None: ... -def test_localized_keyword_accepted() -> None: ... diff --git a/uv.lock b/uv.lock index ac0d82a..f0945c8 100644 --- a/uv.lock +++ b/uv.lock @@ -91,10 +91,11 @@ wheels = [ [[package]] name = "beehave" -version = "2.2.0" +version = "2.3.1" source = { editable = "." } dependencies = [ { name = "gherkin-official" }, + { name = "mypy" }, ] [package.dev-dependencies] @@ -110,7 +111,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "gherkin-official", specifier = ">=39.0" }] +requires-dist = [ + { name = "gherkin-official", specifier = ">=39.0" }, + { name = "mypy", specifier = ">=2.3.0" }, +] [package.metadata.requires-dev] dev = [ From 092b2402355a0eff2610852450d1c2d9aea7111b Mon Sep 17 00:00:00 2001 From: nullhack Date: Mon, 20 Jul 2026 21:58:30 -0400 Subject: [PATCH 11/13] feat(beehave-v7)!: drop consumer-side .pyi, scope check by feature path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3.0.0 superset model: .feature is the sole source of truth. The .py non-private function signatures must match feature-derived signatures 1-1 (names + types + order); private functions are exempt superset. Orphan detection collapses INTO check (a .py non-private fn with no matching scenario IS the orphan signal). generate: emit only *_test.py skeleton (drop .pyi emission + wipe). check: AST-based 1-1 superset (was signature-only regenerate-and-diff against .pyi text). cli: drop _run_stubtest (no consumer-side stubtest); add orphan-module detection by filename stem on full sweep; accept feature path args for scoped check (`beehave check docs/features/x.feature`) — skips orphan detection so consumers can wire incremental scope via git diff without forcing a full feature sweep. status: count *_test.py (was *_test.pyi); print "skeleton file(s)". Drop mypy runtime dep (back to dev-only). BREAKING CHANGE: v3.0.0 removes consumer-side .pyi emission and the stubtest subprocess in check. Existing tests/features/*_test.pyi files become stale; consumers delete manually. Projects relying on the .pyi-driven gate must migrate. --- beehave/__init__.py | 2 +- beehave/check.py | 36 ++++++-- beehave/check.pyi | 17 ++-- beehave/cli.py | 119 ++++++++++++------------- beehave/cli.pyi | 21 +++-- beehave/generate.py | 12 --- beehave/generate.pyi | 17 ++-- beehave/status.py | 4 +- beehave/status.pyi | 2 +- pyproject.toml | 3 +- tests/e2e/check_test.py | 81 ++++++++++++++++- tests/e2e/check_test.pyi | 31 ++++--- tests/e2e/generate_test.py | 51 ++++------- tests/e2e/generate_test.pyi | 25 +++--- tests/e2e/status_test.py | 12 +-- tests/e2e/status_test.pyi | 6 +- tests/integration/idempotency_test.py | 19 +--- tests/integration/idempotency_test.pyi | 13 +-- tests/integration/parametrize_test.py | 43 +++------ tests/integration/parametrize_test.pyi | 7 +- tests/integration/roundtrip_test.py | 24 ++--- tests/integration/roundtrip_test.pyi | 22 ++--- uv.lock | 8 +- 23 files changed, 313 insertions(+), 262 deletions(-) diff --git a/beehave/__init__.py b/beehave/__init__.py index e0fc6d0..fda2e92 100644 --- a/beehave/__init__.py +++ b/beehave/__init__.py @@ -2,4 +2,4 @@ from beehave.step import StepError as StepError from beehave.step import step as step -__version__ = "2.3.1" +__version__ = "3.0.0" diff --git a/beehave/check.py b/beehave/check.py index d7a5156..3e9ffd9 100644 --- a/beehave/check.py +++ b/beehave/check.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ast from typing import TYPE_CHECKING from beehave.gherkin import Rule, parse_feature @@ -22,14 +23,39 @@ def _placeholder_names(steps: list[Step]) -> list[str]: def _signature_line(scenario: Scenario) -> str: names = _placeholder_names(scenario.steps) params = ", ".join(f"{name}: str" for name in names) - return f"def {scenario.function_name}({params}) -> None: ..." + return f"def {scenario.function_name}({params}) -> None" -def check(feature_text: str, stub_text: str) -> bool: +def _expected_signatures(feature_text: str) -> set[str]: feature = parse_feature(feature_text) + sigs: set[str] = set() for child in feature.children: scenarios = child.children if isinstance(child, Rule) else [child] for scenario in scenarios: - if _signature_line(scenario) not in stub_text: - return False - return True + sigs.add(_signature_line(scenario)) + return sigs + + +def _annotation_str(node: ast.expr | None) -> str: + if node is None: + return "" + return ast.unparse(node) + + +def _actual_signatures(py_text: str) -> set[str]: + tree = ast.parse(py_text) + sigs: set[str] = set() + for node in tree.body: + if not isinstance(node, ast.FunctionDef): + continue + if node.name.startswith("_"): + continue + params = ", ".join( + f"{arg.arg}: {_annotation_str(arg.annotation)}" for arg in node.args.args + ) + sigs.add(f"def {node.name}({params}) -> None") + return sigs + + +def check(feature_text: str, py_text: str) -> bool: + return _expected_signatures(feature_text) == _actual_signatures(py_text) diff --git a/beehave/check.pyi b/beehave/check.pyi index e35d4e8..1a1ae84 100644 --- a/beehave/check.pyi +++ b/beehave/check.pyi @@ -1,8 +1,9 @@ -# v2.3 signature check: regenerates expected `def test_(...) -> None: ...` -# lines from the feature source and verifies each appears in `stub_text` -# (the concatenated content of `tests/features/*_test.pyi`). Returns True iff -# every scenario signature is present; False on any missing signature (stale -# `.pyi`). Does NOT inspect the `.py` body — step/parametrize verification is -# the runtime `step()` CM's job (Mode B). The CLI additionally runs -# `mypy.stubtest` on the consumer test modules for `.py` ↔ `.pyi` drift. -def check(feature_text: str, stub_text: str) -> bool: ... +# v3 superset check: derives expected `def test_(params) -> None` lines +# from the feature source, parses `py_text` AST for non-private top-level +# function signatures, and returns True iff the two sets are equal. Private +# functions (leading underscore) are exempt — part of the consumer's superset +# (helpers, fixtures). Non-private function signatures must match 1-1 in +# name, parameter names, parameter types, and order. The CLI additionally +# runs an orphan-module check (a `*_test.py` in `tests/features/` whose stem +# does not correspond to any feature's expected module is flagged). +def check(feature_text: str, py_text: str) -> bool: ... diff --git a/beehave/cli.py b/beehave/cli.py index 1cf643d..26f6fa0 100644 --- a/beehave/cli.py +++ b/beehave/cli.py @@ -1,13 +1,12 @@ from __future__ import annotations -import os -import subprocess import sys from collections.abc import Sequence from pathlib import Path from beehave.check import check -from beehave.generate import generate +from beehave.generate import _slug_from, generate +from beehave.gherkin import Rule, parse_feature from beehave.status import status @@ -22,72 +21,70 @@ def main(argv: Sequence[str] | None = None) -> int: if cmd == "status": return status(Path.cwd()) if cmd == "check": - return _check_all(Path.cwd()) + return _check_all(Path.cwd(), args[1:]) return 2 -def _run_stubtest(root: Path) -> bool: - tests_features_dir = root / "tests" / "features" - if not tests_features_dir.is_dir(): - return True - modules = [p.stem for p in sorted(tests_features_dir.glob("*_test.py"))] - if not modules: - return True - env = dict(os.environ) - existing = env.get("PYTHONPATH", "") - env["PYTHONPATH"] = str(tests_features_dir) + ( - os.pathsep + existing if existing else "" - ) - existing_mypy = env.get("MYPYPATH", "") - env["MYPYPATH"] = str(tests_features_dir) + ( - os.pathsep + existing_mypy if existing_mypy else "" - ) - result = subprocess.run( - [ - sys.executable, - "-m", - "mypy.stubtest", - "--ignore-missing-stub", - *modules, - ], - env=env, - cwd=root, - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - sys.stdout.write(result.stdout) - sys.stderr.write(result.stderr) - return False - return True +def _feature_module_stems(feature_path: Path) -> list[str]: + feature = parse_feature(feature_path.read_text()) + feature_slug = _slug_from(feature_path.stem) + stems: list[str] = [] + has_default = any(not isinstance(c, Rule) for c in feature.children) + if has_default: + stems.append(f"{feature_slug}_default") + for child in feature.children: + if isinstance(child, Rule): + stems.append(f"{feature_slug}_{_slug_from(child.name)}") + return stems + + +def _resolve_feature_arg(root: Path, arg: str) -> Path | None: + p = Path(arg) + if not p.is_absolute(): + p = root / p + if p.suffix != ".feature" or not p.exists(): + return None + return p -def _check_all(root: Path) -> int: +def _check_all(root: Path, feature_args: list[str] | None = None) -> int: features_dir = root / "docs" / "features" if not features_dir.is_dir(): return 1 tests_features_dir = root / "tests" / "features" - if tests_features_dir.is_dir(): - orphans = [ - p - for p in sorted(tests_features_dir.glob("*_test.py")) - if not p.with_suffix(".pyi").exists() - ] - if orphans: - for orphan in orphans: - print(f"orphan: {orphan.name}", file=sys.stderr) - return 1 - if not _run_stubtest(root): - return 1 - stub_text = _read_stub_text(tests_features_dir) - for feature_path in sorted(features_dir.glob("*.feature")): - if not check(feature_path.read_text(), stub_text): - return 1 - return 0 + if feature_args: + feature_paths: list[Path] = [] + for arg in feature_args: + resolved = _resolve_feature_arg(root, arg) + if resolved is None: + print(f"not a feature file: {arg}", file=sys.stderr) + return 1 + feature_paths.append(resolved) + else: + feature_paths = sorted(features_dir.glob("*.feature")) + if tests_features_dir.is_dir(): + expected_stems: set[str] = set() + for feature_path in feature_paths: + expected_stems.update(_feature_module_stems(feature_path)) + actual_stems = { + p.name.removesuffix("_test.py") + for p in tests_features_dir.glob("*_test.py") + } + orphan_stems = sorted(actual_stems - expected_stems) + if orphan_stems: + for stem in orphan_stems: + print(f"orphan: {stem}_test.py", file=sys.stderr) + return 1 -def _read_stub_text(tests_dir: Path) -> str: - if not tests_dir.is_dir(): - return "" - return "\n".join(path.read_text() for path in sorted(tests_dir.glob("*_test.pyi"))) + for feature_path in feature_paths: + stems = _feature_module_stems(feature_path) + py_text = "\n".join( + (tests_features_dir / f"{stem}_test.py").read_text() + for stem in stems + if tests_features_dir.is_dir() + and (tests_features_dir / f"{stem}_test.py").exists() + ) + if not check(feature_path.read_text(), py_text): + return 1 + return 0 diff --git a/beehave/cli.pyi b/beehave/cli.pyi index 65c7ea1..c9fa3f1 100644 --- a/beehave/cli.pyi +++ b/beehave/cli.pyi @@ -1,8 +1,19 @@ from collections.abc import Sequence -# CLI entry: `beehave generate|check|status`. Returns the process exit code -# (0 success; 2 missing features dir on `status`; non-zero on `check` -# structural-binding failure or when orphan `*_test.py` files without `.pyi` -# siblings are found in `/tests/features/`). `argv` defaults to -# `sys.argv[1:]` when None. +# CLI entry: `beehave generate|check [feature...]|status`. Returns the process +# exit code (0 success; 2 missing features dir on `status` or unknown command; +# non-zero on `check` superset failure). `argv` defaults to `sys.argv[1:]` +# when None. +# +# `beehave check` (no args): full sweep — reads every `docs/features/*.feature`, +# verifies each feature's expected scenario signatures exactly match the +# non-private top-level function signatures in the corresponding +# `tests/features/*_test.py` modules, and runs an orphan-module pass +# (`*_test.py` files whose stem doesn't correspond to any feature). +# +# `beehave check ...` (scoped): only the named `.feature` paths are +# parsed and checked. Skips orphan detection (which would require parsing +# every feature anyway, defeating the scoped fast-path). Consumers wire +# incremental scope themselves: `beehave check $(git diff --name-only HEAD~1 +# HEAD -- docs/features/)`. def main(argv: Sequence[str] | None = None) -> int: ... diff --git a/beehave/generate.py b/beehave/generate.py index 3def7d8..ff9e8c9 100644 --- a/beehave/generate.py +++ b/beehave/generate.py @@ -52,14 +52,6 @@ def _parametrize_lines(scenario: Scenario) -> list[str]: return lines -def _render_pyi(scenarios: list[Scenario]) -> str: - lines: list[str] = [] - for scenario in scenarios: - params = _signature_params(scenario) - lines.append(f"def {scenario.function_name}({params}) -> None: ...") - return "\n".join(lines) + "\n" - - def _step_block(step: Step) -> str: kwargs = "".join(f", {p.name}={p.name}" for p in step.placeholders) return f" with step({step.keyword!r}, {step.text!r}{kwargs}):" @@ -116,9 +108,7 @@ def _emit_group( scenarios: list[Scenario], module_tags: list[str], ) -> None: - pyi_path = tests_dir / f"{stem}_test.pyi" py_path = tests_dir / f"{stem}_test.py" - pyi_path.write_text(_render_pyi(scenarios)) if not py_path.exists(): py_path.write_text(_render_py(scenarios, module_tags)) @@ -127,8 +117,6 @@ def generate(root: Path) -> None: features_dir = root / "docs" / "features" tests_dir = root / "tests" / "features" tests_dir.mkdir(parents=True, exist_ok=True) - for stale in tests_dir.glob("*_test.pyi"): - stale.unlink() for feature_path in sorted(features_dir.glob("*.feature")): feature = parse_feature(feature_path.read_text()) diff --git a/beehave/generate.pyi b/beehave/generate.pyi index 5b5cd77..d0736b1 100644 --- a/beehave/generate.pyi +++ b/beehave/generate.pyi @@ -1,12 +1,11 @@ from pathlib import Path -# Emits `_default_test.py{i,}` plus one -# `__test.py{i,}` per Rule into `/tests/features/`, -# reading `/docs/features/*.feature`. Wipes stale `*_test.pyi` in the emit -# dir before writing. Always emits `.pyi`; emits `.py` skeleton only when absent -# (idempotent — never clobbers consumer bodies). Background steps (Feature-level -# and Rule-level) are prepended to the relevant scenarios' `with step(...)` -# block lists in the emitted `.py`. Scenario Outline Examples are emitted as a -# `@pytest.mark.parametrize(...)` decorator over the test function (string rows, -# all params typed `str`); the `.pyi` carries only the flat typed signature. +# Emits `_default_test.py` plus one +# `__test.py` per Rule into `/tests/features/`, +# reading `/docs/features/*.feature`. Emits `.py` skeleton only when +# absent (idempotent — never clobbers consumer bodies). Background steps +# (Feature-level and Rule-level) are prepended to the relevant scenarios' +# `with step(...)` block lists in the emitted `.py`. Scenario Outline Examples +# are emitted as a `@pytest.mark.parametrize(...)` decorator over the test +# function (string rows, all params typed `str`). def generate(root: Path) -> None: ... diff --git a/beehave/status.py b/beehave/status.py index 8e5bcb2..95efa44 100644 --- a/beehave/status.py +++ b/beehave/status.py @@ -7,7 +7,7 @@ def status(root: Path) -> int: return 2 feature_count = len(list(features_dir.glob("*.feature"))) tests_dir = root / "tests" / "features" - stub_count = len(list(tests_dir.glob("*_test.pyi"))) if tests_dir.is_dir() else 0 + skeleton_count = len(list(tests_dir.glob("*_test.py"))) if tests_dir.is_dir() else 0 print(f"{feature_count} feature file(s)") - print(f"{stub_count} stub file(s)") + print(f"{skeleton_count} skeleton file(s)") return 0 diff --git a/beehave/status.pyi b/beehave/status.pyi index 84e7016..515ef3b 100644 --- a/beehave/status.pyi +++ b/beehave/status.pyi @@ -1,7 +1,7 @@ from pathlib import Path # Minimal `status` (journal Q3): prints `.feature` count under -# `/docs/features/` and `*_test.pyi` count under `/tests/features/` +# `/docs/features/` and `*_test.py` count under `/tests/features/` # to stdout; returns 0 when the features directory exists, 2 when it is # missing (filesystem error). v1's rich stage taxonomy / `--json` / tree # output / unmapped-directory reporting are all dropped. diff --git a/pyproject.toml b/pyproject.toml index a6b69d9..b83eabe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "beehave" -version = "2.3.1" +version = "3.0.0" description = "A thin layer on Hypothesis for Gherkin-style BDD testing with vocabulary enforcement" readme = "README.md" requires-python = ">=3.14" @@ -14,7 +14,6 @@ authors = [ ] dependencies = [ "gherkin-official>=39.0", - "mypy>=2.3.0", ] [project.scripts] diff --git a/tests/e2e/check_test.py b/tests/e2e/check_test.py index 0dfef70..50fb39a 100644 --- a/tests/e2e/check_test.py +++ b/tests/e2e/check_test.py @@ -31,7 +31,7 @@ def test_check_passes_on_freshly_generated_project(pytester) -> None: assert run_beehave_check(pytester) == 0 -def test_check_fails_when_pyi_stale_after_feature_adds_scenario(pytester) -> None: +def test_check_fails_when_py_missing_scenario_fn(pytester) -> None: initial = "Feature: Stale Check\nScenario: first scenario\nGiven a step\n" write_feature_text(pytester, "stale.feature", initial) pytester.run("beehave", "generate") @@ -40,7 +40,7 @@ def test_check_fails_when_pyi_stale_after_feature_adds_scenario(pytester) -> Non assert run_beehave_check(pytester) != 0 -def test_check_fails_on_orphan_py_in_tests_features(pytester) -> None: +def test_check_fails_on_orphan_module_in_tests_features(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) pytester.run("beehave", "generate") orphan_path = pytester.path / "tests" / "features" / "orphan_default_test.py" @@ -59,7 +59,7 @@ def test_check_ignores_handwritten_py_outside_tests_features(pytester) -> None: assert run_beehave_check(pytester) == 0 -def test_check_fails_when_py_drifts_from_pyi(pytester) -> None: +def test_check_fails_when_py_fn_signature_drifts(pytester) -> None: outline = ( "Feature: Drift Check\n" "Scenario Outline: drift scenario\n" @@ -80,3 +80,78 @@ def test_check_fails_when_py_drifts_from_pyi(pytester) -> None: assert edited != py_text py_path.write_text(edited) assert run_beehave_check(pytester) != 0 + + +def test_check_passes_with_private_helpers_in_py(pytester) -> None: + feature_text = "Feature: Private Helper\nScenario: main scenario\nGiven anything\n" + write_feature_text(pytester, "private.feature", feature_text) + pytester.run("beehave", "generate") + py_path = pytester.path / "tests" / "features" / "private_default_test.py" + py_text = py_path.read_text() + helper = "\n\ndef _helper():\n return 42\n" + py_path.write_text(py_text + helper) + assert run_beehave_check(pytester) == 0 + + +def test_check_fails_when_py_has_extra_non_private_fn(pytester) -> None: + feature_text = "Feature: Extra Fn\nScenario: main scenario\nGiven anything\n" + write_feature_text(pytester, "extra.feature", feature_text) + pytester.run("beehave", "generate") + py_path = pytester.path / "tests" / "features" / "extra_default_test.py" + py_text = py_path.read_text() + extra_fn = "\n\ndef test_extra_orphan():\n pass\n" + py_path.write_text(py_text + extra_fn) + assert run_beehave_check(pytester) != 0 + + +def test_check_scoped_to_one_feature_path(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + write_feature_text( + pytester, + "other.feature", + "Feature: Other\nScenario: other scenario\nGiven anything\n", + ) + pytester.run("beehave", "generate") + assert run_beehave_check(pytester, "docs/features/hive_activity.feature") == 0 + + +def test_check_scoped_skips_orphan_module_detection(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + pytester.run("beehave", "generate") + orphan_path = pytester.path / "tests" / "features" / "orphan_default_test.py" + orphan_path.write_text("# orphan") + result = pytester.run("beehave", "check", "docs/features/hive_activity.feature") + assert result.ret == 0 + assert "orphan" not in "\n".join(result.errlines) + + +def test_check_scoped_fails_on_drift_in_targeted_feature(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + pytester.run("beehave", "generate") + py_path = pytester.path / "tests" / "features" / "hive_activity_default_test.py" + py_text = py_path.read_text() + edited = py_text.replace( + "def test_honey_production_from_nectar(nectar: str", + "def test_honey_production_from_nectar(renamed: str", + 1, + ) + assert edited != py_text + py_path.write_text(edited) + assert run_beehave_check(pytester, "docs/features/hive_activity.feature") != 0 + + +def test_check_scoped_fails_on_nonexistent_path(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + pytester.run("beehave", "generate") + result = pytester.run("beehave", "check", "docs/features/missing.feature") + assert result.ret != 0 + assert "not a feature file" in "\n".join(result.errlines) + + +def test_check_scoped_fails_on_non_feature_file(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + pytester.run("beehave", "generate") + readme = pytester.path / "README.md" + readme.write_text("# not a feature") + result = pytester.run("beehave", "check", "README.md") + assert result.ret != 0 diff --git a/tests/e2e/check_test.pyi b/tests/e2e/check_test.pyi index e8f8022..3d84aea 100644 --- a/tests/e2e/check_test.pyi +++ b/tests/e2e/check_test.pyi @@ -1,11 +1,15 @@ -# E2E contract for the `beehave check` CLI command (v2.3.0). +# E2E contract for the `beehave check` CLI command (v3.0.0). # -# `beehave check` is a two-layer static gate: -# 1. stubtest on tests/features/*_test.py (catches .py ↔ .pyi signature drift) -# 2. regenerate-and-diff (regenerates expected .pyi signatures from the -# feature, confirms they appear in the on-disk stub text) -# Plus an orphan pass: .py without .pyi sibling in tests/features/ → exit 1. -# Step-body / parametrize-row enforcement is the runtime `step` CM's job. +# `beehave check` is a single static gate: +# 1. Per-feature: derive expected `def test_(params) -> None` lines +# from the feature source, parse the matching `tests/features/*_test.py` +# modules' AST, return False if expected set != actual non-private +# function signature set. +# 2. Orphan module: a `*_test.py` in `tests/features/` whose stem does not +# correspond to any feature's expected module → exit 1. +# Private functions (leading `_`) are exempt — part of the consumer's +# superset (helpers, fixtures wrappers). Step-body / parametrize-row +# enforcement is the runtime `step` CM's job (Mode B). HIVE_ACTIVITY_FEATURE: str @@ -13,7 +17,14 @@ def copy_feature_into_pytester(pytester, basename: str) -> str: ... def write_feature_text(pytester, basename: str, text: str) -> str: ... def run_beehave_check(pytester, *args: str) -> int: ... def test_check_passes_on_freshly_generated_project(pytester) -> None: ... -def test_check_fails_when_pyi_stale_after_feature_adds_scenario(pytester) -> None: ... -def test_check_fails_on_orphan_py_in_tests_features(pytester) -> None: ... +def test_check_fails_when_py_missing_scenario_fn(pytester) -> None: ... +def test_check_fails_on_orphan_module_in_tests_features(pytester) -> None: ... def test_check_ignores_handwritten_py_outside_tests_features(pytester) -> None: ... -def test_check_fails_when_py_drifts_from_pyi(pytester) -> None: ... +def test_check_fails_when_py_fn_signature_drifts(pytester) -> None: ... +def test_check_passes_with_private_helpers_in_py(pytester) -> None: ... +def test_check_fails_when_py_has_extra_non_private_fn(pytester) -> None: ... +def test_check_scoped_to_one_feature_path(pytester) -> None: ... +def test_check_scoped_skips_orphan_module_detection(pytester) -> None: ... +def test_check_scoped_fails_on_drift_in_targeted_feature(pytester) -> None: ... +def test_check_scoped_fails_on_nonexistent_path(pytester) -> None: ... +def test_check_scoped_fails_on_non_feature_file(pytester) -> None: ... diff --git a/tests/e2e/generate_test.py b/tests/e2e/generate_test.py index a40638b..a0b6efe 100644 --- a/tests/e2e/generate_test.py +++ b/tests/e2e/generate_test.py @@ -32,13 +32,6 @@ def run_beehave_generate(pytester, *args: str) -> int: return pytester.run("beehave", "generate", *args).ret -def read_emitted_pyi(pytester, stem: str) -> str: - path = pytester.path / EMISSION_DIR / f"{stem}_test.pyi" - if not path.exists(): - return "" - return path.read_text() - - def read_emitted_py(pytester, stem: str) -> str: path = pytester.path / EMISSION_DIR / f"{stem}_test.py" if not path.exists(): @@ -51,14 +44,14 @@ def list_emitted_stems(pytester) -> list[str]: if not emission.exists(): return [] stems: list[str] = [] - for path in emission.glob("*_test.pyi"): + for path in emission.glob("*_test.py"): name = path.name - if name.endswith("_test.pyi"): - stems.append(name[: -len("_test.pyi")]) + if name.endswith("_test.py"): + stems.append(name[: -len("_test.py")]) return sorted(stems) -def test_emits_pyi_for_default_group_and_each_rule(pytester) -> None: +def test_emits_py_for_default_group_and_each_rule(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) run_beehave_generate(pytester) stems = list_emitted_stems(pytester) @@ -67,14 +60,14 @@ def test_emits_pyi_for_default_group_and_each_rule(pytester) -> None: assert "hive_activity_hive_foraging" in stems -def test_always_emits_pyi_file_for_every_rule_and_default(pytester) -> None: +def test_always_emits_py_file_for_every_rule_and_default(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) exit_code = run_beehave_generate(pytester) assert exit_code == 0 stems = list_emitted_stems(pytester) assert len(stems) >= 3 for stem in stems: - assert read_emitted_pyi(pytester, stem) != "" + assert read_emitted_py(pytester, stem) != "" def test_emits_py_skeleton_only_when_py_absent(pytester) -> None: @@ -89,8 +82,8 @@ def test_emits_py_skeleton_only_when_py_absent(pytester) -> None: def test_scenario_title_emits_test_underscore_slug_function(pytester) -> None: copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) run_beehave_generate(pytester) - pyi = read_emitted_pyi(pytester, "hive_activity_hive_defense") - assert "def test_guard_bee_inspects_visitor" in pyi + py = read_emitted_py(pytester, "hive_activity_hive_defense") + assert "def test_guard_bee_inspects_visitor" in py def test_function_name_carries_no_uppercase_and_collapses_whitespace(pytester) -> None: @@ -101,8 +94,8 @@ def test_function_name_carries_no_uppercase_and_collapses_whitespace(pytester) - ) write_feature_text(pytester, "whitespace.feature", feature_text) run_beehave_generate(pytester) - pyi = read_emitted_pyi(pytester, "whitespace_default") - assert "def test_mixedcase_title_with_spaces" in pyi + py = read_emitted_py(pytester, "whitespace_default") + assert "def test_mixedcase_title_with_spaces" in py def test_feature_background_steps_appear_in_every_emitted_scenario(pytester) -> None: @@ -129,7 +122,7 @@ def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> N assert rule_background_text not in default_py -def test_feature_tags_surface_as_pytestmark_in_py_not_pyi(pytester) -> None: +def test_feature_tags_surface_as_pytestmark_in_py(pytester) -> None: feature_text = ( "@unique_tag_marker\n" "Feature: Tagged\n" @@ -138,10 +131,8 @@ def test_feature_tags_surface_as_pytestmark_in_py_not_pyi(pytester) -> None: ) write_feature_text(pytester, "tagged.feature", feature_text) run_beehave_generate(pytester) - pyi = read_emitted_pyi(pytester, "tagged_default") py_text = read_emitted_py(pytester, "tagged_default") assert "pytestmark = [pytest.mark.unique_tag_marker]" in py_text - assert "unique_tag_marker" not in pyi def test_scenario_tags_surface_as_decorator_marks(pytester) -> None: @@ -165,10 +156,8 @@ def test_step_docstring_surfaces_as_body_local_var(pytester) -> None: ) write_feature_text(pytester, "docstring.feature", feature_text) run_beehave_generate(pytester) - pyi = read_emitted_pyi(pytester, "docstring_default") py_text = read_emitted_py(pytester, "docstring_default") assert "docstring = 'unique docstring marker text'" in py_text - assert "unique docstring marker text" not in pyi def test_step_data_table_surfaces_as_body_local_var(pytester) -> None: @@ -181,20 +170,9 @@ def test_step_data_table_surfaces_as_body_local_var(pytester) -> None: ) write_feature_text(pytester, "datatable.feature", feature_text) run_beehave_generate(pytester) - pyi = read_emitted_pyi(pytester, "datatable_default") py_text = read_emitted_py(pytester, "datatable_default") assert "'unique_col': 'marker'" in py_text assert "data_table = " in py_text - assert "unique_col" not in pyi - - -def test_generate_wipes_stale_pyi_in_tests_features(pytester) -> None: - copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) - stale_path = pytester.path / "tests" / "features" / "stale_default_test.pyi" - stale_path.parent.mkdir(parents=True, exist_ok=True) - stale_path.write_text("# stale") - run_beehave_generate(pytester) - assert not stale_path.exists() def test_generate_creates_tests_features_dir_if_absent(pytester) -> None: @@ -211,3 +189,10 @@ def test_outline_scenario_emits_parametrize_in_py(pytester) -> None: assert "@pytest.mark.parametrize(" in py assert "'nectar'" in py and "'honey'" in py assert "('100', '20', '8', '80')," in py + + +def test_generate_does_not_emit_pyi_files(pytester) -> None: + copy_feature_into_pytester(pytester, HIVE_ACTIVITY_FEATURE) + run_beehave_generate(pytester) + pyi_files = list((pytester.path / EMISSION_DIR).glob("*_test.pyi")) + assert pyi_files == [] diff --git a/tests/e2e/generate_test.pyi b/tests/e2e/generate_test.pyi index 56bc8a0..31830c3 100644 --- a/tests/e2e/generate_test.pyi +++ b/tests/e2e/generate_test.pyi @@ -2,16 +2,16 @@ # # Drives the CLI through the pytester subprocess against `docs/features/*.feature` # and asserts the observable emission surface: -# - `_default_test.pyi` plus one `__test.pyi` per Rule; -# - the `.pyi` is always emitted (Constraint 1); the `.py` skeleton is emitted -# only when absent (idempotency lives at the integration layer); +# - `_default_test.py` plus one `__test.py` per Rule; +# - the `.py` skeleton is emitted only when absent (idempotency); +# - no `.pyi` files are emitted (v3.0.0 dropped consumer-side stubtest); # - Scenario title -> `test_` function name (slug = lowercased title, # whitespace runs collapsed to a single underscore); # - Feature and Rule Background steps are prepended to every relevant # scenario's `with step(...)` block list in the emitted `.py`; -# - tags, step-docstrings, and step-data-tables are parsed (Full Gherkin) but -# do NOT surface in the emitted `.pyi` signature or `with step(...)` block -# shape (minimal-surface preference). +# - tags, step-docstrings, and step-data-tables are parsed (Full Gherkin) +# and surface as `pytestmark` / `@pytest.mark.` decorators and +# body-local variables (`docstring = '...'`, `data_table = [...]`). # Fixture feature basenames available under docs/features/ (the v2 E2E inputs). HIVE_ACTIVITY_FEATURE: str @@ -21,20 +21,19 @@ STATUS_COMMAND_FEATURE: str CASE_INSENSITIVE_MATCHING_FEATURE: str # Default-group suffix in the per-rule emission convention -# `__test.py{i,}`. +# `__test.py`. DEFAULT_GROUP_SUFFIX: str -# The directory `generate` emits `*_test.py{i,}` into (pyproject testpaths). +# The directory `generate` emits `*_test.py` into (pyproject testpaths). EMISSION_DIR: str def copy_feature_into_pytester(pytester, basename: str) -> str: ... def write_feature_text(pytester, basename: str, text: str) -> str: ... def run_beehave_generate(pytester, *args: str) -> int: ... -def read_emitted_pyi(pytester, stem: str) -> str: ... def read_emitted_py(pytester, stem: str) -> str: ... def list_emitted_stems(pytester) -> list[str]: ... -def test_emits_pyi_for_default_group_and_each_rule(pytester) -> None: ... -def test_always_emits_pyi_file_for_every_rule_and_default(pytester) -> None: ... +def test_emits_py_for_default_group_and_each_rule(pytester) -> None: ... +def test_always_emits_py_file_for_every_rule_and_default(pytester) -> None: ... def test_emits_py_skeleton_only_when_py_absent(pytester) -> None: ... def test_scenario_title_emits_test_underscore_slug_function(pytester) -> None: ... def test_function_name_carries_no_uppercase_and_collapses_whitespace( @@ -44,10 +43,10 @@ def test_feature_background_steps_appear_in_every_emitted_scenario( pytester, ) -> None: ... def test_rule_background_steps_appear_only_in_that_rule_scenarios(pytester) -> None: ... -def test_feature_tags_surface_as_pytestmark_in_py_not_pyi(pytester) -> None: ... +def test_feature_tags_surface_as_pytestmark_in_py(pytester) -> None: ... def test_scenario_tags_surface_as_decorator_marks(pytester) -> None: ... def test_step_docstring_surfaces_as_body_local_var(pytester) -> None: ... def test_step_data_table_surfaces_as_body_local_var(pytester) -> None: ... -def test_generate_wipes_stale_pyi_in_tests_features(pytester) -> None: ... def test_generate_creates_tests_features_dir_if_absent(pytester) -> None: ... def test_outline_scenario_emits_parametrize_in_py(pytester) -> None: ... +def test_generate_does_not_emit_pyi_files(pytester) -> None: ... diff --git a/tests/e2e/status_test.py b/tests/e2e/status_test.py index e55df43..762eb88 100644 --- a/tests/e2e/status_test.py +++ b/tests/e2e/status_test.py @@ -8,8 +8,8 @@ def write_feature_text(pytester, basename: str, text: str) -> str: return str(dst) -def write_pyi_stub(pytester, stem: str) -> str: - dst = pytester.path / "tests" / "features" / f"{stem}_test.pyi" +def write_py_stub(pytester, stem: str) -> str: + dst = pytester.path / "tests" / "features" / f"{stem}_test.py" dst.parent.mkdir(parents=True, exist_ok=True) dst.write_text("") return str(dst) @@ -37,13 +37,13 @@ def test_status_reports_feature_file_count(pytester) -> None: assert "feature" in stdout.lower() -def test_status_reports_emitted_stub_count(pytester) -> None: +def test_status_reports_emitted_skeleton_count(pytester) -> None: write_feature_text(pytester, "a.feature", "Feature: A\nScenario: aaaaa\nGiven x\n") - write_pyi_stub(pytester, "a_default") - write_pyi_stub(pytester, "a_other") + write_py_stub(pytester, "a_default") + write_py_stub(pytester, "a_other") stdout = status_stdout(pytester) assert "2" in stdout - assert "stub" in stdout.lower() + assert "skeleton" in stdout.lower() def test_status_exits_two_when_features_dir_missing(pytester) -> None: diff --git a/tests/e2e/status_test.pyi b/tests/e2e/status_test.pyi index 4293bbe..917898a 100644 --- a/tests/e2e/status_test.pyi +++ b/tests/e2e/status_test.pyi @@ -7,16 +7,16 @@ # stored mutable state of its own: # - exit 0 when the features directory exists; # - stdout includes the count of `.feature` files discovered; -# - stdout includes the count of emitted `*_test.pyi` stubs; +# - stdout includes the count of emitted `*_test.py` skeletons; # - exit 2 when the features directory is missing (filesystem error). # The rich v1 stage taxonomy (broken / needs-tests / needs-bodies / needs-fixes # / ok) is DROPPED - ungrounded by v2 CIT/laddering, and not asserted here. def write_feature_text(pytester, basename: str, text: str) -> str: ... -def write_pyi_stub(pytester, stem: str) -> str: ... +def write_py_stub(pytester, stem: str) -> str: ... def run_beehave_status(pytester, *args: str) -> int: ... def status_stdout(pytester) -> str: ... def test_status_exits_zero_when_features_dir_exists(pytester) -> None: ... def test_status_reports_feature_file_count(pytester) -> None: ... -def test_status_reports_emitted_stub_count(pytester) -> None: ... +def test_status_reports_emitted_skeleton_count(pytester) -> None: ... def test_status_exits_two_when_features_dir_missing(pytester) -> None: ... diff --git a/tests/integration/idempotency_test.py b/tests/integration/idempotency_test.py index 4e911e1..d80b113 100644 --- a/tests/integration/idempotency_test.py +++ b/tests/integration/idempotency_test.py @@ -31,18 +31,6 @@ def emit_test_py_for(feature_text: str) -> str: return (root / "tests" / "features" / "input_default_test.py").read_text() -def emit_test_pyi_for(feature_text: str) -> str: - from beehave.generate import generate - - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - features = root / "docs" / "features" - features.mkdir(parents=True) - (features / "input.feature").write_text(feature_text) - generate(root) - return (root / "tests" / "features" / "input_default_test.pyi").read_text() - - def regenerate_over_body(feature_text: str, existing_py_body: str) -> str: from beehave.generate import generate @@ -77,6 +65,7 @@ def test_regenerate_does_not_emit_py_when_py_present() -> None: assert regenerated == consumer_body -def test_regenerate_rewrites_pyi_when_feature_gains_scenario() -> None: - pyi = emit_test_pyi_for(EXTENDED_FEATURE) - assert "test_second_scenario" in pyi +def test_regenerate_is_noop_when_feature_gains_scenario() -> None: + baseline = emit_test_py_for(BASE_FEATURE) + regenerated = regenerate_over_body(EXTENDED_FEATURE, baseline) + assert regenerated == baseline diff --git a/tests/integration/idempotency_test.pyi b/tests/integration/idempotency_test.pyi index 4cb322b..3f7ca47 100644 --- a/tests/integration/idempotency_test.pyi +++ b/tests/integration/idempotency_test.pyi @@ -1,21 +1,22 @@ # Integration contract for `generate` idempotency at the generation unit. # # `generate` re-run on a feature whose `*_test.py` already carries consumer -# bodies preserves those bodies - only the `.pyi` is rewritten (interview L2 +# bodies preserves those bodies - the `.py` is never rewritten (interview L2 # Modifiability / idempotency). This is the cycle's defining property and -# the protection for the consumer-authored seam. +# the protection for the consumer-authored seam. Under v3.0.0 there is no +# `.pyi` emission; adding a scenario to the feature leaves the existing +# `.py` byte-identical (consumer reconciles via `beehave check`). # # These tests drive `generate` in-process; the SUT imports live in each body -# (deferred), so the `.pyi` does not import beehave. +# (deferred). # A minimal feature used as the idempotency input. BASE_FEATURE: str -# The same feature with one additional scenario (drives `.pyi` re-emission). +# The same feature with one additional scenario. EXTENDED_FEATURE: str def emit_test_py_for(feature_text: str) -> str: ... -def emit_test_pyi_for(feature_text: str) -> str: ... def regenerate_over_body(feature_text: str, existing_py_body: str) -> str: ... def test_regenerate_preserves_existing_consumer_py_body() -> None: ... def test_regenerate_does_not_emit_py_when_py_present() -> None: ... -def test_regenerate_rewrites_pyi_when_feature_gains_scenario() -> None: ... +def test_regenerate_is_noop_when_feature_gains_scenario() -> None: ... diff --git a/tests/integration/parametrize_test.py b/tests/integration/parametrize_test.py index 24d24e2..3374457 100644 --- a/tests/integration/parametrize_test.py +++ b/tests/integration/parametrize_test.py @@ -63,28 +63,16 @@ """ -def _emit(root: Path, feature_text: str) -> tuple[str, str]: - from beehave.generate import generate - - features = root / "docs" / "features" - features.mkdir(parents=True) - (features / "input.feature").write_text(feature_text) - generate(root) - py = (root / "tests" / "features" / "input_default_test.py").read_text() - pyi = (root / "tests" / "features" / "input_default_test.pyi").read_text() - return py, pyi - - def _emitted_py(feature_text: str) -> str: - with tempfile.TemporaryDirectory() as tmp: - py, _pyi = _emit(Path(tmp), feature_text) - return py - + from beehave.generate import generate -def _emitted_pyi(feature_text: str) -> str: with tempfile.TemporaryDirectory() as tmp: - _py, pyi = _emit(Path(tmp), feature_text) - return pyi + root = Path(tmp) + features = root / "docs" / "features" + features.mkdir(parents=True) + (features / "input.feature").write_text(feature_text) + generate(root) + return (root / "tests" / "features" / "input_default_test.py").read_text() def test_examples_scenario_emits_parametrize_decorator() -> None: @@ -114,8 +102,7 @@ def test_no_examples_scenario_emits_no_parametrize() -> None: def test_plain_scenario_treats_angle_as_literal_text() -> None: py = _emitted_py(PLAIN_FEATURE_WITH_ANGLE) assert "" in py - pyi = _emitted_pyi(PLAIN_FEATURE_WITH_ANGLE) - assert "def test_plain_angles() -> None:" in pyi + assert "def test_plain_angles() -> None:" in py def test_multiple_examples_tables_are_merged() -> None: @@ -130,16 +117,8 @@ def test_different_tagged_tables_emit_pytest_param() -> None: assert "pytest.param('10', '8', marks=pytest.mark.fast)" in py -def test_pyi_signature_carries_str_params_for_outline() -> None: - pyi = _emitted_pyi(OUTLINE_FEATURE) - assert ( - "def test_honey_from_nectar(nectar: str, hours: str, honey: str) -> None:" - in pyi - ) - - -def test_check_passes_on_freshly_generated_pyi() -> None: +def test_check_passes_on_freshly_generated_py() -> None: from beehave.check import check - pyi = _emitted_pyi(OUTLINE_FEATURE) - assert check(OUTLINE_FEATURE, pyi) + py = _emitted_py(OUTLINE_FEATURE) + assert check(OUTLINE_FEATURE, py) diff --git a/tests/integration/parametrize_test.pyi b/tests/integration/parametrize_test.pyi index ce60606..20c80f4 100644 --- a/tests/integration/parametrize_test.pyi +++ b/tests/integration/parametrize_test.pyi @@ -1,16 +1,12 @@ from __future__ import annotations -from pathlib import Path - OUTLINE_FEATURE: str PLAIN_FEATURE: str PLAIN_FEATURE_WITH_ANGLE: str MULTI_TABLE_FEATURE: str DIFFERENT_TAG_TABLES_FEATURE: str -def _emit(root: Path, feature_text: str) -> tuple[str, str]: ... def _emitted_py(feature_text: str) -> str: ... -def _emitted_pyi(feature_text: str) -> str: ... def test_examples_scenario_emits_parametrize_decorator() -> None: ... def test_parametrize_arg_names_match_examples_headers() -> None: ... def test_parametrize_rows_match_examples_rows() -> None: ... @@ -18,5 +14,4 @@ def test_no_examples_scenario_emits_no_parametrize() -> None: ... def test_plain_scenario_treats_angle_as_literal_text() -> None: ... def test_multiple_examples_tables_are_merged() -> None: ... def test_different_tagged_tables_emit_pytest_param() -> None: ... -def test_pyi_signature_carries_str_params_for_outline() -> None: ... -def test_check_passes_on_freshly_generated_pyi() -> None: ... +def test_check_passes_on_freshly_generated_py() -> None: ... diff --git a/tests/integration/roundtrip_test.py b/tests/integration/roundtrip_test.py index 88c463e..7a972ce 100644 --- a/tests/integration/roundtrip_test.py +++ b/tests/integration/roundtrip_test.py @@ -12,7 +12,7 @@ """ -def emit_test_pyi_for(feature_text: str) -> str: +def emit_test_py_for(feature_text: str) -> str: from beehave.generate import generate with tempfile.TemporaryDirectory() as tmp: @@ -21,26 +21,26 @@ def emit_test_pyi_for(feature_text: str) -> str: features.mkdir(parents=True) (features / "input.feature").write_text(feature_text) generate(root) - return (root / "tests" / "features" / "input_default_test.pyi").read_text() + return (root / "tests" / "features" / "input_default_test.py").read_text() -def check_passes_for(feature_text: str, stub_text: str) -> bool: +def check_passes_for(feature_text: str, py_text: str) -> bool: from beehave.check import check - return check(feature_text, stub_text) + return check(feature_text, py_text) -def test_check_passes_on_freshly_generated_pyi() -> None: - pyi_text = emit_test_pyi_for(ROUND_TRIP_FEATURE) - assert check_passes_for(ROUND_TRIP_FEATURE, pyi_text) +def test_check_passes_on_freshly_generated_py() -> None: + py_text = emit_test_py_for(ROUND_TRIP_FEATURE) + assert check_passes_for(ROUND_TRIP_FEATURE, py_text) -def test_check_fails_when_pyi_missing_signature() -> None: +def test_check_fails_when_py_missing_signature() -> None: assert not check_passes_for(ROUND_TRIP_FEATURE, "") -def test_check_fails_when_pyi_signature_renamed() -> None: - pyi_text = emit_test_pyi_for(ROUND_TRIP_FEATURE) - edited = pyi_text.replace("test_round_trip", "test_renamed") - assert edited != pyi_text +def test_check_fails_when_py_signature_renamed() -> None: + py_text = emit_test_py_for(ROUND_TRIP_FEATURE) + edited = py_text.replace("test_round_trip", "test_renamed") + assert edited != py_text assert not check_passes_for(ROUND_TRIP_FEATURE, edited) diff --git a/tests/integration/roundtrip_test.pyi b/tests/integration/roundtrip_test.pyi index 94a4132..6e55b0b 100644 --- a/tests/integration/roundtrip_test.pyi +++ b/tests/integration/roundtrip_test.pyi @@ -1,15 +1,15 @@ -# Integration contract for the generate -> check round-trip (v2.3.0). +# Integration contract for the generate -> check round-trip (v3.0.0). # -# `generate` emits a `.pyi` (always) and a `.py` skeleton (when absent); -# `check` is signature-only — it regenerates expected `.pyi` signatures from -# the feature in-memory and confirms they appear in the on-disk stub text. -# No AST walk of the `.py` body; step/parametrize enforcement is the runtime -# `step` CM's job (Mode B). +# `generate` emits only a `.py` skeleton (when absent); `check` is a 1-1 +# signature check — it parses the `.py` AST, collects non-private top-level +# function signatures, and verifies the set exactly equals the feature-derived +# signature set. Private functions are exempt (superset). Step/parametrize +# enforcement is the runtime `step` CM's job (Mode B). ROUND_TRIP_FEATURE: str -def emit_test_pyi_for(feature_text: str) -> str: ... -def check_passes_for(feature_text: str, stub_text: str) -> bool: ... -def test_check_passes_on_freshly_generated_pyi() -> None: ... -def test_check_fails_when_pyi_missing_signature() -> None: ... -def test_check_fails_when_pyi_signature_renamed() -> None: ... +def emit_test_py_for(feature_text: str) -> str: ... +def check_passes_for(feature_text: str, py_text: str) -> bool: ... +def test_check_passes_on_freshly_generated_py() -> None: ... +def test_check_fails_when_py_missing_signature() -> None: ... +def test_check_fails_when_py_signature_renamed() -> None: ... diff --git a/uv.lock b/uv.lock index f0945c8..92e67f3 100644 --- a/uv.lock +++ b/uv.lock @@ -91,11 +91,10 @@ wheels = [ [[package]] name = "beehave" -version = "2.3.1" +version = "3.0.0" source = { editable = "." } dependencies = [ { name = "gherkin-official" }, - { name = "mypy" }, ] [package.dev-dependencies] @@ -111,10 +110,7 @@ dev = [ ] [package.metadata] -requires-dist = [ - { name = "gherkin-official", specifier = ">=39.0" }, - { name = "mypy", specifier = ">=2.3.0" }, -] +requires-dist = [{ name = "gherkin-official", specifier = ">=39.0" }] [package.metadata.requires-dev] dev = [ From 234baa3a537f089443328ac2c08fe4215ef563bc Mon Sep 17 00:00:00 2001 From: nullhack Date: Mon, 20 Jul 2026 22:19:07 -0400 Subject: [PATCH 12/13] chore: clean up pre-existing dirty tree Remove 75 stale paths that pre-date the v2/v3 work and were carried as uncommitted deletions across every cycle: - 73 .cache/{interview-notes,sim}/* files (gitignored; tracked before the .gitignore rule was added; never should have been committed). - CHANGELOG.md (v1.0.0 content; stale. Will be re-authored at v3.0.0 release time). - =1.1.0 (stray empty file from a botched `pip install` log). No source or test changes; pure housecleaning. --- .../IN_20260520_case_insensitive_matching.md | 84 ------- .../walkthrough_01_in.json | 13 - .../walkthrough_01_out.json | 11 - .../walkthrough_02_in.json | 13 - .../walkthrough_02_out.json | 11 - .../walkthrough_03_in.json | 13 - .../walkthrough_03_out.json | 9 - .../walkthrough_04_in.json | 13 - .../walkthrough_04_out.json | 9 - .../walkthrough_05_in.json | 13 - .../walkthrough_05_out.json | 11 - .../walkthrough_06_in.json | 13 - .../walkthrough_06_out.json | 12 - .../walkthrough_07_in.json | 11 - .../walkthrough_07_out.json | 11 - .../walkthrough_08_in.json | 11 - .../walkthrough_08_out.json | 11 - .../walkthrough_09_in.json | 11 - .../walkthrough_09_out.json | 11 - .../walkthrough_10_in.json | 11 - .../walkthrough_10_out.json | 11 - .../walkthrough_11_in.json | 13 - .../walkthrough_11_out.json | 15 -- .../walkthrough_12_in.json | 13 - .../walkthrough_12_out.json | 12 - .../walkthrough_13_in.json | 12 - .../walkthrough_13_out.json | 10 - .../walkthrough_14_in.json | 12 - .../walkthrough_14_out.json | 10 - .../walkthrough_15_in.json | 12 - .../walkthrough_15_out.json | 12 - .../walkthrough_16_in.json | 12 - .../walkthrough_16_out.json | 11 - .../walkthrough_17_in.json | 12 - .../walkthrough_17_out.json | 10 - .../walkthrough_18_in.json | 12 - .../walkthrough_18_out.json | 10 - .../walkthrough_19_in.json | 19 -- .../walkthrough_19_out.json | 9 - .../walkthrough_20_in.json | 19 -- .../walkthrough_20_out.json | 9 - .../walkthrough_21_in.json | 19 -- .../walkthrough_21_out.json | 11 - .../walkthrough_22_in.json | 19 -- .../walkthrough_22_out.json | 9 - .../walkthrough_23_in.json | 19 -- .../walkthrough_23_out.json | 9 - .../walkthrough_24_in.json | 19 -- .../walkthrough_24_out.json | 13 - .../walkthrough_25_in.json | 19 -- .../walkthrough_25_out.json | 10 - .../walkthrough_26_in.json | 19 -- .../walkthrough_26_out.json | 11 - .../walkthrough_27_in.json | 15 -- .../walkthrough_27_out.json | 26 -- .../walkthrough_28_in.json | 20 -- .../walkthrough_28_out.json | 8 - .../walkthrough_29_in.json | 16 -- .../walkthrough_29_out.json | 10 - .../walkthrough_30_in.json | 19 -- .../walkthrough_30_out.json | 13 - .../walkthrough_31_in.json | 19 -- .../walkthrough_31_out.json | 12 - .../walkthrough_32_in.json | 19 -- .../walkthrough_32_out.json | 12 - .../walkthrough_33_in.json | 13 - .../walkthrough_33_out.json | 11 - .../walkthrough_34_in.json | 13 - .../walkthrough_34_out.json | 10 - .../walkthrough_35_in.json | 19 -- .../walkthrough_35_out.json | 12 - .../sim/simulation_results_20260520T071043.md | 214 ---------------- .../sim/simulation_results_20260520T073818.md | 229 ------------------ =1.1.0 | 0 CHANGELOG.md | 41 ---- 75 files changed, 1485 deletions(-) delete mode 100644 .cache/interview-notes/IN_20260520_case_insensitive_matching.md delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_01_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_01_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_02_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_02_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_03_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_03_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_04_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_04_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_05_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_05_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_06_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_06_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_07_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_07_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_08_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_08_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_09_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_09_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_10_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_10_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_11_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_11_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_12_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_12_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_13_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_13_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_14_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_14_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_15_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_15_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_16_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_16_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_17_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_17_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_18_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_18_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_19_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_19_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_20_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_20_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_21_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_21_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_22_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_22_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_23_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_23_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_24_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_24_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_25_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_25_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_26_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_26_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_27_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_27_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_28_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_28_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_29_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_29_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_30_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_30_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_31_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_31_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_32_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_32_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_33_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_33_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_34_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_34_out.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_35_in.json delete mode 100644 .cache/sim/case_insensitive_matching/walkthrough_35_out.json delete mode 100644 .cache/sim/simulation_results_20260520T071043.md delete mode 100644 .cache/sim/simulation_results_20260520T073818.md delete mode 100644 =1.1.0 delete mode 100644 CHANGELOG.md diff --git a/.cache/interview-notes/IN_20260520_case_insensitive_matching.md b/.cache/interview-notes/IN_20260520_case_insensitive_matching.md deleted file mode 100644 index 81da04a..0000000 --- a/.cache/interview-notes/IN_20260520_case_insensitive_matching.md +++ /dev/null @@ -1,84 +0,0 @@ -# Interview Notes: Case-Insensitive Matching - -**Session:** IN_20260520_case_insensitive_matching -**Date:** 2026-05-20 -**Stakeholder:** Product Owner (adversarial code review) - ---- - -## Pain Points - -| # | Description | Severity | Location | -|---|-------------|----------|----------| -| 18 | Negative numbers invisible — `_extract_body_nodes` misses `UnaryOp(USub(), Constant(n))` | High | `discover.py:44-48` | -| 19 | Quoted placeholder double-capture — `""` extracted as both Placeholder and Literal | Medium | `gherkin.py:88-96` | -| 20 | Quoted bracket notation captured as literal — `"[PHONE]"` becomes Literal when intent is markup | Medium | `gherkin.py:88-96` | -| 22 | Type mismatch — Gherkin `int(77000)` vs AST `str("77000")` from `Decimal("77000")` | High | `check.py:55` | - -## Business Goals - -1. **Case-insensitive matching for placeholders and literals.** `` in Gherkin must match `dog`, `DOG`, `Dog` in test body. `"Rex"` in Gherkin must match `"rex"`, `"Rex"`, `"REX"` in test body. - -## Formal Rules - -### R1 — Placeholder Extraction -A `` in step text is a Placeholder iff `token` is a valid Python identifier, not a Python keyword, not a Python builtin. Placeholder regex matches regardless of surrounding quotes. Duplicate placeholders within a step are deduplicated. - -### R2 — Numeric Literal Extraction -A bare token in step text is a numeric Literal iff it matches `^-?\d+$`. - -### R3 — String Literal Extraction -A quoted segment (`"..."` or `'...'`) is a string Literal with content extracted as-is between quotes. Exception: `<...>` inside quotes is skipped (already captured as Placeholder via R1). `[...]` inside quotes is captured verbatim as a literal value. - -### R4 — AST Body Constant Extraction -`_extract_body_nodes` collects: (a) `ast.Constant` values directly, (b) folded `UnaryOp(USub(), Constant(n))` → `-n`. Leading docstring expression is excluded. - -### R5 — Placeholder Comparison (case-insensitive) -A placeholder `ph` matches iff `ph.name.lower()` is in `{n.lower() for n in ti.body_name_nodes}`. - -### R6 — Literal Comparison (string-normalized, case-insensitive) -A literal `lit` matches iff `str(lit.value).lower()` is in `{str(c).lower() for c in ti.body_constant_nodes}`. - -## Domain Terms - -| Term | Definition | -|------|-----------| -| Placeholder | `` in Gherkin step text, mapped to Hypothesis strategy parameter | -| Literal | Numeric token or quoted string in Gherkin step text, must appear in test body | -| body_name_nodes | All `ast.Name` identifiers in test function body (after docstring exclusion) | -| body_constant_nodes | All `ast.Constant` values in test function body (after docstring exclusion, plus folded UnaryOp) | -| Case-insensitive matching | Comparison normalizes both sides to lowercase string form | - -## Edge Cases - -| Case | Expected | -|------|----------| -| `-2010` in Gherkin, `x = -2010` in body | Match (#18 fix) | -| `-3.14` in Gherkin, `x = -3.14` in body | Match | -| `""` in Gherkin step with `Scenario Outline` | Placeholder extracted, literal skipped (#19 fix) | -| `"[PHONE]"` in Gherkin, body has `"555-1234"` | `[PHONE]` is a literal matching literal `[PHONE]`; user writes different value → missing-literal (correct — user should use placeholders for dynamic values) | -| `"Rex"` in Gherkin, `"rex"` in body | Match (case-insensitive) | -| `` in Gherkin, `Dog` class in body | Match (case-insensitive) | -| `77000` in Gherkin, `Decimal("77000")` in body | Match (#22 fix via string normalization) | -| `1` in Gherkin, `True` in body | No match — `"1" != "true"` | -| Leading docstring in test body | Excluded from constant collection (existing behavior, unchanged) | -| Stub test bodies | Skipped entirely (existing behavior, unchanged) | - -## Files Affected - -| File | Change | -|------|--------| -| `beehave/discover.py` | `_extract_body_nodes`: fold UnaryOp (#18) | -| `beehave/gherkin.py` | `_extract_literals`: filter `<...>` from quoted captures (#19, #20) | -| `beehave/check.py` | `_check_placeholders`: case-insensitive (R5); `_check_literals`: string-normalized case-insensitive (R6, fixes #22, hardens #18) | -| `tests/` | New edge case tests for all 4 bugs + case variations | - -## Scope - -Single feature. Changes localized to extraction and comparison functions within Feature Parsing, Consistency Checking, and Test Discovery bounded contexts. No new bounded contexts, no cross-cutting concerns, no new dependencies. - -## Quality Attributes - -- **Correctness:** Deterministic comparison — same inputs always yield same result -- **Reliability:** No false positives (existing test suite guards against regression) -- **Simplicity:** string-based comparison replaces type-based + multiple special cases diff --git a/.cache/sim/case_insensitive_matching/walkthrough_01_in.json b/.cache/sim/case_insensitive_matching/walkthrough_01_in.json deleted file mode 100644 index bc1e29f..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_01_in.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 1, - "type": "happy-path", - "rule": "R1 — Placeholder Extraction", - "description": "Plain-text token extraction produces valid Placeholder", - "input": { - "step_text": "Given a dog named ", - "known_builtins": ["int", "str", "list"], - "known_keywords": ["class", "def", "if"] - }, - "initial_state": "parse_feature() processing a Scenario step" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_01_out.json b/.cache/sim/case_insensitive_matching/walkthrough_01_out.json deleted file mode 100644 index d69d398..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_01_out.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 1, - "expected": { - "placeholders": [ - {"name": "name", "raw": ""} - ], - "literals": [] - }, - "verification": "name is a valid Python identifier, not keyword, not builtin → Placeholder extracted" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_02_in.json b/.cache/sim/case_insensitive_matching/walkthrough_02_in.json deleted file mode 100644 index e530162..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_02_in.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 2, - "type": "edge-case", - "rule": "R1 — Placeholder inside quotes still extracted", - "description": "Placeholder regex matches regardless of surrounding quotes", - "input": { - "step_text": "Given a user named \"\"", - "known_builtins": ["int", "str"], - "known_keywords": ["class", "def"] - }, - "initial_state": "Scenario Outline step with quoted placeholder" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_02_out.json b/.cache/sim/case_insensitive_matching/walkthrough_02_out.json deleted file mode 100644 index 2fcaa55..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_02_out.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 2, - "expected": { - "placeholders": [ - {"name": "username", "raw": ""} - ], - "literals": [] - }, - "verification": "Placeholder regex fires first (matches regardless of quotes); literal extraction skips <...> inside quotes per R3 exception" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_03_in.json b/.cache/sim/case_insensitive_matching/walkthrough_03_in.json deleted file mode 100644 index 7761228..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_03_in.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 3, - "type": "edge-case", - "rule": "R1 — Python keyword rejection", - "description": "Token that is a Python keyword is NOT extracted as Placeholder", - "input": { - "step_text": "Given we use the instance", - "known_keywords": ["class", "def", "if", "else", "for", "while"], - "known_builtins": ["int", "str"] - }, - "initial_state": "Keyword token in step text" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_03_out.json b/.cache/sim/case_insensitive_matching/walkthrough_03_out.json deleted file mode 100644 index 12490b9..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_03_out.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 3, - "expected": { - "placeholders": [], - "literals": [] - }, - "verification": " is a Python keyword → rejected, not a Placeholder" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_04_in.json b/.cache/sim/case_insensitive_matching/walkthrough_04_in.json deleted file mode 100644 index 7f945b7..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_04_in.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 4, - "type": "edge-case", - "rule": "R1 — Python builtin rejection", - "description": "Token that is a Python builtin is NOT extracted as Placeholder", - "input": { - "step_text": "Given the value is ", - "known_keywords": ["class", "def"], - "known_builtins": ["int", "str", "list", "dict", "float"] - }, - "initial_state": "Builtin token in step text" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_04_out.json b/.cache/sim/case_insensitive_matching/walkthrough_04_out.json deleted file mode 100644 index 9f7eaee..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_04_out.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 4, - "expected": { - "placeholders": [], - "literals": [] - }, - "verification": " is a Python builtin → rejected, not a Placeholder" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_05_in.json b/.cache/sim/case_insensitive_matching/walkthrough_05_in.json deleted file mode 100644 index 021b51a..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_05_in.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 5, - "type": "edge-case", - "rule": "R1 — Duplicate placeholder deduplication", - "description": "Same twice in one step produces one Placeholder", - "input": { - "step_text": "Given meets ", - "known_keywords": ["class", "def"], - "known_builtins": ["int", "str"] - }, - "initial_state": "Step with repeated placeholder token" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_05_out.json b/.cache/sim/case_insensitive_matching/walkthrough_05_out.json deleted file mode 100644 index b033046..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_05_out.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 5, - "expected": { - "placeholders": [ - {"name": "name", "raw": ""} - ], - "literals": [] - }, - "verification": "Duplicates deduplicated — single Placeholder for despite appearing twice" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_06_in.json b/.cache/sim/case_insensitive_matching/walkthrough_06_in.json deleted file mode 100644 index 6d8cda4..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_06_in.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 6, - "type": "edge-case", - "rule": "R1 — Case-distinct placeholders", - "description": " and are two distinct Placeholders", - "input": { - "step_text": "Given product is also known as ", - "known_keywords": ["class", "def"], - "known_builtins": ["int", "str"] - }, - "initial_state": "Step with case-variant placeholder tokens" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_06_out.json b/.cache/sim/case_insensitive_matching/walkthrough_06_out.json deleted file mode 100644 index e99cfac..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_06_out.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 6, - "expected": { - "placeholders": [ - {"name": "ID", "raw": ""}, - {"name": "id", "raw": ""} - ], - "literals": [] - }, - "verification": "Both extracted — extraction is case-sensitive; case-insensitive matching at comparison stage handles both" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_07_in.json b/.cache/sim/case_insensitive_matching/walkthrough_07_in.json deleted file mode 100644 index 344b627..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_07_in.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 7, - "type": "happy-path", - "rule": "R2 — Numeric literal extraction", - "description": "Bare integer token extracted as numeric Literal", - "input": { - "step_text": "Given 3 items in the cart" - }, - "initial_state": "Step with numeric token" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_07_out.json b/.cache/sim/case_insensitive_matching/walkthrough_07_out.json deleted file mode 100644 index 2fddb60..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_07_out.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 7, - "expected": { - "placeholders": [], - "literals": [ - {"value": 3, "raw": "3", "type": "numeric"} - ] - }, - "verification": "Token '3' matches ^-?\\d+$ → numeric Literal with int value" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_08_in.json b/.cache/sim/case_insensitive_matching/walkthrough_08_in.json deleted file mode 100644 index 20c7bad..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_08_in.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 8, - "type": "edge-case", - "rule": "R2 — Negative numeric literal", - "description": "Bare negative integer token extracted as numeric Literal", - "input": { - "step_text": "Given the balance is -2010" - }, - "initial_state": "Step with negative numeric token" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_08_out.json b/.cache/sim/case_insensitive_matching/walkthrough_08_out.json deleted file mode 100644 index 741cc1a..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_08_out.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 8, - "expected": { - "placeholders": [], - "literals": [ - {"value": -2010, "raw": "-2010", "type": "numeric"} - ] - }, - "verification": "Token '-2010' matches ^-?\\d+$ → numeric Literal. Extract step is correct; the matching failure was in Test Discovery (bug #18, no UnaryOp folding)" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_09_in.json b/.cache/sim/case_insensitive_matching/walkthrough_09_in.json deleted file mode 100644 index 9db1e7c..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_09_in.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 9, - "type": "happy-path", - "rule": "R3 — String literal extraction", - "description": "Double-quoted string extracted as string Literal", - "input": { - "step_text": "Given a dog named \"Rex\"" - }, - "initial_state": "Step with quoted string" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_09_out.json b/.cache/sim/case_insensitive_matching/walkthrough_09_out.json deleted file mode 100644 index 728ceab..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_09_out.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 9, - "expected": { - "placeholders": [], - "literals": [ - {"value": "Rex", "raw": "\"Rex\"", "type": "string"} - ] - }, - "verification": "Content 'Rex' extracted as-is between quotes. Case preserved — matching is case-insensitive at comparison stage" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_10_in.json b/.cache/sim/case_insensitive_matching/walkthrough_10_in.json deleted file mode 100644 index ae51695..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_10_in.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 10, - "type": "edge-case", - "rule": "R3 — Quoted bracket notation captured verbatim", - "description": "[...] inside quotes captured as literal content verbatim (bug #20 decision)", - "input": { - "step_text": "Given a phone number \"[PHONE]\"" - }, - "initial_state": "Step with bracket-delimited content in quotes" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_10_out.json b/.cache/sim/case_insensitive_matching/walkthrough_10_out.json deleted file mode 100644 index 53dabc1..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_10_out.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 10, - "expected": { - "placeholders": [], - "literals": [ - {"value": "[PHONE]", "raw": "\"[PHONE]\"", "type": "string"} - ] - }, - "verification": "[...] is captured verbatim per user decision (not filtered). If user wants dynamic value they should use placeholder instead. This is NOT a bug." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_11_in.json b/.cache/sim/case_insensitive_matching/walkthrough_11_in.json deleted file mode 100644 index f0b22b7..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_11_in.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 11, - "type": "bug-before", - "rule": "Bug #19 — Quoted placeholder double-capture BEFORE fix", - "description": "\"\" in step text currently produces both Placeholder AND Literal", - "input": { - "step_text": "Given a user named \"\" in scenario outline ", - "known_keywords": [], - "known_builtins": [] - }, - "initial_state": "Step with placeholder inside double quotes (existing code)" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_11_out.json b/.cache/sim/case_insensitive_matching/walkthrough_11_out.json deleted file mode 100644 index adba144..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_11_out.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 11, - "bug": true, - "bug_id": 19, - "expected_before_fix": { - "placeholders": [ - {"name": "name", "raw": ""} - ], - "literals": [ - {"value": "", "raw": "\"\"", "type": "string"} - ] - }, - "problem": "Literal extraction does not filter <...> from quoted strings. The literal '' should not exist — the placeholder already captures this semantic. This produces a false positive missing-literal violation because '' is not a real constant in the test body." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_12_in.json b/.cache/sim/case_insensitive_matching/walkthrough_12_in.json deleted file mode 100644 index b7f3d72..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_12_in.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 12, - "type": "bug-after", - "rule": "Bug #19 — Quoted placeholder double-capture AFTER fix", - "description": "\"\" in step text: placeholder extracted, literal skipped", - "input": { - "step_text": "Given a user named \"\" in scenario outline ", - "known_keywords": [], - "known_builtins": [] - }, - "initial_state": "Step with placeholder inside double quotes (fixed code)" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_12_out.json b/.cache/sim/case_insensitive_matching/walkthrough_12_out.json deleted file mode 100644 index fe0f7c6..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_12_out.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 12, - "expected_after_fix": { - "placeholders": [ - {"name": "name", "raw": ""} - ], - "literals": [] - }, - "fix_applied": "Literal extraction filters out any quoted content matching <...> pattern before creating Literal objects. Placeholder regex already captures it. No more double-capture.", - "verification": "Literal list is empty — <...> inside quotes is excluded from literal extraction per R3 exception" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_13_in.json b/.cache/sim/case_insensitive_matching/walkthrough_13_in.json deleted file mode 100644 index 1542ae1..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_13_in.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 13, - "type": "happy-path", - "rule": "R4 — AST Constant extraction", - "description": "Simple ast.Constant values collected from test function body", - "input": { - "test_function_body": "x = 42\ny = 77000\nname = \"Rex\"", - "has_docstring": false - }, - "initial_state": "discover_tests() AST-walking a test function" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_13_out.json b/.cache/sim/case_insensitive_matching/walkthrough_13_out.json deleted file mode 100644 index c692750..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_13_out.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 13, - "expected": { - "body_name_nodes": ["x", "y", "name"], - "body_constant_nodes": [42, 77000, "Rex"], - "is_stub": false - }, - "verification": "All ast.Constant values present. Names collected for placeholder matching." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_14_in.json b/.cache/sim/case_insensitive_matching/walkthrough_14_in.json deleted file mode 100644 index 1c1cee3..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_14_in.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 14, - "type": "happy-path", - "rule": "R4 — UnaryOp(USub()) folding", - "description": "Negative number in body via UnaryOp is folded into constant set", - "input": { - "test_function_body": "balance = -2010\ntemp = -3.14", - "has_docstring": false - }, - "initial_state": "AST with UnaryOp(USub(), Constant(...)) nodes" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_14_out.json b/.cache/sim/case_insensitive_matching/walkthrough_14_out.json deleted file mode 100644 index 3ee10ac..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_14_out.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 14, - "expected": { - "body_name_nodes": ["balance", "temp"], - "body_constant_nodes": [2010, -2010, 3.14, -3.14], - "is_stub": false - }, - "verification": "Both the literal n AND the folded -n appear in constants. -2010 is visible for literal matching. Both positive and negative forms from each UnaryOp." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_15_in.json b/.cache/sim/case_insensitive_matching/walkthrough_15_in.json deleted file mode 100644 index 9c57aca..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_15_in.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 15, - "type": "bug-before", - "rule": "Bug #18 — Negative numbers invisible BEFORE fix", - "description": "discover_tests does NOT fold UnaryOp(USub()) → -2010 missing from body_constant_nodes", - "input": { - "test_function_body": "balance = -2010\nx = 5", - "has_docstring": false - }, - "initial_state": "Existing discover.py (no UnaryOp folding)" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_15_out.json b/.cache/sim/case_insensitive_matching/walkthrough_15_out.json deleted file mode 100644 index 1ef0044..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_15_out.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 15, - "bug": true, - "bug_id": 18, - "expected_before_fix": { - "body_name_nodes": ["balance", "x"], - "body_constant_nodes": [2010, 5], - "is_stub": false - }, - "problem": "Only ast.Constant(n) values collected (2010, 5). UnaryOp(USub(), Constant(2010)) produces -2010 in the AST but _extract_body_nodes ignores it. When Gherkin has literal -2010, check_pair() looks for -2010 in body_constant_nodes but only finds 2010 → missing-literal false positive." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_16_in.json b/.cache/sim/case_insensitive_matching/walkthrough_16_in.json deleted file mode 100644 index 9008ea7..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_16_in.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 16, - "type": "bug-after", - "rule": "Bug #18 — Negative numbers visible AFTER fix", - "description": "discover_tests folds UnaryOp(USub()) → -2010 IS in body_constant_nodes", - "input": { - "test_function_body": "balance = -2010\nx = 5", - "has_docstring": false - }, - "initial_state": "Fixed discover.py (UnaryOp folding implemented)" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_16_out.json b/.cache/sim/case_insensitive_matching/walkthrough_16_out.json deleted file mode 100644 index 79d549f..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_16_out.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 16, - "expected_after_fix": { - "body_name_nodes": ["balance", "x"], - "body_constant_nodes": [2010, -2010, 5], - "is_stub": false - }, - "fix_applied": "When AST walker encounters ast.UnaryOp with USub() op and Constant operand, it folds the value: adds both the positive constant n AND the negated value -n to body_constant_nodes.", - "verification": "-2010 present in body_constant_nodes. Gherkin literal -2010 will find a match." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_17_in.json b/.cache/sim/case_insensitive_matching/walkthrough_17_in.json deleted file mode 100644 index bd053e6..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_17_in.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 17, - "type": "edge-case", - "rule": "R4 — Leading docstring excluded", - "description": "Docstring constant at start of body is excluded from constant collection", - "input": { - "test_function_body": "\"\"\"This is a docstring.\"\"\"\nx = 42", - "has_docstring": true - }, - "initial_state": "Test function with leading docstring" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_17_out.json b/.cache/sim/case_insensitive_matching/walkthrough_17_out.json deleted file mode 100644 index f2e6481..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_17_out.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 17, - "expected": { - "body_name_nodes": ["x"], - "body_constant_nodes": [42], - "is_stub": false - }, - "verification": "Docstring constant 'This is a docstring.' is excluded. Only x and 42 from the second statement are collected." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_18_in.json b/.cache/sim/case_insensitive_matching/walkthrough_18_in.json deleted file mode 100644 index 571d1a9..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_18_in.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 18, - "type": "happy-path", - "rule": "R4 — Name extraction for placeholder matching", - "description": "ast.Name identifiers collected for case-insensitive placeholder matching", - "input": { - "test_function_body": "dog = Dog()\nbalance = get_balance()\nBALANCE = 100", - "has_docstring": false - }, - "initial_state": "Test body with various variable names" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_18_out.json b/.cache/sim/case_insensitive_matching/walkthrough_18_out.json deleted file mode 100644 index f5955a7..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_18_out.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 18, - "expected": { - "body_name_nodes": ["dog", "balance", "BALANCE", "Dog"], - "body_constant_nodes": [100], - "is_stub": false - }, - "verification": "All ast.Name ids collected with case preserved (dog, balance, BALANCE, Dog). Case-insensitive matching at comparison stage: placeholder would match dog, Dog, and DOG." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_19_in.json b/.cache/sim/case_insensitive_matching/walkthrough_19_in.json deleted file mode 100644 index cfdf0a7..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_19_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 19, - "type": "happy-path", - "rule": "R5 — Case-insensitive placeholder match (same case)", - "description": " in Gherkin matches Dog in test body", - "input": { - "scenario": { - "step_text": "Given a barks", - "placeholders": [{"name": "Dog", "raw": ""}], - "literals": [] - }, - "test": { - "body_name_nodes": ["dog", "Dog", "owner"], - "body_constant_nodes": [] - } - }, - "initial_state": "check_pair() comparing placeholders against test body" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_19_out.json b/.cache/sim/case_insensitive_matching/walkthrough_19_out.json deleted file mode 100644 index 54d65e2..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_19_out.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 19, - "expected": { - "violations": [], - "match_detail": "ph.name.lower() = 'dog', body_name_nodes lowered = {'dog', 'owner', 'dog'} → 'dog' in set → match" - }, - "verification": "Exact case match (Dog→Dog already matched by set intersection before fix; post-fix, lowered comparison covers all variants)" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_20_in.json b/.cache/sim/case_insensitive_matching/walkthrough_20_in.json deleted file mode 100644 index b2f8e7d..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_20_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 20, - "type": "edge-case", - "rule": "R5 — Case-insensitive placeholder match (uppercase in body)", - "description": " in Gherkin matches DOG in test body (case-insensitive)", - "input": { - "scenario": { - "step_text": "Given a barks", - "placeholders": [{"name": "Dog", "raw": ""}], - "literals": [] - }, - "test": { - "body_name_nodes": ["DOG", "OWNER"], - "body_constant_nodes": [] - } - }, - "initial_state": "check_pair() with uppercase-only body names" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_20_out.json b/.cache/sim/case_insensitive_matching/walkthrough_20_out.json deleted file mode 100644 index f775d2c..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_20_out.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 20, - "expected": { - "violations": [], - "match_detail": "ph.name.lower() = 'dog', body_name_nodes lowered = {'dog', 'owner'} → 'dog' in set → match" - }, - "verification": "Case-insensitive: 'Dog'.lower()='dog' matches 'DOG'.lower()='dog'. This is the core R5 behavior." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_21_in.json b/.cache/sim/case_insensitive_matching/walkthrough_21_in.json deleted file mode 100644 index 285117e..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_21_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 21, - "type": "edge-case", - "rule": "R5 — Case-insensitive placeholder mismatch", - "description": " in Gherkin does NOT match cat in test body (different identifier)", - "input": { - "scenario": { - "step_text": "Given a barks", - "placeholders": [{"name": "Dog", "raw": ""}], - "literals": [] - }, - "test": { - "body_name_nodes": ["cat", "owner"], - "body_constant_nodes": [] - } - }, - "initial_state": "check_pair() where placeholder identifier is absent from body" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_21_out.json b/.cache/sim/case_insensitive_matching/walkthrough_21_out.json deleted file mode 100644 index 0ea8da1..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_21_out.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 21, - "expected": { - "violations": [ - {"error_type": "missing-placeholder", "message": "placeholder 'Dog' not found in test body"} - ], - "match_detail": "ph.name.lower() = 'dog', body lowered = {'cat', 'owner'} → 'dog' not in set → violation" - }, - "verification": "Missing placeholder violation produced. Case-insensitive but still requires semantic match." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_22_in.json b/.cache/sim/case_insensitive_matching/walkthrough_22_in.json deleted file mode 100644 index f4e04f9..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_22_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 22, - "type": "happy-path", - "rule": "R6 — String-normalized case-insensitive literal match (same case)", - "description": "\"Rex\" in Gherkin matches \"Rex\" in test body", - "input": { - "scenario": { - "step_text": "Given a dog named \"Rex\"", - "placeholders": [], - "literals": [{"value": "Rex", "type": "string"}] - }, - "test": { - "body_name_nodes": [], - "body_constant_nodes": ["Rex"] - } - }, - "initial_state": "check_pair() comparing literals against test body constants" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_22_out.json b/.cache/sim/case_insensitive_matching/walkthrough_22_out.json deleted file mode 100644 index aeb8e63..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_22_out.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 22, - "expected": { - "violations": [], - "match_detail": "str(lit.value).lower() = 'rex', str(const).lower() for each c in body_constant_nodes = {'rex'} → 'rex' in {'rex'} → match" - }, - "verification": "Exact case match via string normalization. Both sides lowered → equality." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_23_in.json b/.cache/sim/case_insensitive_matching/walkthrough_23_in.json deleted file mode 100644 index e1554b0..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_23_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 23, - "type": "edge-case", - "rule": "R6 — Case-insensitive literal match (different case)", - "description": "\"Rex\" in Gherkin matches \"rex\" in test body (case-insensitive)", - "input": { - "scenario": { - "step_text": "Given a dog named \"Rex\"", - "placeholders": [], - "literals": [{"value": "Rex", "type": "string"}] - }, - "test": { - "body_name_nodes": [], - "body_constant_nodes": ["rex"] - } - }, - "initial_state": "check_pair() with lowercase-only constant in body" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_23_out.json b/.cache/sim/case_insensitive_matching/walkthrough_23_out.json deleted file mode 100644 index 939e7c4..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_23_out.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 23, - "expected": { - "violations": [], - "match_detail": "str('Rex').lower() = 'rex', str('rex').lower() = 'rex' → match" - }, - "verification": "Case-insensitive: both sides lowered to 'rex' before comparison. Core R6 behavior." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_24_in.json b/.cache/sim/case_insensitive_matching/walkthrough_24_in.json deleted file mode 100644 index 4a21d85..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_24_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 24, - "type": "bug-before", - "rule": "Bug #22 — Type mismatch BEFORE fix", - "description": "Gherkin int(77000) vs AST str('77000') from Decimal('77000') — type mismatch causes false positive", - "input": { - "scenario": { - "step_text": "Given the population is 77000", - "placeholders": [], - "literals": [{"value": 77000, "type": "numeric"}] - }, - "test": { - "body_name_nodes": [], - "body_constant_nodes": ["77000"] - } - }, - "initial_state": "check_pair() with type-mismatched values (existing code without string normalization)" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_24_out.json b/.cache/sim/case_insensitive_matching/walkthrough_24_out.json deleted file mode 100644 index a205437..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_24_out.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 24, - "bug": true, - "bug_id": 22, - "expected_before_fix": { - "violations": [ - {"error_type": "missing-literal", "message": "literal 77000 not found in test body"} - ], - "problem": "lit.value is int(77000), body_constant_nodes contains str('77000') from Decimal('77000'). Old code compares int directly with str in set → int(77000) not in {'77000'} → false positive. The semantic values match (77000 == '77000' after normalization) but type systems disagree." - }, - "verification": "Bug exists: false positive missing-literal despite semantically matching values." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_25_in.json b/.cache/sim/case_insensitive_matching/walkthrough_25_in.json deleted file mode 100644 index 11fe29d..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_25_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 25, - "type": "bug-after", - "rule": "Bug #22 — Type mismatch resolved AFTER fix", - "description": "Gherkin int(77000) vs AST str('77000') → string normalization resolves type mismatch", - "input": { - "scenario": { - "step_text": "Given the population is 77000", - "placeholders": [], - "literals": [{"value": 77000, "type": "numeric"}] - }, - "test": { - "body_name_nodes": [], - "body_constant_nodes": ["77000"] - } - }, - "initial_state": "check_pair() with string normalization active" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_25_out.json b/.cache/sim/case_insensitive_matching/walkthrough_25_out.json deleted file mode 100644 index ec9e241..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_25_out.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 25, - "expected_after_fix": { - "violations": [], - "match_detail": "str(77000).lower() = '77000', {str(c).lower() for c in ['77000']} = {'77000'} → '77000' in {'77000'} → match" - }, - "fix_applied": "Both sides normalized via str().lower() before comparison. int(77000) → '77000', str('77000') → '77000'. Types erased, values matched.", - "verification": "No violation. Type mismatch resolved by string normalization." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_26_in.json b/.cache/sim/case_insensitive_matching/walkthrough_26_in.json deleted file mode 100644 index 4d97d7f..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_26_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 26, - "type": "edge-case", - "rule": "R6 — True vs 1 no false collision", - "description": "Gherkin literal 1 does NOT match True in test body (different normalized strings)", - "input": { - "scenario": { - "step_text": "Given the flag is 1", - "placeholders": [], - "literals": [{"value": 1, "type": "numeric"}] - }, - "test": { - "body_name_nodes": [], - "body_constant_nodes": [True] - } - }, - "initial_state": "check_pair() with bool constant in body" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_26_out.json b/.cache/sim/case_insensitive_matching/walkthrough_26_out.json deleted file mode 100644 index f5dbf26..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_26_out.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 26, - "expected": { - "violations": [ - {"error_type": "missing-literal", "message": "literal 1 not found in test body"} - ], - "match_detail": "str(1).lower() = '1', {str(True).lower()} = {'true'} → '1' not in {'true'} → no match" - }, - "verification": "True and 1 never collide: 'true' ≠ '1'. Different types produce different normalized strings. Correct." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_27_in.json b/.cache/sim/case_insensitive_matching/walkthrough_27_in.json deleted file mode 100644 index ad8bf21..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_27_in.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 27, - "type": "e2e-happy-path", - "rule": "E2E — Full pipeline: Feature Parsing → Test Discovery → Consistency Checking", - "description": "Complete happy path with case-insensitive matching across all 3 bounded contexts", - "input": { - "feature_file": "docs/features/case_insensitive_matching.feature", - "step_text": "Given a named \"Rex\" with balance -2010", - "test_function": "def test_dog_named_rex(dog, rex, balance):\n dog = Dog()\n name = \"rex\"\n balance = -2010", - "known_keywords": ["class", "def"], - "known_builtins": ["int", "str"] - }, - "initial_state": "beehave check invoked on feature file matching case_insensitive_matching" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_27_out.json b/.cache/sim/case_insensitive_matching/walkthrough_27_out.json deleted file mode 100644 index d4b3a88..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_27_out.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 27, - "expected": { - "feature_parsing_output": { - "placeholders": [{"name": "Dog", "raw": ""}], - "literals": [ - {"value": "Rex", "type": "string"}, - {"value": -2010, "type": "numeric"} - ] - }, - "test_discovery_output": { - "body_name_nodes": ["dog", "rex", "balance", "Dog"], - "body_constant_nodes": ["rex", 2010, -2010] - }, - "consistency_checking_output": { - "violations": [] - }, - "matches": [ - "Placeholder : 'dog'.lower() in {'dog','rex','balance','dog'} → match", - "Literal 'Rex': 'rex' in {'rex'} → match", - "Literal -2010: '-2010' in {'2010','-2010'} → match" - ], - "verification": "All extractions correct, all body nodes correct, all comparisons pass. Zero violations. Full E2E pipeline validates bugs #18, #19, #22 are resolved and R5/R6 work correctly." - } -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_28_in.json b/.cache/sim/case_insensitive_matching/walkthrough_28_in.json deleted file mode 100644 index 93d0eb1..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_28_in.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 28, - "type": "error-path", - "rule": "Stub tests are exempt from all checks", - "description": "Stub test body (pass/...) skips body enforcement — no violation even if placeholders are missing", - "input": { - "scenario": { - "step_text": "Given a named \"Rex\"", - "placeholders": [{"name": "Dog", "raw": ""}], - "literals": [{"value": "Rex", "type": "string"}] - }, - "test": { - "is_stub": true, - "body_name_nodes": [], - "body_constant_nodes": [] - } - }, - "initial_state": "check_pair() encountering a stub test" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_28_out.json b/.cache/sim/case_insensitive_matching/walkthrough_28_out.json deleted file mode 100644 index ca75521..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_28_out.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 28, - "expected": { - "violations": [] - }, - "verification": "Stub tests (is_stub=True) skip all body-based violation checks per invariant. No false positives." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_29_in.json b/.cache/sim/case_insensitive_matching/walkthrough_29_in.json deleted file mode 100644 index 9907eb1..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_29_in.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 29, - "type": "error-path", - "rule": "Unmapped scenario produces violation", - "description": "Scenario with no matching test function → unmapped-scenario violation", - "input": { - "scenario": { - "step_text": "Given a named \"Rex\"", - "placeholders": [{"name": "Dog", "raw": ""}], - "literals": [{"value": "Rex", "type": "string"}] - }, - "test": null - }, - "initial_state": "check_pair() when ti is None (no matching test found)" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_29_out.json b/.cache/sim/case_insensitive_matching/walkthrough_29_out.json deleted file mode 100644 index 7585fda..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_29_out.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 29, - "expected": { - "violations": [ - {"error_type": "unmapped-scenario", "message": "no test found for scenario"} - ] - }, - "verification": "When TestInfo is None, check_pair returns unmapped-scenario violation immediately. No placeholder/literal checks attempted." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_30_in.json b/.cache/sim/case_insensitive_matching/walkthrough_30_in.json deleted file mode 100644 index e07fd2b..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_30_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 30, - "type": "edge-case", - "rule": "R5 — Placeholder Matching Case Insensitive", - "description": "Mixed-case placeholder with underscores vs snake_case body name — different when lowered", - "input": { - "scenario": { - "step_text": "Given the is valid", - "placeholders": [{"name": "PhoneNumber", "raw": ""}], - "literals": [] - }, - "test": { - "body_name_nodes": ["phone_number", "validate_number"], - "body_constant_nodes": [] - } - }, - "initial_state": "check_pair() comparing against body identifiers" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_30_out.json b/.cache/sim/case_insensitive_matching/walkthrough_30_out.json deleted file mode 100644 index 39d2e76..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_30_out.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 30, - "expected": { - "violations": [ - {"error_type": "missing-placeholder", "message": "placeholder 'PhoneNumber' not found in test body"} - ], - "normalized_placeholder": "phonenumber", - "normalized_body_names": ["phone_number", "validate_number"], - "match_found": false - }, - "verification": ".lower() = 'phonenumber', but body has 'phone_number' (with underscore). After lowering: 'phonenumber' ≠ 'phone_number'. Different strings → no match → missing-placeholder violation. This is expected behavior: underscores are not collapsed by case-insensitive matching." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_31_in.json b/.cache/sim/case_insensitive_matching/walkthrough_31_in.json deleted file mode 100644 index a3a10cd..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_31_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 31, - "type": "edge-case", - "rule": "R6 — Literal Matching Case Insensitive (leading zeros)", - "description": "Numeric literal 007 with leading zeros in Gherkin matches body Constant(7)", - "input": { - "scenario": { - "step_text": "Given the code is 007", - "placeholders": [], - "literals": [{"value": 7, "type": "numeric", "raw": "007"}] - }, - "test": { - "body_name_nodes": ["code"], - "body_constant_nodes": [7] - } - }, - "initial_state": "check_pair() comparing Gherkin literal 007 against body constants" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_31_out.json b/.cache/sim/case_insensitive_matching/walkthrough_31_out.json deleted file mode 100644 index 4427b0e..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_31_out.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 31, - "expected": { - "violations": [], - "gherkin_extraction": "int('007') -> 7", - "str_normalized_gherkin": "7", - "str_normalized_body": "7", - "match_found": true - }, - "verification": "Gherkin literal '007' extracted as Literal(value=7) via int('007') → 7. str(7) → '7'. Body Constant(7) → str(7) → '7'. Normalized strings match → zero violations. Leading zeros are erased by int() conversion; this is expected behavior documented in domain_spec _extract_literals rule 1." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_32_in.json b/.cache/sim/case_insensitive_matching/walkthrough_32_in.json deleted file mode 100644 index 75e1b37..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_32_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 32, - "type": "edge-case", - "rule": "R6 — Literal Matching Case Insensitive (empty string)", - "description": "Empty quoted string '\"\"' in Gherkin matches body Constant(\"\")", - "input": { - "scenario": { - "step_text": "Given the value is \"\"", - "placeholders": [], - "literals": [{"value": "", "type": "string", "raw": "\"\""}] - }, - "test": { - "body_name_nodes": ["value"], - "body_constant_nodes": [""] - } - }, - "initial_state": "check_pair() comparing Gherkin literal \"\" against body constants" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_32_out.json b/.cache/sim/case_insensitive_matching/walkthrough_32_out.json deleted file mode 100644 index 548902a..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_32_out.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 32, - "expected": { - "violations": [], - "gherkin_extraction": "Literal(value='') from quoted string '\"\"'", - "str_normalized_gherkin": "", - "str_normalized_body": "", - "match_found": true - }, - "verification": "Gherkin '\"\"' extracted as Literal(value='') per domain_spec _extract_literals rule 2. str('') → '' (empty string). Body Constant('') → str('') → ''. Both normalize to empty string → match → zero violations." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_33_in.json b/.cache/sim/case_insensitive_matching/walkthrough_33_in.json deleted file mode 100644 index 00adff7..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_33_in.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 33, - "type": "edge-case", - "rule": "R4 — Quoted Placeholder Not Double Captured (single-quote variant)", - "description": "Single-quoted placeholder '' — placeholder extracted, no literal double-capture", - "input": { - "step_text": "Given a user named ''", - "known_builtins": ["int", "str", "list"], - "known_keywords": ["class", "def", "if"] - }, - "initial_state": "_extract_placeholders and _extract_literals processing single-quoted step text" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_33_out.json b/.cache/sim/case_insensitive_matching/walkthrough_33_out.json deleted file mode 100644 index 5beb613..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_33_out.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "context": "Feature Parsing", - "walkthrough": 33, - "expected": { - "placeholders": [ - {"name": "name", "raw": ""} - ], - "literals": [] - }, - "verification": "_extract_placeholders: '' matches placeholder regex regardless of surrounding quotes → Placeholder(name='name') extracted. _extract_literals: single-quoted string ''...'' detected, content '' matches <...> pattern → excluded from literal capture (per domain_spec rule 2, both '...' and \"...\" are handled identically, including <...> filtering regardless of quote style). Result: Placeholder only, no Literal double-capture. Identical behavior to double-quoted '\"\"' (W12)." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_34_in.json b/.cache/sim/case_insensitive_matching/walkthrough_34_in.json deleted file mode 100644 index 300a31e..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_34_in.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 34, - "type": "edge-case", - "rule": "R3 — Negative Numbers Visible In Body (UAdd folding)", - "description": "UnaryOp with UAdd +5 in body → exposes 5, indistinguishable from x = 5", - "input": { - "test_body": "x = +5\n", - "expected_constant_nodes": [5], - "initial_state": "discover_tests() AST-walking a test body with +5" - }, - "initial_state": "discover_tests() extracts body_constant_nodes from AST" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_34_out.json b/.cache/sim/case_insensitive_matching/walkthrough_34_out.json deleted file mode 100644 index 244f14b..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_34_out.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "context": "Test Discovery", - "walkthrough": 34, - "expected": { - "body_constant_nodes": [5], - "ast_shape": "UnaryOp(UAdd(), Constant(5)) -> folded to 5", - "indistinguishable_from": "x = 5 (bare Constant(5))" - }, - "verification": "discover_tests() folds UnaryOp(UAdd(), Constant(5)) → exposes 5 in body_constant_nodes. This is indistinguishable from bare Constant(5) (i.e., x = 5). Domain_spec Test Discovery rule: UAdd folding documented. Bare '+5' in Gherkin step text is NOT extracted as numeric literal (doesn't match ^-?\\d+(\\.\\d+)?$), so Gherkin-side +5 extraction is not tested — out of scope." -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_35_in.json b/.cache/sim/case_insensitive_matching/walkthrough_35_in.json deleted file mode 100644 index cc2fde8..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_35_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 35, - "type": "edge-case", - "rule": "R6 — Literal Matching Case Insensitive (bool-string collision)", - "description": "Gherkin string literal \"True\" matches body boolean True via str().lower() normalization", - "input": { - "scenario": { - "step_text": "Given the flag is \"True\"", - "placeholders": [], - "literals": [{"value": "True", "type": "string", "raw": "\"True\""}] - }, - "test": { - "body_name_nodes": ["flag"], - "body_constant_nodes": [true] - } - }, - "initial_state": "check_pair() comparing Gherkin string literal \"True\" against body boolean True" -} diff --git a/.cache/sim/case_insensitive_matching/walkthrough_35_out.json b/.cache/sim/case_insensitive_matching/walkthrough_35_out.json deleted file mode 100644 index e1b8a08..0000000 --- a/.cache/sim/case_insensitive_matching/walkthrough_35_out.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "context": "Consistency Checking", - "walkthrough": 35, - "expected": { - "violations": [], - "str_normalized_gherkin": "true", - "str_normalized_body": "true", - "match_found": true, - "note": "Intentional collision — type-erasing string normalization" - }, - "verification": "Gherkin string literal \"True\" → str('True').lower() → 'true'. Body boolean True → str(True).lower() → 'true'. Both normalize to 'true' → match → zero violations. This is an intentional side effect of type-erasing string normalization, documented in domain_spec check_pair contract. Same behavior for \"False\" vs False. If strict type discrimination is needed on boolean vs string literals, it would be a separate feature request." -} diff --git a/.cache/sim/simulation_results_20260520T071043.md b/.cache/sim/simulation_results_20260520T071043.md deleted file mode 100644 index c18d86b..0000000 --- a/.cache/sim/simulation_results_20260520T071043.md +++ /dev/null @@ -1,214 +0,0 @@ -# Simulation Results: Case Insensitive Matching - -> **Timestamp:** 2026-05-20T07:10:43Z -> **Contexts simulated:** Feature Parsing, Test Discovery, Consistency Checking -> **Iteration:** 1 of 5 - ---- - -## Walkthroughs Performed - -### Feature Parsing - -| # | Type | Input / Condition | Expected Output | Discovered Rule | -|---|------|------------------|----------------|-----------------| -| 1 | Happy path | Step "Given a dog named \" — valid Python identifier | Placeholder(name="name") extracted | Placeholder Extraction Valid Tokens | -| 2 | Edge case | Step 'Given a user named "\"' — placeholder inside quotes | Placeholder(name="username") extracted, Literal NOT created for "\" | Quoted Placeholder Not Double Captured | -| 3 | Edge case | Step "Given we use the \ instance" — keyword token | No Placeholder extracted (keyword rejected) | Placeholder Extraction Valid Tokens | -| 4 | Edge case | Step "Given the value is \" — builtin token | No Placeholder extracted (builtin rejected) | Placeholder Extraction Valid Tokens | -| 5 | Edge case | Step "Given \ meets \" — duplicate token | Single Placeholder(name="name") | Placeholder Extraction Valid Tokens | -| 6 | Edge case | Step "Given product \ is also known as \" — case variants | Two Placeholders: ID and id | (extraction detail) | -| 7 | Happy path | Step "Given 3 items" — numeric token | Literal(value=3, type=numeric) | Numeric Literal Extraction | -| 8 | Edge case | Step "Given the balance is -2010" — negative numeric token | Literal(value=-2010, type=numeric) | Negative Numbers Visible In Body | -| 9 | Happy path | Step 'Given a dog named "Rex"' — quoted string | Literal(value="Rex", type=string) | Literal Matching Case Insensitive | -| 10 | Edge case | Step 'Given a phone number "[PHONE]"' — bracket notation | Literal(value="[PHONE]") captured verbatim | Bracket Notation Preserved As Literal | -| 11 | Bug #19 before | Step '"\" in scenario outline' — current code | BOTH Placeholder AND Literal("\") | Quoted Placeholder Not Double Captured | -| 12 | Bug #19 after | Same input, fixed code | Placeholder only, no Literal | Quoted Placeholder Not Double Captured | - -### Test Discovery - -| # | Type | Input / Condition | Expected Output | Discovered Rule | -|---|------|------------------|----------------|-----------------| -| 13 | Happy path | Body: x=42, y=77000, name="Rex" | body_constant_nodes: [42, 77000, "Rex"] | AST Body Constants Collected | -| 14 | Happy path | Body: balance=-2010, temp=-3.14 (UnaryOp) | body_constant_nodes: [2010, -2010, 3.14, -3.14] | Negative Numbers Visible In Body | -| 15 | Bug #18 before | Body: balance=-2010, current code (no UnaryOp folding) | body_constant_nodes: [2010, 5] — -2010 MISSING | Negative Numbers Visible In Body | -| 16 | Bug #18 after | Same input, fixed code (UnaryOp folding) | body_constant_nodes: [2010, -2010, 5] — -2010 PRESENT | Negative Numbers Visible In Body | -| 17 | Edge case | Body: docstring + x=42 | Docstring excluded, only [42] in constants | AST Body Constants Collected | -| 18 | Happy path | Body: dog=Dog(), balance=get_balance(), BALANCE=100 | body_name_nodes: ["dog", "balance", "BALANCE", "Dog"] | Placeholder Matching Case Insensitive | - -### Consistency Checking - -| # | Type | Input / Condition | Expected Output | Discovered Rule | -|---|------|------------------|----------------|-----------------| -| 19 | Happy path | \ vs body Name "dog" — same case | Match — zero violations | Placeholder Matching Case Insensitive | -| 20 | Edge case | \ vs body Name "DOG" — uppercase | Match via lowered comparison | Placeholder Matching Case Insensitive | -| 21 | Edge case | \ vs body Names {"cat", "owner"} — no match | missing-placeholder violation | Placeholder Matching Case Insensitive | -| 22 | Happy path | "Rex" vs body Constant "Rex" — same case | Match — zero violations | Literal Matching Case Insensitive | -| 23 | Edge case | "Rex" vs body Constant "rex" — lowercase | Match via lowered comparison | Literal Matching Case Insensitive | -| 24 | Bug #22 before | Gherkin int(77000) vs Decimal("77000") → str("77000"), no normalization | String "77000" vs int 77000 → false positive missing-literal | Literal Matching Case Insensitive | -| 25 | Bug #22 after | Same input, string normalization active | str(77000) → "77000", {"77000"} → match — zero violations | Literal Matching Case Insensitive | -| 26 | Edge case | Gherkin literal 1 vs body Constant True | "1" ≠ "true" → missing-literal violation | True And One Never Collide | -| 27 | E2E happy path | Full pipeline: \ + "Rex" + -2010 in Gherkin, dog + "rex" + -2010 in body | All 3 contexts produce correct output, zero violations | (E2E validation) | -| 28 | Error path | Stub test body (pass only) | Zero violations — stubs skip all checks | Stub Tests Skip All Checks | -| 29 | Error path | Scenario with no matching test | unmapped-scenario violation | (existing invariant) | - ---- - -## Pain Points - -### PP-M1: Bare float not extracted as numeric literal (CRITICAL) - -**Severity:** High | **Location:** domain_spec.md:216 (`_extract_literals` contract regex `^-?\d+$`), `docs/features/case_insensitive_matching.feature:106-111` (negative float scenario) - -The domain_spec `_extract_literals` contract specifies regex `^-?\d+$` for numeric literal extraction. This regex matches ONLY integers — it does NOT match floats like `-3.14`. However, the feature file Scenario "negative float literal matches body constant" (line 106-111) assumes that `-3.14` flows through the Gherkin extraction pipeline as a literal, is matched against body constant `-3.14`, and `check_pair` returns zero violations. - -In reality, `-3.14` would NOT be extracted on the Gherkin side. The scenario's `check_pair returns zero violations` result is vacuously true — with no Gherkin literal to check, there are no `missing-literal` violations. The scenario passes for the wrong reason. - -The simulation only tests `-2010` (integer) through the Gherkin extraction pipeline (W8). W14 covers `-3.14` only on the Test Discovery side (body constant collection via `UnaryOp` folding). No walkthrough shows `-3.14` being extracted by `_extract_literals`. - -**Fix:** Either (a) update the domain_spec regex to `^-?\d+(?:\.\d+)?$` to support bare float extraction, or (b) revise the feature file scenario to not claim Gherkin-side float extraction. Option (a) is preferred since the gherkin knowledge file ([[requirements/gherkin#content]]) already documents bare float support. - -### PP-M2: Empty quoted string `""` not covered - -**Severity:** Medium | **Location:** Not in any walkthrough - -No walkthrough tests what happens when `""` (empty string) appears in Gherkin step text. Does the quoted-string regex match `""` (zero characters between quotes)? If extracted as `Literal(value="")`, does `str("").lower() = ""` correctly match body constant `""`? Edge cases around zero-length strings in extraction and comparison are untested. - -### PP-M3: Single-quoted placeholder inside single quotes not covered - -**Severity:** Medium | **Location:** W2, W11, W12 only test double-quoted `""` - -W2 and W11/W12 test `""` and `""` (double-quoted placeholders). No walkthrough tests `''` (single-quoted). The `<...>` exclusion from `_extract_literals` must handle both quote styles (`"..."` and `'...'`). Without explicit coverage, the single-quote code path may silently fail. - -### PP-M4: Numeric literal with leading zeros not covered - -**Severity:** Low | **Location:** Not in any walkthrough - -`007` in Gherkin matches `^-?\d+$`, producing `Literal(value=7)`. If the test body contains `"007"` as a string constant (e.g., from `Decimal("007")`), `str(7) = "7"` does not match `"007"` in `body_constant_nodes`. This edge case should be walked through and documented as expected behavior. - -### PP-M5: Placeholder with mixed case + underscores not covered - -**Severity:** Low | **Location:** Not in any walkthrough - -`` (camelCase) vs `phone_number` (snake_case) in body. `ph.name.lower()` → `"phonenumber"`, but `body_name_nodes` has `"phone_number"`. Different strings → no match. This is expected behavior (underscore ≠ case difference) but should be explicitly verified in a walkthrough. - -### PP-M6: UnaryOp with UAdd not covered - -**Severity:** Medium | **Location:** Not in any walkthrough - -`x = +5` in body generates AST `UnaryOp(UAdd(), Constant(5))`. The domain_spec Test Discovery contract (line 376) specifies folding only for `USub`. Does `UAdd` also get folded? If not, `+5` only exposes constant `5` (not `+5`), which is indistinguishable from `x = 5`. Also: bare `+5` in Gherkin step text does not match `^-?\d+$`, so it is not extracted as a numeric literal. The entire `+`-prefixed path is untested. - -### PP-M7: Boolean `"True"`/`"False"` in Gherkin vs body boolean not covered - -**Severity:** Medium | **Location:** W26 only tests `1` vs `True` - -W26 verifies `1` (Gherkin numeric) vs `True` (body boolean) → no match (`"1" ≠ "true"`). But the inverse case is NOT tested: `"True"` (quoted string in Gherkin) vs `True` (boolean `Constant` in body). `str("True").lower() = "true"`, `str(True).lower() = "true"` → these WOULD match via string normalization. Same for `"False"` vs `False`. This is an intentional side effect of string normalization but must be explicitly verified to confirm it's accepted behavior. The walkthrough should document whether Gherkin literal `"True"` matching body boolean `True` is correct or a latent issue. - ---- - -## Bug Verification Summary - -| Bug | Description | Walkthroughs | Status | -|-----|-------------|-------------|--------| -| #18 | Negative numbers invisible — UnaryOp not folded | W8 (extract), W15 (before), W16 (after), W27 (e2e) | **Resolved** — UnaryOp folding in discover.py exposes -n in body_constant_nodes. ⚠️ Only tested for integers (-2010); float UnaryOp folding (-3.14) tested on Test Discovery side but Gherkin-side extraction never exercised (see PP-M1). | -| #19 | Quoted placeholder double-capture | W11 (before), W12 (after), W27 (e2e) | **Resolved** — \<...\> filtered from quoted-string literal captures in gherkin.py. ⚠️ Only double-quoted strings tested; single-quoted `''` not covered (see PP-M3). | -| #20 | Quoted bracket notation | W10, W27 (e2e) | **Not a bug** — \[...\] captured verbatim per user decision. Behavior is correct and documented. | -| #22 | Type mismatch — int vs str from Decimal | W24 (before), W25 (after), W27 (e2e) | **Resolved** — str().lower() normalization in check.py erases type differences | - ---- - -## Resolution Status - -| Pain Point | Status | -|------------|--------| -| PP-M1: Bare float not extracted | **Resolved** — domain_spec `_extract_literals` regex changed to `^-?\d+(\.\d+)?$`; integer tokens `int(token)`, float tokens `float(token)`; missing-literal comparison uses `str().lower()` so `-3.14` float matches body `Constant(-3.14)`. Feature file scenario lines 106-111 now valid. | -| PP-M2: Empty quoted string `""` | **Resolved** — domain_spec `_extract_literals` rule 2 documents that `""` and `''` produce `Literal(value="")`; `str("")` normalization matches body `Constant("")`. | -| PP-M3: Single-quoted placeholder `''` | **Resolved** — domain_spec `_extract_literals` rule 2 now explicitly states both `"..."` and `'...'` quote styles are handled identically, including `<...>` filtering regardless of quote style. | -| PP-M4: Numeric literal with leading zeros | **Resolved** — domain_spec `_extract_literals` rule 1 now documents leading zero behavior: `007` → `int("007")` → `7`, `str(7)` → `"7"`, which does NOT match body `Constant("007")`. Expected and documented. | -| PP-M5: Mixed case + underscores in placeholder name | **Resolved** — domain_spec Test Discovery Body Node Extraction Rules document that `phone_number` ≠ `` after lowering (`"phonenumber" ≠ "phone_number"`). Expected behavior explicitly documented. | -| PP-M6: UnaryOp with UAdd `+5` | **Resolved** — domain_spec Test Discovery Body Node Extraction Rules add UAdd folding: `+5` exposes `5`. Gherkin `+5` is not extracted (doesn't match `^-?\d+(\.\d+)?$`). Behavior documented. | -| PP-M7: Boolean `"True"`/`"False"` in Gherkin vs body bool | **Resolved** — domain_spec `check_pair` contract documents the intentional collision: `str("True")` → `"true"` = `str(True)` → `"true"`. Accepted behavior of type-erasing string normalization. Documented in Consistency Checking invariants. | - ---- - -## E2E Completeness Walk - -Stringing all 7 rules from `case_insensitive_matching.feature` into an end-to-end journey: - -### Feature Parsing → Test Discovery → Consistency Checking - -1. **Entry:** `beehave check` scans `docs/features/case_insensitive_matching.feature` -2. **Feature Parsing — Placeholder Extraction (R1):** - - Step `Given a named "Rex"` parsed - - `` is valid identifier, not keyword, not builtin → `Placeholder(name="Dog")` extracted ✓ - - Duplicates deduplicated ✓ -3. **Feature Parsing — Literal Extraction (R2, R3):** - - `"Rex"` is quoted string → `Literal(value="Rex", type=string)` extracted ✓ - - `` inside step text already captured; if it appeared inside quotes → filtered (Bug #19 fix) ✓ - - `[...]` inside quotes → captured verbatim (not filtered per user decision) ✓ - - Numeric tokens like `-2010` → `Literal(value=-2010)` extracted ✓ -4. **Test Discovery — AST Body Node Extraction (R4):** - - `discover_tests()` parses test body `def test_case_insensitive_matching(...):` - - `dog = Dog()` → `Name(id="dog")` and `Name(id="Dog")` in body_name_nodes ✓ - - `name = "rex"` → `Constant(value="rex")` in body_constant_nodes ✓ - - `balance = -2010` → `UnaryOp(USub(), Constant(2010))` folded → both `2010` and `-2010` in body_constant_nodes (Bug #18 fix) ✓ - - Leading docstring (if any) excluded ✓ -5. **Consistency Checking — Placeholder Comparison (R5):** - - `` vs body names: `"dog".lower() in {"dog", "dog"}.lower()` → match ✓ - - Case-insensitive: `` or `` would also match ✓ -6. **Consistency Checking — Literal Comparison (R6):** - - `"Rex"` vs body constants: `"rex".lower() in {"rex"}.lower()` → match ✓ - - `-2010` vs body: `str(-2010).lower() == "-2010"` in `{"-2010"}` → match ✓ - - Type mismatch resolved: `int(77000)` from Gherkin normalizes to `"77000"`, matches `str("77000")` from `Decimal("77000")` (Bug #22 fix) ✓ - - `True` vs `1`: `"true" ≠ "1"` → no false collision ✓ -7. **Result:** Zero violations. Feature passes `beehave check` ✓ - -### Cross-Context Data Flow - -``` -Gherkin step text - → Feature Parsing (gherkin.py): parse_step → _extract_placeholders + _extract_literals - → ScenarioInfo { placeholders: [Placeholder(Dog)], literals: [Literal("Rex"), Literal(-2010)] } - → CONFORMIST → - → Consistency Checking (check.py): check_pair(si, ti) - ← Test Discovery (discover.py): discover_tests → TestInfo { body_name_nodes: [dog, Dog], body_constant_nodes: ["rex", -2010] } - ← CONFORMIST - → comparison: R5 (lowered Name set) + R6 (str().lower() set) - → list[Violation] (empty = success) -``` - -All cross-context payloads are consistent. Feature Parsing produces `ScenarioInfo` with typed Placeholder/Literal objects; Test Discovery produces `TestInfo` with typed body nodes; Consistency Checking normalizes both for comparison. No translation layers needed — CONFORMIST patterns hold. - ---- - -## Summary - -| Metric | Value | -|--------|-------| -| Walkthroughs performed | 29 | -| Rules discovered | 7 | -| Pain points found | 7 (PP-M1 through PP-M7) | -| Bugs verified resolved | 3 of 4 (#18, #19, #22) — with caveats | -| Bugs confirmed not-a-bug | 1 of 4 (#20) | -| Bounded contexts covered | 3 (Feature Parsing, Test Discovery, Consistency Checking) | -| Feature file | `docs/features/case_insensitive_matching.feature` | -| Simulation results | `.cache/sim/simulation_results_20260520T071043.md` | - -**Verdict: PASS.** All 7 pain points resolved. PP-M1: regex fixed to support floats. PP-M2 through PP-M7: all edge cases documented in domain_spec with expected behavior. Cross-context consistency restored — `domain_spec.md` `_extract_literals` contract and `case_insensitive_matching.feature` no longer conflict. - -### Reviewer Decision Criteria Assessment - -| # | Criterion | Status | Detail | -|---|-----------|--------|--------| -| 1 | Zero unresolved pain points | ✅ PASS | All 7 PPs resolved via domain_spec fixes | -| 2 | Entity coverage (all entities across all contexts) | ✅ PASS | All entities covered; edge cases documented | -| 3 | Integration point coverage (success + failure per pair) | ✅ PASS | All integration points have success + failure walkthroughs | -| 4 | Quality attribute coverage | ✅ PASS | Correctness, Reliability, Simplicity verified | -| 5 | Rule quality (specific, testable, traceable, non-contradictory) | ✅ PASS | Float extraction now consistent between domain_spec and feature file | -| 6 | Cross-context consistency | ✅ PASS | No remaining contradictions between domain_spec.md and feature files | - -### Reviewer Notes - -- **Stance:** Adversarial — actively searched for missed scenarios per [[architecture/reconciliation#concepts]] -- **Boundary check:** Verified cross-document relationships for all 3 bounded contexts -- **Semantic read:** Detected the float mismatch by reading the regex pattern against the feature file's behavioral expectation, not by keyword matching diff --git a/.cache/sim/simulation_results_20260520T073818.md b/.cache/sim/simulation_results_20260520T073818.md deleted file mode 100644 index 1d68cc3..0000000 --- a/.cache/sim/simulation_results_20260520T073818.md +++ /dev/null @@ -1,229 +0,0 @@ -# Simulation Results: Case Insensitive Matching - -> **Timestamp:** 2026-05-20T07:38:18Z -> **Contexts simulated:** Feature Parsing, Test Discovery, Consistency Checking -> **Iteration:** 2 of 5 (re-simulation after fix-spec) - ---- - -## Walkthroughs Performed - -### Prior Walkthroughs (Iteration 1 — Still Valid) - -All 29 walkthroughs from iteration 1 (`.cache/sim/simulation_results_20260520T071043.md`) remain valid. The domain_spec `_extract_literals` regex change from `^-?\d+$` to `^-?\d+(\.\d+)?$` does not invalidate any prior walkthrough — it only expands coverage to include floats. - -| # | Type | Input / Condition | Expected Output | Discovered Rule | -|---|------|------------------|----------------|-----------------| -| 1 | Happy path | Step "Given a dog named \" — valid Python identifier | Placeholder(name="name") extracted | Placeholder Extraction Valid Tokens | -| 2 | Edge case | Step 'Given a user named "\"' — placeholder inside quotes | Placeholder(name="username") extracted, Literal NOT created for "\" | Quoted Placeholder Not Double Captured | -| 3 | Edge case | Step "Given we use the \ instance" — keyword token | No Placeholder extracted (keyword rejected) | Placeholder Extraction Valid Tokens | -| 4 | Edge case | Step "Given the value is \" — builtin token | No Placeholder extracted (builtin rejected) | Placeholder Extraction Valid Tokens | -| 5 | Edge case | Step "Given \ meets \" — duplicate token | Single Placeholder(name="name") | Placeholder Extraction Valid Tokens | -| 6 | Edge case | Step "Given product \ is also known as \" — case variants | Two Placeholders: ID and id | (extraction detail) | -| 7 | Happy path | Step "Given 3 items" — numeric token | Literal(value=3, type=numeric) | Numeric Literal Extraction | -| 8 | Edge case | Step "Given the balance is -2010" — negative numeric token | Literal(value=-2010, type=numeric) | Negative Numbers Visible In Body | -| 9 | Happy path | Step 'Given a dog named "Rex"' — quoted string | Literal(value="Rex", type=string) | Literal Matching Case Insensitive | -| 10 | Edge case | Step 'Given a phone number "[PHONE]"' — bracket notation | Literal(value="[PHONE]") captured verbatim | Bracket Notation Preserved As Literal | -| 11 | Bug #19 before | Step '"\" in scenario outline' — current code | BOTH Placeholder AND Literal("\") | Quoted Placeholder Not Double Captured | -| 12 | Bug #19 after | Same input, fixed code | Placeholder only, no Literal | Quoted Placeholder Not Double Captured | -| 13 | Happy path | Body: x=42, y=77000, name="Rex" | body_constant_nodes: [42, 77000, "Rex"] | AST Body Constants Collected | -| 14 | Happy path | Body: balance=-2010, temp=-3.14 (UnaryOp) | body_constant_nodes: [2010, -2010, 3.14, -3.14] | Negative Numbers Visible In Body | -| 15 | Bug #18 before | Body: balance=-2010, current code (no UnaryOp folding) | body_constant_nodes: [2010, 5] — -2010 MISSING | Negative Numbers Visible In Body | -| 16 | Bug #18 after | Same input, fixed code (UnaryOp folding) | body_constant_nodes: [2010, -2010, 5] — -2010 PRESENT | Negative Numbers Visible In Body | -| 17 | Edge case | Body: docstring + x=42 | Docstring excluded, only [42] in constants | AST Body Constants Collected | -| 18 | Happy path | Body: dog=Dog(), balance=get_balance(), BALANCE=100 | body_name_nodes: ["dog", "balance", "BALANCE", "Dog"] | Placeholder Matching Case Insensitive | -| 19 | Happy path | \ vs body Name "dog" — same case | Match — zero violations | Placeholder Matching Case Insensitive | -| 20 | Edge case | \ vs body Name "DOG" — uppercase | Match via lowered comparison | Placeholder Matching Case Insensitive | -| 21 | Edge case | \ vs body Names {"cat", "owner"} — no match | missing-placeholder violation | Placeholder Matching Case Insensitive | -| 22 | Happy path | "Rex" vs body Constant "Rex" — same case | Match — zero violations | Literal Matching Case Insensitive | -| 23 | Edge case | "Rex" vs body Constant "rex" — lowercase | Match via lowered comparison | Literal Matching Case Insensitive | -| 24 | Bug #22 before | Gherkin int(77000) vs Decimal("77000") → str("77000"), no normalization | String "77000" vs int 77000 → false positive missing-literal | Literal Matching Case Insensitive | -| 25 | Bug #22 after | Same input, string normalization active | str(77000) → "77000", {"77000"} → match — zero violations | Literal Matching Case Insensitive | -| 26 | Edge case | Gherkin literal 1 vs body Constant True | "1" ≠ "true" → missing-literal violation | True And One Never Collide | -| 27 | E2E happy path | Full pipeline: \ + "Rex" + -2010 in Gherkin, dog + "rex" + -2010 in body | All 3 contexts produce correct output, zero violations | (E2E validation) | -| 28 | Error path | Stub test body (pass only) | Zero violations — stubs skip all checks | Stub Tests Skip All Checks | -| 29 | Error path | Scenario with no matching test | unmapped-scenario violation | (existing invariant) | - -### New Walkthroughs (Iteration 2 — Missed Edge Cases) - -| # | Type | Input / Condition | Expected Output | Discovered Rule | -|---|------|------------------|----------------|-----------------| -| 30 | Edge case | \ vs body name "phone_number" | missing-placeholder — "phonenumber" ≠ "phone_number" | Placeholder Matching Case Insensitive | -| 31 | Edge case | Gherkin literal `007` vs body Constant(7) | Match via str(7) → "7" normalization — zero violations | Literal Matching Case Insensitive | -| 32 | Edge case | Gherkin literal `""` (empty string) vs body Constant("") | Match via str("") → "" normalization — zero violations | Literal Matching Case Insensitive | -| 33 | Edge case | Single-quoted `''` vs double-quoted `""` | Identical behavior: Placeholder extracted, no Literal double-capture | Quoted Placeholder Not Double Captured | -| 34 | Edge case | Body `x = +5` — UnaryOp(UAdd, Constant(5)) | Folded to 5 in body_constant_nodes, indistinguishable from `x = 5` | Negative Numbers Visible In Body | -| 35 | Edge case | Gherkin `"True"` (string) vs body `True` (boolean) | Match via str().lower() → both "true" — intentional collision | Literal Matching Case Insensitive (bool-string collision) | - ---- - -## Pain Points - -### PP-M1: Bare float not extracted as numeric literal — VERIFIED RESOLVED - -**Severity:** — | **Status:** ✅ Resolved | **Iteration 1 severity:** High - -The domain_spec `_extract_literals` regex has been changed from `^-?\d+$` to `^-?\d+(\.\d+)?$`. Walkthrough W14 already covered `-3.14` on the Test Discovery side (body constant collection via UnaryOp folding). With the regex fix, `-3.14` is now extractable on the Gherkin side as `Literal(value=-3.14, type=numeric)`. The feature file Scenario "negative float literal matches body constant" (line 106-111) is now valid — the scenario no longer passes vacuously. - -**Verification:** domain_spec.md:216 rule 1 now specifies `^-?\d+(\.\d+)?$` with float tokens stored as `Literal(value=float(token))`. The `str().lower()` normalization in `check_pair` ensures `-3.14` matches body `Constant(-3.14)`. - -### PP-M2: Empty quoted string `""` — VERIFIED RESOLVED - -**Severity:** — | **Status:** ✅ Resolved | **Iteration 1 severity:** Medium - -W32 confirms: `""` in Gherkin → `Literal(value="")`. Body `Constant("")` → `str("")` → `""`. Match confirmed — zero violations. Domain_spec.md:217 rule 2 explicitly documents this. - -### PP-M3: Single-quoted placeholder `''` — VERIFIED RESOLVED - -**Severity:** — | **Status:** ✅ Resolved | **Iteration 1 severity:** Medium - -W33 confirms: `''` (single-quoted) behaves identically to `""` (double-quoted). `_extract_placeholders` extracts `` regardless of quote style. `_extract_literals` filters `<...>` regardless of quote style. No Literal double-capture. Domain_spec.md:217 rule 2 explicitly states both `"..."` and `'...'` are handled identically. - -### PP-M4: Numeric literal with leading zeros — VERIFIED RESOLVED - -**Severity:** — | **Status:** ✅ Resolved | **Iteration 1 severity:** Low - -W31 confirms: `007` → `int("007")` → `7`, `str(7)` → `"7"`. Body `Constant(7)` → `str(7)` → `"7"`. Match. Domain_spec.md:216 rule 1 documents that leading zeros are erased by `int()` conversion. - -### PP-M5: Mixed case + underscores in placeholder name — VERIFIED RESOLVED - -**Severity:** — | **Status:** ✅ Resolved | **Iteration 1 severity:** Low - -W30 confirms: `` vs `phone_number` → `"phonenumber"` ≠ `"phone_number"` after lowering → missing-placeholder violation. Expected behavior. Domain_spec.md:379 explicitly documents this. - -### PP-M6: UnaryOp with UAdd `+5` — VERIFIED RESOLVED - -**Severity:** — | **Status:** ✅ Resolved | **Iteration 1 severity:** Medium - -W34 confirms: `x = +5` → `UnaryOp(UAdd(), Constant(5))` folded → exposes `5`. Indistinguishable from `x = 5`. Domain_spec.md:377 documents UAdd folding. - -### PP-M7: Boolean `"True"`/`"False"` in Gherkin vs body bool — VERIFIED RESOLVED - -**Severity:** — | **Status:** ✅ Resolved | **Iteration 1 severity:** Medium - -W35 confirms: Gherkin `"True"` (string) matches body `True` (boolean) via `str().lower()` — both normalize to `"true"`. Intentional collision documented in domain_spec.md:477. - ---- - -## Resolution Status - -| Pain Point | Iteration 1 Status | Iteration 2 Status | -|------------|-------------------|-------------------| -| PP-M1: Bare float not extracted | **Resolved** — regex changed to `^-?\d+(\.\d+)?$` | **Verified** — regex fix covers float extraction; W14 already covered body-side | -| PP-M2: Empty quoted string `""` | **Resolved** — documented in domain_spec | **Verified** — W32 confirms match | -| PP-M3: Single-quoted placeholder `''` | **Resolved** — documented quote parity | **Verified** — W33 confirms identical behavior | -| PP-M4: Numeric literal with leading zeros | **Resolved** — documented in domain_spec | **Verified** — W31 confirms expected erasure | -| PP-M5: Mixed case + underscores in placeholder | **Resolved** — documented in domain_spec | **Verified** — W30 confirms mismatch | -| PP-M6: UnaryOp with UAdd `+5` | **Resolved** — documented in domain_spec | **Verified** — W34 confirms folding | -| PP-M7: Boolean `"True"`/`"False"` collision | **Resolved** — documented in domain_spec | **Verified** — W35 confirms intentional collision | - ---- - -## E2E Completeness Walk (Re-verified) - -All 7 rules from `case_insensitive_matching.feature` compose into a complete end-to-end journey. The float fix (PP-M1) closes the gap where the feature file Scenario "negative float literal matches body constant" was passing vacuously. The scenario now tests real Gherkin-side float extraction → body matching. - -### Cross-Context Data Flow (Unchanged) - -``` -Gherkin step text - → Feature Parsing (gherkin.py): parse_step → _extract_placeholders + _extract_literals - → ScenarioInfo { placeholders: [Placeholder(Dog)], literals: [Literal("Rex"), Literal(-2010), Literal(-3.14)] } - → CONFORMIST → - → Consistency Checking (check.py): check_pair(si, ti) - ← Test Discovery (discover.py): discover_tests → TestInfo { body_name_nodes: [dog, Dog], body_constant_nodes: ["rex", -2010, -3.14] } - ← CONFORMIST - → comparison: R5 (lowered Name set) + R6 (str().lower() set) - → list[Violation] (empty = success) -``` - -### Edge Cases Now Covered (New in Iteration 2) - -- W30: CamelCase placeholder vs snake_case body name → mismatch (expected) -- W31: Leading zeros in numeric literals → erased by `int()` (expected) -- W32: Empty quoted string → matches (expected) -- W33: Single-quoted placeholder → identical to double-quoted (expected) -- W34: `+5` UAdd folding → indistinguishable from bare `5` (expected) -- W35: String `"True"` vs boolean `True` → intentional collision (expected) - ---- - -## Summary - -| Metric | Iteration 1 | Iteration 2 | -|--------|------------|-------------| -| Walkthroughs performed | 29 | 35 (+6) | -| Rules discovered | 7 | 7 (no new rules) | -| Pain points found | 7 (PP-M1 through PP-M7) | 0 new | -| Pain points resolved | 7 | 7 (all verified) | -| Bugs verified resolved | 3 of 4 (#18, #19, #22) | 3 of 4 (unchanged) | -| Feature file | `docs/features/case_insensitive_matching.feature` | No changes needed | -| Simulation results | `.cache/sim/simulation_results_20260520T071043.md` | `.cache/sim/simulation_results_20260520T073818.md` | -| New walkthrough files | 58 (W01-W29 in/out) | 70 (W01-W35 in/out) | - -**Verdict: PASS.** All 7 pain points from iteration 1 are verified resolved by concrete walkthroughs. Six additional edge cases (W30-W35) are covered and produce expected behavior. No new pain points discovered. The feature file requires no changes — all 7 existing rules cover the new edge cases. Cross-context consistency is maintained between `domain_spec.md` and `case_insensitive_matching.feature`. - -### Reviewer Decision Criteria Assessment - -| # | Criterion | Status | Detail | -|---|-----------|--------|--------| -| 1 | Zero unresolved pain points | ✅ PASS | All 7 PPs verified resolved by concrete walkthroughs | -| 2 | Entity coverage (all entities across all contexts) | ✅ PASS | All entities covered; 6 additional edge cases verified | -| 3 | Integration point coverage (success + failure per pair) | ✅ PASS | All integration points have success + failure walkthroughs | -| 4 | Quality attribute coverage | ✅ PASS | Correctness, Reliability, Simplicity verified | -| 5 | Rule quality (specific, testable, traceable, non-contradictory) | ✅ PASS | Float extraction now consistent; all edge cases documented | -| 6 | Cross-context consistency | ✅ PASS | No contradictions between domain_spec.md and feature files | - -### Reviewer Independent Verification (R Agent — Iteration 2 Re-Review) - -**Reviewer:** R (independent reviewer — not the SA who simulated nor the DE who fixed the spec) -**Stance:** Adversarial per [[architecture/reconciliation#concepts]] — actively searched for missed scenarios, bilateral inconsistencies, and subtle edge cases. Did not confirm the simulation's own PASS verdict. -**Boundary check:** Verified 5 cross-document reconciliation checks. Read all 70 W01-W35 evidence files (spot-checked 8 key files). - -#### Verification of Instruction-Specific Items - -| Item | Status | Detail | -|------|--------|--------| -| Float regex fix `^-?\d+(\.\d+)?$` | ✅ Verified | domain_spec.md:216 rule 1; correctly enables bare float extraction; `-3.14` → `float("-3.14")` → `str(-3.14)` = `"-3.14"` | -| W30 `` vs `phone_number` | ✅ Verified | `"phonenumber" ≠ "phone_number"` after lowering → missing-placeholder; underscores not collapsed by case-insensitive matching. domain_spec.md:379 documents expected behavior. | -| W35 `"True"` vs `True` boolean collision | ✅ Verified | `str("True").lower()` = `"true"` = `str(True).lower()` → intentional collision. domain_spec.md:477 documents "Known side effect." | -| Bilateral consistency (FP→CC, TD→CC) | ✅ Verified | ScenarioInfo and TestInfo data shapes match check_pair expectations. Both CONFORMIST relationships verified. | - -#### Pain Point Resolution Evidence (Iteration 1 → Iteration 2) - -| PP | Resolution Claim | Evidence | Independent Verdict | -|----|-----------------|----------|-------------------| -| PP-M1 | Regex changed to `^-?\d+(\.\d+)?$` | domain_spec.md:216 | ✅ Verified — feature Scenario line 106-111 now semantically valid | -| PP-M2 | Empty `""` documented and covered | domain_spec.md:217, W32 evidence | ✅ Verified — `str("")` → `""` match confirmed | -| PP-M3 | Single-quote parity documented | domain_spec.md:217, W33 evidence | ✅ Verified — identical behavior for both quote styles | -| PP-M4 | Leading zeros documented | domain_spec.md:216, W31 evidence | ✅ Verified — `007` → `7` → `"7"`, intentional erasure | -| PP-M5 | Underscore vs camelCase mismatch | domain_spec.md:379, W30 evidence | ✅ Verified — expected mismatch behavior | -| PP-M6 | UAdd `+5` folding documented | domain_spec.md:377, W34 evidence | ✅ Verified — exposes `5`, indistinguishable from bare `5` | -| PP-M7 | Bool-string collision documented | domain_spec.md:477, W35 evidence | ✅ Verified — intentional side effect of type-erasing normalization | - -#### Additional Adversarial Findings - -1. **Glossary inconsistency (non-blocking):** `glossary.md:114` and `domain_model.md:59,83` define `Literal` as "a double-quoted string." `domain_spec.md:217` now supports both `"..."` and `'...'` (single quotes). The glossary entry is stale. Severity: documentation only — glossary.md states "Code and tests take precedence over this glossary." No impact on implementation correctness. Fix: update glossary entry during next glossary maintenance. - -2. **Float numeric precision edge (non-blocking):** `float(token)` conversion is deterministic for values like `-3.14`. `str(float_value)` normalization is reliable for the values used in this feature. **Not a pain point** — Python's `str(float)` is stable for these values. - -3. **Feature file uses `Scenario:` not `Example:` keyword:** All 15 Scenario blocks use `Scenario:` keyword. Per [[requirements/gherkin#concepts]], non-outline examples should use `Example:` keyword. Severity: convention-only — does not affect behavioral correctness. Address in polish state, not here. - -4. **No explicit Gherkin-side float extraction walkthrough:** W14 covers body-side float constants. No walkthrough explicitly shows `_extract_literals("Given the temperature is -3.14")` → `Literal(value=-3.14, type=numeric)`. However, the regex `^-?\d+(\.\d+)?$` mathematically covers this, and the feature file Scenario (line 106-111) provides acceptance-level coverage. **Not a pain point** — regex correctness is sufficient at simulation level. - -5. **`"True"`/`"False"` string-bool collision has no Feature Rule:** The `domain_spec.md:477` "Known side effect" is not represented as a Rule or Scenario in the feature file. Currently covered only by W35 walkthrough evidence. The `True And One Never Collide` Rule covers the non-collision case; its complement (string `"True"` → bool `True` does collide) is implied by the `Literal Matching Case Insensitive` Rule's `str().lower()` mechanism. Severity: low — behavior follows deterministically from the documented normalization contract. - -#### Independent Review Verdict - -**PASS.** All 7 pain points from iteration 1 are independently verified resolved with concrete walkthrough evidence (W30-W35). All 6 PASS criteria from [[requirements/spec-simulation#content]] are independently confirmed: - -| # | Criterion | Status | Independent Verification | -|---|-----------|--------|--------------------------| -| 1 | Zero unresolved pain points | ✅ PASS | All 7 PPs verified resolved; 0 new PPs found | -| 2 | Entity coverage | ✅ PASS | ScenarioInfo, Placeholder, Literal, TestInfo, Violation all covered across 3 contexts | -| 3 | Integration point coverage | ✅ PASS | FP→CC and TD→CC both have success + failure walkthroughs | -| 4 | Quality attribute coverage | ✅ PASS | Correctness (W27 E2E), Reliability (W28 stubs), Simplicity (W19-W26 normalization) all stressed | -| 5 | Rule quality | ✅ PASS | All 7 rules specific, testable, traceable to walkthroughs, non-contradictory | -| 6 | Cross-context consistency | ✅ PASS | No bilateral integration mismatches; CONFORMIST payloads verified | - -**Cross-document observations (non-blocking):** glossary.md Literal entry stale (single-quote not reflected); domain_model.md similarly outdated. These do NOT block PASS — they are documentation maintenance items for a separate flow. diff --git a/=1.1.0 b/=1.1.0 deleted file mode 100644 index e69de29..0000000 diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 6fd50f7..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,41 +0,0 @@ -# Changelog - -## [1.0.0] — 2026-05-20 - -### Added - -- **Title Validation** — `beehave check` and `beehave generate` now validate Feature, Rule, and Scenario titles across all `.feature` files. - - Charset: `[\w\s]+` (word characters and spaces only) - - Word count: 2–6 words - - Uniqueness: case-insensitive global comparison across all title types - - 6 new violation types: `invalid-feature-title`, `invalid-rule-title`, `invalid-scenario-title`, `duplicate-feature-title`, `duplicate-rule-title`, `duplicate-scenario-title` - - `generate_stubs()` blocks generation on title violations (pre-flight gate) - - `check_all()` includes title violations alongside scenario-level violations - -- **Case-Insensitive Matching** — Placeholder and literal comparison are now case-insensitive. - - `ph.name.lower()` comparison for placeholders (`` matches `dog`, `DOG`) - - `str(lit.value).lower()` comparison for literals (`"Rex"` matches `"rex"`, `int(77000)` matches `Decimal("77000")`) - - Negative numbers visible in test bodies (`-2010` from `UnaryOp` folding) - - Quoted placeholders no longer double-captured as both placeholder and literal - - Bracket notation `[something]` preserved as literal content - -- **Status Command** (`beehave status`) — Reports the development stage of every feature in a project. - - 7-stage decision tree: `broken` → `no scenarios` → `needs scenarios` → `needs tests` → `needs bodies` → `needs fixes` → `ok` - - Tree-based output format with per-scenario status labels (`ok`, `no body`, `N errors`, `no test`) - - Rule aggregation with comma-joined child counts (`"1 no body, 2 errors"`) - - `--json` flag for machine-readable output - - `--include-unmapped` flag to show unmapped test directories - - Exit codes: 0 (all ok), 1 (any not ok), 2 (fatal error) - -### Changed - -- All test fixture titles updated to 2+ words for title validation compliance -- `OrphanedDir` renamed to `UnmappedDir`, `--include-orphaned` → `--include-unmapped` across all modules -- Product definition expanded to include status reporter -- `.cache/` added to `.gitignore` - -### Fixed - -- Literal extraction type mismatch: `int(77000)` from Gherkin now matches `Decimal("77000")` in test bodies -- Negative number constants from `ast.UnaryOp` now correctly extracted from test bodies -- Quoted `` tokens no longer double-captured as both placeholder and literal From 47a392ce06c9b8e1d228c7380297db37fb56dd22 Mon Sep 17 00:00:00 2001 From: nullhack Date: Tue, 21 Jul 2026 01:28:09 -0400 Subject: [PATCH 13/13] docs: rewrite README, state.md, glossary.md for v3.0.0; add LICENSE The docs were frozen at v1.x/v2.0.0 and extensively wrong about v3.0.0 behavior. This commit brings them in line with the superset model. README.md: full rewrite. Drops Hypothesis (replaced by @parametrize in v2.2), enforced literals/placeholders (dropped in v2), `beehave clean`/ `list`/`--json`/`--force` (never existed in v2+), `[tool.beehave]` config block (never implemented), PyPI badge (not on PyPI). Adds accurate CLI surface (generate / check [feature...] / status), Mode A/B distinction, real emit format (@parametrize + with step blocks), real file naming (flat tests/features/__test.py). docs/state.md: regenerated for v3.0.0 (was v2.0.0). Updated version, test count (66), contract index for signature-only check, dropped Hypothesis + consumer-side stubtest from dependencies, updated data flow narrative. docs/glossary.md: rewrote stale entries (Examples, generate, check, status, .pyi, mypy.stubtest, parse model, structural binding, default group, skeleton). Added v3.0.0 concepts (superset model, scoped check, orphan module, Mode A, Mode B, StepError, NoActiveScenarioError, scenario index, Scenario Outline, .stubtest_allowlist). LICENSE: added MIT license file (README already claimed MIT; file was missing). Removed v1-era cruft: - docs/spec/{domain_model,domain_spec,glossary,product_definition, status_command_spec}.md (v1 specs describing Hypothesis + step defs). - docs/spec/v3/beehave_v3_spec.md (despite the name, v1-era content). - docs/interview-notes/IN_20260513_discovery.md (v1 discovery interview; git history preserves). - docs/branding.md (v1-era; assets/banner.svg + logo.svg retained). --- LICENSE | 21 + README.md | 280 ++---- docs/branding.md | 26 - docs/glossary.md | 151 +-- docs/interview-notes/IN_20260513_discovery.md | 71 -- docs/spec/domain_model.md | 130 --- docs/spec/domain_spec.md | 950 ------------------ docs/spec/glossary.md | 300 ------ docs/spec/product_definition.md | 107 -- docs/spec/status_command_spec.md | 344 ------- docs/spec/v3/beehave_v3_spec.md | 561 ----------- docs/state.md | 71 +- 12 files changed, 252 insertions(+), 2760 deletions(-) create mode 100644 LICENSE delete mode 100644 docs/branding.md delete mode 100644 docs/interview-notes/IN_20260513_discovery.md delete mode 100644 docs/spec/domain_model.md delete mode 100644 docs/spec/domain_spec.md delete mode 100644 docs/spec/glossary.md delete mode 100644 docs/spec/product_definition.md delete mode 100644 docs/spec/status_command_spec.md delete mode 100644 docs/spec/v3/beehave_v3_spec.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..da7dcd6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 nullhack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 677e3c3..8b64210 100644 --- a/README.md +++ b/README.md @@ -4,44 +4,35 @@ [![Python](https://img.shields.io/badge/python-%E2%89%A53.14-blue?style=for-the-badge)](https://www.python.org/downloads/) [![License](https://img.shields.io/badge/license-MIT-green?style=for-the-badge)](LICENSE) -[![PyPI](https://img.shields.io/pypi/v/beehave?style=for-the-badge)](https://pypi.org/project/beehave/) -**Keep your living documentation and test code in sync — without step definitions.** +**BDD without step definitions. The `.feature` is the sole source of truth.** --- -**beehave** is a simpler alternative to **behave** and **pytest-bdd**. Instead of writing step definitions that match Gherkin step text to Python functions with `@given`/`@when`/`@then` decorators, beehave links scenarios to tests by **function name** alone. It generates pure [Hypothesis](https://hypothesis.readthedocs.io/) property-based test stubs from your `.feature` files and checks that your test code stays consistent with your spec. Your tests never import beehave — they just use hypothesis. +**beehave** is a thinner alternative to **behave** and **pytest-bdd**. Instead +of authoring step definitions that match Gherkin step text to Python functions +via `@given`/`@when`/`@then` decorators, beehave links each `Scenario` to a +test function by **function name** alone, generates a `pytest`-native skeleton +(`@pytest.mark.parametrize` rows + `with step(...)` blocks), and statically +verifies that the consumer's `.py` matches the `.feature` contract 1-1. -```bash -pip install beehave -``` - ---- - -## How it differs from standard Gherkin tools - -Traditional BDD frameworks (behave, pytest-bdd) require **step definitions** — separate Python functions decorated with `@given`/`@when`/`@then` whose text must match the Gherkin step text exactly. This creates fragile coupling and framework lock-in. beehave eliminates all of that: - -- **No step definitions.** The function name **is** the link. `Scenario: guard bee inspects visitor` → `test_guard_bee_inspects_visitor`. -- **No runtime imports.** Your tests import only `hypothesis`. beehave is a dev-time CLI. -- **Property-based by default.** Hypothesis `@given()` strategies are inferred from Examples table types. behave and pytest-bdd are example-only. +## Install -To make this work, beehave applies a few constraints beyond standard Gherkin: +beehave is not yet on PyPI. Install from source: -| Constraint | Why | -|-----------|-----| -| Titles contain only **letters, digits, and spaces** | They become Python identifiers (`test_...`) and file paths | -| `"quoted strings"` or `'single-quoted strings'` and bare numbers in step text are **enforced literals** | `check` verifies they appear as `Constant` nodes in the function body (case-insensitive) | -| `` names must be valid Python identifiers, not keywords or builtins | They become function parameters (case-insensitive matching) | -| Scenario titles are **globally unique** across all features | One function name = one scenario, everywhere | +```bash +git clone https://github.com/nullhack/beehave +cd beehave +uv sync +``` ---- +Requires Python ≥ 3.14. -## Usage +## Quick start -### Write a feature +### 1. Write a feature ```gherkin # docs/features/hive_activity.feature @@ -60,195 +51,128 @@ Feature: Hive Activity | nectar | rate | hours | honey | | 100 | 20 | 8 | 80 | | 200 | 25 | 12 | 150 | - | 50 | 30 | 6 | 35 | Rule: Hive defense - - Background: - Given the entrance has 2 guards - Scenario: guard bee inspects visitor Given a visitor bee with colony odor - When the guard inspects the visitor for "floral" scent + When the guard inspects the visitor Then the visitor is - - Rule: Foraging - - Scenario: forager returns with nectar - Given a forager bee named - When the forager returns with milliliters of nectar - Then the hive stores milliliters of nectar ``` -### Generate stubs +### 2. Generate the test skeleton ```bash -beehave generate hive_activity +beehave generate ``` ``` -tests/features/hive_activity/ -├── default_test.py # top-level scenarios (honey production outline) -├── hive_defense_test.py # Rule: Hive defense (guard bee) -└── foraging_test.py # Rule: Foraging (forager returns) +tests/features/ +├── hive_activity_default_test.py # top-level scenarios (the outline) +└── hive_activity_hive_defense_test.py # Rule: Hive defense ``` ```python -# tests/features/hive_activity/default_test.py -from hypothesis import given, example, strategies as st - -@example(nectar=100, rate=20, hours=8, honey=80) -@example(nectar=200, rate=25, hours=12, honey=150) -@example(nectar=50, rate=30, hours=6, honey=35) -@given(nectar=st.integers(), rate=st.integers(), hours=st.integers(), honey=st.integers()) -def test_honey_production_from_nectar(nectar, rate, hours, honey): - ... -``` +# tests/features/hive_activity_default_test.py +from beehave import step +import pytest + + +@pytest.mark.parametrize( + ('nectar', 'rate', 'hours', 'honey'), + [ + ('100', '20', '8', '80'), + ('200', '25', '12', '150'), + ], +) +def test_honey_production_from_nectar(nectar: str, rate: str, hours: str, honey: str) -> None: + with step('Given', 'the hive is active'): + pass + with step('Given', 'the hive has grams of nectar', nectar=nectar): + pass + with step('And', 'the evaporation rate is percent', rate=rate): + pass + with step('When', 'the bees fan their wings for hours', hours=hours): + pass + with step('Then', 'the hive produces grams of honey', honey=honey): + pass +``` + +The skeleton is emitted **only when the `.py` is absent**. Re-running +`generate` never clobbers consumer bodies (idempotent). + +### 3. Fill in the bodies + +Replace the `pass` inside each `with step(...)` block with real code. +The `Then` block is where the outcome assertion lives. ```python -# tests/features/hive_activity/hive_defense_test.py -from hypothesis import given, strategies as st - -@given(scent=st.text(), outcome=st.text()) -def test_guard_bee_inspects_visitor(scent, outcome): - ... +def test_honey_production_from_nectar(nectar: str, rate: str, hours: str, honey: str) -> None: + with step('Given', 'the hive is active'): + activate_hive() + with step('Given', 'the hive has grams of nectar', nectar=nectar): + store_nectar(int(nectar)) + with step('And', 'the evaporation rate is percent', rate=rate): + set_evaporation(int(rate)) + with step('When', 'the bees fan their wings for hours', hours=hours): + fan_wings(int(hours)) + with step('Then', 'the hive produces grams of honey', honey=honey): + assert hive_honey() == int(honey) ``` -Note what beehave extracted automatically: - -- **``, `` …** → `@given()` parameters. Strategies inferred from Examples table types (all integers → `st.integers()`). -- **`100`, `20` …** → `@example()` rows from the Examples table. -- **`"floral"`** → enforced literal from step text. `check` verifies it appears in the function body. -- **`2`** (from Rule Background `2 guards`) → enforced literal, inherited by all scenarios in that Rule. -- **``, ``** → `@given()` parameters. No Examples table, so strategy falls back to `st.text()`. - -### Check consistency - -You implement the guard test: - -```python -@given(scent=st.text(), outcome=st.text()) -def test_guard_bee_inspects_visitor(scent, outcome): - assert "floral" in known_scents() - assert 2 == guard_count() - assert scent in ("floral", "citrus") - assert outcome in ("admitted", "rejected") -``` +### 4. Check + run ```bash -beehave check hive_activity # check one feature -beehave check # check all features -``` - -Remove the `"floral"` assertion and `check` catches it: - -``` -tests/features/hive_activity/hive_defense_test.py:4: missing-literal: literal '"floral"' not found in function body +beehave check # verify .py ↔ .feature 1-1 contract +pytest # run the tests (the step CM attributes failures) ``` -Remove `` from the body but keep it as a `@given()` parameter? Still caught — beehave checks the **body only**: +## Two enforcement modes -``` -tests/features/hive_activity/hive_defense_test.py:4: missing-placeholder: 'scent' not found in function body -``` +| Mode | What `beehave check` verifies | Runtime step verification | +|---|---|---| +| **A — signature-only** | `.py` non-private function signatures match feature-derived signatures exactly (1-1) | None. Bodies are free-form. | +| **B — step-enforced** | Same as A. | `with step(...)` blocks verify `(keyword, text, placeholder-name-set)` at position N against the feature scenario. Failures attribute via `add_note`. | -Rename the scenario? Both sides are reported: +The generated skeleton imports `step` already; Mode B activates the moment +the consumer keeps the `with step(...)` blocks. Removing the import + blocks +downgrades to Mode A (signature-only). -``` -docs/features/hive_activity.feature:22: unmapped-scenario: scenario 'guard checks visitor' has no test function -tests/features/hive_activity/hive_defense_test.py:4: unmapped-test: 'test_guard_bee_inspects_visitor' has no matching scenario -``` +## CLI reference -### Clean up stale functions +| Command | Purpose | +|---|---| +| `beehave generate` | Emit `*_test.py` skeletons into `tests/features/` (only if absent). | +| `beehave check` | Full sweep: every feature's signatures vs. `.py` 1-1 + orphan-module detection. | +| `beehave check ...` | Scoped: only the named `.feature` paths. Skips orphan detection. | +| `beehave status` | Print `.feature` count + emitted `*_test.py` count. Exit 2 if `docs/features/` missing. | -```bash -beehave clean hive_activity # remove unmapped stubs only (safe) -beehave clean hive_activity --force # remove any unmapped function -``` +`beehave check` exits non-zero on any contract violation (missing function, +extra non-private function, signature drift, orphan module on full sweep). +Private functions (leading `_`) are exempt — consumer helpers, fixtures, etc. -### List features +For incremental workflows, scope the check to changed features: ```bash -beehave list # paths and titles -beehave list -v # include scenario counts, rules, stub status +beehave check $(git diff --name-only HEAD~1 HEAD -- 'docs/features/*.feature') ``` -### Show development status +## How the `.feature` maps onto the `.py` -```bash -beehave status # all features with stage and tree output -beehave status --json # machine-readable JSON -beehave status --include-unmapped # include unmapped test directories -``` - -Output example: +- **Scenario title → function name:** `Honey Production From Nectar` → `test_honey_production_from_nectar` (lowered, whitespace collapsed). Globally unique across all features. +- **Rule → module:** Top-level scenarios go to `_default_test.py`. Scenarios inside a Rule go to `__test.py`. +- **Background:** Feature Background prepended to every scenario's step list. Rule Background prepended only to that Rule's scenarios. Background steps may not contain ``. +- **Examples → `@pytest.mark.parametrize`:** All params typed `str`. Row cells are string tuples. Examples-table tags emit `pytest.param(..., marks=pytest.mark.)` only when tags differ across tables. +- **Tags:** `@tag` on Feature or Rule → module-level `pytestmark = [pytest.mark., ...]`. `@tag` on Scenario → `@pytest.mark.` decorator. +- **Step docstrings + data tables:** Parsed (Full Gherkin) and surfaced as body-local variables (`docstring = '...'`, `data_table = [{'col': 'val'}, ...]`) inside the `with step(...)` block. Not passed to the `step()` call. -``` -hive_activity (Hive Activity) needs fixes -├── guard bee inspects visitor 1 error -└── forager returns with nectar ok - -comb_construction (Comb Construction) ok +## Title rules (enforced at parse time) -dance_language (Waggle Dance Communication) needs bodies -├── round dance no body -└── waggle dance no body -``` - ---- - -## What `check` enforces - -| Check | Severity | What it catches | -|-------|----------|-----------------| -| `unmapped-scenario` | error | Scenario has no matching test function | -| `unmapped-test` | error | Test function has no matching scenario | -| `missing-placeholder` | error | `` not referenced in function body | -| `missing-literal` | error | `"string"` or numeric literal not in function body | -| `example-mismatch` | error | Examples row has no matching `@example()` or vice versa | -| `misplaced-test` | warning | Function in wrong file (e.g., after Rule removal) | -| `invalid-feature-title` | error | Feature title fails charset or word count rules | -| `invalid-rule-title` | error | Rule title fails charset or word count rules | -| `invalid-scenario-title` | error | Scenario title fails charset or word count rules | -| `duplicate-feature-title` | error | Duplicate Feature title (case-insensitive, global) | -| `duplicate-rule-title` | error | Duplicate Rule title or Rule-Feature collision | -| `duplicate-scenario-title` | error | Duplicate Scenario title or Scenario-title collision | - -`beehave check` also validates feature, rule, and scenario titles: 2–6 words, `[\w\s]+` charset (letters, digits, spaces, underscores), case-insensitive global uniqueness across all title types. - ---- - -## How it maps - -- **Scenario title → function name:** `Honey Production From Nectar` → `test_honey_production_from_nectar`. Lowercased. Globally unique across all features. -- **Rule → test file:** Top-level scenarios go to `default_test.py`. Scenarios inside a Rule go to `_test.py`. -- **Feature title → directory:** `Hive Activity` → `tests/features/hive_activity/`. -- **Strategy inference:** Examples table column values are typed — all integers → `st.integers()`, all floats → `st.floats()`, all booleans → `st.booleans()`, else → `st.text()`. -- **Background merging:** Feature Background applies to all scenarios. Rule Background applies to that Rule's scenarios only. Background steps cannot contain ``. -- **Literal extraction:** `"quoted strings"` and `'single-quoted strings'` and numeric tokens in step text are enforced as `Constant` AST nodes in the function body. Matching is case-insensitive: `"Rex"` in Gherkin matches `"rex"` in test body. Both single and double quote styles are supported. `[...]` bracket notation inside quotes is preserved verbatim. - ---- - -## Configuration - -```toml -# pyproject.toml -[tool.beehave] -features_dir = "docs/features" -tests_dir = "tests/features" -default_strategy = "text" -background_check_numeric = true -background_check_string = true -``` +- **Charset:** Unicode letters, digits, and spaces only (no hyphens, punctuation, or symbols). +- **Word count:** 2–6 words. +- **Uniqueness:** Case-insensitive, across Feature / Rule / Scenario titles, keyed on the slug (whitespace-collapsed, lowercased). -| Option | Default | Description | -|--------|---------|-------------| -| `features_dir` | `docs/features` | Where `.feature` files live | -| `tests_dir` | `tests/features` | Where generated tests go | -| `default_strategy` | `text` | Fallback strategy for unknown placeholders | -| `background_check_numeric` | `true` | Enforce numeric literals from Background steps | -| `background_check_string` | `true` | Enforce string literals from Background steps | +Violations raise at `parse_feature` time and prevent generation. ## License -MIT +MIT — see [LICENSE](LICENSE). diff --git a/docs/branding.md b/docs/branding.md deleted file mode 100644 index 788b5ef..0000000 --- a/docs/branding.md +++ /dev/null @@ -1,26 +0,0 @@ -# Branding — beehave - -## Identity - -- **Project name:** beehave -- **Tagline:** BDD living documentation in sync -- **Tone of voice:** Friendly, precise, industrious, collaborative - -## Visual - -- **Primary color:** `#1e2128` — deep charcoal (dark backgrounds, banner fill) -- **Accent bright:** `#f5a800` — amber gold (brand mark, "Bee" wordmark, highlights) -- **Accent deep:** `#c87d00` — burnt amber (stripes, secondary accents) -- **Text color:** `#e8eaef` — off-white (primary typography on dark) -- **Muted text:** `#a0a5b0` — cool grey (secondary typography, taglines) - -> Colors meet WCAG 2.1 AA when white text is placed on the primary. - -## Assets - -- **`docs/assets/logo.svg`** — Transparent brand mark. A rounded bee-abdomen / flask hybrid shape with three horizontal burnt-amber stripes (suggesting both bee stripes and test bars) and cute downward-bending antennae with bulb tips. Rendered in bright amber; works on any background. -- **`docs/assets/banner.svg`** — GitHub README banner. Dark background with the amber mark on the left, "Beehave" wordmark center, subtle decorative accents on the right. - -## Release Naming - -- **Convention:** Adjective-animal (e.g., "Busy Badger", "Diligent Dolphin") diff --git a/docs/glossary.md b/docs/glossary.md index 217656a..e9d1323 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1,126 +1,153 @@ # Glossary: beehave > The ubiquitous language for this project — terms shared across conversation, -> code, and documentation (Evans, 2003). Curated from the interview for the -> IMPORTANT domain concepts, not every code symbol. Grouped by bounded context, -> where each term has one meaning. The tests are the source of truth for -> behaviour; this glossary is the source of truth for names. Extend or revise -> entries as understanding shifts. +> code, and documentation (Evans, 2003). Curated for the IMPORTANT domain +> concepts, not every code symbol. Grouped by bounded context, where each term +> has one meaning. The tests are the source of truth for behaviour; this +> glossary is the source of truth for names. ## Context: Design-time (the CLI tool) ### `.feature` -A source artifact that holds Gherkin-syntax feature definitions and is the single source of truth for generation. -*Aliases: feature file · Source: interview 2026-07-18* +A source artifact that holds Gherkin-syntax feature definitions and is the single source of truth for generation + check. +*Aliases: feature file* ### Feature A Gherkin structural element that is the top-level container in a `.feature` file. -*Aliases: none · Source: interview 2026-07-18* +*Aliases: none* ### Rule -A Gherkin structural element that groups scenarios within a Feature and is the unit of per-rule test-file generation. -*Aliases: none · Source: interview 2026-07-18* +A Gherkin structural element that groups scenarios within a Feature and is the unit of per-rule test-file generation (`__test.py`). +*Aliases: none* ### Background A Gherkin structural element whose steps are merged into every scenario's step list and may not contain placeholders. -*Aliases: none · Source: interview 2026-07-18* +*Aliases: none* ### Scenario A Gherkin structural element that maps one-to-one to a generated `test_` function whose name is its identity. -*Aliases: none · Source: interview 2026-07-18* +*Aliases: none* + +### Scenario Outline +A Scenario parameterised by an Examples table; emitted with `@pytest.mark.parametrize` over the test function (one row per Examples row, all params typed `str`). +*Aliases: none* ### Examples -A tabular value source whose columns drive Hypothesis `@given` strategy inference and whose rows drive `@example`. -*Aliases: Examples table · Source: interview 2026-07-18* +A tabular value source whose columns become `@parametrize` arg-names and whose rows become string-tuple parametrize rows. Multiple Examples tables in one Scenario Outline are merged; per-row tags tracked. +*Aliases: Examples table* ### step -A Gherkin structural element that is the unit matched one-to-one (`block[i]`↔`step[i]`) against `with step(...)` blocks in the test body. -*Aliases: Gherkin step · Source: interview 2026-07-18* +A Gherkin structural element that is the unit matched one-to-one (`step[i]` at position N) against `with step(...)` blocks in the test body when Mode B runtime is active. +*Aliases: Gherkin step* ### placeholder -A parameterisation token of the form `` that binds Examples-table columns to step-text positions and whose name-set is one of the three structural-binding fields. -*Aliases: none · Source: interview 2026-07-18* +A parameterisation token of the form `` that binds Examples-table columns to step-text positions and becomes a Python function parameter (typed `str`). Only recognised in Scenario Outline steps whose name appears in the Examples headers; `` in a plain Scenario or non-header position is literal text. +*Aliases: none* ### title -An identifier that derives the `test_` function name and is constrained by uniqueness (case-insensitive), a 2–6 word-count bound, and a Unicode-letters/digits/spaces charset. -*Aliases: none · Source: interview 2026-07-18* +An identifier that derives the `test_` function name and is constrained by uniqueness (case-insensitive, slug-keyed), a 2–6 word-count bound, and a Unicode-letters/digits/spaces charset. +*Aliases: none* ### slug -A normalised identifier that is the lowercased, whitespace-collapsed form of a title and becomes the suffix of the `test_` function name. -*Aliases: none · Source: interview 2026-07-18* +A normalised identifier that is the lowercased, whitespace-collapsed form of a title. Slugs are the uniqueness key (titles differing only in whitespace runs collide) and the suffix of the `test_` function name + module filename. +*Aliases: none* ### Full Gherkin -A grammar-coverage claim meaning v2 parses everything `gherkin-official` emits, including `@tags`, docstrings, and data-tables. -*Aliases: none · Source: interview 2026-07-18* +A grammar-coverage claim meaning beehave parses everything `gherkin-official` emits, including `@tags`, docstrings, and data-tables. Tags surface as `pytestmark` / `@pytest.mark.`; docstrings and data-tables surface as body-local variables. +*Aliases: none* ### noise loophole -A spec-value-fidelity failure in which a placeholder or literal "appears" in a test body as an AST node while testing nothing about the step's actual behaviour — the v1 incident that motivates v2. -*Aliases: none · Source: interview 2026-07-18 (CIT)* +A spec-value-fidelity failure in which a placeholder or literal "appears" in a test body as an AST node while testing nothing about the step's actual behaviour — the v1 incident that motivates the v2 rewrite. Closed in v2 by removing appearance enforcement, and again in v3 by making `check` signature-only (body content is the consumer's responsibility). +*Aliases: none* ### `generate` -A CLI command that emits the `.pyi` typed-stub contract always and the `*_test.py` skeleton only if the `.py` is absent (idempotent — never clobbers existing bodies). -*Aliases: none · Source: interview 2026-07-18* +A CLI command that emits the `*_test.py` skeleton into `tests/features/` only if the file is absent (idempotent — never clobbers consumer bodies). One `_default_test.py` for non-Rule scenarios + one `__test.py` per Rule. +*Aliases: none* ### `check` -A CLI command that validates the `with step(...)` structural binding against the `.feature` and enforces title rules. -*Aliases: none · Source: interview 2026-07-18* +A CLI command that verifies the `.feature` ↔ `.py` contract 1-1: every scenario's expected `def test_(params) -> None` line must exactly equal some non-private top-level function signature in the corresponding `.py` module(s), and vice versa. Private `_*` functions are exempt (superset). Full sweep also runs orphan-module detection by filename stem; scoped invocation (`check ...`) skips it. +*Aliases: none* ### `status` -A CLI command that reports generation/check progress (detailed behaviour deferred to plan). -*Aliases: none · Source: interview 2026-07-18* +A CLI command that prints the `.feature` count under `docs/features/` and the `*_test.py` count under `tests/features/`; exit 0 if features dir exists, 2 if missing. +*Aliases: none* -### parse model -The in-memory typed shapes (`Feature` / `Rule` / `Scenario` / `Step` / `Placeholder` / `Examples` / `Background` / `DataTable`) carried by `beehave/models.py` as the shared kernel between `gherkin.py`, `generate.py`, and `check.py`. Distinguished from *persistence model* — v2 has none (the parse model is the binding data contract). -*Aliases: none · Source: data-model 2026-07-18* +### superset model +The v3.0.0 contract model: `.feature` is the sole source of truth; the `.py` non-private function surface must match it 1-1; private `_*` functions are an unconstrained superset (consumer helpers, fixture wrappers, etc.). Replaces v2's `.pyi`-driven stubtest gate. +*Aliases: none* + +### orphan module +A `*_test.py` file in `tests/features/` whose stem does not correspond to any feature's expected module stems (derived from feature + rule slugs). Detected on full-sweep `beehave check`; reported to stderr; exit 1. +*Aliases: none* -### shared kernel -A bounded-context pattern (Evans, 2003) applied to `beehave/models.py`: a small explicitly-shared vocabulary consumed by `gherkin.py`, `generate.py`, and `check.py`. The seam is justified by the three-consumer access pattern; whether it stays its own module or collapses into `gherkin.py` is an internal source-structure choice, not a data-model concern. -*Aliases: none · Source: interview 2026-07-18 (L3) + data-model 2026-07-18* +### scoped check +`beehave check ...` — verifies only the named `.feature` paths. Skips orphan-module detection (which would require parsing every feature). Consumers wire incremental scope via git-diff. +*Aliases: none* -### structural binding -The `block[i]`↔`step[i]` match on exactly `(keyword, text, placeholder-name-set)` — with `keyword` compared case-insensitively — that `beehave check` enforces by walking the test body's `with step(...)` blocks in source order. Body fidelity is deferred to the review gate; this is the structural-only check that replaces v1's appearance enforcement (the noise loophole). -*Aliases: none · Source: interview 2026-07-18 (L1 Success) + plan design decision 2026-07-18 (keyword case)* +### parse model +The in-memory typed shapes (`Feature` / `Rule` / `Scenario` / `Step` / `Placeholder` / `Examples` / `Background` / `DataTable`) carried by `beehave/gherkin.py` as the shared vocabulary between `generate` and `check`. Distinguished from *persistence model* — beehave has none. +*Aliases: none* ### default group -The emission group for scenarios NOT under a Rule; emitted to `_default_test.py{i,}` alongside one `__test.py{i,}` per Rule. -*Aliases: none · Source: plan design decision 2026-07-18* +The emission group for scenarios NOT under a Rule; emitted to `_default_test.py` alongside one `__test.py` per Rule. +*Aliases: none* ### skeleton -The `*_test.py` body emitted by `beehave generate` ONLY IF the `.py` is absent; a scaffold of `with step(...)` blocks the consumer fills. Re-running `generate` never clobbers an existing skeleton (idempotent) — only the `.pyi` is rewritten. -*Aliases: test skeleton · Source: interview 2026-07-18 (Constraint 1)* +The `*_test.py` body emitted by `beehave generate` ONLY IF the `.py` is absent; a scaffold of `with step(...)` blocks (with `@parametrize` for Outlines, `pytestmark` for tags) the consumer fills. Re-running `generate` never clobbers an existing skeleton (idempotent). +*Aliases: test skeleton* ## Context: Runtime (beehave-the-import) +### Mode A — signature-only +The default usage mode: the consumer's `.py` non-private function signatures match the feature-derived signatures 1-1 (enforced by `beehave check`). The `from beehave import step` import + `with step(...)` blocks may be present or removed; check does not inspect them. +*Aliases: none* + +### Mode B — step-enforced +The opt-in runtime mode: the consumer keeps the generated `with step(...)` blocks. At pytest time each block verifies against the scenario's step N (`keyword.lower()`, `text`, placeholder-name-set) and the `@parametrize` rows verify against `Examples`. Mismatches raise `StepError`; failures attribute via `add_note`. +*Aliases: none* + ### `step` -A context manager imported `from beehave import step` that wraps a step's executable test code as `with step(keyword, text, **placeholders)` and attributes failures to its step via `add_note`. -*Aliases: step context manager · Source: interview 2026-07-18* +A context manager imported `from beehave import step` that wraps a step's executable test code as `with step(keyword, text, **placeholders)`. Looks up the calling `test_*` frame, finds the scenario in the lazy index, advances a frame-keyed step counter, verifies the step N triple, and on exception attributes via `add_note(f"{keyword} {text}")`. +*Aliases: step context manager* ### keyword -A positional argument to the `step` context manager that is a STRING (data, not a method name) so it covers all Gherkin step keywords including localized variants without reserved-word clashes. -*Aliases: none · Source: interview 2026-07-18* +A positional-only argument to the `step` context manager that is a STRING (data, not a method name) so it covers all Gherkin step keywords including localized variants without reserved-word clashes. +*Aliases: none* ### `Then`-asserts A runtime contract that the `Then` step block is where the outcome assertion executes — the step block RUNS code, it does not merely declare it. -*Aliases: none · Source: interview 2026-07-18* +*Aliases: none* ### `add_note` -A pytest mechanism that the `step` context manager uses to attribute a failure to its specific step by name. -*Aliases: none · Source: interview 2026-07-18* +A PEP 678 mechanism that the `step` context manager uses to attribute a failure to its specific step by name (`f"{keyword} {text}"`), preserving the original traceback. +*Aliases: none* + +### `StepError` +The exception raised by the `step` CM at runtime when a `with step(...)` block's `(keyword, text, placeholder-name-set)` triple does not match the feature scenario's step N — Mode B runtime enforcement. +*Aliases: none* + +### `NoActiveScenarioError` +The exception raised by `beehave._index.get(function_name)` when the calling function name does not match any scenario in any feature under `docs/features/` — strict Mode B invariant. +*Aliases: none* + +### scenario index +The lazy module-level `dict[function_name, Scenario]` in `beehave/_index.py`, built once per process on first `step(...)` call by scanning `Path.cwd()/docs/features/*.feature`. Subsequent lookups are O(1); `_reset()` is the test hook. +*Aliases: _index* ## Context: Typing contract ### `.pyi` -A type-stub file that is the typed contract surface `generate` always emits and that consumer type-checkers read in preference to the `.py`. -*Aliases: stub · Source: interview 2026-07-18* +A type-stub file shipped with the **beehave package** (`beehave/*.pyi`) for consumer-side type-checkers. Consumer tests in `tests/features/` do NOT have `.pyi` siblings in v3.0.0 (dropped — the superset model derives the contract directly from `.feature`). +*Aliases: stub* ### `py.typed` -A PEP 561 package-marker file (empty) that signals to type-checkers that the package ships typed stubs, on which the consumer-side mypy gate depends. -*Aliases: none · Source: interview 2026-07-18* - -### drift -A contract violation in which a generated `*_test.py` body diverges from its `.pyi`. -*Aliases: stub drift · Source: interview 2026-07-18* +A PEP 561 package-marker file (empty) that signals to type-checkers that the beehave package ships typed stubs. +*Aliases: none* ### `mypy.stubtest` -A tool that is the SOLE `.py`↔`.pyi` drift detector in v2's gate (pyright/mypy read only the `.pyi`). -*Aliases: stubtest · Source: interview 2026-07-18* +A dev-only tool that verifies `.py` ↔ `.pyi` drift for the **beehave package itself** (`task stubtest` runs `python -m mypy.stubtest beehave --allowlist .stubtest_allowlist`). NOT used on consumer tests in v3.0.0. +*Aliases: stubtest* + +### `.stubtest_allowlist` +A repo-root file listing stubtest false positives (regex matched against object paths). Carries one entry `beehave.step.step` (mypy `@contextmanager` positional-only param-name erasure false positive). +*Aliases: none* diff --git a/docs/interview-notes/IN_20260513_discovery.md b/docs/interview-notes/IN_20260513_discovery.md deleted file mode 100644 index 5c054b1..0000000 --- a/docs/interview-notes/IN_20260513_discovery.md +++ /dev/null @@ -1,71 +0,0 @@ -# IN_20260513_discovery — v3 Spec Synthesis - -> **Status:** COMPLETE -> **Interviewer:** PO -> **Participant(s):** Stakeholder (via v3 specification document) -> **Session type:** Initial discovery - ---- - -## General - -| ID | Question | Answer | -|----|----------|--------| -| Q1 | Who are the users? | Python developers writing property-based tests who want Gherkin as the spec source of truth. | -| Q2 | What does the product do at a high level? | A CLI tool with three commands: `generate` (produces Hypothesis test stubs from Gherkin), `check` (verifies test bodies match feature specs via AST), and `clean` (removes unmapped test functions). | -| Q3 | Why does it exist — what problem does it solve? | Developers need Gherkin specifications to stay synchronized with test code without runtime coupling. Existing BDD tools inject frameworks into test code; beehave generates plain Hypothesis tests with zero imports from beehave itself. | -| Q4 | When and where is it used? | During development, at the command line. Developers run `generate` when writing features, `check` to verify consistency, and `clean` to remove stale tests. | -| Q5 | Success — what does "done" look like? | Generated stubs are valid Hypothesis tests. `check` reports zero violations. Unmapped functions are cleanly removed. No beehave imports appear in test code. | -| Q6 | Failure — what must never happen? | Partial output on failure. Tests must never import from beehave. Function name mapping must never be ambiguous (deterministic algorithm only). | -| Q7 | Out-of-scope — what are we explicitly not building? | Test runner, runtime framework, step-definition engine, assertion DSL, synonym resolver, Hypothesis replacement, bulk processor (one feature per invocation), `--dry-run` preview, code formatter/linter, cache/state manager. | - -## Domain Questions - -| ID | Question | Answer | -|----|----------|--------| -| Q8 | How does traceability work? | 1:1 via deterministic function naming: trim scenario name → collapse spaces → replace with `_` → prepend `test_`. E.g. `Scenario: deposit increases balance` → `test_deposit_increases_balance`. | -| Q9 | How are Background steps handled? | They merge into every scenario in scope transparently — no background functions, no special syntax. They appear as additional commented steps in the generated stub. | -| Q10 | How does `check` detect violations? | It re-parses features, AST-parses test files, joins by function name, and reports violations. Body enforcement checks for placeholder names and literal values. | -| Q11 | What is `clean`'s behavior when all functions are removed? | The file retains its import block. The file is never deleted. | -| Q12 | How does `check` report violations? | One line per violation in machine-parseable format: `:: : ` to stdout. | -| Q13 | How does configuration work? | Via `pyproject.toml` under `[tool.beehave]`: features_dir, tests_dir, default_strategy, max_examples. | -| Q14 | How does `generate` handle idempotency? | Generate is idempotent — re-running produces the same output for unchanged features. | - ---- - -## Quality Attributes - -| ID | Attribute | Scenario | Target | Priority | -|----|-----------|----------|--------|----------| -| QA1 | Correctness | When `check` joins scenario to test by function name, the mapping is a deterministic bijection | 100% deterministic, no ambiguity | Must | -| QA2 | Reliability | When any command encounters an error, it reports immediately and exits non-zero | Zero partial output on failure | Must | -| QA3 | Simplicity | When tests are generated, they import only `hypothesis` — never `beehave` | Zero runtime coupling | Must | -| QA4 | Composability | When stable function APIs are consumed by future plugins, they remain backward-compatible | Public API documented and stable | Should | - ---- - -## Pain Points Identified - -- Existing BDD tools couple tests to frameworks at runtime, making tests fragile -- No existing tool enforces that test bodies stay consistent with Gherkin specs -- Unmapped test functions accumulate without automated cleanup - -## Business Goals Identified - -- Single source of truth: Gherkin features drive test generation -- Zero runtime coupling: tests are plain Hypothesis, no framework lock-in -- Automated consistency enforcement via `check` command - -## Terms to Define (for glossary) - -- stub — generated test function with commented steps and placeholder body -- unmapped — test function with no matching scenario in any feature file -- traceability — 1:1 mapping between scenario names and test function names -- body enforcement — AST-based check that test bodies are not placeholders -- idempotent — re-running `generate` produces identical output for unchanged input - -## Action Items - -- [x] Extract product definition from v3 spec -- [x] Extract interview notes from v3 spec -- [ ] Conduct domain discovery (event storming synthesis) diff --git a/docs/spec/domain_model.md b/docs/spec/domain_model.md deleted file mode 100644 index 89614b9..0000000 --- a/docs/spec/domain_model.md +++ /dev/null @@ -1,130 +0,0 @@ -# Domain Model: beehave - -> Current understanding of the business domain. -> Updated by the Domain Expert when domain understanding evolves. -> This document captures what code cannot express: WHY entities exist, HOW aggregates are bounded, and WHAT business capabilities each context serves. -> -> **Evolving document:** Domain Modeling formalizes understanding into the sections below. The Bounded Contexts, Events and Commands, Entities, Relationships, Aggregate Boundaries, and Context Map sections are the canonical structural spec. - ---- - -## Summary - -beehave is a CLI tool that bridges Gherkin `.feature` files and Python test files. It parses feature files into structured scenario data, generates Hypothesis-based test stubs, checks consistency between feature files and existing test code using AST analysis, and cleans up unmapped test functions. The domain is a single pipeline: parse Gherkin → compare against test code → generate/check/clean. There is no runtime coupling between generated tests and beehave itself — tests import only Hypothesis. - ---- - -## Bounded Contexts - -| Context | Responsibility | Key Entities | Business Capability | Why Separate | Integration Points | -|---------|----------------|--------------|---------------------|-------------|-------------------| -| `Gherkin Parsing` | Parse `.feature` files, extract scenarios, steps, placeholders, literals, merge backgrounds | `Feature`, `Scenario`, `ScenarioOutline`, `Step`, `Placeholder`, `Literal`, `Background`, `ExamplesTable`, `Rule` | Transform Gherkin text into structured domain objects | Distinct input format with its own parsing rules, title validation, and background merging logic | Produces structured scenario data consumed by downstream contexts | -| `Test Discovery` | AST-parse Python test files, extract function names, decorators, body nodes, module-level strategies | `TestFunction`, `ModuleStrategy` | Represent existing test code as comparable domain objects | Different input format (Python AST) with its own extraction rules | Produces test function data consumed by consistency checking | -| `Consistency Checking` | Compare parsed Gherkin against discovered tests, enforce body rules, detect unmapped, generate stubs, clean unmapped | `Stub`, `Unmapped`, `ConsistencyReport` | Ensure feature files and test files stay in sync | Pure coordination logic that depends on both upstream contexts but owns the reconciliation semantics | Consumes data from Gherkin Parsing and Test Discovery | - ---- - -## Events and Commands - -### Domain Events - -| Event | Bounded Context | Description | Trigger Command | -|-------|-----------------|-------------|-----------------| -| `StubsGenerated` | `Consistency Checking` | New test stub files created for scenarios without matching test functions | `generate` | -| `ConsistencyChecked` | `Consistency Checking` | Full consistency report produced: body enforcement violations, example bijection failures, unmapped listed | `check` | -| `UnmappedCleaned` | `Consistency Checking` | Unmapped test functions removed from test files | `clean` | -| `FeatureParsed` | `Gherkin Parsing` | A `.feature` file successfully parsed into structured domain objects | Internal | -| `TestFileDiscovered` | `Test Discovery` | A Python test file successfully AST-parsed into test function data | Internal | - -### Commands - -| Command | Bounded Context | Actor | Read Model | Produces Event(s) | Rejection Event | -|---------|-----------------|-------|------------|---------------------|-------------------| -| `generate` | `Consistency Checking` | CLI user (developer) | Parsed features + discovered test functions | `StubsGenerated` | None (best-effort generation) | -| `check` | `Consistency Checking` | CLI user (developer) | Parsed features + discovered test functions | `ConsistencyChecked` | None | -| `clean` | `Consistency Checking` | CLI user (developer) | Discovered test functions + parsed scenario names | `UnmappedCleaned` | None | - ---- - -## Entities - -| Name | Type | Description | Bounded Context | Aggregate Root? | -|------|------|-------------|-----------------|-----------------| -| `Feature` | Entity | A parsed `.feature` file containing scenarios, rules, and an optional background. Identified by globally-unique title. | `Gherkin Parsing` | Yes | -| `Rule` | Entity | A named group of scenarios within a feature. Title unique within parent feature. | `Gherkin Parsing` | No | -| `Scenario` | Entity | A single test case with ordered steps. Maps 1:1 to a test function via deterministic name derivation. | `Gherkin Parsing` | No | -| `ScenarioOutline` | Entity | A parameterized scenario with an examples table. Generates `@example()` decorated test functions. | `Gherkin Parsing` | No | -| `Step` | Value Object | A single Given/When/Then line containing optional placeholders and literals. | `Gherkin Parsing` | — | -| `Placeholder` | Value Object | A `` token in step text that becomes a Hypothesis `@given()` parameter. | `Gherkin Parsing` | — | -| `Literal` | Value Object | A numeric digit sequence or double-quoted string extracted from step text, enforced as an AST `Constant` node. | `Gherkin Parsing` | — | -| `Background` | Entity | Shared steps merged transparently into every scenario in the feature. No placeholders allowed. Both numeric and quoted-string literals enforced by default; configurable via `background_check_numeric` and `background_check_string` in pyproject.toml. | `Gherkin Parsing` | No | -| `ExamplesTable` | Value Object | A table of parameter values for a Scenario Outline, with optional type inference. | `Gherkin Parsing` | — | -| `TestFunction` | Entity | A Python function discovered via AST parsing. Identified by its derived function name. | `Test Discovery` | No | -| `ModuleStrategy` | Value Object | A module-level variable defining a Hypothesis strategy for a placeholder name. | `Test Discovery` | — | -| `Stub` | Value Object | A generated test function with `...` body. Exempt from body enforcement. | `Consistency Checking` | — | -| `Unmapped` | Value Object | A test function with no matching scenario, or a scenario with no matching test function. | `Consistency Checking` | — | - ---- - -## Relationships - -| Subject | Relation | Object | Cardinality | Notes | -|---------|----------|--------|-------------|-------| -| `Feature` | contains | `Rule` | 0:N | Rules group scenarios within a feature | -| `Feature` | contains | `Scenario` | 1:N | Scenarios directly under the feature (outside any Rule) | -| `Feature` | contains | `Background` | 0:1 | At most one background per feature | -| `Rule` | contains | `Scenario` | 1:N | Scenarios grouped under a rule | -| `Rule` | contains | `ScenarioOutline` | 0:N | Outlines can appear under rules | -| `Feature` | contains | `ScenarioOutline` | 0:N | Outlines directly under the feature | -| `Scenario` | has | `Step` | 1:N | Ordered sequence of steps | -| `Scenario` | maps to | `TestFunction` | 1:1 | Via deterministic name derivation | -| `ScenarioOutline` | maps to | `TestFunction` | 1:1 | One function with N `@example()` decorators, one per Examples table row | -| `Step` | contains | `Placeholder` | 0:N | `` tokens in step text | -| `Step` | contains | `Literal` | 0:N | Numeric digits or double-quoted strings | -| `Background` | merged into | `Scenario` | N:M | Background steps prepended to every scenario in scope | -| `ExamplesTable` | provides values for | `Placeholder` | N:1 | Each column header is a placeholder name | -| `Placeholder` | resolved by | `ModuleStrategy` | 0:1 | Module-level strategy variable takes priority | - ---- - -## Aggregate Boundaries - -| Aggregate | Root Entity | Invariants | Why Grouped | Bounded Context | -|-----------|-------------|------------|-------------|-----------------| -| `Feature` | `Feature` | Feature titles globally unique. Scenario titles globally unique across all features. All titles: Unicode letters, digits, spaces only. Background has no placeholders. Rule titles unique within parent feature. | A feature file is the unit of parsing and the basis for directory structure. Everything inside belongs to one file and shares one background scope. | `Gherkin Parsing` | -| `TestFile` | `TestFile` (implied) | Function name is the sole lookup key. No @id tags, no cache. | A test file is the unit of AST discovery. All functions and strategies within it share the same module scope. | `Test Discovery` | - ---- - -## Context Map - -The three contexts form a linear pipeline with no bidirectional relationships. - -| Upstream Context | Downstream Context | Relationship Pattern | Translation / Anti-Corruption Layer | -|-----------------|-------------------|---------------------|-------------------------------------| -| `Gherkin Parsing` | `Consistency Checking` | Conformist | Structured scenario data flows directly; no translation needed | -| `Test Discovery` | `Consistency Checking` | Conformist | Test function data flows directly; no translation needed | - -### Context Map Diagram - -```mermaid -graph LR - GP[Gherkin Parsing] - TD[Test Discovery] - CC[Consistency Checking] - - GP -->|scenario data| CC - TD -->|test function data| CC -``` - -### Anti-Corruption Layers - -No anti-corruption layers needed. All contexts are internal to beehave with no external system integration. - ---- - -## Changes - -| Date | Source | Change | Reason | -|------|--------|--------|--------| -| 2026-05-13 | v3 spec discovery | Initial domain model created | Bootstrap from v3 specification | diff --git a/docs/spec/domain_spec.md b/docs/spec/domain_spec.md deleted file mode 100644 index 0704b19..0000000 --- a/docs/spec/domain_spec.md +++ /dev/null @@ -1,950 +0,0 @@ -# Domain Specification: beehave - -## Context Map - -### Context Relationships - -| Upstream Context | Downstream Context | Relationship Pattern | Translation Notes | -|-----------------|-------------------|---------------------|-------------------| -| Feature Parsing | Consistency Checking | CONFORMIST | ScenarioInfo consumed as-is | -| Feature Parsing | Code Generation | CONFORMIST | ScenarioInfo consumed as-is | -| Feature Parsing | Status Reporting | CONFORMIST | ScenarioInfo and EmptyRuleInfo consumed as-is | -| Test Discovery | Consistency Checking | CONFORMIST | TestInfo consumed as-is | -| Test Discovery | Code Generation | CONFORMIST | TestInfo consumed as-is | -| Test Discovery | Status Reporting | CONFORMIST | TestInfo consumed as-is | -| Consistency Checking | Status Reporting | CONFORMIST | Violation tuples consumed as-is | -| Configuration | Feature Parsing | OHS | Config provides directories, background_check flags | -| Configuration | Test Discovery | OHS | Config provides directory paths only | -| Configuration | Code Generation | OHS | Config provides default_strategy mapping | -| Configuration | Status Reporting | OHS | Config provides directory paths | -| CLI | Feature Parsing | PL | CLI dispatches to parse_feature and detect_empty_rules via cmd_* handlers | -| CLI | Test Discovery | PL | CLI dispatches via cmd_* handlers | -| CLI | Consistency Checking | PL | CLI dispatches via cmd_* handlers | -| CLI | Status Reporting | PL | CLI dispatches via cmd_status handler | - -### Context Map Diagram - -```mermaid -graph TB - CLI[CLI Interface] -->|PL| FeatureParsing[Feature Parsing] - CLI -->|PL| TestDiscovery[Test Discovery] - CLI -->|PL| ConsistencyChecking[Consistency Checking] - CLI -->|PL| StatusReporting[Status Reporting] - Config[Configuration] -->|OHS| FeatureParsing - Config -->|OHS| TestDiscovery - Config -->|OHS| CodeGen[Code Generation] - Config -->|OHS| StatusReporting - FeatureParsing -->|CONFORMIST| ConsistencyChecking - FeatureParsing -->|CONFORMIST| CodeGen - FeatureParsing -->|CONFORMIST| StatusReporting - TestDiscovery -->|CONFORMIST| ConsistencyChecking - TestDiscovery -->|CONFORMIST| CodeGen - TestDiscovery -->|CONFORMIST| StatusReporting - ConsistencyChecking -->|CONFORMIST| StatusReporting -``` - -### Anti-Corruption Layers - -| ACL | Protects Context | From Context | ADR Reference | -|-----|-----------------|--------------|---------------| -| — | — | — | No external systems | - ---- - -## Feature Parsing - -### Context - -Parses Gherkin `.feature` files into structured domain objects. This context owns the canonical representation of a feature: its title, path, background steps, rules, scenarios, placeholders, literals, and examples tables. All downstream contexts consume ScenarioInfo without modification. - -In addition to full parsing, this context provides a lightweight `detect_empty_rules()` function that inspects the Gherkin AST for Rule nodes with zero Scenario children — enabling Status Reporting to distinguish "no scenarios" from "needs scenarios" without a full parse. - -### Entities - -| Name | Type | Purpose | Aggregate Root? | -|------|------|---------|-----------------| -| ScenarioInfo | Entity | Represents a parsed Gherkin scenario with all extracted metadata (steps, placeholders, literals, examples table) | Yes | -| ParsedStep | Value Object | A single Given/When/Then step with extracted keywords, placeholders, and literals | — | -| Placeholder | Value Object | A `` token extracted from step text | — | -| Literal | Value Object | A numeric or quoted-string token extracted from step text | — | -| ExamplesTable | Value Object | The Examples data table from a Scenario Outline | — | -| EmptyRuleInfo | Value Object | Result of lightweight AST inspection: whether a feature has Rules with zero Scenario children, and which ones | — | - -### Relationships - -| Subject | Relation | Object | Cardinality | Notes | -|---------|----------|--------|-------------|-------| -| ScenarioInfo | contains | ParsedStep | 1:N | Merged feature background + rule background + scenario steps | -| ScenarioInfo | contains | Placeholder | 1:N | Deduplicated across all merged steps | -| ScenarioInfo | contains | Literal | 1:N | From scenario steps always; from backgrounds only if config enables | -| ScenarioInfo | references | ExamplesTable | 0:1 | Only for Scenario Outlines | -| Feature | contains | ScenarioInfo | 1:N | Feature file aggregates all scenarios | - -### Aggregate Boundaries - -| Aggregate | Root Entity | Why Grouped | See | -|-----------|-------------|-------------|-----| -| Feature | ScenarioInfo | Feature file is the consistency unit — parsing, generation, checking, and status all operate per-feature | ### Invariants | - -### Data Shapes - -#### ScenarioInfo - -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| title | str | Yes | Non-empty, letters/digits/spaces only | -| function_name | str | Yes | Derived: `test_` + title lowercased with underscores | -| steps | tuple[ParsedStep] | Yes | Merged bg + rule bg + scenario steps | -| placeholders | tuple[Placeholder] | Yes | Deduplicated | -| literals | tuple[Literal] | Yes | Not deduplicated | -| examples | ExamplesTable \| None | No | Present only for Scenario Outlines | -| is_outline | bool | Yes | True if Scenario Outline | -| feature_title | str | Yes | Feature-level title | -| feature_path | str | Yes | Slug derived from feature title | -| rule_path | str | Yes | "default_test" or "_test" | -| line | int | Yes | Line number in .feature file | - -#### TestInfo - -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| function_name | str | Yes | Must match ScenarioInfo.function_name for mapping | -| given_kwargs | tuple[str] | Yes | Parameters from @given() decorator | -| example_rows | tuple[dict[str,object]] | Yes | Row dicts from @example() decorators | -| body_name_nodes | tuple[str] | Yes | All ast.Name identifiers in body (sorted) | -| body_constant_nodes | tuple[object] | Yes | All ast.Constant values in body (sorted) | -| is_stub | bool | Yes | True if body is only `pass` or `...` | -| line | int | Yes | Line number in test file | - -#### Violation - -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| path | str | Yes | File path where violation occurred | -| line | int | Yes | Line number (0 = unknown) | -| error_type | str | Yes | One of: unmapped-scenario, unmapped-test, missing-placeholder, missing-literal, example-mismatch, misplaced-test, invalid-feature-title, invalid-rule-title, invalid-scenario-title, duplicate-feature-title, duplicate-rule-title, duplicate-scenario-title | -| message | str | Yes | Human-readable description | -| is_warning | bool | Yes | Only misplaced-test is a warning | - -#### EmptyRuleInfo - -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| has_empty_rules | bool | Yes | True if any Rule node has zero Scenario children | -| rule_titles | tuple[str] | Yes | Titles of Rule nodes with zero Scenario children, in file order; empty if none | - -### Integration Points - -#### Technology Requirements - -| Context | Requirement | Verification | -|---------|-------------|-------------| -| Feature Parsing | gherkin-official library for AST parsing | grep import gherkin_official | -| Feature Parsing | Regular expressions for token extraction | grep re.compile | -| Feature Parsing | Python keyword/builtin validation | grep keyword.iskeyword | -| Feature Parsing | Title validation across all .feature files | grep validate_all_titles | - -#### Feature Parsing -> Consistency Checking - -- Purpose: Provide parsed scenarios for violation detection -- Trigger: CLI invokes check command -- Mechanism: Direct function call (parse_feature returns dict[str, ScenarioInfo]) -- Pattern: CONFORMIST -- Payload: {function_name: ScenarioInfo} -- Response: None (Consistency Checking consumes and produces Violations independently) -- Error handling: GherkinError raised on parse failure, caught by caller -- Ownership: Feature Parsing context - -#### Feature Parsing -> Status Reporting - -- Purpose: Provide parsed scenarios for stage computation, and empty-rule detection for disambiguation -- Trigger: CLI invokes status command -- Mechanism: Direct function calls (parse_feature returns dict[str, ScenarioInfo]; detect_empty_rules returns EmptyRuleInfo) -- Pattern: CONFORMIST -- Payload: {function_name: ScenarioInfo} plus EmptyRuleInfo -- Response: None -- Error handling: GherkinError caught (parse_feature), feature marked as broken stage; GherkinError from detect_empty_rules caught, feature treated as "no scenarios" -- Ownership: Feature Parsing context - -#### Feature Parsing -> Title Validation - -- Purpose: Validate all .feature file titles for charset, word count, and global uniqueness -- Trigger: CLI invokes check (check_all) or generate command -- Mechanism: Direct function call (validate_all_titles returns list[Violation]) -- Pattern: CONFORMIST -- Payload: list[Violation] with error_type in {invalid-feature-title, invalid-rule-title, invalid-scenario-title, duplicate-feature-title, duplicate-rule-title, duplicate-scenario-title} -- Response: Empty list if all titles valid -- Error handling: GherkinError from any .feature file caught by caller -- Ownership: Feature Parsing context - -### External Contracts - -#### Contract: parse_feature(feature_path, config, seen_function_names=None) -> dict[str, ScenarioInfo] - -- **Actor**: CLI or downstream context (Consistency Checking, Code Generation, Status Reporting) -- **Trigger**: Feature needs to be parsed from disk -- **Input**: {feature_path: Path, config: Config, seen_function_names: set[str] | None} -- **Output**: {function_name: ScenarioInfo} dict keyed by derived test function name -- **Errors**: - - Gherkin syntax error -> GherkinError - - Empty feature file -> GherkinError ("No feature found") - - Invalid feature title -> GherkinError - - Duplicate function name (if seen_function_names provided) -> GherkinError - - Background with placeholders -> GherkinError - - Invalid scenario title -> GherkinError -- **Side Effects**: Reads .feature file from disk -- **Preconditions**: Feature file exists at given path, is valid Gherkin - -#### Contract: _extract_placeholders(text: str) -> tuple[Placeholder, ...] - -- **Actor**: Feature Parsing (called by _parse_step during parse_feature) -- **Trigger**: A Gherkin step text needs placeholder extraction -- **Input**: {text: str} — the text of a single Gherkin step -- **Output**: tuple of Placeholder value objects -- **Rules**: - 1. A `` in step text is a Placeholder iff `token` is a valid Python identifier, not a Python keyword, not a Python builtin - 2. Placeholder regex matches regardless of surrounding quotes (e.g., `""` extracts `` as Placeholder) - 3. Duplicate placeholders within a step are deduplicated - -#### Contract: _extract_literals(text: str) -> tuple[Literal, ...] - -- **Actor**: Feature Parsing (called by _parse_step during parse_feature) -- **Trigger**: A Gherkin step text needs literal extraction -- **Input**: {text: str} — the text of a single Gherkin step -- **Output**: tuple of Literal value objects -- **Rules**: - 1. **Numeric literals**: A bare token matching `^-?\d+(\.\d+)?$` is a numeric Literal. Integer tokens (matching `^-?\d+$`) are stored as `Literal(value=int(token))`. Float tokens with a decimal point (matching `^-?\d+\.\d+$`) are stored as `Literal(value=float(token))`. Leading zeros (e.g., `007`) produce `int("007")` → `7`; downstream `str(7)` → `"7"`, which does NOT match a body `Constant("007")` (string `"007"`). - 2. **String literals**: A quoted segment (`"..."` or `'...'`) is a string Literal with content extracted as-is between quotes. Both double and single quote styles are supported — `''` is handled identically to `""`. Exception: `<...>` inside quotes is skipped regardless of quote style (already captured as Placeholder). `[...]` inside quotes is captured verbatim. Empty quoted strings (`""` or `''`) produce `Literal(value="")` — the zero-length string is extracted and matches body `Constant("")` via `str("")` normalization. - 3. String literals are NOT deduplicated (each occurrence is a separate literal). - -### Invariants - -- `_extract_placeholders` never produces a Placeholder for `` where token is inside quotes AND token is a valid placeholder — the placeholder Regex matches regardless of quotes -- `_extract_literals` never produces a Literal for `<...>` content found inside quoted strings -- `_extract_literals` captures `[...]` inside quotes verbatim (e.g., `"[PHONE]"` → `Literal(value="[PHONE]")`) -- Placeholder extraction and literal extraction can run on the same step text without interference - -#### Contract: detect_empty_rules(feature_path) -> EmptyRuleInfo - -- **Actor**: Status Reporting -- **Trigger**: `parse_feature()` returned an empty dict `{}` and Status Reporting needs to determine whether the feature has no scenarios at all or has Rules with zero Scenario children -- **Input**: {feature_path: Path} -- **Output**: EmptyRuleInfo with `has_empty_rules: bool` and `rule_titles: tuple[str]` -- **Errors**: - - Gherkin syntax error -> GherkinError (caught by Status Reporting, feature treated as "no scenarios") - - File not found -> GherkinError -- **Side Effects**: Reads .feature file from disk (lightweight AST traversal — no ScenarioInfo extraction) -- **Preconditions**: Feature file exists at given path - -#### Contract: validate_all_titles(config) -> list[Violation] - -- **Actor**: Consistency Checking (check_all), Code Generation (generate_stubs pre-flight) -- **Trigger**: All .feature file titles in the project must be validated for charset, word count, and uniqueness -- **Input**: {config: Config} -- **Output**: list of Violation objects for invalid or duplicate titles; empty list if all titles valid -- **Errors**: - - Gherkin syntax error in any .feature file -> GherkinError (caught by caller, feature treated as broken) -- **Side Effects**: Reads all .feature files from disk (lightweight AST traversal — extracts only titles, no ScenarioInfo) -- **Preconditions**: Config is loaded, features_dir exists -- **Validation Rules**: - - Charset: Feature/Rule/Scenario titles must match `[\w\s]+` - - Word Count: 2–6 words on title text only (after stripping Gherkin keyword prefix) - - Uniqueness: Case-insensitive global comparison across all Feature, Rule, and Scenario titles -- **Violation Types Produced**: - - invalid-feature-title: Feature title fails charset or word count - - invalid-rule-title: Rule title fails charset or word count - - invalid-scenario-title: Scenario/Scenario Outline title fails charset or word count - - duplicate-feature-title: Feature title matches another Feature title (case-insensitive) - - duplicate-rule-title: Rule title matches another Rule or any Feature/Scenario title - - duplicate-scenario-title: Scenario title matches another Scenario or any Feature/Rule title - -### State Machines - -Not applicable — Feature Parsing has no internal state. - -### Error Handling - -| Scenario | Response | -|----------|----------| -| Invalid Gherkin syntax | GherkinError raised with line number and message | -| Feature with no scenarios or rules | `parse_feature()` returns empty dict `{}`; `detect_empty_rules()` returns `EmptyRuleInfo(has_empty_rules=False, rule_titles=())` | -| Rule with no scenarios | `parse_feature()` produces no ScenarioInfo entries; `detect_empty_rules()` returns `EmptyRuleInfo(has_empty_rules=True, rule_titles=("Rule title", ...))` | -| GherkinError from detect_empty_rules | Caught by Status Reporting; feature treated as "no scenarios" (conservative fallback) | -| Feature title invalid (charset) | GherkinError raised by parse_feature; invalid-feature-title Violation by validate_all_titles | -| Feature title invalid (word count) | validate_all_titles returns invalid-feature-title Violation | -| Rule title invalid (charset) | GherkinError raised by parse_feature; invalid-rule-title Violation by validate_all_titles | -| Rule title invalid (word count) | validate_all_titles returns invalid-rule-title Violation | -| Scenario title invalid (charset) | GherkinError raised by parse_feature; invalid-scenario-title Violation by validate_all_titles | -| Scenario title invalid (word count) | validate_all_titles returns invalid-scenario-title Violation | -| Duplicate Feature title | validate_all_titles returns duplicate-feature-title Violation with file paths | -| Duplicate Rule title | validate_all_titles returns duplicate-rule-title Violation | -| Duplicate Scenario title | validate_all_titles returns duplicate-scenario-title Violation | -| Title text extraction ambiguous | validate_all_titles strips Gherkin keyword prefix (Feature:/Rule:/Scenario:/Scenario Outline:) before validation; colons in title text are part of the title | - -### Invariants - -- Every scenario produces exactly one ScenarioInfo -- Function names must be valid Python identifiers -- Function names must not be Python keywords or builtins -- Feature title derivation is deterministic (title slug = lowercase, words joined by underscore) -- Background steps are merged transparently into every scenario's step list -- `detect_empty_rules()` makes a single Gherkin AST pass — it does not extract ScenarioInfo or parse step contents -- `validate_all_titles()` makes a single pass over all .feature files, extracting only titles (Feature, Rule, Scenario) — no ScenarioInfo extraction -- All title validation is case-insensitive for uniqueness comparison -- Title word count validation strips the Gherkin keyword prefix before counting — `Feature: Hive Activity` counts 2 words on `Hive Activity` -- `_extract_placeholders` never produces a Placeholder for `` where token is inside quotes AND token is a valid placeholder — the placeholder Regex matches regardless of quotes -- `_extract_literals` never produces a Literal for `<...>` content found inside quoted strings -- `_extract_literals` captures `[...]` inside quotes verbatim (e.g., `"[PHONE]"` → `Literal(value="[PHONE]")`) -- Placeholder extraction and literal extraction can run on the same step text without interference - ---- - -## Test Discovery - -### Context - -Discovers and analyzes Python test files via AST parsing. Extracts test function metadata (decorator arguments, body AST nodes, stub status) to enable consistency checking and code generation. Owns the canonical representation of a test function's structure. - -### Entities - -| Name | Type | Purpose | Aggregate Root? | -|------|------|---------|-----------------| -| TestInfo | Entity | Represents a discovered test function with all extracted metadata | Yes | - -### Relationships - -| Subject | Relation | Object | Cardinality | Notes | -|---------|----------|--------|-------------|-------| -| TestInfo | contains | given_kwargs | 1:N | From @given() decorator | -| TestInfo | contains | example_rows | 0:N | From @example() decorators | -| TestInfo | contains | body_name_nodes | 0:N | ast.Name ids in function body | -| TestInfo | contains | body_constant_nodes | 0:N | ast.Constant values in function body | -| TestFile | contains | TestInfo | 1:N | One file can contain multiple test functions | - -### Aggregate Boundaries - -| Aggregate | Root Entity | Why Grouped | See | -|-----------|-------------|-------------|-----| -| TestFile | TestInfo | Test file is the unit of discovery — all test functions in a file are discovered together | ### Invariants | - -### Integration Points - -#### Technology Requirements - -| Context | Requirement | Verification | -|---------|-------------|-------------| -| Test Discovery | Python AST module for parsing | grep import ast | -| Test Discovery | Hypothesis decorator introspection | grep @given | - -#### Test Discovery -> Consistency Checking - -- Purpose: Provide discovered test functions for violation detection -- Trigger: CLI invokes check command -- Mechanism: Direct function call (discover_tests returns dict[str, TestInfo]) -- Pattern: CONFORMIST -- Payload: {function_name: TestInfo} -- Response: None -- Error handling: SyntaxError caught, file treated as containing zero tests -- Ownership: Test Discovery context - -#### Test Discovery -> Status Reporting - -- Purpose: Provide discovered test functions for stage computation -- Trigger: CLI invokes status command -- Mechanism: Direct function call (discover_tests returns dict[str, TestInfo]) -- Pattern: CONFORMIST -- Payload: {function_name: TestInfo} -- Response: None -- Error handling: SyntaxError caught, file treated as empty; all scenarios become unmapped -- Ownership: Test Discovery context - -### External Contracts - -#### Contract: discover_tests(test_file: Path) -> dict[str, TestInfo] - -- **Actor**: CLI or downstream context -- **Trigger**: Test file needs analysis -- **Input**: {test_file: Path} -- **Output**: {function_name: TestInfo} dict keyed by function name -- **Errors**: - - Python syntax error -> returns empty dict (file treated as empty) - - File not found -> FileNotFoundError (unhandled) -- **Side Effects**: Reads test file from disk -- **Preconditions**: Test file exists and is valid Python -- **Body Node Extraction Rules**: - - `ast.Constant` values are collected directly (e.g., `42` → `42`, `"hello"` → `"hello"`, `3.14` → `3.14`) - - `ast.UnaryOp(USub(), Constant(n))` is folded: `-n` is added to the constant set alongside `n` (e.g., `-2010` exposes both `2010` and `-2010`; `-3.14` exposes both `3.14` and `-3.14`) - - `ast.UnaryOp(UAdd(), Constant(n))` is folded: `+n` exposes `n` (e.g., `+5` exposes `5`, indistinguishable from `x = 5`). Bare `+5` in Gherkin step text is not extracted as a numeric literal (does not match `^-?\d+(\.\d+)?$`). - - The leading docstring expression (first statement if string constant) is excluded from collection - - `ast.Name` identifiers are collected as-is for placeholder matching. Underscore-separated names (e.g., `phone_number`) are distinct from camelCase names (e.g., ``) — `"phonenumber" ≠ "phone_number"` after lowering, so `` does NOT match body identifier `phone_number`. - -#### Contract: discover_tests_dir_with_paths(tests_dir: Path) -> dict[str, tuple[TestInfo, Path]] - -- **Actor**: Consistency Checking (check_all) -- **Trigger**: Need all test functions across all features for cross-feature mapping -- **Input**: {tests_dir: Path} -- **Output**: {function_name: (TestInfo, file_path)} dict across all *_test.py files -- **Side Effects**: Recursively discovers all *_test.py files -- **Preconditions**: tests_dir exists - -### State Machines - -Not applicable — Test Discovery is stateless. - -### Error Handling - -| Scenario | Response | -|----------|----------| -| Test file with Python syntax error | Returns empty dict; all scenarios unmapped | -| Empty test file (0 bytes) | discover_tests returns {} | -| Test file with only imports, no functions | discover_tests returns {} | - -### Invariants - -- Function body is a stub if and only if it contains exactly one statement that is `pass` or `...` -- Leading docstring is excluded from body node analysis -- Stub detection is exact: a body with docstring + pass (2 statements) is NOT a stub -- UnaryOp folding: `ast.UnaryOp(USub(), Constant(n))` exposes `-n` in body_constant_nodes alongside `n`; `ast.UnaryOp(UAdd(), Constant(n))` exposes `n` (indistinguishable from bare `Constant(n)`). -- Body constant collection includes both direct `ast.Constant` values and folded unary-wrapped constants - ---- - -## Consistency Checking - -### Context - -Verifies that parsed feature scenarios and discovered test functions are consistent. Produces violations when scenarios are unmapped, test functions are unmapped, placeholders or literals are missing from test bodies, or example rows don't match. Supports both single-feature (`check_single`) and all-features (`check_all`) modes. - -### Entities - -| Name | Type | Purpose | Aggregate Root? | -|------|------|---------|-----------------| -| Violation | Value Object | A single consistency issue (path, line, error_type, message, is_warning) | — | - -### Relationships - -Not applicable — Violation is a standalone value object. - -### Aggregate Boundaries - -Not applicable — Checking is stateless. - -### Integration Points - -#### Technology Requirements - -| Context | Requirement | Verification | -|---------|-------------|-------------| -| Consistency Checking | Feature parsing | grep from beehave.gherkin import | -| Consistency Checking | Test discovery | grep from beehave.discover import | -| Consistency Checking | Title validation | grep validate_all_titles | - -#### Consistency Checking -> Status Reporting - -- Purpose: Provide per-scenario violations for status computation -- Trigger: Status command computes stage for each scenario -- Mechanism: Direct call to check_pair(si, ti, test_path, feature_rel) -- Pattern: CONFORMIST -- Payload: (ScenarioInfo, TestInfo, Path, Path) -> list[Violation] -- Response: List of violations (empty if all checks pass), skips checks if test is stub -- Ownership: Consistency Checking context - -#### Feature Parsing -> Consistency Checking (Title Validation) - -- Purpose: Validate all .feature file titles for charset, word count, and global uniqueness before scenario-level checks -- Trigger: CLI invokes check command (check_all) -- Mechanism: Direct function call (validate_all_titles returns list[Violation]) -- Pattern: CONFORMIST -- Payload: list[Violation] from validate_all_titles, appended to check_all result -- Response: Title violations are treated as errors (non-warning), contributing to exit code 1 -- Ownership: Consistency Checking context - -### External Contracts - -#### Contract: check_pair(si, ti, test_path, feature_path) -> list[Violation] - -- **Actor**: Status Reporting, Consistency Checking -- **Trigger**: A scenario-test pair needs validation -- **Input**: {si: ScenarioInfo, ti: TestInfo | None, test_path: Path, feature_path: Path} -- **Output**: list of Violation objects -- **Errors**: - - ti is None -> unmapped-scenario violation - - Placeholder name (case-insensitive) not in ti.body_name_nodes -> missing-placeholder violation - - Literal value (string-normalized, case-insensitive) not in ti.body_constant_nodes -> missing-literal violation - - Examples table row count != @example() decorator count -> example-mismatch violation -- **Comparison Rules**: - - **Placeholder comparison**: `ph.name.lower()` ∈ `{n.lower() | n ∈ ti.body_name_nodes}`. `` matches `dog`, `DOG`, `Dog` in test body. - - **Literal comparison**: `str(lit.value).lower()` ∈ `{str(c).lower() | c ∈ ti.body_constant_nodes}`. `int(77000)` matches `Decimal("77000")` (both normalize to `"77000"`). `"Rex"` matches `"rex"` in test body. `True` vs `1`: `"true"` ≠ `"1"` (no false collision). **Known side effect:** `"True"` (Gherkin string literal) matches body `True` (boolean) — both normalize to `"true"` via `str().lower()`. This is an intentional consequence of type-erasing string normalization. Similarly, `"False"` (Gherkin) matches body `False`. If strict type discrimination is needed, it is a separate feature request. -- **Preconditions**: si and ti are properly parsed - -#### Contract: check_single(feature_path, config) -> list[Violation] - -- **Actor**: CLI (beehave check ) -- **Trigger**: Single feature check -- **Input**: {feature_path: Path, config: Config} -- **Output**: All violations for the feature -- **Side Effects**: Reads .feature file, discovers matching test files - -#### Contract: check_all(config) -> list[Violation] - -- **Actor**: CLI (beehave check) -- **Trigger**: Full project check -- **Input**: {config: Config} -- **Output**: All violations across all features, including title validation violations -- **Side Effects**: Reads all .feature files, discovers all *_test.py files; calls validate_all_titles() before scenario-level checks - -### Error Handling - -| Scenario | Response | -|----------|----------| -| Stub test with missing placeholder | No violation (stubs skip all checks) | -| Stub test with missing literal | No violation | -| Stub test with example mismatch | No violation | -| Misplaced test (wrong directory) | Warning only, does not affect exit code | -| Invalid Feature title (charset or word count) | invalid-feature-title Violation from validate_all_titles | -| Invalid Rule title (charset or word count) | invalid-rule-title Violation from validate_all_titles | -| Invalid Scenario title (charset or word count) | invalid-scenario-title Violation from validate_all_titles | -| Duplicate Feature title | duplicate-feature-title Violation from validate_all_titles | -| Duplicate Rule title | duplicate-rule-title Violation from validate_all_titles | -| Duplicate Scenario title | duplicate-scenario-title Violation from validate_all_titles | - -### Invariants - -- Stubs are exempt from all body-based violation checks -- Misplaced-test is the ONLY warning-level violation; all others are errors -- Title validation violations (invalid-*, duplicate-*) are always errors (not warnings) -- Exit code 1 when any non-warning violation exists -- Exit code 0 when only warnings exist -- Placeholder comparison is case-insensitive: `` matches `dog`, `DOG`, `Dog` in test body -- Literal comparison is string-normalized and case-insensitive: both sides normalized via `str().lower()` before comparison -- UnaryOp folding ensures negative literals like `-2010` are visible in body_constant_nodes -- String normalization prevents type mismatches: `int(77000)` from Gherkin matches `str("77000")` from `Decimal("77000")` in body -- `True` and `1` never collide: `str(True).lower() == "true"`, `str(1).lower() == "1"` — different types, different normalized forms -- `True`/`False` and their string counterparts (`"True"`/`"False"`) DO collide via string normalization: both produce the same lowered string. This is an intentional trade-off of the type-erasing comparison strategy. - ---- - -## Code Generation - -### Context - -Generates Hypothesis test stubs from parsed feature files. Creates test directories, __init__.py files, and *_test.py files with @given() decorators for placeholders and @example() decorators for Scenario Outline rows. Preserves existing non-stub test functions during regeneration. - -### Entities - -Not applicable — Code Generation produces files, not domain objects. - -### Integration Points - -#### Technology Requirements - -| Context | Requirement | Verification | -|---------|-------------|-------------| -| Code Generation | Feature parsing | grep from beehave.gherkin import | -| Code Generation | Test discovery | grep from beehave.discover import | -| Code Generation | Hypothesis imports in generated code | grep import hypothesis | -| Code Generation | Zero beehave imports in generated code | grep -v import beehave in generated files | -| Code Generation | Title validation pre-flight | grep validate_all_titles | - -#### Feature Parsing -> Code Generation (Title Validation Pre-Flight) - -- Purpose: Validate all .feature file titles before generating any stubs -- Trigger: CLI invokes generate command (before parse_feature) -- Mechanism: Direct function call (validate_all_titles returns list[Violation]) -- Pattern: CONFORMIST -- Payload: list[Violation]; if non-empty, generation is refused with exit code 1 -- Response: Generation proceeds only when result is empty -- Error handling: GherkinError from validate_all_titles caught, generation refused with exit code 2 -- Ownership: Code Generation context - -### External Contracts - -#### Contract: generate_stubs(feature_path, config) -> None - -- **Actor**: CLI (beehave generate ) -- **Trigger**: Developer needs test stubs for a feature -- **Input**: {feature_path: str, config: Config} -- **Output**: Creates directories and test files on disk -- **Side Effects**: Creates tests/features// directory, __init__.py, and *_test.py files; runs validate_all_titles() pre-flight before any file writes -- **Preconditions**: All .feature files pass title validation (charset, word count, uniqueness); target feature file is valid Gherkin - -### Error Handling - -| Scenario | Response | -|----------|----------| -| Feature with zero scenarios | Returns without creating anything | -| Existing non-stub test function | Preserved, new stub appended for new scenarios only | -| Title validation fails (any .feature file) | SystemExit(1) with violation list printed; zero stubs written | -| GherkinError during title validation | SystemExit(2); zero stubs written | - -### Invariants - -- Generated code imports only from hypothesis — never from beehave -- Stub body is always `...` (Ellipsis) -- Strategy resolution order: module-level variable > Examples table type > default config -- Title validation pre-flight is all-or-nothing: one invalid/duplicate title anywhere blocks all generation -- Zero partial output: no directories or files are created when pre-flight fails - ---- - -## Status Reporting - -### Context - -Computes and displays the development stage of each feature by synthesizing data from Feature Parsing, Test Discovery, and Consistency Checking. Derives stages entirely from disk state — no stored state, no caching. The command is a presentation layer: it reads parsed data and discovered tests, runs consistency checks, and formats results. It uses `detect_empty_rules()` from Feature Parsing as a lightweight fallback when `parse_feature()` returns an empty dict, to distinguish "no scenarios" from "needs scenarios" without duplicating Gherkin parsing logic. - -### Entities - -| Name | Type | Purpose | Aggregate Root? | -|------|------|---------|-----------------| -| ScenarioStatus | Value Object | Computed status of a single scenario (no test / no body / N errors / ok) | — | -| FeatureStatus | Value Object | Computed status of a feature across all its scenarios | — | -| StatusReport | Value Object | Aggregate report of all features, unmapped directories, and collisions | — | -| UnmappedDir | Value Object | Test directory with no matching .feature file | — | -| Collision | Value Object | Cross-feature function name collision (warning, does not affect stage) | — | - -### Relationships - -| Subject | Relation | Object | Cardinality | Notes | -|---------|----------|--------|-------------|-------| -| FeatureStatus | contains | ScenarioStatus | 1:N | One per scenario in the feature | -| StatusReport | contains | FeatureStatus | 1:N | One per .feature file | -| StatusReport | contains | UnmappedDir | 0:N | Test dirs with no matching .feature | -| StatusReport | contains | Collision | 0:N | Cross-feature function name collisions | - -### Aggregate Boundaries - -| Aggregate | Root Entity | Why Grouped | See | -|-----------|-------------|-------------|-----| -| StatusReport | StatusReport | Single output unit — computed in one pass, formatted once | ### Invariants | - -### Data Shapes - -#### ScenarioStatus - -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| title | str | Yes | Scenario title | -| function_name | str | Yes | Derived test function name | -| status | str | Yes | "no test" / "no body" / "{N} errors" / "ok" | -| is_stub | bool | Yes | True if test body is pass/... | -| is_outline | bool | Yes | True if Scenario Outline | -| line | int | Yes | Line number | -| violations | tuple[Violation] | Yes | Violations from check_pair (empty for ok/stub/no test) | - -#### FeatureStatus - -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| path | str | Yes | Feature slug | -| title | str | Yes | Feature title from Gherkin | -| stage | str | Yes | "broken" / "no scenarios" / "needs scenarios" / "needs tests" / "needs bodies" / "needs fixes" / "ok" | -| scenarios_total | int | Yes | Total scenario count | -| scenarios_ok | int | Yes | Passing scenario count | -| scenarios_errors | int | Yes | Failing scenario count | -| scenarios_no_body | int | Yes | Stub scenario count | -| scenarios_no_test | int | Yes | Unmapped scenario count | -| violations_error_count | int | Yes | Total non-warning violations | -| violations_warning_count | int | Yes | Total warning violations | -| parse_error_message | str \| None | No | Set when stage == "broken" | -| scenarios | tuple[ScenarioStatus] | Yes | Per-scenario detail | - -### Integration Points - -#### Technology Requirements - -| Context | Requirement | Verification | -|---------|-------------|-------------| -| Status Reporting | Feature parsing (parse_feature) | grep from beehave.gherkin import parse_feature | -| Status Reporting | Feature parsing (detect_empty_rules) | grep from beehave.gherkin import detect_empty_rules | -| Status Reporting | Test discovery | grep from beehave.discover import | -| Status Reporting | Consistency checking | grep from beehave.check import check_pair | -| Status Reporting | CLI integration | grep status in cli.py | - -### External Contracts - -#### Contract: beehave status [feature] [options] - -- **Actor**: Developer, CI pipeline -- **Trigger**: CLI invocation -- **Input**: Optional feature path slug, optional flags: - - `--json`: Produce machine-readable JSON output with full hierarchy - - `--include-unmapped`: Include unmapped test directories in output -- **Output**: - - Default: Tree-based hierarchy showing feature/rule/scenario status with fixed-width status column - - `--json`: Machine-readable JSON with `features` array, `unmapped_directories` array, `collisions` array, and `summary` object -- **Exit codes**: - - 0: All features ok (or project has zero features) - - 1: At least one feature not ok (i.e., any feature's stage is not "ok") - - 2: Fatal error (features_dir does not exist in config, disk I/O failure) -- **Side Effects**: Reads .feature files and test files from disk. Reads pyproject.toml for config. No writes. -- **Preconditions**: Project has a valid Config (features_dir and tests_dir are resolveable paths) - -### Stage Decision Tree - -Feature stage is determined by evaluating these conditions in priority order (1 through 7). The first condition whose predicate is satisfied determines the feature stage. Each condition is evaluated across all scenarios in the feature; the worst scenario dictates the outcome. - -| Priority | Condition | Stage | -|----------|-----------|-------| -| 1 | `parse_feature()` raises GherkinError | broken | -| 2 | `parse_feature()` returns `{}` AND `detect_empty_rules()` returns `EmptyRuleInfo(has_empty_rules=False)` | no scenarios | -| 3 | `parse_feature()` returns `{}` AND `detect_empty_rules()` returns `EmptyRuleInfo(has_empty_rules=True)` | needs scenarios | -| 4 | Any scenario has no matching test function (unmapped — no TestInfo found) | needs tests | -| 5 | All scenarios mapped AND any matched test is a stub | needs bodies | -| 6 | All scenarios mapped, all non-stub, AND any check_pair() violation | needs fixes | -| 7 | All scenarios mapped, all non-stub, zero violations | ok | - -**GherkinError from detect_empty_rules**: If `detect_empty_rules()` raises GherkinError (e.g., syntax error on the lightweight AST pass), the feature is treated conservatively as `no scenarios`. This avoids degrading to `broken` when the full parse already succeeded. - -### Scenario Status Decision Tree - -Each scenario receives one of four statuses, computed independently of the feature stage: - -| Priority | Condition | Status | -|----------|-----------|--------| -| 1 | No matching TestInfo found | no test | -| 2 | TestInfo.is_stub is True | no body | -| 3 | check_pair() returns non-empty violations | {N} errors | -| 4 | check_pair() returns empty violations | ok | - -### Output Format (Human-Readable) - -Tree-based hierarchy with status labels on the left in a fixed-width column: - -``` -needs fixes hive_activity (Hive Activity) - ok ├── honey production from nectar (3 ex) - 2 errors ├── Hive defense - 2 errors │ ├── guard bee inspects visitor scent, floral - no body │ └── guard bee inspects visitor2 - ok └── Foraging - ok └── forager returns with nectar - - ok comb_construction (Comb Construction) -``` - -- `ok` features collapse to one line -- Rule aggregate shows worst child status with counts -- Scenario Outlines show example count: `(N ex)` -- Failing scenarios show violation codes inline -- Features are separated by a blank line - -### Output Format (JSON) - -The `--json` flag produces a single JSON object with the following structure. This is the composability surface for external tooling — the schema is stable across minor versions. - -```json -{ - "features": [{ - "path": "hive_activity", - "title": "Hive Activity", - "stage": "needs fixes", - "scenarios_total": 4, - "scenarios_ok": 2, - "scenarios_errors": 1, - "scenarios_no_body": 1, - "scenarios_no_test": 0, - "parse_error_message": null, - "violations_error_count": 2, - "violations_warning_count": 0, - "scenarios": [{ - "title": "guard bee inspects visitor", - "function_name": "test_guard_bee_inspects_visitor", - "status": "2 errors", - "is_stub": false, - "is_outline": false, - "line": 23, - "violations": [ - {"error_type": "missing-placeholder", "message": "placeholder 'scent' not found in test body", "line": 23}, - {"error_type": "missing-literal", "message": "literal 'floral' not found in test body", "line": 23} - ] - }] - }], - "unmapped_directories": [ - {"path": "tests/features/removed_feature", "test_files": ["default_test.py"]} - ], - "collisions": [ - {"function_name": "test_login", "paths": ["tests/features/auth/default_test.py", "tests/features/sso/default_test.py"]} - ], - "summary": { - "total_features": 2, - "broken": 0, - "no_scenarios": 0, - "needs_scenarios": 0, - "needs_tests": 0, - "needs_bodies": 0, - "needs_fixes": 1, - "ok": 1 - } -} -``` - -- `unmapped_directories` is empty unless `--include-unmapped` flag is set -- `collisions` contains cross-feature function name duplicates detected during post-processing -- `summary` counts are computed across all features - -### State Machines - -#### Feature Stage Lifecycle - -A feature file progresses through stages as development advances. The lifecycle is implicit — stages are always computed from disk state on each `beehave status` run; no state is stored between invocations. The typical progression follows the Stage Decision Tree priority order from most to least severe: - -| From Stage | To Stage | Trigger | -|-----------|----------|---------| -| (no file) | broken / no scenarios / needs scenarios | Feature file is created and parsed | -| broken | no scenarios / needs scenarios / needs tests / needs bodies / needs fixes / ok | Parse error is fixed | -| no scenarios | needs scenarios | Rules are added (still no Scenarios) | -| no scenarios | needs tests | Scenarios are added to an empty feature | -| needs scenarios | needs tests | Scenarios are added under existing Rules | -| needs tests | needs bodies | All scenarios are mapped to stub test functions | -| needs tests | needs fixes | All scenarios mapped, some with non-stub tests that have violations | -| needs tests | ok | All scenarios mapped, all non-stub, zero violations | -| needs bodies | needs fixes | Stub bodies are replaced with implementation that has violations | -| needs bodies | ok | Stub bodies are replaced with implementation that passes all checks | -| needs fixes | ok | All violations are resolved | -| ok | needs tests / needs bodies / needs fixes | Regression: scenarios added or test bodies changed | - -### Error Handling - -| Scenario | Response | -|----------|----------| -| Parse error in feature (GherkinError from parse_feature) | Stage = "broken", error message in parse_error_message | -| Feature with no scenarios or rules (parse_feature returns {}, detect_empty_rules returns has_empty_rules=False) | Stage = "no scenarios" | -| Feature with Rules but no Scenarios (parse_feature returns {}, detect_empty_rules returns has_empty_rules=True) | Stage = "needs scenarios" | -| GherkinError from detect_empty_rules | Stage = "no scenarios" (conservative fallback — full parse already succeeded) | -| Test file with Python syntax error (discover_tests returns {}) | All scenarios unmapped → stage = "needs tests" | -| Rule aggregate: mixed status | Shows worst status with counts (e.g., "1 error, 1 no body") | -| Unmapped test directory (no matching .feature file) | Reported in unmapped_directories if --include-unmapped; never affects any feature stage | -| Cross-feature function name collision | Reported in collisions array; warning only, does not affect exit code or any feature stage | -| Features directory does not exist | Fatal: exit code 2, error message to stderr | -| Disk I/O failure during feature read | Fatal: exit code 2, error message to stderr | -| Empty project (zero .feature files) | Exit code 0 (no features to be non-ok), StatusReport with no features | - -### Invariants - -- **Correctness**: Feature stage is determined by evaluating the Stage Decision Tree conditions in priority order (1 through 7). The first condition whose predicate is satisfied by any scenario determines the feature stage. Given the same disk state, the same stage is always produced — stage derivation is deterministic. -- **Reliability**: Zero partial output. If any feature parse fails (GherkinError), that feature gets `broken` stage — not a crash. The StatusReport is always complete: every feature in the features directory appears in the output, even if some are broken. If the features_dir itself is missing or unreadable, the command exits 2 without producing partial output. -- **Simplicity**: Status Reporting imports from beehave internally (gherkin, discover, check). This is expected — it is a beehave command, not generated code. -- **Composability**: The `--json` output is the composability surface for external tooling (CI systems, dashboards, editors). The JSON schema (features array, summary object, unmapped_directories array, collisions array) is stable within a major version. Machine consumers should parse the JSON output, not the human-readable tree. -- **Stage Immutability**: Parse errors are first-class stages, not side-channel stderr output. A broken feature contributes to the StatusReport and exits 1 (not 2) because the project may still have useful work to do on other features. -- **Warning Isolation**: Warnings (misplaced tests from check_pair, cross-feature name collisions from post-processing) do not affect feature stage or exit code. They are informational only. -- **Exit Code Semantics**: Exit 0 when all features are ok (or zero features). Exit 1 when at least one feature is not ok. Exit 2 on fatal errors (config missing, I/O failure). This mirrors `beehave check` semantics. - ---- - -## CLI Interface - -### Context - -Provides the user-facing command-line interface for all beehave operations. Uses argparse for subcommand dispatch. Owns the mapping from CLI flags to context function calls. - -### Entities - -Not applicable — CLI is a dispatch layer. - -### Integration Points - -#### Technology Requirements - -| Context | Requirement | Verification | -|---------|-------------|-------------| -| CLI Interface | argparse for subcommand parsing | grep import argparse | -| CLI Interface | Entry point: beehave = "beehave.cli:main" | grep beehave pyproject.toml | - -### External Contracts - -#### Commands - -| Command | Args | Context Called | -|---------|------|---------------| -| generate | feature (required) | Code Generation | -| check | feature (optional) | Consistency Checking | -| clean | feature (required), --force | Cleanup | -| list | --verbose | Status Reporting (existing list command) | -| status | feature (optional), --json, --include-unmapped | Status Reporting (new) | - -#### Exit Code Contract - -All beehave commands follow consistent exit code semantics: - -| Exit Code | Meaning | When | -|-----------|---------|------| -| 0 | Success | No non-warning violations (check), all features ok (status), generation succeeded (generate), cleanup succeeded (clean) | -| 1 | Work to do | Non-warning violations exist (check), at least one feature not ok (status) | -| 2 | Fatal error | Config missing/unreadable, features_dir does not exist, disk I/O failure, invalid subcommand | - -### State Machines - -Not applicable. - -### Error Handling - -| Scenario | Response | -|----------|----------| -| Config file missing | Uses defaults, no error (features_dir defaults to "docs/features") | -| Invalid subcommand | argparse shows help, exits 2 | -| features_dir does not exist | Exit 2 with error to stderr | - -### Invariants - -- All commands write to stdout, errors to stderr (except status: parse errors are stdout stages within the StatusReport) -- Exit code semantics are consistent across all commands -- Subcommand dispatch is stateless — no stored state between invocations - ---- - -## Configuration - -### Context - -Provides project configuration loaded from pyproject.toml under `[tool.beehave]`. Defines directory paths, default strategy, and background check flags. All contexts consume Config as an immutable frozen dataclass. - -### Entities - -| Name | Type | Purpose | Aggregate Root? | -|------|------|---------|-----------------| -| Config | Value Object | Immutable configuration with directory paths, strategy, and boolean flags | — | - -### Data Shapes - -#### Config - -| Field | Type | Required | Constraints | -|-------|------|----------|-------------| -| features_dir | str | Yes | Default: "docs/features" | -| tests_dir | str | Yes | Default: "tests/features" | -| default_strategy | str | Yes | One of: text, integers, floats, booleans | -| background_check_numeric | bool | Yes | Default: True | -| background_check_string | bool | Yes | Default: True | - -### Integration Points - -#### Technology Requirements - -| Context | Requirement | Verification | -|---------|-------------|-------------| -| Configuration | TOML parsing via tomllib | grep import tomllib | -| Configuration | Strategy map for Hypothesis expressions | grep _STRATEGY_MAP | - -### External Contracts - -#### Contract: load_config(project_root=None) -> Config - -- **Actor**: CLI, all contexts -- **Trigger**: Command execution begins -- **Input**: {project_root: Path | None} -- **Output**: Config with values from pyproject.toml or defaults -- **Side Effects**: Reads pyproject.toml -- **Preconditions**: Running from within a beehave project tree - -### Error Handling - -| Scenario | Response | -|----------|----------| -| pyproject.toml missing | Returns Config with all defaults | -| Invalid default_strategy value | ValueError | - -### Invariants - -- Config is frozen — never mutated after creation -- Strategy expressions are stable mappings (text -> st.text(), integers -> st.integers(), etc.) diff --git a/docs/spec/glossary.md b/docs/spec/glossary.md deleted file mode 100644 index 3bf1417..0000000 --- a/docs/spec/glossary.md +++ /dev/null @@ -1,300 +0,0 @@ -# Glossary: beehave - -> Living glossary of domain terms used in this project. -> Written and maintained by the Domain Expert during Discovery. -> Append-only: never edit or remove past entries. If a term changes, mark it retired in favor of the new entry and write a new entry. -> Code and tests take precedence over this glossary — if they diverge, refactor the code, not this file. - ---- - -## Entry Format - -``` -## - -**Definition:** - -**Aliases:** - -**Example:** - -**Source:** -``` - -Entries are sorted alphabetically. - ---- - -## Background - -**Definition:** A Gherkin block of shared steps that are transparently merged into every scenario in its containing feature, with no placeholders allowed. Both numeric and quoted-string literals are enforced by default; configurable via `background_check_numeric` and `background_check_string` in pyproject.toml. - -**Aliases:** none - -**Example:** A `Background:` block with `Given a user is logged in` prepends that step to all scenarios in the feature. - -**Source:** 2026-05-13 - ---- - -## Bijection - -**Definition:** A one-to-one correspondence between the example rows in a Scenario Outline's Examples table and the `@example()` decorated test functions in the matching test file. - -**Aliases:** examples bijection - -**Example:** A Scenario Outline with 3 example rows produces one test function with exactly 3 `@example()` decorators — no more, no fewer. - -**Source:** 2026-05-13 - ---- - -## Body Enforcement - -**Definition:** An AST-level check that every placeholder in a scenario step appears as a `Name` node and every literal appears as a `Constant` node in the corresponding test function body. - -**Aliases:** body check, enforcement - -**Example:** If a step contains `` and `"hello"`, the test body must reference a variable `count` and contain the string constant `"hello"`. - -**Source:** 2026-05-13 - ---- - -## Examples Table - -**Definition:** A Gherkin data table associated with a Scenario Outline that provides concrete parameter values for each example row, with optional type inference from cell values. - -**Aliases:** Examples - -**Example:** An Examples table with columns `| count | name |` and rows `| 3 | "Alice" |` provides values for the `` and `` placeholders. - -**Source:** 2026-05-13 - ---- - -## Feature - -**Definition:** A Gherkin source file (`.feature`) containing a globally-unique title, an optional Background, Rules, Scenarios, and Scenario Outlines that serves as the root aggregate for parsing and determines the test directory structure. - -**Aliases:** Feature File - -**Example:** `docs/features/cart/add_item.feature` with title "Add item to cart" maps to `tests/features/cart/add_item/default_test.py`. - -**Source:** 2026-05-13 - ---- - -## Function Name - -**Definition:** The sole lookup key for matching scenarios to test functions, derived deterministically from a scenario title by trimming whitespace, collapsing internal spaces to underscores, and prepending `test_`. - -**Aliases:** derived name, test name - -**Example:** Scenario title `"Add item when cart is empty"` becomes function name `test_add_item_when_cart_is_empty`. - -**Source:** 2026-05-13 - ---- - -## Import Block - -**Definition:** The section of a Python test file containing import statements that must be preserved when unmapped functions are cleaned from the file. - -**Aliases:** imports, import section - -**Example:** `from hypothesis import given, example, strategies as st` at the top of a generated test file. - -**Source:** 2026-05-13 - ---- - -## Literal - -**Definition:** A value extracted from step text — either a sequence of numeric digits or a double-quoted string — that must appear as an AST `Constant` node in the matching test function body. - -**Aliases:** literal value - -**Example:** In step `Given 3 items with name "Alice"`, the literals are `3` (numeric) and `"Alice"` (quoted string). - -**Source:** 2026-05-13 - ---- - -## Module-Level Strategy - -**Definition:** A Python variable defined at module scope in a test file whose name matches a placeholder and whose value is a Hypothesis strategy, taking highest priority during strategy resolution. - -**Aliases:** module strategy, strategy variable - -**Example:** `count_strategy = st.integers(min_value=0, max_value=100)` resolves the `` placeholder without needing a default. - -**Source:** 2026-05-13 - ---- - -## Unmapped - -**Definition:** A test function with no matching scenario in any feature file, or a scenario with no matching test function in the expected test file. - -**Aliases:** unmapped function, unmapped scenario - -**Example:** A function `test_old_behavior` remaining after the corresponding scenario was deleted from the feature file. - -**Source:** 2026-05-13 - ---- - -## Placeholder - -**Definition:** A `` token in Gherkin step text that represents a parameterized value and becomes a Hypothesis `@given()` parameter in the generated test function. - -**Aliases:** template variable, parameter - -**Example:** In step `Given items`, the placeholder `` generates a `@given(count=...)` decorator. - -**Source:** 2026-05-13 - ---- - -## Rule - -**Definition:** A named organizational block within a Gherkin Feature that groups related scenarios, with a title that must be globally unique (case-insensitive) across all Feature, Rule, and Scenario titles in the project. - -**Aliases:** none - -**Example:** `Rule: Cart total calculation` grouping scenarios about cart arithmetic. - -**Source:** 2026-05-13 - ---- - -## Scenario - -**Definition:** A single test case defined by a title and an ordered sequence of Given/When/Then steps, mapped 1:1 to a test function via deterministic name derivation. The title must be globally unique (case-insensitive) across all Feature, Rule, and Scenario titles in the project. - -**Aliases:** test scenario - -**Example:** `Scenario: Add item to empty cart` with steps `Given an empty cart` / `When I add an item` / `Then the cart has one item`. - -**Source:** 2026-05-13 - ---- - -## Scenario Outline - -**Definition:** A parameterized scenario template combined with an Examples table that generates a single test function decorated with one `@example()` per table row. - -**Aliases:** Scenario Template, Outline - -**Example:** A Scenario Outline with `` in a step and 3 example rows produces one test function with 3 `@example()` decorators. - -**Source:** 2026-05-13 - ---- - -## Step - -**Definition:** A single Given, When, or Then line in a Gherkin scenario that may contain placeholders and literals, forming the basis for body enforcement checks. - -**Aliases:** step line, scenario step - -**Example:** `Given items with name "Alice"` contains one placeholder (``) and two literals (`"Alice"` and potentially `count`'s resolved value). - -**Source:** 2026-05-13 - ---- - -## Strategy - -**Definition:** A Hypothesis strategy that determines how placeholder values are generated, resolved by checking (1) module-level variable, (2) Examples table type inference, (3) default configuration, in priority order. - -**Aliases:** Hypothesis strategy, generation strategy - -**Example:** The placeholder `` resolves to `st.integers()` by default unless a module-level `count_strategy` variable or an Examples table column type overrides it. - -**Source:** 2026-05-13 - ---- - -## Stub - -**Definition:** A generated test function with a body containing only `...` (Ellipsis) or `pass`, which is exempt from all body enforcement checks. - -**Aliases:** test stub, generated stub - -**Example:** `def test_add_item_to_empty_cart(): ...` is generated for a scenario with no existing matching function. - -**Source:** 2026-05-13 - ---- - -## Type Inference - -**Definition:** The process of determining a Hypothesis strategy by analyzing the values in an Examples table column, used as a fallback when no module-level strategy exists. - -**Aliases:** Examples table type inference - -**Example:** An Examples column containing `1`, `5`, `10` infers an integer strategy; a column containing `"Alice"`, `"Bob"` infers a string strategy. - -**Source:** 2026-05-13 - ---- - -## Feature Stage - -**Definition:** A label computed by `beehave status` for each `.feature` file, derived from the worst status of its constituent scenarios. - -**Aliases:** stage - -**Example:** A feature with 2 passing scenarios and 1 stub scenario has stage `needs bodies`. - -**Source:** 2026-05-19 - ---- - -## Scenario Status - -**Definition:** A label computed by `beehave status` for each scenario, indicating whether it has a test, whether the test body is implemented, and whether `beehave check` reports any violations. - -**Aliases:** status - -**Example:** A scenario whose matching test function is a stub has status `no body`. - -**Source:** 2026-05-19 - ---- - -## Status Report - -**Definition:** The aggregate output of `beehave status` containing feature statuses, unmapped test directories, cross-feature function name collisions, and summary counts by stage. - -**Aliases:** status output - -**Example:** Running `beehave status` produces a tree-based Status Report with one line per feature heading and indented scenario detail. - -**Source:** 2026-05-19 - ---- - -## Unmapped Directory - -**Definition:** A test directory under tests/features/ that has no corresponding .feature file with a matching feature_path slug. - -**Aliases:** unmapped dir - -**Example:** A `tests/features/checkout/` directory with test files but no `docs/features/checkout.feature` is reported as an unmapped directory by `beehave status --include-unmapped`. - -**Source:** 2026-05-20 - ---- - -## Collision - -**Definition:** A cross-feature function name duplication where two scenarios in different .feature files produce the same derived test function name. - -**Aliases:** cross-feature collision - -**Example:** `Feature: Auth` with `Scenario: login` and `Feature: SSO` with `Scenario: login` both derive `test_login`, producing a Collision reported by `beehave status`. - -**Source:** 2026-05-20 diff --git a/docs/spec/product_definition.md b/docs/spec/product_definition.md deleted file mode 100644 index 003a7dd..0000000 --- a/docs/spec/product_definition.md +++ /dev/null @@ -1,107 +0,0 @@ -# Product Definition: beehave - -> **Status:** BASELINED (2026-05-13) -> This document is the single source of truth for project scope and conventions. - ---- - -## What beehave IS - -- A code generator (`beehave generate`) that produces pure Hypothesis `@given()`/`@example()` stubs from Gherkin `.feature` files — one feature per invocation -- A consistency checker (`beehave check`) that re-parses features, AST-parses tests, joins by function name, and reports violations in machine-parseable format -- A cleanup tool (`beehave clean`) that removes unmapped test functions — retains import block even if all functions are removed, never deletes the file -- A status reporter (`beehave status`) that computes and displays the development stage of every feature in a project - -## What beehave IS NOT - -- Does NOT act as a test runner, runtime framework, step-definition engine, or assertion DSL -- Does NOT resolve synonyms or replace Hypothesis in any way -- Does NOT provide `--dry-run` preview (acknowledged as future enhancement, not v3 scope) -- Does NOT manage cache/state, format code, or lint - -## Why does this exist - -Python developers using Gherkin for behavior specification need their test code to stay synchronized with their feature files. Existing BDD tools inject runtime frameworks into test code, creating coupling and fragility. beehave generates plain Hypothesis tests with zero imports from beehave itself, and enforces consistency through AST-based checking — giving developers spec-to-test traceability without framework lock-in. - -## Users - -- **Python developer** — Writes Gherkin feature files and wants generated Hypothesis test stubs that stay consistent with those specs. Uses `generate`, `check`, and `clean` during the development cycle. - -## Quality Attributes - -| Attribute | Scenario | Target | Priority | -|-----------|----------|--------|----------| -| Correctness | When `check` maps scenarios to test functions, the function-name derivation is deterministic and produces a bijection | 100% deterministic mapping, zero ambiguity | Must | -| Reliability | When any command encounters an error, it reports immediately and exits non-zero | Zero partial output on failure | Must | -| Simplicity | When tests are generated, they import only `hypothesis` — never `beehave` | Zero beehave imports in generated test code | Must | -| Composability | When external tooling consumes beehave's function APIs, they remain stable across versions | Public API documented and backward-compatible | Should | - ---- - -## Out of Scope - -- Test runner or execution engine -- Runtime framework or step-definition system -- Assertion DSL or synonym resolution -- `--dry-run` preview mode -- Plugin system (APIs are designed for future composability but no plugin interface in v3) -- Code formatting or linting -- Cache or state management - ---- - -## Project Conventions - -### Definition of Done - -All criteria must be met before a feature is considered done. - -**Development:** - -- [ ] All BDD scenarios from the .feature file pass -- [ ] Quality Gate passes all three tiers (Design → Structure → Conventions) -- [ ] Test coverage meets project threshold (≥ 80%) -- [ ] No test coupling — tests verify behavior, not structure -- [ ] Production code follows priority order: YAGNI > DRY > KISS > OC > SOLID > Design Patterns -- [ ] Code uses ubiquitous language from glossary.md - -**Review:** - -- [ ] CI pipeline passes all three tiers (Design → Structure → Conventions) -- [ ] Code Review approved by R (independent reviewer, not the SE who wrote the code) -- [ ] Acceptance Testing passed — PO verifies BDD scenarios behave as expected - -**Deployment:** - -- [ ] Release Verification checklist completed -- [ ] CHANGELOG.md updated - -### Deployment - -**Deployment type:** CLI - -#### Common (all deployment types) - -- [ ] Version bumped in pyproject.toml -- [ ] CHANGELOG.md updated with version and delivered scenarios -- [ ] Git tag created (format: `v`) - -#### CLI / Library - -- [ ] Package builds without errors (`python -m build`) -- [ ] Package published to PyPI (`twine upload dist/*`) -- [ ] Installable from PyPI in clean environment - -### Branch Strategy - -- **Convention:** Trunk-based (short-lived feature branches from trunk, PR before merge) -- **Branch naming:** `feature/` (e.g., `feature/gherkin-parser`) -- **Merge policy:** Squash merge to trunk after approval - ---- - -## Scope Changes - -| Date | Session | Change | Reason | -|------|---------|--------|--------| -| 2026-05-13 | IN_20260513_discovery | Initial product definition baselined from v3 spec | Discovery phase kickoff | diff --git a/docs/spec/status_command_spec.md b/docs/spec/status_command_spec.md deleted file mode 100644 index 24b4562..0000000 --- a/docs/spec/status_command_spec.md +++ /dev/null @@ -1,344 +0,0 @@ -# beehave status — Command Specification - -**Author:** System Architect (revised by PO 2026-05-19) -**Date:** 2026-05-19 -**Status:** Finalized -**Supersedes:** Original Draft (SA, 2026-05-19) -**Authoritative:** domain_spec.md § Status Reporting - ---- - -## 1. Overview - -The `beehave status` command reports the development stage of each feature in the project. Stages are derived entirely from data already computable by the existing codebase: Gherkin parsing (`parse_feature()`), test discovery (`discover_tests()`), and consistency checking (`check_pair()`). No new parsing or heuristic logic is needed — the command is a **presentation layer** over existing infrastructure. - ---- - -## 2. Stage Taxonomy - -### 2.1 Feature Stages - -A feature (`.feature` file) is in exactly one of seven stages. The decision tree is evaluated in priority order — the first matching condition determines the stage. - -#### Decision Tree - -| Priority | Condition | Stage | -|----------|-----------|-------| -| 1 | `parse_feature()` raises `GherkinError` | `broken` | -| 2 | `parse_feature()` returns `{}` (0 scenarios) | `no scenarios` | -| 3 | Feature has Rules but every Rule has zero Scenarios | `needs scenarios` | -| 4 | Any scenario has no matching test function (`ti is None`) | `needs tests` | -| 5 | All scenarios mapped AND any matched test is a stub (`ti.is_stub`) | `needs bodies` | -| 6 | All scenarios mapped, all non-stub, AND any `check_pair()` violation (errors only) | `needs fixes` | -| 7 | All scenarios mapped, all non-stub, zero violations | `ok` | - -#### Stage Definitions - -| Stage | Meaning | Next Action | -|-------|---------|-------------| -| `broken` | Feature file is syntactically or semantically broken (bad Gherkin, duplicate function name, invalid title, etc.) | Fix the `.feature` file | -| `no scenarios` | Feature file parses but contains no Scenario or Scenario Outline children | Add scenarios or delete the file | -| `needs scenarios` | Feature has Rule blocks but no Scenarios inside any Rule | Add Scenarios to existing Rules | -| `needs tests` | One or more scenarios have no matching test function | Run `beehave generate ` | -| `needs bodies` | Every scenario has a test function, but at least one is a stub | Replace stub bodies with real test logic | -| `needs fixes` | All tests are non-stub, but at least one has violations | Fix the implementation | -| `ok` | All scenarios mapped, all tests non-stub, zero violations | None — feature is complete | - -### 2.2 Scenario Status (per-scenario granularity) - -| Status | Criteria | -|--------|----------| -| `no test` | No matching test function exists | -| `no body` | Matching test exists with stub body (`pass` / `...`) | -| `N errors` | Non-stub test with N violations from `check_pair()` | -| `ok` | Non-stub test with zero violations | - -The feature stage is the "worst" scenario status: `no test` > `no body` > `N errors` > `ok`. - -### 2.3 Non-Feature Artifacts - -#### Unmapped Test Directories - -A test directory (`tests/features//`) with no corresponding `.feature` file. Reported separately when `--include-unmapped` is used. - ---- - -## 3. Edge Case Resolution - -### 3.1 Malformed Feature File - -Call `parse_feature()` inside a try/except. On `GherkinError`, mark the feature as `broken`. Include the error message in verbose output. Do NOT crash or skip silently. - -### 3.2 Empty Feature File - -`parse_feature()` raises `GherkinError`. Treat as `broken`. - -### 3.3 Feature With Zero Scenarios - -`parse_feature()` returns `{}`. Stage = `no scenarios`. - -### 3.4 Feature With Rules But No Scenarios - -`parse_feature()` returns `{}` but Gherkin AST has Rule nodes with zero Scenario children. Stage = `needs scenarios`. Distinct from `no scenarios` — the feature has Rule structure, just no testable content inside those Rules. - -### 3.5 Feature File Changed After Generation - -- Renamed scenario → old test is now unmapped. Feature stage → `needs tests`. -- Added placeholder → `missing-placeholder` violation. If test was non-stub → `needs fixes`. -- Stubs mask violations — a feature can go `needs tests` → `needs bodies` → `needs fixes` as stubs get implemented. - -### 3.6 Mixed-Status Feature - -Feature stage = worst scenario status. A feature with 2 ok, 1 no body, 1 no test → `needs tests`. - -### 3.7 Cross-Feature Function Name Collision - -Parse features independently (no shared `seen_fn`). Detect collisions in post-processing. Collisions are warnings — they do not degrade the stage. - -### 3.8 Misplaced Test - -Test in wrong directory still matches its scenario by `function_name`. Generates a `misplaced-test` warning. Warnings do not affect the feature stage. - -### 3.9 Scenario Outline With Incomplete Example Coverage - -`check_pair()` produces `example-mismatch` violations. Non-stub test → stage `needs fixes`. Stub test → mismatches not checked. - ---- - -## 4. Output Format Specification - -### 4.1 Default: Tree Hierarchy - -Tree-based format showing Feature → Rule → Scenario. Status labels in a fixed-width left column. Plain text labels, no symbols. - -``` -needs fixes hive_activity (Hive Activity) - ok ├── honey production from nectar (3 ex) - 2 errors ├── Hive defense - 2 errors │ ├── guard bee inspects visitor scent, floral - no body │ └── guard bee inspects visitor2 - ok └── Foraging - ok └── forager returns with nectar - - ok comb_construction (Comb Construction) - -needs bodies dance_language (Waggle Dance Communication) - no body ├── round dance - no body └── waggle dance - -no scenarios future_ideas (Future Ideas) - -needs scenarios temperature_control (Temperature Control) - no scenarios ├── Heating - no scenarios ├── Cooling - no scenarios └── Ventilation -``` - -**Format rules:** -- Status column on left, fixed-width (padded to longest status label) -- Tree characters (`├──`, `│`, `└──`) for hierarchy -- `ok` features collapse to one line — no tree expansion -- Rule aggregate shows breakdown of child statuses with counts (`2 errors`, `1 error, 1 no body`) -- Scenario Outlines show example count: `(N ex)` -- Failing scenarios show violation codes inline after the scenario name -- `no scenarios` for features with no children; `needs scenarios` for features with Rules but no Scenarios -- `--json` flag outputs full JSON with feature/scenario hierarchy, counts, and violation details - -### 4.2 JSON Output (`--json`) - -```json -{ - "features": [ - { - "path": "hive_activity", - "title": "Hive Activity", - "stage": "needs fixes", - "scenarios_total": 4, - "scenarios_ok": 2, - "scenarios_errors": 1, - "scenarios_no_body": 1, - "scenarios_no_test": 0, - "violations_error_count": 2, - "violations_warning_count": 0, - "parse_error_message": null, - "scenarios": [ - { - "title": "guard bee inspects visitor", - "function_name": "test_guard_bee_inspects_visitor", - "status": "2 errors", - "is_stub": false, - "is_outline": false, - "line": 23, - "violations": [ - {"path": "...", "line": 23, "error_type": "missing-placeholder", "message": "'scent' not found", "is_warning": false} - ] - } - ] - } - ], - "unmapped_directories": [], - "collisions": [], - "summary": { - "total_features": 2, - "broken": 0, - "no_scenarios": 0, - "needs_scenarios": 0, - "needs_tests": 0, - "needs_bodies": 1, - "needs_fixes": 1, - "ok": 0 - } -} -``` - -### 4.3 Color / ANSI Rules - -| Stage | Color | ANSI | -|-------|-------|------| -| `ok` | Green | `\033[32m` | -| `needs fixes` | Red | `\033[31m` | -| `needs bodies` | Yellow | `\033[33m` | -| `needs tests` | Magenta | `\033[35m` | -| `needs scenarios` | Blue | `\033[34m` | -| `broken` | Red bold | `\033[1;31m` | -| `no scenarios` | Dim | `\033[2m` | - -**Color control:** Auto-detect `sys.stdout.isatty()`. Environment variable fallback: `NO_COLOR` / `FORCE_COLOR`. - ---- - -## 5. CLI Interface - -### 5.1 Command Signature - -``` -beehave status [feature] [options] -``` - -### 5.2 Arguments - -| Argument | Type | Description | -|----------|------|-------------| -| `feature` | positional, optional | Feature path slug (without extension). When provided, shows status for only that feature. When omitted, shows status for all features. | - -### 5.3 Options - -| Flag | Description | -|------|-------------| -| `--json` | Output machine-readable JSON to stdout. | -| `--include-unmapped` | Show unmapped test directories. | - -### 5.4 Exit Codes - -| Code | Meaning | -|------|---------| -| 0 | All features are `ok` (or `no scenarios`/`needs scenarios`, or no features exist) | -| 1 | At least one feature is `broken`, `needs tests`, `needs bodies`, or `needs fixes` | -| 2 | Fatal error: features directory not found, disk I/O error, unhandled exception | - -### 5.5 Examples - -```bash -$ beehave status # Show all features -$ beehave status hive_activity # Single feature -$ beehave status --json | jq '.summary' # Machine-readable -$ beehave status --include-unmapped # Include unmapped test dirs -``` - -### 5.6 Stderr Usage - -- ALL output (table, JSON, summary) goes to **stdout**. -- **stderr** is used only for fatal runtime errors. -- `broken` features are reported in stdout as part of the table, NOT on stderr. - ---- - -## 6. Implementation Strategy - -### 6.1 New Module: `beehave/status.py` - -``` -beehave/status.py -├── ScenarioStatus dataclass -├── FeatureStatus dataclass -├── StatusReport dataclass -├── compute_scenario_status() # ScenarioInfo + TestInfo + Violations → ScenarioStatus -├── compute_feature_status() # Feature path → FeatureStatus (parses, discovers, checks) -├── compute_all_status() # All features → StatusReport -├── format_tree() # StatusReport → str (tree-based output) -├── format_json() # StatusReport → str (JSON) -└── format_summary() # StatusReport → str (summary footer) -``` - -### 6.2 Changes to Existing Modules - -**`beehave/models.py`** (+3 dataclasses): -```python -@dataclass(frozen=True) -class ScenarioStatus: - title: str - function_name: str - status: str - is_stub: bool - is_outline: bool - line: int - violations: tuple[Violation, ...] - -@dataclass(frozen=True) -class FeatureStatus: - path: str - title: str - stage: str - scenarios_total: int - scenarios_ok: int - scenarios_errors: int - scenarios_no_body: int - scenarios_no_test: int - violations_error_count: int - violations_warning_count: int - parse_error_message: str | None - scenarios: tuple[ScenarioStatus, ...] - -@dataclass(frozen=True) -class StatusReport: - features: tuple[FeatureStatus, ...] - unmapped_directories: tuple[dict, ...] - collisions: tuple[dict, ...] - summary: dict[str, int] -``` - -**`beehave/cli.py`** (+~50 lines): Add `cmd_status(args)` and `status` subparser. - -**Unchanged:** `gherkin.py`, `discover.py`, `check.py`, `config.py`, `generate.py`, `clean.py` - -### 6.3 Files Affected - -- **New:** `beehave/status.py` (~300 lines) -- **Modified:** `beehave/models.py` (+3 dataclasses, ~70 lines), `beehave/cli.py` (+~50 lines) -- **Unchanged:** Everything else -- **New tests:** `tests/test_status.py` (~400 lines), additions to `tests/test_cli.py` (~100 lines) - ---- - -## 7. Design Decisions Not Made Here - -- **Caching:** No file-mtime-based caching in v1 -- **Parallel parsing:** No `concurrent.futures` in v1 -- **Historical status tracking:** No `--since ` or trend reporting -- **Watch mode:** No `--watch` flag -- **Auto-fix suggestions:** Status describes state, does not suggest corrective actions - ---- - -## 8. Summary - -The `beehave status` command is a pure presentation layer over existing infrastructure. The seven-stage taxonomy maps directly to existing violation types from `check_pair()`. The decision tree is unambiguous. The tree-based output format mirrors the Gherkin hierarchy naturally. - -**Key design principles:** -- All stage names and status labels are plain English — no symbols, no jargon, no legend needed -- Tree format mirrors the Gherkin parse tree: Feature → Rule → Scenario -- `ok` features collapse to one line for maximum scan efficiency -- Status on the left in a fixed column for vertical scanning -- Parse errors are first-class stages, not side-channel stderr -- Warnings don't block `ok` -- Exit codes mirror `beehave check`: Exit 1 = work to do, Exit 0 = clean diff --git a/docs/spec/v3/beehave_v3_spec.md b/docs/spec/v3/beehave_v3_spec.md deleted file mode 100644 index 0637c32..0000000 --- a/docs/spec/v3/beehave_v3_spec.md +++ /dev/null @@ -1,561 +0,0 @@ -# beehave v3 Specification - -> A CLI that generates plain Hypothesis test stubs from Gherkin, checks body consistency, and reports feature development status. Tests import nothing from beehave at runtime. - ---- - -## Product Definition - -**IS:** -- A code generator (`beehave generate`) producing pure Hypothesis `@given()`/`@example()` stubs — processes one feature per invocation; users script loops for bulk processing -- A consistency checker (`beehave check`) that re-parses features, AST-parses tests, joins by function name, and reports violations in machine-parseable format -- A cleanup tool (`beehave clean`) that removes unmapped test functions — if all functions are removed, the file retains its import block (the file is never deleted) -- A status reporter (`beehave status`) that walks all features and reports their development stage via a stage decision tree with tree-format output -- Function-name-based traceability: `test_deposit_increases_balance` ↔ `Scenario: deposit increases balance` -- Background transparency: background steps merge into every scenario in scope — no background functions, no special syntax, just more commented steps in the generated stub - -**IS NOT:** A test runner, runtime framework, step-definition engine, assertion DSL, synonym resolver, Hypothesis replacement, or bulk processor. No beehave imports appear in test code. - -**Users:** Python developers writing property-based tests who want Gherkin as the spec source of truth. - -### Error Handling - -beehave reports errors immediately and exits non-zero. No partial output on failure. - -| Condition | Behavior | -|-----------|----------| -| Feature file not found | Print error with searched path. Exit 1. | -| Gherkin parse error | Print error with `:` and parse message. Exit 1. | -| Python syntax error in test file | Print error with `:`. Skip the malformed file; other files continue. | -| Missing `features_dir` or `tests_dir` | Print error naming the missing directory and config key. Exit 1. | -| Invalid `default_strategy` value | Print error listing valid options (`text`, `integers`, `floats`, `booleans`). Exit 1. | -| Duplicate scenario title (name collision) | Print error naming both features and the colliding title. Exit 1. | - -### Check Output Format - -`beehave check` writes plain text to stdout. One line per violation, machine-parseable: - -``` -:: : -``` - -- ``: relative file path (feature or test file) -- ``: line number in the file (`0` if not applicable) -- ``: `missing-placeholder` · `missing-literal` · `example-mismatch` · `unmapped-test` · `unmapped-scenario` · `misplaced-test` -- ``: human-readable description - -Example output: - -``` -tests/features/bank_account/default_test.py:42: missing-placeholder: 'amount' not found in function body -tests/features/bank_account/default_test.py:55: unmapped-test: 'test_withdrawal' has no matching scenario -docs/features/bank_account.feature:18: unmapped-scenario: scenario 'overdraft rejected' has no test function -``` - -**Severity:** Each violation has a severity — `error` or `warning`. `misplaced-test` is a warning; all other types are errors. - -**Exit codes:** 0 if no errors (warnings alone do not cause exit 1). 1 if any error-level violations found. - ---- - -## Core Concepts - -### 1:1 Traceability via Function Names - -Every `Scenario` and `Scenario Outline` maps to exactly one test function. The function name is the lookup key, derived from the scenario title by the following deterministic algorithm: - -1. **Trim** leading and trailing whitespace. -2. **Collapse** consecutive internal spaces to a single space. -3. **Replace** each space with an underscore (`_`). -4. **Lowercase** the result. -5. **Prepend** `test_`. -6. **Validate** that the result is a valid Python identifier (`str.isidentifier()` returns `True`). If not, raise a parse error. - -``` -Scenario: deposit increases balance → test_deposit_increases_balance -Scenario: extra spaces here → test_extra_spaces_here -Scenario: Add Single Item → test_add_single_item -``` - -No `@scenario` decorator. No `@id` tags. No cache file. At collection time, beehave re-parses all `.feature` files and AST-parses all test files, then joins on function name. - -**Title rules:** - -- **Characters:** Unicode letters, digits, and spaces only. Applies to Scenario, Scenario Outline, Feature, and Rule titles equally. Special characters would break generated file paths or Python identifiers. -- **Non-empty:** The title must be non-empty after trimming. -- **Scenario titles:** Globally unique across all features. Two scenario titles that collapse to the same function name (case-insensitive) produce a parse error. -- **Rule titles:** Unique within their parent Feature. Rule titles are used internally as keys for background lookup and in error messages — duplicate rule titles within a Feature produce a parse error. Per the Gherkin specification, rule names must be unique within their parent feature. -- **Feature titles:** Globally unique across all features. Feature titles determine the generated folder structure — `Feature: Bank` generates `tests/features/bank/`. Duplicate or special-character feature titles would create path collisions or invalid directories. - -### Body Enforcement as the Consistency Mechanism - -With no beehave imports at runtime, body enforcement replaces vocabulary enforcement. Instead of matching decorator text to `.feature` step text, beehave inspects the test function's AST to verify that every placeholder name and every literal value from the feature's steps is present. This guarantees the test exercises what the Gherkin describes — without any runtime coupling. The enforcement is purely structural: beehave checks for the *existence* of AST nodes, not their correctness. - -### No Runtime Coupling - -Tests import only `hypothesis`. `@given()`, `@example()`, and `@settings` are standard Hypothesis. beehave is a development-time CLI tool — it never appears in test `import` statements. There is no beehave test runner integration in v3. - -However, beehave's internal modules (`gherkin`, `discover`, `check`) are designed as composable functions with stable public APIs. A future `pytest-beehave` plugin (external project, not v3 scope) could call these modules directly from a `pytest_collection_modifyitems` hook — providing automatic enforcement at collection time without any `import beehave` in test files. The plugin would derive the feature path from the test file path (via file conventions), parse that one feature, AST-parse the test module, and report violations as collection errors. This requires zero changes to the v3 spec — the module architecture is already the plugin's API. - ---- - -## Placeholder Syntax and Strategy Inference - -`` tokens in step text become Hypothesis parameters. Duplicate `` tokens across a scenario's steps are deduplicated — each unique name produces exactly one parameter. Constraints on `name`: - -| Constraint | Rule | -|-----------|------| -| Valid Python identifier | `str.isidentifier()` | -| Not a keyword | `not keyword.iskeyword()` | -| Not a builtin | `not hasattr(builtins, name)` | - -No quoting convention — `''` and `` in step text are treated identically. Both resolve to the same placeholder. All occurrences of a placeholder name within a feature file share the same strategy (one module-level variable = one strategy). Scenarios requiring different strategies for the same semantic name should use distinct placeholder names (e.g., ``, ``). - -### Strategy Resolution - -Priority (first match wins): - -| Priority | Source | Strategy | -|----------|--------|----------| -| 1 (highest) | Module-level variable | User-defined expression | -| 2 | Examples table column type | Inferred from column values | -| 3 (lowest) | Default | Configured via `default_strategy` (default: `st.text()`) | - -If no module-level override and no Examples table applies, the placeholder uses the configured default strategy (`st.text()` unless the user has changed `default_strategy` in `pyproject.toml`). - -### Module-Level Variable Discovery (Priority 1) - -To discover a user-defined strategy for placeholder `name`, beehave AST-parses the test file and checks for a top-level assignment of the form `name = `. The rules: - -1. **Case-sensitive match.** The assigned variable name must exactly match the placeholder name. -2. **Top-level only.** Only module-level assignments count. Assignments inside functions, classes, or other compound statements are ignored. -3. **Assignments only, not imports.** `from hypothesis import strategies as name` does not count. Neither does `import name`. Only `name = ` (an `Assign` node with a single `Name` target at module level). -4. **Expression not resolved.** beehave does not evaluate `` or validate that it produces a Hypothesis strategy. It only verifies the assignment exists. A typo on the right-hand side is the user's problem to debug at test runtime. - -Example: - -```python -from hypothesis import given, strategies as st - -name = st.text() # beehave finds this assignment; uses st.text() - -@given(name=name) -def test_bee_has_a_name(name): - ... -``` - -### Examples Table Type Inference (Priority 2) - -When a placeholder appears in a Scenario Outline's Examples table (and has no module-level override), the strategy is inferred from the column's values: - -| Values in column | Inferred strategy | -|-------------------|-------------------| -| `100`, `200` | `st.integers()` | -| `30.5`, `50.0` | `st.floats()` | -| `"Alice"`, `"Bob"` | `st.text()` | -| `true`, `false` (case-insensitive) | `st.booleans()` | -| `[1, 2]` | `st.lists(st.integers())` | -| `{"a": 1}` | `st.dictionaries(st.text(), st.integers())` | -| Mixed types across rows | `st.integers()` + warning | - -This type inference table is also used during `@example()` bijection checking — Examples table cell values are parsed into Python types using these rules before comparison. - ---- - -## Body Enforcement - -For each matched function–scenario pair, beehave inspects the function body's AST. Python comments are not represented in the AST and are ignored. Docstrings (`Expr(Constant(value=...))` nodes at the start of the function body) are excluded from enforcement checks. Only actual code nodes are examined. - -### Stub Exemption - -If the function body consists of a **single** `Expr(Constant(value=Ellipsis))` (i.e., `...`) or a **single** `Pass` node (i.e., `pass`), all body enforcement checks are skipped. This allows generated stubs to pass `beehave check` until the user implements them. A body containing anything beyond these two forms — even a docstring — is not a stub and is subject to all checks. - -### Check 1: Placeholder Presence - -Every `` from the feature's steps must appear as a `Name` node in the function's body AST. Only the function body is examined — parameters and `@given()` kwargs do not satisfy this check. The rationale: a placeholder in `@given()` provides the value, but body enforcement verifies the test *uses* it. - -| Condition | Result | -|-----------|--------| -| `` found as `Name` in function body | Pass | -| `` absent from function body | Fail — report the missing name(s) | - -### Check 2: Literal Presence - -Literal values extracted from step text must appear as `Constant` nodes in the function's AST. - -**What counts as a literal:** - -| Source | Extraction rule | Example step text | Extracted literal | -|--------|----------------|-------------------|-------------------| -| Numeric literal | A whitespace-delimited token consisting entirely of digits (matching `^\d+$`) | `the balance is 100` | `int(100)` | -| Quoted string | Text enclosed in double quotes (`"..."`) within Gherkin step text | `the value is "honey"` | `str("honey")` | -| Bare word | NOT extracted | `the value is honey` | *(nothing)* | - -**Negative numbers are not supported as literals.** The token `-5` does not match `^\d+$` (it contains a hyphen) and is not extracted as a number. Users who need negative number enforcement should use `` placeholders with a module-level strategy override. Only pure digit sequences qualify. - -**Symbols attached to numbers** prevent extraction. The token `1%` does not match `^\d+$` and is not extracted. To enforce such values, enclose them in double quotes: `the rate is "1%"`. - -**Quoted strings** are defined as any substring of Gherkin step text enclosed in double-quote characters (`"`). When beehave parses `Then the flavour is "honey"`, it extracts `honey` (without the quotes) as a string literal and checks that the function AST contains a `Constant(value="honey")`. Quoted strings are the only mechanism for enforcing non-numeric literal values. Bare words — even if they look meaningful — are NOT extracted and NOT enforced. - -### Check 3: `@example()` Bijection - -Each `@example()` decorator on the function must match exactly one row in the Scenario Outline's Examples table, and vice versa. Matching is by deep equality of Python values. - -**Type coercion before comparison:** Examples table cell values are parsed into Python types using the type inference table above *before* comparison. So `100` in an Examples cell becomes Python `int(100)`, `30.5` becomes `float(30.5)`, `true` becomes `bool(True)`, and `"Alice"` becomes `str("Alice")`. The `@example()` decorator's keyword arguments are already Python literal values at AST-parse time. Deep equality is then checked between the coerced Examples row dict and the `@example()` keyword dict. - -| Condition | Result | -|-----------|--------| -| Every row matches exactly one `@example()` and vice versa | Pass | -| Unmatched Examples row | Fail — report the unmatched row | -| Unmatched `@example()` decorator | Fail — report the unmatched decorator | - -This check only applies to Scenario Outlines with Examples tables. Plain scenarios have no `@example()` bijection to verify. - -### `@given()` Parameters: Not Validated - -`@given()` kwargs are **not validated** by beehave — the user may add extra parameters (fixtures, helper values) beyond what the feature defines, or remove computed parameters. Feature placeholders that appear in `@given()` must have corresponding module-level strategy definitions if the configured default strategy (`st.text()` by default) is not desired. This is a user concern, not a beehave enforcement concern. - ---- - -## Background - -Background steps are transparently merged into every scenario in their scope. beehave prepends background step texts to the scenario's step texts before extracting placeholders and literals. Background steps are never mapped to their own test function — they only contribute to the scenarios in their scope. - -| # | Rule | -|---|------| -| 1 | **No placeholders in Background.** Background steps must contain **no ``** — literal text only. If a `` token is found in a Background step, beehave raises a parse error naming the offending placeholder. | -| 2 | **Literal contribution.** Both numeric and quoted-string literals from background steps are added to the scenario's literal enforcement set. Enforcement of each type is configurable via `background_check_numeric` (default: `true`) and `background_check_string` (default: `true`) in pyproject.toml. When disabled, that literal type from background steps is informational only. | -| 3 | **Scoping.** Feature-level background → all scenarios in the feature. Rule-level background → only scenarios within that rule. | -| 4 | **Ordering.** Step texts are concatenated in this order: feature background steps → rule background steps → scenario steps. Placeholders and literals are extracted from the concatenated sequence. | -| 5 | **Uniqueness.** At most one `Background:` block per Feature and at most one per Rule. Multiple `Background:` blocks at the same scope level produce a parse error. | - ---- - -## Working Examples - -### Example 1 — Plain scenario with params and background - -```gherkin -Feature: Bank - Background: - Given a user exists - - Scenario: deposit increases balance - Given a balance of - When is deposited - Then the balance is plus -``` - -```python -from hypothesis import given, strategies as st - -amount = st.integers() -deposit = st.integers() - -@given(amount=amount, deposit=deposit) -def test_deposit_increases_balance(amount, deposit): - user = User() - acct = Account(user, balance=amount) - acct.deposit(deposit) - assert acct.balance == amount + deposit -``` - -Background is transparent — the generated stub includes a commented `# Given a user exists` line, but there is no background function. The user handles setup in the body. `check` verifies `amount` and `deposit` appear as `Name` nodes. - -### Example 2 — No-param scenario - -```gherkin - Scenario: new hive has no honey - Given a new hive - Then the honey level is 0 -``` - -```python -def test_new_hive_has_no_honey(): - hive = Hive() - assert hive.honey == 0 -``` - -No `@given()` → plain function. Any test runner discovers it. Body must contain the literal `0` (enforced as a `Constant` node by `check`). No module-level variables needed — no placeholders to strategise. - -### Example 3 — Default strategy (no override needed) - -```gherkin - Scenario: bee has a name - Given a bee named - Then the bee responds to -``` - -```python -from hypothesis import given, strategies as st - -@given(name=st.text()) -def test_bee_has_a_name(name): - bee = Bee(name=name) - assert bee.responds_to(name) -``` - -All placeholders default to `st.text()` (via `default_strategy`). No module-level variable, no quoting convention — `` and `''` are treated identically. This is exactly what `beehave generate` produces: `@given(name=st.text())` with no user intervention required. - -### Example 4 — User strategy override - -```gherkin - Scenario: balance never negative - Given a balance of - When is withdrawn - Then the balance is non negative -``` - -```python -from hypothesis import given, strategies as st - -amount = st.integers(min_value=0) -withdrawal = st.integers(min_value=0) - -@given(amount=amount, withdrawal=withdrawal) -def test_balance_never_negative(amount, withdrawal): - assert amount - withdrawal >= 0 -``` - -Module-level variables are the **only** mechanism to control strategy in v3. The user defines `amount` and `withdrawal` at module level. No per-placeholder config, no quoting convention. - -### Example 5 — Scenario Outline with Examples - -```gherkin - Scenario Outline: specific transfers - Given account A has - And account B has - When is moved from A to B - Then A has and B has - - Examples: - | balance_a | balance_b | transfer | result_a | result_b | - | 100 | 50 | 30 | 70 | 80 | - | 200 | 0 | 50 | 150 | 50 | -``` - -```python -from hypothesis import given, example, strategies as st - -@example(balance_a=100, balance_b=50, transfer=30, result_a=70, result_b=80) -@example(balance_a=200, balance_b=0, transfer=50, result_a=150, result_b=50) -@given(balance_a=st.integers(), balance_b=st.integers(), transfer=st.integers(), - result_a=st.integers(), result_b=st.integers()) -def test_specific_transfers(balance_a, balance_b, transfer, result_a, result_b): - assert balance_a - transfer == result_a - assert balance_b + transfer == result_b -``` - -`@example()` values are **Python typed**: `100` is an `int`, not the string `"100"`. Deep-equality bijection — each Examples row maps to exactly one `@example()` decorator. `@example()` outermost, `@given()` innermost (Hypothesis requirement). Strategy inferred from Examples table values (all integers → `st.integers()` at Priority 2); no module-level override needed. - -### Example 6 — User removes computed params - -```gherkin - Scenario: spending reduces balance - Given a balance of - When is spent - Then the balance is -``` - -```python -from hypothesis import given, strategies as st - -initial = st.integers() -amount = st.integers() - -@given(initial=initial, amount=amount) -def test_spending_reduces_balance(initial, amount): - remaining = initial - amount - assert remaining >= 0 -``` - -`` is a computed value — the user omits it from `@given()`. `@given()` kwargs are **not validated** by `check`; the user controls what enters `@given()`. Body enforcement still requires `remaining` to appear as a `Name` node in the function body (it does: `remaining = ...`). The module-level variables override the default `st.text()` to `st.integers()` for both `initial` and `amount`. - ---- - -## Architecture - -| Module | Purpose | -|--------|---------| -| `beehave.gherkin` | Parse `.feature` files. Extract scenarios, placeholders, literals, examples. Enforce title rules (globally unique, Unicode letters/digits/spaces only). Merge background steps into scenarios. Return `dict[str, ScenarioInfo]` keyed by function name. | -| `beehave.discover` | AST-parse test files. Extract function names, `@given()` kwargs, `@example()` rows, body AST nodes (`Name`, `Constant`). Discover module-level strategy overrides by walking top-level `Assign` nodes where the target is a single `Name`. Return `dict[str, TestInfo]` keyed by function name. | -| `beehave.check` | Dict-join `ScenarioInfo` ↔ `TestInfo` on function name. Verify body enforcement (placeholder presence, literal presence), examples bijection, unmapped detection, misplaced detection. | -| `beehave.generate` | Produce stub `.py` files from `ScenarioInfo`. Emit `@given()` with inferred strategies, `@example()` rows, commented step text. **Skip existing functions** — only append truly new functions. Background steps appear as commented step text prepended to the scenario's steps. Create target directories as needed. | -| `beehave.clean` | Remove unmapped test functions. If all functions are removed and only import statements remain, leave the file with those imports — do not delete the file. | -| `beehave.cli` | Entry point. `generate`, `check`, `clean` subcommands. No `fix` command, no `--dry-run` flag in v3. | - -All modules expose stable, composable function APIs (`parse_feature()`, `discover_tests()`, `check_pair()`) suitable for consumption by external tools. A future `pytest-beehave` plugin (not v3 scope) would call these functions from pytest collection hooks, using the file path convention to derive feature↔test mappings. - ---- - -## CLI Commands - -### `beehave generate ` - -**Input:** exactly one feature path relative to `features_dir` (no extension). `bank_account` → `docs/features/bank_account.feature`. - -No bulk mode — `generate` accepts one feature path per invocation. To generate multiple features, script the command: - -```bash -for f in bank_account transfer_ledger; do beehave generate "$f"; done -``` - -**Output:** test files in `tests/features//`. One file per Rule (named `_test.py`), plus `default_test.py` for top-level scenarios. Creates the full directory path if it does not exist. - -**Behavior:** - -1. Parse the feature file. On parse error: report path and line number, exit 1. -2. Group scenarios by Rule membership. Top-level scenarios → `default_test.py`, scenarios inside a Rule → `_test.py`. -3. For each scenario, derive the function name from the scenario title. -4. **Skip existing functions** — if a test function with the same name already exists in the target file, do not overwrite. Only append truly new functions. Generate is idempotent. -5. For new functions, emit a stub containing: `@given()` with inferred strategies, `@example()` from Examples table, `...` body. - -**Import block:** Each file begins with `from hypothesis import ...` including only the names needed: `given` if any function has `@given()`, `example` if any function has `@example()`, `strategies as st` if any `@given()` uses strategy inference. When appending to an existing file, `generate` updates the import block to include any newly needed names. No `settings` import — hypothesis configuration is the user's responsibility. - -**Exit codes:** 0 on success. 1 on any error. - -### `beehave check []` - -**Input:** optional feature path. Omit → check all features in `features_dir`. - -**Behavior:** parse features → AST-parse tests → dict join by function name → verify all invariants. Does not auto-fix — the user resolves drift manually. - -**Discovery scope:** When run without a feature argument, `check` scans ALL `*_test.py` files in `tests_dir` (not only those matched by features). This ensures test functions from deleted `.feature` files are still discovered and reported as `unmapped-test`. - -When run with a specific feature argument, `check` scans ALL `*_test.py` files in that feature's test directory (not only files matching surviving Rule paths). For each test function not matching a scenario in its file's rule_path: -- If the function matches a scenario in a different rule_path (same feature or another feature) → emit a `misplaced-test` warning naming both the current file and the expected file. -- If the function matches no scenario in any feature → report as `unmapped-test` (error). - -**Misplaced detection:** `check` also detects Rule-level structural changes. If a Rule is removed or renamed, its test file remains on disk. Functions in that file will be reported as `misplaced-test` (warning) if their matching scenario now maps to a different file (e.g., moved to `default_test.py`), or `unmapped-test` (error) if the scenario was deleted entirely. - -**Output:** see [Check Output Format](#check-output-format). Exit 0 if no errors (warnings alone do not cause exit 1). Exit 1 if any error-level violations found. - -### `beehave clean ` - -**Input:** exactly one feature path. - -**Behavior:** remove test functions that have no matching scenario from the feature's test files (grouped by `rule_path`). If all functions are removed from a file, the file retains its import block — it is never deleted. **Warning:** before removing any non-stub function (body is not `...` or `pass`), print a warning to stderr naming the function and requiring `--force` to proceed. Stub unmapped functions are removed without warning. - -### `beehave status` - -**Input:** optional feature path, optional flags. - -**Behavior:** walks all `.feature` files and reports the development stage of each feature via a stage decision tree. Each feature is classified into one of seven stages in priority order: `broken`, `no scenarios`, `needs scenarios`, `needs tests`, `needs bodies`, `needs fixes`, or `ok`. Features that are `ok` collapse to a single line; all others display a scenario tree with per-scenario status labels. - -**Flags:** -- `--json`: output a machine-readable JSON report containing feature hierarchy, unmapped directories, collisions, and summary. -- `--include-unmapped`: include unmapped test directories in the report. - -**Exit codes:** 0 = all features `ok`, 1 = any feature not `ok`, 2 = fatal error (features directory missing). - -**Output format:** tree-based hierarchy. Each feature shown as `slug (Title) stage`. Non-ok features expand to show rules and scenarios with status labels (`ok`, `no body`, `N errors`, `no test`). Rule aggregates show comma-joined child counts (e.g., `"1 no body, 2 errors"`). - ---- - -## File Conventions - -``` -docs/features//.feature # Gherkin feature files -tests/features///default_test.py # Top-level scenarios (no Rule) -tests/features///_test.py # Scenarios inside a Rule -pyproject.toml # [tool.beehave] config -``` - -Feature path mirrors between `features_dir` and `tests_dir`. `bank_account` → `docs/features/bank_account.feature` + `tests/features/bank_account/` directory. The feature path is derived from the feature title by: trim whitespace → collapse consecutive spaces → replace spaces with underscores → lowercase. - -Within a feature directory, scenarios map to test files based on their Rule membership: -- **Top-level scenarios** (outside any Rule) → `default_test.py` -- **Scenarios inside a Rule** → `_test.py`, where `rule_name` is derived from the Rule title by the same algorithm: trim → collapse spaces → underscores → lowercase → append `_test`. - -Rule titles must be unique within their parent Feature. Rule title uniqueness ensures no filename collisions within a feature's test directory. - -One `.feature` file per feature, one test directory per feature (1:1 mapping). - -Example with rules: - -```gherkin -Feature: Bank - Background: - Given a bank exists - - Scenario: new account starts at zero - ... - - Rule: domestic transfers - Background: - Given domestic banking is enabled - - Scenario: local transfer - ... - Scenario: local transfer fee - ... - - Rule: international transfers - Background: - Given SWIFT is available - - Scenario: wire transfer - ... -``` - -Produces three test files: -- `tests/features/bank/default_test.py` — `test_new_account_starts_at_zero` -- `tests/features/bank/domestic_transfers_test.py` — `test_local_transfer`, `test_local_transfer_fee` -- `tests/features/bank/international_transfers_test.py` — `test_wire_transfer` - -Backgrounds compose transparently — each scenario's enforcement includes the applicable background chain. - -**Title restrictions apply to all levels:** Feature, Rule, and Scenario titles must all contain only Unicode letters, digits, and spaces. Feature and Rule titles are used in folder structure, error messages, and internal keying — special characters would break generated paths or identifiers. - -If a feature grows too large, split it into separate features. - ---- - -## Configuration - -```toml -[tool.beehave] -features_dir = "docs/features" -tests_dir = "tests/features" -default_strategy = "text" # text → st.text() | integers → st.integers() | floats → st.floats() | booleans → st.booleans() -background_check_numeric = true # enforce numeric literals from background steps -background_check_string = true # enforce quoted-string literals from background steps -``` - -| Setting | Default | Description | -|---------|---------|-------------| -| `features_dir` | `"docs/features"` | Where `.feature` files live | -| `tests_dir` | `"tests/features"` | Where generated test files live | -| `default_strategy` | `"text"` | Fallback strategy for placeholders without a user override or Examples-table inference: `text` → `st.text()`, `integers` → `st.integers()`, `floats` → `st.floats()`, `booleans` → `st.booleans()` | -| `background_check_numeric` | `true` | Whether numeric literals extracted from background steps are enforced as `Constant` nodes in scenario test functions. When `false`, numeric literals in background steps are informational only. | -| `background_check_string` | `true` | Whether quoted-string literals extracted from background steps are enforced as `Constant` nodes in scenario test functions. When `false`, quoted-string literals in background steps are informational only. | - -No other configuration settings. No `--dry-run` flag in v3 (future scope). - ---- - -## Gherkin Extensions and Constraints - -| Feature | Standard Gherkin | beehave v3 | -|---------|-----------------|------------| -| `` in steps | Only in Scenario Outline | Any step in any scenario | -| `` in Background | Not supported | Not supported — background steps are literal-only | -| Background handling | Steps run before each scenario | Transparently merged into every scenario in scope; no emitted functions | -| Title uniqueness | Unique within Feature | Globally unique across all features | -| Title characters | Any text | Unicode letters, digits, spaces only | -| Tags | User-defined metadata | `@id` tags not used — function name is the sole lookup key | -| Scenario Outline Examples | Rows provide test data | Rows become `@example()` decorators via deep-equality bijection. Scenario Outline without at least one Examples table with at least one data row is a parse error. | -| Strategy control | N/A | Module-level variables only — no quoting convention, no per-placeholder config. Default: `st.text()` | - ---- - -## What beehave IS NOT - -- **Not a test runner.** beehave generates and checks test files. It does not execute tests. -- **Not a runtime framework.** Tests import only `hypothesis`. No `import beehave` ever appears in test code. (A future `pytest-beehave` plugin would import beehave in its own plugin code, not in test files.) -- **Not a step-definition engine.** No step registries, no decorator-to-step-text matching at runtime. Body enforcement is a static AST check. -- **Not an assertion DSL.** Use plain Python `assert`. -- **Not a synonym resolver.** Step text is not matched, normalized, or compared across steps. -- **Not a Hypothesis replacement.** beehave generates standard Hypothesis decorators. -- **Not a cache or state manager.** No `.beehave_cache` file, no database, no persistent state. Re-parsed from disk every invocation. -- **Not a code formatter or linter.** Use `ruff`, `black`, or similar tools. diff --git a/docs/state.md b/docs/state.md index 05afeed..8e86b56 100644 --- a/docs/state.md +++ b/docs/state.md @@ -2,7 +2,7 @@ > The living specification of this project's current state — what it is and > where it is in its build. Regenerated each pipeline cycle by the refresh step -> from the truth: test `.pyi`, pending marks, cassettes, migrations, and the +> from the truth: tests, pending marks, cassettes, migrations, and the > glossary. Never hand-edited; hand-authoring creates a second source of truth > that drifts. Tests are the source of truth for behaviour — this file is a > derived view. When prose and a test disagree, the test wins. @@ -10,36 +10,37 @@ ## Snapshot - **Project:** beehave -- **Version:** 2.0.0 -- **Generated:** 2026-07-18 by flowr session `beehave-v2` -- **Suite:** green · **Contracts:** 7/7 built (0 pending) +- **Version:** 3.0.0 +- **Generated:** 2026-07-21 +- **Suite:** green · **Tests:** 66 · **Pending:** 0 - **Purpose:** → see `README.md` ## Entry points & boundaries **Entry points:** -- CLI: `beehave generate|check|status` (console-script `beehave = beehave.cli:main`). -- Import: `from beehave import step` — the runtime context manager used inside consumer-authored `*_test.py` bodies. +- CLI: `beehave generate|check [feature...]|status` (console-script `beehave = beehave.cli:main`). +- Import: `from beehave import step` — the runtime context manager used inside consumer-authored `*_test.py` bodies (Mode B opt-in). **Boundary:** -- *Internal (ours):* `beehave.__init__`, `beehave.step`, `beehave.gherkin`, `beehave.generate`, `beehave.check`, `beehave.status`, `beehave.cli` (7 modules; parse-model shapes live in `beehave.gherkin` per Q2-resolution — no separate `models.py`). -- *External (depends on):* `gherkin-official` (parser; Full Gherkin coverage); Hypothesis (imported by generated `*_test.py`, NOT by the package); pytest (test runner, hosts the `step` CM); consumer-side mypy + `mypy.stubtest` (the gate — runs in consumer CI, not in-package). See Dependencies. +- *Internal (ours):* `beehave.__init__`, `beehave.step`, `beehave._index`, `beehave.gherkin`, `beehave.generate`, `beehave.check`, `beehave.status`, `beehave.cli` (8 modules; parse-model shapes live in `beehave.gherkin` per Q2-resolution — no separate `models.py`). +- *External (depends on):* `gherkin-official` (parser; Full Gherkin coverage); pytest (test runner, hosts the `step` CM and `@parametrize`); mypy (dev-only; library stubtest gate). ## Contract index The derived map of what this system does. Each row points at the test (the -behavioural truth) and the source `.pyi` (the type surface). Intent is -regenerator-authored from the test body and self-corrects each cycle. +behavioural truth) and the source module. Intent is regenerator-authored from +the test body and self-corrects each cycle. | Contract | Module | Test | Intent (one line) | Status | |---|---|---|---|---| -| `step` | `beehave.step` | `tests/integration/step_cm_test.py` | `step(keyword, text, /, **placeholders)` CM runs the block, attributes failures via `add_note`, positional-only keyword/text. | built | -| parse model + `parse_feature` | `beehave.gherkin` | `tests/integration/parsing_test.py`, `tests/integration/title_derivation_test.py` | Parse `.feature` into `Feature`/`Rule`/`Scenario`/`Step`/`Placeholder`/`Examples`/`Background`/`DataTable`; enforce title rules (case-insensitive uniqueness, 2–6 words, charset) and reject placeholders in Background. | built | -| `generate` | `beehave.generate` | `tests/integration/idempotency_test.py`, `tests/integration/strategy_inference_test.py` | Emit `_default_test.py{i,}` + one `__test.py{i,}` per Rule; `.pyi` always, `.py` skeleton only if absent; Examples-column → strategy inference (int/float/bool/str; no-Examples → str). | built | -| `check` | `beehave.check` | `tests/integration/roundtrip_test.py`, `tests/e2e/check_test.py` | Structural binding: `block[i]`↔`step[i]` on (keyword case-insensitive, text, placeholder-name-set); body-content NOT inspected (noise loophole closed). | built | -| `status` | `beehave.status` | `tests/e2e/status_test.py` | Print `.feature` count under `/docs/features/` and `*_test.pyi` count under `/tests/`; exit 0 if dir exists, 2 if missing. | built | -| `main` (CLI dispatch) | `beehave.cli` | `tests/e2e/{check,generate,status}_test.py` | Dispatch `beehave generate|check|status` and return each subcommand's exit code; `argv` defaults to `sys.argv[1:]`. | built | -| `__version__` + `step` re-export | `beehave.__init__` | (no test — Q10 deferral) | Single source of truth for `__version__ = "2.0.0"`; re-export `step` for `from beehave import step`. | built | +| `step` (Mode B runtime) | `beehave.step` | `tests/integration/step_cm_test.py` | `step(keyword, text, /, **placeholders)` CM; walks frames to find calling `test_*` fn; verifies step N against feature scenario (keyword/text/placeholder-set); verifies `@parametrize` rows against `Examples`; attributes failures via `add_note`. | built | +| scenario index | `beehave._index` | (exercised via `step_cm_test.py`) | Lazy module-level `dict[function_name, Scenario]`; built once per process from `Path.cwd()/docs/features/*.feature`; `_reset()` test hook. | built | +| parse model + `parse_feature` | `beehave.gherkin` | `tests/integration/parsing_test.py`, `tests/integration/title_derivation_test.py` | Parse `.feature` into `Feature`/`Rule`/`Scenario`/`Step`/`Placeholder`/`Examples`/`Background`/`DataTable`; enforce title rules (case-insensitive slug-keyed uniqueness, 2–6 words, Unicode charset); reject placeholders in Background; merge multiple Examples tables (track per-row tags). | built | +| `generate` | `beehave.generate` | `tests/integration/idempotency_test.py`, `tests/integration/parametrize_test.py`, `tests/e2e/generate_test.py` | Emit `_default_test.py` + one `__test.py` per Rule into `tests/features/`; `.py` skeleton only if absent (idempotent); `@parametrize` for Examples (string rows, `str` params); tags → `pytestmark` + `@pytest.mark.`; docstrings/data-tables → body-local vars. | built | +| `check` | `beehave.check` | `tests/integration/roundtrip_test.py`, `tests/integration/parametrize_test.py`, `tests/e2e/check_test.py` | AST-based 1-1 superset: parse `.feature` → derive `def test_(params) -> None` lines; parse `.py` AST → collect non-private top-level function signatures; return True iff sets equal. Private `_*` fns exempt. | built | +| `status` | `beehave.status` | `tests/e2e/status_test.py` | Print `.feature` count under `/docs/features/` and `*_test.py` count under `/tests/features/`; exit 0 if dir exists, 2 if missing. | built | +| `main` (CLI dispatch) | `beehave.cli` | `tests/e2e/{check,generate,status}_test.py` | Dispatch `beehave generate|check|status`; `_check_all` accepts feature path args for scoped check (skips orphan detection); full sweep runs orphan-module detection by filename stem. | built | +| `__version__` + `step` re-export | `beehave.__init__` | (no test) | Single source of truth for `__version__ = "3.0.0"`; re-export `step`, `StepError`, `NoActiveScenarioError`. | built | ## Composition & data flow @@ -49,15 +50,21 @@ it. Entity names follow `docs/glossary.md`. ``` author → docs/features/.feature → parse_feature(source: str) -> Feature - → generate(root: Path) -> None writes tests/_default_test.py{i,} + one __test.py{i,} per Rule - → consumer fills *_test.py bodies with `with step(keyword, text, **placeholders): ...` - → check(feature_text: str, test_py_text: str) -> bool walks with-step blocks in source order - → pytest runs the consumer's *_test.py; the step CM attributes any AssertionError to its step via add_note - → status(root: Path) -> int reports .feature count + *_test.pyi count - → consumer CI: mypy on beehave.* + mypy.stubtest (the gate — out of package) + → generate(root: Path) -> None writes tests/features/_default_test.py + + one __test.py per Rule + (only if the .py is absent — idempotent) + → consumer fills *_test.py bodies with assertions inside the with step(...) blocks + → check(feature_text: str, py_text: str) -> bool parses .feature for expected sigs, + parses .py AST for actual sigs, + returns True iff sets equal (1-1) + → pytest runs the consumer's *_test.py; the step CM verifies each with-step against + the scenario's step N at runtime and attributes AssertionError via add_note + → status(root: Path) -> int reports .feature count + *_test.py count ``` -**Data flow:** `str` (.feature text) → `Feature` (parse model) → filesystem write (`.pyi` always, `.py` skeleton-if-absent) → `bool` (check) / `int` (status, cli). No persistence layer — the cycle is stateless file emission (data-model §1). +**Data flow:** `str` (.feature text) → `Feature` (parse model) → filesystem +write (`.py` skeleton-if-absent) → `bool` (check) / `int` (status, cli). No +persistence layer — the cycle is stateless file emission. ## Dependencies @@ -67,19 +74,21 @@ external contract); this table points at them and never restates the shape. | Service | Purpose | Protocol | Cassette | Env vars | |---|---|---|---|---| | `gherkin-official` | Parse Full Gherkin (`.feature` → typed tree) | in-process Python lib | N/A — explore pass-through; no HTTP | none | -| Hypothesis | Property-based strategies (`@given`/`@example`) for generated tests | in-process Python lib (consumer-side) | N/A — imported by generated `*_test.py`, not by beehave | none | -| pytest | Test runner hosting the `step` CM inside consumer `*_test.py` | in-process Python lib | N/A | none | -| mypy + `mypy.stubtest` | Consumer-side type gate + `.py`↔`.pyi` drift detector | CLI (consumer CI) | N/A — out-of-package per L3 non-blocks | none | +| pytest | Test runner hosting the `step` CM + `@parametrize` inside consumer `*_test.py` | in-process Python lib | N/A | none | +| mypy + `mypy.stubtest` | Dev-side library stubtest gate (`beehave/*` package only; NOT consumer tests in v3.0.0) | dev CLI | N/A — out-of-package per L3 non-blocks | none | **Persistence** — schema lives in the migrations (the migration IS the schema spec); this table points at them and never restates the DDL. | Entity | Store | Migration | |---|---|---| -| (none) | N/A — beehave v2 has no persistence layer (stateless file emission per data-model §1) | N/A | +| (none) | N/A — beehave has no persistence layer (stateless file emission per data-model §1) | N/A | ## Status & last cycle -- **Built:** 7 · **Pending:** 0 — backlog: none -- **Last cycle:** the beehave-v2 rewrite — 5 build cycles (`step`, `gherkin`, `generate`, `check`, `status`+`cli`) shipped green at `2.0.0`; 55 tests across integration (33) and e2e (22); 3 build-escalations resolved (`parsing_test` 1-word filler, `roundtrip_test` feature-vs-scenario collision, `tests/e2e` pytester-chdir path bug). -- **Next:** none — shipped. v3 spec exploration lives under `docs/spec/v3/` for a future cycle's discovery pass. +- **Built:** 8 modules · **Pending:** 0 — backlog: none +- **Suite:** 66 tests green (integration + e2e), zero pending markers. +- **Last cycle:** v3.0.0 — superset model rearchitecture. Dropped consumer-side `.pyi` emission + consumer-side stubtest gate. `check` rewritten as AST-based 1-1 superset verification (`.feature`-derived signatures == `.py` non-private function signatures, with private `_*` fns exempt). Orphan detection collapsed INTO check by filename-stem on full sweep. Added `beehave check ...` for scoped incremental checks. Dropped `mypy` runtime dep (back to dev-only). Version 3.0.0 (major bump — breaking). +- **Carry-forward (not blocking):** + - `scripts/strip_docstrings.py` referenced by 3 skills but not yet authored. + - Plan-review-gate misses (3 incidental-fixture issues in v2 cycles) — recommend systematic incidental-title audit + `pytester.run` smoke probe at author-test-stubs/simulate-contracts.