diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 9833bb6..26d06f3 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -28,6 +28,9 @@ jobs: - name: Set up Python 3.13 run: uv python install 3.13 + - name: Clean dist + run: rm -rf dist/ + - name: Build wheel and sdist run: uv build diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000..4fbc251 --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -0,0 +1,52 @@ +name: Tag Release + +on: + push: + branches: [main] + paths: [pyproject.toml] + +permissions: + contents: write + +jobs: + tag: + name: Create version tag + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.1 + + - name: Extract version from pyproject.toml + id: version + run: | + VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Check if tag already exists + id: check + run: | + if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.version }}" | grep -q .; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create and push tag + if: steps.check.outputs.exists == 'false' + env: + GIT_AUTHOR_NAME: github-actions[bot] + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + run: | + git tag "${{ steps.version.outputs.tag }}" + git push origin "${{ steps.version.outputs.tag }}" + echo "Created tag ${{ steps.version.outputs.tag }} at $(git rev-parse HEAD)" + + - name: Skip (tag already exists) + if: steps.check.outputs.exists == 'true' + run: echo "Tag ${{ steps.version.outputs.tag }} already exists — skipping." diff --git a/.opencode/agents/product-owner.md b/.opencode/agents/product-owner.md index 456c339..403da3c 100644 --- a/.opencode/agents/product-owner.md +++ b/.opencode/agents/product-owner.md @@ -51,19 +51,18 @@ When a gap is reported (by software-engineer or reviewer): | Situation | Action | |---|---| -| Edge case within current user stories | Add a new Example with a new `@id` to the relevant `.feature` file. | +| Edge case within current user stories | Add a new Example to the relevant `.feature` file. | | New behavior beyond current stories | Add to backlog as a new feature. Do not extend the current feature. | -| Behavior contradicts an existing Example | Write a new Example with new `@id`. | -| Post-merge defect | Move the `.feature` file back to `in-progress/`, add new Example with `@id`, resume at Step 3. | +| Behavior contradicts an existing Example | Add `@deprecated` to the old Example; write a new Example. | +| Post-merge defect | Move the `.feature` file back to `in-progress/`, add new Example, resume at Step 3. | ## Bug Handling When a defect is reported against any feature: -1. Add a `@bug @id:` Example to the relevant `Rule:` block in the `.feature` file. -2. Write the Example using the standard `Given/When/Then` format describing the correct behavior. -3. Update TODO.md to note the new `@id` for the SE to implement. -4. SE implements the `@id` test in `tests/features/` **and** a `@given` Hypothesis property test in `tests/unit/`. Both are required. +1. Add a `@bug` Example to the relevant `Rule:` block in the `.feature` file using the standard `Given/When/Then` format describing the correct behavior. +2. Update TODO.md to note the new bug Example for the SE to implement. +3. SE implements the test in `tests/features/` **and** a `@given` Hypothesis property test in `tests/unit/`. Both are required. ## Available Skills diff --git a/.opencode/agents/reviewer.md b/.opencode/agents/reviewer.md index 11a3d0d..0f0b350 100644 --- a/.opencode/agents/reviewer.md +++ b/.opencode/agents/reviewer.md @@ -54,3 +54,4 @@ If you discover an observable behavior with no acceptance criterion: You never edit `.feature` files or add Examples yourself. + diff --git a/.opencode/agents/setup-project.md b/.opencode/agents/setup-project.md new file mode 100644 index 0000000..a4a1d8f --- /dev/null +++ b/.opencode/agents/setup-project.md @@ -0,0 +1,143 @@ +--- +description: Agent for setting up new projects from the Python template - gathers parameters and applies them directly +mode: subagent +temperature: 0.3 +tools: + write: true + edit: true + bash: true + read: true + grep: true + glob: true + task: false + skill: false +--- + +# Setup Project + +You initialize a new project from this Python template by gathering parameters from the user and applying them directly to the project files. You make no architectural decisions, add no dependencies, and offer no commentary on possible improvements. You only substitute the template variables with user-provided values. + +## Step 1 — Gather Parameters + +Read `template-config.yaml` and show the user the 6 values under `defaults:`. For **each key in order**, display the current default value and ask the user: "Use this value or enter a new one?" Accept the default if the user confirms it. Collect all 6 values before proceeding: + +1. `github_username` — their GitHub handle (e.g. `myusername`) +2. `project_name` — kebab-case repo name (e.g. `my-awesome-project`) +3. `package_name` — snake_case Python package name (e.g. `my_awesome_project`). This becomes the `app/` directory. +4. `project_description` — one sentence describing what the project does +5. `author_name` — their full name +6. `author_email` — their email address + +Do not ask for anything else. Do not suggest additional parameters. + +## Step 2 — Show Summary and Confirm + +Print a table showing old value → new value for all 6 parameters: + +| Parameter | Old (default) | New | +|---|---|---| +| `github_username` | ... | ... | +| `project_name` | ... | ... | +| `package_name` | ... | ... | +| `project_description` | ... | ... | +| `author_name` | ... | ... | +| `author_email` | ... | ... | + +Note explicitly: `github_username` will be used in both `pyproject.toml` URLs and `git remote set-url`. Confirm they are correct before proceeding. + +Ask the user to confirm before making any changes. + +## Step 3 — Apply Changes + +Execute each sub-step in order. Do not skip any. Do not make any changes beyond what is listed here. + +The substitution patterns are the source of truth in `template-config.yaml` under `substitutions:`. The steps below describe each file in plain terms; verify counts against the config if in doubt. + +### 3a. Rename the package directory + +```bash +mv app +``` + +### 3b. Update `pyproject.toml` + +Apply every substitution listed under `substitutions.pyproject.toml` in `template-config.yaml`. Additionally, reset the version field to `0.1.YYYYMMDD` using today's date. + +### 3c. Update `README.md` + +Apply every substitution listed under `substitutions.README.md`. The `eol` → `` replacement applies only to the author credit line; do not replace `eol` in other contexts. + +### 3d. Update test files referencing the package + +Apply every substitution listed under `substitutions.tests/unit/app_test.py`. + +After applying substitutions, verify no stale references remain: + +```bash +grep -rn "from app" tests/ +``` + +The command must return no output before proceeding to Step 3e. + +### 3e. Update `.github/workflows/ci.yml` + +Apply every substitution listed under `substitutions..github/workflows/ci.yml`. + +### 3f. Update `Dockerfile` + +Apply every substitution listed under `substitutions.Dockerfile`. + +### 3g. Update `docker-compose.yml` + +Apply every substitution listed under `substitutions.docker-compose.yml`. + +### 3h. Update `.dockerignore` + +Apply every substitution listed under `substitutions..dockerignore`. + +### 3i. Update `docs/index.html` + +Apply every substitution listed under `substitutions.docs/index.html`. + +### 3j. Update `LICENSE` + +Apply every substitution listed under `substitutions.LICENSE`. + +### 3k. Update `template-config.yaml` + +Apply every substitution listed under `substitutions.template-config.yaml`. This updates the `defaults:` section to reflect the user's values. This is always the last file changed. + +### 3l. Set git remote + +```bash +git remote set-url origin git@github.com:/.git +``` + +## Step 4 — Smoke Test + +```bash +uv sync --all-extras && uv run task test-fast +``` + +Both must succeed. If `uv run task test-fast` fails and the failure is caused by a variable substitution that was missed (e.g. an import still referencing `app` instead of ``), apply the same substitution pattern to fix it. If the failure has any other cause, report the error and stop — do not attempt to fix it. + +## Step 5 — Done + +Tell the user which files were changed (list them). Then show next steps: + +```bash +# Commit the setup +git add -A && git commit -m "chore: initialize project from python-project-template" +git push -u origin main + +# Optional: rename the project folder (run from the parent directory) +cd .. && mv python-project-template +``` + +Then tell the user to start the workflow: + +``` +@product-owner +``` + +The PO picks the first feature from backlog and moves it to in-progress. diff --git a/.opencode/skills/design-patterns/SKILL.md b/.opencode/skills/design-patterns/SKILL.md index 9ad303d..7d1b4de 100644 --- a/.opencode/skills/design-patterns/SKILL.md +++ b/.opencode/skills/design-patterns/SKILL.md @@ -402,5 +402,4 @@ class JsonImporter(Importer): Procedural code is open to inspection but open to modification too — every new case touches existing logic. OOP (via Strategy, State, Observer, etc.) closes existing code to modification and opens it to extension through new types. - The smell is always the same: **a place in the codebase that must change every time the domain grows.** diff --git a/.opencode/skills/feature-selection/SKILL.md b/.opencode/skills/feature-selection/SKILL.md index 3620397..a195b20 100644 --- a/.opencode/skills/feature-selection/SKILL.md +++ b/.opencode/skills/feature-selection/SKILL.md @@ -1,7 +1,7 @@ --- name: feature-selection description: Score and select the next backlog feature by value, effort, and dependencies -version: "2.0" +version: "1.0" author: product-owner audience: product-owner workflow: feature-lifecycle @@ -33,11 +33,15 @@ ls docs/features/in-progress/ ### 2. List BASELINED Candidates -Read each `.feature` file in `docs/features/backlog/`. Check its feature description for `Status: BASELINED`. +Read each `.feature` file in `docs/features/backlog/`. Check its discovery section for `Status: BASELINED`. - Non-BASELINED features are not eligible — they need Step 1 (scope) first - If no BASELINED features exist: inform the stakeholder; run `@product-owner` with `skill scope` to baseline the most promising backlog item first +**IMPORTANT** + +**NEVER move a feature to `in-progress/` unless its discovery section has `Status: BASELINED`** + ### 3. Score Each Candidate For each BASELINED feature, fill this table: @@ -78,8 +82,6 @@ If all BASELINED features have Dependency=1: stop and resolve the blocking depen ### 5. Move and Update TODO.md -**The PO owns this move.** Move the selected feature file: - ```bash mv docs/features/backlog/.feature docs/features/in-progress/.feature ``` @@ -97,7 +99,7 @@ Source: docs/features/in-progress/.feature Run @ ``` -- If the feature has no `Rule:` blocks yet → Step 1 Stage 2 Step A (Stories): `Run @product-owner — load skill scope and write user stories` +- If the feature has no `Rule:` blocks yet → Step 1 (SCOPE): `Run @product-owner — load skill scope and write stories` - If the feature has `Rule:` blocks but no `@id` Examples → Step 1 Stage 2 Step B (Criteria): `Run @product-owner — load skill scope and write acceptance criteria` - If the feature has `@id` Examples → Step 2 (ARCH): `Run @software-engineer — load skill implementation and write architecture stubs` diff --git a/.opencode/skills/git-release/SKILL.md b/.opencode/skills/git-release/SKILL.md index 4a66df5..fe85972 100644 --- a/.opencode/skills/git-release/SKILL.md +++ b/.opencode/skills/git-release/SKILL.md @@ -1,7 +1,7 @@ --- name: git-release -description: Create releases with hybrid major.minor.calver versioning and bee-genus naming -version: "2.0" +description: Create releases with hybrid major.minor.calver versioning and AI-generated adjective-animal naming +version: "1.0" author: software-engineer audience: software-engineer workflow: release-management @@ -28,81 +28,18 @@ v1.2.20260415 → v1.3.20260415 (same-day second release) ## Release Naming -Each release gets a unique name: **`{adjective}-{bee-genus}`** +Each release gets a unique adjective-animal name. Analyze the commits and PRs since the last release, identify the theme, and choose a name that reflects it. -The adjective reflects what this release does or its character. The bee genus is chosen from the curated pool below, matched thematically to the release (e.g. a release about test orchestration might use *Bombus* — the highly-organized bumblebee; a release about precise detection might use *Osmia* — the meticulous mason bee). +Choose any adjective and any animal (use scientific name, not common name). The only constraints: -**Constraints:** -1. **Thematic fit**: adjective and genus should together evoke the release character -2. **No repetition**: neither the adjective nor the genus may appear in any previous release name +1. **Thematic fit**: the name should reflect what this release does +2. **No repetition**: neither the adjective nor the animal may appear in a previous release Check previous names to avoid repetition: ```bash -gh release list --limit 100 +gh release list --limit 20 ``` -### Curated Bee Genus Pool - -Choose from this pool for intentional, memorable names. Each genus has a character note to guide thematic matching. - -| Genus | Common name | Character / theme | -|---|---|---| -| *Apis* | Honey bee | Collaboration, industry, the gold standard | -| *Bombus* | Bumblebee | Robustness, persistence, surprising capability | -| *Osmia* | Mason bee | Precision, craftsmanship, careful construction | -| *Megachile* | Leafcutter bee | Clever tooling, cutting to shape | -| *Xylocopa* | Carpenter bee | Structural work, building into solid foundations | -| *Halictus* | Sweat bee | Small but essential, invisible infrastructure | -| *Lasioglossum* | Small sweat bee | Ubiquity, the most common; baseline correctness | -| *Nomada* | Nomad bee | Migration, discovery, exploratory behavior | -| *Andrena* | Mining bee | Digging deep, uncovering hidden things | -| *Colletes* | Plasterer bee | Sealing, finishing, waterproofing | -| *Hylaeus* | Masked bee | Hidden internals, minimal exterior | -| *Eulaema* | Orchid bee | Exotic, specialized, high-value collection | -| *Eufriesea* | Orchid bee | Rare, distinctive, one-of-a-kind | -| *Agapostemon* | Metallic sweat bee | Brilliance, sheen, polish | -| *Augochlora* | Green sweat bee | Fresh, new, verdant growth | -| *Augochlorella* | Sweat bee | Emerging, small-scale refinement | -| *Augochloropsis* | Sweat bee | Variation on a theme, extension | -| *Panurgus* | Mining bee | Collective effort, many small contributions | -| *Perdita* | Mining bee | Smallest US bee; economy, minimalism | -| *Melitturga* | Mining bee | Clarity, straight lines | -| *Dasypoda* | Pantaloon bee | Deep foundations, load-bearing | -| *Macropis* | Oil bee | Specialized extraction, targeted collection | -| *Melitta* | Melitta bee | Sweetness, reward, delight | -| *Anthidium* | Wool-carder bee | Gathering, tidying, organization | -| *Coelioxys* | Sharp-tailed bee | Edge cases, pointed precision | -| *Stelis* | Cleptoparasitic bee | Detection, catching what doesn't belong | -| *Dioxys* | Cleptoparasitic bee | Finding impostors, validation | -| *Sphecodes* | Blood bee | Ruthless removal of what shouldn't be there | -| *Ceratina* | Small carpenter bee | Incremental progress, small but persistent | -| *Exomalopsis* | Bee | Quiet correctness, unassuming reliability | -| *Emphorella* | Bee | Niche specialization | -| *Peponapis* | Squash bee | Domain-specific excellence | -| *Xenoglossa* | Squash bee | Specialized vocabulary, domain language | -| *Ptilothrix* | Mallow bee | Softness of interface, gentle handling | -| *Melissodes* | Long-horned bee | Signal detection, communication | -| *Svastra* | Long-horned bee | Season-aware, time-sensitive behavior | -| *Eucera* | Long-horned bee | Patient waiting, timing | -| *Tetralonia* | Long-horned bee | Systematic coverage | -| *Anthophora* | Digger bee | Fast, energetic execution | -| *Habropoda* | Digger bee | Buzz-pollination; resonance, vibration | -| *Amegilla* | Blue-banded bee | Vibrant, high-frequency operation | -| *Xylocopinae* | Carpenter bee subfamily | Load-bearing architecture | -| *Euglossa* | Orchid bee | Precision collection, perfume of quality | -| *Eulaema* | Orchid bee | Valuable, coveted output | -| *Trigona* | Stingless bee | Safe, no sharp edges, user-friendly | -| *Tetragonula* | Stingless bee | Compact, structured, geometric | -| *Meliponula* | Stingless bee | African precision; warm-climate reliability | -| *Frieseomelitta* | Stingless bee | Abundant output, productivity | -| *Scaptotrigona* | Stingless bee | Aggressive defense of quality | -| *Plebeia* | Stingless bee | Humble, small, widely deployed | -| *Schwarziana* | Stingless bee | Named for a scientist; rigorous methodology | -| *Ctenocolletes* | Stenotritid bee | Ancient, foundational, rarely changed | -| *Stenotritus* | Stenotritid bee | Narrow, focused, specialized interface | - -If the release theme doesn't match any entry above, choose any other real bee genus and add it to this list with a character note. - ## Release Process ### 1. Analyze changes since last release @@ -110,6 +47,7 @@ If the release theme doesn't match any entry above, choose any other real bee ge ```bash last_tag=$(git describe --tags --abbrev=0) git log ${last_tag}..HEAD --oneline +gh pr list --state merged --limit 20 --json title,number,labels ``` ### 2. Calculate new version @@ -120,46 +58,31 @@ current_date=$(date +%Y%m%d) # new_version="v{major}.{minor}.${current_date}" ``` -### 3. Choose release name - -1. Read the commits and accepted features since the last release -2. Identify the theme (what did this release fundamentally accomplish?) -3. Choose an adjective that captures the theme -4. Choose a bee genus from the pool above that matches the theme -5. Verify neither the adjective nor the genus appear in previous release names: - ```bash - gh release list --limit 100 - ``` - -### 4. Update version in pyproject.toml +### 3. Update version in pyproject.toml and package __init__.py +Both must match: ```bash -# Update the version field in pyproject.toml -# e.g.: version = "0.2.20260418" +# Update pyproject.toml version field +# Update /__version__ to match ``` -No `__version__` in `__init__.py` — version is read at runtime via `importlib.metadata`. - -### 5. Update CHANGELOG.md - -Add at the top of the changelog (below the `# Changelog` heading): +### 4. Update CHANGELOG.md +Add at the top: ```markdown -## [v{version}] — {Adjective Genus} — {YYYY-MM-DD} +## [v{version}] - {Adjective Animal} - {YYYY-MM-DD} ### Added -- feat({feature-name}): description of what was added - -### Fixed -- fix({feature-name}): description of what was fixed +- description (#PR-number) ### Changed -- refactor/chore: description of structural changes -``` +- description (#PR-number) -Reference feature names (from `feat(): ...` commits), not PR numbers — this project uses direct commits rather than PRs. +### Fixed +- description (#PR-number) +``` -### 6. Update living docs +### 5. Update living docs Run the `living-docs` skill to reflect the newly accepted feature in C4 diagrams and the glossary. This step runs inline — do not commit separately. @@ -168,48 +91,51 @@ Load and execute the full `living-docs` skill now: - Update `docs/c4/container.md` (C4 Level 2, if multi-container) - Update `docs/glossary.md` (living glossary) -The `living-docs` commit step is **skipped** here — all changed files are staged together with the version bump in step 7. +The `living-docs` commit step is **skipped** here — all changed files are staged together with the version bump in step 6. -### 7. Regenerate lockfile and commit version bump +### 6. Regenerate lockfile and commit version bump After updating `pyproject.toml`, regenerate the lockfile — CI runs `uv sync --locked` and will fail if it is stale: ```bash uv lock -git add pyproject.toml CHANGELOG.md uv.lock docs/c4/context.md docs/c4/container.md docs/glossary.md -git commit -m "chore(release): bump version to v{version} — {Adjective Genus}" +git add pyproject.toml /__init__.py CHANGELOG.md uv.lock \ + docs/c4/context.md docs/c4/container.md docs/glossary.md +git commit -m "chore(release): bump version to v{version} - {Adjective Animal}" ``` -### 8. Create GitHub release +### 7. Create GitHub release + +Assign the SHA first so it expands correctly inside the notes string: ```bash SHA=$(git rev-parse --short HEAD) gh release create "v{version}" \ - --title "v{version} — {Adjective Genus}" \ - --notes "# v{version} — {Adjective Genus} + --title "v{version} - {Adjective Animal}" \ + --notes "# v{version} - {Adjective Animal} > *\"{one-line tagline matching the release theme}\"* ## Changelog ### Added -- feat({feature-name}): description +- feat: description (#PR) ### Fixed -- fix({feature-name}): description +- fix: description (#PR) ### Changed -- refactor/chore: description +- refactor/chore/docs: description (#PR) ## Summary -2-3 sentences describing what this release accomplishes and why the genus name fits. +2-3 sentences describing what this release accomplishes and why the name fits. --- **SHA**: \`${SHA}\`" ``` -### 9. If a hotfix commit follows the release tag +### 8. If a hotfix commit follows the release tag If CI fails after the release (e.g. a stale lockfile) and a hotfix commit is pushed, reassign the tag and GitHub release to that commit: @@ -235,9 +161,9 @@ The release notes and title do not need to change — only the target commit mov - [ ] `task static-check` passes - [ ] `pyproject.toml` version updated - [ ] `uv lock` run after version bump — lockfile must be up to date -- [ ] CHANGELOG.md updated with only beehave-specific changes (no template history) -- [ ] Release name: adjective not used before, genus not used before -- [ ] Genus chosen from curated pool (or new entry added to pool with character note) -- [ ] Release notes follow the template format +- [ ] `/__version__` matches `pyproject.toml` version +- [ ] CHANGELOG.md updated - [ ] `living-docs` skill run — C4 diagrams and glossary reflect the new feature +- [ ] Release name not used before +- [ ] Release notes follow the template format - [ ] If a hotfix was pushed after the tag: tag reassigned to hotfix commit diff --git a/.opencode/skills/implementation/SKILL.md b/.opencode/skills/implementation/SKILL.md index 9d71ae3..61ada18 100644 --- a/.opencode/skills/implementation/SKILL.md +++ b/.opencode/skills/implementation/SKILL.md @@ -1,7 +1,7 @@ --- name: implementation description: Steps 2-3 — Architecture + TDD Loop, one @id at a time -version: "4.0" +version: "3.0" author: software-engineer audience: software-engineer workflow: feature-lifecycle @@ -15,12 +15,12 @@ Steps 2 (Architecture) and 3 (TDD Loop) combined into a single skill. The softwa During implementation, correctness priorities are (in order): -1. **Design correctness** — YAGNI > KISS > DRY > SOLID > Object Calisthenics > appropriate design patterns +1. **Design correctness** — YAGNI > KISS > DRY > SOLID > Object Calisthenics > appropriate design patterns > complex code > complicated code > failing code > no code 2. **One @id green** — the specific test under work passes, plus `test-fast` still passes 3. **Commit** — when a meaningful increment is green 4. **Quality tooling** — `lint`, `static-check`, full `test` with coverage run at end-of-feature handoff -Design correctness is far more important than lint/pyright/coverage compliance. Never run lint, static-check, or coverage during the TDD loop — those are handoff-only checks. +Design correctness is far more important than lint/pyright/coverage compliance. Never run lint (ruff check, ruff format), static-check (pyright), or coverage during the TDD loop — those are handoff-only checks. --- @@ -28,33 +28,30 @@ Design correctness is far more important than lint/pyright/coverage compliance. ### Prerequisites (stop if any fail — escalate to PO) -1. `docs/features/in-progress/` contains exactly one `.feature` file. If it is empty (only `.gitkeep`): **STOP** — no feature is in progress. Output the escalation message and wait for PO to move a BASELINED feature from `backlog/` to `in-progress/`. -2. The feature file's feature description has `Status: BASELINED`. If not, escalate to PO — Step 1 is incomplete. +1. `docs/features/in-progress/` contains exactly one `.feature` file (not just `.gitkeep`). If none exists, **STOP** — update TODO.md `Next:` to `Run @product-owner — move the chosen feature to in-progress/` and stop. Never self-select or move a feature yourself. +2. The feature file's discovery section has `Status: BASELINED`. If not, escalate to PO — Step 1 is incomplete. 3. The feature file contains `Rule:` blocks with `Example:` blocks and `@id` tags. If not, escalate to PO — criteria have not been written. 4. Package name confirmed: read `pyproject.toml` → locate `[tool.setuptools]` → confirm directory exists on disk. -**The PO moves the `.feature` file from `backlog/` to `in-progress/` before Step 2 starts. Software-engineer never moves feature files.** - -Update `TODO.md` Source path to `docs/features/in-progress/.feature`. - ### Package Verification (mandatory — before writing any code) 1. Read `pyproject.toml` → locate `[tool.setuptools]` → record `packages = [""]` 2. Confirm directory exists: `ls /` -3. All new source files go under `/` — never under a template placeholder. +3. All new source files go under `/` + +**Note on feature file moves**: The PO moves `.feature` files between folders. The software-engineer never moves or edits `.feature` files. Update TODO.md `Source:` path to reflect `in-progress/` once the PO has moved the file. ### Read Phase (all before writing anything) -1. Read `docs/discovery.md` (project-level synthesis changelog) +1. Read `docs/discovery.md` (project-level synthesis changelog) and optionally `docs/discovery_journal.md` (Q&A history for context) 2. Read `docs/glossary.md` if it exists — use existing domain terms when naming classes, methods, and modules; do not invent synonyms for terms already defined -3. Read `docs/architecture.md` (all architectural decisions to date) -4. Read **ALL** `.feature` files in `docs/features/backlog/` (feature descriptions + Rules) -5. Read in-progress `.feature` file (full: Rules + Examples + @id) -6. Read **ALL** existing `.py` files in `/` — understand what already exists before adding anything +3. Read **ALL** `.feature` files in `docs/features/backlog/` (discovery + entities sections) +4. Read in-progress `.feature` file (full: Rules + Examples + @id) +5. Read **ALL** existing `.py` files in `/` — understand what already exists before adding anything ### Domain Analysis -From the Domain Model in `docs/discovery.md` + Rules (Business) in the `.feature` file: +From the Domain Model table in `docs/discovery.md` + Rules (Business) in the `.feature` file: - **Nouns** → named classes, value objects, aggregates - **Verbs** → method names with typed signatures - **Datasets** → named types (not bare dict/list) @@ -114,38 +111,41 @@ class UserRepository(Protocol): Place stubs where responsibility dictates — do not pre-create `ports/` or `adapters/` folders unless a concrete external dependency was identified in scope. Structure follows domain analysis, not a template. -### Write Architectural Decisions (significant decisions only) +### Record Architectural Decisions -For each significant architectural decision, append a dated block to `docs/architecture.md`: +Append a new dated block to `docs/architecture.md` for each significant decision: ```markdown ---- - -## YYYY-MM-DD — : +## YYYY-MM-DD — : Decision: Reason: Alternatives considered: +Feature: ``` -Only append an entry if the decision is non-obvious or has meaningful trade-offs. Routine YAGNI choices do not need an entry. Never edit past entries — append only. +Only write a block for non-obvious decisions with meaningful trade-offs. Routine YAGNI choices do not need a record. ### Architecture Smell Check (hard gate) Apply to the stub files just written: - [ ] No class with >2 responsibilities (SOLID-S) -- [ ] No class with >2 instance variables (OC-8) — behavioural classes only; dataclasses, Pydantic models, value objects, and TypedDicts are exempt +- [ ] No behavioural class with >2 instance variables (OC-8; dataclasses, Pydantic models, value objects, and TypedDicts are exempt) - [ ] All external deps assigned a Protocol (SOLID-D + Hexagonal) — N/A if no external dependencies identified in scope - [ ] No noun with different meaning across modules (DDD Bounded Context) - [ ] No missing Creational pattern: repeated construction without Factory/Builder - [ ] No missing Structural pattern: type-switching without Strategy/Visitor - [ ] No missing Behavioral pattern: state machine or scattered notification without State/Observer -- [ ] Each architectural decision in `docs/architecture.md` consistent with each @id AC — no contradictions +- [ ] Each ADR consistent with each @id AC — no contradictions If any check fails: fix the stub files before committing. -Commit: `feat(): add architecture stubs` +### Generate Test Stubs + +Run `uv run task test-fast` once. It reads the in-progress `.feature` file, assigns `@id` tags to any untagged `Example:` blocks (writing them back to the `.feature` file), and generates `tests/features//_test.py` — one file per `Rule:` block, one skipped function per `@id`. Verify the files were created, then stage all changes (including any `@id` write-backs to the `.feature` file). + +Commit: `feat(): add architecture and test stubs` --- @@ -153,33 +153,17 @@ Commit: `feat(): add architecture stubs` ### Prerequisites -- [ ] Exactly one `.feature` file in `in-progress/`. If not present, load `skill feature-selection` +- [ ] Exactly one .feature `in_progress`. If not present, Load `skill feature-selection` - [ ] Architecture stubs present in `/` (committed by Step 2) - [ ] Read `docs/architecture.md` — understand all architectural decisions before writing any test -- [ ] Test stub files exist in `tests/features//` — one file per `Rule:` block, all `@id` functions present with `@pytest.mark.skip`; if missing, write them now before entering RED - -### Write Test Stubs (if not present) - -For each `Rule:` block in the in-progress `.feature` file, create `tests/features//_test.py` if it does not already exist. Write one function per `@id` Example, all skipped: - -```python -@pytest.mark.skip(reason="not yet implemented") -def test__<@id>() -> None: - """ - Given: ... - When: ... - Then: ... - """ -``` - -Add `[ ]` rows to `## Progress` in `TODO.md` for each `@id` in the in-progress `.feature` file that is not already listed. +- [ ] Test stub files exist in `tests/features//_test.py` — generated by pytest-beehave at Step 2 end; if missing, re-run `uv run task test-fast` and commit the generated files before entering RED ### Build TODO.md Test List 1. List all `@id` tags from in-progress `.feature` file 2. Order: fewest dependencies first; most impactful within that set 3. Each `@id` = one TODO item, status: `pending` -4. Confirm each `@id` has a corresponding skipped stub in `tests/features//` — if any are missing, add them before proceeding +4. Confirm each `@id` has a corresponding skipped stub in `tests/features//` — if any are missing, add them before proceeding ### Outer Loop — One @id at a time @@ -190,17 +174,17 @@ For each pending `@id`: ``` INNER LOOP ├── RED -│ ├── Confirm stub for this @id exists in tests/features// with @pytest.mark.skip +│ ├── Confirm stub for this @id exists in tests/features//_test.py with @pytest.mark.skip │ ├── Read existing stubs in `/` — base the test on the current data model and signatures │ ├── Write test body (Given/When/Then → Arrange/Act/Assert); remove @pytest.mark.skip -│ ├── Update stub signatures as needed — edit the `.py` file directly +│ ├── Update stub signatures as needed — edit the `.py` file directly │ ├── uv run task test-fast │ └── EXIT: this @id FAILS │ (if it passes: test is wrong — fix it first) │ ├── GREEN │ ├── Write minimum code — YAGNI + KISS only -│ │ (no DRY, SOLID, OC here — those belong in REFACTOR) +│ │ (no DRY, SOLID, OC, Docstring, type hint here — those belong in REFACTOR) │ ├── uv run task test-fast │ └── EXIT: this @id passes AND all prior tests pass │ (fix implementation only; do not advance to next @id) @@ -219,7 +203,7 @@ Commit when a meaningful increment is green ```bash uv run task lint uv run task static-check -uv run task test # coverage must be 100% +uv run task test-coverage # coverage must be 100% timeout 10s uv run task run ``` @@ -229,33 +213,35 @@ All must pass before Self-Declaration. ### Self-Declaration (once, after all quality gates pass) + + Communicate verbally to the reviewer. Answer honestly for each principle: -- YAGNI: no code without a failing test — AGREE/DISAGREE | file:line -- YAGNI: no speculative abstractions — AGREE/DISAGREE | file:line -- KISS: simplest solution that passes — AGREE/DISAGREE | file:line -- KISS: no premature optimization — AGREE/DISAGREE | file:line -- DRY: no duplication — AGREE/DISAGREE | file:line -- DRY: no redundant comments — AGREE/DISAGREE | file:line -- SOLID-S: one reason to change per class — AGREE/DISAGREE | file:line -- SOLID-O: open for extension, closed for modification — AGREE/DISAGREE | file:line -- SOLID-L: subtypes substitutable — AGREE/DISAGREE | file:line -- SOLID-I: no forced unused deps — AGREE/DISAGREE | file:line -- SOLID-D: depend on abstractions, not concretions — AGREE/DISAGREE | file:line -- OC-1: one level of indentation per method — AGREE/DISAGREE | deepest: file:line -- OC-2: no else after return — AGREE/DISAGREE | file:line -- OC-3: primitive types wrapped — AGREE/DISAGREE | file:line -- OC-4: first-class collections — AGREE/DISAGREE | file:line -- OC-5: one dot per line — AGREE/DISAGREE | file:line -- OC-6: no abbreviations — AGREE/DISAGREE | file:line -- OC-7: ≤20 lines per function, ≤50 per class — AGREE/DISAGREE | longest: file:line -- OC-8: ≤2 instance variables per class (behavioural classes only; dataclasses, Pydantic models, value objects, and TypedDicts are exempt) — AGREE/DISAGREE | file:line -- OC-9: no getters/setters — AGREE/DISAGREE | file:line -- Patterns: I have no good reason to refactor parts of the code using OOP or Design Patterns — AGREE/DISAGREE | file:line -- Patterns: no creational smell — AGREE/DISAGREE | file:line -- Patterns: no structural smell — AGREE/DISAGREE | file:line -- Patterns: no behavioral smell — AGREE/DISAGREE | file:line -- Semantic: tests operate at same abstraction as AC — AGREE/DISAGREE | file:line +1. YAGNI: no code without a failing test — AGREE/DISAGREE | file:line +2. YAGNI: no speculative abstractions — AGREE/DISAGREE | file:line +3. KISS: simplest solution that passes — AGREE/DISAGREE | file:line +4. KISS: no premature optimization — AGREE/DISAGREE | file:line +5. DRY: no duplication — AGREE/DISAGREE | file:line +6. DRY: no redundant comments — AGREE/DISAGREE | file:line +7. SOLID-S: one reason to change per class — AGREE/DISAGREE | file:line +8. SOLID-O: open for extension, closed for modification — AGREE/DISAGREE | file:line +9. SOLID-L: subtypes substitutable — AGREE/DISAGREE | file:line +10. SOLID-I: no forced unused deps — AGREE/DISAGREE | file:line +11. SOLID-D: depend on abstractions, not concretions — AGREE/DISAGREE | file:line +12. OC-1: one level of indentation per method — AGREE/DISAGREE | deepest: file:line +13. OC-2: no else after return — AGREE/DISAGREE | file:line +14. OC-3: primitive types wrapped — AGREE/DISAGREE | file:line +15. OC-4: first-class collections — AGREE/DISAGREE | file:line +16. OC-5: one dot per line — AGREE/DISAGREE | file:line +17. OC-6: no abbreviations — AGREE/DISAGREE | file:line +18. OC-7: ≤20 lines per function, ≤50 per class — AGREE/DISAGREE | longest: file:line +19. OC-8: ≤2 instance variables per class (behavioural classes only; dataclasses, Pydantic models, value objects, and TypedDicts are exempt) — AGREE/DISAGREE | file:line +20. OC-9: no getters/setters — AGREE/DISAGREE | file:line +21. Patterns: no good reason remains to refactor using OOP or Design Patterns — AGREE/DISAGREE | file:line +22. Patterns: no creational smell — AGREE/DISAGREE | file:line +23. Patterns: no structural smell — AGREE/DISAGREE | file:line +24. Patterns: no behavioral smell — AGREE/DISAGREE | file:line +25. Semantic: tests operate at same abstraction as AC — AGREE/DISAGREE | file:line A `DISAGREE` answer is not automatic rejection — state the reason and fix before handing off. @@ -266,10 +252,6 @@ Signal completion to the reviewer. Provide: - Self-Declaration (communicated verbally, as above) - Summary of what was implemented -**After Step 4 APPROVED: do not move the `.feature` file. Escalate to PO.** Output: - -> Step 4 APPROVED for ``. Escalating to @product-owner — please move `docs/features/in-progress/.feature` to `docs/features/completed/.feature` and pick the next feature from backlog. - --- ## Test Writing Conventions @@ -277,30 +259,20 @@ Signal completion to the reviewer. Provide: ### Test File Layout ``` -tests/features//_test.py ← one per Rule: block -tests/features//examples_test.py ← when no Rule: blocks +tests/features//_test.py ``` -- `` = the `.feature` file stem -- `` = the `Rule:` title slugified +- `` = the `.feature` file stem with hyphens replaced by underscores, lowercase +- `` = the `Rule:` title slugified (lowercase, underscores) ### Function Naming -All tests are top-level functions — no classes, no `self`. - ```python -# Rule block → top-level functions in _test.py -def test_ball_game_a3f2b1c4() -> None: ... -def test_ball_game_c4d5e6f7() -> None: ... - -# No Rule block → top-level functions in examples_test.py -def test_ball_game_a3f2b1c4() -> None: ... +def test__<@id>() -> None: ``` -Function naming in all cases: `test__<@id>` - -- `feature_slug` = the `.feature` file stem with hyphens replaced by underscores, lowercase -- `@id` = the `@id` tag value from the `Example:` block +- `feature_slug` = the `.feature` file stem with spaces/hyphens replaced by underscores, lowercase +- `@id` = the `@id` from the `Example:` block ### Docstring Format (mandatory) @@ -308,24 +280,23 @@ New tests start as skipped stubs. Remove `@pytest.mark.skip` when implementing i ```python @pytest.mark.skip(reason="not yet implemented") -def test_ball_game_a3f2b1c4() -> None: +def test__<@id>() -> None: """ - Given: A ball moving upward reaches y=0 - When: The physics engine processes the next frame - Then: The ball velocity y-component becomes positive + <@id steps raw text including new lines> """ ``` **Rules**: -- Docstring contains `Given:/When:/Then:` on separate indented lines +- Docstring contains `Gherkin steps` as raw text on separate indented lines - No extra metadata in docstring — traceability comes from function name `@id` suffix ### Markers - `@pytest.mark.slow` — takes > 50ms (Hypothesis, DB, network, terminal I/O) -- `@pytest.mark.deprecated` — auto-skipped by conftest; used for superseded Examples +- `@pytest.mark.deprecated` — auto-skipped by pytest-beehave; used for superseded Examples ```python +@pytest.mark.deprecated def test_wall_bounce_a3f2b1c4() -> None: ... @@ -356,11 +327,11 @@ def test_wall_bounce_c4d5e6f7(x: float) -> None: **Rules**: - `@pytest.mark.slow` is mandatory on every `@given`-decorated test - `@example(...)` is optional but encouraged -- Never use Hypothesis for: I/O, side effects, network calls, database writes +- Do not use Hypothesis for: I/O, side effects, network calls, database writes ### Semantic Alignment Rule -The test's Given/When/Then must operate at the **same abstraction level** as the AC's Given/When/Then. +The test's Given/When/Then must operate at the **same abstraction level** as the AC's Steps. | AC says | Test must do | |---|---| @@ -375,7 +346,7 @@ If testing through the real entry point is infeasible, escalate to PO to adjust - No `isinstance()`, `type()`, or internal attribute (`_x`) checks in assertions - One assertion concept per test (multiple `assert` ok if they verify the same thing) - No `pytest.mark.xfail` without written justification -- `pytest.mark.skip` is only valid on stubs (`reason="not yet implemented"`) — remove it when implementing +- `pytest.mark.skip(reason="not yet implemented")` is only valid on stubs — remove it when implementing - Test data embedded directly in the test, not loaded from external files ### Test Tool Decision @@ -402,7 +373,7 @@ Extra tests in `tests/unit/` are allowed freely (coverage, edge cases, etc.) — ## Signature Design -Signatures are written during Step 2 (Architecture) and refined during Step 3 (RED). They live directly in the package `.py` files — never in the `.feature` file. + signatures are written during Step 2 (Architecture) and refined during Step 3 (RED). They live directly in the package `.py` files — never in the `.feature` file. Key rules: - Bodies are always `...` in the architecture stub diff --git a/.opencode/skills/living-docs/SKILL.md b/.opencode/skills/living-docs/SKILL.md index 29e0b80..8472547 100644 --- a/.opencode/skills/living-docs/SKILL.md +++ b/.opencode/skills/living-docs/SKILL.md @@ -15,8 +15,8 @@ The glossary is a secondary artifact derived from the code, the domain model, an ## When to Use -- **PO at Step 5** — after the feature is accepted and moved to `completed/`, run this skill to reflect the new feature in C4 diagrams and glossary. -- **Stakeholder on demand** — when the stakeholder asks "what does the system look like?" or "what does term X mean in this context?". +- **As part of the release process (Step 5)** — the `git-release` skill calls this skill inline at step 5, before the version-bump commit. Do not commit separately; the release process stages all files together. +- **Stakeholder on demand** — when the stakeholder asks "what does the system look like?" or "what does term X mean in this context?". In this case, commit with the standalone message in Step 5 below. ## Ownership Rules @@ -183,13 +183,15 @@ If `docs/glossary.md` already exists: ## Step 5 — Commit -After both C4 diagrams and glossary are updated: +**When called from the release process**: skip this step — the `git-release` skill stages and commits all files together. + +**When run standalone** (stakeholder on demand): commit after all diagrams and glossary are updated: ``` -docs(living-docs): update C4 and glossary after +docs(living-docs): update C4 and glossary after ``` -If this is a stakeholder-requested update without a specific feature trigger: +If triggered without a specific feature (general refresh): ``` docs(living-docs): refresh C4 diagrams and glossary @@ -207,4 +209,5 @@ docs(living-docs): refresh C4 diagrams and glossary - [ ] No existing glossary entry removed - [ ] Every new term has a traceable source in completed feature files or `docs/architecture.md`; no term is invented - [ ] No edits made to `docs/architecture.md` or `docs/discovery.md` -- [ ] Committed with `docs(living-docs): ...` message +- [ ] If standalone: committed with `docs(living-docs): ...` message +- [ ] If called from release: files staged but not committed (release process commits) diff --git a/.opencode/skills/pr-management/SKILL.md b/.opencode/skills/pr-management/SKILL.md index f10605c..94af430 100644 --- a/.opencode/skills/pr-management/SKILL.md +++ b/.opencode/skills/pr-management/SKILL.md @@ -14,7 +14,7 @@ Create and manage pull requests after the reviewer approves the feature (Step 5) ## Branch Naming ``` -feature/ # new feature +feature/ # new feature fix/ # bug fix refactor/ # refactoring docs/ # documentation @@ -42,7 +42,7 @@ git commit -m "chore(deps): add python-dotenv dependency" ```bash # Push branch -git push -u origin feature/ +git push -u origin feature/ # Create PR gh pr create \ diff --git a/.opencode/skills/refactor/SKILL.md b/.opencode/skills/refactor/SKILL.md index 4d48a1e..208d12d 100644 --- a/.opencode/skills/refactor/SKILL.md +++ b/.opencode/skills/refactor/SKILL.md @@ -265,9 +265,9 @@ Refactoring commits are always **separate** from feature commits. | Commit type | Message format | When | |---|---|---| -| Preparatory refactoring | `refactor(): ` | Before RED, to make the feature easier | -| REFACTOR phase | `refactor(): ` | After GREEN, cleaning up the green code | -| Feature addition | `feat(): ` | After GREEN (never mixed with refactor) | +| Preparatory refactoring | `refactor(): ` | Before RED, to make the feature easier | +| REFACTOR phase | `refactor(): ` | After GREEN, cleaning up the green code | +| Feature addition | `feat(): ` | After GREEN (never mixed with refactor) | Never mix a structural cleanup with a behavior addition in one commit. This keeps history bisectable and CI green at every commit. @@ -291,7 +291,7 @@ Before marking the `@id` complete, verify all of the following. Each failed item | OC-5 | One dot per line | `obj.repo.find(id).name` | | OC-6 | No abbreviations | `usr`, `mgr`, `cfg`, `val`, `tmp` | | OC-7 | Classes ≤ 50 lines, methods ≤ 20 lines | Any method requiring scrolling | -| OC-8 | ≤ 2 instance variables per class | `__init__` with 3+ `self.x =` assignments *(dataclasses/Pydantic/value objects/TypedDicts exempt)* | +| OC-8 | ≤ 2 instance variables per class *(behavioural classes only; dataclasses, Pydantic models, value objects, and TypedDicts are exempt)* | `__init__` with 3+ `self.x =` assignments in a behavioural class | | OC-9 | No getters/setters | `def get_name(self)` / `def set_name(self, v)` | ### SOLID (Martin 2000) diff --git a/.opencode/skills/scope/SKILL.md b/.opencode/skills/scope/SKILL.md index 4a10efc..cd02bd5 100644 --- a/.opencode/skills/scope/SKILL.md +++ b/.opencode/skills/scope/SKILL.md @@ -70,7 +70,7 @@ Do not introduce topic labels or categories during active listening. The summary ## Stage 1 — Discovery -Discovery is a continuous, iterative process. Sessions happen whenever scope needs to be established or refined — for a new project, for a new feature, or when new information emerges. There is no fixed number of sessions; every session follows the same structure. +Discovery is a continuous, iterative process. Sessions happen whenever scope needs to be established or refined — for a new project, for a new feature, or when new information emerges. There is no "Phase 1" vs "Phase 2" distinction; every session follows the same structure. ### Session Start (every session) @@ -130,7 +130,7 @@ Append all answered Q&A to `docs/discovery_journal.md`, in groups (general, cros Group headers use this format: - General group: `### General` - Cross-cutting group: `### ` -- Feature group: `### Feature: ` +- Feature group: `### Feature: ` **Step B — Update .feature descriptions** @@ -143,7 +143,7 @@ If a feature is new (just created as a stub): write its initial description now. After all `.feature` files are updated, append one `## Session: YYYY-MM-DD` block to `docs/discovery.md`. The block contains: - `### Feature List` — which features were added or changed (0–N entries); if nothing changed, write "No changes" - `### Domain Model` — new or updated domain entities and verbs; if nothing changed, write "No changes" -- `### Scope` (first session only) — 3–5 sentence synthesis of who the users are, what the product does, why it exists, success/failure conditions, and explicit out-of-scope +- `### Context` (first session only) — 3–5 sentence synthesis of who the users are, what the product does, why it exists, success/failure conditions, and explicit out-of-scope **Step D — Mark session complete** @@ -216,7 +216,7 @@ Avoid: "As the system, I want..." (no business value). Break down stories that c - [ ] Rules collectively cover all entities in scope from the feature description - [ ] Every Rule passes the INVEST gate -Commit: `feat(stories): write user stories for ` +Commit: `feat(stories): write user stories for ` ### Step B — Criteria @@ -244,7 +244,6 @@ All Rules must have their pre-mortems completed before any Examples are written. ``` **Rules**: -- `@id` tag on the line before `Example:` - `Example:` keyword (not `Scenario:`) - `Given/When/Then` in plain English - `Then` must be a single, observable, measurable outcome — no "and" @@ -271,7 +270,6 @@ All Rules must have their pre-mortems completed before any Examples are written. **Review checklist:** - [ ] Every `Rule:` block has at least one Example -- [ ] Every `@id` is unique within this feature - [ ] Every Example has `Given/When/Then` - [ ] Every `Then` is a single, observable, measurable outcome - [ ] No Example tests implementation details @@ -291,15 +289,14 @@ Communicate verbally to the next agent. Every `DISAGREE` is a **hard blocker** - No impl details: no Example tests internal state or implementation — AGREE/DISAGREE | file:line - Coverage: every entity in the feature description appears in at least one Rule — AGREE/DISAGREE | missing: - Distinct: no two Examples test the same observable behavior — AGREE/DISAGREE | file:line -- Unique IDs: all @id values are unique within this feature — AGREE/DISAGREE - Pre-mortem: I ran a pre-mortem on each Rule and found no hidden failure modes — AGREE/DISAGREE | Rule: - Scope: no Example introduces behavior outside the feature boundary — AGREE/DISAGREE | file:line -Commit: `feat(criteria): write acceptance criteria for ` +Commit: `feat(criteria): write acceptance criteria for ` **After this commit, `Example:` blocks are frozen.** Any change requires: 1. Add `@deprecated` tag to the old Example -2. Write a new Example with a new `@id` +2. Write a new Example (the `@id` tag will be assigned automatically) --- @@ -310,14 +307,14 @@ When a defect is reported against a completed or in-progress feature: 1. **PO** adds a new Example to the relevant `Rule:` block in the `.feature` file: ```gherkin - @bug @id: + @bug Example: Given When Then ``` -2. **SE** implements the specific test in `tests/features//` (the `@id` test). +2. **SE** implements the specific test in `tests/features//` (the `@id` test). 3. **SE** also writes a `@given` Hypothesis property test in `tests/unit/` covering the whole class of inputs that triggered the bug — not just the single case. 4. Both tests are required — neither is optional. 5. SE follows the normal TDD loop (Step 3) for the new `@id`. @@ -367,6 +364,7 @@ The **Constraints** section captures non-functional requirements. Testable const What is **not** in `.feature` files: - Entities table — domain model lives in `docs/discovery.md` - Session Q&A blocks — live in `docs/discovery_journal.md` +- Template §N markers — live in `docs/discovery_journal.md` session blocks - Architecture section — lives in `docs/architecture.md` --- @@ -403,7 +401,7 @@ Status: IN-PROGRESS |----|----------|--------| | Q8 | ... | ... | -### Feature: +### Feature: | ID | Question | Answer | |----|----------|--------| @@ -428,13 +426,13 @@ Rules: ## Session: YYYY-MM-DD -### Scope +### Context <3–5 sentence synthesis of who the users are, what the product does, why it exists, success/failure conditions, and out-of-scope boundaries.> (First session only. Omit this subsection in subsequent sessions.) ### Feature List -- `` — +- `` — (Write "No changes" if no features were added or modified this session.) ### Domain Model @@ -458,20 +456,20 @@ Rules: --- -## YYYY-MM-DD — : +## YYYY-MM-DD — : Decision: Reason: Alternatives considered: -Feature: +Feature: ``` Rules: Append-only. When a decision changes, append a new block that supersedes the old one. Cross-feature decisions use `Cross-feature:` in the header. Only write a block for non-obvious decisions with meaningful trade-offs. -Base directory for this skill: file:///home/user/Documents/projects/pytest-beehave/.opencode/skills/scope +Base directory for this skill: file:///home/user/Documents/projects/python-project-template/.opencode/skills/scope Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory. Note: file list is sampled. -/home/user/Documents/projects/pytest-beehave/.opencode/skills/scope/discovery-template.md +/home/user/Documents/projects/python-project-template/.opencode/skills/scope/discovery-template.md diff --git a/.opencode/skills/scope/discovery-template.md b/.opencode/skills/scope/discovery-template.md index daef285..aa4cc5c 100644 --- a/.opencode/skills/scope/discovery-template.md +++ b/.opencode/skills/scope/discovery-template.md @@ -1,19 +1,9 @@ Feature: - <2-4 sentence feature description — what it does, for whom, and why. Always current; PO rewrites when scope changes.> + + <2–4 sentence description of what this feature does and why it exists.> Status: ELICITING Rules (Business): Constraints: - - Rule: - As a - I want - So that - - @id:<8-char-hex> - Example: - Given - When - Then diff --git a/.opencode/skills/session-workflow/SKILL.md b/.opencode/skills/session-workflow/SKILL.md index a5744ea..0281f2c 100644 --- a/.opencode/skills/session-workflow/SKILL.md +++ b/.opencode/skills/session-workflow/SKILL.md @@ -1,7 +1,7 @@ --- name: session-workflow description: Session start and end protocol — read TODO.md, continue from checkpoint, update and commit -version: "4.0" +version: "3.0" author: software-engineer audience: all-agents workflow: session-management @@ -11,19 +11,6 @@ workflow: session-management Every session starts by reading state. Every session ends by writing state. This makes any agent able to continue from where the last session stopped. -## Agent Responsibilities: Feature File Moves - -**The PO is the sole owner of all `.feature` file moves.** Software-engineer and reviewer never move, rename, or create feature files. - -| Trigger | Action | Who | -|---|---|---| -| Feature selected for development | Move `backlog/.feature` → `in-progress/.feature` | PO only | -| Step 5 acceptance | Move `in-progress/.feature` → `completed/.feature` | PO only | - -**Escalation**: if software-engineer or reviewer find no file in `docs/features/in-progress/`, they **stop immediately** and output: - -> No feature is currently in progress. Escalating to @product-owner — please move a BASELINED feature from `docs/features/backlog/` to `docs/features/in-progress/` to begin development. - ## Session Start 1. Read `TODO.md` — find current feature, current step, and the "Next" line. @@ -32,20 +19,20 @@ Every session starts by reading state. Every session ends by writing state. This # Current Work No feature in progress. - Next: Run @ — load skill feature-selection and pick the next BASELINED feature from backlog. + Next: Run @product-owner — load skill feature-selection and pick the next BASELINED feature from backlog. ``` 2. **If you are the PO** and Step 1 (SCOPE) is active: check `docs/discovery_journal.md` for the most recent session block. - If the most recent block has `Status: IN-PROGRESS` → the previous session was interrupted. Resume it before starting a new session: finish updating `.feature` files and `docs/discovery.md`, then mark the block `Status: COMPLETE`. 3. If a feature is active at Step 2–5, read: - - `docs/features/in-progress/.feature` — feature spec (feature description + Rules + Examples) - - `docs/discovery.md` — project-level discovery changelog (for context) + - `docs/features/in-progress/.feature` — feature file (Rules + Examples + @id) + - `docs/discovery.md` — project-level synthesis changelog (for context) 4. Run `git status` — understand what is committed vs. what is not 5. Confirm scope: you are working on exactly one step of one feature **If TODO.md says "No feature in progress":** - **PO**: Load `skill feature-selection` — it guides you through scoring and selecting the next BASELINED backlog feature. You must verify the feature has `Status: BASELINED` before moving it to `in-progress/`. Only you may move it. -- **Software-engineer or reviewer**: Update TODO.md `Next:` line to `Run @ — load skill feature-selection and pick the next BASELINED feature from backlog.` Then **stop**. Never self-select a feature. Never move a `.feature` file. +- **Software-engineer or reviewer**: Update TODO.md `Next:` line to `Run @product-owner — load skill feature-selection and pick the next BASELINED feature from backlog.` Then **stop**. Never self-select a feature. Never move a `.feature` file. ## Session End @@ -56,7 +43,7 @@ Every session starts by reading state. Every session ends by writing state. This 2. Commit any uncommitted work (even WIP): ```bash git add -A - git commit -m "WIP(): " + git commit -m "WIP(): " ``` 3. If a step is fully complete, use the proper commit message instead of WIP. @@ -68,7 +55,7 @@ When a step completes within a session: 2. Commit the TODO.md update: ```bash git add TODO.md - git commit -m "chore: complete step for " + git commit -m "chore: complete step for " ``` 3. Only then begin the next step (in a new session where possible — see Rule 4). @@ -77,9 +64,9 @@ When a step completes within a session: ```markdown # Current Work -Feature: +Feature: Step: <1-5> () -Source: docs/features/in-progress/.feature +Source: docs/features/in-progress/.feature ## Progress - [x] `@id:`: @@ -92,15 +79,15 @@ Run @ **"Next" line format**: Always prefix with `Run @` so the human knows exactly which agent to invoke. Agent names are defined in `AGENTS.md` — use the name exactly as listed there. Examples: - `Run @ — implement @id:a1b2c3d4 (Step 3 RED)` -- `Run @ — load skill implementation and begin Step 2 (Architecture) for ` -- `Run @ — verify feature at Step 4` +- `Run @ — load skill implementation and begin Step 2 (Architecture) for ` +- `Run @ — verify feature at Step 4` - `Run @ — pick next BASELINED feature from backlog` -- `Run @ — accept feature at Step 5` +- `Run @ — accept feature at Step 5` **Source path by step:** -- Step 1: `Source: docs/features/backlog/.feature` -- Steps 2–4: `Source: docs/features/in-progress/.feature` -- Step 5: `Source: docs/features/completed/.feature` +- Step 1: `Source: docs/features/backlog/.feature` +- Steps 2–4: `Source: docs/features/in-progress/.feature` +- Step 5: `Source: docs/features/completed/.feature` Status markers: - `[ ]` — not started @@ -116,39 +103,16 @@ No feature in progress. Next: Run @ — load skill feature-selection and pick the next BASELINED feature from backlog. ``` -## Step 1 (Stage 2 Criteria) Self-Declaration - -When Stage 2 Step B (criteria) is complete and before the `feat(criteria):` commit, the PO produces a Self-Declaration as **conversation output only** — do not write it into TODO.md: - -``` -PO Self-Declaration — -* INVEST-I: each Rule is Independent — AGREE/DISAGREE | conflict: -* INVEST-V: each Rule delivers Value to a named user — AGREE/DISAGREE | Rule: -* INVEST-S: each Rule is Small enough for one development cycle — AGREE/DISAGREE | Rule: -* INVEST-T: each Rule is Testable — AGREE/DISAGREE | Rule: -* Observable: every Then is a single, observable, measurable outcome — AGREE/DISAGREE | file:line -* No impl details: no Example tests internal state or implementation — AGREE/DISAGREE | file:line -* Coverage: every entity in the feature description appears in at least one Rule — AGREE/DISAGREE | missing: -* Distinct: no two Examples test the same observable behavior — AGREE/DISAGREE | file:line -* Unique IDs: all @id values are unique within this feature — AGREE/DISAGREE -* Pre-mortem: I ran a pre-mortem on each Rule and found no hidden failure modes — AGREE/DISAGREE | Rule: -* Scope: no Example introduces behavior outside the feature boundary — AGREE/DISAGREE | file:line -``` - -Every `DISAGREE` is a hard blocker — fix before committing. - ## Step 3 (TDD Loop) Cycle-Aware TODO Format During Step 3 (TDD Loop), TODO.md **must** include a `## Cycle State` block to track Red-Green-Refactor progress. -When `Phase: REFACTOR` is complete for all @id, the SE produces a Self-Declaration as **conversation output only** (not in TODO.md) before handing off to Step 4. - ```markdown # Current Work -Feature: +Feature: Step: 3 (TDD Loop) -Source: docs/features/in-progress/.feature +Source: docs/features/in-progress/.feature ## Cycle State Test: `@id:` — @@ -178,5 +142,3 @@ Phase: RED | GREEN | REFACTOR 5. The "Next" line must be actionable enough that a fresh AI can execute it without asking questions 6. During Step 3, always update `## Cycle State` when transitioning between RED/GREEN/REFACTOR phases 7. When a step completes, update TODO.md and commit **before** any further work -8. During Step 3, produce the Self-Declaration as conversation output after all quality gates pass — every claim must have AGREE/DISAGREE with `file:line` evidence; never write it into TODO.md -9. During Step 1 Stage 2 Step B (criteria), produce the PO Self-Declaration as conversation output before the criteria commit — every DISAGREE is a hard blocker; never write it into TODO.md diff --git a/.opencode/skills/session-workflow/scripts/gen_todo.py b/.opencode/skills/session-workflow/scripts/gen_todo.py deleted file mode 100644 index 980df1e..0000000 --- a/.opencode/skills/session-workflow/scripts/gen_todo.py +++ /dev/null @@ -1,371 +0,0 @@ -"""Generate and sync the TODO.md session bookmark from .feature files. - -Reads the in-progress .feature file (or reports none if not present), -merges missing @id rows into the existing TODO.md, and writes the result. - -Modes: - uv run task gen-todo Merge-write TODO.md (default) - uv run task gen-todo -- --check Dry run — show what would change - -Merge rules: - - Adds @id rows that are in .feature files but missing from TODO.md - - Never removes or downgrades existing [x], [~], [-] rows - - Updates the Feature/Step/Source header from the in-progress file - - If no feature is in-progress, writes the "No feature in progress" format -""" - -from __future__ import annotations - -import re -import sys -from dataclasses import dataclass -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parents[4] -FEATURES_DIR = PROJECT_ROOT / "docs" / "features" -TODO_PATH = PROJECT_ROOT / "TODO.md" - -PROGRESS_ROW_RE = re.compile(r"^- \[(?P[x~\- ])\] `@id:(?P[a-f0-9]{8})`") -ID_TAG_RE = re.compile(r"@id:([a-f0-9]{8})") -EXAMPLE_RE = re.compile(r"^\s*Example:\s*(.+)$") -DEPRECATED_TAG_RE = re.compile(r"@deprecated") - - -@dataclass(frozen=True, slots=True) -class Criterion: - """One acceptance criterion extracted from a .feature file.""" - - id_hex: str - title: str - deprecated: bool - - -def find_in_progress_feature() -> tuple[str, Path] | None: - """Find the single .feature file currently in docs/features/in-progress/. - - Returns: - Tuple of (feature_name, feature_file_path) or None if nothing is in progress. - feature_name is the .feature file stem (e.g. 'display-version'). - """ - in_progress = FEATURES_DIR / "in-progress" - if not in_progress.exists(): - return None - feature_files = [ - f for f in in_progress.iterdir() if f.is_file() and f.suffix == ".feature" - ] - if not feature_files: - return None - feature_file = feature_files[0] - return feature_file.stem, feature_file - - -def find_backlog_features() -> list[str]: - """List feature names in docs/features/backlog/. - - Returns: - Sorted list of .feature file stems. - """ - backlog = FEATURES_DIR / "backlog" - if not backlog.exists(): - return [] - return sorted( - f.stem for f in backlog.iterdir() if f.is_file() and f.suffix == ".feature" - ) - - -def extract_criteria(feature_path: Path) -> list[Criterion]: - """Extract all @id-tagged Examples from a single .feature file. - - Args: - feature_path: Path to the .feature file. - - Returns: - Ordered list of Criterion objects (deprecated ones included). - """ - return _parse_feature_file(feature_path) - - -def _parse_feature_file(path: Path) -> list[Criterion]: - """Parse a single .feature file for @id-tagged Examples. - - Args: - path: Path to the .feature file. - - Returns: - List of Criterion objects found in this file. - """ - lines = path.read_text(encoding="utf-8").splitlines() - criteria: list[Criterion] = [] - i = 0 - while i < len(lines): - line = lines[i] - id_match = ID_TAG_RE.search(line) - if id_match: - id_hex = id_match.group(1) - deprecated = bool(DEPRECATED_TAG_RE.search(line)) - title = _find_example_title(lines, i + 1) - criteria.append( - Criterion(id_hex=id_hex, title=title, deprecated=deprecated) - ) - i += 1 - return criteria - - -def _find_example_title(lines: list[str], start: int) -> str: - """Scan forward from start to find the Example: title line. - - Args: - lines: All lines from the .feature file. - start: Index to start scanning from. - - Returns: - The Example title string, or empty string if not found. - """ - for i in range(start, min(start + 5, len(lines))): - m = EXAMPLE_RE.match(lines[i]) - if m: - return m.group(1).strip() - return "" - - -def read_existing_progress(todo_text: str) -> dict[str, str]: - """Extract existing @id rows and their status from TODO.md content. - - Args: - todo_text: Full content of current TODO.md. - - Returns: - Dict mapping id_hex -> status character ('x', '~', '-', ' '). - """ - existing: dict[str, str] = {} - for line in todo_text.splitlines(): - m = PROGRESS_ROW_RE.match(line) - if m: - existing[m.group("id")] = m.group("status") - return existing - - -def build_progress_lines( - criteria: list[Criterion], - existing: dict[str, str], -) -> list[str]: - """Build the ## Progress section lines, merging new with existing. - - Args: - criteria: All criteria from .feature files (in order). - existing: Existing @id -> status mapping from current TODO.md. - - Returns: - List of progress row strings (without trailing newline). - """ - lines = [] - for c in criteria: - status = existing.get(c.id_hex, " ") - label = c.title or "(no title)" - suffix = " — DEPRECATED" if c.deprecated else "" - lines.append(f"- [{status}] `@id:{c.id_hex}`: {label}{suffix}") - return lines - - -def build_todo_content( - feature_name: str, - step: str, - source: str, - progress_lines: list[str], - next_action: str, -) -> str: - """Assemble the full TODO.md content. - - Args: - feature_name: Display name of the current feature. - step: Current step number and name, e.g. '4 (implement)'. - source: Path to discovery.md. - progress_lines: The ## Progress rows. - next_action: The ## Next one-liner. - - Returns: - Full TODO.md content string. - """ - lines = [ - "# Current Work", - "", - f"Feature: {feature_name}", - f"Step: {step}", - f"Source: {source}", - "", - "## Progress", - *progress_lines, - "", - "## Next", - next_action, - "", - ] - return "\n".join(lines) - - -def build_empty_todo() -> str: - """Build the 'No feature in progress' TODO.md content. - - Returns: - Minimal TODO.md content string. - """ - return "\n".join( - [ - "# Current Work", - "", - "No feature in progress.", - "Next: PO picks feature from docs/features/backlog/ and moves it to" - " docs/features/in-progress/.", - "", - ] - ) - - -def _extract_header_field(todo_text: str, field: str) -> str: - """Extract a header field value from existing TODO.md. - - Args: - todo_text: Full TODO.md content. - field: Field name to look for (e.g. 'Step', 'Feature'). - - Returns: - The value string, or empty string if not found. - """ - pattern = re.compile(rf"^{field}:\s*(.+)$", re.MULTILINE) - m = pattern.search(todo_text) - return m.group(1).strip() if m else "" - - -def _extract_next_action(todo_text: str) -> str: - """Extract the ## Next line from existing TODO.md. - - Args: - todo_text: Full TODO.md content. - - Returns: - The Next action string, or a placeholder. - """ - lines = todo_text.splitlines() - for i, line in enumerate(lines): - if line.strip() == "## Next" and i + 1 < len(lines) and lines[i + 1].strip(): - return lines[i + 1].strip() - return "" - - -def _sync_no_feature(*, check_only: bool) -> int: - """Handle sync when no feature is in progress. - - Args: - check_only: If True, report changes without writing. - - Returns: - Exit code: 0 = in sync or wrote successfully, 1 = changes needed (check mode). - """ - new_content = build_empty_todo() - existing = TODO_PATH.read_text(encoding="utf-8") if TODO_PATH.exists() else "" - if existing.strip() == new_content.strip(): - print("TODO.md is in sync.") - return 0 - if check_only: - print("TODO.md would be updated: no feature in progress format.") - return 1 - TODO_PATH.write_text(new_content, encoding="utf-8") - print("TODO.md updated: no feature in progress.") - return 0 - - -def _write_or_report( - new_content: str, - new_ids: set[str], - criteria: list[Criterion], - *, - check_only: bool, -) -> int: - """Write updated TODO.md or report what would change. - - Args: - new_content: The new TODO.md content to write. - new_ids: Set of @id hex values that are new (not in existing TODO.md). - criteria: All criteria from .feature files. - check_only: If True, report changes without writing. - - Returns: - Exit code: 0 = wrote successfully, 1 = changes needed (check mode). - """ - if check_only: - if new_ids: - print(f"TODO.md would add {len(new_ids)} new @id row(s):") - for c in criteria: - if c.id_hex in new_ids: - print(f" [ ] @id:{c.id_hex}: {c.title}") - else: - print("TODO.md header or structure would be updated.") - return 1 - TODO_PATH.write_text(new_content, encoding="utf-8") - if new_ids: - print(f"TODO.md updated: added {len(new_ids)} new @id row(s).") - for c in criteria: - if c.id_hex in new_ids: - print(f" [ ] @id:{c.id_hex}: {c.title}") - else: - print("TODO.md updated.") - return 0 - - -def sync_todo(*, check_only: bool = False) -> int: - """Main sync logic: read feature state, merge TODO.md, write if changed. - - Args: - check_only: If True, report changes without writing. - - Returns: - Exit code: 0 = in sync or wrote successfully, 1 = changes needed (check mode). - """ - result = find_in_progress_feature() - - if result is None: - return _sync_no_feature(check_only=check_only) - - feature_name, feature_path = result - criteria = extract_criteria(feature_path) - - existing_text = TODO_PATH.read_text(encoding="utf-8") if TODO_PATH.exists() else "" - existing_progress = read_existing_progress(existing_text) - - step = ( - _extract_header_field(existing_text, "Step") or "? (unknown — update manually)" - ) - source = f"docs/features/in-progress/{feature_name}.feature" - next_action = _extract_next_action(existing_text) - - progress_lines = build_progress_lines(criteria, existing_progress) - new_content = build_todo_content( - feature_name=feature_name, - step=step, - source=source, - progress_lines=progress_lines, - next_action=next_action, - ) - - existing_ids = set(existing_progress.keys()) - new_ids = {c.id_hex for c in criteria} - existing_ids - - if existing_text.strip() == new_content.strip(): - print("TODO.md is in sync.") - return 0 - - return _write_or_report(new_content, new_ids, criteria, check_only=check_only) - - -def main() -> int: - """Entry point for the gen-todo command. - - Returns: - Exit code (0 = success, 1 = changes needed in check mode). - """ - check_only = "--check" in sys.argv - return sync_todo(check_only=check_only) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.opencode/skills/verify/SKILL.md b/.opencode/skills/verify/SKILL.md index 171522d..3d5c449 100644 --- a/.opencode/skills/verify/SKILL.md +++ b/.opencode/skills/verify/SKILL.md @@ -9,6 +9,12 @@ workflow: feature-lifecycle # Verify +This skill guides the reviewer through Step 4: independent verification that the feature works correctly and meets quality standards. The output is a written report with a clear APPROVED or REJECTED decision. + +**Your default hypothesis is that the code is broken despite passing automated checks. Your job is to find the failure mode. If you cannot find one after thorough investigation, APPROVE. If you find one, REJECTED.** + +**Every PASS/FAIL cell must have evidence.** Empty evidence = UNCHECKED = REJECTED. + **You never move `.feature` files.** After producing an APPROVED report: update TODO.md `Next:` to `Run @product-owner — accept feature at Step 5.` then stop. The PO accepts the feature and moves the file. The reviewer produces one written report (see template below) that includes: all gate results, the SE Self-Declaration Audit, the **Reviewer Stance Declaration**, and the final APPROVED/REJECTED verdict. Do not start until the software-engineer has committed all work and communicated the Self-Declaration verbally in the handoff message. @@ -20,8 +26,8 @@ The reviewer produces one written report (see template below) that includes: all Read `docs/features/in-progress/.feature`. Extract: - All `@id` tags and their Example titles from `Rule:` blocks - The interaction model (if the feature involves user interaction) -- `docs/architecture.md` — all architectural decisions relevant to this feature -- The software-engineer's Self-Declaration from the handoff message +- The architectural decisions in `docs/architecture.md` relevant to this feature +- The software-engineer's Self-Declaration (communicated verbally in the handoff message) ### 2. pyproject.toml Gate @@ -54,16 +60,20 @@ Run before code review. If any row is FAIL, stop immediately with REJECTED. ### 5. Self-Declaration Audit +**Completeness check (hard gate — REJECT if failed)**: Count the numbered items in the SE's Self-Declaration. The template in `implementation/SKILL.md` has exactly 25 items numbered 1–25. If the count is not 25, or any number in the sequence 1–25 is missing, REJECT immediately — do not proceed to item-level audit. + Read the software-engineer's Self-Declaration from the handoff message. For every **AGREE** claim: - Find the `file:line` — does it hold? For every **DISAGREE** claim: -- If the constraint genuinely falls outside the SE's control (e.g. external library forces method chaining, dataclass/Pydantic/TypedDict exemption for ≤2 ivars): accept with a note in the report. -- If the justification is weak or absent: REJECT — the software-engineer must fix before requesting review again. +- Read the justification carefully. +- If the constraint genuinely falls outside the SE's control (e.g. external library forces method chaining, dataclass/Pydantic/TypedDict exemption for ≤2 ivars): accept with a note in the report and suggest the closest compliant alternative if one exists. +- If the justification is weak, incomplete, or a best-practice alternative exists that the SE did not consider: REJECT with the specific alternative stated. +- If there is no justification: REJECT. -Undeclared violations → REJECT. +Undeclared violations found during code review → REJECT. ### 6. Code Review @@ -121,7 +131,7 @@ Load `skill design-patterns` and apply the full OC checklist (9 rules). Record a | No internal attribute access | Search for `_x` in assertions | None found | `_x`, `isinstance`, `type()` | | Every `@id` has a mapped test | Match `@id` to test functions | All mapped | Missing test | | No orphaned skipped stubs | Search for `@pytest.mark.skip` in `tests/features/` | None found | Any found — stub was written but never implemented | - | Function naming | Matches `test__<@id>` | All match | Mismatch | +| Function naming | Matches `test__<8char_hex>` | All match | Mismatch | | Hypothesis tests have `@slow` | Read every `@given` for `@slow` marker | All present | Any missing | #### 6g. Code Quality — any FAIL → REJECTED @@ -154,7 +164,7 @@ Record what input was given and what output was observed. ### 9. Write the Report ```markdown -## Step 4 Verification Report — +## Step 4 Verification Report — ### pyproject.toml Gate | Check | Result | Notes | @@ -175,33 +185,47 @@ Record what input was given and what output was observed. | uv run task test | PASS / FAIL | | ### Self-Declaration Audit -| Claim | Software-Engineer Claims | Reviewer Verdict | Evidence | -|------|-------------------------|------------------|----------| -| YAGNI | AGREE/DISAGREE | PASS/FAIL | | -| KISS | AGREE/DISAGREE | PASS/FAIL | | -| DRY | AGREE/DISAGREE | PASS/FAIL | | -| SOLID-S | AGREE/DISAGREE | PASS/FAIL | | -| SOLID-O | AGREE/DISAGREE | PASS/FAIL | | -| SOLID-L | AGREE/DISAGREE | PASS/FAIL | | -| SOLID-I | AGREE/DISAGREE | PASS/FAIL | | -| SOLID-D | AGREE/DISAGREE | PASS/FAIL | | -| OC-1 | AGREE/DISAGREE | PASS/FAIL | | -| OC-2 | AGREE/DISAGREE | PASS/FAIL | | -| OC-3 | AGREE/DISAGREE | PASS/FAIL | | -| OC-4 | AGREE/DISAGREE | PASS/FAIL | | -| OC-5 | AGREE/DISAGREE | PASS/FAIL | | -| OC-6 | AGREE/DISAGREE | PASS/FAIL | | -| OC-7 | AGREE/DISAGREE | PASS/FAIL | | -| OC-8 | AGREE/DISAGREE | PASS/FAIL | | -| OC-9 | AGREE/DISAGREE | PASS/FAIL | | -| Patterns Creational | AGREE/DISAGREE | PASS/FAIL | | -| Patterns Structural | AGREE/DISAGREE | PASS/FAIL | | -| Patterns Behavioral | AGREE/DISAGREE | PASS/FAIL | | -| Semantic | AGREE/DISAGREE | PASS/FAIL | | +| # | Claim | SE Claims | Reviewer Verdict | Evidence | +|---|-------|-----------|------------------|----------| +| 1 | YAGNI: no code without a failing test | AGREE/DISAGREE | PASS/FAIL | | +| 2 | YAGNI: no speculative abstractions | AGREE/DISAGREE | PASS/FAIL | | +| 3 | KISS: simplest solution that passes | AGREE/DISAGREE | PASS/FAIL | | +| 4 | KISS: no premature optimization | AGREE/DISAGREE | PASS/FAIL | | +| 5 | DRY: no duplication | AGREE/DISAGREE | PASS/FAIL | | +| 6 | DRY: no redundant comments | AGREE/DISAGREE | PASS/FAIL | | +| 7 | SOLID-S: one reason to change per class | AGREE/DISAGREE | PASS/FAIL | | +| 8 | SOLID-O: open for extension, closed for modification | AGREE/DISAGREE | PASS/FAIL | | +| 9 | SOLID-L: subtypes substitutable | AGREE/DISAGREE | PASS/FAIL | | +| 10 | SOLID-I: no forced unused deps | AGREE/DISAGREE | PASS/FAIL | | +| 11 | SOLID-D: depend on abstractions, not concretions | AGREE/DISAGREE | PASS/FAIL | | +| 12 | OC-1: one level of indentation per method | AGREE/DISAGREE | PASS/FAIL | | +| 13 | OC-2: no else after return | AGREE/DISAGREE | PASS/FAIL | | +| 14 | OC-3: primitive types wrapped | AGREE/DISAGREE | PASS/FAIL | | +| 15 | OC-4: first-class collections | AGREE/DISAGREE | PASS/FAIL | | +| 16 | OC-5: one dot per line | AGREE/DISAGREE | PASS/FAIL | | +| 17 | OC-6: no abbreviations | AGREE/DISAGREE | PASS/FAIL | | +| 18 | OC-7: ≤20 lines per function, ≤50 per class | AGREE/DISAGREE | PASS/FAIL | | +| 19 | OC-8: ≤2 instance variables (behavioural classes only) | AGREE/DISAGREE | PASS/FAIL | | +| 20 | OC-9: no getters/setters | AGREE/DISAGREE | PASS/FAIL | | +| 21 | Patterns: no good reason remains to refactor using OOP or Design Patterns | AGREE/DISAGREE | PASS/FAIL | | +| 22 | Patterns: no creational smell | AGREE/DISAGREE | PASS/FAIL | | +| 23 | Patterns: no structural smell | AGREE/DISAGREE | PASS/FAIL | | +| 24 | Patterns: no behavioral smell | AGREE/DISAGREE | PASS/FAIL | | +| 25 | Semantic: tests operate at same abstraction as AC | AGREE/DISAGREE | PASS/FAIL | | ### Reviewer Stance Declaration -[Write this block before the Decision — see template below] +Write this block **before** the Decision. Every `DISAGREE` must include an inline explanation. A `DISAGREE` with no explanation auto-forces `REJECTED`. + +```markdown +## Reviewer Stance Declaration +As a reviewer I declare: +* Adversarial: I actively tried to find a failure mode, not just confirm passing — AGREE/DISAGREE | note: +* Manual trace: I traced at least one execution path manually beyond automated output — AGREE/DISAGREE | path: +* Boundary check: I checked the boundary conditions and edge cases of every Rule — AGREE/DISAGREE | gaps: +* Semantic read: I read each test against its AC and confirmed it tests the right observable behavior — AGREE/DISAGREE | mismatches: +* Independence: my verdict was not influenced by how much effort has already been spent — AGREE/DISAGREE +``` ### Decision **APPROVED** — all gates passed, no undeclared violations @@ -211,21 +235,7 @@ OR ### Next Steps **If APPROVED**: Run `@product-owner` — accept the feature at Step 5. - **If REJECTED**: Run `@software-engineer` — apply the fixes listed above, re-run quality gate, update Self-Declaration, then signal Step 4 again. ``` -### Reviewer Stance Declaration Template - -Write this block **before** the Decision. Every `DISAGREE` must include an inline explanation. A `DISAGREE` with no explanation auto-forces `REJECTED`. - -```markdown -## Reviewer Stance Declaration -As a reviewer I declare: -* Adversarial: I actively tried to find a failure mode, not just confirm passing — AGREE/DISAGREE | note: -* Manual trace: I traced at least one execution path manually beyond automated output — AGREE/DISAGREE | path: -* Boundary check: I checked the boundary conditions and edge cases of every Rule — AGREE/DISAGREE | gaps: -* Semantic read: I read each test against its AC and confirmed it tests the right observable behavior — AGREE/DISAGREE | mismatches: -* Independence: my verdict was not influenced by how much effort has already been spent — AGREE/DISAGREE -``` diff --git a/.pyproject.toml.swp b/.pyproject.toml.swp new file mode 100644 index 0000000..d8fab30 Binary files /dev/null and b/.pyproject.toml.swp differ diff --git a/CHANGELOG.md b/CHANGELOG.md index fd5de84..a0d79d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to pytest-beehave will be documented in this file. +## [v3.2.20260419] — Mason Osmia — 2026-04-19 + +### Added +- feat(stub-format-config): add `stub_format` config key under `[tool.beehave]` — controls output format of generated test stubs for Rule-block features +- feat(stub-format-config): support two formats: `"functions"` (default, top-level functions) and `"classes"` (class-wrapped methods for backward compatibility) + +### Fixed +- fix(stub-format-config): add `self` parameter to class-method stubs when `stub_format = "classes"` — pytest requires `self` in class methods + +### Changed +- ci(release): add auto-tag workflow (`.github/workflows/tag-release.yml`) — creates `v{version}` tag on merge when `pyproject.toml` version bumps +- chore(skills): simplify test naming convention in `implementation` skill — uses `_<@id>` directly without `Rule:` block routing documentation + +## [v3.1.20260419] — Generative Augochlora — 2026-04-19 + +### Added +- feat(example-hatch): add `--beehave-hatch` flag to generate bee-themed example `.feature` files and demonstrate the plugin in one command + +### Fixed +- fix(example-hatch): wrap `run_hatch()` call in `pytest_configure` with `try/except SystemExit` to produce clean error exit instead of `INTERNALERROR` crash + +### Changed +- docs: add "See it in 2 minutes" demo section to README showing `--beehave-hatch` output and generated stubs +- chore: add `test-coverage` task to `pyproject.toml` for explicit coverage-only runs +- chore(skills): number SE Self-Declaration items 1–25 and add completeness check to reviewer skill + ## [v3.0.20260419] — Foundational Apis — 2026-04-19 ### Added diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index f7b4c30..0000000 --- a/Dockerfile +++ /dev/null @@ -1,51 +0,0 @@ -# syntax=docker/dockerfile:1.7 -# Simplified Dockerfile for pytest-beehave -# Single-stage development-focused build - -ARG PYTHON_VERSION=3.13.1 - -FROM python:${PYTHON_VERSION}-slim AS base - -# Install uv for fast Python package management -RUN pip install --upgrade pip uv - -# Create non-root user -RUN groupadd --gid 1001 appuser && \ - useradd --uid 1001 --gid appuser --shell /bin/bash --create-home appuser - -WORKDIR /app - -# Copy dependency files first (better layer caching) -COPY pyproject.toml uv.lock* ./ - -# Install dependencies -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --dev - -# Copy source code -COPY . . - -# Change ownership to appuser -RUN chown -R appuser:appuser /app -USER appuser - -# Configure Python -ENV PYTHONPATH=/app -ENV PYTHONUNBUFFERED=1 -ENV PYTHONDONTWRITEBYTECODE=1 - -# Expose common ports -EXPOSE 8000 8080 5678 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD python -m pytest_beehave || exit 1 - -# Default command -CMD ["python", "-m", "pytest_beehave"] - -# Labels -LABEL maintainer="eol" -LABEL version="3.0.20260414" -LABEL description="A pytest plugin that runs acceptance criteria stub generation as part of the pytest lifecycle, with auto-ID assignment and generic step docstrings" -LABEL org.opencontainers.image.source="https://github.com/nullhack/pytest-beehave" \ No newline at end of file diff --git a/README.md b/README.md index ea56eef..fb0c853 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ All of this happens in `pytest_configure` — before pytest collects a single te --- +## Why pytest-beehave? + +BDD frameworks sold a compelling promise: human-readable specifications that live alongside your tests, kept honest by the test suite itself. The promise is real. The implementation is the problem. Every scenario explodes into a constellation of `@given`, `@when`, and `@then` step functions scattered across multiple files, wired together by fragile string matching. Refactor one step and you're hunting across the codebase. Add a new scenario and you're registering glue code. The ceremony grows with every feature, and the spec drifts from reality anyway — silently in unused step definitions, loudly in broken ones, always painfully. Plain pytest, on the other hand, is refreshingly direct. But there's no business-readable layer: acceptance criteria live in tickets or comments, never in code, and nothing machine-enforces that what the stakeholder approved is what the test exercises. + +pytest-beehave is the middle ground. Write your acceptance criteria in plain Gherkin — business-readable, version-controlled, owned by the team. The plugin does the worker-bee work: generating test stubs, keeping docstrings in sync with your steps, assigning stable IDs, and flagging drift before it silently rots. You implement the test body however you like, in plain pytest, with no step files and no glue. The hive stays in order automatically — that tedious, thankless, essential synchronisation work is handled so you never have to think about it again. + +--- + ## Installation ```bash @@ -92,6 +100,117 @@ The stub is already in the right place with the right name. Fill in the body and --- +## See it in 2 minutes + +No feature files yet? Generate a working example project in one command: + +``` +$ pytest --beehave-hatch + +[beehave] HATCH backlog/forager-journey.feature +[beehave] HATCH in-progress/waggle-dance.feature +[beehave] HATCH completed/winter-preparation.feature +[beehave] hatch complete +``` + +Three bee-themed `.feature` files land under `docs/features/`, covering every Gherkin construct the plugin supports: `Background`, `Rule`, `Example`, `Scenario Outline` with an `Examples` table, data tables, untagged scenarios (to trigger auto-ID), and `@deprecated`. + +The `in-progress/waggle-dance.feature` file looks like this: + +```gherkin +# language: en +Feature: Waggle Dance Communication + + Background: + Given the hive is in active foraging mode + And the dance floor is clear of obstacles + + Rule: Direction encoding + + @id:hatch003 + Example: Scout encodes flower direction in waggle run angle + Given a scout has located flowers 200 metres to the north-east + When the scout performs the waggle dance + Then the waggle run angle matches the sun-relative bearing to the flowers + + Rule: Distance encoding + + @id:hatch004 + Scenario Outline: Scout encodes distance via waggle run duration + Given a scout has located flowers at metres + When the scout performs the waggle dance + Then the waggle run lasts approximately milliseconds + + Examples: + | distance | duration | + | 100 | 250 | + | 500 | 875 | + | 1000 | 1500 | + + @id:hatch005 + Example: Scout provides a data table of visited flower patches + Given the scout returns from a multi-patch forage + When the scout performs the waggle dance + Then the flower patch register contains the following entries: + | patch_id | species | quality | + | P-001 | Lavender | 0.92 | + | P-002 | Clover | 0.85 | + | P-003 | Sunflower | 0.78 | +``` + +Now run pytest: + +``` +$ pytest + +[beehave] CREATE tests/features/forager_journey/forager_readiness_test.py +[beehave] CREATE tests/features/forager_journey/nectar_quality_control_test.py +[beehave] CREATE tests/features/waggle_dance/direction_encoding_test.py +[beehave] CREATE tests/features/waggle_dance/distance_encoding_test.py +``` + +The untagged `Example:` in `forager-journey.feature` got an `@id` written back in-place. Every stub is already in the right file with the right name: + +```python +# tests/features/waggle_dance/distance_encoding_test.py + +import pytest + + +class TestDistanceEncoding: + @pytest.mark.skip(reason="not yet implemented") + def test_waggle_dance_hatch004() -> None: + """ + Background: + Given: the hive is in active foraging mode + And: the dance floor is clear of obstacles + Given: a scout has located flowers at metres + When: the scout performs the waggle dance + Then: the waggle run lasts approximately milliseconds + """ + raise NotImplementedError + + @pytest.mark.skip(reason="not yet implemented") + def test_waggle_dance_hatch005() -> None: + """ + Background: + Given: the hive is in active foraging mode + And: the dance floor is clear of obstacles + Given: the scout returns from a multi-patch forage + When: the scout performs the waggle dance + Then: the flower patch register contains the following entries: + | patch_id | species | quality | + | P-001 | Lavender | 0.92 | + | P-002 | Clover | 0.85 | + | P-003 | Sunflower | 0.78 | + """ + raise NotImplementedError +``` + +Remove the `skip`, implement the test body, run `pytest` again. The hive stays in sync from here on automatically. + +--- + ## How it works pytest-beehave hooks into `pytest_configure`, the earliest possible entry point. Every stub exists on disk before pytest begins collection. diff --git a/TODO.md b/TODO.md index f0bff67..58e320c 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,22 @@ # Current Work -No feature in progress. -Next: PO picks feature from docs/features/backlog/ and moves it to docs/features/in-progress/. +Feature: stub-format-config +Step: 5 (Accept) +Source: docs/features/completed/stub-format-config.feature + +## Progress +- [x] Stage 1 Discovery: stub-format-config scoped and baselined +- [x] Stage 2A Stories: 5 Rule blocks written and INVEST-gated +- [x] Stage 2B Criteria: 7 Examples written with @id tags +- [x] `@id:a1b2c3d4`: Stub is a top-level function when stub_format is absent +- [x] `@id:b2c3d4e5`: Absent stub_format does not raise an error +- [x] `@id:f1e2d3c4`: Stub is a top-level function when stub_format = "functions" +- [x] `@id:a2b3c4d5`: Stub is a class method when stub_format = "classes" +- [x] `@id:b3c4d5e6`: Class name is derived from the Rule title slug +- [x] `@id:f6a7b8c9`: Pytest fails at startup when stub_format has an unrecognised value +- [x] `@id:a7b8c9d0`: No-Rule feature produces module-level functions regardless of stub_format = "classes" +- [x] Step 4 Verify: APPROVED — all 7 @id tests pass, 100% coverage, 0 lint/type errors +- [x] Step 5 Accept: ACCEPTED — 149 passed, 4 skipped, clean run, feature moved to completed/ + +## Next +Run @product-owner — load skill feature-selection and pick the next BASELINED feature from backlog diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 42ec3ce..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,86 +0,0 @@ -# Docker Compose for pytest-beehave -# Simplified development setup - -services: - # ============================================================================= - # Main application - # ============================================================================= - app: - build: - context: . - dockerfile: Dockerfile - container_name: python-template-app - volumes: - # Hot reload: mount source code - - ./pytest_beehave:/app/pytest_beehave - - ./tests:/app/tests - - ./pyproject.toml:/app/pyproject.toml:ro - ports: - - "8000:8000" # Main application - - "8080:8080" # Documentation server - - "5678:5678" # Debug port - environment: - - PYTHONPATH=/app - - PYTHONUNBUFFERED=1 - - DEVELOPMENT=true - command: python -m pytest_beehave - restart: unless-stopped - - # ============================================================================= - # Test runner - # ============================================================================= - test: - build: - context: . - dockerfile: Dockerfile - container_name: python-template-test - volumes: - - ./:/app:ro - environment: - - PYTHONPATH=/app - - PYTHONUNBUFFERED=1 - command: task test - profiles: - - test - - # ============================================================================= - # Documentation server - # ============================================================================= - docs: - build: - context: . - dockerfile: Dockerfile - container_name: python-template-docs - volumes: - - ./pytest_beehave:/app/pytest_beehave:ro - - ./pyproject.toml:/app/pyproject.toml:ro - ports: - - "8080:8080" - environment: - - PYTHONPATH=/app - command: task doc-serve - profiles: - - docs - - # ============================================================================= - # Code quality checks - # ============================================================================= - quality: - build: - context: . - dockerfile: Dockerfile - container_name: python-template-quality - volumes: - - ./:/app:ro - environment: - - PYTHONPATH=/app - command: bash -c "task lint && task static-check" - profiles: - - quality - -# ============================================================================= -# Networks -# ============================================================================= -networks: - default: - name: python-template-network \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index 0a255a0..68540d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -209,9 +209,44 @@ Feature: features-dir-bootstrap --- +## 2026-04-19 — example-hatch: single hatch.py module owns all generation logic + +Decision: All hatch content generation and writing lives in `pytest_beehave/hatch.py`; plugin.py only wires options and dispatches. +Reason: Keeps the hatch feature self-contained and independently testable without a live pytest session. +Alternatives considered: Inlining into plugin.py — rejected because it mixes lifecycle concerns with content generation. +Feature: example-hatch + +--- + +## 2026-04-19 — example-hatch: early-exit in pytest_configure via pytest.exit() + +Decision: When `--beehave-hatch` is detected, `pytest_configure` calls `run_hatch()`, prints the summary, then calls `pytest.exit(returncode=0)` before any stub-sync or test collection. +Reason: Matches AC constraint "Must exit pytest immediately after hatch completes (no test collection)". +Alternatives considered: Using a custom `pytest_collection_modifyitems` hook to abort collection — rejected because `pytest_configure` is earlier and cleaner. +Feature: example-hatch + +--- + +## 2026-04-19 — example-hatch: HatchFile dataclass carries relative_path + content + +Decision: `HatchFile(relative_path: str, content: str)` is a frozen dataclass; `generate_hatch_files()` returns `list[HatchFile]`; `write_hatch()` writes them to disk. +Reason: Separates content generation (pure, testable) from filesystem writes (side effects). +Alternatives considered: Passing raw dicts — rejected because typed dataclasses catch mistakes at static analysis time. +Feature: example-hatch + +--- + ## 2026-04-19 — stub-creation, stub-updates: drop libcst in favour of direct string manipulation Decision: `stub_writer` and `stub_reader` use direct string parsing/formatting rather than `libcst`. Reason: The implementation does not need to round-trip arbitrary Python source with full formatting preservation. Direct string manipulation is simpler, has zero additional dependencies, and is sufficient for the structured output format (top-level functions with a docstring and `raise NotImplementedError`). `libcst` was never added as a runtime dependency. Supersedes: "2026-04-18 — stub-creation: test file writing library" and "2026-04-18 — stub-updates: stub_reader uses libcst" Feature: stub-creation, stub-updates + +--- + +## 2026-04-19 — stub-format-config: StubFormat threaded through StubSpec + +Decision: add `stub_format: StubFormat` field to `StubSpec` (stub_writer.py) and thread it from `run_sync` down to `write_stub_to_file`. +Reason: keeps the format decision co-located with the stub spec rather than using a global or module-level state. +Alternatives considered: global config object passed via module — rejected (hidden coupling); separate `write_top_level_stub_to_file` / `write_class_stub_to_file` public functions — rejected (duplicate routing logic at call sites). diff --git a/docs/c4/container.md b/docs/c4/container.md index 912413f..68ecf21 100644 --- a/docs/c4/container.md +++ b/docs/c4/container.md @@ -1,5 +1,8 @@ # C4 Level 2 — Container: pytest-beehave +> Last updated: 2026-04-19 +> Source: docs/architecture.md + pytest-beehave is a single Python package installed as a pytest plugin. The "containers" here are the major modules with distinct responsibilities inside the package. ```mermaid @@ -10,7 +13,7 @@ C4Container Person(ci, "CI Pipeline", "Runs pytest in a read-only environment") System_Boundary(beehave, "pytest-beehave package") { - Container(plugin, "plugin.py", "Python module", "pytest_configure entry point; orchestrates all plugin behaviour; injects Protocol adapters for filesystem and terminal writer") + Container(plugin, "plugin.py", "Python module", "pytest_configure entry point; orchestrates all plugin behaviour; injects Protocol adapters for filesystem and terminal writer; dispatches --beehave-hatch flag") Container(config, "config.py", "Python module", "Reads [tool.beehave] from pyproject.toml via stdlib tomllib; returns a frozen BeehaveConfig dataclass with the resolved features_path") Container(bootstrap, "bootstrap.py", "Python module", "Ensures the three canonical subfolders (backlog/, in-progress/, completed/) exist; migrates root-level .feature files to backlog/ before stub sync") Container(feature_parser, "feature_parser.py", "Python module", "Delegates to gherkin-official to parse .feature files into ParsedFeature/ParsedExample domain objects; resolves @deprecated tag inheritance at parse time") @@ -21,7 +24,8 @@ C4Container Container(reporter, "reporter.py", "Python module", "Formats and emits terminal output for bootstrap results and stub sync events via the PytestTerminalWriter Protocol adapter") Container(steps_reporter, "steps_reporter.py", "Python module", "Prints verbatim docstring steps below the test path at -v or above; scoped to tests under tests/features/ only") Container(html_steps_plugin, "html_steps_plugin.py", "Python module", "Adds an Acceptance Criteria column to the pytest-html report; registers only when pytest-html is installed; injects docstring content per feature test") - Container(models, "models.py", "Python module", "Shared domain types: ParsedFeature, ParsedExample, FeatureStage, ExampleId, BootstrapResult, and Protocol definitions for FileSystem and TerminalWriter") + Container(hatch, "hatch.py", "Python module", "Generates bee-themed example .feature files under the configured features path; uses stdlib secrets.choice() only; supports --beehave-hatch-force for overwrite; exits pytest immediately after generation") + Container(models, "models.py", "Python module", "Shared domain types: ParsedFeature, ParsedExample, FeatureStage, ExampleId, BootstrapResult, HatchFile, and Protocol definitions for FileSystem and TerminalWriter") } System_Ext(pytest, "pytest", "Host test framework providing the plugin hook lifecycle") @@ -30,6 +34,7 @@ C4Container System_Ext(fs, "Filesystem", "docs/features/ tree (feature files) and tests/features/ tree (test stubs)") Rel(developer, fs, "Writes .feature files to; reads generated test stubs from") + Rel(developer, pytest, "Runs with --beehave-hatch to seed example features") Rel(ci, pytest, "Runs pytest in read-only environment") Rel(pytest, plugin, "Calls pytest_configure hook on startup") Rel(plugin, config, "Reads features_path from pyproject.toml via") @@ -37,6 +42,7 @@ C4Container Rel(plugin, sync_engine, "Triggers full stub sync via") Rel(plugin, steps_reporter, "Registers terminal steps hook via") Rel(plugin, html_steps_plugin, "Registers HTML column hook via (if pytest-html present)") + Rel(plugin, hatch, "Dispatches --beehave-hatch flag to; exits after generation") Rel(bootstrap, fs, "Creates missing subfolders; migrates root-level .feature files in") Rel(sync_engine, feature_parser, "Parses .feature files via") Rel(sync_engine, stub_reader, "Reads existing test stubs via") @@ -47,6 +53,7 @@ C4Container Rel(stub_writer, fs, "Writes test stub files to tests/features/") Rel(stub_reader, fs, "Reads test stub files from tests/features/") Rel(id_generator, fs, "Writes @id tags back to docs/features/") + Rel(hatch, fs, "Writes generated bee-themed .feature files to docs/features/") Rel(steps_reporter, pytest, "Hooks into pytest_runtest_logreport") Rel(html_steps_plugin, pytest_html, "Hooks into pytest-html result row extra API") ``` @@ -57,3 +64,4 @@ C4Container - `models.py` defines the Protocol interfaces (`FileSystemProtocol`, `TerminalWriterProtocol`) used for dependency injection in tests. - `plugin.py` is the only module that imports pytest internals directly; all others work on domain objects or Protocol abstractions. - `stub_writer` and `stub_reader` use direct string manipulation (not a CST library) — sufficient for the structured stub format and carries zero additional runtime dependencies. +- `hatch.py` is self-contained: all content generation and writing lives there; `plugin.py` only wires the CLI options and dispatches. Uses `stdlib secrets.choice()` only — no external dependencies. diff --git a/docs/c4/context.md b/docs/c4/context.md index f697f97..860c082 100644 --- a/docs/c4/context.md +++ b/docs/c4/context.md @@ -1,5 +1,8 @@ # C4 Level 1 — System Context: pytest-beehave +> Last updated: 2026-04-19 (v3.2 — stub-format-config) +> Source: docs/discovery.md, docs/features/completed/ + ```mermaid C4Context title System Context — pytest-beehave @@ -7,24 +10,27 @@ C4Context Person(developer, "Developer", "Python developer using the Beehave workflow to write BDD-style acceptance tests") Person(ci, "CI Pipeline", "Automated environment (GitHub Actions, etc.) that runs pytest on every push") - System(beehave, "pytest-beehave", "pytest plugin that syncs test stubs from Gherkin feature files before collection, assigns IDs, manages markers, and surfaces acceptance criteria in reports") + System(beehave, "pytest-beehave", "pytest plugin that syncs test stubs from Gherkin feature files before collection, assigns IDs, manages markers, surfaces acceptance criteria in reports, and generates bee-themed example feature files on demand") System_Ext(pytest, "pytest", "Python test framework that discovers, runs, and reports on tests; provides the plugin hook lifecycle") System_Ext(gherkin, "gherkin-official", "Gherkin parser that reads .feature files and produces an AST; supports 70+ human languages") System_Ext(pytest_html, "pytest-html", "Optional pytest plugin that generates an HTML test report; beehave adds an Acceptance Criteria column when installed") + System_Ext(fs, "Filesystem", "docs/features/ tree (feature files) and tests/features/ tree (test stubs)") Rel(developer, pytest, "Runs", "uv run pytest / uv run task test") + Rel(developer, pytest, "Runs with --beehave-hatch to seed example features", "pytest --beehave-hatch") Rel(ci, pytest, "Runs", "uv run pytest (read-only environment)") Rel(pytest, beehave, "Loads via pytest11 entry point", "pytest_configure hook") Rel(beehave, gherkin, "Parses .feature files via", "gherkin-official Python API") Rel(beehave, pytest_html, "Injects Acceptance Criteria column into", "pytest-html report hooks (optional)") - Rel(beehave, developer, "Writes test stubs and @id tags to", "tests/features/ and docs/features/") + Rel(beehave, fs, "Writes test stubs, @id tags, and hatch example files to") Rel(beehave, ci, "Fails run with descriptive error when @id tags are missing in", "read-only CI environment") ``` ## Notes -- **Developer** interacts with beehave indirectly: they write `.feature` files and run pytest; beehave auto-generates and maintains test stubs. +- **Developer** interacts with beehave indirectly: they write `.feature` files and run pytest; beehave auto-generates and maintains test stubs. They can also run `pytest --beehave-hatch` to generate a bee-themed example `docs/features/` tree showcasing all plugin capabilities. - **CI Pipeline** is a read-only environment: beehave detects this and fails fast (with a clear error) instead of writing `@id` tags back. - **gherkin-official** handles all language parsing, including non-English feature files (`# language: es`, `# language: zh-CN`, etc.). beehave delegates fully. - **pytest-html** is an optional install extra (`pip install pytest-beehave[html]`); its absence is silently ignored. +- **`--beehave-hatch`** exits pytest immediately after generating example files — no test collection occurs. Use `--beehave-hatch-force` to overwrite existing content. diff --git a/docs/discovery.md b/docs/discovery.md index 8eee662..e20a354 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -191,3 +191,43 @@ Feature stages and marker state do not affect rendering — steps are shown rega | Verb | suppress steps | omit output when channel is disabled or test is out of scope | Yes | Template §3: CONFIRMED — stakeholder approved 2026-04-18 + +--- + +## Session: 2026-04-19 — Feature: example-hatch + +### Feature List +- `example-hatch` — generate a bee-themed `docs/features/` directory tree showcasing all plugin capabilities via `pytest --beehave-hatch`; stdlib-only randomisation; respects configured features path; fails loudly on existing content unless `--beehave-hatch-force` is passed + +### Domain Model +| Type | Name | Description | In Scope | +|------|------|-------------|----------| +| Noun | hatch | generated `docs/features/` directory tree with example `.feature` files | Yes | +| Noun | `--beehave-hatch` flag | pytest CLI flag that triggers hatch generation | Yes | +| Noun | `--beehave-hatch-force` flag | pytest CLI flag that allows overwriting existing hatch content | Yes | +| Noun | bee/hive-themed content | Feature names, Rule titles, Example titles, and step text using bee/hive metaphors | Yes | +| Noun | capability showcase | set of generated `.feature` files that together exercise every plugin capability | Yes | +| Noun | stdlib randomisation | use of `random` / `secrets` from Python stdlib to vary generated content | Yes | +| Verb | hatch | write the example features directory tree to the configured path | Yes | +| Verb | overwrite-protect | fail loudly when target directory already contains `.feature` files | Yes | +| Verb | force-overwrite | replace existing hatch content when `--beehave-hatch-force` is passed | Yes | + +--- + +## Session: 2026-04-19 — Feature: stub-format-config + +### Feature List +- `stub-format-config` — new `stub_format` key under `[tool.beehave]`; `"functions"` (default, top-level functions) or `"classes"` (class-wrapped methods); hard error on invalid value; no-Rule features unaffected; project-wide setting; does not reformat existing stubs + +### Domain Model +| Type | Name | Description | In Scope | +|------|------|-------------|----------| +| Noun | `stub_format` | config key under `[tool.beehave]` controlling stub output format | Yes | +| Noun | `"functions"` format | top-level functions in `_test.py`, no class wrapper (default) | Yes | +| Noun | `"classes"` format | methods inside `class Test` in `_test.py` | Yes | +| Noun | invalid format value | any `stub_format` value other than `"functions"` or `"classes"` | Yes | +| Verb | read stub_format | parse `stub_format` from `[tool.beehave]` at pytest startup | Yes | +| Verb | default to functions | use `"functions"` when `stub_format` key is absent | Yes | +| Verb | fail on invalid | abort pytest startup with descriptive error when value is unrecognised | Yes | +| Verb | generate function stub | write top-level `def test__<@id>()` with no class wrapper | Yes | +| Verb | generate class stub | write method inside `class Test:` | Yes | diff --git a/docs/discovery_journal.md b/docs/discovery_journal.md index 4cabc41..702d4de 100644 --- a/docs/discovery_journal.md +++ b/docs/discovery_journal.md @@ -117,3 +117,43 @@ Status: COMPLETE |----|----------|--------|--------| | Q10 | Are nested non-canonical subdirectories in the root features folder left alone? | Yes — only the three canonical subfolders are managed; any other subdirectory is ignored | ANSWERED | | Q11 | Is the bootstrap idempotent — safe to run multiple times? | Yes — creating an already-existing subfolder is a no-op; migration only moves files not already in a subfolder | ANSWERED | + +--- + +## 2026-04-19 — Session 3 +Status: IN-PROGRESS + +### Feature: example-hatch + +| ID | Question | Answer | Status | +|----|----------|--------|--------| +| Q1 | What interface is used to invoke the hatch? | `pytest --beehave-hatch` (bee-related wordplay — bees hatch from cells, generating a new colony of examples) — a pytest CLI flag | ANSWERED | +| Q2 | What content should be generated? | `docs/features/` (or the configured path) with pre-defined Gherkin showcasing ALL plugin capabilities; Feature names, scenarios, and step text should use bee/hive metaphors | ANSWERED | +| Q3 | Should randomization use external libraries (e.g. Hypothesis)? | No external dependencies — use Python stdlib only (e.g. `random`, `uuid`) to vary generated content so it is not boring | ANSWERED | +| Q4 | What does success look like? | Run the flag → `docs/features/` is generated → run `pytest` → stubs are properly generated with no errors | ANSWERED | +| Q5 | Where does the generated folder land? | Respects the configured features path (`features_path` in `[tool.beehave]`); defaults to `docs/features/` | ANSWERED | +| Q6 | What happens if the target features directory already contains content? | Fail loudly with a descriptive error; provide a `--beehave-hatch-force` flag to overwrite (PO-resolved by convention — consistent with project hard-error philosophy) | RESOLVED-BY-PO | + +Status: COMPLETE + +--- + +## 2026-04-19 — Feature: stub-format-config — Session 1 +Status: IN-PROGRESS + +### Feature: stub-format-config + +| ID | Question | Answer | Status | +|----|----------|--------|--------| +| Q1 | What config key name and section? | `stub_format` under `[tool.beehave]` in `pyproject.toml` | ANSWERED | +| Q2 | What are the valid values? | `"functions"` (default, top-level functions, no class wrapper) and `"classes"` (class Test wrapping) — case-sensitive | ANSWERED | +| Q3 | What is the default when the key is absent? | `"functions"` — existing projects that never set this key get top-level functions | ANSWERED | +| Q4 | Does this affect features with no Rule blocks? | No — no-Rule features always produce module-level functions in `examples_test.py` regardless of `stub_format` | ANSWERED | +| Q5 | What happens with an invalid value (e.g. `stub_format = "methods"`)? | Hard error at pytest startup with a descriptive message — consistent with the project's hard-error philosophy | ANSWERED | +| Q6 | Does changing `stub_format` reformat existing stubs? | No — only new stubs are affected; existing stubs are not touched | ANSWERED | +| Q7 | Is the setting per-feature or project-wide? | Project-wide — applies uniformly to all Rule-block features | ANSWERED | +| Q8 | What happens to existing projects using the plugin (no `stub_format` key)? | They get `"functions"` (the new default) — top-level functions, which is the desired behavior going forward | ANSWERED | +| Q9 | What happens if a project was relying on class-based output? | They set `stub_format = "classes"` in `[tool.beehave]` to restore the old behavior | ANSWERED | +| Q10 | Is the `"classes"` format identical to the old class-based stub output? | Yes — `class Test:` wrapper with methods inside, same as what `stub_writer.py` currently produces | ANSWERED | + +Status: COMPLETE diff --git a/docs/features/completed/example-hatch.feature b/docs/features/completed/example-hatch.feature new file mode 100644 index 0000000..30d4099 --- /dev/null +++ b/docs/features/completed/example-hatch.feature @@ -0,0 +1,147 @@ +Feature: Example hatch generation + + Generates a ready-to-use `docs/features/` directory tree (or the configured features path) + populated with bee/hive-themed Gherkin `.feature` files that exercise every plugin capability: + auto-ID generation, stub creation, stub updates, deprecation sync, multilingual parsing, and + the bootstrap flow. Content is partially randomised using Python stdlib only so each generated + example feels fresh. Invoked via `pytest --beehave-hatch`; fails loudly if the target directory + already contains content unless `--beehave-hatch-force` is also passed. + + Status: BASELINED (2026-04-19) + + Rules (Business): + - The hatch is triggered by the `--beehave-hatch` pytest flag + - The hatch writes to the configured features path (default `docs/features/`) + - If the target features directory already contains any `.feature` files, the command fails with a descriptive error unless `--beehave-hatch-force` is passed + - `--beehave-hatch-force` overwrites existing content without prompting + - Generated `.feature` files use bee/hive metaphors for Feature names, Rule titles, Example titles, and step text + - Generated content showcases all plugin capabilities: tagged and untagged Examples (auto-ID), deprecated Examples, multilingual file, backlog/in-progress/completed placement, Background blocks, Scenario Outlines, and data tables + - Randomisation uses Python stdlib only (`random`, `secrets`) — no external dependencies + - After the hatch runs, invoking `pytest` on the generated directory must produce stubs without errors + - The hatch emits a terminal summary of files written + + Constraints: + - Must not run stub sync or any other plugin operation during the hatch invocation — hatch only + - Must respect `features_path` from `[tool.beehave]` in `pyproject.toml` + - Must exit pytest immediately after hatch completes (no test collection) + + Rule: Hatch invocation + As a developer evaluating pytest-beehave + I want to generate a complete example features directory with one command + So that I can see all plugin capabilities working without writing any Gherkin myself + + @id:1a2b3c4d + Example: Hatch creates the features directory tree when it does not exist + Given no features directory exists at the configured path + When pytest is invoked with --beehave-hatch + Then the backlog, in-progress, and completed subfolders exist under the configured features path + + @id:2b3c4d5e + Example: Hatch writes bee-themed .feature files into the correct subfolders + Given no features directory exists at the configured path + When pytest is invoked with --beehave-hatch + Then at least one .feature file exists in each of the backlog, in-progress, and completed subfolders + + @id:3c4d5e6f + Example: Hatch emits a terminal summary of files written + Given no features directory exists at the configured path + When pytest is invoked with --beehave-hatch + Then the terminal output lists each .feature file that was created + + @id:4d5e6f7a + Example: pytest exits immediately after hatch without running tests + Given no features directory exists at the configured path + When pytest is invoked with --beehave-hatch + Then no tests are collected or executed + + Rule: Overwrite protection + As a developer with an existing features directory + I want the hatch to fail loudly rather than silently overwrite my work + So that I never lose existing feature files by accident + + @id:5e6f7a8b + Example: Hatch fails when the features directory already contains .feature files + Given the configured features directory already contains at least one .feature file + When pytest is invoked with --beehave-hatch + Then the pytest run exits with a non-zero status code and an error naming the conflicting path + + @id:6f7a8b9c + Example: Hatch overwrites existing content when --beehave-hatch-force is passed + Given the configured features directory already contains at least one .feature file + When pytest is invoked with --beehave-hatch --beehave-hatch-force + Then the existing .feature files are replaced with the newly generated hatch content + + Rule: Capability showcase content + As a developer evaluating pytest-beehave + I want the generated Gherkin to exercise every plugin capability + So that a single `pytest` run after hatching demonstrates the full feature set + + @id:7a8b9c0d + Example: Generated content includes an untagged Example to trigger auto-ID generation + Given no features directory exists at the configured path + When pytest is invoked with --beehave-hatch + Then at least one generated .feature file contains an Example with no @id tag + + @id:8b9c0d1e + Example: Generated content includes a @deprecated-tagged Example + Given no features directory exists at the configured path + When pytest is invoked with --beehave-hatch + Then at least one generated .feature file contains an Example tagged @deprecated + + @id:9c0d1e2f + Example: Generated content includes a multilingual feature file + Given no features directory exists at the configured path + When pytest is invoked with --beehave-hatch + Then at least one generated .feature file begins with a # language: directive + + @id:0d1e2f3a + Example: Generated content includes a feature with a Background block + Given no features directory exists at the configured path + When pytest is invoked with --beehave-hatch + Then at least one generated .feature file contains a Background: block + + @id:1e2f3a4b + Example: Generated content includes a Scenario Outline with an Examples table + Given no features directory exists at the configured path + When pytest is invoked with --beehave-hatch + Then at least one generated .feature file contains a Scenario Outline with an Examples: table + + @id:a1f2e3d4 + Example: Generated content includes a step with an attached data table + Given no features directory exists at the configured path + When pytest is invoked with --beehave-hatch + Then at least one generated .feature file contains a step followed by a data table + + @id:b2e3d4c5 + Example: Generated content includes a feature placed in the completed subfolder + Given no features directory exists at the configured path + When pytest is invoked with --beehave-hatch + Then at least one generated .feature file is placed in the completed subfolder + + Rule: Configured path respect + As a developer using a custom features path + I want the hatch to write to my configured path + So that the generated example integrates with my project layout + + @id:c3d4e5f6 + Example: Hatch writes to the custom path when features_path is configured + Given pyproject.toml contains [tool.beehave] with features_path set to a custom directory + When pytest is invoked with --beehave-hatch + Then the generated .feature files are written under the custom configured path and not under docs/features/ + + Rule: Stdlib-only randomisation + As a developer running the hatch multiple times + I want the generated content to vary slightly between runs + So that the example does not feel like a static copy-paste template + + @id:d4e5f6a7 + Example: Hatch produces a different Feature name on successive runs + Given no features directory exists at the configured path + When pytest is invoked with --beehave-hatch on two separate occasions with the directory removed between runs + Then the Feature name in the generated .feature file differs between the two runs + + @id:e5f6a7b8 + Example: Hatch completes without requiring any additional package installation + Given a clean environment with only pytest-beehave installed and no other packages + When pytest is invoked with --beehave-hatch + Then the hatch completes successfully and no import error or missing-module error is raised diff --git a/docs/features/completed/plugin-hook.feature b/docs/features/completed/plugin-hook.feature index 2fdd27d..bbc6bbb 100644 --- a/docs/features/completed/plugin-hook.feature +++ b/docs/features/completed/plugin-hook.feature @@ -39,3 +39,9 @@ Feature: pytest lifecycle integration Given no pyproject.toml [tool.beehave] section is present and the default docs/features/ directory does not exist When pytest is invoked Then pytest completes collection without errors + + @deprecated @id:e3a13b58 + Example: Plugin does not crash when configured features directory is absent + Given a project where the configured features directory does not exist + When pytest is invoked + Then pytest completes collection without errors diff --git a/docs/features/completed/stub-creation.feature b/docs/features/completed/stub-creation.feature index e4d551b..fdb3a66 100644 --- a/docs/features/completed/stub-creation.feature +++ b/docs/features/completed/stub-creation.feature @@ -53,7 +53,7 @@ Feature: Test stub creation When pytest is invoked Then the generated stub is a method inside class Test in _test.py - @bug + @deprecated @id:c3a8f291 Example: New stub for a Rule block is a top-level function (not a class method) Given a backlog feature file with a Rule block containing a new @id-tagged Example diff --git a/docs/features/completed/stub-format-config.feature b/docs/features/completed/stub-format-config.feature new file mode 100644 index 0000000..53afd91 --- /dev/null +++ b/docs/features/completed/stub-format-config.feature @@ -0,0 +1,89 @@ +Feature: Stub format configuration + + Controls the output format of generated test stubs via a `stub_format` key in + `[tool.beehave]` in `pyproject.toml`. Two formats are supported: `"functions"` + (default) generates top-level functions in `_test.py` with no class + wrapper; `"classes"` generates stubs as methods inside `class Test`. + When the key is absent, `"functions"` is used. Existing projects that relied on + the class-based output can opt back in by setting `stub_format = "classes"`. + + Status: BASELINED (2026-04-19) + + Rules (Business): + - `stub_format` lives under `[tool.beehave]` in `pyproject.toml` + - Valid values are exactly `"functions"` and `"classes"` (case-sensitive) + - Default when key is absent: `"functions"` + - Features with no `Rule:` blocks always produce module-level functions in `examples_test.py` regardless of `stub_format` + - The format setting applies to ALL Rule-block features in the project uniformly + + Constraints: + - Invalid `stub_format` values must produce a hard error at pytest startup (not silently ignored) + - Changing `stub_format` does not retroactively reformat existing stubs — only new stubs are affected + + Rule: Default format selection + As a developer + I want stub generation to default to top-level functions when no stub_format is configured + So that new projects and existing projects without explicit configuration get the preferred format automatically + + @id:a1b2c3d4 + Example: Stub is a top-level function when stub_format is absent + Given a pyproject.toml with no stub_format key under [tool.beehave] + When pytest generates a stub for a Rule-block Example + Then the stub is a top-level function def test__<@id> with no class wrapper + + @id:b2c3d4e5 + Example: Absent stub_format does not raise an error + Given a pyproject.toml with no stub_format key under [tool.beehave] + When pytest starts up + Then pytest starts without any stub_format-related error + + Rule: Explicit functions format + As a developer + I want to explicitly set stub_format = "functions" in pyproject.toml + So that I can document my format choice and ensure top-level function stubs are generated + + @id:f1e2d3c4 + Example: Stub is a top-level function when stub_format = "functions" + Given a pyproject.toml with stub_format = "functions" under [tool.beehave] + When pytest generates a stub for a Rule-block Example + Then the stub is a top-level function def test__<@id> with no class wrapper + + Rule: Classes format selection + As a developer + I want to set stub_format = "classes" in pyproject.toml + So that I can restore the class-wrapped stub output for projects that prefer that style + + @id:a2b3c4d5 + Example: Stub is a class method when stub_format = "classes" + Given a pyproject.toml with stub_format = "classes" under [tool.beehave] + When pytest generates a stub for a Rule-block Example + Then the stub is a method inside class Test in _test.py + + @id:b3c4d5e6 + Example: Class name is derived from the Rule title slug + Given a pyproject.toml with stub_format = "classes" and a Rule titled "Wall bounce" + When pytest generates a stub for an Example under that Rule + Then the stub is inside a class named TestWallBounce + + Rule: Invalid format rejection + As a developer + I want pytest to fail immediately with a clear error when stub_format has an unrecognised value + So that misconfiguration is caught at startup rather than silently producing wrong output + + @id:f6a7b8c9 + Example: Pytest fails at startup when stub_format has an unrecognised value + Given a pyproject.toml with stub_format = "methods" under [tool.beehave] + When pytest starts up + Then pytest exits with a non-zero status and an error message naming the invalid value + + Rule: No-Rule feature unaffected + As a developer + I want features with no Rule blocks to always produce module-level functions in examples_test.py + So that the stub_format setting does not change the behavior of no-Rule features + + @id:a7b8c9d0 + Example: No-Rule feature produces module-level functions regardless of stub_format = "classes" + Given a pyproject.toml with stub_format = "classes" under [tool.beehave] + And a feature file with no Rule blocks + When pytest generates stubs for that feature + Then the stubs are module-level functions in examples_test.py with no class wrapper diff --git a/docs/glossary.md b/docs/glossary.md index 975a157..0200fe6 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -20,11 +20,16 @@ Living glossary generated from the domain model in `docs/discovery.md` and archi | **`@deprecated` tag** | Gherkin tag placed directly on an `Example:`, `Rule:`, or `Feature:` node. When present, beehave applies `@pytest.mark.deprecated` to the corresponding test stub. Inheritance is resolved at parse time. | | **`@id` tag** | `@id:` tag on a Gherkin `Example:` block that uniquely identifies it. Format: `@id:<8-char-hex>`. beehave generates and writes these back if absent (or fails in CI). | | **`@pytest.mark.deprecated`** | pytest marker applied to a test stub when the corresponding Gherkin `Example:` carries a `@deprecated` tag (directly or via inheritance). Auto-skipped by conftest. | -| **`[tool.beehave]` section** | Configuration section in `pyproject.toml` where the developer can set `features_path`. | +| **`[tool.beehave]` section** | Configuration section in `pyproject.toml` where the developer can set `features_path` and other options. | +| **`stub_format`** | Configuration key under `[tool.beehave]` that controls output format of generated test stubs for Rule-block features. Values: `"functions"` (default, top-level functions) and `"classes"` (class-wrapped methods). First appeared: `stub-format-config`. | | **`# language: xx` comment** | Language directive at the top of a Gherkin `.feature` file. Instructs `gherkin-official` to parse using the specified dialect (e.g. `es`, `zh-CN`). beehave delegates this fully to `gherkin-official`. | +| **`--beehave-hatch` flag** | pytest CLI flag that triggers hatch generation. When passed, beehave writes bee-themed example `.feature` files to the configured features path and exits immediately — no test collection occurs. First appeared: `example-hatch`. | +| **`--beehave-hatch-force` flag** | pytest CLI flag that allows overwriting existing hatch content. When passed alongside `--beehave-hatch`, beehave replaces any existing `.feature` files in the hatch output directories. First appeared: `example-hatch`. | | **backlog stage** | The `docs/features/backlog/` directory. Features here receive full stub creation and updates on every pytest run. | | **`BeehaveConfig`** | Frozen dataclass returned by `config.read_config()`; carries the resolved `features_path: Path`. | +| **bee/hive-themed content** | Feature names, Rule titles, Example titles, and step text using bee/hive metaphors. Used in hatch-generated `.feature` files to demonstrate plugin capabilities. First appeared: `example-hatch`. | | **`BootstrapResult`** | Value object returned by `bootstrap_features_directory()`; carries lists of created directories, migrated files, and collision warnings; `is_noop` is `True` when no action was taken. | +| **capability showcase** | The set of generated `.feature` files produced by `--beehave-hatch` that together exercise every plugin capability (Background, Rule, Scenario Outline, data tables, untagged Examples, `@deprecated`). First appeared: `example-hatch`. | | **completed stage** | The `docs/features/completed/` directory. Features here receive only orphan detection and deprecation sync — no new stubs are created, no docstrings are updated. | | **conforming test** | A test function that satisfies both conformance rules: (1) lives in the correct file (`_test.py`) and (2) has the correct function name (`test__<@id>`). | | **default path** | `docs/features/` relative to the project root. Used when no `features_path` is set in `[tool.beehave]`. | @@ -35,6 +40,8 @@ Living glossary generated from the domain model in `docs/discovery.md` and archi | **feature test** | Any test residing under `tests/features/`. These tests have their docstring steps surfaced in terminal and HTML output. | | **features directory** | Root configured features path (e.g. `docs/features/`). beehave expects three canonical subfolders inside it. | | **Gherkin tag inheritance** | When a `@deprecated` tag is placed on a `Rule:` or `Feature:` node, all child `Example:` nodes inherit it. Resolved at parse time by `feature_parser.py`. | +| **hatch** | The generated `docs/features/` directory tree with bee-themed example `.feature` files. Written by `hatch.py` when `--beehave-hatch` is passed. First appeared: `example-hatch`. | +| **`HatchFile`** | Frozen dataclass carrying `relative_path: str` and `content: str`; returned by `generate_hatch_files()` and consumed by `write_hatch()`. Separates pure content generation from filesystem writes. First appeared: `example-hatch`. | | **hex ID** | See `ExampleId`. | | **HTML channel** | The "Acceptance Criteria" column in the pytest-html report, populated with each feature test's docstring. Active only when `pytest-html` is installed and `show_steps_in_html` is not `false`. | | **in-progress stage** | The `docs/features/in-progress/` directory. Features here receive full stub creation and updates on every pytest run. | @@ -47,6 +54,7 @@ Living glossary generated from the domain model in `docs/discovery.md` and archi | **pytest plugin** | The `BeehavePlugin` class registered via the `pytest11` entry point in `pyproject.toml`. Loaded automatically by pytest on every invocation. | | **pytest session** | The pytest lifecycle object; beehave's sync runs inside `pytest_configure` (before collection) so newly generated stubs are collected in the same run. | | **rule slug** | The `Rule:` title slugified to lowercase with underscores. Used as the test file name (`_test.py`) and as part of the test function name. | +| **stdlib randomisation** | Use of `secrets.choice()` from the Python standard library to vary generated hatch content. No external dependencies required. First appeared: `example-hatch`. | | **stub sync** | The full synchronisation operation: parse features, compare against existing stubs, create/rename/orphan/deprecate as needed. | | **terminal channel** | Verbatim docstring printed below the test path in terminal output at `-v` or above, for feature tests only. | | **test file** | `_test.py` (one per `Rule:` block) generated in `tests/features//`. | @@ -79,10 +87,13 @@ Living glossary generated from the domain model in `docs/discovery.md` and archi | **detect missing ID** | Scan `Example:` tags for a valid `@id:` tag; flag if absent. | | **fail run** | Abort pytest with a clear, descriptive error message (used in CI when `@id` tags are absent). | | **fall back to default** | Use `docs/features/` if no `features_path` is set in `[tool.beehave]`. | +| **force-overwrite** | Replace existing hatch content when `--beehave-hatch-force` is passed. First appeared: `example-hatch`. | | **generate ID** | Produce a unique 8-character lowercase hex string, unique within the current `.feature` file. | +| **hatch** | Write the example features directory tree to the configured path. Triggered by `--beehave-hatch`. First appeared: `example-hatch`. | | **mark non-conforming** | Apply `@pytest.mark.skip(reason="non-conforming: moved to ")` to a test function that is in the wrong file or has the wrong name. The conforming stub is always created first. | | **mark orphan** | Apply `@pytest.mark.skip(reason="orphan: ...")` to a test function with no matching `@id` in any `.feature` file. | | **migrate** | Move a root-level `.feature` file found directly in the features directory into `backlog/`. | +| **overwrite-protect** | Fail loudly when the target hatch directory already contains `.feature` files and `--beehave-hatch-force` was not passed. First appeared: `example-hatch`. | | **read config** | Parse `[tool.beehave]` from `pyproject.toml` using `stdlib tomllib`; return a `BeehaveConfig`. | | **register plugin** | The `pytest_configure` entry point that loads `BeehavePlugin` into the pytest session. | | **remove marker** | Remove `@pytest.mark.deprecated` from a test function when the `@deprecated` tag is no longer present. | @@ -91,3 +102,5 @@ Living glossary generated from the domain model in `docs/discovery.md` and archi | **run before collection** | Invoke stub sync inside `pytest_configure` so new stubs are discoverable in the same pytest run. | | **suppress steps** | Omit step output when the channel is disabled (`show_steps_in_terminal = false` / `show_steps_in_html = false`) or the test is outside `tests/features/`. | | **write back** | Insert a generated `@id:` tag into the `.feature` file in-place, on the line immediately before the `Example:` keyword. | +| **select format** | Choose between `"functions"` (top-level) and `"classes"` (class-wrapped) format based on `stub_format` config. First appeared: `stub-format-config`. | +| **wrap class** | Output a test function as a method inside `class Test:` when `stub_format = "classes"`. First appeared: `stub-format-config`. | diff --git a/feedback.md b/feedback.md new file mode 100644 index 0000000..4ec13ef --- /dev/null +++ b/feedback.md @@ -0,0 +1,5 @@ +* new sessions of discovery do not change previous file =/ +* features do not include information of new sessions as well +* we do not need gen-id task anymore +* we do not need gen-stub task anymore +* What about gen-todo? do we need? can we change? diff --git a/pyproject.toml b/pyproject.toml index bcb507a..bcc8a02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pytest-beehave" -version = "3.0.20260419" +version = "3.2.20260419" description = "A pytest plugin that runs acceptance criteria stub generation as part of the pytest lifecycle, with auto-ID assignment and generic step docstrings" readme = "README.md" requires-python = ">=3.13" @@ -135,9 +135,10 @@ pytest \ --html=docs/tests/report.html \ --self-contained-html \ """ -test = "pytest --tb=short" -test-fast = "pytest -m \"not slow\" -q --no-header --tb=no" -test-slow = "pytest -m slow" +test = "pytest -p no:beehave --tb=short" +test-coverage = "pytest -p no:beehave --doctest-modules --cov=pytest_beehave --cov-config=pyproject.toml --cov-report=term:skip-covered --cov-fail-under=100" +test-fast = "pytest -p no:beehave -m \"not slow\" -q --no-header --tb=no" +test-slow = "pytest -p no:beehave -m slow" ruff-check = "ruff check . --fix" ruff-format = "ruff format ." ruff-format-check = "ruff format . --check" diff --git a/pytest_beehave/config.py b/pytest_beehave/config.py index 4b66d28..84f53ae 100644 --- a/pytest_beehave/config.py +++ b/pytest_beehave/config.py @@ -2,8 +2,12 @@ import tomllib from pathlib import Path +from typing import Literal, cast DEFAULT_FEATURES_PATH: str = "docs/features" +type StubFormat = Literal["functions", "classes"] +VALID_STUB_FORMATS: tuple[str, ...] = ("functions", "classes") +DEFAULT_STUB_FORMAT: StubFormat = "functions" def _read_beehave_section(rootdir: Path) -> dict[str, object]: @@ -96,3 +100,25 @@ def resolve_features_path(rootdir: Path) -> Path: if configured is None: return rootdir / DEFAULT_FEATURES_PATH return rootdir / configured + + +def read_stub_format(rootdir: Path) -> StubFormat: + """Read stub_format from [tool.beehave] in pyproject.toml. + + Args: + rootdir: Absolute path to the project root. + + Returns: + The configured StubFormat, or DEFAULT_STUB_FORMAT if absent. + + Raises: + SystemExit: If stub_format has an invalid value. + """ + section = _read_beehave_section(rootdir) + value = section.get("stub_format", DEFAULT_STUB_FORMAT) + if value not in VALID_STUB_FORMATS: + raise SystemExit( + f"[beehave] invalid stub_format: {value!r}" + f" — valid values are {VALID_STUB_FORMATS}" + ) + return cast(StubFormat, value) diff --git a/pytest_beehave/hatch.py b/pytest_beehave/hatch.py new file mode 100644 index 0000000..0742864 --- /dev/null +++ b/pytest_beehave/hatch.py @@ -0,0 +1,215 @@ +"""Hatch command — generate bee-themed example features directory.""" + +from __future__ import annotations + +import secrets +from dataclasses import dataclass +from pathlib import Path + +_FEATURE_NAMES = [ + "The Forager's Journey", + "Queen's Decree", + "Drone Assembly Protocol", + "Worker Bee Orientation", + "Nectar Collection Workflow", + "Hive Temperature Regulation", + "Pollen Scout Dispatch", + "Royal Jelly Production", + "Swarm Formation Ritual", + "Honeycomb Architecture Review", +] + +_BEES = [ + "Beatrice", + "Boris", + "Belinda", + "Bruno", + "Blossom", + "Barnaby", + "Bridget", + "Bertram", +] +_HIVES = ["the Golden Hive", "the Amber Hive", "the Crystal Hive", "the Obsidian Hive"] + +_BACKLOG_CONTENT = """\ +Feature: {feature_name} + + As {bee}, a worker bee in {hive} + I want to complete my assigned foraging route + So that the colony has enough nectar for the season + + Rule: Forager readiness + + @id:hatch001 + Example: Forager departs when pollen reserve is below threshold + Given the pollen reserve is below 30 percent + When the forager sensor detects the shortage + Then {bee} departs for the meadow within one waggle cycle + + Example: Untagged scenario triggers auto-ID assignment + Given the hive registers a new forager + When the forager completes orientation + Then the forager is assigned a unique scout ID + + @deprecated + Example: Legacy hive-entry handshake (deprecated) + Given an older forager approaches the hive entrance + When the guard bee checks the legacy handshake + Then the handshake is accepted but logged as deprecated + + Rule: Nectar quality control + + @id:hatch002 + Example: Low-quality nectar is rejected at the gate + Given a forager returns with nectar of quality below 0.4 brix + When the gate inspector evaluates the sample + Then the nectar is rejected and the forager is sent to a higher-quality source +""" + +_IN_PROGRESS_CONTENT = """\ +# language: en +Feature: Waggle Dance Communication + + Background: + Given the hive is in active foraging mode + And the dance floor is clear of obstacles + + Rule: Direction encoding + + @id:hatch003 + Example: Scout encodes flower direction in waggle run angle + Given a scout has located flowers 200 metres to the north-east + When the scout performs the waggle dance + Then the waggle run angle matches the sun-relative bearing to the flowers + + Rule: Distance encoding + + @id:hatch004 + Scenario Outline: Scout encodes distance via waggle run duration + Given a scout has located flowers at metres + When the scout performs the waggle dance + Then the waggle run lasts approximately milliseconds + + Examples: + | distance | duration | + | 100 | 250 | + | 500 | 875 | + | 1000 | 1500 | + + @id:hatch005 + Example: Scout provides a data table of visited flower patches + Given the scout returns from a multi-patch forage + When the scout performs the waggle dance + Then the flower patch register contains the following entries: + | patch_id | species | quality | + | P-001 | Lavender | 0.92 | + | P-002 | Clover | 0.85 | + | P-003 | Sunflower | 0.78 | +""" + +_COMPLETED_CONTENT = """\ +Feature: Colony Winter Preparation + + As {bee}, the winter logistics coordinator in {hive} + I want to ensure honey stores are sufficient before the first frost + So that the colony survives the winter without starvation + + Rule: Honey reserve verification + + @id:hatch006 + Example: Winter preparation passes when honey reserve exceeds minimum + Given the honey reserve is at 85 percent capacity + When the winter readiness check is performed + Then the colony status is set to WINTER-READY + + @id:hatch007 + Example: Winter preparation fails when honey reserve is insufficient + Given the honey reserve is below 60 percent capacity + When the winter readiness check is performed + Then the colony status is set to AT-RISK and an alert is raised for {bee} +""" + + +@dataclass(frozen=True, slots=True) +class HatchFile: + """A single generated .feature file to be written. + + Attributes: + relative_path: Path relative to the features root (e.g. ``backlog/x.feature``). + content: The full Gherkin text to write. + """ + + relative_path: str + content: str + + +def generate_hatch_files() -> list[HatchFile]: + """Generate bee-themed example .feature files using stdlib randomisation. + + Returns: + A list of HatchFile objects ready to be written to disk. + """ + feature_name = secrets.choice(_FEATURE_NAMES) + bee = secrets.choice(_BEES) + hive = secrets.choice(_HIVES) + + return [ + HatchFile( + relative_path="backlog/forager-journey.feature", + content=_BACKLOG_CONTENT.format( + feature_name=feature_name, bee=bee, hive=hive + ), + ), + HatchFile( + relative_path="in-progress/waggle-dance.feature", + content=_IN_PROGRESS_CONTENT, + ), + HatchFile( + relative_path="completed/winter-preparation.feature", + content=_COMPLETED_CONTENT.format(bee=bee, hive=hive), + ), + ] + + +def write_hatch(features_root: Path, files: list[HatchFile]) -> list[str]: + """Write HatchFile objects to disk under features_root. + + Args: + features_root: The root features directory to write into. + files: The list of HatchFile objects to write. + + Returns: + List of written file paths as strings (relative to features_root). + """ + written: list[str] = [] + for hatch_file in files: + dest = features_root / hatch_file.relative_path + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(hatch_file.content, encoding="utf-8") + written.append(hatch_file.relative_path) + return written + + +def run_hatch(features_root: Path, force: bool) -> list[str]: + """Run the hatch command: check for conflicts, generate, and write files. + + Args: + features_root: The root features directory to populate. + force: If True, overwrite existing content without error. + + Returns: + List of written file paths as strings. + + Raises: + SystemExit: If the directory contains .feature files and force is False. + """ + existing = list(features_root.rglob("*.feature")) if features_root.exists() else [] + if existing and not force: + conflict = existing[0] + raise SystemExit( + f"[beehave] hatch aborted: existing .feature files found at {conflict}. " + "Use --beehave-hatch-force to overwrite." + ) + for old_file in existing: + old_file.unlink() + return write_hatch(features_root, generate_hatch_files()) diff --git a/pytest_beehave/plugin.py b/pytest_beehave/plugin.py index 4670feb..235328c 100644 --- a/pytest_beehave/plugin.py +++ b/pytest_beehave/plugin.py @@ -11,10 +11,12 @@ from pytest_beehave.bootstrap import bootstrap_features_directory from pytest_beehave.config import ( is_explicitly_configured, + read_stub_format, resolve_features_path, show_steps_in_html, show_steps_in_terminal, ) +from pytest_beehave.hatch import run_hatch from pytest_beehave.html_steps_plugin import HtmlStepsPlugin from pytest_beehave.id_generator import assign_ids from pytest_beehave.reporter import ( @@ -81,7 +83,14 @@ def _run_beehave_sync(config: pytest.Config, path: Path) -> None: report_id_write_back(writer, errors) if errors: pytest.exit("[beehave] untagged Examples in read-only files", returncode=1) - report_sync_actions(writer, run_sync(path, config.rootpath / "tests" / "features")) + try: + stub_format = read_stub_format(config.rootpath) + except SystemExit as exc: + pytest.exit(str(exc), returncode=1) + report_sync_actions( + writer, + run_sync(path, config.rootpath / "tests" / "features", stub_format=stub_format), + ) def _html_available() -> bool: @@ -123,6 +132,27 @@ def _register_output_plugins(config: pytest.Config, rootdir: Path) -> None: pm.register(HtmlStepsPlugin(), "beehave-html-steps") +def pytest_addoption(parser: pytest.Parser) -> None: + """Register --beehave-hatch and --beehave-hatch-force CLI options. + + Args: + parser: The pytest argument parser. + """ + group = parser.getgroup("beehave") + group.addoption( + "--beehave-hatch", + action="store_true", + default=False, + help="Generate bee-themed example features directory and exit.", + ) + group.addoption( + "--beehave-hatch-force", + action="store_true", + default=False, + help="Overwrite existing content when using --beehave-hatch.", + ) + + def pytest_configure(config: pytest.Config) -> None: """Read beehave configuration, bootstrap directory, sync stubs. @@ -131,6 +161,16 @@ def pytest_configure(config: pytest.Config) -> None: """ rootdir = config.rootpath path = resolve_features_path(rootdir) + if config.getoption("--beehave-hatch", default=False): + force = bool(config.getoption("--beehave-hatch-force", default=False)) + try: + written = run_hatch(path, force) + except SystemExit as exc: + pytest.exit(str(exc), returncode=1) + writer = _PytestTerminalWriter(config) + for entry in written: + writer.line(f"[beehave] HATCH {entry}") + pytest.exit("[beehave] hatch complete", returncode=0) _exit_if_missing_configured_path(rootdir, path) config.stash[features_path_key] = path if path.exists(): diff --git a/pytest_beehave/stub_writer.py b/pytest_beehave/stub_writer.py index 1d2bed0..cf5635d 100644 --- a/pytest_beehave/stub_writer.py +++ b/pytest_beehave/stub_writer.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from pathlib import Path +from pytest_beehave.config import StubFormat from pytest_beehave.feature_parser import ( ParsedExample, ParsedFeature, @@ -53,12 +54,14 @@ class StubSpec: rule_slug: The rule slug (underscore-separated), or None for top-level stubs. example: The parsed example. feature: The full parsed feature (for docstring context). + stub_format: The output format for the stub ("functions" or "classes"). """ feature_slug: FeatureSlug rule_slug: RuleSlug | None example: ParsedExample feature: ParsedFeature + stub_format: StubFormat = "functions" def build_function_name(feature_slug: FeatureSlug, example_id: ExampleId) -> str: @@ -162,6 +165,8 @@ def _stub_function_source( function_name: str, docstring_body: str, is_deprecated: bool, + *, + is_method: bool = False, ) -> str: """Build full source text for a single test stub function. @@ -169,14 +174,16 @@ def _stub_function_source( function_name: The test function name. docstring_body: The docstring body (without triple-quotes). is_deprecated: If True, add @pytest.mark.deprecated. + is_method: If True, emit (self) as the parameter. Returns: Full function source as a string. """ decorator = _stub_decorator(is_deprecated) + params = "self" if is_method else "" return ( f"{decorator}" - f"def {function_name}() -> None:\n" + f"def {function_name}({params}) -> None:\n" f' """\n{docstring_body}\n """\n' f" raise NotImplementedError\n" ) @@ -262,10 +269,11 @@ def write_stub_to_file(path: Path, spec: StubSpec) -> SyncAction: function_name = build_function_name(spec.feature_slug, example.example_id) rule = _find_rule(spec.feature, spec.rule_slug) if spec.rule_slug else None docstring_body = build_docstring(spec.feature, rule, example) + is_class_method = spec.rule_slug is not None and spec.stub_format == "classes" function_source = _stub_function_source( - function_name, docstring_body, example.is_deprecated + function_name, docstring_body, example.is_deprecated, is_method=is_class_method ) - if spec.rule_slug is not None: + if is_class_method: return _write_class_based_stub(path, spec, function_name, function_source) return _write_top_level_stub(path, function_source) @@ -557,7 +565,8 @@ def mark_non_conforming( match = _find_function_match(content, function_name) if not match: return None - if content[: match.start()].endswith(marker_line): + before_def = content[: match.start()] + if f"non-conforming: should be in {correct_file}" in before_def: return None updated = _insert_marker_before(content, match, marker_line) path.write_text(updated, encoding="utf-8") diff --git a/pytest_beehave/sync_engine.py b/pytest_beehave/sync_engine.py index 8ab24a2..134c394 100644 --- a/pytest_beehave/sync_engine.py +++ b/pytest_beehave/sync_engine.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Protocol +from pytest_beehave.config import StubFormat from pytest_beehave.feature_parser import ( ParsedExample, ParsedFeature, @@ -295,12 +296,14 @@ def _sync_non_conforming( def _sync_active_feature( feature: ParsedFeature, tests_dir: Path, + stub_format: StubFormat = "functions", ) -> list[SyncAction]: """Sync stubs for an active (backlog/in-progress) feature. Args: feature: The parsed feature. tests_dir: Root of the tests/features/ directory. + stub_format: The output format for new stubs. Returns: List of SyncAction objects. @@ -310,7 +313,9 @@ def _sync_active_feature( if feature.rules: for rule in feature.rules: - actions.extend(_sync_rule_stubs(feature, rule, feature_test_dir)) + actions.extend( + _sync_rule_stubs(feature, rule, feature_test_dir, stub_format) + ) elif feature.top_level_examples: actions.extend(_sync_top_level_stubs(feature, feature_test_dir)) @@ -321,6 +326,7 @@ def _sync_rule_stubs( feature: ParsedFeature, rule: ParsedRule, feature_test_dir: Path, + stub_format: StubFormat = "functions", ) -> list[SyncAction]: """Sync stubs for a single rule block. @@ -328,6 +334,7 @@ def _sync_rule_stubs( feature: The parsed feature. rule: The parsed rule. feature_test_dir: Directory for this feature's tests. + stub_format: The output format for new stubs. Returns: List of SyncAction objects. @@ -338,7 +345,9 @@ def _sync_rule_stubs( existing = {s.example_id: s for s in read_stubs_from_file(test_file)} actions: list[SyncAction] = [] for example in rule.examples: - action = _sync_one_example(feature, rule, example, test_file, existing) + action = _sync_one_example( + feature, rule, example, test_file, existing, stub_format + ) if action is not None: actions.append(action) actions.extend(_sync_deprecated_in_rule(feature, rule, test_file)) @@ -379,6 +388,7 @@ def _sync_one_example( example: ParsedExample, test_file: Path, existing: dict[ExampleId, ExistingStub], + stub_format: StubFormat = "functions", ) -> SyncAction | None: """Sync a single example stub — create or update. @@ -388,6 +398,7 @@ def _sync_one_example( example: The parsed example. test_file: Path to the test file. existing: Map of existing stubs by example ID. + stub_format: The output format for new stubs. Returns: SyncAction or None. @@ -402,6 +413,7 @@ def _sync_one_example( rule_slug=rule_slug, example=example, feature=feature, + stub_format=stub_format, ) return write_stub_to_file(test_file, spec) @@ -588,6 +600,7 @@ def run_sync( features_root: Path, tests_root: Path, filesystem: FileSystemProtocol | None = None, + stub_format: StubFormat = "functions", ) -> list[str]: """Sync test stubs from .feature files to the tests directory. @@ -596,6 +609,7 @@ def run_sync( in-progress/, completed/). tests_root: Root of the tests/features/ directory. filesystem: Optional filesystem adapter. Defaults to _RealFileSystem. + stub_format: The output format for new stubs. Returns: List of action description strings. @@ -608,7 +622,7 @@ def run_sync( if stage == FeatureStage.COMPLETED: actions.extend(_sync_completed_feature(feature, tests_root)) else: - actions.extend(_sync_active_feature(feature, tests_root)) + actions.extend(_sync_active_feature(feature, tests_root, stub_format)) expected_locations = _build_expected_locations(feature_stage_pairs, tests_root) actions.extend(_sync_non_conforming(tests_root, expected_locations, filesystem)) all_ids = _collect_all_ids(features_root, filesystem) diff --git a/tests/features/example_hatch/capability_showcase_content_test.py b/tests/features/example_hatch/capability_showcase_content_test.py new file mode 100644 index 0000000..991ce9b --- /dev/null +++ b/tests/features/example_hatch/capability_showcase_content_test.py @@ -0,0 +1,101 @@ +"""Tests for capability showcase content story.""" + +from pathlib import Path + +import pytest + +from pytest_beehave.hatch import run_hatch + + +@pytest.fixture +def hatched(tmp_path: Path) -> Path: + """Run hatch into tmp_path and return the features root.""" + features_root = tmp_path / "features" + run_hatch(features_root, force=False) + return features_root + + +def _all_contents(features_root: Path) -> list[str]: + return [f.read_text(encoding="utf-8") for f in features_root.rglob("*.feature")] + + +def _has_untagged_example(content: str) -> bool: + lines = content.splitlines() + for i, line in enumerate(lines): + if line.strip().startswith("Example:") and i > 0: + prev = lines[i - 1].strip() + if not prev.startswith("@id:"): + return True + return False + + +def test_example_hatch_7a8b9c0d(hatched: Path) -> None: + """ + Given: no features directory exists at the configured path + When: pytest is invoked with --beehave-hatch + Then: at least one generated .feature file contains an Example with no @id tag + """ + contents = _all_contents(hatched) + assert any(_has_untagged_example(c) for c in contents) + + +def test_example_hatch_8b9c0d1e(hatched: Path) -> None: + """ + Given: no features directory exists at the configured path + When: pytest is invoked with --beehave-hatch + Then: at least one generated .feature file contains an Example tagged @deprecated + """ + contents = _all_contents(hatched) + assert any("@deprecated" in content for content in contents) + + +def test_example_hatch_9c0d1e2f(hatched: Path) -> None: + """ + Given: no features directory exists at the configured path + When: pytest is invoked with --beehave-hatch + Then: at least one generated .feature file begins with a # language: directive + """ + files = list(hatched.rglob("*.feature")) + assert any(f.read_text(encoding="utf-8").startswith("# language:") for f in files) + + +def test_example_hatch_0d1e2f3a(hatched: Path) -> None: + """ + Given: no features directory exists at the configured path + When: pytest is invoked with --beehave-hatch + Then: at least one generated .feature file contains a Background: block + """ + contents = _all_contents(hatched) + assert any("Background:" in content for content in contents) + + +def test_example_hatch_1e2f3a4b(hatched: Path) -> None: + """ + Given: no features directory exists at the configured path + When: pytest is invoked with --beehave-hatch + Then: at least one generated .feature file contains a Scenario Outline with an Examples: table + """ + contents = _all_contents(hatched) + assert any( + "Scenario Outline:" in content and "Examples:" in content + for content in contents + ) + + +def test_example_hatch_a1f2e3d4(hatched: Path) -> None: + """ + Given: no features directory exists at the configured path + When: pytest is invoked with --beehave-hatch + Then: at least one generated .feature file contains a step followed by a data table + """ + contents = _all_contents(hatched) + assert any("| " in content for content in contents) + + +def test_example_hatch_b2e3d4c5(hatched: Path) -> None: + """ + Given: no features directory exists at the configured path + When: pytest is invoked with --beehave-hatch + Then: at least one generated .feature file is placed in the completed subfolder + """ + assert list((hatched / "completed").glob("*.feature")) diff --git a/tests/features/example_hatch/configured_path_respect_test.py b/tests/features/example_hatch/configured_path_respect_test.py new file mode 100644 index 0000000..8e82abb --- /dev/null +++ b/tests/features/example_hatch/configured_path_respect_test.py @@ -0,0 +1,20 @@ +"""Tests for configured path respect story.""" + +from pathlib import Path + +from pytest_beehave.hatch import run_hatch + + +def test_example_hatch_c3d4e5f6(tmp_path: Path) -> None: + """ + Given: pyproject.toml contains [tool.beehave] with features_path set to a custom directory + When: pytest is invoked with --beehave-hatch + Then: the generated .feature files are written under the custom configured path and not under docs/features/ + """ + custom_path = tmp_path / "my_custom_features" + default_path = tmp_path / "docs" / "features" + + run_hatch(custom_path, force=False) + + assert list(custom_path.rglob("*.feature")) + assert not default_path.exists() diff --git a/tests/features/example_hatch/hatch_invocation_test.py b/tests/features/example_hatch/hatch_invocation_test.py new file mode 100644 index 0000000..083af03 --- /dev/null +++ b/tests/features/example_hatch/hatch_invocation_test.py @@ -0,0 +1,86 @@ +"""Tests for hatch invocation story.""" + +import io +import sys +from pathlib import Path + +import pytest + +from pytest_beehave.hatch import run_hatch + + +@pytest.fixture +def features_root(tmp_path: Path) -> Path: + """Return a non-existent features root under tmp_path.""" + return tmp_path / "features" + + +def test_example_hatch_1a2b3c4d(features_root: Path) -> None: + """ + Given: no features directory exists at the configured path + When: pytest is invoked with --beehave-hatch + Then: the backlog, in-progress, and completed subfolders exist under the configured features path + """ + run_hatch(features_root, force=False) + + assert (features_root / "backlog").is_dir() + assert (features_root / "in-progress").is_dir() + assert (features_root / "completed").is_dir() + + +def test_example_hatch_2b3c4d5e(features_root: Path) -> None: + """ + Given: no features directory exists at the configured path + When: pytest is invoked with --beehave-hatch + Then: at least one .feature file exists in each of the backlog, in-progress, and completed subfolders + """ + run_hatch(features_root, force=False) + + assert list((features_root / "backlog").glob("*.feature")) + assert list((features_root / "in-progress").glob("*.feature")) + assert list((features_root / "completed").glob("*.feature")) + + +@pytest.mark.slow +def test_example_hatch_3c4d5e6f(tmp_path: Path) -> None: + """ + Given: no features directory exists at the configured path + When: pytest is invoked with --beehave-hatch + Then: the terminal output lists each .feature file that was created + """ + captured = io.StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + result = pytest.main( + [ + "--beehave-hatch", + f"--rootdir={tmp_path}", + "--no-cov", + ], + plugins=[], + ) + finally: + sys.stdout = old_stdout + + assert result == 0 + output = captured.getvalue() + features_root = tmp_path / "docs" / "features" + written = list(features_root.rglob("*.feature")) + assert written + for feature_file in written: + relative = str(feature_file.relative_to(features_root)) + assert relative in output + + +def test_example_hatch_4d5e6f7a(tmp_path: Path) -> None: + """ + Given: no features directory exists at the configured path + When: pytest is invoked with --beehave-hatch + Then: no tests are collected or executed + """ + result = pytest.main( + ["--beehave-hatch", f"--rootdir={tmp_path}", "--co", "-q", "--no-cov"], + plugins=[], + ) + assert result == pytest.ExitCode.NO_TESTS_COLLECTED or result == 0 diff --git a/tests/features/example_hatch/overwrite_protection_test.py b/tests/features/example_hatch/overwrite_protection_test.py new file mode 100644 index 0000000..84aaa79 --- /dev/null +++ b/tests/features/example_hatch/overwrite_protection_test.py @@ -0,0 +1,57 @@ +"""Tests for overwrite protection story.""" + +import io +import sys +from pathlib import Path + +import pytest + + +@pytest.mark.slow +def test_example_hatch_5e6f7a8b(tmp_path: Path) -> None: + """ + Given: the configured features directory already contains at least one .feature file + When: pytest is invoked with --beehave-hatch + Then: the pytest run exits with a non-zero status code and an error naming the conflicting path + """ + features_root = tmp_path / "docs" / "features" + existing = features_root / "backlog" / "existing.feature" + existing.parent.mkdir(parents=True) + existing.write_text("Feature: existing\n", encoding="utf-8") + + captured = io.StringIO() + old_stderr = sys.stderr + sys.stderr = captured + try: + result = pytest.main( + [ + "--beehave-hatch", + f"--rootdir={tmp_path}", + "--no-cov", + ], + plugins=[], + ) + finally: + sys.stderr = old_stderr + + assert result == 1 + assert str(existing) in captured.getvalue() + + +def test_example_hatch_6f7a8b9c(tmp_path: Path) -> None: + """ + Given: the configured features directory already contains at least one .feature file + When: pytest is invoked with --beehave-hatch --beehave-hatch-force + Then: the existing .feature files are replaced with the newly generated hatch content + """ + from pytest_beehave.hatch import run_hatch + + features_root = tmp_path / "features" + existing = features_root / "backlog" / "existing.feature" + existing.parent.mkdir(parents=True) + existing.write_text("Feature: existing\n", encoding="utf-8") + + run_hatch(features_root, force=True) + + assert not existing.exists() + assert list(features_root.rglob("*.feature")) diff --git a/tests/features/example_hatch/stdlib_only_randomisation_test.py b/tests/features/example_hatch/stdlib_only_randomisation_test.py new file mode 100644 index 0000000..52b7942 --- /dev/null +++ b/tests/features/example_hatch/stdlib_only_randomisation_test.py @@ -0,0 +1,44 @@ +"""Tests for stdlib only randomisation story.""" + +from pathlib import Path + +import pytest + +from pytest_beehave.hatch import run_hatch + + +@pytest.mark.slow +def test_example_hatch_d4e5f6a7(tmp_path: Path) -> None: + """ + Given: no features directory exists at the configured path + When: pytest is invoked with --beehave-hatch on two separate occasions with the directory removed between runs + Then: the Feature name in the generated .feature file differs between the two runs + """ + import shutil + + features_root = tmp_path / "features" + seen: set[str] = set() + # Run up to 20 times; probability of all identical is (1/10)^19 ≈ 0 + for _ in range(20): + if features_root.exists(): + shutil.rmtree(features_root) + run_hatch(features_root, force=False) + backlog_files = list((features_root / "backlog").glob("*.feature")) + content = backlog_files[0].read_text(encoding="utf-8") + first_line = content.splitlines()[0] + seen.add(first_line) + if len(seen) > 1: + break + + assert len(seen) > 1 + + +def test_example_hatch_e5f6a7b8(tmp_path: Path) -> None: + """ + Given: a clean environment with only pytest-beehave installed and no other packages + When: pytest is invoked with --beehave-hatch + Then: the hatch completes successfully and no import error or missing-module error is raised + """ + features_root = tmp_path / "features" + written = run_hatch(features_root, force=False) + assert written diff --git a/tests/features/plugin_hook/stub_sync_runs_before_collection_test.py b/tests/features/plugin_hook/stub_sync_runs_before_collection_test.py index 31920e3..ffd14bb 100644 --- a/tests/features/plugin_hook/stub_sync_runs_before_collection_test.py +++ b/tests/features/plugin_hook/stub_sync_runs_before_collection_test.py @@ -53,7 +53,7 @@ def test_plugin_hook_d5824c75(self, pytester: pytest.Pytester) -> None: result = pytester.runpytest() result.stdout.fnmatch_lines(["*CREATE*examples_test.py*"]) - @pytest.mark.skip(reason="orphan: no matching @id in .feature files") + @pytest.mark.deprecated def test_plugin_hook_e3a13b58(self) -> None: """ Given: a project where the configured features directory does not exist diff --git a/tests/features/stub_format_config/classes_format_selection_test.py b/tests/features/stub_format_config/classes_format_selection_test.py new file mode 100644 index 0000000..5e41be1 --- /dev/null +++ b/tests/features/stub_format_config/classes_format_selection_test.py @@ -0,0 +1,61 @@ +"""Tests for classes format selection story.""" + +from pathlib import Path + +from pytest_beehave.sync_engine import run_sync + + +def test_stub_format_config_a2b3c4d5(tmp_path: Path) -> None: + """ + Given: a pyproject.toml with stub_format = "classes" under [tool.beehave] + When: pytest generates a stub for a Rule-block Example + Then: the stub is a method inside class Test in _test.py + """ + features_dir = tmp_path / "features" + tests_dir = tmp_path / "tests" + feature_dir = features_dir / "in-progress" / "my-feature" + feature_dir.mkdir(parents=True) + (feature_dir / "my-feature.feature").write_text( + "Feature: My feature\n\n" + " Rule: My rule\n" + " @id:aabbccdd\n" + " Example: Something\n" + " Given a thing\n" + " When it runs\n" + " Then it works\n", + encoding="utf-8", + ) + run_sync(features_dir, tests_dir, stub_format="classes") + test_file = tests_dir / "my_feature" / "my_rule_test.py" + content = test_file.read_text(encoding="utf-8") + assert "class TestMyRule:" in content + lines = content.splitlines() + def_line = next((ln for ln in lines if "def test_my_feature_aabbccdd" in ln), "") + assert def_line.startswith(" ") + assert "(self)" in def_line + + +def test_stub_format_config_b3c4d5e6(tmp_path: Path) -> None: + """ + Given: a pyproject.toml with stub_format = "classes" and a Rule titled "Wall bounce" + When: pytest generates a stub for an Example under that Rule + Then: the stub is inside a class named TestWallBounce + """ + features_dir = tmp_path / "features" + tests_dir = tmp_path / "tests" + feature_dir = features_dir / "in-progress" / "my-feature" + feature_dir.mkdir(parents=True) + (feature_dir / "my-feature.feature").write_text( + "Feature: My feature\n\n" + " Rule: Wall bounce\n" + " @id:aabbccdd\n" + " Example: Something\n" + " Given a thing\n" + " When it runs\n" + " Then it works\n", + encoding="utf-8", + ) + run_sync(features_dir, tests_dir, stub_format="classes") + test_file = tests_dir / "my_feature" / "wall_bounce_test.py" + content = test_file.read_text(encoding="utf-8") + assert "class TestWallBounce:" in content diff --git a/tests/features/stub_format_config/default_format_selection_test.py b/tests/features/stub_format_config/default_format_selection_test.py new file mode 100644 index 0000000..0f51f75 --- /dev/null +++ b/tests/features/stub_format_config/default_format_selection_test.py @@ -0,0 +1,57 @@ +"""Tests for default format selection story.""" + +from pathlib import Path + +import pytest + +from pytest_beehave.sync_engine import run_sync + + +def test_stub_format_config_a1b2c3d4(tmp_path: Path) -> None: + """ + Given: a pyproject.toml with no stub_format key under [tool.beehave] + When: pytest generates a stub for a Rule-block Example + Then: the stub is a top-level function def test__<@id> with no class wrapper + """ + features_dir = tmp_path / "features" + tests_dir = tmp_path / "tests" + feature_dir = features_dir / "in-progress" / "my-feature" + feature_dir.mkdir(parents=True) + (feature_dir / "my-feature.feature").write_text( + "Feature: My feature\n\n" + " Rule: My rule\n" + " @id:aabbccdd\n" + " Example: Something\n" + " Given a thing\n" + " When it runs\n" + " Then it works\n", + encoding="utf-8", + ) + run_sync(features_dir, tests_dir) + test_file = tests_dir / "my_feature" / "my_rule_test.py" + assert test_file.exists() + content = test_file.read_text(encoding="utf-8") + assert "def test_my_feature_aabbccdd() -> None:" in content + assert "class " not in content + + +@pytest.mark.slow +def test_stub_format_config_b2c3d4e5(tmp_path: Path) -> None: + """ + Given: a pyproject.toml with no stub_format key under [tool.beehave] + When: pytest starts up + Then: pytest starts without any stub_format-related error + """ + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[tool.beehave]\nfeatures_path = "docs/features"\n', encoding="utf-8" + ) + features_dir = tmp_path / "docs" / "features" / "backlog" + features_dir.mkdir(parents=True) + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + conftest = tmp_path / "conftest.py" + conftest.write_text("", encoding="utf-8") + + result = pytest.main(["--no-cov", "--co", "-q", str(tmp_path)], plugins=[]) + assert result in (0, 5) # 0=ok, 5=no tests collected — both mean no error diff --git a/tests/features/stub_format_config/explicit_functions_format_test.py b/tests/features/stub_format_config/explicit_functions_format_test.py new file mode 100644 index 0000000..a482f7d --- /dev/null +++ b/tests/features/stub_format_config/explicit_functions_format_test.py @@ -0,0 +1,32 @@ +"""Tests for explicit functions format story.""" + +from pathlib import Path + +from pytest_beehave.sync_engine import run_sync + + +def test_stub_format_config_f1e2d3c4(tmp_path: Path) -> None: + """ + Given: a pyproject.toml with stub_format = "functions" under [tool.beehave] + When: pytest generates a stub for a Rule-block Example + Then: the stub is a top-level function def test__<@id> with no class wrapper + """ + features_dir = tmp_path / "features" + tests_dir = tmp_path / "tests" + feature_dir = features_dir / "in-progress" / "my-feature" + feature_dir.mkdir(parents=True) + (feature_dir / "my-feature.feature").write_text( + "Feature: My feature\n\n" + " Rule: My rule\n" + " @id:aabbccdd\n" + " Example: Something\n" + " Given a thing\n" + " When it runs\n" + " Then it works\n", + encoding="utf-8", + ) + run_sync(features_dir, tests_dir, stub_format="functions") + test_file = tests_dir / "my_feature" / "my_rule_test.py" + content = test_file.read_text(encoding="utf-8") + assert "def test_my_feature_aabbccdd() -> None:" in content + assert "class " not in content diff --git a/tests/features/stub_format_config/invalid_format_rejection_test.py b/tests/features/stub_format_config/invalid_format_rejection_test.py new file mode 100644 index 0000000..4cb7285 --- /dev/null +++ b/tests/features/stub_format_config/invalid_format_rejection_test.py @@ -0,0 +1,28 @@ +"""Tests for invalid format rejection story.""" + +from pathlib import Path + +import pytest + + +@pytest.mark.slow +def test_stub_format_config_f6a7b8c9(tmp_path: Path) -> None: + """ + Given: a pyproject.toml with stub_format = "methods" under [tool.beehave] + When: pytest starts up + Then: pytest exits with a non-zero status and an error message naming the invalid value + """ + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[tool.beehave]\nfeatures_path = "docs/features"\nstub_format = "methods"\n', + encoding="utf-8", + ) + features_dir = tmp_path / "docs" / "features" / "backlog" + features_dir.mkdir(parents=True) + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + conftest = tmp_path / "conftest.py" + conftest.write_text("", encoding="utf-8") + + result = pytest.main(["--no-cov", "--co", "-q", str(tmp_path)], plugins=[]) + assert result != 0 diff --git a/tests/features/stub_format_config/no_rule_feature_unaffected_test.py b/tests/features/stub_format_config/no_rule_feature_unaffected_test.py new file mode 100644 index 0000000..323ffc4 --- /dev/null +++ b/tests/features/stub_format_config/no_rule_feature_unaffected_test.py @@ -0,0 +1,32 @@ +"""Tests for no-rule feature unaffected story.""" + +from pathlib import Path + +from pytest_beehave.sync_engine import run_sync + + +def test_stub_format_config_a7b8c9d0(tmp_path: Path) -> None: + """ + Given: a pyproject.toml with stub_format = "classes" under [tool.beehave] + And: a feature file with no Rule blocks + When: pytest generates stubs for that feature + Then: the stubs are module-level functions in examples_test.py with no class wrapper + """ + features_dir = tmp_path / "features" + tests_dir = tmp_path / "tests" + feature_dir = features_dir / "in-progress" / "my-feature" + feature_dir.mkdir(parents=True) + (feature_dir / "my-feature.feature").write_text( + "Feature: My feature\n\n" + " @id:aabbccdd\n" + " Example: Something\n" + " Given a thing\n" + " When it runs\n" + " Then it works\n", + encoding="utf-8", + ) + run_sync(features_dir, tests_dir, stub_format="classes") + test_file = tests_dir / "my_feature" / "examples_test.py" + content = test_file.read_text(encoding="utf-8") + assert "def test_my_feature_aabbccdd() -> None:" in content + assert "class " not in content diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 792ee7f..ae95d64 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -2,9 +2,12 @@ from pathlib import Path +import pytest + from pytest_beehave.config import ( DEFAULT_FEATURES_PATH, is_explicitly_configured, + read_stub_format, resolve_features_path, ) @@ -39,3 +42,15 @@ def test_is_explicitly_configured_returns_false_when_no_pyproject( result = is_explicitly_configured(tmp_path) # Then assert result is False + + +def test_read_stub_format_raises_on_invalid_value(tmp_path: Path) -> None: + """ + Given: A pyproject.toml with stub_format set to an invalid value + When: read_stub_format is called + Then: SystemExit is raised with the invalid value in the message + """ + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[tool.beehave]\nstub_format = "methods"\n', encoding="utf-8") + with pytest.raises(SystemExit, match="methods"): + read_stub_format(tmp_path) diff --git a/tests/unit/plugin_test.py b/tests/unit/plugin_test.py index df88557..dc828c5 100644 --- a/tests/unit/plugin_test.py +++ b/tests/unit/plugin_test.py @@ -25,6 +25,7 @@ def test_pytest_configure_stores_resolved_path_when_path_exists( mock_config.rootpath = tmp_path mock_config.stash = {} mock_config.pluginmanager = MagicMock() + mock_config.getoption.return_value = False # When pytest_configure(mock_config) # Then diff --git a/tests/unit/stub_writer_test.py b/tests/unit/stub_writer_test.py index 42d3974..cb94bb2 100644 --- a/tests/unit/stub_writer_test.py +++ b/tests/unit/stub_writer_test.py @@ -171,11 +171,15 @@ def test_write_class_based_stub_adds_to_existing_class(tmp_path: Path) -> None: rule_slug=RuleSlug("my_rule"), example=example, feature=feature, + stub_format="classes", ) action = write_stub_to_file(test_file, spec) assert action.action == "UPDATE" content = test_file.read_text(encoding="utf-8") assert "test_my_feature_22222222" in content + lines = content.splitlines() + def_line = next((ln for ln in lines if "def test_my_feature_22222222" in ln), "") + assert "(self)" in def_line def test_write_class_based_stub_creates_new_class_in_existing_file( @@ -206,12 +210,16 @@ def test_write_class_based_stub_creates_new_class_in_existing_file( rule_slug=RuleSlug("new_rule"), example=example, feature=feature, + stub_format="classes", ) action = write_stub_to_file(test_file, spec) assert action.action == "UPDATE" content = test_file.read_text(encoding="utf-8") assert "class TestNewRule:" in content assert "test_my_feature_aabbccdd" in content + lines = content.splitlines() + def_line = next((ln for ln in lines if "def test_my_feature_aabbccdd" in ln), "") + assert "(self)" in def_line def test_find_rule_returns_none_when_not_found() -> None: @@ -377,3 +385,35 @@ def test_toggle_deprecated_marker_removes_when_not_deprecated(tmp_path: Path) -> assert result.action == "DEPRECATED" content = test_file.read_text(encoding="utf-8") assert "@pytest.mark.deprecated" not in content + + +def test_write_class_based_stub_creates_new_file(tmp_path: Path) -> None: + """ + Given: A non-existent test file and stub_format="classes" + When: write_stub_to_file is called for a rule-based example + Then: A new file is created with a class containing the method stub + """ + test_file = tmp_path / "my_rule_test.py" + example = _make_example("aabbccdd") + rule = ParsedRule( + title="My Rule", + rule_slug=RuleSlug("my_rule"), + examples=(example,), + is_deprecated=False, + ) + feature = _make_feature("my_feature", rules=(rule,)) + spec = StubSpec( + feature_slug=FeatureSlug("my_feature"), + rule_slug=RuleSlug("my_rule"), + example=example, + feature=feature, + stub_format="classes", + ) + action = write_stub_to_file(test_file, spec) + assert action.action == "CREATE" + content = test_file.read_text(encoding="utf-8") + assert "class TestMyRule:" in content + assert "test_my_feature_aabbccdd" in content + lines = content.splitlines() + def_line = next((ln for ln in lines if "def test_my_feature_aabbccdd" in ln), "") + assert "(self)" in def_line diff --git a/uv.lock b/uv.lock index e11ac82..740fa5a 100644 --- a/uv.lock +++ b/uv.lock @@ -672,7 +672,7 @@ wheels = [ [[package]] name = "pytest-beehave" -version = "3.0.20260419" +version = "3.2.20260419" source = { editable = "." } dependencies = [ { name = "fire" },