From 1812f88beb44e3900fe45ebdc34e81d3ef81703b Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 19:09:24 +0200 Subject: [PATCH 1/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 } From 3d8ff699aef7edf992e1ed0760a9691ad369bbc8 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Tue, 29 Sep 2026 18:56:41 +0200 Subject: [PATCH 2/3] Take -RoslynRepo literally and absolutely, escape it in the props, and drop stale -dev packages Co-Authored-By: Claude Opus 5.5 --- start-vs-VisualFSharpSln.ps1 | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/start-vs-VisualFSharpSln.ps1 b/start-vs-VisualFSharpSln.ps1 index b184f5a6b62..efe9e0f05a8 100644 --- a/start-vs-VisualFSharpSln.ps1 +++ b/start-vs-VisualFSharpSln.ps1 @@ -26,26 +26,42 @@ Set-StrictMode -Version Latest; $ErrorActionPreference = 'Stop'; $root = $PSScri # 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)) { + # NuGet resolves a relative source against each project's directory, not this one. + $repo = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($repo) + if (-not (Test-Path -LiteralPath $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) })) { + if (-not ($feeds | Where-Object { Test-Path -LiteralPath (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 } +# A repack keeps the -dev version, and restore would serve the previous pack from the global packages +# folder, so the extracted copy of every package these feeds hold at that version is dropped first. +function Get-StaleRoslynDevPackages([string[]]$feeds, [string]$version) { + $cache = if ($env:NUGET_PACKAGES) { $env:NUGET_PACKAGES } else { Join-Path $env:USERPROFILE '.nuget\packages' } + $suffix = ".$version.nupkg" + foreach ($feed in $feeds) { + if (-not (Test-Path -LiteralPath $feed)) { continue } + Get-ChildItem -LiteralPath $feed -Filter '*.nupkg' | + Where-Object { $_.Name.EndsWith($suffix, [StringComparison]::OrdinalIgnoreCase) } | + ForEach-Object { Join-Path $cache "$($_.Name.Substring(0, $_.Name.Length - $suffix.Length).ToLowerInvariant())\$($version.ToLowerInvariant())" } | + Where-Object { Test-Path -LiteralPath $_ } + } +} + 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' } - elseif (Test-Path $vswhere) { & $vswhere -latest -prerelease -property productPath 2>$null } + elseif (Test-Path -LiteralPath $vswhere) { & $vswhere -latest -prerelease -property productPath 2>$null } } -if (-not ($DevEnv -and (Test-Path $DevEnv))) { throw 'devenv.exe not found; run from a VS Developer prompt or pass -DevEnv.' } +if (-not ($DevEnv -and (Test-Path -LiteralPath $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) @@ -58,7 +74,7 @@ if (-not $RoslynVersion) { 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 | + $hiveRoslyn = Get-ChildItem -LiteralPath (Join-Path $hive 'Extensions') -Recurse -Filter 'Microsoft.CodeAnalysis.dll' -ErrorAction SilentlyContinue | Where-Object { $_.DirectoryName -like '*Roslyn Language Services*' } | Select-Object -First 1 if ($hiveRoslyn) { @@ -67,7 +83,7 @@ if (-not $RoslynVersion) { } 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." } + if (-not (Test-Path -LiteralPath $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' } @@ -114,7 +130,7 @@ 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 ';')" } + if ($feeds) { "`$(RestoreAdditionalProjectSources);$([Security.SecurityElement]::Escape($feeds -join ';'))" } else { '' } New-Item -ItemType Directory -Force (Split-Path $override) | Out-Null @@ -138,11 +154,14 @@ 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] } + $stale = if ($feeds) { @(Get-StaleRoslynDevPackages $feeds $RoslynVersion) } else { @() } if ($DryRun) { if ($RoslynVersion) { Write-Host "DryRun: wrote $override" } else { Write-Host 'DryRun: no override needed' } + $stale | ForEach-Object { Write-Host "DryRun: would remove $_" } Write-Host "Would restore, then open $Solution in $DevEnv; F5 deploys into and launches $RootSuffix" return } + $stale | ForEach-Object { Remove-Item -LiteralPath $_ -Recurse -Force } & (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 f0f219e545ad98974c8b70db9600305d1c0970a1 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Tue, 29 Sep 2026 19:21:02 +0200 Subject: [PATCH 3/3] Add only the Roslyn package folders that exist as restore sources Co-Authored-By: Claude Opus 5.5 --- start-vs-VisualFSharpSln.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/start-vs-VisualFSharpSln.ps1 b/start-vs-VisualFSharpSln.ps1 index efe9e0f05a8..6ab83f91c6d 100644 --- a/start-vs-VisualFSharpSln.ps1 +++ b/start-vs-VisualFSharpSln.ps1 @@ -32,7 +32,9 @@ function Get-RoslynDevFeeds([string]$repo, [string]$version) { 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\$_" } + # NuGet fails restore (NU1301) on a local source that does not exist, so only the folders present are feeds. + $feeds = @('Shipping', 'NonShipping' | ForEach-Object { Join-Path $repo "artifacts\packages\Release\$_" } | + Where-Object { Test-Path -LiteralPath $_ }) $probe = "Microsoft.VisualStudio.LanguageServices.ExternalAccess.$version.nupkg" if (-not ($feeds | Where-Object { Test-Path -LiteralPath (Join-Path $_ $probe) })) { @@ -48,7 +50,6 @@ function Get-StaleRoslynDevPackages([string[]]$feeds, [string]$version) { $cache = if ($env:NUGET_PACKAGES) { $env:NUGET_PACKAGES } else { Join-Path $env:USERPROFILE '.nuget\packages' } $suffix = ".$version.nupkg" foreach ($feed in $feeds) { - if (-not (Test-Path -LiteralPath $feed)) { continue } Get-ChildItem -LiteralPath $feed -Filter '*.nupkg' | Where-Object { $_.Name.EndsWith($suffix, [StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { Join-Path $cache "$($_.Name.Substring(0, $_.Name.Length - $suffix.Length).ToLowerInvariant())\$($version.ToLowerInvariant())" } |