From 46f9251bf44f08cfea487ea73ee1a4c8a9bb284a Mon Sep 17 00:00:00 2001 From: TastyHeadphones <76083688+TastyHeadphones@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:17:54 +0900 Subject: [PATCH] fix: type nilable builtins as any for WarnOnAny find/findLast/findIndex/findLastIndex/first/last/get can return nil. Typing them as the element (or int) let AsInt+WarnOnAny accept expressions that then fail at Run with int(). Type them as any so WarnOnAny catches the mismatch, matching find over []any. Fixes #989 --- builtin/builtin.go | 6 ++++-- builtin/builtin_test.go | 12 ++++++------ checker/checker.go | 15 ++++++++------- checker/checker_test.go | 19 +++++++++++++++++++ 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/builtin/builtin.go b/builtin/builtin.go index 87e73614a..bdc44053b 100644 --- a/builtin/builtin.go +++ b/builtin/builtin.go @@ -611,7 +611,8 @@ var Builtins = []*Function{ case reflect.Interface: return anyType, nil case reflect.Slice, reflect.Array: - return args[0].Elem(), nil + // Empty collection returns nil; type as any so WarnOnAny catches it. + return anyType, nil } return anyType, fmt.Errorf("cannot get first element from %s", args[0]) }, @@ -634,7 +635,8 @@ var Builtins = []*Function{ case reflect.Interface: return anyType, nil case reflect.Slice, reflect.Array: - return args[0].Elem(), nil + // Empty collection returns nil; type as any so WarnOnAny catches it. + return anyType, nil } return anyType, fmt.Errorf("cannot get last element from %s", args[0]) }, diff --git a/builtin/builtin_test.go b/builtin/builtin_test.go index 0d0dec357..ac3cc0c78 100644 --- a/builtin/builtin_test.go +++ b/builtin/builtin_test.go @@ -321,12 +321,12 @@ func TestBuiltin_types(t *testing.T) { input string want reflect.Kind }{ - {`get(ArrayOfString, 0)`, reflect.String}, - {`get(ArrayOfInt, 0)`, reflect.Int}, - {`first(ArrayOfString)`, reflect.String}, - {`first(ArrayOfInt)`, reflect.Int}, - {`last(ArrayOfString)`, reflect.String}, - {`last(ArrayOfInt)`, reflect.Int}, + {`get(ArrayOfString, 0)`, reflect.Interface}, // nilable + {`get(ArrayOfInt, 0)`, reflect.Interface}, + {`first(ArrayOfString)`, reflect.Interface}, + {`first(ArrayOfInt)`, reflect.Interface}, + {`last(ArrayOfString)`, reflect.Interface}, + {`last(ArrayOfInt)`, reflect.Interface}, {`get($env, 'str')`, reflect.String}, {`get($env, 'num')`, reflect.Int}, {`get($env, 'ArrayOfString')`, reflect.Slice}, diff --git a/checker/checker.go b/checker/checker.go index 63425af1f..2f773d3d7 100644 --- a/checker/checker.go +++ b/checker/checker.go @@ -842,10 +842,8 @@ func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { if !predicateOut.IsBool() && !predicateOut.IsUnknown(&v.config.NtCache) { return v.error(node.Arguments[1], "predicate should return boolean (got %s)", predicateOut.String()) } - if collection.IsUnknown(&v.config.NtCache) { - return Nature{} - } - return collection.Elem(&v.config.NtCache) + // May return nil when nothing matches; type as any so WarnOnAny catches it. + return v.config.NtCache.FromType(anyType) } return v.error(node.Arguments[1], "predicate should has one input and one output param") @@ -868,7 +866,8 @@ func (v *Checker) builtinNode(node *ast.BuiltinNode) Nature { if !predicateOut.IsBool() && !predicateOut.IsUnknown(&v.config.NtCache) { return v.error(node.Arguments[1], "predicate should return boolean (got %s)", predicateOut.String()) } - return v.config.NtCache.FromType(intType) + // Returns nil (not -1) when nothing matches; type as any so WarnOnAny catches it. + return v.config.NtCache.FromType(anyType) } return v.error(node.Arguments[1], "predicate should has one input and one output param") @@ -992,12 +991,14 @@ func (v *Checker) checkBuiltinGet(node *ast.BuiltinNode) Nature { if !prop.IsInteger && !prop.IsUnknown(&v.config.NtCache) { return v.error(node.Arguments[1], "non-integer slice index %s", prop.String()) } - return base.Elem(&v.config.NtCache) + // Out-of-range get returns nil; type as any so WarnOnAny catches it. + return v.config.NtCache.FromType(anyType) case reflect.Map: if !prop.AssignableTo(base.Key(&v.config.NtCache)) && !prop.IsUnknown(&v.config.NtCache) { return v.error(node.Arguments[1], "cannot use %s to get an element from %s", prop.String(), base.String()) } - return base.Elem(&v.config.NtCache) + // Missing key returns nil; type as any so WarnOnAny catches it. + return v.config.NtCache.FromType(anyType) } return v.error(node.Arguments[0], "type %v does not support indexing", base.String()) } diff --git a/checker/checker_test.go b/checker/checker_test.go index 7a581612a..ebb032855 100644 --- a/checker/checker_test.go +++ b/checker/checker_test.go @@ -1183,3 +1183,22 @@ func TestCheck_types(t *testing.T) { }) } } + +func TestCheck_NilableBuiltinsWarnOnAny(t *testing.T) { + env := map[string]any{ + "ints": []int{1, 2, 3}, + "empty": []int{}, + } + for _, src := range []string{ + "find(ints, # > 5)", + "findIndex(ints, # > 5)", + "first(empty)", + "get(ints, 9)", + } { + t.Run(src, func(t *testing.T) { + _, err := expr.Compile(src, expr.Env(env), expr.AsInt(), expr.WarnOnAny()) + require.Error(t, err) + require.Contains(t, err.Error(), "expected int") + }) + } +}