Skip to content

Check keyword arguments passed through a ** splat - #63

Draft
apiology wants to merge 14 commits into
fix-1229-intersection-typesfrom
kwsplat-record-types
Draft

Check keyword arguments passed through a ** splat#63
apiology wants to merge 14 commits into
fix-1229-intersection-typesfrom
kwsplat-record-types

Conversation

@apiology

@apiology apiology commented Aug 21, 2026

Copy link
Copy Markdown
Owner

This PR was written by Claude Code on behalf of @apiology.

Problem: Passing a hash through ** reports every declared keyword as missing, even when the hash's type says exactly which keys it holds.

# @param a [Integer]
# @param b [Integer]
# @return [void]
def foo(a:, b:); end

# @param args [Hash{:a => Integer} & Hash{:b => Integer}]
# @return [void]
def bar(args)
  foo(**args)   # Call to #foo is missing keyword argument a
                # Call to #foo is missing keyword argument b
end

Every ** call site reports this at strict and above, so the only way to use one today is to suppress the check.

Solution: Check the splat against the hash's own type, so a record type has its required keywords, value types and unrecognised keys verified for real, while an opaque hash produces one "cannot verify" diagnostic in place of N false ones.

Stacked on castwide#1231.

apiology and others added 2 commits August 20, 2026 09:12
ComplexType::UniqueType#qualify widened every literal type to its class,
so a record type written as Hash{:a => Integer} & Hash{:b => String}
arrived at any consumer as Hash{Symbol => Integer} & Hash{Symbol =>
String} - the keys, which are the whole content of a record type, were
gone by the time inference finished.

Literal keys are now qualified by hand and kept; literals in any other
position still widen, which is what castwide#1201 disabled literal inference
for. Intersection holds conjuncts rather than key_types/subtypes, so it
qualifies those instead of inheriting the walk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AsvDi68YqsKoBtS2kg9ch
--level strong reported every required keyword of a call as missing when
the keywords came through a ** splat of a hash whose type records no
keys, e.g. foo(**args) where args is a Hash{Symbol => Integer}. The keys
are not in the type, so the checker could not see whether they were
supplied; reporting them as absent was a claim it had no basis for.

Two behaviours replace that one. When the splatted value has a record
type - Hash{:a => Integer} & Hash{:b => String} - its keys are checked
like literal keywords: required keywords must be present, values must
conform to the parameter types, and a key the method does not accept is
reported as unrecognized. When the type records no keys, the call gets

  Cannot verify keyword arguments to #foo: the ** splat is
  Hash{Symbol => Integer}, which does not record its keys, so required
  keyword arguments a, b cannot be checked - give the splatted value a
  record type (e.g. Hash{:a => Object}) to check it

once per call rather than one "missing keyword" per parameter.

convert_hash also discarded literal keys whenever a kwsplat shared the
hash node, so foo(a: 1, **args) lost a, and it turned the splatted
variable's own name into a key, which is what produced "Unrecognized
keyword argument kwargs" on super(name: name, **kwargs) - the two
@sg-ignore comments that suppressed it are gone. hash_is_splatted? now
looks at every child rather than only the last.

Two @sg-ignore comments on the calls that destructure
keyword_splat_types record a separate Solargraph defect: multiple
assignment from an Array(A, B) return type gives every variable the type
of the first element. Declaring the type of par cleared eight existing
findings in kwarg_problems_for and left one about the declaration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AsvDi68YqsKoBtS2kg9ch
apiology added a commit that referenced this pull request Aug 27, 2026
apiology added a commit that referenced this pull request Aug 27, 2026
#63's rewrite of UniqueType#qualify used flat_map over @key_types and
@subtypes, collapsing every parameter position's qualified items into
one flat array of bare UniqueTypes instead of an array of one
ComplexType per position. Bench#source_map_hash regressed on CI:
Hash{String => SourceMap} inferred as Hash{String => NilClass},
because SourceMap#filename returns String, nil and a union member at
one position leaked into the wrong slot.

