nativekeel 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nativekeel",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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
@@ -31,8 +31,10 @@ const lineOf = (v) => {
31
31
  };
32
32
 
33
33
  // react, react-native and @react-native/* move together with the React Native version; @babel/* and types are tooling.
34
+ // Also packages that move with React Native and React themselves (Metro, the scheduler, react-is).
34
35
  const isTracked = (name) =>
35
- !['react-native', 'react', 'expo'].includes(name) &&
36
+ !['react-native', 'react', 'expo', 'react-dom', 'react-is', 'react-test-renderer', 'scheduler', 'hermes-engine'].includes(name) &&
37
+ !/^metro(-|$)/.test(name) &&
36
38
  !name.startsWith('@react-native/') &&
37
39
  !name.startsWith('@babel/') &&
38
40
  !name.startsWith('@types/');
@@ -342,7 +344,7 @@ export async function analyze(project, { get, now = new Date(), offline: forcedO
342
344
  area: 'dependency',
343
345
  title,
344
346
  detail: `${detail} (installed: ${dep.name} ${dep.version}; seen in: ${issue.source})`,
345
- fix: { kind: 'known-issue', name: dep.name, title, detail },
347
+ fix: { kind: 'known-issue', name: dep.name, title, detail, severity },
346
348
  });
347
349
  }
348
350
 
package/src/native.js CHANGED
@@ -191,7 +191,9 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
191
191
  }
192
192
  const patched = [];
193
193
  for (const f of patchFiles) {
194
- const m = f.match(/^(.+?)\+(\d+\.\d+\.\d+[^.]*)(?:\+\d+)?\.patch$/);
194
+ // patch-package names: <pkg>+<version>.patch, <pkg>+<version>.dev.patch, and sequenced
195
+ // patches <pkg>+<version>+<NNN>[+<name>].patch (scoped packages use + for the slash).
196
+ const m = f.match(/^(.+?)\+(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\+\d+(?:\+[^/]*)?)?(?:\.dev)?\.patch$/);
195
197
  if (!m) continue;
196
198
  const pkg = m[1].replace(/\+/g, '/');
197
199
  const version = m[2];
@@ -226,7 +228,7 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
226
228
  id: 'patches-present',
227
229
  severity: 'info',
228
230
  area: 'native',
229
- title: `${patched.length} patch-package patch${patched.length === 1 ? '' : 'es'}: ${patched.map((p) => p.pkg).join(', ')}`,
231
+ title: `${patched.length} patch-package patch${patched.length === 1 ? '' : 'es'}: ${[...new Set(patched.map((p) => p.pkg))].join(', ')}`,
230
232
  detail: 'Each was written against one library version and one React Native version. After every upgrade step check that each still applies and is still needed; a patch that adapted a library to an older React Native can break it on a newer one.',
231
233
  fix: { kind: 'patches-review', patches: patched },
232
234
  });
@@ -274,15 +276,18 @@ export function nativeChecks(project, { rnLatest = null, now = new Date() } = {}
274
276
  }
275
277
 
276
278
  // Hermes: JavaScriptCore is no longer bundled with React Native.
