diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/GeneratedTypeNameInventoryTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/GeneratedTypeNameInventoryTests.cs new file mode 100644 index 00000000..5e312a25 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/GeneratedTypeNameInventoryTests.cs @@ -0,0 +1,48 @@ +using OpenCode.Sdk.Tools.Generator.Binding; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Tests.Support; +using TUnit.Assertions.Enums; +using static OpenCode.Sdk.Tools.Tests.Generator.Binding.Support.OwnershipPlanData; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Binding; + +public sealed class GeneratedTypeNameInventoryTests +{ + [Test] + public async Task Create_Should_Include_Every_Generated_Declaration_Without_Losing_Its_Owner() + { + var candidates = new OwnershipCandidates + { + Models = [Model("Body"), StructuralUnion("Shape", "ShapeKind")], + Unions = [Union("Event")], + HoistedInterfaces = [HoistedInterface("ICarrier"), HoistedInterface("INestedCarrier")], + }; + var operation = BodyOperation("Widgets", "Body") with + { + QueryRequest = new QueryRequestPlan { TypeName = "Body", Properties = [], DerivesFromListRequest = false, RidesRequestBody = true }, + Envelope = new EnvelopePlan { ResponseTypeName = "Reply", AdapterTypeName = "ReplyAdapter", EnvelopeDtoTypeName = "ReplyDto", Kind = EnvelopeKind.Data, SuccessStatusCode = 200, PayloadName = "Item", PayloadType = UnionHoistPlanData.Named("Shape") }, + }; + var stream = operation with + { + OperationId = "Widgets.Watch", + Envelope = null, + QueryRequest = operation.QueryRequest with { TypeName = "Query", RidesRequestBody = false }, + Stream = new StreamPlan { AdapterTypeName = "StreamAdapter", PayloadTypeName = "Shape", FailureEventName = "error", CauseTypeName = "Shape[]" }, + }; + var client = new ClientPlan { Name = "Body", Namespace = GeneratedNamespace.Internal, Role = ClientRole.Collection, SubClients = [], Operations = [operation, stream] }; + var inventory = GeneratedTypeNameInventory.Create(candidates, [client], [ + new("ICarrier", "IEvent", GeneratedTypeNameInventory.ClaimRole.Hoisted, "data"), + new("INestedCarrier", "ICarrier", GeneratedTypeNameInventory.ClaimRole.Hoisted, "nested"), + ], new Dictionary(StringComparer.Ordinal) { ["BodySchema"] = "Body" }); + await Assert.That(inventory.Claims.Select(static claim => (claim.Name, claim.Subject))).IsEquivalentTo([ + ("Body", "BodySchema"), ("Shape", "Shape"), ("IEvent", "IEvent"), ("ShapeKind", "Shape"), ("UnknownEvent", "IEvent"), + ("Body", "Body"), ("Reply", "Widgets.SendBody"), ("StreamAdapter", "Widgets.Watch"), ("Query", "Widgets.Watch"), + ("ReplyAdapter", "Widgets.SendBody"), ("ReplyDto", "Widgets.SendBody"), ("ICarrier", "IEvent"), ("INestedCarrier", "ICarrier"), + ], CollectionOrdering.Matching); + await Assert.That(inventory.Names.Order(StringComparer.Ordinal)).IsEquivalentTo([ + "Body", "ICarrier", "IEvent", "INestedCarrier", "Query", "Reply", "ReplyAdapter", "ReplyDto", "Shape", "ShapeKind", "StreamAdapter", "UnknownEvent", + ], StringComparer.Ordinal, CollectionOrdering.Matching); + await Assert.That(inventory.OtherClaims(inventory.Claims[0]).Select(static claim => claim.Subject)) + .IsEquivalentTo(["Body"], StringComparer.Ordinal, CollectionOrdering.Matching); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/InternalNameCollisionCheckTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/InternalNameCollisionCheckTests.cs index db892a56..4f4f1dbe 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/InternalNameCollisionCheckTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/InternalNameCollisionCheckTests.cs @@ -114,7 +114,10 @@ private static BindingErrorCollector Check(PlanNames names) Operations = [ListOperation(names), StreamOperation(names)], }; - InternalNameCollisionCheck.Check(candidates, [client], errors); + var inventory = GeneratedTypeNameInventory.Create(candidates, [client], + [new GeneratedTypeNameClaim(names.Hoisted, names.Union, GeneratedTypeNameInventory.ClaimRole.Hoisted, "data")], + new Dictionary(StringComparer.Ordinal)); + InternalNameCollisionCheck.Check(inventory, errors); return errors; } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index 3e79c90c..d6acbabe 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -10,6 +10,227 @@ namespace OpenCode.Sdk.Tools.Tests.Generator.Binding; public sealed class OperationPlanBinderTests { + private static readonly string[] ClaimOrderModelNames = ["WidgetsClient", "WidgetListResponse", "WidgetStreamAdapter", "WidgetListRequest"]; + + [Test] + [Arguments("kind", "ShapeKind")] + [Arguments("unknown", "UnknownEvent")] + [Arguments("adapter", "WidgetListResponseAdapter")] + [Arguments("model", "SharedModel")] + public async Task CheckTypeNameCollisions_Should_Refuse_A_Model_Against_Another_Generated_Declaration(string declaration, string name) + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(static operation => operation.WithoutResponse(204))); + var clients = declaration == "adapter" ? BindWidgets(document).Clients : []; + ModelPlan[] models = [Support.OwnershipPlanData.Model(name)]; + if (declaration == "kind") + { + models = [Support.OwnershipPlanData.StructuralUnion("Shape", name), .. models]; + } + else if (declaration == "model") + { + models = [.. models, Support.OwnershipPlanData.Model(name)]; + } + var candidates = new OwnershipCandidates + { + Models = models, + Unions = declaration == "unknown" ? [Support.OwnershipPlanData.Union("Event")] : [], + HoistedInterfaces = [], + }; + await Assert.That(NameCollisions(candidates, clients)).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Naming, name, $"type name '{name}' collides with another generated type"), + ], CollectionOrdering.Matching); + } + + [Test] + public async Task Bind_Should_Preserve_The_Mixed_Request_Wall_After_A_Query_Facet_Refuses() + { + var document = await BindingTestHost.IngestAsync(MixedRequestScenario(static operation => + operation.Parameter("broken", "query", static schema => schema.Type("integer")))); + var exception = Assert.Throws(() => BindWidgets(document, "widget.create", strings: ["search"])); + + await Assert.That(exception.Errors).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Operation, "widget.create", "query parameter 'broken' has an unsupported schema shape"), + new BindingError(BindingErrorCategory.Operation, "widget.create", "operations mixing a request body and query parameters are supported only for the location selector"), + ], CollectionOrdering.Matching); + } + + [Test] + public async Task Bind_Should_Preserve_Enumeration_Naming_After_A_Query_Facet_Refuses() + { + var document = await BindingTestHost.IngestAsync(CursorListScenario(configure: static operation => + CursorQuery(operation).Parameter("broken", "query", static schema => schema.Type("integer")))); + var exception = Assert.Throws(() => new BindingTestHost().Bind(document, Selection("widget.list"), + Curation(Groups("widget", ClientGroup("Widgets", null, null)), + operationNames: [OperationName("widget.list", "BrowseWidgetsAsync")], + queryParameterTypes: [QueryParameterType("widget.list", "limit", "int")], + stringQueryParameters: [StringQueryParameter("widget.list", "cursor")]))); + + await Assert.That(exception.Errors).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Operation, "widget.list", "query parameter 'broken' has an unsupported schema shape"), + new BindingError(BindingErrorCategory.Naming, "widget.list", "cursor-list method 'BrowseWidgetsAsync' must be an asynchronous List method before its enumeration name can be derived"), + ], CollectionOrdering.Matching); + } + + private static OperationBuilder CursorQuery(OperationBuilder operation) => operation + .Parameter("limit", "query", QueryScenarioData.NullableString) + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) + .Parameter("cursor", "query", QueryScenarioData.NullableString); + + [Test] + public async Task CheckTypeNameCollisions_Should_Keep_An_Alias_Name_In_The_Schema_Claim_Phase() + { + var candidates = new OwnershipCandidates { Models = [], Unions = [], HoistedInterfaces = [] }; + var names = new Dictionary(StringComparer.Ordinal) { ["Alias"] = "ListCursor" }; + await Assert.That(NameCollisions(candidates, [], names)).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Naming, "Alias", "model type name 'ListCursor' collides with the hand-written spine type 'ListCursor'"), + ], CollectionOrdering.Matching); + } + + [Test] + public async Task CheckTypeNameCollisions_Should_Refuse_Two_Derived_Declarations_With_One_Name() + { + var candidates = new OwnershipCandidates + { + Models = [Support.OwnershipPlanData.StructuralUnion("Shape", "SharedKind")], + Unions = [Support.OwnershipPlanData.Union("Event") with { UnknownTypeName = "SharedKind" }], + HoistedInterfaces = [], + }; + await Assert.That(NameCollisions(candidates, [], + new Dictionary(StringComparer.Ordinal) { ["Shape"] = "Shape", ["Event"] = "IEvent" })).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Naming, "IEvent", "type name 'SharedKind' collides with another generated type"), + ], CollectionOrdering.Matching); + } + + private static IReadOnlyList NameCollisions(OwnershipCandidates candidates, IReadOnlyList clients, + IReadOnlyDictionary? schemaNames = null) + { + var inventory = GeneratedTypeNameInventory.Create(candidates, clients, [], schemaNames ?? new Dictionary(StringComparer.Ordinal)); + var errors = new BindingErrorCollector(); + new OperationPlanBinder().CheckTypeNameCollisions(inventory, clients, errors); + return errors.Snapshot(); + } + + [Test] + public async Task Bind_Should_Preserve_Type_Name_Claim_Order() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(static operation => operation + .Parameter("count", "query", static schema => schema.Type("string").Enum("true", "false")))); + var plan = BindWidgets(document); + var client = plan.Clients.Single(static client => client.Role == ClientRole.Collection); + var first = client.Operations.Single(); + var second = first with { OperationId = "widget.list.get" }; + var stream = first with + { + OperationId = "widget.watch", + Envelope = null, + QueryRequest = null, + Stream = new StreamPlan { AdapterTypeName = "WidgetStreamAdapter", PayloadTypeName = "WidgetInfo", FailureEventName = "error", CauseTypeName = "WidgetCause[]" } + }; + var clients = new[] { client with { Operations = [stream, second, first] } }; + var candidates = new OwnershipCandidates + { + Models = [.. ClaimOrderModelNames + .Select(static name => new ObjectModelPlan { Name = name, Namespace = GeneratedNamespace.Models, Properties = [] })], + Unions = [], + HoistedInterfaces = [] + }; + var errors = new BindingErrorCollector(); + new OperationPlanBinder().CheckTypeNameCollisions(GeneratedTypeNameInventory.Create(candidates, clients, [], new Dictionary(StringComparer.Ordinal)), clients, errors); + await Assert.That(errors.Snapshot()).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Naming, "WidgetsClient", "client type name 'WidgetsClient' collides with another generated type"), + new BindingError(BindingErrorCategory.Naming, "widget.list", "multiple operations map to response type 'WidgetListResponse'"), + new BindingError(BindingErrorCategory.Naming, "widget.list.get", "multiple operations map to response type 'WidgetListResponse'"), + new BindingError(BindingErrorCategory.Naming, "widget.watch", "multiple operations map to stream adapter 'WidgetStreamAdapter'"), + new BindingError(BindingErrorCategory.Naming, "widget.list", "request type name 'WidgetListRequest' collides with another generated type"), + new BindingError(BindingErrorCategory.Naming, "widget.list.get", "request type name 'WidgetListRequest' collides with another generated type"), + new BindingError(BindingErrorCategory.Naming, "widget.list.get", "multiple operations map to route member 'Widgets.ListWidgets'"), + new BindingError(BindingErrorCategory.Naming, "widget.watch", "multiple operations map to route member 'Widgets.ListWidgets'"), + ], CollectionOrdering.Matching); + } + + [Test] + [Arguments("post")] + [Arguments("binary")] + [Arguments("route")] + public async Task Bind_Should_Omit_Only_The_Cursor_Companion_When_The_Signature_Is_Incompatible(string signature) + { + var document = await BindingTestHost.IngestAsync(CursorListScenario(configure: operation => + { + CursorQuery(operation); + if (signature == "binary") + { + operation.RequestBody("application/octet-stream", static schema => schema.Type("string").Format("binary"), required: true); + } + if (signature == "route") + { + operation.Parameter("id", "path", static schema => schema.Type("string"), required: true); + } + }, method: signature == "route" ? "get" : "post", path: signature == "route" ? "/api/widget/{id}" : "/api/widget")); + var list = BindWidgetList(document); + await Assert.That(list.MethodName).IsEqualTo("ListWidgetsAsync"); + await Assert.That(list.QueryRequest!.DerivesFromListRequest).IsTrue(); + await Assert.That(list.QueryRequest.Properties.Select(static property => property.WireName)) + .IsEquivalentTo(["limit", "order", "cursor"], StringComparer.Ordinal, CollectionOrdering.Matching); + await Assert.That(list.Pagination).IsNull(); + if (signature == "binary") + { + await Assert.That(list.RequestBody!.IsBinary).IsTrue(); + } + else + { + await Assert.That(list.RequestBody).IsNull(); + } + await Assert.That(list.Parameters.Select(static parameter => parameter.WireName)) + .IsEquivalentTo(signature == "route" ? ["id"] : [], StringComparer.Ordinal, CollectionOrdering.Matching); + } + + [Test] + public async Task Bind_Should_Reject_A_Refused_Facet_Even_When_The_Error_Is_A_Missing_Row() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(static operation => operation + .Parameter("search", "query", static schema => schema.Type("string")))); + var exception = Assert.Throws(() => BindWidgets(document)); + await Assert.That(exception.Errors).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Curation, "widget.list:search", + "the query parameter is a plain string that no curation row classifies: add a queryParameterTypes row when upstream decodes it as a number, or a stringQueryParameters row that says why it stays a string") { Kind = BindingErrorKind.MissingRow }, + ], CollectionOrdering.Matching); + } + + [Test] + public async Task Bind_Should_Preserve_Independent_Facet_Refusals_In_Binder_Order() + { + var document = await BindingTestHost.IngestAsync(CursorListScenario(configure: static operation => CursorQuery(operation) + .Response(400, "text/plain", static schema => schema.Type("string")))); + var curation = Curation(Groups("widget", ClientGroup("Widgets", null, null)), + operationNames: [OperationName("widget.list", "BrowseWidgetsAsync")], + queryParameterTypes: [QueryParameterType("widget.list", "limit", "int")], + stringQueryParameters: [StringQueryParameter("widget.list", "cursor")]); + var exception = Assert.Throws(() => new BindingTestHost().Bind(document, Selection("widget.list"), curation)); + await Assert.That(exception.Errors).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Operation, "widget.list", "error responses must carry a JSON schema"), + new BindingError(BindingErrorCategory.Naming, "widget.list", "cursor-list method 'BrowseWidgetsAsync' must be an asynchronous List method before its enumeration name can be derived"), + ], CollectionOrdering.Matching); + } + + [Test] + public async Task Bind_Should_Refuse_A_Mixed_Body_And_Nonlocation_Query_Explicitly() + { + var document = await BindingTestHost.IngestAsync(MixedRequestScenario()); + var exception = Assert.Throws(() => BindWidgets(document, "widget.create", strings: ["search"])); + await Assert.That(exception.Errors) + .IsEquivalentTo([new BindingError(BindingErrorCategory.Operation, "widget.create", "operations mixing a request body and query parameters are supported only for the location selector")], CollectionOrdering.Matching); + } + + private static SpecScenario MixedRequestScenario(Action? configure = null) => SpecScenario.Define(spec => spec + .WithOperation("widget.create", method: "post", configure: operation => + { + operation.WithoutResponse(200).Response(204) + .Parameter("search", "query", static schema => schema.Type("string")) + .RequestBody("application/json", static schema => schema.Type("object") + .Property("title", static property => property.Type("string"), required: true), required: true); + configure?.Invoke(operation); + })); + private static void DefinePlaceInfo(SchemaBuilder schema) => _ = schema.Type("object") .Property("directory", property => property.Type("string"), required: true); @@ -22,8 +243,8 @@ private static BindingException RefuseWidgetItem(SpecDocument document) => Assert.Throws(() => _ = new BindingTestHost().Bind(document, Selection("widget.item"), Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null))))); - private static OperationPlan BindWidgetList(SpecDocument document) => - BindWidgets(document, integers: ["limit"], strings: ["cursor"]) + private static OperationPlan BindWidgetList(SpecDocument document, IReadOnlyList? strings = null) => + BindWidgets(document, integers: ["limit"], strings: strings ?? ["cursor"]) .Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); private static BindingException RefuseMergedGadget(SpecDocument document, IReadOnlyDictionary groups) => @@ -226,7 +447,11 @@ public async Task Bind_Should_Refuse_Invalid_Envelope_Payload_Rows(string variat { if (variation == "invalid-binder") { - _ = new OperationPlanBinder().Bind(document, document.Operations, curation, new Dictionary(StringComparer.Ordinal), errors); + var reachable = new ReachableSchemaCollector().Collect(document, document.Operations, errors); + var schemas = new SchemaPlanBinder(new StructuralUnionPlanBinder(), new UnionMembershipValidator(), + new UnionDiscriminatorSelector(), new UnionMemberHoistBinder()).Bind(document, reachable, curation, + new Dictionary(StringComparer.Ordinal), errors); + _ = new OperationPlanBinder().Bind(document, document.Operations, curation, schemas, errors); errors.ThrowIfAny(); } else if (variation == "invalid") @@ -1192,20 +1417,16 @@ public async Task Bind_Should_Leave_Cursor_Pagination_Absent_When_The_Spine_Is_I } [Test] - public async Task Bind_Should_Refuse_Cursor_Pagination_When_A_Filter_Riding_The_Spine_Is_Required() + public async Task Bind_Should_Omit_Only_Cursor_Pagination_When_A_Filter_Riding_The_Spine_Is_Required() { - var document = await BindingTestHost.IngestAsync(CursorListScenario(configure: operation => operation - .Parameter("limit", "query", QueryScenarioData.NullableString) - .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) - .Parameter("cursor", "query", QueryScenarioData.NullableString) + var document = await BindingTestHost.IngestAsync(CursorListScenario(configure: operation => CursorQuery(operation) .Parameter("search", "query", schema => schema.Type("string"), required: true))); - var exception = Assert.Throws(() => _ = BindWidgets(document, integers: ["limit"], strings: ["cursor", "search"])); - - await Assert.That(exception.Errors).Contains(static error => error.Category == BindingErrorCategory.Operation - && error.Problem.Contains( - "cursor pagination cannot continue a query that declares a required parameter", - StringComparison.Ordinal)); + var list = BindWidgetList(document, ["cursor", "search"]); + await Assert.That(list.MethodName).IsEqualTo("ListWidgetsAsync"); + await Assert.That(list.QueryRequest!.Properties.Single(static property => property.WireName == "search").IsRequired).IsTrue(); + await Assert.That(list.QueryRequest.HasRequiredMember).IsTrue(); + await Assert.That(list.Pagination).IsNull(); } [Test] @@ -2142,27 +2363,27 @@ await Assert.That(exception.Errors).Contains(static error => error.Category == B } [Test] - public async Task Bind_Should_Refuse_Cursor_Pagination_On_An_Operation_Declaring_A_Header() + public async Task Bind_Should_Omit_Only_Cursor_Pagination_On_An_Operation_Declaring_A_Header() { - var document = await BindingTestHost.IngestAsync(CursorListScenario(configure: static operation => operation - .Parameter("limit", "query", QueryScenarioData.NullableString) - .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) - .Parameter("cursor", "query", QueryScenarioData.NullableString) + var document = await BindingTestHost.IngestAsync(CursorListScenario(configure: static operation => CursorQuery(operation) .Parameter("x-opencode-ticket", "header", QueryScenarioData.NullableString))); - var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + var plan = new BindingTestHost().Bind( document, Selection("widget.list"), Curation(Groups( "widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null, emission: EmissionMode.InternalRaw)), queryParameterTypes: [QueryParameterType("widget.list", "limit", "int")], - stringQueryParameters: [StringQueryParameter("widget.list", "cursor")]))); - - await Assert.That(exception.Errors).Contains(static error => error.Category == BindingErrorCategory.Operation - && error.Problem.Contains( - "cursor pagination cannot carry a declared header", - StringComparison.Ordinal)); + stringQueryParameters: [StringQueryParameter("widget.list", "cursor")])); + var client = plan.Clients.Single(static client => client.Role == ClientRole.Collection); + var list = client.Operations.Single(); + await Assert.That(client.Name).IsEqualTo("WidgetsRawClient"); + await Assert.That(list.MethodName).IsEqualTo("ListWidgetsAsync"); + await Assert.That(list.DeclaredHeaders).IsEquivalentTo([ + new DeclaredHeaderPlan { WireName = "x-opencode-ticket", Name = "xOpencodeTicket" }, + ], CollectionOrdering.Matching); + await Assert.That(list.Pagination).IsNull(); } [Test] @@ -3164,7 +3385,7 @@ private static SpecScenario WidgetListScenario(Action configur })); private static SpecScenario CursorListScenario(Action? cursor = null, Action? items = null, - Action? configure = null) => + Action? configure = null, string method = "get", string path = "/api/widget") => SpecScenario.Define(spec => spec .WithSchema("WidgetInfo", schema => schema .Type("object") @@ -3176,7 +3397,7 @@ private static SpecScenario CursorListScenario(Action? cursor = n .Type("array") .Items(items ?? (static item => item.Ref("WidgetInfo"))), required: true) .Property("cursor", cursor ?? DefaultCursor, required: true)) - .WithOperation("widget.list", path: "/api/widget", configure: operation => + .WithOperation("widget.list", method: method, path: path, configure: operation => { configure?.Invoke(operation); _ = operation.Response(200, "application/json", schema => schema.Ref("WidgetsResponse")); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/QueryRequestFacetBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/QueryRequestFacetBinderTests.cs new file mode 100644 index 00000000..2183a46f --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/QueryRequestFacetBinderTests.cs @@ -0,0 +1,74 @@ +using OpenCode.Sdk.Tools.Generator.Binding; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Tests.Support; +using TUnit.Assertions.Enums; +using static OpenCode.Sdk.Tools.Tests.Support.BindingScenarioData; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Binding; + +public sealed class QueryRequestFacetBinderTests +{ + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task Bind_Should_Refuse_The_Whole_Facet_When_One_Parameter_Is_Unclassified(bool multiple) + { + var context = await CreateContextAsync(operation => + { + operation.Parameter("good", "query", static schema => schema.Type("string").Enum("true", "false")) + .Parameter("search", "query", static schema => schema.Type("string")); + if (multiple) + { + operation.Parameter("bad", "query", static schema => schema.Type("array").Items(static item => item.Type("string"))); + } + }); + var result = new QueryRequestFacetBinder(context).Bind(); + await Assert.That(result).IsTypeOf.Refused>(); + var expected = new List + { + new(BindingErrorCategory.Curation, "widget.list:search", + "the query parameter is a plain string that no curation row classifies: add a queryParameterTypes row when upstream decodes it as a number, or a stringQueryParameters row that says why it stays a string") { Kind = BindingErrorKind.MissingRow }, + }; + if (multiple) + { + expected.Add(new BindingError(BindingErrorCategory.Operation, "widget.list", "query parameter 'bad' has an unsupported schema shape")); + } + await Assert.That(((FacetResult.Refused)result).Errors).IsEquivalentTo(expected, CollectionOrdering.Matching); + await Assert.That(context.Errors.Snapshot()).IsEmpty(); + } + + [Test] + public async Task Bind_Should_Refuse_An_Enum_Without_A_Generated_Model() + { + var context = await CreateContextAsync(static operation => operation.Parameter("state", "query", + static schema => schema.Type("string").Enum("open", "closed"))); + var result = new QueryRequestFacetBinder(context).Bind(); + await Assert.That(result).IsTypeOf.Refused>(); + await Assert.That(((FacetResult.Refused)result).Errors).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Operation, "widget.list", "query parameter 'state' binds an enum that has no generated model"), + ], CollectionOrdering.Matching); + } + + [Test] + public async Task Bind_Should_Distinguish_An_Absent_Query_From_A_Refused_Query() + { + var absent = new QueryRequestFacetBinder(await CreateContextAsync(static _ => { })).Bind(); + await Assert.That(absent).IsTypeOf.Bound>(); + await Assert.That(((FacetResult.Bound)absent).Value).IsNull(); + var refused = new QueryRequestFacetBinder(await CreateContextAsync(static operation => operation + .Parameter("bad", "query", static schema => schema.Type("integer")))).Bind(); + await Assert.That(refused).IsTypeOf.Refused>(); + await Assert.That(((FacetResult.Refused)refused).Errors).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Operation, "widget.list", "query parameter 'bad' has an unsupported schema shape"), + ], CollectionOrdering.Matching); + } + + private static async Task CreateContextAsync(Action configure) + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec.WithOperation("widget.list", configure: configure))); + var errors = new BindingErrorCollector(); + var names = new Dictionary(StringComparer.Ordinal); + return new OperationFacetContext(document, document.Operations.Single(), Curation(Groups("widget", RootGroup())), names, errors, + new TypePlanBinder(document.Schemas, names, errors)); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs index 098db6bd..f652854c 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs @@ -11,6 +11,49 @@ namespace OpenCode.Sdk.Tools.Tests.Generator.Binding; public sealed class SpecBinderTests { + [Test] + public async Task Bind_Should_Preserve_The_Marker_Refusal_For_An_Unnamed_Error_Schema() + { + var document = await IngestAsync(SpecScenario.Define(static spec => + { + AddTaggedError(spec, "AError", "A"); + AddTaggedError(spec, "BError", "B"); + spec.WithOperation("health.get", configure: static operation => operation.WithoutResponse(200).Response(204) + .Response(400, "application/json", static schema => schema.Ref("AError")) + .Response(500, "application/json", static schema => schema.Ref("BError"))); + })); + var schemas = document.Schemas.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + schemas["BError"] = (ObjectNode)schemas["BError"] with { LiteralMarkers = [] }; + document = document with { Schemas = schemas }; + var exception = Assert.Throws(() => new BindingTestHost().Bind(document, Selection("health.get"), + Curation(Groups("health", RootGroup()), schemaNames: [SchemaName("AError", "SharedError"), SchemaName("BError", "SharedError")]))); + + await Assert.That(exception.Errors).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Naming, "BError", "C# type name 'SharedError' collides with schema 'AError'"), + new BindingError(BindingErrorCategory.Naming, "BError", "error schema has no unique C# type name"), + new BindingError(BindingErrorCategory.Operation, "health.get", "error responses must reference tagged error schemas: 'BError': a tagged error must declare exactly one required '_tag' literal"), + ], CollectionOrdering.Matching); + } + + [Test] + public async Task Bind_Should_Report_An_Unnamed_Error_Schema_Once() + { + var document = await IngestAsync(SpecScenario.Define(static spec => spec + .WithSchema("AError", static schema => schema.Type("object") + .Property("_tag", static property => property.Type("string").Enum("A"), required: true)) + .WithSchema("BError", static schema => schema.Type("object") + .Property("_tag", static property => property.Type("string").Enum("B"), required: true)) + .WithOperation("health.get", configure: static operation => operation.WithoutResponse(200).Response(204) + .Response(400, "application/json", static schema => schema.Ref("AError")) + .Response(500, "application/json", static schema => schema.Ref("BError"))))); + var curation = Curation(Groups("health", RootGroup()), schemaNames: [SchemaName("AError", "SharedError"), SchemaName("BError", "SharedError")]); + var exception = Assert.Throws(() => new BindingTestHost().Bind(document, Selection("health.get"), curation)); + await Assert.That(exception.Errors).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Naming, "BError", "C# type name 'SharedError' collides with schema 'AError'"), + new BindingError(BindingErrorCategory.Naming, "BError", "error schema has no unique C# type name"), + ], CollectionOrdering.Matching); + } + private static readonly string[] ParentScenarioLeaves = ["First", "Second", "Other", "Another"]; [Test] @@ -999,11 +1042,9 @@ await Assert.That(exception.Errors).Contains(static error => error.Subject == "m [Arguments(false, "ReviewedAsync", true, "missing.get", BindingErrorCategory.Curation, "curated operation does not exist in the spec")] [Arguments(false, "ReviewedAsync", true, "widget.other", BindingErrorCategory.Curation, "curated operation is not selected by the current profile")] [Arguments(false, "bad-nameAsync", true, "widget.list", BindingErrorCategory.Naming, "method name 'bad-nameAsync' must be a valid C# identifier ending in 'Async'")] - [Arguments(false, "Reviewed", true, "widget.list", BindingErrorCategory.Naming, "method name 'Reviewed' must be a valid C# identifier ending in 'Async'", "the operation's names cannot be derived mechanically: the group does not pluralize naively")] - [Arguments(false, "Async", true, "widget.list", BindingErrorCategory.Naming, "method name 'Async' must be a valid C# identifier ending in 'Async'", "the operation's names cannot be derived mechanically: the group does not pluralize naively")] [Arguments(false, "", true, "widget.run", BindingErrorCategory.Operation, "operation 'widget.run' closes with 'run', which is not a naming verb, and no operationNames row names it; the HTTP method is never a name source (ADR-0008)")] public async Task Bind_Should_Refuse_Invalid_Operation_Name_Rows(bool handle, string methodName, bool refused, - string operationId, BindingErrorCategory category, string? problem, string? operationProblem = null) + string operationId, BindingErrorCategory category, string? problem) { ArgumentNullException.ThrowIfNull(methodName); var document = await IngestAsync(SpecScenario.Define(spec => @@ -1030,19 +1071,9 @@ public async Task Bind_Should_Refuse_Invalid_Operation_Name_Rows(bool handle, st { var selected = operationId == "widget.run" ? operationId : "widget.list"; var exception = Assert.Throws(() => _ = new BindingTestHost().Bind(document, Selection(selected), curation)); - if (operationProblem is not null) - { - await Assert.That(exception.Errors).IsEquivalentTo([ - new BindingError(category, operationId, problem!), - new BindingError(BindingErrorCategory.Operation, operationId, operationProblem), - ], CollectionOrdering.Matching); - } - else - { - var error = operationId == "widget.list" && category == BindingErrorCategory.Curation ? exception.Errors.Single() - : exception.Errors.Single(error => error.Category == category && error.Subject == operationId); - await Assert.That(error).IsEqualTo(new BindingError(category, operationId, problem!)); - } + var error = operationId == "widget.list" && category == BindingErrorCategory.Curation ? exception.Errors.Single() + : exception.Errors.Single(error => error.Category == category && error.Subject == operationId); + await Assert.That(error).IsEqualTo(new BindingError(category, operationId, problem!)); } else { @@ -1051,6 +1082,21 @@ await Assert.That(exception.Errors).IsEquivalentTo([ } } + [Test] + [Arguments("Reviewed", "method name 'Reviewed' must be a valid C# identifier ending in 'Async'")] + [Arguments("Async", "method name 'Async' must be a valid C# identifier ending in 'Async'")] + public async Task Bind_Should_Report_Only_The_Invalid_Curated_Method_Name(string methodName, string problem) + { + var document = await IngestAsync(SpecScenario.Define(static spec => spec.WithOperation("widget.list", + configure: static operation => operation.WithoutResponse(200).Response(204)))); + var curation = Curation(Groups("widget", ClientGroup("Widgets", null, null)), + operationNames: [OperationName("widget.list", methodName)]); + var exception = Assert.Throws(() => new BindingTestHost().Bind(document, Selection("widget.list"), curation)); + await Assert.That(exception.Errors).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Naming, "widget.list", problem), + ], CollectionOrdering.Matching); + } + [Test] public async Task Bind_Should_Refuse_Curated_Operation_Name_Collisions() { @@ -1776,7 +1822,9 @@ await Assert.That(shared.ImplementedUnionNames.Order(StringComparer.Ordinal)) } [Test] - public async Task Bind_Should_Refuse_A_Tag_Owned_By_Two_Closure_Schemas() + [Arguments(false)] + [Arguments(true)] + public async Task Bind_Should_Refuse_A_Tag_Owned_By_Two_Closure_Schemas(bool responseCollision) { var document = await IngestAsync(SpecScenario.Define(spec => spec .WithSchema("ItemInfo", schema => schema @@ -1795,10 +1843,19 @@ public async Task Bind_Should_Refuse_A_Tag_Owned_By_Two_Closure_Schemas() .Response(404, "application/json", schema => schema.Ref("GoneError")) .Response(410, "application/json", schema => schema.Ref("LostError"))))); - var exception = RefuseHealth(document); - - await Assert.That(exception.Errors).Contains(static error => - error.Problem.Contains("multiple error schemas declare tag 'SharedError'", StringComparison.Ordinal)); + var exception = Assert.Throws(() => new BindingTestHost().Bind(document, Selection("health.get"), + Curation(Groups("health", RootGroup()), schemaNames: responseCollision ? [SchemaName("ItemInfo", "HealthResponse")] : []))); + BindingError[] expected = [ + new(BindingErrorCategory.Schema, "OpenCodeError", "multiple error schemas declare tag 'SharedError' (GoneError, LostError)"), + new(BindingErrorCategory.Schema, "GoneError", "union membership references absent union 'IOpenCodeError'"), + new(BindingErrorCategory.Schema, "LostError", "union membership references absent union 'IOpenCodeError'"), + ]; + if (responseCollision) + { + expected = [.. expected, new(BindingErrorCategory.Naming, "health.get", "multiple operations map to response type 'HealthResponse'")]; + } + await Assert.That(exception.Errors).IsEquivalentTo(expected, CollectionOrdering.Matching) + .Because(string.Join(" | ", exception.Errors)); } [Test] diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/UnionMemberHoistBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/UnionMemberHoistBinderTests.cs index 7fdb0bb1..4ee2dd44 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/UnionMemberHoistBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/UnionMemberHoistBinderTests.cs @@ -1,5 +1,6 @@ using OpenCode.Sdk.Tools.Generator.Binding; using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Tests.Support; using TUnit.Assertions.Enums; using static OpenCode.Sdk.Tools.Tests.Support.BindingScenarioData; using static OpenCode.Sdk.Tools.Tests.Support.UnionHoistPlanData; @@ -8,6 +9,78 @@ namespace OpenCode.Sdk.Tools.Tests.Generator.Binding; public sealed class UnionMemberHoistBinderTests { + [Test] + public async Task Bind_Should_Refuse_Only_The_Second_Hoisted_Claim_To_One_Name() + { + var exception = await BindRefusalsAsync(UnionScenario(static spec => + { + CarrierUnion(spec, "Event", "Created", "Deleted"); + CarrierUnion(spec, "Phase", "Started", "Stopped"); + spec.WithSchema("Feed", static schema => schema.Type("object") + .Property("event", static property => property.Ref("ExampleEvent"), required: true) + .Property("phase", static property => property.Ref("ExamplePhase"), required: true)); + }), + Curation(Groups("health", ClientGroup("Example", null, null)), hoistedMemberNames: [ + HoistedMemberName("IExampleEvent", "durable", "IExampleCarrier"), + HoistedMemberName("IExamplePhase", "durable", "IExampleCarrier"), + ], modelOwners: [Owner("IExampleCarrier", "Common")]), "health.get"); + + await Assert.That(exception.Errors).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Naming, "IExamplePhase", "hoisted member 'durable' resolves to 'IExampleCarrier', which already names another generated type"), + ], CollectionOrdering.Matching); + } + + [Test] + [Arguments("ExampleCreated")] + [Arguments("IExampleEvent")] + public async Task Bind_Should_Refuse_A_Carrier_Name_Before_Placing_The_Colliding_Declaration(string name) + { + var exception = await BindRefusalsAsync(UnionScenario(static spec => + { + CarrierUnion(spec, "Event", "Created", "Deleted"); + spec.WithSchema("Feed", static schema => schema.Type("object") + .Property("event", static property => property.Ref("ExampleEvent"), required: true)); + }), + Curation(Groups("health", ClientGroup("Example", null, null)), + hoistedMemberNames: [HoistedMemberName("IExampleEvent", "durable", name)]), "health.get"); + + await Assert.That(exception.Errors).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Naming, "IExampleEvent", $"hoisted member 'durable' resolves to '{name}', which already names another generated type"), + ], CollectionOrdering.Matching); + } + + private static void CarrierUnion(SpecDocumentBuilder spec, string concept, string first, string second) => spec + .WithSchema("Example" + first, schema => CarrierArm(schema, first.ToLowerInvariant())) + .WithSchema("Example" + second, schema => CarrierArm(schema, second.ToLowerInvariant())) + .WithSchema("Example" + concept, schema => schema.AnyOf(branch => branch.Ref("Example" + first), branch => branch.Ref("Example" + second))); + + [Test] + [Arguments("WidgetsClient")] + [Arguments("ExampleValueKind")] + public async Task Bind_Should_Refuse_A_Carrier_Name_Already_Used_By_A_Structural_Kind_Or_Client(string name) + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(static spec => spec + .WithSchema("CreatedEvent", static schema => CarrierArm(schema, "created")) + .WithSchema("DeletedEvent", static schema => CarrierArm(schema, "deleted")) + .WithSchema("ExampleEvent", static schema => schema.AnyOf(static branch => branch.Ref("CreatedEvent"), static branch => branch.Ref("DeletedEvent"))) + .WithSchema("ExampleValue", static schema => schema.AnyOf(static branch => branch.Type("string"), static branch => branch.Type("integer"))) + .WithSchema("Feed", static schema => schema.Type("object") + .Property("event", static property => property.Ref("ExampleEvent"), required: true) + .Property("value", static property => property.Ref("ExampleValue"), required: true)) + .WithOperation("widget.list", configure: static operation => operation.Response(200, "application/json", static schema => schema.Ref("Feed"))))); + var curation = Curation(Groups("widget", ClientGroup("Widgets", null, null)), + hoistedMemberNames: [HoistedMemberName("IExampleEvent", "durable", name)]); + var exception = Assert.Throws(() => new BindingTestHost().Bind(document, Selection("widget.list"), curation)); + await Assert.That(exception.Errors).IsEquivalentTo([ + new BindingError(BindingErrorCategory.Naming, "IExampleEvent", $"hoisted member 'durable' resolves to '{name}', which already names another generated type"), + ], CollectionOrdering.Matching); + } + + private static void CarrierArm(SchemaBuilder schema, string tag) => schema.Type("object") + .Property("type", property => property.Type("string").Enum(tag), required: true) + .Property("durable", static property => property.Type("object") + .Property("seq", static member => member.Type("string"), required: true), required: true); + [Test] public async Task Bind_Should_Keep_The_First_Carrier_Name_In_Depth_Then_Ordinal_Union_Order() { @@ -375,6 +448,7 @@ private static UnionHoistPlan Hoist(IReadOnlyList unions, IReadOnlyLi { var errors = new BindingErrorCollector(); var plan = new UnionMemberHoistBinder().Bind(unions, models, curation ?? Curation(Groups()), errors); + CheckNames(plan, errors); errors.ThrowIfAny(); return plan; } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/UnionHoistPlanData.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/UnionHoistPlanData.cs index 0bff5b33..51780a37 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/UnionHoistPlanData.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/UnionHoistPlanData.cs @@ -21,6 +21,7 @@ public static UnionHoistPlan HoistDurableCarrier(GenerationCuration curation) .. arms.Select(static arm => Variant(arm.Name, arm.Tag, Property("durable", Named(arm.Name + "Durable"), isRequired: true))), .. arms.Select(static arm => Envelope(arm.Name + "Durable", arm.Version)), ], curation, errors); + CheckNames(result, errors); errors.ThrowIfAny(); return result; } @@ -42,6 +43,14 @@ public static UnionPlan Union(string name, params UnionVariantPlan[] variants) }; } + public static void CheckNames(UnionHoistPlan plan, BindingErrorCollector errors) => + new OperationPlanBinder().CheckTypeNameCollisions(GeneratedTypeNameInventory.Create(new OwnershipCandidates + { + Models = plan.Models, + Unions = plan.Unions, + HoistedInterfaces = plan.Interfaces, + }, [], plan.Claims, new Dictionary(StringComparer.Ordinal)), [], errors); + public static UnionVariantPlan Arm(string typeName) => new() { @@ -137,6 +146,7 @@ public static EmitPlan HoistedEmitPlan(IReadOnlyList unions, IReadOnl var errors = new BindingErrorCollector(); var hoisted = new UnionMemberHoistBinder().Bind(unions, models, curation, errors); + CheckNames(hoisted, errors); errors.ThrowIfAny(); return new EmitPlan { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs index 1b197234..94dab278 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs @@ -8,6 +8,10 @@ internal sealed class BindingErrorCollector public int Count => _errors.Count; + public IReadOnlyList Snapshot() => Array.AsReadOnly([.. _errors]); + + public void Append(IEnumerable errors) => _errors.AddRange(errors); + public BindingError Add(BindingErrorCategory category, string subject, string problem, BindingErrorKind kind = BindingErrorKind.Unspecified) { ArgumentException.ThrowIfNullOrWhiteSpace(subject); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/EnvelopeFacetBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/EnvelopeFacetBinder.cs index 6017d9a3..7e0094d0 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/EnvelopeFacetBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/EnvelopeFacetBinder.cs @@ -6,9 +6,9 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; /// Binds the facet of a non-streaming success. internal sealed class EnvelopeFacetBinder(OperationFacetContext context) { - private readonly OperationFacetContext _context = context ?? throw new ArgumentNullException(nameof(context)); + private readonly OperationFacetContext _context = context.CreateLocal(); - public EnvelopePlan? Bind(SpecResponse success) + public FacetResult Bind(SpecResponse success) { ArgumentNullException.ThrowIfNull(success); @@ -19,13 +19,13 @@ internal sealed class EnvelopeFacetBinder(OperationFacetContext context) if (IsBinary(success)) { - return BindBinaryEnvelope(); + return new FacetResult.Bound(BindBinaryEnvelope()); } if (success.ContentType is not { IsJson: true } || success.Schema is null) { _context.Refuse("the success response must carry a JSON schema"); - return null; + return _context.Refused(); } // Read once and threaded through: SingleKeyMember refuses a malformed wrapper by name, so @@ -39,12 +39,12 @@ internal sealed class EnvelopeFacetBinder(OperationFacetContext context) : null; if (success.EnvelopeShape is SpecEnvelopeShape.DataLocation && locationTypeName is null) { - return null; + return _context.Refused(); } if (payload is null) { - return null; + return _context.Refused(); } // A single-key body wears its payload's own key; every other wrapped shape uses the @@ -55,7 +55,7 @@ internal sealed class EnvelopeFacetBinder(OperationFacetContext context) : "data"; if (wireMemberName is null) { - return null; + return _context.Refused(); } var responseTypeName = OperationNamePolicy.ResponseTypeName(_context.Operation); @@ -67,11 +67,11 @@ success.EnvelopeShape is SpecEnvelopeShape.SingleKey success.EnvelopeShape is SpecEnvelopeShape.SingleKey); if (payloadName is null) { - return null; + return _context.Refused(); } var kind = KindOf(success); - return new EnvelopePlan + return new FacetResult.Bound(new EnvelopePlan { ResponseTypeName = responseTypeName, AdapterTypeName = $"{responseTypeName}Adapter", @@ -86,7 +86,7 @@ success.EnvelopeShape is SpecEnvelopeShape.SingleKey { Summary = kind is EnvelopeKind.Bare ? null : success.Schema.Description ?? _context.Resolve(success.Schema).Description, }, - }; + }); } private EnvelopeKind KindOf(SpecResponse success) => success.EnvelopeShape switch @@ -201,16 +201,16 @@ private EnvelopePlan BindBinaryEnvelope() return payloadName; } - private EnvelopePlan? BindNoContentEnvelope(SpecResponse success) + private FacetResult BindNoContentEnvelope(SpecResponse success) { if (success.ContentType is not null || success.Schema is not null) { _context.Refuse("a 204 success must not carry content"); - return null; + return _context.Refused(); } var responseTypeName = OperationNamePolicy.ResponseTypeName(_context.Operation); - return new EnvelopePlan + return new FacetResult.Bound(new EnvelopePlan { ResponseTypeName = responseTypeName, AdapterTypeName = $"{responseTypeName}Adapter", @@ -219,7 +219,7 @@ private EnvelopePlan BindBinaryEnvelope() Kind = EnvelopeKind.NoContent, SuccessStatusCode = 204, EnvelopeDtoTypeName = null, - }; + }); } private TypeReferencePlan? BindBarePayload(SchemaNode schema) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorMapBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorMapBinder.cs new file mode 100644 index 00000000..542c12ac --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorMapBinder.cs @@ -0,0 +1,128 @@ +using System.Globalization; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +internal sealed class ErrorMapBinder(OperationFacetContext context, ErrorUnionBindingResult errorUnion) +{ + private readonly OperationFacetContext _context = context.CreateLocal(); + private readonly ErrorUnionBindingResult _errorUnion = errorUnion; + + public FacetResult Bind() + { + var statuses = new List(); + var complete = true; + foreach (var response in _context.Operation.Responses.Where(static response => response.StatusCode is >= 400)) + { + var tags = BindErrorStatus(response); + if (tags is null) + { + complete = false; + continue; + } + statuses.Add(new ErrorStatusPlan { StatusCode = response.StatusCode, Tags = tags }); + } + + return complete + ? new FacetResult.Bound(new ErrorMapPlan { Statuses = [.. statuses.OrderBy(static status => status.StatusCode)] }) + : _context.Refused(); + } + + private IReadOnlyList? BindErrorStatus(SpecResponse response) + { + if (response.ContentType is not { IsJson: true } || response.Schema is null) + { + return RefuseNullTags("error responses must carry a JSON schema"); + } + + var targets = ResolveErrorTargets(response.Schema); + if (targets is null) + { + return RefuseNullTags("error responses must reference tagged error schemas"); + } + + var tags = new List(targets.Count); + foreach (var (key, node) in targets) + { + if (_errorUnion.Refusals.TryGetValue(key, out var refusal)) + { + if (refusal.Category is BindingErrorCategory.Naming) + { + // An unnamed closure member has no marker verdict yet; its marker remains an independent wall. + if (ErrorMarkerPolicy.Resolve(node, out var markerProblem) is null) + { + return RefuseNullTags($"error responses must reference tagged error schemas: '{key}': {markerProblem}"); + } + _context.Errors.Append([refusal]); + return null; + } + return RefuseNullTags($"error responses must reference tagged error schemas: '{key}': {refusal.Problem}"); + } + + if (_errorUnion.Variants.TryGetValue(key, out var variant)) + { + tags.Add(new ErrorTagPlan { Tag = variant.Tag, TypeName = variant.TypeName }); + continue; + } + + // Untagged/name-data objects and stream causes do not belong to the error-union closure. + if (ErrorMarkerPolicy.Resolve(node, out var problem) is not { } marker) + { + return RefuseNullTags($"error responses must reference tagged error schemas: '{key}': {problem}"); + } + + if (!_context.TypeNames.TryGetValue(key, out var typeName)) + { + _context.Errors.Add(BindingErrorCategory.Naming, key, "error schema has no unique C# type name"); + return null; + } + + tags.Add(new ErrorTagPlan { Tag = marker.Value, TypeName = typeName }); + } + + var duplicate = tags.GroupBy(static tag => tag.Tag, StringComparer.Ordinal).FirstOrDefault(static tag => tag.Skip(1).Any()); + if (duplicate is not null) + { + return RefuseNullTags($"status '{response.StatusCode.ToString(CultureInfo.InvariantCulture)}' declares duplicate error tag '{duplicate.Key}'"); + } + + return [.. tags.OrderBy(static tag => tag.Tag, StringComparer.Ordinal)]; + } + + /// Resolves a direct object or one level of marked-union branches, not arbitrary schema topology. + private List>? ResolveErrorTargets(SchemaNode schema) + { + if (schema is not RefNode reference || !_context.Document.Schemas.TryGetValue(reference.Target, out var target)) + { + return null; + } + + switch (target) + { + case ObjectNode objectNode: + return [new KeyValuePair(reference.Target, objectNode)]; + case UnionNode { Classification: UnionClassification.Marked } union: + var branches = new List>(union.Branches.Count); + foreach (var branch in union.Branches) + { + if (branch is not RefNode branchReference + || !_context.Document.Schemas.TryGetValue(branchReference.Target, out var branchTarget) + || branchTarget is not ObjectNode branchNode) + { + return null; + } + branches.Add(new KeyValuePair(branchReference.Target, branchNode)); + } + return branches; + default: + return null; + } + } + + private IReadOnlyList? RefuseNullTags(string problem) + { + _context.Refuse(problem); + return null; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorUnionBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorUnionBinder.cs index 1a86f365..c41e81b9 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorUnionBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ErrorUnionBinder.cs @@ -6,7 +6,8 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; internal sealed class ErrorUnionBinder(SchemaBindingContext context) { - private readonly SchemaBindingContext _context = context; + private readonly SchemaBindingContext _context = context with { Errors = new BindingErrorCollector() }; + private readonly BindingErrorCollector _publishedErrors = context.Errors; public ErrorUnionBindingResult Bind() { @@ -68,8 +69,10 @@ public ErrorUnionBindingResult Bind() }; } + var errors = _context.Errors.Snapshot(); + _publishedErrors.Append(errors); return new ErrorUnionBindingResult(new UnionBindingResult(null, plan, edges.AsReadOnly()), - new ReadOnlyDictionary(admitted), new ReadOnlyDictionary(refused)); + new ReadOnlyDictionary(admitted), new ReadOnlyDictionary(refused), errors); } /// diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ExperimentalModelPolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ExperimentalModelPolicy.cs index 01a22ddf..8324f5dd 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/ExperimentalModelPolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ExperimentalModelPolicy.cs @@ -10,11 +10,12 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; internal static class ExperimentalModelPolicy { public static IReadOnlyDictionary Collect(ReachableSchemaCollector reachableSchemas, ReachScope scope, - OwnershipCandidates candidates, BindingErrorCollector errors) + OwnershipCandidates candidates, GeneratedTypeNameInventory inventory, BindingErrorCollector errors) { ArgumentNullException.ThrowIfNull(reachableSchemas); ArgumentNullException.ThrowIfNull(scope); ArgumentNullException.ThrowIfNull(candidates); + ArgumentNullException.ThrowIfNull(inventory); ArgumentNullException.ThrowIfNull(errors); var diagnostics = scope.Clients.SelectMany(static client => client.Operations) @@ -23,15 +24,11 @@ public static IReadOnlyDictionary Collect(ReachableSchemaCollect scope with { Selected = [.. scope.Selected.Where(static operation => !ExperimentalRoutePolicy.IsExperimental(operation.Path))] }, candidates, errors); var reachedBy = new Dictionary>(StringComparer.Ordinal); - var generatedTypes = candidates.Models.Select(static model => model.Name) - .Concat(candidates.Unions.SelectMany(static union => new[] { union.Name, union.UnknownTypeName })) - .Concat(candidates.Models.OfType().Select(static union => union.KindTypeName)) - .Concat(candidates.HoistedInterfaces.Select(static plan => plan.Name)).ToHashSet(StringComparer.Ordinal); foreach (var subset in scope.Selected.Where(operation => diagnostics.GetValueOrDefault(operation.OperationId) is not null) .GroupBy(operation => diagnostics[operation.OperationId]!, StringComparer.Ordinal)) { var reached = ModelReachCollector.CollectSubset(reachableSchemas, scope with { Selected = [.. subset] }, candidates, errors); - foreach (var name in reached.Where(name => generatedTypes.Contains(name) && !stable.Contains(name))) + foreach (var name in reached.Where(name => inventory.Names.Contains(name) && !stable.Contains(name))) { if (!reachedBy.TryGetValue(name, out var ids)) { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/GeneratedTypeNameInventory.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/GeneratedTypeNameInventory.cs new file mode 100644 index 00000000..a8165fce --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/GeneratedTypeNameInventory.cs @@ -0,0 +1,59 @@ +using System.Collections.Frozen; +using OpenCode.Sdk.Tools.Generator.Binding.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +/// The actual generated declarations, in collision-claim order, retaining their diagnostic owners. +internal sealed class GeneratedTypeNameInventory +{ + internal enum ClaimRole { Model, Client, Response, StreamAdapter, QueryRequest, EnvelopeDto, Hoisted, Derived } + + private readonly ILookup _byName; + + private GeneratedTypeNameInventory(IReadOnlyList claims) + { + Claims = Array.AsReadOnly([.. claims]); + Names = claims.Select(static claim => claim.Name).ToFrozenSet(StringComparer.Ordinal); + _byName = claims.ToLookup(static claim => claim.Name, StringComparer.Ordinal); + } + + public IReadOnlyList Claims { get; } + + public IReadOnlySet Names { get; } + + public IEnumerable OtherClaims(GeneratedTypeNameClaim claim) => + _byName[claim.Name].Where(other => !ReferenceEquals(other, claim)); + + public static GeneratedTypeNameInventory Create(OwnershipCandidates candidates, IReadOnlyList clients, + IReadOnlyList hoistedClaims, IReadOnlyDictionary schemaNames) + { + ArgumentNullException.ThrowIfNull(candidates); + ArgumentNullException.ThrowIfNull(clients); + ArgumentNullException.ThrowIfNull(hoistedClaims); + ArgumentNullException.ThrowIfNull(schemaNames); + var schemaOwners = schemaNames.ToLookup(static entry => entry.Value, static entry => entry.Key, StringComparer.Ordinal); + var claims = schemaNames.Select(static entry => new GeneratedTypeNameClaim(entry.Value, entry.Key, ClaimRole.Model)).ToList(); + foreach (var name in candidates.Models.Select(static model => model.Name).Concat(candidates.Unions.Select(static union => union.Name)) + .Where(name => !schemaOwners.Contains(name))) + { + claims.Add(new GeneratedTypeNameClaim(name, name, ClaimRole.Model)); + } + claims.AddRange(candidates.Models.OfType() + .Select(static model => new GeneratedTypeNameClaim(model.KindTypeName, model.Name, ClaimRole.Derived))); + claims.AddRange(candidates.Unions.Select(static union => new GeneratedTypeNameClaim(union.UnknownTypeName, union.Name, ClaimRole.Derived))); + claims.AddRange(clients.Select(static client => new GeneratedTypeNameClaim(client.Name, client.Name, ClaimRole.Client))); + var operations = clients.SelectMany(static client => client.Operations).OrderBy(static operation => operation.OperationId, StringComparer.Ordinal).ToArray(); + claims.AddRange(operations.Where(static operation => operation.Envelope is not null) + .Select(static operation => new GeneratedTypeNameClaim(operation.Envelope!.ResponseTypeName, operation.OperationId, ClaimRole.Response))); + claims.AddRange(operations.Where(static operation => operation.Stream is not null) + .Select(static operation => new GeneratedTypeNameClaim(operation.Stream!.AdapterTypeName, operation.OperationId, ClaimRole.StreamAdapter))); + claims.AddRange(operations.Where(static operation => operation.QueryRequest is { RidesRequestBody: false }) + .Select(static operation => new GeneratedTypeNameClaim(operation.QueryRequest!.TypeName, operation.OperationId, ClaimRole.QueryRequest))); + claims.AddRange(operations.Where(static operation => operation.Envelope is not null) + .Select(static operation => new GeneratedTypeNameClaim(operation.Envelope!.AdapterTypeName, operation.OperationId, ClaimRole.Derived))); + claims.AddRange(operations.Where(static operation => operation.Envelope?.EnvelopeDtoTypeName is not null) + .Select(static operation => new GeneratedTypeNameClaim(operation.Envelope!.EnvelopeDtoTypeName!, operation.OperationId, ClaimRole.EnvelopeDto))); + claims.AddRange(hoistedClaims); + return new GeneratedTypeNameInventory(claims); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/HoistedCarrierComposer.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/HoistedCarrierComposer.cs index 637fe61c..e9c68dc8 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/HoistedCarrierComposer.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/HoistedCarrierComposer.cs @@ -13,7 +13,6 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; internal sealed class HoistedCarrierComposer( IReadOnlyDictionary models, IReadOnlyList namingRows, - ISet takenNames, HoistedMemberSatisfactionPolicy satisfaction, HoistedImplementationAccumulator implementations, BindingErrorCollector errors) @@ -31,7 +30,9 @@ internal sealed class HoistedCarrierComposer( CurationRowContract.Validate(nameof(GenerationCuration.HoistedMemberNames), namingRows ?? throw new ArgumentNullException(nameof(namingRows)), errors) ? namingRows : []; - private readonly ISet _takenNames = takenNames ?? throw new ArgumentNullException(nameof(takenNames)); + private readonly List _claims = []; + + public IReadOnlyList Claims => _claims.AsReadOnly(); private readonly Dictionary _byRecordSet = new(StringComparer.Ordinal); @@ -59,22 +60,22 @@ internal sealed class HoistedCarrierComposer( } var name = curated ?? string.Concat("I", ownerConcept, property.Name); - if (!_takenNames.Add(name)) - { - _errors.Add(BindingErrorCategory.Naming, owner, - $"hoisted member '{property.WireName}' resolves to '{name}', which already names another generated type"); - return null; - } - - _byRecordSet[key] = name; - _interfaces[name] = new HoistedInterfacePlan + _claims.Add(new GeneratedTypeNameClaim(name, owner, GeneratedTypeNameInventory.ClaimRole.Hoisted, property.WireName)); + var plan = new HoistedInterfacePlan { Name = name, Namespace = GeneratedNamespace.Models, ImplementedBy = recordNames, - Members = ComposeMembers(name, recordNames), + Members = [], Description = $"Represents the shared {GeneratedDisplayName.Of(property.Name)} shape of every {GeneratedDisplayName.Of(ownerConcept)} variant.", }; + if (!_interfaces.TryAdd(name, plan)) + { + return null; + } + _byRecordSet[key] = name; + // Claim before recursion, so a nested carrier cannot build the same interface a second time. + _interfaces[name] = plan with { Members = ComposeMembers(name, recordNames) }; foreach (var recordName in recordNames) { _implementations.AddCarrier(recordName, name); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/InternalNameCollisionCheck.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/InternalNameCollisionCheck.cs index 87ba9811..d4a748dd 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/InternalNameCollisionCheck.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/InternalNameCollisionCheck.cs @@ -10,27 +10,13 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; /// internal static class InternalNameCollisionCheck { - public static void Check(OwnershipCandidates candidates, IReadOnlyList clients, BindingErrorCollector errors) + public static void Check(GeneratedTypeNameInventory inventory, BindingErrorCollector errors) { - ArgumentNullException.ThrowIfNull(candidates); - ArgumentNullException.ThrowIfNull(clients); + ArgumentNullException.ThrowIfNull(inventory); ArgumentNullException.ThrowIfNull(errors); - var operations = clients.SelectMany(static client => client.Operations).ToArray(); - var names = candidates.Models.Select(static model => model.Name) - .Concat(candidates.Models.OfType().Select(static model => model.KindTypeName)) - .Concat(candidates.Unions.SelectMany(static union => new[] { union.Name, union.UnknownTypeName })) - .Concat(candidates.HoistedInterfaces.Select(static hoisted => hoisted.Name)) - .Concat(clients.Select(static client => client.Name)) - .Concat(operations.Select(static operation => operation.QueryRequest?.TypeName)) - .Concat(operations.Select(static operation => operation.Envelope?.ResponseTypeName)) - .Concat(operations.Select(static operation => operation.Envelope?.AdapterTypeName)) - .Concat(operations.Select(static operation => operation.Envelope?.EnvelopeDtoTypeName)) - .Concat(operations.Select(static operation => operation.Stream?.AdapterTypeName)) - .OfType(); - foreach (var name in names + foreach (var name in inventory.Names .Where(static name => ReservedNamePolicy.InternalTypeNames.Contains(name)) - .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal)) { errors.Add(BindingErrorCategory.Naming, name, $"type name '{name}' collides with the hand-written internal type '{name}'"); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BoundOperation.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BoundOperation.cs new file mode 100644 index 00000000..b0cd548b --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BoundOperation.cs @@ -0,0 +1,12 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record BoundOperation +{ + public required string OperationId { get; init; } + + public required GroupCuration Row { get; init; } + + public required bool IsHandleOperation { get; init; } + + public required OperationPlan Plan { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ErrorUnionBindingResult.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ErrorUnionBindingResult.cs index bfb080d2..2ec3cf35 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ErrorUnionBindingResult.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ErrorUnionBindingResult.cs @@ -3,4 +3,5 @@ namespace OpenCode.Sdk.Tools.Generator.Binding.Models; internal sealed record ErrorUnionBindingResult( UnionBindingResult Union, IReadOnlyDictionary Variants, - IReadOnlyDictionary Refusals); + IReadOnlyDictionary Refusals, + IReadOnlyList Errors); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/FacetResult.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/FacetResult.cs new file mode 100644 index 00000000..459c77f0 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/FacetResult.cs @@ -0,0 +1,23 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal abstract record FacetResult +{ + private FacetResult() { } + + public sealed record Bound(T Value) : FacetResult; + + public sealed record Refused : FacetResult + { + public Refused(IReadOnlyList errors) + { + ArgumentNullException.ThrowIfNull(errors); + if (errors.Count is 0) + { + throw new ArgumentException("A refused facet must carry its reason.", nameof(errors)); + } + Errors = Array.AsReadOnly([.. errors]); + } + + public IReadOnlyList Errors { get; } + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GeneratedTypeNameClaim.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GeneratedTypeNameClaim.cs new file mode 100644 index 00000000..103a4f0b --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GeneratedTypeNameClaim.cs @@ -0,0 +1,4 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record GeneratedTypeNameClaim(string Name, string Subject, + GeneratedTypeNameInventory.ClaimRole Role, string? WireName = null); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs index 1d43c902..074da037 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs @@ -2,12 +2,16 @@ namespace OpenCode.Sdk.Tools.Generator.Binding.Models; internal sealed record SchemaBindingResult { + public required IReadOnlyDictionary TypeNames { get; init; } + public required IReadOnlyList Models { get; init; } public required IReadOnlyList Unions { get; init; } public required IReadOnlyList HoistedInterfaces { get; init; } + public required IReadOnlyList HoistedClaims { get; init; } + public required RegistryPlan Registry { get; init; } public required ErrorUnionBindingResult ErrorUnion { get; init; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionHoistPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionHoistPlan.cs index b38e13ec..a680937d 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionHoistPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionHoistPlan.cs @@ -4,6 +4,8 @@ namespace OpenCode.Sdk.Tools.Generator.Binding.Models; /// promoted-record carriers gain their interfaces. internal sealed record UnionHoistPlan { + public required IReadOnlyList Claims { get; init; } + public required IReadOnlyList Unions { get; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ObjectModelBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ObjectModelBinder.cs index 843175d3..84be3840 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/ObjectModelBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ObjectModelBinder.cs @@ -7,20 +7,23 @@ internal sealed class ObjectModelBinder(SchemaBindingContext context) { private readonly SchemaBindingContext _context = context; - public ObjectModelPlan? Bind(string key, string name, ObjectNode node, IReadOnlyList implementedNames, bool isRequestReachable) + public FacetResult Bind(string key, string name, ObjectNode node, IReadOnlyList implementedNames, bool isRequestReachable) { - var errors = _context.Errors; + var errors = new BindingErrorCollector(); + var types = new TypePlanBinder(_context.Document.Schemas, _context.TypeNames, errors); if (!TryAdmitAdditionalProperties(key, node, errors, out var emitsExtensionData)) { - return null; + return new FacetResult.Refused(errors.Snapshot()); } var properties = new List(node.Properties.Count); + var refused = false; foreach (var property in node.Properties) { - var type = _context.Types.Bind(key, property.Name, property.Schema); + var type = types.Bind(key, property.Name, property.Schema); if (type is null) { + refused = true; continue; } @@ -51,9 +54,9 @@ internal sealed class ObjectModelBinder(SchemaBindingContext context) }); } - if (properties.Count != node.Properties.Count) + if (refused) { - return null; + return new FacetResult.Refused(errors.Snapshot()); } var duplicate = properties.GroupBy(static property => property.Name, StringComparer.Ordinal) @@ -61,15 +64,15 @@ internal sealed class ObjectModelBinder(SchemaBindingContext context) if (duplicate is not null) { errors.Add(BindingErrorCategory.Naming, key, $"multiple properties map to C# name '{duplicate.Key}'"); - return null; + return new FacetResult.Refused(errors.Snapshot()); } if (emitsExtensionData && TakesExtensionDataMemberName(key, properties, errors)) { - return null; + return new FacetResult.Refused(errors.Snapshot()); } - return new ObjectModelPlan + return new FacetResult.Bound(new ObjectModelPlan { Name = name, Namespace = GeneratedNamespace.Models, @@ -77,7 +80,7 @@ internal sealed class ObjectModelBinder(SchemaBindingContext context) Properties = properties, ImplementedUnionNames = implementedNames, EmitsExtensionData = emitsExtensionData, - }; + }); } /// diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationFacetContext.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationFacetContext.cs index c7e4dc03..24acc71e 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationFacetContext.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationFacetContext.cs @@ -32,6 +32,32 @@ internal sealed class OperationFacetContext( public void Refuse(string problem) => Errors.Add(BindingErrorCategory.Operation, Operation.OperationId, problem); + public OperationFacetContext CreateLocal() + { + var localErrors = new BindingErrorCollector(); + return new OperationFacetContext(Document, Operation, Curation, TypeNames, localErrors, + new TypePlanBinder(Document.Schemas, TypeNames, localErrors)); + } + + public FacetResult Refused() => new FacetResult.Refused(Errors.Snapshot()); + + public FacetResult RefuseFacet(string problem) + { + Refuse(problem); + return Refused(); + } + + public FacetResult Publish(FacetResult result, IReadOnlyList? alreadyPublished = null) + { + if (result is FacetResult.Refused refused) + { + // Only borrowed verdict objects are already published; unrelated equal errors remain independent. + Errors.Append(refused.Errors.Where(error => alreadyPublished is null + || !alreadyPublished.Any(original => ReferenceEquals(original, error)))); + } + return result; + } + public string? RefuseNull(string problem) { Refuse(problem); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 681ed61d..a029edfa 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Globalization; using OpenCode.Sdk.Tools.Generator.Binding.Models; using OpenCode.Sdk.Tools.Generator.Ingestion.Models; @@ -12,19 +11,20 @@ internal sealed class OperationPlanBinder private readonly StringComparer _comparer = StringComparer.Ordinal; public IReadOnlyList Bind(SpecDocument document, IReadOnlyList selected, - GenerationCuration curation, IReadOnlyDictionary typeNames, BindingErrorCollector errors) + GenerationCuration curation, SchemaBindingResult schemas, BindingErrorCollector errors) { ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(selected); ArgumentNullException.ThrowIfNull(curation); - ArgumentNullException.ThrowIfNull(typeNames); + ArgumentNullException.ThrowIfNull(schemas); ArgumentNullException.ThrowIfNull(errors); + var typeNames = schemas.TypeNames; var typeBinder = new TypePlanBinder(document.Schemas, typeNames, errors); var bound = new List(selected.Count); foreach (var operation in selected.OrderBy(static operation => operation.OperationId, _comparer)) { - var operationPlan = new SingleOperationBinder(document, operation, curation, typeNames, errors, typeBinder).Bind(); + var operationPlan = new SingleOperationBinder(new OperationFacetContext(document, operation, curation, typeNames, errors, typeBinder), schemas.ErrorUnion).Bind(); if (operationPlan is not null) { bound.Add(operationPlan); @@ -38,7 +38,6 @@ public IReadOnlyList Bind(SpecDocument document, IReadOnlyList clients, BindingErrorCollect } } - private void CheckTypeNameCollisions(List clients, List bound, - IReadOnlyDictionary typeNames, BindingErrorCollector errors) + public IReadOnlySet CheckTypeNameCollisions(GeneratedTypeNameInventory inventory, IReadOnlyList clients, BindingErrorCollector errors) { - // Model names shadow across namespaces (consumer CS0104) and hand-written spine - // names collide outright (CS0101), so both seed the owner set — reserved names - // first, each model name individually, because a bulk union would swallow a - // model/spine duplicate instead of refusing it. - var owners = new HashSet(_comparer); - owners.UnionWith(ReservedNamePolicy.SpineTypeNames); - foreach (var entry in typeNames - .Where(entry => !owners.Add(entry.Value) && ReservedNamePolicy.SpineTypeNames.Contains(entry.Value)) - .OrderBy(static entry => entry.Key, StringComparer.Ordinal)) - { - errors.Add( - BindingErrorCategory.Naming, - entry.Key, - $"model type name '{entry.Value}' collides with the hand-written spine type '{entry.Value}'"); - } - - foreach (var name in clients.Select(static client => client.Name).Where(name => !owners.Add(name))) - { - errors.Add(BindingErrorCategory.Naming, name, $"client type name '{name}' collides with another generated type"); - } - - // A streaming operation declares no response type; its adapter name is claimed below. - foreach (var operation in bound.Where(operation => - operation.Plan.Envelope is { } envelope && !owners.Add(envelope.ResponseTypeName))) - { - errors.Add( - BindingErrorCategory.Naming, - operation.OperationId, - $"multiple operations map to response type '{operation.Plan.Envelope!.ResponseTypeName}'"); - } - - foreach (var operation in bound.Where(operation => - operation.Plan.Stream is { } stream && !owners.Add(stream.AdapterTypeName))) - { - errors.Add( - BindingErrorCategory.Naming, - operation.OperationId, - $"multiple operations map to stream adapter '{operation.Plan.Stream!.AdapterTypeName}'"); - } - - // A merged request's type IS the body model, so only standalone query records - // claim a name of their own. - foreach (var operation in bound.Where(operation => - operation.Plan.QueryRequest is { RidesRequestBody: false } query && !owners.Add(query.TypeName))) - { - errors.Add( - BindingErrorCategory.Naming, - operation.OperationId, - $"request type name '{operation.Plan.QueryRequest!.TypeName}' collides with another generated type"); - } - - var routeMembers = new HashSet(_comparer); - foreach (var operation in bound.Where(operation => - !routeMembers.Add($"{operation.Plan.RouteContainerName}.{operation.Plan.RouteMemberName}"))) - { - errors.Add( - BindingErrorCategory.Naming, - operation.OperationId, - $"multiple operations map to route member '{operation.Plan.RouteContainerName}.{operation.Plan.RouteMemberName}'"); - } - } - - private sealed record BoundOperation - { - public required string OperationId { get; init; } - - public required string Group { get; init; } - - public required GroupCuration Row { get; init; } - - public required bool IsHandleOperation { get; init; } - - public required OperationPlan Plan { get; init; } - } - - /// - /// Binds one selected operation, batching every wire-contract refusal it finds. The - /// per-facet work lives in the facet binders over the shared context; this class owns - /// the orchestration order, the names, the body-and-query merge policy, the error map, - /// and the path parameters. A new operation shape is one facet file plus one call here. - /// - private sealed class SingleOperationBinder( - SpecDocument document, - SpecOperation operation, - GenerationCuration curation, - IReadOnlyDictionary typeNames, - BindingErrorCollector errors, - TypePlanBinder types) - { - private readonly OperationFacetContext _context = new(document, operation, curation, typeNames, errors, types); - - public BoundOperation? Bind() - { - var group = _context.Operation.Segments[0]; - if (!_context.Curation.Groups.TryGetValue(group, out var row)) - { - // The curation validator already reported the missing row. - return null; - } - - if (!new OperationWireShapeWall(_context, row.Emission).Check()) - { - return null; - } - - var success = _context.Operation.Responses.Single(static response => response.StatusCode is 200 or 204); - var stream = success.IsSse ? new StreamFacetBinder(_context).Bind(success) : null; - var envelope = success.IsSse ? null : new EnvelopeFacetBinder(_context).Bind(success); - if (success.IsSse && _context.Operation.RequestBody is not null) - { - _context.Refuse("streaming operations must not carry a request body"); - return null; - } - - var errorMap = BindErrorMap(); - var declaredHeaders = BindDeclaredHeaders(); - var parameters = BindParameters(row, declaredHeaders); - var optionalPlanErrorsBefore = _context.Errors.Count; - var (queryRequest, requestBody) = BindRequests(); - - var handle = parameters?.Any(static parameter => parameter.IsHandleParameter) ?? false; - var (methodName, routeMemberName, rowRequired) = ResolveNames(row, handle); - if (rowRequired is not null) - { - _context.Refuse(rowRequired); - return null; - } - - if (methodName is null || routeMemberName is null) - { - _context.Refuse("the operation's names cannot be derived mechanically: the group does not pluralize naively"); - return null; - } - - var pagination = new PaginationFacetBinder(_context) - .Bind(methodName, parameters, declaredHeaders, queryRequest, requestBody, envelope); - - if ((success.IsSse ? stream is null : envelope is null) - || errorMap is null || parameters is null || _context.Errors.Count != optionalPlanErrorsBefore) - { - return null; - } - - return new BoundOperation - { - OperationId = _context.Operation.OperationId, - Group = group, - Row = row, - IsHandleOperation = handle, - Plan = new OperationPlan - { - OperationId = _context.Operation.OperationId, - MethodName = methodName, - HttpMethod = _context.Operation.Method, - RouteTemplate = _context.Operation.Path, - ExperimentalDiagnostic = ExperimentalRoutePolicy.IsExperimental(_context.Operation.Path) ? row.ExperimentalDiagnostic : null, - RouteContainerName = row.ClientName ?? CSharpNamePolicy.ToPascalCase(group), - RouteMemberName = routeMemberName, - Parameters = parameters, - DeclaredHeaders = declaredHeaders, - QueryRequest = queryRequest, - RequestBody = requestBody, - Envelope = envelope, - Stream = stream, - Pagination = pagination, - ErrorMap = errorMap, - Summary = _context.Operation.Summary, - Description = _context.Operation.Description, - }, - }; - } - - /// - /// The names a row or the mechanical policy give the operation; the third member is the - /// refusal when neither may name it — the HTTP method is never a name source (ADR-0008). - /// - private (string? MethodName, string? RouteMemberName, string? RowRequired) ResolveNames(GroupCuration row, bool handle) - { - var curatedName = _context.Curation.OperationNames.FirstOrDefault(operationName => - string.Equals(operationName.OperationId, _context.Operation.OperationId, StringComparison.Ordinal)); - if (curatedName is null && (OperationNamePolicy.RowRequiredProblem(_context.Operation) - ?? OperationNamePolicy.AmbiguousPluralProblem(_context.Operation, handle: handle)) is { } rowRequired) - { - return (null, null, rowRequired); - } - - return (OperationNamePolicy.MethodName(_context.Operation, curatedName, handle), - OperationNamePolicy.RouteMemberName(_context.Operation, row.Placement, curatedName, handle), - null); - } - - private (QueryRequestPlan? Query, RequestBodyPlan? Body) BindRequests() - { - var queryRequest = new QueryRequestFacetBinder(_context).Bind(); - var requestBody = new RequestBodyFacetBinder(_context).Bind(); - // A raw-byte body is the caller's stream, not a model the query can ride, so its - // query parameters keep their own request record beside it. - if (queryRequest is null || requestBody is null || requestBody.IsBinary) - { - return (queryRequest, requestBody); - } - - // A body and query merge into one uniform request model only for the location - // channel; every other mix keeps the deliberate wall. - if (queryRequest.Properties.Any(static property => property.Kind is not QueryValueKind.Location)) - { - _context.Refuse("operations mixing a request body and query parameters are supported only for the location selector"); - return (queryRequest, requestBody); - } - - return (queryRequest with { RidesRequestBody = true }, requestBody); - } - - private ErrorMapPlan? BindErrorMap() - { - var statuses = new List(); - var complete = true; - foreach (var response in _context.Operation.Responses.Where(static response => response.StatusCode is >= 400)) - { - var tags = BindErrorStatus(response); - if (tags is null) - { - complete = false; - continue; - } - - statuses.Add(new ErrorStatusPlan - { - StatusCode = response.StatusCode, - Tags = tags, - }); - } - - return complete - ? new ErrorMapPlan - { - Statuses = [.. statuses.OrderBy(static status => status.StatusCode)], - } - : null; - } - - private IReadOnlyList? BindErrorStatus(SpecResponse response) + ArgumentNullException.ThrowIfNull(inventory); + ArgumentNullException.ThrowIfNull(clients); + ArgumentNullException.ThrowIfNull(errors); + var refusedHoists = new HashSet(_comparer); + var hoistedOwners = new HashSet(_comparer); + foreach (var claim in inventory.Claims.Where(claim => claim.Role is GeneratedTypeNameInventory.ClaimRole.Hoisted + && (!hoistedOwners.Add(claim.Name) || inventory.OtherClaims(claim).Any(static other => other.Role is not GeneratedTypeNameInventory.ClaimRole.Hoisted)))) { - if (response.ContentType is not { IsJson: true } || response.Schema is null) - { - return RefuseNullTags("error responses must carry a JSON schema"); - } - - var targets = ResolveErrorTargets(response.Schema); - if (targets is null) - { - return RefuseNullTags("error responses must reference tagged error schemas"); - } - - var tags = new List(targets.Count); - foreach (var (key, node) in targets) - { - if (ErrorMarkerPolicy.Resolve(node, out var problem) is not { } marker) - { - return RefuseNullTags($"error responses must reference tagged error schemas: '{key}': {problem}"); - } - - if (!_context.TypeNames.TryGetValue(key, out var typeName)) - { - _context.Errors.Add(BindingErrorCategory.Naming, key, "error schema has no unique C# type name"); - return null; - } - - tags.Add(new ErrorTagPlan - { - Tag = marker.Value, - TypeName = typeName, - }); - } - - var duplicate = tags.GroupBy(static tag => tag.Tag, StringComparer.Ordinal).FirstOrDefault(static tag => tag.Skip(1).Any()); - if (duplicate is not null) + if (inventory.OtherClaims(claim).Any(static other => other.Role is not GeneratedTypeNameInventory.ClaimRole.Hoisted)) { - return RefuseNullTags( - $"status '{response.StatusCode.ToString(CultureInfo.InvariantCulture)}' declares duplicate error tag '{duplicate.Key}'"); + _ = refusedHoists.Add(claim.Name); } - - return [.. tags.OrderBy(static tag => tag.Tag, StringComparer.Ordinal)]; + errors.Add(BindingErrorCategory.Naming, claim.Subject, + $"hoisted member '{claim.WireName}' resolves to '{claim.Name}', which already names another generated type"); } - /// Resolves an error response schema into the object schemas carrying its tags. - private List>? ResolveErrorTargets(SchemaNode schema) - { - if (schema is not RefNode reference || !_context.Document.Schemas.TryGetValue(reference.Target, out var target)) - { - return null; - } - - switch (target) - { - case ObjectNode objectNode: - return [new KeyValuePair(reference.Target, objectNode)]; - case UnionNode { Classification: UnionClassification.Marked } union: - var branches = new List>(union.Branches.Count); - foreach (var branch in union.Branches) - { - if (branch is not RefNode branchReference - || !_context.Document.Schemas.TryGetValue(branchReference.Target, out var branchTarget) - || branchTarget is not ObjectNode branchNode) - { - return null; - } - - branches.Add(new KeyValuePair(branchReference.Target, branchNode)); - } - - return branches; - default: - return null; - } + // Spine names claim first, followed by models individually so a model/spine duplicate refuses. + var owners = new HashSet(_comparer); + owners.UnionWith(ReservedNamePolicy.SpineTypeNames); + // Derived kit/adapter names reserve their declarations; their owning declaration reports duplicates. + owners.UnionWith(inventory.Claims.Where(static claim => claim.Role is GeneratedTypeNameInventory.ClaimRole.Derived).Select(static claim => claim.Name)); + foreach (var claim in inventory.Claims.Where(claim => claim.Role is GeneratedTypeNameInventory.ClaimRole.Model + && !owners.Add(claim.Name)) + .OrderBy(static claim => claim.Subject, StringComparer.Ordinal)) + { + errors.Add(BindingErrorCategory.Naming, claim.Subject, ReservedNamePolicy.SpineTypeNames.Contains(claim.Name) + ? $"model type name '{claim.Name}' collides with the hand-written spine type '{claim.Name}'" + : $"type name '{claim.Name}' collides with another generated type"); + } + + foreach (var claim in inventory.Claims.Where(claim => claim.Role is not (GeneratedTypeNameInventory.ClaimRole.Model + or GeneratedTypeNameInventory.ClaimRole.Hoisted or GeneratedTypeNameInventory.ClaimRole.EnvelopeDto + or GeneratedTypeNameInventory.ClaimRole.Derived) && !owners.Add(claim.Name))) + { + var problem = claim.Role switch + { + GeneratedTypeNameInventory.ClaimRole.Client => $"client type name '{claim.Name}' collides with another generated type", + GeneratedTypeNameInventory.ClaimRole.Response => $"multiple operations map to response type '{claim.Name}'", + GeneratedTypeNameInventory.ClaimRole.StreamAdapter => $"multiple operations map to stream adapter '{claim.Name}'", + GeneratedTypeNameInventory.ClaimRole.QueryRequest => $"request type name '{claim.Name}' collides with another generated type", + GeneratedTypeNameInventory.ClaimRole.Model or GeneratedTypeNameInventory.ClaimRole.EnvelopeDto + or GeneratedTypeNameInventory.ClaimRole.Hoisted or GeneratedTypeNameInventory.ClaimRole.Derived => throw new UnreachableException(), + _ => throw new UnreachableException(), + }; + errors.Add(BindingErrorCategory.Naming, claim.Subject, problem); } - /// - /// Binds the declared request headers. The wire-shape wall has already refused a - /// header the emitted signature could not carry, and refused every header outside an - /// internal-raw family, so the declaration order carries straight into the plan. - /// - private IReadOnlyList BindDeclaredHeaders() => - [ - .. _context.Operation - .Parameters - .Where(static parameter => parameter.Location is SpecParameterLocation.Header) - .Select(static parameter => new DeclaredHeaderPlan - { - WireName = parameter.Name, - Name = CSharpNamePolicy.ToCamelCase(parameter.Name), - }), - ]; - - /// The C# name a parameter-name row gives one of this operation's route values, if any. - private string? CuratedParameterName(string wireName) => - _context.Curation.ParameterNames.FirstOrDefault(row => - string.Equals(row.OperationId, _context.Operation.OperationId, StringComparison.Ordinal) - && string.Equals(row.Parameter, wireName, StringComparison.Ordinal)) - ?.DotNetName; - - private IReadOnlyList? BindParameters(GroupCuration row, - IReadOnlyList declaredHeaders) + var derivedOwners = new HashSet(ReservedNamePolicy.SpineTypeNames, _comparer); + var responseNames = inventory.Claims.Where(static claim => claim.Role is GeneratedTypeNameInventory.ClaimRole.Response) + .ToDictionary(static claim => claim.Subject, static claim => claim.Name, _comparer); + foreach (var claim in inventory.Claims.Where(claim => claim.Role is GeneratedTypeNameInventory.ClaimRole.Derived && !derivedOwners.Add(claim.Name))) { - var plans = _context.Operation - .Parameters - .Where(static parameter => parameter.Location is SpecParameterLocation.Path) - .Select(parameter => new OperationParameterPlan - { - WireName = parameter.Name, - Name = CuratedParameterName(parameter.Name) ?? CSharpNamePolicy.ToCamelCase(parameter.Name), - TypeName = "string", - IsHandleParameter = row.Placement is GroupPlacement.Client - && string.Equals(parameter.Name, row.HandleParameter, StringComparison.Ordinal), - }) - .ToList(); - - // Route values and declared headers share one emitted signature, so they are - // checked against one another rather than each within its own location. - var names = plans - .Select(static plan => plan.Name) - .Concat(declaredHeaders.Select(static header => header.Name)) - .ToArray(); - var duplicate = names.GroupBy(static name => name, StringComparer.Ordinal).FirstOrDefault(static name => name.Skip(1).Any()); - if (duplicate is not null) - { - _context.Errors.Add( - BindingErrorCategory.Naming, - _context.Operation.OperationId, - $"multiple parameters map to C# name '{duplicate.Key}'"); - return null; - } - - // Emitted signatures append these names after bind-time checks; a wire parameter - // landing on one must fail here, never as an emitted compile error. - var reserved = names - .Where(static name => ReservedNamePolicy.ParameterNames.Contains(name)) - .Order(StringComparer.Ordinal) - .ToArray(); - if (reserved.Length > 0) + // Equal response declarations already refuse with their response-name diagnostic. + if (responseNames.TryGetValue(claim.Subject, out var responseName) + && inventory.OtherClaims(claim).Any(other => other.Role is GeneratedTypeNameInventory.ClaimRole.Derived + && responseNames.TryGetValue(other.Subject, out var otherResponse) && string.Equals(responseName, otherResponse, StringComparison.Ordinal))) { - foreach (var name in reserved) - { - _context.Errors.Add( - BindingErrorCategory.Naming, - _context.Operation.OperationId, - $"parameter name '{name}' is reserved by the emitted method signature"); - } - - return null; + continue; } - - return [.. plans.OrderBy(plan => TemplatePosition(plan.WireName))]; + errors.Add(BindingErrorCategory.Naming, claim.Subject, $"type name '{claim.Name}' collides with another generated type"); } - private int TemplatePosition(string wireName) - { - var position = _context.Operation.Path.IndexOf($"{{{wireName}}}", StringComparison.Ordinal); - Debug.Assert(position >= 0, "Ingestion guarantees every path parameter appears in the route template."); - return position; - } - - private IReadOnlyList? RefuseNullTags(string problem) + var routeMembers = new HashSet(_comparer); + var operations = clients.SelectMany(static client => client.Operations).OrderBy(static operation => operation.OperationId, _comparer).ToArray(); + foreach (var operation in operations.Where(operation => !routeMembers.Add($"{operation.RouteContainerName}.{operation.RouteMemberName}"))) { - _context.Refuse(problem); - return null; + errors.Add(BindingErrorCategory.Naming, operation.OperationId, + $"multiple operations map to route member '{operation.RouteContainerName}.{operation.RouteMemberName}'"); } + return refusedHoists; } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/PaginationFacetBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/PaginationFacetBinder.cs index b0ddcdd2..0cb7a5ce 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/PaginationFacetBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/PaginationFacetBinder.cs @@ -1,4 +1,5 @@ using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; namespace OpenCode.Sdk.Tools.Generator.Binding; @@ -8,14 +9,13 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; /// internal sealed class PaginationFacetBinder(OperationFacetContext context) { - private readonly OperationFacetContext _context = context ?? throw new ArgumentNullException(nameof(context)); + private readonly OperationFacetContext _context = context.CreateLocal(); - public PaginationPlan? Bind(string methodName, IReadOnlyList? parameters, - IReadOnlyList declaredHeaders, QueryRequestPlan? queryRequest, RequestBodyPlan? requestBody, + public FacetResult Bind(string methodName, IReadOnlyList? parameters, + QueryRequestPlan? queryRequest, RequestBodyPlan? requestBody, EnvelopePlan? envelope) { ArgumentException.ThrowIfNullOrWhiteSpace(methodName); - ArgumentNullException.ThrowIfNull(declaredHeaders); if (queryRequest is not { DerivesFromListRequest: true, RidesRequestBody: false } || envelope is not @@ -26,7 +26,7 @@ internal sealed class PaginationFacetBinder(OperationFacetContext context) PayloadType: ListTypeReferencePlan { ElementType: NamedTypeReferencePlan { Name: { } itemTypeName } }, }) { - return null; + return new FacetResult.Bound(null); } if (!string.Equals(_context.Operation.Method, "get", StringComparison.Ordinal) @@ -34,23 +34,21 @@ internal sealed class PaginationFacetBinder(OperationFacetContext context) || parameters is null || parameters.Any(static parameter => !parameter.IsHandleParameter)) { - return _context.RefuseNull( - "cursor pagination currently requires a GET operation with no body or unbound route parameters"); + return new FacetResult.Bound(null); } // The traversal core takes the one-page method as a fixed three-argument delegate, // so an extra emitted parameter would break the method-group conversion. - if (declaredHeaders.Count > 0) + if (_context.Operation.Parameters.Any(static parameter => parameter.Location is SpecParameterLocation.Header)) { - return _context.RefuseNull("cursor pagination cannot carry a declared header parameter"); + return new FacetResult.Bound(null); } // A continuation is the caller's first request with a new cursor, so the traversal must be // able to start one from no request at all; a required filter has no value to carry there. if (queryRequest.HasRequiredMember) { - return _context.RefuseNull( - "cursor pagination cannot continue a query that declares a required parameter"); + return new FacetResult.Bound(null); } var enumerationMethodName = OperationNamePolicy.EnumerationMethodName(methodName); @@ -60,16 +58,16 @@ internal sealed class PaginationFacetBinder(OperationFacetContext context) BindingErrorCategory.Naming, _context.Operation.OperationId, $"cursor-list method '{methodName}' must be an asynchronous List method before its enumeration name can be derived"); - return null; + return _context.Refused(); } - return new PaginationPlan + return new FacetResult.Bound(new PaginationPlan { MethodName = enumerationMethodName, RequestTypeName = queryRequest.TypeName, PageTypeName = pageTypeName, ItemTypeName = itemTypeName, PayloadName = payloadName, - }; + }); } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/QueryRequestFacetBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/QueryRequestFacetBinder.cs index 08d67779..2ee871bf 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/QueryRequestFacetBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/QueryRequestFacetBinder.cs @@ -6,14 +6,17 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; /// Binds the facet from the operation's query parameters. internal sealed class QueryRequestFacetBinder(OperationFacetContext context) { - private readonly OperationFacetContext _context = context ?? throw new ArgumentNullException(nameof(context)); + private readonly OperationFacetContext _context = context.CreateLocal(); - public QueryRequestPlan? Bind() + /// Successfully classified properties for independent diagnostics, never an admission verdict. + public QueryRequestPlan? DiagnosticRequest { get; private set; } + + public FacetResult Bind() { var query = _context.Operation.Parameters.Where(static parameter => parameter.Location is SpecParameterLocation.Query).ToArray(); if (query.Length is 0 && !_context.Operation.HasWildcardPath) { - return null; + return new FacetResult.Bound(null); } var properties = new List(query.Length + 1); @@ -22,6 +25,7 @@ internal sealed class QueryRequestFacetBinder(OperationFacetContext context) properties.Add(BindRouteTail(_context.Operation.Parameters)); } + var refused = false; foreach (var parameter in query) { var property = parameter.IsDeepObject ? BindLocationSelector(parameter) : BindValue(parameter); @@ -29,6 +33,10 @@ internal sealed class QueryRequestFacetBinder(OperationFacetContext context) { properties.Add(property); } + else + { + refused = true; + } } var duplicate = properties @@ -40,7 +48,7 @@ internal sealed class QueryRequestFacetBinder(OperationFacetContext context) BindingErrorCategory.Naming, _context.Operation.OperationId, $"multiple query parameters map to C# name '{duplicate.Key}'"); - return null; + return _context.Refused(); } var derivesFromListRequest = ListRequestSpinePolicy.CarriesSpine(properties); @@ -57,12 +65,13 @@ .. properties.Select(static property => ListRequestSpinePolicy.IsSpineMember(pro ]; } - return new QueryRequestPlan + DiagnosticRequest = new QueryRequestPlan { TypeName = OperationNamePolicy.RequestTypeName(_context.Operation), DerivesFromListRequest = derivesFromListRequest, Properties = properties, }; + return refused ? _context.Refused() : new FacetResult.Bound(DiagnosticRequest); } /// diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/RequestBodyFacetBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/RequestBodyFacetBinder.cs index b510906d..f604a079 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/RequestBodyFacetBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/RequestBodyFacetBinder.cs @@ -6,14 +6,14 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; /// Binds the facet from the operation's declared body. internal sealed class RequestBodyFacetBinder(OperationFacetContext context) { - private readonly OperationFacetContext _context = context ?? throw new ArgumentNullException(nameof(context)); + private readonly OperationFacetContext _context = context.CreateLocal(); - public RequestBodyPlan? Bind() + public FacetResult Bind() { var body = _context.Operation.RequestBody; if (body is null) { - return null; + return new FacetResult.Bound(null); } if (body.ContentType is { Stripped: "application/octet-stream" } @@ -25,25 +25,25 @@ internal sealed class RequestBodyFacetBinder(OperationFacetContext context) if (body.ContentType is not { IsJson: true }) { _context.Refuse("the request body must carry a JSON schema"); - return null; + return _context.Refused(); } if (!body.IsRequired) { _context.Refuse("the request body must be declared required"); - return null; + return _context.Refused(); } if (body.Schema is not RefNode reference || _context.Resolve(body.Schema) is not ObjectNode target) { _context.Refuse("the request body must reference an object schema"); - return null; + return _context.Refused(); } if (!_context.TypeNames.TryGetValue(reference.Target, out var typeName)) { _context.Errors.Add(BindingErrorCategory.Naming, reference.Target, "request body schema has no unique C# type name"); - return null; + return _context.Refused(); } // The name resolver claims every selected body root with the operation-derived @@ -55,15 +55,15 @@ internal sealed class RequestBodyFacetBinder(OperationFacetContext context) BindingErrorCategory.Naming, reference.Target, $"request body resolved to '{typeName}' instead of the operation-derived '{expected}'"); - return null; + return _context.Refused(); } - return new RequestBodyPlan + return new FacetResult.Bound(new RequestBodyPlan { TypeName = typeName, ParameterName = "request", IsOptional = target.Properties.All(static property => !property.IsRequired), - }; + }); } /// @@ -71,20 +71,20 @@ internal sealed class RequestBodyFacetBinder(OperationFacetContext context) /// string/binary schema: the caller's stream, sent as it is (ADR-0029). A body the /// document leaves optional would need a bodiless overload nothing on the pin asks for. /// - private RequestBodyPlan? BindBinary(SpecRequestBody body) + private FacetResult BindBinary(SpecRequestBody body) { if (!body.IsRequired) { _context.Refuse("the request body must be declared required"); - return null; + return _context.Refused(); } - return new RequestBodyPlan + return new FacetResult.Bound(new RequestBodyPlan { TypeName = "Stream", ParameterName = "content", IsOptional = false, IsBinary = true, - }; + }); } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs index 172e7a70..4dcfadf0 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs @@ -41,12 +41,8 @@ public SchemaBindingResult Bind(SpecDocument document, ReachableSchemaSet reacha var models = new List(); foreach (var key in reachable.GraphKeys) { - if (responseRoots.Contains(key) || !document.Schemas.TryGetValue(key, out var schema) || !typeNames.TryGetValue(key, out var name)) - { - continue; - } - - if (!inhabitation.IsInhabited(schema)) + if (responseRoots.Contains(key) || !document.Schemas.TryGetValue(key, out var schema) + || !typeNames.TryGetValue(key, out var name) || !inhabitation.IsInhabited(schema)) { continue; } @@ -55,9 +51,13 @@ public SchemaBindingResult Bind(SpecDocument document, ReachableSchemaSet reacha { case ObjectNode objectNode: var objectPlan = objects.Bind(key, name, objectNode, graph.ImplementedUnionNames(key), requestKeys.Contains(key)); - if (objectPlan is not null) + if (objectPlan is FacetResult.Bound bound) + { + models.Add(bound.Value); + } + else if (objectPlan is FacetResult.Refused refused) { - models.Add(objectPlan); + errors.Append(refused.Errors); } break; @@ -86,9 +86,11 @@ when UnstructuredUnionPolicy.Collapse(structural, document.Schemas) is null: orderedUnions = [.. hoisted.Unions]; return new SchemaBindingResult { + TypeNames = typeNames, Models = orderedModels, Unions = orderedUnions, HoistedInterfaces = hoisted.Interfaces, + HoistedClaims = hoisted.Claims, ErrorUnion = errorUnion, Registry = new RegistryPlan { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SingleOperationBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SingleOperationBinder.cs new file mode 100644 index 00000000..f2810af1 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SingleOperationBinder.cs @@ -0,0 +1,191 @@ +using System.Diagnostics; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +/// Binds one selected operation, publishing independent facet refusals in binder order. +internal sealed class SingleOperationBinder(OperationFacetContext context, ErrorUnionBindingResult errorUnion) +{ + private readonly OperationFacetContext _context = context; + private readonly ErrorUnionBindingResult _errorUnion = errorUnion; + + public BoundOperation? Bind() + { + if (!_context.Curation.Groups.TryGetValue(_context.Operation.Segments[0], out var row)) + { + // The curation validator already reported the missing row. + return null; + } + + if (!new OperationWireShapeWall(_context, row.Emission).Check()) + { + return null; + } + + var success = _context.Operation.Responses.Single(static response => response.StatusCode is 200 or 204); + var stream = success.IsSse ? _context.Publish(new StreamFacetBinder(_context).Bind(success)) : new FacetResult.Bound(null); + var envelope = success.IsSse ? new FacetResult.Bound(null) : _context.Publish(new EnvelopeFacetBinder(_context).Bind(success)); + if (success.IsSse && _context.Operation.RequestBody is not null) + { + _context.Refuse("streaming operations must not carry a request body"); + return null; + } + + var errorMap = _context.Publish(new ErrorMapBinder(_context, _errorUnion).Bind(), _errorUnion.Errors); + var declaredHeaders = BindDeclaredHeaders(); + var parameters = BindParameters(row, declaredHeaders); + var queryBinder = new QueryRequestFacetBinder(_context); + var query = _context.Publish(queryBinder.Bind()); + var body = _context.Publish(new RequestBodyFacetBinder(_context).Bind()); + query = MergeRequests(query, body, queryBinder.DiagnosticRequest); + var handle = parameters?.Any(static parameter => parameter.IsHandleParameter) ?? false; + var (methodName, routeMemberName, rowRequired) = ResolveNames(row, handle); + if (rowRequired is not null) + { + _context.Refuse(rowRequired); + return null; + } + + if (methodName is null || routeMemberName is null) + { + return null; + } + + var pagination = _context.Publish(new PaginationFacetBinder(_context).Bind(methodName, parameters, + query is FacetResult.Bound q ? q.Value : queryBinder.DiagnosticRequest, + body is FacetResult.Bound b ? b.Value : null, + envelope is FacetResult.Bound e ? e.Value : null)); + if (stream is not FacetResult.Bound boundStream || envelope is not FacetResult.Bound boundEnvelope + || errorMap is not FacetResult.Bound boundErrors || parameters is null + || query is not FacetResult.Bound boundQuery || body is not FacetResult.Bound boundBody + || pagination is not FacetResult.Bound boundPagination) + { + return null; + } + + return new BoundOperation + { + OperationId = _context.Operation.OperationId, + Row = row, + IsHandleOperation = handle, + Plan = new OperationPlan + { + OperationId = _context.Operation.OperationId, + MethodName = methodName, + HttpMethod = _context.Operation.Method, + RouteTemplate = _context.Operation.Path, + ExperimentalDiagnostic = ExperimentalRoutePolicy.IsExperimental(_context.Operation.Path) ? row.ExperimentalDiagnostic : null, + RouteContainerName = row.ClientName ?? CSharpNamePolicy.ToPascalCase(_context.Operation.Segments[0]), + RouteMemberName = routeMemberName, + Parameters = parameters, + DeclaredHeaders = declaredHeaders, + QueryRequest = boundQuery.Value, + RequestBody = boundBody.Value, + Envelope = boundEnvelope.Value, + Stream = boundStream.Value, + Pagination = boundPagination.Value, + ErrorMap = boundErrors.Value, + Summary = _context.Operation.Summary, + Description = _context.Operation.Description, + }, + }; + } + + /// The row or mechanical name, and the refusal when neither may name the operation. + private (string? MethodName, string? RouteMemberName, string? RowRequired) ResolveNames(GroupCuration row, bool handle) + { + var curatedName = _context.Curation.OperationNames.FirstOrDefault(operationName => + string.Equals(operationName.OperationId, _context.Operation.OperationId, StringComparison.Ordinal)); + if (curatedName is null && (OperationNamePolicy.RowRequiredProblem(_context.Operation) + ?? OperationNamePolicy.AmbiguousPluralProblem(_context.Operation, handle: handle)) is { } rowRequired) + { + return (null, null, rowRequired); + } + + var method = OperationNamePolicy.MethodName(_context.Operation, curatedName, handle); + var route = OperationNamePolicy.RouteMemberName(_context.Operation, row.Placement, curatedName, handle); + return (method, route, curatedName is null && (method is null || route is null) + ? "the operation's names cannot be derived mechanically: the group does not pluralize naively" + : null); + } + + private FacetResult MergeRequests(FacetResult query, FacetResult body, + QueryRequestPlan? request) + { + if (request is null || body is not FacetResult.Bound { Value: { IsBinary: false } }) + { + return query; + } + + // Only the location selector can ride the body model; raw-byte bodies keep a standalone query. + if (request.Properties.Any(static property => property.Kind is not QueryValueKind.Location)) + { + var local = _context.CreateLocal(); + return _context.Publish(local.RefuseFacet("operations mixing a request body and query parameters are supported only for the location selector")); + } + + // Diagnostic facts never admit a refused query, even when the surviving properties can merge. + return query is FacetResult.Bound + ? new FacetResult.Bound(request with { RidesRequestBody = true }) : query; + } + + /// The wire wall has admitted these headers; their declaration order carries into the plan. + private IReadOnlyList BindDeclaredHeaders() => + [ + .. _context.Operation.Parameters + .Where(static parameter => parameter.Location is SpecParameterLocation.Header) + .Select(static parameter => new DeclaredHeaderPlan + { + WireName = parameter.Name, + Name = CSharpNamePolicy.ToCamelCase(parameter.Name), + }), + ]; + + private string? CuratedParameterName(string wireName) => + _context.Curation.ParameterNames.FirstOrDefault(row => + string.Equals(row.OperationId, _context.Operation.OperationId, StringComparison.Ordinal) + && string.Equals(row.Parameter, wireName, StringComparison.Ordinal))?.DotNetName; + + private IReadOnlyList? BindParameters(GroupCuration row, IReadOnlyList declaredHeaders) + { + var plans = _context.Operation.Parameters + .Where(static parameter => parameter.Location is SpecParameterLocation.Path) + .Select(parameter => new OperationParameterPlan + { + WireName = parameter.Name, + Name = CuratedParameterName(parameter.Name) ?? CSharpNamePolicy.ToCamelCase(parameter.Name), + TypeName = "string", + IsHandleParameter = row.Placement is GroupPlacement.Client + && string.Equals(parameter.Name, row.HandleParameter, StringComparison.Ordinal), + }).ToList(); + // Route values and headers share the emitted signature's namespace. + var names = plans.Select(static plan => plan.Name).Concat(declaredHeaders.Select(static header => header.Name)).ToArray(); + var duplicate = names.GroupBy(static name => name, StringComparer.Ordinal).FirstOrDefault(static name => name.Skip(1).Any()); + if (duplicate is not null) + { + _context.Errors.Add(BindingErrorCategory.Naming, _context.Operation.OperationId, $"multiple parameters map to C# name '{duplicate.Key}'"); + return null; + } + + var reserved = names.Where(static name => ReservedNamePolicy.ParameterNames.Contains(name)).Order(StringComparer.Ordinal).ToArray(); + if (reserved.Length > 0) + { + foreach (var name in reserved) + { + _context.Errors.Add(BindingErrorCategory.Naming, _context.Operation.OperationId, + $"parameter name '{name}' is reserved by the emitted method signature"); + } + return null; + } + + return [.. plans.OrderBy(plan => TemplatePosition(plan.WireName))]; + } + + private int TemplatePosition(string wireName) + { + var position = _context.Operation.Path.IndexOf($"{{{wireName}}}", StringComparison.Ordinal); + Debug.Assert(position >= 0, "Ingestion guarantees every path parameter appears in the route template."); + return position; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs index e4af5b2c..ea8756d1 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs @@ -61,7 +61,15 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera // consume the same map so neither can reinterpret a schema under another name. var typeNames = _schemaNames.Resolve(document, reachable, selected, curation, errors); var schemaResult = _schemaPlans.Bind(document, reachable, curation, typeNames, errors); - var clients = _operationPlans.Bind(document, selected, curation, typeNames, errors); + var clients = _operationPlans.Bind(document, selected, curation, schemaResult, errors); + var candidates = new OwnershipCandidates + { + Models = schemaResult.Models, + Unions = schemaResult.Unions, + HoistedInterfaces = schemaResult.HoistedInterfaces, + }; + var inventory = GeneratedTypeNameInventory.Create(candidates, clients, schemaResult.HoistedClaims, typeNames); + var refusedHoists = _operationPlans.CheckTypeNameCollisions(inventory, clients, errors); var pending = PendingOperations(document, selected, curation); var models = AttachRequestQueryProperties(schemaResult.Models, clients, errors); @@ -69,19 +77,14 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera curation, SchemaAliasApplier.Compose(collapse, curation.SchemaAliases), errors); var masked = SecretMemberPolicy.MaskUnknownPayloads(SecretMemberPolicy.Apply(models, curation, errors), schemaResult.Unions); - CheckDtoNameCollisions(schemaResult.Registry, clients, errors); - var candidates = new OwnershipCandidates - { - Models = masked.Models, - Unions = masked.Unions, - HoistedInterfaces = schemaResult.HoistedInterfaces, - }; - InternalNameCollisionCheck.Check(candidates, clients, errors); + CheckDtoNameCollisions(inventory, errors); + candidates = candidates with { Models = masked.Models, Unions = masked.Unions, HoistedInterfaces = [.. candidates.HoistedInterfaces.Where(plan => !refusedHoists.Contains(plan.Name))] }; + InternalNameCollisionCheck.Check(inventory, errors); // An unplaced model never refuses a bind: it is the generate command's wall, so the // pending-operation probe still judges bindability under its synthetic curation. var placement = ModelOwnershipPolicy.Place(candidates, clients, curation, errors); var reach = ModelReachCollector.Collect(_reachableSchemas, new ReachScope(document, selected, clients, typeNames), candidates, errors); - var experimental = ExperimentalModelPolicy.Collect(_reachableSchemas, new ReachScope(document, selected, clients, typeNames), candidates, errors); + var experimental = ExperimentalModelPolicy.Collect(_reachableSchemas, new ReachScope(document, selected, clients, typeNames), candidates, inventory, errors); errors.ThrowIfAny(); var plan = new EmitPlan { @@ -219,18 +222,14 @@ private static IReadOnlyList AttachRequestQueryProperties(IReadOnlyLi /// same-namespace resolution and collapse two registry identities into one; both /// directions refuse before composition instead of hiding behind a Distinct. /// - private static void CheckDtoNameCollisions(RegistryPlan schemaRegistry, IReadOnlyList clients, - BindingErrorCollector errors) + private static void CheckDtoNameCollisions(GeneratedTypeNameInventory inventory, BindingErrorCollector errors) { - var modelNames = schemaRegistry.TypeNames.ToHashSet(StringComparer.Ordinal); var seen = new HashSet(StringComparer.Ordinal); - foreach (var dtoName in clients - .SelectMany(static client => client.Operations) - .Select(static operation => operation.Envelope?.EnvelopeDtoTypeName) - .OfType() - .OrderBy(static name => name, StringComparer.Ordinal)) + foreach (var claim in inventory.Claims.Where(static claim => claim.Role is GeneratedTypeNameInventory.ClaimRole.EnvelopeDto) + .OrderBy(static claim => claim.Name, StringComparer.Ordinal)) { - if (modelNames.Contains(dtoName)) + var dtoName = claim.Name; + if (inventory.OtherClaims(claim).Any(static other => other.Role is not (GeneratedTypeNameInventory.ClaimRole.EnvelopeDto or GeneratedTypeNameInventory.ClaimRole.Hoisted))) { errors.Add( BindingErrorCategory.Naming, diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/StreamFacetBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/StreamFacetBinder.cs index 5278bda9..6176a417 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/StreamFacetBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/StreamFacetBinder.cs @@ -6,27 +6,27 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; /// Binds the facet of a streaming success. internal sealed class StreamFacetBinder(OperationFacetContext context) { - private readonly OperationFacetContext _context = context ?? throw new ArgumentNullException(nameof(context)); + private readonly OperationFacetContext _context = context.CreateLocal(); /// /// Reads a streaming success: the frame profile the contract declares, the payload its /// JSON-encoded data field carries, and the event name that reports a mid-stream failure. /// Every part is required — a stream whose shape is not fully declared is refused. /// - public StreamPlan? Bind(SpecResponse success) + public FacetResult Bind(SpecResponse success) { ArgumentNullException.ThrowIfNull(success); if (success.ContentType is not { IsEventStream: true } || success.Schema is null) { - return _context.RefuseNull("a streaming success must carry a text/event-stream schema"); + return _context.RefuseFacet("a streaming success must carry a text/event-stream schema"); } if (success.Schema is not RefNode reference || !_context.Document.Schemas.TryGetValue(reference.Target, out var target) || target is not ObjectNode frame) { - return _context.RefuseNull("the event frame must reference a named object schema"); + return _context.RefuseFacet("the event frame must reference a named object schema"); } var id = frame.Properties.FirstOrDefault(static property => property.Name is "id"); @@ -38,34 +38,34 @@ internal sealed class StreamFacetBinder(OperationFacetContext context) || eventName is not { IsRequired: true } || data is not { IsRequired: true }) { - return _context.RefuseNull("the event frame must declare exactly 'id', 'event' and 'data', and require 'event' and 'data'"); + return _context.RefuseFacet("the event frame must declare exactly 'id', 'event' and 'data', and require 'event' and 'data'"); } if (!SpineShapePolicy.IsNullableUnformattedString(_context, id.Schema)) { - return _context.RefuseNull("the event frame 'id' must be a nullable string"); + return _context.RefuseFacet("the event frame 'id' must be a nullable string"); } if (_context.Resolve(eventName.Schema) is not PrimitiveNode { Kind: PrimitiveKind.String, Format: null }) { - return _context.RefuseNull("the event frame 'event' must be a string"); + return _context.RefuseFacet("the event frame 'event' must be a string"); } var payload = BindFramePayload(data.Schema); var failure = BindEffectStream(success); if (payload is null || failure is null) { - return null; + return _context.Refused(); } var responseTypeName = OperationNamePolicy.ResponseTypeName(_context.Operation); - return new StreamPlan + return new FacetResult.Bound(new StreamPlan { PayloadTypeName = payload, AdapterTypeName = $"{responseTypeName}StreamAdapter", FailureEventName = failure.EventName, CauseTypeName = failure.CauseTypeName, - }; + }); } /// The frame's data field is a JSON-encoded string; the stream yields what it encodes. diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/UnionMemberHoistBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/UnionMemberHoistBinder.cs index 942b0f3e..b79ad570 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/UnionMemberHoistBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/UnionMemberHoistBinder.cs @@ -39,6 +39,7 @@ public UnionHoistPlan Bind(IReadOnlyList unions, IReadOnlyList hoisted[union.Name] is { Count: > 0 } members ? union with { HoistedMembers = members } : union)], Models = context.Implementations.Apply(models), Interfaces = context.Carriers.Interfaces, + Claims = context.Carriers.Claims, }; } @@ -46,8 +47,6 @@ private HoistContext CreateContext(IReadOnlyList unions, IReadOnlyLis GenerationCuration curation, BindingErrorCollector errors) { var objectModels = models.OfType().ToDictionary(static model => model.Name, _comparer); - var taken = new HashSet(models.Select(static model => model.Name), _comparer); - taken.UnionWith(unions.SelectMany(static union => new[] { union.Name, union.UnknownTypeName, })); var implementations = new HoistedImplementationAccumulator(); // One policy instance answers both the union interface and every carrier interface, so the // two can never disagree about which record property satisfies a declared member. @@ -58,7 +57,7 @@ private HoistContext CreateContext(IReadOnlyList unions, IReadOnlyLis objectModels, new HoistedMemberIdentityPolicy(objectModels), satisfaction, - new HoistedCarrierComposer(objectModels, curation.HoistedMemberNames, taken, satisfaction, implementations, errors), + new HoistedCarrierComposer(objectModels, curation.HoistedMemberNames, satisfaction, implementations, errors), implementations, errors); } diff --git a/tools/jscpd/tests.baseline.json b/tools/jscpd/tests.baseline.json index 37462ae4..38ec3278 100644 --- a/tools/jscpd/tests.baseline.json +++ b/tools/jscpd/tests.baseline.json @@ -12,10 +12,8 @@ "032245d683ed5066": 1, "0341de072c280e0e": 2, "03f0da59b2a78217": 1, - "04551f236fc0cb20": 1, "047178ad85b36704": 1, "053cf7003742af8b": 1, - "0555dcc5c34a8475": 1, "05990412764d4af8": 1, "059dcd6e0a494338": 1, "05bec1db549b3145": 1, @@ -336,7 +334,6 @@ "76d04a6e44d1a740": 1, "774ec2e63be4fa1d": 1, "776de12b3d238a95": 1, - "78811cccfbf1dfd6": 1, "78e8a6e0e1dacf98": 2, "7931cdb3741c2bb8": 1, "7943699770a884f4": 1, @@ -416,6 +413,7 @@ "93b9870af2c85e3c": 1, "93c7ffbef646d5bd": 1, "943a5cceaaa0085d": 1, + "94b8d278f96ea4ca": 1, "94c3b038871234a4": 1, "950d14b87d7527d0": 1, "9676160daa1042e5": 1,