basalt-ui 1.19.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/AGENTS.md +19 -1
  2. package/CHANGELOG.md +39 -0
  3. package/MIGRATING.md +326 -0
  4. package/README.md +87 -9
  5. package/agent/rules/basalt-app.md +84 -0
  6. package/agent/rules/basalt-charts.md +32 -17
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-mantine.md +25 -10
  9. package/agent/rules/basalt-router.md +79 -15
  10. package/agent/rules/basalt-state.md +21 -3
  11. package/agent/rules/basalt-tokens.md +132 -6
  12. package/agent/skills/basalt-app/SKILL.md +45 -6
  13. package/agent/skills/basalt-charts/SKILL.md +5 -3
  14. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  15. package/configs/check.yml +11 -4
  16. package/configs/lefthook.yml +59 -7
  17. package/configs/oxlint-plugin.js +633 -30
  18. package/configs/oxlint-plugin.test.ts +594 -15
  19. package/configs/oxlint.json +10 -1
  20. package/dist/agent/stick-to-bottom.js +1 -1
  21. package/dist/agent-chat/thread-detail-panel.js +1 -1
  22. package/dist/agent-chat/thread-message.js +2 -2
  23. package/dist/agent-chat/tool-chip.js +1 -1
  24. package/dist/charts/kinds/Bars.d.ts +1 -1
  25. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  26. package/dist/charts/kinds/Donut.d.ts +1 -1
  27. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  28. package/dist/charts/kinds/Donut.js +1 -1
  29. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  30. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  31. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  32. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  33. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  34. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  35. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  36. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  37. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  38. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  39. package/dist/charts/primitives/ChartLegend.js +2 -2
  40. package/dist/charts/primitives/ChartTooltip.js +1 -1
  41. package/dist/cli/index.d.ts +146 -20
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +901 -101
  44. package/dist/cli/toolchain-wiring.test.d.ts +2 -0
  45. package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
  46. package/dist/cli/waiver-audit.test.d.ts +2 -0
  47. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  48. package/dist/dashboard/delta-badge.js +2 -2
  49. package/dist/dashboard/stat-card.js +1 -1
  50. package/dist/data/virtual-list.js +5 -5
  51. package/dist/guard/index.d.ts +101 -2
  52. package/dist/guard/index.d.ts.map +1 -1
  53. package/dist/guard/index.js +474 -62
  54. package/dist/guard/types.d.ts +25 -5
  55. package/dist/guard/types.d.ts.map +1 -1
  56. package/dist/register.d.ts +25 -0
  57. package/dist/register.d.ts.map +1 -1
  58. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  59. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  60. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  61. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  62. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  63. package/dist/router-tanstack/search-param-store.js +31 -3
  64. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  65. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  66. package/dist/shell/app-mobile-nav.module.css +11 -3
  67. package/dist/shell/index.d.ts +12 -5
  68. package/dist/shell/index.d.ts.map +1 -1
  69. package/dist/shell/index.js +24 -6
  70. package/dist/surfaces.d.ts +1 -1
  71. package/dist/surfaces.js +1 -1
  72. package/dist/tokens/index.d.ts +5 -3
  73. package/dist/tokens/index.d.ts.map +1 -1
  74. package/dist/tokens/index.js +7 -1
  75. package/dist/tokens/palette.d.ts +35 -27
  76. package/dist/tokens/palette.d.ts.map +1 -1
  77. package/dist/tokens/palette.js +24 -18
  78. package/dist/tokens/text-scale.test.d.ts +2 -0
  79. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  80. package/dist/tokens.css +21 -17
  81. package/dist/vite-color-mix.js +2 -2
  82. package/dist/vite.d.ts +24 -2
  83. package/dist/vite.d.ts.map +1 -1
  84. package/dist/vite.js +54 -13
  85. package/llms.txt +3 -3
  86. package/package.json +4 -3
  87. package/src/agent/stick-to-bottom.tsx +1 -1
  88. package/src/agent-chat/thread-detail-panel.tsx +1 -1
  89. package/src/agent-chat/thread-message.tsx +3 -3
  90. package/src/agent-chat/tool-chip.tsx +1 -1
  91. package/src/charts/kinds/Bars.tsx +1 -1
  92. package/src/charts/kinds/Donut.tsx +3 -2
  93. package/src/charts/kinds/DualPanel.tsx +2 -2
  94. package/src/charts/kinds/Heatmap.tsx +1 -1
  95. package/src/charts/kinds/MultiLine.tsx +1 -1
  96. package/src/charts/kinds/StackedArea.tsx +1 -1
  97. package/src/charts/kinds/ZonedLine.tsx +1 -1
  98. package/src/charts/primitives/ChartLegend.tsx +2 -2
  99. package/src/charts/primitives/ChartTooltip.tsx +1 -1
  100. package/src/cli/index.ts +1614 -164
  101. package/src/cli/init-sync.test.ts +21 -3
  102. package/src/cli/template-ledger.test.ts +1 -1
  103. package/src/cli/tokens-css.test.ts +27 -14
  104. package/src/cli/toolchain-wiring.test.ts +856 -0
  105. package/src/cli/waiver-audit.test.ts +131 -0
  106. package/src/dashboard/delta-badge.tsx +3 -3
  107. package/src/dashboard/stat-card.tsx +1 -1
  108. package/src/data/virtual-list.tsx +8 -8
  109. package/src/guard/check-source.test.ts +1104 -12
  110. package/src/guard/index.ts +1106 -103
  111. package/src/guard/types.ts +29 -4
  112. package/src/register.ts +32 -0
  113. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  114. package/src/router-tanstack/search-param-store.test.ts +135 -0
  115. package/src/router-tanstack/search-param-store.ts +90 -8
  116. package/src/shell/app-mobile-nav.module.css +11 -3
  117. package/src/shell/app-mobile-nav.tsx +1 -1
  118. package/src/shell/index.test.tsx +52 -0
  119. package/src/shell/index.tsx +64 -13
  120. package/src/surfaces.ts +1 -1
  121. package/src/theme/density-relations.test.ts +8 -5
  122. package/src/theme/spacing.test.ts +2 -0
  123. package/src/theme-toggle/index.tsx +2 -2
  124. package/src/tokens/index.ts +28 -3
  125. package/src/tokens/palette.ts +24 -18
  126. package/src/tokens/text-scale.test.ts +51 -0
  127. package/src/vite-color-mix.ts +2 -2
  128. package/src/vite.test.ts +125 -6
  129. package/src/vite.ts +148 -20
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ./guard — headless policy core. Mantine-free, dependency-free.
3
3
  *
4
- * GUARD_RULES: the closed registry of all 19 violation kinds.
4
+ * GUARD_RULES: the closed registry of all 25 violation kinds.
5
5
  * checkSource: pure (text, relPath, cfg) → Finding[]. No FS, no walk, no console.
6
6
  */
7
7
  import type { Finding, GuardConfig, GuardKind, GuardSeverity } from './types'
@@ -11,17 +11,32 @@ export type { Finding, GuardConfig, GuardKind, GuardSeverity }
11
11
  // ── Static regex consts ──────────────────────────────────────────────────────────────────────────
12
12
 
13
13
  const HEX = /#[0-9a-fA-F]{3,8}\b/g