277
- const hermesOff = /^\s*hermesEnabled\s*=\s*false/m.test(gradleProps) || /:hermes_enabled\s*=>\s*false/.test(podfile);
278
- if (hermesOff) {
279
+ const hermesOffOn = [
280
+ /^\s*hermesEnabled\s*=\s*false/m.test(gradleProps) && 'android',
281
+ /^[^#\n]*:hermes_enabled\s*=>\s*false/m.test(podfile) && 'ios',
282
+ ].filter(Boolean);
283
+ if (hermesOffOn.length) {
279
284
  add({
280
285
  id: 'hermes-disabled',
281
286
  severity: 'medium',
282
287
  area: 'native',
283
- title: 'Hermes is disabled (JavaScriptCore)',
288
+ title: `Hermes is disabled (JavaScriptCore on ${hermesOffOn.join(' + ')})`,
284
289
  detail: 'JavaScriptCore is no longer part of React Native; newer versions need the community JSC package. Switching to Hermes is the supported path and usually faster.',
285
- fix: { kind: 'enable-hermes' },
290
+ fix: { kind: 'enable-hermes', platforms: hermesOffOn },
286
291
  });
287
292
  }
288
293
 
package/src/plan.js CHANGED
@@ -67,10 +67,26 @@ const WEIGHTS = {
67
67
  'default-react-host': () => 0.25,
68
68
  };
69
69
 
70
+ // Routine version bumps are many but cheap next to a React Native, architecture or store
71
+ // change; capping them keeps a nearly current app with many outdated packages from being sized
72
+ // like a migration.
73
+ const BUMP_CAPS = { native: 6, js: 2 };
74
+
70
75
  export function complexity(findings) {
71
- const score = Math.round(
72
- findings.reduce((sum, f) => sum + (f.fix && WEIGHTS[f.fix.kind] ? WEIGHTS[f.fix.kind](f.fix) : 0), 0),
73
- );
76
+ let nativeBumps = 0;
77
+ let jsBumps = 0;
78
+ let rest = 0;
79
+ for (const f of findings) {
80
+ if (!f.fix || !WEIGHTS[f.fix.kind]) continue;
81
+ const w = WEIGHTS[f.fix.kind](f.fix);
82
+ if (f.fix.kind === 'bump-dep') {
83
+ if (f.fix.native) nativeBumps += w;
84
+ else jsBumps += w;
85
+ } else {
86
+ rest += w;
87
+ }
88
+ }
89
+ const score = Math.round(rest + Math.min(nativeBumps, BUMP_CAPS.native) + Math.min(jsBumps, BUMP_CAPS.js));
74
90
  const size = score < 8 ? 'Small' : score < 20 ? 'Medium' : score < 40 ? 'Large' : 'Extra large';
75
91
  return { score, size };
76
92
  }
@@ -200,7 +216,8 @@ export function buildPlan(result) {
200
216
  });
201
217
  }
202
218
 
