basalt-ui 1.19.1 → 1.20.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 (78) hide show
  1. package/AGENTS.md +15 -1
  2. package/CHANGELOG.md +20 -0
  3. package/MIGRATING.md +228 -0
  4. package/README.md +53 -9
  5. package/agent/rules/basalt-app.md +43 -0
  6. package/agent/rules/basalt-charts.md +26 -14
  7. package/agent/rules/basalt-mantine.md +25 -10
  8. package/agent/rules/basalt-router.md +22 -10
  9. package/agent/rules/basalt-tokens.md +75 -6
  10. package/agent/skills/basalt-app/SKILL.md +42 -6
  11. package/configs/check.yml +11 -4
  12. package/configs/lefthook.yml +21 -2
  13. package/configs/oxlint-plugin.js +515 -25
  14. package/configs/oxlint-plugin.test.ts +383 -10
  15. package/configs/oxlint.json +10 -1
  16. package/dist/agent/stick-to-bottom.js +1 -1
  17. package/dist/agent-chat/thread-detail-panel.js +1 -1
  18. package/dist/agent-chat/thread-message.js +2 -2
  19. package/dist/agent-chat/tool-chip.js +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/Donut.js +1 -1
  22. package/dist/charts/primitives/ChartLegend.js +2 -2
  23. package/dist/charts/primitives/ChartTooltip.js +1 -1
  24. package/dist/cli/index.d.ts +107 -15
  25. package/dist/cli/index.d.ts.map +1 -1
  26. package/dist/cli/index.js +610 -72
  27. package/dist/cli/toolchain-wiring.test.d.ts +2 -0
  28. package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
  29. package/dist/dashboard/delta-badge.js +2 -2
  30. package/dist/dashboard/stat-card.js +1 -1
  31. package/dist/data/virtual-list.js +5 -5
  32. package/dist/guard/index.d.ts +56 -2
  33. package/dist/guard/index.d.ts.map +1 -1
  34. package/dist/guard/index.js +326 -59
  35. package/dist/guard/types.d.ts +25 -5
  36. package/dist/guard/types.d.ts.map +1 -1
  37. package/dist/register.d.ts +25 -0
  38. package/dist/register.d.ts.map +1 -1
  39. package/dist/shell/app-mobile-nav.module.css +11 -3
  40. package/dist/surfaces.d.ts +1 -1
  41. package/dist/surfaces.js +1 -1
  42. package/dist/tokens/index.d.ts +3 -3
  43. package/dist/tokens/index.d.ts.map +1 -1
  44. package/dist/tokens/index.js +2 -0
  45. package/dist/tokens/palette.d.ts +35 -27
  46. package/dist/tokens/palette.d.ts.map +1 -1
  47. package/dist/tokens/palette.js +24 -18
  48. package/dist/tokens.css +19 -17
  49. package/dist/vite-color-mix.js +2 -2
  50. package/llms.txt +3 -3
  51. package/package.json +4 -3
  52. package/src/agent/stick-to-bottom.tsx +1 -1
  53. package/src/agent-chat/thread-detail-panel.tsx +1 -1
  54. package/src/agent-chat/thread-message.tsx +3 -3
  55. package/src/agent-chat/tool-chip.tsx +1 -1
  56. package/src/charts/kinds/Donut.tsx +2 -1
  57. package/src/charts/primitives/ChartLegend.tsx +2 -2
  58. package/src/charts/primitives/ChartTooltip.tsx +1 -1
  59. package/src/cli/index.ts +1070 -99
  60. package/src/cli/init-sync.test.ts +19 -3
  61. package/src/cli/tokens-css.test.ts +27 -14
  62. package/src/cli/toolchain-wiring.test.ts +694 -0
  63. package/src/dashboard/delta-badge.tsx +3 -3
  64. package/src/dashboard/stat-card.tsx +1 -1
  65. package/src/data/virtual-list.tsx +8 -8
  66. package/src/guard/check-source.test.ts +663 -11
  67. package/src/guard/index.ts +727 -94
  68. package/src/guard/types.ts +29 -4
  69. package/src/register.ts +32 -0
  70. package/src/shell/app-mobile-nav.module.css +11 -3
  71. package/src/shell/app-mobile-nav.tsx +1 -1
  72. package/src/surfaces.ts +1 -1
  73. package/src/theme/density-relations.test.ts +8 -5
  74. package/src/theme/spacing.test.ts +2 -0
  75. package/src/theme-toggle/index.tsx +2 -2
  76. package/src/tokens/index.ts +5 -3
  77. package/src/tokens/palette.ts +24 -18
  78. package/src/vite-color-mix.ts +2 -2
