@everystack/cli 0.3.3 → 0.3.5
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 +15 -10
- package/src/cli/audit-api.ts +97 -0
- package/src/cli/bundle-audit.ts +225 -0
- package/src/cli/bundle-weight.ts +356 -0
- package/src/cli/bundle.ts +146 -0
- package/src/cli/commands/audit.ts +134 -0
- package/src/cli/commands/bundle.ts +461 -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/commands/ui-audit.ts +76 -0
- package/src/cli/component-audit.ts +487 -0
- package/src/cli/component-model.ts +304 -0
- package/src/cli/index.ts +35 -0
- package/src/cli/lighthouse.ts +155 -0
- package/src/cli/security-probe.ts +243 -0
- package/src/cli/ui-bloat.ts +198 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live security probe — pure plan-builder + assertion engine for
|
|
3
|
+
* `everystack security:probe`.
|
|
4
|
+
*
|
|
5
|
+
* Config-free by INTROSPECTION: the command reads the live DB catalog (grants,
|
|
6
|
+
* columns, RLS) through the ops-Lambda `db:query` path that `security:audit
|
|
7
|
+
* --stage` already uses, and this module turns that catalog into a ProbePlan —
|
|
8
|
+
* which tables are anon/auth-readable, which columns are sensitive, where RLS is
|
|
9
|
+
* off. The HTTP probes then test the deployed surface against that discovered
|
|
10
|
+
* truth. Nothing is hand-declared.
|
|
11
|
+
*
|
|
12
|
+
* Forward path: when the v3 Model lands, the Model's `abilities` become the
|
|
13
|
+
* oracle and the same assertions become `diff(declared, live)` — this engine is
|
|
14
|
+
* reused unchanged; only the source of the plan moves from catalog to Model.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// --- Catalog shapes (rows returned by the introspection SQL) ----------------
|
|
18
|
+
|
|
19
|
+
export interface CatalogColumn {
|
|
20
|
+
schema: string;
|
|
21
|
+
table: string;
|
|
22
|
+
column: string;
|
|
23
|
+
dataType: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CatalogGrant {
|
|
27
|
+
schema: string;
|
|
28
|
+
table: string;
|
|
29
|
+
grantee: string; // 'anon' | 'authenticated' | 'PUBLIC' | ...
|
|
30
|
+
privilege: string; // 'SELECT' | 'INSERT' | ...
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CatalogTable {
|
|
34
|
+
schema: string;
|
|
35
|
+
table: string;
|
|
36
|
+
rlsEnabled: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Catalog {
|
|
40
|
+
tables: CatalogTable[];
|
|
41
|
+
columns: CatalogColumn[];
|
|
42
|
+
grants: CatalogGrant[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- Universal sensitive-column heuristic (app-agnostic) --------------------
|
|
46
|
+
|
|
47
|
+
const SENSITIVE_PATTERNS: RegExp[] = [
|
|
48
|
+
/pass(word)?/i,
|
|
49
|
+
/secret/i,
|
|
50
|
+
/(^|_)token$/i,
|
|
51
|
+
/(^|_)hash$/i,
|
|
52
|
+
/salt/i,
|
|
53
|
+
/(^|_)key$/i,
|
|
54
|
+
/api[_-]?key/i,
|
|
55
|
+
/private[_-]?key/i,
|
|
56
|
+
/stripe/i,
|
|
57
|
+
/session[_-]?id/i,
|
|
58
|
+
/refresh[_-]?token/i,
|
|
59
|
+
/reset[_-]?password/i,
|
|
60
|
+
/(^|_)ip(_address)?$/i,
|
|
61
|
+
/sign_in_ip/i,
|
|
62
|
+
/distinct_id/i,
|
|
63
|
+
/(^|_)otp(_|$)/i,
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export function isSensitiveColumn(name: string): boolean {
|
|
67
|
+
return SENSITIVE_PATTERNS.some((re) => re.test(name));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Probe plan -------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export interface ProbePlan {
|
|
73
|
+
anonReadableTables: string[];
|
|
74
|
+
authReadableTables: string[];
|
|
75
|
+
sensitiveColumns: Record<string, string[]>; // table → sensitive columns
|
|
76
|
+
jsonbColumns: Record<string, string[]>;
|
|
77
|
+
tablesWithoutRls: string[]; // readable tables with RLS disabled
|
|
78
|
+
userTable: string | null; // most-sensitive table (heuristic target)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ANON_GRANTEES = new Set(['anon', 'PUBLIC', 'public']);
|
|
82
|
+
|
|
83
|
+
export function buildProbePlan(catalog: Catalog): ProbePlan {
|
|
84
|
+
const selectGrant = (grantee: (g: string) => boolean): Set<string> => {
|
|
85
|
+
const s = new Set<string>();
|
|
86
|
+
for (const g of catalog.grants) {
|
|
87
|
+
if (g.privilege.toUpperCase() === 'SELECT' && grantee(g.grantee)) {
|
|
88
|
+
s.add(g.table);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return s;
|
|
92
|
+
};
|
|
93
|
+
const anonReadable = selectGrant((g) => ANON_GRANTEES.has(g));
|
|
94
|
+
const authReadable = selectGrant((g) => g === 'authenticated');
|
|
95
|
+
|
|
96
|
+
const sensitiveColumns: Record<string, string[]> = {};
|
|
97
|
+
const jsonbColumns: Record<string, string[]> = {};
|
|
98
|
+
for (const c of catalog.columns) {
|
|
99
|
+
if (isSensitiveColumn(c.column)) {
|
|
100
|
+
(sensitiveColumns[c.table] ||= []).push(c.column);
|
|
101
|
+
}
|
|
102
|
+
if (/json/i.test(c.dataType)) {
|
|
103
|
+
(jsonbColumns[c.table] ||= []).push(c.column);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const rlsByTable = new Map(catalog.tables.map((t) => [t.table, t.rlsEnabled]));
|
|
108
|
+
const readable = new Set([...anonReadable, ...authReadable]);
|
|
109
|
+
const tablesWithoutRls = [...readable].filter((t) => rlsByTable.get(t) === false).sort();
|
|
110
|
+
|
|
111
|
+
// Heuristic user-table target: the readable table with the most sensitive cols.
|
|
112
|
+
let userTable: string | null = null;
|
|
113
|
+
let max = 0;
|
|
114
|
+
for (const t of readable) {
|
|
115
|
+
const n = (sensitiveColumns[t] || []).length;
|
|
116
|
+
if (n > max) {
|
|
117
|
+
max = n;
|
|
118
|
+
userTable = t;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
anonReadableTables: [...anonReadable].sort(),
|
|
124
|
+
authReadableTables: [...authReadable].sort(),
|
|
125
|
+
sensitiveColumns,
|
|
126
|
+
jsonbColumns,
|
|
127
|
+
tablesWithoutRls,
|
|
128
|
+
userTable,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- Probe assertions (pure; over HTTP responses) ---------------------------
|
|
133
|
+
|
|
134
|
+
export type Severity = 'critical' | 'high' | 'medium';
|
|
135
|
+
|
|
136
|
+
export interface ProbeResult {
|
|
137
|
+
id: string;
|
|
138
|
+
name: string;
|
|
139
|
+
severity: Severity;
|
|
140
|
+
pass: boolean;
|
|
141
|
+
detail: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Readable tables MUST have RLS — without it, the JS role map is the only guard. */
|
|
145
|
+
export function assessRlsCoverage(plan: ProbePlan): ProbeResult[] {
|
|
146
|
+
if (plan.tablesWithoutRls.length === 0) {
|
|
147
|
+
return [
|
|
148
|
+
{
|
|
149
|
+
id: 'rls-coverage',
|
|
150
|
+
name: 'RLS enabled on all client-readable tables',
|
|
151
|
+
severity: 'critical',
|
|
152
|
+
pass: true,
|
|
153
|
+
detail: `${plan.anonReadableTables.length + plan.authReadableTables.length} readable table(s), all RLS-protected`,
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
return plan.tablesWithoutRls.map((t) => ({
|
|
158
|
+
id: `rls-coverage:${t}`,
|
|
159
|
+
name: `RLS on ${t}`,
|
|
160
|
+
severity: 'critical' as const,
|
|
161
|
+
pass: false,
|
|
162
|
+
detail: `${t} is client-readable but has ROW LEVEL SECURITY disabled — every row is exposed`,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** An anon GET response must not contain sensitive column values. */
|
|
167
|
+
export function assessColumnExposure(
|
|
168
|
+
table: string,
|
|
169
|
+
rows: Record<string, unknown>[],
|
|
170
|
+
sensitiveCols: string[],
|
|
171
|
+
): ProbeResult {
|
|
172
|
+
const leaked = new Set<string>();
|
|
173
|
+
for (const row of rows) {
|
|
174
|
+
for (const col of sensitiveCols) {
|
|
175
|
+
if (col in row && row[col] !== null && row[col] !== undefined) leaked.add(col);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
id: `column-exposure:${table}`,
|
|
180
|
+
name: `No sensitive columns exposed on ${table}`,
|
|
181
|
+
severity: 'critical',
|
|
182
|
+
pass: leaked.size === 0,
|
|
183
|
+
detail:
|
|
184
|
+
leaked.size === 0
|
|
185
|
+
? `${sensitiveCols.length} sensitive column(s) withheld`
|
|
186
|
+
: `exposed sensitive column(s): ${[...leaked].join(', ')}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** ?limit must be capped — an unbounded result set is a DoS / exfil amplifier. */
|
|
191
|
+
export function assessLimitCap(
|
|
192
|
+
status: number,
|
|
193
|
+
returnedCount: number,
|
|
194
|
+
maxLimit = 10000,
|
|
195
|
+
): ProbeResult {
|
|
196
|
+
const pass = status === 400 || returnedCount <= maxLimit;
|
|
197
|
+
return {
|
|
198
|
+
id: 'limit-cap',
|
|
199
|
+
name: 'Unbounded ?limit is rejected or clamped',
|
|
200
|
+
severity: 'medium',
|
|
201
|
+
pass,
|
|
202
|
+
detail: pass
|
|
203
|
+
? `capped (status ${status}, ${returnedCount} rows)`
|
|
204
|
+
: `returned ${returnedCount} rows (> ${maxLimit}) — no cap`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A crafted JSON-path filter must not reach the DB (500) — 400/empty-200 is safe. */
|
|
209
|
+
export function assessInjection(status: number): ProbeResult {
|
|
210
|
+
const pass = status < 500;
|
|
211
|
+
return {
|
|
212
|
+
id: 'json-path-injection',
|
|
213
|
+
name: 'JSON-path injection payload is rejected, not executed',
|
|
214
|
+
severity: 'high',
|
|
215
|
+
pass,
|
|
216
|
+
detail: pass ? `safe (status ${status})` : `status ${status} — payload reached the database`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A deeply nested embed must be rejected (400), not executed or timed out. */
|
|
221
|
+
export function assessEmbedDepth(status: number): ProbeResult {
|
|
222
|
+
const pass = status === 400 || status === 414;
|
|
223
|
+
return {
|
|
224
|
+
id: 'embed-depth',
|
|
225
|
+
name: 'Over-deep relation embed is rejected',
|
|
226
|
+
severity: 'medium',
|
|
227
|
+
pass,
|
|
228
|
+
detail: pass ? `rejected (status ${status})` : `status ${status} — deep embed not capped`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function summarizeProbe(results: ProbeResult[]): {
|
|
233
|
+
pass: number;
|
|
234
|
+
fail: number;
|
|
235
|
+
critical: number;
|
|
236
|
+
} {
|
|
237
|
+
const failed = results.filter((r) => !r.pass);
|
|
238
|
+
return {
|
|
239
|
+
pass: results.filter((r) => r.pass).length,
|
|
240
|
+
fail: failed.length,
|
|
241
|
+
critical: failed.filter((r) => r.severity === 'critical').length,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundle UI-bloat audit — pure scanners for the remote `bundle:audit` (Brick 5a).
|
|
3
|
+
*
|
|
4
|
+
* The weight scanner (bundle-weight.ts) answers "is the bundle too big / is data
|
|
5
|
+
* leaking into it?" This answers "is the *frontend* bloated — the same component
|
|
6
|
+
* shape hand-written N times instead of reused, the same color hardcoded hundreds
|
|
7
|
+
* of times instead of a token?" It is the universal, gate-to-prod half of the
|
|
8
|
+
* design-bloat loop: it runs on the shipped bundle, needs no repo, and is blind to
|
|
9
|
+
* which ui package was intended — it measures *repetition*, not conformance.
|
|
10
|
+
*
|
|
11
|
+
* Empirically grounded against a real deployed bundle: `className` does not survive
|
|
12
|
+
* (build-time tailwind compiles it away) and component identities are mangled, so
|
|
13
|
+
* the only surviving anchor is the inline `style:{}` object literal. Two truths make
|
|
14
|
+
* it work:
|
|
15
|
+
* - Real reuse (a component rendered N times via `.map`) compiles to ONE call
|
|
16
|
+
* site; copy-paste compiles to N identical sites. So the occurrence count of an
|
|
17
|
+
* identical `style:{}` literal IS the copy-paste count (Metro does not dedupe
|
|
18
|
+
* inline object literals).
|
|
19
|
+
* - App design styling references a theme token or a hardcoded hex; bare
|
|
20
|
+
* structural styling (`flex:1`) is library noise. Filtering on token-or-hex
|
|
21
|
+
* biases the scan to the builder's own code without needing module paths.
|
|
22
|
+
*
|
|
23
|
+
* Advisory only. Refactoring is taste (the governance ceiling), so every finding is
|
|
24
|
+
* `warn` — even at the gate. The stick is that it *shows up* at the gate, never that
|
|
25
|
+
* it blocks the deploy.
|
|
26
|
+
*
|
|
27
|
+
* Everything here is pure: it takes bundle text and returns findings, so the
|
|
28
|
+
* governance MCP imports it directly. It cannot give locations (the bundle is
|
|
29
|
+
* mangled, pathless) — that is the local source auditor's job; this proves magnitude.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { AuditFinding } from './bundle-audit.js';
|
|
33
|
+
|
|
34
|
+
/** Thresholds for the design-bloat gates. All counts are occurrences in the bundle. */
|
|
35
|
+
export interface UiBloatBudget {
|
|
36
|
+
/** A `style:{}` shape hand-written at this many distinct call sites is an
|
|
37
|
+
* un-extracted component. */
|
|
38
|
+
styleRepeatWarn: number;
|
|
39
|
+
/** A single hardcoded hex color repeated this many times should be a token. */
|
|
40
|
+
colorRepeatWarn: number;
|
|
41
|
+
/** This many *distinct* hardcoded hex values is an ad-hoc palette with no token
|
|
42
|
+
* system. */
|
|
43
|
+
paletteWarn: number;
|
|
44
|
+
/** Cap on findings per check, so a bloated bundle yields a readable list. */
|
|
45
|
+
topN: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Defaults from the real-bundle measurement, not round numbers: shapes repeated
|
|
50
|
+
* ≥10× were the clear extract-me set; a single gray hex appeared 470× past a token
|
|
51
|
+
* that existed; the ad-hoc palette ran to ~300 distinct values. The thresholds sit
|
|
52
|
+
* just below those so the signal is caught without noise. Overridable via flags.
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_UI_BLOAT_BUDGET: UiBloatBudget = {
|
|
55
|
+
styleRepeatWarn: 8,
|
|
56
|
+
colorRepeatWarn: 12,
|
|
57
|
+
paletteWarn: 24,
|
|
58
|
+
topN: 12,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const HEX = /'#[0-9a-fA-F]{3,8}'/g;
|
|
62
|
+
// A flat inline style literal: `style:{ … }` with no nested braces. Nested styles
|
|
63
|
+
// (`style:{a:{…}}`) are intentionally out of scope for the count-only remote pass.
|
|
64
|
+
const STYLE_LITERAL = /style:\{[^{}]{4,400}\}/g;
|
|
65
|
+
// A mangled module/theme reference: `a.text`, `p.textSecondary` → folded to `X.`
|
|
66
|
+
// so the same token counts as one shape regardless of its minified alias.
|
|
67
|
+
const MANGLED_REF = /[A-Za-z_$][A-Za-z0-9_$]*\./g;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Canonical form of a style literal for grouping: strip all whitespace and fold any
|
|
71
|
+
* `ident.prop` reference to `X.prop` (mangled bundle aliases AND source theme refs
|
|
72
|
+
* like `theme.border` collapse to the same shape). Shared by the remote bundle
|
|
73
|
+
* scanner and the source-side auditor so both group styles identically.
|
|
74
|
+
*/
|
|
75
|
+
export function canonicalizeStyle(raw: string): string {
|
|
76
|
+
return raw.replace(/\s+/g, '').replace(MANGLED_REF, 'X.');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** True if a style literal is app *design* styling (references a token or a hex),
|
|
80
|
+
* rather than bare structural layout that libraries emit. */
|
|
81
|
+
export function isDesignStyle(literal: string): boolean {
|
|
82
|
+
return /X\./.test(literal) || /#[0-9a-fA-F]{3,8}/.test(literal);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Extract and normalize the design `style:{}` literals from bundle text. Mangled
|
|
87
|
+
* aliases are folded to `X.`; bare structural styling is dropped. Order-preserving,
|
|
88
|
+
* so the length is the occurrence (copy-paste) count.
|
|
89
|
+
*/
|
|
90
|
+
export function extractStyleLiterals(text: string): string[] {
|
|
91
|
+
const out: string[] = [];
|
|
92
|
+
const m = text.match(STYLE_LITERAL);
|
|
93
|
+
if (!m) return out;
|
|
94
|
+
for (const raw of m) {
|
|
95
|
+
const norm = canonicalizeStyle(raw);
|
|
96
|
+
if (isDesignStyle(norm)) out.push(norm);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A short, safe preview of a style literal for the finding detail. */
|
|
102
|
+
function preview(literal: string, max = 80): string {
|
|
103
|
+
return literal.length <= max ? literal : `${literal.slice(0, max)}…}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Flag `style:{}` shapes hand-written at many distinct call sites — each is a
|
|
108
|
+
* component that should be extracted once and reused. Count-only (no locations);
|
|
109
|
+
* the local source auditor pins the `file:line`s.
|
|
110
|
+
*/
|
|
111
|
+
export function scanRepeatedStyles(
|
|
112
|
+
text: string,
|
|
113
|
+
source: string,
|
|
114
|
+
budget: UiBloatBudget = DEFAULT_UI_BLOAT_BUDGET,
|
|
115
|
+
): AuditFinding[] {
|
|
116
|
+
const counts = new Map<string, number>();
|
|
117
|
+
for (const lit of extractStyleLiterals(text)) {
|
|
118
|
+
counts.set(lit, (counts.get(lit) ?? 0) + 1);
|
|
119
|
+
}
|
|
120
|
+
return [...counts.entries()]
|
|
121
|
+
.filter(([, n]) => n >= budget.styleRepeatWarn)
|
|
122
|
+
.sort((a, b) => b[1] - a[1])
|
|
123
|
+
.slice(0, budget.topN)
|
|
124
|
+
.map(([lit, n]) => ({
|
|
125
|
+
severity: 'warn' as const,
|
|
126
|
+
check: 'ui-repeated-style' as const,
|
|
127
|
+
source,
|
|
128
|
+
detail: `a style is hand-written at ${n} distinct call sites (${preview(lit)}) — extract a component and reuse it instead of repeating the markup`,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Flag hardcoded hex colors: a single value repeated past the budget (it should be
|
|
134
|
+
* a theme token), and — separately — an ad-hoc palette of many distinct hardcoded
|
|
135
|
+
* values (no token system). Both are token-violation smells.
|
|
136
|
+
*/
|
|
137
|
+
export function scanHardcodedColors(
|
|
138
|
+
text: string,
|
|
139
|
+
source: string,
|
|
140
|
+
budget: UiBloatBudget = DEFAULT_UI_BLOAT_BUDGET,
|
|
141
|
+
): AuditFinding[] {
|
|
142
|
+
const counts = new Map<string, number>();
|
|
143
|
+
for (const lit of text.match(STYLE_LITERAL) ?? []) {
|
|
144
|
+
for (const hex of lit.match(HEX) ?? []) {
|
|
145
|
+
const v = hex.slice(1, -1); // strip the surrounding quotes
|
|
146
|
+
counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (counts.size === 0) return [];
|
|
150
|
+
|
|
151
|
+
const findings: AuditFinding[] = [...counts.entries()]
|
|
152
|
+
.filter(([, n]) => n >= budget.colorRepeatWarn)
|
|
153
|
+
.sort((a, b) => b[1] - a[1])
|
|
154
|
+
.slice(0, budget.topN)
|
|
155
|
+
.map(([hex, n]) => ({
|
|
156
|
+
severity: 'warn' as const,
|
|
157
|
+
check: 'ui-hardcoded-color' as const,
|
|
158
|
+
source,
|
|
159
|
+
detail: `${hex} is hardcoded ${n} times — replace it with a theme token so it can change in one place (and support dark mode)`,
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
if (counts.size >= budget.paletteWarn) {
|
|
163
|
+
findings.push({
|
|
164
|
+
severity: 'warn',
|
|
165
|
+
check: 'ui-hardcoded-color',
|
|
166
|
+
source,
|
|
167
|
+
detail: `${counts.size} distinct hardcoded hex colors are inlined across the bundle — this is an ad-hoc palette; define a token set instead of scattering raw colors`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return findings;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The combined remote UI-bloat entry — what the command and the MCP call. */
|
|
174
|
+
export function scanBundleUiBloat(
|
|
175
|
+
text: string,
|
|
176
|
+
source: string,
|
|
177
|
+
budget: UiBloatBudget = DEFAULT_UI_BLOAT_BUDGET,
|
|
178
|
+
): AuditFinding[] {
|
|
179
|
+
return [...scanRepeatedStyles(text, source, budget), ...scanHardcodedColors(text, source, budget)];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Resolve a budget from CLI flags, falling back to the defaults. */
|
|
183
|
+
export function resolveUiBloatBudget(
|
|
184
|
+
flags: Record<string, string>,
|
|
185
|
+
base: UiBloatBudget = DEFAULT_UI_BLOAT_BUDGET,
|
|
186
|
+
): UiBloatBudget {
|
|
187
|
+
const num = (key: string): number | undefined => {
|
|
188
|
+
const raw = flags[key];
|
|
189
|
+
if (raw == null) return undefined;
|
|
190
|
+
const n = Number(raw);
|
|
191
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
...base,
|
|
195
|
+
styleRepeatWarn: num('ui-repeat') ?? base.styleRepeatWarn,
|
|
196
|
+
colorRepeatWarn: num('ui-color-repeat') ?? base.colorRepeatWarn,
|
|
197
|
+
};
|
|
198
|
+
}
|