@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.4",
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",
@@ -1,16 +1,23 @@
1
1
  /**
2
2
  * Public audit surface — `@everystack/cli/audit`.
3
3
  *
4
- * The governance MCP imports these pure scanners directly to gate authoring and
5
- * pre-deploy: the secret-leak checks (bundle-audit) and the weight/size checks
6
- * (bundle-weight), plus the bundle composition helpers they build on. Pure,
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';
@@ -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
+ }