4242# --limit N Limit number of modules after filtering.
4343# --exclusions PATH Acknowledged-findings file: one Module.function/arity
4444# per line, anything after "#" is a comment. Enables
45- # STRICT gating: every residue entry must be listed or
46- # the run fails. Entries that no longer match anything
47- # are reported as stale (warning, not fatal).
45+ # STRICT gating: every residue AND untranslatable entry
46+ # must be listed or the run fails (an untranslatable spec
47+ # is lost coverage, not a pass). Entries that no longer
48+ # match anything are reported as stale (warning, not
49+ # fatal).
4850# --help This help.
4951#
5052# Exit status: 1 if any lattice contradiction exists (spec and inference
5153# cannot both be right); with --exclusions additionally 1 if any NEW
52- # (unlisted) residue entry appears; 0 otherwise. The analysis is
53- # deterministic. CI entry point: `make check_specs`.
54+ # (unlisted) residue or untranslatable entry appears; 0 otherwise. The
55+ # analysis is deterministic. CI entry point: `make check_specs`.
5456
5557apps_default = [ :elixir , :eex , :ex_unit , :iex , :logger , :mix ]
5658
@@ -331,6 +333,14 @@ defmodule SpecToDescr do
331333 else
332334 case fetch_type ( mod , name , length ( args ) ) do
333335 { :ok , { params , body } } ->
336+ # Argument ASTs are written in the CALLER's module context, but the
337+ # body is translated with env.module switched to the callee. A bare
338+ # user_type inside an argument (e.g. Keyword.t(ansidata) in IO.ANSI)
339+ # would be wrongly resolved in the callee's namespace -- or silently
340+ # capture a same-named callee type. Qualify arguments as remote
341+ # types against the caller module before substituting.
342+ args = Enum . map ( args , & qualify ( & 1 , env . module ) )
343+
334344 subst =
335345 params
336346 |> Enum . zip ( args )
@@ -346,6 +356,19 @@ defmodule SpecToDescr do
346356 end
347357 end
348358
359+ defp qualify ( { :user_type , l , n , args } , module ) do
360+ { :remote_type , l , [ { :atom , l , module } , { :atom , l , n } , Enum . map ( args , & qualify ( & 1 , module ) ) ] }
361+ end
362+
363+ defp qualify ( { :type , l , n , args } , module ) when is_list ( args ) ,
364+ do: { :type , l , n , Enum . map ( args , & qualify ( & 1 , module ) ) }
365+
366+ defp qualify ( { :remote_type , l , [ m , n , args ] } , module ) ,
367+ do: { :remote_type , l , [ m , n , Enum . map ( args , & qualify ( & 1 , module ) ) ] }
368+
369+ defp qualify ( { :ann_type , l , [ v , t ] } , module ) , do: { :ann_type , l , [ v , qualify ( t , module ) ] }
370+ defp qualify ( other , _module ) , do: other
371+
349372 # `when var: type` specs arrive as bounded_fun.
350373 def debound ( { :type , l , :bounded_fun , [ fun , constraints ] } ) do
351374 subst =
@@ -634,25 +657,32 @@ defmodule Main do
634657 all = Enum . flat_map ( results , & & 1 . entries )
635658 stats = Enum . frequencies_by ( all , & & 1 . verdict )
636659 residue = Enum . filter ( all , & ( & 1 . verdict in @ residue ) )
660+ untranslatable = Enum . filter ( all , & ( & 1 . verdict == :untranslatable ) )
637661
638662 exclusions = opts [ :exclusions ]
639663 excluded? = & ( exclusions != nil and MapSet . member? ( exclusions , mfa_string ( & 1 ) ) )
640664 new_residue = Enum . reject ( residue , excluded? )
665+ new_untranslatable = Enum . reject ( untranslatable , excluded? )
641666
642667 stale =
643668 if exclusions do
644- current = MapSet . new ( residue , & mfa_string / 1 )
669+ current = MapSet . new ( residue ++ untranslatable , & mfa_string / 1 )
645670 exclusions |> MapSet . difference ( current ) |> Enum . sort ( )
646671 else
647672 [ ]
648673 end
649674
650- gate = % { exclusions: exclusions , new_residue: new_residue , stale: stale }
675+ gate = % {
676+ exclusions: exclusions ,
677+ new_residue: new_residue ,
678+ new_untranslatable: new_untranslatable ,
679+ stale: stale
680+ }
651681
652682 out =
653683 case opts [ :format ] do
654- "report" -> render_report ( stats , residue , all , gate )
655- "json" -> JSON . encode_to_iodata! ( render_json ( stats , residue ) )
684+ "report" -> render_report ( stats , residue , untranslatable , all , gate )
685+ "json" -> JSON . encode_to_iodata! ( render_json ( stats , residue , untranslatable ) )
656686 "prompt" -> render_prompt ( stats , residue )
657687 end
658688
@@ -664,11 +694,12 @@ defmodule Main do
664694 # Lattice contradictions are mechanical certainties (the
665695 # over-approximation invariant in SpecToDescr makes disjointness
666696 # conclusions transfer to the real spec type), so they fail the run for
667- # CI purposes. With --exclusions, any residue entry not explicitly
668- # acknowledged in the file also fails the run; stale exclusions only
669- # warn.
697+ # CI purposes. With --exclusions, any residue OR untranslatable entry not
698+ # explicitly acknowledged in the file also fails the run -- a spec the
699+ # translator cannot handle is lost coverage, not a pass; stale exclusions
700+ # only warn.
670701 contradictions = Enum . filter ( new_residue , & ( & 1 . verdict == :contradiction ) )
671- strict_fail = exclusions != nil and new_residue != [ ]
702+ strict_fail = exclusions != nil and ( new_residue != [ ] or new_untranslatable != [ ] )
672703 if contradictions != [ ] or strict_fail , do: System . halt ( 1 )
673704 end
674705
@@ -698,16 +729,21 @@ defmodule Main do
698729 }
699730 end
700731
701- defp render_json ( stats , residue ) do
732+ defp untranslatable_json ( e ) do
733+ % { mfa: mfa_string ( e ) , why: e [ :why ] }
734+ end
735+
736+ defp render_json ( stats , residue , untranslatable ) do
702737 % {
703738 elixir: System . version ( ) ,
704739 format_version: 3 ,
705740 stats: stats ,
706- residue: Enum . map ( residue , & entry_json / 1 )
741+ residue: Enum . map ( residue , & entry_json / 1 ) ,
742+ untranslatable: Enum . map ( untranslatable , & untranslatable_json / 1 )
707743 }
708744 end
709745
710- defp render_report ( stats , residue , all , gate ) do
746+ defp render_report ( stats , residue , untranslatable , all , gate ) do
711747 total = length ( all )
712748 auto = Enum . count ( all , & ( & 1 . verdict in @ auto ) )
713749
@@ -718,7 +754,7 @@ defmodule Main do
718754
719755 exclusions ->
720756 new_lines =
721- for e <- gate . new_residue do
757+ for e <- gate . new_residue ++ gate . new_untranslatable do
722758 " NEW (not in exclusions, FAILS the gate): #{ mfa_string ( e ) } (#{ e . verdict } )\n "
723759 end
724760
@@ -729,15 +765,21 @@ defmodule Main do
729765
730766 summary =
731767 "gate: #{ MapSet . size ( exclusions ) } exclusions -- " <>
732- "#{ length ( gate . new_residue ) } new, #{ length ( gate . stale ) } stale\n "
768+ "#{ length ( gate . new_residue ) + length ( gate . new_untranslatable ) } new, " <>
769+ "#{ length ( gate . stale ) } stale\n "
733770
734771 Enum . join ( [ summary | new_lines ++ stale_lines ] )
735772 end
736773
737774 # With an exclusions file, acknowledged entries are only counted (header
738775 # stats keep the full numbers) -- full details are printed for NEW
739776 # (gate-failing) entries alone, keeping CI output focused on what changed.
740- detail_residue = if gate . exclusions , do: gate . new_residue , else: residue
777+ { detail_residue , detail_untranslatable } =
778+ if gate . exclusions do
779+ { gate . new_residue , gate . new_untranslatable }
780+ else
781+ { residue , untranslatable }
782+ end
741783
742784 header = """
743785 compare_specs_and_signatures: #{ total } spec'd functions
@@ -747,6 +789,11 @@ defmodule Main do
747789 #{ gate_section }
748790 """
749791
792+ untranslatable_section =
793+ for e <- detail_untranslatable do
794+ "untranslatable: #{ mfa_string ( e ) } \n why: #{ e [ :why ] } \n "
795+ end
796+
750797 residue_section =
751798 for e <- Enum . sort_by ( detail_residue , & verdict_rank / 1 ) do
752799 slice_notes =
@@ -766,7 +813,7 @@ defmodule Main do
766813 """
767814 end
768815
769- [ header , Enum . join ( residue_section , "\n " ) ]
816+ [ header , Enum . join ( residue_section , "\n " ) , Enum . join ( untranslatable_section , " \n " ) ]
770817 end
771818
772819 defp inferred_string ( e ) do
@@ -783,7 +830,7 @@ defmodule Main do
783830 defp percent ( n , total ) , do: "#{ Float . round ( n * 100 / total , 1 ) } %"
784831
785832 defp render_prompt ( stats , residue ) do
786- json = JSON . encode_to_iodata! ( render_json ( stats , residue ) )
833+ json = JSON . encode_to_iodata! ( render_json ( stats , residue , [ ] ) )
787834
788835 [
789836 """
0 commit comments