Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- fix: carry over entry points of type aliases to the aliased type (especially important for private types)
- fix: naming of unary operators was wrong (unaryunary...)
- fix: handling of top level functions in the context of show / hide in exports
- fix: report type parameter change in a base class as new change kind instead of removal + addition of base classes

## Version 0.22.0
- fix: Fixes an issue if we have to deal with two types with the same name
Expand Down
3 changes: 3 additions & 0 deletions lib/src/diff/api_change_code.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ enum ApiChangeCode {
/// sealed status changed
ci11._('CI11', 'sealed status changed'),

/// supertype changed
ci12._('CI12', 'supertype changed'),

/// executable parameters removed
ce01._('CE01', 'executable parameters removed'),

Expand Down
58 changes: 56 additions & 2 deletions lib/src/diff/package_api_differ.dart
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,41 @@ class PackageApiDiffer {
final stpnListDiff = _diffIterables<String>(
oldSuperTypes, newSuperTypes, (oldStpn, newStpn) => oldStpn == newStpn);
final changes = <ApiChange>[];
for (final removedSuperType in stpnListDiff.remainingOld) {

// Look for supertype changes (same base type, different parameters)
final remainingOld = stpnListDiff.remainingOld.toList();
final remainingNew = stpnListDiff.remainingNew.toList();
final pairedChanges = <String, String>{};

for (final oldType in [...remainingOld]) {
for (final newType in [...remainingNew]) {
if (_areRelatedGenericTypes(oldType, newType)) {
// Found a pair - this is a supertype change, not remove+add
pairedChanges[oldType] = newType;
remainingOld.remove(oldType);
remainingNew.remove(newType);
break; // Move to next old type
}
}
}

// Report supertype changes
for (final entry in pairedChanges.entries) {
final oldType = entry.key;
final newType = entry.value;
changes.add(ApiChange(
changeCode:
ApiChangeCode.ci12, // New change code for supertype changes
affectedDeclaration: context.top(),
contextTrace: _contextTraceFromStack(context),
type: ApiChangeType
.changeBreaking, // Supertype changes are typically breaking
isExperimental: isExperimental,
changeDescription:
'Super Type changed from "$oldType" to "$newType"'));
}

for (final removedSuperType in remainingOld) {
changes.add(ApiChange(
changeCode: ApiChangeCode.ci05,
affectedDeclaration: context.top(),
Expand All @@ -783,7 +817,8 @@ class PackageApiDiffer {
isExperimental: isExperimental,
changeDescription: 'Super Type "$removedSuperType" removed'));
}
for (final addedSuperType in stpnListDiff.remainingNew) {

for (final addedSuperType in remainingNew) {
changes.add(ApiChange(
changeCode: ApiChangeCode.ci04,
affectedDeclaration: context.top(),
Expand All @@ -795,6 +830,25 @@ class PackageApiDiffer {
return changes;
}

/// Checks if two type strings represent related generic types
/// (same base type, potentially different type parameters)
bool _areRelatedGenericTypes(String type1, String type2) {
final baseType1 = _extractBaseTypeName(type1);
final baseType2 = _extractBaseTypeName(type2);

return baseType1 == baseType2;
}

/// Extracts the base type name from a potentially generic type
/// e.g., `MyClass<int, String>` -> `MyClass`
String _extractBaseTypeName(String typeName) {
final genericStart = typeName.indexOf('<');
if (genericStart == -1) {
return typeName;
}
return typeName.substring(0, genericStart);
}

List<ApiChange> _calculateFieldsDiff(
List<FieldDeclaration> oldFieldDeclarations,
List<FieldDeclaration> newFieldDeclarations,
Expand Down
5 changes: 5 additions & 0 deletions readme/change_codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ If the flag got removed then this change is non-breaking. Adding an experimental

If the flag got removed then this change is non-breaking. Adding a sealed flag is considered a breaking change.

###CI12
> Supertype changed

A supertype of an interface changed. dart_apitool tries to differentiate "change" from "remove" (CI05) and "add" (CI04) where possible.

## Executables
Executables are constructors, methods, and functions. They are all treated the same way.

Expand Down
119 changes: 119 additions & 0 deletions test/package_api_differ_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -772,4 +772,123 @@ void main() {
expect(removeChange.type, ApiChangeType.remove);
});
});

group('Generic type compatibility handling', () {
test(
'Generic types with different parameter counts can be compatible (default parameters)',
() {
// This test covers the GitHub issue scenario where:
// Parent: MyClass<T> -> MyClass<T, O> (with default parameter)
// Subclass: extends MyClass<int> -> extends MyClass<int, String>
// The subclass change should not be reported as a separate breaking change

final oldApiWithSubclass = PackageApi(
packageName: 'test_package',
packageVersion: '1.0.0',
packagePath: '.',
typeHierarchy: TypeHierarchy.empty(),
interfaceDeclarations: [
InterfaceDeclaration(
name: 'MyClass',
typeParameterNames: const ['T'],
executableDeclarations: const [],
fieldDeclarations: const [],
superTypeNames: const {},
typeUsages: {},
relativePath: 'lib/test.dart',
isDeprecated: false,
isExperimental: false,
isSealed: false,
isAbstract: false,
),
InterfaceDeclaration(
name: 'Other',
typeParameterNames: const [],
executableDeclarations: const [],
fieldDeclarations: const [],
superTypeNames: const {'MyClass<int>'},
typeUsages: {},
relativePath: 'lib/test.dart',
isDeprecated: false,
isExperimental: false,
isSealed: false,
isAbstract: false,
),
],
executableDeclarations: const [],
fieldDeclarations: const [],
typeAliasDeclarations: const [],
sdkType: SdkType.unknown,
minSdkVersion: Version.none,
packageDependencies: [],
);

final newApiWithSubclass = PackageApi(
packageName: 'test_package',
packageVersion: '1.0.0',
packagePath: '.',
typeHierarchy: TypeHierarchy.empty(),
interfaceDeclarations: [
InterfaceDeclaration(
name: 'MyClass',
typeParameterNames: const ['T', 'O'],
executableDeclarations: const [],
fieldDeclarations: const [],
superTypeNames: const {},
typeUsages: {},
relativePath: 'lib/test.dart',
isDeprecated: false,
isExperimental: false,
isSealed: false,
isAbstract: false,
),
InterfaceDeclaration(
name: 'Other',
typeParameterNames: const [],
executableDeclarations: const [],
fieldDeclarations: const [],
superTypeNames: const {'MyClass<int, String>'},
typeUsages: {},
relativePath: 'lib/test.dart',
isDeprecated: false,
isExperimental: false,
isSealed: false,
isAbstract: false,
),
],
executableDeclarations: const [],
fieldDeclarations: const [],
typeAliasDeclarations: const [],
sdkType: SdkType.unknown,
minSdkVersion: Version.none,
packageDependencies: [],
);

final differ = PackageApiDiffer();
final diffResult = differ.diff(
oldApi: oldApiWithSubclass,
newApi: newApiWithSubclass,
);

// Should detect: 1) parent type parameter addition, 2) supertype change (not remove+add)
expect(diffResult.apiChanges.length, 2);

final parentTypeParameterChange = diffResult.apiChanges
.where((change) => change.affectedDeclaration?.name == 'MyClass')
.single;
expect(parentTypeParameterChange.type, ApiChangeType.addBreaking);
expect(parentTypeParameterChange.changeDescription,
contains('Number of type parameters changed'));

// Verify inheritance is reported as a single "change" not "remove + add"
final inheritanceChange = diffResult.apiChanges
.where((change) => change.affectedDeclaration?.name == 'Other')
.single;
expect(inheritanceChange.type, ApiChangeType.changeBreaking);
expect(
inheritanceChange.changeDescription,
contains(
'Super Type changed from "MyClass<int>" to "MyClass<int, String>"'));
});
});
}
Loading