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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nativekeel",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Health check for React Native apps: version support, New Architecture blockers, unmaintained native modules and leaked secrets.",
5
5
  "type": "module",
6
6
  "bin": {
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
- const registry = createRegistry(get);
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
- const off = ['android', 'ios'].filter((p) => project.newArch[p] === false);
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-03)',
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#gesture-handler-2 (checked 2026-10-03)',
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: 'https://docs.swmansion.com/react-native-reanimated/docs/guides/compatibility/ and /docs/3.x/guides/compatibility/ (checked 2026-10-03)',
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.1.99', rnMin: 78, rnMax: 81 },
64
- { from: '4.2.0', to: '4.3.99', rnMin: 80, rnMax: 84 },
65
- { from: '4.4.0', to: '4.5.99', rnMin: 83, rnMax: 86 },
66
- { from: '4.6.0', to: '4.6.99', rnMin: 83, rnMax: 88 },
67
- { from: '4.7.0', to: '4.8.99', rnMin: 86, rnMax: 88 },
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: 71 },
73
- { from: '3.6.0', to: '3.8.99', rnMin: 66, rnMax: 72 },
74
- { from: '3.9.0', to: '3.15.99', rnMin: 70, rnMax: 73 },
75
- { from: '3.16.0', to: '3.16.99', rnMin: 71, rnMax: 77 },
76
- { from: '3.17.0', to: '3.17.99', rnMin: 73, rnMax: 79 },
77
- { from: '3.18.0', to: '3.18.99', rnMin: 73, rnMax: 80 },
78
- { from: '3.19.0', to: '3.19.99', rnMin: 77, rnMax: 81 },
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
- if (!manifests.length) {
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: 'App Store Connect rejects uploads that use required-reason APIs without declaring them. React Native uses several (UserDefaults, file timestamps, system boot time). Add PrivacyInfo.xcprivacy to the app target.',
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
- ].filter((l) => l.value !== 'android' && !/^System\.getenv|^\$\{?[A-Z_]+\}?$/.test(l.value));
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 pm = commands[packageManager(result.project.root)];
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
- if (byKind('privacy-manifest').length) {
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 nativeBumps = byKind('bump-dep').filter((d) => d.native);
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
- phases.push({
305
- title: 'Turn on the New Architecture on your current version',
306
- 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.`,
307
- steps,
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++) steps.push(`SDK ${v - 1} → ${v}: \`npx expo install expo@^${v}.0.0 --fix\`, read the SDK ${v} changelog, run \`npx expo-doctor\`, build.`);
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
- export async function fetchJson(url, headers = {}) {
4
- try {
5
- const res = await fetch(url, { headers: { ...UA, ...headers }, signal: AbortSignal.timeout(30000) });
6
- if (!res.ok) return null;
7
- return await res.json();
8
- } catch {
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
- for (const file of walk(root)) {
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\s*\(\s*|\bjest\.(?:mock|requireActual)\s*\(\s*)['"]([^'"\n]+)['"]/g;
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 another dependency (peer or regular).
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 other of Object.keys(project.deps)) {
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(path.join(dir, 'package.json'), 'utf8'));
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);