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 +3 -1
- package/package.json +1 -1
- package/src/analyze.js +115 -20
- package/src/compat.js +34 -15
- package/src/known-issues.js +13 -3
- package/src/native.js +54 -10
- package/src/plan.js +82 -10
- package/src/project.js +68 -2
- package/src/registry.js +43 -6
- package/src/secrets.js +48 -5
- package/src/usage.js +70 -7
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
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
180
|
-
const native =
|
|
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
|
|
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
|
|
341
|
+
severity,
|
|
267
342
|
area: 'dependency',
|
|
268
|
-
title
|
|
269
|
-
detail: `${
|
|
270
|
-
fix: { kind: 'known-issue', name: dep.name, title
|
|
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-
|
|
10
|
+
source: 'https://github.com/software-mansion/react-native-screens#support-for-fabric (checked 2026-10-04)',
|
|
11
11
|
newArch: [
|
|
12
12
|
{ from: '4.0.0', to: '4.4.99', rnMin: 76 },
|
|
13
13
|
{ from: '4.5.0', to: '4.13.99', rnMin: 77 },
|
|
@@ -26,9 +26,13 @@ export const COMPAT = {
|
|
|
26
26
|
],
|
|
27
27
|
},
|
|
28
28
|
'react-native-gesture-handler': {
|
|
29
|
-
source: 'https://github.com/software-mansion/react-native-gesture-handler#
|
|
29
|
+
source: 'https://github.com/software-mansion/react-native-gesture-handler#react-native-support (Gesture Handler 2 table; 3.x needs 0.82+; checked 2026-10-04)',
|
|
30
30
|
note: 'Gesture Handler supports the three latest React Native minors; the table gives minimums.',
|
|
31
31
|
newArch: [
|
|
32
|
+
{ from: '2.0.0', to: '2.9.99', rnMin: 63 },
|
|
33
|
+
{ from: '2.10.0', to: '2.13.99', rnMin: 64 },
|
|
34
|
+
{ from: '2.14.0', to: '2.15.99', rnMin: 67 },
|
|
35
|
+
{ from: '2.16.0', to: '2.17.99', rnMin: 68 },
|
|
32
36
|
{ from: '2.18.0', to: '2.20.99', rnMin: 73 },
|
|
33
37
|
{ from: '2.21.0', to: '2.23.99', rnMin: 74 },
|
|
34
38
|
{ from: '2.24.0', to: '2.24.99', rnMin: 75 },
|
|
@@ -39,6 +43,10 @@ export const COMPAT = {
|
|
|
39
43
|
{ from: '3.0.0', rnMin: 82 },
|
|
40
44
|
],
|
|
41
45
|
legacy: [
|
|
46
|
+
{ from: '2.0.0', to: '2.9.99', rnMin: 63 },
|
|
47
|
+
{ from: '2.10.0', to: '2.13.99', rnMin: 64 },
|
|
48
|
+
{ from: '2.14.0', to: '2.15.99', rnMin: 67 },
|
|
49
|
+
{ from: '2.16.0', to: '2.17.99', rnMin: 68 },
|
|
42
50
|
{ from: '2.18.0', to: '2.20.99', rnMin: 73 },
|
|
43
51
|
{ from: '2.21.0', to: '2.23.99', rnMin: 74 },
|
|
44
52
|
{ from: '2.24.0', to: '2.24.99', rnMin: 75 },
|
|
@@ -49,7 +57,8 @@ export const COMPAT = {
|
|
|
49
57
|
],
|
|
50
58
|
},
|
|
51
59
|
'react-native-reanimated': {
|
|
52
|
-
source: '
|
|
60
|
+
source: 'Reanimated compatibility.json, the data behind docs.swmansion.com/react-native-reanimated/docs/guides/compatibility (rows assume the latest patch of each minor)',
|
|
61
|
+
// Generated by scripts/reanimated-compat.mjs on 2026-10-04 from https://raw.githubusercontent.com/software-mansion/react-native-reanimated/main/packages/react-native-reanimated/compatibility.json
|
|
53
62
|
newArch: [
|
|
54
63
|
{ from: '3.0.0', to: '3.0.99', rnMin: 71, rnMax: 71 },
|
|
55
64
|
{ from: '3.1.0', to: '3.5.99', rnMin: 72, rnMax: 72 },
|
|
@@ -60,22 +69,32 @@ export const COMPAT = {
|
|
|
60
69
|
{ from: '3.17.0', to: '3.17.99', rnMin: 75, rnMax: 79 },
|
|
61
70
|
{ from: '3.18.0', to: '3.18.99', rnMin: 75, rnMax: 80 },
|
|
62
71
|
{ from: '3.19.0', to: '3.19.99', rnMin: 78, rnMax: 81 },
|
|
63
|
-
{ from: '4.0.0', to: '4.
|
|
64
|
-
{ from: '4.
|
|
65
|
-
{ from: '4.
|
|
66
|
-
{ from: '4.
|
|
67
|
-
{ from: '4.
|
|
72
|
+
{ from: '4.0.0', to: '4.0.99', rnMin: 78, rnMax: 81 },
|
|
73
|
+
{ from: '4.1.0', to: '4.1.99', rnMin: 78, rnMax: 82 },
|
|
74
|
+
{ from: '4.2.0', to: '4.2.99', rnMin: 80, rnMax: 84 },
|
|
75
|
+
{ from: '4.3.0', to: '4.3.99', rnMin: 81, rnMax: 85 },
|
|
76
|
+
{ from: '4.4.0', to: '4.4.99', rnMin: 83, rnMax: 86 },
|
|
77
|
+
{ from: '4.5.0', to: '4.5.99', rnMin: 83, rnMax: 86 },
|
|
78
|
+
{ from: '4.6.0', to: '4.6.99', rnMin: 83, rnMax: 87 },
|
|
79
|
+
{ from: '4.7.0', to: '4.7.99', rnMin: 86, rnMax: 88 },
|
|
80
|
+
{ from: '4.8.0', to: '4.8.99', rnMin: 86, rnMax: 88 },
|
|
68
81
|
],
|
|
69
82
|
legacy: [
|
|
83
|
+
{ from: '2.3.0', to: '2.4.99', rnMin: 63, rnMax: 67 },
|
|
84
|
+
{ from: '2.5.0', to: '2.9.99', rnMin: 63, rnMax: 68 },
|
|
85
|
+
{ from: '2.10.0', to: '2.10.99', rnMin: 63, rnMax: 69 },
|
|
86
|
+
{ from: '2.11.0', to: '2.13.99', rnMin: 63, rnMax: 70 },
|
|
87
|
+
{ from: '2.14.0', to: '2.17.99', rnMin: 63, rnMax: 71 },
|
|
70
88
|
{ from: '3.0.0', to: '3.2.99', rnMin: 63, rnMax: 71 },
|
|
71
89
|
{ from: '3.3.0', to: '3.4.99', rnMin: 63, rnMax: 72 },
|
|
72
|
-
{ from: '3.5.0', to: '3.5.99', rnMin: 66, rnMax:
|
|
73
|
-
{ from: '3.6.0', to: '3.8.99', rnMin: 66, rnMax:
|
|
74
|
-
{ from: '3.9.0', to: '3.
|
|
75
|
-
{ from: '3.
|
|
76
|
-
{ from: '3.
|
|
77
|
-
{ from: '3.
|
|
78
|
-
{ from: '3.
|
|
90
|
+
{ from: '3.5.0', to: '3.5.99', rnMin: 66, rnMax: 72 },
|
|
91
|
+
{ from: '3.6.0', to: '3.8.99', rnMin: 66, rnMax: 73 },
|
|
92
|
+
{ from: '3.9.0', to: '3.14.99', rnMin: 71, rnMax: 74 },
|
|
93
|
+
{ from: '3.15.0', to: '3.15.99', rnMin: 72, rnMax: 75 },
|
|
94
|
+
{ from: '3.16.0', to: '3.16.99', rnMin: 74, rnMax: 77 },
|
|
95
|
+
{ from: '3.17.0', to: '3.17.99', rnMin: 75, rnMax: 79 },
|
|
96
|
+
{ from: '3.18.0', to: '3.18.99', rnMin: 75, rnMax: 80 },
|
|
97
|
+
{ from: '3.19.0', to: '3.19.99', rnMin: 78, rnMax: 81 },
|
|
79
98
|
{ from: '4.0.0', unsupported: true },
|
|
80
99
|
],
|
|
81
100
|
note: 'Reanimated 4 also needs react-native-worklets; Reanimated 3.x is no longer actively maintained.',
|
package/src/known-issues.js
CHANGED
|
@@ -38,9 +38,19 @@ export const KNOWN_ISSUES = [
|
|
|
38
38
|
rnFrom: 76,
|
|
39
39
|
when: 'newArch',
|
|
40
40
|
severity: 'high',
|
|
41
|
-
title: '
|
|
42
|
-
detail: "2.x has no Fabric component, so
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
|
156
|
+
const isExpo = !!result.project.expo;
|
|
157
|
+
const basePm = commands[packageManager(result.project.root)];
|
|
158
|
+
// In Expo projects `npx expo install` picks the version that matches the SDK.
|
|
159
|
+
const pm = isExpo ? { ...basePm, add: 'npx expo install' } : basePm;
|
|
160
|
+
const pinned = isExpo ? expoPinnedNames(result.project.root) : new Set();
|
|
161
|
+
const movesWithExpo = (name) => isExpo && (/^(expo-|@expo\/)/.test(name) || name === 'expo' || name === 'jest-expo' || pinned.has(name));
|
|
130
162
|
const installed = new Set(result.deps.map((d) => d.name));
|
|
131
163
|
const byKind = (kind) => result.findings.filter((f) => f.fix && f.fix.kind === kind).map((f) => f.fix);
|
|
132
164
|
const phases = [];
|
|
@@ -190,7 +222,10 @@ export function buildPlan(result) {
|
|
|
190
222
|
}
|
|
191
223
|
|
|
192
224
|
const storeSteps = [];
|
|
193
|
-
|
|
225
|
+
const privacy = byKind('privacy-manifest')[0];
|
|
226
|
+
if (privacy && privacy.generated) {
|
|
227
|
+
storeSteps.push('Run `pod install`, commit the generated `PrivacyInfo.xcprivacy`, and add your collected data types and tracking to it (React Native only fills in the required-reason APIs).');
|
|
228
|
+
} else if (privacy) {
|
|
194
229
|
storeSteps.push('Add `PrivacyInfo.xcprivacy` to the iOS app target. Start from the file in the React Native template and declare the required-reason APIs your app and SDKs use (UserDefaults, file timestamps, system boot time, disk space).');
|
|
195
230
|
}
|
|
196
231
|
const align = byKind('align-16kb')[0];
|
|
@@ -263,7 +298,8 @@ export function buildPlan(result) {
|
|
|
263
298
|
});
|
|
264
299
|
}
|
|
265
300
|
|
|
266
|
-
const
|
|
301
|
+
const expoMoves = byKind('bump-dep').filter((d) => movesWithExpo(d.name));
|
|
302
|
+
const nativeBumps = byKind('bump-dep').filter((d) => d.native && !movesWithExpo(d.name));
|
|
267
303
|
if (nativeBumps.length) {
|
|
268
304
|
phases.push({
|
|
269
305
|
title: 'Update native modules on the current React Native version',
|
|
@@ -281,6 +317,7 @@ export function buildPlan(result) {
|
|
|
281
317
|
steps: [`In \`${depProvider.file}\`: \`#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>\` and \`self.dependencyProvider = [RCTAppDependencyProvider new];\` before \`[super application:didFinishLaunchingWithOptions:]\`.`],
|
|
282
318
|
});
|
|
283
319
|
}
|
|
320
|
+
let deferredArch = null;
|
|
284
321
|
if (arch) {
|
|
285
322
|
const steps = [];
|
|
286
323
|
for (const j of byKind('interop-job')) {
|
|
@@ -301,11 +338,18 @@ export function buildPlan(result) {
|
|
|
301
338
|
steps.push('Clean everything (`./gradlew clean`, `watchman watch-del-all`, delete `ios/build` and `ios/Pods`). Switching architecture without a clean build fails on stale codegen output (e.g. missing `Native…Spec` classes).');
|
|
302
339
|
steps.push('Build debug and release on both platforms.');
|
|
303
340
|
steps.push('Click through every screen that uses a native module. Interop-layer problems show up at runtime, not at build time.');
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
341
|
+
// Before 0.76 the New Architecture is too immature to switch on in place: do it on the way,
|
|
342
|
+
// at the first stop between 0.76 and 0.81 (or Expo SDK 52+), in its own commit.
|
|
343
|
+
const currentMinor = Number((result.project.reactNative || '0.99').split('.')[1]);
|
|
344
|
+
if (currentMinor < 76) {
|
|
345
|
+
deferredArch = steps;
|
|
346
|
+
} else {
|
|
347
|
+
phases.push({
|
|
348
|
+
title: 'Turn on the New Architecture on your current version',
|
|
349
|
+
why: `React Native 0.${NEW_ARCH_ONLY_MINOR} removed the legacy architecture. Switching now, before changing the React Native version, keeps the two kinds of breakage apart.`,
|
|
350
|
+
steps,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
309
353
|
}
|
|
310
354
|
|
|
311
355
|
const rn = byKind('upgrade-rn')[0];
|
|
@@ -332,6 +376,19 @@ export function buildPlan(result) {
|
|
|
332
376
|
from = to;
|
|
333
377
|
return step;
|
|
334
378
|
});
|
|
379
|
+
if (deferredArch) {
|
|
380
|
+
const hops = upgradeHops(rn.from, rn.to);
|
|
381
|
+
const at = hops.findIndex((h) => Number(h.split('.')[1]) >= 76 && Number(h.split('.')[1]) < NEW_ARCH_ONLY_MINOR);
|
|
382
|
+
if (at >= 0) {
|
|
383
|
+
steps.splice(
|
|
384
|
+
at + 1,
|
|
385
|
+
0,
|
|
386
|
+
`**Stop at ${hops[at]} and turn on the New Architecture** before going further: 0.${NEW_ARCH_ONLY_MINOR} removed the legacy one. Do it in its own commit:`,
|
|
387
|
+
...deferredArch.map((x) => `New Architecture: ${x}`),
|
|
388
|
+
);
|
|
389
|
+
deferredArch = null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
335
392
|
steps.push('Bump `react`, `@react-native/*` packages and the Metro/Babel config to the versions the Upgrade Helper shows for the target.');
|
|
336
393
|
const patchesReview = byKind('patches-review')[0];
|
|
337
394
|
if (patchesReview) {
|
|
@@ -349,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++)
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4
|
+
|
|
5
|
+
// GET a JSON document. Returns null for "not found" (a package that does not exist is an
|
|
6
|
+
// answer, not an error). Rate limits (429) and server errors are retried with backoff; if the
|
|
7
|
+
// request still fails, `stats.failed` is incremented so the report can say it is incomplete.
|
|
8
|
+
export async function fetchJson(url, headers = {}, stats = null, { retries = 3, wait = sleep } = {}) {
|
|
9
|
+
for (let attempt = 0; ; attempt++) {
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(url, { headers: { ...UA, ...headers }, signal: AbortSignal.timeout(30000) });
|
|
12
|
+
if (res.ok) return await res.json();
|
|
13
|
+
if (res.status === 404) return null;
|
|
14
|
+
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
|
|
15
|
+
const after = Number(res.headers.get('retry-after'));
|
|
16
|
+
await wait(Math.min(Number.isFinite(after) && after > 0 ? after * 1000 : 1000 * 2 ** attempt, 10000));
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
if (attempt < retries) {
|
|
21
|
+
await wait(1000 * 2 ** attempt);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (stats) stats.failed++;
|
|
9
26
|
return null;
|
|
10
27
|
}
|
|
11
28
|
}
|
|
@@ -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
|
-
|
|
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
|
|
131
|
+
line,
|
|
90
132
|
preview: value ? mask(value) : null,
|
|
91
|
-
inAppBundle: !SERVER_DIRS.has(
|
|
92
|
-
|
|
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
|
|
65
|
+
const re = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire(?:\.resolve)?\s*\(\s*|\bjest\.(?:mock|requireActual)\s*\(\s*)['"]([^'"\n]+)['"]/g;
|
|
62
66
|
for (const m of text.matchAll(re)) {
|
|
63
67
|
const pkg = packageOf(m[1]);
|
|
64
68
|
if (pkg) found.add(pkg);
|
|
@@ -102,22 +106,38 @@ export function findUnused(project) {
|
|
|
102
106
|
readSmall(path.join(root, 'app.config.js')),
|
|
103
107
|
readSmall(path.join(root, 'app.config.ts')),
|
|
104
108
|
readSmall(path.join(root, 'react-native.config.js')),
|
|
109
|
+
// metro/babel/jest config and friends: aliases such as `crypto: 'crypto-browserify'`.
|
|
110
|
+
...fs
|
|
111
|
+
.readdirSync(root)
|
|
112
|
+
.filter((f) => /\.config\.[cm]?[jt]s$|^\.babelrc|^rn-cli\.config/.test(f))
|
|
113
|
+
.map((f) => readSmall(path.join(root, f))),
|
|
105
114
|
].join('\n');
|
|
115
|
+
// CLI tools run from scripts by their command name, which can differ from the package name.
|
|
116
|
+
const scriptText = JSON.stringify(pkgJson.scripts || {});
|
|
117
|
+
const usedByBin = (name) => {
|
|
118
|
+
const dir = findPackageDir(root, name);
|
|
119
|
+
try {
|
|
120
|
+
const bin = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).bin;
|
|
121
|
+
const bins = typeof bin === 'string' ? [name.split('/').pop()] : Object.keys(bin || {});
|
|
122
|
+
return bins.some((b) => scriptText.includes(b));
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
106
127
|
|
|
107
|
-
// Required by
|
|
128
|
+
// Required by any installed package (peer or regular), including indirect ones: e.g.
|
|
129
|
+
// @react-navigation/elements, pulled in by the stack navigator, peers on masked-view.
|
|
108
130
|
const requiredByOthers = new Set();
|
|
109
|
-
for (const
|
|
110
|
-
const dir = findPackageDir(root, other);
|
|
111
|
-
if (!dir) continue;
|
|
131
|
+
for (const manifest of installedManifests(root)) {
|
|
112
132
|
try {
|
|
113
|
-
const m = JSON.parse(fs.readFileSync(
|
|
133
|
+
const m = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
114
134
|
for (const k of ['peerDependencies', 'dependencies']) for (const d of Object.keys(m[k] || {})) requiredByOthers.add(d);
|
|
115
135
|
} catch {
|
|
116
136
|
// unreadable manifest
|
|
117
137
|
}
|
|
118
138
|
}
|
|
119
139
|
|
|
120
|
-
const candidates = names.filter((n) => !used.has(n) && !requiredByOthers.has(n) && !configText.includes(n));
|
|
140
|
+
const candidates = names.filter((n) => !used.has(n) && !requiredByOthers.has(n) && !configText.includes(n) && !usedByBin(n));
|
|
121
141
|
if (!candidates.length) return [];
|
|
122
142
|
|
|
123
143
|
const nativeText = ['ios', 'android'].flatMap((d) => walk(path.join(root, d), NATIVE_EXT)).map(readSmall).join('\n');
|
|
@@ -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
|
+
}
|