A Gherkin foundation for Morphir verification
This draft adds two crates to morphir-rust and moves Morphir's black-box tests onto them:
morphir-gherkin: a model of Gherkin documents read from.featureand.feature.md(Markdown with Gherkin) files. It gives every node a source span, and offers a visitor and a cursor. It keeps prose and fenced blocks as parsed Markdown, and it has extension points for tags, fences and prose that build a typed context. It does not depend on cucumber, so the knowledge base, OKF and requirement tools can use it without running anything.morphir-bdd: execution on cucumber-rs through that model. It provides one shared world type, step libraries that any crate can publish and reuse, one runner configuration with JSON and JUnit output, and base steps for CLI processes, files and output.
morphir itest becomes the user-facing runner for morphir-bdd suites, and itest's notebook support is removed. The compatibility kit's cases move from their custom Markdown grammar to .feature suites at the same time. Options everywhere are native Gherkin tags and step text. Later work builds on this foundation:
- Language syntaxes, IR inspection and IR comparison, whose Gherkin steps use
morphir-bdd; - The compatibility kit with Ion as the reference encoding, which builds on the kit's Gherkin form;
- the Ion sweep of bead
morphir-vvgi.9.
Why
Morphir has three black-box testing systems that do not share code:
- itest is the
morphir itestsubcommand (crates/morphir/src/commands/itest/). It runsscenarios.mdandscenario.ipynbfiles fromexamples/through real CLI processes, withCommand, RegoAssertionand, on thefeat/itest-goldenbranch,Goldensteps. - Cucumber suites: five feature files in finos/morphir and 21 in morphir-rust, on cucumber 0.23 and gherkin 0.16.
- The compatibility kit: its own Markdown case grammar and engine (
spec/ir/mck,crates/morphir-mck). Its options live in headings ({node=Value}) and fence info strings, which no other tool reads.
The cucumber suites show the cost of that split:
- Four custom mains, each configured differently:
crates/integration-tests/tests/cli.rsfilters with a closure;crates/morphir/tests/config_acceptance.rsandkb_acceptance.rscallWorld::rundirectly;mck_run.rsis a replay adapter.
- Tags: only
@wipchanges behaviour, and only in one binary. Every other tag is a label. - Duplicated step code:
config_acceptance.rs:362-396andkb_acceptance.rs:51,120each implement "runmorphir, split the command line, isolate the environment". The shared helperintegration-tests::CliTestContextis not reachable fromcrates/morphir. - Steps cannot be reused: a step is bound to one concrete world type.
- Raw JSON: the IR steps in
crates/integration-tests/tests/cli.rs:258-470walk raw JSON and work only for a single-file v4 JSONLibrary. - No Markdown: cucumber-rs reads only
*.feature(cucumber-0.23.0/src/parser/basic.rs:115), andgherkin0.16 has no Markdown support. - Flattened descriptions:
gherkin0.16 trims every line of a description and drops blank lines. A fenced block in a.featuredescription comes back as"The feature text.\n```yaml morphir\nsyntax: elm\n```\nMore prose.", which breaks indentation-sensitive content.
Design
flowchart TB
subgraph rust["ecosystem/morphir-rust"]
G["morphir-gherkin<br/>model · .feature + .feature.md readers<br/>visitor · cursor · prose<br/>tag / fence / prose extensions · Context"]
B["morphir-bdd<br/>cucumber Parser · MorphirWorld<br/>Suite runner · JSON + JUnit<br/>base steps: CLI, files, output"]
L["domain step libraries<br/>(feature 'steps')"]
K["kb / OKF / requirements (later)"]
end
subgraph umb["finos/morphir"]
IT["morphir itest"]
CF["CLI, config, kb suites"]
MCK["compatibility kit (later)"]
end
B --> G
L --> B
K --> G
IT --> B
CF --> B
MCK --> B
morphir-gherkin: formats
.feature is parsed with gherkin 0.16 and lowered into the model. Two parts are read from the source instead, because gherkin 0.16 changes them:
- Descriptions (feature, rule, background, scenario, examples): the reader takes the raw lines by span, removes only their common indent, and parses them as Markdown. A fenced block in a description keeps every line, every blank line and its span.
- Doc strings:
gherkin0.16 returns the content type as the first line of the text and keeps the indent ("ion\n (ref 'morphir/SDK:basics#add')\n"). The reader takes the content type from the opening delimiter ("""ionor```ion), and it takes the body lines by span, with the delimiter's indent removed, as the Gherkin reference parsers do.
When the model is lowered for cucumber, each step's docstring is the corrected body.
Which format to use: .feature is the default. It is what most platforms and tools read, and the compatibility kit uses it so that every binding's own tooling can read a case. .feature.md suits suites that are documentation first, such as itest examples, where prose and rendered Markdown matter more than tool reach.
.feature.md follows Cucumber's official Markdown with Gherkin (MDG) rules, so other Cucumber tools can read the files:
#,##and###headings carryFeature:,Rule:,Background:,Scenario:,Scenario Outline:andExamples:;*or-list items are steps;- a Markdown table under a step is its data table;
- a fenced block under a step is its doc string, and its language is the doc string's content type;
- tags follow the MDG rules.
This draft adds one rule. A fenced block that is not under a step is a free fence. It is passed to a fence extension by its info string. Every other Markdown block is prose. In a .feature file, a fenced block in any description is a free fence too.
Options are tags and step text
Options use native Gherkin, so every Gherkin tool (editors, Cucumber's own tag filters, reporters) understands them without Morphir's extensions:
- An option of a feature, rule, scenario or examples block is a tag. A namespaced tag carries a value:
@syntax:elm,@node:Value,@version:4. A plain tag is a flag:@wip,@pending,@spelling. - An option of one step's data is part of the step text, for example
Given the tree file "pkg/acme/orders/domain/user.type":orThen stdout at "$.result" should match the golden file "out.json".
Free fences stay an extension point for data that has no Gherkin shape, but no suite in this draft needs them for options.
# Feature: Migrate keeps a module's public face
`@syntax:elm`
The order module is the reference example. It **MUST** keep every exposed signature.
## Scenario: v3 to v4
* When I run "morphir migrate orders.json --output out.json --target-version v4"
* Then the IR value "main#total" should have signature "List Order -> Decimal"
* And the IR type "main#order-status" should be defined as:
```elm
type OrderStatus
= Pending
| Shipped Date
```
The same data in a .feature file:
@syntax:elm
Feature: Migrate keeps a module's public face
The order module is the reference example. It MUST keep every exposed signature.
Scenario: v3 to v4
When I run "morphir migrate orders.json --output out.json --target-version v4"
Then the IR value "main#total" should have signature "List Order -> Decimal"
morphir-gherkin: model
// sketch
pub struct Document { pub path: PathBuf, pub format: Format, pub feature: Option<Feature> }
pub enum Format { Feature, Markdown }
pub struct Feature {
pub name: String, pub tags: Vec<Tag>, pub description: Description,
pub background: Option<Background>, pub rules: Vec<Rule>, pub scenarios: Vec<Scenario>, pub span: Span,
}
pub struct Description { pub prose: Prose, pub fences: Vec<Fence> } // in source order
pub struct Scenario { /* name, keyword, tags, description, steps, examples, span */ }
pub struct Step { pub keyword: Keyword, pub text: String, pub argument: Option<StepArgument>, pub span: Span }
pub enum StepArgument { DocString(DocString), Table(Table) }
pub struct Fence { pub info: FenceInfo, pub body: String, pub span: Span }
pub struct Prose { pub blocks: Vec<ProseBlock> } // parsed Markdown with spans
- Every node has a
Span, which gives the byte range and the line and column in the original file. - Fence info string:
<language> [key=value …]. The fence body is kept exactly as written. - Prose: paragraphs, lists, quotes and headings below the Gherkin levels. Inline content stays parsed: links, inline code, emphasis. The parser is
pulldown-cmark0.13, which itest andmorphir-okfalready use.
morphir-gherkin: navigation
- Visitor: enter and exit callbacks for each node kind (
visit_feature,visit_rule,visit_scenario,visit_step,visit_fence,visit_prose_blockand more). By default each callback walks its children, as the traversal inmorphir-coredoes. - Cursor: a
NodePathsuch asfeature/rule[1]/scenario[2]/step[3]. It hasparent,childrenandsiblings, andDocument::at(span)finds the node at a source position. It serves error messages and editor tooling. - Conversion:
.feature.mdconverts to.featuretext, with a line map back to the Markdown source, for tools that read only plain Gherkin.
morphir-gherkin: extensions and context
// sketch
pub struct Context { /* typed component map */ }
impl Context {
pub fn insert<T: Component>(&mut self, value: T);
pub fn get<T: Component>(&self) -> Option<&T>;
pub fn get_mut<T: Component>(&mut self) -> Option<&mut T>;
}
pub enum Scope { Feature, Rule, Scenario }
pub enum Effect { Continue, Skip(String) }
pub trait TagExtension: Send + Sync {
fn matches(&self, tag: &Tag) -> bool;
fn apply(&self, tag: &Tag, scope: Scope, ctx: &mut Context) -> Result<Effect, ExtensionError>;
}
pub trait FenceExtension: Send + Sync {
fn matches(&self, info: &FenceInfo) -> bool;
fn apply(&self, fence: &Fence, scope: Scope, ctx: &mut Context) -> Result<(), ExtensionError>;
}
pub trait ProseExtension: Send + Sync {
fn apply(&self, prose: &Prose, scope: Scope, ctx: &mut Context) -> Result<(), ExtensionError>;
}
pub trait Processor: Send + Sync {
fn process(&self, doc: &Document, at: &NodePath, ctx: &mut Context) -> Result<(), ExtensionError>;
}
pub struct Extensions { /* ordered registries of the four kinds */ }
impl Extensions {
pub fn context_for(&self, doc: &Document, scenario: &NodePath) -> Result<Context, Vec<ExtensionError>>;
}
- Order: extensions apply from the outside in: feature, then rule, then scenario. So a scenario's
@syntax:gleamoverrides its feature's@syntax:elm, and the same holds for free fences. - Errors: every
ExtensionErrorcarries theNodePathand span of the tag, fence or sentence that caused it. A failing extension fails its scenario; it never fails silently. - Tags: a tag that no extension claims stays a label. An extension may own a tag namespace (such as
@syntax:). A tag in an owned namespace that the extension does not understand is an error, so a typo does not pass. - Owners: extensions live with their owners. For example,
@wipinmorphir-bdd,@syntax:<id>inmorphir-syntax, an RFC 2119 requirement extension in a later requirements crate, and a kb-link extension inmorphir-okf.
morphir-bdd: execution
-
Parser: a custom cucumber
Parserfinds*.featureand*.feature.mdfiles and reads them withmorphir-gherkin. It lowers each document into agherkin::Featureand keeps the original line numbers, so cucumber's messages point into the source file. -
World: one world type for every suite:
// sketch#[derive(Debug, cucumber::World)]#[world(init = MorphirWorld::new)]pub struct MorphirWorld {pub context: Context, // from extensions and from earlier stepspub scenario: ScenarioRef, // the Document and NodePath of the running scenario}A before hook fills
contextwithExtensions::context_for. Skip effects are decided when features are filtered, so a skipped scenario never starts. -
Reusable steps: any crate writes steps against
MorphirWorldand reads the components it needs:// sketch, in morphir-inspect behind the cargo feature "steps"#[then(expr = "the IR value {string} should have signature {snippet}")]fn value_signature(world: &mut MorphirWorld, selector: String, snippet: Snippet) -> StepResult {let ir = world.context.get::<OpenIr>().ok_or(missing("an IR opened by `the IR \"<path>\"`"))?;let syntax = snippet.language_or(world.context.get::<DefaultSyntax>())?;inspect::matches(®ISTRY, syntax, ir.value(&selector)?.signature(), snippet.text())}- cucumber-rs collects steps for each world type through
inventory. Each step library exposes alink()function that a test binary calls, so the linker keeps the library's steps. The first implementation task is a spike that proves steps in another crate are collected. If they are not,morphir-bddregisters libraries explicitly through cucumber's step collection. - When a step needs a component that is missing, it fails and names the earlier step that provides that component.
- cucumber-rs collects steps for each world type through
-
Base step libraries:
- CLI process: run
morphir …with an isolatedHOME,XDG_CONFIG_HOMEand secrets. This replaces the copies inconfig_acceptance.rsandkb_acceptance.rs. Captures become named components. - Files: a temporary directory, "a file X containing:", and file checks.
- Output: stdout and stderr contains or equals, and JSON pointer checks. Every text mismatch prints a git-style unified diff.
- CLI process: run
-
Runner: one configuration for every suite:
// sketchmorphir_bdd::Suite::new("cli").features("tests/features").extensions(Extensions::standard().with(SyntaxTags)).run_and_exit::<MorphirWorld>().await;- Tags: a tag expression selects scenarios, from
--tagsorMORPHIR_BDD_TAGS. - Output: the console, plus JSON and JUnit in
.dev/out/bdd/<suite>.jsonand.xml. - CI: uploads both files for every run, including a failed one, and adds a job summary with the counts and the failures.
- Tags: a tag expression selects scenarios, from
morphir itest
morphir itest is the user-facing runner for morphir-bdd. It reads scenarios.md, .feature and .feature.md files under examples/ through Suite, and it keeps today's options (--tag, --filter, --list, --keep-temp), its PASS and FAIL lines, and its exit code.
A scenario's workspace, evaluator provider and overlay files are one yaml itest fence, {workspace?, provider?, files?}. In the Feature description it sets the whole document's workspace and provider; in a Scenario description it adds that scenario's own files, on top of the Feature's. A scenarios.md section's frontmatter and its morphir:file fences lower into this same fence.
itest's step kinds are step libraries with fixed step text:
| itest today | Step text |
|---|---|
Command with captures and stdout_json | When I run "<command>" with a <n> second timeout, then And I capture "<path>" as <json|text|exists> named "<name>" per capture, and And stdout is JSON when set |
Rego Assertion | Then the result should satisfy the policy rules "<entrypoints>": with a rego doc string, over morphir-opa |
Golden with select and line_endings | Then the file "<actual>" at "<select>" should match the golden file "<file>" with <exact|LF> line endings, or the same step ending : with a doc string in place of the file |
- Existing scenarios: a reader for itest's
scenarios.mdformat lowers each##section into the same model. Every existing example therefore runs unchanged. New examples are written as.feature.md, and old ones move over when they are next touched. - Golden steps:
feat/itest-goldenis rebased and landed first, so the golden step library starts from itsgolden.rs. - Notebooks: itest's notebook support is removed. That covers
scenario.ipynbdiscovery and running, and thenotebookmodule incrates/morphir/src/lib.rs, which only itest uses. The one notebook example,examples/elm/single-file/scenario.ipynb, becomes a.feature.mdfile. Notebook support returns later with the VFS work on document trees and workspaces.
Moving the existing suites
- The kit and itest, together: the kit's cases become
.featuresuites (see The compatibility kit on Gherkin), and itest runs onmorphir-bdd. - finos/morphir: the
cli,config_acceptanceandkb_acceptancemains move toSuiteandMorphirWorld, with the base steps. Their feature text does not change. - morphir-rust: its 21 feature suites move crate by crate, starting with
morphir-tests, the crate that already holds shared BDD tooling.
The compatibility kit on Gherkin
The kit's 131 cases move from its custom Markdown grammar (spec/ir/mck/*.md) to plain .feature files. Plain Gherkin is read by more platforms and tools than Markdown with Gherkin, and every binding's own test tooling can read a kit case. A doc string's content type ("""yaml, """json, """ion) names the format of the document it holds. values-0003 today:
## values-0003: Reference shorthand {node=Value}
```yaml canonical
Reference: morphir/SDK:basics#add
```
```json canonical
{ "Reference": "morphir/SDK:basics#add" }
```
```json accepted
"morphir/SDK:basics#add"
```
The same case, in the real spec/ir/mck/values.feature. A case's canonical checks and its accepted-input checks are different fence roles, so they become two scenarios, both named <id> <title>; each holds a run of one-line fences of one role, so each becomes a scenario outline with one Examples row per fence:
@node:Value
Feature: Values
Scenario Outline: values-0003 Reference shorthand
Then its canonical <format> spelling is <spelling>
Examples:
| format | spelling |
| YAML | Reference: morphir/SDK:basics#add |
| JSON | { "Reference": "morphir/SDK:basics#add" } |
Scenario Outline: values-0003 Reference shorthand
Then a reader of <format> accepts <input>
Examples:
| format | input |
| JSON | "morphir/SDK:basics#add" |
| JSON | { "Reference": { "attributes": {}, "fqname": "morphir/SDK:basics#add" } } |
@node:Value sits on the Feature line, not on either scenario, because every case in values.feature shares it; a file whose cases carry different node kinds, such as versions.feature, keeps @node: on each scenario instead. The full step vocabulary, including warnings, a different node kind and document-tree sets, is in the kit draft.
- Mapping: a case becomes one or more scenarios, each named
<id> <title>; a case file is a feature. Heading keys become tags:node=→@node:<Kind>,version=→@version:<n>,status=pending→@pending,compare=attributes→@compare:attributes. A@node:,@version:or@compare:tag that every scenario of the file shares moves to theFeatureline instead of repeating on each one. Fence order is kept within a case, so the parity replay sends the same requests in the same order the legacy engine did. A run of two or more consecutive one-line fences of the same role and the same keys becomes oneScenario Outline, oneExamplesrow per fence; a single one-line fence, or a fence whose document spans several lines, becomes a step of a plain scenario instead, in fence order. A document-tree case's fences becomeGiven the tree file "<path>" in set "<set>":steps, one per file, followed byThen the "<set>" tree reads back as the canonical formand the outline or plain-scenario steps for its own canonical and accepted checks. - Steps: the kit's steps are a step library in
morphir-mck. The first step of a case runs the whole case once: it sends each of the case's requests to the adapter over the existing protocol, in fence order, so adapters do not change. Each step then checks the records of its own fence, one record for each path mode. - Commands:
morphir mck runbecomes aSuiteoverspec/ir/mck/*.feature. It adds the kit step library and the adapter, as a component started from--adapter.- It still writes the MCK report (v1 and v2) and the HTML report. The report records come from the case runs, one for each fence and path mode, in the same order as the legacy engine; a step passes or fails on the records of its fence.
morphir mck checkvalidates the cases with themorphir-gherkinmodel alone, without an adapter.
- Conversion: a one-off converter rewrites every case file.
- The old and the new engines then run side by side in CI until the new engine gives the same report records as the old one for every case and both adapters.
- During the window the kit holds both the Markdown files and their
.featuretwins, so a CLI that reads only Markdown still reads the kit. The kit manifest'sdriverContractstays1(kit manifest). - After that, the old grammar and its engine code are removed, and the frozen baselines are recorded again under the append-only rule. That removal changes how a runner reads a kit incompatibly: a kit of
.featurefiles only has no case a Markdown-only CLI can find. So the same change moves the CLI's driver contract to2, and the kit manifest'sdriverContractrange to[2]. An older CLI then refuses the kit before it starts an adapter, instead of finding no cases. - The later kit changes build on the Gherkin form: Ion as the reference encoding, spelling and semantic tags, and the round trip (kit draft).
Testing
- morphir-gherkin:
- Golden models of
.featureand.feature.mdfiles. They cover every Gherkin construct: rule, background, outline, examples, tags, doc string and table. - A fence with indentation-sensitive YAML in a
.featuredescription keeps every line and blank line, with correct spans. - A doc string with a content type (
"""ion,```yaml) gives the content type separately and a body without the delimiter's indent, including indentation-sensitive YAML. - Spans and
Document::atreturn the right node for a given position. - The visitor visits nodes in the right order; the cursor's moves work.
- Converting
.feature.mdto.featuregives the samegherkin::Feature, and the line map is correct. - Extension order and overrides, owned tag namespaces, and errors that carry spans.
- Golden models of
- morphir-bdd:
- The spike for linking steps across crates.
- A suite whose steps come from two crates.
- Skip effects.
- Tag expressions.
- The JSON and JUnit files.
- A failing scenario that prints a unified diff.
- The CLI base steps in an isolated environment.
- The kit:
- The converter's output: every case of every file becomes one or more scenarios, in fence order, with the same checks; a run of same-role one-line fences becomes a scenario outline.
- Parity: the new engine gives the same report records as the old one for every case, against the Rust and TypeScript adapters.
mck checkfinds each rule violation in a.featurecase file, with its span.
- itest:
- Every existing
scenarios.mdexample gives the same PASS and FAIL result throughmorphir-bddas before. --list,--filterand--taggive the same output as before.- The converted notebook example passes.
- Every existing
Delivery
- Rebase and land
feat/itest-golden. - morphir-rust:
morphir-gherkin(formats, model, navigation, extensions). - morphir-rust:
morphir-bdd(the linking spike first, then the parser, world, runner and base steps). - finos/morphir, together:
- the kit on Gherkin: the step library,
mck runandmck checkon the foundation, the converter, and the parity window; morphir itestonmorphir-bdd, with thescenarios.mdreader, the step libraries, and the removal of notebook support.
- the kit on Gherkin: the step library,
- finos/morphir: move the three cucumber mains onto
Suite. - morphir-rust: move its feature suites, crate by crate.
Then the rest of the kit draft, syntax and inspect, and the Ion sweep build on top.
Alternatives considered
- Our own runner. It gives full control over scheduling and reporting. But it would rebuild cucumber-rs's runner, tag filters and writers. The model layer is separate from execution, so the runner can still change later.
- The model layer only. Each tool would keep its own execution, and the three testing systems would stay apart.
- A Morphir-specific Markdown shape instead of MDG. It would fit itest's
scenarios.mdmore closely, but other Cucumber tools could not read the files. Thescenarios.mdreader covers the existing examples instead.
Open questions
- Step libraries across the two workspaces. finos/morphir and morphir-rust are separate Cargo workspaces. A step library in morphir-rust is reachable from finos/morphir through the submodule path dependency, as other morphir-rust crates are. The spike should confirm that
inventorycollection also works across that boundary. - Tag expression syntax. cucumber-rs's filter takes a closure.
morphir-bddneeds a parser for Cucumber tag expressions (@a and not @b).gherkin0.16 has atagexprmodule; the plan should check whether it is enough.