@urbicon-ui/mcp-server 6.1.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.
Files changed (130) hide show
  1. package/README.md +161 -0
  2. package/dist/data/catalog-loader.d.ts +37 -0
  3. package/dist/data/catalog-loader.d.ts.map +1 -0
  4. package/dist/data/catalog-loader.js +15 -0
  5. package/dist/data/catalog-loader.js.map +1 -0
  6. package/dist/data/component-loader.d.ts +2 -0
  7. package/dist/data/component-loader.d.ts.map +1 -0
  8. package/dist/data/component-loader.js +17 -0
  9. package/dist/data/component-loader.js.map +1 -0
  10. package/dist/data/recipe-loader.d.ts +4 -0
  11. package/dist/data/recipe-loader.d.ts.map +1 -0
  12. package/dist/data/recipe-loader.js +102 -0
  13. package/dist/data/recipe-loader.js.map +1 -0
  14. package/dist/data/template-loader.d.ts +8 -0
  15. package/dist/data/template-loader.d.ts.map +1 -0
  16. package/dist/data/template-loader.js +33 -0
  17. package/dist/data/template-loader.js.map +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +57 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/resources/catalog.d.ts +3 -0
  23. package/dist/resources/catalog.d.ts.map +1 -0
  24. package/dist/resources/catalog.js +20 -0
  25. package/dist/resources/catalog.js.map +1 -0
  26. package/dist/resources/component.d.ts +3 -0
  27. package/dist/resources/component.d.ts.map +1 -0
  28. package/dist/resources/component.js +29 -0
  29. package/dist/resources/component.js.map +1 -0
  30. package/dist/resources/guides.d.ts +3 -0
  31. package/dist/resources/guides.d.ts.map +1 -0
  32. package/dist/resources/guides.js +36 -0
  33. package/dist/resources/guides.js.map +1 -0
  34. package/dist/server.d.ts +3 -0
  35. package/dist/server.d.ts.map +1 -0
  36. package/dist/server.js.map +1 -0
  37. package/dist/tools/find-components.d.ts +3 -0
  38. package/dist/tools/find-components.d.ts.map +1 -0
  39. package/dist/tools/find-components.js +21 -0
  40. package/dist/tools/find-components.js.map +1 -0
  41. package/dist/tools/get-recipe.d.ts +3 -0
  42. package/dist/tools/get-recipe.d.ts.map +1 -0
  43. package/dist/tools/get-recipe.js +48 -0
  44. package/dist/tools/get-recipe.js.map +1 -0
  45. package/dist/tools/suggest-implementation.d.ts +3 -0
  46. package/dist/tools/suggest-implementation.d.ts.map +1 -0
  47. package/dist/tools/suggest-implementation.js +178 -0
  48. package/dist/tools/suggest-implementation.js.map +1 -0
  49. package/dist/transports/http.d.ts +2 -0
  50. package/dist/transports/http.d.ts.map +1 -0
  51. package/dist/transports/http.js +77 -0
  52. package/dist/transports/http.js.map +1 -0
  53. package/dist/transports/stdio.d.ts +3 -0
  54. package/dist/transports/stdio.d.ts.map +1 -0
  55. package/dist/transports/stdio.js +6 -0
  56. package/dist/transports/stdio.js.map +1 -0
  57. package/dist/tsconfig.tsbuildinfo +1 -0
  58. package/dist/utils/format-catalog.d.ts +7 -0
  59. package/dist/utils/format-catalog.d.ts.map +1 -0
  60. package/dist/utils/format-catalog.js +93 -0
  61. package/dist/utils/format-catalog.js.map +1 -0
  62. package/dist/utils/paths.d.ts +7 -0
  63. package/dist/utils/paths.d.ts.map +1 -0
  64. package/dist/utils/paths.js +23 -0
  65. package/dist/utils/paths.js.map +1 -0
  66. package/dist/utils/search.d.ts +3 -0
  67. package/dist/utils/search.d.ts.map +1 -0
  68. package/dist/utils/search.js +44 -0
  69. package/dist/utils/search.js.map +1 -0
  70. package/package.json +42 -0
  71. package/src/data/catalog-loader.test.ts +42 -0
  72. package/src/data/catalog-loader.ts +78 -0
  73. package/src/data/component-loader.ts +68 -0
  74. package/src/data/design-system-loader.test.ts +82 -0
  75. package/src/data/design-system-loader.ts +125 -0
  76. package/src/data/icon-loader.test.ts +85 -0
  77. package/src/data/icon-loader.ts +90 -0
  78. package/src/data/recipe-loader.test.ts +49 -0
  79. package/src/data/recipe-loader.ts +131 -0
  80. package/src/data/template-loader.ts +55 -0
  81. package/src/design-linter/heuristics.ts +162 -0
  82. package/src/design-linter/index.ts +14 -0
  83. package/src/design-linter/linter.test.ts +257 -0
  84. package/src/design-linter/linter.ts +62 -0
  85. package/src/design-linter/rules.ts +348 -0
  86. package/src/design-linter/tokens.test.ts +80 -0
  87. package/src/design-linter/tokens.ts +203 -0
  88. package/src/design-linter/types.ts +66 -0
  89. package/src/design-manifest/index.ts +20 -0
  90. package/src/design-manifest/manifest.test.ts +175 -0
  91. package/src/design-manifest/manifest.ts +250 -0
  92. package/src/design-manifest/scan.test.ts +51 -0
  93. package/src/design-manifest/scan.ts +74 -0
  94. package/src/design-manifest/types.ts +40 -0
  95. package/src/design-rubric/rubric.test.ts +43 -0
  96. package/src/design-rubric/rubric.ts +140 -0
  97. package/src/eval/briefs.ts +104 -0
  98. package/src/eval/eval.test.ts +99 -0
  99. package/src/eval/index.ts +11 -0
  100. package/src/eval/score.ts +112 -0
  101. package/src/index.ts +75 -0
  102. package/src/prompts/design-prompts.test.ts +51 -0
  103. package/src/prompts/design-prompts.ts +127 -0
  104. package/src/resources/catalog.ts +23 -0
  105. package/src/resources/guides.ts +60 -0
  106. package/src/server.test.ts +69 -0
  107. package/src/server.ts +48 -0
  108. package/src/tools/find-components.ts +83 -0
  109. package/src/tools/find-icons.ts +77 -0
  110. package/src/tools/get-checklist.ts +139 -0
  111. package/src/tools/get-component.ts +204 -0
  112. package/src/tools/get-css-reference.ts +446 -0
  113. package/src/tools/get-design-context.ts +43 -0
  114. package/src/tools/get-design-principles.ts +72 -0
  115. package/src/tools/get-pattern.ts +69 -0
  116. package/src/tools/get-recipe.ts +80 -0
  117. package/src/tools/record-design-decision.ts +99 -0
  118. package/src/tools/suggest-implementation.ts +251 -0
  119. package/src/tools/sync-design-manifest.ts +92 -0
  120. package/src/tools/validate-design.ts +84 -0
  121. package/src/transports/http.ts +79 -0
  122. package/src/transports/stdio.ts +7 -0
  123. package/src/utils/format-catalog.test.ts +144 -0
  124. package/src/utils/format-catalog.ts +130 -0
  125. package/src/utils/paths.test.ts +101 -0
  126. package/src/utils/paths.ts +78 -0
  127. package/src/utils/search.test.ts +141 -0
  128. package/src/utils/search.ts +106 -0
  129. package/tsconfig.json +27 -0
  130. package/vitest.config.ts +15 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Distribution heuristics — stage 2 of the validator. These are *judgements*
