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!; + } + } } }