- name
- gherkin-derive
- description
- Derive Gherkin scenarios directly from a codebase — standalone, with no prior legacy-modernization analysis. Discovers the public surface (OpenAPI, routes, existing tests, exported signatures, plus message-queue, cron, and websocket/GraphQL surfaces), recommends a BDD binding mode via the bdd-value-guide rubric, and merges scenarios into `.feature` files (preserving prior enrichment, never overwriting) plus (in bdd-runner mode) pending step-definition stubs. Use it on its own to capture intended behavior before changing tests, or as Phase 3 of `/test-improve`. Creates no tracker Stories.
- argument-hint
- <repo-path> [--mode none|xunit-with-annotations|bdd-runner] [--repo-slug <slug>]
- role
- worker
- user-invocable
- true
- allowed-tools
- Read, Glob, Grep, Bash, Write
# Gherkin Derive
Role: worker. A **standalone** Gherkin derivation skill. It derives scenarios for
a repo's public surface directly from code — it does **not** require any prior
`/cd-test-architecture` analysis, and it does **not** create tracker Stories
(the calling orchestrator owns triage). `/gherkin-public` remains a
public-boundary Gherkin authoring worker used standalone.
Usable two ways: on its own to capture intended behavior before any test change,
or as the Phase-3 sub-step of `/test-improve`.
## Parse Arguments
- Positional: `<repo-path>` (default: cwd).
- `--mode <none|xunit-with-annotations|bdd-runner>` — the binding mode. When
omitted, present the BDD value rubric (Step 1) and let the operator choose.
- `--repo-slug <slug>` — namespace for the surface inventory under
`.claude/memory/<workflow>/<slug>/` (`/test-improve` passes `--workflow test-improve`).
## Step 1 — Choose the binding mode (BDD value rubric)
If `--mode` was **not** supplied, present the 5-question rubric from
`knowledge/references/bdd-value-guide.md` and recommend a mode from the score:
- `≥ 3 yes` → **`bdd-runner`**
- `1–2 yes` → **`xunit-with-annotations`**
- `0 yes` → **`none`**
Show the recommendation and let the operator override. The three modes:
- **`none`** — emit no Gherkin. Exit immediately with a one-line recommendation
to use plain xUnit (e.g. *"0/5 BDD signals — use plain xUnit; no `.feature`
files written."*). **Write no files.**
- **`xunit-with-annotations`** — derive scenarios and write `.feature` files, but
do **NOT** wire a BDD runner or install any framework. The files are
documentation that `/build` cites in test method names and leading comments.
- **`bdd-runner`** — derive scenarios, write `.feature` files, wire the
language-appropriate BDD framework (Step 4), and generate pending step
definition stubs.
## Step 2 — Discover the public surface
No pre-computed component map is required. Discover surfaces in this **priority
order**, most authoritative first:
1. **OpenAPI / Swagger spec** (`openapi.yaml`, `openapi.json`, `swagger.json`) —
the most authoritative description of the public surface. Each path+method is
a surface.
2. **Route definitions** — Express/Fastify handlers, Spring `@Controller` /
`@RestController`, ASP.NET `[ApiController]`, Go `http.HandleFunc` / Chi / Gin
routes. Each registered route is a surface.
3. **Existing test names** — `describe` / `it` / `[Fact]` / `@Test` blocks. These
yield **characterization** scenarios (current behavior, not intended
behavior). Use them as the primary source only when OpenAPI and routes do
not already cover the surface. **Never treat a test's assertion as ground
truth on its own** — before accepting a test-derived scenario, cross-check
it against any other available signal (docstrings, comments, adjacent
OpenAPI/route info, obvious status-code conventions). Record what was
cross-checked (or that nothing was found) per Step 3.
4. **Public function signatures + docstrings** — exported functions/classes with
doc comments. The lowest-priority fallback for libraries with no HTTP surface.
Stop climbing the list for the **same surface description** once a
higher-priority source covers it — do not duplicate a route's success/failure
scenarios from its tests. But do not let this rule discard information: even
when a surface is already covered by OpenAPI or a route, still scan its
existing tests for error/edge branches that are **not** present in the
documented spec, and add those as *supplemental* characterization scenarios
rather than dropping them.
**Graph-assisted discovery.** Prefer CodeGraph/Repowise over raw `Grep` for
locating routes, handlers, and exported signatures — see
[`knowledge/codegraph-vs-graphify.md`](../../knowledge/codegraph-vs-graphify.md)
for tool selection and the fallback contract.
**Async / event / scheduled surfaces — a separate discovery pass, run
regardless of the cascade above.** These have no OpenAPI equivalent and the
1–4 cascade will never find them, yet Step 3 already has templates
(**Batch / Scheduled Job**, **API / Event Consumer**) waiting to describe
them. Scan for:
- **Message-queue consumers/producers** — Kafka `@KafkaListener` /
`KafkaConsumer`, SQS handlers, RabbitMQ `@RabbitListener`, generic
`consume(...)` / `on_message(...)` callback registrations.
- **Scheduled/cron entry points** — Spring `@Scheduled`, `node-cron` /
`cron.schedule(...)`, Quartz jobs, Kubernetes `CronJob` manifests.
- **WebSocket / GraphQL handlers** — `@SubscribeMessage`, `io.on(...)` /
`socket.on(...)`, GraphQL resolver definitions (`Query`/`Mutation`/
`Subscription` fields).
Each hit is its own surface: route message-queue and event hits to the
**API / Event Consumer** template, and cron/scheduled hits to the
**Batch / Scheduled Job** template.
**In-depth, codebase-wide analysis is the mandatory default — always on,
never operator-supplied (issue #1450).** Every invocation of this step
performs an in-depth analysis of the codebase, not a shallow scan limited to
registered entry points. Beyond the cascade above and the async/event/
scheduled sweep, explicitly analyze **controllers, handlers, services,
domain logic, workflows, validation rules, error handling, and business
processes** to identify behavior-driven scenarios that an entry point's bare
signature does not reveal — e.g. a thin route handler backed by a service
that enforces three distinct business rules yields three scenarios, one per
rule, not one generic "invalid input" scenario. This depth requirement is
unconditional: it is never gated behind an operator-supplied instruction, a
flag, or a specific prompt phrasing — a run that skips this analysis is a
spec violation of this skill, not an acceptable shallow default. Record what
was analyzed for each of these eight categories in the Step 5 surface
inventory's `## Analysis Coverage` section (see Step 5), so a thin run is
detectable rather than merely asserted.
**Resolve the existing file before authoring (issue #1420).** Run
`detect_bdd_convention.py` once per repo to get the project's `.feature`
destination directory, then compose each surface's path yourself as
`<dir>/<surface>.feature` — `detect_bdd_convention.py`'s own contract stays a
single project-wide directory probe; this skill composes the per-surface
path, it never asks the script to resolve one itself:
```
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/detect_bdd_convention.py"
```
A `"dir": null` result (`"signal": "none"`) means the script found no usable
convention — including, since issue #1462, a repo whose only existing
`.feature` root sits under a non-conventionally-named directory. Never
compose a path from `null`; fall back to
`../plan/references/gherkin-persistence.md`'s no-signal handling (prompt the
operator, or its non-interactive default) instead of guessing a destination.
If a file already exists at that composed path, **read it** before authoring
anything for that surface — Step 5 merges into it rather than overwriting.
**Surface identifiers must be unique per method+path (or equivalent
distinguishing element for non-HTTP surfaces) — never collapse two different
surfaces onto the same `<surface>` stem (issue #1526).** For an API Provider
surface, derive `<surface>` from both the HTTP method and the normalized
path (e.g. `GET /users/{id}` → `get_users_id`, `DELETE /users/{id}` →
`delete_users_id`) so two endpoints that differ only by method or path never
compose the same file/feature-title pair. For a non-HTTP surface
(message-queue consumer, cron job, CLI command, exported function), use the
fully-qualified name (queue/topic name, job name, module.function) for the
same reason. A surface-identifier collision is a defect regardless of how it
happens — it silently merges two unrelated behaviors' scenarios into one
Feature block, where the exact-title dedup in Step 5 will treat a second
surface's same-titled scenario as an existing duplicate and drop it.
## Step 3 — Author scenarios
Use the same templates as `/gherkin-public`: **API Provider**, **UI**,
**Batch / Scheduled Job**, **CLI / Library**, **API / Event Consumer**. Every
scenario covers at least one success and one failure path, and every step is
observable at the boundary — no internal calls.
**Ground every failure path in an observed condition.** Before filling a
failure-scenario placeholder, locate a specific failure condition actually
present in the code — a conditional, a thrown/raised exception, a documented
or observed HTTP status code, a validation rule — and cite it in the scenario
body. When CodeGraph/Repowise are available, use `codegraph_explore` (or
Repowise `get_context`/`search_codebase`) to inspect a surface's actual
branches and error-handling depth for this; fall back to reading the source
directly when the tools are unavailable. Do not invent a generic
`<invalid request>` / `<failure-mode-summary>` placeholder as a paraphrase of
the surface's name or signature. When no such condition is discoverable for a
surface, mark that scenario `# TODO: no observed failure path — hand-author`
instead of fabricating one — an honest gap beats an invented scenario.
**Titles must be specific enough to identify the surface without relying on
the `Feature:` header for context (issue #1526).** A `Scenario:` title read
in isolation — in a CI report, a BDD runner's scenario list, or a coverage
dashboard — must be recognizable as belonging to its specific surface, not a
generic category label that could describe any endpoint (e.g. `returns error
for invalid input`, `handles success case`). Prefer wording that names the
concrete condition or resource involved (e.g. `rejects the request when the
id path parameter is non-numeric`, `returns the created order with a 201 and
Location header`) over a bare category name. This applies to success-path
titles as well as failure-path titles — the no-generic-placeholder rule
above already covers failure-path *content*; this rule covers *all* scenario
*titles*. A title that is merely a paraphrase of the surface name (the same
words as the `Feature:` line, reworded) fails this rule the same way a
placeholder failure condition does.
**Label the provenance** in each `.feature` file header:
- Scenarios derived from OpenAPI or docstrings are **specification** scenarios
(intended behavior).
- Scenarios derived from existing tests or code are **characterization**
scenarios — the header MUST state `# Characterization: current behavior, not
intended behavior` so a reader never mistakes a captured bug for a spec.
They are hypotheses about intended behavior, not confirmed specs.
```gherkin
# Source: <openapi path | route | test file | signature>
# Provenance: specification | characterization
# Characterization: current behavior, not intended behavior (characterization only)
# Cross-check: <docstring/OpenAPI/route signal that corroborates this, or "none found — unverified against intended behavior"> (characterization only)
Feature: <surface>
Scenario: <success path>
...
Scenario: <failure path — a real observed condition, or the hand-author TODO>
...
```
**Detect drift in retained scenarios (issue #1420).** For each existing
scenario retained (not replaced) during Step 5's merge, extract the observed
condition for that same path exactly the way this step already does when
authoring a fresh scenario — a status code, exception, or validation rule.
Then call `gherkin_feature_merge.py check-stale` to decide match/mismatch
deterministically, never by eyeballing the comparison yourself:
```
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/gherkin_feature_merge.py" check-stale \
--existing <dir>/<surface>.feature --feature-title "<surface>" \
--observed "<scenario title>=<observed value>" --json
```
On a reported mismatch, leave the retained scenario's text unmodified — do
not rewrite it — and record it for the Step 6 report.
## Step 4 — Wire the BDD framework (bdd-runner mode only)
Skip this step entirely in `none` and `xunit-with-annotations` modes.
Read `knowledge/test-stack-profiles/bdd-frameworks.md` for the per-language install steps
and directory layout, then generate **pending** step-definition stubs so the
suite **compiles and fails intentionally** (red before green) — never empty stubs
that pass silently.
| Language | Framework | Pending stub |
|---|---|---|
| JS/TS | Cucumber.js | `return this.pending();` |
| Java (Maven) | Cucumber-JVM + `cucumber-junit-platform-engine` | `throw new io.cucumber.java.PendingException();` |
| Java (Gradle) | Same via Gradle config | `throw new io.cucumber.java.PendingException();` |
| C# | Reqnroll (xUnit / NUnit / MSTest) | `throw new PendingStepException();` (Reqnroll's own auto-suggested stub; `ScenarioContext.StepIsPending()` is deprecated as of Reqnroll 3.3.4 — see `bdd-frameworks.md`) |
| Go | Godog | `return godog.ErrPending` |
**Merge, never overwrite (issue #1421).** Step-definition stubs are merged
into the existing file the same way Step 5 merges `.feature` scenarios —
never a raw `Write`, which would silently discard any step a human (or
`/build`) has already implemented. For each surface's step-definition file,
write the newly-derived stub text (using the pending-stub form from the
table above) to a scratch candidates file, then invoke
`gherkin_stub_merge.py merge`:
```
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/gherkin_stub_merge.py" merge \
--existing <dir>/<surface>_steps.<ext> --candidates <scratch-file> \
--ext <.js|.ts|.mjs|.cjs|.java|.cs|.go> --json
```
This is exactly one write path whether or not a step-definition file already
existed at that path. Any step already bound in the file — pending or
already implemented — is left byte-for-byte untouched; only genuinely new
step patterns are appended, as pending stubs, after the file's last existing
binding. **Exit 2 means no write occurred** — read the `--json` payload's
`error` field to know why, and report the specific cause per Step 6 rather
than a generic "could not merge" (never retry with a raw `Write`, whichever
cause it is):
- `unbalanced-braces` — the existing file's braces/parens don't lexically
balance, so no binding's body can be safely bounded. The file needs
hand-repair before any merge can succeed.
- `dangling-annotation` — a step marker (a call, annotation, or attribute)
was found with no attached, boundable body. Same remediation — hand-repair
the file first.
- `unsafe-path` — the composed `--existing` path contained a `..` component
and was rejected before any read or write. Fix how the surface name was
derived into a path; do not retry with the same value.
- `malformed-candidates` — the scratch candidates file (this skill's own
intermediate output, not the operator's step-definition file) itself
couldn't be bounded. Re-author the candidates text for that surface and
retry.
- `unreadable-candidates` — the scratch candidates file (this skill's own
intermediate output) is missing or couldn't be read at all. Re-check how
this skill wrote that scratch file for the surface before retrying — not
the operator's step-definition file.
- `unsupported-extension` — the `--ext` value doesn't name a language this
script recognizes (`.js`/`.ts`/`.mjs`/`.cjs`/`.java`/`.cs`/`.go`). Fix how
this skill derived `--ext` from the surface's language before retrying.
**Batch shared context-file edits (issue #1775).** When multiple surfaces
discovered in this run add to the same shared acceptance-test-context file
(e.g. a shared step-definition registration/support file, hooks module, or
world/context setup shared across surfaces), collect every surface's
addition to that shared file first, then apply them in **one edit pass** for
that file — never reopen and re-edit the same shared context file once per
step definition or per surface. Reopening it N times for N step
registrations produces N redundant read/write round-trips for content that
could have been composed once. **"One edit pass" still means one merge, never
one raw `Write`, when the shared file is a step-definition/registration
file** — collecting every surface's addition first is a batching optimization
over how many times you invoke `gherkin_stub_merge.py merge`, not a license
to replace it with a single `Write`; combine all the batched additions into
one `--candidates` scratch file and make exactly one `gherkin_stub_merge.py
merge` call against the shared file, per the Merge, never overwrite rule
above (issue #1421).
## Step 5 — Output
- `features/<surface>.feature` files (all non-`none` modes) — **merged, not
replaced (issue #1420).** For each surface, write the newly-authored
scenario text to a scratch candidates file, then invoke
`gherkin_feature_merge.py merge` — never a raw `Write` — to produce the
file on disk:
```
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/gherkin_feature_merge.py" merge \
--existing <dir>/<surface>.feature --candidates <scratch-file> \
--feature-title "<surface>" --json
```
This is exactly one write path whether or not a file already existed at
that path — a surface with no prior file goes through the same `merge`
subcommand, which synthesizes a fresh block, so there is never a second,
divergent write path to keep in sync. Any prior enrichment already in
the file (hand-authored or from `/feature-coverage-analyzer`) — including
`Background:` sections, `@tag`s, and `Scenario Outline:`/`Examples:` tables
— is preserved byte-for-byte; only genuinely new scenario titles are
appended, after the block's last existing unit. **Exit 2 means no write
occurred** — read the `--json` payload's `error` field to know why, and
report the specific cause per Step 6 rather than a generic "could not
merge" (never retry with a raw `Write`, whichever cause it is):
- `feature-not-found` — the named `Feature:` title isn't in the existing
file; most likely a human renamed it. Reconcile `--feature-title` with
the file's actual header.
- `malformed-feature-block` — the title was found but the block's
structure can't be bounded (a dangling `@tag` line, or a `Scenario
Outline:` missing its `Examples:` table). The existing file needs
hand-repair before any merge can succeed.
View on GitHub