nativekeel 0.1.3 → 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/bin/nativekeel.js CHANGED
@@ -156,7 +156,9 @@ async function main() {
156
156
  return result.findings.some((f) => SEVERITIES.indexOf(f.severity) <= threshold) ? 1 : 0;
157
157
  }
158
158
 
159
+ // Exit only after stdout has flushed: process.exit() right after a large write to a pipe
160
+ // cuts the output off at 8 KB (`npx nativekeel --json | jq` got broken JSON).
159
161
  main().then(
160
- (code) => process.exit(code),
162
+ (code) => process.stdout.write('', () => process.exit(code)),
161
163
  (e) => fail(e.stack || e.message),
162
164
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nativekeel",
3
- "version": "0.1.3",
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
- import { findUnused } from './usage.js';
8
- import { matchKnownIssues } from './known-issues.js';
7
+ import { findUnused, filesImportingWith } from './usage.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 {
@@ -47,13 +47,36 @@ function isNativeModule(root, name, dir) {
47
47
  return false;
48
48
  }
49
49
  }
50
- // Without node_modules, trust React Native Directory's platform flags.
50
+ // Without node_modules, ask React Native Directory. Its ios/android flags mean "works on",
51
+ // not "has native code" (pure JS packages set them too), so prefer its hasNativeCode.
52
+ if (dir && dir.github && typeof dir.github.hasNativeCode === 'boolean') return dir.github.hasNativeCode;
51
53
  return !!(dir && (dir.ios || dir.android) && !dir.web);
52
54
  }
53
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
+
54
75
  // `offline: true` means the user asked for no network access (get returns nothing).
55
76
  export async function analyze(project, { get, now = new Date(), offline: forcedOffline = false } = {}) {
56
- 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)));
57
80
  const findings = [];
58
81
  const add = (f) => findings.push(f);
59
82
 
@@ -74,14 +97,35 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
74
97
  if (!project.hasNodeModules) {
75
98
  warnings.push('node_modules not found: versions are read from package.json ranges and native detection is less accurate. Install dependencies for a precise report.');
76
99
  }
100
+ const rnSpec = project.deps['react-native'] || project.devDeps['react-native'];
101
+ if (!project.rnVersion && rnSpec) {
102
+ warnings.push(`React Native is declared as "${rnSpec}", which has no version number (a git fork or a workspace reference). Install dependencies so NativeKeel can read the installed version; version checks were skipped.`);
103
+ }
104
+ if (project.isLibrary) {
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.');
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
+ };
77
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.');
78
111
  if (!offline && project.expoVersion && !expo) warnings.push('Could not fetch Expo releases from npm; the SDK check was skipped. Run again.');
79
112
 
80
113
  // 1. React Native version support
81
114
  const current = project.rnVersion;
