basalt-ui 1.20.0 → 1.22.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 (89) hide show
  1. package/AGENTS.md +11 -5
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +231 -50
  4. package/README.md +57 -10
  5. package/agent/rules/basalt-app.md +49 -2
  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 +78 -16
  11. package/agent/skills/basalt-app/SKILL.md +5 -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 +218 -69
  16. package/configs/oxlint-plugin.test.ts +331 -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 +55 -15
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +521 -71
  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 +114 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +216 -33
  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 +28 -3
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +75 -26
  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/check-theme.test.ts +35 -0
  71. package/src/cli/help.test.ts +5 -1
  72. package/src/cli/index.ts +978 -133
  73. package/src/cli/init-sync.test.ts +2 -0
  74. package/src/cli/template-ledger.test.ts +1 -1
  75. package/src/cli/toolchain-wiring.test.ts +352 -1
  76. package/src/cli/waiver-audit.test.ts +211 -0
  77. package/src/guard/check-source.test.ts +638 -1
  78. package/src/guard/index.ts +556 -63
  79. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  80. package/src/router-tanstack/search-param-store.test.ts +135 -0
  81. package/src/router-tanstack/search-param-store.ts +90 -8
  82. package/src/shell/app-mobile-nav.tsx +1 -1
  83. package/src/shell/index.test.tsx +52 -0
  84. package/src/shell/index.tsx +64 -13
  85. package/src/tokens/index.ts +24 -1
  86. package/src/tokens/palette.ts +2 -2
  87. package/src/tokens/text-scale.test.ts +51 -0
  88. package/src/vite.test.ts +158 -6
  89. package/src/vite.ts +176 -34
