Skip to content

Commit fa82bb4

Browse files
lukaszsamsonclaude
andcommitted
Compute verdicts via the checker's own application rule
Address review feedback on the comparison semantics: - :spec_wider_return (inference strictly inside the spec return) is auto-triaged: specs are public contracts and are often deliberately wider than the implementation, e.g. atom() instead of specific atoms. It remains a distinct verdict in stats and JSON so tightening hints stay discoverable. Shrinks the exclusion baseline from 126 to 60. - The effective inferred return is computed with the checker's own application rule, mirroring Module.Types.Apply.apply_infer/2: positionwise non-disjoint clauses contribute their returns, and a spec slice on which the checker rejects every conforming call escalates to :contradiction. Each function is additionally probed with all arguments set to Descr.dynamic() (the fully-unknown gradual caller) and the relation of that return to the spec is reported. Inferred applications always produce dynamic()-wrapped returns; stack.mode only affects strong signatures, which never occur in ExCk chunks. - Erlang record types #name{} translate as open tuples tagged with the record name (sound over-approximation), removing nine File.* untranslatable entries. The one remaining untranslatable spec (Code.fetch_docs/1) is due to :erl_anno's OTP 28 nominal types and resolves once Code.Typespec.fetch_types/1 returns nominal types. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 92842f3 commit fa82bb4

2 files changed

Lines changed: 133 additions & 106 deletions

File tree

lib/elixir/scripts/compare_specs_and_signatures.exs

Lines changed: 128 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,24 @@
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

lib/elixir/scripts/compare_specs_exclusions.txt

