basalt-ui 1.19.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/AGENTS.md +19 -1
  2. package/CHANGELOG.md +39 -0
  3. package/MIGRATING.md +326 -0
  4. package/README.md +87 -9
  5. package/agent/rules/basalt-app.md +84 -0
  6. package/agent/rules/basalt-charts.md +32 -17
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-mantine.md +25 -10
  9. package/agent/rules/basalt-router.md +79 -15
  10. package/agent/rules/basalt-state.md +21 -3
  11. package/agent/rules/basalt-tokens.md +132 -6
  12. package/agent/skills/basalt-app/SKILL.md +45 -6
  13. package/agent/skills/basalt-charts/SKILL.md +5 -3
  14. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  15. package/configs/check.yml +11 -4
  16. package/configs/lefthook.yml +59 -7
  17. package/configs/oxlint-plugin.js +633 -30
  18. package/configs/oxlint-plugin.test.ts +594 -15
  19. package/configs/oxlint.json +10 -1
  20. package/dist/agent/stick-to-bottom.js +1 -1
  21. package/dist/agent-chat/thread-detail-panel.js +1 -1
  22. package/dist/agent-chat/thread-message.js +2 -2
  23. package/dist/agent-chat/tool-chip.js +1 -1
  24. package/dist/charts/kinds/Bars.d.ts +1 -1
  25. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  26. package/dist/charts/kinds/Donut.d.ts +1 -1
  27. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  28. package/dist/charts/kinds/Donut.js +1 -1
  29. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  30. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  31. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  32. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  33. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  34. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  35. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  36. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  37. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  38. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  39. package/dist/charts/primitives/ChartLegend.js +2 -2
  40. package/dist/charts/primitives/ChartTooltip.js +1 -1
  41. package/dist/cli/index.d.ts +146 -20
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +901 -101
  44. package/dist/cli/toolchain-wiring.test.d.ts +2 -0
  45. package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
  46. package/dist/cli/waiver-audit.test.d.ts +2 -0
  47. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  48. package/dist/dashboard/delta-badge.js +2 -2
  49. package/dist/dashboard/stat-card.js +1 -1
  50. package/dist/data/virtual-list.js +5 -5
  51. package/dist/guard/index.d.ts +101 -2
  52. package/dist/guard/index.d.ts.map +1 -1
  53. package/dist/guard/index.js +474 -62
  54. package/dist/guard/types.d.ts +25 -5
  55. package/dist/guard/types.d.ts.map +1 -1
  56. package/dist/register.d.ts +25 -0
  57. package/dist/register.d.ts.map +1 -1
  58. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  59. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  60. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  61. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  62. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  63. package/dist/router-tanstack/search-param-store.js +31 -3
  64. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  65. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  66. package/dist/shell/app-mobile-nav.module.css +11 -3
  67. package/dist/shell/index.d.ts +12 -5
  68. package/dist/shell/index.d.ts.map +1 -1
  69. package/dist/shell/index.js +24 -6
  70. package/dist/surfaces.d.ts +1 -1
  71. package/dist/surfaces.js +1 -1
  72. package/dist/tokens/index.d.ts +5 -3
  73. package/dist/tokens/index.d.ts.map +1 -1
  74. package/dist/tokens/index.js +7 -1
  75. package/dist/tokens/palette.d.ts +35 -27
  76. package/dist/tokens/palette.d.ts.map +1 -1
  77. package/dist/tokens/palette.js +24 -18
  78. package/dist/tokens/text-scale.test.d.ts +2 -0
  79. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  80. package/dist/tokens.css +21 -17
  81. package/dist/vite-color-mix.js +2 -2
  82. package/dist/vite.d.ts +24 -2
  83. package/dist/vite.d.ts.map +1 -1
  84. package/dist/vite.js +54 -13
  85. package/llms.txt +3 -3
  86. package/package.json +4 -3
  87. package/src/agent/stick-to-bottom.tsx +1 -1
  88. package/src/agent-chat/thread-detail-panel.tsx +1 -1
  89. package/src/agent-chat/thread-message.tsx +3 -3
  90. package/src/agent-chat/tool-chip.tsx +1 -1
  91. package/src/charts/kinds/Bars.tsx +1 -1
  92. package/src/charts/kinds/Donut.tsx +3 -2
  93. package/src/charts/kinds/DualPanel.tsx +2 -2
  94. package/src/charts/kinds/Heatmap.tsx +1 -1
  95. package/src/charts/kinds/MultiLine.tsx +1 -1
  96. package/src/charts/kinds/StackedArea.tsx +1 -1
  97. package/src/charts/kinds/ZonedLine.tsx +1 -1
  98. package/src/charts/primitives/ChartLegend.tsx +2 -2
  99. package/src/charts/primitives/ChartTooltip.tsx +1 -1
  100. package/src/cli/index.ts +1614 -164
  101. package/src/cli/init-sync.test.ts +21 -3
  102. package/src/cli/template-ledger.test.ts +1 -1
  103. package/src/cli/tokens-css.test.ts +27 -14
  104. package/src/cli/toolchain-wiring.test.ts +856 -0
  105. package/src/cli/waiver-audit.test.ts +131 -0
  106. package/src/dashboard/delta-badge.tsx +3 -3
  107. package/src/dashboard/stat-card.tsx +1 -1
  108. package/src/data/virtual-list.tsx +8 -8
  109. package/src/guard/check-source.test.ts +1104 -12
  110. package/src/guard/index.ts +1106 -103
  111. package/src/guard/types.ts +29 -4
  112. package/src/register.ts +32 -0
  113. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  114. package/src/router-tanstack/search-param-store.test.ts +135 -0
  115. package/src/router-tanstack/search-param-store.ts +90 -8
  116. package/src/shell/app-mobile-nav.module.css +11 -3
  117. package/src/shell/app-mobile-nav.tsx +1 -1
  118. package/src/shell/index.test.tsx +52 -0
  119. package/src/shell/index.tsx +64 -13
  120. package/src/surfaces.ts +1 -1
  121. package/src/theme/density-relations.test.ts +8 -5
  122. package/src/theme/spacing.test.ts +2 -0
  123. package/src/theme-toggle/index.tsx +2 -2
  124. package/src/tokens/index.ts +28 -3
  125. package/src/tokens/palette.ts +24 -18
  126. package/src/tokens/text-scale.test.ts +51 -0
  127. package/src/vite-color-mix.ts +2 -2
  128. package/src/vite.test.ts +125 -6
  129. package/src/vite.ts +148 -20
