Skip to content

fix(resolve): same-line if-is smart-cast, as-cast type inference, nested-ctor qualifier loss - #319

Merged
Hessesian merged 1 commit into
mainfrom
fix/gap-scout-findings-batch1
Sep 14, 2026
Merged

Hessesian merged 1 commit into
mainfrom
fix/gap-scout-findings-batch1

Conversation

@Hessesian

Copy link
Copy Markdown
Owner

Summary

Three fixes from a Gap-scout pass on the real Moneta corpus (resolution-accuracy benchmark 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 from as/as? cast expressions at all. Added as_expression to infer_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 in value_path_anchor) that direct testing disproved: infer_variable_type already 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 uppercase receiver_type into qualified resolution — routing through type_path_anchors, not value_path_anchor at 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 failed
  • cargo clippy --all-targets -- -D warnings — clean
  • Every fix has a red-before/green-after test; two required a decoy fixture to avoid a global bare-name fallback masking the real behavior (documented inline)
  • Live-verified against the real Moneta corpus (resolution-accuracy): recall 90.9%→91.4%, Gap total 8342→7652 (member-ref). 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 in isolation — the real call site sits several lambda/when scopes below its val ... as Activity declaration, 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

…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
@Hessesian
Hessesian requested a lite review from Copilot September 14, 2026 10:41
@Hessesian
Hessesian merged commit 203dce8 into main Sep 14, 2026
5 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.Builder here does not reach the intended qualified resolver in the normal member-reference path. cst_symbol accepts a CstQuery receiver only when indexer.has_type_definition(...) succeeds, but nested symbols are indexed by their leaf name and the qualified resolver explicitly notes that no literal Outer.Builder symbol exists; the new value is therefore rejected, receiver_type becomes None, and resolution falls back to the same global lookup. Update that receiver-type gate/normalization and add an end-to-end Outer.Builder(...).member test 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_text preserves source formatting, so valid Outer . Builder(...) (or a newline/comment around .) produces whitespace/comment bytes in type_name. resolve_symbol splits 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.
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.
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.

2 participants