nativekeel 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -27
- package/package.json +1 -1
- package/src/analyze.js +2 -2
- package/src/plan.js +1 -1
- package/src/project.js +32 -0
- package/src/report-html.js +1 -1
- package/src/security.js +65 -1
- package/src/stability.js +83 -0
package/README.md
CHANGED
|
@@ -6,36 +6,46 @@ Health check for React Native and Expo apps. One command, no install, no account
|
|
|
6
6
|
npx nativekeel
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
**What's new in 0.1.
|
|
10
|
-
|
|
11
|
-
It answers the questions that decide whether your app can still ship:
|
|
12
|
-
|
|
13
|
-
- **Is your React Native / Expo version still supported?** React Native supports the latest minor and the two before it.
|
|
14
|
-
- **Can you upgrade at all?** The legacy architecture was removed in 0.82. If the New Architecture is off, you are stuck at 0.81.
|
|
15
|
-
- **Which packages block the upgrade?** Native modules without New Architecture support, unmaintained or with no release in years, with known alternatives. Outdated "unmaintained" flags are cross-checked against the latest npm release.
|
|
16
|
-
- **What can you simply delete?** Packages that are installed but never imported, referenced natively or required by another package. Every one you remove is one you never upgrade.
|
|
17
|
-
- **Will the stores accept your next build?** Google Play `targetSdk` and 16 KB page size (it opens your built APK/AAB and checks the alignment of every native library), and the iOS privacy manifest.
|
|
18
|
-
- **Are secrets shipped inside your app?** AWS, Stripe, OpenAI, Anthropic, GitHub, Slack, SendGrid, Twilio, Shopify, Google OAuth keys, private keys and Supabase `service_role` keys (anon keys are fine), plus `.env` values that `react-native-config` or `EXPO_PUBLIC_` compile into the bundle. Server-side folders are reported separately.
|
|
19
|
-
- **Is transport security off?** Cleartext HTTP or `debuggable` in the release Android manifest, `NSAllowsArbitraryLoads` on iOS.
|
|
20
|
-
- **Do you ship known vulnerabilities?** Advisories from the GitHub Advisory Database for the exact versions in your lockfile or `node_modules`. Advisories that only affect Node.js servers are kept, at low severity.
|
|
21
|
-
- **Can someone break in?** Grouped by OWASP MASVS: Firebase Realtime Database/Firestore/Storage rules open to anyone (or in test mode), TLS certificate checks turned off in native code, hardcoded encryption keys, MD5/SHA-1 on passwords, ECB mode, `Math.random` for nonces and salts. These are static checks: they catch the common, detectable mistakes, not everything a penetration test of the running app would.
|
|
22
|
-
- **Can someone read your users' data?** Passwords that flow into crash reports, analytics, remote databases or plain AsyncStorage (followed through intermediate variables), WebViews that let page scripts read local files or load HTTP into HTTPS, Android backups that copy tokens, Android components any app can start, API calls over plain HTTP, auth tokens in unencrypted storage.
|
|
23
|
-
- **Is it slow or heavy?** A FlatList inside a ScrollView (no virtualization), animations with `useNativeDriver: false`, `console.log` left in release builds, whole-library `lodash`/`moment` imports, images over 500 KB that the app requires. Static signs to measure on a device, not a profiler.
|
|
24
|
-
- **Can it be taken apart?** Source maps packaged into the app, Hermes or R8 off in release (OWASP MASVS-RESILIENCE). Obfuscation only slows an attacker down; keep secrets and checks on the server.
|
|
25
|
-
- **Will it crash at runtime?** Reanimated without its Babel plugin or `react-native-worklets`, mixed `@react-native-firebase/*` or `@react-navigation/*` majors, a second copy of React or React Native inside a dependency, Kotlin modules that cannot load through the New Architecture interop layer, AppDelegate setups that crash in Release, camera/photos/location access without the Info.plist description iOS requires.
|
|
26
|
-
- **Leftovers from old templates?** Flipper, JavaScriptCore instead of Hermes.
|
|
9
|
+
**What's new in 0.1.15:** run it at a monorepo root and it lists the app folders to scan. **0.1.14:** secrets in Expo config (`extra`, `EXPO_PUBLIC_`, `eas.json`) and imports of APIs removed from React Native core. Recent releases added a *Start here* list in every report, hacking risks mapped to OWASP MASVS, crash and performance checks. Every check is tuned on 280+ open-source React Native and Expo apps to keep false alarms out. [All releases →](https://nativekeel.com/changelog)
|
|
27
10
|
|
|
28
11
|
```
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
Likely upgrade blocker. Alternatives: expo-image.
|
|
12
|
+
Summary 4 critical · 5 high · 4 medium · 6 low
|
|
13
|
+
Security (OWASP MASVS) crypto 1 · network 1 · 7 groups checked
|
|
14
|
+
|
|
15
|
+
Start here
|
|
16
|
+
1. Revoke the AWS access key ID in src/App.js:6 at the provider today.
|
|
17
|
+
2. Set targetSdkVersion = 36 and compileSdkVersion = 36 in android/build.gradle.
|
|
18
|
+
3. Run pod install, commit the generated PrivacyInfo.xcprivacy, and add your data types.
|
|
37
19
|
```
|
|
38
20
|
|
|
21
|
+
## What it checks
|
|
22
|
+
|
|
23
|
+
**Upgrade**
|
|
24
|
+
- **Is your React Native / Expo version still supported?** React Native supports the latest three minors; in Expo projects React Native moves with the SDK.
|
|
25
|
+
- **Can you upgrade at all?** The legacy architecture was removed in 0.82. If the New Architecture is off (it is off by default before 0.76), you are stuck at 0.81.
|
|
26
|
+
- **Which packages block the upgrade?** Native modules without New Architecture support (cross-checked with the package's own codegen spec), unmaintained or with no release in years, with verified alternatives and renamed packages. Library versions for each step come from the libraries' own compatibility tables.
|
|
27
|
+
- **What can you simply delete?** Installed packages that nothing imports, references natively, requires or patches.
|
|
28
|
+
|
|
29
|
+
**Store**
|
|
30
|
+
- **Will Google Play and the App Store accept your next build?** `targetSdk`, 16 KB memory pages (it also opens your built APK/AAB and checks every native library), the iOS privacy manifest, and missing Info.plist usage descriptions.
|
|
31
|
+
|
|
32
|
+
**Crashes**
|
|
33
|
+
- **Will it crash at runtime?** Reanimated without its Babel plugin or `react-native-worklets`, mixed `@react-native-firebase/*` or `@react-navigation/*` majors, a second copy of React or React Native inside a dependency, Kotlin modules that cannot load through the New Architecture interop layer, AppDelegate setups that crash in Release, camera/photos/location access without the Info.plist text iOS requires, imports of APIs removed from React Native core (`AsyncStorage`, `Picker`, `ViewPropTypes`, …), and traps learned from real upgrades.
|
|
34
|
+
|
|
35
|
+
**Security, grouped by OWASP MASVS**
|
|
36
|
+
- **Secrets in the app or the repo:** AWS, Stripe, OpenAI, Anthropic, GitHub, Slack, SendGrid, Twilio, Shopify, Google OAuth and Supabase `service_role` keys, private keys, `.env` values compiled into the bundle, Expo `extra`/`EXPO_PUBLIC_`/`eas.json` secrets, signing keys and passwords.
|
|
37
|
+
- **Data on the device:** passwords that flow into crash reports, analytics, databases or plain AsyncStorage (followed through variables), auth tokens in unencrypted storage, Android backups that copy them.
|
|
38
|
+
- **Network:** TLS certificate checks turned off in native code, cleartext HTTP, `NSAllowsArbitraryLoads`, plain-HTTP API calls, user-installed CAs in release.
|
|
39
|
+
- **Backend rules:** Firebase Realtime Database, Firestore and Storage rules open to anyone or in test mode.
|
|
40
|
+
- **Cryptography:** hardcoded encryption keys, MD5/SHA-1 on passwords, ECB mode, `Math.random` for nonces and salts.
|
|
41
|
+
- **Platform:** WebViews that read local files or load HTTP into HTTPS, Android components any app can start.
|
|
42
|
+
- **Dependencies and reverse engineering:** known vulnerabilities in the exact versions you ship (GitHub Advisory Database), source maps in the app, Hermes or R8 off.
|
|
43
|
+
|
|
44
|
+
These are static checks: they catch the common, detectable mistakes, not everything a penetration test of the running app and backend would.
|
|
45
|
+
|
|
46
|
+
**Performance**
|
|
47
|
+
- **Is it slow or heavy?** A FlatList inside a ScrollView (no virtualization), animations with `useNativeDriver: false`, `console.log` left in release builds, whole-library `lodash`/`moment` imports, images over 500 KB that the app requires.
|
|
48
|
+
|
|
39
49
|
## Usage
|
|
40
50
|
|
|
41
51
|
```sh
|
|
@@ -54,7 +64,7 @@ Everything is free. The scan exits with code `1` when it finds a critical issue,
|
|
|
54
64
|
|
|
55
65
|
### Upgrade plan
|
|
56
66
|
|
|
57
|
-
`nativekeel plan` turns the report into ordered work: stop leaks
|
|
67
|
+
`nativekeel plan` turns the report into ordered work: stop leaks, set up a safety net of UI flows, fix crash risks, meet store deadlines, close security gaps, replace blocking packages, switch on the New Architecture at the right point, then upgrade React Native (or the Expo SDK) in small, reviewable hops with links to the Upgrade Helper diffs and the library versions each hop needs. Performance fixes and routine updates come last.
|
|
58
68
|
|
|
59
69
|
### GitHub Action
|
|
60
70
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nativekeel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Health check for React Native and Expo apps: upgrade blockers, store deadlines, crash risks, vulnerable dependencies and leaked secrets, with a step-by-step upgrade plan.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/analyze.js
CHANGED
|
@@ -6,7 +6,7 @@ import { scanSecrets } from './secrets.js';
|
|
|
6
6
|
import { nativeChecks } from './native.js';
|
|
7
7
|
import { stabilityChecks } from './stability.js';
|
|
8
8
|
import { performanceChecks } from './performance.js';
|
|
9
|
-
import { vulnerableDependencies, passwordLeaks, webViewRisks, androidBackup, exportedComponents, plainHttpCalls, cloudRules, insecureTls, weakCrypto, tokenStorage, reverseEngineering, masvsOf, MASVS_GROUPS } from './security.js';
|
|
9
|
+
import { vulnerableDependencies, passwordLeaks, webViewRisks, androidBackup, exportedComponents, plainHttpCalls, cloudRules, insecureTls, weakCrypto, tokenStorage, reverseEngineering, expoConfigSecrets, masvsOf, MASVS_GROUPS } from './security.js';
|
|
10
10
|
import { lockedVersions } from './lockfile.js';
|
|
11
11
|
import { findUnused, filesImportingWith } from './usage.js';
|
|
12
12
|
import { matchKnownIssues, compareVersions } from './known-issues.js';
|
|
@@ -408,7 +408,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
408
408
|
for (const f of performanceChecks(project)) add(f);
|
|
409
409
|
|
|
410
410
|
// Security beyond leaked keys.
|
|
411
|
-
for (const f of [...passwordLeaks(project.root), ...webViewRisks(project.root), ...androidBackup(project.root), ...exportedComponents(project.root), ...plainHttpCalls(project.root), ...cloudRules(project.root, project.deps, now), ...insecureTls(project.root), ...weakCrypto(project.root), ...tokenStorage(project.root), ...reverseEngineering(project.root, project)]) add(f);
|
|
411
|
+
for (const f of [...passwordLeaks(project.root), ...webViewRisks(project.root), ...androidBackup(project.root), ...exportedComponents(project.root), ...plainHttpCalls(project.root), ...cloudRules(project.root, project.deps, now), ...insecureTls(project.root), ...weakCrypto(project.root), ...tokenStorage(project.root), ...reverseEngineering(project.root, project), ...expoConfigSecrets(project.root)]) add(f);
|
|
412
412
|
if (!forcedOffline) {
|
|
413
413
|
// Exact versions only (installed, else the lockfile): a range would be a guess.
|
|
414
414
|
const locked = lockedVersions(project.root, project.deps);
|
package/src/plan.js
CHANGED
|
@@ -586,7 +586,7 @@ export function buildPlan(result) {
|
|
|
586
586
|
export function planToMarkdown(result, plan) {
|
|
587
587
|
const p = result.project;
|
|
588
588
|
const lines = [
|
|
589
|
-
`# Upgrade plan: ${p.name || 'your app'}`,
|
|
589
|
+
`# ${plan.phases.some((ph) => /^Upgrade (React Native|Expo SDK)/.test(ph.title)) ? 'Upgrade plan' : 'Action plan'}: ${p.name || 'your app'}`,
|
|
590
590
|
'',
|
|
591
591
|
`Generated by NativeKeel on ${result.scannedAt.slice(0, 10)}. `,
|
|
592
592
|
`React Native ${p.reactNative || '-'}${p.expo ? ` · Expo ${p.expo}` : ''} → latest ${result.latest.reactNative || '-'}. `,
|
package/src/project.js
CHANGED
|
@@ -126,6 +126,10 @@ export function loadProject(root) {
|
|
|
126
126
|
const devDeps = resolveCatalogRefs({ ...(pkg.devDependencies || {}) }, catalogs);
|
|
127
127
|
const declared = (name) => deps[name] || devDeps[name];
|
|
128
128
|
if (!declared('react-native') && !declared('expo')) {
|
|
129
|
+
const apps = findAppFolders(root);
|
|
130
|
+
if (apps.length) {
|
|
131
|
+
throw new Error(`This folder is not a React Native app, but it contains ${apps.length === 1 ? 'one' : apps.length}:\n${apps.slice(0, 8).map((a) => ` npx nativekeel ${a}`).join('\n')}${apps.length > 8 ? '\n …' : ''}`);
|
|
132
|
+
}
|
|
129
133
|
throw new Error('This does not look like a React Native app (no react-native or expo dependency). Run it inside your app folder.');
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -193,3 +197,31 @@ function detectAndroid(root) {
|
|
|
193
197
|
};
|
|
194
198
|
return { targetSdk: num('targetSdk'), compileSdk: num('compileSdk'), minSdk: num('minSdk') };
|
|
195
199
|
}
|
|
200
|
+
|
|
201
|
+
// React Native / Expo app folders below `root` (monorepos: apps/mobile, packages/app, …), as
|
|
202
|
+
// relative paths. Libraries (react-native as a peer) are left out.
|
|
203
|
+
export function findAppFolders(root, maxDepth = 3) {
|
|
204
|
+
const out = [];
|
|
205
|
+
const skip = new Set(['node_modules', '.git', 'Pods', 'build', 'dist', '.expo', 'android', 'ios', 'vendor', 'example', 'examples', 'docs', 'website', 'e2e', 'test', 'tests']);
|
|
206
|
+
const visit = (dir, depth) => {
|
|
207
|
+
if (depth > maxDepth) return;
|
|
208
|
+
let entries = [];
|
|
209
|
+
try {
|
|
210
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
211
|
+
} catch {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (const e of entries) {
|
|
215
|
+
if (!e.isDirectory() || skip.has(e.name) || e.name.startsWith('.')) continue;
|
|
216
|
+
const sub = path.join(dir, e.name);
|
|
217
|
+
const pkg = readJson(path.join(sub, 'package.json'));
|
|
218
|
+
const deps = pkg ? { ...pkg.dependencies, ...pkg.devDependencies } : {};
|
|
219
|
+
const isApp = pkg && (deps['react-native'] || deps.expo) && !(pkg.peerDependencies && pkg.peerDependencies['react-native']) &&
|
|
220
|
+
['android', 'ios', 'app.json', 'app.config.js', 'app.config.ts'].some((f) => fs.existsSync(path.join(sub, f)));
|
|
221
|
+
if (isApp) out.push(path.relative(root, sub));
|
|
222
|
+
else visit(sub, depth + 1);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
visit(root, 1);
|
|
226
|
+
return out.sort();
|
|
227
|
+
}
|
package/src/report-html.js
CHANGED
|
@@ -32,7 +32,7 @@ export function htmlReport(result, { brand = null, plan = null } = {}) {
|
|
|
32
32
|
: '';
|
|
33
33
|
|
|
34
34
|
const planHtml = plan
|
|
35
|
-
? `<section><h2
|
|
35
|
+
? `<section><h2>${plan.phases.some((ph) => /^Upgrade (React Native|Expo SDK)/.test(ph.title)) ? 'Upgrade plan' : 'Action plan'} <small>scope: ${esc(plan.complexity.size)}</small></h2>${plan.phases
|
|
36
36
|
.map((ph, i) => `<h3>${i + 1}. ${esc(ph.title)}</h3><p class="why">${esc(ph.why)}</p><ul>${ph.steps.map((s) => `<li>${rich(s)}</li>`).join('')}</ul>`)
|
|
37
37
|
.join('')}</section>`
|
|
38
38
|
: '';
|
package/src/security.js
CHANGED
|
@@ -588,7 +588,7 @@ export const MASVS_GROUPS = {
|
|
|
588
588
|
};
|
|
589
589
|
|
|
590
590
|
export function masvsOf(id) {
|
|
591
|
-
if (/^(secret:|env-bundled|crypto-)/.test(id)) return 'CRYPTO';
|
|
591
|
+
if (/^(secret:|env-bundled|crypto-|expo-secret)/.test(id)) return 'CRYPTO';
|
|
592
592
|
if (/^(password-leak|token-unencrypted|android-allow-backup)/.test(id)) return 'STORAGE';
|
|
593
593
|
if (/^(insecure-tls|user-certificates|plain-http|ios-ats|android-cleartext)/.test(id)) return 'NETWORK';
|
|
594
594
|
if (/^(webview-|android-exported)/.test(id)) return 'PLATFORM';
|
|
@@ -647,3 +647,67 @@ export function reverseEngineering(root, project) {
|
|
|
647
647
|
}
|
|
648
648
|
return findings;
|
|
649
649
|
}
|
|
650
|
+
|
|
651
|
+
// Secrets in Expo config: app.json/app.config `extra` ships inside the app (Constants.expoConfig),
|
|
652
|
+
// EXPO_PUBLIC_ variables are compiled into the bundle, and eas.json build env is committed.
|
|
653
|
+
// Names alone decide; many SDK "API keys" are public by design, so those are left alone.
|
|
654
|
+
const SECRET_NAME = /(?:secret|private|password|passwd|auth[_-]?token|access[_-]?token|refresh[_-]?token|access[_-]?key|service[_-]?role|admin[_-]?key|token$)/i;
|
|
655
|
+
const maskValue = (v) => (v.length <= 8 ? '****' : `${v.slice(0, 4)}…${v.slice(-2)}`);
|
|
656
|
+
|
|
657
|
+
export function expoConfigSecrets(root) {
|
|
658
|
+
const findings = [];
|
|
659
|
+
const shipped = [];
|
|
660
|
+
const committed = [];
|
|
661
|
+
const looksReal = (v) => typeof v === 'string' && v.length >= 12 && !/^\$\{|^process\.env|example|placeholder|your[_-]|xxx|changeme|<.*>/i.test(v);
|
|
662
|
+
|
|
663
|
+
let app = null;
|
|
664
|
+
try {
|
|
665
|
+
app = JSON.parse(read(path.join(root, 'app.json')) || 'null');
|
|
666
|
+
} catch {
|
|
667
|
+
// not JSON
|
|
668
|
+
}
|
|
669
|
+
const extra = (app && (app.expo || app).extra) || {};
|
|
670
|
+
for (const [k, v] of Object.entries(extra)) if (SECRET_NAME.test(k) && looksReal(v)) shipped.push({ where: `app.json extra.${k}`, value: v });
|
|
671
|
+
for (const f of ['app.config.js', 'app.config.ts']) {
|
|
672
|
+
const text = read(path.join(root, f));
|
|
673
|
+
const block = text && text.match(/\bextra\s*:\s*\{([\s\S]*?)\n\s*\}/);
|
|
674
|
+
if (!block) continue;
|
|
675
|
+
for (const m of block[1].matchAll(/([A-Za-z_][\w]*)\s*:\s*['"`]([^'"`\n]{12,})['"`]/g)) {
|
|
676
|
+
if (SECRET_NAME.test(m[1]) && looksReal(m[2])) shipped.push({ where: `${f} extra.${m[1]}`, value: m[2] });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
let eas = null;
|
|
680
|
+
try {
|
|
681
|
+
eas = JSON.parse(read(path.join(root, 'eas.json')) || 'null');
|
|
682
|
+
} catch {
|
|
683
|
+
// not JSON
|
|
684
|
+
}
|
|
685
|
+
for (const [profile, cfg] of Object.entries((eas && eas.build) || {})) {
|
|
686
|
+
for (const [k, v] of Object.entries((cfg && cfg.env) || {})) {
|
|
687
|
+
if (!SECRET_NAME.test(k) || !looksReal(v)) continue;
|
|
688
|
+
if (k.startsWith('EXPO_PUBLIC_')) shipped.push({ where: `eas.json build.${profile}.env.${k}`, value: v });
|
|
689
|
+
else committed.push({ where: `eas.json build.${profile}.env.${k}`, value: v });
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (shipped.length) {
|
|
693
|
+
findings.push({
|
|
694
|
+
id: 'expo-secret-shipped',
|
|
695
|
+
severity: 'high',
|
|
696
|
+
area: 'security',
|
|
697
|
+
title: `Secret-looking value${shipped.length === 1 ? '' : 's'} shipped inside the app via Expo config (${shipped[0].where})`,
|
|
698
|
+
detail: `${shipped.map((s) => `${s.where} = ${maskValue(s.value)}`).slice(0, 3).join('; ')}. Expo \`extra\` is readable from the app manifest and EXPO_PUBLIC_ variables are compiled into the bundle, so anyone with the app can read them. Revoke them and move the calls that need them to a server. (OWASP MASVS-CRYPTO)`,
|
|
699
|
+
fix: { kind: 'secret', label: 'Secret in Expo config', file: shipped[0].where.split(' ')[0], line: 1, bundled: true },
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
if (committed.length) {
|
|
703
|
+
findings.push({
|
|
704
|
+
id: 'expo-secret-committed',
|
|
705
|
+
severity: 'high',
|
|
706
|
+
area: 'security',
|
|
707
|
+
title: `Secret committed in eas.json (${committed[0].where})`,
|
|
708
|
+
detail: `${committed.map((s) => `${s.where} = ${maskValue(s.value)}`).slice(0, 3).join('; ')}. Everyone with repository access can use it (a Sentry auth token, for example, can read and change your Sentry organisation). Rotate it and store it as an EAS secret (\`eas env:create\`) instead. (OWASP MASVS-CRYPTO)`,
|
|
709
|
+
fix: { kind: 'secret', label: 'Secret in eas.json', file: 'eas.json', line: 1, bundled: false },
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
return findings;
|
|
713
|
+
}
|
package/src/stability.js
CHANGED
|
@@ -159,6 +159,7 @@ export function stabilityChecks(project) {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
for (const f of iosUsageDescriptions(project)) add(f);
|
|
162
|
+
for (const f of removedCoreImports(project)) add(f);
|
|
162
163
|
return findings;
|
|
163
164
|
}
|
|
164
165
|
|
|
@@ -246,3 +247,85 @@ export function iosUsageDescriptions(project) {
|
|
|
246
247
|
},
|
|
247
248
|
];
|
|
248
249
|
}
|
|
250
|
+
|
|
251
|
+
// Components and APIs that left React Native core. Importing them from 'react-native' throws
|
|
252
|
+
// ("X has been removed from react-native core", checked in RN's index.js for 0.72-0.76) or, from
|
|
253
|
+
// 0.74 for the PropTypes and later for the rest, is simply undefined: a crash on first use.
|
|
254
|
+
const REMOVED_FROM_CORE = {
|
|
255
|
+
AsyncStorage: '@react-native-async-storage/async-storage',
|
|
256
|
+
NetInfo: '@react-native-community/netinfo',
|
|
257
|
+
WebView: 'react-native-webview',
|
|
258
|
+
Slider: '@react-native-community/slider',
|
|
259
|
+
ListView: 'FlatList or SectionList (core)',
|
|
260
|
+
SwipeableListView: 'FlatList with a swipeable row library',
|
|
261
|
+
CameraRoll: '@react-native-camera-roll/camera-roll',
|
|
262
|
+
ImageEditor: '@react-native-community/image-editor',
|
|
263
|
+
ImageStore: 'expo-file-system or react-native-blob-util',
|
|
264
|
+
TimePickerAndroid: '@react-native-community/datetimepicker',
|
|
265
|
+
DatePickerAndroid: '@react-native-community/datetimepicker',
|
|
266
|
+
DatePickerIOS: '@react-native-community/datetimepicker',
|
|
267
|
+
ToolbarAndroid: '@react-native-community/toolbar-android',
|
|
268
|
+
ViewPagerAndroid: 'react-native-pager-view',
|
|
269
|
+
CheckBox: '@react-native-community/checkbox',
|
|
270
|
+
SegmentedControlIOS: '@react-native-segmented-control/segmented-control',
|
|
271
|
+
StatusBarIOS: 'StatusBar (core)',
|
|
272
|
+
Picker: '@react-native-picker/picker',
|
|
273
|
+
PickerIOS: '@react-native-picker/picker',
|
|
274
|
+
MaskedViewIOS: '@react-native-masked-view/masked-view',
|
|
275
|
+
ImagePickerIOS: 'react-native-image-picker',
|
|
276
|
+
ProgressViewIOS: '@react-native-community/progress-view',
|
|
277
|
+
ART: 'react-native-svg',
|
|
278
|
+
};
|
|
279
|
+
const REMOVED_PROP_TYPES = ['ViewPropTypes', 'ColorPropType', 'EdgeInsetsPropType', 'PointPropType'];
|
|
280
|
+
|
|
281
|
+
export function removedCoreImports(project) {
|
|
282
|
+
const root = project.root;
|
|
283
|
+
const used = new Map(); // name -> first file:line
|
|
284
|
+
const files = [];
|
|
285
|
+
const walk = (dir) => {
|
|
286
|
+
let entries = [];
|
|
287
|
+
try {
|
|
288
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
289
|
+
} catch {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
for (const e of entries) {
|
|
293
|
+
if (e.isDirectory()) {
|
|
294
|
+
if (!['node_modules', '.git', 'android', 'ios', 'build', 'dist', '.expo', 'coverage', '__tests__', '__mocks__'].includes(e.name) && !e.name.startsWith('.')) walk(path.join(dir, e.name));
|
|
295
|
+
} else if (/\.[cm]?[jt]sx?$/.test(e.name) && !/\.(test|spec)\./.test(e.name)) {
|
|
296
|
+
files.push(path.join(dir, e.name));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
walk(root);
|
|
301
|
+
for (const file of files) {
|
|
302
|
+
const text = readText(file) || '';
|
|
303
|
+
if (!text.includes("'react-native'") && !text.includes('"react-native"')) continue;
|
|
304
|
+
const blocks = [
|
|
305
|
+
...[...text.matchAll(/import\s*(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]react-native['"]/g)].map((m) => ({ names: m[1], index: m.index })),
|
|
306
|
+
...[...text.matchAll(/(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\(\s*['"]react-native['"]\s*\)/g)].map((m) => ({ names: m[1], index: m.index })),
|
|
307
|
+
];
|
|
308
|
+
for (const b of blocks) {
|
|
309
|
+
for (const raw of b.names.split(',')) {
|
|
310
|
+
const name = raw.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0].trim();
|
|
311
|
+
if ((REMOVED_FROM_CORE[name] || REMOVED_PROP_TYPES.includes(name)) && !used.has(name)) {
|
|
312
|
+
used.set(name, `${path.relative(root, file)}:${text.slice(0, b.index).split('\n').length}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (!used.size) return [];
|
|
318
|
+
const minor = project.rnVersion ? Number(project.rnVersion.split('.')[1]) : null;
|
|
319
|
+
const crashesNow = [...used.keys()].some((n) => (REMOVED_PROP_TYPES.includes(n) ? minor >= 74 : minor >= 72));
|
|
320
|
+
const list = [...used].map(([n, at]) => `${n} (${at}) → ${REMOVED_FROM_CORE[n] || 'deprecated-react-native-prop-types'}`);
|
|
321
|
+
return [
|
|
322
|
+
{
|
|
323
|
+
id: 'crash-removed-core-imports',
|
|
324
|
+
severity: crashesNow ? 'high' : 'medium',
|
|
325
|
+
area: 'crash',
|
|
326
|
+
title: `${used.size} import${used.size === 1 ? '' : 's'} of APIs removed from React Native core${crashesNow ? '' : ' (they break when you upgrade)'}`,
|
|
327
|
+
detail: `${list.slice(0, 6).join('; ')}${list.length > 6 ? '; …' : ''}. Importing these from 'react-native' throws "has been removed from react-native core" or returns undefined on current versions, which crashes the screen that uses them. Install the replacement and change the import.`,
|
|
328
|
+
fix: { kind: 'crash', step: `Move ${[...used.keys()].map((n) => `\`${n}\``).join(', ')} off 'react-native' to their community packages (${[...new Set([...used.keys()].map((n) => REMOVED_FROM_CORE[n] || 'deprecated-react-native-prop-types'))].slice(0, 4).join(', ')}).` },
|
|
329
|
+
},
|
|
330
|
+
];
|
|
331
|
+
}
|