packages/jsii-diff/lib/type-comparison.ts (438 lines of code) (raw):
import { Stability } from '@jsii/spec';
import * as reflect from 'jsii-reflect';
import * as log4js from 'log4js';
import { validateStabilities } from './stability';
import { isStructuralSuperType, Analysis } from './type-analysis';
import {
describeInterfaceType,
describeType,
ComparisonOptions,
Mismatches,
apiElementIdentifier,
IReport,
} from './types';
import { RecursionBreaker } from './util';
import {
validateBaseTypeAssignability,
validateNotMadeAbstract,
validateSubclassableNotRemoved,
validateNoNewAbstractMembers,
subclassableType,
validateMethodCompatible,
memberPairs,
validateStaticSame,
validateAsyncSame,
validateReturnTypeNotWeakened,
validateNotMadeNonVariadic,
validateReturnTypeSame,
validateExistingParams,
validateNoNewRequiredParams,
validateParameterTypeWeakened,
validateParameterTypeSame,
validateNotMadeImmutable,
validatePropertyTypeNotWeakened,
validatePropertyTypeSame,
validateExistingMembers,
} from './validations';
const LOG = log4js.getLogger('jsii-diff');
/**
* Root object for comparing two assemblies
*
* Tracks mismatches and used as a lookup table to convert FQNs -> ComparableType objects
*/
export class AssemblyComparison {
public readonly mismatches: Mismatches;
private readonly types = new Map<string, ComparableType<any>>();
public constructor(public readonly options: ComparisonOptions) {
this.mismatches = new Mismatches({
defaultStability: options.defaultExperimental
? Stability.Experimental
: Stability.Stable,
});
}
/**
* Load the types from two assemblies to compare
*
* Adds appropriate ComparableType<> instances.
*/
public load(original: reflect.Assembly, updated: reflect.Assembly) {
/* eslint-disable prettier/prettier */
for (const [origClass, updatedClass] of this.typePairs(original.allClasses, updated)) {
this.types.set(origClass.fqn, new ComparableClassType(this, origClass, updatedClass));
}
for (const [origIface, updatedIface] of this.typePairs(original.allInterfaces, updated)) {
if (origIface.datatype !== updatedIface.datatype) {
this.mismatches.report({
ruleKey: 'iface-type',
violator: origIface,
message: `used to be a ${describeInterfaceType(
origIface.datatype,
)}, is now a ${describeInterfaceType(updatedIface.datatype)}.`,
});
continue;
}
this.types.set(origIface.fqn, origIface.datatype
? new ComparableStructType(this, origIface, updatedIface)
: new ComparableInterfaceType(this, origIface, updatedIface));
}
for (const [origEnum, updatedEnum] of this.typePairs(original.allEnums, updated)) {
this.types.set(origEnum.fqn, new ComparableEnumType(this, origEnum, updatedEnum));
}
/* eslint-enable prettier/prettier */
}
/**
* Perform the comparison for all loaded types
*/
public compare() {
LOG.debug(`Comparing ${this.comparableTypes.length} types`);
this.comparableTypes.forEach((t) => t.markTypeRoles());
this.comparableTypes.forEach((t) => t.compare());
}
/**
* Based on a JSII TypeReference, return all reachable ComparableType<> objects.
*/
public typesIn(ref: reflect.TypeReference): Array<ComparableType<any>> {
const ret = new Array<ComparableType<any>>();
for (const fqn of fqnsFrom(ref)) {
const t = this.types.get(fqn);
if (t) {
ret.push(t);
}
}
return ret;
}
/**
* All ComparableType<>s
*/
private get comparableTypes() {
return Array.from(this.types.values());
}
/**
* Find the matching type in the updated assembly based on all types in the original assembly
*/
private *typePairs<T extends reflect.Type>(
xs: readonly T[],
updatedAssembly: reflect.Assembly,
): IterableIterator<[T, T]> {
for (const origType of xs) {
LOG.trace(origType.fqn);
const updatedType = updatedAssembly.tryFindType(origType.fqn);
if (!updatedType) {
this.mismatches.report({
ruleKey: 'removed',
violator: origType,
message: 'has been removed',
});
continue;
}
if (describeType(origType) !== describeType(updatedType)) {
this.mismatches.report({
ruleKey: 'struct-change',
violator: origType,
message: `has been turned into a ${describeType(updatedType)}`,
});
continue;
}
yield [origType, updatedType as T]; // Trust me I know what I'm doing
}
}
}
/**
* Base class for comparable types
*
* Manages notions of crawling types for other reference types, and whether
* they occur in an input/output role, and marking as such on the comparison
* object.
*/
export abstract class ComparableType<T> {
private static readonly recursionBreaker = new RecursionBreaker();
private readonly _inputTypeReasons = new Array<string>();
private readonly _outputTypeReasons = new Array<string>();
public constructor(
protected readonly assemblyComparison: AssemblyComparison,
protected readonly oldType: T,
protected readonly newType: T,
) {}
/**
* Does this type occur in an input role?
*/
public get inputType() {
return this._inputTypeReasons.length > 0;
}
/**
* Does this type occur in an output role?
*/
public get outputType() {
return this._outputTypeReasons.length > 0;
}
/**
* Mark this type as occurring in an input rule.
*
* All types reachable from this type will be marked as input types as well.
*/
public markAsInputType(...reasonFragments: string[]) {
ComparableType.recursionBreaker.do(this, () => {
this._inputTypeReasons.push(reasonFragments.join(', '));
this.forEachRoleSharingType((type, reason) => {
type.markAsInputType(reason, ...reasonFragments);
});
});
}
/**
* Mark this type as occurring in an input rule.
*
* All types reachable from this type will be marked as input types as well.
*/
public markAsOutputType(...reasonFragments: string[]) {
ComparableType.recursionBreaker.do(this, () => {
this._outputTypeReasons.push(reasonFragments.join(', '));
this.forEachRoleSharingType((type, reason) => {
type.markAsOutputType(reason, ...reasonFragments);
});
});
}
/**
* Describe why this type is an input type (if it is)
*/
public get inputTypeReason(): string {
return describeReasons(this._inputTypeReasons);
}
/**
* Describe why this type is an output type (if it is)
*/
public get outputTypeReason(): string {
return describeReasons(this._outputTypeReasons);
}
/**
* Should be overriden in subclasses to mark reachable types as input/output types
*
* Should only be implemented by subclasses that contain callables.
*/
public markTypeRoles() {
// Empty on purpose
}
/**
* Should be overridden in subclasses to perform the comparison
*
* Input/output marking will already have been performed before this is called.
*/
public abstract compare(): void;
/**
* Alias for the root object Mismaches object
*/
protected get mismatches() {
return this.assemblyComparison.mismatches;
}
/**
* Should be overriden in subclasses to execute the callback on reachable types
*
* Should be overriden only for product types (structs).
*/
protected forEachRoleSharingType(
cb: (t: ComparableType<any>, reason: string) => void,
) {
Array.isArray(cb);
}
}
/**
* Base class for reference types
*
* Contains shared code that applies to both class and interface types.
*/
export abstract class ComparableReferenceType<
T extends reflect.ReferenceType,
> extends ComparableType<T> {
/**
* Compare members of the reference types
*/
public compare() {
LOG.debug(`Reference type ${this.oldType.fqn}`);
validateStabilities(this.oldType, this.newType, this.mismatches);
validateBaseTypeAssignability(this.oldType, this.newType, this.mismatches);
validateSubclassableNotRemoved(this.oldType, this.newType, this.mismatches);
if (this.subclassableType) {
validateNoNewAbstractMembers(this.oldType, this.newType, this.mismatches);
}
this.validateMethods();
this.validateProperties();
}
/**
* Mark type accesses (input/output) of methods and properties
*/
public markTypeRoles() {
for (const method of this.oldType.ownMethods) {
determineTypeRolesFromMethod(this.assemblyComparison, method);
}
for (const property of this.oldType.ownProperties) {
determineTypeRolesFromProperty(this.assemblyComparison, property);
}
}
/**
* Validate type signatures on all methods
*/
protected validateMethods() {
for (const [orig, updated] of memberPairs(
this.oldType,
this.oldType.allMethods,
this.newType,
this.mismatches,
)) {
if (reflect.isMethod(updated)) {
this.validateMethod(orig, updated);
}
}
}
/**
* Validate type signature changes on the given method
*/
protected validateMethod(original: reflect.Method, updated: reflect.Method) {
validateStaticSame(original, updated, this.mismatches);
validateAsyncSame(original, updated, this.mismatches);
if (this.subclassableType) {
validateReturnTypeSame(
original,
updated,
this.mismatches.withMotivation('type is @subclassable'),
);
} else {
validateReturnTypeNotWeakened(original, updated, this.mismatches);
}
this.validateCallable(original, updated);
}
/**
* Validate type signature changes on the given callable (method or initializer)
*/
protected validateCallable<T extends reflect.Method | reflect.Initializer>(
original: T,
updated: T,
) {
validateStabilities(original, updated, this.mismatches);
validateNotMadeNonVariadic(original, updated, this.mismatches);
// Check that every original parameter can still be mapped to a parameter in the updated method
validateExistingParams(
original,
updated,
this.mismatches,
(oldParam, newParam) => {
if (this.subclassableType) {
validateParameterTypeSame(
original,
oldParam,
newParam,
this.mismatches.withMotivation('type is @subclassable'),
);
} else {
validateParameterTypeWeakened(
original,
oldParam,
newParam,
this.mismatches,
);
}
},
);
validateNoNewRequiredParams(original, updated, this.mismatches);
}
/**
* Validate type signature changes on all properties
*/
protected validateProperties() {
for (const [orig, updated] of memberPairs(
this.oldType,
this.oldType.allProperties,
this.newType,
this.mismatches,
)) {
if (reflect.isProperty(updated)) {
this.validateProperty(orig, updated);
}
}
}
/**
* Validate type signature changes on the given property
*/
protected validateProperty(
original: reflect.Property,
updated: reflect.Property,
) {
validateStabilities(original, updated, this.mismatches);
validateStaticSame(original, updated, this.mismatches);
validateNotMadeImmutable(original, updated, this.mismatches);
if (this.subclassableType) {
// Hello C# my old friend
validatePropertyTypeSame(
original,
updated,
this.mismatches.withMotivation('type is @subclassable'),
);
} else if (!original.immutable) {
// If the type can be read, it can't be weakened (can't change Dog to Animal, consumers might be counting on a Dog).
// If the type can be written, it can't be strengthened (can't change Animal to Dog, consumers might be sending a Cat).
// => it must remain the same
validatePropertyTypeSame(
original,
updated,
this.mismatches.withMotivation('mutable property cannot change type'),
);
} else {
validatePropertyTypeNotWeakened(original, updated, this.mismatches);
}
}
/**
* Whether the current reference type has been marked as subclassable
*/
private get subclassableType() {
return subclassableType(this.oldType);
}
}
export class ComparableClassType extends ComparableReferenceType<reflect.ClassType> {
/**
* Perform the reference type comparison and include class-specific checks
*/
public compare() {
super.compare();
validateNotMadeAbstract(this.oldType, this.newType, this.mismatches);
// JSII assembler has already taken care of inheritance here
if (this.oldType.initializer && this.newType.initializer) {
validateMethodCompatible(
this.oldType.initializer,
this.newType.initializer,
this.mismatches,
);
}
}
/**
* Type role marking -- include the initializer
*/
public markTypeRoles() {
if (this.oldType.initializer) {
determineTypeRolesFromMethod(
this.assemblyComparison,
this.oldType.initializer,
);
}
super.markTypeRoles();
}
}
/**
* Interface type comparison
*
* (Actually just plain reference type comparison)
*/
export class ComparableInterfaceType extends ComparableReferenceType<reflect.InterfaceType> {}
/**
* Struct type comparison
*
* Most notably: does no-strengthening/no-weakening checks based on whether
* structs appear in input/output positions.
*/
export class ComparableStructType extends ComparableType<reflect.InterfaceType> {
public compare() {
LOG.debug(`Struct type ${this.oldType.fqn}`);
validateStabilities(this.oldType, this.newType, this.mismatches);
validateBaseTypeAssignability(this.oldType, this.newType, this.mismatches);
this.validateNoPropertiesRemoved();
if (this.inputType) {
// If the struct is written, it can't be strengthened (ex: can't change an optional property to required)
this.validateNotStrengthened(
this.mismatches.withMotivation(this.inputTypeReason),
);
}
if (this.outputType) {
// If the struct is read, it can't be weakened (ex: can't change a required property to optional)
this.validateNotWeakened(
this.mismatches.withMotivation(this.outputTypeReason),
);
}
}
/**
* Every type of every property should have the same in/out classification as the outer type
*/
protected forEachRoleSharingType(
cb: (t: ComparableType<any>, reason: string) => void,
) {
for (const prop of this.oldType.allProperties) {
for (const t of this.assemblyComparison.typesIn(prop.type)) {
cb(t, `type of property ${prop.name}`);
}
}
}
/**
* Check that all properties are still present
*
* This is because for all non-structurally typed languages it is not allowed
* to specify members which aren't actually present in the type.
*/
private validateNoPropertiesRemoved() {
// A single run of memberPairs() with nothing else will do this check.
Array.from(
memberPairs(
this.oldType,
this.oldType.allProperties,
this.newType,
this.mismatches,
),
);
}
/**
* Check that the current type is not weakened
*/
private validateNotWeakened(mismatches: IReport) {
const ana = this.isStructuralSuperType(this.oldType, this.newType);
if (!ana.success) {
mismatches.report({
ruleKey: 'weakened',
violator: this.oldType,
message: ana.reasons.join(', '),
});
}
}
/**
* Check that the current type is not strengthened
*/
private validateNotStrengthened(mismatches: IReport) {
const ana = this.isStructuralSuperType(this.newType, this.oldType);
if (!ana.success) {
mismatches.report({
ruleKey: 'strengthened',
violator: this.oldType,
message: ana.reasons.join(', '),
});
}
}
private isStructuralSuperType(
a: reflect.InterfaceType,
b: reflect.InterfaceType,
): Analysis {
try {
return isStructuralSuperType(a, b, this.newType.system);
} catch (e: any) {
// We might get an exception if the type is supposed to come from a different
// assembly and the lookup fails.
return { success: false, reasons: [e.message] };
}
}
}
/**
* Comparison for enums
*/
export class ComparableEnumType extends ComparableType<reflect.EnumType> {
/**
* Perform comparisons on enum members
*/
public compare() {
LOG.debug(`Enum type ${this.oldType.fqn}`);
validateStabilities(this.oldType, this.newType, this.mismatches);
validateExistingMembers(
this.oldType,
this.newType,
this.mismatches,
(oldMember, newMember) => {
validateStabilities(oldMember, newMember, this.mismatches);
},
);
}
}
/**
* Determines input/output roles of types used in this method
*
* - Argument types are treated as IN types
* - Return type is treated as OUT type
*/
function determineTypeRolesFromMethod(
comparison: AssemblyComparison,
method: reflect.Method | reflect.Initializer,
) {
if (reflect.isMethod(method)) {
for (const t of comparison.typesIn(method.returns.type)) {
t.markAsOutputType(`returned from ${apiElementIdentifier(method)}`);
}
}
for (const param of method.parameters ?? []) {
for (const t of comparison.typesIn(param.type)) {
t.markAsInputType(`input to ${apiElementIdentifier(method)}`);
}
}
}
/**
* Determines input/output roles of types used in this property
*
* - Property type is treated as OUT type
* - If mutable, property type is also treated as IN type
*
* In effect, a property is treated as the following methods:
*
* - property(): T;
* - setProperty(: T); <- only if mutable
*/
function determineTypeRolesFromProperty(
comparison: AssemblyComparison,
property: reflect.Property,
) {
for (const t of comparison.typesIn(property.type)) {
t.markAsOutputType(`type of ${apiElementIdentifier(property)}`);
}
if (!property.immutable) {
for (const t of comparison.typesIn(property.type)) {
t.markAsInputType(`type of mutable ${apiElementIdentifier(property)}`);
}
}
}
/**
* Return all the FQNs from a type reference
*
* In the simple case, a simple FQN, but the type might
* be a union or complex type as well.
*/
function fqnsFrom(ref: reflect.TypeReference) {
const ret = new Array<string>();
recurse(ref);
return ret;
function recurse(type: reflect.TypeReference) {
if (type.mapOfType) {
recurse(type.mapOfType);
} else if (type.arrayOfType) {
recurse(type.arrayOfType);
} else if (type.unionOfTypes) {
type.unionOfTypes.forEach(recurse);
} else if (type.fqn) {
ret.push(type.fqn);
}
}
}
function describeReasons(reasons: string[]) {
if (reasons.length === 0) {
return '';
}
if (reasons.length === 1) {
return reasons[0];
}
return `${reasons[0]} (...and ${reasons.length - 1} more...)`;
}