@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.
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { lintDesign } from './linter.js';
3
+
4
+ /** ruleIds present in a lint of `code`. */
5
+ function ruleIds(code: string): string[] {
6
+ return lintDesign(code).findings.map((f) => f.ruleId);
7
+ }
8
+ function apiFindings(code: string) {
9
+ return lintDesign(code).findings.filter((f) => f.ruleId === 'api-hallucination');
10
+ }
11
+
12
+ describe('api-hallucination (F-J)', () => {
13
+ it('flags a foreign prop name and suggests the real one', () => {
14
+ const f = apiFindings('<Button tone="primary">Go</Button>');
15
+ expect(f).toHaveLength(1);
16
+ expect(f[0]!.severity).toBe('warning');
17
+ expect(f[0]!.kind).toBe('deterministic'); // scores against correctness, not slop
18
+ expect(f[0]!.match).toBe('tone');
19
+ expect(f[0]!.fix).toContain('intent');
20
+ expect(f[0]!.line).toBe(1);
21
+ });
22
+
23
+ it('flags the shadcn `variant="outline"` spelling', () => {
24
+ const f = apiFindings('<Button variant="outline">Go</Button>');
25
+ expect(f).toHaveLength(1);
26
+ expect(f[0]!.match).toBe('variant="outline"');
27
+ expect(f[0]!.fix).toContain('outlined');
28
+ });
29
+
30
+ it('catches a foreign boolean prop and a shorthand prop', () => {
31
+ expect(apiFindings('<Button isLoading>Go</Button>')[0]?.fix).toContain('loading');
32
+ expect(apiFindings('<Button {isDisabled} />')[0]?.fix).toContain('disabled');
33
+ });
34
+
35
+ it('does NOT flag a valid per-component value (variant="solid" is real on Tab)', () => {
36
+ // The proof that the value map is global-safe-only: `solid` is a real Tab variant,
37
+ // so it must never be flagged. (Per-component validation is deferred to the catalog.)
38
+ expect(apiFindings('<Tab variant="solid">x</Tab>')).toEqual([]);
39
+ });
40
+
41
+ it('does not flag valid Urbicon usage', () => {
42
+ expect(
43
+ apiFindings('<Button intent="primary" variant="outlined" size="md">Go</Button>')
44
+ ).toEqual([]);
45
+ });
46
+
47
+ it('only fires on Urbicon components, not raw HTML or third-party components', () => {
48
+ expect(apiFindings('<div tone="x">y</div>')).toEqual([]);
49
+ expect(apiFindings('<MyButton tone="x">y</MyButton>')).toEqual([]);
50
+ });
51
+
52
+ it('only flags value confusions on string literals, not expressions', () => {
53
+ // `variant={outline}` passes a JS variable named `outline` — not the bad literal.
54
+ expect(apiFindings('<Button variant={outline}>x</Button>')).toEqual([]);
55
+ });
56
+
57
+ it('ignores usages inside comments (masked before the scan)', () => {
58
+ expect(ruleIds('<!-- <Button tone="x">y</Button> -->')).not.toContain('api-hallucination');
59
+ });
60
+
61
+ it('deducts from the correctness axis', () => {
62
+ const report = lintDesign('<Button tone="primary">Go</Button>');
63
+ expect(report.scores.correctness).toBeLessThan(100);
64
+ });
65
+ });
66
+
67
+ function ariaFindings(code: string) {
68
+ return lintDesign(code).findings.filter((f) => f.ruleId === 'icon-button-no-label');
69
+ }
70
+
71
+ describe('icon-button-no-label (F-G)', () => {
72
+ it('flags an icon-only button with no accessible name', () => {
73
+ const f = ariaFindings('<button><SearchIcon /></button>');
74
+ expect(f).toHaveLength(1);
75
+ expect(f[0]!.severity).toBe('warning');
76
+ expect(f[0]!.kind).toBe('deterministic');
77
+ expect(f[0]!.fix).toContain('aria-label');
78
+ });
79
+
80
+ it('flags an icon-only Urbicon <Button> too', () => {
81
+ expect(ariaFindings('<Button><Icon name="x" /></Button>')).toHaveLength(1);
82
+ });
83
+
84
+ it('does not flag when an accessible name is present', () => {
85
+ expect(ariaFindings('<button aria-label="Search"><SearchIcon /></button>')).toEqual([]);
86
+ expect(ariaFindings('<button title="Search"><svg /></button>')).toEqual([]);
87
+ });
88
+
89
+ it('does not flag when visible or sr-only text labels the control', () => {
90
+ expect(ariaFindings('<button><SearchIcon /> Search</button>')).toEqual([]);
91
+ expect(
92
+ ariaFindings('<button><span class="sr-only">Search</span><SearchIcon /></button>')
93
+ ).toEqual([]);
94
+ });
95
+
96
+ it('skips when a dynamic child or a spread might carry the name', () => {
97
+ expect(ariaFindings('<button><Icon />{label}</button>')).toEqual([]); // {…} could be the label
98
+ expect(ariaFindings('<Button {...rest}><Icon /></Button>')).toEqual([]); // spread may add aria-label
99
+ });
100
+
101
+ it('does not flag a button with text but no icon, or a non-button icon holder', () => {
102
+ expect(ariaFindings('<button>Save</button>')).toEqual([]);
103
+ expect(ariaFindings('<div><SearchIcon /></div>')).toEqual([]);
104
+ });
105
+
106
+ it('ignores an empty aria-label (still no accessible name)', () => {
107
+ expect(ariaFindings('<button aria-label=""><SearchIcon /></button>')).toHaveLength(1);
108
+ });
109
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The AST-pass rules (DESIGN-MCP-V2 §6/§10, Funde F-G/F-J): correctness checks that
3
+ * need to know *which attribute belongs to which element* — what the line-based
4
+ * regex rules in `rules.ts` structurally cannot see. They run on the flat element
5
+ * list from {@link scanMarkup}, scanned once per lint and shared via a tiny cache.
6
+ *
7
+ * All are scoped to Urbicon UI's own components (a curated name set) so they never
8
+ * fire on a third-party `<Button>` from another library — the correctness gate
9
+ * must stay false-positive free.
10
+ */
11
+
12
+ import { type Element, innerContent, scanMarkup } from './markup.js';
13
+ import type { Finding, Rule } from './types.js';
14
+
15
+ /**
16
+ * Urbicon UI's component names, for scoping the AST rules. A name missing here only
17
+ * means a missed lint (never a false positive), so lagging a new component is safe;
18
+ * keep it roughly in sync with the catalog. Not the catalog itself (props/docs) —
19
+ * just the names, the same kind of design-system lint-data as the token sets.
20
+ */
21
+ export const URBICON_COMPONENTS: ReadonlySet<string> = new Set([
22
+ // primitives
23
+ 'Accordion',
24
+ 'Alert',
25
+ 'Avatar',
26
+ 'Badge',
27
+ 'Breadcrumb',
28
+ 'Button',
29
+ 'ButtonGroup',
30
+ 'Card',
31
+ 'Checkbox',
32
+ 'Collapsible',
33
+ 'Combobox',
34
+ 'ConfirmDialog',
35
+ 'Dialog',
36
+ 'Drawer',
37
+ 'FormField',
38
+ 'Input',
39
+ 'Menu',
40
+ 'Pagination',
41
+ 'Popover',
42
+ 'Progress',
43
+ 'RadioGroup',
44
+ 'SegmentGroup',
45
+ 'Select',
46
+ 'Separator',
47
+ 'Sidebar',
48
+ 'Skeleton',
49
+ 'Slider',
50
+ 'Spinner',
51
+ 'Stepper',
52
+ 'Tab',
53
+ 'Textarea',
54
+ 'Toast',
55
+ 'Toggle',
56
+ 'Toolbar',
57
+ 'Tooltip',
58
+ // components
59
+ 'AreaChart',
60
+ 'BarChart',
61
+ 'Calendar',
62
+ 'ChartFrame',
63
+ 'CommandPalette',
64
+ 'CompositionBar',
65
+ 'CurrencyInput',
66
+ 'DatePicker',
67
+ 'DonutChart',
68
+ 'EmptyState',
69
+ 'FileUpload',
70
+ 'LineChart',
71
+ 'LocaleSwitcher',
72
+ 'Sankey',
73
+ 'SidebarLayout',
74
+ 'Sparkline',
75
+ 'ThemeSwitcher',
76
+ 'Table'
77
+ ]);
78
+
79
+ /**
80
+ * Prop names from other component libraries (Chakra/MUI/shadcn muscle memory) that
81
+ * Urbicon UI does not have, mapped to the real prop. Every key is verified absent
82
+ * from every `*Props` interface in the repo, and every value is a real prop — so
83
+ * the fix hint is always correct. (`color`/`leftIcon`/`rightIcon` are deliberately
84
+ * absent: they ARE real props on some components, so flagging them would misfire.)
85
+ */
86
+ const PROP_NAME_CONFUSIONS: Readonly<Record<string, string>> = {
87
+ tone: 'intent',
88
+ colour: 'intent',
89
+ colorScheme: 'intent',
90
+ isLoading: 'loading',
91
+ isDisabled: 'disabled'
92
+ };
93
+
94
+ /**
95
+ * String-literal value confusions, per prop. Only values that are valid in NO
96
+ * Urbicon component go here — variant vocabularies are per-component (`solid` is a
97
+ * real Tab variant, `default` a real one elsewhere), so a global value map is only
98
+ * safe for values that exist nowhere. `outline` (the shadcn spelling of `outlined`)
99
+ * is the one that qualifies. Fuller per-component value validation needs the
100
+ * catalog and is deferred with the find/init step (F-J, see DESIGN-MCP-V2 §10).
101
+ */
102
+ const VALUE_CONFUSIONS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
103
+ variant: { outline: 'outlined' }
104
+ };
105
+
106
+ /** Single-entry scan cache: lintDesign runs the rules sequentially on one source. */
107
+ let cache: { raw: string; els: Element[] } | null = null;
108
+ function scanCached(raw: string): Element[] {
109
+ if (cache?.raw !== raw) cache = { raw, els: scanMarkup(raw) };
110
+ return cache.els;
111
+ }
112
+
113
+ const apiHallucination: Rule = {
114
+ id: 'api-hallucination',
115
+ severity: 'warning',
116
+ description:
117
+ 'Component prop/value from another UI library (e.g. `tone=`, `variant="outline"`) that Urbicon UI does not have.',
118
+ check(_lines, raw) {
119
+ const findings: Finding[] = [];
120
+ for (const el of scanCached(raw)) {
121
+ if (!el.isComponent || !URBICON_COMPONENTS.has(el.tag)) continue;
122
+ for (const attr of el.attrs) {
123
+ const rightName = PROP_NAME_CONFUSIONS[attr.name];
124
+ if (rightName) {
125
+ findings.push({
126
+ ruleId: this.id,
127
+ severity: this.severity,
128
+ kind: 'deterministic',
129
+ message: `\`${el.tag}\` has no \`${attr.name}\` prop — that is another library's name.`,
130
+ fix: `Use \`${rightName}\` instead.`,
131
+ line: attr.line,
132
+ match: attr.name
133
+ });
134
+ continue;
135
+ }
136
+ if (attr.kind === 'string' && attr.value !== null) {
137
+ const right = VALUE_CONFUSIONS[attr.name]?.[attr.value];
138
+ if (right) {
139
+ findings.push({
140
+ ruleId: this.id,
141
+ severity: this.severity,
142
+ kind: 'deterministic',
143
+ message: `\`${attr.name}="${attr.value}"\` is not an Urbicon UI value.`,
144
+ fix: `Use \`${attr.name}="${right}"\`.`,
145
+ line: attr.line,
146
+ match: `${attr.name}="${attr.value}"`
147
+ });
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return findings;
153
+ }
154
+ };
155
+
156
+ /** Attributes that give an interactive control its accessible name. */
157
+ const LABEL_ATTRS: ReadonlySet<string> = new Set(['aria-label', 'aria-labelledby', 'title']);
158
+ /** Controls that are commonly rendered icon-only and then forgotten a label. */
159
+ const ICON_CONTROL_TAGS: ReadonlySet<string> = new Set(['Button', 'button']);
160
+
161
+ /** Any non-whitespace text once child tags are stripped (catches visible + sr-only labels). */
162
+ function hasTextLabel(inner: string): boolean {
163
+ return inner.replace(/<[^>]*>/g, '').trim().length > 0;
164
+ }
165
+ /** An icon child — an `<svg>`, an `<Icon>`, or any `<…Icon>` component. */
166
+ function hasIconChild(inner: string): boolean {
167
+ return /<svg\b/i.test(inner) || /<[A-Za-z][\w.]*Icon\b/.test(inner) || /<Icon\b/.test(inner);
168
+ }
169
+
170
+ /**
171
+ * F-G: an icon-only Button/button with no accessible name — a screen reader
172
+ * announces nothing. Conservative: skips when a spread might carry the label, when
173
+ * content holds a `{…}` expression (a dynamic label), when any text (visible or
174
+ * sr-only) is present, and only fires when an actual icon child is the sole
175
+ * content. Errs toward silence so a labelled control is never flagged.
176
+ */
177
+ const iconButtonNoLabel: Rule = {
178
+ id: 'icon-button-no-label',
179
+ severity: 'warning',
180
+ description: 'Icon-only Button/button with no accessible name (aria-label / title / text).',
181
+ check(_lines, raw) {
182
+ const findings: Finding[] = [];
183
+ for (const el of scanCached(raw)) {
184
+ if (!ICON_CONTROL_TAGS.has(el.tag)) continue;
185
+ if (el.attrs.some((a) => a.kind === 'spread')) continue; // {...rest} may carry aria-label
186
+ const labelled = el.attrs.some(
187
+ (a) => LABEL_ATTRS.has(a.name) && a.value !== null && a.value.trim() !== ''
188
+ );
189
+ if (labelled) continue;
190
+ const inner = innerContent(raw, el);
191
+ if (inner === null) continue; // self-closing / unbalanced — content unknown, skip
192
+ if (inner.includes('{')) continue; // a dynamic child like {label} might be the name
193
+ if (hasTextLabel(inner)) continue; // visible or visually-hidden text present
194
+ if (!hasIconChild(inner)) continue; // only genuine icon-only controls
195
+ findings.push({
196
+ ruleId: this.id,
197
+ severity: this.severity,
198
+ kind: 'deterministic',
199
+ message: `Icon-only \`<${el.tag}>\` has no accessible name — a screen reader announces nothing.`,
200
+ fix: 'Add an `aria-label="…"` (or visually-hidden text) naming the action.',
201
+ line: el.line
202
+ });
203
+ }
204
+ return findings;
205
+ }
206
+ };
207
+
208
+ /** The AST-pass rules, appended to the linter's RULES registry. */
209
+ export const MARKUP_RULES: Rule[] = [apiHallucination, iconButtonNoLabel];
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { type Element, innerContent, scanMarkup } from './markup.js';
3
+
4
+ /** Find the first scanned element with a given tag. */
5
+ function tag(els: Element[], name: string): Element | undefined {
6
+ return els.find((e) => e.tag === name);
7
+ }
8
+
9
+ describe('scanMarkup', () => {
10
+ it('extracts a component with string, expression, and boolean attributes', () => {
11
+ const els = scanMarkup('<Button intent="primary" onclick={save} disabled>Save</Button>');
12
+ expect(els).toHaveLength(1);
13
+ const b = els[0]!;
14
+ expect(b.tag).toBe('Button');
15
+ expect(b.isComponent).toBe(true);
16
+ expect(b.selfClosing).toBe(false);
17
+ expect(b.attrs).toEqual([
18
+ { name: 'intent', value: 'primary', kind: 'string', line: 1 },
19
+ { name: 'onclick', value: 'save', kind: 'expression', line: 1 },
20
+ { name: 'disabled', value: null, kind: 'boolean', line: 1 }
21
+ ]);
22
+ });
23
+
24
+ it('distinguishes components from raw HTML elements', () => {
25
+ const els = scanMarkup('<div><Button /><Foo.Bar /><button /></div>');
26
+ expect(tag(els, 'div')?.isComponent).toBe(false);
27
+ expect(tag(els, 'Button')?.isComponent).toBe(true);
28
+ expect(tag(els, 'Foo.Bar')?.isComponent).toBe(true);
29
+ expect(tag(els, 'button')?.isComponent).toBe(false);
30
+ });
31
+
32
+ it('marks self-closing tags', () => {
33
+ const els = scanMarkup('<Icon name="x" />');
34
+ expect(els[0]!.selfClosing).toBe(true);
35
+ expect(els[0]!.attrs[0]).toEqual({ name: 'name', value: 'x', kind: 'string', line: 1 });
36
+ });
37
+
38
+ it('does not let a `>` inside an expression terminate the tag early', () => {
39
+ const els = scanMarkup('<Box class={a > b ? "x" : "y"} role="grid">hi</Box>');
40
+ const box = tag(els, 'Box')!;
41
+ expect(box.attrs.map((a) => a.name)).toEqual(['class', 'role']);
42
+ expect(box.attrs[1]).toEqual({ name: 'role', value: 'grid', kind: 'string', line: 1 });
43
+ });
44
+
45
+ it('handles object literals and template strings inside an expression attribute', () => {
46
+ const els = scanMarkup('<C style={{ a: `${x}`, b: "}" }} id="z" />');
47
+ const c = tag(els, 'C')!;
48
+ expect(c.attrs.map((a) => a.name)).toEqual(['style', 'id']);
49
+ expect(c.attrs[1]!.value).toBe('z');
50
+ });
51
+
52
+ it('reads shorthand and spread attributes', () => {
53
+ const els = scanMarkup('<C {value} {...rest} />');
54
+ expect(els[0]!.attrs).toEqual([
55
+ { name: 'value', value: 'value', kind: 'shorthand', line: 1 },
56
+ { name: '', value: 'rest', kind: 'spread', line: 1 }
57
+ ]);
58
+ });
59
+
60
+ it('tracks line numbers across a multiline tag', () => {
61
+ const src = '<div>\n <Button\n intent="primary"\n >Go</Button>\n</div>';
62
+ const els = scanMarkup(src);
63
+ const b = tag(els, 'Button')!;
64
+ expect(b.line).toBe(2);
65
+ expect(b.attrs[0]).toEqual({ name: 'intent', value: 'primary', kind: 'string', line: 3 });
66
+ });
67
+
68
+ it('ignores markup inside <script> and <style> blocks', () => {
69
+ const src =
70
+ '<script>\n const html = "<Button bad>";\n</script>\n<Button intent="primary" />\n<style>\n .x { color: red }\n</style>';
71
+ const els = scanMarkup(src);
72
+ expect(els).toHaveLength(1);
73
+ expect(els[0]!.tag).toBe('Button');
74
+ expect(els[0]!.line).toBe(4); // script newlines were preserved
75
+ });
76
+
77
+ it('skips a malformed (unterminated) tag without crashing or guessing', () => {
78
+ const els = scanMarkup('<Button intent="primary'); // no closing quote or >
79
+ expect(els).toEqual([]);
80
+ });
81
+
82
+ it('ignores closing tags, fragments, and svelte blocks', () => {
83
+ const els = scanMarkup('{#if x}<Button />{/if}</wrap>< notATag>');
84
+ expect(els.map((e) => e.tag)).toEqual(['Button']);
85
+ });
86
+
87
+ it('keeps an escaped quote from mis-terminating an expression attribute', () => {
88
+ // The string inside the expression contains an escaped quote then a `}` —
89
+ // neither must end the attribute early, so the following `y` is still parsed.
90
+ const els = scanMarkup('<C x={"a\\"}"} y="z" />');
91
+ const c = tag(els, 'C')!;
92
+ expect(c.attrs.find((a) => a.name === 'y')?.value).toBe('z');
93
+ });
94
+
95
+ it('ignores tags inside HTML comments (self-contained, no upstream mask needed)', () => {
96
+ const els = scanMarkup('<!-- <Button tone="x" /> -->\n<Input />');
97
+ expect(els.map((e) => e.tag)).toEqual(['Input']);
98
+ });
99
+ });
100
+
101
+ describe('innerContent', () => {
102
+ const only = (src: string): { el: Element; src: string } => ({ el: scanMarkup(src)[0]!, src });
103
+
104
+ it('returns the inner text of an element', () => {
105
+ const { el, src } = only('<Button>Save</Button>');
106
+ expect(innerContent(src, el)).toBe('Save');
107
+ });
108
+
109
+ it('honours same-name nesting', () => {
110
+ const src = '<Menu><Menu>inner</Menu>outer</Menu>';
111
+ const el = scanMarkup(src)[0]!;
112
+ expect(innerContent(src, el)).toBe('<Menu>inner</Menu>outer');
113
+ });
114
+
115
+ it('returns null for a self-closing element', () => {
116
+ const { el, src } = only('<Icon name="x" />');
117
+ expect(innerContent(src, el)).toBe(null);
118
+ });
119
+
120
+ it('returns null when no balanced close exists', () => {
121
+ const { el, src } = only('<Button>oops');
122
+ expect(innerContent(src, el)).toBe(null);
123
+ });
124
+
125
+ it('sees only an icon child for an icon-only button (no text)', () => {
126
+ const { el, src } = only('<button><SearchIcon /></button>');
127
+ const inner = innerContent(src, el)!;
128
+ expect(inner.includes('SearchIcon')).toBe(true);
129
+ expect(inner.replace(/<[^>]*>/g, '').trim()).toBe(''); // no human text once tags are stripped
130
+ });
131
+
132
+ it('does not count a </tag> inside an HTML comment', () => {
133
+ // The comment's `</button>` must not close the element early — the real label
134
+ // after it has to be seen. (Self-contained: innerContent masks comments too.)
135
+ const src = '<button><!-- </button> -->Save</button>';
136
+ const el = scanMarkup(src)[0]!;
137
+ expect(innerContent(src, el)).toContain('Save');
138
+ });
139
+ });