Check keyword arguments passed through a ** splat - #63
Draft
apiology wants to merge 14 commits into
Draft
Conversation
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
#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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Every
**call site reports this atstrictand 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.