@@ -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
@@ -281,26 +396,42 @@ export const DEFAULT_GUARD_CONFIG: GuardConfig = {
281
396
  * someone remembering. Adding a kind here is part of shipping it; removing the entry IS the
282
397
  * promotion, and belongs in its own commit so the changelog says enforcement got stricter.
283
398
  *
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).
399
+ * The runway is measured in consumer UPGRADES, not in version numbers: `mantine-shade-index`
400
+ * (the table's first tenant, promoted and gone) sat here across two minors because 1.8.0 shipped
401
+ * the same day as 1.7.0 and 1.9.0 carried the chart batch the same consumer was upgrading for —
402
+ * bundling a build-breaking promotion into the minor they take for the fixes turns a routine bump
403
+ * into an unplanned refactor, which is exactly what the grace minor exists to prevent. The five
404
+ * entries below all landed together in the round-4 guard minor and all promote one minor later.
293
405
  *
294
406
  * @example
295
407
  * const GRACE: Partial<Record<GuardKind, string>> = {
296
408
  * 'raw-font-family': 'introduced 1.4.0 — promote to error in 1.5.0',
297
409
  * }
298
410
  */
299
- const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, string>> = {}
411
+ const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, string>> = {
412
+ 'theme-allow-unscoped':
413
+ 'introduced in the round-4 guard minor — promote to error one minor later. Every consumer has ' +
414
+ 'bare `theme-allow` comments today (basalt itself has ~20), and landing this as an error would ' +
415
+ 'fail every one of their builds on an upgrade they took for the fixes.',
416
+ 'surface-shadow-override':
417
+ 'introduced in the round-4 guard minor — promote to error one minor later. Catches the shape a ' +
418
+ 'TOKEN-FLUENT consumer writes, so the code it rejects is code that looks correct and passed ' +
419
+ 'every previous release.',
420
+ 'css-raw-surface':
421
+ 'introduced in the round-4 guard minor — promote to error one minor later. The surface kinds ' +
422
+ 'reaching CSS at all is the "existing kind, new file type" case the doctrine names explicitly.',
423
+ 'inline-font-size':
424
+ 'introduced in the round-4 guard minor — promote to error one minor later. The oxlint plugin ' +
425
+ 'has enforced the same thing as `basalt/no-raw-font-size` for a while, so a consumer running ' +
426
+ 'both sees nothing new; one running check-theme alone gets a minor of runway.',
427
+ 'hidden-inline-style':
428
+ 'introduced in the round-4 guard minor — promote by DELETING the kind and folding its two ' +
429
+ 'scans into `raw-html-layout`, which is where they belong once the widening has had its minor.',
430
+ }
300
431
 
301
432
  /** A kind's effective severity: consumer override first, then the grace table, then `error`. */
302
433
  function severityOf(kind: GuardKind, cfg: GuardConfig): GuardSeverity {
303
- return cfg.severity?.[kind] ?? (kind in GRACE_PERIOD_KINDS ? 'warn' : 'error')
434
+ return cfg.severity?.[kind] ?? (Object.hasOwn(GRACE_PERIOD_KINDS, kind) ? 'warn' : 'error')
304
435
  }
305
436
 
306
437
  /** The `Finding.text` cap, in characters — a minified CSS line must not blow up the report. */
@@ -332,40 +463,412 @@ function isChartFile(relPath: string): boolean {
332
463
  * are equivalent); a substring match against one longer segment (e.g. `'age'` against `agent`)
333
464
  * never counts.
334
465
  */
466
+ /**
467
+ * Kinds that inherit another kind's `exemptRules` entry. A grace-minor kind that exists only to
468
+ * carry a WIDENING of an established kind is the same rule to a consumer, so an exemption written
469
+ * for the parent must cover it — otherwise the widening arrives as noise in exactly the paths
470
+ * someone already decided the rule does not apply to. The entry disappears with the merge.
471
+ */
472
+ const EXEMPT_RULE_ALIASES: Partial<Record<GuardKind, GuardKind>> = {
473
+ 'hidden-inline-style': 'raw-html-layout',
474
+ 'surface-shadow-override': 'raw-surface',
475
+ 'css-raw-surface': 'raw-surface',
476
+ }
477
+
335
478
  function isRuleExempt(
336
479
  kind: GuardKind,
337
480
  relPath: string,
338
481
  exemptRules: GuardConfig['exemptRules'],
339
482
  ): boolean {
483
+ const alias = EXEMPT_RULE_ALIASES[kind]
484
+ if (alias !== undefined && isRuleExempt(alias, relPath, exemptRules)) return true
340
485
  const patterns = exemptRules?.[kind]
341
486
  if (patterns === undefined || patterns.length === 0) return false
342
487
  const segments = relPath.split('/')
343
488
  return patterns.some((pattern) => segments.includes(pattern.replace(/\/$/, '')))
344
489
  }
345
490
 
491
+ // ── theme-allow annotations ──────────────────────────────────────────────────────────────────────
492
+
493
+ /**
494
+ * The oxlint plugin's rule ids. Not guard kinds — they can never suppress a `checkSource` finding —
495
+ * but they ARE valid ids in a `theme-allow`, so an annotation scoped to one of them must parse as
496
+ * accountable rather than be reported as unscoped. Kept as a literal list because the plugin is a
497
+ * standalone `.js` file this module must not import (see the plugin's own header).
498
+ */
499
+ const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
500
+ 'no-raw-font-size',
501
+ 'raw-size-literal',
502
+ 'card-inset',
503
+ 'chart-in-raw-surface',
504
+ 'hand-rolled-plot',
505
+ 'chart-legend-literal',
506
+ 'hand-rolled-shell',
507
+ 'shadow-basalt-export',
508
+ 'raw-scroll-container',
509
+ 'visx-boundary',
510
+ 'visx-tooltip',
511
+ 'token-layer-boundary',
512
+ // These three honour `basalt-agent-allow`, never `theme-allow` — but they are still REAL ids, so
513
+ // a `theme-allow ai-sdk-major` must parse as a (useless) scoped annotation rather than as prose.
514
+ 'agent-resume-guard',
515
+ 'agent-no-raw-usechat',
516
+ 'ai-sdk-major',
517
+ ])
518
+
519
+ /** The shortest string accepted as a written reason — enough to exclude a stray separator. */
520
+ const MIN_ALLOW_REASON_LENGTH = 4
521
+
522
+ /**
523
+ * One parsed `theme-allow` annotation.
524
+ *
525
+ * `rules` empty AND `unknownRules` empty is the legacy bare form, and only that covers every kind.
526
+ * `unknownRules` holds words that occupied the rule-id slot but name no rule — a typo. Its presence
527
+ * is what makes the parse FAIL CLOSED: `theme-allow raw-hexx — reason` used to consume no id, fall
528
+ * through to `rules: []`, and be read as the blanket form, so one mistyped character escalated a
529
+ * scoped waiver into a whole-line one. A waiver that names a rule must never be more permissive
530
+ * than the same waiver spelled correctly.
531
+ */
532
+ type AllowAnnotation = {
533
+ readonly rules: readonly string[]
534
+ readonly unknownRules: readonly string[]
535
+ readonly hasReason: boolean
536
+ }
537
+
538
+ /** One annotation at the (1-based) line it was WRITTEN on — where `theme-allow-unscoped` reports. */
539
+ type AllowDeclaration = { readonly line: number; readonly annotation: AllowAnnotation }
540
+
541
+ /**
542
+ * Parse the text following the allow token on one line.
543
+ *
544
+ * The grammar is `theme-allow [<rule-id>[, <rule-id>]…] [<separator>] <reason>`. Ids are consumed
545
+ * while they are KNOWN (a guard kind or a plugin rule, optionally `basalt/`-prefixed). A word that
546
+ * occupies the id slot but names no rule ENDS the list as an UNKNOWN id rather than starting the
547
+ * reason — see {@link AllowAnnotation.unknownRules} for why that direction is the safe one.
548
+ *
549
+ * A prose reason therefore has to be introduced the way every annotation in the wild already writes
550
+ * it — with a separator (`—`, `–`, `-`, `:`) — which is what keeps `theme-allow: bespoke mono
551
+ * micro-label` a blanket waiver while `theme-allow raw-hexx — …` waives nothing.
552
+ *
553
+ * **The id slot closes at the first space that no comma opened.** The FIRST word after the token
554
+ * always sits in the id slot, because that is where a typo'd id lands and failing closed there is
555
+ * the whole point. After a resolved id, only a `,` keeps the list open: an unknown word arriving
556
+ * across a comma is a claimed id (`raw-hex, raw-surfacee`) and gets recorded, an unknown word
557
+ * arriving across a space is prose (`raw-surface sub-scale legend corner`, one em-dash away from
558
+ * annotations this package itself ships) and starts the reason. Reporting the second as a typo was
559
+ * a live false-positive class on a waiver that was in fact scoped and reasoned.
560
+ *
561
+ * `Object.hasOwn`, not `in`: `in` walks the prototype chain, so a reason beginning with the word
562
+ * `constructor` (or `toString`, `valueOf`, …) resolved as a real rule id and silently scoped the
563
+ * waiver to a kind that does not exist.
564
+ */
565
+ function parseAllowAnnotation(rest: string): AllowAnnotation {
566
+ const rules: string[] = []
567
+ const unknownRules: string[] = []
568
+ let remainder = rest.replace(/^[\s,]+/, '')
569
+ let inIdSlot = true
570
+ for (;;) {
571
+ const token = /^(?:basalt\/)?([a-z][a-z0-9-]*)(?=$|[\s,:—–])/.exec(remainder)
572
+ const id = token?.[1]
573
+ if (token === null || id === undefined) break
574
+ if (!(Object.hasOwn(GUARD_RULES, id) || PLUGIN_RULE_IDS.has(id))) {
575
+ if (inIdSlot) unknownRules.push(id)
576
+ break
577
+ }
578
+ rules.push(id)
579
+ const after = remainder.slice(token[0].length)
580
+ remainder = after.replace(/^[\s,]+/, '')
581
+ inIdSlot = /^\s*,/.test(after)
582
+ }
583
+ const reason = remainder.replace(/^(?:—|–|-{1,2}|:)\s*/, '').trim()
584
+ return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH }
585
+ }
586
+
587
+ /** How far back a trailing CSS annotation reaches for the declaration it terminates. */
588
+ const MAX_CSS_CONTINUATION_LINES = 8
589
+
590
+ /**
591
+ * A comment opener sitting immediately before the annotation token.
592
+ *
593
+ * `stripComments` is the primary evidence that an occurrence is inside a comment, but it is
594
+ * deliberately biased toward treating an ambiguous `/` as division (see its own doc), and JSX makes
595
+ * that bias fire constantly: in `<Box p={18} /> // theme-allow` the `/` of `/>` follows a `}`, so
596
+ * the stripper reads a regex literal and the trailing comment survives unstripped. This prefix test
597
+ * is the second witness — cheap, local, and exact for every shape that actually occurs.
598
+ */
599
+ const COMMENT_OPENER_BEFORE = /(?:\/\/|\/\*|<!--)\s*$/
600
+
601
+ /** A line that is NOTHING but a comment — the placement that waives the line below it. */
602
+ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
603
+
604
+ /**
605
+ * Everything allowed between the comment opener (or the start of the line) and the annotation
606
+ * token, for the token to count as an ANNOTATION rather than prose that mentions one.
607
+ *
608
+ * The asymmetry is deliberate, and it is about PLACEMENT only. WAIVING does not care where in the
609
+ * comment the token sits — any `theme-allow` inside a comment is read as an annotation, exactly as
610
+ * before, so no upgrade takes a build down over comment placement. REPORTING
611
+ * `theme-allow-unscoped` is strict, because otherwise every sentence documenting the escape hatch
612
+ * becomes a finding: this package's own `guard/types.ts` explains the syntax in five lines of
613
+ * JSDoc, and all five were reported.
614
+ *
615
+ * What the annotation then WAIVES is a separate, fail-closed question — see
616
+ * {@link parseAllowAnnotation}. A word in the id slot that names no rule waives nothing, so
617
+ * `// theme-allow legacy vendor asset` (a prose reason with no separator introducing it) no longer
618
+ * suppresses. That IS a break, deliberately: the alternative is one mistyped character silently
619
+ * widening a scoped waiver into a blanket one. No consumer writes that shape today — every
620
+ * annotation across all seven repos introduces its reason with `—`, `–`, `-` or `:` — which is why
621
+ * it ships as a documented break rather than a `GRACE_PERIOD_KINDS` entry. Grace could not express
622
+ * it anyway: the kind that fires is the UNSUPPRESSED one (`raw-hex`), not `theme-allow-unscoped`,
623
+ * and downgrading `raw-hex` to waive-by-default is the hole this closed.
624
+ */
625
+ const ANNOTATION_PREFIX = /(?:^|\/\/|\/\*|<!--|^\s*\*)\s*$/
626
+
627
+ /**
628
+ * Every line an allow annotation WAIVES, keyed by 0-based line index. Three placements, each one a
629
+ * shape people actually write — and the union of them is what makes the escape survive a formatter.
630
+ *
631
+ * 1. **Its own line.** The original rule, unchanged.
632
+ * 2. **The line below, when the annotation line is a comment and nothing else.** This is the
633
+ * placement the oxlint plugin has always honored and the only one JSX can express: the reported
634
+ * line is usually a multi-line opening tag or a `{expr}` child, where a trailing `//` is a
635
+ * syntax error or renders as visible text. Requiring the annotation line to be comment-ONLY is
636
+ * what keeps a TRAILING comment scoped to its own line — otherwise
637
+ * `const a = '#f00' // theme-allow` would silently waive the next line too.
638
+ * 3. **The rest of the CSS declaration it terminates.** The shipped `oxfmt` reflows
639
+ * `background-color: var(--x, #232326); /* theme-allow *\/` into four lines with the hex on one
640
+ * and the comment on another — two shipped tools pulling in opposite directions, and the
641
+ * exception silently stopped working. Walking back over the continuation lines (stopping at the
642
+ * first `;` / `{` / `}`, which CSS actually has) restores it. Deliberately CSS-only: oxfmt emits
643
+ * NO semicolons in TS, so there is no statement terminator to stop at and the same walk would
644
+ * waive arbitrary code above.
645
+ *
646
+ * An occurrence only counts when it sits inside a COMMENT, which is read off the stripped text:
647
+ * `stripComments` replaces comment characters 1:1 with spaces, so a position that is non-space in
648
+ * the source and space in the stripped copy was a comment. That is what keeps a `'theme-allow'`
649
+ * inside a string literal (or in this file's own rule table) from silently disabling a rule.
650
+ */
651
+ function collectAllowAnnotations(
652
+ lines: readonly string[],
653
+ codeLines: readonly string[],
654
+ allowComment: string,
655
+ syntax: GuardSyntax,
656
+ ): { waivers: Map<number, AllowAnnotation[]>; declared: AllowDeclaration[] } {
657
+ const byLine = new Map<number, AllowAnnotation[]>()
658
+ const declared: AllowDeclaration[] = []
659
+ const add = (index: number, annotation: AllowAnnotation): void => {
660
+ const list = byLine.get(index) ?? []
661
+ list.push(annotation)
662
+ byLine.set(index, list)
663
+ }
664
+
665
+ for (let i = 0; i < lines.length; i++) {
666
+ const line = lines[i] ?? ''
667
+ const stripped = codeLines[i] ?? ''
668
+ const isCommentOnlyLine = stripped.trim() === '' || COMMENT_ONLY_LINE.test(line)
669
+ let from = 0
670
+ for (;;) {
671
+ const at = line.indexOf(allowComment, from)
672
+ if (at === -1) break
673
+ from = at + allowComment.length
674
+ // Not inside a comment (neither witness agrees) → real code, e.g. this file's own rule table.
675
+ if (stripped[at] !== ' ' && !COMMENT_OPENER_BEFORE.test(line.slice(0, at))) continue
676
+ const annotation = parseAllowAnnotation(line.slice(from))
677
+ if (ANNOTATION_PREFIX.test(line.slice(0, at))) declared.push({ line: i + 1, annotation })
678
+ add(i, annotation)
679
+ if (isCommentOnlyLine) {
680
+ add(i + 1, annotation)
681
+ continue
682
+ }
683
+ if (syntax !== 'css') continue
684
+ for (let back = 1; back <= MAX_CSS_CONTINUATION_LINES; back++) {
685
+ const previous = (codeLines[i - back] ?? '').trim()
686
+ if (previous === '' || /[;{}]$/.test(previous)) break
687
+ add(i - back, annotation)
688
+ }
689
+ }
690
+ }
691
+ return { waivers: byLine, declared }
692
+ }
693
+
346
694
  /**
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.
695
+ * Does any annotation on this line cover `kind`?
696
+ *
697
+ * Only a BARE annotation (no ids at all, known or unknown) covers every kind. One that reached for
698
+ * a rule id and missed covers exactly the ids it got right — nothing widens on a typo.
352
699
  */
