diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_slice_whose_generated_members_sit_under_obj.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_slice_whose_generated_members_sit_under_obj.cs
new file mode 100644
index 000000000..94e958504
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_slice_whose_generated_members_sit_under_obj.cs
@@ -0,0 +1,59 @@
+// 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 source generator writes partial members into a slice's namespace and emits them to disk under obj/. Those
+/// files contribute symbols to the slice but say nothing about where its source - and therefore its screens - live, so
+/// the slice is not spread over the folder they sit in and screen discovery must not scan it.
+///
+public class a_slice_whose_generated_members_sit_under_obj : Specification
+{
+ const string Source = """
+ using Cratis.Arc.Commands.ModelBound;
+ using Cratis.Chronicle.Events;
+
+ namespace Library.Authors.Registration;
+
+ [EventType]
+ public record AuthorRegistered(string Name);
+
+ [Command]
+ public partial record RegisterAuthor(string Name)
+ {
+ public AuthorRegistered Handle() => new(Name);
+ }
+ """;
+
+ const string Generated = """
+ namespace Library.Authors.Registration;
+
+ public partial record RegisterAuthor
+ {
+ }
+ """;
+
+ static readonly DeclaredUserInterfaceFiles _files = new(
+ "Library/Authors/Registration/AddAuthor.tsx");
+
+ ApplicationModelAnalysis _analysis;
+ IEnumerable _screens;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(
+ _files,
+ ("Library/Authors/Registration/Registration.cs", Source),
+ ("Library/obj/Debug/net10.0/Generator/RegisterAuthor.Logging.g.cs", Generated));
+ _screens = _analysis.Slice().Screens;
+ }
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Authors/Registration/Registration.cs", Source), ("Library/obj/Debug/net10.0/Generator/RegisterAuthor.Logging.g.cs", Generated)).ShouldBeEmpty();
+ [Fact] void should_not_report_the_slice_as_spread_over_folders() => _analysis.Diagnostics.Select(_ => _.Code).ShouldNotContain(ScreenplayDiagnosticCodes.AmbiguousScreenFile);
+ [Fact] void should_still_recover_the_screen_from_the_authored_folder() => _screens.Select(_ => _.Name).ShouldContainOnly(["AddAuthor"]);
+ [Fact] void should_point_at_the_file_realizing_the_screen() => _screens.Single().FilePath.ShouldEqual("Authors/Registration/AddAuthor.tsx");
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_whose_message_is_a_lambda_returning_a_constant.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_whose_message_is_a_lambda_returning_a_constant.cs
new file mode 100644
index 000000000..ee18b6695
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_whose_message_is_a_lambda_returning_a_constant.cs
@@ -0,0 +1,62 @@
+// 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;
+
+///
+/// FluentValidation lets a message be a value or a lambda producing one, and keeping messages in one place behind a
+/// constant - WithMessage(_ => Messages.NameRequired) - is the common way an application writes them. The
+/// lambda body is a plain reference the semantic model reads a compile-time constant from, so the message is
+/// recovered exactly as it would be from a value written inline.
+///
+public class a_validator_whose_message_is_a_lambda_returning_a_constant : Specification
+{
+ const string Source = """
+ using Cratis.Arc.Commands;
+ using Cratis.Arc.Commands.ModelBound;
+ using FluentValidation;
+
+ namespace Library.Authors.Registration;
+
+ public static class AuthorMessages
+ {
+ public const string NameRequired = "An author must have a name";
+ }
+
+ [Command]
+ public record RegisterAuthor(string Name, string Email)
+ {
+ public void Handle()
+ {
+ }
+ }
+
+ public class RegisterAuthorValidator : CommandValidator
+ {
+ public RegisterAuthorValidator()
+ {
+ RuleFor(_ => _.Name).NotEmpty().WithMessage(_ => AuthorMessages.NameRequired);
+ RuleFor(_ => _.Email).EmailAddress().WithMessage(_ => "An email must look like one");
+ }
+ }
+ """;
+
+ ApplicationModelAnalysis _analysis;
+ IEnumerable _rules;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(Source);
+ _rules = _analysis.Slice().Commands.First().Validations;
+ }
+
+ ValidationRuleModel Rule(ValidationRuleKind kind) => _rules.First(_ => _.Kind == kind);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Feature/Slice/Slice.cs", Source)).ShouldBeEmpty();
+ [Fact] void should_recover_a_message_from_a_lambda_returning_a_constant() => Rule(ValidationRuleKind.NotEmpty).Message.ShouldEqual("An author must have a name");
+ [Fact] void should_recover_a_message_from_a_lambda_returning_a_literal() => Rule(ValidationRuleKind.Matches).Message.ShouldEqual("An email must look like one");
+ [Fact] void should_leave_no_message_unrecovered() => _analysis.Diagnostics.Select(_ => _.Code).ShouldNotContain(ScreenplayDiagnosticCodes.UnmappableValidationRule);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_whose_message_is_computed.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_whose_message_is_computed.cs
new file mode 100644
index 000000000..63cdf2467
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_whose_message_is_computed.cs
@@ -0,0 +1,52 @@
+// 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 message a lambda computes at runtime - an interpolation, a call, a culture-dependent lookup - has no
+/// compile-time constant to read, so there is nothing to write into the document. It stays reported rather than
+/// guessed at, which is what keeps the recovery of the constant forms honest.
+///
+public class a_validator_whose_message_is_computed : Specification
+{
+ const string Source = """
+ using Cratis.Arc.Commands;
+ using Cratis.Arc.Commands.ModelBound;
+ using FluentValidation;
+
+ namespace Library.Authors.Registration;
+
+ [Command]
+ public record RegisterAuthor(string Name)
+ {
+ public void Handle()
+ {
+ }
+ }
+
+ public class RegisterAuthorValidator : CommandValidator
+ {
+ public RegisterAuthorValidator()
+ {
+ RuleFor(_ => _.Name).NotEmpty().WithMessage(_ => $"The name '{_.Name}' is not allowed");
+ }
+ }
+ """;
+
+ ApplicationModelAnalysis _analysis;
+ IEnumerable _rules;
+
+ void Establish()
+ {
+ _analysis = Analyzed.Source(Source);
+ _rules = _analysis.Slice().Commands.First().Validations;
+ }
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Feature/Slice/Slice.cs", Source)).ShouldBeEmpty();
+ [Fact] void should_leave_the_rule_without_a_message() => _rules.First(_ => _.Kind == ValidationRuleKind.NotEmpty).Message.ShouldBeNull();
+ [Fact] void should_report_the_message_it_could_not_recover() => _analysis.Diagnostics.Select(_ => _.Code).ShouldContain(ScreenplayDiagnosticCodes.UnmappableValidationRule);
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/concepts_carried_only_inside_a_record.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/concepts_carried_only_inside_a_record.cs
new file mode 100644
index 000000000..18f5127f8
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/concepts_carried_only_inside_a_record.cs
@@ -0,0 +1,75 @@
+// 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 record is referred to by name and never declared, so nothing inside it is ever named on its own. Collecting only
+/// the types written straight onto an artifact therefore lost every concept reached through one - and a concept marked
+/// as personal data lost that way leaves a document understating what the application holds about people, which is the
+/// one thing declaring concepts is most for. A concept can be declared wherever it was reached from, so it is.
+///
+public class concepts_carried_only_inside_a_record : Specification
+{
+ const string Source = """
+ using System;
+ using System.Collections.Generic;
+ using Cratis.Arc.Queries.ModelBound;
+ using Cratis.Chronicle.Compliance.GDPR;
+ using Cratis.Chronicle.Events;
+ using Cratis.Concepts;
+
+ namespace Library.Authors.Registration;
+
+ public record AuthorId(Guid Value) : ConceptAs(Value);
+
+ [PII("The name of a person")]
+ public record FirstName(string Value) : ConceptAs(Value);
+
+ public record MentorNote(string Value) : ConceptAs(Value);
+
+ public record ShelfCode(string Value) : ConceptAs(Value);
+
+ public enum ContactPreference { Email, Phone }
+
+ public record PersonalDetails(FirstName First, ContactPreference Preference);
+
+ public record Mentorship(MentorNote Note, Mentorship? Next);
+
+ [EventType]
+ public record AuthorRegistered(AuthorId Id, PersonalDetails Details, IEnumerable Mentors);
+
+ [ReadModel]
+ public record Author
+ {
+ public string Id { get; init; } = string.Empty;
+
+ public ShelfCode Shelf { get; init; } = new(string.Empty);
+
+ public static IEnumerable All() => [];
+ }
+ """;
+
+ ApplicationModelAnalysis _analysis;
+
+ void Establish() => _analysis = Analyzed.Source(Source);
+
+ ConceptModel Concept(string name) => _analysis.Model.Concepts.First(_ => _.Name == name);
+
+ IEnumerable Shapes =>
+ _analysis.Diagnostics.Where(_ => _.Code == ScreenplayDiagnosticCodes.UndeclarableShape);
+
+ [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Feature/Slice/Slice.cs", Source)).ShouldBeEmpty();
+ [Fact] void should_declare_a_concept_carried_inside_a_record() => Concept("FirstName").Primitive.ShouldEqual(ScreenplayPrimitive.String);
+ [Fact] void should_keep_what_that_concept_says_about_personal_data() => Concept("FirstName").IsPii.ShouldBeTrue();
+ [Fact] void should_declare_an_enumeration_carried_inside_a_record() => Concept("ContactPreference").EnumValues.ShouldContainOnly(["Email", "Phone"]);
+ [Fact] void should_declare_a_concept_carried_inside_a_record_a_collection_holds() => Concept("MentorNote").Primitive.ShouldEqual(ScreenplayPrimitive.String);
+ [Fact] void should_declare_a_concept_only_a_read_model_carries() => Concept("ShelfCode").Primitive.ShouldEqual(ScreenplayPrimitive.String);
+ [Fact] void should_walk_a_record_referring_to_itself_only_once() => _analysis.Model.Concepts.Select(_ => _.Name).ShouldContainOnly(["AuthorId", "ContactPreference", "FirstName", "MentorNote", "ShelfCode"]);
+ [Fact] void should_say_the_shape_of_a_record_a_property_carries_is_not_declared() => Shapes.Count().ShouldEqual(2);
+ [Fact] void should_name_the_records_it_could_not_declare() => Shapes.All(_ => _.Message.Contains("PersonalDetails", StringComparison.Ordinal) || _.Message.Contains("Mentorship", StringComparison.Ordinal)).ShouldBeTrue();
+ [Fact] void should_not_say_it_of_a_read_model_the_slice_describes() => Shapes.Any(_ => _.Message.Contains("Author'", StringComparison.Ordinal)).ShouldBeFalse();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_a_bin_segment.cs b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_a_bin_segment.cs
new file mode 100644
index 000000000..285728b03
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_a_bin_segment.cs
@@ -0,0 +1,15 @@
+// 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_GeneratedSource.when_checking_if_a_path_is_generated;
+
+public class with_a_bin_segment : Specification
+{
+ bool _result;
+
+ void Because() => _result = GeneratedSource.Is("/src/Core/bin/Release/net10.0/Slice.cs");
+
+ [Fact] void should_recognize_it_as_generated() => _result.ShouldBeTrue();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_a_generated_suffix.cs b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_a_generated_suffix.cs
new file mode 100644
index 000000000..5e9f0d347
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_a_generated_suffix.cs
@@ -0,0 +1,15 @@
+// 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_GeneratedSource.when_checking_if_a_path_is_generated;
+
+public class with_a_generated_suffix : Specification
+{
+ bool _result;
+
+ void Because() => _result = GeneratedSource.Is("/src/Core/Feature/Slice.g.cs");
+
+ [Fact] void should_recognize_it_as_generated() => _result.ShouldBeTrue();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_an_authored_path.cs b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_an_authored_path.cs
new file mode 100644
index 000000000..6496370f9
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_an_authored_path.cs
@@ -0,0 +1,15 @@
+// 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_GeneratedSource.when_checking_if_a_path_is_generated;
+
+public class with_an_authored_path : Specification
+{
+ bool _result;
+
+ void Because() => _result = GeneratedSource.Is("/src/Core/Feature/Slice/Slice.cs");
+
+ [Fact] void should_not_recognize_it_as_generated() => _result.ShouldBeFalse();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_an_obj_segment.cs b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_an_obj_segment.cs
new file mode 100644
index 000000000..94dbff8ab
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_an_obj_segment.cs
@@ -0,0 +1,15 @@
+// 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_GeneratedSource.when_checking_if_a_path_is_generated;
+
+public class with_an_obj_segment : Specification
+{
+ bool _result;
+
+ void Because() => _result = GeneratedSource.Is("/src/Core/obj/Debug/net10.0/Generator/Slice.g.cs");
+
+ [Fact] void should_recognize_it_as_generated() => _result.ShouldBeTrue();
+}
diff --git a/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_carrying_separators.cs b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_carrying_separators.cs
new file mode 100644
index 000000000..fcc5b8c3a
--- /dev/null
+++ b/Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_carrying_separators.cs
@@ -0,0 +1,34 @@
+// 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.for_ScreenplayNaming.when_making_a_declaration_name;
+
+///
+/// A runtime name is idiomatically written with separators - a Chronicle constraint named unique-timesheet-start
+/// is the common case - and a Screenplay identifier cannot hold one. The separators mark word boundaries, so the
+/// segments they mark are PascalCased and joined rather than run together into an unreadable identifier. A name that
+/// already looks like an identifier carries no separator and is left exactly as it was.
+///
+public class from_names_carrying_separators : given.a_naming
+{
+ string _kebabCased;
+ string _snakeCased;
+ string _spaceSeparated;
+ string _alreadyPascalCased;
+ string _acronym;
+
+ void Because()
+ {
+ _kebabCased = _naming.ToDeclarationName("unique-timesheet-start");
+ _snakeCased = _naming.ToDeclarationName("unique_invitation_email");
+ _spaceSeparated = _naming.ToDeclarationName("unique timesheet start");
+ _alreadyPascalCased = _naming.ToDeclarationName("TimesheetStarted");
+ _acronym = _naming.ToDeclarationName("ISBNValue");
+ }
+
+ [Fact] void should_pascal_case_a_kebab_cased_name() => _kebabCased.ShouldEqual("UniqueTimesheetStart");
+ [Fact] void should_pascal_case_a_snake_cased_name() => _snakeCased.ShouldEqual("UniqueInvitationEmail");
+ [Fact] void should_pascal_case_a_space_separated_name() => _spaceSeparated.ShouldEqual("UniqueTimesheetStart");
+ [Fact] void should_leave_an_already_pascal_cased_name_alone() => _alreadyPascalCased.ShouldEqual("TimesheetStarted");
+ [Fact] void should_leave_an_acronym_without_separators_alone() => _acronym.ShouldEqual("ISBNValue");
+}
diff --git a/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs
index b3bdf0f82..6bb0c74f3 100644
--- a/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs
+++ b/Source/DotNET/Screenplay/Analysis/ApplicationModelAnalyzer.cs
@@ -142,6 +142,14 @@ static void ReportTypesTheDocumentCannotName(ArtifactReaders readers, Screenplay
$"'{type}' shares its simple name with a concept the document already declares, so what it is is described by the first one instead",
location);
}
+
+ foreach (var shape in readers.Types.Shapes)
+ {
+ diagnostics.Information(
+ ScreenplayDiagnosticCodes.UndeclarableShape,
+ $"'{shape}' is a record an artifact carries, and there is no way to declare what a record holds, so the document names it without saying what is in it - the concepts it carries are declared, the shape itself is not",
+ location);
+ }
}
///
diff --git a/Source/DotNET/Screenplay/Analysis/GeneratedSource.cs b/Source/DotNET/Screenplay/Analysis/GeneratedSource.cs
new file mode 100644
index 000000000..c860b4764
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/GeneratedSource.cs
@@ -0,0 +1,46 @@
+// 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.Analysis;
+
+///
+/// Recognizes source files that are generator output rather than source a developer wrote.
+///
+///
+/// A compilation is handed the files a build emits to disk alongside the files a developer authored. A source
+/// generator writing partial members into a slice's namespace contributes symbols to the slice but sits under
+/// obj/, so it says nothing about where the slice's source - and therefore its screens - actually live.
+/// Attributing a slice by such a path spreads it across folders it was never spread over.
+///
+public static class GeneratedSource
+{
+ ///
+ /// Determines whether a path names generator output rather than authored source.
+ ///
+ /// The path to check.
+ /// True when the path names generator output.
+ public static bool Is(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return false;
+ }
+
+ var normalized = path.Replace('\\', '/');
+
+ return HasOutputSegment(normalized, "obj") ||
+ HasOutputSegment(normalized, "bin") ||
+ normalized.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase) ||
+ normalized.EndsWith(".g.i.cs", StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Determines whether a normalized path carries a build-output directory as one of its segments.
+ ///
+ /// The path, already normalized onto forward slashes.
+ /// The output segment to look for.
+ /// True when the segment appears as a whole path segment.
+ static bool HasOutputSegment(string normalized, string segment) =>
+ normalized.Contains($"/{segment}/", StringComparison.OrdinalIgnoreCase) ||
+ normalized.StartsWith($"{segment}/", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/Source/DotNET/Screenplay/Analysis/PropertyReader.cs b/Source/DotNET/Screenplay/Analysis/PropertyReader.cs
index 9d393d2a5..8e1f0b39e 100644
--- a/Source/DotNET/Screenplay/Analysis/PropertyReader.cs
+++ b/Source/DotNET/Screenplay/Analysis/PropertyReader.cs
@@ -39,7 +39,7 @@ public IEnumerable Read(ITypeSymbol type)
types.MarkAsPii(property.Type);
}
- properties.Add(new(property.Name, types.Resolve(property.Type)));
+ properties.Add(new(property.Name, types.ResolveCarried(property.Type)));
}
return properties;
diff --git a/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs b/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs
index 2d999286e..4a4c9afb9 100644
--- a/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs
+++ b/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs
@@ -25,7 +25,7 @@ public static IReadOnlyList Of(IEnumerable types) =>
.. types
.SelectMany(_ => _.DeclaringSyntaxReferences)
.Select(_ => _.SyntaxTree.FilePath)
- .Where(_ => !string.IsNullOrWhiteSpace(_))
+ .Where(_ => !string.IsNullOrWhiteSpace(_) && !GeneratedSource.Is(_))
.Select(ScreenFiles.DirectoryOf)
.Where(_ => _.Length > 0)
.Distinct(StringComparer.Ordinal)
diff --git a/Source/DotNET/Screenplay/Analysis/SourcePaths.cs b/Source/DotNET/Screenplay/Analysis/SourcePaths.cs
index 284e94565..10d8864bc 100644
--- a/Source/DotNET/Screenplay/Analysis/SourcePaths.cs
+++ b/Source/DotNET/Screenplay/Analysis/SourcePaths.cs
@@ -37,10 +37,10 @@ public class SourcePaths(string root)
///
public static SourcePaths For(Compilation compilation, ArtifactCatalog catalog)
{
- var declared = DirectoriesOf(catalog.Types.Select(_ => _.SourceFilePath()));
+ var declared = DirectoriesOf(catalog.Types.Select(_ => _.SourceFilePath()).Where(_ => !GeneratedSource.Is(_)));
var anchor = DeepestSharedBy(declared);
var project = Rooted(declared, anchor);
- var root = Rooted(DirectoriesOf(compilation.SyntaxTrees.Select(_ => _.FilePath)), project);
+ var root = Rooted(DirectoriesOf(compilation.SyntaxTrees.Select(_ => _.FilePath).Where(_ => !GeneratedSource.Is(_))), project);
return new(IsFileSystemRoot(root) ? string.Empty : root);
}
diff --git a/Source/DotNET/Screenplay/Analysis/Types/CarriedTypes.cs b/Source/DotNET/Screenplay/Analysis/Types/CarriedTypes.cs
new file mode 100644
index 000000000..23cfe8b47
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Types/CarriedTypes.cs
@@ -0,0 +1,80 @@
+// 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.Types;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Types;
+
+///
+/// Finds every type a record carries, however far down it is carried.
+///
+///
+/// A concept is declared once at the top of a document and referred to by name, and a concept nothing refers to has no
+/// reason to be declared - which is why only the types artifacts really name are collected. Reaching only the outermost
+/// position took that too far: a name carried inside a line of an approved timesheet is referred to by the application
+/// just as much as one written straight onto an event, and a document leaving it out understates what the application
+/// holds. Where that value is marked as personal data, the document then understates something the reader is answerable
+/// for, which is the opposite of what declaring concepts is for.
+///
+public static class CarriedTypes
+{
+ ///
+ /// Determines whether a type is a record carrying values rather than being one.
+ ///
+ /// The type to check.
+ /// True when the type is a record whose members are worth walking.
+ ///
+ /// A record is what a value carrying several values is written as, and stopping at that is what keeps the walk
+ /// finite and about the application. Following every class a property mentions would descend into the framework
+ /// types the application merely touches, and a constructed generic is a type the document cannot name at all.
+ ///
+ public static bool IsRecord(ITypeSymbol type) =>
+ type is INamedTypeSymbol { IsRecord: true, TypeArguments.Length: 0 } record &&
+ !ScreenplayPrimitiveTypes.TryResolve(record.FullMetadataName(), out _) &&
+ record.FindBase(WellKnownTypeNames.ConceptAs) is null;
+
+ ///
+ /// Gets every type reachable through the members of a record.
+ ///
+ /// The type to walk.
+ /// The types, ordered so that the same source always reads the same way.
+ ///
+ /// A record referring to itself, directly or around a loop, is walked once - the second time round would say
+ /// nothing new and never end. What comes back is ordered by name rather than by the order the walk happened to
+ /// reach it, so that two records naming the same concept differently still leave the same document.
+ ///
+ public static IReadOnlyList Within(ITypeSymbol type)
+ {
+ var found = new Dictionary(StringComparer.Ordinal);
+
+ if (IsRecord(type))
+ {
+ Walk(type, found, new HashSet(StringComparer.Ordinal) { type.ToDisplayString() });
+ }
+
+ return [.. found.OrderBy(_ => _.Key, StringComparer.Ordinal).Select(_ => _.Value)];
+ }
+
+ ///
+ /// Collects the types the members of one record carry, descending into the records among them.
+ ///
+ /// The record to walk.
+ /// Everything found so far, keyed by the name it is told apart by.
+ /// The records already walked.
+ static void Walk(ITypeSymbol type, Dictionary found, HashSet walked)
+ {
+ foreach (var property in type.DeclaredProperties())
+ {
+ var carried = UnderlyingTypes.Of(property.Type);
+ var name = carried.ToDisplayString();
+
+ found.TryAdd(name, carried);
+
+ if (IsRecord(carried) && walked.Add(name))
+ {
+ Walk(carried, found, walked);
+ }
+ }
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Types/ConceptRegistry.cs b/Source/DotNET/Screenplay/Analysis/Types/ConceptRegistry.cs
new file mode 100644
index 000000000..27f668e42
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Types/ConceptRegistry.cs
@@ -0,0 +1,163 @@
+// 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.Types;
+using Cratis.Arc.Screenplay.Model;
+using Microsoft.CodeAnalysis;
+
+namespace Cratis.Arc.Screenplay.Analysis.Types;
+
+///
+/// Collects the concepts an application refers to, keeping one declaration per name.
+///
+///
+/// A concept is declared once at the top of the document and referenced by its simple name from there on, so which
+/// concepts a document declares has nothing to do with which ones the application defines and everything to do with
+/// which ones were reached while a type was being resolved. They are gathered as they are encountered rather than
+/// found up front, which keeps the document to what the application actually uses.
+///
+/// What a concept says about itself arrives from more than one place and at different moments - the values of an
+/// enumeration come from the type, the mark saying it carries personal data comes from the property referring to it,
+/// and the rules it holds its own value to come from a validator read later still. Only the declaration is kept as
+/// the concept; the rest is kept beside it and folded in when the concepts are read back, so that a mark or a rule
+/// arriving after the concept was first seen still lands on it.
+///
+///
+public class ConceptRegistry
+{
+ readonly Dictionary _concepts = new(StringComparer.Ordinal);
+ readonly Dictionary> _validations = new(StringComparer.Ordinal);
+ readonly HashSet _pii = new(StringComparer.Ordinal);
+ readonly HashSet _ambiguous = new(StringComparer.Ordinal);
+
+ ///
+ /// Gets the full name of every type whose simple name a concept was already declared under.
+ ///
+ public IEnumerable Ambiguous => _ambiguous.Order(StringComparer.Ordinal);
+
+ ///
+ /// Gets every concept referenced by the application, ordered by name.
+ ///
+ public IEnumerable Concepts =>
+ [
+ .. _concepts.Values
+ .Select(_ => _ with
+ {
+ IsPii = _.IsPii || _pii.Contains(_.Name),
+ Validations = _validations.TryGetValue(_.Name, out var rules) ? rules : []
+ })
+ .OrderBy(_ => _.Name, StringComparer.Ordinal)
+ ];
+
+ ///
+ /// Registers a type as a concept when it is one.
+ ///
+ /// The type to register.
+ /// True when the type is a concept and was registered.
+ ///
+ /// An enumeration and a type backed by ConceptAs are both one value with a name, which is what a concept
+ /// is, and both are therefore declared. Anything else is a type referred to by name and never declared, which is
+ /// what the false answer says.
+ ///
+ public bool TryRegister(ITypeSymbol type)
+ {
+ if (type.TypeKind == TypeKind.Enum)
+ {
+ Register(type, new(type.Name, ScreenplayPrimitive.Enum, false, ValuesOf(type), []));
+
+ return true;
+ }
+
+ if (type.FindBase(WellKnownTypeNames.ConceptAs) is { } concept)
+ {
+ Register(type, ToConcept(type, concept.TypeArguments[0]));
+
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Records that a value of a concept carries personally identifiable information.
+ ///
+ /// The type of the value.
+ ///
+ /// The concept a value is marked under has to be the one it is referenced under, or the mark lands on a name no
+ /// concept is declared with and the document says a value is not sensitive while the runtime encrypts it. Both
+ /// therefore strip the same wrappers - a collection of an optional concept says one thing about the value and
+ /// three things about how many there are and whether it may be absent.
+ ///
+ public void MarkAsPii(ITypeSymbol type) => _pii.Add(UnderlyingTypes.Of(type).Name);
+
+ ///
+ /// Records the validation rules a concept declares for itself.
+ ///
+ /// The name of the concept.
+ /// The rules to record.
+ public void AddValidations(string conceptName, IEnumerable rules)
+ {
+ if (!_validations.TryGetValue(conceptName, out var declared))
+ {
+ declared = [];
+ _validations[conceptName] = declared;
+ }
+
+ declared.AddRange(rules);
+ }
+
+ ///
+ /// Gets the values of an enumeration, in declaration order.
+ ///
+ /// The enumeration to read.
+ /// The value names.
+ static IEnumerable ValuesOf(ITypeSymbol type) =>
+ [.. type.GetMembers().OfType().Where(_ => _.HasConstantValue).Select(_ => _.Name)];
+
+ ///
+ /// Builds the concept a type backed by ConceptAs declares.
+ ///
+ /// The concept type.
+ /// The type the concept is backed by.
+ /// The .
+ ConceptModel ToConcept(ITypeSymbol type, ITypeSymbol backing)
+ {
+ var pii = type.HasAttribute(WellKnownTypeNames.PiiAttribute);
+
+ if (backing.TypeKind == TypeKind.Enum)
+ {
+ return new(type.Name, ScreenplayPrimitive.Enum, pii, ValuesOf(backing), []);
+ }
+
+ var resolved = backing is INamedTypeSymbol named && ScreenplayPrimitiveTypes.TryResolve(named.FullMetadataName(), out var primitive)
+ ? primitive
+ : ScreenplayPrimitive.String;
+
+ return new(type.Name, resolved, pii, [], []);
+ }
+
+ ///
+ /// Registers a concept, keeping the first declaration of a given name.
+ ///
+ /// The type the concept was read from.
+ /// The concept to register.
+ ///
+ /// A concept is declared once at the top of the document and referenced by its simple name, so two types sharing
+ /// that name cannot both be described. Keeping the first is the only choice left, and saying so is what stops the
+ /// document from quietly claiming the second one is something it is not.
+ ///
+ void Register(ITypeSymbol type, ConceptModel concept)
+ {
+ if (!_concepts.TryGetValue(concept.Name, out var existing))
+ {
+ _concepts[concept.Name] = concept;
+
+ return;
+ }
+
+ if (existing.Primitive != concept.Primitive || !existing.EnumValues.SequenceEqual(concept.EnumValues, StringComparer.Ordinal))
+ {
+ _ambiguous.Add(type.ToDisplayString());
+ }
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs b/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs
index 7c64c2bbe..bd864d5e7 100644
--- a/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs
+++ b/Source/DotNET/Screenplay/Analysis/Types/TypeRegistry.cs
@@ -11,41 +11,41 @@ namespace Cratis.Arc.Screenplay.Analysis.Types;
/// Resolves the type of a property, a parameter or a return value, collecting the concepts encountered along the way.
///
///
-/// A concept is declared once at the top of the document and referenced by name from there on, so every concept an
-/// artifact refers to has to be registered while its type is resolved. Only concepts that are actually referenced
-/// are declared, which keeps the document to what the application uses.
+/// Resolving a type is answering two questions at once. The first is what to write - a single identifier, whether
+/// there is one of it or many, and whether it may be absent. The second is what naming it commits the document to,
+/// because every concept reached on the way has to be declared before it can be referenced, and every name that says
+/// less than the type does has to be reported rather than passed off as a description.
+///
+/// The first question is answered here. The second is split: what a name loses and which shapes no declaration can
+/// hold are kept here because they are consequences of writing the name, while the concepts themselves are kept by a
+/// , which decides what a concept is and what happens when two of them share a name.
+///
///
public class TypeRegistry
{
- readonly Dictionary _concepts = new(StringComparer.Ordinal);
- readonly Dictionary> _validations = new(StringComparer.Ordinal);
- readonly HashSet _pii = new(StringComparer.Ordinal);
+ readonly ConceptRegistry _concepts = new();
readonly HashSet _unmappable = new(StringComparer.Ordinal);
- readonly HashSet _ambiguous = new(StringComparer.Ordinal);
+ readonly HashSet _shapes = new(StringComparer.Ordinal);
///
/// Gets the full name of every type that had to be referred to by a name that does not say what it is.
///
public IEnumerable Unmappable => _unmappable.Order(StringComparer.Ordinal);
+ ///
+ /// Gets the full name of every record a property carries whose shape no declaration can hold.
+ ///
+ public IEnumerable Shapes => _shapes.Order(StringComparer.Ordinal);
+
///
/// Gets the full name of every type whose simple name a concept was already declared under.
///
- public IEnumerable Ambiguous => _ambiguous.Order(StringComparer.Ordinal);
+ public IEnumerable Ambiguous => _concepts.Ambiguous;
///
/// Gets every concept referenced by the application, ordered by name.
///
- public IEnumerable Concepts =>
- [
- .. _concepts.Values
- .Select(_ => _ with
- {
- IsPii = _.IsPii || _pii.Contains(_.Name),
- Validations = _validations.TryGetValue(_.Name, out var rules) ? rules : []
- })
- .OrderBy(_ => _.Name, StringComparer.Ordinal)
- ];
+ public IEnumerable Concepts => _concepts.Concepts;
///
/// Resolves the Screenplay type reference a symbol corresponds to.
@@ -55,75 +55,49 @@ .. _concepts.Values
public TypeReferenceModel Resolve(ITypeSymbol type)
{
var optional = false;
- var current = Unwrap(type, ref optional);
- var collection = CollectionElements.ElementOf(current);
- if (collection is not null)
- {
- current = Unwrap(collection, ref optional);
- }
+ var collection = false;
- return new(NameOf(current), collection is not null, optional);
+ return new(NameOf(UnderlyingTypes.Of(type, ref optional, ref collection)), collection, optional);
}
///
- /// Records that a value of a concept carries personally identifiable information.
+ /// Resolves the Screenplay type reference of a value an artifact carries.
///
- /// The type of the value.
- public void MarkAsPii(ITypeSymbol type)
+ /// The type to resolve.
+ /// The .
+ ///
+ /// A property is where a record the document has no way to declare is really referred to - the line carrying it
+ /// names a shape nothing in the document introduces. That is asked here rather than everywhere a type is resolved,
+ /// because a query returning a read model refers to something the slice around it already describes, while a
+ /// property carrying a record refers to a shape stated nowhere at all.
+ ///
+ public TypeReferenceModel ResolveCarried(ITypeSymbol type)
{
var optional = false;
- var current = Unwrap(type, ref optional);
- var collection = CollectionElements.ElementOf(current);
+ var collection = false;
+ var carried = UnderlyingTypes.Of(type, ref optional, ref collection);
- _pii.Add((collection ?? current).Name);
- }
-
- ///
- /// Records the validation rules a concept declares for itself.
- ///
- /// The name of the concept.
- /// The rules to record.
- public void AddValidations(string conceptName, IEnumerable rules)
- {
- if (!_validations.TryGetValue(conceptName, out var declared))
+ if (CarriedTypes.IsRecord(carried))
{
- declared = [];
- _validations[conceptName] = declared;
+ _shapes.Add(carried.ToDisplayString());
}
- declared.AddRange(rules);
+ return new(NameOf(carried), collection, optional);
}
///
- /// Strips the wrappers that only say whether a value may be absent.
+ /// Records that a value of a concept carries personally identifiable information.
///
- /// The type to strip.
- /// Set when a wrapper said the value may be absent.
- /// The wrapped type.
- static ITypeSymbol Unwrap(ITypeSymbol type, ref bool optional)
- {
- if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable)
- {
- optional = true;
-
- return nullable.TypeArguments[0];
- }
-
- if (type.NullableAnnotation == NullableAnnotation.Annotated && type.IsReferenceType)
- {
- optional = true;
- }
-
- return type;
- }
+ /// The type of the value.
+ public void MarkAsPii(ITypeSymbol type) => _concepts.MarkAsPii(type);
///
- /// Gets the values of an enumeration, in declaration order.
+ /// Records the validation rules a concept declares for itself.
///
- /// The enumeration to read.
- /// The value names.
- static IEnumerable ValuesOf(ITypeSymbol type) =>
- [.. type.GetMembers().OfType().Where(_ => _.HasConstantValue).Select(_ => _.Name)];
+ /// The name of the concept.
+ /// The rules to record.
+ public void AddValidations(string conceptName, IEnumerable rules) =>
+ _concepts.AddValidations(conceptName, rules);
///
/// Resolves the name a type is referenced by, registering it as a concept when it is one.
@@ -137,87 +111,50 @@ string NameOf(ITypeSymbol type)
return ScreenplayPrimitiveTypes.GetName(primitive);
}
- if (type.TypeKind == TypeKind.Enum)
- {
- Register(type, new(type.Name, ScreenplayPrimitive.Enum, false, ValuesOf(type), []));
-
- return type.Name;
- }
-
- if (type.FindBase(WellKnownTypeNames.ConceptAs) is { } concept)
+ if (_concepts.TryRegister(type))
{
- Register(type, ToConcept(type, concept.TypeArguments[0]));
-
return type.Name;
}
+ RegisterWhatItCarries(type);
ReportWhatTheNameLoses(type);
return type.Name;
}
///
- /// Records a type whose simple name says less than the type does.
+ /// Registers every concept a record carries, however far down it is carried.
///
/// The type being named.
///
- /// A read model or a nested object referred to by its own name is exactly right. A constructed generic is not -
- /// writing IDictionary<string, string> as the single identifier the grammar allows leaves the word
- /// KeyValuePair behind, which says nothing and which the document never declares. Same for a type
- /// parameter, whose name is a placeholder rather than a type.
+ /// A record is referred to by name and never declared, so nothing inside it is ever named on its own - which left
+ /// every concept reached only through a line of a timesheet or a property of a read model out of the document
+ /// entirely. A concept can be declared wherever it was reached from, so it is, and the shape carrying it waits on
+ /// the language.
///
- void ReportWhatTheNameLoses(ITypeSymbol type)
+ void RegisterWhatItCarries(ITypeSymbol type)
{
- if (type is INamedTypeSymbol { TypeArguments.Length: > 0 } or { TypeKind: TypeKind.TypeParameter })
+ foreach (var carried in CarriedTypes.Within(type))
{
- _unmappable.Add(type.ToDisplayString());
+ _concepts.TryRegister(carried);
}
}
///
- /// Builds the concept a type backed by ConceptAs declares.
- ///
- /// The concept type.
- /// The type the concept is backed by.
- /// The .
- ConceptModel ToConcept(ITypeSymbol type, ITypeSymbol backing)
- {
- var pii = type.HasAttribute(WellKnownTypeNames.PiiAttribute);
-
- if (backing.TypeKind == TypeKind.Enum)
- {
- return new(type.Name, ScreenplayPrimitive.Enum, pii, ValuesOf(backing), []);
- }
-
- var resolved = backing is INamedTypeSymbol named && ScreenplayPrimitiveTypes.TryResolve(named.FullMetadataName(), out var primitive)
- ? primitive
- : ScreenplayPrimitive.String;
-
- return new(type.Name, resolved, pii, [], []);
- }
-
- ///
- /// Registers a concept, keeping the first declaration of a given name.
+ /// Records a type whose simple name says less than the type does.
///
- /// The type the concept was read from.
- /// The concept to register.
+ /// The type being named.
///
- /// A concept is declared once at the top of the document and referenced by its simple name, so two types sharing
- /// that name cannot both be described. Keeping the first is the only choice left, and saying so is what stops the
- /// document from quietly claiming the second one is something it is not.
+ /// A read model or a nested object referred to by its own name is exactly right. A constructed generic is not -
+ /// writing IDictionary<string, string> as the single identifier the grammar allows leaves the word
+ /// KeyValuePair behind, which says nothing and which the document never declares. Same for a type
+ /// parameter, whose name is a placeholder rather than a type.
///
- void Register(ITypeSymbol type, ConceptModel concept)
+ void ReportWhatTheNameLoses(ITypeSymbol type)
{
- if (!_concepts.TryGetValue(concept.Name, out var existing))
- {
- _concepts[concept.Name] = concept;
-
- return;
- }
-
- if (existing.Primitive != concept.Primitive || !existing.EnumValues.SequenceEqual(concept.EnumValues, StringComparer.Ordinal))
+ if (type is INamedTypeSymbol { TypeArguments.Length: > 0 } or { TypeKind: TypeKind.TypeParameter })
{
- _ambiguous.Add(type.ToDisplayString());
+ _unmappable.Add(type.ToDisplayString());
}
}
}
diff --git a/Source/DotNET/Screenplay/Analysis/Types/UnderlyingTypes.cs b/Source/DotNET/Screenplay/Analysis/Types/UnderlyingTypes.cs
new file mode 100644
index 000000000..ba2fc65fa
--- /dev/null
+++ b/Source/DotNET/Screenplay/Analysis/Types/UnderlyingTypes.cs
@@ -0,0 +1,76 @@
+// 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.Types;
+
+///
+/// Strips everything a value is wrapped in that says how many there are or whether it may be absent.
+///
+///
+/// The value a type carries and the wrappers around it answer different questions, and every reader that asks either
+/// one has to strip the same wrappers to get the same answer. A collection of an optional concept says one thing about
+/// the value and three things about how many there are and whether it may be absent - so a reader marking that value
+/// as personal data and a reader naming it have to arrive at the same type, or the mark lands on a name nothing is
+/// declared with.
+///
+public static class UnderlyingTypes
+{
+ ///
+ /// Strips a type down to the value it carries.
+ ///
+ /// The type to strip.
+ /// Set when a wrapper said the value may be absent.
+ /// Set when the value is a collection of what is left.
+ /// The type of the value itself.
+ public static ITypeSymbol Of(ITypeSymbol type, ref bool optional, ref bool collection)
+ {
+ var current = Unwrap(type, ref optional);
+ var element = CollectionElements.ElementOf(current);
+ if (element is null)
+ {
+ return current;
+ }
+
+ collection = true;
+
+ return Unwrap(element, ref optional);
+ }
+
+ ///
+ /// Strips a type down to the value it carries, when nothing is asked about the wrappers.
+ ///
+ /// The type to strip.
+ /// The type of the value itself.
+ public static ITypeSymbol Of(ITypeSymbol type)
+ {
+ var optional = false;
+ var collection = false;
+
+ return Of(type, ref optional, ref collection);
+ }
+
+ ///
+ /// Strips the wrappers that only say whether a value may be absent.
+ ///
+ /// The type to strip.
+ /// Set when a wrapper said the value may be absent.
+ /// The wrapped type.
+ static ITypeSymbol Unwrap(ITypeSymbol type, ref bool optional)
+ {
+ if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable)
+ {
+ optional = true;
+
+ return nullable.TypeArguments[0];
+ }
+
+ if (type.NullableAnnotation == NullableAnnotation.Annotated && type.IsReferenceType)
+ {
+ optional = true;
+ }
+
+ return type;
+ }
+}
diff --git a/Source/DotNET/Screenplay/Analysis/Validation/ValidationChainReader.cs b/Source/DotNET/Screenplay/Analysis/Validation/ValidationChainReader.cs
index 60568f7a7..ad6b30f55 100644
--- a/Source/DotNET/Screenplay/Analysis/Validation/ValidationChainReader.cs
+++ b/Source/DotNET/Screenplay/Analysis/Validation/ValidationChainReader.cs
@@ -70,6 +70,26 @@ public void Read(
}
}
+ ///
+ /// Reads the expression a message is carried by, unwrapping the lambda form WithMessage is idiomatically
+ /// given.
+ ///
+ /// The argument the message was declared with.
+ /// The expression whose constant value is the message.
+ ///
+ /// FluentValidation lets a message be a value or a lambda producing one, and the lambda form pointing at a
+ /// message constant - WithMessage(_ => Messages.NameRequired) - is the common way an application keeps its
+ /// messages in one place. The lambda body is a plain reference the semantic model reads a compile-time constant
+ /// from, so the body is what the constant is asked of; a message computed at runtime - an interpolation, a call,
+ /// a culture-dependent lookup - has no constant to read and is left out as before.
+ ///
+ static ExpressionSyntax MessageExpression(ExpressionSyntax argument) => argument switch
+ {
+ SimpleLambdaExpressionSyntax { ExpressionBody: { } body } => body,
+ ParenthesizedLambdaExpressionSyntax { ExpressionBody: { } body } => body,
+ _ => argument
+ };
+
///
/// Reads the operand a rule compares against.
///
@@ -195,7 +215,9 @@ void ApplyMessage(
int preceding,
string location)
{
- var message = InvocationChain.ArgumentOf(call) is { } argument ? semanticModel.GetConstantValue(argument).Value as string : null;
+ var message = InvocationChain.ArgumentOf(call) is { } argument
+ ? semanticModel.GetConstantValue(MessageExpression(argument)).Value as string
+ : null;
if (message is null || preceding == 0)
{
diagnostics.Warning(
diff --git a/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs b/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs
index f46ecbd06..474581418 100644
--- a/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs
+++ b/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs
@@ -127,10 +127,18 @@ static string OnOneLine(string value)
}
///
- /// Strips everything that is not a valid identifier character, including generic type arity suffixes.
+ /// Strips everything that is not a valid identifier character, including generic type arity suffixes, and joins
+ /// separator-carrying names into a readable identifier.
///
/// The name to sanitize.
/// The sanitized name.
+ ///
+ /// A runtime name is idiomatically written with separators - a Chronicle constraint named unique-timesheet-start
+ /// is the common case. A Screenplay identifier cannot hold a separator, so the segments a separator marks are
+ /// PascalCased and joined rather than run together, which is the difference between UniqueTimesheetStart and
+ /// an unreadable uniquetimesheetstart. A name that carries no separator is left exactly as it was, so a
+ /// name already shaped like an identifier - and the acronym casing another step relies on - is untouched.
+ ///
static string Sanitize(string name)
{
if (string.IsNullOrWhiteSpace(name))
@@ -140,6 +148,65 @@ static string Sanitize(string name)
var backTick = name.IndexOf('`', StringComparison.Ordinal);
var candidate = backTick > 0 ? name[..backTick] : name;
+ var segments = Segments(candidate);
+
+ if (segments.Count <= 1)
+ {
+ return StripToIdentifier(candidate).Normalize(NormalizationForm.FormC);
+ }
+
+ var builder = new StringBuilder(candidate.Length);
+ foreach (var segment in segments)
+ {
+ builder
+ .Append(char.ToUpperInvariant(segment[0]))
+ .Append(segment, 1, segment.Length - 1);
+ }
+
+ return builder.ToString().Normalize(NormalizationForm.FormC);
+ }
+
+ ///
+ /// Splits a name into the runs of letters and digits the separators between them mark as words.
+ ///
+ /// The name to split.
+ /// The segments, in order.
+ static List Segments(string candidate)
+ {
+ var segments = new List();
+ var builder = new StringBuilder(candidate.Length);
+
+ foreach (var character in candidate)
+ {
+ if (char.IsLetterOrDigit(character))
+ {
+ builder.Append(character);
+ continue;
+ }
+
+ if (builder.Length > 0)
+ {
+ segments.Add(builder.ToString());
+ builder.Clear();
+ }
+ }
+
+ if (builder.Length > 0)
+ {
+ segments.Add(builder.ToString());
+ }
+
+ return segments;
+ }
+
+ ///
+ /// Keeps only the characters a single-segment name is allowed to carry, preserving the historical shape of a name
+ /// that carries no separator to bridge.
+ ///
+ /// The name to strip.
+ /// The stripped name.
+ static string StripToIdentifier(string candidate)
+ {
var builder = new StringBuilder(candidate.Length);
foreach (var character in candidate)
@@ -150,6 +217,6 @@ static string Sanitize(string name)
}
}
- return builder.ToString().Normalize(NormalizationForm.FormC);
+ return builder.ToString();
}
}
diff --git a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs
index f7dc7eddc..e2982dba9 100644
--- a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs
+++ b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs
@@ -224,4 +224,18 @@ public static class ScreenplayDiagnosticCodes
/// as it stands so the line that was rejected can be read.
///
public const string DocumentDidNotCompile = "SP0034";
+
+ ///
+ /// A value an artifact carries is a record, whose shape no declaration in the language can hold.
+ ///
+ ///
+ /// A concept is one value with a name, and every concept the application refers to is declared. A record carrying
+ /// several values is a different thing: an event property written as days ApprovedDayLine[] names a shape
+ /// the document has no construct to introduce, so what that line holds is stated nowhere - including anything
+ /// within it the application marks as personal data. The concepts inside it are recovered and declared, because a
+ /// concept can be declared wherever it was reached from; the shape itself waits on the language
+ /// (Cratis/Screenplay#29). This is reported rather than left unsaid because a reader counting what the document
+ /// declares against what the application holds otherwise has no way of knowing where the difference went.
+ ///
+ public const string UndeclarableShape = "SP0035";
}
diff --git a/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/RoleSecuredTypes.cs b/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/RoleSecuredTypes.cs
new file mode 100644
index 000000000..3842a001b
--- /dev/null
+++ b/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/RoleSecuredTypes.cs
@@ -0,0 +1,22 @@
+// 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.Authorization;
+
+namespace Cratis.Arc.ProxyGenerator.ControllerBased.for_MethodInfoExtensions;
+
+public class RoleSecuredTypes
+{
+ [Roles("Librarian")]
+ public void SingleRoleFromConstructor() { }
+
+ [Roles("Librarian", "Admin")]
+ public void MultipleRolesFromConstructor() { }
+
+ [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Librarian")]
+ public void RoleFromNamedArgument() { }
+
+ [Roles("Librarian")]
+ [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Librarian")]
+ public void RoleFromBothForms() { }
+}
diff --git a/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_both_forms_combined.cs b/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_both_forms_combined.cs
new file mode 100644
index 000000000..46d7b4b47
--- /dev/null
+++ b/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_both_forms_combined.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Reflection;
+
+namespace Cratis.Arc.ProxyGenerator.ControllerBased.for_MethodInfoExtensions.when_extracting_roles;
+
+public class from_both_forms_combined : Specification
+{
+ MethodInfo _method;
+ IEnumerable _result;
+
+ void Establish() => _method = typeof(RoleSecuredTypes).GetMethod(nameof(RoleSecuredTypes.RoleFromBothForms));
+
+ void Because() => _result = _method.GetRoles();
+
+ [Fact] void should_deduplicate_the_roles() => _result.ShouldContainOnly(["Librarian"]);
+}
diff --git a/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_constructor_form_with_multiple_roles.cs b/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_constructor_form_with_multiple_roles.cs
new file mode 100644
index 000000000..a539b2c75
--- /dev/null
+++ b/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_constructor_form_with_multiple_roles.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Reflection;
+
+namespace Cratis.Arc.ProxyGenerator.ControllerBased.for_MethodInfoExtensions.when_extracting_roles;
+
+public class from_constructor_form_with_multiple_roles : Specification
+{
+ MethodInfo _method;
+ IEnumerable _result;
+
+ void Establish() => _method = typeof(RoleSecuredTypes).GetMethod(nameof(RoleSecuredTypes.MultipleRolesFromConstructor));
+
+ void Because() => _result = _method.GetRoles();
+
+ [Fact] void should_yield_all_roles() => _result.ShouldContainOnly(["Librarian", "Admin"]);
+}
diff --git a/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_constructor_form_with_single_role.cs b/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_constructor_form_with_single_role.cs
new file mode 100644
index 000000000..fcfaf30df
--- /dev/null
+++ b/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_constructor_form_with_single_role.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Reflection;
+
+namespace Cratis.Arc.ProxyGenerator.ControllerBased.for_MethodInfoExtensions.when_extracting_roles;
+
+public class from_constructor_form_with_single_role : Specification
+{
+ MethodInfo _method;
+ IEnumerable _result;
+
+ void Establish() => _method = typeof(RoleSecuredTypes).GetMethod(nameof(RoleSecuredTypes.SingleRoleFromConstructor));
+
+ void Because() => _result = _method.GetRoles();
+
+ [Fact] void should_yield_the_role() => _result.ShouldContainOnly(["Librarian"]);
+}
diff --git a/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_named_argument_form.cs b/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_named_argument_form.cs
new file mode 100644
index 000000000..0649e53bd
--- /dev/null
+++ b/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_named_argument_form.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Reflection;
+
+namespace Cratis.Arc.ProxyGenerator.ControllerBased.for_MethodInfoExtensions.when_extracting_roles;
+
+public class from_named_argument_form : Specification
+{
+ MethodInfo _method;
+ IEnumerable _result;
+
+ void Establish() => _method = typeof(RoleSecuredTypes).GetMethod(nameof(RoleSecuredTypes.RoleFromNamedArgument));
+
+ void Because() => _result = _method.GetRoles();
+
+ [Fact] void should_yield_the_role() => _result.ShouldContainOnly(["Librarian"]);
+}
diff --git a/Source/DotNET/Tools/ProxyGenerator/MethodInfoExtensions.cs b/Source/DotNET/Tools/ProxyGenerator/MethodInfoExtensions.cs
index b624143c4..a46588e50 100644
--- a/Source/DotNET/Tools/ProxyGenerator/MethodInfoExtensions.cs
+++ b/Source/DotNET/Tools/ProxyGenerator/MethodInfoExtensions.cs
@@ -105,6 +105,7 @@ static IEnumerable GetRolesFromAttributesData(IEnumerable a.MemberName == "Roles");
if (rolesArg != default && rolesArg.TypedValue.Value is string rolesStr && !string.IsNullOrEmpty(rolesStr))
{
@@ -113,6 +114,16 @@ static IEnumerable GetRolesFromAttributesData(IEnumerable 0 &&
+ attr.ConstructorArguments[0].Value is IReadOnlyCollection roleArgs)
+ {
+ foreach (var role in roleArgs.Select(a => a.Value as string).Where(r => !string.IsNullOrEmpty(r)))
+ {
+ yield return role!;
+ }
+ }
}
}