@everystack/cli 0.3.4 → 0.3.6
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 +7 -3
- package/src/cli/audit-api.ts +28 -3
- package/src/cli/audit-source-api.ts +36 -0
- package/src/cli/bundle-audit.ts +41 -1
- package/src/cli/commands/audit.ts +16 -0
- package/src/cli/commands/bundle.ts +12 -3
- 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 +8 -1
- package/src/cli/ui-bloat.ts +198 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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>",
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
"types": "./src/cli/audit-api.ts",
|
|
34
34
|
"default": "./src/cli/audit-api.ts"
|
|
35
35
|
},
|
|
36
|
+
"./audit/source": {
|
|
37
|
+
"types": "./src/cli/audit-source-api.ts",
|
|
38
|
+
"default": "./src/cli/audit-source-api.ts"
|
|
39
|
+
},
|
|
36
40
|
"./client": {
|
|
37
41
|
"types": "./src/client/index.ts",
|
|
38
42
|
"default": "./src/client/index.ts"
|
|
@@ -69,6 +73,7 @@
|
|
|
69
73
|
"node-forge": "1.4.0",
|
|
70
74
|
"structured-headers": "1.0.1",
|
|
71
75
|
"tsx": "4.21.0",
|
|
76
|
+
"typescript": "5.9.3",
|
|
72
77
|
"@everystack/model": "0.3.3"
|
|
73
78
|
},
|
|
74
79
|
"peerDependencies": {
|
|
@@ -135,8 +140,7 @@
|
|
|
135
140
|
"drizzle-orm": "0.41.0",
|
|
136
141
|
"jest": "29.7.0",
|
|
137
142
|
"react": "19.2.6",
|
|
138
|
-
"ts-jest": "29.4.9"
|
|
139
|
-
"typescript": "5.9.3"
|
|
143
|
+
"ts-jest": "29.4.9"
|
|
140
144
|
},
|
|
141
145
|
"scripts": {
|
|
142
146
|
"test": "jest",
|
package/src/cli/audit-api.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Public audit surface — `@everystack/cli/audit`.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
* (bundle-weight), plus the bundle composition helpers they build on. Pure,
|
|
4
|
+
* The bundle-side scanners: secret-leak (bundle-audit), weight/size (bundle-weight),
|
|
5
|
+
* remote UI-bloat (ui-bloat), and the composition helpers they build on. Pure,
|
|
7
6
|
* filesystem-free, no CLI/IO coupling — feed them text or {path, size} lists.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: this barrel is kept TYPESCRIPT-FREE on purpose. The source-side AST
|
|
9
|
+
* scanners (component-audit / component-model) pull in the `typescript` compiler, so
|
|
10
|
+
* they live behind `@everystack/cli/audit/source` instead. The governance MCP imports
|
|
11
|
+
* this light barrel (e.g. SECRET_PATTERNS) and bundles small; importing the source
|
|
12
|
+
* subpath would drag the ~10MB compiler into the hook hot-path.
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
15
|
export {
|
|
11
16
|
// types
|
|
12
17
|
type AuditFinding,
|
|
13
18
|
type SecretPattern,
|
|
19
|
+
type SourceLoc,
|
|
20
|
+
type FixDescriptor,
|
|
14
21
|
// leak scanners
|
|
15
22
|
SECRET_PATTERNS,
|
|
16
23
|
redact,
|
|
@@ -40,6 +47,24 @@ export {
|
|
|
40
47
|
resolveWeightBudget,
|
|
41
48
|
} from './bundle-weight.js';
|
|
42
49
|
|
|
50
|
+
export {
|
|
51
|
+
// types
|
|
52
|
+
type UiBloatBudget,
|
|
53
|
+
// UI-bloat scanners (remote / bundle side of the design-bloat loop)
|
|
54
|
+
DEFAULT_UI_BLOAT_BUDGET,
|
|
55
|
+
canonicalizeStyle,
|
|
56
|
+
isDesignStyle,
|
|
57
|
+
extractStyleLiterals,
|
|
58
|
+
scanRepeatedStyles,
|
|
59
|
+
scanHardcodedColors,
|
|
60
|
+
scanBundleUiBloat,
|
|
61
|
+
resolveUiBloatBudget,
|
|
62
|
+
} from './ui-bloat.js';
|
|
63
|
+
|
|
64
|
+
// NOTE: the source-side AST scanners (component-audit + component-model) moved to
|
|
65
|
+
// `@everystack/cli/audit/source` — they pull in `typescript`, which this barrel must
|
|
66
|
+
// stay free of. See audit-source-api.ts.
|
|
67
|
+
|
|
43
68
|
export {
|
|
44
69
|
// composition (input to scanDependencyWeight)
|
|
45
70
|
type Composition,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-side audit surface — `@everystack/cli/audit/source`.
|
|
3
|
+
*
|
|
4
|
+
* The `.tsx` AST scanners: the faithful component model (`component-model`) and the
|
|
5
|
+
* source-side UI-bloat checks built on it (`component-audit`). These pull in the
|
|
6
|
+
* `typescript` compiler, so they are kept OUT of the light `@everystack/cli/audit`
|
|
7
|
+
* barrel and exposed here instead. Import this only when you need to parse/analyze
|
|
8
|
+
* component source — never from a hot path (it drags ~10MB of compiler).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
// source-side UI-bloat checks (located + fixable + self-verifying)
|
|
13
|
+
type ComponentBudget,
|
|
14
|
+
type FileRole,
|
|
15
|
+
DEFAULT_COMPONENT_BUDGET,
|
|
16
|
+
classifyRole,
|
|
17
|
+
scanComponentRepeatedStyles,
|
|
18
|
+
scanComponentHardcodedColors,
|
|
19
|
+
scanUiReimplementation,
|
|
20
|
+
discoverUiExports,
|
|
21
|
+
scanComponentSize,
|
|
22
|
+
scanDuplicateJsx,
|
|
23
|
+
scanDuplicateComponents,
|
|
24
|
+
auditComponents,
|
|
25
|
+
} from './component-audit.js';
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
// types — the faithful component model (shared with the 5b frontend Module)
|
|
29
|
+
type ComponentDescriptor,
|
|
30
|
+
type ImportRef,
|
|
31
|
+
type JsxNode,
|
|
32
|
+
type StyleLiteral,
|
|
33
|
+
// the source-side foundation: parse/discover .tsx into neutral descriptors
|
|
34
|
+
parseComponents,
|
|
35
|
+
discoverComponents,
|
|
36
|
+
} from './component-model.js';
|
package/src/cli/bundle-audit.ts
CHANGED
|
@@ -17,6 +17,29 @@
|
|
|
17
17
|
* command's operator-run path.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
/** A source location an agent can open and edit. */
|
|
21
|
+
export interface SourceLoc {
|
|
22
|
+
file: string;
|
|
23
|
+
line: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A machine-actionable remediation for a finding. Source-side findings carry one
|
|
27
|
+
* so the agent can apply it without re-deriving the fix from prose. */
|
|
28
|
+
export interface FixDescriptor {
|
|
29
|
+
kind:
|
|
30
|
+
| 'extract-component'
|
|
31
|
+
| 'replace-with-token'
|
|
32
|
+
| 'use-ui-component'
|
|
33
|
+
| 'split-component'
|
|
34
|
+
| 'unify-component';
|
|
35
|
+
summary: string;
|
|
36
|
+
/** extract-component: a suggested name for the new component. */
|
|
37
|
+
suggestedName?: string;
|
|
38
|
+
/** replace-with-token: the raw value to replace and the token to use. */
|
|
39
|
+
from?: string;
|
|
40
|
+
to?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
20
43
|
export interface AuditFinding {
|
|
21
44
|
severity: 'fail' | 'warn';
|
|
22
45
|
check:
|
|
@@ -30,9 +53,26 @@ export interface AuditFinding {
|
|
|
30
53
|
| 'total-weight'
|
|
31
54
|
| 'data-in-bundle'
|
|
32
55
|
| 'js-weight'
|
|
33
|
-
| 'dep-weight'
|
|
56
|
+
| 'dep-weight'
|
|
57
|
+
// UI-bloat checks (ui-bloat.ts / component-audit.ts)
|
|
58
|
+
| 'ui-repeated-style'
|
|
59
|
+
| 'ui-hardcoded-color'
|
|
60
|
+
| 'ui-reimplementation'
|
|
61
|
+
| 'component-size'
|
|
62
|
+
| 'jsx-depth'
|
|
63
|
+
| 'hook-count'
|
|
64
|
+
| 'duplicate-jsx'
|
|
65
|
+
| 'duplicate-component';
|
|
34
66
|
source: string;
|
|
35
67
|
detail: string;
|
|
68
|
+
/** Source-side only: every place the smell occurs (the remote bundle cannot give
|
|
69
|
+
* these — it is mangled and pathless). */
|
|
70
|
+
occurrences?: SourceLoc[];
|
|
71
|
+
/** Source-side only: how to fix it. */
|
|
72
|
+
fix?: FixDescriptor;
|
|
73
|
+
/** How to confirm the fix landed. Uniform: re-run the audit; the finding clears.
|
|
74
|
+
* The auditor is its own verifier. */
|
|
75
|
+
verify?: string;
|
|
36
76
|
}
|
|
37
77
|
|
|
38
78
|
export interface SecretPattern {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
12
12
|
import { runBundleAudit, runBundleWeightAudit, reportBundleAudit } from './bundle.js';
|
|
13
|
+
import { runUiAudit, reportUiAudit } from './ui-audit.js';
|
|
13
14
|
import { resolveWeightBudget } from '../bundle-weight.js';
|
|
14
15
|
import { runSecurityProbe, reportProbeResults } from './security-probe.js';
|
|
15
16
|
import { auditDeployedSql, printReport, loadWaivers } from './security.js';
|
|
@@ -89,6 +90,21 @@ export async function auditCommand(flags: Record<string, string>): Promise<void>
|
|
|
89
90
|
warn(`bundle:audit skipped: ${err.message}`);
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
// --- ui:audit (source-side design bloat) — advisory, never gates ---------
|
|
94
|
+
// Runs over the app source dir (--ui-dir, else cwd); skipped silently if there's
|
|
95
|
+
// no source here (e.g. running the capstone against a remote stage only).
|
|
96
|
+
const uiDir = flags['ui-dir'] || '.';
|
|
97
|
+
console.log('');
|
|
98
|
+
info('── ui:audit ─────────────────────────────────────────────────');
|
|
99
|
+
try {
|
|
100
|
+
const result = runUiAudit(uiDir, flags);
|
|
101
|
+
console.log('');
|
|
102
|
+
reportUiAudit(result); // advisory — does not add to `failures`
|
|
103
|
+
ran.push('ui:audit (advisory)');
|
|
104
|
+
} catch (err: any) {
|
|
105
|
+
warn(`ui:audit skipped: ${err.message}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
92
108
|
// --- audit:lighthouse (perf/SEO) — opt-in; heavy and informational -------
|
|
93
109
|
if (flags.lighthouse) {
|
|
94
110
|
console.log('');
|
|
@@ -43,6 +43,11 @@ import {
|
|
|
43
43
|
type AssetFile,
|
|
44
44
|
type WeightBudget,
|
|
45
45
|
} from '../bundle-weight.js';
|
|
46
|
+
import {
|
|
47
|
+
scanBundleUiBloat,
|
|
48
|
+
DEFAULT_UI_BLOAT_BUDGET,
|
|
49
|
+
type UiBloatBudget,
|
|
50
|
+
} from '../ui-bloat.js';
|
|
46
51
|
import { resolveConfig } from '../config.js';
|
|
47
52
|
import { fail, info, step, success, warn } from '../output.js';
|
|
48
53
|
|
|
@@ -176,7 +181,7 @@ export interface BundleAuditResult {
|
|
|
176
181
|
/** Result-producing core, shared by the command wrapper and the `audit` capstone. */
|
|
177
182
|
export async function runBundleAudit(
|
|
178
183
|
base: string,
|
|
179
|
-
opts: { valuesFrom?: string; weight?: boolean; budget?: WeightBudget } = {},
|
|
184
|
+
opts: { valuesFrom?: string; weight?: boolean; budget?: WeightBudget; uiBudget?: UiBloatBudget } = {},
|
|
180
185
|
): Promise<BundleAuditResult> {
|
|
181
186
|
step(`Fetching ${base} ...`);
|
|
182
187
|
const root = await fetchText(base);
|
|
@@ -259,10 +264,13 @@ export async function runBundleAudit(
|
|
|
259
264
|
findings.push(...scanAssetWeights(files, budget));
|
|
260
265
|
findings.push(...scanTotalWeight(files, budget));
|
|
261
266
|
findings.push(...scanJsWeight(files, budget));
|
|
262
|
-
// Name the data inlined into JS chunks (the most useful diagnosis)
|
|
267
|
+
// Name the data inlined into JS chunks (the most useful diagnosis), and flag
|
|
268
|
+
// UI design-bloat (style repetition + hardcoded colors) in the same chunk text.
|
|
269
|
+
const uiBudget = opts.uiBudget || DEFAULT_UI_BLOAT_BUDGET;
|
|
263
270
|
for (const a of artifacts) {
|
|
264
271
|
if (classifyAsset(a.source) === 'js') {
|
|
265
272
|
findings.push(...analyzeChunkBloat(a.text, a.source, budget));
|
|
273
|
+
findings.push(...scanBundleUiBloat(a.text, a.source, uiBudget));
|
|
266
274
|
}
|
|
267
275
|
}
|
|
268
276
|
largest = largestFiles(files);
|
|
@@ -329,7 +337,7 @@ export function walkExport(root: string): AssetFile[] {
|
|
|
329
337
|
*/
|
|
330
338
|
export async function runBundleWeightAudit(
|
|
331
339
|
dir: string,
|
|
332
|
-
opts: { budget?: WeightBudget; valuesFrom?: string } = {},
|
|
340
|
+
opts: { budget?: WeightBudget; valuesFrom?: string; uiBudget?: UiBloatBudget } = {},
|
|
333
341
|
): Promise<BundleAuditResult> {
|
|
334
342
|
const root = path.resolve(dir);
|
|
335
343
|
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
@@ -359,6 +367,7 @@ export async function runBundleWeightAudit(
|
|
|
359
367
|
findings.push(...classifyPrivateEnvKeys(extractExtraEnvKeys(text), f.path));
|
|
360
368
|
if (classifyAsset(f.path) === 'js') {
|
|
361
369
|
findings.push(...analyzeChunkBloat(text, f.path, budget));
|
|
370
|
+
findings.push(...scanBundleUiBloat(text, f.path, opts.uiBudget || DEFAULT_UI_BLOAT_BUDGET));
|
|
362
371
|
}
|
|
363
372
|
if (opts.valuesFrom) {
|
|
364
373
|
const secrets = parseDotenv(fs.readFileSync(path.resolve(opts.valuesFrom), 'utf8'));
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack ui:audit [dir]` — source-side UI design-bloat audit.
|
|
3
|
+
*
|
|
4
|
+
* The local, actionable half of the design-bloat loop (the remote half rides in
|
|
5
|
+
* `bundle:audit`). It parses the app's `.tsx` source into the component model and
|
|
6
|
+
* runs the source-side checks: styles copy-pasted across call sites, hardcoded
|
|
7
|
+
* colors that should be tokens, raw primitives that reimplement a ui component,
|
|
8
|
+
* oversized components, and duplicated JSX. Every finding carries `file:line`
|
|
9
|
+
* occurrences and a fix the agent can apply, then verifies by re-running.
|
|
10
|
+
*
|
|
11
|
+
* Advisory by design — refactoring is taste, so this never gates (exit 0 even with
|
|
12
|
+
* findings). Config-free: defaults derived from the model, no per-app setup.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { discoverComponents } from '../component-model.js';
|
|
18
|
+
import { auditComponents } from '../component-audit.js';
|
|
19
|
+
import { resolveUiBloatBudget } from '../ui-bloat.js';
|
|
20
|
+
import { summarize, type AuditFinding } from '../bundle-audit.js';
|
|
21
|
+
import { fail, info, step, success, warn } from '../output.js';
|
|
22
|
+
|
|
23
|
+
export interface UiAuditResult {
|
|
24
|
+
findings: AuditFinding[];
|
|
25
|
+
componentCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Result-producing core, shared by the command and the `audit` capstone. */
|
|
29
|
+
export function runUiAudit(dir: string, flags: Record<string, string> = {}): UiAuditResult {
|
|
30
|
+
const root = path.resolve(dir);
|
|
31
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
32
|
+
throw new Error(`${dir} is not a directory — pass the app source dir (defaults to .).`);
|
|
33
|
+
}
|
|
34
|
+
const components = discoverComponents(root);
|
|
35
|
+
const findings = auditComponents(components, resolveUiBloatBudget(flags));
|
|
36
|
+
return { findings, componentCount: components.length };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Print ui:audit findings; returns the number of advisory warnings. */
|
|
40
|
+
export function reportUiAudit(result: UiAuditResult): number {
|
|
41
|
+
const { findings, componentCount } = result;
|
|
42
|
+
if (findings.length === 0) {
|
|
43
|
+
success(`ui:audit — clean. No design bloat across ${componentCount} component(s).`);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
for (const f of findings) {
|
|
47
|
+
warn(`[${f.check}] ${f.source}: ${f.detail}`);
|
|
48
|
+
if (f.fix) info(` fix: ${f.fix.summary}`);
|
|
49
|
+
if (f.occurrences && f.occurrences.length > 1) {
|
|
50
|
+
info(` at: ${f.occurrences.length} sites`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
console.log('');
|
|
54
|
+
const { warn: warns } = summarize(findings);
|
|
55
|
+
warn(`ui:audit — ${warns} advisory finding(s) across ${componentCount} component(s) (advisory; does not gate)`);
|
|
56
|
+
return warns;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function uiAuditCommand(
|
|
60
|
+
positional: string | undefined,
|
|
61
|
+
flags: Record<string, string>,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
const dir = positional || flags.dir || '.';
|
|
64
|
+
step(`Scanning components under ${dir} ...`);
|
|
65
|
+
let result: UiAuditResult;
|
|
66
|
+
try {
|
|
67
|
+
result = runUiAudit(dir, flags);
|
|
68
|
+
} catch (err: any) {
|
|
69
|
+
fail(err.message);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
console.log('');
|
|
73
|
+
reportUiAudit(result);
|
|
74
|
+
// Advisory only — refactoring is taste, so this never gates the build.
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component model — a faithful, neutral representation of `.tsx` source (Brick 5a-B).
|
|
3
|
+
*
|
|
4
|
+
* This is the shared foundation, NOT a private detail of the auditor. The UI-bloat
|
|
5
|
+
* checks consume it; so will the 5b frontend Module contract (checking screens +
|
|
6
|
+
* components against a contract). Its reusability comes from *fidelity*: it
|
|
7
|
+
* represents what a component actually IS — name, location, JSX tree, hooks, props,
|
|
8
|
+
* imports, inline style literals — not an audit-shaped projection of it. It knows
|
|
9
|
+
* nothing about `AuditFinding`, checks, budgets, or fixes. The dependency points one
|
|
10
|
+
* way: the auditor imports this model; this model imports nothing from the auditor.
|
|
11
|
+
*
|
|
12
|
+
* Syntactic only (no type checker): everything here is read off the AST, so it needs
|
|
13
|
+
* a single `createSourceFile` per file — no `tsconfig` resolution, no `Program`, fast
|
|
14
|
+
* and pure. `parseComponents(file, text)` is the pure core (no filesystem);
|
|
15
|
+
* `discoverComponents(dir)` is the thin filesystem walk over it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import ts from 'typescript';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { globSync } from 'glob';
|
|
21
|
+
|
|
22
|
+
/** A file-level import: `import def, { a, b }, * as ns from 'mod'`. */
|
|
23
|
+
export interface ImportRef {
|
|
24
|
+
from: string;
|
|
25
|
+
names: string[];
|
|
26
|
+
default?: string;
|
|
27
|
+
namespace?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A structural JSX node — tag identity, the element's own attributes and inline
|
|
31
|
+
* style, and its nested element children (text and non-JSX expressions are not
|
|
32
|
+
* structure). Map/conditional-wrapped JSX is descended into, so genuine reuse (one
|
|
33
|
+
* `.map`) stays one node. */
|
|
34
|
+
export interface JsxNode {
|
|
35
|
+
tag: string;
|
|
36
|
+
/** 1-based line of this element. */
|
|
37
|
+
line: number;
|
|
38
|
+
/** Attribute names present on this element (e.g. ['style', 'onPress']). */
|
|
39
|
+
attrs: string[];
|
|
40
|
+
/** Raw inline `style={{…}}` object literal on this element, if any. */
|
|
41
|
+
style?: string;
|
|
42
|
+
children: JsxNode[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An inline `style={{…}}` object literal and the line it sits on. */
|
|
46
|
+
export interface StyleLiteral {
|
|
47
|
+
text: string;
|
|
48
|
+
line: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A faithful model of one component. Source facts only — no audit concepts. */
|
|
52
|
+
export interface ComponentDescriptor {
|
|
53
|
+
name: string;
|
|
54
|
+
file: string;
|
|
55
|
+
/** 1-based line of the component declaration. */
|
|
56
|
+
line: number;
|
|
57
|
+
/** Line span of the component (for the size metric). */
|
|
58
|
+
lines: number;
|
|
59
|
+
jsx: JsxNode[];
|
|
60
|
+
hooks: string[];
|
|
61
|
+
props: string[];
|
|
62
|
+
imports: ImportRef[];
|
|
63
|
+
styles: StyleLiteral[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isJsx = (
|
|
67
|
+
n: ts.Node,
|
|
68
|
+
): n is ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment =>
|
|
69
|
+
ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n);
|
|
70
|
+
|
|
71
|
+
/** True if any JSX appears anywhere in the subtree — the test for "is a component". */
|
|
72
|
+
function containsJsx(node: ts.Node): boolean {
|
|
73
|
+
let found = false;
|
|
74
|
+
const walk = (n: ts.Node): void => {
|
|
75
|
+
if (found) return;
|
|
76
|
+
if (isJsx(n)) {
|
|
77
|
+
found = true;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
ts.forEachChild(n, walk);
|
|
81
|
+
};
|
|
82
|
+
ts.forEachChild(node, walk);
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectImports(sf: ts.SourceFile): ImportRef[] {
|
|
87
|
+
const out: ImportRef[] = [];
|
|
88
|
+
for (const stmt of sf.statements) {
|
|
89
|
+
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
90
|
+
const ref: ImportRef = { from: stmt.moduleSpecifier.text, names: [] };
|
|
91
|
+
const clause = stmt.importClause;
|
|
92
|
+
if (clause?.name) ref.default = clause.name.text;
|
|
93
|
+
const bindings = clause?.namedBindings;
|
|
94
|
+
if (bindings) {
|
|
95
|
+
if (ts.isNamespaceImport(bindings)) ref.namespace = bindings.name.text;
|
|
96
|
+
else for (const el of bindings.elements) ref.names.push(el.name.text);
|
|
97
|
+
}
|
|
98
|
+
out.push(ref);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function tagName(n: ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment): string {
|
|
104
|
+
if (ts.isJsxFragment(n)) return 'Fragment';
|
|
105
|
+
const tag = ts.isJsxElement(n) ? n.openingElement.tagName : n.tagName;
|
|
106
|
+
return tag.getText();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read an element's attribute names and its inline `style={{…}}` literal. */
|
|
110
|
+
function readAttrs(
|
|
111
|
+
n: ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment,
|
|
112
|
+
sf: ts.SourceFile,
|
|
113
|
+
): { attrs: string[]; style?: string } {
|
|
114
|
+
if (ts.isJsxFragment(n)) return { attrs: [] };
|
|
115
|
+
const props = (ts.isJsxElement(n) ? n.openingElement : n).attributes.properties;
|
|
116
|
+
const attrs: string[] = [];
|
|
117
|
+
let style: string | undefined;
|
|
118
|
+
for (const p of props) {
|
|
119
|
+
if (!ts.isJsxAttribute(p) || !ts.isIdentifier(p.name)) continue;
|
|
120
|
+
attrs.push(p.name.text);
|
|
121
|
+
if (
|
|
122
|
+
p.name.text === 'style' &&
|
|
123
|
+
p.initializer &&
|
|
124
|
+
ts.isJsxExpression(p.initializer) &&
|
|
125
|
+
p.initializer.expression &&
|
|
126
|
+
ts.isObjectLiteralExpression(p.initializer.expression)
|
|
127
|
+
) {
|
|
128
|
+
style = p.initializer.expression.getText(sf);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { attrs, style };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Build a faithful node for one JSX element, with its attrs, style, line, children. */
|
|
135
|
+
function makeNode(
|
|
136
|
+
n: ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment,
|
|
137
|
+
sf: ts.SourceFile,
|
|
138
|
+
): JsxNode {
|
|
139
|
+
const { attrs, style } = readAttrs(n, sf);
|
|
140
|
+
const node: JsxNode = {
|
|
141
|
+
tag: tagName(n),
|
|
142
|
+
line: sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1,
|
|
143
|
+
attrs,
|
|
144
|
+
children: jsxChildren(n, sf),
|
|
145
|
+
};
|
|
146
|
+
if (style !== undefined) node.style = style;
|
|
147
|
+
return node;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The element children of a JSX node, descending through expression containers
|
|
151
|
+
* ({cond && <X/>}, {items.map(=> <Y/>)}) so structure is captured but text is not. */
|
|
152
|
+
function jsxChildren(
|
|
153
|
+
n: ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment,
|
|
154
|
+
sf: ts.SourceFile,
|
|
155
|
+
): JsxNode[] {
|
|
156
|
+
if (ts.isJsxSelfClosingElement(n)) return [];
|
|
157
|
+
const kids: JsxNode[] = [];
|
|
158
|
+
const fromChild = (child: ts.Node): void => {
|
|
159
|
+
if (isJsx(child)) {
|
|
160
|
+
kids.push(makeNode(child, sf));
|
|
161
|
+
return; // don't descend past a JSX element — its own children handle that
|
|
162
|
+
}
|
|
163
|
+
// descend through JsxExpression / parens / map callbacks to find nested JSX
|
|
164
|
+
if (ts.isJsxText(child)) return;
|
|
165
|
+
ts.forEachChild(child, fromChild);
|
|
166
|
+
};
|
|
167
|
+
for (const child of n.children) fromChild(child);
|
|
168
|
+
return kids;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The outermost JSX nodes within a component body — its roots. */
|
|
172
|
+
function collectRoots(fn: ts.Node, sf: ts.SourceFile): JsxNode[] {
|
|
173
|
+
const roots: JsxNode[] = [];
|
|
174
|
+
const walk = (n: ts.Node): void => {
|
|
175
|
+
if (isJsx(n)) {
|
|
176
|
+
roots.push(makeNode(n, sf));
|
|
177
|
+
return; // stop — nested JSX is captured as children, not as another root
|
|
178
|
+
}
|
|
179
|
+
ts.forEachChild(n, walk);
|
|
180
|
+
};
|
|
181
|
+
ts.forEachChild(fn, walk);
|
|
182
|
+
return roots;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function collectHooks(fn: ts.Node): string[] {
|
|
186
|
+
const hooks: string[] = [];
|
|
187
|
+
const walk = (n: ts.Node): void => {
|
|
188
|
+
if (ts.isCallExpression(n)) {
|
|
189
|
+
const expr = n.expression;
|
|
190
|
+
const name = ts.isIdentifier(expr)
|
|
191
|
+
? expr.text
|
|
192
|
+
: ts.isPropertyAccessExpression(expr)
|
|
193
|
+
? expr.name.text
|
|
194
|
+
: '';
|
|
195
|
+
if (/^use[A-Z]/.test(name)) hooks.push(name);
|
|
196
|
+
}
|
|
197
|
+
ts.forEachChild(n, walk);
|
|
198
|
+
};
|
|
199
|
+
ts.forEachChild(fn, walk);
|
|
200
|
+
return hooks;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function collectProps(fn: ts.FunctionLikeDeclarationBase): string[] {
|
|
204
|
+
const param = fn.parameters[0];
|
|
205
|
+
if (!param) return [];
|
|
206
|
+
if (ts.isObjectBindingPattern(param.name)) {
|
|
207
|
+
return param.name.elements
|
|
208
|
+
.map((el) => (ts.isIdentifier(el.name) ? el.name.text : ''))
|
|
209
|
+
.filter(Boolean);
|
|
210
|
+
}
|
|
211
|
+
if (ts.isIdentifier(param.name)) return [param.name.text];
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function collectStyles(fn: ts.Node, sf: ts.SourceFile): StyleLiteral[] {
|
|
216
|
+
const styles: StyleLiteral[] = [];
|
|
217
|
+
const walk = (n: ts.Node): void => {
|
|
218
|
+
if (
|
|
219
|
+
ts.isJsxAttribute(n) &&
|
|
220
|
+
ts.isIdentifier(n.name) &&
|
|
221
|
+
n.name.text === 'style' &&
|
|
222
|
+
n.initializer &&
|
|
223
|
+
ts.isJsxExpression(n.initializer) &&
|
|
224
|
+
n.initializer.expression &&
|
|
225
|
+
ts.isObjectLiteralExpression(n.initializer.expression)
|
|
226
|
+
) {
|
|
227
|
+
const obj = n.initializer.expression;
|
|
228
|
+
styles.push({
|
|
229
|
+
text: obj.getText(sf),
|
|
230
|
+
line: sf.getLineAndCharacterOfPosition(obj.getStart(sf)).line + 1,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
ts.forEachChild(n, walk);
|
|
234
|
+
};
|
|
235
|
+
ts.forEachChild(fn, walk);
|
|
236
|
+
return styles;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Pure core: parse one `.tsx` file's source text into faithful component
|
|
241
|
+
* descriptors. No filesystem, no type checker.
|
|
242
|
+
*/
|
|
243
|
+
export function parseComponents(file: string, text: string): ComponentDescriptor[] {
|
|
244
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
245
|
+
const imports = collectImports(sf);
|
|
246
|
+
const out: ComponentDescriptor[] = [];
|
|
247
|
+
const lineOf = (pos: number) => sf.getLineAndCharacterOfPosition(pos).line + 1;
|
|
248
|
+
|
|
249
|
+
const consider = (name: string, declNode: ts.Node, fn: ts.FunctionLikeDeclarationBase): void => {
|
|
250
|
+
if (!containsJsx(fn)) return;
|
|
251
|
+
const start = declNode.getStart(sf);
|
|
252
|
+
out.push({
|
|
253
|
+
name,
|
|
254
|
+
file,
|
|
255
|
+
line: lineOf(start),
|
|
256
|
+
lines: lineOf(declNode.getEnd()) - lineOf(start) + 1,
|
|
257
|
+
jsx: collectRoots(fn, sf),
|
|
258
|
+
hooks: collectHooks(fn),
|
|
259
|
+
props: collectProps(fn),
|
|
260
|
+
imports,
|
|
261
|
+
styles: collectStyles(fn, sf),
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
for (const node of sf.statements) {
|
|
266
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
267
|
+
const name =
|
|
268
|
+
node.name?.text ??
|
|
269
|
+
(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ? 'default' : '');
|
|
270
|
+
if (name) consider(name, node, node);
|
|
271
|
+
} else if (ts.isVariableStatement(node)) {
|
|
272
|
+
for (const d of node.declarationList.declarations) {
|
|
273
|
+
if (
|
|
274
|
+
ts.isIdentifier(d.name) &&
|
|
275
|
+
d.initializer &&
|
|
276
|
+
(ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))
|
|
277
|
+
) {
|
|
278
|
+
consider(d.name.text, node, d.initializer);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} else if (ts.isExportAssignment(node)) {
|
|
282
|
+
const e = node.expression;
|
|
283
|
+
if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) consider('default', node, e);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Walk a directory for `.tsx` files (excluding node_modules) and parse each into
|
|
291
|
+
* component descriptors. The thin filesystem shell over `parseComponents`.
|
|
292
|
+
*/
|
|
293
|
+
export function discoverComponents(dir: string): ComponentDescriptor[] {
|
|
294
|
+
const files = globSync('**/*.tsx', {
|
|
295
|
+
cwd: dir,
|
|
296
|
+
absolute: true,
|
|
297
|
+
ignore: ['**/node_modules/**', '**/.expo/**', '**/dist/**'],
|
|
298
|
+
});
|
|
299
|
+
const out: ComponentDescriptor[] = [];
|
|
300
|
+
for (const file of files) {
|
|
301
|
+
out.push(...parseComponents(file, readFileSync(file, 'utf8')));
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { securityProbeCommand } from './commands/security-probe.js';
|
|
|
25
25
|
import { bundleAnalyzeCommand, bundleAuditCommand } from './commands/bundle.js';
|
|
26
26
|
import { auditLighthouseCommand } from './commands/lighthouse.js';
|
|
27
27
|
import { auditCommand } from './commands/audit.js';
|
|
28
|
+
import { uiAuditCommand } from './commands/ui-audit.js';
|
|
28
29
|
import { fail } from './output.js';
|
|
29
30
|
|
|
30
31
|
const args = process.argv.slice(2);
|
|
@@ -229,6 +230,11 @@ async function main() {
|
|
|
229
230
|
await bundleAuditCommand(positional, flags);
|
|
230
231
|
break;
|
|
231
232
|
}
|
|
233
|
+
case 'ui:audit': {
|
|
234
|
+
const positional = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
|
|
235
|
+
await uiAuditCommand(positional, flags);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
232
238
|
case 'secrets': {
|
|
233
239
|
// secrets <subcommand> [positional...] [--flags]
|
|
234
240
|
// Extract positional args: everything after subcommand that isn't a flag or flag value
|
|
@@ -289,7 +295,8 @@ Usage:
|
|
|
289
295
|
everystack audit --stage <name> [--host <url>] [--dir <export>] [--lighthouse] Capstone: security:audit + security:probe + bundle:audit (+ optional lighthouse), combined report + exit code (CI gate); --dir adds the weight/size gate over a local export
|
|
290
296
|
everystack bundle:analyze [dir] Client web-bundle size + composition (run after \`expo export -p web --source-maps\`)
|
|
291
297
|
everystack audit:lighthouse [--stage <name>] [--host <url>] [--runs 3] [--path /] [--json out.json] Mobile Lighthouse (perf/a11y/bp/seo), median over N runs
|
|
292
|
-
everystack bundle:audit [url|--dir <export>] [--stage <name>] [--values-from <.env>] [--leak-only] [--max-file-mb N] [--max-total-mb N] [--max-js-mb N] [--max-data-mb N] Audit a bundle for leaked secrets AND weight (oversized assets, data that belongs in the DB, JS bloat). A URL/stage weighs the remote bundle (assets sized via HEAD, no download); --dir scans a local export; --leak-only skips the weight gate
|
|
298
|
+
everystack bundle:audit [url|--dir <export>] [--stage <name>] [--values-from <.env>] [--leak-only] [--max-file-mb N] [--max-total-mb N] [--max-js-mb N] [--max-data-mb N] Audit a bundle for leaked secrets AND weight (oversized assets, data that belongs in the DB, JS bloat) AND UI design-bloat (style repetition + hardcoded colors). A URL/stage weighs the remote bundle (assets sized via HEAD, no download); --dir scans a local export; --leak-only skips the weight gate
|
|
299
|
+
everystack ui:audit [dir] [--ui-repeat N] [--ui-color-repeat N] Source-side UI design-bloat: styles copy-pasted across call sites, hardcoded colors that should be tokens, raw primitives reimplementing a ui component, oversized components, duplicated JSX. Located + fixable; advisory, never gates
|
|
293
300
|
everystack certs:generate [--output ./certs]
|
|
294
301
|
everystack certs:configure [--input ./certs] [--keyid main]
|
|
295
302
|
everystack cache:purge [--stage <name>] Bust all cached content (global epoch)
|
|
@@ -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
|
+
}
|