353
- function isSkippedLine(line: string, cfg: GuardConfig): boolean {
354
- return line.includes(cfg.allowComment)
700
+ function annotationsCover(annotations: AllowAnnotation[] | undefined, kind: GuardKind): boolean {
701
+ return (annotations ?? []).some(
702
+ (a) => (a.rules.length === 0 && a.unknownRules.length === 0) || a.rules.includes(kind),
703
+ )
355
704
  }
356
705
 
357
- /** The 2 comment/regex dialects the stripper understands, resolved from the file's own extension. */
358
- type GuardSyntax = 'ts' | 'css'
706
+ /** The 3 comment dialects the stripper understands, resolved from the file's own extension. */
707
+ type GuardSyntax = 'ts' | 'css' | 'markup'
359
708
 
360
709
  /**
361
710
  * CSS (including `.module.css`) has no `//` line-comment syntax at all — an unquoted `//`, e.g.
362
711
  * inside `url(https://…)`, is real CSS text, never a comment opener. Match on the `.css` suffix
363
712
  * (not a naive `split('.')`) so `foo.module.css` resolves the same as `foo.css`.
713
+ *
714
+ * `markup` covers the two files whose colors nothing re-derives on a theme change and which the
715
+ * scan never reached — `index.html` and `*.webmanifest` (plus plain `.json`, which a webmanifest
716
+ * often is). Only the color/typography kinds apply there; see `MARKUP_KINDS`.
364
717
  */
365
718
  function guardSyntaxFor(relPath: string): GuardSyntax {
366
- return relPath.endsWith('.css') ? 'css' : 'ts'
719
+ if (relPath.endsWith('.css')) return 'css'
720
+ if (/\.(?:html?|webmanifest|json)$/.test(relPath)) return 'markup'
721
+ return 'ts'
722
+ }
723
+
724
+ /** The only kinds meaningful in an HTML document or a JSON manifest — no JSX, no CSS-in-JS. */
725
+ const MARKUP_KINDS: ReadonlySet<GuardKind> = new Set(['raw-hex', 'raw-color-fn', 'raw-font-family'])
726
+
727
+ /** Blanks out `<!-- … -->` 1:1 with spaces, preserving every offset. JSON has no comment syntax. */
728
+ function stripMarkupComments(text: string): string {
729
+ return text.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '))
730
+ }
731
+
732
+ /**
733
+ * The exact first line of every stylesheet the CLI emits — the contract between `basalt-ui
734
+ * tokens:css` / `fonts:css` and this scanner, and the single source of truth for it (`src/cli`
735
+ * imports this rather than keeping its own copy, which is how the two used to drift apart).
736
+ *
737
+ * The marker exists because `check-theme` reported the file a sibling command had just written:
738
+ * 116 of rollhook's 117 violations were inside the emitted token stylesheet, which is nothing but
739
+ * hex and `rgba()` by construction.
740
+ *
741
+ * **Command-NEUTRAL by necessity.** Line 1 is compared verbatim, so it cannot name the command
742
+ * that produced the file without needing one accepted marker per command — and a second accepted
743
+ * marker is a second forgery surface. It points at the provenance line instead, which does name
744
+ * the command. It used to say `regenerate with \`bunx basalt-ui tokens:css\`` on a `fonts:css`
745
+ * file too: following that instruction overwrote a font sheet with the palette sheet.
746
+ */
747
+ export const GENERATED_HEADER_LINE =
748
+ '/* @generated basalt-ui — do not edit; regenerate with the command on the next line */'
749
+
750
+ /** Line 2 of the header: the emitting version and the exact invocation that produced the file. */
751
+ const GENERATED_PROVENANCE_LINE =
752
+ /^\/\* basalt-ui \d+\.\d+\.\d+[^\s]* — `basalt-ui (?:tokens:css|fonts:css)[^`]*` \*\/$/
753
+
754
+ /** The header is exactly the two lines `generatedHeader()` writes — nothing further in counts. */
755
+ const GENERATED_HEADER_LINES = 2
756
+
757
+ /** A comment that OPENS AND CLOSES on its own line — the only comment shape the emitters write. */
758
+ const GENERATED_COMMENT_LINE = /^\/\*(?:[^*]|\*(?!\/))*\*\/$/
759
+
760
+ /** Outside a block: a selector, a selector-list continuation ending in `,`, or an at-rule opener. */
761
+ const GENERATED_SELECTOR_LINE = /^[^{};]*[,{]$/
762
+
763
+ /** Inside a block: a declaration of a custom property in basalt's OWN namespace, or the close. */
764
+ const GENERATED_DECL_LINE = /^(?:--(?:vx|basalt)-[A-Za-z0-9_-]*\s*:[^{};]*;|\})$/
765
+
766
+ /** No line is skipped in a file that is not one basalt emitted — the shared empty answer. */
767
+ const NO_GENERATED_LINES: ReadonlySet<number> = new Set()
768
+
769
+ /** Net brace depth a line moves the parser by — CSS blocks, counted the cheap way. */
770
+ function braceDelta(line: string): number {
771
+ let delta = 0
772
+ for (const ch of line) {
773
+ if (ch === '{') delta++
774
+ else if (ch === '}') delta--
775
+ }
776
+ return delta
777
+ }
778
+
779
+ /**
780
+ * The (1-based) lines of a stylesheet basalt itself emitted, which the scan may skip. Empty for
781
+ * every other file.
782
+ *
783
+ * The marker used to be honoured on its own: any file whose first five lines contained the string
784
+ * `@generated basalt-ui` was skipped ENTIRELY. That made a two-word comment a whole-file guard
785
+ * bypass — prepend it to any `.tsx` and every finding in it disappears. The replacement gated the
786
+ * whole file on a header plus a line-shape allowlist, and the allowlist was loose enough to forge:
787
+ * `--vx-pad: 0; box-shadow: 0 0 0 1px #ff0000;` passed as "a basalt custom property" because the
788
+ * value pattern permitted `;`, and `/* x *\/ .btn { color: #ff0000; }` passed as "a comment"
789
+ * because the comment pattern never required the comment to close. Both are browser-effective CSS,
790
+ * and either one bought a whole-file exemption.
791
+ *
792
+ * Two changes close that, and the second is the structural one:
793
+ *
794
+ * 1. **The exemption is PER LINE, not per file.** A line-shape allowlist that misses now costs one
795
+ * line, not the whole file — the blast radius of the next mistake is bounded by construction.
796
+ * It also reports the smuggled line precisely instead of burying it under 300 token values.
797
+ * 2. **The allowlist is brace-depth-aware.** CSS declarations only take effect INSIDE a block, so
798
+ * at depth ≥ 1 the only skippable line is a `--vx-*`/`--basalt-*` declaration whose value
799
+ * carries no `;`, a `}`, a self-closing comment, or a blank. `box-shadow: … #ff0000,` is a
800
+ * depth-1 line and is scanned no matter how it is shaped. At depth 0 a loose selector pattern
801
+ * is safe precisely because nothing there is a declaration.
802
+ *
803
+ * Three conditions still gate the file itself: a `.css` path (the CLI emits CSS and nothing else,
804
+ * so the marker is ignored in `.ts`/`.tsx`/`.html`/`.json`, which is where the guard has its
805
+ * teeth), the canonical header verbatim on line 1, and the provenance line on line 2.
806
+ *
807
+ * A line carrying the allow comment is never skipped: an emitted sheet contains no `theme-allow`,
808
+ * and skipping one would suppress the `theme-allow-unscoped` report of a waiver hidden in a file
809
+ * wearing the header.
810
+ *
811
+ * Residual, knowingly accepted: at depth 0 a forger can hide a finding inside something shaped
812
+ * like a selector (`.a[data-x='#ff0000'] {`), and inside a block they can hide the value of a
813
+ * custom property they declare in basalt's own namespace. Neither is browser-effective as a style
814
+ * and the second IS the artifact class this exemption exists for.
815
+ */
816
+ function generatedTokenLines(
817
+ relPath: string,
818
+ lines: readonly string[],
819
+ allowComment: string,
820
+ ): ReadonlySet<number> {
821
+ if (!relPath.endsWith('.css')) return NO_GENERATED_LINES
822
+ if ((lines[0] ?? '').trim() !== GENERATED_HEADER_LINE) return NO_GENERATED_LINES
823
+ if (!GENERATED_PROVENANCE_LINE.test((lines[1] ?? '').trim())) return NO_GENERATED_LINES
824
+
825
+ const skippable = new Set<number>([1, 2])
826
+ let depth = 0
827
+ for (let i = GENERATED_HEADER_LINES; i < lines.length; i++) {
828
+ const line = lines[i] ?? ''
829
+ const trimmed = line.trim()
830
+ const shaped =
831
+ trimmed === '' ||
832
+ GENERATED_COMMENT_LINE.test(trimmed) ||
833
+ (depth === 0 ? GENERATED_SELECTOR_LINE : GENERATED_DECL_LINE).test(trimmed)
834
+ if (shaped && !line.includes(allowComment)) skippable.add(i + 1)
835
+ depth += braceDelta(line)
836
+ // Unbalanced braces mean this is not the emitters' output at all — fail closed on the rest.
837
+ if (depth < 0) return NO_GENERATED_LINES
838
+ }
839
+ return skippable
367
840
  }
