nativekeel 0.1.4 → 0.1.5
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 +46 -9
- package/src/compat.js +34 -15
- package/src/native.js +21 -4
- package/src/plan.js +78 -10
- package/src/registry.js +23 -6
- package/src/secrets.js +31 -3
- package/src/usage.js +58 -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 {
|
|
@@ -53,9 +53,30 @@ function isNativeModule(root, name, dir) {
|
|
|
53
53
|
return !!(dir && (dir.ios || dir.android) && !dir.web);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function readJsonFile(file) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Expo's pins are ~x.y.z (same minor), ^x.y.z (same major), or an exact version.
|
|
65
|
+
export function satisfiesExpoRange(version, range) {
|
|
66
|
+
const m = String(range).trim().match(/^([~^]?)\s*(\d+)\.(\d+)\.(\d+)/);
|
|
67
|
+
if (!m) return true; // a range we do not understand: do not second-guess Expo
|
|
68
|
+
const [maj, min] = version.split('.').map(Number);
|
|
69
|
+
if (compareVersions(version, `${m[2]}.${m[3]}.${m[4]}`) < 0) return false;
|
|
70
|
+
if (m[1] === '^') return maj === Number(m[2]);
|
|
71
|
+
if (m[1] === '~') return maj === Number(m[2]) && min === Number(m[3]);
|
|
72
|
+
return compareVersions(version, `${m[2]}.${m[3]}.${m[4]}`) === 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
56
75
|
// `offline: true` means the user asked for no network access (get returns nothing).
|
|
57
76
|
export async function analyze(project, { get, now = new Date(), offline: forcedOffline = false } = {}) {
|
|
58
|
-
|
|
77
|
+
// Requests that failed even after retries (rate limits, network): reported, never hidden.
|
|
78
|
+
const net = { failed: 0 };
|
|
79
|
+
const registry = createRegistry(get || ((url, headers) => fetchJson(url, headers, net)));
|
|
59
80
|
const findings = [];
|
|
60
81
|
const add = (f) => findings.push(f);
|
|
61
82
|
|
|
@@ -83,6 +104,9 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
83
104
|
if (project.isLibrary) {
|
|
84
105
|
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
106
|
}
|
|
107
|
+
const reportIncomplete = () => {
|
|
108
|
+
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.`);
|
|
109
|
+
};
|
|
86
110
|
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
111
|
if (!offline && project.expoVersion && !expo) warnings.push('Could not fetch Expo releases from npm; the SDK check was skipped. Run again.');
|
|
88
112
|
|
|
@@ -122,14 +146,16 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
122
146
|
}
|
|
123
147
|
|
|
124
148
|
// 2. New Architecture
|
|
125
|
-
|
|
149
|
+
// Not set means the default: off before 0.76, on from 0.76.
|
|
150
|
+
const unset = minorOf(current) < 76 ? ['android', 'ios'].filter((p) => project.newArch[p] === null) : [];
|
|
151
|
+
const off = ['android', 'ios'].filter((p) => project.newArch[p] === false || unset.includes(p));
|
|
126
152
|
if (minorOf(current) < NEW_ARCH_ONLY_MINOR && off.length && rn.lines[0] >= NEW_ARCH_ONLY_MINOR) {
|
|
127
153
|
add({
|
|
128
154
|
id: 'new-arch-disabled',
|
|
129
155
|
severity: 'critical',
|
|
130
156
|
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.`,
|
|
157
|
+
title: `New Architecture is ${unset.length === off.length ? 'off' : 'disabled'} (${off.join(' + ')})`,
|
|
158
|
+
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
159
|
fix: { kind: 'enable-new-arch', platforms: off },
|
|
134
160
|
});
|
|
135
161
|
}
|
|
@@ -297,7 +323,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
297
323
|
const newArchOn = project.newArch.android !== false && project.newArch.ios !== false && minorOf(current || '0.0') >= 76;
|
|
298
324
|
// The legacy architecture ends at 0.81, so any older app with it off has to migrate; this
|
|
299
325
|
// holds even when the latest release could not be fetched (offline).
|
|
300
|
-
const archOff = project.newArch.android === false || project.newArch.ios === false;
|
|
326
|
+
const archOff = project.newArch.android === false || project.newArch.ios === false || (!!current && minorOf(current) < 76 && (project.newArch.android === null || project.newArch.ios === null));
|
|
301
327
|
const newArchRequired =
|
|
302
328
|
findings.some((f) => f.id === 'new-arch-disabled') || (archOff && current && minorOf(current) < NEW_ARCH_ONLY_MINOR);
|
|
303
329
|
for (const { issue, dep } of matchKnownIssues({ deps, rnVersion: current, newArchOn, newArchRequired })) {
|
|
@@ -321,20 +347,30 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
321
347
|
}
|
|
322
348
|
|
|
323
349
|
// Library ↔ React Native compatibility, from the libraries' own tables.
|
|
350
|
+
// Expo SDKs pin tested versions of these libraries (bundledNativeModules.json). A version
|
|
351
|
+
// inside Expo's range is a combination Expo ships, even where the library's table is stricter.
|
|
352
|
+
const expoDir = project.expoVersion ? findPackageDir(project.root, 'expo') : null;
|
|
353
|
+
const expoPins = expoDir ? readJsonFile(path.join(expoDir, 'bundledNativeModules.json')) : null;
|
|
354
|
+
const expoSdk = project.expoVersion ? majorOf(project.expoVersion) : null;
|
|
324
355
|
if (current) {
|
|
325
356
|
const rnMinor = minorOf(current);
|
|
326
357
|
for (const dep of deps.filter((d) => COMPAT[d.name])) {
|
|
358
|
+
const pinned = expoPins && expoPins[dep.name];
|
|
359
|
+
if (pinned && dep.version && satisfiesExpoRange(dep.version, pinned)) continue;
|
|
327
360
|
const problem = checkCompat(dep.name, dep.version, rnMinor, newArchOn);
|
|
328
361
|
// Also check the architecture this app has to move to.
|
|
329
362
|
const ahead = !problem && newArchRequired ? checkCompat(dep.name, dep.version, rnMinor, true) : null;
|
|
330
363
|
if (!problem && !ahead) continue;
|
|
331
364
|
const best = bestRange(dep.name, rnMinor, problem ? newArchOn : true);
|
|
365
|
+
let expoNote = '';
|
|
366
|
+
if (pinned) expoNote = ` Expo SDK ${expoSdk} expects ${pinned}: run \`npx expo install --fix\`.`;
|
|
367
|
+
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
368
|
add({
|
|
333
369
|
id: `compat:${dep.name}`,
|
|
334
|
-
severity: problem ? 'high' : 'medium',
|
|
370
|
+
severity: problem && !(expoSdk && !pinned) ? 'high' : 'medium',
|
|
335
371
|
area: 'dependency',
|
|
336
372
|
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}` : ''}`,
|
|
373
|
+
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
374
|
fix: { kind: 'compat', name: dep.name, range: best && best.range, forNewArch: !problem },
|
|
339
375
|
});
|
|
340
376
|
}
|
|
@@ -392,6 +428,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
392
428
|
}
|
|
393
429
|
}
|
|
394
430
|
|
|
431
|
+
reportIncomplete();
|
|
395
432
|
findings.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
|
|
396
433
|
return {
|
|
397
434
|
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
|
}
|
|
@@ -290,13 +290,26 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
290
290
|
// uses "required reason" APIs, so every React Native app needs one.
|
|
291
291
|
if (hasIos && !project.isLibrary) {
|
|
292
292
|
const manifests = findFiles(path.join(root, 'ios'), (name) => name.endsWith('.xcprivacy'));
|
|
293
|
-
|
|
293
|
+
// From 0.75, `pod install` writes the manifest (with React Native's and the pods' reasons)
|
|
294
|
+
// and adds it to the app target, unless the Podfile turns aggregation off.
|
|
295
|
+
const aggregationOff = /privacy_file_aggregation_enabled\s*(?:=>|:)\s*false|RCT_AGGREGATE_PRIVACY_FILES['"]?\]?\s*=\s*['"]?0/.test(podfile);
|
|
296
|
+
const generated = rn !== null && rn >= 75 && !aggregationOff;
|
|
297
|
+
if (!manifests.length && generated) {
|
|
298
|
+
add({
|
|
299
|
+
id: 'ios-privacy-manifest',
|
|
300
|
+
severity: 'low',
|
|
301
|
+
area: 'store',
|
|
302
|
+
title: 'iOS privacy manifest is generated at pod install, not committed',
|
|
303
|
+
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.",
|
|
304
|
+
fix: { kind: 'privacy-manifest', generated: true },
|
|
305
|
+
});
|
|
306
|
+
} else if (!manifests.length) {
|
|
294
307
|
add({
|
|
295
308
|
id: 'ios-privacy-manifest',
|
|
296
309
|
severity: 'high',
|
|
297
310
|
area: 'store',
|
|
298
311
|
title: 'iOS privacy manifest (PrivacyInfo.xcprivacy) is missing',
|
|
299
|
-
detail:
|
|
312
|
+
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
313
|
fix: { kind: 'privacy-manifest' },
|
|
301
314
|
});
|
|
302
315
|
}
|
|
@@ -429,7 +442,11 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
429
442
|
...[...text.matchAll(/^\s*([A-Z0-9_]*(?:STORE|KEY)_PASSWORD)\s*=\s*(\S+)\s*$/gm)].map((m) => ({ name: m[1], value: m[2] })),
|
|
430
443
|
// Not `props['storePassword']`: there the name is a lookup key, not an assignment.
|
|
431
444
|
...[...text.matchAll(/(?<![\w'"[])(storePassword|keyPassword)\s*=?\s*["']([^"'\n]+)["']/g)].map((m) => ({ name: m[1], value: m[2] })),
|
|
432
|
-
]
|
|
445
|
+
]
|
|
446
|
+
.map((l) => ({ ...l, value: l.value.replace(/^["']|["']$/g, '') }))
|
|
447
|
+
// Not leaks: the debug keystore's public password, environment references, empty
|
|
448
|
+
// values and template placeholders (*****, <password>, your_password).
|
|
449
|
+
.filter((l) => l.value && l.value !== 'android' && !/^System\.getenv|^\$\{?[A-Z_]+\}?$/.test(l.value) && !/^(\*+|x+|<.*>|your[_-]?\w*|changeme)$/i.test(l.value));
|
|
433
450
|
if (leaked.length) {
|
|
434
451
|
add({
|
|
435
452
|
id: `signing-password:${f}`,
|
package/src/plan.js
CHANGED
|
@@ -125,8 +125,40 @@ function safetyNetPhase(tool) {
|
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// Libraries whose version an Expo SDK decides (from Expo's bundledNativeModules.json). When the
|
|
129
|
+
// app's own copy of that file is installed it is used instead; this list covers scans without
|
|
130
|
+
// node_modules.
|
|
131
|
+
const EXPO_PINNED = new Set([
|
|
132
|
+
'react-native-reanimated', 'react-native-worklets', 'react-native-gesture-handler', 'react-native-screens',
|
|
133
|
+
'react-native-safe-area-context', 'react-native-webview', 'react-native-svg', 'react-native-pager-view',
|
|
134
|
+
'react-native-maps', 'react-native-view-shot', 'react-native-get-random-values', 'react-native-keyboard-controller',
|
|
135
|
+
'@react-native-async-storage/async-storage', '@react-native-community/datetimepicker', '@react-native-community/slider',
|
|
136
|
+
'@react-native-community/netinfo', '@react-native-picker/picker', '@react-native-masked-view/masked-view',
|
|
137
|
+
'lottie-react-native', '@shopify/flash-list', '@shopify/react-native-skia', '@stripe/stripe-react-native',
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
function expoPinnedNames(root) {
|
|
141
|
+
try {
|
|
142
|
+
let dir = root;
|
|
143
|
+
while (true) {
|
|
144
|
+
const file = path.join(dir, 'node_modules', 'expo', 'bundledNativeModules.json');
|
|
145
|
+
if (fs.existsSync(file)) return new Set(Object.keys(JSON.parse(fs.readFileSync(file, 'utf8'))));
|
|
146
|
+
const parent = path.dirname(dir);
|
|
147
|
+
if (parent === dir) return EXPO_PINNED;
|
|
148
|
+
dir = parent;
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
return EXPO_PINNED;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
128
155
|
export function buildPlan(result) {
|
|
129
|
-
const
|
|
156
|
+
const isExpo = !!result.project.expo;
|
|
157
|
+
const basePm = commands[packageManager(result.project.root)];
|
|
158
|
+
// In Expo projects `npx expo install` picks the version that matches the SDK.
|
|
159
|
+
const pm = isExpo ? { ...basePm, add: 'npx expo install' } : basePm;
|
|
160
|
+
const pinned = isExpo ? expoPinnedNames(result.project.root) : new Set();
|
|
161
|
+
const movesWithExpo = (name) => isExpo && (/^(expo-|@expo\/)/.test(name) || name === 'expo' || name === 'jest-expo' || pinned.has(name));
|
|
130
162
|
const installed = new Set(result.deps.map((d) => d.name));
|
|
131
163
|
const byKind = (kind) => result.findings.filter((f) => f.fix && f.fix.kind === kind).map((f) => f.fix);
|
|
132
164
|
const phases = [];
|
|
@@ -190,7 +222,10 @@ export function buildPlan(result) {
|
|
|
190
222
|
}
|
|
191
223
|
|
|
192
224
|
const storeSteps = [];
|
|
193
|
-
|
|
225
|
+
const privacy = byKind('privacy-manifest')[0];
|
|
226
|
+
if (privacy && privacy.generated) {
|
|
227
|
+
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).');
|
|
228
|
+
} else if (privacy) {
|
|
194
229
|
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
230
|
}
|
|
196
231
|
const align = byKind('align-16kb')[0];
|
|
@@ -263,7 +298,8 @@ export function buildPlan(result) {
|
|
|
263
298
|
});
|
|
264
299
|
}
|
|
265
300
|
|
|
266
|
-
const
|
|
301
|
+
const expoMoves = byKind('bump-dep').filter((d) => movesWithExpo(d.name));
|
|
302
|
+
const nativeBumps = byKind('bump-dep').filter((d) => d.native && !movesWithExpo(d.name));
|
|
267
303
|
if (nativeBumps.length) {
|
|
268
304
|
phases.push({
|
|
269
305
|
title: 'Update native modules on the current React Native version',
|
|
@@ -281,6 +317,7 @@ export function buildPlan(result) {
|
|
|
281
317
|
steps: [`In \`${depProvider.file}\`: \`#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>\` and \`self.dependencyProvider = [RCTAppDependencyProvider new];\` before \`[super application:didFinishLaunchingWithOptions:]\`.`],
|
|
282
318
|
});
|
|
283
319
|
}
|
|
320
|
+
let deferredArch = null;
|
|
284
321
|
if (arch) {
|
|
285
322
|
const steps = [];
|
|
286
323
|
for (const j of byKind('interop-job')) {
|
|
@@ -301,11 +338,18 @@ export function buildPlan(result) {
|
|
|
301
338
|
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).');
|
|
302
339
|
steps.push('Build debug and release on both platforms.');
|
|
303
340
|
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
|
-
|
|
341
|
+
// Before 0.76 the New Architecture is too immature to switch on in place: do it on the way,
|
|
342
|
+
// at the first stop between 0.76 and 0.81 (or Expo SDK 52+), in its own commit.
|
|
343
|
+
const currentMinor = Number((result.project.reactNative || '0.99').split('.')[1]);
|
|
344
|
+
if (currentMinor < 76) {
|
|
345
|
+
deferredArch = steps;
|
|
346
|
+
} else {
|
|
347
|
+
phases.push({
|
|
348
|
+
title: 'Turn on the New Architecture on your current version',
|
|
349
|
+
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.`,
|
|
350
|
+
steps,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
309
353
|
}
|
|
310
354
|
|
|
311
355
|
const rn = byKind('upgrade-rn')[0];
|
|
@@ -332,6 +376,19 @@ export function buildPlan(result) {
|
|
|
332
376
|
from = to;
|
|
333
377
|
return step;
|
|
334
378
|
});
|
|
379
|
+
if (deferredArch) {
|
|
380
|
+
const hops = upgradeHops(rn.from, rn.to);
|
|
381
|
+
const at = hops.findIndex((h) => Number(h.split('.')[1]) >= 76 && Number(h.split('.')[1]) < NEW_ARCH_ONLY_MINOR);
|
|
382
|
+
if (at >= 0) {
|
|
383
|
+
steps.splice(
|
|
384
|
+
at + 1,
|
|
385
|
+
0,
|
|
386
|
+
`**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:`,
|
|
387
|
+
...deferredArch.map((x) => `New Architecture: ${x}`),
|
|
388
|
+
);
|
|
389
|
+
deferredArch = null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
335
392
|
steps.push('Bump `react`, `@react-native/*` packages and the Metro/Babel config to the versions the Upgrade Helper shows for the target.');
|
|
336
393
|
const patchesReview = byKind('patches-review')[0];
|
|
337
394
|
if (patchesReview) {
|
|
@@ -349,15 +406,26 @@ export function buildPlan(result) {
|
|
|
349
406
|
const expo = byKind('upgrade-expo')[0];
|
|
350
407
|
if (expo) {
|
|
351
408
|
const steps = [];
|
|
352
|
-
for (let v = expo.from + 1; v <= expo.to; v++)
|
|
409
|
+
for (let v = expo.from + 1; v <= expo.to; v++) {
|
|
410
|
+
steps.push(`SDK ${v - 1} → ${v}: \`npx expo install expo@^${v}.0.0 --fix\`, read the SDK ${v} changelog, run \`npx expo-doctor\`, build.`);
|
|
411
|
+
// SDK 52 ships React Native 0.76, the first version where switching is practical.
|
|
412
|
+
if (deferredArch && v >= 52) {
|
|
413
|
+
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}`));
|
|
414
|
+
deferredArch = null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
353
417
|
steps.push('Each SDK brings its own React Native version; do not upgrade React Native separately.');
|
|
418
|
+
if (expoMoves.length) {
|
|
419
|
+
const names = expoMoves.map((d) => `\`${d.name}\``);
|
|
420
|
+
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.`);
|
|
421
|
+
}
|
|
354
422
|
if (!result.project.managed) {
|
|
355
423
|
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
424
|
}
|
|
357
425
|
phases.push({ title: `Upgrade Expo SDK ${expo.from} → ${expo.to}`, why: 'Expo only supports one SDK step at a time reliably.', steps });
|
|
358
426
|
}
|
|
359
427
|
|
|
360
|
-
const jsBumps = byKind('bump-dep').filter((d) => !d.native);
|
|
428
|
+
const jsBumps = byKind('bump-dep').filter((d) => !d.native && !movesWithExpo(d.name));
|
|
361
429
|
if (jsBumps.length) {
|
|
362
430
|
phases.push({
|
|
363
431
|
title: 'Update JavaScript-only packages',
|
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/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,10 @@ 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
|
|
25
29
|
];
|
|
26
30
|
|
|
27
31
|
function walk(dir, exts, out = []) {
|
|
@@ -58,7 +62,7 @@ export function packageOf(spec) {
|
|
|
58
62
|
|
|
59
63
|
export function importedPackages(text) {
|
|
60
64
|
const found = new Set();
|
|
61
|
-
const re = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire
|
|
65
|
+
const re = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire(?:\.resolve)?\s*\(\s*|\bjest\.(?:mock|requireActual)\s*\(\s*)['"]([^'"\n]+)['"]/g;
|
|
62
66
|
for (const m of text.matchAll(re)) {
|
|
63
67
|
const pkg = packageOf(m[1]);
|
|
64
68
|
if (pkg) found.add(pkg);
|
|
@@ -102,22 +106,38 @@ export function findUnused(project) {
|
|
|
102
106
|
readSmall(path.join(root, 'app.config.js')),
|
|
103
107
|
readSmall(path.join(root, 'app.config.ts')),
|
|
104
108
|
readSmall(path.join(root, 'react-native.config.js')),
|
|
109
|
+
// metro/babel/jest config and friends: aliases such as `crypto: 'crypto-browserify'`.
|
|
110
|
+
...fs
|
|
111
|
+
.readdirSync(root)
|
|
112
|
+
.filter((f) => /\.config\.[cm]?[jt]s$|^\.babelrc|^rn-cli\.config/.test(f))
|
|
113
|
+
.map((f) => readSmall(path.join(root, f))),
|
|
105
114
|
].join('\n');
|
|
115
|
+
// CLI tools run from scripts by their command name, which can differ from the package name.
|
|
116
|
+
const scriptText = JSON.stringify(pkgJson.scripts || {});
|
|
117
|
+
const usedByBin = (name) => {
|
|
118
|
+
const dir = findPackageDir(root, name);
|
|
119
|
+
try {
|
|
120
|
+
const bin = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).bin;
|
|
121
|
+
const bins = typeof bin === 'string' ? [name.split('/').pop()] : Object.keys(bin || {});
|
|
122
|
+
return bins.some((b) => scriptText.includes(b));
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
106
127
|
|
|
107
|
-
// Required by
|
|
128
|
+
// Required by any installed package (peer or regular), including indirect ones: e.g.
|
|
129
|
+
// @react-navigation/elements, pulled in by the stack navigator, peers on masked-view.
|
|
108
130
|
const requiredByOthers = new Set();
|
|
109
|
-
for (const
|
|
110
|
-
const dir = findPackageDir(root, other);
|
|
111
|
-
if (!dir) continue;
|
|
131
|
+
for (const manifest of installedManifests(root)) {
|
|
112
132
|
try {
|
|
113
|
-
const m = JSON.parse(fs.readFileSync(
|
|
133
|
+
const m = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
114
134
|
for (const k of ['peerDependencies', 'dependencies']) for (const d of Object.keys(m[k] || {})) requiredByOthers.add(d);
|
|
115
135
|
} catch {
|
|
116
136
|
// unreadable manifest
|
|
117
137
|
}
|
|
118
138
|
}
|
|
119
139
|
|
|
120
|
-
const candidates = names.filter((n) => !used.has(n) && !requiredByOthers.has(n) && !configText.includes(n));
|
|
140
|
+
const candidates = names.filter((n) => !used.has(n) && !requiredByOthers.has(n) && !configText.includes(n) && !usedByBin(n));
|
|
121
141
|
if (!candidates.length) return [];
|
|
122
142
|
|
|
123
143
|
const nativeText = ['ios', 'android'].flatMap((d) => walk(path.join(root, d), NATIVE_EXT)).map(readSmall).join('\n');
|
|
@@ -126,6 +146,37 @@ export function findUnused(project) {
|
|
|
126
146
|
.map((name) => ({ name, native: nativeTokens(root, name).length > 0 }));
|
|
127
147
|
}
|
|
128
148
|
|
|
149
|
+
// package.json of every package in the node_modules folders from the app up to the filesystem
|
|
150
|
+
// root (monorepos hoist), one level of scopes deep. Nested node_modules are skipped.
|
|
151
|
+
function installedManifests(root) {
|
|
152
|
+
const out = [];
|
|
153
|
+
let dir = root;
|
|
154
|
+
while (true) {
|
|
155
|
+
const nm = path.join(dir, 'node_modules');
|
|
156
|
+
let entries = [];
|
|
157
|
+
try {
|
|
158
|
+
entries = fs.readdirSync(nm, { withFileTypes: true });
|
|
159
|
+
} catch {
|
|
160
|
+
// none here
|
|
161
|
+
}
|
|
162
|
+
for (const e of entries) {
|
|
163
|
+
if (!e.isDirectory() && !e.isSymbolicLink()) continue;
|
|
164
|
+
if (e.name.startsWith('@')) {
|
|
165
|
+
try {
|
|
166
|
+
for (const sub of fs.readdirSync(path.join(nm, e.name))) out.push(path.join(nm, e.name, sub, 'package.json'));
|
|
167
|
+
} catch {
|
|
168
|
+
// unreadable scope
|
|
169
|
+
}
|
|
170
|
+
} else if (!e.name.startsWith('.')) {
|
|
171
|
+
out.push(path.join(nm, e.name, 'package.json'));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const parent = path.dirname(dir);
|
|
175
|
+
if (parent === dir) return out;
|
|
176
|
+
dir = parent;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
129
180
|
// Source files (relative paths) that import `pkg` and whose text matches `pattern`.
|
|
130
181
|
export function filesImportingWith(root, pkg, pattern) {
|
|
131
182
|
const re = new RegExp(pattern);
|