@everystack/cli 0.3.0 → 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.
@@ -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
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Bundle analysis — pure, filesystem-free logic for `everystack bundle:analyze`.
3
+ *
4
+ * Measures the served client JS (raw / gzip / brotli — brotli is the real
5
+ * CloudFront-transfer number) and, from the largest chunk's source map, ranks
6
+ * the source-byte contributors per npm package / app workspace.
7
+ *
8
+ * Config-free by construction: the bundle layout is an Expo convention (located
9
+ * by the command), and the workspace bucketing is derived from the source paths
10
+ * themselves — nothing is hand-declared.
11
+ */
12
+
13
+ import zlib from 'node:zlib';
14
+
15
+ export interface ChunkInput {
16
+ name: string;
17
+ buf: Buffer;
18
+ }
19
+
20
+ export interface ChunkSize {
21
+ name: string;
22
+ raw: number;
23
+ gz: number;
24
+ br: number;
25
+ }
26
+
27
+ export interface SizeTotals {
28
+ raw: number;
29
+ gz: number;
30
+ br: number;
31
+ }
32
+
33
+ export interface SourceMap {
34
+ sources?: (string | null)[];
35
+ sourcesContent?: (string | null)[];
36
+ }
37
+
38
+ export interface PkgComposition {
39
+ pkg: string;
40
+ bytes: number;
41
+ share: number; // 0..1
42
+ }
43
+
44
+ export interface Composition {
45
+ total: number;
46
+ modules: number;
47
+ top: PkgComposition[];
48
+ }
49
+
50
+ /** Raw / gzip(9) / brotli byte sizes of a single chunk buffer. Deterministic. */
51
+ export function measureChunk(buf: Buffer): SizeTotals {
52
+ return {
53
+ raw: buf.length,
54
+ gz: zlib.gzipSync(buf, { level: 9 }).length,
55
+ br: zlib.brotliCompressSync(buf).length,
56
+ };
57
+ }
58
+
59
+ /** Measure every chunk, sorted largest-raw first. */
60
+ export function measureChunks(chunks: ChunkInput[]): ChunkSize[] {
61
+ return chunks
62
+ .map((c) => ({ name: c.name, ...measureChunk(c.buf) }))
63
+ .sort((a, b) => b.raw - a.raw);
64
+ }
65
+
66
+ export function totalSizes(chunks: ChunkSize[]): SizeTotals {
67
+ return chunks.reduce<SizeTotals>(
68
+ (acc, c) => ({ raw: acc.raw + c.raw, gz: acc.gz + c.gz, br: acc.br + c.br }),
69
+ { raw: 0, gz: 0, br: 0 },
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Map a source-map `sources` path to its owning bucket. Generic — no hardcoded
75
+ * app or package names. node_modules → the package (scope-aware); a monorepo
76
+ * `/packages/<name>/` or `/apps/<name>/` segment → that workspace; else `other`.
77
+ */
78
+ export function bucketSource(src: string): string {
79
+ const nm = src.lastIndexOf('node_modules/');
80
+ if (nm >= 0) {
81
+ const parts = src.slice(nm + 'node_modules/'.length).split('/');
82
+ return parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0];
83
+ }
84
+ const pkg = src.match(/\/packages\/([^/]+)/);
85
+ if (pkg) return `APP:packages/${pkg[1]}`;
86
+ const app = src.match(/\/apps\/([^/]+)/);
87
+ if (app) return `APP:apps/${app[1]}`;
88
+ return 'APP:other';
89
+ }
90
+
91
+ /** Bucket the largest chunk's source bytes by owning package, top N by size. */
92
+ export function composeSourceMap(map: SourceMap, topN = 30): Composition {
93
+ const sources = map.sources || [];
94
+ const content = map.sourcesContent || [];
95
+ const byPkg: Record<string, number> = {};
96
+ let total = 0;
97
+ for (let i = 0; i < sources.length; i++) {
98
+ const src = sources[i] || '';
99
+ const len = (content[i] || '').length;
100
+ total += len;
101
+ const key = bucketSource(src);
102
+ byPkg[key] = (byPkg[key] || 0) + len;
103
+ }
104
+ const top = Object.entries(byPkg)
105
+ .map(([pkg, bytes]) => ({ pkg, bytes, share: total ? bytes / total : 0 }))
106
+ .sort((a, b) => b.bytes - a.bytes)
107
+ .slice(0, topN);
108
+ return { total, modules: sources.length, top };
109
+ }
110
+
111
+ const mb = (n: number): string => (n / 1048576).toFixed(2);
112
+ const kb = (n: number): string => (n / 1024).toFixed(0);
113
+
114
+ /** Render the size + composition report as lines (testable, IO-free). */
115
+ export function formatBundleReport(chunks: ChunkSize[], composition: Composition | null): string[] {
116
+ const totals = totalSizes(chunks);
117
+ const lines: string[] = [];
118
+ lines.push(`CLIENT JS: ${chunks.length} chunk(s)`);
119
+ lines.push(` raw ${mb(totals.raw)} MB`);
120
+ lines.push(` gzip ${mb(totals.gz)} MB`);
121
+ lines.push(` brotli ${mb(totals.br)} MB (CloudFront transfer)`);
122
+ lines.push('');
123
+ for (const c of chunks) {
124
+ lines.push(` ${kb(c.raw).padStart(7)} KB raw ${kb(c.gz).padStart(6)} KB gz ${c.name}`);
125
+ }
126
+
127
+ if (!composition) {
128
+ lines.push('');
129
+ lines.push('(no source map for composition — re-export with --source-maps)');
130
+ return lines;
131
+ }
132
+
133
+ lines.push('');
134
+ lines.push(
135
+ `largest chunk: ${mb(composition.total)} MB source / ${composition.modules} modules`,
136
+ );
137
+ lines.push(' rank src-KB share package');
138
+ composition.top.forEach((p, i) => {
139
+ lines.push(
140
+ ` ${String(i + 1).padStart(3)} ${kb(p.bytes).padStart(7)} ${(p.share * 100)
141
+ .toFixed(1)
142
+ .padStart(5)}% ${p.pkg}`,
143
+ );
144
+ });
145
+ return lines;
146
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `everystack audit --stage <name>` — the capstone. Runs the live deploy-checks
3
+ * against one stage and emits a combined report + a single exit code (non-zero
4
+ * if anything failed), so it works as a one-shot CI gate.
5
+ *
6
+ * Composes the shared result-producing cores (runBundleAudit, runSecurityProbe)
7
+ * so there's no orchestration duplication. The white-box `security:audit` and
8
+ * the heavier `audit:lighthouse` stay separate commands (run them explicitly).
9
+ */
10
+
11
+ import { resolveConfig, opsFunction } from '../config.js';
12
+ import { runBundleAudit, runBundleWeightAudit, reportBundleAudit } from './bundle.js';
13
+ import { resolveWeightBudget } from '../bundle-weight.js';
14
+ import { runSecurityProbe, reportProbeResults } from './security-probe.js';
15
+ import { auditDeployedSql, printReport, loadWaivers } from './security.js';
16
+ import { collectLighthouseRuns } from './lighthouse.js';
17
+ import { aggregate, formatLighthouseReport } from '../lighthouse.js';
18
+ import { step, info, fail, success, warn } from '../output.js';
19
+
20
+ export async function auditCommand(flags: Record<string, string>): Promise<void> {
21
+ step('Resolving deployed config...');
22
+ let config;
23
+ try {
24
+ config = await resolveConfig(flags.stage);
25
+ } catch (err: any) {
26
+ fail(err.message);
27
+ process.exit(1);
28
+ }
29
+ const base = (flags.host || config.baseUrl || '').replace(/\/+$/, '');
30
+ if (!base) {
31
+ fail('No router URL to audit. Pass --host or ensure the stage has a deployed URL.');
32
+ process.exit(1);
33
+ }
34
+ info(`Region: ${config.region}, URL: ${base}`);
35
+
36
+ let failures = 0;
37
+ const ran: string[] = [];
38
+
39
+ // --- security:audit (white-box: SECDEF / exposing-view SQL surface) -------
40
+ console.log('');
41
+ info('── security:audit ───────────────────────────────────────────');
42
+ try {
43
+ const waivers = await loadWaivers(flags.config);
44
+ const report = await auditDeployedSql(config.region, opsFunction(config), waivers);
45
+ console.log('');
46
+ printReport(report, 'catalog');
47
+ failures += report.red;
48
+ ran.push('security:audit');
49
+ } catch (err: any) {
50
+ warn(`security:audit skipped: ${err.message}`);
51
+ }
52
+
53
+ // --- security:probe (live RLS/grants introspection + surface probing) ----
54
+ console.log('');
55
+ info('── security:probe ───────────────────────────────────────────');
56
+ try {
57
+ const results = await runSecurityProbe({
58
+ region: config.region,
59
+ opsFn: opsFunction(config),
60
+ base,
61
+ });
62
+ console.log('');
63
+ failures += reportProbeResults(results);
64
+ ran.push('security:probe');
65
+ } catch (err: any) {
66
+ warn(`security:probe skipped: ${err.message}`);
67
+ }
68
+
69
+ // --- bundle:audit (leak + weight) ----------------------------------------
70
+ // --dir scans a local export for leaks AND weight (the 180MB gate); without
71
+ // it, the deployed bundle is scanned for leaks only (weight needs the export).
72
+ console.log('');
73
+ info('── bundle:audit ─────────────────────────────────────────────');
74
+ try {
75
+ const result = flags.dir
76
+ ? await runBundleWeightAudit(flags.dir, {
77
+ budget: resolveWeightBudget(flags),
78
+ valuesFrom: flags['values-from'],
79
+ })
80
+ : await runBundleAudit(base, {
81
+ valuesFrom: flags['values-from'],
82
+ weight: !flags['leak-only'],
83
+ budget: resolveWeightBudget(flags),
84
+ });
85
+ console.log('');
86
+ failures += reportBundleAudit(result);
87
+ ran.push('bundle:audit (leak + weight)');
88
+ } catch (err: any) {
89
+ warn(`bundle:audit skipped: ${err.message}`);
90
+ }
91
+
92
+ // --- audit:lighthouse (perf/SEO) — opt-in; heavy and informational -------
93
+ if (flags.lighthouse) {
94
+ console.log('');
95
+ info('── audit:lighthouse ─────────────────────────────────────────');
96
+ try {
97
+ const runs = flags.runs ? parseInt(flags.runs, 10) : 3;
98
+ const extracts = await collectLighthouseRuns(base + '/', runs);
99
+ if (extracts.length) {
100
+ console.log('');
101
+ console.log(formatLighthouseReport(base + '/', extracts.length, aggregate(extracts)).join('\n'));
102
+ ran.push('audit:lighthouse (informational)');
103
+ }
104
+ } catch (err: any) {
105
+ warn(`audit:lighthouse skipped: ${err.message}`);
106
+ }
107
+ }
108
+
109
+ console.log('');
110
+ const summary = `audit — ${failures} failing check(s) across ${ran.join(' + ') || 'no checks'}`;
111
+ if (failures > 0) {
112
+ fail(summary);
113
+ process.exit(1);
114
+ }
115
+ success(summary);
116
+ if (!flags.lighthouse) info('Add --lighthouse for perf/SEO (slower; informational, does not gate).');
117
+ process.exit(0);
118
+ }