@@ -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 ──────────────────────────────────────────────────────────────────────
@@ -495,8 +661,12 @@ function isRuleExempt(
495
661
  * but they ARE valid ids in a `theme-allow`, so an annotation scoped to one of them must parse as
496
662
  * accountable rather than be reported as unscoped. Kept as a literal list because the plugin is a
497
663
  * standalone `.js` file this module must not import (see the plugin's own header).
664
+ *
665
+ * Exported for `check-theme --audit-allows`: an annotation naming one of these is outside the
666
+ * guard's reach, and judging it needs a `oxlint` run rather than a `checkSource` one. Keep in step
667
+ * with the plugin's own `KNOWN_RULE_IDS`; `configs/oxlint-plugin.test.ts` asserts the two agree.
498
668
  */
499
- const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
669
+ export const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
500
670
  'no-raw-font-size',
501
671
  'raw-size-literal',
502
672
  'card-inset',
@@ -519,10 +689,23 @@ const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
519
689
  /** The shortest string accepted as a written reason — enough to exclude a stray separator. */
520
690
  const MIN_ALLOW_REASON_LENGTH = 4
521
691
 
692
+ /**
693
+ * How far one annotation reaches.
694
+ *
695
+ * `line` is the default and covers the placements in {@link collectAllowAnnotations}. `file` is the
696
+ * whole-file declaration, and it has to be SPELLED (`theme-allow-file`) rather than inferred: at
697
+ * 1.20.0 a `theme-allow <id> — <reason>` written anywhere in a file was silently promoted to a file
698
+ * declaration by the oxlint plugin, so naming a rule AND giving a reason — the exact shape the
699
+ * guard's own message asks for — was the only legal annotation and it was always whole-file. A
700
+ * consumer who wanted to waive one node and stay policed on the rest could not say so.
701
+ */
702
+ type AllowScope = 'line' | 'file'
703
+
522
704
  /**
523
705
  * One parsed `theme-allow` annotation.
524
706
  *
525
- * `rules` empty AND `unknownRules` empty is the legacy bare form, and only that covers every kind.
707
+ * `rules` empty AND `unknownRules` empty is the legacy bare form, and only that covers every kind —
708
+ * and only at `line` scope, never `file` (see {@link annotationCovers}).
526
709
  * `unknownRules` holds words that occupied the rule-id slot but name no rule — a typo. Its presence
527
710
  * is what makes the parse FAIL CLOSED: `theme-allow raw-hexx — reason` used to consume no id, fall
528
711
  * through to `rules: []`, and be read as the blanket form, so one mistyped character escalated a
@@ -533,6 +716,7 @@ type AllowAnnotation = {
533
716
  readonly rules: readonly string[]
534
717
  readonly unknownRules: readonly string[]
535
718
  readonly hasReason: boolean
719
+ readonly scope: AllowScope
536
720
  }
537
721
 
538
722
  /** One annotation at the (1-based) line it was WRITTEN on — where `theme-allow-unscoped` reports. */
@@ -562,7 +746,7 @@ type AllowDeclaration = { readonly line: number; readonly annotation: AllowAnnot
562
746
  * `constructor` (or `toString`, `valueOf`, …) resolved as a real rule id and silently scoped the
563
747
  * waiver to a kind that does not exist.
564
748
  */
565
- function parseAllowAnnotation(rest: string): AllowAnnotation {
749
+ function parseAllowAnnotation(rest: string, scope: AllowScope): AllowAnnotation {
566
750
  const rules: string[] = []
567
751
  const unknownRules: string[] = []
568
752
  let remainder = rest.replace(/^[\s,]+/, '')
@@ -581,12 +765,15 @@ function parseAllowAnnotation(rest: string): AllowAnnotation {
581
765
  inIdSlot = /^\s*,/.test(after)
582
766
  }
583
767
  const reason = remainder.replace(/^(?:—|–|-{1,2}|:)\s*/, '').trim()
584
- return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH }
768
+ return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH, scope }
585
769
  }
586
770
 
587
771
  /** How far back a trailing CSS annotation reaches for the declaration it terminates. */
588
772
  const MAX_CSS_CONTINUATION_LINES = 8
589
773
 
774
+ /** How far a comment-only annotation reaches forward through the rest of its own comment block. */
775
+ const MAX_COMMENT_BLOCK_LINES = 8
776
+
590
777
  /**
591
778
  * A comment opener sitting immediately before the annotation token.
592
779
  *
@@ -601,16 +788,42 @@ const COMMENT_OPENER_BEFORE = /(?:\/\/|\/\*|<!--)\s*$/
601
788
  /** A line that is NOTHING but a comment — the placement that waives the line below it. */
602
789
  const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
603
790
 
791
+ /**
792
+ * A JSX expression container closing immediately after the comment that filled it — the `*\/}` of a
793
+ * `{/* … *\/}` whose annotation wrapped onto its own line.
794
+ *
795
+ * `stripComments` blanks the comment and leaves the bare `}` behind, so the line read as CODE and
796
+ * the annotation was classified TRAILING — scoped to a line that holds nothing but a brace, and
797
+ * therefore waiving nothing. That is the shape linewatch writes for every hand-composed chart axis
798
+ * (`{/*` on one line, the annotation and `*\/}` on the next), which worked only because the rules it
799
+ * names live in the oxlint plugin, whose own placement test is comment-node-based and never saw the
800
+ * brace. Any guard kind annotated this way was silently unwaivable.
801
+ *
802
+ * Deliberately narrow: the `}` must abut the comment CLOSE. A `}` that is real code (`} // theme-
803
+ * allow …` closing a block) keeps its trailing classification, so the annotation stays on its own
804
+ * line rather than reaching the statement below.
805
+ */
806
+ const JSX_COMMENT_CLOSE = /\*\/\s*\}\s*$/
807
+
604
808
  /**
605
809
  * Everything allowed between the comment opener (or the start of the line) and the annotation
606
810
  * token, for the token to count as an ANNOTATION rather than prose that mentions one.
607
811
  *
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.
812
+ * **This gates WAIVING as well as reporting.** It used to gate only `theme-allow-unscoped`, on the
813
+ * argument that a stricter waiver could take a build down over comment placement. The cost of that
814
+ * asymmetry was the opposite and worse: a comment that merely MENTIONS the token in prose parsed as
815
+ * the bare blanket form and switched every kind off on the line below it. linewatch documented its
816
+ * own waivers in a docblock and thereby disarmed the file — the third hole found in this contract
817
+ * in two rounds, and the only one that was a false NEGATIVE.
818
+ *
819
+ * So an annotation must START its comment, or start a line inside one: after `//`, `/*` (or a
820
+ * docblock's `/**`), `<!--`, a block-comment gutter `*`, or nothing but leading whitespace.
821
+ * Everything a consumer actually writes qualifies; a sentence about the escape hatch does not.
822
+ *
823
+ * `/**` is in the alternation because the oxlint plugin's copy of this test always accepted it and
824
+ * this one did not: a one-line `/** theme-allow raw-hex — why *\/` waived under `oxlint` and
825
+ * reported under `check-theme`. The two halves of one contract must not disagree about what an
826
+ * annotation IS — only about which rules each can judge.
614
827
  *
615
828
  * What the annotation then WAIVES is a separate, fail-closed question — see
616
829
  * {@link parseAllowAnnotation}. A word in the id slot that names no rule waives nothing, so
@@ -622,19 +835,71 @@ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
622
835
  * it anyway: the kind that fires is the UNSUPPRESSED one (`raw-hex`), not `theme-allow-unscoped`,
623
836
  * and downgrading `raw-hex` to waive-by-default is the hole this closed.
624
837
  */
625
- const ANNOTATION_PREFIX = /(?:^|\/\/|\/\*|<!--|^\s*\*)\s*$/
838
+ const ANNOTATION_PREFIX = /(?:^|\/\/|\/\*\*?|<!--|^\s*\*)\s*$/
839
+
840
+ /** Escape a config-supplied string for literal use inside a RegExp. */
841
+ function escapeRegExp(value: string): string {
842
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
843
+ }
844
+
845
+ /**
846
+ * The annotation token itself, with its optional `-file` suffix.
847
+ *
848
+ * `(?![\w-])` is what keeps the two forms apart and keeps a longer word from being read as the bare
849
+ * form: `theme-allow-file` is the file declaration, `theme-allow-unscoped` (the KIND NAME, written
850
+ * in prose constantly) is neither — before this it consumed no id, fell through to `rules: []` and
851
+ * parsed as a blanket waiver.
852
+ */
853
+ function allowTokenRe(allowComment: string): RegExp {
854
+ return new RegExp(`${escapeRegExp(allowComment)}(-file)?(?![\\w-])`, 'g')
855
+ }
856
+
857
+ /**
858
+ * The JSON dialect of the annotation — `"basalt:theme-allow-file": "<ids> — <why>"`.
859
+ *
860
+ * JSON has no comments, so from 1.20.0 (when the scan reached `.webmanifest` / `.json`) every
861
+ * finding in that file class was permanently unwaivable and the printed remedy prescribed a comment
862
+ * the file cannot carry. Two consumers were pushed into a blanket `exemptRules` entry instead — the
863
+ * un-reviewable shape this contract exists to retire. A member key is the one annotation JSON can
864
+ * hold, it carries ids and a reason exactly like the comment form, and every manifest consumer
865
+ * ignores members it does not know.
866
+ */
867
+ function jsonAllowKeyRe(allowComment: string): RegExp {
868
+ return new RegExp(`"basalt:${escapeRegExp(allowComment)}(-file)?"\\s*:\\s*"([^"]*)"`, 'g')
869
+ }
626
870
 
627
871
  /**
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.
872
+ * Every line an allow annotation WAIVES, keyed by 0-based line index, plus the file-scoped
873
+ * declarations and every annotation as WRITTEN (for `theme-allow-unscoped`).
874
+ *
875
+ * **The grammar, in full:**
876
+ *
877
+ * ```text
878
+ * theme-allow → this placement, EVERY kind (reports unscoped)
879
+ * theme-allow <id>[, <id>…] [— <why>] → this placement, those kinds (unscoped without a why)
880
+ * theme-allow-file <id>[, <id>…] — <why> → the WHOLE FILE, those kinds
881
+ * "basalt:theme-allow[-file]": "<id>… — <why>" → the same two, for JSON/.webmanifest
882
+ * ```
883
+ *
884
+ * Three rules make it unambiguous: the token must START its comment (see {@link ANNOTATION_PREFIX});
885
+ * `-file` is part of the token, not the first word of the reason (see {@link allowTokenRe}); and a
886
+ * bare `theme-allow-file` waives NOTHING, because whole-file blanket immunity off one unnamed
887
+ * comment is the exact thing this contract exists to price (see {@link annotationCovers}).
888
+ *
889
+ * Line scope has three placements, each one a shape people actually write — and the union of them
890
+ * is what makes the escape survive a formatter.
630
891
  *
631
892
  * 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
893
+ * 2. **The first CODE line below, when the annotation line is a comment and nothing else.** This is
894
+ * the placement the oxlint plugin has always honored and the only one JSX can express: the
895
+ * reported line is usually a multi-line opening tag or a `{expr}` child, where a trailing `//` is
896
+ * a syntax error or renders as visible text. Requiring the annotation line to be comment-ONLY is
636
897
  * what keeps a TRAILING comment scoped to its own line — otherwise
637
- * `const a = '#f00' // theme-allow` would silently waive the next line too.
898
+ * `const a = '#f00' // theme-allow` would silently waive the next line too. The rest of the
899
+ * comment BLOCK is walked through rather than counted as the target line: a docblock's `*\/`, or
900
+ * a reason that wrapped onto a second line, used to absorb the whole waiver and the natural
901
+ * shape silently waived nothing. A blank line ends the block — that separation is how people say
902
+ * "this comment is not about the next statement".
638
903
  * 3. **The rest of the CSS declaration it terminates.** The shipped `oxfmt` reflows
639
904
  * `background-color: var(--x, #232326); /* theme-allow *\/` into four lines with the hex on one
640
905
  * and the comment on another — two shipped tools pulling in opposite directions, and the
@@ -653,8 +918,13 @@ function collectAllowAnnotations(
653
918
  codeLines: readonly string[],
654
919
  allowComment: string,
655
920
  syntax: GuardSyntax,
656
- ): { waivers: Map<number, AllowAnnotation[]>; declared: AllowDeclaration[] } {
921
+ ): {
922
+ waivers: Map<number, AllowAnnotation[]>
923
+ fileScoped: AllowAnnotation[]
924
+ declared: AllowDeclaration[]
925
+ } {
657
926
  const byLine = new Map<number, AllowAnnotation[]>()
927
+ const fileScoped: AllowAnnotation[] = []
658
928
  const declared: AllowDeclaration[] = []
659
929
  const add = (index: number, annotation: AllowAnnotation): void => {
660
930
  const list = byLine.get(index) ?? []
@@ -662,45 +932,92 @@ function collectAllowAnnotations(
662
932
  byLine.set(index, list)
663
933
  }
664
934
 
935
+ /** Place one parsed annotation: file declarations reach everywhere, line ones the 3 placements. */
936
+ const record = (index: number, annotation: AllowAnnotation, trailing: boolean): void => {
937
+ declared.push({ line: index + 1, annotation })
938
+ if (annotation.scope === 'file') {
939
+ fileScoped.push(annotation)
940
+ return
941
+ }
942
+ add(index, annotation)
943
+ if (!trailing) {
944
+ // The first line below that is not itself comment. A docblock's `*/` (or the rest of a
945
+ // wrapped reason) sits between the annotation and the code it is about, so `index + 1` alone
946
+ // meant the natural multi-line shape silently waived nothing — argo hit it three times in one
947
+ // upgrade, each looking like a correct annotation. A BLANK line still ends the block: that is
948
+ // the separation people use to mean "this comment is not about the next statement".
949
+ for (let ahead = 1; ahead <= MAX_COMMENT_BLOCK_LINES; ahead++) {
950
+ const raw = lines[index + ahead] ?? ''
951
+ add(index + ahead, annotation)
952
+ if (raw.trim() !== '' && (codeLines[index + ahead] ?? '').trim() !== '') break
953
+ if (raw.trim() === '') break
954
+ }
955
+ return
956
+ }
957
+ if (syntax !== 'css') return
958
+ for (let back = 1; back <= MAX_CSS_CONTINUATION_LINES; back++) {
959
+ const previous = (codeLines[index - back] ?? '').trim()
960
+ if (previous === '' || /[;{}]$/.test(previous)) break
961
+ add(index - back, annotation)
962
+ }
963
+ }
964
+
965
+ const token = allowTokenRe(allowComment)
966
+ const jsonKey = jsonAllowKeyRe(allowComment)
967
+
665
968
  for (let i = 0; i < lines.length; i++) {
666
969
  const line = lines[i] ?? ''
667
970
  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)
971
+ const code = stripped.trim()
972
+ const isCommentOnlyLine =
973
+ code === '' || COMMENT_ONLY_LINE.test(line) || (code === '}' && JSX_COMMENT_CLOSE.test(line))
974
+
975
+ // The JSON member form. Markup-only: in TS/CSS that string would be code, not an annotation.
976
+ if (syntax === 'markup') {
977
+ for (const m of line.matchAll(jsonKey)) {
978
+ record(i, parseAllowAnnotation(m[2] ?? '', m[1] === undefined ? 'line' : 'file'), false)
688
979
  }
689
980
  }
981
+
982
+ for (const m of line.matchAll(token)) {
983
+ const at = m.index
984
+ const before = line.slice(0, at)
985
+ // Not inside a comment (neither witness agrees) → real code, e.g. this file's own rule table.
986
+ if (stripped[at] !== ' ' && !COMMENT_OPENER_BEFORE.test(before)) continue
987
+ // Inside a comment, but not at the START of one → prose that mentions the token, not an
988
+ // annotation. See ANNOTATION_PREFIX: this is what stops a file documenting its own waivers
989
+ // from disarming itself.
990
+ if (!ANNOTATION_PREFIX.test(before)) continue
991
+ const rest = line.slice(at + m[0].length)
992
+ record(
993
+ i,
994
+ parseAllowAnnotation(rest, m[1] === undefined ? 'line' : 'file'),
995
+ !isCommentOnlyLine,
996
+ )
997
+ }
690
998
  }
