Skip to content

Fix #19664: Static extension overload resolution for generic types#19736

Open
T-Gro wants to merge 2 commits into
mainfrom
fix/19664-extension-overloads
Open

Fix #19664: Static extension overload resolution for generic types#19736
T-Gro wants to merge 2 commits into
mainfrom
fix/19664-extension-overloads

Conversation

@T-Gro
Copy link
Copy Markdown
Member

@T-Gro T-Gro commented May 14, 2026

Fixes #19664

When resolving Type<TArg>.Member(...), the name resolver uses LookupIsInstance.Yes (dot-expression form) but finds static intrinsic members. The extension member lookup then also filters for instance-only extensions, missing the static extension overloads entirely.

The fix detects this mismatch and relaxes the filter to LookupIsInstance.Ambivalent so static extensions are included in overload resolution.

type StaticGeneric<'T> =
    static member Bar() = ()

[<AutoOpen>]
module StaticGenericExtensions =
    type StaticGeneric<'T> with
        static member Bar(_: int) = ()  // Previously: FS0505

StaticGeneric<int>.Bar(42)  // Now resolves correctly

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 14, 2026

❗ Release notes required


✅ Found changes and release notes in following paths:

Change path Release notes path Description
src/Compiler docs/release-notes/.FSharp.Compiler.Service/11.0.100.md

@T-Gro T-Gro requested a review from abonie May 14, 2026 14:15
@T-Gro T-Gro added AI-Auto-Resolve-CI Opt-in: LabelOps triages CI failures on this PR every 3h AI-Auto-Resolve-Conflicts Opt-in: LabelOps merges main into this PR and resolves conflicts every 3h labels May 14, 2026
@T-Gro T-Gro enabled auto-merge (squash) May 14, 2026 14:16
@github-actions github-actions Bot added the AI-Tooling-Check-Bypassed Tooling check: non-fork PR, not diff-analyzed label May 14, 2026
@github-actions github-actions Bot mentioned this pull request May 14, 2026
@github-actions
Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Bypassed (non-fork)

(scanned-sha)1d647200b286e43951fb9462fcf29579bc3809d9(/scanned-sha)

Generated by PR Tooling Safety Check · ● 1.9M ·

Copy link
Copy Markdown
Member Author

@T-Gro T-Gro left a comment

Choose a reason for hiding this comment

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

Review – well-targeted fix with clear comments, good regression test, and correct release notes. LGTM.

Summary of the change: When resolving Type<TArg>.Member(...), the resolver enters via dot-expression form (LookupIsInstance.Yes) but TryFindIntrinsicNamedItemOfType returns all matching methods regardless of instance/static. The extension method lookup then applies the strict instance-only filter, missing static extensions. The fix detects when the intrinsic set contradicts the filter and relaxes to Ambivalent, allowing static extensions to participate in overload resolution.

