basalt-ui 1.25.0 → 1.27.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 +8 -6
- package/CHANGELOG.md +26 -0
- package/MIGRATING.md +815 -23
- package/README.md +45 -32
- package/agent/rules/basalt-batteries.md +205 -0
- package/agent/rules/basalt-charts.md +125 -555
- package/agent/rules/basalt-controls.md +160 -0
- package/agent/rules/basalt-mantine.md +146 -439
- package/agent/rules/basalt-state.md +118 -96
- package/agent/rules/basalt-tokens.md +126 -288
- package/agent/skills/basalt-app/SKILL.md +70 -195
- package/agent/skills/basalt-charts/SKILL.md +54 -234
- package/agent/skills/basalt-design/SKILL.md +56 -248
- package/agent/templates/CLAUDE-block.md.tpl +31 -40
- package/agent/templates/DESIGN.md.tpl +22 -51
- package/configs/oxlint-plugin.js +1465 -36
- package/configs/oxlint-plugin.test.ts +1190 -11
- package/configs/oxlint.json +11 -3
- package/dist/charts/cursor/resolve.d.ts +18 -0
- package/dist/charts/cursor/resolve.d.ts.map +1 -1
- package/dist/charts/cursor/resolve.js +15 -6
- package/dist/charts/cursor/scope.d.ts +6 -3
- package/dist/charts/cursor/scope.d.ts.map +1 -1
- package/dist/charts/cursor/store.d.ts +5 -1
- package/dist/charts/cursor/store.d.ts.map +1 -1
- package/dist/charts/cursor/store.js +4 -4
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +9 -8
- package/dist/charts/index.d.ts +2 -2
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.d.ts +34 -9
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +29 -114
- package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
- package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
- package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
- package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
- package/dist/charts/sparklines/BarSparkline.js +18 -4
- package/dist/cli/check-coverage.test.d.ts +2 -0
- package/dist/cli/check-coverage.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts +67 -4
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +225 -7
- package/dist/connectivity/connectivity-indicator.d.ts +4 -1
- package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
- package/dist/connectivity/connectivity-indicator.js +1 -1
- package/dist/content/index.d.ts +5 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +4 -2
- package/dist/content/prose.module.css +7 -28
- package/dist/controls/actions.d.ts +169 -0
- package/dist/controls/actions.d.ts.map +1 -0
- package/dist/controls/actions.js +287 -0
- package/dist/controls/actions.module.css +62 -0
- package/dist/controls/actions.module.css.d.ts +13 -0
- package/dist/controls/actions.test.d.ts +2 -0
- package/dist/controls/actions.test.d.ts.map +1 -0
- package/dist/controls/actions.type-guard.test.d.ts +2 -0
- package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
- package/dist/controls/compare-filter.d.ts +41 -0
- package/dist/controls/compare-filter.d.ts.map +1 -0
- package/dist/controls/compare-filter.js +23 -0
- package/dist/controls/control-group.d.ts +19 -0
- package/dist/controls/control-group.d.ts.map +1 -0
- package/dist/controls/control-group.js +20 -0
- package/dist/controls/control-group.test.d.ts +2 -0
- package/dist/controls/control-group.test.d.ts.map +1 -0
- package/dist/controls/controls.module.css +542 -0
- package/dist/controls/controls.module.css.d.ts +35 -0
- package/dist/controls/controls.router.test.d.ts +2 -0
- package/dist/controls/controls.router.test.d.ts.map +1 -0
- package/dist/controls/enum-filter.d.ts +39 -0
- package/dist/controls/enum-filter.d.ts.map +1 -0
- package/dist/controls/enum-filter.js +93 -0
- package/dist/controls/filter-context.d.ts +60 -0
- package/dist/controls/filter-context.d.ts.map +1 -0
- package/dist/controls/filter-context.js +69 -0
- package/dist/controls/filter-pill.d.ts +28 -0
- package/dist/controls/filter-pill.d.ts.map +1 -0
- package/dist/controls/filter-pill.js +70 -0
- package/dist/controls/filter-set.d.ts +13 -0
- package/dist/controls/filter-set.d.ts.map +1 -0
- package/dist/controls/filter-set.js +125 -0
- package/dist/controls/filter-set.test.d.ts +2 -0
- package/dist/controls/filter-set.test.d.ts.map +1 -0
- package/dist/controls/filter-sheet.d.ts +117 -0
- package/dist/controls/filter-sheet.d.ts.map +1 -0
- package/dist/controls/filter-sheet.js +146 -0
- package/dist/controls/glyphs.d.ts +27 -0
- package/dist/controls/glyphs.d.ts.map +1 -0
- package/dist/controls/glyphs.js +49 -0
- package/dist/controls/index.d.ts +20 -0
- package/dist/controls/index.d.ts.map +1 -0
- package/dist/controls/index.js +46 -0
- package/dist/controls/multi-select-filter.d.ts +23 -0
- package/dist/controls/multi-select-filter.d.ts.map +1 -0
- package/dist/controls/multi-select-filter.js +89 -0
- package/dist/controls/number-filter.d.ts +26 -0
- package/dist/controls/number-filter.d.ts.map +1 -0
- package/dist/controls/number-filter.js +143 -0
- package/dist/controls/peer-boundary.test.d.ts +2 -0
- package/dist/controls/peer-boundary.test.d.ts.map +1 -0
- package/dist/controls/props-contract.test.d.ts +2 -0
- package/dist/controls/props-contract.test.d.ts.map +1 -0
- package/dist/controls/range-filter.d.ts +43 -0
- package/dist/controls/range-filter.d.ts.map +1 -0
- package/dist/controls/range-filter.js +135 -0
- package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
- package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
- package/dist/controls/search-filter.d.ts +10 -0
- package/dist/controls/search-filter.d.ts.map +1 -0
- package/dist/controls/search-filter.js +81 -0
- package/dist/controls/select-filter.d.ts +74 -0
- package/dist/controls/select-filter.d.ts.map +1 -0
- package/dist/controls/select-filter.js +10 -0
- package/dist/controls/sync-button.d.ts +38 -0
- package/dist/controls/sync-button.d.ts.map +1 -0
- package/dist/controls/sync-button.js +119 -0
- package/dist/controls/sync-button.module.css +63 -0
- package/dist/controls/sync-button.module.css.d.ts +11 -0
- package/dist/controls/sync-button.test.d.ts +2 -0
- package/dist/controls/sync-button.test.d.ts.map +1 -0
- package/dist/controls/toggle-filter.d.ts +9 -0
- package/dist/controls/toggle-filter.d.ts.map +1 -0
- package/dist/controls/toggle-filter.js +42 -0
- package/dist/controls/view-tabs.d.ts +23 -0
- package/dist/controls/view-tabs.d.ts.map +1 -0
- package/dist/controls/view-tabs.js +59 -0
- package/dist/controls-dates/date-range-picker.d.ts +4 -0
- package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
- package/dist/controls-dates/date-range-picker.js +18 -0
- package/dist/controls-dates/index.d.ts +9 -0
- package/dist/controls-dates/index.d.ts.map +1 -0
- package/dist/controls-dates/index.js +4 -0
- package/dist/dashboard/delta-badge.d.ts +6 -12
- package/dist/dashboard/delta-badge.d.ts.map +1 -1
- package/dist/dashboard/delta-badge.js +1 -50
- package/dist/dashboard/index.d.ts +1 -1
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +3 -1
- package/dist/dashboard/settings-section.d.ts +6 -4
- package/dist/dashboard/settings-section.d.ts.map +1 -1
- package/dist/dashboard/settings-section.js +23 -16
- package/dist/dashboard/settings-section.module.css +10 -0
- package/dist/dashboard/settings-section.module.css.d.ts +1 -0
- package/dist/dashboard/settings-section.test.d.ts +2 -0
- package/dist/dashboard/settings-section.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.d.ts +95 -9
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +108 -49
- package/dist/dashboard/stat-card.module.css +153 -0
- package/dist/dashboard/stat-card.module.css.d.ts +15 -0
- package/dist/data/data-table.d.ts +37 -15
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +96 -58
- package/dist/guard/grace.test.d.ts +20 -0
- package/dist/guard/grace.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +66 -3
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +78 -7
- package/dist/guard/types.d.ts +3 -3
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -10
- package/dist/nav/types.d.ts +78 -2
- package/dist/nav/types.d.ts.map +1 -1
- package/dist/notifications/bell.d.ts +4 -1
- package/dist/notifications/bell.d.ts.map +1 -1
- package/dist/notifications/index.d.ts +1 -1
- package/dist/router-tanstack/field.d.ts +14 -0
- package/dist/router-tanstack/field.d.ts.map +1 -0
- package/dist/router-tanstack/field.js +4 -0
- package/dist/router-tanstack/index.d.ts +6 -2
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/index.js +4 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +19 -27
- package/dist/router-tanstack/search-param-store.d.ts +3 -87
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +16 -45
- package/dist/router-tanstack/search-store.d.ts +84 -0
- package/dist/router-tanstack/search-store.d.ts.map +1 -0
- package/dist/router-tanstack/search-store.js +271 -0
- package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
- package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
- package/dist/router-tanstack/search-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
- package/dist/router-tanstack/use-nav.d.ts +0 -2
- package/dist/router-tanstack/use-nav.d.ts.map +1 -1
- package/dist/router-tanstack/use-nav.js +34 -12
- package/dist/router-tanstack/use-nav.test.d.ts +2 -0
- package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
- package/dist/section/index.d.ts +2 -0
- package/dist/section/index.d.ts.map +1 -0
- package/dist/section/index.js +4 -0
- package/dist/section/section.d.ts +24 -0
- package/dist/section/section.d.ts.map +1 -0
- package/dist/section/section.js +126 -0
- package/dist/section/section.module.css +104 -0
- package/dist/section/section.module.css.d.ts +11 -0
- package/dist/section/section.test.d.ts +2 -0
- package/dist/section/section.test.d.ts.map +1 -0
- package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
- package/dist/shell/app-breadcrumbs.js +18 -15
- package/dist/shell/app-header.module.css +77 -37
- package/dist/shell/app-header.module.css.d.ts +2 -1
- package/dist/shell/app-mobile-nav.d.ts +9 -2
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +98 -11
- package/dist/shell/app-mobile-nav.module.css +8 -0
- package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/dist/shell/app-sidebar.d.ts +55 -28
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +116 -67
- package/dist/shell/app-sidebar.module.css +303 -20
- package/dist/shell/app-sidebar.module.css.d.ts +19 -1
- package/dist/shell/index.d.ts +55 -21
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +40 -18
- package/dist/shell/mobile-nav-model.d.ts +13 -2
- package/dist/shell/mobile-nav-model.d.ts.map +1 -1
- package/dist/shell/mobile-nav-model.js +15 -1
- package/dist/shell/nav-count-badge.d.ts +1 -1
- package/dist/shell/page-bar.d.ts +51 -0
- package/dist/shell/page-bar.d.ts.map +1 -0
- package/dist/shell/page-bar.js +154 -0
- package/dist/shell/page-bar.module.css +178 -0
- package/dist/shell/page-bar.module.css.d.ts +17 -0
- package/dist/shell/page-bar.test.d.ts +2 -0
- package/dist/shell/page-bar.test.d.ts.map +1 -0
- package/dist/shell/sidebar-block-model.d.ts +48 -0
- package/dist/shell/sidebar-block-model.d.ts.map +1 -0
- package/dist/shell/sidebar-block-model.js +39 -0
- package/dist/shell/sidebar-blocks.d.ts +32 -0
- package/dist/shell/sidebar-blocks.d.ts.map +1 -0
- package/dist/shell/sidebar-blocks.js +169 -0
- package/dist/shell/sidebar-search.d.ts +13 -1
- package/dist/shell/sidebar-search.d.ts.map +1 -1
- package/dist/shell/sidebar-search.js +34 -1
- package/dist/shell/sidebar-search.module.css +26 -0
- package/dist/shell/sidebar-search.module.css.d.ts +2 -0
- package/dist/state/fields.d.ts +565 -0
- package/dist/state/fields.d.ts.map +1 -0
- package/dist/state/fields.js +405 -0
- package/dist/state/fields.test.d.ts +2 -0
- package/dist/state/fields.test.d.ts.map +1 -0
- package/dist/state/persisted.d.ts +60 -0
- package/dist/state/persisted.d.ts.map +1 -0
- package/dist/state/persisted.js +123 -0
- package/dist/state/persisted.test.d.ts +2 -0
- package/dist/state/persisted.test.d.ts.map +1 -0
- package/dist/state.d.ts +11 -56
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +14 -120
- package/dist/styles.css +13 -0
- package/dist/surfaces.d.ts +106 -24
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +156 -19
- package/dist/theme/controls.module.css +16 -1
- package/dist/theme/ctl-theme.d.ts +32 -0
- package/dist/theme/ctl-theme.d.ts.map +1 -0
- package/dist/theme/ctl-theme.js +54 -0
- package/dist/theme/ctl-theme.test.d.ts +2 -0
- package/dist/theme/ctl-theme.test.d.ts.map +1 -0
- package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
- package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
- package/dist/theme/divider-law.test.d.ts +2 -0
- package/dist/theme/divider-law.test.d.ts.map +1 -0
- package/dist/theme/icon-slot.d.ts +44 -0
- package/dist/theme/icon-slot.d.ts.map +1 -0
- package/dist/theme/icon-slot.js +21 -0
- package/dist/theme/icon-slot.module.css +47 -0
- package/dist/theme/icon-slot.module.css.d.ts +8 -0
- package/dist/theme/icon-slot.test.d.ts +2 -0
- package/dist/theme/icon-slot.test.d.ts.map +1 -0
- package/dist/theme/index.d.ts +1 -0
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +82 -5
- package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
- package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
- package/dist/theme/segmented-control.module.css +20 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
- package/dist/tokens/index.d.ts +12 -0
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +38 -8
- package/dist/tokens/palette.d.ts +97 -61
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +76 -38
- package/dist/tokens.css +10 -2
- package/dist/widget-header/delta-badge.d.ts +13 -0
- package/dist/widget-header/delta-badge.d.ts.map +1 -0
- package/dist/widget-header/delta-badge.js +29 -0
- package/dist/widget-header/delta-badge.test.d.ts +2 -0
- package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
- package/dist/widget-header/index.d.ts +3 -0
- package/dist/widget-header/index.d.ts.map +1 -0
- package/dist/widget-header/index.js +6 -0
- package/dist/widget-header/no-mantine.test.d.ts +2 -0
- package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
- package/dist/widget-header/widget-header.d.ts +67 -0
- package/dist/widget-header/widget-header.d.ts.map +1 -0
- package/dist/widget-header/widget-header.js +89 -0
- package/dist/widget-header/widget-header.module.css +350 -0
- package/dist/widget-header/widget-header.module.css.d.ts +25 -0
- package/dist/widget-header/widget-header.test.d.ts +2 -0
- package/dist/widget-header/widget-header.test.d.ts.map +1 -0
- package/llms.txt +30 -17
- package/package.json +14 -1
- package/src/charts/cursor/resolve.test.ts +29 -1
- package/src/charts/cursor/resolve.ts +38 -6
- package/src/charts/cursor/scope.tsx +6 -3
- package/src/charts/cursor/store.test.ts +19 -1
- package/src/charts/cursor/store.ts +10 -5
- package/src/charts/hooks/useChartCursor.ts +16 -8
- package/src/charts/index.ts +2 -2
- package/src/charts/primitives/CartesianChart.test.tsx +124 -0
- package/src/charts/primitives/ChartCard.test.tsx +66 -0
- package/src/charts/primitives/ChartCard.tsx +86 -146
- package/src/charts/sparklines/BarSparkline.tsx +43 -5
- package/src/cli/check-coverage.test.ts +297 -0
- package/src/cli/check-theme.test.ts +38 -14
- package/src/cli/index.ts +392 -16
- package/src/cli/init-sync.test.ts +97 -0
- package/src/connectivity/connectivity-indicator.tsx +16 -9
- package/src/content/index.ts +16 -3
- package/src/content/prose.module.css +7 -28
- package/src/content/prose.module.css.test.ts +8 -24
- package/src/controls/actions.module.css +62 -0
- package/src/controls/actions.module.css.d.ts +13 -0
- package/src/controls/actions.test.tsx +252 -0
- package/src/controls/actions.tsx +655 -0
- package/src/controls/actions.type-guard.test.ts +63 -0
- package/src/controls/compare-filter.tsx +64 -0
- package/src/controls/control-group.test.tsx +229 -0
- package/src/controls/control-group.tsx +63 -0
- package/src/controls/controls.module.css +542 -0
- package/src/controls/controls.module.css.d.ts +35 -0
- package/src/controls/controls.router.test.tsx +1053 -0
- package/src/controls/enum-filter.tsx +170 -0
- package/src/controls/filter-context.tsx +158 -0
- package/src/controls/filter-pill.tsx +120 -0
- package/src/controls/filter-set.test.tsx +472 -0
- package/src/controls/filter-set.tsx +225 -0
- package/src/controls/filter-sheet.tsx +285 -0
- package/src/controls/glyphs.tsx +81 -0
- package/src/controls/index.ts +49 -0
- package/src/controls/multi-select-filter.tsx +140 -0
- package/src/controls/number-filter.tsx +291 -0
- package/src/controls/peer-boundary.test.ts +85 -0
- package/src/controls/props-contract.test.tsx +137 -0
- package/src/controls/range-filter.tsx +237 -0
- package/src/controls/range-filter.type-guard.test.tsx +122 -0
- package/src/controls/search-filter.tsx +123 -0
- package/src/controls/select-filter.tsx +89 -0
- package/src/controls/sync-button.module.css +63 -0
- package/src/controls/sync-button.module.css.d.ts +11 -0
- package/src/controls/sync-button.test.tsx +183 -0
- package/src/controls/sync-button.tsx +197 -0
- package/src/controls/toggle-filter.tsx +60 -0
- package/src/controls/view-tabs.tsx +110 -0
- package/src/controls-dates/date-range-picker.tsx +42 -0
- package/src/controls-dates/index.ts +8 -0
- package/src/dashboard/delta-badge.tsx +4 -74
- package/src/dashboard/index.ts +6 -1
- package/src/dashboard/settings-section.module.css +10 -0
- package/src/dashboard/settings-section.module.css.d.ts +1 -0
- package/src/dashboard/settings-section.test.tsx +56 -0
- package/src/dashboard/settings-section.tsx +33 -30
- package/src/dashboard/stat-card.module.css +153 -0
- package/src/dashboard/stat-card.module.css.d.ts +15 -0
- package/src/dashboard/stat-card.test.tsx +298 -1
- package/src/dashboard/stat-card.tsx +289 -67
- package/src/data/data-table.test.tsx +96 -11
- package/src/data/data-table.tsx +228 -84
- package/src/guard/check-source.test.ts +159 -13
- package/src/guard/grace.test.ts +158 -0
- package/src/guard/index.ts +185 -29
- package/src/guard/types.ts +4 -2
- package/src/index.ts +51 -5
- package/src/nav/types.ts +84 -2
- package/src/notifications/bell.tsx +6 -3
- package/src/notifications/index.ts +1 -1
- package/src/router-tanstack/field.ts +35 -0
- package/src/router-tanstack/index.ts +28 -2
- package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
- package/src/router-tanstack/multi-search-param-store.ts +46 -77
- package/src/router-tanstack/search-param-store.ts +42 -159
- package/src/router-tanstack/search-store.router.test.tsx +882 -0
- package/src/router-tanstack/search-store.test.ts +578 -0
- package/src/router-tanstack/search-store.ts +546 -0
- package/src/router-tanstack/use-nav.test.tsx +187 -0
- package/src/router-tanstack/use-nav.ts +72 -14
- package/src/section/index.ts +1 -0
- package/src/section/section.module.css +104 -0
- package/src/section/section.module.css.d.ts +11 -0
- package/src/section/section.test.tsx +204 -0
- package/src/section/section.tsx +220 -0
- package/src/shell/app-breadcrumbs.tsx +44 -31
- package/src/shell/app-header.module.css +77 -37
- package/src/shell/app-header.module.css.d.ts +2 -1
- package/src/shell/app-mobile-nav.module.css +8 -0
- package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.tsx +147 -8
- package/src/shell/app-sidebar-account.tsx +2 -2
- package/src/shell/app-sidebar.module.css +303 -20
- package/src/shell/app-sidebar.module.css.d.ts +19 -1
- package/src/shell/app-sidebar.test.tsx +456 -41
- package/src/shell/app-sidebar.tsx +276 -140
- package/src/shell/index.test.tsx +291 -5
- package/src/shell/index.tsx +137 -38
- package/src/shell/mobile-nav-model.test.ts +228 -7
- package/src/shell/mobile-nav-model.ts +53 -3
- package/src/shell/nav-count-badge.tsx +1 -1
- package/src/shell/page-bar.module.css +178 -0
- package/src/shell/page-bar.module.css.d.ts +17 -0
- package/src/shell/page-bar.test.tsx +316 -0
- package/src/shell/page-bar.tsx +315 -0
- package/src/shell/sidebar-block-model.ts +83 -0
- package/src/shell/sidebar-blocks.tsx +301 -0
- package/src/shell/sidebar-search.module.css +26 -0
- package/src/shell/sidebar-search.module.css.d.ts +2 -0
- package/src/shell/sidebar-search.tsx +71 -2
- package/src/state/fields.test.tsx +512 -0
- package/src/state/fields.ts +1150 -0
- package/src/state/persisted.test.ts +91 -0
- package/src/state/persisted.ts +261 -0
- package/src/state.ts +52 -240
- package/src/styles.css +13 -0
- package/src/surfaces.ts +204 -36
- package/src/theme/controls.module.css +16 -1
- package/src/theme/ctl-theme.test.tsx +275 -0
- package/src/theme/ctl-theme.tsx +138 -0
- package/src/theme/ctl-tier-coverage.test.ts +170 -0
- package/src/theme/density-relations.test.ts +106 -51
- package/src/theme/divider-law.test.ts +259 -0
- package/src/theme/icon-slot.module.css +47 -0
- package/src/theme/icon-slot.module.css.d.ts +8 -0
- package/src/theme/icon-slot.test.tsx +275 -0
- package/src/theme/icon-slot.tsx +59 -0
- package/src/theme/index.ts +165 -3
- package/src/theme/layout-rhythm-css.test.ts +72 -0
- package/src/theme/radius.test.ts +11 -1
- package/src/theme/segmented-control.module.css +20 -0
- package/src/theme/shadow-surfaces.test.ts +20 -7
- package/src/theme/spacing.test.ts +75 -27
- package/src/theme/use-basalt-spacing.ts +2 -2
- package/src/tokens/density.test.ts +7 -25
- package/src/tokens/index.ts +48 -8
- package/src/tokens/palette.ts +107 -73
- package/src/widget-header/delta-badge.test.tsx +60 -0
- package/src/widget-header/delta-badge.tsx +57 -0
- package/src/widget-header/index.ts +2 -0
- package/src/widget-header/no-mantine.test.ts +33 -0
- package/src/widget-header/widget-header.module.css +350 -0
- package/src/widget-header/widget-header.module.css.d.ts +25 -0
- package/src/widget-header/widget-header.test.tsx +187 -0
- package/src/widget-header/widget-header.tsx +213 -0
- package/agent/rules/basalt-agent.md +0 -452
- package/agent/rules/basalt-app.md +0 -246
- package/agent/rules/basalt-commands.md +0 -320
- package/agent/rules/basalt-content.md +0 -409
- package/agent/rules/basalt-data.md +0 -329
- package/agent/rules/basalt-forms.md +0 -186
- package/agent/rules/basalt-notifications.md +0 -264
- package/agent/rules/basalt-query.md +0 -187
- package/agent/rules/basalt-router.md +0 -336
- package/dist/content/article-filter-bar.d.ts +0 -40
- package/dist/content/article-filter-bar.d.ts.map +0 -1
- package/dist/content/article-filter-bar.js +0 -56
- package/dist/shell/page-header.d.ts +0 -13
- package/dist/shell/page-header.d.ts.map +0 -1
- package/dist/shell/page-header.js +0 -23
- package/src/content/article-filter-bar.tsx +0 -88
- package/src/shell/page-header.tsx +0 -35
package/configs/oxlint-plugin.js
CHANGED
|
@@ -102,6 +102,15 @@ export const KNOWN_RULE_IDS = new Set([
|
|
|
102
102
|
'hand-rolled-shell',
|
|
103
103
|
'shadow-basalt-export',
|
|
104
104
|
'raw-scroll-container',
|
|
105
|
+
'hand-rolled-filter',
|
|
106
|
+
'control-outside-home',
|
|
107
|
+
'control-size-literal',
|
|
108
|
+
'page-bar-budget',
|
|
109
|
+
'responsive-twin',
|
|
110
|
+
'search-literal-link',
|
|
111
|
+
'use-search-from-literal',
|
|
112
|
+
// `in-body-page-title` is BOTH a plugin rule and a guard kind — one id, two lanes, so one
|
|
113
|
+
// annotation waives both. It is listed once, below, with the guard kinds.
|
|
105
114
|
'visx-boundary',
|
|
106
115
|
'visx-tooltip',
|
|
107
116
|
'token-layer-boundary',
|
|
@@ -136,6 +145,8 @@ export const KNOWN_RULE_IDS = new Set([
|
|
|
136
145
|
'css-raw-surface',
|
|
137
146
|
'inline-font-size',
|
|
138
147
|
'hidden-inline-style',
|
|
148
|
+
'in-body-page-title',
|
|
149
|
+
'raw-selection-control',
|
|
139
150
|
])
|
|
140
151
|
|
|
141
152
|
const ALLOW_RULE_TOKEN = /^(?:basalt\/)?([a-z][a-z0-9-]*)(?=$|[\s,:—–])/
|
|
@@ -654,12 +665,42 @@ const RAW_SCROLL_CONTAINER_MESSAGE =
|
|
|
654
665
|
'node (BasaltStickToBottom, BasaltVirtualList) — add a theme-allow comment there. ' +
|
|
655
666
|
'(basalt/raw-scroll-container)'
|
|
656
667
|
|
|
657
|
-
// `overflowX` is
|
|
658
|
-
// column, so a horizontally-scrolling code block or pinned-column
|
|
659
|
-
// pattern, not a ScrollArea candidate.
|
|
668
|
+
// `overflowX` is absent from the UNIVERSAL set for the original reason: a horizontal bar doesn't
|
|
669
|
+
// reserve gutter width in a chrome column, so a horizontally-scrolling code block or pinned-column
|
|
670
|
+
// table is its own legitimate pattern, not a ScrollArea candidate. It IS policed inside a home,
|
|
671
|
+
// where the doctrine is the opposite one — see RAW_SCROLL_CONTAINER_HOME_MESSAGE.
|
|
660
672
|
const OVERFLOW_KEYS = new Set(['overflow', 'overflowY'])
|
|
661
673
|
const SCROLLING_VALUES = new Set(['auto', 'scroll'])
|
|
662
674
|
|
|
675
|
+
const RAW_SCROLL_CONTAINER_HOME_MESSAGE =
|
|
676
|
+
'Horizontal scroll region inside a home — a page bar, a section header and a chart card never ' +
|
|
677
|
+
'scroll sideways and never wrap: overflow folds into a `More` menu (actions) or a `Filters (n)` ' +
|
|
678
|
+
'sheet (filters), computed by basalt from the typed BarAction[] (law C7). A sideways-scrolling ' +
|
|
679
|
+
'row of controls is the shape that law replaces. (basalt/raw-scroll-container)'
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* The basalt home SLOT this node is written in — the owner binding of the enclosing `actions` /
|
|
683
|
+
* `filters` / `tabs` / `sync` / `filtersEnd` attribute, or undefined.
|
|
684
|
+
*
|
|
685
|
+
* The C7 widening is scoped to slot VALUES, not to "anywhere under a `Section`/`ChartCard`". It
|
|
686
|
+
* used to accept any such ancestor, body children included, which made every horizontally
|
|
687
|
+
* scrolling `<Table>`, `<pre>` or `CodeBlock` on a page an `error` carrying the C7 message about
|
|
688
|
+
* "a sideways-scrolling row of controls" — a claim that was false for exactly the pattern this
|
|
689
|
+
* rule's own comment above calls legitimate. A section BODY is page content, not a control row, so
|
|
690
|
+
* it is out of scope again, and the walk stops at the slot ATTRIBUTE (the boundary `SLOT_ATTRS`
|
|
691
|
+
* draws for every control rule).
|
|
692
|
+
*
|
|
693
|
+
* Scoped by ANCESTRY only, with no hoisted-binding lookup: a style object hoisted to a `const` has
|
|
694
|
+
* no JSX ancestor at all, so a hoisted `{ overflowX: 'auto' }` handed to a slot is a known false
|
|
695
|
+
* negative here — the same gap `chart-legend-literal` documents for a hoisted `items` array, and
|
|
696
|
+
* for the same reason (following it means local flow analysis for a bypass nobody reaches by
|
|
697
|
+
* accident).
|
|
698
|
+
*/
|
|
699
|
+
function enclosingHomeOwner(node) {
|
|
700
|
+
const attr = enclosingSlotAttribute(node)
|
|
701
|
+
return attr === undefined ? undefined : slotOwnerBinding(attr)
|
|
702
|
+
}
|
|
703
|
+
|
|
663
704
|
/**
|
|
664
705
|
* Reports a `style` object property that turns a node into its own scroll container. Whether a raw
|
|
665
706
|
* scroll box is wrong depends on who owns the scroll node, which no AST check can see — so the
|
|
@@ -668,6 +709,16 @@ const SCROLLING_VALUES = new Set(['auto', 'scroll'])
|
|
|
668
709
|
* `BasaltVirtualList`, `ThreadTranscript`'s virtualized pane) declares that ownership with the
|
|
669
710
|
* comment and moves on. Severity went `off` → `warn` (1.12.0) → `error` (1.13.0) once every live
|
|
670
711
|
* site in the repo carried that declaration; the opt-out mechanism is identical at either level.
|
|
712
|
+
*
|
|
713
|
+
* **The C7 widening (wave 6) inherits that `error` with no grace runway, and that is a LIMITATION,
|
|
714
|
+
* not a decision.** oxlint severity is per rule ID, so a widened branch cannot ship `warn` while
|
|
715
|
+
* the incumbent branch stays `error` — the only way to buy a runway would have been a second rule
|
|
716
|
+
* id, which is the bundled-vs-split trade the three boundary rules were split apart over, in the
|
|
717
|
+
* opposite direction. What the widening does instead is stay narrow: `overflowX` and
|
|
718
|
+
* `ScrollArea scrollbars="x"` are policed ONLY inside a basalt home SLOT (see
|
|
719
|
+
* {@link enclosingHomeOwner}), which is where C7 actually applies, so the set of newly-rejected
|
|
720
|
+
* code is small enough to fix rather than schedule. A consumer who disagrees turns the whole rule
|
|
721
|
+
* down in their own config.
|
|
671
722
|
*/
|
|
672
723
|
const rawScrollContainer = {
|
|
673
724
|
meta: {
|
|
@@ -678,15 +729,57 @@ const rawScrollContainer = {
|
|
|
678
729
|
schema: [],
|
|
679
730
|
},
|
|
680
731
|
create(context) {
|
|
732
|
+
const homes = new Set()
|
|
733
|
+
const mantineImports = new Map()
|
|
734
|
+
const ownTree = isBasaltOwnSource(getFilename(context))
|
|
735
|
+
// Both halves report at `Program:exit`: whether the enclosing `Section` is BASALT's is an
|
|
736
|
+
// import-list question, and the ancestry that finds the slot is only walkable during the visit.
|
|
737
|
+
const candidates = []
|
|
738
|
+
|
|
681
739
|
return {
|
|
740
|
+
ImportDeclaration(node) {
|
|
741
|
+
collectBasaltImports(node, homes, ownTree)
|
|
742
|
+
collectMantineImports(node, mantineImports)
|
|
743
|
+
},
|
|
682
744
|
Property(node) {
|
|
683
745
|
const key = node.key
|
|
684
746
|
const keyName =
|
|
685
747
|
key.type === 'Identifier' ? key.name : key.type === 'Literal' ? key.value : undefined
|
|
686
|
-
if (typeof keyName !== 'string'
|
|
748
|
+
if (typeof keyName !== 'string') return
|
|
749
|
+
const universal = OVERFLOW_KEYS.has(keyName)
|
|
750
|
+
if (!universal && keyName !== 'overflowX') return
|
|
687
751
|
if (!isStringLiteral(node.value) || !SCROLLING_VALUES.has(node.value.value)) return
|
|
688
|
-
|
|
689
|
-
|
|
752
|
+
candidates.push({ node, universal, home: universal ? undefined : enclosingHomeOwner(node) })
|
|
753
|
+
},
|
|
754
|
+
// `<ScrollArea scrollbars="x">` — the same horizontal scroll region written as a component.
|
|
755
|
+
// Only inside a home, exactly like the `overflowX` half above; a ScrollArea is otherwise the
|
|
756
|
+
// shape this whole rule steers people TOWARD. Mantine-provenanced like every other tag test:
|
|
757
|
+
// a consumer's own `ScrollArea` wrapper is not the component this doctrine is about.
|
|
758
|
+
JSXAttribute(node) {
|
|
759
|
+
if (node.name?.name !== 'scrollbars') return
|
|
760
|
+
const value = unwrapExpressionContainer(node.value)
|
|
761
|
+
if (!isStringLiteral(value) || value.value !== 'x') return
|
|
762
|
+
const owner = node.parent
|
|
763
|
+
if (owner === null || owner === undefined || owner.type !== 'JSXOpeningElement') return
|
|
764
|
+
candidates.push({
|
|
765
|
+
node,
|
|
766
|
+
universal: false,
|
|
767
|
+
home: enclosingHomeOwner(node),
|
|
768
|
+
name: owner.name,
|
|
769
|
+
})
|
|
770
|
+
},
|
|
771
|
+
'Program:exit'() {
|
|
772
|
+
for (const { node, universal, home, name } of candidates) {
|
|
773
|
+
// The C7 widening: only inside a BASALT home slot (see enclosingHomeOwner).
|
|
774
|
+
if (!universal && (home === undefined || !homes.has(home))) continue
|
|
775
|
+
if (name !== undefined && resolveMantineTag(name, mantineImports) !== 'ScrollArea')
|
|
776
|
+
continue
|
|
777
|
+
if (hasThemeAllow(context, node, 'raw-scroll-container')) continue
|
|
778
|
+
context.report({
|
|
779
|
+
node,
|
|
780
|
+
message: universal ? RAW_SCROLL_CONTAINER_MESSAGE : RAW_SCROLL_CONTAINER_HOME_MESSAGE,
|
|
781
|
+
})
|
|
782
|
+
}
|
|
690
783
|
},
|
|
691
784
|
}
|
|
692
785
|
},
|
|
@@ -1463,6 +1556,107 @@ const BASALT_EXPORTS = (() => {
|
|
|
1463
1556
|
return names
|
|
1464
1557
|
})()
|
|
1465
1558
|
|
|
1559
|
+
/**
|
|
1560
|
+
* Renames that ARE the collision, spelled out because the name no longer collides.
|
|
1561
|
+
*
|
|
1562
|
+
* `BASALT_EXPORTS` catches a fork that kept basalt's name. The commoner shape is a fork that
|
|
1563
|
+
* renamed it: argo's `PageHeader`, linewatch's `WindowSelector`, rb's `HeroCard`. Each of these is
|
|
1564
|
+
* the same composite under a different word, so the tripwire has to name the words — and unlike the
|
|
1565
|
+
* barrel read, this table is hand-maintained by construction: there is no artifact to derive
|
|
1566
|
+
* "someone else's word for `Section`" from. Keep it in step with `docs/CONTROLS-SPEC.md` §6.
|
|
1567
|
+
*
|
|
1568
|
+
* Advisory, permanently (see {@link PLUGIN_RULE_ADVISORY}): a name is evidence, never proof.
|
|
1569
|
+
*/
|
|
1570
|
+
const SHADOW_ALIASES = {
|
|
1571
|
+
PageSection: 'Section',
|
|
1572
|
+
SectionTitle: 'Section',
|
|
1573
|
+
SectionHeading: 'Section',
|
|
1574
|
+
WindowSelector: 'RangeFilter',
|
|
1575
|
+
RangeSelector: 'RangeFilter',
|
|
1576
|
+
DateFilter: 'RangeFilter',
|
|
1577
|
+
ViewSwitch: 'ViewTabs',
|
|
1578
|
+
ViewToggle: 'ViewTabs',
|
|
1579
|
+
RefreshButton: 'SyncButton',
|
|
1580
|
+
SyncControl: 'SyncButton',
|
|
1581
|
+
SyncStatusButton: 'SyncButton',
|
|
1582
|
+
PageHeader: 'PageBar',
|
|
1583
|
+
FilterBar: 'PageBar',
|
|
1584
|
+
HeroCard: 'StatCard',
|
|
1585
|
+
HeroStats: 'StatCard',
|
|
1586
|
+
ButtonGroup: 'ControlGroup',
|
|
1587
|
+
ButtonRow: 'ControlGroup',
|
|
1588
|
+
JoinedButtons: 'ControlGroup',
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* The basalt export NAMES a file imports, each mapped to the LOCAL bindings it arrived under —
|
|
1593
|
+
* `StatCard → {StatCard}` for `import { StatCard }`, `StatCard → {BaseStatCard}` for
|
|
1594
|
+
* `import { StatCard as BaseStatCard }`.
|
|
1595
|
+
*
|
|
1596
|
+
* The counterpart of {@link collectBasaltImports}, keyed the other way round: that one collects
|
|
1597
|
+
* LOCAL names (is this `<Section>` basalt's?), this one is keyed by the CANONICAL export (does this
|
|
1598
|
+
* file compose basalt's `StatCard` at all?). An alias is precisely the case that separates them —
|
|
1599
|
+
* a wrapper importing `StatCard as BaseStatCard` composes `StatCard`, and a set of local names
|
|
1600
|
+
* would answer `BaseStatCard`. The local half is carried alongside because an import alone is not
|
|
1601
|
+
* composition: the caller has to check the binding is REFERENCED (see
|
|
1602
|
+
* {@link isValueReferencePosition}).
|
|
1603
|
+
*
|
|
1604
|
+
* TYPE-ONLY specifiers are skipped, declaration-level and per-specifier, exactly as
|
|
1605
|
+
* `agent-no-raw-usechat` skips them: `import type { StatCard }` is erased at compile time, so a fork
|
|
1606
|
+
* copying the original's props through `ComponentProps<typeof StatCard>` composes nothing at all.
|
|
1607
|
+
*
|
|
1608
|
+
* Only the package name and its subpaths; no relative half, because `shadow-basalt-export` already
|
|
1609
|
+
* returns early inside basalt's own source, where a relative import would be the dogfood lane.
|
|
1610
|
+
*/
|
|
1611
|
+
function collectBasaltImportedNames(node, into) {
|
|
1612
|
+
const source = node.source?.value
|
|
1613
|
+
if (typeof source !== 'string' || !BASALT_IMPORT_SOURCE.test(source)) return
|
|
1614
|
+
if (node.importKind === 'type') return
|
|
1615
|
+
for (const spec of node.specifiers ?? []) {
|
|
1616
|
+
if (spec.importKind === 'type') continue
|
|
1617
|
+
const imported = spec.imported?.name ?? spec.imported?.value
|
|
1618
|
+
const local = spec.local?.name
|
|
1619
|
+
if (typeof imported !== 'string' || typeof local !== 'string') continue
|
|
1620
|
+
const locals = into.get(imported)
|
|
1621
|
+
if (locals === undefined) into.set(imported, new Set([local]))
|
|
1622
|
+
else locals.add(local)
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/** The positions an `Identifier`/`JSXIdentifier` occupies WITHOUT referencing a value. */
|
|
1627
|
+
const NON_VALUE_IDENTIFIER_PARENTS = new Set([
|
|
1628
|
+
// The binding's own declaration, not a use of it.
|
|
1629
|
+
'ImportSpecifier',
|
|
1630
|
+
'ImportDefaultSpecifier',
|
|
1631
|
+
'ImportNamespaceSpecifier',
|
|
1632
|
+
// A JSX attribute's NAME (`value={…}`), not its value.
|
|
1633
|
+
'JSXAttribute',
|
|
1634
|
+
])
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* Does this identifier REFERENCE a value, rather than declare or type-position it?
|
|
1638
|
+
*
|
|
1639
|
+
* The type half matters as much as the import half: `typeof StatCard` parses as a `TSTypeQuery`
|
|
1640
|
+
* around a plain `Identifier`, so a value import used only in a type is indistinguishable from a
|
|
1641
|
+
* rendered one without this test — and that shape (a fork copying the original's props) is the
|
|
1642
|
+
* whole reason the composition check exists.
|
|
1643
|
+
*/
|
|
1644
|
+
function isValueReferencePosition(node) {
|
|
1645
|
+
const parentType = node.parent?.type
|
|
1646
|
+
if (typeof parentType !== 'string') return false
|
|
1647
|
+
return !parentType.startsWith('TS') && !NON_VALUE_IDENTIFIER_PARENTS.has(parentType)
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function shadowAliasMessage(name, canonical) {
|
|
1651
|
+
return (
|
|
1652
|
+
`Local component '${name}' is a renamed '${canonical}' — basalt ships that composite, and a ` +
|
|
1653
|
+
'renamed fork is the shape the name-collision half of this rule can never see (laws C8/C12). ' +
|
|
1654
|
+
`Import ${canonical} from 'basalt-ui' (or 'basalt-ui/controls') instead of re-rolling it; if ` +
|
|
1655
|
+
'this genuinely is a different component, rename it and the rule goes quiet. ' +
|
|
1656
|
+
'(basalt/shadow-basalt-export)'
|
|
1657
|
+
)
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1466
1660
|
function shadowBasaltExportMessage(name) {
|
|
1467
1661
|
return (
|
|
1468
1662
|
`Local component '${name}' collides with the basalt-ui export of the same name — import it ` +
|
|
@@ -1501,26 +1695,86 @@ const shadowBasaltExport = {
|
|
|
1501
1695
|
// obsidian's React-free `obsidian-vault-core` took the report and had to waive it.
|
|
1502
1696
|
if (filename.length === 0 || !isBasaltScopedFile(filename)) return {}
|
|
1503
1697
|
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1698
|
+
// Candidates are BUFFERED and judged at `Program:exit`, the same shape the chart and control
|
|
1699
|
+
// rules use: the alias exemption below needs the file's whole import list AND every reference in
|
|
1700
|
+
// the file, and a declaration hoisted above its imports would otherwise be judged against an
|
|
1701
|
+
// empty one.
|
|
1702
|
+
/** Canonical basalt export → the local bindings it was imported under (value imports only). */
|
|
1703
|
+
const basaltImportedNames = new Map()
|
|
1704
|
+
/** Every local name the file REFERENCES as a value — the composition half of the exemption. */
|
|
1705
|
+
const referencedLocals = new Set()
|
|
1706
|
+
const candidates = []
|
|
1707
|
+
|
|
1708
|
+
/** Does the file compose basalt's `canonical` — imported as a value AND referenced? */
|
|
1709
|
+
const composes = (canonical) => {
|
|
1710
|
+
const locals = basaltImportedNames.get(canonical)
|
|
1711
|
+
if (locals === undefined) return false
|
|
1712
|
+
for (const local of locals) {
|
|
1713
|
+
if (referencedLocals.has(local)) return true
|
|
1714
|
+
}
|
|
1715
|
+
return false
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
const noteReference = (node) => {
|
|
1719
|
+
if (typeof node.name !== 'string' || !isValueReferencePosition(node)) return
|
|
1720
|
+
referencedLocals.add(node.name)
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
const note = (node, name) => {
|
|
1724
|
+
if (typeof name !== 'string') return
|
|
1725
|
+
const alias = Object.hasOwn(SHADOW_ALIASES, name) ? SHADOW_ALIASES[name] : undefined
|
|
1726
|
+
if (!BASALT_EXPORTS.has(name) && alias === undefined) return
|
|
1727
|
+
candidates.push({ node, name, alias })
|
|
1508
1728
|
}
|
|
1509
1729
|
|
|
1510
1730
|
return {
|
|
1731
|
+
ImportDeclaration(node) {
|
|
1732
|
+
collectBasaltImportedNames(node, basaltImportedNames)
|
|
1733
|
+
},
|
|
1734
|
+
// Both halves are needed and neither subsumes the other: a JSX tag is a `JSXIdentifier`
|
|
1735
|
+
// (`<Base/>`), while every other use of the same binding is an `Identifier`
|
|
1736
|
+
// (`component={Base}`, `createElement(Base)`, `memo(Base)`).
|
|
1737
|
+
Identifier(node) {
|
|
1738
|
+
noteReference(node)
|
|
1739
|
+
},
|
|
1740
|
+
JSXIdentifier(node) {
|
|
1741
|
+
noteReference(node)
|
|
1742
|
+
},
|
|
1511
1743
|
FunctionDeclaration(node) {
|
|
1512
|
-
|
|
1744
|
+
note(node, node.id?.name)
|
|
1513
1745
|
},
|
|
1514
1746
|
VariableDeclarator(node) {
|
|
1515
1747
|
if (!isComponentInit(node.init)) return
|
|
1516
|
-
|
|
1748
|
+
note(node, node.id?.name)
|
|
1517
1749
|
},
|
|
1518
1750
|
// A class only counts when it EXTENDS something — a legacy class component. basalt ships no
|
|
1519
1751
|
// class components, so a standalone `class X {}` sharing a name with one of its exports is a
|
|
1520
1752
|
// collision between two unrelated things, not a fork.
|
|
1521
1753
|
ClassDeclaration(node) {
|
|
1522
1754
|
if (node.superClass === null || node.superClass === undefined) return
|
|
1523
|
-
|
|
1755
|
+
note(node, node.id?.name)
|
|
1756
|
+
},
|
|
1757
|
+
'Program:exit'() {
|
|
1758
|
+
for (const { node, name, alias } of candidates) {
|
|
1759
|
+
// A file that COMPOSES the export it renames is a wrapper, not a fork, and the advice
|
|
1760
|
+
// ("import StatCard instead of re-rolling it") is already followed — argo's three hero
|
|
1761
|
+
// cards each imported `StatCard` and wrapped it, and each took a warn for it.
|
|
1762
|
+
//
|
|
1763
|
+
// COMPOSES, not "imports": an import the file never references is not composition, and the
|
|
1764
|
+
// two shapes that reach here silently are the ones a fork actually writes — a type-only
|
|
1765
|
+
// `import type { StatCard }` feeding `ComponentProps<typeof StatCard>` (props copied,
|
|
1766
|
+
// nothing rendered) and a dead import left behind after the body was re-rolled. Both used
|
|
1767
|
+
// to exempt the whole file.
|
|
1768
|
+
//
|
|
1769
|
+
// The collision half is deliberately NOT exempted either way: a local `StatCard` beside an
|
|
1770
|
+
// `import { StatCard as Base }` is a fork that kept the name AND kept a piece of the
|
|
1771
|
+
// original, which is the shape this rule most wants to see.
|
|
1772
|
+
if (alias !== undefined && composes(alias)) continue
|
|
1773
|
+
if (hasThemeAllow(context, node, 'shadow-basalt-export')) continue
|
|
1774
|
+
const message =
|
|
1775
|
+
alias === undefined ? shadowBasaltExportMessage(name) : shadowAliasMessage(name, alias)
|
|
1776
|
+
context.report({ node, message })
|
|
1777
|
+
}
|
|
1524
1778
|
},
|
|
1525
1779
|
}
|
|
1526
1780
|
},
|
|
@@ -1600,35 +1854,1202 @@ const handRolledShell = {
|
|
|
1600
1854
|
},
|
|
1601
1855
|
}
|
|
1602
1856
|
|
|
1857
|
+
// ── Control homes — the shared slot-ancestry walk (C1/C5, docs/CONTROLS-SPEC.md §6) ─────────────
|
|
1858
|
+
|
|
1859
|
+
/**
|
|
1860
|
+
* The slot props a control enters a TIERED home through (law C1). A control is only ever IN one of
|
|
1861
|
+
* these homes by being the value of one of these props — never by sitting in a home's children,
|
|
1862
|
+
* which is the body form (a `Section`'s own content, a `SettingsSection`'s rows) and carries no tier
|
|
1863
|
+
* obligation at all.
|
|
1864
|
+
*
|
|
1865
|
+
* **`control` is deliberately absent, and `SettingsRow` with it** (see {@link SLOT_OWNER_TAGS}):
|
|
1866
|
+
* `SettingsRow.control` is law C1's THIRD home, the form row, and a form keeps Mantine's own `md`
|
|
1867
|
+
* tier (`controlHeight` 42 — `docs/CONTROLS-SPEC.md` §5, unchanged). So a raw `Select` bound to a
|
|
1868
|
+
* setting is legitimate there and a `size` literal there is not a tier violation; only
|
|
1869
|
+
* `control-outside-home` cares about a settings row at all, and it treats it as a home
|
|
1870
|
+
* (`CONTROL_HOST_TAGS`) so nothing fires inside one. The two tier rules would otherwise have told
|
|
1871
|
+
* every settings page in every consumer to drop a prop that is what keeps the row at the form tier
|
|
1872
|
+
* — `src/dashboard/settings-section.tsx` wraps `actions` in `CtlSlot`, never `control`.
|
|
1873
|
+
*/
|
|
1874
|
+
const SLOT_ATTRS = new Set(['actions', 'filters', 'tabs', 'sync', 'filtersEnd'])
|
|
1875
|
+
|
|
1876
|
+
/**
|
|
1877
|
+
* The elements whose slot props ARE tiered homes. All THREE halves of the test matter: `actions` on
|
|
1878
|
+
* a consumer's own `<Toolbar actions={…}>` is not a basalt home, a `Section`'s CHILDREN are not a
|
|
1879
|
+
* slot (so the walk stops at the slot ATTRIBUTE, never at the element), and the tag must resolve to
|
|
1880
|
+
* a component imported FROM basalt ({@link collectBasaltImports}) — `Section` is one of the most
|
|
1881
|
+
* common local component names there is, and matching it by bare name made a consumer's own
|
|
1882
|
+
* section an `error`-level tiered home.
|
|
1883
|
+
*
|
|
1884
|
+
* `SettingsSection` is here for its `actions` header slot (`CtlSlot`-wrapped); `SettingsRow` is
|
|
1885
|
+
* NOT — its `control` is the form-row home and keeps Mantine's `md` tier. See {@link SLOT_ATTRS}.
|
|
1886
|
+
*/
|
|
1887
|
+
const SLOT_OWNER_TAGS = new Set([
|
|
1888
|
+
'PageBar',
|
|
1889
|
+
'Section',
|
|
1890
|
+
'WidgetHeader',
|
|
1891
|
+
'ChartCard',
|
|
1892
|
+
'StatCard',
|
|
1893
|
+
'BasaltDataTable',
|
|
1894
|
+
'SettingsSection',
|
|
1895
|
+
'FilterSet',
|
|
1896
|
+
])
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* Raw Mantine selection controls — the shape a store-bound basalt filter replaces. Membership is
|
|
1900
|
+
* checked against the file's `@mantine/*` IMPORTS, not the bare tag name: a consumer's own
|
|
1901
|
+
* `<Select>` (a wrapper, a re-export, a different component entirely) is not the Mantine one, and
|
|
1902
|
+
* these rules ship to consumers.
|
|
1903
|
+
*/
|
|
1904
|
+
const RAW_FILTER_TAGS = new Set([
|
|
1905
|
+
'SegmentedControl',
|
|
1906
|
+
'Select',
|
|
1907
|
+
'MultiSelect',
|
|
1908
|
+
'NativeSelect',
|
|
1909
|
+
'DatePickerInput',
|
|
1910
|
+
'DateInput',
|
|
1911
|
+
'TagsInput',
|
|
1912
|
+
'Chip.Group',
|
|
1913
|
+
])
|
|
1914
|
+
|
|
1915
|
+
/** The bound controls a home is SUPPOSED to hold — `basalt-ui/controls`, each over a FieldHandle. */
|
|
1916
|
+
const BOUND_TAGS = new Set([
|
|
1917
|
+
'RangeFilter',
|
|
1918
|
+
'CompareFilter',
|
|
1919
|
+
'SelectFilter',
|
|
1920
|
+
'MultiSelectFilter',
|
|
1921
|
+
'NumberFilter',
|
|
1922
|
+
'SearchFilter',
|
|
1923
|
+
'ToggleFilter',
|
|
1924
|
+
'ViewTabs',
|
|
1925
|
+
])
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* The names whose DECLARATION means this file defines a basalt control rather than consuming one —
|
|
1929
|
+
* the owner exemption, the same shape as `hand-rolled-plot`'s `notesOwnerDefinition`: a rule that
|
|
1930
|
+
* says "use a bound control" cannot fire inside the module that IS the bound control.
|
|
1931
|
+
*/
|
|
1932
|
+
const CONTROL_OWNER_NAMES = new Set([
|
|
1933
|
+
...BOUND_TAGS,
|
|
1934
|
+
'FilterSet',
|
|
1935
|
+
'FilterPill',
|
|
1936
|
+
'SyncButton',
|
|
1937
|
+
'ActionGroup',
|
|
1938
|
+
'OverflowMenu',
|
|
1939
|
+
'CtlSlot',
|
|
1940
|
+
])
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* Where a raw selection control legitimately lives outside a TIERED home: a settings row, an
|
|
1944
|
+
* overlay, a composer. Every one of these is a place the tier does not apply, so the advice ("move
|
|
1945
|
+
* it into a home slot") would be wrong rather than merely unwelcome.
|
|
1946
|
+
*
|
|
1947
|
+
* `SettingsRow` is the form row — law C1's third home, not an exception to C1 — so
|
|
1948
|
+
* `control-outside-home` treating it as a home is the whole of the enforcement a settings page
|
|
1949
|
+
* gets, and the two tier rules deliberately do not reach inside it (see {@link SLOT_ATTRS}).
|
|
1950
|
+
*
|
|
1951
|
+
* Declared with the shared slot walk rather than beside `control-outside-home` because three rules
|
|
1952
|
+
* read it: that rule's ancestry exemption, and {@link hostedInsideSlot} for the two tier rules.
|
|
1953
|
+
*/
|
|
1954
|
+
const CONTROL_HOST_TAGS = new Set([
|
|
1955
|
+
'SettingsRow',
|
|
1956
|
+
'Modal',
|
|
1957
|
+
'Drawer',
|
|
1958
|
+
'Popover.Dropdown',
|
|
1959
|
+
'Menu.Dropdown',
|
|
1960
|
+
'Composer',
|
|
1961
|
+
])
|
|
1962
|
+
|
|
1963
|
+
/**
|
|
1964
|
+
* A file whose NAME declares it is an overlay's or a form's own body — the cross-file half of
|
|
1965
|
+
* {@link CONTROL_HOST_TAGS}, and the one exemption here that is a CONVENTION rather than a fact
|
|
1966
|
+
* about the AST.
|
|
1967
|
+
*
|
|
1968
|
+
* It exists because law C1's cross-file case is explicitly advisory (`docs/CONTROLS-SPEC.md` §6,
|
|
1969
|
+
* "Honest coverage") and `control-outside-home` was paying for it in false positives: argo's
|
|
1970
|
+
* wave-7 run left 9 warns, all of them a `Select`/`SegmentedControl` inside a `*-modal.tsx` or
|
|
1971
|
+
* `*-form.tsx` whose `<Modal>` is rendered by the PARENT route. Nothing in the flagged file can see
|
|
1972
|
+
* the overlay, so no ancestry walk — however deep — will ever reach it, and the alternative is 9
|
|
1973
|
+
* `theme-allow-file` comments that say what the filename already says.
|
|
1974
|
+
*
|
|
1975
|
+
* The trade is stated plainly: this exempts a whole file on a naming convention, so a `Select` that
|
|
1976
|
+
* genuinely belongs in a page bar goes unreported if it is written in `filters-panel.tsx`. That is
|
|
1977
|
+
* the same bargain `@mantine/form` already buys and a strictly smaller one than promoting the rule
|
|
1978
|
+
* with 9 known false positives. `-panel.tsx` is the loosest member and the one to reconsider first
|
|
1979
|
+
* if the set ever needs narrowing.
|
|
1980
|
+
*
|
|
1981
|
+
* Basename only — a `modal/` DIRECTORY is not the convention, because a directory of modals holds
|
|
1982
|
+
* the page pieces around them too.
|
|
1983
|
+
*/
|
|
1984
|
+
const OVERLAY_CONVENTION_FILE = /(?:^|\/)[^/]*-(?:modal|drawer|popover|panel|form)\.[jt]sx$/
|
|
1985
|
+
|
|
1986
|
+
/** How far an ancestry walk climbs before giving up — a JSX tree this deep is not a slot value. */
|
|
1987
|
+
const ANCESTRY_MAX_DEPTH = 60
|
|
1988
|
+
|
|
1989
|
+
/** A JSX tag name as written: `Select`, or `Chip.Group` for a member tag. */
|
|
1990
|
+
function jsxTagName(nameNode) {
|
|
1991
|
+
if (nameNode === null || nameNode === undefined) return undefined
|
|
1992
|
+
if (typeof nameNode.name === 'string') return nameNode.name
|
|
1993
|
+
const object = jsxTagName(nameNode.object)
|
|
1994
|
+
const property = nameNode.property?.name
|
|
1995
|
+
if (typeof object !== 'string' || typeof property !== 'string') return undefined
|
|
1996
|
+
return `${object}.${property}`
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
/** The BINDING a JSX tag resolves through — `Chip` for `<Chip.Group>`, `Select` for `<Select>`. */
|
|
2000
|
+
function jsxRootName(nameNode) {
|
|
2001
|
+
if (nameNode === null || nameNode === undefined) return undefined
|
|
2002
|
+
if (typeof nameNode.name === 'string') return nameNode.name
|
|
2003
|
+
return jsxRootName(nameNode.object)
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
/** The value a namespace import (`import * as M from '@mantine/core'`) records for its local name. */
|
|
2007
|
+
const MANTINE_NAMESPACE = Symbol('mantine-namespace')
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* Local name → the `@mantine/*` name it was imported UNDER — the provenance test the tag rules
|
|
2011
|
+
* apply, and a MAP rather than a set because the two are not the same string. `Select as
|
|
2012
|
+
* MantineSelect` is the canonical way a consumer wraps a Mantine component, and keying membership
|
|
2013
|
+
* on the local name meant `<MantineSelect value onChange/>` in a slot — the exact C1/C3 violation
|
|
2014
|
+
* `hand-rolled-filter` exists for — was invisible. A namespace import records
|
|
2015
|
+
* {@link MANTINE_NAMESPACE} so `<M.SegmentedControl>` resolves through the member path instead.
|
|
2016
|
+
*/
|
|
2017
|
+
function collectMantineImports(node, into) {
|
|
2018
|
+
const source = node.source?.value
|
|
2019
|
+
if (typeof source !== 'string' || !source.startsWith('@mantine/')) return
|
|
2020
|
+
for (const spec of node.specifiers ?? []) {
|
|
2021
|
+
const local = spec.local?.name
|
|
2022
|
+
if (typeof local !== 'string') continue
|
|
2023
|
+
if (spec.type === 'ImportNamespaceSpecifier') into.set(local, MANTINE_NAMESPACE)
|
|
2024
|
+
else into.set(local, spec.imported?.name ?? spec.imported?.value ?? local)
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/**
|
|
2029
|
+
* The `@mantine/*` name a JSX tag resolves to, or undefined when the tag is not a Mantine binding.
|
|
2030
|
+
*
|
|
2031
|
+
* `<Select>` → `Select`; `import { Select as MantineSelect }` + `<MantineSelect>` → `Select`;
|
|
2032
|
+
* `import { Chip as C }` + `<C.Group>` → `Chip.Group`; `import * as M` + `<M.SegmentedControl>` →
|
|
2033
|
+
* `SegmentedControl`. Resolution already implies provenance, so a caller tests the RESULT against
|
|
2034
|
+
* `RAW_FILTER_TAGS` and needs no separate import-membership check.
|
|
2035
|
+
*/
|
|
2036
|
+
function resolveMantineTag(nameNode, mantineImports) {
|
|
2037
|
+
const tag = jsxTagName(nameNode)
|
|
2038
|
+
const root = jsxRootName(nameNode)
|
|
2039
|
+
if (tag === undefined || root === undefined) return undefined
|
|
2040
|
+
const imported = mantineImports.get(root)
|
|
2041
|
+
if (imported === undefined) return undefined
|
|
2042
|
+
const member = tag.slice(root.length)
|
|
2043
|
+
if (imported !== MANTINE_NAMESPACE) return `${imported}${member}`
|
|
2044
|
+
return member === '' ? undefined : member.slice(1)
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/** The package's own `src/` — where a RELATIVE import of `WidgetHeader` is still basalt's own. */
|
|
2048
|
+
const BASALT_OWN_SRC = `${resolvePath(PACKAGE_ROOT, 'src')}/`
|
|
2049
|
+
|
|
2050
|
+
/** `basalt-ui` and every published subpath — the sources a home component is imported FROM. */
|
|
2051
|
+
const BASALT_IMPORT_SOURCE = /^basalt-ui(?:\/|$)/
|
|
2052
|
+
|
|
2053
|
+
/** A relative specifier — `./section`, `../widget-header`. */
|
|
2054
|
+
const RELATIVE_IMPORT_SOURCE = /^\.\.?\//
|
|
2055
|
+
|
|
2056
|
+
/** Is the linted file part of basalt's OWN source tree — the dogfood side of the owner test? */
|
|
2057
|
+
function isBasaltOwnSource(filename) {
|
|
2058
|
+
return filename.startsWith(BASALT_OWN_SRC)
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
/**
|
|
2062
|
+
* Local names that resolve to a basalt HOME component — the owner-tag counterpart of
|
|
2063
|
+
* {@link collectMantineImports}, and for the same reason: `Section` is one of the most common local
|
|
2064
|
+
* component names there is (a marketing page, a docs layout), and reading a consumer's own
|
|
2065
|
+
* `<Section actions={…}>` as a tiered home made two `error`-level rules fire on code basalt has no
|
|
2066
|
+
* claim over.
|
|
2067
|
+
*
|
|
2068
|
+
* A relative import counts only inside basalt's own `src/` (`ownTree`), where `section.tsx` and
|
|
2069
|
+
* `data-table.tsx` render `<WidgetHeader actions={…}>`/`<FilterSet>` through `../widget-header`
|
|
2070
|
+
* rather than through the package name — without that half the dogfood surface goes silent, which
|
|
2071
|
+
* is the blind spot that let the 1.4.0 regression reach consumers.
|
|
2072
|
+
*
|
|
2073
|
+
* Known accepted false negative: a consumer re-exporting basalt's `Section` through their own
|
|
2074
|
+
* barrel (`from '@/ui'`) — the same gap the Mantine provenance test already accepts.
|
|
2075
|
+
*/
|
|
2076
|
+
function collectBasaltImports(node, into, ownTree) {
|
|
2077
|
+
const source = node.source?.value
|
|
2078
|
+
if (typeof source !== 'string') return
|
|
2079
|
+
if (!BASALT_IMPORT_SOURCE.test(source) && !(ownTree && RELATIVE_IMPORT_SOURCE.test(source))) {
|
|
2080
|
+
return
|
|
2081
|
+
}
|
|
2082
|
+
for (const spec of node.specifiers ?? []) {
|
|
2083
|
+
if (spec.local?.name !== undefined) into.add(spec.local.name)
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/**
|
|
2088
|
+
* {@link collectBasaltImports} as a local→EXPORTED map — the same provenance test, keyed so a caller
|
|
2089
|
+
* can ask WHICH home it found rather than only whether it found one.
|
|
2090
|
+
*
|
|
2091
|
+
* `control-size-literal` needs the distinction: `ChartCard` is the one slot owner whose slot cannot
|
|
2092
|
+
* mount the tier theme, and `import { ChartCard as Card }` is a normal thing to write, so a
|
|
2093
|
+
* membership Set (which answers `Card`) could not express the exemption at all.
|
|
2094
|
+
*/
|
|
2095
|
+
function collectBasaltImportMap(node, into, ownTree) {
|
|
2096
|
+
const source = node.source?.value
|
|
2097
|
+
if (typeof source !== 'string') return
|
|
2098
|
+
if (!BASALT_IMPORT_SOURCE.test(source) && !(ownTree && RELATIVE_IMPORT_SOURCE.test(source))) {
|
|
2099
|
+
return
|
|
2100
|
+
}
|
|
2101
|
+
for (const spec of node.specifiers ?? []) {
|
|
2102
|
+
const local = spec.local?.name
|
|
2103
|
+
if (typeof local !== 'string') continue
|
|
2104
|
+
into.set(local, spec.imported?.name ?? spec.imported?.value ?? local)
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/**
|
|
2109
|
+
* The BINDING a home's slot prop hangs off — `PageBar` for `<PageBar filters={…}>` — or undefined
|
|
2110
|
+
* when `attr` is not a slot-shaped prop on a home-shaped tag.
|
|
2111
|
+
*
|
|
2112
|
+
* Provenance (is that `Section` basalt's?) is deliberately NOT tested here: the answer needs the
|
|
2113
|
+
* file's import list, which is only complete at `Program:exit`, while the ancestry a caller walks to
|
|
2114
|
+
* reach this attribute is only walkable during the visit. So the walk captures the NAME and the
|
|
2115
|
+
* caller resolves it against {@link collectBasaltImports}'s set afterwards.
|
|
2116
|
+
*/
|
|
2117
|
+
function slotOwnerBinding(attr) {
|
|
2118
|
+
if (attr === null || attr === undefined || attr.type !== 'JSXAttribute') return undefined
|
|
2119
|
+
const name = attr.name?.name
|
|
2120
|
+
if (typeof name !== 'string' || !SLOT_ATTRS.has(name)) return undefined
|
|
2121
|
+
const owner = attr.parent
|
|
2122
|
+
if (owner === null || owner === undefined || owner.type !== 'JSXOpeningElement') return undefined
|
|
2123
|
+
if (!SLOT_OWNER_TAGS.has(jsxTagName(owner.name) ?? '')) return undefined
|
|
2124
|
+
return jsxRootName(owner.name)
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/** The home slot attribute that ENCLOSES `node`, or undefined. Stops at the attribute, never the element. */
|
|
2128
|
+
function enclosingSlotAttribute(node) {
|
|
2129
|
+
let current = node.parent
|
|
2130
|
+
for (let depth = 0; current !== null && current !== undefined; depth++) {
|
|
2131
|
+
if (depth > ANCESTRY_MAX_DEPTH || current.type === 'Program') return undefined
|
|
2132
|
+
if (slotOwnerBinding(current) !== undefined) return current
|
|
2133
|
+
current = current.parent
|
|
2134
|
+
}
|
|
2135
|
+
return undefined
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
/**
|
|
2139
|
+
* Does an OVERLAY (or a settings row) sit between `node` and the home slot it is written in?
|
|
2140
|
+
*
|
|
2141
|
+
* Colocating a trigger with its overlay in a slot — a `New` button beside the `<Modal>` it opens, a
|
|
2142
|
+
* `Custom range` button beside its `<Popover.Dropdown>` — is the ordinary way to write both, and
|
|
2143
|
+
* the overlay's own contents are NOT in the bar: they portal out of it, they are not tiered by the
|
|
2144
|
+
* slot's `MantineThemeProvider`, and `control-outside-home` already declares every one of these a
|
|
2145
|
+
* non-home ({@link CONTROL_HOST_TAGS}). Before this test the same overlay was exempt outside a slot
|
|
2146
|
+
* and an `error` inside one.
|
|
2147
|
+
*
|
|
2148
|
+
* The walk stops at the slot attribute so an overlay ABOVE the home (a whole `PageBar` rendered
|
|
2149
|
+
* inside a `Modal`) does not exempt the bar's own controls.
|
|
2150
|
+
*/
|
|
2151
|
+
function hostedInsideSlot(node) {
|
|
2152
|
+
let current = node.parent
|
|
2153
|
+
for (let depth = 0; current !== null && current !== undefined; depth++) {
|
|
2154
|
+
if (depth > ANCESTRY_MAX_DEPTH || current.type === 'Program') return false
|
|
2155
|
+
if (
|
|
2156
|
+
current.type === 'JSXElement' &&
|
|
2157
|
+
CONTROL_HOST_TAGS.has(jsxTagName(current.openingElement?.name) ?? '')
|
|
2158
|
+
) {
|
|
2159
|
+
return true
|
|
2160
|
+
}
|
|
2161
|
+
if (current.type === 'JSXAttribute' && SLOT_ATTRS.has(current.name?.name ?? '')) return false
|
|
2162
|
+
current = current.parent
|
|
2163
|
+
}
|
|
2164
|
+
return false
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
/** The name of the nearest enclosing `const x = …` binding — the hoisted-slot-value lookup. */
|
|
2168
|
+
function enclosingDeclaratorName(node) {
|
|
2169
|
+
let current = node.parent
|
|
2170
|
+
for (let depth = 0; current !== null && current !== undefined; depth++) {
|
|
2171
|
+
if (depth > ANCESTRY_MAX_DEPTH || current.type === 'Program') return undefined
|
|
2172
|
+
if (current.type === 'VariableDeclarator') {
|
|
2173
|
+
return typeof current.id?.name === 'string' ? current.id.name : undefined
|
|
2174
|
+
}
|
|
2175
|
+
current = current.parent
|
|
2176
|
+
}
|
|
2177
|
+
return undefined
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
/** True when any JSX ELEMENT ancestor of `node` opens with one of `tags`. */
|
|
2181
|
+
function hasAncestorTag(node, tags) {
|
|
2182
|
+
let current = node.parent
|
|
2183
|
+
for (let depth = 0; current !== null && current !== undefined; depth++) {
|
|
2184
|
+
if (depth > ANCESTRY_MAX_DEPTH || current.type === 'Program') return false
|
|
2185
|
+
if (current.type === 'JSXElement' && tags.has(jsxTagName(current.openingElement?.name) ?? '')) {
|
|
2186
|
+
return true
|
|
2187
|
+
}
|
|
2188
|
+
current = current.parent
|
|
2189
|
+
}
|
|
2190
|
+
return false
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
/** Identifiers a slot attribute's value resolves through — `filters={pills}`, `actions={[a, b]}`. */
|
|
2194
|
+
function collectSlotValueIdentifiers(node, into, depth = 0) {
|
|
2195
|
+
if (node === null || node === undefined || depth > 8) return
|
|
2196
|
+
if (node.type === 'Identifier') {
|
|
2197
|
+
into.add(node.name)
|
|
2198
|
+
return
|
|
2199
|
+
}
|
|
2200
|
+
if (node.type === 'ArrayExpression') {
|
|
2201
|
+
for (const el of node.elements) collectSlotValueIdentifiers(el, into, depth + 1)
|
|
2202
|
+
return
|
|
2203
|
+
}
|
|
2204
|
+
if (node.type === 'ConditionalExpression') {
|
|
2205
|
+
collectSlotValueIdentifiers(node.consequent, into, depth + 1)
|
|
2206
|
+
collectSlotValueIdentifiers(node.alternate, into, depth + 1)
|
|
2207
|
+
return
|
|
2208
|
+
}
|
|
2209
|
+
if (node.type === 'LogicalExpression') {
|
|
2210
|
+
collectSlotValueIdentifiers(node.left, into, depth + 1)
|
|
2211
|
+
collectSlotValueIdentifiers(node.right, into, depth + 1)
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
/**
|
|
2216
|
+
* The shared "am I inside a home slot" resolver, and the one piece of state every control rule
|
|
2217
|
+
* needs the WHOLE file to answer: a hoisted `const pills = <Select …/>` handed to `filters={pills}`
|
|
2218
|
+
* further down is inside that slot (argo's hoisted `headerExtra`, `cost-over-time.tsx:54-68`), and
|
|
2219
|
+
* the binding is only known to be slot-bound once the attribute has been seen.
|
|
2220
|
+
*
|
|
2221
|
+
* So the ancestry facts are CAPTURED during the visit — while `node.parent` is walkable — and
|
|
2222
|
+
* resolved at `Program:exit`, when the slot-bound identifier set is complete. `capture()` returns a
|
|
2223
|
+
* descriptor, `resolve()` turns it into a verdict; nothing re-walks the tree after traversal ends.
|
|
2224
|
+
*
|
|
2225
|
+
* The descriptor holds the owner BINDING NAME rather than a boolean for the same reason: whether
|
|
2226
|
+
* that `Section` is basalt's is answered by the import list (`noteImport`), which is also only
|
|
2227
|
+
* complete at exit.
|
|
2228
|
+
*/
|
|
2229
|
+
function createSlotContext(context) {
|
|
2230
|
+
/** Local binding → the basalt export it was imported under. Membership IS the home test. */
|
|
2231
|
+
const homes = new Map()
|
|
2232
|
+
const ownTree = isBasaltOwnSource(getFilename(context))
|
|
2233
|
+
/**
|
|
2234
|
+
* Hoisted identifier → EVERY owner BINDING whose slot it was handed to.
|
|
2235
|
+
*
|
|
2236
|
+
* A Set, not one binding: `const acts = <Button size="xs"/>` handed to both `<Section actions>` and
|
|
2237
|
+
* `<ChartCard actions>` is inside two slots at once, and a last-writer-wins map made the verdict
|
|
2238
|
+
* depend on which attribute came later in the file — the same code went silent or reported
|
|
2239
|
+
* depending on JSX order.
|
|
2240
|
+
*/
|
|
2241
|
+
const slotBoundIdentifiers = new Map()
|
|
2242
|
+
return {
|
|
2243
|
+
/** Feed every ImportDeclaration here — this is where the owner provenance comes from. */
|
|
2244
|
+
noteImport(node) {
|
|
2245
|
+
collectBasaltImportMap(node, homes, ownTree)
|
|
2246
|
+
},
|
|
2247
|
+
/** Feed every JSXAttribute here — a slot attribute contributes its value's identifiers. */
|
|
2248
|
+
note(attr) {
|
|
2249
|
+
const owner = slotOwnerBinding(attr)
|
|
2250
|
+
if (owner === undefined) return
|
|
2251
|
+
const identifiers = new Set()
|
|
2252
|
+
collectSlotValueIdentifiers(unwrapExpressionContainer(attr.value), identifiers)
|
|
2253
|
+
for (const id of identifiers) {
|
|
2254
|
+
const owners = slotBoundIdentifiers.get(id)
|
|
2255
|
+
if (owners === undefined) slotBoundIdentifiers.set(id, new Set([owner]))
|
|
2256
|
+
else owners.add(owner)
|
|
2257
|
+
}
|
|
2258
|
+
},
|
|
2259
|
+
/** Snapshot `node`'s ancestry while it is walkable — the owner NAME, resolved later. */
|
|
2260
|
+
capture(node) {
|
|
2261
|
+
const attr = enclosingSlotAttribute(node)
|
|
2262
|
+
return {
|
|
2263
|
+
directOwner: attr === undefined ? undefined : slotOwnerBinding(attr),
|
|
2264
|
+
declaratorName: enclosingDeclaratorName(node),
|
|
2265
|
+
}
|
|
2266
|
+
},
|
|
2267
|
+
/** Is a captured node inside a BASALT home slot — directly, or via a hoisted binding? */
|
|
2268
|
+
resolve(captured) {
|
|
2269
|
+
if (captured.directOwner !== undefined) return homes.has(captured.directOwner)
|
|
2270
|
+
if (captured.declaratorName === undefined) return false
|
|
2271
|
+
const hoistedOwners = slotBoundIdentifiers.get(captured.declaratorName)
|
|
2272
|
+
if (hoistedOwners === undefined) return false
|
|
2273
|
+
// ANY basalt owner puts the binding in a home: a node handed to one basalt slot and one
|
|
2274
|
+
// consumer component still renders inside the basalt slot.
|
|
2275
|
+
for (const owner of hoistedOwners) {
|
|
2276
|
+
if (homes.has(owner)) return true
|
|
2277
|
+
}
|
|
2278
|
+
return false
|
|
2279
|
+
},
|
|
2280
|
+
/** Does `name` resolve to a basalt home component in this file? */
|
|
2281
|
+
isHome(name) {
|
|
2282
|
+
return name !== undefined && homes.has(name)
|
|
2283
|
+
},
|
|
2284
|
+
/**
|
|
2285
|
+
* EVERY basalt EXPORT whose slot a captured node sits in — `['ChartCard']` for
|
|
2286
|
+
* `<ChartCard actions={…}>` and for `<Card actions={…}>` under an alias, `[]` when the node is in
|
|
2287
|
+
* no basalt slot. Resolves through the same hoisted-binding hop `resolve` does.
|
|
2288
|
+
*
|
|
2289
|
+
* A LIST, because one hoisted binding can be handed to several slots, and an exemption keyed on
|
|
2290
|
+
* the owner is only sound when it holds for all of them — a `size="xs"` shared between
|
|
2291
|
+
* `<ChartCard actions>` and `<Section actions>` really does render inside the Section's tiered
|
|
2292
|
+
* slot. Non-basalt owners are dropped: they are not homes, so they carry no tier either way.
|
|
2293
|
+
*/
|
|
2294
|
+
ownerExports(captured) {
|
|
2295
|
+
const bindings =
|
|
2296
|
+
captured.directOwner !== undefined
|
|
2297
|
+
? [captured.directOwner]
|
|
2298
|
+
: captured.declaratorName === undefined
|
|
2299
|
+
? []
|
|
2300
|
+
: [...(slotBoundIdentifiers.get(captured.declaratorName) ?? [])]
|
|
2301
|
+
return bindings.filter((binding) => homes.has(binding)).map((binding) => homes.get(binding))
|
|
2302
|
+
},
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
/** Collects an owner-definition flag for the control layer — see {@link CONTROL_OWNER_NAMES}. */
|
|
2307
|
+
function createControlOwnerProbe() {
|
|
2308
|
+
const state = { definesControl: false }
|
|
2309
|
+
const note = (name) => {
|
|
2310
|
+
if (typeof name === 'string' && CONTROL_OWNER_NAMES.has(name)) state.definesControl = true
|
|
2311
|
+
}
|
|
2312
|
+
return {
|
|
2313
|
+
state,
|
|
2314
|
+
visitors: {
|
|
2315
|
+
FunctionDeclaration(node) {
|
|
2316
|
+
note(node.id?.name)
|
|
2317
|
+
},
|
|
2318
|
+
VariableDeclarator(node) {
|
|
2319
|
+
note(node.id?.name)
|
|
2320
|
+
},
|
|
2321
|
+
},
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
// ── Rule 16 — hand-rolled-filter ────────────────────────────────────────────────────────────────
|
|
2326
|
+
|
|
2327
|
+
const HAND_ROLLED_FILTER_MESSAGE =
|
|
2328
|
+
'Raw Mantine selection control inside a home slot — a filter or tab in a PageBar / Section / ' +
|
|
2329
|
+
'WidgetHeader slot takes a `field` (a FieldHandle) and owns both the URL write and the ' +
|
|
2330
|
+
'localStorage mirror, so it cannot carry value/onChange at all (laws C1–C3). Use ' +
|
|
2331
|
+
'RangeFilter / SelectFilter / MultiSelectFilter / SearchFilter / ToggleFilter / ViewTabs from ' +
|
|
2332
|
+
"basalt-ui/controls. Two shapes are NOT this rule: a control in a home's CHILDREN (a Section " +
|
|
2333
|
+
"body form), and a SettingsRow's `control` — the form-row home, where a raw input at Mantine's " +
|
|
2334
|
+
'md tier is the right answer. (basalt/hand-rolled-filter)'
|
|
2335
|
+
|
|
2336
|
+
/**
|
|
2337
|
+
* A raw Mantine selection control handed to a home slot (laws C1/C3).
|
|
2338
|
+
*
|
|
2339
|
+
* Ships `error` from the start, unlike its sibling `control-outside-home`: "this element is inside
|
|
2340
|
+
* a slot" is a structural fact with no heuristic in it, and the wave-1..4 migrations left no
|
|
2341
|
+
* incumbents. Two shapes are deliberately outside the rule: the body form (the ancestry walk stops
|
|
2342
|
+
* at the slot ATTRIBUTE, so a `<Select>` in a `Section`'s children never reaches it) and
|
|
2343
|
+
* `SettingsRow.control`, the form-row home — see {@link SLOT_ATTRS} for why a raw input is right
|
|
2344
|
+
* there.
|
|
2345
|
+
*/
|
|
2346
|
+
const handRolledFilter = {
|
|
2347
|
+
meta: {
|
|
2348
|
+
type: 'problem',
|
|
2349
|
+
docs: { description: 'Disallow a raw Mantine selection control inside a basalt home slot.' },
|
|
2350
|
+
schema: [],
|
|
2351
|
+
},
|
|
2352
|
+
create(context) {
|
|
2353
|
+
if (isTestFile(context)) return {}
|
|
2354
|
+
const slots = createSlotContext(context)
|
|
2355
|
+
const mantineImports = new Map()
|
|
2356
|
+
const candidates = []
|
|
2357
|
+
|
|
2358
|
+
return {
|
|
2359
|
+
ImportDeclaration(node) {
|
|
2360
|
+
collectMantineImports(node, mantineImports)
|
|
2361
|
+
slots.noteImport(node)
|
|
2362
|
+
},
|
|
2363
|
+
JSXAttribute(node) {
|
|
2364
|
+
slots.note(node)
|
|
2365
|
+
},
|
|
2366
|
+
JSXOpeningElement(node) {
|
|
2367
|
+
// Cheap pre-filter: the tag as WRITTEN, or any Mantine binding (an alias resolves at exit).
|
|
2368
|
+
const root = jsxRootName(node.name)
|
|
2369
|
+
if (!RAW_FILTER_TAGS.has(jsxTagName(node.name) ?? '') && !mantineImports.has(root)) return
|
|
2370
|
+
candidates.push({
|
|
2371
|
+
node,
|
|
2372
|
+
name: node.name,
|
|
2373
|
+
captured: slots.capture(node),
|
|
2374
|
+
hosted: hostedInsideSlot(node),
|
|
2375
|
+
})
|
|
2376
|
+
},
|
|
2377
|
+
'Program:exit'() {
|
|
2378
|
+
for (const { node, name, captured, hosted } of candidates) {
|
|
2379
|
+
const tag = resolveMantineTag(name, mantineImports)
|
|
2380
|
+
if (tag === undefined || !RAW_FILTER_TAGS.has(tag)) continue
|
|
2381
|
+
if (hosted || !slots.resolve(captured)) continue
|
|
2382
|
+
if (hasThemeAllow(context, node, 'hand-rolled-filter')) continue
|
|
2383
|
+
context.report({ node, message: HAND_ROLLED_FILTER_MESSAGE })
|
|
2384
|
+
}
|
|
2385
|
+
},
|
|
2386
|
+
}
|
|
2387
|
+
},
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
// ── Rule 17 — control-outside-home ──────────────────────────────────────────────────────────────
|
|
2391
|
+
|
|
2392
|
+
const CONTROL_OUTSIDE_HOME_MESSAGE =
|
|
2393
|
+
'Raw Mantine selection control with no home — a filter, tab or action belongs in exactly one of ' +
|
|
2394
|
+
'the three homes (a PageBar / Section / WidgetHeader slot, or a form row), and a home is entered ' +
|
|
2395
|
+
'through a slot prop (law C1). A settings row, an overlay (Modal/Drawer/Popover/Menu) and a ' +
|
|
2396
|
+
'form (@mantine/form) are the declared non-homes and never report. ' +
|
|
2397
|
+
'(basalt/control-outside-home)'
|
|
2398
|
+
|
|
2399
|
+
/**
|
|
2400
|
+
* The cross-file half of C1, and the one rule here that is openly a HEURISTIC: "this control has
|
|
2401
|
+
* no home" is a claim about layout intent, which no AST can see. It ships `warn` for that reason
|
|
2402
|
+
* and stays warn until the playground and the five consumer repos run it with ≤3 waivers
|
|
2403
|
+
* (docs/CONTROLS-SPEC.md §9 wave 7).
|
|
2404
|
+
*
|
|
2405
|
+
* Four exemptions carry the false-positive load: an overlay/settings-row ancestor, an
|
|
2406
|
+
* {@link OVERLAY_CONVENTION_FILE} basename (the CROSS-FILE case — the `<Modal>` lives in the
|
|
2407
|
+
* parent), a file that imports `@mantine/form` (a form is the third home and its inputs are not
|
|
2408
|
+
* filters), and the owner exemption — a file DEFINING a basalt control cannot be told to use one.
|
|
2409
|
+
*/
|
|
2410
|
+
const controlOutsideHome = {
|
|
2411
|
+
meta: {
|
|
2412
|
+
type: 'suggestion',
|
|
2413
|
+
docs: { description: 'Warn on a raw Mantine selection control that is in no basalt home.' },
|
|
2414
|
+
schema: [],
|
|
2415
|
+
},
|
|
2416
|
+
create(context) {
|
|
2417
|
+
if (isTestFile(context)) return {}
|
|
2418
|
+
if (OVERLAY_CONVENTION_FILE.test(getFilename(context))) return {}
|
|
2419
|
+
const slots = createSlotContext(context)
|
|
2420
|
+
const owner = createControlOwnerProbe()
|
|
2421
|
+
const mantineImports = new Map()
|
|
2422
|
+
const candidates = []
|
|
2423
|
+
let importsMantineForm = false
|
|
2424
|
+
|
|
2425
|
+
return {
|
|
2426
|
+
...owner.visitors,
|
|
2427
|
+
ImportDeclaration(node) {
|
|
2428
|
+
collectMantineImports(node, mantineImports)
|
|
2429
|
+
slots.noteImport(node)
|
|
2430
|
+
if ((node.source?.value ?? '') === '@mantine/form') importsMantineForm = true
|
|
2431
|
+
},
|
|
2432
|
+
JSXAttribute(node) {
|
|
2433
|
+
slots.note(node)
|
|
2434
|
+
},
|
|
2435
|
+
JSXOpeningElement(node) {
|
|
2436
|
+
// Cheap pre-filter: the tag as WRITTEN, or any Mantine binding (an alias resolves at exit).
|
|
2437
|
+
const root = jsxRootName(node.name)
|
|
2438
|
+
if (!RAW_FILTER_TAGS.has(jsxTagName(node.name) ?? '') && !mantineImports.has(root)) return
|
|
2439
|
+
candidates.push({
|
|
2440
|
+
node,
|
|
2441
|
+
name: node.name,
|
|
2442
|
+
captured: slots.capture(node),
|
|
2443
|
+
hosted: hasAncestorTag(node, CONTROL_HOST_TAGS),
|
|
2444
|
+
})
|
|
2445
|
+
},
|
|
2446
|
+
'Program:exit'() {
|
|
2447
|
+
if (importsMantineForm || owner.state.definesControl) return
|
|
2448
|
+
for (const { node, name, captured, hosted } of candidates) {
|
|
2449
|
+
const tag = resolveMantineTag(name, mantineImports)
|
|
2450
|
+
if (tag === undefined || !RAW_FILTER_TAGS.has(tag)) continue
|
|
2451
|
+
if (hosted || slots.resolve(captured)) continue
|
|
2452
|
+
if (hasThemeAllow(context, node, 'control-outside-home')) continue
|
|
2453
|
+
context.report({ node, message: CONTROL_OUTSIDE_HOME_MESSAGE })
|
|
2454
|
+
}
|
|
2455
|
+
},
|
|
2456
|
+
}
|
|
2457
|
+
},
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
// ── Rule 18 — control-size-literal ──────────────────────────────────────────────────────────────
|
|
2461
|
+
|
|
2462
|
+
/** The props a home already owns — the tier, the width, and the responsive swap (laws C5/C9). */
|
|
2463
|
+
const CONTROL_SIZE_ATTRS = new Set(['size', 'w', 'fullWidth', 'visibleFrom', 'hiddenFrom'])
|
|
2464
|
+
|
|
2465
|
+
const CONTROL_SIZE_LITERAL_MESSAGE =
|
|
2466
|
+
'Size/width/breakpoint prop inside a tiered home slot — the HOME sets the tier (`size="ctl"` = 30px, ' +
|
|
2467
|
+
"mounted by the slot's own MantineThemeProvider), the control owns its own responsive swap, and " +
|
|
2468
|
+
'the overflow fold is computed by basalt from typed data. A `size`/`w`/`fullWidth`/`visibleFrom`/' +
|
|
2469
|
+
'`hiddenFrom` written here fights all three (laws C5/C7/C9) — drop the prop. ' +
|
|
2470
|
+
'(basalt/control-size-literal)'
|
|
2471
|
+
|
|
2472
|
+
/**
|
|
2473
|
+
* The elements the slot's own `MantineThemeProvider` actually re-tiers, beyond the raw filters: a
|
|
2474
|
+
* `Button size="xs"` dropped into `PageBar.actions` is the same defect as a `Select size="xs"` there
|
|
2475
|
+
* — one control in the bar that is not 30px.
|
|
2476
|
+
*
|
|
2477
|
+
* This file is plain JS shipped to consumers and `src/theme/ctl-theme.tsx` is Mantine-coupled
|
|
2478
|
+
* TypeScript it may not import, so the list is written out here — and PINNED to
|
|
2479
|
+
* `CTL_THEME.components` by `oxlint-plugin.test.ts`, which can import both. Drift fails that test
|
|
2480
|
+
* rather than silently re-tiering a component the rule still polices (or the reverse).
|
|
2481
|
+
*
|
|
2482
|
+
* The scope IS the rule: an `<IconDownload size={14}/>`, a count `<Badge size="sm">`, a
|
|
2483
|
+
* `<Loader size={14}/>` or a `<Modal size="lg">` colocated in a slot is NOT sized by the slot theme,
|
|
2484
|
+
* so "drop the prop" produced a 24px icon and a message whose promise ("the HOME sets the tier") was
|
|
2485
|
+
* false for every one of them.
|
|
2486
|
+
*
|
|
2487
|
+
* `Switch` USED to be on that not-sized list and is now on this one, together with `Radio`,
|
|
2488
|
+
* `Checkbox` and the three `*Group`s: the tier gained their `-ctl` vars in the same minor this rule
|
|
2489
|
+
* ships in (`theme/ctl-theme.tsx`, `theme/index.ts`'s `ctlSizeVars`), because a 20px indicator beside
|
|
2490
|
+
* the tier's 13.5px option label was what every filter popover and the mobile sheet rendered. The
|
|
2491
|
+
* grace runway in {@link PLUGIN_RULE_GRACE} is unchanged — the rule has never shipped at any level,
|
|
2492
|
+
* so widening it before its first release restarts nothing.
|
|
2493
|
+
*/
|
|
2494
|
+
export const CTL_THEME_TAGS = new Set([
|
|
2495
|
+
'Button',
|
|
2496
|
+
'ActionIcon',
|
|
2497
|
+
'Input',
|
|
2498
|
+
'TextInput',
|
|
2499
|
+
'Select',
|
|
2500
|
+
'MultiSelect',
|
|
2501
|
+
'SegmentedControl',
|
|
2502
|
+
'NativeSelect',
|
|
2503
|
+
'Radio',
|
|
2504
|
+
'RadioGroup',
|
|
2505
|
+
'Checkbox',
|
|
2506
|
+
'CheckboxGroup',
|
|
2507
|
+
'Switch',
|
|
2508
|
+
'SwitchGroup',
|
|
2509
|
+
])
|
|
2510
|
+
|
|
2511
|
+
/**
|
|
2512
|
+
* A size/width/breakpoint prop on a control the slot theme tiers. The slot's hoisted
|
|
2513
|
+
* `MantineThemeProvider` already resolves the tier with no prop. A `SettingsRow`'s `control` is NOT
|
|
2514
|
+
* a tiered slot (see {@link SLOT_ATTRS}): a form row keeps Mantine's `md`, so the `size` written
|
|
2515
|
+
* there is load-bearing rather than redundant.
|
|
2516
|
+
*
|
|
2517
|
+
* Scoped to `RAW_FILTER_TAGS` ∪ `BOUND_TAGS` (basalt's own controls, by name) ∪ a Mantine-imported
|
|
2518
|
+
* {@link CTL_THEME_TAGS} member, and skipped under an overlay ({@link hostedInsideSlot}) — see
|
|
2519
|
+
* `CTL_THEME_TAGS` for what firing on everything in the slot cost.
|
|
2520
|
+
*/
|
|
2521
|
+
/**
|
|
2522
|
+
* The home whose slot cannot mount the tier theme, so the CONTROL there has to state its own size.
|
|
2523
|
+
*
|
|
2524
|
+
* `ChartCard` lives inside the Mantine-free chart layer (`src/charts/primitives/`), which means it
|
|
2525
|
+
* cannot render a `MantineThemeProvider` at all — it writes `data-basalt-tier="widget"` by hand and
|
|
2526
|
+
* nothing else (`../CLAUDE.md`, "A home sizes its own SLOT"; `docs/CONTROLS-SPEC.md` §5). So this
|
|
2527
|
+
* rule's whole message — "the HOME sets the tier, drop the prop" — is FALSE in exactly one slot: a
|
|
2528
|
+
* `Switch` in `ChartCard.actions` with no `size` renders at Mantine's default, not at `ctl`.
|
|
2529
|
+
*
|
|
2530
|
+
* It has to be an exemption rather than a waiver because the rule fires on the `size` ATTRIBUTE and
|
|
2531
|
+
* cannot tell a correct `size="ctl"` from the `size="xs"` it exists to catch. The playground carried
|
|
2532
|
+
* exactly one waiver for this and it was the honest kind — the rule was wrong, not the code.
|
|
2533
|
+
*
|
|
2534
|
+
* `hand-rolled-filter` is deliberately NOT exempted here: a raw `Select` in `ChartCard.actions` is
|
|
2535
|
+
* still a filter that should take a `field`, and the tier is not what makes that true.
|
|
2536
|
+
*/
|
|
2537
|
+
const TIERLESS_SLOT_OWNERS = new Set(['ChartCard'])
|
|
2538
|
+
|
|
2539
|
+
const controlSizeLiteral = {
|
|
2540
|
+
meta: {
|
|
2541
|
+
type: 'suggestion',
|
|
2542
|
+
docs: { description: 'Warn on a size/width/breakpoint prop written inside a home slot.' },
|
|
2543
|
+
schema: [],
|
|
2544
|
+
},
|
|
2545
|
+
create(context) {
|
|
2546
|
+
if (isTestFile(context)) return {}
|
|
2547
|
+
const slots = createSlotContext(context)
|
|
2548
|
+
const mantineImports = new Map()
|
|
2549
|
+
const candidates = []
|
|
2550
|
+
|
|
2551
|
+
return {
|
|
2552
|
+
ImportDeclaration(node) {
|
|
2553
|
+
collectMantineImports(node, mantineImports)
|
|
2554
|
+
slots.noteImport(node)
|
|
2555
|
+
},
|
|
2556
|
+
JSXAttribute(node) {
|
|
2557
|
+
slots.note(node)
|
|
2558
|
+
const name = node.name?.name
|
|
2559
|
+
if (typeof name !== 'string' || !CONTROL_SIZE_ATTRS.has(name)) return
|
|
2560
|
+
const owner = node.parent
|
|
2561
|
+
if (owner === null || owner === undefined || owner.type !== 'JSXOpeningElement') return
|
|
2562
|
+
candidates.push({
|
|
2563
|
+
node,
|
|
2564
|
+
name: owner.name,
|
|
2565
|
+
captured: slots.capture(node),
|
|
2566
|
+
hosted: hostedInsideSlot(node),
|
|
2567
|
+
})
|
|
2568
|
+
},
|
|
2569
|
+
'Program:exit'() {
|
|
2570
|
+
for (const { node, name, captured, hosted } of candidates) {
|
|
2571
|
+
const written = jsxTagName(name) ?? ''
|
|
2572
|
+
const tiered =
|
|
2573
|
+
RAW_FILTER_TAGS.has(written) ||
|
|
2574
|
+
BOUND_TAGS.has(written) ||
|
|
2575
|
+
CTL_THEME_TAGS.has(resolveMantineTag(name, mantineImports) ?? '')
|
|
2576
|
+
if (!tiered || hosted) continue
|
|
2577
|
+
if (!slots.resolve(captured)) continue
|
|
2578
|
+
// EVERY home the node was handed to has to be tierless. One hoisted `const acts =
|
|
2579
|
+
// <Button size="xs"/>` given to both `<ChartCard actions>` and `<Section actions>` renders
|
|
2580
|
+
// in a tiered slot too, and the prop is redundant there — so the exemption does not apply.
|
|
2581
|
+
const owners = slots.ownerExports(captured)
|
|
2582
|
+
if (owners.length > 0 && owners.every((owner) => TIERLESS_SLOT_OWNERS.has(owner)))
|
|
2583
|
+
continue
|
|
2584
|
+
if (hasThemeAllow(context, node, 'control-size-literal')) continue
|
|
2585
|
+
context.report({ node, message: CONTROL_SIZE_LITERAL_MESSAGE })
|
|
2586
|
+
}
|
|
2587
|
+
},
|
|
2588
|
+
}
|
|
2589
|
+
},
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
// ── Rule 19 — page-bar-budget ───────────────────────────────────────────────────────────────────
|
|
2593
|
+
|
|
2594
|
+
/** `PageBar.actions.secondary` — 4, because the one `primary` makes 5 (law C6). */
|
|
2595
|
+
const PAGE_BAR_SECONDARY_BUDGET = 4
|
|
2596
|
+
/** A `Section`'s `actions` — 3 (law C6). */
|
|
2597
|
+
const SECTION_ACTION_BUDGET = 3
|
|
2598
|
+
|
|
2599
|
+
const PAGE_BAR_BUDGET_MESSAGES = {
|
|
2600
|
+
duplicate:
|
|
2601
|
+
'Second PageBar in the same returned tree — a page has exactly one page bar, and row 1 portals ' +
|
|
2602
|
+
'into the app shell header, so two bars mounted TOGETHER race for the same node (law C6). An ' +
|
|
2603
|
+
'early-return loading state and two page components in one file each get their own tree and ' +
|
|
2604
|
+
'never report. (basalt/page-bar-budget)',
|
|
2605
|
+
secondary:
|
|
2606
|
+
`More than ${PAGE_BAR_SECONDARY_BUDGET} secondary PageBar actions — the bar holds ≤5 entries ` +
|
|
2607
|
+
'including the one `primary`, and everything past that folds into the `More` menu basalt ' +
|
|
2608
|
+
'computes from the typed BarAction[] (laws C6/C7). Move the rest into a `kind: "menu"` entry. ' +
|
|
2609
|
+
'(basalt/page-bar-budget)',
|
|
2610
|
+
section:
|
|
2611
|
+
`More than ${SECTION_ACTION_BUDGET} Section actions — a section header holds ≤3 (law C6). ` +
|
|
2612
|
+
'(basalt/page-bar-budget)',
|
|
2613
|
+
filled:
|
|
2614
|
+
'Second filled Button/ActionIcon inside one slot — a home has exactly one primary action, and ' +
|
|
2615
|
+
'two filled buttons side by side name neither (law C6). A filled Badge/ThemeIcon (a count) and ' +
|
|
2616
|
+
"an overlay's own submit button are not actions in the bar and never report. " +
|
|
2617
|
+
'(basalt/page-bar-budget)',
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
/** The two tags a `variant="filled"` makes a PRIMARY ACTION — a Badge is a count, not an action. */
|
|
2621
|
+
const PRIMARY_ACTION_TAGS = new Set(['Button', 'ActionIcon'])
|
|
2622
|
+
|
|
2623
|
+
/**
|
|
2624
|
+
* The JSX TREE `node` is returned in — the nearest `ReturnStatement`, concise arrow body, or
|
|
2625
|
+
* `const` binding, whichever the walk reaches first.
|
|
2626
|
+
*
|
|
2627
|
+
* This is what "two PageBars race for the same node" actually requires: they must MOUNT together.
|
|
2628
|
+
* Counting per FILE made an `if (!data) return <PageBar title="Jobs"/>` loading state, and two page
|
|
2629
|
+
* components exported from one file, an `error` on code that never renders two bars — with a
|
|
2630
|
+
* `theme-allow` on correct code as the only escape.
|
|
2631
|
+
*/
|
|
2632
|
+
function enclosingReturnedTree(node) {
|
|
2633
|
+
let current = node.parent
|
|
2634
|
+
for (let depth = 0; current !== null && current !== undefined; depth++) {
|
|
2635
|
+
if (depth > ANCESTRY_MAX_DEPTH || current.type === 'Program') return undefined
|
|
2636
|
+
if (current.type === 'ReturnStatement' || current.type === 'VariableDeclarator') return current
|
|
2637
|
+
if (current.type === 'ArrowFunctionExpression' && current.body?.type !== 'BlockStatement') {
|
|
2638
|
+
return current
|
|
2639
|
+
}
|
|
2640
|
+
current = current.parent
|
|
2641
|
+
}
|
|
2642
|
+
return undefined
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
/** The `actions` attribute of a JSXOpeningElement, unwrapped, or undefined. */
|
|
2646
|
+
function actionsAttributeValue(node) {
|
|
2647
|
+
for (const attr of node.attributes ?? []) {
|
|
2648
|
+
if (attr.type !== 'JSXAttribute' || attr.name?.name !== 'actions') continue
|
|
2649
|
+
return { attr, value: unwrapExpressionContainer(attr.value) }
|
|
2650
|
+
}
|
|
2651
|
+
return undefined
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
/**
|
|
2655
|
+
* The four countable halves of C6. `error` from the start: every one of them is arithmetic over a
|
|
2656
|
+
* literal the file wrote down, with no heuristic and no layout intent to guess at.
|
|
2657
|
+
*/
|
|
2658
|
+
const pageBarBudget = {
|
|
2659
|
+
meta: {
|
|
2660
|
+
type: 'problem',
|
|
2661
|
+
docs: { description: 'Enforce the PageBar / Section action budgets and the single primary.' },
|
|
2662
|
+
schema: [],
|
|
2663
|
+
},
|
|
2664
|
+
create(context) {
|
|
2665
|
+
if (isTestFile(context)) return {}
|
|
2666
|
+
const slots = createSlotContext(context)
|
|
2667
|
+
const mantineImports = new Map()
|
|
2668
|
+
const pageBars = []
|
|
2669
|
+
const findings = []
|
|
2670
|
+
/** Slot attribute node → its owner binding + the `variant="filled"` attributes inside it. */
|
|
2671
|
+
const filledPerSlot = new Map()
|
|
2672
|
+
|
|
2673
|
+
return {
|
|
2674
|
+
ImportDeclaration(node) {
|
|
2675
|
+
collectMantineImports(node, mantineImports)
|
|
2676
|
+
slots.noteImport(node)
|
|
2677
|
+
},
|
|
2678
|
+
JSXOpeningElement(node) {
|
|
2679
|
+
const tag = jsxTagName(node.name)
|
|
2680
|
+
const owner = jsxRootName(node.name)
|
|
2681
|
+
if (tag === 'PageBar') {
|
|
2682
|
+
pageBars.push({ node, owner, tree: enclosingReturnedTree(node) })
|
|
2683
|
+
const actions = actionsAttributeValue(node)
|
|
2684
|
+
if (actions?.value?.type === 'ObjectExpression') {
|
|
2685
|
+
for (const prop of actions.value.properties) {
|
|
2686
|
+
if (prop.type !== 'Property' || propertyKeyName(prop) !== 'secondary') continue
|
|
2687
|
+
if (prop.value?.type !== 'ArrayExpression') continue
|
|
2688
|
+
if (prop.value.elements.length <= PAGE_BAR_SECONDARY_BUDGET) continue
|
|
2689
|
+
findings.push({ node: prop, owner, message: PAGE_BAR_BUDGET_MESSAGES.secondary })
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
return
|
|
2693
|
+
}
|
|
2694
|
+
if (tag !== 'Section') return
|
|
2695
|
+
const actions = actionsAttributeValue(node)
|
|
2696
|
+
if (actions?.value?.type !== 'ArrayExpression') return
|
|
2697
|
+
if (actions.value.elements.length <= SECTION_ACTION_BUDGET) return
|
|
2698
|
+
findings.push({ node: actions.attr, owner, message: PAGE_BAR_BUDGET_MESSAGES.section })
|
|
2699
|
+
},
|
|
2700
|
+
JSXAttribute(node) {
|
|
2701
|
+
slots.note(node)
|
|
2702
|
+
if (node.name?.name !== 'variant') return
|
|
2703
|
+
const value = unwrapExpressionContainer(node.value)
|
|
2704
|
+
if (!isStringLiteral(value) || value.value !== 'filled') return
|
|
2705
|
+
// An overlay's own submit button is not an action in the bar — see hostedInsideSlot.
|
|
2706
|
+
if (hostedInsideSlot(node)) return
|
|
2707
|
+
const element = node.parent
|
|
2708
|
+
if (element === null || element === undefined || element.type !== 'JSXOpeningElement')
|
|
2709
|
+
return
|
|
2710
|
+
const slot = enclosingSlotAttribute(node)
|
|
2711
|
+
if (slot === undefined) return
|
|
2712
|
+
const entry = filledPerSlot.get(slot) ?? { owner: slotOwnerBinding(slot), written: [] }
|
|
2713
|
+
entry.written.push({ node, name: element.name })
|
|
2714
|
+
filledPerSlot.set(slot, entry)
|
|
2715
|
+
},
|
|
2716
|
+
'Program:exit'() {
|
|
2717
|
+
/** Returned tree → the PageBars mounted in it. Two in one tree mount together; see C6. */
|
|
2718
|
+
const barsPerTree = new Map()
|
|
2719
|
+
for (const bar of pageBars) {
|
|
2720
|
+
if (!slots.isHome(bar.owner)) continue
|
|
2721
|
+
barsPerTree.set(bar.tree, [...(barsPerTree.get(bar.tree) ?? []), bar])
|
|
2722
|
+
}
|
|
2723
|
+
for (const bars of barsPerTree.values()) {
|
|
2724
|
+
for (const { node, owner } of bars.slice(1)) {
|
|
2725
|
+
findings.push({ node, owner, message: PAGE_BAR_BUDGET_MESSAGES.duplicate })
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
for (const { owner, written } of filledPerSlot.values()) {
|
|
2729
|
+
const actions = written.filter(({ name }) =>
|
|
2730
|
+
PRIMARY_ACTION_TAGS.has(resolveMantineTag(name, mantineImports) ?? ''),
|
|
2731
|
+
)
|
|
2732
|
+
for (const { node } of actions.slice(1)) {
|
|
2733
|
+
findings.push({ node, owner, message: PAGE_BAR_BUDGET_MESSAGES.filled })
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
for (const { node, owner, message } of findings) {
|
|
2737
|
+
if (!slots.isHome(owner)) continue
|
|
2738
|
+
if (hasThemeAllow(context, node, 'page-bar-budget')) continue
|
|
2739
|
+
context.report({ node, message })
|
|
2740
|
+
}
|
|
2741
|
+
},
|
|
2742
|
+
}
|
|
2743
|
+
},
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// ── Rule 20 — in-body-page-title ────────────────────────────────────────────────────────────────
|
|
2747
|
+
|
|
2748
|
+
/** Where an `order={1|2}` Title is a DOCUMENT heading rather than a page title. */
|
|
2749
|
+
const PAGE_TITLE_HOST_TAGS = new Set(['Prose', 'ArticleLayout', 'Modal', 'Drawer'])
|
|
2750
|
+
|
|
2751
|
+
const IN_BODY_PAGE_TITLE_MESSAGE =
|
|
2752
|
+
'In-body page title — the page is named ONCE, by the breadcrumb (`staticData.title`) or by ' +
|
|
2753
|
+
'`PageBar.title` in a shell-less app, and every section/card/table title is a `WidgetHeader` ' +
|
|
2754
|
+
'(law C8). An `<Title order={1|2}>` in the body is a second, drifting name for the same page. ' +
|
|
2755
|
+
'Prose / ArticleLayout / an overlay and anything under a `content/` path are document headings ' +
|
|
2756
|
+
'and never report. (basalt/in-body-page-title)'
|
|
2757
|
+
|
|
2758
|
+
/** The `order` prop as a number literal, or undefined. */
|
|
2759
|
+
function titleOrderOf(node) {
|
|
2760
|
+
for (const attr of node.attributes ?? []) {
|
|
2761
|
+
if (attr.type !== 'JSXAttribute' || attr.name?.name !== 'order') continue
|
|
2762
|
+
const value = unwrapExpressionContainer(attr.value)
|
|
2763
|
+
return isNumericLiteral(value) ? value.value : undefined
|
|
2764
|
+
}
|
|
2765
|
+
return undefined
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
/**
|
|
2769
|
+
* The AST half of C8. Its text-level twin is the `in-body-page-title` GUARD KIND — same id in both
|
|
2770
|
+
* lanes on purpose, so one `theme-allow in-body-page-title — <why>` waives both rather than
|
|
2771
|
+
* needing a different word per enforcement surface.
|
|
2772
|
+
*/
|
|
2773
|
+
const inBodyPageTitle = {
|
|
2774
|
+
meta: {
|
|
2775
|
+
type: 'suggestion',
|
|
2776
|
+
docs: { description: 'Warn on an in-body <Title order={1|2}> outside prose/overlay context.' },
|
|
2777
|
+
schema: [],
|
|
2778
|
+
},
|
|
2779
|
+
create(context) {
|
|
2780
|
+
if (isTestFile(context) || hasPathSegment(getFilename(context), 'content')) return {}
|
|
2781
|
+
|
|
2782
|
+
return {
|
|
2783
|
+
JSXOpeningElement(node) {
|
|
2784
|
+
if (jsxTagName(node.name) !== 'Title') return
|
|
2785
|
+
const order = titleOrderOf(node)
|
|
2786
|
+
if (order !== 1 && order !== 2) return
|
|
2787
|
+
if (hasAncestorTag(node, PAGE_TITLE_HOST_TAGS)) return
|
|
2788
|
+
if (hasThemeAllow(context, node, 'in-body-page-title')) return
|
|
2789
|
+
context.report({ node, message: IN_BODY_PAGE_TITLE_MESSAGE })
|
|
2790
|
+
},
|
|
2791
|
+
}
|
|
2792
|
+
},
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
// ── Rule 21 — responsive-twin ───────────────────────────────────────────────────────────────────
|
|
2796
|
+
|
|
2797
|
+
const RESPONSIVE_TWIN_MESSAGE =
|
|
2798
|
+
'The same control mounted twice under visibleFrom/hiddenFrom — a responsive swap belongs to the ' +
|
|
2799
|
+
'CONTROL, which owns one mount and switches its own presentation in CSS (law C9). Two mounts are ' +
|
|
2800
|
+
'two states: the hidden half keeps its own value, its own focus and its own store writes. ' +
|
|
2801
|
+
'Render it once. (basalt/responsive-twin)'
|
|
2802
|
+
|
|
2803
|
+
/** The breakpoint a `visibleFrom` / `hiddenFrom` prop names, as a string literal. */
|
|
2804
|
+
function breakpointAttr(element, attrName) {
|
|
2805
|
+
for (const attr of element.openingElement?.attributes ?? []) {
|
|
2806
|
+
if (attr.type !== 'JSXAttribute' || attr.name?.name !== attrName) continue
|
|
2807
|
+
const value = unwrapExpressionContainer(attr.value)
|
|
2808
|
+
return isStringLiteral(value) ? value.value : undefined
|
|
2809
|
+
}
|
|
2810
|
+
return undefined
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
/** Every control tag rendered anywhere in `node`'s subtree, itself included. */
|
|
2814
|
+
function subtreeControlTags(node, into = new Set(), depth = 0) {
|
|
2815
|
+
if (node === null || node === undefined || depth > 16) return into
|
|
2816
|
+
if (node.type === 'JSXElement') {
|
|
2817
|
+
const tag = jsxTagName(node.openingElement?.name)
|
|
2818
|
+
if (tag !== undefined && (RAW_FILTER_TAGS.has(tag) || BOUND_TAGS.has(tag))) into.add(tag)
|
|
2819
|
+
for (const child of node.children ?? []) subtreeControlTags(child, into, depth + 1)
|
|
2820
|
+
return into
|
|
2821
|
+
}
|
|
2822
|
+
if (node.type === 'JSXFragment') {
|
|
2823
|
+
for (const child of node.children ?? []) subtreeControlTags(child, into, depth + 1)
|
|
2824
|
+
return into
|
|
2825
|
+
}
|
|
2826
|
+
if (node.type === 'JSXExpressionContainer')
|
|
2827
|
+
return subtreeControlTags(node.expression, into, depth + 1)
|
|
2828
|
+
if (node.type === 'ConditionalExpression') {
|
|
2829
|
+
subtreeControlTags(node.consequent, into, depth + 1)
|
|
2830
|
+
return subtreeControlTags(node.alternate, into, depth + 1)
|
|
2831
|
+
}
|
|
2832
|
+
if (node.type === 'LogicalExpression') {
|
|
2833
|
+
subtreeControlTags(node.left, into, depth + 1)
|
|
2834
|
+
return subtreeControlTags(node.right, into, depth + 1)
|
|
2835
|
+
}
|
|
2836
|
+
return into
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
/**
|
|
2840
|
+
* Law C9 — linewatch shipped three doubled controls this way. The pair test is deliberately narrow:
|
|
2841
|
+
* two SIBLING elements, one `visibleFrom="X"` and one `hiddenFrom="X"` on the SAME breakpoint, whose
|
|
2842
|
+
* subtrees both render the same control tag. A file that DEFINES a basalt control is exempt — the
|
|
2843
|
+
* one legitimate double mount is the CSS swap inside the control itself.
|
|
2844
|
+
*/
|
|
2845
|
+
const responsiveTwin = {
|
|
2846
|
+
meta: {
|
|
2847
|
+
type: 'suggestion',
|
|
2848
|
+
docs: { description: 'Warn on one control mounted twice under visibleFrom/hiddenFrom.' },
|
|
2849
|
+
schema: [],
|
|
2850
|
+
},
|
|
2851
|
+
create(context) {
|
|
2852
|
+
if (isTestFile(context)) return {}
|
|
2853
|
+
const owner = createControlOwnerProbe()
|
|
2854
|
+
const twins = []
|
|
2855
|
+
|
|
2856
|
+
const inspectChildren = (node) => {
|
|
2857
|
+
const children = (node.children ?? []).filter((child) => child.type === 'JSXElement')
|
|
2858
|
+
for (let i = 0; i < children.length; i++) {
|
|
2859
|
+
for (let j = i + 1; j < children.length; j++) {
|
|
2860
|
+
const a = children[i]
|
|
2861
|
+
const b = children[j]
|
|
2862
|
+
const visibleA = breakpointAttr(a, 'visibleFrom')
|
|
2863
|
+
const hiddenA = breakpointAttr(a, 'hiddenFrom')
|
|
2864
|
+
const visibleB = breakpointAttr(b, 'visibleFrom')
|
|
2865
|
+
const hiddenB = breakpointAttr(b, 'hiddenFrom')
|
|
2866
|
+
const paired =
|
|
2867
|
+
(visibleA !== undefined && visibleA === hiddenB) ||
|
|
2868
|
+
(hiddenA !== undefined && hiddenA === visibleB)
|
|
2869
|
+
if (!paired) continue
|
|
2870
|
+
const tagsA = subtreeControlTags(a)
|
|
2871
|
+
if (tagsA.size === 0) continue
|
|
2872
|
+
const shared = [...subtreeControlTags(b)].some((tag) => tagsA.has(tag))
|
|
2873
|
+
if (!shared) continue
|
|
2874
|
+
twins.push(b)
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
return {
|
|
2880
|
+
...owner.visitors,
|
|
2881
|
+
JSXElement: inspectChildren,
|
|
2882
|
+
JSXFragment: inspectChildren,
|
|
2883
|
+
'Program:exit'() {
|
|
2884
|
+
if (owner.state.definesControl) return
|
|
2885
|
+
for (const node of twins) {
|
|
2886
|
+
if (hasThemeAllow(context, node, 'responsive-twin')) continue
|
|
2887
|
+
context.report({ node, message: RESPONSIVE_TWIN_MESSAGE })
|
|
2888
|
+
}
|
|
2889
|
+
},
|
|
2890
|
+
}
|
|
2891
|
+
},
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
// ── Rule 22 — search-literal-link ───────────────────────────────────────────────────────────────
|
|
2895
|
+
|
|
2896
|
+
/** The two calls that build a typed nav definition — `search:` inside one is a nav link's search. */
|
|
2897
|
+
const NAV_DEFINITION_CALLEES = new Set(['defineNav', 'navGroup'])
|
|
2898
|
+
|
|
2899
|
+
const SEARCH_LITERAL_LINK_MESSAGE =
|
|
2900
|
+
"Object literal as a nav link's `search` — it PINS those values on every click, so a nav link " +
|
|
2901
|
+
'always navigates back to the fallback and the persisted selection is silently discarded (law ' +
|
|
2902
|
+
"C10; argo's reader had zero call sites because of exactly this). Pass `store.linkSearch` BY " +
|
|
2903
|
+
'REFERENCE instead — never a literal, never `search: true`, never a global link callback. ' +
|
|
2904
|
+
'(basalt/search-literal-link)'
|
|
2905
|
+
|
|
2906
|
+
/** True when `node` sits inside a `defineNav()` / `navGroup()` argument. */
|
|
2907
|
+
function insideNavDefinition(node) {
|
|
2908
|
+
let current = node.parent
|
|
2909
|
+
for (let depth = 0; current !== null && current !== undefined; depth++) {
|
|
2910
|
+
if (depth > ANCESTRY_MAX_DEPTH || current.type === 'Program') return false
|
|
2911
|
+
if (
|
|
2912
|
+
current.type === 'CallExpression' &&
|
|
2913
|
+
current.callee?.type === 'Identifier' &&
|
|
2914
|
+
NAV_DEFINITION_CALLEES.has(current.callee.name)
|
|
2915
|
+
) {
|
|
2916
|
+
return true
|
|
2917
|
+
}
|
|
2918
|
+
current = current.parent
|
|
2919
|
+
}
|
|
2920
|
+
return false
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
const searchLiteralLink = {
|
|
2924
|
+
meta: {
|
|
2925
|
+
type: 'suggestion',
|
|
2926
|
+
docs: { description: 'Warn on a `search:` object literal inside defineNav/navGroup.' },
|
|
2927
|
+
schema: [],
|
|
2928
|
+
},
|
|
2929
|
+
create(context) {
|
|
2930
|
+
return {
|
|
2931
|
+
CallExpression(node) {
|
|
2932
|
+
if (node.callee?.type !== 'Identifier' || node.callee.name !== 'linkOptions') return
|
|
2933
|
+
const arg0 = node.arguments?.[0]
|
|
2934
|
+
if (arg0 === undefined || arg0.type !== 'ObjectExpression') return
|
|
2935
|
+
for (const prop of arg0.properties) {
|
|
2936
|
+
if (prop.type !== 'Property' || propertyKeyName(prop) !== 'search') continue
|
|
2937
|
+
if (prop.value?.type !== 'ObjectExpression') continue
|
|
2938
|
+
if (!insideNavDefinition(node)) continue
|
|
2939
|
+
if (hasThemeAllow(context, prop, 'search-literal-link')) continue
|
|
2940
|
+
context.report({ node: prop, message: SEARCH_LITERAL_LINK_MESSAGE })
|
|
2941
|
+
}
|
|
2942
|
+
},
|
|
2943
|
+
}
|
|
2944
|
+
},
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
// ── Rule 23 — use-search-from-literal ───────────────────────────────────────────────────────────
|
|
2948
|
+
|
|
2949
|
+
const USE_SEARCH_FROM_LITERAL_MESSAGE =
|
|
2950
|
+
"useSearch({ from: '<route>' }) — a `from` literal pins the reader to ONE route id, so the same " +
|
|
2951
|
+
'component throws the moment it renders on a sibling or child route. A store field reads ' +
|
|
2952
|
+
'`useSearch({ strict: false })` internally, which sees the merged search of every matched route ' +
|
|
2953
|
+
'(law C10) — take the value as a prop, or read it through the store field. ' +
|
|
2954
|
+
'(basalt/use-search-from-literal)'
|
|
2955
|
+
|
|
2956
|
+
const useSearchFromLiteral = {
|
|
2957
|
+
meta: {
|
|
2958
|
+
type: 'suggestion',
|
|
2959
|
+
docs: { description: "Warn on useSearch({ from: '<literal>' })." },
|
|
2960
|
+
schema: [],
|
|
2961
|
+
},
|
|
2962
|
+
create(context) {
|
|
2963
|
+
return {
|
|
2964
|
+
CallExpression(node) {
|
|
2965
|
+
const callee = node.callee
|
|
2966
|
+
const name =
|
|
2967
|
+
callee?.type === 'MemberExpression' ? callee.property?.name : (callee?.name ?? undefined)
|
|
2968
|
+
if (name !== 'useSearch') return
|
|
2969
|
+
const arg0 = node.arguments?.[0]
|
|
2970
|
+
if (arg0 === undefined || arg0.type !== 'ObjectExpression') return
|
|
2971
|
+
for (const prop of arg0.properties) {
|
|
2972
|
+
if (prop.type !== 'Property' || propertyKeyName(prop) !== 'from') continue
|
|
2973
|
+
if (!isStringLiteral(prop.value)) continue
|
|
2974
|
+
if (hasThemeAllow(context, prop, 'use-search-from-literal')) continue
|
|
2975
|
+
context.report({ node: prop, message: USE_SEARCH_FROM_LITERAL_MESSAGE })
|
|
2976
|
+
}
|
|
2977
|
+
},
|
|
2978
|
+
}
|
|
2979
|
+
},
|
|
2980
|
+
}
|
|
2981
|
+
|
|
1603
2982
|
// ── Grace ledger ────────────────────────────────────────────────────────────────────────────────
|
|
1604
2983
|
|
|
2984
|
+
/**
|
|
2985
|
+
* @typedef {{ since: string; promote: string; why: string }} GraceEntry
|
|
2986
|
+
* @typedef {{ since: string; why: string }} AdvisoryEntry
|
|
2987
|
+
*/
|
|
2988
|
+
|
|
1605
2989
|
/**
|
|
1606
2990
|
* Plugin rules still inside their grace minor — `warn` in the shipped consumer preset, `error`
|
|
1607
|
-
* repo-locally
|
|
1608
|
-
*
|
|
1609
|
-
*
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
* `oxlint-plugin.test.ts` asserts the two agree in both directions — a rule listed here
|
|
1613
|
-
* `warn` in the shipped preset, and one absent from
|
|
1614
|
-
* promotion, and the test makes the config
|
|
2991
|
+
* repo-locally. This is the plugin's counterpart to `src/guard`'s `GRACE_PERIOD_KINDS`, and it
|
|
2992
|
+
* exists because its ABSENCE is what let three rules sit at `warn` for up to twelve minors with
|
|
2993
|
+
* nothing tracking them: `configs/oxlint.json` is generated and its top-level keys are fixed by
|
|
2994
|
+
* oxlint's own parser, so the ledger cannot live there.
|
|
2995
|
+
*
|
|
2996
|
+
* `oxlint-plugin.test.ts` asserts the two agree in both directions — a rule listed here or in
|
|
2997
|
+
* {@link PLUGIN_RULE_ADVISORY} must be `warn` in the shipped preset, and one absent from both must
|
|
2998
|
+
* be `error`. Deleting a `PLUGIN_RULE_GRACE` entry IS the promotion, and the test makes the config
|
|
2999
|
+
* change mandatory in the same commit.
|
|
3000
|
+
*
|
|
3001
|
+
* Each entry is `{ since, promote, why }` (semver strings, C16 — `docs/CONTROLS-SPEC.md` §1): a
|
|
3002
|
+
* version-gated test fails the build once `package.json`'s version reaches `promote` while the
|
|
3003
|
+
* entry is still here — the four entries that used to live here (D4) sat at `warn` for up to five
|
|
3004
|
+
* minors because a bare promotion-note string carried no expiry a test could check. The test gate
|
|
3005
|
+
* fires on the version already published; `scripts/check-grace.ts` is the other end of it, run by
|
|
3006
|
+
* `scripts/release.sh` against the version the dry run COMPUTED, so a release that would ship a
|
|
3007
|
+
* due entry still at `warn` is refused before it is cut (the release commit is `[skip ci]` and
|
|
3008
|
+
* runs no tests, so the test alone would only notice on the next unrelated push).
|
|
3009
|
+
*
|
|
3010
|
+
* @type {Record<string, GraceEntry>}
|
|
1615
3011
|
*/
|
|
1616
3012
|
export const PLUGIN_RULE_GRACE = {
|
|
1617
|
-
'
|
|
1618
|
-
|
|
1619
|
-
'
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
3013
|
+
'control-outside-home': {
|
|
3014
|
+
since: '1.26.0',
|
|
3015
|
+
promote: '1.28.0',
|
|
3016
|
+
why:
|
|
3017
|
+
'the wave-6 control guards (docs/CONTROLS-SPEC.md §6). The one openly HEURISTIC rule of the ' +
|
|
3018
|
+
'set — "this control has no home" is a claim about layout intent, so its false-positive load ' +
|
|
3019
|
+
'is carried by four exemptions (overlay/settings-row ancestor, the overlay FILENAME ' +
|
|
3020
|
+
'convention below, @mantine/form, owner definition) rather than by certainty. The other five ' +
|
|
3021
|
+
'wave-6 rules promoted at 1.27.0; this one did not, and the reason is measured rather than ' +
|
|
3022
|
+
'cautious: the wave-7 run found 9 remaining warns in argo, every one of them a control in a ' +
|
|
3023
|
+
'modal/form module whose `<Modal>` is rendered by the PARENT — the cross-file case law C1 is ' +
|
|
3024
|
+
'explicitly advisory about, which no ancestry walk inside one file can ever see. ' +
|
|
3025
|
+
'{@link OVERLAY_CONVENTION_FILE} now exempts the declared naming convention for exactly ' +
|
|
3026
|
+
'that shape; 1.28.0 is when the remainder is re-measured. A file outside the convention ' +
|
|
3027
|
+
"that is still an overlay's own body declares it with " +
|
|
3028
|
+
'`theme-allow-file control-outside-home — overlay`.',
|
|
3029
|
+
},
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
/**
|
|
3033
|
+
* Plugin rules that stay `warn` in the shipped preset PERMANENTLY — outside the C16 version gate,
|
|
3034
|
+
* and not expected to ever promote. `oxlint-plugin.test.ts` still requires `warn` in the shipped
|
|
3035
|
+
* preset and a `why` long enough to justify it, but skips these when checking `promote`.
|
|
3036
|
+
*
|
|
3037
|
+
* `shadow-basalt-export` is the one entry: a name collision is strong evidence a component was
|
|
3038
|
+
* forked rather than imported, but not proof, and renaming the fork defeats the check outright — so
|
|
3039
|
+
* there is no version at which flipping it to `error` is safe to promise in advance.
|
|
3040
|
+
*
|
|
3041
|
+
* @type {Record<string, AdvisoryEntry>}
|
|
3042
|
+
*/
|
|
3043
|
+
export const PLUGIN_RULE_ADVISORY = {
|
|
3044
|
+
'shadow-basalt-export': {
|
|
3045
|
+
since: '1.20.0',
|
|
3046
|
+
why:
|
|
3047
|
+
'new in the round-4 guard minor (1.20.0), widened in round-5 (1.21.0 — reads every ' +
|
|
3048
|
+
'published barrel, not just the root one, so a chart-layer fork is visible), narrowed at ' +
|
|
3049
|
+
'1.22.0 (`isBasaltScopedFile` plus a component-shaped declaration). A name collision is ' +
|
|
3050
|
+
'strong evidence and not proof, and renaming defeats it outright, so this stays a permanent ' +
|
|
3051
|
+
'warn rather than a grace entry with no honest promote date.',
|
|
3052
|
+
},
|
|
1632
3053
|
}
|
|
1633
3054
|
|
|
1634
3055
|
// ── Plugin export ───────────────────────────────────────────────────────────────────────────────
|
|
@@ -1645,6 +3066,14 @@ export default {
|
|
|
1645
3066
|
'shadow-basalt-export': shadowBasaltExport,
|
|
1646
3067
|
'hand-rolled-shell': handRolledShell,
|
|
1647
3068
|
'raw-scroll-container': rawScrollContainer,
|
|
3069
|
+
'hand-rolled-filter': handRolledFilter,
|
|
3070
|
+
'control-outside-home': controlOutsideHome,
|
|
3071
|
+
'control-size-literal': controlSizeLiteral,
|
|
3072
|
+
'page-bar-budget': pageBarBudget,
|
|
3073
|
+
'in-body-page-title': inBodyPageTitle,
|
|
3074
|
+
'responsive-twin': responsiveTwin,
|
|
3075
|
+
'search-literal-link': searchLiteralLink,
|
|
3076
|
+
'use-search-from-literal': useSearchFromLiteral,
|
|
1648
3077
|
'visx-boundary': visxBoundary,
|
|
1649
3078
|
'visx-tooltip': visxTooltip,
|
|
1650
3079
|
'token-layer-boundary': tokenLayerBoundary,
|