diff --git a/Directory.Build.targets b/Directory.Build.targets
index a0ac2867bd2..4c5ab1c561c 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -10,6 +10,17 @@
$(NoWarn);NU1507
+
+
+
+ TargetFramework=netstandard2.1
+
+
+
10) -->
$([System.Text.RegularExpressions.Regex]::Replace('$(FSharpNetCoreProductTargetFramework)', '^net(\d+)\.0$', '$1'))
+
+
+ net10.0
+ $([System.Text.RegularExpressions.Regex]::Replace('$(FSharpCoreShippedNetTargetFramework)', '^net(\d+)\.0$', '$1'))
+
+
+
+
+
+
+
diff --git a/eng/tests/AuditFSharpCoreTfmGuards.fsx b/eng/tests/AuditFSharpCoreTfmGuards.fsx
new file mode 100644
index 00000000000..cac8167e39f
--- /dev/null
+++ b/eng/tests/AuditFSharpCoreTfmGuards.fsx
@@ -0,0 +1,132 @@
+// Allow-list audit of the TFM (#if) guards in src/FSharp.Core (the "#ifdef management" gate).
+//
+// Every TFM-discriminating #if/#elif in src/FSharp.Core/**/*.{fs,fsi} must be allow-listed below by
+// (, ). A non-allow-listed guard fails the audit, forcing review. This
+// catches the regressions that silently mistarget the shipped net TFM:
+// * bare #if NETSTANDARD2_1 -> excludes net, dropping a feature there
+// * bare #if !NET -> drops a BCL polyfill on EVERY net (wrong floor)
+// Standardized idioms: `#if NETSTANDARD2_1_OR_GREATER || NET` (feature on ns2.1 + all net) and
+// `#if !NET5_0_OR_GREATER` / `#if !NET8_0_OR_GREATER` (polyfill below a BCL floor).
+//
+// Run: dotnet fsi eng/tests/AuditFSharpCoreTfmGuards.fsx [--self-test]
+// Uses `git ls-files` + the .NET regex engine (not `git grep -P`, absent on macOS git builds).
+
+open System
+open System.Diagnostics
+open System.Text.RegularExpressions
+
+let scopeGlobs = [ "src/FSharp.Core/*.fs"; "src/FSharp.Core/*.fsi" ]
+
+// The reviewed, allow-listed TFM guards after standardization. Key = (repo-relative path with '/',
+// normalized guard expression). To add an entry: confirm the guard uses the idiom above, then list
+// it here with a one-line justification in the PR.
+let allowList : Set =
+ set [
+ // Collection-expression support ([] + Create(ReadOnlySpan<_>)): present on
+ // netstandard2.1 and every net TFM.
+ "src/FSharp.Core/set.fs", "NETSTANDARD2_1_OR_GREATER || NET"
+ "src/FSharp.Core/set.fsi", "NETSTANDARD2_1_OR_GREATER || NET"
+ "src/FSharp.Core/prim-types.fs", "NETSTANDARD2_1_OR_GREATER || NET"
+ "src/FSharp.Core/prim-types.fsi", "NETSTANDARD2_1_OR_GREATER || NET"
+ // task { use! ... } over IAsyncDisposable (TryFinallyAsync / TaskBuilderBase.Using): ns2.1 + net.
+ "src/FSharp.Core/tasks.fs", "NETSTANDARD2_1 || NET"
+ "src/FSharp.Core/tasks.fsi", "NETSTANDARD2_1 || NET"
+ // BCL polyfills that must be dropped once the BCL provides the type:
+ // DynamicallyAccessedMembers -> floor at NET5
+ // CollectionBuilder/ScopedRef -> floor at NET8
+ "src/FSharp.Core/prim-types.fs", "!NET5_0_OR_GREATER"
+ "src/FSharp.Core/prim-types.fsi", "!NET5_0_OR_GREATER"
+ "src/FSharp.Core/prim-types.fs", "!NET8_0_OR_GREATER"
+ "src/FSharp.Core/prim-types.fsi", "!NET8_0_OR_GREATER"
+ ]
+
+// A guard expression is "TFM-discriminating" if it mentions any of these tokens.
+let tfmToken =
+ Regex(@"NETSTANDARD|NETCOREAPP|NETFRAMEWORK|NET\d|(?:^|[^0-9A-Za-z_])!?NET(?:[^0-9A-Za-z_]|$)",
+ RegexOptions.Compiled)
+
+let guardLine = Regex(@"^\s*#(?:if|elif)\s+(?.*\S)\s*$", RegexOptions.Compiled)
+
+let normalize (s: string) = Regex.Replace(s, @"\s+", " ").Trim()
+
+let runGit (args: string) =
+ let psi = ProcessStartInfo("git", args, RedirectStandardOutput = true, UseShellExecute = false)
+ use p = Process.Start psi
+ let out = p.StandardOutput.ReadToEnd()
+ p.WaitForExit()
+ if p.ExitCode <> 0 then failwithf "git %s failed (exit %d)" args p.ExitCode
+ out
+
+let trackedFiles () =
+ runGit ("ls-files -- " + String.Join(" ", scopeGlobs))
+ |> fun s -> s.Split([| '\n'; '\r' |], StringSplitOptions.RemoveEmptyEntries)
+ |> Array.map (fun p -> p.Trim())
+ |> Array.filter (fun p -> p <> "")
+
+// Returns the TFM guards found in the given lines as (normalizedExpr) values.
+let tfmGuardsIn (lines: string[]) =
+ lines
+ |> Array.choose (fun ln ->
+ let m = guardLine.Match ln
+ if m.Success then
+ let expr = normalize m.Groups.["expr"].Value
+ if tfmToken.IsMatch expr then Some expr else None
+ else None)
+
+let auditRepo () =
+ let violations =
+ [ for file in trackedFiles () do
+ let path = file.Replace('\\', '/')
+ for expr in tfmGuardsIn (IO.File.ReadAllLines file) do
+ if not (allowList.Contains(path, expr)) then
+ yield path, expr ]
+ if violations.IsEmpty then
+ printfn "FSharp.Core TFM #if guard audit: OK (%d allow-listed guards)." allowList.Count
+ 0
+ else
+ eprintfn "FSharp.Core TFM #if guard audit: FAILED. Non-allow-listed TFM guard(s):"
+ for (path, expr) in violations do
+ eprintfn " %s: #if %s" path expr
+ eprintfn ""
+ eprintfn "Use the standardized idiom (see eng/tests/AuditFSharpCoreTfmGuards.fsx header):"
+ eprintfn " * feature on ns2.1 + all net: #if NETSTANDARD2_1_OR_GREATER || NET"
+ eprintfn " * polyfill below a BCL floor: #if !NET8_0_OR_GREATER (or !NET5_0_OR_GREATER)"
+ eprintfn "A bare '#if NETSTANDARD2_1' or '#if !NET' is almost always wrong for the shipped net TFM."
+ eprintfn "If the new guard is a legitimate use of the idiom, add its : to the allow-list."
+ 1
+
+// Self-test: prove the detector actually bites on the known anti-patterns and passes the good idioms.
+let selfTest () =
+ let mutable ok = true
+ let check desc (expr: string) shouldBeTfm =
+ // Drive the REAL extractor (guardLine -> normalize -> tfmToken) via a synthetic #if line, so a
+ // drift in the #if/#elif line parser cannot silently green this self-test while the audit goes blind.
+ let detected = tfmGuardsIn [| sprintf "#if %s" expr |] |> Array.isEmpty |> not
+ if detected <> shouldBeTfm then
+ ok <- false
+ eprintfn " self-test FAIL: %s -> detected=%b, expected %b" desc detected shouldBeTfm
+ // These MUST be recognized as TFM guards (and, not being allow-listed for a fake file, would fail):
+ check "bare NETSTANDARD2_1" "NETSTANDARD2_1" true
+ check "bare !NET" "!NET" true
+ check "NET5_0_OR_GREATER" "NET5_0_OR_GREATER" true
+ check "NETCOREAPP" "NETCOREAPP" true
+ check "good idiom || NET" "NETSTANDARD2_1_OR_GREATER || NET" true
+ // These are NOT TFM guards and must be ignored by the audit:
+ check "DEBUG" "DEBUG" false
+ check "FX_NO_SOMETHING" "FX_NO_SOMETHING" false
+ // The line parser must recognize #elif, not only #if.
+ if tfmGuardsIn [| "#elif NETSTANDARD2_1_OR_GREATER || NET" |] |> Array.isEmpty then
+ ok <- false; eprintfn " self-test FAIL: #elif TFM guard not detected"
+ // Allow-list containment behaves as expected:
+ if not (allowList.Contains("src/FSharp.Core/tasks.fs", "NETSTANDARD2_1 || NET")) then
+ ok <- false; eprintfn " self-test FAIL: known-good pair not in allow-list"
+ if allowList.Contains("src/FSharp.Core/tasks.fs", "NETSTANDARD2_1") then
+ ok <- false; eprintfn " self-test FAIL: bare NETSTANDARD2_1 unexpectedly allow-listed"
+ if ok then
+ printfn "FSharp.Core TFM #if guard audit self-test: OK."; 0
+ else
+ eprintfn "FSharp.Core TFM #if guard audit self-test: FAILED."; 1
+
+let args = Environment.GetCommandLineArgs()
+let exitCode = if Array.contains "--self-test" args then selfTest () else auditRepo ()
+exit exitCode
diff --git a/src/FSharp.Core/FSharp.Core.fsproj b/src/FSharp.Core/FSharp.Core.fsproj
index 565fab62a04..a63b026450d 100644
--- a/src/FSharp.Core/FSharp.Core.fsproj
+++ b/src/FSharp.Core/FSharp.Core.fsproj
@@ -5,7 +5,7 @@
Library
netstandard2.0
- netstandard2.0;netstandard2.1
+ netstandard2.0;netstandard2.1;$(FSharpCoreShippedNetTargetFramework)
$(NoWarn);75
$(NoWarn);1204
true
@@ -36,6 +36,12 @@
Debug;Release;Proto
+
+
+
+
+
$(OtherFlags) --realsig-
diff --git a/src/FSharp.Core/FSharp.Core.nuspec b/src/FSharp.Core/FSharp.Core.nuspec
index cc7e5316ac6..a32a54e5382 100644
--- a/src/FSharp.Core/FSharp.Core.nuspec
+++ b/src/FSharp.Core/FSharp.Core.nuspec
@@ -6,6 +6,7 @@
+
@@ -22,5 +23,11 @@
+
+
+
+
+
+
diff --git a/src/FSharp.Core/Query.fs b/src/FSharp.Core/Query.fs
index 715894dd34a..55489dcc739 100644
--- a/src/FSharp.Core/Query.fs
+++ b/src/FSharp.Core/Query.fs
@@ -107,7 +107,7 @@ type QueryBuilder() =
member _.Head (source: QuerySource<'T, 'Q>) =
Enumerable.First source.Source
- member _.Nth (source: QuerySource<'T, 'Q>, index) =
+ member _.Nth (source: QuerySource<'T, 'Q>, index: int) =
Enumerable.ElementAt (source.Source, index)
member _.Skip (source: QuerySource<'T, 'Q>, count) : QuerySource<'T, 'Q> =
@@ -116,7 +116,7 @@ type QueryBuilder() =
member _.SkipWhile (source: QuerySource<'T, 'Q>, predicate) : QuerySource<'T, 'Q> =
QuerySource (Enumerable.SkipWhile (source.Source, Func<_, _>(predicate)))
- member _.Take (source: QuerySource<'T, 'Q>, count) : QuerySource<'T, 'Q> =
+ member _.Take (source: QuerySource<'T, 'Q>, count: int) : QuerySource<'T, 'Q> =
QuerySource (Enumerable.Take (source.Source, count))
member _.TakeWhile (source: QuerySource<'T, 'Q>, predicate) : QuerySource<'T, 'Q> =
@@ -475,8 +475,8 @@ module Query =
MakeOrCallContainsOrElementAt FQ FE
let MakeElementAt, CallElementAt =
- let FQ = methodhandleof (fun (x, y) -> Queryable.ElementAt(x, y))
- let FE = methodhandleof (fun (x, y) -> Enumerable.ElementAt(x, y))
+ let FQ = methodhandleof (fun (x, y) -> Queryable.ElementAt(x, (y: int)))
+ let FE = methodhandleof (fun (x, y) -> Enumerable.ElementAt(x, (y: int)))
MakeOrCallContainsOrElementAt FQ FE
let MakeOrCallMinByOrMaxBy FQ FE =
@@ -886,8 +886,8 @@ module Query =
let MakeTake =
MakeSkipOrTake
- (methodhandleof (fun (x, y) -> Queryable.Take (x, y)))
- (methodhandleof (fun (x, y) -> Enumerable.Take (x, y)))
+ (methodhandleof (fun (x, y) -> Queryable.Take (x, (y: int))))
+ (methodhandleof (fun (x, y) -> Enumerable.Take (x, (y: int))))
let MakeSkipWhile =
GenMakeSkipWhileOrTakeWhile
diff --git a/src/FSharp.Core/local.fs b/src/FSharp.Core/local.fs
index 16654a06257..c365f9e5bdb 100644
--- a/src/FSharp.Core/local.fs
+++ b/src/FSharp.Core/local.fs
@@ -10,17 +10,17 @@ module internal DetailedExceptions =
open Microsoft.FSharp.Core
/// takes an argument, a formatting string, a param array to splice into the formatting string
- let inline invalidArgFmt (arg:string) (format:string) paramArray =
+ let inline invalidArgFmt (arg:string) (format:string) (paramArray: obj[]) =
let msg = String.Format (format, paramArray)
raise (ArgumentException(msg, arg))
/// takes an argument, a formatting string, a param array to splice into the formatting string
- let inline invalidArgOutOfRangeFmt (arg:string) (format:string) paramArray =
+ let inline invalidArgOutOfRangeFmt (arg:string) (format:string) (paramArray: obj[]) =
let msg = String.Format (format, paramArray)
raise (ArgumentOutOfRangeException(arg, msg))
/// takes a formatting string and a param array to splice into the formatting string
- let inline invalidOpFmt (format:string) paramArray =
+ let inline invalidOpFmt (format:string) (paramArray: obj[]) =
let msg = String.Format (format, paramArray)
raise (InvalidOperationException(msg))
diff --git a/src/FSharp.Core/prim-types.fs b/src/FSharp.Core/prim-types.fs
index 036ba49ce48..5ee0a4e2ff8 100644
--- a/src/FSharp.Core/prim-types.fs
+++ b/src/FSharp.Core/prim-types.fs
@@ -443,13 +443,14 @@ namespace System.Diagnostics.CodeAnalysis
member this.DynamicallyAccessedMembersAttribute(memberTypes: DynamicallyAccessedMemberTypes) =
this.MemberTypes <- memberTypes
+#endif
+
namespace Microsoft.FSharp.Core
open System
open System.Collections
open System.Collections.Generic
open System.Globalization
open System.Reflection
- #endif
[] type float<[] 'Measure> = float
[] type float32<[] 'Measure> = float32
@@ -4096,6 +4097,7 @@ namespace Microsoft.FSharp.Core
and 'T voption = ValueOption<'T>
// These attributes only exist in .NET 8 and up.
+#if !NET8_0_OR_GREATER
namespace System.Runtime.CompilerServices
open System
open Microsoft.FSharp.Core
@@ -4111,6 +4113,7 @@ namespace System.Runtime.CompilerServices
[]
type internal ScopedRefAttribute () =
inherit Attribute ()
+#endif
namespace Microsoft.FSharp.Collections
@@ -4128,7 +4131,7 @@ namespace Microsoft.FSharp.Collections
open Microsoft.FSharp.Core.LanguagePrimitives.IntrinsicFunctions
open Microsoft.FSharp.Core.BasicInlinedOperations
-#if NETSTANDARD2_1_OR_GREATER
+#if NETSTANDARD2_1_OR_GREATER || NET
[, "Create")>]
#endif
[]
@@ -4156,7 +4159,7 @@ namespace Microsoft.FSharp.Collections
and 'T list = List<'T>
-#if NETSTANDARD2_1_OR_GREATER
+#if NETSTANDARD2_1_OR_GREATER || NET
and []
type SupportsWhenTEnum = class end
+#if !NET5_0_OR_GREATER
namespace System.Diagnostics.CodeAnalysis
open System
@@ -1047,6 +1048,8 @@ namespace System.Diagnostics.CodeAnalysis
new: DynamicallyAccessedMemberTypes -> DynamicallyAccessedMembersAttribute
member MemberTypes: DynamicallyAccessedMemberTypes
+#endif
+
namespace Microsoft.FSharp.Core
open System
@@ -2606,6 +2609,7 @@ namespace Microsoft.FSharp.Core
| Error of ErrorValue:'TError
// These attributes only exist in .NET 8 and up.
+#if !NET8_0_OR_GREATER
namespace System.Runtime.CompilerServices
open System
open Microsoft.FSharp.Core
@@ -2637,6 +2641,7 @@ namespace System.Runtime.CompilerServices
type internal ScopedRefAttribute =
inherit Attribute
new: unit -> ScopedRefAttribute
+#endif
namespace Microsoft.FSharp.Collections
@@ -2654,7 +2659,7 @@ namespace Microsoft.FSharp.Collections
///
///
///
-#if NETSTANDARD2_1_OR_GREATER
+#if NETSTANDARD2_1_OR_GREATER || NET
[, "Create")>]
#endif
[]
@@ -2730,7 +2735,7 @@ namespace Microsoft.FSharp.Collections
///
and 'T list = List<'T>
-#if NETSTANDARD2_1_OR_GREATER
+#if NETSTANDARD2_1_OR_GREATER || NET
/// Contains methods for compiler use related to lists.
and [ add comparer k acc) empty l
-#if NETSTANDARD2_1_OR_GREATER
+#if NETSTANDARD2_1_OR_GREATER || NET
[, "Create")>]
#endif
[]
@@ -1097,7 +1097,7 @@ type Set<[] 'T when 'T: comparison>(comparer: IComparer<'
.Append("; ... ]")
.ToString()
-#if NETSTANDARD2_1_OR_GREATER
+#if NETSTANDARD2_1_OR_GREATER || NET
and [See the module for further operations on sets.
///
/// All members of this class are thread-safe and may be used concurrently from multiple threads.
-#if NETSTANDARD2_1_OR_GREATER
+#if NETSTANDARD2_1_OR_GREATER || NET
[, "Create")>]
#endif
[]
@@ -263,7 +263,7 @@ type Set<[] 'T when 'T: comparison> =
interface System.Collections.IStructuralEquatable
interface IReadOnlyCollection<'T>
-#if NETSTANDARD2_1_OR_GREATER
+#if NETSTANDARD2_1_OR_GREATER || NET
/// Contains methods for compiler use related to sets.
and [, body: 'T -> TaskCode<'TOverall, unit>) : TaskCode<'TOverall, unit> =
ResumableCode.For(sequence, body)
-#if NETSTANDARD2_1
+#if NETSTANDARD2_1 || NET
member inline internal this.TryFinallyAsync
(body: TaskCode<'TOverall, 'T>, compensation: unit -> ValueTask)
: TaskCode<'TOverall, 'T> =
diff --git a/src/FSharp.Core/tasks.fsi b/src/FSharp.Core/tasks.fsi
index 76d84bcfd28..95f1bd1be2b 100644
--- a/src/FSharp.Core/tasks.fsi
+++ b/src/FSharp.Core/tasks.fsi
@@ -93,7 +93,7 @@ type TaskBuilderBase =
member inline TryWith:
body: TaskCode<'TOverall, 'T> * catch: (exn -> TaskCode<'TOverall, 'T>) -> TaskCode<'TOverall, 'T>
-#if NETSTANDARD2_1
+#if NETSTANDARD2_1 || NET
///
/// Specifies a unit of task code which binds to the resource implementing IAsyncDisposable and disposes it asynchronously
///
diff --git a/src/Microsoft.FSharp.Compiler/Microsoft.FSharp.Compiler.fsproj b/src/Microsoft.FSharp.Compiler/Microsoft.FSharp.Compiler.fsproj
index ec0704c0cf1..f31891bc587 100644
--- a/src/Microsoft.FSharp.Compiler/Microsoft.FSharp.Compiler.fsproj
+++ b/src/Microsoft.FSharp.Compiler/Microsoft.FSharp.Compiler.fsproj
@@ -45,7 +45,7 @@
TargetFrameworks=netstandard2.0
- TargetFrameworks=netstandard2.1;netstandard2.0
+ TargetFrameworks=netstandard2.1;netstandard2.0;$(FSharpCoreShippedNetTargetFramework)
TargetFrameworks=netstandard2.0
diff --git a/tests/AheadOfTime/NativeAOT/NativeAOT_Test.fsproj b/tests/AheadOfTime/NativeAOT/NativeAOT_Test.fsproj
index 1fa87907110..51cc03edff6 100644
--- a/tests/AheadOfTime/NativeAOT/NativeAOT_Test.fsproj
+++ b/tests/AheadOfTime/NativeAOT/NativeAOT_Test.fsproj
@@ -2,7 +2,9 @@
Exe
- net9.0
+
+ net9.0;$(FSharpCoreShippedNetTargetFramework)
preview
true
diff --git a/tests/AheadOfTime/NativeAOT/check.ps1 b/tests/AheadOfTime/NativeAOT/check.ps1
index dc69fb765df..05190ed3ecd 100644
--- a/tests/AheadOfTime/NativeAOT/check.ps1
+++ b/tests/AheadOfTime/NativeAOT/check.ps1
@@ -9,29 +9,39 @@
$ErrorActionPreference = "Stop"
$root = "NativeAOT_Test"
-$tfm = "net9.0"
+
+# net9.0 is the stable control; the shipped net pin (FSharpCoreShippedNetTargetFramework) adds the
+# net-TFM leg exercising lib/ under NativeAOT. Derived from the knob so it follows a pin bump.
+$netPin = (Select-String -Path "$PSScriptRoot/../../../eng/TargetFrameworks.props" -Pattern 'FSharpCoreShippedNetTargetFramework[^>]*>(net\d+\.0)<').Matches[0].Groups[1].Value
+$tfms = @("net9.0", $netPin)
$cwd = Get-Location
Set-Location $PSScriptRoot
-
-dotnet publish -restore -c release -f:$tfm "$root.fsproj" -bl:"$PSScriptRoot/../../../artifacts/log/Release/AheadOfTime/NativeAOT/$root.binlog"
-if (-not ($LASTEXITCODE -eq 0)) {
- Set-Location $cwd
- Write-Error "NativeAOT publish failed with exit code $LASTEXITCODE" -ErrorAction Stop
+try {
+ foreach ($tfm in $tfms) {
+ Write-Host "NativeAOT publish + run: $tfm"
+
+ dotnet publish -restore -c release -f:$tfm "$root.fsproj" -bl:"$PSScriptRoot/../../../artifacts/log/Release/AheadOfTime/NativeAOT/${root}_${tfm}.binlog"
+ if (-not ($LASTEXITCODE -eq 0)) {
+ Write-Error "NativeAOT publish failed for $tfm with exit code $LASTEXITCODE" -ErrorAction Stop
+ }
+
+ $exe = Join-Path $PSScriptRoot "bin/release/$tfm/win-x64/publish/$root.exe"
+ $output = (& $exe) -join "`n"
+ $exitCode = $LASTEXITCODE
+
+ # The app prints a "FAILED" line per mismatch and "Finished" last, so its output is exactly "Finished" only if all checks passed.
+ if (-not ($exitCode -eq 0)) {
+ Write-Error "NativeAOT app crashed for $tfm with exit code $exitCode.`nOutput:`n$output" -ErrorAction Stop
+ }
+
+ if ($output.Trim() -ne "Finished") {
+ Write-Error "NativeAOT interpolation checks failed for $tfm.`nOutput:`n$output" -ErrorAction Stop
+ }
+
+ Write-Host "NativeAOT interpolated-string test passed for $tfm."
+ }
}
-
-$exe = Join-Path $PSScriptRoot "bin/release/$tfm/win-x64/publish/$root.exe"
-$output = (& $exe) -join "`n"
-$exitCode = $LASTEXITCODE
-Set-Location $cwd
-
-# The app prints a "FAILED" line per mismatch and "Finished" last, so its output is exactly "Finished" only if all checks passed.
-if (-not ($exitCode -eq 0)) {
- Write-Error "NativeAOT app crashed with exit code $exitCode.`nOutput:`n$output" -ErrorAction Stop
-}
-
-if ($output.Trim() -ne "Finished") {
- Write-Error "NativeAOT interpolation checks failed.`nOutput:`n$output" -ErrorAction Stop
+finally {
+ Set-Location $cwd
}
-
-Write-Host "NativeAOT interpolated-string test passed."
diff --git a/tests/AheadOfTime/NetTfmResolution/Directory.Build.props b/tests/AheadOfTime/NetTfmResolution/Directory.Build.props
new file mode 100644
index 00000000000..157d14ae227
--- /dev/null
+++ b/tests/AheadOfTime/NetTfmResolution/Directory.Build.props
@@ -0,0 +1,6 @@
+
+
+
diff --git a/tests/AheadOfTime/NetTfmResolution/Directory.Build.targets b/tests/AheadOfTime/NetTfmResolution/Directory.Build.targets
new file mode 100644
index 00000000000..adfd6924aed
--- /dev/null
+++ b/tests/AheadOfTime/NetTfmResolution/Directory.Build.targets
@@ -0,0 +1,3 @@
+
+
+
diff --git a/tests/AheadOfTime/NetTfmResolution/NetTfmResolution.fsproj b/tests/AheadOfTime/NetTfmResolution/NetTfmResolution.fsproj
new file mode 100644
index 00000000000..cb9615e5f52
--- /dev/null
+++ b/tests/AheadOfTime/NetTfmResolution/NetTfmResolution.fsproj
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ Exe
+ $(FSharpCoreShippedNetTargetFramework)
+ preview
+
+ Major
+
+ false
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/AheadOfTime/NetTfmResolution/NuGet.Config b/tests/AheadOfTime/NetTfmResolution/NuGet.Config
new file mode 100644
index 00000000000..5c38f25aeb2
--- /dev/null
+++ b/tests/AheadOfTime/NetTfmResolution/NuGet.Config
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/AheadOfTime/NetTfmResolution/Program.fs b/tests/AheadOfTime/NetTfmResolution/Program.fs
new file mode 100644
index 00000000000..77b0b2e4fbe
--- /dev/null
+++ b/tests/AheadOfTime/NetTfmResolution/Program.fs
@@ -0,0 +1,34 @@
+module NetTfmResolution.Program
+
+// Runtime smoke for the shipped net-TFM FSharp.Core asset (e2e-2). Exercises TaskBuilderBase.Using
+// for an IAsyncDisposable resource, guarded behind `#if NETSTANDARD2_1 || NET`: this `use` overload
+// is absent from the netstandard2.0 asset, so running it proves the widened net asset was resolved.
+
+open System
+open System.Threading.Tasks
+
+type private AsyncResource(recorder: string list ref) =
+ interface IAsyncDisposable with
+ member _.DisposeAsync() =
+ recorder.Value <- "disposed" :: recorder.Value
+ ValueTask.CompletedTask
+
+let private run () =
+ let recorder = ref []
+ let work =
+ task {
+ use _res = new AsyncResource(recorder)
+ return 42
+ }
+ let result = work.GetAwaiter().GetResult()
+ result, recorder.Value
+
+[]
+let main _ =
+ let result, disposals = run ()
+ if result = 42 && disposals = [ "disposed" ] then
+ printfn "NetTfmResolution OK: widened IAsyncDisposable task member executed."
+ 0
+ else
+ eprintfn "NetTfmResolution FAILED: result=%d disposals=%A" result disposals
+ 1
diff --git a/tests/AheadOfTime/NetTfmResolution/VerifyNetResolution.fsx b/tests/AheadOfTime/NetTfmResolution/VerifyNetResolution.fsx
new file mode 100644
index 00000000000..c2567662d85
--- /dev/null
+++ b/tests/AheadOfTime/NetTfmResolution/VerifyNetResolution.fsx
@@ -0,0 +1,151 @@
+// Consumer asset-RESOLUTION test (e2e-2, the linchpin): restores a net-TFM consumer against the
+// locally built FSharp.Core and witnesses from obj/project.assets.json that FSharp.Core resolved to
+// lib//FSharp.Core.dll for BOTH compile and runtime (never a netstandard asset). Only the
+// FSharp.Core cache entry for that version is purged, never the whole packages dir. A build+run smoke
+// of a widened member follows, reported but non-fatal locally (the structural witness is the gate).
+//
+// Portable (Linux/macOS/Windows): dotnet fsi tests/AheadOfTime/NetTfmResolution/VerifyNetResolution.fsx
+
+open System
+open System.IO
+open System.Text.Json
+open System.Text.RegularExpressions
+open System.Diagnostics
+
+let scriptDir = __SOURCE_DIRECTORY__
+let repoRoot = Path.GetFullPath(Path.Combine(scriptDir, "..", "..", ".."))
+let proj = Path.Combine(scriptDir, "NetTfmResolution.fsproj")
+let dotnet =
+ let local = Path.Combine(repoRoot, ".dotnet", if OperatingSystem.IsWindows() then "dotnet.exe" else "dotnet")
+ if File.Exists local then local else "dotnet"
+
+let run (fileName: string) (args: string) (workDir: string) =
+ let psi = ProcessStartInfo(fileName, args, WorkingDirectory = workDir, UseShellExecute = false,
+ RedirectStandardOutput = true, RedirectStandardError = true)
+ use p = Process.Start psi
+ let out = p.StandardOutput.ReadToEnd()
+ let err = p.StandardError.ReadToEnd()
+ p.WaitForExit()
+ p.ExitCode, out, err
+
+// 1. Newest built FSharp.Core nupkg. The shipped package lands in different sub-lanes depending on
+// the pack (top-level Shipping locally, Dependency/Shipping on CI); search both and take the newest.
+let searchDirs =
+ [ "artifacts/packages/Release/Shipping"
+ "artifacts/packages/Release/Dependency/Shipping" ]
+ |> List.map (fun d -> Path.Combine(repoRoot, d))
+let nupkg =
+ searchDirs
+ |> List.collect (fun d -> if Directory.Exists d then Directory.GetFiles(d, "FSharp.Core.*.nupkg") |> List.ofArray else [])
+ |> List.filter (fun f -> not (f.EndsWith ".symbols.nupkg"))
+ |> List.sortByDescending File.GetLastWriteTimeUtc
+ |> List.tryHead
+
+match nupkg with
+| None ->
+ eprintfn "e2e-2: no FSharp.Core.*.nupkg under: %s — pack first." (String.Join("; ", searchDirs))
+ exit 2
+| Some nupkgPath ->
+
+let ver = Regex.Replace(Path.GetFileName nupkgPath, @"^FSharp\.Core\.(.*)\.nupkg$", "$1")
+printfn "e2e-2: consumer will pin FSharp.Core %s" ver
+
+// 2. Clean the consumer obj/bin and only the cached FSharp.Core for this version.
+for sub in [ "obj"; "bin" ] do
+ let d = Path.Combine(scriptDir, sub)
+ if Directory.Exists d then Directory.Delete(d, true)
+
+// Stage the built nupkg into a clean, flat local feed the consumer's NuGet.Config points at. A
+// depth-0 flat folder is resolved deterministically everywhere, unlike pointing NuGet at the
+// packages root (its folder-source recursion does not reliably reach the Dependency sub-lane on CI).
+let feedDir = Path.Combine(scriptDir, "obj", "localfeed")
+Directory.CreateDirectory feedDir |> ignore
+File.Copy(nupkgPath, Path.Combine(feedDir, Path.GetFileName nupkgPath), true)
+
+let nugetPackages =
+ match Environment.GetEnvironmentVariable "NUGET_PACKAGES" with
+ | null | "" -> Path.Combine(Environment.GetFolderPath Environment.SpecialFolder.UserProfile, ".nuget", "packages")
+ | p -> p
+let cachedFsCore = Path.Combine(nugetPackages, "fsharp.core", ver)
+if Directory.Exists cachedFsCore then
+ printfn "e2e-2: purging cached fsharp.core/%s" ver
+ Directory.Delete(cachedFsCore, true)
+
+let rc, out, err = run dotnet (sprintf "restore \"%s\" -p:FSharpCoreTestVersion=%s --nologo" proj ver) scriptDir
+if rc <> 0 then
+ eprintfn "e2e-2: restore FAILED (exit %d)\n%s\n%s" rc out err
+ exit 1
+printfn "e2e-2: restore OK"
+
+// 3. Structural witness from project.assets.json.
+let assetsPath = Path.Combine(scriptDir, "obj", "project.assets.json")
+if not (File.Exists assetsPath) then
+ eprintfn "e2e-2: %s not produced" assetsPath
+ exit 1
+
+let doc = JsonDocument.Parse(File.ReadAllText assetsPath)
+let root = doc.RootElement
+
+let mutable errors = []
+let fail m = errors <- m :: errors
+let netLibRegex = Regex(@"^lib/net\d+\.0/FSharp\.Core\.dll$", RegexOptions.IgnoreCase)
+let nsLibRegex = Regex(@"^lib/netstandard\d\.\d/FSharp\.Core\.dll$", RegexOptions.IgnoreCase)
+
+// targets -> -> "FSharp.Core/" -> { compile: {...}, runtime: {...} }
+let mutable checkedAny = false
+let targets = root.GetProperty("targets")
+for tfmProp in targets.EnumerateObject() do
+ for libProp in tfmProp.Value.EnumerateObject() do
+ if libProp.Name.StartsWith("FSharp.Core/", StringComparison.OrdinalIgnoreCase) then
+ checkedAny <- true
+ let libVer = libProp.Name.Substring("FSharp.Core/".Length)
+ if libVer <> ver then fail (sprintf "resolved FSharp.Core %s, expected the built %s" libVer ver)
+ let checkSection (section: string) =
+ match libProp.Value.TryGetProperty section with
+ | true, sec ->
+ let paths = sec.EnumerateObject() |> Seq.map (fun p -> p.Name.Replace('\\','/')) |> Seq.toList
+ let dllPaths = paths |> List.filter (fun p -> p.EndsWith("FSharp.Core.dll", StringComparison.OrdinalIgnoreCase))
+ match dllPaths with
+ | [] ->
+ // No FSharp.Core.dll selected for this section — a resolution failure. A "_._"
+ // placeholder is NuGet's explicit "no asset"; either way the section is unbound.
+ let detail = if paths |> List.exists (fun p -> p.EndsWith "_._") then " (_._ placeholder)" else ""
+ fail (sprintf "%s under '%s' selected no FSharp.Core.dll asset%s" section tfmProp.Name detail)
+ | ps ->
+ for p in ps do
+ if nsLibRegex.IsMatch p then fail (sprintf "%s resolved to a netstandard asset (%s) under '%s' — expected the net TFM asset" section p tfmProp.Name)
+ elif not (netLibRegex.IsMatch p) then fail (sprintf "%s resolved to an unexpected path '%s' under '%s'" section p tfmProp.Name)
+ else printfn "e2e-2: %s [%s] -> %s ✓" section tfmProp.Name p
+ // A resolved package always carries both compile and runtime; an absent section means
+ // FSharp.Core did not bind for that phase — fail closed rather than silently pass.
+ | false, _ -> fail (sprintf "no '%s' section for FSharp.Core under '%s' — not bound for that phase" section tfmProp.Name)
+ checkSection "compile"
+ checkSection "runtime"
+
+if not checkedAny then fail "FSharp.Core not found in project.assets.json targets"
+
+match errors with
+| _ :: _ ->
+ eprintfn "e2e-2: STRUCTURAL WITNESS FAILED:"
+ for e in List.rev errors do eprintfn " - %s" e
+ exit 1
+| [] ->
+
+printfn "e2e-2: structural witness OK — FSharp.Core compile+runtime both bound to the net TFM lib."
+
+// 4. Best-effort build + run of the runtime smoke.
+let brc, bout, berr = run dotnet (sprintf "build \"%s\" -c Release -p:FSharpCoreTestVersion=%s --no-restore --nologo" proj ver) scriptDir
+if brc <> 0 then
+ printfn "e2e-2: (non-fatal locally) build did not succeed:\n%s\n%s" bout berr
+ printfn "e2e-2: PASS on the structural witness (the authoritative gate)."
+ exit 0
+
+let rrc, rout, rerr = run dotnet (sprintf "run --project \"%s\" -c Release -p:FSharpCoreTestVersion=%s --no-build --no-restore" proj ver) scriptDir
+printf "%s" rout
+if rrc <> 0 then
+ printfn "e2e-2: (non-fatal locally) run did not succeed (net shared framework may be absent):\n%s" rerr
+ printfn "e2e-2: PASS on the structural witness (the authoritative gate)."
+ exit 0
+
+printfn "e2e-2: PASS — structural witness + runtime smoke both green."
+exit 0
diff --git a/tests/AheadOfTime/Trimming/FSharpMetadataResource_Trimming_Test/FSharpMetadataResource_Trimming_Test.fsproj b/tests/AheadOfTime/Trimming/FSharpMetadataResource_Trimming_Test/FSharpMetadataResource_Trimming_Test.fsproj
index e1f26527b84..8ee38473af9 100644
--- a/tests/AheadOfTime/Trimming/FSharpMetadataResource_Trimming_Test/FSharpMetadataResource_Trimming_Test.fsproj
+++ b/tests/AheadOfTime/Trimming/FSharpMetadataResource_Trimming_Test/FSharpMetadataResource_Trimming_Test.fsproj
@@ -2,7 +2,9 @@
Exe
- net9.0
+
+ net9.0;$(FSharpCoreShippedNetTargetFramework)
preview
true
3879
diff --git a/tests/AheadOfTime/Trimming/SelfContained_Trimming_Test/SelfContained_Trimming_Test.fsproj b/tests/AheadOfTime/Trimming/SelfContained_Trimming_Test/SelfContained_Trimming_Test.fsproj
index 8930dd44e5d..19ca2fee361 100644
--- a/tests/AheadOfTime/Trimming/SelfContained_Trimming_Test/SelfContained_Trimming_Test.fsproj
+++ b/tests/AheadOfTime/Trimming/SelfContained_Trimming_Test/SelfContained_Trimming_Test.fsproj
@@ -2,7 +2,9 @@
Exe
- net9.0
+
+ net9.0;$(FSharpCoreShippedNetTargetFramework)
preview
true
3879
diff --git a/tests/AheadOfTime/Trimming/StaticLinkedFSharpCore_Trimming_Test/StaticLinkedFSharpCore_Trimming_Test.fsproj b/tests/AheadOfTime/Trimming/StaticLinkedFSharpCore_Trimming_Test/StaticLinkedFSharpCore_Trimming_Test.fsproj
index 16e648e78d3..668f0012792 100644
--- a/tests/AheadOfTime/Trimming/StaticLinkedFSharpCore_Trimming_Test/StaticLinkedFSharpCore_Trimming_Test.fsproj
+++ b/tests/AheadOfTime/Trimming/StaticLinkedFSharpCore_Trimming_Test/StaticLinkedFSharpCore_Trimming_Test.fsproj
@@ -2,7 +2,9 @@
Exe
- net9.0
+
+ net9.0;$(FSharpCoreShippedNetTargetFramework)
preview
true
3879
diff --git a/tests/AheadOfTime/Trimming/check.ps1 b/tests/AheadOfTime/Trimming/check.ps1
index 406eefc616e..7afdf7554c4 100644
--- a/tests/AheadOfTime/Trimming/check.ps1
+++ b/tests/AheadOfTime/Trimming/check.ps1
@@ -62,6 +62,10 @@ function CheckTrim($root, $tfm, $outputfile, $expected_len, $callerLineNumber) {
$allErrors = @()
+# Derive the shipped net TFM from the single source of truth (the knob), so these legs follow
+# the pin automatically when it bumps (no literal net10.0 to drift).
+$netPin = (Select-String -Path "$PSScriptRoot/../../../eng/TargetFrameworks.props" -Pattern 'FSharpCoreShippedNetTargetFramework[^>]*>(net\d+\.0)<').Matches[0].Groups[1].Value
+
# Check net9.0 trimmed assemblies.
$allErrors += CheckTrim -root "SelfContained_Trimming_Test" -tfm "net9.0" -outputfile "FSharp.Core.dll" -expected_len 311296 -callerLineNumber 66
@@ -73,6 +77,13 @@ $allErrors += CheckTrim -root "StaticLinkedFSharpCore_Trimming_Test" -tfm "net9.
# Check net9.0 trimmed assemblies with F# metadata resources removed
$allErrors += CheckTrim -root "FSharpMetadataResource_Trimming_Test" -tfm "net9.0" -outputfile "FSharpMetadataResource_Trimming_Test.dll" -expected_len 7613440 -callerLineNumber 74
+# Check the shipped net TFM () trimmed assemblies. The GATE here is that publish SUCCEEDS
+# under TreatWarningsAsErrors=true (IL2xxx/IL3050 become build errors); sizes are report-only
+# (-expected_len -1) because the byte count churns across net previews.
+$allErrors += CheckTrim -root "SelfContained_Trimming_Test" -tfm $netPin -outputfile "FSharp.Core.dll" -expected_len -1 -callerLineNumber 84
+$allErrors += CheckTrim -root "StaticLinkedFSharpCore_Trimming_Test" -tfm $netPin -outputfile "StaticLinkedFSharpCore_Trimming_Test.dll" -expected_len -1 -callerLineNumber 85
+$allErrors += CheckTrim -root "FSharpMetadataResource_Trimming_Test" -tfm $netPin -outputfile "FSharpMetadataResource_Trimming_Test.dll" -expected_len -1 -callerLineNumber 86
+
# Report all errors and exit with failure if any occurred
if ($allErrors.Count -gt 0) {
Write-Host ""
diff --git a/tests/FSharp.Core.ApiCompat/FSharp.Core.ApiCompat.proj b/tests/FSharp.Core.ApiCompat/FSharp.Core.ApiCompat.proj
new file mode 100644
index 00000000000..86e7b7a1f34
--- /dev/null
+++ b/tests/FSharp.Core.ApiCompat/FSharp.Core.ApiCompat.proj
@@ -0,0 +1,50 @@
+
+
+
+
+
+ $(FSharpNetCoreProductTargetFramework)
+ false
+ false
+ true
+ Release
+
+
+
+ <_FSharpCoreBinDir>$(MSBuildThisFileDirectory)..\..\artifacts\bin\FSharp.Core\$(Configuration)
+ <_FSharpCoreNsAssembly>$([MSBuild]::NormalizePath('$(_FSharpCoreBinDir)', 'netstandard2.1', 'FSharp.Core.dll'))
+ <_FSharpCoreNetAssembly>$([MSBuild]::NormalizePath('$(_FSharpCoreBinDir)', '$(FSharpCoreShippedNetTargetFramework)', 'FSharp.Core.dll'))
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/FSharp.Core.PackageVerification/VerifyFSharpCorePackage.fsx b/tests/FSharp.Core.PackageVerification/VerifyFSharpCorePackage.fsx
new file mode 100644
index 00000000000..46f2d07aff9
--- /dev/null
+++ b/tests/FSharp.Core.PackageVerification/VerifyFSharpCorePackage.fsx
@@ -0,0 +1,123 @@
+// End-to-end verification of the SHIPPED FSharp.Core NuGet package (e2e-1): asserts the lib layout
+// (netstandard2.0/netstandard2.1 + the discovered net TFM, non-degenerate), satellite resources, a
+// matching nuspec dependency group, and a uniform AssemblyVersion across the three lib assemblies.
+// The net TFM folder is discovered from the package, never hard-coded.
+//
+// Portable pure .NET; runnable on Linux/macOS/Windows.
+// Usage: dotnet fsi tests/FSharp.Core.PackageVerification/VerifyFSharpCorePackage.fsx []
+
+open System
+open System.IO
+open System.IO.Compression
+open System.Reflection
+open System.Text.RegularExpressions
+
+let repoRoot =
+ let here = __SOURCE_DIRECTORY__
+ Path.GetFullPath(Path.Combine(here, "..", ".."))
+
+let searchDirs =
+ match fsi.CommandLineArgs |> Array.tryItem 1 with
+ | Some d -> [ d ]
+ | None ->
+ // Prefer the shipping lane, then the embedded dependency lanes.
+ [ "artifacts/packages/Release/Shipping"
+ "artifacts/packages/Release/Dependency/Shipping"
+ "artifacts/packages/Debug/Shipping" ]
+ |> List.map (fun d -> Path.Combine(repoRoot, d))
+
+let mutable errors = []
+let fail msg = errors <- msg :: errors
+let netTfmRegex = Regex(@"^lib/(net\d+\.0)/", RegexOptions.Compiled)
+
+let findPackage () =
+ searchDirs
+ |> List.collect (fun d -> if Directory.Exists d then Directory.GetFiles(d, "FSharp.Core.*.nupkg") |> List.ofArray else [])
+ |> List.filter (fun f -> not (f.EndsWith(".symbols.nupkg")))
+ |> List.sortByDescending File.GetLastWriteTimeUtc
+ |> List.tryHead
+
+match findPackage () with
+| None ->
+ eprintfn "e2e-1: no FSharp.Core.*.nupkg found under: %s" (String.Join("; ", searchDirs))
+ eprintfn " Pack first, e.g.: dotnet msbuild src/FSharp.Core/FSharp.Core.fsproj -t:Pack -p:Configuration=Release -p:DISABLE_ARCADE=false -p:Restore=true"
+ exit 2
+| Some pkg ->
+
+printfn "e2e-1: verifying %s" (Path.GetFileName pkg)
+use zip = ZipFile.OpenRead pkg
+let entries = zip.Entries |> Seq.map (fun e -> e.FullName.Replace('\\', '/')) |> Seq.toList
+
+// Discover the shipped net TFM folder from the package itself.
+let discoveredNetTfms =
+ entries
+ |> List.choose (fun e -> let m = netTfmRegex.Match e in if m.Success then Some m.Groups.[1].Value else None)
+ |> List.distinct
+
+match discoveredNetTfms with
+| [] -> fail "no lib/netNN.0 folder found in the package (the shipped net TFM lib is missing)"
+| [ pin ] -> printfn "e2e-1: discovered shipped net TFM lib = %s" pin
+| many -> fail (sprintf "expected exactly one lib/netNN.0 folder, found: %s" (String.Join(", ", many)))
+
+let pin = discoveredNetTfms |> List.tryHead |> Option.defaultValue "net?"
+
+let entryExists (path: string) = entries |> List.exists (fun e -> e.Equals(path, StringComparison.OrdinalIgnoreCase))
+let entrySize (path: string) =
+ zip.Entries |> Seq.tryFind (fun e -> e.FullName.Replace('\\','/').Equals(path, StringComparison.OrdinalIgnoreCase))
+ |> Option.map (fun e -> e.Length) |> Option.defaultValue 0L
+
+// Required lib layout: each TFM must ship a non-degenerate dll + xml.
+let requiredLibs = [ "netstandard2.0"; "netstandard2.1"; pin ]
+let checkAsset (minSize: int64) (path: string) =
+ let size = entrySize path
+ if not (entryExists path) then fail (sprintf "missing %s" path)
+ elif size < minSize then fail (sprintf "%s is degenerate (%d bytes)" path size)
+for tfm in requiredLibs do
+ checkAsset 100000L (sprintf "lib/%s/FSharp.Core.dll" tfm)
+ checkAsset 1000L (sprintf "lib/%s/FSharp.Core.xml" tfm)
+
+// Satellite resources for the shipped net TFM.
+let satellitePattern = Regex(sprintf @"^lib/%s/[^/]+/FSharp\.Core\.resources\.dll$" (Regex.Escape pin))
+if not (entries |> List.exists satellitePattern.IsMatch) then
+ fail (sprintf "no satellite **/FSharp.Core.resources.dll under lib/%s" pin)
+
+// Nuspec dependency group for the shipped net TFM.
+let nuspecEntry = zip.Entries |> Seq.tryFind (fun e -> e.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase))
+match nuspecEntry with
+| None -> fail "no .nuspec in the package"
+| Some e ->
+ use r = new StreamReader(e.Open())
+ let nuspec = r.ReadToEnd()
+ let groupRegex = Regex(sprintf @" dependency group" pin)
+
+// AssemblyVersion equality across all three lib assemblies.
+let tempDir = Path.Combine(Path.GetTempPath(), "fscore-e2e1-" + Guid.NewGuid().ToString("N"))
+Directory.CreateDirectory tempDir |> ignore
+try
+ let versions =
+ requiredLibs
+ |> List.choose (fun tfm ->
+ let src = sprintf "lib/%s/FSharp.Core.dll" tfm
+ match zip.Entries |> Seq.tryFind (fun e -> e.FullName.Replace('\\','/').Equals(src, StringComparison.OrdinalIgnoreCase)) with
+ | None -> None
+ | Some e ->
+ let dst = Path.Combine(tempDir, tfm + "-FSharp.Core.dll")
+ e.ExtractToFile(dst, true)
+ Some (tfm, AssemblyName.GetAssemblyName(dst).Version))
+ for (tfm, v) in versions do printfn "e2e-1: lib/%s AssemblyVersion = %O" tfm v
+ match versions |> List.map snd |> List.distinct with
+ | [ _ ] -> ()
+ | vs -> fail (sprintf "AssemblyVersion mismatch across lib TFMs: %s" (String.Join(", ", vs)))
+finally
+ try Directory.Delete(tempDir, true) with _ -> ()
+
+match errors with
+| [] ->
+ printfn "e2e-1: OK — lib/{netstandard2.0,netstandard2.1,%s} present, satellites + nuspec group present, AssemblyVersion uniform." pin
+ exit 0
+| es ->
+ eprintfn "e2e-1: FAILED:"
+ for e in List.rev es do eprintfn " - %s" e
+ exit 1