mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
Align breaking change detection with new snapshot format (#52353)
Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/52353 This diff aligns breaking change detection script with new snapshot format. It compares hashes to determine if the API changed for each specifier. Changelog: [Internal] Reviewed By: huntie Differential Revision: D77377762 fbshipit-source-id: e1c69692ace389fb08ae9470b9f9631e53834206
This commit is contained in:
committed by
Facebook GitHub Bot
parent
1c7b04db4a
commit
71f2f05f03
@@ -17,22 +17,31 @@ describe('diffApiSnapshot', () => {
|
||||
test('should detect breaking change when a statement is deleted', () => {
|
||||
const prevSnapshot = `
|
||||
import * as React from 'react';
|
||||
export declare type AccessibilityActionEvent = NativeSyntheticEvent<
|
||||
declare type AccessibilityActionEvent = NativeSyntheticEvent<
|
||||
Readonly<{
|
||||
actionName: string;
|
||||
}>
|
||||
>;
|
||||
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
|
||||
export declare const DeletedExport: string;
|
||||
declare const AccessibilityInfo: typeof AccessibilityInfo_2;
|
||||
declare const DeletedExport: string;
|
||||
export {
|
||||
AccessibilityActionEvent, // 00000001
|
||||
AccessibilityInfo, // 00000002
|
||||
DeletedExport, // 00000003
|
||||
}
|
||||
`;
|
||||
const newSnapshot = `
|
||||
import * as React from 'react';
|
||||
export declare type AccessibilityActionEvent = NativeSyntheticEvent<
|
||||
declare type AccessibilityActionEvent = NativeSyntheticEvent<
|
||||
Readonly<{
|
||||
actionName: string;
|
||||
}>
|
||||
>;
|
||||
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
|
||||
declare const AccessibilityInfo: typeof AccessibilityInfo_2;
|
||||
export {
|
||||
AccessibilityActionEvent, // 00000001
|
||||
AccessibilityInfo, // 00000002
|
||||
}
|
||||
`;
|
||||
|
||||
const res = diffApiSnapshot(prevSnapshot, newSnapshot);
|
||||
@@ -48,7 +57,11 @@ describe('diffApiSnapshot', () => {
|
||||
actionName: string;
|
||||
}>
|
||||
>;
|
||||
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
|
||||
export declare const Foo: string;
|
||||
export {
|
||||
AccessibilityActionEvent, // 00000001
|
||||
Foo, // 00000002
|
||||
}
|
||||
`;
|
||||
const newSnapshot = `
|
||||
import * as React from 'react';
|
||||
@@ -57,33 +70,46 @@ describe('diffApiSnapshot', () => {
|
||||
actionName: string;
|
||||
}>
|
||||
>;
|
||||
export declare const AccessibilityInfo: typeof AccessibilityInfo_3; // Changed from AccessibilityInfo_2 to AccessibilityInfo_3
|
||||
export declare const Foo: number;
|
||||
export {
|
||||
AccessibilityActionEvent, // 00000001
|
||||
Foo, // 00000003
|
||||
}
|
||||
`;
|
||||
|
||||
const res = diffApiSnapshot(prevSnapshot, newSnapshot);
|
||||
expect(res.result).toBe(Result.BREAKING);
|
||||
expect(res.changedApis).toEqual(['AccessibilityInfo']);
|
||||
expect(res.changedApis).toEqual(['Foo']);
|
||||
});
|
||||
|
||||
test('should detect potentially not breaking change when a statement is added', () => {
|
||||
const prevSnapshot = `
|
||||
import * as React from 'react';
|
||||
export declare type AccessibilityActionEvent = NativeSyntheticEvent<
|
||||
declare type AccessibilityActionEvent = NativeSyntheticEvent<
|
||||
Readonly<{
|
||||
actionName: string;
|
||||
}>
|
||||
>;
|
||||
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
|
||||
declare const AccessibilityInfo: typeof AccessibilityInfo_2;
|
||||
export {
|
||||
AccessibilityActionEvent, // 00000001
|
||||
AccessibilityInfo, // 00000002
|
||||
}
|
||||
`;
|
||||
const newSnapshot = `
|
||||
import * as React from 'react';
|
||||
export declare type AccessibilityActionEvent = NativeSyntheticEvent<
|
||||
declare type AccessibilityActionEvent = NativeSyntheticEvent<
|
||||
Readonly<{
|
||||
actionName: string;
|
||||
}>
|
||||
>;
|
||||
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
|
||||
export declare const NewExport: string; // New export added
|
||||
declare const AccessibilityInfo: typeof AccessibilityInfo_2;
|
||||
declare const NewExport: string; // New export added
|
||||
export {
|
||||
AccessibilityActionEvent, // 00000001
|
||||
AccessibilityInfo, // 00000002
|
||||
NewExport, // 00000003
|
||||
}
|
||||
`;
|
||||
|
||||
const res = diffApiSnapshot(prevSnapshot, newSnapshot);
|
||||
@@ -94,86 +120,21 @@ describe('diffApiSnapshot', () => {
|
||||
test('should detect not breaking change when nothing is changed', () => {
|
||||
const prevSnapshot = `
|
||||
import * as React from 'react';
|
||||
export declare type AccessibilityActionEvent = NativeSyntheticEvent<
|
||||
declare type AccessibilityActionEvent = NativeSyntheticEvent<
|
||||
Readonly<{
|
||||
actionName: string;
|
||||
}>
|
||||
>;
|
||||
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
|
||||
declare const AccessibilityInfo: typeof AccessibilityInfo_2;
|
||||
|
||||
export {
|
||||
AccessibilityActionEvent, // 00000001
|
||||
AccessibilityInfo, // 00000002
|
||||
}
|
||||
`;
|
||||
|
||||
const res = diffApiSnapshot(prevSnapshot, prevSnapshot);
|
||||
expect(res.result).toBe(Result.NON_BREAKING);
|
||||
expect(res.changedApis).toEqual([]);
|
||||
});
|
||||
|
||||
test('should handle complex type declarations', () => {
|
||||
const prevSnapshot = `
|
||||
import * as React from 'react';
|
||||
export declare type ComplexType = {
|
||||
prop1: string;
|
||||
prop2: number;
|
||||
prop3: {
|
||||
nestedProp1: boolean;
|
||||
nestedProp2: Array<string>;
|
||||
};
|
||||
};
|
||||
`;
|
||||
const newSnapshot = `
|
||||
import * as React from 'react';
|
||||
export declare type ComplexType = {
|
||||
prop1: string;
|
||||
prop2: number;
|
||||
prop3: {
|
||||
nestedProp1: boolean;
|
||||
nestedProp2: Array<string>;
|
||||
nestedProp3: number; // Added property
|
||||
};
|
||||
};
|
||||
`;
|
||||
|
||||
const res = diffApiSnapshot(prevSnapshot, newSnapshot);
|
||||
expect(res.result).toBe(Result.BREAKING);
|
||||
expect(res.changedApis).toEqual(['ComplexType']);
|
||||
});
|
||||
|
||||
test('should handle interface declarations', () => {
|
||||
const prevSnapshot = `
|
||||
import * as React from 'react';
|
||||
export interface TestInterface {
|
||||
method1(): void;
|
||||
property1: string;
|
||||
}
|
||||
`;
|
||||
const newSnapshot = `
|
||||
import * as React from 'react';
|
||||
export interface TestInterface {
|
||||
method1(): void;
|
||||
property1: string;
|
||||
method2(): number; // Added method
|
||||
}
|
||||
`;
|
||||
|
||||
const res = diffApiSnapshot(prevSnapshot, newSnapshot);
|
||||
expect(res.result).toBe(Result.BREAKING);
|
||||
expect(res.changedApis).toEqual(['TestInterface']);
|
||||
});
|
||||
|
||||
test('should handle const and type of the same name', () => {
|
||||
const prevSnapshot = `
|
||||
import * as React from 'react';
|
||||
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
|
||||
export declare type AccessibilityInfo = typeof AccessibilityInfo;
|
||||
`;
|
||||
|
||||
const newSnapshot = `
|
||||
import * as React from 'react';
|
||||
export declare type AccessibilityInfo = typeof AccessibilityInfo;
|
||||
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
|
||||
`;
|
||||
|
||||
const res = diffApiSnapshot(prevSnapshot, newSnapshot);
|
||||
expect(res.result).toBe(Result.NON_BREAKING);
|
||||
expect(res.changedApis).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
const babel = require('@babel/core');
|
||||
const generate = require('@babel/generator').default;
|
||||
const t = require('@babel/types');
|
||||
|
||||
const Result = {
|
||||
BREAKING: 'BREAKING',
|
||||
@@ -23,6 +23,9 @@ type Output = {
|
||||
changedApis: Array<string>,
|
||||
};
|
||||
|
||||
type Hash = string;
|
||||
type APISpecifier = string;
|
||||
|
||||
function diffApiSnapshot(prevSnapshot: string, newSnapshot: string): Output {
|
||||
const prevSnapshotAST = babel.parseSync(prevSnapshot, {
|
||||
plugins: ['@babel/plugin-syntax-typescript'],
|
||||
@@ -30,160 +33,99 @@ function diffApiSnapshot(prevSnapshot: string, newSnapshot: string): Output {
|
||||
const newSnapshotAST = babel.parseSync(newSnapshot, {
|
||||
plugins: ['@babel/plugin-syntax-typescript'],
|
||||
});
|
||||
const prevStatements = getExportedStatements(prevSnapshotAST);
|
||||
const newStatements = getExportedStatements(newSnapshotAST);
|
||||
|
||||
return analyzeStatements(prevStatements, newStatements);
|
||||
const prevSpecHashPair = getExportedSymbols(prevSnapshotAST);
|
||||
const newSpecHashPair = getExportedSymbols(newSnapshotAST);
|
||||
|
||||
if (prevSpecHashPair == null || newSpecHashPair == null) {
|
||||
return {
|
||||
result: Result.BREAKING,
|
||||
changedApis: [],
|
||||
};
|
||||
}
|
||||
|
||||
return analyzeSpecHashPairs(prevSpecHashPair, newSpecHashPair);
|
||||
}
|
||||
|
||||
function getExportedStatements(
|
||||
ast: BabelNodeFile,
|
||||
): Array<BabelNodeExportNamedDeclaration> {
|
||||
return ast.program.body.filter(
|
||||
statement => statement.type === 'ExportNamedDeclaration',
|
||||
);
|
||||
}
|
||||
|
||||
function analyzeStatements(
|
||||
prevStatements: Array<BabelNodeExportNamedDeclaration>,
|
||||
newStatements: Array<BabelNodeExportNamedDeclaration>,
|
||||
function analyzeSpecHashPairs(
|
||||
prevSpecHashPairs: Array<[APISpecifier, Hash]>,
|
||||
newSpecHashPairs: Array<[APISpecifier, Hash]>,
|
||||
): Output {
|
||||
const output = {
|
||||
result: Result.NON_BREAKING,
|
||||
changedApis: [],
|
||||
} as Output;
|
||||
|
||||
// Create a mapping between prev and new statements
|
||||
type Pair = Map<'prev' | 'new', BabelNodeExportNamedDeclaration>;
|
||||
const mapping: Array<[string, Pair]> = [];
|
||||
const prevNodesMapping = getExportedNodesNames(prevStatements);
|
||||
const newNodesMapping = Object.fromEntries(
|
||||
getExportedNodesNames(newStatements),
|
||||
);
|
||||
|
||||
for (const [name, prevNode] of prevNodesMapping) {
|
||||
if (newNodesMapping[name]) {
|
||||
const pairMap: Pair = new Map();
|
||||
pairMap.set('new', newNodesMapping[name]);
|
||||
pairMap.set('prev', prevNode);
|
||||
mapping.push([name, pairMap]);
|
||||
// remove the node to check if there are any new nodes later
|
||||
delete newNodesMapping[name];
|
||||
const newSpecHashMapping = Object.fromEntries(newSpecHashPairs);
|
||||
for (const [name, hash] of prevSpecHashPairs) {
|
||||
if (newSpecHashMapping[name]) {
|
||||
const newHash = newSpecHashMapping[name];
|
||||
if (hash !== newHash) {
|
||||
// The hash has changed which means that the statement has changed
|
||||
output.result = Result.BREAKING;
|
||||
output.changedApis.push(name);
|
||||
}
|
||||
delete newSpecHashMapping[name];
|
||||
} else {
|
||||
// There is no statement of that name in the new rollup which means that:
|
||||
// 1. This statement was entirely removed
|
||||
// 2. This statement was renamed
|
||||
// 3. It is not public anymore
|
||||
output.result = Result.BREAKING;
|
||||
output.changedApis.push(stripSuffix(name));
|
||||
output.changedApis.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, pair] of mapping) {
|
||||
const prevNode = pair.get('prev');
|
||||
const newNode = pair.get('new');
|
||||
if (!prevNode || !newNode) {
|
||||
throw new Error('Node in pair is undefined');
|
||||
}
|
||||
if (didStatementChange(prevNode, newNode)) {
|
||||
output.result = Result.BREAKING;
|
||||
output.changedApis.push(stripSuffix(name));
|
||||
}
|
||||
}
|
||||
|
||||
// if all prev nodes are matched and there are some new nodes left
|
||||
if (
|
||||
output.result === Result.NON_BREAKING &&
|
||||
Object.keys(newNodesMapping).length > 0
|
||||
Object.keys(newSpecHashMapping).length > 0
|
||||
) {
|
||||
// New statement added
|
||||
output.result = Result.POTENTIALLY_NON_BREAKING;
|
||||
for (const name of Object.keys(newNodesMapping)) {
|
||||
output.changedApis.push(stripSuffix(name));
|
||||
for (const name of Object.keys(newSpecHashMapping)) {
|
||||
// saving new APIs
|
||||
output.changedApis.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function getExportedNodesNames(
|
||||
nodes: Array<BabelNodeExportNamedDeclaration>,
|
||||
): Array<[string, BabelNodeExportNamedDeclaration]> {
|
||||
const nodeNames: Array<[string, BabelNodeExportNamedDeclaration]> = [];
|
||||
nodes.forEach(node => {
|
||||
if (node.declaration) {
|
||||
let name = getExportedNodeName(node);
|
||||
// for declare const/type case we get two statements with the same name
|
||||
// export declare const foo = string;
|
||||
// export declare type foo = typeof foo;
|
||||
// we add a _type and _var suffix to differentiate them
|
||||
if (node.declaration?.type === 'TSTypeAliasDeclaration') {
|
||||
name += '__type';
|
||||
} else if (node.declaration?.type === 'VariableDeclaration') {
|
||||
name += '__var';
|
||||
function getExportedSymbols(
|
||||
ast: BabelNodeFile,
|
||||
): Array<[APISpecifier, Hash]> | null {
|
||||
for (const nodePath of ast.program.body) {
|
||||
if (
|
||||
t.isExportNamedDeclaration(nodePath) &&
|
||||
!nodePath.declaration &&
|
||||
nodePath.specifiers != null
|
||||
) {
|
||||
const specifiers = nodePath.specifiers;
|
||||
const result: Array<[APISpecifier, Hash]> = [];
|
||||
for (let i = 0; i < specifiers.length; i++) {
|
||||
const specifier = specifiers[i];
|
||||
const name = specifier.exported.name || '';
|
||||
const comment =
|
||||
specifier.leadingComments && specifier.leadingComments.length > 0
|
||||
? specifier.leadingComments[0]?.value
|
||||
: '';
|
||||
|
||||
if (i > 0) {
|
||||
result[i - 1][1] = comment;
|
||||
}
|
||||
result.push([name, comment]);
|
||||
}
|
||||
nodeNames.push([name, node]);
|
||||
}
|
||||
});
|
||||
|
||||
return nodeNames;
|
||||
}
|
||||
|
||||
function stripSuffix(name: string): string {
|
||||
const regex = /(__type|__var)$/;
|
||||
return name.replace(regex, '');
|
||||
}
|
||||
|
||||
function getExportedNodeName(node: BabelNodeExportNamedDeclaration): string {
|
||||
if (node.declaration?.type === 'TSTypeAliasDeclaration') {
|
||||
return node.declaration.id.name;
|
||||
} else if (node.declaration?.type === 'VariableDeclaration') {
|
||||
if (node.declaration.declarations.length !== 1) {
|
||||
throw new Error('Unsupported number of variable declarations');
|
||||
const lastSpec = specifiers[specifiers.length - 1];
|
||||
const comment =
|
||||
lastSpec.trailingComments && lastSpec.trailingComments.length > 0
|
||||
? lastSpec.trailingComments[0]?.value
|
||||
: '';
|
||||
result[result.length - 1][1] = comment;
|
||||
return result;
|
||||
}
|
||||
const variableDeclaration = node.declaration.declarations[0];
|
||||
if (variableDeclaration.id.type !== 'Identifier') {
|
||||
throw new Error('Variable declaration id type is not Identifier');
|
||||
}
|
||||
|
||||
return variableDeclaration.id.name;
|
||||
} else if (node.declaration?.type === 'ClassDeclaration') {
|
||||
if (!node.declaration.id) {
|
||||
throw new Error('Class declaration id is undefined');
|
||||
}
|
||||
|
||||
return node.declaration.id.name;
|
||||
} else if (node.declaration?.type === 'TSModuleDeclaration') {
|
||||
if (node.declaration.id.type === 'StringLiteral') {
|
||||
return node.declaration.id.value;
|
||||
} else {
|
||||
return node.declaration.id.name;
|
||||
}
|
||||
} else if (node.declaration?.type === 'TSDeclareFunction') {
|
||||
if (!node.declaration.id) {
|
||||
throw new Error('Function declaration id is undefined');
|
||||
}
|
||||
return node.declaration.id?.name;
|
||||
} else if (node.declaration?.type === 'TSInterfaceDeclaration') {
|
||||
return node.declaration.id.name;
|
||||
}
|
||||
|
||||
throw new Error('Unsupported node declaration type');
|
||||
}
|
||||
|
||||
function didStatementChange(
|
||||
previousAST: BabelNodeStatement,
|
||||
newAST: BabelNodeStatement,
|
||||
) {
|
||||
const previousCode = getMinifiedCode(previousAST);
|
||||
const newCode = getMinifiedCode(newAST);
|
||||
return previousCode !== newCode;
|
||||
}
|
||||
|
||||
function getMinifiedCode(ast: BabelNodeStatement) {
|
||||
return generate(ast, {
|
||||
minified: true,
|
||||
}).code;
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {diffApiSnapshot, Result};
|
||||
|
||||
Reference in New Issue
Block a user