Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions internal/engine/verify_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>] 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

<!-- id: scenario.troved.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
Expand Down
2 changes: 1 addition & 1 deletion internal/specmodel/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions internal/specmodel/specmodel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- id: scenario… -->` 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.
Expand Down
38 changes: 30 additions & 8 deletions internal/specmodel/specmodel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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* `<!-- id: scenario… -->`,
// 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)
}
}
}
Loading