From 807cf00f98e0a626afb68ed1aa21852f4bebdfb5 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 14 May 2026 15:21:33 -0700 Subject: [PATCH 1/5] Add TypeScript API compatibility check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 6 + .github/workflows/typescript-api-compat.yml | 122 +++++++ Aspire.slnx | 1 + docs/ci/typescript-api-compat.md | 85 +++++ .../Infrastructure.Tests.csproj | 1 + .../TypeScriptApiCompatTests.cs | 196 +++++++++++ .../ApiCompatDiagnostic.cs | 9 + tools/TypeScriptApiCompat/ApiCompatReport.cs | 78 +++++ tools/TypeScriptApiCompat/ApiCompatResult.cs | 56 +++ .../ApiCompatSuppression.cs | 133 ++++++++ .../AtsCompatibilityComparer.cs | 323 ++++++++++++++++++ tools/TypeScriptApiCompat/AtsSurface.cs | 72 ++++ tools/TypeScriptApiCompat/AtsSurfaceParser.cs | 224 ++++++++++++ .../TypeScriptApiCompat/CommandLineOptions.cs | 11 + .../GitHubAnnotationWriter.cs | 39 +++ tools/TypeScriptApiCompat/Program.cs | 54 +++ .../TypeScriptApiCompat.csproj | 18 + .../TypeScriptApiCompatRunner.cs | 45 +++ 18 files changed, 1473 insertions(+) create mode 100644 .github/workflows/typescript-api-compat.yml create mode 100644 docs/ci/typescript-api-compat.md create mode 100644 tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs create mode 100644 tools/TypeScriptApiCompat/ApiCompatDiagnostic.cs create mode 100644 tools/TypeScriptApiCompat/ApiCompatReport.cs create mode 100644 tools/TypeScriptApiCompat/ApiCompatResult.cs create mode 100644 tools/TypeScriptApiCompat/ApiCompatSuppression.cs create mode 100644 tools/TypeScriptApiCompat/AtsCompatibilityComparer.cs create mode 100644 tools/TypeScriptApiCompat/AtsSurface.cs create mode 100644 tools/TypeScriptApiCompat/AtsSurfaceParser.cs create mode 100644 tools/TypeScriptApiCompat/CommandLineOptions.cs create mode 100644 tools/TypeScriptApiCompat/GitHubAnnotationWriter.cs create mode 100644 tools/TypeScriptApiCompat/Program.cs create mode 100644 tools/TypeScriptApiCompat/TypeScriptApiCompat.csproj create mode 100644 tools/TypeScriptApiCompat/TypeScriptApiCompatRunner.cs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e934deb8b64..1fcf525a5b8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -320,6 +320,10 @@ jobs: name: TypeScript SDK Unit Tests uses: ./.github/workflows/typescript-sdk-tests.yml + typescript_api_compat: + name: TypeScript API Compatibility + uses: ./.github/workflows/typescript-api-compat.yml + results: if: ${{ always() && github.repository_owner == 'microsoft' }} runs-on: ubuntu-latest @@ -336,6 +340,7 @@ jobs: extension_tests_win, cli_starter_validation_windows, typescript_sdk_tests, + typescript_api_compat, tests_no_nugets, tests_no_nugets_overflow, tests_requires_nugets_linux, @@ -420,6 +425,7 @@ jobs: (needs.extension_tests_win.result == 'skipped' || needs.cli_starter_validation_windows.result == 'skipped' || needs.typescript_sdk_tests.result == 'skipped' || + needs.typescript_api_compat.result == 'skipped' || needs.tests_no_nugets.result == 'skipped' || needs.tests_requires_nugets_linux.result == 'skipped' || needs.tests_requires_nugets_windows.result == 'skipped' || diff --git a/.github/workflows/typescript-api-compat.yml b/.github/workflows/typescript-api-compat.yml new file mode 100644 index 00000000000..e38c7fbb624 --- /dev/null +++ b/.github/workflows/typescript-api-compat.yml @@ -0,0 +1,122 @@ +name: TypeScript API Compatibility + +on: + workflow_call: + +permissions: + contents: read + +jobs: + typescript_api_compat: + name: TypeScript API Compatibility + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Restore + run: ./restore.sh + + - name: Build CLI and compatibility tool + run: | + ./dotnet.sh build src/Aspire.Cli/Aspire.Cli.csproj --configuration Release /p:SkipNativeBuild=true + ./dotnet.sh build tools/TypeScriptApiCompat/TypeScriptApiCompat.csproj --configuration Release + + - name: Extract base ATS baseline + shell: bash + env: + BASE_BRANCH: ${{ github.base_ref }} + run: | + set -euo pipefail + + BASE_REF="origin/${BASE_BRANCH}" + BASELINE_DIR="${RUNNER_TEMP}/ats-baseline" + mkdir -p "$BASELINE_DIR" + + git fetch --no-tags --depth=1 origin "+refs/heads/${BASE_BRANCH}:refs/remotes/origin/${BASE_BRANCH}" + git ls-tree -r --name-only "$BASE_REF" -- src | grep '\.ats\.txt$' > "${RUNNER_TEMP}/ats-baseline-files.txt" + + while IFS= read -r file; do + mkdir -p "${BASELINE_DIR}/$(dirname "$file")" + git show "${BASE_REF}:${file}" > "${BASELINE_DIR}/${file}" + done < "${RUNNER_TEMP}/ats-baseline-files.txt" + + echo "BASELINE_DIR=$BASELINE_DIR" >> "$GITHUB_ENV" + + - name: Generate current ATS surface + shell: bash + run: | + set -euo pipefail + + CURRENT_DIR="${RUNNER_TEMP}/ats-current" + PROJECTS_FILE="${RUNNER_TEMP}/ats-projects.txt" + SORTED_PROJECTS_FILE="${RUNNER_TEMP}/ats-projects-sorted.txt" + ASPIRE_CLI="./dotnet.sh run --no-build --project src/Aspire.Cli/Aspire.Cli.csproj --configuration Release -- --nologo" + + mkdir -p "$CURRENT_DIR/src/Aspire.Hosting/api" + : > "$PROJECTS_FILE" + + echo "::group::Aspire.Hosting (core)" + $ASPIRE_CLI sdk dump --format ci -o "$CURRENT_DIR/src/Aspire.Hosting/api/Aspire.Hosting.ats.txt" + echo "::endgroup::" + + while IFS= read -r file; do + case "$file" in + */obj/*|*/bin/*|*Aspire.Hosting.Analyzers*|*Aspire.Hosting.CodeGeneration*|*Aspire.Hosting.RemoteHost*) + continue + ;; + esac + + dir=$(dirname "$file") + while [ "$dir" != "." ] && [ "$dir" != "/" ]; do + if compgen -G "$dir/*.csproj" > /dev/null; then + echo "$dir" >> "$PROJECTS_FILE" + break + fi + + dir=$(dirname "$dir") + done + done < <(grep -rl '\[AspireExport(' src/Aspire.Hosting.*/ --include='*.cs' || true) + + sort -u "$PROJECTS_FILE" > "$SORTED_PROJECTS_FILE" + + while IFS= read -r proj; do + [ -n "$proj" ] || continue + proj_name=$(basename "$proj") + csproj="$proj/$proj_name.csproj" + if [ -f "$csproj" ]; then + echo "::group::$proj_name" + mkdir -p "$CURRENT_DIR/$proj/api" + $ASPIRE_CLI sdk dump --format ci "$csproj" -o "$CURRENT_DIR/$proj/api/$proj_name.ats.txt" + echo "::endgroup::" + fi + done < "$SORTED_PROJECTS_FILE" + + echo "CURRENT_DIR=$CURRENT_DIR" >> "$GITHUB_ENV" + + - name: Check TypeScript API compatibility + shell: bash + run: | + set -euo pipefail + + ./dotnet.sh run \ + --no-build \ + --project tools/TypeScriptApiCompat/TypeScriptApiCompat.csproj \ + --configuration Release \ + -- \ + --baseline "$BASELINE_DIR" \ + --current "$CURRENT_DIR" \ + --suppressions-root "${{ github.workspace }}" \ + --report "${RUNNER_TEMP}/typescript-api-compat-report.md" \ + --github-annotations + + - name: Upload TypeScript API compatibility report + if: failure() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: typescript-api-compat-report + path: ${{ runner.temp }}/typescript-api-compat-report.md + if-no-files-found: ignore diff --git a/Aspire.slnx b/Aspire.slnx index 30979a3df35..b158b97a179 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -537,6 +537,7 @@ + diff --git a/docs/ci/typescript-api-compat.md b/docs/ci/typescript-api-compat.md new file mode 100644 index 00000000000..fe7933c8c33 --- /dev/null +++ b/docs/ci/typescript-api-compat.md @@ -0,0 +1,85 @@ +# TypeScript API compatibility + +The TypeScript API compatibility check prevents pull requests from introducing undeclared breaking changes in the ATS surface used to generate TypeScript polyglot AppHost SDKs. + +## Baseline + +The checked-in `src/Aspire.Hosting*/api/*.ats.txt` files are the compatibility baseline. In pull request CI, `.github/workflows/typescript-api-compat.yml` reads those files from the pull request target branch and compares them with fresh `aspire sdk dump --format ci` output generated from the pull request. + +This intentionally differs from a plain git diff. A pull request cannot hide a breaking change by editing the baseline file in the same PR; the baseline is always loaded from the merge target branch. + +The scheduled `.github/workflows/generate-ats-diffs.yml` workflow remains the review and release mechanism for updating the checked-in ATS files after API changes are accepted. + +After a new version ships, reset the compatibility baseline by updating the checked-in ATS files to the shipped surface. Suppressions for breaks that are now part of that new baseline should be deleted in the same change; keep only suppressions that still describe intentional breaks relative to the target branch baseline. The compatibility checker fails on unused suppressions, which helps catch declarations that should have been removed during the reset. + +## Breaking changes + +The checker treats these ATS changes as breaking: + +- Removed packages, handle types, DTO types, enum types, exported values, or capabilities. +- Removed handle flags such as `ExposeProperties` or `ExposeMethods`. +- Removed DTO properties, optional-to-required DTO property changes, DTO property type changes, or newly added required DTO properties. +- Removed enum values. +- Exported value type or literal value changes. +- Removed capability parameters, optional-to-required parameter changes, required parameter additions, parameter type changes, parameter order changes, or return type changes. + +Additive changes such as new capabilities, new optional parameters, new optional DTO properties, new enum values, and new exported values do not require suppressions. + +## Suppressions + +Intentional breaks must be declared in a suppression file. Per-package suppressions should live next to the ATS file: + +```text +src/Aspire.Hosting.Redis/api/Aspire.Hosting.Redis.tscompat.suppression.txt +``` + +Cross-cutting suppressions may use: + +```text +eng/TypeScriptApiCompat/global.suppression.txt +``` + +The format is: + +```text +BREAK -- -- +``` + +The `` field is required so every intentional breaking change is traceable to the issue or pull request where the API break was reviewed and approved. The `` should briefly explain why the break is intentional. + +Supported `` values are: + +- `package-removed` +- `handle-removed` +- `handle-flag-removed` +- `dto-removed` +- `dto-property-removed` +- `dto-property-type-changed` +- `dto-property-required` +- `dto-property-added-required` +- `enum-removed` +- `enum-value-removed` +- `exported-value-removed` +- `exported-value-type-changed` +- `exported-value-changed` +- `capability-removed` +- `capability-return-type-changed` +- `capability-parameter-removed` +- `capability-parameter-type-changed` +- `capability-parameter-required` +- `capability-parameter-added-required` +- `capability-parameter-order-changed` + +Example: + +```text +BREAK capability-removed Aspire.Hosting.Redis Aspire.Hosting.Redis/withRedisCommander -- https://github.com/microsoft/aspire/issues/16961 -- Removed unsupported API before GA +``` + +Suppression matching is exact. Unused suppressions fail the check so stale entries are removed when the API surface changes again. + +## Generated TypeScript declarations + +This check currently compares the ATS source surface that feeds TypeScript generation. Generator implementation changes are still covered by TypeScript code generation tests and `tests/PolyglotAppHosts/*/TypeScript` validation, but they are not yet classified against a generated `.d.ts` baseline. + +If generator-only API shape changes need the same breaking-change treatment, extend `tools/TypeScriptApiCompat` to generate declaration-only output from the checked-in TypeScript validation AppHosts and compare those declarations with the same suppression format. diff --git a/tests/Infrastructure.Tests/Infrastructure.Tests.csproj b/tests/Infrastructure.Tests/Infrastructure.Tests.csproj index 837fc0e615a..49f855a65b2 100644 --- a/tests/Infrastructure.Tests/Infrastructure.Tests.csproj +++ b/tests/Infrastructure.Tests/Infrastructure.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs b/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs new file mode 100644 index 00000000000..bec2652bd2c --- /dev/null +++ b/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs @@ -0,0 +1,196 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using TypeScriptApiCompat; +using Xunit; + +namespace Infrastructure.Tests.TypeScriptApiCompat; + +public sealed class TypeScriptApiCompatTests +{ + [Fact] + public void ParserReadsAtsCiSurface() + { + var surface = AtsSurfaceParser.Parse("Pkg", """ + # Aspire Type System Capabilities + # Generated by: aspire sdk dump --format ci + + # Handle Types + Pkg/Thing [interface, ExposeProperties] + + # DTO Types + Pkg/Options # DTO description + name: string # property description + count?: number + + # Enum Types + enum:Pkg.Mode = One | Two + + # Exported Values + Configs.Default: string = "dev" # copied value + + # Capabilities + Pkg/addThing(name: string, port?: number) -> Pkg/Thing + """); + + var handle = Assert.Single(surface.HandleTypes.Values); + Assert.Equal("Pkg/Thing", handle.TypeId); + Assert.Contains("interface", handle.Flags); + Assert.Contains("ExposeProperties", handle.Flags); + + var dto = Assert.Single(surface.DtoTypes.Values); + Assert.Equal("Pkg/Options", dto.TypeId); + Assert.False(dto.Properties["name"].IsOptional); + Assert.True(dto.Properties["count"].IsOptional); + + var enumType = Assert.Single(surface.EnumTypes.Values); + Assert.Equal(["One", "Two"], enumType.Values); + + var exportedValue = Assert.Single(surface.ExportedValues.Values); + Assert.Equal("Configs.Default", exportedValue.Path); + Assert.Equal("\"dev\"", exportedValue.Value); + + var capability = Assert.Single(surface.Capabilities.Values); + Assert.Equal("Pkg/addThing", capability.CapabilityId); + Assert.Equal("Pkg/Thing", capability.ReturnTypeId); + Assert.Equal("port", capability.Parameters[1].Name); + Assert.True(capability.Parameters[1].IsOptional); + } + + [Fact] + public void ComparerClassifiesBreakingAndAdditiveChanges() + { + using var tempDirectory = new TestTempDirectory(); + var baselineRoot = Path.Combine(tempDirectory.Path, "baseline"); + var currentRoot = Path.Combine(tempDirectory.Path, "current"); + + WriteSurface(baselineRoot, "Pkg", """ + # Handle Types + Pkg/Thing [ExposeProperties, ExposeMethods] + + # DTO Types + Pkg/Options + name: string + optional?: string + + # Enum Types + enum:Pkg.Mode = One | Two + + # Exported Values + Configs.Default: string = "old" + + # Capabilities + Pkg/addThing(name: string, port?: number) -> Pkg/Thing + Pkg/removeMe() -> void + """); + + WriteSurface(currentRoot, "Pkg", """ + # Handle Types + Pkg/Thing [ExposeProperties] + Pkg/NewThing + + # DTO Types + Pkg/Options + name: number + optional: string + newRequired: string + newOptional?: string + + # Enum Types + enum:Pkg.Mode = One + + # Exported Values + Configs.Default: string = "new" + + # Capabilities + Pkg/addThing(name: number, port: number, requiredName: string, optionalName?: string) -> void + Pkg/newCapability() -> void + """); + + var diagnostics = AtsCompatibilityComparer.Compare(AtsSurfaceSet.Load(baselineRoot), AtsSurfaceSet.Load(currentRoot)); + + Assert.Contains(diagnostics, d => d.Kind == "handle-flag-removed" && d.Symbol == "Pkg/Thing.ExposeMethods"); + Assert.Contains(diagnostics, d => d.Kind == "dto-property-type-changed" && d.Symbol == "Pkg/Options.name"); + Assert.Contains(diagnostics, d => d.Kind == "dto-property-required" && d.Symbol == "Pkg/Options.optional"); + Assert.Contains(diagnostics, d => d.Kind == "dto-property-added-required" && d.Symbol == "Pkg/Options.newRequired"); + Assert.Contains(diagnostics, d => d.Kind == "enum-value-removed" && d.Symbol == "enum:Pkg.Mode.Two"); + Assert.Contains(diagnostics, d => d.Kind == "exported-value-changed" && d.Symbol == "Configs.Default"); + Assert.Contains(diagnostics, d => d.Kind == "capability-removed" && d.Symbol == "Pkg/removeMe"); + Assert.Contains(diagnostics, d => d.Kind == "capability-return-type-changed" && d.Symbol == "Pkg/addThing"); + Assert.Contains(diagnostics, d => d.Kind == "capability-parameter-type-changed" && d.Symbol == "Pkg/addThing(name)"); + Assert.Contains(diagnostics, d => d.Kind == "capability-parameter-required" && d.Symbol == "Pkg/addThing(port)"); + Assert.Contains(diagnostics, d => d.Kind == "capability-parameter-added-required" && d.Symbol == "Pkg/addThing(requiredName)"); + Assert.DoesNotContain(diagnostics, d => d.Symbol is "Pkg/NewThing" or "Pkg/newCapability" or "Pkg/addThing(optionalName)" or "Pkg/Options.newOptional"); + } + + [Fact] + public void SuppressionsUseExactMatchesAndFailWhenUnused() + { + using var tempDirectory = new TestTempDirectory(); + var suppressionPath = Path.Combine(tempDirectory.Path, "Pkg.tscompat.suppression.txt"); + File.WriteAllText(suppressionPath, """ + # Intentional break + BREAK capability-removed Pkg Pkg/removeMe -- https://github.com/microsoft/aspire/issues/16961 -- Intentional test break + BREAK dto-removed Pkg Pkg/Stale -- https://github.com/microsoft/aspire/issues/16961 -- Stale test suppression + """); + + var diagnostics = new[] + { + new ApiCompatDiagnostic("capability-removed", "Pkg", "Pkg/removeMe", "Capability was removed."), + new ApiCompatDiagnostic("enum-removed", "Pkg", "enum:Pkg.Mode", "Enum was removed.") + }; + + var suppressions = ApiCompatSuppressionLoader.Load(tempDirectory.Path); + var result = ApiCompatSuppressor.ApplySuppressions(diagnostics, suppressions); + + var suppressed = Assert.Single(result.SuppressedDiagnostics); + Assert.Equal("capability-removed|Pkg|Pkg/removeMe", suppressed.SuppressionKey); + + var unsuppressed = Assert.Single(result.UnsuppressedDiagnostics); + Assert.Equal("enum-removed|Pkg|enum:Pkg.Mode", unsuppressed.SuppressionKey); + + var unused = Assert.Single(result.UnusedSuppressions); + Assert.Equal("dto-removed|Pkg|Pkg/Stale", unused.SuppressionKey); + } + + [Fact] + public void RunnerWritesReportAndReturnsFailureForUnsuppressedBreaks() + { + using var tempDirectory = new TestTempDirectory(); + var baselineRoot = Path.Combine(tempDirectory.Path, "baseline"); + var currentRoot = Path.Combine(tempDirectory.Path, "current"); + var reportPath = Path.Combine(tempDirectory.Path, "report.md"); + + WriteSurface(baselineRoot, "Pkg", """ + # Handle Types + + # Capabilities + Pkg/removeMe() -> void + """); + + WriteSurface(currentRoot, "Pkg", """ + # Handle Types + + # Capabilities + """); + + var exitCode = TypeScriptApiCompatRunner.Run(new CommandLineOptions( + baselineRoot, + currentRoot, + tempDirectory.Path, + reportPath, + GitHubAnnotations: false)); + + Assert.Equal(1, exitCode); + var report = File.ReadAllText(reportPath); + Assert.Contains("capability-removed", report, StringComparison.Ordinal); + Assert.Contains("Pkg/removeMe", report, StringComparison.Ordinal); + } + + private static void WriteSurface(string rootPath, string packageName, string content) + { + var apiDirectory = Path.Combine(rootPath, "src", packageName, "api"); + Directory.CreateDirectory(apiDirectory); + File.WriteAllText(Path.Combine(apiDirectory, $"{packageName}.ats.txt"), content); + } +} diff --git a/tools/TypeScriptApiCompat/ApiCompatDiagnostic.cs b/tools/TypeScriptApiCompat/ApiCompatDiagnostic.cs new file mode 100644 index 00000000000..53105b0091e --- /dev/null +++ b/tools/TypeScriptApiCompat/ApiCompatDiagnostic.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace TypeScriptApiCompat; + +internal sealed record ApiCompatDiagnostic(string Kind, string PackageName, string Symbol, string Message) +{ + public string SuppressionKey => $"{Kind}|{PackageName}|{Symbol}"; +} diff --git a/tools/TypeScriptApiCompat/ApiCompatReport.cs b/tools/TypeScriptApiCompat/ApiCompatReport.cs new file mode 100644 index 00000000000..0267fda1db3 --- /dev/null +++ b/tools/TypeScriptApiCompat/ApiCompatReport.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using System.Globalization; + +namespace TypeScriptApiCompat; + +internal static class ApiCompatReport +{ + public static string Create(ApiCompatResult result) + { + var builder = new StringBuilder(); + + builder.AppendLine("# TypeScript API compatibility report"); + builder.AppendLine(); + + if (!result.HasFailures) + { + builder.AppendLine("No undeclared TypeScript API compatibility breaks were found."); + if (result.SuppressedDiagnostics.Count > 0) + { + builder.AppendLine(); + builder.AppendLine(string.Format(CultureInfo.InvariantCulture, "Suppressed diagnostics: {0}", result.SuppressedDiagnostics.Count)); + } + + return builder.ToString(); + } + + if (result.UnsuppressedDiagnostics.Count > 0) + { + builder.AppendLine("## Unsuppressed breaking changes"); + builder.AppendLine(); + foreach (var diagnostic in result.UnsuppressedDiagnostics.OrderBy(static d => d.PackageName, StringComparer.Ordinal).ThenBy(static d => d.Kind, StringComparer.Ordinal).ThenBy(static d => d.Symbol, StringComparer.Ordinal)) + { + builder.AppendLine(string.Format(CultureInfo.InvariantCulture, "- `{0}` `{1}` `{2}` - {3}", diagnostic.Kind, diagnostic.PackageName, diagnostic.Symbol, diagnostic.Message)); + } + + builder.AppendLine(); + } + + if (result.SuppressionErrors.Count > 0) + { + builder.AppendLine("## Suppression file errors"); + builder.AppendLine(); + foreach (var error in result.SuppressionErrors) + { + builder.AppendLine(string.Format(CultureInfo.InvariantCulture, "- {0}", error)); + } + + builder.AppendLine(); + } + + if (result.UnusedSuppressions.Count > 0) + { + builder.AppendLine("## Unused suppressions"); + builder.AppendLine(); + foreach (var suppression in result.UnusedSuppressions) + { + builder.AppendLine(string.Format(CultureInfo.InvariantCulture, "- `{0}` `{1}` `{2}` at `{3}:{4}`", suppression.Kind, suppression.PackageName, suppression.Symbol, suppression.FilePath, suppression.LineNumber)); + } + + builder.AppendLine(); + } + + if (result.SuppressedDiagnostics.Count > 0) + { + builder.AppendLine("## Suppressed breaking changes"); + builder.AppendLine(); + foreach (var diagnostic in result.SuppressedDiagnostics.OrderBy(static d => d.PackageName, StringComparer.Ordinal).ThenBy(static d => d.Kind, StringComparer.Ordinal).ThenBy(static d => d.Symbol, StringComparer.Ordinal)) + { + builder.AppendLine(string.Format(CultureInfo.InvariantCulture, "- `{0}` `{1}` `{2}` - {3}", diagnostic.Kind, diagnostic.PackageName, diagnostic.Symbol, diagnostic.Message)); + } + } + + return builder.ToString(); + } +} diff --git a/tools/TypeScriptApiCompat/ApiCompatResult.cs b/tools/TypeScriptApiCompat/ApiCompatResult.cs new file mode 100644 index 00000000000..61e28b6146b --- /dev/null +++ b/tools/TypeScriptApiCompat/ApiCompatResult.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace TypeScriptApiCompat; + +internal sealed record ApiCompatResult( + IReadOnlyList UnsuppressedDiagnostics, + IReadOnlyList SuppressedDiagnostics, + IReadOnlyList UnusedSuppressions, + IReadOnlyList SuppressionErrors) +{ + public bool HasFailures => + UnsuppressedDiagnostics.Count > 0 || + UnusedSuppressions.Count > 0 || + SuppressionErrors.Count > 0; +} + +internal static class ApiCompatSuppressor +{ + public static ApiCompatResult ApplySuppressions( + IReadOnlyList diagnostics, + SuppressionLoadResult suppressionLoadResult) + { + var suppressionsByKey = suppressionLoadResult.Suppressions + .GroupBy(static suppression => suppression.SuppressionKey, StringComparer.Ordinal) + .ToDictionary(static group => group.Key, static group => group.ToArray(), StringComparer.Ordinal); + var usedSuppressionKeys = new HashSet(StringComparer.Ordinal); + var unsuppressedDiagnostics = new List(); + var suppressedDiagnostics = new List(); + + foreach (var diagnostic in diagnostics) + { + if (suppressionsByKey.ContainsKey(diagnostic.SuppressionKey)) + { + usedSuppressionKeys.Add(diagnostic.SuppressionKey); + suppressedDiagnostics.Add(diagnostic); + } + else + { + unsuppressedDiagnostics.Add(diagnostic); + } + } + + var unusedSuppressions = suppressionLoadResult.Suppressions + .Where(suppression => !usedSuppressionKeys.Contains(suppression.SuppressionKey)) + .OrderBy(static suppression => suppression.FilePath, StringComparer.Ordinal) + .ThenBy(static suppression => suppression.LineNumber) + .ToArray(); + + return new ApiCompatResult( + unsuppressedDiagnostics, + suppressedDiagnostics, + unusedSuppressions, + suppressionLoadResult.Errors); + } +} diff --git a/tools/TypeScriptApiCompat/ApiCompatSuppression.cs b/tools/TypeScriptApiCompat/ApiCompatSuppression.cs new file mode 100644 index 00000000000..368cdc88e20 --- /dev/null +++ b/tools/TypeScriptApiCompat/ApiCompatSuppression.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; + +namespace TypeScriptApiCompat; + +internal sealed record ApiCompatSuppression( + string Kind, + string PackageName, + string Symbol, + string Url, + string Reason, + string FilePath, + int LineNumber) +{ + public string SuppressionKey => $"{Kind}|{PackageName}|{Symbol}"; +} + +internal sealed record SuppressionLoadResult( + IReadOnlyList Suppressions, + IReadOnlyList Errors); + +internal static partial class ApiCompatSuppressionLoader +{ + public static SuppressionLoadResult Load(string rootPath) + { + if (!Directory.Exists(rootPath)) + { + return new SuppressionLoadResult([], [$"Suppressions root '{rootPath}' does not exist."]); + } + + var suppressions = new List(); + var errors = new List(); + var files = EnumerateSuppressionFiles(rootPath).Order(StringComparer.Ordinal); + + foreach (var file in files) + { + ParseFile(file, suppressions, errors); + } + + var duplicateGroups = suppressions + .GroupBy(static suppression => suppression.SuppressionKey, StringComparer.Ordinal) + .Where(static group => group.Count() > 1); + + foreach (var group in duplicateGroups) + { + var locations = string.Join(", ", group.Select(static suppression => $"{suppression.FilePath}:{suppression.LineNumber}")); + errors.Add($"Duplicate suppression '{group.Key}' appears at {locations}."); + } + + return new SuppressionLoadResult(suppressions, errors); + } + + private static void ParseFile(string filePath, List suppressions, List errors) + { + var lineNumber = 0; + foreach (var rawLine in File.ReadLines(filePath)) + { + lineNumber++; + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) + { + continue; + } + + var match = SuppressionLineRegex().Match(line); + if (!match.Success) + { + errors.Add($"{filePath}:{lineNumber}: Invalid suppression. Expected: BREAK -- -- "); + continue; + } + + var url = match.Groups["url"].Value; + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) + { + errors.Add($"{filePath}:{lineNumber}: Suppression URL must be an absolute http(s) URL."); + continue; + } + + var reason = match.Groups["reason"].Value.Trim(); + if (reason.Length == 0) + { + errors.Add($"{filePath}:{lineNumber}: Suppression reason is required."); + continue; + } + + suppressions.Add(new ApiCompatSuppression( + match.Groups["kind"].Value, + match.Groups["package"].Value, + match.Groups["symbol"].Value.Trim(), + url, + reason, + filePath, + lineNumber)); + } + } + + private static IEnumerable EnumerateSuppressionFiles(string rootPath) + { + var pendingDirectories = new Stack(); + pendingDirectories.Push(rootPath); + + while (pendingDirectories.Count > 0) + { + var directory = pendingDirectories.Pop(); + foreach (var file in Directory.EnumerateFiles(directory)) + { + var fileName = Path.GetFileName(file); + if (fileName.EndsWith(".tscompat.suppression.txt", StringComparison.Ordinal) || + string.Equals(fileName, "global.suppression.txt", StringComparison.Ordinal)) + { + yield return file; + } + } + + foreach (var childDirectory in Directory.EnumerateDirectories(directory)) + { + var name = Path.GetFileName(childDirectory); + if (name is ".git" or ".dotnet" or "artifacts" or "bin" or "obj" or "node_modules") + { + continue; + } + + pendingDirectories.Push(childDirectory); + } + } + } + + [GeneratedRegex(@"^BREAK\s+(?\S+)\s+(?\S+)\s+(?.+?)\s+--\s+(?\S+)\s+--\s+(?.+)$", RegexOptions.CultureInvariant)] + private static partial Regex SuppressionLineRegex(); +} diff --git a/tools/TypeScriptApiCompat/AtsCompatibilityComparer.cs b/tools/TypeScriptApiCompat/AtsCompatibilityComparer.cs new file mode 100644 index 00000000000..3c1bd273d22 --- /dev/null +++ b/tools/TypeScriptApiCompat/AtsCompatibilityComparer.cs @@ -0,0 +1,323 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace TypeScriptApiCompat; + +internal static class AtsCompatibilityComparer +{ + public static IReadOnlyList Compare(AtsSurfaceSet baselineSet, AtsSurfaceSet currentSet) + { + var diagnostics = new List(); + + foreach (var (packageName, baseline) in baselineSet.Surfaces.OrderBy(static pair => pair.Key, StringComparer.Ordinal)) + { + if (!currentSet.Surfaces.TryGetValue(packageName, out var current)) + { + diagnostics.Add(new ApiCompatDiagnostic( + "package-removed", + packageName, + "*", + $"Package '{packageName}' has an ATS baseline but no current ATS surface.")); + continue; + } + + CompareSurface(baseline, current, diagnostics); + } + + return diagnostics; + } + + private static void CompareSurface(AtsSurface baseline, AtsSurface current, List diagnostics) + { + CompareRemoved( + baseline.PackageName, + baseline.HandleTypes.Keys, + current.HandleTypes.Keys, + "handle-removed", + "Handle type", + diagnostics); + + foreach (var (typeId, baselineHandle) in baseline.HandleTypes) + { + if (!current.HandleTypes.TryGetValue(typeId, out var currentHandle)) + { + continue; + } + + foreach (var flag in baselineHandle.Flags.Order(StringComparer.Ordinal)) + { + if (!currentHandle.Flags.Contains(flag)) + { + diagnostics.Add(new ApiCompatDiagnostic( + "handle-flag-removed", + baseline.PackageName, + $"{typeId}.{flag}", + $"Handle type '{typeId}' no longer has flag '{flag}'.")); + } + } + } + + CompareDtoTypes(baseline, current, diagnostics); + CompareEnumTypes(baseline, current, diagnostics); + CompareExportedValues(baseline, current, diagnostics); + CompareCapabilities(baseline, current, diagnostics); + } + + private static void CompareDtoTypes(AtsSurface baseline, AtsSurface current, List diagnostics) + { + CompareRemoved( + baseline.PackageName, + baseline.DtoTypes.Keys, + current.DtoTypes.Keys, + "dto-removed", + "DTO type", + diagnostics); + + foreach (var (typeId, baselineDto) in baseline.DtoTypes) + { + if (!current.DtoTypes.TryGetValue(typeId, out var currentDto)) + { + continue; + } + + foreach (var (propertyName, baselineProperty) in baselineDto.Properties) + { + var symbol = $"{typeId}.{propertyName}"; + if (!currentDto.Properties.TryGetValue(propertyName, out var currentProperty)) + { + diagnostics.Add(new ApiCompatDiagnostic( + "dto-property-removed", + baseline.PackageName, + symbol, + $"DTO property '{symbol}' was removed.")); + continue; + } + + if (!string.Equals(baselineProperty.TypeId, currentProperty.TypeId, StringComparison.Ordinal)) + { + diagnostics.Add(new ApiCompatDiagnostic( + "dto-property-type-changed", + baseline.PackageName, + symbol, + $"DTO property '{symbol}' type changed from '{baselineProperty.TypeId}' to '{currentProperty.TypeId}'.")); + } + + if (baselineProperty.IsOptional && !currentProperty.IsOptional) + { + diagnostics.Add(new ApiCompatDiagnostic( + "dto-property-required", + baseline.PackageName, + symbol, + $"DTO property '{symbol}' changed from optional to required.")); + } + } + + foreach (var (propertyName, currentProperty) in currentDto.Properties) + { + if (!currentProperty.IsOptional && !baselineDto.Properties.ContainsKey(propertyName)) + { + var symbol = $"{typeId}.{propertyName}"; + diagnostics.Add(new ApiCompatDiagnostic( + "dto-property-added-required", + baseline.PackageName, + symbol, + $"DTO property '{symbol}' was added as required.")); + } + } + } + } + + private static void CompareEnumTypes(AtsSurface baseline, AtsSurface current, List diagnostics) + { + CompareRemoved( + baseline.PackageName, + baseline.EnumTypes.Keys, + current.EnumTypes.Keys, + "enum-removed", + "Enum type", + diagnostics); + + foreach (var (typeId, baselineEnum) in baseline.EnumTypes) + { + if (!current.EnumTypes.TryGetValue(typeId, out var currentEnum)) + { + continue; + } + + var currentValues = currentEnum.Values.ToHashSet(StringComparer.Ordinal); + foreach (var value in baselineEnum.Values) + { + if (!currentValues.Contains(value)) + { + diagnostics.Add(new ApiCompatDiagnostic( + "enum-value-removed", + baseline.PackageName, + $"{typeId}.{value}", + $"Enum value '{typeId}.{value}' was removed.")); + } + } + } + } + + private static void CompareExportedValues(AtsSurface baseline, AtsSurface current, List diagnostics) + { + CompareRemoved( + baseline.PackageName, + baseline.ExportedValues.Keys, + current.ExportedValues.Keys, + "exported-value-removed", + "Exported value", + diagnostics); + + foreach (var (path, baselineValue) in baseline.ExportedValues) + { + if (!current.ExportedValues.TryGetValue(path, out var currentValue)) + { + continue; + } + + if (!string.Equals(baselineValue.TypeId, currentValue.TypeId, StringComparison.Ordinal)) + { + diagnostics.Add(new ApiCompatDiagnostic( + "exported-value-type-changed", + baseline.PackageName, + path, + $"Exported value '{path}' type changed from '{baselineValue.TypeId}' to '{currentValue.TypeId}'.")); + } + + if (!string.Equals(baselineValue.Value, currentValue.Value, StringComparison.Ordinal)) + { + diagnostics.Add(new ApiCompatDiagnostic( + "exported-value-changed", + baseline.PackageName, + path, + $"Exported value '{path}' changed from '{baselineValue.Value}' to '{currentValue.Value}'.")); + } + } + } + + private static void CompareCapabilities(AtsSurface baseline, AtsSurface current, List diagnostics) + { + CompareRemoved( + baseline.PackageName, + baseline.Capabilities.Keys, + current.Capabilities.Keys, + "capability-removed", + "Capability", + diagnostics); + + foreach (var (capabilityId, baselineCapability) in baseline.Capabilities) + { + if (!current.Capabilities.TryGetValue(capabilityId, out var currentCapability)) + { + continue; + } + + if (!string.Equals(baselineCapability.ReturnTypeId, currentCapability.ReturnTypeId, StringComparison.Ordinal)) + { + diagnostics.Add(new ApiCompatDiagnostic( + "capability-return-type-changed", + baseline.PackageName, + capabilityId, + $"Capability '{capabilityId}' return type changed from '{baselineCapability.ReturnTypeId}' to '{currentCapability.ReturnTypeId}'.")); + } + + CompareCapabilityParameters(baseline.PackageName, baselineCapability, currentCapability, diagnostics); + } + } + + private static void CompareCapabilityParameters( + string packageName, + AtsCapability baselineCapability, + AtsCapability currentCapability, + List diagnostics) + { + var currentByName = currentCapability.Parameters.ToDictionary(static p => p.Name, StringComparer.Ordinal); + var baselineByName = baselineCapability.Parameters.ToDictionary(static p => p.Name, StringComparer.Ordinal); + + foreach (var baselineParameter in baselineCapability.Parameters) + { + var symbol = $"{baselineCapability.CapabilityId}({baselineParameter.Name})"; + if (!currentByName.TryGetValue(baselineParameter.Name, out var currentParameter)) + { + diagnostics.Add(new ApiCompatDiagnostic( + "capability-parameter-removed", + packageName, + symbol, + $"Capability parameter '{symbol}' was removed.")); + continue; + } + + if (!string.Equals(baselineParameter.TypeId, currentParameter.TypeId, StringComparison.Ordinal)) + { + diagnostics.Add(new ApiCompatDiagnostic( + "capability-parameter-type-changed", + packageName, + symbol, + $"Capability parameter '{symbol}' type changed from '{baselineParameter.TypeId}' to '{currentParameter.TypeId}'.")); + } + + if (baselineParameter.IsOptional && !currentParameter.IsOptional) + { + diagnostics.Add(new ApiCompatDiagnostic( + "capability-parameter-required", + packageName, + symbol, + $"Capability parameter '{symbol}' changed from optional to required.")); + } + } + + foreach (var currentParameter in currentCapability.Parameters) + { + if (!currentParameter.IsOptional && !baselineByName.ContainsKey(currentParameter.Name)) + { + var symbol = $"{baselineCapability.CapabilityId}({currentParameter.Name})"; + diagnostics.Add(new ApiCompatDiagnostic( + "capability-parameter-added-required", + packageName, + symbol, + $"Capability parameter '{symbol}' was added as required.")); + } + } + + var baselineSharedOrder = baselineCapability.Parameters + .Select(static p => p.Name) + .Where(currentByName.ContainsKey) + .ToArray(); + var currentSharedOrder = currentCapability.Parameters + .Select(static p => p.Name) + .Where(baselineByName.ContainsKey) + .ToArray(); + + if (!baselineSharedOrder.SequenceEqual(currentSharedOrder, StringComparer.Ordinal)) + { + diagnostics.Add(new ApiCompatDiagnostic( + "capability-parameter-order-changed", + packageName, + baselineCapability.CapabilityId, + $"Capability '{baselineCapability.CapabilityId}' parameter order changed from '{string.Join(", ", baselineSharedOrder)}' to '{string.Join(", ", currentSharedOrder)}'.")); + } + } + + private static void CompareRemoved( + string packageName, + IEnumerable baselineSymbols, + IEnumerable currentSymbols, + string kind, + string displayName, + List diagnostics) + { + var currentSet = currentSymbols.ToHashSet(StringComparer.Ordinal); + foreach (var symbol in baselineSymbols.Order(StringComparer.Ordinal)) + { + if (!currentSet.Contains(symbol)) + { + diagnostics.Add(new ApiCompatDiagnostic( + kind, + packageName, + symbol, + $"{displayName} '{symbol}' was removed.")); + } + } + } +} diff --git a/tools/TypeScriptApiCompat/AtsSurface.cs b/tools/TypeScriptApiCompat/AtsSurface.cs new file mode 100644 index 00000000000..30efcefd436 --- /dev/null +++ b/tools/TypeScriptApiCompat/AtsSurface.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace TypeScriptApiCompat; + +internal sealed record AtsSurface( + string PackageName, + IReadOnlyDictionary HandleTypes, + IReadOnlyDictionary DtoTypes, + IReadOnlyDictionary EnumTypes, + IReadOnlyDictionary ExportedValues, + IReadOnlyDictionary Capabilities); + +internal sealed record AtsHandleType(string TypeId, IReadOnlySet Flags); + +internal sealed record AtsDtoType(string TypeId, IReadOnlyDictionary Properties); + +internal sealed record AtsDtoProperty(string Name, string TypeId, bool IsOptional); + +internal sealed record AtsEnumType(string TypeId, IReadOnlyList Values); + +internal sealed record AtsExportedValue(string Path, string TypeId, string Value); + +internal sealed record AtsCapability(string CapabilityId, IReadOnlyList Parameters, string ReturnTypeId); + +internal sealed record AtsParameter(string Name, string TypeId, bool IsOptional); + +internal sealed class AtsSurfaceSet +{ + private AtsSurfaceSet(IReadOnlyDictionary surfaces) + { + Surfaces = surfaces; + } + + public IReadOnlyDictionary Surfaces { get; } + + public static AtsSurfaceSet Load(string rootPath) + { + if (!Directory.Exists(rootPath)) + { + throw new DirectoryNotFoundException($"Surface directory '{rootPath}' does not exist."); + } + + var surfaces = new Dictionary(StringComparer.Ordinal); + + foreach (var file in Directory.EnumerateFiles(rootPath, "*.ats.txt", SearchOption.AllDirectories).Order(StringComparer.Ordinal)) + { + var packageName = GetPackageName(file); + if (surfaces.ContainsKey(packageName)) + { + throw new InvalidOperationException($"Duplicate ATS surface for package '{packageName}' under '{rootPath}'."); + } + + surfaces.Add(packageName, AtsSurfaceParser.Parse(packageName, File.ReadAllText(file))); + } + + return new AtsSurfaceSet(surfaces); + } + + private static string GetPackageName(string filePath) + { + var fileName = Path.GetFileName(filePath); + const string suffix = ".ats.txt"; + + if (!fileName.EndsWith(suffix, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"ATS surface file '{filePath}' does not end with '{suffix}'."); + } + + return fileName[..^suffix.Length]; + } +} diff --git a/tools/TypeScriptApiCompat/AtsSurfaceParser.cs b/tools/TypeScriptApiCompat/AtsSurfaceParser.cs new file mode 100644 index 00000000000..13e69571565 --- /dev/null +++ b/tools/TypeScriptApiCompat/AtsSurfaceParser.cs @@ -0,0 +1,224 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace TypeScriptApiCompat; + +internal static class AtsSurfaceParser +{ + public static AtsSurface Parse(string packageName, string content) + { + var handleTypes = new Dictionary(StringComparer.Ordinal); + var dtoTypes = new Dictionary(StringComparer.Ordinal); + var enumTypes = new Dictionary(StringComparer.Ordinal); + var exportedValues = new Dictionary(StringComparer.Ordinal); + var capabilities = new Dictionary(StringComparer.Ordinal); + + var section = AtsSection.None; + AtsDtoTypeBuilder? currentDto = null; + + foreach (var rawLine in content.ReplaceLineEndings("\n").Split('\n')) + { + if (string.IsNullOrWhiteSpace(rawLine)) + { + continue; + } + + var trimmed = rawLine.Trim(); + if (trimmed.StartsWith("#", StringComparison.Ordinal)) + { + section = trimmed switch + { + "# Handle Types" => AtsSection.HandleTypes, + "# DTO Types" => AtsSection.DtoTypes, + "# Enum Types" => AtsSection.EnumTypes, + "# Exported Values" => AtsSection.ExportedValues, + "# Capabilities" => AtsSection.Capabilities, + _ => AtsSection.None + }; + currentDto = null; + continue; + } + + switch (section) + { + case AtsSection.HandleTypes: + var handle = ParseHandleType(trimmed); + handleTypes.Add(handle.TypeId, handle); + break; + + case AtsSection.DtoTypes: + if (rawLine.StartsWith(" ", StringComparison.Ordinal)) + { + if (currentDto is null) + { + throw new InvalidDataException($"DTO property '{rawLine}' appeared before a DTO type."); + } + + var property = ParseDtoProperty(trimmed); + currentDto.Properties.Add(property.Name, property); + } + else + { + currentDto = new AtsDtoTypeBuilder(StripDescription(trimmed)); + dtoTypes.Add(currentDto.TypeId, currentDto); + } + break; + + case AtsSection.EnumTypes: + var enumType = ParseEnumType(trimmed); + enumTypes.Add(enumType.TypeId, enumType); + break; + + case AtsSection.ExportedValues: + var exportedValue = ParseExportedValue(trimmed); + exportedValues.Add(exportedValue.Path, exportedValue); + break; + + case AtsSection.Capabilities: + var capability = ParseCapability(trimmed); + capabilities.Add(capability.CapabilityId, capability); + break; + } + } + + return new AtsSurface( + packageName, + handleTypes, + dtoTypes.ToDictionary( + static pair => pair.Key, + static pair => new AtsDtoType(pair.Value.TypeId, pair.Value.Properties), + StringComparer.Ordinal), + enumTypes, + exportedValues, + capabilities); + } + + private static AtsHandleType ParseHandleType(string line) + { + var bracketIndex = line.IndexOf(" [", StringComparison.Ordinal); + if (bracketIndex < 0) + { + return new AtsHandleType(line, new HashSet(StringComparer.Ordinal)); + } + + var typeId = line[..bracketIndex]; + var flagsText = line[(bracketIndex + 2)..].TrimEnd(']'); + var flags = flagsText + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .ToHashSet(StringComparer.Ordinal); + + return new AtsHandleType(typeId, flags); + } + + private static AtsDtoProperty ParseDtoProperty(string line) + { + var separatorIndex = line.IndexOf(": ", StringComparison.Ordinal); + if (separatorIndex < 0) + { + throw new InvalidDataException($"Invalid DTO property line '{line}'."); + } + + var nameText = line[..separatorIndex]; + var isOptional = nameText.EndsWith('?'); + var name = isOptional ? nameText[..^1] : nameText; + var typeId = StripDescription(line[(separatorIndex + 2)..]); + + return new AtsDtoProperty(name, typeId, isOptional); + } + + private static AtsEnumType ParseEnumType(string line) + { + var separatorIndex = line.IndexOf(" = ", StringComparison.Ordinal); + if (separatorIndex < 0) + { + throw new InvalidDataException($"Invalid enum line '{line}'."); + } + + var typeId = line[..separatorIndex]; + var values = line[(separatorIndex + 3)..] + .Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + return new AtsEnumType(typeId, values); + } + + private static AtsExportedValue ParseExportedValue(string line) + { + var pathSeparatorIndex = line.IndexOf(": ", StringComparison.Ordinal); + if (pathSeparatorIndex < 0) + { + throw new InvalidDataException($"Invalid exported value line '{line}'."); + } + + var typeSeparatorIndex = line.IndexOf(" = ", pathSeparatorIndex + 2, StringComparison.Ordinal); + if (typeSeparatorIndex < 0) + { + throw new InvalidDataException($"Invalid exported value line '{line}'."); + } + + var path = line[..pathSeparatorIndex]; + var typeId = line[(pathSeparatorIndex + 2)..typeSeparatorIndex]; + var value = StripDescription(line[(typeSeparatorIndex + 3)..]); + + return new AtsExportedValue(path, typeId, value); + } + + private static AtsCapability ParseCapability(string line) + { + var openParenIndex = line.IndexOf('('); + var closeParenIndex = line.LastIndexOf(") -> ", StringComparison.Ordinal); + if (openParenIndex < 0 || closeParenIndex < openParenIndex) + { + throw new InvalidDataException($"Invalid capability line '{line}'."); + } + + var capabilityId = line[..openParenIndex]; + var parametersText = line[(openParenIndex + 1)..closeParenIndex]; + var returnTypeId = line[(closeParenIndex + 5)..]; + var parameters = string.IsNullOrWhiteSpace(parametersText) + ? [] + : parametersText + .Split(", ", StringSplitOptions.RemoveEmptyEntries) + .Select(ParseParameter) + .ToArray(); + + return new AtsCapability(capabilityId, parameters, returnTypeId); + } + + private static AtsParameter ParseParameter(string parameterText) + { + var separatorIndex = parameterText.IndexOf(": ", StringComparison.Ordinal); + if (separatorIndex < 0) + { + throw new InvalidDataException($"Invalid parameter '{parameterText}'."); + } + + var nameText = parameterText[..separatorIndex]; + var isOptional = nameText.EndsWith('?'); + var name = isOptional ? nameText[..^1] : nameText; + var typeId = parameterText[(separatorIndex + 2)..]; + + return new AtsParameter(name, typeId, isOptional); + } + + private static string StripDescription(string value) + { + var descriptionIndex = value.IndexOf(" # ", StringComparison.Ordinal); + return descriptionIndex < 0 ? value : value[..descriptionIndex]; + } + + private sealed class AtsDtoTypeBuilder(string typeId) + { + public string TypeId { get; } = typeId; + public Dictionary Properties { get; } = new(StringComparer.Ordinal); + } + + private enum AtsSection + { + None, + HandleTypes, + DtoTypes, + EnumTypes, + ExportedValues, + Capabilities + } +} diff --git a/tools/TypeScriptApiCompat/CommandLineOptions.cs b/tools/TypeScriptApiCompat/CommandLineOptions.cs new file mode 100644 index 00000000000..8007faf4a13 --- /dev/null +++ b/tools/TypeScriptApiCompat/CommandLineOptions.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace TypeScriptApiCompat; + +internal sealed record CommandLineOptions( + string BaselinePath, + string CurrentPath, + string SuppressionsRoot, + string? ReportPath, + bool GitHubAnnotations); diff --git a/tools/TypeScriptApiCompat/GitHubAnnotationWriter.cs b/tools/TypeScriptApiCompat/GitHubAnnotationWriter.cs new file mode 100644 index 00000000000..42c6b172834 --- /dev/null +++ b/tools/TypeScriptApiCompat/GitHubAnnotationWriter.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace TypeScriptApiCompat; + +internal static class GitHubAnnotationWriter +{ + public static void WriteErrors(ApiCompatResult result) + { + foreach (var diagnostic in result.UnsuppressedDiagnostics) + { + WriteError($"[{diagnostic.PackageName}] {diagnostic.Kind}: {diagnostic.Symbol}", diagnostic.Message); + } + + foreach (var error in result.SuppressionErrors) + { + WriteError("TypeScript API compatibility suppression error", error); + } + + foreach (var suppression in result.UnusedSuppressions) + { + WriteError( + "Unused TypeScript API compatibility suppression", + $"{suppression.FilePath}:{suppression.LineNumber}: {suppression.Kind} {suppression.PackageName} {suppression.Symbol}"); + } + } + + private static void WriteError(string title, string message) + { + Console.WriteLine($"::error title={Escape(title)}::{Escape(message)}"); + } + + private static string Escape(string value) => + value + .Replace("%", "%25", StringComparison.Ordinal) + .Replace("\r", "%0D", StringComparison.Ordinal) + .Replace("\n", "%0A", StringComparison.Ordinal) + .Replace(",", "%2C", StringComparison.Ordinal); +} diff --git a/tools/TypeScriptApiCompat/Program.cs b/tools/TypeScriptApiCompat/Program.cs new file mode 100644 index 00000000000..935c18639f0 --- /dev/null +++ b/tools/TypeScriptApiCompat/Program.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using TypeScriptApiCompat; + +var baselineOption = new Option("--baseline") +{ + Description = "Directory containing base-branch *.ats.txt files.", + Required = true +}; + +var currentOption = new Option("--current") +{ + Description = "Directory containing current *.ats.txt files.", + Required = true +}; + +var suppressionsRootOption = new Option("--suppressions-root") +{ + Description = "Repository root containing *.tscompat.suppression.txt files.", + DefaultValueFactory = _ => Directory.GetCurrentDirectory() +}; + +var reportOption = new Option("--report") +{ + Description = "Write a Markdown report." +}; + +var githubAnnotationsOption = new Option("--github-annotations") +{ + Description = "Emit GitHub Actions ::error annotations." +}; + +var rootCommand = new RootCommand("Compare Aspire TypeScript/ATS API baselines."); +rootCommand.Options.Add(baselineOption); +rootCommand.Options.Add(currentOption); +rootCommand.Options.Add(suppressionsRootOption); +rootCommand.Options.Add(reportOption); +rootCommand.Options.Add(githubAnnotationsOption); + +rootCommand.SetAction(parseResult => +{ + var options = new CommandLineOptions( + parseResult.GetValue(baselineOption)!, + parseResult.GetValue(currentOption)!, + parseResult.GetValue(suppressionsRootOption)!, + parseResult.GetValue(reportOption), + parseResult.GetValue(githubAnnotationsOption)); + + return TypeScriptApiCompatRunner.Run(options); +}); + +return rootCommand.Parse(args).Invoke(); diff --git a/tools/TypeScriptApiCompat/TypeScriptApiCompat.csproj b/tools/TypeScriptApiCompat/TypeScriptApiCompat.csproj new file mode 100644 index 00000000000..2bb117d795c --- /dev/null +++ b/tools/TypeScriptApiCompat/TypeScriptApiCompat.csproj @@ -0,0 +1,18 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + + + + + + + + + + + diff --git a/tools/TypeScriptApiCompat/TypeScriptApiCompatRunner.cs b/tools/TypeScriptApiCompat/TypeScriptApiCompatRunner.cs new file mode 100644 index 00000000000..4c4f0b6dfc0 --- /dev/null +++ b/tools/TypeScriptApiCompat/TypeScriptApiCompatRunner.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace TypeScriptApiCompat; + +internal static class TypeScriptApiCompatRunner +{ + public static int Run(CommandLineOptions options) + { + try + { + var baseline = AtsSurfaceSet.Load(options.BaselinePath); + var current = AtsSurfaceSet.Load(options.CurrentPath); + var diagnostics = AtsCompatibilityComparer.Compare(baseline, current); + var suppressionLoadResult = ApiCompatSuppressionLoader.Load(options.SuppressionsRoot); + var result = ApiCompatSuppressor.ApplySuppressions(diagnostics, suppressionLoadResult); + var report = ApiCompatReport.Create(result); + + if (!string.IsNullOrWhiteSpace(options.ReportPath)) + { + var reportDirectory = Path.GetDirectoryName(options.ReportPath); + if (!string.IsNullOrEmpty(reportDirectory)) + { + Directory.CreateDirectory(reportDirectory); + } + + File.WriteAllText(options.ReportPath, report); + } + + if (options.GitHubAnnotations && result.HasFailures) + { + GitHubAnnotationWriter.WriteErrors(result); + } + + Console.WriteLine(report); + + return result.HasFailures ? 1 : 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) + { + Console.Error.WriteLine(ex.Message); + return 2; + } + } +} From d855368ddada46a7950761ce6feefd5acc908a5f Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 14 May 2026 15:47:18 -0700 Subject: [PATCH 2/5] Detect inserted TypeScript capability parameters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/ci/typescript-api-compat.md | 4 ++-- .../TypeScriptApiCompatTests.cs | 3 +++ .../AtsCompatibilityComparer.cs | 16 +++++++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/ci/typescript-api-compat.md b/docs/ci/typescript-api-compat.md index fe7933c8c33..2a2513c5c91 100644 --- a/docs/ci/typescript-api-compat.md +++ b/docs/ci/typescript-api-compat.md @@ -21,9 +21,9 @@ The checker treats these ATS changes as breaking: - Removed DTO properties, optional-to-required DTO property changes, DTO property type changes, or newly added required DTO properties. - Removed enum values. - Exported value type or literal value changes. -- Removed capability parameters, optional-to-required parameter changes, required parameter additions, parameter type changes, parameter order changes, or return type changes. +- Removed capability parameters, optional-to-required parameter changes, required parameter additions, parameter type changes, parameter order changes, optional parameter insertions before existing parameters, or return type changes. -Additive changes such as new capabilities, new optional parameters, new optional DTO properties, new enum values, and new exported values do not require suppressions. +Additive changes such as new capabilities, optional capability parameters appended after all existing parameters, new optional DTO properties, new enum values, and new exported values do not require suppressions. ## Suppressions diff --git a/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs b/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs index bec2652bd2c..364de755fb8 100644 --- a/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs +++ b/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs @@ -81,6 +81,7 @@ public void ComparerClassifiesBreakingAndAdditiveChanges() # Capabilities Pkg/addThing(name: string, port?: number) -> Pkg/Thing + Pkg/addInsertedOptionalBeforeExisting(name: string, suffix?: string) -> void Pkg/removeMe() -> void """); @@ -104,6 +105,7 @@ public void ComparerClassifiesBreakingAndAdditiveChanges() # Capabilities Pkg/addThing(name: number, port: number, requiredName: string, optionalName?: string) -> void + Pkg/addInsertedOptionalBeforeExisting(name: string, inserted?: string, suffix?: string) -> void Pkg/newCapability() -> void """); @@ -120,6 +122,7 @@ public void ComparerClassifiesBreakingAndAdditiveChanges() Assert.Contains(diagnostics, d => d.Kind == "capability-parameter-type-changed" && d.Symbol == "Pkg/addThing(name)"); Assert.Contains(diagnostics, d => d.Kind == "capability-parameter-required" && d.Symbol == "Pkg/addThing(port)"); Assert.Contains(diagnostics, d => d.Kind == "capability-parameter-added-required" && d.Symbol == "Pkg/addThing(requiredName)"); + Assert.Contains(diagnostics, d => d.Kind == "capability-parameter-order-changed" && d.Symbol == "Pkg/addInsertedOptionalBeforeExisting"); Assert.DoesNotContain(diagnostics, d => d.Symbol is "Pkg/NewThing" or "Pkg/newCapability" or "Pkg/addThing(optionalName)" or "Pkg/Options.newOptional"); } diff --git a/tools/TypeScriptApiCompat/AtsCompatibilityComparer.cs b/tools/TypeScriptApiCompat/AtsCompatibilityComparer.cs index 3c1bd273d22..4b6d181095f 100644 --- a/tools/TypeScriptApiCompat/AtsCompatibilityComparer.cs +++ b/tools/TypeScriptApiCompat/AtsCompatibilityComparer.cs @@ -288,14 +288,24 @@ private static void CompareCapabilityParameters( .Select(static p => p.Name) .Where(baselineByName.ContainsKey) .ToArray(); - - if (!baselineSharedOrder.SequenceEqual(currentSharedOrder, StringComparer.Ordinal)) + var currentParameterOrder = currentCapability.Parameters + .Select(static p => p.Name) + .ToArray(); + var hasInsertedParameterBeforeExistingParameter = currentCapability.Parameters + .Select((parameter, index) => (parameter, index)) + .Any(parameterWithIndex => + !baselineByName.ContainsKey(parameterWithIndex.parameter.Name) && + currentCapability.Parameters + .Skip(parameterWithIndex.index + 1) + .Any(parameter => baselineByName.ContainsKey(parameter.Name))); + + if (!baselineSharedOrder.SequenceEqual(currentSharedOrder, StringComparer.Ordinal) || hasInsertedParameterBeforeExistingParameter) { diagnostics.Add(new ApiCompatDiagnostic( "capability-parameter-order-changed", packageName, baselineCapability.CapabilityId, - $"Capability '{baselineCapability.CapabilityId}' parameter order changed from '{string.Join(", ", baselineSharedOrder)}' to '{string.Join(", ", currentSharedOrder)}'.")); + $"Capability '{baselineCapability.CapabilityId}' parameter order changed from '{string.Join(", ", baselineSharedOrder)}' to '{string.Join(", ", currentParameterOrder)}'.")); } } From 56917ee2d21d8d9dc9a35dfa673f122a722950e6 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 14 May 2026 16:43:23 -0700 Subject: [PATCH 3/5] Fix TypeScript API compatibility workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/typescript-api-compat.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/typescript-api-compat.yml b/.github/workflows/typescript-api-compat.yml index e38c7fbb624..54be9b014f0 100644 --- a/.github/workflows/typescript-api-compat.yml +++ b/.github/workflows/typescript-api-compat.yml @@ -22,7 +22,7 @@ jobs: - name: Build CLI and compatibility tool run: | - ./dotnet.sh build src/Aspire.Cli/Aspire.Cli.csproj --configuration Release /p:SkipNativeBuild=true + ./dotnet.sh build src/Aspire.Cli/Aspire.Cli.csproj --configuration Release ./dotnet.sh build tools/TypeScriptApiCompat/TypeScriptApiCompat.csproj --configuration Release - name: Extract base ATS baseline @@ -48,6 +48,8 @@ jobs: - name: Generate current ATS surface shell: bash + env: + ASPIRE_REPO_ROOT: ${{ github.workspace }} run: | set -euo pipefail From ab538b936c3e07c0b73daac0445f0567744fb6ea Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Thu, 14 May 2026 17:05:37 -0700 Subject: [PATCH 4/5] Compare TypeScript API against generated base Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/typescript-api-compat.yml | 57 ++++++++++++++++++--- docs/ci/typescript-api-compat.md | 6 +-- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/.github/workflows/typescript-api-compat.yml b/.github/workflows/typescript-api-compat.yml index 54be9b014f0..e1f0a793100 100644 --- a/.github/workflows/typescript-api-compat.yml +++ b/.github/workflows/typescript-api-compat.yml @@ -25,24 +25,67 @@ jobs: ./dotnet.sh build src/Aspire.Cli/Aspire.Cli.csproj --configuration Release ./dotnet.sh build tools/TypeScriptApiCompat/TypeScriptApiCompat.csproj --configuration Release - - name: Extract base ATS baseline + - name: Generate base ATS surface shell: bash env: BASE_BRANCH: ${{ github.base_ref }} run: | set -euo pipefail - BASE_REF="origin/${BASE_BRANCH}" + BASE_WORKTREE="${RUNNER_TEMP}/base-worktree" BASELINE_DIR="${RUNNER_TEMP}/ats-baseline" - mkdir -p "$BASELINE_DIR" git fetch --no-tags --depth=1 origin "+refs/heads/${BASE_BRANCH}:refs/remotes/origin/${BASE_BRANCH}" - git ls-tree -r --name-only "$BASE_REF" -- src | grep '\.ats\.txt$' > "${RUNNER_TEMP}/ats-baseline-files.txt" + git worktree add --detach "$BASE_WORKTREE" "origin/${BASE_BRANCH}" + + pushd "$BASE_WORKTREE" + ./restore.sh + ./dotnet.sh build src/Aspire.Cli/Aspire.Cli.csproj --configuration Release + + export ASPIRE_REPO_ROOT="$BASE_WORKTREE" + ASPIRE_CLI="./dotnet.sh run --no-build --project src/Aspire.Cli/Aspire.Cli.csproj --configuration Release -- --nologo" + PROJECTS_FILE="${RUNNER_TEMP}/ats-base-projects.txt" + SORTED_PROJECTS_FILE="${RUNNER_TEMP}/ats-base-projects-sorted.txt" + + mkdir -p "$BASELINE_DIR/src/Aspire.Hosting/api" + : > "$PROJECTS_FILE" + + echo "::group::Base Aspire.Hosting (core)" + $ASPIRE_CLI sdk dump --format ci -o "$BASELINE_DIR/src/Aspire.Hosting/api/Aspire.Hosting.ats.txt" + echo "::endgroup::" while IFS= read -r file; do - mkdir -p "${BASELINE_DIR}/$(dirname "$file")" - git show "${BASE_REF}:${file}" > "${BASELINE_DIR}/${file}" - done < "${RUNNER_TEMP}/ats-baseline-files.txt" + case "$file" in + */obj/*|*/bin/*|*Aspire.Hosting.Analyzers*|*Aspire.Hosting.CodeGeneration*|*Aspire.Hosting.RemoteHost*) + continue + ;; + esac + + dir=$(dirname "$file") + while [ "$dir" != "." ] && [ "$dir" != "/" ]; do + if compgen -G "$dir/*.csproj" > /dev/null; then + echo "$dir" >> "$PROJECTS_FILE" + break + fi + + dir=$(dirname "$dir") + done + done < <(grep -rl '\[AspireExport(' src/Aspire.Hosting.*/ --include='*.cs' || true) + + sort -u "$PROJECTS_FILE" > "$SORTED_PROJECTS_FILE" + + while IFS= read -r proj; do + [ -n "$proj" ] || continue + proj_name=$(basename "$proj") + csproj="$proj/$proj_name.csproj" + if [ -f "$csproj" ]; then + echo "::group::Base $proj_name" + mkdir -p "$BASELINE_DIR/$proj/api" + $ASPIRE_CLI sdk dump --format ci "$csproj" -o "$BASELINE_DIR/$proj/api/$proj_name.ats.txt" + echo "::endgroup::" + fi + done < "$SORTED_PROJECTS_FILE" + popd echo "BASELINE_DIR=$BASELINE_DIR" >> "$GITHUB_ENV" diff --git a/docs/ci/typescript-api-compat.md b/docs/ci/typescript-api-compat.md index 2a2513c5c91..104fba48df7 100644 --- a/docs/ci/typescript-api-compat.md +++ b/docs/ci/typescript-api-compat.md @@ -4,11 +4,11 @@ The TypeScript API compatibility check prevents pull requests from introducing u ## Baseline -The checked-in `src/Aspire.Hosting*/api/*.ats.txt` files are the compatibility baseline. In pull request CI, `.github/workflows/typescript-api-compat.yml` reads those files from the pull request target branch and compares them with fresh `aspire sdk dump --format ci` output generated from the pull request. +The checked-in `src/Aspire.Hosting*/api/*.ats.txt` files are the release compatibility baseline. The scheduled `.github/workflows/generate-ats-diffs.yml` workflow remains the review and release mechanism for updating the checked-in ATS files after API changes are accepted. -This intentionally differs from a plain git diff. A pull request cannot hide a breaking change by editing the baseline file in the same PR; the baseline is always loaded from the merge target branch. +In pull request CI, `.github/workflows/typescript-api-compat.yml` generates ATS output from the pull request target branch and compares it with fresh `aspire sdk dump --format ci` output generated from the pull request. This mergeable baseline avoids failing unrelated pull requests when the target branch contains accepted source changes that have not yet been rolled into the release baseline. -The scheduled `.github/workflows/generate-ats-diffs.yml` workflow remains the review and release mechanism for updating the checked-in ATS files after API changes are accepted. +This intentionally differs from a plain git diff. A pull request cannot hide a breaking change by editing the checked-in ATS files in the same PR; the pull request check compares generated target-branch output with generated pull request output. After a new version ships, reset the compatibility baseline by updating the checked-in ATS files to the shipped surface. Suppressions for breaks that are now part of that new baseline should be deleted in the same change; keep only suppressions that still describe intentional breaks relative to the target branch baseline. The compatibility checker fails on unused suppressions, which helps catch declarations that should have been removed during the reset. From faa1fa9e69a99678b5c5d749a5659198f3fb4a5b Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Fri, 15 May 2026 11:51:41 -0700 Subject: [PATCH 5/5] Ignore inherited unused TypeScript API suppressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/typescript-api-compat.yml | 2 ++ docs/ci/typescript-api-compat.md | 2 +- .../TypeScriptApiCompatTests.cs | 29 ++++++++++++++++++- tools/TypeScriptApiCompat/ApiCompatResult.cs | 7 ++++- .../TypeScriptApiCompat/CommandLineOptions.cs | 1 + tools/TypeScriptApiCompat/Program.cs | 7 +++++ .../TypeScriptApiCompatRunner.cs | 5 +++- 7 files changed, 49 insertions(+), 4 deletions(-) diff --git a/.github/workflows/typescript-api-compat.yml b/.github/workflows/typescript-api-compat.yml index e1f0a793100..3e106316a27 100644 --- a/.github/workflows/typescript-api-compat.yml +++ b/.github/workflows/typescript-api-compat.yml @@ -88,6 +88,7 @@ jobs: popd echo "BASELINE_DIR=$BASELINE_DIR" >> "$GITHUB_ENV" + echo "BASE_SUPPRESSIONS_ROOT=$BASE_WORKTREE" >> "$GITHUB_ENV" - name: Generate current ATS surface shell: bash @@ -155,6 +156,7 @@ jobs: --baseline "$BASELINE_DIR" \ --current "$CURRENT_DIR" \ --suppressions-root "${{ github.workspace }}" \ + --baseline-suppressions-root "$BASE_SUPPRESSIONS_ROOT" \ --report "${RUNNER_TEMP}/typescript-api-compat-report.md" \ --github-annotations diff --git a/docs/ci/typescript-api-compat.md b/docs/ci/typescript-api-compat.md index 104fba48df7..22a19e22a91 100644 --- a/docs/ci/typescript-api-compat.md +++ b/docs/ci/typescript-api-compat.md @@ -10,7 +10,7 @@ In pull request CI, `.github/workflows/typescript-api-compat.yml` generates ATS This intentionally differs from a plain git diff. A pull request cannot hide a breaking change by editing the checked-in ATS files in the same PR; the pull request check compares generated target-branch output with generated pull request output. -After a new version ships, reset the compatibility baseline by updating the checked-in ATS files to the shipped surface. Suppressions for breaks that are now part of that new baseline should be deleted in the same change; keep only suppressions that still describe intentional breaks relative to the target branch baseline. The compatibility checker fails on unused suppressions, which helps catch declarations that should have been removed during the reset. +After a new version ships, reset the compatibility baseline by updating the checked-in ATS files to the shipped surface. Suppressions for breaks that are now part of that new baseline should be deleted in the same change; keep only suppressions that still describe intentional breaks relative to the release baseline. The compatibility checker fails on unused suppressions added by a pull request, but suppressions already present in the target branch are allowed to become unused against the generated target-branch baseline so merged intentional breaks do not block unrelated pull requests before the next release reset. ## Breaking changes diff --git a/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs b/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs index 364de755fb8..fa4fa949cf4 100644 --- a/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs +++ b/tests/Infrastructure.Tests/TypeScriptApiCompat/TypeScriptApiCompatTests.cs @@ -156,6 +156,32 @@ public void SuppressionsUseExactMatchesAndFailWhenUnused() Assert.Equal("dto-removed|Pkg|Pkg/Stale", unused.SuppressionKey); } + [Fact] + public void SuppressionsIgnoreUnusedEntriesInheritedFromBaseline() + { + using var tempDirectory = new TestTempDirectory(); + var currentSuppressionPath = Path.Combine(tempDirectory.Path, "current", "Pkg.tscompat.suppression.txt"); + var baselineSuppressionPath = Path.Combine(tempDirectory.Path, "baseline", "Pkg.tscompat.suppression.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(currentSuppressionPath)!); + Directory.CreateDirectory(Path.GetDirectoryName(baselineSuppressionPath)!); + + File.WriteAllText(currentSuppressionPath, """ + BREAK capability-removed Pkg Pkg/inheritedBreak -- https://github.com/microsoft/aspire/issues/16961 -- Inherited accepted break + BREAK dto-removed Pkg Pkg/NewStale -- https://github.com/microsoft/aspire/issues/16961 -- Newly stale suppression + """); + File.WriteAllText(baselineSuppressionPath, """ + BREAK capability-removed Pkg Pkg/inheritedBreak -- https://github.com/microsoft/aspire/issues/16961 -- Inherited accepted break + """); + + var currentSuppressions = ApiCompatSuppressionLoader.Load(Path.Combine(tempDirectory.Path, "current")); + var baselineSuppressions = ApiCompatSuppressionLoader.Load(Path.Combine(tempDirectory.Path, "baseline")); + + var result = ApiCompatSuppressor.ApplySuppressions([], currentSuppressions, baselineSuppressions); + + var unused = Assert.Single(result.UnusedSuppressions); + Assert.Equal("dto-removed|Pkg|Pkg/NewStale", unused.SuppressionKey); + } + [Fact] public void RunnerWritesReportAndReturnsFailureForUnsuppressedBreaks() { @@ -181,7 +207,8 @@ public void RunnerWritesReportAndReturnsFailureForUnsuppressedBreaks() baselineRoot, currentRoot, tempDirectory.Path, - reportPath, + BaselineSuppressionsRoot: null, + ReportPath: reportPath, GitHubAnnotations: false)); Assert.Equal(1, exitCode); diff --git a/tools/TypeScriptApiCompat/ApiCompatResult.cs b/tools/TypeScriptApiCompat/ApiCompatResult.cs index 61e28b6146b..43881f5eb91 100644 --- a/tools/TypeScriptApiCompat/ApiCompatResult.cs +++ b/tools/TypeScriptApiCompat/ApiCompatResult.cs @@ -19,11 +19,15 @@ internal static class ApiCompatSuppressor { public static ApiCompatResult ApplySuppressions( IReadOnlyList diagnostics, - SuppressionLoadResult suppressionLoadResult) + SuppressionLoadResult suppressionLoadResult, + SuppressionLoadResult? baselineSuppressionLoadResult = null) { var suppressionsByKey = suppressionLoadResult.Suppressions .GroupBy(static suppression => suppression.SuppressionKey, StringComparer.Ordinal) .ToDictionary(static group => group.Key, static group => group.ToArray(), StringComparer.Ordinal); + var baselineSuppressionKeys = baselineSuppressionLoadResult?.Suppressions + .Select(static suppression => suppression.SuppressionKey) + .ToHashSet(StringComparer.Ordinal); var usedSuppressionKeys = new HashSet(StringComparer.Ordinal); var unsuppressedDiagnostics = new List(); var suppressedDiagnostics = new List(); @@ -43,6 +47,7 @@ public static ApiCompatResult ApplySuppressions( var unusedSuppressions = suppressionLoadResult.Suppressions .Where(suppression => !usedSuppressionKeys.Contains(suppression.SuppressionKey)) + .Where(suppression => baselineSuppressionKeys is null || !baselineSuppressionKeys.Contains(suppression.SuppressionKey)) .OrderBy(static suppression => suppression.FilePath, StringComparer.Ordinal) .ThenBy(static suppression => suppression.LineNumber) .ToArray(); diff --git a/tools/TypeScriptApiCompat/CommandLineOptions.cs b/tools/TypeScriptApiCompat/CommandLineOptions.cs index 8007faf4a13..69c48420377 100644 --- a/tools/TypeScriptApiCompat/CommandLineOptions.cs +++ b/tools/TypeScriptApiCompat/CommandLineOptions.cs @@ -7,5 +7,6 @@ internal sealed record CommandLineOptions( string BaselinePath, string CurrentPath, string SuppressionsRoot, + string? BaselineSuppressionsRoot, string? ReportPath, bool GitHubAnnotations); diff --git a/tools/TypeScriptApiCompat/Program.cs b/tools/TypeScriptApiCompat/Program.cs index 935c18639f0..5f1f1ed5234 100644 --- a/tools/TypeScriptApiCompat/Program.cs +++ b/tools/TypeScriptApiCompat/Program.cs @@ -22,6 +22,11 @@ DefaultValueFactory = _ => Directory.GetCurrentDirectory() }; +var baselineSuppressionsRootOption = new Option("--baseline-suppressions-root") +{ + Description = "Target-branch repository root containing inherited suppression files." +}; + var reportOption = new Option("--report") { Description = "Write a Markdown report." @@ -36,6 +41,7 @@ rootCommand.Options.Add(baselineOption); rootCommand.Options.Add(currentOption); rootCommand.Options.Add(suppressionsRootOption); +rootCommand.Options.Add(baselineSuppressionsRootOption); rootCommand.Options.Add(reportOption); rootCommand.Options.Add(githubAnnotationsOption); @@ -45,6 +51,7 @@ parseResult.GetValue(baselineOption)!, parseResult.GetValue(currentOption)!, parseResult.GetValue(suppressionsRootOption)!, + parseResult.GetValue(baselineSuppressionsRootOption), parseResult.GetValue(reportOption), parseResult.GetValue(githubAnnotationsOption)); diff --git a/tools/TypeScriptApiCompat/TypeScriptApiCompatRunner.cs b/tools/TypeScriptApiCompat/TypeScriptApiCompatRunner.cs index 4c4f0b6dfc0..61ab403621b 100644 --- a/tools/TypeScriptApiCompat/TypeScriptApiCompatRunner.cs +++ b/tools/TypeScriptApiCompat/TypeScriptApiCompatRunner.cs @@ -13,7 +13,10 @@ public static int Run(CommandLineOptions options) var current = AtsSurfaceSet.Load(options.CurrentPath); var diagnostics = AtsCompatibilityComparer.Compare(baseline, current); var suppressionLoadResult = ApiCompatSuppressionLoader.Load(options.SuppressionsRoot); - var result = ApiCompatSuppressor.ApplySuppressions(diagnostics, suppressionLoadResult); + var baselineSuppressionLoadResult = options.BaselineSuppressionsRoot is null + ? null + : ApiCompatSuppressionLoader.Load(options.BaselineSuppressionsRoot); + var result = ApiCompatSuppressor.ApplySuppressions(diagnostics, suppressionLoadResult, baselineSuppressionLoadResult); var report = ApiCompatReport.Create(result); if (!string.IsNullOrWhiteSpace(options.ReportPath))