From 71f2f05f0366f1073453c181ba072a0c67b907da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Ma=C5=82ecki?= Date: Tue, 1 Jul 2025 08:58:15 -0700 Subject: [PATCH] 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 --- .../__tests__/diffApiSnapshot-test.js | 131 +++++------- scripts/diff-api-snapshot/diffApiSnapshot.js | 186 ++++++------------ 2 files changed, 110 insertions(+), 207 deletions(-) diff --git a/scripts/diff-api-snapshot/__tests__/diffApiSnapshot-test.js b/scripts/diff-api-snapshot/__tests__/diffApiSnapshot-test.js index ee88834cfd2..0216652ad40 100644 --- a/scripts/diff-api-snapshot/__tests__/diffApiSnapshot-test.js +++ b/scripts/diff-api-snapshot/__tests__/diffApiSnapshot-test.js @@ -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; - }; - }; - `; - const newSnapshot = ` - import * as React from 'react'; - export declare type ComplexType = { - prop1: string; - prop2: number; - prop3: { - nestedProp1: boolean; - nestedProp2: Array; - 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([]); - }); }); diff --git a/scripts/diff-api-snapshot/diffApiSnapshot.js b/scripts/diff-api-snapshot/diffApiSnapshot.js index 2bb586ebd4f..684accce7d0 100644 --- a/scripts/diff-api-snapshot/diffApiSnapshot.js +++ b/scripts/diff-api-snapshot/diffApiSnapshot.js @@ -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, }; +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 { - return ast.program.body.filter( - statement => statement.type === 'ExportNamedDeclaration', - ); -} - -function analyzeStatements( - prevStatements: Array, - newStatements: Array, +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, -): 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};