14
- const FUNC = /\b(?:rgba?|hsla?)\(/g
14
+
15
+ /**
16
+ * A raw `rgb()`/`rgba()`/`hsl()`/`hsla()` color function.
17
+ *
18
+ * The `(?!\s*\$\{)` guard drops a call whose FIRST channel is a `${…}` interpolation — that is a
19
+ * color being COMPUTED, not chosen: an imaging app reporting a sampled pixel
20
+ * (`` `rgb(${px[0]}, ${px[1]}, ${px[2]})` ``), a debug log quoting one, a readout label. There is
21
+ * no token that could be correct for a measurement, and the three cases image-gen reported were
22
+ * all this shape. A literal first channel (`rgba(0, 0, 0, ${o})`) is still a hardcoded color with
23
+ * a variable alpha, and still flags — `alpha(token, a)` is exactly the escape for it.
24
+ */
25
+ const FUNC = /\b(?:rgba?|hsla?)\((?!\s*\$\{)/g
15
26
  const LOCALSTORAGE_THEME = /localStorage\s*\.\s*getItem\s*\(\s*['"]theme['"]\s*\)/g
16
27
 
17
28
  // A hardcoded fontFamily/font-family literal — camelCase (JSX prop / object property, quoted
18
29
  // value) or kebab-case (CSS text in a template literal, quoted OR bare value — kebab-case is never
19
30
  // a valid unquoted JS identifier, so a bare `font-family: Inter` can only be literal CSS text).
20
- // The escape is a `var(...)` reference restricted to the two entry-point prefixes
21
- // (`--basalt-font-*` / `--mantine-font-family-*`) — any OTHER var() reference still flags, so the
22
- // single-entry-point invariant holds. A CSS-wide keyword (inherit/initial/unset/revert) never
23
- // flags either: it defers to the cascade rather than hardcoding a font.
24
- const RAW_FONT_FAMILY_VAR_ESCAPE = /var\(\s*--(?:basalt-font-|mantine-font-family-)/.source
31
+ // The escape is ANY `var(--…)` reference. It used to be restricted to the two entry-point prefixes
32
+ // (`--basalt-font-*` / `--mantine-font-family-*`), which reported `font-family: var(--font-sans)`
33
+ // — a variable REFERENCE — as "a hardcoded fontFamily literal", with a fix pointing at the
34
+ // React-only `createBasaltTheme`. A framework-free consumer defining its own custom property IS
35
+ // routing through a variable; the single-entry-point invariant is a doctrine for basalt's own
36
+ // theme layer, not something this regex can tell apart from a legitimate consumer indirection.
37
+ // A CSS-wide keyword (inherit/initial/unset/revert) never flags either: it defers to the cascade
38
+ // rather than hardcoding a font.
39
+ const RAW_FONT_FAMILY_VAR_ESCAPE = /var\(\s*--/.source
25
40
  const RAW_FONT_FAMILY_KEYWORD = /(?:inherit|initial|unset|revert)\b/.source
26
41
  const RAW_FONT_FAMILY = new RegExp(
27
42
  `\\bfontFamily\\s*[:=]\\s*\\{?(?!['"\`]?\\s*${RAW_FONT_FAMILY_VAR_ESCAPE})(?!['"\`]?\\s*${RAW_FONT_FAMILY_KEYWORD})['"\`][^'"\`]+['"\`]` +
@@ -39,7 +54,77 @@ const RAW_FONT_FAMILY = new RegExp(
39
54
  const SURFACE_BORDER =
40
55
  /\bborder(?:Top|Bottom|Left|Right)?\s*:\s*(?!['"`]?[^'"`]*(?:var\(|\$\{))(?!['"`]?(?:none|transparent|inherit|unset|revert)\b)['"`][^'"`]+['"`]/g
41
56
  const SURFACE_RADIUS = /\bborderRadius\s*:\s*(?:[0-9]+|['"`](?!\s*var\()[^'"`]*[0-9])/g
42
- const SURFACE_SHADOW = /\bboxShadow\s*:\s*(?!['"`]?[^'"`]*(?:var\(|\$\{))['"`][^'"`]+['"`]/g
57
+
58
+ /**
59
+ * A `boxShadow` / `box-shadow` declaration with its whole value captured, so the value can be
60
+ * JUDGED rather than merely pattern-excluded.
61
+ *
62
+ * The old `SURFACE_SHADOW` skipped any value containing `var(` or `${`, which reads as "already
63
+ * system-routed" and is wrong for the shape a token-fluent consumer actually writes:
64
+ * `` boxShadow: `0 0 0 2px ${VX.accent}` `` on a `Paper` is built entirely from tokens and still
65
+ * REPLACES `--vx-shadow-card`, leaving that one card with no depth while every other card in the
66
+ * app has it. Composing WITH card depth (`` `${VX.shadowCard}, 0 0 0 1px …` ``) is the legitimate
67
+ * shape and the one this must not touch — so the discriminator is whether the value still names
68
+ * card depth, not whether it contains a token at all. See `surface-shadow-override`.
69
+ */
70
+ const SHADOW_DECL = /\b(?:boxShadow|box-shadow)\s*:\s*(['"`])((?:[^'"`\\]|\\.)*)\1/g
71
+ /** A shadow value that still carries basalt's card depth — composes with it rather than replacing it. */
72
+ const SHADOW_KEEPS_CARD_DEPTH = /--vx-shadow-|\bVX\.shadow/
73
+ /** A shadow value routed through SOME variable/interpolation (vs. a hardcoded literal). */
74
+ const SHADOW_IS_COMPOSED = /var\(|\$\{/
75
+
76
+ /**
77
+ * The kebab-case surface literals a CSS Module writes. `raw-surface` is camelCase-only — the TSX
78
+ * inline-style dialect — so `border-radius: 4px` beside a flagged `borderRadius: 2` was invisible.
79
+ * `src/guard/index.ts`'s `INLINE_SPACING` records that the same asymmetry was deliberately fixed
80
+ * for spacing; this is that fix for the surface kinds.
81
+ *
82
+ * Reaching CSS is a NEW file type for these checks, so they land under their own kind
83
+ * (`css-raw-surface`, warn for one minor) instead of widening `raw-surface` — see
84
+ * {@link GuardSeverity}. `border`/`border-top` are deliberately NOT covered: nearly every CSS
85
+ * module in every consumer declares one, and the token answer (`var(--vx-surface-border)`) is a
86
+ * COLOR, not a whole shorthand, so the rule would be advice nobody can act on line-by-line.
87
+ */
88
+ const CSS_SURFACE_RADIUS = /(?<![\w-])border-radius\s*:\s*([^;}]+)/g
89
+ const CSS_SURFACE_SHADOW = /(?<![\w-])box-shadow\s*:\s*([^;}]+)/g
90
+
91
+ /** The largest CSS radius literal treated as a sub-scale micro-corner, in px — below the 4px floor. */
92
+ const MICRO_RADIUS_CEILING_PX = 3
93
+ /** Radius values that name a SHAPE rather than a surface corner — a circle and the pill idiom. */
94
+ const SHAPE_RADIUS_VALUES = new Set(['50%', '9999px', '999px', '100%'])
95
+
96
+ /**
97
+ * Is this `border-radius` value already system-routed or sub-scale? `var(...)` components are
98
+ * dropped first (same treatment as `isSubScaleCssSpacing`), then a value survives when every
99
+ * remaining literal is a shape keyword or a length at/below {@link MICRO_RADIUS_CEILING_PX}.
100
+ */
101
+ function isAllowedCssRadius(value: string): boolean {
102
+ const rest = value.replace(/var\([^)]*\)/g, ' ').trim()
103
+ if (rest === '') return true
104
+ return rest.split(/\s+/).every((part) => {
105
+ if (SHAPE_RADIUS_VALUES.has(part)) return true
106
+ const literal = /^[-+]?(\d+(?:\.\d+)?|\.\d+)(px|rem)?$/.exec(part)
107
+ if (literal === null) return false
108
+ const px = literal[2] === 'rem' ? Number(literal[1]) * ROOT_FONT_SIZE_PX : Number(literal[1])
109
+ return px <= MICRO_RADIUS_CEILING_PX
110
+ })
111
+ }
112
+
113
+ /**
114
+ * An inline `fontSize` / `font-size` literal — the `check-theme` counterpart to the oxlint plugin's
115
+ * `basalt/no-raw-font-size`. A consumer whose CI runs only `check-theme` (the documented
116
+ * `configs/check.yml` step) never saw a `style={{ fontSize: 11 }}` at all.
117
+ *
118
+ * Unitless integers and `px` only, matching React's inline-style convention and CSS's absolute
119
+ * form. `rem`/`em`/`%` are deliberately out: they are ratios against something else, and the type
120
+ * scale has no opinion on a relative step.
121
+ */
122
+ // The `(?<![\w-;])` guard is what keeps this out of query strings and URL fragments: argo builds a
123
+ // Static Maps legend as `…;fontSize:10;dpi:96`, which is a REMOTE renderer's parameter, not a type
124
+ // choice, and no `--vx-text-*` could be correct for it. A declaration written by a formatter always
125
+ // has whitespace or an opening brace in front of the property; a packed key/value string does not.
126
+ const INLINE_FONT_SIZE =
127
+ /(?<![\w-;])font-?[Ss]ize\s*:\s*['"]?(\d+(?:\.\d+)?)(?:px)?['"]?(?=[,;\s}]|$)/g
43
128
 
44
129
  // A `Card` / `Paper` opening tag carrying `withBorder`. Card depth is `--vx-shadow-card`, whose 1px
45
130
  // ring lives INSIDE the shadow value; `withBorder` therefore adds a SECOND, real `border` property
@@ -72,12 +157,42 @@ const OFF_SYSTEM_SURFACE_VAR = /var\(--mantine-color-(gray|dark)-\d/g
72
157
  const MANTINE_SHADE_INDEX =
73
158
  /\b(?:color|c|bg|backgroundColor|borderColor)\s*=\s*\{?\s*['"][a-z]+\.\d\b|var\(--mantine-color-(?!gray-|dark-)[a-z]+-\d/g
74
159
 
75
- // Raw lowercase JSX layout/surface element with inline style and layout/surface prop — line-scoped.
76
- const RAW_HTML_TAG = /<(?:div|span|section|header|nav|footer|aside|main|article|ul|ol)\b/
160
+ /**
161
+ * A raw lowercase JSX layout/surface element's own OPENING TAG — bounded full-text scan, the same
162
+ * shape `CARD_SURFACE_TAG` uses.
163
+ *
164
+ * This kind used to be a three-condition conjunction evaluated per LINE, so the identical
165
+ * violation was reported or not depending purely on how the formatter had broken the tag:
166
+ * `<div style={{ position: 'relative', … }}>` on one line fired, while the same `<div>` with
167
+ * `className=` and `style=` on separate lines did not. `card-with-border` already had the answer.
168
+ */
169
+ const RAW_HTML_TAG_NAME = '<(?:div|span|section|header|nav|footer|aside|main|article|ul|ol)\\b'
170
+ const RAW_HTML_TAG = new RegExp(RAW_HTML_TAG_NAME)
171
+ const RAW_HTML_LAYOUT_TAG = new RegExp(`${RAW_HTML_TAG_NAME}(?:=>|[^>])*?>`, 'g')
77
172
  const INLINE_STYLE = /style=\{\{/
173
+ /** `style={someStyleConst}` — the hoisted form that defeats every text-local check. */
174
+ const STYLE_IDENTIFIER = /style=\{\s*([A-Za-z_$][\w$]*)\s*\}/
78
175
  const LAYOUT_SURFACE_PROP =
79
176
  /\b(?:display|padding|margin|gap|flex|grid|border|background|width|height)\b/
80
177
 
178
+ /**
179
+ * The object body of a `const <name> = { … }` declaration, or `''` when there is none.
180
+ *
181
+ * Hoisting the style object out of the tag (`const wrapperStyle: CSSProperties = {…}` then
182
+ * `style={wrapperStyle}`) is the general escape from every text-local kind, and it is what a
183
+ * formatter nudges people toward. One non-nested lookup closes the common case; a style object
184
+ * assembled across statements is out of reach of a regex scanner and stays out of reach.
185
+ */
186
+ function hoistedObjectBody(codeText: string, name: string): string {
187
+ // `name` comes from source text and reaches a RegExp — a `$`-prefixed identifier is legal JS and
188
+ // is an anchor in a pattern, so it gets escaped rather than trusted.
189
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
190
+ const decl = new RegExp(`\\b(?:const|let|var)\\s+${escaped}\\b[^=;]*=\\s*\\{([^}]*)\\}`).exec(
191
+ codeText,
192
+ )
193
+ return decl?.[1] ?? ''
194
+ }
195
+
81
196
  // Spacing/sizing literals — anchored on the property name; `var()` and a plain `0` pass.
82
197
  //
83
198
  // Composed rather than written out because it must cover BOTH spellings of the same property: the
@@ -132,11 +247,93 @@ const INLINE_SPACING = new RegExp(
132
247
  * there is no prop form, and the emitted `--vx-space-*` set is basalt's own component one-offs
133
248
  * (`--vx-space-toc-sub-indent`), not a consumer surface. Without this escape a consumer's CSS
134
249
  * module had no legal way to write a 4px cluster gap: no token to reach for, `theme-allow` on every
135
- * line (which `basalt-tokens.md` explicitly warns against), or `exemptRules` — which matches whole
136
- * path segments and so cannot express `*.module.css`.
250
+ * line (which `basalt-tokens.md` explicitly warns against), or `exemptRules` — which at the time
251
+ * matched whole path segments only and so could not express `*.module.css` (it can now, see
252
+ * `exemptPatternMatches`; the escape stays because a per-file exemption is still the wrong shape
253
+ * for a value that has no token).
137
254
  */
138
255
  const MICRO_SPACING_CEILING_PX = 10
139
256
 
257
+ /**
258
+ * Object-literal openers that make the properties under them CSS.
259
+ *
260
+ * A UNITLESS spacing number is only a length by React's inline-style convention, and that
261
+ * convention only holds inside a style object. `const FIT_BOUNDS_OPTIONS = { padding: 48, duration:
262
+ * 0 }` is a maplibre viewport inset measured in MAP pixels — there is no Mantine token that could
263
+ * express it, and "use p/m/gap with xs..xl" is not a thing that can be done to it. The same shape
264
+ * arrives from every canvas/map/layout library a consumer wraps, so this is a class, not a case.
265
+ *
266
+ * A value carrying a UNIT (`'12px'`, `1.5rem`) is exempt from this test entirely — a unit IS the
267
+ * evidence that the number is CSS, wherever it was written.
268
+ */
269
+ /** `style={{`, `styles={{`, `sx={{`, `css={{` — the object under it IS CSS by construction. */
270
+ const STYLE_ATTRIBUTE_OPENER = /\b(?:style|styles|sx|css)\s*=\s*\{?\s*$/
271
+
272
+ /** `const wrapperStyle: CSSProperties = {` — the hoisted form; name and type are both evidence. */
273
+ const OBJECT_DECLARATION = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::([^=]*))?=\s*$/
274
+
275
+ /** A name that says "these are styles" on its own, without a `style={…}` use site to confirm it. */
276
+ const STYLE_OBJECT_NAME = /(?:Style|Styles|Css)$|^(?:style|styles|css|sx)$/
277
+
278
+ /** A type annotation that says the same thing — `CSSProperties`, `CSSObject`, `MantineStyleProp`. */
279
+ const STYLE_OBJECT_TYPE = /\bCSS(?:Properties|Object)\b|\bStyle(?:s|Prop)?\b/
280
+
281
+ /** `style={someStyleConst}` / `styles={parts}` — collected file-wide, so a hoisted object is known. */
282
+ const STYLE_BOUND_IDENTIFIER = /\b(?:style|styles|sx|css)=\{\s*([A-Za-z_$][\w$]*)\s*\}/g
283
+
284
+ /** A property key introducing a nested object — `input: {`, `'&:hover': {`, `root: {`. */
285
+ const NESTED_OBJECT_KEY = /(?:[\w$]+|'[^']*'|"[^"]*"|\[[^\]]*\])\s*:\s*$/
286
+
287
+ /** How far back {@link isStyleObjectContext} walks before giving up. Bounded so a huge file can't stall. */
288
+ const MAX_STYLE_CONTEXT_SCAN = 4000
289
+
290
+ /**
291
+ * Is the match at `at` inside an inline-style object literal?
292
+ *
293
+ * Walks out through enclosing `{` openers (a nested part like `styles={{ input: { … } }}` keeps
294
+ * walking) and asks what introduced the outermost one: a style ATTRIBUTE, or a `const` whose name
295
+ * says styles or which some `style={…}` in the file binds. Everything else — a call argument, an
296
+ * options bag, a plain record — is not CSS.
297
+ *
298
+ * Consulted only for a UNITLESS number, where the alternative is reporting a violation with no
299
+ * possible remedy. A value carrying a unit is CSS wherever it was written and never reaches here.
300
+ */
301
+ function isStyleObjectContext(
302
+ codeText: string,
303
+ at: number,
304
+ styleIdentifiers: ReadonlySet<string>,
305
+ ): boolean {
306
+ let depth = 0
307
+ const stop = Math.max(0, at - MAX_STYLE_CONTEXT_SCAN)
308
+ for (let i = at - 1; i >= stop; i--) {
309
+ const ch = codeText[i]
310
+ if (ch === '}') depth++
311
+ else if (ch === '{') {
312
+ if (depth > 0) {
313
+ depth--
314
+ continue
315
+ }
316
+ const before = codeText.slice(Math.max(0, i - 120), i)
317
+ if (STYLE_ATTRIBUTE_OPENER.test(before)) return true
318
+ const declaration = OBJECT_DECLARATION.exec(before)
319
+ if (declaration !== null) {
320
+ const [, name = '', annotation = ''] = declaration
321
+ return (
322
+ STYLE_OBJECT_NAME.test(name) ||
323
+ STYLE_OBJECT_TYPE.test(annotation) ||
324
+ styleIdentifiers.has(name)
325
+ )
326
+ }
327
+ // `{{` — a JSX expression container around the style object; keep walking out to the `=`.
328
+ if (before.endsWith('{')) continue
329
+ // A nested style part (`styles={{ input: { … } }}`) — the key names a part, not a context.
330
+ if (NESTED_OBJECT_KEY.test(before)) continue
331
+ return false
332
+ }
333
+ }
334
+ return false
335
+ }
336
+
140
337
  /** The root font size `rem` values resolve against — the same 16 the token layer's `pxRem` uses. */
141
338
  const ROOT_FONT_SIZE_PX = 16
142
339
 
@@ -281,26 +478,42 @@ export const DEFAULT_GUARD_CONFIG: GuardConfig = {
281
478
  * someone remembering. Adding a kind here is part of shipping it; removing the entry IS the
282
479
  * promotion, and belongs in its own commit so the changelog says enforcement got stricter.
283
480
  *
284
- * `mantine-shade-index` landed in 1.7.0 and is the sole entry — it rejects `c="yellow.7"`, which
285
- * previously passed, and the one real consumer has several. Its promotion is **deferred to 1.10.0**,
286
- * for the second time and for the same reason both times: the runway is measured in consumer
287
- * upgrades, not in version numbers. 1.8.0 shipped the same day as 1.7.0, so promoting there would
288
- * have given no runway at all. 1.9.0 then carried the chart-layer batch the same consumer asked
289
- * for — bundling a build-breaking promotion into the minor they upgrade for the fixes turns a
290
- * routine bump into an unplanned refactor, which is precisely what the grace minor exists to
291
- * prevent. 1.10.0 is the first minor they can take the promotion on its own. Promote it by deleting
292
- * the entry (its own commit).
481
+ * The runway is measured in consumer UPGRADES, not in version numbers: `mantine-shade-index`
482
+ * (the table's first tenant, promoted and gone) sat here across two minors because 1.8.0 shipped
483
+ * the same day as 1.7.0 and 1.9.0 carried the chart batch the same consumer was upgrading for —
484
+ * bundling a build-breaking promotion into the minor they take for the fixes turns a routine bump
485
+ * into an unplanned refactor, which is exactly what the grace minor exists to prevent. The five
486
+ * entries below all landed together in the round-4 guard minor and all promote one minor later.
293
487
  *
294
488
  * @example
295
489
  * const GRACE: Partial<Record<GuardKind, string>> = {
296
490
  * 'raw-font-family': 'introduced 1.4.0 — promote to error in 1.5.0',
297
491
  * }
298
492
  */
299
- const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, string>> = {}
493
+ const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, string>> = {
494
+ 'theme-allow-unscoped':
495
+ 'introduced in the round-4 guard minor — promote to error one minor later. Every consumer has ' +
496
+ 'bare `theme-allow` comments today (basalt itself has ~20), and landing this as an error would ' +
497
+ 'fail every one of their builds on an upgrade they took for the fixes.',
498
+ 'surface-shadow-override':
499
+ 'introduced in the round-4 guard minor — promote to error one minor later. Catches the shape a ' +
500
+ 'TOKEN-FLUENT consumer writes, so the code it rejects is code that looks correct and passed ' +
501
+ 'every previous release.',
502
+ 'css-raw-surface':
503
+ 'introduced in the round-4 guard minor — promote to error one minor later. The surface kinds ' +
504
+ 'reaching CSS at all is the "existing kind, new file type" case the doctrine names explicitly.',
505
+ 'inline-font-size':
506
+ 'introduced in the round-4 guard minor — promote to error one minor later. The oxlint plugin ' +
507
+ 'has enforced the same thing as `basalt/no-raw-font-size` for a while, so a consumer running ' +
508
+ 'both sees nothing new; one running check-theme alone gets a minor of runway.',
509
+ 'hidden-inline-style':
510
+ 'introduced in the round-4 guard minor — promote by DELETING the kind and folding its two ' +
511
+ 'scans into `raw-html-layout`, which is where they belong once the widening has had its minor.',
512
+ }
300
513
 
301
514
  /** A kind's effective severity: consumer override first, then the grace table, then `error`. */
302
515
  function severityOf(kind: GuardKind, cfg: GuardConfig): GuardSeverity {
303
- return cfg.severity?.[kind] ?? (kind in GRACE_PERIOD_KINDS ? 'warn' : 'error')
516
+ return cfg.severity?.[kind] ?? (Object.hasOwn(GRACE_PERIOD_KINDS, kind) ? 'warn' : 'error')
304
517
  }
305
518
 
306
519
  /** The `Finding.text` cap, in characters — a minified CSS line must not blow up the report. */
@@ -325,47 +538,626 @@ function isChartFile(relPath: string): boolean {
325
538
  return relPath.includes('/charts/') && !AXIS_WRAPPER_FILE.test(relPath)
326
539
  }
327
540
 
541
+ /**
542
+ * Kinds that inherit another kind's `exemptRules` entry. A grace-minor kind that exists only to
543
+ * carry a WIDENING of an established kind is the same rule to a consumer, so an exemption written
544
+ * for the parent must cover it — otherwise the widening arrives as noise in exactly the paths
545
+ * someone already decided the rule does not apply to. The entry disappears with the merge.
546
+ */
547
+ const EXEMPT_RULE_ALIASES: Partial<Record<GuardKind, GuardKind>> = {
548
+ 'hidden-inline-style': 'raw-html-layout',
549
+ 'surface-shadow-override': 'raw-surface',
550
+ 'css-raw-surface': 'raw-surface',
551
+ }
552
+
553
+ /** `./` and any trailing `/` are noise — `'agent'`, `'agent/'` and `'./agent'` are one pattern. */
554
+ function normalizeExemptPattern(pattern: string): string {
555
+ return pattern.trim().replace(/^\.\//, '').replace(/\/+$/, '')
556
+ }
557
+
558
+ /** `*` stops at a `/`, `**` does not, `?` is one non-`/` character. Everything else is literal. */
559
+ function exemptGlobToRegExp(pattern: string): RegExp {
560
+ const body = pattern
561
+ .split(/(\*\*\/|\*\*|\*|\?)/)
562
+ .map((part) => {
563
+ if (part === '**/') return '(?:.*/)?'
564
+ if (part === '**') return '.*'
565
+ if (part === '*') return '[^/]*'
566
+ if (part === '?') return '[^/]'
567
+ return part.replace(/[.+^${}()|[\]\\]/g, '\\$&')
568
+ })
569
+ .join('')
570
+ return new RegExp(`^${body}$`)
571
+ }
572
+
573
+ /**
574
+ * Does one `exemptRules` pattern match `relPath`?
575
+ *
576
+ * Three shapes, tried in the order a consumer reaches for them:
577
+ *
578
+ * 1. **A relative path** — `public/site.webmanifest` for one file, `public` or `src/agent` for
579
+ * everything under a directory.
580
+ * 2. **A glob** — `public/*.webmanifest`, `**\/*.module.css`; a glob with no `/` in it is also
581
+ * tried against the BASENAME, so `*.module.css` works the way `site.webmanifest` does.
582
+ * 3. **A bare path segment** — the legacy shape, matching a whole segment anywhere in the path.
583
+ *
584
+ * Only (3) existed, and it was the one shape nobody guesses: rollhook wrote the obvious, correct
585
+ * `"public/site.webmanifest"`, matched nothing, got no diagnostic, and kept reporting. That is a
586
+ * silent no-op exemption, and it became load-bearing the minute `.webmanifest`/`.json` entered the
587
+ * scan — see {@link unmatchedExemptPatterns} for the other half of the fix.
588
+ */
589
+ function exemptPatternMatches(pattern: string, relPath: string): boolean {
590
+ const p = normalizeExemptPattern(pattern)
591
+ if (p === '') return false
592
+ if (relPath === p || relPath.startsWith(`${p}/`)) return true
593
+ if (/[*?]/.test(p)) {
594
+ const re = exemptGlobToRegExp(p)
595
+ if (re.test(relPath)) return true
596
+ if (!p.includes('/') && re.test(relPath.split('/').pop() ?? '')) return true
597
+ return false
598
+ }
599
+ return !p.includes('/') && relPath.split('/').includes(p)
600
+ }
601
+
328
602
  /**
329
603
  * Whether `kind` is exempted at `relPath` via `cfg.exemptRules` — the config-driven, per-rule
330
- * counterpart to `isChartFile`'s hardcoded path scoping. A pattern matches when `relPath` split on
331
- * `/` includes it as a WHOLE segment (a trailing `/` is stripped first, so `'agent'` and `'agent/'`
332
- * are equivalent); a substring match against one longer segment (e.g. `'age'` against `agent`)
333
- * never counts.
604
+ * counterpart to `isChartFile`'s hardcoded path scoping.
334
605
  */
335
606
  function isRuleExempt(
336
607
  kind: GuardKind,
337
608
  relPath: string,
338
609
  exemptRules: GuardConfig['exemptRules'],
339
610
  ): boolean {
611
+ const alias = EXEMPT_RULE_ALIASES[kind]
612
+ if (alias !== undefined && isRuleExempt(alias, relPath, exemptRules)) return true
340
613
  const patterns = exemptRules?.[kind]
341
614
  if (patterns === undefined || patterns.length === 0) return false
342
- const segments = relPath.split('/')
343
- return patterns.some((pattern) => segments.includes(pattern.replace(/\/$/, '')))
615
+ return patterns.some((pattern) => exemptPatternMatches(pattern, relPath))
616
+ }
617
+
618
+ /** One `exemptRules` entry that suppressed nothing, and why — see {@link unmatchedExemptPatterns}. */
619
+ export type UnmatchedExemptPattern = {
620
+ readonly kind: string
621
+ readonly pattern: string
622
+ readonly reason: 'unknown-kind' | 'no-match'
344
623
  }
345
624
 
346
625
  /**
347
- * The allow-comment skip the full-text tag-scoped scans re-apply on their own resolved report
348
- * line: a line carrying the allow-comment is never a violation. Pure-comment lines need no
349
- * special case here anymore — they are handled upstream by `stripComments` (matches simply can't
350
- * land on blanked-out comment text), so this only ever runs against the ORIGINAL (unstripped)
351
- * line, since the allow-comment annotation itself lives inside a comment.
626
+ * Every `exemptRules` entry that matched no scanned file, so a run can say so instead of passing.
627
+ *
628
+ * An exemption is a claim ("this rule does not apply here"), and a claim that resolves to nothing is
629
+ * not harmless — it reads as coverage in a config review while enforcing exactly as much as an empty
630
+ * object. rollhook's `["public/site.webmanifest"]` was correct, matched nothing under the
631
+ * segments-only matcher, and reported no diagnostic; the finding it was written for kept failing the
632
+ * build with the config sitting right there looking like the answer. A silent no-op exemption is the
633
+ * same false-green family as a guard that reports green while enforcing nothing.
634
+ *
635
+ * Called once per run by the CLI with every relative path it scanned. Kind keys are checked too: a
636
+ * typo'd kind name is an exemption for a rule that does not exist.
352
637
  */
353
- function isSkippedLine(line: string, cfg: GuardConfig): boolean {
354
- return line.includes(cfg.allowComment)
638
+ export function unmatchedExemptPatterns(
639
+ cfg: GuardConfig,
640
+ scannedRelPaths: readonly string[],
641
+ ): UnmatchedExemptPattern[] {
642
+ const out: UnmatchedExemptPattern[] = []
643
+ for (const [kind, patterns] of Object.entries(cfg.exemptRules ?? {})) {
644
+ const known = Object.hasOwn(GUARD_RULES, kind)
645
+ for (const pattern of patterns ?? []) {
646
+ if (!known) {
647
+ out.push({ kind, pattern, reason: 'unknown-kind' })
648
+ continue
649
+ }
650
+ if (scannedRelPaths.some((relPath) => exemptPatternMatches(pattern, relPath))) continue
651
+ out.push({ kind, pattern, reason: 'no-match' })
652
+ }
653
+ }
654
+ return out
355
655
  }
356
656
 
357
- /** The 2 comment/regex dialects the stripper understands, resolved from the file's own extension. */
358
- type GuardSyntax = 'ts' | 'css'
657
+ // ── theme-allow annotations ──────────────────────────────────────────────────────────────────────
658
+
659
+ /**
660
+ * The oxlint plugin's rule ids. Not guard kinds — they can never suppress a `checkSource` finding —
661
+ * but they ARE valid ids in a `theme-allow`, so an annotation scoped to one of them must parse as
662
+ * accountable rather than be reported as unscoped. Kept as a literal list because the plugin is a
663
+ * standalone `.js` file this module must not import (see the plugin's own header).
664
+ */
665
+ const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
666
+ 'no-raw-font-size',
667
+ 'raw-size-literal',
668
+ 'card-inset',
669
+ 'chart-in-raw-surface',
670
+ 'hand-rolled-plot',
671
+ 'chart-legend-literal',
672
+ 'hand-rolled-shell',
673
+ 'shadow-basalt-export',
674
+ 'raw-scroll-container',
675
+ 'visx-boundary',
676
+ 'visx-tooltip',
677
+ 'token-layer-boundary',
678
+ // These three honour `basalt-agent-allow`, never `theme-allow` — but they are still REAL ids, so
679
+ // a `theme-allow ai-sdk-major` must parse as a (useless) scoped annotation rather than as prose.
680
+ 'agent-resume-guard',
681
+ 'agent-no-raw-usechat',
682
+ 'ai-sdk-major',
683
+ ])
684
+
685
+ /** The shortest string accepted as a written reason — enough to exclude a stray separator. */
686
+ const MIN_ALLOW_REASON_LENGTH = 4
687
+
688
+ /**
689
+ * How far one annotation reaches.
690
+ *
691
+ * `line` is the default and covers the placements in {@link collectAllowAnnotations}. `file` is the
692
+ * whole-file declaration, and it has to be SPELLED (`theme-allow-file`) rather than inferred: at
693
+ * 1.20.0 a `theme-allow <id> — <reason>` written anywhere in a file was silently promoted to a file
694
+ * declaration by the oxlint plugin, so naming a rule AND giving a reason — the exact shape the
695
+ * guard's own message asks for — was the only legal annotation and it was always whole-file. A
696
+ * consumer who wanted to waive one node and stay policed on the rest could not say so.
697
+ */
698
+ type AllowScope = 'line' | 'file'
699
+
700
+ /**
701
+ * One parsed `theme-allow` annotation.
702
+ *
703
+ * `rules` empty AND `unknownRules` empty is the legacy bare form, and only that covers every kind —
704
+ * and only at `line` scope, never `file` (see {@link annotationCovers}).
705
+ * `unknownRules` holds words that occupied the rule-id slot but name no rule — a typo. Its presence
706
+ * is what makes the parse FAIL CLOSED: `theme-allow raw-hexx — reason` used to consume no id, fall
707
+ * through to `rules: []`, and be read as the blanket form, so one mistyped character escalated a
708
+ * scoped waiver into a whole-line one. A waiver that names a rule must never be more permissive
709
+ * than the same waiver spelled correctly.
710
+ */
711
+ type AllowAnnotation = {
712
+ readonly rules: readonly string[]
713
+ readonly unknownRules: readonly string[]
714
+ readonly hasReason: boolean
715
+ readonly scope: AllowScope
716
+ }
717
+
718
+ /** One annotation at the (1-based) line it was WRITTEN on — where `theme-allow-unscoped` reports. */
719
+ type AllowDeclaration = { readonly line: number; readonly annotation: AllowAnnotation }
720
+
721
+ /**
722
+ * Parse the text following the allow token on one line.
723
+ *
724
+ * The grammar is `theme-allow [<rule-id>[, <rule-id>]…] [<separator>] <reason>`. Ids are consumed
725
+ * while they are KNOWN (a guard kind or a plugin rule, optionally `basalt/`-prefixed). A word that
726
+ * occupies the id slot but names no rule ENDS the list as an UNKNOWN id rather than starting the
727
+ * reason — see {@link AllowAnnotation.unknownRules} for why that direction is the safe one.
728
+ *
729
+ * A prose reason therefore has to be introduced the way every annotation in the wild already writes
730
+ * it — with a separator (`—`, `–`, `-`, `:`) — which is what keeps `theme-allow: bespoke mono
731
+ * micro-label` a blanket waiver while `theme-allow raw-hexx — …` waives nothing.
732
+ *
733
+ * **The id slot closes at the first space that no comma opened.** The FIRST word after the token
734
+ * always sits in the id slot, because that is where a typo'd id lands and failing closed there is
735
+ * the whole point. After a resolved id, only a `,` keeps the list open: an unknown word arriving
736
+ * across a comma is a claimed id (`raw-hex, raw-surfacee`) and gets recorded, an unknown word
737
+ * arriving across a space is prose (`raw-surface sub-scale legend corner`, one em-dash away from
738
+ * annotations this package itself ships) and starts the reason. Reporting the second as a typo was
739
+ * a live false-positive class on a waiver that was in fact scoped and reasoned.
740
+ *
741
+ * `Object.hasOwn`, not `in`: `in` walks the prototype chain, so a reason beginning with the word
742
+ * `constructor` (or `toString`, `valueOf`, …) resolved as a real rule id and silently scoped the
743
+ * waiver to a kind that does not exist.
744
+ */
745
+ function parseAllowAnnotation(rest: string, scope: AllowScope): AllowAnnotation {
746
+ const rules: string[] = []
747
+ const unknownRules: string[] = []
748
+ let remainder = rest.replace(/^[\s,]+/, '')
749
+ let inIdSlot = true
750
+ for (;;) {
751
+ const token = /^(?:basalt\/)?([a-z][a-z0-9-]*)(?=$|[\s,:—–])/.exec(remainder)
752
+ const id = token?.[1]
753
+ if (token === null || id === undefined) break
754
+ if (!(Object.hasOwn(GUARD_RULES, id) || PLUGIN_RULE_IDS.has(id))) {
755
+ if (inIdSlot) unknownRules.push(id)
756
+ break
757
+ }
758
+ rules.push(id)
759
+ const after = remainder.slice(token[0].length)
760
+ remainder = after.replace(/^[\s,]+/, '')
761
+ inIdSlot = /^\s*,/.test(after)
762
+ }
763
+ const reason = remainder.replace(/^(?:—|–|-{1,2}|:)\s*/, '').trim()
764
+ return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH, scope }
765
+ }
766
+
767
+ /** How far back a trailing CSS annotation reaches for the declaration it terminates. */
768
+ const MAX_CSS_CONTINUATION_LINES = 8
769
+
770
+ /** How far a comment-only annotation reaches forward through the rest of its own comment block. */
771
+ const MAX_COMMENT_BLOCK_LINES = 8
772
+
773
+ /**
774
+ * A comment opener sitting immediately before the annotation token.
775
+ *
776
+ * `stripComments` is the primary evidence that an occurrence is inside a comment, but it is
777
+ * deliberately biased toward treating an ambiguous `/` as division (see its own doc), and JSX makes
778
+ * that bias fire constantly: in `<Box p={18} /> // theme-allow` the `/` of `/>` follows a `}`, so
779
+ * the stripper reads a regex literal and the trailing comment survives unstripped. This prefix test
780
+ * is the second witness — cheap, local, and exact for every shape that actually occurs.
781
+ */
782
+ const COMMENT_OPENER_BEFORE = /(?:\/\/|\/\*|<!--)\s*$/
783
+
784
+ /** A line that is NOTHING but a comment — the placement that waives the line below it. */
785
+ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
786
+
787
+ /**
788
+ * Everything allowed between the comment opener (or the start of the line) and the annotation
789
+ * token, for the token to count as an ANNOTATION rather than prose that mentions one.
790
+ *
791
+ * **This gates WAIVING as well as reporting.** It used to gate only `theme-allow-unscoped`, on the
792
+ * argument that a stricter waiver could take a build down over comment placement. The cost of that
793
+ * asymmetry was the opposite and worse: a comment that merely MENTIONS the token in prose parsed as
794
+ * the bare blanket form and switched every kind off on the line below it. linewatch documented its
795
+ * own waivers in a docblock and thereby disarmed the file — the third hole found in this contract
796
+ * in two rounds, and the only one that was a false NEGATIVE.
797
+ *
798
+ * So an annotation must START its comment, or start a line inside one: after `//`, `/*`, `<!--`, a
799
+ * block-comment gutter `*`, or nothing but leading whitespace. Everything a consumer actually
800
+ * writes qualifies; a sentence about the escape hatch does not.
801
+ *
802
+ * What the annotation then WAIVES is a separate, fail-closed question — see
803
+ * {@link parseAllowAnnotation}. A word in the id slot that names no rule waives nothing, so
804
+ * `// theme-allow legacy vendor asset` (a prose reason with no separator introducing it) no longer
805
+ * suppresses. That IS a break, deliberately: the alternative is one mistyped character silently
806
+ * widening a scoped waiver into a blanket one. No consumer writes that shape today — every
807
+ * annotation across all seven repos introduces its reason with `—`, `–`, `-` or `:` — which is why
808
+ * it ships as a documented break rather than a `GRACE_PERIOD_KINDS` entry. Grace could not express
809
+ * it anyway: the kind that fires is the UNSUPPRESSED one (`raw-hex`), not `theme-allow-unscoped`,
810
+ * and downgrading `raw-hex` to waive-by-default is the hole this closed.
811
+ */
812
+ const ANNOTATION_PREFIX = /(?:^|\/\/|\/\*|<!--|^\s*\*)\s*$/
813
+
814
+ /** Escape a config-supplied string for literal use inside a RegExp. */
815
+ function escapeRegExp(value: string): string {
816
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
817
+ }
818
+
819
+ /**
820
+ * The annotation token itself, with its optional `-file` suffix.
821
+ *
822
+ * `(?![\w-])` is what keeps the two forms apart and keeps a longer word from being read as the bare
823
+ * form: `theme-allow-file` is the file declaration, `theme-allow-unscoped` (the KIND NAME, written
824
+ * in prose constantly) is neither — before this it consumed no id, fell through to `rules: []` and
825
+ * parsed as a blanket waiver.
826
+ */
827
+ function allowTokenRe(allowComment: string): RegExp {
828
+ return new RegExp(`${escapeRegExp(allowComment)}(-file)?(?![\\w-])`, 'g')
829
+ }
830
+
831
+ /**
832
+ * The JSON dialect of the annotation — `"basalt:theme-allow-file": "<ids> — <why>"`.
833
+ *
834
+ * JSON has no comments, so from 1.20.0 (when the scan reached `.webmanifest` / `.json`) every
835
+ * finding in that file class was permanently unwaivable and the printed remedy prescribed a comment
836
+ * the file cannot carry. Two consumers were pushed into a blanket `exemptRules` entry instead — the
837
+ * un-reviewable shape this contract exists to retire. A member key is the one annotation JSON can
838
+ * hold, it carries ids and a reason exactly like the comment form, and every manifest consumer
839
+ * ignores members it does not know.
840
+ */
841
+ function jsonAllowKeyRe(allowComment: string): RegExp {
842
+ return new RegExp(`"basalt:${escapeRegExp(allowComment)}(-file)?"\\s*:\\s*"([^"]*)"`, 'g')
843
+ }
844
+
845
+ /**
846
+ * Every line an allow annotation WAIVES, keyed by 0-based line index, plus the file-scoped
847
+ * declarations and every annotation as WRITTEN (for `theme-allow-unscoped`).
848
+ *
849
+ * **The grammar, in full:**
850
+ *
851
+ * ```text
852
+ * theme-allow → this placement, EVERY kind (reports unscoped)
853
+ * theme-allow <id>[, <id>…] [— <why>] → this placement, those kinds (unscoped without a why)
854
+ * theme-allow-file <id>[, <id>…] — <why> → the WHOLE FILE, those kinds
855
+ * "basalt:theme-allow[-file]": "<id>… — <why>" → the same two, for JSON/.webmanifest
856
+ * ```
857
+ *
858
+ * Three rules make it unambiguous: the token must START its comment (see {@link ANNOTATION_PREFIX});
859
+ * `-file` is part of the token, not the first word of the reason (see {@link allowTokenRe}); and a
860
+ * bare `theme-allow-file` waives NOTHING, because whole-file blanket immunity off one unnamed
861
+ * comment is the exact thing this contract exists to price (see {@link annotationCovers}).
862
+ *
863
+ * Line scope has three placements, each one a shape people actually write — and the union of them
864
+ * is what makes the escape survive a formatter.
865
+ *
866
+ * 1. **Its own line.** The original rule, unchanged.
867
+ * 2. **The first CODE line below, when the annotation line is a comment and nothing else.** This is
868
+ * the placement the oxlint plugin has always honored and the only one JSX can express: the
869
+ * reported line is usually a multi-line opening tag or a `{expr}` child, where a trailing `//` is
870
+ * a syntax error or renders as visible text. Requiring the annotation line to be comment-ONLY is
871
+ * what keeps a TRAILING comment scoped to its own line — otherwise
872
+ * `const a = '#f00' // theme-allow` would silently waive the next line too. The rest of the
873
+ * comment BLOCK is walked through rather than counted as the target line: a docblock's `*\/`, or
874
+ * a reason that wrapped onto a second line, used to absorb the whole waiver and the natural
875
+ * shape silently waived nothing. A blank line ends the block — that separation is how people say
876
+ * "this comment is not about the next statement".
877
+ * 3. **The rest of the CSS declaration it terminates.** The shipped `oxfmt` reflows
878
+ * `background-color: var(--x, #232326); /* theme-allow *\/` into four lines with the hex on one
879
+ * and the comment on another — two shipped tools pulling in opposite directions, and the
880
+ * exception silently stopped working. Walking back over the continuation lines (stopping at the
881
+ * first `;` / `{` / `}`, which CSS actually has) restores it. Deliberately CSS-only: oxfmt emits
882
+ * NO semicolons in TS, so there is no statement terminator to stop at and the same walk would
883
+ * waive arbitrary code above.
884
+ *
885
+ * An occurrence only counts when it sits inside a COMMENT, which is read off the stripped text:
886
+ * `stripComments` replaces comment characters 1:1 with spaces, so a position that is non-space in
887
+ * the source and space in the stripped copy was a comment. That is what keeps a `'theme-allow'`
888
+ * inside a string literal (or in this file's own rule table) from silently disabling a rule.
889
+ */
890
+ function collectAllowAnnotations(
891
+ lines: readonly string[],
892
+ codeLines: readonly string[],
893
+ allowComment: string,
894
+ syntax: GuardSyntax,
895
+ ): {
896
+ waivers: Map<number, AllowAnnotation[]>
897
+ fileScoped: AllowAnnotation[]
898
+ declared: AllowDeclaration[]
899
+ } {
900
+ const byLine = new Map<number, AllowAnnotation[]>()
901
+ const fileScoped: AllowAnnotation[] = []
902
+ const declared: AllowDeclaration[] = []
903
+ const add = (index: number, annotation: AllowAnnotation): void => {
904
+ const list = byLine.get(index) ?? []
905
+ list.push(annotation)
906
+ byLine.set(index, list)
907
+ }
908
+
909
+ /** Place one parsed annotation: file declarations reach everywhere, line ones the 3 placements. */
910
+ const record = (index: number, annotation: AllowAnnotation, trailing: boolean): void => {
911
+ declared.push({ line: index + 1, annotation })
912
+ if (annotation.scope === 'file') {
913
+ fileScoped.push(annotation)
914
+ return
915
+ }
916
+ add(index, annotation)
917
+ if (!trailing) {
918
+ // The first line below that is not itself comment. A docblock's `*/` (or the rest of a
919
+ // wrapped reason) sits between the annotation and the code it is about, so `index + 1` alone
920
+ // meant the natural multi-line shape silently waived nothing — argo hit it three times in one
921
+ // upgrade, each looking like a correct annotation. A BLANK line still ends the block: that is
922
+ // the separation people use to mean "this comment is not about the next statement".
923
+ for (let ahead = 1; ahead <= MAX_COMMENT_BLOCK_LINES; ahead++) {
924
+ const raw = lines[index + ahead] ?? ''
925
+ add(index + ahead, annotation)
926
+ if (raw.trim() !== '' && (codeLines[index + ahead] ?? '').trim() !== '') break
927
+ if (raw.trim() === '') break
928
+ }
929
+ return
930
+ }
931
+ if (syntax !== 'css') return
932
+ for (let back = 1; back <= MAX_CSS_CONTINUATION_LINES; back++) {
933
+ const previous = (codeLines[index - back] ?? '').trim()
934
+ if (previous === '' || /[;{}]$/.test(previous)) break
935
+ add(index - back, annotation)
936
+ }
937
+ }
938
+
939
+ const token = allowTokenRe(allowComment)
940
+ const jsonKey = jsonAllowKeyRe(allowComment)
941
+
942
+ for (let i = 0; i < lines.length; i++) {
943
+ const line = lines[i] ?? ''
944
+ const stripped = codeLines[i] ?? ''
945
+ const isCommentOnlyLine = stripped.trim() === '' || COMMENT_ONLY_LINE.test(line)
946
+
947
+ // The JSON member form. Markup-only: in TS/CSS that string would be code, not an annotation.
948
+ if (syntax === 'markup') {
949
+ for (const m of line.matchAll(jsonKey)) {
950
+ record(i, parseAllowAnnotation(m[2] ?? '', m[1] === undefined ? 'line' : 'file'), false)
951
+ }
952
+ }
953
+
954
+ for (const m of line.matchAll(token)) {
955
+ const at = m.index
956
+ const before = line.slice(0, at)
957
+ // Not inside a comment (neither witness agrees) → real code, e.g. this file's own rule table.
958
+ if (stripped[at] !== ' ' && !COMMENT_OPENER_BEFORE.test(before)) continue
959
+ // Inside a comment, but not at the START of one → prose that mentions the token, not an
960
+ // annotation. See ANNOTATION_PREFIX: this is what stops a file documenting its own waivers
961
+ // from disarming itself.
962
+ if (!ANNOTATION_PREFIX.test(before)) continue
963
+ const rest = line.slice(at + m[0].length)
964
+ record(
965
+ i,
966
+ parseAllowAnnotation(rest, m[1] === undefined ? 'line' : 'file'),
967
+ !isCommentOnlyLine,
968
+ )
969
+ }
970
+ }
971
+ return { waivers: byLine, fileScoped, declared }
972
+ }
973
+
974
+ /**
975
+ * Does one annotation cover `kind`?
976
+ *
977
+ * Only a BARE **line** annotation (no ids at all, known or unknown) covers every kind. One that
978
+ * reached for a rule id and missed covers exactly the ids it got right — nothing widens on a typo.
979
+ *
980
+ * A bare `theme-allow-file` covers NOTHING. The blanket form is tolerable on one line, where a
981
+ * reader sees what it sits on; over a whole file it is `exemptRules` without the config review, and
982
+ * the widest waiver in the contract must be the one that has to name what it waives.
983
+ */
984
+ function annotationCovers(annotation: AllowAnnotation, kind: GuardKind): boolean {
985
+ if (annotation.rules.includes(kind)) return true
986
+ if (annotation.scope === 'file') return false
987
+ return annotation.rules.length === 0 && annotation.unknownRules.length === 0
988
+ }
989
+
990
+ /** Does any annotation on this line cover `kind`? */
991
+ function annotationsCover(annotations: AllowAnnotation[] | undefined, kind: GuardKind): boolean {
992
+ return (annotations ?? []).some((a) => annotationCovers(a, kind))
993
+ }
994
+
995
+ /** The 3 comment dialects the stripper understands, resolved from the file's own extension. */
996
+ type GuardSyntax = 'ts' | 'css' | 'markup'
359
997
 
360
998
  /**
361
999
  * CSS (including `.module.css`) has no `//` line-comment syntax at all — an unquoted `//`, e.g.
362
1000
  * inside `url(https://…)`, is real CSS text, never a comment opener. Match on the `.css` suffix
363
1001
  * (not a naive `split('.')`) so `foo.module.css` resolves the same as `foo.css`.
1002
+ *
1003
+ * `markup` covers the two files whose colors nothing re-derives on a theme change and which the
1004
+ * scan never reached — `index.html` and `*.webmanifest` (plus plain `.json`, which a webmanifest
1005
+ * often is). Only the color/typography kinds apply there; see `MARKUP_KINDS`.
364
1006
  */
365
1007
  function guardSyntaxFor(relPath: string): GuardSyntax {
366
- return relPath.endsWith('.css') ? 'css' : 'ts'
1008
+ if (relPath.endsWith('.css')) return 'css'
1009
+ if (/\.(?:html?|webmanifest|json)$/.test(relPath)) return 'markup'
1010
+ return 'ts'
1011
+ }
1012
+
1013
+ /** The only kinds meaningful in an HTML document or a JSON manifest — no JSX, no CSS-in-JS. */
1014
+ const MARKUP_KINDS: ReadonlySet<GuardKind> = new Set(['raw-hex', 'raw-color-fn', 'raw-font-family'])
1015
+
1016
+ /** Blanks out `<!-- … -->` 1:1 with spaces, preserving every offset. JSON has no comment syntax. */
1017
+ function stripMarkupComments(text: string): string {
1018
+ return text.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '))
367
1019
  }
368
1020
 
1021
+ /**
1022
+ * The exact first line of every stylesheet the CLI emits — the contract between `basalt-ui
1023
+ * tokens:css` / `fonts:css` and this scanner, and the single source of truth for it (`src/cli`
1024
+ * imports this rather than keeping its own copy, which is how the two used to drift apart).
1025
+ *
1026
+ * The marker exists because `check-theme` reported the file a sibling command had just written:
1027
+ * 116 of rollhook's 117 violations were inside the emitted token stylesheet, which is nothing but
1028
+ * hex and `rgba()` by construction.
1029
+ *
1030
+ * **Command-NEUTRAL by necessity.** Line 1 is compared verbatim, so it cannot name the command
1031
+ * that produced the file without needing one accepted marker per command — and a second accepted
1032
+ * marker is a second forgery surface. It points at the provenance line instead, which does name
1033
+ * the command. It used to say `regenerate with \`bunx basalt-ui tokens:css\`` on a `fonts:css`
1034
+ * file too: following that instruction overwrote a font sheet with the palette sheet.
1035
+ */
1036
+ export const GENERATED_HEADER_LINE =
1037
+ '/* @generated basalt-ui — do not edit; regenerate with the command on the next line */'
1038
+
1039
+ /** Line 2 of the header: the emitting version and the exact invocation that produced the file. */
1040
+ const GENERATED_PROVENANCE_LINE =
1041
+ /^\/\* basalt-ui \d+\.\d+\.\d+[^\s]* — `basalt-ui (?:tokens:css|fonts:css)[^`]*` \*\/$/
1042
+
1043
+ /** The header is exactly the two lines `generatedHeader()` writes — nothing further in counts. */
1044
+ const GENERATED_HEADER_LINES = 2
1045
+
1046
+ /** A comment that OPENS AND CLOSES on its own line — the only comment shape the emitters write. */
1047
+ const GENERATED_COMMENT_LINE = /^\/\*(?:[^*]|\*(?!\/))*\*\/$/
1048
+
1049
+ /** Outside a block: a selector, a selector-list continuation ending in `,`, or an at-rule opener. */
1050
+ const GENERATED_SELECTOR_LINE = /^[^{};]*[,{]$/
1051
+
1052
+ /** Inside a block: a declaration of a custom property in basalt's OWN namespace, or the close. */
1053
+ const GENERATED_DECL_LINE = /^(?:--(?:vx|basalt)-[A-Za-z0-9_-]*\s*:[^{};]*;|\})$/
1054
+
1055
+ /** No line is skipped in a file that is not one basalt emitted — the shared empty answer. */
1056
+ const NO_GENERATED_LINES: ReadonlySet<number> = new Set()
1057
+
1058
+ /** Net brace depth a line moves the parser by — CSS blocks, counted the cheap way. */
1059
+ function braceDelta(line: string): number {
1060
+ let delta = 0
1061
+ for (const ch of line) {
1062
+ if (ch === '{') delta++
1063
+ else if (ch === '}') delta--
1064
+ }
1065
+ return delta
1066
+ }
1067
+
1068
+ /**
1069
+ * The (1-based) lines of a stylesheet basalt itself emitted, which the scan may skip. Empty for
1070
+ * every other file.
1071
+ *
1072
+ * The marker used to be honoured on its own: any file whose first five lines contained the string
1073
+ * `@generated basalt-ui` was skipped ENTIRELY. That made a two-word comment a whole-file guard
1074
+ * bypass — prepend it to any `.tsx` and every finding in it disappears. The replacement gated the
1075
+ * whole file on a header plus a line-shape allowlist, and the allowlist was loose enough to forge:
1076
+ * `--vx-pad: 0; box-shadow: 0 0 0 1px #ff0000;` passed as "a basalt custom property" because the
1077
+ * value pattern permitted `;`, and `/* x *\/ .btn { color: #ff0000; }` passed as "a comment"
1078
+ * because the comment pattern never required the comment to close. Both are browser-effective CSS,
1079
+ * and either one bought a whole-file exemption.
1080
+ *
1081
+ * Two changes close that, and the second is the structural one:
1082
+ *
1083
+ * 1. **The exemption is PER LINE, not per file.** A line-shape allowlist that misses now costs one
1084
+ * line, not the whole file — the blast radius of the next mistake is bounded by construction.
1085
+ * It also reports the smuggled line precisely instead of burying it under 300 token values.
1086
+ * 2. **The allowlist is brace-depth-aware.** CSS declarations only take effect INSIDE a block, so
1087
+ * at depth ≥ 1 the only skippable line is a `--vx-*`/`--basalt-*` declaration whose value
1088
+ * carries no `;`, a `}`, a self-closing comment, or a blank. `box-shadow: … #ff0000,` is a
1089
+ * depth-1 line and is scanned no matter how it is shaped. At depth 0 a loose selector pattern
1090
+ * is safe precisely because nothing there is a declaration.
1091
+ *
1092
+ * Three conditions still gate the file itself: a `.css` path (the CLI emits CSS and nothing else,
1093
+ * so the marker is ignored in `.ts`/`.tsx`/`.html`/`.json`, which is where the guard has its
1094
+ * teeth), the canonical header verbatim on line 1, and the provenance line on line 2.
1095
+ *
1096
+ * A line carrying the allow comment is never skipped: an emitted sheet contains no `theme-allow`,
1097
+ * and skipping one would suppress the `theme-allow-unscoped` report of a waiver hidden in a file
1098
+ * wearing the header.
1099
+ *
1100
+ * Residual, knowingly accepted: at depth 0 a forger can hide a finding inside something shaped
1101
+ * like a selector (`.a[data-x='#ff0000'] {`), and inside a block they can hide the value of a
1102
+ * custom property they declare in basalt's own namespace. Neither is browser-effective as a style
1103
+ * and the second IS the artifact class this exemption exists for.
1104
+ */
1105
+ function generatedTokenLines(
1106
+ relPath: string,
1107
+ lines: readonly string[],
1108
+ allowComment: string,
1109
+ ): ReadonlySet<number> {
1110
+ if (!relPath.endsWith('.css')) return NO_GENERATED_LINES
1111
+ if ((lines[0] ?? '').trim() !== GENERATED_HEADER_LINE) return NO_GENERATED_LINES
1112
+ if (!GENERATED_PROVENANCE_LINE.test((lines[1] ?? '').trim())) return NO_GENERATED_LINES
1113
+
1114
+ const skippable = new Set<number>([1, 2])
1115
+ let depth = 0
1116
+ for (let i = GENERATED_HEADER_LINES; i < lines.length; i++) {
1117
+ const line = lines[i] ?? ''
1118
+ const trimmed = line.trim()
1119
+ const shaped =
1120
+ trimmed === '' ||
1121
+ GENERATED_COMMENT_LINE.test(trimmed) ||
1122
+ (depth === 0 ? GENERATED_SELECTOR_LINE : GENERATED_DECL_LINE).test(trimmed)
1123
+ if (shaped && !line.includes(allowComment)) skippable.add(i + 1)
1124
+ depth += braceDelta(line)
1125
+ // Unbalanced braces mean this is not the emitters' output at all — fail closed on the rest.
1126
+ if (depth < 0) return NO_GENERATED_LINES
1127
+ }
1128
+ return skippable
1129
+ }
1130
+
1131
+ /**
1132
+ * Kinds disabled under `profile: 'tokens-only'` — every kind whose remedy is a Mantine component,
1133
+ * a Mantine prop, or the React theme factory. See {@link GuardConfig.profile}.
1134
+ *
1135
+ * Exported so the CLI can name the profile in `--help`/`doctor` output without re-deriving the
1136
+ * list, and so a test can assert the two halves (disabled vs. surviving) partition the registry.
1137
+ */
1138
+ export const TOKENS_ONLY_DISABLED_KINDS: ReadonlySet<GuardKind> = new Set([
1139
+ 'localstorage-theme',
1140
+ 'off-identity-accent',
1141
+ 'mantine-shade-index',
1142
+ 'raw-spacing',
1143
+ 'raw-radius',
1144
+ 'card-with-border',
1145
+ 'off-system-surface-var',
1146
+ 'raw-html-layout',
1147
+ // Same remedy as raw-html-layout, word for word ("use a Mantine layout primitive") — it is that
1148
+ // kind's widening, so it has to share its gate here exactly as it shares it in
1149
+ // EXEMPT_RULE_ALIASES. Splitting them told a Mantine-free consumer to import Box/Flex.
1150
+ 'hidden-inline-style',
1151
+ 'inline-spacing',
1152
+ 'inline-display',
1153
+ 'raw-visx-axis',
1154
+ 'raw-motion-value',
1155
+ 'unframed-chart',
1156
+ 'chart-missing-aria-label',
1157
+ 'raw-form-control',
1158
+ 'sub-16-input-font',
1159
+ ])
1160
+
369
1161
  // A `/` opens a regex literal (not division) when the previous significant token is one of these
370
1162
  // punctuation marks, or one of the REGEX_PRECEDING_KEYWORDS below, or nothing has been scanned yet
371
1163
  // (start of input) — the standard JS lexer disambiguation. `ts`-syntax only; CSS has no regexes.
@@ -435,7 +1227,7 @@ const WORD_CHAR = /[A-Za-z0-9_$]/
435
1227
  * regex-open would swallow real code as "protected regex content" and could hide a genuine
436
1228
  * violation — the failure mode BUG 1 already showed is unacceptable.
437
1229
  */
438
- function stripComments(text: string, syntax: GuardSyntax): string {
1230
+ function stripComments(text: string, syntax: 'ts' | 'css'): string {
439
1231
  const out: string[] = Array.from<string>({ length: text.length })
440
1232
  let inLineComment = false
441
1233
  let inBlockComment = false
@@ -584,7 +1376,7 @@ type GuardRule = {
584
1376
  }
585
1377
 
586
1378
  /**
587
- * The closed registry of all 20 guard kinds. The triad test asserts
1379
+ * The closed registry of all 25 guard kinds. The triad test asserts
588
1380
  * `surface.guardKinds ⊆ keyof GUARD_RULES` at runtime.
589
1381
  *
590
1382
  * raw-surface, raw-html-layout, and sub-16-input-font are handled inline in checkSource
@@ -775,8 +1567,92 @@ export const GUARD_RULES = {
775
1567
  message:
776
1568
  'fontSize below 16 on a form control is dead code — the styles.css iOS floor is `!important` and always wins. Either drop the override, or be honest about 16px in the design.',
777
1569
  },
1570
+ 'theme-allow-unscoped': {
1571
+ kind: 'theme-allow-unscoped',
1572
+ pattern: /theme-allow/, // handled inline (annotation pass); entry keeps the registry complete
1573
+ message:
1574
+ 'theme-allow without a usable rule id and a reason. Write `theme-allow <rule-id> — <why>`: the id scopes the exception to that one kind (a bare comment waives EVERY kind on the line, including ones added later), and the reason is what makes it reviewable in a diff. An id that names no rule is a typo, not a blanket waiver — it suppresses nothing. `theme-allow-file <rule-id> — <why>` declares the whole file, and must name its ids: a bare one waives nothing.',
1575
+ },
1576
+ 'surface-shadow-override': {
1577
+ kind: 'surface-shadow-override',
1578
+ pattern: SHADOW_DECL, // handled inline (value is judged, not just matched)
1579
+ enabled: (cfg: GuardConfig) => cfg.rawSurface,
1580
+ message:
1581
+ 'Token-composed boxShadow that REPLACES surface depth — a card whose shadow drops --vx-shadow-card is the one card in the app with no depth. Compose with the depth token for its tier (`${VX.shadowCard}, <your ring>`; a fixed/floating surface wants --vx-shadow-overlay) rather than instead of it.',
1582
+ },
1583
+ 'css-raw-surface': {
1584
+ kind: 'css-raw-surface',
1585
+ pattern: CSS_SURFACE_RADIUS, // handled inline (value is judged); entry keeps the registry complete
1586
+ enabled: (cfg: GuardConfig) => cfg.rawSurface,
1587
+ // The kebab dialect only exists in CSS text; the camelCase half is `raw-surface`.
1588
+ appliesTo: (relPath) => relPath.endsWith('.css'),
1589
+ message:
1590
+ 'Surface literal in CSS — use var(--vx-radius-card|--vx-radius-md) for a corner and var(--vx-shadow-card) for depth, the same tokens the TSX side is held to.',
1591
+ },
1592
+ 'hidden-inline-style': {
1593
+ kind: 'hidden-inline-style',
1594
+ pattern: RAW_HTML_LAYOUT_TAG, // handled inline (full-text tag-scoped scan + hoisted-const lookup)
1595
+ enabled: (cfg: GuardConfig) => cfg.rawHtmlLayout,
1596
+ appliesTo: (relPath) => !isChartFile(relPath) && !relPath.endsWith('.css'),
1597
+ message:
1598
+ 'Raw HTML element with inline layout/surface styling that the line scan cannot see — the tag is formatted across lines, or the style object is hoisted to a const. Same violation as raw-html-layout: use a Mantine layout primitive (Box/Flex/Grid/Stack/Group). Its own kind for one minor so the widening lands as a warning; it merges into raw-html-layout at the promotion.',
1599
+ },
1600
+ 'inline-font-size': {
1601
+ kind: 'inline-font-size',
1602
+ pattern: INLINE_FONT_SIZE,
1603
+ message:
1604
+ 'Raw font-size literal — route through VX.text.* (numbers) / --vx-text-* (CSS), or the Mantine fz token. Same rule the oxlint plugin enforces as basalt/no-raw-font-size; this is the half a check-theme-only CI can see.',
1605
+ },
778
1606
  } as const satisfies Record<GuardKind, GuardRule>
779
1607
 
1608
+ /**
1609
+ * The one-line remedy for `kind`, as a report reads it: `raw-hex: Route color through VX.* / …`.
1610
+ *
1611
+ * The registry has always carried a remedy per kind; `check-theme`'s `Fix:` epilogue read a
1612
+ * hand-duplicated subset instead, and the subset was missing every kind added at 1.20.0. Since all
1613
+ * five of those ship `warn` under grace, the findings whose whole argument is "this looks correct
1614
+ * and is not" arrived with no argument at all — just "add a `theme-allow`", which reads as advice to
1615
+ * waive. Sourcing the epilogue from here retires the duplicate rather than extending it.
1616
+ */
1617
+ export function guardKindRemedy(kind: GuardKind): string {
1618
+ return `${kind}: ${(GUARD_RULES[kind] as GuardRule).message}`
1619
+ }
1620
+
1621
+ /** JSON and its manifest dialect — the file class with no comment syntax to carry an annotation. */
1622
+ const JSON_FAMILY_FILE = /\.(?:webmanifest|json)$/
1623
+
1624
+ /**
1625
+ * How to waive a finding **in this file's syntax** — the closer a report prints under `Fix:`.
1626
+ *
1627
+ * `check-theme` printed one closer for every file class, and from 1.20.0 (when the scan reached
1628
+ * `.webmanifest` / `.json`) it prescribed a `theme-allow` comment to files that cannot hold a
1629
+ * comment. Two consumers read that, found no route, and fell back to a blanket `exemptRules` entry —
1630
+ * the un-reviewable shape the release was spent retiring.
1631
+ *
1632
+ * A manifest gets the sharper answer first, because a hex there can never be *right*: a manifest
1633
+ * cannot reference a CSS custom property, so the value is a hand-copy of a token that drifts the day
1634
+ * the palette moves (which is exactly what both consumers found — two dead colours). The remedy is
1635
+ * to stop hand-writing the file: `basaltAppPlugin` emits it from `SURFACE.bg`. The annotation is the
1636
+ * fallback for a manifest basalt does not generate.
1637
+ */
1638
+ export function guardWaiverHint(relPath: string): string {
1639
+ if (relPath.endsWith('.webmanifest')) {
1640
+ return (
1641
+ 'A manifest cannot reference a CSS variable, so a hex here is a hand-copy that drifts: let ' +
1642
+ 'basaltAppPlugin emit it (`manifest: { … }` in vite.config.ts) instead of hand-writing it. ' +
1643
+ 'For a manifest basalt does not generate, declare the exception with a ' +
1644
+ '`"basalt:theme-allow-file": "raw-hex — <why>"` member.'
1645
+ )
1646
+ }
1647
+ if (JSON_FAMILY_FILE.test(relPath)) {
1648
+ return (
1649
+ 'JSON has no comments — declare a deliberate exception with a ' +
1650
+ '`"basalt:theme-allow-file": "<rule-id> — <why>"` member.'
1651
+ )
1652
+ }
1653
+ return 'Add a `theme-allow <rule-id> — <why>` comment for a deliberate exception (`theme-allow-file` declares the whole file).'
1654
+ }
1655
+
780
1656
  /** Whether `kind`'s rule fires for `relPath` — `appliesTo` is opt-in; absent means always applies. */
781
1657
  function ruleApplies(kind: GuardKind, relPath: string): boolean {
782
1658
  const appliesTo = (GUARD_RULES[kind] as GuardRule).appliesTo
@@ -796,9 +1672,16 @@ function ruleApplies(kind: GuardKind, relPath: string): boolean {
796
1672
  * if (findings.some((f) => f.kind === 'raw-hex')) { ... }
797
1673
  */
798
1674
  export function checkSource(text: string, relPath: string, cfg: GuardConfig): Finding[] {
1675
+ const lines = text.split('\n')
1676
+
1677
+ // Lines basalt itself generated are not consumer source — `tokens:css` emits nothing BUT hex and
1678
+ // rgba() by construction. See generatedTokenLines for why the marker alone is not enough, and
1679
+ // why the exemption is per line rather than per file.
1680
+ const generatedLines = generatedTokenLines(relPath, lines, cfg.allowComment)
1681
+
799
1682
  // Severity and text are stamped once at the end rather than at each of the ~20 push sites — both
800
1683
  // are derived from state the push sites don't need: severity from the KIND and the config, text
801
- // from the finding's own `line` against the source split below.
1684
+ // from the finding's own `line` against the source split above.
802
1685
  const findings: Omit<Finding, 'severity' | 'text'>[] = []
803
1686
 
804
1687
  // Derive the 3 dynamic regexes via GUARD_RULES pattern builders.
@@ -813,70 +1696,157 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
813
1696
  )
814
1697
 
815
1698
  // Comment-stripped text drives every match below; `lines` (the ORIGINAL, unstripped split) is
816
- // kept only to resolve the allow-comment escape, since that annotation lives inside a comment
817
- // the stripped text has already blanked out. Same length / same newline positions as `text`, so
818
- // line numbers computed off either one agree.
819
- const lines = text.split('\n')
1699
+ // kept to resolve the allow-comment escape, since that annotation lives inside a comment the
1700
+ // stripped text has already blanked out. Same length / same newline positions as `text`, so line
1701
+ // numbers computed off either one agree.
820
1702
  const syntax = guardSyntaxFor(relPath)
821
- const codeText = stripComments(text, syntax)
1703
+ const codeText = syntax === 'markup' ? stripMarkupComments(text) : stripComments(text, syntax)
822
1704
  const codeLines = codeText.split('\n')
1705
+ const { waivers, fileScoped, declared } = collectAllowAnnotations(
1706
+ lines,
1707
+ codeLines,
1708
+ cfg.allowComment,
1709
+ syntax,
1710
+ )
1711
+
1712
+ /** Kinds a `theme-allow-file` declared for the whole file — resolved once, not per finding. */
1713
+ const fileWaived = new Set<GuardKind>()
1714
+ for (const annotation of fileScoped) {
1715
+ for (const rule of annotation.rules) {
1716
+ if (Object.hasOwn(GUARD_RULES, rule)) fileWaived.add(rule as GuardKind)
1717
+ }
1718
+ }
1719
+
1720
+ /** Is `kind` waived at (1-based) `line`? See `collectAllowAnnotations` for the full grammar. */
1721
+ const isAllowed = (line: number, kind: GuardKind): boolean =>
1722
+ fileWaived.has(kind) || annotationsCover(waivers.get(line - 1), kind)
1723
+
1724
+ const isAllowedInRange = (start: number, end: number, kind: GuardKind): boolean => {
1725
+ for (let n = start; n <= end; n++) if (isAllowed(n, kind)) return true
1726
+ return false
1727
+ }
1728
+
1729
+ const push = (kind: GuardKind, line: number, token: string): void => {
1730
+ if (isAllowed(line, kind)) return
1731
+ findings.push({ relPath, line, token, kind })
1732
+ }
1733
+
1734
+ // theme-allow accountability. Reported directly rather than through `push`: a bare annotation
1735
+ // covers every kind, so routing it through the waiver check would let it waive the report of its
1736
+ // own unaccountability. `basalt.severity` / `exemptRules` are the ways to turn it down.
1737
+ for (const { line, annotation } of declared) {
1738
+ if (annotation.unknownRules.length === 0 && annotation.rules.length > 0 && annotation.hasReason)
1739
+ continue
1740
+ // "waives nothing" is only true when NO id resolved. With one that did, the typo costs its own
1741
+ // id and nothing else — saying otherwise contradicts the findings the same run suppressed.
1742
+ const unknown = `unknown rule id '${annotation.unknownRules.join("', '")}'`
1743
+ const missing =
1744
+ annotation.unknownRules.length > 0
1745
+ ? annotation.rules.length === 0
1746
+ ? `${unknown} — waives nothing`
1747
+ : `${unknown} — not waived`
1748
+ : annotation.rules.length === 0
1749
+ ? 'no rule id'
1750
+ : 'no reason'
1751
+ findings.push({
1752
+ relPath,
1753
+ line,
1754
+ token: `${cfg.allowComment} (${missing})`,
1755
+ kind: 'theme-allow-unscoped',
1756
+ })
1757
+ }
1758
+
1759
+ /** Lines the line-scoped `raw-html-layout` already owns, so `hidden-inline-style` can't double-report. */
1760
+ const rawHtmlLayoutLines = new Set<number>()
1761
+
1762
+ /** Offset of each line's start in `codeText` — lets a line-scoped match ask about its surroundings. */
1763
+ const lineStarts: number[] = []
1764
+ for (let n = 0, i = 0; i < codeLines.length; i++) {
1765
+ lineStarts.push(n)
1766
+ n += (codeLines[i] ?? '').length + 1
1767
+ }
1768
+
1769
+ /** Every identifier the file hands to a `style=`/`styles=` prop — see `isStyleObjectContext`. */
1770
+ const styleIdentifiers = new Set(
1771
+ [...codeText.matchAll(STYLE_BOUND_IDENTIFIER)].map((m) => m[1] ?? ''),
1772
+ )
823
1773
 
824
1774
  for (let i = 0; i < lines.length; i++) {
825
1775
  const line = codeLines[i] ?? ''
826
1776
 
827
- // Skip lines with the allow-comment (checked against the ORIGINAL line text).
828
- if ((lines[i] ?? '').includes(cfg.allowComment)) continue
829
-
830
1777
  // Always-on kinds (raw-hex, raw-color-fn, localstorage-theme) — patterns from GUARD_RULES.
831
1778
  for (const m of line.matchAll(GUARD_RULES['raw-hex'].pattern as RegExp)) {
832
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'raw-hex' })
1779
+ push('raw-hex', i + 1, m[0])
833
1780
  }
834
1781
  for (const m of line.matchAll(GUARD_RULES['raw-color-fn'].pattern as RegExp)) {
835
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'raw-color-fn' })
1782
+ push('raw-color-fn', i + 1, m[0])
836
1783
  }
837
1784
  if (ruleApplies('localstorage-theme', relPath)) {
838
1785
  for (const m of line.matchAll(GUARD_RULES['localstorage-theme'].pattern as RegExp)) {
839
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'localstorage-theme' })
1786
+ push('localstorage-theme', i + 1, m[0])
840
1787
  }
841
1788
  }
842
1789
  for (const m of line.matchAll(GUARD_RULES['raw-font-family'].pattern as RegExp)) {
843
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'raw-font-family' })
1790
+ push('raw-font-family', i + 1, m[0])
1791
+ }
1792
+ for (const m of line.matchAll(GUARD_RULES['inline-font-size'].pattern as RegExp)) {
1793
+ push('inline-font-size', i + 1, m[0])
844
1794
  }
845
1795
 
846
1796
  // Dynamic-regex kinds — patterns already resolved above.
847
1797
  if (ruleApplies('off-identity-accent', relPath)) {
848
1798
  for (const m of line.matchAll(forbiddenAccentRe)) {
849
- findings.push({ relPath, line: i + 1, token: m[1] ?? '', kind: 'off-identity-accent' })
1799
+ push('off-identity-accent', i + 1, m[1] ?? '')
850
1800
  }
851
1801
  }
852
1802
  if (ruleApplies('raw-spacing', relPath)) {
853
- for (const m of line.matchAll(spacingPropRe)) {
854
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'raw-spacing' })
855
- }
1803
+ for (const m of line.matchAll(spacingPropRe)) push('raw-spacing', i + 1, m[0])
856
1804
  }
857
1805
  if (GUARD_RULES['raw-radius'].enabled!(cfg) && ruleApplies('raw-radius', relPath)) {
858
- for (const m of line.matchAll(radiusPropRe)) {
859
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'raw-radius' })
860
- }
1806
+ for (const m of line.matchAll(radiusPropRe)) push('raw-radius', i + 1, m[0])
861
1807
  }
862
1808
 
863
- // raw-surface: 3 separate regex checks, one kind — gated via GUARD_RULES entry.
1809
+ // raw-surface + its two younger siblings, all gated on the same `rawSurface` knob:
1810
+ // • raw-surface — the camelCase (TSX inline-style) literals, unchanged;
1811
+ // • css-raw-surface — the kebab dialect, which the camelCase patterns never saw;
1812
+ // • surface-shadow-override — a shadow built FROM tokens that still drops card depth.
864
1813
  if (GUARD_RULES['raw-surface'].enabled!(cfg)) {
865
- for (const m of line.matchAll(SURFACE_BORDER)) {
866
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'raw-surface' })
867
- }
868
- for (const m of line.matchAll(SURFACE_RADIUS)) {
869
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'raw-surface' })
870
- }
871
- for (const m of line.matchAll(SURFACE_SHADOW)) {
872
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'raw-surface' })
1814
+ for (const m of line.matchAll(SURFACE_BORDER)) push('raw-surface', i + 1, m[0])
1815
+ for (const m of line.matchAll(SURFACE_RADIUS)) push('raw-surface', i + 1, m[0])
1816
+
1817
+ if (syntax === 'css') {
1818
+ for (const m of line.matchAll(CSS_SURFACE_RADIUS)) {
1819
+ const value = m[1] ?? ''
1820
+ if (!/\d/.test(value) || isAllowedCssRadius(value)) continue
1821
+ push('css-raw-surface', i + 1, m[0].trim())
1822
+ }
1823
+ for (const m of line.matchAll(CSS_SURFACE_SHADOW)) {
1824
+ const value = m[1] ?? ''
1825
+ // A var-composed shadow in a CSS MODULE is deliberately not judged. `surface-shadow-
1826
+ // override` is about a Card/Paper losing `--vx-shadow-card`; a module's own
1827
+ // `box-shadow: var(--field-depth)` is a focus ring, an inset, a notch — component
1828
+ // styling with no card identity at stake. Only the CSS-in-JS dialect, where the shadow
1829
+ // sits on a Mantine surface prop, carries that claim.
1830
+ if (SHADOW_IS_COMPOSED.test(value) || /^\s*none\s*$/.test(value)) continue
1831
+ push('css-raw-surface', i + 1, m[0].trim())
1832
+ }
1833
+ } else {
1834
+ for (const m of line.matchAll(SHADOW_DECL)) {
1835
+ const value = m[2] ?? ''
1836
+ if (!SHADOW_IS_COMPOSED.test(value)) {
1837
+ push('raw-surface', i + 1, m[0])
1838
+ continue
1839
+ }
1840
+ if (SHADOW_KEEPS_CARD_DEPTH.test(value)) continue
1841
+ push('surface-shadow-override', i + 1, m[0])
1842
+ }
873
1843
  }
874
1844
  }
875
1845
 
876
1846
  // off-system-surface-var — pattern + gating from GUARD_RULES.
877
1847
  if (GUARD_RULES['off-system-surface-var'].enabled!(cfg)) {
878
1848
  for (const m of line.matchAll(GUARD_RULES['off-system-surface-var'].pattern as RegExp)) {
879
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'off-system-surface-var' })
1849
+ push('off-system-surface-var', i + 1, m[0])
880
1850
  }
881
1851
  }
882
1852
 
@@ -886,11 +1856,12 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
886
1856
  // pattern's own comment for why this kind carries no appliesTo gate.
887
1857
  if (GUARD_RULES['mantine-shade-index'].enabled!(cfg)) {
888
1858
  for (const m of line.matchAll(GUARD_RULES['mantine-shade-index'].pattern as RegExp)) {
889
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'mantine-shade-index' })
1859
+ push('mantine-shade-index', i + 1, m[0])
890
1860
  }
891
1861
  }
892
1862
 
893
1863
  // raw-html-layout: 3-condition conjunction on the same line — gated via GUARD_RULES entry.
1864
+ // Its widened, formatting-independent half is `hidden-inline-style`, scanned below.
894
1865
  if (
895
1866
  GUARD_RULES['raw-html-layout'].enabled!(cfg) &&
896
1867
  ruleApplies('raw-html-layout', relPath) &&
@@ -898,21 +1869,30 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
898
1869
  INLINE_STYLE.test(line) &&
899
1870
  LAYOUT_SURFACE_PROP.test(line)
900
1871
  ) {
901
- findings.push({ relPath, line: i + 1, token: '<raw-html style>', kind: 'raw-html-layout' })
1872
+ rawHtmlLayoutLines.add(i + 1)
1873
+ push('raw-html-layout', i + 1, '<raw-html style>')
902
1874
  }
903
1875
 
904
- // inline-spacing — pattern + gating from GUARD_RULES, plus the CSS-only sub-scale escape.
1876
+ // inline-spacing — pattern + gating from GUARD_RULES, plus the CSS-only sub-scale escape and
1877
+ // the TS-only style-object test for a unitless number (see isStyleObjectContext).
905
1878
  if (GUARD_RULES['inline-spacing'].enabled!(cfg)) {
906
1879
  for (const m of line.matchAll(GUARD_RULES['inline-spacing'].pattern as RegExp)) {
907
1880
  if (syntax === 'css' && isSubScaleCssSpacing(line, m.index)) continue
908
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'inline-spacing' })
1881
+ const hasUnit = /^[a-z%]/.test(line.slice(m.index + m[0].length))
1882
+ if (
1883
+ syntax === 'ts' &&
1884
+ !hasUnit &&
1885
+ !isStyleObjectContext(codeText, lineStarts[i]! + m.index, styleIdentifiers)
1886
+ )
1887
+ continue
1888
+ push('inline-spacing', i + 1, m[0])
909
1889
  }
910
1890
  }
911
1891
 
912
1892
  // inline-display — pattern + gating from GUARD_RULES.
913
1893
  if (GUARD_RULES['inline-display'].enabled!(cfg) && ruleApplies('inline-display', relPath)) {
914
1894
  for (const m of line.matchAll(GUARD_RULES['inline-display'].pattern as RegExp)) {
915
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'inline-display' })
1895
+ push('inline-display', i + 1, m[0])
916
1896
  }
917
1897
  }
918
1898
 
@@ -923,37 +1903,63 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
923
1903
  GUARD_RULES['raw-visx-axis'].appliesTo!(relPath)
924
1904
  ) {
925
1905
  for (const m of line.matchAll(GUARD_RULES['raw-visx-axis'].pattern as RegExp)) {
926
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'raw-visx-axis' })
1906
+ push('raw-visx-axis', i + 1, m[0])
927
1907
  }
928
1908
  }
