Skip to content

Commit 6ec9330

Browse files
lukaszsamsonclaude
andcommitted
Fix remote type argument scoping and gate untranslatable specs
Two review findings in the spec comparison script: Remote type arguments were substituted into the callee body and then translated with the environment switched to the callee module, so a caller-local type inside an argument resolved in the wrong namespace: Keyword.t(ansidata) in IO.ANSI looked up Keyword.ansidata/0 (spuriously untranslatable) and could silently capture a same-named callee type. Arguments are now qualified as remote types against the caller module before substitution. IO.ANSI translates fully as a result. Untranslatable specs were counted but neither reported nor gated, so a new unsupported typespec construct could silently reduce coverage. They are now listed in the report/JSON with the reason and must be acknowledged in the exclusions file like any residue entry. The nine current ones (Erlang record types in File specs) are baselined. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2e52fe8 commit 6ec9330

2 files changed

Lines changed: 84 additions & 26 deletions

File tree

lib/elixir/scripts/compare_specs_and_signatures.exs

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,17 @@
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

5557
apps_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
"""

lib/elixir/scripts/compare_specs_exclusions.txt

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
#
55
# One Module.function/arity per line; anything after "#" is a comment.
66
# Every entry below was triaged on 2026-07-09: intentional API contracts
7-
# (e.g. Enumerable.t() opacity), aspirational specs, deprecated modules, or
8-
# spec-translation approximation artifacts -- not checker bugs.
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.
910
#
1011
# Maintenance: when a spec or inference change removes a divergence, the tool
11-
# reports the entry as stale -- delete the line. A NEW divergence fails the
12-
# gate; triage it (is the spec wrong? is inference wrong? is it intentional?)
13-
# before adding it here with a comment.
12+
# reports the entry as stale -- delete the line. A NEW divergence or a newly
13+
# untranslatable spec fails the gate; triage it (is the spec wrong? is
14+
# inference wrong? is it intentional? does the translator need a new
15+
# construct?) before adding it here with a comment.
1416
Access.all/0 # mixed
1517
Access.at!/1 # mixed
1618
Access.at/1 # mixed
@@ -44,6 +46,15 @@ DateTime.from_unix/3 # mixed
4446
ExUnit.Filters.parse_paths/1 # mixed
4547
ExUnit.async_run/0 # mixed
4648
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)
4758
File.rm_rf/1 # mixed
4859
HashDict.new/0 # spec_wider_return
4960
HashSet.new/0 # spec_wider_return

0 commit comments

Comments
 (0)