Use map and rewrap each position's items in ComplexType.new, matching
the pattern ComplexType#qualify and UniqueType#transform_position
already use.
#63's rewrite of UniqueType#qualify used flat_map over @key_types and
@subtypes, collapsing every parameter position's qualified items into
one flat array of bare UniqueTypes instead of an array of one
ComplexType per position. Bench#source_map_hash regressed on CI:
Hash{String => SourceMap} inferred as Hash{String => NilClass},
because SourceMap#filename returns String, nil and a union member at
one position leaked into the wrong slot.

Use map and rewrap each position's items in ComplexType.new, matching
the pattern ComplexType#qualify and UniqueType#transform_position
already use.
The prior commit fixed the real cause (UniqueType#qualify flattening
key/subtype positions). This suppression attributed the same symptom
to flow-sensitive typing of ||=, which was never the actual cause.
The spec asserted that a non-key literal (e.g. Array<:a>) still widens
to its class (Array<Symbol>) when qualified. Another already-merged
PR intentionally removed that widening behavior; the spec has served
its purpose documenting the transition and no longer describes
intended behavior.
apiology added a commit that referenced this pull request Aug 27, 2026
# Conflicts:
#	.github/workflows/plugins.yml
The five record-typed ** splat examples built their splat value from a
helper whose body was a hash literal, and declared the record type as
that helper's @return. Chain::Hash now infers Hash{K => V} from the
literal's own pairs, and a symbol key infers as Symbol rather than :b
while literal typing stays disabled, so each example gained a second,
unrelated problem: declared ::Hash{:b => ::Integer} does not match
inferred ::Hash{::Symbol => ::Integer}.

Declaring the record type as a @PARAM on the method under test drops
the helper and the hash literal entirely, leaving each example to
exercise only the splat checking it names. The literal-typing gap
itself is already tracked by the pending specs citing
castwide#1223.
Four markers added by this branch quoted the full typecheck error across
five comment lines. The repo tracks these by a short reason string, one
per line, so a multi-line marker cannot be counted or grouped with its
siblings.

Name the gap instead of quoting the error. The two keyword-splat sites
share one reason, since both come from the same multiple-assignment
defect.
The one-line markers introduced three reason strings the census did not
list, so a grep-based count would miss them.

Section totals are left as they are: they already disagree with their own
entries (pending code fixes says 277 while its three entries sum to 306),
so they appear to be regenerated rather than incremented by hand.
Qualification kept literal tags in key positions only, through two
near-identical blocks that differed in one ternary. Restricting it to keys
was conservative rather than principled: a literal tag names a specific
value wherever it appears, and widening it to the value class erases that
in a subtype exactly as it does in a key.

Run one expression over both positions. Array<:sym> now qualifies to
Array<:sym> rather than Array<Symbol>, and nil in a declared union stays
nil rather than becoming NilClass - which is what the two clip specs were
pending on, so their pending lines come out.

Self-typecheck drops from 579 problems to 575.
Brings the base branch up to 9ca269c. One conflict, in Intersection:
this branch added #qualify at 7f1d909 so a record type keeps its
literal keys through qualification, while the base added
#unalias_and_qualify. Neither method existed at the merge base, and the
base defines both side by side on UniqueType and ComplexType, so both
overrides are kept.
qualify_positions asks every type in a key or value position whether
its tag is a literal, so a record type keeps its literal keys instead
of widening them. literal_tag? is non_literal_name != name, and
Intersection raises on non_literal_name, so an intersection sitting in
one of those positions crashed the typecheck instead of being
qualified.

An intersection tag is the compound "A & B" string and is never a
literal value, so it answers false and qualification descends into the
conjuncts through Intersection#qualify.

Neither branch reaches this alone. qualify_positions and literal_tag?
come from this branch at 7f1d909; the non_literal_name raise stub
comes from the base at 095c670, merged in at d8e3001. The base
annotates Initialize#process with a record intersection in a value
position, which is what the typecheck tripped over.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant