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