nativekeel 0.1.3 → 0.1.4
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 +69 -11
- package/src/known-issues.js +13 -3
- package/src/native.js +33 -6
- package/src/plan.js +4 -0
- package/src/project.js +68 -2
- package/src/registry.js +20 -0
- package/src/secrets.js +19 -4
- package/src/usage.js +12 -0
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
|
@@ -4,7 +4,7 @@ import { cleanVersion, findPackageDir, installedVersion } from './project.js';
|
|
|
4
4
|
import { createRegistry } from './registry.js';
|
|
5
5
|
import { scanSecrets } from './secrets.js';
|
|
6
6
|
import { nativeChecks } from './native.js';
|
|
7
|
-
import { findUnused } from './usage.js';
|
|
7
|
+
import { findUnused, filesImportingWith } from './usage.js';
|
|
8
8
|
import { matchKnownIssues } from './known-issues.js';
|
|
9
9
|
import { COMPAT, bestRange, checkCompat } from './compat.js';
|
|
10
10
|
import { interopProblems } from './interop.js';
|
|
@@ -47,7 +47,9 @@ 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
|
|
|
@@ -74,14 +76,32 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
74
76
|
if (!project.hasNodeModules) {
|
|
75
77
|
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
78
|
}
|
|
79
|
+
const rnSpec = project.deps['react-native'] || project.devDeps['react-native'];
|
|
80
|
+
if (!project.rnVersion && rnSpec) {
|
|
81
|
+
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.`);
|
|
82
|
+
}
|
|
83
|
+
if (project.isLibrary) {
|
|
84
|
+
warnings.push('This looks like a React Native library, not an app (react-native is a peer dependency, or android/ builds a library). App Store and Google Play checks were skipped; scan the app that uses it for those.');
|
|
85
|
+
}
|
|
77
86
|
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
87
|
if (!offline && project.expoVersion && !expo) warnings.push('Could not fetch Expo releases from npm; the SDK check was skipped. Run again.');
|
|
79
88
|
|
|
80
89
|
// 1. React Native version support
|
|
81
90
|
const current = project.rnVersion;
|
|
91
|
+
// In an Expo project the SDK pins React Native: upgrading the SDK is how React Native moves,
|
|
92
|
+
// so the Expo finding carries the severity and this one only explains the link.
|
|
93
|
+
const rnFollowsExpo = !!(project.expoVersion && expo);
|
|
82
94
|
if (rn && current) {
|
|
83
95
|
const behind = rn.lines[0] - minorOf(current);
|
|
84
|
-
if (behind
|
|
96
|
+
if (rnFollowsExpo && behind > 0) {
|
|
97
|
+
add({
|
|
98
|
+
id: 'rn-via-expo',
|
|
99
|
+
severity: 'info',
|
|
100
|
+
area: 'react-native',
|
|
101
|
+
title: `React Native ${current} comes with Expo SDK ${majorOf(project.expoVersion)}`,
|
|
102
|
+
detail: `Latest React Native is ${rn.latest} (${behind} minor${behind === 1 ? '' : 's'} behind). Upgrading the Expo SDK upgrades React Native with it.`,
|
|
103
|
+
});
|
|
104
|
+
} else if (behind >= SUPPORTED_RN_MINORS) {
|
|
85
105
|
add({
|
|
86
106
|
id: 'rn-unsupported',
|
|
87
107
|
severity: behind >= CRITICAL_RN_MINORS_BEHIND ? 'critical' : 'high',
|
|
@@ -140,7 +160,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
140
160
|
}
|
|
141
161
|
|
|
142
162
|
// 4. Google Play target SDK
|
|
143
|
-
const target = project.android && project.android.targetSdk;
|
|
163
|
+
const target = !project.isLibrary && project.android && project.android.targetSdk;
|
|
144
164
|
if (target) {
|
|
145
165
|
const today = now.toISOString().slice(0, 10);
|
|
146
166
|
const { updates, visibility } = PLAY_TARGET_SDK;
|
|
@@ -173,11 +193,16 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
173
193
|
const published = forcedOffline ? {} : await registry.publishDates(recencyCandidates);
|
|
174
194
|
const daysSince = (date) => (date ? Math.floor((now - new Date(`${date}T00:00:00Z`)) / 86400000) : null);
|
|
175
195
|
|
|
196
|
+
const nativeOf = Object.fromEntries(names.map((n) => [n, isNativeModule(project.root, n, directory[n])]));
|
|
197
|
+
const versionOf = (n) => cleanVersion(installedVersion(project.root, n) || declaredRange(n));
|
|
198
|
+
const archSuspects = names.filter((n) => nativeOf[n] && directory[n] && directory[n].newArchitecture === false);
|
|
199
|
+
const codegen = forcedOffline ? {} : await registry.codegenSupport(archSuspects.map((n) => ({ name: n, version: versionOf(n) })));
|
|
200
|
+
|
|
176
201
|
const deps = [];
|
|
177
202
|
for (const name of names) {
|
|
178
203
|
const dir = directory[name];
|
|
179
|
-
const version =
|
|
180
|
-
const native =
|
|
204
|
+
const version = versionOf(name);
|
|
205
|
+
const native = nativeOf[name];
|
|
181
206
|
const release = published[name] || {};
|
|
182
207
|
const age = daysSince(release.date);
|
|
183
208
|
const revived = !!(dir && dir.unmaintained && age !== null && age <= RECENT_RELEASE_DAYS);
|
|
@@ -192,7 +217,22 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
192
217
|
};
|
|
193
218
|
deps.push(dep);
|
|
194
219
|
|
|
195
|
-
const
|
|
220
|
+
const cg = codegen[name];
|
|
221
|
+
// Directory says no New Architecture, but npm may know better.
|
|
222
|
+
const archFixedIn = native && dir && dir.newArchitecture === false && cg && !cg.usedHas && cg.latestHas ? cg.latest : null;
|
|
223
|
+
const noNewArch = native && dir && dir.newArchitecture === false && !(cg && (cg.usedHas || cg.latestHas));
|
|
224
|
+
if (cg && cg.usedHas) dep.newArch = true;
|
|
225
|
+
if (archFixedIn) {
|
|
226
|
+
add({
|
|
227
|
+
id: `dep-risk:${name}`,
|
|
228
|
+
severity: 'medium',
|
|
229
|
+
area: 'dependency',
|
|
230
|
+
title: `${name} ${version || ''}: New Architecture support needs ${archFixedIn}`.replace(' ', ' '),
|
|
231
|
+
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.`,
|
|
232
|
+
fix: { kind: 'bump-dep', name, from: version, to: archFixedIn, native },
|
|
233
|
+
});
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
196
236
|
// Only code that has to follow React Native or the OS goes stale; a finished JS utility does not.
|
|
197
237
|
const stale = !dep.unmaintained && !revived && age !== null && age > STALE_RELEASE_DAYS && (native || !!release.rnLink);
|
|
198
238
|
const alternatives = dir && dir.alternatives && dir.alternatives.length ? dir.alternatives : REPLACEMENTS[name] || [];
|
|
@@ -261,13 +301,22 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
261
301
|
const newArchRequired =
|
|
262
302
|
findings.some((f) => f.id === 'new-arch-disabled') || (archOff && current && minorOf(current) < NEW_ARCH_ONLY_MINOR);
|
|
263
303
|
for (const { issue, dep } of matchKnownIssues({ deps, rnVersion: current, newArchOn, newArchRequired })) {
|
|
304
|
+
let { severity, title, detail } = issue;
|
|
305
|
+
if (issue.evidence) {
|
|
306
|
+
const files = filesImportingWith(project.root, issue.evidence.imports, issue.evidence.pattern);
|
|
307
|
+
if (files.length) {
|
|
308
|
+
detail = `Found in ${files.slice(0, 3).join(', ')}${files.length > 3 ? ` and ${files.length - 3} more` : ''}. ${detail}`;
|
|
309
|
+
} else {
|
|
310
|
+
({ severity, title, detail } = { ...issue, ...issue.withoutEvidence });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
264
313
|
add({
|
|
265
314
|
id: `known:${issue.id}`,
|
|
266
|
-
severity
|
|
315
|
+
severity,
|
|
267
316
|
area: 'dependency',
|
|
268
|
-
title
|
|
269
|
-
detail: `${
|
|
270
|
-
fix: { kind: 'known-issue', name: dep.name, title
|
|
317
|
+
title,
|
|
318
|
+
detail: `${detail} (installed: ${dep.name} ${dep.version}; seen in: ${issue.source})`,
|
|
319
|
+
fix: { kind: 'known-issue', name: dep.name, title, detail },
|
|
271
320
|
});
|
|
272
321
|
}
|
|
273
322
|
|
|
@@ -322,6 +371,15 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
|
|
|
322
371
|
detail: `${value}Anyone who downloads the app can extract it. Revoke it now, then move the call to a server.`,
|
|
323
372
|
fix: { kind: 'secret', label: s.label, file: s.file, line: s.line, bundled: true },
|
|
324
373
|
});
|
|
374
|
+
} else if (s.committed !== false && s.inComment) {
|
|
375
|
+
add({
|
|
376
|
+
id: `secret:${s.rule}:${s.file}`,
|
|
377
|
+
severity: 'medium',
|
|
378
|
+
area: 'secret',
|
|
379
|
+
title: `${s.label} in a commented-out line (${where})`,
|
|
380
|
+
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.`,
|
|
381
|
+
fix: { kind: 'secret', label: s.label, file: s.file, line: s.line, bundled: false },
|
|
382
|
+
});
|
|
325
383
|
} else if (s.committed !== false) {
|
|
326
384
|
add({
|
|
327
385
|
id: `secret:${s.rule}:${s.file}`,
|
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
|
@@ -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,7 +288,7 @@ 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
|
if (!manifests.length) {
|
|
284
294
|
add({
|
|
@@ -365,7 +375,14 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
365
375
|
// someone publish as you. The debug keystore and its public 'android' password are fine.
|
|
366
376
|
const tracked = gitTracked(root);
|
|
367
377
|
if (tracked) {
|
|
368
|
-
const
|
|
378
|
+
const nonEmpty = (f) => {
|
|
379
|
+
try {
|
|
380
|
+
return fs.statSync(path.join(root, f)).size > 0;
|
|
381
|
+
} catch {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
const signing = tracked.filter((f) => /\.(jks|keystore)$/i.test(f) && !/debug/i.test(path.basename(f)) && nonEmpty(f));
|
|
369
386
|
for (const f of signing) {
|
|
370
387
|
add({
|
|
371
388
|
id: `signing-keystore:${f}`,
|
|
@@ -376,7 +393,16 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
376
393
|
fix: { kind: 'secret', label: 'Release keystore', file: f, line: 1, bundled: false },
|
|
377
394
|
});
|
|
378
395
|
}
|
|
379
|
-
|
|
396
|
+
// An empty placeholder file is not a key (some repos commit one so fastlane paths resolve).
|
|
397
|
+
const hasContent = (f, marker) => {
|
|
398
|
+
try {
|
|
399
|
+
const text = fs.readFileSync(path.join(root, f), 'utf8');
|
|
400
|
+
return marker ? text.includes(marker) : text.length > 0;
|
|
401
|
+
} catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
for (const f of tracked.filter((x) => /(^|\/)AuthKey_[A-Z0-9]+\.p8$/.test(x) && hasContent(x, 'PRIVATE KEY'))) {
|
|
380
406
|
add({
|
|
381
407
|
id: `signing-asc-key:${f}`,
|
|
382
408
|
severity: 'critical',
|
|
@@ -386,7 +412,7 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
386
412
|
fix: { kind: 'secret', label: 'App Store Connect API key', file: f, line: 1, bundled: false },
|
|
387
413
|
});
|
|
388
414
|
}
|
|
389
|
-
for (const f of tracked.filter((x) => /\.p12$/i.test(x))) {
|
|
415
|
+
for (const f of tracked.filter((x) => /\.p12$/i.test(x) && nonEmpty(x))) {
|
|
390
416
|
add({
|
|
391
417
|
id: `signing-p12:${f}`,
|
|
392
418
|
severity: 'high',
|
|
@@ -401,7 +427,8 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
|
|
|
401
427
|
const text = read(path.join(root, f)) || '';
|
|
402
428
|
const leaked = [
|
|
403
429
|
...[...text.matchAll(/^\s*([A-Z0-9_]*(?:STORE|KEY)_PASSWORD)\s*=\s*(\S+)\s*$/gm)].map((m) => ({ name: m[1], value: m[2] })),
|
|
404
|
-
|
|
430
|
+
// Not `props['storePassword']`: there the name is a lookup key, not an assignment.
|
|
431
|
+
...[...text.matchAll(/(?<![\w'"[])(storePassword|keyPassword)\s*=?\s*["']([^"'\n]+)["']/g)].map((m) => ({ name: m[1], value: m[2] })),
|
|
405
432
|
].filter((l) => l.value !== 'android' && !/^System\.getenv|^\$\{?[A-Z_]+\}?$/.test(l.value));
|
|
406
433
|
if (leaked.length) {
|
|
407
434
|
add({
|
package/src/plan.js
CHANGED
|
@@ -350,6 +350,10 @@ export function buildPlan(result) {
|
|
|
350
350
|
if (expo) {
|
|
351
351
|
const steps = [];
|
|
352
352
|
for (let v = expo.from + 1; v <= expo.to; v++) steps.push(`SDK ${v - 1} → ${v}: \`npx expo install expo@^${v}.0.0 --fix\`, read the SDK ${v} changelog, run \`npx expo-doctor\`, build.`);
|
|
353
|
+
steps.push('Each SDK brings its own React Native version; do not upgrade React Native separately.');
|
|
354
|
+
if (!result.project.managed) {
|
|
355
|
+
steps.push('This app has its own android/ and ios/ folders: after each SDK, apply the native changes from Expo\'s native project upgrade helper (docs.expo.dev/bare/upgrade), then `pod install` and build both platforms.');
|
|
356
|
+
}
|
|
353
357
|
phases.push({ title: `Upgrade Expo SDK ${expo.from} → ${expo.to}`, why: 'Expo only supports one SDK step at a time reliably.', steps });
|
|
354
358
|
}
|
|
355
359
|
|
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
|
@@ -69,6 +69,26 @@ export function createRegistry(get = fetchJson) {
|
|
|
69
69
|
return out;
|
|
70
70
|
},
|
|
71
71
|
|
|
72
|
+
// Whether the version in use, and the latest, ship a codegen spec (`codegenConfig` in their
|
|
73
|
+
// package.json), i.e. native New Architecture support. React Native Directory's flag lags
|
|
74
|
+
// releases, so this is the tie-breaker before calling a package a blocker.
|
|
75
|
+
async codegenSupport(list, concurrency = 8) {
|
|
76
|
+
const out = {};
|
|
77
|
+
const queue = [...list];
|
|
78
|
+
const manifest = (name, tag) => get(`https://registry.npmjs.org/${name.replace('/', '%2F')}/${tag}`);
|
|
79
|
+
const worker = async () => {
|
|
80
|
+
while (queue.length) {
|
|
81
|
+
const { name, version } = queue.shift();
|
|
82
|
+
const latest = await manifest(name, 'latest');
|
|
83
|
+
if (!latest) continue;
|
|
84
|
+
const used = version && version !== latest.version ? await manifest(name, version) : latest;
|
|
85
|
+
out[name] = { latest: latest.version, latestHas: !!latest.codegenConfig, usedHas: !!(used && used.codegenConfig) };
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
89
|
+
return out;
|
|
90
|
+
},
|
|
91
|
+
|
|
72
92
|
// Stable versions of a package, newest release line first.
|
|
73
93
|
async releaseLines(name, pick) {
|
|
74
94
|
// 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,14 @@ 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?$/;
|
|
48
|
+
|
|
49
|
+
const isCommentLine = (line) => /^\s*(\/\/|\/\*|\*|#|<!--)/.test(line);
|
|
50
|
+
|
|
43
51
|
export function mask(value) {
|
|
44
52
|
if (value.length <= 8) return '****';
|
|
45
53
|
return `${value.slice(0, 4)}…${value.slice(-2)}`;
|
|
@@ -77,19 +85,26 @@ export function scanSecrets(root) {
|
|
|
77
85
|
} catch {
|
|
78
86
|
continue;
|
|
79
87
|
}
|
|
88
|
+
const lines = text.split('\n');
|
|
80
89
|
for (const rule of RULES) {
|
|
81
90
|
for (const m of text.matchAll(rule.re)) {
|
|
82
91
|
if (rule.validate && !rule.validate(m)) continue;
|
|
92
|
+
if (PLACEHOLDER.test(m[0])) continue;
|
|
83
93
|
const rel = path.relative(root, file);
|
|
94
|
+
const posix = rel.split(path.sep).join('/');
|
|
84
95
|
const value = m.groups && m.groups.v;
|
|
96
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
97
|
+
// Comments are stripped from the bundle; a key there is committed, not shipped.
|
|
98
|
+
const inComment = isCommentLine(lines[line - 1] || '');
|
|
85
99
|
findings.push({
|
|
86
100
|
rule: rule.id,
|
|
87
101
|
label: rule.label,
|
|
88
102
|
file: rel,
|
|
89
|
-
line
|
|
103
|
+
line,
|
|
90
104
|
preview: value ? mask(value) : null,
|
|
91
|
-
inAppBundle: !SERVER_DIRS.has(
|
|
92
|
-
|
|
105
|
+
inAppBundle: !SERVER_DIRS.has(posix.split('/')[0]) && !TEST_PATH.test(posix) && !inComment,
|
|
106
|
+
inComment,
|
|
107
|
+
committed: tracked ? tracked.has(posix) : null,
|
|
93
108
|
});
|
|
94
109
|
}
|
|
95
110
|
}
|
package/src/usage.js
CHANGED
|
@@ -125,3 +125,15 @@ export function findUnused(project) {
|
|
|
125
125
|
.filter((n) => !nativeText.includes(n) && !nativeTokens(root, n).some((t) => nativeText.includes(t)))
|
|
126
126
|
.map((name) => ({ name, native: nativeTokens(root, name).length > 0 }));
|
|
127
127
|
}
|
|
128
|
+
|
|
129
|
+
// Source files (relative paths) that import `pkg` and whose text matches `pattern`.
|
|
130
|
+
export function filesImportingWith(root, pkg, pattern) {
|
|
131
|
+
const re = new RegExp(pattern);
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const file of walk(root, JS_EXT)) {
|
|
134
|
+
const text = readSmall(file);
|
|
135
|
+
if (!text.includes(pkg) || !importedPackages(text).has(pkg)) continue;
|
|
136
|
+
if (re.test(text)) out.push(path.relative(root, file));
|
|
137
|
+
}
|
|
138
|
+
return out.sort();
|
|
139
|
+
}
|