Repository navigation
Conversation
There was a problem hiding this comment.
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.
9f2b299 to
8af18f6
Compare
| @@ -1,4 +1,4 @@ | |||
| // Copyright 2019 Google LLC | |||
| // Copyright 2019 Google LLC | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
eed4a0d to
83527b5
Compare
| @@ -1,4 +1,4 @@ | |||
| using System; | |||
| using System; | |||
| @@ -1,4 +1,4 @@ | |||
| // Copyright 2019 Google LLC | |||
| // Copyright 2019 Google LLC | |||
|
/gemini review |
There was a problem hiding this comment.
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.
83527b5 to
0c611cc
Compare
robertvoinescu-work
left a comment
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
bc76109 to
cd2908a
Compare
efevans
left a comment
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
...There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
cd2908a to
86b4d32
Compare
| { | ||
| foreach (var method in baseType.Methods) | ||
| { | ||
| if (!(method.IsGetter || method.IsSetter || method.IsConstructor)) |
There was a problem hiding this comment.
The current logic returns all methods including private and internal.
|
|
||
| private readonly ConcurrentDictionary<TypeDefinition, TypeDefinition> _baseTypes = new(); | ||
|
|
||
| public TypeDefinition GetBaseType(TypeDefinition type) |
There was a problem hiding this comment.
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.
…as a major change
86b4d32 to
c1d6281
Compare
303a270 to
25556d4
Compare
25556d4 to
7e75f68
Compare
|
@robertvoinescu-work Some pretty big changes to the implementation. The methods and properties from ancestor classes is retrieved near the beginning of the 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 {}
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:
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. |
b/520002639
do not merge until squashing