basalt-ui 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/AGENTS.md +11 -5
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +231 -50
  4. package/README.md +57 -10
  5. package/agent/rules/basalt-app.md +49 -2
  6. package/agent/rules/basalt-charts.md +15 -12
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-router.md +57 -5
  9. package/agent/rules/basalt-state.md +21 -3
  10. package/agent/rules/basalt-tokens.md +78 -16
  11. package/agent/skills/basalt-app/SKILL.md +5 -1
  12. package/agent/skills/basalt-charts/SKILL.md +5 -3
  13. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  14. package/configs/lefthook.yml +48 -15
  15. package/configs/oxlint-plugin.js +218 -69
  16. package/configs/oxlint-plugin.test.ts +331 -9
  17. package/dist/charts/kinds/Bars.d.ts +1 -1
  18. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  19. package/dist/charts/kinds/Donut.d.ts +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  22. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  23. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  24. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  25. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  26. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  30. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  31. package/dist/cli/index.d.ts +55 -15
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +521 -71
  34. package/dist/cli/waiver-audit.test.d.ts +2 -0
  35. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  36. package/dist/guard/index.d.ts +114 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +216 -33
  39. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  40. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  41. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  42. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  43. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  44. package/dist/router-tanstack/search-param-store.js +31 -3
  45. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  46. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  47. package/dist/shell/index.d.ts +12 -5
  48. package/dist/shell/index.d.ts.map +1 -1
  49. package/dist/shell/index.js +24 -6
  50. package/dist/tokens/index.d.ts +3 -1
  51. package/dist/tokens/index.d.ts.map +1 -1
  52. package/dist/tokens/index.js +5 -1
  53. package/dist/tokens/palette.d.ts +2 -2
  54. package/dist/tokens/palette.js +2 -2
  55. package/dist/tokens/text-scale.test.d.ts +2 -0
  56. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  57. package/dist/tokens.css +4 -2
  58. package/dist/vite.d.ts +28 -3
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +75 -26
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/kinds/Bars.tsx +1 -1
  64. package/src/charts/kinds/Donut.tsx +1 -1
  65. package/src/charts/kinds/DualPanel.tsx +2 -2
  66. package/src/charts/kinds/Heatmap.tsx +1 -1
  67. package/src/charts/kinds/MultiLine.tsx +1 -1
  68. package/src/charts/kinds/StackedArea.tsx +1 -1
  69. package/src/charts/kinds/ZonedLine.tsx +1 -1
  70. package/src/cli/check-theme.test.ts +35 -0
  71. package/src/cli/help.test.ts +5 -1
  72. package/src/cli/index.ts +978 -133
  73. package/src/cli/init-sync.test.ts +2 -0
  74. package/src/cli/template-ledger.test.ts +1 -1
  75. package/src/cli/toolchain-wiring.test.ts +352 -1
  76. package/src/cli/waiver-audit.test.ts +211 -0
  77. package/src/guard/check-source.test.ts +638 -1
  78. package/src/guard/index.ts +556 -63
  79. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  80. package/src/router-tanstack/search-param-store.test.ts +135 -0
  81. package/src/router-tanstack/search-param-store.ts +90 -8
  82. package/src/shell/app-mobile-nav.tsx +1 -1
  83. package/src/shell/index.test.tsx +52 -0
  84. package/src/shell/index.tsx +64 -13
  85. package/src/tokens/index.ts +24 -1
  86. package/src/tokens/palette.ts +2 -2
  87. package/src/tokens/text-scale.test.ts +51 -0
  88. package/src/vite.test.ts +158 -6
  89. package/src/vite.ts +176 -34
@@ -13,11 +13,18 @@ import { SPACE_SCALE } from '../tokens/palette'
13
13
  import {
14
14
  checkSource,
15
15
  DEFAULT_GUARD_CONFIG,
16
+ findAllowAnnotations,
16
17
  GENERATED_HEADER_LINE,
18
+ guardKindRemedy,
19
+ guardWaiverHint,
17
20
  GUARD_RULES,
21
+ neutralizeAllowAnnotation,
22
+ NEUTRALIZED_ALLOW_TOKEN,
23
+ PLUGIN_RULE_IDS,
18
24
  TOKENS_ONLY_DISABLED_KINDS,
25
+ unmatchedExemptPatterns,
19
26
  } from './index'
20
- import type { Finding, GuardKind } from './types'
27
+ import type { Finding, GuardConfig, GuardKind } from './types'
21
28
 
22
29
  const PATH = 'src/Dashboard.tsx'
23
30
  const CHART_PATH = 'src/charts/kinds/Foo.tsx'
@@ -1586,6 +1593,99 @@ describe('exemptRules', () => {
1586
1593
  expect(kinds(f)).toContain('inline-display')
1587
1594
  })
1588
1595
 
