diff --git a/.agents/skills/update-integrations/SKILL.md b/.agents/skills/update-integrations/SKILL.md index 89ff6b5b0a..4fff347d8f 100644 --- a/.agents/skills/update-integrations/SKILL.md +++ b/.agents/skills/update-integrations/SKILL.md @@ -161,6 +161,9 @@ The script performs the following for every package listed in `aspire-integratio Provisioning SDK overlays restore with the same-version `Aspire.Hosting` reference to model their AppHost context; other packages retain standalone per-package restore graphs. Dependency errors remain fatal. + Restores run in parallel, up to 8 at a time by default (`-Parallelism` changes + the limit; `-Sequential` restores one package at a time). A failed `dotnet restore` + is attempted up to three times before the package counts as failed. 4. Selects the best-matching target framework folder (prefers `net10.0`, then `net9.0`, etc.). 5. Uses Roslyn to analyze the assembly and extract all public types, members, XML docs, and attributes. 6. Writes a `{Package}.{Version}.json` file to `src/frontend/src/data/pkgs/`. @@ -222,6 +225,7 @@ The script reports a summary of successes, failures, and skipped packages. Commo - NuGet API resolution failure Packages that fail should be investigated individually — they may need a different framework target or may not ship a public API surface. +Because restores are retried, a restore failure in the summary usually has a persistent cause, such as a pinned version that's no longer published on the package's feed. If an official release feed is required but not yet publicly reachable, rerun with one of the release feed override environment variables set. @@ -257,6 +261,26 @@ combines their literals; each package's API JSON retains its exact enum surface. Use the package-specific SDK to validate behavior that depends on those differences. The generator rejects nonzero SDK dump exits and error diagnostics even when a JSON file was produced. An incomplete dump isn't a successful API-generation result. +The one exception is a known scanner defect: a method inherited by several proxy +types is exported once per derived type under the base type's capability ID, and the +scanner reports each collision as a `Duplicate capability` error. When a dump exits +nonzero after writing its JSON, the script passes `--tolerate-known-scanner-diagnostics` +to the transformer, which accepts only that diagnostic shape, and only when the dump's +`HandleTypes` show that each defining type is, or derives from, the type that owns the +capability ID. Any other error diagnostic, or a nonzero exit without one, still fails the +package. The script lists tolerated diagnostics in its summary, and +`update-integration-data.ps1` raises a CI warning and lists the affected packages in the +pull request body. Remove the tolerance +once the shipped Aspire CLI includes [microsoft/aspire#20443](https://github.com/microsoft/aspire/pull/20443). + +Packages are generated in waves: `Aspire.Hosting` first, then packages that don't need +supporting scan context, then packages that do. Packages in a wave run concurrently, +up to 8 at a time by default; set `ASPIRE_TS_API_PARALLELISM` to change the limit (`1` +processes one package at a time). `-AspireRepoPath` runs always process one package at +a time because they build shared repo projects. Each `aspire` CLI call is limited to 10 +minutes and each helper tool call to 5 minutes. A dump or export that fails without +producing a result, for example after a timeout or a transient restore failure, is +attempted up to three times. When a TypeScript module is regenerated for a new package version, stale `ts-modules` JSON files for older versions of that same package are deleted automatically. Regenerated modules with no functions or types are omitted from `src/frontend/src/data/ts-modules/`. diff --git a/src/frontend/scripts/update-integration-data.ps1 b/src/frontend/scripts/update-integration-data.ps1 index e797f2e7a7..b9ffd3fd92 100644 --- a/src/frontend/scripts/update-integration-data.ps1 +++ b/src/frontend/scripts/update-integration-data.ps1 @@ -368,6 +368,8 @@ $pkgSummary = '' $pkgSkippedPackages = '' $tsApiSummary = '' $tsSkippedPackages = '' +$tsToleratedPackages = '' +$tsToleratedDiagnostics = [System.Collections.Generic.List[string]]::new() $twoslashSummary = '' $semanticSummary = '' @@ -478,6 +480,34 @@ if ($versionsChanged -and -not $SkipRegen) { } else { '' } + # The generator tolerates only the known duplicate inherited-capability scanner + # defect (fixed by microsoft/aspire#20443) and lists each tolerated diagnostic + # under this line as " [Package] message". Surface them loudly for review. + $inToleratedSection = $false + foreach ($line in $tsLog -split "`r?`n") { + if ($line -match '^\s*Packages with tolerated ATS diagnostics:\s*(?.+?)\s*$') { + $tsToleratedPackages = $Matches.Packages + $inToleratedSection = $true + } + elseif ($inToleratedSection -and $line -match '^\s+\[(?[^\]]+)\]\s+(?.+?)\s*$') { + $tsToleratedDiagnostics.Add("$($Matches.Package): $($Matches.Message)") + } + else { + $inToleratedSection = $false + } + } + if ($tsToleratedPackages) { + $toleratedWarning = "TypeScript modules were generated despite known ATS scanner diagnostics for: $tsToleratedPackages. " + + "Their APIs may be incomplete until the Aspire CLI includes microsoft/aspire#20443; review them before merging." + Write-Warning $toleratedWarning + foreach ($diagnostic in $tsToleratedDiagnostics) { + Write-Warning " $diagnostic" + } + if ($IsCI) { + Write-Host "::warning title=Tolerated ATS scanner diagnostics::$toleratedWarning" + } + } + $twoslashSummary = 'succeeded' # 3c. Cross-artifact semantic validation. This runs only after every @@ -607,6 +637,27 @@ if ($regenRan) { if ($tsSkippedPackages) { [void]$sb.AppendLine("- TypeScript packages skipped because they export no ATS functions: ``$tsSkippedPackages``") } + if ($tsToleratedPackages) { + [void]$sb.AppendLine("") + [void]$sb.AppendLine("### :warning: Tolerated ATS scanner diagnostics") + [void]$sb.AppendLine("") + [void]$sb.AppendLine("The Aspire CLI reported known duplicate inherited-capability diagnostics for the packages below. Their TypeScript modules were generated anyway, so the APIs named in these diagnostics may be incomplete until the CLI includes the scanner fix from microsoft/aspire#20443. **Review these modules before merging.**") + [void]$sb.AppendLine("") + foreach ($packageName in $tsToleratedPackages -split ',\s*') { + [void]$sb.AppendLine("- ``$packageName``") + } + if ($tsToleratedDiagnostics.Count -gt 0) { + [void]$sb.AppendLine("") + [void]$sb.AppendLine("
") + [void]$sb.AppendLine("Tolerated diagnostics") + [void]$sb.AppendLine("") + foreach ($diagnostic in $tsToleratedDiagnostics) { + [void]$sb.AppendLine("- ``$diagnostic``") + } + [void]$sb.AppendLine("") + [void]$sb.AppendLine("
") + } + } } else { [void]$sb.AppendLine("_No integration package versions changed in this run — API reference regeneration was skipped._") @@ -628,6 +679,9 @@ if ($iconWarnings) { if ($regenRan) { [void]$sb.AppendLine("- [ ] New/removed ``pkgs/`` and ``ts-modules/`` files match the version changes") [void]$sb.AppendLine("- [ ] ``src/frontend/src/data/twoslash/aspire.d.ts`` is included in the diff") + if ($tsToleratedPackages) { + [void]$sb.AppendLine("- [ ] TypeScript modules generated with tolerated ATS scanner diagnostics were reviewed") + } } $prBody = $sb.ToString() diff --git a/src/tools/AtsJsonGenerator/GenerateCommand.cs b/src/tools/AtsJsonGenerator/GenerateCommand.cs index 2071d73318..03c7ce3d51 100644 --- a/src/tools/AtsJsonGenerator/GenerateCommand.cs +++ b/src/tools/AtsJsonGenerator/GenerateCommand.cs @@ -49,6 +49,11 @@ internal static class GenerateCommand Description = "Paths to core and supporting-context docs-site JSON files. Capabilities and types already present in these packages are excluded from the output.", }; + private static readonly Option s_tolerateKnownScannerDiagnosticsOption = new("--tolerate-known-scanner-diagnostics") + { + Description = "Accept error diagnostics caused by known ATS scanner defects and report each one. Any other error diagnostic still fails.", + }; + public static RootCommand GetCommand() { var command = new RootCommand("Transforms 'aspire sdk dump --format json' output into docs-site JSON.") @@ -60,6 +65,7 @@ public static RootCommand GetCommand() s_sourceRepoOption, s_sourceCommitOption, s_baseOption, + s_tolerateKnownScannerDiagnosticsOption, }; command.SetAction(static parseResult => @@ -71,8 +77,10 @@ public static RootCommand GetCommand() var sourceRepo = parseResult.GetValue(s_sourceRepoOption); var sourceCommit = parseResult.GetValue(s_sourceCommitOption); var basePaths = parseResult.GetValue(s_baseOption); + var tolerateKnownScannerDiagnostics = parseResult.GetValue(s_tolerateKnownScannerDiagnosticsOption); - return TransformFile(input, output, packageName, version, sourceRepo, sourceCommit, basePaths); + return TransformFile( + input, output, packageName, version, sourceRepo, sourceCommit, basePaths, tolerateKnownScannerDiagnostics); }); return command; @@ -85,7 +93,8 @@ internal static int TransformFile( string? version, string? sourceRepo, string? sourceCommit, - string[]? basePaths = null) + string[]? basePaths = null, + bool tolerateKnownScannerDiagnostics = false) { if (!File.Exists(inputPath)) { @@ -104,7 +113,8 @@ internal static int TransformFile( return 1; } - var result = AtsTransformer.Transform(dump, packageName, version, sourceRepo, sourceCommit); + var result = AtsTransformer.Transform( + dump, packageName, version, sourceRepo, sourceCommit, tolerateKnownScannerDiagnostics); // Supporting packages contribute scan targets, not APIs owned by this package. foreach (var basePath in basePaths ?? []) @@ -159,6 +169,15 @@ internal static int TransformFile( var wroteFile = StableFileWriter.WriteIfChanged(outputPath, JsonSerializer.Serialize(result, options)); Console.WriteLine($"{(wroteFile ? "Generated" : "Unchanged")}: {outputPath} ({result.Functions.Count} functions, {result.HandleTypes.Count} handles, {result.DtoTypes.Count} DTOs, {result.EnumTypes.Count} enums)"); + + if (tolerateKnownScannerDiagnostics) + { + foreach (var message in KnownScannerDiagnostics.GetToleratedErrors(dump)) + { + Console.WriteLine($"{KnownScannerDiagnostics.ToleratedOutputPrefix} {message}"); + } + } + return 0; } } diff --git a/src/tools/AtsJsonGenerator/Helpers/AtsTransformer.cs b/src/tools/AtsJsonGenerator/Helpers/AtsTransformer.cs index 9358394fa7..906c979e6e 100644 --- a/src/tools/AtsJsonGenerator/Helpers/AtsTransformer.cs +++ b/src/tools/AtsJsonGenerator/Helpers/AtsTransformer.cs @@ -10,16 +10,20 @@ internal static class AtsTransformer /// /// Transform the deserialized dump output into a . /// + /// + /// Accept error diagnostics that attributes to known scanner defects. + /// Any other error diagnostic still fails the transform. + /// public static TsPackageModel Transform( AtsDumpRoot dump, string packageName, string? version = null, string? sourceRepository = null, - string? sourceCommit = null) + string? sourceCommit = null, + bool tolerateKnownScannerDiagnostics = false) { - var errors = dump.Diagnostics - .Where(diagnostic => diagnostic.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase)) - .Select(diagnostic => diagnostic.Message) + var errors = GetErrorDiagnostics(dump) + .Where(message => !tolerateKnownScannerDiagnostics || !KnownScannerDiagnostics.IsInheritedDuplicateCapability(message, dump)) .ToArray(); if (errors.Length > 0) { @@ -92,6 +96,11 @@ public static TsPackageModel Transform( }; } + internal static IEnumerable GetErrorDiagnostics(AtsDumpRoot dump) => + dump.Diagnostics + .Where(diagnostic => diagnostic.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase)) + .Select(diagnostic => diagnostic.Message); + private static string? NormalizeSourceRepository(string? sourceRepository) { if (string.IsNullOrWhiteSpace(sourceRepository)) diff --git a/src/tools/AtsJsonGenerator/Helpers/KnownScannerDiagnostics.cs b/src/tools/AtsJsonGenerator/Helpers/KnownScannerDiagnostics.cs new file mode 100644 index 0000000000..0992faf3b3 --- /dev/null +++ b/src/tools/AtsJsonGenerator/Helpers/KnownScannerDiagnostics.cs @@ -0,0 +1,82 @@ +using System.Text.RegularExpressions; + +namespace AtsJsonGenerator.Helpers; + +/// +/// Recognizes ATS scanner error diagnostics caused by known scanner defects. +/// +/// +/// The Aspire CLI scanner exports a method inherited by several proxy types once per +/// derived type, all under the base type's capability ID. The scanner keeps the first +/// definition and reports each collision as an error. Remove this tolerance once the +/// shipped Aspire CLI includes the fix (https://github.com/microsoft/aspire/pull/20443). +/// +internal static partial class KnownScannerDiagnostics +{ + /// + /// Prefix of the line written for each tolerated diagnostic. generate-ts-api-json.ps1 parses this prefix. + /// + public const string ToleratedOutputPrefix = "Tolerated known ATS scanner diagnostic:"; + + /// + /// Returns whether reports the inherited duplicate capability defect. + /// + /// + /// Both definitions must be handle types in that are, or derive from, the + /// type that owns the capability ID. Unrelated types that share a capability ID are a genuine collision. + /// + public static bool IsInheritedDuplicateCapability(string message, AtsDumpRoot dump) + { + var match = InheritedDuplicateCapabilityPattern().Match(message); + if (!match.Success) + { + return false; + } + + var capabilityNamespace = match.Groups["namespace"].Value; + var capabilityTypeName = $"{capabilityNamespace}.{match.Groups["type"].Value}"; + var first = SplitQualifiedMember(match.Groups["first"].Value); + var second = SplitQualifiedMember(match.Groups["second"].Value); + + return first.Namespace.Equals(capabilityNamespace, StringComparison.Ordinal) + && second.Namespace.Equals(capabilityNamespace, StringComparison.Ordinal) + && !first.TypeName.Equals(second.TypeName, StringComparison.Ordinal) + && first.Member.Equals(second.Member, StringComparison.Ordinal) + && first.Member.Equals(match.Groups["method"].Value, StringComparison.OrdinalIgnoreCase) + && IsSameOrDerivedHandleType(dump, first.TypeName, capabilityTypeName) + && IsSameOrDerivedHandleType(dump, second.TypeName, capabilityTypeName); + } + + /// + /// Returns the error diagnostics in that match a known scanner defect. + /// + public static IReadOnlyList GetToleratedErrors(AtsDumpRoot dump) => + AtsTransformer.GetErrorDiagnostics(dump) + .Where(message => IsInheritedDuplicateCapability(message, dump)) + .ToArray(); + + private static bool IsSameOrDerivedHandleType(AtsDumpRoot dump, string typeName, string baseTypeName) + { + var handleType = dump.HandleTypes.FirstOrDefault(handle => + AtsTransformer.StripAssemblyPrefix(handle.AtsTypeId).Equals(typeName, StringComparison.Ordinal)); + + return handleType is not null + && (typeName.Equals(baseTypeName, StringComparison.Ordinal) + || handleType.BaseTypeHierarchy.Any(baseType => + AtsTransformer.StripAssemblyPrefix(baseType.TypeId).Equals(baseTypeName, StringComparison.Ordinal))); + } + + private static (string Namespace, string TypeName, string Member) SplitQualifiedMember(string qualifiedMember) + { + // The pattern guarantees at least three non-empty dot-separated segments. + var memberSeparator = qualifiedMember.LastIndexOf('.'); + var typeName = qualifiedMember[..memberSeparator]; + + return (typeName[..typeName.LastIndexOf('.')], typeName, qualifiedMember[(memberSeparator + 1)..]); + } + + [GeneratedRegex( + @"^Duplicate capability '(?\w+(?:\.\w+)*)/(?\w+)\.(?\w+)': defined at '(?\w+(?:\.\w+){2,})' and '(?\w+(?:\.\w+){2,})'\. Remove \[AspireExport\] from one of them or use different capability IDs\.\z", + RegexOptions.CultureInvariant)] + private static partial Regex InheritedDuplicateCapabilityPattern(); +} diff --git a/src/tools/AtsJsonGenerator/README.md b/src/tools/AtsJsonGenerator/README.md index 91c8d6aa8a..dbdab3042f 100644 --- a/src/tools/AtsJsonGenerator/README.md +++ b/src/tools/AtsJsonGenerator/README.md @@ -8,6 +8,12 @@ The Aspire CLI can emit a JSON description of all TypeScript-accessible capabili The companion `generate-ts-api-json.ps1` script reads the generated C# package JSON files in `src/frontend/src/data/pkgs/` and regenerates TypeScript API JSON using the same `Aspire.Hosting*` and `CommunityToolkit.Aspire.Hosting*` package/version sets. Modules with no generated functions or types are omitted so empty packages don't appear in the TypeScript API reference. +### Script behavior + +- **Concurrency:** `Aspire.Hosting` is generated first, then packages that don't need supporting scan context, then packages that do. Packages in each wave run concurrently, up to 8 at a time by default. Set `ASPIRE_TS_API_PARALLELISM` to change the limit; `1` processes one package at a time. `-AspireRepoPath` runs always process one package at a time because dumps build shared repo projects. +- **Timeouts and retries:** Each `aspire` CLI call is limited to 10 minutes, and each transformer or enum-supplement call to 5 minutes. A dump or export that fails without producing a result is attempted up to three times. +- **Error diagnostics:** A dump that reports error diagnostics fails the package, with one exception. When the CLI writes the dump and exits nonzero, the script passes `--tolerate-known-scanner-diagnostics` to the transformer. That option accepts only the known inherited duplicate-capability scanner defect, where a method inherited by several proxy types is reported as `Duplicate capability '…': defined at 'Namespace.TypeA.Method' and 'Namespace.TypeB.Method'`. The transformer also checks the dump's `HandleTypes`: each defining type must be the type that owns the capability ID or derive from it, so unrelated types that share a capability ID aren't tolerated. Any other error diagnostic still fails the package. The transformer prints each tolerated diagnostic, and the script summary lists them under `Packages with tolerated ATS diagnostics:` for `update-integration-data.ps1` to surface in its pull request. Remove the tolerance once the shipped Aspire CLI includes [microsoft/aspire#20443](https://github.com/microsoft/aspire/pull/20443). + ## Usage ### Single file diff --git a/src/tools/AtsJsonGenerator/generate-ts-api-json.ps1 b/src/tools/AtsJsonGenerator/generate-ts-api-json.ps1 index 7b7628b9cc..45c9ec4170 100644 --- a/src/tools/AtsJsonGenerator/generate-ts-api-json.ps1 +++ b/src/tools/AtsJsonGenerator/generate-ts-api-json.ps1 @@ -90,6 +90,23 @@ $AspireCliPath = if ([string]::IsNullOrWhiteSpace($env:ASPIRE_CLI_PATH)) { "aspi # restore requires a feed that is unreachable (e.g. an authenticated internal feed). $CarryForwardOnDumpFailure = ($env:ASPIRE_TS_API_CARRY_FORWARD -eq '1') +# Packages are generated concurrently. ASPIRE_TS_API_PARALLELISM overrides the +# default worker count; set it to 1 to process packages one at a time. +$Parallelism = [Math]::Min([Environment]::ProcessorCount, 8) +if (-not [string]::IsNullOrWhiteSpace($env:ASPIRE_TS_API_PARALLELISM)) { + $parsedParallelism = 0 + if (-not [int]::TryParse($env:ASPIRE_TS_API_PARALLELISM.Trim(), [ref]$parsedParallelism) -or $parsedParallelism -lt 1) { + throw "ASPIRE_TS_API_PARALLELISM must be a positive integer, but was '$($env:ASPIRE_TS_API_PARALLELISM)'." + } + $Parallelism = $parsedParallelism +} + +# Each external tool invocation is bounded. Dump and export attempts that fail +# without producing a result (crash, timeout, transient restore failure) are retried. +$MaxToolAttempts = 3 +$CliTimeoutSeconds = 600 +$ToolTimeoutSeconds = 300 + $FinalOutputDir = if ($OutputDir) { [System.IO.Path]::GetFullPath($OutputDir) } @@ -524,68 +541,292 @@ if (-not $AspireRepoPath -and -not $hasExplicitNuGetPackageVersion) { Write-Host " Found $($NuGetPackageVersion.Count) TypeScript SDK packages to process" -ForegroundColor DarkGray } -# ── Helper: invoke the aspire CLI ────────────────────────────────────────────── +# ── Helpers: generate one package ────────────────────────────────────────────── +# These functions also run inside ForEach-Object -Parallel runspaces, so they +# use only their parameters, never script-scope variables. -function Invoke-AspireCli { +function Invoke-CapturedProcess { param( - [string[]]$Arguments, - [string]$WorkingDirectory, - [string]$StderrFile, - [string]$StdoutFile + [Parameter(Mandatory)][string]$FileName, + [string[]]$Arguments = @(), + [Parameter(Mandatory)][string]$WorkingDirectory, + [Parameter(Mandatory)][int]$TimeoutSeconds ) $startInfo = [System.Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FileName $startInfo.WorkingDirectory = $WorkingDirectory $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true - $startInfo.RedirectStandardOutput = -not [string]::IsNullOrWhiteSpace($StdoutFile) - if ($AspireCliProject) { - $startInfo.FileName = "dotnet" - $allArgs = @("run", "--no-launch-profile", "--no-build", "--project", $AspireCliProject, "--") + $Arguments - } else { - $startInfo.FileName = $AspireCliPath - $allArgs = $Arguments - } - foreach ($argument in $allArgs) { + foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } + $process = [System.Diagnostics.Process]::new() $process.StartInfo = $startInfo try { if (-not $process.Start()) { - throw "Could not start $($startInfo.FileName)." + throw "Could not start $FileName." } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() - $stdoutTask = if ($startInfo.RedirectStandardOutput) { - $process.StandardOutput.ReadToEndAsync() - } else { $null } - $process.WaitForExit() - [System.IO.File]::WriteAllText($StderrFile, $stderrTask.GetAwaiter().GetResult()) - if ($stdoutTask) { - [System.IO.File]::WriteAllText($StdoutFile, $stdoutTask.GetAwaiter().GetResult()) + $timedOut = -not $process.WaitForExit($TimeoutSeconds * 1000) + if ($timedOut) { + try { $process.Kill($true) } catch { } + [void]$process.WaitForExit(30000) + } + # Bound the wait in case an orphaned child process still holds the pipes. + try { + [void][System.Threading.Tasks.Task]::WaitAll([System.Threading.Tasks.Task[]]@($stdoutTask, $stderrTask), 30000) + } + catch { } + + return [PSCustomObject]@{ + ExitCode = if ($process.HasExited) { $process.ExitCode } else { -1 } + TimedOut = $timedOut + StdOut = if ($stdoutTask.IsCompletedSuccessfully) { $stdoutTask.Result } else { "" } + StdErr = if ($stderrTask.IsCompletedSuccessfully) { $stderrTask.Result } else { "" } } - $exitCode = $process.ExitCode } finally { $process.Dispose() } +} + +function Invoke-AspireCli { + param( + [Parameter(Mandatory)][string[]]$Arguments, + [Parameter(Mandatory)][hashtable]$Settings + ) + + if ($Settings.AspireCliProject) { + $fileName = "dotnet" + $allArguments = @("run", "--no-launch-profile", "--no-build", "--project", $Settings.AspireCliProject, "--") + $Arguments + } + else { + $fileName = $Settings.AspireCliPath + $allArguments = $Arguments + } + + $result = Invoke-CapturedProcess -FileName $fileName -Arguments $allArguments ` + -WorkingDirectory $Settings.CliWorkingDirectory -TimeoutSeconds $Settings.CliTimeoutSeconds # Filter out update notices from stderr - if (Test-Path $StderrFile) { - $stderrContent = Get-Content $StderrFile -Raw -ErrorAction SilentlyContinue - if ($stderrContent -and $stderrContent -match "A new version of the Aspire CLI is available") { - # Strip the update notice lines and rewrite - $filtered = ($stderrContent -split "`r?`n" | Where-Object { - $_ -notmatch "A new version of the Aspire CLI is available" -and - $_ -notmatch "To update, run:" -and - $_ -notmatch "For more information, see:" - }) -join "`n" - Set-Content -Path $StderrFile -Value $filtered.Trim() -ErrorAction SilentlyContinue + if ($result.StdErr -match "A new version of the Aspire CLI is available") { + $result.StdErr = (($result.StdErr -split "`r?`n" | Where-Object { + $_ -notmatch "A new version of the Aspire CLI is available" -and + $_ -notmatch "To update, run:" -and + $_ -notmatch "For more information, see:" + }) -join "`n").Trim() + } + + return $result +} + +# Dumps, optionally supplements, and transforms one package. Never throws: the +# result carries the status and the log lines to print on the main thread. +function Invoke-TsModulePackage { + param( + [Parameter(Mandatory)][hashtable]$Package, + [Parameter(Mandatory)][hashtable]$Settings + ) + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $log = [System.Collections.Generic.List[object]]::new() + $tolerated = [System.Collections.Generic.List[string]]::new() + # Kind is Info, Detail, Success, or Warning. + function Write-PackageLog([string]$Text, [string]$Kind = "Info") { + $log.Add([PSCustomObject]@{ Text = $Text; Kind = $Kind }) + } + function Get-ProcessOutput([psobject]$Result) { + $lines = @($Result.StdErr, $Result.StdOut) -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + return $lines -join "`n " + } + function Get-FailureReason([psobject]$Result) { + if ($Result.TimedOut) { return "timed out after $($Settings.CliTimeoutSeconds)s" } + return "failed (exit $($Result.ExitCode))" + } + + $name = $Package.Name + $version = $Package.Version + $fileBaseName = if ($version) { "$name.$version" } else { $name } + $outputFile = Join-Path $Settings.OutputDir "$fileBaseName.json" + $status = "Failed" + + try { + # Keep each package's intermediate files apart from concurrently running packages. + $workDirectory = Join-Path $Settings.TempDir ([Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $workDirectory -Force | Out-Null + $dumpFile = Join-Path $workDirectory "$name.json" + + # Step 1: Run aspire sdk dump --format json + $contextOutputFile = $null + $dumpArgs = @("sdk", "dump", "--format", "json", "--non-interactive", "--nologo", "-o", $dumpFile) + $Package.DumpArgs + if ($Package.ContainsKey("ScanContext")) { + $context = $Package.ScanContext + $contextFileName = if ($context.Version) { "$($context.Name).$($context.Version).json" } else { "$($context.Name).json" } + $contextOutputFile = @( + (Join-Path $Settings.OutputDir $contextFileName), + (Join-Path $Settings.FinalOutputDir $contextFileName) + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $contextOutputFile) { + throw "Generate $contextFileName before $name so supporting APIs can be excluded from its output." + } + $contextModel = Get-Content $contextOutputFile -Raw | ConvertFrom-Json + if ($contextModel.package.name -ne $context.Name -or ($context.Version -and $contextModel.package.version -ne $context.Version)) { + throw "Supporting API metadata does not match the exact scan context: $contextOutputFile" + } + $dumpArgs += $context.DumpArgs + Write-PackageLog " Supporting scan context: $($context.DumpArgs -join ', ')" Detail } + + $dumpReportedDiagnostics = $false + for ($attempt = 1; ; $attempt++) { + Remove-Item $dumpFile -Force -ErrorAction SilentlyContinue + $dump = Invoke-AspireCli -Arguments $dumpArgs -Settings $Settings + $dumpCreated = Test-Path $dumpFile + if (-not $dump.TimedOut -and $dump.ExitCode -eq 0 -and $dumpCreated) { + break + } + if (-not $dump.TimedOut -and $dumpCreated) { + # The CLI writes the dump before it fails on error diagnostics. A retry + # reports the same errors, so the transform decides which are known defects. + $dumpReportedDiagnostics = $true + Write-PackageLog " aspire sdk dump reported error diagnostics (exit $($dump.ExitCode))" Warning + break + } + $reason = if ($dump.TimedOut -or $dump.ExitCode -ne 0) { Get-FailureReason $dump } else { "did not create the dump file" } + if ($attempt -ge $Settings.MaxAttempts) { + throw "aspire sdk dump $reason after $attempt attempt(s):`n $(Get-ProcessOutput $dump)" + } + Write-PackageLog " aspire sdk dump $reason (attempt $attempt of $($Settings.MaxAttempts)); retrying..." Warning + Start-Sleep -Seconds (10 * $attempt) + } + + # Step 2: Resolve canonical enum definitions for generated exports + if ($Package.ContainsKey("HasGeneratedExports") -and $Package.HasGeneratedExports) { + $exportFile = Join-Path $workDirectory "$name.canonical.json" + $exportArgs = @("sdk", "export", "--language", "typescript", "--package", "$name@$version", "--non-interactive", "--nologo") + for ($attempt = 1; ; $attempt++) { + $export = Invoke-AspireCli -Arguments $exportArgs -Settings $Settings + if (-not $export.TimedOut -and $export.ExitCode -eq 0) { + break + } + if ($attempt -ge $Settings.MaxAttempts) { + throw "Canonical export $(Get-FailureReason $export) for $name after $attempt attempt(s):`n $(Get-ProcessOutput $export)" + } + Write-PackageLog " aspire sdk export $(Get-FailureReason $export) (attempt $attempt of $($Settings.MaxAttempts)); retrying..." Warning + Start-Sleep -Seconds (10 * $attempt) + } + [System.IO.File]::WriteAllText($exportFile, $export.StdOut) + + $supplement = Invoke-CapturedProcess -FileName "node" ` + -Arguments @($Settings.TsxCliPath, $Settings.SupplementScriptPath, $dumpFile, $exportFile) ` + -WorkingDirectory $Settings.FrontendDir -TimeoutSeconds $Settings.ToolTimeoutSeconds + if ($supplement.TimedOut -or $supplement.ExitCode -ne 0) { + throw "Could not resolve canonical enum definitions for ${name}:`n $(Get-ProcessOutput $supplement)" + } + foreach ($line in $supplement.StdOut -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) { + Write-PackageLog " $line" Detail + } + } + + # Step 3: Transform with AtsJsonGenerator, excluding core and supporting-context APIs + $transformArgs = @( + $Settings.TransformerPath, + "--input", $dumpFile, + "--output", $outputFile, + "--package-name", $name + ) + if ($Package.SourceRepository) { + $transformArgs += @("--source-repo", $Package.SourceRepository) + } + if (-not [string]::IsNullOrWhiteSpace($Package.SourceCommit)) { + $transformArgs += @("--source-commit", $Package.SourceCommit) + } + if ($Settings.CoreOutputFile) { + $transformArgs += @("--base", $Settings.CoreOutputFile) + } + if ($contextOutputFile) { + $transformArgs += @("--base", $contextOutputFile) + } + if ($dumpReportedDiagnostics) { + $transformArgs += "--tolerate-known-scanner-diagnostics" + } + + $transform = Invoke-CapturedProcess -FileName "dotnet" -Arguments $transformArgs ` + -WorkingDirectory $Settings.RepoRoot -TimeoutSeconds $Settings.ToolTimeoutSeconds + if ($transform.TimedOut -or $transform.ExitCode -ne 0) { + throw "Transform $(if ($transform.TimedOut) { 'timed out' } else { "failed (exit $($transform.ExitCode))" }):`n $(Get-ProcessOutput $transform)" + } + + $toleratedPrefix = "Tolerated known ATS scanner diagnostic:" + foreach ($line in $transform.StdOut -split "`r?`n") { + if ($line.StartsWith($toleratedPrefix, [System.StringComparison]::Ordinal)) { + $tolerated.Add($line.Substring($toleratedPrefix.Length).Trim()) + } + elseif ($line -match "^(Generated|Unchanged):") { + Write-PackageLog " $line" Success + } + } + if ($dumpReportedDiagnostics -and $tolerated.Count -eq 0) { + throw "aspire sdk dump failed (exit $($dump.ExitCode)) without reporting a known scanner defect:`n $(Get-ProcessOutput $dump)" + } + foreach ($message in $tolerated) { + Write-PackageLog " Tolerated known ATS scanner diagnostic: $message" Warning + } + + $status = "Succeeded" + } + catch { + Write-PackageLog " $($_.Exception.Message)" Warning + Remove-Item $outputFile -Force -ErrorAction SilentlyContinue + } + + return [PSCustomObject]@{ + Name = $name + Version = $version + Status = $status + OutputFile = $outputFile + ToleratedDiagnostics = $tolerated.ToArray() + Elapsed = $stopwatch.Elapsed + Log = $log.ToArray() + } +} + +# Runs packages that do not depend on each other, streaming each result as it completes. +function Invoke-TsModuleWave { + param( + [object[]]$WavePackages = @(), + [Parameter(Mandatory)][hashtable]$Settings + ) + + if ($WavePackages.Count -eq 0) { + return + } + if ($Settings.Parallelism -le 1 -or $WavePackages.Count -eq 1) { + foreach ($package in $WavePackages) { + Invoke-TsModulePackage -Package $package -Settings $Settings + } + return + } + + $functionDefinitions = @{} + foreach ($functionName in @("Invoke-CapturedProcess", "Invoke-AspireCli", "Invoke-TsModulePackage")) { + $functionDefinitions[$functionName] = (Get-Command $functionName -CommandType Function).Definition } - return [PSCustomObject]@{ ExitCode = $exitCode } + $WavePackages | ForEach-Object -ThrottleLimit $Settings.Parallelism -Parallel { + Set-StrictMode -Version Latest + $ErrorActionPreference = "Stop" + $definitions = $using:functionDefinitions + foreach ($functionName in $definitions.Keys) { + Set-Item -Path "function:$functionName" -Value ([scriptblock]::Create($definitions[$functionName])) + } + Invoke-TsModulePackage -Package $_ -Settings $using:Settings + } } # ── Collect packages to process ──────────────────────────────────────────────── @@ -704,7 +945,7 @@ foreach ($pkg in $Packages | Where-Object { $_.Name -eq "Aspire.Hosting.Radius" } if ($PackageFilter) { - $Packages = $Packages | Where-Object { $_.Name -like $PackageFilter } + $Packages = @($Packages | Where-Object { $_.Name -like $PackageFilter }) } $branchName = Get-CurrentBranchName @@ -744,252 +985,134 @@ if ($LASTEXITCODE -ne 0) { exit 1 } +# Run the built assembly directly; `dotnet run` would evaluate the project again for every package. +$transformerPath = @(& dotnet msbuild $ToolProject -nologo -getProperty:TargetPath) | Select-Object -Last 1 +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($transformerPath) -or -not (Test-Path $transformerPath.Trim())) { + Write-Host "Could not locate the built AtsJsonGenerator assembly" -ForegroundColor Red + Remove-Item -Path $OutputDir -Recurse -Force -ErrorAction SilentlyContinue + exit 1 +} + # ── Generate ATS dumps ───────────────────────────────────────────────────────── -# Process core Aspire.Hosting first so we can use it as a base for dedup +if ($AspireRepoPath) { + # Dumping a local project builds the repo's shared projects; concurrent dumps would contend for their outputs. + $Parallelism = 1 +} +Write-Host "Parallelism: $Parallelism" -ForegroundColor DarkGray + +$frontendDir = Join-Path $RepoRoot "src" "frontend" +$settings = @{ + AspireCliProject = $AspireCliProject + AspireCliPath = $AspireCliPath + CliWorkingDirectory = if ($AspireRepoPath) { $AspireRepoPath } elseif ($aspireCliWorkingDirectory) { $aspireCliWorkingDirectory } else { $PWD.Path } + RepoRoot = $RepoRoot + FrontendDir = $frontendDir + TsxCliPath = Join-Path $frontendDir "node_modules" "tsx" "dist" "cli.mjs" + SupplementScriptPath = Join-Path $frontendDir "scripts" "supplement-ats-enums.ts" + TransformerPath = $transformerPath.Trim() + TempDir = $TempDir + OutputDir = $OutputDir + FinalOutputDir = $FinalOutputDir + CoreOutputFile = $null + MaxAttempts = $MaxToolAttempts + CliTimeoutSeconds = $CliTimeoutSeconds + ToolTimeoutSeconds = $ToolTimeoutSeconds + Parallelism = $Parallelism +} + +# Core Aspire.Hosting runs first so integrations can exclude its APIs. Packages +# that need a supporting scan context run last, after that context's module exists. $corePackages = @($Packages | Where-Object { $_.Name -eq "Aspire.Hosting" }) -$integrationPackages = @($Packages | Where-Object { $_.Name -ne "Aspire.Hosting" }) +$integrationPackages = @($Packages | Where-Object { $_.Name -ne "Aspire.Hosting" } | Sort-Object { $_.Name }) +$independentPackages = @($integrationPackages | Where-Object { -not $_.ContainsKey("ScanContext") }) +$scanContextPackages = @($integrationPackages | Where-Object { $_.ContainsKey("ScanContext") }) $success = 0 $failed = 0 $skipped = 0 +$completed = 0 $failedPackageNames = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) $skippedPackageNames = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) +$toleratedDiagnosticsByPackage = @{} $coreOutputFile = $null +$slowestResult = $null +$generationStopwatch = [System.Diagnostics.Stopwatch]::StartNew() -# Process core first -foreach ($pkg in $corePackages) { - $name = $pkg.Name - $version = $pkg.Version - $fileBaseName = if ($version) { "$name.$version" } else { $name } - $dumpFile = Join-Path $TempDir "$name.json" - $outputFile = Join-Path $OutputDir "$fileBaseName.json" - - Write-Host "" - Write-Host "[$name] (core — processed first)" -ForegroundColor Cyan +# Prints a package's buffered log and records its outcome on the main thread. +function Complete-TsModuleResult { + param( + [Parameter(Mandatory, ValueFromPipeline)][psobject]$Result, + [switch]$IsCore + ) - # Step 1: Run aspire sdk dump --format json - Write-Host " Dumping ATS capabilities..." - try { - $dumpArgs = @("sdk", "dump", "--format", "json", "--non-interactive", "--nologo", "-o", $dumpFile) - $dumpArgs += $pkg.DumpArgs - - $workDir = if ($AspireRepoPath) { $AspireRepoPath } elseif ($aspireCliWorkingDirectory) { $aspireCliWorkingDirectory } else { $PWD.Path } - $proc = Invoke-AspireCli -Arguments $dumpArgs -WorkingDirectory $workDir ` - -StderrFile (Join-Path $TempDir "$name.stderr.txt") - - if ($proc.ExitCode -ne 0) { - $stderr = Get-Content (Join-Path $TempDir "$name.stderr.txt") -Raw -ErrorAction SilentlyContinue - Write-Warning " aspire sdk dump failed (exit $($proc.ExitCode))" - if ($stderr) { Write-Warning " $stderr" } - $failed++ - [void]$failedPackageNames.Add($name) - continue + process { + $script:completed++ + if ($null -eq $script:slowestResult -or $Result.Elapsed -gt $script:slowestResult.Elapsed) { + $script:slowestResult = $Result } - if (-not (Test-Path $dumpFile)) { - Write-Warning " Dump file not created" - $failed++ - [void]$failedPackageNames.Add($name) - continue + $role = if ($IsCore) { "core — processed first, " } else { "" } + Write-Host "" + Write-Host ("[{0}] ({1}{2}/{3}, {4:N1}s)" -f $Result.Name, $role, $script:completed, $Packages.Count, $Result.Elapsed.TotalSeconds) -ForegroundColor Cyan + foreach ($entry in $Result.Log) { + switch ($entry.Kind) { + "Warning" { Write-Warning $entry.Text } + "Success" { Write-Host $entry.Text -ForegroundColor Green } + "Detail" { Write-Host $entry.Text -ForegroundColor DarkGray } + default { Write-Host $entry.Text } + } } - } - catch { - Write-Warning " Error running aspire sdk dump: $_" - $failed++ - [void]$failedPackageNames.Add($name) - continue - } - - # Step 2: Transform (no --base for core) - Write-Host " Transforming to docs JSON..." - try { - $transformArgs = @( - "run", "--project", $ToolProject, "--no-build", "--", - "--input", $dumpFile, - "--output", $outputFile, - "--package-name", $name - ) - $sourceRepository = $pkg.SourceRepository - if ($sourceRepository) { - $transformArgs += @("--source-repo", $sourceRepository) - } - if (-not [string]::IsNullOrWhiteSpace($pkg.SourceCommit)) { - $transformArgs += @("--source-commit", $pkg.SourceCommit) + if ($Result.Status -ne "Succeeded") { + $script:failed++ + [void]$failedPackageNames.Add($Result.Name) + return } - & dotnet @transformArgs 2>&1 | ForEach-Object { - if ($_ -match "Generated:") { - Write-Host " $_" -ForegroundColor Green - } elseif ($_ -match "FAILED|Error") { - Write-Host " $_" -ForegroundColor Red + try { + if ($Result.Version) { + Remove-StaleTsModuleFiles -PackageName $Result.Name -CurrentOutputFile $Result.OutputFile -OutputDirectory $OutputDir } - } - - if ($LASTEXITCODE -eq 0) { - $success++ - $coreOutputFile = $outputFile - if ($version) { - Remove-StaleTsModuleFiles -PackageName $name -CurrentOutputFile $outputFile -OutputDirectory $OutputDir + if ($IsCore) { + $script:coreOutputFile = $Result.OutputFile + $script:success++ + } + elseif (Remove-EmptyTsModuleFile -PackageName $Result.Name -OutputFile $Result.OutputFile) { + $script:skipped++ + [void]$skippedPackageNames.Add($Result.Name) + } + else { + $script:success++ + } + if ($Result.ToleratedDiagnostics.Count -gt 0) { + $toleratedDiagnosticsByPackage[$Result.Name] = $Result.ToleratedDiagnostics } - } else { - Write-Warning " Transform failed" - $failed++ - [void]$failedPackageNames.Add($name) } - } - catch { - Write-Warning " Error transforming: $_" - $failed++ - [void]$failedPackageNames.Add($name) + catch { + Write-Warning " $($_.Exception.Message)" + Remove-Item $Result.OutputFile -Force -ErrorAction SilentlyContinue + $script:failed++ + [void]$failedPackageNames.Add($Result.Name) + } } } +Invoke-TsModuleWave -WavePackages $corePackages -Settings $settings | Complete-TsModuleResult -IsCore + # Verify core was generated (needed as base for dedup) if (-not $coreOutputFile -or -not (Test-Path $coreOutputFile)) { Write-Warning "Core Aspire.Hosting.json not generated — skipping dedup for integrations" $coreOutputFile = $null } -# Process integration packages with --base for dedup -foreach ($pkg in $integrationPackages | Sort-Object { $_.Name }) { - $name = $pkg.Name - $version = $pkg.Version - $fileBaseName = if ($version) { "$name.$version" } else { $name } - $dumpFile = Join-Path $TempDir "$name.json" - $outputFile = Join-Path $OutputDir "$fileBaseName.json" - - Write-Host "" - Write-Host "[$name]" -ForegroundColor Cyan - - # Step 1: Run aspire sdk dump --format json - Write-Host " Dumping ATS capabilities..." - try { - $contextOutputFile = $null - $dumpArgs = @("sdk", "dump", "--format", "json", "--non-interactive", "--nologo", "-o", $dumpFile) - $dumpArgs += $pkg.DumpArgs - if ($pkg.ContainsKey("ScanContext")) { - $context = $pkg.ScanContext - $contextFileName = if ($context.Version) { "$($context.Name).$($context.Version).json" } else { "$($context.Name).json" } - $contextOutputFile = @( - (Join-Path $OutputDir $contextFileName), - (Join-Path $FinalOutputDir $contextFileName) - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $contextOutputFile) { - throw "Generate $contextFileName before Radius so supporting APIs can be excluded from Radius's output." - } - $contextModel = Get-Content $contextOutputFile -Raw | ConvertFrom-Json - if ($contextModel.package.name -ne $context.Name -or ($context.Version -and $contextModel.package.version -ne $context.Version)) { - throw "Supporting API metadata does not match the exact scan context: $contextOutputFile" - } - $dumpArgs += $context.DumpArgs - Write-Host " Supporting scan context: $($context.DumpArgs -join ', ')" -ForegroundColor DarkGray - } - - $workDir = if ($AspireRepoPath) { $AspireRepoPath } elseif ($aspireCliWorkingDirectory) { $aspireCliWorkingDirectory } else { $PWD.Path } - $proc = Invoke-AspireCli -Arguments $dumpArgs -WorkingDirectory $workDir ` - -StderrFile (Join-Path $TempDir "$name.stderr.txt") - - if ($proc.ExitCode -ne 0) { - $stderr = Get-Content (Join-Path $TempDir "$name.stderr.txt") -Raw -ErrorAction SilentlyContinue - Write-Warning " aspire sdk dump failed (exit $($proc.ExitCode))" - if ($stderr) { Write-Warning " $stderr" } - $failed++ - [void]$failedPackageNames.Add($name) - continue - } - - if (-not (Test-Path $dumpFile)) { - Write-Warning " Dump file not created" - $failed++ - [void]$failedPackageNames.Add($name) - continue - } - - } - catch { - Write-Warning " Error running aspire sdk dump: $_" - $failed++ - [void]$failedPackageNames.Add($name) - continue - } - - # Step 2: Transform with AtsJsonGenerator (with --base dedup) - Write-Host " Transforming to docs JSON..." - try { - if ($pkg.ContainsKey("HasGeneratedExports") -and $pkg.HasGeneratedExports) { - $exportFile = Join-Path $TempDir "$name.canonical.json" - $exportResult = Invoke-AspireCli ` - -Arguments @("sdk", "export", "--language", "typescript", "--package", "$name@$version", "--non-interactive", "--nologo") ` - -WorkingDirectory $workDir ` - -StderrFile (Join-Path $TempDir "$name.export.stderr.txt") ` - -StdoutFile $exportFile - if ($exportResult.ExitCode -ne 0) { - $exportError = Get-Content (Join-Path $TempDir "$name.export.stderr.txt") -Raw - throw "Canonical export failed for $name (exit $($exportResult.ExitCode)): $exportError" - } - $frontend = Join-Path $RepoRoot "src" "frontend" - & node (Join-Path $frontend "node_modules" "tsx" "dist" "cli.mjs") ` - (Join-Path $frontend "scripts" "supplement-ats-enums.ts") $dumpFile $exportFile - if ($LASTEXITCODE -ne 0) { - throw "Could not resolve canonical enum definitions for $name." - } - } - $transformArgs = @( - "run", "--project", $ToolProject, "--no-build", "--", - "--input", $dumpFile, - "--output", $outputFile, - "--package-name", $name - ) - $sourceRepository = $pkg.SourceRepository - if ($sourceRepository) { - $transformArgs += @("--source-repo", $sourceRepository) - } - if (-not [string]::IsNullOrWhiteSpace($pkg.SourceCommit)) { - $transformArgs += @("--source-commit", $pkg.SourceCommit) - } - - # Dedup against core if available - if ($coreOutputFile) { - $transformArgs += @("--base", $coreOutputFile) - } - if ($contextOutputFile) { - $transformArgs += @("--base", $contextOutputFile) - } - - & dotnet @transformArgs 2>&1 | ForEach-Object { - if ($_ -match "Generated:") { - Write-Host " $_" -ForegroundColor Green - } elseif ($_ -match "FAILED|Error") { - Write-Host " $_" -ForegroundColor Red - } - } - - if ($LASTEXITCODE -eq 0) { - if ($version) { - Remove-StaleTsModuleFiles -PackageName $name -CurrentOutputFile $outputFile -OutputDirectory $OutputDir - } - if (Remove-EmptyTsModuleFile -PackageName $name -OutputFile $outputFile) { - $skipped++ - [void]$skippedPackageNames.Add($name) - } else { - $success++ - } - } else { - Write-Warning " Transform failed" - $failed++ - [void]$failedPackageNames.Add($name) - } - } - catch { - Write-Warning " Error transforming: $_" - $failed++ - [void]$failedPackageNames.Add($name) - } -} +$integrationSettings = $settings.Clone() +$integrationSettings.CoreOutputFile = $coreOutputFile +Invoke-TsModuleWave -WavePackages $independentPackages -Settings $integrationSettings | Complete-TsModuleResult +Invoke-TsModuleWave -WavePackages $scanContextPackages -Settings $integrationSettings | Complete-TsModuleResult +$generationStopwatch.Stop() # ── Reconcile and cleanup ─────────────────────────────────────────────────────── @@ -1002,6 +1125,18 @@ if ($failedPackageNames.Count -gt 0) { if ($skippedPackageNames.Count -gt 0) { Write-Host "Skipped packages: $(($skippedPackageNames | Sort-Object) -join ', ')" -ForegroundColor Yellow } +if ($toleratedDiagnosticsByPackage.Count -gt 0) { + # update-integration-data.ps1 parses these lines to warn reviewers in the pull request. + $toleratedPackageNames = @($toleratedDiagnosticsByPackage.Keys | Sort-Object) + Write-Host "Packages with tolerated ATS diagnostics: $($toleratedPackageNames -join ', ')" -ForegroundColor Yellow + foreach ($packageName in $toleratedPackageNames) { + foreach ($diagnostic in $toleratedDiagnosticsByPackage[$packageName]) { + Write-Host " [$packageName] $diagnostic" -ForegroundColor Yellow + } + } +} +$slowestSummary = if ($slowestResult) { " (slowest: $($slowestResult.Name), $('{0:N1}' -f $slowestResult.Elapsed.TotalSeconds)s)" } else { "" } +Write-Host ("Generated in {0:mm\:ss} with parallelism {1}{2}" -f $generationStopwatch.Elapsed, $Parallelism, $slowestSummary) -ForegroundColor DarkGray # Clean up temp files if (Test-Path $TempDir) { diff --git a/src/tools/PackageJsonGenerator/README.md b/src/tools/PackageJsonGenerator/README.md index 7f3cddd8e6..90eafd01cf 100644 --- a/src/tools/PackageJsonGenerator/README.md +++ b/src/tools/PackageJsonGenerator/README.md @@ -109,6 +109,7 @@ dotnet run --project ../PackageJsonGenerator/PackageJsonGenerator.csproj -- \ - Compiler-generated types (e.g., closure classes) are automatically filtered out - The emitted `targetFramework` matches the compile/runtime asset selected by NuGet for the analyzed package - The companion `generate-package-json.ps1` script restores every package independently, uses catalog-pinned versions, and reads `project.assets.json` for exact direct and transitive package references +- Restores run in parallel, up to 8 at a time by default (`-Parallelism` changes the limit; `-Sequential` restores one package at a time), and a failed `dotnet restore` is attempted up to three times before the package counts as failed - Metadata loading includes the matching .NET and ASP.NET Core reference packs; official `Aspire.*` packages resolve from a branch-specific Azure Artifacts feed on `release/*` branches and use nuget.org elsewhere - Generation fails when input references or Aspire export attribute metadata cannot be resolved, rather than emitting incomplete attribute payloads - When the matching `microsoft/aspire` release branch is not publicly reachable yet, set `ASPIRE_RELEASE_FEED_URL`, `ASPIRE_RELEASE_FEED_NAME`, or `ASPIRE_RELEASE_COMMIT` before running the script diff --git a/src/tools/PackageJsonGenerator/generate-package-json.ps1 b/src/tools/PackageJsonGenerator/generate-package-json.ps1 index 42ffbba75f..443aa3f420 100644 --- a/src/tools/PackageJsonGenerator/generate-package-json.ps1 +++ b/src/tools/PackageJsonGenerator/generate-package-json.ps1 @@ -7,7 +7,8 @@ .DESCRIPTION For each package in the list, this script: 1. Queries the NuGet V3 API for the latest stable version (or latest preview if no stable exists). - 2. Restores each package into an isolated project. + 2. Restores each package into an isolated project. Restores run in parallel, + and transient `dotnet restore` failures are retried. 3. Reads NuGet's exact direct and transitive assets from project.assets.json. 4. Runs the PackageJsonGenerator tool in batch mode, passing all packages at once for parallel processing. @@ -25,7 +26,9 @@ Defaults to "net10.0". .PARAMETER Parallelism - Maximum degree of parallelism for the batch tool. Defaults to processor count. + Maximum degree of parallelism for package restores and the batch tool. + Defaults to min(processor count, 8) for restores and processor count for + the batch tool. .PARAMETER Sequential Force sequential processing (one package at a time, legacy mode). @@ -530,9 +533,20 @@ $hostingReference $configLines += '' $configLines | Set-Content $nugetConfigPath -Encoding UTF8 - $restoreResult = & dotnet restore $csprojPath --configfile $nugetConfigPath --verbosity quiet 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "dotnet restore failed for $PackageId $Version`n$restoreResult" + # Retry transient feed and network failures. Deterministic failures, + # such as a missing version, still fail after the final attempt. + $maxRestoreAttempts = 3 + for ($attempt = 1; ; $attempt++) { + $restoreResult = & dotnet restore $csprojPath --configfile $nugetConfigPath --verbosity quiet 2>&1 + if ($LASTEXITCODE -eq 0) { + break + } + if ($attempt -ge $maxRestoreAttempts) { + throw "dotnet restore failed for $PackageId $Version after $attempt attempts`n$restoreResult" + } + $retryDelaySeconds = 5 * $attempt + Write-Warning "dotnet restore failed for $PackageId $Version (attempt $attempt of $maxRestoreAttempts); retrying in ${retryDelaySeconds}s..." + Start-Sleep -Seconds $retryDelaySeconds } $assetsPath = [System.IO.Path]::Combine($restoreDir, "obj", "project.assets.json") @@ -658,6 +672,59 @@ $hostingReference } } +function Invoke-PackageRestore { + [CmdletBinding()] + param([Parameter(Mandatory)][object]$Request) + + # This can run in a ForEach-Object -Parallel runspace, so it doesn't write to + # the host. Its status and retry warnings are returned as data for the main + # runspace to print as each restore completes. Failure details are reported + # in package order after all restores finish. + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $graph = $null + $errorMessage = $null + $restoreWarnings = @() + try { + $graph = Resolve-NuGetPackageRestoreGraph ` + -PackageId $Request.PackageId ` + -Version $Request.Version ` + -RestoreSources $Request.RestoreSources ` + -HostingVersion $Request.HostingVersion ` + -WarningAction SilentlyContinue ` + -WarningVariable restoreWarnings + } + catch { + $errorMessage = "$_" + } + + return [PSCustomObject]@{ + PackageId = $Request.PackageId + Version = $Request.Version + DisplaySource = $Request.DisplaySource + Graph = $graph + Error = $errorMessage + Warnings = @($restoreWarnings | ForEach-Object { $_.Message }) + Elapsed = $stopwatch.Elapsed + } +} + +# Prints a restore's retry warnings and status on the main runspace, then passes +# the result through. +function Write-PackageRestoreStatus { + [CmdletBinding()] + param([Parameter(Mandatory, ValueFromPipeline)][psobject]$Outcome) + + process { + foreach ($warning in $Outcome.Warnings) { + Write-Warning $warning + } + $status = if ($null -eq $Outcome.Error) { "Restored" } else { "Restore failed" } + $color = if ($null -eq $Outcome.Error) { "Yellow" } else { "Red" } + Write-Host (" {0}: {1} {2} from {3} ({4:N1}s)" -f $status, $Outcome.PackageId, $Outcome.Version, $Outcome.DisplaySource, $Outcome.Elapsed.TotalSeconds) -ForegroundColor $color + $Outcome + } +} + # ── Collect runtime reference assemblies ────────────────────────────────────── function Get-RuntimeReferenceAssemblies { @@ -874,25 +941,80 @@ if (-not $hostingVersion) { } } +# Each package restores into its own project, so restores can run concurrently; +# NuGet coordinates access to the shared global packages folder and HTTP cache. +$restoreParallelism = if ($Sequential) { + 1 +} +elseif ($Parallelism -gt 0) { + $Parallelism +} +else { + [Math]::Min([Environment]::ProcessorCount, 8) +} +$restoreRequests = @(foreach ($info in $packageInfos) { + $sourceInfo = $packageSourceMetadata[$info.PackageId] + [PSCustomObject]@{ + PackageId = $info.PackageId + Version = $info.Version + RestoreSources = @($sourceInfo.RestoreSources) + DisplaySource = $sourceInfo.DisplaySource + HostingVersion = $hostingVersion + } + }) + +Write-Host " Restoring $($restoreRequests.Count) packages (parallelism $restoreParallelism)..." +New-Item -ItemType Directory -Path (Join-Path $ScriptDir ".package-json-generator-work") -Force | Out-Null +$restoreStopwatch = [System.Diagnostics.Stopwatch]::StartNew() +$restoreResults = if ($restoreParallelism -le 1 -or $restoreRequests.Count -le 1) { + foreach ($request in $restoreRequests) { + Invoke-PackageRestore -Request $request | Write-PackageRestoreStatus + } +} +else { + $functionDefinitions = @{} + foreach ($functionName in @( + "ConvertTo-NativeAssetPath", + "Get-AssetPaths", + "Resolve-RestoredPackagePath", + "Resolve-NuGetPackageRestoreGraph", + "Invoke-PackageRestore")) { + $functionDefinitions[$functionName] = (Get-Command $functionName -CommandType Function).Definition + } + + $restoreRequests | ForEach-Object -ThrottleLimit $restoreParallelism -Parallel { + Set-StrictMode -Version Latest + $ErrorActionPreference = "Stop" + $definitions = $using:functionDefinitions + foreach ($functionName in $definitions.Keys) { + Set-Item -Path "function:$functionName" -Value ([scriptblock]::Create($definitions[$functionName])) + } + # Resolve-NuGetPackageRestoreGraph reads these script-level values. + $ScriptDir = $using:ScriptDir + $Framework = $using:Framework + Invoke-PackageRestore -Request $_ + } | Write-PackageRestoreStatus +} + +$restoreResultsById = @{} +foreach ($restoreOutcome in @($restoreResults)) { + $restoreResultsById[$restoreOutcome.PackageId] = $restoreOutcome +} +Write-Host (" Restored package graphs in {0:mm\:ss}." -f $restoreStopwatch.Elapsed) + foreach ($info in $packageInfos) { $packageId = $info.PackageId $version = $info.Version - $sourceInfo = $packageSourceMetadata[$packageId] - try { - Write-Host " Restoring: $packageId $version from $($sourceInfo.DisplaySource)" -ForegroundColor Yellow - $restoreGraph = Resolve-NuGetPackageRestoreGraph ` - -PackageId $packageId ` - -Version $version ` - -RestoreSources $sourceInfo.RestoreSources ` - -HostingVersion $hostingVersion - } - catch { - Write-Warning "Failed to restore $packageId $version`: $_" + $restoreOutcome = $restoreResultsById[$packageId] + if ($null -eq $restoreOutcome -or $null -ne $restoreOutcome.Error) { + $restoreError = if ($null -eq $restoreOutcome) { "no restore result was produced." } else { $restoreOutcome.Error } + Write-Warning "Failed to restore $packageId $version`: $restoreError" $failCount++ [void]$failedPackageNames.Add($packageId) continue } + $restoreGraph = $restoreOutcome.Graph if (-not $restoreGraph.InputAssembly) { Write-Warning "No compile/runtime assemblies selected by NuGet for $packageId $version — skipping." diff --git a/tests/AtsJsonGenerator.Tests/AtsJsonGeneratorTests.cs b/tests/AtsJsonGenerator.Tests/AtsJsonGeneratorTests.cs index ee7aa53925..1955cc4468 100644 --- a/tests/AtsJsonGenerator.Tests/AtsJsonGeneratorTests.cs +++ b/tests/AtsJsonGenerator.Tests/AtsJsonGeneratorTests.cs @@ -486,6 +486,47 @@ public void TransformFile_NormalizesToLfAndSkipsRewritingUnchangedOutput() Assert.Equal(unchangedWriteTime, File.GetLastWriteTimeUtc(outputPath)); } + [Fact] + public void TransformFile_ReportsToleratedKnownScannerDiagnostics() + { + using var tempDirectory = new TempDirectory(); + var inputPath = Path.Combine(tempDirectory.Path, "input.json"); + var outputPath = Path.Combine(tempDirectory.Path, "output.json"); + File.WriteAllText(inputPath, JsonSerializer.Serialize(new AtsDumpRoot + { + HandleTypes = AtsTransformerHelperTests.KnownProxyHandleTypes, + Diagnostics = + [ + new() { Severity = "Error", Message = AtsTransformerHelperTests.KustoInheritedDuplicate }, + new() { Severity = "Error", Message = AtsTransformerHelperTests.NetworkInheritedDuplicate }, + ], + })); + + Assert.Throws(() => GenerateCommand.TransformFile( + inputPath, outputPath, "Contoso.Tools", null, null, null)); + Assert.False(File.Exists(outputPath)); + + var originalOut = Console.Out; + using var capturedOut = new StringWriter(); + int exitCode; + try + { + Console.SetOut(capturedOut); + exitCode = GenerateCommand.TransformFile( + inputPath, outputPath, "Contoso.Tools", null, null, null, tolerateKnownScannerDiagnostics: true); + } + finally + { + Console.SetOut(originalOut); + } + + Assert.Equal(0, exitCode); + Assert.True(File.Exists(outputPath)); + var lines = capturedOut.ToString().Split(Environment.NewLine); + Assert.Contains($"Tolerated known ATS scanner diagnostic: {AtsTransformerHelperTests.KustoInheritedDuplicate}", lines); + Assert.Contains($"Tolerated known ATS scanner diagnostic: {AtsTransformerHelperTests.NetworkInheritedDuplicate}", lines); + } + private sealed class TempDirectory : IDisposable { public TempDirectory() diff --git a/tests/AtsJsonGenerator.Tests/AtsTransformerHelperTests.cs b/tests/AtsJsonGenerator.Tests/AtsTransformerHelperTests.cs index 017c57dd27..18670db778 100644 --- a/tests/AtsJsonGenerator.Tests/AtsTransformerHelperTests.cs +++ b/tests/AtsJsonGenerator.Tests/AtsTransformerHelperTests.cs @@ -17,6 +17,171 @@ public void Transform_RejectsDumpContainingErrorDiagnostics() Assert.Contains("Duplicate capability", error.Message); } + internal const string KustoInheritedDuplicate = + "Duplicate capability 'Aspire.Hosting.Azure.Provisioning.Kusto.Generated/KustoDataConnectionProxy.addTo': " + + "defined at 'Aspire.Hosting.Azure.Provisioning.Kusto.Generated.KustoCosmosDBDataConnectionProxy.AddTo' and " + + "'Aspire.Hosting.Azure.Provisioning.Kusto.Generated.KustoEventGridDataConnectionProxy.AddTo'. " + + "Remove [AspireExport] from one of them or use different capability IDs."; + + internal const string NetworkInheritedDuplicate = + "Duplicate capability 'Aspire.Hosting.Azure.Provisioning.Network.Generated/BaseAdminRuleProxy.addTo': " + + "defined at 'Aspire.Hosting.Azure.Provisioning.Network.Generated.BaseAdminRuleProxy.AddTo' and " + + "'Aspire.Hosting.Azure.Provisioning.Network.Generated.NetworkAdminRuleProxy.AddTo'. " + + "Remove [AspireExport] from one of them or use different capability IDs."; + + private const string ContosoDuplicate = + "Duplicate capability 'Contoso.Generated/BaseProxy.addTo': defined at 'Contoso.Generated.FooProxy.AddTo' and " + + "'Contoso.Generated.BarProxy.AddTo'. Remove [AspireExport] from one of them or use different capability IDs."; + + private const string KustoNamespace = "Aspire.Hosting.Azure.Provisioning.Kusto.Generated"; + private const string NetworkNamespace = "Aspire.Hosting.Azure.Provisioning.Network.Generated"; + private const string ProvisionableResourceProxy = "Aspire.Hosting.Azure.Provisioning.ProvisionableResourceProxy"; + + /// + /// Handle types that mirror the proxy hierarchies in the affected Kusto and Network dumps. + /// + internal static List KnownProxyHandleTypes => + [ + CreateHandleType($"{KustoNamespace}.KustoDataConnectionProxy", ProvisionableResourceProxy), + CreateHandleType($"{KustoNamespace}.KustoCosmosDBDataConnectionProxy", $"{KustoNamespace}.KustoDataConnectionProxy", ProvisionableResourceProxy), + CreateHandleType($"{KustoNamespace}.KustoEventGridDataConnectionProxy", $"{KustoNamespace}.KustoDataConnectionProxy", ProvisionableResourceProxy), + CreateHandleType($"{NetworkNamespace}.BaseAdminRuleProxy", ProvisionableResourceProxy), + CreateHandleType($"{NetworkNamespace}.NetworkAdminRuleProxy", $"{NetworkNamespace}.BaseAdminRuleProxy", ProvisionableResourceProxy), + ]; + + [Theory] + // Two derived types inherit the member from the capability's type. + [InlineData(KustoInheritedDuplicate)] + // The capability's type and a derived type both define the member. + [InlineData(NetworkInheritedDuplicate)] + public void IsInheritedDuplicateCapability_MatchesScannerDefectDiagnostics(string message) + { + Assert.True(KnownScannerDiagnostics.IsInheritedDuplicateCapability( + message, new AtsDumpRoot { HandleTypes = KnownProxyHandleTypes })); + } + + [Fact] + public void IsInheritedDuplicateCapability_MatchesDefinitionsDerivedFromCapabilityType() + { + var dump = CreateDump( + CreateHandleType("Contoso.Generated.FooProxy", "Contoso.Generated.BaseProxy"), + CreateHandleType("Contoso.Generated.BarProxy", "Contoso.Generated.IntermediateProxy", "Contoso.Generated.BaseProxy")); + + Assert.True(KnownScannerDiagnostics.IsInheritedDuplicateCapability(ContosoDuplicate, dump)); + } + + [Theory] + // Unrelated types that share the capability ID are a genuine collision. + [InlineData("Contoso.Generated.OtherProxy", "Contoso.Generated.OtherProxy")] + // Only one definition derives from the capability's type. + [InlineData("Contoso.Generated.BaseProxy", "Contoso.Generated.OtherProxy")] + [InlineData("Contoso.Generated.OtherProxy", "Contoso.Generated.BaseProxy")] + public void IsInheritedDuplicateCapability_RejectsDefinitionsNotDerivedFromCapabilityType(string fooBaseType, string barBaseType) + { + var dump = CreateDump( + CreateHandleType("Contoso.Generated.FooProxy", fooBaseType), + CreateHandleType("Contoso.Generated.BarProxy", barBaseType)); + + Assert.False(KnownScannerDiagnostics.IsInheritedDuplicateCapability(ContosoDuplicate, dump)); + } + + [Fact] + public void IsInheritedDuplicateCapability_RejectsDefinitionsMissingFromHandleTypes() + { + var dump = CreateDump(CreateHandleType("Contoso.Generated.FooProxy", "Contoso.Generated.BaseProxy")); + + Assert.False(KnownScannerDiagnostics.IsInheritedDuplicateCapability(ContosoDuplicate, dump)); + } + + [Theory] + // Extension-method capability IDs have no owning type segment. + [InlineData("Duplicate capability 'Aspire.Hosting.Redis/addRedis': defined at 'Aspire.Hosting.RedisBuilderExtensions.AddRedis' and 'Aspire.Hosting.OtherBuilderExtensions.AddRedis'. Remove [AspireExport] from one of them or use different capability IDs.")] + // Both definitions come from the same type. + [InlineData("Duplicate capability 'Contoso.Generated/BaseProxy.addTo': defined at 'Contoso.Generated.FooProxy.AddTo' and 'Contoso.Generated.FooProxy.AddTo'. Remove [AspireExport] from one of them or use different capability IDs.")] + // A definition lives outside the capability's namespace. + [InlineData("Duplicate capability 'Contoso.Generated/BaseProxy.addTo': defined at 'Contoso.Generated.FooProxy.AddTo' and 'Fabrikam.Generated.BarProxy.AddTo'. Remove [AspireExport] from one of them or use different capability IDs.")] + // The definitions are different members. + [InlineData("Duplicate capability 'Contoso.Generated/BaseProxy.addTo': defined at 'Contoso.Generated.FooProxy.AddTo' and 'Contoso.Generated.BarProxy.RemoveFrom'. Remove [AspireExport] from one of them or use different capability IDs.")] + // The definitions do not implement the capability's method. + [InlineData("Duplicate capability 'Contoso.Generated/BaseProxy.addTo': defined at 'Contoso.Generated.FooProxy.Remove' and 'Contoso.Generated.BarProxy.Remove'. Remove [AspireExport] from one of them or use different capability IDs.")] + // The scanner's remediation sentence is missing or altered. + [InlineData("Duplicate capability 'Contoso.Generated/BaseProxy.addTo': defined at 'Contoso.Generated.FooProxy.AddTo' and 'Contoso.Generated.BarProxy.AddTo'.")] + [InlineData("Duplicate capability 'Contoso.Generated/BaseProxy.addTo': defined at 'Contoso.Generated.FooProxy.AddTo' and 'Contoso.Generated.BarProxy.AddTo'. Remove [AspireExport] from one of them.")] + [InlineData("Duplicate capability 'Contoso.Generated/BaseProxy.addTo': defined at 'Contoso.Generated.FooProxy.AddTo' and 'Contoso.Generated.BarProxy.AddTo'. Remove [AspireExport] from one of them or use different capability IDs.\n")] + [InlineData("Duplicate type 'Contoso.Generated/BaseProxy': defined at 'Contoso.Generated.FooProxy.AddTo' and 'Contoso.Generated.BarProxy.AddTo'. Remove [AspireExport] from one of them or use different capability IDs.")] + [InlineData("")] + public void IsInheritedDuplicateCapability_RejectsOtherDiagnostics(string message) + { + // The Contoso definitions derive from the capability's type, so only the message shape is rejected. + var dump = CreateDump( + CreateHandleType("Contoso.Generated.FooProxy", "Contoso.Generated.BaseProxy"), + CreateHandleType("Contoso.Generated.BarProxy", "Contoso.Generated.BaseProxy"), + CreateHandleType("Fabrikam.Generated.BarProxy", "Contoso.Generated.BaseProxy")); + + Assert.False(KnownScannerDiagnostics.IsInheritedDuplicateCapability(message, dump)); + } + + [Fact] + public void Transform_ToleratesKnownScannerDiagnosticsOnlyWhenRequested() + { + var dump = new AtsDumpRoot + { + HandleTypes = KnownProxyHandleTypes, + Diagnostics = + [ + new() { Severity = "Error", Message = KustoInheritedDuplicate }, + new() { Severity = "Error", Message = NetworkInheritedDuplicate }, + ], + }; + + var error = Assert.Throws(() => + AtsTransformer.Transform(dump, "Example.Package")); + Assert.Contains(KustoInheritedDuplicate, error.Message); + + var model = AtsTransformer.Transform(dump, "Example.Package", tolerateKnownScannerDiagnostics: true); + Assert.Equal("Example.Package", model.Package.Name); + Assert.Equal([KustoInheritedDuplicate, NetworkInheritedDuplicate], KnownScannerDiagnostics.GetToleratedErrors(dump)); + } + + [Fact] + public void Transform_ReportsOnlyUnexpectedErrorsWhenToleratingKnownScannerDiagnostics() + { + const string unexpected = "Duplicate capability 'Contoso/addThing': defined at 'Contoso.A.AddThing' and 'Contoso.B.AddThing'. Remove [AspireExport] from one of them or use different capability IDs."; + var dump = new AtsDumpRoot + { + // ContosoDuplicate has the defect's message shape, but its definitions aren't handle types in this dump. + HandleTypes = KnownProxyHandleTypes, + Diagnostics = + [ + new() { Severity = "Error", Message = KustoInheritedDuplicate }, + new() { Severity = "Error", Message = unexpected }, + new() { Severity = "Error", Message = ContosoDuplicate }, + new() { Severity = "Warning", Message = "Ignored warning." }, + ], + }; + + var error = Assert.Throws(() => + AtsTransformer.Transform(dump, "Example.Package", tolerateKnownScannerDiagnostics: true)); + Assert.Equal($"ATS dump contains error diagnostics: {unexpected}; {ContosoDuplicate}", error.Message); + Assert.Equal([KustoInheritedDuplicate], KnownScannerDiagnostics.GetToleratedErrors(dump)); + } + + private static AtsDumpRoot CreateDump(params AtsDumpHandleType[] handleTypes) => new() { HandleTypes = [.. handleTypes] }; + + // Dump type IDs are prefixed with their assembly name. + private static AtsDumpHandleType CreateHandleType(string typeName, params string[] baseTypeNames) => new() + { + AtsTypeId = $"Example.Assembly/{typeName}", + BaseTypeHierarchy = + [ + .. baseTypeNames.Select(baseTypeName => new AtsDumpTypeRef + { + TypeId = $"Example.Assembly/{baseTypeName}", + Category = "Handle", + }), + ], + }; + [Fact] public void FormatTypeRef_PreservesUnionMembersAndArrayPrecedence() {