929
1909
 
930
1910
  // raw-motion-value: 2 separate regex checks, one kind — gated via GUARD_RULES entry.
931
1911
  if (GUARD_RULES['raw-motion-value'].enabled!(cfg) && ruleApplies('raw-motion-value', relPath)) {
932
- for (const m of line.matchAll(MOTION_TRANSITION_NUMERIC)) {
933
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'raw-motion-value' })
934
- }
1912
+ for (const m of line.matchAll(MOTION_TRANSITION_NUMERIC))
1913
+ push('raw-motion-value', i + 1, m[0])
935
1914
  for (const m of line.matchAll(MOTION_TRANSITION_EASE_ARRAY)) {
936
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'raw-motion-value' })
1915
+ push('raw-motion-value', i + 1, m[0])
937
1916
  }
938
1917
  }
939
1918
 
940
1919
  // raw-form-control — pattern + gating from GUARD_RULES.
941
1920
  if (GUARD_RULES['raw-form-control'].enabled!(cfg) && ruleApplies('raw-form-control', relPath)) {
942
1921
  for (const m of line.matchAll(GUARD_RULES['raw-form-control'].pattern as RegExp)) {
943
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'raw-form-control' })
1922
+ push('raw-form-control', i + 1, m[0])
944
1923
  }
945
1924
  }
