mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
6b40f35032
Summary: This diff adds snapshot `diff-api-snapshot` script for public JS API breaking change detection. ### Motivation Detecting if there are any breaking changes introduced in the commit. It is achieved by comparing `ReactNativeApi.d.ts` rollup from the current and previous revision. This is a naive implementation with a three possible outcomes: - BREAKING - POTENTIALLY_NOT_BREAKING, - NOT_BREAKING The algorithm analyses exported top-level statements (after inlining) in both rollups and tries to create a mapping between them by name. The **BREAKING** outcome happens whenever the statement is: - removed - renamed - changed - not exported anymore (private) The **POTENTIALLY_NOT_BREAKING** outcome happens if it's not BREAKING and the new statement is added. The **NOT_BREAKING** outcome happens if public API snapshot doesn't change. Changelog: [General][Added] - Add public JS API breaking change detection under `yarn diff-api-snapshot` script. Pull Request resolved: https://github.com/facebook/react-native/pull/51972 Test Plan: Signals, added tests. In `react-native-github` run: `yarn test scripts/diff-api-snapshot/__tests__/diffApiSnapshot-test.js` Rollback Plan: Reviewed By: j-piasecki Differential Revision: D76430965 Pulled By: coado fbshipit-source-id: 095a196aa4f643501db0af9262556ddefff5d30d
190 lines
5.8 KiB
JavaScript
190 lines
5.8 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow
|
|
* @format
|
|
* @oncall react_native
|
|
*/
|
|
|
|
const babel = require('@babel/core');
|
|
const generate = require('@babel/generator').default;
|
|
|
|
const Result = {
|
|
BREAKING: 'BREAKING',
|
|
POTENTIALLY_NON_BREAKING: 'POTENTIALLY_NON_BREAKING',
|
|
NON_BREAKING: 'NON_BREAKING',
|
|
} as const;
|
|
|
|
type Output = {
|
|
result: $Values<typeof Result>,
|
|
changedApis: Array<string>,
|
|
};
|
|
|
|
function diffApiSnapshot(prevSnapshot: string, newSnapshot: string): Output {
|
|
const prevSnapshotAST = babel.parseSync(prevSnapshot, {
|
|
plugins: ['@babel/plugin-syntax-typescript'],
|
|
});
|
|
const newSnapshotAST = babel.parseSync(newSnapshot, {
|
|
plugins: ['@babel/plugin-syntax-typescript'],
|
|
});
|
|
const prevStatements = getExportedStatements(prevSnapshotAST);
|
|
const newStatements = getExportedStatements(newSnapshotAST);
|
|
|
|
return analyzeStatements(prevStatements, newStatements);
|
|
}
|
|
|
|
function getExportedStatements(
|
|
ast: BabelNodeFile,
|
|
): Array<BabelNodeExportNamedDeclaration> {
|
|
return ast.program.body.filter(
|
|
statement => statement.type === 'ExportNamedDeclaration',
|
|
);
|
|
}
|
|
|
|
function analyzeStatements(
|
|
prevStatements: Array<BabelNodeExportNamedDeclaration>,
|
|
newStatements: Array<BabelNodeExportNamedDeclaration>,
|
|
): 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];
|
|
} 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));
|
|
}
|
|
}
|
|
|
|
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
|
|
) {
|
|
// New statement added
|
|
output.result = Result.POTENTIALLY_NON_BREAKING;
|
|
for (const name of Object.keys(newNodesMapping)) {
|
|
output.changedApis.push(stripSuffix(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';
|
|
}
|
|
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 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;
|
|
}
|
|
|
|
module.exports = {diffApiSnapshot, Result};
|