691
- return { waivers: byLine, declared }
999
+ return { waivers: byLine, fileScoped, declared }
692
1000
  }
693
1001
 
694
1002
  /**
695
- * Does any annotation on this line cover `kind`?
1003
+ * Does one annotation cover `kind`?
696
1004
  *
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.
1005
+ * Only a BARE **line** annotation (no ids at all, known or unknown) covers every kind. One that
1006
+ * reached for a rule id and missed covers exactly the ids it got right — nothing widens on a typo.
1007
+ *
1008
+ * A bare `theme-allow-file` covers NOTHING. The blanket form is tolerable on one line, where a
1009
+ * reader sees what it sits on; over a whole file it is `exemptRules` without the config review, and
1010
+ * the widest waiver in the contract must be the one that has to name what it waives.
699
1011
  */
1012
+ function annotationCovers(annotation: AllowAnnotation, kind: GuardKind): boolean {
1013
+ if (annotation.rules.includes(kind)) return true
1014
+ if (annotation.scope === 'file') return false
1015
+ return annotation.rules.length === 0 && annotation.unknownRules.length === 0
1016
+ }
1017
+
1018
+ /** Does any annotation on this line cover `kind`? */
700
1019
  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
- )
1020
+ return (annotations ?? []).some((a) => annotationCovers(a, kind))
704
1021
  }
