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/report-md.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { SEVERITIES, countBySeverity } from './analyze.js';
|
|
2
|
+
import { SITE_URL } from './config.js';
|
|
3
|
+
|
|
4
|
+
// Markdown for pull request comments and issue trackers.
|
|
5
|
+
const ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '⚪', info: 'ℹ️' };
|
|
6
|
+
const cell = (s) => String(s).replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
7
|
+
|
|
8
|
+
export function markdownReport(result, { verbose = false } = {}) {
|
|
9
|
+
const p = result.project;
|
|
10
|
+
const n = countBySeverity(result.findings);
|
|
11
|
+
const lines = [
|
|
12
|
+
`### NativeKeel health report${p.name ? `: ${p.name}` : ''}`,
|
|
13
|
+
'',
|
|
14
|
+
[
|
|
15
|
+
p.reactNative && `React Native **${p.reactNative}**${result.latest.reactNative ? ` (latest ${result.latest.reactNative})` : ''}`,
|
|
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'}`,
|
|
18
|
+
].filter(Boolean).join(' · '),
|
|
19
|
+
'',
|
|
20
|
+
`${ICON.critical} ${n.critical} critical · ${ICON.high} ${n.high} high · ${ICON.medium} ${n.medium} medium · ${ICON.low} ${n.low} low`,
|
|
21
|
+
'',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const main = result.findings.filter((f) => ['critical', 'high', 'medium'].includes(f.severity));
|
|
25
|
+
if (main.length) {
|
|
26
|
+
lines.push('| | Finding | Details |', '|---|---|---|');
|
|
27
|
+
for (const sev of SEVERITIES) {
|
|
28
|
+
for (const f of main.filter((x) => x.severity === sev)) lines.push(`| ${ICON[sev]} | ${cell(f.title)} | ${cell(f.detail)} |`);
|
|
29
|
+
}
|
|
30
|
+
lines.push('');
|
|
31
|
+
}
|
|
32
|
+
const low = result.findings.filter((f) => f.severity === 'low');
|
|
33
|
+
if (low.length) {
|
|
34
|
+
lines.push(`<details><summary>${low.length} low-priority findings</summary>`, '');
|
|
35
|
+
for (const f of verbose ? low : low.slice(0, 50)) lines.push(`- ${f.title}`);
|
|
36
|
+
if (!verbose && low.length > 50) lines.push(`- … and ${low.length - 50} more`);
|
|
37
|
+
lines.push('', '</details>', '');
|
|
38
|
+
}
|
|
39
|
+
if (!result.findings.some((f) => f.severity !== 'info')) lines.push('✅ Nothing to fix.', '');
|
|
40
|
+
lines.push(`<sub>Generated by [NativeKeel](${SITE_URL}) · \`npx nativekeel plan\` for a step-by-step upgrade plan</sub>`);
|
|
41
|
+
return `${lines.join('\n')}\n`;
|
|
42
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { SITE_URL } from './config.js';
|
|
4
|
+
|
|
5
|
+
// SARIF 2.1.0 for GitHub code scanning: findings show up on the exact line in pull requests.
|
|
6
|
+
// https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
|
|
7
|
+
|
|
8
|
+
const LEVEL = { critical: 'error', high: 'error', medium: 'warning', low: 'note', info: 'note' };
|
|
9
|
+
|
|
10
|
+
// Where a finding lives: its own file/line when known, otherwise the package.json line that
|
|
11
|
+
// declares the dependency, otherwise the top of package.json.
|
|
12
|
+
function locate(result, finding, pkgLines) {
|
|
13
|
+
const fix = finding.fix || {};
|
|
14
|
+
if (fix.file) return { uri: fix.file, line: fix.line || 1 };
|
|
15
|
+
const name = fix.name || (finding.id.includes(':') ? finding.id.split(':').slice(1).join(':') : null);
|
|
16
|
+
if (name) {
|
|
17
|
+
const i = pkgLines.findIndex((l) => l.includes(`"${name}"`));
|
|
18
|
+
if (i >= 0) return { uri: 'package.json', line: i + 1 };
|
|
19
|
+
}
|
|
20
|
+
if (finding.id.startsWith('ios-ats:')) return { uri: finding.id.slice('ios-ats:'.length), line: 1 };
|
|
21
|
+
return { uri: 'package.json', line: 1 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sarifReport(result, { version = '0.0.0' } = {}) {
|
|
25
|
+
let pkgLines = [];
|
|
26
|
+
try {
|
|
27
|
+
pkgLines = fs.readFileSync(path.join(result.project.root, 'package.json'), 'utf8').split('\n');
|
|
28
|
+
} catch {
|
|
29
|
+
pkgLines = [];
|
|
30
|
+
}
|
|
31
|
+
const findings = result.findings.filter((f) => f.severity !== 'info');
|
|
32
|
+
const ruleIds = [...new Set(findings.map((f) => f.id.split(':')[0]))].sort();
|
|
33
|
+
return {
|
|
34
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
35
|
+
version: '2.1.0',
|
|
36
|
+
runs: [
|
|
37
|
+
{
|
|
38
|
+
tool: {
|
|
39
|
+
driver: {
|
|
40
|
+
name: 'NativeKeel',
|
|
41
|
+
version,
|
|
42
|
+
informationUri: SITE_URL,
|
|
43
|
+
rules: ruleIds.map((id) => ({ id, name: id, shortDescription: { text: id.replace(/-/g, ' ') } })),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
results: findings.map((f) => {
|
|
47
|
+
const loc = locate(result, f, pkgLines);
|
|
48
|
+
return {
|
|
49
|
+
ruleId: f.id.split(':')[0],
|
|
50
|
+
level: LEVEL[f.severity],
|
|
51
|
+
message: { text: `${f.title}. ${f.detail}` },
|
|
52
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: loc.uri }, region: { startLine: loc.line } } }],
|
|
53
|
+
// Stable across runs so code scanning tracks one alert per finding.
|
|
54
|
+
partialFingerprints: { nativekeelId: f.id },
|
|
55
|
+
properties: { severity: f.severity, area: f.area },
|
|
56
|
+
};
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { SEVERITIES, countBySeverity } from './analyze.js';
|
|
2
|
+
import { SITE_URL } from './config.js';
|
|
3
|
+
|
|
4
|
+
export function textReport(result, { color = true, verbose = false } = {}) {
|
|
5
|
+
const paint = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
6
|
+
const c = { red: paint('31'), yellow: paint('33'), blue: paint('34'), gray: paint('90'), bold: paint('1'), green: paint('32') };
|
|
7
|
+
const label = {
|
|
8
|
+
critical: c.red('✖ CRITICAL'),
|
|
9
|
+
high: c.yellow('▲ HIGH '),
|
|
10
|
+
medium: c.yellow('● MEDIUM '),
|
|
11
|
+
low: c.gray('· LOW '),
|
|
12
|
+
info: c.blue('ℹ INFO '),
|
|
13
|
+
};
|
|
14
|
+
const indent = ' ';
|
|
15
|
+
const out = [];
|
|
16
|
+
const p = result.project;
|
|
17
|
+
const arch = (v) => (v === true ? c.green('on') : v === false ? c.red('off') : c.gray('default'));
|
|
18
|
+
|
|
19
|
+
out.push('');
|
|
20
|
+
out.push(`${c.bold('NativeKeel health report')} ${p.name || ''}`);
|
|
21
|
+
if (p.reactNative) out.push(`React Native ${p.reactNative}${result.latest.reactNative ? c.gray(` (latest ${result.latest.reactNative})`) : ''}`);
|
|
22
|
+
if (p.expo) out.push(`Expo SDK ${p.expo.split('.')[0]}${p.managed ? ' (managed)' : ''}${result.latest.expo ? c.gray(` (latest SDK ${result.latest.expo.split('.')[0]})`) : ''}`);
|
|
23
|
+
out.push(`New Architecture android: ${arch(p.newArch.android)} ios: ${arch(p.newArch.ios)}`);
|
|
24
|
+
if (p.android && p.android.targetSdk) out.push(`Android targetSdk ${p.android.targetSdk}`);
|
|
25
|
+
out.push(`Dependencies: ${result.deps.length} (${result.deps.filter((d) => d.native).length} native)`);
|
|
26
|
+
for (const w of result.warnings) out.push(c.yellow(`! ${w}`));
|
|
27
|
+
if (result.offlineRequested) out.push(c.gray('Offline mode: no network requests were made. Version and package checks need the network.'));
|
|
28
|
+
else if (result.offline) out.push(c.yellow('! Could not reach npm or React Native Directory. Only local checks (secrets, settings) ran.'));
|
|
29
|
+
out.push('');
|
|
30
|
+
|
|
31
|
+
for (const sev of SEVERITIES) {
|
|
32
|
+
const group = result.findings.filter((f) => f.severity === sev);
|
|
33
|
+
if (!group.length) continue;
|
|
34
|
+
if (sev === 'low') {
|
|
35
|
+
// Outdated packages are many and alike: list them compactly. Everything else gets a full entry.
|
|
36
|
+
const bumps = group.filter((f) => f.id.startsWith('dep-major:'));
|
|
37
|
+
for (const f of group.filter((x) => !x.id.startsWith('dep-major:'))) {
|
|
38
|
+
out.push(`${label.low} ${c.bold(f.title)}`);
|
|
39
|
+
out.push(c.gray(indent + f.detail));
|
|
40
|
+
}
|
|
41
|
+
if (bumps.length) {
|
|
42
|
+
const shown = verbose ? bumps : bumps.slice(0, 8);
|
|
43
|
+
out.push(`${label.low} ${bumps.length} package${bumps.length === 1 ? ' is' : 's are'} a major version behind:`);
|
|
44
|
+
out.push(c.gray(shown.map((f) => indent + f.title).join('\n')));
|
|
45
|
+
if (bumps.length > shown.length) out.push(c.gray(`${indent}… and ${bumps.length - shown.length} more (run with --verbose)`));
|
|
46
|
+
}
|
|
47
|
+
out.push('');
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
for (const f of group) {
|
|
51
|
+
out.push(`${label[sev]} ${c.bold(f.title)}`);
|
|
52
|
+
out.push(c.gray(indent + f.detail));
|
|
53
|
+
}
|
|
54
|
+
out.push('');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (result.baseline) {
|
|
58
|
+
out.push(c.gray(`Baseline ${result.baseline.file}: ${result.baseline.suppressed} known findings hidden${result.baseline.fixed.length ? `, ${result.baseline.fixed.length} fixed since` : ''}.`));
|
|
59
|
+
}
|
|
60
|
+
const n = countBySeverity(result.findings);
|
|
61
|
+
out.push(`${c.bold('Summary')} ${c.red(`${n.critical} critical`)} · ${c.yellow(`${n.high} high`)} · ${n.medium} medium · ${n.low} low`);
|
|
62
|
+
|
|
63
|
+
if (n.critical + n.high > 0) {
|
|
64
|
+
out.push('');
|
|
65
|
+
out.push(`Next: ${c.bold('npx nativekeel plan')} writes a step-by-step upgrade plan.`);
|
|
66
|
+
out.push(c.gray(`No time to do it yourself? Fixed-price upgrades: ${SITE_URL}/#services`));
|
|
67
|
+
} else if (!result.findings.length) {
|
|
68
|
+
out.push(c.green('Nothing to fix. This app is in good shape.'));
|
|
69
|
+
}
|
|
70
|
+
out.push('');
|
|
71
|
+
return out.join('\n');
|
|
72
|
+
}
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Curated knowledge that the public registries do not provide.
|
|
2
|
+
// Every entry here is a claim we make to users, so keep it short, sourced and dated.
|
|
3
|
+
|
|
4
|
+
// The React Native team supports the latest minor and the two before it.
|
|
5
|
+
// https://github.com/reactwg/react-native-releases/blob/main/docs/support.md
|
|
6
|
+
export const SUPPORTED_RN_MINORS = 3;
|
|
7
|
+
|
|
8
|
+
// Out of support is a risk, not an outage. It becomes critical once the gap is about a year
|
|
9
|
+
// of releases (roughly six minors), when upgrades turn into projects.
|
|
10
|
+
export const CRITICAL_RN_MINORS_BEHIND = 6;
|
|
11
|
+
|
|
12
|
+
// First React Native minor where the legacy architecture is removed.
|
|
13
|
+
export const NEW_ARCH_ONLY_MINOR = 82;
|
|
14
|
+
|
|
15
|
+
// Expo ships about three SDKs a year and fixes only recent ones.
|
|
16
|
+
export const SUPPORTED_EXPO_MAJORS = 3;
|
|
17
|
+
export const CRITICAL_EXPO_MAJORS_BEHIND = 5;
|
|
18
|
+
|
|
19
|
+
// Google Play target API level policy.
|
|
20
|
+
// https://support.google.com/googleplay/android-developer/answer/11926878
|
|
21
|
+
export const PLAY_TARGET_SDK = {
|
|
22
|
+
checkedAt: '2026-10-03',
|
|
23
|
+
// New apps and app updates
|
|
24
|
+
updates: { minimum: 36, since: '2026-08-31', extensionUntil: '2026-11-01' },
|
|
25
|
+
// Already published apps stay visible to users on newer Android versions only above this
|
|
26
|
+
visibility: { minimum: 35, since: '2026-08-31' },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Google Play 16 KB memory page size requirement for apps targeting Android 15+.
|
|
30
|
+
// https://android-developers.googleblog.com/2025/05/prepare-play-apps-for-devices-with-16kb-page-size.html
|
|
31
|
+
// React Native supports 16 KB pages from 0.77.
|
|
32
|
+
export const PAGE_SIZE_16K = { since: '2025-11-01', extensionUntil: '2026-05-31', firstRnMinor: 77 };
|
|
33
|
+
|
|
34
|
+
// Minimum OS versions of current React Native (since 0.76).
|
|
35
|
+
// https://reactnative.dev/blog/2024/10/23/release-0.76-new-architecture
|
|
36
|
+
export const PLATFORM_MINIMUMS = { androidMinSdk: 24, iosMin: 15.1 };
|
|
37
|
+
|
|
38
|
+
// A package with no npm release for this long will not follow React Native, iOS or Android changes.
|
|
39
|
+
export const STALE_RELEASE_DAYS = 730;
|
|
40
|
+
// A release this recent overrides an "unmaintained" flag that may be out of date.
|
|
41
|
+
export const RECENT_RELEASE_DAYS = 180;
|
|
42
|
+
|
|
43
|
+
// Replacement advice for native modules we have seen block upgrades.
|
|
44
|
+
// Only used when React Native Directory has no alternatives listed.
|
|
45
|
+
export const REPLACEMENTS = {
|
|
46
|
+
'react-native-fast-image': ['expo-image', '@d11/react-native-fast-image'],
|
|
47
|
+
'react-native-fs': ['@dr.pogodin/react-native-fs', 'expo-file-system'],
|
|
48
|
+
'@react-native-community/push-notification-ios': ['@react-native-firebase/messaging', 'expo-notifications'],
|
|
49
|
+
'react-native-keyboard-aware-scroll-view': ['react-native-keyboard-controller'],
|
|
50
|
+
'react-native-splash-screen': ['react-native-bootsplash', 'expo-splash-screen'],
|
|
51
|
+
'react-native-camera': ['react-native-vision-camera', 'expo-camera'],
|
|
52
|
+
'react-native-sqlite-storage': ['op-sqlite', 'expo-sqlite'],
|
|
53
|
+
'react-native-code-push': ['expo-updates'],
|
|
54
|
+
// react-native-elements was renamed to @rneui, which in turn is continued by @rn-vui.
|
|
55
|
+
'react-native-elements': ['@rn-vui/themed'],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const UPGRADE_HELPER_URL = (from, to) =>
|
|
59
|
+
`https://react-native-community.github.io/upgrade-helper/?from=${from}&to=${to}`;
|
package/src/secrets.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'Pods', 'build', '.gradle', 'DerivedData', '.expo', 'dist', 'coverage']);
|
|
6
|
+
const EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.json', '.plist', '.xml', '.gradle', '.properties', '.env', '.m', '.mm', '.swift', '.kt', '.java']);
|
|
7
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
// Folders that hold server-side code: never bundled into the app, but still a leak if committed.
|
|
10
|
+
const SERVER_DIRS = new Set(['functions', 'server', 'backend', 'api', 'cloud-functions', 'scripts']);
|
|
11
|
+
|
|
12
|
+
// Only patterns that are almost never false positives. The `v` group is the secret value itself.
|
|
13
|
+
const RULES = [
|
|
14
|
+
{ id: 'aws-access-key', label: 'AWS access key ID', re: /\b(?<v>(?:AKIA|ASIA)[0-9A-Z]{16})\b/g },
|
|
15
|
+
{ id: 'aws-secret-key', label: 'AWS secret access key', re: /secretAccessKey["']?\s*[:=]\s*["'](?<v>[A-Za-z0-9/+]{40})["']/g },
|
|
16
|
+
{ id: 'private-key', label: 'Private key', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/g },
|
|
17
|
+
{ id: 'stripe-secret', label: 'Stripe secret key', re: /\bsk_live_[0-9a-zA-Z]{20,}\b/g },
|
|
18
|
+
{ id: 'openai-key', label: 'OpenAI API key', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b/g },
|
|
19
|
+
{ id: 'anthropic-key', label: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{32,}\b/g },
|
|
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 },
|
|
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
|
+
{ id: 'twilio-key', label: 'Twilio API key', re: /\b(?<v>SK[0-9a-fA-F]{32})\b/g },
|
|
24
|
+
{ id: 'google-oauth-secret', label: 'Google OAuth client secret', re: /\b(?<v>GOCSPX-[A-Za-z0-9_-]{28})\b/g },
|
|
25
|
+
{ id: 'mailchimp-key', label: 'Mailchimp API key', re: /\b(?<v>[0-9a-f]{32}-us\d{1,2})\b/g },
|
|
26
|
+
{ id: 'shopify-token', label: 'Shopify access token', re: /\b(?<v>shp(?:at|ca|pa|ss)_[a-fA-F0-9]{32})\b/g },
|
|
27
|
+
// Supabase anon keys are public by design; the service_role key bypasses row-level security.
|
|
28
|
+
// Both are JWTs, so decode the payload and only report the service role.
|
|
29
|
+
{
|
|
30
|
+
id: 'supabase-service-role',
|
|
31
|
+
label: 'Supabase service_role key',
|
|
32
|
+
re: /\b(?<v>eyJ[A-Za-z0-9_-]{10,}\.(?<payload>eyJ[A-Za-z0-9_-]{10,})\.[A-Za-z0-9_-]{20,})\b/g,
|
|
33
|
+
validate: (m) => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(Buffer.from(m.groups.payload, 'base64url').toString('utf8')).role === 'service_role';
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export function mask(value) {
|
|
44
|
+
if (value.length <= 8) return '****';
|
|
45
|
+
return `${value.slice(0, 4)}…${value.slice(-2)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function* walk(dir) {
|
|
49
|
+
let entries;
|
|
50
|
+
try {
|
|
51
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
52
|
+
} catch {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
for (const e of entries) {
|
|
56
|
+
if (e.isDirectory()) {
|
|
57
|
+
if (!SKIP_DIRS.has(e.name)) yield* walk(path.join(dir, e.name));
|
|
58
|
+
} else if (EXTENSIONS.has(path.extname(e.name)) || e.name.startsWith('.env')) {
|
|
59
|
+
yield path.join(dir, e.name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function gitTrackedFiles(root) {
|
|
65
|
+
const res = spawnSync('git', ['ls-files'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
66
|
+
return res.status === 0 ? new Set(res.stdout.split('\n')) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function scanSecrets(root) {
|
|
70
|
+
const findings = [];
|
|
71
|
+
const tracked = gitTrackedFiles(root);
|
|
72
|
+
for (const file of walk(root)) {
|
|
73
|
+
let text;
|
|
74
|
+
try {
|
|
75
|
+
if (fs.statSync(file).size > MAX_FILE_BYTES) continue;
|
|
76
|
+
text = fs.readFileSync(file, 'utf8');
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
for (const rule of RULES) {
|
|
81
|
+
for (const m of text.matchAll(rule.re)) {
|
|
82
|
+
if (rule.validate && !rule.validate(m)) continue;
|
|
83
|
+
const rel = path.relative(root, file);
|
|
84
|
+
const value = m.groups && m.groups.v;
|
|
85
|
+
findings.push({
|
|
86
|
+
rule: rule.id,
|
|
87
|
+
label: rule.label,
|
|
88
|
+
file: rel,
|
|
89
|
+
line: text.slice(0, m.index).split('\n').length,
|
|
90
|
+
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,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return findings;
|
|
98
|
+
}
|
package/src/usage.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { findPackageDir } from './project.js';
|
|
4
|
+
|
|
5
|
+
// Finds runtime dependencies that nothing uses. A package only counts as unused when it is
|
|
6
|
+
// (1) never imported from JS/TS, (2) not referenced from native code, (3) not required by
|
|
7
|
+
// another dependency (e.g. @rneui/themed needs @rneui/base even if the app never imports it),
|
|
8
|
+
// and (4) not mentioned in config files or package.json scripts. Removing unused native
|
|
9
|
+
// modules is the cheapest way to shrink an upgrade.
|
|
10
|
+
|
|
11
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'Pods', 'build', '.gradle', 'DerivedData', '.expo', 'dist', 'coverage', 'vendor']);
|
|
12
|
+
const JS_EXT = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
|
13
|
+
const NATIVE_EXT = new Set(['.m', '.mm', '.h', '.swift', '.java', '.kt', '.gradle', '.kts', '.xml', '.plist']);
|
|
14
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
// Packages that are used without being imported.
|
|
17
|
+
const IMPLICIT = [
|
|
18
|
+
/^(react|react-native|expo|react-dom)$/,
|
|
19
|
+
/^@babel\//,
|
|
20
|
+
/^@types\//,
|
|
21
|
+
/babel-plugin|babel-preset|eslint|prettier|typescript|metro/,
|
|
22
|
+
/^@react-native\//, // versioned with React Native, used by the build
|
|
23
|
+
/^@react-native-community\/cli/,
|
|
24
|
+
/^react-native-vector-icons$/, // fonts linked by the build
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function walk(dir, exts, out = []) {
|
|
28
|
+
let list;
|
|
29
|
+
try {
|
|
30
|
+
list = fs.readdirSync(dir, { withFileTypes: true });
|
|
31
|
+
} catch {
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
for (const e of list) {
|
|
35
|
+
if (e.isDirectory()) {
|
|
36
|
+
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(path.join(dir, e.name), exts, out);
|
|
37
|
+
} else if (exts.has(path.extname(e.name))) {
|
|
38
|
+
out.push(path.join(dir, e.name));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const readSmall = (file) => {
|
|
45
|
+
try {
|
|
46
|
+
return fs.statSync(file).size > MAX_FILE_BYTES ? '' : fs.readFileSync(file, 'utf8');
|
|
47
|
+
} catch {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// 'lodash/fp' -> 'lodash', '@scope/pkg/sub' -> '@scope/pkg'
|
|
53
|
+
export function packageOf(spec) {
|
|
54
|
+
if (!spec || spec.startsWith('.') || spec.startsWith('/')) return null;
|
|
55
|
+
const parts = spec.split('/');
|
|
56
|
+
return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function importedPackages(text) {
|
|
60
|
+
const found = new Set();
|
|
61
|
+
const re = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*|\bjest\.(?:mock|requireActual)\s*\(\s*)['"]([^'"\n]+)['"]/g;
|
|
62
|
+
for (const m of text.matchAll(re)) {
|
|
63
|
+
const pkg = packageOf(m[1]);
|
|
64
|
+
if (pkg) found.add(pkg);
|
|
65
|
+
}
|
|
66
|
+
return found;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Names a package is known by in native code: its podspec module and its Android namespace.
|
|
70
|
+
function nativeTokens(root, name) {
|
|
71
|
+
const dir = findPackageDir(root, name);
|
|
72
|
+
if (!dir) return [];
|
|
73
|
+
const tokens = [];
|
|
74
|
+
try {
|
|
75
|
+
for (const f of fs.readdirSync(dir)) if (f.endsWith('.podspec')) tokens.push(f.replace(/\.podspec$/, ''));
|
|
76
|
+
} catch {
|
|
77
|
+
// no podspec
|
|
78
|
+
}
|
|
79
|
+
const manifest = readSmall(path.join(dir, 'android', 'src', 'main', 'AndroidManifest.xml'));
|
|
80
|
+
const gradle = readSmall(path.join(dir, 'android', 'build.gradle')) + readSmall(path.join(dir, 'android', 'build.gradle.kts'));
|
|
81
|
+
const pkg = manifest.match(/package="([\w.]+)"/) || gradle.match(/namespace\s*=?\s*["']([\w.]+)["']/);
|
|
82
|
+
if (pkg) tokens.push(pkg[1]);
|
|
83
|
+
return tokens.filter((t) => t.length >= 5);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function findUnused(project) {
|
|
87
|
+
if (!project.hasNodeModules) return [];
|
|
88
|
+
const root = project.root;
|
|
89
|
+
const names = Object.keys(project.deps).filter((n) => !IMPLICIT.some((re) => re.test(n)));
|
|
90
|
+
if (!names.length) return [];
|
|
91
|
+
|
|
92
|
+
const used = new Set();
|
|
93
|
+
for (const file of walk(root, JS_EXT)) for (const p of importedPackages(readSmall(file))) used.add(p);
|
|
94
|
+
|
|
95
|
+
// Config and scripts: babel/metro/app config plugins, CLI tools in scripts.
|
|
96
|
+
const pkgText = readSmall(path.join(root, 'package.json'));
|
|
97
|
+
const pkgJson = JSON.parse(pkgText || '{}');
|
|
98
|
+
const configText = [
|
|
99
|
+
JSON.stringify(pkgJson.scripts || {}),
|
|
100
|
+
JSON.stringify(pkgJson.jest || {}),
|
|
101
|
+
readSmall(path.join(root, 'app.json')),
|
|
102
|
+
readSmall(path.join(root, 'app.config.js')),
|
|
103
|
+
readSmall(path.join(root, 'app.config.ts')),
|
|
104
|
+
readSmall(path.join(root, 'react-native.config.js')),
|
|
105
|
+
].join('\n');
|
|
106
|
+
|
|
107
|
+
// Required by another dependency (peer or regular).
|
|
108
|
+
const requiredByOthers = new Set();
|
|
109
|
+
for (const other of Object.keys(project.deps)) {
|
|
110
|
+
const dir = findPackageDir(root, other);
|
|
111
|
+
if (!dir) continue;
|
|
112
|
+
try {
|
|
113
|
+
const m = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
114
|
+
for (const k of ['peerDependencies', 'dependencies']) for (const d of Object.keys(m[k] || {})) requiredByOthers.add(d);
|
|
115
|
+
} catch {
|
|
116
|
+
// unreadable manifest
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const candidates = names.filter((n) => !used.has(n) && !requiredByOthers.has(n) && !configText.includes(n));
|
|
121
|
+
if (!candidates.length) return [];
|
|
122
|
+
|
|
123
|
+
const nativeText = ['ios', 'android'].flatMap((d) => walk(path.join(root, d), NATIVE_EXT)).map(readSmall).join('\n');
|
|
124
|
+
return candidates
|
|
125
|
+
.filter((n) => !nativeText.includes(n) && !nativeTokens(root, n).some((t) => nativeText.includes(t)))
|
|
126
|
+
.map((name) => ({ name, native: nativeTokens(root, name).length > 0 }));
|
|
127
|
+
}
|