Skip to content

fix(resolve): extension-registry follow-up cluster (4 fixes) - #322

Merged
Hessesian merged 7 commits into
mainfrom
fix/extension-registry-follow-ups
Sep 18, 2026
Merged

Hessesian merged 7 commits into
mainfrom
fix/extension-registry-follow-ups

Conversation

@Hessesian

Copy link
Copy Markdown
Owner

Summary

Follow-up to #321 — fixes the 5 items that PR deliberately parked to keep its own measurement
attributable. Four tasks:

  • Task 1: resolve_chain's four IO-policy tails now check the Kotlin built-in→platform-type
    fallback (resolve_kotlin_builtin_type_platform_equivalent) before trusting a
    lookup_definitions/tie-break result, for the 7 of 22 names in
    KOTLIN_BUILTIN_TYPE_PLATFORM_EQUIVALENTS that were losing to a same-named kotlin-stdlib JAR
    decoy (List, MutableList, MutableSet, MutableMap, MutableCollection,
    MutableIterable, MutableIterator). Ships a path-keyed memo for
    detect_android_sdk_source_paths in the same commit — the reorder puts an unmemoized
    filesystem-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.
  • Task 2: new extension_entry_is_in_scope wrapper (the shared extension_is_in_scope
    predicate, which has 4 unrelated callers, is left untouched) adds Kotlin-default-import-package
    awareness, so kotlin.text/kotlin.collections extensions like isNotEmpty/forEach are
    recognized as in-scope without needing an explicit import.
  • Task 3: implicit_receiver_extension_match gets the same overload-disambiguation fix fix(resolve): extension-registry overload-collapse family (4 fixes) #321
    shipped elsewhere (new select_extension_symbol helper, split out of the existing
    select_extension_symbol_range). Also documents a closed decision: rename now correctly
    refuses 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.
  • Task 4: find_extension_fn_return_type_scoped gets the same disambiguation fix (reusing
    Task 3's helper) plus a separate bug: two early-return ?s that used to abort the whole
    function on one unloaded file now continue past 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:

metric before after Δ
member recall 91.6% 91.6% flat (masks +887 member refs — see note)
member Gap (actionable) 7486 7169 −317
FilteredCandidate 6305 6754 +449
wall clock 4:48.57 5:00.59 +12s (+4.2%)

Why FilteredCandidate rose, traced not guessed: Task 1 correctly anchors List on the real
java.util.List (was a supertype-less JAR decoy), which lets resolve_qualified's supertype
walk reach Collection/Iterable for List-typed receivers for the first time. Task 2 makes
kotlin.collections-packaged extensions at those levels in-scope. Compounded, more real
candidates now survive to the walk's own by-design "both returned, caller's shape filter
disambiguates" policy — and land in FilteredCandidate when 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-location
ambiguity 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-refusal
comment was updated to reflect the new frequency, not just restate the old reasoning.

Follow-ups found, deliberately not fixed here

  • Three of Task 1's four reordered tails (Full, NoRg, HierarchyAmbiguitySafe) ship with no
    direct test coverage — only IndexOnly is tested/benchmarked. Low measured risk (all four
    arms' inserted code is identical), a NoRg test is a cheap named follow-up.
  • An allocating Language::from_path call runs before a cheap array scan in the new scope-check
    gate, 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 when
    two overloads' truncated detail strings collide — worth a doc-comment sentence.
  • implicit_receiver_extension_match picks the first shape-accepting entry on genuine ambiguity
    rather 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).
  • The FilteredCandidate rise points at the plan's own already-scoped-out follow-up: arity-aware
    extension-return-type selection needing a CallShape threaded through the Resolver trait —
    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 — clean
  • cargo fmt -- --check — clean
  • Each task independently reviewed (spec compliance + code quality); Task 4's fixture design
    was 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
  • Final whole-branch review (opus): ready to merge, no Critical findings, the cumulative
    real-corpus number above is that review's own finding

🤖 Generated with Claude Code

Hessesian and others added 6 commits September 16, 2026 09:58
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>

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

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 asserts Some("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_diagnostics is 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 new Vec on 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-allocating find passes (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_diagnostics is 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::set bumps its generation even when the same path is configured. If SDK sources or local.properties change 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 thread src/resolver/infer.rs
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 thread src/resolver/resolve.rs
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>
@Hessesian
Hessesian merged commit e64f43f into main Sep 18, 2026
4 checks passed
@Hessesian
Hessesian deleted the fix/extension-registry-follow-ups branch September 18, 2026 08:09
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