3939# longer judges string equivalence. The analysis is fully static: no stdlib
4040# function is executed.
4141#
42+ # Spec-domain body check (informational, does not gate)
43+ # -----------------------------------------------------
44+ # Additionally, the compiler's type checker is re-run over each module's
45+ # definitions (Module.Types.warnings/7) with every spec'd function's
46+ # argument domain taken from its TRANSLATED SPEC instead of the default
47+ # list of dynamics. This reports (a) the warnings the checker emits when
48+ # arguments are assumed spec-typed -- e.g. clauses or code paths that
49+ # cannot match spec-conforming inputs -- and (b) the return type inferred
50+ # under that assumption, compared against the declared spec return.
51+ #
4252# Usage
4353# -----
4454# ./bin/elixir lib/elixir/scripts/compare_specs_and_signatures.exs [options]
4959# --format FORMAT report (default) | json | prompt
5060# --output PATH Write output to PATH instead of stdout.
5161# --limit N Limit number of modules after filtering.
62+ # --no-check-bodies Skip the spec-domain body check (see below).
63+ # --bodies-domain MODE
64+ # dynamic (default): re-check bodies with arguments set
65+ # to dynamic(spec_type) under the checker's :dynamic
66+ # mode. static: raw spec types under :static mode --
67+ # stricter, more (and more speculative) warnings.
5268# --exclusions PATH Acknowledged-findings file: one Module.function/arity
5369# per line, anything after "#" is a comment. Enables
5470# STRICT gating: every residue AND untranslatable entry
@@ -74,6 +90,8 @@ apps_default = [:elixir, :eex, :ex_unit, :iex, :logger, :mix]
7490 format: :string ,
7591 output: :string ,
7692 limit: :integer ,
93+ check_bodies: :boolean ,
94+ bodies_domain: :string ,
7795 exclusions: :string ,
7896 help: :boolean
7997 ]
@@ -99,6 +117,12 @@ unless format in ["report", "json", "prompt"] do
99117 raise "expected --format to be one of: report, json, prompt"
100118end
101119
120+ bodies_domain = opts [ :bodies_domain ] || "dynamic"
121+
122+ unless bodies_domain in [ "dynamic" , "static" ] do
123+ raise "expected --bodies-domain to be one of: dynamic, static"
124+ end
125+
102126unless Code . ensure_loaded? ( Module.Types.Descr ) do
103127 raise "Module.Types.Descr is not available; run with the local ./bin/elixir"
104128end
@@ -605,7 +629,7 @@ defmodule Main do
605629
606630 results =
607631 try do
608- Enum . map ( modules , & analyze_module ( & 1 , cache ) )
632+ Enum . map ( modules , & analyze_module ( & 1 , cache , opts ) )
609633 after
610634 Module.ParallelChecker . stop ( cache )
611635 end
@@ -662,7 +686,7 @@ defmodule Main do
662686 modules
663687 end
664688
665- defp analyze_module ( module , cache ) do
689+ defp analyze_module ( module , cache , opts ) do
666690 exported = MapSet . new ( module . __info__ ( :functions ) )
667691
668692 specs =
@@ -683,7 +707,115 @@ defmodule Main do
683707 analyze_function ( module , name , arity , spec_clauses , cache , env )
684708 end )
685709
686- % { module: module , entries: entries }
710+ { entries , body_warnings } = check_bodies ( module , entries , cache , opts )
711+ % { module: module , entries: entries , body_warnings: body_warnings }
712+ end
713+
714+ # -- Spec-domain body check ------------------------------------------------
715+ #
716+ # Re-runs the compiler's type checker over the module's definitions with
717+ # each spec'd function's argument domain taken from its translated spec
718+ # instead of the default list of dynamics (Module.Types.warnings/7).
719+ # Informational: results are reported but never gate.
720+
721+ defp check_bodies ( module , entries , cache , opts ) do
722+ with true <- opts [ :check_bodies ] ,
723+ { :ok , defs , attrs , file } <- module_debug_info ( module ) do
724+ mode = opts [ :bodies_domain ]
725+
726+ domains =
727+ for % { slices: slices } = e <- entries , slices != [ ] , into: % { } do
728+ args_union =
729+ slices
730+ |> Enum . map ( & & 1 . spec_args )
731+ |> Enum . zip_with ( fn types -> Enum . reduce ( types , & opt_union / 2 ) end )
732+
733+ args =
734+ case mode do
735+ :dynamic -> Enum . map ( args_union , & dynamic / 1 )
736+ :static -> args_union
737+ end
738+
739+ { { e . function , e . arity } , { mode , args } }
740+ end
741+
742+ { warnings , sigs } =
743+ Module.Types . warnings ( module , file , attrs , defs , :all , cache , fn fun_arity ->
744+ case domains do
745+ % { ^ fun_arity => domain } -> domain
746+ % { } -> :default
747+ end
748+ end )
749+
750+ entries =
751+ Enum . map ( entries , fn e ->
752+ fun_arity = { e [ :function ] , e [ :arity ] }
753+
754+ with true <- is_map_key ( domains , fun_arity ) ,
755+ { _kind , { :infer , _dom , [ _ | _ ] = clauses } , _mapping } <- Map . get ( sigs , fun_arity ) do
756+ ret = clauses |> Enum . map ( fn { _args , ret } -> ret end ) |> Enum . reduce ( & opt_union / 2 )
757+ Map . put ( e , :body_check , % { return: ret , relation: body_relation ( ret , e ) } )
758+ else
759+ _ -> e
760+ end
761+ end )
762+
763+ { entries , Enum . map ( warnings , & format_body_warning / 1 ) }
764+ else
765+ _ -> { entries , [ ] }
766+ end
767+ end
768+
769+ defp body_relation ( ret , e ) do
770+ spec_ret = Enum . reduce ( e . slices , none ( ) , fn s , acc -> opt_union ( s . spec_ret , acc ) end )
771+ ret_u = upper_bound ( ret )
772+
773+ cond do
774+ subtype? ( ret_u , spec_ret ) and subtype? ( spec_ret , ret_u ) -> :equal
775+ subtype? ( ret_u , spec_ret ) -> :inside_spec
776+ subtype? ( spec_ret , ret_u ) -> :wider_than_spec
777+ not empty? ( ret_u ) and not empty? ( spec_ret ) and disjoint? ( ret_u , spec_ret ) -> :disjoint
778+ true -> :incomparable
779+ end
780+ end
781+
782+ defp module_debug_info ( module ) do
783+ with [ _ | _ ] = path <- :code . which ( module ) ,
784+ { :ok , binary } <- File . read ( path ) ,
785+ { :ok , { _ , [ debug_info: { :debug_info_v1 , backend , data } ] } } <-
786+ :beam_lib . chunks ( binary , [ :debug_info ] ) ,
787+ { :ok , % { definitions: defs , attributes: attrs , file: file } } <-
788+ backend . debug_info ( :elixir_v1 , module , data , [ ] ) do
789+ { :ok , defs , attrs , file }
790+ else
791+ _ -> :error
792+ end
793+ end
794+
795+ # Warning entries are {module, warning, {file, meta, {mod, fun, arity}}}
796+ # as stored by Module.Types.Helpers.
797+ defp format_body_warning ( { warning_module , warning , location } ) do
798+ message =
799+ try do
800+ % { message: message } = warning_module . format_diagnostic ( warning )
801+ message |> IO . iodata_to_binary ( ) |> String . split ( "\n " ) |> hd ( )
802+ rescue
803+ _ -> inspect ( warning , limit: 5 )
804+ end
805+
806+ { mfa , position } =
807+ case location do
808+ { file , meta , { mod , fun , arity } } ->
809+ line = if is_list ( meta ) , do: Keyword . get ( meta , :line ) , else: nil
810+
811+ { "#{ inspect ( mod ) } .#{ fun } /#{ arity } " ,
812+ "#{ Path . relative_to_cwd ( to_string ( file ) ) } :#{ line || "?" } " }
813+
814+ _ ->
815+ { "?" , "?" }
816+ end
817+
818+ % { mfa: mfa , message: message , location: position }
687819 end
688820
689821 defp analyze_function ( module , name , arity , spec_clauses , cache , env ) do
@@ -768,11 +900,23 @@ defmodule Main do
768900
769901 hints = Enum . filter ( all , & ( & 1 . verdict == :spec_wider_return ) )
770902
903+ body_warnings =
904+ results
905+ |> Enum . flat_map ( & ( & 1 [ :body_warnings ] || [ ] ) )
906+ |> Enum . sort_by ( & { & 1 . mfa , & 1 . location } )
907+
771908 out =
772909 case opts [ :format ] do
773- "report" -> render_report ( stats , residue , untranslatable , all , gate )
774- "json" -> JSON . encode_to_iodata! ( render_json ( stats , residue , untranslatable , hints ) )
775- "prompt" -> render_prompt ( stats , residue , untranslatable )
910+ "report" ->
911+ render_report ( stats , residue , untranslatable , all , gate , body_warnings )
912+
913+ "json" ->
914+ JSON . encode_to_iodata! (
915+ render_json ( stats , residue , untranslatable , hints , body_warnings )
916+ )
917+
918+ "prompt" ->
919+ render_prompt ( stats , residue , untranslatable , body_warnings )
776920 end
777921
778922 case opts [ :output ] do
@@ -819,6 +963,11 @@ defmodule Main do
819963 case e [ :dynamic_probe ] do
820964 nil -> nil
821965 probe -> % { return: to_quoted_string ( probe . return ) , relation: probe . relation }
966+ end ,
967+ body_check:
968+ case e [ :body_check ] do
969+ nil -> nil
970+ bc -> % { return: to_quoted_string ( bc . return ) , relation: bc . relation }
822971 end
823972 }
824973 end
@@ -831,18 +980,19 @@ defmodule Main do
831980 # inference proved the return strictly narrower than an exactly-translated
832981 # spec. Not gated (specs are deliberately wide contracts), but listed so
833982 # documentation-tightening candidates remain identifiable.
834- defp render_json ( stats , residue , untranslatable , hints ) do
983+ defp render_json ( stats , residue , untranslatable , hints , body_warnings ) do
835984 % {
836985 elixir: System . version ( ) ,
837986 format_version: 3 ,
838987 stats: stats ,
839988 residue: Enum . map ( residue , & entry_json / 1 ) ,
840989 untranslatable: Enum . map ( untranslatable , & untranslatable_json / 1 ) ,
841- tightening_hints: Enum . map ( hints , & entry_json / 1 )
990+ tightening_hints: Enum . map ( hints , & entry_json / 1 ) ,
991+ body_warnings: body_warnings
842992 }
843993 end
844994
845- defp render_report ( stats , residue , untranslatable , all , gate ) do
995+ defp render_report ( stats , residue , untranslatable , all , gate , body_warnings ) do
846996 total = length ( all )
847997 auto = Enum . count ( all , & ( & 1 . verdict in @ auto ) )
848998
@@ -913,16 +1063,47 @@ defmodule Main do
9131063 " dynamic args: #{ to_quoted_string ( probe . return ) } (#{ probe . relation } )\n "
9141064 end
9151065
1066+ body_note =
1067+ case e [ :body_check ] do
1068+ nil ->
1069+ ""
1070+
1071+ bc ->
1072+ " body under spec domain: #{ to_quoted_string ( bc . return ) } (#{ bc . relation } )\n "
1073+ end
1074+
9161075 """
9171076 #{ e . verdict } : #{ mfa_string ( e ) }
9181077 spec: #{ Enum . join ( e . spec_strings , " ||| " ) }
9191078 inferred: #{ inferred_string ( e ) }
9201079 #{ slice_notes }
921- #{ probe_note }
1080+ #{ probe_note } #{ body_note }
9221081 """
9231082 end
9241083
925- [ header , Enum . join ( residue_section , "\n " ) , Enum . join ( untranslatable_section , "\n " ) ]
1084+ body_section =
1085+ case body_warnings do
1086+ [ ] ->
1087+ [ ]
1088+
1089+ warnings ->
1090+ lines =
1091+ Enum . map_join ( warnings , "\n " , fn w ->
1092+ " #{ w . mfa } (#{ w . location } ): #{ w . message } "
1093+ end )
1094+
1095+ [
1096+ """
1097+
1098+ SPEC-DOMAIN BODY CHECK (informational, not gated): \
1099+ #{ length ( warnings ) } warning(s) when checking bodies against spec-typed arguments
1100+ #{ lines }
1101+ """
1102+ ]
1103+ end
1104+
1105+ [ header , Enum . join ( residue_section , "\n " ) , Enum . join ( untranslatable_section , "\n " ) ] ++
1106+ body_section
9261107 end
9271108
9281109 defp inferred_string ( e ) do
@@ -938,8 +1119,8 @@ defmodule Main do
9381119 defp percent ( _n , 0 ) , do: "n/a"
9391120 defp percent ( n , total ) , do: "#{ Float . round ( n * 100 / total , 1 ) } %"
9401121
941- defp render_prompt ( stats , residue , untranslatable ) do
942- json = JSON . encode_to_iodata! ( render_json ( stats , residue , untranslatable , [ ] ) )
1122+ defp render_prompt ( stats , residue , untranslatable , body_warnings ) do
1123+ json = JSON . encode_to_iodata! ( render_json ( stats , residue , untranslatable , [ ] , body_warnings ) )
9431124
9441125 [
9451126 """
@@ -982,5 +1163,7 @@ Main.run(%{
9821163 format: format ,
9831164 output: opts [ :output ] ,
9841165 limit: opts [ :limit ] ,
1166+ check_bodies: opts [ :check_bodies ] != false ,
1167+ bodies_domain: String . to_atom ( bodies_domain ) ,
9851168 exclusions: exclusions
9861169} )
0 commit comments