Error Analysis
Julia's type system is quite expressive and its type inference is strong enough to generate fairly optimized code from a highly generic program written in a concise syntax. But as opposed to other statically-compiled languages, Julia by design does not error nor warn anything even if it detects possible errors during its compilation process no matter how serious they are. In essence, Julia achieves highly generic and composable programming by delaying all the errors and warnings to the runtime.
This is a core design choice of the language. On the one hand, Julia's dynamism allows it to work in places where data types can not be fully decided ahead of runtime (e.g. when the program is duck-typed with generic pieces of code, or when the program consumes some data that is only known at runtime). On the other hand, with Julia, it's not straightforward to have such modern development experiences that a static language can typically offer, as like static type checking and rich IDE features.
JET is a trial to get the best of both worlds: can we have a sufficiently useful static checking without losing all the beauty of Julia's dynamism and composability? JET's approach is very different from "gradual typing", that is a common technique to bring static analysis into a dynamic language, as used for e.g. mypy for Python and TypeScript for JavaScript. Rather, JET's static analysis is powered by Julia's builtin type inference system, that is based on a technique called "abstract interpretation". This way JET can analyze just a normal Julia program and smartly detect possible errors statically, without requiring any additional setups like scattering type annotations just for the sake of analysis but preserving original polymorphism and composability of the program, as effectively as the Julia compiler can optimize your Julia program.
Quick Start
julia> using JET
Let's start with the simplest example: how JET can find anything wrong with sum("julia")
? @report_call
and report_call
analyzes a given function call and report back possible problems. They can be used in a similar way as @code_typed
and code_typed
. Those interactive entry points are the easiest way to use JET:
julia> @report_call sum("julia")
═════ 2 possible errors found ═════ ┌ sum(a::String) @ Base ./reduce.jl:561 │┌ sum(a::String; kw::@Kwargs{}) @ Base ./reduce.jl:561 ││┌ sum(f::typeof(identity), a::String) @ Base ./reduce.jl:532 │││┌ sum(f::typeof(identity), a::String; kw::@Kwargs{}) @ Base ./reduce.jl:532 ││││┌ mapreduce(f::typeof(identity), op::typeof(Base.add_sum), itr::String) @ Base ./reduce.jl:307 │││││┌ mapreduce(f::typeof(identity), op::typeof(Base.add_sum), itr::String; kw::@Kwargs{}) @ Base ./reduce.jl:307 ││││││┌ mapfoldl(f::typeof(identity), op::typeof(Base.add_sum), itr::String) @ Base ./reduce.jl:175 │││││││┌ mapfoldl(f::typeof(identity), op::typeof(Base.add_sum), itr::String; init::Base._InitialValue) @ Base ./reduce.jl:175 ││││││││┌ mapfoldl_impl(f::typeof(identity), op::typeof(Base.add_sum), nt::Base._InitialValue, itr::String) @ Base ./reduce.jl:44 │││││││││┌ foldl_impl(op::Base.BottomRF{typeof(Base.add_sum)}, nt::Base._InitialValue, itr::String) @ Base ./reduce.jl:48 ││││││││││┌ _foldl_impl(op::Base.BottomRF{typeof(Base.add_sum)}, init::Base._InitialValue, itr::String) @ Base ./reduce.jl:62 │││││││││││┌ (::Base.BottomRF{typeof(Base.add_sum)})(acc::Char, x::Char) @ Base ./reduce.jl:86 ││││││││││││┌ add_sum(x::Char, y::Char) @ Base ./reduce.jl:24 │││││││││││││ no matching method found `+(::Char, ::Char)`: (x::Char + y::Char) ││││││││││││└──────────────────── │││││││││┌ foldl_impl(op::Base.BottomRF{typeof(Base.add_sum)}, nt::Base._InitialValue, itr::String) @ Base ./reduce.jl:49 ││││││││││┌ reduce_empty_iter(op::Base.BottomRF{typeof(Base.add_sum)}, itr::String) @ Base ./reduce.jl:380 │││││││││││┌ reduce_empty_iter(op::Base.BottomRF{typeof(Base.add_sum)}, itr::String, ::Base.HasEltype) @ Base ./reduce.jl:381 ││││││││││││┌ reduce_empty(op::Base.BottomRF{typeof(Base.add_sum)}, ::Type{Char}) @ Base ./reduce.jl:357 │││││││││││││┌ reduce_empty(::typeof(Base.add_sum), ::Type{Char}) @ Base ./reduce.jl:350 ││││││││││││││┌ reduce_empty(::typeof(+), ::Type{Char}) @ Base ./reduce.jl:343 │││││││││││││││ no matching method found `zero(::Type{Char})`: zero(T::Type{Char}) ││││││││││││││└────────────────────
So JET found two possible problems. Now let's see how they can occur in actual execution:
julia> sum("julia") # will lead to `MethodError: +(::Char, ::Char)`
ERROR: MethodError: no method matching +(::Char, ::Char) The function `+` exists, but no method is defined for this combination of argument types. Closest candidates are: +(::Any, ::Any, ::Any, ::Any...) @ Base operators.jl:596 +(::T, ::Integer) where T<:AbstractChar @ Base char.jl:237 +(::Integer, ::AbstractChar) @ Base char.jl:247 ...
julia> sum("") # will lead to `MethodError: zero(Type{Char})`
ERROR: MethodError: no method matching zero(::Type{Char}) The function `zero` exists, but no method is defined for this combination of argument types. Closest candidates are: zero(::Type{Union{}}, Any...) @ Base number.jl:310 zero(::Type{Dates.DateTime}) @ Dates /opt/hostedtoolcache/julia/1.11.1/x64/share/julia/stdlib/v1.11/Dates/src/types.jl:458 zero(::Type{Dates.Date}) @ Dates /opt/hostedtoolcache/julia/1.11.1/x64/share/julia/stdlib/v1.11/Dates/src/types.jl:459 ...
We should note that @report_call sum("julia")
could detect both of those two different errors that can happen at runtime. This is because @report_call
does a static analysis — it analyzes the function call in a way that does not rely on one instance of runtime execution, but rather it reasons about all the possible executions! This is one of the biggest advantages of static analysis because other alternatives to check software qualities like "testing" usually rely on some runtime execution and they can only cover a subset of all the possible executions.
As mentioned above, JET is designed to work with just a normal Julia program. Let's define new arbitrary functions and run JET on it:
julia> function foo(s0) a = [] for s in split(s0) push!(a, bar(s)) end return sum(a) end
foo (generic function with 1 method)
julia> bar(s::String) = parse(Int, s)
bar (generic function with 1 method)
julia> @report_call foo("1 2 3")
═════ 2 possible errors found ═════ ┌ foo(s0::String) @ Main ./REPL[1]:4 │ no matching method found `bar(::SubString{String})`: Main.bar(s) └──────────────────── ┌ foo(s0::String) @ Main ./REPL[1]:6 │┌ sum(a::Vector{Any}) @ Base ./reducedim.jl:982 ││┌ sum(a::Vector{Any}; dims::Colon, kw::@Kwargs{}) @ Base ./reducedim.jl:982 │││┌ _sum(a::Vector{Any}, ::Colon) @ Base ./reducedim.jl:986 ││││┌ _sum(a::Vector{Any}, ::Colon; kw::@Kwargs{}) @ Base ./reducedim.jl:986 │││││┌ _sum(f::typeof(identity), a::Vector{Any}, ::Colon) @ Base ./reducedim.jl:987 ││││││┌ _sum(f::typeof(identity), a::Vector{Any}, ::Colon; kw::@Kwargs{}) @ Base ./reducedim.jl:987 │││││││┌ mapreduce(f::typeof(identity), op::typeof(Base.add_sum), A::Vector{Any}) @ Base ./reducedim.jl:329 ││││││││┌ mapreduce(f::typeof(identity), op::typeof(Base.add_sum), A::Vector{Any}; dims::Colon, init::Base._InitialValue) @ Base ./reducedim.jl:329 │││││││││┌ _mapreduce_dim(f::typeof(identity), op::typeof(Base.add_sum), ::Base._InitialValue, A::Vector{Any}, ::Colon) @ Base ./reducedim.jl:337 ││││││││││┌ _mapreduce(f::typeof(identity), op::typeof(Base.add_sum), ::IndexLinear, A::Vector{Any}) @ Base ./reduce.jl:429 │││││││││││┌ mapreduce_empty_iter(f::typeof(identity), op::typeof(Base.add_sum), itr::Vector{Any}, ItrEltype::Base.HasEltype) @ Base ./reduce.jl:377 ││││││││││││┌ reduce_empty_iter(op::Base.MappingRF{typeof(identity), typeof(Base.add_sum)}, itr::Vector{Any}, ::Base.HasEltype) @ Base ./reduce.jl:381 │││││││││││││┌ reduce_empty(op::Base.MappingRF{typeof(identity), typeof(Base.add_sum)}, ::Type{Any}) @ Base ./reduce.jl:358 ││││││││││││││┌ mapreduce_empty(::typeof(identity), op::typeof(Base.add_sum), T::Type{Any}) @ Base ./reduce.jl:369 │││││││││││││││┌ reduce_empty(::typeof(Base.add_sum), ::Type{Any}) @ Base ./reduce.jl:350 ││││││││││││││││┌ reduce_empty(::typeof(+), ::Type{Any}) @ Base ./reduce.jl:343 │││││││││││││││││┌ zero(::Type{Any}) @ Base ./missing.jl:106 ││││││││││││││││││ MethodError: no method matching zero(::Type{Any}): Base.throw(Base.MethodError(zero, tuple(Base.Any)::Tuple{DataType})::MethodError) │││││││││││││││││└────────────────────
Now let's fix this problematic code. First, we can fix the definition of bar
so that it accepts generic AbstractString
input. JET's analysis result can be dynamically updated when we refine a function definition, and so we just need to add a new bar(::AbstractString)
definition. As for the second error, let's assume, for some reason, we're not interested in fixing it and we want to ignore errors that may happen within Base
. Then we can use the target_modules
configuration to limit the analysis scope to the current module context to ignore the possible error that may happen within sum(a)
[1].
julia> # hot fix the definition of `bar` bar(s::AbstractString) = parse(Int, s) # now no errors should be reported !
bar (generic function with 2 methods)
julia> @report_call target_modules=(@__MODULE__,) foo("1 2 3")
No errors detected
So far, we have used the default error analysis pass, which collects problems according to one specific (somewhat opinionated) definition of "errors" (see the JET.BasicPass
for more details). JET offers other error reporting passes, including the "sound" error detection (JET.SoundPass
) as well as the simpler "typo" detection pass (JET.TypoPass
)[2]. They can be switched using the mode
configuration:
julia> function myifelse(cond, a, b) if cond return a else return b end end # the default analysis pass doesn't report "non-boolean (T) used in boolean context" error # as far as there is a possibility when the condition "can" be bool (NOTE: Bool <: Integer)
myifelse (generic function with 1 method)
julia> report_call(myifelse, (Integer, Int, Int)) # the sound analyzer doesn't permit such a case: it requires the type of a conditional value to be `Bool` strictly
No errors detected
julia> report_call(myifelse, (Integer, Int, Int); mode=:sound)
═════ 1 possible error found ═════ ┌ myifelse(cond::Integer, a::Int64, b::Int64) @ Main ./REPL[1]:2 │ non-boolean `Integer` may be used in boolean context: goto %4 if not cond::Integer └────────────────────
julia> function strange_sum(a) if rand(Bool) undefsum(a) else sum(a) end end # the default analysis pass will report both problems: # - `undefsum` is not defined # - `sum(a::Vector{Any})` can throw when `a` is empty
strange_sum (generic function with 1 method)
julia> @report_call strange_sum([]) # the typo detection pass will only report the "typo"
═════ 2 possible errors found ═════ ┌ strange_sum(a::Vector{Any}) @ Main ./REPL[4]:3 │ `Main.undefsum` is not defined: Main.undefsum └──────────────────── ┌ strange_sum(a::Vector{Any}) @ Main ./REPL[4]:5 │┌ sum(a::Vector{Any}) @ Base ./reducedim.jl:982 ││┌ sum(a::Vector{Any}; dims::Colon, kw::@Kwargs{}) @ Base ./reducedim.jl:982 │││┌ _sum(a::Vector{Any}, ::Colon) @ Base ./reducedim.jl:986 ││││┌ _sum(a::Vector{Any}, ::Colon; kw::@Kwargs{}) @ Base ./reducedim.jl:986 │││││┌ _sum(f::typeof(identity), a::Vector{Any}, ::Colon) @ Base ./reducedim.jl:987 ││││││┌ _sum(f::typeof(identity), a::Vector{Any}, ::Colon; kw::@Kwargs{}) @ Base ./reducedim.jl:987 │││││││┌ mapreduce(f::typeof(identity), op::typeof(Base.add_sum), A::Vector{Any}) @ Base ./reducedim.jl:329 ││││││││┌ mapreduce(f::typeof(identity), op::typeof(Base.add_sum), A::Vector{Any}; dims::Colon, init::Base._InitialValue) @ Base ./reducedim.jl:329 │││││││││┌ _mapreduce_dim(f::typeof(identity), op::typeof(Base.add_sum), ::Base._InitialValue, A::Vector{Any}, ::Colon) @ Base ./reducedim.jl:337 ││││││││││┌ _mapreduce(f::typeof(identity), op::typeof(Base.add_sum), ::IndexLinear, A::Vector{Any}) @ Base ./reduce.jl:429 │││││││││││┌ mapreduce_empty_iter(f::typeof(identity), op::typeof(Base.add_sum), itr::Vector{Any}, ItrEltype::Base.HasEltype) @ Base ./reduce.jl:377 ││││││││││││┌ reduce_empty_iter(op::Base.MappingRF{typeof(identity), typeof(Base.add_sum)}, itr::Vector{Any}, ::Base.HasEltype) @ Base ./reduce.jl:381 │││││││││││││┌ reduce_empty(op::Base.MappingRF{typeof(identity), typeof(Base.add_sum)}, ::Type{Any}) @ Base ./reduce.jl:358 ││││││││││││││┌ mapreduce_empty(::typeof(identity), op::typeof(Base.add_sum), T::Type{Any}) @ Base ./reduce.jl:369 │││││││││││││││┌ reduce_empty(::typeof(Base.add_sum), ::Type{Any}) @ Base ./reduce.jl:350 ││││││││││││││││┌ reduce_empty(::typeof(+), ::Type{Any}) @ Base ./reduce.jl:343 │││││││││││││││││┌ zero(::Type{Any}) @ Base ./missing.jl:106 ││││││││││││││││││ MethodError: no method matching zero(::Type{Any}): Base.throw(Base.MethodError(zero, tuple(Base.Any)::Tuple{DataType})::MethodError) │││││││││││││││││└────────────────────
julia> @report_call mode=:typo strange_sum([])
═════ 1 possible error found ═════ ┌ strange_sum(a::Vector{Any}) @ Main ./REPL[4]:3 │ `Main.undefsum` is not defined: Main.undefsum └────────────────────
We can use @test_call
and test_call
to assert that your program is free from problems that @report_call
can detect. They work nicely with Test
standard library's unit-testing infrastructure:
julia> @test_call target_modules=(@__MODULE__,) foo("1 2 3")
Test Passed
julia> using Test # we can get the nice summery using `@testset` !
julia> @testset "JET testset" begin @test_call target_modules=(@__MODULE__,) foo("1 2 3") # should pass test_call(myifelse, (Integer, Int, Int); mode=:sound) @test_call broken=true foo("1 2 3") # `broken` and `skip` options are supported @test foo("1 2 3") == 6 # of course other `Test` macros can be used in the same place end
JET testset: JET-test failed at /home/runner/work/JET.jl/JET.jl/src/JET.jl:1078 Expression: (JET.report_call)(Main.myifelse, (Integer, Int64, Int64); mode = sound) ═════ 1 possible error found ═════ ┌ myifelse(cond::Integer, a::Int64, b::Int64) @ Main ./REPL[1]:2 │ non-boolean `Integer` may be used in boolean context: goto %4 if not cond::Integer └──────────────────── Test Summary: | Pass Fail Broken Total Time JET testset | 2 1 1 4 2.8s ERROR: Some tests did not pass: 2 passed, 1 failed, 0 errored, 1 broken.
JET uses JET itself in its test pipeline: JET's static analysis has been proven to be very useful and helped its development a lot. If interested, take a peek at JET's "self check"
testset.
Lastly, let's see the example that demonstrates JET can analyze a "top-level" program. The top-level analysis should be considered as a somewhat experimental feature, and at this moment you may need additional configurations to run it correctly. Please read the descriptions of top-level entry points and choose an appropriate entry point for your use case. Here we run report_file
on demo.jl. It automatically extracts and loads "definitions" of functions, structs and such, and then analyzes their "usages" statically:
julia> report_file(normpath(Base.pkgdir(JET), "demo.jl"))
[toplevel-info] virtualized the context of Main (took 0.015 sec) [toplevel-info] entered into /home/runner/work/JET.jl/JET.jl/demo.jl [toplevel-info] exited from /home/runner/work/JET.jl/JET.jl/demo.jl (took 0.135 sec) ═════ 5 possible errors found ═════ ┌ Toplevel MethodInstance thunk @ Main /home/runner/work/JET.jl/JET.jl/demo.jl:9 │ `m` is not defined: m └──────────────────── ┌ Toplevel MethodInstance thunk @ Main /home/runner/work/JET.jl/JET.jl/demo.jl:10 │┌ fib(n::String) @ Main /home/runner/work/JET.jl/JET.jl/demo.jl:6 ││┌ <=(x::String, y::Int64) @ Base ./operators.jl:402 │││┌ <(x::String, y::Int64) @ Base ./operators.jl:353 ││││ no matching method found `isless(::String, ::Int64)`: isless(x::String, y::Int64) │││└──────────────────── ┌ Toplevel MethodInstance thunk @ Main /home/runner/work/JET.jl/JET.jl/demo.jl:28 │┌ foo(a::Float64) @ Main /home/runner/work/JET.jl/JET.jl/demo.jl:20 ││┌ bar(v::Ty{Float64}) @ Main /home/runner/work/JET.jl/JET.jl/demo.jl:25 │││┌ getproperty(x::Ty{Float64}, f::Symbol) @ Base ./Base.jl:49 ││││ type Ty has no field fdl: Base.getfield(x::Ty{Float64}, f::Symbol) │││└──────────────────── ┌ Toplevel MethodInstance thunk @ Main /home/runner/work/JET.jl/JET.jl/demo.jl:29 │┌ foo(a::String) @ Main /home/runner/work/JET.jl/JET.jl/demo.jl:20 ││┌ bar(v::Ty{String}) @ Main /home/runner/work/JET.jl/JET.jl/demo.jl:26 │││ no matching method found `convert(::Type{Number}, ::String)`: convert(Number, (v::Ty{String}).fld::String) ││└──────────────────── ┌ Toplevel MethodInstance thunk @ Main /home/runner/work/JET.jl/JET.jl/demo.jl:40 │┌ badmerge(a::@NamedTuple{x::Int64, y::Int64}, b::@NamedTuple{y::Int64, z::Int64}) @ Main /home/runner/work/JET.jl/JET.jl/demo.jl:33 ││ `x` is not defined: x │└────────────────────
Errors kinds and how to fix them
no matching method found
Description
This error occurs when running the code might throw a MethodError
at runtime. Similar to normal MethodErrors
, this happens if a function is being called without a method matching the given argument types.
This is the most common error detected in most Julia code.
Example
julia> f(x::Integer) = x + one(x);
julia> g(x) = f(x);
julia> @report_call g(1.0)
═════ 1 possible error found ═════ ┌ g(x::Float64) @ Main ./REPL[2]:2 │ no matching method found `f(::Float64)`: Main.f(x::Float64) └────────────────────
How to fix
This error indicates some kind of type error in your code. You fix it like you would fix a regular MethodError
thrown at runtime.
no matching method found (x/y union split)
Description
This error occurs when a variable x
is inferred to be a union type, and x
being one or more of the union's members would lead to a MethodError
. For example, if the compiler infers x
to be of type Union{A, B}
, and then a function f(x)
is called which would lead to a MethodError
if x
is a A
, this error would occur.
More technically, this happens when one or more branches created by the compiler through union splitting contains a no matching method found
error.
Example
Minimal example:
julia> struct Foo x::Union{Int, String} end # Errors if x.x isa String. # The compiler doesn't know if it's a String or Int
julia> f(x) = x.x + 1;
julia> @report_call f(Foo(1))
═════ 1 possible error found ═════ ┌ f(x::Main.Foo) @ Main ./REPL[2]:5 │ no matching method found `+(::String, ::Int64)` (1/2 union split): Main.:+((x::Main.Foo).x::Union{Int64, String}, 1) └────────────────────
More common example:
julia> function pos_after_tab(v::AbstractArray{UInt8}) # findfirst can return `nothing` on no match p = findfirst(isequal(UInt8('\t')), v) p + 1 end
pos_after_tab (generic function with 1 method)
julia> @report_call pos_after_tab(codeunits("a\tb"))
═════ 1 possible error found ═════ ┌ pos_after_tab(v::Base.CodeUnits{UInt8, String}) @ Main ./REPL[1]:4 │ no matching method found `+(::Nothing, ::Int64)` (1/2 union split): (p Main.:+ 1) └────────────────────
How to fix
This error is unique in that idiomatic Julia code may still lead to this error. For example, in the pos_after_tab
function above, if the input vector does not have a '\t'
byte, p
will be nothing
, and a MethodError
will be thrown when nothing + 1
is attempted. However, in many situations, the possibility of such a MethodError
is not a mistake, but rather an idiomatic way of erroring.
There are different possibilities to address this kind of error. Let's take the pos_after_tab
example:
If you actually could expect p
to legitimately be nothing
for valid input (i.e. the input could lack a '\t'
byte), then your function should be written to take this edge case into account:
julia> function pos_after_tab(v::AbstractArray{UInt8}) p = findfirst(isequal(UInt8('\t')), v) if p === nothing # handle the nothing case return nothing else return p + 1 end end;
julia> @report_call pos_after_tab(codeunits("a\tb"))
No errors detected
By adding the if p === nothing
check, the compiler will know that the type of p
must be Nothing
inside the if
block, and Int
in the else
block. This way, the compiler knows a MethodError
is not possible, and the error will disappear.
If you expect a '\t'
byte to always be present, such that findfirst
always should return an Int
for valid input, you can add a typeassert in the function to assert that the return value of findfirst
must be, say, an Integer
. Then, the compiler will know that if the typeassert passes, the value returned by findfirst
cannot be nothing
(and hence in this case must be Int
):
julia> function pos_after_tab(v::AbstractArray{UInt8}) p = findfirst(isequal(UInt8('\t')), v)::Integer p + 1 end;
julia> @report_call pos_after_tab(codeunits("a\tb"))
No errors detected
The code will still error at runtime due to the typeassert if findfirst
returns nothing
, but JET will no longer detect it as an error, because the programmer, by adding the typeassert, explicitly acknowledge that the compiler's inference may not be precise enough, and helps the compiler.
Note that adding a typeassert also improves code quality:
- The programmer's intent to never observe
nothing
is communicated clearly - After the typeassert passes,
p
is inferred to beInt
instead of a union, and this more precise type inference generates more efficient code. - More precise inference reduces the risk of invalidations from the code, improving latency.
A special case occurs when loading Union
-typed fields from structs. Julia does not realize that loading the same field multiple times from a mutable struct necessarily returns the same object. Hence, in the following example:
julia> mutable struct Foo x::Union{Int, Nothing} end
julia> function f(x) if x.x === nothing nothing else x.x + 1 end end;
julia> @report_call f(Foo(1))
═════ 1 possible error found ═════ ┌ f(x::Main.Foo) @ Main ./REPL[2]:7 │ no matching method found `+(::Nothing, ::Int64)` (1/2 union split): Main.:+((x::Main.Foo).x::Union{Nothing, Int64}, 1) └────────────────────
We might reasonably expect the compiler to know that in the else
branch, x.x
must be an Int
, since it just checked that it is not nothing
. However, the compiler does not know that the value obtained from loading the x
field in the expression x.x
on the like with the if statement in this case is the same value as the value obtained when loading the x
field in the x.x + 1
statement. You can solve this issue by assigning x.x
to a variable:
julia> function f(x) y = x.x if y === nothing nothing else y + 1 end end;
julia> @report_call f(Foo(1))
No errors detected
X is not defined
Description
This happens when a name X
is used in a function, but no object named X
can be found.
Example
julia> f(x) = foo(x) + 1;
julia> @report_call f(1)
═════ 1 possible error found ═════ ┌ f(x::Int64) @ Main ./REPL[1]:1 │ `Main.foo` is not defined: Main.foo └────────────────────
How to fix
This error can have a couple of causes:
X
is misspelled. If so, correct the typoX
exists, but cannot be reached from the scope of the function. If so, pass it in as an argument to the offending function.
type T has no field F
Description
This error occurs when Core.getfield
is (indirectly) called with a nonexisting and hardcoded field name. For example, if an object have a field called vec
and you type it vector
.
Example
julia> struct Foo my_field end
julia> f(x) = x.my_feild; # NB: Typo!
julia> @report_call f(Foo(1))
═════ 1 possible error found ═════ ┌ f(x::Main.Foo) @ Main ./REPL[2]:2 │┌ getproperty(x::Main.Foo, f::Symbol) @ Base ./Base.jl:49 ││ type Foo has no field my_feild: Base.getfield(x::Main.Foo, f::Symbol) │└────────────────────
How to fix
This error often occurs when the field name is mistyped. Correct the typo.
BoundsError: Attempt to access T at index [i]
Description
This error occurs when it is known at compile time that the call will throw a BoundsError
. Note that most BoundsErrors
cannot be predicted at compile time. For the compiler to know a function attempts to access a container out of bounds, both the container length and the index value must be known at compiletime. Hence, the error is detected for a Tuple
input in the example below, but not for a Vector
input.
Example
julia> get_fourth(x) = x[4]
get_fourth (generic function with 1 method)
julia> @report_call get_fourth((1,2,3))
═════ 1 possible error found ═════ ┌ get_fourth(x::Tuple{Int64, Int64, Int64}) @ Main ./REPL[1]:1 │┌ getindex(t::Tuple{Int64, Int64, Int64}, i::Int64) @ Base ./tuple.jl:31 ││ BoundsError: attempt to access Tuple{Int64, Int64, Int64} at index [4]: Base.getfield(t::Tuple{Int64, Int64, Int64}, i::Int64, $(Expr(:boundscheck))) │└────────────────────
julia> @report_call get_fourth([1,2,3]) # NB: False negative!
No errors detected
How to fix
If this error appears, the offending code uses a bad index. Since the error most often occurs when the index is hardcoded, simply fix the index value.
may throw [...]
Description
This error indicates that JET detected the possibility of an exception. By default, JET will not report this error, unless a function is inferred to always throw, AND the exception is not caught in a try statement. In "sound" mode, this error is reported if the function may throw.
Example
In this example, the function is known at compile time to throw an uncaught exception, and so is reported by default:
julia> f(x) = x isa Integer ? throw("Integer") : nothing;
julia> @report_call f(1)
═════ 1 possible error found ═════ ┌ f(x::Int64) @ Main ./REPL[1]:1 │ may throw: Main.throw("Integer") └────────────────────
In this example, it's not known at compile time whether it throws, and therefore, JET reports no errors by default. In sound mode, the error is reported.
julia> f(x) = x == 9873984732 ? nothing : throw("Bad value")
f (generic function with 1 method)
julia> @report_call f(1)
No errors detected
julia> @report_call mode=:sound f(1)
═════ 1 possible error found ═════ ┌ f(x::Int64) @ Main ./REPL[1]:1 │ may throw: Main.throw("Bad value") └────────────────────
In this example, the exception is handled, so JET reports no errors by default. In sound mode, the error is reported:
julia> g() = throw();
julia> f() = try g() catch nothing end;
julia> f()
julia> @report_call f()
No errors detected
julia> @report_call mode=:sound f()
═════ 1 possible error found ═════ ┌ f() @ Main ./REPL[2]:3 │┌ g() @ Main ./REPL[1]:1 ││ may throw: Main.throw() │└────────────────────
Entry Points
Interactive Entry Points
JET offers interactive analysis entry points that can be used similarly to code_typed
and its family:
JET.@report_call
— Macro@report_call [jetconfigs...] f(args...)
Evaluates the arguments to a function call, determines their types, and then calls report_call
on the resulting expression. This macro works in a similar way as the @code_typed
macro.
The general configurations and the error analysis specific configurations can be specified as an optional argument.
JET.report_call
— Functionreport_call(f, [types]; jetconfigs...) -> JETCallResult
report_call(tt::Type{<:Tuple}; jetconfigs...) -> JETCallResult
report_call(mi::Core.MethodInstance; jetconfigs...) -> JETCallResult
Analyzes a function call with the given type signature to find type-level errors and returns back detected problems.
The general configurations and the error analysis specific configurations can be specified as a keyword argument.
See the documentation of the error analysis for more details.
Top-level Entry Points
JET can also analyze your "top-level" program: it can just take your Julia script or package and will report possible errors.
Note that JET will analyze your top-level program "half-statically": JET will selectively interpret and load "definitions" (like a function or struct definition) and try to simulate Julia's top-level code execution process. While it tries to avoid executing any other parts of code like function calls and analyzes them based on abstract interpretation instead (and this is a part where JET statically analyzes your code). If you're interested in how JET selects "top-level definitions", please see JET.virtual_process
.
Because JET will interpret "definitions" in your code, that part of top-level analysis certainly runs your code. So we should note that JET can cause some side effects from your code; for example, JET will try to expand all the macros used in your code, and so the side effects involved with macro expansions will also happen in JET's analysis process.
JET.report_file
— Functionreport_file(file::AbstractString; jetconfigs...) -> JETToplevelResult
Analyzes file
to find type-level errors and returns back detected problems.
This function looks for .JET.toml
configuration file in the directory of file
, and searches upward in the file tree until a .JET.toml
is (or isn't) found. When found, the configurations specified in the file are applied. See JET's configuration file specification for more details.
The general configurations and the error analysis specific configurations can be specified as a keyword argument, and if given, they are preferred over the configurations specified by a .JET.toml
configuration file.
When you want to analyze your package but no files that actually use its functions are available, the analyze_from_definitions
option may be useful since it allows JET to analyze methods based on their declared signatures. For example, JET can analyze JET itself in this way:
# from the root directory of JET.jl
julia> report_file("src/JET.jl";
analyze_from_definitions = true)
See also report_package
.
This function enables the toplevel_logger
configuration with the default logging level by default. You can still explicitly specify and configure it:
report_file(args...;
toplevel_logger = nothing, # suppress the toplevel logger
jetconfigs...) # other configurations
See JET's top-level analysis configurations for more details.
JET.watch_file
— Functionwatch_file(file::AbstractString; jetconfigs...)
Watches file
and keeps re-triggering analysis with report_file
on code update. JET will try to analyze all the include
d files reachable from file
, and it will re-trigger analysis if there is code update detected in any of the include
d files.
This entry point currently uses Revise.jl to monitor code updates, and can only be used after Revise has been loaded into the session. So note that you'll need to have run e.g., using Revise
at some earlier stage to use it. Revise offers possibilities to track changes in files that are not directly analyzed by JET, including changes made to Base
files using configurations like revise_modules = [Base]
. See watch configurations for more details.
This interface is very experimental and likely to subject to change or removal without notice.
See also report_file
.
JET.report_package
— Functionreport_package(package::Module; jetconfigs...) -> JETToplevelResult
report_package(package::AbstractString; jetconfigs...) -> JETToplevelResult
Analyzes package
in the same way as report_file
and returns back type-level errors with the special default configurations, which are especially tuned for analyzing a package (see below for details). The package
argument can be either a Module
or a AbstractString
. In the latter case it must be the name of a package in your current environment.
The error analysis performed by this function is configured as follows by default:
analyze_from_definitions = true
: This allows JET to start analysis without top-level call sites. This is useful for analyzing a package since a package itself usually only contains definitions of types and methods but not their usages (i.e. call sites).concretization_patterns = [:(x_)]
: Concretizes every top-level code in a givenpackage
. The concretizations are generally preferred for successful analysis as far as they can be performed cheaply. In most cases it is indeed cheap to interpret and concretize top-level code written in a package since it usually only defines types and methods.ignore_missing_comparison = true
: JET ignores the possibility of a poorly-inferred comparison operator call (e.g.==
) returningmissing
. This is useful becausereport_package
often relies on poor input argument type information at the beginning of analysis, leading to noisy error reports from branching on the potentialmissing
return value of such a comparison operator call. If a target package needs to handlemissing
, this configuration shuold be turned off since it hides the possibility of errors that may actually at runtime.
See ToplevelConfig
and JETAnalyzer
for more details.
Still the general configurations and the error analysis specific configurations can be specified as a keyword argument, and if given, they are preferred over the default configurations described above.
report_package(; jetconfigs...) -> JETToplevelResult
Like above but analyzes the package of the current project.
See also report_file
.
JET.report_text
— Functionreport_text(text::AbstractString; jetconfigs...) -> JETToplevelResult
report_text(text::AbstractString, filename::AbstractString; jetconfigs...) -> JETToplevelResult
Analyzes top-level text
and returns back type-level errors.
Test
Integration
JET also exports entries that are fully integrated with Test
standard library's unit-testing infrastructure. It can be used in your test suite to assert your program is free from errors that JET can detect:
JET.@test_call
— Macro@test_call [jetconfigs...] [broken=false] [skip=false] f(args...)
Runs @report_call jetconfigs... f(args...)
and tests that the function call f(args...)
is free from problems that @report_call
can detect. Returns a Pass
result if the test is successful, a Fail
result if any problems are detected, or an Error
result if the test encounters an unexpected error. When the test Fail
s, abstract call stack to each problem location will be printed to stdout
.
julia> @test_call sincos(10)
Test Passed
Expression: #= none:1 =# JET.@test_call sincos(10)
As with @report_call
, the general configurations and the error analysis specific configurations can be specified as an optional argument:
julia> cond = false
julia> function f(n)
# `cond` is untyped, and will be reported by the sound analysis pass,
# while JET's default analysis pass will ignore it
if cond
return sin(n)
else
return cos(n)
end
end;
julia> @test_call f(10)
Test Passed
Expression: #= none:1 =# JET.@test_call f(10)
julia> @test_call mode=:sound f(10)
JET-test failed at none:1
Expression: #= none:1 =# JET.@test_call mode = :sound f(10)
═════ 1 possible error found ═════
┌ @ none:2 goto %4 if not cond
│ non-boolean (Any) used in boolean context: goto %4 if not cond
└──────────
ERROR: There was an error during testing
@test_call
is fully integrated with Test
standard library's unit-testing infrastructure. This means that the result of @test_call
will be included in a final @testset
summary and it supports skip
and broken
annotations, just like the @test
macro:
julia> using JET, Test
# Julia can't propagate the type constraint `ref[]::Number` to `sin(ref[])`, JET will report `NoMethodError`
julia> f(ref) = isa(ref[], Number) ? sin(ref[]) : nothing;
# we can make it type-stable if we extract `ref[]` into a local variable `x`
julia> g(ref) = (x = ref[]; isa(x, Number) ? sin(x) : nothing);
julia> @testset "check errors" begin
ref = Ref{Union{Nothing,Int}}(0)
@test_call f(ref) # fail
@test_call g(ref) # fail
@test_call broken=true f(ref) # annotated as broken, thus still "pass"
end
check errors: JET-test failed at REPL[21]:3
Expression: #= REPL[21]:3 =# JET.@test_call f(ref)
═════ 1 possible error found ═════
┌ f(ref::Base.RefValue{Union{Nothing, Int64}}) @ Main ./REPL[19]:1
│ no matching method found `sin(::Nothing)` (1/2 union split): sin((ref::Base.RefValue{Union{Nothing, Int64}})[]::Union{Nothing, Int64})
└────────────────────
Test Summary: | Pass Fail Broken Total Time
check errors | 1 1 1 3 0.2s
ERROR: Some tests did not pass: 1 passed, 1 failed, 0 errored, 1 broken.
JET.test_call
— Functiontest_call(f, [types]; broken::Bool = false, skip::Bool = false, jetconfigs...)
test_call(tt::Type{<:Tuple}; broken::Bool = false, skip::Bool = false, jetconfigs...)
Runs report_call
on a function call with the given type signature and tests that it is free from problems that report_call
can detect. Except that it takes a type signature rather than a call expression, this function works in the same way as @test_call
.
JET.test_file
— Functiontest_file(file::AbstractString; jetconfigs...)
Runs report_file
and tests that there are no problems detected.
As with report_file
, the general configurations and the error analysis specific configurations can be specified as an optional argument.
Like @test_call
, test_file
is fully integrated with the Test
standard library. See @test_call
for the details.
JET.test_package
— Functiontest_package(package::Module; jetconfigs...)
test_package(package::AbstractString; jetconfigs...)
test_package(; jetconfigs...)
Runs report_package
and tests that there are no problems detected.
As with report_package
, the general configurations and the error analysis specific configurations can be specified as an optional argument.
Like @test_call
, test_package
is fully integrated with the Test
standard library. See @test_call
for the details.
julia> @testset "test_package" begin
test_package("Example"; toplevel_logger=nothing)
end;
Test Summary: | Pass Total Time
test_package | 1 1 0.0s
JET.test_text
— Functiontest_text(text::AbstractString; jetconfigs...)
test_text(text::AbstractString, filename::AbstractString; jetconfigs...)
Runs report_text
and tests that there are no problems detected.
As with report_text
, the general configurations and the error analysis specific configurations can be specified as an optional argument.
Like @test_call
, test_text
is fully integrated with the Test
standard library. See @test_call
for the details.
Configurations
In addition to the general configurations, the error analysis can take the following specific configurations:
JET.JETAnalyzer
— TypeEvery entry point of error analysis can accept any of the general configurations as well as the following additional configurations that are specific to the error analysis.
mode::Symbol = :basic
:
Switches the error analysis pass. Each analysis pass reports errors according to their own "error" definition. JET by default offers the following modes:mode = :basic
: the default error analysis pass. This analysis pass is tuned to be useful for general Julia development by reporting common problems, but also note that it is not enough strict to guarantee that your program never throws runtime errors.
SeeBasicPass
for more details.mode = :sound
: the sound error analysis pass. If this pass doesn't report any errors, then your program is assured to run without any runtime errors (unless JET's error definition is not accurate and/or there is an implementation flaw).
SeeSoundPass
for more details.mode = :typo
: a typo detection pass A simple analysis pass to detect "typo"s in your program. This analysis pass is essentially a subset of the default basic pass (BasicPass
), and it only reports undefined global reference and undefined field access. This might be useful especially for a very complex code base, because even the basic pass tends to be too noisy (spammed with too many errors) for such a case.
SeeTypoPass
for more details.
Note You can also set up your own analysis using JET's
AbstractAnalyzer
-Framework.
ignore_missing_comparison::Bool = false
:
Iftrue
, JET will ignores the possibility of a poorly-inferred comparison operator call (e.g.==
) returningmissing
in order to hide the error reports from branching on the potentialmissing
return value of such a comparison operator call. This is turned off by default, because a comparison call results in aUnion{Bool,Missing}
possibility, it likely signifies an inferrability issue or themissing
possibility should be handled someway. But this is useful to reduce the noisy error reports in the situations where specific input arguments type is not available at the beginning of the analysis likereport_package
.
JET.BasicPass
— TypeThe basic error analysis pass. This is used by default.
JET.SoundPass
— TypeThe sound error analysis pass.
JET.TypoPass
— TypeA typo detection pass.
- 1We used
target_modules
just for the sake of demonstration. To make it more idiomatic, we should initializea
as typed vectora = Int[]
, and then we won't get any problem fromsum(a)
even without thetarget_modules
configuration. - 2JET offers the framework to define your own abstract interpretation-based analysis. See
AbstractAnalyzer
-Framework if interested.