368
841
 
842
+ /**
843
+ * Kinds disabled under `profile: 'tokens-only'` — every kind whose remedy is a Mantine component,
844
+ * a Mantine prop, or the React theme factory. See {@link GuardConfig.profile}.
845
+ *
846
+ * Exported so the CLI can name the profile in `--help`/`doctor` output without re-deriving the
847
+ * list, and so a test can assert the two halves (disabled vs. surviving) partition the registry.
848
+ */
849
+ export const TOKENS_ONLY_DISABLED_KINDS: ReadonlySet<GuardKind> = new Set([
850
+ 'localstorage-theme',
851
+ 'off-identity-accent',
852
+ 'mantine-shade-index',
853
+ 'raw-spacing',
854
+ 'raw-radius',
855
+ 'card-with-border',
856
+ 'off-system-surface-var',
857
+ 'raw-html-layout',
858
+ // Same remedy as raw-html-layout, word for word ("use a Mantine layout primitive") — it is that
859
+ // kind's widening, so it has to share its gate here exactly as it shares it in
860
+ // EXEMPT_RULE_ALIASES. Splitting them told a Mantine-free consumer to import Box/Flex.
861
+ 'hidden-inline-style',
862
+ 'inline-spacing',
863
+ 'inline-display',
864
+ 'raw-visx-axis',
865
+ 'raw-motion-value',
866
+ 'unframed-chart',
867
+ 'chart-missing-aria-label',
868
+ 'raw-form-control',
869
+ 'sub-16-input-font',
870
+ ])
871
+
369
872
  // A `/` opens a regex literal (not division) when the previous significant token is one of these