@@ -11,6 +11,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
11
11
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
12
  import { tmpdir } from 'node:os'
13
13
  import { dirname, resolve } from 'node:path'
14
+ // eslint-disable-next-line -- the plugin is plain JS; the ledger + id set are named exports beside it
15
+ import basaltPlugin, { KNOWN_RULE_IDS, PLUGIN_RULE_GRACE } from './oxlint-plugin.js'
16
+ import { GUARD_RULES } from '../src/guard/index.ts'
14
17
 
15
18
  const PLUGIN_PATH = resolve(import.meta.dirname, 'oxlint-plugin.js')
16
19
  const OXLINT_BIN = resolve(import.meta.dirname, '..', '..', '..', 'node_modules', '.bin', 'oxlint')
@@ -30,6 +33,8 @@ beforeEach(() => {
30
33
  'basalt/chart-in-raw-surface': 'error',
31
34
  'basalt/hand-rolled-plot': 'error',
32
35
  'basalt/chart-legend-literal': 'error',
36
+ 'basalt/shadow-basalt-export': 'error',
37
+ 'basalt/hand-rolled-shell': 'error',
33
38
  'basalt/visx-boundary': 'error',
34
39
  'basalt/visx-tooltip': 'error',
35
40
  'basalt/token-layer-boundary': 'error',
@@ -46,7 +51,10 @@ afterEach(() => {
46
51
  })
47
52
 
48
53
  /** Writes `source` as the sole fixture file and runs oxlint against the temp fixture repo. */
49
- function run(source: string, filename = 'fixture.tsx'): { code: number; rules: Set<string> } {
54
+ function run(
55
+ source: string,
56
+ filename = 'fixture.tsx',
57
+ ): { code: number; rules: Set<string>; output: string } {
50
58
  const filePath = resolve(dir, filename)
51
59
  mkdirSync(dirname(filePath), { recursive: true })
52
60
  writeFileSync(filePath, source)
@@ -55,7 +63,7 @@ function run(source: string, filename = 'fixture.tsx'): { code: number; rules: S
55
63
  const rules = new Set(
56
64
  [...output.matchAll(/\(basalt\/([\w-]+)\)/g)].map((match) => match[1] as string),
57
65
  )
58
- return { code: result.exitCode ?? 0, rules }
66
+ return { code: result.exitCode ?? 0, rules, output }
59
67
  }
60
68
 
61
69
  // ── no-raw-font-size ─────────────────────────────────────────────────────────
@@ -355,28 +363,40 @@ describe('basalt/agent-no-raw-usechat', () => {
355
363
 
356
364
  describe('basalt/ai-sdk-major', () => {
357
365
  it("flags when the nearest package.json declares a different ai major (5 vs basalt's 7)", () => {
358
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
366
+ writeFileSync(
367
+ resolve(dir, 'package.json'),
368
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '5.0.196' } }),
369
+ )
359
370
  const { code, rules } = run(`import { streamText } from 'ai'\n`, 'lib.ts')
360
371
  expect(code).toBe(1)
361
372
  expect(rules).toContain('ai-sdk-major')
362
373
  })
363
374
 
364
375
  it('does NOT flag when the nearest package.json declares the same major (^7.0.18)', () => {
365
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '^7.0.18' } }))
376
+ writeFileSync(
377
+ resolve(dir, 'package.json'),
378
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '^7.0.18' } }),
379
+ )
366
380
  const { code, rules } = run(`import { streamText } from 'ai'\n`, 'lib.ts')
367
381
  expect(code).toBe(0)
368
382
  expect(rules).not.toContain('ai-sdk-major')
369
383
  })
370
384
 
371
385
  it('does NOT flag when there is no ai in the nearest package.json', () => {
372
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: {} }))
386
+ writeFileSync(
387
+ resolve(dir, 'package.json'),
388
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1' } }),
389
+ )
373
390
  const { code, rules } = run(`import { streamText } from 'ai'\n`, 'lib.ts')
374
391
  expect(code).toBe(0)
375
392
  expect(rules).not.toContain('ai-sdk-major')
376
393
  })
377
394
 
378
395
  it('does NOT flag an unrelated import even with a skewed nearest package.json', () => {
379
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
396
+ writeFileSync(
397
+ resolve(dir, 'package.json'),
398
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '5.0.196' } }),
399
+ )
380
400
  const { code, rules } = run(`import { useState } from 'react'\n`, 'lib.ts')
381
401
  expect(code).toBe(0)
382
402
  expect(rules).not.toContain('ai-sdk-major')
@@ -385,14 +405,20 @@ describe('basalt/ai-sdk-major', () => {
385
405
  // The matcher covers TWO forms (source === 'ai' OR source.startsWith('@ai-sdk/')) — every
386
406
  // fixture above only exercises the bare 'ai' specifier; this pins the scoped-package half.
387
407
  it("flags a scoped '@ai-sdk/*' import when the nearest package.json declares a different ai major", () => {
388
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
408
+ writeFileSync(
409
+ resolve(dir, 'package.json'),
410
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '5.0.196' } }),
411
+ )
389
412
  const { code, rules } = run(`import { openai } from '@ai-sdk/openai'\n`, 'lib.ts')
390
413
  expect(code).toBe(1)
391
414
  expect(rules).toContain('ai-sdk-major')
392
415
  })
