From 45fcda550ab955ce5e26312a9d10e8dec221f46e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:28:31 +0000 Subject: [PATCH] [major] Strings: WeakString can only be set through Create (#310) SemanticString.WeakString had a public init accessor, so `uuid with { WeakString = "not a uuid" }` or `new Uuid { WeakString = "x" }` built a value that never passed canonicalization or validation. For the path types, `with` also carried over cached Parent and related values for the old path. The accessor is now protected init, and ISemanticString.WeakString is get-only. Create sets the value through the cached setter delegate, which now looks up the non-public accessor. A derived type can still set the value from its own constructor. The path types are sealed, so with the accessor closed nothing outside them can change a path's value through `with` and leave the caches stale. Breaking: callers that set WeakString in an object initializer or a `with` expression must call Create instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW2sHrLz9iC1cJzLLZy --- Semantics.Strings/ISemanticString.cs | 4 +- Semantics.Strings/SemanticString.cs | 12 +++-- Semantics.Test/WeakStringAccessTests.cs | 66 +++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 Semantics.Test/WeakStringAccessTests.cs diff --git a/Semantics.Strings/ISemanticString.cs b/Semantics.Strings/ISemanticString.cs index 1a96b4bf..a63c8acc 100644 --- a/Semantics.Strings/ISemanticString.cs +++ b/Semantics.Strings/ISemanticString.cs @@ -27,8 +27,10 @@ public interface ISemanticString : IComparable, IComparable, IE /// /// Use this property when you need to interoperate with APIs that expect regular strings. /// The name "WeakString" emphasizes that this breaks the type safety guarantees. + /// The value is read-only through this interface: it is set when the semantic string is created, + /// which is where it is canonicalized and validated (#310). /// - public string WeakString { get; init; } + public string WeakString { get; } // System.String interface diff --git a/Semantics.Strings/SemanticString.cs b/Semantics.Strings/SemanticString.cs index 4f319c2f..2e3562e3 100644 --- a/Semantics.Strings/SemanticString.cs +++ b/Semantics.Strings/SemanticString.cs @@ -71,7 +71,7 @@ public TDest As() // ISemanticString implementation /// - /// Gets or initializes the underlying string value for interoperability with non-semantic string operations. + /// Gets the underlying string value for interoperability with non-semantic string operations. /// /// /// The raw string value contained within this semantic string. Defaults to . @@ -80,9 +80,12 @@ public TDest As() /// Use this property when you need to interoperate with APIs that expect regular strings. /// The name "WeakString" emphasizes that accessing this property breaks the type safety guarantees /// that semantic strings provide. Consider using implicit conversion to string instead when possible. - /// This property is init-only to support record initialization while maintaining immutability. + /// The init accessor is protected so that a value can only arrive through Create, which + /// canonicalizes and validates it. A public one let uuid with { WeakString = "not a uuid" } + /// build an instance that never passed either (#310). A derived type may still set it from its own + /// constructor. /// - public string WeakString { get; init; } = string.Empty; + public string WeakString { get; protected init; } = string.Empty; /// public int Length => WeakString.Length; @@ -635,6 +638,7 @@ private static TDest FromStringInternal(string? value) /// /// The init accessor as a delegate, looked up once per type rather than per value (#444). + /// The accessor is protected (#310), so the lookup asks for non-public accessors too. /// /// The semantic string type to set the value of. private static class WeakStringSetter @@ -642,7 +646,7 @@ private static class WeakStringSetter { internal static readonly Action Set = (Action)Delegate.CreateDelegate( typeof(Action), - Ensure.NotNull(typeof(TDest).GetProperty(name: nameof(WeakString))?.GetSetMethod())); + Ensure.NotNull(typeof(TDest).GetProperty(name: nameof(WeakString))?.GetSetMethod(nonPublic: true))); } /// diff --git a/Semantics.Test/WeakStringAccessTests.cs b/Semantics.Test/WeakStringAccessTests.cs new file mode 100644 index 00000000..0c92f428 --- /dev/null +++ b/Semantics.Test/WeakStringAccessTests.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Test; + +using System; +using System.IO; +using System.Reflection; +using ktsu.Semantics.Paths; +using ktsu.Semantics.Strings; +using ktsu.Semantics.Strings.Identifiers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// A semantic string's value only arrives through Create, which canonicalizes and validates it. +/// A public init accessor let uuid with { WeakString = "not a uuid" } skip both (#310). +/// +[TestClass] +public class WeakStringAccessTests +{ + [TestMethod] + public void WeakString_HasNoPublicSetter() + { + Type[] types = + [ + typeof(MySemanticString), + typeof(Uuid), + typeof(AbsoluteDirectoryPath), + typeof(AbsoluteFilePath), + typeof(RelativeDirectoryPath), + ]; + + foreach (Type type in types) + { + PropertyInfo property = type.GetProperty(nameof(ISemanticString.WeakString))!; + + Assert.IsNotNull(property.GetGetMethod(), $"{type.Name}.WeakString should stay publicly readable."); + Assert.IsNull(property.GetSetMethod(), $"{type.Name}.WeakString should not be publicly settable."); + } + } + + [TestMethod] + public void ISemanticString_WeakString_IsReadOnly() + { + PropertyInfo property = typeof(ISemanticString).GetProperty(nameof(ISemanticString.WeakString))!; + + Assert.IsFalse(property.CanWrite); + } + + [TestMethod] + public void Create_StillSetsTheValue() + { + const string value = "123e4567-e89b-12d3-a456-426614174000"; + + Assert.AreEqual(value, Uuid.Create(value).WeakString); + Assert.AreEqual("test", MySemanticString.Create("test").WeakString); + } + + [TestMethod] + public void Create_StillSetsThePathValue() + { + AbsoluteDirectoryPath parent = AbsoluteDirectoryPath.Create(Path.GetTempPath()); + AbsoluteDirectoryPath child = AbsoluteDirectoryPath.Create(Path.Join(parent.WeakString, "weakstring")); + + Assert.AreEqual(parent, child.Parent); + } +}