@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 +5 -1
- package/src/cli/audit-api.ts +54 -0
- package/src/cli/bundle-audit.ts +185 -0
- package/src/cli/bundle-weight.ts +356 -0
- package/src/cli/bundle.ts +146 -0
- package/src/cli/commands/audit.ts +118 -0
- package/src/cli/commands/bundle.ts +452 -0
- package/src/cli/commands/lighthouse.ts +104 -0
- package/src/cli/commands/security-probe.ts +213 -0
- package/src/cli/commands/security.ts +12 -4
- package/src/cli/index.ts +28 -0
- package/src/cli/lighthouse.ts +155 -0
- package/src/cli/security-probe.ts +243 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.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
|
+
}
|