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