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.
Files changed (87) hide show
  1. package/AGENTS.md +9 -5
  2. package/CHANGELOG.md +19 -0
  3. package/MIGRATING.md +147 -49
  4. package/README.md +42 -8
  5. package/agent/rules/basalt-app.md +41 -0
  6. package/agent/rules/basalt-charts.md +15 -12
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-router.md +57 -5
  9. package/agent/rules/basalt-state.md +21 -3
  10. package/agent/rules/basalt-tokens.md +73 -16
  11. package/agent/skills/basalt-app/SKILL.md +4 -1
  12. package/agent/skills/basalt-charts/SKILL.md +5 -3
  13. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  14. package/configs/lefthook.yml +48 -15
  15. package/configs/oxlint-plugin.js +181 -68
  16. package/configs/oxlint-plugin.test.ts +215 -9
  17. package/dist/charts/kinds/Bars.d.ts +1 -1
  18. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  19. package/dist/charts/kinds/Donut.d.ts +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  22. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  23. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  24. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  25. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  26. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  30. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  31. package/dist/cli/index.d.ts +48 -14
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +328 -66
  34. package/dist/cli/waiver-audit.test.d.ts +2 -0
  35. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  36. package/dist/guard/index.d.ts +47 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +176 -31
  39. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  40. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  41. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  42. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  43. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  44. package/dist/router-tanstack/search-param-store.js +31 -3
  45. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  46. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  47. package/dist/shell/index.d.ts +12 -5
  48. package/dist/shell/index.d.ts.map +1 -1
  49. package/dist/shell/index.js +24 -6
  50. package/dist/tokens/index.d.ts +3 -1
  51. package/dist/tokens/index.d.ts.map +1 -1
  52. package/dist/tokens/index.js +5 -1
  53. package/dist/tokens/palette.d.ts +2 -2
  54. package/dist/tokens/palette.js +2 -2
  55. package/dist/tokens/text-scale.test.d.ts +2 -0
  56. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  57. package/dist/tokens.css +4 -2
  58. package/dist/vite.d.ts +24 -2
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +54 -13
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/kinds/Bars.tsx +1 -1
  64. package/src/charts/kinds/Donut.tsx +1 -1
  65. package/src/charts/kinds/DualPanel.tsx +2 -2
  66. package/src/charts/kinds/Heatmap.tsx +1 -1
  67. package/src/charts/kinds/MultiLine.tsx +1 -1
  68. package/src/charts/kinds/StackedArea.tsx +1 -1
  69. package/src/charts/kinds/ZonedLine.tsx +1 -1
  70. package/src/cli/index.ts +607 -128
  71. package/src/cli/init-sync.test.ts +2 -0
  72. package/src/cli/template-ledger.test.ts +1 -1
  73. package/src/cli/toolchain-wiring.test.ts +162 -0
  74. package/src/cli/waiver-audit.test.ts +131 -0
  75. package/src/guard/check-source.test.ts +441 -1
  76. package/src/guard/index.ts +430 -60
  77. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  78. package/src/router-tanstack/search-param-store.test.ts +135 -0
  79. package/src/router-tanstack/search-param-store.ts +90 -8
  80. package/src/shell/app-mobile-nav.tsx +1 -1
  81. package/src/shell/index.test.tsx +52 -0
  82. package/src/shell/index.tsx +64 -13
  83. package/src/tokens/index.ts +24 -1
  84. package/src/tokens/palette.ts +2 -2
  85. package/src/tokens/text-scale.test.ts +51 -0
  86. package/src/vite.test.ts +125 -6
  87. package/src/vite.ts +148 -20
@@ -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 matches whole
251
- * path segments and so cannot express `*.module.css`.
250
+ * line (which `basalt-tokens.md` explicitly warns against), or `exemptRules` — which at the time
251
+ * matched whole path segments only and so could not express `*.module.css` (it can now, see
252
+ * `exemptPatternMatches`; the escape stays because a per-file exemption is still the wrong shape
253
+ * for a value that has no token).
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
- const segments = relPath.split('/')
488
- return patterns.some((pattern) => segments.includes(pattern.replace(/\/$/, '')))
615
+ return patterns.some((pattern) => exemptPatternMatches(pattern, relPath))
616
+ }
617
+
618
+ /** One `exemptRules` entry that suppressed nothing, and why — see {@link unmatchedExemptPatterns}. */
619
+ export type UnmatchedExemptPattern = {
620
+ readonly kind: string
621
+ readonly pattern: string
622
+ readonly reason: 'unknown-kind' | 'no-match'
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
- * 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.
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
- * 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.
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 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
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
- ): { waivers: Map<number, AllowAnnotation[]>; declared: AllowDeclaration[] } {
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
- 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)
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 any annotation on this line cover `kind`?
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 reached for
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 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.',
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(lines, codeLines, cfg.allowComment, syntax)
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 three placements. */
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
  }