nativekeel 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/analyze.js +7 -4
- package/src/native.js +11 -6
- package/src/plan.js +91 -21
- package/src/report-html.js +13 -3
- package/src/report-md.js +8 -1
- package/src/report-sarif.js +6 -1
- package/src/report-text.js +3 -1
- package/src/rules.js +32 -0
- package/src/usage.js +19 -1
package/package.json
CHANGED
package/src/analyze.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
RECENT_RELEASE_DAYS,
|
|
16
16
|
STALE_RELEASE_DAYS,
|
|
17
17
|
PLAY_TARGET_SDK,
|
|
18
|
+
RENAMED,
|
|
18
19
|
REPLACEMENTS,
|
|
19
20
|
SUPPORTED_EXPO_MAJORS,
|
|
20
21
|
SUPPORTED_RN_MINORS,
|
|
@@ -31,8 +32,10 @@ const lineOf = (v) => {
|
|
|
31
32
|
};
|
|
32
33
|
|
|
33
34
|
// react, react-native and @react-native/* move together with the React Native version; @babel/* and types are tooling.
|
|
35
|
+
// Also packages that move with React Native and React themselves (Metro, the scheduler, react-is).
|
|
34
36
|
const isTracked = (name) =>
|
|
35
|
-
!['react-native', 'react', 'expo'].includes(name) &&
|
|
37
|
+
!['react-native', 'react', 'expo', 'react-dom', 'react-is', 'react-test-renderer', 'scheduler', 'hermes-engine'].includes(name) &&
|
|
38
|
+
!/^metro(-|$)/.test(name) &&
|
|
36
39
|
!name.startsWith('@react-native/') &&
|
|
37
40
|
!name.startsWith('@babel/') &&
|
|
38
41
|
!name.startsWith('@types/');
|
|
@@ -261,7 +264,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
261
264
|
}
|
|
262
265
|
// Only code that has to follow React Native or the OS goes stale; a finished JS utility does not.
|
|
263
266
|
const stale = !dep.unmaintained && !revived && age !== null && age > STALE_RELEASE_DAYS && (native || !!release.rnLink);
|
|
264
|
-
const alternatives = dir && dir.alternatives && dir.alternatives.length ? dir.alternatives : REPLACEMENTS[name] || [];
|
|
267
|
+
const alternatives = RENAMED[name] ? [RENAMED[name]] : dir && dir.alternatives && dir.alternatives.length ? dir.alternatives : REPLACEMENTS[name] || [];
|
|
265
268
|
if (noNewArch || dep.unmaintained || stale) {
|
|
266
269
|
const problems = [
|
|
267
270
|
noNewArch && 'no New Architecture support',
|
|
@@ -277,7 +280,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
277
280
|
area: 'dependency',
|
|
278
281
|
title: `${name}: ${problems.join(', ')}`,
|
|
279
282
|
detail: `${why}${alternatives.length ? ` Alternatives: ${alternatives.join(', ')}.` : ''}`,
|
|
280
|
-
fix: { kind: 'replace-dep', name, native, noNewArch, unmaintained: dep.unmaintained || stale, alternatives },
|
|
283
|
+
fix: { kind: 'replace-dep', name, native, noNewArch, unmaintained: dep.unmaintained || stale, alternatives, renamedTo: RENAMED[name] || null },
|
|
281
284
|
});
|
|
282
285
|
} else if (revived) {
|
|
283
286
|
add({
|
|
@@ -342,7 +345,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
342
345
|
area: 'dependency',
|
|
343
346
|
title,
|
|
344
347
|
detail: `${detail} (installed: ${dep.name} ${dep.version}; seen in: ${issue.source})`,
|
|
345
|
-
fix: { kind: 'known-issue', name: dep.name, title, detail },
|
|
348
|
+
fix: { kind: 'known-issue', name: dep.name, title, detail, severity },
|
|
346
349
|
});
|
|
347
350
|
}
|
|
348
351
|
|
package/src/native.js
CHANGED
|
@@ -191,7 +191,9 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
191
191
|
}
|
|
192
192
|
const patched = [];
|
|
193
193
|
for (const f of patchFiles) {
|
|
194
|
-
|
|
194
|
+
// patch-package names: <pkg>+<version>.patch, <pkg>+<version>.dev.patch, and sequenced
|
|
195
|
+
// patches <pkg>+<version>+<NNN>[+<name>].patch (scoped packages use + for the slash).
|
|
196
|
+
const m = f.match(/^(.+?)\+(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\+\d+(?:\+[^/]*)?)?(?:\.dev)?\.patch$/);
|
|
195
197
|
if (!m) continue;
|
|
196
198
|
const pkg = m[1].replace(/\+/g, '/');
|
|
197
199
|
const version = m[2];
|
|
@@ -226,7 +228,7 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
226
228
|
id: 'patches-present',
|
|
227
229
|
severity: 'info',
|
|
228
230
|
area: 'native',
|
|
229
|
-
title: `${patched.length} patch-package patch${patched.length === 1 ? '' : 'es'}: ${patched.map((p) => p.pkg).join(', ')}`,
|
|
231
|
+
title: `${patched.length} patch-package patch${patched.length === 1 ? '' : 'es'}: ${[...new Set(patched.map((p) => p.pkg))].join(', ')}`,
|
|
230
232
|
detail: 'Each was written against one library version and one React Native version. After every upgrade step check that each still applies and is still needed; a patch that adapted a library to an older React Native can break it on a newer one.',
|
|
231
233
|
fix: { kind: 'patches-review', patches: patched },
|
|
232
234
|
});
|
|
@@ -274,15 +276,18 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
274
276
|
}
|
|
275
277
|
|
|
276
278
|
// Hermes: JavaScriptCore is no longer bundled with React Native.
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
+
const hermesOffOn = [
|
|
280
|
+
/^\s*hermesEnabled\s*=\s*false/m.test(gradleProps) && 'android',
|
|
281
|
+
/^[^#\n]*:hermes_enabled\s*=>\s*false/m.test(podfile) && 'ios',
|
|
282
|
+
].filter(Boolean);
|
|
283
|
+
if (hermesOffOn.length) {
|
|
279
284
|
add({
|
|
280
285
|
id: 'hermes-disabled',
|
|
281
286
|
severity: 'medium',
|
|
282
287
|
area: 'native',
|
|
283
|
-
title:
|
|
288
|
+
title: `Hermes is disabled (JavaScriptCore on ${hermesOffOn.join(' + ')})`,
|
|
284
289
|
detail: 'JavaScriptCore is no longer part of React Native; newer versions need the community JSC package. Switching to Hermes is the supported path and usually faster.',
|
|
285
|
-
fix: { kind: 'enable-hermes' },
|
|
290
|
+
fix: { kind: 'enable-hermes', platforms: hermesOffOn },
|
|
286
291
|
});
|
|
287
292
|
}
|
|
288
293
|
|
package/src/plan.js
CHANGED
|
@@ -67,10 +67,26 @@ const WEIGHTS = {
|
|
|
67
67
|
'default-react-host': () => 0.25,
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
// Routine version bumps are many but cheap next to a React Native, architecture or store
|
|
71
|
+
// change; capping them keeps a nearly current app with many outdated packages from being sized
|
|
72
|
+
// like a migration.
|
|
73
|
+
const BUMP_CAPS = { native: 6, js: 2 };
|
|
74
|
+
|
|
70
75
|
export function complexity(findings) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
let nativeBumps = 0;
|
|
77
|
+
let jsBumps = 0;
|
|
78
|
+
let rest = 0;
|
|
79
|
+
for (const f of findings) {
|
|
80
|
+
if (!f.fix || !WEIGHTS[f.fix.kind]) continue;
|
|
81
|
+
const w = WEIGHTS[f.fix.kind](f.fix);
|
|
82
|
+
if (f.fix.kind === 'bump-dep') {
|
|
83
|
+
if (f.fix.native) nativeBumps += w;
|
|
84
|
+
else jsBumps += w;
|
|
85
|
+
} else {
|
|
86
|
+
rest += w;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const score = Math.round(rest + Math.min(nativeBumps, BUMP_CAPS.native) + Math.min(jsBumps, BUMP_CAPS.js));
|
|
74
90
|
const size = score < 8 ? 'Small' : score < 20 ? 'Medium' : score < 40 ? 'Large' : 'Extra large';
|
|
75
91
|
return { score, size };
|
|
76
92
|
}
|
|
@@ -200,7 +216,8 @@ export function buildPlan(result) {
|
|
|
200
216
|
});
|
|
201
217
|
}
|
|
202
218
|
|
|
203
|
-
|
|
219
|
+
// Low-severity notes stay in the report; the plan only carries what can break the app.
|
|
220
|
+
const known = byKind('known-issue').filter((k) => k.severity !== 'low');
|
|
204
221
|
if (known.length) {
|
|
205
222
|
phases.push({
|
|
206
223
|
title: 'Avoid known traps',
|
|
@@ -228,6 +245,11 @@ export function buildPlan(result) {
|
|
|
228
245
|
} else if (privacy) {
|
|
229
246
|
storeSteps.push('Add `PrivacyInfo.xcprivacy` to the iOS app target. Start from the file in the React Native template and declare the required-reason APIs your app and SDKs use (UserDefaults, file timestamps, system boot time, disk space).');
|
|
230
247
|
}
|
|
248
|
+
if (byKind('upgrade-rn-min').length) {
|
|
249
|
+
storeSteps.push(isExpo
|
|
250
|
+
? '**16 KB memory pages:** Google Play rejects updates until the app is on Expo SDK 53 (React Native 0.79) or later. The Expo SDK phase below gets you there; reach SDK 53 before your next Play release.'
|
|
251
|
+
: '**16 KB memory pages:** Google Play rejects updates until the app is on React Native 0.77 or later. The React Native phase below gets you there; reach 0.77 before your next Play release.');
|
|
252
|
+
}
|
|
231
253
|
const align = byKind('align-16kb')[0];
|
|
232
254
|
if (align) {
|
|
233
255
|
storeSteps.push(`Fix 16 KB alignment for ${align.libs.map((l) => `\`${l}\``).join(', ')}: find which package ships each library (\`grep -rl <lib> node_modules/*/android\`) and update it; libraries you build yourself need NDK r28+.`);
|
|
@@ -272,27 +294,46 @@ export function buildPlan(result) {
|
|
|
272
294
|
if (byKind('remove-flipper').length) {
|
|
273
295
|
cleanup.push('Remove Flipper: delete the `com.facebook.flipper` dependencies and `FLIPPER_VERSION` on Android, `use_flipper!` / Flipper config in the Podfile, and the `ReactNativeFlipper` initialization code. Use React Native DevTools instead.');
|
|
274
296
|
}
|
|
275
|
-
|
|
276
|
-
|
|
297
|
+
const hermes = byKind('enable-hermes')[0];
|
|
298
|
+
if (hermes) {
|
|
299
|
+
const where = (hermes.platforms || ['android', 'ios']).map((pl) => (pl === 'android' ? 'set `hermesEnabled=true` in `android/gradle.properties`' : 'set `:hermes_enabled => true` in the Podfile'));
|
|
300
|
+
cleanup.push(`Switch to Hermes: ${where.join(' and ')}, then test anything that relied on JavaScriptCore behavior (Intl, Date parsing).`);
|
|
277
301
|
}
|
|
278
302
|
if (cleanup.length) {
|
|
279
303
|
phases.push({ title: 'Remove legacy tooling', why: 'Leftovers from older templates break on newer React Native versions; removing them first shrinks the upgrade diff.', steps: cleanup });
|
|
280
304
|
}
|
|
281
305
|
|
|
282
|
-
const
|
|
306
|
+
const allReplace = byKind('replace-dep');
|
|
307
|
+
// Abandoned pure-JS packages do not block an upgrade; they get their own, later phase.
|
|
308
|
+
const jsAbandoned = allReplace.filter((d) => !d.native && !d.noNewArch);
|
|
309
|
+
const interopPatched = new Set(byKind('interop-job').map((j) => j.name));
|
|
310
|
+
const replace = allReplace.filter((d) => d.native || d.noNewArch).sort((a, b) => Number(b.noNewArch) - Number(a.noNewArch) || Number(b.native) - Number(a.native));
|
|
283
311
|
if (replace.length) {
|
|
284
312
|
phases.push({
|
|
285
313
|
title: 'Replace blocking and abandoned packages',
|
|
286
314
|
why: 'These packages decide how far you can upgrade. Swap them while the app still runs on its current version, so every problem has one cause.',
|
|
287
315
|
steps: replace.map((d) => {
|
|
288
316
|
const reason = [d.noNewArch && 'no New Architecture support', d.unmaintained && 'unmaintained'].filter(Boolean).join(', ');
|
|
317
|
+
if (d.renamedTo) {
|
|
318
|
+
return `\`${d.name}\` moved to \`${d.renamedTo}\` (same API). \`${pm.remove} ${d.name} && ${pm.add} ${d.renamedTo}\`, then update the imports.`;
|
|
319
|
+
}
|
|
320
|
+
if (interopPatched.has(d.name) && !d.unmaintained) {
|
|
321
|
+
return `\`${d.name}\` (${reason}, native). The New Architecture step below patches it so it loads; move to a release with a TurboModule spec once one exists.`;
|
|
322
|
+
}
|
|
289
323
|
const owned = d.alternatives.find((a) => installed.has(a));
|
|
290
324
|
if (owned) {
|
|
291
325
|
return `\`${d.name}\` (${reason}${d.native ? ', native' : ''}). You already use \`${owned}\`: move the remaining usage there, then \`${pm.remove} ${d.name}\`.`;
|
|
292
326
|
}
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
const
|
|
327
|
+
// expo-* packages need Expo modules; a bare app without them should get another option first.
|
|
328
|
+
const usesExpoModules = isExpo || installed.has('expo');
|
|
329
|
+
const to = usesExpoModules ? d.alternatives[0] : d.alternatives.find((a) => !a.startsWith('expo-')) || d.alternatives[0];
|
|
330
|
+
const needsExpo = to && to.startsWith('expo-') && !usesExpoModules ? ' (Expo packages need Expo modules first: `npx install-expo-modules@latest`)' : '';
|
|
331
|
+
const cmd = to ? ` \`${pm.remove} ${d.name} && ${pm.add} ${to}\`${needsExpo}` : '';
|
|
332
|
+
const options = d.alternatives.length
|
|
333
|
+
? `Options: ${d.alternatives.map((a) => `\`${a}\``).join(', ')}.${cmd}`
|
|
334
|
+
: d.noNewArch
|
|
335
|
+
? 'No known replacement: check for a maintained fork, or vendor and patch it with `patch-package`.'
|
|
336
|
+
: 'Not a known blocker: keep it while it builds and passes your UI flows at every step below, and replace it (or patch it with `patch-package`) when it breaks.';
|
|
296
337
|
return `\`${d.name}\` (${reason}${d.native ? ', native' : ''}). ${options}`;
|
|
297
338
|
}),
|
|
298
339
|
});
|
|
@@ -327,7 +368,7 @@ export function buildPlan(result) {
|
|
|
327
368
|
steps.push(`In \`${depProvider.file}\`, add \`#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>\` and \`self.dependencyProvider = [RCTAppDependencyProvider new];\` before calling super. Without it the switch causes a launch crash in Release.`);
|
|
328
369
|
}
|
|
329
370
|
if (result.project.managed) {
|
|
330
|
-
steps.push('Set `"newArchEnabled": true` in `app.json` (under `expo`), then
|
|
371
|
+
steps.push('Set `"newArchEnabled": true` in `app.json` (under `expo`), then make a new development build (`eas build --profile development`, or `npx expo run:ios` / `npx expo run:android`). Expo Go cannot test this switch.');
|
|
331
372
|
} else {
|
|
332
373
|
if (arch.platforms.includes('android')) steps.push('Android: set `newArchEnabled=true` in `android/gradle.properties`.');
|
|
333
374
|
if (arch.platforms.includes('ios')) steps.push('iOS: set `"newArchEnabled": "true"` in `ios/Podfile.properties.json` (or run `RCT_NEW_ARCH_ENABLED=1 bundle exec pod install`).');
|
|
@@ -335,8 +376,10 @@ export function buildPlan(result) {
|
|
|
335
376
|
for (const c of byKind('compat').filter((x) => x.forNewArch && x.range)) {
|
|
336
377
|
steps.unshift(`First move \`${c.name}\` to ${c.range}: the installed version does not support the New Architecture on this React Native version.`);
|
|
337
378
|
}
|
|
338
|
-
|
|
339
|
-
|
|
379
|
+
if (!result.project.managed) {
|
|
380
|
+
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).');
|
|
381
|
+
}
|
|
382
|
+
steps.push(result.project.managed ? 'Build a development and a production build for both platforms.' : 'Build debug and release on both platforms.');
|
|
340
383
|
steps.push('Click through every screen that uses a native module. Interop-layer problems show up at runtime, not at build time.');
|
|
341
384
|
// Before 0.76 the New Architecture is too immature to switch on in place: do it on the way,
|
|
342
385
|
// at the first stop between 0.76 and 0.81 (or Expo SDK 52+), in its own commit.
|
|
@@ -357,20 +400,33 @@ export function buildPlan(result) {
|
|
|
357
400
|
let from = rn.from;
|
|
358
401
|
const tracked = result.deps.filter((d) => COMPAT[d.name]);
|
|
359
402
|
const lastRange = {};
|
|
360
|
-
const
|
|
403
|
+
const lastMajor = {};
|
|
404
|
+
const hopList = upgradeHops(rn.from, rn.to);
|
|
405
|
+
// When the New Architecture switch happens on the way, library versions up to that stop
|
|
406
|
+
// must still run on the legacy architecture (Reanimated 4, for one, does not).
|
|
407
|
+
const switchAt = deferredArch
|
|
408
|
+
? hopList.findIndex((h) => Number(h.split('.')[1]) >= 76 && Number(h.split('.')[1]) < NEW_ARCH_ONLY_MINOR)
|
|
409
|
+
: -1;
|
|
410
|
+
const majorOf = (v) => Number(String(v).split('.')[0]);
|
|
411
|
+
const steps = hopList.map((to, i) => {
|
|
361
412
|
const minor = Number(to.split('.')[1]);
|
|
413
|
+
const archOn = switchAt < 0 || i > switchAt;
|
|
362
414
|
const bumps = tracked
|
|
363
415
|
.map((d) => {
|
|
364
|
-
const best = bestRange(d.name, minor,
|
|
365
|
-
//
|
|
366
|
-
const major = d.version ?
|
|
367
|
-
const same = major !== null && best &&
|
|
416
|
+
const best = bestRange(d.name, minor, archOn);
|
|
417
|
+
// Stay on the major of the previous step (or the installed one) while it still works.
|
|
418
|
+
const major = lastMajor[d.name] ?? (d.version ? majorOf(d.version) : null);
|
|
419
|
+
const same = major !== null && best && majorOf(best.from) !== major ? bestRange(d.name, minor, archOn, { major }) : null;
|
|
368
420
|
return { name: d.name, best, same };
|
|
369
421
|
})
|
|
370
422
|
.filter((b) => b.best && (b.same || b.best).range !== lastRange[b.name])
|
|
371
423
|
.map((b) => {
|
|
372
|
-
|
|
373
|
-
|
|
424
|
+
const chosen = b.same || b.best;
|
|
425
|
+
const forcedMajor = !b.same && lastMajor[b.name] !== undefined && majorOf(chosen.from) !== lastMajor[b.name];
|
|
426
|
+
lastRange[b.name] = chosen.range;
|
|
427
|
+
lastMajor[b.name] = majorOf(chosen.from);
|
|
428
|
+
if (b.same) return `\`${b.name}\` ${b.same.range} (or ${b.best.range}, a major migration)`;
|
|
429
|
+
return `\`${b.name}\` ${b.best.range}${forcedMajor ? ' (a major migration: read its migration guide first)' : ''}`;
|
|
374
430
|
});
|
|
375
431
|
const step = `${from} → ${to}: apply the diff from ${UPGRADE_HELPER_URL(from, to)}${bumps.length ? `, move ${bumps.join(' and ')}` : ''}, reinstall pods, build both platforms, commit.`;
|
|
376
432
|
from = to;
|
|
@@ -425,6 +481,18 @@ export function buildPlan(result) {
|
|
|
425
481
|
phases.push({ title: `Upgrade Expo SDK ${expo.from} → ${expo.to}`, why: 'Expo only supports one SDK step at a time reliably.', steps });
|
|
426
482
|
}
|
|
427
483
|
|
|
484
|
+
if (jsAbandoned.length) {
|
|
485
|
+
phases.push({
|
|
486
|
+
title: 'Plan replacements for abandoned JavaScript packages',
|
|
487
|
+
why: 'These do not block the upgrade (no native code), but nobody fixes their bugs any more. Replace them when you next touch that code.',
|
|
488
|
+
steps: jsAbandoned.map((d) => {
|
|
489
|
+
const owned = d.alternatives.find((a) => installed.has(a));
|
|
490
|
+
if (owned) return `\`${d.name}\`: you already use \`${owned}\`; move the remaining usage there, then \`${pm.remove} ${d.name}\`.`;
|
|
491
|
+
return `\`${d.name}\`${d.alternatives.length ? `: options ${d.alternatives.map((a) => `\`${a}\``).join(', ')}` : ': look for a maintained alternative, or keep it if it does what you need'}.`;
|
|
492
|
+
}),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
428
496
|
const jsBumps = byKind('bump-dep').filter((d) => !d.native && !movesWithExpo(d.name));
|
|
429
497
|
if (jsBumps.length) {
|
|
430
498
|
phases.push({
|
|
@@ -455,7 +523,9 @@ export function buildPlan(result) {
|
|
|
455
523
|
title: 'Verify before you ship',
|
|
456
524
|
why: 'An upgrade is done when the release build works on real devices, not when it compiles.',
|
|
457
525
|
steps: [
|
|
458
|
-
|
|
526
|
+
result.project.managed
|
|
527
|
+
? 'Production builds on both platforms (`eas build --platform all --profile production`, or `npx expo run:android --variant release` and `npx expo run:ios --configuration Release`).'
|
|
528
|
+
: 'Release builds on both platforms (`./gradlew bundleRelease`, Xcode Archive).',
|
|
459
529
|
'Test on a low-end Android device and on the newest iOS and Android versions.',
|
|
460
530
|
'Ship to internal testing / TestFlight first and watch crash-free sessions for a few days before a staged rollout.',
|
|
461
531
|
'Run `npx nativekeel` again: the report should have no critical or high findings.',
|
package/src/report-html.js
CHANGED
|
@@ -12,13 +12,22 @@ export function htmlReport(result, { brand = null, plan = null } = {}) {
|
|
|
12
12
|
const n = countBySeverity(result.findings);
|
|
13
13
|
const title = `${p.name || 'App'} health report`;
|
|
14
14
|
const sevLabel = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info' };
|
|
15
|
-
const
|
|
15
|
+
const defaultOn = !!result.project.reactNative && Number(result.project.reactNative.split('.')[1]) >= 76;
|
|
16
|
+
const arch = (v) => (v === true ? 'on' : v === false ? 'off' : defaultOn ? 'on (default)' : 'off (default)');
|
|
16
17
|
|
|
18
|
+
// Outdated packages are many and alike: one collapsible table instead of a card each.
|
|
19
|
+
const isBump = (f) => f.id.startsWith('dep-major:');
|
|
17
20
|
const rows = SEVERITIES.flatMap((sev) =>
|
|
18
21
|
result.findings
|
|
19
|
-
.filter((f) => f.severity === sev)
|
|
22
|
+
.filter((f) => f.severity === sev && !isBump(f))
|
|
20
23
|
.map((f) => `<li class="f ${sev}"><span class="tag">${sevLabel[sev]}</span><div><strong>${rich(f.title)}</strong><p>${rich(f.detail)}</p></div></li>`),
|
|
21
24
|
).join('\n');
|
|
25
|
+
const bumps = result.findings.filter(isBump);
|
|
26
|
+
const bumpTable = bumps.length
|
|
27
|
+
? `<details class="bumps"><summary>${bumps.length} package${bumps.length === 1 ? ' is' : 's are'} a major version behind <span class="muted">(low)</span></summary><table><thead><tr><th>Package</th><th>Installed</th><th>Latest</th><th></th></tr></thead><tbody>${bumps
|
|
28
|
+
.map((f) => `<tr><td>${esc(f.fix.name)}</td><td>${esc(f.fix.from || '')}</td><td>${esc(f.fix.to || '')}</td><td class="muted">${f.fix.native ? 'native' : ''}</td></tr>`)
|
|
29
|
+
.join('')}</tbody></table></details>`
|
|
30
|
+
: '';
|
|
22
31
|
|
|
23
32
|
const planHtml = plan
|
|
24
33
|
? `<section><h2>Upgrade plan <small>scope: ${esc(plan.complexity.size)}</small></h2>${plan.phases
|
|
@@ -49,6 +58,7 @@ h1{font-size:26px;margin:0 0 4px}h2{font-size:19px;margin:32px 0 12px}h2 small{f
|
|
|
49
58
|
.stat.critical b{color:var(--critical)}.stat.high b{color:var(--high)}
|
|
50
59
|
.facts{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 16px;margin-top:12px;display:flex;flex-wrap:wrap;gap:6px 24px;font-size:14px}
|
|
51
60
|
ul.list{list-style:none;padding:0;margin:0;display:grid;gap:8px}
|
|
61
|
+
details.bumps{margin-top:8px;background:var(--card);border:1px solid var(--line);border-left:4px solid var(--low);border-radius:8px;padding:10px 14px}details.bumps summary{cursor:pointer;font-weight:600}details.bumps table{width:100%;border-collapse:collapse;margin-top:10px;font-size:14px}details.bumps th,details.bumps td{text-align:left;padding:4px 8px;border-top:1px solid var(--line)}.muted{color:var(--muted);font-weight:400}
|
|
52
62
|
.f{display:flex;gap:12px;background:var(--card);border:1px solid var(--line);border-left:4px solid var(--low);border-radius:8px;padding:10px 14px}
|
|
53
63
|
.f p{margin:2px 0 0;color:var(--muted);font-size:14px}.f strong{overflow-wrap:anywhere}
|
|
54
64
|
.tag{flex:none;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;min-width:64px;padding-top:2px}
|
|
@@ -79,7 +89,7 @@ ${p.android && p.android.targetSdk ? `<span>targetSdk <b>${p.android.targetSdk}<
|
|
|
79
89
|
<span>${result.deps.length} dependencies (${result.deps.filter((d) => d.native).length} native)</span>
|
|
80
90
|
</div>
|
|
81
91
|
<h2>Findings</h2>
|
|
82
|
-
${result.findings.length ? `<ul class="list">${rows}</ul>` : '<p>No issues found.</p>'}
|
|
92
|
+
${result.findings.length ? `${rows ? `<ul class="list">${rows}</ul>` : ''}${bumpTable}` : '<p>No issues found.</p>'}
|
|
83
93
|
${planHtml}
|
|
84
94
|
<footer>${footer}</footer>
|
|
85
95
|
</main></body></html>
|
package/src/report-md.js
CHANGED
|
@@ -14,7 +14,7 @@ export function markdownReport(result, { verbose = false } = {}) {
|
|
|
14
14
|
[
|
|
15
15
|
p.reactNative && `React Native **${p.reactNative}**${result.latest.reactNative ? ` (latest ${result.latest.reactNative})` : ''}`,
|
|
16
16
|
p.expo && `Expo SDK **${p.expo.split('.')[0]}**`,
|
|
17
|
-
`New Architecture: Android ${p
|
|
17
|
+
`New Architecture: Android ${archMd(p, p.newArch.android)}, iOS ${archMd(p, p.newArch.ios)}`,
|
|
18
18
|
].filter(Boolean).join(' · '),
|
|
19
19
|
'',
|
|
20
20
|
`${ICON.critical} ${n.critical} critical · ${ICON.high} ${n.high} high · ${ICON.medium} ${n.medium} medium · ${ICON.low} ${n.low} low`,
|
|
@@ -40,3 +40,10 @@ export function markdownReport(result, { verbose = false } = {}) {
|
|
|
40
40
|
lines.push(`<sub>Generated by [NativeKeel](${SITE_URL}) · \`npx nativekeel plan\` for a step-by-step upgrade plan</sub>`);
|
|
41
41
|
return `${lines.join('\n')}\n`;
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
// Not set means React Native's default: off before 0.76, on from 0.76.
|
|
45
|
+
function archMd(p, v) {
|
|
46
|
+
if (v === false) return '**off**';
|
|
47
|
+
if (v === true) return 'on';
|
|
48
|
+
return p.reactNative && Number(p.reactNative.split('.')[1]) >= 76 ? 'on (default)' : '**off** (default)';
|
|
49
|
+
}
|
package/src/report-sarif.js
CHANGED
|
@@ -49,7 +49,7 @@ export function sarifReport(result, { version = '0.0.0' } = {}) {
|
|
|
49
49
|
ruleId: f.id.split(':')[0],
|
|
50
50
|
level: LEVEL[f.severity],
|
|
51
51
|
message: { text: `${f.title}. ${f.detail}` },
|
|
52
|
-
locations: [{ physicalLocation: { artifactLocation: { uri: loc.uri }, region: { startLine: loc.line } } }],
|
|
52
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: toUri(loc.uri) }, region: { startLine: loc.line } } }],
|
|
53
53
|
// Stable across runs so code scanning tracks one alert per finding.
|
|
54
54
|
partialFingerprints: { nativekeelId: f.id },
|
|
55
55
|
properties: { severity: f.severity, area: f.area },
|
|
@@ -59,3 +59,8 @@ export function sarifReport(result, { version = '0.0.0' } = {}) {
|
|
|
59
59
|
],
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
// SARIF artifact locations are URIs: encode each path segment (spaces, #, %), keep the slashes.
|
|
64
|
+
function toUri(file) {
|
|
65
|
+
return file.split(/[\\/]/).map(encodeURIComponent).join('/');
|
|
66
|
+
}
|
package/src/report-text.js
CHANGED
|
@@ -14,7 +14,9 @@ export function textReport(result, { color = true, verbose = false } = {}) {
|
|
|
14
14
|
const indent = ' ';
|
|
15
15
|
const out = [];
|
|
16
16
|
const p = result.project;
|
|
17
|
-
|
|
17
|
+
// Not set means React Native's default: off before 0.76, on from 0.76.
|
|
18
|
+
const defaultOn = !!p.reactNative && Number(p.reactNative.split('.')[1]) >= 76;
|
|
19
|
+
const arch = (v) => (v === true ? c.green('on') : v === false ? c.red('off') : defaultOn ? c.gray('on (default)') : c.red('off (default)'));
|
|
18
20
|
|
|
19
21
|
out.push('');
|
|
20
22
|
out.push(`${c.bold('NativeKeel health report')} ${p.name || ''}`);
|
package/src/rules.js
CHANGED
|
@@ -53,6 +53,38 @@ export const REPLACEMENTS = {
|
|
|
53
53
|
'react-native-code-push': ['expo-updates'],
|
|
54
54
|
// react-native-elements was renamed to @rneui, which in turn is continued by @rn-vui.
|
|
55
55
|
'react-native-elements': ['@rn-vui/themed'],
|
|
56
|
+
// Checked on npm and React Native Directory on 2026-10-04 (published and not flagged unmaintained).
|
|
57
|
+
'rn-fetch-blob': ['react-native-blob-util'],
|
|
58
|
+
'@react-native-community/blur': ['expo-blur'],
|
|
59
|
+
'react-native-randombytes': ['react-native-get-random-values', 'expo-crypto'],
|
|
60
|
+
'react-native-securerandom': ['react-native-get-random-values', 'expo-crypto'],
|
|
61
|
+
'react-native-tcp': ['react-native-tcp-socket'],
|
|
62
|
+
'@react-native-community/geolocation': ['expo-location'],
|
|
63
|
+
'react-native-geolocation-service': ['expo-location'],
|
|
64
|
+
'react-native-encrypted-storage': ['react-native-keychain', 'expo-secure-store'],
|
|
65
|
+
'react-native-navigation-bar-color': ['react-native-edge-to-edge', 'expo-navigation-bar'],
|
|
66
|
+
'react-native-quick-actions': ['expo-quick-actions'],
|
|
67
|
+
'react-native-version-number': ['react-native-device-info', 'expo-application'],
|
|
68
|
+
'react-native-mail': ['expo-mail-composer'],
|
|
69
|
+
'react-native-rate': ['expo-store-review'],
|
|
70
|
+
'react-native-print': ['expo-print'],
|
|
71
|
+
'react-native-i18n': ['react-native-localize', 'expo-localization'],
|
|
72
|
+
'expo-av': ['expo-audio', 'expo-video'],
|
|
73
|
+
'react-native-snap-carousel': ['react-native-reanimated-carousel'],
|
|
74
|
+
'react-native-iphone-x-helper': ['react-native-safe-area-context'],
|
|
75
|
+
'react-native-slider': ['@react-native-community/slider'],
|
|
76
|
+
'react-native-datepicker': ['@react-native-community/datetimepicker'],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Packages that moved to a new name with the same API: swap the package, update the imports.
|
|
80
|
+
export const RENAMED = {
|
|
81
|
+
'@react-native-community/async-storage': '@react-native-async-storage/async-storage',
|
|
82
|
+
'@react-native-community/cameraroll': '@react-native-camera-roll/camera-roll',
|
|
83
|
+
'@react-native-community/masked-view': '@react-native-masked-view/masked-view',
|
|
84
|
+
'@react-native-community/viewpager': 'react-native-pager-view',
|
|
85
|
+
'@react-native-community/picker': '@react-native-picker/picker',
|
|
86
|
+
'@react-native-community/clipboard': '@react-native-clipboard/clipboard',
|
|
87
|
+
'react-native-netinfo': '@react-native-community/netinfo',
|
|
56
88
|
};
|
|
57
89
|
|
|
58
90
|
export const UPGRADE_HELPER_URL = (from, to) =>
|
package/src/usage.js
CHANGED
|
@@ -26,6 +26,10 @@ const IMPLICIT = [
|
|
|
26
26
|
/^react-native-webp-format$/, // iOS WebP decoder registers itself with the image loader
|
|
27
27
|
/^@react-native-firebase\/(crashlytics|perf)$/, // collect natively without any JS call
|
|
28
28
|
/^react-compiler-runtime$/, // imported by babel-plugin-react-compiler output
|
|
29
|
+
/^@react-native-vector-icons\//, // per-font packages: fonts linked by the build
|
|
30
|
+
/^(hermes-engine|postinstall-postinstall|react-native-web)$/, // engine, install hook, web target
|
|
31
|
+
// Named after Node.js core modules: dependencies require('stream'), Metro resolves the package.
|
|
32
|
+
/^(assert|buffer|constants|crypto|events|os|path|process|punycode|querystring|stream|string_decoder|timers|tty|url|util|vm|zlib)$/,
|
|
29
33
|
];
|
|
30
34
|
|
|
31
35
|
function walk(dir, exts, out = []) {
|
|
@@ -102,6 +106,9 @@ export function findUnused(project) {
|
|
|
102
106
|
const configText = [
|
|
103
107
|
JSON.stringify(pkgJson.scripts || {}),
|
|
104
108
|
JSON.stringify(pkgJson.jest || {}),
|
|
109
|
+
// rn-nodeify style aliases: { "react-native": { "crypto": "react-native-crypto" } }
|
|
110
|
+
JSON.stringify(pkgJson['react-native'] || {}),
|
|
111
|
+
JSON.stringify(pkgJson.browser || {}),
|
|
105
112
|
readSmall(path.join(root, 'app.json')),
|
|
106
113
|
readSmall(path.join(root, 'app.config.js')),
|
|
107
114
|
readSmall(path.join(root, 'app.config.ts')),
|
|
@@ -137,7 +144,18 @@ export function findUnused(project) {
|
|
|
137
144
|
}
|
|
138
145
|
}
|
|
139
146
|
|
|
140
|
-
|
|
147
|
+
// A package someone patched (patch-package) is one they care about: never call it unused.
|
|
148
|
+
const patched = new Set();
|
|
149
|
+
try {
|
|
150
|
+
for (const f of fs.readdirSync(path.join(root, 'patches'))) {
|
|
151
|
+
const m = f.match(/^(@[^+]+\+[^+]+|[^+@][^+]*)\+/);
|
|
152
|
+
if (m) patched.add(m[1].replace('+', '/'));
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// no patches folder
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const candidates = names.filter((n) => !used.has(n) && !requiredByOthers.has(n) && !configText.includes(n) && !usedByBin(n) && !patched.has(n));
|
|
141
159
|
if (!candidates.length) return [];
|
|
142
160
|
|
|
143
161
|
const nativeText = ['ios', 'android'].flatMap((d) => walk(path.join(root, d), NATIVE_EXT)).map(readSmall).join('\n');
|