basalt-ui 1.20.0 → 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 +9 -5
- package/CHANGELOG.md +19 -0
- package/MIGRATING.md +147 -49
- package/README.md +42 -8
- package/agent/rules/basalt-app.md +41 -0
- package/agent/rules/basalt-charts.md +15 -12
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-router.md +57 -5
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +73 -16
- package/agent/skills/basalt-app/SKILL.md +4 -1
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/lefthook.yml +48 -15
- package/configs/oxlint-plugin.js +181 -68
- package/configs/oxlint-plugin.test.ts +215 -9
- 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/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/cli/index.d.ts +48 -14
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +328 -66
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +47 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +176 -31
- 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/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +24 -6
- package/dist/tokens/index.d.ts +3 -1
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +5 -1
- package/dist/tokens/palette.d.ts +2 -2
- package/dist/tokens/palette.js +2 -2
- 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 +4 -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 +2 -2
- package/package.json +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +1 -1
- 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/cli/index.ts +607 -128
- package/src/cli/init-sync.test.ts +2 -0
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/toolchain-wiring.test.ts +162 -0
- package/src/cli/waiver-audit.test.ts +131 -0
- package/src/guard/check-source.test.ts +441 -1
- package/src/guard/index.ts +430 -60
- 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.tsx +1 -1
- package/src/shell/index.test.tsx +52 -0
- package/src/shell/index.tsx +64 -13
- package/src/tokens/index.ts +24 -1
- package/src/tokens/palette.ts +2 -2
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite.test.ts +125 -6
- package/src/vite.ts +148 -20
|
@@ -14,10 +14,13 @@ import {
|
|
|
14
14
|
checkSource,
|
|
15
15
|
DEFAULT_GUARD_CONFIG,
|
|
16
16
|
GENERATED_HEADER_LINE,
|
|
17
|
+
guardKindRemedy,
|
|
18
|
+
guardWaiverHint,
|
|
17
19
|
GUARD_RULES,
|
|
18
20
|
TOKENS_ONLY_DISABLED_KINDS,
|
|
21
|
+
unmatchedExemptPatterns,
|
|
19
22
|
} from './index'
|
|
20
|
-
import type { Finding, GuardKind } from './types'
|
|
23
|
+
import type { Finding, GuardConfig, GuardKind } from './types'
|
|
21
24
|
|
|
22
25
|
const PATH = 'src/Dashboard.tsx'
|
|
23
26
|
const CHART_PATH = 'src/charts/kinds/Foo.tsx'
|
|
@@ -1586,6 +1589,99 @@ describe('exemptRules', () => {
|
|
|
1586
1589
|
expect(kinds(f)).toContain('inline-display')
|
|
1587
1590
|
})
|
|
1588
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
|
+
|
|
1589
1685
|
it('does NOT suppress a different kind on the same line/path', () => {
|
|
1590
1686
|
const f = checkSource(`const c = '#ff0000'; ${TEXT}`, 'src/agent/x.tsx', {
|
|
1591
1687
|
...DEFAULT_GUARD_CONFIG,
|
|
@@ -1926,6 +2022,201 @@ describe('theme-allow scoping', () => {
|
|
|
1926
2022
|
})
|
|
1927
2023
|
})
|
|
1928
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
|
+
|
|
1929
2220
|
// ── 23. surface-shadow-override ──────────────────────────────────────────────
|
|
1930
2221
|
|
|
1931
2222
|
describe('surface-shadow-override', () => {
|
|
@@ -2293,3 +2584,152 @@ describe("profile: 'tokens-only'", () => {
|
|
|
2293
2584
|
expect(kinds(checkSource(src, PATH, TOKENS_ONLY))).not.toContain('hidden-inline-style')
|
|
2294
2585
|
})
|
|
2295
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
|
+
})
|