1596
+ // ── Relative paths and globs ────────────────────────────────────────────────
1597
+ //
1598
+ // Only the bare-segment shape existed, and it is the one nobody guesses. rollhook wrote the
1599
+ // obvious, correct `"public/site.webmanifest"`, matched nothing, got no diagnostic, and kept
1600
+ // reporting — while `exemptRules` had just become the ONLY waiver route for a whole file class.
1601
+
1602
+ const HEX_MANIFEST = '{ "theme_color": "#27272a" }'
1603
+ const exemptedAt = (relPath: string, patterns: string[]): GuardKind[] =>
1604
+ kinds(
1605
+ checkSource(HEX_MANIFEST, relPath, {
1606
+ ...DEFAULT_GUARD_CONFIG,
1607
+ exemptRules: { 'raw-hex': patterns },
1608
+ }),
1609
+ )
1610
+
1611
+ it('matches a real relative path to one file', () => {
1612
+ expect(exemptedAt('public/site.webmanifest', ['public/site.webmanifest'])).not.toContain(
1613
+ 'raw-hex',
1614
+ )
1615
+ })
1616
+
1617
+ it('matches a relative directory prefix', () => {
1618
+ expect(exemptedAt('public/icons/theme.json', ['public/icons'])).not.toContain('raw-hex')
1619
+ })
1620
+
1621
+ it("normalizes a leading './' and a trailing '/'", () => {
1622
+ expect(exemptedAt('public/site.webmanifest', ['./public/'])).not.toContain('raw-hex')
1623
+ })
1624
+
1625
+ it('matches a glob, with `*` stopping at a slash', () => {
1626
+ expect(exemptedAt('public/site.webmanifest', ['public/*.webmanifest'])).not.toContain('raw-hex')
1627
+ expect(exemptedAt('public/nested/site.webmanifest', ['public/*.webmanifest'])).toContain(
1628
+ 'raw-hex',
1629
+ )
1630
+ })
1631
+
1632
+ it('matches a `**` glob across slashes', () => {
1633
+ expect(exemptedAt('src/a/b/theme.json', ['src/**/theme.json'])).not.toContain('raw-hex')
1634
+ })
1635
+
1636
+ it('matches a slash-free glob against the BASENAME, like a bare segment does', () => {
1637
+ const f = checkSource(`.a { padding: 18px }`, 'src/features/Card.module.css', {
1638
+ ...DEFAULT_GUARD_CONFIG,
1639
+ exemptRules: { 'inline-spacing': ['*.module.css'] },
1640
+ })
1641
+ expect(kinds(f)).not.toContain('inline-spacing')
1642
+ })
1643
+
1644
+ it('still fires at a path the path/glob does not match', () => {
1645
+ expect(exemptedAt('public/other.webmanifest', ['public/site.webmanifest'])).toContain('raw-hex')
1646
+ })
1647
+
1648
+ // The other half of the fix. An exemption is a CLAIM, and a claim that resolves to nothing reads
1649
+ // as coverage in a config review while enforcing exactly as much as an empty object.
1650
+ describe('unmatchedExemptPatterns', () => {
1651
+ const scanned = ['src/App.tsx', 'public/site.webmanifest']
1652
+
1653
+ it('reports a pattern that matches no scanned file', () => {
1654
+ const cfg = { ...DEFAULT_GUARD_CONFIG, exemptRules: { 'raw-hex': ['public/manifest.json'] } }
1655
+ expect(unmatchedExemptPatterns(cfg, scanned)).toEqual([
1656
+ { kind: 'raw-hex', pattern: 'public/manifest.json', reason: 'no-match' },
1657
+ ])
1658
+ })
1659
+
1660
+ it('says nothing about a pattern that DOES match', () => {
1661
+ const cfg = {
1662
+ ...DEFAULT_GUARD_CONFIG,
1663
+ exemptRules: { 'raw-hex': ['public/site.webmanifest'] },
1664
+ }
1665
+ expect(unmatchedExemptPatterns(cfg, scanned)).toEqual([])
1666
+ })
1667
+
1668
+ it("reports a typo'd KIND — an exemption for a rule that does not exist", () => {
1669
+ const cfg = {
1670
+ ...DEFAULT_GUARD_CONFIG,
1671
+ exemptRules: { 'raw-hexx': ['src'] } as unknown as GuardConfig['exemptRules'],
1672
+ }
1673
+ expect(unmatchedExemptPatterns(cfg, scanned)).toEqual([
1674
+ { kind: 'raw-hexx', pattern: 'src', reason: 'unknown-kind' },
1675
+ ])
1676
+ })
1677
+
1678
+ it('reports the exact shape rollhook wrote before the path matcher existed', () => {
1679
+ const cfg = { ...DEFAULT_GUARD_CONFIG, exemptRules: { 'raw-hex': ['site.webmanifest'] } }
1680
+ expect(unmatchedExemptPatterns(cfg, ['public/site.webmanifest'])).toEqual([])
1681
+ expect(unmatchedExemptPatterns(cfg, ['src/App.tsx'])).toHaveLength(1)
1682
+ })
1683
+
1684
+ it('is silent on an empty config', () => {
1685
+ expect(unmatchedExemptPatterns(DEFAULT_GUARD_CONFIG, scanned)).toEqual([])
1686
+ })
1687
+ })
1688
+
1589
1689
  it('does NOT suppress a different kind on the same line/path', () => {
1590
1690
  const f = checkSource(`const c = '#ff0000'; ${TEXT}`, 'src/agent/x.tsx', {
1591
1691
  ...DEFAULT_GUARD_CONFIG,
@@ -1926,6 +2026,201 @@ describe('theme-allow scoping', () => {
1926
2026
  })
1927
2027
  })
1928
2028
 
2029
+ // ── 22b. theme-allow — the prefix rule and the two scopes, exhaustively ──────
2030
+ //
2031
+ // Three holes in one contract across two rounds, so this is a matrix rather than a case.
2032
+
2033
+ describe('theme-allow — annotation vs prose (the prefix rule)', () => {
2034
+ const HEX = `const c = '#ff0000'`
2035
+
2036
+ /** `comment` on its own line directly above a raw hex — the placement that waives the line below. */
2037
+ function above(comment: string): GuardKind[] {
2038
+ return kinds(find(`${comment}\n${HEX}`))
2039
+ }
2040
+
2041
+ // ── ACCEPTED — every shape a consumer actually writes ──────────────────────
2042
+
2043
+ it.each([
2044
+ '// theme-allow raw-hex — vendor brand',
2045
+ '//theme-allow raw-hex — no space after the marker',
2046
+ ' // theme-allow raw-hex — indented',
2047
+ '/* theme-allow raw-hex — a block comment */',
2048
+ '{/* theme-allow raw-hex — a JSX expression comment */}',
2049
+ '/**\n * theme-allow raw-hex — a docblock gutter line\n */',
2050
+ '// theme-allow',
2051
+ ])('%s is an annotation and waives the line below', (comment) => {
2052
+ expect([comment, above(comment).includes('raw-hex')]).toEqual([comment, false])
2053
+ })
2054
+
2055
+ it('a trailing annotation waives its own line', () => {
2056
+ expect(kinds(find(`${HEX} // theme-allow raw-hex — vendor brand`))).not.toContain('raw-hex')
2057
+ })
2058
+
2059
+ // The reason wrapping to a second line — or a docblock's own `*/` — used to absorb the whole
2060
+ // waiver, so the natural multi-line shape silently waived nothing. argo hit it three times in one
2061
+ // upgrade, each time on a comment that looked correct.
2062
+ it('a wrapped reason still reaches the code line under it', () => {
2063
+ const f = find(
2064
+ `// theme-allow raw-hex — the vendor brand hex, kept in sync with
2065
+ // the marketing site's own palette
2066
+ ${HEX}`,
2067
+ )
2068
+ expect(kinds(f)).not.toContain('raw-hex')
2069
+ })
2070
+
2071
+ it('a blank line ends the block — the waiver does not jump it', () => {
2072
+ expect(
2073
+ kinds(
2074
+ find(`// theme-allow raw-hex — about something else
2075
+
2076
+ ${HEX}`),
2077
+ ),
2078
+ ).toContain('raw-hex')
2079
+ })
2080
+
2081
+ it('a trailing annotation does NOT walk forward — it stays on its own line', () => {
2082
+ expect(kinds(find(`const ok = 1 // theme-allow raw-hex — about this line\n${HEX}`))).toContain(
2083
+ 'raw-hex',
2084
+ )
2085
+ })
2086
+
2087
+ // ── REJECTED — prose that MENTIONS the token ──────────────────────────────
2088
+
2089
+ // The linewatch defect, and the one that made this a false NEGATIVE: a file documenting its own
2090
+ // waivers disarmed itself. Every shape here is lifted from real consumer or basalt source.
2091
+ it.each([
2092
+ '// see the `theme-allow` contract for how exceptions are written',
2093
+ '/* Since 1.20.0 a `theme-allow` that names a rule declares the file */',
2094
+ '/**\n * A `theme-allow` with a reason declares the whole FILE wherever it is written.\n */',
2095
+ '// each chart carries a theme-allow saying why',
2096
+ '// documented at theme-allow raw-hex — this sentence is prose, not a waiver',
2097
+ ])('%s is prose and waives nothing', (comment) => {
2098
+ expect([comment, above(comment).includes('raw-hex')]).toEqual([comment, true])
2099
+ })
2100
+
2101
+ // A kind name written in prose used to consume no id, fall through to `rules: []`, and read as
2102
+ // the blanket form — so a sentence about the reporting rule switched every rule off.
2103
+ it('a comment starting with the longer word `theme-allow-unscoped` is not an annotation', () => {
2104
+ expect(above('// theme-allow-unscoped is what reports a bare waiver')).toContain('raw-hex')
2105
+ })
2106
+
2107
+ it('the token inside a string literal is code, not an annotation', () => {
2108
+ expect(kinds(find(`const doc = 'theme-allow raw-hex — x'\n${HEX}`))).toContain('raw-hex')
2109
+ })
2110
+
2111
+ // Prose is not an annotation for REPORTING either — otherwise every sentence documenting the
2112
+ // escape hatch becomes a theme-allow-unscoped finding (this package's own guard/types.ts).
2113
+ it('prose mentioning the token is not reported as an unscoped waiver', () => {
2114
+ expect(above('// see the `theme-allow` contract')).not.toContain('theme-allow-unscoped')
2115
+ })
2116
+ })
2117
+
2118
+ describe('theme-allow-file — whole-file scope', () => {
2119
+ const SRC = [`const a = '#ff0000'`, `const b = '#00ff00'`, `const c = '#0000ff'`].join('\n')
2120
+
2121
+ it('waives every line in the file, not just the one below it', () => {
2122
+ const f = find(`// theme-allow-file raw-hex — the vendor brand sheet\n${SRC}`)
2123
+ expect(kinds(f)).not.toContain('raw-hex')
2124
+ })
2125
+
2126
+ it('reaches BACKWARDS too — a declaration at the bottom covers the top', () => {
2127
+ const f = find(`${SRC}\n// theme-allow-file raw-hex — the vendor brand sheet`)
2128
+ expect(kinds(f)).not.toContain('raw-hex')
2129
+ })
2130
+
2131
+ it('waives only the kinds it names', () => {
2132
+ const f = find(
2133
+ `// theme-allow-file raw-hex — vendor brand\n${SRC}\n<Box style={{ display: 'flex' }} />`,
2134
+ )
2135
+ expect(kinds(f)).not.toContain('raw-hex')
2136
+ expect(kinds(f)).toContain('inline-display')
2137
+ })
2138
+
2139
+ // File scope is the widest waiver in the contract, so it is the one that has to name what it
2140
+ // waives. A bare `theme-allow-file` is `exemptRules` without the config review.
2141
+ it('a BARE theme-allow-file waives nothing, and is reported as unscoped', () => {
2142
+ const f = find(`// theme-allow-file — everything in here is bespoke\n${SRC}`)
2143
+ expect(kinds(f)).toContain('raw-hex')
2144
+ expect(kinds(f)).toContain('theme-allow-unscoped')
2145
+ })
2146
+
2147
+ it("a theme-allow-file naming a typo'd id waives nothing", () => {
2148
+ const f = find(`// theme-allow-file raw-hexx — vendor brand\n${SRC}`)
2149
+ expect(kinds(f)).toContain('raw-hex')
2150
+ expect(kinds(f)).toContain('theme-allow-unscoped')
2151
+ })
2152
+
2153
+ it('an id-and-reason theme-allow-file is accountable — no unscoped finding', () => {
2154
+ const f = find(`// theme-allow-file raw-hex — the vendor brand sheet\n${SRC}`)
2155
+ expect(kinds(f)).not.toContain('theme-allow-unscoped')
2156
+ })
2157
+
2158
+ // The complement, and the half 1.20.0 did not deliver: naming a rule AND giving a reason used to
2159
+ // promote a waiver to the whole file in the oxlint plugin, so whole-file was the only expressible
2160
+ // scope. The guard's plain `theme-allow` has always been line-scoped, and stays that way.
2161
+ it('a plain theme-allow with an id and a reason stays line-scoped', () => {
2162
+ const f = find(`// theme-allow raw-hex — vendor brand\n${SRC}`)
2163
+ expect(kinds(f).filter((k) => k === 'raw-hex')).toHaveLength(2)
2164
+ })
2165
+ })
2166
+
2167
+ /** A two-hex manifest with `extra` (an annotation member, or nothing) inserted at the top. */
2168
+ const MANIFEST = (extra: string) =>
2169
+ `{\n${extra} "theme_color": "#27272a",\n "background_color": "#27272a"\n}\n`
2170
+
2171
+ describe('theme-allow in JSON — the "basalt:theme-allow" member', () => {
2172
+ it('a webmanifest hex reports with no annotation — the 1.20.0 baseline', () => {
2173
+ const f = checkSource(MANIFEST(''), 'public/site.webmanifest', DEFAULT_GUARD_CONFIG)
2174
+ expect(kinds(f).filter((k) => k === 'raw-hex')).toHaveLength(2)
2175
+ })
2176
+
2177
+ // JSON has no comments, so from 1.20.0 the whole file class was permanently unwaivable and the
2178
+ // printed remedy prescribed a comment the file cannot carry. Both consumers that hit it fell back
2179
+ // to a blanket `exemptRules` entry — the shape the release was spent retiring.
2180
+ it('a "basalt:theme-allow-file" member declares the file', () => {
2181
+ const f = checkSource(
2182
+ MANIFEST(' "basalt:theme-allow-file": "raw-hex — a manifest cannot read a CSS variable",\n'),
2183
+ 'public/site.webmanifest',
2184
+ DEFAULT_GUARD_CONFIG,
2185
+ )
2186
+ expect(kinds(f)).not.toContain('raw-hex')
2187
+ })
2188
+
2189
+ it('a "basalt:theme-allow" member is line-scoped — its own line and the one below', () => {
2190
+ const f = checkSource(
2191
+ MANIFEST(' "basalt:theme-allow": "raw-hex — the theme color only",\n'),
2192
+ 'public/site.webmanifest',
2193
+ DEFAULT_GUARD_CONFIG,
2194
+ )
2195
+ expect(kinds(f).filter((k) => k === 'raw-hex')).toHaveLength(1)
2196
+ })
2197
+
2198
+ it('the member must name its ids — a bare one waives nothing', () => {
2199
+ const f = checkSource(
2200
+ MANIFEST(' "basalt:theme-allow-file": "everything in here is bespoke",\n'),
2201
+ 'public/site.webmanifest',
2202
+ DEFAULT_GUARD_CONFIG,
2203
+ )
2204
+ expect(kinds(f)).toContain('raw-hex')
2205
+ })
2206
+
2207
+ it('works in a plain .json too, not only a manifest', () => {
2208
+ const f = checkSource(
2209
+ MANIFEST(' "basalt:theme-allow-file": "raw-hex — vendor brand data",\n'),
2210
+ 'src/brand.json',
2211
+ DEFAULT_GUARD_CONFIG,
2212
+ )
2213
+ expect(kinds(f)).not.toContain('raw-hex')
2214
+ })
2215
+
2216
+ // The member is JSON-only: in TS that string is a value, and a value that could switch off a rule
2217
+ // is the hole `stripComments` exists to close.
2218
+ it('the same string in a TS file is code, not an annotation', () => {
2219
+ const f = find(`const cfg = { "basalt:theme-allow-file": "raw-hex — x" }\nconst c = '#ff0000'`)
2220
+ expect(kinds(f)).toContain('raw-hex')
2221
+ })
2222
+ })
2223
+
1929
2224
  // ── 23. surface-shadow-override ──────────────────────────────────────────────
1930
2225
 
1931
2226
  describe('surface-shadow-override', () => {
@@ -2293,3 +2588,345 @@ describe("profile: 'tokens-only'", () => {
2293
2588
  expect(kinds(checkSource(src, PATH, TOKENS_ONLY))).not.toContain('hidden-inline-style')
2294
2589
  })
2295
2590
  })
2591
+
2592
+ // ── 31. remedy + waiver hint — what a report actually PRINTS ─────────────────
2593
+
2594
+ describe('guardKindRemedy', () => {
2595
+ // `check-theme`'s `Fix:` epilogue read a hand-duplicated subset of these, and the subset was
2596
+ // missing every kind added at 1.20.0. All five of those ship `warn` under grace, so the findings
2597
+ // whose whole argument is "this looks correct and is not" arrived with no argument — only "add a
2598
+ // `theme-allow`", which reads as advice to waive. Sourcing it from the registry retires the
2599
+ // duplicate instead of extending it, and this test is what stops a new kind shipping without one.
2600
+ it('gives every kind a remedy, prefixed with the kind id', () => {
2601
+ for (const kind of Object.keys(GUARD_RULES) as GuardKind[]) {
2602
+ const remedy = guardKindRemedy(kind)
2603
+ expect([kind, remedy.startsWith(`${kind}: `)]).toEqual([kind, true])
2604
+ expect([kind, remedy.length > kind.length + 20]).toEqual([kind, true])
2605
+ }
2606
+ })
2607
+
2608
+ it('covers the five 1.20.0 kinds that the duplicated table did not', () => {
2609
+ for (const kind of [
2610
+ 'surface-shadow-override',
2611
+ 'css-raw-surface',
2612
+ 'inline-font-size',
2613
+ 'hidden-inline-style',
2614
+ 'theme-allow-unscoped',
2615
+ ] as GuardKind[]) {
2616
+ expect([kind, guardKindRemedy(kind)]).toEqual([kind, `${kind}: ${GUARD_RULES[kind].message}`])
2617
+ }
2618
+ })
2619
+
2620
+ // argo: the kind fires on a fixed dock replacing card depth, where the right answer is the
2621
+ // overlay tier — and `SHADOW_KEEPS_CARD_DEPTH` accepts any `--vx-shadow-*`, so the message was
2622
+ // narrower than the check and pointed away from the correct token.
2623
+ it('surface-shadow-override names the overlay tier, not only the card one', () => {
2624
+ expect(guardKindRemedy('surface-shadow-override')).toContain('--vx-shadow-overlay')
2625
+ })
2626
+ })
2627
+
2628
+ describe('guardWaiverHint', () => {
2629
+ // The `Fix:` closer prescribed a `theme-allow` COMMENT for every file class, including the one
2630
+ // 1.20.0 had just started scanning and which cannot hold a comment at all.
2631
+ it('never prescribes a comment to a file that cannot carry one', () => {
2632
+ for (const relPath of ['public/site.webmanifest', 'src/brand.json']) {
2633
+ expect([relPath, /theme-allow[^-]/.test(guardWaiverHint(relPath))]).toEqual([relPath, false])
2634
+ expect([relPath, guardWaiverHint(relPath)]).toEqual([
2635
+ relPath,
2636
+ expect.stringContaining('basalt:theme-allow-file'),
2637
+ ])
2638
+ }
2639
+ })
2640
+
2641
+ // rollhook's sharper point: a manifest hex can never be RIGHT, because a manifest cannot
2642
+ // reference a CSS variable — so the value is a hand-copy that drifts. Both consumers that hit it
2643
+ // found a dead colour. The remedy is to stop hand-writing the file.
2644
+ it('points a manifest at basaltAppPlugin first, and the annotation second', () => {
2645
+ const hint = guardWaiverHint('public/site.webmanifest')
2646
+ expect(hint).toContain('basaltAppPlugin')
2647
+ expect(hint.indexOf('basaltAppPlugin')).toBeLessThan(hint.indexOf('basalt:theme-allow-file'))
2648
+ })
2649
+
2650
+ it('prescribes the comment form everywhere a comment exists', () => {
2651
+ for (const relPath of ['src/App.tsx', 'src/app.css', 'index.html']) {
2652
+ expect([relPath, guardWaiverHint(relPath)]).toEqual([
2653
+ relPath,
2654
+ expect.stringContaining('theme-allow <rule-id>'),
2655
+ ])
2656
+ }
2657
+ })
2658
+
2659
+ it('names both scopes, so the narrow one is the one a reader reaches for first', () => {
2660
+ const hint = guardWaiverHint('src/App.tsx')
2661
+ expect(hint.indexOf('theme-allow <rule-id>')).toBeLessThan(hint.indexOf('theme-allow-file'))
2662
+ })
2663
+
2664
+ // Every hint has to name a mechanism that EXISTS — the defect it replaces was a hint that did
2665
+ // not. These two assert the guard actually honours what each hint prescribes.
2666
+ it('the JSON hint names a member the guard honours', () => {
2667
+ const f = checkSource(
2668
+ '{\n "basalt:theme-allow-file": "raw-hex — a manifest cannot read a CSS variable",\n "theme_color": "#27272a"\n}\n',
2669
+ 'public/site.webmanifest',
2670
+ DEFAULT_GUARD_CONFIG,
2671
+ )
2672
+ expect(kinds(f)).not.toContain('raw-hex')
2673
+ })
2674
+
2675
+ it('the comment hint names a form the guard honours', () => {
2676
+ expect(kinds(find(`// theme-allow raw-hex — vendor brand\nconst c = '#ff0000'`))).not.toContain(
2677
+ 'raw-hex',
2678
+ )
2679
+ expect(
2680
+ kinds(find(`// theme-allow-file raw-hex — vendor brand\nconst c = '#ff0000'`)),
2681
+ ).not.toContain('raw-hex')
2682
+ })
2683
+ })
2684
+
2685
+ // ── 32. inline-spacing — a property bag that never reaches CSS ───────────────
2686
+
2687
+ describe('inline-spacing — the style-object test', () => {
2688
+ const inlineSpacing = (src: string): GuardKind[] =>
2689
+ kinds(find(src)).filter((k) => k === 'inline-spacing')
2690
+
2691
+ // argo: `const FIT_BOUNDS_OPTIONS = { padding: 48, duration: 0 }` is a maplibre `fitBounds`
2692
+ // viewport inset measured in MAP pixels. There is no Mantine token that could express it, and
2693
+ // "use p/m/gap with xs..xl" is not a thing that can be done to it. The class recurs in any
2694
+ // consumer wrapping a map or a canvas library — basalt's own shell carries one (a floating-ui
2695
+ // `shift: { padding: 8 }`), which is how it stayed invisible.
2696
+ it('does NOT flag a unitless number in a plain options bag', () => {
2697
+ expect(inlineSpacing('const FIT_BOUNDS_OPTIONS = { padding: 48, duration: 0 }')).toEqual([])
2698
+ })
2699
+
2700
+ it('does NOT flag a unitless number in a call argument', () => {
2701
+ expect(inlineSpacing('map.fitBounds(bounds, { padding: 48 })')).toEqual([])
2702
+ })
2703
+
2704
+ it('does NOT flag a nested non-style bag (floating-ui middleware)', () => {
2705
+ expect(inlineSpacing('const M = { flip: false, shift: { padding: 8 } }')).toEqual([])
2706
+ })
2707
+
2708
+ // Everything the kind was built for still reports.
2709
+ it.each([
2710
+ '<Box style={{ padding: 16 }} />',
2711
+ '<Box\n style={{\n padding: 16,\n }}\n/>',
2712
+ '<Input styles={{ input: { paddingLeft: 12 } }} />',
2713
+ 'const wrapperStyle = { padding: 16 }',
2714
+ 'const s: CSSProperties = { marginTop: 12 }',
2715
+ ])('still flags %s', (src) => {
2716
+ expect([src, inlineSpacing(src)]).toEqual([src, ['inline-spacing']])
2717
+ })
2718
+
2719
+ // The hoisted form the name alone does not give away — resolved from the `style={…}` use site,
2720
+ // the same seam `hidden-inline-style` already walks.
2721
+ it('flags a hoisted object the file hands to a style prop', () => {
2722
+ expect(
2723
+ inlineSpacing('const s = { padding: 16 }\nexport const D = () => <div style={s} />'),
2724
+ ).toEqual(['inline-spacing'])
2725
+ })
2726
+
2727
+ // A UNIT is evidence the number is CSS wherever it was written, so it never consults the context.
2728
+ it.each([`const OPTS = { padding: '16px' }`, `const OPTS = { marginTop: '1.5rem' }`])(
2729
+ '%s carries a unit and is flagged regardless of context',
2730
+ (src) => {
2731
+ expect([src, inlineSpacing(src)]).toEqual([src, ['inline-spacing']])
2732
+ },
2733
+ )
2734
+
2735
+ it('CSS is untouched by the test — there is no object context to consult', () => {
2736
+ const f = checkSource('.a { padding: 18px }', 'src/Card.module.css', DEFAULT_GUARD_CONFIG)
2737
+ expect(kinds(f)).toContain('inline-spacing')
2738
+ })
2739
+ })
2740
+
2741
+ // ── theme-allow comment shapes ───────────────────────────────────────────────────────────────────
2742
+
2743
+ /**
2744
+ * The prefix contract, pinned per comment shape.
2745
+ *
2746
+ * Three holes have been found in this rule in three rounds — two false positives and one false
2747
+ * NEGATIVE (a docblock documenting a file's waivers disarmed the file). So every shape a consumer
2748
+ * actually writes gets a fixture, and the oxlint plugin's copy is pinned identically in
2749
+ * `configs/oxlint-plugin.test.ts`: the two halves of one contract must agree on what an annotation
2750
+ * IS, and may differ only on which rules each can judge.
2751
+ */
2752
+ describe('theme-allow comment shapes', () => {
2753
+ const REASON = 'deliberate legacy value'
2754
+ const waives = (relPath: string, src: string): boolean =>
2755
+ checkSource(src, relPath, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex').length === 0
2756
+
2757
+ it.each([
2758
+ ['own line //', PATH, `// theme-allow raw-hex — ${REASON}\nconst a = '#f00'\n`],
2759
+ [
2760
+ 'indented //',
2761
+ PATH,
2762
+ `function f() {\n // theme-allow raw-hex — ${REASON}\n const a = '#f00'\n}\n`,
2763
+ ],
2764
+ ['trailing //', PATH, `const a = '#f00' // theme-allow raw-hex — ${REASON}\n`],
2765
+ ['trailing block', PATH, `const a = '#f00' /* theme-allow raw-hex — ${REASON} */\n`],
2766
+ [
2767
+ 'JSX expression comment',
2768
+ PATH,
2769
+ `export const C = () => (\n <div>\n {/* theme-allow raw-hex — ${REASON} */}\n <span style={{ color: '#f00' }} />\n </div>\n)\n`,
2770
+ ],
2771
+ [
2772
+ 'JSX expression comment, token on its own wrapped line',
2773
+ PATH,
2774
+ `export const C = () => (\n <div>\n {/*\n theme-allow raw-hex — ${REASON} */}\n <span style={{ color: '#f00' }} />\n </div>\n)\n`,
2775
+ ],
2776
+ ['block gutter *', PATH, `/**\n * theme-allow raw-hex — ${REASON}\n */\nconst a = '#f00'\n`],
2777
+ ['docblock opener /**', PATH, `/** theme-allow raw-hex — ${REASON} */\nconst a = '#f00'\n`],
2778
+ ['css trailing', 'src/a.css', `a { color: #f00; /* theme-allow raw-hex — ${REASON} */ }\n`],
2779
+ [
2780
+ 'css reflowed onto continuation lines',
2781
+ 'src/a.css',
2782
+ `a {\n color: var(\n --x,\n #f00\n ); /* theme-allow raw-hex — ${REASON} */\n}\n`,
2783
+ ],
2784
+ [
2785
+ 'html comment',
2786
+ 'index.html',
2787
+ `<!-- theme-allow raw-hex — ${REASON} -->\n<b style="color: #f00"></b>\n`,
2788
+ ],
2789
+ [
2790
+ 'html trailing comment',
2791
+ 'index.html',
2792
+ `<b style="color: #f00"></b> <!-- theme-allow raw-hex — ${REASON} -->\n`,
2793
+ ],
2794
+ [
2795
+ 'JSON member form',
2796
+ 'public/site.webmanifest',
2797
+ `{\n "basalt:theme-allow-file": "raw-hex — ${REASON}",\n "theme_color": "#f00"\n}\n`,
2798
+ ],
2799
+ ])('%s waives', (_name, relPath, src) => {
2800
+ expect(waives(relPath as string, src as string)).toBe(true)
2801
+ })
2802
+
2803
+ // The false negative the prefix rule closed: a sentence that MENTIONS the token is not one.
2804
+ it.each([
2805
+ [
2806
+ 'mid-sentence in a line comment',
2807
+ `// we normally write a theme-allow raw-hex here\nconst a = '#f00'\n`,
2808
+ ],
2809
+ [
2810
+ 'mid-sentence in a docblock',
2811
+ `/**\n * Each value below is escaped with a theme-allow raw-hex annotation.\n */\nconst a = '#f00'\n`,
2812
+ ],
2813
+ ['inside a string literal', `const doc = 'theme-allow raw-hex'\nconst a = '#f00'\n`],
2814
+ ])('%s does NOT waive', (_name, src) => {
2815
+ expect(waives(PATH, src as string)).toBe(false)
2816
+ })
2817
+
2818
+ // The narrowness of the `*/}` exception: a `}` that is REAL code keeps its trailing
2819
+ // classification, so the annotation does not reach the statement below it.
2820
+ it('a trailing annotation on a real closing brace stays on its own line', () => {
2821
+ const src = `function f() {\n const a = 1\n} // theme-allow raw-hex — ${REASON}\nconst b = '#0f0'\n`
2822
+ expect(
2823
+ checkSource(src, PATH, DEFAULT_GUARD_CONFIG)
2824
+ .filter((f) => f.kind === 'raw-hex')
2825
+ .map((f) => f.line),
2826
+ ).toEqual([4])
2827
+ })
2828
+
2829
+ // A trailing annotation is scoped to its OWN line — otherwise `const a = '#f00' // theme-allow`
2830
+ // would silently waive the statement below it too.
2831
+ it('a trailing annotation does not reach the line below', () => {
2832
+ const src = `const a = '#f00' // theme-allow raw-hex — ${REASON}\nconst b = '#0f0'\n`
2833
+ expect(
2834
+ checkSource(src, PATH, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex'),
2835
+ ).toHaveLength(1)
2836
+ })
2837
+ })
2838
+
2839
+ // ── findAllowAnnotations / neutralizeAllowAnnotation ─────────────────────────────────────────────
2840
+
2841
+ describe('findAllowAnnotations', () => {
2842
+ const cfg = DEFAULT_GUARD_CONFIG
2843
+
2844
+ it('classifies each named id by whose reach it falls in', () => {
2845
+ const src = [
2846
+ `// theme-allow raw-hex — a guard kind`,
2847
+ `const a = '#f00'`,
2848
+ `// theme-allow hand-rolled-plot — a plugin rule, outside checkSource's reach`,
2849
+ `const b = 1`,
2850
+ `// theme-allow raw-hexx — a typo`,
2851
+ `const c = 2`,
2852
+ `// theme-allow`,
2853
+ `const d = 3`,
2854
+ ].join('\n')
2855
+ const sites = findAllowAnnotations(src, PATH, cfg)
2856
+
2857
+ expect(sites.map((s) => s.line)).toEqual([1, 3, 5, 7])
2858
+ expect(sites[0]).toMatchObject({ guardKinds: ['raw-hex'], pluginRules: [], bare: false })
2859
+ expect(sites[1]).toMatchObject({ guardKinds: [], pluginRules: ['hand-rolled-plot'] })
2860
+ expect(sites[2]).toMatchObject({ rules: [], unknownRules: ['raw-hexx'], bare: false })
2861
+ expect(sites[3]).toMatchObject({ bare: true, hasReason: false })
2862
+ })
2863
+
2864
+ it('records the file-declaration form once, at the line it is written on', () => {
2865
+ const src = `// theme-allow-file raw-hex — the whole palette fixture\nconst a = '#f00'\n`
2866
+ expect(findAllowAnnotations(src, PATH, cfg)).toMatchObject([
2867
+ { line: 1, scope: 'file', guardKinds: ['raw-hex'] },
2868
+ ])
2869
+ })
2870
+
2871
+ it('carries the source line verbatim, and finds nothing in a file with no annotation', () => {
2872
+ expect(findAllowAnnotations(`const a = '#f00'\n`, PATH, cfg)).toEqual([])
2873
+ expect(
2874
+ findAllowAnnotations(` // theme-allow raw-hex — why not\nconst a = 1\n`, PATH, cfg)[0]?.text,
2875
+ ).toBe(' // theme-allow raw-hex — why not')
2876
+ })
2877
+
2878
+ // It shares `collectAllowAnnotations` with `checkSource`, so it cannot list a line the scan does
2879
+ // not honour — the mirrored regex `--audit-allows` carried was already one alternation behind.
2880
+ it('agrees with checkSource about what is prose and what is an annotation', () => {
2881
+ expect(
2882
+ findAllowAnnotations(`// a theme-allow raw-hex goes here\nconst a = 1\n`, PATH, cfg),
2883
+ ).toEqual([])
2884
+ expect(
2885
+ findAllowAnnotations(`/** theme-allow raw-hex — deliberate */\nconst a = 1\n`, PATH, cfg),
2886
+ ).toHaveLength(1)
2887
+ })
2888
+
2889
+ it('finds the JSON member form in a manifest', () => {
2890
+ const src = `{\n "basalt:theme-allow-file": "raw-hex — brand color"\n}\n`
2891
+ expect(findAllowAnnotations(src, 'public/site.webmanifest', cfg)).toMatchObject([
2892
+ { line: 2, scope: 'file', guardKinds: ['raw-hex'] },
2893
+ ])
2894
+ })
2895
+ })
2896
+
2897
+ describe('neutralizeAllowAnnotation', () => {
2898
+ const cfg = DEFAULT_GUARD_CONFIG
2899
+
2900
+ it('neutralizes ONE annotation and reveals exactly what it suppressed', () => {
2901
+ const src = [
2902
+ `const a = '#f00' // theme-allow raw-hex — first`,
2903
+ `const b = '#0f0' // theme-allow raw-hex — second`,
2904
+ ].join('\n')
2905
+ expect(checkSource(src, PATH, cfg).filter((f) => f.kind === 'raw-hex')).toEqual([])
2906
+
2907
+ const probe = neutralizeAllowAnnotation(src, 1, cfg)
2908
+ expect(probe).toContain(NEUTRALIZED_ALLOW_TOKEN)
2909
+ expect(
2910
+ checkSource(probe, PATH, cfg)
2911
+ .filter((f) => f.kind === 'raw-hex')
2912
+ .map((f) => f.line),
2913
+ ).toEqual([1])
2914
+ })
2915
+
2916
+ it('leaves a line with no token, and an out-of-range line, untouched', () => {
2917
+ const src = `const a = 1\nconst b = 2\n`
2918
+ expect(neutralizeAllowAnnotation(src, 1, cfg)).toBe(src)
2919
+ expect(neutralizeAllowAnnotation(src, 99, cfg)).toBe(src)
2920
+ })
2921
+
2922
+ it('the neutralized token shares no substring with the annotation it replaces', () => {
2923
+ expect(NEUTRALIZED_ALLOW_TOKEN).not.toContain(cfg.allowComment)
2924
+ expect(NEUTRALIZED_ALLOW_TOKEN.includes('theme-allow')).toBe(false)
2925
+ })
2926
+ })
2927
+
2928
+ describe('PLUGIN_RULE_IDS', () => {
2929
+ it('names no guard kind — the two registries are disjoint by construction', () => {
2930
+ for (const id of PLUGIN_RULE_IDS) expect(Object.hasOwn(GUARD_RULES, id)).toBe(false)
2931
+ })
2932
+ })