Compare commits

...
Author SHA1 Message Date
Mustafa Abban bad188babe Merge branch 'main' into eas-build-valid-lock 2026-04-30 10:46:50 -05:00
Mustafa Abban f6ba878b29 Merge branch 'main' into eas-build-valid-lock 2026-03-13 12:59:07 -05:00
AbbanMustafa 07fbe5c777 fmt 2026-03-11 14:32:41 -05:00
AbbanMustafa c8739b66af changelog 2026-03-11 14:16:43 -05:00
AbbanMustafa 777a3c947f package lockfile-utils 2026-03-11 14:14:17 -05:00
AbbanMustafa ce3d17ae4c casing 2026-03-11 13:47:16 -05:00
AbbanMustafa 2f9dfb6610 env var to skip check 2026-03-11 13:45:53 -05:00
AbbanMustafa b862a0575c turtlebot 2026-03-02 16:02:30 -06:00
AbbanMustafa c1fe243a70 peer dependency conflicts 2026-03-02 15:33:39 -06:00
AbbanMustafa 8065715d4c changelog 2026-03-02 16:14:29 -05:00
AbbanMustafa 402cc40bb9 clean log 2026-03-02 15:57:11 -05:00
AbbanMustafa 61eeabb404 verify package versions 2026-03-02 15:48:01 -05:00
AbbanMustafa edbaaf8000 warn on staleness 2026-03-01 16:34:18 -05:00
AbbanMustafa 68e561e879 [eas-build] - lockfile preflight checks to eas build command 2026-02-26 12:51:07 -05:00
15 changed files with 1258 additions and 0 deletions

No files matched your search

