diff --git a/.gitignore b/.gitignore
index 819656f16c3..d9a2e5384de 100644
--- a/.gitignore
+++ b/.gitignore
@@ -148,3 +148,13 @@ TestResults/*.trx
StandardOutput.txt
StandardError.txt
**/TestResults/
+
+# CompilerCompat test project generated files
+tests/projects/CompilerCompat/**/nuget.config
+tests/projects/CompilerCompat/**/global.json
+tests/projects/CompilerCompat/**/*.deps.json
+tests/projects/CompilerCompat/**/*.xml
+tests/projects/CompilerCompat/local-nuget-packages/
+tests/projects/CompilerCompat/lib-output-*/
+tests/projects/CompilerCompat/**/bin/
+tests/projects/CompilerCompat/**/obj/
diff --git a/azure-pipelines-PR.yml b/azure-pipelines-PR.yml
index cfa5411ee33..ff316adf56a 100644
--- a/azure-pipelines-PR.yml
+++ b/azure-pipelines-PR.yml
@@ -740,6 +740,8 @@ stages:
env:
FSHARP_EXPERIMENTAL_FEATURES: $(_experimental_flag)
displayName: End to end build tests
+ - script: .\eng\common\dotnet.cmd fsi .\tests\FSharp.Compiler.ComponentTests\CompilerCompatibilityTests.fsx
+ displayName: Compiler compatibility tests
# Up-to-date - disabled due to it being flaky
#- job: UpToDate_Windows
diff --git a/tests/FSharp.Compiler.ComponentTests/CompilerCompatibilityTests.fsx b/tests/FSharp.Compiler.ComponentTests/CompilerCompatibilityTests.fsx
new file mode 100644
index 00000000000..e2c91e120f2
--- /dev/null
+++ b/tests/FSharp.Compiler.ComponentTests/CompilerCompatibilityTests.fsx
@@ -0,0 +1,246 @@
+#!/usr/bin/env dotnet fsi
+
+// Standalone F# script to test compiler compatibility across different F# SDK versions
+// Can be run with: dotnet fsi CompilerCompatibilityTests.fsx
+
+open System
+open System.IO
+open System.Diagnostics
+
+// Configuration
+let compilerConfiguration = "Release"
+let repoRoot = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "../.."))
+let projectsPath = Path.Combine(__SOURCE_DIRECTORY__, "../projects/CompilerCompat")
+let libProjectPath = Path.Combine(projectsPath, "CompilerCompatLib")
+let appProjectPath = Path.Combine(projectsPath, "CompilerCompatApp")
+
+// Test scenarios: (libCompiler, appCompiler, description)
+let testScenarios = [
+ ("local", "local", "Baseline - Both library and app built with local compiler")
+ ("latest", "local", "Forward compatibility - Library with SDK, app with local")
+ ("local", "latest", "Backward compatibility - Library with local, app with SDK")
+ ("latest", "latest", "SDK only - Both library and app built with latest SDK")
+ ("net9", "local", "Net9 forward compatibility - Library with .NET 9 SDK, app with local")
+ ("local", "net9", "Net9 backward compatibility - Library with local, app with .NET 9 SDK")
+]
+
+// Helper functions
+let runCommand (command: string) (args: string) (workingDir: string) (envVars: (string * string) list) =
+ let psi = ProcessStartInfo()
+ psi.FileName <- command
+ psi.Arguments <- args
+ psi.WorkingDirectory <- workingDir
+ psi.RedirectStandardOutput <- true
+ psi.RedirectStandardError <- true
+ psi.UseShellExecute <- false
+ psi.CreateNoWindow <- true
+
+ // Set environment variables
+ for (key, value) in envVars do
+ psi.EnvironmentVariables.[key] <- value
+
+ use p = new Process()
+ p.StartInfo <- psi
+
+ if not (p.Start()) then
+ failwith $"Failed to start process: {command} {args}"
+
+ let stdout = p.StandardOutput.ReadToEnd()
+ let stderr = p.StandardError.ReadToEnd()
+ p.WaitForExit()
+
+ if p.ExitCode <> 0 then
+ printfn "Command failed: %s %s" command args
+ printfn "Working directory: %s" workingDir
+ printfn "Exit code: %d" p.ExitCode
+ printfn "Stdout: %s" stdout
+ printfn "Stderr: %s" stderr
+ failwith $"Command exited with code {p.ExitCode}"
+
+ stdout
+
+let cleanDirectory path =
+ if Directory.Exists(path) then
+ Directory.Delete(path, true)
+
+let cleanBinObjDirectories projectPath =
+ cleanDirectory (Path.Combine(projectPath, "bin"))
+ cleanDirectory (Path.Combine(projectPath, "obj"))
+ let libBuildInfo = Path.Combine(projectPath, "LibBuildInfo.fs")
+ let appBuildInfo = Path.Combine(projectPath, "AppBuildInfo.fs")
+ if File.Exists(libBuildInfo) then File.Delete(libBuildInfo)
+ if File.Exists(appBuildInfo) then File.Delete(appBuildInfo)
+
+let manageGlobalJson compilerVersion enable =
+ let globalJsonPath = Path.Combine(projectsPath, "global.json")
+ if compilerVersion = "net9" then
+ if enable && not (File.Exists(globalJsonPath) && File.ReadAllText(globalJsonPath).Contains("9.0.0")) then
+ printfn " Enabling .NET 9 SDK via global.json..."
+ let globalJsonContent = """{
+ "sdk": {
+ "version": "9.0.0",
+ "rollForward": "latestMajor"
+ },
+ "msbuild-sdks": {
+ "Microsoft.DotNet.Arcade.Sdk": "11.0.0-beta.25509.1"
+ }
+}"""
+ File.WriteAllText(globalJsonPath, globalJsonContent)
+ elif not enable && File.Exists(globalJsonPath) then
+ printfn " Removing global.json..."
+ File.Delete(globalJsonPath)
+
+let packProject projectPath compilerVersion outputDir =
+ let useLocal = (compilerVersion = "local")
+ // Use timestamp-based version to ensure fresh package each time
+ let timestamp = DateTime.Now.ToString("HHmmss")
+ let envVars = [
+ ("LoadLocalFSharpBuild", if useLocal then "True" else "False")
+ ("LocalFSharpCompilerConfiguration", compilerConfiguration)
+ ("PackageVersion", $"1.0.{timestamp}")
+ ]
+
+ // Manage global.json for net9 compiler
+ manageGlobalJson compilerVersion true
+
+ printfn " Packing library with %s compiler..." compilerVersion
+ let projectFile = Path.Combine(projectPath, "CompilerCompatLib.fsproj")
+ let output = runCommand "dotnet" $"pack \"{projectFile}\" -c {compilerConfiguration} -o \"{outputDir}\"" projectPath envVars
+
+ // Clean up global.json after pack
+ manageGlobalJson compilerVersion false
+
+ output |> ignore
+
+let buildApp projectPath compilerVersion =
+ let useLocal = (compilerVersion = "local")
+ let envVars = [
+ ("LoadLocalFSharpBuild", if useLocal then "True" else "False")
+ ("LocalFSharpCompilerConfiguration", compilerConfiguration)
+ ]
+
+ // Manage global.json for net9 compiler
+ manageGlobalJson compilerVersion true
+
+ printfn " Building app with %s compiler..." compilerVersion
+ let projectFile = Path.Combine(projectPath, "CompilerCompatApp.fsproj")
+
+ // First restore with force to get fresh NuGet packages
+ runCommand "dotnet" $"restore \"{projectFile}\" --force --no-cache" projectPath envVars |> ignore
+
+ // Then build
+ runCommand "dotnet" $"build \"{projectFile}\" -c {compilerConfiguration} --no-restore" projectPath envVars
+ |> ignore
+
+ // Clean up global.json after build
+ manageGlobalJson compilerVersion false
+
+let runApp() =
+ let appDll = Path.Combine(appProjectPath, "bin", compilerConfiguration, "net8.0", "CompilerCompatApp.dll")
+ printfn " Running app..."
+ // Use --roll-forward Major to allow running net8.0 app on net10.0 runtime
+ let envVars = [
+ ("DOTNET_ROLL_FORWARD", "Major")
+ ]
+ let output = runCommand "dotnet" $"\"{appDll}\"" appProjectPath envVars
+ output
+
+let extractValue (sectionHeader: string) (searchPattern: string) (lines: string array) =
+ lines
+ |> Array.tryFindIndex (fun (l: string) -> l.StartsWith(sectionHeader))
+ |> Option.bind (fun startIdx ->
+ lines
+ |> Array.skip (startIdx + 1)
+ |> Array.take (min 10 (lines.Length - startIdx - 1))
+ |> Array.tryFind (fun (l: string) -> l.Contains(searchPattern)))
+
+let verifyOutput libCompilerVersion appCompilerVersion (output: string) =
+ let lines = output.Split('\n') |> Array.map (fun (s: string) -> s.Trim())
+
+ // Check for success message
+ if not (Array.exists (fun (l: string) -> l.Contains("SUCCESS: All compiler compatibility tests passed")) lines) then
+ failwith "App did not report success"
+
+ // Extract build info
+ let getBool section pattern =
+ extractValue section pattern lines
+ |> Option.map (fun l -> l.Contains("true"))
+ |> Option.defaultValue false
+
+ let libIsLocal = getBool "Library Build Info:" "Is Local Build:"
+ let appIsLocal = getBool "Application Build Info:" "Is Local Build:"
+
+ // Verify - both "latest" and "net9" should result in isLocalBuild=false
+ let expectedLibIsLocal = (libCompilerVersion = "local")
+ let expectedAppIsLocal = (appCompilerVersion = "local")
+
+ if libIsLocal <> expectedLibIsLocal then
+ failwith $"Library: expected isLocalBuild={expectedLibIsLocal} for '{libCompilerVersion}', but got {libIsLocal}"
+
+ if appIsLocal <> expectedAppIsLocal then
+ failwith $"App: expected isLocalBuild={expectedAppIsLocal} for '{appCompilerVersion}', but got {appIsLocal}"
+
+ printfn " ✓ Build info verification passed"
+
+// Main test execution
+let runTest (libCompiler, appCompiler, description) =
+ printfn "\n=== Test: %s ===" description
+ printfn "Library compiler: %s, App compiler: %s" libCompiler appCompiler
+
+ try
+ // Clean previous builds
+ cleanBinObjDirectories libProjectPath
+ cleanBinObjDirectories appProjectPath
+
+ // Create local NuGet directory
+ let localNuGetDir = Path.Combine(projectsPath, "local-nuget-packages")
+ cleanDirectory localNuGetDir
+ Directory.CreateDirectory(localNuGetDir) |> ignore
+
+ // Create nuget.config for app
+ let nugetConfig = Path.Combine(appProjectPath, "nuget.config")
+ let nugetConfigContent = $"""
+
+
+
+
+
+
+"""
+ File.WriteAllText(nugetConfig, nugetConfigContent)
+
+ // Pack library
+ packProject libProjectPath libCompiler localNuGetDir
+
+ // Build and run app
+ buildApp appProjectPath appCompiler
+ let output = runApp()
+
+ // Verify
+ verifyOutput libCompiler appCompiler output
+
+ printfn "✓ PASSED: %s" description
+ true
+ with ex ->
+ printfn "✗ FAILED: %s" description
+ printfn "Error: %s" ex.Message
+ false
+
+// Run all tests
+printfn "F# Compiler Compatibility Test Suite"
+printfn "======================================"
+
+let results = testScenarios |> List.map runTest
+
+let passed = results |> List.filter id |> List.length
+let total = results |> List.length
+
+printfn "\n======================================"
+printfn "Results: %d/%d tests passed" passed total
+
+if passed = total then
+ printfn "All tests PASSED ✓"
+ exit 0
+else
+ printfn "Some tests FAILED ✗"
+ exit 1
diff --git a/tests/FSharp.Compiler.ComponentTests/CompilerOptions/fsc/checked/checked.fs b/tests/FSharp.Compiler.ComponentTests/CompilerOptions/fsc/checked/checked.fs
index b842f04de77..7421bd50261 100644
--- a/tests/FSharp.Compiler.ComponentTests/CompilerOptions/fsc/checked/checked.fs
+++ b/tests/FSharp.Compiler.ComponentTests/CompilerOptions/fsc/checked/checked.fs
@@ -23,7 +23,7 @@ module Checked =
compilation
|> getCompilation
|> asFs
- |> withOptions["--checked"]
+ |> withOptions ["--checked"]
|> compile
|> shouldSucceed
@@ -33,7 +33,7 @@ module Checked =
compilation
|> getCompilation
|> asFs
- |> withOptions["--checked+"]
+ |> withOptions ["--checked+"]
|> compile
|> shouldSucceed
@@ -43,7 +43,7 @@ module Checked =
compilation
|> getCompilation
|> asFs
- |> withOptions["--checked-"]
+ |> withOptions ["--checked-"]
|> compile
|> shouldSucceed
@@ -53,7 +53,7 @@ module Checked =
compilation
|> getCompilation
|> asFsx
- |> withOptions["--checked-"]
+ |> withOptions ["--checked-"]
|> compile
|> shouldSucceed
@@ -63,7 +63,7 @@ module Checked =
compilation
|> getCompilation
|> asFsx
- |> withOptions["--checked"]
+ |> withOptions ["--checked"]
|> compile
|> shouldSucceed
@@ -73,7 +73,7 @@ module Checked =
compilation
|> getCompilation
|> asFsx
- |> withOptions["--checked+"]
+ |> withOptions ["--checked+"]
|> compile
|> shouldSucceed
@@ -83,7 +83,7 @@ module Checked =
compilation
|> getCompilation
|> asFsx
- |> withOptions["--checked-"]
+ |> withOptions ["--checked-"]
|> compile
|> shouldSucceed
@@ -96,7 +96,7 @@ module Checked =
compilation
|> getCompilation
|> asFs
- |> withOptions["--checked"; "--checked+"]
+ |> withOptions ["--checked"; "--checked+"]
|> compile
|> shouldSucceed
@@ -106,7 +106,7 @@ module Checked =
compilation
|> getCompilation
|> asFs
- |> withOptions["--checked-"; "--checked+"]
+ |> withOptions ["--checked-"; "--checked+"]
|> compile
|> shouldSucceed
@@ -116,7 +116,7 @@ module Checked =
compilation
|> getCompilation
|> asFs
- |> withOptions["--checked+"; "--checked-"]
+ |> withOptions ["--checked+"; "--checked-"]
|> compile
|> shouldSucceed
@@ -126,7 +126,7 @@ module Checked =
compilation
|> getCompilation
|> asFsx
- |> withOptions["--checked"; "--checked+"]
+ |> withOptions ["--checked"; "--checked+"]
|> compile
|> shouldSucceed
@@ -136,7 +136,7 @@ module Checked =
compilation
|> getCompilation
|> asFsx
- |> withOptions["--checked-"; "--checked+"]
+ |> withOptions ["--checked-"; "--checked+"]
|> compile
|> shouldSucceed
@@ -146,7 +146,7 @@ module Checked =
compilation
|> getCompilation
|> asFsx
- |> withOptions["--checked+"; "--checked-"]
+ |> withOptions ["--checked+"; "--checked-"]
|> compile
|> shouldSucceed
@@ -157,7 +157,7 @@ module Checked =
compilation
|> getCompilation
|> asFs
- |> withOptions["--Checked"]
+ |> withOptions ["--Checked"]
|> compile
|> shouldFail
|> withDiagnostics [
@@ -170,7 +170,7 @@ module Checked =
compilation
|> getCompilation
|> asFs
- |> withOptions["--checked*"]
+ |> withOptions ["--checked*"]
|> compile
|> shouldFail
|> withDiagnostics [
diff --git a/tests/projects/CompilerCompat/.gitignore b/tests/projects/CompilerCompat/.gitignore
new file mode 100644
index 00000000000..658db31ab74
--- /dev/null
+++ b/tests/projects/CompilerCompat/.gitignore
@@ -0,0 +1,6 @@
+# Ignore test-generated files
+global.json
+commandline.txt
+**/*BuildInfo.fs
+StandardOutput.txt
+StandardError.txt
\ No newline at end of file
diff --git a/tests/projects/CompilerCompat/CompilerCompatApp/CompilerCompatApp.fsproj b/tests/projects/CompilerCompat/CompilerCompatApp/CompilerCompatApp.fsproj
new file mode 100644
index 00000000000..311c09b259b
--- /dev/null
+++ b/tests/projects/CompilerCompat/CompilerCompatApp/CompilerCompatApp.fsproj
@@ -0,0 +1,53 @@
+
+
+
+
+ false
+ false
+
+
+
+ Exe
+ net8.0
+
+
+ bin\
+ bin\$(Configuration)\
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ false
+ $(DotnetFscCompilerPath.Replace('"', ''))
+ N/A
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/projects/CompilerCompat/CompilerCompatApp/Program.fs b/tests/projects/CompilerCompat/CompilerCompatApp/Program.fs
new file mode 100644
index 00000000000..3463bb05d20
--- /dev/null
+++ b/tests/projects/CompilerCompat/CompilerCompatApp/Program.fs
@@ -0,0 +1,54 @@
+open CompilerCompatLib
+open CompilerCompatApp
+open System
+
+[]
+let main _argv =
+ try
+ // Helper to get the actual compiler path (prefer dotnetFscCompilerPath when using local build)
+ let getActualCompilerPath (dotnetPath: string) (fallbackPath: string) =
+ if dotnetPath <> "N/A" && dotnetPath <> "" then dotnetPath else fallbackPath
+
+ // Print detailed build information to verify which compiler was used
+ printfn "=== BUILD VERIFICATION ==="
+ printfn "Library Build Info:"
+ printfn " SDK Version: %s" LibBuildInfo.sdkVersion
+ printfn " F# Compiler Path: %s" (getActualCompilerPath LibBuildInfo.dotnetFscCompilerPath LibBuildInfo.fsharpCompilerPath)
+ printfn " Is Local Build: %b" LibBuildInfo.isLocalBuild
+ printfn "Application Build Info:"
+ printfn " SDK Version: %s" AppBuildInfo.sdkVersion
+ printfn " F# Compiler Path: %s" (getActualCompilerPath AppBuildInfo.dotnetFscCompilerPath AppBuildInfo.fsharpCompilerPath)
+ printfn " Is Local Build: %b" AppBuildInfo.isLocalBuild
+ printfn "=========================="
+
+ // Test basic anonymous record functionality
+ let record = Library.getAnonymousRecord()
+ printfn "Basic record: X=%d, Y=%s" record.X record.Y
+
+ // Verify expected values
+ if record.X <> 42 || record.Y <> "hello" then
+ printfn "ERROR: Basic record values don't match expected"
+ 1
+ else
+ printfn "SUCCESS: Basic record test passed"
+
+ // Test complex anonymous record functionality
+ let complex = Library.getComplexAnonymousRecord()
+ printfn "Complex record: Simple.A=%d, Simple.B=%s" complex.Simple.A complex.Simple.B
+ printfn "Complex record: List has %d items" complex.List.Length
+ printfn "Complex record: Tuple=(%d, Value=%f)" (fst complex.Tuple) (snd complex.Tuple).Value
+
+ // Test function that takes anonymous record
+ let processed = Library.processAnonymousRecord({| X = 123; Y = "test" |})
+ printfn "Processed result: %s" processed
+
+ if processed = "Processed: X=123, Y=test" then
+ printfn "SUCCESS: All compiler compatibility tests passed"
+ 0
+ else
+ printfn "ERROR: Processed result doesn't match expected"
+ 1
+
+ with ex ->
+ printfn "ERROR: Exception occurred: %s" ex.Message
+ 1
\ No newline at end of file
diff --git a/tests/projects/CompilerCompat/CompilerCompatLib/CompilerCompatLib.fsproj b/tests/projects/CompilerCompat/CompilerCompatLib/CompilerCompatLib.fsproj
new file mode 100644
index 00000000000..a9442854e07
--- /dev/null
+++ b/tests/projects/CompilerCompat/CompilerCompatLib/CompilerCompatLib.fsproj
@@ -0,0 +1,64 @@
+
+
+
+
+ false
+ false
+
+
+
+ net8.0
+ true
+
+
+ bin\
+ bin\$(Configuration)\
+
+
+ true
+ CompilerCompatLib
+ $(PackageVersion)
+ 1.0.0
+ Test
+ Test library for compiler compatibility tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ lib/$(TargetFramework)
+ PreserveNewest
+ true
+
+
+
+
+
+
+ true
+ false
+ $(DotnetFscCompilerPath.Replace('"', ''))
+ N/A
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/projects/CompilerCompat/CompilerCompatLib/Library.fs b/tests/projects/CompilerCompat/CompilerCompatLib/Library.fs
new file mode 100644
index 00000000000..e0b4f380802
--- /dev/null
+++ b/tests/projects/CompilerCompat/CompilerCompatLib/Library.fs
@@ -0,0 +1,17 @@
+namespace CompilerCompatLib
+
+module Library =
+ /// Returns an anonymous record to test compiler compatibility
+ let getAnonymousRecord () = {| X = 42; Y = "hello" |}
+
+ /// Returns a more complex anonymous record with nested structure
+ let getComplexAnonymousRecord () =
+ {|
+ Simple = {| A = 1; B = "test" |};
+ List = [ {| Id = 1; Name = "first" |}; {| Id = 2; Name = "second" |} ];
+ Tuple = (42, {| Value = 3.14; Label = "pi" |})
+ |}
+
+ /// Function that takes an anonymous record as parameter
+ let processAnonymousRecord (record: {| X: int; Y: string |}) =
+ sprintf "Processed: X=%d, Y=%s" record.X record.Y
\ No newline at end of file