unslop-ui 0.1.0
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/README.md +84 -0
- package/dist/chunk-34KLV5KL.js +1051 -0
- package/dist/chunk-34KLV5KL.js.map +1 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +63 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +113 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
// src/config/load-config.ts
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { createJiti } from "jiti";
|
|
5
|
+
|
|
6
|
+
// src/config/default-config.ts
|
|
7
|
+
var defaultConfig = {
|
|
8
|
+
include: [
|
|
9
|
+
"app/**/*.{ts,tsx,js,jsx}",
|
|
10
|
+
"pages/**/*.{ts,tsx,js,jsx}",
|
|
11
|
+
"components/**/*.{ts,tsx,js,jsx}",
|
|
12
|
+
"src/**/*.{ts,tsx,js,jsx}"
|
|
13
|
+
],
|
|
14
|
+
ignore: [
|
|
15
|
+
"node_modules/**",
|
|
16
|
+
".next/**",
|
|
17
|
+
"dist/**",
|
|
18
|
+
"build/**",
|
|
19
|
+
"coverage/**",
|
|
20
|
+
"*.test.*",
|
|
21
|
+
"*.spec.*"
|
|
22
|
+
],
|
|
23
|
+
stack: {
|
|
24
|
+
react: true,
|
|
25
|
+
tailwind: true,
|
|
26
|
+
shadcn: true
|
|
27
|
+
},
|
|
28
|
+
rules: {
|
|
29
|
+
"ui-slop/token-bypass": "warn",
|
|
30
|
+
"ui-slop/arbitrary-tailwind-values": "warn",
|
|
31
|
+
"ui-slop/raw-hex-colors": "error",
|
|
32
|
+
"ui-slop/gradient-soup": "warn",
|
|
33
|
+
"ui-slop/card-lasagna": "warn",
|
|
34
|
+
"ui-slop/muted-everything": "warn",
|
|
35
|
+
"ui-slop/random-radius": "warn",
|
|
36
|
+
"ui-slop/random-shadow": "warn",
|
|
37
|
+
"ui-slop/shadcn-default-look": "warn",
|
|
38
|
+
"ui-slop/generic-ai-hero": "warn",
|
|
39
|
+
"ui-slop/inconsistent-icon-size": "warn"
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// src/config/schema.ts
|
|
44
|
+
import { z } from "zod";
|
|
45
|
+
var severitySchema = z.enum(["off", "warn", "error"]);
|
|
46
|
+
var configSchema = z.object({
|
|
47
|
+
include: z.array(z.string()).optional(),
|
|
48
|
+
ignore: z.array(z.string()).optional(),
|
|
49
|
+
stack: z.object({
|
|
50
|
+
react: z.boolean().optional(),
|
|
51
|
+
tailwind: z.boolean().optional(),
|
|
52
|
+
shadcn: z.boolean().optional()
|
|
53
|
+
}).optional(),
|
|
54
|
+
rules: z.record(z.string(), severitySchema).optional()
|
|
55
|
+
}).strict();
|
|
56
|
+
|
|
57
|
+
// src/config/load-config.ts
|
|
58
|
+
var CONFIG_FILES = [
|
|
59
|
+
"unslop.config.ts",
|
|
60
|
+
"unslop.config.mts",
|
|
61
|
+
"unslop.config.js",
|
|
62
|
+
"unslop.config.mjs",
|
|
63
|
+
"unslop.config.cjs",
|
|
64
|
+
"unslop.config.json"
|
|
65
|
+
];
|
|
66
|
+
async function loadConfig(options) {
|
|
67
|
+
const configPath = options.configPath ? path.resolve(options.cwd, options.configPath) : findConfigFile(options.cwd);
|
|
68
|
+
if (!configPath) {
|
|
69
|
+
return { config: defaultConfig };
|
|
70
|
+
}
|
|
71
|
+
if (!existsSync(configPath)) {
|
|
72
|
+
throw new Error(`Config file not found: ${configPath}`);
|
|
73
|
+
}
|
|
74
|
+
const rawConfig = await importConfig(configPath);
|
|
75
|
+
const parsed = configSchema.parse(rawConfig ?? {});
|
|
76
|
+
return {
|
|
77
|
+
config: mergeConfig(parsed),
|
|
78
|
+
configPath
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function findConfigFile(cwd) {
|
|
82
|
+
for (const fileName of CONFIG_FILES) {
|
|
83
|
+
const candidate = path.join(cwd, fileName);
|
|
84
|
+
if (existsSync(candidate)) {
|
|
85
|
+
return candidate;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return void 0;
|
|
89
|
+
}
|
|
90
|
+
async function importConfig(configPath) {
|
|
91
|
+
if (configPath.endsWith(".json")) {
|
|
92
|
+
return JSON.parse(readFileSync(configPath, "utf8"));
|
|
93
|
+
}
|
|
94
|
+
const jiti = createJiti(import.meta.url);
|
|
95
|
+
const loaded = await jiti.import(configPath, { default: true });
|
|
96
|
+
return loaded ?? {};
|
|
97
|
+
}
|
|
98
|
+
function mergeConfig(config) {
|
|
99
|
+
return {
|
|
100
|
+
include: config.include ?? defaultConfig.include,
|
|
101
|
+
ignore: config.ignore ?? defaultConfig.ignore,
|
|
102
|
+
stack: {
|
|
103
|
+
...defaultConfig.stack,
|
|
104
|
+
...config.stack
|
|
105
|
+
},
|
|
106
|
+
rules: {
|
|
107
|
+
...defaultConfig.rules,
|
|
108
|
+
...config.rules
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/utils/tailwind.ts
|
|
114
|
+
var PALETTE_COLORS = [
|
|
115
|
+
"slate",
|
|
116
|
+
"gray",
|
|
117
|
+
"zinc",
|
|
118
|
+
"neutral",
|
|
119
|
+
"stone",
|
|
120
|
+
"red",
|
|
121
|
+
"orange",
|
|
122
|
+
"amber",
|
|
123
|
+
"yellow",
|
|
124
|
+
"lime",
|
|
125
|
+
"green",
|
|
126
|
+
"emerald",
|
|
127
|
+
"teal",
|
|
128
|
+
"cyan",
|
|
129
|
+
"sky",
|
|
130
|
+
"blue",
|
|
131
|
+
"indigo",
|
|
132
|
+
"violet",
|
|
133
|
+
"purple",
|
|
134
|
+
"fuchsia",
|
|
135
|
+
"pink",
|
|
136
|
+
"rose"
|
|
137
|
+
];
|
|
138
|
+
var COLOR_SCALE = "(?:50|100|200|300|400|500|600|700|800|900|950)";
|
|
139
|
+
var PALETTE_PATTERN = new RegExp(
|
|
140
|
+
`^(?:bg|text|border|from|via|to|ring|fill|stroke)-(?:${PALETTE_COLORS.join("|")})-${COLOR_SCALE}$`
|
|
141
|
+
);
|
|
142
|
+
var MUTED_TEXT_PATTERN = /^(?:text-muted-foreground|text-(?:gray|slate|zinc|neutral)-[45]00)$/;
|
|
143
|
+
function splitClasses(value) {
|
|
144
|
+
return value.split(/\s+/).map((part) => part.trim()).filter(Boolean);
|
|
145
|
+
}
|
|
146
|
+
function isPaletteColorClass(className) {
|
|
147
|
+
return PALETTE_PATTERN.test(stripVariantPrefixes(className));
|
|
148
|
+
}
|
|
149
|
+
function isMutedTextClass(className) {
|
|
150
|
+
return MUTED_TEXT_PATTERN.test(stripVariantPrefixes(className));
|
|
151
|
+
}
|
|
152
|
+
function stripVariantPrefixes(className) {
|
|
153
|
+
const arbitraryVariantIndex = className.lastIndexOf("]:");
|
|
154
|
+
if (arbitraryVariantIndex >= 0) {
|
|
155
|
+
return className.slice(arbitraryVariantIndex + 2);
|
|
156
|
+
}
|
|
157
|
+
const parts = className.split(":");
|
|
158
|
+
return parts[parts.length - 1] ?? className;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/rules/helpers.ts
|
|
162
|
+
function createFinding(rule, context, options) {
|
|
163
|
+
return {
|
|
164
|
+
ruleId: rule.id,
|
|
165
|
+
severity: rule.defaultSeverity === "error" ? "error" : "warn",
|
|
166
|
+
filePath: context.filePath,
|
|
167
|
+
category: rule.category,
|
|
168
|
+
...options
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function allClasses(context) {
|
|
172
|
+
return context.classNames.flatMap((usage) => usage.classes);
|
|
173
|
+
}
|
|
174
|
+
function firstUsageLine(context, predicate) {
|
|
175
|
+
const usage = context.classNames.find((item) => item.classes.some(predicate));
|
|
176
|
+
return { line: usage?.line, column: usage?.column };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/rules/arbitrary-tailwind-values.ts
|
|
180
|
+
var arbitraryTailwindValuesRule = {
|
|
181
|
+
id: "ui-slop/arbitrary-tailwind-values",
|
|
182
|
+
name: "Arbitrary Tailwind values",
|
|
183
|
+
category: "design-system",
|
|
184
|
+
defaultSeverity: "warn",
|
|
185
|
+
run(context) {
|
|
186
|
+
const arbitraryClasses = allClasses(context).filter(
|
|
187
|
+
(className) => /\[[^\]]+\]/.test(stripVariantPrefixes(className))
|
|
188
|
+
);
|
|
189
|
+
if (arbitraryClasses.length < 3) return [];
|
|
190
|
+
const location = firstUsageLine(context, (className) => /\[[^\]]+\]/.test(className));
|
|
191
|
+
const stronger = arbitraryClasses.length >= 8;
|
|
192
|
+
return [
|
|
193
|
+
createFinding(this, context, {
|
|
194
|
+
...location,
|
|
195
|
+
message: stronger ? `Found ${arbitraryClasses.length} arbitrary Tailwind values. This strongly suggests non-systematic spacing, sizing, or color choices.` : `Found ${arbitraryClasses.length} arbitrary Tailwind values. This often indicates generated or non-systematic UI.`,
|
|
196
|
+
confidence: stronger ? "high" : "medium",
|
|
197
|
+
suggestion: "Prefer design tokens, Tailwind theme values, or a small documented exception."
|
|
198
|
+
})
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// src/rules/card-lasagna.ts
|
|
204
|
+
var cardLasagnaRule = {
|
|
205
|
+
id: "ui-slop/card-lasagna",
|
|
206
|
+
name: "Card lasagna",
|
|
207
|
+
category: "layout",
|
|
208
|
+
defaultSeverity: "warn",
|
|
209
|
+
run(context) {
|
|
210
|
+
const cardLike = context.jsx.filter((element) => getCardSignals(element.classes).size >= 2);
|
|
211
|
+
const deepCardLike = cardLike.filter((element) => element.depth >= 4);
|
|
212
|
+
if (cardLike.length < 6 && deepCardLike.length < 3) return [];
|
|
213
|
+
const first = cardLike[0];
|
|
214
|
+
return [
|
|
215
|
+
createFinding(this, context, {
|
|
216
|
+
line: first?.line,
|
|
217
|
+
column: first?.column,
|
|
218
|
+
message: `Nested card-heavy layout detected: ${cardLike.length} card-like wrappers use stacked border, radius, shadow, background, or padding treatments.`,
|
|
219
|
+
confidence: cardLike.length >= 9 || deepCardLike.length >= 3 ? "high" : "medium",
|
|
220
|
+
suggestion: "Flatten the hierarchy or reduce repeated borders, shadows, and rounded containers."
|
|
221
|
+
})
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
function getCardSignals(classes) {
|
|
226
|
+
const signals = /* @__PURE__ */ new Set();
|
|
227
|
+
for (const rawClass of classes) {
|
|
228
|
+
const className = stripVariantPrefixes(rawClass);
|
|
229
|
+
if (className === "card" || className === "bg-card" || className === "bg-white") {
|
|
230
|
+
signals.add("background");
|
|
231
|
+
}
|
|
232
|
+
if (className === "border" || className.startsWith("border-")) {
|
|
233
|
+
signals.add("border");
|
|
234
|
+
}
|
|
235
|
+
if (className === "rounded" || className.startsWith("rounded-")) {
|
|
236
|
+
signals.add("radius");
|
|
237
|
+
}
|
|
238
|
+
if (className === "shadow" || className.startsWith("shadow-")) {
|
|
239
|
+
signals.add("shadow");
|
|
240
|
+
}
|
|
241
|
+
if (/^p[trblxy]?-(?:\d+|\[[^\]]+\])$/.test(className)) {
|
|
242
|
+
signals.add("padding");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return signals;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/rules/generic-ai-hero.ts
|
|
249
|
+
var GENERIC_WORDS = [
|
|
250
|
+
"ai-powered",
|
|
251
|
+
"supercharge",
|
|
252
|
+
"transform",
|
|
253
|
+
"beautiful",
|
|
254
|
+
"modern",
|
|
255
|
+
"seamless",
|
|
256
|
+
"workflow",
|
|
257
|
+
"ship faster"
|
|
258
|
+
];
|
|
259
|
+
var genericAiHeroRule = {
|
|
260
|
+
id: "ui-slop/generic-ai-hero",
|
|
261
|
+
name: "Generic AI hero",
|
|
262
|
+
category: "ai-slop",
|
|
263
|
+
defaultSeverity: "warn",
|
|
264
|
+
run(context) {
|
|
265
|
+
const classes = allClasses(context).map(stripVariantPrefixes);
|
|
266
|
+
const sourceText = context.jsx.map((element) => element.text).join(" ").toLowerCase();
|
|
267
|
+
const sourceLower = context.source.toLowerCase();
|
|
268
|
+
let score = 0;
|
|
269
|
+
if (classes.includes("text-center") && classes.some((className) => /^max-w-[34]xl$/.test(className))) {
|
|
270
|
+
score += 2;
|
|
271
|
+
}
|
|
272
|
+
if (classes.some((className) => /^text-(?:5xl|6xl|7xl)$/.test(className)) || classes.includes("tracking-tight")) {
|
|
273
|
+
score += 1;
|
|
274
|
+
}
|
|
275
|
+
if (classes.includes("bg-clip-text") && classes.includes("text-transparent")) {
|
|
276
|
+
score += 2;
|
|
277
|
+
}
|
|
278
|
+
if (classes.some((className) => /^(?:rounded-full|border|px-[23]|py-1)$/.test(className)) && /badge|pill/i.test(context.source)) {
|
|
279
|
+
score += 1;
|
|
280
|
+
}
|
|
281
|
+
if ((sourceLower.match(/<button|<Button|role=["']button|href=/g) ?? []).length >= 2) {
|
|
282
|
+
score += 1;
|
|
283
|
+
}
|
|
284
|
+
if (GENERIC_WORDS.some((word) => sourceText.includes(word))) {
|
|
285
|
+
score += 2;
|
|
286
|
+
}
|
|
287
|
+
if (score < 5) return [];
|
|
288
|
+
const firstHero = context.jsx.find((element) => ["section", "main", "header"].includes(element.elementName));
|
|
289
|
+
return [
|
|
290
|
+
createFinding(this, context, {
|
|
291
|
+
line: firstHero?.line,
|
|
292
|
+
column: firstHero?.column,
|
|
293
|
+
message: "Hero matches a common AI-generated SaaS pattern. Make the layout and copy more specific to the product.",
|
|
294
|
+
confidence: score >= 7 ? "high" : "medium",
|
|
295
|
+
suggestion: "Replace generic badge/headline/gradient/dual-CTA structure with product-specific proof and layout."
|
|
296
|
+
})
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// src/rules/gradient-soup.ts
|
|
302
|
+
var AI_GRADIENT_COLORS = /^(?:from|via|to)-(?:purple|pink|blue|indigo|violet|fuchsia|cyan)-/;
|
|
303
|
+
var gradientSoupRule = {
|
|
304
|
+
id: "ui-slop/gradient-soup",
|
|
305
|
+
name: "Gradient soup",
|
|
306
|
+
category: "ai-slop",
|
|
307
|
+
defaultSeverity: "warn",
|
|
308
|
+
run(context) {
|
|
309
|
+
const classes = allClasses(context).map(stripVariantPrefixes);
|
|
310
|
+
const gradientUtilities = classes.filter(
|
|
311
|
+
(className) => className.startsWith("bg-gradient-to-") || /^(?:from|via|to)-/.test(className)
|
|
312
|
+
);
|
|
313
|
+
const glowUtilities = classes.filter(
|
|
314
|
+
(className) => /^(?:blur-[23]xl|blur-\[[^\]]+\]|opacity-[3-9]0)$/.test(className)
|
|
315
|
+
);
|
|
316
|
+
const hasAiGradientColors = classes.some((className) => AI_GRADIENT_COLORS.test(className));
|
|
317
|
+
if (gradientUtilities.length < 3 && !(gradientUtilities.length >= 2 && glowUtilities.length > 0)) {
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
return [
|
|
321
|
+
createFinding(this, context, {
|
|
322
|
+
...firstUsageLine(
|
|
323
|
+
context,
|
|
324
|
+
(className) => stripVariantPrefixes(className).startsWith("bg-gradient-to-")
|
|
325
|
+
),
|
|
326
|
+
message: hasAiGradientColors && glowUtilities.length > 0 ? "Gradient-heavy styling with glow utilities detected. This is a common AI-generated SaaS UI pattern." : "Gradient-heavy styling detected. This is a common AI-generated SaaS UI pattern.",
|
|
327
|
+
confidence: hasAiGradientColors ? "high" : "medium",
|
|
328
|
+
suggestion: "Use gradients sparingly and tie color choices back to the product visual system."
|
|
329
|
+
})
|
|
330
|
+
];
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// src/rules/inconsistent-icon-size.ts
|
|
335
|
+
var inconsistentIconSizeRule = {
|
|
336
|
+
id: "ui-slop/inconsistent-icon-size",
|
|
337
|
+
name: "Inconsistent icon size",
|
|
338
|
+
category: "components",
|
|
339
|
+
defaultSeverity: "warn",
|
|
340
|
+
run(context) {
|
|
341
|
+
const classes = allClasses(context).map(stripVariantPrefixes);
|
|
342
|
+
const directSizes = classes.map((className) => /^size-(\d+)$/.exec(className)?.[1]).filter((value) => Boolean(value));
|
|
343
|
+
const heightSizes = classes.map((className) => /^h-(\d+)$/.exec(className)?.[1]).filter((value) => Boolean(value));
|
|
344
|
+
const widthSizes = classes.map((className) => /^w-(\d+)$/.exec(className)?.[1]).filter((value) => Boolean(value));
|
|
345
|
+
const pairedSizes = heightSizes.filter((size) => widthSizes.includes(size));
|
|
346
|
+
const uniqueSizes = [.../* @__PURE__ */ new Set([...directSizes, ...pairedSizes])];
|
|
347
|
+
if (uniqueSizes.length < 3) return [];
|
|
348
|
+
return [
|
|
349
|
+
createFinding(this, context, {
|
|
350
|
+
...firstUsageLine(context, (className) => /^(?:size|h|w)-\d+$/.test(stripVariantPrefixes(className))),
|
|
351
|
+
message: `Inconsistent icon sizing detected (${uniqueSizes.map((size) => `${size}`).join(", ")}). Use a smaller set of icon sizes for better rhythm.`,
|
|
352
|
+
confidence: uniqueSizes.length >= 4 ? "high" : "medium",
|
|
353
|
+
suggestion: "Standardize icon sizes by role, such as 16px for dense controls and 20px for primary actions."
|
|
354
|
+
})
|
|
355
|
+
];
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// src/rules/muted-everything.ts
|
|
360
|
+
var mutedEverythingRule = {
|
|
361
|
+
id: "ui-slop/muted-everything",
|
|
362
|
+
name: "Muted everything",
|
|
363
|
+
category: "typography",
|
|
364
|
+
defaultSeverity: "warn",
|
|
365
|
+
run(context) {
|
|
366
|
+
const mutedTextClasses = allClasses(context).filter(isMutedTextClass);
|
|
367
|
+
const textBearingElements = context.jsx.filter((element) => element.text.length > 0);
|
|
368
|
+
const mutedRatio = textBearingElements.length > 0 ? mutedTextClasses.length / textBearingElements.length : 0;
|
|
369
|
+
if (mutedTextClasses.length < 4 && !(mutedTextClasses.length >= 3 && mutedRatio >= 0.5)) {
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
return [
|
|
373
|
+
createFinding(this, context, {
|
|
374
|
+
...firstUsageLine(context, isMutedTextClass),
|
|
375
|
+
message: `Muted text is overused (${mutedTextClasses.length} muted text utilities). Primary explanatory copy usually needs stronger contrast.`,
|
|
376
|
+
confidence: mutedRatio >= 0.5 ? "high" : "medium",
|
|
377
|
+
suggestion: "Reserve muted text for secondary metadata, helper copy, and de-emphasized labels."
|
|
378
|
+
})
|
|
379
|
+
];
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// src/rules/random-radius.ts
|
|
384
|
+
var randomRadiusRule = {
|
|
385
|
+
id: "ui-slop/random-radius",
|
|
386
|
+
name: "Random radius",
|
|
387
|
+
category: "design-system",
|
|
388
|
+
defaultSeverity: "warn",
|
|
389
|
+
run(context) {
|
|
390
|
+
const radiusClasses = [
|
|
391
|
+
...new Set(
|
|
392
|
+
allClasses(context).map(stripVariantPrefixes).filter(
|
|
393
|
+
(className) => /^rounded(?:$|-(?:none|sm|md|lg|xl|2xl|3xl|full|\[[^\]]+\]|[trbl][trbl]?-.+))/.test(
|
|
394
|
+
className
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
];
|
|
399
|
+
if (radiusClasses.length <= 4) return [];
|
|
400
|
+
return [
|
|
401
|
+
createFinding(this, context, {
|
|
402
|
+
...firstUsageLine(context, (className) => stripVariantPrefixes(className).startsWith("rounded")),
|
|
403
|
+
message: `Too many border-radius variants found (${radiusClasses.length}). This creates inconsistent UI rhythm.`,
|
|
404
|
+
confidence: radiusClasses.length >= 7 ? "high" : "medium",
|
|
405
|
+
suggestion: "Standardize around a small radius scale for repeated surfaces and controls."
|
|
406
|
+
})
|
|
407
|
+
];
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// src/rules/random-shadow.ts
|
|
412
|
+
var randomShadowRule = {
|
|
413
|
+
id: "ui-slop/random-shadow",
|
|
414
|
+
name: "Random shadow",
|
|
415
|
+
category: "design-system",
|
|
416
|
+
defaultSeverity: "warn",
|
|
417
|
+
run(context) {
|
|
418
|
+
const shadowClasses = allClasses(context).map(stripVariantPrefixes).filter((className) => /^shadow(?:$|-(?:sm|md|lg|xl|2xl|inner|none|\[[^\]]+\]))$/.test(className));
|
|
419
|
+
const uniqueShadowClasses = [...new Set(shadowClasses)];
|
|
420
|
+
const heavyShadowCount = shadowClasses.filter((className) => ["shadow-xl", "shadow-2xl"].includes(className)).length;
|
|
421
|
+
if (uniqueShadowClasses.length <= 3 && heavyShadowCount < 2) return [];
|
|
422
|
+
return [
|
|
423
|
+
createFinding(this, context, {
|
|
424
|
+
...firstUsageLine(context, (className) => stripVariantPrefixes(className).startsWith("shadow")),
|
|
425
|
+
message: "Inconsistent or heavy shadow usage detected.",
|
|
426
|
+
confidence: uniqueShadowClasses.length >= 5 || heavyShadowCount >= 3 ? "high" : "medium",
|
|
427
|
+
suggestion: "Use a smaller shadow scale and avoid heavy shadows on repeated surfaces."
|
|
428
|
+
})
|
|
429
|
+
];
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// src/utils/source-location.ts
|
|
434
|
+
function getLineAndColumn(source, index) {
|
|
435
|
+
let line = 1;
|
|
436
|
+
let column = 1;
|
|
437
|
+
for (let i = 0; i < index; i += 1) {
|
|
438
|
+
if (source.charCodeAt(i) === 10) {
|
|
439
|
+
line += 1;
|
|
440
|
+
column = 1;
|
|
441
|
+
} else {
|
|
442
|
+
column += 1;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return { line, column };
|
|
446
|
+
}
|
|
447
|
+
function firstRegexLocation(source, pattern) {
|
|
448
|
+
const flags = pattern.flags.replaceAll("g", "").replaceAll("y", "");
|
|
449
|
+
const regex = new RegExp(pattern.source, flags);
|
|
450
|
+
const match = regex.exec(source);
|
|
451
|
+
if (!match || match.index === void 0) return void 0;
|
|
452
|
+
return getLineAndColumn(source, match.index);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// src/rules/raw-hex-colors.ts
|
|
456
|
+
var HEX_COLOR_PATTERN = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
|
|
457
|
+
var rawHexColorsRule = {
|
|
458
|
+
id: "ui-slop/raw-hex-colors",
|
|
459
|
+
name: "Raw hex colors",
|
|
460
|
+
category: "design-system",
|
|
461
|
+
defaultSeverity: "error",
|
|
462
|
+
run(context) {
|
|
463
|
+
HEX_COLOR_PATTERN.lastIndex = 0;
|
|
464
|
+
const matches = [...context.source.matchAll(HEX_COLOR_PATTERN)];
|
|
465
|
+
if (matches.length === 0) return [];
|
|
466
|
+
return [
|
|
467
|
+
createFinding(this, context, {
|
|
468
|
+
...firstRegexLocation(context.source, HEX_COLOR_PATTERN),
|
|
469
|
+
message: matches.length === 1 ? "Raw hex color found. Prefer semantic tokens or Tailwind theme colors." : `Found ${matches.length} raw hex colors. Prefer semantic tokens or Tailwind theme colors.`,
|
|
470
|
+
confidence: "high",
|
|
471
|
+
suggestion: "Move color choices into semantic tokens such as bg-card, text-foreground, or primary."
|
|
472
|
+
})
|
|
473
|
+
];
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// src/rules/shadcn-default-look.ts
|
|
478
|
+
var shadcnDefaultLookRule = {
|
|
479
|
+
id: "ui-slop/shadcn-default-look",
|
|
480
|
+
name: "shadcn default look",
|
|
481
|
+
category: "components",
|
|
482
|
+
defaultSeverity: "warn",
|
|
483
|
+
run(context) {
|
|
484
|
+
const defaultishElements = context.jsx.filter((element) => {
|
|
485
|
+
const classes = new Set(element.classes.map(stripVariantPrefixes));
|
|
486
|
+
let score = 0;
|
|
487
|
+
if (classes.has("rounded-lg")) score += 1;
|
|
488
|
+
if (classes.has("border")) score += 1;
|
|
489
|
+
if (classes.has("bg-card")) score += 1;
|
|
490
|
+
if (classes.has("text-card-foreground")) score += 1;
|
|
491
|
+
if (classes.has("shadow-sm")) score += 1;
|
|
492
|
+
if (classes.has("text-muted-foreground")) score += 1;
|
|
493
|
+
if (classes.has("space-y-1.5")) score += 1;
|
|
494
|
+
return score >= 3;
|
|
495
|
+
});
|
|
496
|
+
const mutedDefaults = context.classNames.filter(
|
|
497
|
+
(usage) => usage.classes.map(stripVariantPrefixes).includes("text-muted-foreground")
|
|
498
|
+
);
|
|
499
|
+
if (defaultishElements.length < 2 || mutedDefaults.length < 2) return [];
|
|
500
|
+
const first = defaultishElements[0];
|
|
501
|
+
return [
|
|
502
|
+
createFinding(this, context, {
|
|
503
|
+
line: first?.line,
|
|
504
|
+
column: first?.column,
|
|
505
|
+
message: "UI appears close to default shadcn styling. Consider applying product-specific visual language.",
|
|
506
|
+
confidence: defaultishElements.length >= 4 ? "high" : "medium",
|
|
507
|
+
suggestion: "Keep the component behavior, but tune spacing, type, density, and surface treatments."
|
|
508
|
+
})
|
|
509
|
+
];
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// src/rules/token-bypass.ts
|
|
514
|
+
var tokenBypassRule = {
|
|
515
|
+
id: "ui-slop/token-bypass",
|
|
516
|
+
name: "Token bypass",
|
|
517
|
+
category: "design-system",
|
|
518
|
+
defaultSeverity: "warn",
|
|
519
|
+
run(context) {
|
|
520
|
+
const directPaletteColors = allClasses(context).filter(isPaletteColorClass);
|
|
521
|
+
const uniquePaletteColors = [...new Set(directPaletteColors)];
|
|
522
|
+
if (uniquePaletteColors.length < 5) return [];
|
|
523
|
+
return [
|
|
524
|
+
createFinding(this, context, {
|
|
525
|
+
...firstUsageLine(context, isPaletteColorClass),
|
|
526
|
+
message: `Tailwind palette colors are used directly ${uniquePaletteColors.length} times. Prefer semantic design tokens like bg-card, text-muted-foreground, border-border, primary, or secondary.`,
|
|
527
|
+
confidence: uniquePaletteColors.length >= 9 ? "high" : "medium",
|
|
528
|
+
suggestion: "Map repeated palette choices to semantic tokens in your theme."
|
|
529
|
+
})
|
|
530
|
+
];
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
// src/rules/index.ts
|
|
535
|
+
var allRules = [
|
|
536
|
+
rawHexColorsRule,
|
|
537
|
+
arbitraryTailwindValuesRule,
|
|
538
|
+
tokenBypassRule,
|
|
539
|
+
gradientSoupRule,
|
|
540
|
+
cardLasagnaRule,
|
|
541
|
+
mutedEverythingRule,
|
|
542
|
+
randomRadiusRule,
|
|
543
|
+
randomShadowRule,
|
|
544
|
+
shadcnDefaultLookRule,
|
|
545
|
+
genericAiHeroRule,
|
|
546
|
+
inconsistentIconSizeRule
|
|
547
|
+
];
|
|
548
|
+
|
|
549
|
+
// src/scanner/scan-project.ts
|
|
550
|
+
import { readFile } from "fs/promises";
|
|
551
|
+
import path3 from "path";
|
|
552
|
+
|
|
553
|
+
// src/core/score.ts
|
|
554
|
+
function calculateScore(findings) {
|
|
555
|
+
const errorCount = findings.filter((finding) => finding.severity === "error").length;
|
|
556
|
+
const warnCount = findings.filter((finding) => finding.severity === "warn").length;
|
|
557
|
+
const lowConfidenceCount = findings.filter((finding) => finding.confidence === "low").length;
|
|
558
|
+
return Math.max(0, 100 - errorCount * 8 - warnCount * 3 - lowConfidenceCount);
|
|
559
|
+
}
|
|
560
|
+
function getScoreLabel(score) {
|
|
561
|
+
if (score >= 90) return "Clean";
|
|
562
|
+
if (score >= 75) return "Minor slop";
|
|
563
|
+
if (score >= 60) return "Noticeable slop";
|
|
564
|
+
if (score >= 40) return "Heavy slop";
|
|
565
|
+
return "Slop emergency";
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/core/rule-engine.ts
|
|
569
|
+
function runRules(context, rules) {
|
|
570
|
+
const findings = [];
|
|
571
|
+
for (const rule of rules) {
|
|
572
|
+
const configuredSeverity = context.project.config.rules[rule.id] ?? rule.defaultSeverity;
|
|
573
|
+
if (configuredSeverity === "off") {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
for (const finding of rule.run(context)) {
|
|
577
|
+
findings.push({
|
|
578
|
+
...finding,
|
|
579
|
+
severity: configuredSeverity
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return findings.sort((a, b) => {
|
|
584
|
+
const severityDelta = severityRank(b.severity) - severityRank(a.severity);
|
|
585
|
+
if (severityDelta !== 0) return severityDelta;
|
|
586
|
+
return `${a.filePath}:${a.line ?? 0}:${a.ruleId}`.localeCompare(
|
|
587
|
+
`${b.filePath}:${b.line ?? 0}:${b.ruleId}`
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
function severityRank(severity) {
|
|
592
|
+
return severity === "error" ? 2 : 1;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// src/core/types.ts
|
|
596
|
+
var TOOL_NAME = "unslop-ui";
|
|
597
|
+
var TOOL_VERSION = "0.1.0";
|
|
598
|
+
|
|
599
|
+
// src/utils/paths.ts
|
|
600
|
+
import path2 from "path";
|
|
601
|
+
function toPosixPath(filePath) {
|
|
602
|
+
return filePath.split(path2.sep).join("/");
|
|
603
|
+
}
|
|
604
|
+
function relativePosixPath(root, filePath) {
|
|
605
|
+
return toPosixPath(path2.relative(root, filePath));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/scanner/extract-classnames.ts
|
|
609
|
+
var CLASS_HELPERS = /* @__PURE__ */ new Set(["cn", "clsx", "classnames", "cva"]);
|
|
610
|
+
function extractClassNameUsages(ast, filePath) {
|
|
611
|
+
const usages = [];
|
|
612
|
+
walk(ast, (node) => {
|
|
613
|
+
if (!isRecord(node) || node.type !== "JSXOpeningElement") return;
|
|
614
|
+
const elementName = getJsxName(node.name);
|
|
615
|
+
const attributes = Array.isArray(node.attributes) ? node.attributes : [];
|
|
616
|
+
for (const attribute of attributes) {
|
|
617
|
+
if (!isRecord(attribute) || attribute.type !== "JSXAttribute") continue;
|
|
618
|
+
if (!isRecord(attribute.name) || attribute.name.name !== "className") continue;
|
|
619
|
+
const values = extractClassNameValues(attribute.value);
|
|
620
|
+
for (const value of values) {
|
|
621
|
+
const classes = splitClasses(value);
|
|
622
|
+
if (classes.length === 0) continue;
|
|
623
|
+
usages.push({
|
|
624
|
+
value,
|
|
625
|
+
classes,
|
|
626
|
+
filePath,
|
|
627
|
+
line: getLine(attribute),
|
|
628
|
+
column: getColumn(attribute),
|
|
629
|
+
elementName
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
return usages;
|
|
635
|
+
}
|
|
636
|
+
function extractClassNameValues(node) {
|
|
637
|
+
if (!isRecord(node)) return [];
|
|
638
|
+
switch (node.type) {
|
|
639
|
+
case "StringLiteral":
|
|
640
|
+
return typeof node.value === "string" ? [node.value] : [];
|
|
641
|
+
case "JSXExpressionContainer":
|
|
642
|
+
return extractClassNameValues(node.expression);
|
|
643
|
+
case "TemplateLiteral":
|
|
644
|
+
return [
|
|
645
|
+
...getTemplateQuasis(node),
|
|
646
|
+
...getArray(node.expressions).flatMap((expression) => extractClassNameValues(expression))
|
|
647
|
+
];
|
|
648
|
+
case "CallExpression": {
|
|
649
|
+
const calleeName = getCalleeName(node.callee);
|
|
650
|
+
if (!calleeName || !CLASS_HELPERS.has(calleeName)) return [];
|
|
651
|
+
return getArray(node.arguments).flatMap((argument) => extractClassNameValues(argument));
|
|
652
|
+
}
|
|
653
|
+
case "ConditionalExpression":
|
|
654
|
+
return [
|
|
655
|
+
...extractClassNameValues(node.consequent),
|
|
656
|
+
...extractClassNameValues(node.alternate)
|
|
657
|
+
];
|
|
658
|
+
case "LogicalExpression":
|
|
659
|
+
return extractClassNameValues(node.left).concat(extractClassNameValues(node.right));
|
|
660
|
+
case "ArrayExpression":
|
|
661
|
+
return getArray(node.elements).flatMap((element) => extractClassNameValues(element));
|
|
662
|
+
case "ObjectExpression":
|
|
663
|
+
return getArray(node.properties).flatMap((property) => {
|
|
664
|
+
if (!isRecord(property) || property.type !== "ObjectProperty") return [];
|
|
665
|
+
return extractObjectKey(property.key);
|
|
666
|
+
});
|
|
667
|
+
case "BinaryExpression":
|
|
668
|
+
if (node.operator !== "+") return [];
|
|
669
|
+
return extractClassNameValues(node.left).concat(extractClassNameValues(node.right));
|
|
670
|
+
default:
|
|
671
|
+
return [];
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function walk(node, visitor) {
|
|
675
|
+
if (!isRecord(node)) return;
|
|
676
|
+
visitor(node);
|
|
677
|
+
for (const [key, value] of Object.entries(node)) {
|
|
678
|
+
if (shouldSkipKey(key)) continue;
|
|
679
|
+
if (Array.isArray(value)) {
|
|
680
|
+
for (const item of value) {
|
|
681
|
+
if (isAstNode(item)) walk(item, visitor);
|
|
682
|
+
}
|
|
683
|
+
} else if (isAstNode(value)) {
|
|
684
|
+
walk(value, visitor);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function extractObjectKey(node) {
|
|
689
|
+
if (!isRecord(node)) return [];
|
|
690
|
+
if (node.type === "StringLiteral" && typeof node.value === "string") return [node.value];
|
|
691
|
+
if (node.type === "Identifier" && typeof node.name === "string") return [node.name];
|
|
692
|
+
return [];
|
|
693
|
+
}
|
|
694
|
+
function getTemplateQuasis(node) {
|
|
695
|
+
return getArray(node.quasis).map((quasi) => {
|
|
696
|
+
if (!isRecord(quasi) || !isRecord(quasi.value)) return "";
|
|
697
|
+
return typeof quasi.value.cooked === "string" ? quasi.value.cooked : "";
|
|
698
|
+
}).filter(Boolean);
|
|
699
|
+
}
|
|
700
|
+
function getCalleeName(node) {
|
|
701
|
+
if (!isRecord(node)) return void 0;
|
|
702
|
+
if (node.type === "Identifier" && typeof node.name === "string") return node.name;
|
|
703
|
+
if (node.type === "MemberExpression" && isRecord(node.property)) {
|
|
704
|
+
return typeof node.property.name === "string" ? node.property.name : void 0;
|
|
705
|
+
}
|
|
706
|
+
return void 0;
|
|
707
|
+
}
|
|
708
|
+
function getJsxName(node) {
|
|
709
|
+
if (!isRecord(node)) return void 0;
|
|
710
|
+
if (node.type === "JSXIdentifier" && typeof node.name === "string") return node.name;
|
|
711
|
+
if (node.type === "JSXMemberExpression") {
|
|
712
|
+
const objectName = getJsxName(node.object);
|
|
713
|
+
const propertyName = getJsxName(node.property);
|
|
714
|
+
return [objectName, propertyName].filter(Boolean).join(".");
|
|
715
|
+
}
|
|
716
|
+
return void 0;
|
|
717
|
+
}
|
|
718
|
+
function getLine(node) {
|
|
719
|
+
return isRecord(node.loc) && isRecord(node.loc.start) && typeof node.loc.start.line === "number" ? node.loc.start.line : void 0;
|
|
720
|
+
}
|
|
721
|
+
function getColumn(node) {
|
|
722
|
+
return isRecord(node.loc) && isRecord(node.loc.start) && typeof node.loc.start.column === "number" ? node.loc.start.column + 1 : void 0;
|
|
723
|
+
}
|
|
724
|
+
function shouldSkipKey(key) {
|
|
725
|
+
return [
|
|
726
|
+
"loc",
|
|
727
|
+
"start",
|
|
728
|
+
"end",
|
|
729
|
+
"extra",
|
|
730
|
+
"leadingComments",
|
|
731
|
+
"trailingComments",
|
|
732
|
+
"innerComments"
|
|
733
|
+
].includes(key);
|
|
734
|
+
}
|
|
735
|
+
function isAstNode(value) {
|
|
736
|
+
return isRecord(value) && typeof value.type === "string";
|
|
737
|
+
}
|
|
738
|
+
function getArray(value) {
|
|
739
|
+
return Array.isArray(value) ? value : [];
|
|
740
|
+
}
|
|
741
|
+
function isRecord(value) {
|
|
742
|
+
return typeof value === "object" && value !== null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// src/scanner/extract-jsx.ts
|
|
746
|
+
function extractJsxUsages(ast, filePath) {
|
|
747
|
+
const usages = [];
|
|
748
|
+
walkJsx(ast, filePath, usages, 0);
|
|
749
|
+
return usages;
|
|
750
|
+
}
|
|
751
|
+
function walkJsx(node, filePath, usages, depth) {
|
|
752
|
+
if (!isRecord2(node)) return;
|
|
753
|
+
if (node.type === "JSXElement") {
|
|
754
|
+
const opening = isRecord2(node.openingElement) ? node.openingElement : void 0;
|
|
755
|
+
const className = opening ? getClassName(opening) : void 0;
|
|
756
|
+
const classes = className ? splitClasses(className) : [];
|
|
757
|
+
usages.push({
|
|
758
|
+
elementName: opening ? getJsxName2(opening.name) ?? "unknown" : "unknown",
|
|
759
|
+
classes,
|
|
760
|
+
className,
|
|
761
|
+
filePath,
|
|
762
|
+
line: opening ? getLine2(opening) : void 0,
|
|
763
|
+
column: opening ? getColumn2(opening) : void 0,
|
|
764
|
+
depth,
|
|
765
|
+
text: collectText(node)
|
|
766
|
+
});
|
|
767
|
+
for (const child of getArray2(node.children)) {
|
|
768
|
+
walkJsx(child, filePath, usages, depth + 1);
|
|
769
|
+
}
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
for (const [key, value] of Object.entries(node)) {
|
|
773
|
+
if (shouldSkipKey2(key)) continue;
|
|
774
|
+
if (Array.isArray(value)) {
|
|
775
|
+
for (const item of value) {
|
|
776
|
+
if (isAstNode2(item)) walkJsx(item, filePath, usages, depth);
|
|
777
|
+
}
|
|
778
|
+
} else if (isAstNode2(value)) {
|
|
779
|
+
walkJsx(value, filePath, usages, depth);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function getClassName(openingElement) {
|
|
784
|
+
const attributes = getArray2(openingElement.attributes);
|
|
785
|
+
for (const attribute of attributes) {
|
|
786
|
+
if (!isRecord2(attribute) || attribute.type !== "JSXAttribute") continue;
|
|
787
|
+
if (!isRecord2(attribute.name) || attribute.name.name !== "className") continue;
|
|
788
|
+
return extractClassNameValues(attribute.value).join(" ").trim() || void 0;
|
|
789
|
+
}
|
|
790
|
+
return void 0;
|
|
791
|
+
}
|
|
792
|
+
function collectText(node) {
|
|
793
|
+
if (!isRecord2(node)) return "";
|
|
794
|
+
if (node.type === "JSXElement") {
|
|
795
|
+
return getArray2(node.children).map((child) => collectText(child)).join(" ").replace(/\s+/g, " ").trim();
|
|
796
|
+
}
|
|
797
|
+
if (node.type === "JSXExpressionContainer") {
|
|
798
|
+
return collectText(node.expression);
|
|
799
|
+
}
|
|
800
|
+
if (node.type === "JSXText" && typeof node.value === "string") {
|
|
801
|
+
return node.value;
|
|
802
|
+
}
|
|
803
|
+
if (node.type === "StringLiteral" && typeof node.value === "string") {
|
|
804
|
+
return node.value;
|
|
805
|
+
}
|
|
806
|
+
if (node.type === "TemplateLiteral") {
|
|
807
|
+
return getArray2(node.quasis).map((quasi) => {
|
|
808
|
+
if (!isRecord2(quasi) || !isRecord2(quasi.value)) return "";
|
|
809
|
+
return typeof quasi.value.cooked === "string" ? quasi.value.cooked : "";
|
|
810
|
+
}).join(" ");
|
|
811
|
+
}
|
|
812
|
+
let text = "";
|
|
813
|
+
for (const [key, value] of Object.entries(node)) {
|
|
814
|
+
if (shouldSkipKey2(key)) continue;
|
|
815
|
+
if (Array.isArray(value)) {
|
|
816
|
+
text += ` ${value.map((item) => collectText(item)).join(" ")}`;
|
|
817
|
+
} else if (isAstNode2(value)) {
|
|
818
|
+
text += ` ${collectText(value)}`;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return text.replace(/\s+/g, " ").trim();
|
|
822
|
+
}
|
|
823
|
+
function getJsxName2(node) {
|
|
824
|
+
if (!isRecord2(node)) return void 0;
|
|
825
|
+
if (node.type === "JSXIdentifier" && typeof node.name === "string") return node.name;
|
|
826
|
+
if (node.type === "JSXMemberExpression") {
|
|
827
|
+
const objectName = getJsxName2(node.object);
|
|
828
|
+
const propertyName = getJsxName2(node.property);
|
|
829
|
+
return [objectName, propertyName].filter(Boolean).join(".");
|
|
830
|
+
}
|
|
831
|
+
return void 0;
|
|
832
|
+
}
|
|
833
|
+
function getLine2(node) {
|
|
834
|
+
return isRecord2(node.loc) && isRecord2(node.loc.start) && typeof node.loc.start.line === "number" ? node.loc.start.line : void 0;
|
|
835
|
+
}
|
|
836
|
+
function getColumn2(node) {
|
|
837
|
+
return isRecord2(node.loc) && isRecord2(node.loc.start) && typeof node.loc.start.column === "number" ? node.loc.start.column + 1 : void 0;
|
|
838
|
+
}
|
|
839
|
+
function shouldSkipKey2(key) {
|
|
840
|
+
return [
|
|
841
|
+
"loc",
|
|
842
|
+
"start",
|
|
843
|
+
"end",
|
|
844
|
+
"extra",
|
|
845
|
+
"leadingComments",
|
|
846
|
+
"trailingComments",
|
|
847
|
+
"innerComments"
|
|
848
|
+
].includes(key);
|
|
849
|
+
}
|
|
850
|
+
function isAstNode2(value) {
|
|
851
|
+
return isRecord2(value) && typeof value.type === "string";
|
|
852
|
+
}
|
|
853
|
+
function getArray2(value) {
|
|
854
|
+
return Array.isArray(value) ? value : [];
|
|
855
|
+
}
|
|
856
|
+
function isRecord2(value) {
|
|
857
|
+
return typeof value === "object" && value !== null;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// src/scanner/file-discovery.ts
|
|
861
|
+
import fg from "fast-glob";
|
|
862
|
+
async function discoverFiles(root, config) {
|
|
863
|
+
const files = await fg(config.include, {
|
|
864
|
+
cwd: root,
|
|
865
|
+
absolute: true,
|
|
866
|
+
onlyFiles: true,
|
|
867
|
+
ignore: config.ignore,
|
|
868
|
+
dot: false
|
|
869
|
+
});
|
|
870
|
+
return files.sort((a, b) => toPosixPath(a).localeCompare(toPosixPath(b)));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// src/scanner/parse-file.ts
|
|
874
|
+
import { parse } from "@babel/parser";
|
|
875
|
+
function parseFile(source, filePath) {
|
|
876
|
+
try {
|
|
877
|
+
return {
|
|
878
|
+
ast: parse(source, {
|
|
879
|
+
sourceFilename: filePath,
|
|
880
|
+
sourceType: "unambiguous",
|
|
881
|
+
errorRecovery: true,
|
|
882
|
+
plugins: ["jsx", "typescript", "decorators-legacy"]
|
|
883
|
+
})
|
|
884
|
+
};
|
|
885
|
+
} catch (error) {
|
|
886
|
+
return {
|
|
887
|
+
ast: null,
|
|
888
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// src/scanner/scan-project.ts
|
|
894
|
+
async function scanProject(targetPath = ".", options = {}) {
|
|
895
|
+
const cwd = options.cwd ? path3.resolve(options.cwd) : process.cwd();
|
|
896
|
+
const root = path3.resolve(cwd, targetPath);
|
|
897
|
+
const loaded = await loadConfig({
|
|
898
|
+
cwd: root,
|
|
899
|
+
configPath: options.configPath ? path3.isAbsolute(options.configPath) ? options.configPath : path3.resolve(cwd, options.configPath) : void 0
|
|
900
|
+
});
|
|
901
|
+
const files = await discoverFiles(root, loaded.config);
|
|
902
|
+
const project = {
|
|
903
|
+
root,
|
|
904
|
+
config: loaded.config,
|
|
905
|
+
files: files.map((file) => relativePosixPath(root, file))
|
|
906
|
+
};
|
|
907
|
+
const findings = [];
|
|
908
|
+
for (const absolutePath of files) {
|
|
909
|
+
const source = await readFile(absolutePath, "utf8");
|
|
910
|
+
const filePath = relativePosixPath(root, absolutePath);
|
|
911
|
+
const parsed = parseFile(source, filePath);
|
|
912
|
+
const classNames = parsed.ast ? extractClassNameUsages(parsed.ast, filePath) : [];
|
|
913
|
+
const jsx = parsed.ast ? extractJsxUsages(parsed.ast, filePath) : [];
|
|
914
|
+
findings.push(
|
|
915
|
+
...runRules(
|
|
916
|
+
{
|
|
917
|
+
filePath,
|
|
918
|
+
absolutePath,
|
|
919
|
+
source,
|
|
920
|
+
classNames,
|
|
921
|
+
jsx,
|
|
922
|
+
project
|
|
923
|
+
},
|
|
924
|
+
allRules
|
|
925
|
+
)
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
const score = calculateScore(findings);
|
|
929
|
+
return {
|
|
930
|
+
tool: TOOL_NAME,
|
|
931
|
+
version: TOOL_VERSION,
|
|
932
|
+
root,
|
|
933
|
+
score,
|
|
934
|
+
label: getScoreLabel(score),
|
|
935
|
+
filesScanned: files.length,
|
|
936
|
+
findingCount: findings.length,
|
|
937
|
+
findings
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// src/reporters/json.ts
|
|
942
|
+
function formatJsonReport(result) {
|
|
943
|
+
return `${JSON.stringify(
|
|
944
|
+
{
|
|
945
|
+
tool: result.tool,
|
|
946
|
+
version: result.version,
|
|
947
|
+
score: result.score,
|
|
948
|
+
label: result.label,
|
|
949
|
+
filesScanned: result.filesScanned,
|
|
950
|
+
findingCount: result.findingCount,
|
|
951
|
+
findings: result.findings.map((finding) => ({
|
|
952
|
+
ruleId: finding.ruleId,
|
|
953
|
+
severity: finding.severity,
|
|
954
|
+
category: finding.category,
|
|
955
|
+
confidence: finding.confidence,
|
|
956
|
+
filePath: finding.filePath,
|
|
957
|
+
line: finding.line,
|
|
958
|
+
column: finding.column,
|
|
959
|
+
message: finding.message,
|
|
960
|
+
suggestion: finding.suggestion
|
|
961
|
+
}))
|
|
962
|
+
},
|
|
963
|
+
null,
|
|
964
|
+
2
|
|
965
|
+
)}
|
|
966
|
+
`;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// src/reporters/terminal.ts
|
|
970
|
+
import pc from "picocolors";
|
|
971
|
+
var CATEGORY_LABELS = {
|
|
972
|
+
"design-system": "Design system",
|
|
973
|
+
"ai-slop": "AI slop",
|
|
974
|
+
layout: "Layout",
|
|
975
|
+
typography: "Typography",
|
|
976
|
+
components: "Components"
|
|
977
|
+
};
|
|
978
|
+
function formatTerminalReport(result, options = {}) {
|
|
979
|
+
if (options.quiet) {
|
|
980
|
+
return `Unslop UI: ${result.score}/100 - ${result.label}; ${result.findingCount} findings in ${result.filesScanned} files
|
|
981
|
+
`;
|
|
982
|
+
}
|
|
983
|
+
const maxFindings = options.maxFindings ?? 20;
|
|
984
|
+
const lines = [
|
|
985
|
+
pc.bold("Unslop UI"),
|
|
986
|
+
"",
|
|
987
|
+
`Score: ${colorScore(result.score, `${result.score}/100`)} - ${result.label}`,
|
|
988
|
+
`Files scanned: ${result.filesScanned}`,
|
|
989
|
+
`Findings: ${result.findingCount}`
|
|
990
|
+
];
|
|
991
|
+
if (result.findings.length === 0) {
|
|
992
|
+
lines.push("", pc.green("No findings. The UI surface looks controlled."));
|
|
993
|
+
return `${lines.join("\n")}
|
|
994
|
+
`;
|
|
995
|
+
}
|
|
996
|
+
const errors = result.findings.filter((finding) => finding.severity === "error");
|
|
997
|
+
const warnings = result.findings.filter((finding) => finding.severity === "warn");
|
|
998
|
+
appendFindingGroup(lines, "High impact", errors, maxFindings);
|
|
999
|
+
appendFindingGroup(lines, "Warnings", warnings, Math.max(0, maxFindings - errors.length));
|
|
1000
|
+
const hiddenCount = Math.max(0, result.findings.length - maxFindings);
|
|
1001
|
+
if (hiddenCount > 0) {
|
|
1002
|
+
lines.push("", pc.dim(`Showing ${maxFindings} of ${result.findings.length} findings. Re-run with --verbose to show all.`));
|
|
1003
|
+
}
|
|
1004
|
+
lines.push("", pc.bold("Summary by category"));
|
|
1005
|
+
for (const [category, count] of getCategoryCounts(result.findings)) {
|
|
1006
|
+
lines.push(` ${CATEGORY_LABELS[category]}: ${count}`);
|
|
1007
|
+
}
|
|
1008
|
+
return `${lines.join("\n")}
|
|
1009
|
+
`;
|
|
1010
|
+
}
|
|
1011
|
+
function appendFindingGroup(lines, title, findings, maxCount) {
|
|
1012
|
+
if (findings.length === 0 || maxCount <= 0) return;
|
|
1013
|
+
lines.push("", pc.bold(title));
|
|
1014
|
+
for (const finding of findings.slice(0, maxCount)) {
|
|
1015
|
+
lines.push(` ${formatSeverity(finding.severity)} ${finding.ruleId}`);
|
|
1016
|
+
lines.push(` ${finding.filePath}${finding.line ? `:${finding.line}` : ""}`);
|
|
1017
|
+
lines.push(` ${finding.message}`);
|
|
1018
|
+
if (finding.suggestion) {
|
|
1019
|
+
lines.push(` ${pc.dim(finding.suggestion)}`);
|
|
1020
|
+
}
|
|
1021
|
+
lines.push("");
|
|
1022
|
+
}
|
|
1023
|
+
if (lines[lines.length - 1] === "") {
|
|
1024
|
+
lines.pop();
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
function formatSeverity(severity) {
|
|
1028
|
+
return severity === "error" ? pc.red("error") : pc.yellow("warn");
|
|
1029
|
+
}
|
|
1030
|
+
function colorScore(score, value) {
|
|
1031
|
+
if (score >= 90) return pc.green(value);
|
|
1032
|
+
if (score >= 75) return pc.cyan(value);
|
|
1033
|
+
if (score >= 60) return pc.yellow(value);
|
|
1034
|
+
return pc.red(value);
|
|
1035
|
+
}
|
|
1036
|
+
function getCategoryCounts(findings) {
|
|
1037
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1038
|
+
for (const finding of findings) {
|
|
1039
|
+
counts.set(finding.category, (counts.get(finding.category) ?? 0) + 1);
|
|
1040
|
+
}
|
|
1041
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
export {
|
|
1045
|
+
loadConfig,
|
|
1046
|
+
allRules,
|
|
1047
|
+
scanProject,
|
|
1048
|
+
formatJsonReport,
|
|
1049
|
+
formatTerminalReport
|
|
1050
|
+
};
|
|
1051
|
+
//# sourceMappingURL=chunk-34KLV5KL.js.map
|