From 2df8bb759f72a7fc926ea98f12e58ba7dea35998 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 7 Oct 2026 10:39:51 +0100 Subject: [PATCH 1/4] feat: added json schema --- Directory.Packages.props | 1 + Justfile | 7 + README.md | 2 + docs/wiki/Architecture.md | 2 + docs/wiki/Configuration-Reference.md | 24 + package.json | 2 +- purview-build.json | 2 + purview-build.schema.json | 492 ++++++++++++++++++ src/Build.slnx | 1 + src/src/Build/Build.csproj | 17 + src/src/Build/Configuration/JsonConfigFile.cs | 105 +++- src/src/Build/Schema/ConfigSchemaGenerator.cs | 275 ++++++++++ src/src/Build/Schema/SchemaDocumentation.cs | 94 ++++ src/src/Build/Settings/GitHubSettings.cs | 8 + src/src/Build/Settings/NuGetSettings.cs | 8 + .../Build.UnitTests/ConfigSchemaTests.cs | 241 +++++++++ .../Infra/ScenarioRepository.cs | 19 +- src/tests/Build.UnitTests/Infra/Scenarios.cs | 5 + .../fixtures/config-resolution-scenarios.json | 19 +- src/tests/scenarios/run-config-matrix.sh | 5 + 20 files changed, 1320 insertions(+), 9 deletions(-) create mode 100644 purview-build.schema.json create mode 100644 src/src/Build/Schema/ConfigSchemaGenerator.cs create mode 100644 src/src/Build/Schema/SchemaDocumentation.cs create mode 100644 src/tests/Build.UnitTests/ConfigSchemaTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 29c0d79..994bba8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/Justfile b/Justfile index aae2c90..73b23f1 100644 --- a/Justfile +++ b/Justfile @@ -126,6 +126,13 @@ release-explain-golden: --treenode-filter "/*/*/ReleaseExplainGoldenTests/*" git --no-pager diff -- src/tests/fixtures/release-explain.golden.json +# Regenerate purview-build.schema.json from the settings types, then show what changed +[group('Build and Test')] +schema: + PURVIEW_BUILD_UPDATE_SCHEMA=1 dotnet test {{ golden_test_project }} -c Release \ + --treenode-filter "/*/*/ConfigSchemaTests/Schema_MatchesTheGeneratedFile" + git --no-pager diff -- purview-build.schema.json + # Build the tool in Release so the scenario matrices can run the real binary [private] scenario-build: diff --git a/README.md b/README.md index 941d48d..b22d28a 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ Add `purview-build.json`. Everything is optional; defaults are baked into the to The file is found at the repository root, or at `.config/`, `.build/`, `build/`, `.purview/` or `.github/` beneath it — first match wins, and any lower-priority file that also exists is reported as shadowed rather than merged. Select one explicitly with `--config ` or `PURVIEW_BUILD_CONFIG`. Relative paths inside the file always anchor to the repository root, wherever the file itself lives. Run `purview-build --help` to print the probe order and the path that resolved. See the [configuration reference](docs/wiki/Configuration-Reference.md#where-the-configuration-file-lives). +`purview-build.json` is validated against a JSON Schema as it loads, so an unknown key, a wrong type or an invalid enum value fails the run instead of being silently ignored. Add `"$schema": "https://raw.githubusercontent.com/purview-dev/build/main/purview-build.schema.json"` to the file for editor completion and validation; see [validation](docs/wiki/Configuration-Reference.md#validation). + The pipeline is dotnet-first but supports **Web** projects (Bun/JS/TS, e.g. the Astro/Starlight `purview-dev` portal) by setting `Build:ProjectType=Web`: restore/build/lint/test then run the repository's root `package.json` scripts (`bun install`, `bun run build`, `bun run format:check`/`bun run lint`, `bun run test`), and the pack step zips `Build:WebBuildOutput` (default `src/dist`) into `Build:ArtifactsFolder` for the GitHub release. Every Web command is overridable via the `Web*` settings below. ```json diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 01c393b..f6a8333 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -37,6 +37,8 @@ The eligibility layer is the one worth being precise about. Before this split, t `purview-build.json` is resolved before the pipeline is built: an explicit `--config` / `PURVIEW_BUILD_CONFIG` location, or the first hit from a documented probe list held as data. Relative paths inside the file always anchor to the repository root, never to the file's own directory, so moving the file changes nothing else. See [Configuration Reference](Configuration-Reference.md#where-the-configuration-file-lives). +The resolved file is parse-checked and then validated against a JSON Schema generated from the settings types, so a key the tool cannot bind fails the run instead of being silently ignored. The schema is embedded in the tool for validation and shipped alongside `appsettings.json` for editors; `just schema` regenerates it and the test suite fails when it drifts from the types. See [Validation](Configuration-Reference.md#validation). + Because configuration is composed before any module (and therefore any pipeline context) exists, the locality check that gates opt-in machine-local user configuration is a first-party helper rather than `ctx.IsRunningLocally()`. ## Module ordering diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index 7452fb6..88901c3 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -59,6 +59,30 @@ It is also a reproducibility hazard — a machine-local file silently altering a - **Lower precedence than the repository configuration**, higher than `appsettings.json`. - When active, the resolved path is logged at `Information`. +## Validation + +`purview-build.json` is validated against a JSON Schema when it is loaded, before any module runs. The schema is generated from the tool's settings types, so it describes exactly the keys the tool can bind, and it ships with the tool (embedded for validation, and alongside `appsettings.json` in the package). + +- A key the tool cannot bind — a typo, or a setting that has been renamed — is an **error**, not a silently ignored line. So is a value of the wrong type and an enum value outside its allowed set. +- `$`-prefixed keys are reserved for metadata and ignored, so `$schema` and `$comment` are accepted. +- Property names use the canonical casing shown in this reference (`"Build"`, not `"build"`). +- `//` comments and trailing commas are tolerated, as they are by the configuration binder. + +A failure names the file and every offending key, then exits 1 — exactly as malformed JSON does. + +### Editor support + +Add the schema to the file and editors (VS Code, Rider, Visual Studio) complete keys and flag unknown ones as you type: + +```json +{ + "$schema": "https://raw.githubusercontent.com/purview-dev/build/main/purview-build.schema.json", + "Build": { "Solution": "src/MyProduct.slnx" } +} +``` + +The schema is generated from the settings types: `just schema` regenerates it after a deliberate settings change, and the test suite fails when the committed file drifts from the types. + ## Precedence ```text diff --git a/package.json b/package.json index f23e7d8..70c192b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-build", - "version": "0.4.0", + "version": "0.5.0", "private": true, "homepage": "https://purview.dev/projects/build/", "bugs": { diff --git a/purview-build.json b/purview-build.json index f9914e5..3704d6a 100644 --- a/purview-build.json +++ b/purview-build.json @@ -1,4 +1,5 @@ { + "$schema": "https://raw.githubusercontent.com/purview-dev/build/main/purview-build.schema.json", "Build": { "Solution": "src/Build.slnx", "TestRoot": "src/tests", @@ -18,6 +19,7 @@ "purview.build": [ "tools/**/Purview.Build.dll", "tools/**/appsettings.json", + "tools/**/purview-build.schema.json", "README.md", "purview-logo-light.png" ] diff --git a/purview-build.schema.json b/purview-build.schema.json new file mode 100644 index 0000000..8791a9c --- /dev/null +++ b/purview-build.schema.json @@ -0,0 +1,492 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/purview-dev/build/main/purview-build.schema.json", + "title": "Purview.Build configuration", + "description": "Configuration for the Purview.Build shared pipeline (purview-build.json). Every section is optional; unset keys fall back to the tool\u0027s defaults.", + "type": "object", + "properties": { + "Build": { + "type": "object", + "properties": { + "LogLevel": { + "type": "string", + "enum": [ + "Trace", + "Debug", + "Information", + "Warning", + "Error", + "Critical", + "None" + ], + "default": "Information" + }, + "ProjectType": { + "type": "string", + "enum": [ + "DotNet", + "Web" + ], + "description": "The kind of repository the pipeline operates on. DotNet runs the dotnet-based modules (restore/build/test/pack/lint); Web runs the corresponding Bun-based commands from the repository\u0027s root package.json scripts.", + "default": "DotNet" + }, + "Solution": { + "type": "string", + "default": "src/Product.slnx" + }, + "Configuration": { + "type": "string", + "default": "Release" + }, + "ArtifactsFolder": { + "type": "string", + "default": "artifacts" + }, + "CleanArtifacts": { + "type": "boolean", + "description": "When true, CleanArtifactsModule deletes and recreates ArtifactsFolder before the pipeline produces any output, so validation, publishing, and release uploads only ever see the artifacts from the current run. Ignored when RunPack is false (nothing will be packed, so existing artifacts, such as a folder being inspected ahead of a publish, are left untouched).", + "default": true + }, + "RunTests": { + "type": "boolean", + "default": true + }, + "TestRoot": { + "type": "string", + "description": "Root directory (relative to the repository root) under which test projects are discovered.", + "default": "src/tests" + }, + "TestPatterns": { + "type": "string", + "description": "Comma-separated project search patterns, recursively applied under TestRoot.", + "default": "*Tests.csproj" + }, + "TestProjects": { + "type": "string", + "description": "Comma-separated list of test project file names (or glob patterns) to run. Empty or \u0022*\u0022 runs every discovered test project.", + "default": "*" + }, + "TestFramework": { + "type": "string", + "enum": [ + "TUnit", + "xUnit" + ], + "default": "TUnit" + }, + "TestFilter": { + "type": "string", + "description": "Test filter. For TUnit this is a Microsoft.Testing.Platform tree-node filter (e.g. \u0022/*/*/*/*[Category=Unit]\u0022); for xUnit it is a VSTest filter (e.g. \u0022Category=Unit\u0022). Empty disables the filter.", + "default": "/*/*/*/*/" + }, + "RunLint": { + "type": "boolean", + "default": true + }, + "RunPack": { + "type": "boolean", + "default": true + }, + "ValidatePack": { + "type": "boolean", + "default": true + }, + "WebInstallCommand": { + "type": "string", + "description": "Install command used by RestoreModule for Web projects.", + "default": "bun install" + }, + "WebBuildCommand": { + "type": "string", + "description": "Build command used by BuildModule for Web projects. When left at the default, the module first runs a data:sync script if one is declared. Override to take full control of the build (for example a chain of validation scripts).", + "default": "bun run build" + }, + "WebLintCommand": { + "type": "string", + "description": "Lint command used by LintModule for Web projects.", + "default": "bun run lint" + }, + "WebFormatCheckCommand": { + "type": "string", + "description": "Format-check command used by LintModule for Web projects.", + "default": "bun run format:check" + }, + "WebTestCommand": { + "type": "string", + "description": "Test command used by RunTestsModule for Web projects.", + "default": "bun run test" + }, + "WebBuildOutput": { + "type": "string", + "description": "Directory (relative to the repository root) whose contents PackModule zips into Build:ArtifactsFolder for Web projects.", + "default": "src/dist" + } + }, + "additionalProperties": false, + "patternProperties": { + "^\\$": true + } + }, + "PackValidation": { + "type": "object", + "properties": { + "RequireSymbolPackage": { + "type": "boolean", + "description": "Every .nupkg must have a matching .snupkg (same id/version) and vice versa.", + "default": true + }, + "RequireSymbolFiles": { + "type": "boolean", + "description": "Every .snupkg must contain at least one .pdb file.", + "default": true + }, + "RequireSourceLink": { + "type": "boolean", + "description": "Every .dll/.exe in the .nupkg must have a matching portable PDB (in the .snupkg) that contains a Source Link record.", + "default": false + }, + "RequireDeterministic": { + "type": "boolean", + "description": "Every .dll/.exe in the .nupkg must be built deterministically (the PE must carry the Reproducible debug directory entry emitted by deterministic compiler builds).", + "default": false + }, + "RequiredCompilerFlags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Compiler-flag key=value entries that must appear in each assembly\u0027s PDB compiler-flags record (case-insensitive), e.g. \u0022optimization=release\u0022.", + "default": [] + }, + "RequiredContent": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Package id (glob, case-insensitive; \u0022*\u0022 matches every package) to entry path globs that MUST be present in the .nupkg. Paths use forward slashes, e.g. \u0022tools/**/Foo.dll\u0022 or \u0022lib/netstandard2.0/Foo.dll\u0022.", + "default": {} + }, + "ForbiddenContent": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Package id (glob, case-insensitive; \u0022*\u0022 matches every package) to entry path globs that MUST NOT be present in the .nupkg. Paths use forward slashes, e.g. \u0022**/*.pdb\u0022.", + "default": {} + }, + "RequireExplicitContent": { + "type": "boolean", + "description": "When true, RequiredContent becomes the exhaustive, exact definition of every package\u0027s contents: Every produced .nupkg\u0027s id must match a RequiredContent key; a package with no matching rule is an error. Every entry in the .nupkg (excluding standard NuGet/OPC metadata such as the .nuspec, \u0022[Content_Types].xml\u0022, \u0022_rels/\u0022, \u0022package/services/metadata/\u0022, and \u0022.signature.p7s\u0022) must match one of that package\u0027s (TFM-expanded) RequiredContent globs; any undeclared entry is an error.", + "default": true + } + }, + "additionalProperties": false, + "patternProperties": { + "^\\$": true + } + }, + "NuGet": { + "type": "object", + "properties": { + "APIKey": { + "type": "string", + "description": "Secret: the NuGet API key used to push packages. Prefer the NUGET_APIKEY environment variable over committing it here." + }, + "EnvAPIKey": { + "type": "string", + "description": "Secret: binds NuGet__NUGET_APIKEY. Prefer the plain NUGET_APIKEY environment variable." + }, + "FeedUrl": { + "type": "string", + "default": "https://api.nuget.org/v3/index.json" + }, + "TrustedPublishing": { + "type": "boolean", + "description": "When true, packages are pushed without an API key using NuGet Trusted Publishing (OIDC federation, e.g. via the NuGet/login GitHub Action). No API key is required.", + "default": false + } + }, + "additionalProperties": false, + "patternProperties": { + "^\\$": true + } + }, + "PublishLocalNuGet": { + "type": "object", + "properties": { + "LocalFeedPath": { + "type": "string", + "default": "" + }, + "EnvLocalFeedPath": { + "type": "string", + "description": "Env-var bound alias for LocalFeedPath via PublishLocalNuGet__LOCAL_NUGET_FEED_PATH." + }, + "OverwriteExistingPackages": { + "type": "boolean", + "default": true + }, + "ShutdownDotnetBuilderServer": { + "type": "boolean", + "default": true + }, + "ClearPackageCache": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": false, + "patternProperties": { + "^\\$": true + } + }, + "GitHub": { + "type": "object", + "properties": { + "AccessToken": { + "type": "string", + "description": "Secret: the GitHub token used to create releases. Prefer the GITHUB_TOKEN environment variable over committing it here." + }, + "EnvAccessToken": { + "type": "string", + "description": "Secret: binds GitHub__GITHUB_TOKEN. Prefer the plain GITHUB_TOKEN environment variable." + }, + "ProductHeader": { + "type": "string", + "default": "Purview.Build.Pipeline" + } + }, + "additionalProperties": false, + "patternProperties": { + "^\\$": true + } + }, + "Version": { + "type": "object", + "properties": { + "Source": { + "type": "string", + "enum": [ + "PackageJson" + ], + "default": "PackageJson" + }, + "Strictness": { + "type": "string", + "enum": [ + "NuGet", + "SemVer2" + ], + "default": "NuGet" + } + }, + "additionalProperties": false, + "patternProperties": { + "^\\$": true + } + }, + "Release": { + "type": "object", + "properties": { + "Mode": { + "type": "string", + "enum": [ + "None", + "NuGet", + "GitHubRelease", + "LocalNuGet" + ], + "description": "Preset that derives Publish and GitHubRelease. Retained as the primary switch; an explicitly set Publish/GitHubRelease wins over whatever the preset implies.", + "default": "None" + }, + "Publish": { + "type": [ + "boolean", + "null" + ], + "description": "Whether packages are pushed. Null means \u0022derive from Mode\u0022." + }, + "GitHubRelease": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the tag and GitHub release are created. Null means \u0022derive from the channel, then from Mode\u0022." + }, + "Channel": { + "type": "string", + "description": "Named channel, selecting the feed and the label policy.", + "default": "stable" + }, + "Channels": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "FeedUrl": { + "type": "string", + "description": "Feed this channel publishes to. Falls back to NuGet:FeedUrl." + }, + "GitHubRelease": { + "type": [ + "boolean", + "null" + ], + "description": "Whether a GitHub release is created for this channel. Falls back to the resolved Release:GitHubRelease." + }, + "MarkPrerelease": { + "type": [ + "boolean", + "null" + ], + "description": "Whether a prerelease version is marked as a GitHub prerelease. Falls back to Release:MarkPrerelease." + }, + "LabelPattern": { + "type": "string", + "description": "Pattern the prerelease label must match (REL007), for example prerelease or preview. A trailing * is a prefix match. Null disables the check even when REL007 is enabled." + } + }, + "additionalProperties": false, + "patternProperties": { + "^\\$": true + }, + "description": "A named release channel: where packages go, whether a GitHub release is cut, and which prerelease labels are allowed." + }, + "default": {} + }, + "DryRun": { + "type": "boolean", + "description": "Runs the full pipeline but skips publishing and the GitHub release, logging what each would have done.", + "default": false + }, + "UploadArtifacts": { + "type": "boolean", + "description": "When true, the GitHub release module uploads every file in Build:ArtifactsFolder (for example .nupkg/.snupkg or .vsix) as release assets.", + "default": false + }, + "MarkPrerelease": { + "type": "boolean", + "description": "When true (the default), a GitHub release whose version is a prerelease (for example 2.0.0-prerelease.25) is created as a prerelease, so it is not presented as the latest stable release. Set to false to publish prerelease versions as stable releases.", + "default": true + }, + "Eligibility": { + "type": "object", + "properties": { + "Policy": { + "type": "string", + "description": "Name of the selected policy. Defaults to the policy that reproduces the pipeline\u0027s pre-eligibility behaviour, so a repository with no Release:Eligibility section is unaffected.", + "default": "ReleaseOnMain" + }, + "Policies": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "Inherits": { + "type": "string", + "description": "Name of the policy this one is expressed as a diff from. A missing parent is an error naming the unresolved policy." + }, + "Rules": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "StableRefs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Refs a stable (non-prerelease) version may be released from (REL003). Entries may use a trailing *, for example refs/heads/release/*.", + "default": [] + }, + "ServicingRefs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Refs a version with a non-zero PATCH component may be released from (REL004).", + "default": [] + }, + "TrunkRefs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Refs that represent the trunk, where a release is not cut. Recorded so a policy can describe the branch model it belongs to; no rule reads it directly today.", + "default": [] + }, + "FourPartRefs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Refs a four-part version may be released from (REL006).", + "default": [] + }, + "AllowFourPart": { + "type": "boolean", + "description": "Whether four-part versions are permitted at all (REL006).", + "default": false + } + }, + "additionalProperties": false, + "patternProperties": { + "^\\$": true + }, + "description": "One named release-eligibility policy: which REL0nn rules apply, and the refs they judge against." + }, + "description": "Policies by name. Merged over the built-in policies, so a repository can select TrunkReservesMinor without redeclaring it, and can override a built-in by name.", + "default": {} + } + }, + "additionalProperties": false, + "patternProperties": { + "^\\$": true + }, + "default": { + "Policy": "ReleaseOnMain", + "Policies": {} + } + }, + "Context": { + "type": "object", + "properties": { + "Ref": { + "type": "string", + "description": "Overrides the evaluated ref. Defaults to GITHUB_REF, then the current git branch." + }, + "ExistingTags": { + "type": "string", + "description": "Path to a newline- or JSON-delimited tag list, used instead of querying git." + }, + "PublishedVersions": { + "type": "string", + "description": "Path to a newline- or JSON-delimited version list treated as already on the feed, used instead of querying it." + } + }, + "additionalProperties": false, + "patternProperties": { + "^\\$": true + }, + "description": "Simulated release context, so eligibility can be evaluated on a developer machine without being on the branch or having the tags and feed state the rules judge against." + } + }, + "additionalProperties": false, + "patternProperties": { + "^\\$": true + } + } + }, + "additionalProperties": false, + "patternProperties": { + "^\\$": true + } +} diff --git a/src/Build.slnx b/src/Build.slnx index 428b45a..461c7a8 100644 --- a/src/Build.slnx +++ b/src/Build.slnx @@ -4,6 +4,7 @@ + diff --git a/src/src/Build/Build.csproj b/src/src/Build/Build.csproj index 4cb9586..2fb44e4 100644 --- a/src/src/Build/Build.csproj +++ b/src/src/Build/Build.csproj @@ -71,6 +71,7 @@ + @@ -85,4 +86,20 @@ PreserveNewest + + + + + + PreserveNewest + + \ No newline at end of file diff --git a/src/src/Build/Configuration/JsonConfigFile.cs b/src/src/Build/Configuration/JsonConfigFile.cs index 9b9b5ea..d2696da 100644 --- a/src/src/Build/Configuration/JsonConfigFile.cs +++ b/src/src/Build/Configuration/JsonConfigFile.cs @@ -1,30 +1,58 @@ using System.Text.Json; +using Json.Schema; +using Purview.Build.Schema; namespace Purview.Build.Configuration; /// -/// Parse-checks a configuration file before it reaches the configuration binder. +/// Parse-checks and schema-validates a configuration file before it reaches the configuration binder. /// /// /// The JSON configuration provider reports a malformed file as a bare deserialisation failure with -/// no path, which is useless when six probe locations are possible. Parsing first means the error -/// names the file and, where the parser supplies them, the line and position. +/// no path, which is useless when six probe locations are possible; it also silently ignores a key +/// it cannot bind, so a typo behaves exactly like an unset setting. Parsing first names the file and +/// the position, and validating against the schema the tool ships turns "silently ignored" into a +/// failure that names the offending key. /// static class JsonConfigFile { - /// The file exists but is not valid JSON. + const string SchemaResourceName = "Purview.Build.ConfigSchema.json"; + + static readonly Lazy Schema = new(LoadSchema); + + /// + /// The file exists but is not valid JSON, or does not match the configuration schema. + /// public static void Validate(string path) { if (!File.Exists(path)) return; + using var document = Parse(path); + + var results = Schema.Value.Evaluate( + document.RootElement, + // The default output format reports only valid/invalid; Hierarchical carries the failing + // keyword and instance location that make a diagnostic actionable. + new EvaluationOptions { OutputFormat = Json.Schema.OutputFormat.Hierarchical } + ); + if (results.IsValid) + return; + + throw new InvalidOperationException(Describe(path, results)); + } + + static JsonDocument Parse(string path) + { try { using var stream = File.OpenRead(path); - using var document = JsonDocument.Parse( + return JsonDocument.Parse( stream, new JsonDocumentOptions { + // Consuming repositories annotate their configuration with // comments and trailing + // commas; the configuration provider tolerates both, so validation must too. CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, } @@ -44,4 +72,71 @@ exception.LineNumber is { } line && exception.BytePositionInLine is { } bytePosi ); } } + + static JsonSchema LoadSchema() + { + using var stream = + typeof(JsonConfigFile).Assembly.GetManifestResourceStream(SchemaResourceName) + ?? throw new InvalidOperationException( + $"The embedded configuration schema '{SchemaResourceName}' was not found." + ); + using StreamReader reader = new(stream); + + return JsonSchema.FromText(reader.ReadToEnd()); + } + + static string Describe(string path, EvaluationResults results) + { + var violations = Leaves(results) + .SelectMany( + result => + result.Errors?.Select(error => $"{Location(result)}: {DescribeError(result, error.Value)}") ?? [] + ) + .ToList(); + + return $"'{path}' does not match the Purview.Build configuration schema:" + + Environment.NewLine + + string.Join(Environment.NewLine, violations.Select(violation => $" - {violation}")) + + Environment.NewLine + + $"Add \"$schema\": \"{ConfigSchemaGenerator.SchemaId}\" to the file for editor validation."; + } + + /// + /// The failing results that have no failing children, so a container failure + /// (additionalProperties, properties) reports the key that caused it rather than a + /// generic message about the object. + /// + static IEnumerable Leaves(EvaluationResults results) + { + var children = (results.Details ?? []).Where(detail => !detail.IsValid).ToList(); + + if (children.Count == 0) + { + if (!results.IsValid) + yield return results; + + yield break; + } + + foreach (var child in children) + foreach (var leaf in Leaves(child)) + yield return leaf; + } + + static string Location(EvaluationResults result) + { + var pointer = result.InstanceLocation.ToString(); + + return string.IsNullOrEmpty(pointer) ? "(root)" : pointer.TrimStart('/').Replace('/', '.'); + } + + /// + /// A failure whose evaluation path ends at additionalProperties is a key the schema does + /// not know, which is the typo the schema exists to catch; the library's own wording for it + /// ("all values fail against the false schema") says nothing useful to the reader. + /// + static string DescribeError(EvaluationResults result, string message) => + result.EvaluationPath.ToString().EndsWith("/additionalProperties", StringComparison.Ordinal) + ? "unknown property (not a Purview.Build setting)" + : message; } diff --git a/src/src/Build/Schema/ConfigSchemaGenerator.cs b/src/src/Build/Schema/ConfigSchemaGenerator.cs new file mode 100644 index 0000000..f811573 --- /dev/null +++ b/src/src/Build/Schema/ConfigSchemaGenerator.cs @@ -0,0 +1,275 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Purview.Build.Schema; + +/// +/// Generates the JSON Schema for purview-build.json from the settings types. +/// +/// +/// The settings types are the single source of truth: a key the tool can bind is a key the schema +/// describes, and a key the tool cannot bind fails validation. just schema regenerates the +/// committed file, and ConfigSchemaTests fails the build when the two drift. +/// +static class ConfigSchemaGenerator +{ + /// + /// The canonical location of the schema, used both as the schema's $id and in the + /// diagnostic the tool prints when a file does not validate. + /// + public const string SchemaId = + "https://raw.githubusercontent.com/purview-dev/build/main/purview-build.schema.json"; + + /// + /// Every configuration section, in the order they appear in the schema. A section is a top-level + /// object in purview-build.json; anything else reachable from it is nested. + /// + static readonly (string Section, Type Type)[] Sections = + [ + (BuildSettings.SectionName, typeof(BuildSettings)), + (PackValidationSettings.SectionName, typeof(PackValidationSettings)), + (NuGetSettings.SectionName, typeof(NuGetSettings)), + (PublishLocalNuGetSettings.SectionName, typeof(PublishLocalNuGetSettings)), + (GitHubSettings.SectionName, typeof(GitHubSettings)), + (VersionSettings.SectionName, typeof(VersionSettings)), + (ReleaseSettings.SectionName, typeof(ReleaseSettings)), + ]; + + static readonly JsonSerializerOptions ValueOptions = new() + { + Converters = { new JsonStringEnumConverter() }, + }; + + static readonly JsonSerializerOptions RenderOptions = new() + { + WriteIndented = true, + IndentCharacter = '\t', + IndentSize = 1, + // Pinned so the generated file is identical on every platform, like the other JSON here. + NewLine = "\n", + }; + + /// + /// Builds the schema, with (the tool's appsettings.json) + /// overlaid on the C# initialisers so every default states the value the tool actually + /// ships. + /// + public static JsonObject Build(JsonObject? shippedDefaults = null) + { + var documentation = SchemaDocumentation.Load(); + + JsonObject properties = new(); + foreach (var (section, type) in Sections) + { + properties[section] = BuildObject(type, shippedDefaults?[section] as JsonObject, documentation); + } + + return new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["$id"] = SchemaId, + ["title"] = "Purview.Build configuration", + ["description"] = + "Configuration for the Purview.Build shared pipeline (purview-build.json). " + + "Every section is optional; unset keys fall back to the tool's defaults.", + ["type"] = "object", + ["properties"] = properties, + ["additionalProperties"] = false, + // "$" is reserved for metadata ($schema, $comment, ...). The tool ignores those keys, so + // the schema accepts them while still rejecting a misspelled setting. + ["patternProperties"] = new JsonObject { ["^\\$"] = true }, + }; + } + + /// Renders the schema exactly as the committed file stores it. + public static string Render(JsonObject? shippedDefaults = null) => + Build(shippedDefaults).ToJsonString(RenderOptions) + "\n"; + + /// + /// Loads the shipped appsettings.json so its values win over the C# initialisers, exactly + /// as they do at runtime. + /// + public static JsonObject? LoadShippedDefaults(string path) => + File.Exists(path) ? JsonNode.Parse(File.ReadAllText(path)) as JsonObject : null; + + static JsonObject BuildObject( + Type type, + JsonObject? shippedDefaults, + IReadOnlyDictionary documentation + ) + { + var instance = Activator.CreateInstance(type); + JsonObject properties = new(); + + foreach (var property in SettingsProperties(type)) + { + properties[property.Name] = BuildProperty( + property, + shippedDefaults?[property.Name], + instance is null ? null : property.GetValue(instance), + documentation + ); + } + + JsonObject schema = new() + { + ["type"] = "object", + ["properties"] = properties, + ["additionalProperties"] = false, + ["patternProperties"] = new JsonObject { ["^\\$"] = true }, + }; + + if (documentation.TryGetValue($"T:{type.FullName}", out var summary)) + schema["description"] = summary; + + return schema; + } + + static JsonObject BuildProperty( + PropertyInfo property, + JsonNode? shippedDefault, + object? csharpValue, + IReadOnlyDictionary documentation + ) + { + var declared = property.PropertyType; + var type = Nullable.GetUnderlyingType(declared) ?? declared; + var nullable = type != declared; + + var schema = BuildType(type, shippedDefault as JsonObject, documentation); + + if (nullable && schema["type"] is JsonValue value && value.TryGetValue(out var typeName)) + schema["type"] = new JsonArray(typeName, "null"); + + if (documentation.TryGetValue($"P:{property.DeclaringType!.FullName}.{property.Name}", out var summary)) + schema["description"] = summary; + + // A JSON null means "unset", so it is not worth stating as a default. A nested object's + // default comes only from the shipped appsettings.json, because serialising the C# instance + // would also emit computed members such as Release:Context:IsSimulated. + var @default = Prune(shippedDefault); + if (@default is null && !IsNestedSettings(type)) + @default = Prune(ToJsonValue(csharpValue)); + + if (@default is not null) + schema["default"] = @default; + + return schema; + } + + /// + /// Whether a property's type is a nested settings object rather than a scalar, array or map. + /// + static bool IsNestedSettings(Type type) => + !type.IsEnum + && type != typeof(string) + && type != typeof(bool) + && type != typeof(int) + && type != typeof(long) + && !type.IsArray + && DictionaryValueType(type) is null; + + static JsonObject BuildType( + Type type, + JsonObject? shippedDefaults, + IReadOnlyDictionary documentation + ) + { + if (type.IsEnum) + { + JsonArray values = new(); + foreach (var name in Enum.GetNames(type)) + values.Add(name); + return new JsonObject { ["type"] = "string", ["enum"] = values }; + } + + if (type == typeof(bool)) + return new JsonObject { ["type"] = "boolean" }; + + if (type == typeof(int) || type == typeof(long)) + return new JsonObject { ["type"] = "integer" }; + + if (type == typeof(string)) + return new JsonObject { ["type"] = "string" }; + + if (type.IsArray) + return new JsonObject + { + ["type"] = "array", + ["items"] = BuildType(type.GetElementType()!, shippedDefaults: null, documentation), + }; + + var dictionaryValue = DictionaryValueType(type); + if (dictionaryValue is not null) + return new JsonObject + { + ["type"] = "object", + ["additionalProperties"] = BuildType(dictionaryValue, shippedDefaults: null, documentation), + }; + + // Any remaining settings type is a nested object with its own known keys. + return BuildObject(type, shippedDefaults, documentation); + } + + static Type? DictionaryValueType(Type type) + { + if ( + type.IsGenericType + && type.GetGenericTypeDefinition() == typeof(Dictionary<,>) + && type.GetGenericArguments()[0] == typeof(string) + ) + return type.GetGenericArguments()[1]; + + return null; + } + + /// + /// The bindable properties of a settings type, in declaration order. Excludes the static + /// SectionName/Default/Stable helpers and computed properties such as + /// IsSimulated, neither of which the configuration binder can set. + /// + static IEnumerable SettingsProperties(Type type) => + type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.CanWrite) + .OrderBy(property => property.MetadataToken); + + static JsonNode? ToJsonValue(object? value) => + value is null ? null : JsonSerializer.SerializeToNode(value, value.GetType(), ValueOptions); + + /// + /// Drops nulls from a default value: a nested object whose members are all null carries no + /// information, and null is not a useful stated default for an optional key. + /// + static JsonNode? Prune(JsonNode? node) + { + switch (node) + { + case null: + return null; + case JsonObject obj: + { + JsonObject pruned = new(); + foreach (var (key, value) in obj) + { + if (Prune(value) is { } kept) + pruned[key] = kept; + } + return pruned; + } + case JsonArray array: + { + JsonArray pruned = new(); + foreach (var item in array) + { + if (Prune(item) is { } kept) + pruned.Add(kept); + } + return pruned; + } + default: + return node.DeepClone(); + } + } +} diff --git a/src/src/Build/Schema/SchemaDocumentation.cs b/src/src/Build/Schema/SchemaDocumentation.cs new file mode 100644 index 0000000..0d74ba1 --- /dev/null +++ b/src/src/Build/Schema/SchemaDocumentation.cs @@ -0,0 +1,94 @@ +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace Purview.Build.Schema; + +/// +/// Reads the tool's XML documentation so the generated schema can carry the same summaries the +/// settings types already declare, instead of a second, drifting copy of the same prose. +/// +static partial class SchemaDocumentation +{ + static readonly IReadOnlyDictionary Empty = new Dictionary(); + + public static IReadOnlyDictionary Load() => Load(typeof(SchemaDocumentation).Assembly); + + public static IReadOnlyDictionary Load(Assembly assembly) + { + var path = Path.Combine(AppContext.BaseDirectory, $"{assembly.GetName().Name}.xml"); + if (!File.Exists(path)) + return Empty; + + var members = XDocument.Load(path).Root?.Element("members")?.Elements("member"); + if (members is null) + return Empty; + + Dictionary documentation = new(StringComparer.Ordinal); + foreach (var member in members) + { + var name = member.Attribute("name")?.Value; + if (name is null || member.Element("summary") is not { } summary) + continue; + + documentation[name] = Flatten(summary); + } + + return documentation; + } + + /// + /// Renders a documentation node as plain text: XML markup is dropped, a cref becomes the + /// name it points at, and runs of whitespace collapse to a single space. + /// + static string Flatten(XElement element) + { + StringBuilder text = new(); + + foreach (var node in element.Nodes()) + { + switch (node) + { + case XText value: + text.Append(value.Value); + break; + case XElement child when child.Name.LocalName is "see" or "seealso": + text.Append(SeeText(child)); + break; + // Block-level children separate from their neighbours; inline ones (, ) do not. + case XElement child when child.Name.LocalName is "item" or "para" or "list" or "br": + text.Append(' ').Append(Flatten(child)); + break; + case XElement child: + text.Append(Flatten(child)); + break; + default: + break; + } + } + + return Whitespace().Replace(text.ToString(), " ").Trim(); + } + + static string SeeText(XElement see) + { + // carries its text on the attribute, not as a cref. + var langword = see.Attribute("langword")?.Value; + return string.IsNullOrEmpty(langword) ? CrefName(see.Attribute("cref")?.Value) : langword; + } + + static string CrefName(string? cref) + { + if (string.IsNullOrEmpty(cref)) + return string.Empty; + + var name = cref.Length > 2 && cref[1] == ':' ? cref[2..] : cref; + var separator = name.LastIndexOf('.'); + + return separator >= 0 ? name[(separator + 1)..] : name; + } + + [GeneratedRegex(@"\s+")] + private static partial Regex Whitespace(); +} diff --git a/src/src/Build/Settings/GitHubSettings.cs b/src/src/Build/Settings/GitHubSettings.cs index 0368528..a359c6f 100644 --- a/src/src/Build/Settings/GitHubSettings.cs +++ b/src/src/Build/Settings/GitHubSettings.cs @@ -6,9 +6,17 @@ public sealed record GitHubSettings { public const string SectionName = "GitHub"; + /// + /// Secret: the GitHub token used to create releases. Prefer the GITHUB_TOKEN environment + /// variable over committing it here. + /// [SecretValue] public string? AccessToken { get; init; } + /// + /// Secret: binds GitHub__GITHUB_TOKEN. Prefer the plain GITHUB_TOKEN environment + /// variable. + /// [SecretValue] [ConfigurationKeyName("GITHUB_TOKEN")] public string? EnvAccessToken { get; init; } diff --git a/src/src/Build/Settings/NuGetSettings.cs b/src/src/Build/Settings/NuGetSettings.cs index 360de93..439faab 100644 --- a/src/src/Build/Settings/NuGetSettings.cs +++ b/src/src/Build/Settings/NuGetSettings.cs @@ -6,9 +6,17 @@ public sealed record NuGetSettings { public const string SectionName = "NuGet"; + /// + /// Secret: the NuGet API key used to push packages. Prefer the NUGET_APIKEY environment + /// variable over committing it here. + /// [SecretValue] public string? APIKey { get; set; } + /// + /// Secret: binds NuGet__NUGET_APIKEY. Prefer the plain NUGET_APIKEY environment + /// variable. + /// [SecretValue] [ConfigurationKeyName("NUGET_APIKEY")] public string? EnvAPIKey { get; set; } diff --git a/src/tests/Build.UnitTests/ConfigSchemaTests.cs b/src/tests/Build.UnitTests/ConfigSchemaTests.cs new file mode 100644 index 0000000..a85e133 --- /dev/null +++ b/src/tests/Build.UnitTests/ConfigSchemaTests.cs @@ -0,0 +1,241 @@ +using System.Text.Json.Nodes; +using Purview.Build.Configuration; +using Purview.Build.Schema; + +namespace Purview.Build; + +/// +/// Pins the generated configuration schema against the settings types, and pins the validation the +/// tool applies when it loads purview-build.json. +/// +/// +/// The schema is generated from the settings types, so it cannot describe a key the tool does not +/// bind. Regenerate it with just schema after deliberately changing the settings; a drift +/// fails here rather than shipping a schema that disagrees with the tool. +/// +public class ConfigSchemaTests +{ + const string SchemaFileName = "purview-build.schema.json"; + + [Test] + public async Task Schema_MatchesTheGeneratedFile() + { + // Arrange + var path = Path.Combine(RepositoryRoot, SchemaFileName); + var generated = ConfigSchemaGenerator.Render(ShippedDefaults()); + + // Act + // Regeneration is deliberately opt-in, exactly as for the release-explain golden file: a + // file that rewrites itself on mismatch records the change instead of reporting it. + if (Environment.GetEnvironmentVariable("PURVIEW_BUILD_UPDATE_SCHEMA") == "1") + await File.WriteAllTextAsync(path, generated); + + // Assert + await Assert + .That(File.Exists(path)) + .IsTrue() + .Because($"the schema '{path}' must exist; regenerate it with `just schema` if it was removed."); + + await Assert + .That(Normalise(await File.ReadAllTextAsync(path))) + .IsEqualTo(Normalise(generated)) + .Because("the schema is generated from the settings types; regenerate it with `just schema`."); + } + + [Test] + public async Task Schema_GivenShippedDefaults_StatesTheEffectiveDefaults() + { + // Arrange + var schema = ConfigSchemaGenerator.Build(ShippedDefaults()); + + // Assert + // The shipped appsettings.json wins over the C# initialisers, so the stated default is the + // value a consumer actually gets, not the one the property initialiser suggests. + await Assert.That(Property(schema, "Build", "ProjectType")["default"]!.GetValue()).IsEqualTo("DotNet"); + await Assert + .That(Property(schema, "PackValidation", "RequireSourceLink")["default"]!.GetValue()) + .IsFalse(); + await Assert + .That(Property(schema, "PackValidation", "RequireDeterministic")["default"]!.GetValue()) + .IsFalse(); + } + + [Test] + public async Task Schema_DescribesEveryNestedSettingsType() + { + // Arrange + var release = Section(schema: ConfigSchemaGenerator.Build(ShippedDefaults()), "Release")[ + "properties" + ]!.AsObject(); + + // Assert + await Assert.That(release.ContainsKey("Eligibility")).IsTrue(); + await Assert.That(release.ContainsKey("Context")).IsTrue(); + await Assert.That(release.ContainsKey("Channels")).IsTrue(); + + var channels = release["Channels"]!.AsObject(); + await Assert.That(channels["additionalProperties"]!["type"]!.GetValue()).IsEqualTo("object"); + + var policies = release["Eligibility"]!["properties"]!["Policies"]!.AsObject(); + await Assert.That(policies["additionalProperties"]!["properties"]!.AsObject().ContainsKey("Rules")).IsTrue(); + } + + [Test] + public void Validate_GivenTheRepositoryConfiguration_Succeeds() => + AssertValid(File.ReadAllText(Path.Combine(RepositoryRoot, "purview-build.json"))); + + [Test] + public void Validate_GivenCommentsAndTrailingCommas_Succeeds() => + // Consuming repositories annotate their configuration with // comments; the binder tolerates + // them, so validation must too. + AssertValid( + """ + { + // The solution to build. + "Build": { "Solution": "src/Product.slnx" }, + } + """ + ); + + [Test] + public void Validate_GivenMetadataKeys_Succeeds() => + // "$schema" drives editor completion; other "$"-prefixed keys are ignored metadata. Both must + // be accepted so the schema can be referenced from the file it validates. + AssertValid( + """ + { + "$schema": "https://raw.githubusercontent.com/purview-dev/build/main/purview-build.schema.json", + "$comment": "Annotated for the next reader.", + "Build": { "Solution": "src/Product.slnx" }, + "$marker": "used by the configuration-resolution scenarios" + } + """ + ); + + [Test] + public async Task Validate_GivenUnknownProperty_FailsNamingTheKey() + { + var error = AssertInvalid("""{ "Build": { "RunPackk": true } }"""); + + await Assert.That(error).Contains("Build.RunPackk"); + await Assert.That(error).Contains("unknown property"); + } + + [Test] + public async Task Validate_GivenUnknownSection_FailsNamingTheKey() + { + var error = AssertInvalid("""{ "Releases": { "Mode": "None" } }"""); + + await Assert.That(error).Contains("Releases"); + await Assert.That(error).Contains("unknown property"); + } + + [Test] + public async Task Validate_GivenWrongType_FailsNamingTheKey() + { + var error = AssertInvalid("""{ "Build": { "RunPack": "yes" } }"""); + + await Assert.That(error).Contains("Build.RunPack"); + } + + [Test] + public async Task Validate_GivenInvalidEnumValue_FailsNamingTheKey() + { + var error = AssertInvalid("""{ "Release": { "Mode": "Nugget" } }"""); + + await Assert.That(error).Contains("Release.Mode"); + } + + [Test] + public async Task Validate_GivenMalformedJson_FailsNamingTheFile() + { + var error = AssertInvalid("{ \"Build\": { \"Solution\": "); + + await Assert.That(error).Contains("is not valid JSON"); + } + + static void AssertValid(string json) + { + var path = WriteTemporary(json); + try + { + JsonConfigFile.Validate(path); + } + catch (InvalidOperationException exception) + { + throw new InvalidOperationException( + $"Expected the configuration to be valid, but validation failed: {exception.Message}", + exception + ); + } + finally + { + File.Delete(path); + } + } + + static string AssertInvalid(string json) + { + var path = WriteTemporary(json); + try + { + try + { + JsonConfigFile.Validate(path); + } + catch (InvalidOperationException exception) + { + return exception.Message; + } + + throw new InvalidOperationException( + $"Expected the configuration to fail validation, but it was accepted:{Environment.NewLine}{json}" + ); + } + finally + { + File.Delete(path); + } + } + + static string WriteTemporary(string json) + { + var path = Path.Combine(Path.GetTempPath(), $"purview-build-schema-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, json); + return path; + } + + static JsonObject ShippedDefaults() => + ConfigSchemaGenerator.LoadShippedDefaults( + Path.Combine(RepositoryRoot, "src", "src", "Build", "appsettings.json") + ) ?? []; + + static JsonObject Section(JsonObject schema, string name) => schema["properties"]![name]!.AsObject(); + + static JsonObject Property(JsonObject schema, string section, string property) => + Section(schema, section)["properties"]![property]!.AsObject(); + + static string Normalise(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim(); + + static string RepositoryRoot { get; } = FindRepositoryRoot(); + + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(Path.Combine(directory.FullName, SchemaFileName)) + && File.Exists(Path.Combine(directory.FullName, "package.json")) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"Could not locate the repository root walking up from '{AppContext.BaseDirectory}'." + ); + } +} diff --git a/src/tests/Build.UnitTests/Infra/ScenarioRepository.cs b/src/tests/Build.UnitTests/Infra/ScenarioRepository.cs index 4c6517e..2bb1d91 100644 --- a/src/tests/Build.UnitTests/Infra/ScenarioRepository.cs +++ b/src/tests/Build.UnitTests/Infra/ScenarioRepository.cs @@ -40,7 +40,11 @@ public ScenarioRepository(ConfigScenario scenario) ); foreach (var relativePath in scenario.Files) - WriteConfigFile(relativePath, malformed: relativePath == scenario.Malformed); + WriteConfigFile( + relativePath, + malformed: relativePath == scenario.Malformed, + invalid: relativePath == scenario.Invalid + ); _userConfigHome = Path.Combine(Root, ".user-config-home"); @@ -97,7 +101,7 @@ public string ReadSolution(string configPath) return document.RootElement.GetProperty("Build").GetProperty("Solution").GetString()!; } - void WriteConfigFile(string relativePath, bool malformed) + void WriteConfigFile(string relativePath, bool malformed, bool invalid) { var path = FullPath(relativePath); Directory.CreateDirectory(Path.GetDirectoryName(path)!); @@ -109,6 +113,17 @@ void WriteConfigFile(string relativePath, bool malformed) return; } + if (invalid) + { + // Parseable, but the schema rejects it: a misspelled key and a wrong type. + File.WriteAllText( + path, + /*lang=json,strict*/ + """{ "Build": { "RunPack": "yes" }, "Release": { "Mdoe": "NuGet" } }""" + ); + return; + } + // The marker is the relative path, so a test can tell WHICH file was loaded, and // Build:Solution is identical everywhere so anchoring can be asserted. var json = $$""" diff --git a/src/tests/Build.UnitTests/Infra/Scenarios.cs b/src/tests/Build.UnitTests/Infra/Scenarios.cs index 9d55cd6..21f30dd 100644 --- a/src/tests/Build.UnitTests/Infra/Scenarios.cs +++ b/src/tests/Build.UnitTests/Infra/Scenarios.cs @@ -114,6 +114,11 @@ public sealed record ConfigScenario /// Relative path of a listed file to write as malformed JSON. public string? Malformed { get; init; } + /// + /// Relative path of a listed file to write as valid JSON that violates the configuration schema. + /// + public string? Invalid { get; init; } + public string? Config { get; init; } public string? Env { get; init; } diff --git a/src/tests/fixtures/config-resolution-scenarios.json b/src/tests/fixtures/config-resolution-scenarios.json index dd9652b..2e84eba 100644 --- a/src/tests/fixtures/config-resolution-scenarios.json +++ b/src/tests/fixtures/config-resolution-scenarios.json @@ -6,7 +6,8 @@ "", "Fields: files (created relative to a throwaway repository root, each containing a marker", "setting), config (--config value), env (PURVIEW_BUILD_CONFIG value), userConfig (--user-config),", - "isLocal (whether the run is treated as local). Expected: resolvedPath (relative to the", + "isLocal (whether the run is treated as local), malformed (a file to write as invalid JSON),", + "invalid (a file to write as schema-violating JSON). Expected: resolvedPath (relative to the", "repository root, or null), shadowWarnings (relative paths), exitCode, and optionally", "userConfigActive and errorContains." ], @@ -230,6 +231,22 @@ "errorContains": "is not valid JSON" } }, + { + "name": "schema-violation-fails-naming-the-key", + "why": "A key the tool cannot bind behaves like an unset setting; validation must fail instead.", + "files": ["purview-build.json"], + "invalid": "purview-build.json", + "config": null, + "env": null, + "userConfig": false, + "isLocal": true, + "expected": { + "resolvedPath": null, + "shadowWarnings": [], + "exitCode": 1, + "errorContains": "does not match the Purview.Build configuration schema" + } + }, { "name": "near-miss-filename-is-warned-not-loaded", "why": "An obvious typo must not read as 'no configuration'.", diff --git a/src/tests/scenarios/run-config-matrix.sh b/src/tests/scenarios/run-config-matrix.sh index c7a2a12..a40cbb3 100644 --- a/src/tests/scenarios/run-config-matrix.sh +++ b/src/tests/scenarios/run-config-matrix.sh @@ -46,6 +46,7 @@ while [ "$index" -lt "$count" ]; do is_local=$(printf '%s' "$scenario" | jqr '.isLocal') user_config_file=$(printf '%s' "$scenario" | jqr '.userConfigFile // false') malformed=$(printf '%s' "$scenario" | jqr '.malformed // ""') + invalid=$(printf '%s' "$scenario" | jqr '.invalid // ""') expected_path=$(printf '%s' "$scenario" | jqr '.expected.resolvedPath // ""') expected_exit=$(printf '%s' "$scenario" | jqr '.expected.exitCode') expected_error=$(printf '%s' "$scenario" | jqr '.expected.errorContains // ""') @@ -62,6 +63,10 @@ while [ "$index" -lt "$count" ]; do mkdir -p "$case_dir/$(dirname "$file")" if [ "$file" = "$malformed" ]; then printf '{ "Build": { "Solution": "src/Product.slnx" \n' > "$case_dir/$file" + elif [ "$file" = "$invalid" ]; then + # Parseable, but the schema rejects it: a misspelled key and a wrong type. + printf '{ "Build": { "RunPack": "yes" }, "Release": { "Mdoe": "NuGet" } }\n' \ + > "$case_dir/$file" else printf '{ "Build": { "Solution": "src/Product.slnx" }, "$marker": "%s" }\n' "$file" \ > "$case_dir/$file" From 8c919895f58ad7edde3df51fd1849a1aaeb655d9 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 7 Oct 2026 10:40:57 +0100 Subject: [PATCH 2/4] chore: bumped version --- purview-build.json => .config/purview-build.json | 0 package.json | 2 +- src/Build.slnx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename purview-build.json => .config/purview-build.json (100%) diff --git a/purview-build.json b/.config/purview-build.json similarity index 100% rename from purview-build.json rename to .config/purview-build.json diff --git a/package.json b/package.json index 70c192b..1834e5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-build", - "version": "0.5.0", + "version": "0.5.1", "private": true, "homepage": "https://purview.dev/projects/build/", "bugs": { diff --git a/src/Build.slnx b/src/Build.slnx index 461c7a8..bd7833f 100644 --- a/src/Build.slnx +++ b/src/Build.slnx @@ -3,7 +3,7 @@ - + From 1b7e02266a330a7663ab44e40a96491faf84471a Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 7 Oct 2026 10:42:32 +0100 Subject: [PATCH 3/4] chore: bumped version --- AGENTS.md | 4 ++-- Justfile | 2 +- purview-build.schema.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 50d6a1f..e6e62a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ This file is the primary instruction set for human and AI agents working in this - `docs/wiki/Local-Development.md` — `just` recipes, `release-explain`, simulation, and the local rehearsal. - `docs/wiki/Migration-*.md` — per-consumer migration guides. - `README.md` — user-facing overview and minimal consumer setup. -- `purview-build.json` — this repository's own pipeline configuration. +- `.config/purview-build.json` — this repository's own pipeline configuration. - `Justfile` — developer recipes (`just --list`). ## Build, test, lint @@ -82,4 +82,4 @@ When changing release behaviour, grep the consuming repositories' `docs/wiki/Rel - "The workflow does not publish to NuGet" — `release-mode: NuGet` runs `PublishNuGetModule` and pushes to `NuGet:FeedUrl`. - "The shared pipeline does not mark the GitHub release with the prerelease flag" — `Release:MarkPrerelease` defaults to `true`. -- "…and attaches the package artifacts" — assets are attached only when the caller passes `upload-artifacts: true`, which no consumer currently does. \ No newline at end of file +- "…and attaches the package artifacts" — assets are attached only when the caller passes `upload-artifacts: true`, which no consumer currently does. diff --git a/Justfile b/Justfile index 73b23f1..4f8656d 100644 --- a/Justfile +++ b/Justfile @@ -92,7 +92,7 @@ pipeline-dogfood *args: dotnet tool install Purview.Build --tool-path "{{ dogfood_tool_path }}" \ --add-source "{{ dogfood_artifacts }}" --version "{{ current_version }}" echo "Running the freshly packed tool against this repository..." - "{{ dogfood_tool_path }}/purview-build" {{ args }} + "{{ dogfood_tool_path }}/.config/purview-build" {{ args }} # Explain the release decision for the working tree, without running any module or mutating anything [group('Release')] diff --git a/purview-build.schema.json b/purview-build.schema.json index 8791a9c..144e73d 100644 --- a/purview-build.schema.json +++ b/purview-build.schema.json @@ -489,4 +489,4 @@ "patternProperties": { "^\\$": true } -} +} \ No newline at end of file From 272e59c22055effae240f49af89b1b28c37a4309 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 7 Oct 2026 10:58:27 +0100 Subject: [PATCH 4/4] fix: fixed tests after moving build config --- src/src/Build/Configuration/JsonConfigFile.cs | 8 ++- src/src/Build/Schema/ConfigSchemaGenerator.cs | 39 +++++----- .../Build.UnitTests/ConfigSchemaTests.cs | 72 ++++++++++++------- .../Build.UnitTests/Infra/GoldenScenario.cs | 6 +- 4 files changed, 76 insertions(+), 49 deletions(-) diff --git a/src/src/Build/Configuration/JsonConfigFile.cs b/src/src/Build/Configuration/JsonConfigFile.cs index d2696da..183c5da 100644 --- a/src/src/Build/Configuration/JsonConfigFile.cs +++ b/src/src/Build/Configuration/JsonConfigFile.cs @@ -1,6 +1,6 @@ -using System.Text.Json; using Json.Schema; using Purview.Build.Schema; +using System.Text.Json; namespace Purview.Build.Configuration; @@ -119,8 +119,10 @@ static IEnumerable Leaves(EvaluationResults results) } foreach (var child in children) - foreach (var leaf in Leaves(child)) - yield return leaf; + { + foreach (var leaf in Leaves(child)) + yield return leaf; + } } static string Location(EvaluationResults result) diff --git a/src/src/Build/Schema/ConfigSchemaGenerator.cs b/src/src/Build/Schema/ConfigSchemaGenerator.cs index f811573..ea9bcab 100644 --- a/src/src/Build/Schema/ConfigSchemaGenerator.cs +++ b/src/src/Build/Schema/ConfigSchemaGenerator.cs @@ -60,7 +60,7 @@ public static JsonObject Build(JsonObject? shippedDefaults = null) { var documentation = SchemaDocumentation.Load(); - JsonObject properties = new(); + JsonObject properties = []; foreach (var (section, type) in Sections) { properties[section] = BuildObject(type, shippedDefaults?[section] as JsonObject, documentation); @@ -101,7 +101,7 @@ IReadOnlyDictionary documentation ) { var instance = Activator.CreateInstance(type); - JsonObject properties = new(); + JsonObject properties = []; foreach (var property in SettingsProperties(type)) { @@ -179,9 +179,7 @@ IReadOnlyDictionary documentation { if (type.IsEnum) { - JsonArray values = new(); - foreach (var name in Enum.GetNames(type)) - values.Add(name); + JsonArray values = [.. Enum.GetNames(type)]; return new JsonObject { ["type"] = "string", ["enum"] = values }; } @@ -220,8 +218,11 @@ IReadOnlyDictionary documentation && type.GetGenericTypeDefinition() == typeof(Dictionary<,>) && type.GetGenericArguments()[0] == typeof(string) ) + { return type.GetGenericArguments()[1]; + } + // The configuration binder can also bind to an interface, so accept that too. It is not worth return null; } @@ -249,25 +250,25 @@ static IEnumerable SettingsProperties(Type type) => case null: return null; case JsonObject obj: - { - JsonObject pruned = new(); - foreach (var (key, value) in obj) { - if (Prune(value) is { } kept) - pruned[key] = kept; + JsonObject pruned = []; + foreach (var (key, value) in obj) + { + if (Prune(value) is { } kept) + pruned[key] = kept; + } + return pruned; } - return pruned; - } case JsonArray array: - { - JsonArray pruned = new(); - foreach (var item in array) { - if (Prune(item) is { } kept) - pruned.Add(kept); + JsonArray pruned = []; + foreach (var item in array) + { + if (Prune(item) is { } kept) + pruned.Add(kept); + } + return pruned; } - return pruned; - } default: return node.DeepClone(); } diff --git a/src/tests/Build.UnitTests/ConfigSchemaTests.cs b/src/tests/Build.UnitTests/ConfigSchemaTests.cs index a85e133..dbd4799 100644 --- a/src/tests/Build.UnitTests/ConfigSchemaTests.cs +++ b/src/tests/Build.UnitTests/ConfigSchemaTests.cs @@ -81,27 +81,37 @@ public async Task Schema_DescribesEveryNestedSettingsType() } [Test] - public void Validate_GivenTheRepositoryConfiguration_Succeeds() => - AssertValid(File.ReadAllText(Path.Combine(RepositoryRoot, "purview-build.json"))); + public async Task Validate_GivenTheRepositoryConfiguration_Succeeds(CancellationToken cancellationToken) + { + var content = await File.ReadAllTextAsync( + Path.Combine(RepositoryRoot, ".config/purview-build.json"), + cancellationToken + ); + + await AssertValidAsync(content, cancellationToken); + } [Test] - public void Validate_GivenCommentsAndTrailingCommas_Succeeds() => + public async Task Validate_GivenCommentsAndTrailingCommas_Succeeds(CancellationToken cancellationToken) => // Consuming repositories annotate their configuration with // comments; the binder tolerates // them, so validation must too. - AssertValid( + await AssertValidAsync( + /*lang=json*/ """ { // The solution to build. "Build": { "Solution": "src/Product.slnx" }, } - """ + """, + cancellationToken ); [Test] - public void Validate_GivenMetadataKeys_Succeeds() => + public async Task Validate_GivenMetadataKeys_Succeeds(CancellationToken cancellationToken) => // "$schema" drives editor completion; other "$"-prefixed keys are ignored metadata. Both must // be accepted so the schema can be referenced from the file it validates. - AssertValid( + await AssertValidAsync( + /*lang=json,strict*/ """ { "$schema": "https://raw.githubusercontent.com/purview-dev/build/main/purview-build.schema.json", @@ -109,54 +119,67 @@ public void Validate_GivenMetadataKeys_Succeeds() => "Build": { "Solution": "src/Product.slnx" }, "$marker": "used by the configuration-resolution scenarios" } - """ + """, + cancellationToken ); [Test] - public async Task Validate_GivenUnknownProperty_FailsNamingTheKey() + public async Task Validate_GivenUnknownProperty_FailsNamingTheKey(CancellationToken cancellationToken) { - var error = AssertInvalid("""{ "Build": { "RunPackk": true } }"""); + var error = await AssertInvalidAsync( /*lang=json,strict*/ + """{ "Build": { "RunPackk": true } }""", + cancellationToken + ); await Assert.That(error).Contains("Build.RunPackk"); await Assert.That(error).Contains("unknown property"); } [Test] - public async Task Validate_GivenUnknownSection_FailsNamingTheKey() + public async Task Validate_GivenUnknownSection_FailsNamingTheKey(CancellationToken cancellationToken) { - var error = AssertInvalid("""{ "Releases": { "Mode": "None" } }"""); + var error = await AssertInvalidAsync( /*lang=json,strict*/ + """{ "Releases": { "Mode": "None" } }""", + cancellationToken + ); await Assert.That(error).Contains("Releases"); await Assert.That(error).Contains("unknown property"); } [Test] - public async Task Validate_GivenWrongType_FailsNamingTheKey() + public async Task Validate_GivenWrongType_FailsNamingTheKey(CancellationToken cancellationToken) { - var error = AssertInvalid("""{ "Build": { "RunPack": "yes" } }"""); + var error = await AssertInvalidAsync( /*lang=json,strict*/ + """{ "Build": { "RunPack": "yes" } }""", + cancellationToken + ); await Assert.That(error).Contains("Build.RunPack"); } [Test] - public async Task Validate_GivenInvalidEnumValue_FailsNamingTheKey() + public async Task Validate_GivenInvalidEnumValue_FailsNamingTheKey(CancellationToken cancellationToken) { - var error = AssertInvalid("""{ "Release": { "Mode": "Nugget" } }"""); + var error = await AssertInvalidAsync( /*lang=json,strict*/ + """{ "Release": { "Mode": "Nugget" } }""", + cancellationToken + ); await Assert.That(error).Contains("Release.Mode"); } [Test] - public async Task Validate_GivenMalformedJson_FailsNamingTheFile() + public async Task Validate_GivenMalformedJson_FailsNamingTheFile(CancellationToken cancellationToken) { - var error = AssertInvalid("{ \"Build\": { \"Solution\": "); + var error = await AssertInvalidAsync("{ \"Build\": { \"Solution\": ", cancellationToken); await Assert.That(error).Contains("is not valid JSON"); } - static void AssertValid(string json) + static async Task AssertValidAsync(string json, CancellationToken cancellationToken) { - var path = WriteTemporary(json); + var path = await WriteTemporaryAsync(json, cancellationToken); try { JsonConfigFile.Validate(path); @@ -174,9 +197,9 @@ static void AssertValid(string json) } } - static string AssertInvalid(string json) + static async Task AssertInvalidAsync(string json, CancellationToken cancellationToken) { - var path = WriteTemporary(json); + var path = await WriteTemporaryAsync(json, cancellationToken); try { try @@ -198,10 +221,11 @@ static string AssertInvalid(string json) } } - static string WriteTemporary(string json) + static async Task WriteTemporaryAsync(string json, CancellationToken cancellationToken) { var path = Path.Combine(Path.GetTempPath(), $"purview-build-schema-{Guid.NewGuid():N}.json"); - File.WriteAllText(path, json); + await File.WriteAllTextAsync(path, json, cancellationToken); + return path; } diff --git a/src/tests/Build.UnitTests/Infra/GoldenScenario.cs b/src/tests/Build.UnitTests/Infra/GoldenScenario.cs index 3df6d73..3d5b4d1 100644 --- a/src/tests/Build.UnitTests/Infra/GoldenScenario.cs +++ b/src/tests/Build.UnitTests/Infra/GoldenScenario.cs @@ -40,7 +40,7 @@ static async Task BuildAsync(string root, CancellationToken { await File.WriteAllTextAsync( Path.Combine(root, "package.json"), - $$"""{ "name": "golden", "version": "{{Version}}" }""", + /*lang=json,strict*/$$"""{ "name": "golden", "version": "{{Version}}" }""", cancellationToken ); @@ -50,9 +50,9 @@ await File.WriteAllTextAsync( var configPath = Path.Combine(root, "purview-build.json"); await File.WriteAllTextAsync( configPath, + /*lang=json,strict*/ $$""" - { - "Build": { "Solution": "src/Product.slnx" }, + {"Build": { "Solution": "src/Product.slnx" }, "Release": { "Mode": "NuGet", "Eligibility": { "Policy": "TrunkReservesMinor" },