370
873
  // punctuation marks, or one of the REGEX_PRECEDING_KEYWORDS below, or nothing has been scanned yet
371
874
  // (start of input) — the standard JS lexer disambiguation. `ts`-syntax only; CSS has no regexes.
@@ -435,7 +938,7 @@ const WORD_CHAR = /[A-Za-z0-9_$]/
435
938
  * regex-open would swallow real code as "protected regex content" and could hide a genuine
436
939
  * violation — the failure mode BUG 1 already showed is unacceptable.
437
940
  */
438
- function stripComments(text: string, syntax: GuardSyntax): string {
941
+ function stripComments(text: string, syntax: 'ts' | 'css'): string {
439
942
  const out: string[] = Array.from<string>({ length: text.length })
440
943
  let inLineComment = false
441
944
  let inBlockComment = false
@@ -584,7 +1087,7 @@ type GuardRule = {
584
1087
  }
585
1088
 
586
1089
  /**
587
- * The closed registry of all 20 guard kinds. The triad test asserts
1090
+ * The closed registry of all 25 guard kinds. The triad test asserts
588
1091
  * `surface.guardKinds ⊆ keyof GUARD_RULES` at runtime.
589
1092
  *
590
1093
  * raw-surface, raw-html-layout, and sub-16-input-font are handled inline in checkSource
@@ -775,6 +1278,42 @@ export const GUARD_RULES = {
775
1278
  message:
776
1279
  '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
1280
  },
1281
+ 'theme-allow-unscoped': {
1282
+ kind: 'theme-allow-unscoped',
1283
+ pattern: /theme-allow/, // handled inline (annotation pass); entry keeps the registry complete
1284
+ message:
1285
+ '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.',
1286
+ },
1287
+ 'surface-shadow-override': {
1288
+ kind: 'surface-shadow-override',
1289
+ pattern: SHADOW_DECL, // handled inline (value is judged, not just matched)
1290
+ enabled: (cfg: GuardConfig) => cfg.rawSurface,
1291
+ message:
1292
+ 'Token-composed boxShadow that REPLACES card depth — a card whose shadow drops --vx-shadow-card is the one card in the app with no depth. Compose with it (`${VX.shadowCard}, <your ring>`) rather than instead of it.',
1293
+ },
1294
+ 'css-raw-surface': {
1295
+ kind: 'css-raw-surface',
1296
+ pattern: CSS_SURFACE_RADIUS, // handled inline (value is judged); entry keeps the registry complete
1297
+ enabled: (cfg: GuardConfig) => cfg.rawSurface,
1298
+ // The kebab dialect only exists in CSS text; the camelCase half is `raw-surface`.
1299
+ appliesTo: (relPath) => relPath.endsWith('.css'),
1300
+ message:
1301
+ '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.',
1302
+ },
1303
+ 'hidden-inline-style': {
1304
+ kind: 'hidden-inline-style',
1305
+ pattern: RAW_HTML_LAYOUT_TAG, // handled inline (full-text tag-scoped scan + hoisted-const lookup)
1306
+ enabled: (cfg: GuardConfig) => cfg.rawHtmlLayout,
1307
+ appliesTo: (relPath) => !isChartFile(relPath) && !relPath.endsWith('.css'),
1308
+ message:
1309
+ '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.',
1310
+ },
1311
+ 'inline-font-size': {
1312
+ kind: 'inline-font-size',
1313
+ pattern: INLINE_FONT_SIZE,
1314
+ message:
1315
+ '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.',
1316
+ },
778
1317
  } as const satisfies Record<GuardKind, GuardRule>
779
1318
 
780
1319
  /** Whether `kind`'s rule fires for `relPath` — `appliesTo` is opt-in; absent means always applies. */
@@ -796,9 +1335,16 @@ function ruleApplies(kind: GuardKind, relPath: string): boolean {
796
1335
  * if (findings.some((f) => f.kind === 'raw-hex')) { ... }
797
1336
  */
798
1337
  export function checkSource(text: string, relPath: string, cfg: GuardConfig): Finding[] {
1338
+ const lines = text.split('\n')
1339
+
1340
+ // Lines basalt itself generated are not consumer source — `tokens:css` emits nothing BUT hex and
1341
+ // rgba() by construction. See generatedTokenLines for why the marker alone is not enough, and
1342
+ // why the exemption is per line rather than per file.
1343
+ const generatedLines = generatedTokenLines(relPath, lines, cfg.allowComment)
1344
+
799
1345
  // Severity and text are stamped once at the end rather than at each of the ~20 push sites — both
800
1346
  // 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.
1347
+ // from the finding's own `line` against the source split above.
802
1348
  const findings: Omit<Finding, 'severity' | 'text'>[] = []
803
1349
 
804
1350
  // Derive the 3 dynamic regexes via GUARD_RULES pattern builders.
@@ -813,70 +1359,132 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
813
1359
  )
814
1360
 
815
1361
  // 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')
1362
+ // kept to resolve the allow-comment escape, since that annotation lives inside a comment the
1363
+ // stripped text has already blanked out. Same length / same newline positions as `text`, so line
1364
+ // numbers computed off either one agree.
820
1365
  const syntax = guardSyntaxFor(relPath)
821
- const codeText = stripComments(text, syntax)
1366
+ const codeText = syntax === 'markup' ? stripMarkupComments(text) : stripComments(text, syntax)
822
1367
  const codeLines = codeText.split('\n')
1368
+ const { waivers, declared } = collectAllowAnnotations(lines, codeLines, cfg.allowComment, syntax)
1369
+
1370
+ /** Is `kind` waived at (1-based) `line`? See `collectAllowAnnotations` for the three placements. */
1371
+ const isAllowed = (line: number, kind: GuardKind): boolean =>
1372
+ annotationsCover(waivers.get(line - 1), kind)
1373
+
1374
+ const isAllowedInRange = (start: number, end: number, kind: GuardKind): boolean => {
1375
+ for (let n = start; n <= end; n++) if (isAllowed(n, kind)) return true
1376
+ return false
1377
+ }
1378
+
1379
+ const push = (kind: GuardKind, line: number, token: string): void => {
1380
+ if (isAllowed(line, kind)) return
1381
+ findings.push({ relPath, line, token, kind })
1382
+ }
1383
+
1384
+ // theme-allow accountability. Reported directly rather than through `push`: a bare annotation
1385
+ // covers every kind, so routing it through the waiver check would let it waive the report of its
1386
+ // own unaccountability. `basalt.severity` / `exemptRules` are the ways to turn it down.
1387
+ for (const { line, annotation } of declared) {
1388
+ if (annotation.unknownRules.length === 0 && annotation.rules.length > 0 && annotation.hasReason)
1389
+ continue
1390
+ // "waives nothing" is only true when NO id resolved. With one that did, the typo costs its own
1391
+ // id and nothing else — saying otherwise contradicts the findings the same run suppressed.
1392
+ const unknown = `unknown rule id '${annotation.unknownRules.join("', '")}'`
1393
+ const missing =
1394
+ annotation.unknownRules.length > 0
1395
+ ? annotation.rules.length === 0
1396
+ ? `${unknown} — waives nothing`
1397
+ : `${unknown} — not waived`
1398
+ : annotation.rules.length === 0
1399
+ ? 'no rule id'
1400
+ : 'no reason'
1401
+ findings.push({
1402
+ relPath,
1403
+ line,
1404
+ token: `${cfg.allowComment} (${missing})`,
1405
+ kind: 'theme-allow-unscoped',
1406
+ })
1407
+ }
1408
+
1409
+ /** Lines the line-scoped `raw-html-layout` already owns, so `hidden-inline-style` can't double-report. */
1410
+ const rawHtmlLayoutLines = new Set<number>()
823
1411
 
824
1412
  for (let i = 0; i < lines.length; i++) {
825
1413
  const line = codeLines[i] ?? ''
826
1414
 
827
- // Skip lines with the allow-comment (checked against the ORIGINAL line text).
828
- if ((lines[i] ?? '').includes(cfg.allowComment)) continue
829
-
830
1415
  // Always-on kinds (raw-hex, raw-color-fn, localstorage-theme) — patterns from GUARD_RULES.
831
1416
  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' })
1417
+ push('raw-hex', i + 1, m[0])
833
1418
  }
834
1419
  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' })
1420
+ push('raw-color-fn', i + 1, m[0])
836
1421
  }
837
1422
  if (ruleApplies('localstorage-theme', relPath)) {
838
1423
  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' })
1424
+ push('localstorage-theme', i + 1, m[0])
840
1425
  }
841
1426
  }
842
1427
  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' })
1428
+ push('raw-font-family', i + 1, m[0])
1429
+ }
1430
+ for (const m of line.matchAll(GUARD_RULES['inline-font-size'].pattern as RegExp)) {
1431
+ push('inline-font-size', i + 1, m[0])
844
1432
  }
