From e3c09ac68f6a1cfd68fb83b599ec1b13d0e73f0a Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Sun, 12 Jul 2026 23:20:24 +0200 Subject: [PATCH 1/2] fix(types): repeat struct bounds in where-clauses to stop silent widening to Any MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `where {X}` clause that names a type parameter without repeating the bound the struct already declares silently widens it to `<:Any`. This is invisible via `isa` on concrete instances but breaks Julia's method-specificity ranking the moment a competing method exists — either mis-dispatching silently or throwing `MethodError: ... is ambiguous`. Fixes the 6 sites found in the 2026-07-12 alias/where bound-dropping audit: - Models/model.jl: `time_dependence(::Model{TD})` now `where {TD<:TimeDependence}`. - Components/constraints_accessors.jl: the 5 ConstraintsModel accessors each restrict 4 params to `<:Tuple` in the type-parameter position but extracted the 5th via a bare `where`; now repeat `<:Tuple` on it. Also applies the audit's two optional hardening bounds, verified safe against every construction site (production and tests): - CoercedTrajectory: `coerce::C` is always `only`/`identity` → `C<:Union{typeof(only),typeof(identity)}`. - MergedTrajectory: `comps::C` is always `Dict{Int,Function}` → `C<:AbstractDict{Int,<:Function}` (struct + both hand-written constructors). Adds a regression guard (test/suite/meta/test_where_bounds.jl) asserting each fixed method's induced TypeVar upper bound is the intended bound, not `Any`. Full suite: 4014/4014 pass (incl. Aqua ambiguity checks). No behaviour change — tightening a where/field bound cannot invalidate a previously-valid call. Co-Authored-By: Claude Opus 4.8 --- src/Components/constraints_accessors.jl | 10 ++-- src/Components/functors.jl | 2 +- src/Init/init_functors.jl | 10 ++-- src/Models/model.jl | 2 +- test/suite/meta/test_where_bounds.jl | 67 +++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 test/suite/meta/test_where_bounds.jl diff --git a/src/Components/constraints_accessors.jl b/src/Components/constraints_accessors.jl index c85a6d17..31208070 100644 --- a/src/Components/constraints_accessors.jl +++ b/src/Components/constraints_accessors.jl @@ -32,7 +32,7 @@ See also: [`CTModels.Components.boundary_constraints_nl`](@ref), [`CTModels.Comp """ function path_constraints_nl( model::ConstraintsModel{TP,<:Tuple,<:Tuple,<:Tuple,<:Tuple} -) where {TP} +) where {TP<:Tuple} return model.path_nl end @@ -48,7 +48,7 @@ See also: [`CTModels.Components.path_constraints_nl`](@ref), [`CTModels.Componen """ function boundary_constraints_nl( model::ConstraintsModel{<:Tuple,TB,<:Tuple,<:Tuple,<:Tuple} -) where {TB} +) where {TB<:Tuple} return model.boundary_nl end @@ -64,7 +64,7 @@ See also: [`CTModels.Components.control_constraints_box`](@ref), [`CTModels.Comp """ function state_constraints_box( model::ConstraintsModel{<:Tuple,<:Tuple,TS,<:Tuple,<:Tuple} -) where {TS} +) where {TS<:Tuple} return model.state_box end @@ -80,7 +80,7 @@ See also: [`CTModels.Components.state_constraints_box`](@ref), [`CTModels.Compon """ function control_constraints_box( model::ConstraintsModel{<:Tuple,<:Tuple,<:Tuple,TC,<:Tuple} -) where {TC} +) where {TC<:Tuple} return model.control_box end @@ -96,7 +96,7 @@ See also: [`CTModels.Components.state_constraints_box`](@ref), [`CTModels.Compon """ function variable_constraints_box( model::ConstraintsModel{<:Tuple,<:Tuple,<:Tuple,<:Tuple,TV} -) where {TV} +) where {TV<:Tuple} return model.variable_box end diff --git a/src/Components/functors.jl b/src/Components/functors.jl index b85cfb81..6a472569 100644 --- a/src/Components/functors.jl +++ b/src/Components/functors.jl @@ -67,7 +67,7 @@ g = CoercedTrajectory(t -> [t, 2t], identity) g(0.5) # returns [0.5, 1.0] ``` """ -struct CoercedTrajectory{F,C} <: Function +struct CoercedTrajectory{F,C<:Union{typeof(only),typeof(identity)}} <: Function inner::F coerce::C end diff --git a/src/Init/init_functors.jl b/src/Init/init_functors.jl index eab6a111..66e493b7 100644 --- a/src/Init/init_functors.jl +++ b/src/Init/init_functors.jl @@ -109,13 +109,15 @@ f = MergedTrajectory(base, comps, 2, :state) f(0.5) # returns [0.0, sin(0.5)] ``` """ -struct MergedTrajectory{F,C} <: Function +struct MergedTrajectory{F,C<:AbstractDict{Int,<:Function}} <: Function base::F comps::C # Dict{Int,Function} dim::Int role::Symbol - function MergedTrajectory{F,C}(base::F, comps::C, dim::Int, role::Symbol) where {F,C} + function MergedTrajectory{F,C}( + base::F, comps::C, dim::Int, role::Symbol + ) where {F,C<:AbstractDict{Int,<:Function}} for i in keys(comps) if !(1 <= i <= dim) throw( @@ -133,7 +135,9 @@ struct MergedTrajectory{F,C} <: Function end end -function MergedTrajectory(base::F, comps::C, dim::Int, role::Symbol) where {F,C} +function MergedTrajectory( + base::F, comps::C, dim::Int, role::Symbol +) where {F,C<:AbstractDict{Int,<:Function}} return MergedTrajectory{F,C}(base, comps, dim, role) end diff --git a/src/Models/model.jl b/src/Models/model.jl index 531298ab..197e68da 100644 --- a/src/Models/model.jl +++ b/src/Models/model.jl @@ -114,7 +114,7 @@ end Traits.has_time_dependence_trait(::Model) = true -Traits.time_dependence(::Model{TD}) where {TD} = TD +Traits.time_dependence(::Model{TD}) where {TD<:TimeDependence} = TD Traits.has_variable_dependence_trait(::Model) = true diff --git a/test/suite/meta/test_where_bounds.jl b/test/suite/meta/test_where_bounds.jl new file mode 100644 index 00000000..655e28a1 --- /dev/null +++ b/test/suite/meta/test_where_bounds.jl @@ -0,0 +1,67 @@ +""" +Regression guard for the `where`-clause bound-dropping pitfall (see +`.reports/2026-07-12_alias-where-bounds-audit.md` and the Handbook rule in +`philosophy/types-traits-interfaces.md#aliases-and-where`). + +A `where {X}` clause that names a type parameter without repeating the bound the +struct already declares for it silently widens it to `<:Any`. This is invisible +via `isa` on concrete instances, but it breaks Julia's method-specificity ranking +the moment a competing method exists — either mis-dispatching silently or throwing +`MethodError: ... is ambiguous`. + +CTModels has no parametric aliases, so the CTFlows-style `Alias <: Parent` guard +does not apply. Instead this test asserts, for each method whose `where`-clause was +tightened, that the induced `TypeVar`'s upper bound is the intended bound and not +`Any`. A future edit that drops one of these bounds again fails loudly here. +""" + +module TestWhereBounds + +import Test: Test +import CTBase.Traits: Traits +import CTModels.Components: Components +import CTModels.Models: Models + +const VERBOSE = isdefined(Main, :TestData) ? Main.TestData.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestData) ? Main.TestData.SHOWTIMING : true + +# Upper bound of the outermost `where`-var of the method dispatched for `argtypes`. +# Returns `nothing` when the signature is not a `UnionAll` (i.e. no `where`-var at +# all), which fails the `=== expected` assertions below just as a widened bound does. +function _where_ub(f, argtypes::Type{<:Tuple}) + m = which(f, argtypes) + return m.sig isa UnionAll ? m.sig.var.ub : nothing +end + +function test_where_bounds() + Test.@testset "where-clause bound-dropping regression guard" verbose = VERBOSE showtiming = + SHOWTIMING begin + + # The 5 ConstraintsModel accessors: each restricts 4 params to <:Tuple in the + # type-parameter position and extracts the 5th via a `where`-clause that must + # repeat the same <:Tuple bound (constraints_accessors.jl). `which` on a bare + # ConstraintsModel argument selects the ConstraintsModel-specific method (the + # convenience Model-forwarding overload does not match a ConstraintsModel). + Test.@testset "ConstraintsModel accessors — where-var ub === Tuple" begin + CM = Tuple{Components.ConstraintsModel} + Test.@test _where_ub(Components.path_constraints_nl, CM) === Tuple + Test.@test _where_ub(Components.boundary_constraints_nl, CM) === Tuple + Test.@test _where_ub(Components.state_constraints_box, CM) === Tuple + Test.@test _where_ub(Components.control_constraints_box, CM) === Tuple + Test.@test _where_ub(Components.variable_constraints_box, CM) === Tuple + end + + # Model's time-dependence trait reads TD from the type parameter; the method's + # `where`-clause must repeat the `TD<:TimeDependence` bound the Model struct + # declares (model.jl). + Test.@testset "Model time_dependence — where-var ub === TimeDependence" begin + Test.@test _where_ub(Traits.time_dependence, Tuple{Models.Model}) === + Components.TimeDependence + end + end +end + +end # module + +# CRITICAL: Redefine in outer scope for TestRunner +test_where_bounds() = TestWhereBounds.test_where_bounds() From 58e253fa2e631154aed8b41c28114d357baa76be Mon Sep 17 00:00:00 2001 From: Olivier Cots Date: Sun, 12 Jul 2026 23:21:30 +0200 Subject: [PATCH 2/2] add mis- --- _typos.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/_typos.toml b/_typos.toml index 7453e551..049bf364 100644 --- a/_typos.toml +++ b/_typos.toml @@ -2,6 +2,7 @@ locale = "en" extend-ignore-re = [ "ser", + "mis-", ] [files]