Fix #19664: Static extension overload resolution for generic types#19736
Fix #19664: Static extension overload resolution for generic types#19736T-Gro wants to merge 2 commits into
Conversation
❗ Release notes required
|
|
🔍 Tooling Safety Check — Bypassed (non-fork) (scanned-sha)1d647200b286e43951fb9462fcf29579bc3809d9(/scanned-sha)
|
T-Gro
left a comment
There was a problem hiding this comment.
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):
-
Ambivalent branch in the
isAmbivalentcheck – TheLookupIsInstance.Ambivalent -> truecase meansList.existsreturns true for any non-emptyminfos, then re-assignsisInstanceFilter = Ambivalent(already its value). This is harmless but reads slightly confusingly. You could simplify to only check theYes/Nocases:
\\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. -
Property-branch symmetry – The
PropertyItemarm (line ~2818) and the RFC-1137 extension-method lookup (line ~2864) both useisInstanceFilterwithout 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. -
Release-notes entry – minor: once merged, add the PR link (
[PR #19736](...)) to match the other entries in the file.
…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>
1d64720 to
3dcc376
Compare
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
abonie
left a comment
There was a problem hiding this comment.
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) -> |
There was a problem hiding this comment.
[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:
- For a call like
M.Foo(x)whereT.Foohas both static and instance intrinsic overloads, in-scope instance extensions namedFoowill 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. - 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
Noarm, or (b) restrict the relaxation to theYescase actually motivated by the issue and leave a TODO forNo.
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 = |
There was a problem hiding this comment.
[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 |
There was a problem hiding this comment.
[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 | ||
|
|
There was a problem hiding this comment.
[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"] |
There was a problem hiding this comment.
[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:
- Remove
--nowarn:1125and 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. - If FS1125 is genuinely required, add an inline comment explaining why, and pin it with
withDiagnosticsas an expected warning rather than a silent suppression.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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 asStaticGeneric<int>.Bar(42)- must still produce a deterministic, well-worded diagnostic. - Symmetric
LookupIsInstance.Noarm: the fix touches both arms but no test exercises theNoarm - module-qualifiedM.T.Foo(...)whereT.Foois a static intrinsic with an in-scope instance extensionFoo. - IDE path:
ResolveExprDotLongIdentis also called from quick-info / completion paths. AFSharpChecker-level smoke test that quick-info onStaticGeneric<int>.Barreturns 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.
|
It seems I figured out a better solution instead of this awkward fix. As |
Fixes #19664
When resolving
Type<TArg>.Member(...), the name resolver usesLookupIsInstance.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.Ambivalentso static extensions are included in overload resolution.