basalt-ui 1.28.0 → 1.29.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 +11 -18
- package/CHANGELOG.md +13 -0
- package/MIGRATING.md +151 -7
- package/README.md +23 -66
- package/agent/rules/basalt-batteries.md +59 -182
- package/agent/rules/basalt-charts.md +56 -75
- package/agent/rules/basalt-controls.md +38 -69
- package/agent/rules/basalt-mantine.md +58 -103
- package/agent/rules/basalt-state.md +42 -60
- package/agent/rules/basalt-tokens.md +46 -67
- package/agent/skills/basalt-app/SKILL.md +2 -0
- package/agent/templates/CLAUDE-block.md.tpl +14 -14
- package/configs/oxlint-plugin.js +70 -167
- package/configs/oxlint-plugin.test.ts +20 -186
- package/configs/oxlint.json +0 -1
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +7 -4
- package/dist/agent/coalesce.js +1 -1
- package/dist/agent/merge.js +1 -1
- package/dist/agent/transport.d.ts +13 -2
- package/dist/agent/transport.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.d.ts +12 -1
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +20 -6
- package/dist/agent-chat/index.d.ts +1 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/relative-time.js +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-feed-row.d.ts +32 -7
- package/dist/agent-chat/thread-feed-row.d.ts.map +1 -1
- package/dist/agent-chat/thread-feed-row.js +121 -86
- package/dist/agent-chat/thread-feed.d.ts +11 -1
- package/dist/agent-chat/thread-feed.d.ts.map +1 -1
- package/dist/agent-chat/thread-feed.js +10 -6
- package/dist/agent-chat/thread-message.js +1 -1
- package/dist/agent-chat/virtualize.d.ts +39 -0
- package/dist/agent-chat/virtualize.d.ts.map +1 -1
- package/dist/agent-chat/virtualize.js +15 -0
- package/dist/charts/index.d.ts +3 -3
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +8 -4
- package/dist/charts/kinds/BandStrip.js +1 -1
- package/dist/charts/kinds/Bars.d.ts +1 -3
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +3 -1
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +1 -1
- package/dist/charts/kinds/StackedArea.js +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +1 -3
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/primitives/Axes.d.ts +21 -2
- package/dist/charts/primitives/Axes.d.ts.map +1 -1
- package/dist/charts/primitives/Axes.js +34 -2
- package/dist/charts/primitives/ChartCard.d.ts +20 -4
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +62 -33
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +8 -1
- package/dist/charts/primitives/ChartPending.d.ts +12 -6
- package/dist/charts/primitives/ChartPending.d.ts.map +1 -1
- package/dist/charts/primitives/ChartPending.js +1 -1
- package/dist/charts/utils/format.d.ts +5 -92
- package/dist/charts/utils/format.d.ts.map +1 -1
- package/dist/charts/utils/format.js +10 -80
- package/dist/cli/audit-allows.d.ts +24 -0
- package/dist/cli/audit-allows.d.ts.map +1 -0
- package/dist/cli/audit-allows.js +240 -0
- package/dist/cli/check-theme.d.ts +10 -0
- package/dist/cli/check-theme.d.ts.map +1 -0
- package/dist/cli/check-theme.js +139 -0
- package/dist/cli/doctor.d.ts +47 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +197 -0
- package/dist/cli/guard-hook.d.ts +9 -0
- package/dist/cli/guard-hook.d.ts.map +1 -0
- package/dist/cli/guard-hook.js +93 -0
- package/dist/cli/help.d.ts +9 -0
- package/dist/cli/help.d.ts.map +1 -0
- package/dist/cli/help.js +53 -0
- package/dist/cli/index.d.ts +360 -270
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +85 -1901
- package/dist/cli/init.d.ts +4 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +165 -0
- package/dist/cli/sync.d.ts +43 -0
- package/dist/cli/sync.d.ts.map +1 -0
- package/dist/cli/sync.js +206 -0
- package/dist/cli/tokens-css.d.ts +51 -0
- package/dist/cli/tokens-css.d.ts.map +1 -0
- package/dist/cli/tokens-css.js +165 -0
- package/dist/commands/define-commands.js +1 -1
- package/dist/commands/define-overlays.js +1 -1
- package/dist/commands/index.d.ts +2 -1
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +6 -0
- package/dist/commands/overlays-mount.d.ts +7 -9
- package/dist/commands/overlays-mount.d.ts.map +1 -1
- package/dist/commands/shell-bridge.d.ts +57 -0
- package/dist/commands/shell-bridge.d.ts.map +1 -0
- package/dist/commands/shell-bridge.js +32 -0
- package/dist/common/errors.d.ts +35 -0
- package/dist/common/errors.d.ts.map +1 -1
- package/dist/common/errors.js +29 -1
- package/dist/common/index.d.ts +2 -0
- package/dist/common/index.d.ts.map +1 -1
- package/dist/common/index.js +6 -0
- package/dist/common/is-dev.d.ts.map +1 -0
- package/dist/{motion/index.d.ts → common/motion.d.ts} +1 -1
- package/dist/common/motion.d.ts.map +1 -0
- package/dist/common/props.d.ts +1 -1
- package/dist/common/props.d.ts.map +1 -1
- package/dist/common/validate.js +1 -1
- package/dist/content/markdown.d.ts +3 -4
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +1 -4
- package/dist/controls/actions.d.ts +1 -1
- package/dist/controls/actions.d.ts.map +1 -1
- package/dist/controls/panel-row.js +1 -1
- package/dist/controls/range-filter.js +1 -1
- package/dist/dashboard/delta-badge.d.ts +18 -10
- package/dist/dashboard/delta-badge.d.ts.map +1 -1
- package/dist/dashboard/delta-badge.js +33 -1
- package/dist/dashboard/empty-state.d.ts +1 -4
- package/dist/dashboard/empty-state.d.ts.map +1 -1
- package/dist/dashboard/empty-state.js +1 -2
- package/dist/dashboard/index.d.ts +4 -2
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +7 -1
- package/dist/dashboard/query-state.d.ts +4 -13
- package/dist/dashboard/query-state.d.ts.map +1 -1
- package/dist/dashboard/query-state.js +4 -7
- package/dist/{section → dashboard}/section.d.ts +2 -2
- package/dist/{section → dashboard}/section.d.ts.map +1 -1
- package/dist/{section → dashboard}/section.js +2 -2
- package/dist/dashboard/settings-section.d.ts +1 -1
- package/dist/dashboard/settings-section.d.ts.map +1 -1
- package/dist/dashboard/settings-section.js +1 -1
- package/dist/dashboard/stat-card.d.ts +1 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +1 -1
- package/dist/dashboard/widget-header.d.ts.map +1 -0
- package/dist/data/data-table.js +2 -2
- package/dist/data/table.d.ts +1 -1
- package/dist/data/table.d.ts.map +1 -1
- package/dist/data/table.js +2 -16
- package/dist/data/virtual.d.ts +3 -3
- package/dist/dev-dock.d.ts +28 -0
- package/dist/dev-dock.d.ts.map +1 -0
- package/dist/dev-dock.js +85 -0
- package/dist/format/date.d.ts +62 -0
- package/dist/format/date.d.ts.map +1 -0
- package/dist/format/date.js +106 -0
- package/dist/format/index.d.ts +39 -0
- package/dist/format/index.d.ts.map +1 -0
- package/dist/format/index.js +54 -0
- package/dist/format/measure.d.ts +15 -0
- package/dist/format/measure.d.ts.map +1 -0
- package/dist/format/measure.js +16 -0
- package/dist/format/number.d.ts +69 -0
- package/dist/format/number.d.ts.map +1 -0
- package/dist/format/number.js +62 -0
- package/dist/format/shared.d.ts +27 -0
- package/dist/format/shared.d.ts.map +1 -0
- package/dist/format/shared.js +29 -0
- package/dist/forms/field.d.ts +4 -20
- package/dist/forms/field.d.ts.map +1 -1
- package/dist/forms/field.js +0 -4
- package/dist/forms/form-layout.d.ts +1 -1
- package/dist/forms/form-layout.d.ts.map +1 -1
- package/dist/forms/form-layout.js +1 -1
- package/dist/forms/index.d.ts +1 -1
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/index.js +1 -2
- package/dist/forms/use-form-submit.js +1 -1
- package/dist/guard/index.d.ts +2 -9
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +16 -26
- package/dist/guard/types.d.ts +8 -10
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +16 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -29
- package/dist/notifications/index.d.ts +5 -5
- package/dist/notifications/index.d.ts.map +1 -1
- package/dist/notifications/index.js +0 -2
- package/dist/notifications/mount-guard.d.ts.map +1 -1
- package/dist/notifications/mount-guard.js +2 -2
- package/dist/provider/connectivity-indicator.d.ts.map +1 -0
- package/dist/{connectivity → provider}/connectivity-provider.d.ts +1 -1
- package/dist/provider/connectivity-provider.d.ts.map +1 -0
- package/dist/{connectivity/types.d.ts → provider/connectivity-types.d.ts} +1 -1
- package/dist/provider/connectivity-types.d.ts.map +1 -0
- package/dist/provider/error-fallback.module.css +21 -0
- package/dist/provider/error-fallback.module.css.d.ts +9 -0
- package/dist/provider/index.d.ts +20 -36
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +42 -32
- package/dist/provider/lab-theme.js +1 -1
- package/dist/provider/use-connectivity.d.ts +3 -0
- package/dist/provider/use-connectivity.d.ts.map +1 -0
- package/dist/query-client.d.ts +63 -0
- package/dist/query-client.d.ts.map +1 -0
- package/dist/query-client.js +33 -0
- package/dist/{query/devtools.d.ts → query-devtools.d.ts} +2 -2
- package/dist/query-devtools.d.ts.map +1 -0
- package/dist/register.d.ts +17 -3
- package/dist/register.d.ts.map +1 -1
- package/dist/router-tanstack/index.d.ts +4 -7
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/index.js +1 -9
- package/dist/router-tanstack/nav.d.ts +1 -1
- package/dist/router-tanstack/nav.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.d.ts +3 -6
- package/dist/router-tanstack/search-store.d.ts.map +1 -1
- package/dist/router-tanstack/use-nav.d.ts +1 -1
- package/dist/router-tanstack/use-nav.d.ts.map +1 -1
- package/dist/shell/app-breadcrumbs.d.ts +1 -1
- package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
- package/dist/shell/app-main.module.css +11 -0
- package/dist/shell/app-mobile-nav.d.ts +1 -1
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +24 -2
- package/dist/shell/app-sidebar.d.ts +1 -1
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +31 -2
- package/dist/shell/app-sidebar.module.css +10 -0
- package/dist/shell/index.d.ts +10 -4
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +13 -17
- package/dist/shell/mobile-nav-model.d.ts +1 -1
- package/dist/shell/mobile-nav-model.d.ts.map +1 -1
- package/dist/{nav/types.d.ts → shell/nav-types.d.ts} +1 -1
- package/dist/shell/nav-types.d.ts.map +1 -0
- package/dist/shell/page-aside.d.ts.map +1 -1
- package/dist/shell/page-aside.js +2 -29
- package/dist/shell/page-title.d.ts +14 -0
- package/dist/shell/page-title.d.ts.map +1 -0
- package/dist/shell/page-title.js +25 -0
- package/dist/shell/page-title.module.css +33 -0
- package/dist/shell/page-title.module.css.d.ts +11 -0
- package/dist/shell/sidebar-block-model.d.ts +1 -1
- package/dist/shell/sidebar-block-model.d.ts.map +1 -1
- package/dist/shell/sidebar-blocks.d.ts +1 -1
- package/dist/shell/sidebar-blocks.d.ts.map +1 -1
- package/dist/shell/use-breakpoint.d.ts +11 -0
- package/dist/shell/use-breakpoint.d.ts.map +1 -0
- package/dist/shell/use-breakpoint.js +33 -0
- package/dist/state/fields.d.ts +3 -19
- package/dist/state/fields.d.ts.map +1 -1
- package/dist/state/fields.js +6 -16
- package/dist/styles.css +36 -0
- package/dist/surfaces.d.ts +18 -41
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +46 -59
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/{theme-toggle/index.d.ts → theme/theme-toggle.d.ts} +1 -1
- package/dist/theme/theme-toggle.d.ts.map +1 -0
- package/dist/{theme-toggle/index.js → theme/theme-toggle.js} +1 -1
- package/dist/tokens/index.d.ts +1 -0
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +10 -0
- package/dist/tokens/palette.d.ts +12 -0
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +13 -1
- package/dist/tokens.css +1 -0
- package/llms.txt +9 -24
- package/package.json +12 -16
- package/src/agent/ai-sdk-transport.ts +11 -4
- package/src/agent/coalesce.ts +1 -1
- package/src/agent/merge.ts +1 -1
- package/src/agent/transport.ts +15 -2
- package/src/agent/use-agent-thread-runs.ts +65 -15
- package/src/agent-chat/index.ts +6 -4
- package/src/agent-chat/relative-time.ts +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +1 -1
- package/src/agent-chat/thread-feed-row.tsx +105 -30
- package/src/agent-chat/thread-feed.tsx +51 -25
- package/src/agent-chat/thread-message.tsx +1 -1
- package/src/agent-chat/virtualize.ts +48 -0
- package/src/charts/index.ts +7 -10
- package/src/charts/kinds/BandStrip.tsx +1 -1
- package/src/charts/kinds/Bars.tsx +2 -5
- package/src/charts/kinds/DualPanel.tsx +4 -3
- package/src/charts/kinds/StackedArea.tsx +1 -1
- package/src/charts/kinds/ZonedLine.tsx +1 -4
- package/src/charts/primitives/Axes.tsx +49 -0
- package/src/charts/primitives/ChartCard.tsx +49 -6
- package/src/charts/primitives/ChartFrame.tsx +5 -1
- package/src/charts/primitives/ChartPending.tsx +14 -8
- package/src/charts/utils/format.ts +17 -186
- package/src/cli/audit-allows.ts +371 -0
- package/src/cli/check-theme.ts +197 -0
- package/src/cli/doctor.ts +331 -0
- package/src/cli/guard-hook.ts +115 -0
- package/src/cli/help.ts +101 -0
- package/src/cli/index.ts +269 -3439
- package/src/cli/init.ts +247 -0
- package/src/cli/sync.ts +324 -0
- package/src/cli/tokens-css.ts +252 -0
- package/src/commands/define-commands.ts +1 -1
- package/src/commands/define-overlays.ts +1 -1
- package/src/commands/index.ts +10 -1
- package/src/commands/overlays-mount.tsx +8 -10
- package/src/commands/shell-bridge.ts +86 -0
- package/src/common/errors.ts +70 -0
- package/src/common/index.ts +2 -0
- package/src/common/props.ts +1 -1
- package/src/common/validate.ts +1 -1
- package/src/content/markdown.tsx +4 -8
- package/src/controls/actions.tsx +1 -1
- package/src/controls/panel-row.tsx +1 -1
- package/src/controls/range-filter.tsx +1 -1
- package/src/dashboard/delta-badge.tsx +74 -7
- package/src/dashboard/empty-state.tsx +2 -6
- package/src/dashboard/index.ts +11 -2
- package/src/dashboard/query-state.tsx +5 -18
- package/src/{section → dashboard}/section.tsx +4 -4
- package/src/dashboard/settings-section.tsx +2 -2
- package/src/dashboard/stat-card.tsx +2 -2
- package/src/data/data-table.tsx +3 -3
- package/src/data/table.ts +6 -25
- package/src/data/virtual-list.tsx +1 -1
- package/src/data/virtual.ts +3 -3
- package/src/dev-dock.tsx +159 -0
- package/src/format/date.ts +184 -0
- package/src/format/index.ts +69 -0
- package/src/format/measure.ts +31 -0
- package/src/format/number.ts +129 -0
- package/src/format/shared.ts +63 -0
- package/src/forms/field.ts +4 -24
- package/src/forms/form-layout.tsx +2 -2
- package/src/forms/index.ts +1 -2
- package/src/forms/use-form-submit.ts +1 -1
- package/src/guard/index.ts +30 -56
- package/src/guard/types.ts +7 -10
- package/src/index.ts +38 -29
- package/src/notifications/index.ts +5 -7
- package/src/notifications/mount-guard.ts +7 -9
- package/src/{connectivity → provider}/connectivity-indicator.tsx +1 -1
- package/src/{connectivity → provider}/connectivity-provider.tsx +1 -1
- package/src/provider/error-fallback.module.css +21 -0
- package/src/provider/error-fallback.module.css.d.ts +9 -0
- package/src/provider/index.tsx +71 -71
- package/src/provider/lab-theme.ts +1 -1
- package/src/{connectivity → provider}/use-connectivity.ts +3 -3
- package/src/query-client.ts +100 -0
- package/src/{query/devtools.tsx → query-devtools.tsx} +5 -4
- package/src/register.ts +20 -4
- package/src/router-tanstack/index.ts +4 -15
- package/src/router-tanstack/nav.ts +1 -1
- package/src/router-tanstack/search-store.ts +3 -4
- package/src/router-tanstack/use-nav.ts +1 -1
- package/src/shell/app-breadcrumbs.tsx +1 -1
- package/src/shell/app-main.module.css +11 -0
- package/src/shell/app-mobile-nav.tsx +28 -1
- package/src/shell/app-sidebar.module.css +10 -0
- package/src/shell/app-sidebar.tsx +31 -2
- package/src/shell/index.tsx +23 -28
- package/src/shell/mobile-nav-model.ts +1 -1
- package/src/shell/page-aside.tsx +2 -52
- package/src/shell/page-title.module.css +33 -0
- package/src/shell/page-title.module.css.d.ts +11 -0
- package/src/shell/page-title.tsx +57 -0
- package/src/shell/sidebar-block-model.ts +1 -1
- package/src/shell/sidebar-blocks.tsx +1 -1
- package/src/shell/use-breakpoint.ts +73 -0
- package/src/state/fields.ts +7 -43
- package/src/styles.css +36 -0
- package/src/surfaces.ts +45 -60
- package/src/theme/index.ts +7 -0
- package/src/{theme-toggle/index.tsx → theme/theme-toggle.tsx} +1 -1
- package/src/tokens/index.ts +10 -0
- package/src/tokens/palette.ts +12 -0
- package/dist/connectivity/connectivity-indicator.d.ts.map +0 -1
- package/dist/connectivity/connectivity-provider.d.ts.map +0 -1
- package/dist/connectivity/index.d.ts +0 -14
- package/dist/connectivity/index.d.ts.map +0 -1
- package/dist/connectivity/index.js +0 -8
- package/dist/connectivity/types.d.ts.map +0 -1
- package/dist/connectivity/use-connectivity.d.ts +0 -3
- package/dist/connectivity/use-connectivity.d.ts.map +0 -1
- package/dist/data/index.d.ts +0 -38
- package/dist/data/index.d.ts.map +0 -1
- package/dist/data/index.js +0 -24
- package/dist/motion/index.d.ts.map +0 -1
- package/dist/nav/types.d.ts.map +0 -1
- package/dist/notifications/overlay.d.ts +0 -20
- package/dist/notifications/overlay.d.ts.map +0 -1
- package/dist/notifications/overlay.js +0 -14
- package/dist/query/devtools.d.ts.map +0 -1
- package/dist/query/error-message.d.ts +0 -41
- package/dist/query/error-message.d.ts.map +0 -1
- package/dist/query/error-message.js +0 -30
- package/dist/query/index.d.ts +0 -47
- package/dist/query/index.d.ts.map +0 -1
- package/dist/query/index.js +0 -56
- package/dist/router-tanstack/field.d.ts +0 -14
- package/dist/router-tanstack/field.d.ts.map +0 -1
- package/dist/router-tanstack/field.js +0 -4
- package/dist/router-tanstack/multi-search-param-store.d.ts +0 -43
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +0 -1
- package/dist/router-tanstack/multi-search-param-store.js +0 -30
- package/dist/router-tanstack/search-param-store.d.ts +0 -42
- package/dist/router-tanstack/search-param-store.d.ts.map +0 -1
- package/dist/router-tanstack/search-param-store.js +0 -25
- package/dist/section/index.d.ts +0 -2
- package/dist/section/index.d.ts.map +0 -1
- package/dist/section/index.js +0 -4
- package/dist/theme-toggle/index.d.ts.map +0 -1
- package/dist/utils/is-dev.d.ts.map +0 -1
- package/dist/widget-header/delta-badge.d.ts +0 -19
- package/dist/widget-header/delta-badge.d.ts.map +0 -1
- package/dist/widget-header/delta-badge.js +0 -36
- package/dist/widget-header/index.d.ts +0 -3
- package/dist/widget-header/index.d.ts.map +0 -1
- package/dist/widget-header/index.js +0 -8
- package/dist/widget-header/widget-header.d.ts.map +0 -1
- package/src/connectivity/index.ts +0 -19
- package/src/data/index.ts +0 -69
- package/src/notifications/overlay.tsx +0 -66
- package/src/query/error-message.ts +0 -70
- package/src/query/index.ts +0 -97
- package/src/router-tanstack/field.ts +0 -36
- package/src/router-tanstack/multi-search-param-store.ts +0 -106
- package/src/router-tanstack/search-param-store.ts +0 -100
- package/src/section/index.ts +0 -1
- package/src/widget-header/delta-badge.tsx +0 -77
- package/src/widget-header/index.ts +0 -10
- /package/dist/{utils → common}/is-dev.d.ts +0 -0
- /package/dist/{utils → common}/is-dev.js +0 -0
- /package/dist/{motion/index.js → common/motion.js} +0 -0
- /package/dist/{section → dashboard}/section.module.css +0 -0
- /package/dist/{section → dashboard}/section.module.css.d.ts +0 -0
- /package/dist/{widget-header → dashboard}/widget-header.d.ts +0 -0
- /package/dist/{widget-header → dashboard}/widget-header.js +0 -0
- /package/dist/{widget-header → dashboard}/widget-header.module.css +0 -0
- /package/dist/{widget-header → dashboard}/widget-header.module.css.d.ts +0 -0
- /package/dist/{connectivity → provider}/connectivity-indicator.d.ts +0 -0
- /package/dist/{connectivity → provider}/connectivity-indicator.js +0 -0
- /package/dist/{connectivity → provider}/connectivity-provider.js +0 -0
- /package/dist/{connectivity/types.js → provider/connectivity-types.js} +0 -0
- /package/dist/{connectivity → provider}/use-connectivity.js +0 -0
- /package/dist/{query/devtools.js → query-devtools.js} +0 -0
- /package/dist/{nav/types.js → shell/nav-types.js} +0 -0
- /package/src/{utils → common}/is-dev.ts +0 -0
- /package/src/{motion/index.ts → common/motion.ts} +0 -0
- /package/src/{section → dashboard}/section.module.css +0 -0
- /package/src/{section → dashboard}/section.module.css.d.ts +0 -0
- /package/src/{widget-header → dashboard}/widget-header.module.css +0 -0
- /package/src/{widget-header → dashboard}/widget-header.module.css.d.ts +0 -0
- /package/src/{widget-header → dashboard}/widget-header.tsx +0 -0
- /package/src/{connectivity/types.ts → provider/connectivity-types.ts} +0 -0
- /package/src/{nav/types.ts → shell/nav-types.ts} +0 -0
package/src/cli/index.ts
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* basalt-ui CLI — `init`, `sync`, `check-theme`, `
|
|
2
|
+
* basalt-ui CLI — `init`, `sync`, `check-theme`, `doctor`.
|
|
3
3
|
*
|
|
4
4
|
* `checkTheme` is a thin FS walker over the headless guard core (`../guard`). It reads the
|
|
5
5
|
* BasaltConfig, builds a GuardConfig, walks the source roots, calls `checkSource` per file,
|
|
6
6
|
* collects Finding[], groups/reports findings, and returns an exit code.
|
|
7
7
|
*
|
|
8
|
-
* `checkCoverage` asserts 11 invariants: guardKinds ⊆ GUARD_RULES, rule files on disk, skill files
|
|
9
|
-
* on disk, subpath-export-coverage, exports→SURFACES reverse, globs required for non-empty
|
|
10
|
-
* forbiddenImports, headless Mantine-ban completeness, optionalPeers
|
|
11
|
-
* peerDependencies/peerDependenciesMeta presence, every plugin rule on exactly one surface, every
|
|
12
|
-
* guard kind on at least one, and the agent-layer line budgets. See `checkCoverage`'s own JSDoc for
|
|
13
|
-
* what each one buys.
|
|
14
|
-
*
|
|
15
8
|
* `init` / `sync` scaffold and reconcile the framework's *agentic* surface into a consumer repo:
|
|
16
9
|
* Claude Code rules + skills, a managed CLAUDE.md block, a DESIGN.md seed, and the toolchain
|
|
17
10
|
* seeds (oxlint / oxfmt / lefthook / CI). Both are sha256-manifest driven for safe,
|
|
@@ -36,29 +29,16 @@ import {
|
|
|
36
29
|
import { dirname, isAbsolute, relative, resolve } from 'node:path'
|
|
37
30
|
import { fileURLToPath } from 'node:url'
|
|
38
31
|
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
unmatchedExemptPatterns,
|
|
50
|
-
} from '../guard'
|
|
51
|
-
import type { Finding, GuardConfig, GuardKind, GuardSeverity } from '../guard'
|
|
52
|
-
import { evaluateGuardHook } from '../guard/guard-hook'
|
|
53
|
-
import {
|
|
54
|
-
PLUGIN_RULE_ID_LIST,
|
|
55
|
-
RULE_NAMES,
|
|
56
|
-
SKILL_NAMES,
|
|
57
|
-
SURFACES,
|
|
58
|
-
TOKEN_LAYER_BOUNDARY_SURFACES,
|
|
59
|
-
} from '../surfaces'
|
|
60
|
-
import type { DoctrineSpec, RuleName, SurfaceSpec } from '../surfaces'
|
|
61
|
-
import { buildPaletteCss, deriveSpacing } from '../tokens'
|
|
32
|
+
import { DEFAULT_GUARD_CONFIG, guardWaiverHint } from '../guard'
|
|
33
|
+
import type { GuardConfig, GuardKind, GuardSeverity } from '../guard'
|
|
34
|
+
import { RULE_NAMES, SKILL_NAMES } from '../surfaces'
|
|
35
|
+
import { checkTheme } from './check-theme'
|
|
36
|
+
import { doctor } from './doctor'
|
|
37
|
+
import { guardHook } from './guard-hook'
|
|
38
|
+
import { COMMAND_OPTIONS, USAGE, unknownFlag } from './help'
|
|
39
|
+
import { init } from './init'
|
|
40
|
+
import { sync } from './sync'
|
|
41
|
+
import { fontsCss, normalizeColorFunctions, tokensCss } from './tokens-css'
|
|
62
42
|
|
|
63
43
|
/** Shape of the optional `"basalt"` key in a consumer's package.json. */
|
|
64
44
|
export type BasaltConfig = {
|
|
@@ -165,12 +145,6 @@ export type BasaltConfig = {
|
|
|
165
145
|
* (ON). Set `false` to disable the `raw-motion-value` check.
|
|
166
146
|
*/
|
|
167
147
|
rawMotionValue?: boolean
|
|
168
|
-
/**
|
|
169
|
-
* Flag a hand-rolled `<ChartLegend items={[...]}>` array literal — legend entries must be
|
|
170
|
-
* derived (e.g. `items={deriveLegend(series)}`), never authored inline. Default: `true` (ON).
|
|
171
|
-
* Set `false` to disable the `unframed-chart` check.
|
|
172
|
-
*/
|
|
173
|
-
unframedChart?: boolean
|
|
174
148
|
/**
|
|
175
149
|
* Flag a chart entry-point JSX tag (`MultiLine`/`Bars`/`Donut`/`DualPanel`/`Heatmap`/`ZonedLine`/
|
|
176
150
|
* `StackedArea`/`LineSparkline`/`BarSparkline`) missing an `ariaLabel` prop. Default: `true`
|
|
@@ -245,16 +219,16 @@ export type BasaltConfig = {
|
|
|
245
219
|
* One `basalt.exemptRules` value: the historical bare path list, or the same list with the reason
|
|
246
220
|
* it exists. See `BasaltConfig.exemptRules`.
|
|
247
221
|
*/
|
|
248
|
-
type ExemptRuleEntry = string[] | { paths: string[]; reason: string }
|
|
222
|
+
export type ExemptRuleEntry = string[] | { paths: string[]; reason: string }
|
|
249
223
|
|
|
250
224
|
/** The paths half of an exemption entry, whichever form it was written in. */
|
|
251
|
-
function exemptRulePaths(entry: ExemptRuleEntry | undefined): string[] {
|
|
225
|
+
export function exemptRulePaths(entry: ExemptRuleEntry | undefined): string[] {
|
|
252
226
|
if (entry === undefined) return []
|
|
253
227
|
return Array.isArray(entry) ? entry : entry.paths
|
|
254
228
|
}
|
|
255
229
|
|
|
256
230
|
/** The recorded reason for an exemption entry, or null for the bare-array form. */
|
|
257
|
-
function exemptRuleReason(entry: ExemptRuleEntry | undefined): string | null {
|
|
231
|
+
export function exemptRuleReason(entry: ExemptRuleEntry | undefined): string | null {
|
|
258
232
|
if (entry === undefined || Array.isArray(entry)) return null
|
|
259
233
|
return entry.reason.trim().length > 0 ? entry.reason.trim() : null
|
|
260
234
|
}
|
|
@@ -264,7 +238,7 @@ function exemptRuleReason(entry: ExemptRuleEntry | undefined): string | null {
|
|
|
264
238
|
* the guard: it is accountability for a human reading a diff and for `--audit-allows`, and giving
|
|
265
239
|
* the scan a second shape to understand would only be a way for the two to disagree.
|
|
266
240
|
*/
|
|
267
|
-
function resolveExemptRules(cfg: BasaltConfig): GuardConfig['exemptRules'] {
|
|
241
|
+
export function resolveExemptRules(cfg: BasaltConfig): GuardConfig['exemptRules'] {
|
|
268
242
|
if (cfg.exemptRules === undefined) return DEFAULT_GUARD_CONFIG.exemptRules
|
|
269
243
|
const out: Partial<Record<GuardKind, string[]>> = {}
|
|
270
244
|
for (const [kind, entry] of Object.entries(cfg.exemptRules)) {
|
|
@@ -273,8 +247,8 @@ function resolveExemptRules(cfg: BasaltConfig): GuardConfig['exemptRules'] {
|
|
|
273
247
|
return out
|
|
274
248
|
}
|
|
275
249
|
|
|
276
|
-
const DEFAULT_ROOT = 'src'
|
|
277
|
-
const DEFAULT_ROOTS = [DEFAULT_ROOT]
|
|
250
|
+
export const DEFAULT_ROOT = 'src'
|
|
251
|
+
export const DEFAULT_ROOTS = [DEFAULT_ROOT]
|
|
278
252
|
|
|
279
253
|
/**
|
|
280
254
|
* The configured source roots, or the built-in default. The ONE resolution every roots-derived seed
|
|
@@ -282,7 +256,7 @@ const DEFAULT_ROOTS = [DEFAULT_ROOT]
|
|
|
282
256
|
* would let `[]` through and render an empty oxfmt glob into the seeded CI, reproducing the exact
|
|
283
257
|
* "matches zero files" break this derivation exists to prevent.
|
|
284
258
|
*/
|
|
285
|
-
function resolveRoots(cfg: BasaltConfig): string[] {
|
|
259
|
+
export function resolveRoots(cfg: BasaltConfig): string[] {
|
|
286
260
|
return cfg.roots?.length ? cfg.roots : DEFAULT_ROOTS
|
|
287
261
|
}
|
|
288
262
|
|
|
@@ -317,13 +291,13 @@ function toRootGlob(root: string): string {
|
|
|
317
291
|
* directs every consumer to put raw hex series colors there). Shares `resolveSeriesModulePath` with
|
|
318
292
|
* `buildTemplateVars`'s `{{SERIES_MODULE_PATH}}`, so the seeded path and the exemption can't drift.
|
|
319
293
|
*/
|
|
320
|
-
function defaultExempt(cfg: BasaltConfig): string[] {
|
|
294
|
+
export function defaultExempt(cfg: BasaltConfig): string[] {
|
|
321
295
|
return [resolveSeriesModulePath(cfg)]
|
|
322
296
|
}
|
|
323
297
|
|
|
324
|
-
const SKIP = /\.gen\.ts$|\.test\.[tj]sx?$|\.d\.ts$/
|
|
298
|
+
export const SKIP = /\.gen\.ts$|\.test\.[tj]sx?$|\.d\.ts$/
|
|
325
299
|
/** Filenames that look like they ARE the palette source, for the check-theme escape-hatch hint. */
|
|
326
|
-
const SERIES_MODULE_HINT_RE = /(series|palette)\.tsx?$/i
|
|
300
|
+
export const SERIES_MODULE_HINT_RE = /(series|palette)\.tsx?$/i
|
|
327
301
|
|
|
328
302
|
const SEVERITY_VALUES: readonly string[] = ['warn', 'error']
|
|
329
303
|
|
|
@@ -359,7 +333,7 @@ function sanitizeSeverity(cfg: BasaltConfig): BasaltConfig {
|
|
|
359
333
|
return { ...cfg, severity: kept as Partial<Record<GuardKind, GuardSeverity>> }
|
|
360
334
|
}
|
|
361
335
|
|
|
362
|
-
function readBasaltConfig(cwd: string): BasaltConfig {
|
|
336
|
+
export function readBasaltConfig(cwd: string): BasaltConfig {
|
|
363
337
|
try {
|
|
364
338
|
const pkg = JSON.parse(readFileSync(resolve(cwd, 'package.json'), 'utf8')) as {
|
|
365
339
|
basalt?: BasaltConfig
|
|
@@ -421,7 +395,7 @@ function walkSourceFiles(dir: string, out: string[] = []): string[] {
|
|
|
421
395
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
422
396
|
|
|
423
397
|
/** A path relative to `fromDir`, POSIX-separated and always explicitly relative (`./` / `../`). */
|
|
424
|
-
function relativePosix(fromDir: string, toPath: string): string {
|
|
398
|
+
export function relativePosix(fromDir: string, toPath: string): string {
|
|
425
399
|
const rel = relative(fromDir, toPath).replace(/\\/g, '/')
|
|
426
400
|
if (rel === '') return '.'
|
|
427
401
|
return rel.startsWith('.') ? rel : `./${rel}`
|
|
@@ -438,7 +412,7 @@ function relativePosix(fromDir: string, toPath: string): string {
|
|
|
438
412
|
* OUTWARD from the consumer dir first, then across the workspace packages — and every caller
|
|
439
413
|
* renders its paths from the answer rather than assuming the root.
|
|
440
414
|
*/
|
|
441
|
-
type BasaltInstall = {
|
|
415
|
+
export type BasaltInstall = {
|
|
442
416
|
/** Absolute path of the resolved `node_modules/basalt-ui` directory, or null when unresolvable. */
|
|
443
417
|
dir: string | null
|
|
444
418
|
/** The resolved copy's own version, or null. */
|
|
@@ -463,7 +437,7 @@ function basaltInstallAt(dir: string): { dir: string; version: string | null } |
|
|
|
463
437
|
}
|
|
464
438
|
|
|
465
439
|
/** Resolve the installed basalt-ui: cwd, then every ancestor, then the workspace packages. */
|
|
466
|
-
function findBasaltInstall(cwd: string): BasaltInstall {
|
|
440
|
+
export function findBasaltInstall(cwd: string): BasaltInstall {
|
|
467
441
|
const own = basaltInstallAt(cwd)
|
|
468
442
|
if (own !== null) return { ...own, how: 'cwd' }
|
|
469
443
|
|
|
@@ -490,127 +464,42 @@ function findBasaltInstall(cwd: string): BasaltInstall {
|
|
|
490
464
|
* root-relative form when nothing resolves (a pre-install `init`), so the seed is still the shape a
|
|
491
465
|
* later `bun install` makes true.
|
|
492
466
|
*/
|
|
493
|
-
function shippedAssetPath(install: BasaltInstall, fromDir: string, asset: string): string {
|
|
467
|
+
export function shippedAssetPath(install: BasaltInstall, fromDir: string, asset: string): string {
|
|
494
468
|
if (install.dir === null) return `./node_modules/basalt-ui/${asset}`
|
|
495
469
|
return `${relativePosix(fromDir, install.dir)}/${asset}`
|
|
496
470
|
}
|
|
497
471
|
|
|
498
|
-
/** True when `dir` is a basalt-configured project — it carries the manifest or a `basalt` key. */
|
|
499
|
-
function hasBasaltProject(dir: string): boolean {
|
|
500
|
-
if (existsSync(resolve(dir, MANIFEST_PATH))) return true
|
|
501
|
-
const raw = readIfExists(resolve(dir, 'package.json'))
|
|
502
|
-
if (raw === null) return false
|
|
503
|
-
try {
|
|
504
|
-
return (JSON.parse(raw) as { basalt?: unknown }).basalt !== undefined
|
|
505
|
-
} catch {
|
|
506
|
-
return false
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
472
|
/**
|
|
511
473
|
* Which directory a project-scoped command (`check-theme`, `doctor`) should actually read.
|
|
512
474
|
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
475
|
+
* One resolver, nothing inferred: `BASALT_CWD` when set, else the invocation cwd. A monorepo
|
|
476
|
+
* package whose install lives elsewhere sets `BASALT_CWD` explicitly (or the invoking script's
|
|
477
|
+
* `root:`/`cwd` does) rather than relying on workspace-glob discovery or an ancestor/descendant
|
|
478
|
+
* walk — both were deleted (see `resolveProjectDir`'s own doc below) as multi-repo speculation for
|
|
479
|
+
* a single-owner fleet where every consumer already runs its own commands from its own directory.
|
|
480
|
+
* The scan scope within that directory is `basalt.roots` in `package.json` (default `src`).
|
|
519
481
|
*/
|
|
520
482
|
type ProjectResolution = {
|
|
521
|
-
/** The directory to read. Equals the invocation cwd unless
|
|
483
|
+
/** The directory to read. Equals the invocation cwd unless BASALT_CWD relocated it. */
|
|
522
484
|
dir: string
|
|
523
|
-
/** The invocation cwd when the command
|
|
485
|
+
/** The invocation cwd when BASALT_CWD relocated the command away from it, else null. */
|
|
524
486
|
relocatedFrom: string | null
|
|
525
|
-
/** Candidate project dirs when relocation was impossible because several exist, else null. */
|
|
526
|
-
ambiguous: string[] | null
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/**
|
|
530
|
-
* Basalt projects in `cwd`'s immediate subtree — the fallback for a repo that declares no
|
|
531
|
-
* `workspaces` field at all.
|
|
532
|
-
*
|
|
533
|
-
* {@link collectWorkspacePackages} is the primary source and it is exact, but it answers `[]` for
|
|
534
|
-
* every repo whose root `package.json` carries no `workspaces` key. linewatch is that shape: the
|
|
535
|
-
* entire basalt consumer lives in `web/`, with no workspace declaration anywhere. Measured at its
|
|
536
|
-
* repo root, all three project-scoped commands got that wrong in a different way — `check-theme`
|
|
537
|
-
* printed `✓ no off-palette colors` having scanned ZERO files, `doctor` inferred `tokens-only` for
|
|
538
|
-
* a full Mantine consumer, and `sync` refused while naming `basalt-ui init` as the remedy, which is
|
|
539
|
-
* the one command that WOULD have written a competing install at the root. A gate that reports
|
|
540
|
-
* green having looked at nothing is the failure this resolver exists to prevent, so the search
|
|
541
|
-
* falls back to the layout when there is no declaration to read.
|
|
542
|
-
*
|
|
543
|
-
* Bounded to two levels (`web/`, `apps/admin/`), dot-directories and `node_modules` skipped, and it
|
|
544
|
-
* never descends INTO a project it found — a consumer's own nested package is not a rival candidate.
|
|
545
|
-
*/
|
|
546
|
-
function descendantProjects(cwd: string, depth = 2): string[] {
|
|
547
|
-
if (depth === 0) return []
|
|
548
|
-
const out: string[] = []
|
|
549
|
-
for (const name of subdirNames(cwd, { skipDotDirs: true })) {
|
|
550
|
-
const dir = resolve(cwd, name)
|
|
551
|
-
if (hasBasaltProject(dir)) out.push(dir)
|
|
552
|
-
else out.push(...descendantProjects(dir, depth - 1))
|
|
553
|
-
}
|
|
554
|
-
return out
|
|
555
487
|
}
|
|
556
488
|
|
|
557
489
|
/**
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
* `✓ no off-palette colors`, and printed the invention back as `basalt.roots (src)`. Its
|
|
564
|
-
* `--audit-allows` then reported `0 live` for a repo carrying a live waiver — a clean slate from
|
|
565
|
-
* the one command whose entire job is to deny one. `doctor`, standing in that same directory,
|
|
566
|
-
* already resolved the parent install and named it, so the two commands disagreed about the same
|
|
567
|
-
* repo.
|
|
568
|
-
*
|
|
569
|
-
* Bounded by {@link findRepoRoot}, inclusive: an ancestor outside the repo is not this project's
|
|
570
|
-
* config. `repoRoot === cwd` means either cwd IS the root (nothing above it to ascend to) or there
|
|
571
|
-
* is no `.git` anywhere (no bound to walk within) — both answer null, which leaves a standalone
|
|
572
|
-
* unconfigured consumer on the built-in defaults exactly as before.
|
|
490
|
+
* Which directory a project-scoped command reads: `BASALT_CWD` when set, else the invocation cwd
|
|
491
|
+
* — nothing inferred, nothing walked. Workspace-glob discovery and ancestor/descendant fallbacks
|
|
492
|
+
* (multi-repo speculation for a single-owner fleet where every consumer runs its own commands from
|
|
493
|
+
* its own directory) were deleted; a monorepo package that needs a different root sets `BASALT_CWD`
|
|
494
|
+
* explicitly, or the invoking script's `root:`/`cwd` does.
|
|
573
495
|
*/
|
|
574
|
-
function
|
|
575
|
-
const repoRoot = findRepoRoot(cwd)
|
|
576
|
-
if (repoRoot === cwd) return null
|
|
577
|
-
let current = dirname(cwd)
|
|
578
|
-
for (;;) {
|
|
579
|
-
if (hasBasaltProject(current)) return current
|
|
580
|
-
if (current === repoRoot) return null
|
|
581
|
-
const parent = dirname(current)
|
|
582
|
-
if (parent === current) return null
|
|
583
|
-
current = parent
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
function resolveProjectDir(cwd: string): ProjectResolution {
|
|
496
|
+
export function resolveProjectDir(cwd: string): ProjectResolution {
|
|
588
497
|
const override = process.env['BASALT_CWD']
|
|
589
|
-
if (override
|
|
590
|
-
|
|
591
|
-
return { dir, relocatedFrom: dir === cwd ? null : cwd, ambiguous: null }
|
|
592
|
-
}
|
|
593
|
-
if (hasBasaltProject(cwd)) return { dir: cwd, relocatedFrom: null, ambiguous: null }
|
|
594
|
-
|
|
595
|
-
const declared = collectWorkspacePackages(cwd)
|
|
596
|
-
.packages.filter((pkg) => pkg.dir !== cwd && hasBasaltProject(pkg.dir))
|
|
597
|
-
.map((pkg) => pkg.dir)
|
|
598
|
-
// The workspace declaration wins whenever there is one — it is exact, and a repo that declares
|
|
599
|
-
// its packages has already said where they are. The layout scan only answers for the repos that
|
|
600
|
-
// declare nothing; see descendantProjects.
|
|
601
|
-
const candidates = declared.length > 0 ? declared : descendantProjects(cwd)
|
|
602
|
-
if (candidates.length === 1) {
|
|
603
|
-
return { dir: candidates[0] as string, relocatedFrom: cwd, ambiguous: null }
|
|
498
|
+
if (override === undefined || override.length === 0) {
|
|
499
|
+
return { dir: cwd, relocatedFrom: null }
|
|
604
500
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
// Nothing at cwd and nothing below it: look UP before falling back to a default. See
|
|
608
|
-
// {@link ascendantProject} — the fabricated `roots: ['src']` this replaces is what let a scan
|
|
609
|
-
// pass over half a repo and an audit report zero waivers for a repo that has one.
|
|
610
|
-
const above = ascendantProject(cwd)
|
|
611
|
-
if (above !== null) return { dir: above, relocatedFrom: cwd, ambiguous: null }
|
|
612
|
-
|
|
613
|
-
return { dir: cwd, relocatedFrom: null, ambiguous: null }
|
|
501
|
+
const dir = isAbsolute(override) ? override : resolve(cwd, override)
|
|
502
|
+
return { dir, relocatedFrom: dir === cwd ? null : cwd }
|
|
614
503
|
}
|
|
615
504
|
|
|
616
505
|
/**
|
|
@@ -629,10 +518,10 @@ function resolveProjectDir(cwd: string): ProjectResolution {
|
|
|
629
518
|
* dependency in a workspace package rather than the one holding the basalt config, which is
|
|
630
519
|
* precisely this round's "reports green while enforcing nothing" failure with the guard's own
|
|
631
520
|
* hand on the switch.
|
|
632
|
-
* - `doctor`
|
|
633
|
-
*
|
|
521
|
+
* - `doctor` no longer infers a profile at all (workspace-wide Mantine detection was deleted along
|
|
522
|
+
* with the rest of the multi-repo discovery); it reads the same declaration `check-theme` does.
|
|
634
523
|
*/
|
|
635
|
-
function declaredProfile(cfg: BasaltConfig, flags: readonly string[]): DoctorProfile {
|
|
524
|
+
export function declaredProfile(cfg: BasaltConfig, flags: readonly string[]): DoctorProfile {
|
|
636
525
|
if (flags.includes('--tokens-only')) return 'tokens-only'
|
|
637
526
|
if (flags.includes('--framework')) return 'framework'
|
|
638
527
|
return cfg.profile === 'tokens-only' ? 'tokens-only' : 'framework'
|
|
@@ -644,41 +533,16 @@ function declaredProfile(cfg: BasaltConfig, flags: readonly string[]): DoctorPro
|
|
|
644
533
|
* vs `--selector-attribute`. Silently preferring one meant a CI step that accumulated both flags
|
|
645
534
|
* enforced whichever the code happened to test first.
|
|
646
535
|
*/
|
|
647
|
-
function conflictingProfileFlags(flags: readonly string[]): boolean {
|
|
536
|
+
export function conflictingProfileFlags(flags: readonly string[]): boolean {
|
|
648
537
|
return flags.includes('--tokens-only') && flags.includes('--framework')
|
|
649
538
|
}
|
|
650
539
|
|
|
651
|
-
/** True when nothing anywhere in the workspace declares Mantine — see {@link inferredProfile}. */
|
|
652
|
-
function workspaceHasMantine(cwd: string): boolean {
|
|
653
|
-
if (hasDependency(cwd, '@mantine/core')) return true
|
|
654
|
-
return collectWorkspacePackages(cwd).packages.some((pkg) =>
|
|
655
|
-
hasDependency(pkg.dir, '@mantine/core'),
|
|
656
|
-
)
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
/**
|
|
660
|
-
* `doctor`'s profile: the declaration if there is one, else inferred from no scaffold manifest and
|
|
661
|
-
* no `@mantine/core` anywhere in the workspace. Advice only — see {@link declaredProfile}.
|
|
662
|
-
*/
|
|
663
|
-
function inferredProfile(cwd: string, cfg: BasaltConfig, flags: readonly string[]): DoctorProfile {
|
|
664
|
-
if (
|
|
665
|
-
flags.includes('--tokens-only') ||
|
|
666
|
-
flags.includes('--framework') ||
|
|
667
|
-
cfg.profile !== undefined
|
|
668
|
-
) {
|
|
669
|
-
return declaredProfile(cfg, flags)
|
|
670
|
-
}
|
|
671
|
-
return !existsSync(resolve(cwd, MANIFEST_PATH)) && !workspaceHasMantine(cwd)
|
|
672
|
-
? 'tokens-only'
|
|
673
|
-
: 'framework'
|
|
674
|
-
}
|
|
675
|
-
|
|
676
540
|
/**
|
|
677
541
|
* The relative paths `check-theme` would actually scan for a given config — the ONE place the walk
|
|
678
542
|
* + `SKIP` + `exempt` filtering lives, so `doctor`'s "the guard sees zero files" check can never
|
|
679
543
|
* disagree with what `check-theme` does.
|
|
680
544
|
*/
|
|
681
|
-
function scannableFiles(cwd: string, cfg: BasaltConfig): string[] {
|
|
545
|
+
export function scannableFiles(cwd: string, cfg: BasaltConfig): string[] {
|
|
682
546
|
const exempt = new Set(cfg.exempt ?? defaultExempt(cfg))
|
|
683
547
|
const seen = new Set<string>()
|
|
684
548
|
const out: string[] = []
|
|
@@ -744,7 +608,7 @@ const PLAIN_JSON_HINT_PATH = 'manifest.json'
|
|
|
744
608
|
* member as the whole answer — plus one sentence saying which remedy is being withheld and why.
|
|
745
609
|
* The remedy text still comes from the guard's own registry; only the class mapping is decided here.
|
|
746
610
|
*/
|
|
747
|
-
function waiverHintFor(relPath: string, profile: DoctorProfile): string {
|
|
611
|
+
export function waiverHintFor(relPath: string, profile: DoctorProfile): string {
|
|
748
612
|
if (profile !== 'tokens-only' || !relPath.endsWith('.webmanifest')) {
|
|
749
613
|
return guardWaiverHint(relPath)
|
|
750
614
|
}
|
|
@@ -754,538 +618,6 @@ function waiverHintFor(relPath: string, profile: DoctorProfile): string {
|
|
|
754
618
|
)
|
|
755
619
|
}
|
|
756
620
|
|
|
757
|
-
/**
|
|
758
|
-
* Theme guard — thin FS walker over the headless `../guard` core. Reads BasaltConfig, builds a
|
|
759
|
-
* GuardConfig, walks roots, calls checkSource per file, collects Finding[], groups/reports, returns
|
|
760
|
-
* 0 (clean) / 1 (violations). A `theme-allow` comment exempts a line.
|
|
761
|
-
*
|
|
762
|
-
* Runs against `resolveProjectDir(cwd)`, not `cwd` — see that type's doc for why a root-invoked
|
|
763
|
-
* hook or CI step has to find the package the config lives in rather than scan nothing and pass.
|
|
764
|
-
*/
|
|
765
|
-
export function checkTheme(
|
|
766
|
-
invocationCwd: string = process.cwd(),
|
|
767
|
-
flags: readonly string[] = [],
|
|
768
|
-
): number {
|
|
769
|
-
if (conflictingProfileFlags(flags)) {
|
|
770
|
-
console.error(
|
|
771
|
-
'basalt-ui check-theme: --tokens-only and --framework are alternatives — pass one.',
|
|
772
|
-
)
|
|
773
|
-
return 1
|
|
774
|
-
}
|
|
775
|
-
const project = resolveProjectDir(invocationCwd)
|
|
776
|
-
if (project.ambiguous !== null) {
|
|
777
|
-
console.error(
|
|
778
|
-
`✖ basalt-ui check-theme: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} ` +
|
|
779
|
-
`packages below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
|
|
780
|
-
'run it from one of them, or set BASALT_CWD to pick.',
|
|
781
|
-
)
|
|
782
|
-
return 1
|
|
783
|
-
}
|
|
784
|
-
const cwd = project.dir
|
|
785
|
-
if (project.relocatedFrom !== null) {
|
|
786
|
-
console.log(
|
|
787
|
-
`basalt-ui check-theme: no basalt config at ${project.relocatedFrom} — running in ` +
|
|
788
|
-
`${relativePosix(project.relocatedFrom, cwd)}, where it lives.`,
|
|
789
|
-
)
|
|
790
|
-
}
|
|
791
|
-
const cfg = readBasaltConfig(cwd)
|
|
792
|
-
const roots = resolveRoots(cfg)
|
|
793
|
-
const profile = declaredProfile(cfg, flags)
|
|
794
|
-
if (profile === 'tokens-only') {
|
|
795
|
-
console.log(
|
|
796
|
-
'basalt-ui check-theme: tokens-only profile — ' +
|
|
797
|
-
`${TOKENS_ONLY_DISABLED_KINDS.size} Mantine-coupled kinds are off; the color and typography ` +
|
|
798
|
-
'kinds still apply. Pass --framework to force the full set.',
|
|
799
|
-
)
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
const guardCfg: GuardConfig = {
|
|
803
|
-
spacingSteps: cfg.spacingSteps ?? DEFAULT_GUARD_CONFIG.spacingSteps,
|
|
804
|
-
rawRadius: cfg.rawRadius ?? DEFAULT_GUARD_CONFIG.rawRadius,
|
|
805
|
-
forbiddenAccents: cfg.forbiddenAccents ?? DEFAULT_GUARD_CONFIG.forbiddenAccents,
|
|
806
|
-
mantineShadeIndex: cfg.mantineShadeIndex ?? DEFAULT_GUARD_CONFIG.mantineShadeIndex,
|
|
807
|
-
rawSurface: cfg.rawSurface ?? DEFAULT_GUARD_CONFIG.rawSurface,
|
|
808
|
-
cardWithBorder: cfg.cardWithBorder ?? DEFAULT_GUARD_CONFIG.cardWithBorder,
|
|
809
|
-
offSystemSurfaceVar: cfg.offSystemSurfaceVar ?? DEFAULT_GUARD_CONFIG.offSystemSurfaceVar,
|
|
810
|
-
rawHtmlLayout: cfg.rawHtmlLayout ?? DEFAULT_GUARD_CONFIG.rawHtmlLayout,
|
|
811
|
-
inlineSpacing: cfg.inlineSpacing ?? DEFAULT_GUARD_CONFIG.inlineSpacing,
|
|
812
|
-
inlineDisplay: cfg.inlineDisplay ?? DEFAULT_GUARD_CONFIG.inlineDisplay,
|
|
813
|
-
rawVisxAxis: cfg.rawVisxAxis ?? DEFAULT_GUARD_CONFIG.rawVisxAxis,
|
|
814
|
-
rawMotionValue: cfg.rawMotionValue ?? DEFAULT_GUARD_CONFIG.rawMotionValue,
|
|
815
|
-
unframedChart: cfg.unframedChart ?? DEFAULT_GUARD_CONFIG.unframedChart,
|
|
816
|
-
chartMissingAriaLabel: cfg.chartMissingAriaLabel ?? DEFAULT_GUARD_CONFIG.chartMissingAriaLabel,
|
|
817
|
-
rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
|
|
818
|
-
sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
|
|
819
|
-
allowComment: 'theme-allow',
|
|
820
|
-
exemptRules: resolveExemptRules(cfg),
|
|
821
|
-
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
|
|
822
|
-
...(profile === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
const findings: Finding[] = []
|
|
826
|
-
const scanned = scannableFiles(cwd, cfg)
|
|
827
|
-
for (const rel of scanned) {
|
|
828
|
-
findings.push(...checkSource(readFileSync(resolve(cwd, rel), 'utf8'), rel, guardCfg))
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
if (scanned.length === 0) {
|
|
832
|
-
// A configured-but-wrong root is never intentional, and silently scanning 0 files under the
|
|
833
|
-
// built-in defaults (argo's pre-migration layout) is the same failure mode for every other
|
|
834
|
-
// consumer — both cases fail loud instead of warn-plus-green.
|
|
835
|
-
if (cfg.roots === undefined) {
|
|
836
|
-
console.error(
|
|
837
|
-
`✖ basalt-ui check-theme: 0 files scanned — no "basalt.roots" configured in package.json, and ` +
|
|
838
|
-
`the built-in default roots (${DEFAULT_ROOTS.join(', ')}) matched zero files. ` +
|
|
839
|
-
'Set "basalt": { "roots": [...] } in package.json to point at your source directories.',
|
|
840
|
-
)
|
|
841
|
-
} else {
|
|
842
|
-
console.error(
|
|
843
|
-
`✖ basalt-ui check-theme: 0 files scanned — the configured "basalt.roots" (${roots.join(', ')}) ` +
|
|
844
|
-
'matched zero files. Check the paths in "basalt.roots" in package.json.',
|
|
845
|
-
)
|
|
846
|
-
}
|
|
847
|
-
return 1
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// The audit is a REPORT over the same scan, not a second scan with different rules — it runs
|
|
851
|
-
// after the 0-files gate so "every waiver is dead" can never mean "nothing was read".
|
|
852
|
-
if (flags.includes('--audit-allows')) return auditAllows(cwd, cfg, guardCfg, scanned)
|
|
853
|
-
|
|
854
|
-
// An exemption that matched nothing is reported, never silently honoured. A WARNING rather than a
|
|
855
|
-
// failure: the entry enforced exactly nothing before this line existed, so failing on it would
|
|
856
|
-
// break a green build at upgrade time over config that was already inert — `--audit-allows` is
|
|
857
|
-
// the lane that exits non-zero on a dead waiver, for a consumer who opts into that gate.
|
|
858
|
-
for (const unmatched of unmatchedExemptPatterns(guardCfg, scanned)) {
|
|
859
|
-
console.error(
|
|
860
|
-
`⚠ basalt-ui check-theme: "basalt.exemptRules" entry ${unmatched.kind}: ` +
|
|
861
|
-
`"${unmatched.pattern}" ` +
|
|
862
|
-
(unmatched.reason === 'unknown-kind'
|
|
863
|
-
? 'names no guard kind — it exempts nothing at all (a typo, or a kind that was renamed).'
|
|
864
|
-
: 'matched none of the scanned files — it suppresses nothing, and reads as coverage in a ' +
|
|
865
|
-
'config review while enforcing as much as an empty object.'),
|
|
866
|
-
)
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
if (findings.length === 0) {
|
|
870
|
-
console.log('✓ Theme guard: no off-palette colors.')
|
|
871
|
-
return 0
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
// Errors and warnings are reported as two separate blocks, not one list with a column: a
|
|
875
|
-
// consumer scanning the output needs "what breaks my build" answered before "what will later".
|
|
876
|
-
const errors = findings.filter((f) => f.severity === 'error')
|
|
877
|
-
const warnings = findings.filter((f) => f.severity === 'warn')
|
|
878
|
-
|
|
879
|
-
const report = (group: Finding[], heading: string): void => {
|
|
880
|
-
const byFile = new Map<string, Finding[]>()
|
|
881
|
-
for (const f of group) {
|
|
882
|
-
const list = byFile.get(f.relPath) ?? []
|
|
883
|
-
list.push(f)
|
|
884
|
-
byFile.set(f.relPath, list)
|
|
885
|
-
}
|
|
886
|
-
console.error(heading)
|
|
887
|
-
for (const [file, vs] of [...byFile].toSorted()) {
|
|
888
|
-
console.error(file)
|
|
889
|
-
for (const v of vs.toSorted((a, b) => a.line - b.line)) {
|
|
890
|
-
console.error(` ${String(v.line).padStart(4)} ${v.kind.padEnd(22)} ${v.text}`)
|
|
891
|
-
}
|
|
892
|
-
console.error('')
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
if (warnings.length > 0) {
|
|
897
|
-
report(
|
|
898
|
-
warnings,
|
|
899
|
-
// Deliberately neutral about WHY a kind is a warning. Two origins reach this block — a kind
|
|
900
|
-
// in its grace minor, and a consumer's own `basalt.severity` override — and the finding
|
|
901
|
-
// doesn't carry which. Promising promotion next minor would be wrong for the second.
|
|
902
|
-
`⚠ Theme guard: ${warnings.length} warning(s) — reported, not fatal. A kind warns while it ` +
|
|
903
|
-
'is in its grace minor, or because `basalt.severity` turned it down; fix on your own ' +
|
|
904
|
-
'schedule.\n',
|
|
905
|
-
)
|
|
906
|
-
}
|
|
907
|
-
if (errors.length > 0) {
|
|
908
|
-
report(errors, `✖ Theme guard: ${errors.length} off-palette / off-identity violation(s)\n`)
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
// Remedies come from the guard's own rule registry, never from a copy kept here: the local table
|
|
912
|
-
// this replaced had no entry for any of the five kinds 1.20.0 added, so exactly the new findings
|
|
913
|
-
// — the ones whose whole argument is "this looks correct and is not" — printed no argument.
|
|
914
|
-
const presentKinds = [...new Set(findings.map((f) => f.kind))].toSorted()
|
|
915
|
-
// And the closer is per FILE CLASS: prescribing a `theme-allow` comment to a .webmanifest (which
|
|
916
|
-
// has no comment syntax) is what pushed two consumers into a blanket exemption instead.
|
|
917
|
-
const waiverHints = [
|
|
918
|
-
...new Set(findings.map((f) => waiverHintFor(f.relPath, profile))),
|
|
919
|
-
].toSorted()
|
|
920
|
-
console.error(['Fix:', ...presentKinds.map(guardKindRemedy), ...waiverHints].join(' '))
|
|
921
|
-
|
|
922
|
-
// A violation in a file that looks like the palette source itself is likely intentional — point
|
|
923
|
-
// at the exempt escape hatch instead of leaving the author to search for it.
|
|
924
|
-
if (findings.some((f) => SERIES_MODULE_HINT_RE.test(f.relPath))) {
|
|
925
|
-
console.error(
|
|
926
|
-
'Hint: if a flagged file IS your palette/series source, exempt it via ' +
|
|
927
|
-
'"basalt": { "exempt": ["<path>"] } in package.json.',
|
|
928
|
-
)
|
|
929
|
-
}
|
|
930
|
-
// Warnings alone pass. That is the whole point of the grace minor: a consumer takes the upgrade
|
|
931
|
-
// on a green build and schedules the fix, instead of the release scheduling it for them.
|
|
932
|
-
return errors.length > 0 ? 1 : 0
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
// ── check-theme --audit-allows — every waiver, and whether it still suppresses anything ─────────
|
|
936
|
-
|
|
937
|
-
/** `relPath:line:kind` — a finding's identity for set arithmetic across two runs of the same file. */
|
|
938
|
-
function findingKey(f: Finding): string {
|
|
939
|
-
return `${f.relPath}:${f.line}:${f.kind}`
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
/** One annotation the guard cannot judge, queued for the oxlint half of the audit. */
|
|
943
|
-
type PluginProbe = {
|
|
944
|
-
/** Index into the report's line array, so the verdict lands where the placeholder sits. */
|
|
945
|
-
readonly slot: number
|
|
946
|
-
readonly rel: string
|
|
947
|
-
/** 1-based line of the annotation, as {@link findAllowAnnotations} reports it. */
|
|
948
|
-
readonly line: number
|
|
949
|
-
readonly ids: readonly string[]
|
|
950
|
-
readonly site: string
|
|
951
|
-
readonly suffix: string
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
/** One oxlint diagnostic, as `--format=json` renders it — only the fields the audit reads. */
|
|
955
|
-
type OxlintDiagnostic = {
|
|
956
|
-
code?: unknown
|
|
957
|
-
filename?: unknown
|
|
958
|
-
labels?: { span?: { line?: unknown } }[]
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
/** The nearest `node_modules/.bin/<name>` walking up from `dir`, else `name` if it is on PATH. */
|
|
962
|
-
function resolveToolBin(dir: string, name: string): string | null {
|
|
963
|
-
let current = dir
|
|
964
|
-
for (;;) {
|
|
965
|
-
const candidate = resolve(current, 'node_modules/.bin', name)
|
|
966
|
-
if (existsSync(candidate)) return candidate
|
|
967
|
-
const parent = resolve(current, '..')
|
|
968
|
-
if (parent === current) break
|
|
969
|
-
current = parent
|
|
970
|
-
}
|
|
971
|
-
const probe = spawnSync(name, ['--version'], { stdio: 'ignore', timeout: 10_000 })
|
|
972
|
-
return probe.error === undefined && probe.status === 0 ? name : null
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
/** `line:rule` counts for one file's diagnostics — a multiset, so two findings on a line differ. */
|
|
976
|
-
function diagnosticCounts(
|
|
977
|
-
diagnostics: readonly OxlintDiagnostic[],
|
|
978
|
-
filename: string,
|
|
979
|
-
): Map<string, number> {
|
|
980
|
-
const counts = new Map<string, number>()
|
|
981
|
-
for (const d of diagnostics) {
|
|
982
|
-
if (d.filename !== filename) continue
|
|
983
|
-
const code = typeof d.code === 'string' ? d.code : ''
|
|
984
|
-
const rule = /^basalt\((.+)\)$/.exec(code)?.[1]
|
|
985
|
-
if (rule === undefined) continue
|
|
986
|
-
const line = d.labels?.[0]?.span?.line
|
|
987
|
-
const key = `${typeof line === 'number' ? line : 0}:${rule}`
|
|
988
|
-
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
989
|
-
}
|
|
990
|
-
return counts
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
/**
|
|
994
|
-
* The oxlint half of the audit: what each plugin-rule annotation still suppresses.
|
|
995
|
-
*
|
|
996
|
-
* The `check-theme` half proves a verdict by re-running `checkSource` with one occurrence
|
|
997
|
-
* neutralized. The `error`-severity design rules do not live there — they live in the oxlint
|
|
998
|
-
* plugin — so the audit declined to judge them and printed "not a check-theme kind". In a
|
|
999
|
-
* chart-heavy consumer that was 8 of 8 and 11 of 14: a CI gate that exited 0 having inspected
|
|
1000
|
-
* nothing. The method generalizes exactly, one level over: neutralize the annotation, re-run
|
|
1001
|
-
* oxlint over that file, and the findings that appear are what it covers.
|
|
1002
|
-
*
|
|
1003
|
-
* oxlint has no stdin mode, so a probe has to be a real file. It is written BESIDE the original —
|
|
1004
|
-
* same directory, so the same `.oxlintrc.json`, the same `charts/` path segment, the same nearest
|
|
1005
|
-
* `package.json` — and removed in a `finally`. Returns null when oxlint cannot be run at all, which
|
|
1006
|
-
* is a "cannot judge", never a "dead".
|
|
1007
|
-
*/
|
|
1008
|
-
function probePluginRules(
|
|
1009
|
-
cwd: string,
|
|
1010
|
-
guardCfg: GuardConfig,
|
|
1011
|
-
probes: readonly PluginProbe[],
|
|
1012
|
-
sources: ReadonlyMap<string, string>,
|
|
1013
|
-
): Map<number, string[]> | null {
|
|
1014
|
-
if (probes.length === 0) return new Map()
|
|
1015
|
-
const bin = resolveToolBin(cwd, 'oxlint')
|
|
1016
|
-
if (bin === null) return null
|
|
1017
|
-
|
|
1018
|
-
const written: string[] = []
|
|
1019
|
-
const probePaths = new Map<number, string>()
|
|
1020
|
-
try {
|
|
1021
|
-
for (const probe of probes) {
|
|
1022
|
-
// Neutralized by the guard's own helper, so the oxlint probe and the checkSource probe can
|
|
1023
|
-
// never differ by a substitution detail — a verdict that disagrees between the two halves of
|
|
1024
|
-
// one audit is worse than no verdict.
|
|
1025
|
-
const probed = neutralizeAllowAnnotation(sources.get(probe.rel) ?? '', probe.line, guardCfg)
|
|
1026
|
-
const dot = probe.rel.lastIndexOf('.')
|
|
1027
|
-
const rel =
|
|
1028
|
-
dot === -1
|
|
1029
|
-
? `${probe.rel}.basalt-audit-${probe.slot}`
|
|
1030
|
-
: `${probe.rel.slice(0, dot)}.basalt-audit-${probe.slot}${probe.rel.slice(dot)}`
|
|
1031
|
-
const abs = resolve(cwd, rel)
|
|
1032
|
-
if (existsSync(abs)) continue
|
|
1033
|
-
writeFileSync(abs, probed, 'utf8')
|
|
1034
|
-
written.push(abs)
|
|
1035
|
-
probePaths.set(probe.slot, rel)
|
|
1036
|
-
}
|
|
1037
|
-
if (probePaths.size === 0) return null
|
|
1038
|
-
|
|
1039
|
-
const targets = [...new Set(probes.map((p) => p.rel)), ...probePaths.values()]
|
|
1040
|
-
const run = spawnSync(bin, ['--format=json', '--no-error-on-unmatched-pattern', ...targets], {
|
|
1041
|
-
cwd,
|
|
1042
|
-
encoding: 'utf8',
|
|
1043
|
-
timeout: 120_000,
|
|
1044
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1045
|
-
})
|
|
1046
|
-
if (run.error !== undefined || typeof run.stdout !== 'string') return null
|
|
1047
|
-
let diagnostics: OxlintDiagnostic[]
|
|
1048
|
-
try {
|
|
1049
|
-
diagnostics =
|
|
1050
|
-
(JSON.parse(run.stdout) as { diagnostics?: OxlintDiagnostic[] }).diagnostics ?? []
|
|
1051
|
-
} catch {
|
|
1052
|
-
return null
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
const verdicts = new Map<number, string[]>()
|
|
1056
|
-
for (const probe of probes) {
|
|
1057
|
-
const probeRel = probePaths.get(probe.slot)
|
|
1058
|
-
if (probeRel === undefined) continue
|
|
1059
|
-
const before = diagnosticCounts(diagnostics, probe.rel)
|
|
1060
|
-
const after = diagnosticCounts(diagnostics, probeRel)
|
|
1061
|
-
const revealed: string[] = []
|
|
1062
|
-
for (const [key, count] of after) {
|
|
1063
|
-
if (count <= (before.get(key) ?? 0)) continue
|
|
1064
|
-
const [line = '', rule = ''] = key.split(':')
|
|
1065
|
-
revealed.push(`${rule}@${line}`)
|
|
1066
|
-
}
|
|
1067
|
-
verdicts.set(probe.slot, revealed.toSorted())
|
|
1068
|
-
}
|
|
1069
|
-
return verdicts
|
|
1070
|
-
} finally {
|
|
1071
|
-
for (const abs of written) {
|
|
1072
|
-
try {
|
|
1073
|
-
unlinkSync(abs)
|
|
1074
|
-
} catch {
|
|
1075
|
-
// The probe is a temp file basalt owns; a failed unlink must not mask the audit's verdict.
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
/**
|
|
1082
|
-
* `check-theme --audit-allows` — list every active waiver and prove, per waiver, whether it still
|
|
1083
|
-
* suppresses anything.
|
|
1084
|
-
*
|
|
1085
|
-
* The accountability release shipped `theme-allow-unscoped`, which reports a waiver that names no
|
|
1086
|
-
* rule — but a waiver that is perfectly well written and covers a finding that no longer exists is
|
|
1087
|
-
* invisible to it, and to everything else. Two consumers found five of those by hand, by editing
|
|
1088
|
-
* the token out and re-running the guard. This is that method, automated: for each annotation the
|
|
1089
|
-
* file is re-checked with THAT occurrence neutralized, and the findings that appear are exactly
|
|
1090
|
-
* what it suppresses. Nothing is inferred from the annotation's text, so the audit cannot disagree
|
|
1091
|
-
* with the scan — it IS the scan, run twice.
|
|
1092
|
-
*
|
|
1093
|
-
* `basalt.exemptRules` entries are audited the same way, one pattern at a time. That also answers
|
|
1094
|
-
* the question the key could never answer for itself: a pattern that matches no scanned file (a
|
|
1095
|
-
* real relative path, say, where the matcher takes whole path SEGMENTS) suppresses nothing and is
|
|
1096
|
-
* reported as dead rather than passing silently.
|
|
1097
|
-
*
|
|
1098
|
-
* Exit 1 when anything is dead, so it can be wired into CI as a gate. A waiver nobody re-checks is
|
|
1099
|
-
* how a rule quietly stops applying to the file that needed the exception most.
|
|
1100
|
-
*/
|
|
1101
|
-
function auditAllows(
|
|
1102
|
-
cwd: string,
|
|
1103
|
-
cfg: BasaltConfig,
|
|
1104
|
-
guardCfg: GuardConfig,
|
|
1105
|
-
scanned: string[],
|
|
1106
|
-
): number {
|
|
1107
|
-
const token = guardCfg.allowComment
|
|
1108
|
-
const lines: string[] = [`\nbasalt-ui check-theme --audit-allows — ${cwd}\n`]
|
|
1109
|
-
let dead = 0
|
|
1110
|
-
let live = 0
|
|
1111
|
-
let outOfReach = 0
|
|
1112
|
-
let unaccountable = 0
|
|
1113
|
-
|
|
1114
|
-
// ── theme-allow annotations ─────────────────────────────────────────────────
|
|
1115
|
-
const waiverLines: string[] = []
|
|
1116
|
-
const pluginProbes: PluginProbe[] = []
|
|
1117
|
-
const sources = new Map<string, string>()
|
|
1118
|
-
for (const rel of scanned) sources.set(rel, readFileSync(resolve(cwd, rel), 'utf8'))
|
|
1119
|
-
|
|
1120
|
-
for (const rel of scanned) {
|
|
1121
|
-
const text = sources.get(rel) ?? ''
|
|
1122
|
-
if (!text.includes(token)) continue
|
|
1123
|
-
const baseline = checkSource(text, rel, guardCfg)
|
|
1124
|
-
const baselineKeys = new Set(
|
|
1125
|
-
baseline.filter((f) => f.kind !== 'theme-allow-unscoped').map(findingKey),
|
|
1126
|
-
)
|
|
1127
|
-
const unscopedAt = new Map(
|
|
1128
|
-
baseline.filter((f) => f.kind === 'theme-allow-unscoped').map((f) => [f.line, f.token]),
|
|
1129
|
-
)
|
|
1130
|
-
// Enumerated by the guard, never by a regex kept here: `findAllowAnnotations` shares its
|
|
1131
|
-
// collector with `checkSource`, so the audit lists exactly the annotations the scan honours.
|
|
1132
|
-
// The private mirror this replaced was already one comment shape behind.
|
|
1133
|
-
for (const site of findAllowAnnotations(text, rel, guardCfg)) {
|
|
1134
|
-
const revealed = checkSource(
|
|
1135
|
-
neutralizeAllowAnnotation(text, site.line, guardCfg),
|
|
1136
|
-
rel,
|
|
1137
|
-
guardCfg,
|
|
1138
|
-
)
|
|
1139
|
-
.filter((f) => f.kind !== 'theme-allow-unscoped' && !baselineKeys.has(findingKey(f)))
|
|
1140
|
-
.map((f) => `${f.kind}@${f.line}`)
|
|
1141
|
-
const note = unscopedAt.get(site.line)
|
|
1142
|
-
if (note !== undefined) unaccountable++
|
|
1143
|
-
const label = ` ${rel}:${site.line}`.padEnd(52)
|
|
1144
|
-
const suffix = note === undefined ? '' : ` [${note}]`
|
|
1145
|
-
if (revealed.length > 0) {
|
|
1146
|
-
live++
|
|
1147
|
-
waiverLines.push(`${label} suppresses ${[...new Set(revealed)].join(', ')}${suffix}`)
|
|
1148
|
-
continue
|
|
1149
|
-
}
|
|
1150
|
-
// Nothing in check-theme's reach moved. If the annotation names a PLUGIN rule, the oxlint
|
|
1151
|
-
// half decides it — a placeholder holds the slot so the report stays in file/line order.
|
|
1152
|
-
if (site.pluginRules.length > 0 && site.guardKinds.length === 0) {
|
|
1153
|
-
pluginProbes.push({
|
|
1154
|
-
slot: waiverLines.length,
|
|
1155
|
-
rel,
|
|
1156
|
-
line: site.line,
|
|
1157
|
-
ids: site.pluginRules,
|
|
1158
|
-
site: label,
|
|
1159
|
-
suffix,
|
|
1160
|
-
})
|
|
1161
|
-
waiverLines.push('')
|
|
1162
|
-
continue
|
|
1163
|
-
}
|
|
1164
|
-
if (site.guardKinds.length === 0 && site.unknownRules.length > 0) {
|
|
1165
|
-
outOfReach++
|
|
1166
|
-
waiverLines.push(
|
|
1167
|
-
`${label} scoped to ${site.unknownRules.join(', ')} — names no guard kind and no ` +
|
|
1168
|
-
'plugin rule, so this audit cannot judge it (most likely a typo, which ' +
|
|
1169
|
-
`theme-allow-unscoped reports)${suffix}`,
|
|
1170
|
-
)
|
|
1171
|
-
continue
|
|
1172
|
-
}
|
|
1173
|
-
dead++
|
|
1174
|
-
waiverLines.push(`${label} SUPPRESSES NOTHING — dead, delete it${suffix}`)
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
// ── the oxlint half — plugin-rule annotations, judged the same way ──────────
|
|
1179
|
-
const pluginVerdicts = probePluginRules(cwd, guardCfg, pluginProbes, sources)
|
|
1180
|
-
for (const probe of pluginProbes) {
|
|
1181
|
-
const revealed = pluginVerdicts?.get(probe.slot)
|
|
1182
|
-
if (revealed === undefined) {
|
|
1183
|
-
outOfReach++
|
|
1184
|
-
waiverLines[probe.slot] =
|
|
1185
|
-
`${probe.site} scoped to ${probe.ids.join(', ')} — an oxlint plugin rule, and oxlint ` +
|
|
1186
|
-
`could not be run here, so this audit cannot judge it${probe.suffix}`
|
|
1187
|
-
continue
|
|
1188
|
-
}
|
|
1189
|
-
if (revealed.length > 0) {
|
|
1190
|
-
live++
|
|
1191
|
-
waiverLines[probe.slot] =
|
|
1192
|
-
`${probe.site} suppresses ${revealed.join(', ')} (oxlint)${probe.suffix}`
|
|
1193
|
-
continue
|
|
1194
|
-
}
|
|
1195
|
-
dead++
|
|
1196
|
-
waiverLines[probe.slot] =
|
|
1197
|
-
`${probe.site} SUPPRESSES NOTHING — dead, delete it (proved by re-running oxlint over the ` +
|
|
1198
|
-
`file with this annotation neutralized)${probe.suffix}`
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
lines.push(`${token} annotations (${waiverLines.length}):`)
|
|
1202
|
-
lines.push(...(waiverLines.length === 0 ? [' (none)'] : waiverLines))
|
|
1203
|
-
|
|
1204
|
-
// ── basalt.exemptRules entries ──────────────────────────────────────────────
|
|
1205
|
-
const exemptLines: string[] = []
|
|
1206
|
-
let exemptCount = 0
|
|
1207
|
-
const declared = Object.entries(cfg.exemptRules ?? {}) as [GuardKind, ExemptRuleEntry][]
|
|
1208
|
-
if (declared.length > 0) {
|
|
1209
|
-
const baselineKeys = new Set<string>()
|
|
1210
|
-
for (const rel of scanned) {
|
|
1211
|
-
for (const f of checkSource(sources.get(rel) ?? '', rel, guardCfg))
|
|
1212
|
-
baselineKeys.add(findingKey(f))
|
|
1213
|
-
}
|
|
1214
|
-
for (const [kind, entry] of declared) {
|
|
1215
|
-
const reason = exemptRuleReason(entry)
|
|
1216
|
-
for (const pattern of exemptRulePaths(entry)) {
|
|
1217
|
-
exemptCount++
|
|
1218
|
-
const probeCfg: GuardConfig = {
|
|
1219
|
-
...guardCfg,
|
|
1220
|
-
exemptRules: {
|
|
1221
|
-
...guardCfg.exemptRules,
|
|
1222
|
-
[kind]: exemptRulePaths(entry).filter((p) => p !== pattern),
|
|
1223
|
-
},
|
|
1224
|
-
}
|
|
1225
|
-
const revealed = new Set<string>()
|
|
1226
|
-
for (const rel of scanned) {
|
|
1227
|
-
for (const f of checkSource(sources.get(rel) ?? '', rel, probeCfg)) {
|
|
1228
|
-
if (!baselineKeys.has(findingKey(f))) revealed.add(f.relPath)
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
const site = ` ${kind}: "${pattern}"`.padEnd(52)
|
|
1232
|
-
const why = reason === null ? ' [no reason recorded]' : ` — ${reason}`
|
|
1233
|
-
if (revealed.size === 0) {
|
|
1234
|
-
dead++
|
|
1235
|
-
const unmatched = unmatchedExemptPatterns(guardCfg, scanned).find(
|
|
1236
|
-
(u) => u.kind === kind && u.pattern === pattern,
|
|
1237
|
-
)
|
|
1238
|
-
exemptLines.push(
|
|
1239
|
-
`${site} SUPPRESSES NOTHING — ` +
|
|
1240
|
-
(unmatched === undefined
|
|
1241
|
-
? 'the files it matches have no such finding; delete it'
|
|
1242
|
-
: unmatched.reason === 'unknown-kind'
|
|
1243
|
-
? 'it names no guard kind (a typo, or a renamed kind)'
|
|
1244
|
-
: 'it matches no scanned file at all') +
|
|
1245
|
-
why,
|
|
1246
|
-
)
|
|
1247
|
-
} else {
|
|
1248
|
-
live++
|
|
1249
|
-
exemptLines.push(
|
|
1250
|
-
`${site} suppresses findings in ${[...revealed].toSorted().join(', ')}${why}`,
|
|
1251
|
-
)
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
lines.push(`\nbasalt.exemptRules entries (${exemptCount}):`)
|
|
1257
|
-
lines.push(...(exemptLines.length === 0 ? [' (none)'] : exemptLines))
|
|
1258
|
-
|
|
1259
|
-
lines.push(
|
|
1260
|
-
`\n${live} live, ${dead} dead, ${outOfReach} unjudgeable, ${unaccountable} ` +
|
|
1261
|
-
'unaccountable (reported as theme-allow-unscoped by a normal run).',
|
|
1262
|
-
)
|
|
1263
|
-
// The audit reads exactly what `check-theme` reads, and that is `scannableFiles` — which is
|
|
1264
|
-
// WIDER than `basalt.roots`: it also takes each root's sibling `index.html` and `public/` tree
|
|
1265
|
-
// (see appShellFiles) and anything named in `basalt.include`. Two consumers reported a live
|
|
1266
|
-
// `public/site.webmanifest` waiver under a scope line saying files outside `roots` are not
|
|
1267
|
-
// audited; the line was wrong, not the scan. Saying the scope out loud is the difference between
|
|
1268
|
-
// "0 dead" and "0 dead, over these files" — so it has to name every class actually reached.
|
|
1269
|
-
const includes = cfg.include ?? []
|
|
1270
|
-
lines.push(
|
|
1271
|
-
`Scope: the ${scanned.length} file(s) check-theme scans — everything under basalt.roots ` +
|
|
1272
|
-
`(${resolveRoots(cfg).join(', ')}), plus each root's sibling index.html and public/ tree, ` +
|
|
1273
|
-
(includes.length === 0
|
|
1274
|
-
? 'plus anything named in basalt.include (none). '
|
|
1275
|
-
: `plus basalt.include (${includes.join(', ')}). `) +
|
|
1276
|
-
'A waiver in a file none of those reach is not audited — widen roots, or name it in ' +
|
|
1277
|
-
'basalt.include, or accept that nothing polices that file.',
|
|
1278
|
-
)
|
|
1279
|
-
if (dead > 0) {
|
|
1280
|
-
lines.push(
|
|
1281
|
-
'A dead waiver is not harmless: it is an exception nobody re-checked, and it will silently ' +
|
|
1282
|
-
'cover the next real finding on that line.',
|
|
1283
|
-
)
|
|
1284
|
-
}
|
|
1285
|
-
console.log(lines.join('\n'))
|
|
1286
|
-
return dead > 0 ? 1 : 0
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
621
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
1290
622
|
// init / sync — managed-files engine
|
|
1291
623
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -1333,7 +665,7 @@ type ManagedFile = {
|
|
|
1333
665
|
}
|
|
1334
666
|
|
|
1335
667
|
/** Everything a template needs to resolve its `{{…}}` placeholders. */
|
|
1336
|
-
type RenderContext = {
|
|
668
|
+
export type RenderContext = {
|
|
1337
669
|
pkgRoot: string
|
|
1338
670
|
vars: TemplateVars
|
|
1339
671
|
}
|
|
@@ -1366,19 +698,19 @@ type TemplateVars = {
|
|
|
1366
698
|
// (`<!-- basalt:begin 1.0.0 -->`), so the region is matched by the begin PREFIX, not an exact string.
|
|
1367
699
|
const BLOCK_BEGIN_PREFIX = '<!-- basalt:begin'
|
|
1368
700
|
const BLOCK_END = '<!-- basalt:end -->'
|
|
1369
|
-
const MANIFEST_PATH = '.basalt/manifest.json'
|
|
701
|
+
export const MANIFEST_PATH = '.basalt/manifest.json'
|
|
1370
702
|
|
|
1371
|
-
function sha256(text: string): string {
|
|
703
|
+
export function sha256(text: string): string {
|
|
1372
704
|
return createHash('sha256').update(text, 'utf8').digest('hex')
|
|
1373
705
|
}
|
|
1374
706
|
|
|
1375
|
-
function readIfExists(abs: string): string | null {
|
|
707
|
+
export function readIfExists(abs: string): string | null {
|
|
1376
708
|
if (!existsSync(abs)) return null
|
|
1377
709
|
return readFileSync(abs, 'utf8')
|
|
1378
710
|
}
|
|
1379
711
|
|
|
1380
712
|
/** Read a shipped source asset from the package root; null if the sibling hasn't authored it yet. */
|
|
1381
|
-
function readSource(pkgRoot: string, source: string): string | null {
|
|
713
|
+
export function readSource(pkgRoot: string, source: string): string | null {
|
|
1382
714
|
return readIfExists(resolve(pkgRoot, source))
|
|
1383
715
|
}
|
|
1384
716
|
|
|
@@ -1393,7 +725,7 @@ function readPackageName(cwd: string): string {
|
|
|
1393
725
|
}
|
|
1394
726
|
|
|
1395
727
|
/** The framework's own published version, read from the package root package.json. */
|
|
1396
|
-
function readFrameworkVersion(pkgRoot: string): string {
|
|
728
|
+
export function readFrameworkVersion(pkgRoot: string): string {
|
|
1397
729
|
try {
|
|
1398
730
|
const pkg = JSON.parse(readFileSync(resolve(pkgRoot, 'package.json'), 'utf8')) as {
|
|
1399
731
|
version?: string
|
|
@@ -1414,7 +746,7 @@ function readFrameworkVersion(pkgRoot: string): string {
|
|
|
1414
746
|
* placed here. Every emitted invocation goes through this function so there is one place for it to
|
|
1415
747
|
* be right.
|
|
1416
748
|
*/
|
|
1417
|
-
function basaltBinCommand(install: BasaltInstall, cwd: string): string {
|
|
749
|
+
export function basaltBinCommand(install: BasaltInstall, cwd: string): string {
|
|
1418
750
|
if (install.dir === null) return 'bunx basalt-ui'
|
|
1419
751
|
const binDir = resolve(install.dir, '..', '.bin')
|
|
1420
752
|
return `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`
|
|
@@ -1481,7 +813,7 @@ function applyBlock(host: string, block: string): string {
|
|
|
1481
813
|
* Explicit `--with-router` / `--with-query` CLI flags force-include a router/query scaffold even
|
|
1482
814
|
* when the peer isn't detected in the consumer's package.json yet (e.g. it's about to be added).
|
|
1483
815
|
*/
|
|
1484
|
-
type ScaffoldFlags = {
|
|
816
|
+
export type ScaffoldFlags = {
|
|
1485
817
|
withRouter?: boolean
|
|
1486
818
|
withQuery?: boolean
|
|
1487
819
|
/** `--merge-lint`: splice the shipped preset's `extends` into an `.oxlintrc.json` init would keep. */
|
|
@@ -1489,7 +821,7 @@ type ScaffoldFlags = {
|
|
|
1489
821
|
}
|
|
1490
822
|
|
|
1491
823
|
/** Router/query peer presence, resolved from detection + explicit flags. */
|
|
1492
|
-
type PeerFlags = { hasRouter: boolean; hasQuery: boolean }
|
|
824
|
+
export type PeerFlags = { hasRouter: boolean; hasQuery: boolean }
|
|
1493
825
|
|
|
1494
826
|
/** True when `pkgName` appears in the consumer's dependencies, devDependencies, or peerDependencies. */
|
|
1495
827
|
function hasDependency(cwd: string, pkgName: string): boolean {
|
|
@@ -1515,7 +847,7 @@ function hasDependency(cwd: string, pkgName: string): boolean {
|
|
|
1515
847
|
* which seed scaffolds to write) and `basalt-ui sync` (so it doesn't silently re-seed a scaffold whose
|
|
1516
848
|
* peer was never installed) call this once, up front.
|
|
1517
849
|
*/
|
|
1518
|
-
function resolvePeerFlags(cwd: string, flags: ScaffoldFlags): PeerFlags {
|
|
850
|
+
export function resolvePeerFlags(cwd: string, flags: ScaffoldFlags): PeerFlags {
|
|
1519
851
|
return {
|
|
1520
852
|
hasRouter: flags.withRouter === true || hasDependency(cwd, '@tanstack/react-router'),
|
|
1521
853
|
hasQuery: flags.withQuery === true || hasDependency(cwd, '@tanstack/react-query'),
|
|
@@ -1527,7 +859,7 @@ function resolvePeerFlags(cwd: string, flags: ScaffoldFlags): PeerFlags {
|
|
|
1527
859
|
* repo-root-shaped seeds are safe to write. `init`/`sync` compute it once per run via
|
|
1528
860
|
* `resolvePlacement` and print the matching skip notices themselves; `managedFiles` only reads it.
|
|
1529
861
|
*/
|
|
1530
|
-
type PlacementFlags = {
|
|
862
|
+
export type PlacementFlags = {
|
|
1531
863
|
/** True when the package directory IS the detected repo root, or no `.git` exists anywhere above
|
|
1532
864
|
* it (a consumer not using git keeps today's behaviour exactly). False means the package lives
|
|
1533
865
|
* in a subdirectory of the repo (a monorepo's `web/`, say) — lefthook and GitHub Actions both
|
|
@@ -1545,7 +877,7 @@ type PlacementFlags = {
|
|
|
1545
877
|
}
|
|
1546
878
|
|
|
1547
879
|
/** The default placement — today's single-package-at-repo-root behaviour, unconditionally. */
|
|
1548
|
-
const ROOT_PLACEMENT: PlacementFlags = {
|
|
880
|
+
export const ROOT_PLACEMENT: PlacementFlags = {
|
|
1549
881
|
isPackageRepoRoot: true,
|
|
1550
882
|
repoRoot: '',
|
|
1551
883
|
relocatedQueryClient: null,
|
|
@@ -1557,7 +889,7 @@ const ROOT_PLACEMENT: PlacementFlags = {
|
|
|
1557
889
|
* Stops at the filesystem root. Returns `dir` itself when no `.git` is found anywhere, so a
|
|
1558
890
|
* consumer not using git falls back to today's behaviour exactly.
|
|
1559
891
|
*/
|
|
1560
|
-
function findRepoRoot(dir: string): string {
|
|
892
|
+
export function findRepoRoot(dir: string): string {
|
|
1561
893
|
let current = dir
|
|
1562
894
|
for (;;) {
|
|
1563
895
|
if (existsSync(resolve(current, '.git'))) return current
|
|
@@ -1610,7 +942,7 @@ function findRelocatedQueryClient(cwd: string): string | null {
|
|
|
1610
942
|
}
|
|
1611
943
|
|
|
1612
944
|
/** Resolve a run's placement flags once, from the consumer cwd. */
|
|
1613
|
-
function resolvePlacement(cwd: string): PlacementFlags {
|
|
945
|
+
export function resolvePlacement(cwd: string): PlacementFlags {
|
|
1614
946
|
const repoRoot = findRepoRoot(cwd)
|
|
1615
947
|
return {
|
|
1616
948
|
isPackageRepoRoot: repoRoot === cwd,
|
|
@@ -1620,7 +952,10 @@ function resolvePlacement(cwd: string): PlacementFlags {
|
|
|
1620
952
|
}
|
|
1621
953
|
|
|
1622
954
|
/** The full managed-file manifest. Stable, declarative — the single source of truth for init/sync. */
|
|
1623
|
-
function managedFiles(
|
|
955
|
+
export function managedFiles(
|
|
956
|
+
peers: PeerFlags,
|
|
957
|
+
placement: PlacementFlags = ROOT_PLACEMENT,
|
|
958
|
+
): ManagedFile[] {
|
|
1624
959
|
// ── managed: exactly what Claude reads ──────────────────────────────────────
|
|
1625
960
|
const rules: ManagedFile[] = RULE_NAMES.map((name) => ({
|
|
1626
961
|
dest: `.claude/rules/basalt-${name}.md`,
|
|
@@ -1779,7 +1114,7 @@ function managedFiles(peers: PeerFlags, placement: PlacementFlags = ROOT_PLACEME
|
|
|
1779
1114
|
return [...rules, ...skills, claudeBlock, design, oxfmt, ...rootTooling, oxlintrc, ...scaffolds]
|
|
1780
1115
|
}
|
|
1781
1116
|
|
|
1782
|
-
type Manifest = {
|
|
1117
|
+
export type Manifest = {
|
|
1783
1118
|
version: 1
|
|
1784
1119
|
/** The basalt-ui version that last wrote this manifest — `doctor`'s one reconciliation axis. */
|
|
1785
1120
|
basaltVersion?: string
|
|
@@ -1800,7 +1135,7 @@ type Manifest = {
|
|
|
1800
1135
|
spacingScale?: Record<string, number>
|
|
1801
1136
|
}
|
|
1802
1137
|
|
|
1803
|
-
function readManifest(cwd: string): Manifest {
|
|
1138
|
+
export function readManifest(cwd: string): Manifest {
|
|
1804
1139
|
const raw = readIfExists(resolve(cwd, MANIFEST_PATH))
|
|
1805
1140
|
if (raw === null) return { version: 1, files: {} }
|
|
1806
1141
|
try {
|
|
@@ -1827,26 +1162,6 @@ function readManifest(cwd: string): Manifest {
|
|
|
1827
1162
|
}
|
|
1828
1163
|
}
|
|
1829
1164
|
|
|
1830
|
-
/** The pre-rename oxfmt scaffold dest, superseded by `.oxfmtrc.json` (oxfmt's auto-discovered name). */
|
|
1831
|
-
const LEGACY_OXFMT_DEST = 'oxfmt.json'
|
|
1832
|
-
|
|
1833
|
-
/**
|
|
1834
|
-
* One-time cleanup for consumers who ran `init`/`sync` before the `.oxfmtrc.json` rename: drops the
|
|
1835
|
-
* stale `oxfmt.json` manifest entry so it stops being reconciled/recreated forever, and deletes the
|
|
1836
|
-
* on-disk file when it still byte-matches the shipped default (untouched since the last sync). A
|
|
1837
|
-
* locally-edited copy is left in place — the manifest entry alone determines "was basalt tracking
|
|
1838
|
-
* this", so a fresh consumer (no legacy entry) is a no-op.
|
|
1839
|
-
*/
|
|
1840
|
-
function migrateLegacyOxfmt(cwd: string, pkgRoot: string, manifest: Manifest): void {
|
|
1841
|
-
if (!(LEGACY_OXFMT_DEST in manifest.files)) return
|
|
1842
|
-
delete manifest.files[LEGACY_OXFMT_DEST]
|
|
1843
|
-
const legacyAbs = resolve(cwd, LEGACY_OXFMT_DEST)
|
|
1844
|
-
const onDisk = readIfExists(legacyAbs)
|
|
1845
|
-
if (onDisk === null) return
|
|
1846
|
-
const shipped = readSource(pkgRoot, 'configs/oxfmt.json')
|
|
1847
|
-
if (shipped !== null && onDisk === shipped) unlinkSync(legacyAbs)
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
1165
|
/**
|
|
1851
1166
|
* The two managed NAMESPACES whose membership is derived, not fixed: `RULE_NAMES` and `SKILL_NAMES`
|
|
1852
1167
|
* decide which files `managedFiles()` places under them, so a rule or skill that is RETIRED
|
|
@@ -1876,14 +1191,14 @@ export function retiredManagedDests(
|
|
|
1876
1191
|
}
|
|
1877
1192
|
|
|
1878
1193
|
/** Outcome of one prune pass — `removed` was deleted, `drifted` was locally edited and left alone. */
|
|
1879
|
-
type PruneResult = { removed: string[]; drifted: string[] }
|
|
1194
|
+
export type PruneResult = { removed: string[]; drifted: string[] }
|
|
1880
1195
|
|
|
1881
1196
|
/**
|
|
1882
1197
|
* Report a prune pass. Always names the files: a doctrine file DISAPPEARING from a consumer's
|
|
1883
1198
|
* `.claude/` is exactly the class of change the sync diff exists to be reviewed, and `9 removed`
|
|
1884
1199
|
* with no names is what made the kept-files line a false green before 1.23.0.
|
|
1885
1200
|
*/
|
|
1886
|
-
function reportPrune(pruned: PruneResult): void {
|
|
1201
|
+
export function reportPrune(pruned: PruneResult): void {
|
|
1887
1202
|
if (pruned.removed.length > 0) {
|
|
1888
1203
|
console.log(
|
|
1889
1204
|
`\nRetired by this basalt-ui version (deleted — they no longer ship):\n` +
|
|
@@ -1912,7 +1227,7 @@ function reportPrune(pruned: PruneResult): void {
|
|
|
1912
1227
|
* entry dropped; a locally-edited one is LEFT in place, reported, and keeps its entry so
|
|
1913
1228
|
* `sync --check` stays red and `--force` can still finish the job. `dryRun` is `--check`.
|
|
1914
1229
|
*/
|
|
1915
|
-
function pruneRetiredManagedFiles(
|
|
1230
|
+
export function pruneRetiredManagedFiles(
|
|
1916
1231
|
cwd: string,
|
|
1917
1232
|
manifest: Manifest,
|
|
1918
1233
|
files: readonly ManagedFile[],
|
|
@@ -1955,40 +1270,13 @@ function removeEmptyParentDir(fileAbs: string): void {
|
|
|
1955
1270
|
}
|
|
1956
1271
|
|
|
1957
1272
|
/** The frozen `> Managed by basalt-ui (1.9.0).` opener every pre-fix DESIGN.md seed carries. */
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
/**
|
|
1961
|
-
* Strip the frozen version out of an already-seeded `DESIGN.md` opener.
|
|
1962
|
-
*
|
|
1963
|
-
* `DESIGN.md` is a `seed`: written once, then consumer-owned and never reconciled. It opened with
|
|
1964
|
-
* `> Managed by basalt-ui ({{BASALT_VERSION}})`, which is accurate for exactly as long as it takes
|
|
1965
|
-
* to run one upgrade — measured across the seven consumers the same line read 1.0.0, 1.9.0, 1.21.0
|
|
1966
|
-
* and 1.22.0 under an identical 1.22.0 install, and three rounds of reports treated that as four
|
|
1967
|
-
* separate doc bugs. It was one: a version number in a file nothing ever rewrites.
|
|
1968
|
-
*
|
|
1969
|
-
* Dropping it from the template fixes new scaffolds only — every existing consumer would carry its
|
|
1970
|
-
* stale number forever, since sync does not touch a seed. So sync deletes the parenthetical in
|
|
1971
|
-
* place, and only that: not a re-seed, not a three-way merge. Everything else in the file is left
|
|
1972
|
-
* exactly as the consumer wrote it, and a DESIGN.md whose opener was already rewritten by hand
|
|
1973
|
-
* does not match at all.
|
|
1974
|
-
*/
|
|
1975
|
-
function migrateDesignVersionLine(cwd: string): void {
|
|
1976
|
-
const dest = resolve(cwd, 'DESIGN.md')
|
|
1977
|
-
const onDisk = readIfExists(dest)
|
|
1978
|
-
if (onDisk === null || !DESIGN_VERSION_LINE.test(onDisk)) return
|
|
1979
|
-
writeFileEnsuringDir(
|
|
1980
|
-
dest,
|
|
1981
|
-
onDisk.replace(DESIGN_VERSION_LINE, '$1 — run `basalt-ui doctor` for the version.'),
|
|
1982
|
-
)
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
function writeFileEnsuringDir(abs: string, content: string): void {
|
|
1273
|
+
export function writeFileEnsuringDir(abs: string, content: string): void {
|
|
1986
1274
|
mkdirSync(dirname(abs), { recursive: true })
|
|
1987
1275
|
writeFileSync(abs, content)
|
|
1988
1276
|
}
|
|
1989
1277
|
|
|
1990
1278
|
/** Resolve the package root at runtime (two levels up from dist/cli/index.js). */
|
|
1991
|
-
function packageRoot(): string {
|
|
1279
|
+
export function packageRoot(): string {
|
|
1992
1280
|
return fileURLToPath(new URL('../../', import.meta.url))
|
|
1993
1281
|
}
|
|
1994
1282
|
|
|
@@ -1997,7 +1285,7 @@ function packageRoot(): string {
|
|
|
1997
1285
|
* marker-spliced file the "current" unit is the existing region (markers included); otherwise it
|
|
1998
1286
|
* is the whole file.
|
|
1999
1287
|
*/
|
|
2000
|
-
type UnitState = {
|
|
1288
|
+
export type UnitState = {
|
|
2001
1289
|
/** The managed unit's current bytes on disk (block region / whole file). */
|
|
2002
1290
|
current: string | null
|
|
2003
1291
|
/** The managed unit's desired bytes from the shipped source. null = source not shipped yet. */
|
|
@@ -2013,7 +1301,7 @@ function extractBlockRegion(host: string | null): string | null {
|
|
|
2013
1301
|
}
|
|
2014
1302
|
|
|
2015
1303
|
/** Read the current vs desired managed-unit bytes for a file. */
|
|
2016
|
-
function unitState(file: ManagedFile, cwd: string, ctx: RenderContext): UnitState {
|
|
1304
|
+
export function unitState(file: ManagedFile, cwd: string, ctx: RenderContext): UnitState {
|
|
2017
1305
|
const desired = file.render(ctx)
|
|
2018
1306
|
const destAbs = resolve(cwd, file.dest)
|
|
2019
1307
|
const onDisk = readIfExists(destAbs)
|
|
@@ -2046,7 +1334,7 @@ type Classification = 'unchanged' | 'drifted' | 'missing' | 'current'
|
|
|
2046
1334
|
* Deliberately whitespace-only — word content is untouched, so a genuine hand-edit inside the block
|
|
2047
1335
|
* still changes the normalized text and still classifies `drifted`.
|
|
2048
1336
|
*/
|
|
2049
|
-
function normalizeForLedger(text: string): string {
|
|
1337
|
+
export function normalizeForLedger(text: string): string {
|
|
2050
1338
|
return text
|
|
2051
1339
|
.replace(/(<!-- basalt:begin)\s+\S+(\s*-->)/, '$1$2')
|
|
2052
1340
|
.split('\n')
|
|
@@ -2069,7 +1357,7 @@ function normalizeForLedger(text: string): string {
|
|
|
2069
1357
|
* recording the normalized hash lets `classify()` normalize `state.current` the same way and
|
|
2070
1358
|
* compare directly against it, no separate historical-hash storage required.
|
|
2071
1359
|
*/
|
|
2072
|
-
function writeUnit(file: ManagedFile, cwd: string, desired: string): string {
|
|
1360
|
+
export function writeUnit(file: ManagedFile, cwd: string, desired: string): string {
|
|
2073
1361
|
const destAbs = resolve(cwd, file.dest)
|
|
2074
1362
|
if (file.markers) {
|
|
2075
1363
|
const host = readIfExists(destAbs) ?? ''
|
|
@@ -2110,7 +1398,7 @@ function writeUnit(file: ManagedFile, cwd: string, desired: string): string {
|
|
|
2110
1398
|
* - drifted : on disk matches NONE of the above (a real, word-level local edit) → skip unless
|
|
2111
1399
|
* --force.
|
|
2112
1400
|
*/
|
|
2113
|
-
function classify(state: UnitState, manifestHash: string | undefined): Classification {
|
|
1401
|
+
export function classify(state: UnitState, manifestHash: string | undefined): Classification {
|
|
2114
1402
|
if (state.current === null) return 'missing'
|
|
2115
1403
|
if (state.desired !== null && state.current === state.desired) return 'current'
|
|
2116
1404
|
const currentHash = sha256(state.current)
|
|
@@ -2125,7 +1413,7 @@ function classify(state: UnitState, manifestHash: string | undefined): Classific
|
|
|
2125
1413
|
return 'drifted'
|
|
2126
1414
|
}
|
|
2127
1415
|
|
|
2128
|
-
function diffSummary(file: ManagedFile, state: UnitState): string {
|
|
1416
|
+
export function diffSummary(file: ManagedFile, state: UnitState): string {
|
|
2129
1417
|
const cur = state.current ?? ''
|
|
2130
1418
|
const des = state.desired ?? ''
|
|
2131
1419
|
const curLines = cur.split('\n').length
|
|
@@ -2134,7 +1422,7 @@ function diffSummary(file: ManagedFile, state: UnitState): string {
|
|
|
2134
1422
|
}
|
|
2135
1423
|
|
|
2136
1424
|
/** Build the render context for a run from the package root + consumer cwd/config. */
|
|
2137
|
-
function renderContext(cwd: string): RenderContext {
|
|
1425
|
+
export function renderContext(cwd: string): RenderContext {
|
|
2138
1426
|
const pkgRoot = packageRoot()
|
|
2139
1427
|
return { pkgRoot, vars: buildTemplateVars(pkgRoot, cwd, readBasaltConfig(cwd)) }
|
|
2140
1428
|
}
|
|
@@ -2149,7 +1437,10 @@ function renderContext(cwd: string): RenderContext {
|
|
|
2149
1437
|
* happened. Deliberately a JSON round-trip rather than a textual splice: key ORDER is preserved by
|
|
2150
1438
|
* `JSON.parse`/`stringify`, and anything fancier would be a formatter this CLI has no business being.
|
|
2151
1439
|
*/
|
|
2152
|
-
function patchPackageJson(
|
|
1440
|
+
export function patchPackageJson(
|
|
1441
|
+
cwd: string,
|
|
1442
|
+
mutate: (pkg: Record<string, unknown>) => boolean,
|
|
1443
|
+
): boolean {
|
|
2153
1444
|
const abs = resolve(cwd, 'package.json')
|
|
2154
1445
|
const raw = readIfExists(abs)
|
|
2155
1446
|
if (raw === null) return false
|
|
@@ -2189,7 +1480,7 @@ function detectRoots(cwd: string): string[] {
|
|
|
2189
1480
|
}
|
|
2190
1481
|
|
|
2191
1482
|
/** What `reconcileRoots` found — the one roots decision `init` and `sync` both report from. */
|
|
2192
|
-
type RootsState =
|
|
1483
|
+
export type RootsState =
|
|
2193
1484
|
/** The key was absent and has just been written. */
|
|
2194
1485
|
| { readonly kind: 'wrote'; readonly roots: string[] }
|
|
2195
1486
|
/** The key is absent and nothing plausible could be inferred. */
|
|
@@ -2218,7 +1509,7 @@ function rootCovers(root: string, detected: string): boolean {
|
|
|
2218
1509
|
* consumer decides. Silently widening someone's deliberate scope would turn an upgrade into an
|
|
2219
1510
|
* unplanned lint debt.
|
|
2220
1511
|
*/
|
|
2221
|
-
function reconcileRoots(cwd: string, opts: { write: boolean }): RootsState {
|
|
1512
|
+
export function reconcileRoots(cwd: string, opts: { write: boolean }): RootsState {
|
|
2222
1513
|
const declared = readBasaltConfig(cwd).roots
|
|
2223
1514
|
const detected = detectRoots(cwd)
|
|
2224
1515
|
if (declared !== undefined) {
|
|
@@ -2237,7 +1528,7 @@ function reconcileRoots(cwd: string, opts: { write: boolean }): RootsState {
|
|
|
2237
1528
|
}
|
|
2238
1529
|
|
|
2239
1530
|
/** The lines `init`/`sync` print for a roots reconciliation. Empty when there is nothing to say. */
|
|
2240
|
-
function rootsNotices(state: RootsState): { log: string[]; error: string[] } {
|
|
1531
|
+
export function rootsNotices(state: RootsState): { log: string[]; error: string[] } {
|
|
2241
1532
|
if (state.kind === 'wrote') {
|
|
2242
1533
|
return {
|
|
2243
1534
|
log: [
|
|
@@ -2290,42 +1581,31 @@ function rootsNotices(state: RootsState): { log: string[]; error: string[] } {
|
|
|
2290
1581
|
function stripJsonc(text: string): string {
|
|
2291
1582
|
let out = ''
|
|
2292
1583
|
let inString = false
|
|
2293
|
-
let
|
|
2294
|
-
let i = 0
|
|
2295
|
-
while (i < text.length) {
|
|
1584
|
+
for (let i = 0; i < text.length; i++) {
|
|
2296
1585
|
const ch = text[i] as string
|
|
2297
1586
|
if (inString) {
|
|
2298
1587
|
out += ch
|
|
2299
|
-
if (
|
|
2300
|
-
else if (ch === '\\') escaped = true
|
|
1588
|
+
if (ch === '\\') out += text[++i] ?? ''
|
|
2301
1589
|
else if (ch === '"') inString = false
|
|
2302
|
-
i += 1
|
|
2303
1590
|
continue
|
|
2304
1591
|
}
|
|
2305
|
-
if (ch === '"')
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
i += 1
|
|
2309
|
-
continue
|
|
2310
|
-
}
|
|
2311
|
-
if (ch === '/' && text[i + 1] === '/') {
|
|
2312
|
-
while (i < text.length && text[i] !== '\n') i += 1
|
|
1592
|
+
if (ch === '"') inString = true
|
|
1593
|
+
else if (ch === '/' && text[i + 1] === '/') {
|
|
1594
|
+
while (i < text.length && text[i] !== '\n') i++
|
|
2313
1595
|
continue
|
|
2314
|
-
}
|
|
2315
|
-
if (ch === '/' && text[i + 1] === '*') {
|
|
2316
|
-
i += 2
|
|
2317
|
-
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1
|
|
1596
|
+
} else if (ch === '/' && text[i + 1] === '*') {
|
|
2318
1597
|
i += 2
|
|
1598
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++
|
|
1599
|
+
i++
|
|
2319
1600
|
continue
|
|
2320
1601
|
}
|
|
2321
1602
|
out += ch
|
|
2322
|
-
i += 1
|
|
2323
1603
|
}
|
|
2324
1604
|
return out.replace(/,(\s*[}\]])/g, '$1')
|
|
2325
1605
|
}
|
|
2326
1606
|
|
|
2327
1607
|
/** Parse a JSONC document, or null when it does not parse at all. */
|
|
2328
|
-
function parseJsonc(text: string): Record<string, unknown> | null {
|
|
1608
|
+
export function parseJsonc(text: string): Record<string, unknown> | null {
|
|
2329
1609
|
try {
|
|
2330
1610
|
return JSON.parse(stripJsonc(text)) as Record<string, unknown>
|
|
2331
1611
|
} catch {
|
|
@@ -2334,7 +1614,7 @@ function parseJsonc(text: string): Record<string, unknown> | null {
|
|
|
2334
1614
|
}
|
|
2335
1615
|
|
|
2336
1616
|
/** Outcome of splicing the shipped preset into an `.oxlintrc.json` the consumer already had. */
|
|
2337
|
-
type MergeLintResult = 'added' | 'already' | 'absent' | 'unreadable' | 'has-comments'
|
|
1617
|
+
export type MergeLintResult = 'added' | 'already' | 'absent' | 'unreadable' | 'has-comments'
|
|
2338
1618
|
|
|
2339
1619
|
/**
|
|
2340
1620
|
* The `extends` entry pointing at the shipped oxlint preset, or null when none does.
|
|
@@ -2345,7 +1625,7 @@ type MergeLintResult = 'added' | 'already' | 'absent' | 'unreadable' | 'has-comm
|
|
|
2345
1625
|
* to start with `NotFound` while a check built to prove the framework is ON reports green. The
|
|
2346
1626
|
* caller resolves this against the config's own directory — see `extendsSeam`.
|
|
2347
1627
|
*/
|
|
2348
|
-
function basaltPresetEntry(entries: unknown): string | null {
|
|
1628
|
+
export function basaltPresetEntry(entries: unknown): string | null {
|
|
2349
1629
|
if (!Array.isArray(entries)) return null
|
|
2350
1630
|
const match = entries.find(
|
|
2351
1631
|
(entry): entry is string =>
|
|
@@ -2354,17 +1634,14 @@ function basaltPresetEntry(entries: unknown): string | null {
|
|
|
2354
1634
|
return match ?? null
|
|
2355
1635
|
}
|
|
2356
1636
|
|
|
2357
|
-
// ── The
|
|
2358
|
-
//
|
|
2359
|
-
// `.oxlintrc.json` and `lefthook.yml` are consumer-owned after the first write: `sync` never
|
|
2360
|
-
// touches either. So 1.20.0's path-resolution fix reached NEW scaffolds only — every existing
|
|
2361
|
-
// monorepo consumer kept a root-relative `extends` that resolves to nothing. oxlint at least
|
|
2362
|
-
// hard-errors (`NotFound`). lefthook does not: a missing extends target merges to ZERO commands and
|
|
2363
|
-
// exits 0, so `lefthook dump` is clean and the repo silently has no pre-commit gate at all.
|
|
1637
|
+
// ── The lefthook gate — checked via `lefthook dump`, not hand-rolled YAML parsing ────────────────
|
|
2364
1638
|
//
|
|
2365
|
-
//
|
|
2366
|
-
//
|
|
2367
|
-
//
|
|
1639
|
+
// `lefthook dump` resolves `extends`/`include`/remote configs/`root:` the same way lefthook itself
|
|
1640
|
+
// does, so this reads the MERGED config lefthook actually runs rather than pattern-matching the
|
|
1641
|
+
// YAML text for an `extends:` entry (which cannot tell a resolvable seam from a broken one, and
|
|
1642
|
+
// cannot see a gate spelled out directly instead of via `extends` at all). Best-effort: lefthook is
|
|
1643
|
+
// a consumer tool basalt neither installs nor requires, so an absent binary or a dump that fails
|
|
1644
|
+
// downgrades the verdict to a warning that says so, rather than inventing a pass or a fail.
|
|
2368
1645
|
|
|
2369
1646
|
const LEFTHOOK_CONFIG_NAMES = ['lefthook.yml', 'lefthook.yaml', '.lefthook.yml', '.lefthook.yaml']
|
|
2370
1647
|
|
|
@@ -2373,138 +1650,54 @@ function findLefthookConfig(dir: string): string | null {
|
|
|
2373
1650
|
return LEFTHOOK_CONFIG_NAMES.find((name) => existsSync(resolve(dir, name))) ?? null
|
|
2374
1651
|
}
|
|
2375
1652
|
|
|
2376
|
-
/**
|
|
2377
|
-
|
|
2378
|
-
* array form a consumer may write instead.
|
|
2379
|
-
*
|
|
2380
|
-
* A three-line reader rather than a YAML dependency: this parses ONE top-level key whose value is a
|
|
2381
|
-
* list of plain scalars, and a wrong answer costs a diagnostic, never a write.
|
|
2382
|
-
*/
|
|
2383
|
-
function yamlUnquote(raw: string): string {
|
|
2384
|
-
return raw.trim().replace(/^['"]|['"]$/g, '')
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
function yamlExtendsEntries(text: string): string[] {
|
|
2388
|
-
const lines = text.split('\n')
|
|
1653
|
+
/** Every command's `run:` string across every hook in a `lefthook dump --format json` payload. */
|
|
1654
|
+
function lefthookDumpRunLines(dump: Record<string, unknown>): string[] {
|
|
2389
1655
|
const out: string[] = []
|
|
2390
|
-
for (const
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
for (const next of lines.slice(index + 1)) {
|
|
2398
|
-
if (/^\s*(#.*)?$/.test(next ?? '')) continue
|
|
2399
|
-
const item = /^\s*-\s*(.+?)\s*$/.exec(next ?? '')
|
|
2400
|
-
if (item === null) break
|
|
2401
|
-
out.push(yamlUnquote(item[1] ?? ''))
|
|
1656
|
+
for (const hook of Object.values(dump)) {
|
|
1657
|
+
if (typeof hook !== 'object' || hook === null || !('commands' in hook)) continue
|
|
1658
|
+
const commands = (hook as { commands?: unknown }).commands
|
|
1659
|
+
if (typeof commands !== 'object' || commands === null) continue
|
|
1660
|
+
for (const command of Object.values(commands as Record<string, unknown>)) {
|
|
1661
|
+
const run = (command as { run?: unknown } | undefined)?.run
|
|
1662
|
+
if (typeof run === 'string') out.push(run)
|
|
2402
1663
|
}
|
|
2403
1664
|
}
|
|
2404
1665
|
return out
|
|
2405
1666
|
}
|
|
2406
1667
|
|
|
2407
|
-
|
|
2408
|
-
type ExtendsSeam =
|
|
1668
|
+
export type LefthookGate =
|
|
2409
1669
|
/** No such config file — nothing is wired, and that is a legitimate consumer choice. */
|
|
2410
1670
|
| { readonly kind: 'no-file' }
|
|
2411
|
-
/**
|
|
2412
|
-
| { readonly kind: '
|
|
2413
|
-
/**
|
|
2414
|
-
| { readonly kind: '
|
|
2415
|
-
|
|
1671
|
+
/** A command runs the guard — the gate exists. */
|
|
1672
|
+
| { readonly kind: 'wired'; readonly file: string }
|
|
1673
|
+
/** `lefthook dump` merged everything and no command runs the guard — provably no gate. */
|
|
1674
|
+
| { readonly kind: 'absent'; readonly file: string }
|
|
1675
|
+
/** A config exists but `lefthook dump --format json` could not be run or parsed. */
|
|
1676
|
+
| { readonly kind: 'unreadable'; readonly file: string }
|
|
2416
1677
|
|
|
2417
|
-
|
|
2418
|
-
* Resolve a lefthook config's basalt `extends` entry against the directory the config lives in —
|
|
2419
|
-
* which is how lefthook itself resolves it.
|
|
2420
|
-
*/
|
|
2421
|
-
function inspectLefthookSeam(repoRoot: string): ExtendsSeam {
|
|
1678
|
+
export function inspectLefthookGate(repoRoot: string): LefthookGate {
|
|
2422
1679
|
const file = findLefthookConfig(repoRoot)
|
|
2423
1680
|
if (file === null) return { kind: 'no-file' }
|
|
2424
|
-
const raw = readIfExists(resolve(repoRoot, file))
|
|
2425
|
-
if (raw === null) return { kind: 'no-file' }
|
|
2426
|
-
const entry = yamlExtendsEntries(raw).find((e) => e.endsWith('basalt-ui/configs/lefthook.yml'))
|
|
2427
|
-
if (entry === undefined) return { kind: 'unwired', file }
|
|
2428
|
-
return existsSync(resolve(repoRoot, entry))
|
|
2429
|
-
? { kind: 'ok', file, entry }
|
|
2430
|
-
: { kind: 'broken', file, entry }
|
|
2431
|
-
}
|
|
2432
|
-
|
|
2433
|
-
/** A `run:` value that invokes the theme guard — the one command that IS the pre-commit gate. */
|
|
2434
|
-
const LEFTHOOK_GUARD_RUN = /^\s*run:\s*.*\bcheck-theme\b/m
|
|
2435
|
-
|
|
2436
|
-
/**
|
|
2437
|
-
* The merged lefthook config as `lefthook dump` renders it, or null when it cannot be obtained.
|
|
2438
|
-
*
|
|
2439
|
-
* Authoritative where the YAML text is not: `dump` resolves `extends`, `include`, remote configs
|
|
2440
|
-
* and per-command `root:`, so it answers "does a gate exist" for a repo that wired the jobs its own
|
|
2441
|
-
* way. Best-effort by design — lefthook is a consumer tool basalt neither installs nor requires, so
|
|
2442
|
-
* an absent binary downgrades the verdict to advisory rather than inventing one.
|
|
2443
|
-
*/
|
|
2444
|
-
function lefthookDump(repoRoot: string): string | null {
|
|
2445
1681
|
const localBin = resolve(repoRoot, 'node_modules/.bin/lefthook')
|
|
2446
1682
|
const bin = existsSync(localBin) ? localBin : 'lefthook'
|
|
1683
|
+
let dump: Record<string, unknown> | null = null
|
|
2447
1684
|
try {
|
|
2448
|
-
const result = spawnSync(bin, ['dump'], {
|
|
1685
|
+
const result = spawnSync(bin, ['dump', '--format', 'json'], {
|
|
2449
1686
|
cwd: repoRoot,
|
|
2450
1687
|
encoding: 'utf8',
|
|
2451
1688
|
timeout: 10_000,
|
|
2452
1689
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
2453
1690
|
})
|
|
2454
|
-
|
|
2455
|
-
|
|
1691
|
+
dump =
|
|
1692
|
+
result.error === undefined && result.status === 0
|
|
1693
|
+
? (JSON.parse(result.stdout) as Record<string, unknown>)
|
|
1694
|
+
: null
|
|
2456
1695
|
} catch {
|
|
2457
|
-
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2460
|
-
|
|
2461
|
-
/**
|
|
2462
|
-
* Whether the repo's pre-commit hook genuinely runs basalt's gates — not whether it `extends` the
|
|
2463
|
-
* shipped preset.
|
|
2464
|
-
*
|
|
2465
|
-
* The 1.21.0 check tested for the extends STRING, and a consumer whose root config spells the same
|
|
2466
|
-
* three jobs out with a per-command `root:` (because `extends` merges commands WITHOUT their
|
|
2467
|
-
* working directory, which would run `bunx oxlint` where there is no `.oxlintrc.json`) was warned
|
|
2468
|
-
* at, and told to make a change that would have broken it. A check that is confidently wrong is
|
|
2469
|
-
* worse than one that admits its limit, so this one asks the question three ways and says which
|
|
2470
|
-
* answer it got:
|
|
2471
|
-
*
|
|
2472
|
-
* - `lefthook dump` — authoritative, resolves extends/include/remote/`root:` the same way lefthook
|
|
2473
|
-
* itself does.
|
|
2474
|
-
* - the config text — enough to see a gate spelled out in the root file, which is the shape that
|
|
2475
|
-
* produced the false positive.
|
|
2476
|
-
* - neither — `advisory`, reported as a warning that names what it could not see.
|
|
2477
|
-
*/
|
|
2478
|
-
type LefthookGate =
|
|
2479
|
-
| { readonly kind: 'no-file' }
|
|
2480
|
-
| { readonly kind: 'extends-ok'; readonly file: string; readonly entry: string }
|
|
2481
|
-
| { readonly kind: 'extends-broken'; readonly file: string; readonly entry: string }
|
|
2482
|
-
/** A command runs the guard directly. `proof` names what established it. */
|
|
2483
|
-
| { readonly kind: 'wired'; readonly file: string; readonly proof: 'dump' | 'config' }
|
|
2484
|
-
/** `lefthook dump` merged everything and no command runs the guard — provably no gate. */
|
|
2485
|
-
| { readonly kind: 'absent'; readonly file: string }
|
|
2486
|
-
/** No dump, no extends, no guard command in the text — unknown, not absent. */
|
|
2487
|
-
| { readonly kind: 'advisory'; readonly file: string }
|
|
2488
|
-
|
|
2489
|
-
function inspectLefthookGate(repoRoot: string): LefthookGate {
|
|
2490
|
-
const seam = inspectLefthookSeam(repoRoot)
|
|
2491
|
-
if (seam.kind === 'no-file') return { kind: 'no-file' }
|
|
2492
|
-
// A resolvable extends is the wiring basalt seeds; report it as itself rather than as a generic
|
|
2493
|
-
// "a command exists", since the remedy for a BROKEN one is specific and worth keeping.
|
|
2494
|
-
if (seam.kind === 'ok') return { kind: 'extends-ok', file: seam.file, entry: seam.entry }
|
|
2495
|
-
if (seam.kind === 'broken') {
|
|
2496
|
-
return { kind: 'extends-broken', file: seam.file, entry: seam.entry }
|
|
2497
|
-
}
|
|
2498
|
-
|
|
2499
|
-
const dump = lefthookDump(repoRoot)
|
|
2500
|
-
if (dump !== null) {
|
|
2501
|
-
return LEFTHOOK_GUARD_RUN.test(dump)
|
|
2502
|
-
? { kind: 'wired', file: seam.file, proof: 'dump' }
|
|
2503
|
-
: { kind: 'absent', file: seam.file }
|
|
1696
|
+
dump = null
|
|
2504
1697
|
}
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
return { kind: '
|
|
1698
|
+
if (dump === null) return { kind: 'unreadable', file }
|
|
1699
|
+
const wired = lefthookDumpRunLines(dump).some((run) => /\bcheck-theme\b/.test(run))
|
|
1700
|
+
return wired ? { kind: 'wired', file } : { kind: 'absent', file }
|
|
2508
1701
|
}
|
|
2509
1702
|
|
|
2510
1703
|
/**
|
|
@@ -2513,7 +1706,7 @@ function inspectLefthookGate(repoRoot: string): LefthookGate {
|
|
|
2513
1706
|
* lint-debt notice `init` prints), and that is a decision, not a scaffold step. Prepends the preset
|
|
2514
1707
|
* so the consumer's own `extends` entries still win.
|
|
2515
1708
|
*/
|
|
2516
|
-
function mergeOxlintExtends(cwd: string, presetPath: string): MergeLintResult {
|
|
1709
|
+
export function mergeOxlintExtends(cwd: string, presetPath: string): MergeLintResult {
|
|
2517
1710
|
const abs = resolve(cwd, '.oxlintrc.json')
|
|
2518
1711
|
const raw = readIfExists(abs)
|
|
2519
1712
|
if (raw === null) return 'absent'
|
|
@@ -2537,2531 +1730,167 @@ function mergeOxlintExtends(cwd: string, presetPath: string): MergeLintResult {
|
|
|
2537
1730
|
return 'added'
|
|
2538
1731
|
}
|
|
2539
1732
|
|
|
1733
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1734
|
+
// workspace-glob primitives — shared by findBasaltInstall and reconcileRoots (`doctor` moved to
|
|
1735
|
+
// its own file in C2 and no longer walks the workspace: ai-major-parity is scoped to one
|
|
1736
|
+
// package.json, and project resolution is BASALT_CWD → cwd, nothing inferred).
|
|
1737
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1738
|
+
|
|
2540
1739
|
/**
|
|
2541
|
-
*
|
|
2542
|
-
*
|
|
2543
|
-
*
|
|
1740
|
+
* Which shape of consumer a project-scoped command is looking at.
|
|
1741
|
+
*
|
|
1742
|
+
* `framework` — the Mantine app the scaffold is for. `tokens-only` — a consumer that took the
|
|
1743
|
+
* `--vx-*` layer and nothing else (rollhook: no Mantine, no manifest, `tokens:css` output). Telling
|
|
1744
|
+
* the second to "run `basalt-ui init`" is the wrong instruction: init scaffolds Mantine rules,
|
|
1745
|
+
* skills and a DESIGN.md it will never read. DECLARED only (`--framework` / `--tokens-only`, or
|
|
1746
|
+
* `"basalt": { "profile": "tokens-only" }`) — never inferred; see {@link declaredProfile}.
|
|
2544
1747
|
*/
|
|
2545
|
-
|
|
2546
|
-
'.oxlintrc.json':
|
|
2547
|
-
'the shipped oxlint preset (the basalt/* design rules AND the jsx-a11y / import / promise / ' +
|
|
2548
|
-
'unicorn plugins) is NOT active — nothing else turns it on, and every gate stays green ' +
|
|
2549
|
-
'without it. Re-run with `--merge-lint` to splice the extends in, or add it by hand.',
|
|
2550
|
-
'lefthook.yml':
|
|
2551
|
-
'the pre-commit oxlint / oxfmt / check-theme jobs are not wired — add `extends: ' +
|
|
2552
|
-
'[<preset>]` to your own file.',
|
|
2553
|
-
'.github/workflows/check.yml':
|
|
2554
|
-
'CI does not run check-theme or the sync drift gate — copy those steps into your own workflow.',
|
|
2555
|
-
'.oxfmtrc.json': 'your existing formatter config stands; basalt formats nothing on its own.',
|
|
2556
|
-
'DESIGN.md': 'the design brief Claude reads is yours, not the seeded one.',
|
|
2557
|
-
}
|
|
2558
|
-
|
|
2559
|
-
/** Scaffold all managed files into the consumer repo, then write the manifest. Idempotent. Returns 0. */
|
|
2560
|
-
export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags = {}): number {
|
|
2561
|
-
// ── Describe the repo BEFORE rendering anything ───────────────────────────
|
|
2562
|
-
// Every roots-derived seed (the CI oxfmt globs, DESIGN.md's series path, the default scan
|
|
2563
|
-
// exemption) renders from `basalt.roots`, so the key has to exist first. Writing it is what
|
|
2564
|
-
// stops a workspace repo scaffolding a guard that scans zero files while doctor reports green.
|
|
2565
|
-
const install = findBasaltInstall(cwd)
|
|
2566
|
-
if (install.dir === null) {
|
|
2567
|
-
console.error(
|
|
2568
|
-
`⚠ basalt-ui init: basalt-ui does not resolve from ${cwd} or any directory above it — the ` +
|
|
2569
|
-
'seeded `extends` paths and CI steps are being written against the repo root by assumption. ' +
|
|
2570
|
-
'Install basalt-ui here (or at the repo root) and re-run `basalt-ui init` so they resolve.',
|
|
2571
|
-
)
|
|
2572
|
-
}
|
|
1748
|
+
export type DoctorProfile = 'framework' | 'tokens-only'
|
|
2573
1749
|
|
|
2574
|
-
|
|
1750
|
+
type WorkspacePackage = { readonly name: string; readonly dir: string }
|
|
2575
1751
|
|
|
2576
|
-
|
|
2577
|
-
|
|
1752
|
+
/** {@link collectWorkspacePackages}'s return: the walked packages plus whether a `workspaces`
|
|
1753
|
+
* field was present at all (distinguishes "nothing to compare" from "not a workspace root"). */
|
|
1754
|
+
type WorkspaceWalk = {
|
|
1755
|
+
readonly packages: readonly WorkspacePackage[]
|
|
1756
|
+
readonly hasWorkspacesField: boolean
|
|
1757
|
+
}
|
|
2578
1758
|
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
1759
|
+
/**
|
|
1760
|
+
* Every subdirectory of `base` (name only), or `[]` if `base` doesn't exist / isn't a directory.
|
|
1761
|
+
* `node_modules` is always excluded — a wildcard workspaces pattern must never descend into a
|
|
1762
|
+
* dependency tree. `skipDotDirs` additionally excludes dot-directories; it is only set by the
|
|
1763
|
+
* `*`/`**` wildcard descent in {@link expandPatternSegments}, never for an explicitly named
|
|
1764
|
+
* literal segment, so a pattern like `.internal/*` still resolves.
|
|
1765
|
+
*/
|
|
1766
|
+
function subdirNames(base: string, options: { skipDotDirs?: boolean } = {}): string[] {
|
|
1767
|
+
let entries: string[]
|
|
1768
|
+
try {
|
|
1769
|
+
entries = readdirSync(base)
|
|
1770
|
+
} catch {
|
|
1771
|
+
return []
|
|
1772
|
+
}
|
|
1773
|
+
return entries.filter((name) => {
|
|
1774
|
+
if (name === 'node_modules') return false
|
|
1775
|
+
if (options.skipDotDirs && name.startsWith('.')) return false
|
|
1776
|
+
try {
|
|
1777
|
+
return statSync(resolve(base, name)).isDirectory()
|
|
1778
|
+
} catch {
|
|
1779
|
+
return false
|
|
1780
|
+
}
|
|
2588
1781
|
})
|
|
2589
|
-
|
|
2590
|
-
const manifest = readManifest(cwd)
|
|
2591
|
-
migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
|
|
2592
|
-
const peers = resolvePeerFlags(cwd, scaffoldFlags)
|
|
2593
|
-
const placement = resolvePlacement(cwd)
|
|
2594
|
-
const files = managedFiles(peers, placement)
|
|
2595
|
-
|
|
2596
|
-
const writtenFiles: string[] = []
|
|
2597
|
-
const keptFiles: string[] = []
|
|
2598
|
-
const missingSources: string[] = []
|
|
2599
|
-
|
|
2600
|
-
for (const file of files) {
|
|
2601
|
-
const state = unitState(file, cwd, ctx)
|
|
2602
|
-
if (state.desired === null) {
|
|
2603
|
-
missingSources.push(file.source)
|
|
2604
|
-
continue
|
|
2605
|
-
}
|
|
2606
|
-
|
|
2607
|
-
// Whole files (managed + seed) are skip-if-exists on init; a marker-spliced region always
|
|
2608
|
-
// inserts/updates itself inside its host file.
|
|
2609
|
-
const destExists = existsSync(resolve(cwd, file.dest))
|
|
2610
|
-
if (!file.markers && destExists) {
|
|
2611
|
-
// Already present — keep the consumer's copy untouched. Record the SHIPPED hash (normalized,
|
|
2612
|
-
// same ledger form `writeUnit` uses) so a later sync treats a pre-existing-but-different file
|
|
2613
|
-
// as locally drifted (skip unless --force), never silently clobbering a file the consumer
|
|
2614
|
-
// authored before init.
|
|
2615
|
-
manifest.files[file.dest] = sha256(normalizeForLedger(state.desired))
|
|
2616
|
-
keptFiles.push(file.dest)
|
|
2617
|
-
continue
|
|
2618
|
-
}
|
|
2619
|
-
|
|
2620
|
-
const hash = writeUnit(file, cwd, state.desired)
|
|
2621
|
-
manifest.files[file.dest] = hash
|
|
2622
|
-
writtenFiles.push(file.dest)
|
|
2623
|
-
}
|
|
2624
|
-
|
|
2625
|
-
// A re-`init` over an existing install reconciles the derived namespaces too — otherwise the only
|
|
2626
|
-
// route out of a retired rule file is `sync`, and `init` is what a consumer reaches for after an
|
|
2627
|
-
// upgrade that changed the doctrine set.
|
|
2628
|
-
const pruned = pruneRetiredManagedFiles(cwd, manifest, files)
|
|
2629
|
-
|
|
2630
|
-
const mergeLint: MergeLintResult | null =
|
|
2631
|
-
scaffoldFlags.mergeLint === true ? mergeOxlintExtends(cwd, ctx.vars.OXLINT_PRESET_PATH) : null
|
|
2632
|
-
|
|
2633
|
-
manifest.basaltVersion = ctx.vars.BASALT_VERSION
|
|
2634
|
-
manifest.spacingScale = { ...deriveSpacing(0).scale }
|
|
2635
|
-
writeFileEnsuringDir(resolve(cwd, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
2636
|
-
|
|
2637
|
-
console.log(
|
|
2638
|
-
`basalt-ui init: ${writtenFiles.length} written, ${keptFiles.length} kept, manifest at ${MANIFEST_PATH}`,
|
|
2639
|
-
)
|
|
2640
|
-
reportPrune(pruned)
|
|
2641
|
-
if (missingSources.length > 0) {
|
|
2642
|
-
console.log(
|
|
2643
|
-
`basalt-ui init: ${missingSources.length} shipped asset(s) not present, skipped: ${missingSources.join(', ')}`,
|
|
2644
|
-
)
|
|
2645
|
-
}
|
|
2646
|
-
// Naming the kept files is the fix for the report's headline false-green: `20 written, 2 kept`
|
|
2647
|
-
// named neither the file nor the consequence, so a repo that already had an `.oxlintrc.json`
|
|
2648
|
-
// ended up with the framework's lint half off and every gate downstream reporting green.
|
|
2649
|
-
if (keptFiles.length > 0) {
|
|
2650
|
-
console.log(`\nKept (already present — basalt did not touch them):`)
|
|
2651
|
-
for (const dest of keptFiles) {
|
|
2652
|
-
const cost = KEPT_FILE_COST[dest]
|
|
2653
|
-
console.log(cost === undefined ? ` · ${dest}` : ` · ${dest} — ${cost}`)
|
|
2654
|
-
}
|
|
2655
|
-
}
|
|
2656
|
-
if (mergeLint !== null) {
|
|
2657
|
-
const mergeMessage: Record<MergeLintResult, string> = {
|
|
2658
|
-
added: `spliced "${ctx.vars.OXLINT_PRESET_PATH}" into the existing .oxlintrc.json extends`,
|
|
2659
|
-
already: '.oxlintrc.json already extends the shipped preset — nothing to do',
|
|
2660
|
-
absent:
|
|
2661
|
-
'no existing .oxlintrc.json to merge into (the seeded one already extends the preset)',
|
|
2662
|
-
unreadable: '.oxlintrc.json is not valid JSON — merge the extends entry by hand',
|
|
2663
|
-
'has-comments':
|
|
2664
|
-
'.oxlintrc.json carries comments; rewriting it would delete them (usually the reason each ' +
|
|
2665
|
-
`rule is off). Add "extends": ["${ctx.vars.OXLINT_PRESET_PATH}"] as the first entry by hand.`,
|
|
2666
|
-
}
|
|
2667
|
-
console.log(`basalt-ui init: --merge-lint — ${mergeMessage[mergeLint]}`)
|
|
2668
|
-
}
|
|
2669
|
-
const rootsMessages = rootsNotices(rootsState)
|
|
2670
|
-
for (const line of rootsMessages.log) console.log(`basalt-ui init: ${line}`)
|
|
2671
|
-
for (const line of rootsMessages.error) console.error(`⚠ basalt-ui init: ${line}`)
|
|
2672
|
-
if (seededLintScript) {
|
|
2673
|
-
console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`)
|
|
2674
|
-
}
|
|
2675
|
-
// lefthook.yml / .github/workflows/check.yml are repo-root-shaped — neither lefthook nor GitHub
|
|
2676
|
-
// Actions reads config from anywhere but the repo root, so a package living in a subdirectory
|
|
2677
|
-
// skips both rather than relocating them into a spot nothing reads.
|
|
2678
|
-
if (!placement.isPackageRepoRoot) {
|
|
2679
|
-
console.log(
|
|
2680
|
-
`basalt-ui init: skipped lefthook.yml (this package is not the repo root — repo root ` +
|
|
2681
|
-
`detected at ${placement.repoRoot}) — lefthook only reads config at the repo root; extend ` +
|
|
2682
|
-
`${ctx.vars.LEFTHOOK_PRESET_PATH} from your root lefthook.yml instead, and give its ` +
|
|
2683
|
-
`check-theme command \`env: { BASALT_CWD: ${relativePosix(placement.repoRoot, cwd)} }\` ` +
|
|
2684
|
-
'so the guard runs where your basalt config actually lives.',
|
|
2685
|
-
)
|
|
2686
|
-
console.log(
|
|
2687
|
-
`basalt-ui init: skipped .github/workflows/check.yml (this package is not the repo root — ` +
|
|
2688
|
-
`repo root detected at ${placement.repoRoot}) — GitHub Actions only reads .github/ at the ` +
|
|
2689
|
-
`repo root; copy the steps from ${shippedAssetPath(install, placement.repoRoot, 'configs/check.yml')} ` +
|
|
2690
|
-
'into your root CI workflow instead.',
|
|
2691
|
-
)
|
|
2692
|
-
}
|
|
2693
|
-
if (placement.relocatedQueryClient !== null) {
|
|
2694
|
-
console.log(
|
|
2695
|
-
`basalt-ui init: skipped src/query-client.ts (found an existing query client at ` +
|
|
2696
|
-
`${relative(cwd, placement.relocatedQueryClient)}) — import it from there instead of ` +
|
|
2697
|
-
're-seeding at the original path.',
|
|
2698
|
-
)
|
|
2699
|
-
}
|
|
2700
|
-
// query-client.ts / __root.tsx reference the optional TanStack peers directly — seeding them
|
|
2701
|
-
// without the peer installed would ship an unresolved import. Hint how to opt in instead.
|
|
2702
|
-
if (!peers.hasQuery) {
|
|
2703
|
-
console.log(
|
|
2704
|
-
'basalt-ui init: skipped src/query-client.ts (no @tanstack/react-query dependency detected) — ' +
|
|
2705
|
-
'install it, or re-run with --with-query, to scaffold it.',
|
|
2706
|
-
)
|
|
2707
|
-
}
|
|
2708
|
-
if (!peers.hasRouter || !peers.hasQuery) {
|
|
2709
|
-
console.log(
|
|
2710
|
-
'basalt-ui init: skipped src/routes/__root.tsx (needs both @tanstack/react-router and ' +
|
|
2711
|
-
'@tanstack/react-query) — install both, or re-run with --with-router --with-query, to scaffold it.',
|
|
2712
|
-
)
|
|
2713
|
-
}
|
|
2714
|
-
// The guard-hook PreToolUse registration is NOT written automatically — add it manually to
|
|
2715
|
-
// .claude/settings.json so every Write/Edit/MultiEdit goes through the theme guard.
|
|
2716
|
-
console.log(
|
|
2717
|
-
`\nTheme guard hook: add to .claude/settings.json → hooks.PreToolUse to catch violations before they land:\n` +
|
|
2718
|
-
` "hooks": {\n` +
|
|
2719
|
-
` "PreToolUse": [\n` +
|
|
2720
|
-
` {\n` +
|
|
2721
|
-
` "matcher": "Write|Edit|MultiEdit",\n` +
|
|
2722
|
-
` "hooks": [{ "type": "command", "command": "${ctx.vars.BASALT_BIN} guard-hook" }]\n` +
|
|
2723
|
-
` }\n` +
|
|
2724
|
-
` ]\n` +
|
|
2725
|
-
` }`,
|
|
2726
|
-
)
|
|
2727
|
-
// `init` on an existing app is a LINT-DEBT EVENT, not a no-op — the shipped preset turns on whole
|
|
2728
|
-
// oxlint plugins the repo was never linted against, so previously-clean code lands with real
|
|
2729
|
-
// findings on the first run. Naming the plugins (derived from the preset, so the count can't
|
|
2730
|
-
// drift) is what turns that from a nasty surprise into a scheduled triage.
|
|
2731
|
-
const presetPlugins = readOxlintPresetPlugins(ctx.pkgRoot)
|
|
2732
|
-
if (presetPlugins.length > 0) {
|
|
2733
|
-
console.log(
|
|
2734
|
-
`\nLint debt: adopting the shipped oxlint preset on an EXISTING app is not a no-op — it turns ` +
|
|
2735
|
-
`on ${presetPlugins.length} plugins (${presetPlugins.join(', ')}) plus the basalt/* design ` +
|
|
2736
|
-
'rules, on code never linted against them. Run `oxlint .` now and triage the count before ' +
|
|
2737
|
-
'your next commit; turn a rule off in your own .oxlintrc.json with a written reason rather ' +
|
|
2738
|
-
'than blanket-disabling a plugin.',
|
|
2739
|
-
)
|
|
2740
|
-
}
|
|
2741
|
-
console.log(
|
|
2742
|
-
'Activate the hooks: `lefthook install` — the seeded lefthook.yml is inert until the git hooks ' +
|
|
2743
|
-
'are written, and looks configured either way.',
|
|
2744
|
-
)
|
|
2745
|
-
// First adoption on a previously guard-clean repo can surface a wall of findings (the 1.0 guard
|
|
2746
|
-
// adds several rule kinds beyond a legacy local guard) — steer toward tuning config, not mass-allow.
|
|
2747
|
-
console.log(
|
|
2748
|
-
`\nFirst run: run \`${ctx.vars.BASALT_BIN} check-theme\` next (or \`bun run lint:basalt\`), then ` +
|
|
2749
|
-
'tune the per-rule `basalt.*` config keys in package.json for anything that fires — do not ' +
|
|
2750
|
-
'mass-`theme-allow` findings. Then `basalt-ui doctor` to confirm the wiring actually took.',
|
|
2751
|
-
)
|
|
2752
|
-
return 0
|
|
2753
|
-
}
|
|
2754
|
-
|
|
2755
|
-
/** The oxlint plugins the shipped preset switches on — read from the preset, never restated. */
|
|
2756
|
-
function readOxlintPresetPlugins(pkgRoot: string): string[] {
|
|
2757
|
-
const raw = readSource(pkgRoot, 'configs/oxlint.json')
|
|
2758
|
-
if (raw === null) return []
|
|
2759
|
-
try {
|
|
2760
|
-
const plugins = (JSON.parse(raw) as { plugins?: unknown }).plugins
|
|
2761
|
-
return isStringArray(plugins) ? plugins : []
|
|
2762
|
-
} catch {
|
|
2763
|
-
return []
|
|
2764
|
-
}
|
|
2765
|
-
}
|
|
2766
|
-
|
|
2767
|
-
type SyncOptions = {
|
|
2768
|
-
force?: boolean
|
|
2769
|
-
check?: boolean
|
|
2770
|
-
/** The raw flag list, read for `--tokens-only` / `--framework` exactly as check-theme reads it. */
|
|
2771
|
-
flags?: readonly string[]
|
|
2772
|
-
}
|
|
2773
|
-
|
|
2774
|
-
/**
|
|
2775
|
-
* The nearest ANCESTOR of `dir` (exclusive) carrying a basalt manifest, or null.
|
|
2776
|
-
*
|
|
2777
|
-
* `resolveProjectDir` relocates DOWNWARD — a root-invoked hook finding the one workspace package
|
|
2778
|
-
* that holds the config. The opposite direction is the shape that produced this function: a
|
|
2779
|
-
* consumer standing in `apps/dashboard`, the package that actually depends on basalt-ui, whose
|
|
2780
|
-
* install lives at the repo root above it. Nothing walks up, so `sync` read "no manifest here" as
|
|
2781
|
-
* "nothing scaffolded yet" and wrote a complete second consumer beside the real one.
|
|
2782
|
-
*/
|
|
2783
|
-
/**
|
|
2784
|
-
* The sentence `sync` prints when the cwd carries no install but an ancestor does.
|
|
2785
|
-
*
|
|
2786
|
-
* Shared with `doctor` deliberately. The two commands already share `resolveProjectDir`, but not
|
|
2787
|
-
* the ADVICE: `sync` named the parent install (1.22.0's "stop scaffolding a second consumer" fix)
|
|
2788
|
-
* while `doctor`, in the same directory, kept prescribing `basalt-ui init` — so following doctor
|
|
2789
|
-
* literally performed the exact mistake that fix exists to prevent. One function, one sentence.
|
|
2790
|
-
*/
|
|
2791
|
-
function parentInstallAdvice(cwd: string, parent: string, verb: string): string {
|
|
2792
|
-
return (
|
|
2793
|
-
`This repo's install is at ${relativePosix(cwd, parent)} (${parent}) — run ${verb} there, ` +
|
|
2794
|
-
'or set BASALT_CWD to it.'
|
|
2795
|
-
)
|
|
2796
|
-
}
|
|
2797
|
-
|
|
2798
|
-
function findManifestAbove(dir: string): string | null {
|
|
2799
|
-
let current = resolve(dir, '..')
|
|
2800
|
-
for (;;) {
|
|
2801
|
-
if (existsSync(resolve(current, MANIFEST_PATH))) return current
|
|
2802
|
-
const parent = resolve(current, '..')
|
|
2803
|
-
if (parent === current) return null
|
|
2804
|
-
current = parent
|
|
2805
|
-
}
|
|
2806
|
-
}
|
|
2807
|
-
|
|
2808
|
-
/**
|
|
2809
|
-
* Reconcile managed files with the shipped versions via a sha256 three-way compare.
|
|
2810
|
-
*
|
|
2811
|
-
* - `--check` makes NO writes; exits non-zero if any managed file is out-of-date or locally drifted
|
|
2812
|
-
* (a CI freshness gate), exit 0 when all current.
|
|
2813
|
-
* - `--force` overwrites locally-drifted files instead of skipping them.
|
|
2814
|
-
*
|
|
2815
|
-
* Runs against `resolveProjectDir(cwd)`, exactly as `check-theme` and `doctor` do — and then
|
|
2816
|
-
* REFUSES rather than scaffolding when the resolved project has no manifest. See
|
|
2817
|
-
* {@link findManifestAbove} for the failure that bought this: `sync` is the drift refresh and
|
|
2818
|
-
* `init` is the scaffold, so placing twenty files on a cwd that was never a consumer is a decision
|
|
2819
|
-
* `sync` does not get to make silently.
|
|
2820
|
-
*/
|
|
2821
|
-
export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd()): number {
|
|
2822
|
-
const syncFlags = opts.flags ?? []
|
|
2823
|
-
if (conflictingProfileFlags(syncFlags)) {
|
|
2824
|
-
console.error('basalt-ui sync: --tokens-only and --framework are alternatives — pass one.')
|
|
2825
|
-
return 1
|
|
2826
|
-
}
|
|
2827
|
-
const project = resolveProjectDir(invocationCwd)
|
|
2828
|
-
if (project.ambiguous !== null) {
|
|
2829
|
-
console.error(
|
|
2830
|
-
`✖ basalt-ui sync: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} ` +
|
|
2831
|
-
`packages below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
|
|
2832
|
-
'run it from one of them, or set BASALT_CWD to pick.',
|
|
2833
|
-
)
|
|
2834
|
-
return 1
|
|
2835
|
-
}
|
|
2836
|
-
const cwd = project.dir
|
|
2837
|
-
if (project.relocatedFrom !== null) {
|
|
2838
|
-
console.log(
|
|
2839
|
-
`basalt-ui sync: no basalt config at ${project.relocatedFrom} — running in ` +
|
|
2840
|
-
`${relativePosix(project.relocatedFrom, cwd)}, where it lives.`,
|
|
2841
|
-
)
|
|
2842
|
-
}
|
|
2843
|
-
|
|
2844
|
-
// Profile before the refusal, because in a tokens-only consumer the refusal's own advice is
|
|
2845
|
-
// wrong: it names `basalt-ui init`, and `doctor` in the same directory at the same version says
|
|
2846
|
-
// in so many words that init is NOT the fix there — it places a Mantine doctrine the consumer
|
|
2847
|
-
// has no use for. Mirrors doctor's manifest row verbatim, and exits 0 so `sync --check` is
|
|
2848
|
-
// wirable into a tokens-only repo's CI (the one drift gate every other consumer runs).
|
|
2849
|
-
//
|
|
2850
|
-
// DECLARED, never inferred — the same asymmetry check-theme applies, for the same reason: the
|
|
2851
|
-
// answer SILENCES the whole reconciliation, and inferring it from a missing @mantine/core would
|
|
2852
|
-
// turn a framework consumer that keeps Mantine in a sibling package into a silent no-op.
|
|
2853
|
-
if (declaredProfile(readBasaltConfig(cwd), syncFlags) === 'tokens-only') {
|
|
2854
|
-
console.log(
|
|
2855
|
-
`basalt-ui sync: ${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to ` +
|
|
2856
|
-
'reconcile. The token layer is refreshed by re-running `basalt-ui tokens:css`, and ' +
|
|
2857
|
-
'`tokens:css --check` is its drift gate. Pass --framework to force the full profile.',
|
|
2858
|
-
)
|
|
2859
|
-
return 0
|
|
2860
|
-
}
|
|
2861
|
-
|
|
2862
|
-
// The refusal runs BEFORE reconcileRoots, which WRITES `basalt.roots` into package.json — that
|
|
2863
|
-
// key is half the damage: a second `basalt` block means check-theme scans a different tree
|
|
2864
|
-
// depending on which directory it was invoked from.
|
|
2865
|
-
if (!existsSync(resolve(cwd, MANIFEST_PATH))) {
|
|
2866
|
-
const elsewhere = findManifestAbove(cwd)
|
|
2867
|
-
const wouldWrite = managedFiles(resolvePeerFlags(cwd, {}), resolvePlacement(cwd)).length
|
|
2868
|
-
console.error(
|
|
2869
|
-
`✖ basalt-ui sync: no ${MANIFEST_PATH} at ${cwd} — refusing to scaffold. ` +
|
|
2870
|
-
`\`sync\` refreshes an EXISTING basalt install; creating one is \`basalt-ui init\`'s ` +
|
|
2871
|
-
`decision, and continuing here would write ${wouldWrite} managed file(s) into this ` +
|
|
2872
|
-
'directory.' +
|
|
2873
|
-
(elsewhere === null
|
|
2874
|
-
? ' No manifest exists at any ancestor either — run `basalt-ui init` here if this ' +
|
|
2875
|
-
'package really is meant to be a consumer.'
|
|
2876
|
-
: ` ${parentInstallAdvice(cwd, elsewhere, 'sync')}`),
|
|
2877
|
-
)
|
|
2878
|
-
return 1
|
|
2879
|
-
}
|
|
2880
|
-
|
|
2881
|
-
// Before renderContext, which reads `basalt.roots` for the roots-derived template variables.
|
|
2882
|
-
const rootsState = reconcileRoots(cwd, { write: opts.check !== true })
|
|
2883
|
-
const ctx = renderContext(cwd)
|
|
2884
|
-
const manifest = readManifest(cwd)
|
|
2885
|
-
if (!opts.check) {
|
|
2886
|
-
migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
|
|
2887
|
-
migrateDesignVersionLine(cwd)
|
|
2888
|
-
}
|
|
2889
|
-
const peers = resolvePeerFlags(cwd, {})
|
|
2890
|
-
const placement = resolvePlacement(cwd)
|
|
2891
|
-
const files = managedFiles(peers, placement)
|
|
2892
|
-
|
|
2893
|
-
let updated = 0
|
|
2894
|
-
// `created` and `recreated` were one counter, and `0 updated, 20 recreated` is what a full
|
|
2895
|
-
// scaffold printed — a word that means "it was here, it went missing, I put it back" describing
|
|
2896
|
-
// twenty files this directory never had. The ledger already knows which: an entry in
|
|
2897
|
-
// `manifest.files` is basalt saying it placed the file once.
|
|
2898
|
-
let created = 0
|
|
2899
|
-
let recreated = 0
|
|
2900
|
-
let skippedDrift = 0
|
|
2901
|
-
let staleForCheck = 0
|
|
2902
|
-
const driftLines: string[] = []
|
|
2903
|
-
const missingSources: string[] = []
|
|
2904
|
-
|
|
2905
|
-
for (const file of files) {
|
|
2906
|
-
const state = unitState(file, cwd, ctx)
|
|
2907
|
-
if (state.desired === null) {
|
|
2908
|
-
missingSources.push(file.source)
|
|
2909
|
-
continue
|
|
2910
|
-
}
|
|
2911
|
-
|
|
2912
|
-
const tracked = manifest.files[file.dest] !== undefined
|
|
2913
|
-
|
|
2914
|
-
// `seed` is written once, then owned by the consumer — never reconciled, never reported.
|
|
2915
|
-
if (file.mode === 'seed') {
|
|
2916
|
-
if (state.current === null && !opts.check) {
|
|
2917
|
-
manifest.files[file.dest] = writeUnit(file, cwd, state.desired)
|
|
2918
|
-
if (tracked) recreated++
|
|
2919
|
-
else created++
|
|
2920
|
-
}
|
|
2921
|
-
continue
|
|
2922
|
-
}
|
|
2923
|
-
|
|
2924
|
-
const kind = classify(state, manifest.files[file.dest])
|
|
2925
|
-
|
|
2926
|
-
if (kind === 'current') {
|
|
2927
|
-
// On-disk bytes already equal `desired` exactly — record the ledger's normalized form
|
|
2928
|
-
// (not the raw bytes) so this entry keeps behaving like every other `writeUnit`-recorded
|
|
2929
|
-
// hash for a future classify() call.
|
|
2930
|
-
manifest.files[file.dest] = sha256(normalizeForLedger(state.desired))
|
|
2931
|
-
continue
|
|
2932
|
-
}
|
|
2933
|
-
|
|
2934
|
-
if (kind === 'drifted' && !opts.force) {
|
|
2935
|
-
driftLines.push(diffSummary(file, state))
|
|
2936
|
-
staleForCheck++
|
|
2937
|
-
skippedDrift++
|
|
2938
|
-
continue
|
|
2939
|
-
}
|
|
2940
|
-
|
|
2941
|
-
// missing | unchanged | (drifted && force) → write the shipped version.
|
|
2942
|
-
staleForCheck++
|
|
2943
|
-
if (opts.check) continue
|
|
2944
|
-
manifest.files[file.dest] = writeUnit(file, cwd, state.desired)
|
|
2945
|
-
if (kind !== 'missing') updated++
|
|
2946
|
-
else if (tracked) recreated++
|
|
2947
|
-
else created++
|
|
2948
|
-
}
|
|
2949
|
-
|
|
2950
|
-
// Retired rules/skills: the derived namespaces (RULE_NAMES / SKILL_NAMES) are the only managed
|
|
2951
|
-
// sets whose MEMBERSHIP moves between versions, so this is where an upstream deletion reaches a
|
|
2952
|
-
// consumer. Counted into `staleForCheck` so `sync --check` is red until it is applied — a rule
|
|
2953
|
-
// file basalt stopped shipping is drift, and the agent goes on reading it otherwise.
|
|
2954
|
-
const pruned = pruneRetiredManagedFiles(cwd, manifest, files, {
|
|
2955
|
-
dryRun: opts.check === true,
|
|
2956
|
-
force: opts.force === true,
|
|
2957
|
-
})
|
|
2958
|
-
staleForCheck += pruned.removed.length + pruned.drifted.length
|
|
2959
|
-
|
|
2960
|
-
// Placement notices — informational, never affect the exit code (a skipped tooling seed or a
|
|
2961
|
-
// relocated scaffold is a legitimate consumer choice, not a sync failure).
|
|
2962
|
-
if (!placement.isPackageRepoRoot) {
|
|
2963
|
-
console.log(
|
|
2964
|
-
`basalt-ui sync: skipped lefthook.yml (this package is not the repo root — repo root ` +
|
|
2965
|
-
`detected at ${placement.repoRoot}) — lefthook only reads config at the repo root; extend ` +
|
|
2966
|
-
'node_modules/basalt-ui/configs/lefthook.yml from your root lefthook.yml instead.',
|
|
2967
|
-
)
|
|
2968
|
-
console.log(
|
|
2969
|
-
`basalt-ui sync: skipped .github/workflows/check.yml (this package is not the repo root — ` +
|
|
2970
|
-
`repo root detected at ${placement.repoRoot}) — GitHub Actions only reads .github/ at the ` +
|
|
2971
|
-
'repo root; extend node_modules/basalt-ui/configs/check.yml from your root CI workflow instead.',
|
|
2972
|
-
)
|
|
2973
|
-
}
|
|
2974
|
-
if (placement.relocatedQueryClient !== null) {
|
|
2975
|
-
console.log(
|
|
2976
|
-
`basalt-ui sync: skipped src/query-client.ts (found an existing query client at ` +
|
|
2977
|
-
`${relative(cwd, placement.relocatedQueryClient)}) — import it from there instead of ` +
|
|
2978
|
-
're-seeding at the original path.',
|
|
2979
|
-
)
|
|
2980
|
-
}
|
|
2981
|
-
|
|
2982
|
-
const rootsMessages = rootsNotices(rootsState)
|
|
2983
|
-
for (const line of rootsMessages.log) console.log(`basalt-ui sync: ${line}`)
|
|
2984
|
-
for (const line of rootsMessages.error) console.error(`⚠ basalt-ui sync: ${line}`)
|
|
2985
|
-
|
|
2986
|
-
// The two `extends` seams sync does NOT own. Reported here because sync is the command an
|
|
2987
|
-
// UPGRADE runs, and an upgrade is exactly when a resolved path goes stale — 1.20.0 fixed the
|
|
2988
|
-
// paths `init` renders and reached no existing consumer at all. Informational: these are
|
|
2989
|
-
// consumer-owned files, and `doctor` is the gate that fails on them.
|
|
2990
|
-
const install = findBasaltInstall(cwd)
|
|
2991
|
-
const lefthookSeam = inspectLefthookSeam(placement.repoRoot)
|
|
2992
|
-
if (lefthookSeam.kind === 'broken') {
|
|
2993
|
-
console.error(
|
|
2994
|
-
`⚠ basalt-ui sync: ${lefthookSeam.file} extends "${lefthookSeam.entry}", which does not ` +
|
|
2995
|
-
'exist — lefthook merges a missing extends target into ZERO commands and still exits 0, ' +
|
|
2996
|
-
'so there is no pre-commit gate. Repoint it at ' +
|
|
2997
|
-
`"${shippedAssetPath(install, placement.repoRoot, 'configs/lefthook.yml')}".`,
|
|
2998
|
-
)
|
|
2999
|
-
}
|
|
3000
|
-
const oxlintrcRaw = readIfExists(resolve(cwd, '.oxlintrc.json'))
|
|
3001
|
-
const oxlintEntry =
|
|
3002
|
-
oxlintrcRaw === null ? null : basaltPresetEntry(parseJsonc(oxlintrcRaw)?.['extends'])
|
|
3003
|
-
if (oxlintEntry !== null && !existsSync(resolve(cwd, oxlintEntry))) {
|
|
3004
|
-
console.error(
|
|
3005
|
-
`⚠ basalt-ui sync: .oxlintrc.json extends "${oxlintEntry}", which does not exist — oxlint ` +
|
|
3006
|
-
'refuses to start on a missing extends target (`NotFound`). Repoint it at ' +
|
|
3007
|
-
`"${shippedAssetPath(install, cwd, 'configs/oxlint.json')}".`,
|
|
3008
|
-
)
|
|
3009
|
-
}
|
|
3010
|
-
|
|
3011
|
-
if (opts.check) {
|
|
3012
|
-
if (driftLines.length > 0) {
|
|
3013
|
-
console.error('basalt-ui sync --check: locally-drifted managed files:')
|
|
3014
|
-
for (const l of driftLines) console.error(l)
|
|
3015
|
-
}
|
|
3016
|
-
const retired = [...pruned.removed, ...pruned.drifted]
|
|
3017
|
-
if (retired.length > 0) {
|
|
3018
|
-
console.error(
|
|
3019
|
-
`basalt-ui sync --check: ${retired.length} retired rule/skill file(s) still present: ` +
|
|
3020
|
-
`${retired.join(', ')} — run \`basalt-ui sync\`.`,
|
|
3021
|
-
)
|
|
3022
|
-
}
|
|
3023
|
-
if (staleForCheck > 0) {
|
|
3024
|
-
console.error(`basalt-ui sync --check: ${staleForCheck} managed file(s) out of date.`)
|
|
3025
|
-
return 1
|
|
3026
|
-
}
|
|
3027
|
-
console.log('✓ basalt-ui sync --check: all managed files current.')
|
|
3028
|
-
return 0
|
|
3029
|
-
}
|
|
3030
|
-
|
|
3031
|
-
manifest.basaltVersion = ctx.vars.BASALT_VERSION
|
|
3032
|
-
manifest.spacingScale = { ...deriveSpacing(0).scale }
|
|
3033
|
-
writeFileEnsuringDir(resolve(cwd, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
3034
|
-
|
|
3035
|
-
console.log(
|
|
3036
|
-
`basalt-ui sync: ${updated} updated, ${created} created, ${recreated} recreated, ` +
|
|
3037
|
-
`${skippedDrift} skipped (drift), ${pruned.removed.length} retired.`,
|
|
3038
|
-
)
|
|
3039
|
-
reportPrune(pruned)
|
|
3040
|
-
if (driftLines.length > 0) {
|
|
3041
|
-
console.log('Locally-edited files were skipped (run with --force to overwrite):')
|
|
3042
|
-
for (const l of driftLines) console.log(l)
|
|
3043
|
-
}
|
|
3044
|
-
if (missingSources.length > 0) {
|
|
3045
|
-
console.log(
|
|
3046
|
-
`basalt-ui sync: ${missingSources.length} shipped asset(s) not present, skipped: ${missingSources.join(', ')}`,
|
|
3047
|
-
)
|
|
3048
|
-
}
|
|
3049
|
-
return 0
|
|
3050
|
-
}
|
|
3051
|
-
|
|
3052
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
3053
|
-
// coverage headers — the generated `<!-- basalt:coverage -->` block per rule file
|
|
3054
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
3055
|
-
|
|
3056
|
-
/** The block's delimiters. Both are HTML comments, so the block renders as nothing in a viewer. */
|
|
3057
|
-
export const COVERAGE_BLOCK_OPEN = '<!-- basalt:coverage -->'
|
|
3058
|
-
export const COVERAGE_BLOCK_CLOSE = '<!-- /basalt:coverage -->'
|
|
3059
|
-
|
|
3060
|
-
const COVERAGE_BLOCK_RE = /<!-- basalt:coverage -->[\s\S]*?<!-- \/basalt:coverage -->/
|
|
3061
|
-
|
|
3062
|
-
/**
|
|
3063
|
-
* What actually enforces one rule file's doctrine, as the file itself should state it.
|
|
3064
|
-
*
|
|
3065
|
-
* Derived from SURFACES — the union over every doctrine surface carrying that `rule`, because
|
|
3066
|
-
* `rule` is many-to-one (`data` covers `./data`, `./data/table`, `./data/virtual`; `mantine` covers
|
|
3067
|
-
* `.` and `./connectivity`). Naming only one surface's coverage would understate the rule.
|
|
3068
|
-
*/
|
|
3069
|
-
export function coverageFor(rule: RuleName): {
|
|
3070
|
-
guardKinds: string[]
|
|
3071
|
-
pluginRules: string[]
|
|
3072
|
-
advisoryLaws: string[]
|
|
3073
|
-
} {
|
|
3074
|
-
const specs = (Object.values(SURFACES) as SurfaceSpec[]).filter(
|
|
3075
|
-
(s): s is DoctrineSpec => s.kind === 'doctrine' && s.rule === rule,
|
|
3076
|
-
)
|
|
3077
|
-
const unique = (values: readonly string[]): string[] => [...new Set(values)].toSorted()
|
|
3078
|
-
return {
|
|
3079
|
-
guardKinds: unique(specs.flatMap((s) => [...s.guardKinds])),
|
|
3080
|
-
pluginRules: unique(specs.flatMap((s) => [...s.pluginRules])),
|
|
3081
|
-
// NOT sorted, and not deduped across surfaces by content: an advisory law is a sentence, and
|
|
3082
|
-
// its order is the order the spec argues it in.
|
|
3083
|
-
advisoryLaws: specs.flatMap((s) => [...(s.advisoryLaws ?? [])]),
|
|
3084
|
-
}
|
|
3085
|
-
}
|
|
3086
|
-
|
|
3087
|
-
/**
|
|
3088
|
-
* The generated block for one rule, verbatim — HTML comments only, one claim per line so a diff
|
|
3089
|
-
* points at the claim that changed rather than at a reflowed paragraph.
|
|
3090
|
-
*
|
|
3091
|
-
* `not guarded` is printed even when empty, and that is the point: a rule file whose block says
|
|
3092
|
-
* `not guarded: —` is claiming full coverage, which is a claim someone can check. A block that
|
|
3093
|
-
* simply omitted the section would read as "nothing to declare" whether or not anyone had looked
|
|
3094
|
-
* (D8 — see docs/CONTROLS-SPEC.md §6 "Honest coverage").
|
|
3095
|
-
*/
|
|
3096
|
-
export function coverageBlock(rule: RuleName): string {
|
|
3097
|
-
const { guardKinds, pluginRules, advisoryLaws } = coverageFor(rule)
|
|
3098
|
-
const backedBy = [
|
|
3099
|
-
guardKinds.length > 0 ? `guard kinds — ${guardKinds.join(', ')}` : 'guard kinds — none',
|
|
3100
|
-
pluginRules.length > 0
|
|
3101
|
-
? `oxlint rules — ${pluginRules.map((id) => `basalt/${id}`).join(', ')}`
|
|
3102
|
-
: 'oxlint rules — none',
|
|
3103
|
-
].join(' · ')
|
|
3104
|
-
const lines = [
|
|
3105
|
-
COVERAGE_BLOCK_OPEN,
|
|
3106
|
-
'<!-- GENERATED from src/surfaces.ts — `basalt-ui check-coverage --write`. Do not hand-edit. -->',
|
|
3107
|
-
`<!-- backed by: ${backedBy} -->`,
|
|
3108
|
-
]
|
|
3109
|
-
if (advisoryLaws.length === 0) lines.push('<!-- not guarded: — -->')
|
|
3110
|
-
else for (const law of advisoryLaws) lines.push(`<!-- not guarded: ${law} -->`)
|
|
3111
|
-
lines.push(COVERAGE_BLOCK_CLOSE)
|
|
3112
|
-
return lines.join('\n')
|
|
3113
|
-
}
|
|
3114
|
-
|
|
3115
|
-
/** The block a rule file currently carries, or null when it has none yet. */
|
|
3116
|
-
export function readCoverageBlock(text: string): string | null {
|
|
3117
|
-
return COVERAGE_BLOCK_RE.exec(text)?.[0] ?? null
|
|
3118
|
-
}
|
|
3119
|
-
|
|
3120
|
-
/**
|
|
3121
|
-
* `text` with `block` in it — replacing the existing block, or inserted directly below the YAML
|
|
3122
|
-
* frontmatter (the rule files' `paths:` header is load-bearing and must stay first).
|
|
3123
|
-
*/
|
|
3124
|
-
export function applyCoverageBlock(text: string, block: string): string {
|
|
3125
|
-
if (COVERAGE_BLOCK_RE.test(text)) return text.replace(COVERAGE_BLOCK_RE, block)
|
|
3126
|
-
const frontmatter = /^---\n[\s\S]*?\n---\n/.exec(text)
|
|
3127
|
-
if (frontmatter === null) return `${block}\n\n${text}`
|
|
3128
|
-
const end = frontmatter[0].length
|
|
3129
|
-
return `${text.slice(0, end)}\n${block}\n${text.slice(end)}`
|
|
3130
|
-
}
|
|
3131
|
-
|
|
3132
|
-
/**
|
|
3133
|
-
* Reconcile every rule file's coverage block against SURFACES.
|
|
3134
|
-
*
|
|
3135
|
-
* `'write'` refreshes them; `'check'` is the CI gate. A rule file with NO block yet is REPORTED,
|
|
3136
|
-
* never failed — the blocks are inserted by the agent-layer wave that rewrites those files, and a
|
|
3137
|
-
* gate that failed before they exist would have to be landed disabled, which is how a gate stays
|
|
3138
|
-
* disabled. A block that exists and DISAGREES is a hard failure: that is drift, and drift in a
|
|
3139
|
-
* coverage claim is the defect the block was added for.
|
|
3140
|
-
*/
|
|
3141
|
-
export function reconcileCoverageBlocks(
|
|
3142
|
-
pkgRoot: string,
|
|
3143
|
-
mode: 'write' | 'check',
|
|
3144
|
-
): { failures: string[]; notes: string[] } {
|
|
3145
|
-
const failures: string[] = []
|
|
3146
|
-
const notes: string[] = []
|
|
3147
|
-
for (const rule of RULE_NAMES) {
|
|
3148
|
-
const rel = `agent/rules/basalt-${rule}.md`
|
|
3149
|
-
const path = resolve(pkgRoot, rel)
|
|
3150
|
-
if (!existsSync(path)) continue // assertion 2 already reports a missing rule file
|
|
3151
|
-
const text = readFileSync(path, 'utf8')
|
|
3152
|
-
const current = readCoverageBlock(text)
|
|
3153
|
-
const expected = coverageBlock(rule)
|
|
3154
|
-
if (mode === 'check') {
|
|
3155
|
-
if (current === null) notes.push(`${rel}: no <!-- basalt:coverage --> block yet`)
|
|
3156
|
-
else if (current !== expected) {
|
|
3157
|
-
failures.push(
|
|
3158
|
-
`${rel}: coverage block is out of sync with SURFACES (check-coverage --write)`,
|
|
3159
|
-
)
|
|
3160
|
-
}
|
|
3161
|
-
continue
|
|
3162
|
-
}
|
|
3163
|
-
if (current === expected) {
|
|
3164
|
-
notes.push(`${rel}: unchanged`)
|
|
3165
|
-
continue
|
|
3166
|
-
}
|
|
3167
|
-
writeFileSync(path, applyCoverageBlock(text, expected), 'utf8')
|
|
3168
|
-
notes.push(`${rel}: ${current === null ? 'block inserted' : 'block updated'}`)
|
|
3169
|
-
}
|
|
3170
|
-
return { failures, notes }
|
|
3171
|
-
}
|
|
3172
|
-
|
|
3173
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
3174
|
-
// check-coverage — 10-assertion coverage gate
|
|
3175
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
3176
|
-
|
|
3177
|
-
/**
|
|
3178
|
-
* Per-file line budgets for the shipped agent layer (`docs/CONTROLS-SPEC.md` §7's target table).
|
|
3179
|
-
*
|
|
3180
|
-
* A budget, not a guideline: seven `src/**`-scoped rules loaded ~2,000 lines on any source edit
|
|
3181
|
-
* before the merge, and 55% of that text was doctrine nothing enforced. The numbers are the bar the
|
|
3182
|
-
* spec argued, so they live beside the gate that reads them rather than in prose.
|
|
3183
|
-
*/
|
|
3184
|
-
const AGENT_LINE_BUDGETS: Readonly<Record<string, number>> = {
|
|
3185
|
-
'agent/rules/basalt-tokens.md': 160,
|
|
3186
|
-
'agent/rules/basalt-mantine.md': 180,
|
|
3187
|
-
'agent/rules/basalt-charts.md': 140,
|
|
3188
|
-
'agent/rules/basalt-state.md': 160,
|
|
3189
|
-
'agent/rules/basalt-controls.md': 185,
|
|
3190
|
-
'agent/rules/basalt-batteries.md': 220,
|
|
3191
|
-
'agent/skills/basalt-app/SKILL.md': 100,
|
|
3192
|
-
'agent/skills/basalt-design/SKILL.md': 100,
|
|
3193
|
-
'agent/skills/basalt-charts/SKILL.md': 100,
|
|
3194
|
-
'agent/templates/CLAUDE-block.md.tpl': 40,
|
|
3195
|
-
'agent/templates/DESIGN.md.tpl': 45,
|
|
3196
|
-
}
|
|
3197
|
-
|
|
3198
|
-
/** The whole-layer bar — a per-file budget alone can be satisfied by adding a seventh rule file. */
|
|
3199
|
-
const AGENT_RULE_TOTAL_BUDGET = 1050
|
|
3200
|
-
|
|
3201
|
-
/**
|
|
3202
|
-
* Assert the 11 invariants against the live SURFACES + GUARD_RULES + the shipped agent layer, and
|
|
3203
|
-
* (with `--write` / `--check`) reconcile the generated `<!-- basalt:coverage -->` header of every
|
|
3204
|
-
* rule file against SURFACES. Returns 0 when all pass; 1 when any fail (console.error each failure).
|
|
3205
|
-
*
|
|
3206
|
-
* Eleven assertions:
|
|
3207
|
-
* 1. Every doctrine spec's guardKinds ⊆ keyof GUARD_RULES.
|
|
3208
|
-
* 2. Every doctrine rule (deduped) maps to agent/rules/basalt-{rule}.md on disk.
|
|
3209
|
-
* 3. Every doctrine skill (deduped) maps to agent/skills/{skill}/SKILL.md on disk.
|
|
3210
|
-
* 4. Every non-#, non-'.' JS-subpath SURFACES key has a package.json exports entry.
|
|
3211
|
-
* 5. Every real package.json exports key has a SURFACES entry.
|
|
3212
|
-
* 6. Every surface with non-empty forbiddenImports has a globs field.
|
|
3213
|
-
* 7. Every headless surface is Mantine-free — via all 3 `forbiddenImports` bans, OR (for
|
|
3214
|
-
* `TOKEN_LAYER_BOUNDARY_SURFACES` members, `./charts`/`./tokens`) exemption, since their
|
|
3215
|
-
* Mantine-free guarantee is covered by the repo-local-only `basalt/token-layer-boundary`
|
|
3216
|
-
* plugin rule instead — this assertion cannot verify that rule's live registration itself (see
|
|
3217
|
-
* the assertion's own comment below for why); basalt's own CI does
|
|
3218
|
-
* (`tests/surfaces-coverage.test.ts`).
|
|
3219
|
-
* 8. Every doctrine optionalPeers entry exists in peerDependencies AND peerDependenciesMeta.
|
|
3220
|
-
* 9. Every registered oxlint plugin rule (PLUGIN_RULE_ID_LIST) maps to EXACTLY ONE surface's
|
|
3221
|
-
* `pluginRules` — so a new rule cannot ship homeless, and one rule cannot be counted twice as
|
|
3222
|
-
* coverage. Like assertion 7 this reads SURFACES only: whether the plugin really registers
|
|
3223
|
-
* those ids is asserted against the plugin itself in `configs/oxlint-plugin.test.ts`, which a
|
|
3224
|
-
* shipped CLI running from a consumer's node_modules cannot do.
|
|
3225
|
-
* 10. Every shipped agent-layer file is inside its line budget, and the six rules are inside the
|
|
3226
|
-
* whole-layer total (`AGENT_LINE_BUDGETS` / `AGENT_RULE_TOTAL_BUDGET`).
|
|
3227
|
-
* 11. Every `GUARD_RULES` kind appears on at least one doctrine surface — assertion 9's twin for
|
|
3228
|
-
* the text lane. Without it a kind could ship, be documented as a law in the spec, and be
|
|
3229
|
-
* omitted from every generated coverage header, which is how two wave-6 kinds left
|
|
3230
|
-
* `agent/rules/basalt-controls.md` printing "guard kinds — none" (D8). Unlike 9 this is
|
|
3231
|
-
* one-directional: a kind may legitimately appear on SEVERAL surfaces (`raw-hex` is on
|
|
3232
|
-
* `./charts` and `./tokens`), because a text scan is not partitioned by subpath the way a
|
|
3233
|
-
* plugin rule id is.
|
|
3234
|
-
*
|
|
3235
|
-
* Tooling surfaces are exempt from assertions 1–3 by the discriminant.
|
|
3236
|
-
* Synthetic #-keys participate in assertions 1 and 2 but feed assertion 3 only
|
|
3237
|
-
* via the deduped skill union (no independent per-#-surface skill row).
|
|
3238
|
-
*/
|
|
3239
|
-
export function checkCoverage(flags: readonly string[] = []): number {
|
|
3240
|
-
const pkgRoot = packageRoot()
|
|
3241
|
-
const failures: string[] = []
|
|
3242
|
-
const notes: string[] = []
|
|
3243
|
-
|
|
3244
|
-
const allSpecs = Object.entries(SURFACES) as [string, SurfaceSpec][]
|
|
3245
|
-
const doctrineSpecs = (Object.values(SURFACES) as SurfaceSpec[]).filter(
|
|
3246
|
-
(s): s is DoctrineSpec => s.kind === 'doctrine',
|
|
3247
|
-
)
|
|
3248
|
-
|
|
3249
|
-
// ── Assertion 1: every doctrine spec's guardKinds ⊆ keyof GUARD_RULES ──────
|
|
3250
|
-
const validGuardKinds = new Set(Object.keys(GUARD_RULES))
|
|
3251
|
-
for (const [key, spec] of allSpecs) {
|
|
3252
|
-
if (spec.kind !== 'doctrine') continue
|
|
3253
|
-
for (const kind of spec.guardKinds) {
|
|
3254
|
-
if (!validGuardKinds.has(kind)) {
|
|
3255
|
-
failures.push(
|
|
3256
|
-
`SURFACES['${key}'].guardKinds includes '${kind}' which is not in GUARD_RULES`,
|
|
3257
|
-
)
|
|
3258
|
-
}
|
|
3259
|
-
}
|
|
3260
|
-
}
|
|
3261
|
-
|
|
3262
|
-
// ── Assertion 2: every doctrine rule (deduped) → agent/rules/basalt-{rule}.md ──
|
|
3263
|
-
for (const rule of RULE_NAMES) {
|
|
3264
|
-
const rulePath = resolve(pkgRoot, `agent/rules/basalt-${rule}.md`)
|
|
3265
|
-
if (!existsSync(rulePath)) {
|
|
3266
|
-
failures.push(
|
|
3267
|
-
`Missing rule file: agent/rules/basalt-${rule}.md (derived from SURFACES doctrine rules)`,
|
|
3268
|
-
)
|
|
3269
|
-
}
|
|
3270
|
-
}
|
|
3271
|
-
|
|
3272
|
-
// ── Assertion 3: every doctrine skill (deduped) → agent/skills/{skill}/SKILL.md ──
|
|
3273
|
-
// Mirrors assertion 2's shape — skills ship in the npm package and are placed by init/sync,
|
|
3274
|
-
// so a skill referenced by SURFACES must exist as a shipped asset at the package root.
|
|
3275
|
-
for (const skill of SKILL_NAMES) {
|
|
3276
|
-
const skillPath = resolve(pkgRoot, `agent/skills/${skill}/SKILL.md`)
|
|
3277
|
-
if (!existsSync(skillPath)) {
|
|
3278
|
-
failures.push(
|
|
3279
|
-
`Missing skill file: agent/skills/${skill}/SKILL.md (derived from SURFACES doctrine skills)`,
|
|
3280
|
-
)
|
|
3281
|
-
}
|
|
3282
|
-
}
|
|
3283
|
-
|
|
3284
|
-
// ── Assertion 4: subpath-export-coverage ────────────────────────────────────
|
|
3285
|
-
// Every non-#, non-'.' JS-subpath SURFACES key must have a package.json exports entry.
|
|
3286
|
-
let pkgExports: Set<string> = new Set()
|
|
3287
|
-
try {
|
|
3288
|
-
const consumerPkgPath = resolve(pkgRoot, 'package.json')
|
|
3289
|
-
const consumerPkg = JSON.parse(readFileSync(consumerPkgPath, 'utf8')) as {
|
|
3290
|
-
exports?: Record<string, unknown>
|
|
3291
|
-
}
|
|
3292
|
-
pkgExports = new Set(Object.keys(consumerPkg.exports ?? {}))
|
|
3293
|
-
} catch {
|
|
3294
|
-
failures.push(`Cannot read package.json at ${pkgRoot}`)
|
|
3295
|
-
}
|
|
3296
|
-
|
|
3297
|
-
for (const key of Object.keys(SURFACES)) {
|
|
3298
|
-
if (key.startsWith('#') || key === '.') continue
|
|
3299
|
-
if (!pkgExports.has(key)) {
|
|
3300
|
-
failures.push(
|
|
3301
|
-
`SURFACES key '${key}' is a JS subpath but has no entry in package.json exports`,
|
|
3302
|
-
)
|
|
3303
|
-
}
|
|
3304
|
-
}
|
|
3305
|
-
|
|
3306
|
-
// ── Assertion 5: every real package.json exports key has a SURFACES entry ────
|
|
3307
|
-
// Excludes ./styles.css, ./package.json and ./configs/* (non-JS assets / raw file paths).
|
|
3308
|
-
for (const exportKey of pkgExports) {
|
|
3309
|
-
if (
|
|
3310
|
-
exportKey === '.' ||
|
|
3311
|
-
exportKey === './styles.css' ||
|
|
3312
|
-
exportKey === './package.json' ||
|
|
3313
|
-
exportKey.startsWith('./configs/')
|
|
3314
|
-
)
|
|
3315
|
-
continue
|
|
3316
|
-
if (!Object.hasOwn(SURFACES, exportKey)) {
|
|
3317
|
-
failures.push(`package.json exports key '${exportKey}' has no matching SURFACES entry`)
|
|
3318
|
-
}
|
|
3319
|
-
}
|
|
3320
|
-
|
|
3321
|
-
// ── Assertion 6: every surface with non-empty forbiddenImports has a globs field ──
|
|
3322
|
-
for (const [key, spec] of allSpecs) {
|
|
3323
|
-
if (spec.forbiddenImports.length === 0) continue
|
|
3324
|
-
if (!('globs' in spec) || spec.globs === undefined) {
|
|
3325
|
-
failures.push(
|
|
3326
|
-
`SURFACES['${key}'] has non-empty forbiddenImports but no globs field (required for oxlint emission)`,
|
|
3327
|
-
)
|
|
3328
|
-
}
|
|
3329
|
-
}
|
|
3330
|
-
|
|
3331
|
-
// ── Assertion 7: every headless surface is Mantine-free ──────────────────────
|
|
3332
|
-
// Guarantees the Mantine-free boundary — a future headless surface without coverage fails here.
|
|
3333
|
-
// Coverage is either all 3 forbiddenImports bans, OR membership in
|
|
3334
|
-
// `TOKEN_LAYER_BOUNDARY_SURFACES` (`./charts`/`./tokens`), whose Mantine-free guarantee comes
|
|
3335
|
-
// from the `basalt/token-layer-boundary` plugin rule instead — protects both layering (tokens
|
|
3336
|
-
// upstream of Mantine) and packaging (those two subpaths resolve with no `@mantine/*` installed,
|
|
3337
|
-
// CI-tested via `scripts/pack-test.sh` + `scripts/check-dist-layering.mjs`; see surfaces.ts's
|
|
3338
|
-
// `TOKEN_LAYER_BOUNDARY_SURFACES` doc comment). This assertion CANNOT also verify
|
|
3339
|
-
// that rule's live registration: `check-coverage` is a shipped CLI subcommand whose assertions
|
|
3340
|
-
// must all read pkgRoot-relative paths so it works from inside a consumer's node_modules — and a
|
|
3341
|
-
// repo-local `.oxlintrc.json` read from an installed package path-resolves to the CONSUMER's own
|
|
3342
|
-
// config, not basalt's. That "fails loudly if enforcement is removed" guarantee lives in
|
|
3343
|
-
// basalt's own CI instead (`tests/surfaces-coverage.test.ts`, via
|
|
3344
|
-
// `hasTokenLayerBoundaryRegistered` against `.oxlintrc.json`).
|
|
3345
|
-
const REQUIRED_MANTINE_BANS = ['@mantine/core', '@mantine/hooks', '@mantine/*'] as const
|
|
3346
|
-
|
|
3347
|
-
for (const [key, spec] of allSpecs) {
|
|
3348
|
-
if (spec.layer !== 'headless') continue
|
|
3349
|
-
if (TOKEN_LAYER_BOUNDARY_SURFACES.has(key)) continue
|
|
3350
|
-
for (const required of REQUIRED_MANTINE_BANS) {
|
|
3351
|
-
const hasBan = spec.forbiddenImports.some((fi) => fi.spec === required)
|
|
3352
|
-
if (!hasBan) {
|
|
3353
|
-
failures.push(
|
|
3354
|
-
`SURFACES['${key}'] is headless but missing Mantine ban for '${required}' in forbiddenImports`,
|
|
3355
|
-
)
|
|
3356
|
-
}
|
|
3357
|
-
}
|
|
3358
|
-
}
|
|
3359
|
-
|
|
3360
|
-
// ── Assertion 8: every doctrine optionalPeers entry → peerDependencies + peerDependenciesMeta ──
|
|
3361
|
-
// Closes the silent-drop gap: an optionalPeer listed in SURFACES but absent from package.json
|
|
3362
|
-
// means the published package never tells npm about the dependency.
|
|
3363
|
-
let peerDependencies: Record<string, string> = {}
|
|
3364
|
-
let peerDependenciesMeta: Record<string, { optional?: boolean }> = {}
|
|
3365
|
-
try {
|
|
3366
|
-
const frameworkPkg = JSON.parse(readFileSync(resolve(pkgRoot, 'package.json'), 'utf8')) as {
|
|
3367
|
-
peerDependencies?: Record<string, string>
|
|
3368
|
-
peerDependenciesMeta?: Record<string, { optional?: boolean }>
|
|
3369
|
-
}
|
|
3370
|
-
peerDependencies = frameworkPkg.peerDependencies ?? {}
|
|
3371
|
-
peerDependenciesMeta = frameworkPkg.peerDependenciesMeta ?? {}
|
|
3372
|
-
} catch {
|
|
3373
|
-
failures.push(`Cannot read package.json peerDependencies at ${pkgRoot}`)
|
|
3374
|
-
}
|
|
3375
|
-
|
|
3376
|
-
for (const spec of doctrineSpecs) {
|
|
3377
|
-
for (const peer of spec.optionalPeers ?? []) {
|
|
3378
|
-
if (!(peer in peerDependencies)) {
|
|
3379
|
-
failures.push(
|
|
3380
|
-
`SURFACES doctrine optionalPeer '${peer}' is not listed in package.json peerDependencies`,
|
|
3381
|
-
)
|
|
3382
|
-
}
|
|
3383
|
-
if (peerDependenciesMeta[peer]?.optional !== true) {
|
|
3384
|
-
failures.push(
|
|
3385
|
-
`SURFACES doctrine optionalPeer '${peer}' is not marked optional in package.json peerDependenciesMeta`,
|
|
3386
|
-
)
|
|
3387
|
-
}
|
|
3388
|
-
}
|
|
3389
|
-
}
|
|
3390
|
-
|
|
3391
|
-
// ── Assertion 9: every plugin rule maps to exactly one surface ───────────────
|
|
3392
|
-
// The plugin's own rules were the one half of the enforcement seam SURFACES did not model, so a
|
|
3393
|
-
// generated coverage header could name a guard kind and stay silent about the AST rule doing the
|
|
3394
|
-
// actual work (D8). Now every id has exactly one home, and "exactly" is both directions: an
|
|
3395
|
-
// unmapped id is a rule no doc claims, a doubly-mapped one is coverage counted twice.
|
|
3396
|
-
const surfacesPerPluginRule = new Map<string, string[]>()
|
|
3397
|
-
for (const [key, spec] of allSpecs) {
|
|
3398
|
-
if (spec.kind !== 'doctrine') continue
|
|
3399
|
-
for (const id of spec.pluginRules) {
|
|
3400
|
-
surfacesPerPluginRule.set(id, [...(surfacesPerPluginRule.get(id) ?? []), key])
|
|
3401
|
-
}
|
|
3402
|
-
}
|
|
3403
|
-
for (const id of PLUGIN_RULE_ID_LIST) {
|
|
3404
|
-
const owners = surfacesPerPluginRule.get(id) ?? []
|
|
3405
|
-
if (owners.length === 1) continue
|
|
3406
|
-
failures.push(
|
|
3407
|
-
owners.length === 0
|
|
3408
|
-
? `Plugin rule 'basalt/${id}' maps to no surface — add it to one surface's pluginRules`
|
|
3409
|
-
: `Plugin rule 'basalt/${id}' maps to ${owners.length} surfaces (${owners.join(', ')}) — exactly one`,
|
|
3410
|
-
)
|
|
3411
|
-
}
|
|
3412
|
-
|
|
3413
|
-
// ── Assertion 10: the agent layer stays inside its line budgets ─────────────
|
|
3414
|
-
// The budgets ARE the doctrine (docs/CONTROLS-SPEC.md §7): thirteen rules reached 4,177 lines by
|
|
3415
|
-
// growing a few lines at a time, each addition locally reasonable, and the only thing that
|
|
3416
|
-
// notices is a number nobody is checking. A rule over budget is over budget in the commit that
|
|
3417
|
-
// did it, not four minors later.
|
|
3418
|
-
for (const [rel, budget] of Object.entries(AGENT_LINE_BUDGETS)) {
|
|
3419
|
-
const abs = resolve(pkgRoot, rel)
|
|
3420
|
-
if (!existsSync(abs)) continue // assertions 2/3 already report a missing rule or skill
|
|
3421
|
-
const lines = readFileSync(abs, 'utf8').split('\n').length - 1
|
|
3422
|
-
if (lines > budget) failures.push(`${rel}: ${lines} lines exceeds its ${budget}-line budget`)
|
|
3423
|
-
}
|
|
3424
|
-
const ruleTotal = RULE_NAMES.reduce((sum, rule) => {
|
|
3425
|
-
const abs = resolve(pkgRoot, `agent/rules/basalt-${rule}.md`)
|
|
3426
|
-
return existsSync(abs) ? sum + readFileSync(abs, 'utf8').split('\n').length - 1 : sum
|
|
3427
|
-
}, 0)
|
|
3428
|
-
if (ruleTotal > AGENT_RULE_TOTAL_BUDGET) {
|
|
3429
|
-
failures.push(
|
|
3430
|
-
`agent/rules/*.md: ${ruleTotal} lines total exceeds the ${AGENT_RULE_TOTAL_BUDGET}-line budget`,
|
|
3431
|
-
)
|
|
3432
|
-
}
|
|
3433
|
-
|
|
3434
|
-
// ── Assertion 11: every guard kind maps to at least one surface ──────────────
|
|
3435
|
-
// The guard-kind twin of assertion 9. Assertion 1 already checks the other direction (no surface
|
|
3436
|
-
// names a kind that does not exist); this one catches the kind nobody claims, whose coverage
|
|
3437
|
-
// header therefore under-reports the lane that actually enforces it.
|
|
3438
|
-
const surfacedGuardKinds = new Set<string>()
|
|
3439
|
-
for (const spec of doctrineSpecs) {
|
|
3440
|
-
for (const kind of spec.guardKinds) surfacedGuardKinds.add(kind)
|
|
3441
|
-
}
|
|
3442
|
-
for (const kind of Object.keys(GUARD_RULES)) {
|
|
3443
|
-
if (surfacedGuardKinds.has(kind)) continue
|
|
3444
|
-
failures.push(
|
|
3445
|
-
`Guard kind '${kind}' maps to no surface — add it to one surface's guardKinds so the ` +
|
|
3446
|
-
`generated coverage headers name it`,
|
|
3447
|
-
)
|
|
3448
|
-
}
|
|
3449
|
-
|
|
3450
|
-
// ── Coverage headers (--write / --check) ─────────────────────────────────────
|
|
3451
|
-
const write = flags.includes('--write')
|
|
3452
|
-
const check = flags.includes('--check')
|
|
3453
|
-
if (write && check) {
|
|
3454
|
-
failures.push('--write and --check are alternatives; pass one')
|
|
3455
|
-
} else if (write || check) {
|
|
3456
|
-
const result = reconcileCoverageBlocks(pkgRoot, write ? 'write' : 'check')
|
|
3457
|
-
failures.push(...result.failures)
|
|
3458
|
-
notes.push(...result.notes)
|
|
3459
|
-
}
|
|
3460
|
-
|
|
3461
|
-
// ── Report ──────────────────────────────────────────────────────────────────
|
|
3462
|
-
for (const note of notes) {
|
|
3463
|
-
console.log(` ${note}`)
|
|
3464
|
-
}
|
|
3465
|
-
|
|
3466
|
-
if (failures.length === 0) {
|
|
3467
|
-
console.log('✓ check-coverage: all 11 assertions pass.')
|
|
3468
|
-
return 0
|
|
3469
|
-
}
|
|
3470
|
-
|
|
3471
|
-
console.error(`✖ check-coverage: ${failures.length} failure(s)`)
|
|
3472
|
-
for (const f of failures) {
|
|
3473
|
-
console.error(` ${f}`)
|
|
3474
|
-
}
|
|
3475
|
-
return 1
|
|
3476
|
-
}
|
|
3477
|
-
|
|
3478
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
3479
|
-
// doctor — consumer repo integration health check
|
|
3480
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
3481
|
-
|
|
3482
|
-
type DoctorResult = {
|
|
3483
|
-
/** Number of hard failures (exit non-zero). */
|
|
3484
|
-
hardFailures: number
|
|
3485
|
-
/** Number of warnings (informational only). */
|
|
3486
|
-
warnings: number
|
|
3487
|
-
/**
|
|
3488
|
-
* Number of checks that could not RUN (as opposed to ran and passed). Counted separately and
|
|
3489
|
-
* exits non-zero on its own: under bun's isolated linker `doctor` used to drop two of its five
|
|
3490
|
-
* checks and still print "All checks passed", which is the same false-green this whole surface
|
|
3491
|
-
* exists to remove. A check that cannot run is not a check that passed.
|
|
3492
|
-
*/
|
|
3493
|
-
skipped: number
|
|
3494
|
-
}
|
|
3495
|
-
|
|
3496
|
-
/**
|
|
3497
|
-
* Which shape of consumer `doctor` is looking at.
|
|
3498
|
-
*
|
|
3499
|
-
* `framework` — the Mantine app the scaffold is for. `tokens-only` — a consumer that took the
|
|
3500
|
-
* `--vx-*` layer and nothing else (rollhook: no Mantine, no manifest, `tokens:css` output). Telling
|
|
3501
|
-
* the second to "run `basalt-ui init`" is the wrong instruction: init scaffolds Mantine rules,
|
|
3502
|
-
* skills and a DESIGN.md it will never read. Auto-detected, forced by `--framework` / `--tokens-only`.
|
|
3503
|
-
*/
|
|
3504
|
-
type DoctorProfile = 'framework' | 'tokens-only'
|
|
3505
|
-
|
|
3506
|
-
type WorkspacePackage = { readonly name: string; readonly dir: string }
|
|
3507
|
-
|
|
3508
|
-
/** {@link collectWorkspacePackages}'s return: the walked packages plus whether a `workspaces`
|
|
3509
|
-
* field was present at all (distinguishes "nothing to compare" from "not a workspace root"). */
|
|
3510
|
-
type WorkspaceWalk = {
|
|
3511
|
-
readonly packages: readonly WorkspacePackage[]
|
|
3512
|
-
readonly hasWorkspacesField: boolean
|
|
3513
|
-
}
|
|
3514
|
-
|
|
3515
|
-
/**
|
|
3516
|
-
* Every subdirectory of `base` (name only), or `[]` if `base` doesn't exist / isn't a directory.
|
|
3517
|
-
* `node_modules` is always excluded — a wildcard workspaces pattern must never descend into a
|
|
3518
|
-
* dependency tree. `skipDotDirs` additionally excludes dot-directories; it is only set by the
|
|
3519
|
-
* `*`/`**` wildcard descent in {@link expandPatternSegments}, never for an explicitly named
|
|
3520
|
-
* literal segment, so a pattern like `.internal/*` still resolves.
|
|
3521
|
-
*/
|
|
3522
|
-
function subdirNames(base: string, options: { skipDotDirs?: boolean } = {}): string[] {
|
|
3523
|
-
let entries: string[]
|
|
3524
|
-
try {
|
|
3525
|
-
entries = readdirSync(base)
|
|
3526
|
-
} catch {
|
|
3527
|
-
return []
|
|
3528
|
-
}
|
|
3529
|
-
return entries.filter((name) => {
|
|
3530
|
-
if (name === 'node_modules') return false
|
|
3531
|
-
if (options.skipDotDirs && name.startsWith('.')) return false
|
|
3532
|
-
try {
|
|
3533
|
-
return statSync(resolve(base, name)).isDirectory()
|
|
3534
|
-
} catch {
|
|
3535
|
-
return false
|
|
3536
|
-
}
|
|
3537
|
-
})
|
|
3538
|
-
}
|
|
3539
|
-
|
|
3540
|
-
/**
|
|
3541
|
-
* Expands one glob PATTERN's segments against the filesystem, starting from `baseDir`. Supports
|
|
3542
|
-
* three segment kinds: a literal name, a single `*` wildcard (any one directory), and a `**`
|
|
3543
|
-
* wildcard (zero or more directory levels, i.e. arbitrary depth) — covering a trailing single
|
|
3544
|
-
* wildcard, an interior wildcard between literal segments, and a trailing double-wildcard alike,
|
|
3545
|
-
* not just one trailing single-wildcard segment.
|
|
3546
|
-
*/
|
|
3547
|
-
function expandPatternSegments(baseDir: string, segments: readonly string[]): string[] {
|
|
3548
|
-
if (segments.length === 0) return [baseDir]
|
|
3549
|
-
const [head, ...rest] = segments
|
|
3550
|
-
|
|
3551
|
-
if (head === '**') {
|
|
3552
|
-
// Zero levels: `**` consumes nothing and the rest of the pattern matches right here.
|
|
3553
|
-
const matches = expandPatternSegments(baseDir, rest)
|
|
3554
|
-
// One-or-more levels: descend into every subdirectory, `**` still active.
|
|
3555
|
-
for (const name of subdirNames(baseDir, { skipDotDirs: true })) {
|
|
3556
|
-
matches.push(...expandPatternSegments(resolve(baseDir, name), segments))
|
|
3557
|
-
}
|
|
3558
|
-
return matches
|
|
3559
|
-
}
|
|
3560
|
-
|
|
3561
|
-
const names =
|
|
3562
|
-
head === '*'
|
|
3563
|
-
? subdirNames(baseDir, { skipDotDirs: true })
|
|
3564
|
-
: subdirNames(baseDir).filter((n) => n === head)
|
|
3565
|
-
return names.flatMap((name) => expandPatternSegments(resolve(baseDir, name), rest))
|
|
3566
|
-
}
|
|
3567
|
-
|
|
3568
|
-
/**
|
|
3569
|
-
* Expands a root `package.json`'s `workspaces` patterns into concrete package directories.
|
|
3570
|
-
* Supports literal segments, a single-wildcard segment, and a double-wildcard segment, both
|
|
3571
|
-
* interior and trailing (e.g. `packages` followed by a double wildcard, or an interior single
|
|
3572
|
-
* wildcard between two literal segments) — not just one trailing single-wildcard segment. A
|
|
3573
|
-
* candidate directory that has no `package.json` of its own is skipped, not reported — an empty
|
|
3574
|
-
* scaffold directory is not a workspace package. Also honours npm/bun/yarn `!`-prefixed exclusion
|
|
3575
|
-
* entries (e.g. `["packages/*", "!packages/legacy"]`) — both the included and excluded patterns
|
|
3576
|
-
* are expanded through the same machinery, then the excluded set is subtracted.
|
|
3577
|
-
*/
|
|
3578
|
-
function expandWorkspaceGlobs(cwd: string, patterns: readonly string[]): string[] {
|
|
3579
|
-
const expand = (pattern: string): string[] =>
|
|
3580
|
-
expandPatternSegments(
|
|
3581
|
-
cwd,
|
|
3582
|
-
pattern.split('/').filter((s) => s.length > 0),
|
|
3583
|
-
)
|
|
3584
|
-
|
|
3585
|
-
const excluded = new Set(
|
|
3586
|
-
patterns
|
|
3587
|
-
.filter((pattern) => pattern.startsWith('!'))
|
|
3588
|
-
.flatMap((pattern) => expand(pattern.replace(/^!+/, ''))),
|
|
3589
|
-
)
|
|
3590
|
-
|
|
3591
|
-
const dirs: string[] = []
|
|
3592
|
-
for (const pattern of patterns) {
|
|
3593
|
-
if (pattern.startsWith('!')) continue
|
|
3594
|
-
for (const abs of expand(pattern)) {
|
|
3595
|
-
if (excluded.has(abs)) continue
|
|
3596
|
-
if (existsSync(resolve(abs, 'package.json'))) dirs.push(abs)
|
|
3597
|
-
}
|
|
3598
|
-
}
|
|
3599
|
-
return dirs
|
|
3600
|
-
}
|
|
3601
|
-
|
|
3602
|
-
/**
|
|
3603
|
-
* Every workspace package declared by `cwd`'s root `package.json` (npm/bun/yarn `workspaces`
|
|
3604
|
-
* field, array or `{ packages: [...] }` object form), named + directory-resolved — PLUS the root
|
|
3605
|
-
* manifest itself, labelled `"{name} (root)"`. The root is walked because it commonly carries a
|
|
3606
|
-
* hoisted `ai` dev dependency that can itself skew against a workspace package's own major — that
|
|
3607
|
-
* is the producer/consumer shape the whole check exists to catch, and it is invisible if only the
|
|
3608
|
-
* expanded workspace dirs are inspected. `hasWorkspacesField` is `false` (and `packages` empty)
|
|
3609
|
-
* when `cwd` has no `package.json`, no `workspaces` field, or the field is a shape this doesn't
|
|
3610
|
-
* recognize — a repo-wide walk is a bonus check, not something that should ever throw doctor over
|
|
3611
|
-
* an unrecognized `workspaces` shape.
|
|
3612
|
-
*/
|
|
3613
|
-
function collectWorkspacePackages(cwd: string): WorkspaceWalk {
|
|
3614
|
-
const raw = readIfExists(resolve(cwd, 'package.json'))
|
|
3615
|
-
if (raw === null) return { packages: [], hasWorkspacesField: false }
|
|
3616
|
-
|
|
3617
|
-
let parsed: { workspaces?: unknown; name?: string }
|
|
3618
|
-
try {
|
|
3619
|
-
parsed = JSON.parse(raw) as { workspaces?: unknown; name?: string }
|
|
3620
|
-
} catch {
|
|
3621
|
-
return { packages: [], hasWorkspacesField: false }
|
|
3622
|
-
}
|
|
3623
|
-
|
|
3624
|
-
const patterns = isStringArray(parsed.workspaces)
|
|
3625
|
-
? parsed.workspaces
|
|
3626
|
-
: parsed.workspaces !== null &&
|
|
3627
|
-
typeof parsed.workspaces === 'object' &&
|
|
3628
|
-
isStringArray((parsed.workspaces as { packages?: unknown }).packages)
|
|
3629
|
-
? (parsed.workspaces as { packages: string[] }).packages
|
|
3630
|
-
: null
|
|
3631
|
-
if (patterns === null) return { packages: [], hasWorkspacesField: false }
|
|
3632
|
-
|
|
3633
|
-
const rootName = parsed.name && parsed.name.length > 0 ? `${parsed.name} (root)` : '(root)'
|
|
3634
|
-
const workspacePackages = expandWorkspaceGlobs(cwd, patterns).map((dir) => ({
|
|
3635
|
-
name: readPackageName(dir),
|
|
3636
|
-
dir,
|
|
3637
|
-
}))
|
|
3638
|
-
|
|
3639
|
-
return {
|
|
3640
|
-
packages: [{ name: rootName, dir: cwd }, ...workspacePackages],
|
|
3641
|
-
hasWorkspacesField: true,
|
|
3642
|
-
}
|
|
3643
|
-
}
|
|
3644
|
-
|
|
3645
|
-
/** Narrows to a string array — shared by `collectWorkspacePackages`'s two `workspaces` shapes. */
|
|
3646
|
-
function isStringArray(value: unknown): value is string[] {
|
|
3647
|
-
return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
|
|
3648
|
-
}
|
|
3649
|
-
|
|
3650
|
-
/**
|
|
3651
|
-
* First run of digits in a semver range string (`^7.0.15` → `7`), or null if there is none.
|
|
3652
|
-
*
|
|
3653
|
-
* Duplicated verbatim in `configs/oxlint-plugin.js` — that file must stay import-free from this
|
|
3654
|
-
* package (it loads via `jsPlugins` out of a consumer's node_modules, before this package's own
|
|
3655
|
-
* code is necessarily resolvable), so the duplication is structural, not an oversight. Keep both
|
|
3656
|
-
* copies in sync by hand; a future parsing fix (pre-release suffixes, say) must land in both.
|
|
3657
|
-
*/
|
|
3658
|
-
function majorOf(range: string | undefined): number | null {
|
|
3659
|
-
if (typeof range !== 'string') return null
|
|
3660
|
-
const match = range.match(/\d+/)
|
|
3661
|
-
return match === null ? null : Number(match[0])
|
|
3662
|
-
}
|
|
3663
|
-
|
|
3664
|
-
/** The `ai` package's declared major at `dir`'s own `package.json`, or null if undeclared/unreadable. */
|
|
3665
|
-
function aiMajorAt(dir: string): number | null {
|
|
3666
|
-
const raw = readIfExists(resolve(dir, 'package.json'))
|
|
3667
|
-
if (raw === null) return null
|
|
3668
|
-
try {
|
|
3669
|
-
const pkg = JSON.parse(raw) as {
|
|
3670
|
-
dependencies?: Record<string, string>
|
|
3671
|
-
devDependencies?: Record<string, string>
|
|
3672
|
-
peerDependencies?: Record<string, string>
|
|
3673
|
-
}
|
|
3674
|
-
return majorOf(
|
|
3675
|
-
pkg.dependencies?.['ai'] ?? pkg.devDependencies?.['ai'] ?? pkg.peerDependencies?.['ai'],
|
|
3676
|
-
)
|
|
3677
|
-
} catch {
|
|
3678
|
-
return null
|
|
3679
|
-
}
|
|
3680
|
-
}
|
|
3681
|
-
|
|
3682
|
-
/**
|
|
3683
|
-
* Splits `basalt.aiMajorSkewReason` into a validated reason (or null) plus whether the key was
|
|
3684
|
-
* present at all but failed validation. A present-but-invalid value (anything other than a
|
|
3685
|
-
* non-empty string — including a bare `true`) is treated as absent for the pass/fail decision, same
|
|
3686
|
-
* as a missing key, but doctor's failure message still calls out that the key exists and is
|
|
3687
|
-
* malformed rather than silently falling back to the plain "no exemption" message — a forgotten key
|
|
3688
|
-
* and a broken one are different mistakes and deserve different guidance.
|
|
3689
|
-
*/
|
|
3690
|
-
function resolveAiMajorSkewReason(cfg: BasaltConfig): {
|
|
3691
|
-
reason: string | null
|
|
3692
|
-
presentButInvalid: boolean
|
|
3693
|
-
} {
|
|
3694
|
-
const raw = cfg.aiMajorSkewReason
|
|
3695
|
-
if (raw === undefined) return { reason: null, presentButInvalid: false }
|
|
3696
|
-
if (typeof raw === 'string' && raw.trim().length > 0) {
|
|
3697
|
-
return { reason: raw, presentButInvalid: false }
|
|
3698
|
-
}
|
|
3699
|
-
return { reason: null, presentButInvalid: true }
|
|
3700
|
-
}
|
|
3701
|
-
|
|
3702
|
-
/**
|
|
3703
|
-
* Check a consumer repo's basalt integration and print a pass/warn report.
|
|
3704
|
-
*
|
|
3705
|
-
* With the plugin retired there is exactly ONE version axis — the npm package — so doctor verifies
|
|
3706
|
-
* one thing: the basalt-ui resolved in the consumer's node_modules against the version recorded in
|
|
3707
|
-
* .basalt/manifest.json at the last init/sync. File-level drift is `sync --check`'s job, not
|
|
3708
|
-
* doctor's.
|
|
3709
|
-
*
|
|
3710
|
-
* The checks are numbered in the order they run and print — there is no gap, and the number in a
|
|
3711
|
-
* section header below is the number in the report.
|
|
3712
|
-
*
|
|
3713
|
-
* Hard failures (exit non-zero):
|
|
3714
|
-
* 1. .basalt/manifest.json exists (init was run).
|
|
3715
|
-
* 2. `basalt-resolves`: basalt-ui resolves from here. Unresolvable used to make checks silently
|
|
3716
|
-
* VANISH from the report (5 checks became 3) while the footer still read "All checks passed".
|
|
3717
|
-
* It is also the moment `bunx basalt-ui` stops using the pinned copy.
|
|
3718
|
-
* 6. `guard-scan`: `check-theme` would scan MORE than zero files. `check-theme` already exits 1
|
|
3719
|
-
* on "0 files scanned"; doctor disagreeing with it in the same repo is the bug.
|
|
3720
|
-
* 7. `oxlint-preset`: the consumer's `.oxlintrc.json` actually `extends` the shipped preset AND
|
|
3721
|
-
* that path RESOLVES. `init` keeps an existing config, so the framework's whole lint half can
|
|
3722
|
-
* be off with nothing to say so — one repo carried six real violations invisibly across five
|
|
3723
|
-
* minors that way. Matching the string alone reproduced the same false green one layer in: a
|
|
3724
|
-
* well-shaped entry pointing at a path that no longer exists passed this check in the very
|
|
3725
|
-
* tree where oxlint refused to start with `NotFound`.
|
|
3726
|
-
* 8. `lefthook-preset`: the repo-root lefthook config's `extends` target resolves. lefthook
|
|
3727
|
-
* merges a MISSING target into zero commands and exits 0 — no error, a clean `lefthook dump`,
|
|
3728
|
-
* and no pre-commit gate. Nothing but a check can see that. Not-extending-at-all is a warning
|
|
3729
|
-
* (a consumer may run their own hooks); extending a path that isn't there is a hard failure.
|
|
3730
|
-
* 10. `ai-major-parity`: every workspace package that declares the `ai` package agrees on its
|
|
3731
|
-
* major version. `basalt/ai-sdk-major` (the oxlint plugin rule) cannot catch this — it checks
|
|
3732
|
-
* a linted file's NEAREST package.json, so a lint run scoped to one workspace package only
|
|
3733
|
-
* ever sees that package's own `ai` major and is perfectly happy; the cross-package skew (one
|
|
3734
|
-
* package streaming on `ai@5`, another parsing it on `ai@7`) only a repo-wide manifest walk
|
|
3735
|
-
* can see. Skipped entirely when `cwd` has no `workspaces` field or none of its packages
|
|
3736
|
-
* declare `ai`. A detected skew is exempt-able via `basalt.aiMajorSkewReason` (a non-empty
|
|
3737
|
-
* reason string, not a bare `true`) in the consumer's package.json — an intentional
|
|
3738
|
-
* producer/consumer split neutralized by a transform is a locked decision, not a defect, and
|
|
3739
|
-
* the written reason IS the pin that a permanently-failing guard would otherwise lack. Missing
|
|
3740
|
-
* or invalid (empty/non-string) still hard-fails exactly as if the key were absent.
|
|
3741
|
-
*
|
|
3742
|
-
* Warnings (non-fatal):
|
|
3743
|
-
* 3. The installed node_modules/basalt-ui version matches the manifest's basaltVersion
|
|
3744
|
-
* (a mismatch means the package was upgraded but `sync` never ran — the placed doctrine is
|
|
3745
|
-
* stale).
|
|
3746
|
-
* 4. The spacing scale still matches the one stamped into the manifest at the last init/sync
|
|
3747
|
-
* (skipped when there is no manifest to compare against; a neutral pass when this CLI is
|
|
3748
|
-
* demonstrably not the installed package — see the check).
|
|
3749
|
-
* 5. The running CLI's own version matches the installed basalt-ui (catches a stale
|
|
3750
|
-
* `bunx basalt-ui` npm fetch; best-effort, skipped if node_modules is absent).
|
|
3751
|
-
* 9. The icon files `basaltAppPlugin` (basalt-ui/vite) references exist under the app package's
|
|
3752
|
-
* public/ — the package found via `basalt.roots`, not just cwd, so a root-invoked run still
|
|
3753
|
-
* reaches it. Never silently omitted: no plugin call anywhere is a pass that says so, a
|
|
3754
|
-
* plugin call with no public/ beside it is a SKIPPED.
|
|
3755
|
-
* 10 (second half). A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the
|
|
3756
|
-
* exemption is stale and can be deleted; an exemption nobody revisits is how a real, later
|
|
3757
|
-
* skew slips through unnoticed.
|
|
3758
|
-
*
|
|
3759
|
-
* An AMBIGUOUS project (no config here, several workspace packages carrying one) short-circuits
|
|
3760
|
-
* before any of them, exactly as `check-theme` does: running the remaining checks against the wrong
|
|
3761
|
-
* root buries the one real error under spurious ones.
|
|
3762
|
-
*
|
|
3763
|
-
* SKIPPED is now a third outcome beside pass/warn/fail and exits non-zero on its own — a check that
|
|
3764
|
-
* cannot run is not a check that passed. "Not applicable to this profile" is a pass, not a skip.
|
|
3765
|
-
*
|
|
3766
|
-
* Returns the exit code: 0 = all good, 1 = one or more hard failures or unrunnable checks.
|
|
3767
|
-
*/
|
|
3768
|
-
export function doctor(invocationCwd: string = process.cwd(), flags: string[] = []): number {
|
|
3769
|
-
if (conflictingProfileFlags(flags)) {
|
|
3770
|
-
console.error('basalt-ui doctor: --tokens-only and --framework are alternatives — pass one.')
|
|
3771
|
-
return 1
|
|
3772
|
-
}
|
|
3773
|
-
const project = resolveProjectDir(invocationCwd)
|
|
3774
|
-
// Ambiguity is terminal, exactly as it is for check-theme. Falling back to `invocationCwd` ran
|
|
3775
|
-
// every remaining check against the WRONG root and buried the one real error under spurious
|
|
3776
|
-
// ones — guard-scan reporting 0 files, .oxlintrc.json "missing" — which is this round's own
|
|
3777
|
-
// false-report bug class reappearing inside the fix for it.
|
|
3778
|
-
if (project.ambiguous !== null) {
|
|
3779
|
-
console.error(
|
|
3780
|
-
`\nbasalt-ui doctor — ${invocationCwd}\n\n` +
|
|
3781
|
-
` ✖ no basalt config at ${invocationCwd}, and ${project.ambiguous.length} packages ` +
|
|
3782
|
-
`below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
|
|
3783
|
-
'run doctor from one of them, or set BASALT_CWD to pick.\n\n' +
|
|
3784
|
-
'1 hard failure(s), 0 warning(s).\n',
|
|
3785
|
-
)
|
|
3786
|
-
return 1
|
|
3787
|
-
}
|
|
3788
|
-
const cwd = project.dir
|
|
3789
|
-
const cfg = readBasaltConfig(cwd)
|
|
3790
|
-
const result: DoctorResult = { hardFailures: 0, warnings: 0, skipped: 0 }
|
|
3791
|
-
const lines: string[] = [`\nbasalt-ui doctor — ${cwd}\n`]
|
|
3792
|
-
|
|
3793
|
-
function pass(msg: string): void {
|
|
3794
|
-
lines.push(` ✓ ${msg}`)
|
|
3795
|
-
}
|
|
3796
|
-
function warn(msg: string): void {
|
|
3797
|
-
lines.push(` ⚠ ${msg}`)
|
|
3798
|
-
result.warnings++
|
|
3799
|
-
}
|
|
3800
|
-
function skip(msg: string): void {
|
|
3801
|
-
lines.push(` ⊘ SKIPPED — ${msg}`)
|
|
3802
|
-
result.skipped++
|
|
3803
|
-
}
|
|
3804
|
-
function fail(msg: string): void {
|
|
3805
|
-
lines.push(` ✖ ${msg}`)
|
|
3806
|
-
result.hardFailures++
|
|
3807
|
-
}
|
|
3808
|
-
|
|
3809
|
-
if (project.relocatedFrom !== null) {
|
|
3810
|
-
lines.push(
|
|
3811
|
-
` → no basalt config at ${project.relocatedFrom} — reporting on ` +
|
|
3812
|
-
`${relativePosix(project.relocatedFrom, cwd)}, where it lives.\n`,
|
|
3813
|
-
)
|
|
3814
|
-
}
|
|
3815
|
-
|
|
3816
|
-
// ── Profile ────────────────────────────────────────────────────────────────
|
|
3817
|
-
const manifestAbs = resolve(cwd, MANIFEST_PATH)
|
|
3818
|
-
const manifestExists = existsSync(manifestAbs)
|
|
3819
|
-
// Non-null ⇒ this directory is a package UNDER a configured repo, not an unscaffolded consumer.
|
|
3820
|
-
// Every remedy below that would otherwise say "run `basalt-ui init`" points at the parent
|
|
3821
|
-
// instead — see {@link parentInstallAdvice}.
|
|
3822
|
-
const parentInstall = manifestExists ? null : findManifestAbove(cwd)
|
|
3823
|
-
const profile = inferredProfile(cwd, cfg, flags)
|
|
3824
|
-
if (profile === 'tokens-only') {
|
|
3825
|
-
lines.push(
|
|
3826
|
-
' profile: tokens-only — checking the token layer only. The scaffold checks below are not\n' +
|
|
3827
|
-
' applicable; `basalt-ui init` is NOT the fix here, it places a Mantine doctrine you have no\n' +
|
|
3828
|
-
' use for. Pass --framework to force the full profile.\n',
|
|
3829
|
-
)
|
|
3830
|
-
// check-theme will NOT infer this — it silences 17 kinds, so it moves only on a declaration.
|
|
3831
|
-
// Doctor is the surface that can safely detect the shape, so it is the one that has to say so.
|
|
3832
|
-
if (cfg.profile === undefined) {
|
|
3833
|
-
lines.push(
|
|
3834
|
-
' → inferred (no .basalt/manifest.json and no @mantine/core in this workspace). Write it\n' +
|
|
3835
|
-
' down as "basalt": { "profile": "tokens-only" } in package.json — check-theme does not\n' +
|
|
3836
|
-
' infer it, so today it still reports the Mantine-only kinds against this repo.\n',
|
|
3837
|
-
)
|
|
3838
|
-
}
|
|
3839
|
-
}
|
|
3840
|
-
|
|
3841
|
-
// ── Hard check 1: manifest exists ──────────────────────────────────────────
|
|
3842
|
-
if (profile === 'tokens-only') {
|
|
3843
|
-
pass(`${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to reconcile`)
|
|
3844
|
-
} else if (manifestExists) {
|
|
3845
|
-
pass(`${MANIFEST_PATH} exists`)
|
|
3846
|
-
} else if (parentInstall !== null) {
|
|
3847
|
-
fail(
|
|
3848
|
-
`${MANIFEST_PATH} missing here, but this is not an unscaffolded consumer. ` +
|
|
3849
|
-
`${parentInstallAdvice(cwd, parentInstall, 'doctor')} \`basalt-ui init\` is NOT the fix: ` +
|
|
3850
|
-
'it would scaffold a SECOND consumer beside the real one.',
|
|
3851
|
-
)
|
|
3852
|
-
} else {
|
|
3853
|
-
fail(`${MANIFEST_PATH} missing — run \`basalt-ui init\` to scaffold the consumer repo`)
|
|
3854
|
-
}
|
|
3855
|
-
|
|
3856
|
-
// ── Hard check 2: basalt-ui resolves from here ─────────────────────────────
|
|
3857
|
-
// Unresolvable used to make checks 3 and 5 VANISH from the report while the footer still read
|
|
3858
|
-
// "All checks passed" — 5 checks silently became 3. It is also the moment `bunx basalt-ui`
|
|
3859
|
-
// stops using the pinned copy and quietly downloads a different one from npm, so the failure
|
|
3860
|
-
// has to be loud rather than absent.
|
|
3861
|
-
const install = findBasaltInstall(cwd)
|
|
3862
|
-
const installedVersion = install.version
|
|
3863
|
-
if (install.dir === null) {
|
|
3864
|
-
fail(
|
|
3865
|
-
`basalt-ui does not resolve from ${cwd}, any directory above it, or any workspace package — ` +
|
|
3866
|
-
'so every version check below is unrunnable, `extends: [<preset>]` cannot resolve, and ' +
|
|
3867
|
-
'`bunx basalt-ui` will silently fetch a DIFFERENT copy from npm instead of your pinned ' +
|
|
3868
|
-
'one. Add basalt-ui as a dependency here (or at the repo root) and re-run.',
|
|
3869
|
-
)
|
|
3870
|
-
} else if (install.how === 'cwd') {
|
|
3871
|
-
pass(
|
|
3872
|
-
`basalt-ui resolves at ./node_modules/basalt-ui (${installedVersion ?? 'unknown version'})`,
|
|
3873
|
-
)
|
|
3874
|
-
} else {
|
|
3875
|
-
pass(
|
|
3876
|
-
`basalt-ui resolves at ${relativePosix(cwd, install.dir)} (${installedVersion ?? 'unknown version'}, ` +
|
|
3877
|
-
`found via the ${install.how}) — seeded \`extends\` paths and CI steps must use that path, ` +
|
|
3878
|
-
'not ./node_modules/basalt-ui.',
|
|
3879
|
-
)
|
|
3880
|
-
}
|
|
3881
|
-
|
|
3882
|
-
// ── Warn check 3: installed basalt-ui version matches the manifest ─────────
|
|
3883
|
-
// THE one version axis. The manifest records the version whose init/sync placed the doctrine;
|
|
3884
|
-
// node_modules is what the app actually resolves. A mismatch means "upgrade landed, sync didn't".
|
|
3885
|
-
if (profile === 'framework' && manifestExists && installedVersion === null) {
|
|
3886
|
-
skip(
|
|
3887
|
-
'installed-vs-manifest version — basalt-ui did not resolve, so there is nothing to compare',
|
|
3888
|
-
)
|
|
3889
|
-
}
|
|
3890
|
-
if (manifestExists && installedVersion !== null) {
|
|
3891
|
-
const manifestVersion = readManifest(cwd).basaltVersion
|
|
3892
|
-
if (manifestVersion === undefined) {
|
|
3893
|
-
warn(
|
|
3894
|
-
`${MANIFEST_PATH} records no basaltVersion (written by an older basalt-ui) — run ` +
|
|
3895
|
-
'`basalt-ui sync` to refresh the doctrine and stamp the manifest.',
|
|
3896
|
-
)
|
|
3897
|
-
} else if (manifestVersion !== installedVersion) {
|
|
3898
|
-
warn(
|
|
3899
|
-
`installed basalt-ui (${installedVersion}) differs from the version that last synced ` +
|
|
3900
|
-
`(${manifestVersion}) — the placed doctrine is stale; run \`basalt-ui sync\`.`,
|
|
3901
|
-
)
|
|
3902
|
-
} else {
|
|
3903
|
-
pass(`installed basalt-ui (${installedVersion}) matches the manifest's basaltVersion`)
|
|
3904
|
-
}
|
|
3905
|
-
}
|
|
3906
|
-
|
|
3907
|
-
const cliVersion = readFrameworkVersion(packageRoot())
|
|
3908
|
-
|
|
3909
|
-
// ── Warn check 4: the spacing scale has not moved under the app ────────────
|
|
3910
|
-
// The only check here that reports a change in RENDERED OUTPUT rather than in placed files. A
|
|
3911
|
-
// retune of the spacing bases moves every surface in an app calling `createBasaltTheme()` bare,
|
|
3912
|
-
// and majors are banned by design, so nothing in the version number says so — 1.2.0's retune
|
|
3913
|
-
// shipped under "tighten the sidebar, open up components" and sat unverified in production for a
|
|
3914
|
-
// day. Diffing the current scale against what last synced makes that move something a consumer
|
|
3915
|
-
// is told rather than something they have to notice.
|
|
3916
|
-
//
|
|
3917
|
-
// The scale that matters is the INSTALLED package's — that is what the app renders with — but
|
|
3918
|
-
// `deriveSpacing` here is the RUNNING CLI's, which a stale `bunx basalt-ui` fetch makes a
|
|
3919
|
-
// different package. Rather than load the installed tokens entry (async, and doctor is sync),
|
|
3920
|
-
// the comparison is skipped whenever the two versions are known to disagree: a false "matches"
|
|
3921
|
-
// is worse than no answer. Check 5 below names that disagreement, so this stays a neutral note.
|
|
3922
|
-
const cliIsTheInstall = installedVersion === null || installedVersion === cliVersion
|
|
3923
|
-
if (profile === 'tokens-only') {
|
|
3924
|
-
pass('spacing scale: n/a — a tokens-only consumer renders no basalt Mantine theme')
|
|
3925
|
-
} else if (!manifestExists) {
|
|
3926
|
-
// Printing NOTHING is the failure SKIPPED exists to end: the check simply vanished from the
|
|
3927
|
-
// report whenever there was no manifest, which is precisely when it is least safe to assume.
|
|
3928
|
-
skip(
|
|
3929
|
-
`spacing scale — no ${MANIFEST_PATH} records the scale this app last synced with, so there ` +
|
|
3930
|
-
'is nothing to compare against',
|
|
3931
|
-
)
|
|
3932
|
-
} else if (!cliIsTheInstall) {
|
|
3933
|
-
pass(
|
|
3934
|
-
`spacing scale not compared — this CLI (${cliVersion}) is not the installed basalt-ui ` +
|
|
3935
|
-
`(${installedVersion}), so its scale is not the one the app renders with`,
|
|
3936
|
-
)
|
|
3937
|
-
} else {
|
|
3938
|
-
const recorded = readManifest(cwd).spacingScale
|
|
3939
|
-
const current = deriveSpacing(0).scale as Record<string, number>
|
|
3940
|
-
if (recorded === undefined) {
|
|
3941
|
-
// Silent for a manifest written before this field existed — the version check above already
|
|
3942
|
-
// says "run sync", and a second warning for the same cause is noise.
|
|
3943
|
-
pass('spacing scale not yet recorded — `basalt-ui sync` will stamp it')
|
|
3944
|
-
} else {
|
|
3945
|
-
// Over the UNION of both key sets: a step the scale gained since the last sync is a move the
|
|
3946
|
-
// app feels, and so is one it lost. Iterating only `current` would miss the second.
|
|
3947
|
-
const steps = [...new Set([...Object.keys(current), ...Object.keys(recorded)])].toSorted()
|
|
3948
|
-
const moved = steps
|
|
3949
|
-
.filter((step) => recorded[step] !== current[step])
|
|
3950
|
-
.map((step) => `${step} ${recorded[step] ?? '(none)'}→${current[step] ?? '(removed)'}`)
|
|
3951
|
-
if (moved.length > 0) {
|
|
3952
|
-
warn(
|
|
3953
|
-
`the spacing scale moved since the last sync (${moved.join(', ')}) — every surface in an ` +
|
|
3954
|
-
'app calling createBasaltTheme() bare shifts with it. Review the app visually, then run ' +
|
|
3955
|
-
'`basalt-ui sync` to record the new scale.',
|
|
3956
|
-
)
|
|
3957
|
-
} else {
|
|
3958
|
-
pass('spacing scale matches the last sync')
|
|
3959
|
-
}
|
|
3960
|
-
}
|
|
3961
|
-
}
|
|
3962
|
-
|
|
3963
|
-
// ── Warn check 5: running CLI version matches the consumer's installed basalt-ui ────
|
|
3964
|
-
// Catches the failure mode where bunx fetches a stale published package instead of the local
|
|
3965
|
-
// install — the CLI that ran doctor and the package resolved from the consumer's node_modules
|
|
3966
|
-
// silently disagree.
|
|
3967
|
-
if (installedVersion === null) {
|
|
3968
|
-
skip('CLI-vs-installed version — basalt-ui did not resolve, so there is no installed version')
|
|
3969
|
-
} else if (installedVersion !== cliVersion) {
|
|
3970
|
-
warn(
|
|
3971
|
-
`running CLI version (${cliVersion}) differs from the installed basalt-ui version at ` +
|
|
3972
|
-
`${install.dir === null ? 'node_modules' : relativePosix(cwd, install.dir)} (${installedVersion}) — ` +
|
|
3973
|
-
'likely a stale `bunx basalt-ui` fetch from npm; run the local bin ' +
|
|
3974
|
-
'(`node_modules/.bin/basalt-ui`, or a package.json script) so the pinned copy is the one ' +
|
|
3975
|
-
'that runs.',
|
|
3976
|
-
)
|
|
3977
|
-
} else {
|
|
3978
|
-
pass(`CLI version (${cliVersion}) matches the installed basalt-ui`)
|
|
3979
|
-
}
|
|
3980
|
-
|
|
3981
|
-
// ── Hard check 6: the guard would actually scan something ──────────────────
|
|
3982
|
-
// `check-theme` exits 1 on "0 files scanned" — doctor reporting all-green in the SAME repo is
|
|
3983
|
-
// the disagreement consumers reported: init ran, doctor passed, and the palette guard was a
|
|
3984
|
-
// no-op the whole time because no `basalt.roots` described the workspace layout.
|
|
3985
|
-
if (profile === 'tokens-only') {
|
|
3986
|
-
pass('guard-scan: n/a — the theme guard is a Mantine-app check')
|
|
3987
|
-
} else {
|
|
3988
|
-
const scanned = scannableFiles(cwd, cfg)
|
|
3989
|
-
if (scanned.length === 0) {
|
|
3990
|
-
fail(
|
|
3991
|
-
`guard-scan: check-theme would scan 0 files — the roots (${resolveRoots(cfg).join(', ')}) ` +
|
|
3992
|
-
`match nothing under ${cwd}, so the palette guard is a no-op that still exits green in ` +
|
|
3993
|
-
'every gate that does not run it. Set "basalt": { "roots": [...] } in package.json.',
|
|
3994
|
-
)
|
|
3995
|
-
} else {
|
|
3996
|
-
pass(
|
|
3997
|
-
`guard-scan: check-theme covers ${scanned.length} file(s) under ${resolveRoots(cfg).join(', ')}`,
|
|
3998
|
-
)
|
|
3999
|
-
}
|
|
4000
|
-
}
|
|
4001
|
-
|
|
4002
|
-
// ── Hard check 7: the consumer's oxlint config extends the shipped preset ──
|
|
4003
|
-
// `init` KEEPS an existing `.oxlintrc.json`, so a repo can carry the whole scaffold with the
|
|
4004
|
-
// framework's lint half switched off and nothing anywhere saying so. One repo ran five minors
|
|
4005
|
-
// that way and surfaced six real `basalt/no-raw-font-size` errors the moment it was wired.
|
|
4006
|
-
if (profile === 'tokens-only') {
|
|
4007
|
-
pass('oxlint-preset: n/a — the shipped preset is a Mantine/React preset')
|
|
4008
|
-
} else {
|
|
4009
|
-
const oxlintrcRaw = readIfExists(resolve(cwd, '.oxlintrc.json'))
|
|
4010
|
-
if (oxlintrcRaw === null) {
|
|
4011
|
-
fail(
|
|
4012
|
-
'.oxlintrc.json missing — the shipped oxlint preset (the basalt/* design rules) is not ' +
|
|
4013
|
-
'active. ' +
|
|
4014
|
-
(parentInstall === null
|
|
4015
|
-
? 'Run `basalt-ui init` to seed it.'
|
|
4016
|
-
: `${parentInstallAdvice(cwd, parentInstall, 'doctor')} The preset is seeded once, at ` +
|
|
4017
|
-
'the install — not per package.'),
|
|
4018
|
-
)
|
|
4019
|
-
} else {
|
|
4020
|
-
// JSONC: oxlint accepts comments and real consumer configs carry them.
|
|
4021
|
-
const parsed = parseJsonc(oxlintrcRaw)
|
|
4022
|
-
if (parsed === null) {
|
|
4023
|
-
skip(
|
|
4024
|
-
'.oxlintrc.json does not parse as JSON/JSONC — cannot tell whether it extends the preset',
|
|
4025
|
-
)
|
|
4026
|
-
} else {
|
|
4027
|
-
const entry = basaltPresetEntry(parsed['extends'])
|
|
4028
|
-
const correct = shippedAssetPath(install, cwd, 'configs/oxlint.json')
|
|
4029
|
-
if (entry === null) {
|
|
4030
|
-
fail(
|
|
4031
|
-
'.oxlintrc.json does NOT extend the shipped preset — every basalt/* design rule is off ' +
|
|
4032
|
-
`and oxlint reports green without them. Add "extends": ["${correct}"], or re-run ` +
|
|
4033
|
-
'`basalt-ui init --merge-lint` to splice it in.',
|
|
4034
|
-
)
|
|
4035
|
-
} else if (!existsSync(resolve(cwd, entry)) && entry === correct && install.dir !== null) {
|
|
4036
|
-
// The path is the one basalt resolves from and the asset still isn't there — a partial
|
|
4037
|
-
// or corrupted install, not a stale path. Saying "repoint it at <the same string>" would
|
|
4038
|
-
// be the unhelpful shape of a correct diagnosis.
|
|
4039
|
-
fail(
|
|
4040
|
-
`.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) — ` +
|
|
4041
|
-
'basalt-ui resolves there but ships no `configs/oxlint.json` at that path, so oxlint ' +
|
|
4042
|
-
'refuses to start (`NotFound`) and nothing is linted. Reinstall basalt-ui.',
|
|
4043
|
-
)
|
|
4044
|
-
} else if (!existsSync(resolve(cwd, entry))) {
|
|
4045
|
-
// The string is right and the file is not there. oxlint refuses to start at all
|
|
4046
|
-
// (`invalid config file … NotFound`), so a green line here is a check that proves the
|
|
4047
|
-
// framework is ON while the linter is dead — the same false-green class this check was
|
|
4048
|
-
// added to close, one layer in. `install` already computed the true path in this run.
|
|
4049
|
-
fail(
|
|
4050
|
-
`.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) — ` +
|
|
4051
|
-
'oxlint refuses to start on a missing extends target (`NotFound`), so nothing is ' +
|
|
4052
|
-
`linted at all. Repoint it at "${correct}", where basalt-ui actually resolves.`,
|
|
4053
|
-
)
|
|
4054
|
-
} else {
|
|
4055
|
-
pass(`.oxlintrc.json extends the shipped basalt-ui oxlint preset (${entry})`)
|
|
4056
|
-
}
|
|
4057
|
-
}
|
|
4058
|
-
}
|
|
4059
|
-
}
|
|
4060
|
-
|
|
4061
|
-
// ── Check 8: the pre-commit hook actually runs the guard ───────────────────
|
|
4062
|
-
// The loudest silent failure in the toolchain: lefthook merges a MISSING extends target into
|
|
4063
|
-
// zero commands and exits 0 — `lefthook dump` prints the extends line, no commands, and a clean
|
|
4064
|
-
// exit — so a repo whose path went stale has no pre-commit gate and nothing anywhere says so.
|
|
4065
|
-
// Unlike oxlint there is no error to notice, which is why this has to be a check rather than a
|
|
4066
|
-
// remedy line someone reads after a failure.
|
|
4067
|
-
//
|
|
4068
|
-
// But a BROKEN extends is the only half of this that can be asserted from the string. Whether a
|
|
4069
|
-
// gate EXISTS is a different question, and 1.21.0 answered it by testing for the extends target
|
|
4070
|
-
// — warning at a correctly-wired repo and prescribing a change that would have broken it. See
|
|
4071
|
-
// {@link inspectLefthookGate}: `lefthook dump` where it runs, the config text where it doesn't,
|
|
4072
|
-
// and an advisory that names the gap where neither settles it.
|
|
4073
|
-
const repoRoot = findRepoRoot(cwd)
|
|
4074
|
-
const lefthookGate =
|
|
4075
|
-
profile === 'tokens-only' ? { kind: 'n/a' as const } : inspectLefthookGate(repoRoot)
|
|
4076
|
-
const lefthookCorrect = shippedAssetPath(install, repoRoot, 'configs/lefthook.yml')
|
|
4077
|
-
if (lefthookGate.kind === 'n/a') {
|
|
4078
|
-
pass('lefthook-preset: n/a — a tokens-only consumer wires its own hooks')
|
|
4079
|
-
} else if (lefthookGate.kind === 'no-file') {
|
|
4080
|
-
pass(`lefthook-preset: n/a — no lefthook config at ${repoRoot}`)
|
|
4081
|
-
} else if (lefthookGate.kind === 'extends-ok') {
|
|
4082
|
-
pass(`${lefthookGate.file} extends the shipped lefthook preset (${lefthookGate.entry})`)
|
|
4083
|
-
} else if (lefthookGate.kind === 'wired') {
|
|
4084
|
-
// Extending the preset is basalt's recommendation, not the contract. The contract is that a
|
|
4085
|
-
// commit cannot land without the guard running, and a repo spelling the jobs out with a
|
|
4086
|
-
// per-command `root:` satisfies it — `extends` merges commands without their working
|
|
4087
|
-
// directory, so for that repo the preset is the thing that would NOT work.
|
|
4088
|
-
pass(
|
|
4089
|
-
`lefthook-preset: ${lefthookGate.file} does not extend the shipped preset, but a pre-commit ` +
|
|
4090
|
-
`command runs check-theme directly — the gate exists (read from ` +
|
|
4091
|
-
(lefthookGate.proof === 'dump'
|
|
4092
|
-
? '`lefthook dump`, so extends/include/root: are all resolved).'
|
|
4093
|
-
: 'the config text; `lefthook dump` was not runnable here, so an `include:` or a ' +
|
|
4094
|
-
'remote config was not consulted).'),
|
|
4095
|
-
)
|
|
4096
|
-
} else if (lefthookGate.kind === 'extends-broken') {
|
|
4097
|
-
fail(
|
|
4098
|
-
`${lefthookGate.file} extends "${lefthookGate.entry}", which does not exist ` +
|
|
4099
|
-
`(${resolve(repoRoot, lefthookGate.entry)}) — lefthook merges a missing extends ` +
|
|
4100
|
-
'target into ZERO commands and still exits 0, so this repo has no pre-commit gate and ' +
|
|
4101
|
-
`\`lefthook dump\` looks clean. Repoint it at "${lefthookCorrect}".`,
|
|
4102
|
-
)
|
|
4103
|
-
} else if (lefthookGate.kind === 'absent') {
|
|
4104
|
-
// Warn, not fail — same tier the string-matching version used. A consumer may deliberately
|
|
4105
|
-
// gate in CI only, and doctor does not get to promote its own advice to a build break.
|
|
4106
|
-
warn(
|
|
4107
|
-
`${lefthookGate.file}: \`lefthook dump\` resolves the merged config and NO pre-commit ` +
|
|
4108
|
-
'command runs check-theme — the theme guard is not gating commits. Either add ' +
|
|
4109
|
-
`"extends: [${lefthookCorrect}]", or add your own command running ` +
|
|
4110
|
-
`\`${basaltBinCommand(install, cwd)} check-theme\` (with \`root:\` if it must run in a ` +
|
|
4111
|
-
'subdirectory).',
|
|
4112
|
-
)
|
|
4113
|
-
} else {
|
|
4114
|
-
warn(
|
|
4115
|
-
`${lefthookGate.file} names no basalt gate and \`lefthook dump\` could not be run here, so ` +
|
|
4116
|
-
'this check cannot tell a missing gate from one wired through an `include:` or a remote ' +
|
|
4117
|
-
'config it never read — treat it as advisory. If the guard really is unwired, add ' +
|
|
4118
|
-
`"extends: [${lefthookCorrect}]" or your own command running ` +
|
|
4119
|
-
`\`${basaltBinCommand(install, cwd)} check-theme\`.`,
|
|
4120
|
-
)
|
|
4121
|
-
}
|
|
4122
|
-
|
|
4123
|
-
// ── Warn check 8: the icon files basaltAppPlugin actually references exist ────
|
|
4124
|
-
// basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references six filenames by default
|
|
4125
|
-
// (the realfavicongenerator convention), but since 1.23.0 an app can NAME its own — so the option
|
|
4126
|
-
// is read (see {@link readAppIconsOption}) rather than assumed.
|
|
4127
|
-
//
|
|
4128
|
-
// It used to read `cwd` alone, and in a monorepo the vite config lives in the app PACKAGE while
|
|
4129
|
-
// the only invocation that exits 0 is the repo root. The check therefore vanished from the root
|
|
4130
|
-
// run with no line at all — the failure mode SKIPPED exists to eliminate — leaving no single
|
|
4131
|
-
// invocation that both passes and checks the icons. It now resolves the app package the same way
|
|
4132
|
-
// the scan does, off `basalt.roots`; see {@link findAppPluginDir}.
|
|
4133
|
-
if (profile === 'framework') {
|
|
4134
|
-
const appDir = findAppPluginDir(cwd, cfg)
|
|
4135
|
-
const iconsIn = appDir ?? cwd
|
|
4136
|
-
const icons = readAppIconsOption(iconsIn)
|
|
4137
|
-
const here = appDir === null || appDir === cwd ? '' : `${relativePosix(cwd, appDir)}/`
|
|
4138
|
-
const publicDir = resolve(iconsIn, 'public')
|
|
4139
|
-
if (appDir === null && !existsSync(publicDir)) {
|
|
4140
|
-
// No `basaltAppPlugin(` anywhere under the declared roots and no public/ here: nothing in
|
|
4141
|
-
// this project references icon files, so there is nothing to check — and saying so is the
|
|
4142
|
-
// point. A check that vanishes without a word is indistinguishable from one that passed.
|
|
4143
|
-
pass('app icons: no `basaltAppPlugin(` under this project and no public/ — nothing to check')
|
|
4144
|
-
} else if (icons.kind === 'none') {
|
|
4145
|
-
pass('basaltAppPlugin is configured with no icons — nothing to check')
|
|
4146
|
-
} else if (!existsSync(publicDir)) {
|
|
4147
|
-
skip(
|
|
4148
|
-
`app icons — ${here === '' ? 'this package' : here.replace(/\/$/, '')} configures ` +
|
|
4149
|
-
'basaltAppPlugin, but there is no `public/` beside its vite config, so the files it ' +
|
|
4150
|
-
"references cannot be located. Use Vite's public-dir convention, or set `icons: false` " +
|
|
4151
|
-
'if this app ships none.',
|
|
4152
|
-
)
|
|
4153
|
-
} else if (icons.kind === 'explicit') {
|
|
4154
|
-
// The app NAMED its icons, so those are the only files it owes. Checking the six defaults
|
|
4155
|
-
// here is what told 1.23.0's flagship adopter to generate five files it deliberately lacks.
|
|
4156
|
-
const missing = icons.sources.filter(
|
|
4157
|
-
(src) => !existsSync(resolve(publicDir, src.replace(/^\//, ''))),
|
|
4158
|
-
)
|
|
4159
|
-
if (missing.length > 0) {
|
|
4160
|
-
warn(
|
|
4161
|
-
`basaltAppPlugin declares icon(s) that are not in ${here}public/: ${missing.join(', ')} — ` +
|
|
4162
|
-
'the manifest and the head links will point at a 404. Place them, or drop them from ' +
|
|
4163
|
-
'the `icons` array.',
|
|
4164
|
-
)
|
|
4165
|
-
} else {
|
|
4166
|
-
pass(
|
|
4167
|
-
`${here}public/ has all ${icons.sources.length} icon file(s) basaltAppPlugin's ` +
|
|
4168
|
-
'`icons` option names',
|
|
4169
|
-
)
|
|
4170
|
-
}
|
|
4171
|
-
} else {
|
|
4172
|
-
const dir = icons.kind === 'default' ? icons.dir : null
|
|
4173
|
-
const iconRoot = dir === null ? publicDir : resolve(publicDir, dir.replace(/^\//, ''))
|
|
4174
|
-
const shown = dir === null ? `${here}public/` : `${here}public/${dir.replace(/^\//, '')}`
|
|
4175
|
-
const missingIcons = DEFAULT_APP_ICON_FILES.filter((f) => !existsSync(resolve(iconRoot, f)))
|
|
4176
|
-
if (missingIcons.length > 0) {
|
|
4177
|
-
warn(
|
|
4178
|
-
`${shown} is missing basaltAppPlugin icon file(s): ${missingIcons.join(', ')} — generate ` +
|
|
4179
|
-
`them (e.g. via realfavicongenerator.net) and place them at the ${shown} root, or name ` +
|
|
4180
|
-
"the icons this app really has via the plugin's `icons` array.",
|
|
4181
|
-
)
|
|
4182
|
-
} else {
|
|
4183
|
-
pass(`${shown} has all of basaltAppPlugin's default icon files`)
|
|
4184
|
-
}
|
|
4185
|
-
}
|
|
4186
|
-
}
|
|
4187
|
-
|
|
4188
|
-
// ── Hard check 9: ai package major version parity across workspace packages ─
|
|
4189
|
-
// basalt/ai-sdk-major (the lint rule) is per-file against the NEAREST package.json, so a lint
|
|
4190
|
-
// run scoped to one workspace package only ever sees that package's own `ai` major and is
|
|
4191
|
-
// perfectly happy. The real defect is CROSS-package (argo defect 1: apps/api on ai@5 producing a
|
|
4192
|
-
// stream apps/dashboard's ai@7 client can't parse) and only a repo-wide manifest walk sees it —
|
|
4193
|
-
// this is that walk. Hard failure, not a warning: a skewed pair throws at runtime, it doesn't
|
|
4194
|
-
// just look different. `basalt.aiMajorSkewReason` exempts an INTENTIONAL skew (e.g. a
|
|
4195
|
-
// producer-side TransformStream neutralizing the divergent enum value) — see the BasaltConfig
|
|
4196
|
-
// JSDoc for its exact semantics; a stale declaration (skew resolved, reason still present) warns
|
|
4197
|
-
// instead of passing silently.
|
|
4198
|
-
const workspaceWalk = collectWorkspacePackages(cwd)
|
|
4199
|
-
const aiMajors = workspaceWalk.packages
|
|
4200
|
-
.map((pkg) => ({ name: pkg.name, major: aiMajorAt(pkg.dir) }))
|
|
4201
|
-
.filter((entry): entry is { name: string; major: number } => entry.major !== null)
|
|
4202
|
-
const { reason: aiMajorSkewReason, presentButInvalid: aiMajorSkewReasonInvalid } =
|
|
4203
|
-
resolveAiMajorSkewReason(cfg)
|
|
4204
|
-
if (aiMajors.length > 0) {
|
|
4205
|
-
const distinctMajors = new Set(aiMajors.map((entry) => entry.major))
|
|
4206
|
-
if (distinctMajors.size > 1) {
|
|
4207
|
-
const summary = aiMajors.map((entry) => `${entry.name}@ai${entry.major}`).join(', ')
|
|
4208
|
-
if (aiMajorSkewReason !== null) {
|
|
4209
|
-
pass(
|
|
4210
|
-
`ai package major version mismatch across workspace packages: ${summary} — exempted via ` +
|
|
4211
|
-
`basalt.aiMajorSkewReason: "${aiMajorSkewReason}"`,
|
|
4212
|
-
)
|
|
4213
|
-
} else if (aiMajorSkewReasonInvalid) {
|
|
4214
|
-
fail(
|
|
4215
|
-
`ai package major version mismatch across workspace packages: ${summary} — ` +
|
|
4216
|
-
'basalt.aiMajorSkewReason is present but is not a non-empty string (a bare `true` is ' +
|
|
4217
|
-
'not accepted); the exemption must carry a written reason, e.g. ' +
|
|
4218
|
-
'`aiMajorSkewReason: "apps/api on ai@5, apps/dashboard on ai@7, neutralized by a ' +
|
|
4219
|
-
'producer-side TransformStream"`.',
|
|
4220
|
-
)
|
|
4221
|
-
} else {
|
|
4222
|
-
fail(`ai package major version mismatch across workspace packages: ${summary}`)
|
|
4223
|
-
}
|
|
4224
|
-
} else {
|
|
4225
|
-
const [major] = distinctMajors
|
|
4226
|
-
if (aiMajorSkewReason !== null) {
|
|
4227
|
-
warn(
|
|
4228
|
-
`basalt.aiMajorSkewReason ("${aiMajorSkewReason}") is declared but the ai package major ` +
|
|
4229
|
-
`already matches across all ${aiMajors.length} workspace package(s) declaring it ` +
|
|
4230
|
-
`(ai@${major}) — the exemption is no longer needed and can be deleted.`,
|
|
4231
|
-
)
|
|
4232
|
-
} else {
|
|
4233
|
-
pass(
|
|
4234
|
-
`ai package major matches across ${aiMajors.length} workspace package(s) declaring it (ai@${major})`,
|
|
4235
|
-
)
|
|
4236
|
-
}
|
|
4237
|
-
}
|
|
4238
|
-
} else if (workspaceWalk.hasWorkspacesField) {
|
|
4239
|
-
// A hard check that can silently not run is worse than one that is absent — say so.
|
|
4240
|
-
pass('ai-major-parity: no workspace package declares the ai dependency — nothing to compare')
|
|
4241
|
-
}
|
|
4242
|
-
|
|
4243
|
-
// ── Report ──────────────────────────────────────────────────────────────────
|
|
4244
|
-
// "All checks passed" is only printable when every check ran AND passed. A skipped check counts
|
|
4245
|
-
// against the exit code on its own — the report that dropped two of five checks and still
|
|
4246
|
-
// claimed green is the bug this footer exists to make impossible.
|
|
4247
|
-
lines.push('')
|
|
4248
|
-
if (result.hardFailures === 0 && result.warnings === 0 && result.skipped === 0) {
|
|
4249
|
-
lines.push('All checks passed.')
|
|
4250
|
-
} else {
|
|
4251
|
-
const parts: string[] = []
|
|
4252
|
-
if (result.hardFailures > 0) parts.push(`${result.hardFailures} hard failure(s)`)
|
|
4253
|
-
if (result.skipped > 0) parts.push(`${result.skipped} check(s) SKIPPED (could not run)`)
|
|
4254
|
-
parts.push(`${result.warnings} warning(s)`)
|
|
4255
|
-
lines.push(`${parts.join(', ')}.`)
|
|
4256
|
-
}
|
|
4257
|
-
lines.push('')
|
|
4258
|
-
|
|
4259
|
-
if (result.hardFailures > 0 || result.skipped > 0) {
|
|
4260
|
-
console.error(lines.join('\n'))
|
|
4261
|
-
return 1
|
|
4262
|
-
}
|
|
4263
|
-
console.log(lines.join('\n'))
|
|
4264
|
-
return 0
|
|
4265
|
-
}
|
|
4266
|
-
|
|
4267
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
4268
|
-
// info — SURFACES-derived surface map
|
|
4269
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
4270
|
-
|
|
4271
|
-
/** One row in the `basalt info` output — one per published JS subpath export. */
|
|
4272
|
-
export type InfoSubpath = {
|
|
4273
|
-
path: string
|
|
4274
|
-
description: string
|
|
4275
|
-
layer: string
|
|
4276
|
-
rule: string | null
|
|
4277
|
-
skills: readonly string[]
|
|
4278
|
-
optionalPeers: string[]
|
|
4279
|
-
}
|
|
4280
|
-
|
|
4281
|
-
/** The stable JSON shape for `basalt info --json`. */
|
|
4282
|
-
export type InfoOutput = {
|
|
4283
|
-
name: string
|
|
4284
|
-
version: string
|
|
4285
|
-
subpaths: InfoSubpath[]
|
|
4286
|
-
}
|
|
1782
|
+
}
|
|
4287
1783
|
|
|
4288
1784
|
/**
|
|
4289
|
-
*
|
|
4290
|
-
*
|
|
4291
|
-
*
|
|
4292
|
-
*
|
|
4293
|
-
*
|
|
1785
|
+
* Expands one glob PATTERN's segments against the filesystem, starting from `baseDir`. Supports
|
|
1786
|
+
* three segment kinds: a literal name, a single `*` wildcard (any one directory), and a `**`
|
|
1787
|
+
* wildcard (zero or more directory levels, i.e. arbitrary depth) — covering a trailing single
|
|
1788
|
+
* wildcard, an interior wildcard between literal segments, and a trailing double-wildcard alike,
|
|
1789
|
+
* not just one trailing single-wildcard segment.
|
|
4294
1790
|
*/
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
// Read package.json for name, version, peerDependencies, peerDependenciesMeta, and exports.
|
|
4299
|
-
let pkgName = 'basalt-ui'
|
|
4300
|
-
let pkgVersion = '0.0.0'
|
|
4301
|
-
let peerDeps: Record<string, string> = {}
|
|
4302
|
-
let peerMeta: Record<string, { optional?: boolean }> = {}
|
|
4303
|
-
let pkgExports: Record<string, unknown> = {}
|
|
4304
|
-
try {
|
|
4305
|
-
const pkg = JSON.parse(readFileSync(resolve(pkgRoot, 'package.json'), 'utf8')) as {
|
|
4306
|
-
name?: string
|
|
4307
|
-
version?: string
|
|
4308
|
-
peerDependencies?: Record<string, string>
|
|
4309
|
-
peerDependenciesMeta?: Record<string, { optional?: boolean }>
|
|
4310
|
-
exports?: Record<string, unknown>
|
|
4311
|
-
}
|
|
4312
|
-
pkgName = pkg.name ?? pkgName
|
|
4313
|
-
pkgVersion = pkg.version ?? pkgVersion
|
|
4314
|
-
peerDeps = pkg.peerDependencies ?? {}
|
|
4315
|
-
peerMeta = pkg.peerDependenciesMeta ?? {}
|
|
4316
|
-
pkgExports = pkg.exports ?? {}
|
|
4317
|
-
} catch {
|
|
4318
|
-
// proceed with defaults
|
|
4319
|
-
}
|
|
4320
|
-
|
|
4321
|
-
// Derive optional peer set: all peers marked optional in peerDependenciesMeta.
|
|
4322
|
-
const optionalPeerSet = new Set(
|
|
4323
|
-
Object.entries(peerMeta)
|
|
4324
|
-
.filter(([, m]) => m.optional === true)
|
|
4325
|
-
.map(([name]) => name),
|
|
4326
|
-
)
|
|
4327
|
-
|
|
4328
|
-
// Build subpath rows from SURFACES — only real JS subpath keys (non-#, non-'./configs/*').
|
|
4329
|
-
// Restrict to keys that also appear in package.json exports so we match what is published.
|
|
4330
|
-
const publishedExportKeys = new Set(Object.keys(pkgExports))
|
|
4331
|
-
|
|
4332
|
-
const subpaths: InfoSubpath[] = []
|
|
4333
|
-
for (const [key, spec] of Object.entries(SURFACES) as [string, SurfaceSpec][]) {
|
|
4334
|
-
if (key.startsWith('#')) continue
|
|
4335
|
-
if (!publishedExportKeys.has(key)) continue
|
|
4336
|
-
|
|
4337
|
-
const docSpec = spec.kind === 'doctrine' ? spec : null
|
|
4338
|
-
|
|
4339
|
-
// Resolve optional peers from spec.optionalPeers (SURFACES SSOT), with versions from package.json.
|
|
4340
|
-
const specPeers: readonly string[] =
|
|
4341
|
-
docSpec !== null && 'optionalPeers' in docSpec && Array.isArray(docSpec.optionalPeers)
|
|
4342
|
-
? docSpec.optionalPeers
|
|
4343
|
-
: []
|
|
4344
|
-
const optionalPeers = specPeers
|
|
4345
|
-
.filter((p) => optionalPeerSet.has(p) && p in peerDeps)
|
|
4346
|
-
.map((p) => `${p}@${peerDeps[p]}`)
|
|
4347
|
-
|
|
4348
|
-
subpaths.push({
|
|
4349
|
-
path: `basalt-ui${key === '.' ? '' : key.slice(1)}`,
|
|
4350
|
-
description: spec.description ?? `basalt-ui${key === '.' ? '' : key} subpath`,
|
|
4351
|
-
layer: spec.layer,
|
|
4352
|
-
rule: docSpec?.rule ?? null,
|
|
4353
|
-
skills: docSpec?.skill ?? [],
|
|
4354
|
-
optionalPeers,
|
|
4355
|
-
})
|
|
4356
|
-
}
|
|
4357
|
-
|
|
4358
|
-
const output: InfoOutput = { name: pkgName, version: pkgVersion, subpaths }
|
|
4359
|
-
|
|
4360
|
-
if (flags.includes('--json')) {
|
|
4361
|
-
console.log(JSON.stringify(output, null, 2))
|
|
4362
|
-
return 0
|
|
4363
|
-
}
|
|
4364
|
-
|
|
4365
|
-
// Human-readable output
|
|
4366
|
-
console.log(`\nbasalt-ui v${pkgVersion} — published surface\n`)
|
|
4367
|
-
const COL = { path: 32, layer: 18, rule: 18, skills: 36 }
|
|
4368
|
-
const header = [
|
|
4369
|
-
'SUBPATH'.padEnd(COL.path),
|
|
4370
|
-
'LAYER'.padEnd(COL.layer),
|
|
4371
|
-
'RULE'.padEnd(COL.rule),
|
|
4372
|
-
'SKILLS'.padEnd(COL.skills),
|
|
4373
|
-
'OPTIONAL PEERS',
|
|
4374
|
-
].join(' ')
|
|
4375
|
-
console.log(header)
|
|
4376
|
-
console.log('-'.repeat(header.length))
|
|
4377
|
-
for (const row of output.subpaths) {
|
|
4378
|
-
const line = [
|
|
4379
|
-
row.path.padEnd(COL.path),
|
|
4380
|
-
row.layer.padEnd(COL.layer),
|
|
4381
|
-
(row.rule ?? '—').padEnd(COL.rule),
|
|
4382
|
-
(row.skills.join(', ') || '—').padEnd(COL.skills),
|
|
4383
|
-
row.optionalPeers.join(', ') || '—',
|
|
4384
|
-
].join(' ')
|
|
4385
|
-
console.log(line)
|
|
4386
|
-
}
|
|
4387
|
-
console.log('')
|
|
4388
|
-
return 0
|
|
4389
|
-
}
|
|
4390
|
-
|
|
4391
|
-
/** The six filenames `basaltAppPlugin` references when `icons` is left at its default. */
|
|
4392
|
-
const DEFAULT_APP_ICON_FILES = [
|
|
4393
|
-
'favicon.ico',
|
|
4394
|
-
'favicon.svg',
|
|
4395
|
-
'favicon-96x96.png',
|
|
4396
|
-
'apple-touch-icon.png',
|
|
4397
|
-
'web-app-manifest-192x192.png',
|
|
4398
|
-
'web-app-manifest-512x512.png',
|
|
4399
|
-
] as const
|
|
4400
|
-
|
|
4401
|
-
const VITE_CONFIG_FILES = [
|
|
4402
|
-
'vite.config.ts',
|
|
4403
|
-
'vite.config.mts',
|
|
4404
|
-
'vite.config.cts',
|
|
4405
|
-
'vite.config.js',
|
|
4406
|
-
'vite.config.mjs',
|
|
4407
|
-
'vite.config.cjs',
|
|
4408
|
-
] as const
|
|
1791
|
+
function expandPatternSegments(baseDir: string, segments: readonly string[]): string[] {
|
|
1792
|
+
if (segments.length === 0) return [baseDir]
|
|
1793
|
+
const [head, ...rest] = segments
|
|
4409
1794
|
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
* no vite config and silently emitted nothing. Each root is walked from its own directory up to
|
|
4417
|
-
* cwd, nearest first, so `apps/web/src` resolves to `apps/web`, the package that owns both the
|
|
4418
|
-
* vite config and the `public/` tree.
|
|
4419
|
-
*/
|
|
4420
|
-
function findAppPluginDir(cwd: string, cfg: BasaltConfig): string | null {
|
|
4421
|
-
const seen = new Set<string>()
|
|
4422
|
-
const candidates: string[] = []
|
|
4423
|
-
const add = (dir: string): void => {
|
|
4424
|
-
if (seen.has(dir)) return
|
|
4425
|
-
seen.add(dir)
|
|
4426
|
-
candidates.push(dir)
|
|
4427
|
-
}
|
|
4428
|
-
add(cwd)
|
|
4429
|
-
for (const root of resolveRoots(cfg)) {
|
|
4430
|
-
let current = resolve(cwd, root)
|
|
4431
|
-
// A root that escapes cwd (`../shared`) is not this project's app package — never walk out.
|
|
4432
|
-
while (current !== cwd && !relative(cwd, current).startsWith('..')) {
|
|
4433
|
-
add(current)
|
|
4434
|
-
const parent = dirname(current)
|
|
4435
|
-
if (parent === current) break
|
|
4436
|
-
current = parent
|
|
1795
|
+
if (head === '**') {
|
|
1796
|
+
// Zero levels: `**` consumes nothing and the rest of the pattern matches right here.
|
|
1797
|
+
const matches = expandPatternSegments(baseDir, rest)
|
|
1798
|
+
// One-or-more levels: descend into every subdirectory, `**` still active.
|
|
1799
|
+
for (const name of subdirNames(baseDir, { skipDotDirs: true })) {
|
|
1800
|
+
matches.push(...expandPatternSegments(resolve(baseDir, name), segments))
|
|
4437
1801
|
}
|
|
1802
|
+
return matches
|
|
4438
1803
|
}
|
|
4439
|
-
for (const dir of candidates) {
|
|
4440
|
-
const configPath = VITE_CONFIG_FILES.map((f) => resolve(dir, f)).find((f) => existsSync(f))
|
|
4441
|
-
if (configPath === undefined) continue
|
|
4442
|
-
if (readIfExists(configPath)?.includes('basaltAppPlugin(') === true) return dir
|
|
4443
|
-
}
|
|
4444
|
-
return null
|
|
4445
|
-
}
|
|
4446
1804
|
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
* NARROWER than it was, never blinder.
|
|
4453
|
-
*/
|
|
4454
|
-
type AppIconsOption =
|
|
4455
|
-
| { kind: 'default'; dir: string | null }
|
|
4456
|
-
| { kind: 'none' }
|
|
4457
|
-
| { kind: 'explicit'; sources: readonly string[] }
|
|
4458
|
-
| { kind: 'unknown' }
|
|
4459
|
-
|
|
4460
|
-
/** The balanced `(`…`)` / `{`…`}` / `[`…`]` span starting at `open`, or null if it never closes. */
|
|
4461
|
-
function balancedSpan(text: string, open: number): string | null {
|
|
4462
|
-
const pairs: Record<string, string> = { '(': ')', '{': '}', '[': ']' }
|
|
4463
|
-
const closer = pairs[text[open] as string]
|
|
4464
|
-
if (closer === undefined) return null
|
|
4465
|
-
let depth = 0
|
|
4466
|
-
for (let i = open; i < text.length; i++) {
|
|
4467
|
-
const ch = text[i] as string
|
|
4468
|
-
if (ch === text[open]) depth++
|
|
4469
|
-
else if (ch === closer) {
|
|
4470
|
-
depth--
|
|
4471
|
-
if (depth === 0) return text.slice(open, i + 1)
|
|
4472
|
-
}
|
|
4473
|
-
}
|
|
4474
|
-
return null
|
|
1805
|
+
const names =
|
|
1806
|
+
head === '*'
|
|
1807
|
+
? subdirNames(baseDir, { skipDotDirs: true })
|
|
1808
|
+
: subdirNames(baseDir).filter((n) => n === head)
|
|
1809
|
+
return names.flatMap((name) => expandPatternSegments(resolve(baseDir, name), rest))
|
|
4475
1810
|
}
|
|
4476
1811
|
|
|
4477
1812
|
/**
|
|
4478
|
-
*
|
|
4479
|
-
*
|
|
4480
|
-
*
|
|
4481
|
-
*
|
|
4482
|
-
*
|
|
4483
|
-
*
|
|
4484
|
-
*
|
|
4485
|
-
*
|
|
4486
|
-
* bounded textual read of the config file, the same shape the guard uses on source. It is
|
|
4487
|
-
* deliberately conservative: anything it cannot classify returns `unknown` and the old check runs.
|
|
1813
|
+
* Expands a root `package.json`'s `workspaces` patterns into concrete package directories.
|
|
1814
|
+
* Supports literal segments, a single-wildcard segment, and a double-wildcard segment, both
|
|
1815
|
+
* interior and trailing (e.g. `packages` followed by a double wildcard, or an interior single
|
|
1816
|
+
* wildcard between two literal segments) — not just one trailing single-wildcard segment. A
|
|
1817
|
+
* candidate directory that has no `package.json` of its own is skipped, not reported — an empty
|
|
1818
|
+
* scaffold directory is not a workspace package. Also honours npm/bun/yarn `!`-prefixed exclusion
|
|
1819
|
+
* entries (e.g. `["packages/*", "!packages/legacy"]`) — both the included and excluded patterns
|
|
1820
|
+
* are expanded through the same machinery, then the excluded set is subtracted.
|
|
4488
1821
|
*/
|
|
4489
|
-
function
|
|
4490
|
-
const
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
const call = text.indexOf('basaltAppPlugin(')
|
|
4495
|
-
if (call === -1) return { kind: 'unknown' }
|
|
4496
|
-
const args = balancedSpan(text, call + 'basaltAppPlugin'.length)
|
|
4497
|
-
if (args === null) return { kind: 'unknown' }
|
|
4498
|
-
const key = args.search(/\bicons\s*:/)
|
|
4499
|
-
if (key === -1) return { kind: 'default', dir: null }
|
|
4500
|
-
const valueStart = args.indexOf(':', key) + 1
|
|
4501
|
-
const rest = args.slice(valueStart).trimStart()
|
|
4502
|
-
if (rest.startsWith('false')) return { kind: 'none' }
|
|
4503
|
-
const offset = args.length - rest.length
|
|
4504
|
-
if (rest.startsWith('[')) {
|
|
4505
|
-
const list = balancedSpan(args, offset)
|
|
4506
|
-
if (list === null) return { kind: 'unknown' }
|
|
4507
|
-
const sources = [...list.matchAll(/\bsrc\s*:\s*['"`]([^'"`]+)['"`]/g)].map(
|
|
4508
|
-
(m) => m[1] as string,
|
|
1822
|
+
function expandWorkspaceGlobs(cwd: string, patterns: readonly string[]): string[] {
|
|
1823
|
+
const expand = (pattern: string): string[] =>
|
|
1824
|
+
expandPatternSegments(
|
|
1825
|
+
cwd,
|
|
1826
|
+
pattern.split('/').filter((s) => s.length > 0),
|
|
4509
1827
|
)
|
|
4510
|
-
// An empty array means "this app has no icons" to the plugin, same as `false`.
|
|
4511
|
-
return sources.length === 0 ? { kind: 'none' } : { kind: 'explicit', sources }
|
|
4512
|
-
}
|
|
4513
|
-
if (rest.startsWith('{')) {
|
|
4514
|
-
const obj = balancedSpan(args, offset)
|
|
4515
|
-
if (obj === null) return { kind: 'unknown' }
|
|
4516
|
-
const dir = /\bdir\s*:\s*['"`]([^'"`]+)['"`]/.exec(obj)
|
|
4517
|
-
return { kind: 'default', dir: dir === null ? null : (dir[1] as string) }
|
|
4518
|
-
}
|
|
4519
|
-
return { kind: 'unknown' }
|
|
4520
|
-
}
|
|
4521
|
-
|
|
4522
|
-
/** Read the value that follows a `--flag` in an argv slice; `undefined` when absent or terminal. */
|
|
4523
|
-
function flagValue(flags: string[], name: string): string | undefined {
|
|
4524
|
-
const i = flags.indexOf(name)
|
|
4525
|
-
if (i === -1) return undefined
|
|
4526
|
-
const value = flags[i + 1]
|
|
4527
|
-
return value === undefined || value.startsWith('--') ? undefined : value
|
|
4528
|
-
}
|
|
4529
|
-
|
|
4530
|
-
/**
|
|
4531
|
-
* The generated-file header: the canonical marker line (imported from the guard, which owns it),
|
|
4532
|
-
* then the version + invocation that produced the file.
|
|
4533
|
-
*
|
|
4534
|
-
* Both lines are load-bearing, not decoration — `checkSource` only exempts a stylesheet whose line
|
|
4535
|
-
* 1 is the marker verbatim AND whose line 2 parses as this provenance line. Reword either half and
|
|
4536
|
-
* basalt starts reporting its own output again (116 findings in rollhook's token sheet).
|
|
4537
|
-
*/
|
|
4538
|
-
function generatedHeader(version: string, command: string, flags: string[]): string {
|
|
4539
|
-
const shown = flags.filter((f) => f !== '--check')
|
|
4540
|
-
const invocation = shown.length === 0 ? command : `${command} ${shown.join(' ')}`
|
|
4541
|
-
return `${GENERATED_HEADER_LINE}\n/* basalt-ui ${version} — \`basalt-ui ${invocation}\` */\n`
|
|
4542
|
-
}
|
|
4543
|
-
|
|
4544
|
-
/**
|
|
4545
|
-
* The provenance line, split so only the VERSION token can be neutralized.
|
|
4546
|
-
*
|
|
4547
|
-
* Deliberately the same shape as the guard's `GENERATED_PROVENANCE_LINE` (`src/guard/index.ts`),
|
|
4548
|
-
* which is what the `@generated` exemption tests line 2 against — the two must keep agreeing, so
|
|
4549
|
-
* this is written to match, not to be lenient. A line that does not match is left VERBATIM, which
|
|
4550
|
-
* is what makes a deleted, reworded or forged header fail `--check` instead of being blanked into
|
|
4551
|
-
* agreement.
|
|
4552
|
-
*/
|
|
4553
|
-
const PROVENANCE_VERSION =
|
|
4554
|
-
/^(\/\* basalt-ui )(\d+\.\d+\.\d+\S*)( — `basalt-ui (?:tokens:css|fonts:css)[^`]*` \*\/)$/
|
|
4555
|
-
|
|
4556
|
-
/** The provenance line's version token, or null when line 2 is not a provenance line at all. */
|
|
4557
|
-
function provenanceVersion(css: string): string | null {
|
|
4558
|
-
const match = PROVENANCE_VERSION.exec(css.split('\n')[1] ?? '')
|
|
4559
|
-
return match === null ? null : (match[2] as string)
|
|
4560
|
-
}
|
|
4561
|
-
|
|
4562
|
-
/**
|
|
4563
|
-
* The generated file with only the provenance line's VERSION TOKEN neutralized — what `--check`
|
|
4564
|
-
* actually compares.
|
|
4565
|
-
*
|
|
4566
|
-
* `--check` used to gate the whole file byte-for-byte, so every basalt-ui release forced a
|
|
4567
|
-
* mandatory no-op commit in every consumer that commits a generated sheet. 1.23.1 fixed that by
|
|
4568
|
-
* blanking the whole of line 2 — and line 2 is the line that carries the exact invocation, which
|
|
4569
|
-
* line 1 tells the reader to regenerate with. The gate stopped verifying the one instruction it
|
|
4570
|
-
* hands out: rollhook's header was rewritten from `--only core --no-legacy-aliases` to `--only all
|
|
4571
|
-
* --with-legacy-aliases` and `--check` passed clean.
|
|
4572
|
-
*
|
|
4573
|
-
* So the version token is replaced by a sentinel and the rest of the line still gates. A release
|
|
4574
|
-
* alone never trips the check; a changed flag, a reworded header, and a header that is simply gone
|
|
4575
|
-
* all do.
|
|
4576
|
-
*
|
|
4577
|
-
* The version STAYS in the file rather than being dropped, because the consumer this command exists
|
|
4578
|
-
* for — a framework-free site running `bunx basalt-ui tokens:css` with no basalt in its dependency
|
|
4579
|
-
* tree at all — has no other record of which version emitted it, and no `doctor` to ask.
|
|
4580
|
-
*
|
|
4581
|
-
* Line-aligned, never dropped, so the reported line counts still match the files on disk. The
|
|
4582
|
-
* `@generated` exemption is untouched by all of this: nothing here changes what
|
|
4583
|
-
* {@link generatedHeader} emits, and {@link PROVENANCE_VERSION} is matched to the guard's own
|
|
4584
|
-
* provenance regex rather than replacing it.
|
|
4585
|
-
*/
|
|
4586
|
-
function withoutProvenanceVersion(css: string): string {
|
|
4587
|
-
const lines = css.split('\n')
|
|
4588
|
-
const line = lines[1]
|
|
4589
|
-
if (line === undefined) return css
|
|
4590
|
-
const match = PROVENANCE_VERSION.exec(line)
|
|
4591
|
-
if (match === null) return css
|
|
4592
|
-
lines[1] = `${match[1] as string}<version>${match[3] as string}`
|
|
4593
|
-
return lines.join('\n')
|
|
4594
|
-
}
|
|
4595
|
-
|
|
4596
|
-
/** The leading `major.minor.patch` of a version string as numbers, or null when it has none. */
|
|
4597
|
-
function versionTriple(version: string): number[] | null {
|
|
4598
|
-
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version)
|
|
4599
|
-
return match === null ? null : [Number(match[1]), Number(match[2]), Number(match[3])]
|
|
4600
|
-
}
|
|
4601
|
-
|
|
4602
|
-
/**
|
|
4603
|
-
* How the on-disk provenance version relates to the one that just ran — measured, not assumed.
|
|
4604
|
-
*
|
|
4605
|
-
* The success message asserted the file "still names an older basalt-ui" without ever reading the
|
|
4606
|
-
* token, so `0.0.1-nonsense` got the same sentence. Both sides are parsed; the numeric triples are
|
|
4607
|
-
* compared and anything that does not order cleanly is reported as merely different.
|
|
4608
|
-
*/
|
|
4609
|
-
function describeVersionSkew(onDisk: string, current: string): string {
|
|
4610
|
-
const a = versionTriple(onDisk)
|
|
4611
|
-
const b = versionTriple(current)
|
|
4612
|
-
if (a === null || b === null) return 'a different'
|
|
4613
|
-
for (let i = 0; i < 3; i++) {
|
|
4614
|
-
const left = a[i] as number
|
|
4615
|
-
const right = b[i] as number
|
|
4616
|
-
if (left < right) return 'an older'
|
|
4617
|
-
if (left > right) return 'a newer'
|
|
4618
|
-
}
|
|
4619
|
-
return 'a different'
|
|
4620
|
-
}
|
|
4621
|
-
|
|
4622
|
-
/**
|
|
4623
|
-
* Space- and number-normalize the argument list of a legacy color function so the emitted file
|
|
4624
|
-
* survives a normal repo's formatter.
|
|
4625
|
-
*
|
|
4626
|
-
* This is FORMATTING ONLY and deliberately the CLI's single deviation from "print exactly what
|
|
4627
|
-
* `buildPaletteCss` returned": `rgba(255,255,255,0.6)` and `rgba(255, 255, 255, 0.6)` are the same
|
|
4628
|
-
* colour, the shipped palette emits the first form on the dark side and the second on the light
|
|
4629
|
-
* side, and a consumer committing the output ate a lint-ignore entry for the difference. The token
|
|
4630
|
-
* VALUES are untouched — nothing here can change what basalt's tokens are.
|
|
4631
|
-
*
|
|
4632
|
-
* Trailing zeros go the same way and for the same reason: prettier rewrites `0.10` → `0.1`, so the
|
|
4633
|
-
* two `rgba(28, 25, 23, 0.10)` alphas in the light shadow tokens were the whole reason the one
|
|
4634
|
-
* framework-free consumer still could not lint its committed sheet — `--fix` and `tokens:css
|
|
4635
|
-
* --check` disagreed forever, and the lint-ignore entry survived two releases. Only a bare decimal
|
|
4636
|
-
* literal is touched (never a `%`, a `var()` or a unit), and `0.10`/`0.1` are the same number.
|
|
4637
|
-
*
|
|
4638
|
-
* Exported for tests, so the expectation can be computed rather than restated.
|
|
4639
|
-
*/
|
|
4640
|
-
export function normalizeColorFunctions(css: string): string {
|
|
4641
|
-
return css.replace(/\b(rgba?|hsla?)\(([^()]*)\)/g, (_match, fn: string, args: string) => {
|
|
4642
|
-
const parts = args.split(',').map((part) => normalizeCssNumber(part.trim()))
|
|
4643
|
-
return `${fn}(${parts.join(', ')})`
|
|
4644
|
-
})
|
|
4645
|
-
}
|
|
4646
1828
|
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
/** A scheme-class emission uses this sentinel attribute, then rewrites it to a class selector. */
|
|
4654
|
-
const SCHEME_CLASS_SENTINEL = 'data-basalt-scheme-class'
|
|
1829
|
+
const excluded = new Set(
|
|
1830
|
+
patterns
|
|
1831
|
+
.filter((pattern) => pattern.startsWith('!'))
|
|
1832
|
+
.flatMap((pattern) => expand(pattern.replace(/^!+/, ''))),
|
|
1833
|
+
)
|
|
4655
1834
|
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
const out = flagValue(flags, '--out')
|
|
4663
|
-
const check = flags.includes('--check')
|
|
4664
|
-
if (out === undefined) {
|
|
4665
|
-
if (check) {
|
|
4666
|
-
console.error(
|
|
4667
|
-
`${command}: --check needs --out <path> — there is nothing to compare stdout to.`,
|
|
4668
|
-
)
|
|
4669
|
-
return 1
|
|
4670
|
-
}
|
|
4671
|
-
process.stdout.write(content)
|
|
4672
|
-
return 0
|
|
4673
|
-
}
|
|
4674
|
-
const target = isAbsolute(out) ? out : resolve(cwd, out)
|
|
4675
|
-
const shown = relative(cwd, target) || target
|
|
4676
|
-
if (check) {
|
|
4677
|
-
const onDisk = readIfExists(target)
|
|
4678
|
-
if (onDisk === null) {
|
|
4679
|
-
console.error(
|
|
4680
|
-
`✖ ${command} --check: ${shown} does not exist — run without --check to write it.`,
|
|
4681
|
-
)
|
|
4682
|
-
return 1
|
|
4683
|
-
}
|
|
4684
|
-
if (withoutProvenanceVersion(onDisk) === withoutProvenanceVersion(content)) {
|
|
4685
|
-
const diskVersion = provenanceVersion(onDisk)
|
|
4686
|
-
const currentVersion = provenanceVersion(content)
|
|
4687
|
-
const skew =
|
|
4688
|
-
diskVersion !== null && currentVersion !== null && diskVersion !== currentVersion
|
|
4689
|
-
? ` (Its provenance line names basalt-ui ${diskVersion}, ` +
|
|
4690
|
-
`${describeVersionSkew(diskVersion, currentVersion)} release than the ` +
|
|
4691
|
-
`${currentVersion} that just ran — only that version token is excluded from the ` +
|
|
4692
|
-
'comparison. Re-run without --check whenever you want it refreshed.)'
|
|
4693
|
-
: ''
|
|
4694
|
-
console.log(`✓ ${command} --check: ${shown} is up to date.${skew}`)
|
|
4695
|
-
return 0
|
|
4696
|
-
}
|
|
4697
|
-
// Name the provenance line when it is the ONLY thing that moved: the diff is one line the
|
|
4698
|
-
// reader is about to scroll past, and a rewritten invocation is the failure that bought this
|
|
4699
|
-
// branch — line 1 tells them to regenerate with line 2, so line 2 has to be true.
|
|
4700
|
-
const diskLines = onDisk.split('\n')
|
|
4701
|
-
const wantLines = content.split('\n')
|
|
4702
|
-
const provenanceOnly =
|
|
4703
|
-
diskLines.length === wantLines.length &&
|
|
4704
|
-
diskLines.every((line, i) => i === 1 || line === wantLines[i]) &&
|
|
4705
|
-
diskLines[1] !== wantLines[1]
|
|
4706
|
-
if (provenanceOnly) {
|
|
4707
|
-
console.error(
|
|
4708
|
-
`✖ ${command} --check: ${shown}'s \`@generated\` provenance line does not match the ` +
|
|
4709
|
-
'command that produced this content — the token values agree, line 2 does not.\n' +
|
|
4710
|
-
` on disk: ${diskLines[1]}\n` +
|
|
4711
|
-
` emitted: ${wantLines[1]}\n` +
|
|
4712
|
-
'Line 1 tells the reader to regenerate with line 2, so line 2 is gated: run the emitted ' +
|
|
4713
|
-
'invocation without --check and commit the result, or change the flags this check runs ' +
|
|
4714
|
-
'with to the ones the file was really built from. Only the version token is exempt.',
|
|
4715
|
-
)
|
|
4716
|
-
return 1
|
|
1835
|
+
const dirs: string[] = []
|
|
1836
|
+
for (const pattern of patterns) {
|
|
1837
|
+
if (pattern.startsWith('!')) continue
|
|
1838
|
+
for (const abs of expand(pattern)) {
|
|
1839
|
+
if (excluded.has(abs)) continue
|
|
1840
|
+
if (existsSync(resolve(abs, 'package.json'))) dirs.push(abs)
|
|
4717
1841
|
}
|
|
4718
|
-
console.error(
|
|
4719
|
-
`✖ ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today ` +
|
|
4720
|
-
`(on disk ${diskLines.length} lines, emitted ${wantLines.length}) — ` +
|
|
4721
|
-
're-run the same command without --check and commit the result. Only the `@generated` ' +
|
|
4722
|
-
"provenance line's VERSION token is excluded from this comparison, so a release alone " +
|
|
4723
|
-
'never trips it — but its flags do.',
|
|
4724
|
-
)
|
|
4725
|
-
return 1
|
|
4726
|
-
}
|
|
4727
|
-
mkdirSync(dirname(target), { recursive: true })
|
|
4728
|
-
writeFileSync(target, content)
|
|
4729
|
-
console.log(`wrote ${content.split('\n').length} lines → ${shown}`)
|
|
4730
|
-
return 0
|
|
4731
|
-
}
|
|
4732
|
-
|
|
4733
|
-
/**
|
|
4734
|
-
* tokens:css — emit the `--vx-*` stylesheet, optionally retargeted.
|
|
4735
|
-
*
|
|
4736
|
-
* The escape hatch from installing anything: a static site runs this once (`bunx basalt-ui
|
|
4737
|
-
* tokens:css --selector-attribute data-theme --out src/tokens.css`) and consumes basalt's token
|
|
4738
|
-
* system as plain CSS, with no package in its dependency tree at all. `basalt-ui/tokens.css` is the
|
|
4739
|
-
* same artifact for a consumer that does install; this command exists for the ones that shouldn't
|
|
4740
|
-
* have to just to change a selector.
|
|
4741
|
-
*
|
|
4742
|
-
* It parses flags and calls `buildPaletteCss` for the token VALUES — the CLI and the API must not
|
|
4743
|
-
* be able to disagree about what basalt's tokens are. What it adds is strictly file framing for an
|
|
4744
|
-
* artifact a consumer COMMITS: the `@generated` header (the guard's skip marker), a trailing
|
|
4745
|
-
* newline, and `rgba()` argument spacing. All three were reported by the one framework-free
|
|
4746
|
-
* consumer as the reasons the output could not simply be committed.
|
|
4747
|
-
*/
|
|
4748
|
-
export function tokensCss(flags: string[], cwd: string = process.cwd()): number {
|
|
4749
|
-
const attribute = flagValue(flags, '--selector-attribute')
|
|
4750
|
-
const schemeClass = flagValue(flags, '--selector-class')
|
|
4751
|
-
const lightClass = flagValue(flags, '--light-class')
|
|
4752
|
-
const darkValue = flagValue(flags, '--dark-value')
|
|
4753
|
-
const lightValue = flagValue(flags, '--light-value')
|
|
4754
|
-
const defaultScheme = flagValue(flags, '--default-scheme')
|
|
4755
|
-
|
|
4756
|
-
const only = flagValue(flags, '--only')
|
|
4757
|
-
|
|
4758
|
-
if (defaultScheme !== undefined && !['dark', 'light', 'none'].includes(defaultScheme)) {
|
|
4759
|
-
console.error(
|
|
4760
|
-
`tokens:css: --default-scheme must be dark, light or none (got '${defaultScheme}')`,
|
|
4761
|
-
)
|
|
4762
|
-
return 1
|
|
4763
|
-
}
|
|
4764
|
-
if (only !== undefined && !['core', 'all'].includes(only)) {
|
|
4765
|
-
console.error(`tokens:css: --only must be core or all (got '${only}')`)
|
|
4766
|
-
return 1
|
|
4767
|
-
}
|
|
4768
|
-
if (schemeClass !== undefined && attribute !== undefined) {
|
|
4769
|
-
console.error(
|
|
4770
|
-
'tokens:css: --selector-class and --selector-attribute are alternatives — pass one.',
|
|
4771
|
-
)
|
|
4772
|
-
return 1
|
|
4773
|
-
}
|
|
4774
|
-
if (schemeClass !== undefined && /[^\w-]/.test(schemeClass)) {
|
|
4775
|
-
console.error(
|
|
4776
|
-
`tokens:css: --selector-class must be a plain CSS class name (got '${schemeClass}')`,
|
|
4777
|
-
)
|
|
4778
|
-
return 1
|
|
4779
|
-
}
|
|
4780
|
-
if (lightClass !== undefined && /[^\w-]/.test(lightClass)) {
|
|
4781
|
-
console.error(`tokens:css: --light-class must be a plain CSS class name (got '${lightClass}')`)
|
|
4782
|
-
return 1
|
|
4783
|
-
}
|
|
4784
|
-
|
|
4785
|
-
// A class selector is Tailwind's universal dark convention (`<html class="dark">`) and was
|
|
4786
|
-
// reachable before only by parking dark on the bare `:root` via --default-scheme. `buildPaletteCss`
|
|
4787
|
-
// emits attribute selectors, so the class form is produced by emitting against a sentinel
|
|
4788
|
-
// attribute this CLI chose itself and rewriting exactly those selectors — a rewrite of strings
|
|
4789
|
-
// the command generated deterministically, never of a token value.
|
|
4790
|
-
const resolvedDarkValue = schemeClass !== undefined ? schemeClass : darkValue
|
|
4791
|
-
const resolvedLightValue = schemeClass !== undefined ? (lightClass ?? 'light') : lightValue
|
|
4792
|
-
const resolvedAttribute = schemeClass !== undefined ? SCHEME_CLASS_SENTINEL : attribute
|
|
4793
|
-
|
|
4794
|
-
const scheme = {
|
|
4795
|
-
...(resolvedAttribute === undefined ? {} : { attribute: resolvedAttribute }),
|
|
4796
|
-
...(resolvedDarkValue === undefined ? {} : { darkValue: resolvedDarkValue }),
|
|
4797
|
-
...(resolvedLightValue === undefined ? {} : { lightValue: resolvedLightValue }),
|
|
4798
|
-
}
|
|
4799
|
-
|
|
4800
|
-
let css = buildPaletteCss({
|
|
4801
|
-
...(Object.keys(scheme).length === 0 ? {} : { scheme }),
|
|
4802
|
-
...(defaultScheme === undefined
|
|
4803
|
-
? {}
|
|
4804
|
-
: { defaultScheme: defaultScheme as 'dark' | 'light' | 'none' }),
|
|
4805
|
-
...(flags.includes('--media-fallback') ? { mediaFallback: true } : {}),
|
|
4806
|
-
...(only === undefined ? {} : { only: only as 'core' | 'all' }),
|
|
4807
|
-
// The tokens-only consumer is exactly the one who writes these names by hand, so they are also
|
|
4808
|
-
// the one who wants the deprecated camelCase aliases gone — and this command is their only
|
|
4809
|
-
// entry point. Without the flag the CLI and the API could disagree about what basalt's tokens
|
|
4810
|
-
// are, which is the one thing this command is documented never to allow.
|
|
4811
|
-
...(flags.includes('--no-legacy-aliases') ? { legacyAliases: false } : {}),
|
|
4812
|
-
})
|
|
4813
|
-
|
|
4814
|
-
if (schemeClass !== undefined) {
|
|
4815
|
-
css = css.replace(
|
|
4816
|
-
new RegExp(`\\[${SCHEME_CLASS_SENTINEL}='([\\w-]+)'\\]`, 'g'),
|
|
4817
|
-
(_match, value: string) => `.${value}`,
|
|
4818
|
-
)
|
|
4819
|
-
}
|
|
4820
|
-
|
|
4821
|
-
const version = readFrameworkVersion(packageRoot())
|
|
4822
|
-
const content = `${generatedHeader(version, 'tokens:css', flags)}${normalizeColorFunctions(css)}\n`
|
|
4823
|
-
return emitGeneratedCss(content, flags, cwd, 'tokens:css')
|
|
4824
|
-
}
|
|
4825
|
-
|
|
4826
|
-
/** Every `--basalt-font-*` declaration in the shipped stylesheet, in source order. */
|
|
4827
|
-
function readShippedFontDecls(pkgRoot: string): { name: string; value: string }[] {
|
|
4828
|
-
const css = readSource(pkgRoot, 'dist/styles.css') ?? readSource(pkgRoot, 'src/styles.css')
|
|
4829
|
-
if (css === null) return []
|
|
4830
|
-
const decls: { name: string; value: string }[] = []
|
|
4831
|
-
for (const match of css.matchAll(/(--basalt-font[\w-]*)\s*:\s*([^;]+);/g)) {
|
|
4832
|
-
decls.push({
|
|
4833
|
-
name: match[1] as string,
|
|
4834
|
-
value: (match[2] as string).replace(/\s+/g, ' ').trim(),
|
|
4835
|
-
})
|
|
4836
|
-
}
|
|
4837
|
-
return decls
|
|
4838
|
-
}
|
|
4839
|
-
|
|
4840
|
-
/**
|
|
4841
|
-
* fonts:css — emit the shipped `--basalt-font-*` stacks as plain CSS.
|
|
4842
|
-
*
|
|
4843
|
-
* The typeface half of the framework-free route, which had no supported path at all: `tokens.css`
|
|
4844
|
-
* emits no font vars, `styles.css` is the one place the defaults live and a framework-free consumer
|
|
4845
|
-
* is told not to import it, and `buildFontsCss` returns `''` unless the caller already knows the
|
|
4846
|
-
* stacks. The one non-Mantine consumer lost its font identity entirely and hardcoded the stacks —
|
|
4847
|
-
* which the theme guard then flagged as `raw-font-family`.
|
|
4848
|
-
*
|
|
4849
|
-
* The declarations are READ from the shipped stylesheet rather than restated here, so this command
|
|
4850
|
-
* and `basalt-ui/styles.css` can never name different typefaces. Note the fonts themselves are
|
|
4851
|
-
* `@fontsource-variable/*` packages: without them the stacks fall through to their own system
|
|
4852
|
-
* fallbacks, which is the intended framework-free behaviour.
|
|
4853
|
-
*/
|
|
4854
|
-
export function fontsCss(flags: string[], cwd: string = process.cwd()): number {
|
|
4855
|
-
const pkgRoot = packageRoot()
|
|
4856
|
-
const decls = readShippedFontDecls(pkgRoot)
|
|
4857
|
-
if (decls.length === 0) {
|
|
4858
|
-
console.error(
|
|
4859
|
-
'fonts:css: could not read the shipped font stacks from styles.css — this is a packaging ' +
|
|
4860
|
-
'bug in basalt-ui, not a config error.',
|
|
4861
|
-
)
|
|
4862
|
-
return 1
|
|
4863
1842
|
}
|
|
4864
|
-
|
|
4865
|
-
const header = generatedHeader(readFrameworkVersion(pkgRoot), 'fonts:css', flags)
|
|
4866
|
-
return emitGeneratedCss(`${header}${body}`, flags, cwd, 'fonts:css')
|
|
1843
|
+
return dirs
|
|
4867
1844
|
}
|
|
4868
1845
|
|
|
4869
1846
|
/**
|
|
4870
|
-
*
|
|
4871
|
-
*
|
|
4872
|
-
*
|
|
4873
|
-
*
|
|
4874
|
-
*
|
|
1847
|
+
* Every workspace package declared by `cwd`'s root `package.json` (npm/bun/yarn `workspaces`
|
|
1848
|
+
* field, array or `{ packages: [...] }` object form), named + directory-resolved — PLUS the root
|
|
1849
|
+
* manifest itself, labelled `"{name} (root)"`. The root is walked because it commonly carries a
|
|
1850
|
+
* hoisted `ai` dev dependency that can itself skew against a workspace package's own major — that
|
|
1851
|
+
* is the producer/consumer shape the whole check exists to catch, and it is invisible if only the
|
|
1852
|
+
* expanded workspace dirs are inspected. `hasWorkspacesField` is `false` (and `packages` empty)
|
|
1853
|
+
* when `cwd` has no `package.json`, no `workspaces` field, or the field is a shape this doesn't
|
|
1854
|
+
* recognize — a repo-wide walk is a bonus check, not something that should ever throw doctor over
|
|
1855
|
+
* an unrecognized `workspaces` shape.
|
|
4875
1856
|
*/
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
// Bun: Bun.stdin.text() drains stdin to a string; under Node fall back to manual drain.
|
|
4880
|
-
if (typeof (globalThis as Record<string, unknown>)['Bun'] !== 'undefined') {
|
|
4881
|
-
raw = await globalThis['Bun'].stdin.text()
|
|
4882
|
-
} else {
|
|
4883
|
-
const chunks: Buffer[] = []
|
|
4884
|
-
for await (const chunk of process.stdin) chunks.push(chunk as Buffer)
|
|
4885
|
-
raw = Buffer.concat(chunks).toString('utf8')
|
|
4886
|
-
}
|
|
4887
|
-
} catch {
|
|
4888
|
-
// Unreadable stdin → allow
|
|
4889
|
-
process.stdout.write(
|
|
4890
|
-
'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
|
|
4891
|
-
)
|
|
4892
|
-
return 0
|
|
4893
|
-
}
|
|
1857
|
+
function collectWorkspacePackages(cwd: string): WorkspaceWalk {
|
|
1858
|
+
const raw = readIfExists(resolve(cwd, 'package.json'))
|
|
1859
|
+
if (raw === null) return { packages: [], hasWorkspacesField: false }
|
|
4894
1860
|
|
|
4895
|
-
let
|
|
1861
|
+
let parsed: { workspaces?: unknown; name?: string }
|
|
4896
1862
|
try {
|
|
4897
|
-
|
|
1863
|
+
parsed = JSON.parse(raw) as { workspaces?: unknown; name?: string }
|
|
4898
1864
|
} catch {
|
|
4899
|
-
|
|
4900
|
-
process.stdout.write(
|
|
4901
|
-
'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
|
|
4902
|
-
)
|
|
4903
|
-
return 0
|
|
1865
|
+
return { packages: [], hasWorkspacesField: false }
|
|
4904
1866
|
}
|
|
4905
1867
|
|
|
4906
|
-
const
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
offSystemSurfaceVar: cfg.offSystemSurfaceVar ?? DEFAULT_GUARD_CONFIG.offSystemSurfaceVar,
|
|
4915
|
-
rawHtmlLayout: cfg.rawHtmlLayout ?? DEFAULT_GUARD_CONFIG.rawHtmlLayout,
|
|
4916
|
-
inlineSpacing: cfg.inlineSpacing ?? DEFAULT_GUARD_CONFIG.inlineSpacing,
|
|
4917
|
-
inlineDisplay: cfg.inlineDisplay ?? DEFAULT_GUARD_CONFIG.inlineDisplay,
|
|
4918
|
-
rawVisxAxis: cfg.rawVisxAxis ?? DEFAULT_GUARD_CONFIG.rawVisxAxis,
|
|
4919
|
-
rawMotionValue: cfg.rawMotionValue ?? DEFAULT_GUARD_CONFIG.rawMotionValue,
|
|
4920
|
-
unframedChart: cfg.unframedChart ?? DEFAULT_GUARD_CONFIG.unframedChart,
|
|
4921
|
-
chartMissingAriaLabel: cfg.chartMissingAriaLabel ?? DEFAULT_GUARD_CONFIG.chartMissingAriaLabel,
|
|
4922
|
-
rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
|
|
4923
|
-
sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
|
|
4924
|
-
allowComment: 'theme-allow',
|
|
4925
|
-
exemptRules: resolveExemptRules(cfg),
|
|
4926
|
-
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
|
|
4927
|
-
// Same detection check-theme uses: the hook must never block an edit over advice the app
|
|
4928
|
-
// cannot take ("use @mantine/core's Select") in a repo with no Mantine.
|
|
4929
|
-
...(declaredProfile(cfg, []) === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
|
|
4930
|
-
}
|
|
1868
|
+
const patterns = isStringArray(parsed.workspaces)
|
|
1869
|
+
? parsed.workspaces
|
|
1870
|
+
: parsed.workspaces !== null &&
|
|
1871
|
+
typeof parsed.workspaces === 'object' &&
|
|
1872
|
+
isStringArray((parsed.workspaces as { packages?: unknown }).packages)
|
|
1873
|
+
? (parsed.workspaces as { packages: string[] }).packages
|
|
1874
|
+
: null
|
|
1875
|
+
if (patterns === null) return { packages: [], hasWorkspacesField: false }
|
|
4931
1876
|
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
const abs = isAbsolute(filePath) ? filePath : resolve(cwd, filePath)
|
|
4938
|
-
const rel = relative(cwd, abs).replace(/\\/g, '/')
|
|
4939
|
-
if (rel === '' || rel.startsWith('..')) return false
|
|
4940
|
-
if (SKIP.test(rel) || exempt.has(rel)) return false
|
|
4941
|
-
return roots.some((root) => {
|
|
4942
|
-
const r = root.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
4943
|
-
return rel === r || rel.startsWith(`${r}/`)
|
|
4944
|
-
})
|
|
4945
|
-
}
|
|
1877
|
+
const rootName = parsed.name && parsed.name.length > 0 ? `${parsed.name} (root)` : '(root)'
|
|
1878
|
+
const workspacePackages = expandWorkspaceGlobs(cwd, patterns).map((dir) => ({
|
|
1879
|
+
name: readPackageName(dir),
|
|
1880
|
+
dir,
|
|
1881
|
+
}))
|
|
4946
1882
|
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
process.stdout.write(
|
|
4951
|
-
JSON.stringify({
|
|
4952
|
-
hookSpecificOutput: {
|
|
4953
|
-
hookEventName: 'PreToolUse',
|
|
4954
|
-
permissionDecision: 'deny',
|
|
4955
|
-
permissionDecisionReason: result.reason,
|
|
4956
|
-
},
|
|
4957
|
-
}) + '\n',
|
|
4958
|
-
)
|
|
4959
|
-
} else {
|
|
4960
|
-
process.stdout.write(
|
|
4961
|
-
'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
|
|
4962
|
-
)
|
|
1883
|
+
return {
|
|
1884
|
+
packages: [{ name: rootName, dir: cwd }, ...workspacePackages],
|
|
1885
|
+
hasWorkspacesField: true,
|
|
4963
1886
|
}
|
|
4964
|
-
return 0
|
|
4965
|
-
}
|
|
4966
|
-
|
|
4967
|
-
/**
|
|
4968
|
-
* Every flag each subcommand accepts — the table the unknown-flag gate reads.
|
|
4969
|
-
*
|
|
4970
|
-
* An unrecognized flag used to be SILENTLY IGNORED and the command exited 0: `check-theme
|
|
4971
|
-
* --audit-allow` scanned normally and reported success, `doctor --json` printed the human report.
|
|
4972
|
-
* That is the same fail-open shape `--version` had, and it is worse on a CLI agents drive
|
|
4973
|
-
* programmatically, where a typo'd gate reads as a passing gate.
|
|
4974
|
-
*/
|
|
4975
|
-
const COMMAND_FLAGS: Record<string, readonly string[]> = {
|
|
4976
|
-
init: ['--with-router', '--with-query', '--merge-lint'],
|
|
4977
|
-
sync: ['--force', '--check', '--tokens-only', '--framework'],
|
|
4978
|
-
'check-theme': ['--audit-allows', '--tokens-only', '--framework'],
|
|
4979
|
-
'check-coverage': ['--write', '--check'],
|
|
4980
|
-
info: ['--json'],
|
|
4981
|
-
doctor: ['--tokens-only', '--framework'],
|
|
4982
|
-
'guard-hook': [],
|
|
4983
|
-
'tokens:css': [
|
|
4984
|
-
'--out',
|
|
4985
|
-
'--check',
|
|
4986
|
-
'--selector-attribute',
|
|
4987
|
-
'--selector-class',
|
|
4988
|
-
'--light-class',
|
|
4989
|
-
'--dark-value',
|
|
4990
|
-
'--light-value',
|
|
4991
|
-
'--default-scheme',
|
|
4992
|
-
'--media-fallback',
|
|
4993
|
-
'--only',
|
|
4994
|
-
'--no-legacy-aliases',
|
|
4995
|
-
],
|
|
4996
|
-
'fonts:css': ['--out', '--check'],
|
|
4997
1887
|
}
|
|
4998
1888
|
|
|
4999
|
-
/**
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
'--out',
|
|
5003
|
-
'--selector-attribute',
|
|
5004
|
-
'--selector-class',
|
|
5005
|
-
'--light-class',
|
|
5006
|
-
'--dark-value',
|
|
5007
|
-
'--light-value',
|
|
5008
|
-
'--default-scheme',
|
|
5009
|
-
'--only',
|
|
5010
|
-
])
|
|
5011
|
-
|
|
5012
|
-
/** The first flag `cmd` does not accept, or null. Unknown commands return null — the dispatcher's
|
|
5013
|
-
* default branch already names those. */
|
|
5014
|
-
function unknownFlag(cmd: string, flags: readonly string[]): string | null {
|
|
5015
|
-
const allowed = COMMAND_FLAGS[cmd]
|
|
5016
|
-
if (allowed === undefined) return null
|
|
5017
|
-
for (let i = 0; i < flags.length; i++) {
|
|
5018
|
-
const flag = flags[i] as string
|
|
5019
|
-
if (!flag.startsWith('-')) continue
|
|
5020
|
-
if (!allowed.includes(flag)) return flag
|
|
5021
|
-
if (VALUE_FLAGS.has(flag)) i++
|
|
5022
|
-
}
|
|
5023
|
-
return null
|
|
1889
|
+
/** Narrows to a string array — shared by `collectWorkspacePackages`'s two `workspaces` shapes. */
|
|
1890
|
+
export function isStringArray(value: unknown): value is string[] {
|
|
1891
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
|
|
5024
1892
|
}
|
|
5025
1893
|
|
|
5026
|
-
/** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
|
|
5027
|
-
const USAGE =
|
|
5028
|
-
'Usage: basalt-ui <--version | init [--with-router] [--with-query] [--merge-lint] |\n' +
|
|
5029
|
-
' sync [--force] [--check] [--tokens-only|--framework] |\n' +
|
|
5030
|
-
' check-theme [--audit-allows] | check-coverage [--write|--check] |\n' +
|
|
5031
|
-
' info [--json] |\n' +
|
|
5032
|
-
' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
|
|
5033
|
-
'check-theme [--tokens-only|--framework] [--audit-allows]\n' +
|
|
5034
|
-
' --audit-allows reports instead of scanning: every `theme-allow` annotation and every\n' +
|
|
5035
|
-
' `basalt.exemptRules` entry, with what each one still suppresses — proved by re-running the\n' +
|
|
5036
|
-
' guard with that one waiver neutralized, not by reading its text. Exits 1 on a waiver that\n' +
|
|
5037
|
-
' suppresses nothing (dead), which is what makes it usable as a CI gate.\n\n' +
|
|
5038
|
-
'tokens:css [--out <path>] [--check] [--selector-attribute <attr> | --selector-class <class>]\n' +
|
|
5039
|
-
' [--light-class <class>] [--dark-value <v>] [--light-value <v>]\n' +
|
|
5040
|
-
' [--default-scheme <dark|light|none>] [--media-fallback] [--only <core|all>]\n' +
|
|
5041
|
-
' [--no-legacy-aliases]\n' +
|
|
5042
|
-
' Emit the --vx-* stylesheet (stdout unless --out). Defaults reproduce basalt-ui/tokens.css.\n' +
|
|
5043
|
-
' --selector-class emits `:root.dark` instead of an attribute selector (the Tailwind convention).\n' +
|
|
5044
|
-
' --check writes nothing and exits 1 when --out differs from what would be emitted (a CI gate).\n' +
|
|
5045
|
-
' --no-legacy-aliases drops the deprecated camelCase spellings (--vx-accentFill and friends),\n' +
|
|
5046
|
-
' which are emitted by default as aliases of the canonical kebab-case names.\n\n' +
|
|
5047
|
-
'fonts:css [--out <path>] [--check]\n' +
|
|
5048
|
-
' Emit the shipped --basalt-font-* stacks as plain CSS — the typeface half of the token layer,\n' +
|
|
5049
|
-
' otherwise reachable only by importing styles.css.\n\n' +
|
|
5050
|
-
'doctor [--tokens-only|--framework]\n' +
|
|
5051
|
-
' Auto-detects a tokens-only consumer (no manifest + no @mantine/core) and checks only what\n' +
|
|
5052
|
-
' applies. A check that cannot RUN is reported as SKIPPED and exits non-zero.\n\n' +
|
|
5053
|
-
'sync [--force] [--check] [--tokens-only|--framework]\n' +
|
|
5054
|
-
' Refreshes an EXISTING install; it never creates one (that is init). A tokens-only consumer\n' +
|
|
5055
|
-
' has no scaffold to reconcile, so sync reports n/a and exits 0 there — as doctor does.\n\n' +
|
|
5056
|
-
'check-theme / doctor / sync all resolve their project the same way: they honour BASALT_CWD, and\n' +
|
|
5057
|
-
'relocate to the single package below the cwd carrying a basalt config when invoked from a repo\n' +
|
|
5058
|
-
'root that has none — several carrying one is ambiguous and exits 1 in ALL THREE.\n' +
|
|
5059
|
-
'--tokens-only and --framework are alternatives; passing both is an error.\n\n' +
|
|
5060
|
-
'Every subcommand accepts --help / -h to print this message and exit without running.\n' +
|
|
5061
|
-
'--version / -v prints the resolved package version and exits 0 — the one command that proves\n' +
|
|
5062
|
-
'WHICH basalt-ui ran, which a `bunx` cache can otherwise make a lie. An unknown command or an\n' +
|
|
5063
|
-
'unrecognized flag exits 1 and names what it did not understand; neither is ever ignored.'
|
|
5064
|
-
|
|
5065
1894
|
/**
|
|
5066
1895
|
* CLI dispatcher — parses argv (subcommand + flags) and returns the command's exit code. The bin
|
|
5067
1896
|
* entry is the ONLY caller that translates this to process.exit, so init/sync/checkTheme stay free
|
|
@@ -5096,9 +1925,10 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
|
|
|
5096
1925
|
if (cmd !== undefined) {
|
|
5097
1926
|
const bad = unknownFlag(cmd, flags)
|
|
5098
1927
|
if (bad !== null) {
|
|
1928
|
+
const accepted = Object.keys(COMMAND_OPTIONS[cmd] ?? {}).map((k) => `--${k}`)
|
|
5099
1929
|
console.error(
|
|
5100
1930
|
`basalt-ui ${cmd}: unrecognized flag '${bad}'. ` +
|
|
5101
|
-
`Accepted: ${
|
|
1931
|
+
`Accepted: ${accepted.join(' ') || '(none)'} --help\n`,
|
|
5102
1932
|
)
|
|
5103
1933
|
return 1
|
|
5104
1934
|
}
|
|
@@ -5117,10 +1947,6 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
|
|
|
5117
1947
|
)
|
|
5118
1948
|
case 'check-theme':
|
|
5119
1949
|
return checkTheme(cwd, flags)
|
|
5120
|
-
case 'check-coverage':
|
|
5121
|
-
return checkCoverage(flags)
|
|
5122
|
-
case 'info':
|
|
5123
|
-
return info(flags)
|
|
5124
1950
|
case 'doctor':
|
|
5125
1951
|
return doctor(cwd, flags)
|
|
5126
1952
|
case 'guard-hook':
|
|
@@ -5143,8 +1969,12 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
|
|
|
5143
1969
|
}
|
|
5144
1970
|
}
|
|
5145
1971
|
|
|
5146
|
-
//
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
export
|
|
1972
|
+
// `RULE_NAMES`/`SKILL_NAMES` re-exported for testing / introspection (no default export).
|
|
1973
|
+
export { RULE_NAMES, SKILL_NAMES }
|
|
1974
|
+
export type { ManagedFile }
|
|
1975
|
+
|
|
1976
|
+
// Re-export every split-out command so `import { X } from './index'` keeps working — `index.ts` is
|
|
1977
|
+
// dispatch + shared helpers only (C2); the command implementations live in their own files.
|
|
1978
|
+
export { checkTheme, doctor, guardHook, init, sync, fontsCss, normalizeColorFunctions, tokensCss }
|
|
1979
|
+
export type { SyncOptions } from './sync'
|
|
1980
|
+
export type { DoctorResult } from './doctor'
|