diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 1ea259456..b19bbf995 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -7,6 +7,12 @@
"commands": [
"NuGetKeyVaultSignTool"
]
+ },
+ "damianh.playwright.installtool": {
+ "version": "0.2.0",
+ "commands": [
+ "playwright"
+ ]
}
}
}
\ No newline at end of file
diff --git a/.editorconfig b/.editorconfig
index 818677a79..ab68ac41e 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -43,6 +43,13 @@ dotnet_diagnostic.IDE0066.severity = warning # Use switch expressions
dotnet_diagnostic.IDE0073.severity = warning # Require file header
dotnet_diagnostic.IDE0130.severity = warning # Namespace does not match folder structure
dotnet_diagnostic.IDE0161.severity = warning # Use file-scoped namespace
+dotnet_naming_rule.async_methods_must_end_with_async.severity = warning
+dotnet_naming_rule.async_methods_must_end_with_async.style = async_method_style
+dotnet_naming_rule.async_methods_must_end_with_async.symbols = async_method_symbols
+dotnet_naming_style.async_method_style.capitalization = pascal_case
+dotnet_naming_style.async_method_style.required_suffix = Async
+dotnet_naming_symbols.async_method_symbols.applicable_kinds = method
+dotnet_naming_symbols.async_method_symbols.required_modifiers = async
dotnet_sort_system_directives_first = true
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
@@ -56,3 +63,28 @@ indent_size = 4
[*.razor]
indent_size = 4
+
+# ASP.NET Core middleware Invoke methods cannot be renamed (framework constraint)
+[platform/src/Authentication.Web/OpenIdConnect/Hosting/*Middleware.cs]
+dotnet_naming_rule.async_methods_must_end_with_async.severity = none
+
+[platform/src/Authentication.Web/Hosting/DynamicProviders/DynamicSchemes/*Middleware.cs]
+dotnet_naming_rule.async_methods_must_end_with_async.severity = none
+
+# xUnit test methods use Should_* naming convention without Async suffix
+[platform/test/**/*.cs]
+dotnet_naming_rule.async_methods_must_end_with_async.severity = none
+
+# Sample test projects also use xUnit test naming conventions
+[platform/samples/**/test/**/*.cs]
+dotnet_naming_rule.async_methods_must_end_with_async.severity = none
+
+# gRPC service methods override generated base class methods and cannot be renamed (framework constraint)
+[platform/src/AdminStudio.Server/Services/**/*GrpcService.cs]
+dotnet_naming_rule.async_methods_must_end_with_async.severity = none
+
+[platform/src/AdminStudio.Web/Services/**/*GrpcService.cs]
+dotnet_naming_rule.async_methods_must_end_with_async.severity = none
+
+[platform/src/AdminStudio.Web/Services/*GrpcService.cs]
+dotnet_naming_rule.async_methods_must_end_with_async.severity = none
diff --git a/.gitignore b/.gitignore
index 8ca708e45..e7f6e2857 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,9 @@
# Rider
.idea
+# NCrunch
+*.ncrunchproject
+
# User-specific files
*.suo
*.user
@@ -209,9 +212,6 @@ FakesAssemblies/
*.opt
docs/_build/
-# Local .NET CLI tools
-tools/
-
# Visual Studio Code workspace options
**/.vscode/settings.json
@@ -243,3 +243,11 @@ reports
*.db-wal
nul
.weave
+progress.txt
+
+# AdminStudio runtime data (secrets, user stores)
+faro-connections.json
+adminstudio-data/
+
+# VS Code C# Dev Kit language service cache
+*.lscache
diff --git a/Directory.Build.props b/Directory.Build.props
index e35d7d54a..dbaf49611 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -43,4 +43,5 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7ba895698..546d307fd 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,21 +1,22 @@
-
-
-
+
+
+
+
-
+
-
+
+
-
@@ -26,23 +27,26 @@
-
+
+
+
-
+
+
@@ -51,8 +55,14 @@
+
+
+
+
+
+
-
+
@@ -67,7 +77,8 @@
-
+
+
@@ -80,16 +91,20 @@
-
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
@@ -105,11 +120,13 @@
+
+
diff --git a/docs-mcp/README.md b/docs-mcp/README.md
index 84bfc6108..8c9dd5758 100644
--- a/docs-mcp/README.md
+++ b/docs-mcp/README.md
@@ -36,6 +36,13 @@ Alternatively, you can add a `.vscode/mcp.json` file to your workspace:
The Duende Documentation MCP Server will create its database index at the path defined in the `--database` parameter.
+### Command-line Options
+
+| Flag | Description | Default |
+|----------------------|---------------------------------------------------------------|------------------------------------------|
+| `--database ` | Fully qualified path to the SQLite database file | `mcp.db` (relative to working directory) |
+| `--with-http [port]` | Enable the HTTP transport (Streamable HTTP) on the given port | Disabled; port defaults to `5800` |
+
Next, open GitHub Copilot and select Agent Mode to work with the MCP server.
### JetBrains Rider
@@ -118,7 +125,7 @@ documentation and samples at [docs.duendesoftware.com](https://docs.duendesoftwa
### Development
-* Run the project. This will host a server on port 3000 (http), and with stdio bindings.
+* Run the project with `--with-http 3000` to enable the HTTP transport (e.g., on port 3000).
* In VS Code, add a `.vscode/mcp.json` to your workspace:
```json
{
diff --git a/docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj b/docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj
index fa5c978de..5ff218f3a 100644
--- a/docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj
+++ b/docs-mcp/src/Documentation.Mcp/Documentation.Mcp.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -16,6 +16,10 @@
$(NoWarn);CA1873
$(NoWarn);CA1812
+
+ $(NoWarn);CA1303
+
+ $(NoWarn);CA1515
@@ -31,5 +35,6 @@
+
diff --git a/docs-mcp/src/Documentation.Mcp/Program.cs b/docs-mcp/src/Documentation.Mcp/Program.cs
index e4b44d675..9b90a919b 100644
--- a/docs-mcp/src/Documentation.Mcp/Program.cs
+++ b/docs-mcp/src/Documentation.Mcp/Program.cs
@@ -6,25 +6,24 @@ using Documentation.Mcp.Sources.Blog;
using Documentation.Mcp.Sources.Docs;
using Documentation.Mcp.Sources.Samples;
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
-// Build server
-var builder = WebApplication.CreateBuilder(args);
-
-// Configure all logs to go to stderr in case the MCP is used as a stdio server
-builder.Logging.AddConsole(consoleLogOptions =>
-{
- consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
-});
-
-// Determine database path
+// Parse command-line arguments
var databasePath = "mcp.db";
+var enableHttp = false;
+var httpPort = 5800;
+var dbParameterIndex = -1;
+var httpParameterIndex = -1;
+var httpPortIsNextArg = false;
+
if (args.Length > 0)
{
- var dbParameterIndex = args.IndexOf("--database");
+ dbParameterIndex = args.IndexOf("--database");
if (dbParameterIndex >= 0 && args.Length > dbParameterIndex + 1)
{
var dbPathParameter = args[dbParameterIndex + 1].Replace("\"", "", StringComparison.OrdinalIgnoreCase);
@@ -33,8 +32,69 @@ if (args.Length > 0)
databasePath = dbPathParameter;
}
}
+
+ httpParameterIndex = Array.FindIndex(args, arg =>
+ string.Equals(arg, "--with-http", StringComparison.OrdinalIgnoreCase) ||
+ arg.StartsWith("--with-http=", StringComparison.OrdinalIgnoreCase));
+ enableHttp = httpParameterIndex >= 0;
+ if (httpParameterIndex >= 0)
+ {
+ string? httpPortValue = null;
+ var httpArgument = args[httpParameterIndex];
+ if (httpArgument.StartsWith("--with-http=", StringComparison.OrdinalIgnoreCase))
+ {
+ httpPortValue = httpArgument["--with-http=".Length..];
+ }
+ else if (args.Length > httpParameterIndex + 1 && !args[httpParameterIndex + 1].StartsWith("--", StringComparison.Ordinal))
+ {
+ httpPortValue = args[httpParameterIndex + 1];
+ httpPortIsNextArg = true;
+ }
+ if (!string.IsNullOrEmpty(httpPortValue))
+ {
+ if (!int.TryParse(httpPortValue, out var parsedPort) ||
+ parsedPort is < 1 or > 65535)
+ {
+ await Console.Error.WriteLineAsync("Invalid HTTP port. Specify a value between 1 and 65535.");
+ return 1;
+ }
+ httpPort = parsedPort;
+ }
+ }
}
+// Filter out custom CLI flags before passing args to the host builder to avoid
+// unintended configuration state from the default command-line config parser.
+var excludedArgIndices = new HashSet();
+if (dbParameterIndex >= 0)
+{
+ _ = excludedArgIndices.Add(dbParameterIndex);
+ if (args.Length > dbParameterIndex + 1)
+ {
+ _ = excludedArgIndices.Add(dbParameterIndex + 1);
+ }
+}
+if (httpParameterIndex >= 0)
+{
+ _ = excludedArgIndices.Add(httpParameterIndex);
+ if (httpPortIsNextArg)
+ {
+ _ = excludedArgIndices.Add(httpParameterIndex + 1);
+ }
+}
+var hostArgs = args.Where((_, i) => !excludedArgIndices.Contains(i)).ToArray();
+
+// Build server
+IHostApplicationBuilder builder = enableHttp
+ ? WebApplication.CreateBuilder(hostArgs)
+ : Host.CreateApplicationBuilder(hostArgs);
+
+// Configure all logs to go to stderr in case the MCP is used as a stdio server
+builder.Logging.AddConsole(consoleLogOptions =>
+{
+ consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
+});
+
// Setup services
builder.Services.AddHttpClient();
builder.Services.AddSqlite("Data Source=" + databasePath + ";Cache=Shared");
@@ -43,7 +103,7 @@ builder.Services.AddHostedService();
builder.Services.AddHostedService();
builder.Services.AddHostedService();
-builder.Services
+var mcpServerBuilder = builder.Services
.AddMcpServer(options =>
{
options.ServerInfo = new Implementation
@@ -70,17 +130,41 @@ builder.Services
.WithTools()
.WithTools()
.WithTools()
- .WithStdioServerTransport()
- .WithHttpTransport();
+ .WithStdioServerTransport();
+
+if (enableHttp)
+{
+ _ = mcpServerBuilder.WithHttpTransport();
+
+ if (builder is WebApplicationBuilder webApplicationBuilder)
+ {
+ _ = webApplicationBuilder.WebHost.ConfigureKestrel(options => options.ListenLocalhost(httpPort));
+ }
+}
// Setup application
-var app = builder.Build();
+IHost app;
+if (enableHttp)
+{
+ var webApp = (builder as WebApplicationBuilder)!.Build();
+ webApp.Logger.LogInformation("Transport enabled: HTTP on port {Port}", httpPort);
+ _ = webApp.MapMcp();
-app.MapMcp();
+ app = webApp;
+}
+else
+{
+ var consoleApp = (builder as HostApplicationBuilder)!.Build();
+
+ app = consoleApp;
+}
+
+app.Logger.LogInformation("Transport enabled: stdio");
await EnsureDb(app.Services, app.Logger);
await app.RunAsync();
+return 0;
async Task EnsureDb(IServiceProvider services, ILogger logger)
{
@@ -95,3 +179,11 @@ async Task EnsureDb(IServiceProvider services, ILogger logger)
logger.LogInformation("Updated database");
}
}
+
+internal static class HostExtensions
+{
+ extension(IHost host)
+ {
+ internal ILogger Logger => host.Services.GetRequiredService>();
+ }
+}
diff --git a/docs-mcp/src/Documentation.Mcp/Properties/launchSettings.json b/docs-mcp/src/Documentation.Mcp/Properties/launchSettings.json
index 7bb5589f5..84c33fdaf 100644
--- a/docs-mcp/src/Documentation.Mcp/Properties/launchSettings.json
+++ b/docs-mcp/src/Documentation.Mcp/Properties/launchSettings.json
@@ -5,10 +5,10 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
- "applicationUrl": "http://localhost:3000",
+ "commandLineArgs": "--with-http 3000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
-}
\ No newline at end of file
+}
diff --git a/products.slnx b/products.slnx
index fde8d558d..8b8803f2f 100644
--- a/products.slnx
+++ b/products.slnx
@@ -1,16 +1,33 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -21,6 +38,7 @@
+
@@ -48,6 +66,7 @@
+
@@ -60,24 +79,36 @@
+
+
+
+
+
+
+
-
+
+
+
+
+
+
@@ -145,7 +176,9 @@
+
+
@@ -158,18 +191,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/shared/AppHost.Abstractions/AppHost.Abstractions.csproj b/shared/AppHost.Abstractions/AppHost.Abstractions.csproj
new file mode 100644
index 000000000..6dcb12a24
--- /dev/null
+++ b/shared/AppHost.Abstractions/AppHost.Abstractions.csproj
@@ -0,0 +1,8 @@
+
+
+ None
+ Duende.AppHost.Abstractions
+ Duende.Xunit.Playwright
+ false
+
+
diff --git a/shared/Xunit.Playwright/IAppHostServiceRoutes.cs b/shared/AppHost.Abstractions/IAppHostServiceRoutes.cs
similarity index 100%
rename from shared/Xunit.Playwright/IAppHostServiceRoutes.cs
rename to shared/AppHost.Abstractions/IAppHostServiceRoutes.cs
diff --git a/shared/ValueObjectsGenerator/FileScanner.cs b/shared/ValueObjectsGenerator/FileScanner.cs
new file mode 100644
index 000000000..1f3ad7be0
--- /dev/null
+++ b/shared/ValueObjectsGenerator/FileScanner.cs
@@ -0,0 +1,399 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Duende.ValueObjectsGenerator;
+
+///
+/// Scans a directory for C# files containing value object types annotated with
+/// [StringValue] or [ValueOf<T>] and builds ValueObjectInfo descriptors.
+///
+internal static class FileScanner
+{
+ // Well-known C# keyword → fully-qualified global type name mappings
+ private static readonly Dictionary WellKnownTypes = new(StringComparer.Ordinal)
+ {
+ ["bool"] = "global::System.Boolean",
+ ["byte"] = "global::System.Byte",
+ ["char"] = "global::System.Char",
+ ["decimal"] = "global::System.Decimal",
+ ["double"] = "global::System.Double",
+ ["float"] = "global::System.Single",
+ ["int"] = "global::System.Int32",
+ ["long"] = "global::System.Int64",
+ ["sbyte"] = "global::System.SByte",
+ ["short"] = "global::System.Int16",
+ ["uint"] = "global::System.UInt32",
+ ["ulong"] = "global::System.UInt64",
+ ["ushort"] = "global::System.UInt16",
+ // Common non-keyword structs that are always System.*
+ ["Guid"] = "global::System.Guid",
+ ["DateTime"] = "global::System.DateTime",
+ ["DateOnly"] = "global::System.DateOnly",
+ ["TimeOnly"] = "global::System.TimeOnly",
+ ["TimeSpan"] = "global::System.TimeSpan",
+ ["DateTimeOffset"] = "global::System.DateTimeOffset",
+ ["Uri"] = "global::System.Uri",
+ };
+
+ ///
+ /// Scans recursively and returns all discovered value objects.
+ ///
+ internal static IReadOnlyList Scan(string rootPath, string rootNamespace)
+ {
+ var csFiles = Directory
+ .EnumerateFiles(rootPath, "*.cs", SearchOption.AllDirectories)
+ .Where(IsEligibleFile)
+ .ToList();
+
+ // Pass 1: collect candidate records and build a lookup of all VO type names → namespace
+ var rawCandidates = new List();
+ foreach (var file in csFiles)
+ {
+ var text = File.ReadAllText(file);
+ var tree = CSharpSyntaxTree.ParseText(text);
+ var root = tree.GetCompilationUnitRoot();
+ CollectCandidates(file, root, rawCandidates);
+ }
+
+ // Build a lookup of all value-object type names → namespace for nested detection.
+ // Use a dictionary keyed by simple type name. If multiple types share the same short name
+ // (different namespaces), the first one wins — this is safe because cross-namespace nesting
+ // within a single project would be ambiguous at the C# level too.
+ var valueObjectTypeMap = new Dictionary(StringComparer.Ordinal);
+ foreach (var c in rawCandidates)
+ {
+ _ = valueObjectTypeMap.TryAdd(c.TypeName, c.Namespace);
+ }
+
+ // Pass 2: resolve full info (including nested detection and FQN resolution)
+ var results = new List(rawCandidates.Count);
+ foreach (var raw in rawCandidates)
+ {
+ string? genericTypeArgument = null;
+ var isNested = false;
+
+ if (raw.Kind == ValueObjectKind.ValueOf && raw.RawGenericArg is { } rawArg)
+ {
+ genericTypeArgument = ResolveGenericTypeArgument(rawArg, raw.Namespace, raw.UsingNamespaces, valueObjectTypeMap);
+
+ // Normalize to the simple type name (rightmost identifier) for nested detection.
+ // Handles "global::Some.Namespace.MyType", "Some.Namespace.MyType", and "MyType".
+ var simpleArg = rawArg.Trim();
+ if (simpleArg.StartsWith("global::", StringComparison.Ordinal))
+ {
+ simpleArg = simpleArg["global::".Length..];
+ }
+
+ var lastDot = simpleArg.LastIndexOf('.');
+ if (lastDot >= 0)
+ {
+ simpleArg = simpleArg[(lastDot + 1)..];
+ }
+
+ isNested = valueObjectTypeMap.ContainsKey(simpleArg);
+ }
+
+ results.Add(new ValueObjectInfo(
+ SourceFilePath: raw.SourceFilePath,
+ TypeName: raw.TypeName,
+ Namespace: raw.Namespace,
+ RootNamespace: rootNamespace,
+ Kind: raw.Kind,
+ GenericTypeArgument: genericTypeArgument,
+ IsNestedValueObject: isNested,
+ HasMaxLength: raw.HasMaxLength,
+ HasAllowedCharacters: raw.HasAllowedCharacters,
+ HasRegex: raw.HasRegex,
+ HasAllowedCharSet: raw.HasAllowedCharSet,
+ HasTryValidate: raw.HasTryValidate,
+ HasErrorMessage: raw.HasErrorMessage,
+ HasParse: raw.HasParse,
+ HasTryParse: raw.HasTryParse,
+ HasNormalize: raw.HasNormalize,
+ GenerateToString: raw.GenerateToString,
+ HasComparer: raw.HasComparer,
+ HasInternalConstructor: raw.HasInternalConstructor,
+ HasValue: raw.HasValue,
+ HasInternalValue: raw.HasInternalValue,
+ HasLoadFromStorage: raw.HasLoadFromStorage,
+ HasToString: raw.HasToString));
+ }
+
+ return results;
+ }
+
+ private static bool IsEligibleFile(string path)
+ {
+ // Skip generated files and build output
+ if (path.EndsWith(".g.cs", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ var normalized = path.Replace('\\', '/');
+
+ if (normalized.Contains("/obj/", StringComparison.Ordinal) || normalized.Contains("/bin/", StringComparison.Ordinal) || normalized.Contains("/Generated/", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static void CollectCandidates(string filePath, CompilationUnitSyntax root, List results)
+ {
+ // Collect using directives for namespace resolution
+ var usingNamespaces = root.Usings
+ .Select(u => u.Name?.ToString() ?? string.Empty)
+ .Where(u => !string.IsNullOrEmpty(u))
+ .ToList();
+
+ foreach (var record in root.DescendantNodes().OfType())
+ {
+ if (!record.Modifiers.Any(SyntaxKind.PartialKeyword))
+ {
+ continue;
+ }
+
+ if (!IsRecordClass(record))
+ {
+ continue;
+ }
+
+ var (kind, rawArg, generateToString) = DetectAttribute(record);
+ if (kind is null)
+ {
+ continue;
+ }
+
+ var ns = GetNamespace(record);
+ var typeName = record.Identifier.Text;
+ var members = record.Members;
+
+ var candidate = new RawCandidate(
+ SourceFilePath: filePath,
+ TypeName: typeName,
+ Namespace: ns,
+ Kind: kind.Value,
+ RawGenericArg: rawArg,
+ UsingNamespaces: usingNamespaces,
+ HasMaxLength: HasMember(members, "MaxLength"),
+ HasAllowedCharacters: HasMember(members, "AllowedCharacters"),
+ HasRegex: HasMethod(members, "Regex"),
+ HasAllowedCharSet: HasMember(members, "AllowedCharSet"),
+ HasTryValidate: HasMethod(members, "TryValidate"),
+ HasErrorMessage: HasMember(members, "ErrorMessage"),
+ HasParse: HasMethod(members, "Create"),
+ HasTryParse: HasMethod(members, "TryCreate"),
+ HasNormalize: HasMethod(members, "Normalize"),
+ HasComparer: HasMember(members, "Comparer"),
+ HasInternalConstructor: HasInternalConstructor(members, typeName),
+ HasValue: HasMember(members, "Value"),
+ HasInternalValue: HasInternalProperty(members, "Value"),
+ HasLoadFromStorage: HasMethod(members, "Load"),
+ HasToString: HasMethod(members, "ToString"),
+ GenerateToString: generateToString
+ );
+
+ results.Add(candidate);
+ }
+ }
+
+ private static bool IsRecordClass(RecordDeclarationSyntax record) =>
+ !record.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword);
+
+ private static (ValueObjectKind? kind, string? rawArg, bool generateToString) DetectAttribute(RecordDeclarationSyntax record)
+ {
+ foreach (var attrList in record.AttributeLists)
+ {
+ foreach (var attr in attrList.Attributes)
+ {
+ var name = attr.Name.ToString();
+
+ if (name == "StringValue" || name == "StringValueAttribute" ||
+ name == "Duende.StringValue" || name == "Duende.StringValueAttribute")
+ {
+ return (ValueObjectKind.StringValue, null, ExtractGenerateToString(attr));
+ }
+
+ if (name.StartsWith("ValueOf<", StringComparison.Ordinal) ||
+ name.StartsWith("ValueOfAttribute<", StringComparison.Ordinal) ||
+ name.StartsWith("Duende.ValueOf<", StringComparison.Ordinal) ||
+ name.StartsWith("Duende.ValueOfAttribute<", StringComparison.Ordinal))
+ {
+ string? rawArg = null;
+ if (attr.Name is GenericNameSyntax generic && generic.TypeArgumentList.Arguments.Count > 0)
+ {
+ rawArg = generic.TypeArgumentList.Arguments[0].ToString().Trim();
+ }
+ return (ValueObjectKind.ValueOf, rawArg, ExtractGenerateToString(attr));
+ }
+ }
+ }
+
+ return (null, null, true);
+ }
+
+ private static bool ExtractGenerateToString(AttributeSyntax attr)
+ {
+ if (attr.ArgumentList is null)
+ {
+ return true;
+ }
+
+ foreach (var arg in attr.ArgumentList.Arguments)
+ {
+ if (arg.NameEquals?.Name.ToString() == "GenerateToString" &&
+ arg.Expression is LiteralExpressionSyntax lit)
+ {
+ return !lit.IsKind(SyntaxKind.FalseLiteralExpression);
+ }
+ }
+
+ return true;
+ }
+
+ private static bool HasInternalConstructor(SyntaxList members, string typeName) =>
+ members.OfType().Any(c =>
+ c.Identifier.Text == typeName &&
+ c.Modifiers.Any(SyntaxKind.InternalKeyword));
+
+ private static bool HasMember(SyntaxList members, string name) =>
+ members.Any(m => GetMemberName(m) == name);
+
+ private static bool HasInternalProperty(SyntaxList members, string name) =>
+ members.OfType()
+ .Any(p => p.Identifier.Text == name &&
+ p.Modifiers.Any(m => m.IsKind(SyntaxKind.InternalKeyword)));
+
+ private static bool HasMethod(SyntaxList members, string name) =>
+ members.OfType().Any(m => m.Identifier.Text == name);
+
+ private static string? GetMemberName(MemberDeclarationSyntax member) => member switch
+ {
+ FieldDeclarationSyntax f => f.Declaration.Variables.FirstOrDefault()?.Identifier.Text,
+ PropertyDeclarationSyntax p => p.Identifier.Text,
+ MethodDeclarationSyntax m => m.Identifier.Text,
+ _ => null
+ };
+
+ private static string GetNamespace(BaseTypeDeclarationSyntax syntax)
+ {
+ var ns = string.Empty;
+ var parent = syntax.Parent;
+
+ while (parent is not null &&
+ parent is not NamespaceDeclarationSyntax &&
+ parent is not FileScopedNamespaceDeclarationSyntax)
+ {
+ parent = parent.Parent;
+ }
+
+ if (parent is BaseNamespaceDeclarationSyntax nsDecl)
+ {
+ ns = nsDecl.Name.ToString();
+
+ while (true)
+ {
+ if (nsDecl.Parent is not NamespaceDeclarationSyntax outerNs)
+ {
+ break;
+ }
+
+ ns = $"{outerNs.Name}.{ns}";
+ nsDecl = outerNs;
+ }
+ }
+
+ return ns;
+ }
+
+ ///
+ /// Resolves a raw generic type argument string to a fully-qualified global:: name.
+ ///
+ private static string ResolveGenericTypeArgument(
+ string rawArg,
+ string currentNamespace,
+ IReadOnlyList usingNamespaces,
+ Dictionary valueObjectTypeMap)
+ {
+ var trimmed = rawArg.Trim();
+
+ // Already fully qualified with global:: prefix
+ if (trimmed.StartsWith("global::", StringComparison.Ordinal))
+ {
+ return trimmed;
+ }
+
+ // Well-known primitive / BCL struct (e.g. int, Guid, DateTime)
+ if (WellKnownTypes.TryGetValue(trimmed, out var fqn))
+ {
+ return fqn;
+ }
+
+ // Already namespace-qualified (contains a dot, e.g. "Some.Namespace.MyType")
+ // — treat as fully qualified, just add global:: prefix
+ if (trimmed.Contains('.', StringComparison.Ordinal))
+ {
+ return $"global::{trimmed}";
+ }
+
+ // Check if it's a known value object type in the same project
+ if (valueObjectTypeMap.TryGetValue(trimmed, out var voNamespace))
+ {
+ return $"global::{voNamespace}.{trimmed}";
+ }
+
+ // Try to resolve from using directives — the type could be imported via a using.
+ // For simple (unqualified) names, check if using namespace + type name forms a known type.
+ foreach (var usingNs in usingNamespaces)
+ {
+ var candidate = $"{usingNs}.{trimmed}";
+
+ // Check well-known types with the fully-qualified candidate
+ if (WellKnownTypes.TryGetValue(candidate, out var resolved))
+ {
+ return resolved;
+ }
+
+ // Check if using namespace + simple name matches a known value object
+ if (valueObjectTypeMap.TryGetValue(trimmed, out var voNs) && voNs == usingNs)
+ {
+ return $"global::{voNs}.{trimmed}";
+ }
+ }
+
+ // Fall back to the current namespace
+ return $"global::{currentNamespace}.{trimmed}";
+ }
+
+ // Internal record for first-pass collection
+ private sealed record RawCandidate(
+ string SourceFilePath,
+ string TypeName,
+ string Namespace,
+ ValueObjectKind Kind,
+ string? RawGenericArg,
+ IReadOnlyList UsingNamespaces,
+ bool HasMaxLength,
+ bool HasAllowedCharacters,
+ bool HasRegex,
+ bool HasAllowedCharSet,
+ bool HasTryValidate,
+ bool HasErrorMessage,
+ bool HasParse,
+ bool HasTryParse,
+ bool HasNormalize,
+ bool HasComparer,
+ bool HasInternalConstructor,
+ bool HasValue,
+ bool HasInternalValue,
+ bool HasLoadFromStorage,
+ bool HasToString,
+ bool GenerateToString
+ );
+}
diff --git a/shared/ValueObjectsGenerator/InfrastructureCodeGenerator.cs b/shared/ValueObjectsGenerator/InfrastructureCodeGenerator.cs
new file mode 100644
index 000000000..549066f4f
--- /dev/null
+++ b/shared/ValueObjectsGenerator/InfrastructureCodeGenerator.cs
@@ -0,0 +1,221 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Duende.ValueObjectsGenerator;
+
+///
+/// Generates the per-project infrastructure files: IValueOf.g.cs,
+/// ValueOfTypeConverter.g.cs, and CharsetExtensions.g.cs.
+/// The content is identical to what the Roslyn source generator (Setup.cs) produced.
+///
+internal static class InfrastructureCodeGenerator
+{
+ internal static string GetInterfaceSource(string rootNamespace) => NormalizeLineEndings($$"""
+ //
+ // Copyright (c) Duende Software. All rights reserved.
+ // See LICENSE in the project root for license information.
+ #nullable enable
+
+ using System.Diagnostics.CodeAnalysis;
+
+ namespace {{rootNamespace}};
+
+ ///
+ /// Provides type-safe access to the underlying value of a value object.
+ ///
+ /// The type of the underlying value.
+ internal interface IValueOf
+ {
+ ///
+ /// Gets the underlying value.
+ ///
+ T Value { get; }
+ }
+
+ ///
+ /// Provides type-safe access to the underlying value of a value object,
+ /// along with creation capabilities.
+ ///
+ /// The value object type itself.
+ /// The type of the underlying value.
+ internal interface IValueOf : IValueOf where TSelf : IValueOf
+ {
+ ///
+ /// Creates a value object from a string representation.
+ ///
+ static abstract TSelf Create(string s);
+
+ ///
+ /// Tries to create a value object from a string representation.
+ ///
+ static abstract bool TryCreate(string? s, [NotNullWhen(true)] out TSelf? result);
+ }
+
+ internal interface IStringValue : IValueOf where TSelf : IStringValue
+ {
+ }
+
+ """);
+
+ internal static string GetCharSetsSource(string rootNamespace) => NormalizeLineEndings($$"""
+ //
+ // Copyright (c) Duende Software. All rights reserved.
+ // See LICENSE in the project root for license information.
+ #nullable enable
+
+ using System;
+ using System.Text;
+
+ namespace {{rootNamespace}};
+
+ ///
+ /// Extension methods for CharSet validation
+ ///
+ internal static class CharSetExtensions
+ {
+ ///
+ /// Defines the set of symbols for the 'Symbols' flag.
+ ///
+ private const string SymbolSet = "!@#$%^&*()_+-=[]{}|;:',.<>/?";
+
+ ///
+ /// Gets the combined string of all allowed characters for a given CharSet.
+ ///
+ internal static string GetAllowedCharacters(this CharSet charset)
+ {
+ var sb = new StringBuilder();
+
+ if (charset.HasFlag(CharSet.LowercaseLatin))
+ {
+ sb.Append("abcdefghijklmnopqrstuvwxyz");
+ }
+ if (charset.HasFlag(CharSet.UppercaseLatin))
+ {
+ sb.Append("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
+ }
+ if (charset.HasFlag(CharSet.Digits))
+ {
+ sb.Append("0123456789");
+ }
+ if (charset.HasFlag(CharSet.Symbols))
+ {
+ sb.Append(SymbolSet);
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Checks if a string contains ONLY characters defined by the CharSet.
+ ///
+ /// The charset to validate against.
+ /// The string to check.
+ /// True if the string is null, empty, or contains only allowed characters. False otherwise.
+ internal static bool IsMatch(this CharSet charset, string input)
+ {
+ // An empty or null string contains no *invalid* characters.
+ if (string.IsNullOrEmpty(input))
+ {
+ return true;
+ }
+
+ // Cache the flags for quick lookups inside the loop
+ bool allowLower = charset.HasFlag(CharSet.LowercaseLatin);
+ bool allowUpper = charset.HasFlag(CharSet.UppercaseLatin);
+ bool allowDigits = charset.HasFlag(CharSet.Digits);
+ bool allowSymbols = charset.HasFlag(CharSet.Symbols);
+
+ foreach (char c in input)
+ {
+ // We use a series of 'continue' statements.
+ // If any condition is met, the character is valid, and we check the next.
+
+ // Using BCL methods is faster than c >= 'a' && c <= 'z'
+ if (allowLower && char.IsAsciiLetterLower(c))
+ {
+ continue;
+ }
+ if (allowUpper && char.IsAsciiLetterUpper(c))
+ {
+ continue;
+ }
+ if (allowDigits && char.IsAsciiDigit(c))
+ {
+ continue;
+ }
+
+ // For symbols, checking against the string is the clearest way
+ if (allowSymbols && SymbolSet.Contains(c))
+ {
+ continue;
+ }
+
+ // If we get here, the character 'c' did not match any
+ // allowed set, so the string is not a match.
+ return false;
+ }
+
+ // If the loop finishes, all characters were valid.
+ return true;
+ }
+ }
+
+ """);
+
+ internal static string GetTypeConverterSource(string rootNamespace) => NormalizeLineEndings($$"""
+ //
+ // Copyright (c) Duende Software. All rights reserved.
+ // See LICENSE in the project root for license information.
+ #nullable enable
+
+ using System;
+ using System.ComponentModel;
+ using System.Globalization;
+
+ namespace {{rootNamespace}};
+
+ ///
+ /// Generic type converter for value objects.
+ /// Enables ASP.NET Core model binding and IConfiguration support.
+ ///
+ internal class ValueOfTypeConverter : TypeConverter where TValue : IValueOf
+ {
+ public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
+ {
+ return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
+ }
+
+ public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
+ {
+ if (value is string stringValue)
+ {
+ if (TValue.TryCreate(stringValue, out var result))
+ {
+ return result;
+ }
+ throw new FormatException($"'{stringValue}' is not a valid {typeof(TValue).Name}.");
+ }
+ return base.ConvertFrom(context, culture!, value);
+ }
+
+ public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
+ {
+ return destinationType == typeof(string) || base.CanConvertTo(context, destinationType);
+ }
+
+ public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
+ {
+ if (destinationType == typeof(string) && value is TValue typedValue)
+ {
+ return (typedValue.Value is IFormattable formattable)
+ ? formattable.ToString(null, CultureInfo.InvariantCulture)
+ : typedValue.Value?.ToString();
+ }
+ return base.ConvertTo(context, culture, value, destinationType);
+ }
+ }
+
+ """);
+
+ private static string NormalizeLineEndings(string source) => source.Replace("\r\n", "\n", StringComparison.Ordinal);
+}
diff --git a/shared/ValueObjectsGenerator/Program.cs b/shared/ValueObjectsGenerator/Program.cs
new file mode 100644
index 000000000..99345497f
--- /dev/null
+++ b/shared/ValueObjectsGenerator/Program.cs
@@ -0,0 +1,175 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.ValueObjectsGenerator;
+
+// Simple argument parsing — this is internal tooling, no framework needed
+string? path = null;
+string? ns = null;
+
+for (var i = 0; i < args.Length; i++)
+{
+ if (args[i] == "--path" && i + 1 < args.Length)
+ {
+ path = args[++i];
+ }
+ else if (args[i] == "--namespace" && i + 1 < args.Length)
+ {
+ ns = args[++i];
+ }
+}
+
+if (path is null || ns is null)
+{
+ Console.Error.WriteLine("Usage: ValueObjectsGenerator --path --namespace ");
+ return 1;
+}
+
+if (!Directory.Exists(path))
+{
+ Console.Error.WriteLine($"Directory not found: {path}");
+ return 1;
+}
+
+try
+{
+ return Run(path, ns);
+}
+catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
+{
+ Console.Error.WriteLine($"ValueObjectsGenerator failed: {ex.Message}");
+ return 1;
+}
+
+static int Run(string path, string ns)
+{
+ var valueObjects = FileScanner.Scan(path, ns);
+
+ var written = 0;
+ var skipped = 0;
+ var expectedFiles = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ // Generate per-type .g.cs files next to their source files
+ foreach (var vo in valueObjects)
+ {
+ var sourceDir = Path.GetDirectoryName(vo.SourceFilePath)!;
+ var outputPath = Path.Combine(sourceDir, $"{vo.TypeName}.g.cs");
+
+ var content = vo.Kind switch
+ {
+ ValueObjectKind.StringValue => StringValueCodeGenerator.Generate(vo),
+ ValueObjectKind.ValueOf => ValueOfCodeGenerator.Generate(vo),
+ _ => throw new InvalidOperationException($"Unknown kind: {vo.Kind}")
+ };
+
+ _ = expectedFiles.Add(Path.GetFullPath(outputPath));
+
+ if (WriteIfChanged(outputPath, content))
+ {
+ written++;
+ }
+ else
+ {
+ skipped++;
+ }
+ }
+
+ // Generate infrastructure files in {path}/Internal/ValueObjects/
+ var valueObjectsDir = Path.Combine(path, "Internal", "ValueObjects");
+ _ = Directory.CreateDirectory(valueObjectsDir);
+
+ TrackAndWrite(ref written, ref skipped, expectedFiles,
+ Path.Combine(valueObjectsDir, "IValueOf.g.cs"), InfrastructureCodeGenerator.GetInterfaceSource(ns));
+
+ TrackAndWrite(ref written, ref skipped, expectedFiles,
+ Path.Combine(valueObjectsDir, "ValueOfTypeConverter.g.cs"), InfrastructureCodeGenerator.GetTypeConverterSource(ns));
+
+ TrackAndWrite(ref written, ref skipped, expectedFiles,
+ Path.Combine(valueObjectsDir, "CharsetExtensions.g.cs"), InfrastructureCodeGenerator.GetCharSetsSource(ns));
+
+ // Clean up orphaned .g.cs files that were previously generated by this tool
+ var deleted = CleanOrphanedFiles(path, expectedFiles);
+
+ Console.WriteLine($"ValueObjectsGenerator: {valueObjects.Count} value objects — {written} files written, {skipped} unchanged, {deleted} orphans deleted.");
+ return 0;
+}
+
+static void TrackAndWrite(ref int written, ref int skipped, HashSet expectedFiles, string filePath, string content)
+{
+ _ = expectedFiles.Add(Path.GetFullPath(filePath));
+ if (WriteIfChanged(filePath, content))
+ {
+ written++;
+ }
+ else
+ {
+ skipped++;
+ }
+}
+
+// Deletes orphaned .g.cs files that were previously generated by this tool but are no longer expected.
+// Only deletes files that contain the ValueObjectsGenerator marker comment.
+static int CleanOrphanedFiles(string rootPath, HashSet expectedFiles)
+{
+ const string marker = "// ";
+ var deleted = 0;
+
+ foreach (var file in Directory.EnumerateFiles(rootPath, "*.g.cs", SearchOption.AllDirectories))
+ {
+ var fullPath = Path.GetFullPath(file);
+
+ // Skip files in obj/ and bin/ directories
+ var normalized = fullPath.Replace('\\', '/');
+ if (normalized.Contains("/obj/", StringComparison.Ordinal) ||
+ normalized.Contains("/bin/", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ // Skip files we just generated
+ if (expectedFiles.Contains(fullPath))
+ {
+ continue;
+ }
+
+ // Only delete files that were generated by this tool (contain our marker)
+ string? firstLine;
+ using (var reader = new StreamReader(file))
+ {
+ firstLine = reader.ReadLine();
+ }
+
+ if (firstLine == marker)
+ {
+ File.Delete(file);
+ Console.WriteLine($" Deleted orphaned file: {file}");
+ deleted++;
+ }
+ }
+
+ return deleted;
+}
+
+// Writes content to path only if it differs from what's already there.
+// Returns true if the file was written, false if unchanged or locked.
+static bool WriteIfChanged(string filePath, string content)
+{
+ if (File.Exists(filePath))
+ {
+ var existing = File.ReadAllText(filePath);
+ // Normalize line endings for comparison
+ if (existing.Replace("\r\n", "\n", StringComparison.Ordinal) == content.Replace("\r\n", "\n", StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ // Use FileShare.ReadWrite so IDE language servers holding a shared lock don't block us.
+ // FileMode.OpenOrCreate + SetLength(0) avoids the truncation that FileMode.Create requires
+ // (which needs exclusive access on Windows).
+ using var stream = new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite);
+ stream.SetLength(0);
+ using var writer = new StreamWriter(stream, System.Text.Encoding.UTF8);
+ writer.Write(content);
+ return true;
+}
diff --git a/shared/ValueObjectsGenerator/StringValueCodeGenerator.cs b/shared/ValueObjectsGenerator/StringValueCodeGenerator.cs
new file mode 100644
index 000000000..698ee8361
--- /dev/null
+++ b/shared/ValueObjectsGenerator/StringValueCodeGenerator.cs
@@ -0,0 +1,315 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Duende.ValueObjectsGenerator;
+
+///
+/// Generates .g.cs content for [StringValue] types.
+/// The output is identical to what the Roslyn StringValues source generator produced.
+///
+internal static class StringValueCodeGenerator
+{
+ internal static string Generate(ValueObjectInfo info)
+ {
+ var structName = info.TypeName;
+ var namespaceName = info.Namespace;
+ var internalCtor = info.HasInternalConstructor;
+
+ var validationErrorChecks = BuildValidationErrorChecks(
+ info.HasMaxLength, info.HasAllowedCharacters, info.HasRegex,
+ info.HasAllowedCharSet, info.HasTryValidate);
+
+ var normalizeCall = info.HasNormalize
+ ? """
+
+ s = Normalize(s);
+ if (string.IsNullOrWhiteSpace(s))
+ {
+ errors = ["Value is empty after normalization."];
+ return false;
+ }
+ """
+ : string.Empty;
+
+ var parse = internalCtor ? string.Empty :
+ info.HasParse ? string.Empty :
+ info.HasTryParse
+ ? AddOverridable(
+ $$"""
+ public static {{structName}} Create(string s)
+ {
+ if (!TryCreate(s, out var result))
+ {
+ throw new FormatException($"The value '{s}' is not a valid {{structName}}.");
+ }
+ return result;
+ }
+ """,
+ 4)
+ : AddOverridable(
+ $$"""
+ public static {{structName}} Create(string s)
+ {
+ if (!TryCreate(s, out var result, out var errors))
+ {
+ throw new FormatException($"The value '{s}' is not a valid {{structName}}. {string.Join(" ", errors)}");
+ }
+ return result;
+ }
+ """,
+ 4);
+
+ var tryParse = internalCtor ? string.Empty :
+ info.HasTryParse ? string.Empty : AddOverridable(
+ $$"""
+ public static bool TryCreate(string? s, [NotNullWhen(true)] out {{structName}}? result)
+ => TryCreate(s, out result, out _);
+
+ public static bool TryCreate(string? s, [NotNullWhen(true)] out {{structName}}? result, [NotNullWhen(false)] out IReadOnlyList? errors)
+ {
+ result = null;
+ errors = null;
+ if (string.IsNullOrWhiteSpace(s))
+ {
+ errors = ["A value is required."];
+ return false;
+ }
+ {{normalizeCall}}{{validationErrorChecks}}
+ result = new {{structName}}(s);
+ return true;
+ }
+ """,
+ 4);
+
+ var toString = (info.GenerateToString && !info.HasToString)
+ ? $$"""
+ public override string ToString() => Value;
+ """ : string.Empty;
+
+ var collectionsUsing = (!internalCtor && !info.HasTryParse) ? "using System.Collections.Generic;\n" : string.Empty;
+
+ // When the type's namespace differs from the root namespace (where IStringValue etc. live),
+ // we need an explicit using directive so the generated code can find the infrastructure types.
+ var needsRootNamespace = !info.HasInternalValue &&
+ !string.Equals(info.Namespace, info.RootNamespace, StringComparison.Ordinal);
+ var rootNamespaceUsing = needsRootNamespace
+ ? $"using {info.RootNamespace};\n"
+ : string.Empty;
+
+ var equalityMethods = info.HasComparer
+ ? $$"""
+
+ public virtual bool Equals({{structName}}? other) =>
+ other is not null && Comparer.Equals(Value, other.Value);
+
+ public override int GetHashCode() =>
+ Value is null ? 0 : Comparer.GetHashCode(Value);
+
+ """
+ : string.Empty;
+
+ // When HasInternalConstructor: use internal constructor, skip implicit operator and ParseOrDefault
+ var loadFromStorage = info.HasLoadFromStorage ? string.Empty :
+ $"internal static {structName} Load(string value) => new {structName}(value);";
+
+ var ctorVisibility = internalCtor ? "internal" : "private";
+ // When HasInternalConstructor, the user provides the constructor in the hand-written partial — skip emitting it
+ var ctorDeclaration = internalCtor ? string.Empty :
+ $"// Constructor for controlled creation\n {ctorVisibility} {structName}(string value) => Value = value;";
+
+ var valueProperty = info.HasValue ? string.Empty : "public string Value { get; }";
+
+ var implicitOperatorAndParseOrDefault = internalCtor ? string.Empty :
+ $$"""
+
+ public static implicit operator {{structName}}(string value) => Create(value);
+
+ {{toString}}
+
+ public static {{structName}}? CreateOrDefault(string? input)
+ {
+ if (string.IsNullOrEmpty(input))
+ {
+ return null;
+ }
+
+ return Create(input);
+ }
+ """;
+
+ var toStringWhenInternal = internalCtor
+ ? $$"""
+
+ {{toString}}
+ """
+ : string.Empty;
+
+ var source = $$"""
+ //
+ // Copyright (c) Duende Software. All rights reserved.
+ // See LICENSE in the project root for license information.
+ #nullable enable
+
+ {{collectionsUsing}}{{rootNamespaceUsing}}{{(!internalCtor && !info.HasTryParse ? "using System.Diagnostics.CodeAnalysis;\n" : string.Empty)}}
+ namespace {{namespaceName}};
+
+ {{(internalCtor || info.HasInternalValue ? string.Empty : $"[System.ComponentModel.TypeConverter(typeof(ValueOfTypeConverter<{structName}, string>))]\n")}}partial record {{structName}}{{(info.HasInternalValue ? string.Empty : (internalCtor ? " : IValueOf" : $" : IStringValue<{structName}>"))}}
+ {
+ {{ctorDeclaration}}
+
+ {{valueProperty}}
+
+ {{parse}}
+
+ {{tryParse}}
+ {{implicitOperatorAndParseOrDefault}}{{toStringWhenInternal}}
+
+ {{loadFromStorage}}
+ {{equalityMethods}}
+ }
+
+ """;
+
+ return CollapseBlankLines(source.Replace("\r\n", "\n", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Collapses runs of multiple blank lines into a single blank line and trims trailing whitespace from lines.
+ ///
+ internal static string CollapseBlankLines(string text)
+ {
+ var lines = text.Split('\n');
+ var result = new List(lines.Length);
+ var previousWasBlank = false;
+
+ foreach (var line in lines)
+ {
+ var trimmed = line.TrimEnd();
+ var isBlank = trimmed.Length == 0;
+
+ if (isBlank && previousWasBlank)
+ {
+ continue;
+ }
+
+ result.Add(trimmed);
+ previousWasBlank = isBlank;
+ }
+
+ return string.Join("\n", result);
+ }
+
+ private static string BuildValidationErrorChecks(
+ bool hasMaxLength,
+ bool hasAllowedCharacters,
+ bool hasRegex,
+ bool hasAllowedCharSet,
+ bool hasTryValidate)
+ {
+ var hasAnyRule = hasMaxLength || hasAllowedCharacters || hasRegex || hasAllowedCharSet || hasTryValidate;
+ if (!hasAnyRule)
+ {
+ return string.Empty;
+ }
+
+ var checks = new List();
+
+ checks.Add("""
+
+ var validationErrors = new List();
+ """);
+
+ if (hasMaxLength)
+ {
+ checks.Add("""
+
+ if (s.Length > MaxLength)
+ {
+ validationErrors.Add($"Must not exceed {MaxLength} characters.");
+ }
+ """);
+ }
+
+ if (hasAllowedCharacters)
+ {
+ checks.Add("""
+
+ if (s.Any(c => !AllowedCharacters.Contains(c)))
+ {
+ validationErrors.Add("Must only contain allowed characters.");
+ }
+ """);
+ }
+
+ if (hasRegex)
+ {
+ checks.Add("""
+
+ if (!Regex().IsMatch(s))
+ {
+ validationErrors.Add("Must match the required pattern.");
+ }
+ """);
+ }
+
+ if (hasAllowedCharSet)
+ {
+ checks.Add("""
+
+ if (!AllowedCharSet.IsMatch(s))
+ {
+ validationErrors.Add("Must only contain allowed character types.");
+ }
+ """);
+ }
+
+ if (hasTryValidate)
+ {
+ checks.Add("""
+
+ if (!TryValidate(s, out var tryValidateErrors))
+ {
+ if (tryValidateErrors is { Count: > 0 })
+ {
+ validationErrors.AddRange(tryValidateErrors);
+ }
+ else
+ {
+ validationErrors.Add($"The value '{s}' is not valid.");
+ }
+ }
+ """);
+ }
+
+ checks.Add("""
+
+ if (validationErrors.Count > 0)
+ {
+ errors = validationErrors;
+ return false;
+ }
+ """);
+
+ return string.Join(string.Empty, checks);
+ }
+
+ ///
+ /// Adds indentation to each line (except the first) of the content block.
+ /// Mirrors the AddOverridable helper in the source generator.
+ ///
+ private static string AddOverridable(string content, int leadingSpaces)
+ {
+ var indent = new string(' ', leadingSpaces);
+ var lines = content.Split('\n');
+ var indentedLines = lines.Select((line, index) =>
+ {
+ if (index == 0)
+ {
+ return line;
+ }
+
+ return string.IsNullOrEmpty(line.TrimEnd()) ? line : indent + line;
+ });
+ return string.Join("\n", indentedLines);
+ }
+}
diff --git a/shared/ValueObjectsGenerator/ValueObjectInfo.cs b/shared/ValueObjectsGenerator/ValueObjectInfo.cs
new file mode 100644
index 000000000..27646d0c2
--- /dev/null
+++ b/shared/ValueObjectsGenerator/ValueObjectInfo.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Duende.ValueObjectsGenerator;
+
+///
+/// Describes the kind of value object: string-based or generic.
+///
+internal enum ValueObjectKind
+{
+ StringValue,
+ ValueOf
+}
+
+///
+/// All information needed to generate code for one value object type.
+///
+internal sealed record ValueObjectInfo(
+ string SourceFilePath,
+ string TypeName,
+ string Namespace,
+ string RootNamespace,
+ ValueObjectKind Kind,
+ // ValueOf only — fully-qualified generic type argument, e.g. "global::System.Guid"
+ string? GenericTypeArgument,
+ // Whether the generic type argument is itself a value object (nested)
+ bool IsNestedValueObject,
+ bool HasMaxLength,
+ bool HasAllowedCharacters,
+ bool HasRegex,
+ bool HasAllowedCharSet,
+ bool HasTryValidate,
+ bool HasErrorMessage,
+ bool HasParse,
+ bool HasTryParse,
+ bool HasNormalize,
+ bool GenerateToString,
+ bool HasComparer,
+ bool HasInternalConstructor,
+ bool HasValue,
+ bool HasInternalValue,
+ bool HasLoadFromStorage,
+ bool HasToString
+);
diff --git a/shared/ValueObjectsGenerator/ValueObjectsGenerator.csproj b/shared/ValueObjectsGenerator/ValueObjectsGenerator.csproj
new file mode 100644
index 000000000..00eaed26e
--- /dev/null
+++ b/shared/ValueObjectsGenerator/ValueObjectsGenerator.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ net10.0
+ Duende.ValueObjectsGenerator
+ Duende.ValueObjectsGenerator
+ enable
+ enable
+ false
+
+ false
+
+
+
+
+
+
+
diff --git a/shared/ValueObjectsGenerator/ValueObjectsGenerator.targets b/shared/ValueObjectsGenerator/ValueObjectsGenerator.targets
new file mode 100644
index 000000000..4887fca35
--- /dev/null
+++ b/shared/ValueObjectsGenerator/ValueObjectsGenerator.targets
@@ -0,0 +1,29 @@
+
+
+
+
+ $(MSBuildThisFileDirectory)ValueObjectsGenerator.csproj
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/shared/ValueObjectsGenerator/ValueOfCodeGenerator.cs b/shared/ValueObjectsGenerator/ValueOfCodeGenerator.cs
new file mode 100644
index 000000000..65ff914fd
--- /dev/null
+++ b/shared/ValueObjectsGenerator/ValueOfCodeGenerator.cs
@@ -0,0 +1,198 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Duende.ValueObjectsGenerator;
+
+///
+/// Generates .g.cs content for [ValueOf<T>] types.
+/// The output is identical to what the Roslyn ValueOf source generator produced.
+///
+internal static class ValueOfCodeGenerator
+{
+ internal static string Generate(ValueObjectInfo info)
+ {
+ var structName = info.TypeName;
+ var namespaceName = info.Namespace;
+ var genericTypeArgument = info.GenericTypeArgument ?? "global::System.Object";
+ var isNested = info.IsNestedValueObject;
+ var internalCtor = info.HasInternalConstructor;
+
+ // Validation check inside TryParse errors overload when TryValidate exists
+ var validationErrorCheck = info.HasTryValidate
+ ? $$"""
+
+ if (!TryValidate(value, out var tryValidateErrors))
+ {
+ errors = tryValidateErrors is { Count: > 0 } ? tryValidateErrors : [$"The value is not a valid '{nameof({{structName}})}'."];
+ return false;
+ }
+ """
+ : string.Empty;
+
+ // Implicit operator body
+ var implicitOperatorCheck = info.HasTryValidate
+ ? AddOverridable($$"""
+ if (!TryValidate(value, out var errors))
+ {
+ var errorMessage = $"The value '{value}' is not a valid '{nameof({{structName}})}'. {string.Join(" ", errors ?? [])}";
+ throw new FormatException(errorMessage);
+ }
+ return new {{structName}}(value);
+ """, 8)
+ : $"return new {structName}(value);";
+
+ // Create method
+ var parse = internalCtor ? string.Empty :
+ info.HasParse ? string.Empty :
+ info.HasTryParse
+ ? AddOverridable($$"""
+ public static {{structName}} Create(string s)
+ {
+ if (!TryCreate(s, out var result))
+ {
+ throw new FormatException($"The value '{s}' is not a valid '{nameof({{structName}})}'.");
+ }
+ return result;
+ }
+ """, 4)
+ : AddOverridable($$"""
+ public static {{structName}} Create(string s)
+ {
+ if (!TryCreate(s, out var result, out var errors))
+ {
+ throw new FormatException($"The value '{s}' is not a valid '{nameof({{structName}})}'. {string.Join(" ", errors)}");
+ }
+ return result;
+ }
+ """, 4);
+
+ // TryParse inner call varies on whether T is itself a value object
+ var tryParseInnerCall = isNested
+ ? $"{genericTypeArgument}.TryCreate(s, out var value)"
+ : $"{genericTypeArgument}.TryParse(s, CultureInfo.InvariantCulture, out var value)";
+
+ // TryCreate method
+ var tryParse = internalCtor ? string.Empty :
+ info.HasTryParse ? string.Empty :
+ AddOverridable($$"""
+ public static bool TryCreate(string? s, [NotNullWhen(true)] out {{structName}}? result)
+ => TryCreate(s, out result, out _);
+
+ public static bool TryCreate(string? s, [NotNullWhen(true)] out {{structName}}? result, [NotNullWhen(false)] out IReadOnlyList? errors)
+ {
+ result = null;
+ errors = null;
+ if (string.IsNullOrWhiteSpace(s))
+ {
+ errors = ["A value is required."];
+ return false;
+ }
+
+ if ({{tryParseInnerCall}})
+ {{{validationErrorCheck}}
+ var instance = new {{structName}}(value);
+ result = instance;
+ return true;
+ }
+
+ errors = ["The value could not be parsed."];
+ return false;
+ }
+ """, 4);
+
+ // ToString override
+ var toString = (info.GenerateToString && !info.HasToString)
+ ? (isNested
+ ? "public override string ToString() => Value.ToString();"
+ : "public override string ToString() => Value.ToString(null, CultureInfo.InvariantCulture);")
+ : string.Empty;
+
+ var collectionsUsing = (!internalCtor && !info.HasTryParse) ? "using System.Collections.Generic;\n" : string.Empty;
+
+ var rootNamespaceUsing = !string.Equals(info.Namespace, info.RootNamespace, StringComparison.Ordinal)
+ ? $"using {info.RootNamespace};\n"
+ : string.Empty;
+
+ var loadFromStorage = info.HasLoadFromStorage ? string.Empty :
+ $"internal static {structName} Load({genericTypeArgument} value) => new {structName}(value);";
+
+ var ctorVisibility = internalCtor ? "internal" : "private";
+
+ var valueProperty = info.HasValue ? string.Empty : $"public {genericTypeArgument} Value {{ get; }}";
+
+ var implicitOperatorAndParseOrDefault = internalCtor ? string.Empty :
+ $$"""
+
+ public static implicit operator {{structName}}({{genericTypeArgument}} value)
+ {
+ {{implicitOperatorCheck}}
+ }
+
+ {{toString}}
+
+ public static {{structName}}? CreateOrDefault(string? input)
+ {
+ if (string.IsNullOrEmpty(input))
+ {
+ return null;
+ }
+
+ return Create(input);
+ }
+ """;
+
+ var toStringWhenInternal = internalCtor
+ ? $$"""
+
+ {{toString}}
+ """
+ : string.Empty;
+
+ var source = $$"""
+ //
+ // Copyright (c) Duende Software. All rights reserved.
+ // See LICENSE in the project root for license information.
+ #nullable enable
+
+ {{collectionsUsing}}{{rootNamespaceUsing}}{{(internalCtor ? string.Empty : "using System.Globalization;\n")}}{{(!internalCtor && !info.HasTryParse ? "using System.Diagnostics.CodeAnalysis;\n" : string.Empty)}}
+ namespace {{namespaceName}};
+
+ {{(internalCtor ? string.Empty : $"[System.ComponentModel.TypeConverter(typeof(ValueOfTypeConverter<{structName}, {genericTypeArgument}>))]\n")}}partial record {{structName}} : {{(internalCtor ? $"IValueOf<{genericTypeArgument}>" : $"IValueOf<{structName}, {genericTypeArgument}>")}}
+ {
+ // Constructor for controlled creation
+ {{ctorVisibility}} {{structName}}({{genericTypeArgument}} value) => Value = value;
+
+ {{valueProperty}}
+
+ {{parse}}
+
+ {{tryParse}}
+ {{implicitOperatorAndParseOrDefault}}{{toStringWhenInternal}}
+
+ {{loadFromStorage}}
+ }
+
+ """;
+
+ return StringValueCodeGenerator.CollapseBlankLines(source.Replace("\r\n", "\n", StringComparison.Ordinal));
+ }
+
+ ///
+ /// Adds indentation to each line (except the first) of the content block.
+ ///
+ private static string AddOverridable(string content, int leadingSpaces)
+ {
+ var indent = new string(' ', leadingSpaces);
+ var lines = content.Split('\n');
+ var indentedLines = lines.Select((line, index) =>
+ {
+ if (index == 0)
+ {
+ return line;
+ }
+
+ return string.IsNullOrEmpty(line.TrimEnd()) ? line : indent + line;
+ });
+ return string.Join("\n", indentedLines);
+ }
+}
diff --git a/shared/Xunit.Playwright/Duende.Xunit.Playwright.v3.ncrunchproject b/shared/Xunit.Playwright/Duende.Xunit.Playwright.v3.ncrunchproject
deleted file mode 100644
index f36abf246..000000000
--- a/shared/Xunit.Playwright/Duende.Xunit.Playwright.v3.ncrunchproject
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
- Debug_NCrunch
-
-
\ No newline at end of file
diff --git a/shared/Xunit.Playwright/Xunit.Playwright.csproj b/shared/Xunit.Playwright/Xunit.Playwright.csproj
index e9e37cea5..44b26d8b2 100644
--- a/shared/Xunit.Playwright/Xunit.Playwright.csproj
+++ b/shared/Xunit.Playwright/Xunit.Playwright.csproj
@@ -8,6 +8,9 @@
$(DefineConstants);DEBUG_NCRUNCH
+
+
+
diff --git a/src.props b/src.props
index a2d731bb9..6f765d651 100644
--- a/src.props
+++ b/src.props
@@ -32,6 +32,10 @@
+
+
+
+
diff --git a/test.props b/test.props
index 77c540734..4311d162d 100644
--- a/test.props
+++ b/test.props
@@ -10,20 +10,20 @@
true
- $(NoWarn);CA1707
+ $(NoWarn);CA1707;IDE1006
-
-
-
-
+
+
+
+