845
1433
 
846
1434
  // Dynamic-regex kinds — patterns already resolved above.
847
1435
  if (ruleApplies('off-identity-accent', relPath)) {
848
1436
  for (const m of line.matchAll(forbiddenAccentRe)) {
849
- findings.push({ relPath, line: i + 1, token: m[1] ?? '', kind: 'off-identity-accent' })
1437
+ push('off-identity-accent', i + 1, m[1] ?? '')
850
1438
  }
851
1439
  }
852
1440
  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
- }
1441
+ for (const m of line.matchAll(spacingPropRe)) push('raw-spacing', i + 1, m[0])
856
1442
  }
857
1443
  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
- }
1444
+ for (const m of line.matchAll(radiusPropRe)) push('raw-radius', i + 1, m[0])
861
1445
  }
862
1446
 
863
- // raw-surface: 3 separate regex checks, one kind — gated via GUARD_RULES entry.
1447
+ // raw-surface + its two younger siblings, all gated on the same `rawSurface` knob:
1448
+ // • raw-surface — the camelCase (TSX inline-style) literals, unchanged;
1449
+ // • css-raw-surface — the kebab dialect, which the camelCase patterns never saw;
1450
+ // • surface-shadow-override — a shadow built FROM tokens that still drops card depth.
864
1451
  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' })
