nativekeel 0.1.4 → 0.1.6
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/package.json +1 -1
- package/src/analyze.js +50 -11
- package/src/compat.js +34 -15
- package/src/native.js +32 -10
- package/src/plan.js +146 -24
- package/src/registry.js +23 -6
- package/src/report-html.js +2 -1
- package/src/report-md.js +8 -1
- package/src/report-text.js +3 -1
- package/src/rules.js +17 -0
- package/src/secrets.js +31 -3
- package/src/usage.js +76 -7
package/package.json
CHANGED
package/src/analyze.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { cleanVersion, findPackageDir, installedVersion } from './project.js';
|
|
4
|
-
import { createRegistry } from './registry.js';
|
|
4
|
+
import { createRegistry, fetchJson } from './registry.js';
|
|
5
5
|
import { scanSecrets } from './secrets.js';
|
|
6
6
|
import { nativeChecks } from './native.js';
|
|
7
7
|
import { findUnused, filesImportingWith } from './usage.js';
|
|
8
|
-
import { matchKnownIssues } from './known-issues.js';
|
|
8
|
+
import { matchKnownIssues, compareVersions } from './known-issues.js';
|
|
9
9
|
import { COMPAT, bestRange, checkCompat } from './compat.js';
|
|
10
10
|
import { interopProblems } from './interop.js';
|
|
11
11
|
import {
|
|
@@ -31,8 +31,10 @@ const lineOf = (v) => {
|
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
// react, react-native and @react-native/* move together with the React Native version; @babel/* and types are tooling.
|
|
34
|
+
// Also packages that move with React Native and React themselves (Metro, the scheduler, react-is).
|
|
34
35
|
const isTracked = (name) =>
|
|
35
|
-
!['react-native', 'react', 'expo'].includes(name) &&
|
|
36
|
+
!['react-native', 'react', 'expo', 'react-dom', 'react-is', 'react-test-renderer', 'scheduler', 'hermes-engine'].includes(name) &&
|
|
37
|
+
!/^metro(-|$)/.test(name) &&
|
|
36
38
|
!name.startsWith('@react-native/') &&
|
|
37
39
|
!name.startsWith('@babel/') &&
|
|
38
40
|
!name.startsWith('@types/');
|
|
@@ -53,9 +55,30 @@ function isNativeModule(root, name, dir) {
|
|
|
53
55
|
return !!(dir && (dir.ios || dir.android) && !dir.web);
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
function readJsonFile(file) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Expo's pins are ~x.y.z (same minor), ^x.y.z (same major), or an exact version.
|
|
67
|
+
export function satisfiesExpoRange(version, range) {
|
|
68
|
+
const m = String(range).trim().match(/^([~^]?)\s*(\d+)\.(\d+)\.(\d+)/);
|
|
69
|
+
if (!m) return true; // a range we do not understand: do not second-guess Expo
|
|
70
|
+
const [maj, min] = version.split('.').map(Number);
|
|
71
|
+
if (compareVersions(version, `${m[2]}.${m[3]}.${m[4]}`) < 0) return false;
|
|
72
|
+
if (m[1] === '^') return maj === Number(m[2]);
|
|
73
|
+
if (m[1] === '~') return maj === Number(m[2]) && min === Number(m[3]);
|
|
74
|
+
return compareVersions(version, `${m[2]}.${m[3]}.${m[4]}`) === 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
56
77
|
// `offline: true` means the user asked for no network access (get returns nothing).
|
|
57
78
|
export async function analyze(project, { get, now = new Date(), offline: forcedOffline = false } = {}) {
|
|
58
|
-
|
|
79
|
+
// Requests that failed even after retries (rate limits, network): reported, never hidden.
|
|
80
|
+
const net = { failed: 0 };
|
|
81
|
+
const registry = createRegistry(get || ((url, headers) => fetchJson(url, headers, net)));
|
|
59
82
|
const findings = [];
|
|
60
83
|
const add = (f) => findings.push(f);
|
|
61
84
|
|
|
@@ -83,6 +106,9 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
83
106
|
if (project.isLibrary) {
|
|
84
107
|
warnings.push('This looks like a React Native library, not an app (react-native is a peer dependency, or android/ builds a library). App Store and Google Play checks were skipped; scan the app that uses it for those.');
|
|
85
108
|
}
|
|
109
|
+
const reportIncomplete = () => {
|
|
110
|
+
if (net.failed && !offline) warnings.push(`${net.failed} request${net.failed === 1 ? '' : 's'} to npm or React Native Directory failed (rate limit or network), so some dependency checks may be missing. Run again in a few minutes.`);
|
|
111
|
+
};
|
|
86
112
|
if (!offline && project.rnVersion && !rn) warnings.push('Could not fetch React Native releases from npm; version and New Architecture checks were skipped. Run again.');
|
|
87
113
|
if (!offline && project.expoVersion && !expo) warnings.push('Could not fetch Expo releases from npm; the SDK check was skipped. Run again.');
|
|
88
114
|
|
|
@@ -122,14 +148,16 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
122
148
|
}
|
|
123
149
|
|
|
124
150
|
// 2. New Architecture
|
|
125
|
-
|
|
151
|
+
// Not set means the default: off before 0.76, on from 0.76.
|
|
152
|
+
const unset = minorOf(current) < 76 ? ['android', 'ios'].filter((p) => project.newArch[p] === null) : [];
|
|
153
|
+
const off = ['android', 'ios'].filter((p) => project.newArch[p] === false || unset.includes(p));
|
|
126
154
|
if (minorOf(current) < NEW_ARCH_ONLY_MINOR && off.length && rn.lines[0] >= NEW_ARCH_ONLY_MINOR) {
|
|
127
155
|
add({
|
|
128
156
|
id: 'new-arch-disabled',
|
|
129
157
|
severity: 'critical',
|
|
130
158
|
area: 'new-architecture',
|
|
131
|
-
title: `New Architecture is disabled (${off.join(' + ')})`,
|
|
132
|
-
detail: `The legacy architecture was removed in 0.${NEW_ARCH_ONLY_MINOR}. This app cannot upgrade past 0.${NEW_ARCH_ONLY_MINOR - 1} until it migrates.`,
|
|
159
|
+
title: `New Architecture is ${unset.length === off.length ? 'off' : 'disabled'} (${off.join(' + ')})`,
|
|
160
|
+
detail: `${unset.length ? `Not set, and before React Native 0.76 the default is off. ` : ''}The legacy architecture was removed in 0.${NEW_ARCH_ONLY_MINOR}. This app cannot upgrade past 0.${NEW_ARCH_ONLY_MINOR - 1} until it migrates.`,
|
|
133
161
|
fix: { kind: 'enable-new-arch', platforms: off },
|
|
134
162
|
});
|
|
135
163
|
}
|
|
@@ -297,7 +325,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
297
325
|
const newArchOn = project.newArch.android !== false && project.newArch.ios !== false && minorOf(current || '0.0') >= 76;
|
|
298
326
|
// The legacy architecture ends at 0.81, so any older app with it off has to migrate; this
|
|
299
327
|
// holds even when the latest release could not be fetched (offline).
|
|
300
|
-
const archOff = project.newArch.android === false || project.newArch.ios === false;
|
|
328
|
+
const archOff = project.newArch.android === false || project.newArch.ios === false || (!!current && minorOf(current) < 76 && (project.newArch.android === null || project.newArch.ios === null));
|
|
301
329
|
const newArchRequired =
|
|
302
330
|
findings.some((f) => f.id === 'new-arch-disabled') || (archOff && current && minorOf(current) < NEW_ARCH_ONLY_MINOR);
|
|
303
331
|
for (const { issue, dep } of matchKnownIssues({ deps, rnVersion: current, newArchOn, newArchRequired })) {
|
|
@@ -316,25 +344,35 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
316
344
|
area: 'dependency',
|
|
317
345
|
title,
|
|
318
346
|
detail: `${detail} (installed: ${dep.name} ${dep.version}; seen in: ${issue.source})`,
|
|
319
|
-
fix: { kind: 'known-issue', name: dep.name, title, detail },
|
|
347
|
+
fix: { kind: 'known-issue', name: dep.name, title, detail, severity },
|
|
320
348
|
});
|
|
321
349
|
}
|
|
322
350
|
|
|
323
351
|
// Library ↔ React Native compatibility, from the libraries' own tables.
|
|
352
|
+
// Expo SDKs pin tested versions of these libraries (bundledNativeModules.json). A version
|
|
353
|
+
// inside Expo's range is a combination Expo ships, even where the library's table is stricter.
|
|
354
|
+
const expoDir = project.expoVersion ? findPackageDir(project.root, 'expo') : null;
|
|
355
|
+
const expoPins = expoDir ? readJsonFile(path.join(expoDir, 'bundledNativeModules.json')) : null;
|
|
356
|
+
const expoSdk = project.expoVersion ? majorOf(project.expoVersion) : null;
|
|
324
357
|
if (current) {
|
|
325
358
|
const rnMinor = minorOf(current);
|
|
326
359
|
for (const dep of deps.filter((d) => COMPAT[d.name])) {
|
|
360
|
+
const pinned = expoPins && expoPins[dep.name];
|
|
361
|
+
if (pinned && dep.version && satisfiesExpoRange(dep.version, pinned)) continue;
|
|
327
362
|
const problem = checkCompat(dep.name, dep.version, rnMinor, newArchOn);
|
|
328
363
|
// Also check the architecture this app has to move to.
|
|
329
364
|
const ahead = !problem && newArchRequired ? checkCompat(dep.name, dep.version, rnMinor, true) : null;
|
|
330
365
|
if (!problem && !ahead) continue;
|
|
331
366
|
const best = bestRange(dep.name, rnMinor, problem ? newArchOn : true);
|
|
367
|
+
let expoNote = '';
|
|
368
|
+
if (pinned) expoNote = ` Expo SDK ${expoSdk} expects ${pinned}: run \`npx expo install --fix\`.`;
|
|
369
|
+
else if (expoSdk) expoNote = ` Expo SDK ${expoSdk} pins a tested version of this package; \`npx expo install --check\` shows whether yours matches (install dependencies for a precise check).`;
|
|
332
370
|
add({
|
|
333
371
|
id: `compat:${dep.name}`,
|
|
334
|
-
severity: problem ? 'high' : 'medium',
|
|
372
|
+
severity: problem && !(expoSdk && !pinned) ? 'high' : 'medium',
|
|
335
373
|
area: 'dependency',
|
|
336
374
|
title: problem || `${ahead} (needed for the New Architecture)`,
|
|
337
|
-
detail: `${best ? `Use ${dep.name} ${best.range} on React Native ${current}. ` : ''}Source: ${COMPAT[dep.name].source}.${COMPAT[dep.name].note ? ` ${COMPAT[dep.name].note}` : ''}`,
|
|
375
|
+
detail: `${best ? `Use ${dep.name} ${best.range} on React Native ${current}. ` : ''}Source: ${COMPAT[dep.name].source}.${COMPAT[dep.name].note ? ` ${COMPAT[dep.name].note}` : ''}${expoNote}`,
|
|
338
376
|
fix: { kind: 'compat', name: dep.name, range: best && best.range, forNewArch: !problem },
|
|
339
377
|
});
|
|
340
378
|
}
|
|
@@ -392,6 +430,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
392
430
|
}
|
|
393
431
|
}
|
|
394
432
|
|
|
433
|
+
reportIncomplete();
|
|
395
434
|
findings.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
|
|
396
435
|
return {
|
|
397
436
|
tool: 'nativekeel',
|
package/src/compat.js
CHANGED
|
@@ -7,7 +7,7 @@ import { compareVersions } from './known-issues.js';
|
|
|
7
7
|
|
|
8
8
|
export const COMPAT = {
|
|
9
9
|
'react-native-screens': {
|
|
10
|
-
source: 'https://github.com/software-mansion/react-native-screens#support-for-fabric (checked 2026-10-
|
|
10
|
+
source: 'https://github.com/software-mansion/react-native-screens#support-for-fabric (checked 2026-10-04)',
|
|
11
11
|
newArch: [
|
|
12
12
|
{ from: '4.0.0', to: '4.4.99', rnMin: 76 },
|
|
13
13
|
{ from: '4.5.0', to: '4.13.99', rnMin: 77 },
|
|
@@ -26,9 +26,13 @@ export const COMPAT = {
|
|
|
26
26
|
],
|
|
27
27
|
},
|
|
28
28
|
'react-native-gesture-handler': {
|
|
29
|
-
source: 'https://github.com/software-mansion/react-native-gesture-handler#
|
|
29
|
+
source: 'https://github.com/software-mansion/react-native-gesture-handler#react-native-support (Gesture Handler 2 table; 3.x needs 0.82+; checked 2026-10-04)',
|
|
30
30
|
note: 'Gesture Handler supports the three latest React Native minors; the table gives minimums.',
|
|
31
31
|
newArch: [
|
|
32
|
+
{ from: '2.0.0', to: '2.9.99', rnMin: 63 },
|
|
33
|
+
{ from: '2.10.0', to: '2.13.99', rnMin: 64 },
|
|
34
|
+
{ from: '2.14.0', to: '2.15.99', rnMin: 67 },
|
|
35
|
+
{ from: '2.16.0', to: '2.17.99', rnMin: 68 },
|
|
32
36
|
{ from: '2.18.0', to: '2.20.99', rnMin: 73 },
|
|
33
37
|
{ from: '2.21.0', to: '2.23.99', rnMin: 74 },
|
|
34
38
|
{ from: '2.24.0', to: '2.24.99', rnMin: 75 },
|
|
@@ -39,6 +43,10 @@ export const COMPAT = {
|
|
|
39
43
|
{ from: '3.0.0', rnMin: 82 },
|
|
40
44
|
],
|
|
41
45
|
legacy: [
|
|
46
|
+
{ from: '2.0.0', to: '2.9.99', rnMin: 63 },
|
|
47
|
+
{ from: '2.10.0', to: '2.13.99', rnMin: 64 },
|
|
48
|
+
{ from: '2.14.0', to: '2.15.99', rnMin: 67 },
|
|
49
|
+
{ from: '2.16.0', to: '2.17.99', rnMin: 68 },
|
|
42
50
|
{ from: '2.18.0', to: '2.20.99', rnMin: 73 },
|
|
43
51
|
{ from: '2.21.0', to: '2.23.99', rnMin: 74 },
|
|
44
52
|
{ from: '2.24.0', to: '2.24.99', rnMin: 75 },
|
|
@@ -49,7 +57,8 @@ export const COMPAT = {
|
|
|
49
57
|
],
|
|
50
58
|
},
|
|
51
59
|
'react-native-reanimated': {
|
|
52
|
-
source: '
|
|
60
|
+
source: 'Reanimated compatibility.json, the data behind docs.swmansion.com/react-native-reanimated/docs/guides/compatibility (rows assume the latest patch of each minor)',
|
|
61
|
+
// Generated by scripts/reanimated-compat.mjs on 2026-10-04 from https://raw.githubusercontent.com/software-mansion/react-native-reanimated/main/packages/react-native-reanimated/compatibility.json
|
|
53
62
|
newArch: [
|
|
54
63
|
{ from: '3.0.0', to: '3.0.99', rnMin: 71, rnMax: 71 },
|
|
55
64
|
{ from: '3.1.0', to: '3.5.99', rnMin: 72, rnMax: 72 },
|
|
@@ -60,22 +69,32 @@ export const COMPAT = {
|
|
|
60
69
|
{ from: '3.17.0', to: '3.17.99', rnMin: 75, rnMax: 79 },
|
|
61
70
|
{ from: '3.18.0', to: '3.18.99', rnMin: 75, rnMax: 80 },
|
|
62
71
|
{ from: '3.19.0', to: '3.19.99', rnMin: 78, rnMax: 81 },
|
|
63
|
-
{ from: '4.0.0', to: '4.
|
|
64
|
-
{ from: '4.
|
|
65
|
-
{ from: '4.
|
|
66
|
-
{ from: '4.
|
|
67
|
-
{ from: '4.
|
|
72
|
+
{ from: '4.0.0', to: '4.0.99', rnMin: 78, rnMax: 81 },
|
|
73
|
+
{ from: '4.1.0', to: '4.1.99', rnMin: 78, rnMax: 82 },
|
|
74
|
+
{ from: '4.2.0', to: '4.2.99', rnMin: 80, rnMax: 84 },
|
|
75
|
+
{ from: '4.3.0', to: '4.3.99', rnMin: 81, rnMax: 85 },
|
|
76
|
+
{ from: '4.4.0', to: '4.4.99', rnMin: 83, rnMax: 86 },
|
|
77
|
+
{ from: '4.5.0', to: '4.5.99', rnMin: 83, rnMax: 86 },
|
|
78
|
+
{ from: '4.6.0', to: '4.6.99', rnMin: 83, rnMax: 87 },
|
|
79
|
+
{ from: '4.7.0', to: '4.7.99', rnMin: 86, rnMax: 88 },
|
|
80
|
+
{ from: '4.8.0', to: '4.8.99', rnMin: 86, rnMax: 88 },
|
|
68
81
|
],
|
|
69
82
|
legacy: [
|
|
83
|
+
{ from: '2.3.0', to: '2.4.99', rnMin: 63, rnMax: 67 },
|
|
84
|
+
{ from: '2.5.0', to: '2.9.99', rnMin: 63, rnMax: 68 },
|
|
85
|
+
{ from: '2.10.0', to: '2.10.99', rnMin: 63, rnMax: 69 },
|
|
86
|
+
{ from: '2.11.0', to: '2.13.99', rnMin: 63, rnMax: 70 },
|
|
87
|
+
{ from: '2.14.0', to: '2.17.99', rnMin: 63, rnMax: 71 },
|
|
70
88
|
{ from: '3.0.0', to: '3.2.99', rnMin: 63, rnMax: 71 },
|
|
71
89
|
{ from: '3.3.0', to: '3.4.99', rnMin: 63, rnMax: 72 },
|
|
72
|
-
{ from: '3.5.0', to: '3.5.99', rnMin: 66, rnMax:
|
|
73
|
-
{ from: '3.6.0', to: '3.8.99', rnMin: 66, rnMax:
|
|
74
|
-
{ from: '3.9.0', to: '3.
|
|
75
|
-
{ from: '3.
|
|
76
|
-
{ from: '3.
|
|
77
|
-
{ from: '3.
|
|
78
|
-
{ from: '3.
|
|
90
|
+
{ from: '3.5.0', to: '3.5.99', rnMin: 66, rnMax: 72 },
|
|
91
|
+
{ from: '3.6.0', to: '3.8.99', rnMin: 66, rnMax: 73 },
|
|
92
|
+
{ from: '3.9.0', to: '3.14.99', rnMin: 71, rnMax: 74 },
|
|
93
|
+
{ from: '3.15.0', to: '3.15.99', rnMin: 72, rnMax: 75 },
|
|
94
|
+
{ from: '3.16.0', to: '3.16.99', rnMin: 74, rnMax: 77 },
|
|
95
|
+
{ from: '3.17.0', to: '3.17.99', rnMin: 75, rnMax: 79 },
|
|
96
|
+
{ from: '3.18.0', to: '3.18.99', rnMin: 75, rnMax: 80 },
|
|
97
|
+
{ from: '3.19.0', to: '3.19.99', rnMin: 78, rnMax: 81 },
|
|
79
98
|
{ from: '4.0.0', unsupported: true },
|
|
80
99
|
],
|
|
81
100
|
note: 'Reanimated 4 also needs react-native-worklets; Reanimated 3.x is no longer actively maintained.',
|
package/src/native.js
CHANGED
|
@@ -62,7 +62,7 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
62
62
|
severity: 'critical',
|
|
63
63
|
area: 'store',
|
|
64
64
|
title: `React Native ${project.rnVersion} does not support 16 KB memory pages`,
|
|
65
|
-
detail: `Google Play rejects updates without 16 KB page support since ${PAGE_SIZE_16K.since} (extensions ended ${PAGE_SIZE_16K.extensionUntil}). React Native supports it from 0.${PAGE_SIZE_16K.firstRnMinor}.`,
|
|
65
|
+
detail: `Google Play rejects updates without 16 KB page support since ${PAGE_SIZE_16K.since} (extensions ended ${PAGE_SIZE_16K.extensionUntil}). React Native supports it from 0.${PAGE_SIZE_16K.firstRnMinor}${project.expoVersion ? ', which in Expo means SDK 53 or later (SDK 52 ships React Native 0.76)' : ''}.`,
|
|
66
66
|
fix: { kind: 'upgrade-rn-min', to: `0.${PAGE_SIZE_16K.firstRnMinor}.0` },
|
|
67
67
|
});
|
|
68
68
|
}
|
|
@@ -191,7 +191,9 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
191
191
|
}
|
|
192
192
|
const patched = [];
|
|
193
193
|
for (const f of patchFiles) {
|
|
194
|
-
|
|
194
|
+
// patch-package names: <pkg>+<version>.patch, <pkg>+<version>.dev.patch, and sequenced
|
|
195
|
+
// patches <pkg>+<version>+<NNN>[+<name>].patch (scoped packages use + for the slash).
|
|
196
|
+
const m = f.match(/^(.+?)\+(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\+\d+(?:\+[^/]*)?)?(?:\.dev)?\.patch$/);
|
|
195
197
|
if (!m) continue;
|
|
196
198
|
const pkg = m[1].replace(/\+/g, '/');
|
|
197
199
|
const version = m[2];
|
|
@@ -226,7 +228,7 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
226
228
|
id: 'patches-present',
|
|
227
229
|
severity: 'info',
|
|
228
230
|
area: 'native',
|
|
229
|
-
title: `${patched.length} patch-package patch${patched.length === 1 ? '' : 'es'}: ${patched.map((p) => p.pkg).join(', ')}`,
|
|
231
|
+
title: `${patched.length} patch-package patch${patched.length === 1 ? '' : 'es'}: ${[...new Set(patched.map((p) => p.pkg))].join(', ')}`,
|
|
230
232
|
detail: 'Each was written against one library version and one React Native version. After every upgrade step check that each still applies and is still needed; a patch that adapted a library to an older React Native can break it on a newer one.',
|
|
231
233
|
fix: { kind: 'patches-review', patches: patched },
|
|
232
234
|
});
|
|
@@ -274,15 +276,18 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
274
276
|
}
|
|
275
277
|
|
|
276
278
|
// Hermes: JavaScriptCore is no longer bundled with React Native.
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
+
const hermesOffOn = [
|
|
280
|
+
/^\s*hermesEnabled\s*=\s*false/m.test(gradleProps) && 'android',
|
|
281
|
+
/^[^#\n]*:hermes_enabled\s*=>\s*false/m.test(podfile) && 'ios',
|
|
282
|
+
].filter(Boolean);
|
|
283
|
+
if (hermesOffOn.length) {
|
|
279
284
|
add({
|
|
280
285
|
id: 'hermes-disabled',
|
|
281
286
|
severity: 'medium',
|
|
282
287
|
area: 'native',
|
|
283
|
-
title:
|
|
288
|
+
title: `Hermes is disabled (JavaScriptCore on ${hermesOffOn.join(' + ')})`,
|
|
284
289
|
detail: 'JavaScriptCore is no longer part of React Native; newer versions need the community JSC package. Switching to Hermes is the supported path and usually faster.',
|
|
285
|
-
fix: { kind: 'enable-hermes' },
|
|
290
|
+
fix: { kind: 'enable-hermes', platforms: hermesOffOn },
|
|
286
291
|
});
|
|
287
292
|
}
|
|
288
293
|
|
|
@@ -290,13 +295,26 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
290
295
|
// uses "required reason" APIs, so every React Native app needs one.
|
|
291
296
|
if (hasIos && !project.isLibrary) {
|
|
292
297
|
const manifests = findFiles(path.join(root, 'ios'), (name) => name.endsWith('.xcprivacy'));
|
|
293
|
-
|
|
298
|
+
// From 0.75, `pod install` writes the manifest (with React Native's and the pods' reasons)
|
|
299
|
+
// and adds it to the app target, unless the Podfile turns aggregation off.
|
|
300
|
+
const aggregationOff = /privacy_file_aggregation_enabled\s*(?:=>|:)\s*false|RCT_AGGREGATE_PRIVACY_FILES['"]?\]?\s*=\s*['"]?0/.test(podfile);
|
|
301
|
+
const generated = rn !== null && rn >= 75 && !aggregationOff;
|
|
302
|
+
if (!manifests.length && generated) {
|
|
303
|
+
add({
|
|
304
|
+
id: 'ios-privacy-manifest',
|
|
305
|
+
severity: 'low',
|
|
306
|
+
area: 'store',
|
|
307
|
+
title: 'iOS privacy manifest is generated at pod install, not committed',
|
|
308
|
+
detail: "React Native 0.75+ creates PrivacyInfo.xcprivacy during `pod install` with the required-reason APIs of React Native and your pods. Commit it, then add what only you know: collected data types and tracking. App Store Connect checks those too.",
|
|
309
|
+
fix: { kind: 'privacy-manifest', generated: true },
|
|
310
|
+
});
|
|
311
|
+
} else if (!manifests.length) {
|
|
294
312
|
add({
|
|
295
313
|
id: 'ios-privacy-manifest',
|
|
296
314
|
severity: 'high',
|
|
297
315
|
area: 'store',
|
|
298
316
|
title: 'iOS privacy manifest (PrivacyInfo.xcprivacy) is missing',
|
|
299
|
-
detail:
|
|
317
|
+
detail: `App Store Connect rejects uploads that use required-reason APIs without declaring them. React Native uses several (UserDefaults, file timestamps, system boot time). ${rn !== null && rn < 75 ? 'React Native before 0.75 does not add it to the app for you: ' : ''}Add PrivacyInfo.xcprivacy to the app target.`,
|
|
300
318
|
fix: { kind: 'privacy-manifest' },
|
|
301
319
|
});
|
|
302
320
|
}
|
|
@@ -429,7 +447,11 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
429
447
|
...[...text.matchAll(/^\s*([A-Z0-9_]*(?:STORE|KEY)_PASSWORD)\s*=\s*(\S+)\s*$/gm)].map((m) => ({ name: m[1], value: m[2] })),
|
|
430
448
|
// Not `props['storePassword']`: there the name is a lookup key, not an assignment.
|
|
431
449
|
...[...text.matchAll(/(?<![\w'"[])(storePassword|keyPassword)\s*=?\s*["']([^"'\n]+)["']/g)].map((m) => ({ name: m[1], value: m[2] })),
|
|
432
|
-
]
|
|
450
|
+
]
|
|
451
|
+
.map((l) => ({ ...l, value: l.value.replace(/^["']|["']$/g, '') }))
|
|
452
|
+
// Not leaks: the debug keystore's public password, environment references, empty
|
|
453
|
+
// values and template placeholders (*****, <password>, your_password).
|
|
454
|
+
.filter((l) => l.value && l.value !== 'android' && !/^System\.getenv|^\$\{?[A-Z_]+\}?$/.test(l.value) && !/^(\*+|x+|<.*>|your[_-]?\w*|changeme)$/i.test(l.value));
|
|
433
455
|
if (leaked.length) {
|
|
434
456
|
add({
|
|
435
457
|
id: `signing-password:${f}`,
|
package/src/plan.js
CHANGED
|
@@ -67,10 +67,26 @@ const WEIGHTS = {
|
|
|
67
67
|
'default-react-host': () => 0.25,
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
// Routine version bumps are many but cheap next to a React Native, architecture or store
|
|
71
|
+
// change; capping them keeps a nearly current app with many outdated packages from being sized
|
|
72
|
+
// like a migration.
|
|
73
|
+
const BUMP_CAPS = { native: 6, js: 2 };
|
|
74
|
+
|
|
70
75
|
export function complexity(findings) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
let nativeBumps = 0;
|
|
77
|
+
let jsBumps = 0;
|
|
78
|
+
let rest = 0;
|
|
79
|
+
for (const f of findings) {
|
|
80
|
+
if (!f.fix || !WEIGHTS[f.fix.kind]) continue;
|
|
81
|
+
const w = WEIGHTS[f.fix.kind](f.fix);
|
|
82
|
+
if (f.fix.kind === 'bump-dep') {
|
|
83
|
+
if (f.fix.native) nativeBumps += w;
|
|
84
|
+
else jsBumps += w;
|
|
85
|
+
} else {
|
|
86
|
+
rest += w;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const score = Math.round(rest + Math.min(nativeBumps, BUMP_CAPS.native) + Math.min(jsBumps, BUMP_CAPS.js));
|
|
74
90
|
const size = score < 8 ? 'Small' : score < 20 ? 'Medium' : score < 40 ? 'Large' : 'Extra large';
|
|
75
91
|
return { score, size };
|
|
76
92
|
}
|
|
@@ -125,8 +141,40 @@ function safetyNetPhase(tool) {
|
|
|
125
141
|
};
|
|
126
142
|
}
|
|
127
143
|
|
|
144
|
+
// Libraries whose version an Expo SDK decides (from Expo's bundledNativeModules.json). When the
|
|
145
|
+
// app's own copy of that file is installed it is used instead; this list covers scans without
|
|
146
|
+
// node_modules.
|
|
147
|
+
const EXPO_PINNED = new Set([
|
|
148
|
+
'react-native-reanimated', 'react-native-worklets', 'react-native-gesture-handler', 'react-native-screens',
|
|
149
|
+
'react-native-safe-area-context', 'react-native-webview', 'react-native-svg', 'react-native-pager-view',
|
|
150
|
+
'react-native-maps', 'react-native-view-shot', 'react-native-get-random-values', 'react-native-keyboard-controller',
|
|
151
|
+
'@react-native-async-storage/async-storage', '@react-native-community/datetimepicker', '@react-native-community/slider',
|
|
152
|
+
'@react-native-community/netinfo', '@react-native-picker/picker', '@react-native-masked-view/masked-view',
|
|
153
|
+
'lottie-react-native', '@shopify/flash-list', '@shopify/react-native-skia', '@stripe/stripe-react-native',
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
function expoPinnedNames(root) {
|
|
157
|
+
try {
|
|
158
|
+
let dir = root;
|
|
159
|
+
while (true) {
|
|
160
|
+
const file = path.join(dir, 'node_modules', 'expo', 'bundledNativeModules.json');
|
|
161
|
+
if (fs.existsSync(file)) return new Set(Object.keys(JSON.parse(fs.readFileSync(file, 'utf8'))));
|
|
162
|
+
const parent = path.dirname(dir);
|
|
163
|
+
if (parent === dir) return EXPO_PINNED;
|
|
164
|
+
dir = parent;
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
return EXPO_PINNED;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
128
171
|
export function buildPlan(result) {
|
|
129
|
-
const
|
|
172
|
+
const isExpo = !!result.project.expo;
|
|
173
|
+
const basePm = commands[packageManager(result.project.root)];
|
|
174
|
+
// In Expo projects `npx expo install` picks the version that matches the SDK.
|
|
175
|
+
const pm = isExpo ? { ...basePm, add: 'npx expo install' } : basePm;
|
|
176
|
+
const pinned = isExpo ? expoPinnedNames(result.project.root) : new Set();
|
|
177
|
+
const movesWithExpo = (name) => isExpo && (/^(expo-|@expo\/)/.test(name) || name === 'expo' || name === 'jest-expo' || pinned.has(name));
|
|
130
178
|
const installed = new Set(result.deps.map((d) => d.name));
|
|
131
179
|
const byKind = (kind) => result.findings.filter((f) => f.fix && f.fix.kind === kind).map((f) => f.fix);
|
|
132
180
|
const phases = [];
|
|
@@ -168,7 +216,8 @@ export function buildPlan(result) {
|
|
|
168
216
|
});
|
|
169
217
|
}
|
|
170
218
|
|
|
171
|
-
|
|
219
|
+
// Low-severity notes stay in the report; the plan only carries what can break the app.
|
|
220
|
+
const known = byKind('known-issue').filter((k) => k.severity !== 'low');
|
|
172
221
|
if (known.length) {
|
|
173
222
|
phases.push({
|
|
174
223
|
title: 'Avoid known traps',
|
|
@@ -190,9 +239,17 @@ export function buildPlan(result) {
|
|
|
190
239
|
}
|
|
191
240
|
|
|
192
241
|
const storeSteps = [];
|
|
193
|
-
|
|
242
|
+
const privacy = byKind('privacy-manifest')[0];
|
|
243
|
+
if (privacy && privacy.generated) {
|
|
244
|
+
storeSteps.push('Run `pod install`, commit the generated `PrivacyInfo.xcprivacy`, and add your collected data types and tracking to it (React Native only fills in the required-reason APIs).');
|
|
245
|
+
} else if (privacy) {
|
|
194
246
|
storeSteps.push('Add `PrivacyInfo.xcprivacy` to the iOS app target. Start from the file in the React Native template and declare the required-reason APIs your app and SDKs use (UserDefaults, file timestamps, system boot time, disk space).');
|
|
195
247
|
}
|
|
248
|
+
if (byKind('upgrade-rn-min').length) {
|
|
249
|
+
storeSteps.push(isExpo
|
|
250
|
+
? '**16 KB memory pages:** Google Play rejects updates until the app is on Expo SDK 53 (React Native 0.79) or later. The Expo SDK phase below gets you there; reach SDK 53 before your next Play release.'
|
|
251
|
+
: '**16 KB memory pages:** Google Play rejects updates until the app is on React Native 0.77 or later. The React Native phase below gets you there; reach 0.77 before your next Play release.');
|
|
252
|
+
}
|
|
196
253
|
const align = byKind('align-16kb')[0];
|
|
197
254
|
if (align) {
|
|
198
255
|
storeSteps.push(`Fix 16 KB alignment for ${align.libs.map((l) => `\`${l}\``).join(', ')}: find which package ships each library (\`grep -rl <lib> node_modules/*/android\`) and update it; libraries you build yourself need NDK r28+.`);
|
|
@@ -237,33 +294,50 @@ export function buildPlan(result) {
|
|
|
237
294
|
if (byKind('remove-flipper').length) {
|
|
238
295
|
cleanup.push('Remove Flipper: delete the `com.facebook.flipper` dependencies and `FLIPPER_VERSION` on Android, `use_flipper!` / Flipper config in the Podfile, and the `ReactNativeFlipper` initialization code. Use React Native DevTools instead.');
|
|
239
296
|
}
|
|
240
|
-
|
|
241
|
-
|
|
297
|
+
const hermes = byKind('enable-hermes')[0];
|
|
298
|
+
if (hermes) {
|
|
299
|
+
const where = (hermes.platforms || ['android', 'ios']).map((pl) => (pl === 'android' ? 'set `hermesEnabled=true` in `android/gradle.properties`' : 'set `:hermes_enabled => true` in the Podfile'));
|
|
300
|
+
cleanup.push(`Switch to Hermes: ${where.join(' and ')}, then test anything that relied on JavaScriptCore behavior (Intl, Date parsing).`);
|
|
242
301
|
}
|
|
243
302
|
if (cleanup.length) {
|
|
244
303
|
phases.push({ title: 'Remove legacy tooling', why: 'Leftovers from older templates break on newer React Native versions; removing them first shrinks the upgrade diff.', steps: cleanup });
|
|
245
304
|
}
|
|
246
305
|
|
|
247
|
-
const
|
|
306
|
+
const allReplace = byKind('replace-dep');
|
|
307
|
+
// Abandoned pure-JS packages do not block an upgrade; they get their own, later phase.
|
|
308
|
+
const jsAbandoned = allReplace.filter((d) => !d.native && !d.noNewArch);
|
|
309
|
+
const interopPatched = new Set(byKind('interop-job').map((j) => j.name));
|
|
310
|
+
const replace = allReplace.filter((d) => d.native || d.noNewArch).sort((a, b) => Number(b.noNewArch) - Number(a.noNewArch) || Number(b.native) - Number(a.native));
|
|
248
311
|
if (replace.length) {
|
|
249
312
|
phases.push({
|
|
250
313
|
title: 'Replace blocking and abandoned packages',
|
|
251
314
|
why: 'These packages decide how far you can upgrade. Swap them while the app still runs on its current version, so every problem has one cause.',
|
|
252
315
|
steps: replace.map((d) => {
|
|
253
316
|
const reason = [d.noNewArch && 'no New Architecture support', d.unmaintained && 'unmaintained'].filter(Boolean).join(', ');
|
|
317
|
+
if (interopPatched.has(d.name) && !d.unmaintained) {
|
|
318
|
+
return `\`${d.name}\` (${reason}, native). The New Architecture step below patches it so it loads; move to a release with a TurboModule spec once one exists.`;
|
|
319
|
+
}
|
|
254
320
|
const owned = d.alternatives.find((a) => installed.has(a));
|
|
255
321
|
if (owned) {
|
|
256
322
|
return `\`${d.name}\` (${reason}${d.native ? ', native' : ''}). You already use \`${owned}\`: move the remaining usage there, then \`${pm.remove} ${d.name}\`.`;
|
|
257
323
|
}
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
const
|
|
324
|
+
// expo-* packages need Expo modules; a bare app without them should get another option first.
|
|
325
|
+
const usesExpoModules = isExpo || installed.has('expo');
|
|
326
|
+
const to = usesExpoModules ? d.alternatives[0] : d.alternatives.find((a) => !a.startsWith('expo-')) || d.alternatives[0];
|
|
327
|
+
const needsExpo = to && to.startsWith('expo-') && !usesExpoModules ? ' (Expo packages need Expo modules first: `npx install-expo-modules@latest`)' : '';
|
|
328
|
+
const cmd = to ? ` \`${pm.remove} ${d.name} && ${pm.add} ${to}\`${needsExpo}` : '';
|
|
329
|
+
const options = d.alternatives.length
|
|
330
|
+
? `Options: ${d.alternatives.map((a) => `\`${a}\``).join(', ')}.${cmd}`
|
|
331
|
+
: d.noNewArch
|
|
332
|
+
? 'No known replacement: check for a maintained fork, or vendor and patch it with `patch-package`.'
|
|
333
|
+
: 'Not a known blocker: keep it while it builds and passes your UI flows at every step below, and replace it (or patch it with `patch-package`) when it breaks.';
|
|
261
334
|
return `\`${d.name}\` (${reason}${d.native ? ', native' : ''}). ${options}`;
|
|
262
335
|
}),
|
|
263
336
|
});
|
|
264
337
|
}
|
|
265
338
|
|
|
266
|
-
const
|
|
339
|
+
const expoMoves = byKind('bump-dep').filter((d) => movesWithExpo(d.name));
|
|
340
|
+
const nativeBumps = byKind('bump-dep').filter((d) => d.native && !movesWithExpo(d.name));
|
|
267
341
|
if (nativeBumps.length) {
|
|
268
342
|
phases.push({
|
|
269
343
|
title: 'Update native modules on the current React Native version',
|
|
@@ -281,6 +355,7 @@ export function buildPlan(result) {
|
|
|
281
355
|
steps: [`In \`${depProvider.file}\`: \`#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>\` and \`self.dependencyProvider = [RCTAppDependencyProvider new];\` before \`[super application:didFinishLaunchingWithOptions:]\`.`],
|
|
282
356
|
});
|
|
283
357
|
}
|
|
358
|
+
let deferredArch = null;
|
|
284
359
|
if (arch) {
|
|
285
360
|
const steps = [];
|
|
286
361
|
for (const j of byKind('interop-job')) {
|
|
@@ -290,7 +365,7 @@ export function buildPlan(result) {
|
|
|
290
365
|
steps.push(`In \`${depProvider.file}\`, add \`#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>\` and \`self.dependencyProvider = [RCTAppDependencyProvider new];\` before calling super. Without it the switch causes a launch crash in Release.`);
|
|
291
366
|
}
|
|
292
367
|
if (result.project.managed) {
|
|
293
|
-
steps.push('Set `"newArchEnabled": true` in `app.json` (under `expo`), then
|
|
368
|
+
steps.push('Set `"newArchEnabled": true` in `app.json` (under `expo`), then make a new development build (`eas build --profile development`, or `npx expo run:ios` / `npx expo run:android`). Expo Go cannot test this switch.');
|
|
294
369
|
} else {
|
|
295
370
|
if (arch.platforms.includes('android')) steps.push('Android: set `newArchEnabled=true` in `android/gradle.properties`.');
|
|
296
371
|
if (arch.platforms.includes('ios')) steps.push('iOS: set `"newArchEnabled": "true"` in `ios/Podfile.properties.json` (or run `RCT_NEW_ARCH_ENABLED=1 bundle exec pod install`).');
|
|
@@ -298,14 +373,23 @@ export function buildPlan(result) {
|
|
|
298
373
|
for (const c of byKind('compat').filter((x) => x.forNewArch && x.range)) {
|
|
299
374
|
steps.unshift(`First move \`${c.name}\` to ${c.range}: the installed version does not support the New Architecture on this React Native version.`);
|
|
300
375
|
}
|
|
301
|
-
|
|
302
|
-
|
|
376
|
+
if (!result.project.managed) {
|
|
377
|
+
steps.push('Clean everything (`./gradlew clean`, `watchman watch-del-all`, delete `ios/build` and `ios/Pods`). Switching architecture without a clean build fails on stale codegen output (e.g. missing `Native…Spec` classes).');
|
|
378
|
+
}
|
|
379
|
+
steps.push(result.project.managed ? 'Build a development and a production build for both platforms.' : 'Build debug and release on both platforms.');
|
|
303
380
|
steps.push('Click through every screen that uses a native module. Interop-layer problems show up at runtime, not at build time.');
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
381
|
+
// Before 0.76 the New Architecture is too immature to switch on in place: do it on the way,
|
|
382
|
+
// at the first stop between 0.76 and 0.81 (or Expo SDK 52+), in its own commit.
|
|
383
|
+
const currentMinor = Number((result.project.reactNative || '0.99').split('.')[1]);
|
|
384
|
+
if (currentMinor < 76) {
|
|
385
|
+
deferredArch = steps;
|
|
386
|
+
} else {
|
|
387
|
+
phases.push({
|
|
388
|
+
title: 'Turn on the New Architecture on your current version',
|
|
389
|
+
why: `React Native 0.${NEW_ARCH_ONLY_MINOR} removed the legacy architecture. Switching now, before changing the React Native version, keeps the two kinds of breakage apart.`,
|
|
390
|
+
steps,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
309
393
|
}
|
|
310
394
|
|
|
311
395
|
const rn = byKind('upgrade-rn')[0];
|
|
@@ -332,6 +416,19 @@ export function buildPlan(result) {
|
|
|
332
416
|
from = to;
|
|
333
417
|
return step;
|
|
334
418
|
});
|
|
419
|
+
if (deferredArch) {
|
|
420
|
+
const hops = upgradeHops(rn.from, rn.to);
|
|
421
|
+
const at = hops.findIndex((h) => Number(h.split('.')[1]) >= 76 && Number(h.split('.')[1]) < NEW_ARCH_ONLY_MINOR);
|
|
422
|
+
if (at >= 0) {
|
|
423
|
+
steps.splice(
|
|
424
|
+
at + 1,
|
|
425
|
+
0,
|
|
426
|
+
`**Stop at ${hops[at]} and turn on the New Architecture** before going further: 0.${NEW_ARCH_ONLY_MINOR} removed the legacy one. Do it in its own commit:`,
|
|
427
|
+
...deferredArch.map((x) => `New Architecture: ${x}`),
|
|
428
|
+
);
|
|
429
|
+
deferredArch = null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
335
432
|
steps.push('Bump `react`, `@react-native/*` packages and the Metro/Babel config to the versions the Upgrade Helper shows for the target.');
|
|
336
433
|
const patchesReview = byKind('patches-review')[0];
|
|
337
434
|
if (patchesReview) {
|
|
@@ -349,15 +446,38 @@ export function buildPlan(result) {
|
|
|
349
446
|
const expo = byKind('upgrade-expo')[0];
|
|
350
447
|
if (expo) {
|
|
351
448
|
const steps = [];
|
|
352
|
-
for (let v = expo.from + 1; v <= expo.to; v++)
|
|
449
|
+
for (let v = expo.from + 1; v <= expo.to; v++) {
|
|
450
|
+
steps.push(`SDK ${v - 1} → ${v}: \`npx expo install expo@^${v}.0.0 --fix\`, read the SDK ${v} changelog, run \`npx expo-doctor\`, build.`);
|
|
451
|
+
// SDK 52 ships React Native 0.76, the first version where switching is practical.
|
|
452
|
+
if (deferredArch && v >= 52) {
|
|
453
|
+
steps.push(`**At SDK ${v}, turn on the New Architecture** before going further (it is the default from SDK 53, and React Native 0.${NEW_ARCH_ONLY_MINOR} removed the legacy one):`, ...deferredArch.map((x) => `New Architecture: ${x}`));
|
|
454
|
+
deferredArch = null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
353
457
|
steps.push('Each SDK brings its own React Native version; do not upgrade React Native separately.');
|
|
458
|
+
if (expoMoves.length) {
|
|
459
|
+
const names = expoMoves.map((d) => `\`${d.name}\``);
|
|
460
|
+
steps.push(`\`npx expo install --fix\` also moves ${names.slice(0, 8).join(', ')}${names.length > 8 ? ` and ${names.length - 8} more` : ''} to the versions each SDK expects. Do not bump them yourself: a newer major than the SDK supports fails to build.`);
|
|
461
|
+
}
|
|
354
462
|
if (!result.project.managed) {
|
|
355
463
|
steps.push('This app has its own android/ and ios/ folders: after each SDK, apply the native changes from Expo\'s native project upgrade helper (docs.expo.dev/bare/upgrade), then `pod install` and build both platforms.');
|
|
356
464
|
}
|
|
357
465
|
phases.push({ title: `Upgrade Expo SDK ${expo.from} → ${expo.to}`, why: 'Expo only supports one SDK step at a time reliably.', steps });
|
|
358
466
|
}
|
|
359
467
|
|
|
360
|
-
|
|
468
|
+
if (jsAbandoned.length) {
|
|
469
|
+
phases.push({
|
|
470
|
+
title: 'Plan replacements for abandoned JavaScript packages',
|
|
471
|
+
why: 'These do not block the upgrade (no native code), but nobody fixes their bugs any more. Replace them when you next touch that code.',
|
|
472
|
+
steps: jsAbandoned.map((d) => {
|
|
473
|
+
const owned = d.alternatives.find((a) => installed.has(a));
|
|
474
|
+
if (owned) return `\`${d.name}\`: you already use \`${owned}\`; move the remaining usage there, then \`${pm.remove} ${d.name}\`.`;
|
|
475
|
+
return `\`${d.name}\`${d.alternatives.length ? `: options ${d.alternatives.map((a) => `\`${a}\``).join(', ')}` : ': look for a maintained alternative, or keep it if it does what you need'}.`;
|
|
476
|
+
}),
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const jsBumps = byKind('bump-dep').filter((d) => !d.native && !movesWithExpo(d.name));
|
|
361
481
|
if (jsBumps.length) {
|
|
362
482
|
phases.push({
|
|
363
483
|
title: 'Update JavaScript-only packages',
|
|
@@ -387,7 +507,9 @@ export function buildPlan(result) {
|
|
|
387
507
|
title: 'Verify before you ship',
|
|
388
508
|
why: 'An upgrade is done when the release build works on real devices, not when it compiles.',
|
|
389
509
|
steps: [
|
|
390
|
-
|
|
510
|
+
result.project.managed
|
|
511
|
+
? 'Production builds on both platforms (`eas build --platform all --profile production`, or `npx expo run:android --variant release` and `npx expo run:ios --configuration Release`).'
|
|
512
|
+
: 'Release builds on both platforms (`./gradlew bundleRelease`, Xcode Archive).',
|
|
391
513
|
'Test on a low-end Android device and on the newest iOS and Android versions.',
|
|
392
514
|
'Ship to internal testing / TestFlight first and watch crash-free sessions for a few days before a staged rollout.',
|
|
393
515
|
'Run `npx nativekeel` again: the report should have no critical or high findings.',
|
package/src/registry.js
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
const UA = { 'User-Agent': 'nativekeel' };
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4
|
+
|
|
5
|
+
// GET a JSON document. Returns null for "not found" (a package that does not exist is an
|
|
6
|
+
// answer, not an error). Rate limits (429) and server errors are retried with backoff; if the
|
|
7
|
+
// request still fails, `stats.failed` is incremented so the report can say it is incomplete.
|
|
8
|
+
export async function fetchJson(url, headers = {}, stats = null, { retries = 3, wait = sleep } = {}) {
|
|
9
|
+
for (let attempt = 0; ; attempt++) {
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(url, { headers: { ...UA, ...headers }, signal: AbortSignal.timeout(30000) });
|
|
12
|
+
if (res.ok) return await res.json();
|
|
13
|
+
if (res.status === 404) return null;
|
|
14
|
+
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
|
|
15
|
+
const after = Number(res.headers.get('retry-after'));
|
|
16
|
+
await wait(Math.min(Number.isFinite(after) && after > 0 ? after * 1000 : 1000 * 2 ** attempt, 10000));
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
if (attempt < retries) {
|
|
21
|
+
await wait(1000 * 2 ** attempt);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (stats) stats.failed++;
|
|
9
26
|
return null;
|
|
10
27
|
}
|
|
11
28
|
}
|
package/src/report-html.js
CHANGED
|
@@ -12,7 +12,8 @@ export function htmlReport(result, { brand = null, plan = null } = {}) {
|
|
|
12
12
|
const n = countBySeverity(result.findings);
|
|
13
13
|
const title = `${p.name || 'App'} health report`;
|
|
14
14
|
const sevLabel = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info' };
|
|
15
|
-
const
|
|
15
|
+
const defaultOn = !!result.project.reactNative && Number(result.project.reactNative.split('.')[1]) >= 76;
|
|
16
|
+
const arch = (v) => (v === true ? 'on' : v === false ? 'off' : defaultOn ? 'on (default)' : 'off (default)');
|
|
16
17
|
|
|
17
18
|
const rows = SEVERITIES.flatMap((sev) =>
|
|
18
19
|
result.findings
|
package/src/report-md.js
CHANGED
|
@@ -14,7 +14,7 @@ export function markdownReport(result, { verbose = false } = {}) {
|
|
|
14
14
|
[
|
|
15
15
|
p.reactNative && `React Native **${p.reactNative}**${result.latest.reactNative ? ` (latest ${result.latest.reactNative})` : ''}`,
|
|
16
16
|
p.expo && `Expo SDK **${p.expo.split('.')[0]}**`,
|
|
17
|
-
`New Architecture: Android ${p
|
|
17
|
+
`New Architecture: Android ${archMd(p, p.newArch.android)}, iOS ${archMd(p, p.newArch.ios)}`,
|
|
18
18
|
].filter(Boolean).join(' · '),
|
|
19
19
|
'',
|
|
20
20
|
`${ICON.critical} ${n.critical} critical · ${ICON.high} ${n.high} high · ${ICON.medium} ${n.medium} medium · ${ICON.low} ${n.low} low`,
|
|
@@ -40,3 +40,10 @@ export function markdownReport(result, { verbose = false } = {}) {
|
|
|
40
40
|
lines.push(`<sub>Generated by [NativeKeel](${SITE_URL}) · \`npx nativekeel plan\` for a step-by-step upgrade plan</sub>`);
|
|
41
41
|
return `${lines.join('\n')}\n`;
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
// Not set means React Native's default: off before 0.76, on from 0.76.
|
|
45
|
+
function archMd(p, v) {
|
|
46
|
+
if (v === false) return '**off**';
|
|
47
|
+
if (v === true) return 'on';
|
|
48
|
+
return p.reactNative && Number(p.reactNative.split('.')[1]) >= 76 ? 'on (default)' : '**off** (default)';
|
|
49
|
+
}
|
package/src/report-text.js
CHANGED
|
@@ -14,7 +14,9 @@ export function textReport(result, { color = true, verbose = false } = {}) {
|
|
|
14
14
|
const indent = ' ';
|
|
15
15
|
const out = [];
|
|
16
16
|
const p = result.project;
|
|
17
|
-
|
|
17
|
+
// Not set means React Native's default: off before 0.76, on from 0.76.
|
|
18
|
+
const defaultOn = !!p.reactNative && Number(p.reactNative.split('.')[1]) >= 76;
|
|
19
|
+
const arch = (v) => (v === true ? c.green('on') : v === false ? c.red('off') : defaultOn ? c.gray('on (default)') : c.red('off (default)'));
|
|
18
20
|
|
|
19
21
|
out.push('');
|
|
20
22
|
out.push(`${c.bold('NativeKeel health report')} ${p.name || ''}`);
|
package/src/rules.js
CHANGED
|
@@ -53,6 +53,23 @@ export const REPLACEMENTS = {
|
|
|
53
53
|
'react-native-code-push': ['expo-updates'],
|
|
54
54
|
// react-native-elements was renamed to @rneui, which in turn is continued by @rn-vui.
|
|
55
55
|
'react-native-elements': ['@rn-vui/themed'],
|
|
56
|
+
// Checked on npm and React Native Directory on 2026-10-04 (published and not flagged unmaintained).
|
|
57
|
+
'rn-fetch-blob': ['react-native-blob-util'],
|
|
58
|
+
'@react-native-community/blur': ['expo-blur'],
|
|
59
|
+
'react-native-randombytes': ['react-native-get-random-values', 'expo-crypto'],
|
|
60
|
+
'react-native-securerandom': ['react-native-get-random-values', 'expo-crypto'],
|
|
61
|
+
'react-native-tcp': ['react-native-tcp-socket'],
|
|
62
|
+
'@react-native-community/geolocation': ['expo-location'],
|
|
63
|
+
'react-native-geolocation-service': ['expo-location'],
|
|
64
|
+
'react-native-encrypted-storage': ['react-native-keychain', 'expo-secure-store'],
|
|
65
|
+
'react-native-navigation-bar-color': ['react-native-edge-to-edge', 'expo-navigation-bar'],
|
|
66
|
+
'react-native-quick-actions': ['expo-quick-actions'],
|
|
67
|
+
'react-native-version-number': ['react-native-device-info', 'expo-application'],
|
|
68
|
+
'react-native-mail': ['expo-mail-composer'],
|
|
69
|
+
'react-native-rate': ['expo-store-review'],
|
|
70
|
+
'react-native-print': ['expo-print'],
|
|
71
|
+
'react-native-i18n': ['react-native-localize', 'expo-localization'],
|
|
72
|
+
'expo-av': ['expo-audio', 'expo-video'],
|
|
56
73
|
};
|
|
57
74
|
|
|
58
75
|
export const UPGRADE_HELPER_URL = (from, to) =>
|
package/src/secrets.js
CHANGED
|
@@ -44,7 +44,28 @@ const RULES = [
|
|
|
44
44
|
const PLACEHOLDER = /example|test|dummy|fake|sample|placeholder|xxxx|changeme|redacted|your[_-]?(?:api[_-]?)?(?:key|token|secret)/i;
|
|
45
45
|
|
|
46
46
|
// Test code is never part of the app bundle.
|
|
47
|
-
const TEST_PATH = /(^|\/)(__tests__|__mocks__|__fixtures__|test|tests|e2e|fixtures)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
47
|
+
const TEST_PATH = /(^|\/)(__tests__|__mocks__|__fixtures__|test|tests|e2e|fixtures)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)tests?\.[cm]?[jt]sx?$|[a-z]Test\.[cm]?[jt]sx?$/;
|
|
48
|
+
|
|
49
|
+
const JS_FILE = /\.[cm]?[jt]sx?$/;
|
|
50
|
+
|
|
51
|
+
// A JSON file only ends up in the JavaScript bundle when code imports it (a service-account
|
|
52
|
+
// file lying in the project root is committed, not shipped). Native resources (plist, xml) and
|
|
53
|
+
// assets under android/ and ios/ are packaged by the build, so they always ship.
|
|
54
|
+
function importedJsonNames(files) {
|
|
55
|
+
const names = new Set();
|
|
56
|
+
for (const file of files) {
|
|
57
|
+
if (!JS_FILE.test(file)) continue;
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
if (fs.statSync(file).size > MAX_FILE_BYTES) continue;
|
|
61
|
+
text = fs.readFileSync(file, 'utf8');
|
|
62
|
+
} catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
for (const m of text.matchAll(/['"`]([^'"`\n]+\.json)['"`]/g)) names.add(path.basename(m[1]));
|
|
66
|
+
}
|
|
67
|
+
return names;
|
|
68
|
+
}
|
|
48
69
|
|
|
49
70
|
const isCommentLine = (line) => /^\s*(\/\/|\/\*|\*|#|<!--)/.test(line);
|
|
50
71
|
|
|
@@ -77,7 +98,9 @@ function gitTrackedFiles(root) {
|
|
|
77
98
|
export function scanSecrets(root) {
|
|
78
99
|
const findings = [];
|
|
79
100
|
const tracked = gitTrackedFiles(root);
|
|
80
|
-
|
|
101
|
+
const files = [...walk(root)];
|
|
102
|
+
let jsonImports = null; // computed only if a JSON file holds a secret
|
|
103
|
+
for (const file of files) {
|
|
81
104
|
let text;
|
|
82
105
|
try {
|
|
83
106
|
if (fs.statSync(file).size > MAX_FILE_BYTES) continue;
|
|
@@ -96,13 +119,18 @@ export function scanSecrets(root) {
|
|
|
96
119
|
const line = text.slice(0, m.index).split('\n').length;
|
|
97
120
|
// Comments are stripped from the bundle; a key there is committed, not shipped.
|
|
98
121
|
const inComment = isCommentLine(lines[line - 1] || '');
|
|
122
|
+
let unreferencedJson = false;
|
|
123
|
+
if (file.endsWith('.json') && !/^(android|ios)\//.test(posix)) {
|
|
124
|
+
jsonImports = jsonImports || importedJsonNames(files);
|
|
125
|
+
unreferencedJson = !jsonImports.has(path.basename(file));
|
|
126
|
+
}
|
|
99
127
|
findings.push({
|
|
100
128
|
rule: rule.id,
|
|
101
129
|
label: rule.label,
|
|
102
130
|
file: rel,
|
|
103
131
|
line,
|
|
104
132
|
preview: value ? mask(value) : null,
|
|
105
|
-
inAppBundle: !SERVER_DIRS.has(posix.split('/')[0]) && !TEST_PATH.test(posix) && !inComment,
|
|
133
|
+
inAppBundle: !SERVER_DIRS.has(posix.split('/')[0]) && !TEST_PATH.test(posix) && !inComment && !unreferencedJson,
|
|
106
134
|
inComment,
|
|
107
135
|
committed: tracked ? tracked.has(posix) : null,
|
|
108
136
|
});
|
package/src/usage.js
CHANGED
|
@@ -22,6 +22,14 @@ const IMPLICIT = [
|
|
|
22
22
|
/^@react-native\//, // versioned with React Native, used by the build
|
|
23
23
|
/^@react-native-community\/cli/,
|
|
24
24
|
/^react-native-vector-icons$/, // fonts linked by the build
|
|
25
|
+
// Work by being installed: native auto-registration or compiler output.
|
|
26
|
+
/^react-native-webp-format$/, // iOS WebP decoder registers itself with the image loader
|
|
27
|
+
/^@react-native-firebase\/(crashlytics|perf)$/, // collect natively without any JS call
|
|
28
|
+
/^react-compiler-runtime$/, // imported by babel-plugin-react-compiler output
|
|
29
|
+
/^@react-native-vector-icons\//, // per-font packages: fonts linked by the build
|
|
30
|
+
/^(hermes-engine|postinstall-postinstall|react-native-web)$/, // engine, install hook, web target
|
|
31
|
+
// Named after Node.js core modules: dependencies require('stream'), Metro resolves the package.
|
|
32
|
+
/^(assert|buffer|constants|crypto|events|os|path|process|punycode|querystring|stream|string_decoder|timers|tty|url|util|vm|zlib)$/,
|
|
25
33
|
];
|
|
26
34
|
|
|
27
35
|
function walk(dir, exts, out = []) {
|
|
@@ -58,7 +66,7 @@ export function packageOf(spec) {
|
|
|
58
66
|
|
|
59
67
|
export function importedPackages(text) {
|
|
60
68
|
const found = new Set();
|
|
61
|
-
const re = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire
|
|
69
|
+
const re = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire(?:\.resolve)?\s*\(\s*|\bjest\.(?:mock|requireActual)\s*\(\s*)['"]([^'"\n]+)['"]/g;
|
|
62
70
|
for (const m of text.matchAll(re)) {
|
|
63
71
|
const pkg = packageOf(m[1]);
|
|
64
72
|
if (pkg) found.add(pkg);
|
|
@@ -98,26 +106,56 @@ export function findUnused(project) {
|
|
|
98
106
|
const configText = [
|
|
99
107
|
JSON.stringify(pkgJson.scripts || {}),
|
|
100
108
|
JSON.stringify(pkgJson.jest || {}),
|
|
109
|
+
// rn-nodeify style aliases: { "react-native": { "crypto": "react-native-crypto" } }
|
|
110
|
+
JSON.stringify(pkgJson['react-native'] || {}),
|
|
111
|
+
JSON.stringify(pkgJson.browser || {}),
|
|
101
112
|
readSmall(path.join(root, 'app.json')),
|
|
102
113
|
readSmall(path.join(root, 'app.config.js')),
|
|
103
114
|
readSmall(path.join(root, 'app.config.ts')),
|
|
104
115
|
readSmall(path.join(root, 'react-native.config.js')),
|
|
116
|
+
// metro/babel/jest config and friends: aliases such as `crypto: 'crypto-browserify'`.
|
|
117
|
+
...fs
|
|
118
|
+
.readdirSync(root)
|
|
119
|
+
.filter((f) => /\.config\.[cm]?[jt]s$|^\.babelrc|^rn-cli\.config/.test(f))
|
|
120
|
+
.map((f) => readSmall(path.join(root, f))),
|
|
105
121
|
].join('\n');
|
|
122
|
+
// CLI tools run from scripts by their command name, which can differ from the package name.
|
|
123
|
+
const scriptText = JSON.stringify(pkgJson.scripts || {});
|
|
124
|
+
const usedByBin = (name) => {
|
|
125
|
+
const dir = findPackageDir(root, name);
|
|
126
|
+
try {
|
|
127
|
+
const bin = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).bin;
|
|
128
|
+
const bins = typeof bin === 'string' ? [name.split('/').pop()] : Object.keys(bin || {});
|
|
129
|
+
return bins.some((b) => scriptText.includes(b));
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
106
134
|
|
|
107
|
-
// Required by
|
|
135
|
+
// Required by any installed package (peer or regular), including indirect ones: e.g.
|
|
136
|
+
// @react-navigation/elements, pulled in by the stack navigator, peers on masked-view.
|
|
108
137
|
const requiredByOthers = new Set();
|
|
109
|
-
for (const
|
|
110
|
-
const dir = findPackageDir(root, other);
|
|
111
|
-
if (!dir) continue;
|
|
138
|
+
for (const manifest of installedManifests(root)) {
|
|
112
139
|
try {
|
|
113
|
-
const m = JSON.parse(fs.readFileSync(
|
|
140
|
+
const m = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
114
141
|
for (const k of ['peerDependencies', 'dependencies']) for (const d of Object.keys(m[k] || {})) requiredByOthers.add(d);
|
|
115
142
|
} catch {
|
|
116
143
|
// unreadable manifest
|
|
117
144
|
}
|
|
118
145
|
}
|
|
119
146
|
|
|
120
|
-
|
|
147
|
+
// A package someone patched (patch-package) is one they care about: never call it unused.
|
|
148
|
+
const patched = new Set();
|
|
149
|
+
try {
|
|
150
|
+
for (const f of fs.readdirSync(path.join(root, 'patches'))) {
|
|
151
|
+
const m = f.match(/^(@[^+]+\+[^+]+|[^+@][^+]*)\+/);
|
|
152
|
+
if (m) patched.add(m[1].replace('+', '/'));
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// no patches folder
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const candidates = names.filter((n) => !used.has(n) && !requiredByOthers.has(n) && !configText.includes(n) && !usedByBin(n) && !patched.has(n));
|
|
121
159
|
if (!candidates.length) return [];
|
|
122
160
|
|
|
123
161
|
const nativeText = ['ios', 'android'].flatMap((d) => walk(path.join(root, d), NATIVE_EXT)).map(readSmall).join('\n');
|
|
@@ -126,6 +164,37 @@ export function findUnused(project) {
|
|
|
126
164
|
.map((name) => ({ name, native: nativeTokens(root, name).length > 0 }));
|
|
127
165
|
}
|
|
128
166
|
|
|
167
|
+
// package.json of every package in the node_modules folders from the app up to the filesystem
|
|
168
|
+
// root (monorepos hoist), one level of scopes deep. Nested node_modules are skipped.
|
|
169
|
+
function installedManifests(root) {
|
|
170
|
+
const out = [];
|
|
171
|
+
let dir = root;
|
|
172
|
+
while (true) {
|
|
173
|
+
const nm = path.join(dir, 'node_modules');
|
|
174
|
+
let entries = [];
|
|
175
|
+
try {
|
|
176
|
+
entries = fs.readdirSync(nm, { withFileTypes: true });
|
|
177
|
+
} catch {
|
|
178
|
+
// none here
|
|
179
|
+
}
|
|
180
|
+
for (const e of entries) {
|
|
181
|
+
if (!e.isDirectory() && !e.isSymbolicLink()) continue;
|
|
182
|
+
if (e.name.startsWith('@')) {
|
|
183
|
+
try {
|
|
184
|
+
for (const sub of fs.readdirSync(path.join(nm, e.name))) out.push(path.join(nm, e.name, sub, 'package.json'));
|
|
185
|
+
} catch {
|
|
186
|
+
// unreadable scope
|
|
187
|
+
}
|
|
188
|
+
} else if (!e.name.startsWith('.')) {
|
|
189
|
+
out.push(path.join(nm, e.name, 'package.json'));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const parent = path.dirname(dir);
|
|
193
|
+
if (parent === dir) return out;
|
|
194
|
+
dir = parent;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
129
198
|
// Source files (relative paths) that import `pkg` and whose text matches `pattern`.
|
|
130
199
|
export function filesImportingWith(root, pkg, pattern) {
|
|
131
200
|
const re = new RegExp(pattern);
|