Minor observations (non-blocking):

  1. Ambivalent branch in the isAmbivalent check – The LookupIsInstance.Ambivalent -> true case means List.exists returns true for any non-empty minfos, then re-assigns isInstanceFilter = Ambivalent (already its value). This is harmless but reads slightly confusingly. You could simplify to only check the Yes/No cases:
    \\ sharp
    let isInstanceFilter =
    match isInstanceFilter with
    | LookupIsInstance.Ambivalent -> isInstanceFilter
    | LookupIsInstance.Yes when minfos |> List.exists (fun m -> not m.IsInstance) ->
    LookupIsInstance.Ambivalent
    | LookupIsInstance.No when minfos |> List.exists (fun m -> m.IsInstance) ->
    LookupIsInstance.Ambivalent
    | _ -> isInstanceFilter
    \
    Purely a readability suggestion – the current code is functionally correct.

  2. Property-branch symmetry – The PropertyItem arm (line ~2818) and the RFC-1137 extension-method lookup (line ~2864) both use isInstanceFilter without this relaxation. If a similar scenario can occur with static extension properties on generic types via explicit type args, the same fix may be needed there. Not required for this PR though.

  3. Release-notes entry – minor: once merged, add the PR link ([PR #19736](...)) to match the other entries in the file.

@T-Gro T-Gro added the AI-reviewed PR reviewed by AI review council label May 18, 2026
T-Gro and others added 2 commits May 19, 2026 11:59
…ad resolution with explicit type args)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…r (TDD green)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@T-Gro T-Gro force-pushed the fix/19664-extension-overloads branch from 1d64720 to 3dcc376 Compare May 19, 2026 10:18
@T-Gro
Copy link
Copy Markdown
Member Author

T-Gro commented May 19, 2026

/azp run

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

Copy link
Copy Markdown
Member

@abonie abonie left a comment

Choose a reason for hiding this comment

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

Expert AI review - see inline comments. The fix is small and targeted at the reported symptom, but (a) the symmetric LookupIsInstance.No arm is changed without motivation or coverage, (b) the root cause (dot-expression form classifying Type<TArg>.Member as LookupIsInstance.Yes) is patched at the candidate-gathering site rather than upstream, and (c) the --nowarn:1125 suppression in the regression test deserves justification. No language-feature gate is needed (this is a bugfix to existing behavior). No binary-compat or codegen concerns.

| LookupIsInstance.Ambivalent -> isInstanceFilter
| LookupIsInstance.Yes when minfos |> List.exists (fun m -> not m.IsInstance) ->
LookupIsInstance.Ambivalent
| LookupIsInstance.No when minfos |> List.exists (fun m -> m.IsInstance) ->
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Overload Resolution / Test Coverage - Behavioral, medium] This LookupIsInstance.No arm relaxes the extension filter on every module-qualified or unqualified expression resolution (entry points at lines 3063 and 3199) whenever the intrinsic candidate set for the name contains any instance member. That is a much broader trigger than the reported bug (#19664 only concerns Type<TArg>.Member via ResolveExprDotLongIdent, which uses LookupIsInstance.Yes).

Consequences worth justifying:

  1. For a call like M.Foo(x) where T.Foo has both static and instance intrinsic overloads, in-scope instance extensions named Foo will now be added to the candidate set. Previously they were filtered out. Overload resolution should reject them on receiver-shape mismatch in well-typed code, but the candidate-set expansion can change error wording (FS0041 ambiguity instead of FS0501) and can in principle change selection in SRTP / weak inference contexts.
  2. There is no test covering this arm. Per the repo's review rules (every behavioral change has a corresponding test), please either (a) add a positive + negative test for the No arm, or (b) restrict the relaxation to the Yes case actually motivated by the issue and leave a TODO for No.

The Yes-only fix is the minimal change required to close #19664; expanding to No is a latent behavior change without coverage.

// resolved as a dot-expression with LookupIsInstance.Yes but yields
// static intrinsic members), relax the filter for the extension lookup
// so static extension members are also considered. See issue #19664.
let isInstanceFilter =
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Type System / Name Resolution - design, medium] This change patches the symptom at the candidate-gathering site, not the root cause. The dot-expression Type<TArg>.Member is classified as LookupIsInstance.Yes by ResolveExprDotLongIdent (line 4185) even when the LHS denotes a type, not an instance. The adhocDotSearchAll fallback at line 4204 already retries with Ambivalent - but only when the first lookup fails. When intrinsic statics exist for the name, the first lookup succeeds with a meth group that excludes static extensions, the fallback is skipped, and overload resolution later raises FS0505.

Consider whether the right fix is upstream: either (i) classify Type<TArg>.Member as Ambivalent in ResolveExprDotLongIdent when the LHS resolves to a type expression, or (ii) take the fallback path whenever the successful result yields no compatible overload. The current fix permanently couples extension-filter relaxation to the shape of the intrinsic set, which is fragile (see the ExcludeHiddenOfMethInfos comment) and silently changes behavior on the No path that has nothing to do with this bug.

| Some(MethodItem msets) when isLookUpExpr ->
let minfos = msets |> ExcludeHiddenOfMethInfos g ncenv.amap m

