From 05ca64caaffe5451d07be11018c3d09847504ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 17 Oct 2026 10:09:57 +0300 Subject: [PATCH] refactor(generator): union binding returns its decisions, and two latent union defects refuse by name Marked, error and object binding move out of SchemaPlanBinder (810 -> 166 lines) into MarkedUnionBinder, ErrorUnionBinder and ObjectModelBinder. Each returns its plan and its membership edges, and UnionInheritanceGraph assembles base types and fixed markers from them, replacing the shared inheritance and fixed-marker accumulators and their post-pass. Every order the emitted code depends on is kept, and a test pins each: record base lists (graph-key order, then branch order, the error union last), error variants by type name, and first-claim hoisted carriers by depth before name. Generated output is byte-identical. - P36: a nested union under two parents, or a stream-cause union with a union parent, refuses with one named error instead of silently taking the last parent. - P37: a free-form object-or-array union binds to JsonElement, the representation upstream's schema admits; a typed object-or-array pair stays a structural union. - P45: each error-spine spelling has one owner. The '_tag' wire literal is owned on the Ingestion side and ErrorMarkerPolicy refers to it, keeping the Binding -> Ingestion direction. --- docs/architecture/protocol-and-generation.md | 4 + .../Generator/Binding/SpecBinderTests.cs | 159 +++- .../Binding/UnionMemberHoistBinderTests.cs | 15 + .../Generator/Emission/ModelEmitterTests.cs | 62 ++ .../Support/BindingScenarioData.cs | 6 + .../Binding/BindingErrorCollector.cs | 6 +- .../Generator/Binding/ErrorMarkerPolicy.cs | 16 +- .../Generator/Binding/ErrorUnionBinder.cs | 94 +++ .../Generator/Binding/MarkedUnionBinder.cs | 321 ++++++++ .../Binding/Models/ErrorUnionBindingResult.cs | 6 + .../Binding/Models/SchemaBindingContext.cs | 13 + .../Binding/Models/SchemaBindingResult.cs | 2 + .../Binding/Models/UnionBindingContext.cs | 10 +- .../Binding/Models/UnionBindingResult.cs | 4 + .../Binding/Models/UnionChildEdge.cs | 3 + .../Generator/Binding/ObjectModelBinder.cs | 129 ++++ .../Generator/Binding/SchemaNameResolver.cs | 2 +- .../Generator/Binding/SchemaPlanBinder.cs | 706 +----------------- .../Binding/UnionInheritanceGraph.cs | 70 ++ .../Binding/UnstructuredUnionPolicy.cs | 4 +- .../Generator/Emission/EnvelopeEmitter.cs | 2 +- .../Emission/StreamAdapterEmitter.cs | 2 +- .../Projection/ErrorStyleClassifier.cs | 8 +- tools/jscpd/tests.baseline.json | 2 +- 24 files changed, 947 insertions(+), 699 deletions(-) create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorUnionBinder.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/MarkedUnionBinder.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ErrorUnionBindingResult.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingContext.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionBindingResult.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionChildEdge.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/ObjectModelBinder.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/UnionInheritanceGraph.cs diff --git a/docs/architecture/protocol-and-generation.md b/docs/architecture/protocol-and-generation.md index 11ecbe2b..073e650f 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 c215f70f..098db6bd 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 75545b8f..7fdb0bb1 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 e9493869..6a93636f 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 101e55d3..9174f664 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 b43e76b1..1b197234 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 c97c8a05..01f05b03 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 00000000..1a86f365 --- /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 00000000..8f560432 --- /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 00000000..bfb080d2 --- /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 00000000..58bd8bc2 --- /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 7591924a..1d43c902 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 d84cf158..f94f18de 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 00000000..6d194abf --- /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 00000000..3773a52e --- /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 00000000..843175d3 --- /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 f956b5ce..fbbe6e90 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 76cbd0de..172e7a70 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 00000000..9bf86144 --- /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 ec7d1b57..c5ec1860 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 32196411..e649504e 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 185e0b11..4499a1ff 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 af6ec2b3..00eaea0a 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 36465bcb..22ce90fa 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,