fix(resolve): extension-registry follow-up cluster (4 fixes) - #322
Merged
Merged
Conversation
Plan for the 5 items parked out of PR #321: resolve_chain tail reorder for KOTLIN_BUILTIN_TYPE_PLATFORM_EQUIVALENTS names (List/MutableList/ MutableSet/MutableMap/MutableCollection/MutableIterable/MutableIterator losing a tie-break or a unique-match shortcut to a kotlin-stdlib JAR decoy), Kotlin-default-import-package awareness for extension-scope checking (isNotEmpty/forEach rejected as out of scope), the identical first-match bug PR #321 already fixed elsewhere still present in implicit_receiver_extension_match, the same family in find_extension_fn_return_type_scoped plus a separate early-abort bug, and a closed decision on rename.rs's new ambiguous-refusal behavior. Independently critiqued (blocker found: Task 4's original test fixtures were false-green and one was unfixable as designed) and revised with the blocker's fix verified by an actual red-then-green test run before finalizing, not just designed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…me-named decoy resolve_chain's four IO-policy tails (Full, NoRg, IndexOnly, HierarchyAmbiguitySafe) each fell back to resolve_kotlin_builtin_type_platform_equivalent only after the global-definitions lookup came up empty. For 7 of the 22 names in KOTLIN_BUILTIN_TYPE_PLATFORM_EQUIVALENTS that lookup does not come up empty -- it returns a same-named decoy from inside a JAR, either via default_kotlin_import_tie_break narrowing List's 32 candidates to a body-less kotlin.collections decoy, or via a unique-match shortcut for the five Mutable* names, each of which has exactly one same-named candidate (a kotlin-gradle-plugin decoy). Anchoring on a decoy with no compiled body leaves the type with no walkable supertype chain, so no member or extension lookup downstream can ever reach it. Reorder all four tails to try the platform equivalent first, below steps 1-4.5 (local declaration, imports, same package, star imports, class hierarchy) so a real workspace declaration still wins. The reorder puts an unmemoized detect_android_sdk_source_paths (local.properties read + read_dir + a log::info! per success) on the hot path of every built-in-name resolution. Add a path-keyed memo (android_sdk_source_roots) in the same change, keyed on the workspace root since WorkspaceRoot::set can change it mid-session. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
extension_is_in_scope only knows same-package and explicit-import visibility, so a correctly-registered extension declared in one of Kotlin's own default-import packages (kotlin.text, kotlin.collections, ...) was rejected as out-of-scope at every real call site, since no Kotlin file ever writes e.g. `import kotlin.text.isNotEmpty`. Adds extension_entry_is_in_scope, a wrapper used only at the two extension-registry call sites plus the extension return-type scoped lookup, rather than editing extension_is_in_scope directly -- four of its six callers aren't about extension scoping at all, and widening the shared predicate would change bare-name JAR candidate preference corpus-wide. Gated on the calling file's language: resolve_qualified and the resolution-accuracy benchmark both run over indexed .java files too, and a Java file never implicitly imports kotlin.*. Real-corpus measurement (Moneta android, before/after release binaries): member Gap 7578->7188 (-390), FilteredCandidate 6553->6868 (+315, expected -- more in-scope candidates now reach the shape filter, pointing at follow-up work there), member recall 91.4%->91.5%. isNotEmpty and the 0-arg firstOrNull shape both left the Gap top-20 as predicted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…l shape wants implicit_receiver_extension_match had the same first-match bug PR #321 fixed elsewhere in extension.rs: it looked up the declaring symbol for each registry entry via .find(extension_declaration_matches), a predicate that only checks (name, receiver, container) -- identical across every overload of the same name -- so every entry in the loop resolved to the same first-declared overload regardless of which entry was actually being iterated, making every overload but the first unreachable through this entry point. Split select_extension_symbol_range's existing disambiguation (filter by extension_declaration_matches, then pick the exact detail match, falling back to the first) out into select_extension_symbol, returning the whole SymbolEntry instead of just its Range. select_extension_symbol_range is now a two-line wrapper over it, and implicit_receiver_extension_match uses the new helper so its per-entry arity check runs against the right overload. Also documents Decision D5 in rename.rs: PR #321 means a same-arity extension collision now correctly refuses to rename rather than silently picking one candidate -- reviewed and kept as-is, not a bug. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… lookup find_extension_fn_return_type_scoped's truncated-detail fallback picked the FIRST declaration matching (name, receiver, container) in the declaring file -- a predicate identical across every overload of the same extension -- so a second overload's return type was unreachable no matter which registry entry was being processed. Route it through select_extension_symbol (PR #321), which disambiguates by matching the declaration's full signature against the registry entry's own detail. Separately, both `?` operators in this loop (file lookup, declaration lookup) returned None from the whole function instead of continuing to the next entry, so a single registry entry whose declaring file wasn't loaded silently hid every entry after it in iteration order. Both are now `continue`. Not fixed: arity-aware overload selection needs a CallShape threaded through Resolver::method_return_type -- 482 registry groups on the Moneta corpus have differing-return overloads, and that's its own plan. find_extension_fn_return_type_global has no ExtensionEntry/detail to disambiguate with and is not on a production path (its only caller always passes Some(uri)), so it's left as-is with a comment recording why. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ency impact The final whole-branch review of this cluster ran the cumulative real-corpus measurement no single task had run (878cc4e..b0ad175 in one pass) and found FilteredCandidate rose 449, not the -167 the individual tasks' pairwise deltas would suggest composing to. Traced mechanism: Task 2's extension-scope widening feeds more real candidates into resolve_extension_in_scope, which is exactly the function rename's ambiguity check depends on. The refusal's own reasoning (PR #321 correctness) is unchanged; only its frequency is. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Critical visibility and Java-language fallback issues remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR improves Kotlin extension resolution, platform-type fallback, overload handling, and inference robustness.
Changes:
- Prioritizes platform equivalents and memoizes SDK source discovery.
- Adds default-import-aware extension scoping.
- Improves overload selection and continues past unusable inference entries.
- Adds regression tests and supporting documentation.
File summaries
| File | Reviewed changes |
|---|---|
src/resolver/tests.rs |
Resolver regression coverage. |
src/resolver/resolve.rs |
Platform fallback resolution. |
src/resolver/platform_types.rs |
SDK source-path memoization. |
src/resolver/infer.rs |
Extension scope and inference fixes. |
src/resolver/infer_tests.rs |
Inference regression tests. |
src/resolver/imports.rs |
Default-import package detection. |
src/resolver/extension.rs |
Overload-aware extension selection. |
src/features/rename.rs |
Ambiguity refusal documentation. |
docs/superpowers/plans/2026-09-16-extension-follow-ups-plan.md |
Design and measurement documentation. |
Review details
Suppressed comments (5)
docs/superpowers/plans/2026-09-16-extension-follow-ups-plan.md:1187
- This plan snippet asserts
Some("T?"), but the checked-in regression test intentionally strips trailing nullability and assertsSome("T")(src/resolver/tests.rs:11465-11470). Following the plan's example would produce a failing test even when the scope fix is correct; update the snippet to match the actual helper contract.
Some("T?".to_owned()),
docs/superpowers/plans/2026-09-16-extension-follow-ups-plan.md:367
- This plan repeats the same inaccurate caller-count rationale as the implementation comment:
nullable_call_diagnosticsis an extension caller, so only two of the six callers are unrelated to extensions. Rewrite the explanation in terms of extension-registry consumers versus the three callers with different reachability contracts.
/// Deliberately NOT folded into [`extension_is_in_scope`] itself: four of that
/// function's six callers are not about extensions at all
/// (`candidate_declaration_is_reachable`, `Indexer::jar_candidate_is_reachable`
/// and `nullable_call_diagnostics`' stricter own rule), and widening the shared
/// predicate would change bare-name JAR candidate preference corpus-wide.
src/resolver/extension.rs:47
- This helper is now called once per registry entry by
implicit_receiver_extension_match, but it materializes every shape-matching declaration into a newVecon each iteration. That adds an allocation and forces a full scan of all overloads for every entry; the old path stopped at the first match without either cost. Use two non-allocatingfindpasses (exact detail first, then the first shape match) or otherwise avoid collecting the matches.
let declaring_symbols: Vec<_> = file_data
.symbols
.iter()
.filter(|symbol| {
crate::resolver::infer::extension_declaration_matches(
symbol,
name,
receiver_base,
container,
)
})
.collect();
src/resolver/infer.rs:1881
- This rationale says four of the six callers are unrelated to extensions, but the cited list contains only two such callers;
nullable_call_diagnosticsis itself an extension caller with a stricter contract. The inaccurate count makes the wrapper's blast-radius justification misleading. State that only the three extension-registry consumers should use the wrapper and that the other three callers have different reachability contracts.
/// Deliberately NOT folded into [`extension_is_in_scope`] itself: four of that
/// function's six callers are not about extensions at all
/// (`candidate_declaration_is_reachable`, `Indexer::jar_candidate_is_reachable`
/// and `nullable_call_diagnostics`' stricter own rule), and widening the shared
/// predicate would change bare-name JAR candidate preference corpus-wide.
src/resolver/platform_types.rs:187
- The memo is keyed only by the path, while
WorkspaceRoot::setbumps its generation even when the same path is configured. If SDK sources orlocal.propertieschange after the first probe, reinitializing or rescanning that same workspace keeps returning the old cached paths, so built-in resolution remains wrong until the root string changes. Key this cache by the workspace generation or invalidate it when same-root configuration changes.
if let Some((cached_root, cached_paths)) = detected.as_ref() {
if cached_root == workspace_root {
return cached_paths.clone();
- Files reviewed: 9/9 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
+1897
to
+1902
| let caller_is_kotlin = crate::Language::from_path(from_uri.as_str()) == crate::Language::Kotlin; | ||
| let entry_is_default_imported = entry | ||
| .package | ||
| .as_ref() | ||
| .is_some_and(|package| crate::resolver::imports::is_default_import_package(package)); | ||
| caller_is_kotlin && entry_is_default_imported |
Comment on lines
+399
to
+401
| let platform_equivalent = resolve_kotlin_builtin_type_platform_equivalent(indexer, name); | ||
| if !platform_equivalent.is_empty() { | ||
| return platform_equivalent; |
- src/resolver/extension.rs: select_extension_symbol no longer collects
every shape-matching declaration into a Vec before checking for an
exact detail match. Two non-allocating iterator passes instead —
real overhead since this is now called once per registry entry from
implicit_receiver_extension_match's own loop.
- src/resolver/infer.rs, docs/.../2026-09-16-extension-follow-ups-plan.md:
extension_entry_is_in_scope's doc comment claimed "four of six
callers are unrelated to extensions" naming only three, one of which
(nullable_call_diagnostics's extension_in_scope_here) actually IS
extension-registry-shaped (takes a real ExtensionEntry), just with
its own stricter member-extension rule layered on top. Corrected to
name the two genuinely unrelated callers (receiver-less by-name
fallbacks with no ExtensionEntry at all) and describe the third
accurately, tightened to stay concise per the same doc-comment
discipline AGENTS.md already asks for.
- Same file: a stale test-expectation snippet in the plan doc
(Some("T?")) corrected to match the actual helper contract
(Some("T") -- extract_return_type_from_detail strips nullable),
matching what the real test in tests.rs already asserts.
D1's memo-staleness concern (same root path, different SDK config)
was already explicitly argued in platform_types.rs's own doc comment
with a real precedent (the workspace scan has the same one-time-read
staleness) -- left as-is, not re-litigated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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 #321 — fixes the 5 items that PR deliberately parked to keep its own measurement
attributable. Four tasks:
resolve_chain's four IO-policy tails now check the Kotlin built-in→platform-typefallback (
resolve_kotlin_builtin_type_platform_equivalent) before trusting alookup_definitions/tie-break result, for the 7 of 22 names inKOTLIN_BUILTIN_TYPE_PLATFORM_EQUIVALENTSthat were losing to a same-named kotlin-stdlib JARdecoy (
List,MutableList,MutableSet,MutableMap,MutableCollection,MutableIterable,MutableIterator). Ships a path-keyed memo fordetect_android_sdk_source_pathsin the same commit — the reorder puts an unmemoizedfilesystem-scanning call on a much hotter path, and it turns out that call was already being
hit ~53k times per corpus run even before this branch.
extension_entry_is_in_scopewrapper (the sharedextension_is_in_scopepredicate, which has 4 unrelated callers, is left untouched) adds Kotlin-default-import-package
awareness, so
kotlin.text/kotlin.collectionsextensions likeisNotEmpty/forEacharerecognized as in-scope without needing an explicit import.
implicit_receiver_extension_matchgets the same overload-disambiguation fix fix(resolve): extension-registry overload-collapse family (4 fixes) #321shipped elsewhere (new
select_extension_symbolhelper, split out of the existingselect_extension_symbol_range). Also documents a closed decision:renamenow correctlyrefuses on a same-arity extension collision (a consequence of fix(resolve): extension-registry overload-collapse family (4 fixes) #321's own fix) — one comment,
no behavior change.
find_extension_fn_return_type_scopedgets the same disambiguation fix (reusingTask 3's helper) plus a separate bug: two early-return
?s that used to abort the wholefunction on one unloaded file now
continuepast that entry instead.Full design, evidence, and two rounds of independent adversarial review: see
docs/superpowers/plans/2026-09-16-extension-follow-ups-plan.md.Real-corpus measurement — cumulative, not pairwise
Each task measured against its own immediate predecessor. The final whole-branch review ran the
number nobody had:
main(before this branch) → this branch's tip, one pass, same warm cache.The individual tasks' pairwise deltas do not compose — naively summing them suggests an
improvement; the real cumulative number is a real, understood cost:
Why FilteredCandidate rose, traced not guessed: Task 1 correctly anchors
Liston the realjava.util.List(was a supertype-less JAR decoy), which letsresolve_qualified's supertypewalk reach
Collection/IterableforList-typed receivers for the first time. Task 2 makeskotlin.collections-packaged extensions at those levels in-scope. Compounded, more realcandidates now survive to the walk's own by-design "both returned, caller's shape filter
disambiguates" policy — and land in
FilteredCandidatewhen that filter can't narrow to one.Neither task's own diff review could see this; it only shows up at whole-branch scale.
Gap top-20 names that left as intended:
firstOrNull(−122),isNotEmpty(−95),forEach(−80),
filter(−65),map(−64),contains(−61).<bare>.List's 2-distinct-locationambiguity also disappeared. The member-recall percentage staying flat is a reclassification
artifact (bare refs fell by exactly 887 — the same denominator trap this project has hit before),
not a null result — absolute counts (Gap −317, CstResolved +755) are the honest read.
This is a real, accepted cost, not a bug: reaching more real candidates that a shape filter
genuinely can't disambiguate is more honest than the previous behavior (many of those receivers
resolved to nothing at all before this branch).
rename.rs's existing ambiguous-refusalcomment was updated to reflect the new frequency, not just restate the old reasoning.
Follow-ups found, deliberately not fixed here
Full,NoRg,HierarchyAmbiguitySafe) ship with nodirect test coverage — only
IndexOnlyis tested/benchmarked. Low measured risk (all fourarms' inserted code is identical), a
NoRgtest is a cheap named follow-up.Language::from_pathcall runs before a cheap array scan in the new scope-checkgate, on a hot per-entry loop — same category Task 1 already fixed elsewhere, Minor severity.
select_extension_symbol's exact-match disambiguation silently degrades to first-match whentwo overloads' truncated
detailstrings collide — worth a doc-comment sentence.implicit_receiver_extension_matchpicks the first shape-accepting entry on genuine ambiguityrather than declining (pre-existing asymmetry with fix(resolve): extension-registry overload-collapse family (4 fixes) #321's decline-on-ambiguity policy
elsewhere, made more reachable by Task 3's own fix).
extension-return-type selection needing a
CallShapethreaded through theResolvertrait —this branch's own result makes that more urgent, not less.
Test plan
cargo test— 1966 passed, 0 failed, 3 ignored (plus integration suites, all green)cargo clippy --all-targets -- -D warnings— cleancargo fmt -- --check— cleanwas the site of a real blocker found by an independent adversarial plan review (false-green
tests, one unfixable as originally designed) — rebuilt, and independently re-verified
red-then-green by both the plan's own drafting agent and the task reviewer, each in a
separate throwaway worktree
real-corpus number above is that review's own finding
🤖 Generated with Claude Code