705
1022
 
706
1023
  /** The 3 comment dialects the stripper understands, resolved from the file's own extension. */
@@ -1282,14 +1599,14 @@ export const GUARD_RULES = {
1282
1599
  kind: 'theme-allow-unscoped',
1283
1600
  pattern: /theme-allow/, // handled inline (annotation pass); entry keeps the registry complete
1284
1601
  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.',
1602
+ '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
1603
  },
1287
1604
  'surface-shadow-override': {
1288
1605
  kind: 'surface-shadow-override',
1289
1606
  pattern: SHADOW_DECL, // handled inline (value is judged, not just matched)
1290
1607
  enabled: (cfg: GuardConfig) => cfg.rawSurface,
1291
1608
  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.',
1609
+ '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
1610
  },
1294
1611
  'css-raw-surface': {
1295
1612
  kind: 'css-raw-surface',
@@ -1316,6 +1633,54 @@ export const GUARD_RULES = {
1316
1633
  },
1317
1634
  } as const satisfies Record<GuardKind, GuardRule>
1318
1635
 
1636
+ /**
1637
+ * The one-line remedy for `kind`, as a report reads it: `raw-hex: Route color through VX.* / …`.
1638
+ *
1639
+ * The registry has always carried a remedy per kind; `check-theme`'s `Fix:` epilogue read a
1640
+ * hand-duplicated subset instead, and the subset was missing every kind added at 1.20.0. Since all
1641
+ * five of those ship `warn` under grace, the findings whose whole argument is "this looks correct
1642
+ * and is not" arrived with no argument at all — just "add a `theme-allow`", which reads as advice to
1643
+ * waive. Sourcing the epilogue from here retires the duplicate rather than extending it.
1644
+ */
1645
+ export function guardKindRemedy(kind: GuardKind): string {
1646
+ return `${kind}: ${(GUARD_RULES[kind] as GuardRule).message}`
1647
+ }
1648
+
1649
+ /** JSON and its manifest dialect — the file class with no comment syntax to carry an annotation. */
1650
+ const JSON_FAMILY_FILE = /\.(?:webmanifest|json)$/
1651
+
1652
+ /**
1653
+ * How to waive a finding **in this file's syntax** — the closer a report prints under `Fix:`.
1654
+ *
1655
+ * `check-theme` printed one closer for every file class, and from 1.20.0 (when the scan reached
1656
+ * `.webmanifest` / `.json`) it prescribed a `theme-allow` comment to files that cannot hold a
1657
+ * comment. Two consumers read that, found no route, and fell back to a blanket `exemptRules` entry —
1658
+ * the un-reviewable shape the release was spent retiring.
1659
+ *
1660
+ * A manifest gets the sharper answer first, because a hex there can never be *right*: a manifest
1661
+ * cannot reference a CSS custom property, so the value is a hand-copy of a token that drifts the day
1662
+ * the palette moves (which is exactly what both consumers found — two dead colours). The remedy is
1663
+ * to stop hand-writing the file: `basaltAppPlugin` emits it from `SURFACE.bg`. The annotation is the
1664
+ * fallback for a manifest basalt does not generate.
1665
+ */
1666
+ export function guardWaiverHint(relPath: string): string {
1667
+ if (relPath.endsWith('.webmanifest')) {
1668
+ return (
1669
+ 'A manifest cannot reference a CSS variable, so a hex here is a hand-copy that drifts: let ' +
1670
+ 'basaltAppPlugin emit it (`manifest: { … }` in vite.config.ts) instead of hand-writing it. ' +
1671
+ 'For a manifest basalt does not generate, declare the exception with a ' +
1672
+ '`"basalt:theme-allow-file": "raw-hex — <why>"` member.'
1673
+ )
1674
+ }
1675
+ if (JSON_FAMILY_FILE.test(relPath)) {
1676
+ return (
1677
+ 'JSON has no comments — declare a deliberate exception with a ' +
1678
+ '`"basalt:theme-allow-file": "<rule-id> — <why>"` member.'
1679
+ )
1680
+ }
1681
+ return 'Add a `theme-allow <rule-id> — <why>` comment for a deliberate exception (`theme-allow-file` declares the whole file).'
1682
+ }
1683
+
1319
1684
  /** Whether `kind`'s rule fires for `relPath` — `appliesTo` is opt-in; absent means always applies. */
