Files
react-native/scripts/diff-api-snapshot/diffApiSnapshot.js
T
Dawid Małecki 6b40f35032 Add diff-api-snapshot for public API breaking change detection (#51972)
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
2025-06-17 02:27:53 -07:00

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};