Skip to content

Commit b94546c

Browse files
Make C# back-compat aware of ApiCompat baseline files (#11048)
## Problem The backward-compatibility system uses `LastContractView` to keep generated APIs source-compatible with the previous version: it resurrects removed public members and preserves a property's previous type. But when a breaking change has **already been accepted** in an [ApiCompat](https://github.com/dotnet/sdk/tree/main/src/Compatibility) baseline (suppression) file — conventionally `eng/apicompatbaselines/<AssemblyName>.txt` in the Azure SDK — that back-compat behavior fights the accepted decision: it re-introduces intentionally-removed API and can reference types that no longer exist. Example baseline entries: ``` TypesMustExist : Type 'Azure.AI.Projects.Agents.ProjectsAgentProtocol' does not exist in the implementation but it does exist in the contract. MembersMustExist : Member 'public Azure.AI.Projects.Agents.ProtocolVersionRecord Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ProtocolVersionRecord(Azure.AI.Projects.Agents.ProjectsAgentProtocol, System.String)' does not exist in the implementation but it does exist in the contract. ``` ## Fix - Add `ApiCompatBaseline` (`Microsoft.TypeSpec.Generator.SourceInput`): a tolerant parser for ApiCompat baseline files that understands `TypesMustExist` and `MembersMustExist` suppressions. Member matching uses declaring-type full name + member name + parameter count (arity), avoiding brittle parameter-type string comparisons. Type suppression implies all of its members are suppressed. - Discover the baseline file by walking up from the project directory to `eng/apicompatbaselines/<PrimaryNamespace>.txt` (`GeneratedCodeWorkspace.LoadApiCompatBaseline`), expose the parsed baseline on `SourceInputModel`, and wire loading into `CSharpGen`. - **Skip resurrected members** in the model factory back-compat path (`ModelFactoryProvider.BuildMethodsForBackCompatibility`): a removed factory method whose removal is accepted in the baseline is not regenerated. - **Allow property type changes** in `ModelProvider.BuildProperties`: the generator normally reverts a property to its previous (last-contract) type to avoid a breaking change. When that previous type — or a type nested within it (e.g. a collection element type) — is an accepted baseline removal, preserving it would reference a now-deleted type, so the current (spec) type is kept instead. Added `ApiCompatBaseline.ReferencesSuppressedType` (recurses into generic arguments) and a `BaselineAcceptedRemovalSkipped` change category. The remaining back-compat consumers (`ModelProvider` constructors, the enum providers, `ClientProvider`, and `RestClientProvider`) can be made baseline-aware the same way as a follow-up; this is documented in `docs/backward-compatibility.md`. ## Tests - `ApiCompatBaselineTests` — parser coverage (types, methods, constructors, property accessors, generic-arg arity, unknown/malformed lines) and `ReferencesSuppressedType` (direct + nested generic argument). - `ModelFactoryProviderTests.BackCompatibility_SuppressedByApiCompatBaselineNotRegenerated` — a baselined factory method is **not** regenerated. - `ModelProviderTests.BackCompat_PropertyTypeChangeAllowedWhenPreviousTypeSuppressed` — a property whose previous type is a baseline-accepted removal takes its current spec type. - Full `Microsoft.TypeSpec.Generator.Tests` suite passes (1552 tests). --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f8f1a51 commit b94546c

19 files changed

Lines changed: 633 additions & 9 deletions

File tree

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ public async Task ExecuteAsync()
5959

6060
CodeModelGenerator.Instance.SourceInputModel = new SourceInputModel(
6161
await customCodeWorkspace.GetCompilationAsync(),
62-
await GeneratedCodeWorkspace.LoadBaselineContract());
62+
await GeneratedCodeWorkspace.LoadBaselineContract(),
63+
GeneratedCodeWorkspace.LoadApiCompatBaseline());
6364

6465
GeneratedCodeWorkspace generatedCodeWorkspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: false);
6566

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/EmitterRpc/BackCompatibilityChangeCategory.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,8 @@ public enum BackCompatibilityChangeCategory
4242

4343
/// <summary>A back-compat overload of a client method was added because new optional non-body parameter(s) were introduced relative to the last contract.</summary>
4444
SvcMethodNewOptionalParameterOverloadAdded,
45+
46+
/// <summary>A back-compat change was skipped because the removal was accepted in the ApiCompat baseline.</summary>
47+
BaselineAcceptedRemovalSkipped,
4548
}
4649
}

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/EmitterRpc/Emitter.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ public void WriteBufferedMessages()
181181
BackCompatibilityChangeCategory.ModelFactoryMethodAdded => "Model Factory Method Added For Back-Compat",
182182
BackCompatibilityChangeCategory.ModelFactoryMethodSkipped => "Model Factory Method Back-Compat Skipped",
183183
BackCompatibilityChangeCategory.SvcMethodNewOptionalParameterOverloadAdded => "Method Back-Compat Overload Added For New Optional Parameter",
184+
BackCompatibilityChangeCategory.BaselineAcceptedRemovalSkipped => "Back-Compat Skipped For ApiCompat Baseline Accepted Removal",
184185
_ => category.ToString(),
185186
};
186187

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
using Microsoft.CodeAnalysis.Simplification;
1717
using Microsoft.TypeSpec.Generator.Primitives;
1818
using Microsoft.TypeSpec.Generator.Providers;
19+
using Microsoft.TypeSpec.Generator.SourceInput;
1920
using Microsoft.TypeSpec.Generator.Utilities;
2021
using NuGet.Configuration;
2122

