From c7355b44064c2e6f4780d2beb3ab2767278613c3 Mon Sep 17 00:00:00 2001 From: Quinlan Jung Date: Mon, 2 Dec 2024 13:30:49 -0800 Subject: [PATCH] [eas-cli] fingerprint:compare --- packages/eas-cli/graphql.schema.json | 96 +++++ packages/eas-cli/package.json | 2 + packages/eas-cli/src/commandUtils/builds.ts | 10 +- .../src/commands/fingerprint/compare.ts | 378 ++++++++++++++++++ packages/eas-cli/src/graphql/generated.ts | 26 ++ .../eas-cli/src/graphql/queries/BuildQuery.ts | 39 +- packages/eas-cli/src/graphql/types/Build.ts | 15 + .../eas-cli/src/graphql/types/Fingerprint.ts | 9 + .../utils/__tests__/fingerprintDiff-test.ts | 119 ++++++ packages/eas-cli/src/utils/fingerprint.ts | 114 ++++++ packages/eas-cli/src/utils/fingerprintCli.ts | 41 +- packages/eas-cli/src/utils/fingerprintDiff.ts | 127 ++++++ packages/eas-cli/tsconfig.json | 2 +- yarn.lock | 10 + 14 files changed, 972 insertions(+), 16 deletions(-) create mode 100644 packages/eas-cli/src/commands/fingerprint/compare.ts create mode 100644 packages/eas-cli/src/graphql/types/Fingerprint.ts create mode 100644 packages/eas-cli/src/utils/__tests__/fingerprintDiff-test.ts create mode 100644 packages/eas-cli/src/utils/fingerprint.ts create mode 100644 packages/eas-cli/src/utils/fingerprintDiff.ts diff --git a/packages/eas-cli/graphql.schema.json b/packages/eas-cli/graphql.schema.json index 0752779aa5..e99613d5ff 100644 --- a/packages/eas-cli/graphql.schema.json +++ b/packages/eas-cli/graphql.schema.json @@ -4201,6 +4201,12 @@ "inputFields": null, "interfaces": null, "enumValues": [ + { + "name": "PROFILE_IMAGE_UPLOAD", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "WORKFLOWS_PROJECT_SOURCES", "description": null, @@ -13381,6 +13387,23 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "ENUM", + "name": "AppUploadSessionType", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "PROFILE_IMAGE_UPLOAD", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "OBJECT", "name": "AppVersion", @@ -19462,6 +19485,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "hasFingerprint", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "platform", "description": null, @@ -19577,6 +19612,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "hasFingerprint", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "platforms", "description": null, @@ -47717,6 +47764,55 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "createAppScopedUploadSession", + "description": "Create an Upload Session for a specific app", + "args": [ + { + "name": "appID", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AppUploadSessionType", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "JSONObject", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "createUploadSession", "description": "Create an Upload Session", diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index 3b008b3116..6b2ffc83aa 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -41,6 +41,7 @@ "chalk": "4.1.2", "cli-progress": "3.12.0", "dateformat": "4.6.3", + "diff": "7.0.0", "dotenv": "16.3.1", "env-paths": "2.2.0", "envinfo": "7.11.0", @@ -96,6 +97,7 @@ "@graphql-codegen/typescript-operations": "4.0.1", "@types/cli-progress": "3.11.5", "@types/dateformat": "3.0.1", + "@types/diff": "6.0.0", "@types/envinfo": "7.8.3", "@types/express": "4.17.15", "@types/fs-extra": "11.0.4", diff --git a/packages/eas-cli/src/commandUtils/builds.ts b/packages/eas-cli/src/commandUtils/builds.ts index e329de3b2b..5debcff17e 100644 --- a/packages/eas-cli/src/commandUtils/builds.ts +++ b/packages/eas-cli/src/commandUtils/builds.ts @@ -38,7 +38,12 @@ export async function fetchBuildsAsync({ }: { graphqlClient: ExpoGraphqlClient; projectId: string; - filters?: { statuses?: BuildStatus[]; platform?: RequestedPlatform; profile?: string }; + filters?: { + statuses?: BuildStatus[]; + platform?: RequestedPlatform; + profile?: string; + hasFingerprint?: boolean; + }; }): Promise { let builds: BuildFragment[]; const queryFilters: InputMaybe = {}; @@ -48,6 +53,9 @@ export async function fetchBuildsAsync({ if (filters?.profile) { queryFilters['buildProfile'] = filters.profile; } + if (filters?.hasFingerprint) { + queryFilters['hasFingerprint'] = filters.hasFingerprint; + } if (!filters?.statuses) { builds = await BuildQuery.viewBuildsOnAppAsync(graphqlClient, { appId: projectId, diff --git a/packages/eas-cli/src/commands/fingerprint/compare.ts b/packages/eas-cli/src/commands/fingerprint/compare.ts new file mode 100644 index 0000000000..5a2d269336 --- /dev/null +++ b/packages/eas-cli/src/commands/fingerprint/compare.ts @@ -0,0 +1,378 @@ +import { Platform } from '@expo/eas-build-job'; +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; + +import EasCommand from '../../commandUtils/EasCommand'; +import { fetchBuildsAsync, formatBuild } from '../../commandUtils/builds'; +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags'; +import { AppPlatform, BuildStatus } from '../../graphql/generated'; +import { BuildQuery } from '../../graphql/queries/BuildQuery'; +import Log from '../../log'; +import { ora } from '../../ora'; +import { RequestedPlatform } from '../../platform'; +import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils'; +import { resolveWorkflowPerPlatformAsync } from '../../project/workflow'; +import { selectAsync } from '../../prompts'; +import { Fingerprint, FingerprintDiffItem } from '../../utils/fingerprint'; +import { createFingerprintAsync, diffFingerprint } from '../../utils/fingerprintCli'; +import { abridgedDiff } from '../../utils/fingerprintDiff'; +import formatFields, { FormatFieldsItem } from '../../utils/formatFields'; +import { enableJsonOutput } from '../../utils/json'; + +export default class FingerprintCompare extends EasCommand { + static override description = 'compare fingerprints of the current project, builds and updates'; + static override hidden = true; + + static override flags = { + 'build-id': Flags.string({ + aliases: ['buildId'], + description: 'Compare the fingerprint with the build with the specified ID', + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.ProjectConfig, + ...this.ContextOptions.LoggedIn, + ...this.ContextOptions.Vcs, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(FingerprintCompare); + const { json: jsonFlag, 'non-interactive': nonInteractive, buildId: buildIdFromArg } = flags; + + const { + projectId, + privateProjectConfig: { projectDir }, + loggedIn: { graphqlClient }, + vcsClient, + } = await this.getContextAsync(FingerprintCompare, { + nonInteractive, + withServerSideEnvironment: null, + }); + if (jsonFlag) { + enableJsonOutput(); + } + + const displayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId); + let buildId: string | null = buildIdFromArg; + if (!buildId) { + if (nonInteractive) { + throw new Error('Build ID must be provided in non-interactive mode'); + } + + buildId = await selectBuildToCompareAsync(graphqlClient, projectId, displayName, { + filters: { hasFingerprint: true }, + }); + if (!buildId) { + return; + } + } + + Log.log(`Comparing fingerprints of the current project and build ${buildId}…`); + const buildWithFingerprint = await BuildQuery.withFingerprintByIdAsync(graphqlClient, buildId); + const fingerprintDebugUrl = buildWithFingerprint.fingerprint?.debugInfoUrl; + if (!fingerprintDebugUrl) { + Log.error('A fingerprint for the build could not be found.'); + return; + } + const fingerprintResponse = await fetch(fingerprintDebugUrl); + const fingerprint = (await fingerprintResponse.json()) as Fingerprint; + const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient); + const buildPlatform = buildWithFingerprint.platform; + const workflow = workflows[appPlatformToPlatform(buildPlatform)]; + + const projectFingerprint = await createFingerprintAsync(projectDir, { + workflow, + platforms: [appPlatformToString(buildPlatform)], + debug: true, + env: undefined, + }); + if (!projectFingerprint) { + Log.error('Project fingerprints can only be computed for projects with SDK 52 or higher'); + return; + } + if (fingerprint.hash === projectFingerprint.hash) { + Log.log(`✅ Project fingerprint matches build`); + return; + } else { + Log.log(`🔄 Project fingerprint differs from build`); + } + + const fingerprintDiffs = diffFingerprint(projectDir, fingerprint, projectFingerprint); + if (!fingerprintDiffs) { + Log.error('Fingerprint diffs can only be computed for projects with SDK 52 or higher'); + return; + } + + const filePathDiffs = fingerprintDiffs.filter(diff => { + let sourceType; + if (diff.op === 'added') { + sourceType = diff.addedSource.type; + } else if (diff.op === 'removed') { + sourceType = diff.removedSource.type; + } else if (diff.op === 'changed') { + sourceType = diff.beforeSource.type; + } + return sourceType === 'dir' || sourceType === 'file'; + }); + if (filePathDiffs.length > 0) { + Log.newLine(); + Log.log('📁 Paths with native dependencies:'); + } + const fields = []; + for (const diff of filePathDiffs) { + const field = getDiffFilePathFields(diff); + if (!field) { + throw new Error(`Unsupported diff: ${JSON.stringify(diff)}`); + } + fields.push(field); + } + Log.log( + formatFields(fields, { + labelFormat: label => ` ${chalk.dim(label)}:`, + }) + ); + + const contentDiffs = fingerprintDiffs.filter(diff => { + let sourceType; + if (diff.op === 'added') { + sourceType = diff.addedSource.type; + } else if (diff.op === 'removed') { + sourceType = diff.removedSource.type; + } else if (diff.op === 'changed') { + sourceType = diff.beforeSource.type; + } + return sourceType === 'contents'; + }); + + for (const diff of contentDiffs) { + printContentDiff(diff); + } + } +} + +function printContentDiff(diff: FingerprintDiffItem): void { + if (diff.op === 'added') { + const sourceType = diff.addedSource.type; + if (sourceType === 'contents') { + printContentSource({ + op: diff.op, + sourceType, + contentsId: diff.addedSource.id, + contentsAfter: diff.addedSource.contents, + }); + } + } else if (diff.op === 'removed') { + const sourceType = diff.removedSource.type; + if (sourceType === 'contents') { + printContentSource({ + op: diff.op, + sourceType, + contentsId: diff.removedSource.id, + contentsBefore: diff.removedSource.contents, + }); + } + } else if (diff.op === 'changed') { + const sourceType = diff.beforeSource.type; + if (sourceType === 'contents') { + if (diff.afterSource.type !== 'contents') { + throw new Error( + `Changed fingerprint source types must be the same, received ${diff.beforeSource.type}, ${diff.afterSource.type}` + ); + } + printContentSource({ + op: diff.op, + sourceType: diff.beforeSource.type, // before and after source types should be the same + contentsId: diff.beforeSource.id, // before and after content ids should be the same + contentsBefore: diff.beforeSource.contents, + contentsAfter: diff.afterSource.contents, + }); + } + } +} + +function getDiffFilePathFields(diff: FingerprintDiffItem): FormatFieldsItem | null { + if (diff.op === 'added') { + const sourceType = diff.addedSource.type; + if (sourceType !== 'contents') { + return getFilePathSourceFields({ + op: diff.op, + sourceType, + filePath: diff.addedSource.filePath, + }); + } + } else if (diff.op === 'removed') { + const sourceType = diff.removedSource.type; + if (sourceType !== 'contents') { + return getFilePathSourceFields({ + op: diff.op, + sourceType, + filePath: diff.removedSource.filePath, + }); + } + } else if (diff.op === 'changed') { + const sourceType = diff.beforeSource.type; + if (sourceType !== 'contents') { + return getFilePathSourceFields({ + op: diff.op, + sourceType: diff.beforeSource.type, // before and after source types should be the same + filePath: diff.beforeSource.filePath, // before and after filePaths should be the same + }); + } + } + return null; +} + +function getFilePathSourceFields({ + op, + sourceType, + filePath, +}: { + op: 'added' | 'removed' | 'changed'; + sourceType: 'dir' | 'file'; + filePath: string; +}): FormatFieldsItem { + if (sourceType === 'dir') { + if (op === 'added') { + return { label: 'new directory:', value: filePath }; + } else if (op === 'removed') { + return { label: 'removed directory:', value: filePath }; + } else if (op === 'changed') { + return { label: 'modified directory:', value: filePath }; + } + } else if (sourceType === 'file') { + if (op === 'added') { + return { label: 'new file:', value: filePath }; + } else if (op === 'removed') { + return { label: 'removed file:', value: filePath }; + } else if (op === 'changed') { + return { label: 'modified file:', value: filePath }; + } + } + throw new Error(`Unsupported source and op: ${sourceType}, ${op}`); +} + +const PRETTY_CONTENT_ID: Record = { + 'expoAutolinkingConfig:ios': 'Expo autolinking config (iOS)', + 'expoAutolinkingConfig:android': 'Expo autolinking config (Android)', + 'packageJson:scripts': 'package.json scripts', + expoConfig: 'Expo config', +}; + +function printContentSource({ + op, + contentsBefore, + contentsAfter, + contentsId, +}: { + op: 'added' | 'removed' | 'changed'; + sourceType: 'contents'; + contentsBefore?: string | Buffer; + contentsAfter?: string | Buffer; + contentsId: string; +}): void { + Log.newLine(); + const prettyContentId = PRETTY_CONTENT_ID[contentsId] ?? contentsId; + if (op === 'added') { + Log.log(`${chalk.dim('📝 new content')}: ${prettyContentId}`); + } else if (op === 'removed') { + Log.log(`${chalk.dim('📝 removed content')}: ${prettyContentId}`); + } else if (op === 'changed') { + Log.log(`${chalk.dim('📝 modified content')}: ${prettyContentId}`); + } + printContentsDiff(contentsBefore ?? '', contentsAfter ?? ''); +} + +function printContentsDiff(contents1: string | Buffer, contents2: string | Buffer): void { + const stringifiedContents1 = Buffer.isBuffer(contents1) ? contents1.toString() : contents1; + const stringifiedContents2 = Buffer.isBuffer(contents2) ? contents2.toString() : contents2; + + const isStr1JSON = isJSON(stringifiedContents1); + const isStr2JSON = isJSON(stringifiedContents2); + + const prettifiedContents1 = isStr1JSON + ? JSON.stringify(JSON.parse(stringifiedContents1), null, 2) + : stringifiedContents1; + const prettifiedContents2 = isStr2JSON + ? JSON.stringify(JSON.parse(stringifiedContents2), null, 2) + : stringifiedContents2; + + abridgedDiff(prettifiedContents1, prettifiedContents2); +} + +function isJSON(str: string): boolean { + try { + JSON.parse(str); + return true; + } catch { + return false; + } +} + +function appPlatformToPlatform(platform: AppPlatform): Platform { + switch (platform) { + case AppPlatform.Android: + return Platform.ANDROID; + case AppPlatform.Ios: + return Platform.IOS; + default: + throw new Error(`Unsupported platform: ${platform}`); + } +} + +function appPlatformToString(platform: AppPlatform): string { + switch (platform) { + case AppPlatform.Android: + return 'android'; + case AppPlatform.Ios: + return 'ios'; + default: + throw new Error(`Unsupported platform: ${platform}`); + } +} + +export async function selectBuildToCompareAsync( + graphqlClient: ExpoGraphqlClient, + projectId: string, + projectDisplayName: string, + { + filters, + }: { + filters?: { + statuses?: BuildStatus[]; + platform?: RequestedPlatform; + profile?: string; + hasFingerprint?: boolean; + }; + } = {} +): Promise { + const spinner = ora().start('Fetching builds…'); + + let builds; + try { + builds = await fetchBuildsAsync({ graphqlClient, projectId, filters }); + spinner.stop(); + } catch (error) { + spinner.fail( + `Something went wrong and we couldn't fetch the builds for the project ${projectDisplayName}.` + ); + throw error; + } + if (builds.length === 0) { + Log.warn(`No fingerprints have been computed for builds of project ${projectDisplayName}.`); + return null; + } else { + const build = await selectAsync( + 'Which build do you want to compare?', + builds.map(build => ({ + title: formatBuild(build), + value: build.id, + })) + ); + + return build; + } +} diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index dc93da030f..f19fa907df 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -711,6 +711,7 @@ export type AccountSsoConfigurationPublicDataQueryPublicDataByAccountNameArgs = }; export enum AccountUploadSessionType { + ProfileImageUpload = 'PROFILE_IMAGE_UPLOAD', WorkflowsProjectSources = 'WORKFLOWS_PROJECT_SOURCES' } @@ -2048,6 +2049,10 @@ export type AppUpdatesConnection = { pageInfo: PageInfo; }; +export enum AppUploadSessionType { + ProfileImageUpload = 'PROFILE_IMAGE_UPLOAD' +} + /** Represents Play Store/App Store version of an application */ export type AppVersion = { __typename?: 'AppVersion'; @@ -2866,6 +2871,7 @@ export type BuildFilter = { channel?: InputMaybe; distribution?: InputMaybe; gitCommitHash?: InputMaybe; + hasFingerprint?: InputMaybe; platform?: InputMaybe; runtimeVersion?: InputMaybe; sdkVersion?: InputMaybe; @@ -2877,6 +2883,7 @@ export type BuildFilterInput = { channel?: InputMaybe; developmentClient?: InputMaybe; distributions?: InputMaybe>; + hasFingerprint?: InputMaybe; platforms?: InputMaybe>; releaseChannel?: InputMaybe; runtimeVersion?: InputMaybe; @@ -6857,6 +6864,8 @@ export type UploadSession = { __typename?: 'UploadSession'; /** Create an Upload Session for a specific account */ createAccountScopedUploadSession: Scalars['JSONObject']['output']; + /** Create an Upload Session for a specific app */ + createAppScopedUploadSession: Scalars['JSONObject']['output']; /** Create an Upload Session */ createUploadSession: Scalars['JSONObject']['output']; }; @@ -6868,6 +6877,12 @@ export type UploadSessionCreateAccountScopedUploadSessionArgs = { }; +export type UploadSessionCreateAppScopedUploadSessionArgs = { + appID: Scalars['ID']['input']; + type: AppUploadSessionType; +}; + + export type UploadSessionCreateUploadSessionArgs = { type: UploadSessionType; }; @@ -9120,6 +9135,13 @@ export type BuildsWithSubmissionsByIdQueryVariables = Exact<{ export type BuildsWithSubmissionsByIdQuery = { __typename?: 'RootQuery', builds: { __typename?: 'BuildQuery', byId: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logFiles: Array, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }>, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } } } }; +export type BuildsWithFingerprintByIdQueryVariables = Exact<{ + buildId: Scalars['ID']['input']; +}>; + + +export type BuildsWithFingerprintByIdQuery = { __typename?: 'RootQuery', builds: { __typename?: 'BuildQuery', byId: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null } | null, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } } } }; + export type ViewBuildsOnAppQueryVariables = Exact<{ appId: Scalars['String']['input']; offset: Scalars['Int']['input']; @@ -9321,12 +9343,16 @@ export type BuildFragment = { __typename?: 'Build', id: string, status: BuildSta export type BuildWithSubmissionsFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logFiles: Array, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }>, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } }; +export type BuildWithFingerprintFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null } | null, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, initiatingActor?: { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string } }; + export type EnvironmentSecretFragment = { __typename?: 'EnvironmentSecret', id: string, name: string, type: EnvironmentSecretType, createdAt: any }; export type EnvironmentVariableFragment = { __typename?: 'EnvironmentVariable', id: string, name: string, value?: string | null, environments?: Array | null, createdAt: any, updatedAt: any, scope: EnvironmentVariableScope, visibility?: EnvironmentVariableVisibility | null, type: EnvironmentSecretType }; export type EnvironmentVariableWithSecretFragment = { __typename?: 'EnvironmentVariableWithSecret', id: string, name: string, value?: string | null, environments?: Array | null, createdAt: any, updatedAt: any, scope: EnvironmentVariableScope, visibility: EnvironmentVariableVisibility, type: EnvironmentSecretType }; +export type FingerprintFragment = { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null }; + export type RuntimeFragment = { __typename?: 'Runtime', id: string, version: string }; export type StatuspageServiceFragment = { __typename?: 'StatuspageService', id: string, name: StatuspageServiceName, status: StatuspageServiceStatus, incidents: Array<{ __typename?: 'StatuspageIncident', id: string, status: StatuspageIncidentStatus, name: string, impact: StatuspageIncidentImpact, shortlink: string }> }; diff --git a/packages/eas-cli/src/graphql/queries/BuildQuery.ts b/packages/eas-cli/src/graphql/queries/BuildQuery.ts index c068645771..f3a3743da0 100644 --- a/packages/eas-cli/src/graphql/queries/BuildQuery.ts +++ b/packages/eas-cli/src/graphql/queries/BuildQuery.ts @@ -5,15 +5,22 @@ import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/creat import { withErrorHandlingAsync } from '../client'; import { BuildFragment, + BuildWithFingerprintFragment, BuildWithSubmissionsFragment, BuildsByIdQuery, BuildsByIdQueryVariables, + BuildsWithFingerprintByIdQuery, + BuildsWithFingerprintByIdQueryVariables, BuildsWithSubmissionsByIdQuery, BuildsWithSubmissionsByIdQueryVariables, ViewBuildsOnAppQuery, ViewBuildsOnAppQueryVariables, } from '../generated'; -import { BuildFragmentNode, BuildFragmentWithSubmissionsNode } from '../types/Build'; +import { + BuildFragmentNode, + BuildFragmentWithFingerprintNode, + BuildFragmentWithSubmissionsNode, +} from '../types/Build'; export const BuildQuery = { async byIdAsync( @@ -76,6 +83,36 @@ export const BuildQuery = { return data.builds.byId; }, + async withFingerprintByIdAsync( + graphqlClient: ExpoGraphqlClient, + buildId: string, + { useCache = true }: { useCache?: boolean } = {} + ): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .query( + gql` + query BuildsWithFingerprintByIdQuery($buildId: ID!) { + builds { + byId(buildId: $buildId) { + id + ...BuildWithFingerprintFragment + } + } + } + ${print(BuildFragmentWithFingerprintNode)} + `, + { buildId }, + { + requestPolicy: useCache ? 'cache-first' : 'network-only', + additionalTypenames: ['Build'], + } + ) + .toPromise() + ); + + return data.builds.byId; + }, async viewBuildsOnAppAsync( graphqlClient: ExpoGraphqlClient, { appId, limit, offset, filter }: ViewBuildsOnAppQueryVariables diff --git a/packages/eas-cli/src/graphql/types/Build.ts b/packages/eas-cli/src/graphql/types/Build.ts index f85b1950a8..abf3779974 100644 --- a/packages/eas-cli/src/graphql/types/Build.ts +++ b/packages/eas-cli/src/graphql/types/Build.ts @@ -1,6 +1,7 @@ import { print } from 'graphql'; import gql from 'graphql-tag'; +import { FingerprintFragmentNode } from './Fingerprint'; import { SubmissionFragmentNode } from './Submission'; export const BuildFragmentNode = gql` @@ -72,3 +73,17 @@ export const BuildFragmentWithSubmissionsNode = gql` } } `; + +export const BuildFragmentWithFingerprintNode = gql` + ${print(FingerprintFragmentNode)} + ${print(BuildFragmentNode)} + + fragment BuildWithFingerprintFragment on Build { + id + ...BuildFragment + fingerprint { + id + ...FingerprintFragment + } + } +`; diff --git a/packages/eas-cli/src/graphql/types/Fingerprint.ts b/packages/eas-cli/src/graphql/types/Fingerprint.ts new file mode 100644 index 0000000000..ff797f7c1b --- /dev/null +++ b/packages/eas-cli/src/graphql/types/Fingerprint.ts @@ -0,0 +1,9 @@ +import gql from 'graphql-tag'; + +export const FingerprintFragmentNode = gql` + fragment FingerprintFragment on Fingerprint { + id + hash + debugInfoUrl + } +`; diff --git a/packages/eas-cli/src/utils/__tests__/fingerprintDiff-test.ts b/packages/eas-cli/src/utils/__tests__/fingerprintDiff-test.ts new file mode 100644 index 0000000000..201a25dbc7 --- /dev/null +++ b/packages/eas-cli/src/utils/__tests__/fingerprintDiff-test.ts @@ -0,0 +1,119 @@ +import Log from '../../log'; +import { abridgedDiff } from '../fingerprintDiff'; + +jest.mock('chalk', () => ({ + red: jest.fn(text => `red(${text})`), + green: jest.fn(text => `green(${text})`), + gray: jest.fn(text => `gray(${text})`), + cyan: jest.fn(text => `cyan(${text})`), +})); + +jest.spyOn(Log, 'log').mockImplementation(jest.fn()); + +describe('abridgedDiff', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should output the diff for added and removed lines with context', () => { + const str1 = 'Line1\nLine2\nLine3'; + const str2 = 'Line1\nLineX\nLine3'; + + abridgedDiff(str1, str2, 1); + + const expectedOutput = [ + 'cyan(@@ -2,1 +2,1 @@)', + ' gray(Line1)', + 'red(-Line2)', + 'green(+LineX)', + ' gray(Line3)', + ].join('\n'); + + expect(Log.log).toHaveBeenCalledTimes(1); + expect(Log.log).toHaveBeenCalledWith(expectedOutput); + }); + + it('should output the diff for completely different strings', () => { + const str1 = 'LineA\nLineB'; + const str2 = 'LineC\nLineD'; + + abridgedDiff(str1, str2, 0); + + const expectedOutput = [ + 'cyan(@@ -1,2 +1,2 @@)', + 'red(-LineA)', + 'red(-LineB)', + 'green(+LineC)', + 'green(+LineD)', + ].join('\n'); + + expect(Log.log).toHaveBeenCalledTimes(1); + expect(Log.log).toHaveBeenCalledWith(expectedOutput); + }); + + it('should not include context lines if contextLines is 0', () => { + const str1 = 'Line1\nLine2\nLine3'; + const str2 = 'Line1\nLineX\nLine3'; + + abridgedDiff(str1, str2, 0); + + const expectedOutput = ['cyan(@@ -2,1 +2,1 @@)', 'red(-Line2)', 'green(+LineX)'].join('\n'); + + expect(Log.log).toHaveBeenCalledTimes(1); + expect(Log.log).toHaveBeenCalledWith(expectedOutput); + }); + + it('should handle strings with no differences', () => { + const str1 = 'SameLine1\nSameLine2'; + const str2 = 'SameLine1\nSameLine2'; + + abridgedDiff(str1, str2, 1); + + expect(Log.log).toHaveBeenCalledTimes(1); + expect(Log.log).toHaveBeenCalledWith(''); + }); + + it('should handle empty input strings', () => { + const str1 = ''; + const str2 = 'NewLine1\nNewLine2'; + + abridgedDiff(str1, str2, 1); + + const expectedOutput = ['cyan(@@ -1,0 +1,2 @@)', 'green(+NewLine1)', 'green(+NewLine2)'].join( + '\n' + ); + + expect(Log.log).toHaveBeenCalledTimes(1); + expect(Log.log).toHaveBeenCalledWith(expectedOutput); + }); + + it('should handle strings with only removed lines', () => { + const str1 = 'OldLine1\nOldLine2'; + const str2 = ''; + + abridgedDiff(str1, str2, 1); + + const expectedOutput = ['cyan(@@ -1,2 +1,0 @@)', 'red(-OldLine1)', 'red(-OldLine2)'].join('\n'); + + expect(Log.log).toHaveBeenCalledTimes(1); + expect(Log.log).toHaveBeenCalledWith(expectedOutput); + }); + + it('should handle strings with mixed changes and context lines', () => { + const str1 = 'Header\nLine1\nLine2\nFooter'; + const str2 = 'Header\nLine1\nLineX\nFooter'; + + abridgedDiff(str1, str2, 1); + + const expectedOutput = [ + 'cyan(@@ -3,1 +3,1 @@)', + ' gray(Line1)', + 'red(-Line2)', + 'green(+LineX)', + ' gray(Footer)', + ].join('\n'); + + expect(Log.log).toHaveBeenCalledTimes(1); + expect(Log.log).toHaveBeenCalledWith(expectedOutput); + }); +}); diff --git a/packages/eas-cli/src/utils/fingerprint.ts b/packages/eas-cli/src/utils/fingerprint.ts new file mode 100644 index 0000000000..adf1a11a5c --- /dev/null +++ b/packages/eas-cli/src/utils/fingerprint.ts @@ -0,0 +1,114 @@ +/** + * DO NOT EDIT unless the same change is made in `@expo/fingerprint` + */ + +export interface HashSourceFile { + type: 'file'; + filePath: string; + + /** + * Reasons of this source coming from + */ + reasons: string[]; +} + +export interface HashSourceDir { + type: 'dir'; + filePath: string; + + /** + * Reasons of this source coming from + */ + reasons: string[]; +} + +export interface HashSourceContents { + type: 'contents'; + id: string; + contents: string | Buffer; + + /** + * Reasons of this source coming from + */ + reasons: string[]; +} + +export type HashSource = HashSourceFile | HashSourceDir | HashSourceContents; + +export interface Fingerprint { + /** + * Sources and their hash values to generate a fingerprint + */ + sources: FingerprintSource[]; + + /** + * The final hash value of the whole fingerprint + */ + hash: string; +} + +export interface DebugInfoFile { + path: string; + hash: string; +} + +export interface DebugInfoDir { + path: string; + hash: string; + children: (DebugInfoFile | DebugInfoDir | undefined)[]; +} + +export interface DebugInfoContents { + hash: string; +} + +export type DebugInfo = DebugInfoFile | DebugInfoDir | DebugInfoContents; + +export type FingerprintSource = HashSource & { + /** + * Hash value of the `source`. + * If the source is excluding by `Options.dirExcludes`, the value will be null. + */ + hash: string | null; + /** + * Debug info from the hashing process. Differs based on source type. Designed to be consumed by humans + * as opposed to programmatically. + */ + debugInfo?: DebugInfo; +}; + +export type FingerprintDiffItem = + | { + /** + * The operation type of the diff item. + */ + op: 'added'; + /** + * The added source. + */ + addedSource: FingerprintSource; + } + | { + /** + * The operation type of the diff item. + */ + op: 'removed'; + /** + * The removed source. + */ + removedSource: FingerprintSource; + } + | { + /** + * The operation type of the diff item. + */ + op: 'changed'; + /** + * The source before. + */ + beforeSource: FingerprintSource; + /** + * The source after. + */ + afterSource: FingerprintSource; + }; diff --git a/packages/eas-cli/src/utils/fingerprintCli.ts b/packages/eas-cli/src/utils/fingerprintCli.ts index 9dc6b37b31..651b26bce2 100644 --- a/packages/eas-cli/src/utils/fingerprintCli.ts +++ b/packages/eas-cli/src/utils/fingerprintCli.ts @@ -2,6 +2,7 @@ import { Env, Workflow } from '@expo/eas-build-job'; import { silent as silentResolveFrom } from 'resolve-from'; import mapMapAsync from './expodash/mapMapAsync'; +import { Fingerprint, FingerprintDiffItem } from './fingerprint'; import Log from '../log'; import { ora } from '../ora'; @@ -13,14 +14,30 @@ export type FingerprintOptions = { cwd?: string; }; +export function diffFingerprint( + projectDir: string, + fingerprint1: Fingerprint, + fingerprint2: Fingerprint +): FingerprintDiffItem[] | null { + // @expo/fingerprint is exported in the expo package for SDK 52+ + const fingerprintPath = silentResolveFrom(projectDir, 'expo/fingerprint'); + if (!fingerprintPath) { + return null; + } + + const Fingerprint = require(fingerprintPath); + return Fingerprint.diffFingerprints(fingerprint1, fingerprint2); +} + export async function createFingerprintAsync( projectDir: string, options: FingerprintOptions -): Promise<{ - hash: string; - sources: object[]; - isDebugSource: boolean; -} | null> { +): Promise< + | (Fingerprint & { + isDebugSource: boolean; + }) + | null +> { // @expo/fingerprint is exported in the expo package for SDK 52+ const fingerprintPath = silentResolveFrom(projectDir, 'expo/fingerprint'); if (!fingerprintPath) { @@ -61,11 +78,11 @@ async function createFingerprintWithoutLoggingAsync( projectDir: string, fingerprintPath: string, options: FingerprintOptions -): Promise<{ - hash: string; - sources: object[]; - isDebugSource: boolean; -}> { +): Promise< + Fingerprint & { + isDebugSource: boolean; + } +> { const Fingerprint = require(fingerprintPath); const fingerprintOptions: Record = {}; if (options.platforms) { @@ -99,9 +116,7 @@ export async function createFingerprintsByKeyAsync( ): Promise< Map< string, - { - hash: string; - sources: object[]; + Fingerprint & { isDebugSource: boolean; } > diff --git a/packages/eas-cli/src/utils/fingerprintDiff.ts b/packages/eas-cli/src/utils/fingerprintDiff.ts new file mode 100644 index 0000000000..87a2cac42c --- /dev/null +++ b/packages/eas-cli/src/utils/fingerprintDiff.ts @@ -0,0 +1,127 @@ +import chalk from 'chalk'; +import { diffLines } from 'diff'; + +import Log from '../log'; + +/** + * Computes and prints a line-based diff between two strings, displaying changes as chunks. + * + * Each chunk contains lines that were added or removed, prefixed with contextual lines + * from the unchanged parts of the strings. The output is styled similarly to `git diff` + * with headers indicating line ranges for the changes in the original and modified strings. + * + * @param {string} str1 - The original string to compare. + * @param {string} str2 - The modified string to compare against the original. + * @param {number} [contextLines=2] - The number of unchanged lines to display before and after each chunk of changes. + * + * ### Output: + * - Each chunk begins with a header in the format `@@ - + @@`. + * - Removed lines are prefixed with a red `-` and the original line number. + * - Added lines are prefixed with a green `+` and the modified line number. + * - Context lines are displayed in gray without a `+` or `-` prefix. + * + * ### Notes: + * - Consecutive changes are grouped into a single chunk if separated by no more than the specified number of context lines. + * + * ### Example: + * ```typescript + * abridgedDiff("Line1\nLine2\nLine3", "Line1\nLineX\nLine3", 1); + * + * Output: + * `@@ -2,1 +2,1 @@` + * Line1 + * -Line2 + * +LineX + * Line3 + * ``` + */ +export function abridgedDiff(str1: string, str2: string, contextLines: number = 2): void { + const changes = diffLines(str1, str2); + + const output: string[] = []; + let lineNumberOriginal = 1; + let lineNumberModified = 1; + + let currentChunk: string[] = []; + let currentChunkPriorContext: string[] = []; + let currentChunkAfterContext: string[] = []; + let startOriginal: number | null = null; // Start line in the original for the current chunk + let startModified: number | null = null; // Start line in the modified for the current chunk + let addedLines = 0; + let removedLines = 0; + + const flushChunk = (): void => { + if (currentChunk.length > 0) { + const originalRange = `${startOriginal},${removedLines || 0}`; + const modifiedRange = `${startModified},${addedLines || 0}`; + // `git diff` style header + output.push(chalk.cyan(`@@ -${originalRange} +${modifiedRange} @@`)); + output.push(...currentChunkPriorContext); + output.push(...currentChunk); + output.push(...currentChunkAfterContext); + currentChunk = []; + currentChunkPriorContext = []; + currentChunkAfterContext = []; + addedLines = 0; + removedLines = 0; + } + }; + + for (const change of changes) { + const lines = change.value.split('\n').filter(line => line); + + if (change.added || change.removed) { + // Initialize start lines for the chunk if not already set + if (startOriginal === null) { + startOriginal = lineNumberOriginal; + } + if (startModified === null) { + startModified = lineNumberModified; + } + + if (change.removed) { + lines.forEach(line => { + currentChunk.push(`${chalk.red(`-${line}`)}`); + lineNumberOriginal++; + removedLines++; + }); + } + if (change.added) { + lines.forEach(line => { + currentChunk.push(`${chalk.green(`+${line}`)}`); + lineNumberModified++; + addedLines++; + }); + } + } else { + // Unchanged lines (context) + lines.forEach((line, i) => { + if (currentChunk.length > 0) { + // Add leading context after a change + if (i < contextLines) { + currentChunkAfterContext.push(` ${chalk.gray(line)}`); + } + } else { + // Add trailing context before a change + if (lines.length - 1 - contextLines < i) { + currentChunkPriorContext.push(` ${chalk.gray(line)}`); + } + } + + const isFinalLineOfAfterContext = i === contextLines - 1 || i === lines.length - 1; + + if (currentChunk.length > 0 && isFinalLineOfAfterContext) { + flushChunk(); + } + lineNumberOriginal++; + lineNumberModified++; + }); + + startOriginal = null; + startModified = null; + } + } + + flushChunk(); // Flush any remaining chunk + Log.log(output.join('\n')); +} diff --git a/packages/eas-cli/tsconfig.json b/packages/eas-cli/tsconfig.json index 4ccda43a11..60f23404c3 100644 --- a/packages/eas-cli/tsconfig.json +++ b/packages/eas-cli/tsconfig.json @@ -10,7 +10,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "strict": true, - "target": "ES2021", + "target": "ES2022", "outDir": "build", "rootDir": "src", "typeRoots": ["../../ts-declarations", "../../node_modules/@types", "./node_modules/@types"] diff --git a/yarn.lock b/yarn.lock index 7846c2c972..4d3c6dd36e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3640,6 +3640,11 @@ resolved "https://registry.yarnpkg.com/@types/dateformat/-/dateformat-3.0.1.tgz#98d747a2e5e9a56070c6bf14e27bff56204e34cc" integrity sha512-KlPPdikagvL6ELjWsljbyDIPzNCeliYkqRpI+zea99vBBbCIA5JNshZAwQKTON139c87y9qvTFVgkFd14rtS4g== +"@types/diff@6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@types/diff/-/diff-6.0.0.tgz#031f27cf57564f3cce825f38fb19fdd4349ad07a" + integrity sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA== + "@types/envinfo@7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@types/envinfo/-/envinfo-7.8.3.tgz#6fccc3425e300ee377aad15423e555dc6fc12fa1" @@ -6097,6 +6102,11 @@ diff-sequences@^29.6.3: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== +diff@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" + integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== + diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"