scripts/checkRepackBabelAllowlist.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bun
/**
* CI guard for the Re.Pack babel allowlist (config/repack/babelPackages.mjs).
*
* The native build routes only allowlisted packages through babel + hermes-parser; everything else
* takes the fast OXC/SWC path, which can't handle Flow syntax or the RN codegen markers. A
* dependency change that introduces such a package otherwise surfaces as a native build failure or
* an app-launch crash — this script fails the PR instead, naming the package and the evidence file.
*
* Run from the repo root: bun ./scripts/checkRepackBabelAllowlist.ts
*/
import {isRecord} from '@libs/ObjectUtils';
import fs from 'node:fs';
import path from 'node:path';
// The explicit .mjs extension is required for Node/bun ESM resolution of this JS module.
// eslint-disable-next-line import/extensions
import BABEL_PACKAGES from '../config/repack/babelPackages.mjs';
/** Why a package needs the babel path. */
type BabelNeed = 'flow' | 'codegen';
type ScanFinding = {
packageName: string;
need: BabelNeed;
/** node_modules-relative path of the file that triggered the finding. */
file: string;
};
/** Only the top of a file is searched for the Flow pragma, matching how Flow itself reads it. */
const FLOW_PRAGMA_WINDOW_BYTES = 1024;
/**
* `@flow` anywhere in the leading window, not just directly after the comment opener: React Native's
* own header puts a licence block first. A false positive only costs a package the slower path, while
* a miss breaks the build, so this errs wide.
*/
const FLOW_PRAGMA_REGEX = /@flow(?:\s|$)/m;
/** Fabric codegen markers whose transform only the RN babel preset provides. */
const CODEGEN_MARKERS = ['codegenNativeComponent', 'codegenNativeCommands'];
/**
* Several packages ship their codegen specs as `.ts` inside `lib/` and the bundler resolves them,
* so TypeScript has to be scanned too. `.d.ts` is excluded: declarations are never bundled.
*/
const SCANNED_EXTENSIONS = new Set(['.js', '.cjs', '.mjs', '.jsx', '.ts', '.tsx']);
/**
* Build-time tooling that ships to node_modules as a prod dependency but is never resolved into
* the native app bundle. Some implement codegen detection themselves (regexes/strings); others are
* genuinely Flow-typed but only ever run on the build machine. Verified against a real bundle:
* neither @expo/metro-file-map nor jsc-safe-url appears in it.
*/
const TOOLING_PACKAGE_PREFIXES = ['@expo/cli', '@expo/metro-file-map', '@react-native-community/cli', 'expo-modules-autolinking', '@react-native/codegen', 'jsc-safe-url'];
function isToolingPackage(packageName: string): boolean {
return TOOLING_PACKAGE_PREFIXES.some((prefix) => packageName === prefix || packageName.startsWith(`${prefix}-`) || packageName.startsWith(`${prefix}/`));
}
/**
* True when `packageName` is covered by the allowlist. A scope entry (e.g. '@react-native')
* covers every package in the scope, mirroring how the rspack rule's regex matches paths.
*/
function isAllowlisted(packageName: string, allowlist: string[]): boolean {
return allowlist.some((entry) => packageName === entry || (entry.startsWith('@') && !entry.includes('/') && packageName.startsWith(`${entry}/`)));
}
/** Recursively collect relative JS entry paths from a package.json `exports` value. */
function collectExportPaths(exportsValue: unknown, accumulator: string[]): void {
if (typeof exportsValue === 'string') {
accumulator.push(exportsValue);
return;
}
if (Array.isArray(exportsValue)) {
for (const entry of exportsValue) {
collectExportPaths(entry, accumulator);
}
return;
}
if (typeof exportsValue === 'object' && exportsValue !== null) {
for (const entry of Object.values(exportsValue)) {
collectExportPaths(entry, accumulator);
}
}
}
/**
* Directories of a package that ship runtime JS, derived from its package.json entry fields.
* Scanning only these avoids false positives from Flow-typed `src/` trees that are published
* alongside compiled output but never resolved by the bundler. Returns package-relative
* directories; ['.'] means the whole package must be scanned (entry at the root or no entry data).
*/
function getShippedDirectories(packageJson: Record<string, unknown>): string[] {
const entryPaths: string[] = [];
for (const field of ['main', 'module', 'react-native', 'browser']) {
const value = packageJson[field];
if (typeof value === 'string') {
entryPaths.push(value);
}
}
collectExportPaths(packageJson.exports, entryPaths);
const directories = new Set<string>();
for (const entryPath of entryPaths) {
if (!/\.[cm]?jsx?$/.test(entryPath) && !/\/[^.]+$/.test(entryPath)) {
continue;
}
const normalized = path.normalize(entryPath).replace(/^\.\//, '');
const [topLevelDirectory] = normalized.split(path.sep);
if (!topLevelDirectory || topLevelDirectory.includes('.') || topLevelDirectory === '*') {
// Entry file sits at the package root — we can't narrow the scan.
return ['.'];
}
directories.add(topLevelDirectory);
}
return directories.size > 0 ? [...directories] : ['.'];
}
function needsBabelReason(filePath: string): BabelNeed | null {
let content: string;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch {
return null;
}
if (FLOW_PRAGMA_REGEX.test(content.slice(0, FLOW_PRAGMA_WINDOW_BYTES))) {
return 'flow';
}
if (CODEGEN_MARKERS.some((marker) => content.includes(marker))) {
return 'codegen';
}
return null;
}
/** First babel-requiring file under `directory`, searched depth-first. */
function scanDirectory(directory: string, packageRoot: string): {need: BabelNeed; file: string} | null {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(directory, {withFileTypes: true});
} catch {
return null;
}
for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name === '__mocks__') {
continue;
}
const found = scanDirectory(entryPath, packageRoot);
if (found) {
return found;
}
} else if (
entry.isFile() &&
SCANNED_EXTENSIONS.has(path.extname(entry.name)) &&
!entry.name.endsWith('.js.flow') &&
!entry.name.endsWith('.flow.js') &&
!entry.name.endsWith('.d.ts')
) {
const need = needsBabelReason(entryPath);
if (need) {
return {need, file: path.relative(packageRoot, entryPath)};
}
}
}
return null;
}
/** Scan one installed package; null when it doesn't need the babel path. */
function scanPackage(packageDirectory: string, packageName: string): ScanFinding | null {
let packageJson: Record<string, unknown>;
try {
const parsed: unknown = JSON.parse(fs.readFileSync(path.join(packageDirectory, 'package.json'), 'utf8'));
if (!isRecord(parsed)) {
return null;
}
packageJson = parsed;
} catch {
return null;
}
for (const shippedDirectory of getShippedDirectories(packageJson)) {
const found = scanDirectory(path.resolve(packageDirectory, shippedDirectory), packageDirectory);
if (found) {
return {packageName, need: found.need, file: path.join(shippedDirectory === '.' ? '' : '', found.file)};
}
}
return null;
}
/** Names of all installed packages (scoped included), sorted. */
function listInstalledPackages(nodeModulesPath: string): string[] {
const names: string[] = [];
for (const entry of fs.readdirSync(nodeModulesPath, {withFileTypes: true})) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
continue;
}
if (entry.name.startsWith('.')) {
continue;
}
if (entry.name.startsWith('@')) {
const scopePath = path.join(nodeModulesPath, entry.name);
for (const scopedEntry of fs.readdirSync(scopePath, {withFileTypes: true})) {
if (scopedEntry.isDirectory() || scopedEntry.isSymbolicLink()) {
names.push(`${entry.name}/${scopedEntry.name}`);
}
}
} else {
names.push(entry.name);
}
}
return names.sort();
}
/**
* Names of packages that some prod (non-dev) install instance in the lockfile resolves to.
* Dev-only packages can't end up in the native bundle, so the scan skips them. Nested installs
* (node_modules/a/node_modules/b) count under their bare name. Returns null when the lockfile
* can't be read — callers then scan everything rather than silently skipping packages.
*/
function loadProdPackageNames(lockfilePath: string): Set<string> | null {
let packages: Record<string, unknown>;
try {
const parsed: unknown = JSON.parse(fs.readFileSync(lockfilePath, 'utf8'));
if (!isRecord(parsed) || !isRecord(parsed.packages)) {
return null;
}
packages = parsed.packages;
} catch {
return null;
}
const prodNames = new Set<string>();
for (const [installPath, entry] of Object.entries(packages)) {
if (!installPath || (isRecord(entry) && entry.dev === true)) {
continue;
}
const marker = installPath.lastIndexOf('node_modules/');
if (marker === -1) {
continue;
}
prodNames.add(installPath.slice(marker + 'node_modules/'.length));
}
return prodNames;
}
/**
* Packages under `nodeModulesPath` that need the babel path but are missing from `allowlist`.
* This is the CI guard's core: a non-empty result means a dependency change would break the
* native build or crash the app at launch (see config/repack/babelPackages.mjs).
*/
function findMissingAllowlistEntries(nodeModulesPath: string, allowlist: string[], prodPackageNames: Set<string> | null = null): ScanFinding[] {
const findings: ScanFinding[] = [];
for (const packageName of listInstalledPackages(nodeModulesPath)) {
if (isAllowlisted(packageName, allowlist) || isToolingPackage(packageName)) {
continue;
}
if (prodPackageNames && !prodPackageNames.has(packageName)) {
continue;
}
const finding = scanPackage(path.join(nodeModulesPath, packageName), packageName);
if (finding) {
findings.push(finding);
}
}
return findings;
}
function main() {
const projectRoot = path.resolve(__dirname, '..');
const nodeModulesPath = path.join(projectRoot, 'node_modules');
const prodPackageNames = loadProdPackageNames(path.join(projectRoot, 'package-lock.json'));
if (!prodPackageNames) {
console.warn('⚠️ Could not read package-lock.json — scanning all installed packages, dev dependencies included.');
}
const missing = findMissingAllowlistEntries(nodeModulesPath, BABEL_PACKAGES, prodPackageNames);
if (missing.length === 0) {
console.log(`✅ All packages needing the babel path are allowlisted (${BABEL_PACKAGES.length} entries).`);
process.exit(0);
}
console.error('❌ Packages that need the Re.Pack babel path but are missing from config/repack/babelPackages.mjs:\n');
for (const finding of missing) {
const reason = finding.need === 'flow' ? 'ships Flow-typed runtime JS' : 'uses RN codegen markers (codegenNativeComponent/Commands)';
console.error(` - ${finding.packageName}: ${reason}\n evidence: node_modules/${finding.packageName}/${finding.file}`);
}
console.error('\nAdd each package to BABEL_PACKAGES in config/repack/babelPackages.mjs (with a comment on why), or exclude the offending file from the native bundle.');
process.exit(1);
}
if (import.meta.main) {
main();
}
export type {ScanFinding, BabelNeed};
export {findMissingAllowlistEntries, isAllowlisted, getShippedDirectories, scanPackage, listInstalledPackages, loadProdPackageNames, isToolingPackage};