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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/architecture/protocol-and-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,10 @@ member makes its object branch uninhabitable. Tagged unions preserve such a bran
impossible tag in their bound plan, emit no dead public variant for it, and refuse that tag during
dispatch instead of routing it through ADR-0009's unknown carrier (ADR-0015).

An unmarked union of a free-form object and an array of unrestricted values binds to
`JsonElement`, so both the object and the array that Effect's object keyword admits remain
materializable.

## Version-skew tolerance

- Every tagged union deserializes an unrecognized discriminator into that union's explicit unknown
Expand Down
159 changes: 157 additions & 2 deletions tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,156 @@ namespace OpenCode.Sdk.Tools.Tests.Generator.Binding;

public sealed class SpecBinderTests
{
private static readonly string[] ParentScenarioLeaves = ["First", "Second", "Other", "Another"];

[Test]
public async Task Bind_Should_Keep_The_Error_Union_Independent_Of_A_Component_With_The_Same_Key()
{
var document = await IngestAsync(UnionScenario(spec =>
{
AddTaggedError(spec, "OpenCodeError", "first");
AddTaggedError(spec, "SecondError", "second");
spec.WithSchema("Feed", schema => schema.AnyOf(first => first.Ref("OpenCodeError"), second => second.Ref("SecondError")));
}));
var plan = new BindingTestHost().Bind(document, Selection("health.get"), Curation(Groups("health", RootGroup()),
schemaNames: [SchemaName("OpenCodeError", "FirstError")]));
var errorUnion = plan.Unions.Single(union => union.Name == "IOpenCodeError");

await Assert.That(errorUnion.BaseTypeName).IsNull();
await Assert.That(errorUnion.FixedMarker).IsNull();
await Assert.That(errorUnion.Variants.Select(variant => variant.TypeName))
.IsEquivalentTo(["FirstError", "SecondError"], StringComparer.Ordinal, CollectionOrdering.Matching);
await Assert.That(plan.Models.OfType<ObjectModelPlan>().Single(model => model.Name == "FirstError").ImplementedUnionNames)
.IsEquivalentTo(["IFeed", "IOpenCodeError"], StringComparer.Ordinal, CollectionOrdering.Matching);
}

[Test]
public async Task Bind_Should_Refuse_A_Stream_Cause_Union_With_A_Union_Parent_By_Name()
{
var nested = await IngestAsync(NestedParentScenario("Cause", twoParents: false));
var stream = await IngestAsync(new StreamOperationScenario());
var operation = stream.Operations.Single();
var document = nested with
{
Schemas = nested.Schemas.Concat(stream.Schemas).ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal),
Operations = [.. nested.Operations, operation with
{
Responses = [.. operation.Responses.Select(response => response.EffectStream is null ? response : response with
{
EffectStream = response.EffectStream with { CauseSchema = new ArrayNode { Item = new RefNode { Target = "Cause" } } },
})],
}],
};
var exception = Assert.Throws<BindingException>(() => new BindingTestHost().Bind(document,
Selection("health.get", StreamOperationScenario.OperationId),
Curation(new Dictionary<string, GroupCuration>(StringComparer.Ordinal)
{
["health"] = RootGroup(),
["example"] = RootGroup(),
})));

await Assert.That(exception.Errors)
.IsEquivalentTo([new BindingError(BindingErrorCategory.Schema, "Cause",
"nested union has multiple parents 'IOuterA' and 'IOpenCodeStreamFailureCause'")], CollectionOrdering.Matching);
}

[Test]
public async Task Bind_Should_Refuse_A_Nested_Union_With_Two_Parents_By_Name()
{
var document = await IngestAsync(NestedParentScenario("Inner", twoParents: true));
var exception = RefuseHealth(document);

await Assert.That(exception.Errors)
.IsEquivalentTo([new BindingError(BindingErrorCategory.Schema, "Inner",
"nested union has multiple parents 'IOuterA' and 'IOuterB'")], CollectionOrdering.Matching);
}

private static SpecScenario NestedParentScenario(string nestedKey, bool twoParents) => UnionScenario(spec =>
{
foreach (var name in ParentScenarioLeaves)
{
spec.WithSchema(name, schema => schema.Type("object")
.Property("type", property => property.Type("string").Enum(name), required: true));
}

spec.WithSchema(nestedKey, schema => schema.AnyOf(first => first.Ref("First"), second => second.Ref("Second")))
.WithSchema("OuterA", schema => schema.AnyOf(inner => inner.Ref(nestedKey), other => other.Ref("Other")))
.WithSchema("Feed", schema => schema.Type("object")
.Property("a", property => property.Ref("OuterA"), required: true));
if (twoParents)
{
spec.WithSchema("OuterB", schema => schema.AnyOf(inner => inner.Ref(nestedKey), other => other.Ref("Another")))
.WithSchema("Feed", schema => schema.Type("object")
.Property("a", property => property.Ref("OuterA"), required: true)
.Property("b", property => property.Ref("OuterB"), required: true));
}
});

