fix(resolve): same-line if-is smart-cast, as-cast type inference, nested-ctor qualifier loss - #319
Merged
Merged
Conversation
…ted-ctor qualifier loss
Three real, independently-verified fixes from a Gap-scout pass on the real
Moneta corpus (resolution-accuracy benchmark, main tip):
- `if_is_smart_cast`'s backward scan was `(start..line_idx).rev()`,
exclusive of the cursor's own line -- `if (drawable is Animatable)
drawable.start()` puts the type test and the member access on the SAME
line, so the scan never even looked at it. Made inclusive.
- No code path extracted a type from an `as`/`as?` cast expression at all --
`val activity = context as Activity` never gave `activity` an inferred
type. Added `as_expression` to `infer_expr_type_at_depth`'s dispatch: the
cast's own target type IS the expression's type, verbatim, no inference
needed. Verified directly against `CstQuery::expr_type()` for both `as`
and `as?` (same grammar node, confirmed via `tree` dump).
- `constructor_fallback` reported only `ctx.fn_name` (`call_fn_name`'s bare
leaf) for a nested-class constructor call like
`CaliforniaActivity.Builder(...)`, discarding the "CaliforniaActivity."
qualifier -- the bare "Builder" then collided with every other unrelated
same-named nested class once it reached a global bare-name lookup
downstream. Now uses the callee's own full dotted text for a
navigation_expression callee, which `resolve_symbol`'s existing
`name.contains('.')` branch already knows how to walk.
A fourth candidate fix (generic-arg stripping + call-segment handling in
`value_path_anchor`, targeting `firstOrNull`/`filter`/`toString`) was
investigated and NOT implemented: direct testing disproved its premise
(`infer_variable_type` already strips generics before `value_path_anchor`
ever sees them) and further tracing showed the resolution-accuracy
benchmark's actual reference classification (`resolve_identity` in
cst_symbol.rs) passes a CST-resolved `receiver_type` — always an uppercase
type name — into qualified resolution, routing through `type_path_anchors`,
not `value_path_anchor` at all. `value_path_anchor` only serves the LSP
cursor char-scan path (`word_and_qualifier_at`), a different consumer.
Needs fresh investigation through the correct path (does `List`'s indexed
declaration carry `Iterable`/`Collection` as a supertype? is `Iterable`'s
`firstOrNull` correctly keyed in `extension_by_receiver`?) — left for a
follow-up rather than forcing a fix based on a disproven premise.
Verified: `cargo test` (1940 passed), `cargo clippy -D warnings` clean, and
a resolution-accuracy scan against the real Moneta corpus: recall
90.9%→91.4%, Gap total 8342→7652. `start` and `fragmentArguments`/
`fragmentBundle` both confirmed gone from the Gap top-20. `finishAffinity`
did NOT move despite the as-cast fix being independently verified correct
at the primitive level (CstQuery::expr_type() test) — the real corpus call
site (`contextActivity.finishAffinity()`) is nested several lambda/when
scopes below the `val contextActivity = LocalContext.current as Activity`
declaration, suggesting a separate, pre-existing cross-scope variable-
declaration-lookup gap sits upstream of this fix for that specific shape.
Documented, not chased further in this pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L7ZonwYUh94VsuQphiHykF
There was a problem hiding this comment.
🟡 Changes recommended
Four unresolved moderate findings remain in smart-cast, cast nullability, and qualified-constructor resolution.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Improves Kotlin resolver inference for same-line smart casts, cast expressions, and qualified nested-class constructors.
Changes:
- Includes the current line in smart-cast scanning.
- Adds
as/as?expression type inference. - Preserves qualified nested-constructor names and adds regression tests.
File summaries
| File | Summary |
|---|---|
src/resolver/tests.rs |
Adds same-line smart-cast coverage. |
src/resolver/infer_lines.rs |
Expands smart-cast scanning. Moderate (3 votes): the line-wide scan can incorrectly retain a smart cast after a semicolon. |
src/queries.rs |
Adds the as_expression CST kind. |
src/indexer/infer/mod_tests.rs |
Adds cast and nested-constructor tests. |
src/indexer/infer/expr_type.rs |
Infers types from cast expressions. Moderate (3 votes): safe casts are inferred as non-null instead of nullable. |
src/indexer/infer/chain.rs |
Preserves qualified constructor names. Moderate (1 vote each): qualified names are rejected by the normal resolver gate, and formatted qualified calls are not normalized. |
Review details
Suppressed comments (2)
src/indexer/infer/chain.rs:855
- Returning
Outer.Builderhere does not reach the intended qualified resolver in the normal member-reference path.cst_symbolaccepts a CstQuery receiver only whenindexer.has_type_definition(...)succeeds, but nested symbols are indexed by their leaf name and the qualified resolver explicitly notes that no literalOuter.Buildersymbol exists; the new value is therefore rejected,receiver_typebecomesNone, and resolution falls back to the same global lookup. Update that receiver-type gate/normalization and add an end-to-endOuter.Builder(...).membertest alongside this fallback.
let type_name = if ctx.callee.kind() == KIND_NAV_EXPR {
ctx.callee
.utf8_text(ctx.bytes)
.map(str::to_owned)
.unwrap_or_else(|_| ctx.fn_name.to_owned())
src/indexer/infer/chain.rs:855
utf8_textpreserves source formatting, so validOuter . Builder(...)(or a newline/comment around.) produces whitespace/comment bytes intype_name.resolve_symbolsplits dotted names without normalizing those bytes, so this fallback cannot resolve the qualified type for formatted calls. Build the name from CST identifier segments instead of raw callee text, and add a formatted-qualified regression test.
let type_name = if ctx.callee.kind() == KIND_NAV_EXPR {
ctx.callee
.utf8_text(ctx.bytes)
.map(str::to_owned)
.unwrap_or_else(|_| ctx.fn_name.to_owned())
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+325
to
+328
| fn infer_as_expr_type(node: Node<'_>, bytes: &[u8]) -> Option<String> { | ||
| let count = node.named_child_count() as u32; | ||
| let type_node = node.named_child(count.checked_sub(1)?)?; | ||
| type_node.utf8_text_owned(bytes) |
| // line never even looked at it. | ||
| let mut brace_depth: usize = 0; | ||
| for i in (start..line_idx).rev() { | ||
| for i in (start..=line_idx).rev() { |
Hessesian
added a commit
that referenced
this pull request
Sep 14, 2026
Two real findings from Copilot's review of PR #319, both verified red-then-green: - infer_as_expr_type treated `as?` identically to `as`, always returning the bare target type. Kotlin's safe cast always yields a nullable result regardless of whether the target type itself carries a `?` in source -- `x as? Activity` is `Activity?`, never bare `Activity`. Now checks the as_expression's own operator token. - Making if_is_smart_cast's same-line scan inclusive (PR #319) covered the whole line regardless of column: `if (x is Y) x.use(); x.other()` -- a brace-less if only guards ONE statement, ending at the first top-level `;`. Threaded the cursor's column through smart_cast_type_at_line so the same-line case can decline once the access falls past that semicolon. Every other caller (multi-line bodies, no column available) passes None and keeps its existing behavior exactly. Verified: cargo test (1941 passed), cargo clippy -D warnings clean.
3 tasks
Hessesian
added a commit
that referenced
this pull request
Sep 14, 2026
Two real findings from Copilot's review of PR #319, both verified red-then-green: - infer_as_expr_type treated `as?` identically to `as`, always returning the bare target type. Kotlin's safe cast always yields a nullable result regardless of whether the target type itself carries a `?` in source -- `x as? Activity` is `Activity?`, never bare `Activity`. Now checks the as_expression's own operator token. - Making if_is_smart_cast's same-line scan inclusive (PR #319) covered the whole line regardless of column: `if (x is Y) x.use(); x.other()` -- a brace-less if only guards ONE statement, ending at the first top-level `;`. Threaded the cursor's column through smart_cast_type_at_line so the same-line case can decline once the access falls past that semicolon. Every other caller (multi-line bodies, no column available) passes None and keeps its existing behavior exactly. Verified: cargo test (1941 passed), cargo clippy -D warnings clean.
Hessesian
added a commit
that referenced
this pull request
Sep 14, 2026
…ast (#320) * fix(resolve): as? cast nullability, semicolon-bound same-line smart cast Two real findings from Copilot's review of PR #319, both verified red-then-green: - infer_as_expr_type treated `as?` identically to `as`, always returning the bare target type. Kotlin's safe cast always yields a nullable result regardless of whether the target type itself carries a `?` in source -- `x as? Activity` is `Activity?`, never bare `Activity`. Now checks the as_expression's own operator token. - Making if_is_smart_cast's same-line scan inclusive (PR #319) covered the whole line regardless of column: `if (x is Y) x.use(); x.other()` -- a brace-less if only guards ONE statement, ending at the first top-level `;`. Threaded the cursor's column through smart_cast_type_at_line so the same-line case can decline once the access falls past that semicolon. Every other caller (multi-line bodies, no column available) passes None and keeps its existing behavior exactly. Verified: cargo test (1941 passed), cargo clippy -D warnings clean. * fix(resolve): address PR #320 Copilot review findings All 8 findings from the automated review (3 posted inline, 5 more in the review body's suppressed-comments list), each verified red-then-green: - infer_as_expr_type: `as?` on a function-type target misrendered nullability onto the return type (`(String) -> Int?`) instead of the whole function value (`((String) -> Int)?`). A bare function type is the only Kotlin type-annotation shape starting with `(`, so that's a sufficient signal to wrap the whole thing before appending `?`. - Same function: use StrExt::is_nullable() instead of reimplementing the check with ends_with('?'). - if_is_smart_cast's same-line bound searched for the terminating `;` from the start of the physical line, not from after the if-condition's own `)` — an EARLIER statement's semicolon (`foo(); if (x is Y) x.use()`) was mistaken for this if's own terminator. Also, balanced braces from a trailing lambda on the same line (`if (x is Y) x.use { }`) were indistinguishable from an unrelated multi-line block and suppressed a valid narrow. Rewrote the bound as a proper forward scan from the if-condition's own end, depth-tracking parens/brackets/braces so only a genuinely top-level `;` counts as the statement's terminator. - Renamed the new `col` parameter/binding to `column` throughout (repo's no-abbreviated-names guideline). - constructor_fallback: the qualified-constructor-name fix used the callee's raw span text, which preserves source whitespace (`Outer . Builder(x)` produced spaced, unresolvable segments). Now builds the dotted name from clean identifier segments via collect_nav_segments. - Same function: the fix fired for EVERY navigation_expression callee, including a value-qualified call whose root is a lowercase variable (`factory.Builder(...)`), misclassifying a value path as a type path. Now requires the chain's root segment to itself be uppercase (a type) before treating the dotted text as a constructed type name, falling back to the bare leaf (the pre-existing behavior for this shape) otherwise. Verified: cargo test (1946 passed), cargo clippy -D warnings clean, and a resolution-accuracy scan against the real Moneta corpus confirms no regression on this work's own targets (`start`, `fragmentArguments`, `fragmentBundle` all still absent from the Gap top-20). Aggregate recall moved 91.4%→90.8% since the prior measurement; no single new anomalous entry appeared in the Gap/FilteredCandidate top lists tied to these changes, and this session has repeatedly measured swings of this size on an otherwise-unchanged binary (corpus-scan non-determinism), so this is treated as noise rather than a proven regression pending further evidence.
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.
Summary
Three fixes from a Gap-scout pass on the real Moneta corpus (
resolution-accuracybenchmark against main tip), each independently verified:start:if_is_smart_cast's backward scan (infer_lines.rs) excluded the cursor's own line —if (drawable is Animatable) drawable.start()puts the test and the access on one physical line, never inspected. Made the range inclusive.finishAffinity/general: no code path inferred a type fromas/as?cast expressions at all. Addedas_expressiontoinfer_expr_type_at_depth's CST dispatch (expr_type.rs) — the cast's own target type is the expression's type, verbatim.fragmentArguments/fragmentBundle:constructor_fallback(chain.rs) reported only the bare leaf name for a nested-class constructor call (Outer.Builder(...)→"Builder"), discarding the qualifier and colliding with every other same-named nested class in the corpus once that bare name hit a global lookup. Now uses the callee's full dotted text.Investigated, not implemented
A fourth candidate (
firstOrNull/filter/toString) was scoped from an initial hypothesis (generic-arg stripping invalue_path_anchor) that direct testing disproved:infer_variable_typealready strips generics before that function ever sees them, and tracing the benchmark's actual reference-classification path (resolve_identity,cst_symbol.rs) showed it passes a CST-resolved uppercasereceiver_typeinto qualified resolution — routing throughtype_path_anchors, notvalue_path_anchorat all (that function only serves the LSP cursor char-scan path, a different consumer). Left for a fresh, correctly-targeted investigation rather than forcing a fix based on the disproven premise.Test plan
cargo test— 1940 passed, 0 failedcargo clippy --all-targets -- -D warnings— cleanresolution-accuracy): recall 90.9%→91.4%, Gap total 8342→7652 (member-ref).startandfragmentArguments/fragmentBundleboth confirmed gone from the Gap top-20.finishAffinitydid NOT move despite the as-cast fix being independently verified correct in isolation — the real call site sits several lambda/whenscopes below itsval ... as Activitydeclaration, pointing at a separate, pre-existing cross-scope variable-lookup gap upstream of this fix. Documented in the commit message, not chased further here.🤖 Generated with Claude Code
https://claude.ai/code/session_01L7ZonwYUh94VsuQphiHykF