393
416
 
394
417
  it("does NOT flag a scoped '@ai-sdk/*' import when the nearest package.json declares the same major", () => {
395
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '^7.0.18' } }))
418
+ writeFileSync(
419
+ resolve(dir, 'package.json'),
420
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '^7.0.18' } }),
421
+ )
396
422
  const { code, rules } = run(`import { openai } from '@ai-sdk/openai'\n`, 'lib.ts')
397
423
  expect(code).toBe(0)
398
424
  expect(rules).not.toContain('ai-sdk-major')
@@ -402,7 +428,10 @@ describe('basalt/ai-sdk-major', () => {
402
428
  // token as its two agent-chat siblings above — not `theme-allow`. A skewed producer/consumer
403
429
  // import that is genuinely intentional (see doctor's `aiMajorSkewReason`) can mark the line.
404
430
  it('does NOT flag an import marked basalt-agent-allow, even with a skewed nearest package.json', () => {
405
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
431
+ writeFileSync(
432
+ resolve(dir, 'package.json'),
433
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '5.0.196' } }),
434
+ )
406
435
  const { code, rules } = run(
407
436
  `// basalt-agent-allow\nimport { streamText } from 'ai'\n`,
408
437
  'lib.ts',
@@ -412,11 +441,67 @@ describe('basalt/ai-sdk-major', () => {
412
441
  })
413
442
 
414
443
  it('STILL flags an import marked only theme-allow (wrong escape token)', () => {
415
- writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
444
+ writeFileSync(
445
+ resolve(dir, 'package.json'),
446
+ JSON.stringify({ dependencies: { 'basalt-ui': '^1.19.1', ai: '5.0.196' } }),
447
+ )
416
448
  const { code, rules } = run(`// theme-allow\nimport { streamText } from 'ai'\n`, 'lib.ts')
417
449
  expect(code).toBe(1)
418
450
  expect(rules).toContain('ai-sdk-major')
419
451
  })
452
+
453
+ // The scoping fix: a workspace package with no basalt-ui dependency is not a basalt consumer, so
454
+ // its `ai` pin is none of this rule's business. rb's `apps/api` took 3 errors for exactly this,
455
+ // while `doctor` — which scopes the same concern by workspace — reported green.
456
+ it('does NOT flag a package that does not depend on basalt-ui at all', () => {
457
+ writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ dependencies: { ai: '5.0.196' } }))
458
+ const { code, rules } = run(`import { streamText } from 'ai'\n`, 'lib.ts')
459
+ expect(code).toBe(0)
460
+ expect(rules).not.toContain('ai-sdk-major')
461
+ })
462
+
463
+ it('does NOT flag a file outside a declared basalt.roots', () => {
464
+ writeFileSync(
465
+ resolve(dir, 'package.json'),
466
+ JSON.stringify({ basalt: { roots: ['web/src'] }, dependencies: { ai: '5.0.196' } }),
467
+ )
468
+ const { code, rules } = run(`import { streamText } from 'ai'\n`, 'api/src/llm.ts')
469
+ expect(code).toBe(0)
470
+ expect(rules).not.toContain('ai-sdk-major')
471
+ })
472
+
473
+ // The ORDERING half of the scoping fix, and the layout `init` produces for a monorepo: one
474
+ // manifest carrying BOTH the workspace-root CLI devDependency and `basalt.roots` naming the app.
475
+ // Dep-first returned in-scope on that root before `roots` was ever read, so rb's `apps/api` — no
476
+ // basalt dependency, deliberately on a different `ai` major, explicitly outside the roots — kept
477
+ // taking 3 errors and kept its `.oxlintrc.json` override.
478
+ it('reads basalt.roots BEFORE the basalt-ui dependency in the same manifest', () => {
479
+ writeFileSync(
480
+ resolve(dir, 'package.json'),
481
+ JSON.stringify({
482
+ basalt: { roots: ['apps/web/src'] },
483
+ devDependencies: { 'basalt-ui': '^1.20.0' },
484
+ dependencies: { ai: '5.0.196' },
485
+ }),
486
+ )
487
+ const outside = run(`import { streamText } from 'ai'\n`, 'apps/api/src/llm.ts')
488
+ expect(outside.code).toBe(0)
489
+ expect(outside.rules).not.toContain('ai-sdk-major')
490
+
491
+ // …and the declared root itself is still in scope, so the reorder narrows nothing it shouldn't.
492
+ const inside = run(`import { streamText } from 'ai'\n`, 'apps/web/src/llm.ts')
493
+ expect(inside.rules).toContain('ai-sdk-major')
494
+ })
495
+
496
+ it('DOES flag a file inside a declared basalt.roots', () => {
497
+ writeFileSync(
498
+ resolve(dir, 'package.json'),
499
+ JSON.stringify({ basalt: { roots: ['web/src'] }, dependencies: { ai: '5.0.196' } }),
500
+ )
501
+ const { code, rules } = run(`import { streamText } from 'ai'\n`, 'web/src/llm.ts')
502
+ expect(code).toBe(1)
503
+ expect(rules).toContain('ai-sdk-major')
504
+ })
420
505
  })
421
506
 
422
507
  // ── raw-scroll-container — promotion (off → warn → error shipped) ───────────
@@ -500,11 +585,13 @@ describe('basalt/hand-rolled-plot', () => {
500
585
  expect(rules).not.toContain('hand-rolled-plot')
501
586
  })
