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
25 changes: 0 additions & 25 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -745,31 +745,6 @@ dotnet_diagnostic.VSTHRD111.severity = none
[tools/opencode-tool.cs]
dotnet_diagnostic.MA0048.severity = none

# GraphKeyBuilder centralizes pointer and branch-identity policy shared by projection
# collaborators. Winner: one sealed policy owner instead of duplicated static mechanics;
# static-member rules remain enabled for every other file.
[tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/GraphKeyBuilder.cs]
dotnet_diagnostic.CA1822.severity = none
dotnet_diagnostic.S2325.severity = none

# ErrorStyleClassifier is a named policy collaborator constructed by object projection;
# making its only operation static would erase that ownership boundary. Winner: the sealed
# classifier contract; static-member rules remain enabled for every other file.
[tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/ErrorStyleClassifier.cs]
dotnet_diagnostic.CA1822.severity = none
dotnet_diagnostic.S2325.severity = none

# EnumSchemaProjector and LiteralClassifier are named schema-policy collaborators composed by
# SchemaProjector; static operations would erase that ownership. Winner: the sealed collaborator
# contracts; static-member rules remain enabled for every other file.
[tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/EnumSchemaProjector.cs]
dotnet_diagnostic.CA1822.severity = none
dotnet_diagnostic.S2325.severity = none

[tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/LiteralClassifier.cs]
dotnet_diagnostic.CA1822.severity = none
dotnet_diagnostic.S2325.severity = none

# The abstract adapter base carries shared, non-virtual mapping behavior for the
# generated response adapters, and default interface members cannot carry shared
# behavior on the net472/netstandard2.0 targets (CS8701). Winner: the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,8 @@ public async Task ToPascalCase_Should_Keep_A_Brand_Spelling(string wireName, str
}

