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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unit tests for checkSource — the pure (text, relPath, cfg) → Finding[] core.
|
|
3
3
|
*
|
|
4
|
-
* Covers all
|
|
4
|
+
* Covers all 25 guard kinds. Co-located with the guard, excluded from tsc
|
|
5
5
|
* (tsconfig exclude: src/**\/*.test.ts), run via `bun test`.
|
|
6
6
|
*
|
|
7
7
|
* The walker/reporter half is covered by the integration test in
|
|
@@ -10,8 +10,17 @@
|
|
|
10
10
|
import { describe, expect, it } from 'bun:test'
|
|
11
11
|
import { pxRem } from '../tokens'
|
|
12
12
|
import { SPACE_SCALE } from '../tokens/palette'
|
|
13
|
-
import {
|
|
14
|
-
|
|
13
|
+
import {
|
|
14
|
+
checkSource,
|
|
15
|
+
DEFAULT_GUARD_CONFIG,
|
|
16
|
+
GENERATED_HEADER_LINE,
|
|
17
|
+
guardKindRemedy,
|
|
18
|
+
guardWaiverHint,
|
|
19
|
+
GUARD_RULES,
|
|
20
|
+
TOKENS_ONLY_DISABLED_KINDS,
|
|
21
|
+
unmatchedExemptPatterns,
|
|
22
|
+
} from './index'
|
|
23
|
+
import type { Finding, GuardConfig, GuardKind } from './types'
|
|
15
24
|
|
|
16
25
|
const PATH = 'src/Dashboard.tsx'
|
|
17
26
|
const CHART_PATH = 'src/charts/kinds/Foo.tsx'
|
|
@@ -1004,14 +1013,19 @@ describe('raw-font-family', () => {
|
|
|
1004
1013
|
expect(kinds(f)).toContain('raw-font-family')
|
|
1005
1014
|
})
|
|
1006
1015
|
|
|
1007
|
-
|
|
1016
|
+
// A var() REFERENCE is not a literal, whichever custom property it names. The escape used to be
|
|
1017
|
+
// restricted to `--basalt-font-*` / `--mantine-font-family-*`, which reported a framework-free
|
|
1018
|
+
// consumer's `font-family: var(--font-sans)` as "a hardcoded fontFamily literal" and pointed the
|
|
1019
|
+
// fix at the React-only `createBasaltTheme`. The single-entry-point invariant is doctrine for
|
|
1020
|
+
// basalt's own theme layer; this regex cannot tell it apart from a consumer's own indirection.
|
|
1021
|
+
it('does NOT flag a fontFamily bound to a var(...) reference outside basalt’s own prefixes', () => {
|
|
1008
1022
|
const f = find(`fontFamily: 'var(--some-other-var)'`)
|
|
1009
|
-
expect(kinds(f)).toContain('raw-font-family')
|
|
1023
|
+
expect(kinds(f)).not.toContain('raw-font-family')
|
|
1010
1024
|
})
|
|
1011
1025
|
|
|
1012
|
-
it('
|
|
1013
|
-
const f = find(`font-family: var(--
|
|
1014
|
-
expect(kinds(f)).toContain('raw-font-family')
|
|
1026
|
+
it('does NOT flag a kebab-case font-family bound to a consumer var(...) reference', () => {
|
|
1027
|
+
const f = find(`font-family: var(--font-sans);`)
|
|
1028
|
+
expect(kinds(f)).not.toContain('raw-font-family')
|
|
1015
1029
|
})
|
|
1016
1030
|
|
|
1017
1031
|
it('does NOT flag a kebab-case font-family bound to var(--mantine-font-family-…)', () => {
|
|
@@ -1575,6 +1589,99 @@ describe('exemptRules', () => {
|
|
|
1575
1589
|
expect(kinds(f)).toContain('inline-display')
|
|
1576
1590
|
})
|
|
1577
1591
|
|
|
1592
|
+
// ── Relative paths and globs ────────────────────────────────────────────────
|
|
1593
|
+
//
|
|
1594
|
+
// Only the bare-segment shape existed, and it is the one nobody guesses. rollhook wrote the
|
|
1595
|
+
// obvious, correct `"public/site.webmanifest"`, matched nothing, got no diagnostic, and kept
|
|
1596
|
+
// reporting — while `exemptRules` had just become the ONLY waiver route for a whole file class.
|
|
1597
|
+
|
|
1598
|
+
const HEX_MANIFEST = '{ "theme_color": "#27272a" }'
|
|
1599
|
+
const exemptedAt = (relPath: string, patterns: string[]): GuardKind[] =>
|
|
1600
|
+
kinds(
|
|
1601
|
+
checkSource(HEX_MANIFEST, relPath, {
|
|
1602
|
+
...DEFAULT_GUARD_CONFIG,
|
|
1603
|
+
exemptRules: { 'raw-hex': patterns },
|
|
1604
|
+
}),
|
|
1605
|
+
)
|
|
1606
|
+
|
|
1607
|
+
it('matches a real relative path to one file', () => {
|
|
1608
|
+
expect(exemptedAt('public/site.webmanifest', ['public/site.webmanifest'])).not.toContain(
|
|
1609
|
+
'raw-hex',
|
|
1610
|
+
)
|
|
1611
|
+
})
|
|
1612
|
+
|
|
1613
|
+
it('matches a relative directory prefix', () => {
|
|
1614
|
+
expect(exemptedAt('public/icons/theme.json', ['public/icons'])).not.toContain('raw-hex')
|
|
1615
|
+
})
|
|
1616
|
+
|
|
1617
|
+
it("normalizes a leading './' and a trailing '/'", () => {
|
|
1618
|
+
expect(exemptedAt('public/site.webmanifest', ['./public/'])).not.toContain('raw-hex')
|
|
1619
|
+
})
|
|
1620
|
+
|
|
1621
|
+
it('matches a glob, with `*` stopping at a slash', () => {
|
|
1622
|
+
expect(exemptedAt('public/site.webmanifest', ['public/*.webmanifest'])).not.toContain('raw-hex')
|
|
1623
|
+
expect(exemptedAt('public/nested/site.webmanifest', ['public/*.webmanifest'])).toContain(
|
|
1624
|
+
'raw-hex',
|
|
1625
|
+
)
|
|
1626
|
+
})
|
|
1627
|
+
|
|
1628
|
+
it('matches a `**` glob across slashes', () => {
|
|
1629
|
+
expect(exemptedAt('src/a/b/theme.json', ['src/**/theme.json'])).not.toContain('raw-hex')
|
|
1630
|
+
})
|
|
1631
|
+
|
|
1632
|
+
it('matches a slash-free glob against the BASENAME, like a bare segment does', () => {
|
|
1633
|
+
const f = checkSource(`.a { padding: 18px }`, 'src/features/Card.module.css', {
|
|
1634
|
+
...DEFAULT_GUARD_CONFIG,
|
|
1635
|
+
exemptRules: { 'inline-spacing': ['*.module.css'] },
|
|
1636
|
+
})
|
|
1637
|
+
expect(kinds(f)).not.toContain('inline-spacing')
|
|
1638
|
+
})
|
|
1639
|
+
|
|
1640
|
+
it('still fires at a path the path/glob does not match', () => {
|
|
1641
|
+
expect(exemptedAt('public/other.webmanifest', ['public/site.webmanifest'])).toContain('raw-hex')
|
|
1642
|
+
})
|
|
1643
|
+
|
|
1644
|
+
// The other half of the fix. An exemption is a CLAIM, and a claim that resolves to nothing reads
|
|
1645
|
+
// as coverage in a config review while enforcing exactly as much as an empty object.
|
|
1646
|
+
describe('unmatchedExemptPatterns', () => {
|
|
1647
|
+
const scanned = ['src/App.tsx', 'public/site.webmanifest']
|
|
1648
|
+
|
|
1649
|
+
it('reports a pattern that matches no scanned file', () => {
|
|
1650
|
+
const cfg = { ...DEFAULT_GUARD_CONFIG, exemptRules: { 'raw-hex': ['public/manifest.json'] } }
|
|
1651
|
+
expect(unmatchedExemptPatterns(cfg, scanned)).toEqual([
|
|
1652
|
+
{ kind: 'raw-hex', pattern: 'public/manifest.json', reason: 'no-match' },
|
|
1653
|
+
])
|
|
1654
|
+
})
|
|
1655
|
+
|
|
1656
|
+
it('says nothing about a pattern that DOES match', () => {
|
|
1657
|
+
const cfg = {
|
|
1658
|
+
...DEFAULT_GUARD_CONFIG,
|
|
1659
|
+
exemptRules: { 'raw-hex': ['public/site.webmanifest'] },
|
|
1660
|
+
}
|
|
1661
|
+
expect(unmatchedExemptPatterns(cfg, scanned)).toEqual([])
|
|
1662
|
+
})
|
|
1663
|
+
|
|
1664
|
+
it("reports a typo'd KIND — an exemption for a rule that does not exist", () => {
|
|
1665
|
+
const cfg = {
|
|
1666
|
+
...DEFAULT_GUARD_CONFIG,
|
|
1667
|
+
exemptRules: { 'raw-hexx': ['src'] } as unknown as GuardConfig['exemptRules'],
|
|
1668
|
+
}
|
|
1669
|
+
expect(unmatchedExemptPatterns(cfg, scanned)).toEqual([
|
|
1670
|
+
{ kind: 'raw-hexx', pattern: 'src', reason: 'unknown-kind' },
|
|
1671
|
+
])
|
|
1672
|
+
})
|
|
1673
|
+
|
|
1674
|
+
it('reports the exact shape rollhook wrote before the path matcher existed', () => {
|
|
1675
|
+
const cfg = { ...DEFAULT_GUARD_CONFIG, exemptRules: { 'raw-hex': ['site.webmanifest'] } }
|
|
1676
|
+
expect(unmatchedExemptPatterns(cfg, ['public/site.webmanifest'])).toEqual([])
|
|
1677
|
+
expect(unmatchedExemptPatterns(cfg, ['src/App.tsx'])).toHaveLength(1)
|
|
1678
|
+
})
|
|
1679
|
+
|
|
1680
|
+
it('is silent on an empty config', () => {
|
|
1681
|
+
expect(unmatchedExemptPatterns(DEFAULT_GUARD_CONFIG, scanned)).toEqual([])
|
|
1682
|
+
})
|
|
1683
|
+
})
|
|
1684
|
+
|
|
1578
1685
|
it('does NOT suppress a different kind on the same line/path', () => {
|
|
1579
1686
|
const f = checkSource(`const c = '#ff0000'; ${TEXT}`, 'src/agent/x.tsx', {
|
|
1580
1687
|
...DEFAULT_GUARD_CONFIG,
|
|
@@ -1599,6 +1706,59 @@ describe('exemptRules', () => {
|
|
|
1599
1706
|
expect(kinds(f)).toContain('inline-display')
|
|
1600
1707
|
expect(kinds(f)).toContain('raw-html-layout')
|
|
1601
1708
|
})
|
|
1709
|
+
|
|
1710
|
+
// ── EXEMPT_RULE_ALIASES: a grace-minor WIDENING inherits its parent's exemption ───────────────
|
|
1711
|
+
// A kind that exists only to widen an established one is the same rule to a consumer, so an
|
|
1712
|
+
// exemption already written for the parent has to cover it — otherwise the widening arrives as
|
|
1713
|
+
// noise in exactly the paths someone decided the rule does not apply to.
|
|
1714
|
+
|
|
1715
|
+
const HIDDEN =
|
|
1716
|
+
"const s = { display: 'flex', padding: 18 }\nexport const D = () => <div style={s} />"
|
|
1717
|
+
const SHADOW = 'const s = { boxShadow: `0 0 0 2px ${VX.accent}` }'
|
|
1718
|
+
const CSS_SURFACE = '.card { border-radius: 12px; }'
|
|
1719
|
+
|
|
1720
|
+
it("hidden-inline-style inherits raw-html-layout's exemption", () => {
|
|
1721
|
+
expect(kinds(find(HIDDEN, 'src/agent/x.tsx'))).toContain('hidden-inline-style')
|
|
1722
|
+
const f = checkSource(HIDDEN, 'src/agent/x.tsx', {
|
|
1723
|
+
...DEFAULT_GUARD_CONFIG,
|
|
1724
|
+
exemptRules: { 'raw-html-layout': ['agent'] },
|
|
1725
|
+
})
|
|
1726
|
+
expect(kinds(f)).not.toContain('hidden-inline-style')
|
|
1727
|
+
})
|
|
1728
|
+
|
|
1729
|
+
it("surface-shadow-override and css-raw-surface inherit raw-surface's exemption", () => {
|
|
1730
|
+
expect(kinds(find(SHADOW, 'src/agent/x.tsx'))).toContain('surface-shadow-override')
|
|
1731
|
+
expect(kinds(find(CSS_SURFACE, 'src/agent/x.css'))).toContain('css-raw-surface')
|
|
1732
|
+
const cfg = { ...DEFAULT_GUARD_CONFIG, exemptRules: { 'raw-surface': ['agent'] } }
|
|
1733
|
+
expect(kinds(checkSource(SHADOW, 'src/agent/x.tsx', cfg))).not.toContain(
|
|
1734
|
+
'surface-shadow-override',
|
|
1735
|
+
)
|
|
1736
|
+
expect(kinds(checkSource(CSS_SURFACE, 'src/agent/x.css', cfg))).not.toContain('css-raw-surface')
|
|
1737
|
+
})
|
|
1738
|
+
|
|
1739
|
+
it('the inheritance is one-way — exempting the CHILD leaves the parent enforced', () => {
|
|
1740
|
+
const f = checkSource(HIDDEN, 'src/agent/x.tsx', {
|
|
1741
|
+
...DEFAULT_GUARD_CONFIG,
|
|
1742
|
+
exemptRules: { 'hidden-inline-style': ['agent'] },
|
|
1743
|
+
})
|
|
1744
|
+
expect(kinds(f)).not.toContain('hidden-inline-style')
|
|
1745
|
+
expect(
|
|
1746
|
+
kinds(
|
|
1747
|
+
checkSource(TEXT, 'src/agent/x.tsx', {
|
|
1748
|
+
...DEFAULT_GUARD_CONFIG,
|
|
1749
|
+
exemptRules: { 'hidden-inline-style': ['agent'] },
|
|
1750
|
+
}),
|
|
1751
|
+
),
|
|
1752
|
+
).toContain('raw-html-layout')
|
|
1753
|
+
})
|
|
1754
|
+
|
|
1755
|
+
it('an alias does not exempt a path the parent exemption never named', () => {
|
|
1756
|
+
const f = checkSource(SHADOW, 'src/charts/x.tsx', {
|
|
1757
|
+
...DEFAULT_GUARD_CONFIG,
|
|
1758
|
+
exemptRules: { 'raw-surface': ['agent'] },
|
|
1759
|
+
})
|
|
1760
|
+
expect(kinds(f)).toContain('surface-shadow-override')
|
|
1761
|
+
})
|
|
1602
1762
|
})
|
|
1603
1763
|
|
|
1604
1764
|
// ── 20. severity ─────────────────────────────────────────────────────────────
|
|
@@ -1627,10 +1787,34 @@ describe('severity', () => {
|
|
|
1627
1787
|
expect(f.find((v) => v.kind === 'raw-spacing')?.severity).toBe('error')
|
|
1628
1788
|
})
|
|
1629
1789
|
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1790
|
+
// The grace ledger. Five kinds landed warn-only in the round-4 guard minor; deleting an entry
|
|
1791
|
+
// from GRACE_PERIOD_KINDS is the promotion, and it shows up here as an explicit diff.
|
|
1792
|
+
it.each([
|
|
1793
|
+
'theme-allow-unscoped',
|
|
1794
|
+
'surface-shadow-override',
|
|
1795
|
+
'css-raw-surface',
|
|
1796
|
+
'inline-font-size',
|
|
1797
|
+
'hidden-inline-style',
|
|
1798
|
+
])('%s is still in its grace minor (warn)', (kind) => {
|
|
1799
|
+
const cases: Record<string, [string, string]> = {
|
|
1800
|
+
'theme-allow-unscoped': ['const x = 1 // theme-allow', PATH],
|
|
1801
|
+
'surface-shadow-override': ['const s = { boxShadow: `0 0 0 2px ${VX.accent}` }', PATH],
|
|
1802
|
+
'css-raw-surface': ['.a { border-radius: 6px; }', 'src/a.module.css'],
|
|
1803
|
+
'inline-font-size': [`<div style={{ fontSize: 11 }} />`, PATH],
|
|
1804
|
+
'hidden-inline-style': [
|
|
1805
|
+
`const rowStyle = { display: 'flex', gap: 12 }\nexport const C = () => <div style={rowStyle} />`,
|
|
1806
|
+
PATH,
|
|
1807
|
+
],
|
|
1808
|
+
}
|
|
1809
|
+
const [src, path] = cases[kind] as [string, string]
|
|
1810
|
+
const hit = find(src, path).find((v) => v.kind === kind)
|
|
1811
|
+
expect(hit?.severity).toBe('warn')
|
|
1812
|
+
})
|
|
1813
|
+
|
|
1814
|
+
it('ships every OTHER kind past its grace period', () => {
|
|
1815
|
+
// Not a constraint, a LEDGER: when a new kind lands warn-only, this expectation changes in the
|
|
1816
|
+
// same commit, and changing it back is the promotion. A grace entry that outlives its minor
|
|
1817
|
+
// shows up here as an unexplained diff.
|
|
1634
1818
|
const cfg = DEFAULT_GUARD_CONFIG
|
|
1635
1819
|
const everyKind = checkSource(
|
|
1636
1820
|
`<Box p={${SPACE_SCALE.md}} radius={4} style={{ color: '#ff0000', gap: 3 }} />`,
|
|
@@ -1641,3 +1825,911 @@ describe('severity', () => {
|
|
|
1641
1825
|
expect(everyKind.every((v) => v.severity === 'error')).toBe(true)
|
|
1642
1826
|
})
|
|
1643
1827
|
})
|
|
1828
|
+
|
|
1829
|
+
// ── 21. theme-allow placement ────────────────────────────────────────────────
|
|
1830
|
+
|
|
1831
|
+
describe('theme-allow placement', () => {
|
|
1832
|
+
it('honours a standalone comment on the PRECEDING line (the JSX-legal form)', () => {
|
|
1833
|
+
const f = find(
|
|
1834
|
+
`export const C = () => (
|
|
1835
|
+
{/* theme-allow raw-hex — brand asset, fixed by the vendor */}
|
|
1836
|
+
<Box style={{ color: '#ff0000' }} />
|
|
1837
|
+
)`,
|
|
1838
|
+
)
|
|
1839
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
1840
|
+
})
|
|
1841
|
+
|
|
1842
|
+
// A TRAILING comment stays scoped to its own line: `const a = '#f00' // theme-allow` must not
|
|
1843
|
+
// silently waive the next line as well.
|
|
1844
|
+
it('does NOT let a trailing comment waive the line below it', () => {
|
|
1845
|
+
const f = find(`const a = '#ff0000' // theme-allow raw-hex — vendor brand\nconst b = '#00ff00'`)
|
|
1846
|
+
expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
|
|
1847
|
+
})
|
|
1848
|
+
|
|
1849
|
+
// rb's defect: the shipped oxfmt reflows a long declaration and lands the comment on a different
|
|
1850
|
+
// line than the value, so a same-line-only escape silently stops working. Verified against real
|
|
1851
|
+
// oxfmt output — the hex ends up ABOVE the comment, which is why preceding-line support alone
|
|
1852
|
+
// does not fix it and the walk goes backwards over the declaration.
|
|
1853
|
+
it('reaches back over an oxfmt-reflowed CSS declaration', () => {
|
|
1854
|
+
const f = find(
|
|
1855
|
+
`.root {
|
|
1856
|
+
background-color: var(
|
|
1857
|
+
--mantine-color-body,
|
|
1858
|
+
#232326
|
|
1859
|
+
); /* theme-allow raw-hex — Mantine body fallback for the pre-paint value */
|
|
1860
|
+
}`,
|
|
1861
|
+
'src/styles.css',
|
|
1862
|
+
)
|
|
1863
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
1864
|
+
})
|
|
1865
|
+
|
|
1866
|
+
it('stops the backward walk at the previous declaration', () => {
|
|
1867
|
+
const f = find(
|
|
1868
|
+
`.root {
|
|
1869
|
+
color: #112233;
|
|
1870
|
+
background: var(
|
|
1871
|
+
#232326
|
|
1872
|
+
); /* theme-allow raw-hex — fallback */
|
|
1873
|
+
}`,
|
|
1874
|
+
'src/styles.css',
|
|
1875
|
+
)
|
|
1876
|
+
expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
|
|
1877
|
+
expect(f.find((x) => x.kind === 'raw-hex')?.line).toBe(2)
|
|
1878
|
+
})
|
|
1879
|
+
})
|
|
1880
|
+
|
|
1881
|
+
// ── 22. theme-allow rule scoping + accountability ────────────────────────────
|
|
1882
|
+
|
|
1883
|
+
describe('theme-allow scoping', () => {
|
|
1884
|
+
it('a scoped annotation waives only the kind it names', () => {
|
|
1885
|
+
const f = find(
|
|
1886
|
+
`<Box p={${SPACE_SCALE.md}} style={{ color: '#ff0000' }} /> // theme-allow raw-hex — vendor brand`,
|
|
1887
|
+
)
|
|
1888
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
1889
|
+
expect(kinds(f)).toContain('raw-spacing')
|
|
1890
|
+
})
|
|
1891
|
+
|
|
1892
|
+
it('a bare annotation still waives everything (back-compat) but is reported', () => {
|
|
1893
|
+
const f = find(`<Box p={${SPACE_SCALE.md}} style={{ color: '#ff0000' }} /> // theme-allow`)
|
|
1894
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
1895
|
+
expect(kinds(f)).not.toContain('raw-spacing')
|
|
1896
|
+
expect(kinds(f)).toContain('theme-allow-unscoped')
|
|
1897
|
+
})
|
|
1898
|
+
|
|
1899
|
+
it('reports a reason-less scoped annotation too', () => {
|
|
1900
|
+
const f = find(`<Box style={{ color: '#ff0000' }} /> // theme-allow raw-hex`)
|
|
1901
|
+
expect(kinds(f)).toContain('theme-allow-unscoped')
|
|
1902
|
+
})
|
|
1903
|
+
|
|
1904
|
+
it('accepts the accountable form silently', () => {
|
|
1905
|
+
const f = find(
|
|
1906
|
+
`<Box style={{ color: '#ff0000' }} /> // theme-allow raw-hex — vendor brand asset`,
|
|
1907
|
+
)
|
|
1908
|
+
expect(f).toHaveLength(0)
|
|
1909
|
+
})
|
|
1910
|
+
|
|
1911
|
+
it('accepts a basalt/-prefixed plugin rule id as accountable', () => {
|
|
1912
|
+
const f = find(`const x = 1 // theme-allow basalt/hand-rolled-plot — multi-pane, not one plot`)
|
|
1913
|
+
expect(f).toHaveLength(0)
|
|
1914
|
+
})
|
|
1915
|
+
|
|
1916
|
+
// A prose reason is introduced with a separator — the shape every annotation in the wild
|
|
1917
|
+
// already has (`theme-allow: …`, `theme-allow — …`). That is what keeps prose out of the rule-id
|
|
1918
|
+
// slot without having to guess whether an unknown word is a reason or a typo.
|
|
1919
|
+
it('reads a separated reason as a reason, not as rule ids', () => {
|
|
1920
|
+
for (const annotation of ['theme-allow: legacy vendor asset', 'theme-allow — legacy vendor']) {
|
|
1921
|
+
const f = find(`<Box style={{ color: '#ff0000' }} /> // ${annotation}`)
|
|
1922
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
1923
|
+
}
|
|
1924
|
+
})
|
|
1925
|
+
|
|
1926
|
+
// ── fail closed: a waiver that names a rule id must never widen into a blanket one ────────────
|
|
1927
|
+
|
|
1928
|
+
it('waives NOTHING when the named rule id is a typo', () => {
|
|
1929
|
+
const f = find(
|
|
1930
|
+
`<Box p={${SPACE_SCALE.md}} style={{ color: '#ff0000' }} /> // theme-allow raw-hexx — vendor brand`,
|
|
1931
|
+
)
|
|
1932
|
+
// The old parse consumed no id, fell through to the empty-`rules` branch, and read the whole
|
|
1933
|
+
// thing as the LEGACY BLANKET form: one mistyped character silenced every kind on the line.
|
|
1934
|
+
expect(kinds(f)).toContain('raw-hex')
|
|
1935
|
+
expect(kinds(f)).toContain('raw-spacing')
|
|
1936
|
+
})
|
|
1937
|
+
|
|
1938
|
+
it('names the unknown id rather than reporting it as "no rule id"', () => {
|
|
1939
|
+
const f = find(`const c = '#ff0000' // theme-allow raw-hexx — vendor brand`)
|
|
1940
|
+
const unscoped = f.find((v) => v.kind === 'theme-allow-unscoped')
|
|
1941
|
+
expect(unscoped?.token).toContain("unknown rule id 'raw-hexx'")
|
|
1942
|
+
})
|
|
1943
|
+
|
|
1944
|
+
it('a typo is never more permissive than the same annotation spelled correctly', () => {
|
|
1945
|
+
const correct = find(`const c = '#ff0000' // theme-allow raw-hex — vendor brand`)
|
|
1946
|
+
const typo = find(`const c = '#ff0000' // theme-allow raw-hexx — vendor brand`)
|
|
1947
|
+
expect(typo.length).toBeGreaterThanOrEqual(correct.length)
|
|
1948
|
+
})
|
|
1949
|
+
|
|
1950
|
+
it('still waives the ids it got right in a mixed list', () => {
|
|
1951
|
+
const f = find(
|
|
1952
|
+
`<Box p={${SPACE_SCALE.md}} style={{ color: '#ff0000' }} /> // theme-allow raw-hex, raw-spacingg — mixed`,
|
|
1953
|
+
)
|
|
1954
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
1955
|
+
expect(kinds(f)).toContain('raw-spacing')
|
|
1956
|
+
})
|
|
1957
|
+
|
|
1958
|
+
it('says "not waived", not "waives nothing", when an id in the list DID resolve', () => {
|
|
1959
|
+
const f = find(`const c = '#ff0000' // theme-allow raw-hex, raw-spacingg — mixed`)
|
|
1960
|
+
const unscoped = f.find((v) => v.kind === 'theme-allow-unscoped')
|
|
1961
|
+
expect(unscoped?.token).toBe("theme-allow (unknown rule id 'raw-spacingg' — not waived)")
|
|
1962
|
+
// The claim has to match what the same run actually did: raw-hex IS waived here.
|
|
1963
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
1964
|
+
})
|
|
1965
|
+
|
|
1966
|
+
// ── prose after a resolved id is prose, not a typo ────────────────────────────────────────────
|
|
1967
|
+
//
|
|
1968
|
+
// The id slot closes at the first space no comma opened. `theme-allow raw-surface sub-scale …`
|
|
1969
|
+
// is one em-dash away from annotations this package itself ships (see ChartLegend), and reading
|
|
1970
|
+
// `sub-scale` as an attempted id made a scoped, reasoned waiver report as unscoped.
|
|
1971
|
+
|
|
1972
|
+
it('reads an unknown word after a resolved id as the reason', () => {
|
|
1973
|
+
const f = find(
|
|
1974
|
+
'const s = { borderRadius: 3 } // theme-allow raw-surface sub-scale legend corner',
|
|
1975
|
+
)
|
|
1976
|
+
expect(f).toHaveLength(0)
|
|
1977
|
+
})
|
|
1978
|
+
|
|
1979
|
+
it('still fails closed on a typo in the FIRST id slot, separator or not', () => {
|
|
1980
|
+
for (const annotation of [
|
|
1981
|
+
'theme-allow raw-hexx — vendor',
|
|
1982
|
+
'theme-allow raw-hexx vendor asset',
|
|
1983
|
+
]) {
|
|
1984
|
+
expect(kinds(find(`const c = '#ff0000' // ${annotation}`))).toContain('raw-hex')
|
|
1985
|
+
}
|
|
1986
|
+
})
|
|
1987
|
+
|
|
1988
|
+
// Documented break, not a regression: a reason with NO separator introducing it sits in the id
|
|
1989
|
+
// slot, names no rule, and therefore waives nothing. Kept as a test so the behaviour is pinned
|
|
1990
|
+
// rather than rediscovered — MIGRATING.md carries the consumer-facing half.
|
|
1991
|
+
it('does not waive a reason written with no separator introducing it', () => {
|
|
1992
|
+
const f = find(`const c = '#ff0000' // theme-allow legacy vendor asset`)
|
|
1993
|
+
expect(f.find((v) => v.kind === 'raw-hex')?.severity).toBe('error')
|
|
1994
|
+
expect(f.find((v) => v.kind === 'theme-allow-unscoped')?.token).toContain(
|
|
1995
|
+
"unknown rule id 'legacy' — waives nothing",
|
|
1996
|
+
)
|
|
1997
|
+
})
|
|
1998
|
+
|
|
1999
|
+
// `in` walks the prototype chain, so `'constructor' in GUARD_RULES` was true and a reason
|
|
2000
|
+
// starting with that word scoped the waiver to a rule that does not exist.
|
|
2001
|
+
it('does not resolve an Object.prototype key as a rule id', () => {
|
|
2002
|
+
const f = find(`const c = '#ff0000' // theme-allow constructor — inherited key, not a rule`)
|
|
2003
|
+
expect(kinds(f)).toContain('raw-hex')
|
|
2004
|
+
})
|
|
2005
|
+
|
|
2006
|
+
it('accepts an id belonging to a plugin rule that does not honour theme-allow', () => {
|
|
2007
|
+
// Not a guard kind and not theme-allow-aware — but a REAL id, so it parses as accountable
|
|
2008
|
+
// rather than being read as a typo (or, before, as a blanket waiver).
|
|
2009
|
+
const f = find(`const x = 1 // theme-allow ai-sdk-major — intentional producer/consumer skew`)
|
|
2010
|
+
expect(f).toHaveLength(0)
|
|
2011
|
+
})
|
|
2012
|
+
|
|
2013
|
+
it('ships theme-allow-unscoped as a warning, not a build failure', () => {
|
|
2014
|
+
const f = find(`const x = 1 // theme-allow`)
|
|
2015
|
+
expect(f.every((v) => v.severity === 'warn')).toBe(true)
|
|
2016
|
+
})
|
|
2017
|
+
|
|
2018
|
+
// The annotation itself lives in a comment; a `theme-allow` in real code must not waive anything.
|
|
2019
|
+
it('ignores the token when it is not inside a comment', () => {
|
|
2020
|
+
const f = find(`const label = 'theme-allow'\nconst c = '#ff0000'`)
|
|
2021
|
+
expect(kinds(f)).toContain('raw-hex')
|
|
2022
|
+
})
|
|
2023
|
+
})
|
|
2024
|
+
|
|
2025
|
+
// ── 22b. theme-allow — the prefix rule and the two scopes, exhaustively ──────
|
|
2026
|
+
//
|
|
2027
|
+
// Three holes in one contract across two rounds, so this is a matrix rather than a case.
|
|
2028
|
+
|
|
2029
|
+
describe('theme-allow — annotation vs prose (the prefix rule)', () => {
|
|
2030
|
+
const HEX = `const c = '#ff0000'`
|
|
2031
|
+
|
|
2032
|
+
/** `comment` on its own line directly above a raw hex — the placement that waives the line below. */
|
|
2033
|
+
function above(comment: string): GuardKind[] {
|
|
2034
|
+
return kinds(find(`${comment}\n${HEX}`))
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// ── ACCEPTED — every shape a consumer actually writes ──────────────────────
|
|
2038
|
+
|
|
2039
|
+
it.each([
|
|
2040
|
+
'// theme-allow raw-hex — vendor brand',
|
|
2041
|
+
'//theme-allow raw-hex — no space after the marker',
|
|
2042
|
+
' // theme-allow raw-hex — indented',
|
|
2043
|
+
'/* theme-allow raw-hex — a block comment */',
|
|
2044
|
+
'{/* theme-allow raw-hex — a JSX expression comment */}',
|
|
2045
|
+
'/**\n * theme-allow raw-hex — a docblock gutter line\n */',
|
|
2046
|
+
'// theme-allow',
|
|
2047
|
+
])('%s is an annotation and waives the line below', (comment) => {
|
|
2048
|
+
expect([comment, above(comment).includes('raw-hex')]).toEqual([comment, false])
|
|
2049
|
+
})
|
|
2050
|
+
|
|
2051
|
+
it('a trailing annotation waives its own line', () => {
|
|
2052
|
+
expect(kinds(find(`${HEX} // theme-allow raw-hex — vendor brand`))).not.toContain('raw-hex')
|
|
2053
|
+
})
|
|
2054
|
+
|
|
2055
|
+
// The reason wrapping to a second line — or a docblock's own `*/` — used to absorb the whole
|
|
2056
|
+
// waiver, so the natural multi-line shape silently waived nothing. argo hit it three times in one
|
|
2057
|
+
// upgrade, each time on a comment that looked correct.
|
|
2058
|
+
it('a wrapped reason still reaches the code line under it', () => {
|
|
2059
|
+
const f = find(
|
|
2060
|
+
`// theme-allow raw-hex — the vendor brand hex, kept in sync with
|
|
2061
|
+
// the marketing site's own palette
|
|
2062
|
+
${HEX}`,
|
|
2063
|
+
)
|
|
2064
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
2065
|
+
})
|
|
2066
|
+
|
|
2067
|
+
it('a blank line ends the block — the waiver does not jump it', () => {
|
|
2068
|
+
expect(
|
|
2069
|
+
kinds(
|
|
2070
|
+
find(`// theme-allow raw-hex — about something else
|
|
2071
|
+
|
|
2072
|
+
${HEX}`),
|
|
2073
|
+
),
|
|
2074
|
+
).toContain('raw-hex')
|
|
2075
|
+
})
|
|
2076
|
+
|
|
2077
|
+
it('a trailing annotation does NOT walk forward — it stays on its own line', () => {
|
|
2078
|
+
expect(kinds(find(`const ok = 1 // theme-allow raw-hex — about this line\n${HEX}`))).toContain(
|
|
2079
|
+
'raw-hex',
|
|
2080
|
+
)
|
|
2081
|
+
})
|
|
2082
|
+
|
|
2083
|
+
// ── REJECTED — prose that MENTIONS the token ──────────────────────────────
|
|
2084
|
+
|
|
2085
|
+
// The linewatch defect, and the one that made this a false NEGATIVE: a file documenting its own
|
|
2086
|
+
// waivers disarmed itself. Every shape here is lifted from real consumer or basalt source.
|
|
2087
|
+
it.each([
|
|
2088
|
+
'// see the `theme-allow` contract for how exceptions are written',
|
|
2089
|
+
'/* Since 1.20.0 a `theme-allow` that names a rule declares the file */',
|
|
2090
|
+
'/**\n * A `theme-allow` with a reason declares the whole FILE wherever it is written.\n */',
|
|
2091
|
+
'// each chart carries a theme-allow saying why',
|
|
2092
|
+
'// documented at theme-allow raw-hex — this sentence is prose, not a waiver',
|
|
2093
|
+
])('%s is prose and waives nothing', (comment) => {
|
|
2094
|
+
expect([comment, above(comment).includes('raw-hex')]).toEqual([comment, true])
|
|
2095
|
+
})
|
|
2096
|
+
|
|
2097
|
+
// A kind name written in prose used to consume no id, fall through to `rules: []`, and read as
|
|
2098
|
+
// the blanket form — so a sentence about the reporting rule switched every rule off.
|
|
2099
|
+
it('a comment starting with the longer word `theme-allow-unscoped` is not an annotation', () => {
|
|
2100
|
+
expect(above('// theme-allow-unscoped is what reports a bare waiver')).toContain('raw-hex')
|
|
2101
|
+
})
|
|
2102
|
+
|
|
2103
|
+
it('the token inside a string literal is code, not an annotation', () => {
|
|
2104
|
+
expect(kinds(find(`const doc = 'theme-allow raw-hex — x'\n${HEX}`))).toContain('raw-hex')
|
|
2105
|
+
})
|
|
2106
|
+
|
|
2107
|
+
// Prose is not an annotation for REPORTING either — otherwise every sentence documenting the
|
|
2108
|
+
// escape hatch becomes a theme-allow-unscoped finding (this package's own guard/types.ts).
|
|
2109
|
+
it('prose mentioning the token is not reported as an unscoped waiver', () => {
|
|
2110
|
+
expect(above('// see the `theme-allow` contract')).not.toContain('theme-allow-unscoped')
|
|
2111
|
+
})
|
|
2112
|
+
})
|
|
2113
|
+
|
|
2114
|
+
describe('theme-allow-file — whole-file scope', () => {
|
|
2115
|
+
const SRC = [`const a = '#ff0000'`, `const b = '#00ff00'`, `const c = '#0000ff'`].join('\n')
|
|
2116
|
+
|
|
2117
|
+
it('waives every line in the file, not just the one below it', () => {
|
|
2118
|
+
const f = find(`// theme-allow-file raw-hex — the vendor brand sheet\n${SRC}`)
|
|
2119
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
2120
|
+
})
|
|
2121
|
+
|
|
2122
|
+
it('reaches BACKWARDS too — a declaration at the bottom covers the top', () => {
|
|
2123
|
+
const f = find(`${SRC}\n// theme-allow-file raw-hex — the vendor brand sheet`)
|
|
2124
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
2125
|
+
})
|
|
2126
|
+
|
|
2127
|
+
it('waives only the kinds it names', () => {
|
|
2128
|
+
const f = find(
|
|
2129
|
+
`// theme-allow-file raw-hex — vendor brand\n${SRC}\n<Box style={{ display: 'flex' }} />`,
|
|
2130
|
+
)
|
|
2131
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
2132
|
+
expect(kinds(f)).toContain('inline-display')
|
|
2133
|
+
})
|
|
2134
|
+
|
|
2135
|
+
// File scope is the widest waiver in the contract, so it is the one that has to name what it
|
|
2136
|
+
// waives. A bare `theme-allow-file` is `exemptRules` without the config review.
|
|
2137
|
+
it('a BARE theme-allow-file waives nothing, and is reported as unscoped', () => {
|
|
2138
|
+
const f = find(`// theme-allow-file — everything in here is bespoke\n${SRC}`)
|
|
2139
|
+
expect(kinds(f)).toContain('raw-hex')
|
|
2140
|
+
expect(kinds(f)).toContain('theme-allow-unscoped')
|
|
2141
|
+
})
|
|
2142
|
+
|
|
2143
|
+
it("a theme-allow-file naming a typo'd id waives nothing", () => {
|
|
2144
|
+
const f = find(`// theme-allow-file raw-hexx — vendor brand\n${SRC}`)
|
|
2145
|
+
expect(kinds(f)).toContain('raw-hex')
|
|
2146
|
+
expect(kinds(f)).toContain('theme-allow-unscoped')
|
|
2147
|
+
})
|
|
2148
|
+
|
|
2149
|
+
it('an id-and-reason theme-allow-file is accountable — no unscoped finding', () => {
|
|
2150
|
+
const f = find(`// theme-allow-file raw-hex — the vendor brand sheet\n${SRC}`)
|
|
2151
|
+
expect(kinds(f)).not.toContain('theme-allow-unscoped')
|
|
2152
|
+
})
|
|
2153
|
+
|
|
2154
|
+
// The complement, and the half 1.20.0 did not deliver: naming a rule AND giving a reason used to
|
|
2155
|
+
// promote a waiver to the whole file in the oxlint plugin, so whole-file was the only expressible
|
|
2156
|
+
// scope. The guard's plain `theme-allow` has always been line-scoped, and stays that way.
|
|
2157
|
+
it('a plain theme-allow with an id and a reason stays line-scoped', () => {
|
|
2158
|
+
const f = find(`// theme-allow raw-hex — vendor brand\n${SRC}`)
|
|
2159
|
+
expect(kinds(f).filter((k) => k === 'raw-hex')).toHaveLength(2)
|
|
2160
|
+
})
|
|
2161
|
+
})
|
|
2162
|
+
|
|
2163
|
+
/** A two-hex manifest with `extra` (an annotation member, or nothing) inserted at the top. */
|
|
2164
|
+
const MANIFEST = (extra: string) =>
|
|
2165
|
+
`{\n${extra} "theme_color": "#27272a",\n "background_color": "#27272a"\n}\n`
|
|
2166
|
+
|
|
2167
|
+
describe('theme-allow in JSON — the "basalt:theme-allow" member', () => {
|
|
2168
|
+
it('a webmanifest hex reports with no annotation — the 1.20.0 baseline', () => {
|
|
2169
|
+
const f = checkSource(MANIFEST(''), 'public/site.webmanifest', DEFAULT_GUARD_CONFIG)
|
|
2170
|
+
expect(kinds(f).filter((k) => k === 'raw-hex')).toHaveLength(2)
|
|
2171
|
+
})
|
|
2172
|
+
|
|
2173
|
+
// JSON has no comments, so from 1.20.0 the whole file class was permanently unwaivable and the
|
|
2174
|
+
// printed remedy prescribed a comment the file cannot carry. Both consumers that hit it fell back
|
|
2175
|
+
// to a blanket `exemptRules` entry — the shape the release was spent retiring.
|
|
2176
|
+
it('a "basalt:theme-allow-file" member declares the file', () => {
|
|
2177
|
+
const f = checkSource(
|
|
2178
|
+
MANIFEST(' "basalt:theme-allow-file": "raw-hex — a manifest cannot read a CSS variable",\n'),
|
|
2179
|
+
'public/site.webmanifest',
|
|
2180
|
+
DEFAULT_GUARD_CONFIG,
|
|
2181
|
+
)
|
|
2182
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
2183
|
+
})
|
|
2184
|
+
|
|
2185
|
+
it('a "basalt:theme-allow" member is line-scoped — its own line and the one below', () => {
|
|
2186
|
+
const f = checkSource(
|
|
2187
|
+
MANIFEST(' "basalt:theme-allow": "raw-hex — the theme color only",\n'),
|
|
2188
|
+
'public/site.webmanifest',
|
|
2189
|
+
DEFAULT_GUARD_CONFIG,
|
|
2190
|
+
)
|
|
2191
|
+
expect(kinds(f).filter((k) => k === 'raw-hex')).toHaveLength(1)
|
|
2192
|
+
})
|
|
2193
|
+
|
|
2194
|
+
it('the member must name its ids — a bare one waives nothing', () => {
|
|
2195
|
+
const f = checkSource(
|
|
2196
|
+
MANIFEST(' "basalt:theme-allow-file": "everything in here is bespoke",\n'),
|
|
2197
|
+
'public/site.webmanifest',
|
|
2198
|
+
DEFAULT_GUARD_CONFIG,
|
|
2199
|
+
)
|
|
2200
|
+
expect(kinds(f)).toContain('raw-hex')
|
|
2201
|
+
})
|
|
2202
|
+
|
|
2203
|
+
it('works in a plain .json too, not only a manifest', () => {
|
|
2204
|
+
const f = checkSource(
|
|
2205
|
+
MANIFEST(' "basalt:theme-allow-file": "raw-hex — vendor brand data",\n'),
|
|
2206
|
+
'src/brand.json',
|
|
2207
|
+
DEFAULT_GUARD_CONFIG,
|
|
2208
|
+
)
|
|
2209
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
2210
|
+
})
|
|
2211
|
+
|
|
2212
|
+
// The member is JSON-only: in TS that string is a value, and a value that could switch off a rule
|
|
2213
|
+
// is the hole `stripComments` exists to close.
|
|
2214
|
+
it('the same string in a TS file is code, not an annotation', () => {
|
|
2215
|
+
const f = find(`const cfg = { "basalt:theme-allow-file": "raw-hex — x" }\nconst c = '#ff0000'`)
|
|
2216
|
+
expect(kinds(f)).toContain('raw-hex')
|
|
2217
|
+
})
|
|
2218
|
+
})
|
|
2219
|
+
|
|
2220
|
+
// ── 23. surface-shadow-override ──────────────────────────────────────────────
|
|
2221
|
+
|
|
2222
|
+
describe('surface-shadow-override', () => {
|
|
2223
|
+
it('flags a token-composed shadow that REPLACES card depth', () => {
|
|
2224
|
+
const f = find('const s = { boxShadow: `0 0 0 2px ${VX.accent}` }')
|
|
2225
|
+
expect(kinds(f)).toContain('surface-shadow-override')
|
|
2226
|
+
})
|
|
2227
|
+
|
|
2228
|
+
it('does NOT flag one that composes WITH card depth', () => {
|
|
2229
|
+
const f = find('const s = { boxShadow: `${VX.shadowCard}, 0 0 0 2px ${VX.accent}` }')
|
|
2230
|
+
expect(kinds(f)).not.toContain('surface-shadow-override')
|
|
2231
|
+
})
|
|
2232
|
+
|
|
2233
|
+
it('does NOT flag a var(--vx-shadow-…) reference', () => {
|
|
2234
|
+
const f = find(`const s = { boxShadow: 'var(--vx-shadow-card)' }`)
|
|
2235
|
+
expect(kinds(f)).not.toContain('surface-shadow-override')
|
|
2236
|
+
})
|
|
2237
|
+
|
|
2238
|
+
it('still reports a fully literal shadow as raw-surface, not the new kind', () => {
|
|
2239
|
+
const f = find(`const s = { boxShadow: '0 1px 3px black' }`)
|
|
2240
|
+
expect(kinds(f)).toContain('raw-surface')
|
|
2241
|
+
expect(kinds(f)).not.toContain('surface-shadow-override')
|
|
2242
|
+
})
|
|
2243
|
+
|
|
2244
|
+
it('ships as a warning for its grace minor', () => {
|
|
2245
|
+
const f = find('const s = { boxShadow: `0 0 0 2px ${VX.accent}` }')
|
|
2246
|
+
expect(f.find((v) => v.kind === 'surface-shadow-override')?.severity).toBe('warn')
|
|
2247
|
+
})
|
|
2248
|
+
})
|
|
2249
|
+
|
|
2250
|
+
// ── 24. css-raw-surface ──────────────────────────────────────────────────────
|
|
2251
|
+
|
|
2252
|
+
describe('css-raw-surface', () => {
|
|
2253
|
+
it('flags a kebab-case border-radius literal in CSS', () => {
|
|
2254
|
+
const f = find('.a { border-radius: 4px; }', 'src/a.module.css')
|
|
2255
|
+
expect(kinds(f)).toContain('css-raw-surface')
|
|
2256
|
+
})
|
|
2257
|
+
|
|
2258
|
+
it('does NOT flag a var(--vx-radius-…) reference', () => {
|
|
2259
|
+
const f = find('.a { border-radius: var(--vx-radius-card); }', 'src/a.module.css')
|
|
2260
|
+
expect(kinds(f)).not.toContain('css-raw-surface')
|
|
2261
|
+
})
|
|
2262
|
+
|
|
2263
|
+
it('does NOT flag a sub-scale micro-corner below the 4px floor', () => {
|
|
2264
|
+
const f = find('.a { border-radius: 2px; }', 'src/a.module.css')
|
|
2265
|
+
expect(kinds(f)).not.toContain('css-raw-surface')
|
|
2266
|
+
})
|
|
2267
|
+
|
|
2268
|
+
it('does NOT flag the circle/pill shape values', () => {
|
|
2269
|
+
const f = find('.a { border-radius: 50%; }\n.b { border-radius: 9999px; }', 'src/a.module.css')
|
|
2270
|
+
expect(kinds(f)).not.toContain('css-raw-surface')
|
|
2271
|
+
})
|
|
2272
|
+
|
|
2273
|
+
it('flags a literal box-shadow in CSS', () => {
|
|
2274
|
+
const f = find('.a { box-shadow: 0 1px 3px rgba(0,0,0,0.2); }', 'src/a.module.css')
|
|
2275
|
+
expect(kinds(f)).toContain('css-raw-surface')
|
|
2276
|
+
})
|
|
2277
|
+
|
|
2278
|
+
it('never fires in TSX (the camelCase dialect is raw-surface’s)', () => {
|
|
2279
|
+
const f = find(`<div style={{ borderRadius: 8 }} />`)
|
|
2280
|
+
expect(kinds(f)).not.toContain('css-raw-surface')
|
|
2281
|
+
expect(kinds(f)).toContain('raw-surface')
|
|
2282
|
+
})
|
|
2283
|
+
})
|
|
2284
|
+
|
|
2285
|
+
// ── 25. inline-font-size ─────────────────────────────────────────────────────
|
|
2286
|
+
|
|
2287
|
+
describe('inline-font-size', () => {
|
|
2288
|
+
it('flags a unitless inline fontSize (the check-theme-only CI gap)', () => {
|
|
2289
|
+
const f = find(`<div style={{ padding: '6px 8px', fontSize: 11 }} />`)
|
|
2290
|
+
expect(kinds(f)).toContain('inline-font-size')
|
|
2291
|
+
})
|
|
2292
|
+
|
|
2293
|
+
it('flags a px font-size in CSS', () => {
|
|
2294
|
+
const f = find('.a { font-size: 13px; }', 'src/a.module.css')
|
|
2295
|
+
expect(kinds(f)).toContain('inline-font-size')
|
|
2296
|
+
})
|
|
2297
|
+
|
|
2298
|
+
it('does NOT flag a token reference', () => {
|
|
2299
|
+
const f = find(`<div style={{ fontSize: VX.text.sm }} />`)
|
|
2300
|
+
expect(kinds(f)).not.toContain('inline-font-size')
|
|
2301
|
+
})
|
|
2302
|
+
|
|
2303
|
+
it('does NOT flag a relative unit', () => {
|
|
2304
|
+
const f = find('.a { font-size: 0.8rem; }', 'src/a.module.css')
|
|
2305
|
+
expect(kinds(f)).not.toContain('inline-font-size')
|
|
2306
|
+
})
|
|
2307
|
+
})
|
|
2308
|
+
|
|
2309
|
+
// ── 26. hidden-inline-style ──────────────────────────────────────────────────
|
|
2310
|
+
|
|
2311
|
+
describe('hidden-inline-style', () => {
|
|
2312
|
+
it('flags a multi-line-formatted raw div the line scan cannot see', () => {
|
|
2313
|
+
const f = find(
|
|
2314
|
+
`export const C = () => (
|
|
2315
|
+
<div
|
|
2316
|
+
className="x"
|
|
2317
|
+
style={{
|
|
2318
|
+
display: 'flex',
|
|
2319
|
+
padding: 12,
|
|
2320
|
+
}}
|
|
2321
|
+
/>
|
|
2322
|
+
)`,
|
|
2323
|
+
)
|
|
2324
|
+
expect(kinds(f)).toContain('hidden-inline-style')
|
|
2325
|
+
})
|
|
2326
|
+
|
|
2327
|
+
it('flags a hoisted style const passed by identifier', () => {
|
|
2328
|
+
const f = find(
|
|
2329
|
+
`const wrapperStyle = { position: 'relative', width: '100%' }
|
|
2330
|
+
export const C = () => <div className="x" style={wrapperStyle} />`,
|
|
2331
|
+
)
|
|
2332
|
+
expect(kinds(f)).toContain('hidden-inline-style')
|
|
2333
|
+
})
|
|
2334
|
+
|
|
2335
|
+
it('does NOT double-report what raw-html-layout already owns', () => {
|
|
2336
|
+
const f = find(`<div style={{ display: 'flex', padding: 12 }} />`)
|
|
2337
|
+
expect(kinds(f)).toContain('raw-html-layout')
|
|
2338
|
+
expect(kinds(f)).not.toContain('hidden-inline-style')
|
|
2339
|
+
})
|
|
2340
|
+
|
|
2341
|
+
it('does NOT flag a hoisted const with no layout/surface property', () => {
|
|
2342
|
+
const f = find(
|
|
2343
|
+
`const labelStyle = { fontWeight: 500 }
|
|
2344
|
+
export const C = () => <div style={labelStyle} />`,
|
|
2345
|
+
)
|
|
2346
|
+
expect(kinds(f)).not.toContain('hidden-inline-style')
|
|
2347
|
+
})
|
|
2348
|
+
|
|
2349
|
+
it('ships as a warning for its grace minor', () => {
|
|
2350
|
+
const f = find(
|
|
2351
|
+
`const wrapperStyle = { position: 'relative', width: '100%' }
|
|
2352
|
+
export const C = () => <div style={wrapperStyle} />`,
|
|
2353
|
+
)
|
|
2354
|
+
expect(f.find((v) => v.kind === 'hidden-inline-style')?.severity).toBe('warn')
|
|
2355
|
+
})
|
|
2356
|
+
})
|
|
2357
|
+
|
|
2358
|
+
// ── 27. raw-color-fn on computed colors ──────────────────────────────────────
|
|
2359
|
+
|
|
2360
|
+
describe('raw-color-fn — computed values', () => {
|
|
2361
|
+
it('does NOT flag a color function whose first channel is interpolated', () => {
|
|
2362
|
+
const f = find('const swatch = `rgb(${px[0]}, ${px[1]}, ${px[2]})`')
|
|
2363
|
+
expect(kinds(f)).not.toContain('raw-color-fn')
|
|
2364
|
+
})
|
|
2365
|
+
|
|
2366
|
+
it('still flags a literal color with only a variable alpha', () => {
|
|
2367
|
+
const f = find('const s = `rgba(0, 0, 0, ${opacity})`')
|
|
2368
|
+
expect(kinds(f)).toContain('raw-color-fn')
|
|
2369
|
+
})
|
|
2370
|
+
})
|
|
2371
|
+
|
|
2372
|
+
// ── 28. generated-file marker ────────────────────────────────────────────────
|
|
2373
|
+
|
|
2374
|
+
describe('@generated basalt-ui marker', () => {
|
|
2375
|
+
const HEADER = `${GENERATED_HEADER_LINE}\n/* basalt-ui 1.20.0 — \`basalt-ui tokens:css --only core\` */\n`
|
|
2376
|
+
const BODY = ':root {\n --vx-fill-gray: #717176;\n --vx-axis: rgba(255, 255, 255, 0.6);\n}\n'
|
|
2377
|
+
const EMITTED = `${HEADER}${BODY}`
|
|
2378
|
+
|
|
2379
|
+
it('skips a file basalt itself emitted', () => {
|
|
2380
|
+
expect(find(EMITTED, 'src/styles/basalt-tokens.css')).toHaveLength(0)
|
|
2381
|
+
})
|
|
2382
|
+
|
|
2383
|
+
// ── the forgery half: the marker was a whole-file bypass anyone could hand-write ──────────────
|
|
2384
|
+
|
|
2385
|
+
it('ignores the marker entirely outside a .css file', () => {
|
|
2386
|
+
const forged = `${HEADER}export const C = () => <div style={{ color: '#ff0000', padding: 18 }} />\n`
|
|
2387
|
+
expect(kinds(find(forged, 'src/a.tsx'))).toContain('raw-hex')
|
|
2388
|
+
expect(kinds(find(forged, 'src/a.tsx'))).toContain('inline-spacing')
|
|
2389
|
+
})
|
|
2390
|
+
|
|
2391
|
+
it('does not exempt hand-written CSS wearing the header', () => {
|
|
2392
|
+
const forged = `${HEADER}.btn { color: #ff0000; }\n`
|
|
2393
|
+
expect(kinds(find(forged, 'src/a.css'))).toContain('raw-hex')
|
|
2394
|
+
})
|
|
2395
|
+
|
|
2396
|
+
it('does not exempt a generated sheet with an ordinary rule smuggled onto the end', () => {
|
|
2397
|
+
const smuggled = `${EMITTED}.btn { color: #ff0000; }\n`
|
|
2398
|
+
expect(kinds(find(smuggled, 'src/tokens.css'))).toContain('raw-hex')
|
|
2399
|
+
})
|
|
2400
|
+
|
|
2401
|
+
it('requires the provenance line, not just the marker', () => {
|
|
2402
|
+
const bare = `${GENERATED_HEADER_LINE}\n${BODY}`
|
|
2403
|
+
expect(kinds(find(bare, 'src/tokens.css'))).toContain('raw-hex')
|
|
2404
|
+
const loose = `/* @generated basalt-ui */\n${BODY}`
|
|
2405
|
+
expect(kinds(find(loose, 'src/tokens.css'))).toContain('raw-hex')
|
|
2406
|
+
})
|
|
2407
|
+
|
|
2408
|
+
// ── the header WINDOW: exactly the two lines the emitter writes, and no more ──────────────────
|
|
2409
|
+
|
|
2410
|
+
it('honours the header only on lines 1-2', () => {
|
|
2411
|
+
const onLineOne = `${HEADER}${BODY}`
|
|
2412
|
+
expect(find(onLineOne, 'src/tokens.css')).toHaveLength(0)
|
|
2413
|
+
// One blank line ahead of it is already outside the window — the boundary is exact, not "the
|
|
2414
|
+
// first few lines", which is what let a marker be pasted in above unrelated content.
|
|
2415
|
+
const shifted = `\n${HEADER}${BODY}`
|
|
2416
|
+
expect(kinds(find(shifted, 'src/tokens.css'))).toContain('raw-hex')
|
|
2417
|
+
const buried = `${'\n'.repeat(8)}${HEADER}${BODY}`
|
|
2418
|
+
expect(kinds(find(buried, 'src/tokens.css'))).toContain('raw-hex')
|
|
2419
|
+
})
|
|
2420
|
+
|
|
2421
|
+
// ── the second forgery round: the line-shape allowlist itself was forgeable ───────────────────
|
|
2422
|
+
//
|
|
2423
|
+
// Both of the first two were demonstrated against the real `checkSource`, and both were valid,
|
|
2424
|
+
// browser-effective CSS bought a WHOLE-FILE exemption. Every case below must report — a passing
|
|
2425
|
+
// `toHaveLength(0)` here would mean the hole is open again.
|
|
2426
|
+
|
|
2427
|
+
const forgeries: ReadonlyArray<readonly [string, string]> = [
|
|
2428
|
+
// A `;` inside a custom-property value smuggled two ordinary declarations onto a line the
|
|
2429
|
+
// allowlist read as "a basalt custom property".
|
|
2430
|
+
[
|
|
2431
|
+
'a semicolon inside a custom-property value',
|
|
2432
|
+
`${HEADER}:root {\n --vx-pad: 0; box-shadow: 0 0 0 1px #ff0000; border-radius: 11px;\n}\n`,
|
|
2433
|
+
],
|
|
2434
|
+
// The comment branch was `\/\*.*` — it never required the comment to CLOSE, so anything after
|
|
2435
|
+
// `*/` rode along.
|
|
2436
|
+
['a comment that never closes', `${HEADER}/* x */ .btn { color: #ff0000; }\n`],
|
|
2437
|
+
// A comment reopened mid-line: closing and reopening keeps the line comment-SHAPED at both
|
|
2438
|
+
// ends while the middle is a live declaration.
|
|
2439
|
+
['a comment reopened mid-line', `${HEADER}:root {\n /* a */ color: #ff0000 /* b */\n}\n`],
|
|
2440
|
+
// The first line of a multi-line declaration ends in `,`, which is also how a selector list
|
|
2441
|
+
// continues — the depth check is what tells them apart.
|
|
2442
|
+
[
|
|
2443
|
+
'a multi-line declaration whose first line ends in a comma',
|
|
2444
|
+
`${HEADER}.btn {\n box-shadow: 0 0 0 1px #ff0000,\n 0 0 2px #00ff00;\n}\n`,
|
|
2445
|
+
],
|
|
2446
|
+
// The at-rule branch permitted `;`, so a declaration could ride behind `@…;`.
|
|
2447
|
+
['a declaration behind an at-rule', `${HEADER}@media all; color: #ff0000;\n`],
|
|
2448
|
+
// No trailing `;` anywhere, so nothing to split on.
|
|
2449
|
+
['a whole rule on one line with no semicolon', `${HEADER}.btn { color: #ff0000 }\n`],
|
|
2450
|
+
[
|
|
2451
|
+
'a rule reopened after a closing brace',
|
|
2452
|
+
`${HEADER}:root {\n --vx-a: #fff;\n} .btn { color: #ff0000; }\n`,
|
|
2453
|
+
],
|
|
2454
|
+
['a nested block', `${HEADER}:root {\n .btn {\n color: #ff0000;\n }\n}\n`],
|
|
2455
|
+
]
|
|
2456
|
+
|
|
2457
|
+
for (const [what, source] of forgeries) {
|
|
2458
|
+
it(`reports ${what}`, () => {
|
|
2459
|
+
expect(find(source, 'src/tokens.css').length).toBeGreaterThan(0)
|
|
2460
|
+
})
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
it('never skips a line carrying theme-allow, so a hidden waiver still reports', () => {
|
|
2464
|
+
// Without this the annotation would waive the line below AND its own unscoped report, because
|
|
2465
|
+
// a bare `/* theme-allow */` is comment-SHAPED and would otherwise be a skippable line.
|
|
2466
|
+
const smuggled = `${HEADER}/* theme-allow */\n.btn { color: #ff0000; }\n`
|
|
2467
|
+
expect(kinds(find(smuggled, 'src/tokens.css'))).toContain('theme-allow-unscoped')
|
|
2468
|
+
})
|
|
2469
|
+
|
|
2470
|
+
it('exempts LINES, not the file — a smuggled rule reports without burying it in token values', () => {
|
|
2471
|
+
const smuggled = `${EMITTED}.btn { color: #ff0000; }\n`
|
|
2472
|
+
const f = find(smuggled, 'src/tokens.css')
|
|
2473
|
+
expect(f.map((x) => x.line)).toEqual([7])
|
|
2474
|
+
expect(kinds(f)).toEqual(['raw-hex'])
|
|
2475
|
+
})
|
|
2476
|
+
})
|
|
2477
|
+
|
|
2478
|
+
// ── 29. markup files ─────────────────────────────────────────────────────────
|
|
2479
|
+
|
|
2480
|
+
describe('markup files (index.html / *.webmanifest)', () => {
|
|
2481
|
+
it('flags raw hex in index.html', () => {
|
|
2482
|
+
const f = find(`<meta name="theme-color" content="#EDEFF2" />`, 'index.html')
|
|
2483
|
+
expect(kinds(f)).toContain('raw-hex')
|
|
2484
|
+
})
|
|
2485
|
+
|
|
2486
|
+
it('flags raw hex in a webmanifest', () => {
|
|
2487
|
+
const f = find(`{ "theme_color": "#242424" }`, 'public/site.webmanifest')
|
|
2488
|
+
expect(kinds(f)).toContain('raw-hex')
|
|
2489
|
+
})
|
|
2490
|
+
|
|
2491
|
+
it('does not apply the JSX/CSS-in-JS kinds there', () => {
|
|
2492
|
+
const f = find(`<div style="padding: 18px"><input /></div>`, 'index.html')
|
|
2493
|
+
expect(kinds(f)).not.toContain('raw-form-control')
|
|
2494
|
+
expect(kinds(f)).not.toContain('inline-spacing')
|
|
2495
|
+
})
|
|
2496
|
+
|
|
2497
|
+
it('honours a theme-allow inside an HTML comment', () => {
|
|
2498
|
+
const f = find(
|
|
2499
|
+
`<!-- theme-allow raw-hex — favicon mask color -->\n<meta content="#EDEFF2" />`,
|
|
2500
|
+
'index.html',
|
|
2501
|
+
)
|
|
2502
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
2503
|
+
})
|
|
2504
|
+
})
|
|
2505
|
+
|
|
2506
|
+
// ── 30. tokens-only profile ──────────────────────────────────────────────────
|
|
2507
|
+
|
|
2508
|
+
describe("profile: 'tokens-only'", () => {
|
|
2509
|
+
const TOKENS_ONLY = { ...DEFAULT_GUARD_CONFIG, profile: 'tokens-only' as const }
|
|
2510
|
+
|
|
2511
|
+
it('drops the kinds whose remedy is a Mantine component', () => {
|
|
2512
|
+
const src = `<input /> // a Mantine-free consumer has no TextInput to reach for`
|
|
2513
|
+
expect(kinds(checkSource(src, PATH, DEFAULT_GUARD_CONFIG))).toContain('raw-form-control')
|
|
2514
|
+
expect(kinds(checkSource(src, PATH, TOKENS_ONLY))).not.toContain('raw-form-control')
|
|
2515
|
+
})
|
|
2516
|
+
|
|
2517
|
+
it('keeps the color kinds — the token layer IS what they consume', () => {
|
|
2518
|
+
const f = checkSource(`.a { color: #ff0000; }`, 'src/a.css', TOKENS_ONLY)
|
|
2519
|
+
expect(kinds(f)).toContain('raw-hex')
|
|
2520
|
+
})
|
|
2521
|
+
|
|
2522
|
+
/**
|
|
2523
|
+
* Every kind, classified by hand: is its remedy a Mantine component, a Mantine prop, or the React
|
|
2524
|
+
* theme factory? The exhaustive literal IS the test — the previous spot check of four kinds is
|
|
2525
|
+
* how `hidden-inline-style` shipped ENABLED under the profile, telling a Mantine-free app to
|
|
2526
|
+
* import Box/Flex in a message copied word for word from `raw-html-layout`, which is disabled.
|
|
2527
|
+
* A new kind fails the first assertion until it is classified here.
|
|
2528
|
+
*/
|
|
2529
|
+
const MANTINE_COUPLED: Record<GuardKind, boolean> = {
|
|
2530
|
+
'raw-hex': false,
|
|
2531
|
+
'raw-color-fn': false,
|
|
2532
|
+
'localstorage-theme': true,
|
|
2533
|
+
'off-identity-accent': true,
|
|
2534
|
+
'mantine-shade-index': true,
|
|
2535
|
+
'raw-spacing': true,
|
|
2536
|
+
'raw-radius': true,
|
|
2537
|
+
'raw-surface': false,
|
|
2538
|
+
'card-with-border': true,
|
|
2539
|
+
'off-system-surface-var': true,
|
|
2540
|
+
'raw-html-layout': true,
|
|
2541
|
+
'inline-spacing': true,
|
|
2542
|
+
'inline-display': true,
|
|
2543
|
+
'raw-visx-axis': true,
|
|
2544
|
+
'raw-motion-value': true,
|
|
2545
|
+
'unframed-chart': true,
|
|
2546
|
+
'chart-missing-aria-label': true,
|
|
2547
|
+
'raw-form-control': true,
|
|
2548
|
+
'sub-16-input-font': true,
|
|
2549
|
+
'raw-font-family': false,
|
|
2550
|
+
'theme-allow-unscoped': false,
|
|
2551
|
+
'surface-shadow-override': false,
|
|
2552
|
+
'css-raw-surface': false,
|
|
2553
|
+
'inline-font-size': false,
|
|
2554
|
+
'hidden-inline-style': true,
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
it('classifies every kind in the registry — the table is exhaustive', () => {
|
|
2558
|
+
expect(Object.keys(MANTINE_COUPLED).toSorted()).toEqual(Object.keys(GUARD_RULES).toSorted())
|
|
2559
|
+
expect(Object.keys(MANTINE_COUPLED)).toHaveLength(25)
|
|
2560
|
+
})
|
|
2561
|
+
|
|
2562
|
+
it('the disabled set is exactly the Mantine-coupled half — a complete partition', () => {
|
|
2563
|
+
for (const [kind, coupled] of Object.entries(MANTINE_COUPLED) as [GuardKind, boolean][]) {
|
|
2564
|
+
expect([kind, TOKENS_ONLY_DISABLED_KINDS.has(kind)]).toEqual([kind, coupled])
|
|
2565
|
+
}
|
|
2566
|
+
expect(TOKENS_ONLY_DISABLED_KINDS.size).toBe(
|
|
2567
|
+
Object.values(MANTINE_COUPLED).filter(Boolean).length,
|
|
2568
|
+
)
|
|
2569
|
+
})
|
|
2570
|
+
|
|
2571
|
+
it('no surviving kind tells a Mantine-free app to reach for a Mantine component', () => {
|
|
2572
|
+
const MANTINE_REMEDY =
|
|
2573
|
+
/@mantine\/|\b(?:Box|Flex|Grid|Stack|Group|TextInput|NumberInput|Select|Textarea)\b/
|
|
2574
|
+
for (const kind of Object.keys(GUARD_RULES) as GuardKind[]) {
|
|
2575
|
+
if (TOKENS_ONLY_DISABLED_KINDS.has(kind)) continue
|
|
2576
|
+
expect([kind, MANTINE_REMEDY.test(GUARD_RULES[kind].message)]).toEqual([kind, false])
|
|
2577
|
+
}
|
|
2578
|
+
})
|
|
2579
|
+
|
|
2580
|
+
it("hidden-inline-style is off — its remedy is raw-html-layout's, word for word", () => {
|
|
2581
|
+
const src =
|
|
2582
|
+
"const s = { display: 'flex', padding: 18 }\nexport const D = () => <div style={s} />"
|
|
2583
|
+
expect(kinds(checkSource(src, PATH, DEFAULT_GUARD_CONFIG))).toContain('hidden-inline-style')
|
|
2584
|
+
expect(kinds(checkSource(src, PATH, TOKENS_ONLY))).not.toContain('hidden-inline-style')
|
|
2585
|
+
})
|
|
2586
|
+
})
|
|
2587
|
+
|
|
2588
|
+
// ── 31. remedy + waiver hint — what a report actually PRINTS ─────────────────
|
|
2589
|
+
|
|
2590
|
+
describe('guardKindRemedy', () => {
|
|
2591
|
+
// `check-theme`'s `Fix:` epilogue read a hand-duplicated subset of these, and the subset was
|
|
2592
|
+
// missing every kind added at 1.20.0. All five of those ship `warn` under grace, so the findings
|
|
2593
|
+
// whose whole argument is "this looks correct and is not" arrived with no argument — only "add a
|
|
2594
|
+
// `theme-allow`", which reads as advice to waive. Sourcing it from the registry retires the
|
|
2595
|
+
// duplicate instead of extending it, and this test is what stops a new kind shipping without one.
|
|
2596
|
+
it('gives every kind a remedy, prefixed with the kind id', () => {
|
|
2597
|
+
for (const kind of Object.keys(GUARD_RULES) as GuardKind[]) {
|
|
2598
|
+
const remedy = guardKindRemedy(kind)
|
|
2599
|
+
expect([kind, remedy.startsWith(`${kind}: `)]).toEqual([kind, true])
|
|
2600
|
+
expect([kind, remedy.length > kind.length + 20]).toEqual([kind, true])
|
|
2601
|
+
}
|
|
2602
|
+
})
|
|
2603
|
+
|
|
2604
|
+
it('covers the five 1.20.0 kinds that the duplicated table did not', () => {
|
|
2605
|
+
for (const kind of [
|
|
2606
|
+
'surface-shadow-override',
|
|
2607
|
+
'css-raw-surface',
|
|
2608
|
+
'inline-font-size',
|
|
2609
|
+
'hidden-inline-style',
|
|
2610
|
+
'theme-allow-unscoped',
|
|
2611
|
+
] as GuardKind[]) {
|
|
2612
|
+
expect([kind, guardKindRemedy(kind)]).toEqual([kind, `${kind}: ${GUARD_RULES[kind].message}`])
|
|
2613
|
+
}
|
|
2614
|
+
})
|
|
2615
|
+
|
|
2616
|
+
// argo: the kind fires on a fixed dock replacing card depth, where the right answer is the
|
|
2617
|
+
// overlay tier — and `SHADOW_KEEPS_CARD_DEPTH` accepts any `--vx-shadow-*`, so the message was
|
|
2618
|
+
// narrower than the check and pointed away from the correct token.
|
|
2619
|
+
it('surface-shadow-override names the overlay tier, not only the card one', () => {
|
|
2620
|
+
expect(guardKindRemedy('surface-shadow-override')).toContain('--vx-shadow-overlay')
|
|
2621
|
+
})
|
|
2622
|
+
})
|
|
2623
|
+
|
|
2624
|
+
describe('guardWaiverHint', () => {
|
|
2625
|
+
// The `Fix:` closer prescribed a `theme-allow` COMMENT for every file class, including the one
|
|
2626
|
+
// 1.20.0 had just started scanning and which cannot hold a comment at all.
|
|
2627
|
+
it('never prescribes a comment to a file that cannot carry one', () => {
|
|
2628
|
+
for (const relPath of ['public/site.webmanifest', 'src/brand.json']) {
|
|
2629
|
+
expect([relPath, /theme-allow[^-]/.test(guardWaiverHint(relPath))]).toEqual([relPath, false])
|
|
2630
|
+
expect([relPath, guardWaiverHint(relPath)]).toEqual([
|
|
2631
|
+
relPath,
|
|
2632
|
+
expect.stringContaining('basalt:theme-allow-file'),
|
|
2633
|
+
])
|
|
2634
|
+
}
|
|
2635
|
+
})
|
|
2636
|
+
|
|
2637
|
+
// rollhook's sharper point: a manifest hex can never be RIGHT, because a manifest cannot
|
|
2638
|
+
// reference a CSS variable — so the value is a hand-copy that drifts. Both consumers that hit it
|
|
2639
|
+
// found a dead colour. The remedy is to stop hand-writing the file.
|
|
2640
|
+
it('points a manifest at basaltAppPlugin first, and the annotation second', () => {
|
|
2641
|
+
const hint = guardWaiverHint('public/site.webmanifest')
|
|
2642
|
+
expect(hint).toContain('basaltAppPlugin')
|
|
2643
|
+
expect(hint.indexOf('basaltAppPlugin')).toBeLessThan(hint.indexOf('basalt:theme-allow-file'))
|
|
2644
|
+
})
|
|
2645
|
+
|
|
2646
|
+
it('prescribes the comment form everywhere a comment exists', () => {
|
|
2647
|
+
for (const relPath of ['src/App.tsx', 'src/app.css', 'index.html']) {
|
|
2648
|
+
expect([relPath, guardWaiverHint(relPath)]).toEqual([
|
|
2649
|
+
relPath,
|
|
2650
|
+
expect.stringContaining('theme-allow <rule-id>'),
|
|
2651
|
+
])
|
|
2652
|
+
}
|
|
2653
|
+
})
|
|
2654
|
+
|
|
2655
|
+
it('names both scopes, so the narrow one is the one a reader reaches for first', () => {
|
|
2656
|
+
const hint = guardWaiverHint('src/App.tsx')
|
|
2657
|
+
expect(hint.indexOf('theme-allow <rule-id>')).toBeLessThan(hint.indexOf('theme-allow-file'))
|
|
2658
|
+
})
|
|
2659
|
+
|
|
2660
|
+
// Every hint has to name a mechanism that EXISTS — the defect it replaces was a hint that did
|
|
2661
|
+
// not. These two assert the guard actually honours what each hint prescribes.
|
|
2662
|
+
it('the JSON hint names a member the guard honours', () => {
|
|
2663
|
+
const f = checkSource(
|
|
2664
|
+
'{\n "basalt:theme-allow-file": "raw-hex — a manifest cannot read a CSS variable",\n "theme_color": "#27272a"\n}\n',
|
|
2665
|
+
'public/site.webmanifest',
|
|
2666
|
+
DEFAULT_GUARD_CONFIG,
|
|
2667
|
+
)
|
|
2668
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
2669
|
+
})
|
|
2670
|
+
|
|
2671
|
+
it('the comment hint names a form the guard honours', () => {
|
|
2672
|
+
expect(kinds(find(`// theme-allow raw-hex — vendor brand\nconst c = '#ff0000'`))).not.toContain(
|
|
2673
|
+
'raw-hex',
|
|
2674
|
+
)
|
|
2675
|
+
expect(
|
|
2676
|
+
kinds(find(`// theme-allow-file raw-hex — vendor brand\nconst c = '#ff0000'`)),
|
|
2677
|
+
).not.toContain('raw-hex')
|
|
2678
|
+
})
|
|
2679
|
+
})
|
|
2680
|
+
|
|
2681
|
+
// ── 32. inline-spacing — a property bag that never reaches CSS ───────────────
|
|
2682
|
+
|
|
2683
|
+
describe('inline-spacing — the style-object test', () => {
|
|
2684
|
+
const inlineSpacing = (src: string): GuardKind[] =>
|
|
2685
|
+
kinds(find(src)).filter((k) => k === 'inline-spacing')
|
|
2686
|
+
|
|
2687
|
+
// argo: `const FIT_BOUNDS_OPTIONS = { padding: 48, duration: 0 }` is a maplibre `fitBounds`
|
|
2688
|
+
// viewport inset measured in MAP pixels. There is no Mantine token that could express it, and
|
|
2689
|
+
// "use p/m/gap with xs..xl" is not a thing that can be done to it. The class recurs in any
|
|
2690
|
+
// consumer wrapping a map or a canvas library — basalt's own shell carries one (a floating-ui
|
|
2691
|
+
// `shift: { padding: 8 }`), which is how it stayed invisible.
|
|
2692
|
+
it('does NOT flag a unitless number in a plain options bag', () => {
|
|
2693
|
+
expect(inlineSpacing('const FIT_BOUNDS_OPTIONS = { padding: 48, duration: 0 }')).toEqual([])
|
|
2694
|
+
})
|
|
2695
|
+
|
|
2696
|
+
it('does NOT flag a unitless number in a call argument', () => {
|
|
2697
|
+
expect(inlineSpacing('map.fitBounds(bounds, { padding: 48 })')).toEqual([])
|
|
2698
|
+
})
|
|
2699
|
+
|
|
2700
|
+
it('does NOT flag a nested non-style bag (floating-ui middleware)', () => {
|
|
2701
|
+
expect(inlineSpacing('const M = { flip: false, shift: { padding: 8 } }')).toEqual([])
|
|
2702
|
+
})
|
|
2703
|
+
|
|
2704
|
+
// Everything the kind was built for still reports.
|
|
2705
|
+
it.each([
|
|
2706
|
+
'<Box style={{ padding: 16 }} />',
|
|
2707
|
+
'<Box\n style={{\n padding: 16,\n }}\n/>',
|
|
2708
|
+
'<Input styles={{ input: { paddingLeft: 12 } }} />',
|
|
2709
|
+
'const wrapperStyle = { padding: 16 }',
|
|
2710
|
+
'const s: CSSProperties = { marginTop: 12 }',
|
|
2711
|
+
])('still flags %s', (src) => {
|
|
2712
|
+
expect([src, inlineSpacing(src)]).toEqual([src, ['inline-spacing']])
|
|
2713
|
+
})
|
|
2714
|
+
|
|
2715
|
+
// The hoisted form the name alone does not give away — resolved from the `style={…}` use site,
|
|
2716
|
+
// the same seam `hidden-inline-style` already walks.
|
|
2717
|
+
it('flags a hoisted object the file hands to a style prop', () => {
|
|
2718
|
+
expect(
|
|
2719
|
+
inlineSpacing('const s = { padding: 16 }\nexport const D = () => <div style={s} />'),
|
|
2720
|
+
).toEqual(['inline-spacing'])
|
|
2721
|
+
})
|
|
2722
|
+
|
|
2723
|
+
// A UNIT is evidence the number is CSS wherever it was written, so it never consults the context.
|
|
2724
|
+
it.each([`const OPTS = { padding: '16px' }`, `const OPTS = { marginTop: '1.5rem' }`])(
|
|
2725
|
+
'%s carries a unit and is flagged regardless of context',
|
|
2726
|
+
(src) => {
|
|
2727
|
+
expect([src, inlineSpacing(src)]).toEqual([src, ['inline-spacing']])
|
|
2728
|
+
},
|
|
2729
|
+
)
|
|
2730
|
+
|
|
2731
|
+
it('CSS is untouched by the test — there is no object context to consult', () => {
|
|
2732
|
+
const f = checkSource('.a { padding: 18px }', 'src/Card.module.css', DEFAULT_GUARD_CONFIG)
|
|
2733
|
+
expect(kinds(f)).toContain('inline-spacing')
|
|
2734
|
+
})
|
|
2735
|
+
})
|