Skip to content

fix(VersionCompat): Fix public override method removal being treated as a breaking changed - #15826

Open
efevans wants to merge 14 commits into
googleapis:mainfrom
efevans:breaking-changes-checker-override
Open

efevans wants to merge 14 commits into
googleapis:mainfrom
efevans:breaking-changes-checker-override

Conversation

@efevans

@efevans efevans commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

b/520002639

do not merge until squashing

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces compatibility checks to handle cases where a method or property is removed from a class but an implementation still exists in an ancestor class. The review feedback identifies two important issues in the new logic: an infinite loop in GetPropertyImplementationFromAncestor caused by incorrectly reassigning baseType using descendentType, and a potential NullReferenceException if both the getter and setter of a property are null. Both comments are valid and should be addressed.

Comment thread tools/Google.Cloud.Tools.VersionCompat/Detectors/ClassStructInterface.cs Outdated
Comment thread tools/Google.Cloud.Tools.VersionCompat/Detectors/ClassStructInterface.cs Outdated
@efevans
efevans force-pushed the breaking-changes-checker-override branch 2 times, most recently from 9f2b299 to 8af18f6 Compare August 12, 2026 21:08
@@ -1,4 +1,4 @@
// Copyright 2019 Google LLC
// Copyright 2019 Google LLC

@efevans efevans Aug 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I can tell, VS is stripping the BOM. I'll revert this if requested, however from what I read online BOM marker is not-preferred by unix systems. Left the change in until it's requested removed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fine by me, seems to be what the /.editorconfig is setup to do so we should update there if we want to keep it as is.

@efevans
efevans marked this pull request as ready for review August 12, 2026 21:20
@efevans
efevans requested a review from a team as a code owner August 12, 2026 21:20
@efevans
efevans marked this pull request as draft August 13, 2026 19:55
@efevans
efevans force-pushed the breaking-changes-checker-override branch 7 times, most recently from eed4a0d to 83527b5 Compare August 14, 2026 00:22
@@ -1,4 +1,4 @@
using System;
using System;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BOM removal

@@ -1,4 +1,4 @@
// Copyright 2019 Google LLC
// Copyright 2019 Google LLC

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BOM removal

@efevans
efevans marked this pull request as ready for review August 14, 2026 00:22
@efevans

efevans commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the compatibility checker to avoid flagging removed methods or properties as breaking changes if a matching implementation is inherited from an ancestor class. It adds corresponding unit tests to verify these scenarios. The review feedback highlights critical operator precedence bugs in the AreLikeMethods helper functions that cause incorrect evaluations for non-virtual members, as well as a case of reversed arguments when calling this helper during property checks.

Comment thread tools/Google.Cloud.Tools.VersionCompat/Detectors/ClassStructInterface.cs Outdated
Comment thread tools/Google.Cloud.Tools.VersionCompat/Detectors/ClassStructInterface.cs Outdated
Comment thread tools/Google.Cloud.Tools.VersionCompat/Detectors/ClassStructInterface.cs Outdated
@efevans
efevans force-pushed the breaking-changes-checker-override branch from 83527b5 to 0c611cc Compare August 14, 2026 00:30

@robertvoinescu-work robertvoinescu-work left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it looks good, but am suggesting a slightly different implementation. Let me know what you think.

@@ -1,4 +1,4 @@
// Copyright 2019 Google LLC
// Copyright 2019 Google LLC

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fine by me, seems to be what the /.editorconfig is setup to do so we should update there if we want to keep it as is.

Diff.Major(C(Cause.MethodRemoved, Cause.CtorRemoved), $"{prefix} removed.");
// A removed constructor is considered a breaking change, but for methods
// we first check if there's a like method inherited from up the tree.
if (isCtor || !LikeMethodInheritedFromAncestor(descendentType: _n, referenceMethod: o))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be a little more cohesive to me if we do this a little earlier and have all inherited methods be included ininO (and also inN). If we do it that way we can also avoid searching up the parent tree multiple times. Same comment for the property one. I think what this means is just translate your code that searches through parent classes to return all methods and use that in the passed methodSelector.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the method discovery down below on line 266 so that when MethodsCtors() is called, the Func that will return all the methods will include those of the ancestors. This will then get used to defend against the false negatives.