946
1925
  }
947
1926
 
1927
+ // hidden-inline-style — the formatting-independent half of raw-html-layout. Bounded full-text tag
1928
+ // scan (the `card-with-border` model), plus one hoisted-const lookup, so neither `<div\n style=`
1929
+ // nor `style={wrapperStyle}` walks past a rule that catches the identical single-line form.
1930
+ if (
1931
+ GUARD_RULES['hidden-inline-style'].enabled!(cfg) &&
1932
+ ruleApplies('hidden-inline-style', relPath)
1933
+ ) {
1934
+ for (const m of codeText.matchAll(RAW_HTML_LAYOUT_TAG)) {
1935
+ const tagText = m[0]
1936
+ let styleBody = ''
1937
+ if (INLINE_STYLE.test(tagText)) styleBody = tagText
1938
+ else {
1939
+ const ident = STYLE_IDENTIFIER.exec(tagText)?.[1]
1940
+ if (ident !== undefined) styleBody = hoistedObjectBody(codeText, ident)
1941
+ }
1942
+ if (styleBody === '' || !LAYOUT_SURFACE_PROP.test(styleBody)) continue
1943
+ const startLine = codeText.slice(0, m.index ?? 0).split('\n').length
1944
+ if (rawHtmlLayoutLines.has(startLine)) continue
1945
+ const endLine = startLine + (tagText.split('\n').length - 1)
1946
+ if (isAllowedInRange(startLine, endLine, 'hidden-inline-style')) continue
1947
+ findings.push({
1948
+ relPath,
1949
+ line: startLine,
1950
+ token: '<raw-html style>',
1951
+ kind: 'hidden-inline-style',
1952
+ })
1953
+ }
1954
+ }
1955
+
948
1956
  // unframed-chart — full-text tag-scoped scan (not per-line, see RAW_CHART_LEGEND_ARRAY comment).
