basalt-ui 1.23.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -17
- package/CHANGELOG.md +23 -0
- package/MIGRATING.md +222 -6
- package/README.md +159 -49
- package/agent/rules/basalt-app.md +8 -2
- package/agent/rules/basalt-charts.md +38 -2
- package/agent/rules/basalt-content.md +3 -3
- package/agent/rules/basalt-data.md +45 -13
- package/agent/rules/basalt-mantine.md +4 -4
- package/agent/rules/basalt-query.md +57 -0
- package/agent/rules/basalt-tokens.md +18 -5
- package/agent/skills/basalt-app/SKILL.md +32 -12
- package/agent/skills/basalt-charts/SKILL.md +4 -2
- package/agent/templates/CLAUDE-block.md.tpl +3 -2
- package/dist/charts/kinds/BandStrip.d.ts +20 -6
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +59 -6
- package/dist/charts/kinds/MirroredBars.d.ts +4 -2
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +13 -0
- package/dist/cli/index.d.ts +4 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +269 -27
- package/dist/dashboard/empty-state.d.ts +6 -2
- package/dist/dashboard/empty-state.d.ts.map +1 -1
- package/dist/dashboard/empty-state.js +1 -1
- package/dist/dashboard/index.d.ts +1 -0
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +8 -0
- package/dist/dashboard/query-state.d.ts +96 -0
- package/dist/dashboard/query-state.d.ts.map +1 -0
- package/dist/dashboard/query-state.js +123 -0
- package/dist/dashboard/query-state.test.d.ts +2 -0
- package/dist/dashboard/query-state.test.d.ts.map +1 -0
- package/dist/data/data-table.d.ts +61 -4
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +41 -4
- package/dist/data/data-table.test.d.ts +2 -0
- package/dist/data/data-table.test.d.ts.map +1 -0
- package/dist/data/table.d.ts +1 -1
- package/dist/data/table.d.ts.map +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +50 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/query/error-message.d.ts +41 -0
- package/dist/query/error-message.d.ts.map +1 -0
- package/dist/query/error-message.js +30 -0
- package/dist/query/error-message.test.d.ts +2 -0
- package/dist/query/error-message.test.d.ts.map +1 -0
- package/dist/query/index.d.ts +1 -19
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +2 -14
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/charts/kinds/BandStrip.test.tsx +87 -5
- package/src/charts/kinds/BandStrip.tsx +105 -12
- package/src/charts/kinds/MirroredBars.test.tsx +39 -0
- package/src/charts/kinds/MirroredBars.tsx +38 -2
- package/src/cli/check-theme.test.ts +34 -0
- package/src/cli/help.test.ts +74 -1
- package/src/cli/index.ts +496 -41
- package/src/cli/tokens-css.test.ts +129 -0
- package/src/cli/toolchain-wiring.test.ts +146 -0
- package/src/dashboard/empty-state.tsx +9 -3
- package/src/dashboard/index.ts +11 -0
- package/src/dashboard/query-state.test.tsx +152 -0
- package/src/dashboard/query-state.tsx +272 -0
- package/src/data/data-table.test.tsx +158 -0
- package/src/data/data-table.tsx +123 -5
- package/src/data/table.ts +6 -1
- package/src/guard/check-source.test.ts +214 -0
- package/src/guard/index.ts +169 -15
- package/src/index.ts +9 -0
- package/src/query/error-message.test.ts +67 -0
- package/src/query/error-message.ts +70 -0
- package/src/query/index.ts +2 -34
- package/src/surfaces.ts +1 -1
|
@@ -66,6 +66,58 @@ describe('raw-hex', () => {
|
|
|
66
66
|
const f = find(`// use #ff0000 from the palette`)
|
|
67
67
|
expect(kinds(f)).not.toContain('raw-hex')
|
|
68
68
|
})
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Round 9, rollhook: `{` / `}` — the escaped braces an Astro template writes to show a
|
|
72
|
+
* literal `${…}` in prose — were read as the hex colors `#123` / `#125`, and 4 findings blocked a
|
|
73
|
+
* release on a page with no color in it at all. The hole was in the KIND, not in the extension:
|
|
74
|
+
* the same string produced the same two findings in `.html`, `.tsx`, `.css` and `.vue`.
|
|
75
|
+
*/
|
|
76
|
+
describe('HTML numeric character references', () => {
|
|
77
|
+
const ENTITIES = `<p>image: ${IMAGE_TAG}</p>`
|
|
78
|
+
|
|
79
|
+
for (const rel of [
|
|
80
|
+
'index.html',
|
|
81
|
+
'src/App.tsx',
|
|
82
|
+
'src/a.css',
|
|
83
|
+
'src/Hero.vue',
|
|
84
|
+
'src/pages/index.astro',
|
|
85
|
+
]) {
|
|
86
|
+
it(`does NOT read {/} as a hex color in ${rel}`, () => {
|
|
87
|
+
expect(kinds(find(ENTITIES, rel))).not.toContain('raw-hex')
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The exemption is the ENTITY, never the file type — a template that hardcodes a color is
|
|
92
|
+
// exactly what making `.astro`/`.vue` scannable was for.
|
|
93
|
+
it('still flags a real hex in an .astro file', () => {
|
|
94
|
+
const f = find(`<div style="color: #ff0000">x</div>`, 'src/pages/index.astro')
|
|
95
|
+
expect(kinds(f)).toContain('raw-hex')
|
|
96
|
+
expect(f.find((x) => x.kind === 'raw-hex')?.token).toBe('#ff0000')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('still flags a real hex in a .vue file', () => {
|
|
100
|
+
const f = find(`<style>\n .hero { color: #ff0000; }\n</style>`, 'src/Hero.vue')
|
|
101
|
+
expect(kinds(f)).toContain('raw-hex')
|
|
102
|
+
expect(f.find((x) => x.kind === 'raw-hex')?.token).toBe('#ff0000')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('flags a hex after a bare & — an entity needs its terminating semicolon', () => {
|
|
106
|
+
expect(kinds(find(`<p>a&#fff b</p>`, 'index.html'))).toContain('raw-hex')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('flags a plain #123 with no entity around it', () => {
|
|
110
|
+
expect(kinds(find(`const c = '#123'`))).toContain('raw-hex')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// The neighbouring raw-text kinds were checked for the same blind spot and do NOT share it: a
|
|
114
|
+
// character reference contains no `(`, so `raw-color-fn`'s `rgba?|hsla?\(` anchor cannot occur
|
|
115
|
+
// inside one, and every other raw-text kind anchors on a property name, `var(`, or a JSX `=`.
|
|
116
|
+
it('leaves the sibling color kinds unchanged — nothing else fires inside an entity', () => {
|
|
117
|
+
const src = `<p>& — — … 😀 ©</p>`
|
|
118
|
+
expect(find(src, 'index.html')).toHaveLength(0)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
69
121
|
})
|
|
70
122
|
|
|
71
123
|
// ── 2. raw-color-fn ──────────────────────────────────────────────────────────
|
|
@@ -800,6 +852,79 @@ describe('chart-missing-aria-label', () => {
|
|
|
800
852
|
const f = find(text)
|
|
801
853
|
expect(kinds(f)).not.toContain('chart-missing-aria-label')
|
|
802
854
|
})
|
|
855
|
+
|
|
856
|
+
// ── tag provenance ─────────────────────────────────────────────────────────
|
|
857
|
+
//
|
|
858
|
+
// The rule keys on a tag NAME, so a consumer's OWN component sharing a shipped kind's name
|
|
859
|
+
// collected an unactionable `ariaLabel` demand (reported against 1.23.0: a hand-composed local
|
|
860
|
+
// `MirroredBars`, 235 lines, nothing to do with the kind). The gate is a one-directional
|
|
861
|
+
// narrowing — skip only what this file DEFINES and does not import from basalt-ui.
|
|
862
|
+
|
|
863
|
+
it('does NOT flag a tag this file DEFINES itself (a local component sharing a kind name)', () => {
|
|
864
|
+
const text = [
|
|
865
|
+
'function MirroredBars({ data }: Props) {',
|
|
866
|
+
' return <svg />',
|
|
867
|
+
'}',
|
|
868
|
+
'export function Panel() {',
|
|
869
|
+
' return <MirroredBars data={points} />',
|
|
870
|
+
'}',
|
|
871
|
+
].join('\n')
|
|
872
|
+
expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
it('skips a locally defined kind name however it is declared (const/memo, not just function)', () => {
|
|
876
|
+
const text = ['const BandStrip = memo(Inner)', '<BandStrip data={rows} />'].join('\n')
|
|
877
|
+
expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
it('STILL flags when the same file also imports that name from basalt-ui (a real shadow)', () => {
|
|
881
|
+
const text = [
|
|
882
|
+
"import { MirroredBars } from 'basalt-ui/charts'",
|
|
883
|
+
'function Panel() {',
|
|
884
|
+
' return <MirroredBars data={points} />',
|
|
885
|
+
'}',
|
|
886
|
+
].join('\n')
|
|
887
|
+
expect(kinds(find(text))).toContain('chart-missing-aria-label')
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
it('STILL flags a tag imported from a consumer BARREL that re-exports the kind', () => {
|
|
891
|
+
const text = ["import { MultiLine } from '../charts'", '<MultiLine data={points} />'].join('\n')
|
|
892
|
+
expect(kinds(find(text))).toContain('chart-missing-aria-label')
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
it('STILL flags a file with no imports and no local definition (coverage is not import-gated)', () => {
|
|
896
|
+
expect(kinds(find('<Donut data={slices} />'))).toContain('chart-missing-aria-label')
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
it('honours an aliased basalt import, since the alias is the tag the JSX writes', () => {
|
|
900
|
+
const text = [
|
|
901
|
+
"import { Bars as BasaltBars } from 'basalt-ui/charts'",
|
|
902
|
+
'const Bars = () => null',
|
|
903
|
+
'<Bars data={points} />',
|
|
904
|
+
].join('\n')
|
|
905
|
+
// `Bars` is defined locally here and only `BasaltBars` came from basalt — the local one is skipped.
|
|
906
|
+
expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
|
|
907
|
+
})
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
describe('unframed-chart — the same provenance gate', () => {
|
|
911
|
+
it('does NOT flag a consumer’s OWN ChartLegend taking an items array', () => {
|
|
912
|
+
const text = [
|
|
913
|
+
'function ChartLegend({ items }: Props) {',
|
|
914
|
+
' return <ul />',
|
|
915
|
+
'}',
|
|
916
|
+
'const view = <ChartLegend items={[{ label: "a" }]} />',
|
|
917
|
+
].join('\n')
|
|
918
|
+
expect(kinds(find(text))).not.toContain('unframed-chart')
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
it('still flags the shipped ChartLegend built from an inline array', () => {
|
|
922
|
+
const text = [
|
|
923
|
+
"import { ChartLegend } from 'basalt-ui/charts'",
|
|
924
|
+
'const view = <ChartLegend items={[{ label: "a" }]} />',
|
|
925
|
+
].join('\n')
|
|
926
|
+
expect(kinds(find(text))).toContain('unframed-chart')
|
|
927
|
+
})
|
|
803
928
|
})
|
|
804
929
|
|
|
805
930
|
// ── 16. card-with-border ─────────────────────────────────────────────────────
|
|
@@ -2507,6 +2632,95 @@ describe('markup files (index.html / *.webmanifest)', () => {
|
|
|
2507
2632
|
})
|
|
2508
2633
|
})
|
|
2509
2634
|
|
|
2635
|
+
// ── 29b. single-file components (.astro / .vue) ───────────────────────────────
|
|
2636
|
+
|
|
2637
|
+
/**
|
|
2638
|
+
* Round 9: `.astro` and `.vue` became scannable and fell through to the `ts` dialect, so `<!-- … -->`
|
|
2639
|
+
* was never stripped — a `theme-allow` written in an HTML comment waived nothing there, and a color
|
|
2640
|
+
* inside a commented-out block still reported. They resolve as `sfc` now: BOTH comment dialects,
|
|
2641
|
+
* and the full 25-kind set (not `MARKUP_KINDS` — an `.astro` template is JSX-shaped and a `.vue`
|
|
2642
|
+
* `<script setup>` is real TS).
|
|
2643
|
+
*/
|
|
2644
|
+
describe('single-file components (.astro / .vue)', () => {
|
|
2645
|
+
const ASTRO = 'src/pages/index.astro'
|
|
2646
|
+
const VUE = 'src/Hero.vue'
|
|
2647
|
+
|
|
2648
|
+
for (const rel of [ASTRO, VUE]) {
|
|
2649
|
+
it(`strips an HTML comment in ${rel}`, () => {
|
|
2650
|
+
expect(kinds(find(`<!-- <div style="color: #ff0000" /> -->`, rel))).not.toContain('raw-hex')
|
|
2651
|
+
})
|
|
2652
|
+
|
|
2653
|
+
it(`honours a theme-allow inside an HTML comment in ${rel}`, () => {
|
|
2654
|
+
const f = find(
|
|
2655
|
+
`<!-- theme-allow raw-hex — brand asset -->\n<div style="color: #ff0000" />`,
|
|
2656
|
+
rel,
|
|
2657
|
+
)
|
|
2658
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
2659
|
+
})
|
|
2660
|
+
|
|
2661
|
+
it(`honours a theme-allow-file inside an HTML comment in ${rel}`, () => {
|
|
2662
|
+
const f = find(
|
|
2663
|
+
`<!-- theme-allow-file raw-hex — brand asset -->\n<b style="color: #ff0000" />`,
|
|
2664
|
+
rel,
|
|
2665
|
+
)
|
|
2666
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
2667
|
+
})
|
|
2668
|
+
|
|
2669
|
+
it(`honours a multi-line HTML comment annotation in ${rel}`, () => {
|
|
2670
|
+
const f = find(
|
|
2671
|
+
`<!--\n theme-allow raw-hex — brand asset\n-->\n<b style="color: #ff0000" />`,
|
|
2672
|
+
rel,
|
|
2673
|
+
)
|
|
2674
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
2675
|
+
})
|
|
2676
|
+
|
|
2677
|
+
it(`still strips the JS dialect in ${rel} — an SFC carries both`, () => {
|
|
2678
|
+
expect(kinds(find(`// const c = '#ff0000'`, rel))).not.toContain('raw-hex')
|
|
2679
|
+
expect(kinds(find(`/* const c = '#ff0000' */`, rel))).not.toContain('raw-hex')
|
|
2680
|
+
})
|
|
2681
|
+
|
|
2682
|
+
it(`honours a theme-allow in the script fence of ${rel}`, () => {
|
|
2683
|
+
const f = find(`// theme-allow raw-hex — brand asset\nconst c = '#ff0000'`, rel)
|
|
2684
|
+
expect(kinds(f)).not.toContain('raw-hex')
|
|
2685
|
+
})
|
|
2686
|
+
|
|
2687
|
+
it(`keeps the non-markup kinds — an SFC is not MARKUP_KINDS-restricted in ${rel}`, () => {
|
|
2688
|
+
expect(kinds(find(`const s = { border: '1px solid #ccc' }`, rel))).toContain('raw-surface')
|
|
2689
|
+
expect(kinds(find(`<div style={{ padding: 18 }} />`, rel))).toContain('inline-spacing')
|
|
2690
|
+
})
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
it('does not let an HTML comment open a runaway JS block comment', () => {
|
|
2694
|
+
const f = find(`<!-- a /* unclosed -->\n<div style="color: #ff0000" />`, ASTRO)
|
|
2695
|
+
expect(kinds(f)).toContain('raw-hex')
|
|
2696
|
+
})
|
|
2697
|
+
|
|
2698
|
+
/**
|
|
2699
|
+
* Asserted LIMITS, so "unsupported" and "silently broken" don't read the same. Both are
|
|
2700
|
+
* false-negative-only — the direction a release-blocking false positive says to take.
|
|
2701
|
+
*/
|
|
2702
|
+
describe('known limits', () => {
|
|
2703
|
+
it('does not fire the kebab-CSS surface kinds inside a <style> fence', () => {
|
|
2704
|
+
const src = `<style>\n .card { border-radius: 8px; }\n</style>`
|
|
2705
|
+
expect(kinds(find(src, ASTRO))).not.toContain('css-raw-surface')
|
|
2706
|
+
// The same text in a real .css file still reports — the limit is the SFC dialect, not the rule.
|
|
2707
|
+
expect(kinds(find(src.replace(/<\/?style>/g, ''), 'src/a.css'))).toContain('css-raw-surface')
|
|
2708
|
+
})
|
|
2709
|
+
|
|
2710
|
+
it('over-strips a `<!--` that lives inside a script string', () => {
|
|
2711
|
+
expect(
|
|
2712
|
+
kinds(find(`const s = '<!--'\nconst c = '#ff0000'\nconst e = '-->'`, ASTRO)),
|
|
2713
|
+
).not.toContain('raw-hex')
|
|
2714
|
+
})
|
|
2715
|
+
|
|
2716
|
+
it('over-strips after an unquoted `https://` in template prose', () => {
|
|
2717
|
+
expect(
|
|
2718
|
+
kinds(find(`<p>see https://x.test <b style="color: #ff0000" /></p>`, ASTRO)),
|
|
2719
|
+
).not.toContain('raw-hex')
|
|
2720
|
+
})
|
|
2721
|
+
})
|
|
2722
|
+
})
|
|
2723
|
+
|
|
2510
2724
|
// ── 30. tokens-only profile ──────────────────────────────────────────────────
|
|
2511
2725
|
|
|
2512
2726
|
describe("profile: 'tokens-only'", () => {
|
package/src/guard/index.ts
CHANGED
|
@@ -10,7 +10,32 @@ export type { Finding, GuardConfig, GuardKind, GuardSeverity }
|
|
|
10
10
|
|
|
11
11
|
// ── Static regex consts ──────────────────────────────────────────────────────────────────────────
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* A raw hex color literal.
|
|
15
|
+
*
|
|
16
|
+
* The `(?!(?<=&#)\d+;)` guard drops an HTML NUMERIC CHARACTER REFERENCE. `{` is the escaped
|
|
17
|
+
* `{` and `}` the escaped `}` — the two an Astro/JSX template writes to show a literal brace
|
|
18
|
+
* in prose — and their `#123` / `#125` read as three-digit hex. It is not markup-specific: the same
|
|
19
|
+
* string produced the same two findings in `.html`, `.tsx`, `.css` and `.vue`, so this was a hole
|
|
20
|
+
* in the kind itself that `.astro` merely walked into first (rollhook, round 9).
|
|
21
|
+
*
|
|
22
|
+
* The lookahead is deliberately the FULL reference — `&#`, digits, `;` — not a bare `(?<!&)`. Only
|
|
23
|
+
* the decimal form can collide at all (`😀` never matches: `x` is not a hex digit), and a
|
|
24
|
+
* `#` that follows an `&` without terminating in `;` is not an entity, so `color: red&#fff` still
|
|
25
|
+
* flags. Nothing is exempted by file type; a real `#ff0000` in an `.astro` file fires exactly as it
|
|
26
|
+
* does in a `.tsx` one.
|
|
27
|
+
*
|
|
28
|
+
* The sibling raw-text kinds do NOT share this blind spot, and the reason is structural rather than
|
|
29
|
+
* lucky: a character reference contains no `(`, so `raw-color-fn`'s `rgba?|hsla?\(` anchor cannot
|
|
30
|
+
* occur inside one, and every other raw-text kind is anchored on a property name, `var(`, or a JSX
|
|
31
|
+
* `=`. Probed across `.html`/`.astro`/`.tsx` over 20 entity shapes — `raw-hex` was the only kind
|
|
32
|
+
* that fired.
|
|
33
|
+
*
|
|
34
|
+
* Known and NOT covered, because the fix would cost real findings: an all-hex URL fragment or SVG
|
|
35
|
+
* reference (`href="#cafe"`, `fill="url(#abcdef)"`) is indistinguishable from a color by text
|
|
36
|
+
* alone, and both still report. `theme-allow` is the escape there.
|
|
37
|
+
*/
|
|
38
|
+
const HEX = /#(?!(?<=&#)\d+;)[0-9a-fA-F]{3,8}\b/g
|
|
14
39
|
|
|
15
40
|
/**
|
|
16
41
|
* A raw `rgb()`/`rgba()`/`hsl()`/`hsla()` color function.
|
|
@@ -400,9 +425,55 @@ const RAW_CHART_LEGEND_ARRAY = /<ChartLegend\b[^>]*?\bitems\s*=\s*\{\s*\[/g
|
|
|
400
425
|
// alternative so their `>` never terminates the tag early. A bare `>` comparison inside a prop
|
|
401
426
|
// expression still ends the match (accepted limitation of the bounded scan).
|
|
402
427
|
const CHART_ENTRY_POINT_TAG =
|
|
403
|
-
/<(
|
|
428
|
+
/<(MultiLine|Bars|BandStrip|Donut|DualPanel|Heatmap|MirroredBars|ZonedLine|StackedArea|LineSparkline|BarSparkline)\b(?:<[^<>]*>)?(?:=>|[^>])*?>/g
|
|
404
429
|
const HAS_ARIA_LABEL_PROP = /\bariaLabel\s*=/
|
|
405
430
|
|
|
431
|
+
// ── Tag provenance, shared by the two chart tag rules ────────────────────────────────────────────
|
|
432
|
+
//
|
|
433
|
+
// Both `chart-missing-aria-label` and `unframed-chart` key on a JSX tag NAME, which is the whole of
|
|
434
|
+
// their signal — so a consumer's OWN component that merely shares a shipped kind's name collected
|
|
435
|
+
// the finding too. Reported by a consumer on 1.23.0: a hand-composed local `<MirroredBars>` was
|
|
436
|
+
// told to pass an `ariaLabel` prop it does not accept, by a rule that presents as a correctness
|
|
437
|
+
// finding rather than as a naming one. `shadow-basalt-export` / `ai-sdk-major` scope through
|
|
438
|
+
// `isBasaltScopedFile` (package-level); the analogous file-level signal here is where the name in
|
|
439
|
+
// THIS file came from.
|
|
440
|
+
//
|
|
441
|
+
// The gate is deliberately a one-directional NARROWING: a tag is skipped only when the file DEFINES
|
|
442
|
+
// a component of that name and does not also import it from basalt-ui. Everything else still fires
|
|
443
|
+
// — a tag imported from basalt-ui, one imported from a consumer barrel that re-exports it (`import
|
|
444
|
+
// { MultiLine } from '../charts'`, the shape a downstream component library uses), and one the scan
|
|
445
|
+
// cannot attribute at all. Requiring a POSITIVE basalt import instead would have silently switched
|
|
446
|
+
// both rules off for every barrel-wrapping consumer and for every file with no import statements —
|
|
447
|
+
// a far bigger hole than the false positive it closes.
|
|
448
|
+
const LOCAL_COMPONENT_DEF =
|
|
449
|
+
/(?:^|[\n;}])\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+([A-Z]\w*)/g
|
|
450
|
+
// `from 'basalt-ui'` and every subpath (`basalt-ui/charts`, `basalt-ui/tokens`, …).
|
|
451
|
+
const BASALT_NAMED_IMPORT =
|
|
452
|
+
/import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+['"]basalt-ui(?:\/[^'"]*)?['"]/g
|
|
453
|
+
|
|
454
|
+
/** Component-shaped names DEFINED in this file — the provable "not the shipped kind" signal. */
|
|
455
|
+
function localComponentNames(codeText: string): ReadonlySet<string> {
|
|
456
|
+
const out = new Set<string>()
|
|
457
|
+
for (const m of codeText.matchAll(LOCAL_COMPONENT_DEF)) out.add(m[1] as string)
|
|
458
|
+
return out
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Names bound in this file by a named import from `basalt-ui` — the local binding, so
|
|
462
|
+
* `{ Bars as BasaltBars }` records `BasaltBars`, which is the name the JSX tag will use. */
|
|
463
|
+
function basaltImportedNames(codeText: string): ReadonlySet<string> {
|
|
464
|
+
const out = new Set<string>()
|
|
465
|
+
for (const m of codeText.matchAll(BASALT_NAMED_IMPORT)) {
|
|
466
|
+
for (const raw of (m[1] as string).split(',')) {
|
|
467
|
+
const spec = raw.trim().replace(/^type\s+/, '')
|
|
468
|
+
if (spec.length === 0) continue
|
|
469
|
+
const parts = spec.split(/\s+as\s+/)
|
|
470
|
+
const bound = (parts[parts.length - 1] as string).trim()
|
|
471
|
+
if (bound.length > 0) out.add(bound)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return out
|
|
475
|
+
}
|
|
476
|
+
|
|
406
477
|
// Raw lowercase form-control element — line-scoped, same shape as RAW_VISX_AXIS. `\b` after the
|
|
407
478
|
// tag name rejects a same-prefixed custom component (`<inputRef`, `<selectAll`).
|
|
408
479
|
const RAW_FORM_CONTROL = /<(?:input|select|textarea)\b/g
|
|
@@ -485,6 +556,21 @@ export const DEFAULT_GUARD_CONFIG: GuardConfig = {
|
|
|
485
556
|
* into an unplanned refactor, which is exactly what the grace minor exists to prevent. The five
|
|
486
557
|
* entries below all landed together in the round-4 guard minor and all promote one minor later.
|
|
487
558
|
*
|
|
559
|
+
* **A `SCANNABLE_EXT` widening cannot be expressed here, and 1.23.2's was deliberately not.**
|
|
560
|
+
* Adding `.astro`/`.jsx`/`.vue` widens the FILE SET for all 25 kinds at once — there is no kind to
|
|
561
|
+
* key an entry on, and the nearest one (`raw-hex`, which is what actually fired) is keyed per KIND,
|
|
562
|
+
* not per extension: an entry for it would demote basalt's most load-bearing kind to `warn` in
|
|
563
|
+
* every `.tsx` and `.css` in every consumer for a minor, to buy runway on a file type only one
|
|
564
|
+
* consumer has. `css-raw-surface` is the shape that DOES fit — the new dialect got its own kind
|
|
565
|
+
* because kebab CSS is a distinct pattern set; more files running the same patterns is not.
|
|
566
|
+
*
|
|
567
|
+
* The measurement agreed with the structure. Once the two defects the widening exposed were fixed
|
|
568
|
+
* (`{` read as a hex; `<!-- … -->` never stripped in an SFC), rollhook's marketing site scans
|
|
569
|
+
* 6 files with 0 findings, and no other consumer has a single `.astro`, `.vue` or `.jsx` file — so
|
|
570
|
+
* grace would have covered zero incumbent violations. The doctrine protects code that "passed every
|
|
571
|
+
* previous release"; a violation in a newly-scanned file type is code the consumer is writing now,
|
|
572
|
+
* with `theme-allow`, `basalt.severity` and `exemptRules` all still available.
|
|
573
|
+
*
|
|
488
574
|
* @example
|
|
489
575
|
* const GRACE: Partial<Record<GuardKind, string>> = {
|
|
490
576
|
* 'raw-font-family': 'introduced 1.4.0 — promote to error in 1.5.0',
|
|
@@ -800,10 +886,13 @@ function commentBlockEnd(
|
|
|
800
886
|
syntax: GuardSyntax,
|
|
801
887
|
): number {
|
|
802
888
|
const line = lines[index] ?? ''
|
|
889
|
+
// An SFC carries both dialects, so the closer is resolved from the opener the token actually sits
|
|
890
|
+
// behind rather than from the extension: whichever of `<!--` / `/*` is NEAREST on its left wins.
|
|
891
|
+
const markup = syntax === 'markup' || (syntax === 'sfc' && opensMarkupComment(line, tokenAt))
|
|
803
892
|
// A line comment closes at its own end of line. CSS has no `//`, so the test is TS-only: there,
|
|
804
893
|
// a `//`-looking sequence is text and the conservative same-line answer is the right one anyway.
|
|
805
|
-
if (syntax
|
|
806
|
-
const closer =
|
|
894
|
+
if (!markup && syntax !== 'css' && /\/\/\s*$/.test(line.slice(0, tokenAt))) return index
|
|
895
|
+
const closer = markup ? '-->' : '*/'
|
|
807
896
|
if (line.slice(tokenAt).includes(closer)) return index
|
|
808
897
|
for (let ahead = index + 1; ahead < lines.length; ahead++) {
|
|
809
898
|
if ((lines[ahead] ?? '').includes(closer)) return ahead
|
|
@@ -811,6 +900,18 @@ function commentBlockEnd(
|
|
|
811
900
|
return index
|
|
812
901
|
}
|
|
813
902
|
|
|
903
|
+
/**
|
|
904
|
+
* In an `sfc` file, is the comment the token sits in an HTML one?
|
|
905
|
+
*
|
|
906
|
+
* Both dialects are legal in the same file, so the extension cannot answer it — the nearest opener
|
|
907
|
+
* on the token's left does. A tie (neither present) reads as the JS dialect, which is what an
|
|
908
|
+
* annotation on a bare frontmatter line is.
|
|
909
|
+
*/
|
|
910
|
+
function opensMarkupComment(line: string, tokenAt: number): boolean {
|
|
911
|
+
const before = line.slice(0, tokenAt)
|
|
912
|
+
return before.lastIndexOf('<!--') > before.lastIndexOf('/*')
|
|
913
|
+
}
|
|
914
|
+
|
|
814
915
|
/**
|
|
815
916
|
* A comment opener sitting immediately before the annotation token.
|
|
816
917
|
*
|
|
@@ -1086,8 +1187,8 @@ function annotationsCover(annotations: AllowAnnotation[] | undefined, kind: Guar
|
|
|
1086
1187
|
return (annotations ?? []).some((a) => annotationCovers(a, kind))
|
|
1087
1188
|
}
|
|
1088
1189
|
|
|
1089
|
-
/** The
|
|
1090
|
-
type GuardSyntax = 'ts' | 'css' | 'markup'
|
|
1190
|
+
/** The 4 comment dialects the stripper understands, resolved from the file's own extension. */
|
|
1191
|
+
type GuardSyntax = 'ts' | 'css' | 'markup' | 'sfc'
|
|
1091
1192
|
|
|
1092
1193
|
/**
|
|
1093
1194
|
* CSS (including `.module.css`) has no `//` line-comment syntax at all — an unquoted `//`, e.g.
|
|
@@ -1097,13 +1198,50 @@ type GuardSyntax = 'ts' | 'css' | 'markup'
|
|
|
1097
1198
|
* `markup` covers the two files whose colors nothing re-derives on a theme change and which the
|
|
1098
1199
|
* scan never reached — `index.html` and `*.webmanifest` (plus plain `.json`, which a webmanifest
|
|
1099
1200
|
* often is). Only the color/typography kinds apply there; see `MARKUP_KINDS`.
|
|
1201
|
+
*
|
|
1202
|
+
* `sfc` is the single-file-component dialect — `.astro` and `.vue`, which carry BOTH regions:
|
|
1203
|
+
* `<!-- … -->` in the template and `//` / `/* … *\/` in the frontmatter/`<script>` fence. They used
|
|
1204
|
+
* to fall through to `ts`, so an HTML comment was never stripped: a `theme-allow` written in one
|
|
1205
|
+
* waived nothing, and a color inside a commented-out block still reported (rollhook, round 9 —
|
|
1206
|
+
* `.astro` became scannable in the same minor that exposed it). They are NOT `markup`: an `.astro`
|
|
1207
|
+
* template is JSX-shaped and a `.vue` `<script setup>` is real TS, so restricting them to
|
|
1208
|
+
* {@link MARKUP_KINDS} would drop 22 of the 25 kinds on a file type that can violate all of them.
|
|
1209
|
+
*
|
|
1210
|
+
* `.jsx` needs no entry — it is TS syntax with no HTML-comment region.
|
|
1211
|
+
*
|
|
1212
|
+
* **Two asserted limits, both false-NEGATIVE-only** (pinned in `check-source.test.ts`):
|
|
1213
|
+
*
|
|
1214
|
+
* 1. The kebab-CSS kinds (`css-raw-surface`) do not fire inside a `<style>` fence — that branch
|
|
1215
|
+
* keys on `syntax === 'css'`, and an SFC is one file with three dialects, not three files. The
|
|
1216
|
+
* color kinds, which are what an unguarded template layer actually leaks, do fire there.
|
|
1217
|
+
* 2. Stripping is region-BLIND: `<!-- … -->` is blanked anywhere in the file, and `//` anywhere
|
|
1218
|
+
* outside a string. A `<!--` inside a script string, or an unquoted `https://` in template
|
|
1219
|
+
* prose, over-strips the rest of that construct. Both directions lose findings rather than
|
|
1220
|
+
* inventing them, which is the side of the trade a release-blocking false positive says to take.
|
|
1100
1221
|
*/
|
|
1101
1222
|
function guardSyntaxFor(relPath: string): GuardSyntax {
|
|
1102
1223
|
if (relPath.endsWith('.css')) return 'css'
|
|
1103
1224
|
if (/\.(?:html?|webmanifest|json)$/.test(relPath)) return 'markup'
|
|
1225
|
+
if (/\.(?:astro|vue)$/.test(relPath)) return 'sfc'
|
|
1104
1226
|
return 'ts'
|
|
1105
1227
|
}
|
|
1106
1228
|
|
|
1229
|
+
/**
|
|
1230
|
+
* The comment-stripped twin of `text`, same length and same newline positions, for any dialect.
|
|
1231
|
+
*
|
|
1232
|
+
* One place rather than the two call sites (`checkSource` and `findAllowAnnotations`) that used to
|
|
1233
|
+
* spell the `markup ? … : …` ternary out independently — adding `sfc` to one and not the other is
|
|
1234
|
+
* exactly how the scan and `--audit-allows` drift apart on what counts as a comment.
|
|
1235
|
+
*
|
|
1236
|
+
* The SFC order is markup-first: an HTML comment may legally contain `//` or an unterminated `/*`,
|
|
1237
|
+
* and blanking it before the TS pass keeps that text from opening a comment that runs to EOF.
|
|
1238
|
+
*/
|
|
1239
|
+
function stripGuardComments(text: string, syntax: GuardSyntax): string {
|
|
1240
|
+
if (syntax === 'markup') return stripMarkupComments(text)
|
|
1241
|
+
if (syntax === 'sfc') return stripComments(stripMarkupComments(text), 'ts')
|
|
1242
|
+
return stripComments(text, syntax)
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1107
1245
|
/** The only kinds meaningful in an HTML document or a JSON manifest — no JSX, no CSS-in-JS. */
|
|
1108
1246
|
const MARKUP_KINDS: ReadonlySet<GuardKind> = new Set(['raw-hex', 'raw-color-fn', 'raw-font-family'])
|
|
1109
1247
|
|
|
@@ -1794,7 +1932,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
1794
1932
|
// stripped text has already blanked out. Same length / same newline positions as `text`, so line
|
|
1795
1933
|
// numbers computed off either one agree.
|
|
1796
1934
|
const syntax = guardSyntaxFor(relPath)
|
|
1797
|
-
const codeText =
|
|
1935
|
+
const codeText = stripGuardComments(text, syntax)
|
|
1798
1936
|
const codeLines = codeText.split('\n')
|
|
1799
1937
|
const { waivers, fileScoped, declared } = collectAllowAnnotations(
|
|
1800
1938
|
lines,
|
|
@@ -1974,7 +2112,9 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
1974
2112
|
if (syntax === 'css' && isSubScaleCssSpacing(line, m.index)) continue
|
|
1975
2113
|
const hasUnit = /^[a-z%]/.test(line.slice(m.index + m[0].length))
|
|
1976
2114
|
if (
|
|
1977
|
-
|
|
2115
|
+
// An SFC's script fence IS TS, so it takes the same gate — without it a unitless
|
|
2116
|
+
// `padding: 8` in `.astro`/`.vue` frontmatter reports with no style-object behind it.
|
|
2117
|
+
(syntax === 'ts' || syntax === 'sfc') &&
|
|
1978
2118
|
!hasUnit &&
|
|
1979
2119
|
!isStyleObjectContext(codeText, lineStarts[i]! + m.index, styleIdentifiers)
|
|
1980
2120
|
)
|
|
@@ -2047,10 +2187,25 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
2047
2187
|
}
|
|
2048
2188
|
}
|
|
2049
2189
|
|
|
2190
|
+
// Both chart tag rules below gate on tag PROVENANCE (see the block above LOCAL_COMPONENT_DEF).
|
|
2191
|
+
// Computed once, and only if one of the two actually runs.
|
|
2192
|
+
const unframedChartRuns =
|
|
2193
|
+
GUARD_RULES['unframed-chart'].enabled!(cfg) && ruleApplies('unframed-chart', relPath)
|
|
2194
|
+
const ariaLabelRuns =
|
|
2195
|
+
GUARD_RULES['chart-missing-aria-label'].enabled!(cfg) &&
|
|
2196
|
+
ruleApplies('chart-missing-aria-label', relPath)
|
|
2197
|
+
const localNames =
|
|
2198
|
+
unframedChartRuns || ariaLabelRuns ? localComponentNames(codeText) : new Set<string>()
|
|
2199
|
+
const basaltNames =
|
|
2200
|
+
unframedChartRuns || ariaLabelRuns ? basaltImportedNames(codeText) : new Set<string>()
|
|
2201
|
+
const isShippedTag = (tag: string): boolean => basaltNames.has(tag) || !localNames.has(tag)
|
|
2202
|
+
|
|
2050
2203
|
// unframed-chart — full-text tag-scoped scan (not per-line, see RAW_CHART_LEGEND_ARRAY comment).
|
|
2051
2204
|
// Scans `codeText` (comment-stripped) so a legend example inside a comment can't match; reports
|
|
2052
|
-
// at the line of the `items={[` token itself.
|
|
2053
|
-
|
|
2205
|
+
// at the line of the `items={[` token itself. Skipped wholesale when `ChartLegend` is this file's
|
|
2206
|
+
// OWN component: a consumer's legend taking an `items` array is not the shipped one that derives
|
|
2207
|
+
// its entries, and the remedy ("pass deriveLegend(series)") is unreachable there.
|
|
2208
|
+
if (unframedChartRuns && isShippedTag('ChartLegend')) {
|
|
2054
2209
|
for (const m of codeText.matchAll(RAW_CHART_LEGEND_ARRAY)) {
|
|
2055
2210
|
const lineNo = codeText.slice(0, (m.index ?? 0) + m[0].length).split('\n').length
|
|
2056
2211
|
push('unframed-chart', lineNo, 'items={[')
|
|
@@ -2064,12 +2219,11 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
2064
2219
|
// which is what `Finding.text` would then quote back — the closing bracket rather than the tag
|
|
2065
2220
|
// that is missing the prop. The allow-comment is honored anywhere in the tag's span, so a
|
|
2066
2221
|
// `theme-allow` that used to sit on the closing line keeps working.
|
|
2067
|
-
if (
|
|
2068
|
-
GUARD_RULES['chart-missing-aria-label'].enabled!(cfg) &&
|
|
2069
|
-
ruleApplies('chart-missing-aria-label', relPath)
|
|
2070
|
-
) {
|
|
2222
|
+
if (ariaLabelRuns) {
|
|
2071
2223
|
for (const m of codeText.matchAll(CHART_ENTRY_POINT_TAG)) {
|
|
2072
2224
|
const tagText = m[0]
|
|
2225
|
+
// A tag this file defines itself is not the shipped kind — it does not take `ariaLabel`.
|
|
2226
|
+
if (!isShippedTag(m[1] as string)) continue
|
|
2073
2227
|
if (HAS_ARIA_LABEL_PROP.test(tagText)) continue
|
|
2074
2228
|
const startLine = codeText.slice(0, m.index ?? 0).split('\n').length
|
|
2075
2229
|
const endLine = startLine + (tagText.split('\n').length - 1)
|
|
@@ -2196,7 +2350,7 @@ export function findAllowAnnotations(
|
|
|
2196
2350
|
): AllowAnnotationSite[] {
|
|
2197
2351
|
const lines = text.split('\n')
|
|
2198
2352
|
const syntax = guardSyntaxFor(relPath)
|
|
2199
|
-
const codeText =
|
|
2353
|
+
const codeText = stripGuardComments(text, syntax)
|
|
2200
2354
|
const { declared } = collectAllowAnnotations(
|
|
2201
2355
|
lines,
|
|
2202
2356
|
codeText.split('\n'),
|
package/src/index.ts
CHANGED
|
@@ -95,6 +95,15 @@ export {
|
|
|
95
95
|
type SettingsRowProps,
|
|
96
96
|
DangerZone,
|
|
97
97
|
type DangerZoneProps,
|
|
98
|
+
QueryState,
|
|
99
|
+
type QueryStateProps,
|
|
100
|
+
type QueryStateLike,
|
|
101
|
+
type QueryStateVariant,
|
|
102
|
+
type QueryEmptyCopy,
|
|
103
|
+
LoadingState,
|
|
104
|
+
type LoadingStateProps,
|
|
105
|
+
ErrorState,
|
|
106
|
+
type ErrorStateProps,
|
|
98
107
|
} from './dashboard'
|
|
99
108
|
|
|
100
109
|
// ── Connectivity (auto-mounted by BasaltProvider) ────────────────────────────────────────────────
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `toErrorMessage`'s job is to never hand a human a string that says nothing. The decode ladder
|
|
3
|
+
* was already right; what was missing is the floor under it — an opaque envelope decoded to the
|
|
4
|
+
* literal `"{}"`, and `toErrorMessage(undefined)` returned `JSON.stringify(undefined)`, which is
|
|
5
|
+
* not a string at all despite the `string` return type. Both are the shape a page renders straight
|
|
6
|
+
* into an Alert, so both are asserted here.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test'
|
|
9
|
+
import { errorStatus, toErrorMessage } from './error-message'
|
|
10
|
+
|
|
11
|
+
describe('toErrorMessage — decode ladder', () => {
|
|
12
|
+
test('Error instance → message', () => {
|
|
13
|
+
expect(toErrorMessage(new Error('slug already in use'))).toBe('slug already in use')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('{ message } → message', () => {
|
|
17
|
+
expect(toErrorMessage({ message: 'recursive is rejected' })).toBe('recursive is rejected')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('Eden { status, value } envelope → one level down', () => {
|
|
21
|
+
expect(toErrorMessage({ status: 409, value: { message: 'slug taken' } })).toBe('slug taken')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('plain string → itself', () => {
|
|
25
|
+
expect(toErrorMessage('boom')).toBe('boom')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('toErrorMessage — the unusable floor', () => {
|
|
30
|
+
test('an opaque object falls back instead of rendering "{}"', () => {
|
|
31
|
+
expect(toErrorMessage({}, 'Could not create share')).toBe('Could not create share')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('undefined returns a real string, not the undefined value', () => {
|
|
35
|
+
const message = toErrorMessage(undefined)
|
|
36
|
+
expect(typeof message).toBe('string')
|
|
37
|
+
expect(message).toBe('The request failed.')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('an opaque envelope WITH a status folds the status into the fallback', () => {
|
|
41
|
+
expect(toErrorMessage({ status: 502, value: {} }, 'Could not load images')).toBe(
|
|
42
|
+
'Could not load images (HTTP 502)',
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('a readable body wins over the status — the server message is never replaced', () => {
|
|
47
|
+
expect(toErrorMessage({ status: 403, value: { message: 'forbidden' } }, 'Nope')).toBe(
|
|
48
|
+
'forbidden',
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('whitespace-only decodes as unusable', () => {
|
|
53
|
+
expect(toErrorMessage({ message: ' ' }, 'Fallback')).toBe('Fallback')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('errorStatus', () => {
|
|
58
|
+
test('reads a numeric status off an envelope', () => {
|
|
59
|
+
expect(errorStatus({ status: 404, value: null })).toBe(404)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('is undefined for anything else', () => {
|
|
63
|
+
expect(errorStatus(new Error('x'))).toBeUndefined()
|
|
64
|
+
expect(errorStatus(null)).toBeUndefined()
|
|
65
|
+
expect(errorStatus({ status: '404' })).toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
})
|