@@ -0,0 +1,377 @@
import { Platform } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { CommonContext } from '../context';
import { checkLockfileAsync } from '../validateLockfile';
import { Client } from '../../vcs/vcs';
jest.mock('fs');
jest.mock('@expo/package-manager', () => ({
NPM_LOCK_FILE: 'package-lock.json',
YARN_LOCK_FILE: 'yarn.lock',
PNPM_LOCK_FILE: 'pnpm-lock.yaml',
BUN_LOCK_FILE: 'bun.lockb',
BUN_TEXT_LOCK_FILE: 'bun.lock',
resolveWorkspaceRoot: jest.fn(() => null),
}));
const { resolveWorkspaceRoot } = jest.requireMock<{
resolveWorkspaceRoot: jest.Mock;
}>('@expo/package-manager');
const mockProcessExit = jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
function createMockContext(
overrides: Partial<{
projectDir: string;
requiredPackageManager: string | null;
isFileIgnoredAsync: (filePath: string) => Promise<boolean>;
getRootPathAsync: () => Promise<string>;
}> = {}
): CommonContext<Platform> {
const vcsClient = {
isFileIgnoredAsync: overrides.isFileIgnoredAsync ?? (async () => false),
getRootPathAsync: overrides.getRootPathAsync ?? (async () => '/project'),
} as unknown as Client;
return {
projectDir: overrides.projectDir ?? '/project',
requiredPackageManager: overrides.requiredPackageManager ?? null,
vcsClient,
} as unknown as CommonContext<Platform>;
}
const PACKAGE_JSON_WITH_REACT = JSON.stringify({
dependencies: { react: '19.0.4', 'react-native': '~0.79.0' },
});
const NPM_LOCKFILE_SYNCED = JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.4' },
'node_modules/react-native': { version: '0.79.4' },
},
});
const NPM_LOCKFILE_STALE = JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.0' },
'node_modules/react-native': { version: '0.79.4' },
},
});
// bun.lock uses JSONC with trailing commas
const BUN_LOCKFILE_STALE = `{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"react": "19.0.4",
},
},
},
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
},
}`;
const BUN_LOCKFILE_SYNCED = `{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"react": "19.0.4",
},
},
},
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
},
}`;
const YARN_LOCKFILE_STALE = `# yarn lockfile v1
[email protected]:
version "19.0.0"
resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz"
`;
const YARN_LOCKFILE_SYNCED = `# yarn lockfile v1
[email protected]:
version "19.0.4"
resolved "https://registry.yarnpkg.com/react/-/react-19.0.4.tgz"
react-native@~0.79.0:
version "0.79.4"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.79.4.tgz"
`;
beforeEach(() => {
vol.reset();
resolveWorkspaceRoot.mockReturnValue(null);
mockProcessExit.mockClear();
delete process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK;
});
describe(checkLockfileAsync, () => {
it('skips all checks when EAS_BUILD_SKIP_LOCKFILE_CHECK is set', async () => {
process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK = '1';
// This would normally fail — no lockfile, no package manager
vol.fromJSON({ '/project/package.json': '{}' });
const ctx = createMockContext({ requiredPackageManager: null });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('exits when no package manager is detected', async () => {
vol.fromJSON({ '/project/package.json': '{}' });
const ctx = createMockContext({ requiredPackageManager: null });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('exits when npm is detected but package-lock.json is missing', async () => {
vol.fromJSON({ '/project/package.json': '{}' });
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when lockfile exists and is in sync', async () => {
vol.fromJSON({
'/project/package.json': PACKAGE_JSON_WITH_REACT,
'/project/package-lock.json': NPM_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('exits when lockfile exists but is gitignored', async () => {
vol.fromJSON({
'/project/package.json': '{}',
'/project/yarn.lock': '# yarn lockfile',
});
const ctx = createMockContext({
requiredPackageManager: 'yarn',
isFileIgnoredAsync: async () => true,
});
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('exits when conflicting lockfiles from multiple managers exist', async () => {
vol.fromJSON({
'/project/package.json': '{}',
'/project/package-lock.json': NPM_LOCKFILE_SYNCED,
'/project/yarn.lock': '# yarn lockfile',
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('exits when npm lockfile version does not satisfy package.json specifier', async () => {
vol.fromJSON({
'/project/package.json': PACKAGE_JSON_WITH_REACT,
'/project/package-lock.json': NPM_LOCKFILE_STALE,
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('exits when bun.lock version does not satisfy package.json specifier', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/bun.lock': BUN_LOCKFILE_STALE,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when bun.lock is in sync', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/bun.lock': BUN_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('exits when yarn lockfile version does not satisfy package.json specifier', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/yarn.lock': YARN_LOCKFILE_STALE,
});
const ctx = createMockContext({ requiredPackageManager: 'yarn' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when yarn lockfile is in sync', async () => {
vol.fromJSON({
'/project/package.json': PACKAGE_JSON_WITH_REACT,
'/project/yarn.lock': YARN_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'yarn' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('finds lockfile at workspace root for monorepos', async () => {
vol.fromJSON({
'/monorepo/yarn.lock': YARN_LOCKFILE_SYNCED,
'/monorepo/packages/app/package.json': PACKAGE_JSON_WITH_REACT,
});
resolveWorkspaceRoot.mockReturnValue('/monorepo');
const ctx = createMockContext({
projectDir: '/monorepo/packages/app',
requiredPackageManager: 'yarn',
getRootPathAsync: async () => '/monorepo',
});
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('does not report a conflict when both bun.lock and bun.lockb are present', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/bun.lockb': 'binary',
'/project/bun.lock': BUN_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('skips sync check for non-registry specifiers', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: {
'my-local-pkg': 'file:../local',
'my-workspace-pkg': 'workspace:*',
react: '19.0.4',
},
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.4' },
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
describe('peer dependency conflicts', () => {
it('exits when a direct dependency has an unsatisfied peer (npm)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { react: '19.0.0', 'react-dom': '19.2.4' },
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.0' },
'node_modules/react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('exits when a direct dependency has an unsatisfied peer (bun)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { react: '19.0.0', 'react-dom': '19.2.4' },
}),
'/project/bun.lock': `{
"lockfileVersion": 1,
"workspaces": { "": { "dependencies": { "react": "19.0.0", "react-dom": "19.2.4" } } },
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
"react-dom": ["[email protected]", "", { "peerDependencies": { "react": "^19.2.4" } }, "sha512-def"],
},
}`,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when peer dependencies are satisfied', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { react: '19.2.4', 'react-dom': '19.2.4' },
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.2.4' },
'node_modules/react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('skips optional peer dependencies (npm peerDependenciesMeta)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { '@0no-co/graphql.web': '1.2.0' },
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/@0no-co/graphql.web': {
version: '1.2.0',
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
peerDependenciesMeta: { graphql: { optional: true } },
},
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('skips optional peer dependencies (bun optionalPeers)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { '@0no-co/graphql.web': '1.2.0' },
}),
'/project/bun.lock': `{
"lockfileVersion": 1,
"workspaces": { "": { "dependencies": { "@0no-co/graphql.web": "1.2.0" } } },
"packages": {
"@0no-co/graphql.web": ["@0no-co/[email protected]", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-abc"],
},
}`,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
});
});
@@ -23,6 +23,7 @@ import { evaluateConfigWithEnvVarsAsync } from './evaluateConfigWithEnvVarsAsync
import { prepareIosBuildAsync } from './ios/build';
import { LocalBuildMode, LocalBuildOptions } from './local';
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
import { checkLockfileAsync } from './validateLockfile';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureRepoIsCleanAsync } from './utils/repository';
import { Analytics } from '../analytics/AnalyticsManager';
@@ -84,6 +85,7 @@ import { Client } from '../vcs/vcs';
let metroConfigValidated = false;
let sdkVersionChecked = false;
let lockfileChecked = false;
let hasWarnedAboutUsageOverages = false;
export interface BuildFlags {
@@ -414,6 +416,11 @@ async function prepareAndStartBuildAsync({
env,
});
if (!lockfileChecked) {
await checkLockfileAsync(buildCtx);
lockfileChecked = true;
}
if (!hasWarnedAboutUsageOverages && !flags.localBuildOptions.localBuildMode) {
hasWarnedAboutUsageOverages = true;
Log.newLine();
@@ -0,0 +1,231 @@
import {
BUN_LOCK_FILE,
BUN_TEXT_LOCK_FILE,
NPM_LOCK_FILE,
PNPM_LOCK_FILE,
YARN_LOCK_FILE,
resolveWorkspaceRoot,
} from '@expo/package-manager';
import { Platform } from '@expo/eas-build-job';
import {
LockfileData,
findLockfileMismatches,
findPeerDependencyConflicts,
parseLockfile,
} from '@expo/lockfile-utils';
import fs from 'fs-extra';
import path from 'path';
import { CommonContext } from './context';
import Log from '../log';
const SKIP_HINT =
'To skip this check, set the EAS_BUILD_SKIP_LOCKFILE_CHECK=1 environment variable.';
interface LockfileInfo {
/** Filename of the lockfile (e.g. 'yarn.lock') */
filename: string;
/** Package manager name for display (e.g. 'yarn') */
managerName: string;
}
const LOCKFILE_BY_MANAGER: Record<string, LockfileInfo> = {
npm: { filename: NPM_LOCK_FILE, managerName: 'npm' },
yarn: { filename: YARN_LOCK_FILE, managerName: 'yarn' },
pnpm: { filename: PNPM_LOCK_FILE, managerName: 'pnpm' },
bun: { filename: BUN_LOCK_FILE, managerName: 'bun' },
};
const ALL_LOCKFILES: { filename: string; manager: string }[] = [
{ filename: NPM_LOCK_FILE, manager: 'npm' },
{ filename: YARN_LOCK_FILE, manager: 'yarn' },
{ filename: PNPM_LOCK_FILE, manager: 'pnpm' },
{ filename: BUN_LOCK_FILE, manager: 'bun' },
{ filename: BUN_TEXT_LOCK_FILE, manager: 'bun' },
];
/**
* Find the lockfile for the given filename by checking the workspace root first
* (for monorepos), then the project directory.
*/
async function findLockfileAsync(
projectDir: string,
filename: string
): Promise<string | null> {
const workspaceRoot = resolveWorkspaceRoot(projectDir);
if (workspaceRoot) {
const workspaceLockfile = path.join(workspaceRoot, filename);
if (await fs.pathExists(workspaceLockfile)) {
return workspaceLockfile;
}
}
const projectLockfile = path.join(projectDir, filename);
if (await fs.pathExists(projectLockfile)) {
return projectLockfile;
}
return null;
}
async function readLockfileDataAsync(
projectDir: string,
lockfilePath: string,
manager: string
): Promise<{ lockfileData: LockfileData; packageJson: Record<string, unknown> } | null> {
const packageJsonPath = path.join(projectDir, 'package.json');
if (!(await fs.pathExists(packageJsonPath))) {
return null;
}
const packageJson = await fs.readJson(packageJsonPath);
const lockfileContent = await fs.readFile(lockfilePath, 'utf-8');
const lockfileData = parseLockfile(lockfileContent, manager, lockfilePath);
if (!lockfileData) {
return null;
}
return { lockfileData, packageJson };
}
/**
* Pre-flight lockfile validation for `eas build`. Catches common lockfile
* problems before uploading to the builder.
*/
export async function checkLockfileAsync<T extends Platform>(
ctx: CommonContext<T>
): Promise<void> {
if (process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK) {
Log.warn('Skipping lockfile validation (EAS_BUILD_SKIP_LOCKFILE_CHECK is set).');
return;
}
const { projectDir, requiredPackageManager, vcsClient } = ctx;
// --- 1. Lockfile existence ---
if (!requiredPackageManager) {
Log.error('No lockfile found in the project directory.');
Log.error(
'A lockfile is required to ensure deterministic dependency installation on the EAS builder.'
);
Log.error(
'Run your package manager\'s install command (e.g. "npm install") to generate one, then commit it to version control.'
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
const lockInfo = LOCKFILE_BY_MANAGER[requiredPackageManager];
if (!lockInfo) {
// Unknown package manager — nothing we can validate
return;
}
// For bun, also check the text lockfile variant
const lockfilePath =
requiredPackageManager === 'bun'
? (await findLockfileAsync(projectDir, BUN_LOCK_FILE)) ??
(await findLockfileAsync(projectDir, BUN_TEXT_LOCK_FILE))
: await findLockfileAsync(projectDir, lockInfo.filename);
if (!lockfilePath) {
Log.error(
`${lockInfo.managerName} lockfile (${lockInfo.filename}) not found.`
);
Log.error(
`Run "${requiredPackageManager} install" to generate a lockfile, then commit it to version control.`
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 2. Lockfile tracked in VCS ---
const rootDir = path.normalize(await vcsClient.getRootPathAsync());
const relativeLockfilePath = path.relative(rootDir, lockfilePath);
if (await vcsClient.isFileIgnoredAsync(relativeLockfilePath)) {
Log.error(
`${lockInfo.managerName} lockfile (${relativeLockfilePath}) is ignored by your version control system and won't be uploaded to the EAS builder.`
);
Log.error(
'Remove the lockfile from .gitignore (and .easignore, if applicable), then commit it.'
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 3. Conflicting lockfiles ---
const foundManagers = new Set<string>();
for (const { filename, manager } of ALL_LOCKFILES) {
if (await findLockfileAsync(projectDir, filename)) {
foundManagers.add(manager);
}
}
if (foundManagers.size > 1) {
const managerList = [...foundManagers].sort().join(', ');
Log.error(`Conflicting lockfiles detected from multiple package managers: ${managerList}.`);
Log.error(
'Having lockfiles from multiple package managers can cause install failures on the EAS builder. Remove the lockfiles you do not need and commit the change.'
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 4 & 5. Parse lockfile for dependency sync and peer dep checks ---
// For bun, prefer the text lockfile for parsing (bun.lockb is binary)
const parsableLockfilePath =
requiredPackageManager === 'bun'
? (await findLockfileAsync(projectDir, BUN_TEXT_LOCK_FILE)) ?? lockfilePath
: lockfilePath;
const parsed = await readLockfileDataAsync(
projectDir,
parsableLockfilePath,
requiredPackageManager
);
if (!parsed) {
return; // Can't parse — skip remaining checks
}
// --- 4. Lockfile out of sync ---
const mismatches = findLockfileMismatches(parsed.packageJson, parsed.lockfileData);
if (mismatches.length > 0) {
Log.error('Lockfile is out of sync with package.json:');
for (const m of mismatches) {
Log.error(
` ${m.name}: package.json requires ${m.specifier}, but lockfile has ${m.lockedVersion}`
);
}
Log.error(
`Run "${requiredPackageManager} install" to update your lockfile, then commit the changes.`
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 5. Peer dependency conflicts ---
const peerConflicts = findPeerDependencyConflicts(parsed.packageJson, parsed.lockfileData);
if (peerConflicts.length > 0) {
Log.error('Peer dependency conflicts detected:');
for (const c of peerConflicts) {
Log.error(
` ${c.source}@${c.sourceVersion} requires peer ${c.peer}@"${c.peerRange}", but ${c.peer}@${c.installedVersion} is installed`
);
}
Log.error(
'Update the conflicting packages to compatible versions, then run ' +
`"${requiredPackageManager} install" and commit the changes.`
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { Config } from '@jest/types';
import baseConfig from '../../jest/jest.shared.config';
const config: Config.InitialOptions = {
...baseConfig,
rootDir: 'src',
testMatch: ['**/__tests__/*.test.ts'],
clearMocks: true,
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
};
export default config;
@@ -0,0 +1,5 @@
if (process.env.NODE_ENV !== 'test') {
throw new Error("NODE_ENV environment variable must be set to 'test'.");
}
// Always mock:
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@expo/lockfile-utils",
"version": "18.0.1",
"description": "Parse npm, yarn, pnpm, and bun lockfiles and validate them against package.json for version mismatches and peer dependency conflicts",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/lockfile-utils"
},
"files": [
"dist"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"start": "yarn watch",
"watch": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
"test": "jest --config jest.config.ts",
"test:watch": "jest --config jest.config.ts --watch",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
},
"dependencies": {
"semver": "^7.6.0"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"@types/semver": "^7.5.8",
"jest": "^29.7.0",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
}
}
@@ -0,0 +1,152 @@
import {
parseLockfile,
parseNpmLock,
parseYarnLock,
parsePnpmLock,
parseBunLock,
} from '../parsers';
const NPM_LOCKFILE = JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.4' },
'node_modules/react-native': { version: '0.79.4' },
'node_modules/react/node_modules/loose-envify': { version: '1.4.0' },
},
});
const NPM_LOCKFILE_WITH_PEERS = JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.0' },
'node_modules/react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
'node_modules/@0no-co/graphql.web': {
version: '1.2.0',
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
peerDependenciesMeta: { graphql: { optional: true } },
},
},
});
const YARN_LOCKFILE = `# yarn lockfile v1
[email protected]:
version "19.0.4"
resolved "https://registry.yarnpkg.com/react/-/react-19.0.4.tgz"
react-native@~0.79.0:
version "0.79.4"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.79.4.tgz"
`;
const BUN_LOCKFILE = `{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"react": "19.0.4",
},
},
},
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
"react-dom": ["[email protected]", "", { "peerDependencies": { "react": "^19.2.4" } }, "sha512-def"],
"@0no-co/graphql.web": ["@0no-co/[email protected]", "", { "peerDependencies": { "graphql": "^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-ghi"],
},
}`;
const PNPM_LOCKFILE = `lockfileVersion: '9.0'
packages:
[email protected]:
resolution: {integrity: sha512-abc}
@types/[email protected]:
resolution: {integrity: sha512-def}
`;
describe('parseNpmLock', () => {
it('parses top-level packages', () => {
const result = parseNpmLock(NPM_LOCKFILE);
expect(result).not.toBeNull();
expect(result!.packages.get('react')?.version).toBe('19.0.4');
expect(result!.packages.get('react-native')?.version).toBe('0.79.4');
});
it('skips nested node_modules (transitive deps)', () => {
const result = parseNpmLock(NPM_LOCKFILE);
expect(result!.packages.has('loose-envify')).toBe(false);
});
it('parses peer dependencies and optional peers', () => {
const result = parseNpmLock(NPM_LOCKFILE_WITH_PEERS);
const reactDom = result!.packages.get('react-dom');
expect(reactDom?.peerDependencies).toEqual({ react: '^19.2.4' });
expect(reactDom?.optionalPeers).toBeUndefined();
const graphqlWeb = result!.packages.get('@0no-co/graphql.web');
expect(graphqlWeb?.optionalPeers?.has('graphql')).toBe(true);
});
it('returns null for invalid JSON', () => {
expect(parseNpmLock('not json')).toBeNull();
});
});
describe('parseYarnLock', () => {
it('parses package versions', () => {
const result = parseYarnLock(YARN_LOCKFILE);
expect(result).not.toBeNull();
expect(result!.packages.get('react')?.version).toBe('19.0.4');
expect(result!.packages.get('react-native')?.version).toBe('0.79.4');
});
});
describe('parsePnpmLock', () => {
it('parses package versions including scoped packages', () => {
const result = parsePnpmLock(PNPM_LOCKFILE);
expect(result).not.toBeNull();
expect(result!.packages.get('react')?.version).toBe('19.0.4');
expect(result!.packages.get('@types/react')?.version).toBe('19.0.4');
});
});
describe('parseBunLock', () => {
it('parses package versions from JSONC format', () => {
const result = parseBunLock(BUN_LOCKFILE);
expect(result).not.toBeNull();
expect(result!.packages.get('react')?.version).toBe('19.0.4');
});
it('parses peer dependencies and optional peers', () => {
const result = parseBunLock(BUN_LOCKFILE);
const reactDom = result!.packages.get('react-dom');
expect(reactDom?.peerDependencies).toEqual({ react: '^19.2.4' });
const graphqlWeb = result!.packages.get('@0no-co/graphql.web');
expect(graphqlWeb?.optionalPeers?.has('graphql')).toBe(true);
});
it('returns null for invalid content', () => {
expect(parseBunLock('not json at all {')).toBeNull();
});
});
describe('parseLockfile', () => {
it('dispatches to the correct parser by manager', () => {
expect(parseLockfile(NPM_LOCKFILE, 'npm', 'package-lock.json')).not.toBeNull();
expect(parseLockfile(YARN_LOCKFILE, 'yarn', 'yarn.lock')).not.toBeNull();
expect(parseLockfile(PNPM_LOCKFILE, 'pnpm', 'pnpm-lock.yaml')).not.toBeNull();
expect(parseLockfile(BUN_LOCKFILE, 'bun', 'bun.lock')).not.toBeNull();
});
it('returns null for binary bun.lockb', () => {
expect(parseLockfile('binary', 'bun', 'bun.lockb')).toBeNull();
});
it('returns null for unknown manager', () => {
expect(parseLockfile('{}', 'deno', 'deno.lock')).toBeNull();
});
});
@@ -0,0 +1,118 @@
import { findLockfileMismatches, findPeerDependencyConflicts } from '../validators';
import { LockfileData, PackageInfo } from '../types';
function makeLockfileData(packages: Record<string, PackageInfo>): LockfileData {
return { packages: new Map(Object.entries(packages)) };
}
describe('findLockfileMismatches', () => {
it('returns empty when lockfile is in sync', () => {
const packageJson = { dependencies: { react: '19.0.4', 'react-native': '~0.79.0' } };
const lockfile = makeLockfileData({
react: { version: '19.0.4' },
'react-native': { version: '0.79.4' },
});
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
});
it('detects version mismatch', () => {
const packageJson = { dependencies: { react: '19.0.4' } };
const lockfile = makeLockfileData({ react: { version: '19.0.0' } });
const mismatches = findLockfileMismatches(packageJson, lockfile);
expect(mismatches).toHaveLength(1);
expect(mismatches[0]).toEqual({
name: 'react',
specifier: '19.0.4',
lockedVersion: '19.0.0',
});
});
it('checks devDependencies and optionalDependencies', () => {
const packageJson = {
devDependencies: { typescript: '^5.5.0' },
optionalDependencies: { fsevents: '^2.3.0' },
};
const lockfile = makeLockfileData({
typescript: { version: '5.5.4' },
fsevents: { version: '2.3.3' },
});
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
});
it('skips non-registry specifiers', () => {
const packageJson = {
dependencies: {
'my-local': 'file:../local',
'my-workspace': 'workspace:*',
'my-git': 'git+https://github.com/foo/bar.git',
'my-github': 'github:foo/bar',
'my-latest': 'latest',
},
};
const lockfile = makeLockfileData({});
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
});
it('skips packages not found in lockfile', () => {
const packageJson = { dependencies: { 'new-dep': '^1.0.0' } };
const lockfile = makeLockfileData({});
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
});
});
describe('findPeerDependencyConflicts', () => {
it('returns empty when peers are satisfied', () => {
const packageJson = { dependencies: { react: '19.2.4', 'react-dom': '19.2.4' } };
const lockfile = makeLockfileData({
react: { version: '19.2.4' },
'react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
});
expect(findPeerDependencyConflicts(packageJson, lockfile)).toEqual([]);
});
it('detects unsatisfied peer dependency', () => {
const packageJson = { dependencies: { react: '19.0.0', 'react-dom': '19.2.4' } };
const lockfile = makeLockfileData({
react: { version: '19.0.0' },
'react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
});
const conflicts = findPeerDependencyConflicts(packageJson, lockfile);
expect(conflicts).toHaveLength(1);
expect(conflicts[0]).toEqual({
source: 'react-dom',
sourceVersion: '19.2.4',
peer: 'react',
peerRange: '^19.2.4',
installedVersion: '19.0.0',
});
});
it('skips optional peers', () => {
const packageJson = { dependencies: { '@0no-co/graphql.web': '1.2.0' } };
const lockfile = makeLockfileData({
'@0no-co/graphql.web': {
version: '1.2.0',
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
optionalPeers: new Set(['graphql']),
},
});
expect(findPeerDependencyConflicts(packageJson, lockfile)).toEqual([]);
});
it('skips peers not installed in lockfile', () => {
const packageJson = { dependencies: { 'react-dom': '19.2.4' } };
const lockfile = makeLockfileData({
'react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
});
expect(findPeerDependencyConflicts(packageJson, lockfile)).toEqual([]);
});
});
+8
View File
@@ -0,0 +1,8 @@
export { parseLockfile, parseNpmLock, parseYarnLock, parsePnpmLock, parseBunLock } from './parsers';
export { findLockfileMismatches, findPeerDependencyConflicts } from './validators';
export type {
PackageInfo,
LockfileData,
DependencyMismatch,
PeerDependencyConflict,
} from './types';
+154
View File
@@ -0,0 +1,154 @@
import { LockfileData, PackageInfo } from './types';
export function parseNpmLock(content: string): LockfileData | null {
try {
const lockfile = JSON.parse(content);
const rawPackages: Record<
string,
{
version?: string;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
}
> = lockfile.packages ?? {};
const packages = new Map<string, PackageInfo>();
for (const [key, value] of Object.entries(rawPackages)) {
if (!key.startsWith('node_modules/')) {
continue;
}
const rest = key.slice('node_modules/'.length);
// Skip nested node_modules (transitive deps)
if (rest.includes('node_modules/')) {
continue;
}
if (value.version) {
const optionalPeers = new Set<string>();
if (value.peerDependenciesMeta) {
for (const [peer, meta] of Object.entries(value.peerDependenciesMeta)) {
if (meta.optional) {
optionalPeers.add(peer);
}
}
}
packages.set(rest, {
version: value.version,
peerDependencies: value.peerDependencies,
optionalPeers: optionalPeers.size > 0 ? optionalPeers : undefined,
});
}
}
return { packages };
} catch {
return null;
}
}
export function parseYarnLock(content: string): LockfileData | null {
try {
const packages = new Map<string, PackageInfo>();
const lines = content.split('\n');
let currentPackage: string | null = null;
for (const line of lines) {
// Entry header: not indented, not a comment, ends with ':'
if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.endsWith(':')) {
// Examples:
// yarn v1: react@^19.0.0: or "react@^19.0.0":
// yarn v2+: "react@npm:^19.0.0":
const entry = line.replace(/^"/, '').replace(/:$/, '').replace(/"$/, '');
const atIndex = entry.indexOf('@', entry.startsWith('@') ? 1 : 0);
if (atIndex > 0) {
currentPackage = entry.slice(0, atIndex);
}
}
// Version line (indented): ` version "19.0.0"` or ` version: 19.0.0`
if (currentPackage) {
const versionMatch = line.match(/^\s+version:?\s+"?([^"\s]+)"?/);
if (versionMatch) {
if (!packages.has(currentPackage)) {
packages.set(currentPackage, { version: versionMatch[1] });
}
currentPackage = null;
}
}
}
// yarn.lock doesn't include peer dependency info
return { packages };
} catch {
return null;
}
}
export function parsePnpmLock(content: string): LockfileData | null {
try {
const packages = new Map<string, PackageInfo>();
// Match entries in the packages section like:
// [email protected]: or @types/[email protected]:
const packageRegex = /^ {2}((?:@[\w.-]+\/)?[\w.-]+)@(\d+[^:\s]*?):/gm;
let match;
while ((match = packageRegex.exec(content)) !== null) {
if (!packages.has(match[1])) {
packages.set(match[1], { version: match[2] });
}
}
// pnpm-lock.yaml doesn't readily expose peer deps in a parseable way without YAML
return { packages };
} catch {
return null;
}
}
export function parseBunLock(content: string): LockfileData | null {
try {
// bun.lock is JSONC (has trailing commas) — strip them for JSON.parse
const jsonContent = content.replace(/,(\s*[}\]])/g, '$1');
const lockfile = JSON.parse(jsonContent);
const rawPackages: Record<string, unknown[]> = lockfile.packages ?? {};
const packages = new Map<string, PackageInfo>();
for (const [name, value] of Object.entries(rawPackages)) {
// Format: "react": ["[email protected]", "", { peerDependencies: {...}, optionalPeers: [...] }, "sha512-..."]
if (Array.isArray(value) && typeof value[0] === 'string') {
const entry: string = value[0];
const atIndex = entry.lastIndexOf('@');
if (atIndex > 0) {
const meta = (typeof value[2] === 'object' && value[2] !== null ? value[2] : {}) as {
peerDependencies?: Record<string, string>;
optionalPeers?: string[];
};
packages.set(name, {
version: entry.slice(atIndex + 1),
peerDependencies: meta.peerDependencies,
optionalPeers: meta.optionalPeers ? new Set(meta.optionalPeers) : undefined,
});
}
}
}
return { packages };
} catch {
return null;
}
}
export function parseLockfile(
content: string,
manager: string,
lockfilePath: string
): LockfileData | null {
switch (manager) {
case 'npm':
return parseNpmLock(content);
case 'yarn':
return parseYarnLock(content);
case 'pnpm':
return parsePnpmLock(content);
case 'bun':
// bun.lockb is binary — can only parse the text variant
if (lockfilePath.endsWith('.lockb')) {
return null;
}
return parseBunLock(content);
default:
return null;
}
}
+26
View File
@@ -0,0 +1,26 @@
export interface PackageInfo {
version: string;
peerDependencies?: Record<string, string>;
optionalPeers?: Set<string>;
}
export interface LockfileData {
packages: Map<string, PackageInfo>;
}
export interface DependencyMismatch {
name: string;
specifier: string;
lockedVersion: string;
}
export interface PeerDependencyConflict {
/** Package that declares the peer dependency */
source: string;
sourceVersion: string;
/** The peer dependency that is not satisfied */
peer: string;
peerRange: string;
/** The actual version of the peer in the lockfile */
installedVersion: string;
}
+92
View File
@@ -0,0 +1,92 @@
import semver from 'semver';
import { DependencyMismatch, LockfileData, PeerDependencyConflict } from './types';
function isNonRegistrySpecifier(specifier: string): boolean {
return (
specifier.startsWith('file:') ||
specifier.startsWith('link:') ||
specifier.startsWith('workspace:') ||
specifier.startsWith('git:') ||
specifier.startsWith('git+') ||
specifier.startsWith('github:') ||
specifier.startsWith('http:') ||
specifier.startsWith('https:') ||
specifier.startsWith('portal:') ||
specifier === '*' ||
specifier === 'latest' ||
specifier === 'next'
);
}
export function findLockfileMismatches(
packageJson: Record<string, unknown>,
lockfileData: LockfileData
): DependencyMismatch[] {
const allDeps: Record<string, string> = {
...(packageJson.dependencies as Record<string, string> | undefined),
...(packageJson.devDependencies as Record<string, string> | undefined),
...(packageJson.optionalDependencies as Record<string, string> | undefined),
};
const mismatches: DependencyMismatch[] = [];
for (const [name, specifier] of Object.entries(allDeps)) {
if (isNonRegistrySpecifier(specifier)) {
continue;
}
const pkg = lockfileData.packages.get(name);
if (!pkg) {
continue; // Package not found in lockfile — may be a new dep or hoisted differently
}
if (!semver.satisfies(pkg.version, specifier)) {
mismatches.push({ name, specifier, lockedVersion: pkg.version });
}
}
return mismatches;
}
export function findPeerDependencyConflicts(
packageJson: Record<string, unknown>,
lockfileData: LockfileData
): PeerDependencyConflict[] {
// Check peer deps of direct dependencies only (most common source of build failures)
const directDeps: Record<string, string> = {
...(packageJson.dependencies as Record<string, string> | undefined),
...(packageJson.devDependencies as Record<string, string> | undefined),
};
const conflicts: PeerDependencyConflict[] = [];
for (const depName of Object.keys(directDeps)) {
const pkg = lockfileData.packages.get(depName);
if (!pkg?.peerDependencies) {
continue;
}
for (const [peer, peerRange] of Object.entries(pkg.peerDependencies)) {
// Skip optional peers
if (pkg.optionalPeers?.has(peer)) {
continue;
}
const peerPkg = lockfileData.packages.get(peer);
if (!peerPkg) {
continue; // Peer not installed — might be optional or provided by a parent
}
if (!semver.satisfies(peerPkg.version, peerRange)) {
conflicts.push({
source: depName,
sourceVersion: pkg.version,
peer,
peerRange,
installedVersion: peerPkg.version,
});
}
}
}
return conflicts;
}
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/**/__mocks__/**/*.ts", "src/**/__tests__/**/*.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"moduleResolution": "node",
"target": "es2022",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"composite": true,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*"]
}
Generated
+16
View File
@@ -3270,6 +3270,21 @@ __metadata:
languageName: node
linkType: hard
"@expo/lockfile-utils@npm:18.0.1, @expo/lockfile-utils@workspace:packages/lockfile-utils":
version: 0.0.0-use.local
resolution: "@expo/lockfile-utils@workspace:packages/lockfile-utils"
dependencies:
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
"@types/semver": "npm:^7.5.8"
jest: "npm:^29.7.0"
semver: "npm:^7.6.0"
ts-jest: "npm:^29.1.4"
typescript: "npm:^5.5.4"
languageName: unknown
linkType: soft
"@expo/logger@npm:18.0.1, @expo/logger@workspace:packages/logger":
"@expo/logger@npm:18.5.0, @expo/logger@workspace:packages/logger":
version: 0.0.0-use.local
resolution: "@expo/logger@workspace:packages/logger"
@@ -11006,6 +11021,7 @@ __metadata:
"@expo/eas-json": "npm:18.8.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/lockfile-utils": "npm:18.0.1"
"@expo/logger": "npm:18.5.0"
"@expo/multipart-body-parser": "npm:2.0.0"
"@expo/osascript": "npm:2.1.4"