949
1957
  // Scans `codeText` (comment-stripped) so a legend example inside a comment can't match; reports
950
- // at the line of the `items={[` token itself, honoring the same allow-comment escape as every
951
- // per-line kind by checking the ORIGINAL reported line directly.
1958
+ // at the line of the `items={[` token itself.
952
1959
  if (GUARD_RULES['unframed-chart'].enabled!(cfg) && ruleApplies('unframed-chart', relPath)) {
953
1960
  for (const m of codeText.matchAll(RAW_CHART_LEGEND_ARRAY)) {
954
1961
  const lineNo = codeText.slice(0, (m.index ?? 0) + m[0].length).split('\n').length
955
- if (isSkippedLine(lines[lineNo - 1] ?? '', cfg)) continue
956
- findings.push({ relPath, line: lineNo, token: 'items={[', kind: 'unframed-chart' })
1962
+ push('unframed-chart', lineNo, 'items={[')
957
1963
  }
958
1964
  }
959
1965
 
@@ -973,14 +1979,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
973
1979
  if (HAS_ARIA_LABEL_PROP.test(tagText)) continue
974
1980
  const startLine = codeText.slice(0, m.index ?? 0).split('\n').length
975
1981
  const endLine = startLine + (tagText.split('\n').length - 1)
976
- let skipped = false
977
- for (let n = startLine; n <= endLine; n++) {
978
- if (isSkippedLine(lines[n - 1] ?? '', cfg)) {
979
- skipped = true
980
- break
981
- }
982
- }
983
- if (skipped) continue
1982
+ if (isAllowedInRange(startLine, endLine, 'chart-missing-aria-label')) continue
984
1983
  findings.push({
985
1984
  relPath,
986
1985
  line: startLine,
@@ -998,8 +1997,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
998
1997
  const withBorder = WITH_BORDER_PROP.exec(m[0])
999
1998
  if (withBorder === null) continue
1000
1999
  const lineNo = codeText.slice(0, (m.index ?? 0) + withBorder.index).split('\n').length
1001
- if (isSkippedLine(lines[lineNo - 1] ?? '', cfg)) continue
1002
- findings.push({ relPath, line: lineNo, token: 'withBorder', kind: 'card-with-border' })
2000
+ push('card-with-border', lineNo, 'withBorder')
1003
2001
  }
1004
2002
  }
