From 594c775baaa84d3d215b8f60fde991732dc59319 Mon Sep 17 00:00:00 2001 From: Ewout Kramer Date: Wed, 30 Sep 2026 18:04:14 +0200 Subject: [PATCH 1/2] Make IAssertionContainer public, as an experimental extension point Custom building blocks can be defined outside this library, and a container-like one must be able to implement IAssertionContainer, or schema rewriters (such as the advisor framework's) silently cannot descend into it. This is also why the set of containers is not a fixed list. AssertionStepKind, AssertionStep, IAssertionContainer and AssertionContainerExtensions become public, marked [Experimental]/EditorBrowsable(Never) like IValidatable and IGroupValidatable, so the contract can still change while the advisor rework (phase 3) lands. The OSS implementations stay explicit. The remarks on the interface now spell out the obligation for custom building blocks. Co-Authored-By: Claude Opus 5.5 --- .../PublicAPI.Unshipped.txt | 35 +++++++++++++++ .../Schema/IAssertionContainer.cs | 45 ++++++++++++++++--- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/Firely.Fhir.Validation/PublicAPI.Unshipped.txt b/src/Firely.Fhir.Validation/PublicAPI.Unshipped.txt index 74c26f28..b9e2bd41 100644 --- a/src/Firely.Fhir.Validation/PublicAPI.Unshipped.txt +++ b/src/Firely.Fhir.Validation/PublicAPI.Unshipped.txt @@ -584,3 +584,38 @@ virtual Firely.Fhir.Validation.InvariantValidator.PrintMembers(System.Text.Strin Firely.Fhir.Validation.NamedExtensionMapper Firely.Fhir.Validation.ValidationSettings.MapNamedElement -> Firely.Fhir.Validation.NamedExtensionMapper! virtual Firely.Fhir.Validation.NamedExtensionMapper.Invoke(string! jsonName) -> string? +~override Firely.Fhir.Validation.AssertionStep.Equals(object obj) -> bool +Firely.Fhir.Validation.AssertionContainerExtensions +Firely.Fhir.Validation.AssertionStep +Firely.Fhir.Validation.AssertionStep.Anchor.get -> string? +Firely.Fhir.Validation.AssertionStep.AssertionStep() -> void +Firely.Fhir.Validation.AssertionStep.AssertionStep(Firely.Fhir.Validation.AssertionStepKind Kind, string? Name = null) -> void +Firely.Fhir.Validation.AssertionStep.Deconstruct(out Firely.Fhir.Validation.AssertionStepKind Kind, out string? Name) -> void +Firely.Fhir.Validation.AssertionStep.ElementName.get -> string? +Firely.Fhir.Validation.AssertionStep.Equals(Firely.Fhir.Validation.AssertionStep other) -> bool +Firely.Fhir.Validation.AssertionStep.Kind.get -> Firely.Fhir.Validation.AssertionStepKind +Firely.Fhir.Validation.AssertionStep.Kind.init -> void +Firely.Fhir.Validation.AssertionStep.Name.get -> string? +Firely.Fhir.Validation.AssertionStep.Name.init -> void +Firely.Fhir.Validation.AssertionStep.SliceName.get -> string? +Firely.Fhir.Validation.AssertionStep.TargetType.get -> string? +Firely.Fhir.Validation.AssertionStepKind +Firely.Fhir.Validation.AssertionStepKind.Child = 1 -> Firely.Fhir.Validation.AssertionStepKind +Firely.Fhir.Validation.AssertionStepKind.Member = 0 -> Firely.Fhir.Validation.AssertionStepKind +Firely.Fhir.Validation.AssertionStepKind.ReferenceTarget = 4 -> Firely.Fhir.Validation.AssertionStepKind +Firely.Fhir.Validation.AssertionStepKind.Slice = 2 -> Firely.Fhir.Validation.AssertionStepKind +Firely.Fhir.Validation.AssertionStepKind.Subschema = 3 -> Firely.Fhir.Validation.AssertionStepKind +Firely.Fhir.Validation.IAssertionContainer +Firely.Fhir.Validation.IAssertionContainer.WithChildren(System.Func! rewrite) -> Firely.Fhir.Validation.IAssertion! +override Firely.Fhir.Validation.AssertionStep.GetHashCode() -> int +override Firely.Fhir.Validation.AssertionStep.ToString() -> string! +static Firely.Fhir.Validation.AssertionContainerExtensions.Children(this Firely.Fhir.Validation.IAssertionContainer! container) -> System.Collections.Generic.IReadOnlyList<(Firely.Fhir.Validation.AssertionStep Step, Firely.Fhir.Validation.IAssertion! Child)>! +static Firely.Fhir.Validation.AssertionContainerExtensions.TryRewriteItems(this System.Collections.Generic.IReadOnlyCollection! items, System.Func! select, System.Func! replace, System.Func! rewrite) -> T[]? +static Firely.Fhir.Validation.AssertionContainerExtensions.TryRewriteMembers(this System.Collections.Generic.IReadOnlyCollection! members, Firely.Fhir.Validation.AssertionStep step, System.Func! rewrite) -> Firely.Fhir.Validation.IAssertion![]? +static Firely.Fhir.Validation.AssertionStep.Child(string! elementName) -> Firely.Fhir.Validation.AssertionStep +static Firely.Fhir.Validation.AssertionStep.Member() -> Firely.Fhir.Validation.AssertionStep +static Firely.Fhir.Validation.AssertionStep.operator !=(Firely.Fhir.Validation.AssertionStep left, Firely.Fhir.Validation.AssertionStep right) -> bool +static Firely.Fhir.Validation.AssertionStep.operator ==(Firely.Fhir.Validation.AssertionStep left, Firely.Fhir.Validation.AssertionStep right) -> bool +static Firely.Fhir.Validation.AssertionStep.ReferenceTarget(string? targetType) -> Firely.Fhir.Validation.AssertionStep +static Firely.Fhir.Validation.AssertionStep.Slice(string! sliceName) -> Firely.Fhir.Validation.AssertionStep +static Firely.Fhir.Validation.AssertionStep.Subschema(string! anchor) -> Firely.Fhir.Validation.AssertionStep diff --git a/src/Firely.Fhir.Validation/Schema/IAssertionContainer.cs b/src/Firely.Fhir.Validation/Schema/IAssertionContainer.cs index 71ca2de3..cf3bc6b1 100644 --- a/src/Firely.Fhir.Validation/Schema/IAssertionContainer.cs +++ b/src/Firely.Fhir.Validation/Schema/IAssertionContainer.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel; namespace Firely.Fhir.Validation { @@ -18,7 +19,13 @@ namespace Firely.Fhir.Validation /// during validation. Only steps that are visible in the compiled schema /// exist here: the instance-side events (indexes, internal references, resource starts) have no /// meaning while rewriting a schema. - internal enum AssertionStepKind + [EditorBrowsable(EditorBrowsableState.Never)] +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.Experimental(diagnosticId: "ExperimentalApi")] +#else + [System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] +#endif + public enum AssertionStepKind { /// /// A member of the container that does not change the position within the instance: a member of an @@ -62,7 +69,13 @@ internal enum AssertionStepKind /// /// The step describes how the nested assertion is reached, not what it is: identity (canonical, /// version, schema id) is carried by the schemas encountered along the way, not by the steps. - internal readonly record struct AssertionStep(AssertionStepKind Kind, string? Name = null) + [EditorBrowsable(EditorBrowsableState.Never)] +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.Experimental(diagnosticId: "ExperimentalApi")] +#else + [System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] +#endif + public readonly record struct AssertionStep(AssertionStepKind Kind, string? Name = null) { /// /// A step to a member that does not change the position within the instance. @@ -130,10 +143,18 @@ internal readonly record struct AssertionStep(AssertionStepKind Kind, string? Na /// walked and rewritten without knowing the concrete container types. /// /// Every that keeps nested assertions as part of its state must - /// implement this interface - AssertionContainerTests enforces that for this assembly. Implement - /// it explicitly: it is an implementation detail of schema rewriting, not part of the assertion's - /// public surface. - internal interface IAssertionContainer + /// implement this interface - AssertionContainerTests enforces that for this assembly. That + /// includes custom building blocks defined outside this library: an assertion that holds nested + /// assertions but does not implement this interface is opaque to schema rewriters, which then silently + /// leave everything nested in it unchanged. Implement it explicitly: it is an implementation detail of + /// schema rewriting, not part of the assertion's public surface. + [EditorBrowsable(EditorBrowsableState.Never)] +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.Experimental(diagnosticId: "ExperimentalApi")] +#else + [System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] +#endif + public interface IAssertionContainer { /// /// Returns a copy of this container in which every nested assertion is replaced by the result of @@ -155,7 +176,17 @@ internal interface IAssertionContainer IAssertion WithChildren(Func rewrite); } - internal static class AssertionContainerExtensions + /// + /// Helpers for walking s, and for implementing + /// . + /// + [EditorBrowsable(EditorBrowsableState.Never)] +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.Experimental(diagnosticId: "ExperimentalApi")] +#else + [System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] +#endif + public static class AssertionContainerExtensions { /// /// Enumerates the nested assertions of a container, with the step leading to each of them. From 146bb12fed9845c9158dc623cdb57a1ef9cde44f Mon Sep 17 00:00:00 2001 From: Ewout Kramer Date: Wed, 30 Sep 2026 18:23:38 +0200 Subject: [PATCH 2/2] Keep AssertionStep construction behind its factory methods Now that AssertionStep is public, its positional constructor and init accessors let callers build malformed steps (a Child without a name, a Member with one). The constructor is now private and the properties get-only, and the factories that need a name reject null. default(AssertionStep) is a nameless Member step, which is valid. Co-Authored-By: Claude Opus 5.5 --- .../PublicAPI.Unshipped.txt | 4 --- .../Schema/IAssertionContainer.cs | 32 ++++++++++++++++--- .../Schema/AssertionContainerTests.cs | 11 +++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/Firely.Fhir.Validation/PublicAPI.Unshipped.txt b/src/Firely.Fhir.Validation/PublicAPI.Unshipped.txt index b9e2bd41..a4813d7e 100644 --- a/src/Firely.Fhir.Validation/PublicAPI.Unshipped.txt +++ b/src/Firely.Fhir.Validation/PublicAPI.Unshipped.txt @@ -589,14 +589,10 @@ Firely.Fhir.Validation.AssertionContainerExtensions Firely.Fhir.Validation.AssertionStep Firely.Fhir.Validation.AssertionStep.Anchor.get -> string? Firely.Fhir.Validation.AssertionStep.AssertionStep() -> void -Firely.Fhir.Validation.AssertionStep.AssertionStep(Firely.Fhir.Validation.AssertionStepKind Kind, string? Name = null) -> void -Firely.Fhir.Validation.AssertionStep.Deconstruct(out Firely.Fhir.Validation.AssertionStepKind Kind, out string? Name) -> void Firely.Fhir.Validation.AssertionStep.ElementName.get -> string? Firely.Fhir.Validation.AssertionStep.Equals(Firely.Fhir.Validation.AssertionStep other) -> bool Firely.Fhir.Validation.AssertionStep.Kind.get -> Firely.Fhir.Validation.AssertionStepKind -Firely.Fhir.Validation.AssertionStep.Kind.init -> void Firely.Fhir.Validation.AssertionStep.Name.get -> string? -Firely.Fhir.Validation.AssertionStep.Name.init -> void Firely.Fhir.Validation.AssertionStep.SliceName.get -> string? Firely.Fhir.Validation.AssertionStep.TargetType.get -> string? Firely.Fhir.Validation.AssertionStepKind diff --git a/src/Firely.Fhir.Validation/Schema/IAssertionContainer.cs b/src/Firely.Fhir.Validation/Schema/IAssertionContainer.cs index cf3bc6b1..c9aca7c8 100644 --- a/src/Firely.Fhir.Validation/Schema/IAssertionContainer.cs +++ b/src/Firely.Fhir.Validation/Schema/IAssertionContainer.cs @@ -75,8 +75,29 @@ public enum AssertionStepKind #else [System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")] #endif - public readonly record struct AssertionStep(AssertionStepKind Kind, string? Name = null) + public readonly record struct AssertionStep { + // Steps are only created by the factory methods below, so every step has a name exactly when its + // kind requires one. Note that default(AssertionStep) is a Member step, which is valid as well. + private AssertionStep(AssertionStepKind kind, string? name = null) + { + Kind = kind; + Name = name; + } + + /// + /// The kind of step. + /// + public AssertionStepKind Kind { get; } + + /// + /// The name that goes with the kind of step: the element name, slice name, anchor or target type. + /// Always null for a step, and never null for a + /// , or + /// step. + /// + public string? Name { get; } + /// /// A step to a member that does not change the position within the instance. /// @@ -85,17 +106,20 @@ public readonly record struct AssertionStep(AssertionStepKind Kind, string? Name /// /// A step to the assertions for the child element with the given name. /// - public static AssertionStep Child(string elementName) => new(AssertionStepKind.Child, elementName); + public static AssertionStep Child(string elementName) => + new(AssertionStepKind.Child, elementName ?? throw new ArgumentNullException(nameof(elementName))); /// /// A step to the assertions for the slice with the given name. /// - public static AssertionStep Slice(string sliceName) => new(AssertionStepKind.Slice, sliceName); + public static AssertionStep Slice(string sliceName) => + new(AssertionStepKind.Slice, sliceName ?? throw new ArgumentNullException(nameof(sliceName))); /// /// A step to the subschema with the given anchor. /// - public static AssertionStep Subschema(string anchor) => new(AssertionStepKind.Subschema, anchor); + public static AssertionStep Subschema(string anchor) => + new(AssertionStepKind.Subschema, anchor ?? throw new ArgumentNullException(nameof(anchor))); /// /// A step to the schema for the targets of a reference of the given type, or for the targets of diff --git a/test/Firely.Fhir.Validation.Tests/Schema/AssertionContainerTests.cs b/test/Firely.Fhir.Validation.Tests/Schema/AssertionContainerTests.cs index 0107e876..7caa3a0a 100644 --- a/test/Firely.Fhir.Validation.Tests/Schema/AssertionContainerTests.cs +++ b/test/Firely.Fhir.Validation.Tests/Schema/AssertionContainerTests.cs @@ -215,6 +215,17 @@ public void ReferenceTargetsCarryTheirType() Assert.AreEqual("->*", untypedStep.ToString()); } + [TestMethod] + public void NamedStepsRequireTheirName() + { + Assert.ThrowsException(() => AssertionStep.Child(null!)); + Assert.ThrowsException(() => AssertionStep.Slice(null!)); + Assert.ThrowsException(() => AssertionStep.Subschema(null!)); + + // the default step is a (nameless) member, which is a valid step + Assert.AreEqual(AssertionStep.Member(), default(AssertionStep)); + } + #endregion #region an unchanged container keeps its identity