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 CHANGED
@@ -156,7 +156,9 @@ async function main() {
156
156
  return result.findings.some((f) => SEVERITIES.indexOf(f.severity) <= threshold) ? 1 : 0;
157
157
  }
158
158
 
159
+ // Exit only after stdout has flushed: process.exit() right after a large write to a pipe
160
+ // cuts the output off at 8 KB (`npx nativekeel --json | jq` got broken JSON).
159
161
  main().then(
160
- (code) => process.exit(code),
162
+ (code) => process.stdout.write('', () => process.exit(code)),
161
163
  (e) => fail(e.stack || e.message),
162
164
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nativekeel",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Health check for React Native apps: version support, New Architecture blockers, unmaintained native modules and leaked secrets.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/analyze.js CHANGED
@@ -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, trust React Native Directory's platform flags.
50
+ // Without node_modules, ask React Native Directory. Its ios/android flags mean "works on",
51
+ // not "has native code" (pure JS packages set them too), so prefer its hasNativeCode.
52
+ if (dir && dir.github && typeof dir.github.hasNativeCode === 'boolean') return dir.github.hasNativeCode;
51
53
  return !!(dir && (dir.ios || dir.android) && !dir.web);
52
54
  }
53
55
 
@@ -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 >= SUPPORTED_RN_MINORS) {
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 = cleanVersion(installedVersion(project.root, name) || declaredRange(name));
180
- const native = isNativeModule(project.root, name, dir);
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 noNewArch = native && dir && dir.newArchitecture === false;
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: issue.severity,
315
+ severity,
267
316
  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 },
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}`,
@@ -38,9 +38,19 @@ export const KNOWN_ISSUES = [
38
38
  rnFrom: 76,
39
39
  when: 'newArch',
40
40
  severity: 'high',
41
- title: 'react-native-linear-gradient 2.x crashes the New Architecture when a screen unmounts',
42
- detail: "2.x has no Fabric component, so BVLinearGradient runs through the interop layer. Its children are not detached before React Native recycles them: \"RCTComponentViewRegistry: Attempt to recycle a mounted view\" (an abort on iOS, right after login in the app where it was found). React Native 0.76+ draws gradients itself: replace it with a small component that sets experimental_backgroundImage: 'linear-gradient(...)' and drop the native package.",
43
- source: 'real upgrade of an RN 0.80 app, 2026-10-04 (UI test after login)',
41
+ title: 'LinearGradient around a Modal: a known New Architecture crash',
42
+ detail: "react-native-linear-gradient 2.x has no Fabric component, so it runs through the interop layer. When a LinearGradient contains a Modal (or a Modal-based bottom sheet), closing it can abort the app: \"RCTComponentViewRegistry: Attempt to recycle a mounted view\" (upstream issue #718). React Native 0.76+ draws gradients itself: experimental_backgroundImage: 'linear-gradient(...)' on a View.",
43
+ source: 'real upgrade of an RN 0.80 app, 2026-10-04 (UI test after login); upstream issue #718',
44
+ // Only high when the code has the crashing shape; many apps use 2.x under Fabric without it.
45
+ evidence: {
46
+ imports: 'react-native-linear-gradient',
47
+ pattern: '<Modal\\b|<RBSheet\\b|<BottomSheetModal\\b|react-native-modal|react-native-raw-bottom-sheet',
48
+ },
49
+ withoutEvidence: {
50
+ severity: 'low',
51
+ title: 'react-native-linear-gradient 2.x runs through the New Architecture interop layer',
52
+ detail: "It has no Fabric component. No LinearGradient next to a Modal was found, which is the layout known to crash (upstream issue #718). React Native 0.76+ can draw gradients itself (experimental_backgroundImage), which removes a native dependency.",
53
+ },
44
54
  },
45
55
  ];
46
56
 
package/src/native.js CHANGED
@@ -131,7 +131,8 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
131
131
  const appGradle = read(path.join(root, 'android', 'app', 'build.gradle')) || read(path.join(root, 'android', 'app', 'build.gradle.kts')) || '';
132
132
  const gradleProps = read(path.join(root, 'android', 'gradle.properties')) || '';
133
133
  const podfile = read(path.join(root, 'ios', 'Podfile')) || '';
134
- if (/com\.facebook\.flipper|FLIPPER_VERSION/.test(appGradle + gradleProps) || /use_flipper!|FlipperConfiguration\.enabled/.test(podfile)) {
134
+ const flipperWired = /com\.facebook\.flipper/.test(appGradle) || /^\s*[^#\n]*(use_flipper!|FlipperConfiguration\.enabled)/m.test(podfile);
135
+ if (flipperWired) {
135
136
  add({
136
137
  id: 'flipper',
137
138
  severity: 'medium',
@@ -140,6 +141,15 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
140
141
  detail: 'Flipper was removed from React Native in 0.74 and its native libraries are not 16 KB aligned. Remove the Flipper dependencies and initialization code before upgrading; use React Native DevTools instead.',
141
142
  fix: { kind: 'remove-flipper' },
142
143
  });
144
+ } else if (/^\s*FLIPPER_VERSION\s*=/m.test(gradleProps)) {
145
+ // A leftover property from an old template: harmless, but worth deleting.
146
+ add({
147
+ id: 'flipper-leftover',
148
+ severity: 'low',
149
+ area: 'native',
150
+ title: 'Leftover FLIPPER_VERSION in android/gradle.properties',
151
+ detail: 'Nothing uses it (Flipper is not wired into the build). Delete the line so nobody wonders whether Flipper is still there.',
152
+ });
143
153
  }
144
154
 
145
155
  // Podfile still loading the CLI's native_modules.rb: React Native provides use_native_modules!
@@ -278,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 signing = tracked.filter((f) => /\.(jks|keystore)$/i.test(f) && !/debug/i.test(path.basename(f)));
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
- for (const f of tracked.filter((x) => /(^|\/)AuthKey_[A-Z0-9]+\.p8$/.test(x))) {
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
- ...[...text.matchAll(/\b(storePassword|keyPassword)\s*=?\s*["']([^"']+)["']/g)].map((m) => ({ name: m[1], value: m[2] })),
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 deps = { ...(pkg.dependencies || {}) };
105
+ const usesCatalog = JSON.stringify([pkg.dependencies, pkg.devDependencies]).includes('"catalog:');
106
+ const catalogs = usesCatalog ? readCatalogs(root) : null;
107
+ const deps = resolveCatalogRefs({ ...(pkg.dependencies || {}) }, catalogs);
49
108
  // Monorepos often keep react-native in devDependencies; autolinking still links native modules from there.
50
- const devDeps = { ...(pkg.devDependencies || {}) };
109
+ const devDeps = resolveCatalogRefs({ ...(pkg.devDependencies || {}) }, catalogs);
51
110
  const declared = (name) => deps[name] || devDeps[name];
52
111
  if (!declared('react-native') && !declared('expo')) {
53
112
  throw new Error('This does not look like a React Native app (no react-native or expo dependency). Run it inside your app folder.');
@@ -57,9 +116,16 @@ export function loadProject(root) {
57
116
  const hasIos = fs.existsSync(path.join(root, 'ios'));
58
117
  const appConfig = readJson(path.join(root, 'app.json'));
59
118
 
119
+ // A React Native library (or an example inside one) is not an app: store checks do not apply.
120
+ const androidGradle = readText(path.join(root, 'android', 'build.gradle')) || '';
121
+ const isLibrary =
122
+ !!(pkg.peerDependencies && pkg.peerDependencies['react-native']) ||
123
+ /apply plugin:\s*['"]com\.android\.library['"]|id\s*\(?\s*['"]com\.android\.library['"]/.test(androidGradle);
124
+
60
125
  return {
61
126
  root,
62
127
  name: pkg.name,
128
+ isLibrary,
63
129
  deps,
64
130
  devDeps,
65
131
  rnVersion: cleanVersion(installedVersion(root, 'react-native') || declared('react-native') || ''),
package/src/registry.js CHANGED
@@ -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: text.slice(0, m.index).split('\n').length,
103
+ line,
90
104
  preview: value ? mask(value) : null,
91
- inAppBundle: !SERVER_DIRS.has(rel.split(path.sep)[0]),
92
- committed: tracked ? tracked.has(rel.split(path.sep).join('/')) : null,
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
+ }