1005
2003
 
@@ -1016,8 +2014,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
1016
2014
  const value = Number.parseFloat(fontSizeMatch[1] ?? '')
1017
2015
  if (!Number.isFinite(value) || value >= 16) continue
1018
2016
  const lineNo = codeText.slice(0, (m.index ?? 0) + tagText.length).split('\n').length
1019
- if (isSkippedLine(lines[lineNo - 1] ?? '', cfg)) continue
1020
- findings.push({ relPath, line: lineNo, token: fontSizeMatch[0], kind: 'sub-16-input-font' })
2017
+ push('sub-16-input-font', lineNo, fontSizeMatch[0])
1021
2018
  }
1022
2019
 
1023
2020
  for (const m of codeText.matchAll(STYLES_INPUT_PART)) {
@@ -1027,20 +2024,26 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
1027
2024
  const value = Number.parseFloat(fontSizeMatch[1] ?? '')
1028
2025
  if (!Number.isFinite(value) || value >= 16) continue
1029
2026
  const lineNo = codeText.slice(0, (m.index ?? 0) + m[0].length).split('\n').length
1030
- if (isSkippedLine(lines[lineNo - 1] ?? '', cfg)) continue
1031
- findings.push({ relPath, line: lineNo, token: fontSizeMatch[0], kind: 'sub-16-input-font' })
2027
+ push('sub-16-input-font', lineNo, fontSizeMatch[0])
1032
2028
  }