1320
1685
  function ruleApplies(kind: GuardKind, relPath: string): boolean {
1321
1686
  const appliesTo = (GUARD_RULES[kind] as GuardRule).appliesTo
@@ -1365,11 +1730,24 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
1365
1730
  const syntax = guardSyntaxFor(relPath)
1366
1731
  const codeText = syntax === 'markup' ? stripMarkupComments(text) : stripComments(text, syntax)
1367
1732
  const codeLines = codeText.split('\n')
1368
- const { waivers, declared } = collectAllowAnnotations(lines, codeLines, cfg.allowComment, syntax)
1733
+ const { waivers, fileScoped, declared } = collectAllowAnnotations(
1734
+ lines,
1735
+ codeLines,
1736
+ cfg.allowComment,
1737
+ syntax,
1738
+ )
1369
1739
 
1370
- /** Is `kind` waived at (1-based) `line`? See `collectAllowAnnotations` for the three placements. */
1740
+ /** Kinds a `theme-allow-file` declared for the whole file — resolved once, not per finding. */
1741
+ const fileWaived = new Set<GuardKind>()
1742
+ for (const annotation of fileScoped) {
1743
+ for (const rule of annotation.rules) {
1744
+ if (Object.hasOwn(GUARD_RULES, rule)) fileWaived.add(rule as GuardKind)
1745
+ }
1746
+ }
1747
+
1748
+ /** Is `kind` waived at (1-based) `line`? See `collectAllowAnnotations` for the full grammar. */
1371
1749
  const isAllowed = (line: number, kind: GuardKind): boolean =>
1372
- annotationsCover(waivers.get(line - 1), kind)
1750
+ fileWaived.has(kind) || annotationsCover(waivers.get(line - 1), kind)
1373
1751
 
1374
1752
  const isAllowedInRange = (start: number, end: number, kind: GuardKind): boolean => {
1375
1753
  for (let n = start; n <= end; n++) if (isAllowed(n, kind)) return true
@@ -1409,6 +1787,18 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
1409
1787
  /** Lines the line-scoped `raw-html-layout` already owns, so `hidden-inline-style` can't double-report. */
1410
1788
  const rawHtmlLayoutLines = new Set<number>()
1411
1789
 
1790
+ /** Offset of each line's start in `codeText` — lets a line-scoped match ask about its surroundings. */
1791
+ const lineStarts: number[] = []
1792
+ for (let n = 0, i = 0; i < codeLines.length; i++) {
1793
+ lineStarts.push(n)
1794
+ n += (codeLines[i] ?? '').length + 1
1795
+ }
1796
+
1797
+ /** Every identifier the file hands to a `style=`/`styles=` prop — see `isStyleObjectContext`. */
1798
+ const styleIdentifiers = new Set(
1799
+ [...codeText.matchAll(STYLE_BOUND_IDENTIFIER)].map((m) => m[1] ?? ''),
1800
+ )
1801
+
1412
1802
  for (let i = 0; i < lines.length; i++) {
1413
1803
  const line = codeLines[i] ?? ''
1414
1804
 
@@ -1511,10 +1901,18 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
1511
1901
  push('raw-html-layout', i + 1, '<raw-html style>')
1512
1902
  }
1513
1903
 
1514
- // inline-spacing — pattern + gating from GUARD_RULES, plus the CSS-only sub-scale escape.
1904
+ // inline-spacing — pattern + gating from GUARD_RULES, plus the CSS-only sub-scale escape and
1905
+ // the TS-only style-object test for a unitless number (see isStyleObjectContext).
1515
1906
  if (GUARD_RULES['inline-spacing'].enabled!(cfg)) {
1516
1907
  for (const m of line.matchAll(GUARD_RULES['inline-spacing'].pattern as RegExp)) {
1517
1908
  if (syntax === 'css' && isSubScaleCssSpacing(line, m.index)) continue
1909
+ const hasUnit = /^[a-z%]/.test(line.slice(m.index + m[0].length))
1910
+ if (
1911
+ syntax === 'ts' &&
1912
+ !hasUnit &&
1913
+ !isStyleObjectContext(codeText, lineStarts[i]! + m.index, styleIdentifiers)
1914
+ )
1915
+ continue
1518
1916
  push('inline-spacing', i + 1, m[0])
1519
1917
  }
1520
1918
  }
@@ -1680,3 +2078,98 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
1680
2078
  severity: severityOf(f.kind, cfg),
1681
2079
  }))
