diff --git a/docs/architecture/protocol-and-generation.md b/docs/architecture/protocol-and-generation.md index 11ecbe2b5..073e650ff 100644 --- a/docs/architecture/protocol-and-generation.md +++ b/docs/architecture/protocol-and-generation.md @@ -281,6 +281,10 @@ member makes its object branch uninhabitable. Tagged unions preserve such a bran impossible tag in their bound plan, emit no dead public variant for it, and refuse that tag during dispatch instead of routing it through ADR-0009's unknown carrier (ADR-0015). +An unmarked union of a free-form object and an array of unrestricted values binds to +`JsonElement`, so both the object and the array that Effect's object keyword admits remain +materializable. + ## Version-skew tolerance - Every tagged union deserializes an unrecognized discriminator into that union's explicit unknown diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs index c215f70f5..098db6bd6 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs @@ -11,6 +11,156 @@ namespace OpenCode.Sdk.Tools.Tests.Generator.Binding; public sealed class SpecBinderTests { + private static readonly string[] ParentScenarioLeaves = ["First", "Second", "Other", "Another"]; + + [Test] + public async Task Bind_Should_Keep_The_Error_Union_Independent_Of_A_Component_With_The_Same_Key() + { + var document = await IngestAsync(UnionScenario(spec => + { + AddTaggedError(spec, "OpenCodeError", "first"); + AddTaggedError(spec, "SecondError", "second"); + spec.WithSchema("Feed", schema => schema.AnyOf(first => first.Ref("OpenCodeError"), second => second.Ref("SecondError"))); + })); + var plan = new BindingTestHost().Bind(document, Selection("health.get"), Curation(Groups("health", RootGroup()), + schemaNames: [SchemaName("OpenCodeError", "FirstError")])); + var errorUnion = plan.Unions.Single(union => union.Name == "IOpenCodeError"); + + await Assert.That(errorUnion.BaseTypeName).IsNull(); + await Assert.That(errorUnion.FixedMarker).IsNull(); + await Assert.That(errorUnion.Variants.Select(variant => variant.TypeName)) + .IsEquivalentTo(["FirstError", "SecondError"], StringComparer.Ordinal, CollectionOrdering.Matching); + await Assert.That(plan.Models.OfType().Single(model => model.Name == "FirstError").ImplementedUnionNames) + .IsEquivalentTo(["IFeed", "IOpenCodeError"], StringComparer.Ordinal, CollectionOrdering.Matching); + } + + [Test] + public async Task Bind_Should_Refuse_A_Stream_Cause_Union_With_A_Union_Parent_By_Name() + { + var nested = await IngestAsync(NestedParentScenario("Cause", twoParents: false)); + var stream = await IngestAsync(new StreamOperationScenario()); + var operation = stream.Operations.Single(); + var document = nested with + { + Schemas = nested.Schemas.Concat(stream.Schemas).ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal), + Operations = [.. nested.Operations, operation with + { + Responses = [.. operation.Responses.Select(response => response.EffectStream is null ? response : response with + { + EffectStream = response.EffectStream with { CauseSchema = new ArrayNode { Item = new RefNode { Target = "Cause" } } }, + })], + }], + }; + var exception = Assert.Throws(() => new BindingTestHost().Bind(document, + Selection("health.get", StreamOperationScenario.OperationId), + Curation(new Dictionary(StringComparer.Ordinal) + { + ["health"] = RootGroup(), + ["example"] = RootGroup(), + }))); + + await Assert.That(exception.Errors) + .IsEquivalentTo([new BindingError(BindingErrorCategory.Schema, "Cause", + "nested union has multiple parents 'IOuterA' and 'IOpenCodeStreamFailureCause'")], CollectionOrdering.Matching); + } + + [Test] + public async Task Bind_Should_Refuse_A_Nested_Union_With_Two_Parents_By_Name() + { + var document = await IngestAsync(NestedParentScenario("Inner", twoParents: true)); + var exception = RefuseHealth(document); + + await Assert.That(exception.Errors) + .IsEquivalentTo([new BindingError(BindingErrorCategory.Schema, "Inner", + "nested union has multiple parents 'IOuterA' and 'IOuterB'")], CollectionOrdering.Matching); + } + + private static SpecScenario NestedParentScenario(string nestedKey, bool twoParents) => UnionScenario(spec => + { + foreach (var name in ParentScenarioLeaves) + { + spec.WithSchema(name, schema => schema.Type("object") + .Property("type", property => property.Type("string").Enum(name), required: true)); + } + + spec.WithSchema(nestedKey, schema => schema.AnyOf(first => first.Ref("First"), second => second.Ref("Second"))) + .WithSchema("OuterA", schema => schema.AnyOf(inner => inner.Ref(nestedKey), other => other.Ref("Other"))) + .WithSchema("Feed", schema => schema.Type("object") + .Property("a", property => property.Ref("OuterA"), required: true)); + if (twoParents) + { + spec.WithSchema("OuterB", schema => schema.AnyOf(inner => inner.Ref(nestedKey), other => other.Ref("Another"))) + .WithSchema("Feed", schema => schema.Type("object") + .Property("a", property => property.Ref("OuterA"), required: true) + .Property("b", property => property.Ref("OuterB"), required: true)); + } + }); + + [Test] + public async Task Bind_Should_Preserve_Marked_And_Error_Variant_Order() + { + var document = await IngestAsync(UnionScenario(spec => + { + AddTaggedError(spec, "AError", "middle"); + AddTaggedError(spec, "BError", "zebra"); + AddTaggedError(spec, "CError", "apple"); + spec.WithSchema("Feed", schema => schema.AnyOf(first => first.Ref("BError"), middle => middle.Ref("AError"), last => last.Ref("CError"))); + })); + var plan = new BindingTestHost().Bind(document, Selection("health.get"), Curation(Groups("health", RootGroup()), + schemaNames: [SchemaName("AError", "ZuluError"), SchemaName("BError", "AlphaError"), SchemaName("CError", "MikeError")])); + + await Assert.That(plan.Unions.Single(union => union.Name == "IFeed").Variants.Select(variant => variant.TypeName)) + .IsEquivalentTo(["AlphaError", "ZuluError", "MikeError"], StringComparer.Ordinal, CollectionOrdering.Matching); + await Assert.That(plan.Unions.Single(union => union.Name == "IFeed").Variants.Select(variant => variant.Tag)) + .IsEquivalentTo(["zebra", "middle", "apple"], StringComparer.Ordinal, CollectionOrdering.Matching); + await Assert.That(plan.Unions.Single(union => union.Name == "IOpenCodeError").Variants.Select(variant => variant.TypeName)) + .IsEquivalentTo(["AlphaError", "MikeError", "ZuluError"], StringComparer.Ordinal, CollectionOrdering.Matching); + await Assert.That(plan.Unions.Single(union => union.Name == "IOpenCodeError").Variants.Select(variant => variant.Tag)) + .IsEquivalentTo(["zebra", "apple", "middle"], StringComparer.Ordinal, CollectionOrdering.Matching); + } + + [Test] + public async Task Bind_Should_Retain_Per_Schema_Error_Variants_And_Refusals() + { + var document = await IngestAsync(UnionScenario(spec => + { + AddTaggedError(spec, "AAdmitted", "admitted"); + AddTaggedError(spec, "BUnnamed", "unnamed"); + AddTaggedError(spec, "CBadMarker", "bad"); + spec.WithSchema("Feed", schema => schema.Type("object") + .Property("first", property => property.Ref("AAdmitted")) + .Property("second", property => property.Ref("BUnnamed")) + .Property("third", property => property.Ref("CBadMarker"))); + })); + var schemas = document.Schemas.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + // Exercise the error binder's refusal contract over owned IR, after projection. + schemas["CBadMarker"] = (ObjectNode)schemas["CBadMarker"] with { LiteralMarkers = [] }; + document = document with { Schemas = schemas }; + var errors = new BindingErrorCollector(); + var reachable = new ReachableSchemaCollector().Collect(document, document.Operations, errors); + var names = new Dictionary(StringComparer.Ordinal) { ["AAdmitted"] = "AdmittedError", ["CBadMarker"] = "BadMarkerError" }; + var result = new ErrorUnionBinder(new SchemaBindingContext(document, reachable, + reachable.ResponseRootKeys.ToHashSet(StringComparer.Ordinal), reachable.StreamCauseKeys.ToHashSet(StringComparer.Ordinal), names, + new TypePlanBinder(schemas, names, errors), new SchemaInhabitationPolicy(schemas), errors)).Bind(); + var expectedRefusals = new Dictionary(StringComparer.Ordinal) + { + ["BUnnamed"] = new(BindingErrorCategory.Naming, "BUnnamed", "error schema has no unique C# type name"), + ["CBadMarker"] = new(BindingErrorCategory.Schema, "CBadMarker", "a tagged error must declare exactly one required '_tag' literal"), + }; + + await Assert.That(result.Variants).IsEquivalentTo(new Dictionary(StringComparer.Ordinal) + { + ["AAdmitted"] = new() { TypeName = "AdmittedError", Tag = "admitted", MarkerWireName = "_tag" }, + }, CollectionOrdering.Matching); + await Assert.That(result.Refusals).IsEquivalentTo(expectedRefusals, CollectionOrdering.Matching); + await Assert.That(Assert.Throws(errors.ThrowIfAny).Errors) + .IsEquivalentTo(expectedRefusals.Values, CollectionOrdering.Matching); + } + + private static void AddTaggedError(SpecDocumentBuilder spec, string key, string tag) => spec + .WithSchema(key, schema => schema.Type("object") + .Property("_tag", property => property.Type("string").Enum(tag), required: true)); + public static IEnumerable GroupRefusals() => GroupRefusalRows().Select(static row => new[] { row.fault, row.row, row.expected }); private static IEnumerable<(string fault, object? row, BindingError[] expected)> GroupRefusalRows() => @@ -1520,7 +1670,7 @@ public async Task Bind_Should_Refuse_A_Structural_Union_Arm_That_Is_A_Base64_Str } [Test] - public async Task Bind_Should_Read_An_Object_Or_Array_Union_As_An_Unspecified_Object() + public async Task Bind_Should_Read_An_Object_Or_Array_Union_As_JsonElement() { var document = await IngestAsync(SpecScenario.Define(spec => spec .WithSchema("ItemInfo", schema => schema @@ -1542,7 +1692,12 @@ public async Task Bind_Should_Read_An_Object_Or_Array_Union_As_An_Unspecified_Ob var item = plan.Models.OfType().Single(static model => model.Name == "ItemInfo"); var data = item.Properties.Single(static property => property.WireName == "payload").Type; - await Assert.That(data).IsTypeOf(); + await Assert.That(data).IsEqualTo(new NamedTypeReferencePlan + { + Name = "JsonElement", + IsNullable = false, + JsonNullRepresentation = JsonNullRepresentation.InBand, + }); } [Test] diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/UnionMemberHoistBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/UnionMemberHoistBinderTests.cs index 75545b8ff..7fdb0bb1a 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/UnionMemberHoistBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/UnionMemberHoistBinderTests.cs @@ -8,6 +8,21 @@ namespace OpenCode.Sdk.Tools.Tests.Generator.Binding; public sealed class UnionMemberHoistBinderTests { + [Test] + public async Task Bind_Should_Keep_The_First_Carrier_Name_In_Depth_Then_Ordinal_Union_Order() + { + var plan = Hoist( + [Union("IBeta", Arm("CreatedEvent"), Arm("DeletedEvent")) with { BaseTypeName = "IZulu" }, + Union("IAlpha", Arm("CreatedEvent"), Arm("DeletedEvent")) with { BaseTypeName = "IZulu" }, + Union("IZulu", Arm("CreatedEvent"), Arm("DeletedEvent"))], + [Variant("CreatedEvent", "created", Property("durable", Named("CreatedEventDurable"), isRequired: true)), + Variant("DeletedEvent", "deleted", Property("durable", Named("DeletedEventDurable"), isRequired: true)), + Envelope("CreatedEventDurable", "1"), Envelope("DeletedEventDurable", "2")]); + + await Assert.That(plan.Interfaces.Select(carrier => carrier.Name)) + .IsEquivalentTo(["IZuluDurable"], StringComparer.Ordinal, CollectionOrdering.Matching); + } + [Test] [Arguments(null, "curation is null")] [Arguments("Owner", "curation names no owner")] diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs index e9493869f..6a93636ff 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs @@ -1,11 +1,73 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using OpenCode.Sdk.Tools.Generator.Binding.Models; using OpenCode.Sdk.Tools.Generator.Emission; using OpenCode.Sdk.Tools.Tests.Generator.Emission.Support; using OpenCode.Sdk.Tools.Tests.Support; +using TUnit.Assertions.Enums; +using static OpenCode.Sdk.Tools.Tests.Support.BindingScenarioData; namespace OpenCode.Sdk.Tools.Tests.Generator.Emission; public sealed class ModelEmitterTests { + [Test] + [Arguments(false, "JsonElement")] + [Arguments(true, "FeedPayload")] + public async Task Emit_Should_Use_JsonElement_For_An_Object_Or_Array_Union(bool typed, string expectedType) + { + var document = await BindingTestHost.IngestAsync(UnionScenario(typed ? ArrangeTypedObjectOrArray : ArrangeUnrestrictedObjectOrArray)); + var plan = new BindingTestHost().Bind(document, Selection("health.get"), Curation(Groups("health", RootGroup()))); + var payload = plan.Models.OfType().Single(model => model.Name == "Feed").Properties.Single(); + var root = await CSharpSyntaxTree.ParseText(EmitterSnapshot.Create(ModelEmitter.Emit(plan))).GetRootAsync(); + var property = root.DescendantNodes().OfType().Single(record => record.Identifier.ValueText == "Feed") + .Members.OfType().Single(member => member.Identifier.ValueText == "Payload"); + + await Assert.That(((NamedTypeReferencePlan)payload.Type).Name).IsEqualTo(expectedType); + await Assert.That(property.Type.ToString()).IsEqualTo(expectedType); + await Assert.That(plan.Models.OfType().Select(model => model.Name)) + .IsEquivalentTo(typed ? ["FeedPayload"] : [], StringComparer.Ordinal, CollectionOrdering.Matching); + } + + private static void ArrangeTypedObjectOrArray(SpecDocumentBuilder spec) => spec + .WithSchema("Item", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("Feed", schema => schema.Type("object") + .Property("payload", property => property.AnyOf( + branch => branch.Ref("Item"), branch => branch.Type("array").Items(item => item.Ref("Item"))), required: true)); + + private static void ArrangeUnrestrictedObjectOrArray(SpecDocumentBuilder spec) => spec + .WithSchema("Feed", schema => schema.Type("object") + .Property("payload", property => property.AnyOf( + branch => branch.Type("object"), branch => branch.Type("array")), required: true)); + + [Test] + public async Task Emit_Should_Preserve_Binding_Union_Membership_Order_In_Record_Base_Lists() + { + var document = await BindingTestHost.IngestAsync(UnionScenario(spec => spec + .WithSchema("SharedError", schema => schema.Type("object") + .Property("_tag", property => property.Type("string").Enum("shared"), required: true)) + .WithSchema("Other", schema => schema.Type("object") + .Property("_tag", property => property.Type("string").Enum("other"), required: true)) + .WithSchema("AOuter", schema => schema.AnyOf(branch => branch.Ref("SharedError"), branch => branch.Ref("Other"))) + .WithSchema("ZOuter", schema => schema.OneOf(branch => branch.Ref("SharedError"), branch => branch.Ref("Other"))) + .WithSchema("Feed", schema => schema.Type("object") + .Property("first", property => property.Ref("AOuter"), required: true) + .Property("last", property => property.Ref("ZOuter"), required: true)))); + var plan = new BindingTestHost().Bind(document, Selection("health.get"), + Curation(Groups("health", RootGroup()), schemaNames: [SchemaName("AOuter", "IZulu"), SchemaName("ZOuter", "IAlpha")])); + var model = plan.Models.OfType().Single(model => model.Name == "SharedError"); + var source = EmitterSnapshot.Create(ModelEmitter.Emit(plan)); + var root = await CSharpSyntaxTree.ParseText(source).GetRootAsync(); + var record = root.DescendantNodes().OfType() + .Single(record => record.Identifier.ValueText == "SharedError"); + + await Assert.That(record.BaseList!.Types.Select(type => type.Type.ToString())) + .IsEquivalentTo(["IZulu", "IAlpha", "IOpenCodeError"], StringComparer.Ordinal, CollectionOrdering.Matching); + await Assert.That(model.ImplementedUnionNames) + .IsEquivalentTo(["IZulu", "IAlpha", "IOpenCodeError"], StringComparer.Ordinal, CollectionOrdering.Matching); + } + [Test] public async Task Emit_Should_Choose_The_Exact_First_Word_Article() { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs index 101e55d35..9174f6644 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs @@ -6,6 +6,12 @@ namespace OpenCode.Sdk.Tools.Tests.Support; internal static class BindingScenarioData { + public static SpecScenario UnionScenario(Action arrange) => SpecScenario.Define(spec => + { + arrange(spec); + spec.WithOperation("health.get", configure: operation => operation.Response(200, "application/json", schema => schema.Ref("Feed"))); + }); + public static async Task BindRefusalsAsync(SpecScenario scenario, GenerationCuration curation, params string[] operations) { var document = await BindingTestHost.IngestAsync(scenario); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs index b43e76b18..1b1972347 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs @@ -8,12 +8,14 @@ internal sealed class BindingErrorCollector public int Count => _errors.Count; - public void Add(BindingErrorCategory category, string subject, string problem, BindingErrorKind kind = BindingErrorKind.Unspecified) + public BindingError Add(BindingErrorCategory category, string subject, string problem, BindingErrorKind kind = BindingErrorKind.Unspecified) { ArgumentException.ThrowIfNullOrWhiteSpace(subject); ArgumentException.ThrowIfNullOrWhiteSpace(problem); - _errors.Add(new BindingError(category, subject, problem) { Kind = kind }); + var error = new BindingError(category, subject, problem) { Kind = kind }; + _errors.Add(error); + return error; } public void ThrowIfAny() diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorMarkerPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorMarkerPolicy.cs index c97c8a055..01f05b03a 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorMarkerPolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorMarkerPolicy.cs @@ -1,4 +1,5 @@ using OpenCode.Sdk.Tools.Generator.Ingestion.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Projection; namespace OpenCode.Sdk.Tools.Generator.Binding; @@ -7,18 +8,25 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; /// binder, the per-status error map, and the emitted converter never spell it themselves: every /// typed error is tagged by a required literal _tag. A schema without one is refused by /// name, and a schema that carries a required literal name instead is refused as the -/// {name, data} error dialect, which the generator does not admit. The same marker is -/// spelled once more in Ingestion/Projection/ErrorStyleClassifier, which recognizes it -/// from a schema's properties; the two must agree. +/// {name, data} error dialect, which the generator does not admit. /// internal static class ErrorMarkerPolicy { /// The wire property every typed error carries its tag under. - public const string WireName = "_tag"; + public const string WireName = ErrorStyleClassifier.WireName; /// The concept the error spine's union is named after; stream failure causes share the marker but not the concept. public const string ErrorConceptName = "OpenCodeError"; + /// The C# property exposing the wire tag on every typed error. + public const string MarkerMemberName = "Tag"; + + /// The shared interface implemented by every admitted error variant. + public const string InterfaceTypeName = "I" + ErrorConceptName; + + /// The carrier preserving errors whose tags this pin does not declare. + public const string UnknownTypeName = "Unknown" + ErrorConceptName; + private const string NameDialectWireName = "name"; /// diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorUnionBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorUnionBinder.cs new file mode 100644 index 000000000..1a86f365f --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorUnionBinder.cs @@ -0,0 +1,94 @@ +using System.Collections.ObjectModel; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal sealed class ErrorUnionBinder(SchemaBindingContext context) +{ + private readonly SchemaBindingContext _context = context; + + public ErrorUnionBindingResult Bind() + { + var errorsInClosure = new List>(); + foreach (var key in _context.Reachable.GraphKeys) + { + if (!_context.ResponseRootKeys.Contains(key) + && !_context.StreamCauseKeys.Contains(key) + && _context.Document.Schemas.TryGetValue(key, out var schema) + && schema is ObjectNode { ErrorStyle: not ErrorStyle.None } objectNode) + { + errorsInClosure.Add(new KeyValuePair(key, objectNode)); + } + } + + var variants = new List(errorsInClosure.Count); + var edges = new List(); + var admitted = new Dictionary(StringComparer.Ordinal); + var refused = new Dictionary(StringComparer.Ordinal); + foreach (var (key, objectNode) in errorsInClosure.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + if (!_context.TypeNames.TryGetValue(key, out var typeName)) + { + refused.Add(key, _context.Errors.Add(BindingErrorCategory.Naming, key, "error schema has no unique C# type name")); + continue; + } + + if (ErrorMarkerPolicy.Resolve(objectNode, out var problem) is not { } marker) + { + refused.Add(key, _context.Errors.Add(BindingErrorCategory.Schema, key, problem)); + continue; + } + + edges.Add(new UnionChildEdge(key, ErrorMarkerPolicy.InterfaceTypeName)); + var variant = new UnionVariantPlan + { + TypeName = typeName, + Tag = marker.Value, + MarkerWireName = marker.PropertyName, + }; + variants.Add(variant); + admitted.Add(key, variant); + } + + UnionPlan? plan = null; + if (variants.Count > 0 && HasUniqueErrorTags(variants, _context.Errors)) + { + plan = new UnionPlan + { + Name = ErrorMarkerPolicy.InterfaceTypeName, + ConceptName = ErrorMarkerPolicy.ErrorConceptName, + Namespace = GeneratedNamespace.Models, + UnknownTypeName = ErrorMarkerPolicy.UnknownTypeName, + MarkerWireName = ErrorMarkerPolicy.WireName, + MarkerName = ErrorMarkerPolicy.MarkerMemberName, + MarkerKind = LiteralKind.String, + Variants = [.. variants.OrderBy(static variant => variant.TypeName, StringComparer.Ordinal)], + Description = "Represents a typed error returned by the opencode API.", + }; + } + + return new ErrorUnionBindingResult(new UnionBindingResult(null, plan, edges.AsReadOnly()), + new ReadOnlyDictionary(admitted), new ReadOnlyDictionary(refused)); + } + + /// + /// Duplicate tags would poison dispatch; refuse naming their sorted owners. Structurally + /// identical duplicates can be resolved with a schema alias. + /// + private static bool HasUniqueErrorTags(IReadOnlyList variants, BindingErrorCollector errors) + { + var duplicates = variants + .GroupBy(static variant => variant.Tag, StringComparer.Ordinal) + .Where(static group => group.Skip(1).Any()) + .OrderBy(static group => group.Key, StringComparer.Ordinal) + .ToArray(); + foreach (var group in duplicates) + { + var owners = string.Join(", ", group.Select(static variant => variant.TypeName).Order(StringComparer.Ordinal)); + errors.Add(BindingErrorCategory.Schema, ErrorMarkerPolicy.ErrorConceptName, $"multiple error schemas declare tag '{group.Key}' ({owners})"); + } + + return duplicates.Length is 0; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/MarkedUnionBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/MarkedUnionBinder.cs new file mode 100644 index 000000000..8f560432c --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/MarkedUnionBinder.cs @@ -0,0 +1,321 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal sealed class MarkedUnionBinder(SchemaBindingContext context, UnionDiscriminatorSelector discriminators) +{ + private static readonly IReadOnlyList NoPrefixMarkers = []; + private readonly SchemaBindingContext _context = context; + private readonly UnionDiscriminatorSelector _discriminators = discriminators; + + public UnionBindingResult Bind(string key, string name, UnionNode union) + { + var edges = new List(); + var plan = BindUnion(name, key, union, edges); + return new UnionBindingResult(key, plan, edges.AsReadOnly()); + } + + private UnionPlan? BindUnion(string name, string key, UnionNode union, ICollection edges) + { + var resolved = ResolveBranches(key, union); + if (resolved is null) + { + return null; + } + + var discriminator = _discriminators.Select(resolved); + if (discriminator is null) + { + _context.Errors.Add(BindingErrorCategory.Schema, key, "marked union branches share no discriminating marker property"); + return null; + } + + var prefixBranches = discriminator.PrefixBranches; + if (prefixBranches.Count > 1) + { + var arms = string.Join(", ", prefixBranches.Select(static branch => $"'{branch.TypeName}'").Order(StringComparer.Ordinal)); + _context.Errors.Add(BindingErrorCategory.Schema, key, $"marked union declares more than one prefix-tagged arm ({arms}); at most one is admitted"); + return null; + } + + var binding = new UnionBindingContext + { + Key = key, + Name = name, + Marker = discriminator.Marker, + ChildEdges = edges, + Errors = _context.Errors, + }; + var literals = BindLiteralVariants(binding, discriminator.LiteralBranches); + UnionPrefixVariantPlan? prefixVariant = null; + if (prefixBranches.Count is 1) + { + prefixVariant = BindPrefixVariant(binding, prefixBranches[0], literals); + if (prefixVariant is null) + { + return null; + } + } + + var marker = discriminator.Marker; + var conceptName = CSharpNamePolicy.ToUnionConceptName(name); + return new UnionPlan + { + Name = name, + ConceptName = conceptName, + Namespace = GeneratedNamespace.Models, + UnknownTypeName = $"Unknown{conceptName}", + MarkerWireName = marker.PropertyName, + MarkerName = CSharpNamePolicy.ToPascalCase(marker.PropertyName), + MarkerKind = marker.Kind, + Variants = literals.Variants, + PrefixVariant = prefixVariant, + KnownImpossibleTags = [.. literals.KnownImpossibleTags.Order(StringComparer.Ordinal)], + Description = union.Description, + }; + } + + /// + /// Turns literal branches into dispatch entries and membership edges. An uninhabited branch + /// contributes its tag to the known-impossible set instead of a variant. + /// + private static UnionLiteralVariants BindLiteralVariants(UnionBindingContext context, IReadOnlyList branches) + { + var marker = context.Marker; + var variants = new List(branches.Count); + var knownImpossibleTags = new List(); + foreach (var branch in branches) + { + var tag = branch.Markers.First(candidate => + string.Equals(candidate.PropertyName, marker.PropertyName, StringComparison.Ordinal)) + .Value; + if (!branch.IsInhabited) + { + knownImpossibleTags.Add(tag); + continue; + } + + var fixedMarker = branch.IsNestedUnion ? new UnionFixedMarkerPlan + { + WireName = marker.PropertyName, + Name = CSharpNamePolicy.ToPascalCase(marker.PropertyName), + Kind = marker.Kind, + Value = tag, + } : null; + context.ChildEdges.Add(new UnionChildEdge(branch.MemberKey, context.Name, fixedMarker)); + variants.Add(new UnionVariantPlan + { + TypeName = branch.TypeName, + Tag = tag, + MarkerWireName = marker.PropertyName, + IsNestedUnion = branch.IsNestedUnion, + }); + } + + return new UnionLiteralVariants(variants, knownImpossibleTags); + } + + /// + /// Admits one direct, inhabited prefix arm on the union's string marker. Its span cannot + /// overlap a literal tag, because dispatch order must not decide who owns a value. + /// + private static UnionPrefixVariantPlan? BindPrefixVariant(UnionBindingContext context, ResolvedUnionBranch branch, + UnionLiteralVariants literals) + { + var key = context.Key; + var marker = context.Marker; + var errors = context.Errors; + // Only an object carries prefix markers; a promoted inline object's key distinguishes it + // from a direct component arm. + if (branch.MemberKey.Contains('#', StringComparison.Ordinal)) + { + errors.Add(BindingErrorCategory.Schema, key, + $"prefix-tagged arm '{branch.TypeName}' must be a direct component branch of the marked union, not a promoted inline branch"); + return null; + } + + var prefixMarker = branch.PrefixMarkers.FirstOrDefault(candidate => + string.Equals(candidate.PropertyName, marker.PropertyName, StringComparison.Ordinal)); + if (prefixMarker is null) + { + errors.Add(BindingErrorCategory.Schema, key, + $"prefix-tagged arm '{branch.TypeName}' tags '{branch.PrefixMarkers[0].PropertyName}' " + + $"but the union discriminates on '{marker.PropertyName}'"); + return null; + } + + if (marker.Kind is not LiteralKind.String) + { + errors.Add(BindingErrorCategory.Schema, key, + $"prefix-tagged arm '{branch.TypeName}' requires a string marker; the union discriminates on a '{marker.Kind}' marker"); + return null; + } + + if (!branch.IsInhabited) + { + errors.Add(BindingErrorCategory.Schema, key, $"prefix-tagged arm '{branch.TypeName}' admits no JSON value"); + return null; + } + + var covered = literals.Variants.FirstOrDefault(variant => variant.Tag.StartsWith(prefixMarker.Prefix, StringComparison.Ordinal)); + var coveredTag = covered?.Tag + ?? literals.KnownImpossibleTags.FirstOrDefault(tag => tag.StartsWith(prefixMarker.Prefix, StringComparison.Ordinal)); + if (coveredTag is not null) + { + errors.Add(BindingErrorCategory.Schema, key, + $"prefix-tagged arm '{branch.TypeName}' prefix '{prefixMarker.Prefix}' " + + $"is a prefix of literal tag '{coveredTag}' ('{covered?.TypeName ?? "(known-impossible)"}')"); + return null; + } + + context.ChildEdges.Add(new UnionChildEdge(branch.MemberKey, context.Name)); + return new UnionPrefixVariantPlan + { + TypeName = branch.TypeName, + Prefix = prefixMarker.Prefix, + MarkerWireName = marker.PropertyName, + }; + } + + private List? ResolveBranches(string key, UnionNode union) + { + var graph = _context.Document.Schemas; + var names = _context.TypeNames; + var inhabitation = _context.Inhabitation; + var errors = _context.Errors; + var resolved = new List(union.Branches.Count); + foreach (var branch in union.Branches) + { + if (branch is not RefNode reference + || !graph.TryGetValue(reference.Target, out var target) + || !names.TryGetValue(reference.Target, out var typeName)) + { + errors.Add(BindingErrorCategory.Schema, key, + "marked union branch must reference a named object or nested marked union with a literal or prefix marker"); + continue; + } + + // A nested prefix arm's converter never sees the outer marker. + if (target is UnionNode nestedUnion && FindPrefixLeaf(nestedUnion) is { } prefixLeaf) + { + errors.Add(BindingErrorCategory.Schema, key, + $"prefix-tagged arm '{prefixLeaf}' must be a direct component branch of the marked union, " + + $"not a branch of nested union '{typeName}'"); + continue; + } + + // Uniform markers make the nested union one tag; otherwise dispatch spans its leaves. + if (target is UnionNode { Classification: UnionClassification.Marked } nested + && ResolveUniformNestedMarkers(nested, graph) is not { Count: > 0 }) + { + var spanned = ResolveSpannedBranches(nested, reference.Target); + if (spanned is null) + { + errors.Add(BindingErrorCategory.Schema, key, + "marked union branch must reference a named object or nested marked union with a literal marker"); + continue; + } + + resolved.AddRange(spanned); + continue; + } + + var markers = target switch + { + ObjectNode { LiteralMarkers.Count: > 0 } objectNode => objectNode.LiteralMarkers, + UnionNode { Classification: UnionClassification.Marked } uniform => ResolveUniformNestedMarkers(uniform, graph), + _ => null, + }; + var prefixMarkers = target is ObjectNode prefixed ? prefixed.PrefixMarkers : NoPrefixMarkers; + if (markers is not { Count: > 0 } && prefixMarkers.Count is 0) + { + errors.Add(BindingErrorCategory.Schema, key, + "marked union branch must reference a named object or nested marked union with a literal or prefix marker"); + continue; + } + + resolved.Add(new ResolvedUnionBranch(typeName, markers ?? [], prefixMarkers, + target is UnionNode, reference.Target, inhabitation.IsInhabited(target))); + } + + // A spanning branch contributes several entries under one member key. + var answered = resolved.Select(static branch => branch.MemberKey).Distinct(StringComparer.Ordinal).Count(); + return answered == union.Branches.Count ? resolved : null; + } + + /// Expands a marker-spanning nested union into the leaves the outer dispatches to. + private List? ResolveSpannedBranches(UnionNode nested, string nestedKey) + { + var result = new List(nested.Branches.Count); + foreach (var branch in nested.Branches) + { + // Only one level is admitted: a spanning leaf must itself be a marked object. + if (branch is not RefNode reference + || !_context.Document.Schemas.TryGetValue(reference.Target, out var target) + || target is not ObjectNode { LiteralMarkers.Count: > 0 } leaf + || !_context.TypeNames.TryGetValue(reference.Target, out var typeName)) + { + return null; + } + + // Prefix leaves are refused before this expansion runs. + result.Add(new ResolvedUnionBranch(typeName, leaf.LiteralMarkers, PrefixMarkers: [], + IsNestedUnion: false, nestedKey, _context.Inhabitation.IsInhabited(leaf))); + } + + return result; + } + + /// + /// Names a nested leaf tagging its own discriminating property with a prefix. Prefixes on + /// other properties do not make an ordinary literal variant a buried prefix arm. + /// + private string? FindPrefixLeaf(UnionNode nested) + { + var leaves = new List(nested.Branches.Count); + foreach (var branch in nested.Branches) + { + if (branch is not RefNode reference + || !_context.Document.Schemas.TryGetValue(reference.Target, out var target) + || target is not ObjectNode leaf + || !_context.TypeNames.TryGetValue(reference.Target, out var leafName)) + { + return null; + } + + leaves.Add(new ResolvedUnionBranch(leafName, leaf.LiteralMarkers, leaf.PrefixMarkers, + IsNestedUnion: false, reference.Target, _context.Inhabitation.IsInhabited(leaf))); + } + + var arms = leaves.Count is 0 ? null : _discriminators.Select(leaves)?.PrefixBranches; + return arms is { Count: > 0 } ? arms[0].TypeName : null; + } + + /// Finds markers every variant of a nested union fixes to one shared value. + private static IReadOnlyList? ResolveUniformNestedMarkers(UnionNode nested, + IReadOnlyDictionary graph) + { + var variantMarkers = new List>(nested.Branches.Count); + foreach (var branch in nested.Branches) + { + if (branch is not RefNode reference + || !graph.TryGetValue(reference.Target, out var target) + || target is not ObjectNode { LiteralMarkers.Count: > 0 } objectNode) + { + return null; + } + + variantMarkers.Add(objectNode.LiteralMarkers); + } + + return + [ + .. variantMarkers[0] + .Where(candidate => variantMarkers.Skip(1).All(markers => markers.Any(other => + string.Equals(other.PropertyName, candidate.PropertyName, StringComparison.Ordinal) + && other.Kind == candidate.Kind + && string.Equals(other.Value, candidate.Value, StringComparison.Ordinal)))), + ]; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ErrorUnionBindingResult.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ErrorUnionBindingResult.cs new file mode 100644 index 000000000..bfb080d29 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ErrorUnionBindingResult.cs @@ -0,0 +1,6 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record ErrorUnionBindingResult( + UnionBindingResult Union, + IReadOnlyDictionary Variants, + IReadOnlyDictionary Refusals); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingContext.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingContext.cs new file mode 100644 index 000000000..58bd8bc22 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingContext.cs @@ -0,0 +1,13 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record SchemaBindingContext( + SpecDocument Document, + ReachableSchemaSet Reachable, + IReadOnlySet ResponseRootKeys, + IReadOnlySet StreamCauseKeys, + IReadOnlyDictionary TypeNames, + TypePlanBinder Types, + SchemaInhabitationPolicy Inhabitation, + BindingErrorCollector Errors); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs index 7591924a7..1d43c9026 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs @@ -9,4 +9,6 @@ internal sealed record SchemaBindingResult public required IReadOnlyList HoistedInterfaces { get; init; } public required RegistryPlan Registry { get; init; } + + public required ErrorUnionBindingResult ErrorUnion { get; init; } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionBindingContext.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionBindingContext.cs index d84cf158a..f94f18dec 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionBindingContext.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionBindingContext.cs @@ -4,7 +4,7 @@ namespace OpenCode.Sdk.Tools.Generator.Binding.Models; /// /// What every branch of one marked union binds against: the union's identity, the marker it -/// dispatches on, and the accumulators that record membership and fixed outer markers. +/// dispatches on, and the edges this single decision produces. /// internal sealed record UnionBindingContext { @@ -17,11 +17,9 @@ internal sealed record UnionBindingContext /// Gets the literal marker the union dispatches on. public required LiteralMarker Marker { get; init; } - /// Gets the membership accumulator: a schema key to the unions it is a branch of. - public required IDictionary> Inheritance { get; init; } - - /// Gets the accumulator of outer markers a nested union fixes, by its member key. - public required IDictionary FixedMarkers { get; init; } + /// Gets the ordered membership edges produced by this union decision alone. + public required ICollection ChildEdges { get; init; } + /// Gets the collector that publishes branch refusals in traversal order. public required BindingErrorCollector Errors { get; init; } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionBindingResult.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionBindingResult.cs new file mode 100644 index 000000000..6d194abf7 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionBindingResult.cs @@ -0,0 +1,4 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// A union decision; a null schema key keeps the synthetic error union outside schema-node wiring. +internal sealed record UnionBindingResult(string? SchemaKey, UnionPlan? Plan, IReadOnlyList ChildEdges); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionChildEdge.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionChildEdge.cs new file mode 100644 index 000000000..3773a52ec --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionChildEdge.cs @@ -0,0 +1,3 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record UnionChildEdge(string SchemaKey, string ParentTypeName, UnionFixedMarkerPlan? FixedMarker = null); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ObjectModelBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ObjectModelBinder.cs new file mode 100644 index 000000000..843175d39 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ObjectModelBinder.cs @@ -0,0 +1,129 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal sealed class ObjectModelBinder(SchemaBindingContext context) +{ + private readonly SchemaBindingContext _context = context; + + public ObjectModelPlan? Bind(string key, string name, ObjectNode node, IReadOnlyList implementedNames, bool isRequestReachable) + { + var errors = _context.Errors; + if (!TryAdmitAdditionalProperties(key, node, errors, out var emitsExtensionData)) + { + return null; + } + + var properties = new List(node.Properties.Count); + foreach (var property in node.Properties) + { + var type = _context.Types.Bind(key, property.Name, property.Schema); + if (type is null) + { + continue; + } + + // Before optional widening, IsNullable still means exactly "admits JSON null". + // After widening it means "optional or nullable", so the tri-state wrapper must + // read the two facts while they are still separable (ADR-0004). + var emitsOptionalWrapper = isRequestReachable && !property.IsRequired && type.IsNullable; + if (!property.IsRequired) + { + type = type with + { + IsNullable = true + }; + } + + var literal = property.Schema as LiteralNode; + properties.Add(new ModelPropertyPlan + { + WireName = property.Name, + Name = CSharpNamePolicy.ToPascalCase(property.Name), + Type = type, + IsRequired = property.IsRequired, + IsLiteral = literal is not null, + EmitsOptionalWrapper = emitsOptionalWrapper, + LiteralKind = literal?.Kind, + LiteralValue = literal?.Value, + Description = property.Schema.Description ?? AliasDescription(property.Schema, _context.Document.Schemas), + }); + } + + if (properties.Count != node.Properties.Count) + { + return null; + } + + var duplicate = properties.GroupBy(static property => property.Name, StringComparer.Ordinal) + .FirstOrDefault(static group => group.Skip(1).Any()); + if (duplicate is not null) + { + errors.Add(BindingErrorCategory.Naming, key, $"multiple properties map to C# name '{duplicate.Key}'"); + return null; + } + + if (emitsExtensionData && TakesExtensionDataMemberName(key, properties, errors)) + { + return null; + } + + return new ObjectModelPlan + { + Name = name, + Namespace = GeneratedNamespace.Models, + Description = node.Description, + Properties = properties, + ImplementedUnionNames = implementedNames, + EmitsExtensionData = emitsExtensionData, + }; + } + + /// + /// Reads a primitive alias's description when the referencing member has none of its own. + /// + private static string? AliasDescription(SchemaNode schema, IReadOnlyDictionary schemas) => + schema is RefNode reference && schemas.TryGetValue(reference.Target, out var target) && target is PrimitiveNode + ? target.Description + : null; + + /// + /// Only an unrestricted bag rides beside named properties without loss. The out value says + /// whether the admitted object carries extension data. + /// + private static bool TryAdmitAdditionalProperties(string key, ObjectNode node, BindingErrorCollector errors, out bool emitsExtensionData) + { + emitsExtensionData = false; + if (node.AdditionalProperties is not AdditionalPropertiesKind.Schema) + { + return true; + } + + if (node.AdditionalPropertiesSchema is not (UnrestrictedNode or NullableNode { Inner: UnrestrictedNode })) + { + errors.Add(BindingErrorCategory.Schema, string.Concat(key, "/additionalProperties"), + "named properties and typed additional properties cannot be represented without data loss; only an unrestricted bag rides as extension data"); + return false; + } + + emitsExtensionData = true; + return true; + } + + /// A named property must not shadow either extension-data member. + private static bool TakesExtensionDataMemberName(string key, IEnumerable properties, BindingErrorCollector errors) + { + var taken = properties.FirstOrDefault(static property => + string.Equals(property.Name, ObjectModelPlan.ExtensionDataMemberName, StringComparison.Ordinal) + || string.Equals(property.Name, ObjectModelPlan.ExtensionDataBagName, StringComparison.Ordinal)); + if (taken is null) + { + return false; + } + + errors.Add(BindingErrorCategory.Naming, key, + $"a property maps to C# name '{taken.Name}', which the extension-data member takes"); + return true; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs index f956b5ce6..fbbe6e902 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs @@ -115,7 +115,7 @@ private static Dictionary ResolveEffectStreamTypeNames(SpecDocum { if (!document.Schemas.TryGetValue(target, out var branchSchema) || branchSchema is not ObjectNode objectNode - || objectNode.LiteralMarkers.FirstOrDefault(static marker => marker.PropertyName is "_tag") is not { } marker) + || objectNode.LiteralMarkers.FirstOrDefault(static marker => marker.PropertyName is ErrorMarkerPolicy.WireName) is not { } marker) { continue; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs index 76cbd0de3..172e7a709 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs @@ -9,20 +9,13 @@ internal sealed class SchemaPlanBinder( UnionDiscriminatorSelector discriminators, UnionMemberHoistBinder memberHoists) { - /// The prefix-marker list every branch that is not a marked object resolves to. - private static readonly IReadOnlyList NoPrefixMarkers = []; - private readonly StringComparer _comparer = StringComparer.Ordinal; - private readonly StructuralUnionPlanBinder _structuralUnions = structuralUnions ?? throw new ArgumentNullException(nameof(structuralUnions)); - private readonly UnionMembershipValidator _unionMemberships = unionMemberships ?? throw new ArgumentNullException(nameof(unionMemberships)); - private readonly UnionDiscriminatorSelector _discriminators = discriminators ?? throw new ArgumentNullException(nameof(discriminators)); - private readonly UnionMemberHoistBinder _memberHoists = memberHoists ?? throw new ArgumentNullException(nameof(memberHoists)); public SchemaBindingResult Bind(SpecDocument document, ReachableSchemaSet reachable, GenerationCuration curation, @@ -38,15 +31,13 @@ public SchemaBindingResult Bind(SpecDocument document, ReachableSchemaSet reacha var requestKeys = reachable.RequestReachableKeys.ToHashSet(_comparer); var streamCauseKeys = reachable.StreamCauseKeys.ToHashSet(_comparer); var inhabitation = new SchemaInhabitationPolicy(document.Schemas); - var inheritance = new Dictionary>(StringComparer.Ordinal); - var unions = BindExplicitUnions(document, reachable, responseRoots, streamCauseKeys, typeNames, inheritance, inhabitation, errors); - var errorUnion = BindErrorUnion(document, reachable, responseRoots, streamCauseKeys, typeNames, inheritance, errors); - if (errorUnion is not null) - { - unions.Add(errorUnion); - } - var typeBinder = new TypePlanBinder(document.Schemas, typeNames, errors); + var context = new SchemaBindingContext(document, reachable, responseRoots, streamCauseKeys, typeNames, typeBinder, inhabitation, errors); + var decisions = BindExplicitUnions(context, responseRoots); + var errorUnion = new ErrorUnionBinder(context).Bind(); + decisions.Add(errorUnion.Union); + var graph = UnionInheritanceGraph.Create(decisions, streamCauseKeys, errors); + var objects = new ObjectModelBinder(context); var models = new List(); foreach (var key in reachable.GraphKeys) { @@ -63,7 +54,7 @@ public SchemaBindingResult Bind(SpecDocument document, ReachableSchemaSet reacha switch (schema) { case ObjectNode objectNode: - var objectPlan = BindObject(key, name, objectNode, inheritance, requestKeys.Contains(key), document.Schemas, typeBinder, errors); + var objectPlan = objects.Bind(key, name, objectNode, graph.ImplementedUnionNames(key), requestKeys.Contains(key)); if (objectPlan is not null) { models.Add(objectPlan); @@ -86,11 +77,10 @@ when UnstructuredUnionPolicy.Collapse(structural, document.Schemas) is null: } var orderedModels = models.OrderBy(static model => model.Name, _comparer).ToArray(); - var orderedUnions = unions.OrderBy(static union => union.Name, _comparer).ToArray(); + var orderedUnions = graph.Unions.OrderBy(static union => union.Name, _comparer).ToArray(); _unionMemberships.Validate([.. orderedModels.OfType()], orderedUnions, errors); - // Hoisting reads the bound plans and only adds members to them, so it runs after the - // membership wall and before the registry, which never lists a hoisted carrier: the - // carrier is an interface no converter and no source-generated metadata materializes. + // Hoisting adds members after membership validation. Its interfaces never enter the + // registry because no converter or source-generated metadata materializes them. var hoisted = _memberHoists.Bind(orderedUnions, orderedModels, curation, errors); orderedModels = [.. hoisted.Models]; orderedUnions = [.. hoisted.Unions]; @@ -99,6 +89,7 @@ when UnstructuredUnionPolicy.Collapse(structural, document.Schemas) is null: Models = orderedModels, Unions = orderedUnions, HoistedInterfaces = hoisted.Interfaces, + ErrorUnion = errorUnion, Registry = new RegistryPlan { TypeNames = ComposeRegistryNames(orderedModels, orderedUnions), @@ -106,10 +97,25 @@ when UnstructuredUnionPolicy.Collapse(structural, document.Schemas) is null: }; } - /// - /// The types the serializer registry must carry. A hoisted carrier is never among them: it - /// is an interface no converter names and no payload deserializes into. - /// + private List BindExplicitUnions(SchemaBindingContext context, HashSet responseRoots) + { + var markedBinder = new MarkedUnionBinder(context, _discriminators); + var decisions = new List(); + foreach (var key in context.Reachable.GraphKeys) + { + if (!responseRoots.Contains(key) + && context.Document.Schemas.TryGetValue(key, out var schema) + && schema is UnionNode { Classification: UnionClassification.Marked } union + && context.TypeNames.TryGetValue(key, out var name)) + { + decisions.Add(markedBinder.Bind(key, name, union)); + } + } + + return decisions; + } + + /// The materializable types the serializer registry must carry, never hoisted interfaces. private string[] ComposeRegistryNames(IReadOnlyList models, IReadOnlyList unions) => [ .. models @@ -124,634 +130,6 @@ .. models .Order(_comparer), ]; - private List BindExplicitUnions(SpecDocument document, ReachableSchemaSet reachable, HashSet responseRoots, - HashSet streamCauseKeys, IReadOnlyDictionary names, Dictionary> inheritance, - SchemaInhabitationPolicy inhabitation, BindingErrorCollector errors) - { - var plans = new Dictionary(StringComparer.Ordinal); - var fixedMarkers = new Dictionary(StringComparer.Ordinal); - foreach (var key in reachable.GraphKeys) - { - if (responseRoots.Contains(key) - || !document.Schemas.TryGetValue(key, out var schema) - || schema is not UnionNode { Classification: UnionClassification.Marked } union - || !names.TryGetValue(key, out var name)) - { - continue; - } - - var plan = BindUnion(name, key, union, document.Schemas, names, inheritance, fixedMarkers, inhabitation, errors); - if (plan is not null) - { - plans.Add(key, plan); - } - } - - // A nested union learns its base type and fixed outer marker while the outer union - // binds, which can happen after the nested plan was built — the wiring is a post-pass. - var result = new List(plans.Count); - foreach (var (key, plan) in plans) - { - var wired = plan; - if (inheritance.TryGetValue(key, out var baseTypes) && baseTypes.Count is 1) - { - wired = wired with - { - BaseTypeName = baseTypes[0] - }; - } - - if (fixedMarkers.TryGetValue(key, out var fixedMarker)) - { - wired = wired with - { - FixedMarker = fixedMarker - }; - } - - if (streamCauseKeys.Contains(key)) - { - wired = wired with - { - BaseTypeName = EffectStreamTypeNamePolicy.CauseMarkerInterface - }; - } - - result.Add(wired); - } - - return result; - } - - private UnionPlan? BindUnion(string name, string key, UnionNode union, IReadOnlyDictionary graph, - IReadOnlyDictionary names, Dictionary> inheritance, - Dictionary fixedMarkers, SchemaInhabitationPolicy inhabitation, BindingErrorCollector errors) - { - var resolved = ResolveBranches(key, union, graph, names, inhabitation, errors); - if (resolved is null) - { - return null; - } - - var discriminator = _discriminators.Select(resolved); - if (discriminator is null) - { - errors.Add(BindingErrorCategory.Schema, key, "marked union branches share no discriminating marker property"); - return null; - } - - var prefixBranches = discriminator.PrefixBranches; - if (prefixBranches.Count > 1) - { - var arms = string.Join(", ", prefixBranches.Select(static branch => $"'{branch.TypeName}'").Order(StringComparer.Ordinal)); - errors.Add(BindingErrorCategory.Schema, key, $"marked union declares more than one prefix-tagged arm ({arms}); at most one is admitted"); - return null; - } - - var context = new UnionBindingContext - { - Key = key, - Name = name, - Marker = discriminator.Marker, - Inheritance = inheritance, - FixedMarkers = fixedMarkers, - Errors = errors, - }; - var literals = BindLiteralVariants(context, discriminator.LiteralBranches); - UnionPrefixVariantPlan? prefixVariant = null; - if (prefixBranches.Count is 1) - { - prefixVariant = BindPrefixVariant(context, prefixBranches[0], literals); - if (prefixVariant is null) - { - return null; - } - } - - var marker = discriminator.Marker; - var conceptName = CSharpNamePolicy.ToUnionConceptName(name); - return new UnionPlan - { - Name = name, - ConceptName = conceptName, - Namespace = GeneratedNamespace.Models, - UnknownTypeName = $"Unknown{conceptName}", - MarkerWireName = marker.PropertyName, - MarkerName = CSharpNamePolicy.ToPascalCase(marker.PropertyName), - MarkerKind = marker.Kind, - Variants = literals.Variants, - PrefixVariant = prefixVariant, - KnownImpossibleTags = [.. literals.KnownImpossibleTags.Order(StringComparer.Ordinal)], - Description = union.Description, - }; - } - - /// - /// Turns the literal-tagged branches into dispatch entries, recording each one's membership - /// and, for a nested union, the outer marker it fixes. A branch whose schema admits no JSON - /// value contributes its tag to the known-impossible set instead of a variant. - /// - private static UnionLiteralVariants BindLiteralVariants(UnionBindingContext context, IReadOnlyList branches) - { - var marker = context.Marker; - var variants = new List(branches.Count); - var knownImpossibleTags = new List(); - foreach (var branch in branches) - { - var tag = branch.Markers.First(candidate => - string.Equals(candidate.PropertyName, marker.PropertyName, StringComparison.Ordinal)) - .Value; - if (!branch.IsInhabited) - { - knownImpossibleTags.Add(tag); - continue; - } - - AddInheritance(branch.MemberKey, context.Name, context.Inheritance, context.Errors); - if (branch.IsNestedUnion) - { - context.FixedMarkers[branch.MemberKey] = new UnionFixedMarkerPlan - { - WireName = marker.PropertyName, - Name = CSharpNamePolicy.ToPascalCase(marker.PropertyName), - Kind = marker.Kind, - Value = tag, - }; - } - - variants.Add(new UnionVariantPlan - { - TypeName = branch.TypeName, - Tag = tag, - MarkerWireName = marker.PropertyName, - IsNestedUnion = branch.IsNestedUnion, - }); - } - - return new UnionLiteralVariants(variants, knownImpossibleTags); - } - - /// - /// Admits the one prefix-tagged arm of a marked union, or refuses naming the wall it hit. - /// The arm dispatches on the union's own marker after every literal tag, so it must be a - /// direct component branch, tag that same string marker, admit a JSON value, and claim no - /// value a declared literal tag already owns. - /// - private static UnionPrefixVariantPlan? BindPrefixVariant(UnionBindingContext context, ResolvedUnionBranch branch, - UnionLiteralVariants literals) - { - var key = context.Key; - var marker = context.Marker; - var errors = context.Errors; - - // Only an object carries prefix markers, so a nested-union branch can never be the arm; - // a promoted inline object can, and its member key is the only thing that says so. - if (branch.MemberKey.Contains('#', StringComparison.Ordinal)) - { - errors.Add(BindingErrorCategory.Schema, key, - $"prefix-tagged arm '{branch.TypeName}' must be a direct component branch of the marked union, not a promoted inline branch"); - return null; - } - - var prefixMarker = branch.PrefixMarkers.FirstOrDefault(candidate => - string.Equals(candidate.PropertyName, marker.PropertyName, StringComparison.Ordinal)); - if (prefixMarker is null) - { - errors.Add(BindingErrorCategory.Schema, key, - $"prefix-tagged arm '{branch.TypeName}' tags '{branch.PrefixMarkers[0].PropertyName}' " - + $"but the union discriminates on '{marker.PropertyName}'"); - return null; - } - - if (marker.Kind is not LiteralKind.String) - { - errors.Add(BindingErrorCategory.Schema, key, - $"prefix-tagged arm '{branch.TypeName}' requires a string marker; the union discriminates on a '{marker.Kind}' marker"); - return null; - } - - if (!branch.IsInhabited) - { - errors.Add(BindingErrorCategory.Schema, key, $"prefix-tagged arm '{branch.TypeName}' admits no JSON value"); - return null; - } - - // A literal tag inside the arm's span would be claimed by whichever dispatch ran first, - // so the overlap refuses rather than silently ordering one over the other. - var covered = literals.Variants.FirstOrDefault(variant => variant.Tag.StartsWith(prefixMarker.Prefix, StringComparison.Ordinal)); - var coveredTag = covered?.Tag - ?? literals.KnownImpossibleTags.FirstOrDefault(tag => tag.StartsWith(prefixMarker.Prefix, StringComparison.Ordinal)); - if (coveredTag is not null) - { - errors.Add(BindingErrorCategory.Schema, key, - $"prefix-tagged arm '{branch.TypeName}' prefix '{prefixMarker.Prefix}' " - + $"is a prefix of literal tag '{coveredTag}' ('{covered?.TypeName ?? "(known-impossible)"}')"); - return null; - } - - AddInheritance(branch.MemberKey, context.Name, context.Inheritance, errors); - return new UnionPrefixVariantPlan - { - TypeName = branch.TypeName, - Prefix = prefixMarker.Prefix, - MarkerWireName = marker.PropertyName, - }; - } - - private List? ResolveBranches(string key, UnionNode union, IReadOnlyDictionary graph, - IReadOnlyDictionary names, SchemaInhabitationPolicy inhabitation, BindingErrorCollector errors) - { - var resolved = new List(union.Branches.Count); - foreach (var branch in union.Branches) - { - if (branch is not RefNode reference - || !graph.TryGetValue(reference.Target, out var target) - || !names.TryGetValue(reference.Target, out var typeName)) - { - errors.Add( - BindingErrorCategory.Schema, - key, - "marked union branch must reference a named object or nested marked union with a literal or prefix marker"); - continue; - } - - // A prefix tag is matched against the outer union's own marker, so an arm one level - // down would have to be reached through a converter that never sees that marker. - if (target is UnionNode nestedUnion && FindPrefixLeaf(nestedUnion, graph, names, inhabitation) is { } prefixLeaf) - { - errors.Add( - BindingErrorCategory.Schema, - key, - $"prefix-tagged arm '{prefixLeaf}' must be a direct component branch of the marked union, " - + $"not a branch of nested union '{typeName}'"); - continue; - } - - // A nested union that fixes the outer marker behaves like one tag. One that - // discriminates on the outer marker instead spans it, so the outer dispatches - // straight to that union's own leaves rather than through a second converter. - if (target is UnionNode { Classification: UnionClassification.Marked } nested - && ResolveUniformNestedMarkers(nested, graph) is not { Count: > 0 }) - { - var spanned = ResolveSpannedBranches(nested, reference.Target, graph, names, inhabitation); - if (spanned is null) - { - errors.Add( - BindingErrorCategory.Schema, - key, - "marked union branch must reference a named object or nested marked union with a literal marker"); - continue; - } - - resolved.AddRange(spanned); - continue; - } - - var markers = target switch - { - ObjectNode { LiteralMarkers.Count: > 0 } objectNode => objectNode.LiteralMarkers, - UnionNode { Classification: UnionClassification.Marked } uniform => ResolveUniformNestedMarkers(uniform, graph), - _ => null, - }; - var prefixMarkers = target is ObjectNode prefixed ? prefixed.PrefixMarkers : NoPrefixMarkers; - if (markers is not { Count: > 0 } && prefixMarkers.Count is 0) - { - errors.Add( - BindingErrorCategory.Schema, - key, - "marked union branch must reference a named object or nested marked union with a literal or prefix marker"); - continue; - } - - resolved.Add(new ResolvedUnionBranch( - typeName, - markers ?? [], - prefixMarkers, - target is UnionNode, - reference.Target, - inhabitation.IsInhabited(target))); - } - - // A spanning branch contributes several entries under one member key, so the arity - // check counts branches answered rather than entries produced. - var answered = resolved.Select(static branch => branch.MemberKey).Distinct(StringComparer.Ordinal).Count(); - return answered == union.Branches.Count ? resolved : null; - } - - /// Expands a marker-spanning nested union into the leaves the outer dispatches to. - private static List? ResolveSpannedBranches(UnionNode nested, string nestedKey, - IReadOnlyDictionary graph, IReadOnlyDictionary names, - SchemaInhabitationPolicy inhabitation) - { - var result = new List(nested.Branches.Count); - foreach (var branch in nested.Branches) - { - // Only one level is admitted: a leaf of a spanning union must itself be a marked - // object, never another union. - if (branch is not RefNode reference - || !graph.TryGetValue(reference.Target, out var target) - || target is not ObjectNode { LiteralMarkers.Count: > 0 } leaf - || !names.TryGetValue(reference.Target, out var typeName)) - { - return null; - } - - // A prefix leaf under a nested union is refused before the spanning expansion runs, - // so every leaf reached here carries literal markers only. - result.Add(new ResolvedUnionBranch( - typeName, - leaf.LiteralMarkers, - PrefixMarkers: [], - IsNestedUnion: false, - nestedKey, - inhabitation.IsInhabited(leaf))); - } - - return result; - } - - /// - /// Names the leaf that tags the nested union's own discriminating property with a prefix, or - /// null when it has none. The same selection runs here as for the outer union, so a leaf - /// whose prefix markers sit on other properties — a templated identifier beside a literal - /// tag — is an ordinary variant of the nested union rather than an arm buried one level down. - /// - private string? FindPrefixLeaf(UnionNode nested, IReadOnlyDictionary graph, - IReadOnlyDictionary names, SchemaInhabitationPolicy inhabitation) - { - var leaves = new List(nested.Branches.Count); - foreach (var branch in nested.Branches) - { - if (branch is not RefNode reference - || !graph.TryGetValue(reference.Target, out var target) - || target is not ObjectNode leaf - || !names.TryGetValue(reference.Target, out var leafName)) - { - return null; - } - - leaves.Add(new ResolvedUnionBranch( - leafName, - leaf.LiteralMarkers, - leaf.PrefixMarkers, - IsNestedUnion: false, - reference.Target, - inhabitation.IsInhabited(leaf))); - } - - var arms = leaves.Count is 0 ? null : _discriminators.Select(leaves)?.PrefixBranches; - return arms is { Count: > 0 } ? arms[0].TypeName : null; - } - - /// - /// Resolves the markers every variant of a nested union fixes to one shared value — for - /// those properties the whole nested union behaves like a single tag. - /// - private static IReadOnlyList? ResolveUniformNestedMarkers(UnionNode nested, - IReadOnlyDictionary graph) - { - var variantMarkers = new List>(nested.Branches.Count); - foreach (var branch in nested.Branches) - { - if (branch is not RefNode reference - || !graph.TryGetValue(reference.Target, out var target) - || target is not ObjectNode { LiteralMarkers.Count: > 0 } objectNode) - { - return null; - } - - variantMarkers.Add(objectNode.LiteralMarkers); - } - - return - [ - .. variantMarkers[0] - .Where(candidate => variantMarkers - .Skip(1) - .All(markers => markers.Any(other => - string.Equals(other.PropertyName, candidate.PropertyName, StringComparison.Ordinal) - && other.Kind == candidate.Kind - && string.Equals(other.Value, candidate.Value, StringComparison.Ordinal)))), - ]; - } - - private static UnionPlan? BindErrorUnion(SpecDocument document, ReachableSchemaSet reachable, HashSet responseRoots, - HashSet streamCauseKeys, IReadOnlyDictionary names, - IDictionary> inheritance, BindingErrorCollector errors) - { - var errorsInClosure = new List>(); - foreach (var key in reachable.GraphKeys) - { - if (!responseRoots.Contains(key) - && !streamCauseKeys.Contains(key) - && document.Schemas.TryGetValue(key, out var schema) - && schema is ObjectNode { ErrorStyle: not ErrorStyle.None } objectNode) - { - errorsInClosure.Add(new KeyValuePair(key, objectNode)); - } - } - - if (errorsInClosure.Count is 0) - { - return null; - } - - var variants = new List(errorsInClosure.Count); - foreach (var (key, objectNode) in errorsInClosure.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) - { - if (!names.TryGetValue(key, out var typeName)) - { - errors.Add(BindingErrorCategory.Naming, key, "error schema has no unique C# type name"); - continue; - } - - if (ErrorMarkerPolicy.Resolve(objectNode, out var problem) is not { } marker) - { - errors.Add(BindingErrorCategory.Schema, key, problem); - continue; - } - - AddInheritance(key, CSharpNamePolicy.ToUnionInterfaceName("OpenCodeError"), inheritance, errors); - variants.Add(new UnionVariantPlan - { - TypeName = typeName, - Tag = marker.Value, - MarkerWireName = marker.PropertyName, - }); - } - - if (variants.Count is 0 || !HasUniqueErrorTags(variants, errors)) - { - return null; - } - - return new UnionPlan - { - Name = CSharpNamePolicy.ToUnionInterfaceName(ErrorMarkerPolicy.ErrorConceptName), - ConceptName = ErrorMarkerPolicy.ErrorConceptName, - Namespace = GeneratedNamespace.Models, - UnknownTypeName = "UnknownOpenCodeError", - MarkerWireName = ErrorMarkerPolicy.WireName, - MarkerName = "Tag", - MarkerKind = LiteralKind.String, - Variants = [.. variants.OrderBy(static variant => variant.TypeName, StringComparer.Ordinal)], - Description = "Represents a typed error returned by the opencode API.", - }; - } - - /// - /// One tag owned by two closure types would poison the converter's dispatch map at its - /// first use, and the reader's per-status filter reads the same one member, so a collision - /// refuses naming its owners; structurally identical duplicates - /// have the schema-alias escape. Returns whether the tags are unique - the caller reads it - /// as the go-ahead, so the name says what true means. - /// - private static bool HasUniqueErrorTags(IReadOnlyList variants, BindingErrorCollector errors) - { - var duplicates = variants - .GroupBy(static variant => variant.Tag, StringComparer.Ordinal) - .Where(static group => group.Skip(1).Any()) - .OrderBy(static group => group.Key, StringComparer.Ordinal) - .ToArray(); - foreach (var group in duplicates) - { - var owners = string.Join(", ", group.Select(static variant => variant.TypeName).Order(StringComparer.Ordinal)); - errors.Add(BindingErrorCategory.Schema, "OpenCodeError", $"multiple error schemas declare tag '{group.Key}' ({owners})"); - } - - return duplicates.Length is 0; - } - - /// - /// Reads the description of a named primitive alias (an upstream ID or cursor type) a member - /// references, since such a member carries no description of its own. - /// - private static string? AliasDescription(SchemaNode schema, IReadOnlyDictionary schemas) => - schema is RefNode reference && schemas.TryGetValue(reference.Target, out var target) && target is PrimitiveNode - ? target.Description - : null; - - private static ObjectModelPlan? BindObject(string key, string name, ObjectNode node, Dictionary> inheritance, - bool isRequestReachable, IReadOnlyDictionary schemas, TypePlanBinder typeBinder, BindingErrorCollector errors) - { - if (!TryAdmitAdditionalProperties(key, node, errors, out var emitsExtensionData)) - { - return null; - } - - var properties = new List(node.Properties.Count); - foreach (var property in node.Properties) - { - var type = typeBinder.Bind(key, property.Name, property.Schema); - if (type is null) - { - continue; - } - - // Read before the widening below: at this instant IsNullable still means exactly - // "the schema admits JSON null", which is half of what the tri-state wrapper is - // keyed on. After the widening it means "optional or nullable" and the two are - // no longer separable (ADR-0004). - var emitsOptionalWrapper = isRequestReachable && !property.IsRequired && type.IsNullable; - if (!property.IsRequired) - { - type = type with - { - IsNullable = true - }; - } - - var literal = property.Schema as LiteralNode; - properties.Add(new ModelPropertyPlan - { - WireName = property.Name, - Name = CSharpNamePolicy.ToPascalCase(property.Name), - Type = type, - IsRequired = property.IsRequired, - IsLiteral = literal is not null, - EmitsOptionalWrapper = emitsOptionalWrapper, - LiteralKind = literal?.Kind, - LiteralValue = literal?.Value, - Description = property.Schema.Description ?? AliasDescription(property.Schema, schemas), - }); - } - - if (properties.Count != node.Properties.Count) - { - return null; - } - - var duplicate = properties - .GroupBy(static property => property.Name, StringComparer.Ordinal) - .FirstOrDefault(static group => group.Skip(1).Any()); - - if (duplicate is not null) - { - errors.Add(BindingErrorCategory.Naming, key, $"multiple properties map to C# name '{duplicate.Key}'"); - return null; - } - - if (emitsExtensionData && TakesExtensionDataMemberName(key, properties, errors)) - { - return null; - } - - return new ObjectModelPlan - { - Name = name, - Namespace = GeneratedNamespace.Models, - Description = node.Description, - Properties = properties, - ImplementedUnionNames = inheritance.TryGetValue(key, out var implemented) ? implemented : [], - EmitsExtensionData = emitsExtensionData, - }; - } - - /// - /// Only a bag that admits any value rides beside named properties without loss: JsonElement - /// carries every token exactly as sent, so the record gains the extension-data member. A - /// typed bag would need a member no record has, so it keeps refusing. Returns whether the - /// object binds at all; the out value says whether it carries the bag. - /// - private static bool TryAdmitAdditionalProperties(string key, ObjectNode node, BindingErrorCollector errors, out bool emitsExtensionData) - { - emitsExtensionData = false; - if (node.AdditionalProperties is not AdditionalPropertiesKind.Schema) - { - return true; - } - - if (node.AdditionalPropertiesSchema is not (UnrestrictedNode or NullableNode { Inner: UnrestrictedNode })) - { - errors.Add( - BindingErrorCategory.Schema, - string.Concat(key, "/additionalProperties"), - "named properties and typed additional properties cannot be represented without data loss; only an unrestricted bag rides as extension data"); - return false; - } - - emitsExtensionData = true; - return true; - } - - /// A named property whose C# name is one of the two extension-data members' refuses the object rather than shadowing them. - private static bool TakesExtensionDataMemberName(string key, IEnumerable properties, BindingErrorCollector errors) - { - var taken = properties.FirstOrDefault(static property => - string.Equals(property.Name, ObjectModelPlan.ExtensionDataMemberName, StringComparison.Ordinal) - || string.Equals(property.Name, ObjectModelPlan.ExtensionDataBagName, StringComparison.Ordinal)); - if (taken is null) - { - return false; - } - - errors.Add( - BindingErrorCategory.Naming, - key, - $"a property maps to C# name '{taken.Name}', which the extension-data member takes"); - return true; - } - /// Each value's member name comes from . private static EnumModelPlan BindEnum(string key, string name, EnumNode node, IReadOnlyList memberNames, BindingErrorCollector errors) @@ -769,8 +147,7 @@ private static EnumModelPlan BindEnum(string key, string name, EnumNode node, errors.Add(BindingErrorCategory.Naming, name, $"multiple enum values map to C# name '{duplicate.Key}'"); } - // The extensible enum declares members of its own beside the values; a value may not take - // one of their names, nor the enum's. + // An enum value cannot take the enum's name or a member the extensible enum declares. foreach (var value in values.Where(value => ExtensibleEnumMemberPolicy.DeclaredNames.Contains(value.Name) || string.Equals(value.Name, name, StringComparison.Ordinal))) { @@ -786,25 +163,4 @@ private static EnumModelPlan BindEnum(string key, string name, EnumNode node, Values = values, }; } - - /// - /// Records one union a schema is a branch of. A schema can be a branch of several, so - /// membership accumulates rather than refusing the second (ADR-0011). - /// - private static void AddInheritance(string schemaKey, string unionName, IDictionary> inheritance, - BindingErrorCollector errors) - { - ArgumentNullException.ThrowIfNull(errors); - - if (!inheritance.TryGetValue(schemaKey, out var unions)) - { - unions = []; - inheritance[schemaKey] = unions; - } - - if (!unions.Contains(unionName, StringComparer.Ordinal)) - { - unions.Add(unionName); - } - } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/UnionInheritanceGraph.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/UnionInheritanceGraph.cs new file mode 100644 index 000000000..9bf861448 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/UnionInheritanceGraph.cs @@ -0,0 +1,70 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal sealed class UnionInheritanceGraph +{ + private readonly Dictionary> _memberships = new(StringComparer.Ordinal); + + private UnionInheritanceGraph() { } + + public IReadOnlyList Unions { get; private set; } = []; + + public IReadOnlyList ImplementedUnionNames(string schemaKey) => _memberships.GetValueOrDefault(schemaKey) ?? []; + + public static UnionInheritanceGraph Create(IReadOnlyList results, + IReadOnlySet streamCauseKeys, BindingErrorCollector errors) + { + var edges = results.SelectMany(result => result.ChildEdges).ToLookup(edge => edge.SchemaKey, StringComparer.Ordinal); + var graph = new UnionInheritanceGraph(); + foreach (var group in edges) + { + graph._memberships.Add(group.Key, Array.AsReadOnly(group.Select(edge => edge.ParentTypeName).Distinct(StringComparer.Ordinal).ToArray())); + } + + var unions = new List(); + var refusedNames = new HashSet(StringComparer.Ordinal); + foreach (var result in results) + { + if (result.Plan is not { } plan) + { + continue; + } + + if (result.SchemaKey is null) + { + unions.Add(plan); + continue; + } + + var parents = graph.ImplementedUnionNames(result.SchemaKey).ToList(); + if (streamCauseKeys.Contains(result.SchemaKey) && !parents.Contains(EffectStreamTypeNamePolicy.CauseMarkerInterface, StringComparer.Ordinal)) + { + parents.Add(EffectStreamTypeNamePolicy.CauseMarkerInterface); + } + + if (parents.Count > 1) + { + errors.Add(BindingErrorCategory.Schema, result.SchemaKey, + $"nested union has multiple parents {string.Join(" and ", parents.Select(parent => $"'{parent}'"))}"); + _ = refusedNames.Add(plan.Name); + continue; + } + + unions.Add(plan with + { + BaseTypeName = parents.SingleOrDefault(), + FixedMarker = edges[result.SchemaKey].Select(edge => edge.FixedMarker).LastOrDefault(marker => marker is not null), + }); + } + + // A refused parent's missing declaration is already diagnosed, not a new leaf error. + foreach (var (key, names) in graph._memberships) + { + graph._memberships[key] = Array.AsReadOnly(names.Where(name => !refusedNames.Contains(name)).ToArray()); + } + + graph.Unions = unions.AsReadOnly(); + return graph; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/UnstructuredUnionPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/UnstructuredUnionPolicy.cs index ec7d1b576..c5ec18601 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/UnstructuredUnionPolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/UnstructuredUnionPolicy.cs @@ -7,7 +7,7 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; /// refinements of one primitive differ only in constraints this SDK does not enforce, so the /// union is that primitive — an open enum is the common case. An object-or-array pair is how /// Effect renders its object keyword, and neither branch says what the content is, so it -/// is an object of unspecified content. Anything else stays a structural union and is refused. +/// is carried losslessly as JsonElement. Anything else stays a structural union. /// internal static class UnstructuredUnionPolicy { @@ -27,7 +27,7 @@ internal static class UnstructuredUnionPolicy return union.Branches.Count is 2 && union.Branches.Count(static branch => branch is FreeFormObjectNode) is 1 && union.Branches.Count(static branch => branch is ArrayNode { Item: UnrestrictedNode }) is 1 - ? union.Branches.OfType().Single() + ? new UnrestrictedNode() : null; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs index 321964115..e649504e2 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs @@ -179,7 +179,7 @@ private static ConstructorDeclarationSyntax EmitErrorConstructor(EnvelopePlan en SyntaxFactory.Parameter(SyntaxFactory.Identifier("status")) .WithType(SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword))), SyntaxFactory.Parameter(SyntaxFactory.Identifier("error")) - .WithType(SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("IOpenCodeError"))), + .WithType(SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed(ErrorMarkerPolicy.InterfaceTypeName))), SyntaxFactory.Parameter(SyntaxFactory.Identifier("rawBody")) .WithType(SyntaxFactory.NullableType(SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)))), ]))) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/StreamAdapterEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/StreamAdapterEmitter.cs index 185e0b11e..4499a1ff1 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/StreamAdapterEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/StreamAdapterEmitter.cs @@ -180,7 +180,7 @@ .. operation.ErrorMap.Statuses.Select(static status => SyntaxFactory.SwitchExpre return SyntaxFactory .MethodDeclaration( - SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("IOpenCodeError")), + SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed(ErrorMarkerPolicy.InterfaceTypeName)), "ReadError") .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ErrorStyleClassifier.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ErrorStyleClassifier.cs index af6ec2b3f..00eaea0a0 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ErrorStyleClassifier.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ErrorStyleClassifier.cs @@ -3,19 +3,19 @@ namespace OpenCode.Sdk.Tools.Generator.Ingestion.Projection; /// -/// Recognizes a schema that spells a typed error: a required literal _tag. The marker name -/// appears here as ingestion's recognition key and again in Binding/ErrorMarkerPolicy, which -/// owns the binding-side marker the emitted converter scans for; the two must spell the same name. +/// Recognizes a schema that spells a typed error: a required literal _tag. /// Any other shape classifies as no error, and the binder refuses it wherever an error response /// references it. /// internal sealed class ErrorStyleClassifier { + public const string WireName = "_tag"; + public ErrorStyle Classify(IReadOnlyList properties) { ArgumentNullException.ThrowIfNull(properties); - return properties.Any(static property => property is { Name: "_tag", IsRequired: true, Schema: LiteralNode }) + return properties.Any(static property => property is { Name: WireName, IsRequired: true, Schema: LiteralNode }) ? ErrorStyle.EffectTag : ErrorStyle.None; } diff --git a/tools/jscpd/tests.baseline.json b/tools/jscpd/tests.baseline.json index 36465bcb5..22ce90fa1 100644 --- a/tools/jscpd/tests.baseline.json +++ b/tools/jscpd/tests.baseline.json @@ -14,6 +14,7 @@ "04551f236fc0cb20": 1, "047178ad85b36704": 1, "053cf7003742af8b": 1, + "0555dcc5c34a8475": 1, "05990412764d4af8": 1, "059dcd6e0a494338": 1, "05bec1db549b3145": 1, @@ -542,7 +543,6 @@ "cb72f4a5d460e4a8": 1, "cbf3676e991f1146": 1, "cc7e06ec26f04567": 1, - "ccafdf2207f06e4f": 1, "ccb62a02a7741fc6": 1, "ccc29e43c6a46325": 1, "cd14fbe400cc8d59": 1,