basalt-ui 1.21.0 → 1.23.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 (85) hide show
  1. package/AGENTS.md +11 -1
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +205 -9
  4. package/README.md +63 -16
  5. package/agent/rules/basalt-app.md +39 -9
  6. package/agent/rules/basalt-charts.md +41 -6
  7. package/agent/rules/basalt-router.md +1 -1
  8. package/agent/rules/basalt-state.md +4 -4
  9. package/agent/rules/basalt-tokens.md +25 -4
  10. package/agent/skills/basalt-app/SKILL.md +4 -3
  11. package/agent/skills/basalt-charts/SKILL.md +23 -8
  12. package/agent/templates/DESIGN.md.tpl +3 -1
  13. package/configs/oxlint-plugin.js +86 -9
  14. package/configs/oxlint-plugin.test.ts +173 -0
  15. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  16. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  17. package/dist/charts/hooks/useBandPlot.js +127 -0
  18. package/dist/charts/index.d.ts +4 -0
  19. package/dist/charts/index.d.ts.map +1 -1
  20. package/dist/charts/index.js +14 -0
  21. package/dist/charts/kinds/BandStrip.d.ts +113 -0
  22. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  23. package/dist/charts/kinds/BandStrip.js +218 -0
  24. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  25. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  26. package/dist/charts/kinds/Bars.d.ts +5 -1
  27. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  28. package/dist/charts/kinds/Bars.js +2 -0
  29. package/dist/charts/kinds/MirroredBars.d.ts +93 -0
  30. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  31. package/dist/charts/kinds/MirroredBars.js +315 -0
  32. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  33. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  34. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  35. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  36. package/dist/charts/kinds/MultiLine.js +2 -0
  37. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  38. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  39. package/dist/charts/kinds/StackedArea.js +2 -0
  40. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  41. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  42. package/dist/charts/kinds/ZonedLine.js +2 -0
  43. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  44. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  45. package/dist/charts/primitives/CartesianChart.js +5 -2
  46. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  47. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  48. package/dist/charts/primitives/HatchPattern.js +31 -0
  49. package/dist/cli/index.d.ts +9 -1
  50. package/dist/cli/index.d.ts.map +1 -1
  51. package/dist/cli/index.js +275 -48
  52. package/dist/cli/shipped-versions.test.d.ts +2 -0
  53. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  54. package/dist/guard/index.d.ts +67 -0
  55. package/dist/guard/index.d.ts.map +1 -1
  56. package/dist/guard/index.js +70 -11
  57. package/dist/vite.d.ts +44 -2
  58. package/dist/vite.d.ts.map +1 -1
  59. package/dist/vite.js +71 -26
  60. package/llms.txt +2 -2
  61. package/package.json +1 -1
  62. package/src/charts/hooks/useBandPlot.ts +302 -0
  63. package/src/charts/index.ts +14 -0
  64. package/src/charts/kinds/BandStrip.test.tsx +200 -0
  65. package/src/charts/kinds/BandStrip.tsx +364 -0
  66. package/src/charts/kinds/Bars.tsx +7 -1
  67. package/src/charts/kinds/DualPanel.test.tsx +10 -4
  68. package/src/charts/kinds/MirroredBars.test.tsx +193 -0
  69. package/src/charts/kinds/MirroredBars.tsx +455 -0
  70. package/src/charts/kinds/MultiLine.tsx +7 -1
  71. package/src/charts/kinds/StackedArea.tsx +7 -1
  72. package/src/charts/kinds/ZonedLine.tsx +7 -1
  73. package/src/charts/primitives/CartesianChart.test.tsx +86 -4
  74. package/src/charts/primitives/CartesianChart.tsx +28 -3
  75. package/src/charts/primitives/HatchPattern.tsx +55 -0
  76. package/src/cli/check-theme.test.ts +35 -0
  77. package/src/cli/help.test.ts +5 -1
  78. package/src/cli/index.ts +552 -79
  79. package/src/cli/shipped-versions.test.ts +83 -0
  80. package/src/cli/toolchain-wiring.test.ts +362 -6
  81. package/src/cli/waiver-audit.test.ts +88 -4
  82. package/src/guard/check-source.test.ts +293 -0
  83. package/src/guard/index.ts +209 -20
  84. package/src/vite.test.ts +112 -0
  85. package/src/vite.ts +147 -31
@@ -13,10 +13,14 @@ import { SPACE_SCALE } from '../tokens/palette'
13
13
  import {
14
14
  checkSource,
15
15
  DEFAULT_GUARD_CONFIG,
16
+ findAllowAnnotations,
16
17
  GENERATED_HEADER_LINE,
17
18
  guardKindRemedy,
18
19
  guardWaiverHint,
19
20
  GUARD_RULES,
21
+ neutralizeAllowAnnotation,
22
+ NEUTRALIZED_ALLOW_TOKEN,
23
+ PLUGIN_RULE_IDS,
20
24
  TOKENS_ONLY_DISABLED_KINDS,
21
25
  unmatchedExemptPatterns,
22
26
  } from './index'
