diff --git a/Source/DotNET/Screenplay.Specs/IntegrationTesting.cs b/Source/DotNET/Screenplay.Specs/IntegrationTesting.cs
new file mode 100644
index 000000000..ce10be200
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/IntegrationTesting.cs
@@ -0,0 +1,111 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay;
+
+///
+/// Declares the testing surface a Chronicle integration specification is written against, as source.
+///
+///
+/// The generator recognizes these by the fully qualified name of the type declaring each member, which is exactly
+/// what lets it read a specification without the testing packages being referenced. Declaring them here rather than
+/// referencing them keeps every specification about reading them hermetic, and asks the recognition the only
+/// question worth asking of it - whether the names alone are enough.
+///
+public static class IntegrationTesting
+{
+ ///
+ /// The path the surface is compiled as.
+ ///
+ public const string Path = "Library/Testing/IntegrationTesting.cs";
+
+ ///
+ /// The source of the surface.
+ ///
+ public const string Source = """
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using Cratis.Chronicle.Events;
+ using Cratis.Chronicle.EventSequences;
+
+ namespace Cratis.Arc.Testing.Commands
+ {
+ public class CommandScenario
+ {
+ public Cratis.Arc.Chronicle.Testing.Commands.CommandScenarioChronicleGivenBuilder Given => new();
+
+ public IEventSequence EventSequence => null!;
+
+ public Task Execute(TCommand command) => Task.FromResult(new Result());
+
+ public Task Validate(TCommand command) => Task.FromResult(new Result());
+ }
+
+ public class Result
+ {
+ public bool IsSuccess => true;
+ }
+ }
+
+ namespace Cratis.Arc.Chronicle.Testing.Commands
+ {
+ public class CommandScenarioChronicleGivenBuilder
+ {
+ public CommandScenarioSourceGivenBuilder ForEventSource(EventSourceId eventSourceId) => new();
+ }
+
+ public class CommandScenarioSourceGivenBuilder
+ {
+ public void Events(params object[] events)
+ {
+ }
+
+ public void ReadModel(TReadModel readModel)
+ where TReadModel : class
+ {
+ }
+ }
+ }
+
+ namespace Cratis.Chronicle.XUnit.Integration
+ {
+ public static class HttpClientExtensions
+ {
+ public static Task ExecuteCommand(
+ this HttpClient client,
+ string requestUri,
+ TCommand command) => Task.FromResult(new Cratis.Arc.Testing.Commands.Result());
+ }
+ }
+
+ namespace Cratis.Chronicle.Testing.EventSequences
+ {
+ public static class EventSequenceShouldExtensions
+ {
+ public static void ShouldHaveAppendedEvent(this IEventSequence sequence, EventSourceId eventSourceId)
+ {
+ }
+
+ public static void ShouldHaveTailSequenceNumber(this IEventSequence sequence, int expected)
+ {
+ }
+
+ public static void ShouldBeSuccessful(this Cratis.Arc.Testing.Commands.Result result)
+ {
+ }
+
+ public static void ShouldNotBeSuccessful(this Cratis.Arc.Testing.Commands.Result result)
+ {
+ }
+
+ public static void ShouldHaveConstraintViolationFor(this Cratis.Arc.Testing.Commands.Result result, string constraintName)
+ {
+ }
+
+ public static void ShouldBeFalse(this bool value)
+ {
+ }
+ }
+ }
+ """;
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_against_a_running_host.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_against_a_running_host.cs
new file mode 100644
index 000000000..f3e6a15e2
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_against_a_running_host.cs
@@ -0,0 +1,87 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// The other shape Arc documents drives a running host rather than the pipeline in process: what the event log
+/// already held is appended to it directly, the command goes over HTTP, and the steps live on a nested context with
+/// only the assertions left outside. It says the same thing as the in-process shape and has to read as the same
+/// thing, or a document would describe an application differently depending on how it was specified.
+///
+public class a_specification_against_a_running_host : Specification
+{
+ const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Scenario = """
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using Cratis.Chronicle.EventSequences;
+ using Cratis.Chronicle.Testing.EventSequences;
+ using Cratis.Chronicle.XUnit.Integration;
+ using Library.Authors.Registration;
+ using Xunit;
+
+ namespace Library.Authors.Registration.when_registering;
+
+ public class and_the_name_is_already_taken
+ {
+ public class context
+ {
+ public const string AuthorName = "Jane Austen";
+
+ protected IEventLog EventLog = null!;
+ protected HttpClient Client = null!;
+
+ async Task Establish() => await EventLog.Append("author", new AuthorRegistered(AuthorName));
+
+ async Task Because() => await Client.ExecuteCommand("/api/authors/register", new RegisterAuthor(AuthorName));
+ }
+
+ readonly IEventSequence _sequence = null!;
+
+ [Fact] void should_register_the_author() => _sequence.ShouldHaveAppendedEvent("author");
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Registration/when_registering/and_the_name_is_already_taken.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+ SpecificationModel _specification;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(_sources);
+ _specification = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Single();
+ }
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_read_the_steps_written_on_the_nested_context() => _specification.Given.Single().Name.ShouldEqual("AuthorRegistered");
+ [Fact] void should_state_the_value_appended_to_the_log() => _specification.Given.Single().Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen"))]);
+ [Fact] void should_issue_the_command_sent_over_http() => _specification.When.Name.ShouldEqual("RegisterAuthor");
+ [Fact] void should_state_the_values_the_command_was_sent_with() => _specification.When.Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen"))]);
+ [Fact] void should_read_the_assertions_left_outside_the_context() => _specification.Then.Single().Name.ShouldEqual("AuthorRegistered");
+ [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_expecting_a_rejection.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_expecting_a_rejection.cs
new file mode 100644
index 000000000..4d21f9698
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_expecting_a_rejection.cs
@@ -0,0 +1,93 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A command being turned down is half of what a slice is specified for, and the language says it as a rejection
+/// with a reason. A source naming the reason - the constraint that held - has it carried across; a source saying
+/// only that the command did not go through has no reason to carry, and a made up sentence would describe an
+/// application nobody wrote. The scenario is named after the words the source uses for it, so the reason is already
+/// said there.
+///
+public class a_specification_expecting_a_rejection : Specification
+{
+ const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Scenario = """
+ using System.Threading.Tasks;
+ using Cratis.Arc.Testing.Commands;
+ using Cratis.Chronicle.Testing.EventSequences;
+ using Library.Authors.Registration;
+ using Xunit;
+
+ namespace Library.Authors.Registration.when_registering;
+
+ public class and_the_name_is_taken
+ {
+ public const string UniqueAuthorName = "unique-author-name";
+
+ readonly CommandScenario _scenario = new();
+ Result _result = null!;
+
+ async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Jane Austen"));
+
+ [Fact] void should_not_register_the_author() => _result.ShouldHaveConstraintViolationFor(UniqueAuthorName);
+ }
+
+ public class and_nothing_was_given
+ {
+ readonly CommandScenario _scenario = new();
+ Result _result = null!;
+
+ async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Jane Austen"));
+
+ [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful();
+ [Fact] void should_say_so_on_the_result() => _result.IsSuccess.ShouldBeFalse();
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Registration/when_registering/and_the_name_is_taken.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+ SpecificationModel _named;
+ SpecificationModel _unnamed;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(_sources);
+ var specifications = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.ToList();
+ _named = specifications.Single(_ => _.Name.EndsWith("and_the_name_is_taken", StringComparison.Ordinal));
+ _unnamed = specifications.Single(_ => _.Name.EndsWith("and_nothing_was_given", StringComparison.Ordinal));
+ }
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_read_both_scenarios_written_in_one_file() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Count().ShouldEqual(2);
+ [Fact] void should_carry_across_the_reason_the_source_names() => _named.Errors.ShouldContainOnly(["unique-author-name"]);
+ [Fact] void should_expect_no_event_alongside_a_rejection() => _named.Then.ShouldBeEmpty();
+ [Fact] void should_state_a_rejection_the_source_names_no_reason_for() => _unnamed.Errors.ShouldContainOnly([string.Empty]);
+ [Fact] void should_state_two_ways_of_saying_the_same_rejection_once() => _unnamed.Errors.Count().ShouldEqual(1);
+ [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_collaborator.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_collaborator.cs
new file mode 100644
index 000000000..e7e32794e
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_collaborator.cs
@@ -0,0 +1,78 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// Most of the specifications an application carries stand a collaborator up behind a substitute and say what it was
+/// asked to do. That is a statement about the inside of a slice rather than about its behavior, so the language has
+/// nowhere to put it - and reporting each one would say a gap exists where none does. They are passed over entirely,
+/// which is only safe because what is read is decided by what a specification touches rather than by where it sits.
+///
+public class a_specification_of_a_collaborator : Specification
+{
+ const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Scenario = """
+ using Xunit;
+
+ namespace Library.Authors.Registration.when_registering;
+
+ public interface INameFormatter
+ {
+ string Format(string name);
+ }
+
+ public class and_the_name_is_formatted
+ {
+ string _result = string.Empty;
+
+ void Because() => _result = new Formatter().Format("jane austen");
+
+ [Fact] void should_capitalize_the_name() => _result.ShouldEqualTheExpected("Jane Austen");
+ }
+
+ public class Formatter : INameFormatter
+ {
+ public string Format(string name) => name;
+ }
+
+ public static class Assertions
+ {
+ public static void ShouldEqualTheExpected(this string actual, string expected)
+ {
+ }
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Registration/when_registering/and_the_name_is_formatted.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(_sources);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_specify_the_slice_by_nothing() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.ShouldBeEmpty();
+ [Fact] void should_report_no_scenario_left_out() => _analysis.Diagnostics.Where(_ => _.Code == ScreenplayDiagnosticCodes.UnreadableSpecification).ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_slice.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_slice.cs
new file mode 100644
index 000000000..bc8d41d10
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_of_a_slice.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// The scenarios a slice is specified by sit in a folder beneath it, which is a namespace beneath its own, and are
+/// written to a convention rigid enough to read: what had happened is stated against the scenario, the command is
+/// issued through it, and each assertion says one thing about what followed. Reading them is what turns a document
+/// stating what a slice does into one carrying the examples proving it.
+///
+public class a_specification_of_a_slice : Specification
+{
+ const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Arc.Queries.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [ReadModel]
+ public record Author(string Id, string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name, int Age)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Scenario = """
+ using System.Threading.Tasks;
+ using Cratis.Arc.Testing.Commands;
+ using Cratis.Chronicle.Testing.EventSequences;
+ using Library.Authors.Registration;
+ using Xunit;
+
+ namespace Library.Authors.Registration.when_registering;
+
+ public class and_the_author_is_new
+ {
+ readonly CommandScenario _scenario = new();
+
+ void Establish()
+ {
+ _scenario.Given.ForEventSource("author").Events(new AuthorRegistered("Jane Austen"));
+ _scenario.Given.ForEventSource("author").ReadModel(new Author("author", "Jane Austen"));
+ }
+
+ async Task Because() => await _scenario.Execute(new RegisterAuthor("Mary Shelley", 42));
+
+ [Fact] void should_register_the_author() => _scenario.EventSequence.ShouldHaveAppendedEvent("author");
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Registration/when_registering/and_the_author_is_new.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+ SpecificationModel _specification;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(_sources);
+ _specification = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Single();
+ }
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_put_it_under_the_slice_it_specifies() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Count().ShouldEqual(1);
+ [Fact] void should_name_it_after_every_word_between_the_slice_and_itself() => _specification.Name.ShouldEqual("when_registering_and_the_author_is_new");
+ [Fact] void should_start_from_the_event_that_had_happened() => _specification.Given.First().Name.ShouldEqual("AuthorRegistered");
+ [Fact] void should_know_the_event_it_starts_from_is_an_event() => _specification.Given.First().Kind.ShouldEqual(SpecificationStateKind.Event);
+ [Fact] void should_state_the_values_of_the_event_it_starts_from() => _specification.Given.First().Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen"))]);
+ [Fact] void should_start_from_the_read_model_that_was_pinned() => _specification.Given.Last().Kind.ShouldEqual(SpecificationStateKind.ReadModel);
+ [Fact] void should_state_the_values_of_the_read_model() => _specification.Given.Last().Values.Select(_ => _.Property).ShouldContainOnly(["Id", "Name"]);
+ [Fact] void should_issue_the_command() => _specification.When.Name.ShouldEqual("RegisterAuthor");
+ [Fact] void should_state_the_values_the_command_was_issued_with() => _specification.When.Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Mary Shelley")), new PropertyMappingModel("Age", new LiteralSource(42))]);
+ [Fact] void should_expect_the_event_the_assertion_names() => _specification.Then.Single().Name.ShouldEqual("AuthorRegistered");
+ [Fact] void should_expect_no_rejection() => _specification.Errors.ShouldBeEmpty();
+ [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_stating_a_value_that_is_code.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_stating_a_value_that_is_code.cs
new file mode 100644
index 000000000..70f7c6d4e
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_stating_a_value_that_is_code.cs
@@ -0,0 +1,82 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A scenario written in the host language routinely rests an identity on a value made at run time, which is exactly
+/// what a document stating values has no way to name. Unlike a step, a value stands on its own: leaving one out
+/// leaves the rest of the scenario saying what the source says, so it is left out and said rather than taking the
+/// scenario with it.
+///
+public class a_specification_stating_a_value_that_is_code : Specification
+{
+ const string Slice = """
+ using System;
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(Guid Id, string Name, int Age)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Scenario = """
+ using System;
+ using System.Threading.Tasks;
+ using Cratis.Arc.Testing.Commands;
+ using Cratis.Chronicle.Testing.EventSequences;
+ using Library.Authors.Registration;
+ using Xunit;
+
+ namespace Library.Authors.Registration.when_registering;
+
+ public class and_the_identity_is_made_at_run_time
+ {
+ readonly CommandScenario _scenario = new();
+ readonly Guid _id = Guid.NewGuid();
+ Result _result = null!;
+
+ async Task Because() => _result = await _scenario.Execute(new RegisterAuthor(_id, "Jane Austen", 41));
+
+ [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful();
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Registration/when_registering/and_the_identity_is_made_at_run_time.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+ SpecificationModel _specification;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(_sources);
+ _specification = _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.Single();
+ }
+
+ ScreenplayDiagnostic LeftOut() =>
+ _analysis.Diagnostics.Single(_ => _.Code == ScreenplayDiagnosticCodes.UnreadableSpecificationValue);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_keep_the_scenario() => _specification.When.Name.ShouldEqual("RegisterAuthor");
+ [Fact] void should_state_every_value_it_can_read() => _specification.When.Values.ShouldContainOnly([new PropertyMappingModel("Name", new LiteralSource("Jane Austen")), new PropertyMappingModel("Age", new LiteralSource(41))]);
+ [Fact] void should_leave_out_the_one_it_cannot() => _specification.When.Values.Select(_ => _.Property).ShouldNotContain("Id");
+ [Fact] void should_say_which_value_it_left_out() => LeftOut().Message.ShouldEqual("The value 'when_registering_and_the_identity_is_made_at_run_time' states for 'RegisterAuthor.Id' is code rather than a constant, so the scenario states everything but that value");
+ [Fact] void should_say_where_it_left_it_out() => LeftOut().Location.ShouldEqual("Library.Authors.Registration.when_registering.and_the_identity_is_made_at_run_time");
+ [Fact] void should_report_it_as_information() => LeftOut().Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Information);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_whose_steps_cannot_be_read.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_whose_steps_cannot_be_read.cs
new file mode 100644
index 000000000..1bace393b
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_specification_whose_steps_cannot_be_read.cs
@@ -0,0 +1,110 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis;
+
+namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing;
+
+///
+/// A scenario is one example, and an example missing the state it started from, the command it issued or the outcome
+/// it expects is a different example from the one the source states. Each of the three is left out whole and said
+/// so, which is the difference between a document with a known gap and one that is quietly wrong about what an
+/// application was specified against.
+///
+public class a_specification_whose_steps_cannot_be_read : Specification
+{
+ const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Scenario = """
+ using System.Threading.Tasks;
+ using Cratis.Arc.Testing.Commands;
+ using Cratis.Chronicle.Testing.EventSequences;
+ using Library.Authors.Registration;
+ using Xunit;
+
+ namespace Library.Authors.Registration.when_registering;
+
+ public class and_the_command_is_held_in_a_field
+ {
+ readonly CommandScenario _scenario = new();
+ RegisterAuthor _command = null!;
+ Result _result = null!;
+
+ void Establish() => _command = new RegisterAuthor("Jane Austen");
+
+ async Task Because() => _result = await _scenario.Execute(_command);
+
+ [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful();
+ }
+
+ public class and_what_it_starts_from_depends_on_a_condition
+ {
+ readonly CommandScenario _scenario = new();
+ Result _result = null!;
+
+ protected virtual bool AuthorAlreadyRegistered => true;
+
+ void Establish()
+ {
+ if (AuthorAlreadyRegistered)
+ {
+ _scenario.Given.ForEventSource("author").Events(new AuthorRegistered("Jane Austen"));
+ }
+ }
+
+ async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Mary Shelley"));
+
+ [Fact] void should_not_succeed() => _result.ShouldNotBeSuccessful();
+ }
+
+ public class and_nothing_it_expects_has_a_place_in_the_language
+ {
+ readonly CommandScenario _scenario = new();
+ Result _result = null!;
+
+ async Task Because() => _result = await _scenario.Execute(new RegisterAuthor("Mary Shelley"));
+
+ [Fact] void should_succeed() => _result.ShouldBeSuccessful();
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Authors/Registration/Registration.cs", Slice),
+ ("Library/Authors/Registration/when_registering/and_the_command_is_held_in_a_field.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(_sources);
+
+ IEnumerable LeftOut() =>
+ _analysis.Diagnostics.Where(_ => _.Code == ScreenplayDiagnosticCodes.UnreadableSpecification);
+
+ bool SaidOf(string scenario, string reason) =>
+ LeftOut().Any(_ => _.Message.Contains(scenario, StringComparison.Ordinal) && _.Message.Contains(reason, StringComparison.Ordinal));
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_leave_out_every_scenario_it_cannot_read() => _analysis.Model.Slices.Single(_ => _.Name == "Registration").Specifications.ShouldBeEmpty();
+ [Fact] void should_report_each_of_them() => LeftOut().Count().ShouldEqual(3);
+ [Fact] void should_say_a_command_put_together_elsewhere_cannot_be_read() => SaidOf("and_the_command_is_held_in_a_field", "the command it issues is put together somewhere this cannot read").ShouldBeTrue();
+ [Fact] void should_say_a_starting_point_under_a_condition_cannot_be_read() => SaidOf("and_what_it_starts_from_depends_on_a_condition", "only happens under a condition").ShouldBeTrue();
+ [Fact] void should_say_an_outcome_the_language_cannot_hold_cannot_be_read() => SaidOf("and_nothing_it_expects_has_a_place_in_the_language", "it expects no event and no rejection").ShouldBeTrue();
+ [Fact] void should_say_where_each_of_them_lives() => LeftOut().Select(_ => _.Location).ShouldContain("Library.Authors.Registration.when_registering.and_the_command_is_held_in_a_field");
+ [Fact] void should_report_them_as_warnings() => LeftOut().Select(_ => _.Severity).ShouldContainOnly([ScreenplayDiagnosticSeverity.Warning, ScreenplayDiagnosticSeverity.Warning, ScreenplayDiagnosticSeverity.Warning]);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_carrying_the_specifications_of_a_slice.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_carrying_the_specifications_of_a_slice.cs
new file mode 100644
index 000000000..336b40592
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayGenerator/when_generating/from_source_carrying_the_specifications_of_a_slice.cs
@@ -0,0 +1,96 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Screenplay;
+
+namespace Cratis.Arc.Screenplay.for_ScreenplayGenerator.when_generating;
+
+///
+/// A scenario recovered from source has to survive being written down: the name it is declared under, the events it
+/// starts from, the command it issues and the rejection it expects are each a line the language reads back, and a
+/// scenario is the one construct where the value of a property and the name of a step sit one indentation apart.
+/// This asks the whole way through, from source to printed text and back again through the compiler.
+///
+public class from_source_carrying_the_specifications_of_a_slice : Specification
+{
+ const string Slice = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Arc.Queries.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Invoicing.Issuing;
+
+ [EventType]
+ public record InvoiceIssued(string Number, int Lines);
+
+ [ReadModel]
+ public record Invoice(string Id, string Number);
+
+ [Command]
+ public record IssueInvoice(string Number, int Lines)
+ {
+ public InvoiceIssued Handle() => new(Number, Lines);
+ }
+ """;
+
+ const string Scenario = """
+ using System.Threading.Tasks;
+ using Cratis.Arc.Testing.Commands;
+ using Cratis.Chronicle.Testing.EventSequences;
+ using Library.Invoicing.Issuing;
+ using Xunit;
+
+ namespace Library.Invoicing.Issuing.when_issuing;
+
+ public class and_the_invoice_has_no_lines
+ {
+ public const string OneInvoicePerNumber = "one-invoice-per-number";
+
+ readonly CommandScenario _scenario = new();
+ Result _result = null!;
+
+ void Establish()
+ {
+ _scenario.Given.ForEventSource("invoice").Events(new InvoiceIssued("2026-1", 3));
+ _scenario.Given.ForEventSource("invoice").ReadModel(new Invoice("invoice", "2026-1"));
+ }
+
+ async Task Because() => _result = await _scenario.Execute(new IssueInvoice("2026-2", 0));
+
+ [Fact] void should_not_issue_the_invoice() => _result.ShouldHaveConstraintViolationFor(OneInvoicePerNumber);
+ }
+ """;
+
+ static readonly (string Path, string Text)[] _sources =
+ [
+ ("Library/Invoicing/Issuing/Issuing.cs", Slice),
+ ("Library/Invoicing/Issuing/when_issuing/and_the_invoice_has_no_lines.cs", Scenario),
+ (IntegrationTesting.Path, IntegrationTesting.Source)
+ ];
+
+ ScreenplayGenerationResult _result;
+ CompilationResult _compiled;
+ string _reprinted;
+
+ void Because()
+ {
+ _result = new ScreenplayGenerator().Generate(Analyzed.Compile(_sources), new ScreenplayOptions());
+ _compiled = new ScreenplayCompiler().Compile(_result.Source);
+ _reprinted = _compiled.Value is null ? string.Empty : new Cratis.Screenplay.Printing.ScreenplayPrinter().Print(_compiled.Value);
+ }
+
+ bool Says(string text) => _result.Source.Contains(text, StringComparison.Ordinal);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(_sources).ShouldBeEmpty();
+ [Fact] void should_produce_a_document_that_compiles() => _compiled.Success.ShouldBeTrue();
+ [Fact] void should_produce_a_document_the_compiler_says_nothing_about() => _compiled.Diagnostics.ShouldBeEmpty();
+ [Fact] void should_print_the_same_text_on_a_second_pass() => _reprinted.ShouldEqual(_result.Source);
+ [Fact] void should_declare_the_scenario_under_the_words_the_source_wrote() => Says("specification WhenIssuingAndTheInvoiceHasNoLines").ShouldBeTrue();
+ [Fact] void should_state_the_event_it_starts_from() => Says("given InvoiceIssued").ShouldBeTrue();
+ [Fact] void should_state_the_read_model_it_starts_from() => Says("given readmodel Invoice").ShouldBeTrue();
+ [Fact] void should_state_the_values_it_starts_from() => Says("number = \"2026-1\"").ShouldBeTrue();
+ [Fact] void should_state_the_command_it_issues() => Says("when IssueInvoice").ShouldBeTrue();
+ [Fact] void should_state_the_values_the_command_carries() => Says("lines = 0").ShouldBeTrue();
+ [Fact] void should_state_the_rejection_and_the_reason_named_for_it() => Says("then error \"one-invoice-per-number\"").ShouldBeTrue();
+ [Fact] void should_be_successful() => _result.IsSuccess.ShouldBeTrue();
+}
diff --git a/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs
index 70d00fc9f..300f172a8 100644
--- a/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs
+++ b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs
@@ -63,7 +63,8 @@ public ApplicationModelAnalysis Analyze(IReadOnlyList compilations,
whole.Elsewhere.Report(diagnostics);
TypesTheDocumentCannotName.Report(whole.Types, diagnostics, domain);
- var slices = SliceUnion.Of(projects.SelectMany(_ => _.Slices), diagnostics);
+ var joined = SliceUnion.Of(projects.SelectMany(_ => _.Slices), diagnostics);
+ var slices = Specified(projects, joined, diagnostics);
var imports = ExternalEvents.Resolve(ordered, slices, diagnostics);
NamespacesWithoutStructure.Report(slices, diagnostics, options.SegmentsToSkip ?? 0);
@@ -100,6 +101,42 @@ public ApplicationModelAnalysis Analyze(IReadOnlyList compilations,
diagnostics.All);
}
+ ///
+ /// Puts every scenario the application specifies its slices by under the slice it belongs to.
+ ///
+ /// The projects the application is written as, in the order they were read.
+ /// The slices of the application, joined across every project.
+ /// The diagnostics to report to.
+ /// The slices, each carrying the scenarios it is specified by.
+ ///
+ /// A scenario is written in one project and reads the symbols of that project's compilation, so it is recovered
+ /// per project exactly as an artifact is. Which slice it belongs to is a different question: a scenario names no
+ /// slice, it is placed by the namespace above it that declares one, and nothing says the project declaring that
+ /// slice is the project the scenario was written in - a bounded context handling its commands beside the contracts
+ /// project publishing its events is specified from the project holding the handlers. So placement is decided
+ /// against the slices of the whole application rather than the slices of the project the scenario came from, which
+ /// is why this reads them once the union is in rather than within each project.
+ ///
+ static IReadOnlyList Specified(
+ IReadOnlyList projects,
+ IReadOnlyList slices,
+ ScreenplayDiagnostics diagnostics)
+ {
+ var namespaces = slices.Select(_ => _.Namespace).ToList();
+ var catalogs = projects.Select(_ => _.Specifications(namespaces, diagnostics)).ToList();
+
+ return
+ [
+ .. slices.Select(slice => slice with
+ {
+ Specifications = SliceUnion.Specifications(
+ catalogs.SelectMany(_ => _.For(slice.Namespace)),
+ slice.Namespace,
+ diagnostics)
+ })
+ ];
+ }
+
///
/// Gets everything within a slice that requires something of the caller.
///
diff --git a/Source/DotNET/Screenplay/Analysis/CompilationAnalysis.cs b/Source/DotNET/Screenplay/Analysis/CompilationAnalysis.cs
index 7fed1403b..0b8dc1a4c 100644
--- a/Source/DotNET/Screenplay/Analysis/CompilationAnalysis.cs
+++ b/Source/DotNET/Screenplay/Analysis/CompilationAnalysis.cs
@@ -3,6 +3,7 @@
using Cratis.Arc.Screenplay.Analysis.Screens;
using Cratis.Arc.Screenplay.Analysis.Slices;
+using Cratis.Arc.Screenplay.Analysis.Specifications;
using Cratis.Arc.Screenplay.Model;
using Microsoft.CodeAnalysis;
@@ -14,9 +15,9 @@ namespace Cratis.Arc.Screenplay.Analysis;
///
/// Everything within this reads symbols and syntax of a single compilation, which is what makes it the unit an
/// application of several projects is read in. What comes out is joined to what the other projects yielded, and the
-/// two questions that can only be answered once every project has been read - what a validator declares about a
-/// concept another project holds, and how much the errors of this project left behind - are asked of this afterwards
-/// rather than kept inside it.
+/// questions that can only be answered once every project has been read - what a validator declares about a concept
+/// another project holds, which slice of the whole application a scenario written here specifies, and how much the
+/// errors of this project left behind - are asked of this afterwards rather than kept inside it.
///
public class CompilationAnalysis
{
@@ -82,6 +83,19 @@ public static CompilationAnalysis Of(
return new(compilation, catalog, readers, recovered, slices);
}
+ ///
+ /// Reads every scenario this project specifies a slice of the application by.
+ ///
+ /// The namespaces a slice was recovered from, across every project.
+ /// The diagnostics to report to.
+ /// The scenarios, arranged under the slice each belongs to.
+ ///
+ /// This waits until every project has been read, because a scenario is placed by the nearest namespace above it
+ /// that declares a slice - and the project declaring that slice need not be the one the scenario is written in.
+ ///
+ public SpecificationCatalog Specifications(IEnumerable slices, ScreenplayDiagnostics diagnostics) =>
+ SpecificationCatalog.Read(Compilation, _catalog, slices, diagnostics);
+
///
/// Attaches the rules the validators of this project declare to the concepts they validate.
///
diff --git a/Source/DotNET/Screenplay/Analysis/Slices/SliceUnion.cs b/Source/DotNET/Screenplay/Analysis/Slices/SliceUnion.cs
index 335bf9efa..647890fbb 100644
--- a/Source/DotNET/Screenplay/Analysis/Slices/SliceUnion.cs
+++ b/Source/DotNET/Screenplay/Analysis/Slices/SliceUnion.cs
@@ -36,6 +36,25 @@ .. slices
.Select(group => Join([.. group], diagnostics))
];
+ ///
+ /// Joins the scenarios every project specifies one slice by.
+ ///
+ /// The scenarios each project placed under the slice, in the order they were read.
+ /// The namespace of the slice.
+ /// The diagnostics to report to.
+ /// The scenarios kept, ordered by name.
+ ///
+ /// Scenarios arrive apart from the rest of a slice, because which slice one belongs to is only known once every
+ /// project has been read, so they are joined here rather than in . What holds for every other
+ /// part of a slice holds for them: a document saying specification and_the_name_is_taken twice in one slice
+ /// says the same word twice and means it differently, so the first is kept.
+ ///
+ public static IReadOnlyList Specifications(
+ IEnumerable specifications,
+ string @namespace,
+ ScreenplayDiagnostics diagnostics) =>
+ Once(specifications, _ => _.Name, "specification", @namespace, diagnostics);
+
///
/// Joins the parts of one slice.
///
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/CallArguments.cs b/Source/DotNET/Screenplay/Analysis/Specifications/CallArguments.cs
new file mode 100644
index 000000000..f4bccd55f
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/CallArguments.cs
@@ -0,0 +1,96 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Gets the expressions a call gives to one of its parameters.
+///
+///
+/// What a specification states sits in one argument of a call, and which one it is has to be resolved by name rather
+/// than by position - the calls carrying it declare everything after it as optional, take it through a parameter
+/// array, or are extension methods whose first parameter is the receiver. A parameter array and a parameter taking a
+/// collection are both flattened, so that stating three events in one call and stating them in three reads the same
+/// way.
+///
+public static class CallArguments
+{
+ ///
+ /// Gets the expressions a call gives to a parameter.
+ ///
+ /// The call to read.
+ /// The method being called.
+ /// The name of the parameter to read.
+ /// The expressions, in the order the call declares them.
+ public static IEnumerable For(InvocationExpressionSyntax invocation, IMethodSymbol method, string parameter)
+ {
+ var arguments = invocation.ArgumentList.Arguments;
+ var given = new List();
+
+ for (var index = 0; index < arguments.Count; index++)
+ {
+ if (string.Equals(NameOf(arguments[index], method, index), parameter, StringComparison.Ordinal))
+ {
+ given.AddRange(Flatten(arguments[index].Expression));
+ }
+ }
+
+ return given;
+ }
+
+ ///
+ /// Gets the name of the parameter an argument fills in.
+ ///
+ /// The argument to name.
+ /// The method being called.
+ /// The position of the argument.
+ /// The parameter name, or when it cannot be resolved.
+ static string? NameOf(ArgumentSyntax argument, IMethodSymbol method, int index)
+ {
+ if (argument.NameColon is { } named)
+ {
+ return named.Name.Identifier.ValueText;
+ }
+
+ if (index < method.Parameters.Length)
+ {
+ return method.Parameters[index].Name;
+ }
+
+ return method.Parameters is [.., { IsParams: true } last] ? last.Name : null;
+ }
+
+ ///
+ /// Opens up an expression that holds several values into the values it holds.
+ ///
+ /// The expression to open up.
+ /// The values, or the expression itself when it holds one.
+ static IEnumerable Flatten(ExpressionSyntax expression) => expression switch
+ {
+ CollectionExpressionSyntax collection => collection.Elements.Select(ValueOf),
+ ImplicitArrayCreationExpressionSyntax implicitly => implicitly.Initializer.Expressions,
+ ArrayCreationExpressionSyntax array when array.Initializer is { } initializer => initializer.Expressions,
+ _ => [expression]
+ };
+
+ ///
+ /// Gets the expression one element of a collection stands for.
+ ///
+ /// The element to read.
+ /// The expression.
+ ///
+ /// An element spreading another collection into this one stands for however many values that collection holds,
+ /// which is not something a source text says. The expression being spread is returned as it is, so that whoever
+ /// asked for the values finds one it cannot read rather than a list quietly missing them.
+ ///
+ static ExpressionSyntax ValueOf(CollectionElementSyntax element) => element switch
+ {
+ ExpressionElementSyntax value => value.Expression,
+ SpreadElementSyntax spread => spread.Expression,
+ _ => SyntaxFactory.IdentifierName(element.ToString())
+ };
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationAssertions.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationAssertions.cs
new file mode 100644
index 000000000..16aaba1e7
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationAssertions.cs
@@ -0,0 +1,95 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Recognizes what an assertion of a specification says the outcome was.
+///
+///
+/// Assertions are matched on the name of the helper alone. The same helper is declared several times over across the
+/// testing packages - once for the result of a command, once for the result of an append, once for the scenario
+/// itself - and a specification calls whichever of them its own shape reaches, so matching the declaring type would
+/// recognize the same sentence in one specification and not in the next.
+///
+/// Only the two outcomes Screenplay can hold are recognized: an event was appended, and the command was rejected.
+/// Everything else a specification asserts - how far the sequence got, that no exception was thrown, that the result
+/// was valid - says nothing the language has a place for and is passed over rather than reported, in the same way a
+/// unit level specification is.
+///
+///
+public static class SpecificationAssertions
+{
+ /// The assertion naming an event the command appended.
+ public const string AppendedEventAssertion = "ShouldHaveAppendedEvent";
+
+ /// The assertion saying a value is false, which is a rejection when the value is the success of a result.
+ public const string FalseAssertion = "ShouldBeFalse";
+
+ /// The value of a result saying whether the command was carried out.
+ public const string SuccessProperty = "IsSuccess";
+
+ /// The assertions saying the command was rejected, without naming why.
+ public static readonly string[] Rejections =
+ [
+ "ShouldHaveExceptions",
+ "ShouldHaveValidationErrors",
+ "ShouldNotBeAuthorized",
+ "ShouldNotBeSuccessful"
+ ];
+
+ /// The assertions saying the command was rejected and naming the reason.
+ public static readonly string[] NamedRejections =
+ [
+ "ShouldHaveConstraintViolation",
+ "ShouldHaveConstraintViolationFor",
+ "ShouldHaveValidationErrorFor"
+ ];
+
+ ///
+ /// Gets the event an assertion says was appended.
+ ///
+ /// The method being called.
+ /// The event type, or when the assertion is about something else.
+ ///
+ /// The event is the last type argument rather than the only one, because the helper taking the scenario also
+ /// takes the command it is a scenario for, which has to be named ahead of it.
+ ///
+ public static ITypeSymbol? AppendedEventOf(IMethodSymbol method) =>
+ string.Equals(method.Name, AppendedEventAssertion, StringComparison.Ordinal)
+ ? method.TypeArguments.LastOrDefault()
+ : null;
+
+ ///
+ /// Determines whether an assertion says the command was rejected.
+ ///
+ /// The assertion to read.
+ /// The method being called.
+ /// True when the assertion is a rejection.
+ public static bool IsRejection(InvocationExpressionSyntax invocation, IMethodSymbol method) =>
+ Array.Exists(Rejections, _ => string.Equals(_, method.Name, StringComparison.Ordinal)) ||
+ IsNamedRejection(method) ||
+ IsUnsuccessful(invocation, method);
+
+ ///
+ /// Determines whether an assertion names the reason the command was rejected.
+ ///
+ /// The method being called.
+ /// True when the assertion names a reason.
+ public static bool IsNamedRejection(IMethodSymbol method) =>
+ Array.Exists(NamedRejections, _ => string.Equals(_, method.Name, StringComparison.Ordinal));
+
+ ///
+ /// Determines whether an assertion says the result of the command was not a success.
+ ///
+ /// The assertion to read.
+ /// The method being called.
+ /// True when the assertion is about the success of a result.
+ static bool IsUnsuccessful(InvocationExpressionSyntax invocation, IMethodSymbol method) =>
+ string.Equals(method.Name, FalseAssertion, StringComparison.Ordinal) &&
+ invocation.Expression is MemberAccessExpressionSyntax { Expression: MemberAccessExpressionSyntax subject } &&
+ string.Equals(subject.Name.Identifier.ValueText, SuccessProperty, StringComparison.Ordinal);
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCalls.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCalls.cs
new file mode 100644
index 000000000..60cad5db5
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCalls.cs
@@ -0,0 +1,129 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Recognizes the calls a Chronicle integration specification is written with.
+///
+///
+/// Two ways of writing one are recognized, because Arc documents both. A specification driving the command pipeline
+/// in process states what had happened through the scenario it holds and issues the command through the same
+/// scenario; one driving a running host states it against the event log and issues the command over HTTP. Everything
+/// is matched on the fully qualified metadata name of the type declaring the method, so neither testing package has
+/// to be referenced for either to be read.
+///
+public static class SpecificationCalls
+{
+ /// The method appending one event to a sequence.
+ public const string AppendMethod = "Append";
+
+ /// The method appending several events to a sequence as one transaction.
+ public const string AppendManyMethod = "AppendMany";
+
+ /// The method stating the events that had happened for one event source.
+ public const string EventsMethod = "Events";
+
+ /// The method pinning the read model one event source resolves to.
+ public const string ReadModelMethod = "ReadModel";
+
+ /// The method issuing a command through the in-process pipeline.
+ public const string ExecuteMethod = "Execute";
+
+ /// The method taking a command as far as the pipeline validates it.
+ public const string ValidateMethod = "Validate";
+
+ /// The method issuing a command over HTTP.
+ public const string ExecuteCommandMethod = "ExecuteCommand";
+
+ /// The parameter carrying the single event an append is given.
+ public const string EventParameter = "event";
+
+ /// The parameter carrying the events an append or a scenario is given.
+ public const string EventsParameter = "events";
+
+ /// The parameter carrying the read model a scenario is pinned with.
+ public const string ReadModelParameter = "readModel";
+
+ /// The parameter carrying the command issued over HTTP.
+ public const string CommandParameter = "command";
+
+ ///
+ /// Determines whether a call states the events a specification starts from.
+ ///
+ /// The method being called.
+ /// True when the call states events.
+ public static bool IsGivenEvents(IMethodSymbol method) =>
+ (IsOn(method, WellKnownTypeNames.EventSequence) && (Named(method, AppendMethod) || Named(method, AppendManyMethod))) ||
+ (IsOn(method, WellKnownTypeNames.CommandScenarioSourceGivenBuilder) && Named(method, EventsMethod));
+
+ ///
+ /// Determines whether a call pins the read model a specification starts from.
+ ///
+ /// The method being called.
+ /// True when the call pins a read model.
+ public static bool IsGivenReadModel(IMethodSymbol method) =>
+ IsOn(method, WellKnownTypeNames.CommandScenarioSourceGivenBuilder) && Named(method, ReadModelMethod);
+
+ ///
+ /// Determines whether a call issues the command a specification is about.
+ ///
+ /// The method being called.
+ /// True when the call issues a command.
+ public static bool IsExecution(IMethodSymbol method) =>
+ (IsOn(method, WellKnownTypeNames.CommandScenario) && (Named(method, ExecuteMethod) || Named(method, ValidateMethod))) ||
+ (IsOn(method, WellKnownTypeNames.HttpClientExtensions) && Named(method, ExecuteCommandMethod));
+
+ ///
+ /// Gets the name of the parameter carrying what a recognized call is given.
+ ///
+ /// The method being called.
+ /// The parameter name, or when the call carries nothing this reads.
+ public static string? PayloadParameterOf(IMethodSymbol method)
+ {
+ if (IsGivenReadModel(method))
+ {
+ return ReadModelParameter;
+ }
+
+ if (IsExecution(method))
+ {
+ return Named(method, ExecuteCommandMethod) ? CommandParameter : method.Parameters.FirstOrDefault()?.Name;
+ }
+
+ if (!IsGivenEvents(method))
+ {
+ return null;
+ }
+
+ return Named(method, AppendMethod) ? EventParameter : EventsParameter;
+ }
+
+ ///
+ /// Determines whether a method carries a name.
+ ///
+ /// The method to check.
+ /// The name to match.
+ /// True when the names are the same.
+ static bool Named(IMethodSymbol method, string name) => string.Equals(method.Name, name, StringComparison.Ordinal);
+
+ ///
+ /// Determines whether a method belongs to a named type, following the type it was reduced from.
+ ///
+ /// The method to check.
+ /// The fully qualified metadata name of the declaring type.
+ /// True when the method belongs to that type.
+ ///
+ /// A specialization of an interface inherits its members without redeclaring them, so the interface a member is
+ /// declared on is matched as well as the one it is reached through - appending to the event log is appending to a
+ /// sequence, whichever of the two the call site names.
+ ///
+ static bool IsOn(IMethodSymbol method, string fullMetadataName)
+ {
+ var declaring = (method.ReducedFrom ?? method).ContainingType;
+
+ return declaring.Is(fullMetadataName) || declaring.FindInterface(fullMetadataName) is not null;
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCatalog.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCatalog.cs
new file mode 100644
index 000000000..64567b0d4
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationCatalog.cs
@@ -0,0 +1,88 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Holds every scenario the application specifies its slices by, arranged under the slice each belongs to.
+///
+///
+/// Specifications are read once the slices are in, because which slice a scenario belongs to is answered by which
+/// namespaces turned out to declare one. Reading them alongside would mean deciding that against namespaces still
+/// being read, and a scenario would land under a different slice depending on the order the compilation was walked.
+///
+public class SpecificationCatalog
+{
+ readonly Dictionary> _bySlice;
+
+ SpecificationCatalog(Dictionary> bySlice) => _bySlice = bySlice;
+
+ ///
+ /// Reads every specification the compilation declares.
+ ///
+ /// The compilation being analyzed.
+ /// The catalogue of everything the compilation declares.
+ /// The namespaces a slice was recovered from.
+ /// The anything unreadable is reported to.
+ /// The .
+ public static SpecificationCatalog Read(
+ Compilation compilation,
+ ArtifactCatalog catalog,
+ IEnumerable slices,
+ ScreenplayDiagnostics diagnostics)
+ {
+ var reader = new SpecificationReader(compilation, diagnostics);
+ var placement = new SpecificationPlacement(slices);
+ var bySlice = new Dictionary>(StringComparer.Ordinal);
+
+ foreach (var type in catalog.Types.Where(reader.IsSpecification))
+ {
+ if (placement.SliceOf(type) is not { } slice)
+ {
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.UnreadableSpecification,
+ $"The scenario '{type.Name}' was left out because no namespace above it declares a slice for it to specify",
+ type.ToDisplayString());
+
+ continue;
+ }
+
+ if (reader.Read(type, SpecificationPlacement.NameOf(type, slice)) is { } specification)
+ {
+ Add(bySlice, slice, specification);
+ }
+ }
+
+ return new(bySlice);
+ }
+
+ ///
+ /// Gets the specifications belonging to a slice.
+ ///
+ /// The namespace of the slice.
+ /// The specifications, ordered by name.
+ public IEnumerable For(string @namespace) =>
+ _bySlice.TryGetValue(@namespace, out var specifications)
+ ? specifications.OrderBy(_ => _.Name, StringComparer.Ordinal)
+ : [];
+
+ ///
+ /// Adds a specification under the slice it belongs to.
+ ///
+ /// The specifications collected so far.
+ /// The namespace of the slice.
+ /// The specification to add.
+ static void Add(Dictionary> bySlice, string slice, SpecificationModel specification)
+ {
+ if (!bySlice.TryGetValue(slice, out var specifications))
+ {
+ specifications = [];
+ bySlice[slice] = specifications;
+ }
+
+ specifications.Add(specification);
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationDraft.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationDraft.cs
new file mode 100644
index 000000000..c868c0bc1
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationDraft.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Model;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Collects a specification while the type declaring it is being read.
+///
+///
+/// A scenario is one example, so the first step of it that cannot be read decides the outcome for all of them. The
+/// reason is kept rather than the fact, because what made a scenario unreadable is the only part of it worth
+/// reporting - and the first reason is kept rather than the last, since every reason after it is read from a
+/// scenario already known to be incomplete.
+///
+public class SpecificationDraft
+{
+ ///
+ /// Gets what had already happened when the command was issued.
+ ///
+ public IList Given { get; } = [];
+
+ ///
+ /// Gets the events and read model states that followed.
+ ///
+ public IList Then { get; } = [];
+
+ ///
+ /// Gets the rejections that followed, each named by the reason the source gives for it.
+ ///
+ public IList Errors { get; } = [];
+
+ ///
+ /// Gets or sets the command that was issued.
+ ///
+ public SpecificationStateModel? When { get; set; }
+
+ ///
+ /// Gets why the scenario could not be read, or while all of it still can be.
+ ///
+ public string? Unreadable { get; private set; }
+
+ ///
+ /// Records why the scenario cannot be read.
+ ///
+ /// What made it unreadable.
+ public void CannotRead(string reason) => Unreadable ??= reason;
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationMembers.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationMembers.cs
new file mode 100644
index 000000000..56f761fc4
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationMembers.cs
@@ -0,0 +1,114 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Finds the parts of a specification within the chain of types it is written across.
+///
+///
+/// A specification inherits the world it starts from. Every type in the chain may set part of it up, and the
+/// specification framework calls each of those in turn from the base down, so the chain is walked in that same order
+/// and what it states comes out in the order it really happens in.
+///
+/// Where the steps are written differs between the two shapes Arc documents. A specification driving the pipeline in
+/// process writes them on itself; one driving a running host writes them on a nested type the fixture is handed to,
+/// and keeps only its assertions outside. Which of the two a specification is follows from where the steps are, not
+/// from what it derives from, so a chain no package declares reads exactly the same way.
+///
+///
+public static class SpecificationMembers
+{
+ /// The method setting up the world a specification starts from.
+ public const string EstablishMethod = "Establish";
+
+ /// The method performing the single action a specification is about.
+ public const string BecauseMethod = "Because";
+
+ /// The nested type the steps of a specification against a running host are written on.
+ public const string ContextType = "context";
+
+ ///
+ /// Gets the type the steps of a specification are written on.
+ ///
+ /// The type declaring the specification.
+ /// The nested context when the specification has one, otherwise the type itself.
+ public static INamedTypeSymbol StepsOf(INamedTypeSymbol type) =>
+ type.GetTypeMembers(ContextType).FirstOrDefault(_ => Declares(_, BecauseMethod) || Declares(_, EstablishMethod)) ?? type;
+
+ ///
+ /// Gets the chain of types a type is written across, from the base down.
+ ///
+ /// The type to walk.
+ /// The chain, base first.
+ public static IEnumerable ChainOf(INamedTypeSymbol type)
+ {
+ var chain = new List();
+ for (var current = type; current is not null && current.SpecialType != SpecialType.System_Object; current = current.BaseType)
+ {
+ chain.Insert(0, current);
+ }
+
+ return chain;
+ }
+
+ ///
+ /// Gets every declaration of a method in a chain of types.
+ ///
+ /// The type to walk from.
+ /// The name of the method.
+ /// The methods, from the base down.
+ public static IEnumerable MethodsIn(INamedTypeSymbol type, string name) =>
+ ChainOf(type).SelectMany(_ => _.GetMembers(name)).OfType().Where(_ => _.MethodKind == MethodKind.Ordinary);
+
+ ///
+ /// Gets every assertion a specification makes.
+ ///
+ /// The type declaring the specification.
+ /// The assertions, ordered by name so that the same source always reads the same way.
+ public static IEnumerable AssertionsIn(INamedTypeSymbol type) =>
+ ChainOf(type)
+ .SelectMany(_ => _.GetMembers())
+ .OfType()
+ .Where(_ => _.HasAttribute(WellKnownTypeNames.FactAttribute))
+ .OrderBy(_ => _.Name, StringComparer.Ordinal)
+ .ThenBy(_ => _.ToDisplayString(), StringComparer.Ordinal);
+
+ ///
+ /// Determines whether a type holds a scenario the command pipeline is driven through.
+ ///
+ /// The type to check.
+ /// True when the type or one of its bases holds a scenario.
+ ///
+ /// Holding one is what makes a specification an integration specification of the slice rather than a unit level
+ /// one about a collaborator, and it holds whether or not the command is issued somewhere this can read. That is
+ /// exactly why it is asked: a specification that holds a scenario and issues its command through a helper is one
+ /// this cannot read, and saying so is the difference between a known gap and a silent one.
+ ///
+ public static bool HoldsAScenario(INamedTypeSymbol type) =>
+ ChainOf(type)
+ .SelectMany(_ => _.GetMembers())
+ .Any(member => TypeOf(member).Is(WellKnownTypeNames.CommandScenario));
+
+ ///
+ /// Determines whether a type declares a method itself.
+ ///
+ /// The type to check.
+ /// The name of the method.
+ /// True when the type declares it.
+ static bool Declares(INamedTypeSymbol type, string name) => type.GetMembers(name).OfType().Any();
+
+ ///
+ /// Gets the type a member holds a value of.
+ ///
+ /// The member to read.
+ /// The type, or when the member holds no value.
+ static ITypeSymbol? TypeOf(ISymbol member) => member switch
+ {
+ IFieldSymbol field => field.Type,
+ IPropertySymbol property => property.Type,
+ _ => null
+ };
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationOutcomeReader.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationOutcomeReader.cs
new file mode 100644
index 000000000..a41fcc5da
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationOutcomeReader.cs
@@ -0,0 +1,151 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis.Commands;
+using Cratis.Arc.Screenplay.Analysis.Events;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Reads what a specification says followed from the command it issued.
+///
+/// The compilation being analyzed.
+/// The anything unreadable is reported to.
+///
+/// Each assertion is one sentence about the outcome, and several of them routinely say the same sentence about a
+/// different part of the same event - once for each value it carries. Screenplay says an event followed once, so a
+/// sentence already said is passed over rather than repeated.
+///
+public class SpecificationOutcomeReader(Compilation compilation, ScreenplayDiagnostics diagnostics)
+{
+ ///
+ /// Reads what a specification says followed.
+ ///
+ /// The type declaring the specification.
+ /// The scenario collected so far.
+ /// The name of the specification.
+ /// Where the specification lives, for use in diagnostics.
+ public void Read(INamedTypeSymbol type, SpecificationDraft draft, string name, string location)
+ {
+ foreach (var assertion in SpecificationMembers.AssertionsIn(type))
+ {
+ foreach (var body in HandlerBodies.Of(assertion))
+ {
+ ReadBody(body, compilation.GetSemanticModel(body.SyntaxTree), draft, name, location);
+ }
+ }
+ }
+
+ ///
+ /// Adds an event a specification says followed, or records that it cannot be read.
+ ///
+ /// The type the assertion names.
+ /// The scenario collected so far.
+ static void AddEvent(ITypeSymbol appended, SpecificationDraft draft)
+ {
+ if (!EventReader.IsEvent(appended))
+ {
+ draft.CannotRead($"it expects '{appended.Name}' to follow, which is not declared as an event");
+ return;
+ }
+
+ if (!draft.Then.Any(_ => string.Equals(_.Name, appended.Name, StringComparison.Ordinal)))
+ {
+ draft.Then.Add(new(appended.Name, SpecificationStateKind.Event, []));
+ }
+ }
+
+ ///
+ /// Reads what one assertion says followed.
+ ///
+ /// The body of the assertion.
+ /// The semantic model of the tree the body lives in.
+ /// The scenario collected so far.
+ /// The name of the specification.
+ /// Where the specification lives.
+ void ReadBody(SyntaxNode body, SemanticModel semanticModel, SpecificationDraft draft, string name, string location)
+ {
+ foreach (var invocation in body.DescendantNodesAndSelf().OfType())
+ {
+ if (semanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol method)
+ {
+ continue;
+ }
+
+ var appended = SpecificationAssertions.AppendedEventOf(method);
+ var rejection = SpecificationAssertions.IsRejection(invocation, method);
+ if (appended is null && !rejection)
+ {
+ continue;
+ }
+
+ if (!StepsTaken.Always(invocation, body))
+ {
+ draft.CannotRead("what it expects to follow is only expected under a condition, and a scenario says what followed");
+ return;
+ }
+
+ if (appended is not null)
+ {
+ AddEvent(appended, draft);
+ continue;
+ }
+
+ AddError(invocation, method, semanticModel, draft, name, location);
+ }
+ }
+
+ ///
+ /// Adds a rejection a specification says followed, named by the reason the source gives for it.
+ ///
+ /// The assertion to read.
+ /// The method being called.
+ /// The semantic model of the tree the assertion lives in.
+ /// The scenario collected so far.
+ /// The name of the specification.
+ /// Where the specification lives.
+ void AddError(
+ InvocationExpressionSyntax invocation,
+ IMethodSymbol method,
+ SemanticModel semanticModel,
+ SpecificationDraft draft,
+ string name,
+ string location)
+ {
+ var reason = SpecificationAssertions.IsNamedRejection(method)
+ ? ReasonOf(invocation, semanticModel, name, location)
+ : string.Empty;
+
+ if (!draft.Errors.Contains(reason, StringComparer.Ordinal))
+ {
+ draft.Errors.Add(reason);
+ }
+ }
+
+ ///
+ /// Gets the reason an assertion names for a rejection.
+ ///
+ /// The assertion to read.
+ /// The semantic model of the tree the assertion lives in.
+ /// The name of the specification.
+ /// Where the specification lives.
+ /// The reason, empty when the source names one this cannot read.
+ string ReasonOf(InvocationExpressionSyntax invocation, SemanticModel semanticModel, string name, string location)
+ {
+ var named = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression;
+ if (named is not null && semanticModel.GetConstantValue(named) is { HasValue: true, Value: { } value })
+ {
+ return value.ToString() ?? string.Empty;
+ }
+
+ diagnostics.Information(
+ ScreenplayDiagnosticCodes.UnreadableSpecificationValue,
+ $"The reason '{name}' gives for a rejection is code rather than a constant, so the rejection is stated without one",
+ location);
+
+ return string.Empty;
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationPlacement.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationPlacement.cs
new file mode 100644
index 000000000..8663601f3
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationPlacement.cs
@@ -0,0 +1,79 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Decides which slice a specification belongs to and what it is called there.
+///
+/// The namespaces a slice was recovered from.
+///
+/// A specification sits in a folder beneath the slice it is about, which is a namespace beneath the slice's own. The
+/// slice it belongs to is therefore the nearest namespace above it that declares one - nearest rather than any,
+/// because a slice within a slice is exactly what a feature holding both a behavior and the behaviors beneath it
+/// looks like.
+///
+/// The name is every word between the slice and the specification, in the order the source wrote them. That is what
+/// the convention was built to read as - a folder saying when something happens and a file saying what else was true
+/// at the time - so keeping all of it is what makes the specification say in the document what it says in the source.
+///
+///
+public class SpecificationPlacement(IEnumerable slices)
+{
+ /// The namespace segment holding the world several specifications share.
+ public const string SharedContextSegment = "given";
+
+ readonly HashSet _slices = [.. slices];
+
+ ///
+ /// Gets the name a specification is declared under.
+ ///
+ /// The type declaring the specification.
+ /// The namespace of the slice it belongs to.
+ /// The name, as the words the source wrote joined together.
+ public static string NameOf(INamedTypeSymbol type, string slice)
+ {
+ var below = Segments(type)[SegmentsIn(slice)..];
+
+ return string.Join(
+ '_',
+ below.Where(_ => !string.Equals(_, SharedContextSegment, StringComparison.Ordinal)).Append(type.Name));
+ }
+
+ ///
+ /// Gets the namespace of the slice a specification belongs to.
+ ///
+ /// The type declaring the specification.
+ /// The namespace, or when no slice above it declares anything.
+ public string? SliceOf(INamedTypeSymbol type)
+ {
+ var segments = Segments(type);
+
+ for (var depth = segments.Length - 1; depth > 0; depth--)
+ {
+ var candidate = string.Join('.', segments[..depth]);
+ if (_slices.Contains(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Gets the segments of the namespace a type lives in, with the type left out.
+ ///
+ /// The type to read.
+ /// The segments.
+ static string[] Segments(INamedTypeSymbol type) => type.Namespace().Split('.', StringSplitOptions.RemoveEmptyEntries);
+
+ ///
+ /// Counts the segments of a namespace.
+ ///
+ /// The namespace to count.
+ /// The number of segments.
+ static int SegmentsIn(string @namespace) => @namespace.Split('.', StringSplitOptions.RemoveEmptyEntries).Length;
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationReader.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationReader.cs
new file mode 100644
index 000000000..efb5b396d
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationReader.cs
@@ -0,0 +1,133 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis.Commands;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Reads the scenario one type specifies a slice by.
+///
+/// The compilation being analyzed.
+/// The anything unreadable is reported to.
+///
+/// Only a specification driving a command through the real pipeline is read. A unit level one stands a collaborator
+/// up behind a substitute and says what that collaborator was asked to do, which is a statement about the inside of
+/// the slice rather than about its behavior, and Screenplay has nowhere to put it. Whether a specification is one or
+/// the other is decided by what it touches: holding a scenario the pipeline runs in, or reaching the event log, is
+/// what an integration specification does and nothing else does.
+///
+public class SpecificationReader(Compilation compilation, ScreenplayDiagnostics diagnostics)
+{
+ ///
+ /// Determines whether a type specifies a slice by driving a command through the pipeline.
+ ///
+ /// The type to check.
+ /// True when the type is a specification of the kind this reads.
+ public bool IsSpecification(INamedTypeSymbol type)
+ {
+ if (type is not { TypeKind: TypeKind.Class, IsAbstract: false, ContainingType: null })
+ {
+ return false;
+ }
+
+ var steps = SpecificationMembers.StepsOf(type);
+
+ return SpecificationMembers.MethodsIn(steps, SpecificationMembers.BecauseMethod).Any() &&
+ (SpecificationMembers.HoldsAScenario(steps) || DrivesASlice(steps));
+ }
+
+ ///
+ /// Reads the scenario a type specifies a slice by.
+ ///
+ /// The type declaring the specification.
+ /// The name the specification is declared under.
+ /// The , or when the scenario cannot be read.
+ ///
+ /// What was left out of a scenario is held back until the scenario is known to survive. Saying that a value was
+ /// left out of a scenario that was itself left out describes a document that says something it does not say, and
+ /// there are far more values than scenarios - so the noise would be the loudest thing in the report.
+ ///
+ public SpecificationModel? Read(INamedTypeSymbol type, string name)
+ {
+ var location = type.ToDisplayString();
+ var steps = SpecificationMembers.StepsOf(type);
+ var draft = new SpecificationDraft();
+ var stated = new ScreenplayDiagnostics();
+
+ var reader = new SpecificationStepReader(compilation, new(stated));
+ reader.ReadGiven(steps, draft, name, location);
+ reader.ReadWhen(steps, draft, name, location);
+ new SpecificationOutcomeReader(compilation, stated).Read(type, draft, name, location);
+
+ if (draft.When is null)
+ {
+ draft.CannotRead("the command it issues is put together somewhere this cannot read");
+ }
+ else if (draft.Then.Count == 0 && draft.Errors.Count == 0)
+ {
+ draft.CannotRead("it expects no event and no rejection, and those are the outcomes the language holds");
+ }
+
+ if (draft.Unreadable is not null)
+ {
+ diagnostics.Warning(
+ ScreenplayDiagnosticCodes.UnreadableSpecification,
+ $"The scenario '{name}' was left out because {draft.Unreadable}",
+ location);
+
+ return null;
+ }
+
+ diagnostics.AddRange(stated.All);
+
+ return new(name, [.. draft.Given], draft.When, [.. draft.Then], [.. draft.Errors]);
+ }
+
+ ///
+ /// Determines whether the steps of a specification drive a slice rather than exercise a part of one.
+ ///
+ /// The type the steps are written on.
+ /// True when the specification issues a command, or says what the event store already held.
+ ///
+ /// A specification against a running host holds no scenario of its own - the host is the scenario - so what makes
+ /// it one is that it hands a command to the host, or says what the event log already held before doing so.
+ ///
+ /// Where the two are looked for differs, and deliberately. Issuing a command is what a slice does, wherever it is
+ /// written. Appending to a sequence is only the world a scenario starts in when it happens before the action, and
+ /// a specification appending as its action is one about the event store itself - a constraint holding, an append
+ /// being rejected - which is a part of a slice rather than the behavior of one.
+ ///
+ ///
+ bool DrivesASlice(INamedTypeSymbol steps) =>
+ Calls(steps, SpecificationMembers.EstablishMethod)
+ .Any(_ => SpecificationCalls.IsGivenEvents(_) || SpecificationCalls.IsGivenReadModel(_) || SpecificationCalls.IsExecution(_)) ||
+ Calls(steps, SpecificationMembers.BecauseMethod).Any(SpecificationCalls.IsExecution);
+
+ ///
+ /// Gets every call a method of a chain resolves to.
+ ///
+ /// The type the steps are written on.
+ /// The name of the method to read.
+ /// The methods called.
+ IEnumerable Calls(INamedTypeSymbol steps, string method) =>
+ SpecificationMembers.MethodsIn(steps, method).SelectMany(HandlerBodies.Of).SelectMany(CallsIn);
+
+ ///
+ /// Gets every call a body resolves to.
+ ///
+ /// The body to walk.
+ /// The methods called.
+ IEnumerable CallsIn(SyntaxNode body)
+ {
+ var semanticModel = compilation.GetSemanticModel(body.SyntaxTree);
+
+ return body.DescendantNodesAndSelf()
+ .OfType()
+ .Select(invocation => semanticModel.GetSymbolInfo(invocation).Symbol)
+ .OfType();
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationStepReader.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationStepReader.cs
new file mode 100644
index 000000000..cf516032b
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationStepReader.cs
@@ -0,0 +1,154 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis.Commands;
+using Cratis.Arc.Screenplay.Analysis.Events;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Reads what a specification starts from and the command it issues, from the bodies stating them.
+///
+/// The compilation being analyzed.
+/// The reading the values each step states.
+public class SpecificationStepReader(Compilation compilation, SpecificationValues values)
+{
+ ///
+ /// Reads what a specification had already seen when it issued its command.
+ ///
+ /// The type the steps are written on.
+ /// The scenario collected so far.
+ /// The name of the specification.
+ /// Where the specification lives, for use in diagnostics.
+ public void ReadGiven(INamedTypeSymbol steps, SpecificationDraft draft, string name, string location)
+ {
+ foreach (var (invocation, method, semanticModel, always) in CallsIn(steps, SpecificationMembers.EstablishMethod))
+ {
+ if (!SpecificationCalls.IsGivenEvents(method) && !SpecificationCalls.IsGivenReadModel(method))
+ {
+ continue;
+ }
+
+ if (!always)
+ {
+ draft.CannotRead("what it starts from only happens under a condition, and a scenario says what happened");
+ return;
+ }
+
+ var kind = SpecificationCalls.IsGivenReadModel(method) ? SpecificationStateKind.ReadModel : SpecificationStateKind.Event;
+
+ foreach (var stated in CallArguments.For(invocation, method, SpecificationCalls.PayloadParameterOf(method) ?? string.Empty))
+ {
+ Add(draft.Given, stated, kind, semanticModel, draft, name, location);
+ }
+ }
+ }
+
+ ///
+ /// Reads the command a specification issues.
+ ///
+ /// The type the steps are written on.
+ /// The scenario collected so far.
+ /// The name of the specification.
+ /// Where the specification lives, for use in diagnostics.
+ public void ReadWhen(INamedTypeSymbol steps, SpecificationDraft draft, string name, string location)
+ {
+ foreach (var (invocation, method, semanticModel, always) in CallsIn(steps, SpecificationMembers.BecauseMethod))
+ {
+ if (!SpecificationCalls.IsExecution(method))
+ {
+ continue;
+ }
+
+ if (!always)
+ {
+ draft.CannotRead("the command it issues is only issued under a condition, and a scenario says what happened");
+ return;
+ }
+
+ if (draft.When is not null)
+ {
+ draft.CannotRead("it issues more than one command, and a scenario is about one");
+ return;
+ }
+
+ var stated = CallArguments.For(invocation, method, SpecificationCalls.PayloadParameterOf(method) ?? string.Empty).ToList();
+ if (stated is not [BaseObjectCreationExpressionSyntax creation] ||
+ semanticModel.GetTypeInfo(creation).Type is not INamedTypeSymbol command)
+ {
+ draft.CannotRead("the command it issues is put together somewhere this cannot read");
+ return;
+ }
+
+ if (!CommandReader.IsCommand(command))
+ {
+ draft.CannotRead($"'{command.Name}' is not a command the document declares");
+ return;
+ }
+
+ draft.When = new(command.Name, SpecificationStateKind.Command, values.Read(creation, semanticModel, command, name, location));
+ }
+ }
+
+ ///
+ /// Gets every call a body makes that resolves to a method.
+ ///
+ /// The body to walk.
+ /// The semantic model of the tree the body lives in.
+ /// The calls, in the order the body makes them.
+ static IEnumerable Calls(SyntaxNode body, SemanticModel semanticModel) =>
+ body.DescendantNodesAndSelf()
+ .OfType()
+ .Select(invocation => (invocation, Method: semanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol, semanticModel, body))
+ .Where(call => call.Method is not null)
+ .Select(call => new Step(call.invocation, call.Method!, call.semanticModel, StepsTaken.Always(call.invocation, call.body)));
+
+ ///
+ /// Gets every call a method of a chain makes, with the model the tree it lives in is read through.
+ ///
+ /// The type to walk from.
+ /// The name of the method to read.
+ /// The calls, from the base down and in the order each body makes them.
+ IEnumerable CallsIn(INamedTypeSymbol type, string method) =>
+ SpecificationMembers.MethodsIn(type, method)
+ .SelectMany(HandlerBodies.Of)
+ .SelectMany(body => Calls(body, compilation.GetSemanticModel(body.SyntaxTree)));
+
+ ///
+ /// Adds one state a specification starts from, or records that it cannot be read.
+ ///
+ /// The states collected so far.
+ /// The expression stating it.
+ /// What the state is.
+ /// The semantic model of the tree the expression lives in.
+ /// The scenario collected so far.
+ /// The name of the specification.
+ /// Where the specification lives.
+ void Add(
+ IList states,
+ ExpressionSyntax stated,
+ SpecificationStateKind kind,
+ SemanticModel semanticModel,
+ SpecificationDraft draft,
+ string name,
+ string location)
+ {
+ if (stated is not BaseObjectCreationExpressionSyntax creation ||
+ semanticModel.GetTypeInfo(creation).Type is not INamedTypeSymbol type)
+ {
+ draft.CannotRead("what it starts from is put together somewhere this cannot read");
+ return;
+ }
+
+ if (kind == SpecificationStateKind.Event && !EventReader.IsEvent(type))
+ {
+ draft.CannotRead($"it starts from '{type.Name}', which is not declared as an event");
+ return;
+ }
+
+ states.Add(new(type.Name, kind, values.Read(creation, semanticModel, type, name, location)));
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationValues.cs b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationValues.cs
new file mode 100644
index 000000000..18a383d8c
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/SpecificationValues.cs
@@ -0,0 +1,135 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Analysis.Commands;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Reads the values a specification states for one artifact, from the construction stating them.
+///
+/// The anything unreadable is reported to.
+///
+/// A specification is closed - it refers to nothing outside itself - so a constant is the only source of a value
+/// there is. That is the same discipline a produces mapping is read with, one source shorter: a produces mapping may
+/// also name the input of the command being handled, and a scenario has no input to name.
+///
+public class SpecificationValues(ScreenplayDiagnostics diagnostics)
+{
+ readonly MappingSourceReader _sources = new(diagnostics);
+
+ ///
+ /// Reads the values one construction states.
+ ///
+ /// The construction to read.
+ /// The semantic model of the tree the construction lives in.
+ /// The type being constructed.
+ /// The name of the specification, for use in diagnostics.
+ /// Where the specification lives, for use in diagnostics.
+ /// The values, in the order the source declares them.
+ public IEnumerable Read(
+ BaseObjectCreationExpressionSyntax creation,
+ SemanticModel semanticModel,
+ ITypeSymbol type,
+ string specification,
+ string location)
+ {
+ var values = new List();
+ var constructor = semanticModel.GetSymbolInfo(creation).Symbol as IMethodSymbol;
+
+ foreach (var (name, expression) in Stated(creation, constructor))
+ {
+ Add(values, PropertyOf(type, name), expression, semanticModel, type, specification, location);
+ }
+
+ return values;
+ }
+
+ ///
+ /// Gets every value a construction states, from its arguments and from its initializer.
+ ///
+ /// The construction to read.
+ /// The constructor being called.
+ /// The values, each with the name it fills in, in the order the source declares them.
+ static IEnumerable<(string Name, ExpressionSyntax Expression)> Stated(
+ BaseObjectCreationExpressionSyntax creation,
+ IMethodSymbol? constructor)
+ {
+ var arguments = creation.ArgumentList?.Arguments ?? [];
+ for (var index = 0; index < arguments.Count; index++)
+ {
+ if (NameOf(arguments[index], constructor, index) is { } name)
+ {
+ yield return (name, arguments[index].Expression);
+ }
+ }
+
+ foreach (var assignment in creation.Initializer?.Expressions.OfType() ?? [])
+ {
+ if (assignment.Left is IdentifierNameSyntax identifier)
+ {
+ yield return (identifier.Identifier.ValueText, assignment.Right);
+ }
+ }
+ }
+
+ ///
+ /// Gets the name of the property an argument fills in.
+ ///
+ /// The argument to name.
+ /// The constructor being called.
+ /// The position of the argument.
+ /// The parameter name, or when it cannot be resolved.
+ static string? NameOf(ArgumentSyntax argument, IMethodSymbol? constructor, int index)
+ {
+ if (argument.NameColon is { } named)
+ {
+ return named.Name.Identifier.ValueText;
+ }
+
+ return constructor is not null && index < constructor.Parameters.Length ? constructor.Parameters[index].Name : null;
+ }
+
+ ///
+ /// Resolves the declared casing of a property from the name an argument used.
+ ///
+ /// The type being constructed.
+ /// The name to resolve.
+ /// The property name.
+ static string PropertyOf(ITypeSymbol type, string name) =>
+ type.DeclaredProperties().FirstOrDefault(_ => string.Equals(_.Name, name, StringComparison.OrdinalIgnoreCase))?.Name ?? name;
+
+ ///
+ /// Adds a value, reporting one that is code rather than stating it as something it is not.
+ ///
+ /// The values collected so far.
+ /// The property being filled in.
+ /// The expression filling it in.
+ /// The semantic model of the tree the expression lives in.
+ /// The type being constructed.
+ /// The name of the specification.
+ /// Where the specification lives.
+ void Add(
+ List values,
+ string property,
+ ExpressionSyntax expression,
+ SemanticModel semanticModel,
+ ITypeSymbol type,
+ string specification,
+ string location)
+ {
+ if (_sources.Read(expression, semanticModel, type, location) is LiteralSource literal)
+ {
+ values.Add(new(property, literal));
+ return;
+ }
+
+ diagnostics.Information(
+ ScreenplayDiagnosticCodes.UnreadableSpecificationValue,
+ $"The value '{specification}' states for '{type.Name}.{property}' is code rather than a constant, so the scenario states everything but that value",
+ location);
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/Step.cs b/Source/DotNET/Screenplay/Analysis/Specifications/Step.cs
new file mode 100644
index 000000000..ce9a3b423
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/Step.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Represents one call a specification makes, with everything needed to read what it says.
+///
+/// The call as it is written.
+/// The method it resolves to.
+/// The semantic model of the tree it lives in.
+/// Whether the body it is written in always makes the call, exactly once.
+public record Step(
+ InvocationExpressionSyntax Invocation,
+ IMethodSymbol Method,
+ SemanticModel SemanticModel,
+ bool Always);
diff --git a/Source/DotNET/Screenplay/Analysis/Specifications/StepsTaken.cs b/Source/DotNET/Screenplay/Analysis/Specifications/StepsTaken.cs
new file mode 100644
index 000000000..e0c312396
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Specifications/StepsTaken.cs
@@ -0,0 +1,64 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Cratis.Arc.Screenplay.Analysis.Specifications;
+
+///
+/// Answers whether a step written in a body is one the body always takes.
+///
+///
+/// A scenario says what happened, once, in order. A step written inside a branch happened in some runs of the
+/// specification and not in others, and one written inside a loop happened as many times as the loop went round -
+/// neither of which the source text says. Reading such a step as if it always happened states a world the
+/// application was never specified against, which is worse than saying nothing: it is the one failure mode a reader
+/// has no way of catching.
+///
+/// Which condition a branch stood on is routinely knowable to the compiler - a virtual property a derived
+/// specification overrides with a constant is the usual shape - but knowing it means following values through
+/// dispatch rather than reading what was written, which is a different discipline from the one everything else here
+/// keeps to. So a conditional step is left unread and said so, and a scenario that has one is left out whole.
+///
+///
+public static class StepsTaken
+{
+ ///
+ /// Determines whether a body always takes a step, exactly once.
+ ///
+ /// The call the step is written as.
+ /// The body the step is written in.
+ /// True when nothing between the two makes the step conditional or repeated.
+ public static bool Always(SyntaxNode call, SyntaxNode body)
+ {
+ for (var current = call.Parent; current is not null && current != body; current = current.Parent)
+ {
+ if (IsConditional(current))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Determines whether a construct makes what is written inside it conditional, repeated or deferred.
+ ///
+ /// The construct to check.
+ /// True when it does.
+ static bool IsConditional(SyntaxNode node) => node switch
+ {
+ IfStatementSyntax or SwitchStatementSyntax or SwitchExpressionSyntax => true,
+ ForStatementSyntax or ForEachStatementSyntax or WhileStatementSyntax or DoStatementSyntax => true,
+ ConditionalExpressionSyntax or ConditionalAccessExpressionSyntax => true,
+ CatchClauseSyntax or FinallyClauseSyntax => true,
+ AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax => true,
+ BinaryExpressionSyntax binary => binary.IsKind(SyntaxKind.LogicalAndExpression) ||
+ binary.IsKind(SyntaxKind.LogicalOrExpression) ||
+ binary.IsKind(SyntaxKind.CoalesceExpression),
+ _ => false
+ };
+}
diff --git a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs
index 62fc50fb4..4bd288e83 100644
--- a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs
+++ b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs
@@ -123,6 +123,21 @@ public static class WellKnownTypeNames
/// The everything a query is performed with, which the host fills in.
public const string QueryContext = "Cratis.Arc.Queries.QueryContext";
+ /// The sequence a specification appends the events it starts from to.
+ public const string EventSequence = "Cratis.Chronicle.EventSequences.IEventSequence";
+
+ /// The scenario a specification issues a command through in process.
+ public const string CommandScenario = "Cratis.Arc.Testing.Commands.CommandScenario`1";
+
+ /// The builder a specification states the state of one event source with.
+ public const string CommandScenarioSourceGivenBuilder = "Cratis.Arc.Chronicle.Testing.Commands.CommandScenarioSourceGivenBuilder`1";
+
+ /// The extensions a specification issues a command through over HTTP.
+ public const string HttpClientExtensions = "Cratis.Chronicle.XUnit.Integration.HttpClientExtensions";
+
+ /// The attribute marking a method as one assertion of a specification.
+ public const string FactAttribute = "Xunit.FactAttribute";
+
/// The page of a result a query is performed for, which the host fills in from the request.
public const string Paging = "Cratis.Arc.Queries.Paging";
diff --git a/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs
index 403ca5534..f8d9986ae 100644
--- a/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs
+++ b/Source/DotNET/Screenplay/Emission/ApplicationSyntaxBuilder.cs
@@ -12,6 +12,7 @@
using Cratis.Arc.Screenplay.Emission.Reactors;
using Cratis.Arc.Screenplay.Emission.Screens;
using Cratis.Arc.Screenplay.Emission.Slices;
+using Cratis.Arc.Screenplay.Emission.Specifications;
using Cratis.Arc.Screenplay.Emission.Types;
using Cratis.Arc.Screenplay.Emission.Validation;
using Cratis.Arc.Screenplay.Model;
@@ -145,7 +146,8 @@ SliceSyntaxBuilder CreateSliceBuilder() =>
new ConstraintSyntaxBuilder(naming),
new ReactorSyntaxBuilder(naming, diagnostics),
new ProjectionSyntaxBuilder(naming, diagnostics, _names),
- new ScreenSyntaxBuilder(naming, _types));
+ new ScreenSyntaxBuilder(naming, _types),
+ new SpecificationSyntaxBuilder(naming));
///
/// Sanitizes a document level name, falling back when it yields nothing usable.
diff --git a/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs
index e5ab049d2..585d50edc 100644
--- a/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs
+++ b/Source/DotNET/Screenplay/Emission/Slices/SliceSyntaxBuilder.cs
@@ -9,6 +9,7 @@
using Cratis.Arc.Screenplay.Emission.Queries;
using Cratis.Arc.Screenplay.Emission.Reactors;
using Cratis.Arc.Screenplay.Emission.Screens;
+using Cratis.Arc.Screenplay.Emission.Specifications;
using Cratis.Arc.Screenplay.Model;
using Cratis.Screenplay.Diagnostics;
using Cratis.Screenplay.Syntax;
@@ -27,6 +28,7 @@ namespace Cratis.Arc.Screenplay.Emission.Slices;
/// The for the reactors of the slice.
/// The for the projection of the slice.
/// The for the screens of the slice.
+/// The for the scenarios the slice is specified by.
public class SliceSyntaxBuilder(
IScreenplayNaming naming,
CommandSyntaxBuilder commands,
@@ -35,7 +37,8 @@ public class SliceSyntaxBuilder(
ConstraintSyntaxBuilder constraints,
ReactorSyntaxBuilder reactors,
ProjectionSyntaxBuilder projections,
- ScreenSyntaxBuilder screens)
+ ScreenSyntaxBuilder screens,
+ SpecificationSyntaxBuilder specifications)
{
///
/// The name given to a slice whose own name yields nothing usable.
@@ -73,7 +76,7 @@ .. slice.Constraints
.Select(_ => constraints.Build(_, slice.Namespace))
.OrderBy(_ => _.Name, StringComparer.Ordinal)
],
- [],
+ [.. specifications.Build(slice.Specifications)],
SourceLocation.Start,
naming.ToStringLiteral(slice.Description));
diff --git a/Source/DotNET/Screenplay/Emission/Specifications/SpecificationSyntaxBuilder.cs b/Source/DotNET/Screenplay/Emission/Specifications/SpecificationSyntaxBuilder.cs
new file mode 100644
index 000000000..89b941387
--- /dev/null
+++ b/Source/DotNET/Screenplay/Emission/Specifications/SpecificationSyntaxBuilder.cs
@@ -0,0 +1,84 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Screenplay.Emission.Expressions;
+using Cratis.Arc.Screenplay.Emission.Naming;
+using Cratis.Arc.Screenplay.Model;
+using Cratis.Screenplay.Diagnostics;
+using Cratis.Screenplay.Syntax;
+using Cratis.Screenplay.Syntax.Specifications;
+
+namespace Cratis.Arc.Screenplay.Emission.Specifications;
+
+///
+/// Builds the Screenplay specification blocks of a slice.
+///
+/// The used for name conversion.
+///
+/// Nothing a step states is ever left out over its name. Every other block decides what a line is from its first
+/// word, so a property called after a directive collides with it; the values of a step are written one level deeper
+/// than the step itself and the step takes every line beneath it as a value, whatever its first word says.
+///
+public class SpecificationSyntaxBuilder(IScreenplayNaming naming)
+{
+ readonly MappingSourceConverter _sources = new(naming);
+
+ ///
+ /// Builds the specifications of a slice.
+ ///
+ /// The scenarios the slice is specified by.
+ /// The specifications, ordered by name.
+ public IEnumerable Build(IEnumerable specifications) =>
+ [
+ .. specifications
+ .Select(Build)
+ .OrderBy(_ => _.Name, StringComparer.Ordinal)
+ ];
+
+ ///
+ /// Builds one specification.
+ ///
+ /// The scenario to build for.
+ /// The .
+ SpecificationSyntax Build(SpecificationModel specification) =>
+ new(
+ naming.ToDeclarationName(specification.Name),
+ [.. Events(specification.Given)],
+ new SpecificationCommandSyntax(
+ naming.ToDeclarationName(specification.When.Name),
+ [.. Values(specification.When)],
+ SourceLocation.Start),
+ [.. Events(specification.Then)],
+ [.. specification.Errors.Select(_ => new SpecificationErrorSyntax(naming.ToStringLiteral(_) ?? string.Empty, SourceLocation.Start))],
+ SourceLocation.Start,
+ [.. ReadModels(specification.Given)],
+ [.. ReadModels(specification.Then)]);
+
+ ///
+ /// Builds the states of a step that name an event.
+ ///
+ /// The states to build from.
+ /// The events.
+ IEnumerable Events(IEnumerable states) =>
+ states
+ .Where(_ => _.Kind == SpecificationStateKind.Event)
+ .Select(_ => new SpecificationEventSyntax(naming.ToDeclarationName(_.Name), [.. Values(_)], SourceLocation.Start));
+
+ ///
+ /// Builds the states of a step that name a read model.
+ ///
+ /// The states to build from.
+ /// The read models.
+ IEnumerable ReadModels(IEnumerable states) =>
+ states
+ .Where(_ => _.Kind == SpecificationStateKind.ReadModel)
+ .Select(_ => new SpecificationReadModelSyntax(naming.ToDeclarationName(_.Name), [.. Values(_)], SourceLocation.Start));
+
+ ///
+ /// Builds the values a step states.
+ ///
+ /// The state to build from.
+ /// The values.
+ IEnumerable Values(SpecificationStateModel state) =>
+ state.Values.Select(_ => new PropertyMappingSyntax(naming.ToPropertyName(_.Property), _sources.Convert(_.Source), SourceLocation.Start));
+}
diff --git a/Source/DotNET/Screenplay/Model/SliceModel.cs b/Source/DotNET/Screenplay/Model/SliceModel.cs
index 9aa5a0838..aeb944390 100644
--- a/Source/DotNET/Screenplay/Model/SliceModel.cs
+++ b/Source/DotNET/Screenplay/Model/SliceModel.cs
@@ -37,6 +37,16 @@ public record SliceModel(
///
public IEnumerable Screens { get; init; } = [];
+ ///
+ /// Gets the scenarios the slice is specified by.
+ ///
+ ///
+ /// A scenario is recovered from a namespace beneath the slice's own rather than from the slice's, so it arrives
+ /// once every namespace has been read and which of them declare a slice is known - which is after the slice
+ /// itself exists.
+ ///
+ public IEnumerable Specifications { get; init; } = [];
+
///
/// Creates a slice that declares nothing, for use as a starting point.
///
diff --git a/Source/DotNET/Screenplay/Model/SpecificationModel.cs b/Source/DotNET/Screenplay/Model/SpecificationModel.cs
new file mode 100644
index 000000000..fa68980f1
--- /dev/null
+++ b/Source/DotNET/Screenplay/Model/SpecificationModel.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.Model;
+
+///
+/// Represents one scenario a slice is specified by - what had happened, the command that was issued and what
+/// followed.
+///
+/// The name of the specification.
+/// What had already happened when the command was issued.
+/// The command that was issued.
+/// The events and read model states that followed.
+/// The rejections that followed, each named by the reason the source gives for it.
+///
+/// A rejection the source names no reason for carries an empty one. The scenario is named after the words the
+/// source itself uses for it, so what the rejection was about is already said by the name and inventing a sentence
+/// to repeat it would be describing an application nobody wrote.
+///
+public record SpecificationModel(
+ string Name,
+ IEnumerable Given,
+ SpecificationStateModel When,
+ IEnumerable Then,
+ IEnumerable Errors);
diff --git a/Source/DotNET/Screenplay/Model/SpecificationStateKind.cs b/Source/DotNET/Screenplay/Model/SpecificationStateKind.cs
new file mode 100644
index 000000000..6bf316bd5
--- /dev/null
+++ b/Source/DotNET/Screenplay/Model/SpecificationStateKind.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.Model;
+
+///
+/// Represents what a state a specification names actually is.
+///
+public enum SpecificationStateKind
+{
+ ///
+ /// An event, which is what a given and a then name by default.
+ ///
+ Event = 0,
+
+ ///
+ /// A read model, which a given and a then name behind the readmodel keyword.
+ ///
+ ReadModel = 1,
+
+ ///
+ /// A command, which is what the single when of a specification names.
+ ///
+ Command = 2
+}
diff --git a/Source/DotNET/Screenplay/Model/SpecificationStateModel.cs b/Source/DotNET/Screenplay/Model/SpecificationStateModel.cs
new file mode 100644
index 000000000..0bdaf3dec
--- /dev/null
+++ b/Source/DotNET/Screenplay/Model/SpecificationStateModel.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Cratis.Arc.Screenplay.Model;
+
+///
+/// Represents one artifact a specification names, together with the values it states for it.
+///
+/// The name of the event, read model or command being named.
+/// What the named artifact is.
+/// The values the specification states, in the order the source declares them.
+///
+/// A given, a when and a then are the same shape - a name and a list of values - so one model
+/// covers all three and the step it belongs to says which of them it is.
+///
+public record SpecificationStateModel(string Name, SpecificationStateKind Kind, IEnumerable Values);
diff --git a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs
index 2969ec1b1..19e68754c 100644
--- a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs
+++ b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs
@@ -341,4 +341,30 @@ public static class ScreenplayDiagnosticCodes
///
///
public const string ProjectsWithoutASharedRoot = "SP0038";
+
+ ///
+ /// A scenario a slice is specified by states a step that could not be read, so the whole scenario was left out.
+ ///
+ ///
+ /// A specification is one concrete example, and an example missing the command it issues, the state it started
+ /// from or the outcome it expects is not that example - it is a different one nobody wrote. So unlike a mapping,
+ /// which stands on its own and can be left out while the rest of the block still says something true, a step that
+ /// cannot be read takes the scenario with it. What made it unreadable is said, because the difference between a
+ /// scenario resting on a value computed at run time and one resting on a helper the reader could inline is the
+ /// difference between a gap that will always be there and one an afternoon closes.
+ ///
+ public const string UnreadableSpecification = "SP0039";
+
+ ///
+ /// A value a scenario states is code rather than a constant, so the scenario states everything but that value.
+ ///
+ ///
+ /// A scenario is written in the host language, where the identity two steps agree on is routinely a fresh
+ /// identifier held in a field rather than something written down twice. Screenplay states values and has no way
+ /// to name one, so such a value is left out and the rest of the scenario stands: which events had happened, which
+ /// command was issued and what followed are all still exactly what the source says. This is reported per value
+ /// rather than per scenario for the same reason a produces mapping is - a reader counting what a scenario states
+ /// against what the source states otherwise has no way of knowing which of the two it is looking at.
+ ///
+ public const string UnreadableSpecificationValue = "SP0040";
}