From 03d242b477fc742ee38a14fa4062a0d2dd2a882a Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 18:26:33 +0200 Subject: [PATCH 1/3] Build against the hive's own Roslyn, not the installed VS's F5 loads the extension into a hive (RootSuffix), and that hive's own deployed Roslyn wins over the installed VS's when one is present: a locally built Roslyn deployed there stamps itself 42.42.42.42 and redirects every reference to itself via a hive-level binding redirect. The script detected the installed VS's Roslyn version and overrode the repo's packages to match it unconditionally, which built against the wrong version whenever the target hive carried its own Roslyn - RoslynDev in particular, the hive this repo's own DEVGUIDE points contributors at. It now reads devenv.isolation.ini to find the hive's own Extensions folder first, and only falls back to the installed VS's Roslyn when the hive has none of its own. A new -RootSuffix parameter names the hive (default RoslynDev, matching the VisualFSharpDebug launch profile). When the detected version's minor matches what the repo's Version.Details.props already flows, no override is written at all - the common case for a hive built from this same source - and a locally built hive Roslyn with no package version at all now fails fast asking for one instead of silently building against packages that do not match what will actually load. The override file also moves to its own path (RoslynOverride.start-vs.props) so a build-vs-VisualFSharpSln.ps1 run in the same session cannot silently overwrite it, since a long-lived VS process restores against whatever CustomAfterMicrosoftCommonProps last pointed at. Co-Authored-By: Claude Fable 5.1 --- start-vs-VisualFSharpSln.ps1 | 84 +++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/start-vs-VisualFSharpSln.ps1 b/start-vs-VisualFSharpSln.ps1 index 7dc9cecb955..da673b85397 100644 --- a/start-vs-VisualFSharpSln.ps1 +++ b/start-vs-VisualFSharpSln.ps1 @@ -1,10 +1,18 @@ -# Launch VS on VisualFSharp.slnx, building the F# VS extension against the Roslyn your installed VS -# ships (not the newer one flowed into the repo) so F5 loads it. See DEVGUIDE.md. Needs VS Roslyn >= 5.10. -# -RoslynVersion forces a version (e.g. if the exact VS build isn't on a feed); any 5.Y.* binds identically. +# Launch VS on VisualFSharp.slnx so that F5 builds the F# VS extension against the Roslyn it will +# actually run against, and deploys it to $RootSuffix. See DEVGUIDE.md. +# +# What F5 runs against is the hive's Roslyn, not the installed VS's: deploying a locally built Roslyn +# into the hive stamps it 42.42.42.42 and redirects every reference to itself, so the hive wins. Only +# when the hive has no Roslyn of its own does the installation's version apply. This picks whichever +# of the two is in force and matches the package versions to it - or leaves the repo's flowed versions +# alone when they already match, which is the common case for a hive built from this same source. +# +# -RoslynVersion forces a package version; any 5.Y.* binds identically within a minor. [CmdletBinding()] param( [string]$RoslynVersion, [string]$DevEnv, + [string]$RootSuffix = 'RoslynDev', [string]$Solution = 'VisualFSharp.slnx', [switch]$DryRun ) @@ -17,41 +25,85 @@ if (-not $DevEnv) { } if (-not ($DevEnv -and (Test-Path $DevEnv))) { throw 'devenv.exe not found; run from a VS Developer prompt or pass -DevEnv.' } +$ideDir = Split-Path $DevEnv +$flowed = ([xml](Get-Content -LiteralPath (Join-Path $root 'eng\Version.Details.props') -Raw) + ).GetElementsByTagName('MicrosoftCodeAnalysisPackageVersion')[0].InnerText.Trim() + if (-not $RoslynVersion) { - $dll = "$(Split-Path $DevEnv)\CommonExtensions\Microsoft\VBCSharp\LanguageServices\Microsoft.CodeAnalysis.dll" - if (-not (Test-Path $dll)) { throw "Can't detect your VS Roslyn version; pass -RoslynVersion." } - $RoslynVersion = ([System.Diagnostics.FileVersionInfo]::GetVersionInfo($dll).ProductVersion -split '\+')[0] + $ini = Get-Content -LiteralPath (Join-Path $ideDir 'devenv.isolation.ini') -Raw + if ($ini -notmatch '(?m)^InstallationID=(?\S+)') { throw 'No InstallationID in devenv.isolation.ini.' } + $installationId = $Matches.id + if ($ini -notmatch '(?m)^InstallationVersion=(?\d+)') { throw 'No InstallationVersion in devenv.isolation.ini.' } + $hive = Join-Path $env:LOCALAPPDATA ('Microsoft\VisualStudio\{0}.0_{1}{2}' -f $Matches.v, $installationId, $RootSuffix) + + $hiveRoslyn = Get-ChildItem (Join-Path $hive 'Extensions') -Recurse -Filter 'Microsoft.CodeAnalysis.dll' -ErrorAction SilentlyContinue | + Where-Object { $_.DirectoryName -like '*Roslyn Language Services*' } | Select-Object -First 1 + + if ($hiveRoslyn) { + $target = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($hiveRoslyn.FullName).ProductVersion + $source = "deployed in $RootSuffix" + } + else { + $dll = Join-Path $ideDir 'CommonExtensions\Microsoft\VBCSharp\LanguageServices\Microsoft.CodeAnalysis.dll' + if (-not (Test-Path $dll)) { throw "Can't detect the Roslyn version for $RootSuffix; pass -RoslynVersion." } + $target = ([System.Diagnostics.FileVersionInfo]::GetVersionInfo($dll).ProductVersion -split '\+')[0] + $source = 'shipped with the installed VS' + } + Write-Host "Roslyn in force: $target ($source)." + + # A dev build's "5.12.0-dev" is no package version, so only its minor is usable - and when that is + # the minor the repo already flows, the flowed packages are the match and no override belongs here. + $targetMinor = [version](($target -split '-')[0]) + $flowedMinor = [version](($flowed -split '-')[0]) + if ($targetMinor.Major -eq $flowedMinor.Major -and $targetMinor.Minor -eq $flowedMinor.Minor) { + Write-Host "Repo already flows Roslyn $flowed - building against it, no override." + } + elseif ($target -match '-dev$') { + throw "$RootSuffix has a locally built Roslyn $target but the repo flows $flowed; pass -RoslynVersion with a $($targetMinor.Major).$($targetMinor.Minor).* package version to build against that minor." + } + else { $RoslynVersion = $target } } -$minor = [version](($RoslynVersion -split '-')[0]) -if ($minor -lt [version]'5.10.0') { - throw "Your VS ships Roslyn $RoslynVersion but the repo references packages from >= 5.10 (unified ExternalAccess, #20099). Update VS or deploy a local Roslyn." + +if ($RoslynVersion) { + $minor = [version](($RoslynVersion -split '-')[0]) + if ($minor -lt [version]'5.10.0') { + throw "Roslyn $RoslynVersion is older than the 5.10 the repo's sources expect (unified ExternalAccess, #20099). Update VS or deploy a local Roslyn." + } + Write-Host "Building the F# extension against Roslyn $RoslynVersion ($DevEnv)." } -Write-Host "Building the F# extension against Roslyn $RoslynVersion ($DevEnv)." # Repoint every Roslyn package (versions set in eng/Version.Details.props) via a props file MSBuild # imports after it through the CustomAfterMicrosoftCommonProps hook the launched VS inherits. $names = 'MicrosoftCodeAnalysis', 'MicrosoftCodeAnalysisCompilers', 'MicrosoftCodeAnalysisCSharp', 'MicrosoftCodeAnalysisEditorFeatures', 'MicrosoftCodeAnalysisEditorFeaturesText', 'MicrosoftCodeAnalysisFeatures', 'MicrosoftVisualStudioLanguageServices', 'MicrosoftVisualStudioLanguageServicesExternalAccess' -$override = Join-Path $root 'artifacts\RoslynOverride.props' -New-Item -ItemType Directory -Force (Split-Path $override) | Out-Null -"$(-join ($names | ForEach-Object { "<${_}Version>$RoslynVersion" }))" | - Set-Content -LiteralPath $override -Encoding UTF8 +# Its own file: build-vs-VisualFSharpSln.ps1 writes an override too, and one path for both means +# whichever ran last decides what a long-lived VS session restores against. +$override = Join-Path $root 'artifacts\RoslynOverride.start-vs.props' +if ($RoslynVersion) { + New-Item -ItemType Directory -Force (Split-Path $override) | Out-Null + "$(-join ($names | ForEach-Object { "<${_}Version>$RoslynVersion" }))" | + Set-Content -LiteralPath $override -Encoding UTF8 +} # Apply to THIS process only, restoring in finally so it can't leak into a later build.cmd/CI run # (which must keep the flowed Roslyn); the launched VS snapshots the env for its F5/restore builds. $vars = @{ - CustomAfterMicrosoftCommonProps = $override DOTNET_ROOT = Join-Path $root '.dotnet' 'DOTNET_ROOT(x86)' = Join-Path $root '.dotnet\x86' PATH = "$(Join-Path $root '.dotnet');$env:PATH" RunNetFrameworkApiCompat = 'false' RunRefApiCompat = 'false' } +if ($RoslynVersion) { $vars.CustomAfterMicrosoftCommonProps = $override } $saved = @{}; foreach ($k in $vars.Keys) { $saved[$k] = [Environment]::GetEnvironmentVariable($k) } try { foreach ($k in $vars.Keys) { Set-Item -LiteralPath "Env:\$k" -Value $vars[$k] } - if ($DryRun) { Write-Host "DryRun: $override"; return } + if ($DryRun) { + if ($RoslynVersion) { Write-Host "DryRun: wrote $override" } else { Write-Host 'DryRun: no override needed' } + Write-Host "Would restore, then open $Solution in $DevEnv" + return + } & (Join-Path $root 'Restore.cmd') if ($LASTEXITCODE) { throw "Restore failed for Roslyn $RoslynVersion; try another 5.$($minor.Minor).* build via -RoslynVersion." } Start-Process $DevEnv "`"$(Join-Path $root $Solution)`"" From a9b67375abe8c5f3ac05abfe4707af183c2c2920 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 17:34:39 +0200 Subject: [PATCH 2/3] Deploy and launch into the hive -RootSuffix names -RootSuffix only chose which hive to read the Roslyn version from: the VSIX projects hard-coded VSRootSuffix to RoslynDev, and that is both the hive F5 deploys into and the /rootsuffix launchSettings passes, so -RootSuffix Foo built against Foo's Roslyn and then ran in RoslynDev. VSRootSuffix now defaults to RoslynDev only when nothing set it, and the script hands its suffix to the Visual Studio it launches, whose builds read it from the environment. Co-Authored-By: Claude Opus 5 --- start-vs-VisualFSharpSln.ps1 | 7 +++++-- vsintegration/Vsix/Directory.Build.props | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/start-vs-VisualFSharpSln.ps1 b/start-vs-VisualFSharpSln.ps1 index da673b85397..9ba9aec4e8a 100644 --- a/start-vs-VisualFSharpSln.ps1 +++ b/start-vs-VisualFSharpSln.ps1 @@ -88,12 +88,15 @@ if ($RoslynVersion) { # Apply to THIS process only, restoring in finally so it can't leak into a later build.cmd/CI run # (which must keep the flowed Roslyn); the launched VS snapshots the env for its F5/restore builds. +# VSRootSuffix is what the VSIX projects deploy into and what F5 passes as /rootsuffix, so the hive +# probed above is also the one the extension lands in. $vars = @{ DOTNET_ROOT = Join-Path $root '.dotnet' 'DOTNET_ROOT(x86)' = Join-Path $root '.dotnet\x86' PATH = "$(Join-Path $root '.dotnet');$env:PATH" RunNetFrameworkApiCompat = 'false' RunRefApiCompat = 'false' + VSRootSuffix = $RootSuffix } if ($RoslynVersion) { $vars.CustomAfterMicrosoftCommonProps = $override } $saved = @{}; foreach ($k in $vars.Keys) { $saved[$k] = [Environment]::GetEnvironmentVariable($k) } @@ -101,13 +104,13 @@ try { foreach ($k in $vars.Keys) { Set-Item -LiteralPath "Env:\$k" -Value $vars[$k] } if ($DryRun) { if ($RoslynVersion) { Write-Host "DryRun: wrote $override" } else { Write-Host 'DryRun: no override needed' } - Write-Host "Would restore, then open $Solution in $DevEnv" + Write-Host "Would restore, then open $Solution in $DevEnv; F5 deploys into and launches $RootSuffix" return } & (Join-Path $root 'Restore.cmd') if ($LASTEXITCODE) { throw "Restore failed for Roslyn $RoslynVersion; try another 5.$($minor.Minor).* build via -RoslynVersion." } Start-Process $DevEnv "`"$(Join-Path $root $Solution)`"" - Write-Host 'Launched VS. Set VisualFSharpDebug as the startup project, then F5 / Ctrl+F5.' + Write-Host "Launched VS. Set VisualFSharpDebug as the startup project, then F5 / Ctrl+F5 to run in $RootSuffix." } finally { foreach ($k in $saved.Keys) { diff --git a/vsintegration/Vsix/Directory.Build.props b/vsintegration/Vsix/Directory.Build.props index 66b95cf6e1a..d81bbf2613f 100644 --- a/vsintegration/Vsix/Directory.Build.props +++ b/vsintegration/Vsix/Directory.Build.props @@ -4,7 +4,7 @@ Microsoft.FSharp - RoslynDev + RoslynDev $(VSRootSuffix) $(ArtifactsDir)bin\fscAnyCpu\$(Configuration)\net472\ From 204694e6550863beffbf1e7a5528808880e98eb1 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 19:09:24 +0200 Subject: [PATCH 3/3] Build against a locally built hive Roslyn from its own packages A Roslyn built locally and deployed into the hive is there because its API surface differs from the flowed package: it is where an ExternalAccess contract lives before it flows. Matching the minor the repo flows therefore says nothing about whether the flowed packages will do, and refusing the dev version left no way to build against the Roslyn F5 actually runs. A dev version now always overrides, and the override adds the package folders of that Roslyn build to RestoreAdditionalProjectSources, since no feed carries its version. -RoslynRepo names the repository, next to this one by default, and the script checks that it actually holds the package before restore fails halfway with NU1101. Co-Authored-By: Claude Opus 5 --- start-vs-VisualFSharpSln.ps1 | 60 ++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/start-vs-VisualFSharpSln.ps1 b/start-vs-VisualFSharpSln.ps1 index 9ba9aec4e8a..b184f5a6b62 100644 --- a/start-vs-VisualFSharpSln.ps1 +++ b/start-vs-VisualFSharpSln.ps1 @@ -8,9 +8,12 @@ # alone when they already match, which is the common case for a hive built from this same source. # # -RoslynVersion forces a package version; any 5.Y.* binds identically within a minor. +# -RoslynRepo is the Roslyn repository whose packages a locally built hive Roslyn comes from (default: +# a roslyn folder next to this repository). [CmdletBinding()] param( [string]$RoslynVersion, + [string]$RoslynRepo, [string]$DevEnv, [string]$RootSuffix = 'RoslynDev', [string]$Solution = 'VisualFSharp.slnx', @@ -18,6 +21,25 @@ param( ) Set-StrictMode -Version Latest; $ErrorActionPreference = 'Stop'; $root = $PSScriptRoot +# The package folders of a locally built Roslyn, checked to actually hold that version: a hive can carry +# a dev build whose packages were cleaned or never packed, and NU1101 halfway through a restore says far +# less about that than a message here does. +function Get-RoslynDevFeeds([string]$repo, [string]$version) { + if (-not $repo) { $repo = Join-Path (Split-Path $PSScriptRoot) 'roslyn' } + if (-not (Test-Path $repo)) { + throw "$RootSuffix runs a locally built Roslyn $version, which only its own packages provide, but there is no repository at $repo; pass -RoslynRepo." + } + + $feeds = 'Shipping', 'NonShipping' | ForEach-Object { Join-Path $repo "artifacts\packages\Release\$_" } + $probe = "Microsoft.VisualStudio.LanguageServices.ExternalAccess.$version.nupkg" + + if (-not ($feeds | Where-Object { Test-Path (Join-Path $_ $probe) })) { + throw "$repo has no $probe; pack that Roslyn (.\Build.cmd -restore -pack -c Release) or pass -RoslynVersion to build against a published one instead." + } + + $feeds +} + if (-not $DevEnv) { $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' $DevEnv = if ($env:DevEnvDir) { Join-Path $env:DevEnvDir 'devenv.exe' } @@ -51,25 +73,33 @@ if (-not $RoslynVersion) { } Write-Host "Roslyn in force: $target ($source)." - # A dev build's "5.12.0-dev" is no package version, so only its minor is usable - and when that is - # the minor the repo already flows, the flowed packages are the match and no override belongs here. - $targetMinor = [version](($target -split '-')[0]) - $flowedMinor = [version](($flowed -split '-')[0]) - if ($targetMinor.Major -eq $flowedMinor.Major -and $targetMinor.Minor -eq $flowedMinor.Minor) { - Write-Host "Repo already flows Roslyn $flowed - building against it, no override." - } - elseif ($target -match '-dev$') { - throw "$RootSuffix has a locally built Roslyn $target but the repo flows $flowed; pass -RoslynVersion with a $($targetMinor.Major).$($targetMinor.Minor).* package version to build against that minor." + # A locally built Roslyn is deployed precisely because its API surface differs from the flowed + # package - it is where an ExternalAccess contract lives before it flows - so a matching minor says + # nothing and the override belongs here whatever the repo flows. Its own packages are the only ones + # that carry the difference, so the feeds they sit in come along; without them restore cannot find + # the version at all. + if ($target -match '-dev$') { $RoslynVersion = $target } + else { + # A shipped Roslyn is a real package version, and within a minor any 5.Y.* binds identically, + # so the flowed packages are the match when the minors agree. + $targetMinor = [version](($target -split '-')[0]) + $flowedMinor = [version](($flowed -split '-')[0]) + + if ($targetMinor.Major -eq $flowedMinor.Major -and $targetMinor.Minor -eq $flowedMinor.Minor) { + Write-Host "Repo already flows Roslyn $flowed - building against it, no override." + } + else { $RoslynVersion = $target } } - else { $RoslynVersion = $target } } +$feeds = $null if ($RoslynVersion) { $minor = [version](($RoslynVersion -split '-')[0]) if ($minor -lt [version]'5.10.0') { throw "Roslyn $RoslynVersion is older than the 5.10 the repo's sources expect (unified ExternalAccess, #20099). Update VS or deploy a local Roslyn." } - Write-Host "Building the F# extension against Roslyn $RoslynVersion ($DevEnv)." + if ($RoslynVersion -match '-dev$') { $feeds = Get-RoslynDevFeeds $RoslynRepo $RoslynVersion } + Write-Host "Building the F# extension against Roslyn $RoslynVersion ($DevEnv)$(if ($feeds) { ' from its own packages' })." } # Repoint every Roslyn package (versions set in eng/Version.Details.props) via a props file MSBuild @@ -81,8 +111,14 @@ $names = 'MicrosoftCodeAnalysis', 'MicrosoftCodeAnalysisCompilers', 'MicrosoftCo # whichever ran last decides what a long-lived VS session restores against. $override = Join-Path $root 'artifacts\RoslynOverride.start-vs.props' if ($RoslynVersion) { + # RestoreAdditionalProjectSources has to arrive through the props import rather than on the command + # line: Microsoft.FSharp.NetSdk.targets declares it TreatAsLocalProperty and appends to it. + $sources = + if ($feeds) { "`$(RestoreAdditionalProjectSources);$($feeds -join ';')" } + else { '' } + New-Item -ItemType Directory -Force (Split-Path $override) | Out-Null - "$(-join ($names | ForEach-Object { "<${_}Version>$RoslynVersion" }))" | + "$(-join ($names | ForEach-Object { "<${_}Version>$RoslynVersion" }))$sources" | Set-Content -LiteralPath $override -Encoding UTF8 }