@everystack/cli 0.3.3 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -29,6 +29,10 @@
29
29
  "types": "./src/cli/model-api.ts",
30
30
  "default": "./src/cli/model-api.ts"
31
31
  },
32
+ "./audit": {
33
+ "types": "./src/cli/audit-api.ts",
34
+ "default": "./src/cli/audit-api.ts"
35
+ },
32
36
  "./client": {
33
37
  "types": "./src/client/index.ts",
34
38
  "default": "./src/client/index.ts"
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Public audit surface — `@everystack/cli/audit`.
3
+ *
4
+ * The governance MCP imports these pure scanners directly to gate authoring and
5
+ * pre-deploy: the secret-leak checks (bundle-audit) and the weight/size checks
6
+ * (bundle-weight), plus the bundle composition helpers they build on. Pure,
7
+ * filesystem-free, no CLI/IO coupling — feed them text or {path, size} lists.
8
+ */
9
+
10
+ export {
11
+ // types
12
+ type AuditFinding,
13
+ type SecretPattern,
14
+ // leak scanners
15
+ SECRET_PATTERNS,
16
+ redact,
17
+ isPublicEnvKey,
18
+ scanSecretShapes,
19
+ extractBundleUrls,
20
+ extractExtraEnvKeys,
21
+ classifyPrivateEnvKeys,
22
+ scanSecretValue,
23
+ summarize,
24
+ } from './bundle-audit.js';
25
+
26
+ export {
27
+ // types
28
+ type AssetFile,
29
+ type AssetKind,
30
+ type WeightBudget,
31
+ // weight scanners
32
+ DEFAULT_WEIGHT_BUDGET,
33
+ classifyAsset,
34
+ formatBytes,
35
+ scanAssetWeights,
36
+ scanTotalWeight,
37
+ scanJsWeight,
38
+ scanSourceMapsPresent,
39
+ scanDependencyWeight,
40
+ resolveWeightBudget,
41
+ } from './bundle-weight.js';
42
+
43
+ export {
44
+ // composition (input to scanDependencyWeight)
45
+ type Composition,
46
+ type ChunkInput,
47
+ type ChunkSize,
48
+ type SourceMap,
49
+ measureChunk,
50
+ measureChunks,
51
+ totalSizes,
52
+ bucketSource,
53
+ composeSourceMap,
54
+ } from './bundle.js';
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Bundle secret-leak audit — pure scanners for `everystack bundle:audit`.
3
+ *
4
+ * Scans the *served* client bundle for leaked secrets. Config-free by leaning on
5
+ * universal truths instead of a hand-maintained deny-list:
6
+ *
7
+ * 1. Generic secret SHAPES (Postgres/Mongo/Redis URLs, sk_live_, AKIA…, PEM
8
+ * blocks) — these match a secret's *value* regardless of its key name.
9
+ * 2. The Expo convention: Metro only inlines `EXPO_PUBLIC_*` env vars into the
10
+ * client bundle. Any *other* env key serialized into the bundle is, by
11
+ * definition, a private value that leaked.
12
+ * 3. (Operator-run) value canary: grep the bundle for the literal values of the
13
+ * deployed secrets — the only way to catch a Metro-inlined value whose key
14
+ * name never appears. Values are redacted in output; never printed.
15
+ *
16
+ * Nothing here reads secret values; the canary matcher receives them from the
17
+ * command's operator-run path.
18
+ */
19
+
20
+ export interface AuditFinding {
21
+ severity: 'fail' | 'warn';
22
+ check:
23
+ // leak checks (this file)
24
+ | 'secret-shape'
25
+ | 'source-map'
26
+ | 'private-env-key'
27
+ | 'secret-value'
28
+ // weight/size checks (bundle-weight.ts)
29
+ | 'asset-weight'
30
+ | 'total-weight'
31
+ | 'data-in-bundle'
32
+ | 'js-weight'
33
+ | 'dep-weight';
34
+ source: string;
35
+ detail: string;
36
+ }
37
+
38
+ export interface SecretPattern {
39
+ name: string;
40
+ re: RegExp;
41
+ }
42
+
43
+ /** Generic, app-agnostic secret shapes. A value matching any of these in a
44
+ * client bundle is a leak no matter what env key carried it. */
45
+ export const SECRET_PATTERNS: SecretPattern[] = [
46
+ { name: 'postgres-url-with-credentials', re: /postgres(?:ql)?:\/\/[^:\s'"`]+:[^@\s'"`]+@/ },
47
+ { name: 'mongodb-url-with-credentials', re: /mongodb(?:\+srv)?:\/\/[^:\s'"`]+:[^@\s'"`]+@/ },
48
+ { name: 'redis-url-with-credentials', re: /redis:\/\/[^:\s'"`]*:[^@\s'"`]+@/ },
49
+ { name: 'stripe-secret-key', re: /sk_(?:live|test)_[A-Za-z0-9]{10,}/ },
50
+ { name: 'mapbox-secret-token', re: /sk\.[A-Za-z0-9_-]{60,}/ },
51
+ { name: 'sendgrid-key', re: /SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/ },
52
+ { name: 'google-api-key', re: /AIza[0-9A-Za-z_-]{35}/ },
53
+ { name: 'github-token', re: /gh[pousr]_[A-Za-z0-9]{36,}/ },
54
+ { name: 'slack-token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/ },
55
+ { name: 'aws-access-key-id', re: /AKIA[0-9A-Z]{16}/ },
56
+ { name: 'pem-private-key', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
57
+ { name: 'generic-credentials-in-url', re: /\b[a-z][a-z0-9+.-]*:\/\/[A-Za-z0-9._%-]+:[^@\s'"`/]{6,}@/ },
58
+ ];
59
+
60
+ /** Mask a matched secret so the output never prints it in full. */
61
+ export function redact(s: string): string {
62
+ if (s.length <= 6) return '******';
63
+ return `${s.slice(0, 4)}…${s.slice(-2)} (${s.length} chars)`;
64
+ }
65
+
66
+ /** True if an env key is client-public by Expo convention (Metro-inlined). */
67
+ export function isPublicEnvKey(key: string): boolean {
68
+ return key.startsWith('EXPO_PUBLIC_');
69
+ }
70
+
71
+ /** Scan one artifact's text for generic secret shapes. Matches are redacted. */
72
+ export function scanSecretShapes(text: string, source: string): AuditFinding[] {
73
+ const findings: AuditFinding[] = [];
74
+ const seen = new Set<string>(); // dedupe overlapping patterns on the same span
75
+ for (const { name, re } of SECRET_PATTERNS) {
76
+ const m = re.exec(text);
77
+ if (m && !seen.has(m[0])) {
78
+ seen.add(m[0]);
79
+ findings.push({
80
+ severity: 'fail',
81
+ check: 'secret-shape',
82
+ source,
83
+ detail: `${name}: ${redact(m[0])}`,
84
+ });
85
+ }
86
+ }
87
+ return findings;
88
+ }
89
+
90
+ /** Extract client JS bundle URLs from served HTML (Expo web export convention). */
91
+ export function extractBundleUrls(html: string): string[] {
92
+ const urls = new Set<string>();
93
+ const re = /["'(]([^"'()]*_expo\/static\/js\/web\/[^"'()?]+\.js)/g;
94
+ let m: RegExpExecArray | null;
95
+ while ((m = re.exec(html)) !== null) {
96
+ urls.add(m[1].replace(/^\.?\//, ''));
97
+ }
98
+ return [...urls];
99
+ }
100
+
101
+ /**
102
+ * Extract served *static asset* references (images, data files, fonts, css)
103
+ * from a bundle/HTML — anything under `assets/` or `_expo/static/` that is not
104
+ * JS or a source map (those are crawled / gated separately). Used by the remote
105
+ * weight scan to discover what to HEAD for its size without downloading it.
106
+ */
107
+ export function extractAssetUrls(text: string): string[] {
108
+ const urls = new Set<string>();
109
+ const re = /["'(]((?:\.?\/)?(?:assets|_expo\/static)\/[^"'()\s]+?\.[A-Za-z0-9]+)(?:\?[^"'()\s]*)?["')]/g;
110
+ let m: RegExpExecArray | null;
111
+ while ((m = re.exec(text)) !== null) {
112
+ const rel = m[1].replace(/^\.?\//, '');
113
+ if (/\.(?:js|mjs|cjs|map)$/i.test(rel)) continue;
114
+ urls.add(rel);
115
+ }
116
+ return [...urls];
117
+ }
118
+
119
+ const EXTRA_KEY_SKIP = new Set([
120
+ 'JSON', 'URL', 'GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS', 'PATCH',
121
+ 'API', 'HTTP', 'HTTPS', 'UTF', 'UUID', 'ID', 'OK', 'EAS',
122
+ ]);
123
+
124
+ /**
125
+ * Best-effort extraction of ALL-CAPS env-looking keys serialized near an
126
+ * `"extra"` region of the bundle (where Expo embeds app.config `extra`). No EAS
127
+ * projectId anchor needed — we scan a window around each `"extra"` occurrence.
128
+ */
129
+ export function extractExtraEnvKeys(text: string, window = 600): string[] {
130
+ const keys = new Set<string>();
131
+ const keyRe = /["']([A-Z][A-Z0-9_]{2,})["']\s*:/g;
132
+ let idx = text.indexOf('"extra"');
133
+ while (idx !== -1) {
134
+ const region = text.slice(idx, idx + window);
135
+ let m: RegExpExecArray | null;
136
+ keyRe.lastIndex = 0;
137
+ while ((m = keyRe.exec(region)) !== null) {
138
+ if (!EXTRA_KEY_SKIP.has(m[1])) keys.add(m[1]);
139
+ }
140
+ idx = text.indexOf('"extra"', idx + 1);
141
+ }
142
+ return [...keys];
143
+ }
144
+
145
+ /** Flag any non-EXPO_PUBLIC env key found embedded in the bundle's extra. */
146
+ export function classifyPrivateEnvKeys(keys: string[], source: string): AuditFinding[] {
147
+ return keys
148
+ .filter((k) => !isPublicEnvKey(k))
149
+ .map((k) => ({
150
+ severity: 'fail' as const,
151
+ check: 'private-env-key' as const,
152
+ source,
153
+ detail: `${k} is embedded in the bundle but is not EXPO_PUBLIC_* — a private value leaked`,
154
+ }));
155
+ }
156
+
157
+ /**
158
+ * Operator-run value canary: does the bundle contain the literal secret value?
159
+ * Returns a finding with the value REDACTED (replaced by a [KEY] marker) — the
160
+ * raw value is never placed in the output.
161
+ */
162
+ export function scanSecretValue(
163
+ text: string,
164
+ key: string,
165
+ value: string,
166
+ source: string,
167
+ ): AuditFinding | null {
168
+ if (isPublicEnvKey(key)) return null; // public by design
169
+ if (value.length < 8) return null; // too short to be a meaningful secret
170
+ const at = text.indexOf(value);
171
+ if (at === -1) return null;
172
+ return {
173
+ severity: 'fail',
174
+ check: 'secret-value',
175
+ source,
176
+ detail: `value of ${key} appears verbatim in the bundle (Metro-inlined leak) [${redact(value)}]`,
177
+ };
178
+ }
179
+
180
+ export function summarize(findings: AuditFinding[]): { fail: number; warn: number } {
181
+ return {
182
+ fail: findings.filter((f) => f.severity === 'fail').length,
183
+ warn: findings.filter((f) => f.severity === 'warn').length,
184
+ };
185
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Bundle weight/size audit — pure scanners for `everystack bundle:audit`.
3
+ *
4
+ * The leak scanner (bundle-audit.ts) answers "did a secret get into the bundle?"
5
+ * This answers "is the bundle bloated, and is data shipping that belongs in the
6
+ * database?" — the class of mistake test-case-zero is made of: an agent that
7
+ * defaults to training-consensus and bundles a 180MB JSON into an Expo app.
8
+ *
9
+ * Config-free by construction. The only inputs are universal truths:
10
+ * - a file's byte size (the budget gate),
11
+ * - its extension (data vs image vs js vs source-map vs other),
12
+ * - and, when a source map is present, the per-package source-byte breakdown
13
+ * already computed by bundle.ts (the dependency-weight gate).
14
+ *
15
+ * Everything here is pure: it takes a list of {path, size} and returns findings.
16
+ * The command layer walks the export directory (and the app source tree) and
17
+ * feeds those lists in, so the governance MCP can import these scanners directly
18
+ * without touching the filesystem.
19
+ */
20
+
21
+ import type { AuditFinding } from './bundle-audit.js';
22
+ import type { Composition } from './bundle.js';
23
+
24
+ /** One file's relative path and byte size — the only input the gates need. */
25
+ export interface AssetFile {
26
+ path: string;
27
+ size: number;
28
+ }
29
+
30
+ export type AssetKind = 'data' | 'image' | 'js' | 'sourcemap' | 'other';
31
+
32
+ /** Per-file and total byte budgets. All sizes are bytes. */
33
+ export interface WeightBudget {
34
+ /** Generic single-file warn/fail (non-data, non-image, non-js). */
35
+ perFileWarn: number;
36
+ perFileFail: number;
37
+ /** An image past this is almost certainly unoptimized. */
38
+ imageWarn: number;
39
+ /** Sum of every file in the export. */
40
+ totalWarn: number;
41
+ totalFail: number;
42
+ /** Sum of served client JS chunks. */
43
+ jsWarn: number;
44
+ jsFail: number;
45
+ /** A data file (.json/.csv/…) shipped in the bundle. Categorically suspect:
46
+ * data belongs in the DB/Model/API, so the thresholds are tighter. */
47
+ dataWarn: number;
48
+ dataFail: number;
49
+ /** A single dependency's source-byte contribution (from the source map). */
50
+ depWarn: number;
51
+ /** …or its share of the largest chunk, whichever trips first. */
52
+ depShareWarn: number;
53
+ }
54
+
55
+ const MB = 1024 * 1024;
56
+
57
+ /**
58
+ * Defaults chosen against real Expo/mobile norms, not arbitrary round numbers:
59
+ * - Optimized images sit well under 1MB; a 2MB+ non-image asset is unusual and
60
+ * a 10MB single file is almost always a mistake.
61
+ * - Data files belong in the DB, so they fail tighter (5MB) with a redirect
62
+ * message — a 180MB JSON trips this loudly.
63
+ * - A typical Expo web client bundle is a few MB raw; 15MB+ is bloat.
64
+ * - A single dependency over ~512KB of source, or dominating >25% of the
65
+ * largest chunk, is worth a second look.
66
+ * All overridable via flags (resolveWeightBudget).
67
+ */
68
+ export const DEFAULT_WEIGHT_BUDGET: WeightBudget = {
69
+ perFileWarn: 2 * MB,
70
+ perFileFail: 10 * MB,
71
+ imageWarn: 1 * MB,
72
+ totalWarn: 25 * MB,
73
+ totalFail: 100 * MB,
74
+ jsWarn: 5 * MB,
75
+ jsFail: 15 * MB,
76
+ dataWarn: 1 * MB,
77
+ dataFail: 5 * MB,
78
+ depWarn: 512 * 1024,
79
+ depShareWarn: 0.25,
80
+ };
81
+
82
+ const DATA_EXTS = new Set([
83
+ '.json', '.csv', '.tsv', '.geojson', '.ndjson', '.jsonl',
84
+ '.xml', '.yaml', '.yml', '.parquet', '.sqlite', '.db',
85
+ ]);
86
+ const IMAGE_EXTS = new Set([
87
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.avif',
88
+ ]);
89
+
90
+ function ext(p: string): string {
91
+ const dot = p.lastIndexOf('.');
92
+ return dot < 0 ? '' : p.slice(dot).toLowerCase();
93
+ }
94
+
95
+ /** Classify a file by extension — the basis of every weight gate. */
96
+ export function classifyAsset(p: string): AssetKind {
97
+ const e = ext(p);
98
+ if (e === '.map') return 'sourcemap';
99
+ if (e === '.js' || e === '.mjs' || e === '.cjs') return 'js';
100
+ if (DATA_EXTS.has(e)) return 'data';
101
+ if (IMAGE_EXTS.has(e)) return 'image';
102
+ return 'other';
103
+ }
104
+
105
+ /** Human-readable byte size: "180.00 MB", "512.00 KB", "40 B". */
106
+ export function formatBytes(n: number): string {
107
+ if (n < 1024) return `${n} B`;
108
+ if (n < MB) return `${(n / 1024).toFixed(2)} KB`;
109
+ if (n < 1024 * MB) return `${(n / MB).toFixed(2)} MB`;
110
+ return `${(n / (1024 * MB)).toFixed(2)} GB`;
111
+ }
112
+
113
+ /**
114
+ * Per-file weight gate over data / image / other assets. JS and source maps are
115
+ * skipped here (scanJsWeight / scanSourceMapsPresent own those). A data file
116
+ * over budget is reported as `data-in-bundle` with the "belongs in the DB"
117
+ * redirect; an oversized image is called out as unoptimized.
118
+ */
119
+ export function scanAssetWeights(
120
+ files: AssetFile[],
121
+ budget: WeightBudget = DEFAULT_WEIGHT_BUDGET,
122
+ ): AuditFinding[] {
123
+ const findings: AuditFinding[] = [];
124
+ for (const f of files) {
125
+ const kind = classifyAsset(f.path);
126
+ if (kind === 'js' || kind === 'sourcemap') continue;
127
+
128
+ if (kind === 'data') {
129
+ if (f.size >= budget.dataFail) {
130
+ findings.push({
131
+ severity: 'fail',
132
+ check: 'data-in-bundle',
133
+ source: f.path,
134
+ detail: `${f.path} is ${formatBytes(f.size)} of data shipped in the bundle — this belongs in the database (a Model/migration) and should be served over the API, not bundled`,
135
+ });
136
+ } else if (f.size >= budget.dataWarn) {
137
+ findings.push({
138
+ severity: 'warn',
139
+ check: 'data-in-bundle',
140
+ source: f.path,
141
+ detail: `${f.path} is ${formatBytes(f.size)} of data in the bundle — consider moving it to the database/API`,
142
+ });
143
+ }
144
+ continue;
145
+ }
146
+
147
+ const warnAt = kind === 'image' ? budget.imageWarn : budget.perFileWarn;
148
+ if (f.size >= budget.perFileFail) {
149
+ findings.push({
150
+ severity: 'fail',
151
+ check: 'asset-weight',
152
+ source: f.path,
153
+ detail:
154
+ kind === 'image'
155
+ ? `${f.path} is ${formatBytes(f.size)} — an unoptimized image this large should be compressed or served from a CDN`
156
+ : `${f.path} is ${formatBytes(f.size)}, over the ${formatBytes(budget.perFileFail)} per-file budget`,
157
+ });
158
+ } else if (f.size >= warnAt) {
159
+ findings.push({
160
+ severity: 'warn',
161
+ check: 'asset-weight',
162
+ source: f.path,
163
+ detail:
164
+ kind === 'image'
165
+ ? `${f.path} is ${formatBytes(f.size)} — large for an image, consider optimizing (WebP/AVIF) or a CDN`
166
+ : `${f.path} is ${formatBytes(f.size)}, over the ${formatBytes(budget.perFileWarn)} per-file warning budget`,
167
+ });
168
+ }
169
+ }
170
+ return findings;
171
+ }
172
+
173
+ /** Total-export-size gate over every file. */
174
+ export function scanTotalWeight(
175
+ files: AssetFile[],
176
+ budget: WeightBudget = DEFAULT_WEIGHT_BUDGET,
177
+ ): AuditFinding[] {
178
+ const total = files.reduce((sum, f) => sum + f.size, 0);
179
+ if (total >= budget.totalFail) {
180
+ return [
181
+ {
182
+ severity: 'fail',
183
+ check: 'total-weight',
184
+ source: 'export',
185
+ detail: `total export is ${formatBytes(total)} across ${files.length} file(s), over the ${formatBytes(budget.totalFail)} budget`,
186
+ },
187
+ ];
188
+ }
189
+ if (total >= budget.totalWarn) {
190
+ return [
191
+ {
192
+ severity: 'warn',
193
+ check: 'total-weight',
194
+ source: 'export',
195
+ detail: `total export is ${formatBytes(total)} across ${files.length} file(s), over the ${formatBytes(budget.totalWarn)} warning budget`,
196
+ },
197
+ ];
198
+ }
199
+ return [];
200
+ }
201
+
202
+ /** Served-client-JS weight gate (sum of all .js chunks). */
203
+ export function scanJsWeight(
204
+ files: AssetFile[],
205
+ budget: WeightBudget = DEFAULT_WEIGHT_BUDGET,
206
+ ): AuditFinding[] {
207
+ const js = files.filter((f) => classifyAsset(f.path) === 'js');
208
+ if (js.length === 0) return [];
209
+ const total = js.reduce((sum, f) => sum + f.size, 0);
210
+ if (total >= budget.jsFail) {
211
+ return [
212
+ {
213
+ severity: 'fail',
214
+ check: 'js-weight',
215
+ source: 'client-js',
216
+ detail: `client JS is ${formatBytes(total)} raw across ${js.length} chunk(s), over the ${formatBytes(budget.jsFail)} budget — see the data-in-bundle findings for inlined datasets, or \`everystack bundle:analyze\` for the per-package breakdown`,
217
+ },
218
+ ];
219
+ }
220
+ if (total >= budget.jsWarn) {
221
+ return [
222
+ {
223
+ severity: 'warn',
224
+ check: 'js-weight',
225
+ source: 'client-js',
226
+ detail: `client JS is ${formatBytes(total)} raw across ${js.length} chunk(s), over the ${formatBytes(budget.jsWarn)} warning budget`,
227
+ },
228
+ ];
229
+ }
230
+ return [];
231
+ }
232
+
233
+ /**
234
+ * Flag source maps present in the export — they will be served to prod and
235
+ * expose original source. Distinct from the network leak check, which catches a
236
+ * map already served at a URL; this catches it before deploy.
237
+ */
238
+ export function scanSourceMapsPresent(files: AssetFile[]): AuditFinding[] {
239
+ return files
240
+ .filter((f) => classifyAsset(f.path) === 'sourcemap')
241
+ .map((f) => ({
242
+ severity: 'warn' as const,
243
+ check: 'source-map' as const,
244
+ source: f.path,
245
+ detail: `source map (${formatBytes(f.size)}) is in the export and will be served to prod — exclude it from the deployed artifacts`,
246
+ }));
247
+ }
248
+
249
+ /**
250
+ * Best-effort dependency-weight gate from the largest chunk's source-map
251
+ * composition (bundle.ts → composeSourceMap). A single dependency over the byte
252
+ * budget, or dominating the chunk by share, is flagged. App workspace buckets
253
+ * (APP:*) are skipped — they are the app's own code, not a dependency.
254
+ */
255
+ export function scanDependencyWeight(
256
+ composition: Composition,
257
+ budget: WeightBudget = DEFAULT_WEIGHT_BUDGET,
258
+ ): AuditFinding[] {
259
+ const findings: AuditFinding[] = [];
260
+ for (const p of composition.top) {
261
+ if (p.pkg.startsWith('APP:')) continue;
262
+ const overBytes = p.bytes >= budget.depWarn;
263
+ const overShare = p.share >= budget.depShareWarn;
264
+ if (overBytes || overShare) {
265
+ findings.push({
266
+ severity: 'warn',
267
+ check: 'dep-weight',
268
+ source: p.pkg,
269
+ detail: `${p.pkg} contributes ${formatBytes(p.bytes)} (${(p.share * 100).toFixed(1)}%) to the largest chunk — consider a lighter alternative or lazy-loading`,
270
+ });
271
+ }
272
+ }
273
+ return findings;
274
+ }
275
+
276
+ /**
277
+ * Pinpoint *data inlined into a JS chunk* — the most useful diagnosis, because
278
+ * a dataset imported into app code (`import data from './big.json'`) is bundled
279
+ * as a Metro module and shows up only as "JS is big". This splits the chunk on
280
+ * Metro module boundaries (`__d(`), finds the heavy ones whose body is an
281
+ * `exports = {…}`/`[…]` literal with high data density (digits/quotes/colons),
282
+ * and names each with its size and a key hint, redirecting it to the DB/API.
283
+ *
284
+ * Pure: takes the chunk text, returns findings. Top offenders only (capped), so
285
+ * a chunk full of data produces a readable list, not hundreds of lines.
286
+ */
287
+ export function analyzeChunkBloat(
288
+ text: string,
289
+ source: string,
290
+ budget: WeightBudget = DEFAULT_WEIGHT_BUDGET,
291
+ topN = 8,
292
+ ): AuditFinding[] {
293
+ const starts: number[] = [];
294
+ const re = /__d\(/g;
295
+ let m: RegExpExecArray | null;
296
+ while ((m = re.exec(text)) !== null) starts.push(m.index);
297
+ if (starts.length === 0) return [];
298
+
299
+ const found: AuditFinding[] = [];
300
+ for (let i = 0; i < starts.length; i++) {
301
+ const s = starts[i];
302
+ const e = i + 1 < starts.length ? starts[i + 1] : text.length;
303
+ const size = e - s;
304
+ if (size < budget.dataWarn) continue;
305
+
306
+ const head = text.slice(s, s + 220);
307
+ if (!/\.exports\s*=\s*[{[]/.test(head)) continue; // not an export literal
308
+
309
+ // Data density on a sample: data files are mostly quotes/digits/punctuation.
310
+ const sample = text.slice(s, s + 4096);
311
+ const dataChars = (sample.match(/["{}:,[\]0-9.]/g) || []).length;
312
+ if (!sample.length || dataChars / sample.length < 0.22) continue; // looks like code
313
+
314
+ const hint = (head.match(/exports\s*=\s*[{[]\s*"?([A-Za-z0-9_:.-]{2,40})"?\s*[:,]/) || [])[1];
315
+ found.push({
316
+ severity: size >= budget.dataFail ? 'fail' : 'warn',
317
+ check: 'data-in-bundle',
318
+ source,
319
+ detail: `${formatBytes(size)} of data is inlined into ${source}${hint ? ` (a module whose first key is "${hint}")` : ''} — this is a dataset imported into app code; move it to the database (a Model/migration) and serve it over the API instead of bundling it`,
320
+ });
321
+ }
322
+ return found.sort((a, b) => byteHint(b.detail) - byteHint(a.detail)).slice(0, topN);
323
+ }
324
+
325
+ /** Pull the leading byte count back out of a formatted detail, for sorting. */
326
+ function byteHint(detail: string): number {
327
+ const m = detail.match(/^([\d.]+)\s*(B|KB|MB|GB)/);
328
+ if (!m) return 0;
329
+ const units: Record<string, number> = { B: 1, KB: 1024, MB: MB, GB: 1024 * MB };
330
+ return parseFloat(m[1]) * (units[m[2]] || 1);
331
+ }
332
+
333
+ /** The N biggest files, largest first — for an always-on "largest files" list. */
334
+ export function largestFiles(files: AssetFile[], n = 10): AssetFile[] {
335
+ return [...files].sort((a, b) => b.size - a.size).slice(0, n);
336
+ }
337
+
338
+ /** Resolve a budget from CLI flags (MB), falling back to the defaults. */
339
+ export function resolveWeightBudget(
340
+ flags: Record<string, string>,
341
+ base: WeightBudget = DEFAULT_WEIGHT_BUDGET,
342
+ ): WeightBudget {
343
+ const mb = (key: string): number | undefined => {
344
+ const raw = flags[key];
345
+ if (raw == null) return undefined;
346
+ const n = Number(raw);
347
+ return Number.isFinite(n) && n > 0 ? n * MB : undefined;
348
+ };
349
+ return {
350
+ ...base,
351
+ perFileFail: mb('max-file-mb') ?? base.perFileFail,
352
+ totalFail: mb('max-total-mb') ?? base.totalFail,
353
+ jsFail: mb('max-js-mb') ?? base.jsFail,
354
+ dataFail: mb('max-data-mb') ?? base.dataFail,
355
+ };
356
+ }