Lines changed: 5 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
# (CI entry point: make check_specs).
44
#
55
# One Module.function/arity per line; anything after "#" is a comment.
6-
# Every entry below was triaged on 2026-07-09: intentional API contracts
7-
# (e.g. Enumerable.t() opacity), aspirational specs, deprecated modules,
8-
# spec-translation approximation artifacts, or specs using constructs the
9-
# translator cannot express (e.g. Erlang record types) -- not checker bugs.
6+
# Every entry below was triaged: intentional API contracts and abstractions
7+
# (e.g. Enumerable.t() opacity), approximation artifacts of the
8+
# spec-to-descr translation (function types, Macro.t(), iodata()), or specs
9+
# using constructs the translator cannot express -- not checker bugs.
1010
#
1111
# Maintenance: when a spec or inference change removes a divergence, the tool
1212
# reports the entry as stale -- delete the line. A NEW divergence or a newly
@@ -32,9 +32,8 @@ Calendar.ISO.parse_time/2 # mixed
3232
Calendar.ISO.parse_utc_datetime/1 # mixed
3333
Calendar.ISO.parse_utc_datetime/2 # mixed
3434
Calendar.ISO.time_to_iodata/5 # mixed
35-
Code.Typespec.fetch_types/1 # spec_wider_return
3635
Code.Typespec.spec_to_quoted/2 # mixed
37-
Config.Provider.init/3 # spec_wider_return
36+
Code.fetch_docs/1 # untranslatable ({:missing_type, :erl_anno, :location, 0}) -- OTP 28 nominal type; resolves once Code.Typespec returns nominal types
3837
Config.__env__!/0 # mixed
3938
Config.__target__!/0 # mixed
4039
Date.from_erl/2 # mixed
@@ -46,105 +45,31 @@ DateTime.from_unix/3 # mixed
4645
ExUnit.Filters.parse_paths/1 # mixed
4746
ExUnit.async_run/0 # mixed
4847
ExUnit.configuration/0 # mixed
49-
File.Stat.from_record/1 # untranslatable (:record)
50-
File.Stat.to_record/1 # untranslatable (:record)
51-
File.close/1 # untranslatable (:record)
52-
File.copy!/3 # untranslatable (:record)
53-
File.copy/3 # untranslatable (:record)
54-
File.open!/2 # untranslatable (:record)
55-
File.open!/3 # untranslatable (:record)
56-
File.open/2 # untranslatable (:record)
57-
File.open/3 # untranslatable (:record)
5848
File.rm_rf/1 # mixed
59-
HashDict.new/0 # spec_wider_return
60-
HashSet.new/0 # spec_wider_return
6149
IEx.Pry.annotate_quoted/3 # mixed
6250
IEx.Pry.whereami/3 # mixed
63-
IO.binstream/0 # spec_wider_return
64-
IO.binstream/2 # spec_wider_return
65-
IO.stream/0 # spec_wider_return
66-
IO.stream/2 # spec_wider_return
6751
Inspect.Algebra.container_doc_with_opts/6 # mixed
6852
Keyword.put/3 # mixed
6953
Keyword.replace!/3 # mixed
7054
Keyword.update!/3 # mixed
7155
Keyword.update/4 # mixed
7256
Logger.Backends.Internal.add/2 # mixed
73-
Logger.Formatter.new/1 # spec_wider_return
7457
Macro.Env.expand_import/5 # mixed
7558
Macro.Env.expand_require/6 # mixed
76-
Macro.Env.location/1 # mixed
7759
Macro.Env.prepend_tracer/2 # mixed
7860
Macro.Env.prune_compile_info/1 # mixed
79-
Macro.Env.stacktrace/1 # mixed
8061
Macro.Env.to_guard/1 # mixed
8162
Macro.Env.to_match/1 # mixed
8263
Macro.decompose_call/1 # mixed
8364
Macro.pipe/3 # mixed
84-
Macro.postwalker/1 # spec_wider_return
85-
Macro.prewalker/1 # spec_wider_return
8665
Macro.unique_var/2 # mixed
87-
MapSet.new/0 # spec_wider_return
8866
Mix.Project.get!/0 # mixed
8967
Mix.Release.from_config!/3 # mixed
9068
Mix.Tasks.Format.formatter_for_file/2 # mixed
9169
NaiveDateTime.from_iso8601/2 # mixed
92-
Process.delete/1 # spec_wider_return
93-
Process.get_label/1 # spec_wider_return
94-
Process.put/2 # spec_wider_return
95-
Stream.chunk_while/4 # spec_wider_return
96-
Stream.concat/1 # spec_wider_return
97-
Stream.concat/2 # spec_wider_return
98-
Stream.cycle/1 # spec_wider_return
99-
Stream.dedup/1 # spec_wider_return
100-
Stream.dedup_by/2 # spec_wider_return
101-
Stream.drop/2 # spec_wider_return
102-
Stream.drop_every/2 # spec_wider_return
103-
Stream.drop_while/2 # spec_wider_return
104-
Stream.duplicate/2 # spec_wider_return
105-
Stream.each/2 # spec_wider_return
106-
Stream.filter/2 # spec_wider_return
107-
Stream.flat_map/2 # spec_wider_return
108-
Stream.from_index/1 # spec_wider_return
109-
Stream.intersperse/2 # spec_wider_return
110-
Stream.interval/1 # spec_wider_return
111-
Stream.into/3 # spec_wider_return
112-
Stream.iterate/2 # spec_wider_return
113-
Stream.map/2 # spec_wider_return
114-
Stream.map_every/3 # spec_wider_return
115-
Stream.reject/2 # spec_wider_return
116-
Stream.repeatedly/1 # spec_wider_return
117-
Stream.resource/3 # spec_wider_return
118-
Stream.scan/2 # spec_wider_return
119-
Stream.scan/3 # spec_wider_return
120-
Stream.take/2 # spec_wider_return
121-
Stream.take_every/2 # spec_wider_return
122-
Stream.take_while/2 # spec_wider_return
123-
Stream.timer/1 # spec_wider_return
124-
Stream.transform/3 # spec_wider_return
125-
Stream.transform/4 # spec_wider_return
126-
Stream.transform/5 # spec_wider_return
127-
Stream.unfold/2 # spec_wider_return
128-
Stream.uniq/1 # spec_wider_return
129-
Stream.uniq_by/2 # spec_wider_return
130-
Stream.with_index/2 # spec_wider_return
131-
Stream.zip/1 # spec_wider_return
132-
Stream.zip/2 # spec_wider_return
133-
Stream.zip_with/2 # spec_wider_return
134-
Stream.zip_with/3 # spec_wider_return
135-
String.splitter/3 # spec_wider_return
136-
Supervisor.Spec.supervise/2 # spec_wider_return
137-
System.compiled_endianness/0 # spec_wider_return
138-
Task.Supervisor.async_stream/4 # spec_wider_return
139-
Task.Supervisor.async_stream/6 # spec_wider_return
140-
Task.Supervisor.async_stream_nolink/4 # spec_wider_return
141-
Task.Supervisor.async_stream_nolink/6 # spec_wider_return
14270
Task.async/1 # mixed
14371
Task.async/3 # mixed
144-
Task.async_stream/3 # spec_wider_return
145-
Task.async_stream/5 # spec_wider_return
14672
Time.convert/2 # mixed
14773
Time.from_iso8601/2 # mixed
14874
URI.append_path/2 # mixed
14975
URI.append_query/2 # mixed
150-
URI.query_decoder/2 # spec_wider_return

0 commit comments

Comments
 (0)