basalt-ui 1.20.0 → 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 +9 -5
- package/CHANGELOG.md +19 -0
- package/MIGRATING.md +147 -49
- package/README.md +42 -8
- package/agent/rules/basalt-app.md +41 -0
- package/agent/rules/basalt-charts.md +15 -12
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-router.md +57 -5
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +73 -16
- package/agent/skills/basalt-app/SKILL.md +4 -1
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/lefthook.yml +48 -15
- package/configs/oxlint-plugin.js +181 -68
- package/configs/oxlint-plugin.test.ts +215 -9
- 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/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/cli/index.d.ts +48 -14
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +328 -66
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +47 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +176 -31
- 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/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +24 -6
- package/dist/tokens/index.d.ts +3 -1
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +5 -1
- package/dist/tokens/palette.d.ts +2 -2
- package/dist/tokens/palette.js +2 -2
- 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 +4 -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 +2 -2
- package/package.json +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +1 -1
- 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/cli/index.ts +607 -128
- package/src/cli/init-sync.test.ts +2 -0
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/toolchain-wiring.test.ts +162 -0
- package/src/cli/waiver-audit.test.ts +131 -0
- package/src/guard/check-source.test.ts +441 -1
- package/src/guard/index.ts +430 -60
- 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.tsx +1 -1
- package/src/shell/index.test.tsx +52 -0
- package/src/shell/index.tsx +64 -13
- package/src/tokens/index.ts +24 -1
- package/src/tokens/palette.ts +2 -2
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite.test.ts +125 -6
- package/src/vite.ts +148 -20
package/src/guard/index.ts
CHANGED
|
@@ -247,11 +247,93 @@ const INLINE_SPACING = new RegExp(
|
|
|
247
247
|
* there is no prop form, and the emitted `--vx-space-*` set is basalt's own component one-offs
|
|
248
248
|
* (`--vx-space-toc-sub-indent`), not a consumer surface. Without this escape a consumer's CSS
|
|
249
249
|
* module had no legal way to write a 4px cluster gap: no token to reach for, `theme-allow` on every
|
|
250
|
-
* line (which `basalt-tokens.md` explicitly warns against), or `exemptRules` — which
|
|
251
|
-
* 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).
|
|
252
254
|
*/
|
|
253
255
|
const MICRO_SPACING_CEILING_PX = 10
|
|
254
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
|
+
|
|
255
337
|
/** The root font size `rem` values resolve against — the same 16 the token layer's `pxRem` uses. */
|
|
256
338
|
const ROOT_FONT_SIZE_PX = 16
|
|
257
339
|
|
|
@@ -456,13 +538,6 @@ function isChartFile(relPath: string): boolean {
|
|
|
456
538
|
return relPath.includes('/charts/') && !AXIS_WRAPPER_FILE.test(relPath)
|
|
457
539
|
}
|
|
458
540
|
|
|
459
|
-
/**
|
|
460
|
-
* Whether `kind` is exempted at `relPath` via `cfg.exemptRules` — the config-driven, per-rule
|
|
461
|
-
* counterpart to `isChartFile`'s hardcoded path scoping. A pattern matches when `relPath` split on
|
|
462
|
-
* `/` includes it as a WHOLE segment (a trailing `/` is stripped first, so `'agent'` and `'agent/'`
|
|
463
|
-
* are equivalent); a substring match against one longer segment (e.g. `'age'` against `agent`)
|
|
464
|
-
* never counts.
|
|
465
|
-
*/
|
|
466
541
|
/**
|
|
467
542
|
* Kinds that inherit another kind's `exemptRules` entry. A grace-minor kind that exists only to
|
|
468
543
|
* carry a WIDENING of an established kind is the same rule to a consumer, so an exemption written
|
|
@@ -475,6 +550,59 @@ const EXEMPT_RULE_ALIASES: Partial<Record<GuardKind, GuardKind>> = {
|
|
|
475
550
|
'css-raw-surface': 'raw-surface',
|
|
476
551
|
}
|
|
477
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
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Whether `kind` is exempted at `relPath` via `cfg.exemptRules` — the config-driven, per-rule
|
|
604
|
+
* counterpart to `isChartFile`'s hardcoded path scoping.
|
|
605
|
+
*/
|
|
478
606
|
function isRuleExempt(
|
|
479
607
|
kind: GuardKind,
|
|
480
608
|
relPath: string,
|
|
@@ -484,8 +612,46 @@ function isRuleExempt(
|
|
|
484
612
|
if (alias !== undefined && isRuleExempt(alias, relPath, exemptRules)) return true
|
|
485
613
|
const patterns = exemptRules?.[kind]
|
|
486
614
|
if (patterns === undefined || patterns.length === 0) return false
|
|
487
|
-
|
|
488
|
-
|
|
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'
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
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.
|
|
637
|
+
*/
|
|
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
|
|
489
655
|
}
|
|
490
656
|
|
|
491
657
|
// ── theme-allow annotations ──────────────────────────────────────────────────────────────────────
|
|
@@ -519,10 +685,23 @@ const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
|
|
|
519
685
|
/** The shortest string accepted as a written reason — enough to exclude a stray separator. */
|
|
520
686
|
const MIN_ALLOW_REASON_LENGTH = 4
|
|
521
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
|
+
|
|
522
700
|
/**
|
|
523
701
|
* One parsed `theme-allow` annotation.
|
|
524
702
|
*
|
|
525
|
-
* `rules` empty AND `unknownRules` empty is the legacy bare form, and only that covers every kind
|
|
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}).
|
|
526
705
|
* `unknownRules` holds words that occupied the rule-id slot but name no rule — a typo. Its presence
|
|
527
706
|
* is what makes the parse FAIL CLOSED: `theme-allow raw-hexx — reason` used to consume no id, fall
|
|
528
707
|
* through to `rules: []`, and be read as the blanket form, so one mistyped character escalated a
|
|
@@ -533,6 +712,7 @@ type AllowAnnotation = {
|
|
|
533
712
|
readonly rules: readonly string[]
|
|
534
713
|
readonly unknownRules: readonly string[]
|
|
535
714
|
readonly hasReason: boolean
|
|
715
|
+
readonly scope: AllowScope
|
|
536
716
|
}
|
|
537
717
|
|
|
538
718
|
/** One annotation at the (1-based) line it was WRITTEN on — where `theme-allow-unscoped` reports. */
|
|
@@ -562,7 +742,7 @@ type AllowDeclaration = { readonly line: number; readonly annotation: AllowAnnot
|
|
|
562
742
|
* `constructor` (or `toString`, `valueOf`, …) resolved as a real rule id and silently scoped the
|
|
563
743
|
* waiver to a kind that does not exist.
|
|
564
744
|
*/
|
|
565
|
-
function parseAllowAnnotation(rest: string): AllowAnnotation {
|
|
745
|
+
function parseAllowAnnotation(rest: string, scope: AllowScope): AllowAnnotation {
|
|
566
746
|
const rules: string[] = []
|
|
567
747
|
const unknownRules: string[] = []
|
|
568
748
|
let remainder = rest.replace(/^[\s,]+/, '')
|
|
@@ -581,12 +761,15 @@ function parseAllowAnnotation(rest: string): AllowAnnotation {
|
|
|
581
761
|
inIdSlot = /^\s*,/.test(after)
|
|
582
762
|
}
|
|
583
763
|
const reason = remainder.replace(/^(?:—|–|-{1,2}|:)\s*/, '').trim()
|
|
584
|
-
return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH }
|
|
764
|
+
return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH, scope }
|
|
585
765
|
}
|
|
586
766
|
|
|
587
767
|
/** How far back a trailing CSS annotation reaches for the declaration it terminates. */
|
|
588
768
|
const MAX_CSS_CONTINUATION_LINES = 8
|
|
589
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
|
+
|
|
590
773
|
/**
|
|
591
774
|
* A comment opener sitting immediately before the annotation token.
|
|
592
775
|
*
|
|
@@ -605,12 +788,16 @@ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
|
|
|
605
788
|
* Everything allowed between the comment opener (or the start of the line) and the annotation
|
|
606
789
|
* token, for the token to count as an ANNOTATION rather than prose that mentions one.
|
|
607
790
|
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
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.
|
|
614
801
|
*
|
|
615
802
|
* What the annotation then WAIVES is a separate, fail-closed question — see
|
|
616
803
|
* {@link parseAllowAnnotation}. A word in the id slot that names no rule waives nothing, so
|
|
@@ -624,17 +811,69 @@ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
|
|
|
624
811
|
*/
|
|
625
812
|
const ANNOTATION_PREFIX = /(?:^|\/\/|\/\*|<!--|^\s*\*)\s*$/
|
|
626
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
|
+
|
|
627
819
|
/**
|
|
628
|
-
*
|
|
629
|
-
*
|
|
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.
|
|
630
865
|
*
|
|
631
866
|
* 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
|
|
633
|
-
* placement the oxlint plugin has always honored and the only one JSX can express: the
|
|
634
|
-
* line is usually a multi-line opening tag or a `{expr}` child, where a trailing `//` is
|
|
635
|
-
* syntax error or renders as visible text. Requiring the annotation line to be comment-ONLY is
|
|
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
|
|
636
871
|
* what keeps a TRAILING comment scoped to its own line — otherwise
|
|
637
|
-
* `const a = '#f00' // theme-allow` would silently waive the next line too.
|
|
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".
|
|
638
877
|
* 3. **The rest of the CSS declaration it terminates.** The shipped `oxfmt` reflows
|
|
639
878
|
* `background-color: var(--x, #232326); /* theme-allow *\/` into four lines with the hex on one
|
|
640
879
|
* and the comment on another — two shipped tools pulling in opposite directions, and the
|
|
@@ -653,8 +892,13 @@ function collectAllowAnnotations(
|
|
|
653
892
|
codeLines: readonly string[],
|
|
654
893
|
allowComment: string,
|
|
655
894
|
syntax: GuardSyntax,
|
|
656
|
-
): {
|
|
895
|
+
): {
|
|
896
|
+
waivers: Map<number, AllowAnnotation[]>
|
|
897
|
+
fileScoped: AllowAnnotation[]
|
|
898
|
+
declared: AllowDeclaration[]
|
|
899
|
+
} {
|
|
657
900
|
const byLine = new Map<number, AllowAnnotation[]>()
|
|
901
|
+
const fileScoped: AllowAnnotation[] = []
|
|
658
902
|
const declared: AllowDeclaration[] = []
|
|
659
903
|
const add = (index: number, annotation: AllowAnnotation): void => {
|
|
660
904
|
const list = byLine.get(index) ?? []
|
|
@@ -662,45 +906,90 @@ function collectAllowAnnotations(
|
|
|
662
906
|
byLine.set(index, list)
|
|
663
907
|
}
|
|
664
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
|
+
|
|
665
942
|
for (let i = 0; i < lines.length; i++) {
|
|
666
943
|
const line = lines[i] ?? ''
|
|
667
944
|
const stripped = codeLines[i] ?? ''
|
|
668
945
|
const isCommentOnlyLine = stripped.trim() === '' || COMMENT_ONLY_LINE.test(line)
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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)
|
|
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)
|
|
688
951
|
}
|
|
689
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
|
+
}
|
|
690
970
|
}
|
|
691
|
-
return { waivers: byLine, declared }
|
|
971
|
+
return { waivers: byLine, fileScoped, declared }
|
|
692
972
|
}
|
|
693
973
|
|
|
694
974
|
/**
|
|
695
|
-
* Does
|
|
975
|
+
* Does one annotation cover `kind`?
|
|
696
976
|
*
|
|
697
|
-
* Only a BARE annotation (no ids at all, known or unknown) covers every kind. One that
|
|
698
|
-
* a rule id and missed covers exactly the ids it got right — nothing widens on a typo.
|
|
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.
|
|
699
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`? */
|
|
700
991
|
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
|
-
)
|
|
992
|
+
return (annotations ?? []).some((a) => annotationCovers(a, kind))
|
|
704
993
|
}
|
|
705
994
|
|
|
706
995
|
/** The 3 comment dialects the stripper understands, resolved from the file's own extension. */
|
|
@@ -1282,14 +1571,14 @@ export const GUARD_RULES = {
|
|
|
1282
1571
|
kind: 'theme-allow-unscoped',
|
|
1283
1572
|
pattern: /theme-allow/, // handled inline (annotation pass); entry keeps the registry complete
|
|
1284
1573
|
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.',
|
|
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.',
|
|
1286
1575
|
},
|
|
1287
1576
|
'surface-shadow-override': {
|
|
1288
1577
|
kind: 'surface-shadow-override',
|
|
1289
1578
|
pattern: SHADOW_DECL, // handled inline (value is judged, not just matched)
|
|
1290
1579
|
enabled: (cfg: GuardConfig) => cfg.rawSurface,
|
|
1291
1580
|
message:
|
|
1292
|
-
'Token-composed boxShadow that REPLACES
|
|
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.',
|
|
1293
1582
|
},
|
|
1294
1583
|
'css-raw-surface': {
|
|
1295
1584
|
kind: 'css-raw-surface',
|
|
@@ -1316,6 +1605,54 @@ export const GUARD_RULES = {
|
|
|
1316
1605
|
},
|
|
1317
1606
|
} as const satisfies Record<GuardKind, GuardRule>
|
|
1318
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
|
+
|
|
1319
1656
|
/** Whether `kind`'s rule fires for `relPath` — `appliesTo` is opt-in; absent means always applies. */
|
|
1320
1657
|
function ruleApplies(kind: GuardKind, relPath: string): boolean {
|
|
1321
1658
|
const appliesTo = (GUARD_RULES[kind] as GuardRule).appliesTo
|
|
@@ -1365,11 +1702,24 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
1365
1702
|
const syntax = guardSyntaxFor(relPath)
|
|
1366
1703
|
const codeText = syntax === 'markup' ? stripMarkupComments(text) : stripComments(text, syntax)
|
|
1367
1704
|
const codeLines = codeText.split('\n')
|
|
1368
|
-
const { waivers, declared } = collectAllowAnnotations(
|
|
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
|
+
}
|
|
1369
1719
|
|
|
1370
|
-
/** Is `kind` waived at (1-based) `line`? See `collectAllowAnnotations` for the
|
|
1720
|
+
/** Is `kind` waived at (1-based) `line`? See `collectAllowAnnotations` for the full grammar. */
|
|
1371
1721
|
const isAllowed = (line: number, kind: GuardKind): boolean =>
|
|
1372
|
-
annotationsCover(waivers.get(line - 1), kind)
|
|
1722
|
+
fileWaived.has(kind) || annotationsCover(waivers.get(line - 1), kind)
|
|
1373
1723
|
|
|
1374
1724
|
const isAllowedInRange = (start: number, end: number, kind: GuardKind): boolean => {
|
|
1375
1725
|
for (let n = start; n <= end; n++) if (isAllowed(n, kind)) return true
|
|
@@ -1409,6 +1759,18 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
1409
1759
|
/** Lines the line-scoped `raw-html-layout` already owns, so `hidden-inline-style` can't double-report. */
|
|
1410
1760
|
const rawHtmlLayoutLines = new Set<number>()
|
|
1411
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
|
+
)
|
|
1773
|
+
|
|
1412
1774
|
for (let i = 0; i < lines.length; i++) {
|
|
1413
1775
|
const line = codeLines[i] ?? ''
|
|
1414
1776
|
|
|
@@ -1511,10 +1873,18 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
1511
1873
|
push('raw-html-layout', i + 1, '<raw-html style>')
|
|
1512
1874
|
}
|
|
1513
1875
|
|
|
1514
|
-
// 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).
|
|
1515
1878
|
if (GUARD_RULES['inline-spacing'].enabled!(cfg)) {
|
|
1516
1879
|
for (const m of line.matchAll(GUARD_RULES['inline-spacing'].pattern as RegExp)) {
|
|
1517
1880
|
if (syntax === 'css' && isSubScaleCssSpacing(line, m.index)) continue
|
|
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
|
|
1518
1888
|
push('inline-spacing', i + 1, m[0])
|
|
1519
1889
|
}
|
|
1520
1890
|
}
|