diff --git a/Buildvana.slnx b/Buildvana.slnx index db368d7..2cd2ea2 100644 --- a/Buildvana.slnx +++ b/Buildvana.slnx @@ -42,6 +42,7 @@ + @@ -50,6 +51,8 @@ + + diff --git a/docs/ToolDiagnostics.md b/docs/ToolDiagnostics.md new file mode 100644 index 0000000..49f5521 --- /dev/null +++ b/docs/ToolDiagnostics.md @@ -0,0 +1,30 @@ +# Diagnostics issued by `bv` + + +**Table of contents** + + +- [Overview](#overview) +- [Main program (1000-1099)](#main-program-1000-1099) +- [Configuration (1100-1199)](#configuration-1100-1199) + +## Overview + +All diagnostics issued by the `bv` CLI tool have a `BV` prefix. All numbers start from 1000, so there are no leading zeros. + +Each part of the program is assigned a contiguous range of 100 diagnostics, as listed below. The first range is reserved for the main program. + +## Main program (1000-1099) + +There are no associated diagnostics. + +## Configuration (1100-1199) + +| Code | Severity | Message | Description | +| ------ | :------: | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| BV1100 | Error | _(the JSON parser's reason)_ | The configuration file could not be parsed as JSON. The message carries the parser's reason; the location points at the offending character. | +| BV1101 | Error | Expected _(type)_, but found _(type)_. | A value has a type the schema does not allow at that location (for example, a number where a string is required, or an explicit `null`). | +| BV1102 | Error | _(value)_ is not one of the allowed values: _(list)_. | A value is not among those the schema permits at that location (for example, an unknown enumeration value). | +| BV1103 | Error | Unknown property '_(name)_'. | The configuration file contains a property the schema does not define, or a dictionary key outside the allowed set. | +| BV1104 | Error | Missing required property '_(name)_'. | A property the schema marks as required is absent. | +| BV1105 | Error | No value is allowed here. | A value appears at a location where the schema permits none. | diff --git a/schemas/buildvana.schema.json b/schemas/buildvana.schema.json index 3b098c9..b879f66 100644 --- a/schemas/buildvana.schema.json +++ b/schemas/buildvana.schema.json @@ -5,91 +5,60 @@ "properties": { "$schema": { "description": "URI of the JSON schema describing this file.", - "type": [ - "string", - "null" - ] + "type": "string" }, "release": { "description": "Configuration for the bv release workflow.", - "type": [ - "object", - "null" - ], + "type": "object", "properties": { "branches": { "description": "Regular expressions (matched against the short branch name) identifying branches that produce public releases.", - "type": [ - "array", - "null" - ], + "type": "array", "items": { "type": "string" } }, "generateDocsFrom": { "description": "Regular expressions (matched against the short branch name) identifying branches that documentation is generated from.", - "type": [ - "array", - "null" - ], + "type": "array", "items": { "type": "string" } }, "configuration": { "description": "Build configuration used to produce release artifacts. Defaults to dotnet.configuration when omitted.", - "type": [ - "string", - "null" - ] + "type": "string" }, "checkPublicApi": { "description": "Whether public API files are checked before a release.", - "type": [ - "boolean", - "null" - ] + "type": "boolean" }, "changelogUpdates": { "description": "Which releases require a changelog update.", "enum": [ "none", "stable", - "all", - null + "all" ] }, "emptyChangelog": { "description": "Text substituted when a release has no changelog entries. When omitted, an empty changelog fails the release.", - "type": [ - "string", - "null" - ] + "type": "string" }, "dogfood": { "description": "Whether self-references are updated (dogfooding) during a release.", - "type": [ - "boolean", - "null" - ] + "type": "boolean" }, "prereleaseTag": { "description": "Prerelease tag applied to prerelease versions. When omitted, prerelease versions are not allowed.", - "type": [ - "string", - "null" - ] + "type": "string" } }, "additionalProperties": false }, "versioning": { "description": "Configuration for version computation.", - "type": [ - "object", - "null" - ], + "type": "object", "properties": { "assemblyVersionPrecision": { "description": "How many version components are carried into the assembly version.", @@ -97,8 +66,7 @@ "major", "minor", "build", - "revision", - null + "revision" ] } }, @@ -106,69 +74,104 @@ }, "dotnet": { "description": "Configuration for invocations of the dotnet CLI.", - "type": [ - "object", - "null" - ], + "type": "object", "properties": { "configuration": { "description": "Default build configuration (e.g. Debug, Release).", - "type": [ - "string", - "null" - ] + "type": "string" }, - "args": { - "description": "Extra arguments forwarded to dotnet, keyed by command name (all, restore, build, test, pack).", - "type": [ - "object", - "null" - ], + "all": { + "description": "Invocation configuration common to all \u0060dotnet\u0060 commands.", + "type": "object", "properties": { - "all": { - "type": [ - "array", - "null" - ], + "args": { + "description": "Extra arguments forwarded to \u0060dotnet\u0060.", + "type": "array", "items": { "type": "string" } }, - "restore": { - "type": [ - "array", - "null" - ], - "items": { + "env": { + "description": "Environment variables forwarded to \u0060dotnet\u0060, keyed by variable name.", + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": false + }, + "restore": { + "description": "Invocation configuration for the \u0060dotnet restore\u0060 command.", + "type": "object", + "properties": { + "args": { + "description": "Extra arguments forwarded to \u0060dotnet\u0060.", + "$ref": "#/properties/dotnet/properties/all/properties/args" }, - "build": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } + "env": { + "description": "Environment variables forwarded to \u0060dotnet\u0060, keyed by variable name.", + "$ref": "#/properties/dotnet/properties/all/properties/env" + } + }, + "additionalProperties": false + }, + "build": { + "description": "Invocation configuration for the \u0060dotnet build\u0060 command.", + "type": "object", + "properties": { + "args": { + "description": "Extra arguments forwarded to \u0060dotnet\u0060.", + "$ref": "#/properties/dotnet/properties/all/properties/args" }, - "test": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } + "env": { + "description": "Environment variables forwarded to \u0060dotnet\u0060, keyed by variable name.", + "$ref": "#/properties/dotnet/properties/all/properties/env" + } + }, + "additionalProperties": false + }, + "test": { + "description": "Invocation configuration for the \u0060dotnet test\u0060 command.", + "type": "object", + "properties": { + "args": { + "description": "Extra arguments forwarded to \u0060dotnet\u0060.", + "$ref": "#/properties/dotnet/properties/all/properties/args" }, - "pack": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } + "env": { + "description": "Environment variables forwarded to \u0060dotnet\u0060, keyed by variable name.", + "$ref": "#/properties/dotnet/properties/all/properties/env" + } + }, + "additionalProperties": false + }, + "pack": { + "description": "Invocation configuration for the \u0060dotnet pack\u0060 command.", + "type": "object", + "properties": { + "args": { + "description": "Extra arguments forwarded to \u0060dotnet\u0060.", + "$ref": "#/properties/dotnet/properties/all/properties/args" + }, + "env": { + "description": "Environment variables forwarded to \u0060dotnet\u0060, keyed by variable name.", + "$ref": "#/properties/dotnet/properties/all/properties/env" + } + }, + "additionalProperties": false + }, + "nugetPush": { + "description": "Invocation configuration for the \u0060dotnet nuget push\u0060 command.", + "type": "object", + "properties": { + "args": { + "description": "Extra arguments forwarded to \u0060dotnet\u0060.", + "$ref": "#/properties/dotnet/properties/all/properties/args" + }, + "env": { + "description": "Environment variables forwarded to \u0060dotnet\u0060, keyed by variable name.", + "$ref": "#/properties/dotnet/properties/all/properties/env" } }, "additionalProperties": false @@ -178,60 +181,36 @@ }, "nuget": { "description": "Configuration for NuGet package publishing.", - "type": [ - "object", - "null" - ], + "type": "object", "properties": { "feeds": { "description": "Push feeds, keyed by channel name (prerelease, release).", - "type": [ - "object", - "null" - ], + "type": "object", "properties": { "prerelease": { - "type": [ - "object", - "null" - ], + "type": "object", "properties": { "source": { "description": "Source URL of the NuGet feed.", - "type": [ - "string", - "null" - ] + "type": "string" }, "apiKeyEnv": { "description": "Name of the environment variable that holds the feed API key.", - "type": [ - "string", - "null" - ] + "type": "string" } }, "additionalProperties": false }, "release": { - "type": [ - "object", - "null" - ], + "type": "object", "properties": { "source": { "description": "Source URL of the NuGet feed.", - "type": [ - "string", - "null" - ] + "type": "string" }, "apiKeyEnv": { "description": "Name of the environment variable that holds the feed API key.", - "type": [ - "string", - "null" - ] + "type": "string" } }, "additionalProperties": false @@ -244,48 +223,30 @@ }, "github": { "description": "Configuration for GitHub integration.", - "type": [ - "object", - "null" - ], + "type": "object", "properties": { "tokenEnv": { "description": "Name of the environment variable that holds the GitHub access token.", - "type": [ - "string", - "null" - ] + "type": "string" } }, "additionalProperties": false }, "git": { "description": "Configuration for Git-related behavior.", - "type": [ - "object", - "null" - ], + "type": "object", "properties": { "identity": { "description": "Git identity used by automated commits.", - "type": [ - "object", - "null" - ], + "type": "object", "properties": { "name": { "description": "Display name used as the Git author/committer.", - "type": [ - "string", - "null" - ] + "type": "string" }, "email": { "description": "Email address used as the Git author/committer.", - "type": [ - "string", - "null" - ] + "type": "string" } }, "additionalProperties": false diff --git a/src/Buildvana.Core.Abstractions/BuildDiagnostic.cs b/src/Buildvana.Core.Abstractions/BuildDiagnostic.cs new file mode 100644 index 0000000..9d77069 --- /dev/null +++ b/src/Buildvana.Core.Abstractions/BuildDiagnostic.cs @@ -0,0 +1,40 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Globalization; + +namespace Buildvana.Core; + +/// +/// A single structured problem a build step can report: a severity, a code, a message, and an optional source +/// location. Carried by so a host can render each problem on its own line. +/// +/// The severity of the problem. +/// A diagnostic code (e.g. BV1101) that documents the problem. +/// A human-readable description of the problem. +/// The file the problem was found in, or if it has no location. +/// The 1-based line of the problem, or 0 if unknown. +/// The 1-based column of the problem, or 0 if unknown. +public sealed record BuildDiagnostic( + BuildDiagnosticSeverity Severity, + string Code, + string Message, + string? File = null, + int Line = 0, + int Column = 0) +{ + /// + /// Returns the diagnostic in the canonical compiler/MSBuild format + /// (file(line,column): severity code: message), which terminals such as VS Code render as a + /// clickable link. The location prefix is omitted when is . + /// + /// The formatted diagnostic line. + public override string ToString() + { + var severity = Severity is BuildDiagnosticSeverity.Error ? "error" : "warning"; + var location = File is null ? string.Empty + : Line > 0 ? string.Create(CultureInfo.InvariantCulture, $"{File}({Line},{Column}): ") + : $"{File}: "; + return $"{location}{severity} {Code}: {Message}"; + } +} diff --git a/src/Buildvana.Core.Abstractions/BuildDiagnosticSeverity.cs b/src/Buildvana.Core.Abstractions/BuildDiagnosticSeverity.cs new file mode 100644 index 0000000..d8ab3b4 --- /dev/null +++ b/src/Buildvana.Core.Abstractions/BuildDiagnosticSeverity.cs @@ -0,0 +1,16 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core; + +/// +/// The severity of a . +/// +public enum BuildDiagnosticSeverity +{ + /// A problem that does not by itself prevent the build from succeeding. + Warning, + + /// A problem that prevents the build from succeeding. + Error, +} diff --git a/src/Buildvana.Core.Abstractions/BuildFailedException.cs b/src/Buildvana.Core.Abstractions/BuildFailedException.cs index d910612..d307967 100644 --- a/src/Buildvana.Core.Abstractions/BuildFailedException.cs +++ b/src/Buildvana.Core.Abstractions/BuildFailedException.cs @@ -2,7 +2,9 @@ // See the LICENSE file in the project root for full license information. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Runtime.CompilerServices; namespace Buildvana.Core; @@ -20,12 +22,14 @@ public sealed class BuildFailedException : Exception /// public const int DefaultExitCode = 1; + private const string DefaultMessage = "The build failed."; + /// /// Initializes a new instance of the class /// with and a generic message. /// public BuildFailedException() - : this(DefaultExitCode, "The build failed.") + : this(DefaultExitCode, DefaultMessage) { } @@ -61,6 +65,7 @@ public BuildFailedException(int exitCode, string message) : base(message) { ExitCode = exitCode; + Diagnostics = []; } /// @@ -75,6 +80,32 @@ public BuildFailedException(int exitCode, string message, Exception innerExcepti : base(message, innerException) { ExitCode = exitCode; + Diagnostics = []; + } + + /// + /// Initializes a new instance of the class with + /// and the specified and . + /// + /// A message explaining the reason for failing the build. + /// The diagnostics describing why the build failed. + public BuildFailedException(string message, IReadOnlyList diagnostics) + : this(DefaultExitCode, message, diagnostics) + { + } + + /// + /// Initializes a new instance of the class with the specified + /// , , and . + /// + /// The exit code that should be surfaced to the host's runtime, where applicable. + /// A message explaining the reason for failing the build. + /// The diagnostics describing why the build failed. + public BuildFailedException(int exitCode, string message, IReadOnlyList diagnostics) + : base(message) + { + ExitCode = exitCode; + Diagnostics = diagnostics; } /// @@ -82,6 +113,12 @@ public BuildFailedException(int exitCode, string message, Exception innerExcepti /// public int ExitCode { get; } + /// + /// Gets the structured diagnostics describing the failure, or an empty list when the failure was reported as + /// a plain message. + /// + public IReadOnlyList Diagnostics { get; } + /// /// Throws a with /// and the specified if is . diff --git a/src/Buildvana.Core.Configuration/Buildvana.Core.Configuration.csproj b/src/Buildvana.Core.Configuration/Buildvana.Core.Configuration.csproj index 687751b..0771c42 100644 --- a/src/Buildvana.Core.Configuration/Buildvana.Core.Configuration.csproj +++ b/src/Buildvana.Core.Configuration/Buildvana.Core.Configuration.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Buildvana.Core.Configuration/BuildvanaConfig.cs b/src/Buildvana.Core.Configuration/BuildvanaConfig.cs index 8886ed0..e1b2e62 100644 --- a/src/Buildvana.Core.Configuration/BuildvanaConfig.cs +++ b/src/Buildvana.Core.Configuration/BuildvanaConfig.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.Text.Json.Serialization; +using Buildvana.Core.JsonSchema; using JetBrains.Annotations; namespace Buildvana.Core.Configuration; @@ -13,6 +14,7 @@ namespace Buildvana.Core.Configuration; /// /// Every member is optional; an absent configuration file is equivalent to an instance with all members unset. /// +[JsonSchemaTitle("Buildvana configuration")] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public sealed record BuildvanaConfig { diff --git a/src/Buildvana.Core.Configuration/BuildvanaConfigLoader.cs b/src/Buildvana.Core.Configuration/BuildvanaConfigLoader.cs index 2950b29..18e2f73 100644 --- a/src/Buildvana.Core.Configuration/BuildvanaConfigLoader.cs +++ b/src/Buildvana.Core.Configuration/BuildvanaConfigLoader.cs @@ -1,10 +1,13 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; +using Buildvana.Core.JsonSchema; using CommunityToolkit.Diagnostics; namespace Buildvana.Core.Configuration; @@ -17,14 +20,21 @@ public static class BuildvanaConfigLoader private const string JsonFileName = "buildvana.json"; private const string JsoncFileName = "buildvana.jsonc"; + private static readonly JsonDocumentOptions DocumentOptions = new() + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + /// /// Loads the configuration file found in . /// /// The home directory to search for a configuration file. /// The parsed configuration, or an empty if no file is present. /// - /// Both buildvana.json and buildvana.jsonc are present, the file cannot be read, - /// is not valid JSON, contains an unknown member, or contains an unknown dictionary key. + /// Both buildvana.json and buildvana.jsonc are present, or the file cannot be read. + /// The file is present but not valid JSON, or does not conform to the schema; in that case + /// lists each problem with its source location. /// public static BuildvanaConfig Load(string homeDirectory) { @@ -47,79 +57,103 @@ public static BuildvanaConfig Load(string homeDirectory) return new BuildvanaConfig(); } - var config = Parse(path); - Validate(config, path); - return config; + var json = StripBom(ReadAllBytes(path)); + var node = Parse(json, path); + Validate(node, json, path); + + // Validation guarantees a non-null object at the root, so deserialization cannot return null here. + return node!.Deserialize(BuildvanaConfigSerialization.Options) ?? new BuildvanaConfig(); } - private static BuildvanaConfig Parse(string path) + private static byte[] ReadAllBytes(string path) { - string text; try { - text = File.ReadAllText(path); + return File.ReadAllBytes(path); } catch (IOException e) { throw new BuildFailedException($"Could not read from {path}: {e.Message}", e); } + } + // Removes a leading UTF-8 byte order mark, if present, so the reader sees only JSON and positions start at 1. + private static byte[] StripBom(byte[] bytes) + => bytes is [0xEF, 0xBB, 0xBF, .. var rest] ? rest : bytes; + + private static JsonNode? Parse(byte[] json, string path) + { try { - var config = JsonSerializer.Deserialize(text, BuildvanaConfigSerialization.Options); - return config ?? throw new BuildFailedException($"{path} was parsed as JSON null."); + return JsonNode.Parse(json, documentOptions: DocumentOptions); } catch (JsonException e) { - throw new BuildFailedException($"{path} is not a valid Buildvana configuration file: {e.Message}", e); + var (line, column) = ResolveParseErrorPosition(json, e); + throw new BuildFailedException( + $"Invalid JSON in {path}", + [new BuildDiagnostic(BuildDiagnosticSeverity.Error, DiagnosticCodes.InvalidJson, e.Message, path, line, column)]); } } - private static void Validate(BuildvanaConfig config, string path) + // Converts a JsonException's 0-based line and byte position into a 1-based line and *character* column, so a + // parse-error location lines up with the character-based columns JsonSchemaValidator reports for schema errors. + private static (int Line, int Column) ResolveParseErrorPosition(byte[] json, JsonException e) { - ValidateDictionaryKeys(config.DotNet?.Args?.Keys, DotNetConfig.AllowedArgsKeys, "dotnet.args", path); - ValidateDictionaryKeys(config.NuGet?.Feeds?.Keys, NuGetConfig.AllowedFeedKeys, "nuget.feeds", path); - ValidateNoNullItems(config.Release?.Branches, "release.branches", path); - ValidateNoNullItems(config.Release?.GenerateDocsFrom, "release.generateDocsFrom", path); + var line = (int)(e.LineNumber ?? 0); + var byteInLine = (int)(e.BytePositionInLine ?? 0); - if (config.DotNet?.Args is { } args) + // Find the byte offset at which the error's line starts (just past the line-th newline). + var lineStart = 0; + var newlines = 0; + for (var i = 0; newlines < line && i < json.Length; i++) { - foreach (var (key, value) in args) + if (json[i] == (byte)'\n') { - ValidateNoNullItems(value, $"dotnet.args.{key}", path); + newlines++; + lineStart = i + 1; } } + + // Count characters (not bytes) from the line start to the offending position, clamping in the unlikely + // event the reported position lands past the end of the buffer. + var available = json.Length - lineStart; + var count = byteInLine < available ? byteInLine : available; + var column = Encoding.UTF8.GetCharCount(json, lineStart, count) + 1; + return (line + 1, column); } - private static void ValidateDictionaryKeys(IEnumerable? keys, string[] allowed, string section, string path) + private static void Validate(JsonNode? node, byte[] json, string path) { - if (keys is null) + var errors = JsonSchemaValidator.Validate(node, json, BuildvanaConfigSerialization.Options); + if (errors.Count == 0) { return; } - foreach (var key in keys) + var diagnostics = new List(errors.Count); + foreach (var error in errors) { - BuildFailedException.ThrowIfNot( - Array.IndexOf(allowed, key) >= 0, - $"Unknown key '{key}' in {section} ({path}). Allowed keys: {string.Join(", ", allowed)}."); + diagnostics.Add(new BuildDiagnostic( + BuildDiagnosticSeverity.Error, + CodeFor(error.Kind), + error.Message, + path, + error.Line, + error.Column)); } - } - private static void ValidateNoNullItems(IEnumerable? items, string section, string path) - { - if (items is null) - { - return; - } + throw new BuildFailedException($"Invalid configuration file {path}", diagnostics); + } - var index = 0; - foreach (var item in items) + private static string CodeFor(JsonSchemaErrorKind kind) + => kind switch { - BuildFailedException.ThrowIf( - item is null, - $"Null item at index {index} in {section} ({path}). All items must be non-null strings."); - index++; - } - } + JsonSchemaErrorKind.TypeMismatch => DiagnosticCodes.TypeMismatch, + JsonSchemaErrorKind.DisallowedValue => DiagnosticCodes.DisallowedValue, + JsonSchemaErrorKind.UnknownProperty => DiagnosticCodes.UnknownProperty, + JsonSchemaErrorKind.MissingProperty => DiagnosticCodes.MissingProperty, + JsonSchemaErrorKind.ValueNotAllowed => DiagnosticCodes.ValueNotAllowed, + _ => throw new UnreachableException(), + }; } diff --git a/src/Buildvana.Core.Configuration/BuildvanaConfigSchema.cs b/src/Buildvana.Core.Configuration/BuildvanaConfigSchema.cs index d07aed9..1789a36 100644 --- a/src/Buildvana.Core.Configuration/BuildvanaConfigSchema.cs +++ b/src/Buildvana.Core.Configuration/BuildvanaConfigSchema.cs @@ -1,11 +1,8 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.ComponentModel; -using System.Linq; using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Schema; +using Buildvana.Core.JsonSchema; namespace Buildvana.Core.Configuration; @@ -19,37 +16,14 @@ public static class BuildvanaConfigSchema /// /// The schema as an indented JSON string, using LF line endings and a trailing newline. /// - /// [Description] attributes on the model are surfaced as description keywords, so editors - /// can show them as hover documentation. + /// The schema is shaped entirely from attributes on the model — [Description], + /// [JsonNullable], [JsonAllowedKeys], and [JsonSchemaTitle] — by + /// . The same drive both + /// generation and deserialization, so the schema always describes what the loader accepts. /// public static string Generate() { - var exporterOptions = new JsonSchemaExporterOptions - { - TransformSchemaNode = TransformSchemaNode, - }; - - var schema = BuildvanaConfigSerialization.Options.GetJsonSchemaAsNode(typeof(BuildvanaConfig), exporterOptions); - - // Declare the JSON Schema dialect and a title so editors recognize and label the document. - if (schema is JsonObject root) - { - root.Insert(0, "$schema", "https://json-schema.org/draft/2020-12/schema"); - root.Insert(1, "title", "Buildvana configuration"); - - // The exporter marks the root as nullable, but the loader rejects a JSON null document. - root["type"] = "object"; - - // The exporter leaves these dictionaries open-ended, but the loader only accepts a fixed set of keys; - // pin the schema to the same keys so editors reject what the loader would reject. - ConstrainKeys(root, "dotnet", "args", DotNetConfig.AllowedArgsKeys); - ConstrainKeys(root, "nuget", "feeds", NuGetConfig.AllowedFeedKeys); - - // Collection element types in the model are non-nullable, but the exporter still emits nullable - // item schemas; tighten them so the schema matches the model (and what the loader accepts). - StripNullFromArrayItems(root); - } - + var schema = JsonSchemaGenerator.Generate(BuildvanaConfigSerialization.Options); var json = schema.ToJsonString(new JsonSerializerOptions { WriteIndented = true, @@ -60,109 +34,4 @@ public static string Generate() // Normalize to LF + a single trailing newline, independent of the host platform. return json.ReplaceLineEndings("\n") + "\n"; } - - // Surfaces a [Description] (on the property, or on the type) as a schema "description" keyword. - // Adapted from the System.Text.Json schema-exporter documentation sample. - private static JsonNode TransformSchemaNode(JsonSchemaExporterContext context, JsonNode schema) - { - var attributeProvider = context.PropertyInfo is not null - ? context.PropertyInfo.AttributeProvider - : context.TypeInfo.Type; - - var description = attributeProvider? - .GetCustomAttributes(inherit: true) - .OfType() - .FirstOrDefault()? - .Description; - - if (description is null) - { - return schema; - } - - if (schema is not JsonObject schemaObject) - { - // A Boolean schema (true/false) cannot carry a description, so wrap it in an object first. - var valueKind = schema.GetValueKind(); - schemaObject = new JsonObject(); - if (valueKind is JsonValueKind.False) - { - schemaObject.Add("not", true); - } - - schema = schemaObject; - } - - schemaObject.Insert(0, "description", description); - return schema; - } - - // Replaces the open-ended additionalProperties of a dictionary section (e.g. dotnet.args, nuget.feeds) with an - // explicit set of allowed keys, each mapped to the original value schema, plus additionalProperties: false. - private static void ConstrainKeys(JsonObject root, string topProperty, string dictProperty, string[] allowedKeys) - { - var section = (JsonObject)root["properties"]![topProperty]!["properties"]![dictProperty]!; - var valueSchema = section["additionalProperties"]!; - _ = section.Remove("additionalProperties"); - - var properties = new JsonObject(); - foreach (var key in allowedKeys) - { - properties.Add(key, valueSchema.DeepClone()); - } - - section["properties"] = properties; - section["additionalProperties"] = false; - } - - // Walks the schema tree and removes "null" from the type of every array's item schema, since no collection - // in the model accepts null elements. - private static void StripNullFromArrayItems(JsonNode? node) - { - switch (node) - { - case JsonObject obj: - if (obj["items"] is JsonObject items) - { - RemoveNullFromType(items); - } - - foreach (var property in obj) - { - StripNullFromArrayItems(property.Value); - } - - break; - case JsonArray array: - foreach (var element in array) - { - StripNullFromArrayItems(element); - } - - break; - } - } - - // Removes "null" from a schema's "type" keyword when it is expressed as an array, collapsing a single - // remaining type to a scalar for cleaner output. No-op when "type" is already a scalar. - private static void RemoveNullFromType(JsonObject schema) - { - if (schema["type"] is not JsonArray typeArray) - { - return; - } - - for (var i = typeArray.Count - 1; i >= 0; i--) - { - if (typeArray[i]?.GetValue() == "null") - { - typeArray.RemoveAt(i); - } - } - - if (typeArray.Count == 1) - { - schema["type"] = typeArray[0]!.GetValue(); - } - } } diff --git a/src/Buildvana.Core.Configuration/DiagnosticCodes.cs b/src/Buildvana.Core.Configuration/DiagnosticCodes.cs new file mode 100644 index 0000000..c9243e8 --- /dev/null +++ b/src/Buildvana.Core.Configuration/DiagnosticCodes.cs @@ -0,0 +1,16 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.Configuration; + +// Diagnostic codes reported while loading a configuration file. Documented in docs/ToolDiagnostics.md +// (JSON schema validation, BV1100-BV1199). +internal static class DiagnosticCodes +{ + public const string InvalidJson = "BV1100"; + public const string TypeMismatch = "BV1101"; + public const string DisallowedValue = "BV1102"; + public const string UnknownProperty = "BV1103"; + public const string MissingProperty = "BV1104"; + public const string ValueNotAllowed = "BV1105"; +} diff --git a/src/Buildvana.Core.Configuration/DotNetConfig.cs b/src/Buildvana.Core.Configuration/DotNetConfig.cs index 75028e7..a5dc5ef 100644 --- a/src/Buildvana.Core.Configuration/DotNetConfig.cs +++ b/src/Buildvana.Core.Configuration/DotNetConfig.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Collections.Generic; using System.ComponentModel; using JetBrains.Annotations; @@ -13,15 +12,45 @@ namespace Buildvana.Core.Configuration; [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public sealed record DotNetConfig { - // Allowed keys for Args, in schema-output order. Shared between loader validation and schema generation - // so the two cannot diverge. - internal static readonly string[] AllowedArgsKeys = ["all", "restore", "build", "test", "pack"]; - - /// Gets the default build configuration passed to dotnet. + /// + /// Gets the default build configuration passed to dotnet. + /// [Description("Default build configuration (e.g. Debug, Release).")] public string? Configuration { get; init; } - /// Gets extra arguments forwarded to dotnet, keyed by command name (all, restore, build, test, pack). - [Description("Extra arguments forwarded to dotnet, keyed by command name (all, restore, build, test, pack).")] - public IReadOnlyDictionary>? Args { get; init; } + /// + /// Gets invocation configuration common to all dotnet commands. + /// + [Description("Invocation configuration common to all `dotnet` commands.")] + public DotNetInvocationConfig? All { get; init; } + + /// + /// Gets invocation configuration for the dotnet restore command. + /// + [Description("Invocation configuration for the `dotnet restore` command.")] + public DotNetInvocationConfig? Restore { get; init; } + + /// + /// Gets invocation configuration for the dotnet build command. + /// + [Description("Invocation configuration for the `dotnet build` command.")] + public DotNetInvocationConfig? Build { get; init; } + + /// + /// Gets invocation configuration for the dotnet test command. + /// + [Description("Invocation configuration for the `dotnet test` command.")] + public DotNetInvocationConfig? Test { get; init; } + + /// + /// Gets invocation configuration for the dotnet pack command. + /// + [Description("Invocation configuration for the `dotnet pack` command.")] + public DotNetInvocationConfig? Pack { get; init; } + + /// + /// Gets invocation configuration for the dotnet nuget push command. + /// + [Description("Invocation configuration for the `dotnet nuget push` command.")] + public DotNetInvocationConfig? NugetPush { get; init; } } diff --git a/src/Buildvana.Core.Configuration/DotNetInvocationConfig.cs b/src/Buildvana.Core.Configuration/DotNetInvocationConfig.cs new file mode 100644 index 0000000..ea31429 --- /dev/null +++ b/src/Buildvana.Core.Configuration/DotNetInvocationConfig.cs @@ -0,0 +1,22 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.ComponentModel; + +namespace Buildvana.Core.Configuration; + +public sealed record DotNetInvocationConfig +{ + /// + /// Gets extra arguments forwarded to dotnet. + /// + [Description("Extra arguments forwarded to `dotnet`.")] + public IReadOnlyList? Args { get; init; } + + /// + /// Gets environment variables forwarded to dotnet, keyed by variable name. + /// + [Description("Environment variables forwarded to `dotnet`, keyed by variable name.")] + public IReadOnlyDictionary? Env { get; init; } +} diff --git a/src/Buildvana.Core.Configuration/NuGetConfig.cs b/src/Buildvana.Core.Configuration/NuGetConfig.cs index 81d56f2..7f8a285 100644 --- a/src/Buildvana.Core.Configuration/NuGetConfig.cs +++ b/src/Buildvana.Core.Configuration/NuGetConfig.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel; +using Buildvana.Core.JsonSchema; using JetBrains.Annotations; namespace Buildvana.Core.Configuration; @@ -13,11 +14,8 @@ namespace Buildvana.Core.Configuration; [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public sealed record NuGetConfig { - // Allowed keys for Feeds, in schema-output order. Shared between loader validation and schema generation - // so the two cannot diverge. - internal static readonly string[] AllowedFeedKeys = ["prerelease", "release"]; - /// Gets the push feeds, keyed by channel name (prerelease, release). [Description("Push feeds, keyed by channel name (prerelease, release).")] + [JsonAllowedKeys("prerelease, release")] public IReadOnlyDictionary? Feeds { get; init; } } diff --git a/src/Buildvana.Core.JsonSchema/Buildvana.Core.JsonSchema.csproj b/src/Buildvana.Core.JsonSchema/Buildvana.Core.JsonSchema.csproj new file mode 100644 index 0000000..55565ce --- /dev/null +++ b/src/Buildvana.Core.JsonSchema/Buildvana.Core.JsonSchema.csproj @@ -0,0 +1,9 @@ + + + + Buildvana JSON schema + Generic JSON schema generation and validation, independent of any specific model. Reports validation failures via JsonSchemaValidationException. + $(StandardTfm) + + + diff --git a/src/Buildvana.Core.JsonSchema/JsonAllowedKeysAttribute.cs b/src/Buildvana.Core.JsonSchema/JsonAllowedKeysAttribute.cs new file mode 100644 index 0000000..8711efc --- /dev/null +++ b/src/Buildvana.Core.JsonSchema/JsonAllowedKeysAttribute.cs @@ -0,0 +1,31 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; + +namespace Buildvana.Core.JsonSchema; + +/// +/// Constrains a dictionary-valued property to a fixed set of keys, so the generated schema rejects any other +/// key (and an editor flags it). +/// +/// +/// A comma-separated list of the keys the dictionary is allowed to contain, in schema-output order. Surrounding +/// whitespace is trimmed. A single string argument (rather than a params array) keeps the attribute +/// CLS-compliant. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class JsonAllowedKeysAttribute(string keys) : Attribute +{ + /// + /// Gets the comma-separated keys exactly as specified on the attribute. + /// + public string Keys { get; } = keys; + + /// + /// Gets the individual keys the dictionary is allowed to contain, trimmed and in order. + /// + public IReadOnlyList AllowedKeys { get; } = + keys.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); +} diff --git a/src/Buildvana.Core.JsonSchema/JsonNullableAttribute.cs b/src/Buildvana.Core.JsonSchema/JsonNullableAttribute.cs new file mode 100644 index 0000000..ca79375 --- /dev/null +++ b/src/Buildvana.Core.JsonSchema/JsonNullableAttribute.cs @@ -0,0 +1,19 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; + +namespace Buildvana.Core.JsonSchema; + +/// +/// Marks a property whose JSON is a meaningful value, so the schema generator keeps +/// null among the property's allowed types instead of stripping it. +/// +/// +/// Without this attribute, a nullable property is treated as merely optional: an absent key expresses +/// "unset", and an explicit null is disallowed by the generated schema. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class JsonNullableAttribute : Attribute +{ +} diff --git a/src/Buildvana.Core.JsonSchema/JsonSchemaErrorKind.cs b/src/Buildvana.Core.JsonSchema/JsonSchemaErrorKind.cs new file mode 100644 index 0000000..57db23b --- /dev/null +++ b/src/Buildvana.Core.JsonSchema/JsonSchemaErrorKind.cs @@ -0,0 +1,26 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.JsonSchema; + +/// +/// Identifies the kind of a , so callers can map a failure to their own +/// diagnostics without parsing its message. +/// +public enum JsonSchemaErrorKind +{ + /// A value did not match the type the schema requires. + TypeMismatch, + + /// A value was not among those allowed by an enum constraint. + DisallowedValue, + + /// An object contained a property the schema does not allow. + UnknownProperty, + + /// An object was missing a property the schema requires. + MissingProperty, + + /// A value appeared where the schema allows none (a false schema). + ValueNotAllowed, +} diff --git a/src/Buildvana.Core.JsonSchema/JsonSchemaGenerator.cs b/src/Buildvana.Core.JsonSchema/JsonSchemaGenerator.cs new file mode 100644 index 0000000..f75a36e --- /dev/null +++ b/src/Buildvana.Core.JsonSchema/JsonSchemaGenerator.cs @@ -0,0 +1,194 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Schema; + +namespace Buildvana.Core.JsonSchema; + +/// +/// Generates a JSON Schema (draft 2020-12) document from a .NET type, shaping the output from attributes the +/// model carries: , , +/// , and . +/// +/// +/// The same should drive both generation and deserialization, so the +/// schema always describes exactly what the deserializer accepts. +/// +public static class JsonSchemaGenerator +{ + private const string Dialect = "https://json-schema.org/draft/2020-12/schema"; + + /// + /// Generates the JSON schema describing . + /// + /// The type to describe. + /// The serializer options that govern property naming, enum formatting, and so on. + /// The schema as a . + public static JsonNode Generate(JsonSerializerOptions options) => Generate(typeof(T), options); + + /// + /// Generates the JSON schema describing . + /// + /// The type to describe. + /// The serializer options that govern property naming, enum formatting, and so on. + /// The schema as a . + public static JsonNode Generate(Type type, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(options); + + var exporterOptions = new JsonSchemaExporterOptions { TransformSchemaNode = TransformSchemaNode }; + var schema = options.GetJsonSchemaAsNode(type, exporterOptions); + + // Declare the dialect and (optionally) a title so editors recognize and label the document. + if (schema is JsonObject root) + { + root.Insert(0, "$schema", Dialect); + if (type.GetCustomAttribute() is { Title: var title }) + { + root.Insert(1, "title", title); + } + } + + return schema; + } + + private static JsonNode TransformSchemaNode(JsonSchemaExporterContext context, JsonNode schema) + { + var attributeProvider = context.PropertyInfo is not null + ? context.PropertyInfo.AttributeProvider + : context.TypeInfo.Type; + + schema = ApplyDescription(attributeProvider, schema); + + // Strip "null" wherever the exporter emits it, except where a property opts in with [JsonNullable]: + // a nullable property otherwise means "unset" (an absent key), which needs no explicit null. + var keepNull = attributeProvider?.IsDefined(typeof(JsonNullableAttribute), inherit: true) ?? false; + if (!keepNull && schema is JsonObject nullableSchema) + { + RemoveNullFromType(nullableSchema); + RemoveNullFromEnum(nullableSchema); + } + + // Close a dictionary to a fixed set of keys when the property carries [JsonAllowedKeys]. The exporter + // runs this transform bottom-up, so the value schema cloned below is already null-stripped. + if (TryGetAllowedKeys(attributeProvider, out var keys) && schema is JsonObject dictionarySchema) + { + ConstrainKeys(dictionarySchema, keys); + } + + return schema; + } + + // Surfaces a [Description] (on the property, or on the type) as a schema "description" keyword. + // Adapted from the System.Text.Json schema-exporter documentation sample. + private static JsonNode ApplyDescription(ICustomAttributeProvider? attributeProvider, JsonNode schema) + { + var description = attributeProvider? + .GetCustomAttributes(inherit: true) + .OfType() + .FirstOrDefault()? + .Description; + + if (description is null) + { + return schema; + } + + if (schema is not JsonObject schemaObject) + { + // A Boolean schema (true/false) cannot carry a description, so wrap it in an object first. + var valueKind = schema.GetValueKind(); + schemaObject = new JsonObject(); + if (valueKind is JsonValueKind.False) + { + schemaObject.Add("not", true); + } + + schema = schemaObject; + } + + schemaObject.Insert(0, "description", description); + return schema; + } + + private static bool TryGetAllowedKeys(ICustomAttributeProvider? attributeProvider, out IReadOnlyList keys) + { + var attribute = attributeProvider? + .GetCustomAttributes(inherit: true) + .OfType() + .FirstOrDefault(); + + keys = attribute?.AllowedKeys ?? []; + return attribute is not null; + } + + // Replaces a dictionary's open-ended additionalProperties value schema with an explicit set of allowed keys, + // each mapped to a clone of that value schema, plus additionalProperties: false. + private static void ConstrainKeys(JsonObject schema, IReadOnlyList keys) + { + if (schema["additionalProperties"] is not { } valueSchema) + { + return; + } + + _ = schema.Remove("additionalProperties"); + + var properties = new JsonObject(); + foreach (var key in keys) + { + properties.Add(key, valueSchema.DeepClone()); + } + + schema["properties"] = properties; + schema["additionalProperties"] = false; + } + + // Removes "null" from a schema's "type" keyword when it is expressed as an array, collapsing a single + // remaining type to a scalar for cleaner output. No-op when "type" is absent or already a scalar. + private static void RemoveNullFromType(JsonObject schema) + { + if (schema["type"] is not JsonArray typeArray) + { + return; + } + + for (var i = typeArray.Count - 1; i >= 0; i--) + { + if (typeArray[i]?.GetValue() == "null") + { + typeArray.RemoveAt(i); + } + } + + if (typeArray.Count == 1) + { + schema["type"] = typeArray[0]!.GetValue(); + } + } + + // Removes the JSON null member that the exporter appends to a nullable enum's "enum" list. No-op when the + // schema has no "enum" keyword. + private static void RemoveNullFromEnum(JsonObject schema) + { + if (schema["enum"] is not JsonArray enumArray) + { + return; + } + + for (var i = enumArray.Count - 1; i >= 0; i--) + { + if (enumArray[i] is null) + { + enumArray.RemoveAt(i); + } + } + } +} diff --git a/src/Buildvana.Core.JsonSchema/JsonSchemaTitleAttribute.cs b/src/Buildvana.Core.JsonSchema/JsonSchemaTitleAttribute.cs new file mode 100644 index 0000000..b93beb8 --- /dev/null +++ b/src/Buildvana.Core.JsonSchema/JsonSchemaTitleAttribute.cs @@ -0,0 +1,19 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; + +namespace Buildvana.Core.JsonSchema; + +/// +/// Specifies the title keyword for the schema generated from the annotated type. +/// +/// The schema title. +[AttributeUsage(AttributeTargets.Class)] +public sealed class JsonSchemaTitleAttribute(string title) : Attribute +{ + /// + /// Gets the schema title. + /// + public string Title { get; } = title; +} diff --git a/src/Buildvana.Core.JsonSchema/JsonSchemaValidationError.cs b/src/Buildvana.Core.JsonSchema/JsonSchemaValidationError.cs new file mode 100644 index 0000000..e1bb478 --- /dev/null +++ b/src/Buildvana.Core.JsonSchema/JsonSchemaValidationError.cs @@ -0,0 +1,36 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.JsonSchema; + +/// +/// Describes a single JSON Schema validation failure. +/// +/// The kind of failure, for callers that map errors to their own diagnostics. +/// +/// An RFC 6901 JSON Pointer locating the offending value, or an empty string for the document root. This is the +/// stable, unambiguous key used to look the value up in a . +/// +/// +/// A human-friendly path to the offending value, or an empty string for the document root. Built during +/// validation, so object members render as .name and array elements as [index], with a numeric +/// object key never mistaken for an index. Intended for display only; use as a key. +/// +/// A human-readable description of the failure. +/// The 1-based source line of the offending value, or 0 when it has not been resolved. +/// The 1-based source column of the offending value, or 0 when it has not been resolved. +public sealed record JsonSchemaValidationError( + JsonSchemaErrorKind Kind, + string JsonPointer, + string DisplayPath, + string Message, + int Line = 0, + int Column = 0) +{ + /// + /// Returns a string combining the display path and the message. + /// + /// The message alone for a root-level error, or "{DisplayPath}: {Message}" otherwise. + public override string ToString() + => DisplayPath.Length == 0 ? Message : $"{DisplayPath}: {Message}"; +} diff --git a/src/Buildvana.Core.JsonSchema/JsonSchemaValidationException.cs b/src/Buildvana.Core.JsonSchema/JsonSchemaValidationException.cs new file mode 100644 index 0000000..beb7f96 --- /dev/null +++ b/src/Buildvana.Core.JsonSchema/JsonSchemaValidationException.cs @@ -0,0 +1,71 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Buildvana.Core.JsonSchema; + +/// +/// The exception thrown when a JSON value fails validation against a schema. +/// +public sealed class JsonSchemaValidationException : Exception +{ + /// + /// Initializes a new instance of the class with no errors. + /// + public JsonSchemaValidationException() + : this([]) + { + } + + /// + /// Initializes a new instance of the class with the specified message. + /// + /// A message describing the validation failure. + public JsonSchemaValidationException(string message) + : base(message) + { + Errors = []; + } + + /// + /// Initializes a new instance of the class with the specified + /// message and inner exception. + /// + /// A message describing the validation failure. + /// The exception that caused the current exception. + public JsonSchemaValidationException(string message, Exception innerException) + : base(message, innerException) + { + Errors = []; + } + + /// + /// Initializes a new instance of the class with the specified errors. + /// + /// The validation errors that caused the failure. + public JsonSchemaValidationException(IReadOnlyList errors) + : base(FormatMessage(errors)) + { + Errors = errors; + } + + /// + /// Gets the validation errors that caused the failure. + /// + public IReadOnlyList Errors { get; } + + private static string FormatMessage(IReadOnlyList errors) + { + ArgumentNullException.ThrowIfNull(errors); + return errors.Count switch + { + 0 => "JSON schema validation failed.", + 1 => $"JSON schema validation failed: {errors[0]}", + _ => "JSON schema validation failed:" + Environment.NewLine + + string.Join(Environment.NewLine, errors.Select(static error => $" - {error}")), + }; + } +} diff --git a/src/Buildvana.Core.JsonSchema/JsonSchemaValidator.cs b/src/Buildvana.Core.JsonSchema/JsonSchemaValidator.cs new file mode 100644 index 0000000..acd6607 --- /dev/null +++ b/src/Buildvana.Core.JsonSchema/JsonSchemaValidator.cs @@ -0,0 +1,386 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Buildvana.Core.JsonSchema; + +/// +/// Validates a against the subset of JSON Schema (draft 2020-12) keywords that +/// JsonSchemaGenerator emits: type, enum, properties, required, +/// additionalProperties, and items. Meta keywords such as $schema, title, and +/// description are ignored. +/// +public static class JsonSchemaValidator +{ + /// + /// Generates the schema for , validates against it, and + /// resolves each error's source position from — so a caller supplies only the + /// model type, not a schema or a source map. + /// + /// The type whose schema must conform to. + /// The JSON value to validate. represents a JSON null. + /// The UTF-8 bytes that was parsed from. + /// The serializer options that drive schema generation. + /// + /// The validation errors found, each carrying its 1-based line and column; an empty list when valid. + /// + /// + /// The schema is generated on every call. To validate many documents against one schema, call + /// once and pass the result to + /// . + /// + public static IReadOnlyList Validate( + JsonNode? instance, + ReadOnlySpan utf8Json, + JsonSerializerOptions options) + => Validate(instance, JsonSchemaGenerator.Generate(options), utf8Json); + + /// + /// Validates against and returns any failures. + /// + /// The JSON value to validate. represents a JSON null. + /// The schema to validate against. + /// The validation errors found, or an empty list when is valid. + /// + /// is malformed: it contains an unresolvable or circular $ref. + /// + public static IReadOnlyList Validate(JsonNode? instance, JsonNode schema) + { + ArgumentNullException.ThrowIfNull(schema); + + var errors = new List(); + ValidateNode(instance, schema, string.Empty, string.Empty, schema, [], errors); + return errors; + } + + /// + /// Validates against and resolves each error's source + /// position from , so callers do not each have to build a source map and translate + /// pointers to positions. + /// + /// The JSON value to validate. represents a JSON null. + /// The schema to validate against. + /// The UTF-8 bytes that was parsed from. + /// + /// The validation errors found, each carrying its 1-based and + /// ; an empty list when is valid. + /// + /// + /// is malformed: it contains an unresolvable or circular $ref. + /// + public static IReadOnlyList Validate( + JsonNode? instance, + JsonNode schema, + ReadOnlySpan utf8Json) + { + var errors = Validate(instance, schema); + if (errors.Count == 0) + { + return errors; + } + + var sourceMap = JsonSourceMap.Build(utf8Json); + var located = new List(errors.Count); + foreach (var error in errors) + { + _ = sourceMap.TryGetPosition(error.JsonPointer, out var line, out var column); + located.Add(error with { Line = line, Column = column }); + } + + return located; + } + + /// + /// Validates against and throws if it is invalid. + /// + /// The JSON value to validate. represents a JSON null. + /// The schema to validate against. + /// is invalid. + public static void ValidateAndThrow(JsonNode? instance, JsonNode schema) + { + var errors = Validate(instance, schema); + if (errors.Count > 0) + { + throw new JsonSchemaValidationException(errors); + } + } + + private static void ValidateNode( + JsonNode? instance, + JsonNode schemaNode, + string pointer, + string displayPath, + JsonNode root, + HashSet visitedRefs, + List errors) + { + // A Boolean schema accepts (true) or rejects (false) every instance. + if (schemaNode is JsonValue) + { + if (schemaNode.GetValueKind() is JsonValueKind.False) + { + errors.Add(new JsonSchemaValidationError(JsonSchemaErrorKind.ValueNotAllowed, pointer, displayPath, "No value is allowed here.")); + } + + return; + } + + if (schemaNode is not JsonObject schema) + { + return; + } + + // A $ref points (as a root-relative JSON Pointer) to another schema the instance must also satisfy. + // Sibling keywords still apply, so resolution does not short-circuit the checks below. visitedRefs guards + // against a reference cycle that would otherwise recurse forever without consuming the instance; it is + // reset whenever validation descends into a child value (see ValidateObject / ValidateArray). + if (schema["$ref"] is JsonValue reference) + { + var target = reference.GetValue(); + ThrowIfCircularReference(visitedRefs, target); + ValidateNode(instance, ResolveReference(root, target), pointer, displayPath, root, visitedRefs, errors); + } + + ValidateType(instance, schema, pointer, displayPath, errors); + ValidateEnum(instance, schema, pointer, displayPath, errors); + ValidateObject(instance, schema, pointer, displayPath, root, errors); + ValidateArray(instance, schema, pointer, displayPath, root, errors); + } + + private static void ThrowIfCircularReference(HashSet visitedRefs, string reference) + { + if (!visitedRefs.Add(reference)) + { + throw new ArgumentException($"The schema contains a circular $ref: '{reference}'."); + } + } + + private static JsonNode ResolveReference(JsonNode root, string reference) + { + var pointer = reference.StartsWith('#') ? reference[1..] : reference; + var current = root; + foreach (var rawToken in pointer.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + // RFC 6901 escaping: decode "~1" to "/" first, then "~0" to "~". + var token = rawToken + .Replace("~1", "/", StringComparison.Ordinal) + .Replace("~0", "~", StringComparison.Ordinal); + + var next = current switch + { + JsonObject obj => obj[token], + JsonArray array when int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out var index) + && index < array.Count => array[index], + _ => null, + }; + + current = next ?? throw new ArgumentException( + $"The schema contains an unresolvable $ref: '{reference}'."); + } + + return current; + } + + private static void ValidateType( + JsonNode? instance, + JsonObject schema, + string pointer, + string displayPath, + List errors) + { + switch (schema["type"]) + { + case JsonValue typeValue: + var type = typeValue.GetValue(); + if (!TypeMatches(instance, type)) + { + errors.Add(new JsonSchemaValidationError( + JsonSchemaErrorKind.TypeMismatch, + pointer, + displayPath, + $"Expected {type}, but found {ActualType(instance)}.")); + } + + break; + case JsonArray typeArray: + var allowedTypes = typeArray.Select(static t => t?.GetValue()).ToArray(); + var matchesAny = allowedTypes.Any(t => t is not null && TypeMatches(instance, t)); + if (!matchesAny) + { + var expected = string.Join(" or ", allowedTypes); + errors.Add(new JsonSchemaValidationError( + JsonSchemaErrorKind.TypeMismatch, + pointer, + displayPath, + $"Expected {expected}, but found {ActualType(instance)}.")); + } + + break; + } + } + + private static void ValidateEnum( + JsonNode? instance, + JsonObject schema, + string pointer, + string displayPath, + List errors) + { + if (schema["enum"] is not JsonArray allowed) + { + return; + } + + var isAllowed = allowed.Any(candidate => JsonNode.DeepEquals(instance, candidate)); + if (isAllowed) + { + return; + } + + var rendered = string.Join(", ", allowed.Select(RenderValue)); + errors.Add(new JsonSchemaValidationError( + JsonSchemaErrorKind.DisallowedValue, + pointer, + displayPath, + $"{RenderValue(instance)} is not one of the allowed values: {rendered}.")); + } + + private static void ValidateObject( + JsonNode? instance, + JsonObject schema, + string pointer, + string displayPath, + JsonNode root, + List errors) + { + if (instance is not JsonObject obj) + { + return; + } + + if (schema["required"] is JsonArray required) + { + foreach (var entry in required) + { + var name = entry?.GetValue(); + if (name is not null && !obj.ContainsKey(name)) + { + errors.Add(new JsonSchemaValidationError( + JsonSchemaErrorKind.MissingProperty, + pointer, + displayPath, + $"Missing required property '{name}'.")); + } + } + } + + var properties = schema["properties"] as JsonObject; + var additional = schema["additionalProperties"]; + foreach (var (key, value) in obj) + { + if (properties?[key] is { } propertySchema) + { + ValidateNode(value, propertySchema, Append(pointer, key), AppendKey(displayPath, key), root, [], errors); + } + else if (additional is JsonValue additionalValue && additionalValue.GetValueKind() is JsonValueKind.False) + { + // Point at the offending member itself (it exists in the instance), not the enclosing object. + errors.Add(new JsonSchemaValidationError( + JsonSchemaErrorKind.UnknownProperty, + Append(pointer, key), + AppendKey(displayPath, key), + $"Unknown property '{key}'.")); + } + else if (additional is JsonObject additionalSchema) + { + ValidateNode(value, additionalSchema, Append(pointer, key), AppendKey(displayPath, key), root, [], errors); + } + } + } + + private static void ValidateArray( + JsonNode? instance, + JsonObject schema, + string pointer, + string displayPath, + JsonNode root, + List errors) + { + if (instance is not JsonArray array) + { + return; + } + + if (schema["items"] is not { } items) + { + return; + } + + for (var i = 0; i < array.Count; i++) + { + var index = i.ToString(CultureInfo.InvariantCulture); + var itemPointer = $"{pointer}/{index}"; + var itemDisplay = $"{displayPath}[{index}]"; + ValidateNode(array[i], items, itemPointer, itemDisplay, root, [], errors); + } + } + + private static bool TypeMatches(JsonNode? instance, string type) + { + var kind = instance?.GetValueKind() ?? JsonValueKind.Null; + return type switch + { + "null" => kind is JsonValueKind.Null, + "boolean" => kind is JsonValueKind.True or JsonValueKind.False, + "object" => kind is JsonValueKind.Object, + "array" => kind is JsonValueKind.Array, + "string" => kind is JsonValueKind.String, + "number" => kind is JsonValueKind.Number, + "integer" => kind is JsonValueKind.Number && IsIntegral(instance!), + _ => true, // Unknown type keyword: do not fail on a constraint we do not understand. + }; + } + + private static bool IsIntegral(JsonNode instance) + { + var value = instance.AsValue(); + if (value.TryGetValue(out _)) + { + return true; + } + + return value.TryGetValue(out var number) && double.IsInteger(number); + } + + private static string ActualType(JsonNode? instance) + => (instance?.GetValueKind() ?? JsonValueKind.Null) switch + { + JsonValueKind.Null => "null", + JsonValueKind.True or JsonValueKind.False => "boolean", + JsonValueKind.Object => "object", + JsonValueKind.Array => "array", + JsonValueKind.String => "string", + JsonValueKind.Number => "number", + _ => "unknown", + }; + + private static string RenderValue(JsonNode? instance) + => instance?.ToJsonString() ?? "null"; + + // Appends one object-member step to an RFC 6901 JSON Pointer, escaping "~" and "/" in the key. + private static string Append(string pointer, string key) + { + var escaped = key.Replace("~", "~0", StringComparison.Ordinal).Replace("/", "~1", StringComparison.Ordinal); + return $"{pointer}/{escaped}"; + } + + // Appends one object-member step to a human-friendly display path: ".key", or just "key" at the root. + private static string AppendKey(string displayPath, string key) + => displayPath.Length == 0 ? key : $"{displayPath}.{key}"; +} diff --git a/src/Buildvana.Core.JsonSchema/JsonSourceMap.Frame.cs b/src/Buildvana.Core.JsonSchema/JsonSourceMap.Frame.cs new file mode 100644 index 0000000..ac2605e --- /dev/null +++ b/src/Buildvana.Core.JsonSchema/JsonSourceMap.Frame.cs @@ -0,0 +1,19 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Buildvana.Core.JsonSchema; + +partial class JsonSourceMap +{ + // Tracks one open container while walking the document, so a value's pointer can be built from its parent. + private sealed class Frame(string pointer, bool isArray) + { + public string Pointer { get; } = pointer; + + public bool IsArray { get; } = isArray; + + public int NextIndex { get; set; } + + public string? PendingKey { get; set; } + } +} diff --git a/src/Buildvana.Core.JsonSchema/JsonSourceMap.cs b/src/Buildvana.Core.JsonSchema/JsonSourceMap.cs new file mode 100644 index 0000000..1c3d6b4 --- /dev/null +++ b/src/Buildvana.Core.JsonSchema/JsonSourceMap.cs @@ -0,0 +1,176 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace Buildvana.Core.JsonSchema; + +/// +/// Maps RFC 6901 JSON Pointers to 1-based line and column positions within a UTF-8 JSON document, so that +/// validation errors (which are keyed by pointer) can be reported at their location in the source. +/// +/// +/// Columns are counted in characters (UTF-16 code units), not bytes, so positions stay correct for +/// documents containing non-ASCII text. +/// +public sealed partial class JsonSourceMap +{ + private readonly Dictionary _positions; + + private JsonSourceMap(Dictionary positions) + { + _positions = positions; + } + + /// + /// Builds a source map from a UTF-8 encoded JSON document. + /// + /// The UTF-8 bytes of the JSON document. + /// A describing . + /// is not valid JSON. + public static JsonSourceMap Build(ReadOnlySpan utf8Json) + { + var positions = new Dictionary(StringComparer.Ordinal); + var lineStarts = BuildLineStarts(utf8Json); + var reader = new Utf8JsonReader( + utf8Json, + new JsonReaderOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }); + + var frames = new Stack(); + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.PropertyName: + frames.Peek().PendingKey = reader.GetString(); + break; + case JsonTokenType.StartObject: + case JsonTokenType.StartArray: + var containerPointer = NextPointer(frames); + Record(positions, containerPointer, reader.TokenStartIndex, lineStarts, utf8Json); + frames.Push(new Frame(containerPointer, reader.TokenType is JsonTokenType.StartArray)); + break; + case JsonTokenType.EndObject: + case JsonTokenType.EndArray: + _ = frames.Pop(); + break; + case JsonTokenType.String: + case JsonTokenType.Number: + case JsonTokenType.True: + case JsonTokenType.False: + case JsonTokenType.Null: + Record(positions, NextPointer(frames), reader.TokenStartIndex, lineStarts, utf8Json); + break; + } + } + + return new JsonSourceMap(positions); + } + + /// + /// Gets the source position of the value at the specified JSON Pointer. + /// + /// An RFC 6901 JSON Pointer (an empty string for the document root). + /// When this method returns , the 1-based line number. + /// When this method returns , the 1-based column number. + /// if a position was recorded for ; otherwise, . + public bool TryGetPosition(string jsonPointer, out int line, out int column) + { + if (_positions.TryGetValue(jsonPointer, out var position)) + { + (line, column) = position; + return true; + } + + (line, column) = (0, 0); + return false; + } + + private static string NextPointer(Stack frames) + { + if (frames.Count == 0) + { + return string.Empty; + } + + var top = frames.Peek(); + if (top.IsArray) + { + var childPointer = $"{top.Pointer}/{top.NextIndex.ToString(CultureInfo.InvariantCulture)}"; + top.NextIndex++; + return childPointer; + } + + var key = top.PendingKey ?? string.Empty; + top.PendingKey = null; + return $"{top.Pointer}/{Escape(key)}"; + } + + private static string Escape(string token) + => token.Replace("~", "~0", StringComparison.Ordinal).Replace("/", "~1", StringComparison.Ordinal); + + private static void Record( + Dictionary positions, + string pointer, + long tokenStartIndex, + List lineStarts, + ReadOnlySpan utf8Json) + { + // The first occurrence wins; a well-formed document never repeats a pointer anyway. + if (!positions.ContainsKey(pointer)) + { + positions[pointer] = OffsetToPosition((int)tokenStartIndex, lineStarts, utf8Json); + } + } + + private static List BuildLineStarts(ReadOnlySpan utf8Json) + { + var lineStarts = new List { 0 }; + for (var i = 0; i < utf8Json.Length; i++) + { + if (utf8Json[i] is (byte)'\n') + { + lineStarts.Add(i + 1); + } + } + + return lineStarts; + } + + private static (int Line, int Column) OffsetToPosition(int offset, List lineStarts, ReadOnlySpan utf8Json) + { + var lineIndex = FindLineIndex(lineStarts, offset); + var lineStart = lineStarts[lineIndex]; + var column = Encoding.UTF8.GetCharCount(utf8Json[lineStart..offset]) + 1; + return (lineIndex + 1, column); + } + + private static int FindLineIndex(List lineStarts, int offset) + { + // Binary search for the greatest line start that is less than or equal to offset. + var low = 0; + var high = lineStarts.Count - 1; + while (low < high) + { + var mid = (low + high + 1) / 2; + if (lineStarts[mid] <= offset) + { + low = mid; + } + else + { + high = mid - 1; + } + } + + return low; + } +} diff --git a/src/Buildvana.Tool/Program.cs b/src/Buildvana.Tool/Program.cs index 2c10505..d3e1fee 100644 --- a/src/Buildvana.Tool/Program.cs +++ b/src/Buildvana.Tool/Program.cs @@ -147,7 +147,17 @@ void OnCancel(object? sender, ConsoleCancelEventArgs e) } catch (BuildFailedException ex) { - (reporter ?? CreateDefaultReporter()).Error(ex.Message); + var activeReporter = reporter ?? CreateDefaultReporter(); + activeReporter.Error(ex.Message); + + // Emit each diagnostic verbatim (no level label or color) in canonical compiler format, so a + // terminal such as VS Code renders the file(line,column) prefix as a clickable link. + // Verbosity.Quiet guarantees we emit them at any verbosity level. + foreach (var diagnostic in ex.Diagnostics) + { + activeReporter.ChildError(diagnostic.ToString(), Verbosity.Quiet); + } + return ex.ExitCode; } @@ -202,6 +212,6 @@ private static ServiceProvider BuildServiceProvider( "NORMAL" or "N" => Verbosity.Normal, "DETAILED" or "D" => Verbosity.Detailed, "DIAGNOSTIC" or "DIAG" => Verbosity.Diagnostic, - _ => throw new BuildFailedException($"Unknown verbosity level '{raw}'. Use one of: quiet, minimal, normal, detailed, diagnostic."), + _ => throw new BuildFailedException($"Unknown verbosity level '{raw}'. Use one of: [q]uiet, [m]inimal, [n]ormal, [d]etailed, [diag]nostic."), }; } diff --git a/tests/Buildvana.Core.Configuration.Tests/Buildvana.Core.Configuration.Tests.csproj b/tests/Buildvana.Core.Configuration.Tests/Buildvana.Core.Configuration.Tests.csproj new file mode 100644 index 0000000..603da20 --- /dev/null +++ b/tests/Buildvana.Core.Configuration.Tests/Buildvana.Core.Configuration.Tests.csproj @@ -0,0 +1,20 @@ + + + + Exe + $(StandardTfm) + true + true + false + true + + + + + + + + + + + diff --git a/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigLoaderTests.cs b/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigLoaderTests.cs new file mode 100644 index 0000000..62bf753 --- /dev/null +++ b/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigLoaderTests.cs @@ -0,0 +1,152 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Text; +using Buildvana.Core; +using Buildvana.Core.Configuration; + +internal sealed class BuildvanaConfigLoaderTests +{ + [Test] + public async Task Load_NoFile_ReturnsEmptyConfig() + { + var dir = NewDir(); + try + { + var config = BuildvanaConfigLoader.Load(dir); + await Assert.That(config.Release).IsNull(); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Test] + public async Task Load_ValidConfig_Loads() + { + var dir = NewDir(); + try + { + Write(dir, "buildvana.jsonc", """{ "release": { "branches": ["main"] } }"""); + var config = BuildvanaConfigLoader.Load(dir); + await Assert.That(config.Release!.Branches!.Count).IsEqualTo(1); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Test] + public async Task Load_BothFilesPresent_ThrowsWithoutDiagnostics() + { + var dir = NewDir(); + try + { + Write(dir, "buildvana.json", "{}"); + Write(dir, "buildvana.jsonc", "{}"); + var exception = Catch(dir); + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Diagnostics.Count).IsEqualTo(0); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Test] + public async Task Load_InvalidJson_ReportsBV1100() + { + var dir = NewDir(); + try + { + Write(dir, "buildvana.jsonc", "{ not json "); + var exception = Catch(dir); + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Diagnostics.Count).IsEqualTo(1); + await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1100"); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Test] + public async Task Load_SchemaViolation_ReportsCodeAndPosition() + { + var dir = NewDir(); + try + { + Write(dir, "buildvana.jsonc", "{\n \"release\": { \"branches\": [\"main\", null] }\n}"); + var exception = Catch(dir); + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Diagnostics.Count).IsEqualTo(1); + await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1101"); + await Assert.That(exception.Diagnostics[0].Line).IsEqualTo(2); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Test] + public async Task Load_UnknownProperty_ReportsBV1103() + { + var dir = NewDir(); + try + { + Write(dir, "buildvana.jsonc", """{ "bogus": 1 }"""); + var exception = Catch(dir); + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Diagnostics[0].Code).IsEqualTo("BV1103"); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Test] + public async Task Load_BomPrefixedFile_DoesNotOffsetPositions() + { + var dir = NewDir(); + try + { + Write(dir, "buildvana.jsonc", "{\n \"release\": { \"branches\": [42] }\n}", bom: true); + var exception = Catch(dir); + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Diagnostics[0].Line).IsEqualTo(2); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + private static string NewDir() + { + var dir = Path.Combine(Path.GetTempPath(), "bvtest_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + return dir; + } + + private static void Write(string dir, string fileName, string content, bool bom = false) + => File.WriteAllText(Path.Combine(dir, fileName), content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: bom)); + + private static BuildFailedException? Catch(string dir) + { + try + { + _ = BuildvanaConfigLoader.Load(dir); + return null; + } + catch (BuildFailedException exception) + { + return exception; + } + } +} diff --git a/tests/Buildvana.Core.JsonSchema.Tests/Buildvana.Core.JsonSchema.Tests.csproj b/tests/Buildvana.Core.JsonSchema.Tests/Buildvana.Core.JsonSchema.Tests.csproj new file mode 100644 index 0000000..41b0f64 --- /dev/null +++ b/tests/Buildvana.Core.JsonSchema.Tests/Buildvana.Core.JsonSchema.Tests.csproj @@ -0,0 +1,20 @@ + + + + Exe + $(StandardTfm) + true + true + false + true + + + + + + + + + + + diff --git a/tests/Buildvana.Core.JsonSchema.Tests/GeneratorSample.cs b/tests/Buildvana.Core.JsonSchema.Tests/GeneratorSample.cs new file mode 100644 index 0000000..279e899 --- /dev/null +++ b/tests/Buildvana.Core.JsonSchema.Tests/GeneratorSample.cs @@ -0,0 +1,25 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Buildvana.Core.JsonSchema; + +// A model that exercises every shaping attribute in one schema, for JsonSchemaGeneratorTests. +[JsonSchemaTitle("Sample Title")] +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Reflected over by the schema generator under test; never instantiated.")] +internal sealed record GeneratorSample +{ + // Nullable, no opt-in: the schema should drop "null" from the type. + public string? Plain { get; init; } + + // Nullable with opt-in: the schema should keep "null". + [JsonNullable] + public string? Maybe { get; init; } + + [Description("a described field")] + public string? Described { get; init; } + + [JsonAllowedKeys("alpha, beta")] + public IReadOnlyDictionary? Map { get; init; } +} diff --git a/tests/Buildvana.Core.JsonSchema.Tests/JsonSchemaGeneratorTests.cs b/tests/Buildvana.Core.JsonSchema.Tests/JsonSchemaGeneratorTests.cs new file mode 100644 index 0000000..28f0d7c --- /dev/null +++ b/tests/Buildvana.Core.JsonSchema.Tests/JsonSchemaGeneratorTests.cs @@ -0,0 +1,60 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using Buildvana.Core.JsonSchema; + +internal sealed class JsonSchemaGeneratorTests +{ + private static readonly JsonSerializerOptions Options = new() + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + [Test] + public async Task Generate_EmitsDialectAndTitle() + { + var schema = Generate(); + await Assert.That(schema["$schema"]!.GetValue()) + .IsEqualTo("https://json-schema.org/draft/2020-12/schema"); + await Assert.That(schema["title"]!.GetValue()).IsEqualTo("Sample Title"); + } + + [Test] + public async Task Generate_StripsNullFromPlainNullableProperty() + { + var type = Generate()["properties"]!["plain"]!["type"]; + await Assert.That(type!.GetValueKind()).IsEqualTo(JsonValueKind.String); + await Assert.That(type.GetValue()).IsEqualTo("string"); + } + + [Test] + public async Task Generate_KeepsNullWhenPropertyIsJsonNullable() + { + var type = Generate()["properties"]!["maybe"]!["type"]; + await Assert.That(type is JsonArray).IsTrue(); + await Assert.That(((JsonArray)type!).Count).IsEqualTo(2); + } + + [Test] + public async Task Generate_SurfacesDescription() + { + var description = Generate()["properties"]!["described"]!["description"]; + await Assert.That(description!.GetValue()).IsEqualTo("a described field"); + } + + [Test] + public async Task Generate_ConstrainsDictionaryToAllowedKeys() + { + var map = Generate()["properties"]!["map"]!; + await Assert.That(map["additionalProperties"]!.GetValue()).IsFalse(); + await Assert.That(map["properties"]!["alpha"]).IsNotNull(); + await Assert.That(map["properties"]!["beta"]).IsNotNull(); + await Assert.That((map["properties"] as JsonObject)!.Count).IsEqualTo(2); + } + + private static JsonNode Generate() => JsonSchemaGenerator.Generate(Options); +} diff --git a/tests/Buildvana.Core.JsonSchema.Tests/JsonSchemaValidatorTests.cs b/tests/Buildvana.Core.JsonSchema.Tests/JsonSchemaValidatorTests.cs new file mode 100644 index 0000000..8514645 --- /dev/null +++ b/tests/Buildvana.Core.JsonSchema.Tests/JsonSchemaValidatorTests.cs @@ -0,0 +1,136 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Text; +using System.Text.Json.Nodes; +using Buildvana.Core.JsonSchema; + +internal sealed class JsonSchemaValidatorTests +{ + [Test] + public async Task Validate_ValidInstance_ReturnsNoErrors() + { + var errors = Validate( + """{"type":"object","properties":{"name":{"type":"string"}},"additionalProperties":false}""", + """{"name":"x"}"""); + await Assert.That(errors.Count).IsEqualTo(0); + } + + [Test] + public async Task Validate_TypeMismatch_ReportsKindAndPointer() + { + var errors = Validate( + """{"type":"object","properties":{"name":{"type":"string"}}}""", + """{"name":42}"""); + await Assert.That(errors.Count).IsEqualTo(1); + await Assert.That(errors[0].Kind).IsEqualTo(JsonSchemaErrorKind.TypeMismatch); + await Assert.That(errors[0].JsonPointer).IsEqualTo("/name"); + } + + [Test] + public async Task Validate_NullInstanceAgainstObject_ReportsTypeMismatchAtRoot() + { + var errors = JsonSchemaValidator.Validate(null, Schema("""{"type":"object"}""")); + await Assert.That(errors.Count).IsEqualTo(1); + await Assert.That(errors[0].Kind).IsEqualTo(JsonSchemaErrorKind.TypeMismatch); + await Assert.That(errors[0].JsonPointer).IsEqualTo(string.Empty); + } + + [Test] + public async Task Validate_DisallowedEnumValue_ReportsDisallowedValue() + { + var errors = Validate( + """{"type":"object","properties":{"c":{"enum":["a","b"]}}}""", + """{"c":"z"}"""); + await Assert.That(errors.Count).IsEqualTo(1); + await Assert.That(errors[0].Kind).IsEqualTo(JsonSchemaErrorKind.DisallowedValue); + } + + [Test] + public async Task Validate_UnknownProperty_PointsAtMember() + { + var errors = Validate( + """{"type":"object","properties":{},"additionalProperties":false}""", + """{"extra":1}"""); + await Assert.That(errors.Count).IsEqualTo(1); + await Assert.That(errors[0].Kind).IsEqualTo(JsonSchemaErrorKind.UnknownProperty); + await Assert.That(errors[0].JsonPointer).IsEqualTo("/extra"); + } + + [Test] + public async Task Validate_MissingRequiredProperty_ReportsMissingProperty() + { + var errors = Validate( + """{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}""", + """{}"""); + await Assert.That(errors.Count).IsEqualTo(1); + await Assert.That(errors[0].Kind).IsEqualTo(JsonSchemaErrorKind.MissingProperty); + } + + [Test] + public async Task Validate_ArrayItems_ReportPerElementPointers() + { + var errors = Validate( + """{"type":"array","items":{"type":"string"}}""", + """["a", 2, null]"""); + await Assert.That(errors.Count).IsEqualTo(2); + await Assert.That(errors[0].JsonPointer).IsEqualTo("/1"); + await Assert.That(errors[1].JsonPointer).IsEqualTo("/2"); + } + + [Test] + public async Task Validate_ResolvesRefAndReportsAtReferringPointer() + { + var errors = Validate( + """{"type":"object","properties":{"a":{"type":"string"},"b":{"$ref":"#/properties/a"}}}""", + """{"b":42}"""); + await Assert.That(errors.Count).IsEqualTo(1); + await Assert.That(errors[0].Kind).IsEqualTo(JsonSchemaErrorKind.TypeMismatch); + await Assert.That(errors[0].JsonPointer).IsEqualTo("/b"); + } + + [Test] + public async Task Validate_CircularRef_Throws() + => await Assert.That(() => JsonSchemaValidator.Validate( + JsonNode.Parse("123"), + Schema("""{"$ref":"#/a","a":{"$ref":"#/a"}}"""))) + .Throws(); + + [Test] + public async Task Validate_UnresolvableRef_Throws() + => await Assert.That(() => JsonSchemaValidator.Validate( + JsonNode.Parse("123"), + Schema("""{"$ref":"#/missing"}"""))) + .Throws(); + + [Test] + public async Task Validate_WithBytes_FillsLineAndColumn() + { + var schema = Schema("""{"type":"object","properties":{"name":{"type":"string"}}}"""); + var bytes = Encoding.UTF8.GetBytes("{\n \"name\": 42\n}"); + var errors = JsonSchemaValidator.Validate(JsonNode.Parse(bytes), schema, bytes); + await Assert.That(errors.Count).IsEqualTo(1); + await Assert.That(errors[0].Line).IsEqualTo(2); + await Assert.That(errors[0].Column).IsEqualTo(11); + } + + [Test] + public async Task Validate_NumericObjectKeyVersusArrayIndex_DisambiguatesDisplayPath() + { + var errors = Validate( + """{"type":"object","properties":{"obj":{"type":"object","additionalProperties":{"type":"string"}},"arr":{"type":"array","items":{"type":"string"}}}}""", + """{"obj":{"1":true},"arr":["x",true]}"""); + await Assert.That(errors.Count).IsEqualTo(2); + + // Both offending values sit at a pointer token "1", but only the array element is an index. + await Assert.That(errors[0].JsonPointer).IsEqualTo("/obj/1"); + await Assert.That(errors[0].DisplayPath).IsEqualTo("obj.1"); + await Assert.That(errors[1].JsonPointer).IsEqualTo("/arr/1"); + await Assert.That(errors[1].DisplayPath).IsEqualTo("arr[1]"); + } + + private static JsonNode Schema(string json) => JsonNode.Parse(json)!; + + private static IReadOnlyList Validate(string schema, string instance) + => JsonSchemaValidator.Validate(JsonNode.Parse(instance), Schema(schema)); +} diff --git a/tests/Buildvana.Core.JsonSchema.Tests/JsonSourceMapTests.cs b/tests/Buildvana.Core.JsonSchema.Tests/JsonSourceMapTests.cs new file mode 100644 index 0000000..2b1885b --- /dev/null +++ b/tests/Buildvana.Core.JsonSchema.Tests/JsonSourceMapTests.cs @@ -0,0 +1,56 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Text; +using Buildvana.Core.JsonSchema; + +internal sealed class JsonSourceMapTests +{ + [Test] + public async Task TryGetPosition_NestedValue_ReturnsLineAndColumn() + { + var map = Map("{\n \"a\": {\n \"b\": 1\n }\n}"); + var found = map.TryGetPosition("/a/b", out var line, out var column); + await Assert.That(found).IsTrue(); + await Assert.That(line).IsEqualTo(3); + await Assert.That(column).IsEqualTo(10); + } + + [Test] + public async Task TryGetPosition_ObjectValue_PointsAtOpeningBrace() + { + var map = Map("{\n \"a\": {\n \"b\": 1\n }\n}"); + map.TryGetPosition("/a", out var line, out var column); + await Assert.That(line).IsEqualTo(2); + await Assert.That(column).IsEqualTo(8); + } + + [Test] + public async Task TryGetPosition_ArrayElement_UsesIndexPointer() + { + var map = Map("[\n 10,\n 20\n]"); + map.TryGetPosition("/1", out var line, out var column); + await Assert.That(line).IsEqualTo(3); + await Assert.That(column).IsEqualTo(3); + } + + [Test] + public async Task TryGetPosition_NonAsciiKey_CountsCharactersNotBytes() + { + // "ä" is two UTF-8 bytes but one character; a byte-based column would place "b" one too far. + var map = Map("{\"ä\":1,\"b\":2}"); + map.TryGetPosition("/b", out var line, out var column); + await Assert.That(line).IsEqualTo(1); + await Assert.That(column).IsEqualTo(12); + } + + [Test] + public async Task TryGetPosition_UnknownPointer_ReturnsFalse() + { + var map = Map("{\"a\":1}"); + var found = map.TryGetPosition("/nope", out _, out _); + await Assert.That(found).IsFalse(); + } + + private static JsonSourceMap Map(string json) => JsonSourceMap.Build(Encoding.UTF8.GetBytes(json)); +}