design-system-enforcer 1.0.1 → 1.0.4
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/dist/cli.cjs +728 -117
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +728 -117
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +347 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +347 -56
- package/dist/index.js.map +1 -1
- package/package.json +71 -71
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import
|
|
5
|
-
import
|
|
4
|
+
import fs8 from "fs";
|
|
5
|
+
import path8 from "path";
|
|
6
6
|
import { Command } from "commander";
|
|
7
|
-
import
|
|
7
|
+
import pc3 from "picocolors";
|
|
8
8
|
|
|
9
9
|
// src/config/loader.ts
|
|
10
|
-
import
|
|
11
|
-
import
|
|
10
|
+
import fs3 from "fs";
|
|
11
|
+
import path3 from "path";
|
|
12
12
|
import { pathToFileURL } from "url";
|
|
13
13
|
|
|
14
14
|
// src/config/presets/berserk.ts
|
|
@@ -97,6 +97,9 @@ var berserkPreset = {
|
|
|
97
97
|
disallowRawHtml: {
|
|
98
98
|
button: { replaceWith: "Button", importFrom: "@/components/ui/button", checkRawElement: true },
|
|
99
99
|
select: { replaceWith: "Dropdown", importFrom: "@/components/ui/dropdown", checkRawElement: true },
|
|
100
|
+
textarea: { replaceWith: "Textarea", importFrom: "@/components/ui/textarea", checkRawElement: true },
|
|
101
|
+
progress: { replaceWith: "ProgressBar", importFrom: "@/components/ui/progress", checkRawElement: true },
|
|
102
|
+
dialog: { replaceWith: "Modal or Dialog", importFrom: "@/components/ui/modal", checkRawElement: true },
|
|
100
103
|
table: { replaceWith: "Table", importFrom: "@/components/ui/table", checkRawElement: true },
|
|
101
104
|
thead: { replaceWith: "TableHeader", importFrom: "@/components/ui/table", checkRawElement: true },
|
|
102
105
|
tbody: { replaceWith: "TableBody", importFrom: "@/components/ui/table", checkRawElement: true },
|
|
@@ -148,7 +151,8 @@ var berserkPreset = {
|
|
|
148
151
|
"table-no-horizontal-stacking": "error",
|
|
149
152
|
"table-windowed-pagination": "error",
|
|
150
153
|
"tabs-component-required": "error",
|
|
151
|
-
"component-inventory-active": "error"
|
|
154
|
+
"component-inventory-active": "error",
|
|
155
|
+
"no-wrapping-compact-elements": "error"
|
|
152
156
|
}
|
|
153
157
|
};
|
|
154
158
|
|
|
@@ -185,7 +189,10 @@ var defaultPreset = {
|
|
|
185
189
|
components: {
|
|
186
190
|
disallowRawHtml: {
|
|
187
191
|
button: { replaceWith: "Button", importFrom: "@/components/ui/button", checkRawElement: true },
|
|
188
|
-
select: { replaceWith: "Dropdown", importFrom: "@/components/ui/dropdown", checkRawElement: true }
|
|
192
|
+
select: { replaceWith: "Dropdown", importFrom: "@/components/ui/dropdown", checkRawElement: true },
|
|
193
|
+
textarea: { replaceWith: "Textarea", importFrom: "@/components/ui/textarea", checkRawElement: true },
|
|
194
|
+
progress: { replaceWith: "ProgressBar", importFrom: "@/components/ui/progress", checkRawElement: true },
|
|
195
|
+
dialog: { replaceWith: "Modal or Dialog", importFrom: "@/components/ui/modal", checkRawElement: true }
|
|
189
196
|
},
|
|
190
197
|
disallowPseudoChips: true
|
|
191
198
|
},
|
|
@@ -196,7 +203,8 @@ var defaultPreset = {
|
|
|
196
203
|
"no-pseudo-chips": "warn",
|
|
197
204
|
"no-chip-colored-buttons": "error",
|
|
198
205
|
"no-text-arrows": "error",
|
|
199
|
-
"no-backdrop-blur": "warn"
|
|
206
|
+
"no-backdrop-blur": "warn",
|
|
207
|
+
"no-wrapping-compact-elements": "warn"
|
|
200
208
|
}
|
|
201
209
|
};
|
|
202
210
|
|
|
@@ -260,6 +268,221 @@ var pythonPreset = {
|
|
|
260
268
|
}
|
|
261
269
|
};
|
|
262
270
|
|
|
271
|
+
// src/detector/index.ts
|
|
272
|
+
import fs2 from "fs";
|
|
273
|
+
import path2 from "path";
|
|
274
|
+
|
|
275
|
+
// src/detector/ignores.ts
|
|
276
|
+
import fs from "fs";
|
|
277
|
+
import path from "path";
|
|
278
|
+
var DEFAULT_IGNORES = [
|
|
279
|
+
"**/node_modules/**",
|
|
280
|
+
"**/.next/**",
|
|
281
|
+
"**/dist/**",
|
|
282
|
+
"**/build/**",
|
|
283
|
+
"**/out/**",
|
|
284
|
+
"**/.turbo/**",
|
|
285
|
+
"**/.git/**",
|
|
286
|
+
"**/coverage/**",
|
|
287
|
+
"**/.venv/**",
|
|
288
|
+
"**/venv/**",
|
|
289
|
+
"**/env/**",
|
|
290
|
+
"**/__pycache__/**",
|
|
291
|
+
"**/.pytest_cache/**",
|
|
292
|
+
"**/*.pyc",
|
|
293
|
+
// Non-UI code
|
|
294
|
+
"**/api/**",
|
|
295
|
+
"**/*.test.*",
|
|
296
|
+
"**/*.spec.*",
|
|
297
|
+
"**/__tests__/**",
|
|
298
|
+
"**/*.stories.*",
|
|
299
|
+
"**/generated/**"
|
|
300
|
+
];
|
|
301
|
+
function getSmartIgnores(cwd) {
|
|
302
|
+
const result = new Set(DEFAULT_IGNORES);
|
|
303
|
+
const gitignorePath = path.resolve(cwd, ".gitignore");
|
|
304
|
+
if (fs.existsSync(gitignorePath)) {
|
|
305
|
+
try {
|
|
306
|
+
const content = fs.readFileSync(gitignorePath, "utf-8");
|
|
307
|
+
const lines = content.split(/\r?\n/);
|
|
308
|
+
for (let line of lines) {
|
|
309
|
+
line = line.trim();
|
|
310
|
+
if (!line || line.startsWith("#")) continue;
|
|
311
|
+
if (line.startsWith("/")) {
|
|
312
|
+
line = line.substring(1);
|
|
313
|
+
}
|
|
314
|
+
if (line.endsWith("/")) {
|
|
315
|
+
line = line.slice(0, -1);
|
|
316
|
+
}
|
|
317
|
+
if (line) {
|
|
318
|
+
result.add(`**/${line}/**`);
|
|
319
|
+
result.add(`**/${line}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch {
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return Array.from(result);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/detector/index.ts
|
|
329
|
+
function safeReadJson(filePath) {
|
|
330
|
+
try {
|
|
331
|
+
if (fs2.existsSync(filePath)) {
|
|
332
|
+
const raw = fs2.readFileSync(filePath, "utf-8");
|
|
333
|
+
return JSON.parse(raw);
|
|
334
|
+
}
|
|
335
|
+
} catch {
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
function detectProject(cwd = process.cwd()) {
|
|
340
|
+
const pkg = safeReadJson(path2.resolve(cwd, "package.json"));
|
|
341
|
+
const deps = { ...pkg?.dependencies || {}, ...pkg?.devDependencies || {} };
|
|
342
|
+
const hasRequirements = fs2.existsSync(path2.resolve(cwd, "requirements.txt"));
|
|
343
|
+
const hasPyproject = fs2.existsSync(path2.resolve(cwd, "pyproject.toml"));
|
|
344
|
+
const hasPython = hasRequirements || hasPyproject || fs2.existsSync(path2.resolve(cwd, "setup.py"));
|
|
345
|
+
let isStreamlit = false;
|
|
346
|
+
if (hasPython) {
|
|
347
|
+
if (hasRequirements) {
|
|
348
|
+
try {
|
|
349
|
+
const reqContent = fs2.readFileSync(path2.resolve(cwd, "requirements.txt"), "utf-8");
|
|
350
|
+
if (reqContent.toLowerCase().includes("streamlit")) {
|
|
351
|
+
isStreamlit = true;
|
|
352
|
+
}
|
|
353
|
+
} catch {
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const isPnpmMonorepo = fs2.existsSync(path2.resolve(cwd, "pnpm-workspace.yaml"));
|
|
358
|
+
const isTurbo = fs2.existsSync(path2.resolve(cwd, "turbo.json"));
|
|
359
|
+
let monorepoWorkspaces = [];
|
|
360
|
+
if (Array.isArray(pkg?.workspaces)) {
|
|
361
|
+
monorepoWorkspaces = pkg.workspaces;
|
|
362
|
+
} else if (pkg?.workspaces?.packages) {
|
|
363
|
+
monorepoWorkspaces = pkg.workspaces.packages;
|
|
364
|
+
}
|
|
365
|
+
const isMonorepo = isPnpmMonorepo || isTurbo || monorepoWorkspaces.length > 0;
|
|
366
|
+
let framework = "unknown";
|
|
367
|
+
if (deps["next"]) {
|
|
368
|
+
framework = "nextjs";
|
|
369
|
+
} else if (deps["vite"]) {
|
|
370
|
+
framework = "vite";
|
|
371
|
+
} else if (deps["@remix-run/react"]) {
|
|
372
|
+
framework = "remix";
|
|
373
|
+
} else if (deps["astro"]) {
|
|
374
|
+
framework = "astro";
|
|
375
|
+
} else if (deps["react-scripts"]) {
|
|
376
|
+
framework = "react-cra";
|
|
377
|
+
} else if (isStreamlit) {
|
|
378
|
+
framework = "streamlit";
|
|
379
|
+
} else if (hasPython) {
|
|
380
|
+
framework = "python-general";
|
|
381
|
+
}
|
|
382
|
+
const libraries = [];
|
|
383
|
+
const hasShadcnConfig = fs2.existsSync(path2.resolve(cwd, "components.json"));
|
|
384
|
+
if (hasShadcnConfig || deps["@radix-ui/react-slot"]) {
|
|
385
|
+
libraries.push("shadcn");
|
|
386
|
+
}
|
|
387
|
+
if (Object.keys(deps).some((d) => d.startsWith("@radix-ui/"))) {
|
|
388
|
+
if (!libraries.includes("shadcn")) libraries.push("radix");
|
|
389
|
+
}
|
|
390
|
+
if (deps["tailwindcss"]) {
|
|
391
|
+
libraries.push("tailwind");
|
|
392
|
+
}
|
|
393
|
+
if (deps["lucide-react"] || deps["lucide"]) {
|
|
394
|
+
libraries.push("lucide");
|
|
395
|
+
}
|
|
396
|
+
if (deps["@mui/material"] || deps["@material-ui/core"]) {
|
|
397
|
+
libraries.push("mui");
|
|
398
|
+
}
|
|
399
|
+
if (deps["@chakra-ui/react"]) {
|
|
400
|
+
libraries.push("chakra");
|
|
401
|
+
}
|
|
402
|
+
const hasTailwind = Boolean(deps["tailwindcss"] || fs2.existsSync(path2.resolve(cwd, "tailwind.config.js")) || fs2.existsSync(path2.resolve(cwd, "tailwind.config.ts")));
|
|
403
|
+
let tailwindVersion;
|
|
404
|
+
if (hasTailwind) {
|
|
405
|
+
const rawVersion = deps["tailwindcss"] || "";
|
|
406
|
+
if (rawVersion.includes("^4") || rawVersion.includes("~4") || rawVersion.startsWith("4")) {
|
|
407
|
+
tailwindVersion = "v4";
|
|
408
|
+
} else {
|
|
409
|
+
tailwindVersion = "v3";
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const hasTypeScript = Boolean(deps["typescript"] || fs2.existsSync(path2.resolve(cwd, "tsconfig.json")));
|
|
413
|
+
const suggestedIncludes = [];
|
|
414
|
+
const extGroup = hasTypeScript ? "{tsx,jsx,ts,js}" : "{jsx,js}";
|
|
415
|
+
const candidateDirs = [
|
|
416
|
+
"frontend/src",
|
|
417
|
+
"frontend",
|
|
418
|
+
"apps/web/src",
|
|
419
|
+
"apps/web",
|
|
420
|
+
"client/src",
|
|
421
|
+
"client",
|
|
422
|
+
"src",
|
|
423
|
+
"app",
|
|
424
|
+
"pages",
|
|
425
|
+
"components"
|
|
426
|
+
];
|
|
427
|
+
let foundUiDir = false;
|
|
428
|
+
for (const dir of candidateDirs) {
|
|
429
|
+
if (fs2.existsSync(path2.resolve(cwd, dir))) {
|
|
430
|
+
if (dir === "frontend" && fs2.existsSync(path2.resolve(cwd, "frontend/src"))) continue;
|
|
431
|
+
if (dir === "client" && fs2.existsSync(path2.resolve(cwd, "client/src"))) continue;
|
|
432
|
+
if (dir === "apps/web" && fs2.existsSync(path2.resolve(cwd, "apps/web/src"))) continue;
|
|
433
|
+
suggestedIncludes.push(`${dir}/**/*.${extGroup}`);
|
|
434
|
+
foundUiDir = true;
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (hasPython) {
|
|
439
|
+
suggestedIncludes.push("**/*.py");
|
|
440
|
+
} else if (!foundUiDir) {
|
|
441
|
+
suggestedIncludes.push(`**/*.${extGroup}`);
|
|
442
|
+
}
|
|
443
|
+
const suggestedIgnores = getSmartIgnores(cwd);
|
|
444
|
+
if (hasShadcnConfig || libraries.includes("shadcn")) {
|
|
445
|
+
suggestedIgnores.push("**/components/ui/**");
|
|
446
|
+
}
|
|
447
|
+
const tokenCandidates = [
|
|
448
|
+
"frontend/src/lib/tokens.ts",
|
|
449
|
+
"frontend/src/tokens.ts",
|
|
450
|
+
"src/lib/tokens.ts",
|
|
451
|
+
"src/tokens.ts",
|
|
452
|
+
"src/design/tokens.ts",
|
|
453
|
+
"styles/tokens.css",
|
|
454
|
+
"src/app/globals.css",
|
|
455
|
+
"frontend/src/index.css",
|
|
456
|
+
"src/index.css"
|
|
457
|
+
];
|
|
458
|
+
let tokensFile;
|
|
459
|
+
for (const candidate of tokenCandidates) {
|
|
460
|
+
if (fs2.existsSync(path2.resolve(cwd, candidate))) {
|
|
461
|
+
tokensFile = candidate;
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
let preset = "berserk";
|
|
466
|
+
if (framework === "streamlit" || framework === "python-general") {
|
|
467
|
+
preset = "python";
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
root: cwd,
|
|
471
|
+
isMonorepo,
|
|
472
|
+
monorepoWorkspaces,
|
|
473
|
+
framework,
|
|
474
|
+
libraries,
|
|
475
|
+
hasTailwind,
|
|
476
|
+
tailwindVersion,
|
|
477
|
+
hasTypeScript,
|
|
478
|
+
hasPython,
|
|
479
|
+
suggestedIncludes,
|
|
480
|
+
suggestedIgnores,
|
|
481
|
+
tokensFile,
|
|
482
|
+
preset
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
263
486
|
// src/config/loader.ts
|
|
264
487
|
var CONFIG_FILES = [
|
|
265
488
|
"dse.config.js",
|
|
@@ -271,17 +494,18 @@ var CONFIG_FILES = [
|
|
|
271
494
|
".dserc"
|
|
272
495
|
];
|
|
273
496
|
async function loadConfig(cwd = process.cwd(), customPath) {
|
|
497
|
+
const detected = detectProject(cwd);
|
|
274
498
|
let userConfig = {};
|
|
275
499
|
let foundConfigPath = null;
|
|
276
500
|
if (customPath) {
|
|
277
|
-
const resolved =
|
|
278
|
-
if (
|
|
501
|
+
const resolved = path3.resolve(cwd, customPath);
|
|
502
|
+
if (fs3.existsSync(resolved)) {
|
|
279
503
|
foundConfigPath = resolved;
|
|
280
504
|
}
|
|
281
505
|
} else {
|
|
282
506
|
for (const filename of CONFIG_FILES) {
|
|
283
|
-
const candidate =
|
|
284
|
-
if (
|
|
507
|
+
const candidate = path3.resolve(cwd, filename);
|
|
508
|
+
if (fs3.existsSync(candidate)) {
|
|
285
509
|
foundConfigPath = candidate;
|
|
286
510
|
break;
|
|
287
511
|
}
|
|
@@ -289,7 +513,7 @@ async function loadConfig(cwd = process.cwd(), customPath) {
|
|
|
289
513
|
}
|
|
290
514
|
if (foundConfigPath) {
|
|
291
515
|
if (foundConfigPath.endsWith(".json") || foundConfigPath.endsWith(".dserc")) {
|
|
292
|
-
const content =
|
|
516
|
+
const content = fs3.readFileSync(foundConfigPath, "utf-8");
|
|
293
517
|
try {
|
|
294
518
|
userConfig = JSON.parse(content);
|
|
295
519
|
} catch (err) {
|
|
@@ -302,7 +526,7 @@ async function loadConfig(cwd = process.cwd(), customPath) {
|
|
|
302
526
|
userConfig = mod.default || mod;
|
|
303
527
|
} catch {
|
|
304
528
|
try {
|
|
305
|
-
const content =
|
|
529
|
+
const content = fs3.readFileSync(foundConfigPath, "utf-8");
|
|
306
530
|
const cjsReq = createCjsEvaluator(foundConfigPath, content);
|
|
307
531
|
userConfig = cjsReq;
|
|
308
532
|
} catch (innerErr) {
|
|
@@ -311,18 +535,19 @@ async function loadConfig(cwd = process.cwd(), customPath) {
|
|
|
311
535
|
}
|
|
312
536
|
}
|
|
313
537
|
}
|
|
314
|
-
let presetName = userConfig.preset;
|
|
315
|
-
if (!presetName) {
|
|
316
|
-
const isPython = fs.existsSync(path.resolve(cwd, "requirements.txt")) || fs.existsSync(path.resolve(cwd, "pyproject.toml")) || fs.existsSync(path.resolve(cwd, ".streamlit")) || fs.existsSync(path.resolve(cwd, "manage.py"));
|
|
317
|
-
presetName = isPython ? "python" : "berserk";
|
|
318
|
-
}
|
|
538
|
+
let presetName = userConfig.preset || detected.preset;
|
|
319
539
|
let basePreset = berserkPreset;
|
|
320
540
|
if (presetName === "python") {
|
|
321
541
|
basePreset = pythonPreset;
|
|
322
542
|
} else if (presetName === "recommended") {
|
|
323
543
|
basePreset = defaultPreset;
|
|
324
544
|
}
|
|
325
|
-
|
|
545
|
+
const autonomousDefaults = {
|
|
546
|
+
include: detected.suggestedIncludes,
|
|
547
|
+
exclude: detected.suggestedIgnores
|
|
548
|
+
};
|
|
549
|
+
const withAutonomous = mergeConfigs(basePreset, autonomousDefaults);
|
|
550
|
+
return mergeConfigs(withAutonomous, userConfig);
|
|
326
551
|
}
|
|
327
552
|
function createCjsEvaluator(filePath, content) {
|
|
328
553
|
if (content.includes("export default")) {
|
|
@@ -372,8 +597,8 @@ function mergeConfigs(base, overrides) {
|
|
|
372
597
|
}
|
|
373
598
|
|
|
374
599
|
// src/rules/runner.ts
|
|
375
|
-
import
|
|
376
|
-
import
|
|
600
|
+
import fs4 from "fs";
|
|
601
|
+
import path4 from "path";
|
|
377
602
|
import fg from "fast-glob";
|
|
378
603
|
|
|
379
604
|
// src/parser/ast.ts
|
|
@@ -786,16 +1011,52 @@ function checkRawHtmlElements(tagName, attributes, pos, filePath, addViolation,
|
|
|
786
1011
|
pos.line,
|
|
787
1012
|
pos.col,
|
|
788
1013
|
"no-raw-html-elements",
|
|
789
|
-
`Raw browser default '<input type="checkbox">' detected. Use standard design system '<Checkbox>' or '<Toggle>'.`,
|
|
1014
|
+
`Raw browser default '<input type="checkbox">' detected. Never use unstyled browser checkboxes. Use standard design system '<Checkbox>' or '<Toggle>'.`,
|
|
790
1015
|
lineSnippet
|
|
791
1016
|
);
|
|
792
|
-
} else if (
|
|
1017
|
+
} else if (typeVal === "radio") {
|
|
793
1018
|
addViolation(
|
|
794
1019
|
filePath,
|
|
795
1020
|
pos.line,
|
|
796
1021
|
pos.col,
|
|
797
1022
|
"no-raw-html-elements",
|
|
798
|
-
`Raw '<input type="
|
|
1023
|
+
`Raw browser default '<input type="radio">' detected. Never use unstyled browser radio buttons. Use standard design system '<RadioGroup>' or styled toggle.`,
|
|
1024
|
+
lineSnippet
|
|
1025
|
+
);
|
|
1026
|
+
} else if (typeVal === "range") {
|
|
1027
|
+
addViolation(
|
|
1028
|
+
filePath,
|
|
1029
|
+
pos.line,
|
|
1030
|
+
pos.col,
|
|
1031
|
+
"no-raw-html-elements",
|
|
1032
|
+
`Raw browser default '<input type="range">' detected. Never use unstyled browser sliders. Use standard design system '<Slider>'.`,
|
|
1033
|
+
lineSnippet
|
|
1034
|
+
);
|
|
1035
|
+
} else if (typeVal === "file") {
|
|
1036
|
+
addViolation(
|
|
1037
|
+
filePath,
|
|
1038
|
+
pos.line,
|
|
1039
|
+
pos.col,
|
|
1040
|
+
"no-raw-html-elements",
|
|
1041
|
+
`Raw browser default '<input type="file">' detected. Never display native unstyled file picker inputs. Use a custom styled dropzone or trigger.`,
|
|
1042
|
+
lineSnippet
|
|
1043
|
+
);
|
|
1044
|
+
} else if (typeVal === "color") {
|
|
1045
|
+
addViolation(
|
|
1046
|
+
filePath,
|
|
1047
|
+
pos.line,
|
|
1048
|
+
pos.col,
|
|
1049
|
+
"no-raw-html-elements",
|
|
1050
|
+
`Raw browser default '<input type="color">' detected. Never use native browser color pickers. Use a custom color picker component.`,
|
|
1051
|
+
lineSnippet
|
|
1052
|
+
);
|
|
1053
|
+
} else if (["text", "email", "search", "password", "number", "tel", "url"].includes(typeVal)) {
|
|
1054
|
+
addViolation(
|
|
1055
|
+
filePath,
|
|
1056
|
+
pos.line,
|
|
1057
|
+
pos.col,
|
|
1058
|
+
"no-raw-html-elements",
|
|
1059
|
+
`Raw browser default '<input type="${typeVal}">' element detected. Use standard design system '<Input>' component.`,
|
|
799
1060
|
lineSnippet
|
|
800
1061
|
);
|
|
801
1062
|
}
|
|
@@ -1053,6 +1314,9 @@ function checkPseudoChips(tagName, attributes, pos, filePath, addViolation, line
|
|
|
1053
1314
|
function checkElementClasses(node, tagName, attributes, pos, filePath, addViolation, sourceFile, config) {
|
|
1054
1315
|
if (!attributes.className) return;
|
|
1055
1316
|
const isButton = ["Button", "IconButton", "ButtonyIcon", "FloatingActionButton"].includes(tagName);
|
|
1317
|
+
const isChip = ["Chip", "Badge", "Tag", "StatusBadge"].includes(tagName);
|
|
1318
|
+
const isTab = ["Tab", "TabTrigger", "TabsTrigger"].includes(tagName);
|
|
1319
|
+
const isCompactElement = isButton || isChip || isTab;
|
|
1056
1320
|
const isCheckbox = tagName === "Checkbox" || attributes.type?.initializer?.getText(sourceFile)?.includes("checkbox") || attributes.role?.initializer?.getText(sourceFile)?.includes("checkbox") || Boolean(attributes["aria-checked"]) || attributes.className && /\b(?:size-5|w-5\s+h-5)\b/.test(attributes.className.rawText);
|
|
1057
1321
|
const lineSnippet = sourceFile.getFullText().split("\n")[pos.line - 1] || "";
|
|
1058
1322
|
const classStrings = extractClassNames(attributes.className.initializer, sourceFile);
|
|
@@ -1092,8 +1356,33 @@ function checkElementClasses(node, tagName, attributes, pos, filePath, addViolat
|
|
|
1092
1356
|
);
|
|
1093
1357
|
}
|
|
1094
1358
|
}
|
|
1359
|
+
if (isCompactElement && config.rules?.["no-wrapping-compact-elements"] !== "off") {
|
|
1360
|
+
if (dec.core === "flex-wrap" || dec.core === "whitespace-normal" || dec.core === "whitespace-pre-wrap") {
|
|
1361
|
+
addViolation(
|
|
1362
|
+
filePath,
|
|
1363
|
+
nodePos.line,
|
|
1364
|
+
nodePos.col,
|
|
1365
|
+
"no-wrapping-compact-elements",
|
|
1366
|
+
`Disallowed text wrapping class '${dec.core}' on <${tagName}>. Compact interactive elements (buttons, chips, badges, tabs) must never wrap text across multiple lines.`,
|
|
1367
|
+
lineSnippet
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1095
1371
|
});
|
|
1096
1372
|
});
|
|
1373
|
+
if (tagName === "TableHead" && config.rules?.["no-wrapping-compact-elements"] !== "off") {
|
|
1374
|
+
const rawClass = attributes.className?.rawText || "";
|
|
1375
|
+
if (/\b(?:flex-wrap|whitespace-normal|whitespace-pre-wrap)\b/.test(rawClass)) {
|
|
1376
|
+
addViolation(
|
|
1377
|
+
filePath,
|
|
1378
|
+
pos.line,
|
|
1379
|
+
pos.col,
|
|
1380
|
+
"no-wrapping-compact-elements",
|
|
1381
|
+
`Disallowed text wrapping on <TableHead>. Column headers must remain single-line ('whitespace-nowrap') to preserve table column alignment.`,
|
|
1382
|
+
lineSnippet
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1097
1386
|
}
|
|
1098
1387
|
|
|
1099
1388
|
// src/rules/regex-rules.ts
|
|
@@ -1314,8 +1603,8 @@ function runPythonRules(content, filePath, addViolation, config) {
|
|
|
1314
1603
|
|
|
1315
1604
|
// src/rules/runner.ts
|
|
1316
1605
|
async function runDseScan(config, cwd = process.cwd()) {
|
|
1317
|
-
let targetDir =
|
|
1318
|
-
if (!
|
|
1606
|
+
let targetDir = path4.resolve(cwd, config.targetDir || "src");
|
|
1607
|
+
if (!fs4.existsSync(targetDir)) {
|
|
1319
1608
|
targetDir = cwd;
|
|
1320
1609
|
}
|
|
1321
1610
|
const includePatterns = config.include || ["**/*.{tsx,jsx,ts,js}"];
|
|
@@ -1347,7 +1636,7 @@ async function runDseScan(config, cwd = process.cwd()) {
|
|
|
1347
1636
|
const violations = [];
|
|
1348
1637
|
const seenViolationKeys = /* @__PURE__ */ new Set();
|
|
1349
1638
|
const addViolation = (file, line, col, rule, message, snippet) => {
|
|
1350
|
-
const normFile =
|
|
1639
|
+
const normFile = path4.relative(cwd, file).replace(/\\/g, "/");
|
|
1351
1640
|
const key = `${normFile}:${line}:${rule}`;
|
|
1352
1641
|
if (seenViolationKeys.has(key)) return;
|
|
1353
1642
|
seenViolationKeys.add(key);
|
|
@@ -1365,7 +1654,7 @@ async function runDseScan(config, cwd = process.cwd()) {
|
|
|
1365
1654
|
};
|
|
1366
1655
|
let scannedJsxFiles = false;
|
|
1367
1656
|
for (const filePath of files) {
|
|
1368
|
-
const content =
|
|
1657
|
+
const content = fs4.readFileSync(filePath, "utf-8");
|
|
1369
1658
|
const isJsx = filePath.endsWith(".tsx") || filePath.endsWith(".jsx") || filePath.endsWith(".ts") || filePath.endsWith(".js");
|
|
1370
1659
|
const isPythonOrTemplate = filePath.endsWith(".py") || filePath.endsWith(".html") || filePath.endsWith(".jinja") || filePath.endsWith(".jinja2") || filePath.endsWith(".css");
|
|
1371
1660
|
if (isJsx) {
|
|
@@ -1486,8 +1775,8 @@ function formatJson(result) {
|
|
|
1486
1775
|
}
|
|
1487
1776
|
|
|
1488
1777
|
// src/scaffold/index.ts
|
|
1489
|
-
import
|
|
1490
|
-
import
|
|
1778
|
+
import fs5 from "fs";
|
|
1779
|
+
import path5 from "path";
|
|
1491
1780
|
|
|
1492
1781
|
// src/scaffold/templates/tokens.ts
|
|
1493
1782
|
var tokensTemplate = `/**
|
|
@@ -1630,7 +1919,8 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
1630
1919
|
({ className = "", variant = "primary", size = "default", children, ...props }, ref) => {
|
|
1631
1920
|
// Standard 36px height, 8px radius, font-medium, strictly neutral palette
|
|
1632
1921
|
const baseClasses =
|
|
1633
|
-
"inline-flex items-center justify-center font-medium text-sm rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 disabled:pointer-events-none disabled:opacity-50 select-none";
|
|
1922
|
+
"inline-flex items-center justify-center font-medium text-sm rounded-md whitespace-nowrap shrink-0 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 disabled:pointer-events-none disabled:opacity-50 select-none cursor-pointer";
|
|
1923
|
+
|
|
1634
1924
|
|
|
1635
1925
|
const variantClasses = {
|
|
1636
1926
|
primary: "bg-neutral-900 text-white hover:bg-neutral-800 shadow-2xs",
|
|
@@ -1671,7 +1961,8 @@ export const Chip: React.FC<ChipProps> = ({
|
|
|
1671
1961
|
}) => {
|
|
1672
1962
|
// Standard 36px height, 8px radius, 14px typography
|
|
1673
1963
|
const baseClasses =
|
|
1674
|
-
"inline-flex items-center h-[36px] min-h-[36px] px-3 rounded-md text-sm font-medium gap-1.5 select-none";
|
|
1964
|
+
"inline-flex items-center h-[36px] min-h-[36px] px-3 rounded-md text-sm font-medium whitespace-nowrap shrink-0 gap-1.5 select-none";
|
|
1965
|
+
|
|
1675
1966
|
|
|
1676
1967
|
const variantClasses = {
|
|
1677
1968
|
neutral: "bg-neutral-100 text-neutral-800 border border-neutral-200",
|
|
@@ -1841,12 +2132,12 @@ export const TableHead = React.forwardRef<HTMLTableCellElement, TableHeadProps>(
|
|
|
1841
2132
|
({ className = "", icon, resizable = true, children, ...props }, ref) => (
|
|
1842
2133
|
<th
|
|
1843
2134
|
ref={ref}
|
|
1844
|
-
className={\`h-[36px] px-4 text-left font-medium text-sm text-neutral-900 select-none align-middle relative \${className}\`}
|
|
2135
|
+
className={\`h-[36px] px-4 text-left font-medium text-sm text-neutral-900 select-none align-middle relative whitespace-nowrap \${className}\`}
|
|
1845
2136
|
{...props}
|
|
1846
2137
|
>
|
|
1847
2138
|
<div className="flex items-center gap-2">
|
|
1848
2139
|
{icon && <span className="text-neutral-500 shrink-0">{icon}</span>}
|
|
1849
|
-
<span>{children}</span>
|
|
2140
|
+
<span className="whitespace-nowrap">{children}</span>
|
|
1850
2141
|
</div>
|
|
1851
2142
|
</th>
|
|
1852
2143
|
)
|
|
@@ -1892,7 +2183,7 @@ export const Tabs: React.FC<TabsProps> = ({ items, activeId, onChange, className
|
|
|
1892
2183
|
type="button"
|
|
1893
2184
|
disabled={tab.disabled}
|
|
1894
2185
|
onClick={() => onChange(tab.id)}
|
|
1895
|
-
className={\`inline-flex h-[28px] items-center justify-center gap-1.5 rounded-sm px-3 text-sm font-medium transition-all select-none \${
|
|
2186
|
+
className={\`inline-flex h-[28px] items-center justify-center gap-1.5 rounded-sm px-3 text-sm font-medium whitespace-nowrap shrink-0 transition-all select-none \${
|
|
1896
2187
|
isActive
|
|
1897
2188
|
? "bg-white text-neutral-900 shadow-2xs"
|
|
1898
2189
|
: "text-neutral-600 hover:text-neutral-900 hover:bg-neutral-200/50"
|
|
@@ -1984,8 +2275,8 @@ var COMPONENT_TEMPLATES = {
|
|
|
1984
2275
|
};
|
|
1985
2276
|
function scaffoldConfig(cwd = process.cwd(), preset = "berserk") {
|
|
1986
2277
|
if (preset === "python") {
|
|
1987
|
-
const jsonPath =
|
|
1988
|
-
if (
|
|
2278
|
+
const jsonPath = path5.resolve(cwd, "dse.config.json");
|
|
2279
|
+
if (fs5.existsSync(jsonPath)) return jsonPath;
|
|
1989
2280
|
const content2 = JSON.stringify(
|
|
1990
2281
|
{
|
|
1991
2282
|
preset: "python",
|
|
@@ -2002,11 +2293,11 @@ function scaffoldConfig(cwd = process.cwd(), preset = "berserk") {
|
|
|
2002
2293
|
null,
|
|
2003
2294
|
2
|
|
2004
2295
|
) + "\n";
|
|
2005
|
-
|
|
2296
|
+
fs5.writeFileSync(jsonPath, content2, "utf-8");
|
|
2006
2297
|
return jsonPath;
|
|
2007
2298
|
}
|
|
2008
|
-
const configPath =
|
|
2009
|
-
if (
|
|
2299
|
+
const configPath = path5.resolve(cwd, "dse.config.js");
|
|
2300
|
+
if (fs5.existsSync(configPath)) {
|
|
2010
2301
|
return configPath;
|
|
2011
2302
|
}
|
|
2012
2303
|
const content = `import { defineConfig } from "design-system-enforcer";
|
|
@@ -2034,21 +2325,21 @@ export default defineConfig({
|
|
|
2034
2325
|
],
|
|
2035
2326
|
});
|
|
2036
2327
|
`;
|
|
2037
|
-
|
|
2328
|
+
fs5.writeFileSync(configPath, content, "utf-8");
|
|
2038
2329
|
return configPath;
|
|
2039
2330
|
}
|
|
2040
2331
|
function scaffoldTokens(cwd = process.cwd()) {
|
|
2041
|
-
const libDir =
|
|
2042
|
-
const appDir =
|
|
2043
|
-
if (!
|
|
2044
|
-
if (!
|
|
2045
|
-
const tokensPath =
|
|
2046
|
-
if (!
|
|
2047
|
-
|
|
2048
|
-
}
|
|
2049
|
-
const cssPath =
|
|
2050
|
-
if (!
|
|
2051
|
-
|
|
2332
|
+
const libDir = path5.resolve(cwd, "src/lib");
|
|
2333
|
+
const appDir = path5.resolve(cwd, "src/app");
|
|
2334
|
+
if (!fs5.existsSync(libDir)) fs5.mkdirSync(libDir, { recursive: true });
|
|
2335
|
+
if (!fs5.existsSync(appDir)) fs5.mkdirSync(appDir, { recursive: true });
|
|
2336
|
+
const tokensPath = path5.join(libDir, "tokens.ts");
|
|
2337
|
+
if (!fs5.existsSync(tokensPath)) {
|
|
2338
|
+
fs5.writeFileSync(tokensPath, tokensTemplate, "utf-8");
|
|
2339
|
+
}
|
|
2340
|
+
const cssPath = path5.join(appDir, "globals.css");
|
|
2341
|
+
if (!fs5.existsSync(cssPath)) {
|
|
2342
|
+
fs5.writeFileSync(cssPath, cssTemplate, "utf-8");
|
|
2052
2343
|
}
|
|
2053
2344
|
return { tokensPath, cssPath };
|
|
2054
2345
|
}
|
|
@@ -2058,13 +2349,13 @@ function scaffoldComponent(componentName, cwd = process.cwd()) {
|
|
|
2058
2349
|
if (!template) {
|
|
2059
2350
|
return null;
|
|
2060
2351
|
}
|
|
2061
|
-
const uiDir =
|
|
2062
|
-
if (!
|
|
2063
|
-
|
|
2352
|
+
const uiDir = path5.resolve(cwd, "src/components/ui");
|
|
2353
|
+
if (!fs5.existsSync(uiDir)) {
|
|
2354
|
+
fs5.mkdirSync(uiDir, { recursive: true });
|
|
2064
2355
|
}
|
|
2065
|
-
const targetPath =
|
|
2066
|
-
if (!
|
|
2067
|
-
|
|
2356
|
+
const targetPath = path5.join(uiDir, template.filename);
|
|
2357
|
+
if (!fs5.existsSync(targetPath)) {
|
|
2358
|
+
fs5.writeFileSync(targetPath, template.content, "utf-8");
|
|
2068
2359
|
}
|
|
2069
2360
|
return targetPath;
|
|
2070
2361
|
}
|
|
@@ -2129,12 +2420,283 @@ function generateMarkdownRules(config) {
|
|
|
2129
2420
|
`;
|
|
2130
2421
|
}
|
|
2131
2422
|
|
|
2423
|
+
// src/doctor/index.ts
|
|
2424
|
+
function computeDoctorScore(result) {
|
|
2425
|
+
const categoryViolations = {
|
|
2426
|
+
typography: 0,
|
|
2427
|
+
geometry: 0,
|
|
2428
|
+
components: 0,
|
|
2429
|
+
colors: 0,
|
|
2430
|
+
wrapping: 0,
|
|
2431
|
+
tables: 0
|
|
2432
|
+
};
|
|
2433
|
+
const fileCountMap = {};
|
|
2434
|
+
result.violations.forEach((v) => {
|
|
2435
|
+
fileCountMap[v.file] = (fileCountMap[v.file] || 0) + 1;
|
|
2436
|
+
if (v.rule.startsWith("font-") || v.rule.includes("text-size") || v.rule.includes("uppercase")) {
|
|
2437
|
+
categoryViolations.typography++;
|
|
2438
|
+
} else if (v.rule.includes("wrapping")) {
|
|
2439
|
+
categoryViolations.wrapping++;
|
|
2440
|
+
} else if (v.rule.startsWith("control-") || v.rule.startsWith("corner-radius")) {
|
|
2441
|
+
categoryViolations.geometry++;
|
|
2442
|
+
} else if (v.rule.includes("color") || v.rule.includes("gradient") || v.rule.includes("blur")) {
|
|
2443
|
+
categoryViolations.colors++;
|
|
2444
|
+
} else if (v.rule.startsWith("table-")) {
|
|
2445
|
+
categoryViolations.tables++;
|
|
2446
|
+
} else {
|
|
2447
|
+
categoryViolations.components++;
|
|
2448
|
+
}
|
|
2449
|
+
});
|
|
2450
|
+
const filesCount = Math.max(1, result.scannedFilesCount || 1);
|
|
2451
|
+
function calcScore(violations) {
|
|
2452
|
+
const penaltyPerViolation = 100 / (filesCount * 2);
|
|
2453
|
+
return Math.max(0, Math.round(100 - violations * penaltyPerViolation));
|
|
2454
|
+
}
|
|
2455
|
+
const categories = [
|
|
2456
|
+
{ name: "Typography Scale", category: "typography", violations: categoryViolations.typography, score: calcScore(categoryViolations.typography) },
|
|
2457
|
+
{ name: "Geometry & Radii", category: "geometry", violations: categoryViolations.geometry, score: calcScore(categoryViolations.geometry) },
|
|
2458
|
+
{ name: "Wrap Integrity", category: "wrapping", violations: categoryViolations.wrapping, score: calcScore(categoryViolations.wrapping) },
|
|
2459
|
+
{ name: "Component Inventory", category: "components", violations: categoryViolations.components, score: calcScore(categoryViolations.components) },
|
|
2460
|
+
{ name: "Color Purity", category: "colors", violations: categoryViolations.colors, score: calcScore(categoryViolations.colors) },
|
|
2461
|
+
{ name: "Table Invariants", category: "tables", violations: categoryViolations.tables, score: calcScore(categoryViolations.tables) }
|
|
2462
|
+
];
|
|
2463
|
+
const totalScore = Math.round(categories.reduce((acc, c) => acc + c.score, 0) / categories.length);
|
|
2464
|
+
let grade = "F";
|
|
2465
|
+
if (totalScore >= 98) grade = "A+";
|
|
2466
|
+
else if (totalScore >= 90) grade = "A";
|
|
2467
|
+
else if (totalScore >= 80) grade = "B";
|
|
2468
|
+
else if (totalScore >= 70) grade = "C";
|
|
2469
|
+
else if (totalScore >= 60) grade = "D";
|
|
2470
|
+
const fileBreakdown = Object.entries(fileCountMap).map(([file, violations]) => ({ file, violations })).sort((a, b) => b.violations - a.violations).slice(0, 5);
|
|
2471
|
+
return {
|
|
2472
|
+
overallScore: totalScore,
|
|
2473
|
+
grade,
|
|
2474
|
+
scannedFiles: filesCount,
|
|
2475
|
+
totalViolations: result.violations.length,
|
|
2476
|
+
categories,
|
|
2477
|
+
fileBreakdown
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
// src/doctor/formatter.ts
|
|
2482
|
+
import pc2 from "picocolors";
|
|
2483
|
+
function renderProgressBar(percentage, width = 20) {
|
|
2484
|
+
const filledChars = Math.round(percentage / 100 * width);
|
|
2485
|
+
const emptyChars = width - filledChars;
|
|
2486
|
+
let colorFn = pc2.green;
|
|
2487
|
+
if (percentage < 70) colorFn = pc2.red;
|
|
2488
|
+
else if (percentage < 90) colorFn = pc2.yellow;
|
|
2489
|
+
const bar = colorFn("\u2588".repeat(filledChars)) + pc2.dim("\u2591".repeat(emptyChars));
|
|
2490
|
+
return `${bar} ${colorFn(percentage.toString().padStart(3) + "%")}`;
|
|
2491
|
+
}
|
|
2492
|
+
function formatDoctorOutput(report) {
|
|
2493
|
+
const lines = [];
|
|
2494
|
+
lines.push("");
|
|
2495
|
+
lines.push(pc2.bold(pc2.cyan("========================================================")));
|
|
2496
|
+
lines.push(pc2.bold(pc2.cyan(" DESIGN SYSTEM ENFORCER - HEALTH REPORT ")));
|
|
2497
|
+
lines.push(pc2.bold(pc2.cyan("========================================================")));
|
|
2498
|
+
lines.push("");
|
|
2499
|
+
let gradeColor = pc2.green;
|
|
2500
|
+
if (report.grade === "B") gradeColor = pc2.cyan;
|
|
2501
|
+
else if (report.grade === "C") gradeColor = pc2.yellow;
|
|
2502
|
+
else if (report.grade === "D" || report.grade === "F") gradeColor = pc2.red;
|
|
2503
|
+
lines.push(
|
|
2504
|
+
` ${pc2.bold("Overall Score:")} ${gradeColor(pc2.bold(report.overallScore + "/100"))} [${gradeColor(pc2.bold(report.grade))}]`
|
|
2505
|
+
);
|
|
2506
|
+
lines.push(` ${pc2.bold("Scanned Files:")} ${pc2.white(report.scannedFiles)}`);
|
|
2507
|
+
lines.push(` ${pc2.bold("Total Issues:")} ${report.totalViolations === 0 ? pc2.green("0") : pc2.red(report.totalViolations)}`);
|
|
2508
|
+
lines.push("");
|
|
2509
|
+
lines.push(pc2.bold(" Category Compliance:"));
|
|
2510
|
+
report.categories.forEach((cat) => {
|
|
2511
|
+
const paddedName = cat.name.padEnd(22, " ");
|
|
2512
|
+
const issueCount = cat.violations > 0 ? pc2.dim(`(${cat.violations} issues)`) : "";
|
|
2513
|
+
lines.push(` ${pc2.dim(paddedName)} ${renderProgressBar(cat.score)} ${issueCount}`);
|
|
2514
|
+
});
|
|
2515
|
+
lines.push("");
|
|
2516
|
+
if (report.fileBreakdown.length > 0) {
|
|
2517
|
+
lines.push(pc2.bold(pc2.yellow(" Top Offending Files:")));
|
|
2518
|
+
report.fileBreakdown.forEach((item, idx) => {
|
|
2519
|
+
lines.push(` ${idx + 1}. ${pc2.cyan(item.file)}: ${pc2.red(item.violations + " violations")}`);
|
|
2520
|
+
});
|
|
2521
|
+
lines.push("");
|
|
2522
|
+
lines.push(pc2.dim(" Run 'dse check' for detailed line-by-line violation reports."));
|
|
2523
|
+
lines.push(pc2.dim(" Run 'dse fix' to automatically remediate common violations."));
|
|
2524
|
+
} else {
|
|
2525
|
+
lines.push(pc2.bold(pc2.green(" \u2714 All design tokens and component invariants are 100% compliant!")));
|
|
2526
|
+
}
|
|
2527
|
+
lines.push("");
|
|
2528
|
+
return lines.join("\n");
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
// src/fixer/index.ts
|
|
2532
|
+
import fs6 from "fs";
|
|
2533
|
+
import path6 from "path";
|
|
2534
|
+
async function runDseFix(config, cwd = process.cwd()) {
|
|
2535
|
+
const scanResult = await runDseScan(config, cwd);
|
|
2536
|
+
const violationsByFile = {};
|
|
2537
|
+
scanResult.violations.forEach((v) => {
|
|
2538
|
+
if (!violationsByFile[v.file]) {
|
|
2539
|
+
violationsByFile[v.file] = [];
|
|
2540
|
+
}
|
|
2541
|
+
violationsByFile[v.file].push(v);
|
|
2542
|
+
});
|
|
2543
|
+
const details = [];
|
|
2544
|
+
let totalModifications = 0;
|
|
2545
|
+
for (const [relPath, violations] of Object.entries(violationsByFile)) {
|
|
2546
|
+
const fullPath = path6.resolve(cwd, relPath);
|
|
2547
|
+
if (!fs6.existsSync(fullPath)) continue;
|
|
2548
|
+
let content = fs6.readFileSync(fullPath, "utf-8");
|
|
2549
|
+
const originalContent = content;
|
|
2550
|
+
const fileFixes = [];
|
|
2551
|
+
if (violations.some((v) => v.rule === "no-wrapping-compact-elements")) {
|
|
2552
|
+
const before = content;
|
|
2553
|
+
content = content.replace(/(<(?:Button|IconButton|Chip|Badge)\b[^>]*className=["'`][^"'`]*?)\b(?:whitespace-normal|whitespace-pre-wrap|flex-wrap)\b([^"'`]*?["'`])/g, (match, prefix, suffix) => {
|
|
2554
|
+
return `${prefix}whitespace-nowrap shrink-0${suffix}`;
|
|
2555
|
+
});
|
|
2556
|
+
if (before !== content) {
|
|
2557
|
+
fileFixes.push("Replaced wrapping classes with 'whitespace-nowrap shrink-0' on compact controls");
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
if (violations.some((v) => v.rule === "no-raw-html-elements" && v.message.includes("<button>"))) {
|
|
2561
|
+
const before = content;
|
|
2562
|
+
content = content.replace(/<button(\s|>)/g, "<Button$1");
|
|
2563
|
+
content = content.replace(/<\/button>/g, "</Button>");
|
|
2564
|
+
if (before !== content) {
|
|
2565
|
+
if (!content.includes("import { Button }") && !content.includes("import Button") && !relPath.includes("components/ui/button")) {
|
|
2566
|
+
content = `import { Button } from "@/components/ui/button";
|
|
2567
|
+
` + content;
|
|
2568
|
+
}
|
|
2569
|
+
fileFixes.push("Upgraded raw <button> to <Button>");
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
if (violations.some((v) => v.rule === "no-raw-html-elements" && v.message.includes("<select>"))) {
|
|
2573
|
+
const before = content;
|
|
2574
|
+
content = content.replace(/<select(\s|>)/g, "<Dropdown$1");
|
|
2575
|
+
content = content.replace(/<\/select>/g, "</Dropdown>");
|
|
2576
|
+
if (before !== content) {
|
|
2577
|
+
if (!content.includes("import { Dropdown }") && !content.includes("import Dropdown") && !relPath.includes("components/ui/dropdown")) {
|
|
2578
|
+
content = `import { Dropdown } from "@/components/ui/dropdown";
|
|
2579
|
+
` + content;
|
|
2580
|
+
}
|
|
2581
|
+
fileFixes.push("Upgraded raw <select> to <Dropdown>");
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
if (violations.some((v) => v.rule === "no-raw-html-elements" && v.message.includes("<textarea>"))) {
|
|
2585
|
+
const before = content;
|
|
2586
|
+
content = content.replace(/<textarea(\s|>)/g, "<Textarea$1");
|
|
2587
|
+
content = content.replace(/<\/textarea>/g, "</Textarea>");
|
|
2588
|
+
if (before !== content) {
|
|
2589
|
+
if (!content.includes("import { Textarea }") && !content.includes("import Textarea") && !relPath.includes("components/ui/textarea")) {
|
|
2590
|
+
content = `import { Textarea } from "@/components/ui/textarea";
|
|
2591
|
+
` + content;
|
|
2592
|
+
}
|
|
2593
|
+
fileFixes.push("Upgraded raw <textarea> to <Textarea>");
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
if (violations.some((v) => v.rule === "no-arbitrary-text-size")) {
|
|
2597
|
+
const before = content;
|
|
2598
|
+
content = content.replace(/\btext-\[(?:13|13\.5|14|14\.5|15|15\.5)px\]\b/g, "text-sm");
|
|
2599
|
+
content = content.replace(/\btext-\[(?:11|11\.5|12|12\.5)px\]\b/g, "text-xs");
|
|
2600
|
+
content = content.replace(/\btext-\[(?:16|17)px\]\b/g, "text-base");
|
|
2601
|
+
if (before !== content) {
|
|
2602
|
+
fileFixes.push("Standardized arbitrary font sizes to compliant token scales");
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
if (violations.some((v) => v.rule === "no-backdrop-blur")) {
|
|
2606
|
+
const before = content;
|
|
2607
|
+
content = content.replace(/\bbackdrop-blur(?:-(?:none|sm|md|lg|xl|2xl|3xl))?\b/g, "");
|
|
2608
|
+
if (before !== content) {
|
|
2609
|
+
fileFixes.push("Removed non-compliant backdrop-blur classes");
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
if (content !== originalContent) {
|
|
2613
|
+
fs6.writeFileSync(fullPath, content, "utf-8");
|
|
2614
|
+
totalModifications += fileFixes.length;
|
|
2615
|
+
details.push({
|
|
2616
|
+
file: relPath,
|
|
2617
|
+
fixes: fileFixes
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
return {
|
|
2622
|
+
filesFixed: details.length,
|
|
2623
|
+
totalModifications,
|
|
2624
|
+
details
|
|
2625
|
+
};
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
// src/guardrails/index.ts
|
|
2629
|
+
import fs7 from "fs";
|
|
2630
|
+
import path7 from "path";
|
|
2631
|
+
function generateAgentInstructions(config) {
|
|
2632
|
+
const allowedRadii = config.tokens?.geometry?.allowedRadii?.join(", ") || "0px, 8px, 12px, 16px, 9999px";
|
|
2633
|
+
const maxControlHeight = config.tokens?.geometry?.maxControlHeight || 36;
|
|
2634
|
+
const bannedColors = config.tokens?.colors?.bannedColorFamilies?.join(", ") || "slate, gray, zinc, stone, etc.";
|
|
2635
|
+
return `<!-- BEGIN:DSE-AGENT-RULES -->
|
|
2636
|
+
# DESIGN SYSTEM ENFORCER (DSE) - MANDATORY RULES
|
|
2637
|
+
|
|
2638
|
+
You MUST adhere to these design system invariants for all UI generation:
|
|
2639
|
+
|
|
2640
|
+
1. COMPONENT INVARIANTS:
|
|
2641
|
+
- NEVER use raw HTML: <button>, <select>, <textarea>, <table>, <input type="checkbox">, <input type="radio">.
|
|
2642
|
+
- ALWAYS use standard primitives: <Button>, <Dropdown>, <Textarea>, <Table>, <Checkbox>, <Chip>.
|
|
2643
|
+
- Never create pseudo-chips using raw <div> or <span> with background fills and borders. Use <Chip>.
|
|
2644
|
+
|
|
2645
|
+
2. WRAP INTEGRITY (COMPACT ELEMENTS):
|
|
2646
|
+
- Interactive compact elements (<Button>, <Chip>, <Badge>, <TableHead>, tabs) MUST NOT wrap text.
|
|
2647
|
+
- ALWAYS include 'whitespace-nowrap shrink-0' on compact interactive components.
|
|
2648
|
+
|
|
2649
|
+
3. GEOMETRY & RADII:
|
|
2650
|
+
- Standard control height for buttons and inputs is strictly max ${maxControlHeight}px ('h-[36px]' or 'h-9').
|
|
2651
|
+
- Allowed corner radii tokens: ${allowedRadii}. Never use random arbitrary radii.
|
|
2652
|
+
|
|
2653
|
+
4. COLOR SCALE & PURITY:
|
|
2654
|
+
- Strictly neutral palette for base surfaces and borders ('neutral-100' through 'neutral-900').
|
|
2655
|
+
- Banned arbitrary color families: ${bannedColors}.
|
|
2656
|
+
- Never use arbitrary inline hex colors ('#1a2b3c') in utility classes. Use design system CSS variables ('var(--primary)').
|
|
2657
|
+
|
|
2658
|
+
5. BEFORE FINISHING UI EDITS:
|
|
2659
|
+
- Run 'npx dse check' to verify zero design system violations.
|
|
2660
|
+
<!-- END:DSE-AGENT-RULES -->
|
|
2661
|
+
`;
|
|
2662
|
+
}
|
|
2663
|
+
function installPreCommitHook(cwd = process.cwd()) {
|
|
2664
|
+
const gitDir = path7.resolve(cwd, ".git");
|
|
2665
|
+
if (!fs7.existsSync(gitDir)) {
|
|
2666
|
+
throw new Error("No .git repository found in this directory.");
|
|
2667
|
+
}
|
|
2668
|
+
const hooksDir = path7.resolve(gitDir, "hooks");
|
|
2669
|
+
if (!fs7.existsSync(hooksDir)) {
|
|
2670
|
+
fs7.mkdirSync(hooksDir, { recursive: true });
|
|
2671
|
+
}
|
|
2672
|
+
const hookPath = path7.resolve(hooksDir, "pre-commit");
|
|
2673
|
+
const hookScript = `#!/usr/bin/env sh
|
|
2674
|
+
# Design System Enforcer (DSE) Pre-Commit Hook
|
|
2675
|
+
echo "
|
|
2676
|
+
\u{1F50D} Running Design System Enforcer pre-commit check..."
|
|
2677
|
+
|
|
2678
|
+
if command -v npx >/dev/null 2>&1; then
|
|
2679
|
+
npx dse check --strict
|
|
2680
|
+
RESULT=$?
|
|
2681
|
+
if [ $RESULT -ne 0 ]; then
|
|
2682
|
+
echo "
|
|
2683
|
+
\u274C Commit rejected: Design System violations found."
|
|
2684
|
+
echo "\u{1F4A1} Run 'npx dse fix' or 'npx dse check' to review issues.
|
|
2685
|
+
"
|
|
2686
|
+
exit 1
|
|
2687
|
+
fi
|
|
2688
|
+
fi
|
|
2689
|
+
`;
|
|
2690
|
+
fs7.writeFileSync(hookPath, hookScript, { mode: 493 });
|
|
2691
|
+
return true;
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2132
2694
|
// src/cli.ts
|
|
2133
2695
|
var program = new Command();
|
|
2134
|
-
program.name("dse").description("Design System Enforcer (DSE) - AST-based Linter and Scaffold Engine").version("1.0.
|
|
2696
|
+
program.name("dse").description("Design System Enforcer (DSE) - AST-based Linter and Scaffold Engine").version("1.0.4");
|
|
2135
2697
|
program.command("check [targetDir]", { isDefault: true }).description("Scan codebase and enforce Design System rules").option("-c, --config <path>", "Path to custom dse.config.js / dse.config.json").option("-f, --format <format>", "Output format: 'stylish' | 'json'", "stylish").option("--strict", "Treat warnings as errors", false).action(async (targetDirArg, options) => {
|
|
2136
2698
|
try {
|
|
2137
|
-
const cwd = targetDirArg ?
|
|
2699
|
+
const cwd = targetDirArg ? path8.resolve(process.cwd(), targetDirArg) : process.cwd();
|
|
2138
2700
|
const config = await loadConfig(cwd, options.config);
|
|
2139
2701
|
const result = await runDseScan(config, cwd);
|
|
2140
2702
|
if (options.format === "json") {
|
|
@@ -2147,76 +2709,125 @@ program.command("check [targetDir]", { isDefault: true }).description("Scan code
|
|
|
2147
2709
|
process.exit(1);
|
|
2148
2710
|
}
|
|
2149
2711
|
} catch (err) {
|
|
2150
|
-
console.error(
|
|
2712
|
+
console.error(pc3.red(`
|
|
2713
|
+
\u2716 Error running scan: ${err.message}`));
|
|
2151
2714
|
process.exit(1);
|
|
2152
2715
|
}
|
|
2153
2716
|
});
|
|
2154
|
-
program.command("
|
|
2717
|
+
program.command("doctor [targetDir]").description("Perform a visual Design System health audit and compute compliance score").option("-c, --config <path>", "Path to custom dse.config.js / dse.config.json").action(async (targetDirArg, options) => {
|
|
2718
|
+
try {
|
|
2719
|
+
const cwd = targetDirArg ? path8.resolve(process.cwd(), targetDirArg) : process.cwd();
|
|
2720
|
+
const config = await loadConfig(cwd, options.config);
|
|
2721
|
+
const result = await runDseScan(config, cwd);
|
|
2722
|
+
const report = computeDoctorScore(result);
|
|
2723
|
+
console.log(formatDoctorOutput(report));
|
|
2724
|
+
} catch (err) {
|
|
2725
|
+
console.error(pc3.red(`
|
|
2726
|
+
\u2716 Error generating health report: ${err.message}`));
|
|
2727
|
+
process.exit(1);
|
|
2728
|
+
}
|
|
2729
|
+
});
|
|
2730
|
+
program.command("fix [targetDir]").description("Automatically remediate common Design System violations in place").option("-c, --config <path>", "Path to custom dse.config.js / dse.config.json").action(async (targetDirArg, options) => {
|
|
2731
|
+
try {
|
|
2732
|
+
const cwd = targetDirArg ? path8.resolve(process.cwd(), targetDirArg) : process.cwd();
|
|
2733
|
+
const config = await loadConfig(cwd, options.config);
|
|
2734
|
+
console.log(pc3.cyan("\n\u{1F527} Running Design System Enforcer Auto-Fixer...\n"));
|
|
2735
|
+
const fixReport = await runDseFix(config, cwd);
|
|
2736
|
+
if (fixReport.filesFixed === 0) {
|
|
2737
|
+
console.log(pc3.green("\u2714 No auto-fixable violations found. Your codebase is clean!"));
|
|
2738
|
+
} else {
|
|
2739
|
+
console.log(pc3.bold(pc3.green(`\u2714 Successfully fixed ${fixReport.totalModifications} issues across ${fixReport.filesFixed} files:
|
|
2740
|
+
`)));
|
|
2741
|
+
fixReport.details.forEach((item) => {
|
|
2742
|
+
console.log(` ${pc3.bold(pc3.cyan(item.file))}:`);
|
|
2743
|
+
item.fixes.forEach((f) => console.log(` - ${pc3.dim(f)}`));
|
|
2744
|
+
});
|
|
2745
|
+
}
|
|
2746
|
+
console.log("");
|
|
2747
|
+
} catch (err) {
|
|
2748
|
+
console.error(pc3.red(`
|
|
2749
|
+
\u2716 Error during auto-fix: ${err.message}`));
|
|
2750
|
+
process.exit(1);
|
|
2751
|
+
}
|
|
2752
|
+
});
|
|
2753
|
+
program.command("detect [targetDir]").description("Inspect project architecture, framework, libraries, and design tokens autonomously").action((targetDirArg) => {
|
|
2754
|
+
const cwd = targetDirArg ? path8.resolve(process.cwd(), targetDirArg) : process.cwd();
|
|
2755
|
+
const info = detectProject(cwd);
|
|
2756
|
+
console.log(pc3.bold(pc3.cyan("\n\u{1F50D} DSE Autonomous Project Diagnostics\n")));
|
|
2757
|
+
console.log(` ${pc3.bold("Target Directory:")} ${info.root}`);
|
|
2758
|
+
console.log(` ${pc3.bold("Framework:")} ${pc3.green(info.framework)}`);
|
|
2759
|
+
console.log(` ${pc3.bold("Monorepo:")} ${info.isMonorepo ? pc3.yellow("Yes") : "No"}`);
|
|
2760
|
+
if (info.isMonorepo && info.monorepoWorkspaces.length > 0) {
|
|
2761
|
+
console.log(` ${pc3.bold("Workspaces:")} ${info.monorepoWorkspaces.join(", ")}`);
|
|
2762
|
+
}
|
|
2763
|
+
console.log(` ${pc3.bold("Libraries:")} ${info.libraries.length ? pc3.magenta(info.libraries.join(", ")) : "None"}`);
|
|
2764
|
+
console.log(` ${pc3.bold("Tailwind CSS:")} ${info.hasTailwind ? pc3.green(info.tailwindVersion || "Yes") : "No"}`);
|
|
2765
|
+
console.log(` ${pc3.bold("TypeScript:")} ${info.hasTypeScript ? "Yes" : "No"}`);
|
|
2766
|
+
console.log(` ${pc3.bold("Tokens File:")} ${info.tokensFile ? pc3.cyan(info.tokensFile) : pc3.dim("None (using preset defaults)")}`);
|
|
2767
|
+
console.log(` ${pc3.bold("Default Preset:")} ${pc3.yellow(info.preset)}`);
|
|
2768
|
+
console.log(` ${pc3.bold("Auto Includes:")} ${info.suggestedIncludes.join(", ")}`);
|
|
2769
|
+
console.log(` ${pc3.bold("Auto Ignores:")} ${info.suggestedIgnores.slice(0, 5).join(", ")} (+${Math.max(0, info.suggestedIgnores.length - 5)} more)`);
|
|
2770
|
+
console.log("");
|
|
2771
|
+
});
|
|
2772
|
+
program.command("export-agent").description("Export ultra-compact prompt rules for Cursor, Claude, Antigravity, or Copilot").option("-w, --write <file>", "Target file to write/append into (e.g. .cursorrules, AGENTS.md)").action(async (options) => {
|
|
2155
2773
|
const cwd = process.cwd();
|
|
2156
|
-
|
|
2157
|
-
const
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2774
|
+
const config = await loadConfig(cwd);
|
|
2775
|
+
const instructions = generateAgentInstructions(config);
|
|
2776
|
+
if (options.write) {
|
|
2777
|
+
const targetPath = path8.resolve(cwd, options.write);
|
|
2778
|
+
if (fs8.existsSync(targetPath)) {
|
|
2779
|
+
let existing = fs8.readFileSync(targetPath, "utf-8");
|
|
2780
|
+
const startTag = "<!-- BEGIN:DSE-AGENT-RULES -->";
|
|
2781
|
+
const endTag = "<!-- END:DSE-AGENT-RULES -->";
|
|
2782
|
+
if (existing.includes(startTag) && existing.includes(endTag)) {
|
|
2783
|
+
const regex = new RegExp(`${startTag}[\\s\\S]*?${endTag}`, "g");
|
|
2784
|
+
existing = existing.replace(regex, instructions.trim());
|
|
2785
|
+
} else {
|
|
2786
|
+
existing = `${existing.trim()}
|
|
2787
|
+
|
|
2788
|
+
${instructions.trim()}
|
|
2789
|
+
`;
|
|
2170
2790
|
}
|
|
2171
|
-
|
|
2172
|
-
console.
|
|
2791
|
+
fs8.writeFileSync(targetPath, existing, "utf-8");
|
|
2792
|
+
console.log(pc3.green(`\u2714 Updated AI guardrails in ${options.write}`));
|
|
2793
|
+
} else {
|
|
2794
|
+
fs8.writeFileSync(targetPath, instructions, "utf-8");
|
|
2795
|
+
console.log(pc3.green(`\u2714 Created ${options.write} with AI guardrails`));
|
|
2173
2796
|
}
|
|
2174
|
-
}
|
|
2175
|
-
if (options.withPrimitives) {
|
|
2176
|
-
console.log(pc2.cyan("Scaffolding primitives and theme tokens..."));
|
|
2177
|
-
const created = scaffoldAllPrimitives(cwd);
|
|
2178
|
-
created.forEach((file) => {
|
|
2179
|
-
console.log(pc2.green("\u2714 Created:"), pc2.dim(path4.relative(cwd, file)));
|
|
2180
|
-
});
|
|
2181
2797
|
} else {
|
|
2182
|
-
console.log(
|
|
2183
|
-
pc2.dim("\nTip: Run 'npx dse scaffold' to generate rule-compliant base primitives and tokens anytime.")
|
|
2184
|
-
);
|
|
2798
|
+
console.log(instructions);
|
|
2185
2799
|
}
|
|
2186
|
-
console.log(pc2.bold(pc2.green("\n\u2714 DSE setup complete! Run 'npm run dse' to verify your project.\n")));
|
|
2187
2800
|
});
|
|
2188
|
-
program.command("
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
);
|
|
2801
|
+
program.command("hook [action]").description("Install or manage git pre-commit hooks to block non-compliant commits").action((action) => {
|
|
2802
|
+
if (action === "install" || !action) {
|
|
2803
|
+
try {
|
|
2804
|
+
installPreCommitHook(process.cwd());
|
|
2805
|
+
console.log(pc3.bold(pc3.green("\n\u2714 Successfully installed pre-commit hook in .git/hooks/pre-commit!")));
|
|
2806
|
+
console.log(pc3.dim(" Every git commit will now be checked with 'dse check --strict'.\n"));
|
|
2807
|
+
} catch (err) {
|
|
2808
|
+
console.error(pc3.red(`
|
|
2809
|
+
\u2716 Could not install hook: ${err.message}
|
|
2810
|
+
`));
|
|
2199
2811
|
}
|
|
2200
2812
|
}
|
|
2201
|
-
console.log("");
|
|
2202
2813
|
});
|
|
2203
|
-
program.command("
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2814
|
+
program.command("init").description("Initialize DSE configuration in current repository").option("-p, --preset <name>", "Preset: berserk | recommended | minimal | python", "berserk").action((options) => {
|
|
2815
|
+
scaffoldConfig(process.cwd(), options.preset);
|
|
2816
|
+
});
|
|
2817
|
+
program.command("scaffold-tokens").description("Generate official design system tokens TypeScript/CSS file").action(() => {
|
|
2818
|
+
scaffoldTokens(process.cwd());
|
|
2819
|
+
});
|
|
2820
|
+
program.command("scaffold-primitives").description("Scaffold all canonical zero-compromise design system UI primitives").action(() => {
|
|
2821
|
+
scaffoldAllPrimitives(process.cwd());
|
|
2211
2822
|
});
|
|
2212
2823
|
program.command("export-rules").description("Export rules in Markdown format for AI coding assistants (AGENTS.md, .cursorrules, CLAUDE.md)").option("-w, --write <file>", "Write directly to target file (e.g. AGENTS.md)").action(async (options) => {
|
|
2213
2824
|
const cwd = process.cwd();
|
|
2214
2825
|
const config = await loadConfig(cwd);
|
|
2215
2826
|
const rulesMd = generateMarkdownRules(config);
|
|
2216
2827
|
if (options.write) {
|
|
2217
|
-
const targetFile =
|
|
2218
|
-
if (
|
|
2219
|
-
let existing =
|
|
2828
|
+
const targetFile = path8.resolve(cwd, options.write);
|
|
2829
|
+
if (fs8.existsSync(targetFile)) {
|
|
2830
|
+
let existing = fs8.readFileSync(targetFile, "utf-8");
|
|
2220
2831
|
const startTag = "<!-- BEGIN:design-system-enforcer-rules -->";
|
|
2221
2832
|
const endTag = "<!-- END:design-system-enforcer-rules -->";
|
|
2222
2833
|
if (existing.includes(startTag) && existing.includes(endTag)) {
|
|
@@ -2228,11 +2839,11 @@ program.command("export-rules").description("Export rules in Markdown format for
|
|
|
2228
2839
|
${rulesMd.trim()}
|
|
2229
2840
|
`;
|
|
2230
2841
|
}
|
|
2231
|
-
|
|
2232
|
-
console.log(
|
|
2842
|
+
fs8.writeFileSync(targetFile, existing, "utf-8");
|
|
2843
|
+
console.log(pc3.green(`\u2714 Updated rules in ${options.write}`));
|
|
2233
2844
|
} else {
|
|
2234
|
-
|
|
2235
|
-
console.log(
|
|
2845
|
+
fs8.writeFileSync(targetFile, rulesMd, "utf-8");
|
|
2846
|
+
console.log(pc3.green(`\u2714 Created ${options.write} with design system rules`));
|
|
2236
2847
|
}
|
|
2237
2848
|
} else {
|
|
2238
2849
|
console.log(rulesMd);
|