/// <summary>
/// A whole name that opens with a digit needs a letter prefix; a fragment appended to a
/// stem does not, and taking it there would spell an interior underscore the repository's
/// format pass rewrites out of the declaration without renaming the file it was written to.
/// A whole name that opens with a digit needs the Value prefix; a fragment appended to a
/// non-empty stem does not.
/// </summary>
[Test]
[Arguments("0", "Value0", "0")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,10 @@ private static async Task<IReadOnlyDictionary<string, IReadOnlyList<string>>> Co
var document = await BindingTestHost.IngestAsync(ReachScenario());
var operationsById = document.Operations.ToDictionary(static operation => operation.OperationId, StringComparer.Ordinal);
IReadOnlyList<SpecOperation> selected = [.. operationIds.Select(operationId => operationsById[operationId])];
var reachableSchemas = new ReachableSchemaCollector();
var reachable = reachableSchemas.Collect(document, selected, errors);
var typeNames = new SchemaNameResolver().Resolve(document, reachable, selected, Curation(Groups()), errors);
var reachable = ReachableSchemaCollector.Collect(document, selected, errors);
var typeNames = SchemaNameResolver.Resolve(document, reachable, selected, Curation(Groups()), errors);

var reach = ModelReachCollector.Collect(reachableSchemas, new ReachScope(document, selected, Clients(selected), typeNames),
var reach = ModelReachCollector.Collect(new ReachScope(document, selected, Clients(selected), typeNames),
candidates, errors);

errors.ThrowIfAny();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ private static IReadOnlyList<BindingError> NameCollisions(OwnershipCandidates ca
{
var inventory = GeneratedTypeNameInventory.Create(candidates, clients, [], schemaNames ?? new Dictionary<string, string>(StringComparer.Ordinal));
var errors = new BindingErrorCollector();
new OperationPlanBinder().CheckTypeNameCollisions(inventory, clients, errors);
OperationPlanBinder.CheckTypeNameCollisions(inventory, clients, errors);
return errors.Snapshot();
}

Expand Down Expand Up @@ -135,7 +135,7 @@ public async Task Bind_Should_Preserve_Type_Name_Claim_Order()
HoistedInterfaces = []
};
var errors = new BindingErrorCollector();
new OperationPlanBinder().CheckTypeNameCollisions(GeneratedTypeNameInventory.Create(candidates, clients, [], new Dictionary<string, string>(StringComparer.Ordinal)), clients, errors);
OperationPlanBinder.CheckTypeNameCollisions(GeneratedTypeNameInventory.Create(candidates, clients, [], new Dictionary<string, string>(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'"),
Expand Down Expand Up @@ -447,17 +447,17 @@ public async Task Bind_Should_Refuse_Invalid_Envelope_Payload_Rows(string variat
{
if (variation == "invalid-binder")
{
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<string, string>(StringComparer.Ordinal), errors);
_ = new OperationPlanBinder().Bind(document, document.Operations, curation, schemas, errors);
var reachable = ReachableSchemaCollector.Collect(document, document.Operations, errors);
var names = new Dictionary<string, string>(StringComparer.Ordinal);
var context = SchemaBindingContext.Create(document, reachable, names, errors);
var schemas = new SchemaPlanBinder(context).Bind(curation);
_ = OperationPlanBinder.Bind(document, document.Operations, curation, schemas, errors);
errors.ThrowIfAny();
}
else if (variation == "invalid")
{
new CurationValidator().Validate(document, document.Operations,
new ReachableSchemaCollector().Collect(document, document.Operations, errors), curation, errors);
CurationValidator.Validate(document, document.Operations,
ReachableSchemaCollector.Collect(document, document.Operations, errors), curation, errors);
errors.ThrowIfAny();
}
else
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public async Task Probe_Should_Mark_A_Walled_Operation_Refused_With_The_Walls_Ve
await Assert.That(marks.Count).IsEqualTo(1);
await Assert.That(marks[0].OperationId).IsEqualTo("widget.connect");
await Assert.That(marks[0].IsBindable).IsFalse();
await Assert.That(marks[0].RefusalMessage).IsEqualTo("WebSocket operations are not supported in M1");
await Assert.That(marks[0].RefusalMessage).IsEqualTo("WebSocket operations are not supported");
}

[Test]
Expand All @@ -83,7 +83,7 @@ public async Task Probe_Should_List_Every_Independent_Wall_In_Binder_Order_When_
await Assert.That(marks.Count).IsEqualTo(1);
await Assert.That(marks[0].IsBindable).IsFalse();
await Assert.That(marks[0].RefusalMessage).IsEqualTo(
"WebSocket operations are not supported in M1; PUT operations must carry a request body");
"WebSocket operations are not supported; PUT operations must carry a request body");
}

[Test]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using OpenCode.Sdk.Tools.Generator.Binding;
using OpenCode.Sdk.Tools.Generator.Binding.Models;
using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
using OpenCode.Sdk.Tools.Tests.Support;
using TUnit.Assertions.Enums;
using static OpenCode.Sdk.Tools.Tests.Support.BindingScenarioData;
Expand Down Expand Up @@ -38,17 +39,47 @@ public async Task Bind_Should_Refuse_The_Whole_Facet_When_One_Parameter_Is_Uncla
}

[Test]
public async Task Bind_Should_Refuse_An_Enum_Without_A_Generated_Model()
[Arguments(false)]
[Arguments(true)]
public async Task Bind_Should_Refuse_An_Enum_Without_A_Generated_Model(bool referencedNullable)
{
var context = await CreateContextAsync(static operation => operation.Parameter("state", "query",
static schema => schema.Type("string").Enum("open", "closed")));
var context = referencedNullable
? await CreateContextAsync(static operation => operation.Parameter("state", "query", static schema => schema.Ref("State")),
static spec => spec.WithSchema("State", static schema => schema.AnyOf(
static branch => branch.Type("string").Enum("open", "closed"), static branch => branch.Type("null"))))
: 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<FacetResult<QueryRequestPlan?>.Refused>();
await Assert.That(((FacetResult<QueryRequestPlan?>.Refused)result).Errors).IsEquivalentTo([
new BindingError(BindingErrorCategory.Operation, "widget.list", "query parameter 'state' binds an enum that has no generated model"),
], CollectionOrdering.Matching);
}

[Test]
[Arguments(false)]
[Arguments(true)]
public async Task Bind_Should_Keep_A_Referenced_Nullable_Enum_Refused(bool fullPipeline)
{
var document = await BindingTestHost.IngestAsync(SpecScenario.Define(static spec => spec
.WithSchema("State", static schema => schema.AnyOf(
static branch => branch.Type("string").Enum("open", "closed"), static branch => branch.Type("null")))
.WithOperation("widget.list", configure: static operation => operation
.WithoutResponse(200).Response(204)
.Parameter("state", "query", static schema => schema.Ref("State")))));
if (!fullPipeline)
{
await Assert.That(QueryEnumShapePolicy.ResolveModelKey(new RefNode { Target = "State" }, document.Schemas)).IsNull();
return;
}

var exception = Assert.Throws<BindingException>(() => new BindingTestHost().Bind(document,
Selection("widget.list"), Curation(Groups("widget", RootGroup()))));
await Assert.That(exception.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()
{
Expand All @@ -63,9 +94,14 @@ await Assert.That(((FacetResult<QueryRequestPlan?>.Refused)refused).Errors).IsEq
], CollectionOrdering.Matching);
}

private static async Task<OperationFacetContext> CreateContextAsync(Action<OperationBuilder> configure)
private static async Task<OperationFacetContext> CreateContextAsync(Action<OperationBuilder> configure,
Action<SpecDocumentBuilder>? configureSchemas = null)
{
var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec.WithOperation("widget.list", configure: configure)));
var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec =>
{
configureSchemas?.Invoke(spec);
spec.WithOperation("widget.list", configure: configure);
}));
var errors = new BindingErrorCollector();
var names = new Dictionary<string, string>(StringComparer.Ordinal);
return new OperationFacetContext(document, document.Operations.Single(), Curation(Groups("widget", RootGroup())), names, errors,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using OpenCode.Sdk.Tools.Generator.Binding;
using OpenCode.Sdk.Tools.Generator.Ingestion.Models;

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

public sealed class RefChainTests
{
[Test]
[Arguments("terminal", "B")]
[Arguments("missing", "A")]
[Arguments("self-cycle", "A")]
[Arguments("two-node-cycle", "B")]
[Arguments("nullable", "B")]
public async Task Resolve_Should_Return_The_Last_Node_And_Last_Successful_Key(string shape, string lastKey)
{
var lookups = 0;
// A broken cycle guard fails deterministically, rather than hanging the test host.
var comparer = EqualityComparer<string>.Create((left, right) => ++lookups > 32
? throw new InvalidOperationException("Reference traversal revisited the cycle")
: StringComparer.Ordinal.Equals(left, right), StringComparer.Ordinal.GetHashCode);
var terminal = new EnumNode { Values = ["open", "closed"] };
SchemaNode expected = shape switch
{
"missing" => new RefNode { Target = "absent" },
"self-cycle" or "two-node-cycle" => new RefNode { Target = "A" },
"nullable" => new NullableNode { Inner = terminal },
_ => terminal,
};
var graph = new Dictionary<string, SchemaNode>(comparer)
{
["A"] = shape is "missing" or "self-cycle" ? expected : new RefNode { Target = "B" },
["B"] = expected,
};

var result = RefChain.Resolve(new RefNode { Target = "A" }, graph);

await Assert.That(result.Node).IsSameReferenceAs(expected);
await Assert.That(result.LastKey).IsEqualTo(lastKey);
}

[Test]
public async Task Resolve_Should_Not_Unwrap_A_Nullable_Node()
{
var node = new NullableNode { Inner = new RefNode { Target = "A" } };
var result = RefChain.Resolve(node, new Dictionary<string, SchemaNode>(StringComparer.Ordinal));
await Assert.That(result.Node).IsSameReferenceAs(node);
await Assert.That(result.LastKey).IsNull();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,31 @@ namespace OpenCode.Sdk.Tools.Tests.Generator.Binding;

public sealed class SpecBinderTests
{
[Test]
[Arguments(false, "JSON-encoded strings are not supported by the emitter")]
[Arguments(true, "tuple schemas are not supported by the emitter")]
public async Task Bind_Should_Refuse_Unsupported_Type_Shapes_Without_Milestone_Text(bool tuple, string problem)
{
var document = await IngestAsync(SpecScenario.Define(spec => spec
.WithObject("ItemInfo", schema => schema.Property("value", value =>
{
if (tuple)
{
value.Type("array").PrefixItems(static item => item.Type("string"), static item => item.Type("integer"))
.MinItems(2).MaxItems(2);
}
else
{
value.Type("string").ContentSchema("application/json", static inner => inner.Type("string"));
}
}, required: true))
.WithJsonOperation("health.get", "/health", static schema => schema.Ref("ItemInfo"))));
var exception = RefuseHealth(document);
await Assert.That(exception.Errors).IsEquivalentTo([
new BindingError(BindingErrorCategory.Schema, "ItemInfo.value", problem),
], CollectionOrdering.Matching);
}

[Test]
public async Task Bind_Should_Preserve_The_Marker_Refusal_For_An_Unnamed_Error_Schema()
{
Expand Down Expand Up @@ -180,11 +205,9 @@ public async Task Bind_Should_Retain_Per_Schema_Error_Variants_And_Refusals()
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 reachable = 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 result = new ErrorUnionBinder(SchemaBindingContext.Create(document, reachable, names, errors)).Bind();
var expectedRefusals = new Dictionary<string, BindingError>(StringComparer.Ordinal)
{
["BUnnamed"] = new(BindingErrorCategory.Naming, "BUnnamed", "error schema has no unique C# type name"),
Expand Down Expand Up @@ -266,7 +289,7 @@ public async Task Bind_Should_Refuse_Invalid_Group_Configuration(string fault, o
experimentalDiagnostic: fault == "shared-diagnostic" ? "OPENCODE001" : null);
}
var errors = new BindingErrorCollector();
new CurationValidator().Validate(document, selected, new ReachableSchemaCollector().Collect(document, selected, errors), Curation(groups), errors);
CurationValidator.Validate(document, selected, ReachableSchemaCollector.Collect(document, selected, errors), Curation(groups), errors);
var identity = expected[0];
await Assert.That(Assert.Throws<BindingException>(errors.ThrowIfAny).Errors
.Where(error => error.Category == identity.Category && error.Subject == identity.Subject))
Expand Down Expand Up @@ -302,7 +325,7 @@ public async Task Bind_Should_Refuse_Invalid_Transport_Owned_Rows(string fault,
var errors = new BindingErrorCollector();
var selected = document.Operations.Where(operation => operation.OperationId == "health.get" || fault == "selected").ToArray();
var declared = fault switch { "malformed" => "bad", "fingerprint" => new string('0', 64), _ => hash };
new CurationValidator().Validate(document, selected, new ReachableSchemaCollector().Collect(document, selected, errors),
CurationValidator.Validate(document, selected, ReachableSchemaCollector.Collect(document, selected, errors),
Curation(Groups("health", RootGroup()), transportOwned: [TransportOwned(subject, declared)]), errors);
var expected = new BindingError(BindingErrorCategory.Curation, subject, problem);
var exception = Assert.Throws<BindingException>(errors.ThrowIfAny);
Expand Down Expand Up @@ -1847,8 +1870,6 @@ public async Task Bind_Should_Refuse_A_Tag_Owned_By_Two_Closure_Schemas(bool res
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)
{
Expand Down
Loading
Loading