Note that there is a change within MethodCtors() on line line 244 and 250 where we only include a message about a new or missing exported method if that method was defined at this level in the tree. This way we avoid duplicates messages about a removed exported method in an ancestor, otherwise it would show up once on the diff between the old and new ancestor class, as well as the diff between the old and new descendent class.

Properties has a similar change, except it just calls the ExportedMethod/ExportedProperties method without passing in a Func object (the method case needed this as it shared a definition with Ctors.

@efevans
efevans force-pushed the breaking-changes-checker-override branch 3 times, most recently from bc76109 to cd2908a Compare August 19, 2026 23:46

@efevans efevans left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ended up needing more changes that I expected, there's relatively similarly changes to both the methods and properties sides. Properties is a bit more awkward because they are comprised of accessors so in the ExportedProperties method, as we collect exported properties, we keep track of the one with the most accessors. I explain a bit more of the why in the XML doc for that method, but basically property overrides can't implement an accessor that wasn't in the base definition so as long as we always keep the implementation with more accessors, we should end up with PropertyDefinition that isn't missing any accessors.

Diff.Major(C(Cause.MethodRemoved, Cause.CtorRemoved), $"{prefix} removed.");
// A removed constructor is considered a breaking change, but for methods
// we first check if there's a like method inherited from up the tree.
if (isCtor || !LikeMethodInheritedFromAncestor(descendentType: _n, referenceMethod: o))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the method discovery down below on line 266 so that when MethodsCtors() is called, the Func that will return all the methods will include those of the ancestors. This will then get used to defend against the false negatives.

Note that there is a change within MethodCtors() on line line 244 and 250 where we only include a message about a new or missing exported method if that method was defined at this level in the tree. This way we avoid duplicates messages about a removed exported method in an ancestor, otherwise it would show up once on the diff between the old and new ancestor class, as well as the diff between the old and new descendent class.

Properties has a similar change, except it just calls the ExportedMethod/ExportedProperties method without passing in a Func object (the method case needed this as it shared a definition with Ctors.

else if (inN && n.IsExported() && n.IsDefinedInType(_n))
{
var diff = typeType == TypeType.Class || typeType == TypeType.Struct ? (Func<Cause, FormattableString, Diff>)Diff.Minor : Diff.Major;
var diff = typeType == TypeType.Class || typeType == TypeType.Struct ? (Func<Cause, FormattableString, Diff>) Diff.Minor : Diff.Major;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another note here similar to the changes removing the BOM in some files; .editorconfig contains a rule about this too csharp_space_after_cast = true so while I didn't intend for this change, it was picked up by VS. My personal preference would be to start allowing these small lint-related changes in PRs, maybe this would be best talked about when Amanda is back.


private readonly ConcurrentDictionary<TypeDefinition, TypeDefinition> _baseTypes = new();

public TypeDefinition GetBaseType(TypeDefinition type)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cache for getting the parent type of a class to avoid the number of calls to Resolve() which can quickly get expensive and slow down the run time.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How big will this cache grow to? We are running the breaking change checker for multiple dlls and the garbage collector will be prevented from cleaning them up.

Instead of a cache and running the logic for each method what if we just push down the logic little lower (closer to the level you had before) and gate the evaluation behind a conditional so we grab inheritedO and inheritedN if we detect a breaking change - which is a rare case and so can probably take the occasional hit. For example something like this before any of the code in `CallStructInterface:

if (inO && !inN)
{
  // It might have been moved up to a parent class which is not a breaking change.
  // This is slow, but breaking changes are infrequent.
  var inheritedN = _n.GetInheritedMethod(o); // Maybe a if(_n.TryGetInheritedMethod(o, out var inheritedN))?
  if (inheritedN != null) 
  { 
    inN = true; 
    n = inheritedN; 
  }
}
else if (!inO && inN)
{
  // Similar here just swap o and n
  var inheritedO = _o.GetInheritedMethod(n);
  ...
}

Open to other suggestions too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're suggestion is a good middle ground, let's go with that. Does this look about where you're imagining these changes to go?

var oProps = _o.ExportedProperties().ToImmutableHashSet(SamePropertyComparer.Instance);
var nProps = _n.ExportedProperties().ToImmutableHashSet(SamePropertyComparer.Instance);
foreach (var prop in oProps.Union(nProps).OrderBy(x => x.FullName))
{
    var inO = oProps.TryGetValue(prop, out var o);
    var inN = nProps.TryGetValue(prop, out var n);

    // NEW CODE
    if (inO && !inN)
    {
        // get definition for n from _n's ancestor if it exists
    }
    else if (!inO && inN)
    {
        // get definition for o from _o's ancestor if it exists
    }
    // END NEW CODE
    
    // rest of implementation
    ...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah i believe so. Thinking about it more we might just need the inO && !inN check. Correct me if I'm wrong but for !inO && inN we don't need to look up the family tree because we will eventually run the code for that parent and detect the change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may not be understanding your point, but you can still have a change that won't be detectable in the parent level and we'll need to look up the tree. Consider a hierarchy like this

class A { public virtual int Do() => 1; }
class B : A { public override int Do() => 2; }

if you removed the overriding Do from B in a change

class A { public virtual int Do() => 1; }
class B : A {  }

then there's no change at the parent level. The public surface of A also hasn't changed, but the current breaking changes checker will think there is a major breaking change due to Do not longer being defined at this level of the hierarchy.

You do bring up a good point that we should avoid duplicating diff changes, I'll need to ponder the implementation a bit more.

@efevans
efevans force-pushed the breaking-changes-checker-override branch from cd2908a to 86b4d32 Compare August 20, 2026 19:25
{
foreach (var method in baseType.Methods)
{
if (!(method.IsGetter || method.IsSetter || method.IsConstructor))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current logic returns all methods including private and internal.


private readonly ConcurrentDictionary<TypeDefinition, TypeDefinition> _baseTypes = new();

public TypeDefinition GetBaseType(TypeDefinition type)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How big will this cache grow to? We are running the breaking change checker for multiple dlls and the garbage collector will be prevented from cleaning them up.

Instead of a cache and running the logic for each method what if we just push down the logic little lower (closer to the level you had before) and gate the evaluation behind a conditional so we grab inheritedO and inheritedN if we detect a breaking change - which is a rare case and so can probably take the occasional hit. For example something like this before any of the code in `CallStructInterface:

if (inO && !inN)
{
  // It might have been moved up to a parent class which is not a breaking change.
  // This is slow, but breaking changes are infrequent.
  var inheritedN = _n.GetInheritedMethod(o); // Maybe a if(_n.TryGetInheritedMethod(o, out var inheritedN))?
  if (inheritedN != null) 
  { 
    inN = true; 
    n = inheritedN; 
  }
}
else if (!inO && inN)
{
  // Similar here just swap o and n
  var inheritedO = _o.GetInheritedMethod(n);
  ...
}

Open to other suggestions too.

@efevans
efevans force-pushed the breaking-changes-checker-override branch from 86b4d32 to c1d6281 Compare August 31, 2026 21:00
@efevans
efevans force-pushed the breaking-changes-checker-override branch 2 times, most recently from 303a270 to 25556d4 Compare September 1, 2026 22:23
@efevans
efevans force-pushed the breaking-changes-checker-override branch from 25556d4 to 7e75f68 Compare September 1, 2026 22:25
@efevans

efevans commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

@robertvoinescu-work Some pretty big changes to the implementation. The methods and properties from ancestor classes is retrieved near the beginning of the MethodsCtors and Properties, but now we only go through the methods and properties for a class that could possibly cause a breaking change. Take the following class example:

old version:

public class MyClass
{
    public virtual int CountMoney() => 5;
}

public class ChildClass : MyClass
{
    public override int CountMoney() => 6;
}

new version:

public class MyClass
{
    public virtual int CountMoney() => 5;
}

public class ChildClass : MyClass {}

ChildClass overrides CountMoney(), but a new version with that override removed isn't included in the list (the ImmutableHashSet in both methods) that gets iterated over because neither side, old or new, has a definition that could be a major or minor change (I'm calling these "breakable" changes, even though they may end up just be minor or no changes at all depending on the other side).

There's some extension methods that abstract away the idea of what it means to be breakable, but the gist is a method or property is breakable if one of the following is true:

  • It's the a non-virtual definition
  • It's the original virtual definition of a virtual method/property
  • it's a sealed virtual definition

These are the only cases (at least I could think up) that could result in a change that needs to be determined at this level of the family tree, whereas other changes like visibility, return type, etc. would all be found higher up in the tree where the method or property is originally defined.

Let me know what you think, I think this is a good trade off that solves the initial issue of the bug (as well as on the property side) without causing adverse side effects like extra reporting or excessively slow run times.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants