@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,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component UI-bloat audit — source-side checks over the component model (Brick 5a-C).
|
|
3
|
+
*
|
|
4
|
+
* The remote bundle scanner (ui-bloat.ts) proves *that* design bloat ships and how
|
|
5
|
+
* much; it cannot say *where*. This is the actionable half of the loop: it runs over
|
|
6
|
+
* the faithful `ComponentDescriptor` model, so every finding carries the `file:line`
|
|
7
|
+
* occurrences an agent can edit and a structured `fix` it can apply. Verification is
|
|
8
|
+
* uniform — re-run the audit and the finding's occurrences drop to zero. The auditor
|
|
9
|
+
* is its own oracle.
|
|
10
|
+
*
|
|
11
|
+
* Same anchor and grouping as the bundle scanner (shared `canonicalizeStyle` /
|
|
12
|
+
* `isDesignStyle`), so a smell measured at the gate and the smell fixed in source are
|
|
13
|
+
* the same thing seen from two sides. Advisory only: refactoring is taste, so every
|
|
14
|
+
* finding is `warn`.
|
|
15
|
+
*
|
|
16
|
+
* Pure: takes `ComponentDescriptor[]`, returns findings. The model knows nothing about
|
|
17
|
+
* this file; this file imports the model, never the reverse.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { AuditFinding, SourceLoc } from './bundle-audit.js';
|
|
21
|
+
import type { ComponentDescriptor, JsxNode } from './component-model.js';
|
|
22
|
+
import { canonicalizeStyle, isDesignStyle, type UiBloatBudget } from './ui-bloat.js';
|
|
23
|
+
|
|
24
|
+
export { DEFAULT_UI_BLOAT_BUDGET, type UiBloatBudget } from './ui-bloat.js';
|
|
25
|
+
import { DEFAULT_UI_BLOAT_BUDGET } from './ui-bloat.js';
|
|
26
|
+
|
|
27
|
+
const VERIFY = 'rerun ui:audit; this finding clears when its occurrence count drops below the threshold';
|
|
28
|
+
|
|
29
|
+
// ── File role: the architectural lens ──────────────────────────────────────────
|
|
30
|
+
//
|
|
31
|
+
// The goal is separation: keep expo-router screens (app/**) thin, and push reusable
|
|
32
|
+
// UI into shared components — ideally a packages/ui in the monorepo. Duplication
|
|
33
|
+
// *across screens* is the high-value "extract a component" signal; markup already in
|
|
34
|
+
// a shared ui package is fine. Role is derived from the file's location (a fact about
|
|
35
|
+
// where it lives), so it stays out of the neutral model and lives with the auditor.
|
|
36
|
+
|
|
37
|
+
export type FileRole = 'screen' | 'ui' | 'component' | 'other';
|
|
38
|
+
|
|
39
|
+
/** Classify a file by its place in the architecture (most specific first). */
|
|
40
|
+
export function classifyRole(file: string): FileRole {
|
|
41
|
+
const p = file.replace(/\\/g, '/');
|
|
42
|
+
if (/\/packages\/[^/]*ui[^/]*\//.test(p)) return 'ui'; // a shared ui package
|
|
43
|
+
if (/\/components\//.test(p)) return 'component'; // a reusable component (incl. app/components)
|
|
44
|
+
if (/\/app\//.test(p)) return 'screen'; // an expo-router route file
|
|
45
|
+
return 'other';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const HEX = /#[0-9a-fA-F]{3,8}/g;
|
|
49
|
+
|
|
50
|
+
interface StyleSite {
|
|
51
|
+
file: string;
|
|
52
|
+
line: number;
|
|
53
|
+
raw: string;
|
|
54
|
+
canon: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Flatten every design style literal across the tree into located sites. */
|
|
58
|
+
function styleSites(components: ComponentDescriptor[]): StyleSite[] {
|
|
59
|
+
const sites: StyleSite[] = [];
|
|
60
|
+
for (const c of components) {
|
|
61
|
+
for (const s of c.styles) {
|
|
62
|
+
const canon = canonicalizeStyle(s.text);
|
|
63
|
+
if (!isDesignStyle(canon)) continue; // skip bare structural styling
|
|
64
|
+
sites.push({ file: c.file, line: s.line, raw: s.text, canon });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return sites;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function preview(raw: string, max = 80): string {
|
|
71
|
+
const flat = raw.replace(/\s+/g, ' ').trim();
|
|
72
|
+
return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A style hand-written at many distinct call sites is an un-extracted component.
|
|
77
|
+
* Groups by canonical shape, reports each over-threshold group with all its
|
|
78
|
+
* `file:line`s and an extract-component fix.
|
|
79
|
+
*/
|
|
80
|
+
export function scanComponentRepeatedStyles(
|
|
81
|
+
components: ComponentDescriptor[],
|
|
82
|
+
budget: UiBloatBudget = DEFAULT_UI_BLOAT_BUDGET,
|
|
83
|
+
): AuditFinding[] {
|
|
84
|
+
const groups = new Map<string, StyleSite[]>();
|
|
85
|
+
for (const site of styleSites(components)) {
|
|
86
|
+
const g = groups.get(site.canon);
|
|
87
|
+
if (g) g.push(site);
|
|
88
|
+
else groups.set(site.canon, [site]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return [...groups.values()]
|
|
92
|
+
.filter((sites) => sites.length >= budget.styleRepeatWarn)
|
|
93
|
+
.sort((a, b) => b.length - a.length)
|
|
94
|
+
.slice(0, budget.topN)
|
|
95
|
+
.map((sites) => {
|
|
96
|
+
const occurrences: SourceLoc[] = sites.map((s) => ({ file: s.file, line: s.line }));
|
|
97
|
+
return {
|
|
98
|
+
severity: 'warn' as const,
|
|
99
|
+
check: 'ui-repeated-style' as const,
|
|
100
|
+
source: sites[0].canon,
|
|
101
|
+
detail: `a style is hand-written at ${sites.length} distinct call sites (${preview(sites[0].raw)}) — extract a component and reuse it`,
|
|
102
|
+
occurrences,
|
|
103
|
+
fix: {
|
|
104
|
+
kind: 'extract-component' as const,
|
|
105
|
+
summary: `Extract a component that renders this style once, then replace the ${sites.length} inline copies with it`,
|
|
106
|
+
},
|
|
107
|
+
verify: VERIFY,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Hardcoded hex colors: a single value repeated past the budget (it should be a
|
|
114
|
+
* token) and an ad-hoc palette of many distinct values (no token system). Both carry
|
|
115
|
+
* located occurrences and a replace-with-token fix.
|
|
116
|
+
*/
|
|
117
|
+
export function scanComponentHardcodedColors(
|
|
118
|
+
components: ComponentDescriptor[],
|
|
119
|
+
budget: UiBloatBudget = DEFAULT_UI_BLOAT_BUDGET,
|
|
120
|
+
): AuditFinding[] {
|
|
121
|
+
const byHex = new Map<string, SourceLoc[]>();
|
|
122
|
+
const distinct = new Set<string>();
|
|
123
|
+
for (const site of styleSites(components)) {
|
|
124
|
+
for (const hex of site.raw.match(HEX) ?? []) {
|
|
125
|
+
const v = hex.toLowerCase();
|
|
126
|
+
distinct.add(v);
|
|
127
|
+
const locs = byHex.get(v);
|
|
128
|
+
const loc = { file: site.file, line: site.line };
|
|
129
|
+
if (locs) locs.push(loc);
|
|
130
|
+
else byHex.set(v, [loc]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const findings: AuditFinding[] = [...byHex.entries()]
|
|
135
|
+
.filter(([, locs]) => locs.length >= budget.colorRepeatWarn)
|
|
136
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
137
|
+
.slice(0, budget.topN)
|
|
138
|
+
.map(([hex, locs]) => ({
|
|
139
|
+
severity: 'warn' as const,
|
|
140
|
+
check: 'ui-hardcoded-color' as const,
|
|
141
|
+
source: hex,
|
|
142
|
+
detail: `${hex} is hardcoded ${locs.length} times — replace it with a theme token so it changes in one place (and supports dark mode)`,
|
|
143
|
+
occurrences: locs,
|
|
144
|
+
fix: {
|
|
145
|
+
kind: 'replace-with-token' as const,
|
|
146
|
+
summary: `Define a theme token for ${hex} and replace each hardcoded use with it`,
|
|
147
|
+
from: hex,
|
|
148
|
+
},
|
|
149
|
+
verify: VERIFY,
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
if (distinct.size >= budget.paletteWarn) {
|
|
153
|
+
findings.push({
|
|
154
|
+
severity: 'warn',
|
|
155
|
+
check: 'ui-hardcoded-color',
|
|
156
|
+
source: 'palette',
|
|
157
|
+
detail: `${distinct.size} distinct hardcoded hex colors across the components — this is an ad-hoc palette; define a token set instead of scattering raw colors`,
|
|
158
|
+
fix: {
|
|
159
|
+
kind: 'replace-with-token',
|
|
160
|
+
summary: 'Introduce a theme token set and migrate the raw hex colors onto it',
|
|
161
|
+
},
|
|
162
|
+
verify: VERIFY,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return findings;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Check 3: "you rebuilt a ui primitive" (named, source-side only) ────────────
|
|
169
|
+
//
|
|
170
|
+
// The reference set is the ui package the app actually uses. We always seed the
|
|
171
|
+
// everystack primitives (this is the everystack auditor, so we know our own export
|
|
172
|
+
// list — naming our own components is fine), and union any `*/ui` imports so a
|
|
173
|
+
// consumer's own design system is covered too. Matchers are a hand-authored
|
|
174
|
+
// signature table gated by that set; deriving each signature from the ui component's
|
|
175
|
+
// own source is the elegant target (see the spec), not a v1 requirement.
|
|
176
|
+
|
|
177
|
+
/** everystack/ui's primitives — our own package, so the list is known. */
|
|
178
|
+
const EVERYSTACK_UI = [
|
|
179
|
+
'Button', 'Card', 'TextInput', 'FormField', 'Avatar', 'Badge',
|
|
180
|
+
'LoadingState', 'EmptyState', 'ErrorState', 'DataTable',
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
const TOUCHABLES = new Set([
|
|
184
|
+
'Pressable', 'TouchableOpacity', 'TouchableHighlight', 'TouchableWithoutFeedback',
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
/** True if an import source names a ui package (the framework's or a consumer's). */
|
|
188
|
+
function isUiSource(from: string): boolean {
|
|
189
|
+
return from === '@everystack/ui' || /(^|\/)ui$/.test(from);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The primitive names available to the app: the everystack set plus any ui-package imports. */
|
|
193
|
+
export function discoverUiExports(components: ComponentDescriptor[]): Set<string> {
|
|
194
|
+
const set = new Set<string>(EVERYSTACK_UI);
|
|
195
|
+
for (const c of components) {
|
|
196
|
+
for (const imp of c.imports) {
|
|
197
|
+
if (isUiSource(imp.from) && imp.from !== '@everystack/ui') {
|
|
198
|
+
for (const n of imp.names) set.add(n);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return set;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Map each imported name to the module it came from, for disambiguation. */
|
|
206
|
+
function importSources(c: ComponentDescriptor): Map<string, string> {
|
|
207
|
+
const map = new Map<string, string>();
|
|
208
|
+
for (const imp of c.imports) {
|
|
209
|
+
for (const n of imp.names) map.set(n, imp.from);
|
|
210
|
+
if (imp.default) map.set(imp.default, imp.from);
|
|
211
|
+
}
|
|
212
|
+
return map;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function walkNodes(nodes: JsxNode[], fn: (n: JsxNode) => void): void {
|
|
216
|
+
for (const n of nodes) {
|
|
217
|
+
fn(n);
|
|
218
|
+
walkNodes(n.children, fn);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Flag raw React Native primitives shaped to mimic a ui component that already
|
|
224
|
+
* exists. Each finding names the ui component and carries a use-ui-component fix.
|
|
225
|
+
*/
|
|
226
|
+
export function scanUiReimplementation(
|
|
227
|
+
components: ComponentDescriptor[],
|
|
228
|
+
available: Set<string> = discoverUiExports(components),
|
|
229
|
+
): AuditFinding[] {
|
|
230
|
+
const findings: AuditFinding[] = [];
|
|
231
|
+
for (const c of components) {
|
|
232
|
+
const sources = importSources(c);
|
|
233
|
+
walkNodes(c.jsx, (n) => {
|
|
234
|
+
// Button: a touchable with background + rounded + onPress.
|
|
235
|
+
if (
|
|
236
|
+
available.has('Button') &&
|
|
237
|
+
TOUCHABLES.has(n.tag) &&
|
|
238
|
+
n.attrs.includes('onPress') &&
|
|
239
|
+
n.style &&
|
|
240
|
+
/background/i.test(n.style) &&
|
|
241
|
+
/(borderRadius|rounded)/i.test(n.style)
|
|
242
|
+
) {
|
|
243
|
+
findings.push({
|
|
244
|
+
severity: 'warn',
|
|
245
|
+
check: 'ui-reimplementation',
|
|
246
|
+
source: `${c.file}:${n.line}`,
|
|
247
|
+
detail: `a hand-rolled <${n.tag}> with background + rounded + onPress reimplements <Button> — import Button from the ui package`,
|
|
248
|
+
occurrences: [{ file: c.file, line: n.line }],
|
|
249
|
+
fix: {
|
|
250
|
+
kind: 'use-ui-component',
|
|
251
|
+
summary: `Replace this <${n.tag}> with <Button> from the ui package`,
|
|
252
|
+
from: n.tag,
|
|
253
|
+
to: 'Button',
|
|
254
|
+
},
|
|
255
|
+
verify: VERIFY,
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// Raw TextInput: react-native's, styled like an input (the ui one is disambiguated by import source).
|
|
260
|
+
if (
|
|
261
|
+
available.has('TextInput') &&
|
|
262
|
+
n.tag === 'TextInput' &&
|
|
263
|
+
sources.get('TextInput') === 'react-native' &&
|
|
264
|
+
n.style &&
|
|
265
|
+
/(border|padding)/i.test(n.style)
|
|
266
|
+
) {
|
|
267
|
+
findings.push({
|
|
268
|
+
severity: 'warn',
|
|
269
|
+
check: 'ui-reimplementation',
|
|
270
|
+
source: `${c.file}:${n.line}`,
|
|
271
|
+
detail: `a raw <TextInput> from react-native with input styling — use <TextInput> or <FormField> from the ui package for consistent styling and validation`,
|
|
272
|
+
occurrences: [{ file: c.file, line: n.line }],
|
|
273
|
+
fix: {
|
|
274
|
+
kind: 'use-ui-component',
|
|
275
|
+
summary: 'Use <TextInput> or <FormField> from the ui package',
|
|
276
|
+
from: 'TextInput',
|
|
277
|
+
to: 'FormField',
|
|
278
|
+
},
|
|
279
|
+
verify: VERIFY,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return findings;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Checks 4 & 5: size / complexity, and structural duplication ────────────────
|
|
288
|
+
//
|
|
289
|
+
// Mechanical, universal, config-free defaults. Output is always a "candidate"
|
|
290
|
+
// (warn), never a verdict — decomposition is taste, the ceiling we do not cross.
|
|
291
|
+
|
|
292
|
+
/** Per-component size/complexity thresholds and the duplication floor. */
|
|
293
|
+
export interface ComponentBudget {
|
|
294
|
+
/** Line span past which a component is a split candidate. */
|
|
295
|
+
linesWarn: number;
|
|
296
|
+
/** Total JSX nodes in one component. */
|
|
297
|
+
jsxCountWarn: number;
|
|
298
|
+
/** Max JSX nesting depth. */
|
|
299
|
+
depthWarn: number;
|
|
300
|
+
/** Hooks in one component (a responsibilities proxy). */
|
|
301
|
+
hookCountWarn: number;
|
|
302
|
+
/** Props on one component. */
|
|
303
|
+
propCountWarn: number;
|
|
304
|
+
/** A duplicated subtree must have at least this many nodes to count (no trivial dup). */
|
|
305
|
+
dupMinNodes: number;
|
|
306
|
+
/** A subtree recurring this many times is flagged. */
|
|
307
|
+
dupRepeatWarn: number;
|
|
308
|
+
/** Cap on findings per check. */
|
|
309
|
+
topN: number;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Defaults against RN/Expo norms, not round numbers: a 200+ line component, 40+
|
|
313
|
+
* JSX nodes, depth past 7, or 10+ hooks/props in one file is worth a second look. */
|
|
314
|
+
export const DEFAULT_COMPONENT_BUDGET: ComponentBudget = {
|
|
315
|
+
linesWarn: 200,
|
|
316
|
+
jsxCountWarn: 40,
|
|
317
|
+
depthWarn: 7,
|
|
318
|
+
hookCountWarn: 10,
|
|
319
|
+
propCountWarn: 10,
|
|
320
|
+
dupMinNodes: 5,
|
|
321
|
+
dupRepeatWarn: 3,
|
|
322
|
+
topN: 12,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
function countNodes(n: JsxNode): number {
|
|
326
|
+
return 1 + n.children.reduce((s, c) => s + countNodes(c), 0);
|
|
327
|
+
}
|
|
328
|
+
function maxDepth(n: JsxNode): number {
|
|
329
|
+
return 1 + n.children.reduce((d, c) => Math.max(d, maxDepth(c)), 0);
|
|
330
|
+
}
|
|
331
|
+
function treeStat(roots: JsxNode[], f: (n: JsxNode) => number): number {
|
|
332
|
+
return roots.reduce((m, r) => Math.max(m, f(r)), 0);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Per-component size/complexity gate. Each tripped dimension is a split *candidate*
|
|
337
|
+
* (warn), located at the component, with a split-component fix.
|
|
338
|
+
*/
|
|
339
|
+
export function scanComponentSize(
|
|
340
|
+
components: ComponentDescriptor[],
|
|
341
|
+
budget: ComponentBudget = DEFAULT_COMPONENT_BUDGET,
|
|
342
|
+
): AuditFinding[] {
|
|
343
|
+
const findings: AuditFinding[] = [];
|
|
344
|
+
for (const c of components) {
|
|
345
|
+
const jsxCount = c.jsx.reduce((s, r) => s + countNodes(r), 0);
|
|
346
|
+
const depth = treeStat(c.jsx, maxDepth);
|
|
347
|
+
const at: SourceLoc[] = [{ file: c.file, line: c.line }];
|
|
348
|
+
const fix = {
|
|
349
|
+
kind: 'split-component' as const,
|
|
350
|
+
summary: `Split ${c.name} into smaller components`,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const dims: Array<[boolean, AuditFinding['check'], string]> = [
|
|
354
|
+
[c.lines >= budget.linesWarn, 'component-size', `is ${c.lines} lines`],
|
|
355
|
+
[jsxCount >= budget.jsxCountWarn, 'component-size', `has ${jsxCount} JSX nodes`],
|
|
356
|
+
[c.props.length >= budget.propCountWarn, 'component-size', `takes ${c.props.length} props`],
|
|
357
|
+
[depth >= budget.depthWarn, 'jsx-depth', `nests JSX ${depth} deep`],
|
|
358
|
+
[c.hooks.length >= budget.hookCountWarn, 'hook-count', `uses ${c.hooks.length} hooks`],
|
|
359
|
+
];
|
|
360
|
+
for (const [tripped, check, why] of dims) {
|
|
361
|
+
if (!tripped) continue;
|
|
362
|
+
findings.push({
|
|
363
|
+
severity: 'warn',
|
|
364
|
+
check,
|
|
365
|
+
source: `${c.file}:${c.line}`,
|
|
366
|
+
detail: `${c.name} ${why} — a split candidate, not a verdict (decomposition is your call)`,
|
|
367
|
+
occurrences: at,
|
|
368
|
+
fix,
|
|
369
|
+
verify: VERIFY,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return findings.slice(0, budget.topN);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** A stable structural signature of a JSX subtree — tag nesting only, text-free. */
|
|
377
|
+
function structuralSig(n: JsxNode): string {
|
|
378
|
+
return n.children.length ? `${n.tag}(${n.children.map(structuralSig).join(',')})` : n.tag;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Structural duplication: identical JSX subtrees (above the size floor) recurring
|
|
383
|
+
* across the codebase — copy-paste that should become a shared component.
|
|
384
|
+
*/
|
|
385
|
+
export function scanDuplicateJsx(
|
|
386
|
+
components: ComponentDescriptor[],
|
|
387
|
+
budget: ComponentBudget = DEFAULT_COMPONENT_BUDGET,
|
|
388
|
+
): AuditFinding[] {
|
|
389
|
+
const groups = new Map<string, { size: number; locs: SourceLoc[] }>();
|
|
390
|
+
for (const c of components) {
|
|
391
|
+
const visit = (n: JsxNode): void => {
|
|
392
|
+
const size = countNodes(n);
|
|
393
|
+
if (size >= budget.dupMinNodes) {
|
|
394
|
+
const sig = structuralSig(n);
|
|
395
|
+
const g = groups.get(sig);
|
|
396
|
+
if (g) g.locs.push({ file: c.file, line: n.line });
|
|
397
|
+
else groups.set(sig, { size, locs: [{ file: c.file, line: n.line }] });
|
|
398
|
+
}
|
|
399
|
+
for (const child of n.children) visit(child);
|
|
400
|
+
};
|
|
401
|
+
for (const root of c.jsx) visit(root);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return [...groups.values()]
|
|
405
|
+
.filter((g) => g.locs.length >= budget.dupRepeatWarn)
|
|
406
|
+
.sort((a, b) => b.size * b.locs.length - a.size * a.locs.length)
|
|
407
|
+
.slice(0, budget.topN)
|
|
408
|
+
.map((g) => {
|
|
409
|
+
const screens = new Set(
|
|
410
|
+
g.locs.filter((l) => classifyRole(l.file) === 'screen').map((l) => l.file),
|
|
411
|
+
);
|
|
412
|
+
const acrossScreens = screens.size >= 2;
|
|
413
|
+
return {
|
|
414
|
+
severity: 'warn' as const,
|
|
415
|
+
check: 'duplicate-jsx' as const,
|
|
416
|
+
source: `${g.locs.length}×${g.size}-node`,
|
|
417
|
+
detail:
|
|
418
|
+
`a ${g.size}-node JSX structure is repeated at ${g.locs.length} sites` +
|
|
419
|
+
(acrossScreens ? ` across ${screens.size} screens` : '') +
|
|
420
|
+
` — extract a reusable component` +
|
|
421
|
+
(acrossScreens ? ' (e.g. into packages/ui)' : ''),
|
|
422
|
+
occurrences: g.locs,
|
|
423
|
+
fix: {
|
|
424
|
+
kind: 'extract-component' as const,
|
|
425
|
+
summary: `Extract the repeated ${g.size}-node structure into one reusable component`,
|
|
426
|
+
},
|
|
427
|
+
verify: VERIFY,
|
|
428
|
+
};
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Whole-component structural duplication: N components with an identical JSX
|
|
434
|
+
* structure are one component waiting to happen. The stronger sibling of
|
|
435
|
+
* scanDuplicateJsx — it compares entire components, not subtrees, so it catches
|
|
436
|
+
* "these two screens/cards are the same, parameterize the difference".
|
|
437
|
+
*/
|
|
438
|
+
export function scanDuplicateComponents(
|
|
439
|
+
components: ComponentDescriptor[],
|
|
440
|
+
budget: ComponentBudget = DEFAULT_COMPONENT_BUDGET,
|
|
441
|
+
): AuditFinding[] {
|
|
442
|
+
const groups = new Map<string, ComponentDescriptor[]>();
|
|
443
|
+
for (const c of components) {
|
|
444
|
+
const jsxCount = c.jsx.reduce((s, r) => s + countNodes(r), 0);
|
|
445
|
+
if (jsxCount < budget.dupMinNodes) continue; // skip trivial components
|
|
446
|
+
const sig = c.jsx.map(structuralSig).join('|');
|
|
447
|
+
if (!sig) continue;
|
|
448
|
+
const g = groups.get(sig);
|
|
449
|
+
if (g) g.push(c);
|
|
450
|
+
else groups.set(sig, [c]);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return [...groups.values()]
|
|
454
|
+
.filter((g) => g.length >= 2)
|
|
455
|
+
.sort((a, b) => b.length - a.length)
|
|
456
|
+
.slice(0, budget.topN)
|
|
457
|
+
.map((g) => {
|
|
458
|
+
const names = g.map((c) => c.name).join(', ');
|
|
459
|
+
return {
|
|
460
|
+
severity: 'warn' as const,
|
|
461
|
+
check: 'duplicate-component' as const,
|
|
462
|
+
source: names,
|
|
463
|
+
detail: `${g.length} components share an identical structure (${names}) — unify into one reusable component (parameterize what differs), ideally in packages/ui`,
|
|
464
|
+
occurrences: g.map((c) => ({ file: c.file, line: c.line })),
|
|
465
|
+
fix: {
|
|
466
|
+
kind: 'unify-component' as const,
|
|
467
|
+
summary: `Replace ${names} with one parameterized component in a shared ui package`,
|
|
468
|
+
},
|
|
469
|
+
verify: VERIFY,
|
|
470
|
+
};
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The combined source-side entry — what `ui:audit` and the MCP call. */
|
|
475
|
+
export function auditComponents(
|
|
476
|
+
components: ComponentDescriptor[],
|
|
477
|
+
budget: UiBloatBudget = DEFAULT_UI_BLOAT_BUDGET,
|
|
478
|
+
): AuditFinding[] {
|
|
479
|
+
return [
|
|
480
|
+
...scanComponentRepeatedStyles(components, budget),
|
|
481
|
+
...scanComponentHardcodedColors(components, budget),
|
|
482
|
+
...scanUiReimplementation(components),
|
|
483
|
+
...scanComponentSize(components),
|
|
484
|
+
...scanDuplicateComponents(components),
|
|
485
|
+
...scanDuplicateJsx(components),
|
|
486
|
+
];
|
|
487
|
+
}
|