3
+ * about how design properties are spread across a page, not facts, so they emit
4
+ * `info`/`heuristic` findings with a light score impact. They operationalise the
5
+ * Design-Quality guidance ("Color = meaning", "Spacing = hierarchy", "Vary
6
+ * visual weight", "Commit to a radius") and target the exact monotony that
7
+ * scored lowest in design-quality evaluation (uniform spacing, rainbow
8
+ * intents, identical Cards, zero radius strategy).
9
+ *
10
+ * Thresholds are named constants so the eval-suite (WP5) can tune them with data
11
+ * instead of guesswork.
12
+ */
13
+
14
+ import type { Finding } from './types.js';
15
+
16
+ /**
17
+ * Chromatic intents only — `neutral` is structural greyscale, so neutral
18
+ * backgrounds (common and legitimate) must not count toward a decorative
19
+ * "rainbow". `info` is a distinct blue hue and does count.
20
+ */
21
+ const CHROMATIC_INTENTS = ['primary', 'secondary', 'success', 'warning', 'danger', 'info'] as const;
22
+
23
+ /** Tunable thresholds. Kept together so evaluation can adjust them in one place. */
24
+ export const HEURISTIC_THRESHOLDS = {
25
+ /** Distinct intent hues used as backgrounds before it reads as a decorative "rainbow". */
26
+ rainbowIntentFamilies: 4,
27
+ /** Minimum spacing utilities before uniformity is judged (avoids nagging tiny snippets). */
28
+ minSpacingUtilities: 6,
29
+ /** Minimum Card instances before monotony is judged. */
30
+ minCards: 4,
31
+ /** Minimum structural surfaces before a missing radius strategy is worth a nudge. */
32
+ minSurfacesForRadius: 3
33
+ } as const;
34
+
35
+ /** All atoms inside class strings, lower-cased, comments already masked upstream. */
36
+ function collectAtoms(code: string): string[] {
37
+ const atoms: string[] = [];
38
+ // class="...", class={...}, slotClasses={{ base: '...' }} — grab quoted runs of utility-ish text.
39
+ const stringRe = /["'`]([^"'`]*?)["'`]/g;
40
+ for (const m of code.matchAll(stringRe)) {
41
+ const body = m[1]!;
42
+ if (!/[a-z]-/.test(body) && !/\b(flex|grid|block|hidden|relative|absolute)\b/.test(body))
43
+ continue;
44
+ for (const atom of body.split(/\s+/)) {
45
+ if (atom) atoms.push(atom);
46
+ }
47
+ }
48
+ return atoms;
49
+ }
50
+
51
+ /** "Color = meaning": flag a decorative rainbow of intent backgrounds. */
52
+ function checkIntentRainbow(atoms: string[]): Finding[] {
53
+ const families = new Set<string>();
54
+ const bgIntent = new RegExp(
55
+ `^bg-(${CHROMATIC_INTENTS.join('|')})(?:-(?:hover|active|subtle|emphasis|\\d{2,3}))?(?:\\/\\d{1,3})?$`
56
+ );
57
+ for (const atom of atoms) {
58
+ const m = atom.match(bgIntent);
59
+ if (m) families.add(m[1]!);
60
+ }
61
+ if (families.size >= HEURISTIC_THRESHOLDS.rainbowIntentFamilies) {
62
+ return [
63
+ {
64
+ ruleId: 'intent-rainbow',
65
+ severity: 'info',
66
+ kind: 'heuristic',
67
+ message: `${families.size} different intent hues used as backgrounds (${[...families].join(', ')}). Reads as decoration, not meaning.`,
68
+ fix: 'Let neutral surfaces dominate (80–90%). Reserve intent colour for genuine status/severity/action signals.'
69
+ }
70
+ ];
71
+ }
72
+ return [];
73
+ }
74
+
75
+ /** "Spacing = hierarchy": flag a single uniform rhythm tier. */
76
+ function checkSpacingUniformity(atoms: string[]): Finding[] {
77
+ const values = new Set<string>();
78
+ let total = 0;
79
+ const spacingRe = /^(?:gap|gap-x|gap-y|space-x|space-y)-(\d+(?:\.\d+)?|px)$/;
80
+ for (const atom of atoms) {
81
+ const m = atom.match(spacingRe);
82
+ if (m) {
83
+ values.add(m[1]!);
84
+ total++;
85
+ }
86
+ }
87
+ if (total >= HEURISTIC_THRESHOLDS.minSpacingUtilities && values.size <= 1) {
88
+ return [
89
+ {
90
+ ruleId: 'spacing-uniform',
91
+ severity: 'info',
92
+ kind: 'heuristic',
93
+ message: `All ${total} spacing utilities use one value (\`${[...values][0] ?? '?'}\`). No tight-within vs generous-between rhythm.`,
94
+ fix: 'Use two tiers: tight (`gap-2`/`gap-3`) within related items, generous (`gap-8`/`gap-10`) between sections.'
95
+ }
96
+ ];
97
+ }
98
+ return [];
99
+ }
100
+
101
+ /** "Vary visual weight": flag many identical Cards (same variant + padding). */
102
+ function checkCardMonotony(code: string): Finding[] {
103
+ const cardRe = /<Card\b([^>]*)>/g;
104
+ const signatures: string[] = [];
105
+ for (const m of code.matchAll(cardRe)) {
106
+ const attrs = m[1]!;
107
+ const variant = attrs.match(/\bvariant=(?:"([^"]*)"|'([^']*)'|\{['"]([^'"]*)['"]\})/);
108
+ const padding = attrs.match(/\bpadding=(?:"([^"]*)"|'([^']*)'|\{['"]([^'"]*)['"]\})/);
109
+ const v = variant ? (variant[1] ?? variant[2] ?? variant[3]) : 'default';
110
+ const p = padding ? (padding[1] ?? padding[2] ?? padding[3]) : 'default';
111
+ signatures.push(`${v}/${p}`);
112
+ }
113
+ if (signatures.length >= HEURISTIC_THRESHOLDS.minCards) {
114
+ const distinct = new Set(signatures);
115
+ if (distinct.size === 1) {
116
+ return [
117
+ {
118
+ ruleId: 'card-monotony',
119
+ severity: 'info',
120
+ kind: 'heuristic',
121
+ message: `All ${signatures.length} Cards share one look (\`${[...distinct][0]}\`). Visual weight does not vary.`,
122
+ fix: 'Differentiate: prominent content `variant="elevated"`/`padding="lg"`, secondary `variant="outlined"`/`padding="md"`.'
123
+ }
124
+ ];
125
+ }
126
+ }
127
+ return [];
128
+ }
129
+
130
+ /** "Commit to a radius": nudge when structural surfaces exist but no radius override does. */
131
+ function checkRadiusStrategy(code: string, atoms: string[]): Finding[] {
132
+ // Only count genuine container surfaces. A bare `border`/`border-b` atom is far
133
+ // more often a table row, list divider, or input frame than a Card-like surface,
134
+ // so including it inflated the count into false positives (review finding #1).
135
+ const surfaceCount =
136
+ (code.match(/<Card\b/g)?.length ?? 0) +
137
+ (code.match(/<(?:Dialog|Drawer|Popover)\b/g)?.length ?? 0);
138
+ const hasRadiusOverride = atoms.some((a) => /^rounded(?:-|$)/.test(a));
139
+ if (surfaceCount >= HEURISTIC_THRESHOLDS.minSurfacesForRadius && !hasRadiusOverride) {
140
+ return [
141
+ {
142
+ ruleId: 'no-radius-strategy',
143
+ severity: 'info',
144
+ kind: 'heuristic',
145
+ message: 'Multiple surfaces but no explicit radius — relying solely on component defaults.',
146
+ fix: 'If the default tier radii do not match your design identity, commit to one philosophy (`rounded-lg`/`rounded-xl`/`rounded-2xl`) applied consistently via `class`/`slotClasses`.'
147
+ }
148
+ ];
149
+ }
150
+ return [];
151
+ }
152
+
153
+ /** Run all heuristics over the (comment-masked) code. */
154
+ export function runHeuristics(code: string): Finding[] {
155
+ const atoms = collectAtoms(code);
156
+ return [
157
+ ...checkIntentRainbow(atoms),
158
+ ...checkSpacingUniformity(atoms),
159
+ ...checkCardMonotony(code),
160
+ ...checkRadiusStrategy(code, atoms)
161
+ ];
162
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Public API of the Urbicon UI design linter.
3
+ *
4
+ * The linter is the deterministic half of the "generate → validate → fix" loop
5
+ * (docs/DESIGN-MCP.md, Option B). It is consumed by the `validate_design` MCP
6
+ * tool and, programmatically, by the eval-suite (WP5) which scores generated
7
+ * pages without an LLM in the loop.
8
+ */
9
+
10
+ export { HEURISTIC_THRESHOLDS } from './heuristics.js';
11
+ export { lintDesign, maskComments, SCORE_WEIGHTS } from './linter.js';
12
+ export { RULES } from './rules.js';
13
+ export { VALID_TOKEN_CORES } from './tokens.js';
14
+ export type { Finding, FindingKind, LintOptions, LintReport, Rule, Severity } from './types.js';
@@ -0,0 +1,257 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { lintDesign, maskComments } from './linter.js';
3
+ import type { Finding } from './types.js';
4
+
5
+ function ids(findings: Finding[]): string[] {
6
+ return findings.map((f) => f.ruleId);
7
+ }
8
+ function has(findings: Finding[], ruleId: string): boolean {
9
+ return findings.some((f) => f.ruleId === ruleId);
10
+ }
11
+
12
+ describe('raw-tailwind-color', () => {
13
+ it('flags numbered chromatic palette utilities', () => {
14
+ const { findings } = lintDesign(
15
+ '<div class="bg-blue-500 text-red-600 border-l-amber-400">x</div>'
16
+ );
17
+ expect(findings.filter((f) => f.ruleId === 'raw-tailwind-color')).toHaveLength(3);
18
+ });
19
+ it('flags opacity-suffixed raw colours', () => {
20
+ expect(has(lintDesign('<div class="bg-green-500/40">').findings, 'raw-tailwind-color')).toBe(
21
+ true
22
+ );
23
+ });
24
+ it('does NOT flag library tokens (intents, neutral, surfaces)', () => {
25
+ const { findings } = lintDesign(
26
+ '<div class="bg-primary-500 bg-neutral-100 bg-surface-base text-success border-border-subtle">'
27
+ );
28
+ expect(has(findings, 'raw-tailwind-color')).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe('dark-mode-override', () => {
33
+ it('flags dark: variants', () => {
34
+ expect(
35
+ has(
36
+ lintDesign('<div class="bg-white dark:bg-surface-elevated">').findings,
37
+ 'dark-mode-override'
38
+ )
39
+ ).toBe(true);
40
+ });
41
+ it('flags the important-modifier form dark:!', () => {
42
+ expect(
43
+ has(lintDesign('<div class="dark:!bg-surface-elevated">').findings, 'dark-mode-override')
44
+ ).toBe(true);
45
+ });
46
+ it('does NOT flag plain semantic tokens', () => {
47
+ expect(
48
+ has(lintDesign('<div class="bg-surface-elevated">').findings, 'dark-mode-override')
49
+ ).toBe(false);
50
+ });
51
+ });
52
+
53
+ describe('focus-not-visible', () => {
54
+ it('flags focus: and group-focus:', () => {
55
+ expect(has(lintDesign('<button class="focus:ring-2">').findings, 'focus-not-visible')).toBe(
56
+ true
57
+ );
58
+ expect(
59
+ has(lintDesign('<div class="group-focus:opacity-100">').findings, 'focus-not-visible')
60
+ ).toBe(true);
61
+ });
62
+ it('does NOT flag focus-visible: or focus-within:', () => {
63
+ const { findings } = lintDesign(
64
+ '<button class="focus-visible:ring-2 focus-within:bg-surface-hover">'
65
+ );
66
+ expect(has(findings, 'focus-not-visible')).toBe(false);
67
+ });
68
+ });
69
+
70
+ describe('hardcoded-z-index', () => {
71
+ it('flags numeric and bracketed z-index', () => {
72
+ expect(has(lintDesign('<div class="z-10">').findings, 'hardcoded-z-index')).toBe(true);
73
+ expect(has(lintDesign('<div class="z-[999]">').findings, 'hardcoded-z-index')).toBe(true);
74
+ });
75
+ it('does NOT flag the z-index token form or z-auto', () => {
76
+ const { findings } = lintDesign('<div class="z-[var(--z-modal)] z-auto">');
77
+ expect(has(findings, 'hardcoded-z-index')).toBe(false);
78
+ });
79
+ });
80
+
81
+ describe('dynamic-class-interpolation', () => {
82
+ it('flags interpolated Tailwind utility fragments', () => {
83
+ expect(
84
+ has(
85
+ lintDesign("<div class=\"gap-{isHero ? '4' : '3'}\">").findings,
86
+ 'dynamic-class-interpolation'
87
+ )
88
+ ).toBe(true);
89
+ expect(
90
+ has(lintDesign('<div class={`py-${pad}`}>').findings, 'dynamic-class-interpolation')
91
+ ).toBe(true);
92
+ });
93
+ it('does NOT flag interpolation that is not a Tailwind root (ids, data keys)', () => {
94
+ const { findings } = lintDesign('<label for={`field-${id}`}>');
95
+ expect(has(findings, 'dynamic-class-interpolation')).toBe(false);
96
+ });
97
+ });
98
+
99
+ describe('token-hallucination', () => {
100
+ it('flags invented status-* and -fg tokens', () => {
101
+ expect(has(lintDesign('<div class="bg-status-danger">').findings, 'token-hallucination')).toBe(
102
+ true
103
+ );
104
+ expect(has(lintDesign('<div class="text-success-fg">').findings, 'token-hallucination')).toBe(
105
+ true
106
+ );
107
+ });
108
+ it('flags intent-with-bad-suffix and namespace typos', () => {
109
+ expect(has(lintDesign('<div class="bg-primary-muted">').findings, 'token-hallucination')).toBe(
110
+ true
111
+ );
112
+ expect(has(lintDesign('<div class="bg-surface-raised">').findings, 'token-hallucination')).toBe(
113
+ true
114
+ );
115
+ });
116
+ it('does NOT flag valid tokens', () => {
117
+ const valid =
118
+ '<div class="bg-surface-subtle text-text-primary bg-primary bg-primary-500 text-success bg-feedback-success-subtle border-border-strong">';
119
+ expect(has(lintDesign(valid).findings, 'token-hallucination')).toBe(false);
120
+ });
121
+ it('does NOT flag genuine Tailwind utilities or arbitrary values', () => {
122
+ const { findings } = lintDesign(
123
+ '<div class="bg-transparent bg-[#fff] text-sm bg-cover from-transparent">'
124
+ );
125
+ expect(has(findings, 'token-hallucination')).toBe(false);
126
+ });
127
+ it('does NOT flag font-size cores sharing the text- namespace', () => {
128
+ const { findings } = lintDesign('<div class="bg-text-sm text-text-2xl border-text-base">');
129
+ expect(has(findings, 'token-hallucination')).toBe(false);
130
+ });
131
+ it('keeps the opacity suffix in the reported match', () => {
132
+ const f = lintDesign('<div class="bg-surface-raised/50">').findings.find(
133
+ (x) => x.ruleId === 'token-hallucination'
134
+ );
135
+ expect(f?.match).toBe('bg-surface-raised/50');
136
+ });
137
+ it('flags shadcn/ui vocabulary — the top hallucination source (round-3 finding)', () => {
138
+ const code =
139
+ '<div class="text-foreground bg-accent text-muted-foreground bg-card bg-surface text-fg text-fg-muted border-card-foreground bg-destructive">x</div>';
140
+ const matches = lintDesign(code)
141
+ .findings.filter((f) => f.ruleId === 'token-hallucination')
142
+ .map((f) => f.match);
143
+ for (const t of [
144
+ 'text-foreground',
145
+ 'bg-accent',
146
+ 'text-muted-foreground',
147
+ 'bg-card',
148
+ 'bg-surface',
149
+ 'text-fg',
150
+ 'text-fg-muted',
151
+ 'border-card-foreground',
152
+ 'bg-destructive'
153
+ ]) {
154
+ expect(matches, t).toContain(t);
155
+ }
156
+ });
157
+ });
158
+
159
+ describe('heuristics', () => {
160
+ it('flags an intent rainbow of ≥4 chromatic background hues', () => {
161
+ const code =
162
+ '<div class="bg-primary"></div><div class="bg-success"></div><div class="bg-warning"></div><div class="bg-danger"></div>';
163
+ expect(has(lintDesign(code).findings, 'intent-rainbow')).toBe(true);
164
+ });
165
+ it('does NOT count neutral backgrounds toward the rainbow', () => {
166
+ const code =
167
+ '<div class="bg-neutral-100"></div><div class="bg-neutral-200"></div><div class="bg-surface-base"></div><div class="bg-surface-elevated"></div>';
168
+ expect(has(lintDesign(code).findings, 'intent-rainbow')).toBe(false);
169
+ });
170
+ it('flags uniform spacing (one rhythm tier)', () => {
171
+ const code =
172
+ '<div class="gap-4"><div class="gap-4"></div><div class="gap-4"></div><div class="gap-4"></div><div class="gap-4"></div><div class="gap-4"></div></div>';
173
+ expect(has(lintDesign(code).findings, 'spacing-uniform')).toBe(true);
174
+ });
175
+ it('does NOT flag two-tier spacing', () => {
176
+ const code =
177
+ '<div class="gap-10"><div class="gap-3"></div><div class="gap-3"></div><div class="gap-10"></div><div class="gap-3"></div><div class="gap-10"></div></div>';
178
+ expect(has(lintDesign(code).findings, 'spacing-uniform')).toBe(false);
179
+ });
180
+ it('flags identical Cards (no visual-weight variation)', () => {
181
+ const card = '<Card variant="elevated" padding="md">x</Card>';
182
+ expect(has(lintDesign(card.repeat(4)).findings, 'card-monotony')).toBe(true);
183
+ });
184
+ it('does NOT flag differentiated Cards', () => {
185
+ const code =
186
+ '<Card variant="elevated" padding="lg">x</Card><Card variant="outlined" padding="md">x</Card><Card variant="outlined" padding="md">x</Card><Card variant="quiet" padding="sm">x</Card>';
187
+ expect(has(lintDesign(code).findings, 'card-monotony')).toBe(false);
188
+ });
189
+ it('nudges when surfaces exist but no radius strategy does', () => {
190
+ const code = '<Card>a</Card><Card>b</Card><Card>c</Card>';
191
+ expect(has(lintDesign(code).findings, 'no-radius-strategy')).toBe(true);
192
+ });
193
+ it('does NOT nudge once a radius override is present', () => {
194
+ const code =
195
+ '<Card class="rounded-xl">a</Card><Card class="rounded-xl">b</Card><Card class="rounded-xl">c</Card>';
196
+ expect(has(lintDesign(code).findings, 'no-radius-strategy')).toBe(false);
197
+ });
198
+ it('does NOT treat bordered table rows / dividers as surfaces (no false radius nudge)', () => {
199
+ const code =
200
+ '<table><tr class="border-b"><td>a</td></tr><tr class="border-b"><td>b</td></tr><tr class="border-b"><td>c</td></tr></table>';
201
+ expect(has(lintDesign(code).findings, 'no-radius-strategy')).toBe(false);
202
+ });
203
+ it('can be skipped via skipHeuristics', () => {
204
+ const code = '<Card variant="elevated" padding="md">x</Card>'.repeat(4);
205
+ const { findings } = lintDesign(code, { skipHeuristics: true });
206
+ expect(findings.every((f) => f.kind === 'deterministic')).toBe(true);
207
+ });
208
+ });
209
+
210
+ describe('comment masking', () => {
211
+ it('ignores violations inside HTML and block comments', () => {
212
+ const code =
213
+ '<!-- class="focus:ring-2 bg-blue-500" --><div class="bg-surface-base">/* z-50 */</div>';
214
+ const { findings } = lintDesign(code);
215
+ expect(findings).toHaveLength(0);
216
+ });
217
+ it('keeps line numbers correct after masking', () => {
218
+ const masked = maskComments('a\n<!--\nx\n-->\nfocus:ring');
219
+ expect(masked.split('\n')).toHaveLength(5);
220
+ });
221
+ });
222
+
223
+ describe('scoring', () => {
224
+ it('scores clean code 100', () => {
225
+ const { score } = lintDesign('<div class="bg-surface-base text-text-primary">clean</div>');
226
+ expect(score).toBe(100);
227
+ });
228
+ it('deducts per finding and floors at 0', () => {
229
+ const oneError = lintDesign('<div class="bg-blue-500">');
230
+ expect(oneError.score).toBe(90);
231
+ // Per-line dedupe collapses identical hits on one line, so spread distinct hits across lines.
232
+ const many = lintDesign(
233
+ Array.from({ length: 12 }, () => '<div class="bg-blue-500">').join('\n')
234
+ );
235
+ expect(many.score).toBe(0);
236
+ });
237
+ it('reports severity counts', () => {
238
+ const { counts } = lintDesign('<div class="bg-blue-500 bg-status-x">');
239
+ expect(counts.error).toBeGreaterThanOrEqual(1);
240
+ expect(counts.warning).toBeGreaterThanOrEqual(1);
241
+ });
242
+ });
243
+
244
+ describe('rule metadata', () => {
245
+ it('every finding carries a fix hint', () => {
246
+ const code =
247
+ '<button class="bg-blue-500 dark:bg-red-500 focus:ring z-50 gap-{x} bg-status-bad">';
248
+ for (const f of lintDesign(code).findings) {
249
+ expect(f.fix.length).toBeGreaterThan(0);
250
+ expect(f.ruleId.length).toBeGreaterThan(0);
251
+ }
252
+ });
253
+ it('produces a stable id ordering for the same input', () => {
254
+ const code = '<div class="z-50 bg-blue-500">';
255
+ expect(ids(lintDesign(code).findings)).toEqual(ids(lintDesign(code).findings));
256
+ });
257
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The design linter engine: masks comments, runs the deterministic rules and the
3
+ * distribution heuristics, and reduces the findings to a 0–100 score. Pure and
4
+ * dependency-free so it is trivially unit-testable (one of the explicit wins of
5
+ * a linter over prose guidance — see docs/DESIGN-MCP.md, Option B).
6
+ */
7
+
8
+ import { runHeuristics } from './heuristics.js';
9
+ import { RULES } from './rules.js';
10
+ import type { Finding, LintOptions, LintReport, Severity } from './types.js';
11
+
12
+ /** Per-severity score deduction. Errors dominate (they are real defects). Centralised for tuning. */
13
+ export const SCORE_WEIGHTS: Record<Severity, number> = {
14
+ error: 10,
15
+ warning: 5,
16
+ info: 2
17
+ };
18
+
19
+ /**
20
+ * Blank out comment bodies while preserving newlines (so line numbers stay
21
+ * correct) — keeps the rules from firing on `focus:` in a `<!-- … -->` note or a
22
+ * `/* … *\/` block. Line comments are intentionally left alone: masking `//`
23
+ * safely (without eating `https://`) is not worth the complexity for v1.
24
+ */
25
+ export function maskComments(code: string): string {
26
+ const blankKeepNewlines = (s: string) => s.replace(/[^\n]/g, ' ');
27
+ return code
28
+ .replace(/<!--[\s\S]*?-->/g, blankKeepNewlines)
29
+ .replace(/\/\*[\s\S]*?\*\//g, blankKeepNewlines);
30
+ }
31
+
32
+ const SEVERITY_ORDER: Record<Severity, number> = { error: 0, warning: 1, info: 2 };
33
+
34
+ /** Lint one code unit. Returns findings, a score, and severity counts. */
35
+ export function lintDesign(code: string, opts: LintOptions = {}): LintReport {
36
+ const masked = maskComments(code);
37
+ const lines = masked.split('\n');
38
+
39
+ const findings: Finding[] = [];
40
+ for (const rule of RULES) {
41
+ findings.push(...rule.check(lines, masked));
42
+ }
43
+ if (!opts.skipHeuristics) {
44
+ findings.push(...runHeuristics(masked));
45
+ }
46
+
47
+ findings.sort((a, b) => {
48
+ const lineDiff = (a.line ?? Infinity) - (b.line ?? Infinity);
49
+ if (lineDiff !== 0) return lineDiff;
50
+ return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
51
+ });
52
+
53
+ const counts = { error: 0, warning: 0, info: 0 };
54
+ let deduction = 0;
55
+ for (const f of findings) {
56
+ counts[f.severity]++;
57
+ deduction += SCORE_WEIGHTS[f.severity];
58
+ }
59
+ const score = Math.max(0, 100 - deduction);
60
+
61
+ return { findings, score, counts, filename: opts.filename };
62
+ }