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) + } + } +}