1452
+ for (const m of line.matchAll(SURFACE_BORDER)) push('raw-surface', i + 1, m[0])
1453
+ for (const m of line.matchAll(SURFACE_RADIUS)) push('raw-surface', i + 1, m[0])
1454
+
1455
+ if (syntax === 'css') {
1456
+ for (const m of line.matchAll(CSS_SURFACE_RADIUS)) {
1457
+ const value = m[1] ?? ''
1458
+ if (!/\d/.test(value) || isAllowedCssRadius(value)) continue
1459
+ push('css-raw-surface', i + 1, m[0].trim())
1460
+ }
1461
+ for (const m of line.matchAll(CSS_SURFACE_SHADOW)) {
1462
+ const value = m[1] ?? ''
1463
+ // A var-composed shadow in a CSS MODULE is deliberately not judged. `surface-shadow-
1464
+ // override` is about a Card/Paper losing `--vx-shadow-card`; a module's own
1465
+ // `box-shadow: var(--field-depth)` is a focus ring, an inset, a notch — component
1466
+ // styling with no card identity at stake. Only the CSS-in-JS dialect, where the shadow
1467
+ // sits on a Mantine surface prop, carries that claim.
1468
+ if (SHADOW_IS_COMPOSED.test(value) || /^\s*none\s*$/.test(value)) continue
1469
+ push('css-raw-surface', i + 1, m[0].trim())
1470
+ }
1471
+ } else {
1472
+ for (const m of line.matchAll(SHADOW_DECL)) {
1473
+ const value = m[2] ?? ''
1474
+ if (!SHADOW_IS_COMPOSED.test(value)) {
1475
+ push('raw-surface', i + 1, m[0])
1476
+ continue
1477
+ }
1478
+ if (SHADOW_KEEPS_CARD_DEPTH.test(value)) continue
1479
+ push('surface-shadow-override', i + 1, m[0])
1480
+ }
873
1481
  }
874
1482
  }
875
1483
 
876
1484
  // off-system-surface-var — pattern + gating from GUARD_RULES.
877
1485
  if (GUARD_RULES['off-system-surface-var'].enabled!(cfg)) {
878
1486
  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' })
1487
+ push('off-system-surface-var', i + 1, m[0])
880
1488
  }
881
1489
  }
882
1490
 
@@ -886,11 +1494,12 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
886
1494
  // pattern's own comment for why this kind carries no appliesTo gate.
887
1495
  if (GUARD_RULES['mantine-shade-index'].enabled!(cfg)) {
888
1496
  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' })
1497
+ push('mantine-shade-index', i + 1, m[0])
890
1498
  }
891
1499
  }
892
1500
 
893
1501
  // raw-html-layout: 3-condition conjunction on the same line — gated via GUARD_RULES entry.
1502
+ // Its widened, formatting-independent half is `hidden-inline-style`, scanned below.
894
1503
  if (
895
1504
  GUARD_RULES['raw-html-layout'].enabled!(cfg) &&
896
1505
  ruleApplies('raw-html-layout', relPath) &&
@@ -898,21 +1507,22 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
898
1507
  INLINE_STYLE.test(line) &&
899
1508
  LAYOUT_SURFACE_PROP.test(line)
900
1509
  ) {
901
- findings.push({ relPath, line: i + 1, token: '<raw-html style>', kind: 'raw-html-layout' })
1510
+ rawHtmlLayoutLines.add(i + 1)
1511
+ push('raw-html-layout', i + 1, '<raw-html style>')
902
1512
  }
903
1513
 
904
1514
  // inline-spacing — pattern + gating from GUARD_RULES, plus the CSS-only sub-scale escape.
905
1515
  if (GUARD_RULES['inline-spacing'].enabled!(cfg)) {
906
1516
  for (const m of line.matchAll(GUARD_RULES['inline-spacing'].pattern as RegExp)) {
907
1517
  if (syntax === 'css' && isSubScaleCssSpacing(line, m.index)) continue
908
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'inline-spacing' })
1518
+ push('inline-spacing', i + 1, m[0])
909
1519
  }
910
1520
  }
911
1521
 
912
1522
  // inline-display — pattern + gating from GUARD_RULES.
913
1523
  if (GUARD_RULES['inline-display'].enabled!(cfg) && ruleApplies('inline-display', relPath)) {
914
1524
  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' })
1525
+ push('inline-display', i + 1, m[0])
916
1526
  }
