@urbicon-ui/design-engine 6.1.8
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 +36 -0
- package/package.json +47 -0
- package/src/index.ts +23 -0
- package/src/linter/heuristics.ts +609 -0
- package/src/linter/index.ts +23 -0
- package/src/linter/linter.test.ts +509 -0
- package/src/linter/linter.ts +96 -0
- package/src/linter/markup-rules.test.ts +109 -0
- package/src/linter/markup-rules.ts +209 -0
- package/src/linter/markup.test.ts +139 -0
- package/src/linter/markup.ts +274 -0
- package/src/linter/rules.ts +354 -0
- package/src/linter/tokens.test.ts +111 -0
- package/src/linter/tokens.ts +230 -0
- package/src/linter/types.ts +119 -0
- package/src/manifest/history.test.ts +65 -0
- package/src/manifest/history.ts +57 -0
- package/src/manifest/index.ts +27 -0
- package/src/manifest/manifest.test.ts +338 -0
- package/src/manifest/manifest.ts +439 -0
- package/src/manifest/scan.test.ts +51 -0
- package/src/manifest/scan.ts +74 -0
- package/src/manifest/types.ts +98 -0
- package/src/rubric/index.ts +10 -0
- package/src/rubric/rubric.test.ts +43 -0
- package/src/rubric/rubric.ts +140 -0
- package/src/search/index.ts +12 -0
- package/src/search/match.ts +115 -0
- package/src/search/search.test.ts +195 -0
- package/src/search/section.ts +47 -0
- package/src/search/types.ts +44 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, dependency-free markup scanner — the structural pass the regex rules
|
|
3
|
+
* cannot be (DESIGN-MCP-V2 §6/§10 "AST pass"). It does NOT build a full Svelte
|
|
4
|
+
* AST (that would mean a `svelte/compiler` dependency, and this engine is
|
|
5
|
+
* zero-dep): it extracts the one thing the line-based rules miss — *which
|
|
6
|
+
* attribute belongs to which element* — by walking the source once and emitting a
|
|
7
|
+
* flat list of opening tags with their attributes, plus a helper to slice an
|
|
8
|
+
* element's inner content.
|
|
9
|
+
*
|
|
10
|
+
* It is deliberately conservative: anything it cannot parse confidently (an
|
|
11
|
+
* unterminated tag, an exotic expression) is skipped, never guessed. A rule built
|
|
12
|
+
* on this therefore never fires on a mis-parse — a missed element is silence, not
|
|
13
|
+
* a false positive, which is the contract the correctness gate depends on.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** How an attribute carries its value. */
|
|
17
|
+
export type AttrKind =
|
|
18
|
+
| 'string' // attr="literal" / attr='literal' / attr=bare
|
|
19
|
+
| 'expression' // attr={expr}
|
|
20
|
+
| 'boolean' // bare attr, no value
|
|
21
|
+
| 'shorthand' // {value} — name and expression are the same identifier
|
|
22
|
+
| 'spread'; // {...rest}
|
|
23
|
+
|
|
24
|
+
export interface Attr {
|
|
25
|
+
/** Attribute name, e.g. `variant`, `aria-label`, `on:click`. Empty for spread/shorthand. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** Raw inner value (no quotes/braces); `null` for a boolean attribute. */
|
|
28
|
+
value: string | null;
|
|
29
|
+
kind: AttrKind;
|
|
30
|
+
/** 1-based line of the attribute name. */
|
|
31
|
+
line: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Element {
|
|
35
|
+
/** Tag name as written, e.g. `Button`, `button`, `Foo.Bar`. */
|
|
36
|
+
tag: string;
|
|
37
|
+
/** PascalCase or dotted tag → a component (not a raw HTML element). */
|
|
38
|
+
isComponent: boolean;
|
|
39
|
+
attrs: Attr[];
|
|
40
|
+
/** 1-based line of the opening `<`. */
|
|
41
|
+
line: number;
|
|
42
|
+
selfClosing: boolean;
|
|
43
|
+
/** Char offset of the opening `<`. */
|
|
44
|
+
openStart: number;
|
|
45
|
+
/** Char offset just past the opening tag's `>`. */
|
|
46
|
+
openEnd: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const blankRegion = (s: string): string => s.replace(/[^\n]/g, ' ');
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Blank HTML comments and `<script>`/`<style>` blocks (keeping newlines so line
|
|
53
|
+
* numbers hold) — their bodies are comments/JS/CSS, not markup, and would feed the
|
|
54
|
+
* tag scanner garbage. Comments are blanked here too (not relying on an upstream
|
|
55
|
+
* mask) so {@link scanMarkup} and {@link innerContent} are correct on raw input.
|
|
56
|
+
*
|
|
57
|
+
* Caveat: the `<script>` match is non-greedy, so a `</script>` literal inside a JS
|
|
58
|
+
* string ends the blank early and the trailing JS is then scanned as markup. This
|
|
59
|
+
* is narrower than the line-based rules (which don't blank scripts at all); no real
|
|
60
|
+
* file hits it, and any mis-scan only ever yields a skipped tag, never a wrong
|
|
61
|
+
* finding from the curated rules.
|
|
62
|
+
*/
|
|
63
|
+
function blankNonMarkup(src: string): string {
|
|
64
|
+
return src
|
|
65
|
+
.replace(/<!--[\s\S]*?-->/g, blankRegion)
|
|
66
|
+
.replace(/<script[\s\S]*?<\/script>/gi, blankRegion)
|
|
67
|
+
.replace(/<style[\s\S]*?<\/style>/gi, blankRegion);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isNameStart = (c: string | undefined): boolean => c !== undefined && /[A-Za-z]/.test(c);
|
|
71
|
+
const isTagNameChar = (c: string | undefined): boolean =>
|
|
72
|
+
c !== undefined && /[A-Za-z0-9.\-:]/.test(c);
|
|
73
|
+
const isAttrNameChar = (c: string | undefined): boolean =>
|
|
74
|
+
c !== undefined && !/[\s=/>]/.test(c) && c !== '<';
|
|
75
|
+
|
|
76
|
+
/** Read a quoted string starting at `src[i]` (a quote char). Returns inner value + index past the close. */
|
|
77
|
+
function readQuoted(src: string, i: number): { value: string; end: number } {
|
|
78
|
+
const quote = src[i];
|
|
79
|
+
let j = i + 1;
|
|
80
|
+
while (j < src.length && src[j] !== quote) j++;
|
|
81
|
+
return { value: src.slice(i + 1, j), end: j + 1 }; // j+1 steps past the closing quote (or EOF)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read a balanced `{…}` expression starting at `src[i]` (`{`). Brace depth counts
|
|
86
|
+
* outside of `"`/`'` strings (so a `}` inside a string literal does not close it,
|
|
87
|
+
* and a `\"` escape does not end the string early); `${…}` in template literals
|
|
88
|
+
* balances naturally through the same counter. Returns the inner text + index past
|
|
89
|
+
* the closing brace, or `end: -1` if never closed.
|
|
90
|
+
*/
|
|
91
|
+
function readBraced(src: string, i: number): { value: string; end: number } {
|
|
92
|
+
let depth = 0;
|
|
93
|
+
let str: string | null = null; // active "/' string delimiter, if any
|
|
94
|
+
for (let j = i; j < src.length; j++) {
|
|
95
|
+
const c = src[j];
|
|
96
|
+
if (str !== null) {
|
|
97
|
+
if (c === '\\') {
|
|
98
|
+
j++; // a backslash escapes the next char — don't let `\"` close the string early
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (c === str) str = null;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (c === '"' || c === "'") str = c;
|
|
105
|
+
else if (c === '{') depth++;
|
|
106
|
+
else if (c === '}') {
|
|
107
|
+
depth--;
|
|
108
|
+
if (depth === 0) return { value: src.slice(i + 1, j), end: j + 1 };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { value: '', end: -1 };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Parse one attribute starting at `src[i]` (first name/`{` char). Returns the attr + next index, or null if malformed. */
|
|
115
|
+
function parseAttr(src: string, i: number, line: number): { attr: Attr; end: number } | null {
|
|
116
|
+
if (src[i] === '{') {
|
|
117
|
+
const { value, end } = readBraced(src, i);
|
|
118
|
+
if (end === -1) return null;
|
|
119
|
+
const trimmed = value.trim();
|
|
120
|
+
const spread = trimmed.startsWith('...');
|
|
121
|
+
return {
|
|
122
|
+
attr: {
|
|
123
|
+
name: spread ? '' : trimmed,
|
|
124
|
+
value: spread ? trimmed.slice(3).trim() : trimmed,
|
|
125
|
+
kind: spread ? 'spread' : 'shorthand',
|
|
126
|
+
line
|
|
127
|
+
},
|
|
128
|
+
end
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let j = i;
|
|
133
|
+
while (isAttrNameChar(src[j])) j++;
|
|
134
|
+
const name = src.slice(i, j);
|
|
135
|
+
if (name === '') return null; // not a valid attribute start — bail (caller skips the tag)
|
|
136
|
+
|
|
137
|
+
// Optional `= value`, allowing whitespace around `=`.
|
|
138
|
+
let k = j;
|
|
139
|
+
while (k < src.length && /\s/.test(src[k]!)) k++;
|
|
140
|
+
if (src[k] !== '=') {
|
|
141
|
+
return { attr: { name, value: null, kind: 'boolean', line }, end: j };
|
|
142
|
+
}
|
|
143
|
+
k++; // past '='
|
|
144
|
+
while (k < src.length && /\s/.test(src[k]!)) k++;
|
|
145
|
+
|
|
146
|
+
const c = src[k];
|
|
147
|
+
if (c === '"' || c === "'") {
|
|
148
|
+
const { value, end } = readQuoted(src, k);
|
|
149
|
+
return { attr: { name, value, kind: 'string', line }, end };
|
|
150
|
+
}
|
|
151
|
+
if (c === '{') {
|
|
152
|
+
const { value, end } = readBraced(src, k);
|
|
153
|
+
if (end === -1) return null;
|
|
154
|
+
return { attr: { name, value, kind: 'expression', line }, end };
|
|
155
|
+
}
|
|
156
|
+
// Bare unquoted value: read until whitespace or tag end.
|
|
157
|
+
let m = k;
|
|
158
|
+
while (m < src.length && !/[\s/>]/.test(src[m]!)) m++;
|
|
159
|
+
return { attr: { name, value: src.slice(k, m), kind: 'string', line }, end: m };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Parse an opening tag starting at `src[start]` (`<`). Returns the element + index past `>`, or null. */
|
|
163
|
+
function parseOpenTag(
|
|
164
|
+
src: string,
|
|
165
|
+
start: number,
|
|
166
|
+
line: number
|
|
167
|
+
): { element: Element; end: number } | null {
|
|
168
|
+
let i = start + 1;
|
|
169
|
+
while (isTagNameChar(src[i])) i++;
|
|
170
|
+
const tag = src.slice(start + 1, i);
|
|
171
|
+
if (tag === '') return null;
|
|
172
|
+
|
|
173
|
+
const attrs: Attr[] = [];
|
|
174
|
+
let curLine = line;
|
|
175
|
+
// Count newlines as we advance so each attr/tag gets the right line.
|
|
176
|
+
const bump = (from: number, to: number): void => {
|
|
177
|
+
for (let p = from; p < to; p++) if (src[p] === '\n') curLine++;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
let selfClosing = false;
|
|
181
|
+
let closed = false;
|
|
182
|
+
while (i < src.length) {
|
|
183
|
+
const before = i;
|
|
184
|
+
while (i < src.length && /\s/.test(src[i]!)) i++;
|
|
185
|
+
bump(before, i);
|
|
186
|
+
|
|
187
|
+
const c = src[i];
|
|
188
|
+
if (c === undefined) break; // EOF reached — `closed` stays false, rejected below
|
|
189
|
+
if (c === '>') {
|
|
190
|
+
i++;
|
|
191
|
+
closed = true;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
if (c === '/' && src[i + 1] === '>') {
|
|
195
|
+
selfClosing = true;
|
|
196
|
+
i += 2;
|
|
197
|
+
closed = true;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
const parsed = parseAttr(src, i, curLine);
|
|
201
|
+
if (!parsed) return null; // unparseable attribute — skip the whole tag conservatively
|
|
202
|
+
attrs.push(parsed.attr);
|
|
203
|
+
bump(i, parsed.end);
|
|
204
|
+
i = parsed.end;
|
|
205
|
+
}
|
|
206
|
+
if (!closed) return null; // ran off the end without a `>`/`/>` — malformed, skip
|
|
207
|
+
|
|
208
|
+
const isComponent = /^[A-Z]/.test(tag) || tag.includes('.');
|
|
209
|
+
return {
|
|
210
|
+
element: { tag, isComponent, attrs, line, selfClosing, openStart: start, openEnd: i },
|
|
211
|
+
end: i
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Scan source for opening element/component tags with their attributes. */
|
|
216
|
+
export function scanMarkup(source: string): Element[] {
|
|
217
|
+
const src = blankNonMarkup(source);
|
|
218
|
+
const elements: Element[] = [];
|
|
219
|
+
let line = 1;
|
|
220
|
+
let i = 0;
|
|
221
|
+
while (i < src.length) {
|
|
222
|
+
const c = src[i];
|
|
223
|
+
if (c === '\n') {
|
|
224
|
+
line++;
|
|
225
|
+
i++;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// A tag opens at `<` immediately followed by a letter (not `</`, `<!`, `< `).
|
|
229
|
+
if (c === '<' && isNameStart(src[i + 1])) {
|
|
230
|
+
const parsed = parseOpenTag(src, i, line);
|
|
231
|
+
if (parsed) {
|
|
232
|
+
elements.push(parsed.element);
|
|
233
|
+
for (let p = i; p < parsed.end; p++) if (src[p] === '\n') line++;
|
|
234
|
+
i = parsed.end;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
i++;
|
|
239
|
+
}
|
|
240
|
+
return elements;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Does `src` have `<tag`/`</tag` at `pos` with a real tag boundary after the name? */
|
|
244
|
+
function tagAt(src: string, pos: number, tag: string, closing: boolean): boolean {
|
|
245
|
+
const lead = closing ? `</${tag}` : `<${tag}`;
|
|
246
|
+
if (!src.startsWith(lead, pos)) return false;
|
|
247
|
+
const after = src[pos + lead.length];
|
|
248
|
+
return after === undefined || /[\s/>]/.test(after);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The raw inner content of an element (between its opening `>` and matching
|
|
253
|
+
* `</tag>`), honouring same-name nesting. Returns `null` for a self-closing
|
|
254
|
+
* element or when no balanced close is found — callers treat `null` as "unknown",
|
|
255
|
+
* and skip, so an unbalanced document never produces a false finding.
|
|
256
|
+
*/
|
|
257
|
+
export function innerContent(source: string, el: Element): string | null {
|
|
258
|
+
if (el.selfClosing) return null;
|
|
259
|
+
const src = blankNonMarkup(source);
|
|
260
|
+
let depth = 1;
|
|
261
|
+
let i = el.openEnd;
|
|
262
|
+
while (i < src.length) {
|
|
263
|
+
if (src[i] === '<') {
|
|
264
|
+
if (tagAt(src, i, el.tag, true)) {
|
|
265
|
+
depth--;
|
|
266
|
+
if (depth === 0) return src.slice(el.openEnd, i);
|
|
267
|
+
} else if (tagAt(src, i, el.tag, false)) {
|
|
268
|
+
depth++;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
i++;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic design rules. Each is a fact about the code — a regex/string
|
|
3
|
+
* match with no judgement — so it can carry `error`/`warning` severity and be
|
|
4
|
+
* covered by regression tests. The prose source is the Anti-Patterns section of
|
|
5
|
+
* `design-system/principles.md` plus the documented known failure modes
|
|
6
|
+
* (token hallucination, dynamic Tailwind classes).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { MARKUP_RULES } from './markup-rules.js';
|
|
10
|
+
import {
|
|
11
|
+
INTENT_NAMES,
|
|
12
|
+
INTENT_PREFIXES,
|
|
13
|
+
KNOWN_BAD_NAMESPACES,
|
|
14
|
+
KNOWN_FOREIGN_CORES,
|
|
15
|
+
SEMANTIC_NAMESPACES,
|
|
16
|
+
VALID_TOKEN_CORES
|
|
17
|
+
} from './tokens.js';
|
|
18
|
+
import type { Finding, Rule } from './types.js';
|
|
19
|
+
|
|
20
|
+
const SHADCN_FIX =
|
|
21
|
+
'This is shadcn/ui vocabulary, not Urbicon UI. Use surface tokens (`bg-surface-base`/`-elevated`), text tokens (`text-text-primary`/`-secondary`), or intents (`bg-primary`, `text-success`).';
|
|
22
|
+
|
|
23
|
+
/** shadcn/ui-family cores (bare set + `-foreground` suffix + `fg`/`fg-`). */
|
|
24
|
+
function isForeignVocab(core: string): boolean {
|
|
25
|
+
return (
|
|
26
|
+
KNOWN_FOREIGN_CORES.has(core) ||
|
|
27
|
+
core.endsWith('-foreground') ||
|
|
28
|
+
core === 'fg' ||
|
|
29
|
+
core.startsWith('fg-')
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Tailwind's default chromatic palette names — none are Urbicon UI tokens. `neutral` is ours, so it is excluded. */
|
|
34
|
+
const RAW_PALETTE =
|
|
35
|
+
'slate|gray|zinc|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose';
|
|
36
|
+
|
|
37
|
+
/** Colour-bearing Tailwind prefixes (longest first so `border-l` wins over `border`). */
|
|
38
|
+
const COLOR_PREFIXES = [
|
|
39
|
+
'ring-offset',
|
|
40
|
+
'border-x',
|
|
41
|
+
'border-y',
|
|
42
|
+
'border-t',
|
|
43
|
+
'border-r',
|
|
44
|
+
'border-b',
|
|
45
|
+
'border-l',
|
|
46
|
+
'border-s',
|
|
47
|
+
'border-e',
|
|
48
|
+
'bg',
|
|
49
|
+
'text',
|
|
50
|
+
'border',
|
|
51
|
+
'ring',
|
|
52
|
+
'divide',
|
|
53
|
+
'outline',
|
|
54
|
+
'decoration',
|
|
55
|
+
'fill',
|
|
56
|
+
'stroke',
|
|
57
|
+
'from',
|
|
58
|
+
'via',
|
|
59
|
+
'to',
|
|
60
|
+
'accent',
|
|
61
|
+
'caret',
|
|
62
|
+
'placeholder'
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** Tailwind utility roots that take a scale/value — used to catch broken dynamic interpolation. */
|
|
66
|
+
const DYNAMIC_UTILITY_ROOTS = [
|
|
67
|
+
'gap',
|
|
68
|
+
'gap-x',
|
|
69
|
+
'gap-y',
|
|
70
|
+
'space-x',
|
|
71
|
+
'space-y',
|
|
72
|
+
'p',
|
|
73
|
+
'px',
|
|
74
|
+
'py',
|
|
75
|
+
'pt',
|
|
76
|
+
'pb',
|
|
77
|
+
'pl',
|
|
78
|
+
'pr',
|
|
79
|
+
'm',
|
|
80
|
+
'mx',
|
|
81
|
+
'my',
|
|
82
|
+
'mt',
|
|
83
|
+
'mb',
|
|
84
|
+
'ml',
|
|
85
|
+
'mr',
|
|
86
|
+
'w',
|
|
87
|
+
'h',
|
|
88
|
+
'min-w',
|
|
89
|
+
'min-h',
|
|
90
|
+
'max-w',
|
|
91
|
+
'max-h',
|
|
92
|
+
'size',
|
|
93
|
+
'text',
|
|
94
|
+
'bg',
|
|
95
|
+
'border',
|
|
96
|
+
'rounded',
|
|
97
|
+
'grid-cols',
|
|
98
|
+
'grid-rows',
|
|
99
|
+
'col-span',
|
|
100
|
+
'row-span',
|
|
101
|
+
'top',
|
|
102
|
+
'left',
|
|
103
|
+
'right',
|
|
104
|
+
'bottom',
|
|
105
|
+
'inset',
|
|
106
|
+
'z',
|
|
107
|
+
'leading',
|
|
108
|
+
'tracking',
|
|
109
|
+
'opacity',
|
|
110
|
+
'scale',
|
|
111
|
+
'rotate',
|
|
112
|
+
'translate-x',
|
|
113
|
+
'translate-y',
|
|
114
|
+
'duration',
|
|
115
|
+
'delay'
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
function dedupeByLine(findings: Finding[]): Finding[] {
|
|
119
|
+
const seen = new Set<string>();
|
|
120
|
+
const out: Finding[] = [];
|
|
121
|
+
for (const f of findings) {
|
|
122
|
+
const key = `${f.ruleId}:${f.line}:${f.match}`;
|
|
123
|
+
if (seen.has(key)) continue;
|
|
124
|
+
seen.add(key);
|
|
125
|
+
out.push(f);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const rawTailwindColor: Rule = {
|
|
131
|
+
id: 'raw-tailwind-color',
|
|
132
|
+
severity: 'error',
|
|
133
|
+
description: 'Raw Tailwind palette colour (e.g. `bg-blue-500`) instead of a semantic token.',
|
|
134
|
+
check(lines) {
|
|
135
|
+
// Trailing `(?![a-z0-9-])` (not `\b`) so an optional `/NN` opacity suffix stays in the match.
|
|
136
|
+
const re = new RegExp(
|
|
137
|
+
`\\b(?:${COLOR_PREFIXES.join('|')})-(?:${RAW_PALETTE})-(?:50|100|200|300|400|500|600|700|800|900|950)(?:\\/\\d{1,3})?(?![a-z0-9-])`,
|
|
138
|
+
'g'
|
|
139
|
+
);
|
|
140
|
+
const findings: Finding[] = [];
|
|
141
|
+
lines.forEach((line, i) => {
|
|
142
|
+
for (const m of line.matchAll(re)) {
|
|
143
|
+
findings.push({
|
|
144
|
+
ruleId: this.id,
|
|
145
|
+
severity: this.severity,
|
|
146
|
+
kind: 'deterministic',
|
|
147
|
+
message: `Raw Tailwind colour \`${m[0]}\` bypasses the token system (no dark-mode adaptation, no theming).`,
|
|
148
|
+
fix: 'Use a semantic token: `bg-surface-*`, `text-text-*`, `border-border-*`, or an intent (`bg-primary`, `text-success`).',
|
|
149
|
+
line: i + 1,
|
|
150
|
+
match: m[0]
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
return dedupeByLine(findings);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const darkModeOverride: Rule = {
|
|
159
|
+
id: 'dark-mode-override',
|
|
160
|
+
severity: 'error',
|
|
161
|
+
description: 'Manual `dark:` override instead of automatic `light-dark()` semantic tokens.',
|
|
162
|
+
check(lines) {
|
|
163
|
+
// `!` covers the Tailwind important modifier (`dark:!bg-…`).
|
|
164
|
+
const re = /\bdark:[a-z[!]/g;
|
|
165
|
+
const findings: Finding[] = [];
|
|
166
|
+
lines.forEach((line, i) => {
|
|
167
|
+
for (const m of line.matchAll(re)) {
|
|
168
|
+
findings.push({
|
|
169
|
+
ruleId: this.id,
|
|
170
|
+
severity: this.severity,
|
|
171
|
+
kind: 'deterministic',
|
|
172
|
+
message:
|
|
173
|
+
'Manual `dark:` override. Dark mode resolves automatically via `light-dark()` semantic tokens.',
|
|
174
|
+
fix: 'Remove the `dark:` variant and rely on semantic tokens (`bg-surface-elevated` etc.), which already switch.',
|
|
175
|
+
line: i + 1,
|
|
176
|
+
match: m[0].slice(0, -1)
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
return dedupeByLine(findings);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const focusNotVisible: Rule = {
|
|
185
|
+
id: 'focus-not-visible',
|
|
186
|
+
severity: 'error',
|
|
187
|
+
description: 'Plain `focus:` ring instead of keyboard-only `focus-visible:`.',
|
|
188
|
+
check(lines) {
|
|
189
|
+
// `focus:` followed by a utility start, but never `focus-visible:`/`focus-within:`
|
|
190
|
+
// (those contain `focus-`, not `focus:`). Catches `group-focus:` / `peer-focus:` too.
|
|
191
|
+
const re = /\bfocus:(?=[a-z[])/g;
|
|
192
|
+
const findings: Finding[] = [];
|
|
193
|
+
lines.forEach((line, i) => {
|
|
194
|
+
for (const _ of line.matchAll(re)) {
|
|
195
|
+
findings.push({
|
|
196
|
+
ruleId: this.id,
|
|
197
|
+
severity: this.severity,
|
|
198
|
+
kind: 'deterministic',
|
|
199
|
+
message:
|
|
200
|
+
'`focus:` shows a focus ring on mouse clicks too. Keyboard-only rings are the house style.',
|
|
201
|
+
fix: 'Use `focus-visible:` instead of `focus:`.',
|
|
202
|
+
line: i + 1,
|
|
203
|
+
match: 'focus:'
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
return dedupeByLine(findings);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const hardcodedZIndex: Rule = {
|
|
212
|
+
id: 'hardcoded-z-index',
|
|
213
|
+
severity: 'error',
|
|
214
|
+
description: 'Hardcoded z-index instead of a `z-[var(--z-*)]` token.',
|
|
215
|
+
check(lines) {
|
|
216
|
+
// `z-10`, `z-50`, `z-[999]` — but not `z-[var(--z-modal)]` or `z-auto`.
|
|
217
|
+
// Trailing `(?![\w-])` (not `\b`) so the bracket form terminates correctly after `]`.
|
|
218
|
+
const re = /\bz-(?:\d{1,4}|\[\d{1,4}\])(?![\w-])/g;
|
|
219
|
+
const findings: Finding[] = [];
|
|
220
|
+
lines.forEach((line, i) => {
|
|
221
|
+
for (const m of line.matchAll(re)) {
|
|
222
|
+
findings.push({
|
|
223
|
+
ruleId: this.id,
|
|
224
|
+
severity: this.severity,
|
|
225
|
+
kind: 'deterministic',
|
|
226
|
+
message: `Hardcoded z-index \`${m[0]}\` collides with the layering scale and can sit behind/above the wrong overlay.`,
|
|
227
|
+
fix: 'Use a z-index token: `z-[var(--z-modal)]`, `z-[var(--z-dropdown)]`, `z-[var(--z-tooltip)]`, …',
|
|
228
|
+
line: i + 1,
|
|
229
|
+
match: m[0]
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
return dedupeByLine(findings);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const dynamicClassInterpolation: Rule = {
|
|
238
|
+
id: 'dynamic-class-interpolation',
|
|
239
|
+
severity: 'error',
|
|
240
|
+
description:
|
|
241
|
+
'String-interpolated Tailwind class fragment (e.g. `gap-{x}`) — never compiled by Tailwind.',
|
|
242
|
+
check(lines) {
|
|
243
|
+
// A Tailwind utility root immediately glued to a `{` or `${` interpolation.
|
|
244
|
+
// Tailwind needs static class names; `gap-${x}` produces no CSS at all.
|
|
245
|
+
const re = new RegExp(`\\b(?:${DYNAMIC_UTILITY_ROOTS.join('|')})-(\\$\\{|\\{)`, 'g');
|
|
246
|
+
const findings: Finding[] = [];
|
|
247
|
+
lines.forEach((line, i) => {
|
|
248
|
+
for (const m of line.matchAll(re)) {
|
|
249
|
+
findings.push({
|
|
250
|
+
ruleId: this.id,
|
|
251
|
+
severity: this.severity,
|
|
252
|
+
kind: 'deterministic',
|
|
253
|
+
message: `Interpolated class fragment \`${m[0]}…\` — Tailwind only compiles static class names, so this utility is never generated.`,
|
|
254
|
+
fix: "Switch the whole class string per state: `class={isHero ? 'gap-4' : 'gap-3'}` — keep each utility a complete literal.",
|
|
255
|
+
line: i + 1,
|
|
256
|
+
match: m[0]
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
return dedupeByLine(findings);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Token hallucination: a colour utility whose core *looks* like an Urbicon UI
|
|
266
|
+
* semantic token (right namespace / intent prefix) but is not in the validated
|
|
267
|
+
* whitelist. Narrow by construction — only fires on our own namespaces, so it
|
|
268
|
+
* never flags `bg-transparent`, `bg-cover`, or arbitrary `bg-[#fff]`.
|
|
269
|
+
*/
|
|
270
|
+
const tokenHallucination: Rule = {
|
|
271
|
+
id: 'token-hallucination',
|
|
272
|
+
severity: 'warning',
|
|
273
|
+
description:
|
|
274
|
+
'Colour utility referencing a non-existent semantic token (e.g. `bg-status-danger`).',
|
|
275
|
+
check(lines, _raw, ctx) {
|
|
276
|
+
// The effective whitelist for this run: per-call project tokens merged in by
|
|
277
|
+
// lintDesign, or the built-in set when the rule is invoked standalone.
|
|
278
|
+
const validCores = ctx?.validTokenCores ?? VALID_TOKEN_CORES;
|
|
279
|
+
const prefixAlt = COLOR_PREFIXES.join('|');
|
|
280
|
+
// capture: prefix, then the core up to a class boundary / opacity / end
|
|
281
|
+
const re = new RegExp(`\\b(${prefixAlt})-([a-z][a-z0-9-]*)(?:\\/\\d{1,3})?\\b`, 'g');
|
|
282
|
+
const findings: Finding[] = [];
|
|
283
|
+
|
|
284
|
+
lines.forEach((line, i) => {
|
|
285
|
+
for (const m of line.matchAll(re)) {
|
|
286
|
+
const core = m[2]!;
|
|
287
|
+
if (!looksSemantic(core)) continue;
|
|
288
|
+
if (validCores.has(core)) continue;
|
|
289
|
+
|
|
290
|
+
findings.push({
|
|
291
|
+
ruleId: this.id,
|
|
292
|
+
severity: this.severity,
|
|
293
|
+
kind: 'deterministic',
|
|
294
|
+
message: `\`${m[1]}-${core}\` is not a real token — likely hallucinated.`,
|
|
295
|
+
fix: suggestForBadCore(core),
|
|
296
|
+
line: i + 1,
|
|
297
|
+
match: m[0] // full token incl. any `/NN` opacity suffix
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
return dedupeByLine(findings);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/** Does this utility core sit in one of our semantic namespaces / intent families? */
|
|
306
|
+
function looksSemantic(core: string): boolean {
|
|
307
|
+
// Font-size cores (`text-sm`, `text-2xl`) share the `text-` namespace but are not colour tokens.
|
|
308
|
+
if (/^text-(?:xs|sm|base|lg|\d?xl)$/.test(core)) return false;
|
|
309
|
+
if (isForeignVocab(core)) return true; // shadcn/ui vocabulary — always foreign
|
|
310
|
+
if (SEMANTIC_NAMESPACES.some((ns) => core.startsWith(ns))) return true;
|
|
311
|
+
if (core.startsWith('status-')) return true; // owned-looking, never valid → flag
|
|
312
|
+
// intent-with-suffix: `primary-muted`, `success-foo` (bare `primary` is valid and caught by the whitelist)
|
|
313
|
+
for (const intent of INTENT_PREFIXES) {
|
|
314
|
+
if (core.startsWith(`${intent}-`)) return true;
|
|
315
|
+
}
|
|
316
|
+
if (core.endsWith('-fg')) return true; // `-foreground` is already covered by isForeignVocab above
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function suggestForBadCore(core: string): string {
|
|
321
|
+
// The `-foreground` family is the most common shadcn pattern — give the precise replacement first.
|
|
322
|
+
if (
|
|
323
|
+
core === 'foreground' ||
|
|
324
|
+
core.endsWith('-foreground') ||
|
|
325
|
+
core === 'fg' ||
|
|
326
|
+
core.endsWith('-fg')
|
|
327
|
+
) {
|
|
328
|
+
return 'Use `text-on-primary` / `text-on-surface` for foreground-on-intent text, or `text-text-primary`/`-secondary` for general text.';
|
|
329
|
+
}
|
|
330
|
+
if (isForeignVocab(core)) return SHADCN_FIX;
|
|
331
|
+
for (const [bad, hint] of Object.entries(KNOWN_BAD_NAMESPACES)) {
|
|
332
|
+
if (bad.endsWith('-') ? core.startsWith(bad) : core.endsWith(bad)) return hint;
|
|
333
|
+
}
|
|
334
|
+
if (core.startsWith('surface-'))
|
|
335
|
+
return 'Valid surfaces: surface-base/quiet/subtle/elevated/overlay/hover/active/selected/inverted.';
|
|
336
|
+
if (core.startsWith('feedback-'))
|
|
337
|
+
return 'Valid feedback tokens: feedback-{info,success,warning,error}[-subtle].';
|
|
338
|
+
const intent = INTENT_NAMES.find((n) => core.startsWith(`${n}-`));
|
|
339
|
+
if (intent)
|
|
340
|
+
return `Valid \`${intent}\` variants: ${intent}, ${intent}-hover, ${intent}-active, ${intent}-subtle, ${intent}-emphasis, or a scale step ${intent}-50…${intent}-950.`;
|
|
341
|
+
return 'Check `get_css_reference()` for the exact token name.';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** All deterministic rules, in report order. The line-based regex rules first, then
|
|
345
|
+
* the AST-pass rules (which read whole elements, not single lines). */
|
|
346
|
+
export const RULES: Rule[] = [
|
|
347
|
+
rawTailwindColor,
|
|
348
|
+
darkModeOverride,
|
|
349
|
+
focusNotVisible,
|
|
350
|
+
hardcodedZIndex,
|
|
351
|
+
dynamicClassInterpolation,
|
|
352
|
+
tokenHallucination,
|
|
353
|
+
...MARKUP_RULES
|
|
354
|
+
];
|