nativekeel 0.1.0
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/LICENSE +21 -0
- package/README.md +101 -0
- package/bin/nativekeel.js +162 -0
- package/package.json +38 -0
- package/src/analyze.js +360 -0
- package/src/baseline.js +23 -0
- package/src/compare.js +55 -0
- package/src/compat.js +125 -0
- package/src/config.js +3 -0
- package/src/elf-align.js +136 -0
- package/src/interop.js +53 -0
- package/src/known-issues.js +61 -0
- package/src/native.js +454 -0
- package/src/plan.js +357 -0
- package/src/project.js +112 -0
- package/src/registry.js +89 -0
- package/src/report-html.js +87 -0
- package/src/report-md.js +42 -0
- package/src/report-sarif.js +61 -0
- package/src/report-text.js +72 -0
- package/src/rules.js +59 -0
- package/src/secrets.js +98 -0
- package/src/usage.js +127 -0
package/src/analyze.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { cleanVersion, findPackageDir, installedVersion } from './project.js';
|
|
4
|
+
import { createRegistry } from './registry.js';
|
|
5
|
+
import { scanSecrets } from './secrets.js';
|
|
6
|
+
import { nativeChecks } from './native.js';
|
|
7
|
+
import { findUnused } from './usage.js';
|
|
8
|
+
import { matchKnownIssues } from './known-issues.js';
|
|
9
|
+
import { COMPAT, bestRange, checkCompat } from './compat.js';
|
|
10
|
+
import { interopProblems } from './interop.js';
|
|
11
|
+
import {
|
|
12
|
+
CRITICAL_EXPO_MAJORS_BEHIND,
|
|
13
|
+
CRITICAL_RN_MINORS_BEHIND,
|
|
14
|
+
NEW_ARCH_ONLY_MINOR,
|
|
15
|
+
RECENT_RELEASE_DAYS,
|
|
16
|
+
STALE_RELEASE_DAYS,
|
|
17
|
+
PLAY_TARGET_SDK,
|
|
18
|
+
REPLACEMENTS,
|
|
19
|
+
SUPPORTED_EXPO_MAJORS,
|
|
20
|
+
SUPPORTED_RN_MINORS,
|
|
21
|
+
} from './rules.js';
|
|
22
|
+
|
|
23
|
+
export const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|
24
|
+
|
|
25
|
+
const minorOf = (v) => Number(v.split('.')[1]);
|
|
26
|
+
const majorOf = (v) => Number(v.split('.')[0]);
|
|
27
|
+
// For 0.x packages the minor is the breaking-change line.
|
|
28
|
+
const lineOf = (v) => {
|
|
29
|
+
const [maj, min] = v.split('.').map(Number);
|
|
30
|
+
return maj === 0 ? `0.${min}` : String(maj);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// react, react-native and @react-native/* move together with the React Native version; @babel/* and types are tooling.
|
|
34
|
+
const isTracked = (name) =>
|
|
35
|
+
!['react-native', 'react', 'expo'].includes(name) &&
|
|
36
|
+
!name.startsWith('@react-native/') &&
|
|
37
|
+
!name.startsWith('@babel/') &&
|
|
38
|
+
!name.startsWith('@types/');
|
|
39
|
+
|
|
40
|
+
function isNativeModule(root, name, dir) {
|
|
41
|
+
const base = findPackageDir(root, name);
|
|
42
|
+
if (base) {
|
|
43
|
+
if (fs.existsSync(path.join(base, 'android')) || fs.existsSync(path.join(base, 'ios'))) return true;
|
|
44
|
+
try {
|
|
45
|
+
return fs.readdirSync(base).some((f) => f.endsWith('.podspec'));
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Without node_modules, trust React Native Directory's platform flags.
|
|
51
|
+
return !!(dir && (dir.ios || dir.android) && !dir.web);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// `offline: true` means the user asked for no network access (get returns nothing).
|
|
55
|
+
export async function analyze(project, { get, now = new Date(), offline: forcedOffline = false } = {}) {
|
|
56
|
+
const registry = createRegistry(get);
|
|
57
|
+
const findings = [];
|
|
58
|
+
const add = (f) => findings.push(f);
|
|
59
|
+
|
|
60
|
+
const runtime = Object.keys(project.deps).filter(isTracked);
|
|
61
|
+
const devCandidates = Object.keys(project.devDeps || {}).filter((n) => isTracked(n) && !runtime.includes(n));
|
|
62
|
+
const [rn, expo, directory] = await Promise.all([
|
|
63
|
+
project.rnVersion ? registry.releaseLines('react-native', (v) => (v.startsWith('0.') ? minorOf(v) : NaN)) : null,
|
|
64
|
+
project.expoVersion ? registry.releaseLines('expo', majorOf) : null,
|
|
65
|
+
registry.directoryInfo([...runtime, ...devCandidates]),
|
|
66
|
+
]);
|
|
67
|
+
// From devDependencies only native modules matter: they get autolinked into the app.
|
|
68
|
+
const devNative = devCandidates.filter((n) => isNativeModule(project.root, n, directory[n]));
|
|
69
|
+
const names = [...runtime, ...devNative];
|
|
70
|
+
const declaredRange = (n) => project.deps[n] || project.devDeps[n];
|
|
71
|
+
const latest = await registry.npmLatest(names);
|
|
72
|
+
const offline = forcedOffline || (!rn && !Object.keys(directory).length && !Object.values(latest).some(Boolean));
|
|
73
|
+
const warnings = [];
|
|
74
|
+
if (!project.hasNodeModules) {
|
|
75
|
+
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
|
+
}
|
|
77
|
+
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
|
+
if (!offline && project.expoVersion && !expo) warnings.push('Could not fetch Expo releases from npm; the SDK check was skipped. Run again.');
|
|
79
|
+
|
|
80
|
+
// 1. React Native version support
|
|
81
|
+
const current = project.rnVersion;
|
|
82
|
+
if (rn && current) {
|
|
83
|
+
const behind = rn.lines[0] - minorOf(current);
|
|
84
|
+
if (behind >= SUPPORTED_RN_MINORS) {
|
|
85
|
+
add({
|
|
86
|
+
id: 'rn-unsupported',
|
|
87
|
+
severity: behind >= CRITICAL_RN_MINORS_BEHIND ? 'critical' : 'high',
|
|
88
|
+
area: 'react-native',
|
|
89
|
+
title: `React Native ${current} is no longer supported`,
|
|
90
|
+
detail: `Latest is ${rn.latest}. You are ${behind} minor versions behind; only the latest ${SUPPORTED_RN_MINORS} minors receive fixes.`,
|
|
91
|
+
fix: { kind: 'upgrade-rn', from: current, to: rn.latest },
|
|
92
|
+
});
|
|
93
|
+
} else if (behind > 0) {
|
|
94
|
+
add({
|
|
95
|
+
id: 'rn-behind',
|
|
96
|
+
severity: 'info',
|
|
97
|
+
area: 'react-native',
|
|
98
|
+
title: `React Native ${current} is still supported`,
|
|
99
|
+
detail: `Latest is ${rn.latest} (${behind} minor behind).`,
|
|
100
|
+
fix: { kind: 'upgrade-rn', from: current, to: rn.latest },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. New Architecture
|
|
105
|
+
const off = ['android', 'ios'].filter((p) => project.newArch[p] === false);
|
|
106
|
+
if (minorOf(current) < NEW_ARCH_ONLY_MINOR && off.length && rn.lines[0] >= NEW_ARCH_ONLY_MINOR) {
|
|
107
|
+
add({
|
|
108
|
+
id: 'new-arch-disabled',
|
|
109
|
+
severity: 'critical',
|
|
110
|
+
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.`,
|
|
113
|
+
fix: { kind: 'enable-new-arch', platforms: off },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 3. Expo SDK support
|
|
119
|
+
if (expo && project.expoVersion) {
|
|
120
|
+
const behind = expo.lines[0] - majorOf(project.expoVersion);
|
|
121
|
+
if (behind >= SUPPORTED_EXPO_MAJORS) {
|
|
122
|
+
add({
|
|
123
|
+
id: 'expo-unsupported',
|
|
124
|
+
severity: behind >= CRITICAL_EXPO_MAJORS_BEHIND ? 'critical' : 'high',
|
|
125
|
+
area: 'expo',
|
|
126
|
+
title: `Expo SDK ${majorOf(project.expoVersion)} is no longer supported`,
|
|
127
|
+
detail: `Latest is SDK ${expo.lines[0]}. Upgrade one SDK at a time with \`npx expo install expo@^<next> --fix\`.`,
|
|
128
|
+
fix: { kind: 'upgrade-expo', from: majorOf(project.expoVersion), to: expo.lines[0] },
|
|
129
|
+
});
|
|
130
|
+
} else if (behind > 0) {
|
|
131
|
+
add({
|
|
132
|
+
id: 'expo-behind',
|
|
133
|
+
severity: 'info',
|
|
134
|
+
area: 'expo',
|
|
135
|
+
title: `Expo SDK ${majorOf(project.expoVersion)} is still supported`,
|
|
136
|
+
detail: `Latest is SDK ${expo.lines[0]}.`,
|
|
137
|
+
fix: { kind: 'upgrade-expo', from: majorOf(project.expoVersion), to: expo.lines[0] },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 4. Google Play target SDK
|
|
143
|
+
const target = project.android && project.android.targetSdk;
|
|
144
|
+
if (target) {
|
|
145
|
+
const today = now.toISOString().slice(0, 10);
|
|
146
|
+
const { updates, visibility } = PLAY_TARGET_SDK;
|
|
147
|
+
if (today >= visibility.since && target < visibility.minimum) {
|
|
148
|
+
add({
|
|
149
|
+
id: 'play-target-sdk-visibility',
|
|
150
|
+
severity: 'critical',
|
|
151
|
+
area: 'store',
|
|
152
|
+
title: `targetSdk ${target} hides the app from users on newer Android versions`,
|
|
153
|
+
detail: `Since ${visibility.since} Google Play requires API ${visibility.minimum}+ for published apps to stay visible, and API ${updates.minimum}+ to publish any update.`,
|
|
154
|
+
fix: { kind: 'target-sdk', from: target, to: updates.minimum },
|
|
155
|
+
});
|
|
156
|
+
} else if (today >= updates.since && target < updates.minimum) {
|
|
157
|
+
add({
|
|
158
|
+
id: 'play-target-sdk-updates',
|
|
159
|
+
severity: 'critical',
|
|
160
|
+
area: 'store',
|
|
161
|
+
title: `targetSdk ${target}: Google Play will reject your next update`,
|
|
162
|
+
detail: `Since ${updates.since} new apps and updates must target API ${updates.minimum}+ (extension possible until ${updates.extensionUntil}).`,
|
|
163
|
+
fix: { kind: 'target-sdk', from: target, to: updates.minimum },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 5. Dependencies
|
|
169
|
+
// React Native Directory knows most packages, but its "unmaintained" flag can lag reality in
|
|
170
|
+
// both directions. For packages it does not know, or flags as unmaintained, the date of the
|
|
171
|
+
// latest npm release decides.
|
|
172
|
+
const recencyCandidates = names.filter((n) => !directory[n] || directory[n].unmaintained);
|
|
173
|
+
const published = forcedOffline ? {} : await registry.publishDates(recencyCandidates);
|
|
174
|
+
const daysSince = (date) => (date ? Math.floor((now - new Date(`${date}T00:00:00Z`)) / 86400000) : null);
|
|
175
|
+
|
|
176
|
+
const deps = [];
|
|
177
|
+
for (const name of names) {
|
|
178
|
+
const dir = directory[name];
|
|
179
|
+
const version = cleanVersion(installedVersion(project.root, name) || declaredRange(name));
|
|
180
|
+
const native = isNativeModule(project.root, name, dir);
|
|
181
|
+
const release = published[name] || {};
|
|
182
|
+
const age = daysSince(release.date);
|
|
183
|
+
const revived = !!(dir && dir.unmaintained && age !== null && age <= RECENT_RELEASE_DAYS);
|
|
184
|
+
const dep = {
|
|
185
|
+
name,
|
|
186
|
+
version,
|
|
187
|
+
latest: latest[name] || null,
|
|
188
|
+
native,
|
|
189
|
+
newArch: dir ? dir.newArchitecture : undefined,
|
|
190
|
+
unmaintained: !!(dir && dir.unmaintained) && !revived,
|
|
191
|
+
lastRelease: release.date || null,
|
|
192
|
+
};
|
|
193
|
+
deps.push(dep);
|
|
194
|
+
|
|
195
|
+
const noNewArch = native && dir && dir.newArchitecture === false;
|
|
196
|
+
// Only code that has to follow React Native or the OS goes stale; a finished JS utility does not.
|
|
197
|
+
const stale = !dep.unmaintained && !revived && age !== null && age > STALE_RELEASE_DAYS && (native || !!release.rnLink);
|
|
198
|
+
const alternatives = dir && dir.alternatives && dir.alternatives.length ? dir.alternatives : REPLACEMENTS[name] || [];
|
|
199
|
+
if (noNewArch || dep.unmaintained || stale) {
|
|
200
|
+
const problems = [
|
|
201
|
+
noNewArch && 'no New Architecture support',
|
|
202
|
+
dep.unmaintained && 'unmaintained',
|
|
203
|
+
stale && `no release since ${release.date.slice(0, 7)}`,
|
|
204
|
+
].filter(Boolean);
|
|
205
|
+
const why = noNewArch
|
|
206
|
+
? 'Likely upgrade blocker (it may still run through the interop layer). Plan a replacement or a maintained fork.'
|
|
207
|
+
: 'No fixes will come for future React Native, iOS or Android releases.';
|
|
208
|
+
add({
|
|
209
|
+
id: `dep-risk:${name}`,
|
|
210
|
+
severity: noNewArch || native ? 'high' : stale && !dep.unmaintained && release.rnLink === 'name' ? 'low' : 'medium',
|
|
211
|
+
area: 'dependency',
|
|
212
|
+
title: `${name}: ${problems.join(', ')}`,
|
|
213
|
+
detail: `${why}${alternatives.length ? ` Alternatives: ${alternatives.join(', ')}.` : ''}`,
|
|
214
|
+
fix: { kind: 'replace-dep', name, native, noNewArch, unmaintained: dep.unmaintained || stale, alternatives },
|
|
215
|
+
});
|
|
216
|
+
} else if (revived) {
|
|
217
|
+
add({
|
|
218
|
+
id: `dep-revived:${name}`,
|
|
219
|
+
severity: 'info',
|
|
220
|
+
area: 'dependency',
|
|
221
|
+
title: `${name}: marked unmaintained, but ${release.date} saw a new release`,
|
|
222
|
+
detail: 'React Native Directory still lists it as unmaintained. Maintenance may have resumed; check the changelog and issue tracker before replacing it.',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (!noNewArch && !dep.unmaintained && !stale && version && dep.latest && lineOf(version) !== lineOf(dep.latest)) {
|
|
226
|
+
add({
|
|
227
|
+
id: `dep-major:${name}`,
|
|
228
|
+
severity: 'low',
|
|
229
|
+
area: 'dependency',
|
|
230
|
+
title: `${name} ${version} → ${dep.latest}`,
|
|
231
|
+
detail: 'Major version behind.',
|
|
232
|
+
fix: { kind: 'bump-dep', name, from: version, to: dep.latest, native },
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Unused runtime dependencies: removing them is cheaper than upgrading them.
|
|
238
|
+
const unused = new Set();
|
|
239
|
+
for (const u of findUnused(project)) {
|
|
240
|
+
unused.add(u.name);
|
|
241
|
+
add({
|
|
242
|
+
id: `unused:${u.name}`,
|
|
243
|
+
severity: u.native ? 'medium' : 'low',
|
|
244
|
+
area: 'dependency',
|
|
245
|
+
title: `${u.name} is installed but never used${u.native ? ' (native module)' : ''}`,
|
|
246
|
+
detail: `Not imported from JS, not referenced from native code, not required by another dependency.${u.native ? ' It still compiles into both apps and has to survive every upgrade.' : ''} Remove it.`,
|
|
247
|
+
fix: { kind: 'remove-dep', name: u.name, native: u.native },
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
// A package you are about to delete does not need upgrade advice.
|
|
251
|
+
for (let i = findings.length - 1; i >= 0; i--) {
|
|
252
|
+
const f = findings[i];
|
|
253
|
+
if (f.fix && ['replace-dep', 'bump-dep'].includes(f.fix.kind) && unused.has(f.fix.name)) findings.splice(i, 1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Known version-specific breakages, learned from real upgrades.
|
|
257
|
+
const newArchOn = project.newArch.android !== false && project.newArch.ios !== false && minorOf(current || '0.0') >= 76;
|
|
258
|
+
// The legacy architecture ends at 0.81, so any older app with it off has to migrate; this
|
|
259
|
+
// holds even when the latest release could not be fetched (offline).
|
|
260
|
+
const archOff = project.newArch.android === false || project.newArch.ios === false;
|
|
261
|
+
const newArchRequired =
|
|
262
|
+
findings.some((f) => f.id === 'new-arch-disabled') || (archOff && current && minorOf(current) < NEW_ARCH_ONLY_MINOR);
|
|
263
|
+
for (const { issue, dep } of matchKnownIssues({ deps, rnVersion: current, newArchOn, newArchRequired })) {
|
|
264
|
+
add({
|
|
265
|
+
id: `known:${issue.id}`,
|
|
266
|
+
severity: issue.severity,
|
|
267
|
+
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 },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Library ↔ React Native compatibility, from the libraries' own tables.
|
|
275
|
+
if (current) {
|
|
276
|
+
const rnMinor = minorOf(current);
|
|
277
|
+
for (const dep of deps.filter((d) => COMPAT[d.name])) {
|
|
278
|
+
const problem = checkCompat(dep.name, dep.version, rnMinor, newArchOn);
|
|
279
|
+
// Also check the architecture this app has to move to.
|
|
280
|
+
const ahead = !problem && newArchRequired ? checkCompat(dep.name, dep.version, rnMinor, true) : null;
|
|
281
|
+
if (!problem && !ahead) continue;
|
|
282
|
+
const best = bestRange(dep.name, rnMinor, problem ? newArchOn : true);
|
|
283
|
+
add({
|
|
284
|
+
id: `compat:${dep.name}`,
|
|
285
|
+
severity: problem ? 'high' : 'medium',
|
|
286
|
+
area: 'dependency',
|
|
287
|
+
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}` : ''}`,
|
|
289
|
+
fix: { kind: 'compat', name: dep.name, range: best && best.range, forNewArch: !problem },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Native modules that cannot load through the New Architecture interop layer (Android).
|
|
295
|
+
const androidNewArch = project.newArch.android !== false && minorOf(current || '0.0') >= 76;
|
|
296
|
+
if (androidNewArch || newArchRequired) {
|
|
297
|
+
for (const p of interopProblems(project, deps)) {
|
|
298
|
+
add({
|
|
299
|
+
id: `interop-job:${p.name}`,
|
|
300
|
+
severity: androidNewArch ? 'critical' : 'high',
|
|
301
|
+
area: 'new-architecture',
|
|
302
|
+
title: `${p.name} will not load on Android with the New Architecture (${p.methods.length} @ReactMethod${p.methods.length === 1 ? '' : 's'} return a coroutine Job)`,
|
|
303
|
+
detail: `${p.methods.slice(0, 4).join(', ')}${p.methods.length > 4 ? ', …' : ''} are written as \`fun x(...) = scope.launch { }\`. The interop layer only accepts void methods ("Unable to parse @ReactMethod ... unsupported return class: kotlinx.coroutines.Job"), so the whole module fails to load; if it is imported at startup the app shows a red screen. Patch the methods to return Unit (patch-package) or move to a version with a TurboModule spec.`,
|
|
304
|
+
fix: { kind: 'interop-job', name: p.name, methods: p.methods },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 6. Native project: store requirements, built binaries, security settings
|
|
310
|
+
findings.push(...nativeChecks(project, { rnLatest: rn && rn.latest, now }));
|
|
311
|
+
|
|
312
|
+
// 7. Secrets
|
|
313
|
+
for (const s of scanSecrets(project.root)) {
|
|
314
|
+
const value = s.preview ? `Value ${s.preview}. ` : '';
|
|
315
|
+
const where = `${s.file}:${s.line}`;
|
|
316
|
+
if (s.inAppBundle) {
|
|
317
|
+
add({
|
|
318
|
+
id: `secret:${s.rule}:${s.file}`,
|
|
319
|
+
severity: 'critical',
|
|
320
|
+
area: 'secret',
|
|
321
|
+
title: `${s.label} shipped inside the app (${where})`,
|
|
322
|
+
detail: `${value}Anyone who downloads the app can extract it. Revoke it now, then move the call to a server.`,
|
|
323
|
+
fix: { kind: 'secret', label: s.label, file: s.file, line: s.line, bundled: true },
|
|
324
|
+
});
|
|
325
|
+
} else if (s.committed !== false) {
|
|
326
|
+
add({
|
|
327
|
+
id: `secret:${s.rule}:${s.file}`,
|
|
328
|
+
severity: 'high',
|
|
329
|
+
area: 'secret',
|
|
330
|
+
title: `${s.label} committed to the repository (${where})`,
|
|
331
|
+
detail: `${value}Not shipped in the app, but everyone with repo access has it and it stays in git history. Rotate it and load it from the environment instead.`,
|
|
332
|
+
fix: { kind: 'secret', label: s.label, file: s.file, line: s.line, bundled: false },
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
findings.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
|
|
338
|
+
return {
|
|
339
|
+
tool: 'nativekeel',
|
|
340
|
+
scannedAt: now.toISOString(),
|
|
341
|
+
project: {
|
|
342
|
+
name: project.name,
|
|
343
|
+
root: project.root,
|
|
344
|
+
reactNative: project.rnVersion,
|
|
345
|
+
expo: project.expoVersion,
|
|
346
|
+
managed: project.managed,
|
|
347
|
+
newArch: project.newArch,
|
|
348
|
+
android: project.android,
|
|
349
|
+
},
|
|
350
|
+
latest: { reactNative: rn && rn.latest, expo: expo && expo.latest },
|
|
351
|
+
offline,
|
|
352
|
+
offlineRequested: forcedOffline,
|
|
353
|
+
warnings,
|
|
354
|
+
deps,
|
|
355
|
+
findings,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export const countBySeverity = (findings) =>
|
|
360
|
+
Object.fromEntries(SEVERITIES.map((s) => [s, findings.filter((f) => f.severity === s).length]));
|
package/src/baseline.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
// A baseline is the list of finding ids a team has accepted. In CI only findings that are
|
|
4
|
+
// not in the baseline fail the build, so an old app can adopt NativeKeel without fixing
|
|
5
|
+
// everything on day one, and nothing new slips in afterwards.
|
|
6
|
+
|
|
7
|
+
export function saveBaseline(file, result) {
|
|
8
|
+
const data = {
|
|
9
|
+
tool: 'nativekeel',
|
|
10
|
+
createdAt: result.scannedAt,
|
|
11
|
+
ids: [...new Set(result.findings.filter((f) => f.severity !== 'info').map((f) => f.id))].sort(),
|
|
12
|
+
};
|
|
13
|
+
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
14
|
+
return data.ids.length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function applyBaseline(file, result) {
|
|
18
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
19
|
+
const known = new Set(data.ids || []);
|
|
20
|
+
const findings = result.findings.filter((f) => !known.has(f.id));
|
|
21
|
+
const fixed = [...known].filter((id) => !result.findings.some((f) => f.id === id));
|
|
22
|
+
return { ...result, findings, baseline: { file, suppressed: result.findings.length - findings.length, fixed } };
|
|
23
|
+
}
|
package/src/compare.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { SEVERITIES, countBySeverity } from './analyze.js';
|
|
2
|
+
|
|
3
|
+
// Compares two scans (from `--json`): what got fixed, what is new, how versions moved.
|
|
4
|
+
// Used for monthly Care reports and before/after case studies.
|
|
5
|
+
|
|
6
|
+
const arch = (a) => (a && a.android !== false && a.ios !== false ? 'on' : 'off');
|
|
7
|
+
|
|
8
|
+
export function compareScans(before, after) {
|
|
9
|
+
const beforeIds = new Map(before.findings.map((f) => [f.id, f]));
|
|
10
|
+
const afterIds = new Map(after.findings.map((f) => [f.id, f]));
|
|
11
|
+
const notInfo = (f) => f.severity !== 'info';
|
|
12
|
+
return {
|
|
13
|
+
fixed: before.findings.filter((f) => notInfo(f) && !afterIds.has(f.id)),
|
|
14
|
+
added: after.findings.filter((f) => notInfo(f) && !beforeIds.has(f.id)),
|
|
15
|
+
counts: { before: countBySeverity(before.findings), after: countBySeverity(after.findings) },
|
|
16
|
+
versions: {
|
|
17
|
+
reactNative: [before.project.reactNative, after.project.reactNative],
|
|
18
|
+
expo: [before.project.expo, after.project.expo],
|
|
19
|
+
newArch: [arch(before.project.newArch), arch(after.project.newArch)],
|
|
20
|
+
dependencies: [before.deps.length, after.deps.length],
|
|
21
|
+
native: [before.deps.filter((d) => d.native).length, after.deps.filter((d) => d.native).length],
|
|
22
|
+
},
|
|
23
|
+
dates: [before.scannedAt, after.scannedAt],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function compareToMarkdown(c, { title = 'Health report: before and after' } = {}) {
|
|
28
|
+
const row = (label, [a, b]) => (a === b || (a == null && b == null) ? null : `| ${label} | ${a ?? '–'} | ${b ?? '–'} |`);
|
|
29
|
+
const lines = [`### ${title}`, '', `${c.dates[0].slice(0, 10)} → ${c.dates[1].slice(0, 10)}`, '', '| | Before | After |', '|---|---|---|'];
|
|
30
|
+
lines.push(
|
|
31
|
+
...[
|
|
32
|
+
row('React Native', c.versions.reactNative),
|
|
33
|
+
row('Expo SDK', c.versions.expo),
|
|
34
|
+
row('New Architecture', c.versions.newArch),
|
|
35
|
+
row('Dependencies', c.versions.dependencies),
|
|
36
|
+
row('Native modules', c.versions.native),
|
|
37
|
+
].filter(Boolean),
|
|
38
|
+
);
|
|
39
|
+
for (const sev of SEVERITIES.filter((s) => s !== 'info')) {
|
|
40
|
+
lines.push(`| ${sev[0].toUpperCase()}${sev.slice(1)} findings | ${c.counts.before[sev]} | ${c.counts.after[sev]} |`);
|
|
41
|
+
}
|
|
42
|
+
lines.push('');
|
|
43
|
+
if (c.fixed.length) {
|
|
44
|
+
lines.push(`**Fixed (${c.fixed.length})**`, '');
|
|
45
|
+
for (const f of c.fixed) lines.push(`- ✅ ${f.title}`);
|
|
46
|
+
lines.push('');
|
|
47
|
+
}
|
|
48
|
+
if (c.added.length) {
|
|
49
|
+
lines.push(`**New (${c.added.length})**`, '');
|
|
50
|
+
for (const f of c.added) lines.push(`- ${f.severity === 'critical' || f.severity === 'high' ? '⚠️' : '•'} ${f.title}`);
|
|
51
|
+
lines.push('');
|
|
52
|
+
}
|
|
53
|
+
if (!c.fixed.length && !c.added.length) lines.push('No changes in findings.', '');
|
|
54
|
+
return `${lines.join('\n')}\n`;
|
|
55
|
+
}
|
package/src/compat.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { compareVersions } from './known-issues.js';
|
|
2
|
+
|
|
3
|
+
// Which library versions work with which React Native versions, per architecture. Taken from
|
|
4
|
+
// each library's own compatibility table; nothing here is guessed. Rows are version ranges
|
|
5
|
+
// [from, to] (inclusive; `to` may be open) with the React Native minor range they support.
|
|
6
|
+
// legacy: false means the range does not run on the old architecture at all.
|
|
7
|
+
|
|
8
|
+
export const COMPAT = {
|
|
9
|
+
'react-native-screens': {
|
|
10
|
+
source: 'https://github.com/software-mansion/react-native-screens#support-for-fabric (checked 2026-10-03)',
|
|
11
|
+
newArch: [
|
|
12
|
+
{ from: '4.0.0', to: '4.4.99', rnMin: 76 },
|
|
13
|
+
{ from: '4.5.0', to: '4.13.99', rnMin: 77 },
|
|
14
|
+
{ from: '4.14.0', to: '4.18.99', rnMin: 79 },
|
|
15
|
+
{ from: '4.19.0', to: '4.24.99', rnMin: 81 },
|
|
16
|
+
{ from: '4.25.0', to: '4.25.99', rnMin: 82 },
|
|
17
|
+
{ from: '4.26.0', rnMin: 84 },
|
|
18
|
+
],
|
|
19
|
+
legacy: [
|
|
20
|
+
{ from: '4.0.0', to: '4.4.99', rnMin: 72 },
|
|
21
|
+
{ from: '4.5.0', to: '4.8.99', rnMin: 74 },
|
|
22
|
+
{ from: '4.9.0', to: '4.13.99', rnMin: 76 },
|
|
23
|
+
{ from: '4.14.0', to: '4.18.99', rnMin: 79 },
|
|
24
|
+
{ from: '4.19.0', to: '4.24.99', rnMin: 80 },
|
|
25
|
+
{ from: '4.25.0', unsupported: true },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
'react-native-gesture-handler': {
|
|
29
|
+
source: 'https://github.com/software-mansion/react-native-gesture-handler#gesture-handler-2 (checked 2026-10-03)',
|
|
30
|
+
note: 'Gesture Handler supports the three latest React Native minors; the table gives minimums.',
|
|
31
|
+
newArch: [
|
|
32
|
+
{ from: '2.18.0', to: '2.20.99', rnMin: 73 },
|
|
33
|
+
{ from: '2.21.0', to: '2.23.99', rnMin: 74 },
|
|
34
|
+
{ from: '2.24.0', to: '2.24.99', rnMin: 75 },
|
|
35
|
+
{ from: '2.25.0', to: '2.25.99', rnMin: 76 },
|
|
36
|
+
{ from: '2.26.0', to: '2.27.99', rnMin: 78 },
|
|
37
|
+
{ from: '2.28.0', to: '2.31.99', rnMin: 79 },
|
|
38
|
+
{ from: '2.32.0', to: '2.99.99', rnMin: 84 },
|
|
39
|
+
{ from: '3.0.0', rnMin: 82 },
|
|
40
|
+
],
|
|
41
|
+
legacy: [
|
|
42
|
+
{ from: '2.18.0', to: '2.20.99', rnMin: 73 },
|
|
43
|
+
{ from: '2.21.0', to: '2.23.99', rnMin: 74 },
|
|
44
|
+
{ from: '2.24.0', to: '2.24.99', rnMin: 75 },
|
|
45
|
+
{ from: '2.25.0', to: '2.25.99', rnMin: 76 },
|
|
46
|
+
{ from: '2.26.0', to: '2.27.99', rnMin: 78 },
|
|
47
|
+
{ from: '2.28.0', to: '2.31.99', rnMin: 79 },
|
|
48
|
+
{ from: '2.32.0', to: '2.99.99', rnMin: 84 },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
'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)',
|
|
53
|
+
newArch: [
|
|
54
|
+
{ from: '3.0.0', to: '3.0.99', rnMin: 71, rnMax: 71 },
|
|
55
|
+
{ from: '3.1.0', to: '3.5.99', rnMin: 72, rnMax: 72 },
|
|
56
|
+
{ from: '3.6.0', to: '3.8.99', rnMin: 72, rnMax: 73 },
|
|
57
|
+
{ from: '3.9.0', to: '3.14.99', rnMin: 74, rnMax: 74 },
|
|
58
|
+
{ from: '3.15.0', to: '3.15.99', rnMin: 74, rnMax: 75 },
|
|
59
|
+
{ from: '3.16.0', to: '3.16.99', rnMin: 74, rnMax: 77 },
|
|
60
|
+
{ from: '3.17.0', to: '3.17.99', rnMin: 75, rnMax: 79 },
|
|
61
|
+
{ from: '3.18.0', to: '3.18.99', rnMin: 75, rnMax: 80 },
|
|
62
|
+
{ 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 },
|
|
68
|
+
],
|
|
69
|
+
legacy: [
|
|
70
|
+
{ from: '3.0.0', to: '3.2.99', rnMin: 63, rnMax: 71 },
|
|
71
|
+
{ 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 },
|
|
79
|
+
{ from: '4.0.0', unsupported: true },
|
|
80
|
+
],
|
|
81
|
+
note: 'Reanimated 4 also needs react-native-worklets; Reanimated 3.x is no longer actively maintained.',
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const inRange = (version, row) =>
|
|
86
|
+
compareVersions(version, row.from) >= 0 && (!row.to || compareVersions(version, row.to) <= 0);
|
|
87
|
+
|
|
88
|
+
const rnLabel = (row) => (row.rnMax ? `0.${row.rnMin}–0.${row.rnMax}` : `0.${row.rnMin}+`);
|
|
89
|
+
function versionLabel(row) {
|
|
90
|
+
const from = row.from.replace(/\.0$/, '');
|
|
91
|
+
if (!row.to) return `${from}+`;
|
|
92
|
+
const to = row.to.replace(/\.99$/, '');
|
|
93
|
+
return from === to ? `${from}.x` : `${from}–${to}.x`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Problem with the installed version on this React Native minor and architecture, or null.
|
|
97
|
+
export function checkCompat(pkg, version, rnMinor, newArch) {
|
|
98
|
+
const table = COMPAT[pkg];
|
|
99
|
+
if (!table || !version || rnMinor == null) return null;
|
|
100
|
+
const rows = newArch ? table.newArch : table.legacy;
|
|
101
|
+
const row = rows.find((r) => inRange(version, r));
|
|
102
|
+
if (!row) return null; // older than the table covers: no claim
|
|
103
|
+
if (row.unsupported) return `${pkg} ${version} does not support the ${newArch ? 'New' : 'legacy'} Architecture`;
|
|
104
|
+
if (rnMinor < row.rnMin || (row.rnMax && rnMinor > row.rnMax)) {
|
|
105
|
+
return `${pkg} ${version} supports React Native ${rnLabel(row)}${newArch ? '' : ' on the legacy architecture'}, not 0.${rnMinor}`;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The newest version range of `pkg` that supports this React Native minor and architecture.
|
|
111
|
+
// With `major`, only ranges of that major version (to avoid a major migration in a hop).
|
|
112
|
+
export function bestRange(pkg, rnMinor, newArch, { major = null } = {}) {
|
|
113
|
+
const table = COMPAT[pkg];
|
|
114
|
+
if (!table) return null;
|
|
115
|
+
const rows = (newArch ? table.newArch : table.legacy).filter(
|
|
116
|
+
(r) =>
|
|
117
|
+
!r.unsupported &&
|
|
118
|
+
rnMinor >= r.rnMin &&
|
|
119
|
+
(!r.rnMax || rnMinor <= r.rnMax) &&
|
|
120
|
+
(major === null || Number(r.from.split('.')[0]) === major),
|
|
121
|
+
);
|
|
122
|
+
if (!rows.length) return null;
|
|
123
|
+
const best = rows.sort((a, b) => compareVersions(b.from, a.from))[0];
|
|
124
|
+
return { range: versionLabel(best), from: best.from, to: best.to || null };
|
|
125
|
+
}
|
package/src/config.js
ADDED