From 12ae67efc46505525471ad5655c49330baf45f7a Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 29 May 2026 13:15:49 +0200 Subject: [PATCH 1/6] [RED] Add failing tests for issue #16432 - single FS0039 on inherit of unknown type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InheritsDeclarations.fs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/FSharp.Compiler.ComponentTests/Conformance/ObjectOrientedTypeDefinitions/ClassTypes/InheritsDeclarations/InheritsDeclarations.fs b/tests/FSharp.Compiler.ComponentTests/Conformance/ObjectOrientedTypeDefinitions/ClassTypes/InheritsDeclarations/InheritsDeclarations.fs index ab983ded7ab..03a40c24131 100644 --- a/tests/FSharp.Compiler.ComponentTests/Conformance/ObjectOrientedTypeDefinitions/ClassTypes/InheritsDeclarations/InheritsDeclarations.fs +++ b/tests/FSharp.Compiler.ComponentTests/Conformance/ObjectOrientedTypeDefinitions/ClassTypes/InheritsDeclarations/InheritsDeclarations.fs @@ -56,3 +56,90 @@ module InheritsDeclarations = |> ignoreWarnings |> compile |> shouldSucceed + + // Regression tests for https://github.com/dotnet/fsharp/issues/16432: + // inheriting from an unknown type should emit FS0039 only once, + // not three times (once per name-resolution site in CheckDeclarations). + + [] + let ``Inherit nonexistent type reports single FS0039`` () = + let result = + FSharp """ +type MyClass() = + inherit NonExistentBase() +""" + |> typecheck + + let fs39 = + result.Output.Diagnostics + |> List.filter (fun d -> d.Error = (Error 39)) + + Assert.True( + fs39.Length = 1, + sprintf "Expected exactly 1 FS0039 but got %d. Diagnostics:\n%A" fs39.Length result.Output.Diagnostics + ) + + [] + let ``Two different undefined names still report separately`` () = + let result = + FSharp """ +type MyClass() = + inherit NonExistentBase() + member _.X = undefinedValue +""" + |> typecheck + + let fs39 = + result.Output.Diagnostics + |> List.filter (fun d -> d.Error = (Error 39)) + + Assert.True( + fs39.Length >= 2, + sprintf "Expected >= 2 FS0039, got %d. Diagnostics:\n%A" fs39.Length result.Output.Diagnostics + ) + + [] + let ``Inherit with generic nonexistent type single error`` () = + let result = + FSharp """ +type MyClass() = + inherit MissingGeneric() +""" + |> typecheck + + let fs39 = + result.Output.Diagnostics + |> List.filter (fun d -> d.Error = (Error 39)) + + Assert.True( + fs39.Length = 1, + sprintf "Expected exactly 1 FS0039 but got %d. Diagnostics:\n%A" fs39.Length result.Output.Diagnostics + ) + + [] + let ``Valid inherit produces no FS0039`` () = + FSharp """ +open System +type MyClass() = + inherit Exception("test") +""" + |> typecheck + |> shouldSucceed + + [] + let ``Interface inherit nonexistent single error`` () = + let result = + FSharp """ +type IMyInterface = + inherit INonExistent +""" + |> typecheck + + let fs39 = + result.Output.Diagnostics + |> List.filter (fun d -> d.Error = (Error 39)) + + Assert.True( + fs39.Length = 1, + sprintf "Expected exactly 1 FS0039 but got %d. Diagnostics:\n%A" fs39.Length result.Output.Diagnostics + ) From 57ffff0ff642334d66c716f7ccbb829140c7a9e5 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 29 May 2026 13:21:17 +0200 Subject: [PATCH 2/6] fix(checker): dedup FS0039 within a single inherit clause (#16432) Multiple typecheck passes (EstablishTypeDefinitionCores FirstPass and SecondPass, plus Phase2AInherit's TcType/TcNewExpr) each emit the same FS0039 for an undefined base type in an 'inherit'/'interface inherit' clause, surfacing three identical diagnostics. Introduce a DedupInheritDiagnosticsLogger overlay scoped via UseTransformedDiagnosticsLogger to TcMutRecDefinitions. The dedup key is the formatted message of UndefinedName so duplicate FS0039s for the same identifier collapse to one while unrelated diagnostics, including FS0039 for distinct identifiers, pass through unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Compiler/Checking/CheckDeclarations.fs | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/Compiler/Checking/CheckDeclarations.fs b/src/Compiler/Checking/CheckDeclarations.fs index 4a4361e9808..20919675fdc 100644 --- a/src/Compiler/Checking/CheckDeclarations.fs +++ b/src/Compiler/Checking/CheckDeclarations.fs @@ -48,6 +48,30 @@ open FSharp.Compiler.TypeRelations open FSharp.Compiler.TypeProviders #endif +/// Wraps an inner DiagnosticsLogger and suppresses subsequent identical +/// 'UndefinedName' (FS0039) errors raised for the same identifier text within +/// its lifetime. This addresses the case where an undefined base type in an +/// 'inherit' / 'interface inherit' clause is reported multiple times because +/// the type-establishment pipeline resolves the same SynType in several passes +/// (FirstPass / SecondPass / Phase2AInherit). The key is the formatted +/// diagnostic message so all other diagnostics, and FS0039 emissions for +/// distinct identifiers, pass through unchanged. +type private DedupInheritDiagnosticsLogger(inner: DiagnosticsLogger) = + inherit DiagnosticsLogger("DedupInheritDiagnosticsLogger") + let seen = HashSet() + + let rec isDuplicateUndefinedName (e: exn) = + match e with + | UndefinedName _ -> not (seen.Add e.Message) + | WrappedError(inner, _) -> isDuplicateUndefinedName inner + | _ -> false + + override _.DiagnosticSink(diagnostic: PhasedDiagnostic) = + if not (isDuplicateUndefinedName diagnostic.Exception) then + inner.DiagnosticSink diagnostic + + override _.ErrorCount = inner.ErrorCount + type cenv = TcFileState //------------------------------------------------------------------------- @@ -4611,6 +4635,16 @@ module TcDeclarations = let g = cenv.g + // Suppress duplicate FS0039 ('UndefinedName') diagnostics emitted across the + // multiple establishment passes for a mutually recursive type-definition group. + // For example, an undefined base type in an 'inherit' / 'interface inherit' + // clause is otherwise reported once each in FirstPass, SecondPass and the + // Phase2AInherit member-checking pass. Dedup is keyed by the formatted + // 'UndefinedName' message so unrelated diagnostics pass through unchanged. + use _ = + UseTransformedDiagnosticsLogger(fun inner -> + DedupInheritDiagnosticsLogger(inner) :> DiagnosticsLogger) + // Split the definitions into "core representations" and "members". The code to process core representations // is shared between processing of signature files and implementation files. let mutRecDefnsAfterSplit = mutRecDefns |> MutRecShapes.mapTycons (fun i -> SplitTyconDefn g i) From c2735ab02dadcf3248d1284763c214111ff200f1 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 29 May 2026 14:16:08 +0200 Subject: [PATCH 3/6] [GREEN] Dedup FS0039 across inherit-clause type-checking passes Adds a cenv-scoped `(range, idText)` dedup of `UndefinedName` diagnostics applied around Phase 1F inherit type checking and Phase 2A `Phase2AInherit` processing in CheckDeclarations.fs. Resolves the triple-reporting in dotnet/fsharp#16432 without changing the surface API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Compiler/Checking/CheckBasics.fs | 7 ++++ src/Compiler/Checking/CheckBasics.fsi | 6 ++++ src/Compiler/Checking/CheckDeclarations.fs | 38 +++++++++------------- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/Compiler/Checking/CheckBasics.fs b/src/Compiler/Checking/CheckBasics.fs index d8fdd288af3..44fe20b4949 100644 --- a/src/Compiler/Checking/CheckBasics.fs +++ b/src/Compiler/Checking/CheckBasics.fs @@ -313,6 +313,12 @@ type TcFileState = argInfoCache: ConcurrentDictionary + /// Tracks (range, identifier-text) keys of `UndefinedName` diagnostics that have already + /// been reported during this cenv's lifetime so they can be deduplicated when the same + /// identifier is re-resolved by multiple passes (e.g. Phase 1F vs. Phase 2A of an + /// `inherit` clause). See issue dotnet/fsharp#16432. + reportedUndefinedNames: HashSet + // forward call TcPat: WarnOnUpperFlag -> TcFileState -> TcEnv -> PrelimValReprInfo option -> TcPatValFlags -> TcPatLinearEnv -> TType -> SynPat -> (TcPatPhase2Input -> Pattern) * TcPatLinearEnv @@ -363,6 +369,7 @@ type TcFileState = isInternalTestSpanStackReferring = isInternalTestSpanStackReferring diagnosticOptions = diagnosticOptions argInfoCache = ConcurrentDictionary() + reportedUndefinedNames = HashSet(HashIdentity.Structural) TcPat = tcPat TcSimplePats = tcSimplePats TcSequenceExpressionEntry = tcSequenceExpressionEntry diff --git a/src/Compiler/Checking/CheckBasics.fsi b/src/Compiler/Checking/CheckBasics.fsi index 0191cf018f2..7ce10fbb7f1 100644 --- a/src/Compiler/Checking/CheckBasics.fsi +++ b/src/Compiler/Checking/CheckBasics.fsi @@ -274,6 +274,12 @@ type TcFileState = /// we're always dealing with the same instance and the updates don't get lost argInfoCache: ConcurrentDictionary + /// Tracks (range, identifier-text) keys of `UndefinedName` diagnostics that have already + /// been reported during this cenv's lifetime so they can be deduplicated when the same + /// identifier is re-resolved by multiple passes (e.g. Phase 1F vs. Phase 2A of an + /// `inherit` clause). See issue dotnet/fsharp#16432. + reportedUndefinedNames: HashSet + // forward call TcPat: WarnOnUpperFlag diff --git a/src/Compiler/Checking/CheckDeclarations.fs b/src/Compiler/Checking/CheckDeclarations.fs index 20919675fdc..184ac387d14 100644 --- a/src/Compiler/Checking/CheckDeclarations.fs +++ b/src/Compiler/Checking/CheckDeclarations.fs @@ -48,21 +48,16 @@ open FSharp.Compiler.TypeRelations open FSharp.Compiler.TypeProviders #endif -/// Wraps an inner DiagnosticsLogger and suppresses subsequent identical -/// 'UndefinedName' (FS0039) errors raised for the same identifier text within -/// its lifetime. This addresses the case where an undefined base type in an -/// 'inherit' / 'interface inherit' clause is reported multiple times because -/// the type-establishment pipeline resolves the same SynType in several passes -/// (FirstPass / SecondPass / Phase2AInherit). The key is the formatted -/// diagnostic message so all other diagnostics, and FS0039 emissions for -/// distinct identifiers, pass through unchanged. -type private DedupInheritDiagnosticsLogger(inner: DiagnosticsLogger) = - inherit DiagnosticsLogger("DedupInheritDiagnosticsLogger") - let seen = HashSet() +/// A diagnostics-logger wrapper used while type-checking an `inherit` clause. It drops any +/// `UndefinedName` diagnostic whose `(id.idRange, id.idText)` key has already been seen in +/// the same `cenv`, so duplicate FS0039s caused by re-resolution across Phase 1F / Phase 2A +/// are suppressed. All other diagnostics are forwarded unchanged. See issue #16432. +type private InheritDedupDiagnosticsLogger(seen: HashSet, inner: DiagnosticsLogger) = + inherit DiagnosticsLogger("InheritDedupDiagnosticsLogger") let rec isDuplicateUndefinedName (e: exn) = match e with - | UndefinedName _ -> not (seen.Add e.Message) + | UndefinedName(_, _, id, _) -> not (seen.Add(id.idRange, id.idText)) | WrappedError(inner, _) -> isDuplicateUndefinedName inner | _ -> false @@ -74,6 +69,10 @@ type private DedupInheritDiagnosticsLogger(inner: DiagnosticsLogger) = type cenv = TcFileState +let private useInheritDedupLogger (cenv: cenv) = + UseTransformedDiagnosticsLogger(fun inner -> + InheritDedupDiagnosticsLogger(cenv.reportedUndefinedNames, inner) :> DiagnosticsLogger) + //------------------------------------------------------------------------- // Mutually recursive shapes //------------------------------------------------------------------------- @@ -1390,6 +1389,7 @@ module MutRecBindingChecking = // Phase2B: typecheck the argument to an 'inherits' call and build the new object expr for the inherit-call | Phase2AInherit (synBaseTy, arg, baseValOpt, m) -> + use _ = useInheritDedupLogger cenv let inheritsExpr, tpenv = try let baseTy, tpenv = TcType cenv NoNewTypars CheckCxs ItemOccurrence.Use WarnOnIWSAM.Yes envInstance tpenv synBaseTy @@ -3341,7 +3341,9 @@ module EstablishTypeDefinitionCores = let kind = InferTyconKind g (kind, attrs, slotsigs, fields, inSig, isConcrete, m) let inherits = inherits |> List.map (fun (ty, m, _) -> (ty, m)) - let inheritedTys = fst (List.mapFold (mapFoldFst (TcTypeAndRecover cenv NoNewTypars checkConstraints ItemOccurrence.UseInType WarnOnIWSAM.No envinner)) tpenv inherits) + let inheritedTys = + use _ = useInheritDedupLogger cenv + fst (List.mapFold (mapFoldFst (TcTypeAndRecover cenv NoNewTypars checkConstraints ItemOccurrence.UseInType WarnOnIWSAM.No envinner)) tpenv inherits) let implementedTys, inheritedTys = match kind with | SynTypeDefnKind.Interface -> @@ -4635,16 +4637,6 @@ module TcDeclarations = let g = cenv.g - // Suppress duplicate FS0039 ('UndefinedName') diagnostics emitted across the - // multiple establishment passes for a mutually recursive type-definition group. - // For example, an undefined base type in an 'inherit' / 'interface inherit' - // clause is otherwise reported once each in FirstPass, SecondPass and the - // Phase2AInherit member-checking pass. Dedup is keyed by the formatted - // 'UndefinedName' message so unrelated diagnostics pass through unchanged. - use _ = - UseTransformedDiagnosticsLogger(fun inner -> - DedupInheritDiagnosticsLogger(inner) :> DiagnosticsLogger) - // Split the definitions into "core representations" and "members". The code to process core representations // is shared between processing of signature files and implementation files. let mutRecDefnsAfterSplit = mutRecDefns |> MutRecShapes.mapTycons (fun i -> SplitTyconDefn g i) From e34c6123af0fa9b44ffd585079fa639e0aa6fae7 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 29 May 2026 14:23:19 +0200 Subject: [PATCH 4/6] chore(#16432): release notes for inherit FS0039 dedup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/release-notes/.FSharp.Compiler.Service/11.0.100.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index 70ca7428416..c83239dc6c5 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -1,5 +1,6 @@ ### Fixed +* Report `FS0039` only once when a class `inherit` clause references an undefined base type. ([Issue #16432](https://github.com/dotnet/fsharp/issues/16432)) * Fix FS0421 "The address of the variable cannot be used at this point" incorrectly raised for the discard pattern `let _ = &expr` when `let x = &expr` compiles. ([Issue #18841](https://github.com/dotnet/fsharp/issues/18841), [PR #19811](https://github.com/dotnet/fsharp/pull/19811)) * Honor `--nowarn` and `--warnaserror` for warnings emitted during command-line option parsing ([Issue #19576](https://github.com/dotnet/fsharp/issues/19576), [PR #19776](https://github.com/dotnet/fsharp/pull/19776)) * Fix `[]` prefix attributes being silently dropped on class members, and fix false-positive `AllowMultiple=false` errors when `[]` and `[]` are applied to the same binding. ([Issue #17904](https://github.com/dotnet/fsharp/issues/17904), [Issue #19020](https://github.com/dotnet/fsharp/issues/19020), [PR #19738](https://github.com/dotnet/fsharp/pull/19738)) From 88a176eff817f398d4b3d2346f9f241b8bbf3806 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 15:09:51 +0200 Subject: [PATCH 5/6] [REFACTOR] Format and address expert review for #16432 - Switch reportedUndefinedNames to ConcurrentDictionary for consistency with the sibling argInfoCache field and to be defensive against any future parallel cenv use. - Forward CheckForRealErrorsIgnoringWarnings to the wrapped logger so the InheritDedupDiagnosticsLogger is a faithful pass-through for everything other than the intended UndefinedName dedup. - Document the rationale for unwrapping only WrappedError in isDuplicateUndefinedName. Expert reviewer item 1 (merging suggestions from later UndefinedName into the first) intentionally not applied: in the inherit-clause case the colliding diagnostics carry identical suggestion sets, and merging would couple this site to NameResolution.AddResults internals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Compiler/Checking/CheckBasics.fs | 4 ++-- src/Compiler/Checking/CheckBasics.fsi | 2 +- src/Compiler/Checking/CheckDeclarations.fs | 12 ++++++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Compiler/Checking/CheckBasics.fs b/src/Compiler/Checking/CheckBasics.fs index 44fe20b4949..32ca10577d5 100644 --- a/src/Compiler/Checking/CheckBasics.fs +++ b/src/Compiler/Checking/CheckBasics.fs @@ -317,7 +317,7 @@ type TcFileState = /// been reported during this cenv's lifetime so they can be deduplicated when the same /// identifier is re-resolved by multiple passes (e.g. Phase 1F vs. Phase 2A of an /// `inherit` clause). See issue dotnet/fsharp#16432. - reportedUndefinedNames: HashSet + reportedUndefinedNames: ConcurrentDictionary // forward call TcPat: WarnOnUpperFlag -> TcFileState -> TcEnv -> PrelimValReprInfo option -> TcPatValFlags -> TcPatLinearEnv -> TType -> SynPat -> (TcPatPhase2Input -> Pattern) * TcPatLinearEnv @@ -369,7 +369,7 @@ type TcFileState = isInternalTestSpanStackReferring = isInternalTestSpanStackReferring diagnosticOptions = diagnosticOptions argInfoCache = ConcurrentDictionary() - reportedUndefinedNames = HashSet(HashIdentity.Structural) + reportedUndefinedNames = ConcurrentDictionary() TcPat = tcPat TcSimplePats = tcSimplePats TcSequenceExpressionEntry = tcSequenceExpressionEntry diff --git a/src/Compiler/Checking/CheckBasics.fsi b/src/Compiler/Checking/CheckBasics.fsi index 7ce10fbb7f1..80b4baec7e5 100644 --- a/src/Compiler/Checking/CheckBasics.fsi +++ b/src/Compiler/Checking/CheckBasics.fsi @@ -278,7 +278,7 @@ type TcFileState = /// been reported during this cenv's lifetime so they can be deduplicated when the same /// identifier is re-resolved by multiple passes (e.g. Phase 1F vs. Phase 2A of an /// `inherit` clause). See issue dotnet/fsharp#16432. - reportedUndefinedNames: HashSet + reportedUndefinedNames: ConcurrentDictionary // forward call TcPat: diff --git a/src/Compiler/Checking/CheckDeclarations.fs b/src/Compiler/Checking/CheckDeclarations.fs index 184ac387d14..6107f9520d3 100644 --- a/src/Compiler/Checking/CheckDeclarations.fs +++ b/src/Compiler/Checking/CheckDeclarations.fs @@ -3,6 +3,7 @@ module internal FSharp.Compiler.CheckDeclarations open System +open System.Collections.Concurrent open System.Collections.Generic open System.Threading @@ -52,12 +53,17 @@ open FSharp.Compiler.TypeProviders /// `UndefinedName` diagnostic whose `(id.idRange, id.idText)` key has already been seen in /// the same `cenv`, so duplicate FS0039s caused by re-resolution across Phase 1F / Phase 2A /// are suppressed. All other diagnostics are forwarded unchanged. See issue #16432. -type private InheritDedupDiagnosticsLogger(seen: HashSet, inner: DiagnosticsLogger) = +type private InheritDedupDiagnosticsLogger + (seen: ConcurrentDictionary, inner: DiagnosticsLogger) = inherit DiagnosticsLogger("InheritDedupDiagnosticsLogger") + // Only `WrappedError` is unwrapped here: it is the single wrapper that the + // `inherit` type-checking path can place around an `UndefinedName`. Other diagnostic + // exception families (`StopProcessingExn`/`ReportedError` short-circuit before reaching + // the sink, and `DiagnosticWithSuggestions` etc. are peer errors, not wrappers). let rec isDuplicateUndefinedName (e: exn) = match e with - | UndefinedName(_, _, id, _) -> not (seen.Add(id.idRange, id.idText)) + | UndefinedName(_, _, id, _) -> not (seen.TryAdd(struct (id.idRange, id.idText), ())) | WrappedError(inner, _) -> isDuplicateUndefinedName inner | _ -> false @@ -67,6 +73,8 @@ type private InheritDedupDiagnosticsLogger(seen: HashSet, inner: override _.ErrorCount = inner.ErrorCount + override _.CheckForRealErrorsIgnoringWarnings = inner.CheckForRealErrorsIgnoringWarnings + type cenv = TcFileState let private useInheritDedupLogger (cenv: cenv) = From 966314a49f2c2f0187a37ae658ce91a9f0eae767 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 29 May 2026 16:08:17 +0200 Subject: [PATCH 6/6] docs: add PR link to release notes for #16432 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/release-notes/.FSharp.Compiler.Service/11.0.100.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index c83239dc6c5..4e0d93e732d 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -1,6 +1,6 @@ ### Fixed -* Report `FS0039` only once when a class `inherit` clause references an undefined base type. ([Issue #16432](https://github.com/dotnet/fsharp/issues/16432)) +* Report `FS0039` only once when a class `inherit` clause references an undefined base type. ([Issue #16432](https://github.com/dotnet/fsharp/issues/16432), [PR #19862](https://github.com/dotnet/fsharp/pull/19862)) * Fix FS0421 "The address of the variable cannot be used at this point" incorrectly raised for the discard pattern `let _ = &expr` when `let x = &expr` compiles. ([Issue #18841](https://github.com/dotnet/fsharp/issues/18841), [PR #19811](https://github.com/dotnet/fsharp/pull/19811)) * Honor `--nowarn` and `--warnaserror` for warnings emitted during command-line option parsing ([Issue #19576](https://github.com/dotnet/fsharp/issues/19576), [PR #19776](https://github.com/dotnet/fsharp/pull/19776)) * Fix `[]` prefix attributes being silently dropped on class members, and fix false-positive `AllowMultiple=false` errors when `[]` and `[]` are applied to the same binding. ([Issue #17904](https://github.com/dotnet/fsharp/issues/17904), [Issue #19020](https://github.com/dotnet/fsharp/issues/19020), [PR #19738](https://github.com/dotnet/fsharp/pull/19738))