Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
351 changes: 307 additions & 44 deletions .editorconfig

Large diffs are not rendered by default.

262 changes: 0 additions & 262 deletions .globalconfig

This file was deleted.

3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
// Configure file associations to languages (e.g. "*.extension": "html"). These have precedence over the default associations of the languages installed.
"files.associations": {
".globalconfig": "editorconfig",
"*.md": "markdown"
"*.md": "markdown",
"*.sarif": "json",
},
// The default character set encoding to use when reading and writing files.
"files.encoding": "utf8",
Expand Down
4 changes: 4 additions & 0 deletions Buildvana.slnx.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<s:String x:Key="/Default/CodeEditing/GenerateMemberBody/DocumentationGenerationKind/@EntryValue">None</s:String>
<s:String x:Key="/Default/CodeEditing/GenerateMemberBody/MethodImplementationKind/@EntryValue">NotCompiledCode</s:String>
<s:Boolean x:Key="/Default/CodeEditing/Intellisense/CodeCompletion/IntelliSenseCompletingCharacters/CSharpCompletingCharacters/UpgradedFromVSSettings/@EntryValue">True</s:Boolean>
<s:String x:Key="/Default/CodeInspection/Highlighting/AnalysisEnabled/@EntryValue">SOLUTION</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/CodeIssueFilter/IssueTypesToHide/=_003CConfigurableSeverity_0020Id_003D_0022AbstractClassConstructorCanBeMadeProtected_0022_0020_002F_003E/@EntryIndexedValue">DoShow</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/CodeIssueFilter/IssueTypesToHide/=_003CConfigurableSeverity_0020Id_003D_0022AmdDependencyPathProblem_0022_0020_002F_003E/@EntryIndexedValue">DoShow</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/CodeIssueFilter/IssueTypesToHide/=_003CConfigurableSeverity_0020Id_003D_0022AnnotateCanBeNullParameter_0022_0020_002F_003E/@EntryIndexedValue">DoShow</s:String>
Expand Down Expand Up @@ -424,6 +425,7 @@
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=AnnotateNotNullTypeMember/@EntryIndexedValue">WARNING</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=ConvertToPrimaryConstructor/@EntryIndexedValue">DO_NOT_SHOW</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=UnusedMember_002EGlobal/@EntryIndexedValue">WARNING</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/SweaWarningsMode/@EntryValue">ShowAndRun</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/VsLightBulbDisplayMode/@EntryValue">MergeVsActionsIntoResharperMenu</s:String>
<s:String x:Key="/Default/CodeInspection/JsInspections/LanguageLevel/@EntryValue">Experimental</s:String>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CommonFormatter/END_OF_LINE/@EntryValue">LF</s:String>
Expand Down Expand Up @@ -487,13 +489,15 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=a0b4bc4d_002Dd13b_002D4a37_002Db37e_002Dc9c6864e4302/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Any" AccessRightKinds="Any" Description="Types and namespaces"&gt;&lt;ElementKinds&gt;&lt;Kind Name="NAMESPACE" /&gt;&lt;Kind Name="CLASS" /&gt;&lt;Kind Name="STRUCT" /&gt;&lt;Kind Name="ENUM" /&gt;&lt;Kind Name="DELEGATE" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb_AaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/Connection/XmlConnectionList/@EntryValue">&lt;Configurator&gt;&lt;ConnectList /&gt;&lt;/Configurator&gt;</s:String>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EdotCover_002EInteractive_002ECore_002EFilterManagement_002EMigration_002EGlobalFilterSettingsManagerMigrateSettings/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EPsiFeatures_002EVisualStudio_002EBackend_002EDaemon_002EHighlightingSettingsMigrator/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpKeepExistingMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpPlaceEmbeddedOnSameLineMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpUseContinuousIndentInsideBracesMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EPredefinedNamingRulesToUserRulesUpgrade/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/GlobalFilterSettingsManager/AppliedDefaultAttributeFilterString/@EntryValue">System.CodeDom.Compiler.GeneratedCodeAttribute</s:String>
<s:String x:Key="/Default/GlobalFilterSettingsManager/AttributeFilterXml/@EntryValue">&lt;data&gt;&lt;AttributeFilter ClassMask="System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute" IsEnabled="True" /&gt;&lt;AttributeFilter ClassMask="System.CodeDom.Compiler.GeneratedCodeAttribute" IsEnabled="True" /&gt;&lt;/data&gt;</s:String>
<s:String x:Key="/Default/Housekeeping/VsHighlighting/VsLightBulbDisplayMode/@EntryValue">MergeVsActionsIntoResharperMenu</s:String>
<s:Boolean x:Key="/Default/ReSpeller/DontCheckInheritedMembers/@EntryValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Agostini/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Arnott/@EntryIndexedValue">True</s:Boolean>
Expand Down
7 changes: 7 additions & 0 deletions Common.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project>

<ItemGroup Condition="'$(UsingMicrosoftNoTargetsSdk)' != 'true'">
<PackageReference Include="JetBrains.Annotations.Sources" />
</ItemGroup>

