Skip to content

Commit 6b40f35

Browse files
coadofacebook-github-bot
authored andcommitted
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: #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
1 parent 4556bdf commit 6b40f35

File tree

3 files changed

+429
-0
lines changed

3 files changed

+429
-0
lines changed
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
* @format
9+
* @oncall react_native
10+
*/
11+
12+
'use strict';
13+
14+
const {Result, diffApiSnapshot} = require('../diffApiSnapshot');
15+
16+
describe('diffApiSnapshot', () => {
17+
test('should detect breaking change when a statement is deleted', () => {
18+
const prevSnapshot = `
19+
import * as React from 'react';
20+
export declare type AccessibilityActionEvent = NativeSyntheticEvent<
21+
Readonly<{
22+
actionName: string;
23+
}>
24+
>;
25+
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
26+
export declare const DeletedExport: string;
27+
`;
28+
const newSnapshot = `
29+
import * as React from 'react';
30+
export declare type AccessibilityActionEvent = NativeSyntheticEvent<
31+
Readonly<{
32+
actionName: string;
33+
}>
34+
>;
35+
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
36+
`;
37+
38+
const res = diffApiSnapshot(prevSnapshot, newSnapshot);
39+
expect(res.result).toBe(Result.BREAKING);
40+
expect(res.changedApis).toEqual(['DeletedExport']);
41+
});
42+
43+
test('should detect breaking change when a statement is changed', () => {
44+
const prevSnapshot = `
45+
import * as React from 'react';
46+
export declare type AccessibilityActionEvent = NativeSyntheticEvent<
47+
Readonly<{
48+
actionName: string;
49+
}>
50+
>;
51+
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
52+
`;
53+
const newSnapshot = `
54+
import * as React from 'react';
55+
export declare type AccessibilityActionEvent = NativeSyntheticEvent<
56+
Readonly<{
57+
actionName: string;
58+
}>
59+
>;
60+
export declare const AccessibilityInfo: typeof AccessibilityInfo_3; // Changed from AccessibilityInfo_2 to AccessibilityInfo_3
61+
`;
62+
63+
const res = diffApiSnapshot(prevSnapshot, newSnapshot);
64+
expect(res.result).toBe(Result.BREAKING);
65+
expect(res.changedApis).toEqual(['AccessibilityInfo']);
66+
});
67+
68+
test('should detect potentially not breaking change when a statement is added', () => {
69+
const prevSnapshot = `
70+
import * as React from 'react';
71+
export declare type AccessibilityActionEvent = NativeSyntheticEvent<
72+
Readonly<{
73+
actionName: string;
74+
}>
75+
>;
76+
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
77+
`;
78+
const newSnapshot = `
79+
import * as React from 'react';
80+
export declare type AccessibilityActionEvent = NativeSyntheticEvent<
81+
Readonly<{
82+
actionName: string;
83+
}>
84+
>;
85+
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
86+
export declare const NewExport: string; // New export added
87+
`;
88+
89+
const res = diffApiSnapshot(prevSnapshot, newSnapshot);
90+
expect(res.result).toBe(Result.POTENTIALLY_NON_BREAKING);
91+
expect(res.changedApis).toEqual(['NewExport']);
92+
});
93+
94+
test('should detect not breaking change when nothing is changed', () => {
95+
const prevSnapshot = `
96+
import * as React from 'react';
97+
export declare type AccessibilityActionEvent = NativeSyntheticEvent<
98+
Readonly<{
99+
actionName: string;
100+
}>
101+
>;
102+
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
103+
`;
104+
105+
const res = diffApiSnapshot(prevSnapshot, prevSnapshot);
106+
expect(res.result).toBe(Result.NON_BREAKING);
107+
expect(res.changedApis).toEqual([]);
108+
});
109+
110+
test('should handle complex type declarations', () => {
111+
const prevSnapshot = `
112+
import * as React from 'react';
113+
export declare type ComplexType = {
114+
prop1: string;
115+
prop2: number;
116+
prop3: {
117+
nestedProp1: boolean;
118+
nestedProp2: Array<string>;
119+
};
120+
};
121+
`;
122+
const newSnapshot = `
123+
import * as React from 'react';
124+
export declare type ComplexType = {
125+
prop1: string;
126+
prop2: number;
127+
prop3: {
128+
nestedProp1: boolean;
129+
nestedProp2: Array<string>;
130+
nestedProp3: number; // Added property
131+
};
132+
};
133+
`;
134+
135+
const res = diffApiSnapshot(prevSnapshot, newSnapshot);
136+
expect(res.result).toBe(Result.BREAKING);
137+
expect(res.changedApis).toEqual(['ComplexType']);
138+
});
139+
140+
test('should handle interface declarations', () => {
141+
const prevSnapshot = `
142+
import * as React from 'react';
143+
export interface TestInterface {
144+
method1(): void;
145+
property1: string;
146+
}
147+
`;
148+
const newSnapshot = `
149+
import * as React from 'react';
150+
export interface TestInterface {
151+
method1(): void;
152+
property1: string;
153+
method2(): number; // Added method
154+
}
155+
`;
156+
157+
const res = diffApiSnapshot(prevSnapshot, newSnapshot);
158+
expect(res.result).toBe(Result.BREAKING);
159+
expect(res.changedApis).toEqual(['TestInterface']);
160+
});
161+
162+
test('should handle const and type of the same name', () => {
163+
const prevSnapshot = `
164+
import * as React from 'react';
165+
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
166+
export declare type AccessibilityInfo = typeof AccessibilityInfo;
167+
`;
168+
169+
const newSnapshot = `
170+
import * as React from 'react';
171+
export declare type AccessibilityInfo = typeof AccessibilityInfo;
172+
export declare const AccessibilityInfo: typeof AccessibilityInfo_2;
173+
`;
174+
175+
const res = diffApiSnapshot(prevSnapshot, newSnapshot);
176+
expect(res.result).toBe(Result.NON_BREAKING);
177+
expect(res.changedApis).toEqual([]);
178+
});
179+
});
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
* @format
9+
* @oncall react_native
10+
*/
11+
12+
const babel = require('@babel/core');
13+
const generate = require('@babel/generator').default;
14+
15+
const Result = {
16+
BREAKING: 'BREAKING',
17+
POTENTIALLY_NON_BREAKING: 'POTENTIALLY_NON_BREAKING',
18+
NON_BREAKING: 'NON_BREAKING',
19+
} as const;
20+
21+
type Output = {
22+
result: $Values<typeof Result>,
23+
changedApis: Array<string>,
24+
};
25+
26+
function diffApiSnapshot(prevSnapshot: string, newSnapshot: string): Output {
27+
const prevSnapshotAST = babel.parseSync(prevSnapshot, {
28+
plugins: ['@babel/plugin-syntax-typescript'],
29+
});
30+
const newSnapshotAST = babel.parseSync(newSnapshot, {
31+
plugins: ['@babel/plugin-syntax-typescript'],
32+
});
33+
const prevStatements = getExportedStatements(prevSnapshotAST);
34+
const newStatements = getExportedStatements(newSnapshotAST);
35+
36+
return analyzeStatements(prevStatements, newStatements);
37+
}
38+
39+
function getExportedStatements(
40+
ast: BabelNodeFile,
41+
): Array<BabelNodeExportNamedDeclaration> {
42+
return ast.program.body.filter(
43+
statement => statement.type === 'ExportNamedDeclaration',
44+
);
45+
}
46+
47+
function analyzeStatements(
48+
prevStatements: Array<BabelNodeExportNamedDeclaration>,
49+
newStatements: Array<BabelNodeExportNamedDeclaration>,
50+
): Output {
51+
const output = {
52+
result: Result.NON_BREAKING,
53+
changedApis: [],
54+
} as Output;
55+
56+
// Create a mapping between prev and new statements
57+
type Pair = Map<'prev' | 'new', BabelNodeExportNamedDeclaration>;
58+
const mapping: Array<[string, Pair]> = [];
59+
const prevNodesMapping = getExportedNodesNames(prevStatements);
60+
const newNodesMapping = Object.fromEntries(
61+
getExportedNodesNames(newStatements),
62+
);
63+
64+
for (const [name, prevNode] of prevNodesMapping) {
65+
if (newNodesMapping[name]) {
66+
const pairMap: Pair = new Map();
67+
pairMap.set('new', newNodesMapping[name]);
68+
pairMap.set('prev', prevNode);
69+
mapping.push([name, pairMap]);
70+
// remove the node to check if there are any new nodes later
71+
delete newNodesMapping[name];
72+
} else {
73+
// There is no statement of that name in the new rollup which means that:
74+
// 1. This statement was entirely removed
75+
// 2. This statement was renamed
76+
// 3. It is not public anymore
77+
output.result = Result.BREAKING;
78+
output.changedApis.push(stripSuffix(name));
79+
}
80+
}
81+
82+
for (const [name, pair] of mapping) {
83+
const prevNode = pair.get('prev');
84+
const newNode = pair.get('new');
85+
if (!prevNode || !newNode) {
86+
throw new Error('Node in pair is undefined');
87+
}
88+
if (didStatementChange(prevNode, newNode)) {
89+
output.result = Result.BREAKING;
90+
output.changedApis.push(stripSuffix(name));
91+
}
92+
}
93+
94+
// if all prev nodes are matched and there are some new nodes left
95+
if (
96+
output.result === Result.NON_BREAKING &&
97+
Object.keys(newNodesMapping).length > 0
98+
) {
99+
// New statement added
100+
output.result = Result.POTENTIALLY_NON_BREAKING;
101+
for (const name of Object.keys(newNodesMapping)) {
102+
output.changedApis.push(stripSuffix(name));
103+
}
104+
}
105+
106+
return output;
107+
}
108+
109+
function getExportedNodesNames(
110+
nodes: Array<BabelNodeExportNamedDeclaration>,
111+
): Array<[string, BabelNodeExportNamedDeclaration]> {
112+
const nodeNames: Array<[string, BabelNodeExportNamedDeclaration]> = [];
113+
nodes.forEach(node => {
114+
if (node.declaration) {
115+
let name = getExportedNodeName(node);
116+
// for declare const/type case we get two statements with the same name
117+
// export declare const foo = string;
118+
// export declare type foo = typeof foo;
119+
// we add a _type and _var suffix to differentiate them
120+
if (node.declaration?.type === 'TSTypeAliasDeclaration') {
121+
name += '__type';
122+
} else if (node.declaration?.type === 'VariableDeclaration') {
123+
name += '__var';
124+
}
125+
nodeNames.push([name, node]);
126+
}
127+
});
128+
129+
return nodeNames;
130+
}
131+
132+
function stripSuffix(name: string): string {
133+
const regex = /(__type|__var)$/;
134+
return name.replace(regex, '');
135+
}
136+
137+
function getExportedNodeName(node: BabelNodeExportNamedDeclaration): string {
138+
if (node.declaration?.type === 'TSTypeAliasDeclaration') {
139+
return node.declaration.id.name;
140+
} else if (node.declaration?.type === 'VariableDeclaration') {
141+
if (node.declaration.declarations.length !== 1) {
142+
throw new Error('Unsupported number of variable declarations');
143+
}
144+
const variableDeclaration = node.declaration.declarations[0];
145+
if (variableDeclaration.id.type !== 'Identifier') {
146+
throw new Error('Variable declaration id type is not Identifier');
147+
}
148+
149+
return variableDeclaration.id.name;
150+
} else if (node.declaration?.type === 'ClassDeclaration') {
151+
if (!node.declaration.id) {
152+
throw new Error('Class declaration id is undefined');
153+
}
154+
155+
return node.declaration.id.name;
156+
} else if (node.declaration?.type === 'TSModuleDeclaration') {
157+
if (node.declaration.id.type === 'StringLiteral') {
158+
return node.declaration.id.value;
159+
} else {
160+
return node.declaration.id.name;
161+
}
162+
} else if (node.declaration?.type === 'TSDeclareFunction') {
163+
if (!node.declaration.id) {
164+
throw new Error('Function declaration id is undefined');
165+
}
166+
return node.declaration.id?.name;
167+
} else if (node.declaration?.type === 'TSInterfaceDeclaration') {
168+
return node.declaration.id.name;
169+
}
170+
171+
throw new Error('Unsupported node declaration type');
172+
}
173+
174+
function didStatementChange(
175+
previousAST: BabelNodeStatement,
176+
newAST: BabelNodeStatement,
177+
) {
178+
const previousCode = getMinifiedCode(previousAST);
179+
const newCode = getMinifiedCode(newAST);
180+
return previousCode !== newCode;
181+
}
182+
183+
function getMinifiedCode(ast: BabelNodeStatement) {
184+
return generate(ast, {
185+
minified: true,
186+
}).code;
187+
}
188+
189+
module.exports = {diffApiSnapshot, Result};

0 commit comments

Comments
 (0)