502
587
 
503
- it('reports ONCE per file — declaring the exception is a file-level decision', () => {
588
+ // `theme-allow-file` is the written declaration about the file, and it covers every assembly node
589
+ // in it — the DualPanel shape.
590
+ it('a theme-allow-file declaration covers the whole file', () => {
504
591
  const { code, rules } = run(
505
592
  `${CHARTS_IMPORT}export const C = () => (
506
593
  <svg>
507
- {/* theme-allow: multi-pane shape, not a single cartesian plot */}
594
+ {/* theme-allow-file basalt/hand-rolled-plot: multi-pane shape, not a single cartesian plot */}
508
595
  <AxisLeftNumeric scale={a} />
509
596
  <HoverOverlay width={1} height={1} />
510
597
  <Crosshair x={0} top={0} bottom={1} />
@@ -515,6 +602,54 @@ describe('basalt/hand-rolled-plot', () => {
515
602
  expect(rules).not.toContain('hand-rolled-plot')
516
603
  })
517
604
 
605
+ // The other half of the 1.20.0 fix. Naming the rule AND giving a reason used to promote the
606
+ // waiver to the whole file, which made whole-file the ONLY expressible scope: the shape the rule's
607
+ // own message asks for was the shape that granted immunity. It is now what it reads as — one node.
608
+ it('a named-with-a-reason theme-allow waives its own node ONLY, not the file', () => {
609
+ const { code, rules } = run(
610
+ `${CHARTS_IMPORT}export const C = () => (
611
+ <svg>
612
+ {/* theme-allow basalt/hand-rolled-plot — this one axis is drawn against a second scale */}
613
+ <AxisLeftNumeric scale={a} />
614
+ <HoverOverlay width={1} height={1} />
615
+ </svg>
616
+ )\n`,
617
+ )
618
+ expect(code).toBe(1)
619
+ expect(rules).toContain('hand-rolled-plot')
620
+ })
621
+
622
+ // The linewatch defect: a BARE comment used to buy the whole file permanent immunity, because
623
+ // only the first node was reported and the waiver was tested there. It now waives its own node
624
+ // and nothing else, so everything added to the file afterwards is still reported.
625
+ it('a bare theme-allow waives only the node it sits above, not the file', () => {
626
+ const { code, rules } = run(
627
+ `${CHARTS_IMPORT}export const C = () => (
628
+ <svg>
629
+ {/* theme-allow */}
630
+ <AxisLeftNumeric scale={a} />
631
+ <HoverOverlay width={1} height={1} />
632
+ </svg>
633
+ )\n`,
634
+ )
635
+ expect(code).toBe(1)
636
+ expect(rules).toContain('hand-rolled-plot')
637
+ })
638
+
639
+ // A comment written about something else must not double as a licence to hand-assemble a plot.
640
+ it('a theme-allow scoped to a DIFFERENT rule does not waive this one', () => {
641
+ const { code, rules } = run(
642
+ `${CHARTS_IMPORT}export const C = () => (
643
+ <svg>
644
+ {/* theme-allow raw-hex — the swatch below is a measured pixel */}
645
+ <AxisLeftNumeric scale={a} />
646
+ </svg>
647
+ )\n`,
648
+ )
649
+ expect(code).toBe(1)
650
+ expect(rules).toContain('hand-rolled-plot')
651
+ })
652
+
518
653
  it('does NOT flag a same-named component that is NOT a basalt chart primitive', () => {
519
654
  const { code, rules } = run(
520
655
  `import { Crosshair } from './my-own-widgets'
@@ -524,25 +659,152 @@ export const C = () => <svg><Crosshair x={0} /></svg>\n`,
524
659
  expect(rules).not.toContain('hand-rolled-plot')
525
660
  })
526
661
 
662
+ // `code` is 1 in both of these and that is correct: a CONSUMER file declaring `CartesianChart`
663
+ // shadows a basalt export, which `shadow-basalt-export` now sees since it reads the charts barrel
664
+ // too. The assertion under test is that `hand-rolled-plot` stays silent — a rule saying "compose
665
+ // X" cannot fire inside X.
527
666
  it('does NOT flag the module that DEFINES CartesianChart (function form)', () => {
528
- const { code, rules } = run(
667
+ const { rules } = run(
529
668
  `import { AxisBottomDate } from '../primitives/Axes'
530
669
  export function CartesianChart() {
531
670
  return <svg><AxisBottomDate scale={s} top={0} tickValues={[]} /></svg>
532
671
  }\n`,
533
672
  )
534
- expect(code).toBe(0)
535
673
  expect(rules).not.toContain('hand-rolled-plot')
536
674
  })
537
675
 
538
676
  it('does NOT flag the module that DEFINES CartesianChart (const form)', () => {
539
- const { code, rules } = run(
677
+ const { rules } = run(
540
678
  `import { AxisBottomDate } from '../primitives/Axes'
541
679
  export const CartesianChart = () => <svg><AxisBottomDate scale={s} top={0} tickValues={[]} /></svg>\n`,
680
+ )
681
+ expect(rules).not.toContain('hand-rolled-plot')
682
+ })
683
+ })
684
+
685
+ // ── theme-allow grammar — the accepted and rejected shapes, exhaustively ─────
686
+ //
687
+ // Two rounds produced three holes in this one contract, so it gets a matrix rather than a case.
688
+ // `hand-rolled-plot` is the vehicle: it is the only rule with BOTH scopes, so one fixture shape
689
+ // exercises the node waiver, the file declaration and the prefix rule at once.
690
+
691
+ describe('theme-allow grammar', () => {
692
+ /** Two assembly nodes, with `annotation` placed as a comment-only line above the first. */
693
+ function twoNodes(annotation: string): { code: number; rules: Set<string> } {
694
+ return run(
695
+ `${CHARTS_IMPORT}export const C = () => (
696
+ <svg>
697
+ ${annotation}
698
+ <AxisLeftNumeric scale={a} />
699
+ <HoverOverlay width={1} height={1} />
700
+ </svg>
701
+ )\n`,
702
+ )
703
+ }
704
+
705
+ const waivesFirstNodeOnly = (annotation: string) => {
706
+ const { rules } = twoNodes(annotation)
707
+ expect([annotation, rules.has('hand-rolled-plot')]).toEqual([annotation, true])
708
+ }
709
+ const waivesWholeFile = (annotation: string) => {
710
+ const { rules } = twoNodes(annotation)
711
+ expect([annotation, rules.has('hand-rolled-plot')]).toEqual([annotation, false])
712
+ }
713
+
714
+ // ── ACCEPTED as a NODE waiver (the second node still reports) ──────────────
715
+
716
+ it.each([
717
+ '{/* theme-allow */}',
718
+ '{/* theme-allow hand-rolled-plot */}',
719
+ '{/* theme-allow hand-rolled-plot — a reason */}',
720
+ '{/* theme-allow basalt/hand-rolled-plot: a reason */}',
721
+ '{/* theme-allow hand-rolled-plot, raw-hex — two ids */}',
722
+ '{/*theme-allow hand-rolled-plot — no space after the opener*/}',
723
+ ])('%s waives its own node and leaves the rest policed', waivesFirstNodeOnly)
724
+
725
+ // ── ACCEPTED as a FILE declaration (nothing in the file reports) ───────────
726
+
727
+ it.each([
728
+ '{/* theme-allow-file hand-rolled-plot — a reason */}',
729
+ '{/* theme-allow-file basalt/hand-rolled-plot: a reason */}',
730
+ '{/* theme-allow-file hand-rolled-plot */}',
731
+ '{/* theme-allow-file hand-rolled-plot, chart-legend-literal — two ids */}',
732
+ ])('%s declares the whole file', waivesWholeFile)
733
+
734
+ it('a theme-allow-file inside a docblock, detached from any node, declares the file', () => {
735
+ const { code, rules } = run(
736
+ `${CHARTS_IMPORT}/**
737
+ * A two-pane shape.
738
+ *
739
+ * theme-allow-file hand-rolled-plot — two panes over one x scale is not a single plot
740
+ */
741
+ export const C = () => (
742
+ <svg>
743
+ <AxisLeftNumeric scale={a} />
744
+ <HoverOverlay width={1} height={1} />
745
+ </svg>
746
+ )\n`,
542
747
  )
543
748
  expect(code).toBe(0)
544
749
  expect(rules).not.toContain('hand-rolled-plot')
545
750
  })
751
+
752
+ // ── REJECTED — prose that merely MENTIONS the token (the linewatch defect) ─
753
+
754
+ // The whole class: a file documenting its own waivers used to disarm itself. Every one of these
755
+ // parsed as the bare blanket form under the old `indexOf('theme-allow')` and silenced the node
756
+ // below it. All four are shapes taken from real consumer source.
757
+ it.each([
758
+ '{/* Since 1.20.0 a `theme-allow` that names a rule is a file declaration */}',
759
+ '{/* see the theme-allow contract for how this file is waived */}',
760
+ '{/* `theme-allow` with a reason declares the whole FILE wherever it is written */}',
761
+ '{/* each with a theme-allow saying why */}',
762
+ ])('%s is prose, not an annotation — it waives nothing', (annotation) => {
763
+ const { code, rules } = twoNodes(annotation)
764
+ expect([annotation, code]).toEqual([annotation, 1])
765
+ expect([annotation, rules.has('hand-rolled-plot')]).toEqual([annotation, true])
766
+ })
767
+
768
+ it('a prose docblock line mentioning theme-allow-file does not declare the file', () => {
769
+ const { rules } = twoNodes(
770
+ '{/* the shape to write here is a theme-allow-file hand-rolled-plot — see the docs */}',
771
+ )
772
+ expect(rules).toContain('hand-rolled-plot')
773
+ })
774
+
775
+ // ── REJECTED — a longer word that merely STARTS with the token ─────────────
776
+
777
+ // `theme-allow-unscoped` is a KIND NAME, written in prose constantly. It used to consume no id,
778
+ // fall through to the empty-`rules` branch, and read as a blanket waiver.
779
+ it('a comment starting with the kind name `theme-allow-unscoped` is not an annotation', () => {
780
+ const { rules } = twoNodes('{/* theme-allow-unscoped is what reports a bare waiver */}')
781
+ expect(rules).toContain('hand-rolled-plot')
782
+ })
783
+
784
+ // ── REJECTED — a bare file declaration, and a typo'd id ────────────────────
785
+
786
+ it('a BARE theme-allow-file waives nothing — file scope must name its ids', () => {
787
+ const { code, rules } = twoNodes('{/* theme-allow-file — everything in here is bespoke */}')
788
+ expect(code).toBe(1)
789
+ expect(rules).toContain('hand-rolled-plot')
790
+ })
791
+
792
+ it("a theme-allow-file naming a typo'd id waives nothing", () => {
793
+ const { rules } = twoNodes('{/* theme-allow-file hand-rolled-plott — a reason */}')
794
+ expect(rules).toContain('hand-rolled-plot')
795
+ })
796
+
797
+ it('a theme-allow-file scoped to a DIFFERENT rule does not waive this one', () => {
798
+ const { rules } = twoNodes('{/* theme-allow-file raw-hex — the swatch is a measured pixel */}')
799
+ expect(rules).toContain('hand-rolled-plot')
800
+ })
801
+
802
+ // ── REJECTED — the token inside a string literal, not a comment ────────────
803
+
804
+ it('the token inside a string literal is code, not an annotation', () => {
805
+ const { rules } = twoNodes("{'theme-allow hand-rolled-plot — a reason'}")
806
+ expect(rules).toContain('hand-rolled-plot')
807
+ })
546
808
  })
547
809
 
548
810
  // ── chart-legend-literal ─────────────────────────────────────────────────────
@@ -583,3 +845,320 @@ describe('basalt/chart-legend-literal', () => {
583
845
  expect(rules).not.toContain('chart-legend-literal')
584
846
  })
585
847
  })
848
+
849
+ // ── shadow-basalt-export ─────────────────────────────────────────────────────
850
+
851
+ describe('basalt/shadow-basalt-export', () => {
852
+ it('flags a local component that collides with a basalt-ui root export', () => {
853
+ const { code, rules } = run(`export function EmptyState() { return null }\n`)
854
+ expect(code).toBe(1)
855
+ expect(rules).toContain('shadow-basalt-export')
856
+ })
857
+
858
+ it('flags the arrow/const form too (rb shipped a local StatCard)', () => {
859
+ const { code, rules } = run(`const StatCard = () => null\nexport default StatCard\n`)
860
+ expect(code).toBe(1)
861
+ expect(rules).toContain('shadow-basalt-export')
862
+ })
863
+
864
+ it('does NOT flag a name basalt does not export', () => {
865
+ const { code, rules } = run(`export function ExerciseSummaryCard() { return null }\n`)
866
+ expect(code).toBe(0)
867
+ expect(rules).not.toContain('shadow-basalt-export')
868
+ })
869
+
870
+ // The rule read ONLY the root barrel, and `./charts` is deliberately not re-exported from it —
871
+ // so a consumer whose forks all live in the chart layer, which is the layer forks actually live
872
+ // in, could never trip it. Correctly silent, for the wrong reason.
873
+ it('sees the charts layer, not just the root barrel', () => {
874
+ const { code, rules } = run(`export function ChartCard() { return null }\n`)
875
+ expect(code).toBe(1)
876
+ expect(rules).toContain('shadow-basalt-export')
877
+ })
878
+
879
+ it('sees the content layer too', () => {
880
+ const { rules } = run(`export function ReadingProgress() { return null }\n`)
881
+ expect(rules).toContain('shadow-basalt-export')
882
+ })
883
+
884
+ // …but a barrel is not only basalt's own names. `./charts` re-exports `Bar`, `Line`, `Pie`,
885
+ // `AreaClosed` … straight from `@visx/shape`, and a local `Bar` is not a fork of anything basalt
886
+ // wrote. The playground's own demo tripped on it the moment the rule started reading that barrel.
887
+ it('does NOT flag a name the barrel merely re-exports from a third party', () => {
888
+ const { code, rules } = run(`export function Bar() { return null }\n`)
889
+ expect(code).toBe(0)
890
+ expect(rules).not.toContain('shadow-basalt-export')
891
+ })
892
+
893
+ // A NAME collision is the whole of the signal, so renaming defeats the rule in one keystroke —
894
+ // linewatch's forks are `Cell` and `Box`, rb's is `Stat`, and all three are invisible here. That
895
+ // limit cannot be closed without intent analysis, so the rule STATES it rather than implying
896
+ // coverage it does not have.
897
+ it('a renamed fork is invisible, and the message says so rather than implying coverage', () => {
898
+ expect(run(`export function Stat() { return null }\n`).rules).not.toContain(
899
+ 'shadow-basalt-export',
900
+ )
901
+ const { output } = run(`export function EmptyState() { return null }\n`)
902
+ expect(output).toContain('tripwire, not coverage')
903
+ expect(output).toContain('Silence here is not evidence')
904
+ })
905
+
906
+ // The export list is derived from the real barrel, so a TYPE export must not create a collision:
907
+ // a local `StatCardProps` is an ordinary thing for a consumer to declare.
908
+ it('does NOT flag a name that basalt exports only as a type', () => {
909
+ const { code, rules } = run(`export type StatCardProps = { label: string }\n`, 'types.ts')
910
+ expect(code).toBe(0)
911
+ expect(rules).not.toContain('shadow-basalt-export')
912
+ })
913
+
914
+ it('is waivable with a scoped theme-allow', () => {
915
+ const { code, rules } = run(
916
+ `// theme-allow basalt/shadow-basalt-export — domain-specific, unrelated to the shipped one\nexport function EmptyState() { return null }\n`,
917
+ )
918
+ expect(code).toBe(0)
919
+ expect(rules).not.toContain('shadow-basalt-export')
920
+ })
921
+ })
922
+
923
+ // ── hand-rolled-shell ────────────────────────────────────────────────────────
924
+
925
+ describe('basalt/hand-rolled-shell', () => {
926
+ it('flags a hand-assembled Mantine AppShell', () => {
927
+ const { code, rules } = run(
928
+ `import { AppShell, Burger } from '@mantine/core'
929
+ export const Shell = () => (
930
+ <AppShell>
931
+ <AppShell.Header><Burger opened={false} /></AppShell.Header>
932
+ <AppShell.Navbar>nav</AppShell.Navbar>
933
+ </AppShell>
934
+ )\n`,
935
+ )
936
+ expect(code).toBe(1)
937
+ expect(rules).toContain('hand-rolled-shell')
938
+ })
939
+
940
+ it('does NOT flag a file that renders BasaltShell', () => {
941
+ const { code, rules } = run(
942
+ `import { AppShell } from '@mantine/core'
943
+ import { BasaltShell } from 'basalt-ui'
944
+ export const Shell = () => <BasaltShell sections={[]}><AppShell.Main>x</AppShell.Main></BasaltShell>\n`,
945
+ )
946
+ expect(code).toBe(0)
947
+ expect(rules).not.toContain('hand-rolled-shell')
948
+ })
949
+
950
+ // Exempt by DECLARATION, not by path — a rule saying "compose X" cannot fire inside X. (The
951
+ // fixture also trips `shadow-basalt-export`, which is correct for a consumer file and irrelevant
952
+ // here, so this asserts the rule set rather than the exit code.)
953
+ it('does NOT flag the module that DEFINES BasaltShell', () => {
954
+ const { rules } = run(
955
+ `import { AppShell } from '@mantine/core'
956
+ export function BasaltShell() { return <AppShell.Navbar>nav</AppShell.Navbar> }\n`,
957
+ )
958
+ expect(rules).not.toContain('hand-rolled-shell')
959
+ })
960
+
961
+ it("does NOT flag a consumer's own AppShell-named component with no @mantine import", () => {
962
+ const { code, rules } = run(
963
+ `import { AppShell } from './my-layout'
964
+ export const C = () => <AppShell.Navbar>nav</AppShell.Navbar>\n`,
965
+ )
966
+ expect(code).toBe(0)
967
+ expect(rules).not.toContain('hand-rolled-shell')
968
+ })
969
+
970
+ it('does NOT flag a standalone Mantine NavLink (deliberately not a trigger)', () => {
971
+ const { code, rules } = run(
972
+ `import { NavLink } from '@mantine/core'
973
+ export const C = () => <NavLink label="Settings" />\n`,
974
+ )
975
+ expect(code).toBe(0)
976
+ expect(rules).not.toContain('hand-rolled-shell')
977
+ })
978
+ })
979
+
980
+ // ── grace ledger ↔ shipped preset ────────────────────────────────────────────
981
+
982
+ describe('PLUGIN_RULE_GRACE', () => {
983
+ const shipped = JSON.parse(readFileSync(resolve(import.meta.dirname, 'oxlint.json'), 'utf8')) as {
984
+ rules: Record<string, unknown>
985
+ }
986
+ const graceIds = Object.keys(PLUGIN_RULE_GRACE)
987
+ const shippedBasaltIds = Object.keys(shipped.rules).filter((id) => id.startsWith('basalt/'))
988
+
989
+ // The mechanism whose ABSENCE let three rules sit at `warn` for up to twelve minors with nothing
990
+ // tracking them. Deleting a ledger entry IS the promotion, and this test makes flipping the
991
+ // shipped level part of the same commit.
992
+ it('every ledger entry is warn in the shipped preset', () => {
993
+ for (const id of graceIds) expect(shipped.rules[`basalt/${id}`]).toBe('warn')
994
+ })
995
+
996
+ it('every shipped rule NOT in the ledger is error', () => {
997
+ for (const id of shippedBasaltIds) {
998
+ if (graceIds.includes(id.slice('basalt/'.length))) continue
999
+ expect([id, shipped.rules[id]]).toEqual([id, 'error'])
1000
+ }
1001
+ })
1002
+
1003
+ it('every ledger entry carries a written promotion note', () => {
1004
+ for (const id of graceIds) {
1005
+ expect((PLUGIN_RULE_GRACE as Record<string, string>)[id]?.length ?? 0).toBeGreaterThan(40)
1006
+ }
1007
+ })
1008
+ })
1009
+
1010
+ // ── chart-legend-literal: the .map() half ────────────────────────────────────
1011
+
1012
+ describe('basalt/chart-legend-literal — derived-from-series', () => {
1013
+ const IMPORT = `import { ChartLegend } from 'basalt-ui/charts'\n`
1014
+
1015
+ // Deriving from *an* array is not deriving from *the* series: every field of a
1016
+ // `refLines.map(...)` / `PACE_ZONES.map(...)` legend is authored at the call site, and the list
1017
+ // can keep naming a band the plot no longer draws exactly like a `[...]` literal can.
1018
+ it('flags a .map() over a non-series array', () => {
1019
+ const { code, rules } = run(
1020
+ `${IMPORT}export const C = () => <ChartLegend items={refLines.map((r) => ({ key: r.k, label: r.l }))} />\n`,
1021
+ )
1022
+ expect(code).toBe(1)
1023
+ expect(rules).toContain('chart-legend-literal')
1024
+ })
1025
+
1026
+ it('does NOT flag a .map() over the series', () => {
1027
+ const { code, rules } = run(
1028
+ `${IMPORT}export const C = () => <ChartLegend items={series.map((s) => ({ key: s.key }))} />\n`,
1029
+ )
1030
+ expect(code).toBe(0)
1031
+ expect(rules).not.toContain('chart-legend-literal')
1032
+ })
1033
+
1034
+ it('does NOT flag a .map() over a derived series binding', () => {
1035
+ const { code, rules } = run(
1036
+ `${IMPORT}export const C = () => <ChartLegend items={visibleSeries.map((s) => ({ key: s.key }))} />\n`,
1037
+ )
1038
+ expect(code).toBe(0)
1039
+ expect(rules).not.toContain('chart-legend-literal')
1040
+ })
1041
+
1042
+ it('does NOT flag deriveLegend(series)', () => {
1043
+ const { code, rules } = run(
1044
+ `${IMPORT}export const C = () => <ChartLegend items={deriveLegend(series)} />\n`,
1045
+ )
1046
+ expect(code).toBe(0)
1047
+ expect(rules).not.toContain('chart-legend-literal')
1048
+ })
1049
+ })
1050
+
1051
+ // ── no-raw-font-size: style-context scoping ──────────────────────────────────
1052
+
1053
+ describe('basalt/no-raw-font-size — style context', () => {
1054
+ it('flags a fontSize inside a style attribute', () => {
1055
+ const { code, rules } = run(`export const C = () => <div style={{ fontSize: 11 }} />\n`)
1056
+ expect(code).toBe(1)
1057
+ expect(rules).toContain('no-raw-font-size')
1058
+ })
1059
+
1060
+ it('flags a fontSize inside a Mantine styles per-part object', () => {
1061
+ const { code, rules } = run(
1062
+ `export const C = () => <TextInput styles={{ input: { fontSize: 12 } }} />\n`,
1063
+ )
1064
+ expect(code).toBe(1)
1065
+ expect(rules).toContain('no-raw-font-size')
1066
+ })
1067
+
1068
+ it('flags a fontSize in a CSSProperties-annotated const', () => {
1069
+ const { code, rules } = run(
1070
+ `import type { CSSProperties } from 'react'\nconst rowLook: CSSProperties = { fontSize: 11 }\nexport { rowLook }\n`,
1071
+ 'look.ts',
1072
+ )
1073
+ expect(code).toBe(1)
1074
+ expect(rules).toContain('no-raw-font-size')
1075
+ })
1076
+
1077
+ // The obsidian false positive: a `fontSize` key in a plain data object, in a package with no
1078
+ // React and no Mantine, where "route it through VX.text.*" is advice about another domain.
1079
+ it('does NOT flag a fontSize key in a plain data object', () => {
1080
+ const { code, rules } = run(
1081
+ `export const iconizeData = { settings: { fontSize: 16 }, Inbox: 'LiInbox' }\n`,
1082
+ 'data.ts',
1083
+ )
1084
+ expect(code).toBe(0)
1085
+ expect(rules).not.toContain('no-raw-font-size')
1086
+ })
1087
+
1088
+ it('does NOT run in a test file at all', () => {
1089
+ const { code, rules } = run(
1090
+ `export const C = () => <div style={{ fontSize: 11 }} />\n`,
1091
+ 'nav-config.test.tsx',
1092
+ )
1093
+ expect(code).toBe(0)
1094
+ expect(rules).not.toContain('no-raw-font-size')
1095
+ })
1096
+ })
1097
+
1098
+ // ── KNOWN_RULE_IDS — the hand-maintained list a typo'd theme-allow is measured against ──────────
1099
+
1100
+ describe('KNOWN_RULE_IDS', () => {
1101
+ // Forgetting an entry is not cosmetic: an id this set does not know is treated as a typo, so the
1102
+ // annotation naming it waives nothing. Before the fail-closed fix it was worse — an unknown id
1103
+ // fell through to the empty-`rules` branch and became a BLANKET waiver, which is how a
1104
+ // hand-maintained duplicate turned a missing entry into a hole in the guard.
1105
+ it('contains every rule the plugin actually exports', () => {
1106
+ for (const id of Object.keys(basaltPlugin.rules)) {
1107
+ expect([id, KNOWN_RULE_IDS.has(id)]).toEqual([id, true])
1108
+ }
1109
+ })
1110
+
1111
+ it('contains every guard kind', () => {
1112
+ for (const kind of Object.keys(GUARD_RULES)) {
1113
+ expect([kind, KNOWN_RULE_IDS.has(kind)]).toEqual([kind, true])
1114
+ }
1115
+ })
1116
+
1117
+ it('contains nothing else — it is exactly the union of the two registries', () => {
1118
+ const expected = [...Object.keys(basaltPlugin.rules), ...Object.keys(GUARD_RULES)].toSorted()
1119
+ expect([...KNOWN_RULE_IDS].toSorted()).toEqual(expected)
1120
+ })
1121
+ })
1122
+
1123
+ // ── theme-allow fails closed on a typo ─────────────────────────────────────────────────────────
1124
+
1125
+ describe('theme-allow with an unrecognized rule id', () => {
1126
+ it('waives nothing rather than escalating to a blanket suppression', () => {
1127
+ // `no-raw-font-size` would be waived by a bare `theme-allow`. A typo'd id must not do that.
1128
+ const { code, rules } = run(
1129
+ `export const C = () => <Text fz={10} /> // theme-allow no-raw-font-sizee — legacy\n`,
1130
+ )
1131
+ expect(code).toBe(1)
1132
+ expect(rules).toContain('no-raw-font-size')
1133
+ })
1134
+
1135
+ it('a correctly spelled id still waives', () => {
1136
+ const { code, rules } = run(
1137
+ `export const C = () => <Text fz={10} /> // theme-allow no-raw-font-size — legacy\n`,
1138
+ )
1139
+ expect(rules).not.toContain('no-raw-font-size')
1140
+ expect(code).toBe(0)
1141
+ })
1142
+
1143
+ it('a bare theme-allow still waives everything', () => {
1144
+ const { rules } = run(`export const C = () => <Text fz={10} /> // theme-allow\n`)
1145
+ expect(rules).not.toContain('no-raw-font-size')
1146
+ })
1147
+
1148
+ it('does not resolve an Object.prototype key as a rule id', () => {
1149
+ const { code, rules } = run(
1150
+ `export const C = () => <Text fz={10} /> // theme-allow constructor — inherited key\n`,
1151
+ )
1152
+ expect(code).toBe(1)
1153
+ expect(rules).toContain('no-raw-font-size')
1154
+ })
1155
+
1156
+ // The id slot closes at the first space no comma opened — mirrors `parseAllowAnnotation`.
1157
+ it('reads an unknown word after a resolved id as the reason, not as a second id', () => {
1158
+ const { code, rules } = run(
1159
+ `export const C = () => <Text fz={10} /> // theme-allow no-raw-font-size sub-scale micro label\n`,
1160
+ )
1161
+ expect(rules).not.toContain('no-raw-font-size')
1162
+ expect(code).toBe(0)
1163
+ })
1164
+ })