</Project>
5 changes: 4 additions & 1 deletion schemas/buildvana.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@
"description": "Environment variables forwarded to \u0060dotnet\u0060, keyed by variable name.",
"type": "object",
"additionalProperties": {
"type": "string"
"type": [
"string",
"null"
]
}
}
},
Expand Down
1 change: 0 additions & 1 deletion src/Buildvana.Core.Abstractions/BuildFailedException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;

namespace Buildvana.Core;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,4 @@
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="JetBrains.Annotations.Sources" />
</ItemGroup>

</Project>
10 changes: 8 additions & 2 deletions src/Buildvana.Core.Abstractions/Process/IProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@ public interface IProcessRunner
/// </summary>
/// <param name="executable">The path to (or name of) the executable to run.</param>
/// <param name="args">The arguments to pass to <paramref name="executable"/>.</param>
/// <param name="workingDirectory">The working directory in which to run the process, or <see langword="null"/> to inherit the current process's working directory.</param>
/// <param name="throwOnNonZero">If <see langword="true"/> (the default), a <see cref="BuildFailedException"/> is thrown when the process exits with a non-zero exit code; if <see langword="false"/>, the result is returned regardless of exit code.</param>
/// <param name="environment">Environment variables to apply on top of the inherited environment: each entry adds or
/// overrides a variable, and a <see langword="null"/> value removes that variable from the child process. Pass
/// <see langword="null"/> to run with the current process's environment unchanged.</param>
/// <param name="workingDirectory">The working directory in which to run the process, or <see langword="null"/>
/// to inherit the current process's working directory.</param>
/// <param name="throwOnNonZero">If <see langword="true"/> (the default), a <see cref="BuildFailedException"/> is thrown when
/// the process exits with a non-zero exit code; if <see langword="false"/>, the result is returned regardless of exit code.</param>
/// <param name="onStdout">An optional callback invoked once per line of standard output as it is produced.
/// The full output text is captured into the returned <see cref="ProcessResult"/> regardless.</param>
/// <param name="onStderr">An optional callback invoked once per line of standard error as it is produced.
Expand All @@ -29,6 +34,7 @@ public interface IProcessRunner
Task<ProcessResult> RunAsync(
string executable,
IEnumerable<string> args,
IReadOnlyDictionary<string, string?>? environment = null,
string? workingDirectory = null,
bool throwOnNonZero = true,
Action<string>? onStdout = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

<ItemGroup>
<PackageReference Include="CommunityToolkit.Diagnostics" />
<PackageReference Include="JetBrains.Annotations.Sources" />
</ItemGroup>

</Project>
2 changes: 1 addition & 1 deletion src/Buildvana.Core.Configuration/DotNetInvocationConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ public sealed record DotNetInvocationConfig
/// Gets environment variables forwarded to <c>dotnet</c>, keyed by variable name.
/// </summary>
[Description("Environment variables forwarded to `dotnet`, keyed by variable name.")]
public IReadOnlyDictionary<string, string>? Env { get; init; }
public IReadOnlyDictionary<string, string?>? Env { get; init; }
}
2 changes: 1 addition & 1 deletion src/Buildvana.Core.HomeDirectory/HomeDirectoryDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public static bool TryDiscover(string startDirectory, [MaybeNullWhen(false)] out
{
Guard.IsNotNullOrEmpty(startDirectory);

string? current = Path.GetFullPath(startDirectory);
var current = Path.GetFullPath(startDirectory);
while (current is not null)
{
if (DirectoryContainsMarker(current))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<Title>Buildvana JSON schema</Title>
<Description>Generic JSON schema generation and validation, independent of any specific model. Reports validation failures via JsonSchemaValidationException.</Description>
<Description>Generic JSON schema generation and validation, independent of any specific model.</Description>
<TargetFramework>$(StandardTfm)</TargetFramework>
</PropertyGroup>

Expand Down
4 changes: 1 addition & 3 deletions src/Buildvana.Core.JsonSchema/JsonNullableAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,4 @@ namespace Buildvana.Core.JsonSchema;
/// "unset", and an explicit <c>null</c> is disallowed by the generated schema.</para>
/// </remarks>
[AttributeUsage(AttributeTargets.Property)]
public sealed class JsonNullableAttribute : Attribute
{
}
public sealed class JsonNullableAttribute : Attribute;
106 changes: 97 additions & 9 deletions src/Buildvana.Core.JsonSchema/JsonSchemaGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@
/// <remarks>
/// <para>The same <see cref="JsonSerializerOptions"/> should drive both generation and deserialization, so the
/// schema always describes exactly what the deserializer accepts.</para>
/// <para><see cref="System.Text.Json"/> marks every reference-type dictionary value and collection element
/// nullable regardless of how the model annotates it, so the generator reconciles that against the declared
/// nullability read from the owning property or field via <see cref="NullabilityInfoContext"/>. This requires
/// a member to read the annotations from: when the type being described is <em>itself</em> a dictionary or
/// collection (so its values or elements have no owning member), their declared nullability cannot be
/// recovered and the nullability emitted by the exporter is kept as-is. Wrap such a type in a containing
/// object property to control the nullability of its values or elements.</para>
/// </remarks>
public static class JsonSchemaGenerator
{
Expand All @@ -44,7 +51,11 @@
ArgumentNullException.ThrowIfNull(type);
ArgumentNullException.ThrowIfNull(options);

var exporterOptions = new JsonSchemaExporterOptions { TransformSchemaNode = TransformSchemaNode };
var nullabilityContext = new NullabilityInfoContext();
var exporterOptions = new JsonSchemaExporterOptions
{
TransformSchemaNode = (context, schema) => TransformSchemaNode(context, schema, nullabilityContext),
};
var schema = options.GetJsonSchemaAsNode(type, exporterOptions);

// Declare the dialect and (optionally) a title so editors recognize and label the document.
Expand All @@ -60,33 +71,110 @@
return schema;
}

private static JsonNode TransformSchemaNode(JsonSchemaExporterContext context, JsonNode schema)
private static JsonNode TransformSchemaNode(
JsonSchemaExporterContext context,
JsonNode schema,
NullabilityInfoContext nullabilityContext)
{
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.
// Strip the "null" the exporter adds to a property's own type: a nullable property means "optional"
// (an absent key already expresses "unset"), so an explicit null is redundant unless the property
// opts in with [JsonNullable]. Value and element nodes skip this — their nullability is reconciled
// by the owning property below, because the exporter marks every reference-type value or element
// nullable regardless of how the model actually declares it.
var isValueOrElement = context.PropertyInfo is null && !context.Path.IsEmpty;
var keepNull = attributeProvider?.IsDefined(typeof(JsonNullableAttribute), inherit: true) ?? false;
if (!keepNull && schema is JsonObject nullableSchema)
if (!isValueOrElement && !keepNull && schema is JsonObject ownSchema)
{
RemoveNullFromType(nullableSchema);
RemoveNullFromEnum(nullableSchema);
RemoveNullFromType(ownSchema);
RemoveNullFromEnum(ownSchema);
}

// 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.
// Reconcile the nullability the exporter put on this property's values and elements with what the
// model actually declares (string vs string?), recursing through nested generics. This runs before
// ConstrainKeys so the keys it clones inherit the corrected value schema.
if (context.PropertyInfo?.AttributeProvider is MemberInfo member && schema is JsonObject propertySchema)
{
var nullability = CreateNullabilityInfo(nullabilityContext, member);
if (nullability is not null)
{
ReconcileValueNullability(propertySchema, nullability);
}
}

// Close a dictionary to a fixed set of keys when the property carries [JsonAllowedKeys].
if (TryGetAllowedKeys(attributeProvider, out var keys) && schema is JsonObject dictionarySchema)
{
ConstrainKeys(dictionarySchema, keys);
}

return schema;
}

Check notice on line 118 in src/Buildvana.Core.JsonSchema/JsonSchemaGenerator.cs

View check run for this annotation

codefactor.io / CodeFactor

src/Buildvana.Core.JsonSchema/JsonSchemaGenerator.cs#L74-L118

Complex Method
private static NullabilityInfo? CreateNullabilityInfo(NullabilityInfoContext context, MemberInfo member)
=> member switch
{
PropertyInfo property => context.Create(property),
FieldInfo field => context.Create(field),
_ => null,
};

// Walks a property schema's value ("additionalProperties") and element ("items") subschemas alongside
// the matching nullability metadata, keeping "null" only where the model declares the value or element
// nullable. Recurses so nested generics (a dictionary of lists, say) are handled at every level.
private static void ReconcileValueNullability(JsonObject schema, NullabilityInfo nullability)
{
if (schema["additionalProperties"] is JsonObject valueSchema)
{
ApplyDeclaredNullability(valueSchema, GetValueNullability(nullability));
}

if (schema["items"] is JsonObject itemSchema)
{
ApplyDeclaredNullability(itemSchema, GetElementNullability(nullability));
}
}

private static void ApplyDeclaredNullability(JsonObject schema, NullabilityInfo? nullability)
{
if (nullability is null)
{
return;
}

if (nullability.ReadState != NullabilityState.Nullable)
{
RemoveNullFromType(schema);
RemoveNullFromEnum(schema);
}

ReconcileValueNullability(schema, nullability);
}

// The value type of a dictionary is its last generic argument (IReadOnlyDictionary<TKey, TValue>).
private static NullabilityInfo? GetValueNullability(NullabilityInfo nullability)
{
var args = nullability.GenericTypeArguments;
return args.Length > 0 ? args[^1] : null;
}

// The element type is the array element, or the single generic argument of a collection.
private static NullabilityInfo? GetElementNullability(NullabilityInfo nullability)
{
if (nullability.ElementType is { } elementType)
{
return elementType;
}

var args = nullability.GenericTypeArguments;
return args.Length == 1 ? args[0] : null;
}

// 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)
Expand Down
Loading
Loading