@@ -364,6 +365,32 @@ internal static async Task AddPackageReferencesFromProject()
364365
}
365366
}
366367

368+
/// <summary>
369+
/// Locates and parses the ApiCompat baseline (suppression) file for the current library, if
370+
/// present. The file is expected at <c>eng/apicompatbaselines/&lt;AssemblyName&gt;.txt</c>
371+
/// relative to a repository root discovered by walking up from the project directory.
372+
/// Returns <see cref="ApiCompatBaseline.Empty"/> when no baseline file is found.
373+
/// </summary>
374+
internal static ApiCompatBaseline LoadApiCompatBaseline()
375+
{
376+
var packageName = CodeModelGenerator.Instance.TypeFactory.PrimaryNamespace;
377+
var directory = new DirectoryInfo(CodeModelGenerator.Instance.Configuration.ProjectDirectory);
378+
379+
while (directory != null)
380+
{
381+
var candidate = Path.Combine(directory.FullName, "eng", "apicompatbaselines", $"{packageName}.txt");
382+
if (File.Exists(candidate))
383+
{
384+
CodeModelGenerator.Instance.Emitter.Debug($"Loading ApiCompat baseline from {candidate}");
385+
return ApiCompatBaseline.FromFile(candidate);
386+
}
387+
388+
directory = directory.Parent;
389+
}
390+
391+
return ApiCompatBaseline.Empty;
392+
}
393+
367394
internal static async Task<Compilation?> LoadBaselineContract()
368395
{
369396
var packageName = CodeModelGenerator.Instance.TypeFactory.PrimaryNamespace;

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelFactoryProvider.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,19 @@ protected internal sealed override IReadOnlyList<MethodProvider> BuildMethodsFor
116116
continue;
117117
}
118118

119+
// If the removal of this factory method has already been accepted in the ApiCompat
120+
// baseline, honor that decision and do not resurrect a compatibility shim for it.
121+
if (CodeModelGenerator.Instance.SourceInputModel?.ApiCompatBaseline.IsMemberSuppressed(
122+
Type.FullyQualifiedName,
123+
previousMethod.Signature.Name,
124+
previousMethod.Signature.Parameters.Count) == true)
125+
{
126+
CodeModelGenerator.Instance.Emitter.Info(
127+
$"Skipping back-compat shim for '{Type.FullyQualifiedName}.{previousMethod.Signature.Name}'; removal is accepted in the ApiCompat baseline.",
128+
BackCompatibilityChangeCategory.BaselineAcceptedRemovalSkipped);
129+
continue;
130+
}
131+
119132
List<MethodSignature> currentOverloads = [];
120133
bool foundCompatibleOverload = false;
121134

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -620,10 +620,22 @@ protected internal override PropertyProvider[] BuildProperties()
620620
LastContractPropertiesMap.TryGetValue(outputProperty.Name, out var lastContractPropertyType) &&
621621
!lastContractPropertyType.Equals(outputProperty.Type))
622622
{
623-
outputProperty.Type = lastContractPropertyType.ApplyInputSpecProperty(property);
624-
CodeModelGenerator.Instance.Emitter.Info(
625-
$"Changed property '{Name}.{outputProperty.Name}' type to '{lastContractPropertyType}' to match last contract.",
626-
BackCompatibilityChangeCategory.PropertyTypePreserved);
623+
// If the previous property type (or a type nested in it) has been intentionally
624+
// removed and that removal is accepted in the ApiCompat baseline, preserving it
625+
// would reference a now-deleted type. Honor the baseline and allow the new type.
626+
if (CodeModelGenerator.Instance.SourceInputModel?.ApiCompatBaseline.ReferencesSuppressedType(lastContractPropertyType) == true)
627+
{
628+
CodeModelGenerator.Instance.Emitter.Info(
629+
$"Allowing property '{Name}.{outputProperty.Name}' type change to '{outputProperty.Type}'; previous type '{lastContractPropertyType}' is an accepted removal in the ApiCompat baseline.",
630+
BackCompatibilityChangeCategory.BaselineAcceptedRemovalSkipped);
631+
}
632+
else
633+
{
634+
outputProperty.Type = lastContractPropertyType.ApplyInputSpecProperty(property);
635+
CodeModelGenerator.Instance.Emitter.Info(
636+
$"Changed property '{Name}.{outputProperty.Name}' type to '{lastContractPropertyType}' to match last contract.",
637+
BackCompatibilityChangeCategory.PropertyTypePreserved);
638+
}
627639
}
628640

629641
if (!isDiscriminator)

0 commit comments

Comments
 (0)