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));
+}