1033
2029
  }
1034
2030
 
1035
- // Per-rule, per-path exemption post-filter (§exemptRules) — applied once here so it uniformly
1036
- // covers every kind regardless of whether it was emitted via the GUARD_RULES registry loop above
1037
- // or one of the inline-handled kinds (raw-surface, raw-html-layout, sub-16-input-font, …).
2031
+ // Post-filters, applied once here so they uniformly cover every kind regardless of whether it was
2032
+ // emitted via the GUARD_RULES registry loop above or one of the inline-handled kinds:
2033
+ // • generated — the lines of a stylesheet basalt itself emitted (empty for every other file);
2034
+ // • §exemptRules — per-rule, per-path consumer exemptions;
2035
+ // • markup — an HTML document / JSON manifest has no JSX and no CSS-in-JS, so only the
2036
+ // color/typography kinds are meaningful there;
2037
+ // • profile — a tokens-only consumer is not told to install @mantine/core.
1038
2038
  //
1039
2039
  // `text` is stamped here too, in the same single post-pass, off the ORIGINAL (unstripped) `lines`
1040
2040
  // split — never `codeLines` — so a finding on a line whose comment was stripped still reports the
1041
2041
  // real source text a human would read.
1042
2042
  return findings
2043
+ .filter((f) => !generatedLines.has(f.line))
1043
2044
  .filter((f) => !isRuleExempt(f.kind, f.relPath, cfg.exemptRules))
2045
+ .filter((f) => syntax !== 'markup' || MARKUP_KINDS.has(f.kind))
2046
+ .filter((f) => cfg.profile !== 'tokens-only' || !TOKENS_ONLY_DISABLED_KINDS.has(f.kind))
1044
2047
  .map((f) => ({
1045
2048
  ...f,
1046
2049
  text: textForFinding(lines[f.line - 1], f.token),