diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b9f4bbfb..1b5843cbd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -114,6 +114,16 @@ Conventions for contributors:
### Fixed
+- **The Visual Studio preview failed to start every session.** The extension was built
+ against a newer `System.Text.Json` than Visual Studio binds extensions to, so it failed
+ to load at runtime. It now tracks the `Microsoft.VisualStudio.SDK` baseline, with a test
+ to keep it there. The VSIX's advertised minimum host moves to Visual Studio 17.14 to
+ match that baseline. (PR #1282)
+
+- **The Visual Studio preview now supports packaged (MSIX) apps.** `dotnet new reactor`
+ generates a packaged app, and previewing one previously failed. It now works with no
+ project changes. (PR #1282)
+
- **Wrong code and guidance in the shipped agent-kit skills (spec 064 §4, issue
#1275).** Three gesture snippets in `reactor-input` used WinUI's nested
`ManipulationDelta` shape rather than Reactor's flat gesture structs, and the
diff --git a/docs/_pipeline/templates/index.md.dt b/docs/_pipeline/templates/index.md.dt
index daec554e3..771d6a682 100644
--- a/docs/_pipeline/templates/index.md.dt
+++ b/docs/_pipeline/templates/index.md.dt
@@ -139,7 +139,7 @@ first; everyone else can follow the order.
### 7. Tooling & process
- **[Dev Tooling](dev-tooling.md)** — `mur` CLI, MCP server, VS Code panel, dotnet watch, in-app dev menu
-- **[VS Extension](vs-extension.md)** — Rough experimental embedded preview for Visual Studio 2022 (17.8+) and 2026 (18.x)
+- **[VS Extension](vs-extension.md)** — Rough experimental embedded preview for Visual Studio 2022 (17.14+) and 2026 (18.x)
- **[Testing](testing.md)** — Headless renderer, structural assertions, async test patterns
- **[Performance](performance.md)** — ETW, EventDispatch, flame graphs
- **[Packaging](packaging.md)** — MSIX, single-file, ARM64, AOT considerations
diff --git a/docs/_pipeline/templates/vs-extension.md.dt b/docs/_pipeline/templates/vs-extension.md.dt
index d33b86393..7a09b1e12 100644
--- a/docs/_pipeline/templates/vs-extension.md.dt
+++ b/docs/_pipeline/templates/vs-extension.md.dt
@@ -12,7 +12,7 @@ tier: stub
> ⚠️ **Very rough / experimental.** This is the roughest surface in an already experimental Reactor repository. It is meant for early feedback on the embedded-preview direction, not for reliable day-to-day development yet. Expect install friction, solution-load races, HWND/DPI edge cases, blank-window bugs, and breaking changes while this area is hardened.
-The Visual Studio Embedded Preview hosts a live Microsoft.UI.Reactor (Reactor) component inside a Visual Studio tool window — Visual Studio 2022 (17.8+) or Visual Studio 2026 (18.x), with the **Visual Studio extension development** workload. Unlike the VS Code preview, which streams screenshots, the Visual Studio extension embeds the real WinUI surface so mouse, keyboard, focus, popups, and accessibility tree inspection work against the running app.
+The Visual Studio Embedded Preview hosts a live Microsoft.UI.Reactor (Reactor) component inside a Visual Studio tool window — Visual Studio 2022 (17.14+) or Visual Studio 2026 (18.x), with the **Visual Studio extension development** workload. Unlike the VS Code preview, which streams screenshots, the Visual Studio extension embeds the real WinUI surface so mouse, keyboard, focus, popups, and accessibility tree inspection work against the running app.
## When to use it
@@ -35,6 +35,15 @@ The component picker is a ComboBox in the tool window chrome. By default it auto
- **L1 hot reload** — edits inside a `Render` body usually apply in place. The embedded UI refreshes without recreating the placeholder HWND.
- **L2 respawn** — rude edits such as record/type shape changes cause `dotnet watch` to rebuild and restart the child. The extension detects the new port/token pair and re-embeds the new child into the same placeholder, normally within about 10 seconds.
+## Packaged (MSIX) projects
+
+Packaged projects work with no project changes: `dotnet new reactor` generates a packaged
+app that carries the Windows App SDK run support, and the preview supplies the rest. A
+project converted to MSIX by hand needs that run support
+(`Microsoft.Windows.SDK.BuildTools.WinApp`) added before the preview can attach.
+
+To preview the app unpackaged instead, set `None`.
+
## Known limitations
- WinAppSDK 1.6 or newer is required by the target Reactor app.
diff --git a/docs/guide/README.md b/docs/guide/README.md
index a58d7b578..e0bd7f939 100644
--- a/docs/guide/README.md
+++ b/docs/guide/README.md
@@ -165,7 +165,7 @@ first; everyone else can follow the order.
### 7. Tooling & process
- **[Dev Tooling](dev-tooling.md)** — `mur` CLI, MCP server, VS Code panel, dotnet watch, in-app dev menu
-- **[VS Extension](vs-extension.md)** — Rough experimental embedded preview for Visual Studio 2022 (17.8+) and 2026 (18.x)
+- **[VS Extension](vs-extension.md)** — Rough experimental embedded preview for Visual Studio 2022 (17.14+) and 2026 (18.x)
- **[Testing](testing.md)** — Headless renderer, structural assertions, async test patterns
- **[Performance](performance.md)** — ETW, EventDispatch, flame graphs
- **[Packaging](packaging.md)** — MSIX, single-file, ARM64, AOT considerations
diff --git a/docs/guide/index.md b/docs/guide/index.md
index a58d7b578..e0bd7f939 100644
--- a/docs/guide/index.md
+++ b/docs/guide/index.md
@@ -165,7 +165,7 @@ first; everyone else can follow the order.
### 7. Tooling & process
- **[Dev Tooling](dev-tooling.md)** — `mur` CLI, MCP server, VS Code panel, dotnet watch, in-app dev menu
-- **[VS Extension](vs-extension.md)** — Rough experimental embedded preview for Visual Studio 2022 (17.8+) and 2026 (18.x)
+- **[VS Extension](vs-extension.md)** — Rough experimental embedded preview for Visual Studio 2022 (17.14+) and 2026 (18.x)
- **[Testing](testing.md)** — Headless renderer, structural assertions, async test patterns
- **[Performance](performance.md)** — ETW, EventDispatch, flame graphs
- **[Packaging](packaging.md)** — MSIX, single-file, ARM64, AOT considerations
diff --git a/docs/guide/vs-extension.md b/docs/guide/vs-extension.md
index 605d8c540..76e924175 100644
--- a/docs/guide/vs-extension.md
+++ b/docs/guide/vs-extension.md
@@ -3,7 +3,7 @@
> ⚠️ **Very rough / experimental.** This is the roughest surface in an already experimental Reactor repository. It is meant for early feedback on the embedded-preview direction, not for reliable day-to-day development yet. Expect install friction, solution-load races, HWND/DPI edge cases, blank-window bugs, and breaking changes while this area is hardened.
-The Visual Studio Embedded Preview hosts a live Microsoft.UI.Reactor (Reactor) component inside a Visual Studio tool window — Visual Studio 2022 (17.8+) or Visual Studio 2026 (18.x), with the **Visual Studio extension development** workload. Unlike the VS Code preview, which streams screenshots, the Visual Studio extension embeds the real WinUI surface so mouse, keyboard, focus, popups, and accessibility tree inspection work against the running app.
+The Visual Studio Embedded Preview hosts a live Microsoft.UI.Reactor (Reactor) component inside a Visual Studio tool window — Visual Studio 2022 (17.14+) or Visual Studio 2026 (18.x), with the **Visual Studio extension development** workload. Unlike the VS Code preview, which streams screenshots, the Visual Studio extension embeds the real WinUI surface so mouse, keyboard, focus, popups, and accessibility tree inspection work against the running app.
## When to use it
@@ -26,6 +26,15 @@ The component picker is a ComboBox in the tool window chrome. By default it auto
- **L1 hot reload** — edits inside a `Render` body usually apply in place. The embedded UI refreshes without recreating the placeholder HWND.
- **L2 respawn** — rude edits such as record/type shape changes cause `dotnet watch` to rebuild and restart the child. The extension detects the new port/token pair and re-embeds the new child into the same placeholder, normally within about 10 seconds.
+## Packaged (MSIX) projects
+
+Packaged projects work with no project changes: `dotnet new reactor` generates a packaged
+app that carries the Windows App SDK run support, and the preview supplies the rest. A
+project converted to MSIX by hand needs that run support
+(`Microsoft.Windows.SDK.BuildTools.WinApp`) added before the preview can attach.
+
+To preview the app unpackaged instead, set `None`.
+
## Known limitations
- WinAppSDK 1.6 or newer is required by the target Reactor app.
diff --git a/docs/specs/056-visual-studio-embedded-preview.md b/docs/specs/056-visual-studio-embedded-preview.md
index 8ae00fed3..f08391dcc 100644
--- a/docs/specs/056-visual-studio-embedded-preview.md
+++ b/docs/specs/056-visual-studio-embedded-preview.md
@@ -640,8 +640,8 @@ The csproj is the legacy SDK style (`Microsoft.NET.Sdk` + `true
Key NuGet packages:
-- `Microsoft.VisualStudio.SDK` 17.8+
-- `Microsoft.VSSDK.BuildTools` 17.8+
+- `Microsoft.VisualStudio.SDK` 17.14+
+- `Microsoft.VSSDK.BuildTools` 17.14+
- `Newtonsoft.Json` (VS already ships it; pin to the version VS 2022 carries to avoid binding redirect headaches) — *or* `System.Text.Json` via the JSON helpers already in the SDK.
### §6.2 Package + tool window
diff --git a/docs/specs/tasks/056-visual-studio-embedded-preview-implementation.md b/docs/specs/tasks/056-visual-studio-embedded-preview-implementation.md
index 0017146db..969a109fa 100644
--- a/docs/specs/tasks/056-visual-studio-embedded-preview-implementation.md
+++ b/docs/specs/tasks/056-visual-studio-embedded-preview-implementation.md
@@ -178,8 +178,8 @@ real extension from a clean slate.
- [ ] Create `src/vs-reactor-spike/` (gitignored, **not** part of
`Reactor.slnx`). Hand-roll a minimal VSIX project on .NET
- Framework 4.7.2 with `Microsoft.VisualStudio.SDK` 17.8+ and
- `Microsoft.VSSDK.BuildTools` 17.8+. Single `ToolWindowPane`
+ Framework 4.7.2 with `Microsoft.VisualStudio.SDK` 17.14+ and
+ `Microsoft.VSSDK.BuildTools` 17.14+. Single `ToolWindowPane`
whose content is a `Grid` containing only a `HwndHostPlaceholder`.
- [ ] Hand-roll a minimal Reactor target app under `samples/apps/embed-spike/`
(also gitignored or removed before Phase 1 PR) whose `Program.cs`
@@ -464,12 +464,12 @@ green)
- [ ] Create `src/vs-reactor/Reactor.VsExtension/` with:
- `Reactor.VsExtension.csproj` (.NET Framework 4.7.2, SDK style,
- `true`, `Microsoft.VisualStudio.SDK` 17.8+
- PackageReference, `Microsoft.VSSDK.BuildTools` 17.8+,
+ `true`, `Microsoft.VisualStudio.SDK` 17.14+
+ PackageReference, `Microsoft.VSSDK.BuildTools` 17.14+,
`true`, VSIX
properties).
- `source.extension.vsixmanifest` declaring the Reactor publisher,
- name, version, dependencies (VS 2022 17.8+), and the
+ name, version, dependencies (VS 2022 17.14+), and the
`extension.vsixmanifest` Product/Asset entries.
- Standard VS SDK template files: `Properties/AssemblyInfo.cs`,
`Resources.resx`, `VSPackage.resx`, `index.html` if needed.
diff --git a/src/vs-reactor/README.md b/src/vs-reactor/README.md
index 7141a063d..f17dc4a0d 100644
--- a/src/vs-reactor/README.md
+++ b/src/vs-reactor/README.md
@@ -1,6 +1,6 @@
# Visual Studio Reactor Preview
-Live embedded preview pane for Microsoft.UI.Reactor apps in Visual Studio 2022 (17.8+) and Visual Studio 2026 (18.x).
+Live embedded preview pane for Microsoft.UI.Reactor apps in Visual Studio 2022 (17.14+) and Visual Studio 2026 (18.x).
> ⚠️ **Very rough / experimental.** This VSIX is the roughest surface in an already experimental repository. It exists to validate the embedded-preview direction and collect feedback; expect install friction, solution-load races, HWND/DPI quirks, blank-window bugs, and breaking changes while it is hardened.
@@ -12,7 +12,7 @@ Build the full VSIX, double-click it, install into the target Visual Studio hive
pwsh -File src\vs-reactor\Build-Vsix.ps1
```
-This requires Visual Studio 2022 (17.8+) or Visual Studio 2026 (18.x) with the **Visual Studio extension development** workload. Phase 1 may ship unsigned for dev distribution, so Visual Studio may show an unsigned-extension warning. Treat installs as developer smoke builds, not stable product drops.
+This requires Visual Studio 2022 (17.14+) or Visual Studio 2026 (18.x) with the **Visual Studio extension development** workload. Phase 1 may ship unsigned for dev distribution, so Visual Studio may show an unsigned-extension warning. Treat installs as developer smoke builds, not stable product drops.
## Debug
diff --git a/src/vs-reactor/Reactor.VsExtension/Embed/ReactorChildLauncher.cs b/src/vs-reactor/Reactor.VsExtension/Embed/ReactorChildLauncher.cs
index 4b405a68f..66e3d96d8 100644
--- a/src/vs-reactor/Reactor.VsExtension/Embed/ReactorChildLauncher.cs
+++ b/src/vs-reactor/Reactor.VsExtension/Embed/ReactorChildLauncher.cs
@@ -146,9 +146,55 @@ private static ProcessStartInfo CreateStartInfo(StartOptions options)
info.EnvironmentVariables["DOTNET_WATCH_RESTART_ON_RUDE_EDIT"] = "1";
info.EnvironmentVariables["DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"] = "1";
info.EnvironmentVariables["NoDefaultCurrentDirectoryInExePath"] = "1";
+ ApplyPackagedRunDefaults(info);
return info;
}
+ ///
+ /// Lets a packaged (MSIX) project complete the devtools handshake.
+ ///
+ ///
+ ///
+ /// A packaged project launched by dotnet watch run goes through the launcher
+ /// that Microsoft.Windows.SDK.BuildTools.WinApp installs, which the Reactor
+ /// templates reference. That launcher activates the app by AUMID, which is brokered
+ /// and therefore has no stdout at all — and stdout is how the child reports
+ /// CAPTURE_PORT / CAPTURE_TOKEN. The app would start and render while
+ /// the preview silently waited out its handshake timeout, with no error anywhere to
+ /// explain it. Setting this property switches the launcher to an execution alias,
+ /// which inherits stdout.
+ ///
+ ///
+ /// A project that does not carry that run support has nothing to consume this
+ /// property and must add the package first — a project converted to MSIX by hand is
+ /// the case in point. See the VS extension guide.
+ ///
+ ///
+ /// Set unconditionally rather than behind a packaged/unpackaged probe: the WinApp
+ /// targets only consume it when their run support is active, so it is inert for an
+ /// unpackaged project (verified — an unpackaged launch is byte-identical with and
+ /// without it), and probing would cost an MSBuild evaluation on every launch.
+ ///
+ ///
+ /// It is passed through the environment, not -p:, for two reasons.
+ /// dotnet watch rejects -p alongside --project as ambiguous, and
+ /// an environment property is the lowest-precedence MSBuild property, so a project
+ /// that explicitly sets <WinAppRunUseExecutionAlias>false</...>
+ /// still wins and keeps control.
+ ///
+ ///
+ private static void ApplyPackagedRunDefaults(ProcessStartInfo info)
+ {
+ info.EnvironmentVariables[WinAppRunUseExecutionAliasVariable] = "true";
+ }
+
+ internal const string WinAppRunUseExecutionAliasVariable = "WinAppRunUseExecutionAlias";
+
+ internal static ProcessStartInfo CreateStartInfoForTest(StartOptions options)
+ {
+ return CreateStartInfo(options);
+ }
+
private static string BuildArguments(StartOptions options)
{
var args = new List
diff --git a/src/vs-reactor/Reactor.VsExtension/Reactor.VsExtension.csproj b/src/vs-reactor/Reactor.VsExtension/Reactor.VsExtension.csproj
index 55d062774..e3e08f367 100644
--- a/src/vs-reactor/Reactor.VsExtension/Reactor.VsExtension.csproj
+++ b/src/vs-reactor/Reactor.VsExtension/Reactor.VsExtension.csproj
@@ -53,7 +53,18 @@
-
+
+
+
diff --git a/src/vs-reactor/Tests/Reactor.VsExtension.Tests/ReactorChildLauncherEnvironmentTests.cs b/src/vs-reactor/Tests/Reactor.VsExtension.Tests/ReactorChildLauncherEnvironmentTests.cs
new file mode 100644
index 000000000..04caef0c0
--- /dev/null
+++ b/src/vs-reactor/Tests/Reactor.VsExtension.Tests/ReactorChildLauncherEnvironmentTests.cs
@@ -0,0 +1,71 @@
+#nullable enable
+
+using Microsoft.UI.Reactor.VsExtension.Embed;
+using Xunit;
+
+namespace Reactor.VsExtension.Tests
+{
+ ///
+ /// Covers the environment the preview child is launched with. These are the knobs that
+ /// decide whether a packaged (MSIX) project can complete the devtools handshake at all.
+ ///
+ public sealed class ReactorChildLauncherEnvironmentTests
+ {
+ private static ReactorChildLauncher.StartOptions Options() => new ReactorChildLauncher.StartOptions
+ {
+ CsprojPath = @"C:\src\App\App.csproj",
+ DotnetPath = "dotnet",
+ HostPid = 1234,
+ };
+
+ ///
+ /// Without this the WinApp launcher activates a packaged app by AUMID, which is
+ /// brokered and has no stdout, so CAPTURE_PORT is never observable and the preview
+ /// times out with nothing in stderr to explain why.
+ ///
+ [Fact]
+ public void StartInfo_RequestsExecutionAliasLaunchForPackagedProjects()
+ {
+ var info = ReactorChildLauncher.CreateStartInfoForTest(Options());
+
+ Assert.Equal(
+ "true",
+ info.EnvironmentVariables[ReactorChildLauncher.WinAppRunUseExecutionAliasVariable]);
+ }
+
+ ///
+ /// The property has to travel in the environment. `dotnet watch` rejects `-p` when
+ /// `--project` is also present ("Cannot specify both '--project' and '-p' options"),
+ /// and an environment property is the lowest-precedence MSBuild property, so a
+ /// project that sets it explicitly still wins.
+ ///
+ [Fact]
+ public void StartInfo_DoesNotPassTheAliasPropertyOnTheCommandLine()
+ {
+ var info = ReactorChildLauncher.CreateStartInfoForTest(Options());
+
+ Assert.DoesNotContain(ReactorChildLauncher.WinAppRunUseExecutionAliasVariable, info.Arguments);
+ Assert.DoesNotContain(" -p:", info.Arguments);
+ Assert.DoesNotContain(" --property", info.Arguments);
+ }
+
+ [Fact]
+ public void StartInfo_StillCarriesTheExistingWatchEnvironment()
+ {
+ var info = ReactorChildLauncher.CreateStartInfoForTest(Options());
+
+ Assert.Equal("1", info.EnvironmentVariables["DOTNET_WATCH_RESTART_ON_RUDE_EDIT"]);
+ Assert.Equal("1", info.EnvironmentVariables["DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"]);
+ Assert.Equal("1", info.EnvironmentVariables["NoDefaultCurrentDirectoryInExePath"]);
+ }
+
+ [Fact]
+ public void StartInfo_RedirectsStdoutSoTheHandshakeCanBeRead()
+ {
+ var info = ReactorChildLauncher.CreateStartInfoForTest(Options());
+
+ Assert.True(info.RedirectStandardOutput);
+ Assert.False(info.UseShellExecute);
+ }
+ }
+}
diff --git a/src/vs-reactor/Tests/Reactor.VsExtension.Tests/VsSharedAssemblyBindingTests.cs b/src/vs-reactor/Tests/Reactor.VsExtension.Tests/VsSharedAssemblyBindingTests.cs
new file mode 100644
index 000000000..84712f711
--- /dev/null
+++ b/src/vs-reactor/Tests/Reactor.VsExtension.Tests/VsSharedAssemblyBindingTests.cs
@@ -0,0 +1,255 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Xml.Linq;
+using Microsoft.UI.Reactor.VsExtension.Embed;
+using Xunit;
+
+namespace Reactor.VsExtension.Tests
+{
+ ///
+ /// Guards the assembly-version ceiling on framework libraries that Visual Studio ships as
+ /// shared assemblies and binds on the extension's behalf.
+ ///
+ public sealed class VsSharedAssemblyBindingTests
+ {
+ // Visual Studio owns the identity of these assemblies inside devenv.exe: it ships one
+ // copy under Common7\IDE\SharedAssemblies and binds every extension to it through a
+ // devenv.exe.config . VS 18 declares:
+ //
+ //
+ //
+ //
+ // A redirect unifies UPWARD, and only within its oldVersion range. Compile the
+ // extension against a *higher* assembly version and the request falls outside the
+ // range, so no redirect applies; the VSIX carries no private copy (VS strips
+ // assemblies it provides) and the package registers no BindingPath, so the CLR probes
+ // devenv's base directory, finds nothing, and throws:
+ //
+ // FileNotFoundException: Could not load file or assembly
+ // 'System.Text.Json, Version=10.0.0.12, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51'
+ //
+ // That is not hypothetical: the repo-wide CPM pin (System.Text.Json 10.0.12 ->
+ // AssemblyVersion 10.0.0.12) flowed into the extension and landed two servicing
+ // revisions past VS 18's ceiling, killing every preview session at the first JSON call.
+ //
+ // The safe ceiling is the version Microsoft.VisualStudio.SDK itself depends on: every
+ // VS release that satisfies the SDK redirects at least that high, and building lower is
+ // always safe because redirects unify upward. Bump these only together with the
+ // Microsoft.VisualStudio.SDK PackageVersion in Directory.Packages.props.
+ //
+ // Only *direct* assembly references are gated, because that is what the CLR resolves
+ // against this assembly's manifest. An entry for something the extension does not
+ // reference directly would never be exercised and would be pure false confidence, so
+ // EveryGatedAssemblyIsActuallyReferenced fails on any such entry rather than letting it
+ // sit dormant. System.Text.Encodings.Web was exactly that: transitive through
+ // System.Text.Json, with no direct reference for this probe to ever see.
+ private static readonly IReadOnlyDictionary Ceilings =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["System.Text.Json"] = new Version(9, 0, 0, 0),
+ };
+
+ private static AssemblyName[] ExtensionReferences()
+ {
+ return typeof(EmbedClient).Assembly.GetReferencedAssemblies();
+ }
+
+ ///
+ /// The ceiling above is only half the contract. A shared-assembly reference also has
+ /// to be covered by the oldest Visual Studio the VSIX claims to install into,
+ /// and that claim lives in source.extension.vsixmanifest — a different file that
+ /// nothing otherwise ties to this pin.
+ ///
+ ///
+ /// Derived from the central Microsoft.VisualStudio.SDK version rather than
+ /// written down again here. The extension cannot support a host older than the SDK it
+ /// compiles against, and that SDK is what fixes the System.Text.Json baseline: the SDK
+ /// baselines it per VS version (17.8 -> 7.0.3, 17.9 -> 8.0.0, 17.14 -> 9.0.0). A second
+ /// hard-coded literal would let an SDK bump update the pin and the ceiling while
+ /// silently leaving the host minimum behind, keeping every test green while the VSIX
+ /// advertised hosts the new pin cannot load on. One source of truth means that bump
+ /// fails this gate until the manifest is raised deliberately.
+ ///
+ private static Version MinimumSupportedVsHost => VisualStudioSdkVersion();
+
+ private static Version VisualStudioSdkVersion()
+ {
+ var props = FindRepoFile("Directory.Packages.props");
+
+ var raw = XDocument.Load(props)
+ .Descendants()
+ .Where(e => e.Name.LocalName == "PackageVersion")
+ .Where(e => string.Equals(
+ (string?)e.Attribute("Include"),
+ "Microsoft.VisualStudio.SDK",
+ StringComparison.OrdinalIgnoreCase))
+ .Select(e => (string?)e.Attribute("Version"))
+ .FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
+
+ if (raw == null || !Version.TryParse(raw, out var version))
+ {
+ throw new InvalidOperationException(
+ "Could not read the Microsoft.VisualStudio.SDK PackageVersion from "
+ + $"'{props}'. The host-minimum gate has no source of truth without it — fix "
+ + "the lookup rather than reintroducing a hard-coded version.");
+ }
+
+ return new Version(version.Major, version.Minor);
+ }
+
+ [Fact]
+ public void VsixManifest_DoesNotAdvertiseHostsOlderThanThePinnedBaseline()
+ {
+ var manifest = FindRepoFile("src/vs-reactor/Reactor.VsExtension/source.extension.vsixmanifest");
+
+ // Parsed as XML and scoped to the host elements rather than pattern-matched out of
+ // the raw text: a regex over version literals silently skips any shape it did not
+ // anticipate (a patch component such as [17.8.1,19.0) being the obvious one), and a
+ // skipped range reads exactly like a compliant one.
+ var doc = XDocument.Load(manifest);
+ var declarations = doc.Descendants()
+ .Where(e => e.Name.LocalName == "InstallationTarget" || e.Name.LocalName == "Prerequisite")
+ .Select(e => new
+ {
+ Element = e.Name.LocalName,
+ Id = (string?)e.Attribute("Id"),
+ Version = (string?)e.Attribute("Version"),
+ })
+ .Where(d => !string.IsNullOrWhiteSpace(d.Version))
+ .ToArray();
+
+ // Positive control: a manifest we failed to read must not report "no violations".
+ Assert.NotEmpty(declarations);
+
+ var tooOld = new List();
+ foreach (var declaration in declarations)
+ {
+ // Unparseable is a failure, never a skip — that is the hole this replaced.
+ var minimum = ParseRangeMinimum(declaration.Version!);
+ if (minimum < MinimumSupportedVsHost)
+ {
+ tooOld.Add($"{declaration.Element} {declaration.Id} {declaration.Version}");
+ }
+ }
+
+ Assert.True(
+ tooOld.Count == 0,
+ $"source.extension.vsixmanifest advertises Visual Studio hosts older than {MinimumSupportedVsHost}, "
+ + "the Microsoft.VisualStudio.SDK version the extension compiles against. Those hosts redirect "
+ + "System.Text.Json below the version the extension is pinned to, so the extension would fail to "
+ + "load there with FileNotFoundException. Raise the manifest minimum to match the SDK, or move the "
+ + "SDK, the System.Text.Json pin and the manifest together. Offending declarations: "
+ + string.Join("; ", tooOld));
+ }
+
+ ///
+ /// Reads the lower bound out of a VSIX version range such as [17.14,19.0),
+ /// [17.8.1,19.0) or a bare 17.14. Throws rather than returning a
+ /// sentinel, so an unrecognised shape fails the gate instead of slipping past it.
+ ///
+ private static Version ParseRangeMinimum(string range)
+ {
+ var text = range.Trim().TrimStart('[', '(');
+ var comma = text.IndexOf(',');
+ if (comma >= 0)
+ {
+ text = text.Substring(0, comma);
+ }
+
+ text = text.TrimEnd(']', ')').Trim();
+
+ if (!Version.TryParse(text, out var version))
+ {
+ throw new FormatException(
+ $"Could not read a minimum version out of the manifest range '{range}'. "
+ + "Teach this parser the new shape — do not let it be skipped.");
+ }
+
+ return version;
+ }
+
+ private static string FindRepoFile(string relativePath)
+ {
+ // A rooted argument would make Path.Combine below discard dir.FullName, so every
+ // iteration would probe the same absolute path and the walk would be meaningless.
+ // Fail loudly rather than let the lookup quietly stop searching.
+ if (Path.IsPathRooted(relativePath))
+ {
+ throw new ArgumentException(
+ $"Expected a repo-relative path, got the rooted path '{relativePath}'.",
+ nameof(relativePath));
+ }
+
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null)
+ {
+ var candidate = Path.Combine(dir.FullName, relativePath);
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+
+ dir = dir.Parent;
+ }
+
+ throw new FileNotFoundException(
+ $"Could not locate '{relativePath}' walking up from {AppContext.BaseDirectory}. "
+ + "The test cannot pass vacuously — fix the lookup rather than deleting the assertion.");
+ }
+
+ ///
+ /// Positive control for :
+ /// a clean result there is only meaningful if the probe can actually see the references
+ /// it gates. Because that probe reads direct assembly references, a ceiling entry for
+ /// something referenced only transitively would never be evaluated and the gate would
+ /// report success having tested nothing. Asserting every entry is visible makes a
+ /// dormant entry impossible: add one the extension does not reference directly and this
+ /// fails, forcing it to be made real or dropped.
+ ///
+ [Fact]
+ public void EveryGatedAssemblyIsActuallyReferenced()
+ {
+ var names = ExtensionReferences()
+ .Select(r => r.Name)
+ .Where(n => n != null)
+ .ToArray();
+
+ var dormant = Ceilings.Keys
+ .Where(k => !names.Contains(k, StringComparer.OrdinalIgnoreCase))
+ .ToArray();
+
+ Assert.True(
+ dormant.Length == 0,
+ "These entries in Ceilings are not direct assembly references of Reactor.VsExtension, "
+ + "so the ceiling gate never evaluates them and would stay green no matter what version "
+ + "resolved: " + string.Join(", ", dormant) + ". Either the extension should reference them "
+ + "directly, or they should be removed — a ceiling nothing checks is false confidence. "
+ + "(Transitive dependencies are not covered by this gate; the CLR resolves this assembly's "
+ + "own manifest references, which is what the binding failure this guards against involves.)");
+ }
+
+ [Fact]
+ public void SharedAssemblyReferences_StayWithinVsBindingRedirectCeiling()
+ {
+ var violations = ExtensionReferences()
+ .Where(r => r.Name != null && r.Version != null)
+ .Where(r => Ceilings.TryGetValue(r.Name!, out var ceiling) && r.Version! > ceiling)
+ .Select(r => $"{r.Name} {r.Version} (ceiling {Ceilings[r.Name!]})")
+ .ToArray();
+
+ Assert.True(
+ violations.Length == 0,
+ "Reactor.VsExtension references a Visual Studio shared assembly at a higher version than "
+ + "VS's binding redirect covers. This does not fail the build — it fails at run time "
+ + "inside devenv with FileNotFoundException on the first call that touches the assembly. "
+ + "Pin it with a VersionOverride in Reactor.VsExtension.csproj instead of following the "
+ + "repo-wide Directory.Packages.props version. Offending references: "
+ + string.Join(", ", violations));
+ }
+ }
+}