orbiq-neural-ui-kit 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,528 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { afterEach, describe, expect, it } from 'vitest'
7
+ import {
8
+ detectMigrationState,
9
+ findInkShiftMarker,
10
+ findInkShiftMarkerBelow,
11
+ getMigrationMode,
12
+ INK_SHIFT_MARKER,
13
+ migrateTokens,
14
+ writeInkShiftMarker,
15
+ } from './migrate-tokens-v2.js'
16
+
17
+ const tempDirs = []
18
+
19
+ afterEach(() => {
20
+ for (const dir of tempDirs.splice(0)) {
21
+ fs.rmSync(dir, { recursive: true, force: true })
22
+ }
23
+ })
24
+
25
+ describe('tokens v2 migration', () => {
26
+ it('runs the full migration for unmigrated content', () => {
27
+ const result = migrateTokens('<div class="bg-surface-white text-xl font-medium"></div>')
28
+
29
+ expect(result.migrated).toBe('<div class="bg-surface-base text-2xl-medium"></div>')
30
+ })
31
+
32
+ it('runs typography-only correction for already migrated content', () => {
33
+ const result = migrateTokens(
34
+ '<div class="bg-surface-base text-lg text-xl text-2xl-medium"></div>',
35
+ { mode: 'migrated-typography' },
36
+ )
37
+
38
+ expect(result.migrated).toBe(
39
+ '<div class="bg-surface-base text-md text-lg text-xl-medium"></div>',
40
+ )
41
+ })
42
+
43
+ it('leaves an old-scale size with no live v2 destination untouched and flags it (#940)', () => {
44
+ // Old-scale `12xl` used to shift onto v2 `13xl`, dropped in #940 — must
45
+ // not be rewritten to a class that ships no CSS.
46
+ const result = migrateTokens('<div class="text-12xl"></div>')
47
+
48
+ expect(result.migrated).toBe('<div class="text-12xl"></div>')
49
+ expect(result.flagged).toEqual([{ token: 'text-12xl', line: 1 }])
50
+ })
51
+
52
+ it('leaves a migrated-temp-scale size with no live v2 destination untouched and flags it (#940)', () => {
53
+ // Temp-scale `17xl` used to correct onto v2 `16xl`, dropped in #940.
54
+ const result = migrateTokens('<div class="text-17xl"></div>', {
55
+ mode: 'migrated-typography',
56
+ })
57
+
58
+ expect(result.migrated).toBe('<div class="text-17xl"></div>')
59
+ expect(result.flagged).toEqual([{ token: 'text-17xl', line: 1 }])
60
+ })
61
+
62
+ it('flags text-tiny and dropped xl sizes in every mode without rewriting them (#940)', () => {
63
+ const full = migrateTokens('<div class="text-tiny text-14xl-bold"></div>')
64
+ expect(full.migrated).toBe('<div class="text-tiny text-14xl-bold"></div>')
65
+ expect(full.flagged.map((f) => f.token)).toEqual(['text-tiny', 'text-14xl-bold'])
66
+
67
+ const migrated = migrateTokens('<div class="text-tiny text-14xl-bold"></div>', {
68
+ mode: 'migrated-typography',
69
+ })
70
+ expect(migrated.migrated).toBe('<div class="text-tiny text-14xl-bold"></div>')
71
+ expect(migrated.flagged.map((f) => f.token)).toEqual(['text-tiny', 'text-14xl-bold'])
72
+ })
73
+
74
+ it('still corrects temp-scale text-12xl to text-11xl in migrated-typography mode, unflagged', () => {
75
+ // `12xl` survives in v2, so this correction is unaffected by #940 — only
76
+ // the destinations dropped in #940 (14xl-17xl) got capped.
77
+ const result = migrateTokens('<div class="text-12xl"></div>', {
78
+ mode: 'migrated-typography',
79
+ })
80
+
81
+ expect(result.migrated).toBe('<div class="text-11xl"></div>')
82
+ expect(result.flagged).toEqual([])
83
+ })
84
+
85
+ it('renames radius aliases to numbered tokens (ADR-0006)', () => {
86
+ const result = migrateTokens(
87
+ '<div class="rounded rounded-sm rounded-md rounded-lg rounded-xl rounded-2xl"></div>',
88
+ )
89
+
90
+ expect(result.migrated).toBe(
91
+ '<div class="rounded-4 rounded-1 rounded-5 rounded-6 rounded-7 rounded-8"></div>',
92
+ )
93
+ expect(result.flagged).toEqual([])
94
+ })
95
+
96
+ it('renames directional and variant-prefixed radius aliases', () => {
97
+ const result = migrateTokens(
98
+ '<div class="rounded-t rounded-t-lg rounded-tl-sm hover:rounded-2xl rounded-ss-md"></div>',
99
+ )
100
+
101
+ expect(result.migrated).toBe(
102
+ '<div class="rounded-t-4 rounded-t-6 rounded-tl-1 hover:rounded-8 rounded-ss-5"></div>',
103
+ )
104
+ })
105
+
106
+ it('keeps rounded-none, rounded-full, numbered tokens, and arbitrary radii', () => {
107
+ const input = '<div class="rounded-none rounded-full rounded-5 rounded-[3px]"></div>'
108
+ const result = migrateTokens(input)
109
+
110
+ expect(result.migrated).toBe(input)
111
+ })
112
+
113
+ it('renames radius aliases in migrated-typography mode too', () => {
114
+ const result = migrateTokens('<div class="rounded rounded-md text-lg"></div>', {
115
+ mode: 'migrated-typography',
116
+ })
117
+
118
+ expect(result.migrated).toBe('<div class="rounded-4 rounded-5 text-md"></div>')
119
+ })
120
+
121
+ it('rewrites bare `rounded` in class contexts and @apply, not in prose', () => {
122
+ const input = [
123
+ "const cls = 'px-2 rounded border'",
124
+ '// line-heights are rounded to px',
125
+ 'The shell (rounded/bg/shadow) is owned by the parent.',
126
+ '.btn { @apply rounded border; }',
127
+ 'Values are rounded up, per the `rounded` utility docs.',
128
+ ].join('\n')
129
+
130
+ const result = migrateTokens(input)
131
+
132
+ expect(result.migrated).toBe(
133
+ [
134
+ "const cls = 'px-2 rounded-4 border'",
135
+ '// line-heights are rounded to px',
136
+ 'The shell (rounded/bg/shadow) is owned by the parent.',
137
+ '.btn { @apply rounded-4 border; }',
138
+ 'Values are rounded up, per the `rounded-4` utility docs.',
139
+ ].join('\n'),
140
+ )
141
+ })
142
+
143
+ it('does not treat apostrophes in prose as string delimiters', () => {
144
+ const input = [
145
+ "// the row's corners are rounded when it's hovered",
146
+ "const cls = 'rounded' // it's the default",
147
+ ].join('\n')
148
+
149
+ const result = migrateTokens(input)
150
+
151
+ expect(result.migrated).toBe(
152
+ [
153
+ "// the row's corners are rounded when it's hovered",
154
+ "const cls = 'rounded-4' // it's the default",
155
+ ].join('\n'),
156
+ )
157
+ })
158
+
159
+ it('flags text-*-black instead of renaming it (#998)', () => {
160
+ const result = migrateTokens('<div class="text-base-black text-p-xl-black"></div>')
161
+
162
+ expect(result.migrated).toBe('<div class="text-base-black text-p-xl-black"></div>')
163
+ expect(result.flagged.map((f) => f.token)).toEqual([
164
+ 'text-base-black',
165
+ 'text-p-xl-black',
166
+ ])
167
+ })
168
+
169
+ it('flags a size + font-extrabold/font-black pair instead of merging (#998)', () => {
170
+ const result = migrateTokens('<div class="text-xl font-extrabold"></div>')
171
+
172
+ // The size still shifts (xl → 2xl); the weight is not merged onto the
173
+ // removed black style.
174
+ expect(result.migrated).toBe('<div class="text-2xl font-extrabold"></div>')
175
+ expect(result.merges).toEqual([])
176
+ expect(result.flagged.map((f) => f.token)).toEqual(['text-2xl + font-extrabold'])
177
+
178
+ const black = migrateTokens('<div class="text-sm font-black"></div>')
179
+ expect(black.migrated).toBe('<div class="text-sm font-black"></div>')
180
+ expect(black.flagged.map((f) => f.token)).toEqual(['text-sm + font-black'])
181
+ })
182
+
183
+ it('selects typography-only mode when v2 sentinels are present', () => {
184
+ const file = writeTempFile(
185
+ '<div class="bg-surface-base bg-surface-white text-xl"></div>',
186
+ )
187
+ const state = detectMigrationState([file])
188
+
189
+ expect(state).toEqual({ pre: 1, post: 1, likelyMigrated: true })
190
+ expect(getMigrationMode(state)).toBe('migrated-typography')
191
+ expect(getMigrationMode(state, { force: true })).toBe('full')
192
+ expect(getMigrationMode(state, { radiusOnly: true })).toBe('radius-only')
193
+ })
194
+
195
+ it('ink-shift mode shifts chromatic ink scales down one level (#1016)', () => {
196
+ const result = migrateTokens(
197
+ '<div class="text-ink-red-2 bg-ink-blue-5 text-ink-pink-10"></div>',
198
+ { mode: 'ink-shift' },
199
+ )
200
+
201
+ expect(result.migrated).toBe(
202
+ '<div class="text-ink-red-1 bg-ink-blue-4 text-ink-pink-9"></div>',
203
+ )
204
+ expect(result.flagged).toEqual([])
205
+ })
206
+
207
+ it('ink-shift is single-pass: ink-red-3 lands on ink-red-2, not ink-red-1', () => {
208
+ const result = migrateTokens('<div class="text-ink-red-3 text-ink-red-2"></div>', {
209
+ mode: 'ink-shift',
210
+ })
211
+
212
+ expect(result.migrated).toBe('<div class="text-ink-red-2 text-ink-red-1"></div>')
213
+ })
214
+
215
+ it('ink-shift maps the old -10 top step onto the new -9 end of scale', () => {
216
+ const result = migrateTokens('var(--ink-red-10)', { mode: 'ink-shift' })
217
+
218
+ expect(result.migrated).toBe('var(--ink-red-9)')
219
+ })
220
+
221
+ it('ink-shift leaves ink-gray untouched', () => {
222
+ const input = '<div class="text-ink-gray-2 text-ink-gray-5 bg-ink-gray-9"></div>'
223
+ const result = migrateTokens(input, { mode: 'ink-shift' })
224
+
225
+ expect(result.migrated).toBe(input)
226
+ expect(result.flagged).toEqual([])
227
+ })
228
+
229
+ it('ink-shift never touches stale non-v2 numerics like ink-blue-600', () => {
230
+ const input = '<div class="text-ink-blue-600"></div>'
231
+ const result = migrateTokens(input, { mode: 'ink-shift' })
232
+
233
+ expect(result.migrated).toBe(input)
234
+ expect(result.replacements).toEqual([])
235
+ expect(result.flagged).toEqual([])
236
+ })
237
+
238
+ it('ink-shift flags ink-<family>-1 (old white step) instead of rewriting it', () => {
239
+ const input = '<div class="text-ink-red-1">var(--ink-blue-1)</div>'
240
+ const result = migrateTokens(input, { mode: 'ink-shift' })
241
+
242
+ expect(result.migrated).toBe(input)
243
+ expect(result.flagged.map((f) => f.token)).toEqual(['ink-red-1', 'ink-blue-1'])
244
+ })
245
+
246
+ it('ink-shift runs nothing else: colors, typography, radii, merges all untouched', () => {
247
+ const input =
248
+ '<div class="bg-surface-white text-xl font-medium rounded rounded-md text-base-black"></div>'
249
+ const result = migrateTokens(input, { mode: 'ink-shift' })
250
+
251
+ expect(result.migrated).toBe(input)
252
+ expect(result.merges).toEqual([])
253
+ expect(result.flagged).toEqual([])
254
+ })
255
+
256
+ it('ink-shift marker round-trips: absent, written, found', () => {
257
+ // realpath: the search resolves symlinks, and os.tmpdir() is one on macOS.
258
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-v2-')))
259
+ tempDirs.push(dir)
260
+
261
+ expect(findInkShiftMarker(dir)).toBe(null)
262
+ writeInkShiftMarker(dir)
263
+ expect(findInkShiftMarker(dir)).toBe(path.join(dir, INK_SHIFT_MARKER))
264
+ })
265
+
266
+ it('ink-shift marker is found in both directions: ancestor and subtree', () => {
267
+ // realpath: os.tmpdir() is a symlink on macOS, and path.resolve does not
268
+ // follow symlinks.
269
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-v2-')))
270
+ tempDirs.push(root)
271
+ const sub = path.join(root, 'src')
272
+ fs.mkdirSync(sub)
273
+
274
+ // A marker written at the root is found from the subdirectory (ancestor
275
+ // search), and a marker written in the subdirectory is found from the
276
+ // root (subtree search) — but not by the ancestor search alone.
277
+ writeInkShiftMarker(root)
278
+ expect(findInkShiftMarker(sub)).toBe(path.join(root, INK_SHIFT_MARKER))
279
+ fs.rmSync(path.join(root, INK_SHIFT_MARKER))
280
+ writeInkShiftMarker(sub)
281
+ expect(findInkShiftMarkerBelow(root)).toBe(path.join(sub, INK_SHIFT_MARKER))
282
+
283
+ // node_modules is skipped like the file walk skips it.
284
+ fs.rmSync(path.join(sub, INK_SHIFT_MARKER))
285
+ const nm = path.join(root, 'node_modules')
286
+ fs.mkdirSync(nm)
287
+ writeInkShiftMarker(nm)
288
+ expect(findInkShiftMarkerBelow(root)).toBe(null)
289
+ })
290
+
291
+ it('marker search ignores this run own markers but still finds a rival', () => {
292
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-v2-')))
293
+ tempDirs.push(root)
294
+ const sub = path.join(root, 'src')
295
+ fs.mkdirSync(sub)
296
+ // This is what claim-then-verify does: our own claim must not look like a
297
+ // rival, or every run would abort itself.
298
+ writeInkShiftMarker(root)
299
+ const ours = new Set([path.join(root, INK_SHIFT_MARKER)])
300
+ expect(findInkShiftMarker(root, { ignore: ours })).toBe(null)
301
+ expect(findInkShiftMarkerBelow(root, { ignore: ours })).toBe(null)
302
+
303
+ // A concurrent run claiming a nested target is still found.
304
+ writeInkShiftMarker(sub)
305
+ expect(findInkShiftMarkerBelow(root, { ignore: ours })).toBe(
306
+ path.join(sub, INK_SHIFT_MARKER),
307
+ )
308
+ expect(findInkShiftMarker(sub, { ignore: new Set([path.join(sub, INK_SHIFT_MARKER)]) })).toBe(
309
+ path.join(root, INK_SHIFT_MARKER),
310
+ )
311
+ })
312
+
313
+ it('ink-shift CLI guard: real run writes the marker, a second real run refuses, --dry-run previews', () => {
314
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-v2-')))
315
+ tempDirs.push(root)
316
+ const fixture = path.join(root, 'a.vue')
317
+ fs.writeFileSync(fixture, '<i class="text-ink-red-3"></i>')
318
+ const script = fileURLToPath(new URL('./migrate-tokens-v2.js', import.meta.url))
319
+ const run = (...cliArgs) =>
320
+ spawnSync(process.execPath, [script, ...cliArgs], { encoding: 'utf8' })
321
+
322
+ const first = run('--ink-shift', root)
323
+ expect(first.status).toBe(0)
324
+ expect(fs.readFileSync(fixture, 'utf8')).toContain('ink-red-2')
325
+ expect(fs.existsSync(path.join(root, INK_SHIFT_MARKER))).toBe(true)
326
+
327
+ const second = run('--ink-shift', root)
328
+ expect(second.status).toBe(1)
329
+ expect(second.stderr).toContain('already ran')
330
+ expect(fs.readFileSync(fixture, 'utf8')).toContain('ink-red-2')
331
+
332
+ const dry = run('--ink-shift', '--dry-run', root)
333
+ expect(dry.status).toBe(0)
334
+ expect(dry.stderr).toContain('already ran')
335
+ expect(fs.readFileSync(fixture, 'utf8')).toContain('ink-red-2')
336
+
337
+ // A file target is rejected — its marker would over-claim the directory.
338
+ const fileRun = run('--ink-shift', fixture)
339
+ expect(fileRun.status).toBe(1)
340
+ expect(fileRun.stderr).toContain('directory targets only')
341
+ })
342
+
343
+ it('writeInkShiftMarker claims the directory: a second write throws EEXIST', () => {
344
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-v2-')))
345
+ tempDirs.push(dir)
346
+ writeInkShiftMarker(dir)
347
+ // Exclusive create, so the claim is atomic: a concurrent run cannot pass
348
+ // the guard and write over the winner.
349
+ expect(() => writeInkShiftMarker(dir)).toThrow(/EEXIST/)
350
+ })
351
+
352
+ // chmod 0o444 does not stop root, so the simulated interrupt never happens there.
353
+ it.skipIf(process.getuid?.() === 0)(
354
+ 'ink-shift CLI fails closed when interrupted: marker lands before rewrites, retry refuses',
355
+ () => {
356
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-v2-')))
357
+ tempDirs.push(root)
358
+ // Unwritable file: the run crashes mid-loop after the marker is written.
359
+ const blocked = path.join(root, 'a.vue')
360
+ fs.writeFileSync(blocked, '<i class="text-ink-red-3"></i>')
361
+ fs.chmodSync(blocked, 0o444)
362
+ const script = fileURLToPath(new URL('./migrate-tokens-v2.js', import.meta.url))
363
+ const run = () =>
364
+ spawnSync(process.execPath, [script, '--ink-shift', root], { encoding: 'utf8' })
365
+
366
+ const interrupted = run()
367
+ expect(interrupted.status).not.toBe(0)
368
+ expect(fs.existsSync(path.join(root, INK_SHIFT_MARKER))).toBe(true)
369
+ expect(fs.readFileSync(blocked, 'utf8')).toContain('ink-red-3')
370
+
371
+ // The retry refuses instead of double-shifting whatever was rewritten.
372
+ const retry = run()
373
+ expect(retry.status).toBe(1)
374
+ expect(retry.stderr).toContain('already ran')
375
+ },
376
+ )
377
+
378
+ // A read-only directory does not stop root, so the write never fails there.
379
+ it.skipIf(process.getuid?.() === 0)(
380
+ 'ink-shift CLI rolls back earlier markers when a marker write fails',
381
+ () => {
382
+ const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-v2-')))
383
+ tempDirs.push(base)
384
+ const ok = path.join(base, 'ok')
385
+ const blocked = path.join(base, 'blocked')
386
+ fs.mkdirSync(ok)
387
+ fs.mkdirSync(blocked)
388
+ const fixture = path.join(ok, 'a.vue')
389
+ fs.writeFileSync(fixture, '<i class="text-ink-red-3"></i>')
390
+ // The second target cannot take a marker, so the run must undo the first.
391
+ fs.chmodSync(blocked, 0o555)
392
+ const script = fileURLToPath(new URL('./migrate-tokens-v2.js', import.meta.url))
393
+
394
+ const result = spawnSync(process.execPath, [script, '--ink-shift', ok, blocked], {
395
+ encoding: 'utf8',
396
+ })
397
+ fs.chmodSync(blocked, 0o755)
398
+ expect(result.status).toBe(1)
399
+ // No rewrite happened, so no marker may survive to refuse the retry.
400
+ expect(fs.existsSync(path.join(ok, INK_SHIFT_MARKER))).toBe(false)
401
+ expect(fs.readFileSync(fixture, 'utf8')).toContain('ink-red-3')
402
+
403
+ // The retry runs and shifts exactly once.
404
+ const retry = spawnSync(process.execPath, [script, '--ink-shift', ok], { encoding: 'utf8' })
405
+ expect(retry.status).toBe(0)
406
+ expect(fs.readFileSync(fixture, 'utf8')).toContain('ink-red-2')
407
+ },
408
+ )
409
+
410
+ it('ink-shift CLI shifts a file once when targets overlap', () => {
411
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-v2-')))
412
+ tempDirs.push(root)
413
+ const sub = path.join(root, 'src')
414
+ fs.mkdirSync(sub)
415
+ const fixture = path.join(sub, 'a.vue')
416
+ fs.writeFileSync(fixture, '<i class="text-ink-red-3"></i>')
417
+ const script = fileURLToPath(new URL('./migrate-tokens-v2.js', import.meta.url))
418
+
419
+ // A symlink alias of the subdirectory is the same target, not a third one.
420
+ const alias = path.join(root, 'src-alias')
421
+ fs.symlinkSync(sub, alias)
422
+
423
+ const result = spawnSync(
424
+ process.execPath,
425
+ [script, '--ink-shift', root, sub, alias],
426
+ { encoding: 'utf8' },
427
+ )
428
+ expect(result.status).toBe(0)
429
+ // One shift (-3 → -2), not one per overlapping target (-3 → -1).
430
+ expect(fs.readFileSync(fixture, 'utf8')).toContain('ink-red-2')
431
+
432
+ // The marker is also found through the alias, so a retry via the symlink refuses.
433
+ const retry = spawnSync(process.execPath, [script, '--ink-shift', alias], {
434
+ encoding: 'utf8',
435
+ })
436
+ expect(retry.status).toBe(1)
437
+ expect(retry.stderr).toContain('already ran')
438
+ })
439
+
440
+ it('ink-shift CLI follows internal symlinks, reports external ones, survives a cycle', () => {
441
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-v2-')))
442
+ tempDirs.push(root)
443
+ // Internal: a symlink to a sibling directory inside the target.
444
+ const pkg = path.join(root, 'pkg')
445
+ fs.mkdirSync(pkg)
446
+ const internalFixture = path.join(pkg, 'a.vue')
447
+ fs.writeFileSync(internalFixture, '<i class="text-ink-red-3"></i>')
448
+ fs.symlinkSync(pkg, path.join(root, 'pkg-link'))
449
+ // A cycle: an internal link back to the root itself.
450
+ fs.symlinkSync(root, path.join(pkg, 'back'))
451
+ // External: a shared package outside the target, linked under it.
452
+ const shared = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-v2-shared-')))
453
+ tempDirs.push(shared)
454
+ const externalFixture = path.join(shared, 'b.vue')
455
+ fs.writeFileSync(externalFixture, '<i class="text-ink-red-3"></i>')
456
+ fs.symlinkSync(shared, path.join(root, 'linked-pkg'))
457
+ // External file link: same break through a narrower entry point.
458
+ const externalFile = path.join(shared, 'theme.css')
459
+ fs.writeFileSync(externalFile, '.a { @apply text-ink-red-3; }')
460
+ fs.symlinkSync(externalFile, path.join(root, 'theme.css'))
461
+ const script = fileURLToPath(new URL('./migrate-tokens-v2.js', import.meta.url))
462
+
463
+ const result = spawnSync(process.execPath, [script, '--ink-shift', root], {
464
+ encoding: 'utf8',
465
+ timeout: 30_000,
466
+ })
467
+ expect(result.status).toBe(0)
468
+ // Internal content is shifted exactly once, through both the dir and its link.
469
+ expect(fs.readFileSync(internalFixture, 'utf8')).toContain('ink-red-2')
470
+ // The external package is untouched — it has no marker of its own, so a
471
+ // rewrite here would set up a double-shift on a later direct run.
472
+ expect(fs.readFileSync(externalFixture, 'utf8')).toContain('ink-red-3')
473
+ // A symlinked file out of the target is skipped and reported too.
474
+ expect(fs.readFileSync(externalFile, 'utf8')).toContain('ink-red-3')
475
+ expect(result.stdout).toContain('NOT migrated')
476
+ expect(result.stdout).toContain('linked-pkg')
477
+ expect(result.stdout).toContain('theme.css')
478
+ })
479
+
480
+ it('ink-shift CLI ignores a marker inside an external linked package', () => {
481
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-v2-')))
482
+ tempDirs.push(root)
483
+ const fixture = path.join(root, 'a.vue')
484
+ fs.writeFileSync(fixture, '<i class="text-ink-red-3"></i>')
485
+ // An external package that already shifted on its own, linked under a
486
+ // target that has not. The run never rewrites it, so its marker must not
487
+ // block the target.
488
+ const shared = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-v2-shared-')))
489
+ tempDirs.push(shared)
490
+ writeInkShiftMarker(shared)
491
+ fs.symlinkSync(shared, path.join(root, 'linked-pkg'))
492
+
493
+ expect(findInkShiftMarkerBelow(root)).toBe(null)
494
+
495
+ const script = fileURLToPath(new URL('./migrate-tokens-v2.js', import.meta.url))
496
+ const result = spawnSync(process.execPath, [script, '--ink-shift', root], {
497
+ encoding: 'utf8',
498
+ timeout: 30_000,
499
+ })
500
+ expect(result.status).toBe(0)
501
+ expect(fs.readFileSync(fixture, 'utf8')).toContain('ink-red-2')
502
+ })
503
+
504
+ it('getMigrationMode selects ink-shift regardless of migration state', () => {
505
+ expect(getMigrationMode({ likelyMigrated: true }, { inkShift: true })).toBe('ink-shift')
506
+ expect(getMigrationMode({ likelyMigrated: false }, { inkShift: true })).toBe('ink-shift')
507
+ })
508
+
509
+ it('radius-only mode renames radii and touches nothing else', () => {
510
+ const result = migrateTokens(
511
+ '<div class="rounded rounded-md text-lg bg-surface-white text-base-black"></div>',
512
+ { mode: 'radius-only' },
513
+ )
514
+
515
+ expect(result.migrated).toBe(
516
+ '<div class="rounded-4 rounded-5 text-lg bg-surface-white text-base-black"></div>',
517
+ )
518
+ expect(result.flagged.map((f) => f.token)).toEqual(['text-base-black'])
519
+ })
520
+ })
521
+
522
+ function writeTempFile(content) {
523
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-v2-'))
524
+ tempDirs.push(dir)
525
+ const file = path.join(dir, 'fixture.vue')
526
+ fs.writeFileSync(file, content)
527
+ return file
528
+ }