115
+ // In an Expo project the SDK pins React Native: upgrading the SDK is how React Native moves,
116
+ // so the Expo finding carries the severity and this one only explains the link.
117
+ const rnFollowsExpo = !!(project.expoVersion && expo);
82
118
  if (rn && current) {
83
119
  const behind = rn.lines[0] - minorOf(current);
84
- if (behind >= SUPPORTED_RN_MINORS) {
120
+ if (rnFollowsExpo && behind > 0) {
121
+ add({
122
+ id: 'rn-via-expo',
123
+ severity: 'info',
124
+ area: 'react-native',
125
+ title: `React Native ${current} comes with Expo SDK ${majorOf(project.expoVersion)}`,
126
+ detail: `Latest React Native is ${rn.latest} (${behind} minor${behind === 1 ? '' : 's'} behind). Upgrading the Expo SDK upgrades React Native with it.`,
127
+ });
128
+ } else if (behind >= SUPPORTED_RN_MINORS) {
85
129
  add({
86
130
  id: 'rn-unsupported',
87
131
  severity: behind >= CRITICAL_RN_MINORS_BEHIND ? 'critical' : 'high',
@@ -102,14 +146,16 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
102
146
  }
103
147
 
104
148
  // 2. New Architecture
105
- 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));
106
152
  if (minorOf(current) < NEW_ARCH_ONLY_MINOR && off.length && rn.lines[0] >= NEW_ARCH_ONLY_MINOR) {
107
153
  add({
108
154
  id: 'new-arch-disabled',
109
155
  severity: 'critical',
110
156
  area: 'new-architecture',
111
- title: `New Architecture is disabled (${off.join(' + ')})`,
112
- 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.`,
113
159
  fix: { kind: 'enable-new-arch', platforms: off },
114
160
  });
115
161
  }
@@ -140,7 +186,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
140
186
  }
141
187
 
142
188
  // 4. Google Play target SDK
143
- const target = project.android && project.android.targetSdk;
189
+ const target = !project.isLibrary && project.android && project.android.targetSdk;
144
190
  if (target) {
145
191
  const today = now.toISOString().slice(0, 10);
146
192
  const { updates, visibility } = PLAY_TARGET_SDK;
@@ -173,11 +219,16 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
173
219
  const published = forcedOffline ? {} : await registry.publishDates(recencyCandidates);
174
220
  const daysSince = (date) => (date ? Math.floor((now - new Date(`${date}T00:00:00Z`)) / 86400000) : null);
175
221
 
222
+ const nativeOf = Object.fromEntries(names.map((n) => [n, isNativeModule(project.root, n, directory[n])]));
223
+ const versionOf = (n) => cleanVersion(installedVersion(project.root, n) || declaredRange(n));
224
+ const archSuspects = names.filter((n) => nativeOf[n] && directory[n] && directory[n].newArchitecture === false);
225
+ const codegen = forcedOffline ? {} : await registry.codegenSupport(archSuspects.map((n) => ({ name: n, version: versionOf(n) })));
226
+
176
227
  const deps = [];
177
228
  for (const name of names) {
178
229
  const dir = directory[name];
179
- const version = cleanVersion(installedVersion(project.root, name) || declaredRange(name));
180
- const native = isNativeModule(project.root, name, dir);
230
+ const version = versionOf(name);
231
+ const native = nativeOf[name];
181
232
  const release = published[name] || {};
182
233
  const age = daysSince(release.date);
183
234
  const revived = !!(dir && dir.unmaintained && age !== null && age <= RECENT_RELEASE_DAYS);
@@ -192,7 +243,22 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
192
243
  };
193
244
  deps.push(dep);
194
245
 
195
- const noNewArch = native && dir && dir.newArchitecture === false;
246
+ const cg = codegen[name];
247
+ // Directory says no New Architecture, but npm may know better.
248
+ const archFixedIn = native && dir && dir.newArchitecture === false && cg && !cg.usedHas && cg.latestHas ? cg.latest : null;
249
+ const noNewArch = native && dir && dir.newArchitecture === false && !(cg && (cg.usedHas || cg.latestHas));
250
+ if (cg && cg.usedHas) dep.newArch = true;
251
+ if (archFixedIn) {
252
+ add({
253
+ id: `dep-risk:${name}`,
254
+ severity: 'medium',
255
+ area: 'dependency',
256
+ title: `${name} ${version || ''}: New Architecture support needs ${archFixedIn}`.replace(' ', ' '),
257
+ detail: `React Native Directory lists it without New Architecture support, but ${archFixedIn} ships a codegen spec. Update to ${archFixedIn} (check its changelog for breaking changes) instead of replacing it.`,
258
+ fix: { kind: 'bump-dep', name, from: version, to: archFixedIn, native },
259
+ });
260
+ continue;
261
+ }
196
262
  // Only code that has to follow React Native or the OS goes stale; a finished JS utility does not.
197
263
  const stale = !dep.unmaintained && !revived && age !== null && age > STALE_RELEASE_DAYS && (native || !!release.rnLink);
198
264
  const alternatives = dir && dir.alternatives && dir.alternatives.length ? dir.alternatives : REPLACEMENTS[name] || [];
@@ -257,35 +323,54 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
257
323
  const newArchOn = project.newArch.android !== false && project.newArch.ios !== false && minorOf(current || '0.0') >= 76;
258
324
  // The legacy architecture ends at 0.81, so any older app with it off has to migrate; this
259
325
  // holds even when the latest release could not be fetched (offline).
260
- 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));
261
327
  const newArchRequired =
262
328
  findings.some((f) => f.id === 'new-arch-disabled') || (archOff && current && minorOf(current) < NEW_ARCH_ONLY_MINOR);
263
329
  for (const { issue, dep } of matchKnownIssues({ deps, rnVersion: current, newArchOn, newArchRequired })) {
330
+ let { severity, title, detail } = issue;
331
+ if (issue.evidence) {
332
+ const files = filesImportingWith(project.root, issue.evidence.imports, issue.evidence.pattern);
333
+ if (files.length) {
334
+ detail = `Found in ${files.slice(0, 3).join(', ')}${files.length > 3 ? ` and ${files.length - 3} more` : ''}. ${detail}`;
335
+ } else {
336
+ ({ severity, title, detail } = { ...issue, ...issue.withoutEvidence });
337
+ }
338
+ }
264
339
  add({
265
340
  id: `known:${issue.id}`,
266
- severity: issue.severity,
341
+ severity,
267
342
  area: 'dependency',
268
- title: issue.title,
269
- detail: `${issue.detail} (installed: ${dep.name} ${dep.version}; seen in: ${issue.source})`,
270
- fix: { kind: 'known-issue', name: dep.name, title: issue.title, detail: issue.detail },
343
+ title,
344
+ detail: `${detail} (installed: ${dep.name} ${dep.version}; seen in: ${issue.source})`,
345
+ fix: { kind: 'known-issue', name: dep.name, title, detail },
271
346
  });
272
347
  }
273
348
 
274
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;
275
355
  if (current) {
276
356
  const rnMinor = minorOf(current);
277
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;
278
360
  const problem = checkCompat(dep.name, dep.version, rnMinor, newArchOn);
279
361
  // Also check the architecture this app has to move to.
280
362
  const ahead = !problem && newArchRequired ? checkCompat(dep.name, dep.version, rnMinor, true) : null;
281
363
  if (!problem && !ahead) continue;
282
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).`;
283
368
  add({
284
369
  id: `compat:${dep.name}`,
285
- severity: problem ? 'high' : 'medium',
370
+ severity: problem && !(expoSdk && !pinned) ? 'high' : 'medium',
286
371
  area: 'dependency',
287
372
  title: problem || `${ahead} (needed for the New Architecture)`,
288
- 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}`,
289
374
  fix: { kind: 'compat', name: dep.name, range: best && best.range, forNewArch: !problem },
290
375
  });
291
376
  }
@@ -322,6 +407,15 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
322
407
  detail: `${value}Anyone who downloads the app can extract it. Revoke it now, then move the call to a server.`,
323
408
  fix: { kind: 'secret', label: s.label, file: s.file, line: s.line, bundled: true },
324
409
  });
410
+ } else if (s.committed !== false && s.inComment) {
411
+ add({
412
+ id: `secret:${s.rule}:${s.file}`,
413
+ severity: 'medium',
414
+ area: 'secret',
415
+ title: `${s.label} in a commented-out line (${where})`,
416
+ detail: `${value}Comments are not shipped in the app, but the line is in the repository and its git history. If the value is real, rotate it; either way, delete the line.`,
417
+ fix: { kind: 'secret', label: s.label, file: s.file, line: s.line, bundled: false },
418
+ });
325
419
  } else if (s.committed !== false) {
326
420
  add({
327
421
  id: `secret:${s.rule}:${s.file}`,
@@ -334,6 +428,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
334
428
  }
335
429
  }
336
430
 
431
+ reportIncomplete();
337
432
  findings.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
338
433
  return {
339
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.',
@@ -38,9 +38,19 @@ export const KNOWN_ISSUES = [
38
38
  rnFrom: 76,
39
39
  when: 'newArch',
40
40
  severity: 'high',
41
- title: 'react-native-linear-gradient 2.x crashes the New Architecture when a screen unmounts',
42
- detail: "2.x has no Fabric component, so BVLinearGradient runs through the interop layer. Its children are not detached before React Native recycles them: \"RCTComponentViewRegistry: Attempt to recycle a mounted view\" (an abort on iOS, right after login in the app where it was found). React Native 0.76+ draws gradients itself: replace it with a small component that sets experimental_backgroundImage: 'linear-gradient(...)' and drop the native package.",
43
- source: 'real upgrade of an RN 0.80 app, 2026-10-04 (UI test after login)',
41
+ title: 'LinearGradient around a Modal: a known New Architecture crash',
42
+ detail: "react-native-linear-gradient 2.x has no Fabric component, so it runs through the interop layer. When a LinearGradient contains a Modal (or a Modal-based bottom sheet), closing it can abort the app: \"RCTComponentViewRegistry: Attempt to recycle a mounted view\" (upstream issue #718). React Native 0.76+ draws gradients itself: experimental_backgroundImage: 'linear-gradient(...)' on a View.",
43
+ source: 'real upgrade of an RN 0.80 app, 2026-10-04 (UI test after login); upstream issue #718',
44
+ // Only high when the code has the crashing shape; many apps use 2.x under Fabric without it.
45
+ evidence: {
46
+ imports: 'react-native-linear-gradient',
47
+ pattern: '<Modal\\b|<RBSheet\\b|<BottomSheetModal\\b|react-native-modal|react-native-raw-bottom-sheet',
48
+ },
49
+ withoutEvidence: {
50
+ severity: 'low',
51
+ title: 'react-native-linear-gradient 2.x runs through the New Architecture interop layer',
52
+ detail: "It has no Fabric component. No LinearGradient next to a Modal was found, which is the layout known to crash (upstream issue #718). React Native 0.76+ can draw gradients itself (experimental_backgroundImage), which removes a native dependency.",
53
+ },
44
54
  },
45
55
  ];
46
56
 
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
  }
@@ -131,7 +131,8 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
131
131
  const appGradle = read(path.join(root, 'android', 'app', 'build.gradle')) || read(path.join(root, 'android', 'app', 'build.gradle.kts')) || '';
132
132
  const gradleProps = read(path.join(root, 'android', 'gradle.properties')) || '';
133
133
  const podfile = read(path.join(root, 'ios', 'Podfile')) || '';
134
- if (/com\.facebook\.flipper|FLIPPER_VERSION/.test(appGradle + gradleProps) || /use_flipper!|FlipperConfiguration\.enabled/.test(podfile)) {
134
+ const flipperWired = /com\.facebook\.flipper/.test(appGradle) || /^\s*[^#\n]*(use_flipper!|FlipperConfiguration\.enabled)/m.test(podfile);
135
+ if (flipperWired) {
135
136
  add({
136
137
  id: 'flipper',
137
138
  severity: 'medium',
@@ -140,6 +141,15 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
140
141
  detail: 'Flipper was removed from React Native in 0.74 and its native libraries are not 16 KB aligned. Remove the Flipper dependencies and initialization code before upgrading; use React Native DevTools instead.',
141
142
  fix: { kind: 'remove-flipper' },
142
143
  });
144
+ } else if (/^\s*FLIPPER_VERSION\s*=/m.test(gradleProps)) {
145
+ // A leftover property from an old template: harmless, but worth deleting.
146
+ add({
147
+ id: 'flipper-leftover',
148
+ severity: 'low',
149
+ area: 'native',
150
+ title: 'Leftover FLIPPER_VERSION in android/gradle.properties',
151
+ detail: 'Nothing uses it (Flipper is not wired into the build). Delete the line so nobody wonders whether Flipper is still there.',
152
+ });
143
153
  }
144
154
 
145
155
  // Podfile still loading the CLI's native_modules.rb: React Native provides use_native_modules!
@@ -278,15 +288,28 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
278
288
 
279
289
  // iOS privacy manifest: required by App Store Connect since 1 May 2024. React Native itself
280
290
  // uses "required reason" APIs, so every React Native app needs one.
281
- if (hasIos) {
291
+ if (hasIos && !project.isLibrary) {
282
292
  const manifests = findFiles(path.join(root, 'ios'), (name) => name.endsWith('.xcprivacy'));
283
- 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) {
284
307
  add({
285
308
  id: 'ios-privacy-manifest',
286
309
  severity: 'high',
287
310
  area: 'store',
288
311
  title: 'iOS privacy manifest (PrivacyInfo.xcprivacy) is missing',
289
- 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.`,
290
313
  fix: { kind: 'privacy-manifest' },
291
314
  });
292
315
  }
@@ -365,7 +388,14 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
365
388
  // someone publish as you. The debug keystore and its public 'android' password are fine.
366
389
  const tracked = gitTracked(root);
367
390
  if (tracked) {
368
- const signing = tracked.filter((f) => /\.(jks|keystore)$/i.test(f) && !/debug/i.test(path.basename(f)));
391
+ const nonEmpty = (f) => {
392
+ try {
393
+ return fs.statSync(path.join(root, f)).size > 0;
394
+ } catch {
395
+ return false;
396
+ }
397
+ };
398
+ const signing = tracked.filter((f) => /\.(jks|keystore)$/i.test(f) && !/debug/i.test(path.basename(f)) && nonEmpty(f));
369
399
  for (const f of signing) {
370
400
  add({
371
401
  id: `signing-keystore:${f}`,
@@ -376,7 +406,16 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
376
406
  fix: { kind: 'secret', label: 'Release keystore', file: f, line: 1, bundled: false },
377
407
  });
378
408
  }
379
- for (const f of tracked.filter((x) => /(^|\/)AuthKey_[A-Z0-9]+\.p8$/.test(x))) {
409
+ // An empty placeholder file is not a key (some repos commit one so fastlane paths resolve).
410
+ const hasContent = (f, marker) => {
411
+ try {
412
+ const text = fs.readFileSync(path.join(root, f), 'utf8');
413
+ return marker ? text.includes(marker) : text.length > 0;
414
+ } catch {
415
+ return false;
416
+ }
417
+ };
418
+ for (const f of tracked.filter((x) => /(^|\/)AuthKey_[A-Z0-9]+\.p8$/.test(x) && hasContent(x, 'PRIVATE KEY'))) {
380
419
  add({
381
420
  id: `signing-asc-key:${f}`,
382
421
  severity: 'critical',
@@ -386,7 +425,7 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
386
425
  fix: { kind: 'secret', label: 'App Store Connect API key', file: f, line: 1, bundled: false },
387
426
  });
388
427
  }
389
- for (const f of tracked.filter((x) => /\.p12$/i.test(x))) {
428
+ for (const f of tracked.filter((x) => /\.p12$/i.test(x) && nonEmpty(x))) {
390
429
  add({
391
430
  id: `signing-p12:${f}`,
392
431
  severity: 'high',
@@ -401,8 +440,13 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
401
440
  const text = read(path.join(root, f)) || '';
402
441
  const leaked = [
403
442
  ...[...text.matchAll(/^\s*([A-Z0-9_]*(?:STORE|KEY)_PASSWORD)\s*=\s*(\S+)\s*$/gm)].map((m) => ({ name: m[1], value: m[2] })),
404
- ...[...text.matchAll(/\b(storePassword|keyPassword)\s*=?\s*["']([^"']+)["']/g)].map((m) => ({ name: m[1], value: m[2] })),
405
- ].filter((l) => l.value !== 'android' && !/^System\.getenv|^\$\{?[A-Z_]+\}?$/.test(l.value));
443
+ // Not `props['storePassword']`: there the name is a lookup key, not an assignment.
444
+ ...[...text.matchAll(/(?<![\w'"[])(storePassword|keyPassword)\s*=?\s*["']([^"'\n]+)["']/g)].map((m) => ({ name: m[1], value: m[2] })),
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));
406
450
  if (leaked.length) {
407
451
  add({
408
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,11 +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
+ }
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
+ }
422
+ if (!result.project.managed) {
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.');
424
+ }
353
425
  phases.push({ title: `Upgrade Expo SDK ${expo.from} → ${expo.to}`, why: 'Expo only supports one SDK step at a time reliably.', steps });
354
426
  }
355
427
 
356
- const jsBumps = byKind('bump-dep').filter((d) => !d.native);
428
+ const jsBumps = byKind('bump-dep').filter((d) => !d.native && !movesWithExpo(d.name));
357
429
  if (jsBumps.length) {
358
430
  phases.push({
359
431
  title: 'Update JavaScript-only packages',
package/src/project.js CHANGED
@@ -42,12 +42,71 @@ export function installedVersion(root, name) {
42
42
  return pkg ? pkg.version : null;
43
43
  }
44
44
 
45
+ // pnpm and Bun "catalogs": a dependency declared as `catalog:` or `catalog:<name>` takes its
46
+ // version from the workspace root (pnpm-workspace.yaml, or package.json `workspaces.catalog`).
47
+ // Returns { default: {pkg: range}, <name>: {...} } or null.
48
+ export function readCatalogs(root) {
49
+ let dir = root;
50
+ while (true) {
51
+ const yaml = readText(path.join(dir, 'pnpm-workspace.yaml'));
52
+ if (yaml) return parsePnpmCatalogs(yaml);
53
+ const pkg = dir !== root ? readJson(path.join(dir, 'package.json')) : null;
54
+ const ws = pkg && pkg.workspaces && !Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg;
55
+ if (ws && (ws.catalog || ws.catalogs)) return { default: ws.catalog || {}, ...(ws.catalogs || {}) };
56
+ const parent = path.dirname(dir);
57
+ if (parent === dir) return null;
58
+ dir = parent;
59
+ }
60
+ }
61
+
62
+ // Just enough YAML for the two catalog sections: `catalog:` and `catalogs: <name>:` maps of
63
+ // "package": version lines. Anything else in the file is ignored.
64
+ function parsePnpmCatalogs(text) {
65
+ const out = { default: {} };
66
+ let section = null; // 'default' or a named catalog
67
+ let inCatalogs = false;
68
+ const unquote = (v) => v.trim().replace(/^["']|["']$/g, '');
69
+ for (const raw of text.split('\n')) {
70
+ const line = raw.replace(/\s+#.*$/, '');
71
+ if (!line.trim()) continue;
72
+ const indent = line.length - line.trimStart().length;
73
+ const m = line.trim().match(/^("[^"]+"|'[^']+'|[^:\s]+)\s*:\s*(.*)$/);
74
+ if (!m) continue;
75
+ const key = unquote(m[1]);
76
+ const value = m[2];
77
+ if (indent === 0) {
78
+ inCatalogs = key === 'catalogs';
79
+ section = key === 'catalog' ? 'default' : null;
80
+ } else if (inCatalogs && !value) {
81
+ section = key;
82
+ out[section] = out[section] || {};
83
+ } else if (section && value) {
84
+ out[section][key] = unquote(value);
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+
90
+ function resolveCatalogRefs(deps, catalogs) {
91
+ if (!catalogs) return deps;
92
+ const out = { ...deps };
93
+ for (const [name, spec] of Object.entries(deps)) {
94
+ if (typeof spec !== 'string' || !spec.startsWith('catalog:')) continue;
95
+ const which = spec.slice('catalog:'.length) || 'default';
96
+ const version = catalogs[which] && catalogs[which][name];
97
+ if (version) out[name] = version;
98
+ }
99
+ return out;
100
+ }
101
+
45
102
  export function loadProject(root) {
46
103
  const pkg = readJson(path.join(root, 'package.json'));
47
104
  if (!pkg) throw new Error(`No package.json found in ${root}`);
48
- const deps = { ...(pkg.dependencies || {}) };
105
+ const usesCatalog = JSON.stringify([pkg.dependencies, pkg.devDependencies]).includes('"catalog:');
106
+ const catalogs = usesCatalog ? readCatalogs(root) : null;
107
+ const deps = resolveCatalogRefs({ ...(pkg.dependencies || {}) }, catalogs);
49
108
  // Monorepos often keep react-native in devDependencies; autolinking still links native modules from there.
50
- const devDeps = { ...(pkg.devDependencies || {}) };
109
+ const devDeps = resolveCatalogRefs({ ...(pkg.devDependencies || {}) }, catalogs);
51
110
  const declared = (name) => deps[name] || devDeps[name];
52
111
  if (!declared('react-native') && !declared('expo')) {
53
112
  throw new Error('This does not look like a React Native app (no react-native or expo dependency). Run it inside your app folder.');
@@ -57,9 +116,16 @@ export function loadProject(root) {
57
116
  const hasIos = fs.existsSync(path.join(root, 'ios'));
58
117
  const appConfig = readJson(path.join(root, 'app.json'));
59
118
 
119
+ // A React Native library (or an example inside one) is not an app: store checks do not apply.
120
+ const androidGradle = readText(path.join(root, 'android', 'build.gradle')) || '';
121
+ const isLibrary =
122
+ !!(pkg.peerDependencies && pkg.peerDependencies['react-native']) ||
123
+ /apply plugin:\s*['"]com\.android\.library['"]|id\s*\(?\s*['"]com\.android\.library['"]/.test(androidGradle);
124
+
60
125
  return {
61
126
  root,
62
127
  name: pkg.name,
128
+ isLibrary,
63
129
  deps,
64
130
  devDeps,
65
131
  rnVersion: cleanVersion(installedVersion(root, 'react-native') || declared('react-native') || ''),
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
  }
@@ -69,6 +86,26 @@ export function createRegistry(get = fetchJson) {
69
86
  return out;
70
87
  },
71
88
 
89
+ // Whether the version in use, and the latest, ship a codegen spec (`codegenConfig` in their
90
+ // package.json), i.e. native New Architecture support. React Native Directory's flag lags
91
+ // releases, so this is the tie-breaker before calling a package a blocker.
92
+ async codegenSupport(list, concurrency = 8) {
93
+ const out = {};
94
+ const queue = [...list];
95
+ const manifest = (name, tag) => get(`https://registry.npmjs.org/${name.replace('/', '%2F')}/${tag}`);
96
+ const worker = async () => {
97
+ while (queue.length) {
98
+ const { name, version } = queue.shift();
99
+ const latest = await manifest(name, 'latest');
100
+ if (!latest) continue;
101
+ const used = version && version !== latest.version ? await manifest(name, version) : latest;
102
+ out[name] = { latest: latest.version, latestHas: !!latest.codegenConfig, usedHas: !!(used && used.codegenConfig) };
103
+ }
104
+ };
105
+ await Promise.all(Array.from({ length: concurrency }, worker));
106
+ return out;
107
+ },
108
+
72
109
  // Stable versions of a package, newest release line first.
73
110
  async releaseLines(name, pick) {
74
111
  // The abbreviated "install" document is a fraction of the full packument's size.
package/src/secrets.js CHANGED
@@ -18,7 +18,7 @@ const RULES = [
18
18
  { id: 'openai-key', label: 'OpenAI API key', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b/g },
19
19
  { id: 'anthropic-key', label: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{32,}\b/g },
20
20
  { id: 'github-token', label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
21
- { id: 'slack-token', label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
21
+ { id: 'slack-token', label: 'Slack token', re: /\bxox[baprs]-\d{6,}-[A-Za-z0-9-]{10,}\b/g },
22
22
  { id: 'sendgrid-key', label: 'SendGrid API key', re: /\b(?<v>SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43})\b/g },
23
23
  { id: 'twilio-key', label: 'Twilio API key', re: /\b(?<v>SK[0-9a-fA-F]{32})\b/g },
24
24
  { id: 'google-oauth-secret', label: 'Google OAuth client secret', re: /\b(?<v>GOCSPX-[A-Za-z0-9_-]{28})\b/g },
@@ -40,6 +40,35 @@ const RULES = [
40
40
  },
41
41
  ];
42
42
 
43
+ // Values that are obviously not real: docs examples, test fixtures, templates.
44
+ const PLACEHOLDER = /example|test|dummy|fake|sample|placeholder|xxxx|changeme|redacted|your[_-]?(?:api[_-]?)?(?:key|token|secret)/i;
45
+
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?$|(^|\/)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
+ }
69
+
70
+ const isCommentLine = (line) => /^\s*(\/\/|\/\*|\*|#|<!--)/.test(line);
71
+
43
72
  export function mask(value) {
44
73
  if (value.length <= 8) return '****';
45
74
  return `${value.slice(0, 4)}…${value.slice(-2)}`;
@@ -69,7 +98,9 @@ function gitTrackedFiles(root) {
69
98
  export function scanSecrets(root) {
70
99
  const findings = [];
71
100
  const tracked = gitTrackedFiles(root);
72
- 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) {
73
104
  let text;
74
105
  try {
75
106
  if (fs.statSync(file).size > MAX_FILE_BYTES) continue;
@@ -77,19 +108,31 @@ export function scanSecrets(root) {
77
108
  } catch {
78
109
  continue;
79
110
  }
111
+ const lines = text.split('\n');
80
112
  for (const rule of RULES) {
81
113
  for (const m of text.matchAll(rule.re)) {
82
114
  if (rule.validate && !rule.validate(m)) continue;
115
+ if (PLACEHOLDER.test(m[0])) continue;
83
116
  const rel = path.relative(root, file);
117
+ const posix = rel.split(path.sep).join('/');
84
118
  const value = m.groups && m.groups.v;
119
+ const line = text.slice(0, m.index).split('\n').length;
120
+ // Comments are stripped from the bundle; a key there is committed, not shipped.
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
+ }
85
127
  findings.push({
86
128
  rule: rule.id,
87
129
  label: rule.label,
88
130
  file: rel,
89
- line: text.slice(0, m.index).split('\n').length,
131
+ line,
90
132
  preview: value ? mask(value) : null,
91
- inAppBundle: !SERVER_DIRS.has(rel.split(path.sep)[0]),
92
- committed: tracked ? tracked.has(rel.split(path.sep).join('/')) : null,
133
+ inAppBundle: !SERVER_DIRS.has(posix.split('/')[0]) && !TEST_PATH.test(posix) && !inComment && !unreferencedJson,
134
+ inComment,
135
+ committed: tracked ? tracked.has(posix) : null,
93
136
  });
94
137
  }
95
138
  }
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');
@@ -125,3 +145,46 @@ export function findUnused(project) {
125
145
  .filter((n) => !nativeText.includes(n) && !nativeTokens(root, n).some((t) => nativeText.includes(t)))
126
146
  .map((name) => ({ name, native: nativeTokens(root, name).length > 0 }));
127
147
  }
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
+
180
+ // Source files (relative paths) that import `pkg` and whose text matches `pattern`.
181
+ export function filesImportingWith(root, pkg, pattern) {
182
+ const re = new RegExp(pattern);
183
+ const out = [];
184
+ for (const file of walk(root, JS_EXT)) {
185
+ const text = readSmall(file);
186
+ if (!text.includes(pkg) || !importedPackages(text).has(pkg)) continue;
187
+ if (re.test(text)) out.push(path.relative(root, file));
188
+ }
189
+ return out.sort();
190
+ }