basalt-ui 1.19.1 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +19 -1
- package/CHANGELOG.md +39 -0
- package/MIGRATING.md +326 -0
- package/README.md +87 -9
- package/agent/rules/basalt-app.md +84 -0
- package/agent/rules/basalt-charts.md +32 -17
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-mantine.md +25 -10
- package/agent/rules/basalt-router.md +79 -15
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +132 -6
- package/agent/skills/basalt-app/SKILL.md +45 -6
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/check.yml +11 -4
- package/configs/lefthook.yml +59 -7
- package/configs/oxlint-plugin.js +633 -30
- package/configs/oxlint-plugin.test.ts +594 -15
- package/configs/oxlint.json +10 -1
- package/dist/agent/stick-to-bottom.js +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.js +2 -2
- package/dist/agent-chat/tool-chip.js +1 -1
- package/dist/charts/kinds/Bars.d.ts +1 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.d.ts +1 -1
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +1 -1
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.d.ts +1 -1
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.d.ts +1 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.d.ts +1 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +2 -2
- package/dist/charts/primitives/ChartTooltip.js +1 -1
- package/dist/cli/index.d.ts +146 -20
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +901 -101
- package/dist/cli/toolchain-wiring.test.d.ts +2 -0
- package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/dashboard/delta-badge.js +2 -2
- package/dist/dashboard/stat-card.js +1 -1
- package/dist/data/virtual-list.js +5 -5
- package/dist/guard/index.d.ts +101 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +474 -62
- package/dist/guard/types.d.ts +25 -5
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/register.d.ts +25 -0
- package/dist/register.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +4 -1
- package/dist/router-tanstack/search-param-store.d.ts +29 -5
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +31 -3
- package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
- package/dist/shell/app-mobile-nav.module.css +11 -3
- package/dist/shell/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +24 -6
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/tokens/index.d.ts +5 -3
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +7 -1
- package/dist/tokens/palette.d.ts +35 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +24 -18
- package/dist/tokens/text-scale.test.d.ts +2 -0
- package/dist/tokens/text-scale.test.d.ts.map +1 -0
- package/dist/tokens.css +21 -17
- package/dist/vite-color-mix.js +2 -2
- package/dist/vite.d.ts +24 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +54 -13
- package/llms.txt +3 -3
- package/package.json +4 -3
- package/src/agent/stick-to-bottom.tsx +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +1 -1
- package/src/agent-chat/thread-message.tsx +3 -3
- package/src/agent-chat/tool-chip.tsx +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +3 -2
- package/src/charts/kinds/DualPanel.tsx +2 -2
- package/src/charts/kinds/Heatmap.tsx +1 -1
- package/src/charts/kinds/MultiLine.tsx +1 -1
- package/src/charts/kinds/StackedArea.tsx +1 -1
- package/src/charts/kinds/ZonedLine.tsx +1 -1
- package/src/charts/primitives/ChartLegend.tsx +2 -2
- package/src/charts/primitives/ChartTooltip.tsx +1 -1
- package/src/cli/index.ts +1614 -164
- package/src/cli/init-sync.test.ts +21 -3
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/tokens-css.test.ts +27 -14
- package/src/cli/toolchain-wiring.test.ts +856 -0
- package/src/cli/waiver-audit.test.ts +131 -0
- package/src/dashboard/delta-badge.tsx +3 -3
- package/src/dashboard/stat-card.tsx +1 -1
- package/src/data/virtual-list.tsx +8 -8
- package/src/guard/check-source.test.ts +1104 -12
- package/src/guard/index.ts +1106 -103
- package/src/guard/types.ts +29 -4
- package/src/register.ts +32 -0
- package/src/router-tanstack/multi-search-param-store.ts +16 -1
- package/src/router-tanstack/search-param-store.test.ts +135 -0
- package/src/router-tanstack/search-param-store.ts +90 -8
- package/src/shell/app-mobile-nav.module.css +11 -3
- package/src/shell/app-mobile-nav.tsx +1 -1
- package/src/shell/index.test.tsx +52 -0
- package/src/shell/index.tsx +64 -13
- package/src/surfaces.ts +1 -1
- package/src/theme/density-relations.test.ts +8 -5
- package/src/theme/spacing.test.ts +2 -0
- package/src/theme-toggle/index.tsx +2 -2
- package/src/tokens/index.ts +28 -3
- package/src/tokens/palette.ts +24 -18
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite-color-mix.ts +2 -2
- package/src/vite.test.ts +125 -6
- package/src/vite.ts +148 -20
package/src/guard/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
21
|
-
// (`--basalt-font-*` / `--mantine-font-family-*`)
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
136
|
-
* path segments and so
|
|
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
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
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
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
817
|
-
//
|
|
818
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
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),
|