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.
- package/AGENTS.md +19 -1
- package/CHANGELOG.md +39 -0
- package/MIGRATING.md +326 -0
- package/README.md +87 -9
- package/agent/rules/basalt-app.md +84 -0
- package/agent/rules/basalt-charts.md +32 -17
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-mantine.md +25 -10
- package/agent/rules/basalt-router.md +79 -15
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +132 -6
- package/agent/skills/basalt-app/SKILL.md +45 -6
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/check.yml +11 -4
- package/configs/lefthook.yml +59 -7
- package/configs/oxlint-plugin.js +633 -30
- package/configs/oxlint-plugin.test.ts +594 -15
- package/configs/oxlint.json +10 -1
- package/dist/agent/stick-to-bottom.js +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.js +2 -2
- package/dist/agent-chat/tool-chip.js +1 -1
- package/dist/charts/kinds/Bars.d.ts +1 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.d.ts +1 -1
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +1 -1
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.d.ts +1 -1
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.d.ts +1 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.d.ts +1 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +2 -2
- package/dist/charts/primitives/ChartTooltip.js +1 -1
- package/dist/cli/index.d.ts +146 -20
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +901 -101
- package/dist/cli/toolchain-wiring.test.d.ts +2 -0
- package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/dashboard/delta-badge.js +2 -2
- package/dist/dashboard/stat-card.js +1 -1
- package/dist/data/virtual-list.js +5 -5
- package/dist/guard/index.d.ts +101 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +474 -62
- package/dist/guard/types.d.ts +25 -5
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/register.d.ts +25 -0
- package/dist/register.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +4 -1
- package/dist/router-tanstack/search-param-store.d.ts +29 -5
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +31 -3
- package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
- package/dist/shell/app-mobile-nav.module.css +11 -3
- package/dist/shell/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +24 -6
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/tokens/index.d.ts +5 -3
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +7 -1
- package/dist/tokens/palette.d.ts +35 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +24 -18
- package/dist/tokens/text-scale.test.d.ts +2 -0
- package/dist/tokens/text-scale.test.d.ts.map +1 -0
- package/dist/tokens.css +21 -17
- package/dist/vite-color-mix.js +2 -2
- package/dist/vite.d.ts +24 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +54 -13
- package/llms.txt +3 -3
- package/package.json +4 -3
- package/src/agent/stick-to-bottom.tsx +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +1 -1
- package/src/agent-chat/thread-message.tsx +3 -3
- package/src/agent-chat/tool-chip.tsx +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +3 -2
- package/src/charts/kinds/DualPanel.tsx +2 -2
- package/src/charts/kinds/Heatmap.tsx +1 -1
- package/src/charts/kinds/MultiLine.tsx +1 -1
- package/src/charts/kinds/StackedArea.tsx +1 -1
- package/src/charts/kinds/ZonedLine.tsx +1 -1
- package/src/charts/primitives/ChartLegend.tsx +2 -2
- package/src/charts/primitives/ChartTooltip.tsx +1 -1
- package/src/cli/index.ts +1614 -164
- package/src/cli/init-sync.test.ts +21 -3
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/tokens-css.test.ts +27 -14
- package/src/cli/toolchain-wiring.test.ts +856 -0
- package/src/cli/waiver-audit.test.ts +131 -0
- package/src/dashboard/delta-badge.tsx +3 -3
- package/src/dashboard/stat-card.tsx +1 -1
- package/src/data/virtual-list.tsx +8 -8
- package/src/guard/check-source.test.ts +1104 -12
- package/src/guard/index.ts +1106 -103
- package/src/guard/types.ts +29 -4
- package/src/register.ts +32 -0
- package/src/router-tanstack/multi-search-param-store.ts +16 -1
- package/src/router-tanstack/search-param-store.test.ts +135 -0
- package/src/router-tanstack/search-param-store.ts +90 -8
- package/src/shell/app-mobile-nav.module.css +11 -3
- package/src/shell/app-mobile-nav.tsx +1 -1
- package/src/shell/index.test.tsx +52 -0
- package/src/shell/index.tsx +64 -13
- package/src/surfaces.ts +1 -1
- package/src/theme/density-relations.test.ts +8 -5
- package/src/theme/spacing.test.ts +2 -0
- package/src/theme-toggle/index.tsx +2 -2
- package/src/tokens/index.ts +28 -3
- package/src/tokens/palette.ts +24 -18
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite-color-mix.ts +2 -2
- package/src/vite.test.ts +125 -6
- 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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
+
})
|