Skip to content

Commit 640e7cf

Browse files
lukaszsamsonclaude
andcommitted
Type check function bodies against their spec-derived domains
Implements the suggested approach of running the Elixir type checker itself with spec types as argument domains, rather than applying the stored inferred signature: Module.Types.warnings/7 is a new variant of warnings/6 that accepts a `domains` function returning either :default (the usual list of dynamics) or {mode, [arg_descr]} per fun_arity, and additionally returns the local signatures inferred under those domains. The comparison script re-checks every module's definitions with each spec'd function's argument domain taken from its translated spec: dynamic(spec_type) under :dynamic mode by default, or raw spec types under :static mode with --bodies-domain static. It reports (a) the warnings the checker emits when arguments are assumed spec-typed and (b) the return type inferred under that assumption, compared per entry against the declared spec return. Informational, never gates. On the current tree the dynamic-domain run finds 66 warnings (91 in static mode): mostly defensive clauses that cannot match spec-conforming inputs (Map.get/3's badmap clause, List.first!/1's empty-list raise, the deprecated Dict module's catch-alls) and spec-vs-deprecated-alias tensions such as System.normalize_time_unit/1 clauses handling plural time units that time_unit() excludes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7bc3700 commit 640e7cf

2 files changed

Lines changed: 221 additions & 16 deletions

File tree

lib/elixir/lib/module/types.ex

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,11 +218,33 @@ defmodule Module.Types do
218218

219219
@doc false
220220
def warnings(module, file, attrs, defs, no_warn_undefined, cache) do
221+
{warnings, _sigs} =
222+
warnings(module, file, attrs, defs, no_warn_undefined, cache, fn _ -> :default end)
223+
224+
warnings
225+
end
226+
227+
# Variant that allows type checking each definition against a custom
228+
# argument domain (for example derived from its typespec): `domains`
229+
# receives each fun_arity and returns either :default (the usual list of
230+
# dynamics) or {mode, [arg_descr]} to be used as the expected argument
231+
# types, where mode is :dynamic or :static. Also returns the local
232+
# signatures inferred under those domains so callers can compare return
233+
# types. Used by lib/elixir/scripts/compare_specs_and_signatures.exs.
234+
@doc false
235+
def warnings(module, file, attrs, defs, no_warn_undefined, cache, domains) do
221236
impl = impl_for(attrs)
222237

238+
domain = fn def, fun_arity ->
239+
case domains.(fun_arity) do
240+
:default -> default_domain(:dynamic, def, fun_arity, impl)
241+
{mode, args} when mode in [:dynamic, :static] and is_list(args) -> {mode, def, args}
242+
end
243+
end
244+
223245
finder = fn fun_arity ->
224246
case :lists.keyfind(fun_arity, 1, defs) do
225-
{_, _, _, _} = def -> default_domain(:dynamic, def, fun_arity, impl)
247+
{_, _, _, _} = def -> domain.(def, fun_arity)
226248
false -> false
227249
end
228250
end
@@ -233,13 +255,13 @@ defmodule Module.Types do
233255
context =
234256
Enum.reduce(defs, context(), fn {fun_arity, _kind, meta, _clauses} = def, context ->
235257
# Optimized version of finder, since we already have the definition
236-
finder = fn _ -> default_domain(:dynamic, def, fun_arity, impl) end
258+
finder = fn _ -> domain.(def, fun_arity) end
237259
{_kind, _inferred, context} = local_handler(meta, fun_arity, stack, context, finder)
238260
context
239261
end)
240262

241263
context = warn_unused_clauses(defs, stack, context)
242-
context.warnings
264+
{context.warnings, context.local_sigs}
243265
end
244266

245267
defp warn_unused_clauses(defs, stack, context) do

lib/elixir/scripts/compare_specs_and_signatures.exs

Lines changed: 196 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@
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]
@@ -49,6 +59,12 @@
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"
100118
end
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+
102126
unless Code.ensure_loaded?(Module.Types.Descr) do
103127
raise "Module.Types.Descr is not available; run with the local ./bin/elixir"
104128
end
@@ -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

Comments
 (0)