@kong/eslint-plugin-design-tokens 0.0.1

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,1017 @@
1
+ import { describe, it } from 'vitest'
2
+ import { RuleTester } from 'eslint'
3
+ import vueParser from 'vue-eslint-parser'
4
+ import * as tsParser from '@typescript-eslint/parser'
5
+ import rule from '../index.mjs'
6
+
7
+ // Wire up vitest's describe/it so RuleTester integrates with the vitest reporter
8
+ RuleTester.describe = describe
9
+ RuleTester.it = it
10
+ RuleTester.itOnly = it.only
11
+
12
+ const tester = new RuleTester({
13
+ languageOptions: {
14
+ parser: vueParser,
15
+ parserOptions: {
16
+ ecmaVersion: 2020,
17
+ sourceType: 'module',
18
+ },
19
+ },
20
+ })
21
+
22
+ const RULE_NAME = '@kong/design-tokens/token-constant-requires-css-var'
23
+
24
+ // SFC source helpers
25
+
26
+ /**
27
+ * Builds a minimal `<script setup>` + `<template>` SFC string.
28
+ * @param {object} [parts]
29
+ * @param {string} [parts.script] - `<script setup>` body
30
+ * @param {string} [parts.template] - `<template>` body
31
+ * @param {string} [parts.lang] - Optional `lang` attribute (e.g. `'ts'`)
32
+ */
33
+ function sfc({ script = '', template = '', lang } = {}) {
34
+ return [
35
+ `<script setup${lang ? ` lang="${lang}"` : ''}>`,
36
+ script.trim(),
37
+ '</script>',
38
+ '<template>',
39
+ template.trim(),
40
+ '</template>',
41
+ ].join('\n')
42
+ }
43
+
44
+ /**
45
+ * Shorthand for an SFC that imports one token from `@kong/design-tokens`.
46
+ * @param {string} varName - The exported constant name (e.g. `KUI_COLOR_TEXT_INVERSE`)
47
+ * @param {string} template - The `<template>` body
48
+ * @param {string} [alias] - Optional local alias (`import { varName as alias }`)
49
+ * @param {string} [lang] - Optional `<script setup>` lang (e.g. `'ts'`)
50
+ */
51
+ function withImport(varName, template, alias, lang) {
52
+ const specifier = alias ? `${varName} as ${alias}` : varName
53
+ return sfc({
54
+ script: `import { ${specifier} } from '@kong/design-tokens'`,
55
+ template,
56
+ lang,
57
+ })
58
+ }
59
+
60
+ /** TypeScript variant of {@link sfc} (`<script setup lang="ts">`). */
61
+ function sfcTs({ script = '', template = '' } = {}) {
62
+ return sfc({ script, template, lang: 'ts' })
63
+ }
64
+
65
+ /**
66
+ * TypeScript variant of {@link withImport} (`<script setup lang="ts">`).
67
+ * @param {string} varName - The exported constant name (e.g. `KUI_COLOR_TEXT_INVERSE`)
68
+ * @param {string} template - The `<template>` body
69
+ * @param {string} [alias] - Optional local alias (`import { varName as alias }`)
70
+ */
71
+ function withImportTs(varName, template, alias) {
72
+ return withImport(varName, template, alias, 'ts')
73
+ }
74
+
75
+ tester.run(RULE_NAME, rule, {
76
+ valid: [
77
+ // Already properly wrapped — idempotency check
78
+ {
79
+ filename: 'test.vue',
80
+ code: withImport(
81
+ 'KUI_COLOR_TEXT_INVERSE',
82
+ '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
83
+ ),
84
+ },
85
+
86
+ // Already wrapped inside a ternary branch
87
+ {
88
+ filename: 'test.vue',
89
+ code: withImport(
90
+ 'KUI_COLOR_TEXT_INVERSE',
91
+ "<div :color=\"cond ? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` : 'red'\" />",
92
+ ),
93
+ },
94
+
95
+ // Identifier not imported from @kong/design-tokens
96
+ {
97
+ filename: 'test.vue',
98
+ code: sfc({
99
+ script: "const myColor = '#fff'",
100
+ template: '<div :color="myColor" />',
101
+ }),
102
+ },
103
+
104
+ // Import from a different package — not tracked
105
+ {
106
+ filename: 'test.vue',
107
+ code: sfc({
108
+ script: "import { KUI_COLOR_TEXT_INVERSE } from 'other-package'",
109
+ template: '<div :color="KUI_COLOR_TEXT_INVERSE" />',
110
+ }),
111
+ },
112
+
113
+ // KUI token in v-if — not a v-bind style value
114
+ {
115
+ filename: 'test.vue',
116
+ code: withImport(
117
+ 'KUI_COLOR_TEXT_INVERSE',
118
+ '<div v-if="KUI_COLOR_TEXT_INVERSE" />',
119
+ ),
120
+ },
121
+
122
+ // KUI token in mustache interpolation — not a v-bind
123
+ {
124
+ filename: 'test.vue',
125
+ code: withImport(
126
+ 'KUI_COLOR_TEXT_INVERSE',
127
+ '<div>{{ KUI_COLOR_TEXT_INVERSE }}</div>',
128
+ ),
129
+ },
130
+
131
+ // Namespace import — not individually tracked
132
+ {
133
+ filename: 'test.vue',
134
+ code: sfc({
135
+ script: "import * as tokens from '@kong/design-tokens'",
136
+ template: '<div :color="tokens.KUI_COLOR_TEXT_INVERSE" />',
137
+ }),
138
+ },
139
+
140
+ // Static (non-binding) attribute — no colon
141
+ {
142
+ filename: 'test.vue',
143
+ code: withImport(
144
+ 'KUI_COLOR_TEXT_INVERSE',
145
+ '<div color="someStaticValue" />',
146
+ ),
147
+ },
148
+
149
+ // v-on directive — not a v-bind, never visited by the rule
150
+ {
151
+ filename: 'test.vue',
152
+ code: withImport(
153
+ 'KUI_COLOR_TEXT_INVERSE',
154
+ '<div @click="handler(KUI_COLOR_TEXT_INVERSE)" />',
155
+ ),
156
+ },
157
+
158
+ /**
159
+ * MemberExpression property name — KUI token is the key, not the value reference.
160
+ * `walkExpression` only walks the object side of a MemberExpression, not the property.
161
+ */
162
+ {
163
+ filename: 'test.vue',
164
+ code: withImport(
165
+ 'KUI_COLOR_TEXT_INVERSE',
166
+ '<div :color="theme.KUI_COLOR_TEXT_INVERSE" />',
167
+ ),
168
+ },
169
+
170
+ // Partially wrapped ternary — the already-wrapped branch is idempotent (no re-report)
171
+ {
172
+ filename: 'test.vue',
173
+ code: withImport(
174
+ 'KUI_COLOR_TEXT_INVERSE',
175
+ '<div :color="cond ? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` : `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
176
+ ),
177
+ },
178
+
179
+ /**
180
+ * Idempotency with import alias — the CSS var is derived from the canonical imported
181
+ * name, not the local alias, so the already-wrapped form must be recognised as valid.
182
+ */
183
+ {
184
+ filename: 'test.vue',
185
+ code: withImport(
186
+ 'KUI_COLOR_TEXT_INVERSE',
187
+ '<div :color="`var(--kui-color-text-inverse, ${myColor})`" />',
188
+ 'myColor',
189
+ ),
190
+ },
191
+
192
+ /**
193
+ * Idempotency is whitespace-tolerant — extra spaces inside var() are valid CSS
194
+ * and must not cause a false re-report after a developer manually writes the binding.
195
+ */
196
+ {
197
+ filename: 'test.vue',
198
+ code: withImport(
199
+ 'KUI_COLOR_TEXT_INVERSE',
200
+ '<div :color="`var( --kui-color-text-inverse , ${KUI_COLOR_TEXT_INVERSE} )`" />',
201
+ ),
202
+ },
203
+
204
+ /**
205
+ * Options API `<script>` (no setup attribute) — module-scope KUI aliases must not
206
+ * be tracked because they are not directly accessible in the template; the template
207
+ * `myColor` refers to a prop/data/computed property, not the module-level const.
208
+ */
209
+ {
210
+ filename: 'test.vue',
211
+ code: [
212
+ '<script>',
213
+ "import { KUI_COLOR_TEXT_INVERSE } from '@kong/design-tokens'",
214
+ 'const myColor = KUI_COLOR_TEXT_INVERSE',
215
+ "export default { props: ['myColor'] }",
216
+ '</script>',
217
+ '<template>',
218
+ '<div :color="myColor" />',
219
+ '</template>',
220
+ ].join('\n'),
221
+ },
222
+
223
+ /**
224
+ * KUI_BREAKPOINT_* tokens are excluded — they are viewport pixel widths, not CSS
225
+ * custom properties, so DOM-level theming does not apply.
226
+ */
227
+ {
228
+ filename: 'test.vue',
229
+ code: withImport(
230
+ 'KUI_BREAKPOINT_PHABLET',
231
+ '<div :style="{ maxWidth: KUI_BREAKPOINT_PHABLET }" />',
232
+ ),
233
+ },
234
+
235
+ // KUI_BREAKPOINT_* with alias — exclusion covers the canonical imported name
236
+ {
237
+ filename: 'test.vue',
238
+ code: withImport(
239
+ 'KUI_BREAKPOINT_PHABLET',
240
+ '<div :style="{ maxWidth: bp }" />',
241
+ 'bp',
242
+ ),
243
+ },
244
+
245
+ /**
246
+ * Shadowing: a `v-for` item that happens to share a token's name resolves to
247
+ * the loop variable, not the import, so it must not be flagged.
248
+ */
249
+ {
250
+ filename: 'test.vue',
251
+ code: withImport(
252
+ 'KUI_COLOR_TEXT_INVERSE',
253
+ '<div v-for="KUI_COLOR_TEXT_INVERSE in colors" :key="KUI_COLOR_TEXT_INVERSE" :color="KUI_COLOR_TEXT_INVERSE" />',
254
+ ),
255
+ },
256
+
257
+ /**
258
+ * Shadowing: a scoped-slot prop that shares a token's name resolves to the
259
+ * slot variable, not the import.
260
+ */
261
+ {
262
+ filename: 'test.vue',
263
+ code: withImport(
264
+ 'KUI_COLOR_TEXT_INVERSE',
265
+ '<Comp><template #default="{ KUI_COLOR_TEXT_INVERSE }"><div :color="KUI_COLOR_TEXT_INVERSE" /></template></Comp>',
266
+ ),
267
+ },
268
+
269
+ /**
270
+ * Script-setup alias already wrapped with correct CSS var — theming works,
271
+ * so this is idempotent and must not be reported.
272
+ */
273
+ {
274
+ filename: 'test.vue',
275
+ code: sfc({
276
+ script: [
277
+ "import { KUI_COLOR_TEXT_INVERSE } from '@kong/design-tokens'",
278
+ 'const myColor = KUI_COLOR_TEXT_INVERSE',
279
+ ].join('\n'),
280
+ template: '<div :color="`var(--kui-color-text-inverse, ${myColor})`" />',
281
+ }),
282
+ },
283
+
284
+ /**
285
+ * Function-scoped `const local = KUI_X` must not be tracked — template binding
286
+ * uses a different `myColor` (e.g. from a prop) and this would be a false positive.
287
+ */
288
+ {
289
+ filename: 'test.vue',
290
+ code: sfc({
291
+ script: [
292
+ "import { KUI_COLOR_TEXT_INVERSE } from '@kong/design-tokens'",
293
+ 'function helper() { const myColor = KUI_COLOR_TEXT_INVERSE; return myColor }',
294
+ ].join('\n'),
295
+ template: '<div :color="myColor" />',
296
+ }),
297
+ },
298
+
299
+ // Idempotency across token families — the rule must not re-report already-fixed bindings
300
+ {
301
+ filename: 'test.vue',
302
+ code: withImport(
303
+ 'KUI_FONT_SIZE_30',
304
+ '<div :style="{ fontSize: `var(--kui-font-size-30, ${KUI_FONT_SIZE_30})` }" />',
305
+ ),
306
+ },
307
+
308
+ // Idempotency for multiple tokens in one object — both slots already wrapped
309
+ {
310
+ filename: 'test.vue',
311
+ code: sfc({
312
+ script: "import { KUI_SPACE_40, KUI_BORDER_RADIUS_20 } from '@kong/design-tokens'",
313
+ template: '<div :style="{ padding: `var(--kui-space-40, ${KUI_SPACE_40})`, borderRadius: `var(--kui-border-radius-20, ${KUI_BORDER_RADIUS_20})` }" />',
314
+ }),
315
+ },
316
+ ],
317
+
318
+ invalid: [
319
+ // Simple: bare identifier as the whole binding expression
320
+ {
321
+ filename: 'test.vue',
322
+ code: withImport(
323
+ 'KUI_COLOR_TEXT_INVERSE',
324
+ '<div :color="KUI_COLOR_TEXT_INVERSE" />',
325
+ ),
326
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
327
+ output: withImport(
328
+ 'KUI_COLOR_TEXT_INVERSE',
329
+ '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
330
+ ),
331
+ },
332
+
333
+ // Import alias: CSS var uses canonical name, fallback uses local alias
334
+ {
335
+ filename: 'test.vue',
336
+ code: withImport(
337
+ 'KUI_COLOR_TEXT_INVERSE',
338
+ '<div :color="myColor" />',
339
+ 'myColor',
340
+ ),
341
+ errors: [{ messageId: 'wrapInVar', data: { local: 'myColor', cssVar: 'kui-color-text-inverse' } }],
342
+ output: withImport(
343
+ 'KUI_COLOR_TEXT_INVERSE',
344
+ '<div :color="`var(--kui-color-text-inverse, ${myColor})`" />',
345
+ 'myColor',
346
+ ),
347
+ },
348
+
349
+ // Ternary: both branches are the same KUI token — two separate fixes
350
+ {
351
+ filename: 'test.vue',
352
+ code: withImport(
353
+ 'KUI_COLOR_TEXT_INVERSE',
354
+ '<div :color="cond ? KUI_COLOR_TEXT_INVERSE : KUI_COLOR_TEXT_INVERSE" />',
355
+ ),
356
+ errors: [{ messageId: 'wrapInVar' }, { messageId: 'wrapInVar' }],
357
+ output: withImport(
358
+ 'KUI_COLOR_TEXT_INVERSE',
359
+ '<div :color="cond ? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` : `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
360
+ ),
361
+ },
362
+
363
+ // Ternary: two DIFFERENT KUI tokens in consequent and alternate — independent CSS vars
364
+ {
365
+ filename: 'test.vue',
366
+ code: sfc({
367
+ script: "import { KUI_COLOR_TEXT_INVERSE, KUI_COLOR_BACKGROUND_PRIMARY } from '@kong/design-tokens'",
368
+ template: '<div :color="cond ? KUI_COLOR_TEXT_INVERSE : KUI_COLOR_BACKGROUND_PRIMARY" />',
369
+ }),
370
+ errors: [
371
+ { messageId: 'wrapInVar', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } },
372
+ { messageId: 'wrapInVar', data: { local: 'KUI_COLOR_BACKGROUND_PRIMARY', cssVar: 'kui-color-background-primary' } },
373
+ ],
374
+ output: sfc({
375
+ script: "import { KUI_COLOR_TEXT_INVERSE, KUI_COLOR_BACKGROUND_PRIMARY } from '@kong/design-tokens'",
376
+ template: '<div :color="cond ? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` : `var(--kui-color-background-primary, ${KUI_COLOR_BACKGROUND_PRIMARY})`" />',
377
+ }),
378
+ },
379
+
380
+ // Partially wrapped ternary: only the unwrapped branch is reported and fixed
381
+ {
382
+ filename: 'test.vue',
383
+ code: withImport(
384
+ 'KUI_COLOR_TEXT_INVERSE',
385
+ '<div :color="cond ? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` : KUI_COLOR_TEXT_INVERSE" />',
386
+ ),
387
+ errors: [{ messageId: 'wrapInVar' }],
388
+ output: withImport(
389
+ 'KUI_COLOR_TEXT_INVERSE',
390
+ '<div :color="cond ? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` : `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
391
+ ),
392
+ },
393
+
394
+ // Object: `:style="{ color: KUI_X }"`
395
+ {
396
+ filename: 'test.vue',
397
+ code: withImport(
398
+ 'KUI_COLOR_TEXT_INVERSE',
399
+ '<div :style="{ color: KUI_COLOR_TEXT_INVERSE }" />',
400
+ ),
401
+ errors: [{ messageId: 'wrapInVar' }],
402
+ output: withImport(
403
+ 'KUI_COLOR_TEXT_INVERSE',
404
+ '<div :style="{ color: `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` }" />',
405
+ ),
406
+ },
407
+
408
+ // Object with two KUI values — both fixed in a single pass
409
+ {
410
+ filename: 'test.vue',
411
+ code: sfc({
412
+ script: "import { KUI_COLOR_TEXT_INVERSE, KUI_COLOR_BACKGROUND_PRIMARY } from '@kong/design-tokens'",
413
+ template: '<div :style="{ color: KUI_COLOR_TEXT_INVERSE, background: KUI_COLOR_BACKGROUND_PRIMARY }" />',
414
+ }),
415
+ errors: [{ messageId: 'wrapInVar' }, { messageId: 'wrapInVar' }],
416
+ output: sfc({
417
+ script: "import { KUI_COLOR_TEXT_INVERSE, KUI_COLOR_BACKGROUND_PRIMARY } from '@kong/design-tokens'",
418
+ template: '<div :style="{ color: `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`, background: `var(--kui-color-background-primary, ${KUI_COLOR_BACKGROUND_PRIMARY})` }" />',
419
+ }),
420
+ },
421
+
422
+ // Array element
423
+ {
424
+ filename: 'test.vue',
425
+ code: withImport(
426
+ 'KUI_COLOR_TEXT_INVERSE',
427
+ '<div :style="[baseStyle, KUI_COLOR_TEXT_INVERSE]" />',
428
+ ),
429
+ errors: [{ messageId: 'wrapInVar' }],
430
+ output: withImport(
431
+ 'KUI_COLOR_TEXT_INVERSE',
432
+ '<div :style="[baseStyle, `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`]" />',
433
+ ),
434
+ },
435
+
436
+ // LogicalExpression (??) — autofix the right-hand operand
437
+ {
438
+ filename: 'test.vue',
439
+ code: withImport(
440
+ 'KUI_COLOR_TEXT_INVERSE',
441
+ '<div :color="theme.color ?? KUI_COLOR_TEXT_INVERSE" />',
442
+ ),
443
+ errors: [{ messageId: 'wrapInVar' }],
444
+ output: withImport(
445
+ 'KUI_COLOR_TEXT_INVERSE',
446
+ '<div :color="theme.color ?? `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
447
+ ),
448
+ },
449
+
450
+ // LogicalExpression (||) — autofix the right-hand operand
451
+ {
452
+ filename: 'test.vue',
453
+ code: withImport(
454
+ 'KUI_COLOR_TEXT_INVERSE',
455
+ '<div :color="theme.color || KUI_COLOR_TEXT_INVERSE" />',
456
+ ),
457
+ errors: [{ messageId: 'wrapInVar' }],
458
+ output: withImport(
459
+ 'KUI_COLOR_TEXT_INVERSE',
460
+ '<div :color="theme.color || `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
461
+ ),
462
+ },
463
+
464
+ // Dynamic argument (:[propName])
465
+ {
466
+ filename: 'test.vue',
467
+ code: withImport(
468
+ 'KUI_COLOR_TEXT_INVERSE',
469
+ '<div :[propName]="KUI_COLOR_TEXT_INVERSE" />',
470
+ ),
471
+ errors: [{ messageId: 'wrapInVar' }],
472
+ output: withImport(
473
+ 'KUI_COLOR_TEXT_INVERSE',
474
+ '<div :[propName]="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
475
+ ),
476
+ },
477
+
478
+ // v-bind without argument (object spread syntax)
479
+ {
480
+ filename: 'test.vue',
481
+ code: withImport(
482
+ 'KUI_COLOR_TEXT_INVERSE',
483
+ '<div v-bind="{ color: KUI_COLOR_TEXT_INVERSE }" />',
484
+ ),
485
+ errors: [{ messageId: 'wrapInVar' }],
486
+ output: withImport(
487
+ 'KUI_COLOR_TEXT_INVERSE',
488
+ '<div v-bind="{ color: `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` }" />',
489
+ ),
490
+ },
491
+
492
+ /**
493
+ * Object shorthand property { KUI_X } — fixer must expand to { KUI_X: `var(...)` }
494
+ * because replacing only the identifier drops the key and yields invalid JS.
495
+ */
496
+ {
497
+ filename: 'test.vue',
498
+ code: withImport(
499
+ 'KUI_COLOR_TEXT_INVERSE',
500
+ '<div v-bind="{ KUI_COLOR_TEXT_INVERSE }" />',
501
+ ),
502
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
503
+ output: withImport(
504
+ 'KUI_COLOR_TEXT_INVERSE',
505
+ '<div v-bind="{ KUI_COLOR_TEXT_INVERSE: `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` }" />',
506
+ ),
507
+ },
508
+
509
+ // SequenceExpression — last element gets AUTOFIX context; earlier ones are REPORT_ONLY
510
+ {
511
+ filename: 'test.vue',
512
+ code: withImport(
513
+ 'KUI_COLOR_TEXT_INVERSE',
514
+ '<div :color="(sideEffect(), KUI_COLOR_TEXT_INVERSE)" />',
515
+ ),
516
+ errors: [{ messageId: 'wrapInVar' }],
517
+ output: withImport(
518
+ 'KUI_COLOR_TEXT_INVERSE',
519
+ '<div :color="(sideEffect(), `var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`)" />',
520
+ ),
521
+ },
522
+
523
+ // AssignmentExpression — always REPORT_ONLY (wrapping the assigned value changes semantics)
524
+ {
525
+ filename: 'test.vue',
526
+ code: withImport(
527
+ 'KUI_COLOR_TEXT_INVERSE',
528
+ '<div :color="(x = KUI_COLOR_TEXT_INVERSE)" />',
529
+ ),
530
+ errors: [{ messageId: 'wrapInVarNoFix', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
531
+ output: null,
532
+ },
533
+
534
+ // Token family coverage — same autofix transform applies to all KUI_ prefixes
535
+
536
+ // KUI_FONT_SIZE — typography scale tokens
537
+ {
538
+ filename: 'test.vue',
539
+ code: withImport(
540
+ 'KUI_FONT_SIZE_30',
541
+ '<div :style="{ fontSize: KUI_FONT_SIZE_30 }" />',
542
+ ),
543
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_FONT_SIZE_30', cssVar: 'kui-font-size-30' } }],
544
+ output: withImport(
545
+ 'KUI_FONT_SIZE_30',
546
+ '<div :style="{ fontSize: `var(--kui-font-size-30, ${KUI_FONT_SIZE_30})` }" />',
547
+ ),
548
+ },
549
+
550
+ // KUI_BORDER_RADIUS — shape tokens
551
+ {
552
+ filename: 'test.vue',
553
+ code: withImport(
554
+ 'KUI_BORDER_RADIUS_20',
555
+ '<div :style="{ borderRadius: KUI_BORDER_RADIUS_20 }" />',
556
+ ),
557
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_BORDER_RADIUS_20', cssVar: 'kui-border-radius-20' } }],
558
+ output: withImport(
559
+ 'KUI_BORDER_RADIUS_20',
560
+ '<div :style="{ borderRadius: `var(--kui-border-radius-20, ${KUI_BORDER_RADIUS_20})` }" />',
561
+ ),
562
+ },
563
+
564
+ // KUI_FONT_WEIGHT — weight scale tokens
565
+ {
566
+ filename: 'test.vue',
567
+ code: withImport(
568
+ 'KUI_FONT_WEIGHT_BOLD',
569
+ '<div :style="{ fontWeight: KUI_FONT_WEIGHT_BOLD }" />',
570
+ ),
571
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_FONT_WEIGHT_BOLD', cssVar: 'kui-font-weight-bold' } }],
572
+ output: withImport(
573
+ 'KUI_FONT_WEIGHT_BOLD',
574
+ '<div :style="{ fontWeight: `var(--kui-font-weight-bold, ${KUI_FONT_WEIGHT_BOLD})` }" />',
575
+ ),
576
+ },
577
+
578
+ // KUI_LINE_HEIGHT — line height tokens
579
+ {
580
+ filename: 'test.vue',
581
+ code: withImport(
582
+ 'KUI_LINE_HEIGHT_40',
583
+ '<div :style="{ lineHeight: KUI_LINE_HEIGHT_40 }" />',
584
+ ),
585
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_LINE_HEIGHT_40', cssVar: 'kui-line-height-40' } }],
586
+ output: withImport(
587
+ 'KUI_LINE_HEIGHT_40',
588
+ '<div :style="{ lineHeight: `var(--kui-line-height-40, ${KUI_LINE_HEIGHT_40})` }" />',
589
+ ),
590
+ },
591
+
592
+ // KUI_Z_INDEX — z-index tokens
593
+ {
594
+ filename: 'test.vue',
595
+ code: withImport(
596
+ 'KUI_Z_INDEX_10',
597
+ '<div :style="{ zIndex: KUI_Z_INDEX_10 }" />',
598
+ ),
599
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_Z_INDEX_10', cssVar: 'kui-z-index-10' } }],
600
+ output: withImport(
601
+ 'KUI_Z_INDEX_10',
602
+ '<div :style="{ zIndex: `var(--kui-z-index-10, ${KUI_Z_INDEX_10})` }" />',
603
+ ),
604
+ },
605
+
606
+ // KUI_SPACE — spacing tokens (also confirms numeric suffix handling)
607
+ {
608
+ filename: 'test.vue',
609
+ code: withImport(
610
+ 'KUI_SPACE_40',
611
+ '<div :style="{ padding: KUI_SPACE_40 }" />',
612
+ ),
613
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_SPACE_40', cssVar: 'kui-space-40' } }],
614
+ output: withImport(
615
+ 'KUI_SPACE_40',
616
+ '<div :style="{ padding: `var(--kui-space-40, ${KUI_SPACE_40})` }" />',
617
+ ),
618
+ },
619
+
620
+ // REPORT_ONLY — no autofix because rewriting would change expression semantics
621
+
622
+ // Inside TemplateLiteral (no autofix: would nest backticks)
623
+ {
624
+ filename: 'test.vue',
625
+ code: withImport(
626
+ 'KUI_COLOR_TEXT_INVERSE',
627
+ '<div :color="`${KUI_COLOR_TEXT_INVERSE}`" />',
628
+ ),
629
+ errors: [{ messageId: 'wrapInVarNoFix', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
630
+ output: null,
631
+ },
632
+
633
+ /**
634
+ * Multi-token TemplateLiteral — both tokens reported, no autofix. Adjacent slots
635
+ * share quasi boundaries (quasis[1] is simultaneously the suffix of slot 0 and the
636
+ * prefix of slot 1), so per-token replacement ranges overlap. ESLint rejects
637
+ * overlapping fixes; a whole-template rewrite would be needed instead.
638
+ * Manual fix: `:padding="'var(--kui-space-0, ' + KUI_SPACE_0 + ') var(--kui-space-70, ' + KUI_SPACE_70 + ')'"`.
639
+ */
640
+ {
641
+ filename: 'test.vue',
642
+ code: sfc({
643
+ script: "import { KUI_SPACE_0, KUI_SPACE_70 } from '@kong/design-tokens'",
644
+ template: '<div :padding="`${KUI_SPACE_0} ${KUI_SPACE_70}`" />',
645
+ }),
646
+ errors: [
647
+ { messageId: 'wrapInVarNoFix', data: { local: 'KUI_SPACE_0', cssVar: 'kui-space-0' } },
648
+ { messageId: 'wrapInVarNoFix', data: { local: 'KUI_SPACE_70', cssVar: 'kui-space-70' } },
649
+ ],
650
+ output: null,
651
+ },
652
+
653
+ /**
654
+ * Mismatched CSS var: wraps the token with the WRONG custom property.
655
+ * `var(--kui-color-text-primary, ${KUI_COLOR_TEXT_INVERSE})` must be caught
656
+ * because the theme override targets the wrong property and theming will not work.
657
+ */
658
+ {
659
+ filename: 'test.vue',
660
+ code: withImport(
661
+ 'KUI_COLOR_TEXT_INVERSE',
662
+ '<div :color="`var(--kui-color-text-primary, ${KUI_COLOR_TEXT_INVERSE})`" />',
663
+ ),
664
+ errors: [{ messageId: 'wrapInVarNoFix', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
665
+ output: null,
666
+ },
667
+
668
+ /**
669
+ * Mismatched CSS var with import alias — alias resolves to the canonical imported name,
670
+ * so the wrong var is still caught even when a local alias is used.
671
+ */
672
+ {
673
+ filename: 'test.vue',
674
+ code: withImport(
675
+ 'KUI_COLOR_TEXT_INVERSE',
676
+ '<div :color="`var(--kui-color-text-primary, ${myColor})`" />',
677
+ 'myColor',
678
+ ),
679
+ errors: [{ messageId: 'wrapInVarNoFix', data: { local: 'myColor', cssVar: 'kui-color-text-inverse' } }],
680
+ output: null,
681
+ },
682
+
683
+ /**
684
+ * Token nested inside a TemplateLiteral slot expression (not a direct Identifier).
685
+ * `asDirectIdentifier` returns null for the ConditionalExpression, so the slot
686
+ * context is NOT passed and idempotency is not checked — the nested token is always reported.
687
+ */
688
+ {
689
+ filename: 'test.vue',
690
+ code: withImport(
691
+ 'KUI_COLOR_TEXT_INVERSE',
692
+ '<div :color="`var(--kui-color-text-inverse, ${cond ? KUI_COLOR_TEXT_INVERSE : \'red\'})`" />',
693
+ ),
694
+ errors: [{ messageId: 'wrapInVarNoFix', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
695
+ output: null,
696
+ },
697
+
698
+ // BinaryExpression (no autofix: changes string semantics)
699
+ {
700
+ filename: 'test.vue',
701
+ code: withImport(
702
+ 'KUI_COLOR_TEXT_INVERSE',
703
+ "<div :color=\"KUI_COLOR_TEXT_INVERSE + '!important'\" />",
704
+ ),
705
+ errors: [{ messageId: 'wrapInVarNoFix', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
706
+ output: null,
707
+ },
708
+
709
+ // CallExpression argument (no autofix: could break color helpers like darken/rgba)
710
+ {
711
+ filename: 'test.vue',
712
+ code: withImport(
713
+ 'KUI_COLOR_TEXT_INVERSE',
714
+ '<div :color="darken(KUI_COLOR_TEXT_INVERSE)" />',
715
+ ),
716
+ errors: [{ messageId: 'wrapInVarNoFix', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
717
+ output: null,
718
+ },
719
+
720
+ // Script-setup variable (`const c = KUI_X`) — detected, no autofix
721
+ {
722
+ filename: 'test.vue',
723
+ code: sfc({
724
+ script: [
725
+ "import { KUI_COLOR_TEXT_INVERSE } from '@kong/design-tokens'",
726
+ 'const myColor = KUI_COLOR_TEXT_INVERSE',
727
+ ].join('\n'),
728
+ template: '<div :color="myColor" />',
729
+ }),
730
+ errors: [{ messageId: 'wrapInVarScriptSetup', data: { imported: 'KUI_COLOR_TEXT_INVERSE', local: 'myColor', cssVar: 'kui-color-text-inverse' } }],
731
+ output: null,
732
+ },
733
+ ],
734
+ })
735
+
736
+ /**
737
+ * TypeScript SFCs — requires a second RuleTester that delegates <script lang="ts">
738
+ * to @typescript-eslint/parser, which produces TS-specific AST nodes (TSAsExpression,
739
+ * TSNonNullExpression, importKind) that the rule handles explicitly.
740
+ */
741
+ const tsTester = new RuleTester({
742
+ languageOptions: {
743
+ parser: vueParser,
744
+ parserOptions: {
745
+ parser: tsParser,
746
+ ecmaVersion: 2020,
747
+ sourceType: 'module',
748
+ },
749
+ },
750
+ })
751
+
752
+ tsTester.run(`${RULE_NAME} (TypeScript)`, rule, {
753
+ valid: [
754
+ // `import type { KUI_X }` — declaration-level type-only import is not tracked
755
+ {
756
+ filename: 'test.vue',
757
+ code: sfcTs({
758
+ script: "import type { KUI_COLOR_TEXT_INVERSE } from '@kong/design-tokens'",
759
+ template: '<div :color="KUI_COLOR_TEXT_INVERSE" />',
760
+ }),
761
+ },
762
+
763
+ // `${KUI_X as string}` in an already-correct var() slot — idempotency through a TS cast
764
+ {
765
+ filename: 'test.vue',
766
+ code: withImportTs(
767
+ 'KUI_COLOR_TEXT_INVERSE',
768
+ '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE as string})`" />',
769
+ ),
770
+ },
771
+
772
+ // `${KUI_X satisfies T}` in an already-correct var() slot — idempotency through `satisfies`
773
+ {
774
+ filename: 'test.vue',
775
+ code: withImportTs(
776
+ 'KUI_COLOR_TEXT_INVERSE',
777
+ '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE satisfies string})`" />',
778
+ ),
779
+ },
780
+ ],
781
+
782
+ invalid: [
783
+ // TSAsExpression — autofix unwraps the cast to reach the identifier; cast is preserved in output
784
+ {
785
+ filename: 'test.vue',
786
+ code: withImportTs(
787
+ 'KUI_COLOR_TEXT_INVERSE',
788
+ '<div :color="KUI_COLOR_TEXT_INVERSE as string" />',
789
+ ),
790
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
791
+ output: withImportTs(
792
+ 'KUI_COLOR_TEXT_INVERSE',
793
+ '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` as string" />',
794
+ ),
795
+ },
796
+
797
+ // TSNonNullExpression — autofix unwraps `!`; non-null assertion is preserved in output
798
+ {
799
+ filename: 'test.vue',
800
+ code: withImportTs(
801
+ 'KUI_COLOR_TEXT_INVERSE',
802
+ '<div :color="KUI_COLOR_TEXT_INVERSE!" />',
803
+ ),
804
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
805
+ output: withImportTs(
806
+ 'KUI_COLOR_TEXT_INVERSE',
807
+ '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`!" />',
808
+ ),
809
+ },
810
+
811
+ // TSSatisfiesExpression — autofix unwraps `satisfies`; the satisfies clause is preserved
812
+ {
813
+ filename: 'test.vue',
814
+ code: withImportTs(
815
+ 'KUI_COLOR_TEXT_INVERSE',
816
+ '<div :color="KUI_COLOR_TEXT_INVERSE satisfies string" />',
817
+ ),
818
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
819
+ output: withImportTs(
820
+ 'KUI_COLOR_TEXT_INVERSE',
821
+ '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})` satisfies string" />',
822
+ ),
823
+ },
824
+
825
+ /**
826
+ * One-hop alias with a TS `satisfies` initializer — the wrapper must be unwrapped
827
+ * so the alias is tracked and the template binding is reported.
828
+ */
829
+ {
830
+ filename: 'test.vue',
831
+ code: sfcTs({
832
+ script: [
833
+ "import { KUI_COLOR_TEXT_INVERSE } from '@kong/design-tokens'",
834
+ 'const myColor = KUI_COLOR_TEXT_INVERSE satisfies string',
835
+ ].join('\n'),
836
+ template: '<div :color="myColor" />',
837
+ }),
838
+ errors: [{ messageId: 'wrapInVarScriptSetup', data: { imported: 'KUI_COLOR_TEXT_INVERSE', local: 'myColor', cssVar: 'kui-color-text-inverse' } }],
839
+ output: null,
840
+ },
841
+
842
+ // `${KUI_X as string}` in a var() slot with the WRONG custom property — still caught
843
+ {
844
+ filename: 'test.vue',
845
+ code: withImportTs(
846
+ 'KUI_COLOR_TEXT_INVERSE',
847
+ '<div :color="`var(--kui-color-text-primary, ${KUI_COLOR_TEXT_INVERSE as string})`" />',
848
+ ),
849
+ errors: [{ messageId: 'wrapInVarNoFix', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
850
+ output: null,
851
+ },
852
+
853
+ // Mixed import `{ KUI_SPACE_40, type KUI_COLOR_TEXT_INVERSE }` — only the value specifier is reported
854
+ {
855
+ filename: 'test.vue',
856
+ code: sfcTs({
857
+ script: "import { KUI_SPACE_40, type KUI_COLOR_TEXT_INVERSE } from '@kong/design-tokens'",
858
+ template: '<div :style="{ padding: KUI_SPACE_40, color: KUI_COLOR_TEXT_INVERSE }" />',
859
+ }),
860
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_SPACE_40', cssVar: 'kui-space-40' } }],
861
+ output: sfcTs({
862
+ script: "import { KUI_SPACE_40, type KUI_COLOR_TEXT_INVERSE } from '@kong/design-tokens'",
863
+ template: '<div :style="{ padding: `var(--kui-space-40, ${KUI_SPACE_40})`, color: KUI_COLOR_TEXT_INVERSE }" />',
864
+ }),
865
+ },
866
+
867
+ /**
868
+ * One-hop alias with a TS `as` cast initializer — the wrapper must be unwrapped
869
+ * so the alias is still tracked and the template binding is reported.
870
+ */
871
+ {
872
+ filename: 'test.vue',
873
+ code: sfcTs({
874
+ script: [
875
+ "import { KUI_COLOR_TEXT_INVERSE } from '@kong/design-tokens'",
876
+ 'const myColor = KUI_COLOR_TEXT_INVERSE as string',
877
+ ].join('\n'),
878
+ template: '<div :color="myColor" />',
879
+ }),
880
+ errors: [{ messageId: 'wrapInVarScriptSetup', data: { imported: 'KUI_COLOR_TEXT_INVERSE', local: 'myColor', cssVar: 'kui-color-text-inverse' } }],
881
+ output: null,
882
+ },
883
+
884
+ /**
885
+ * One-hop alias with a TS non-null `!` initializer — same unwrapping requirement.
886
+ */
887
+ {
888
+ filename: 'test.vue',
889
+ code: sfcTs({
890
+ script: [
891
+ "import { KUI_COLOR_TEXT_INVERSE } from '@kong/design-tokens'",
892
+ 'const myColor = KUI_COLOR_TEXT_INVERSE!',
893
+ ].join('\n'),
894
+ template: '<div :color="myColor" />',
895
+ }),
896
+ errors: [{ messageId: 'wrapInVarScriptSetup', data: { imported: 'KUI_COLOR_TEXT_INVERSE', local: 'myColor', cssVar: 'kui-color-text-inverse' } }],
897
+ output: null,
898
+ },
899
+ ],
900
+ })
901
+
902
+ // Note: TSTypeAssertion (`<Type>expr` syntax) is NOT tested. In a Vue template
903
+ // attribute expression `:<prop>="<Type>expr"`, the parser sees `<Type>` as an
904
+ // HTML open-tag rather than a cast, making this syntax unreachable in the binding
905
+ // context where the rule runs. The TSAsExpression and TSNonNullExpression cases
906
+ // above already exercise the shared unwrap code path in both walkExpression and
907
+ // asDirectIdentifier.
908
+
909
+ /**
910
+ * Shorthand for an SFC that imports one token from `@kong/portal-design-tokens`.
911
+ * @param {string} varName - The exported constant name (e.g. `KUI_COLOR_TEXT_INVERSE`)
912
+ * @param {string} template - The `<template>` body
913
+ */
914
+ function withPortalImport(varName, template) {
915
+ return sfc({
916
+ script: `import { ${varName} } from '@kong/portal-design-tokens'`,
917
+ template,
918
+ })
919
+ }
920
+
921
+ /**
922
+ * importSources option — controls which packages the rule tracks token imports from.
923
+ * Default: ['@kong/design-tokens', '@kong/portal-design-tokens']
924
+ */
925
+ tester.run(`${RULE_NAME} (importSources option)`, rule, {
926
+ valid: [
927
+ // Default options: import from @kong/portal-design-tokens IS tracked — already wrapped is valid
928
+ {
929
+ filename: 'test.vue',
930
+ code: withPortalImport(
931
+ 'KUI_COLOR_TEXT_INVERSE',
932
+ '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
933
+ ),
934
+ },
935
+
936
+ // importSources restricted to @kong/design-tokens only — portal import is NOT tracked (no error)
937
+ {
938
+ filename: 'test.vue',
939
+ options: [{ importSources: ['@kong/design-tokens'] }],
940
+ code: withPortalImport(
941
+ 'KUI_COLOR_TEXT_INVERSE',
942
+ '<div :color="KUI_COLOR_TEXT_INVERSE" />',
943
+ ),
944
+ },
945
+
946
+ // importSources restricted to @kong/portal-design-tokens only — design-tokens import is NOT tracked
947
+ {
948
+ filename: 'test.vue',
949
+ options: [{ importSources: ['@kong/portal-design-tokens'] }],
950
+ code: withImport(
951
+ 'KUI_COLOR_TEXT_INVERSE',
952
+ '<div :color="KUI_COLOR_TEXT_INVERSE" />',
953
+ ),
954
+ },
955
+ ],
956
+
957
+ invalid: [
958
+ // Default options: import from @kong/portal-design-tokens IS flagged (same as @kong/design-tokens)
959
+ {
960
+ filename: 'test.vue',
961
+ code: withPortalImport(
962
+ 'KUI_COLOR_TEXT_INVERSE',
963
+ '<div :color="KUI_COLOR_TEXT_INVERSE" />',
964
+ ),
965
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
966
+ output: withPortalImport(
967
+ 'KUI_COLOR_TEXT_INVERSE',
968
+ '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
969
+ ),
970
+ },
971
+
972
+ // importSources: ['@kong/portal-design-tokens'] — portal import IS flagged, design-tokens is NOT
973
+ {
974
+ filename: 'test.vue',
975
+ options: [{ importSources: ['@kong/portal-design-tokens'] }],
976
+ code: withPortalImport(
977
+ 'KUI_COLOR_TEXT_INVERSE',
978
+ '<div :color="KUI_COLOR_TEXT_INVERSE" />',
979
+ ),
980
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
981
+ output: withPortalImport(
982
+ 'KUI_COLOR_TEXT_INVERSE',
983
+ '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
984
+ ),
985
+ },
986
+
987
+ // Fully custom importSources: ['my-company/design-tokens'] — only that source is tracked
988
+ {
989
+ filename: 'test.vue',
990
+ options: [{ importSources: ['my-company/design-tokens'] }],
991
+ code: sfc({
992
+ script: "import { KUI_COLOR_TEXT_INVERSE } from 'my-company/design-tokens'",
993
+ template: '<div :color="KUI_COLOR_TEXT_INVERSE" />',
994
+ }),
995
+ errors: [{ messageId: 'wrapInVar', data: { local: 'KUI_COLOR_TEXT_INVERSE', cssVar: 'kui-color-text-inverse' } }],
996
+ output: sfc({
997
+ script: "import { KUI_COLOR_TEXT_INVERSE } from 'my-company/design-tokens'",
998
+ template: '<div :color="`var(--kui-color-text-inverse, ${KUI_COLOR_TEXT_INVERSE})`" />',
999
+ }),
1000
+ },
1001
+ ],
1002
+ })
1003
+
1004
+ // Fully custom importSources: ['my-company/design-tokens'] — @kong/design-tokens is NOT tracked
1005
+ tester.run(`${RULE_NAME} (fully custom importSources — kong package not tracked)`, rule, {
1006
+ valid: [
1007
+ {
1008
+ filename: 'test.vue',
1009
+ options: [{ importSources: ['my-company/design-tokens'] }],
1010
+ code: withImport(
1011
+ 'KUI_COLOR_TEXT_INVERSE',
1012
+ '<div :color="KUI_COLOR_TEXT_INVERSE" />',
1013
+ ),
1014
+ },
1015
+ ],
1016
+ invalid: [],
1017
+ })