@@ -2733,3 +2737,292 @@ describe('inline-spacing — the style-object test', () => {
2733
2737
  expect(kinds(f)).toContain('inline-spacing')
2734
2738
  })
2735
2739
  })
2740
+
2741
+ // ── theme-allow shape grid ───────────────────────────────────────────────────────────────────────
2742
+
2743
+ /**
2744
+ * The whole shape space of an annotation, pinned cell by cell.
2745
+ *
2746
+ * FIVE holes have been found in this one contract in four rounds — two false positives, one false
2747
+ * negative, and twice the guard and the oxlint plugin disagreeing about what an annotation IS. Each
2748
+ * was found by a consumer writing a shape nobody had thought to test, and round 6's answer (a
2749
+ * thirteen-shape list of "every shape a consumer actually writes") missed the next three anyway,
2750
+ * because a list of observed shapes cannot cover the ones not yet observed.
2751
+ *
2752
+ * So this is a GRID over the four things that actually vary, not a list of anecdotes:
2753
+ *
2754
+ * | axis | values |
2755
+ * |-|-|
2756
+ * | comment style | `//` · `/* *\/` · `/** *\/` · `{/* *\/}` · `{/** *\/}` · `<!-- -->` · JSON member |
2757
+ * | token position | opener line · gutter line · trailing after code |
2758
+ * | where the closer falls | the token's line · its own line · N lines below · n/a |
2759
+ * | what follows | code · blank then code · comment then code · code on the closer's line |
2760
+ *
2761
+ * `configs/oxlint-plugin.test.ts` pins the same grid, under the same row names, for the subset the
2762
+ * plugin can judge (TS/JSX — it never sees CSS, HTML or JSON). The two halves must agree on every
2763
+ * shared cell; add a row to one and it goes in the other in the same commit.
2764
+ *
2765
+ * The unsupported cells are asserted too. "Unsupported" and "silently broken" read identically to a
2766
+ * consumer, and the whole point of the grid is to make them different things.
2767
+ */
2768
+ /** Wraps JSX children in a component, so a `{/* … *\/}` child is legal source. */
2769
+ const jsxShape = (body: string): string =>
2770
+ `export const C = () => (\n <div>\n${body}\n </div>\n)\n`
2771
+
2772
+ /** Does the file waive its `raw-hex`? — the one question every grid row asks. */
2773
+ const shapeWaives = (relPath: string, src: string): boolean =>
2774
+ checkSource(src, relPath, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex').length === 0
2775
+
2776
+ describe('theme-allow shape grid', () => {
2777
+ const REASON = 'deliberate legacy value'
2778
+ const A = `theme-allow raw-hex — ${REASON}`
2779
+ const AF = `theme-allow-file raw-hex — ${REASON}`
2780
+ /** The statement the guard reports `raw-hex` on. */
2781
+ const T = `const a = '#f00'`
2782
+ /** The JSX child the guard reports `raw-hex` on. */
2783
+ const JT = `<span style={{ color: '#f00' }} />`
2784
+ const J = jsxShape
2785
+ const waives = shapeWaives
2786
+
2787
+ // ── SUPPORTED — every cell of the grid that waives ────────────────────────
2788
+ it.each([
2789
+ // `//` — no closer to place.
2790
+ ['// own line', PATH, `// ${A}\n${T}\n`],
2791
+ ['// indented', PATH, `function f() {\n // ${A}\n ${T}\n}\n`],
2792
+ ['// no space after the marker', PATH, `//${A}\n${T}\n`],
2793
+ ['// trailing', PATH, `${T} // ${A}\n`],
2794
+ ['// reason wrapped onto a second // comment', PATH, `// ${A},\n// continued here\n${T}\n`],
2795
+ // `/* */` and `/** */` at statement level, closer in each of its three places.
2796
+ ['/* */ own line', PATH, `/* ${A} */\n${T}\n`],
2797
+ ['/* */ trailing', PATH, `${T} /* ${A} */\n`],
2798
+ ['/** */ own line (docblock opener)', PATH, `/** ${A} */\n${T}\n`],
2799
+ ['/* opener + token, closer on its own line', PATH, `/* ${A}\n*/\n${T}\n`],
2800
+ ['/* gutter token, closer on the token line', PATH, `/*\n ${A} */\n${T}\n`],
2801
+ ['/* gutter token, closer on its own line', PATH, `/*\n ${A}\n*/\n${T}\n`],
2802
+ ['/** star gutter, closer on the token line', PATH, `/**\n * ${A} */\n${T}\n`],
2803
+ ['/** star gutter, closer on its own line', PATH, `/**\n * ${A}\n */\n${T}\n`],
2804
+ // Prose between the token and the closer — any amount of it.
2805
+ ['/** token then 1 prose line', PATH, `/**\n * ${A}\n * more\n */\n${T}\n`],
2806
+ ['/** token then 12 prose lines', PATH, `/**\n * ${A}\n${' * p\n'.repeat(12)} */\n${T}\n`],
2807
+ [
2808
+ '/** prose first, token on a later gutter line',
2809
+ PATH,
2810
+ `/**\n * some prose\n * ${A}\n */\n${T}\n`,
2811
+ ],
2812
+ // JSX expression containers — the family every disagreement so far has come from.
2813
+ ['{/* */} own line', PATH, J(` {/* ${A} */}\n ${JT}`)],
2814
+ ['{/* */} trailing on the target line', PATH, J(` ${JT} {/* ${A} */}`)],
2815
+ ['{/* opener + token, closer on its own line', PATH, J(` {/* ${A}\n */}\n ${JT}`)],
2816
+ ['{/* gutter token, closer on the token line', PATH, J(` {/*\n ${A} */}\n ${JT}`)],
2817
+ [
2818
+ '{/* gutter token, closer on its own line',
2819
+ PATH,
2820
+ J(` {/*\n ${A}\n */}\n ${JT}`),
2821
+ ],
2822
+ ['{/** star gutter, closer on the token line', PATH, J(` {/**\n * ${A} */}\n ${JT}`)],
2823
+ [
2824
+ '{/** star gutter, closer on its own line',
2825
+ PATH,
2826
+ J(` {/**\n * ${A}\n */}\n ${JT}`),
2827
+ ],
2828
+ [
2829
+ '{/** token then 6 prose lines',
2830
+ PATH,
2831
+ J(` {/**\n * ${A}\n${' * p\n'.repeat(6)} */}\n ${JT}`),
2832
+ ],
2833
+ ['{/* target on the closer line', PATH, J(` {/*\n ${A}\n */}${JT}`)],
2834
+ ['{/* closer alone, tab indented', PATH, J(`\t{/*\n\t ${A}\n\t*/}\n\t${JT}`)],
2835
+ [
2836
+ '{/* closer alone, space between */ and }',
2837
+ PATH,
2838
+ J(` {/*\n ${A}\n */ }\n ${JT}`),
2839
+ ],
2840
+ [
2841
+ '{/* closer alone, two annotations in one block',
2842
+ PATH,
2843
+ J(` {/*\n ${A}\n theme-allow raw-color-fn — ${REASON}\n */}\n ${JT}`),
2844
+ ],
2845
+ // A further comment between the annotation and the code is walked through; a blank line is not.
2846
+ ['/** */ then an unrelated // note', PATH, `/** ${A} */\n// an unrelated note\n${T}\n`],
2847
+ [
2848
+ '{/* */} then an unrelated {/* */}',
2849
+ PATH,
2850
+ J(` {/* ${A} */}\n {/* unrelated */}\n ${JT}`),
2851
+ ],
2852
+ // The file-declaration form, which is position-independent — pinned in two placements anyway,
2853
+ // because "position-independent" is a claim the parser has to keep making.
2854
+ [
2855
+ 'theme-allow-file, {/* closer on its own line',
2856
+ PATH,
2857
+ J(` {/*\n ${AF}\n */}\n ${JT}`),
2858
+ ],
2859
+ ['theme-allow-file, /** star gutter', PATH, `/**\n * ${AF}\n */\n${T}\n`],
2860
+ // Dialects the plugin never sees — CSS continuation lines, HTML, and the JSON member form.
2861
+ ['css trailing', 'src/a.css', `a { color: #f00; /* ${A} */ }\n`],
2862
+ [
2863
+ 'css reflowed onto continuation lines',
2864
+ 'src/a.css',
2865
+ `a {\n color: var(\n --x,\n #f00\n ); /* ${A} */\n}\n`,
2866
+ ],
2867
+ ['html comment own line', 'index.html', `<!-- ${A} -->\n<b style="color: #f00"></b>\n`],
2868
+ ['html trailing comment', 'index.html', `<b style="color: #f00"></b> <!-- ${A} -->\n`],
2869
+ [
2870
+ 'JSON member form',
2871
+ 'public/site.webmanifest',
2872
+ `{\n "basalt:theme-allow-file": "raw-hex — ${REASON}",\n "theme_color": "#f00"\n}\n`,
2873
+ ],
2874
+ ])('%s waives', (_name, relPath, src) => {
2875
+ expect(waives(relPath as string, src as string)).toBe(true)
2876
+ })
2877
+
2878
+ // ── UNSUPPORTED — asserted, so a hole can never pass for a design decision ──
2879
+ it.each([
2880
+ // A blank line is the separation people use for "this comment is not about the next statement".
2881
+ ['blank line after a // annotation', PATH, `// ${A}\n\n${T}\n`],
2882
+ ['blank line after a {/* */} annotation', PATH, J(` {/* ${A} */}\n\n ${JT}`)],
2883
+ [
2884
+ 'blank line after a {/* closer on its own line',
2885
+ PATH,
2886
+ J(` {/*\n ${A}\n */}\n\n ${JT}`),
2887
+ ],
2888
+ // Prose that MENTIONS the token is not an annotation — the one false NEGATIVE in the set, and
2889
+ // the reason the token has to START its comment.
2890
+ ['mid-sentence in a line comment', PATH, `// we normally write a ${A} here\n${T}\n`],
2891
+ [
2892
+ 'mid-sentence in a docblock gutter',
2893
+ PATH,
2894
+ `/**\n * Each value is escaped with a ${A} annotation.\n */\n${T}\n`,
2895
+ ],
2896
+ [
2897
+ 'mid-sentence in a JSX expression comment',
2898
+ PATH,
2899
+ J(` {/* the shape here is a ${A} comment */}\n ${JT}`),
2900
+ ],
2901
+ ['inside a string literal', PATH, `const doc = 'theme-allow raw-hex'\n${T}\n`],
2902
+ // An annotation reaches the first line below its comment — not an arbitrary line further down.
2903
+ // A multi-line opening tag therefore needs the annotation beside the attribute, not above the
2904
+ // tag. Both halves agree, and both report rather than silently waiving the wrong node.
2905
+ [
2906
+ 'above a multi-line opening tag',
2907
+ PATH,
2908
+ J(` {/* ${A} */}\n <span\n style={{ color: '#f00' }}\n />`),
2909
+ ],
2910
+ ])('%s does NOT waive', (_name, relPath, src) => {
2911
+ expect(waives(relPath as string, src as string)).toBe(false)
2912
+ })
2913
+
2914
+ // The narrowness of the `*/}` exception: a `}` that is REAL code keeps its trailing
2915
+ // classification, so the annotation does not reach the statement below it.
2916
+ it('a trailing annotation on a real closing brace stays on its own line', () => {
2917
+ const src = `function f() {\n const a = 1\n} // ${A}\nconst b = '#0f0'\n`
2918
+ expect(
2919
+ checkSource(src, PATH, DEFAULT_GUARD_CONFIG)
2920
+ .filter((f) => f.kind === 'raw-hex')
2921
+ .map((f) => f.line),
2922
+ ).toEqual([4])
2923
+ })
2924
+
2925
+ // A trailing annotation is scoped to its OWN line — otherwise `const a = '#f00' // theme-allow`
2926
+ // would silently waive the statement below it too.
2927
+ it('a trailing annotation does not reach the line below', () => {
2928
+ const src = `const a = '#f00' // ${A}\nconst b = '#0f0'\n`
2929
+ expect(
2930
+ checkSource(src, PATH, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex'),
2931
+ ).toHaveLength(1)
2932
+ })
2933
+ })
2934
+
2935
+ // ── findAllowAnnotations / neutralizeAllowAnnotation ─────────────────────────────────────────────
2936
+
2937
+ describe('findAllowAnnotations', () => {
2938
+ const cfg = DEFAULT_GUARD_CONFIG
2939
+
2940
+ it('classifies each named id by whose reach it falls in', () => {
2941
+ const src = [
2942
+ `// theme-allow raw-hex — a guard kind`,
2943
+ `const a = '#f00'`,
2944
+ `// theme-allow hand-rolled-plot — a plugin rule, outside checkSource's reach`,
2945
+ `const b = 1`,
2946
+ `// theme-allow raw-hexx — a typo`,
2947
+ `const c = 2`,
2948
+ `// theme-allow`,
2949
+ `const d = 3`,
2950
+ ].join('\n')
2951
+ const sites = findAllowAnnotations(src, PATH, cfg)
2952
+
2953
+ expect(sites.map((s) => s.line)).toEqual([1, 3, 5, 7])
2954
+ expect(sites[0]).toMatchObject({ guardKinds: ['raw-hex'], pluginRules: [], bare: false })
2955
+ expect(sites[1]).toMatchObject({ guardKinds: [], pluginRules: ['hand-rolled-plot'] })
2956
+ expect(sites[2]).toMatchObject({ rules: [], unknownRules: ['raw-hexx'], bare: false })
2957
+ expect(sites[3]).toMatchObject({ bare: true, hasReason: false })
2958
+ })
2959
+
2960
+ it('records the file-declaration form once, at the line it is written on', () => {
2961
+ const src = `// theme-allow-file raw-hex — the whole palette fixture\nconst a = '#f00'\n`
2962
+ expect(findAllowAnnotations(src, PATH, cfg)).toMatchObject([
2963
+ { line: 1, scope: 'file', guardKinds: ['raw-hex'] },
2964
+ ])
2965
+ })
2966
+
2967
+ it('carries the source line verbatim, and finds nothing in a file with no annotation', () => {
2968
+ expect(findAllowAnnotations(`const a = '#f00'\n`, PATH, cfg)).toEqual([])
2969
+ expect(
2970
+ findAllowAnnotations(` // theme-allow raw-hex — why not\nconst a = 1\n`, PATH, cfg)[0]?.text,
2971
+ ).toBe(' // theme-allow raw-hex — why not')
2972
+ })
2973
+
2974
+ // It shares `collectAllowAnnotations` with `checkSource`, so it cannot list a line the scan does
2975
+ // not honour — the mirrored regex `--audit-allows` carried was already one alternation behind.
2976
+ it('agrees with checkSource about what is prose and what is an annotation', () => {
2977
+ expect(
2978
+ findAllowAnnotations(`// a theme-allow raw-hex goes here\nconst a = 1\n`, PATH, cfg),
2979
+ ).toEqual([])
2980
+ expect(
2981
+ findAllowAnnotations(`/** theme-allow raw-hex — deliberate */\nconst a = 1\n`, PATH, cfg),
2982
+ ).toHaveLength(1)
2983
+ })
2984
+
2985
+ it('finds the JSON member form in a manifest', () => {
2986
+ const src = `{\n "basalt:theme-allow-file": "raw-hex — brand color"\n}\n`
2987
+ expect(findAllowAnnotations(src, 'public/site.webmanifest', cfg)).toMatchObject([
2988
+ { line: 2, scope: 'file', guardKinds: ['raw-hex'] },
2989
+ ])
2990
+ })
2991
+ })
2992
+
2993
+ describe('neutralizeAllowAnnotation', () => {
2994
+ const cfg = DEFAULT_GUARD_CONFIG
2995
+
2996
+ it('neutralizes ONE annotation and reveals exactly what it suppressed', () => {
2997
+ const src = [
2998
+ `const a = '#f00' // theme-allow raw-hex — first`,
2999
+ `const b = '#0f0' // theme-allow raw-hex — second`,
3000
+ ].join('\n')
3001
+ expect(checkSource(src, PATH, cfg).filter((f) => f.kind === 'raw-hex')).toEqual([])
3002
+
3003
+ const probe = neutralizeAllowAnnotation(src, 1, cfg)
3004
+ expect(probe).toContain(NEUTRALIZED_ALLOW_TOKEN)
3005
+ expect(
3006
+ checkSource(probe, PATH, cfg)
3007
+ .filter((f) => f.kind === 'raw-hex')
3008
+ .map((f) => f.line),
3009
+ ).toEqual([1])
3010
+ })
3011
+
3012
+ it('leaves a line with no token, and an out-of-range line, untouched', () => {
3013
+ const src = `const a = 1\nconst b = 2\n`
3014
+ expect(neutralizeAllowAnnotation(src, 1, cfg)).toBe(src)
3015
+ expect(neutralizeAllowAnnotation(src, 99, cfg)).toBe(src)
3016
+ })
3017
+
3018
+ it('the neutralized token shares no substring with the annotation it replaces', () => {
3019
+ expect(NEUTRALIZED_ALLOW_TOKEN).not.toContain(cfg.allowComment)
3020
+ expect(NEUTRALIZED_ALLOW_TOKEN.includes('theme-allow')).toBe(false)
3021
+ })
3022
+ })
3023
+
3024
+ describe('PLUGIN_RULE_IDS', () => {
3025
+ it('names no guard kind — the two registries are disjoint by construction', () => {
3026
+ for (const id of PLUGIN_RULE_IDS) expect(Object.hasOwn(GUARD_RULES, id)).toBe(false)
3027
+ })
3028
+ })
@@ -400,7 +400,7 @@ const RAW_CHART_LEGEND_ARRAY = /<ChartLegend\b[^>]*?\bitems\s*=\s*\{\s*\[/g
400
400
  // alternative so their `>` never terminates the tag early. A bare `>` comparison inside a prop
401
401
  // expression still ends the match (accepted limitation of the bounded scan).
402
402
  const CHART_ENTRY_POINT_TAG =
403
- /<(?:MultiLine|Bars|Donut|DualPanel|Heatmap|ZonedLine|StackedArea|LineSparkline|BarSparkline)\b(?:<[^<>]*>)?(?:=>|[^>])*?>/g
403
+ /<(?:MultiLine|Bars|BandStrip|Donut|DualPanel|Heatmap|MirroredBars|ZonedLine|StackedArea|LineSparkline|BarSparkline)\b(?:<[^<>]*>)?(?:=>|[^>])*?>/g
404
404
  const HAS_ARIA_LABEL_PROP = /\bariaLabel\s*=/
405
405
 
406
406
  // Raw lowercase form-control element — line-scoped, same shape as RAW_VISX_AXIS. `\b` after the
@@ -661,8 +661,12 @@ export function unmatchedExemptPatterns(
661
661
  * but they ARE valid ids in a `theme-allow`, so an annotation scoped to one of them must parse as
662
662
  * accountable rather than be reported as unscoped. Kept as a literal list because the plugin is a
663
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.
664
668
  */
665
- const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
669
+ export const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
666
670
  'no-raw-font-size',
667
671
  'raw-size-literal',
668
672
  'card-inset',
@@ -767,8 +771,45 @@ function parseAllowAnnotation(rest: string, scope: AllowScope): AllowAnnotation
767
771
  /** How far back a trailing CSS annotation reaches for the declaration it terminates. */
768
772
  const MAX_CSS_CONTINUATION_LINES = 8
769
773
 
770
- /** How far a comment-only annotation reaches forward through the rest of its own comment block. */
771
- const MAX_COMMENT_BLOCK_LINES = 8
774
+ /**
775
+ * The 0-based line the annotation's OWN comment closes on.
776
+ *
777
+ * A comment-only annotation waives the first line below its comment that is not itself comment, so
778
+ * the walk needs to know how far the annotation's own comment reaches. It used to substitute a
779
+ * budget — walk forward at most eight lines — which was wrong in two ways nothing could see:
780
+ *
781
+ * 1. **A comment block longer than the budget.** A docblock with ~12 lines of reason between the
782
+ * token and its `*\/` ran the walk out before it reached the code, while the oxlint plugin
783
+ * (which reads the comment NODE, so length is free) waived it. Nothing about the shape said so;
784
+ * the annotation just stopped working past a line count nobody could count.
785
+ * 2. **A blank line inside the block.** Outside a comment a blank line SEPARATES — it is how people
786
+ * say "this comment is not about the next statement" — but a blank gutter line in the middle of
787
+ * a docblock is just prose, and ending the walk there dropped the waiver.
788
+ *
789
+ * Knowing where the comment ends settles both: inside it, keep going; past it, the ordinary
790
+ * comment-run rules apply (see the walk in {@link collectAllowAnnotations}, and
791
+ * {@link isCommentOnly} for the third hole this pass closed).
792
+ *
793
+ * An unterminated block comment reports its own line — the conservative answer for a file that does
794
+ * not parse anyway, and it keeps the walk from running to EOF.
795
+ */
796
+ function commentBlockEnd(
797
+ lines: readonly string[],
798
+ index: number,
799
+ tokenAt: number,
800
+ syntax: GuardSyntax,
801
+ ): number {
802
+ const line = lines[index] ?? ''
803
+ // A line comment closes at its own end of line. CSS has no `//`, so the test is TS-only: there,
804
+ // a `//`-looking sequence is text and the conservative same-line answer is the right one anyway.
805
+ if (syntax === 'ts' && /\/\/\s*$/.test(line.slice(0, tokenAt))) return index
806
+ const closer = syntax === 'markup' ? '-->' : '*/'
807
+ if (line.slice(tokenAt).includes(closer)) return index
808
+ for (let ahead = index + 1; ahead < lines.length; ahead++) {
809
+ if ((lines[ahead] ?? '').includes(closer)) return ahead
810
+ }
811
+ return index
812
+ }
772
813
 
773
814
  /**
774
815
  * A comment opener sitting immediately before the annotation token.
@@ -784,6 +825,39 @@ const COMMENT_OPENER_BEFORE = /(?:\/\/|\/\*|<!--)\s*$/
784
825
  /** A line that is NOTHING but a comment — the placement that waives the line below it. */
785
826
  const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
786
827
 
828
+ /**
829
+ * A JSX expression container closing immediately after the comment that filled it — the `*\/}` of a
830
+ * `{/* … *\/}` whose annotation wrapped onto its own line.
831
+ *
832
+ * `stripComments` blanks the comment and leaves the bare `}` behind, so the line read as CODE and
833
+ * the annotation was classified TRAILING — scoped to a line that holds nothing but a brace, and
834
+ * therefore waiving nothing. That is the shape linewatch writes for every hand-composed chart axis
835
+ * (`{/*` on one line, the annotation and `*\/}` on the next), which worked only because the rules it
836
+ * names live in the oxlint plugin, whose own placement test is comment-node-based and never saw the
837
+ * brace. Any guard kind annotated this way was silently unwaivable.
838
+ *
839
+ * Deliberately narrow: the `}` must abut the comment CLOSE. A `}` that is real code (`} // theme-
840
+ * allow …` closing a block) keeps its trailing classification, so the annotation stays on its own
841
+ * line rather than reaching the statement below.
842
+ */
843
+ const JSX_COMMENT_CLOSE = /\*\/\s*\}\s*$/
844
+
845
+ /**
846
+ * Is this line NOTHING but comment? — one definition, used both to classify the annotation's own
847
+ * line (own-line vs trailing) and to walk the lines under it.
848
+ *
849
+ * The two used to be written out separately, and the walk's copy was the one missing the
850
+ * {@link JSX_COMMENT_CLOSE} clause: a `*\/}` alone on its line read as the code `}`, so the walk
851
+ * stopped ON the closer and the annotation waived a brace. Two consumers isolated that
852
+ * independently — `{/* … *\/}` and `{/** … *\/}` — each with a probe pair differing by one newline,
853
+ * while the oxlint plugin (whose test is comment-node-based and never sees the brace) waived both.
854
+ * A shared predicate is what stops the two placements from drifting apart again.
855
+ */
856
+ function isCommentOnly(raw: string, stripped: string): boolean {
857
+ const code = stripped.trim()
858
+ return code === '' || COMMENT_ONLY_LINE.test(raw) || (code === '}' && JSX_COMMENT_CLOSE.test(raw))
859
+ }
860
+
787
861
  /**
788
862
  * Everything allowed between the comment opener (or the start of the line) and the annotation
789
863
  * token, for the token to count as an ANNOTATION rather than prose that mentions one.
@@ -795,9 +869,14 @@ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
795
869
  * own waivers in a docblock and thereby disarmed the file — the third hole found in this contract
796
870
  * in two rounds, and the only one that was a false NEGATIVE.
797
871
  *
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.
872
+ * So an annotation must START its comment, or start a line inside one: after `//`, `/*` (or a
873
+ * docblock's `/**`), `<!--`, a block-comment gutter `*`, or nothing but leading whitespace.
874
+ * Everything a consumer actually writes qualifies; a sentence about the escape hatch does not.
875
+ *
876
+ * `/**` is in the alternation because the oxlint plugin's copy of this test always accepted it and
877
+ * this one did not: a one-line `/** theme-allow raw-hex — why *\/` waived under `oxlint` and
878
+ * reported under `check-theme`. The two halves of one contract must not disagree about what an
879
+ * annotation IS — only about which rules each can judge.
801
880
  *
802
881
  * What the annotation then WAIVES is a separate, fail-closed question — see
803
882
  * {@link parseAllowAnnotation}. A word in the id slot that names no rule waives nothing, so
@@ -809,7 +888,7 @@ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
809
888
  * it anyway: the kind that fires is the UNSUPPRESSED one (`raw-hex`), not `theme-allow-unscoped`,
810
889
  * and downgrading `raw-hex` to waive-by-default is the hole this closed.
811
890
  */
812
- const ANNOTATION_PREFIX = /(?:^|\/\/|\/\*|<!--|^\s*\*)\s*$/
891
+ const ANNOTATION_PREFIX = /(?:^|\/\/|\/\*\*?|<!--|^\s*\*)\s*$/
813
892
 
814
893
  /** Escape a config-supplied string for literal use inside a RegExp. */
815
894
  function escapeRegExp(value: string): string {
@@ -907,7 +986,12 @@ function collectAllowAnnotations(
907
986
  }
908
987
 
909
988
  /** Place one parsed annotation: file declarations reach everywhere, line ones the 3 placements. */
910
- const record = (index: number, annotation: AllowAnnotation, trailing: boolean): void => {
989
+ const record = (
990
+ index: number,
991
+ annotation: AllowAnnotation,
992
+ trailing: boolean,
993
+ tokenAt: number,
994
+ ): void => {
911
995
  declared.push({ line: index + 1, annotation })
912
996
  if (annotation.scope === 'file') {
913
997
  fileScoped.push(annotation)
@@ -915,16 +999,20 @@ function collectAllowAnnotations(
915
999
  }
916
1000
  add(index, annotation)
917
1001
  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
1002
+ // The first CODE line below the comment. A docblock's `*/` (or the rest of a wrapped reason)
1003
+ // sits between the annotation and the code it is about, so `index + 1` alone meant the
1004
+ // natural multi-line shape silently waived nothing — argo hit it three times in one upgrade,
1005
+ // each looking like a correct annotation. A BLANK line outside the comment still ends the
1006
+ // block: that is the separation people use to mean "this comment is not about the next
1007
+ // statement". Inside it, a blank gutter line is just prose.
1008
+ const closesAt = commentBlockEnd(lines, index, tokenAt, syntax)
1009
+ for (let ahead = 1; index + ahead < lines.length; ahead++) {
1010
+ const at = index + ahead
1011
+ add(at, annotation)
1012
+ if (at <= closesAt) continue
1013
+ const raw = lines[at] ?? ''
927
1014
  if (raw.trim() === '') break
1015
+ if (!isCommentOnly(raw, codeLines[at] ?? '')) break
928
1016
  }
929
1017
  return
930
1018
  }
@@ -942,12 +1030,17 @@ function collectAllowAnnotations(
942
1030
  for (let i = 0; i < lines.length; i++) {
943
1031
  const line = lines[i] ?? ''
944
1032
  const stripped = codeLines[i] ?? ''
945
- const isCommentOnlyLine = stripped.trim() === '' || COMMENT_ONLY_LINE.test(line)
1033
+ const isCommentOnlyLine = isCommentOnly(line, stripped)
946
1034
 
947
1035
  // The JSON member form. Markup-only: in TS/CSS that string would be code, not an annotation.
948
1036
  if (syntax === 'markup') {
949
1037
  for (const m of line.matchAll(jsonKey)) {
950
- record(i, parseAllowAnnotation(m[2] ?? '', m[1] === undefined ? 'line' : 'file'), false)
1038
+ record(
1039
+ i,
1040
+ parseAllowAnnotation(m[2] ?? '', m[1] === undefined ? 'line' : 'file'),
1041
+ false,
1042
+ m.index,
1043
+ )
951
1044
  }
952
1045
  }
953
1046
 
@@ -965,6 +1058,7 @@ function collectAllowAnnotations(
965
1058
  i,
966
1059
  parseAllowAnnotation(rest, m[1] === undefined ? 'line' : 'file'),
967
1060
  !isCommentOnlyLine,
1061
+ at,
968
1062
  )
969
1063
  }
970
1064
  }
@@ -2050,3 +2144,98 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2050
2144
  severity: severityOf(f.kind, cfg),
2051
2145
  }))
2052
2146
  }
2147
+
2148
+ // ── Allow-annotation enumeration (check-theme --audit-allows) ─────────────────────────────────────
2149
+
2150
+ /**
2151
+ * One `theme-allow` annotation as WRITTEN, classified by whose reach its ids fall in.
2152
+ *
2153
+ * `guardKinds` is what `checkSource` can judge by re-running itself with the annotation
2154
+ * neutralized. `pluginRules` is what only an `oxlint` run can judge. `unknownRules` names no rule
2155
+ * at all. All three empty with `bare: true` is the legacy blanket form.
2156
+ */
2157
+ export type AllowAnnotationSite = {
2158
+ /** 1-based line the annotation is written on. */
2159
+ readonly line: number
2160
+ /** `'file'` for the `theme-allow-file` declaration form, `'line'` otherwise. */
2161
+ readonly scope: 'line' | 'file'
2162
+ /** Every id the annotation names that resolves to a real rule, `basalt/` prefix stripped. */
2163
+ readonly rules: readonly string[]
2164
+ /** The subset of `rules` `checkSource` owns. */
2165
+ readonly guardKinds: readonly GuardKind[]
2166
+ /** The subset of `rules` the oxlint plugin owns — outside `checkSource`'s reach. */
2167
+ readonly pluginRules: readonly string[]
2168
+ /** Words that occupied the id slot but name no rule. Their presence makes the parse fail closed. */
2169
+ readonly unknownRules: readonly string[]
2170
+ /** A written reason follows the ids. */
2171
+ readonly hasReason: boolean
2172
+ /** The legacy blanket form — no ids at all, known or unknown. Only covers anything at line scope. */
2173
+ readonly bare: boolean
2174
+ /** The source line, verbatim. */
2175
+ readonly text: string
2176
+ }
2177
+
2178
+ /**
2179
+ * Every allow annotation in one file, with the ids it names classified by reach.
2180
+ *
2181
+ * Pure, and it shares `collectAllowAnnotations` with {@link checkSource} — so a caller enumerating
2182
+ * annotations sees exactly the set the scan honours, rather than a second regex that can drift from
2183
+ * it. `--audit-allows` carried such a mirror and it was already one alternation behind.
2184
+ *
2185
+ * A file-scoped declaration appears once, at the line it is written on.
2186
+ *
2187
+ * @example
2188
+ * for (const site of findAllowAnnotations(text, rel, cfg)) {
2189
+ * if (site.pluginRules.length > 0) probeWithOxlint(neutralizeAllowAnnotation(text, site.line, cfg))
2190
+ * }
2191
+ */
2192
+ export function findAllowAnnotations(
2193
+ text: string,
2194
+ relPath: string,
2195
+ cfg: GuardConfig,
2196
+ ): AllowAnnotationSite[] {
2197
+ const lines = text.split('\n')
2198
+ const syntax = guardSyntaxFor(relPath)
2199
+ const codeText = syntax === 'markup' ? stripMarkupComments(text) : stripComments(text, syntax)
2200
+ const { declared } = collectAllowAnnotations(
2201
+ lines,
2202
+ codeText.split('\n'),
2203
+ cfg.allowComment,
2204
+ syntax,
2205
+ )
2206
+ return declared.map(({ line, annotation }) => ({
2207
+ line,
2208
+ scope: annotation.scope,
2209
+ rules: [...annotation.rules],
2210
+ guardKinds: annotation.rules.filter((id): id is GuardKind => Object.hasOwn(GUARD_RULES, id)),
2211
+ pluginRules: annotation.rules.filter(
2212
+ (id) => !Object.hasOwn(GUARD_RULES, id) && PLUGIN_RULE_IDS.has(id),
2213
+ ),
2214
+ unknownRules: [...annotation.unknownRules],
2215
+ hasReason: annotation.hasReason,
2216
+ bare: annotation.rules.length === 0 && annotation.unknownRules.length === 0,
2217
+ text: lines[line - 1] ?? '',
2218
+ }))
2219
+ }
2220
+
2221
+ /** The token a neutralized annotation is rewritten to. Shares no substring with `theme-allow`. */
2222
+ export const NEUTRALIZED_ALLOW_TOKEN = 'basalt-audit-neutralized'
2223
+
2224
+ /**
2225
+ * `text` with the allow token on ONE (1-based) line rewritten to {@link NEUTRALIZED_ALLOW_TOKEN},
2226
+ * leaving every other annotation intact.
2227
+ *
2228
+ * The probe half of an audit: re-scan the result and the findings that appear are exactly what that
2229
+ * one annotation suppresses. Shipped rather than left to each caller so the guard probe and the
2230
+ * oxlint probe neutralize identically — a `--audit-allows` verdict that differs between the two
2231
+ * halves by a substitution detail is worse than no verdict.
2232
+ *
2233
+ * A line with no token comes back unchanged.
2234
+ */
2235
+ export function neutralizeAllowAnnotation(text: string, line: number, cfg: GuardConfig): string {
2236
+ const lines = text.split('\n')
2237
+ const target = lines[line - 1]
2238
+ if (target === undefined) return text
2239
+ lines[line - 1] = target.replaceAll(cfg.allowComment, NEUTRALIZED_ALLOW_TOKEN)
2240
+ return lines.join('\n')
2241
+ }