917
1527
  }
918
1528
 
@@ -923,37 +1533,63 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
923
1533
  GUARD_RULES['raw-visx-axis'].appliesTo!(relPath)
924
1534
  ) {
925
1535
  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' })
1536
+ push('raw-visx-axis', i + 1, m[0])
927
1537
  }
928
1538
  }
929
1539
 
930
1540
  // raw-motion-value: 2 separate regex checks, one kind — gated via GUARD_RULES entry.
931
1541
  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
- }
1542
+ for (const m of line.matchAll(MOTION_TRANSITION_NUMERIC))
1543
+ push('raw-motion-value', i + 1, m[0])
935
1544
  for (const m of line.matchAll(MOTION_TRANSITION_EASE_ARRAY)) {
936
- findings.push({ relPath, line: i + 1, token: m[0], kind: 'raw-motion-value' })
1545
+ push('raw-motion-value', i + 1, m[0])
937
1546
  }
938
1547
  }
939
1548
 
940
1549
  // raw-form-control — pattern + gating from GUARD_RULES.
941
1550
  if (GUARD_RULES['raw-form-control'].enabled!(cfg) && ruleApplies('raw-form-control', relPath)) {
942
1551
  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' })
1552
+ push('raw-form-control', i + 1, m[0])
1553
+ }
1554
+ }
1555
+ }
1556
+
1557
+ // hidden-inline-style — the formatting-independent half of raw-html-layout. Bounded full-text tag
1558
+ // scan (the `card-with-border` model), plus one hoisted-const lookup, so neither `<div\n style=`
1559
+ // nor `style={wrapperStyle}` walks past a rule that catches the identical single-line form.
1560
+ if (
1561
+ GUARD_RULES['hidden-inline-style'].enabled!(cfg) &&
1562
+ ruleApplies('hidden-inline-style', relPath)
1563
+ ) {
1564
+ for (const m of codeText.matchAll(RAW_HTML_LAYOUT_TAG)) {
1565
+ const tagText = m[0]
1566
+ let styleBody = ''
1567
+ if (INLINE_STYLE.test(tagText)) styleBody = tagText
1568
+ else {
1569
+ const ident = STYLE_IDENTIFIER.exec(tagText)?.[1]
1570
+ if (ident !== undefined) styleBody = hoistedObjectBody(codeText, ident)
944
1571
  }
1572
+ if (styleBody === '' || !LAYOUT_SURFACE_PROP.test(styleBody)) continue
1573
+ const startLine = codeText.slice(0, m.index ?? 0).split('\n').length
1574
+ if (rawHtmlLayoutLines.has(startLine)) continue
1575
+ const endLine = startLine + (tagText.split('\n').length - 1)
1576
+ if (isAllowedInRange(startLine, endLine, 'hidden-inline-style')) continue
1577
+ findings.push({
1578
+ relPath,
1579
+ line: startLine,
1580
+ token: '<raw-html style>',
1581
+ kind: 'hidden-inline-style',
1582
+ })
945
1583
  }
946
1584
  }
947
1585
 
948
1586
  // unframed-chart — full-text tag-scoped scan (not per-line, see RAW_CHART_LEGEND_ARRAY comment).
949
1587
  // 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.
1588
+ // at the line of the `items={[` token itself.
952
1589
  if (GUARD_RULES['unframed-chart'].enabled!(cfg) && ruleApplies('unframed-chart', relPath)) {
953
1590
  for (const m of codeText.matchAll(RAW_CHART_LEGEND_ARRAY)) {
954
1591
  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' })
1592
+ push('unframed-chart', lineNo, 'items={[')
957
1593
  }
958
1594
  }
959
1595
 
@@ -973,14 +1609,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
973
1609
  if (HAS_ARIA_LABEL_PROP.test(tagText)) continue
974
1610
  const startLine = codeText.slice(0, m.index ?? 0).split('\n').length
975
1611
  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
1612
+ if (isAllowedInRange(startLine, endLine, 'chart-missing-aria-label')) continue
984
1613
  findings.push({
985
1614
  relPath,
986
1615
  line: startLine,
@@ -998,8 +1627,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
998
1627
  const withBorder = WITH_BORDER_PROP.exec(m[0])
999
1628
  if (withBorder === null) continue
1000
1629
  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' })
1630
+ push('card-with-border', lineNo, 'withBorder')
1003
1631
  }
1004
1632
  }
1005
1633
 
@@ -1016,8 +1644,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
1016
1644
  const value = Number.parseFloat(fontSizeMatch[1] ?? '')
1017
1645
  if (!Number.isFinite(value) || value >= 16) continue
1018
1646
  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' })
1647
+ push('sub-16-input-font', lineNo, fontSizeMatch[0])
1021
1648
  }
1022
1649
 
1023
1650
  for (const m of codeText.matchAll(STYLES_INPUT_PART)) {
@@ -1027,20 +1654,26 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
1027
1654
  const value = Number.parseFloat(fontSizeMatch[1] ?? '')
1028
1655
  if (!Number.isFinite(value) || value >= 16) continue
1029
1656
  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' })
1657
+ push('sub-16-input-font', lineNo, fontSizeMatch[0])
1032
1658
  }
1033
1659
  }
1034
1660
 
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, …).
1661
+ // Post-filters, applied once here so they uniformly cover every kind regardless of whether it was
1662
+ // emitted via the GUARD_RULES registry loop above or one of the inline-handled kinds:
1663
+ // • generated — the lines of a stylesheet basalt itself emitted (empty for every other file);
1664
+ // • §exemptRules — per-rule, per-path consumer exemptions;
1665
+ // • markup — an HTML document / JSON manifest has no JSX and no CSS-in-JS, so only the
1666
+ // color/typography kinds are meaningful there;
1667
+ // • profile — a tokens-only consumer is not told to install @mantine/core.
1038
1668
  //
1039
1669
  // `text` is stamped here too, in the same single post-pass, off the ORIGINAL (unstripped) `lines`
1040
1670
  // split — never `codeLines` — so a finding on a line whose comment was stripped still reports the
1041
1671
  // real source text a human would read.
1042
1672
  return findings
1673
+ .filter((f) => !generatedLines.has(f.line))
1043
1674
  .filter((f) => !isRuleExempt(f.kind, f.relPath, cfg.exemptRules))
1675
+ .filter((f) => syntax !== 'markup' || MARKUP_KINDS.has(f.kind))
1676
+ .filter((f) => cfg.profile !== 'tokens-only' || !TOKENS_ONLY_DISABLED_KINDS.has(f.kind))
1044
1677
  .map((f) => ({
1045
1678
  ...f,
1046
1679
  text: textForFinding(lines[f.line - 1], f.token),