1515# :equivalent same domain and return (up to the lattice) -> dropped
1616# :inference_wider spec <= inferred; expected conservatism -> dropped
1717# :spec_wider_return inferred return strictly inside the spec return with an
18- # EXACT translation -> spec-tightening candidate
19- # :contradiction spec and inferred returns are disjoint -> someone is
18+ # EXACT translation -> dropped: specs are public
19+ # contracts, deliberately wider than the implementation
20+ # (still reported in stats/JSON as tightening hints)
21+ # :contradiction spec and inferred returns are disjoint, or the checker
22+ # rejects every spec-conforming call -> someone is
2023# definitely wrong
2124# :mixed none of the above -> needs judgment
2225#
23- # Return comparison is SLICE-WISE: for each spec clause, the effective
24- # inferred return is the union of returns of inferred clauses whose domains
25- # intersect that spec clause's domain (inferred clauses are overlapping upper
26- # bounds, so this is the checker's actual prediction for calls in that slice).
26+ # Return comparison is SLICE-WISE and uses the checker's own application
27+ # rule (mirroring Module.Types.Apply.apply_infer/2): for each spec clause,
28+ # the inferred signature is applied at the spec's argument types -- clauses
29+ # with positionwise non-disjoint domains contribute their returns; no
30+ # matching clause means the checker would warn on every call in that slice.
31+ # Each function is additionally probed with all arguments set to
32+ # Descr.dynamic() (the fully-unknown gradual caller) and the resulting
33+ # return's relation to the spec return is reported. Inferred applications
34+ # always produce dynamic()-wrapped returns regardless of stack.mode; mode
35+ # only affects strong signatures, which do not occur in ExCk chunks.
2736#
2837# Only the residue (typically a few % of functions) is emitted for human
2938# review, with verdicts and precision flags attached -- the reviewer no
@@ -256,6 +265,13 @@ defmodule SpecToDescr do
256265 { tuple ( elems ) , exact }
257266 end
258267
268+ # Erlang record type #name{...}: a record value is a tuple whose first
269+ # element is the record name. Field types are not resolvable from the beam
270+ # (record definitions are compile-time), so over-approximate the remaining
271+ # elements (sound per the INVARIANT above).
272+ defp builtin ( :record , [ { :atom , _ , name } | _field_overrides ] , _e , _d ) ,
273+ do: { open_tuple ( [ atom ( [ name ] ) ] ) , false }
274+
259275 # {:type, _, :map, []} is the empty-map literal %{} (map() arrives as :any)
260276 defp builtin ( :map , [ ] , _e , _d ) , do: { empty_map ( ) , true }
261277 defp builtin ( :map , :any , _e , _d ) , do: { open_map ( ) , true }
@@ -402,7 +418,7 @@ defmodule SpecToDescr do
402418 with { :ok , types } <- Code.Typespec . fetch_types ( mod ) ,
403419 { _kind , { ^ name , body , params } } <-
404420 Enum . find ( types , fn { kind , { n , _b , p } } ->
405- kind in [ :type , :typep , :opaque ] and n == name and length ( p ) == arity
421+ kind in [ :type , :typep , :opaque , :nominal ] and n == name and length ( p ) == arity
406422 end ) do
407423 { :ok , { params , body } }
408424 else
@@ -426,6 +442,9 @@ defmodule Verdict do
426442 # Returns {verdict, details} where details carry the per-slice data used by
427443 # reporting.
428444
445+ # Mirrors Module.Types.Apply @max_clauses.
446+ @ max_clauses 16
447+
429448 def compare ( spec_clauses , iclauses , arity ) do
430449 idom =
431450 Enum . reduce ( iclauses , List . duplicate ( none ( ) , arity ) , fn { args , _ } , acc ->
@@ -434,15 +453,23 @@ defmodule Verdict do
434453
435454 slices =
436455 for { sargs , exact_args , sret , exact_ret } <- spec_clauses do
437- effective = effective_return ( sargs , iclauses )
456+ applied = checker_apply ( iclauses , sargs )
438457 sret_u = upper_bound ( sret )
439458
459+ effective =
460+ case applied do
461+ { :ok , ret } -> upper_bound ( ret )
462+ :badapply -> none ( )
463+ end
464+
440465 # Domains: inference legitimately narrows aspirational spec domains
441466 # (e.g. Enumerable.t() is term(); inference lists the shapes the body
442467 # handles) and the checker warning on out-of-inferred-domain calls is
443468 # usually a true positive. So domains never gate the return verdict;
444469 # they are only reported, and only a DISJOINT domain position (spec
445- # and inference cannot both be right about any call) escalates.
470+ # and inference cannot both be right about any call) escalates -- as
471+ # does :badapply on a non-empty spec domain, where the checker's own
472+ # application rule rejects EVERY spec-conforming call.
446473 dom_s_sub_i =
447474 Enum . zip ( sargs , idom )
448475 |> Enum . all? ( fn { s , i } -> subtype? ( upper_bound ( s ) , i ) end )
@@ -454,9 +481,12 @@ defmodule Verdict do
454481 not empty? ( su ) and not empty? ( i ) and disjoint? ( su , i )
455482 end )
456483
484+ badapply? =
485+ applied == :badapply and Enum . all? ( sargs , & ( not empty? ( upper_bound ( & 1 ) ) ) )
486+
457487 slice_verdict =
458488 cond do
459- dom_disjoint ->
489+ dom_disjoint or badapply? ->
460490 :contradiction
461491
462492 not empty? ( sret_u ) and not empty? ( effective ) and disjoint? ( sret_u , effective ) ->
@@ -486,20 +516,71 @@ defmodule Verdict do
486516 }
487517 end
488518
489- { combine ( Enum . map ( slices , & & 1 . verdict ) ) , slices }
519+ dynamic_probe = dynamic_args_probe ( iclauses , spec_clauses , arity )
520+ { combine ( Enum . map ( slices , & & 1 . verdict ) ) , slices , dynamic_probe }
490521 end
491522
492- # Effective inferred return for calls inside `sargs`: union of returns of
493- # inferred clauses whose domains intersect it positionwise. Inferred clauses
494- # are overlapping upper bounds, so this is the checker's actual prediction
495- # for that slice.
496- defp effective_return ( sargs , iclauses ) do
497- iclauses
498- |> Enum . filter ( fn { iargs , _ret } ->
499- Enum . zip ( sargs , iargs )
500- |> Enum . all? ( fn { s , i } -> not disjoint? ( upper_bound ( s ) , upper_bound ( i ) ) end )
501- end )
502- |> Enum . reduce ( none ( ) , fn { _args , ret } , acc -> opt_union ( upper_bound ( ret ) , acc ) end )
523+ # The checker's own application of an inferred signature, mirroring
524+ # Module.Types.Apply.apply_infer/2: clauses whose domains are positionwise
525+ # NON-DISJOINT from the argument types contribute their returns; no
526+ # matching clause means the checker warns on every such call (:badapply);
527+ # more than @max_clauses matching collapse to dynamic(). Inferred
528+ # applications always wrap the result in dynamic() regardless of
529+ # stack.mode -- the mode only affects strong signatures (via
530+ # Apply.return/3), which never appear in ExCk chunks, so running "in
531+ # static mode" degenerates to the same computation here.
532+ def checker_apply ( iclauses , args_types ) do
533+ matching =
534+ Enum . filter ( iclauses , fn { iargs , _ret } -> zip_not_disjoint? ( args_types , iargs ) end )
535+
536+ cond do
537+ matching == [ ] ->
538+ :badapply
539+
540+ length ( matching ) > @ max_clauses ->
541+ { :ok , dynamic ( ) }
542+
543+ true ->
544+ { :ok , matching |> Enum . map ( & elem ( & 1 , 1 ) ) |> Enum . reduce ( & opt_union / 2 ) |> dynamic ( ) }
545+ end
546+ end
547+
548+ defp zip_not_disjoint? ( [ a | as ] , [ e | es ] ) ,
549+ do: not disjoint? ( a , e ) and zip_not_disjoint? ( as , es )
550+
551+ defp zip_not_disjoint? ( [ ] , [ ] ) , do: true
552+
553+ # The "wrap all arguments in dynamic()" probe: what the checker infers for
554+ # a call site where nothing is known about the arguments (the common
555+ # gradual caller). dynamic() is non-disjoint from every non-empty domain,
556+ # so every inferred clause applies. Reported per function, not gated: the
557+ # inferred domain may legitimately cover inputs outside the spec domain
558+ # (defensive clauses), so a wider dynamic-args return is not a spec
559+ # violation by itself.
560+ defp dynamic_args_probe ( iclauses , spec_clauses , arity ) do
561+ spec_ret_union =
562+ Enum . reduce ( spec_clauses , none ( ) , fn { _ , _ , sret , _ } , acc ->
563+ opt_union ( upper_bound ( sret ) , acc )
564+ end )
565+
566+ case checker_apply ( iclauses , List . duplicate ( dynamic ( ) , arity ) ) do
567+ :badapply ->
568+ % { return: none ( ) , relation: :badapply }
569+
570+ { :ok , ret } ->
571+ ret_u = upper_bound ( ret )
572+
573+ relation =
574+ cond do
575+ subtype? ( ret_u , spec_ret_union ) and subtype? ( spec_ret_union , ret_u ) -> :equal
576+ subtype? ( ret_u , spec_ret_union ) -> :inside_spec
577+ subtype? ( spec_ret_union , ret_u ) -> :wider_than_spec
578+ disjoint? ( ret_u , spec_ret_union ) -> :disjoint
579+ true -> :incomparable
580+ end
581+
582+ % { return: ret_u , relation: relation }
583+ end
503584 end
504585
505586 @ order [ :contradiction , :mixed , :spec_wider_return , :inference_wider , :equivalent ]
@@ -625,11 +706,12 @@ defmodule Main do
625706 { dargs , exact_args , dret , exact_ret }
626707 end )
627708
628- { verdict , slices } = Verdict . compare ( translated , iclauses , arity )
709+ { verdict , slices , dynamic_probe } = Verdict . compare ( translated , iclauses , arity )
629710
630711 base
631712 |> Map . put ( :verdict , verdict )
632713 |> Map . put ( :slices , slices )
714+ |> Map . put ( :dynamic_probe , dynamic_probe )
633715 |> Map . put ( :iclauses , iclauses )
634716 |> Map . put ( :spec_strings , spec_strings ( name , spec_clauses ) )
635717 catch
@@ -650,8 +732,13 @@ defmodule Main do
650732
651733 # -- Rendering ------------------------------------------------------------
652734
653- @ auto [ :equivalent , :inference_wider , :no_signature ]
654- @ residue [ :contradiction , :mixed , :spec_wider_return ]
735+ # :spec_wider_return (inference strictly inside the spec return) is
736+ # auto-triaged: specs are public contracts and are often deliberately
737+ # wider than the implementation (e.g. atom() instead of specific atoms)
738+ # to keep room for evolution. It stays a distinct verdict in the stats
739+ # and JSON so tightening candidates remain discoverable.
740+ @ auto [ :equivalent , :inference_wider , :spec_wider_return , :no_signature ]
741+ @ residue [ :contradiction , :mixed ]
655742
656743 defp render ( results , opts ) do
657744 all = Enum . flat_map ( results , & & 1 . entries )
@@ -725,7 +812,12 @@ defmodule Main do
725812 inferred_effective_return: to_quoted_string ( s . effective_ret ) ,
726813 translation_exact: % { args: s . exact_args , return: s . exact_ret }
727814 }
728- end )
815+ end ) ,
816+ dynamic_args:
817+ case e [ :dynamic_probe ] do
818+ nil -> nil
819+ probe -> % { return: to_quoted_string ( probe . return ) , relation: probe . relation }
820+ end
729821 }
730822 end
731823
@@ -805,11 +897,21 @@ defmodule Main do
805897 " #{ s . verdict } #{ approx } : spec ret #{ to_quoted_string ( s . spec_ret ) } vs inferred #{ to_quoted_string ( s . effective_ret ) } "
806898 end )
807899
900+ probe_note =
901+ case e [ :dynamic_probe ] do
902+ nil ->
903+ ""
904+
905+ probe ->
906+ " dynamic args: #{ to_quoted_string ( probe . return ) } (#{ probe . relation } )\n "
907+ end
908+
808909 """
809910 #{ e . verdict } : #{ mfa_string ( e ) }
810911 spec: #{ Enum . join ( e . spec_strings , " ||| " ) }
811912 inferred: #{ inferred_string ( e ) }
812913 #{ slice_notes }
914+ #{ probe_note }
813915 """
814916 end
815917
0 commit comments