From 872856c8b777eceb5134706616954a353d9b8091 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 25 Aug 2026 12:45:51 -0700 Subject: [PATCH] feat(binding): widen scalar capability inputs --- builder_test.go | 4 +- dispatch_test.go | 191 ++++++++++++--- internal/binding/input.go | 252 ++++++++++++++++---- internal/binding/input_test.go | 363 +++++++++++++++++++++++++++++ internal/binding/output.go | 2 +- internal/binding/plan.go | 56 ++++- internal/binding/plan_test.go | 137 ++++++++++- internal/binding/signature.go | 88 ++++++- internal/binding/signature_test.go | 96 +++++++- internal/execution/execute_test.go | 177 ++++++++++++-- 10 files changed, 1239 insertions(+), 127 deletions(-) diff --git a/builder_test.go b/builder_test.go index 41a82ce..75446d3 100644 --- a/builder_test.go +++ b/builder_test.go @@ -28,8 +28,8 @@ type builderOutput struct { // invalidBuilderInput contains a type outside the restricted binder contract. type invalidBuilderInput struct { - // Count is intentionally unsupported as a required integer. - Count int64 `json:"count"` + // Count is intentionally unsupported as a 32-bit integer. + Count int32 `json:"count"` } // nilPolicy is a typed-nil authorization implementation used to test Build validation. diff --git a/dispatch_test.go b/dispatch_test.go index 1a03f12..173d08f 100644 --- a/dispatch_test.go +++ b/dispatch_test.go @@ -3,6 +3,7 @@ package codemode import ( "context" "errors" + "math" "sync/atomic" "testing" "time" @@ -31,6 +32,33 @@ type dispatchOutput struct { Value string `json:"value"` } +// widenedDispatchInput is the eight-form dispatcher test input. +type widenedDispatchInput struct { + // Org is the required string argument. + Org string `json:"org"` + + // Count is the required signed integer argument. + Count int64 `json:"count"` + + // Active is the required Boolean argument. + Active bool `json:"active"` + + // Score is the required finite floating-point argument. + Score float64 `json:"score"` + + // Label is the optional string argument. + Label *string `json:"label,omitempty"` + + // Limit is the optional signed integer argument. + Limit *int64 `json:"limit,omitempty"` + + // Enabled is the optional Boolean argument. + Enabled *bool `json:"enabled"` + + // Weight is the optional finite floating-point argument. + Weight *float64 `json:"weight,omitempty"` +} + // dispatchOutcome carries one asynchronous dispatcher result. type dispatchOutcome struct { // value is the converted native result. @@ -44,33 +72,70 @@ type dispatchOutcome struct { func TestDispatchBindsAuthorizesThenInvokes(t *testing.T) { authorizer := authzmocks.NewMockAuthorizer(t) events := make([]string, 0, 2) + label := "beta" + limit := int64(25) + enabled := true + weight := 2.5 + canonical := map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": 1.5, + "label": "beta", + "limit": int64(25), + "enabled": true, + "weight": 2.5, + } authorizer.EXPECT().Authorize(mock.Anything, mock.MatchedBy(func(input authz.AuthorizationInput) bool { return input.Subject.ID == "subject-1" && input.CapabilityID == "cap.lookup" && input.CapabilityName == "records.lookup" && - assert.ObjectsAreEqual(map[string]any{"value": "alpha"}, input.Arguments) + assert.ObjectsAreEqual(canonical, input.Arguments) })).Run(func(context.Context, authz.AuthorizationInput) { events = append(events, "authorize") }).Return(nil).Once() - subject := newDispatchSubject(t, authorizer, func(_ context.Context, _ authz.Subject, input any) (any, error) { - events = append(events, "handler") - typed, ok := input.(dispatchInput) - if !ok { - return nil, catalog.ErrInputTypeMismatch - } - return dispatchOutput(typed), nil - }) + subject := newWidenedDispatchSubject( + t, + authorizer, + func(_ context.Context, _ authz.Subject, input any) (any, error) { + events = append(events, "handler") + typed, ok := input.(widenedDispatchInput) + if !ok { + return nil, catalog.ErrInputTypeMismatch + } + assert.Equal(t, widenedDispatchInput{ + Org: "meigma", + Count: 3, + Active: true, + Score: 1.5, + Label: &label, + Limit: &limit, + Enabled: &enabled, + Weight: &weight, + }, typed) + return dispatchOutput{Value: typed.Org}, nil + }, + ) result, err := subject.dispatch.dispatch( t.Context(), authz.Subject{ID: "subject-1"}, "cap.lookup", - map[string]any{"value": "alpha"}, + map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": 1.5, + "label": "beta", + "limit": int64(25), + "enabled": true, + "weight": 2.5, + }, ) require.NoError(t, err) assert.Equal(t, []string{"authorize", "handler"}, events) - assert.Equal(t, map[string]any{"value": "alpha"}, result) + assert.Equal(t, map[string]any{"value": "meigma"}, result) } // TestDispatchTranslatesEveryBindValueFailureInternally proves decoded maps never reach policy. @@ -81,20 +146,52 @@ func TestDispatchTranslatesEveryBindValueFailureInternally(t *testing.T) { // arguments is the decoded child map presented to dispatch. arguments map[string]any + + // widened selects the eight-form input contract. + widened bool }{ {name: "missing required", arguments: map[string]any{}}, {name: "unknown", arguments: map[string]any{"value": "alpha", "other": "extra"}}, {name: "mistyped required string", arguments: map[string]any{"value": int64(1)}}, + {name: "widened missing required integer", widened: true, arguments: map[string]any{ + "org": "meigma", + "active": true, + "score": 1.5, + }}, + {name: "widened float where int is required", widened: true, arguments: map[string]any{ + "org": "meigma", + "count": float64(3), + "active": true, + "score": 1.5, + }}, + {name: "widened NaN float", widened: true, arguments: map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": math.NaN(), + }}, + {name: "widened infinity float", widened: true, arguments: map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": math.Inf(1), + }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { authorizer := authzmocks.NewMockAuthorizer(t) var handlerCalls atomic.Int64 - subject := newDispatchSubject(t, authorizer, func(context.Context, authz.Subject, any) (any, error) { + invoke := func(context.Context, authz.Subject, any) (any, error) { handlerCalls.Add(1) return dispatchOutput{}, nil - }) + } + var subject *dispatchSubject + if tt.widened { + subject = newWidenedDispatchSubject(t, authorizer, invoke) + } else { + subject = newDispatchSubject(t, authorizer, invoke) + } _, err := subject.dispatch.dispatch( t.Context(), @@ -250,24 +347,34 @@ func TestDispatchClassifiesPolicyAndHandlerFailures(t *testing.T) { // TestDispatchReturnsFreshCanonicalMaps proves decoded child maps never become authorization input. func TestDispatchReturnsFreshCanonicalMaps(t *testing.T) { authorizer := authzmocks.NewMockAuthorizer(t) - decoded := map[string]any{"value": "alpha"} + decoded := map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": 1.5, + "limit": int64(25), + } var authorized map[string]any authorizer.EXPECT().Authorize(mock.Anything, mock.Anything).Run( func(_ context.Context, input authz.AuthorizationInput) { authorized = input.Arguments - input.Arguments["value"] = "mutated-policy" - decoded["value"] = "mutated-decoded" + input.Arguments["org"] = "mutated-policy" + decoded["org"] = "mutated-decoded" }, ).Return(nil).Once() - var received dispatchInput - subject := newDispatchSubject(t, authorizer, func(_ context.Context, _ authz.Subject, input any) (any, error) { - typed, ok := input.(dispatchInput) - if !ok { - return nil, catalog.ErrInputTypeMismatch - } - received = typed - return dispatchOutput(typed), nil - }) + var received widenedDispatchInput + subject := newWidenedDispatchSubject( + t, + authorizer, + func(_ context.Context, _ authz.Subject, input any) (any, error) { + typed, ok := input.(widenedDispatchInput) + if !ok { + return nil, catalog.ErrInputTypeMismatch + } + received = typed + return dispatchOutput{Value: typed.Org}, nil + }, + ) result, err := subject.dispatch.dispatch( t.Context(), @@ -278,10 +385,14 @@ func TestDispatchReturnsFreshCanonicalMaps(t *testing.T) { require.NoError(t, err) require.NotNil(t, authorized) - assert.Equal(t, "mutated-policy", authorized["value"]) - assert.Equal(t, "mutated-decoded", decoded["value"]) - assert.Equal(t, "alpha", received.Value) - assert.Equal(t, map[string]any{"value": "alpha"}, result) + assert.Equal(t, "mutated-policy", authorized["org"]) + assert.Equal(t, "mutated-decoded", decoded["org"]) + assert.Equal(t, "meigma", received.Org) + assert.Equal(t, int64(3), received.Count) + require.NotNil(t, received.Limit) + assert.Equal(t, int64(25), *received.Limit) + assert.Nil(t, received.Label) + assert.Equal(t, map[string]any{"value": "meigma"}, result) } // TestDispatchCancellationAfterAllowPreventsInvoke proves a stale allow cannot reach the handler. @@ -473,3 +584,25 @@ func newDispatchSubject(t *testing.T, authorizer authz.Authorizer, invoke catalo dispatch: newDispatcher(capabilityCatalog, authorizer, 16, 64*1024), } } + +// newWidenedDispatchSubject builds a dispatcher around the eight-form input contract. +func newWidenedDispatchSubject(t *testing.T, authorizer authz.Authorizer, invoke catalog.Invoker) *dispatchSubject { + t.Helper() + plan, err := binding.CompileFor[widenedDispatchInput, dispatchOutput]() + require.NoError(t, err) + capabilityCatalog, err := catalog.Build([]catalog.Registration{{ + ID: "cap.lookup", + Name: "records.lookup", + Summary: "Return one record.", + Description: "Returns the supplied record value.", + Plan: plan, + Invoke: invoke, + }}, catalog.Options{ + MaxSearchQueryBytes: 256, + MaxSearchResults: 20, + }) + require.NoError(t, err) + return &dispatchSubject{ + dispatch: newDispatcher(capabilityCatalog, authorizer, 16, 64*1024), + } +} diff --git a/internal/binding/input.go b/internal/binding/input.go index 2a3334d..0bffdf7 100644 --- a/internal/binding/input.go +++ b/internal/binding/input.go @@ -74,38 +74,17 @@ func (plan *Plan) BindValue(arguments map[string]any) (any, map[string]any, erro canonical := make(map[string]any, len(plan.inputFields)) for _, field := range plan.inputFields { value, present := arguments[field.name] - switch field.kind { - case fieldString: - if !present { - return nil, nil, fmt.Errorf("%w: missing required argument %q", ErrInvalidArguments, field.name) - } - converted, ok := value.(string) - if !ok { - return nil, nil, fmt.Errorf("%w: argument %q must be a string", ErrInvalidArguments, field.name) - } - input.Field(field.index).SetString(converted) - canonical[field.name] = converted - case fieldOptionalInt64: - if !present || value == nil { - continue - } - converted, ok := value.(int64) - if !ok { - return nil, nil, fmt.Errorf( - "%w: argument %q must be an integer or None", - ErrInvalidArguments, - field.name, - ) - } - pointer := reflect.New(input.Field(field.index).Type().Elem()) - pointer.Elem().SetInt(converted) - input.Field(field.index).Set(pointer) - canonical[field.name] = converted - case fieldInt64, fieldBool, fieldFloat64: - return nil, nil, fmt.Errorf("%w: argument %q has an invalid compiled kind", ErrInvalidPlan, field.name) - default: - return nil, nil, fmt.Errorf("%w: argument %q has an unknown compiled kind", ErrInvalidPlan, field.name) + converted, omitted, err := convertCanonicalArgument(field, present, value) + if err != nil { + return nil, nil, err + } + if omitted { + continue } + if err := assignInputField(input.Field(field.index), field, converted); err != nil { + return nil, nil, err + } + canonical[field.name] = converted } for name := range arguments { if _, known := plan.inputByName[name]; !known { @@ -131,25 +110,214 @@ func keywordArgument(keyword starlark.Tuple) (string, starlark.Value, error) { func convertShapeArgument(field FieldShape, name string, value starlark.Value) (any, bool, error) { switch { case isRequiredStringShape(field): - converted, ok := starlark.AsString(value) - if !ok { - return nil, false, fmt.Errorf("%w: argument %q must be a string", ErrInvalidArguments, name) + return convertStarlarkString(name, value, false) + case isOptionalStringShape(field): + if value == starlark.None { + return nil, true, nil } - return converted, false, nil + return convertStarlarkString(name, value, true) + case isRequiredIntegerShape(field): + return convertStarlarkInteger(name, value, false) case isOptionalIntegerShape(field): if value == starlark.None { return nil, true, nil } - integer, ok := value.(starlark.Int) - if !ok { - return nil, false, fmt.Errorf("%w: argument %q must be an integer or None", ErrInvalidArguments, name) + return convertStarlarkInteger(name, value, true) + case isRequiredBoolShape(field): + return convertStarlarkBool(name, value, false) + case isOptionalBoolShape(field): + if value == starlark.None { + return nil, true, nil } - converted, ok := integer.Int64() - if !ok { - return nil, false, fmt.Errorf("%w: argument %q overflows int64", ErrInvalidArguments, name) + return convertStarlarkBool(name, value, true) + case isRequiredFloatShape(field): + return convertStarlarkFloat(name, value, false) + case isOptionalFloatShape(field): + if value == starlark.None { + return nil, true, nil } - return converted, false, nil + return convertStarlarkFloat(name, value, true) default: return nil, false, fmt.Errorf("%w: argument %q has an unsupported shape", ErrInvalidPlan, name) } } + +// convertCanonicalArgument converts one normalized parent argument against a compiled field. +func convertCanonicalArgument(field inputField, present bool, value any) (any, bool, error) { + if field.required && !present { + return nil, false, fmt.Errorf("%w: missing required argument %q", ErrInvalidArguments, field.name) + } + if !field.required && (!present || value == nil) { + return nil, true, nil + } + switch field.kind { + case fieldString: + return convertCanonicalString(field.name, value, false) + case fieldOptionalString: + return convertCanonicalString(field.name, value, true) + case fieldInt64: + return convertCanonicalInteger(field.name, value, false) + case fieldOptionalInt64: + return convertCanonicalInteger(field.name, value, true) + case fieldBool: + return convertCanonicalBool(field.name, value, false) + case fieldOptionalBool: + return convertCanonicalBool(field.name, value, true) + case fieldFloat64: + return convertCanonicalFloat(field.name, value, false) + case fieldOptionalFloat64: + return convertCanonicalFloat(field.name, value, true) + default: + return nil, false, fmt.Errorf("%w: argument %q has an unknown compiled kind", ErrInvalidPlan, field.name) + } +} + +// assignInputField writes one converted scalar onto the reconstructed Go input. +func assignInputField(target reflect.Value, field inputField, converted any) error { + switch field.kind { + case fieldString, fieldOptionalString: + value, err := compiledScalar[string](converted, field.name) + if err != nil { + return err + } + return writeScalar(target, field.required, value, target.SetString) + case fieldInt64, fieldOptionalInt64: + value, err := compiledScalar[int64](converted, field.name) + if err != nil { + return err + } + return writeScalar(target, field.required, value, target.SetInt) + case fieldBool, fieldOptionalBool: + value, err := compiledScalar[bool](converted, field.name) + if err != nil { + return err + } + return writeScalar(target, field.required, value, target.SetBool) + case fieldFloat64, fieldOptionalFloat64: + value, err := compiledScalar[float64](converted, field.name) + if err != nil { + return err + } + return writeScalar(target, field.required, value, target.SetFloat) + default: + return fmt.Errorf("%w: argument %q has an unknown compiled kind", ErrInvalidPlan, field.name) + } +} + +// compiledScalar reports an impossible compiled/value mismatch as an invalid plan. +func compiledScalar[T any](converted any, name string) (T, error) { + typed, ok := converted.(T) + if !ok { + var zero T + return zero, fmt.Errorf("%w: argument %q has an invalid compiled value", ErrInvalidPlan, name) + } + return typed, nil +} + +// writeScalar stores a required scalar or a non-nil optional pointer. +func writeScalar[T any](target reflect.Value, required bool, value T, set func(T)) error { + if required { + set(value) + return nil + } + assignOptionalScalar(target, value) + return nil +} + +// assignOptionalScalar stores a non-nil pointer to converted on target. +func assignOptionalScalar(target reflect.Value, converted any) { + pointer := reflect.New(target.Type().Elem()) + pointer.Elem().Set(reflect.ValueOf(converted).Convert(target.Type().Elem())) + target.Set(pointer) +} + +// convertStarlarkString converts a Starlark string argument. +func convertStarlarkString(name string, value starlark.Value, optional bool) (any, bool, error) { + converted, ok := starlark.AsString(value) + if !ok { + return nil, false, scalarArgumentError(name, "a string", optional) + } + return converted, false, nil +} + +// convertStarlarkInteger converts a Starlark integer that fits signed 64-bit range. +func convertStarlarkInteger(name string, value starlark.Value, optional bool) (any, bool, error) { + integer, ok := value.(starlark.Int) + if !ok { + return nil, false, scalarArgumentError(name, "an integer", optional) + } + converted, ok := integer.Int64() + if !ok { + return nil, false, fmt.Errorf("%w: argument %q overflows int64", ErrInvalidArguments, name) + } + return converted, false, nil +} + +// convertStarlarkBool converts a Starlark Boolean argument. +func convertStarlarkBool(name string, value starlark.Value, optional bool) (any, bool, error) { + boolean, ok := value.(starlark.Bool) + if !ok { + return nil, false, scalarArgumentError(name, "a bool", optional) + } + return bool(boolean), false, nil +} + +// convertStarlarkFloat converts a finite Starlark floating-point argument. +func convertStarlarkFloat(name string, value starlark.Value, optional bool) (any, bool, error) { + number, ok := value.(starlark.Float) + if !ok { + return nil, false, scalarArgumentError(name, "a float", optional) + } + converted := float64(number) + if !isFiniteFloat(converted) { + return nil, false, fmt.Errorf("%w: argument %q is not finite", ErrInvalidArguments, name) + } + return converted, false, nil +} + +// convertCanonicalString converts a normalized string argument. +func convertCanonicalString(name string, value any, optional bool) (any, bool, error) { + converted, ok := value.(string) + if !ok { + return nil, false, scalarArgumentError(name, "a string", optional) + } + return converted, false, nil +} + +// convertCanonicalInteger converts a normalized signed 64-bit integer argument. +func convertCanonicalInteger(name string, value any, optional bool) (any, bool, error) { + converted, ok := value.(int64) + if !ok { + return nil, false, scalarArgumentError(name, "an integer", optional) + } + return converted, false, nil +} + +// convertCanonicalBool converts a normalized Boolean argument. +func convertCanonicalBool(name string, value any, optional bool) (any, bool, error) { + converted, ok := value.(bool) + if !ok { + return nil, false, scalarArgumentError(name, "a bool", optional) + } + return converted, false, nil +} + +// convertCanonicalFloat converts a normalized finite floating-point argument. +func convertCanonicalFloat(name string, value any, optional bool) (any, bool, error) { + converted, ok := value.(float64) + if !ok { + return nil, false, scalarArgumentError(name, "a float", optional) + } + if !isFiniteFloat(converted) { + return nil, false, fmt.Errorf("%w: argument %q is not finite", ErrInvalidArguments, name) + } + return converted, false, nil +} + +// scalarArgumentError classifies a mistyped scalar before authorization. +func scalarArgumentError(name string, expected string, optional bool) error { + if optional { + return fmt.Errorf("%w: argument %q must be %s or None", ErrInvalidArguments, name, expected) + } + return fmt.Errorf("%w: argument %q must be %s", ErrInvalidArguments, name, expected) +} diff --git a/internal/binding/input_test.go b/internal/binding/input_test.go index 87b58ff..1498e96 100644 --- a/internal/binding/input_test.go +++ b/internal/binding/input_test.go @@ -2,6 +2,8 @@ package binding import ( "encoding/json" + "maps" + "math" "math/big" "testing" @@ -73,6 +75,184 @@ func TestBindShapeAndBindValueAgreeOnSupportedCalls(t *testing.T) { } } +// TestBindShapeAndBindValueAgreeOnWidenedScalars proves all eight forms share child and parent behavior. +func TestBindShapeAndBindValueAgreeOnWidenedScalars(t *testing.T) { + plan, err := CompileFor[widenedInput, representativeOutput]() + require.NoError(t, err) + label := "beta" + limit := int64(25) + enabled := true + weight := 2.5 + + tests := []struct { + // name identifies the supported call shape. + name string + + // kwargs contains the Starlark keyword arguments. + kwargs []starlark.Tuple + + // want is the exact registered input reconstructed by the parent. + want widenedInput + + // canonical is the fresh JSON-shaped authorization map. + canonical map[string]any + }{ + { + name: "all eight present values", + kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("count", starlark.MakeInt64(3)), + keyword("active", starlark.True), + keyword("score", starlark.Float(1.5)), + keyword("label", starlark.String(label)), + keyword("limit", starlark.MakeInt64(limit)), + keyword("enabled", starlark.True), + keyword("weight", starlark.Float(weight)), + }, + want: widenedInput{ + Org: "meigma", + Count: 3, + Active: true, + Score: 1.5, + Label: &label, + Limit: &limit, + Enabled: &enabled, + Weight: &weight, + }, + canonical: map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": 1.5, + "label": "beta", + "limit": int64(25), + "enabled": true, + "weight": 2.5, + }, + }, + { + name: "omitted optional scalars", + kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("count", starlark.MakeInt64(3)), + keyword("active", starlark.False), + keyword("score", starlark.Float(0)), + }, + want: widenedInput{Org: "meigma", Count: 3, Score: 0}, + canonical: map[string]any{ + "org": "meigma", + "count": int64(3), + "active": false, + "score": float64(0), + }, + }, + { + name: "explicit None optional scalars", + kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("count", starlark.MakeInt64(3)), + keyword("active", starlark.True), + keyword("score", starlark.Float(1.5)), + keyword("label", starlark.None), + keyword("limit", starlark.None), + keyword("enabled", starlark.None), + keyword("weight", starlark.None), + }, + want: widenedInput{Org: "meigma", Count: 3, Active: true, Score: 1.5}, + canonical: map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": 1.5, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + child, err := BindShape(plan.InputShape(), nil, tt.kwargs) + require.NoError(t, err) + assert.Equal(t, tt.canonical, child) + + bound, canonical, err := plan.BindValue(child) + require.NoError(t, err) + typed, ok := bound.(widenedInput) + require.True(t, ok, "parent must reconstruct the exact registered input type") + assert.Equal(t, tt.want, typed) + assert.Equal(t, tt.canonical, canonical) + child["org"] = "mutated-child" + assert.Equal(t, tt.canonical, canonical) + _, hasLabel := canonical["label"] + _, hasLimit := canonical["limit"] + _, hasEnabled := canonical["enabled"] + _, hasWeight := canonical["weight"] + if tt.want.Label == nil { + assert.False(t, hasLabel) + assert.Nil(t, typed.Label) + } + if tt.want.Limit == nil { + assert.False(t, hasLimit) + assert.Nil(t, typed.Limit) + } + if tt.want.Enabled == nil { + assert.False(t, hasEnabled) + assert.Nil(t, typed.Enabled) + } + if tt.want.Weight == nil { + assert.False(t, hasWeight) + assert.Nil(t, typed.Weight) + } + }) + } +} + +// TestBindShapeAndBindValueAgreeOnNamedAliases proves aliases bind by underlying kind. +func TestBindShapeAndBindValueAgreeOnNamedAliases(t *testing.T) { + plan, err := CompileFor[aliasedInput, representativeOutput]() + require.NoError(t, err) + label := orgName("beta") + limit := itemCount(25) + enabled := flag(true) + weight := score(2.5) + + child, err := BindShape(plan.InputShape(), nil, []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("count", starlark.MakeInt64(3)), + keyword("active", starlark.True), + keyword("score", starlark.Float(1.5)), + keyword("label", starlark.String("beta")), + keyword("limit", starlark.MakeInt64(25)), + keyword("enabled", starlark.True), + keyword("weight", starlark.Float(2.5)), + }) + require.NoError(t, err) + + bound, canonical, err := plan.BindValue(child) + require.NoError(t, err) + typed, ok := bound.(aliasedInput) + require.True(t, ok, "parent must reconstruct the exact named-alias input type") + assert.Equal(t, aliasedInput{ + Org: "meigma", + Count: 3, + Active: true, + Score: 1.5, + Label: &label, + Limit: &limit, + Enabled: &enabled, + Weight: &weight, + }, typed) + assert.Equal(t, map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": 1.5, + "label": "beta", + "limit": int64(25), + "enabled": true, + "weight": 2.5, + }, canonical) +} + // TestBindShapeHandlesAnEmptyInputShape proves a zero-field manifest binds no arguments. func TestBindShapeHandlesAnEmptyInputShape(t *testing.T) { plan, err := CompileFor[struct{}, representativeOutput]() @@ -221,6 +401,189 @@ func TestBindValueRejectsNormalizedWireMismatches(t *testing.T) { } } +// TestBindShapeRejectsWidenedScalarMismatches proves category, range, and finiteness fail before authorization. +func TestBindShapeRejectsWidenedScalarMismatches(t *testing.T) { + plan, err := CompileFor[widenedInput, representativeOutput]() + require.NoError(t, err) + overflow := new(big.Int).Lsh(big.NewInt(1), 80) + required := []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("count", starlark.MakeInt64(3)), + keyword("active", starlark.True), + keyword("score", starlark.Float(1.5)), + } + + tests := []struct { + // name identifies the malformed call. + name string + + // kwargs contains keyword arguments. + kwargs []starlark.Tuple + + // contains is the expected safe diagnostic fragment. + contains string + }{ + {name: "missing required integer", kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("active", starlark.True), + keyword("score", starlark.Float(1.5)), + }, contains: "missing required"}, + {name: "integer supplied as float", kwargs: append(append([]starlark.Tuple{}, required[:2]...), + keyword("active", starlark.True), + keyword("score", starlark.MakeInt64(1)), + ), contains: "must be a float"}, + {name: "float supplied as integer", kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("count", starlark.Float(3)), + keyword("active", starlark.True), + keyword("score", starlark.Float(1.5)), + }, contains: "must be an integer"}, + {name: "bool supplied as integer", kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("count", starlark.True), + keyword("active", starlark.True), + keyword("score", starlark.Float(1.5)), + }, contains: "must be an integer"}, + {name: "overflowing required integer", kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("count", starlark.MakeBigInt(overflow)), + keyword("active", starlark.True), + keyword("score", starlark.Float(1.5)), + }, contains: "overflows int64"}, + {name: "NaN float", kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("count", starlark.MakeInt64(3)), + keyword("active", starlark.True), + keyword("score", starlark.Float(math.NaN())), + }, contains: "not finite"}, + {name: "positive infinity", kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("count", starlark.MakeInt64(3)), + keyword("active", starlark.True), + keyword("score", starlark.Float(math.Inf(1))), + }, contains: "not finite"}, + {name: "negative infinity", kwargs: []starlark.Tuple{ + keyword("org", starlark.String("meigma")), + keyword("count", starlark.MakeInt64(3)), + keyword("active", starlark.True), + keyword("score", starlark.Float(math.Inf(-1))), + }, contains: "not finite"}, + {name: "optional float NaN", kwargs: append(append([]starlark.Tuple{}, required...), + keyword("weight", starlark.Float(math.NaN())), + ), contains: "not finite"}, + {name: "optional integer overflow", kwargs: append(append([]starlark.Tuple{}, required...), + keyword("limit", starlark.MakeBigInt(overflow)), + ), contains: "overflows int64"}, + {name: "optional bool as integer", kwargs: append(append([]starlark.Tuple{}, required...), + keyword("enabled", starlark.MakeInt64(1)), + ), contains: "bool or None"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := BindShape(plan.InputShape(), nil, tt.kwargs) + + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidArguments) + assert.Contains(t, err.Error(), tt.contains) + }) + } +} + +// TestBindValueRejectsWidenedNormalizedMismatches proves parent re-binding rejects coerced or non-finite values. +func TestBindValueRejectsWidenedNormalizedMismatches(t *testing.T) { + plan, err := CompileFor[widenedInput, representativeOutput]() + require.NoError(t, err) + required := map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": 1.5, + } + + tests := []struct { + // name identifies the rejected decoded map. + name string + + // arguments is the decoded child map presented to the parent. + arguments map[string]any + + // contains is the expected safe diagnostic fragment. + contains string + }{ + {name: "missing required bool", arguments: map[string]any{ + "org": "meigma", + "count": int64(3), + "score": 1.5, + }, contains: "missing required"}, + {name: "float64 where int64 is required", arguments: map[string]any{ + "org": "meigma", + "count": float64(3), + "active": true, + "score": 1.5, + }, contains: "must be an integer"}, + {name: "int64 where float64 is required", arguments: map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": int64(1), + }, contains: "must be a float"}, + {name: "bool where int64 is required", arguments: map[string]any{ + "org": "meigma", + "count": true, + "active": true, + "score": 1.5, + }, contains: "must be an integer"}, + {name: "json.Number float", arguments: map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": json.Number("1.5"), + }, contains: "must be a float"}, + {name: "NaN float", arguments: map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": math.NaN(), + }, contains: "not finite"}, + {name: "positive infinity", arguments: map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": math.Inf(1), + }, contains: "not finite"}, + { + name: "optional float infinity", + arguments: withArgument(required, "weight", math.Inf(-1)), + contains: "not finite", + }, + { + name: "optional integer json.Number", + arguments: withArgument(required, "limit", json.Number("25")), + contains: "integer or None", + }, + {name: "unknown extra key", arguments: withArgument(required, "other", "value"), contains: "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := plan.BindValue(tt.arguments) + + require.Error(t, err) + require.ErrorIs(t, err, ErrInvalidArguments) + assert.Contains(t, err.Error(), tt.contains) + }) + } +} + +// withArgument returns a copy of base with one extra or replaced key. +func withArgument(base map[string]any, name string, value any) map[string]any { + copied := make(map[string]any, len(base)+1) + maps.Copy(copied, base) + copied[name] = value + return copied +} + // keyword constructs one Starlark keyword tuple. func keyword(name string, value starlark.Value) starlark.Tuple { return starlark.Tuple{starlark.String(name), value} diff --git a/internal/binding/output.go b/internal/binding/output.go index 70cf222..b76da91 100644 --- a/internal/binding/output.go +++ b/internal/binding/output.go @@ -41,7 +41,7 @@ func convertOutputField(field outputField, value reflect.Value) (any, error) { return nil, fmt.Errorf("%w: output field %q is not finite", ErrUnsupportedValue, field.name) } return float, nil - case fieldOptionalInt64: + case fieldOptionalString, fieldOptionalInt64, fieldOptionalBool, fieldOptionalFloat64: return nil, fmt.Errorf("%w: output field %q has an invalid compiled kind", ErrInvalidPlan, field.name) } return nil, fmt.Errorf("%w: output field %q has an unknown compiled kind", ErrInvalidPlan, field.name) diff --git a/internal/binding/plan.go b/internal/binding/plan.go index 0f5d462..a5c3411 100644 --- a/internal/binding/plan.go +++ b/internal/binding/plan.go @@ -27,10 +27,13 @@ type fieldKind uint8 const ( fieldString fieldKind = iota + 1 - fieldOptionalInt64 fieldInt64 fieldBool fieldFloat64 + fieldOptionalString + fieldOptionalInt64 + fieldOptionalBool + fieldOptionalFloat64 ) // inputField is one immutable input-field conversion step. @@ -130,24 +133,55 @@ func compileInput(inputType reflect.Type) ([]inputField, map[string]int, error) } compiled := inputField{name: name, index: index} - switch { - case field.Type.Kind() == reflect.String: - if options.omitempty { - return nil, nil, fmt.Errorf("%w: required input %q cannot use omitempty", ErrInvalidPlan, name) - } - compiled.kind = fieldString - compiled.required = true - case field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Int64: - compiled.kind = fieldOptionalInt64 - default: + kind, required, ok := compileInputKind(field.Type) + if !ok { return nil, nil, fmt.Errorf("%w: input field %q has unsupported type %s", ErrInvalidPlan, name, field.Type) } + if required && options.omitempty { + return nil, nil, fmt.Errorf("%w: required input %q cannot use omitempty", ErrInvalidPlan, name) + } + compiled.kind = kind + compiled.required = required byName[name] = len(fields) fields = append(fields, compiled) } return fields, byName, nil } +// compileInputKind maps one input field type onto a supported scalar conversion. +// +// Named aliases are accepted by underlying kind. Pointers to the four scalar +// kinds are optional; non-pointer scalars are required. +func compileInputKind(fieldType reflect.Type) (fieldKind, bool, bool) { + if fieldType.Kind() == reflect.Pointer { + elementKind := fieldType.Elem().Kind() + switch elementKind { //nolint:exhaustive // Unsupported pointer element kinds share registration-time rejection. + case reflect.String: + return fieldOptionalString, false, true + case reflect.Int64: + return fieldOptionalInt64, false, true + case reflect.Bool: + return fieldOptionalBool, false, true + case reflect.Float64: + return fieldOptionalFloat64, false, true + default: + return 0, false, false + } + } + switch fieldType.Kind() { //nolint:exhaustive // Unsupported reflect kinds share registration-time rejection. + case reflect.String: + return fieldString, true, true + case reflect.Int64: + return fieldInt64, true, true + case reflect.Bool: + return fieldBool, true, true + case reflect.Float64: + return fieldFloat64, true, true + default: + return 0, false, false + } +} + // compileOutput validates and compiles the restricted output struct. func compileOutput(outputType reflect.Type) ([]outputField, error) { if outputType == nil || outputType.Kind() != reflect.Struct { diff --git a/internal/binding/plan_test.go b/internal/binding/plan_test.go index e019c63..61300ef 100644 --- a/internal/binding/plan_test.go +++ b/internal/binding/plan_test.go @@ -32,6 +32,72 @@ type representativeOutput struct { Score float64 `json:"score"` } +// orgName is a named string alias accepted as a required or optional input. +type orgName string + +// itemCount is a named int64 alias accepted as a required or optional input. +type itemCount int64 + +// flag is a named bool alias accepted as a required or optional input. +type flag bool + +// score is a named float64 alias accepted as a required or optional input. +type score float64 + +// widenedInput is the eight-form scalar input used by widened binding tests. +type widenedInput struct { + // Org is the required string argument. + Org string `json:"org"` + + // Count is the required signed integer argument. + Count int64 `json:"count"` + + // Active is the required Boolean argument. + Active bool `json:"active"` + + // Score is the required finite floating-point argument. + Score float64 `json:"score"` + + // Label is the optional string argument. + Label *string `json:"label,omitempty"` + + // Limit is the optional signed integer argument. + Limit *int64 `json:"limit,omitempty"` + + // Enabled is the optional Boolean argument. + Enabled *bool `json:"enabled"` + + // Weight is the optional finite floating-point argument. + Weight *float64 `json:"weight,omitempty"` +} + +// aliasedInput is the eight-form scalar input using named underlying kinds. +type aliasedInput struct { + // Org is the required named string argument. + Org orgName `json:"org"` + + // Count is the required named integer argument. + Count itemCount `json:"count"` + + // Active is the required named Boolean argument. + Active flag `json:"active"` + + // Score is the required named floating-point argument. + Score score `json:"score"` + + // Label is the optional named string argument. + Label *orgName `json:"label,omitempty"` + + // Limit is the optional named integer argument. + Limit *itemCount `json:"limit,omitempty"` + + // Enabled is the optional named Boolean argument. + Enabled *flag `json:"enabled"` + + // Weight is the optional named floating-point argument. + Weight *score `json:"weight,omitempty"` +} + // embeddedInputBase supplies a field that must not be promoted implicitly. type embeddedInputBase struct { // Value is an embedded candidate argument. @@ -53,6 +119,49 @@ func TestCompileAcceptsRepresentativeTypes(t *testing.T) { assert.Equal(t, reflect.TypeFor[representativeOutput](), plan.OutputType()) } +// TestCompileAcceptsWidenedScalarInputs proves all eight scalar forms and named aliases compile. +func TestCompileAcceptsWidenedScalarInputs(t *testing.T) { + tests := []struct { + // name identifies the accepted input type. + name string + + // inputType is compiled as a capability input. + inputType reflect.Type + }{ + {name: "eight scalar forms", inputType: reflect.TypeFor[widenedInput]()}, + {name: "named aliases", inputType: reflect.TypeFor[aliasedInput]()}, + {name: "required integer", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value int64 `json:"value"` + }{})}, + {name: "required bool", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value bool `json:"value"` + }{})}, + {name: "required float", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value float64 `json:"value"` + }{})}, + {name: "optional string without omitempty", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value *string `json:"value"` + }{})}, + {name: "optional integer without omitempty", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value *int64 `json:"value"` + }{})}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plan, err := Compile(tt.inputType, reflect.TypeFor[representativeOutput]()) + + require.NoError(t, err) + assert.Equal(t, tt.inputType, plan.InputType()) + }) + } +} + // TestCompileRejectsUnsupportedInputShapes proves malformed binding plans fail during registration. func TestCompileRejectsUnsupportedInputShapes(t *testing.T) { tests := []struct { @@ -66,13 +175,21 @@ func TestCompileRejectsUnsupportedInputShapes(t *testing.T) { contains string }{ {name: "pointer input", inputType: reflect.TypeFor[*representativeInput](), contains: "non-pointer struct"}, - {name: "required integer", inputType: reflect.TypeOf(struct { + {name: "unsupported integer width", inputType: reflect.TypeOf(struct { // Value is the field under validation. - Value int64 `json:"value"` + Value int32 `json:"value"` + }{}), contains: "unsupported type"}, + {name: "unsupported unsigned integer", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value uint64 `json:"value"` }{}), contains: "unsupported type"}, - {name: "wrong optional pointer", inputType: reflect.TypeOf(struct { + {name: "unsupported optional integer width", inputType: reflect.TypeOf(struct { // Value is the field under validation. - Value *string `json:"value,omitempty"` + Value *int `json:"value,omitempty"` + }{}), contains: "unsupported type"}, + {name: "unsupported float width", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value float32 `json:"value"` }{}), contains: "unsupported type"}, {name: "embedded field", inputType: reflect.TypeFor[embeddedInput](), contains: "embedded fields"}, {name: "unexported field", inputType: reflect.TypeOf(struct { @@ -112,6 +229,18 @@ func TestCompileRejectsUnsupportedInputShapes(t *testing.T) { // Value is the field under validation. Value string `json:"value,omitempty"` }{}), contains: "cannot use omitempty"}, + {name: "required integer omitempty", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value int64 `json:"value,omitempty"` + }{}), contains: "cannot use omitempty"}, + {name: "required bool omitempty", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value bool `json:"value,omitempty"` + }{}), contains: "cannot use omitempty"}, + {name: "required float omitempty", inputType: reflect.TypeOf(struct { + // Value is the field under validation. + Value float64 `json:"value,omitempty"` + }{}), contains: "cannot use omitempty"}, } for _, tt := range tests { diff --git a/internal/binding/signature.go b/internal/binding/signature.go index 6bf47d5..cb05503 100644 --- a/internal/binding/signature.go +++ b/internal/binding/signature.go @@ -14,8 +14,26 @@ const ( // integerType is the compact Starlark-facing notation for a signed integer. integerType = "int" + // boolType is the compact Starlark-facing notation for a Boolean value. + boolType = "bool" + + // floatType is the compact Starlark-facing notation for a finite floating-point value. + floatType = "float" + + // noneSuffix is the exact InputShape suffix for an optional scalar. + noneSuffix = " | None" + + // optionalStringType is the exact InputShape notation for an optional string. + optionalStringType = stringType + noneSuffix + // optionalIntegerType is the exact InputShape notation for an optional integer. - optionalIntegerType = integerType + " | None" + optionalIntegerType = integerType + noneSuffix + + // optionalBoolType is the exact InputShape notation for an optional Boolean. + optionalBoolType = boolType + noneSuffix + + // optionalFloatType is the exact InputShape notation for an optional float. + optionalFloatType = floatType + noneSuffix ) // FieldShape is one model-facing field in a supported capability input or output structure. @@ -78,10 +96,11 @@ func (plan *Plan) OutputShape() []FieldShape { // ValidateInputShape reports whether fields is a combination Plan.InputShape can produce. // -// A required string is exactly Type "str" and Required true. An optional integer -// is exactly Type "int | None" and Required false. An empty shape is valid. -// Names must be unique Starlark identifiers. Any other pair, including the -// literal "unsupported" notation, is rejected. +// Required scalars are exactly Type "str", "int", "bool", or "float" with +// Required true. Optional scalars are exactly those types with " | None" and +// Required false. An empty shape is valid. Names must be unique Starlark +// identifiers. Any other pair, including the literal "unsupported" notation, is +// rejected. func ValidateInputShape(fields []FieldShape) error { seen := make(map[string]struct{}, len(fields)) for _, field := range fields { @@ -101,7 +120,14 @@ func ValidateInputShape(fields []FieldShape) error { // isSupportedInputShape reports whether field is an exact compiled input pair. func isSupportedInputShape(field FieldShape) bool { - return isRequiredStringShape(field) || isOptionalIntegerShape(field) + return isRequiredStringShape(field) || + isRequiredIntegerShape(field) || + isRequiredBoolShape(field) || + isRequiredFloatShape(field) || + isOptionalStringShape(field) || + isOptionalIntegerShape(field) || + isOptionalBoolShape(field) || + isOptionalFloatShape(field) } // isRequiredStringShape reports whether field is a required string descriptor. @@ -109,20 +135,60 @@ func isRequiredStringShape(field FieldShape) bool { return field.Type == stringType && field.Required } +// isRequiredIntegerShape reports whether field is a required integer descriptor. +func isRequiredIntegerShape(field FieldShape) bool { + return field.Type == integerType && field.Required +} + +// isRequiredBoolShape reports whether field is a required Boolean descriptor. +func isRequiredBoolShape(field FieldShape) bool { + return field.Type == boolType && field.Required +} + +// isRequiredFloatShape reports whether field is a required float descriptor. +func isRequiredFloatShape(field FieldShape) bool { + return field.Type == floatType && field.Required +} + +// isOptionalStringShape reports whether field is an optional string descriptor. +func isOptionalStringShape(field FieldShape) bool { + return field.Type == optionalStringType && !field.Required +} + // isOptionalIntegerShape reports whether field is an optional integer descriptor. func isOptionalIntegerShape(field FieldShape) bool { return field.Type == optionalIntegerType && !field.Required } +// isOptionalBoolShape reports whether field is an optional Boolean descriptor. +func isOptionalBoolShape(field FieldShape) bool { + return field.Type == optionalBoolType && !field.Required +} + +// isOptionalFloatShape reports whether field is an optional float descriptor. +func isOptionalFloatShape(field FieldShape) bool { + return field.Type == optionalFloatType && !field.Required +} + // inputKindSignature returns the model-facing notation for one supported input conversion. func inputKindSignature(kind fieldKind) string { switch kind { case fieldString: return stringType + case fieldInt64: + return integerType + case fieldBool: + return boolType + case fieldFloat64: + return floatType + case fieldOptionalString: + return optionalStringType case fieldOptionalInt64: return optionalIntegerType - case fieldInt64, fieldBool, fieldFloat64: - return unsupportedTypeSignature + case fieldOptionalBool: + return optionalBoolType + case fieldOptionalFloat64: + return optionalFloatType } return unsupportedTypeSignature } @@ -135,10 +201,10 @@ func outputKindSignature(kind fieldKind) string { case fieldInt64: return integerType case fieldBool: - return "bool" + return boolType case fieldFloat64: - return "float" - case fieldOptionalInt64: + return floatType + case fieldOptionalString, fieldOptionalInt64, fieldOptionalBool, fieldOptionalFloat64: return unsupportedTypeSignature } return unsupportedTypeSignature diff --git a/internal/binding/signature_test.go b/internal/binding/signature_test.go index 2871821..5570fda 100644 --- a/internal/binding/signature_test.go +++ b/internal/binding/signature_test.go @@ -30,6 +30,28 @@ func TestSignatureUsesTheCompiledInputPlan(t *testing.T) { assert.Equal(t, "records.lookup(*, org: str, limit: int | None)", signature) } +// TestSignatureUsesWidenedScalarInputs proves all eight compiled input forms appear in declaration order. +func TestSignatureUsesWidenedScalarInputs(t *testing.T) { + plan, err := CompileFor[widenedInput, representativeOutput]() + require.NoError(t, err) + + assert.Equal( + t, + "records.lookup(*, org: str, count: int, active: bool, score: float, label: str | None, limit: int | None, enabled: bool | None, weight: float | None)", + plan.Signature("records.lookup"), + ) + assert.Equal(t, []FieldShape{ + {Name: "org", Type: "str", Required: true}, + {Name: "count", Type: "int", Required: true}, + {Name: "active", Type: "bool", Required: true}, + {Name: "score", Type: "float", Required: true}, + {Name: "label", Type: "str | None", Required: false}, + {Name: "limit", Type: "int | None", Required: false}, + {Name: "enabled", Type: "bool | None", Required: false}, + {Name: "weight", Type: "float | None", Required: false}, + }, plan.InputShape()) +} + // TestSignatureHandlesAnEmptyInputStruct proves zero-argument capabilities omit the keyword-only marker. func TestSignatureHandlesAnEmptyInputStruct(t *testing.T) { plan, err := CompileFor[struct{}, representativeOutput]() @@ -96,6 +118,38 @@ func TestValidateInputShapeAcceptsCompiledAndEmptyShapes(t *testing.T) { Required: true, }}, }, + { + name: "required integer", + fields: []FieldShape{{ + Name: "count", + Type: "int", + Required: true, + }}, + }, + { + name: "required bool", + fields: []FieldShape{{ + Name: "active", + Type: "bool", + Required: true, + }}, + }, + { + name: "required float", + fields: []FieldShape{{ + Name: "score", + Type: "float", + Required: true, + }}, + }, + { + name: "optional string", + fields: []FieldShape{{ + Name: "label", + Type: "str | None", + Required: false, + }}, + }, { name: "optional integer", fields: []FieldShape{{ @@ -104,6 +158,23 @@ func TestValidateInputShapeAcceptsCompiledAndEmptyShapes(t *testing.T) { Required: false, }}, }, + { + name: "optional bool", + fields: []FieldShape{{ + Name: "enabled", + Type: "bool | None", + Required: false, + }}, + }, + { + name: "optional float", + fields: []FieldShape{{ + Name: "weight", + Type: "float | None", + Required: false, + }}, + }, + {name: "compiled widened", fields: mustCompileWidenedInputShape(t)}, } for _, tt := range tests { @@ -135,7 +206,7 @@ func TestValidateInputShapeRejectsNonCompiledPairs(t *testing.T) { contains: "unsupported shape", }, { - name: "required integer Type/Required mismatch", + name: "required optional-integer Type/Required mismatch", fields: []FieldShape{{ Name: "limit", Type: "int | None", @@ -144,10 +215,19 @@ func TestValidateInputShapeRejectsNonCompiledPairs(t *testing.T) { contains: "unsupported shape", }, { - name: "output integer notation", + name: "required bool marked optional", fields: []FieldShape{{ - Name: "count", - Type: "int", + Name: "active", + Type: "bool", + Required: false, + }}, + contains: "unsupported shape", + }, + { + name: "optional float marked required", + fields: []FieldShape{{ + Name: "weight", + Type: "float | None", Required: true, }}, contains: "unsupported shape", @@ -190,3 +270,11 @@ func TestValidateInputShapeRejectsNonCompiledPairs(t *testing.T) { }) } } + +// mustCompileWidenedInputShape returns the compiled eight-form input shape. +func mustCompileWidenedInputShape(t *testing.T) []FieldShape { + t.Helper() + plan, err := CompileFor[widenedInput, representativeOutput]() + require.NoError(t, err) + return plan.InputShape() +} diff --git a/internal/execution/execute_test.go b/internal/execution/execute_test.go index 27e8a9d..90e032c 100644 --- a/internal/execution/execute_test.go +++ b/internal/execution/execute_test.go @@ -121,36 +121,141 @@ def main(): // TestExecuteBindsThenDispatches proves canonical validation precedes the native port. func TestExecuteBindsThenDispatches(t *testing.T) { - var gotID string - var gotArguments map[string]any - nativeCall := func(id string, arguments map[string]any) (any, error) { - gotID = id - gotArguments = arguments - return map[string]any{"value": "alpha"}, nil + tests := []struct { + // name identifies the bound call. + name string + + // engine constructs the execution engine under test. + engine func(*testing.T) *execution.Engine + + // source is the Starlark program that performs one native call. + source string + + // wantArguments is the canonical map forwarded to the native port. + wantArguments map[string]any + }{ + { + name: "required string", + engine: buildEngine, + source: `def main(): return records.lookup(value="alpha")`, + wantArguments: map[string]any{ + "value": "alpha", + }, + }, + { + name: "all eight scalar forms", + engine: buildWidenedEngine, + source: ` +def main(): + return records.lookup(org="meigma", count=3, active=True, score=1.5, label="beta", limit=25, enabled=True, weight=2.5) +`, + wantArguments: map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": 1.5, + "label": "beta", + "limit": int64(25), + "enabled": true, + "weight": 2.5, + }, + }, + { + name: "omitted optional scalars", + engine: buildWidenedEngine, + source: `def main(): return records.lookup(org="meigma", count=3, active=False, score=0.0)`, + wantArguments: map[string]any{ + "org": "meigma", + "count": int64(3), + "active": false, + "score": 0.0, + }, + }, + { + name: "explicit None optional scalars", + engine: buildWidenedEngine, + source: `def main(): return records.lookup(org="meigma", count=3, active=True, score=1.5, label=None, limit=None, enabled=None, weight=None)`, + wantArguments: map[string]any{ + "org": "meigma", + "count": int64(3), + "active": true, + "score": 1.5, + }, + }, } - result, err := buildEngine(t).Execute( - `def main(): return records.lookup(value="alpha")`, - nativeCall, - defaultExecutionLimits(), - ) - require.NoError(t, err) - assert.Equal(t, "cap.lookup", gotID) - assert.Equal(t, map[string]any{"value": "alpha"}, gotArguments) - assert.Equal(t, map[string]any{"value": "alpha"}, result) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotID string + var gotArguments map[string]any + nativeCall := func(id string, arguments map[string]any) (any, error) { + gotID = id + gotArguments = arguments + return map[string]any{"value": "alpha"}, nil + } + result, err := tt.engine(t).Execute(tt.source, nativeCall, defaultExecutionLimits()) + + require.NoError(t, err) + assert.Equal(t, "cap.lookup", gotID) + assert.Equal(t, tt.wantArguments, gotArguments) + assert.Equal(t, map[string]any{"value": "alpha"}, result) + }) + } } // TestExecuteRejectsMalformedArgumentsBeforeNativeCall proves invalid calls never reach the native port. func TestExecuteRejectsMalformedArgumentsBeforeNativeCall(t *testing.T) { - var nativeCalls atomic.Int64 - _, err := buildEngine(t).Execute( - `def main(): return records.lookup()`, - countingNativeCall(&nativeCalls), - defaultExecutionLimits(), - ) + tests := []struct { + // name identifies the malformed call. + name string - require.ErrorIs(t, err, execution.ErrInvalidArguments) - assert.Zero(t, nativeCalls.Load()) + // engine constructs the execution engine under test. + engine func(*testing.T) *execution.Engine + + // source is the Starlark program that performs one native call. + source string + }{ + { + name: "missing required string", + engine: buildEngine, + source: `def main(): return records.lookup()`, + }, + { + name: "missing required integer", + engine: buildWidenedEngine, + source: `def main(): return records.lookup(org="meigma", active=True, score=1.5)`, + }, + { + name: "integer supplied as float", + engine: buildWidenedEngine, + source: `def main(): return records.lookup(org="meigma", count=3.0, active=True, score=1.5)`, + }, + { + name: "overflowing integer", + engine: buildWidenedEngine, + source: `def main(): return records.lookup(org="meigma", count=9223372036854775808, active=True, score=1.5)`, + }, + { + name: "NaN float", + engine: buildWidenedEngine, + source: `def main(): return records.lookup(org="meigma", count=3, active=True, score=float("nan"))`, + }, + { + name: "infinity float", + engine: buildWidenedEngine, + source: `def main(): return records.lookup(org="meigma", count=3, active=True, score=float("inf"))`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var nativeCalls atomic.Int64 + _, err := tt.engine(t).Execute(tt.source, countingNativeCall(&nativeCalls), defaultExecutionLimits()) + + require.ErrorIs(t, err, execution.ErrInvalidArguments) + assert.Zero(t, nativeCalls.Load()) + }) + } } // TestExecuteRejectsDuplicateKeywordSyntaxAsInvalidProgram proves repeated keywords fail at parse time. @@ -346,6 +451,32 @@ func lookupBinding() execution.CapabilityBinding { } } +// buildWidenedEngine creates one engine exposing all eight scalar input forms. +func buildWidenedEngine(t *testing.T) *execution.Engine { + t.Helper() + engine, err := execution.New([]execution.CapabilityBinding{widenedLookupBinding()}) + require.NoError(t, err) + return engine +} + +// widenedLookupBinding returns records.lookup with every supported scalar input form. +func widenedLookupBinding() execution.CapabilityBinding { + return execution.CapabilityBinding{ + ID: "cap.lookup", + Name: "records.lookup", + Input: []binding.FieldShape{ + {Name: "org", Type: "str", Required: true}, + {Name: "count", Type: "int", Required: true}, + {Name: "active", Type: "bool", Required: true}, + {Name: "score", Type: "float", Required: true}, + {Name: "label", Type: "str | None", Required: false}, + {Name: "limit", Type: "int | None", Required: false}, + {Name: "enabled", Type: "bool | None", Required: false}, + {Name: "weight", Type: "float | None", Required: false}, + }, + } +} + // withID returns a copy of capability with a replacement ID. func withID(capability execution.CapabilityBinding, id string) execution.CapabilityBinding { capability.ID = id