fix(resolve): as? cast nullability, semicolon-bound same-line smart cast - #320
Merged
Merged
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved moderate issues remain in safe-cast nullability, same-line smart-cast boundaries, and qualified constructor inference.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Improves Kotlin safe-cast type inference, same-line smart-cast handling, and nested constructor resolution.
Changes:
- Adds nullable
as?type inference. - Bounds same-line smart casts by cursor position and semicolons.
- Preserves qualified constructor names and adds regression tests.
File summaries
| File | Summary and findings |
|---|---|
src/resolver/tests.rs |
Adds and updates smart-cast regression tests. |
src/resolver/infer.rs |
Threads cursor columns into smart-cast inference. Nit (1 vote): rename col to column. |
src/resolver/infer_lines.rs |
Implements same-line smart-cast handling. Moderate (3 votes): semicolon detection can use earlier or nested delimiters. Moderate (1 vote): trailing-lambda braces can suppress valid narrowing. Nit (3 votes): rename col to column. |
src/queries.rs |
Adds the as_expression CST kind. |
src/indexer/infer/mod_tests.rs |
Adds cast and nested-constructor inference tests. |
src/indexer/infer/expr_type.rs |
Infers cast expression types. Moderate (2 votes): function-type safe casts must wrap the whole function type in nullability. Nit (1 vote): use StrExt::is_nullable(). |
src/indexer/infer/chain.rs |
Preserves qualified constructor names. Moderate (1 vote): normalize spaced qualifier segments. Moderate (1 vote): avoid treating value-qualified calls as type paths. |
Review details
Suppressed comments (5)
src/indexer/infer/chain.rs:854
utf8_textpreserves source whitespace, so a legal spaced qualifier such asOuter . Builder(x)producesOuter . Builder. The dotted resolver splits that into segments containing spaces and cannot resolve the nested type. Build this name from normalized CST identifier segments (or trim each segment) rather than using the raw callee span, and add a spaced-qualifier regression test.
let type_name = if ctx.callee.kind() == KIND_NAV_EXPR {
ctx.callee
.utf8_text(ctx.bytes)
.map(str::to_owned)
src/indexer/infer/chain.rs:855
- This branch runs for every
KIND_NAV_EXPR, including value-qualified calls such asfactory.Builder(...), not only type-qualified nested constructors. If indexed method resolution misses an uppercase method, returningfactory.Builderas the inferred type misclassifies a value path as a type path; preserve the dotted text only when the callee is actually rooted at an uppercase/type identifier, and otherwise avoid or retain the appropriate leaf 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/expr_type.rs:337
- Use the canonical
StrExt::is_nullable()helper instead of reimplementing nullability withends_with('?'). The helper is the repository-wide nullable check and handles trailing whitespace consistently (src/str_ext.rs:25-30).
if is_safe_cast && !type_text.ends_with('?') {
src/resolver/infer.rs:260
- The new
colparameter is an abbreviation; use the full namecolumn(and update its uses) so this position-sensitive API follows the repository's no-abbreviated-names guideline.
fn smart_cast_narrowed_type(
indexer: &Indexer,
name: &str,
uri: &Url,
line: u32,
col: Option<u32>,
) -> Option<String> {
src/resolver/infer_lines.rs:793
- Balanced braces on the same line are treated as an
ifblock and suppress the narrowing. In validif (value is Target) value.use { },extract_if_is_typefinds the test butopens == closes, so this new inclusive path still reports no cast for the member access. Distinguish braces belonging to a trailing lambda from anifbody (or use the CST) and add a regression test.
// A brace-less `if` on the CURSOR's own line covers only
// ONE statement, ending at the first top-level `;` — an
// access after that `;` on the same physical line
// belongs to a later, unguarded statement (Copilot
// review finding). Only checked for this exact shape:
- Files reviewed: 7/7 changed files
- Comments generated: 3
- 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
+337
to
+338
| if is_safe_cast && !type_text.ends_with('?') { | ||
| Some(format!("{type_text}?")) |
Comment on lines
+799
to
+801
| lines[i].find(';').is_some_and(|byte_pos| { | ||
| let semi_col = lines[i][..byte_pos].encode_utf16().count() as u32; | ||
| col > semi_col |
Comment on lines
+689
to
+690
| line: u32, | ||
| col: Option<u32>, |
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.
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.
Hessesian
force-pushed
the
fix/gap-scout-findings-batch1
branch
from
September 14, 2026 11:25
83b6bf7 to
aef3ef2
Compare
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
Follow-up to #319 (already merged): two real findings from Copilot's review of that PR, both verified red-then-green.
infer_as_expr_typetreatedas?identically toas, always returning the bare target type. Kotlin's safe cast always yields a nullable result regardless of whether the target type itself carries a?—x as? ActivityisActivity?, never bareActivity. Now checks theas_expression's own operator token.if_is_smart_cast's same-line scan inclusive (fix(resolve): same-line if-is smart-cast, as-cast type inference, nested-ctor qualifier loss #319) covered the whole line regardless of column:if (x is Y) x.use(); x.other()— a brace-lessifonly guards ONE statement, ending at the first top-level;. Threaded the cursor's column throughsmart_cast_type_at_lineso the same-line case can decline once the access falls past that semicolon. Every other caller (multi-line bodies, no column available) passesNoneand keeps its existing behavior exactly.Test plan
cargo test— 1941 passed, 0 failedcargo clippy --all-targets -- -D warnings— clean