// If the intrinsic candidate set already disagrees with the strict
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Type System - low/medium] The trigger condition is computed after ExcludeHiddenOfMethInfos (line 2875). If an intrinsic overload of the name is hidden by a derived override, minfos will not reflect what is actually present on the type, and the relaxation can fail to fire (false negative) or fire spuriously (false positive) relative to what overload resolution will see. At minimum, leave a comment explaining why hidden-exclusion is the right input for the trigger check; ideally evaluate against the unfiltered msets for the trigger, while still emitting minfos into the meth group.

| LookupIsInstance.No when minfos |> List.exists (fun m -> m.IsInstance) ->
LookupIsInstance.Ambivalent
| _ -> isInstanceFilter

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Diagnostic Quality - low] Worth verifying: when no overload matches after this relaxation, does the diagnostic still point at the same set of candidates as before? FS0501 (member does not take N arguments) vs FS0505 (is not a static method) vs FS0041 (overload ambiguity) can swap depending on the meth-group contents. Add a negative test (e.g., StaticGeneric<int>.Bar(1, 2, 3) - no overload of arity 3) and pin the expected diagnostic so future regressions surface.

StaticGeneric<int>.Bar(42) // regressed: extension, 1 arg, see issue 19664 (FS0505)
StaticGeneric<int>.Bar(42, 0) // intrinsic, 2 args
"""
|> withOptions ["--nowarn:1125"]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Test Coverage - medium] --nowarn:1125 suppresses FS1125 The instantiation of the generic type '%s' is missing and can't be inferred.... Every call site in this test passes explicit type arguments (StaticGeneric<int>.Bar(...)), so FS1125 should not fire - and if it does, that itself is suspicious. It may indicate that the extension call is binding against a freshened type variable rather than the user-written int.

Please:

  1. Remove --nowarn:1125 and confirm the test still passes. If it fails, FS1125 firing on the extension overload needs investigation before merging - it suggests the fix lets the call through name resolution but downstream inference treats the generic instantiation as missing.
  2. If FS1125 is genuinely required, add an inline comment explaining why, and pin it with withDiagnostics as an expected warning rather than a silent suppression.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In that test case --nowarn:1125 is exactly redundant. Before some changes it supressed a warn for the call StaticGeneric.Bar(42) (without explicit generic).

StaticGeneric<int>.Bar(42, 0) // intrinsic, 2 args
"""
|> withOptions ["--nowarn:1125"]
|> typecheck
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Test Coverage - medium] Test gaps to fill before merge:

  • Ambiguity coverage: add a case where an instance extension and a static intrinsic share name and arity, to confirm the relaxation does not introduce a spurious FS0041. E.g. type StaticGeneric<'T> with member _.Bar(_: int) = () (instance extension) alongside the static intrinsic, called as StaticGeneric<int>.Bar(42) - must still produce a deterministic, well-worded diagnostic.
  • Symmetric LookupIsInstance.No arm: the fix touches both arms but no test exercises the No arm - module-qualified M.T.Foo(...) where T.Foo is a static intrinsic with an in-scope instance extension Foo.
  • IDE path: ResolveExprDotLongIdent is also called from quick-info / completion paths. A FSharpChecker-level smoke test that quick-info on StaticGeneric<int>.Bar returns the merged overload group would lock the IDE-visible behavior.
  • Non-generic but same shape: confirm Type.Member (no type args) continues to resolve identically - the same code path is hit.

@evgTSV
Copy link
Copy Markdown
Contributor

evgTSV commented May 21, 2026

It seems I figured out a better solution instead of this awkward fix. As abonie commented, we should resolve it upsream. I've just discovered a boolean flag staticOnly is already presented in the function ResolveExprDotLongIdentAndComputeRange. The solution is just to simply pass the flag down to ResolveExprDotLongIdent and create the proper lookupKind base on it, ensuring that static calls will only be looked up within static members. It should fix the bug not ony for methods, but also for other types of members like properties, events, etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI-Auto-Resolve-CI Opt-in: LabelOps triages CI failures on this PR every 3h AI-Auto-Resolve-Conflicts Opt-in: LabelOps merges main into this PR and resolves conflicts every 3h AI-reviewed PR reviewed by AI review council AI-Tooling-Check-Bypassed Tooling check: non-fork PR, not diff-analyzed

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

Overload resolution of static member extensions for generic types broken

3 participants