From 2181cb7efe2e9c76fca880cd553cd99326fd1f47 Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 14:16:14 -0500 Subject: [PATCH] fix: parse scenarios on every non-singular spec kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CarriesScenarios() gated scenario parsing to story/domain, so a `kind: protocol` spec's `` was never parsed into Spec.Scenarios. It never entered the declared set, so a test's `// [scenario…]` binding joined to nothing and Join reported it as a Dangling binding — a spurious D12 violation. Trove blocks on owning scenarios from protocol specs. Gate on `!k.Singular()` instead: every per-file behavioral contract kind (story, domain, protocol, use-case, flow, view-model, command, error) carries scenarios; the singular cross-cutting doc kinds (narrative, architecture, design-system, conventions) stay excluded. That exclusion is load-bearing — specs/CONVENTIONS.md carries an illustrative `` that must never be parsed as a real declaration. Regression: TestVerifyProtocolScenarioJoins drives the real Verify path over a kind: protocol fixture + a Go `// [scenario…]` binding and asserts the scenario joins (no Dangling), passes, and locks. TestCarriesScenarios pins the non-singular contract. --- internal/engine/verify_run_test.go | 55 ++++++++++++++++++++++++++++ internal/specmodel/parse.go | 2 +- internal/specmodel/specmodel.go | 12 ++++-- internal/specmodel/specmodel_test.go | 38 +++++++++++++++---- 4 files changed, 95 insertions(+), 12 deletions(-) diff --git a/internal/engine/verify_run_test.go b/internal/engine/verify_run_test.go index f4285bfc9b..6dbf83fd55 100644 --- a/internal/engine/verify_run_test.go +++ b/internal/engine/verify_run_test.go @@ -150,6 +150,61 @@ func TestVerifyStrictFlagsUntaggedTest(t *testing.T) { } } +// A kind: protocol spec that declares a scenario (heading + sub-id comment), and +// a Go test that binds it with a leading // [scenario.] comment. Protocol is a +// behavioral contract kind, so its scenarios must be parsed and joinable just like +// a story's — not reported as a dangling binding to an undeclared scenario (D12). +const protocolSpec = `--- +id: protocol.troved.search +kind: protocol +--- + +# Protocol: troved search + +## Acceptance Criteria + +### Scenario 1: search returns mapped results + + + +- Given an indexed corpus +- When a query matches +- Then results are mapped +` + +const protocolSource = "// [scenario.troved.search.returns-mapped-results]\nfunc TestSearchReturnsMappedResults(t *testing.T) {}\n" +const protocolReport = "{\"Action\":\"pass\",\"Package\":\"x\",\"Test\":\"TestSearchReturnsMappedResults\"}\n" + +// A protocol spec's scenario joins to its source-bound test and verifies green — +// it must not be reported as a dangling binding to an undeclared scenario, which is +// what happened when scenario parsing was gated to story/domain only (D12). +// +// SPEC: story.engine.verify (scenario.engine.verify.source-bound-join) +func TestVerifyProtocolScenarioJoins(t *testing.T) { + root := t.TempDir() + writeSpecFile(t, root, "specs/protocol/troved.search.md", protocolSpec) + writeSpecFile(t, root, "go/troved_test.go", protocolSource) + writeSpecFile(t, root, "go/report.gotest.json", protocolReport) + + cfg := VerifyConfig{Format: "gotest", Report: "go/report.gotest.json", Source: "go"} + v, locked, err := Verify(root, "go", cfg) + if err != nil { + t.Fatal(err) + } + if len(v.Dangling) != 0 { + t.Fatalf("a protocol scenario's binding must join, not dangle (D12): %+v", v.Dangling) + } + if !contains(v.Passed, "scenario.troved.search.returns-mapped-results") { + t.Errorf("expected the protocol scenario in Passed, got %+v", v.Passed) + } + if !v.Green() { + t.Fatalf("expected green: %+v", v) + } + if len(locked) != 1 || locked[0] != "protocol.troved.search" { + t.Fatalf("expected protocol.troved.search locked, got %v", locked) + } +} + // SPEC: story.engine.lock (scenario.engine.lock.no-write-on-red) func TestVerifyRedWritesNoLock(t *testing.T) { root := setupVerifyProject(t, junitReport(true, false)) // scenario b fails diff --git a/internal/specmodel/parse.go b/internal/specmodel/parse.go index a945199d89..5b3dc67c2d 100644 --- a/internal/specmodel/parse.go +++ b/internal/specmodel/parse.go @@ -10,7 +10,7 @@ import ( type Spec struct { Frontmatter Path string // slash path within the library FS - Scenarios []Scenario // populated for stories (Gherkin) and domains (acceptance bullets) + Scenarios []Scenario // populated for every non-singular kind (Gherkin headings or acceptance bullets) } // Scenario is a Gherkin scenario heading and its declared sub-ID (empty if the diff --git a/internal/specmodel/specmodel.go b/internal/specmodel/specmodel.go index 17724e20f1..795e7ec9e7 100644 --- a/internal/specmodel/specmodel.go +++ b/internal/specmodel/specmodel.go @@ -64,10 +64,16 @@ func (k Kind) Prefix() string { } // CarriesScenarios reports whether a spec of this kind declares scenarios the -// engine parses: stories (Gherkin `## Scenario` headings) and domains (the -// `- [scenario.id]` acceptance-criterion bullet form on a foundational contract). +// engine parses and joins to tests (D12). Every non-singular kind is a per-file +// behavioral contract that may carry scenarios — stories and domains, and equally +// protocols, use-cases, flows, view-models, commands, and errors — in either the +// Gherkin `## Scenario` heading form or the `- [scenario.id]` acceptance-bullet +// form. The singular cross-cutting doc kinds (narrative, architecture, +// design-system, conventions) do not: their files are prose, and CONVENTIONS.md +// itself contains an illustrative `` that must never be +// parsed as a real declaration. func (k Kind) CarriesScenarios() bool { - return k == KindStory || k == KindDomain + return !k.Singular() } // Valid reports whether k is a member of the closed taxonomy. diff --git a/internal/specmodel/specmodel_test.go b/internal/specmodel/specmodel_test.go index 427541cdc4..5b4fd3fbb3 100644 --- a/internal/specmodel/specmodel_test.go +++ b/internal/specmodel/specmodel_test.go @@ -25,20 +25,42 @@ func TestKindPrefixes(t *testing.T) { KindConventions: "", // singular cross-cutting kind: ID is just the kind name } - // command is in the closed taxonomy (CLI command behavior specs), and a domain - // spec carries scenarios (the acceptance-bullet form) just like a story. + // command is in the closed taxonomy (CLI command behavior specs). if !KindCommand.Valid() { t.Error("KindCommand must be in the closed Kinds taxonomy") } - if !KindStory.CarriesScenarios() || !KindDomain.CarriesScenarios() { - t.Error("story and domain must carry scenarios") - } - if KindCommand.CarriesScenarios() || KindConventions.CarriesScenarios() { - t.Error("only story/domain carry scenarios") - } for k, want := range cases { if got := k.Prefix(); got != want { t.Errorf("%s.Prefix() = %q, want %q", k, got, want) } } } + +// CarriesScenarios is the scenario-parsing gate. Every non-singular spec kind is a +// per-file behavioral contract that may declare scenarios the engine joins — +// story/domain (already) plus protocol and the other behavioral kinds. The singular +// cross-cutting doc kinds (narrative, architecture, design-system, conventions) do +// not: CONVENTIONS.md itself carries an *illustrative* ``, +// which must never be parsed as a real declaration. +func TestCarriesScenarios(t *testing.T) { + carries := []Kind{ + KindStory, KindDomain, KindProtocol, KindUseCase, + KindFlow, KindViewModel, KindCommand, KindError, + } + for _, k := range carries { + if !k.CarriesScenarios() { + t.Errorf("%s is a behavioral (non-singular) kind and must carry scenarios", k) + } + if k.Singular() { + t.Errorf("%s must not be Singular()", k) + } + } + for _, k := range []Kind{KindNarrative, KindArchitecture, KindDesignSystem, KindConventions} { + if k.CarriesScenarios() { + t.Errorf("%s is a singular cross-cutting doc kind and must not carry scenarios", k) + } + if !k.Singular() { + t.Errorf("%s must be Singular()", k) + } + } +}