203
- const known = byKind('known-issue');
219
+ // Low-severity notes stay in the report; the plan only carries what can break the app.
220
+ const known = byKind('known-issue').filter((k) => k.severity !== 'low');
204
221
  if (known.length) {
205
222
  phases.push({
206
223
  title: 'Avoid known traps',
@@ -228,6 +245,11 @@ export function buildPlan(result) {
228
245
  } else if (privacy) {
229
246
  storeSteps.push('Add `PrivacyInfo.xcprivacy` to the iOS app target. Start from the file in the React Native template and declare the required-reason APIs your app and SDKs use (UserDefaults, file timestamps, system boot time, disk space).');
230
247
  }
248
+ if (byKind('upgrade-rn-min').length) {
249
+ storeSteps.push(isExpo
250
+ ? '**16 KB memory pages:** Google Play rejects updates until the app is on Expo SDK 53 (React Native 0.79) or later. The Expo SDK phase below gets you there; reach SDK 53 before your next Play release.'
251
+ : '**16 KB memory pages:** Google Play rejects updates until the app is on React Native 0.77 or later. The React Native phase below gets you there; reach 0.77 before your next Play release.');
252
+ }
231
253
  const align = byKind('align-16kb')[0];
232
254
  if (align) {
233
255
  storeSteps.push(`Fix 16 KB alignment for ${align.libs.map((l) => `\`${l}\``).join(', ')}: find which package ships each library (\`grep -rl <lib> node_modules/*/android\`) and update it; libraries you build yourself need NDK r28+.`);
@@ -272,27 +294,43 @@ export function buildPlan(result) {
272
294
  if (byKind('remove-flipper').length) {
273
295
  cleanup.push('Remove Flipper: delete the `com.facebook.flipper` dependencies and `FLIPPER_VERSION` on Android, `use_flipper!` / Flipper config in the Podfile, and the `ReactNativeFlipper` initialization code. Use React Native DevTools instead.');
274
296
  }
275
- if (byKind('enable-hermes').length) {
276
- cleanup.push('Switch to Hermes: set `hermesEnabled=true` in `android/gradle.properties` and `:hermes_enabled => true` in the Podfile, then test anything that relied on JavaScriptCore behavior (Intl, Date parsing).');
297
+ const hermes = byKind('enable-hermes')[0];
298
+ if (hermes) {
299
+ const where = (hermes.platforms || ['android', 'ios']).map((pl) => (pl === 'android' ? 'set `hermesEnabled=true` in `android/gradle.properties`' : 'set `:hermes_enabled => true` in the Podfile'));
300
+ cleanup.push(`Switch to Hermes: ${where.join(' and ')}, then test anything that relied on JavaScriptCore behavior (Intl, Date parsing).`);
277
301
  }
278
302
  if (cleanup.length) {
279
303
  phases.push({ title: 'Remove legacy tooling', why: 'Leftovers from older templates break on newer React Native versions; removing them first shrinks the upgrade diff.', steps: cleanup });
280
304
  }
281
305
 
282
- const replace = byKind('replace-dep').sort((a, b) => Number(b.noNewArch) - Number(a.noNewArch) || Number(b.native) - Number(a.native));
306
+ const allReplace = byKind('replace-dep');
307
+ // Abandoned pure-JS packages do not block an upgrade; they get their own, later phase.
308
+ const jsAbandoned = allReplace.filter((d) => !d.native && !d.noNewArch);
309
+ const interopPatched = new Set(byKind('interop-job').map((j) => j.name));
310
+ const replace = allReplace.filter((d) => d.native || d.noNewArch).sort((a, b) => Number(b.noNewArch) - Number(a.noNewArch) || Number(b.native) - Number(a.native));
283
311
  if (replace.length) {
284
312
  phases.push({
285
313
  title: 'Replace blocking and abandoned packages',
286
314
  why: 'These packages decide how far you can upgrade. Swap them while the app still runs on its current version, so every problem has one cause.',
287
315
  steps: replace.map((d) => {
288
316
  const reason = [d.noNewArch && 'no New Architecture support', d.unmaintained && 'unmaintained'].filter(Boolean).join(', ');
317
+ if (interopPatched.has(d.name) && !d.unmaintained) {
318
+ return `\`${d.name}\` (${reason}, native). The New Architecture step below patches it so it loads; move to a release with a TurboModule spec once one exists.`;
319
+ }
289
320
  const owned = d.alternatives.find((a) => installed.has(a));
290
321
  if (owned) {
291
322
  return `\`${d.name}\` (${reason}${d.native ? ', native' : ''}). You already use \`${owned}\`: move the remaining usage there, then \`${pm.remove} ${d.name}\`.`;
292
323
  }
293
- const to = d.alternatives[0];
294
- const cmd = to ? ` \`${pm.remove} ${d.name} && ${pm.add} ${to}\`` : '';
295
- const options = d.alternatives.length ? `Options: ${d.alternatives.map((a) => `\`${a}\``).join(', ')}.${cmd}` : 'No known replacement: check for a maintained fork, or vendor and patch it with `patch-package`.';
324
+ // expo-* packages need Expo modules; a bare app without them should get another option first.
325
+ const usesExpoModules = isExpo || installed.has('expo');
326
+ const to = usesExpoModules ? d.alternatives[0] : d.alternatives.find((a) => !a.startsWith('expo-')) || d.alternatives[0];
327
+ const needsExpo = to && to.startsWith('expo-') && !usesExpoModules ? ' (Expo packages need Expo modules first: `npx install-expo-modules@latest`)' : '';
328
+ const cmd = to ? ` \`${pm.remove} ${d.name} && ${pm.add} ${to}\`${needsExpo}` : '';
329
+ const options = d.alternatives.length
330
+ ? `Options: ${d.alternatives.map((a) => `\`${a}\``).join(', ')}.${cmd}`
331
+ : d.noNewArch
332
+ ? 'No known replacement: check for a maintained fork, or vendor and patch it with `patch-package`.'
333
+ : 'Not a known blocker: keep it while it builds and passes your UI flows at every step below, and replace it (or patch it with `patch-package`) when it breaks.';
296
334
  return `\`${d.name}\` (${reason}${d.native ? ', native' : ''}). ${options}`;
297
335
  }),
298
336
  });
@@ -327,7 +365,7 @@ export function buildPlan(result) {
327
365
  steps.push(`In \`${depProvider.file}\`, add \`#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>\` and \`self.dependencyProvider = [RCTAppDependencyProvider new];\` before calling super. Without it the switch causes a launch crash in Release.`);
328
366
  }
329
367
  if (result.project.managed) {
330
- steps.push('Set `"newArchEnabled": true` in `app.json` (under `expo`), then run `npx expo prebuild --clean` and build.');
368
+ steps.push('Set `"newArchEnabled": true` in `app.json` (under `expo`), then make a new development build (`eas build --profile development`, or `npx expo run:ios` / `npx expo run:android`). Expo Go cannot test this switch.');
331
369
  } else {
332
370
  if (arch.platforms.includes('android')) steps.push('Android: set `newArchEnabled=true` in `android/gradle.properties`.');
333
371
  if (arch.platforms.includes('ios')) steps.push('iOS: set `"newArchEnabled": "true"` in `ios/Podfile.properties.json` (or run `RCT_NEW_ARCH_ENABLED=1 bundle exec pod install`).');
@@ -335,8 +373,10 @@ export function buildPlan(result) {
335
373
  for (const c of byKind('compat').filter((x) => x.forNewArch && x.range)) {
336
374
  steps.unshift(`First move \`${c.name}\` to ${c.range}: the installed version does not support the New Architecture on this React Native version.`);
337
375
  }
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).');
339
- steps.push('Build debug and release on both platforms.');
376
+ if (!result.project.managed) {
377
+ 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).');
378
+ }
379
+ steps.push(result.project.managed ? 'Build a development and a production build for both platforms.' : 'Build debug and release on both platforms.');
340
380
  steps.push('Click through every screen that uses a native module. Interop-layer problems show up at runtime, not at build time.');
341
381
  // Before 0.76 the New Architecture is too immature to switch on in place: do it on the way,
342
382
  // at the first stop between 0.76 and 0.81 (or Expo SDK 52+), in its own commit.
@@ -425,6 +465,18 @@ export function buildPlan(result) {
425
465
  phases.push({ title: `Upgrade Expo SDK ${expo.from} → ${expo.to}`, why: 'Expo only supports one SDK step at a time reliably.', steps });
426
466
  }
427
467
 
468
+ if (jsAbandoned.length) {
469
+ phases.push({
470
+ title: 'Plan replacements for abandoned JavaScript packages',
471
+ why: 'These do not block the upgrade (no native code), but nobody fixes their bugs any more. Replace them when you next touch that code.',
472
+ steps: jsAbandoned.map((d) => {
473
+ const owned = d.alternatives.find((a) => installed.has(a));
474
+ if (owned) return `\`${d.name}\`: you already use \`${owned}\`; move the remaining usage there, then \`${pm.remove} ${d.name}\`.`;
475
+ return `\`${d.name}\`${d.alternatives.length ? `: options ${d.alternatives.map((a) => `\`${a}\``).join(', ')}` : ': look for a maintained alternative, or keep it if it does what you need'}.`;
476
+ }),
477
+ });
478
+ }
479
+
428
480
  const jsBumps = byKind('bump-dep').filter((d) => !d.native && !movesWithExpo(d.name));
429
481
  if (jsBumps.length) {
430
482
  phases.push({
@@ -455,7 +507,9 @@ export function buildPlan(result) {
455
507
  title: 'Verify before you ship',
456
508
  why: 'An upgrade is done when the release build works on real devices, not when it compiles.',
457
509
  steps: [
458
- 'Release builds on both platforms (`./gradlew bundleRelease`, Xcode Archive).',
510
+ result.project.managed
511
+ ? 'Production builds on both platforms (`eas build --platform all --profile production`, or `npx expo run:android --variant release` and `npx expo run:ios --configuration Release`).'
512
+ : 'Release builds on both platforms (`./gradlew bundleRelease`, Xcode Archive).',
459
513
  'Test on a low-end Android device and on the newest iOS and Android versions.',
460
514
  'Ship to internal testing / TestFlight first and watch crash-free sessions for a few days before a staged rollout.',
461
515
  'Run `npx nativekeel` again: the report should have no critical or high findings.',
@@ -12,7 +12,8 @@ export function htmlReport(result, { brand = null, plan = null } = {}) {
12
12
  const n = countBySeverity(result.findings);
13
13
  const title = `${p.name || 'App'} health report`;
14
14
  const sevLabel = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info' };
15
- const arch = (v) => (v === true ? 'on' : v === false ? 'off' : 'default');
15
+ const defaultOn = !!result.project.reactNative && Number(result.project.reactNative.split('.')[1]) >= 76;
16
+ const arch = (v) => (v === true ? 'on' : v === false ? 'off' : defaultOn ? 'on (default)' : 'off (default)');
16
17
 
17
18
  const rows = SEVERITIES.flatMap((sev) =>
18
19
  result.findings
package/src/report-md.js CHANGED
@@ -14,7 +14,7 @@ export function markdownReport(result, { verbose = false } = {}) {
14
14
  [
15
15
  p.reactNative && `React Native **${p.reactNative}**${result.latest.reactNative ? ` (latest ${result.latest.reactNative})` : ''}`,
16
16
  p.expo && `Expo SDK **${p.expo.split('.')[0]}**`,
17
- `New Architecture: Android ${p.newArch.android === false ? '**off**' : p.newArch.android ? 'on' : 'default'}, iOS ${p.newArch.ios === false ? '**off**' : p.newArch.ios ? 'on' : 'default'}`,
17
+ `New Architecture: Android ${archMd(p, p.newArch.android)}, iOS ${archMd(p, p.newArch.ios)}`,
18
18
  ].filter(Boolean).join(' · '),
19
19
  '',
20
20
  `${ICON.critical} ${n.critical} critical · ${ICON.high} ${n.high} high · ${ICON.medium} ${n.medium} medium · ${ICON.low} ${n.low} low`,
@@ -40,3 +40,10 @@ export function markdownReport(result, { verbose = false } = {}) {
40
40
  lines.push(`<sub>Generated by [NativeKeel](${SITE_URL}) · \`npx nativekeel plan\` for a step-by-step upgrade plan</sub>`);
41
41
  return `${lines.join('\n')}\n`;
42
42
  }
43
+
44
+ // Not set means React Native's default: off before 0.76, on from 0.76.
45
+ function archMd(p, v) {
46
+ if (v === false) return '**off**';
47
+ if (v === true) return 'on';
48
+ return p.reactNative && Number(p.reactNative.split('.')[1]) >= 76 ? 'on (default)' : '**off** (default)';
49
+ }
@@ -14,7 +14,9 @@ export function textReport(result, { color = true, verbose = false } = {}) {
14
14
  const indent = ' ';
15
15
  const out = [];
16
16
  const p = result.project;
17
- const arch = (v) => (v === true ? c.green('on') : v === false ? c.red('off') : c.gray('default'));
17
+ // Not set means React Native's default: off before 0.76, on from 0.76.
18
+ const defaultOn = !!p.reactNative && Number(p.reactNative.split('.')[1]) >= 76;
19
+ const arch = (v) => (v === true ? c.green('on') : v === false ? c.red('off') : defaultOn ? c.gray('on (default)') : c.red('off (default)'));
18
20
 
19
21
  out.push('');
20
22
  out.push(`${c.bold('NativeKeel health report')} ${p.name || ''}`);
package/src/rules.js CHANGED
@@ -53,6 +53,23 @@ export const REPLACEMENTS = {
53
53
  'react-native-code-push': ['expo-updates'],
54
54
  // react-native-elements was renamed to @rneui, which in turn is continued by @rn-vui.
55
55
  'react-native-elements': ['@rn-vui/themed'],
56
+ // Checked on npm and React Native Directory on 2026-10-04 (published and not flagged unmaintained).
57
+ 'rn-fetch-blob': ['react-native-blob-util'],
58
+ '@react-native-community/blur': ['expo-blur'],
59
+ 'react-native-randombytes': ['react-native-get-random-values', 'expo-crypto'],
60
+ 'react-native-securerandom': ['react-native-get-random-values', 'expo-crypto'],
61
+ 'react-native-tcp': ['react-native-tcp-socket'],
62
+ '@react-native-community/geolocation': ['expo-location'],
63
+ 'react-native-geolocation-service': ['expo-location'],
64
+ 'react-native-encrypted-storage': ['react-native-keychain', 'expo-secure-store'],
65
+ 'react-native-navigation-bar-color': ['react-native-edge-to-edge', 'expo-navigation-bar'],
66
+ 'react-native-quick-actions': ['expo-quick-actions'],
67
+ 'react-native-version-number': ['react-native-device-info', 'expo-application'],
68
+ 'react-native-mail': ['expo-mail-composer'],
69
+ 'react-native-rate': ['expo-store-review'],
70
+ 'react-native-print': ['expo-print'],
71
+ 'react-native-i18n': ['react-native-localize', 'expo-localization'],
72
+ 'expo-av': ['expo-audio', 'expo-video'],
56
73
  };
57
74
 
58
75
  export const UPGRADE_HELPER_URL = (from, to) =>
package/src/usage.js CHANGED
@@ -26,6 +26,10 @@ const IMPLICIT = [
26
26
  /^react-native-webp-format$/, // iOS WebP decoder registers itself with the image loader
27
27
  /^@react-native-firebase\/(crashlytics|perf)$/, // collect natively without any JS call
28
28
  /^react-compiler-runtime$/, // imported by babel-plugin-react-compiler output
29
+ /^@react-native-vector-icons\//, // per-font packages: fonts linked by the build
30
+ /^(hermes-engine|postinstall-postinstall|react-native-web)$/, // engine, install hook, web target
31
+ // Named after Node.js core modules: dependencies require('stream'), Metro resolves the package.
32
+ /^(assert|buffer|constants|crypto|events|os|path|process|punycode|querystring|stream|string_decoder|timers|tty|url|util|vm|zlib)$/,
29
33
  ];
30
34
 
31
35
  function walk(dir, exts, out = []) {
@@ -102,6 +106,9 @@ export function findUnused(project) {
102
106
  const configText = [
103
107
  JSON.stringify(pkgJson.scripts || {}),
104
108
  JSON.stringify(pkgJson.jest || {}),
109
+ // rn-nodeify style aliases: { "react-native": { "crypto": "react-native-crypto" } }
110
+ JSON.stringify(pkgJson['react-native'] || {}),
111
+ JSON.stringify(pkgJson.browser || {}),
105
112
  readSmall(path.join(root, 'app.json')),
106
113
  readSmall(path.join(root, 'app.config.js')),
107
114
  readSmall(path.join(root, 'app.config.ts')),
@@ -137,7 +144,18 @@ export function findUnused(project) {
137
144
  }
138
145
  }
139
146
 
140
- const candidates = names.filter((n) => !used.has(n) && !requiredByOthers.has(n) && !configText.includes(n) && !usedByBin(n));
147
+ // A package someone patched (patch-package) is one they care about: never call it unused.
148
+ const patched = new Set();
149
+ try {
150
+ for (const f of fs.readdirSync(path.join(root, 'patches'))) {
151
+ const m = f.match(/^(@[^+]+\+[^+]+|[^+@][^+]*)\+/);
152
+ if (m) patched.add(m[1].replace('+', '/'));
153
+ }
154
+ } catch {
155
+ // no patches folder
156
+ }
157
+
158
+ const candidates = names.filter((n) => !used.has(n) && !requiredByOthers.has(n) && !configText.includes(n) && !usedByBin(n) && !patched.has(n));
141
159
  if (!candidates.length) return [];
142
160
 
143
161
  const nativeText = ['ios', 'android'].flatMap((d) => walk(path.join(root, d), NATIVE_EXT)).map(readSmall).join('\n');