[Test]
public async Task Bind_Should_Preserve_Marked_And_Error_Variant_Order()
{
var document = await IngestAsync(UnionScenario(spec =>
{
AddTaggedError(spec, "AError", "middle");
AddTaggedError(spec, "BError", "zebra");
AddTaggedError(spec, "CError", "apple");
spec.WithSchema("Feed", schema => schema.AnyOf(first => first.Ref("BError"), middle => middle.Ref("AError"), last => last.Ref("CError")));
}));
var plan = new BindingTestHost().Bind(document, Selection("health.get"), Curation(Groups("health", RootGroup()),
schemaNames: [SchemaName("AError", "ZuluError"), SchemaName("BError", "AlphaError"), SchemaName("CError", "MikeError")]));

await Assert.That(plan.Unions.Single(union => union.Name == "IFeed").Variants.Select(variant => variant.TypeName))
.IsEquivalentTo(["AlphaError", "ZuluError", "MikeError"], StringComparer.Ordinal, CollectionOrdering.Matching);
await Assert.That(plan.Unions.Single(union => union.Name == "IFeed").Variants.Select(variant => variant.Tag))
.IsEquivalentTo(["zebra", "middle", "apple"], StringComparer.Ordinal, CollectionOrdering.Matching);
await Assert.That(plan.Unions.Single(union => union.Name == "IOpenCodeError").Variants.Select(variant => variant.TypeName))
.IsEquivalentTo(["AlphaError", "MikeError", "ZuluError"], StringComparer.Ordinal, CollectionOrdering.Matching);
await Assert.That(plan.Unions.Single(union => union.Name == "IOpenCodeError").Variants.Select(variant => variant.Tag))
.IsEquivalentTo(["zebra", "apple", "middle"], StringComparer.Ordinal, CollectionOrdering.Matching);
}

[Test]
public async Task Bind_Should_Retain_Per_Schema_Error_Variants_And_Refusals()
{
var document = await IngestAsync(UnionScenario(spec =>
{
AddTaggedError(spec, "AAdmitted", "admitted");
AddTaggedError(spec, "BUnnamed", "unnamed");
AddTaggedError(spec, "CBadMarker", "bad");
spec.WithSchema("Feed", schema => schema.Type("object")
.Property("first", property => property.Ref("AAdmitted"))
.Property("second", property => property.Ref("BUnnamed"))
.Property("third", property => property.Ref("CBadMarker")));
}));
var schemas = document.Schemas.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
// Exercise the error binder's refusal contract over owned IR, after projection.
schemas["CBadMarker"] = (ObjectNode)schemas["CBadMarker"] with { LiteralMarkers = [] };
document = document with { Schemas = schemas };
var errors = new BindingErrorCollector();
var reachable = new ReachableSchemaCollector().Collect(document, document.Operations, errors);
var names = new Dictionary<string, string>(StringComparer.Ordinal) { ["AAdmitted"] = "AdmittedError", ["CBadMarker"] = "BadMarkerError" };
var result = new ErrorUnionBinder(new SchemaBindingContext(document, reachable,
reachable.ResponseRootKeys.ToHashSet(StringComparer.Ordinal), reachable.StreamCauseKeys.ToHashSet(StringComparer.Ordinal), names,
new TypePlanBinder(schemas, names, errors), new SchemaInhabitationPolicy(schemas), errors)).Bind();
var expectedRefusals = new Dictionary<string, BindingError>(StringComparer.Ordinal)
{
["BUnnamed"] = new(BindingErrorCategory.Naming, "BUnnamed", "error schema has no unique C# type name"),
["CBadMarker"] = new(BindingErrorCategory.Schema, "CBadMarker", "a tagged error must declare exactly one required '_tag' literal"),
};

await Assert.That(result.Variants).IsEquivalentTo(new Dictionary<string, UnionVariantPlan>(StringComparer.Ordinal)
{
["AAdmitted"] = new() { TypeName = "AdmittedError", Tag = "admitted", MarkerWireName = "_tag" },
}, CollectionOrdering.Matching);
await Assert.That(result.Refusals).IsEquivalentTo(expectedRefusals, CollectionOrdering.Matching);
await Assert.That(Assert.Throws<BindingException>(errors.ThrowIfAny).Errors)
.IsEquivalentTo(expectedRefusals.Values, CollectionOrdering.Matching);
}