1682
2080
  }
2081
+
2082
+ // ── Allow-annotation enumeration (check-theme --audit-allows) ─────────────────────────────────────
2083
+
2084
+ /**
2085
+ * One `theme-allow` annotation as WRITTEN, classified by whose reach its ids fall in.
2086
+ *
2087
+ * `guardKinds` is what `checkSource` can judge by re-running itself with the annotation
2088
+ * neutralized. `pluginRules` is what only an `oxlint` run can judge. `unknownRules` names no rule
2089
+ * at all. All three empty with `bare: true` is the legacy blanket form.
2090
+ */
2091
+ export type AllowAnnotationSite = {
2092
+ /** 1-based line the annotation is written on. */
2093
+ readonly line: number
2094
+ /** `'file'` for the `theme-allow-file` declaration form, `'line'` otherwise. */
2095
+ readonly scope: 'line' | 'file'
2096
+ /** Every id the annotation names that resolves to a real rule, `basalt/` prefix stripped. */
2097
+ readonly rules: readonly string[]
2098
+ /** The subset of `rules` `checkSource` owns. */
2099
+ readonly guardKinds: readonly GuardKind[]
2100
+ /** The subset of `rules` the oxlint plugin owns — outside `checkSource`'s reach. */
2101
+ readonly pluginRules: readonly string[]
2102
+ /** Words that occupied the id slot but name no rule. Their presence makes the parse fail closed. */
2103
+ readonly unknownRules: readonly string[]
2104
+ /** A written reason follows the ids. */
2105
+ readonly hasReason: boolean
2106
+ /** The legacy blanket form — no ids at all, known or unknown. Only covers anything at line scope. */
2107
+ readonly bare: boolean
2108
+ /** The source line, verbatim. */
2109
+ readonly text: string
2110
+ }
2111
+
2112
+ /**
2113
+ * Every allow annotation in one file, with the ids it names classified by reach.
2114
+ *
2115
+ * Pure, and it shares `collectAllowAnnotations` with {@link checkSource} — so a caller enumerating
2116
+ * annotations sees exactly the set the scan honours, rather than a second regex that can drift from
2117
+ * it. `--audit-allows` carried such a mirror and it was already one alternation behind.
2118
+ *
2119
+ * A file-scoped declaration appears once, at the line it is written on.
2120
+ *
2121
+ * @example
2122
+ * for (const site of findAllowAnnotations(text, rel, cfg)) {
2123
+ * if (site.pluginRules.length > 0) probeWithOxlint(neutralizeAllowAnnotation(text, site.line, cfg))
2124
+ * }
2125
+ */
2126
+ export function findAllowAnnotations(
2127
+ text: string,
2128
+ relPath: string,
2129
+ cfg: GuardConfig,
2130
+ ): AllowAnnotationSite[] {
2131
+ const lines = text.split('\n')
2132
+ const syntax = guardSyntaxFor(relPath)
2133
+ const codeText = syntax === 'markup' ? stripMarkupComments(text) : stripComments(text, syntax)
2134
+ const { declared } = collectAllowAnnotations(
2135
+ lines,
2136
+ codeText.split('\n'),
2137
+ cfg.allowComment,
2138
+ syntax,
2139
+ )
2140
+ return declared.map(({ line, annotation }) => ({
2141
+ line,
2142
+ scope: annotation.scope,
2143
+ rules: [...annotation.rules],
2144
+ guardKinds: annotation.rules.filter((id): id is GuardKind => Object.hasOwn(GUARD_RULES, id)),
2145
+ pluginRules: annotation.rules.filter(
2146
+ (id) => !Object.hasOwn(GUARD_RULES, id) && PLUGIN_RULE_IDS.has(id),
2147
+ ),
2148
+ unknownRules: [...annotation.unknownRules],
2149
+ hasReason: annotation.hasReason,
2150
+ bare: annotation.rules.length === 0 && annotation.unknownRules.length === 0,
2151
+ text: lines[line - 1] ?? '',
2152
+ }))
2153
+ }
2154
+
2155
+ /** The token a neutralized annotation is rewritten to. Shares no substring with `theme-allow`. */
2156
+ export const NEUTRALIZED_ALLOW_TOKEN = 'basalt-audit-neutralized'
2157
+
2158
+ /**
2159
+ * `text` with the allow token on ONE (1-based) line rewritten to {@link NEUTRALIZED_ALLOW_TOKEN},
2160
+ * leaving every other annotation intact.
2161
+ *
2162
+ * The probe half of an audit: re-scan the result and the findings that appear are exactly what that
2163
+ * one annotation suppresses. Shipped rather than left to each caller so the guard probe and the
2164
+ * oxlint probe neutralize identically — a `--audit-allows` verdict that differs between the two
2165
+ * halves by a substitution detail is worse than no verdict.
2166
+ *
2167
+ * A line with no token comes back unchanged.
2168
+ */
2169
+ export function neutralizeAllowAnnotation(text: string, line: number, cfg: GuardConfig): string {
2170
+ const lines = text.split('\n')
2171
+ const target = lines[line - 1]
2172
+ if (target === undefined) return text
2173
+ lines[line - 1] = target.replaceAll(cfg.allowComment, NEUTRALIZED_ALLOW_TOKEN)
2174
+ return lines.join('\n')
2175
+ }