From ca597f20010a6866565fdc453d4d1688b5ff3b92 Mon Sep 17 00:00:00 2001 From: Gabriel-Lucaci_slch Date: Fri, 18 Sep 2026 08:27:35 +0200 Subject: [PATCH 1/2] fix: update dotnet-options validation to ignore --no-restore flag --- .../src/Confix.Library/Validation/DotnetOptionsValidator.cs | 6 +----- .../CodeFirst/DotnetOptionsValidatorTests.cs | 5 +++-- .../test/Confix.Tool.Tests/CodeFirst/RunnerBoundaryTests.cs | 6 ------ 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsValidator.cs b/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsValidator.cs index b40704d..cf9a878 100644 --- a/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsValidator.cs +++ b/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsValidator.cs @@ -22,11 +22,7 @@ internal sealed class DotnetOptionsValidator : IConfigurationValidator ValidationConfiguration settings, bool exportSchema) { - if (context.Parameter.TryGet(NoRestoreOptions.Instance, out bool noRestore) && noRestore) - { - throw new ExitException("--no-restore is not supported for dotnet-options validation."); - } - + // --no-restore only skips schema restore, which this validator never reads. var runner = Path.Combine(AppContext.BaseDirectory, RunnerAssembly); if (!File.Exists(runner)) diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/DotnetOptionsValidatorTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/DotnetOptionsValidatorTests.cs index d4af56e..8270fbd 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/DotnetOptionsValidatorTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/DotnetOptionsValidatorTests.cs @@ -20,13 +20,14 @@ public sealed class DotnetOptionsValidatorTests : IDisposable public void Dispose() => _directory.Delete(recursive: true); [Fact] - public async Task RequiredValidationCannotBeSkippedWithNoRestore() + public async Task NoRestoreIsIgnoredInsteadOfRejected() { + // The flag is shared with json-schema projects, so it must not fail the build here. var validate = async () => await ValidateAsync( new Dictionary { [NoRestoreOptions.Instance] = true }); (await validate.Should().ThrowAsync()) - .Which.Message.Should().Contain("--no-restore is not supported"); + .Which.Message.Should().Contain("exactly one project file"); } [Fact] diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/RunnerBoundaryTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/RunnerBoundaryTests.cs index 7cbe471..98b1eb0 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/RunnerBoundaryTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/RunnerBoundaryTests.cs @@ -186,12 +186,6 @@ await File.WriteAllTextAsync( var relaxed = await Run(folder, cli, "validate"); relaxed.Exit.Should().Be(0, relaxed.Output); - - // Required validation can never be bypassed. - var skipped = await Run(folder, cli, "validate", "--no-restore"); - - skipped.Exit.Should().NotBe(0); - skipped.Output.Should().Contain("--no-restore"); } finally { From 0e2fb739f6dd16e66f788ecf6815ceab0d4a7c86 Mon Sep 17 00:00:00 2001 From: Gabriel-Lucaci_slch Date: Fri, 18 Sep 2026 09:05:26 +0200 Subject: [PATCH 2/2] feat: implement code-first schema derivation and validation enhancements --- src/Confix.Runner/SchemaExport.cs | 64 ++++- src/Confix.Runner/ValidationRunner.cs | 4 +- .../Extensions/ProjectMiddlewareExtensions.cs | 3 + .../Project/RestoreProjectMiddleware.cs | 27 +++ .../ConfigurationValidationPipeline.cs | 4 + .../Validation/DotnetOptionsRunner.cs | 220 ++++++++++++++++++ .../Validation/DotnetOptionsSchemaComposer.cs | 80 +++++++ .../Validation/DotnetOptionsValidator.cs | 205 ++-------------- .../RestoreDotnetSchemaMiddleware.cs | 29 +++ .../CodeFirst/RunnerBoundaryTests.cs | 63 +++++ 10 files changed, 506 insertions(+), 193 deletions(-) create mode 100644 src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsRunner.cs create mode 100644 src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsSchemaComposer.cs create mode 100644 src/Confix.Tool/src/Confix.Library/Validation/RestoreDotnetSchemaMiddleware.cs diff --git a/src/Confix.Runner/SchemaExport.cs b/src/Confix.Runner/SchemaExport.cs index e6d44e5..8b9d9a7 100644 --- a/src/Confix.Runner/SchemaExport.cs +++ b/src/Confix.Runner/SchemaExport.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Reflection; @@ -120,6 +121,7 @@ private static JsonSchemaExporterOptions CreateExporterOptions() } ApplyAnnotations(obj, member); + ApplyDefault(obj, member); // Any value may instead be a variable expression that Confix resolves on build. return new JsonObject @@ -138,7 +140,6 @@ private static void ApplyAnnotations(JsonObject schema, PropertyInfo member) { schema["description"] = description.Description; } - if (member.GetCustomAttribute() is { Minimum: int min, Maximum: int max }) { schema["minimum"] = min; @@ -171,6 +172,67 @@ private static void ApplyAnnotations(JsonObject schema, PropertyInfo member) } } + /// + /// Surfaces property initializers as schema defaults so scaffolding can write real values. + /// + private static void ApplyDefault(JsonObject schema, PropertyInfo member) + { + var declaringType = member.DeclaringType; + + if (declaringType is null) + { + return; + } + + var instance = _instances.GetOrAdd(declaringType, static type => + { + try + { + return Activator.CreateInstance(type); + } + catch + { + return null; + } + }); + + if (instance is null) + { + return; + } + + object? value; + + try + { + value = member.GetValue(instance); + } + catch + { + return; + } + + // A value equal to the CLR default is indistinguishable from "no initializer". + JsonNode? defaultNode = value switch + { + string s => JsonValue.Create(s), + true => JsonValue.Create(true), + Enum e => JsonValue.Create(e.ToString()), + sbyte or byte or short or ushort or int or uint or long or ulong + when Convert.ToInt64(value) != 0 => JsonValue.Create(Convert.ToInt64(value)), + float or double or decimal + when Convert.ToDecimal(value) != 0 => JsonValue.Create(Convert.ToDecimal(value)), + _ => null + }; + + if (defaultNode is not null) + { + schema["default"] = defaultNode; + } + } + + private static readonly ConcurrentDictionary _instances = new(); + private static void Mount( JsonObject root, JsonNode schema, diff --git a/src/Confix.Runner/ValidationRunner.cs b/src/Confix.Runner/ValidationRunner.cs index abf7cef..0377a75 100644 --- a/src/Confix.Runner/ValidationRunner.cs +++ b/src/Confix.Runner/ValidationRunner.cs @@ -80,7 +80,9 @@ private static (List Errors, JsonObject? Schema) Validate(string[] args, var errors = ContractValidation.Validate(host.Services, configuration, strict).ToList(); CheckSectionObjects(document, contracts, errors); - var schema = errors.Count == 0 && payload["exportSchema"]?.GetValue() == true + // The schema comes from the contracts alone, so scaffolding tooling can request it + // precisely when the document is still invalid. + var schema = payload["exportSchema"]?.GetValue() == true ? SchemaExport.Export(contracts, strict) : null; diff --git a/src/Confix.Tool/src/Confix.Library/Middlewares/Project/Extensions/ProjectMiddlewareExtensions.cs b/src/Confix.Tool/src/Confix.Library/Middlewares/Project/Extensions/ProjectMiddlewareExtensions.cs index 37fd256..12c3a7d 100644 --- a/src/Confix.Tool/src/Confix.Library/Middlewares/Project/Extensions/ProjectMiddlewareExtensions.cs +++ b/src/Confix.Tool/src/Confix.Library/Middlewares/Project/Extensions/ProjectMiddlewareExtensions.cs @@ -1,5 +1,6 @@ using Confix.Tool.Entities.Components.DotNet; using Confix.Tool.Reporting; +using Confix.Tool.Validation; using Confix.Utilities; using Microsoft.Extensions.DependencyInjection; @@ -15,6 +16,8 @@ public static ConfixCommandLineBuilder RegisterProjectMiddlewares(this ConfixCom sp.GetRequiredService())) .AddTransient(sp => new InitializeConfigurationDefaultValues(sp.GetRequiredService())) + .AddTransient(sp => + new RestoreDotnetSchemaMiddleware(sp.GetRequiredService())) .AddTransient() .AddTransient() .AddTransient() diff --git a/src/Confix.Tool/src/Confix.Library/Middlewares/Project/RestoreProjectMiddleware.cs b/src/Confix.Tool/src/Confix.Library/Middlewares/Project/RestoreProjectMiddleware.cs index 36af06d..e11147c 100644 --- a/src/Confix.Tool/src/Confix.Library/Middlewares/Project/RestoreProjectMiddleware.cs +++ b/src/Confix.Tool/src/Confix.Library/Middlewares/Project/RestoreProjectMiddleware.cs @@ -5,6 +5,7 @@ using Confix.Tool.Entities.Components.DotNet; using Confix.Tool.Middlewares.JsonSchemas; using Confix.Tool.Schema; +using Confix.Tool.Validation; using Confix.Variables; namespace Confix.Tool.Middlewares.Project; @@ -41,6 +42,32 @@ public async Task InvokeAsync(IMiddlewareContext context, MiddlewareDelegate nex var project = configuration.EnsureProject(); var solution = configuration.EnsureSolution(); + // Code-first projects derive their schema from the option contracts instead of components. + var validation = DotnetOptionsSchemaComposer.ResolveSettings(context); + + if (validation?.EffectiveType == ValidationConfiguration.DotnetOptions) + { + var storedSchema = await DotnetOptionsSchemaComposer.ComposeAndStoreAsync( + context, validation, _schemaStore); + + jsonSchemas.Schemas.Add(new JsonSchemaDefinition + { + Project = project, + Solution = solution.Directory!, + FileMatch = files + .Select(x => x.InputFile.RelativeTo(solution.Directory!)) + .ToList(), + SchemaFile = storedSchema, + RelativePathToProject = Path.GetRelativePath( + solution.Directory!.FullName, + project.Directory!.FullName) + }); + + context.Logger.LogSchemaCompositionCompleted(project); + + return; + } + context.SetStatus("Loading components..."); var components = await context.Features.Get() .Executor.LoadComponents(solution, project, cancellationToken); diff --git a/src/Confix.Tool/src/Confix.Library/Validation/ConfigurationValidationPipeline.cs b/src/Confix.Tool/src/Confix.Library/Validation/ConfigurationValidationPipeline.cs index 2583edf..ccba95a 100644 --- a/src/Confix.Tool/src/Confix.Library/Validation/ConfigurationValidationPipeline.cs +++ b/src/Confix.Tool/src/Confix.Library/Validation/ConfigurationValidationPipeline.cs @@ -56,6 +56,10 @@ public static async Task DispatchAsync( await continuePipeline(current); }) .Use() + // Build scaffolds missing sections from the contract schema before validating. + .When(_ => write, scaffold => scaffold + .Use() + .Use()) .Use() .Use((current, _) => ValidateAsync(current, validator, settings, write, exportSchema))) .ExecuteAsync(context); diff --git a/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsRunner.cs b/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsRunner.cs new file mode 100644 index 0000000..a0875d9 --- /dev/null +++ b/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsRunner.cs @@ -0,0 +1,220 @@ +using System.Text.Json.Nodes; +using Confix.Tool.Commands.Logging; +using Confix.Tool.Common.Pipelines; + +namespace Confix.Tool.Validation; + +/// Builds the code-first host project and exchanges requests with its runner. +internal static class DotnetOptionsRunner +{ + public const int ProtocolVersion = 1; + private const string RunnerAssembly = "Confix.Runner.dll"; + private const string DefaultConfiguration = "Debug"; + + public static string ResolveRunner() + { + var runner = Path.Combine(AppContext.BaseDirectory, RunnerAssembly); + + if (!File.Exists(runner)) + { + throw new ExitException("The Confix installation is missing its validation runner."); + } + + return runner; + } + + /// + /// Builds and resolves the target once per pipeline run; schema derivation and validation + /// share the same build. + /// + public static async Task EnsureTargetAsync( + IMiddlewareContext context, + string directory, + string? framework) + { + if (context.Features.TryGet(out DotnetValidationTargetFeature? cached)) + { + return cached.Target; + } + + var projects = Directory.GetFiles(directory, "*.csproj"); + + if (projects.Length != 1) + { + throw new ExitException( + "Code-first validation requires exactly one project file in the project directory."); + } + + context.Parameter.TryGet( + DotnetConfigurationOptions.Instance, + out string? configuration); + + if (string.IsNullOrWhiteSpace(configuration)) + { + configuration = DefaultConfiguration; + } + + await BuildAsync(context, directory, projects[0], configuration, framework); + + var target = await ResolveTargetAsync( + context, directory, projects[0], configuration, framework); + + context.Features.Set(new DotnetValidationTargetFeature(target)); + + return target; + } + + public static async Task InvokeAsync( + IMiddlewareContext context, + ValidationTarget target, + string directory, + JsonObject request) + { + string[] arguments = + [ + "exec", + "--runtimeconfig", target.RuntimeConfig, + "--depsfile", target.Dependencies, + ResolveRunner(), + target.Assembly + ]; + + var result = await DotnetValidationProcess.RunAsync( + arguments, + request.ToJsonString(), + directory, + context.CancellationToken); + + JsonNode? response; + + try + { + response = JsonNode.Parse(result.Output); + } + catch (System.Text.Json.JsonException) + { + throw new ExitException("The code-first runner returned an invalid response."); + } + + if (response?["version"]?.GetValue() != ProtocolVersion) + { + throw new ExitException("Unsupported code-first runner protocol."); + } + + var errors = response["errors"]?.AsArray() + ?? throw new ExitException("Invalid code-first validation response."); + + return new RunnerResponse( + result.ExitCode, + errors.Select(e => e!.GetValue()).ToArray(), + response["schema"]?.DeepClone()); + } + + private static async Task BuildAsync( + IMiddlewareContext context, + string directory, + string project, + string configuration, + string? framework) + { + var arguments = new List + { + "build", + project, + "--configuration", configuration, + "--nologo", + "-v:q", + "--disable-build-servers" + }; + + if (framework is not null) + { + arguments.AddRange(["--framework", framework]); + } + + context.Logger.Information("Building code-first validation contracts."); + + var build = await DotnetValidationProcess.RunAsync( + arguments, + null, + directory, + context.CancellationToken); + + if (build.ExitCode != 0) + { + throw new ExitException( + "Code-first project build failed. " + + "Run dotnet build on the project for compiler diagnostics."); + } + + context.Logger.Debug("Code-first project build completed."); + } + + private static async Task ResolveTargetAsync( + IMiddlewareContext context, + string directory, + string project, + string configuration, + string? framework) + { + var arguments = new List + { + "msbuild", + project, + "-nologo", + "-nodeReuse:false", + "-getProperty:TargetPath,TargetFramework,TargetFrameworks", + "-property:Configuration=" + configuration + }; + + if (framework is not null) + { + arguments.Add("-property:TargetFramework=" + framework); + } + + var result = await DotnetValidationProcess.RunAsync( + arguments, + null, + directory, + context.CancellationToken); + + if (result.ExitCode != 0) + { + throw new ExitException("Could not determine the code-first target output."); + } + + var properties = JsonNode.Parse(result.Output)?["Properties"]; + + if (string.IsNullOrEmpty(properties?["TargetFramework"]?.GetValue())) + { + throw new ExitException("Set project.validation.framework for a multi-target project."); + } + + var assembly = properties["TargetPath"]!.GetValue(); + var runtimeConfig = Path.ChangeExtension(assembly, ".runtimeconfig.json"); + var dependencies = Path.ChangeExtension(assembly, ".deps.json"); + + if (!File.Exists(runtimeConfig) || !File.Exists(dependencies)) + { + throw new ExitException( + "Code-first validation requires a host project that produces " + + "runtimeconfig.json and deps.json."); + } + + context.Logger.Debug("Code-first target selected: " + assembly); + + return new ValidationTarget(assembly, runtimeConfig, dependencies); + } +} + +internal sealed record ValidationTarget( + string Assembly, + string RuntimeConfig, + string Dependencies); + +internal sealed record RunnerResponse( + int ExitCode, + IReadOnlyList Errors, + JsonNode? Schema); + +internal sealed record DotnetValidationTargetFeature(ValidationTarget Target); diff --git a/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsSchemaComposer.cs b/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsSchemaComposer.cs new file mode 100644 index 0000000..8bfc4a5 --- /dev/null +++ b/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsSchemaComposer.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Nodes; +using Confix.Tool.Abstractions; +using Confix.Tool.Common.Pipelines; +using Confix.Tool.Entities.Components.DotNet; +using Confix.Tool.Middlewares; +using Json.Schema; + +namespace Confix.Tool.Validation; + +/// +/// Derives the project schema from the application's option contracts and stores it where +/// json-schema composition stores its result, so defaults initialization, init and IDE +/// integration work identically for both providers. +/// +internal static class DotnetOptionsSchemaComposer +{ + public static async Task ComposeAndStoreAsync( + IMiddlewareContext context, + ValidationConfiguration settings, + ISchemaStore schemaStore) + { + var configuration = context.Features.Get(); + + configuration.EnsureProjectScope(); + + var project = configuration.EnsureProject(); + var solution = configuration.EnsureSolution(); + var directory = project.Directory!.FullName; + + context.SetStatus("Deriving the schema from the option contracts..."); + + var target = await DotnetOptionsRunner.EnsureTargetAsync( + context, directory, settings.Framework); + + // The current document rides along so host capture sees realistic configuration, + // but its validation errors are irrelevant for schema derivation. + var document = context.Features + .Get() + .Files + .FirstOrDefault()? + .Content? + .DeepClone() ?? new JsonObject(); + + var request = new JsonObject + { + ["version"] = DotnetOptionsRunner.ProtocolVersion, + ["configuration"] = document, + ["coverage"] = settings.Coverage ?? ValidationConfiguration.Strict, + ["exportSchema"] = true + }; + + var response = await DotnetOptionsRunner.InvokeAsync(context, target, directory, request); + + if (response.Schema is null) + { + throw new ExitException( + "The code-first schema could not be derived:\n" + + string.Join('\n', response.Errors.DefaultIfEmpty("The runner returned no schema."))); + } + + var schema = JsonSchema.FromText(response.Schema.ToJsonString()); + + return await schemaStore.StoreAsync(solution, project, schema, context.CancellationToken); + } + + /// The validation settings that apply to the project in the current context. + public static ValidationConfiguration? ResolveSettings(IMiddlewareContext context) + { + var project = context.Features.Get().EnsureProject(); + + if (!context.Features.TryGet(out EnvironmentFeature? environment)) + { + return project.Validation; + } + + var validation = environment.ActiveEnvironment.Validation; + + return project.Validation?.Merge(validation) ?? validation; + } +} diff --git a/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsValidator.cs b/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsValidator.cs index cf9a878..88d7d4f 100644 --- a/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsValidator.cs +++ b/src/Confix.Tool/src/Confix.Library/Validation/DotnetOptionsValidator.cs @@ -1,9 +1,7 @@ using System.Text.Json.Nodes; using Confix.Tool.Abstractions; -using Confix.Tool.Commands.Logging; using Confix.Tool.Common.Pipelines; using Confix.Tool.Middlewares; -using Confix.Tool.Schema; using Spectre.Console; namespace Confix.Tool.Validation; @@ -13,23 +11,12 @@ namespace Confix.Tool.Validation; /// internal sealed class DotnetOptionsValidator : IConfigurationValidator { - private const int ProtocolVersion = 1; - private const string RunnerAssembly = "Confix.Runner.dll"; - private const string DefaultConfiguration = "Debug"; - public async Task ValidateAsync( IMiddlewareContext context, ValidationConfiguration settings, bool exportSchema) { // --no-restore only skips schema restore, which this validator never reads. - var runner = Path.Combine(AppContext.BaseDirectory, RunnerAssembly); - - if (!File.Exists(runner)) - { - throw new ExitException("The Confix installation is missing its validation runner."); - } - var directory = context.Features .Get() .EnsureProject() @@ -37,7 +24,8 @@ internal sealed class DotnetOptionsValidator : IConfigurationValidator .FullName; var files = context.Features.Get().Files; - var target = await BuildTargetAsync(context, directory, settings.Framework); + var target = await DotnetOptionsRunner.EnsureTargetAsync( + context, directory, settings.Framework); JsonNode? schema = null; @@ -46,192 +34,27 @@ internal sealed class DotnetOptionsValidator : IConfigurationValidator // Contracts are identical for every input, so the schema is requested only once. var request = new JsonObject { - ["version"] = ProtocolVersion, + ["version"] = DotnetOptionsRunner.ProtocolVersion, ["configuration"] = file.Content?.DeepClone(), ["coverage"] = settings.Coverage ?? ValidationConfiguration.Strict, ["exportSchema"] = exportSchema && schema is null }; - string[] arguments = - [ - "exec", - "--runtimeconfig", target.RuntimeConfig, - "--depsfile", target.Dependencies, - runner, - target.Assembly - ]; - - var validation = await DotnetValidationProcess.RunAsync( - arguments, - request.ToJsonString(), - directory, - context.CancellationToken); - - schema = ReadResponse(validation.Output, validation.ExitCode, file.InputFile.Name) - ?? schema; - } - - return schema; - } - - private static async Task BuildTargetAsync( - IMiddlewareContext context, - string directory, - string? framework) - { - var projects = Directory.GetFiles(directory, "*.csproj"); - - if (projects.Length != 1) - { - throw new ExitException( - "Code-first validation requires exactly one project file in the project directory."); - } - - context.Parameter.TryGet( - DotnetConfigurationOptions.Instance, - out string? configuration); - - if (string.IsNullOrWhiteSpace(configuration)) - { - configuration = DefaultConfiguration; - } - - await BuildAsync(context, directory, projects[0], configuration, framework); - - return await ResolveTargetAsync(context, directory, projects[0], configuration, framework); - } - - private static async Task BuildAsync( - IMiddlewareContext context, - string directory, - string project, - string configuration, - string? framework) - { - var arguments = new List - { - "build", - project, - "--configuration", configuration, - "--nologo", - "-v:q", - "--disable-build-servers" - }; - - if (framework is not null) - { - arguments.AddRange(["--framework", framework]); - } - - context.Logger.Information("Building code-first validation contracts."); - - var build = await DotnetValidationProcess.RunAsync( - arguments, - null, - directory, - context.CancellationToken); - - if (build.ExitCode != 0) - { - throw new ExitException( - "Code-first project build failed. " + - "Run dotnet build on the project for compiler diagnostics."); - } - - context.Logger.Debug("Code-first project build completed."); - } - - private static async Task ResolveTargetAsync( - IMiddlewareContext context, - string directory, - string project, - string configuration, - string? framework) - { - var arguments = new List - { - "msbuild", - project, - "-nologo", - "-nodeReuse:false", - "-getProperty:TargetPath,TargetFramework,TargetFrameworks", - "-property:Configuration=" + configuration - }; + var response = await DotnetOptionsRunner.InvokeAsync( + context, target, directory, request); - if (framework is not null) - { - arguments.Add("-property:TargetFramework=" + framework); - } - - var result = await DotnetValidationProcess.RunAsync( - arguments, - null, - directory, - context.CancellationToken); - - if (result.ExitCode != 0) - { - throw new ExitException("Could not determine the code-first target output."); - } - - var properties = JsonNode.Parse(result.Output)?["Properties"]; - - if (string.IsNullOrEmpty(properties?["TargetFramework"]?.GetValue())) - { - throw new ExitException("Set project.validation.framework for a multi-target project."); - } - - var assembly = properties["TargetPath"]!.GetValue(); - var runtimeConfig = Path.ChangeExtension(assembly, ".runtimeconfig.json"); - var dependencies = Path.ChangeExtension(assembly, ".deps.json"); - - if (!File.Exists(runtimeConfig) || !File.Exists(dependencies)) - { - throw new ExitException( - "Code-first validation requires a host project that produces " + - "runtimeconfig.json and deps.json."); - } - - context.Logger.Debug("Code-first target selected: " + assembly); - - return new ValidationTarget(assembly, runtimeConfig, dependencies); - } - - private static JsonNode? ReadResponse(string output, int exitCode, string inputFileName) - { - JsonNode? response; - - try - { - response = JsonNode.Parse(output); - } - catch (System.Text.Json.JsonException) - { - throw new ExitException("The code-first runner returned an invalid response."); - } - - if (response?["version"]?.GetValue() != ProtocolVersion) - { - throw new ExitException("Unsupported code-first runner protocol."); - } - - var errors = response["errors"]?.AsArray() - ?? throw new ExitException("Invalid code-first validation response."); + if (response.ExitCode != 0 || response.Errors.Count > 0) + { + var messages = response.Errors.Select(Markup.Escape); - if (exitCode != 0 || errors.Count > 0) - { - var messages = errors.Select(error => Markup.Escape(error!.GetValue())); + throw new ExitException( + $"Code-first validation failed for {file.InputFile.Name}:\n" + + string.Join('\n', messages)); + } - throw new ExitException( - $"Code-first validation failed for {inputFileName}:\n" + - string.Join('\n', messages)); + schema = response.Schema ?? schema; } - return response["schema"]?.DeepClone(); + return schema; } - - private sealed record ValidationTarget( - string Assembly, - string RuntimeConfig, - string Dependencies); } diff --git a/src/Confix.Tool/src/Confix.Library/Validation/RestoreDotnetSchemaMiddleware.cs b/src/Confix.Tool/src/Confix.Library/Validation/RestoreDotnetSchemaMiddleware.cs new file mode 100644 index 0000000..74a8465 --- /dev/null +++ b/src/Confix.Tool/src/Confix.Library/Validation/RestoreDotnetSchemaMiddleware.cs @@ -0,0 +1,29 @@ +using Confix.Tool.Abstractions; +using Confix.Tool.Common.Pipelines; +using Confix.Tool.Entities.Components.DotNet; +using Confix.Tool.Middlewares; + +namespace Confix.Tool.Validation; + +/// +/// Refreshes the stored schema from the option contracts so defaults initialization +/// always scaffolds against the contracts of the current build. +/// +internal sealed class RestoreDotnetSchemaMiddleware(ISchemaStore schemaStore) : IMiddleware +{ + public async Task InvokeAsync(IMiddlewareContext context, MiddlewareDelegate next) + { + var settings = DotnetOptionsSchemaComposer.ResolveSettings(context); + + // Defaults initialization restores on its own when no schema is cached. + var skip = context.Parameter.TryGet(NoRestoreOptions.Instance, out bool noRestore) + && noRestore; + + if (!skip && settings?.EffectiveType == ValidationConfiguration.DotnetOptions) + { + await DotnetOptionsSchemaComposer.ComposeAndStoreAsync(context, settings, schemaStore); + } + + await next(context); + } +} diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/RunnerBoundaryTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/RunnerBoundaryTests.cs index 98b1eb0..47c9038 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/RunnerBoundaryTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/CodeFirst/RunnerBoundaryTests.cs @@ -193,6 +193,69 @@ await File.WriteAllTextAsync( } } + [Fact] + public async Task BuildScaffoldsRequiredSectionsWithContractDefaults() + { + var root = FindRoot(); + var folder = Path.Combine(Path.GetTempPath(), "confix-scaffold-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + try + { + await WriteHostAsync(root, folder, """ + [ConfixSection("Mail")] + public sealed class Mail + { + [Required] public string Host { get; set; } = ""; + [Range(1, 65535)] public int Port { get; set; } = 587; + public bool UseTls { get; set; } = true; + } + """); + + var input = Path.Combine(folder, "appsettings.json"); + var output = Path.Combine(folder, "rendered.json"); + var cli = CliPath(root); + + await File.WriteAllTextAsync(input, "{}"); + + // The scaffold happens even though the build fails on the still-empty host. + var scaffolding = await Run(folder, cli, "build", "--output-file", output); + + scaffolding.Exit.Should().NotBe(0); + File.Exists(output).Should().BeFalse("an invalid build must not publish"); + + var scaffolded = JsonNode.Parse(await File.ReadAllTextAsync(input))!; + + scaffolded["Mail"].Should().NotBeNull("the required section must be initialized"); + scaffolded["Mail"]!["Port"]!.GetValue().Should().Be(587); + scaffolded["Mail"]!["UseTls"]!.GetValue().Should().BeTrue(); + scaffolded["Mail"]!["Host"]!.GetValue().Should().BeEmpty(); + + // Filling in the single scaffolded gap makes the same build pass. + scaffolded["Mail"]!["Host"] = "server"; + await File.WriteAllTextAsync(input, scaffolded.ToJsonString()); + + var complete = await Run(folder, cli, "build", "--output-file", output); + + complete.Exit.Should().Be(0, complete.Output); + + var rendered = JsonNode.Parse(await File.ReadAllTextAsync(output))!; + + rendered["Mail"]!["Port"]!.GetValue().Should().Be(587); + + // validate never scaffolds: the input file stays untouched. + await File.WriteAllTextAsync(input, "{}"); + + var validate = await Run(folder, cli, "validate"); + + validate.Exit.Should().NotBe(0); + (await File.ReadAllTextAsync(input)).Should().Be("{}"); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + [Fact] public async Task VariablesAreResolvedBeforeTheContractsSeeTheConfiguration() {