private static void AddTaggedError(SpecDocumentBuilder spec, string key, string tag) => spec
.WithSchema(key, schema => schema.Type("object")
.Property("_tag", property => property.Type("string").Enum(tag), required: true));

public static IEnumerable<object?[]> GroupRefusals() => GroupRefusalRows().Select(static row => new[] { row.fault, row.row, row.expected });

private static IEnumerable<(string fault, object? row, BindingError[] expected)> GroupRefusalRows() =>
Expand Down Expand Up @@ -1520,7 +1670,7 @@ public async Task Bind_Should_Refuse_A_Structural_Union_Arm_That_Is_A_Base64_Str
}

[Test]
public async Task Bind_Should_Read_An_Object_Or_Array_Union_As_An_Unspecified_Object()
public async Task Bind_Should_Read_An_Object_Or_Array_Union_As_JsonElement()
{
var document = await IngestAsync(SpecScenario.Define(spec => spec
.WithSchema("ItemInfo", schema => schema
Expand All @@ -1542,7 +1692,12 @@ public async Task Bind_Should_Read_An_Object_Or_Array_Union_As_An_Unspecified_Ob

var item = plan.Models.OfType<ObjectModelPlan>().Single(static model => model.Name == "ItemInfo");
var data = item.Properties.Single(static property => property.WireName == "payload").Type;
await Assert.That(data).IsTypeOf<DictionaryTypeReferencePlan>();
await Assert.That(data).IsEqualTo(new NamedTypeReferencePlan
{
Name = "JsonElement",
IsNullable = false,
JsonNullRepresentation = JsonNullRepresentation.InBand,
});
}

[Test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ namespace OpenCode.Sdk.Tools.Tests.Generator.Binding;

public sealed class UnionMemberHoistBinderTests
{
[Test]
public async Task Bind_Should_Keep_The_First_Carrier_Name_In_Depth_Then_Ordinal_Union_Order()
{
var plan = Hoist(
[Union("IBeta", Arm("CreatedEvent"), Arm("DeletedEvent")) with { BaseTypeName = "IZulu" },
Union("IAlpha", Arm("CreatedEvent"), Arm("DeletedEvent")) with { BaseTypeName = "IZulu" },
Union("IZulu", Arm("CreatedEvent"), Arm("DeletedEvent"))],
[Variant("CreatedEvent", "created", Property("durable", Named("CreatedEventDurable"), isRequired: true)),
Variant("DeletedEvent", "deleted", Property("durable", Named("DeletedEventDurable"), isRequired: true)),
Envelope("CreatedEventDurable", "1"), Envelope("DeletedEventDurable", "2")]);

await Assert.That(plan.Interfaces.Select(carrier => carrier.Name))
.IsEquivalentTo(["IZuluDurable"], StringComparer.Ordinal, CollectionOrdering.Matching);
}

[Test]
[Arguments(null, "curation is null")]
[Arguments("Owner", "curation names no owner")]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,73 @@
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using OpenCode.Sdk.Tools.Generator.Binding.Models;
using OpenCode.Sdk.Tools.Generator.Emission;
using OpenCode.Sdk.Tools.Tests.Generator.Emission.Support;
using OpenCode.Sdk.Tools.Tests.Support;
using TUnit.Assertions.Enums;
using static OpenCode.Sdk.Tools.Tests.Support.BindingScenarioData;

namespace OpenCode.Sdk.Tools.Tests.Generator.Emission;

public sealed class ModelEmitterTests
{
[Test]
[Arguments(false, "JsonElement")]
[Arguments(true, "FeedPayload")]
public async Task Emit_Should_Use_JsonElement_For_An_Object_Or_Array_Union(bool typed, string expectedType)
{
var document = await BindingTestHost.IngestAsync(UnionScenario(typed ? ArrangeTypedObjectOrArray : ArrangeUnrestrictedObjectOrArray));
var plan = new BindingTestHost().Bind(document, Selection("health.get"), Curation(Groups("health", RootGroup())));
var payload = plan.Models.OfType<ObjectModelPlan>().Single(model => model.Name == "Feed").Properties.Single();
var root = await CSharpSyntaxTree.ParseText(EmitterSnapshot.Create(ModelEmitter.Emit(plan))).GetRootAsync();
var property = root.DescendantNodes().OfType<RecordDeclarationSyntax>().Single(record => record.Identifier.ValueText == "Feed")
.Members.OfType<PropertyDeclarationSyntax>().Single(member => member.Identifier.ValueText == "Payload");

await Assert.That(((NamedTypeReferencePlan)payload.Type).Name).IsEqualTo(expectedType);
await Assert.That(property.Type.ToString()).IsEqualTo(expectedType);
await Assert.That(plan.Models.OfType<StructuralUnionModelPlan>().Select(model => model.Name))
.IsEquivalentTo(typed ? ["FeedPayload"] : [], StringComparer.Ordinal, CollectionOrdering.Matching);
}

private static void ArrangeTypedObjectOrArray(SpecDocumentBuilder spec) => spec
.WithSchema("Item", schema => schema.Type("object")
.Property("id", property => property.Type("string"), required: true))
.WithSchema("Feed", schema => schema.Type("object")
.Property("payload", property => property.AnyOf(
branch => branch.Ref("Item"), branch => branch.Type("array").Items(item => item.Ref("Item"))), required: true));

private static void ArrangeUnrestrictedObjectOrArray(SpecDocumentBuilder spec) => spec
.WithSchema("Feed", schema => schema.Type("object")
.Property("payload", property => property.AnyOf(
branch => branch.Type("object"), branch => branch.Type("array")), required: true));

[Test]
public async Task Emit_Should_Preserve_Binding_Union_Membership_Order_In_Record_Base_Lists()
{
var document = await BindingTestHost.IngestAsync(UnionScenario(spec => spec
.WithSchema("SharedError", schema => schema.Type("object")
.Property("_tag", property => property.Type("string").Enum("shared"), required: true))
.WithSchema("Other", schema => schema.Type("object")
.Property("_tag", property => property.Type("string").Enum("other"), required: true))
.WithSchema("AOuter", schema => schema.AnyOf(branch => branch.Ref("SharedError"), branch => branch.Ref("Other")))
.WithSchema("ZOuter", schema => schema.OneOf(branch => branch.Ref("SharedError"), branch => branch.Ref("Other")))
.WithSchema("Feed", schema => schema.Type("object")
.Property("first", property => property.Ref("AOuter"), required: true)
.Property("last", property => property.Ref("ZOuter"), required: true))));
var plan = new BindingTestHost().Bind(document, Selection("health.get"),
Curation(Groups("health", RootGroup()), schemaNames: [SchemaName("AOuter", "IZulu"), SchemaName("ZOuter", "IAlpha")]));
var model = plan.Models.OfType<ObjectModelPlan>().Single(model => model.Name == "SharedError");
var source = EmitterSnapshot.Create(ModelEmitter.Emit(plan));
var root = await CSharpSyntaxTree.ParseText(source).GetRootAsync();
var record = root.DescendantNodes().OfType<RecordDeclarationSyntax>()
.Single(record => record.Identifier.ValueText == "SharedError");

await Assert.That(record.BaseList!.Types.Select(type => type.Type.ToString()))
.IsEquivalentTo(["IZulu", "IAlpha", "IOpenCodeError"], StringComparer.Ordinal, CollectionOrdering.Matching);
await Assert.That(model.ImplementedUnionNames)
.IsEquivalentTo(["IZulu", "IAlpha", "IOpenCodeError"], StringComparer.Ordinal, CollectionOrdering.Matching);
}

[Test]
public async Task Emit_Should_Choose_The_Exact_First_Word_Article()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ namespace OpenCode.Sdk.Tools.Tests.Support;

internal static class BindingScenarioData
{
public static SpecScenario UnionScenario(Action<SpecDocumentBuilder> arrange) => SpecScenario.Define(spec =>
{
arrange(spec);
spec.WithOperation("health.get", configure: operation => operation.Response(200, "application/json", schema => schema.Ref("Feed")));
});

public static async Task<BindingException> BindRefusalsAsync(SpecScenario scenario, GenerationCuration curation, params string[] operations)
{
var document = await BindingTestHost.IngestAsync(scenario);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ internal sealed class BindingErrorCollector

public int Count => _errors.Count;

public void Add(BindingErrorCategory category, string subject, string problem, BindingErrorKind kind = BindingErrorKind.Unspecified)
public BindingError Add(BindingErrorCategory category, string subject, string problem, BindingErrorKind kind = BindingErrorKind.Unspecified)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subject);
ArgumentException.ThrowIfNullOrWhiteSpace(problem);

_errors.Add(new BindingError(category, subject, problem) { Kind = kind });
var error = new BindingError(category, subject, problem) { Kind = kind };
_errors.Add(error);
return error;
}

public void ThrowIfAny()
Expand Down
Loading
Loading