From 43ac8e469e50aaa941d969966b3f93d710f01335 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:51:59 +0000 Subject: [PATCH 1/6] Fix proxy generator to read roles from [Roles("...")] constructor form (#2381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role extraction only inspected named arguments, so the idiomatic [Roles("...")] attribute — which sets Roles via a params string[] constructor — yielded no roles. Also read the constructor argument array so both the constructor and named-argument forms produce roles. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B1mrPcRT2GYjRxrx4T1p8N --- .../RoleSecuredTypes.cs | 23 +++++++++++++++++++ .../from_both_forms_combined.cs | 18 +++++++++++++++ ...om_constructor_form_with_multiple_roles.cs | 18 +++++++++++++++ .../from_constructor_form_with_single_role.cs | 18 +++++++++++++++ .../from_named_argument_form.cs | 18 +++++++++++++++ .../ProxyGenerator/MethodInfoExtensions.cs | 11 +++++++++ 6 files changed, 106 insertions(+) create mode 100644 Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/RoleSecuredTypes.cs create mode 100644 Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_both_forms_combined.cs create mode 100644 Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_constructor_form_with_multiple_roles.cs create mode 100644 Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_constructor_form_with_single_role.cs create mode 100644 Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/when_extracting_roles/from_named_argument_form.cs 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..f35713840 --- /dev/null +++ b/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/RoleSecuredTypes.cs @@ -0,0 +1,23 @@ +// 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; +using Microsoft.AspNetCore.Authorization; + +namespace Cratis.Arc.ProxyGenerator.ControllerBased.for_MethodInfoExtensions; + +public class RoleSecuredTypes +{ + [Roles("Librarian")] + public void SingleRoleFromConstructor() { } + + [Roles("Librarian", "Admin")] + public void MultipleRolesFromConstructor() { } + + [Authorize(Roles = "Librarian")] + public void RoleFromNamedArgument() { } + + [Roles("Librarian")] + [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!; + } + } } } From 603498d3c6356c4a6dd7c423b4d22a27b13e473c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:57:01 +0000 Subject: [PATCH 2/6] Exclude obj/ and bin/ output from slice source-folder attribution (#2395) Generator output emitted under obj/ contributes partial members to a slice's namespace but says nothing about where the slice's source or its screens live. Excluding generated files from folder attribution and the project-root vote removes false SP0025 diagnostics and stops screen discovery from scanning build-output trees. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B1mrPcRT2GYjRxrx4T1p8N --- ...e_whose_generated_members_sit_under_obj.cs | 59 +++++++++++++++++++ .../with_a_bin_segment.cs | 15 +++++ .../with_a_generated_suffix.cs | 15 +++++ .../with_an_authored_path.cs | 15 +++++ .../with_an_obj_segment.cs | 15 +++++ .../Screenplay/Analysis/GeneratedSource.cs | 46 +++++++++++++++ .../Analysis/Screens/SliceDirectories.cs | 1 + .../DotNET/Screenplay/Analysis/SourcePaths.cs | 4 +- 8 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_slice_whose_generated_members_sit_under_obj.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_a_bin_segment.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_a_generated_suffix.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_an_authored_path.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_GeneratedSource/when_checking_if_a_path_is_generated/with_an_obj_segment.cs create mode 100644 Source/DotNET/Screenplay/Analysis/GeneratedSource.cs 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_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/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/Screens/SliceDirectories.cs b/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs index 2d999286e..ec44dbc53 100644 --- a/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs +++ b/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs @@ -26,6 +26,7 @@ .. types .SelectMany(_ => _.DeclaringSyntaxReferences) .Select(_ => _.SyntaxTree.FilePath) .Where(_ => !string.IsNullOrWhiteSpace(_)) + .Where(_ => !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); } From 852a27a7c6bb7355ce1d585effbbfa5f4d577e99 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:00:44 +0000 Subject: [PATCH 3/6] PascalCase separator-carrying names into readable Screenplay identifiers (#2398) Constraint names written with separators (kebab-case is idiomatic for Chronicle constraint names) were sanitized by deleting the separators, producing run-together all-lowercase identifiers. Treat separators as word boundaries and PascalCase the segments, so 'unique-timesheet-start' becomes 'UniqueTimesheetStart'. Names that carry no separator are left untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B1mrPcRT2GYjRxrx4T1p8N --- .../from_names_carrying_separators.cs | 34 +++++++++ .../Emission/Naming/ScreenplayNaming.cs | 70 ++++++++++++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 Source/DotNET/Screenplay.Specs/for_ScreenplayNaming/when_making_a_declaration_name/from_names_carrying_separators.cs 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/Emission/Naming/ScreenplayNaming.cs b/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs index f46ecbd06..47bef3f15 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,64 @@ 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])); + builder.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 +216,6 @@ static string Sanitize(string name) } } - return builder.ToString().Normalize(NormalizationForm.FormC); + return builder.ToString(); } } From 3b97c7b5a45c5bdf4281629c447177a4789fd118 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:02:37 +0000 Subject: [PATCH 4/6] Recover validation messages from WithMessage lambda constants (#2397) The WithMessage(_ => Messages.Constant) form - a lambda returning a compile-time constant - was rejected as having no constant value, dropping every message in applications that keep messages behind constants. Read the lambda body's constant value, so both the constant and literal lambda forms are recovered. Genuinely computed messages still have no constant and are reported as before. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B1mrPcRT2GYjRxrx4T1p8N --- ...essage_is_a_lambda_returning_a_constant.cs | 62 +++++++++++++++++++ .../a_validator_whose_message_is_computed.cs | 52 ++++++++++++++++ .../Validation/ValidationChainReader.cs | 24 ++++++- 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_whose_message_is_a_lambda_returning_a_constant.cs create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_validator_whose_message_is_computed.cs 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/Analysis/Validation/ValidationChainReader.cs b/Source/DotNET/Screenplay/Analysis/Validation/ValidationChainReader.cs index 60568f7a7..3b5e28baf 100644 --- a/Source/DotNET/Screenplay/Analysis/Validation/ValidationChainReader.cs +++ b/Source/DotNET/Screenplay/Analysis/Validation/ValidationChainReader.cs @@ -195,7 +195,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( @@ -211,4 +213,24 @@ void ApplyMessage( rules[index] = rules[index] with { Message = message }; } } + + /// + /// 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 + }; } From 1770f62d916b05087af92fcbd0d94439191608a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:37:30 +0000 Subject: [PATCH 5/6] Fix analyzer and ambiguous-reference build errors from CI - Qualify [Authorize] in RoleSecuredTypes spec to resolve CS0104 against the two AuthorizeAttribute types in scope - Move static MessageExpression ahead of instance members (SA1204) - Combine adjacent Where clauses in SliceDirectories (MA0029/RCS1112) - Chain StringBuilder.Append calls in ScreenplayNaming (RCS1201) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B1mrPcRT2GYjRxrx4T1p8N --- .../Analysis/Screens/SliceDirectories.cs | 3 +- .../Validation/ValidationChainReader.cs | 40 +++++++++---------- .../Emission/Naming/ScreenplayNaming.cs | 5 ++- .../RoleSecuredTypes.cs | 5 +-- 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs b/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs index ec44dbc53..4a4c9afb9 100644 --- a/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs +++ b/Source/DotNET/Screenplay/Analysis/Screens/SliceDirectories.cs @@ -25,8 +25,7 @@ public static IReadOnlyList Of(IEnumerable types) => .. types .SelectMany(_ => _.DeclaringSyntaxReferences) .Select(_ => _.SyntaxTree.FilePath) - .Where(_ => !string.IsNullOrWhiteSpace(_)) - .Where(_ => !GeneratedSource.Is(_)) + .Where(_ => !string.IsNullOrWhiteSpace(_) && !GeneratedSource.Is(_)) .Select(ScreenFiles.DirectoryOf) .Where(_ => _.Length > 0) .Distinct(StringComparer.Ordinal) diff --git a/Source/DotNET/Screenplay/Analysis/Validation/ValidationChainReader.cs b/Source/DotNET/Screenplay/Analysis/Validation/ValidationChainReader.cs index 3b5e28baf..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. /// @@ -213,24 +233,4 @@ void ApplyMessage( rules[index] = rules[index] with { Message = message }; } } - - /// - /// 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 - }; } diff --git a/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs b/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs index 47bef3f15..474581418 100644 --- a/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs +++ b/Source/DotNET/Screenplay/Emission/Naming/ScreenplayNaming.cs @@ -158,8 +158,9 @@ static string Sanitize(string name) var builder = new StringBuilder(candidate.Length); foreach (var segment in segments) { - builder.Append(char.ToUpperInvariant(segment[0])); - builder.Append(segment, 1, segment.Length - 1); + builder + .Append(char.ToUpperInvariant(segment[0])) + .Append(segment, 1, segment.Length - 1); } return builder.ToString().Normalize(NormalizationForm.FormC); diff --git a/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/RoleSecuredTypes.cs b/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/RoleSecuredTypes.cs index f35713840..3842a001b 100644 --- a/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/RoleSecuredTypes.cs +++ b/Source/DotNET/Tools/ProxyGenerator.Specs/ControllerBased/for_MethodInfoExtensions/RoleSecuredTypes.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.Arc.Authorization; -using Microsoft.AspNetCore.Authorization; namespace Cratis.Arc.ProxyGenerator.ControllerBased.for_MethodInfoExtensions; @@ -14,10 +13,10 @@ public void SingleRoleFromConstructor() { } [Roles("Librarian", "Admin")] public void MultipleRolesFromConstructor() { } - [Authorize(Roles = "Librarian")] + [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Librarian")] public void RoleFromNamedArgument() { } [Roles("Librarian")] - [Authorize(Roles = "Librarian")] + [Microsoft.AspNetCore.Authorization.Authorize(Roles = "Librarian")] public void RoleFromBothForms() { } } From 67b596b5949985a7d134ccfc09639fa90d5c135e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 11:06:14 +0000 Subject: [PATCH 6/6] Declare concepts reached only through child records and read models Concepts referenced solely from inside a complex record or a read-model property were dropped from a generated Screenplay document, taking their validation rules and PII marking with them. The type registry now walks the members of every record it names, cycle-safe and in ordinal order, so a concept is declared wherever it was reached from. A record an artifact carries has no declaration construct in the grammar, so its shape is still lost - that loss is now reported with a new Information diagnostic (SP0035) naming the record, rather than dropped silently. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B1mrPcRT2GYjRxrx4T1p8N --- .../concepts_carried_only_inside_a_record.cs | 75 +++++++ .../Analysis/ApplicationModelAnalyzer.cs | 8 + .../Screenplay/Analysis/PropertyReader.cs | 2 +- .../Screenplay/Analysis/Types/CarriedTypes.cs | 80 ++++++++ .../Analysis/Types/ConceptRegistry.cs | 163 +++++++++++++++ .../Screenplay/Analysis/Types/TypeRegistry.cs | 187 ++++++------------ .../Analysis/Types/UnderlyingTypes.cs | 76 +++++++ .../Screenplay/ScreenplayDiagnosticCodes.cs | 14 ++ 8 files changed, 479 insertions(+), 126 deletions(-) create mode 100644 Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/concepts_carried_only_inside_a_record.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Types/CarriedTypes.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Types/ConceptRegistry.cs create mode 100644 Source/DotNET/Screenplay/Analysis/Types/UnderlyingTypes.cs 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/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/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/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/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"; }