From 63fe4c0aa15744bf2b6c10544d62371fe65505fc Mon Sep 17 00:00:00 2001 From: jolov Date: Tue, 31 Mar 2026 20:09:17 -0700 Subject: [PATCH 1/8] fix(http-client-csharp): resolve PackageReference assemblies for custom code compilation When custom code references types from external NuGet packages (e.g., Azure.Storage.Common.StorageSharedKeyCredential), the Roslyn compilation would fail because those assemblies weren't added as metadata references. Parse the project's .csproj file for PackageReference items and resolve their assemblies from the NuGet global packages cache. This allows custom constructors and other user code that references external library types to compile correctly. Fixes #10224 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/CSharpGen.cs | 4 ++ .../PostProcessing/GeneratedCodeWorkspace.cs | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs index 777c96bf558..6b40e0b9b31 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs @@ -32,6 +32,10 @@ public async Task ExecuteAsync() var outputPath = CodeModelGenerator.Instance.Configuration.OutputDirectory; var generatedSourceOutputPath = CodeModelGenerator.Instance.Configuration.ProjectGeneratedDirectory; + // Resolve PackageReference items from the .csproj so custom code referencing + // external NuGet types (e.g., Azure.Storage.Common) compiles correctly. + GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + GeneratedCodeWorkspace customCodeWorkspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: true); // The generated attributes need to be added into the workspace before loading the custom code. Otherwise, // Roslyn doesn't load the attributes completely and we are unable to get the attribute arguments. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs index 6a389540e83..56fd518ca29 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs @@ -280,6 +280,56 @@ public async Task PostProcessAsync() } } + /// + /// Resolves PackageReference items from the project's .csproj file and adds their assemblies + /// as metadata references so that custom code referencing external NuGet types compiles correctly. + /// + internal static void AddPackageReferencesFromProject() + { + var packageName = CodeModelGenerator.Instance.TypeFactory.PrimaryNamespace; + string projectFilePath = Path.GetFullPath( + Path.Combine(CodeModelGenerator.Instance.Configuration.ProjectDirectory, $"{packageName}.csproj")); + + if (!File.Exists(projectFilePath)) + return; + + var projectRoot = ProjectRootElement.Open(projectFilePath); + + var nugetSettings = Settings.LoadDefaultSettings(projectFilePath); + var globalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(nugetSettings); + + foreach (var item in projectRoot.Items.Where(i => i.ItemType == "PackageReference")) + { + var refPackageName = item.Include; + var refVersion = item.Metadata.FirstOrDefault(m => m.Name == "Version")?.Value + ?? item.Children.OfType().FirstOrDefault(m => m.Name == "Version")?.Value; + + if (string.IsNullOrEmpty(refPackageName) || string.IsNullOrEmpty(refVersion)) + continue; + + // Try to find the assembly in the NuGet global packages folder + foreach (var tfm in NugetPackageDownloader.PreferredDotNetFrameworkVersions) + { + var assemblyPath = Path.Combine( + globalPackagesFolder, + refPackageName.ToLowerInvariant(), + refVersion.ToLowerInvariant(), + "lib", + tfm, + $"{refPackageName}.dll"); + + if (File.Exists(assemblyPath)) + { + CodeModelGenerator.Instance.AddMetadataReference( + MetadataReference.CreateFromFile(assemblyPath)); + CodeModelGenerator.Instance.Emitter.Debug( + $"Added metadata reference: {refPackageName}@{refVersion} ({tfm})"); + break; + } + } + } + } + internal static async Task LoadBaselineContract() { var packageName = CodeModelGenerator.Instance.TypeFactory.PrimaryNamespace; From 096213148ad1194b6445dc7c015aef51ca6d6673 Mon Sep 17 00:00:00 2001 From: jolov Date: Tue, 31 Mar 2026 20:26:42 -0700 Subject: [PATCH 2/8] fix(http-client-csharp): resolve PackageReference metadata refs Parse .csproj PackageReference items and resolve assemblies from the NuGet global cache. Fixes custom code that references external types (e.g. StorageSharedKeyCredential) failing to compile. Use fresh ProjectCollection to avoid MSBuild caching stale project files. Use Configuration.PackageName for .csproj file discovery. Tests: - AddsReferencesFromCsproj: verifies assembly is added from NuGet cache - SkipsWhenNoCsproj: no-op when project file doesn't exist - SkipsPackageNotInCache: no-op when package isn't in cache Fixes #10224 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PostProcessing/GeneratedCodeWorkspace.cs | 11 ++- .../test/GeneratedCodeWorkspaceTests.cs | 98 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs index 56fd518ca29..290e30879da 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs @@ -286,14 +286,16 @@ public async Task PostProcessAsync() /// internal static void AddPackageReferencesFromProject() { - var packageName = CodeModelGenerator.Instance.TypeFactory.PrimaryNamespace; + var packageName = CodeModelGenerator.Instance.Configuration.PackageName; string projectFilePath = Path.GetFullPath( Path.Combine(CodeModelGenerator.Instance.Configuration.ProjectDirectory, $"{packageName}.csproj")); if (!File.Exists(projectFilePath)) + { return; + } - var projectRoot = ProjectRootElement.Open(projectFilePath); + var projectRoot = ProjectRootElement.Open(projectFilePath, new Microsoft.Build.Evaluation.ProjectCollection()); var nugetSettings = Settings.LoadDefaultSettings(projectFilePath); var globalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(nugetSettings); @@ -301,11 +303,12 @@ internal static void AddPackageReferencesFromProject() foreach (var item in projectRoot.Items.Where(i => i.ItemType == "PackageReference")) { var refPackageName = item.Include; - var refVersion = item.Metadata.FirstOrDefault(m => m.Name == "Version")?.Value - ?? item.Children.OfType().FirstOrDefault(m => m.Name == "Version")?.Value; + var refVersion = item.Metadata.FirstOrDefault(m => m.Name == "Version")?.Value; if (string.IsNullOrEmpty(refPackageName) || string.IsNullOrEmpty(refVersion)) + { continue; + } // Try to find the assembly in the NuGet global packages folder foreach (var tfm in NugetPackageDownloader.PreferredDotNetFrameworkVersions) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs index 62c2fa12635..149abda0eac 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs @@ -97,6 +97,104 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.NotNull(fooMethod, "Foo method should be found in the SimpleType"); } + [Test] + public void AddPackageReferencesFromProject_AddsReferencesFromCsproj() + { + var ns = "TestNamespace"; + var nugetCacheDir = Path.Combine(_tempDirectory!, "NuGetCache"); + + // Create a fake external package assembly in the NuGet cache + var externalPkgName = "My.External.Library"; + var externalPkgVersion = "2.0.0"; + var externalPkgDir = Path.Combine( + nugetCacheDir, externalPkgName.ToLowerInvariant(), externalPkgVersion, "lib", "netstandard2.0"); + Directory.CreateDirectory(externalPkgDir); + + var externalSyntaxTree = CSharpSyntaxTree.ParseText(@" +namespace My.External.Library +{ + public class ExternalCredential { } +}"); + var externalCompilation = CSharpCompilation.Create( + externalPkgName, + [externalSyntaxTree], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var externalDllPath = Path.Combine(externalPkgDir, $"{externalPkgName}.dll"); + var emitResult = externalCompilation.Emit(externalDllPath); + Assert.IsTrue(emitResult.Success, "Failed to emit external test assembly"); + + // Create a .csproj with a PackageReference to the external package + var csprojContent = $@" + + netstandard2.0 + + + + {externalPkgVersion} + + +"; + var csProjPath = Path.Combine(_projectDir!, "src", $"{ns}.csproj"); + File.WriteAllText(csProjPath, csprojContent); + + MockHelpers.LoadMockGenerator( + inputNamespaceName: ns, + outputPath: _projectDir, + configuration: $"{{\"package-name\": \"{ns}\"}}"); + + var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + + Assert.AreEqual(refCountBefore + 1, refCountAfter, "Should have added one metadata reference"); + } + + [Test] + public void AddPackageReferencesFromProject_SkipsWhenNoCsproj() + { + // Use a namespace that doesn't match any .csproj in the project dir + MockHelpers.LoadMockGenerator( + inputNamespaceName: "NonExistentNamespace", + outputPath: _projectDir, + configuration: "{\"package-name\": \"NonExistentNamespace\"}"); + + var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + + Assert.AreEqual(refCountBefore, refCountAfter, "Should not add references when no .csproj exists"); + } + + [Test] + public void AddPackageReferencesFromProject_SkipsPackageNotInCache() + { + var ns = "TestNamespace"; + + // Create a .csproj referencing a package that's NOT in the cache + var csprojContent = @" + + netstandard2.0 + + + + +"; + var csProjPath = Path.Combine(_projectDir!, "src", $"{ns}.csproj"); + File.WriteAllText(csProjPath, csprojContent); + + MockHelpers.LoadMockGenerator( + inputNamespaceName: ns, + outputPath: _projectDir, + configuration: $"{{\"package-name\": \"{ns}\"}}"); + + var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + + Assert.AreEqual(refCountBefore, refCountAfter, "Should not add references for packages not in cache"); + } + private void CreateTestAssemblyAndProjectFile(string nugetCacheDir, string csProjectFileName) { var ns = csProjectFileName.StartsWith("TestNamespaceUnevaluatedFrameworkValue") From ed44ff7f08f68bb8b4e1bfc9a62e71f8e1f8e5cb Mon Sep 17 00:00:00 2001 From: jolov Date: Tue, 31 Mar 2026 20:44:19 -0700 Subject: [PATCH 3/8] fix: add NuGet download fallback for missing packages When a PackageReference assembly isn't in the NuGet global cache, download it from NuGet feeds using the existing NugetPackageDownloader. This matches the pattern used by LoadBaselineContract and ensures external references resolve even without a prior dotnet restore. Update tests to async and verify graceful skip when packages can't be found in cache or NuGet feeds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/CSharpGen.cs | 2 +- .../PostProcessing/GeneratedCodeWorkspace.cs | 36 ++++++++++++++++--- .../test/GeneratedCodeWorkspaceTests.cs | 19 +++++----- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs index 6b40e0b9b31..9f31bebadcd 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs @@ -34,7 +34,7 @@ public async Task ExecuteAsync() // Resolve PackageReference items from the .csproj so custom code referencing // external NuGet types (e.g., Azure.Storage.Common) compiles correctly. - GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); GeneratedCodeWorkspace customCodeWorkspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: true); // The generated attributes need to be added into the workspace before loading the custom code. Otherwise, diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs index 290e30879da..1928167dd9e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs @@ -284,7 +284,7 @@ public async Task PostProcessAsync() /// Resolves PackageReference items from the project's .csproj file and adds their assemblies /// as metadata references so that custom code referencing external NuGet types compiles correctly. /// - internal static void AddPackageReferencesFromProject() + internal static async Task AddPackageReferencesFromProject() { var packageName = CodeModelGenerator.Instance.Configuration.PackageName; string projectFilePath = Path.GetFullPath( @@ -311,6 +311,7 @@ internal static void AddPackageReferencesFromProject() } // Try to find the assembly in the NuGet global packages folder + string? resolvedAssemblyPath = null; foreach (var tfm in NugetPackageDownloader.PreferredDotNetFrameworkVersions) { var assemblyPath = Path.Combine( @@ -323,13 +324,38 @@ internal static void AddPackageReferencesFromProject() if (File.Exists(assemblyPath)) { - CodeModelGenerator.Instance.AddMetadataReference( - MetadataReference.CreateFromFile(assemblyPath)); - CodeModelGenerator.Instance.Emitter.Debug( - $"Added metadata reference: {refPackageName}@{refVersion} ({tfm})"); + resolvedAssemblyPath = assemblyPath; break; } } + + // If not found locally, download from NuGet + if (resolvedAssemblyPath == null) + { + try + { + var downloader = new NugetPackageDownloader(refPackageName, refVersion, null, nugetSettings); + var downloadedPath = await downloader.DownloadAndInstallPackage(); + var downloadedAssembly = Path.Combine(downloadedPath, $"{refPackageName}.dll"); + if (File.Exists(downloadedAssembly)) + { + resolvedAssemblyPath = downloadedAssembly; + } + } + catch (Exception ex) + { + CodeModelGenerator.Instance.Emitter.Debug( + $"Could not download package {refPackageName}@{refVersion}: {ex.Message}"); + } + } + + if (resolvedAssemblyPath != null) + { + CodeModelGenerator.Instance.AddMetadataReference( + MetadataReference.CreateFromFile(resolvedAssemblyPath)); + CodeModelGenerator.Instance.Emitter.Debug( + $"Added metadata reference: {refPackageName}@{refVersion}"); + } } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs index 149abda0eac..aa2657cfe81 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs @@ -98,7 +98,7 @@ await MockHelpers.LoadMockGeneratorAsync( } [Test] - public void AddPackageReferencesFromProject_AddsReferencesFromCsproj() + public async Task AddPackageReferencesFromProject_AddsReferencesFromCsproj() { var ns = "TestNamespace"; var nugetCacheDir = Path.Combine(_tempDirectory!, "NuGetCache"); @@ -144,14 +144,14 @@ public class ExternalCredential { } configuration: $"{{\"package-name\": \"{ns}\"}}"); var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; - GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; Assert.AreEqual(refCountBefore + 1, refCountAfter, "Should have added one metadata reference"); } [Test] - public void AddPackageReferencesFromProject_SkipsWhenNoCsproj() + public async Task AddPackageReferencesFromProject_SkipsWhenNoCsproj() { // Use a namespace that doesn't match any .csproj in the project dir MockHelpers.LoadMockGenerator( @@ -160,24 +160,27 @@ public void AddPackageReferencesFromProject_SkipsWhenNoCsproj() configuration: "{\"package-name\": \"NonExistentNamespace\"}"); var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; - GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; Assert.AreEqual(refCountBefore, refCountAfter, "Should not add references when no .csproj exists"); } [Test] - public void AddPackageReferencesFromProject_SkipsPackageNotInCache() + public async Task AddPackageReferencesFromProject_SkipsPackageNotInCache() { var ns = "TestNamespace"; - // Create a .csproj referencing a package that's NOT in the cache + // Create a .csproj referencing a package that doesn't exist in + // the cache or on any NuGet feed — should gracefully skip it. var csprojContent = @" netstandard2.0 - + + 1.0.0 + "; var csProjPath = Path.Combine(_projectDir!, "src", $"{ns}.csproj"); @@ -189,7 +192,7 @@ public void AddPackageReferencesFromProject_SkipsPackageNotInCache() configuration: $"{{\"package-name\": \"{ns}\"}}"); var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; - GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; Assert.AreEqual(refCountBefore, refCountAfter, "Should not add references for packages not in cache"); From 49e3fc9712af4fefa3c351ffa8aee6541beef6ef Mon Sep 17 00:00:00 2001 From: jolov Date: Tue, 31 Mar 2026 20:47:21 -0700 Subject: [PATCH 4/8] fix: skip packages already in AdditionalMetadataReferences Check existing metadata references before resolving from NuGet cache or downloading. Packages already added (e.g., by a plugin via AddMetadataReference) are skipped to avoid redundant downloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/PostProcessing/GeneratedCodeWorkspace.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs index 1928167dd9e..a73860897b5 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs @@ -300,6 +300,13 @@ internal static async Task AddPackageReferencesFromProject() var nugetSettings = Settings.LoadDefaultSettings(projectFilePath); var globalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(nugetSettings); + // Build a set of assembly names already registered so we can skip them + var existingRefs = new HashSet( + CodeModelGenerator.Instance.AdditionalMetadataReferences + .Select(r => Path.GetFileNameWithoutExtension(r.Display ?? "")) + .Where(n => !string.IsNullOrEmpty(n)), + StringComparer.OrdinalIgnoreCase); + foreach (var item in projectRoot.Items.Where(i => i.ItemType == "PackageReference")) { var refPackageName = item.Include; @@ -310,6 +317,12 @@ internal static async Task AddPackageReferencesFromProject() continue; } + // Skip packages already added as metadata references (e.g., by a plugin) + if (existingRefs.Contains(refPackageName)) + { + continue; + } + // Try to find the assembly in the NuGet global packages folder string? resolvedAssemblyPath = null; foreach (var tfm in NugetPackageDownloader.PreferredDotNetFrameworkVersions) From 67ea3a750f556e0b666a41c542992ebb49129b43 Mon Sep 17 00:00:00 2001 From: jolov Date: Tue, 31 Mar 2026 20:52:19 -0700 Subject: [PATCH 5/8] test: add dedup and multiple PackageReference tests - SkipsAlreadyAddedReferences: verifies packages already in AdditionalMetadataReferences are not re-added - AddsMultiplePackageReferences: verifies all PackageReference items are resolved, not just the first Extract CreateFakeNuGetPackage helper to reduce duplication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/GeneratedCodeWorkspaceTests.cs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs index aa2657cfe81..39d924177a8 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs @@ -198,6 +198,110 @@ public async Task AddPackageReferencesFromProject_SkipsPackageNotInCache() Assert.AreEqual(refCountBefore, refCountAfter, "Should not add references for packages not in cache"); } + [Test] + public async Task AddPackageReferencesFromProject_SkipsAlreadyAddedReferences() + { + var ns = "TestNamespace"; + var nugetCacheDir = Path.Combine(_tempDirectory!, "NuGetCache"); + + // Create a fake external package assembly in the NuGet cache + var externalPkgName = "Already.Added.Package"; + var externalPkgVersion = "1.0.0"; + var dllPath = CreateFakeNuGetPackage(nugetCacheDir, externalPkgName, externalPkgVersion); + + // Create a .csproj referencing the package + var csprojContent = $@" + + netstandard2.0 + + + + {externalPkgVersion} + + +"; + File.WriteAllText(Path.Combine(_projectDir!, "src", $"{ns}.csproj"), csprojContent); + + MockHelpers.LoadMockGenerator( + inputNamespaceName: ns, + outputPath: _projectDir, + configuration: $"{{\"package-name\": \"{ns}\"}}"); + + // Pre-add the reference (simulating a plugin that already added it) + CodeModelGenerator.Instance.AddMetadataReference( + MetadataReference.CreateFromFile(dllPath)); + + var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + + Assert.AreEqual(refCountBefore, refCountAfter, + "Should not add duplicate reference for a package already in AdditionalMetadataReferences"); + } + + [Test] + public async Task AddPackageReferencesFromProject_AddsMultiplePackageReferences() + { + var ns = "TestNamespace"; + var nugetCacheDir = Path.Combine(_tempDirectory!, "NuGetCache"); + + // Create two fake packages in the cache + CreateFakeNuGetPackage(nugetCacheDir, "First.Package", "1.0.0"); + CreateFakeNuGetPackage(nugetCacheDir, "Second.Package", "3.5.0"); + + var csprojContent = @" + + netstandard2.0 + + + + 1.0.0 + + + 3.5.0 + + +"; + File.WriteAllText(Path.Combine(_projectDir!, "src", $"{ns}.csproj"), csprojContent); + + MockHelpers.LoadMockGenerator( + inputNamespaceName: ns, + outputPath: _projectDir, + configuration: $"{{\"package-name\": \"{ns}\"}}"); + + var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + + Assert.AreEqual(refCountBefore + 2, refCountAfter, "Should have added two metadata references"); + } + + /// + /// Creates a fake NuGet package assembly in the given cache directory and returns the DLL path. + /// + private static string CreateFakeNuGetPackage(string nugetCacheDir, string packageName, string version) + { + var pkgDir = Path.Combine( + nugetCacheDir, packageName.ToLowerInvariant(), version, "lib", "netstandard2.0"); + Directory.CreateDirectory(pkgDir); + + var syntaxTree = CSharpSyntaxTree.ParseText($@" +namespace {packageName} +{{ + public class Placeholder {{ }} +}}"); + var compilation = CSharpCompilation.Create( + packageName, + [syntaxTree], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var dllPath = Path.Combine(pkgDir, $"{packageName}.dll"); + var result = compilation.Emit(dllPath); + Assert.IsTrue(result.Success, $"Failed to emit fake assembly for {packageName}"); + return dllPath; + } + private void CreateTestAssemblyAndProjectFile(string nugetCacheDir, string csProjectFileName) { var ns = csProjectFileName.StartsWith("TestNamespaceUnevaluatedFrameworkValue") From 333b6487223af4d20497426674c0a124d87eedb4 Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 1 Apr 2026 09:29:00 -0700 Subject: [PATCH 6/8] fix: address PR feedback - Import ProjectCollection via using alias to avoid ambiguity - Check Display is not null instead of coalescing with empty string - Handle PackageReference with no Version (centrally managed packages) by scanning all cached versions for matching assemblies - Move existingRefs check before version parsing - Extract FindPackageAssembly helper for cache lookup - Add test for versionless PackageReference resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PostProcessing/GeneratedCodeWorkspace.cs | 77 +++++++++++++------ .../test/GeneratedCodeWorkspaceTests.cs | 34 ++++++++ 2 files changed, 87 insertions(+), 24 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs index a73860897b5..59ffd6cdfb6 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Microsoft.Build.Construction; using Microsoft.CodeAnalysis; +using MSBuildProjectCollection = Microsoft.Build.Evaluation.ProjectCollection; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Formatting; using Microsoft.CodeAnalysis.Simplification; @@ -295,7 +296,7 @@ internal static async Task AddPackageReferencesFromProject() return; } - var projectRoot = ProjectRootElement.Open(projectFilePath, new Microsoft.Build.Evaluation.ProjectCollection()); + var projectRoot = ProjectRootElement.Open(projectFilePath, new MSBuildProjectCollection()); var nugetSettings = Settings.LoadDefaultSettings(projectFilePath); var globalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(nugetSettings); @@ -303,16 +304,16 @@ internal static async Task AddPackageReferencesFromProject() // Build a set of assembly names already registered so we can skip them var existingRefs = new HashSet( CodeModelGenerator.Instance.AdditionalMetadataReferences - .Select(r => Path.GetFileNameWithoutExtension(r.Display ?? "")) + .Where(r => r.Display is not null) + .Select(r => Path.GetFileNameWithoutExtension(r.Display!)) .Where(n => !string.IsNullOrEmpty(n)), StringComparer.OrdinalIgnoreCase); foreach (var item in projectRoot.Items.Where(i => i.ItemType == "PackageReference")) { var refPackageName = item.Include; - var refVersion = item.Metadata.FirstOrDefault(m => m.Name == "Version")?.Value; - if (string.IsNullOrEmpty(refPackageName) || string.IsNullOrEmpty(refVersion)) + if (string.IsNullOrEmpty(refPackageName)) { continue; } @@ -323,27 +324,15 @@ internal static async Task AddPackageReferencesFromProject() continue; } - // Try to find the assembly in the NuGet global packages folder - string? resolvedAssemblyPath = null; - foreach (var tfm in NugetPackageDownloader.PreferredDotNetFrameworkVersions) - { - var assemblyPath = Path.Combine( - globalPackagesFolder, - refPackageName.ToLowerInvariant(), - refVersion.ToLowerInvariant(), - "lib", - tfm, - $"{refPackageName}.dll"); + var refVersion = item.Metadata.FirstOrDefault(m => m.Name == "Version")?.Value; - if (File.Exists(assemblyPath)) - { - resolvedAssemblyPath = assemblyPath; - break; - } - } + // Try to find the assembly in the NuGet global packages folder. + // When version is null (centrally managed packages), scan all available versions. + string? resolvedAssemblyPath = FindPackageAssembly( + globalPackagesFolder, refPackageName, refVersion); - // If not found locally, download from NuGet - if (resolvedAssemblyPath == null) + // If not found locally and we have a version, download from NuGet + if (resolvedAssemblyPath == null && !string.IsNullOrEmpty(refVersion)) { try { @@ -367,9 +356,49 @@ internal static async Task AddPackageReferencesFromProject() CodeModelGenerator.Instance.AddMetadataReference( MetadataReference.CreateFromFile(resolvedAssemblyPath)); CodeModelGenerator.Instance.Emitter.Debug( - $"Added metadata reference: {refPackageName}@{refVersion}"); + $"Added metadata reference: {refPackageName} from {resolvedAssemblyPath}"); + } + } + } + + /// + /// Searches the NuGet global packages folder for a package assembly. + /// When is null, scans all available versions and + /// returns the first match found (for centrally managed package references). + /// + private static string? FindPackageAssembly(string globalPackagesFolder, string packageName, string? version) + { + var packageDir = Path.Combine(globalPackagesFolder, packageName.ToLowerInvariant()); + + IEnumerable versionDirs; + if (!string.IsNullOrEmpty(version)) + { + // Specific version + versionDirs = [Path.Combine(packageDir, version.ToLowerInvariant())]; + } + else + { + // No version specified (centrally managed) — try all available versions + if (!Directory.Exists(packageDir)) + { + return null; + } + versionDirs = Directory.GetDirectories(packageDir).OrderDescending(); + } + + foreach (var versionDir in versionDirs) + { + foreach (var tfm in NugetPackageDownloader.PreferredDotNetFrameworkVersions) + { + var assemblyPath = Path.Combine(versionDir, "lib", tfm, $"{packageName}.dll"); + if (File.Exists(assemblyPath)) + { + return assemblyPath; + } } } + + return null; } internal static async Task LoadBaselineContract() diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs index 39d924177a8..0a75d8c9360 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs @@ -198,6 +198,40 @@ public async Task AddPackageReferencesFromProject_SkipsPackageNotInCache() Assert.AreEqual(refCountBefore, refCountAfter, "Should not add references for packages not in cache"); } + [Test] + public async Task AddPackageReferencesFromProject_ResolvesPackageWithNoVersion() + { + var ns = "TestNamespace"; + var nugetCacheDir = Path.Combine(_tempDirectory!, "NuGetCache"); + + // Create a fake package in the cache (simulating a centrally managed package) + var externalPkgName = "Centrally.Managed.Package"; + CreateFakeNuGetPackage(nugetCacheDir, externalPkgName, "4.2.0"); + + // Create a .csproj with no Version on the PackageReference + var csprojContent = $@" + + netstandard2.0 + + + + +"; + File.WriteAllText(Path.Combine(_projectDir!, "src", $"{ns}.csproj"), csprojContent); + + MockHelpers.LoadMockGenerator( + inputNamespaceName: ns, + outputPath: _projectDir, + configuration: $"{{\"package-name\": \"{ns}\"}}"); + + var refCountBefore = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + var refCountAfter = CodeModelGenerator.Instance.AdditionalMetadataReferences.Count; + + Assert.AreEqual(refCountBefore + 1, refCountAfter, + "Should resolve package from cache even without a version (centrally managed)"); + } + [Test] public async Task AddPackageReferencesFromProject_SkipsAlreadyAddedReferences() { From 3c34718760c4094658f83bd78dec8d6516cc85e5 Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 1 Apr 2026 09:47:20 -0700 Subject: [PATCH 7/8] fix: simplify to version-agnostic cache lookup Remove version-specific checks and NuGet download fallback. Instead, scan all cached versions for the package and use whatever is available. The cache will contain the correct version from the last dotnet restore. This handles centrally managed packages (no version in csproj) and avoids downloading the wrong version from NuGet. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PostProcessing/GeneratedCodeWorkspace.cs | 55 ++++--------------- 1 file changed, 11 insertions(+), 44 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs index 59ffd6cdfb6..a42bfc4b0dc 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs @@ -324,32 +324,11 @@ internal static async Task AddPackageReferencesFromProject() continue; } - var refVersion = item.Metadata.FirstOrDefault(m => m.Name == "Version")?.Value; - - // Try to find the assembly in the NuGet global packages folder. - // When version is null (centrally managed packages), scan all available versions. - string? resolvedAssemblyPath = FindPackageAssembly( - globalPackagesFolder, refPackageName, refVersion); - - // If not found locally and we have a version, download from NuGet - if (resolvedAssemblyPath == null && !string.IsNullOrEmpty(refVersion)) - { - try - { - var downloader = new NugetPackageDownloader(refPackageName, refVersion, null, nugetSettings); - var downloadedPath = await downloader.DownloadAndInstallPackage(); - var downloadedAssembly = Path.Combine(downloadedPath, $"{refPackageName}.dll"); - if (File.Exists(downloadedAssembly)) - { - resolvedAssemblyPath = downloadedAssembly; - } - } - catch (Exception ex) - { - CodeModelGenerator.Instance.Emitter.Debug( - $"Could not download package {refPackageName}@{refVersion}: {ex.Message}"); - } - } + // Search the NuGet global packages folder for any cached version of this package. + // We don't require a specific version — the cache will contain the correct version + // from the last dotnet restore, regardless of whether the version is specified in + // the csproj or centrally managed via Directory.Packages.props. + string? resolvedAssemblyPath = FindPackageAssembly(globalPackagesFolder, refPackageName); if (resolvedAssemblyPath != null) { @@ -362,31 +341,19 @@ internal static async Task AddPackageReferencesFromProject() } /// - /// Searches the NuGet global packages folder for a package assembly. - /// When is null, scans all available versions and - /// returns the first match found (for centrally managed package references). + /// Searches the NuGet global packages folder for a package assembly across all cached versions. + /// Returns the first matching assembly found, preferring newer versions. /// - private static string? FindPackageAssembly(string globalPackagesFolder, string packageName, string? version) + private static string? FindPackageAssembly(string globalPackagesFolder, string packageName) { var packageDir = Path.Combine(globalPackagesFolder, packageName.ToLowerInvariant()); - IEnumerable versionDirs; - if (!string.IsNullOrEmpty(version)) - { - // Specific version - versionDirs = [Path.Combine(packageDir, version.ToLowerInvariant())]; - } - else + if (!Directory.Exists(packageDir)) { - // No version specified (centrally managed) — try all available versions - if (!Directory.Exists(packageDir)) - { - return null; - } - versionDirs = Directory.GetDirectories(packageDir).OrderDescending(); + return null; } - foreach (var versionDir in versionDirs) + foreach (var versionDir in Directory.GetDirectories(packageDir).OrderDescending()) { foreach (var tfm in NugetPackageDownloader.PreferredDotNetFrameworkVersions) { From 47ff5b1b7028a49f4a20a7b44e2fe31fb8c0dc66 Mon Sep 17 00:00:00 2001 From: jolov Date: Wed, 1 Apr 2026 09:53:23 -0700 Subject: [PATCH 8/8] fix: download latest from NuGet when not in cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a PackageReference assembly isn't found in the NuGet global cache, resolve the latest stable version from configured NuGet feeds and download it. Version checks are skipped — we use whatever is cached first, falling back to latest from NuGet. This handles centrally managed packages where the version isn't in the csproj. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PostProcessing/GeneratedCodeWorkspace.cs | 62 ++++++++++++++++++- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs index a42bfc4b0dc..431b5443059 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs @@ -18,6 +18,8 @@ using Microsoft.TypeSpec.Generator.Providers; using Microsoft.TypeSpec.Generator.Utilities; using NuGet.Configuration; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; namespace Microsoft.TypeSpec.Generator { @@ -325,11 +327,32 @@ internal static async Task AddPackageReferencesFromProject() } // Search the NuGet global packages folder for any cached version of this package. - // We don't require a specific version — the cache will contain the correct version - // from the last dotnet restore, regardless of whether the version is specified in - // the csproj or centrally managed via Directory.Packages.props. string? resolvedAssemblyPath = FindPackageAssembly(globalPackagesFolder, refPackageName); + // If not found in cache, download the latest version from NuGet feeds + if (resolvedAssemblyPath == null) + { + try + { + var latestVersion = await ResolveLatestPackageVersion(refPackageName, nugetSettings); + if (latestVersion != null) + { + var downloader = new NugetPackageDownloader(refPackageName, latestVersion, null, nugetSettings); + var downloadedPath = await downloader.DownloadAndInstallPackage(); + var downloadedAssembly = Path.Combine(downloadedPath, $"{refPackageName}.dll"); + if (File.Exists(downloadedAssembly)) + { + resolvedAssemblyPath = downloadedAssembly; + } + } + } + catch (Exception ex) + { + CodeModelGenerator.Instance.Emitter.Debug( + $"Could not download package {refPackageName}: {ex.Message}"); + } + } + if (resolvedAssemblyPath != null) { CodeModelGenerator.Instance.AddMetadataReference( @@ -368,6 +391,39 @@ internal static async Task AddPackageReferencesFromProject() return null; } + /// + /// Queries configured NuGet feeds to resolve the latest stable version of a package. + /// + private static async Task ResolveLatestPackageVersion(string packageName, ISettings nugetSettings) + { + var sources = SettingsUtility.GetEnabledSources(nugetSettings); + using var cacheContext = new SourceCacheContext(); + foreach (var source in sources) + { + try + { + var repository = Repository.Factory.GetCoreV3(source.Source); + var resource = await repository.GetResourceAsync(); + var versions = await resource.GetAllVersionsAsync( + packageName, cacheContext, NuGet.Common.NullLogger.Instance, CancellationToken.None); + var latest = versions? + .Where(v => !v.IsPrerelease) + .OrderByDescending(v => v) + .FirstOrDefault(); + if (latest != null) + { + return latest.ToString(); + } + } + catch + { + // Skip sources that fail (auth, network, etc.) + } + } + + return null; + } + internal static async Task LoadBaselineContract() { var packageName = CodeModelGenerator.Instance.TypeFactory.PrimaryNamespace;