basalt-ui 1.27.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.
Files changed (1245) hide show
  1. package/AGENTS.md +28 -35
  2. package/CHANGELOG.md +72 -0
  3. package/MIGRATING.md +759 -22
  4. package/README.md +56 -69
  5. package/agent/rules/basalt-batteries.md +60 -168
  6. package/agent/rules/basalt-charts.md +56 -75
  7. package/agent/rules/basalt-controls.md +81 -87
  8. package/agent/rules/basalt-mantine.md +64 -98
  9. package/agent/rules/basalt-state.md +42 -60
  10. package/agent/rules/basalt-tokens.md +46 -67
  11. package/agent/skills/basalt-app/SKILL.md +2 -0
  12. package/agent/templates/CLAUDE-block.md.tpl +14 -14
  13. package/configs/oxlint-plugin.js +964 -33
  14. package/configs/oxlint-plugin.test.ts +902 -2
  15. package/configs/oxlint.json +6 -0
  16. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  17. package/dist/agent/ai-sdk-transport.js +7 -4
  18. package/dist/agent/coalesce.d.ts.map +1 -1
  19. package/dist/agent/coalesce.js +2 -1
  20. package/dist/agent/merge.d.ts.map +1 -1
  21. package/dist/agent/merge.js +2 -1
  22. package/dist/agent/part-list.d.ts.map +1 -1
  23. package/dist/agent/part-list.js +2 -0
  24. package/dist/agent/stick-to-bottom.d.ts +3 -6
  25. package/dist/agent/stick-to-bottom.d.ts.map +1 -1
  26. package/dist/agent/stick-to-bottom.js +6 -3
  27. package/dist/agent/transport.d.ts +13 -2
  28. package/dist/agent/transport.d.ts.map +1 -1
  29. package/dist/agent/use-agent-thread-runs.d.ts +12 -1
  30. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  31. package/dist/agent/use-agent-thread-runs.js +20 -6
  32. package/dist/agent-chat/composer.d.ts +6 -2
  33. package/dist/agent-chat/composer.d.ts.map +1 -1
  34. package/dist/agent-chat/composer.js +49 -33
  35. package/dist/agent-chat/index.d.ts +1 -1
  36. package/dist/agent-chat/index.d.ts.map +1 -1
  37. package/dist/agent-chat/relative-time.d.ts +0 -9
  38. package/dist/agent-chat/relative-time.d.ts.map +1 -1
  39. package/dist/agent-chat/relative-time.js +2 -1
  40. package/dist/agent-chat/thread-detail-panel.d.ts +3 -2
  41. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  42. package/dist/agent-chat/thread-detail-panel.js +9 -6
  43. package/dist/agent-chat/thread-feed-row.d.ts +32 -6
  44. package/dist/agent-chat/thread-feed-row.d.ts.map +1 -1
  45. package/dist/agent-chat/thread-feed-row.js +61 -26
  46. package/dist/agent-chat/thread-feed.d.ts +13 -2
  47. package/dist/agent-chat/thread-feed.d.ts.map +1 -1
  48. package/dist/agent-chat/thread-feed.js +16 -7
  49. package/dist/agent-chat/thread-message.d.ts +4 -3
  50. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  51. package/dist/agent-chat/thread-message.js +70 -31
  52. package/dist/agent-chat/thread-outcome-card.d.ts +3 -2
  53. package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
  54. package/dist/agent-chat/thread-outcome-card.js +9 -2
  55. package/dist/agent-chat/thread-workspace.d.ts +7 -2
  56. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  57. package/dist/agent-chat/thread-workspace.js +62 -29
  58. package/dist/agent-chat/tool-chip.d.ts +3 -2
  59. package/dist/agent-chat/tool-chip.d.ts.map +1 -1
  60. package/dist/agent-chat/tool-chip.js +7 -2
  61. package/dist/agent-chat/virtualize.d.ts +39 -0
  62. package/dist/agent-chat/virtualize.d.ts.map +1 -1
  63. package/dist/agent-chat/virtualize.js +15 -0
  64. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -1
  65. package/dist/charts/hooks/useBandPlot.js +15 -8
  66. package/dist/charts/index.d.ts +11 -10
  67. package/dist/charts/index.d.ts.map +1 -1
  68. package/dist/charts/index.js +53 -9
  69. package/dist/charts/kinds/BandStrip.d.ts +10 -7
  70. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  71. package/dist/charts/kinds/BandStrip.js +12 -7
  72. package/dist/charts/kinds/Bars.d.ts +13 -9
  73. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  74. package/dist/charts/kinds/Bars.js +37 -11
  75. package/dist/charts/kinds/Donut.d.ts +6 -5
  76. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  77. package/dist/charts/kinds/Donut.js +19 -2
  78. package/dist/charts/kinds/DualPanel.d.ts +9 -6
  79. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  80. package/dist/charts/kinds/DualPanel.js +37 -9
  81. package/dist/charts/kinds/Heatmap.d.ts +10 -6
  82. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  83. package/dist/charts/kinds/Heatmap.js +58 -31
  84. package/dist/charts/kinds/MirroredBars.d.ts +6 -5
  85. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  86. package/dist/charts/kinds/MirroredBars.js +9 -2
  87. package/dist/charts/kinds/MultiLine.d.ts +11 -6
  88. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  89. package/dist/charts/kinds/MultiLine.js +24 -15
  90. package/dist/charts/kinds/StackedArea.d.ts +18 -8
  91. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  92. package/dist/charts/kinds/StackedArea.js +29 -4
  93. package/dist/charts/kinds/ZonedLine.d.ts +13 -11
  94. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  95. package/dist/charts/kinds/ZonedLine.js +33 -23
  96. package/dist/charts/layout/auto-margin.d.ts +65 -8
  97. package/dist/charts/layout/auto-margin.d.ts.map +1 -1
  98. package/dist/charts/layout/auto-margin.js +45 -10
  99. package/dist/charts/layout/log-ticks.d.ts +27 -0
  100. package/dist/charts/layout/log-ticks.d.ts.map +1 -0
  101. package/dist/charts/layout/log-ticks.js +42 -0
  102. package/dist/charts/primitives/Axes.d.ts +32 -4
  103. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  104. package/dist/charts/primitives/Axes.js +49 -7
  105. package/dist/charts/primitives/CartesianChart.d.ts +45 -7
  106. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  107. package/dist/charts/primitives/CartesianChart.js +86 -28
  108. package/dist/charts/primitives/ChartCard.d.ts +45 -4
  109. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  110. package/dist/charts/primitives/ChartCard.js +70 -21
  111. package/dist/charts/primitives/ChartFrame.d.ts +30 -4
  112. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  113. package/dist/charts/primitives/ChartFrame.js +52 -15
  114. package/dist/charts/primitives/ChartLegend.d.ts +13 -2
  115. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  116. package/dist/charts/primitives/ChartLegend.js +33 -13
  117. package/dist/charts/primitives/ChartPending.d.ts +85 -6
  118. package/dist/charts/primitives/ChartPending.d.ts.map +1 -1
  119. package/dist/charts/primitives/ChartPending.js +100 -6
  120. package/dist/charts/primitives/ChartTooltip.d.ts +7 -0
  121. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  122. package/dist/charts/primitives/ChartTooltip.js +18 -5
  123. package/dist/charts/primitives/Crosshair.d.ts +3 -0
  124. package/dist/charts/primitives/Crosshair.d.ts.map +1 -1
  125. package/dist/charts/primitives/Crosshair.js +3 -1
  126. package/dist/charts/primitives/chart-frame-layout.d.ts +95 -0
  127. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -0
  128. package/dist/charts/primitives/chart-frame-layout.js +89 -0
  129. package/dist/charts/primitives/chart-tier.d.ts +16 -0
  130. package/dist/charts/primitives/chart-tier.d.ts.map +1 -0
  131. package/dist/charts/primitives/chart-tier.js +20 -0
  132. package/dist/charts/series.d.ts +55 -1
  133. package/dist/charts/series.d.ts.map +1 -1
  134. package/dist/charts/series.js +27 -1
  135. package/dist/charts/sparklines/BarSparkline.d.ts +3 -3
  136. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  137. package/dist/charts/sparklines/BarSparkline.js +9 -3
  138. package/dist/charts/sparklines/LineSparkline.d.ts +3 -3
  139. package/dist/charts/sparklines/LineSparkline.d.ts.map +1 -1
  140. package/dist/charts/sparklines/LineSparkline.js +15 -3
  141. package/dist/charts/sparklines/index.d.ts +2 -2
  142. package/dist/charts/sparklines/index.d.ts.map +1 -1
  143. package/dist/charts/utils/format.d.ts +7 -4
  144. package/dist/charts/utils/format.d.ts.map +1 -1
  145. package/dist/charts/utils/format.js +17 -24
  146. package/dist/charts/utils/ticks.d.ts +81 -2
  147. package/dist/charts/utils/ticks.d.ts.map +1 -1
  148. package/dist/charts/utils/ticks.js +54 -4
  149. package/dist/cli/audit-allows.d.ts +24 -0
  150. package/dist/cli/audit-allows.d.ts.map +1 -0
  151. package/dist/cli/audit-allows.js +240 -0
  152. package/dist/cli/check-theme.d.ts +10 -0
  153. package/dist/cli/check-theme.d.ts.map +1 -0
  154. package/dist/cli/check-theme.js +139 -0
  155. package/dist/cli/doctor.d.ts +47 -0
  156. package/dist/cli/doctor.d.ts.map +1 -0
  157. package/dist/cli/doctor.js +197 -0
  158. package/dist/cli/guard-hook.d.ts +9 -0
  159. package/dist/cli/guard-hook.d.ts.map +1 -0
  160. package/dist/cli/guard-hook.js +93 -0
  161. package/dist/cli/help.d.ts +9 -0
  162. package/dist/cli/help.d.ts.map +1 -0
  163. package/dist/cli/help.js +53 -0
  164. package/dist/cli/index.d.ts +360 -270
  165. package/dist/cli/index.d.ts.map +1 -1
  166. package/dist/cli/index.js +85 -1901
  167. package/dist/cli/init.d.ts +4 -0
  168. package/dist/cli/init.d.ts.map +1 -0
  169. package/dist/cli/init.js +165 -0
  170. package/dist/cli/sync.d.ts +43 -0
  171. package/dist/cli/sync.d.ts.map +1 -0
  172. package/dist/cli/sync.js +206 -0
  173. package/dist/cli/tokens-css.d.ts +51 -0
  174. package/dist/cli/tokens-css.d.ts.map +1 -0
  175. package/dist/cli/tokens-css.js +165 -0
  176. package/dist/commands/ShortcutsHelp.d.ts +3 -2
  177. package/dist/commands/ShortcutsHelp.d.ts.map +1 -1
  178. package/dist/commands/ShortcutsHelp.js +8 -2
  179. package/dist/commands/define-commands.d.ts.map +1 -1
  180. package/dist/commands/define-commands.js +2 -2
  181. package/dist/commands/define-overlays.d.ts +102 -0
  182. package/dist/commands/define-overlays.d.ts.map +1 -1
  183. package/dist/commands/define-overlays.js +102 -2
  184. package/dist/commands/index.d.ts +12 -2
  185. package/dist/commands/index.d.ts.map +1 -1
  186. package/dist/commands/index.js +9 -1
  187. package/dist/commands/overlays-mount.d.ts +16 -11
  188. package/dist/commands/overlays-mount.d.ts.map +1 -1
  189. package/dist/commands/overlays-mount.js +23 -14
  190. package/dist/commands/shell-bridge.d.ts +57 -0
  191. package/dist/commands/shell-bridge.d.ts.map +1 -0
  192. package/dist/commands/shell-bridge.js +32 -0
  193. package/dist/common/errors.d.ts +69 -0
  194. package/dist/common/errors.d.ts.map +1 -0
  195. package/dist/common/errors.js +64 -0
  196. package/dist/common/index.d.ts +17 -0
  197. package/dist/common/index.d.ts.map +1 -0
  198. package/dist/common/index.js +35 -0
  199. package/dist/common/is-dev.d.ts +3 -0
  200. package/dist/common/is-dev.d.ts.map +1 -0
  201. package/dist/common/is-dev.js +6 -0
  202. package/dist/{motion/index.d.ts → common/motion.d.ts} +1 -1
  203. package/dist/common/motion.d.ts.map +1 -0
  204. package/dist/common/props.d.ts +63 -0
  205. package/dist/common/props.d.ts.map +1 -0
  206. package/dist/common/props.js +6 -0
  207. package/dist/common/query-state-like.d.ts +43 -0
  208. package/dist/common/query-state-like.d.ts.map +1 -0
  209. package/dist/common/query-state-like.js +20 -0
  210. package/dist/common/refs.d.ts +29 -0
  211. package/dist/common/refs.d.ts.map +1 -0
  212. package/dist/common/refs.js +29 -0
  213. package/dist/common/scroll-parent.d.ts +23 -0
  214. package/dist/common/scroll-parent.d.ts.map +1 -0
  215. package/dist/common/scroll-parent.js +21 -0
  216. package/dist/common/validate.d.ts +38 -0
  217. package/dist/common/validate.d.ts.map +1 -0
  218. package/dist/common/validate.js +36 -0
  219. package/dist/content/article-layout.module.css +11 -4
  220. package/dist/content/callout.d.ts +8 -4
  221. package/dist/content/callout.d.ts.map +1 -1
  222. package/dist/content/callout.js +15 -8
  223. package/dist/content/guide.d.ts +5 -4
  224. package/dist/content/guide.d.ts.map +1 -1
  225. package/dist/content/guide.js +13 -4
  226. package/dist/content/heading-anchor.d.ts +24 -2
  227. package/dist/content/heading-anchor.d.ts.map +1 -1
  228. package/dist/content/heading-anchor.js +23 -9
  229. package/dist/content/index.d.ts.map +1 -1
  230. package/dist/content/markdown.d.ts +3 -4
  231. package/dist/content/markdown.d.ts.map +1 -1
  232. package/dist/content/markdown.js +3 -4
  233. package/dist/content/prose.module.css +9 -7
  234. package/dist/content/reading-progress.d.ts +10 -4
  235. package/dist/content/reading-progress.d.ts.map +1 -1
  236. package/dist/content/reading-progress.js +26 -10
  237. package/dist/content/slug.d.ts +4 -0
  238. package/dist/content/slug.d.ts.map +1 -1
  239. package/dist/content/toc.d.ts +11 -0
  240. package/dist/content/toc.d.ts.map +1 -1
  241. package/dist/content/toc.js +23 -10
  242. package/dist/controls/actions.d.ts +50 -7
  243. package/dist/controls/actions.d.ts.map +1 -1
  244. package/dist/controls/actions.js +39 -8
  245. package/dist/controls/compare-filter.d.ts +3 -2
  246. package/dist/controls/compare-filter.d.ts.map +1 -1
  247. package/dist/controls/compare-filter.js +17 -2
  248. package/dist/controls/control-group.d.ts +3 -3
  249. package/dist/controls/control-group.d.ts.map +1 -1
  250. package/dist/controls/control-group.js +9 -2
  251. package/dist/controls/controls.module.css +169 -186
  252. package/dist/controls/controls.module.css.d.ts +9 -11
  253. package/dist/controls/enum-filter.d.ts +3 -2
  254. package/dist/controls/enum-filter.d.ts.map +1 -1
  255. package/dist/controls/enum-filter.js +28 -13
  256. package/dist/controls/filter-context.d.ts +8 -4
  257. package/dist/controls/filter-context.d.ts.map +1 -1
  258. package/dist/controls/filter-pill.d.ts +3 -3
  259. package/dist/controls/filter-pill.d.ts.map +1 -1
  260. package/dist/controls/filter-pill.js +4 -1
  261. package/dist/controls/filter-set.d.ts +3 -2
  262. package/dist/controls/filter-set.d.ts.map +1 -1
  263. package/dist/controls/filter-set.js +66 -41
  264. package/dist/controls/filter-sheet.d.ts +20 -80
  265. package/dist/controls/filter-sheet.d.ts.map +1 -1
  266. package/dist/controls/filter-sheet.js +9 -95
  267. package/dist/controls/index.d.ts +2 -0
  268. package/dist/controls/index.d.ts.map +1 -1
  269. package/dist/controls/index.js +4 -0
  270. package/dist/controls/multi-select-filter.d.ts +18 -2
  271. package/dist/controls/multi-select-filter.d.ts.map +1 -1
  272. package/dist/controls/multi-select-filter.js +122 -20
  273. package/dist/controls/number-filter.d.ts +7 -5
  274. package/dist/controls/number-filter.d.ts.map +1 -1
  275. package/dist/controls/number-filter.js +40 -11
  276. package/dist/controls/panel-row.d.ts +132 -0
  277. package/dist/controls/panel-row.d.ts.map +1 -0
  278. package/dist/controls/panel-row.js +173 -0
  279. package/dist/controls/panel-row.module.css +86 -0
  280. package/dist/controls/panel-row.module.css.d.ts +13 -0
  281. package/dist/controls/range-filter.d.ts +2 -1
  282. package/dist/controls/range-filter.d.ts.map +1 -1
  283. package/dist/controls/range-filter.js +57 -35
  284. package/dist/controls/search-filter.d.ts +3 -2
  285. package/dist/controls/search-filter.d.ts.map +1 -1
  286. package/dist/controls/search-filter.js +25 -10
  287. package/dist/controls/select-filter.d.ts +2 -1
  288. package/dist/controls/select-filter.d.ts.map +1 -1
  289. package/dist/controls/select-filter.js +4 -0
  290. package/dist/controls/slider-control.d.ts +36 -0
  291. package/dist/controls/slider-control.d.ts.map +1 -0
  292. package/dist/controls/slider-control.js +64 -0
  293. package/dist/controls/sync-button.d.ts +3 -2
  294. package/dist/controls/sync-button.d.ts.map +1 -1
  295. package/dist/controls/sync-button.js +16 -3
  296. package/dist/controls/toggle-filter.d.ts +3 -2
  297. package/dist/controls/toggle-filter.d.ts.map +1 -1
  298. package/dist/controls/toggle-filter.js +28 -11
  299. package/dist/controls/view-tabs.d.ts +3 -2
  300. package/dist/controls/view-tabs.d.ts.map +1 -1
  301. package/dist/controls/view-tabs.js +43 -11
  302. package/dist/controls-dates/date-range-picker.d.ts +1 -1
  303. package/dist/controls-dates/date-range-picker.d.ts.map +1 -1
  304. package/dist/controls-dates/date-range-picker.js +6 -1
  305. package/dist/dashboard/delta-badge.d.ts +18 -6
  306. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  307. package/dist/dashboard/delta-badge.js +33 -1
  308. package/dist/dashboard/empty-state.d.ts +8 -4
  309. package/dist/dashboard/empty-state.d.ts.map +1 -1
  310. package/dist/dashboard/empty-state.js +36 -6
  311. package/dist/dashboard/index.d.ts +9 -5
  312. package/dist/dashboard/index.d.ts.map +1 -1
  313. package/dist/dashboard/index.js +13 -1
  314. package/dist/dashboard/query-state.d.ts +39 -27
  315. package/dist/dashboard/query-state.d.ts.map +1 -1
  316. package/dist/dashboard/query-state.js +109 -58
  317. package/dist/dashboard/section.d.ts +58 -0
  318. package/dist/dashboard/section.d.ts.map +1 -0
  319. package/dist/dashboard/section.js +118 -0
  320. package/dist/{section → dashboard}/section.module.css +11 -0
  321. package/dist/dashboard/settings-section.d.ts +19 -9
  322. package/dist/dashboard/settings-section.d.ts.map +1 -1
  323. package/dist/dashboard/settings-section.js +75 -26
  324. package/dist/dashboard/stat-card.d.ts +44 -38
  325. package/dist/dashboard/stat-card.d.ts.map +1 -1
  326. package/dist/dashboard/stat-card.js +56 -43
  327. package/dist/dashboard/stat-card.module.css +46 -31
  328. package/dist/dashboard/stat-card.module.css.d.ts +1 -0
  329. package/dist/dashboard/stat-group.d.ts +41 -0
  330. package/dist/dashboard/stat-group.d.ts.map +1 -0
  331. package/dist/dashboard/stat-group.js +30 -0
  332. package/dist/dashboard/stat-group.module.css +66 -0
  333. package/dist/dashboard/stat-group.module.css.d.ts +8 -0
  334. package/dist/dashboard/widget-grid.d.ts +27 -0
  335. package/dist/dashboard/widget-grid.d.ts.map +1 -0
  336. package/dist/dashboard/widget-grid.js +41 -0
  337. package/dist/dashboard/widget-grid.module.css +53 -0
  338. package/dist/dashboard/widget-grid.module.css.d.ts +9 -0
  339. package/dist/dashboard/widget-header.d.ts +132 -0
  340. package/dist/dashboard/widget-header.d.ts.map +1 -0
  341. package/dist/{widget-header → dashboard}/widget-header.js +42 -27
  342. package/dist/{widget-header → dashboard}/widget-header.module.css +72 -7
  343. package/dist/data/data-table.d.ts +151 -12
  344. package/dist/data/data-table.d.ts.map +1 -1
  345. package/dist/data/data-table.js +385 -139
  346. package/dist/data/data-table.module.css +64 -0
  347. package/dist/data/data-table.module.css.d.ts +11 -0
  348. package/dist/data/query-branch.d.ts +28 -0
  349. package/dist/data/query-branch.d.ts.map +1 -0
  350. package/dist/data/query-branch.js +10 -0
  351. package/dist/data/table.d.ts +2 -2
  352. package/dist/data/table.d.ts.map +1 -1
  353. package/dist/data/table.js +2 -16
  354. package/dist/data/virtual-list.d.ts +65 -6
  355. package/dist/data/virtual-list.d.ts.map +1 -1
  356. package/dist/data/virtual-list.js +89 -38
  357. package/dist/data/virtual.d.ts +4 -4
  358. package/dist/data/virtual.d.ts.map +1 -1
  359. package/dist/dev-dock.d.ts +28 -0
  360. package/dist/dev-dock.d.ts.map +1 -0
  361. package/dist/dev-dock.js +85 -0
  362. package/dist/format/date.d.ts +62 -0
  363. package/dist/format/date.d.ts.map +1 -0
  364. package/dist/format/date.js +106 -0
  365. package/dist/format/index.d.ts +39 -0
  366. package/dist/format/index.d.ts.map +1 -0
  367. package/dist/format/index.js +54 -0
  368. package/dist/format/measure.d.ts +15 -0
  369. package/dist/format/measure.d.ts.map +1 -0
  370. package/dist/format/measure.js +16 -0
  371. package/dist/format/number.d.ts +69 -0
  372. package/dist/format/number.d.ts.map +1 -0
  373. package/dist/format/number.js +62 -0
  374. package/dist/format/shared.d.ts +27 -0
  375. package/dist/format/shared.d.ts.map +1 -0
  376. package/dist/format/shared.js +29 -0
  377. package/dist/forms/FormErrorSummary.d.ts +4 -3
  378. package/dist/forms/FormErrorSummary.d.ts.map +1 -1
  379. package/dist/forms/FormErrorSummary.js +6 -4
  380. package/dist/forms/create-form.d.ts +2 -2
  381. package/dist/forms/field.d.ts +33 -9
  382. package/dist/forms/field.d.ts.map +1 -1
  383. package/dist/forms/field.js +7 -6
  384. package/dist/forms/form-layout.d.ts +142 -0
  385. package/dist/forms/form-layout.d.ts.map +1 -0
  386. package/dist/forms/form-layout.js +110 -0
  387. package/dist/forms/form-layout.module.css +145 -0
  388. package/dist/forms/form-layout.module.css.d.ts +20 -0
  389. package/dist/forms/form-state.d.ts +42 -0
  390. package/dist/forms/form-state.d.ts.map +1 -0
  391. package/dist/forms/form-state.js +26 -0
  392. package/dist/forms/index.d.ts +22 -6
  393. package/dist/forms/index.d.ts.map +1 -1
  394. package/dist/forms/index.js +19 -4
  395. package/dist/forms/use-field-array.d.ts +60 -0
  396. package/dist/forms/use-field-array.d.ts.map +1 -0
  397. package/dist/forms/use-field-array.js +20 -0
  398. package/dist/forms/use-form-draft.d.ts +25 -10
  399. package/dist/forms/use-form-draft.d.ts.map +1 -1
  400. package/dist/forms/use-form-draft.js +55 -18
  401. package/dist/forms/use-form-submit.d.ts +88 -0
  402. package/dist/forms/use-form-submit.d.ts.map +1 -0
  403. package/dist/forms/use-form-submit.js +63 -0
  404. package/dist/guard/index.d.ts +2 -9
  405. package/dist/guard/index.d.ts.map +1 -1
  406. package/dist/guard/index.js +38 -28
  407. package/dist/guard/types.d.ts +8 -10
  408. package/dist/guard/types.d.ts.map +1 -1
  409. package/dist/index.d.ts +21 -14
  410. package/dist/index.d.ts.map +1 -1
  411. package/dist/index.js +40 -6
  412. package/dist/notifications/bell.d.ts +3 -2
  413. package/dist/notifications/bell.d.ts.map +1 -1
  414. package/dist/notifications/bell.js +73 -56
  415. package/dist/notifications/center.d.ts +15 -4
  416. package/dist/notifications/center.d.ts.map +1 -1
  417. package/dist/notifications/center.js +5 -2
  418. package/dist/notifications/define-notifications.d.ts +56 -14
  419. package/dist/notifications/define-notifications.d.ts.map +1 -1
  420. package/dist/notifications/define-notifications.js +2 -1
  421. package/dist/notifications/index.d.ts +11 -7
  422. package/dist/notifications/index.d.ts.map +1 -1
  423. package/dist/notifications/index.js +5 -3
  424. package/dist/notifications/mount-guard.d.ts +8 -0
  425. package/dist/notifications/mount-guard.d.ts.map +1 -0
  426. package/dist/notifications/mount-guard.js +19 -0
  427. package/dist/notifications/notify.d.ts +58 -1
  428. package/dist/notifications/notify.d.ts.map +1 -1
  429. package/dist/notifications/notify.js +46 -2
  430. package/dist/notifications/store.d.ts +7 -0
  431. package/dist/notifications/store.d.ts.map +1 -1
  432. package/dist/provider/connectivity-indicator.d.ts +13 -0
  433. package/dist/provider/connectivity-indicator.d.ts.map +1 -0
  434. package/dist/{connectivity → provider}/connectivity-indicator.js +3 -1
  435. package/dist/{connectivity → provider}/connectivity-provider.d.ts +1 -1
  436. package/dist/provider/connectivity-provider.d.ts.map +1 -0
  437. package/dist/{connectivity → provider}/connectivity-provider.js +4 -1
  438. package/dist/{connectivity/types.d.ts → provider/connectivity-types.d.ts} +3 -3
  439. package/dist/provider/connectivity-types.d.ts.map +1 -0
  440. package/dist/provider/error-fallback.module.css +21 -0
  441. package/dist/provider/error-fallback.module.css.d.ts +9 -0
  442. package/dist/provider/index.d.ts +29 -13
  443. package/dist/provider/index.d.ts.map +1 -1
  444. package/dist/provider/index.js +82 -34
  445. package/dist/provider/lab-theme.d.ts.map +1 -1
  446. package/dist/provider/lab-theme.js +2 -1
  447. package/dist/provider/use-connectivity.d.ts +3 -0
  448. package/dist/provider/use-connectivity.d.ts.map +1 -0
  449. package/dist/query-client.d.ts +63 -0
  450. package/dist/query-client.d.ts.map +1 -0
  451. package/dist/query-client.js +33 -0
  452. package/dist/{query/devtools.d.ts → query-devtools.d.ts} +2 -2
  453. package/dist/query-devtools.d.ts.map +1 -0
  454. package/dist/{query/devtools.js → query-devtools.js} +1 -0
  455. package/dist/register.d.ts +17 -3
  456. package/dist/register.d.ts.map +1 -1
  457. package/dist/router-tanstack/index.d.ts +4 -7
  458. package/dist/router-tanstack/index.d.ts.map +1 -1
  459. package/dist/router-tanstack/index.js +1 -9
  460. package/dist/router-tanstack/nav.d.ts +1 -1
  461. package/dist/router-tanstack/nav.d.ts.map +1 -1
  462. package/dist/router-tanstack/search-store.d.ts +3 -6
  463. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  464. package/dist/router-tanstack/search-store.js +1 -1
  465. package/dist/router-tanstack/use-nav.d.ts +1 -1
  466. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  467. package/dist/router-tanstack/use-nav.js +1 -1
  468. package/dist/shell/account-types.d.ts +5 -2
  469. package/dist/shell/account-types.d.ts.map +1 -1
  470. package/dist/shell/app-brand.d.ts +13 -0
  471. package/dist/shell/app-brand.d.ts.map +1 -0
  472. package/dist/shell/app-brand.js +94 -0
  473. package/dist/shell/app-brand.module.css +109 -0
  474. package/dist/shell/app-brand.module.css.d.ts +14 -0
  475. package/dist/shell/app-breadcrumbs.d.ts +5 -3
  476. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  477. package/dist/shell/app-breadcrumbs.js +33 -22
  478. package/dist/shell/app-header.module.css +10 -5
  479. package/dist/shell/app-main.module.css +118 -0
  480. package/dist/shell/app-main.module.css.d.ts +11 -0
  481. package/dist/shell/app-mobile-nav.d.ts +4 -3
  482. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  483. package/dist/shell/app-mobile-nav.js +53 -17
  484. package/dist/shell/app-mobile-nav.module.css +48 -27
  485. package/dist/shell/app-mobile-nav.module.css.d.ts +0 -1
  486. package/dist/shell/app-sidebar-account.d.ts +2 -1
  487. package/dist/shell/app-sidebar-account.d.ts.map +1 -1
  488. package/dist/shell/app-sidebar-account.js +57 -22
  489. package/dist/shell/app-sidebar.d.ts +10 -4
  490. package/dist/shell/app-sidebar.d.ts.map +1 -1
  491. package/dist/shell/app-sidebar.js +78 -103
  492. package/dist/shell/app-sidebar.module.css +63 -96
  493. package/dist/shell/app-sidebar.module.css.d.ts +0 -5
  494. package/dist/shell/index.d.ts +24 -10
  495. package/dist/shell/index.d.ts.map +1 -1
  496. package/dist/shell/index.js +60 -29
  497. package/dist/shell/isomorphic-layout-effect.d.ts +14 -0
  498. package/dist/shell/isomorphic-layout-effect.d.ts.map +1 -0
  499. package/dist/shell/isomorphic-layout-effect.js +5 -0
  500. package/dist/shell/mobile-nav-model.d.ts +1 -1
  501. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  502. package/dist/shell/mobile-nav-model.js +27 -6
  503. package/dist/shell/nav-count-badge.d.ts +5 -3
  504. package/dist/shell/nav-count-badge.d.ts.map +1 -1
  505. package/dist/shell/nav-count-badge.js +3 -1
  506. package/dist/{nav/types.d.ts → shell/nav-types.d.ts} +16 -7
  507. package/dist/shell/nav-types.d.ts.map +1 -0
  508. package/dist/shell/page-aside.d.ts +43 -0
  509. package/dist/shell/page-aside.d.ts.map +1 -0
  510. package/dist/shell/page-aside.js +175 -0
  511. package/dist/shell/page-aside.module.css +174 -0
  512. package/dist/shell/page-aside.module.css.d.ts +14 -0
  513. package/dist/shell/page-bar.d.ts +55 -15
  514. package/dist/shell/page-bar.d.ts.map +1 -1
  515. package/dist/shell/page-bar.js +130 -41
  516. package/dist/shell/page-bar.module.css +107 -13
  517. package/dist/shell/page-bar.module.css.d.ts +4 -1
  518. package/dist/shell/page-title.d.ts +14 -0
  519. package/dist/shell/page-title.d.ts.map +1 -0
  520. package/dist/shell/page-title.js +25 -0
  521. package/dist/shell/page-title.module.css +33 -0
  522. package/dist/shell/page-title.module.css.d.ts +11 -0
  523. package/dist/shell/sidebar-block-model.d.ts +1 -1
  524. package/dist/shell/sidebar-block-model.d.ts.map +1 -1
  525. package/dist/shell/sidebar-blocks.d.ts +1 -1
  526. package/dist/shell/sidebar-blocks.d.ts.map +1 -1
  527. package/dist/shell/sidebar-search.d.ts +4 -2
  528. package/dist/shell/sidebar-search.d.ts.map +1 -1
  529. package/dist/shell/sidebar-search.js +9 -4
  530. package/dist/shell/sidebar-search.module.css +1 -1
  531. package/dist/shell/use-breakpoint.d.ts +11 -0
  532. package/dist/shell/use-breakpoint.d.ts.map +1 -0
  533. package/dist/shell/use-breakpoint.js +33 -0
  534. package/dist/state/fields.d.ts +26 -22
  535. package/dist/state/fields.d.ts.map +1 -1
  536. package/dist/state/fields.js +19 -18
  537. package/dist/state/persisted-or-local.d.ts +9 -0
  538. package/dist/state/persisted-or-local.d.ts.map +1 -0
  539. package/dist/state/persisted-or-local.js +24 -0
  540. package/dist/state.d.ts +1 -1
  541. package/dist/state.d.ts.map +1 -1
  542. package/dist/state.js +2 -6
  543. package/dist/styles.css +36 -0
  544. package/dist/surfaces.d.ts +25 -47
  545. package/dist/surfaces.d.ts.map +1 -1
  546. package/dist/surfaces.js +72 -55
  547. package/dist/theme/ctl-theme.d.ts.map +1 -1
  548. package/dist/theme/index.d.ts.map +1 -1
  549. package/dist/theme/index.js +102 -17
  550. package/dist/theme/nav-link.module.css +37 -26
  551. package/dist/theme/segmented-control.module.css +22 -0
  552. package/dist/theme/segmented-control.module.css.d.ts +1 -0
  553. package/dist/theme/theme-toggle.d.ts +9 -0
  554. package/dist/theme/theme-toggle.d.ts.map +1 -0
  555. package/dist/{theme-toggle/index.js → theme/theme-toggle.js} +9 -3
  556. package/dist/theme-lab/boot.d.ts +66 -0
  557. package/dist/theme-lab/boot.d.ts.map +1 -0
  558. package/dist/theme-lab/boot.js +82 -0
  559. package/dist/theme-lab/index.d.ts +4 -43
  560. package/dist/theme-lab/index.d.ts.map +1 -1
  561. package/dist/theme-lab/index.js +9 -69
  562. package/dist/tokens/index.d.ts +43 -0
  563. package/dist/tokens/index.d.ts.map +1 -1
  564. package/dist/tokens/index.js +45 -6
  565. package/dist/tokens/palette.d.ts +57 -25
  566. package/dist/tokens/palette.d.ts.map +1 -1
  567. package/dist/tokens/palette.js +44 -15
  568. package/dist/tokens.css +5 -5
  569. package/llms.txt +11 -26
  570. package/package.json +54 -34
  571. package/src/agent/ai-sdk-transport.ts +11 -4
  572. package/src/agent/coalesce.ts +2 -1
  573. package/src/agent/merge.ts +2 -1
  574. package/src/agent/part-list.tsx +2 -0
  575. package/src/agent/stick-to-bottom.tsx +9 -11
  576. package/src/agent/transport.ts +15 -2
  577. package/src/agent/use-agent-thread-runs.ts +65 -15
  578. package/src/agent-chat/composer.tsx +124 -103
  579. package/src/agent-chat/index.ts +6 -4
  580. package/src/agent-chat/relative-time.ts +2 -1
  581. package/src/agent-chat/thread-detail-panel.tsx +10 -6
  582. package/src/agent-chat/thread-feed-row.tsx +117 -34
  583. package/src/agent-chat/thread-feed.tsx +59 -27
  584. package/src/agent-chat/thread-message.tsx +48 -13
  585. package/src/agent-chat/thread-outcome-card.tsx +9 -1
  586. package/src/agent-chat/thread-workspace.tsx +72 -33
  587. package/src/agent-chat/tool-chip.tsx +8 -2
  588. package/src/agent-chat/virtualize.ts +48 -0
  589. package/src/charts/hooks/useBandPlot.ts +26 -8
  590. package/src/charts/index.ts +46 -14
  591. package/src/charts/kinds/BandStrip.tsx +36 -14
  592. package/src/charts/kinds/Bars.tsx +68 -20
  593. package/src/charts/kinds/Donut.tsx +30 -3
  594. package/src/charts/kinds/DualPanel.tsx +60 -13
  595. package/src/charts/kinds/Heatmap.tsx +90 -33
  596. package/src/charts/kinds/MirroredBars.tsx +21 -3
  597. package/src/charts/kinds/MultiLine.tsx +61 -18
  598. package/src/charts/kinds/StackedArea.tsx +77 -6
  599. package/src/charts/kinds/ZonedLine.tsx +72 -35
  600. package/src/charts/layout/auto-margin.ts +134 -21
  601. package/src/charts/layout/log-ticks.ts +90 -0
  602. package/src/charts/primitives/Axes.tsx +82 -5
  603. package/src/charts/primitives/CartesianChart.tsx +194 -41
  604. package/src/charts/primitives/ChartCard.tsx +111 -27
  605. package/src/charts/primitives/ChartFrame.tsx +111 -26
  606. package/src/charts/primitives/ChartLegend.tsx +43 -13
  607. package/src/charts/primitives/ChartPending.tsx +194 -10
  608. package/src/charts/primitives/ChartTooltip.tsx +37 -5
  609. package/src/charts/primitives/Crosshair.tsx +6 -3
  610. package/src/charts/primitives/chart-frame-layout.ts +212 -0
  611. package/src/charts/primitives/chart-tier.tsx +37 -0
  612. package/src/charts/series.ts +77 -1
  613. package/src/charts/sparklines/BarSparkline.tsx +11 -3
  614. package/src/charts/sparklines/LineSparkline.tsx +18 -4
  615. package/src/charts/sparklines/index.ts +2 -2
  616. package/src/charts/utils/format.ts +19 -27
  617. package/src/charts/utils/ticks.ts +149 -4
  618. package/src/cli/audit-allows.ts +371 -0
  619. package/src/cli/check-theme.ts +197 -0
  620. package/src/cli/doctor.ts +331 -0
  621. package/src/cli/guard-hook.ts +115 -0
  622. package/src/cli/help.ts +101 -0
  623. package/src/cli/index.ts +269 -3434
  624. package/src/cli/init.ts +247 -0
  625. package/src/cli/sync.ts +324 -0
  626. package/src/cli/tokens-css.ts +252 -0
  627. package/src/commands/ShortcutsHelp.tsx +10 -3
  628. package/src/commands/define-commands.ts +2 -2
  629. package/src/commands/define-overlays.ts +216 -1
  630. package/src/commands/index.ts +22 -1
  631. package/src/commands/overlays-mount.tsx +63 -31
  632. package/src/commands/shell-bridge.ts +86 -0
  633. package/src/common/errors.ts +143 -0
  634. package/src/common/index.ts +23 -0
  635. package/src/common/is-dev.ts +4 -0
  636. package/src/common/props.ts +71 -0
  637. package/src/common/query-state-like.ts +64 -0
  638. package/src/common/refs.ts +58 -0
  639. package/src/common/scroll-parent.ts +42 -0
  640. package/src/common/validate.ts +94 -0
  641. package/src/content/article-layout.module.css +11 -4
  642. package/src/content/callout.tsx +13 -7
  643. package/src/content/guide.tsx +14 -4
  644. package/src/content/heading-anchor.tsx +14 -3
  645. package/src/content/index.ts +2 -0
  646. package/src/content/markdown.tsx +6 -8
  647. package/src/content/prose.module.css +9 -7
  648. package/src/content/reading-progress.tsx +40 -15
  649. package/src/content/slug.ts +4 -0
  650. package/src/content/toc.tsx +47 -10
  651. package/src/controls/actions.tsx +90 -10
  652. package/src/controls/compare-filter.tsx +20 -3
  653. package/src/controls/control-group.tsx +11 -4
  654. package/src/controls/controls.module.css +169 -186
  655. package/src/controls/controls.module.css.d.ts +9 -11
  656. package/src/controls/enum-filter.tsx +32 -16
  657. package/src/controls/filter-context.tsx +14 -6
  658. package/src/controls/filter-pill.tsx +6 -3
  659. package/src/controls/filter-set.tsx +32 -13
  660. package/src/controls/filter-sheet.tsx +44 -194
  661. package/src/controls/index.ts +7 -0
  662. package/src/controls/multi-select-filter.tsx +177 -27
  663. package/src/controls/number-filter.tsx +61 -23
  664. package/src/controls/panel-row.module.css +86 -0
  665. package/src/controls/panel-row.module.css.d.ts +13 -0
  666. package/src/controls/panel-row.tsx +392 -0
  667. package/src/controls/range-filter.tsx +76 -39
  668. package/src/controls/search-filter.tsx +30 -12
  669. package/src/controls/select-filter.tsx +11 -1
  670. package/src/controls/slider-control.tsx +159 -0
  671. package/src/controls/sync-button.tsx +13 -3
  672. package/src/controls/toggle-filter.tsx +36 -16
  673. package/src/controls/view-tabs.tsx +63 -11
  674. package/src/controls-dates/date-range-picker.tsx +8 -1
  675. package/src/dashboard/delta-badge.tsx +75 -4
  676. package/src/dashboard/empty-state.tsx +67 -24
  677. package/src/dashboard/index.ts +24 -3
  678. package/src/dashboard/query-state.tsx +142 -85
  679. package/src/{section → dashboard}/section.module.css +11 -0
  680. package/src/dashboard/section.tsx +287 -0
  681. package/src/dashboard/settings-section.tsx +88 -32
  682. package/src/dashboard/stat-card.module.css +46 -31
  683. package/src/dashboard/stat-card.module.css.d.ts +1 -0
  684. package/src/dashboard/stat-card.tsx +186 -128
  685. package/src/dashboard/stat-group.module.css +66 -0
  686. package/src/dashboard/stat-group.module.css.d.ts +8 -0
  687. package/src/dashboard/stat-group.tsx +74 -0
  688. package/src/dashboard/widget-grid.module.css +53 -0
  689. package/src/dashboard/widget-grid.module.css.d.ts +9 -0
  690. package/src/dashboard/widget-grid.tsx +127 -0
  691. package/src/{widget-header → dashboard}/widget-header.module.css +72 -7
  692. package/src/dashboard/widget-header.tsx +292 -0
  693. package/src/data/data-table.module.css +64 -0
  694. package/src/data/data-table.module.css.d.ts +11 -0
  695. package/src/data/data-table.tsx +921 -348
  696. package/src/data/query-branch.ts +53 -0
  697. package/src/data/table.ts +9 -23
  698. package/src/data/virtual-list.tsx +190 -56
  699. package/src/data/virtual.ts +8 -4
  700. package/src/dev-dock.tsx +159 -0
  701. package/src/format/date.ts +184 -0
  702. package/src/format/index.ts +69 -0
  703. package/src/format/measure.ts +31 -0
  704. package/src/format/number.ts +129 -0
  705. package/src/format/shared.ts +63 -0
  706. package/src/forms/FormErrorSummary.tsx +13 -6
  707. package/src/forms/create-form.ts +2 -2
  708. package/src/forms/field.ts +42 -13
  709. package/src/forms/form-layout.module.css +145 -0
  710. package/src/forms/form-layout.module.css.d.ts +20 -0
  711. package/src/forms/form-layout.tsx +305 -0
  712. package/src/forms/form-state.tsx +94 -0
  713. package/src/forms/index.ts +37 -7
  714. package/src/forms/use-field-array.ts +94 -0
  715. package/src/forms/use-form-draft.ts +145 -53
  716. package/src/forms/use-form-submit.ts +199 -0
  717. package/src/guard/index.ts +99 -70
  718. package/src/guard/types.ts +7 -10
  719. package/src/index.ts +92 -19
  720. package/src/notifications/bell.tsx +18 -4
  721. package/src/notifications/center.tsx +18 -5
  722. package/src/notifications/define-notifications.ts +64 -18
  723. package/src/notifications/index.ts +17 -8
  724. package/src/notifications/mount-guard.ts +39 -0
  725. package/src/notifications/notify.tsx +122 -3
  726. package/src/notifications/store.ts +7 -0
  727. package/src/{connectivity → provider}/connectivity-indicator.tsx +7 -2
  728. package/src/{connectivity → provider}/connectivity-provider.tsx +5 -2
  729. package/src/{connectivity/types.ts → provider/connectivity-types.ts} +5 -2
  730. package/src/provider/error-fallback.module.css +21 -0
  731. package/src/provider/error-fallback.module.css.d.ts +9 -0
  732. package/src/provider/index.tsx +116 -28
  733. package/src/provider/lab-theme.ts +2 -1
  734. package/src/{connectivity → provider}/use-connectivity.ts +4 -3
  735. package/src/query-client.ts +100 -0
  736. package/src/{query/devtools.tsx → query-devtools.tsx} +6 -4
  737. package/src/register.ts +20 -4
  738. package/src/router-tanstack/index.ts +4 -15
  739. package/src/router-tanstack/nav.ts +1 -1
  740. package/src/router-tanstack/search-store.ts +6 -14
  741. package/src/router-tanstack/use-nav.ts +19 -14
  742. package/src/shell/account-types.ts +5 -2
  743. package/src/shell/app-brand.module.css +109 -0
  744. package/src/shell/app-brand.module.css.d.ts +14 -0
  745. package/src/shell/app-brand.tsx +140 -0
  746. package/src/shell/app-breadcrumbs.tsx +20 -10
  747. package/src/shell/app-header.module.css +10 -5
  748. package/src/shell/app-main.module.css +118 -0
  749. package/src/shell/app-main.module.css.d.ts +11 -0
  750. package/src/shell/app-mobile-nav.module.css +48 -27
  751. package/src/shell/app-mobile-nav.module.css.d.ts +0 -1
  752. package/src/shell/app-mobile-nav.tsx +71 -12
  753. package/src/shell/app-sidebar-account.tsx +29 -6
  754. package/src/shell/app-sidebar.module.css +63 -96
  755. package/src/shell/app-sidebar.module.css.d.ts +0 -5
  756. package/src/shell/app-sidebar.tsx +61 -105
  757. package/src/shell/index.tsx +179 -93
  758. package/src/shell/isomorphic-layout-effect.ts +14 -0
  759. package/src/shell/mobile-nav-model.ts +67 -11
  760. package/src/shell/nav-count-badge.tsx +6 -1
  761. package/src/{nav/types.ts → shell/nav-types.ts} +15 -6
  762. package/src/shell/page-aside.module.css +174 -0
  763. package/src/shell/page-aside.module.css.d.ts +14 -0
  764. package/src/shell/page-aside.tsx +327 -0
  765. package/src/shell/page-bar.module.css +107 -13
  766. package/src/shell/page-bar.module.css.d.ts +4 -1
  767. package/src/shell/page-bar.tsx +294 -94
  768. package/src/shell/page-title.module.css +33 -0
  769. package/src/shell/page-title.module.css.d.ts +11 -0
  770. package/src/shell/page-title.tsx +57 -0
  771. package/src/shell/sidebar-block-model.ts +1 -1
  772. package/src/shell/sidebar-blocks.tsx +1 -1
  773. package/src/shell/sidebar-search.module.css +1 -1
  774. package/src/shell/sidebar-search.tsx +13 -4
  775. package/src/shell/use-breakpoint.ts +73 -0
  776. package/src/state/fields.ts +50 -53
  777. package/src/state/persisted-or-local.ts +50 -0
  778. package/src/state.ts +0 -9
  779. package/src/styles.css +36 -0
  780. package/src/surfaces.ts +72 -57
  781. package/src/theme/ctl-theme.tsx +10 -0
  782. package/src/theme/index.ts +136 -16
  783. package/src/theme/nav-link.module.css +37 -26
  784. package/src/theme/segmented-control.module.css +22 -0
  785. package/src/theme/segmented-control.module.css.d.ts +1 -0
  786. package/src/{theme-toggle/index.tsx → theme/theme-toggle.tsx} +11 -4
  787. package/src/theme-lab/boot.ts +140 -0
  788. package/src/theme-lab/index.tsx +21 -112
  789. package/src/tokens/index.ts +60 -6
  790. package/src/tokens/palette.ts +70 -30
  791. package/dist/agent/adapter.test.d.ts +0 -2
  792. package/dist/agent/adapter.test.d.ts.map +0 -1
  793. package/dist/agent/ai-sdk-transport.test.d.ts +0 -2
  794. package/dist/agent/ai-sdk-transport.test.d.ts.map +0 -1
  795. package/dist/agent/coalesce.test.d.ts +0 -2
  796. package/dist/agent/coalesce.test.d.ts.map +0 -1
  797. package/dist/agent/foreign.test.d.ts +0 -2
  798. package/dist/agent/foreign.test.d.ts.map +0 -1
  799. package/dist/agent/id.test.d.ts +0 -2
  800. package/dist/agent/id.test.d.ts.map +0 -1
  801. package/dist/agent/merge.test.d.ts +0 -2
  802. package/dist/agent/merge.test.d.ts.map +0 -1
  803. package/dist/agent/part-list.test.d.ts +0 -2
  804. package/dist/agent/part-list.test.d.ts.map +0 -1
  805. package/dist/agent/parts.test.d.ts +0 -2
  806. package/dist/agent/parts.test.d.ts.map +0 -1
  807. package/dist/agent/thread.test.d.ts +0 -2
  808. package/dist/agent/thread.test.d.ts.map +0 -1
  809. package/dist/agent/transport.test.d.ts +0 -2
  810. package/dist/agent/transport.test.d.ts.map +0 -1
  811. package/dist/agent/use-agent-stream.test.d.ts +0 -2
  812. package/dist/agent/use-agent-stream.test.d.ts.map +0 -1
  813. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +0 -2
  814. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +0 -1
  815. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +0 -2
  816. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +0 -1
  817. package/dist/agent/use-agent-thread-runs.test.d.ts +0 -2
  818. package/dist/agent/use-agent-thread-runs.test.d.ts.map +0 -1
  819. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +0 -2
  820. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +0 -1
  821. package/dist/agent-chat/composer.test.d.ts +0 -2
  822. package/dist/agent-chat/composer.test.d.ts.map +0 -1
  823. package/dist/agent-chat/relative-time.test.d.ts +0 -2
  824. package/dist/agent-chat/relative-time.test.d.ts.map +0 -1
  825. package/dist/agent-chat/thread-feed-row.test.d.ts +0 -2
  826. package/dist/agent-chat/thread-feed-row.test.d.ts.map +0 -1
  827. package/dist/agent-chat/thread-feed.test.d.ts +0 -2
  828. package/dist/agent-chat/thread-feed.test.d.ts.map +0 -1
  829. package/dist/agent-chat/thread-message.test.d.ts +0 -2
  830. package/dist/agent-chat/thread-message.test.d.ts.map +0 -1
  831. package/dist/agent-chat/thread-workspace.test.d.ts +0 -2
  832. package/dist/agent-chat/thread-workspace.test.d.ts.map +0 -1
  833. package/dist/agent-chat/tool-chip.test.d.ts +0 -2
  834. package/dist/agent-chat/tool-chip.test.d.ts.map +0 -1
  835. package/dist/agent-chat/virtualize.type-guard.test.d.ts +0 -2
  836. package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +0 -1
  837. package/dist/charts/cursor/resolve.test.d.ts +0 -2
  838. package/dist/charts/cursor/resolve.test.d.ts.map +0 -1
  839. package/dist/charts/cursor/store.test.d.ts +0 -2
  840. package/dist/charts/cursor/store.test.d.ts.map +0 -1
  841. package/dist/charts/kinds/BandStrip.test.d.ts +0 -2
  842. package/dist/charts/kinds/BandStrip.test.d.ts.map +0 -1
  843. package/dist/charts/kinds/Bars.test.d.ts +0 -2
  844. package/dist/charts/kinds/Bars.test.d.ts.map +0 -1
  845. package/dist/charts/kinds/DualPanel.test.d.ts +0 -2
  846. package/dist/charts/kinds/DualPanel.test.d.ts.map +0 -1
  847. package/dist/charts/kinds/MirroredBars.test.d.ts +0 -2
  848. package/dist/charts/kinds/MirroredBars.test.d.ts.map +0 -1
  849. package/dist/charts/kinds/MultiLine.test.d.ts +0 -2
  850. package/dist/charts/kinds/MultiLine.test.d.ts.map +0 -1
  851. package/dist/charts/kinds/StackedArea.test.d.ts +0 -2
  852. package/dist/charts/kinds/StackedArea.test.d.ts.map +0 -1
  853. package/dist/charts/kinds/ZonedLine.test.d.ts +0 -2
  854. package/dist/charts/kinds/ZonedLine.test.d.ts.map +0 -1
  855. package/dist/charts/layout/auto-margin.test.d.ts +0 -2
  856. package/dist/charts/layout/auto-margin.test.d.ts.map +0 -1
  857. package/dist/charts/primitives/CartesianChart.test.d.ts +0 -2
  858. package/dist/charts/primitives/CartesianChart.test.d.ts.map +0 -1
  859. package/dist/charts/primitives/ChartCard.test.d.ts +0 -2
  860. package/dist/charts/primitives/ChartCard.test.d.ts.map +0 -1
  861. package/dist/charts/primitives/ChartFrame.test.d.ts +0 -2
  862. package/dist/charts/primitives/ChartFrame.test.d.ts.map +0 -1
  863. package/dist/charts/primitives/ChartLegend.test.d.ts +0 -2
  864. package/dist/charts/primitives/ChartLegend.test.d.ts.map +0 -1
  865. package/dist/charts/primitives/ChartTooltip.test.d.ts +0 -2
  866. package/dist/charts/primitives/ChartTooltip.test.d.ts.map +0 -1
  867. package/dist/charts/primitives/XZoneRects.test.d.ts +0 -2
  868. package/dist/charts/primitives/XZoneRects.test.d.ts.map +0 -1
  869. package/dist/charts/series.test.d.ts +0 -2
  870. package/dist/charts/series.test.d.ts.map +0 -1
  871. package/dist/charts/utils/domain.test.d.ts +0 -2
  872. package/dist/charts/utils/domain.test.d.ts.map +0 -1
  873. package/dist/charts/utils/measure-text.test.d.ts +0 -2
  874. package/dist/charts/utils/measure-text.test.d.ts.map +0 -1
  875. package/dist/cli/check-coverage.test.d.ts +0 -2
  876. package/dist/cli/check-coverage.test.d.ts.map +0 -1
  877. package/dist/cli/check-theme.test.d.ts +0 -2
  878. package/dist/cli/check-theme.test.d.ts.map +0 -1
  879. package/dist/cli/help.test.d.ts +0 -2
  880. package/dist/cli/help.test.d.ts.map +0 -1
  881. package/dist/cli/init-sync.test.d.ts +0 -2
  882. package/dist/cli/init-sync.test.d.ts.map +0 -1
  883. package/dist/cli/placement-engine.test.d.ts +0 -2
  884. package/dist/cli/placement-engine.test.d.ts.map +0 -1
  885. package/dist/cli/shipped-versions.test.d.ts +0 -2
  886. package/dist/cli/shipped-versions.test.d.ts.map +0 -1
  887. package/dist/cli/tarball-parity.test.d.ts +0 -2
  888. package/dist/cli/tarball-parity.test.d.ts.map +0 -1
  889. package/dist/cli/template-format.test.d.ts +0 -2
  890. package/dist/cli/template-format.test.d.ts.map +0 -1
  891. package/dist/cli/template-ledger.test.d.ts +0 -2
  892. package/dist/cli/template-ledger.test.d.ts.map +0 -1
  893. package/dist/cli/tokens-css.test.d.ts +0 -2
  894. package/dist/cli/tokens-css.test.d.ts.map +0 -1
  895. package/dist/cli/toolchain-wiring.test.d.ts +0 -2
  896. package/dist/cli/toolchain-wiring.test.d.ts.map +0 -1
  897. package/dist/cli/waiver-audit.test.d.ts +0 -2
  898. package/dist/cli/waiver-audit.test.d.ts.map +0 -1
  899. package/dist/connectivity/connectivity-indicator.d.ts +0 -11
  900. package/dist/connectivity/connectivity-indicator.d.ts.map +0 -1
  901. package/dist/connectivity/connectivity-provider.d.ts.map +0 -1
  902. package/dist/connectivity/index.d.ts +0 -13
  903. package/dist/connectivity/index.d.ts.map +0 -1
  904. package/dist/connectivity/index.js +0 -8
  905. package/dist/connectivity/types.d.ts.map +0 -1
  906. package/dist/connectivity/use-connectivity.d.ts +0 -3
  907. package/dist/connectivity/use-connectivity.d.ts.map +0 -1
  908. package/dist/content/article-actions.test.d.ts +0 -2
  909. package/dist/content/article-actions.test.d.ts.map +0 -1
  910. package/dist/content/article-model.test.d.ts +0 -2
  911. package/dist/content/article-model.test.d.ts.map +0 -1
  912. package/dist/content/block-split.test.d.ts +0 -2
  913. package/dist/content/block-split.test.d.ts.map +0 -1
  914. package/dist/content/heading-components.test.d.ts +0 -2
  915. package/dist/content/heading-components.test.d.ts.map +0 -1
  916. package/dist/content/markdown.test.d.ts +0 -2
  917. package/dist/content/markdown.test.d.ts.map +0 -1
  918. package/dist/content/prose.module.css.test.d.ts +0 -2
  919. package/dist/content/prose.module.css.test.d.ts.map +0 -1
  920. package/dist/content/sanitize.test.d.ts +0 -2
  921. package/dist/content/sanitize.test.d.ts.map +0 -1
  922. package/dist/content/slug.test.d.ts +0 -2
  923. package/dist/content/slug.test.d.ts.map +0 -1
  924. package/dist/controls/actions.test.d.ts +0 -2
  925. package/dist/controls/actions.test.d.ts.map +0 -1
  926. package/dist/controls/actions.type-guard.test.d.ts +0 -2
  927. package/dist/controls/actions.type-guard.test.d.ts.map +0 -1
  928. package/dist/controls/control-group.test.d.ts +0 -2
  929. package/dist/controls/control-group.test.d.ts.map +0 -1
  930. package/dist/controls/controls.router.test.d.ts +0 -2
  931. package/dist/controls/controls.router.test.d.ts.map +0 -1
  932. package/dist/controls/filter-set.test.d.ts +0 -2
  933. package/dist/controls/filter-set.test.d.ts.map +0 -1
  934. package/dist/controls/peer-boundary.test.d.ts +0 -2
  935. package/dist/controls/peer-boundary.test.d.ts.map +0 -1
  936. package/dist/controls/props-contract.test.d.ts +0 -2
  937. package/dist/controls/props-contract.test.d.ts.map +0 -1
  938. package/dist/controls/range-filter.type-guard.test.d.ts +0 -2
  939. package/dist/controls/range-filter.type-guard.test.d.ts.map +0 -1
  940. package/dist/controls/sync-button.test.d.ts +0 -2
  941. package/dist/controls/sync-button.test.d.ts.map +0 -1
  942. package/dist/dashboard/query-state.test.d.ts +0 -2
  943. package/dist/dashboard/query-state.test.d.ts.map +0 -1
  944. package/dist/dashboard/settings-section.test.d.ts +0 -2
  945. package/dist/dashboard/settings-section.test.d.ts.map +0 -1
  946. package/dist/dashboard/stat-card.test.d.ts +0 -2
  947. package/dist/dashboard/stat-card.test.d.ts.map +0 -1
  948. package/dist/data/data-table.test.d.ts +0 -2
  949. package/dist/data/data-table.test.d.ts.map +0 -1
  950. package/dist/data/index.d.ts +0 -38
  951. package/dist/data/index.d.ts.map +0 -1
  952. package/dist/data/index.js +0 -2
  953. package/dist/guard/check-source.test.d.ts +0 -2
  954. package/dist/guard/check-source.test.d.ts.map +0 -1
  955. package/dist/guard/grace.test.d.ts +0 -20
  956. package/dist/guard/grace.test.d.ts.map +0 -1
  957. package/dist/guard/guard-hook.test.d.ts +0 -2
  958. package/dist/guard/guard-hook.test.d.ts.map +0 -1
  959. package/dist/motion/index.d.ts.map +0 -1
  960. package/dist/nav/types.d.ts.map +0 -1
  961. package/dist/notifications/overlay.d.ts +0 -20
  962. package/dist/notifications/overlay.d.ts.map +0 -1
  963. package/dist/notifications/overlay.js +0 -12
  964. package/dist/provider/index.test.d.ts +0 -2
  965. package/dist/provider/index.test.d.ts.map +0 -1
  966. package/dist/provider/lab-theme.test.d.ts +0 -2
  967. package/dist/provider/lab-theme.test.d.ts.map +0 -1
  968. package/dist/query/devtools.d.ts.map +0 -1
  969. package/dist/query/error-message.d.ts +0 -41
  970. package/dist/query/error-message.d.ts.map +0 -1
  971. package/dist/query/error-message.js +0 -30
  972. package/dist/query/error-message.test.d.ts +0 -2
  973. package/dist/query/error-message.test.d.ts.map +0 -1
  974. package/dist/query/index.d.ts +0 -47
  975. package/dist/query/index.d.ts.map +0 -1
  976. package/dist/query/index.js +0 -56
  977. package/dist/router-tanstack/field.d.ts +0 -14
  978. package/dist/router-tanstack/field.d.ts.map +0 -1
  979. package/dist/router-tanstack/field.js +0 -4
  980. package/dist/router-tanstack/multi-search-param-store.d.ts +0 -43
  981. package/dist/router-tanstack/multi-search-param-store.d.ts.map +0 -1
  982. package/dist/router-tanstack/multi-search-param-store.js +0 -30
  983. package/dist/router-tanstack/multi-search-param-store.test.d.ts +0 -2
  984. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +0 -1
  985. package/dist/router-tanstack/nav.test.d.ts +0 -2
  986. package/dist/router-tanstack/nav.test.d.ts.map +0 -1
  987. package/dist/router-tanstack/search-param-store.d.ts +0 -42
  988. package/dist/router-tanstack/search-param-store.d.ts.map +0 -1
  989. package/dist/router-tanstack/search-param-store.js +0 -25
  990. package/dist/router-tanstack/search-param-store.test.d.ts +0 -2
  991. package/dist/router-tanstack/search-param-store.test.d.ts.map +0 -1
  992. package/dist/router-tanstack/search-store.router.test.d.ts +0 -2
  993. package/dist/router-tanstack/search-store.router.test.d.ts.map +0 -1
  994. package/dist/router-tanstack/search-store.test.d.ts +0 -2
  995. package/dist/router-tanstack/search-store.test.d.ts.map +0 -1
  996. package/dist/router-tanstack/use-nav.test.d.ts +0 -2
  997. package/dist/router-tanstack/use-nav.test.d.ts.map +0 -1
  998. package/dist/section/index.d.ts +0 -2
  999. package/dist/section/index.d.ts.map +0 -1
  1000. package/dist/section/index.js +0 -4
  1001. package/dist/section/section.d.ts +0 -24
  1002. package/dist/section/section.d.ts.map +0 -1
  1003. package/dist/section/section.js +0 -126
  1004. package/dist/section/section.test.d.ts +0 -2
  1005. package/dist/section/section.test.d.ts.map +0 -1
  1006. package/dist/shell/app-mobile-nav.test.d.ts +0 -2
  1007. package/dist/shell/app-mobile-nav.test.d.ts.map +0 -1
  1008. package/dist/shell/app-sidebar.test.d.ts +0 -2
  1009. package/dist/shell/app-sidebar.test.d.ts.map +0 -1
  1010. package/dist/shell/index.test.d.ts +0 -2
  1011. package/dist/shell/index.test.d.ts.map +0 -1
  1012. package/dist/shell/mobile-nav-model.test.d.ts +0 -2
  1013. package/dist/shell/mobile-nav-model.test.d.ts.map +0 -1
  1014. package/dist/shell/page-bar.test.d.ts +0 -2
  1015. package/dist/shell/page-bar.test.d.ts.map +0 -1
  1016. package/dist/state/fields.test.d.ts +0 -2
  1017. package/dist/state/fields.test.d.ts.map +0 -1
  1018. package/dist/state/persisted.test.d.ts +0 -2
  1019. package/dist/state/persisted.test.d.ts.map +0 -1
  1020. package/dist/styles.floor.test.d.ts +0 -2
  1021. package/dist/styles.floor.test.d.ts.map +0 -1
  1022. package/dist/theme/border-coverage.test.d.ts +0 -18
  1023. package/dist/theme/border-coverage.test.d.ts.map +0 -1
  1024. package/dist/theme/contrast.test.d.ts +0 -2
  1025. package/dist/theme/contrast.test.d.ts.map +0 -1
  1026. package/dist/theme/ctl-theme.test.d.ts +0 -2
  1027. package/dist/theme/ctl-theme.test.d.ts.map +0 -1
  1028. package/dist/theme/ctl-tier-coverage.test.d.ts +0 -2
  1029. package/dist/theme/ctl-tier-coverage.test.d.ts.map +0 -1
  1030. package/dist/theme/density-option.test.d.ts +0 -2
  1031. package/dist/theme/density-option.test.d.ts.map +0 -1
  1032. package/dist/theme/density-relations.test.d.ts +0 -2
  1033. package/dist/theme/density-relations.test.d.ts.map +0 -1
  1034. package/dist/theme/derive-theme.test.d.ts +0 -2
  1035. package/dist/theme/derive-theme.test.d.ts.map +0 -1
  1036. package/dist/theme/divider-law.test.d.ts +0 -2
  1037. package/dist/theme/divider-law.test.d.ts.map +0 -1
  1038. package/dist/theme/fonts-option.test.d.ts +0 -2
  1039. package/dist/theme/fonts-option.test.d.ts.map +0 -1
  1040. package/dist/theme/icon-slot.test.d.ts +0 -2
  1041. package/dist/theme/icon-slot.test.d.ts.map +0 -1
  1042. package/dist/theme/layout-rhythm-css.test.d.ts +0 -2
  1043. package/dist/theme/layout-rhythm-css.test.d.ts.map +0 -1
  1044. package/dist/theme/radius-option.test.d.ts +0 -2
  1045. package/dist/theme/radius-option.test.d.ts.map +0 -1
  1046. package/dist/theme/radius.test.d.ts +0 -2
  1047. package/dist/theme/radius.test.d.ts.map +0 -1
  1048. package/dist/theme/shadow-surfaces.test.d.ts +0 -26
  1049. package/dist/theme/shadow-surfaces.test.d.ts.map +0 -1
  1050. package/dist/theme/spacing.test.d.ts +0 -2
  1051. package/dist/theme/spacing.test.d.ts.map +0 -1
  1052. package/dist/theme/use-basalt-spacing.test.d.ts +0 -2
  1053. package/dist/theme/use-basalt-spacing.test.d.ts.map +0 -1
  1054. package/dist/theme-lab/derive-controls.test.d.ts +0 -2
  1055. package/dist/theme-lab/derive-controls.test.d.ts.map +0 -1
  1056. package/dist/theme-lab/derive-state.test.d.ts +0 -2
  1057. package/dist/theme-lab/derive-state.test.d.ts.map +0 -1
  1058. package/dist/theme-lab/theme-lab.test.d.ts +0 -2
  1059. package/dist/theme-lab/theme-lab.test.d.ts.map +0 -1
  1060. package/dist/theme-toggle/index.d.ts +0 -8
  1061. package/dist/theme-toggle/index.d.ts.map +0 -1
  1062. package/dist/tokens/build-fonts-css.test.d.ts +0 -2
  1063. package/dist/tokens/build-fonts-css.test.d.ts.map +0 -1
  1064. package/dist/tokens/build-radius-css.test.d.ts +0 -2
  1065. package/dist/tokens/build-radius-css.test.d.ts.map +0 -1
  1066. package/dist/tokens/chart-margin.test.d.ts +0 -2
  1067. package/dist/tokens/chart-margin.test.d.ts.map +0 -1
  1068. package/dist/tokens/density.test.d.ts +0 -2
  1069. package/dist/tokens/density.test.d.ts.map +0 -1
  1070. package/dist/tokens/derive-radius.test.d.ts +0 -2
  1071. package/dist/tokens/derive-radius.test.d.ts.map +0 -1
  1072. package/dist/tokens/derive.test.d.ts +0 -2
  1073. package/dist/tokens/derive.test.d.ts.map +0 -1
  1074. package/dist/tokens/palette.test.d.ts +0 -2
  1075. package/dist/tokens/palette.test.d.ts.map +0 -1
  1076. package/dist/tokens/text-scale.test.d.ts +0 -2
  1077. package/dist/tokens/text-scale.test.d.ts.map +0 -1
  1078. package/dist/vite-color-mix.test.d.ts +0 -2
  1079. package/dist/vite-color-mix.test.d.ts.map +0 -1
  1080. package/dist/vite.test.d.ts +0 -2
  1081. package/dist/vite.test.d.ts.map +0 -1
  1082. package/dist/widget-header/delta-badge.d.ts +0 -13
  1083. package/dist/widget-header/delta-badge.d.ts.map +0 -1
  1084. package/dist/widget-header/delta-badge.js +0 -29
  1085. package/dist/widget-header/delta-badge.test.d.ts +0 -2
  1086. package/dist/widget-header/delta-badge.test.d.ts.map +0 -1
  1087. package/dist/widget-header/index.d.ts +0 -3
  1088. package/dist/widget-header/index.d.ts.map +0 -1
  1089. package/dist/widget-header/index.js +0 -6
  1090. package/dist/widget-header/no-mantine.test.d.ts +0 -2
  1091. package/dist/widget-header/no-mantine.test.d.ts.map +0 -1
  1092. package/dist/widget-header/widget-header.d.ts +0 -67
  1093. package/dist/widget-header/widget-header.d.ts.map +0 -1
  1094. package/dist/widget-header/widget-header.test.d.ts +0 -2
  1095. package/dist/widget-header/widget-header.test.d.ts.map +0 -1
  1096. package/src/agent/adapter.test.ts +0 -1640
  1097. package/src/agent/ai-sdk-transport.test.ts +0 -658
  1098. package/src/agent/coalesce.test.ts +0 -387
  1099. package/src/agent/foreign.test.ts +0 -51
  1100. package/src/agent/id.test.ts +0 -169
  1101. package/src/agent/merge.test.ts +0 -206
  1102. package/src/agent/part-list.test.tsx +0 -112
  1103. package/src/agent/parts.test.ts +0 -720
  1104. package/src/agent/thread.test.ts +0 -220
  1105. package/src/agent/transport.test.ts +0 -70
  1106. package/src/agent/use-agent-stream.test.tsx +0 -287
  1107. package/src/agent/use-agent-thread-runs.foreign.test.tsx +0 -138
  1108. package/src/agent/use-agent-thread-runs.resume.test.tsx +0 -294
  1109. package/src/agent/use-agent-thread-runs.test.tsx +0 -1120
  1110. package/src/agent/use-agent-thread-runs.wedge.test.tsx +0 -387
  1111. package/src/agent-chat/composer.test.tsx +0 -924
  1112. package/src/agent-chat/relative-time.test.ts +0 -95
  1113. package/src/agent-chat/thread-feed-row.test.tsx +0 -510
  1114. package/src/agent-chat/thread-feed.test.tsx +0 -241
  1115. package/src/agent-chat/thread-message.test.tsx +0 -1285
  1116. package/src/agent-chat/thread-workspace.test.tsx +0 -169
  1117. package/src/agent-chat/tool-chip.test.tsx +0 -139
  1118. package/src/agent-chat/virtualize.type-guard.test.ts +0 -49
  1119. package/src/charts/cursor/resolve.test.ts +0 -151
  1120. package/src/charts/cursor/store.test.ts +0 -64
  1121. package/src/charts/kinds/BandStrip.test.tsx +0 -282
  1122. package/src/charts/kinds/Bars.test.tsx +0 -183
  1123. package/src/charts/kinds/DualPanel.test.tsx +0 -527
  1124. package/src/charts/kinds/MirroredBars.test.tsx +0 -232
  1125. package/src/charts/kinds/MultiLine.test.tsx +0 -218
  1126. package/src/charts/kinds/StackedArea.test.tsx +0 -114
  1127. package/src/charts/kinds/ZonedLine.test.tsx +0 -147
  1128. package/src/charts/layout/auto-margin.test.ts +0 -42
  1129. package/src/charts/primitives/CartesianChart.test.tsx +0 -647
  1130. package/src/charts/primitives/ChartCard.test.tsx +0 -66
  1131. package/src/charts/primitives/ChartFrame.test.tsx +0 -142
  1132. package/src/charts/primitives/ChartLegend.test.tsx +0 -78
  1133. package/src/charts/primitives/ChartTooltip.test.tsx +0 -44
  1134. package/src/charts/primitives/XZoneRects.test.tsx +0 -123
  1135. package/src/charts/series.test.ts +0 -112
  1136. package/src/charts/utils/domain.test.ts +0 -54
  1137. package/src/charts/utils/measure-text.test.ts +0 -36
  1138. package/src/cli/check-coverage.test.ts +0 -297
  1139. package/src/cli/check-theme.test.ts +0 -837
  1140. package/src/cli/help.test.ts +0 -170
  1141. package/src/cli/init-sync.test.ts +0 -343
  1142. package/src/cli/placement-engine.test.ts +0 -298
  1143. package/src/cli/shipped-versions.test.ts +0 -83
  1144. package/src/cli/tarball-parity.test.ts +0 -44
  1145. package/src/cli/template-format.test.ts +0 -64
  1146. package/src/cli/template-ledger.test.ts +0 -219
  1147. package/src/cli/tokens-css.test.ts +0 -249
  1148. package/src/cli/toolchain-wiring.test.ts +0 -1358
  1149. package/src/cli/waiver-audit.test.ts +0 -215
  1150. package/src/connectivity/index.ts +0 -18
  1151. package/src/content/article-actions.test.ts +0 -78
  1152. package/src/content/article-model.test.ts +0 -119
  1153. package/src/content/block-split.test.ts +0 -75
  1154. package/src/content/heading-components.test.tsx +0 -65
  1155. package/src/content/markdown.test.tsx +0 -603
  1156. package/src/content/prose.module.css.test.ts +0 -39
  1157. package/src/content/sanitize.test.ts +0 -659
  1158. package/src/content/slug.test.ts +0 -62
  1159. package/src/controls/actions.test.tsx +0 -252
  1160. package/src/controls/actions.type-guard.test.ts +0 -63
  1161. package/src/controls/control-group.test.tsx +0 -229
  1162. package/src/controls/controls.router.test.tsx +0 -1053
  1163. package/src/controls/filter-set.test.tsx +0 -472
  1164. package/src/controls/peer-boundary.test.ts +0 -85
  1165. package/src/controls/props-contract.test.tsx +0 -137
  1166. package/src/controls/range-filter.type-guard.test.tsx +0 -122
  1167. package/src/controls/sync-button.test.tsx +0 -183
  1168. package/src/dashboard/query-state.test.tsx +0 -152
  1169. package/src/dashboard/settings-section.test.tsx +0 -56
  1170. package/src/dashboard/stat-card.test.tsx +0 -385
  1171. package/src/data/data-table.test.tsx +0 -434
  1172. package/src/data/index.ts +0 -41
  1173. package/src/guard/check-source.test.ts +0 -3388
  1174. package/src/guard/grace.test.ts +0 -158
  1175. package/src/guard/guard-hook.test.ts +0 -203
  1176. package/src/notifications/overlay.tsx +0 -64
  1177. package/src/provider/index.test.tsx +0 -101
  1178. package/src/provider/lab-theme.test.ts +0 -147
  1179. package/src/query/error-message.test.ts +0 -67
  1180. package/src/query/error-message.ts +0 -70
  1181. package/src/query/index.ts +0 -97
  1182. package/src/router-tanstack/field.ts +0 -35
  1183. package/src/router-tanstack/multi-search-param-store.test.ts +0 -124
  1184. package/src/router-tanstack/multi-search-param-store.ts +0 -106
  1185. package/src/router-tanstack/nav.test.ts +0 -111
  1186. package/src/router-tanstack/search-param-store.test.ts +0 -135
  1187. package/src/router-tanstack/search-param-store.ts +0 -100
  1188. package/src/router-tanstack/search-store.router.test.tsx +0 -882
  1189. package/src/router-tanstack/search-store.test.ts +0 -578
  1190. package/src/router-tanstack/use-nav.test.tsx +0 -187
  1191. package/src/section/index.ts +0 -1
  1192. package/src/section/section.test.tsx +0 -204
  1193. package/src/section/section.tsx +0 -220
  1194. package/src/shell/app-mobile-nav.test.tsx +0 -403
  1195. package/src/shell/app-sidebar.test.tsx +0 -509
  1196. package/src/shell/index.test.tsx +0 -428
  1197. package/src/shell/mobile-nav-model.test.ts +0 -517
  1198. package/src/shell/page-bar.test.tsx +0 -316
  1199. package/src/state/fields.test.tsx +0 -512
  1200. package/src/state/persisted.test.ts +0 -91
  1201. package/src/styles.floor.test.ts +0 -36
  1202. package/src/theme/border-coverage.test.ts +0 -272
  1203. package/src/theme/contrast.test.ts +0 -354
  1204. package/src/theme/ctl-theme.test.tsx +0 -275
  1205. package/src/theme/ctl-tier-coverage.test.ts +0 -170
  1206. package/src/theme/density-option.test.ts +0 -96
  1207. package/src/theme/density-relations.test.ts +0 -506
  1208. package/src/theme/derive-theme.test.ts +0 -101
  1209. package/src/theme/divider-law.test.ts +0 -259
  1210. package/src/theme/fonts-option.test.ts +0 -75
  1211. package/src/theme/icon-slot.test.tsx +0 -275
  1212. package/src/theme/layout-rhythm-css.test.ts +0 -72
  1213. package/src/theme/radius-option.test.ts +0 -86
  1214. package/src/theme/radius.test.ts +0 -142
  1215. package/src/theme/shadow-surfaces.test.ts +0 -488
  1216. package/src/theme/spacing.test.ts +0 -495
  1217. package/src/theme/use-basalt-spacing.test.tsx +0 -55
  1218. package/src/theme-lab/derive-controls.test.ts +0 -94
  1219. package/src/theme-lab/derive-state.test.ts +0 -71
  1220. package/src/theme-lab/theme-lab.test.ts +0 -46
  1221. package/src/tokens/build-fonts-css.test.ts +0 -52
  1222. package/src/tokens/build-radius-css.test.ts +0 -25
  1223. package/src/tokens/chart-margin.test.ts +0 -39
  1224. package/src/tokens/density.test.ts +0 -259
  1225. package/src/tokens/derive-radius.test.ts +0 -62
  1226. package/src/tokens/derive.test.ts +0 -30
  1227. package/src/tokens/palette.test.ts +0 -114
  1228. package/src/tokens/text-scale.test.ts +0 -51
  1229. package/src/vite-color-mix.test.ts +0 -46
  1230. package/src/vite.test.ts +0 -423
  1231. package/src/widget-header/delta-badge.test.tsx +0 -60
  1232. package/src/widget-header/delta-badge.tsx +0 -57
  1233. package/src/widget-header/index.ts +0 -2
  1234. package/src/widget-header/no-mantine.test.ts +0 -33
  1235. package/src/widget-header/widget-header.test.tsx +0 -187
  1236. package/src/widget-header/widget-header.tsx +0 -213
  1237. /package/dist/{motion/index.js → common/motion.js} +0 -0
  1238. /package/dist/{section → dashboard}/section.module.css.d.ts +0 -0
  1239. /package/dist/{widget-header → dashboard}/widget-header.module.css.d.ts +0 -0
  1240. /package/dist/{connectivity/types.js → provider/connectivity-types.js} +0 -0
  1241. /package/dist/{connectivity → provider}/use-connectivity.js +0 -0
  1242. /package/dist/{nav/types.js → shell/nav-types.js} +0 -0
  1243. /package/src/{motion/index.ts → common/motion.ts} +0 -0
  1244. /package/src/{section → dashboard}/section.module.css.d.ts +0 -0
  1245. /package/src/{widget-header → dashboard}/widget-header.module.css.d.ts +0 -0
package/src/cli/index.ts CHANGED
@@ -1,17 +1,10 @@
1
1
  /**
2
- * basalt-ui CLI — `init`, `sync`, `check-theme`, `check-coverage`.
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
- checkSource,
41
- DEFAULT_GUARD_CONFIG,
42
- GENERATED_HEADER_LINE,
43
- findAllowAnnotations,
44
- guardKindRemedy,
45
- GUARD_RULES,
46
- guardWaiverHint,
47
- neutralizeAllowAnnotation,
48
- TOKENS_ONLY_DISABLED_KINDS,
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
- * The shipped lefthook preset and every `bunx basalt-ui …` in the seeded CI run at the REPO ROOT,
514
- * while in a monorepo the `basalt` config and the manifest live in a package below it — verified to
515
- * produce `0 files scanned` and a `manifest missing` failure, i.e. a gate that never gates. Rather
516
- * than make every consumer hand-write a `--cwd`, a command with no basalt project at its own cwd
517
- * relocates to the single workspace package that has one, and says so. Two or more candidates is
518
- * genuinely ambiguous and reported as such; `BASALT_CWD` is the explicit override.
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 a relocation happened. */
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 relocated away from it, else null. */
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
- * The nearest ANCESTOR of `cwd`, within the same repo, that is a basalt project — or null.
559
- *
560
- * The descend direction was handled and announced; ascend was not, and it fabricated instead. From
561
- * `apps/demo` in a two-root repo, `readBasaltConfig` answered `{}`, `resolveRoots` substituted the
562
- * built-in `['src']`, and check-theme scanned 22 of the repo's 44 guarded files, reported
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 ascendantProject(cwd: string): string | null {
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 !== undefined && override.length > 0) {
590
- const dir = isAbsolute(override) ? override : resolve(cwd, override)
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
- if (candidates.length > 1) return { dir: cwd, relocatedFrom: null, ambiguous: candidates }
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` ({@link inferredProfile}) only changes which ADVICE it prints, never what it enforces,
633
- * so it may infer from absence and then tell the consumer to write the key down.
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(peers: PeerFlags, placement: PlacementFlags = ROOT_PLACEMENT): ManagedFile[] {
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
- const DESIGN_VERSION_LINE = /^(> Managed by basalt-ui) \(\d+\.\d+\.\d+\)\./m
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(cwd: string, mutate: (pkg: Record<string, unknown>) => boolean): boolean {
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 escaped = false
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 (escaped) escaped = false
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
- inString = true
2307
- out += ch
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 two `extends` seams init writes once and nothing ever revisits ───────────────────────────
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
- // Both seams therefore need a checker that resolves the path rather than pattern-matching the
2366
- // string, and both `doctor` (the gate) and `sync` (the command an upgrade actually runs) report
2367
- // through it.
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
- * The `extends:` entries of a lefthook YAML — the block-sequence form init seeds and the inline
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 [index, line] of lines.entries()) {
2391
- const inline = /^extends:\s*\[(.*)\]\s*$/.exec(line ?? '')
2392
- if (inline !== null) {
2393
- out.push(...(inline[1] ?? '').split(',').map(yamlUnquote).filter(Boolean))
2394
- continue
2395
- }
2396
- if (!/^extends:\s*(#.*)?$/.test(line ?? '')) continue
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
- /** The state of one shipped-preset `extends` seam in a consumer-owned config. */
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
- /** The file exists but names no basalt preset. */
2412
- | { readonly kind: 'unwired'; readonly file: string }
2413
- /** It names one, and the path resolves to nothing — the silent-gate case. */
2414
- | { readonly kind: 'broken'; readonly file: string; readonly entry: string }
2415
- | { readonly kind: 'ok'; readonly file: string; readonly entry: string }
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
- if (result.error !== undefined || result.status !== 0) return null
2455
- return result.stdout
1691
+ dump =
1692
+ result.error === undefined && result.status === 0
1693
+ ? (JSON.parse(result.stdout) as Record<string, unknown>)
1694
+ : null
2456
1695
  } catch {
2457
- return null
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
- const raw = readIfExists(resolve(repoRoot, seam.file)) ?? ''
2506
- if (LEFTHOOK_GUARD_RUN.test(raw)) return { kind: 'wired', file: seam.file, proof: 'config' }
2507
- return { kind: 'advisory', file: seam.file }
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,2526 +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
- * What a consumer LOSES by `init` keeping a file it already had — the half the aggregate
2542
- * `20 written, 2 kept` never said. A kept `.oxlintrc.json` is the headline case: the framework's
2543
- * whole lint half stays off, and every gate downstream reports green over it.
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
- const KEPT_FILE_COST: Record<string, string> = {
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
- const rootsState = reconcileRoots(cwd, { write: true })
1750
+ type WorkspacePackage = { readonly name: string; readonly dir: string }
2575
1751
 
2576
- // Built AFTER the roots patch — every roots-derived template variable reads the key just written.
2577
- const ctx = renderContext(cwd)
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
- // `basalt-tokens.md` tells the consumer to wire `oxlint . && basalt-ui check-theme` into their
2580
- // lint, and init's own closing message says to run check-theme next — but nothing ever added a
2581
- // script, so the guard stayed manual for exactly as long as someone remembered it.
2582
- const lintScript = `oxlint . && ${ctx.vars.BASALT_BIN} check-theme`
2583
- const seededLintScript = patchPackageJson(cwd, (pkg) => {
2584
- const scripts = (pkg['scripts'] ?? {}) as Record<string, unknown>
2585
- if (scripts['lint:basalt'] !== undefined) return false
2586
- pkg['scripts'] = { ...scripts, 'lint:basalt': lintScript }
2587
- return true
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': 160,
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 and ./configs/* (non-JS assets / raw file paths).
3308
- for (const exportKey of pkgExports) {
3309
- if (exportKey === '.' || exportKey === './styles.css' || exportKey.startsWith('./configs/'))
3310
- continue
3311
- if (!Object.hasOwn(SURFACES, exportKey)) {
3312
- failures.push(`package.json exports key '${exportKey}' has no matching SURFACES entry`)
3313
- }
3314
- }
3315
-
3316
- // ── Assertion 6: every surface with non-empty forbiddenImports has a globs field ──
3317
- for (const [key, spec] of allSpecs) {
3318
- if (spec.forbiddenImports.length === 0) continue
3319
- if (!('globs' in spec) || spec.globs === undefined) {
3320
- failures.push(
3321
- `SURFACES['${key}'] has non-empty forbiddenImports but no globs field (required for oxlint emission)`,
3322
- )
3323
- }
3324
- }
3325
-
3326
- // ── Assertion 7: every headless surface is Mantine-free ──────────────────────
3327
- // Guarantees the Mantine-free boundary — a future headless surface without coverage fails here.
3328
- // Coverage is either all 3 forbiddenImports bans, OR membership in
3329
- // `TOKEN_LAYER_BOUNDARY_SURFACES` (`./charts`/`./tokens`), whose Mantine-free guarantee comes
3330
- // from the `basalt/token-layer-boundary` plugin rule instead — protects both layering (tokens
3331
- // upstream of Mantine) and packaging (those two subpaths resolve with no `@mantine/*` installed,
3332
- // CI-tested via `scripts/pack-test.sh` + `scripts/check-dist-layering.mjs`; see surfaces.ts's
3333
- // `TOKEN_LAYER_BOUNDARY_SURFACES` doc comment). This assertion CANNOT also verify
3334
- // that rule's live registration: `check-coverage` is a shipped CLI subcommand whose assertions
3335
- // must all read pkgRoot-relative paths so it works from inside a consumer's node_modules — and a
3336
- // repo-local `.oxlintrc.json` read from an installed package path-resolves to the CONSUMER's own
3337
- // config, not basalt's. That "fails loudly if enforcement is removed" guarantee lives in
3338
- // basalt's own CI instead (`tests/surfaces-coverage.test.ts`, via
3339
- // `hasTokenLayerBoundaryRegistered` against `.oxlintrc.json`).
3340
- const REQUIRED_MANTINE_BANS = ['@mantine/core', '@mantine/hooks', '@mantine/*'] as const
3341
-
3342
- for (const [key, spec] of allSpecs) {
3343
- if (spec.layer !== 'headless') continue
3344
- if (TOKEN_LAYER_BOUNDARY_SURFACES.has(key)) continue
3345
- for (const required of REQUIRED_MANTINE_BANS) {
3346
- const hasBan = spec.forbiddenImports.some((fi) => fi.spec === required)
3347
- if (!hasBan) {
3348
- failures.push(
3349
- `SURFACES['${key}'] is headless but missing Mantine ban for '${required}' in forbiddenImports`,
3350
- )
3351
- }
3352
- }
3353
- }
3354
-
3355
- // ── Assertion 8: every doctrine optionalPeers entry → peerDependencies + peerDependenciesMeta ──
3356
- // Closes the silent-drop gap: an optionalPeer listed in SURFACES but absent from package.json
3357
- // means the published package never tells npm about the dependency.
3358
- let peerDependencies: Record<string, string> = {}
3359
- let peerDependenciesMeta: Record<string, { optional?: boolean }> = {}
3360
- try {
3361
- const frameworkPkg = JSON.parse(readFileSync(resolve(pkgRoot, 'package.json'), 'utf8')) as {
3362
- peerDependencies?: Record<string, string>
3363
- peerDependenciesMeta?: Record<string, { optional?: boolean }>
3364
- }
3365
- peerDependencies = frameworkPkg.peerDependencies ?? {}
3366
- peerDependenciesMeta = frameworkPkg.peerDependenciesMeta ?? {}
3367
- } catch {
3368
- failures.push(`Cannot read package.json peerDependencies at ${pkgRoot}`)
3369
- }
3370
-
3371
- for (const spec of doctrineSpecs) {
3372
- for (const peer of spec.optionalPeers ?? []) {
3373
- if (!(peer in peerDependencies)) {
3374
- failures.push(
3375
- `SURFACES doctrine optionalPeer '${peer}' is not listed in package.json peerDependencies`,
3376
- )
3377
- }
3378
- if (peerDependenciesMeta[peer]?.optional !== true) {
3379
- failures.push(
3380
- `SURFACES doctrine optionalPeer '${peer}' is not marked optional in package.json peerDependenciesMeta`,
3381
- )
3382
- }
3383
- }
3384
- }
3385
-
3386
- // ── Assertion 9: every plugin rule maps to exactly one surface ───────────────
3387
- // The plugin's own rules were the one half of the enforcement seam SURFACES did not model, so a
3388
- // generated coverage header could name a guard kind and stay silent about the AST rule doing the
3389
- // actual work (D8). Now every id has exactly one home, and "exactly" is both directions: an
3390
- // unmapped id is a rule no doc claims, a doubly-mapped one is coverage counted twice.
3391
- const surfacesPerPluginRule = new Map<string, string[]>()
3392
- for (const [key, spec] of allSpecs) {
3393
- if (spec.kind !== 'doctrine') continue
3394
- for (const id of spec.pluginRules) {
3395
- surfacesPerPluginRule.set(id, [...(surfacesPerPluginRule.get(id) ?? []), key])
3396
- }
3397
- }
3398
- for (const id of PLUGIN_RULE_ID_LIST) {
3399
- const owners = surfacesPerPluginRule.get(id) ?? []
3400
- if (owners.length === 1) continue
3401
- failures.push(
3402
- owners.length === 0
3403
- ? `Plugin rule 'basalt/${id}' maps to no surface — add it to one surface's pluginRules`
3404
- : `Plugin rule 'basalt/${id}' maps to ${owners.length} surfaces (${owners.join(', ')}) — exactly one`,
3405
- )
3406
- }
3407
-
3408
- // ── Assertion 10: the agent layer stays inside its line budgets ─────────────
3409
- // The budgets ARE the doctrine (docs/CONTROLS-SPEC.md §7): thirteen rules reached 4,177 lines by
3410
- // growing a few lines at a time, each addition locally reasonable, and the only thing that
3411
- // notices is a number nobody is checking. A rule over budget is over budget in the commit that
3412
- // did it, not four minors later.
3413
- for (const [rel, budget] of Object.entries(AGENT_LINE_BUDGETS)) {
3414
- const abs = resolve(pkgRoot, rel)
3415
- if (!existsSync(abs)) continue // assertions 2/3 already report a missing rule or skill
3416
- const lines = readFileSync(abs, 'utf8').split('\n').length - 1
3417
- if (lines > budget) failures.push(`${rel}: ${lines} lines exceeds its ${budget}-line budget`)
3418
- }
3419
- const ruleTotal = RULE_NAMES.reduce((sum, rule) => {
3420
- const abs = resolve(pkgRoot, `agent/rules/basalt-${rule}.md`)
3421
- return existsSync(abs) ? sum + readFileSync(abs, 'utf8').split('\n').length - 1 : sum
3422
- }, 0)
3423
- if (ruleTotal > AGENT_RULE_TOTAL_BUDGET) {
3424
- failures.push(
3425
- `agent/rules/*.md: ${ruleTotal} lines total exceeds the ${AGENT_RULE_TOTAL_BUDGET}-line budget`,
3426
- )
3427
- }
3428
-
3429
- // ── Assertion 11: every guard kind maps to at least one surface ──────────────
3430
- // The guard-kind twin of assertion 9. Assertion 1 already checks the other direction (no surface
3431
- // names a kind that does not exist); this one catches the kind nobody claims, whose coverage
3432
- // header therefore under-reports the lane that actually enforces it.
3433
- const surfacedGuardKinds = new Set<string>()
3434
- for (const spec of doctrineSpecs) {
3435
- for (const kind of spec.guardKinds) surfacedGuardKinds.add(kind)
3436
- }
3437
- for (const kind of Object.keys(GUARD_RULES)) {
3438
- if (surfacedGuardKinds.has(kind)) continue
3439
- failures.push(
3440
- `Guard kind '${kind}' maps to no surface — add it to one surface's guardKinds so the ` +
3441
- `generated coverage headers name it`,
3442
- )
3443
- }
3444
-
3445
- // ── Coverage headers (--write / --check) ─────────────────────────────────────
3446
- const write = flags.includes('--write')
3447
- const check = flags.includes('--check')
3448
- if (write && check) {
3449
- failures.push('--write and --check are alternatives; pass one')
3450
- } else if (write || check) {
3451
- const result = reconcileCoverageBlocks(pkgRoot, write ? 'write' : 'check')
3452
- failures.push(...result.failures)
3453
- notes.push(...result.notes)
3454
- }
3455
-
3456
- // ── Report ──────────────────────────────────────────────────────────────────
3457
- for (const note of notes) {
3458
- console.log(` ${note}`)
3459
- }
3460
-
3461
- if (failures.length === 0) {
3462
- console.log('✓ check-coverage: all 11 assertions pass.')
3463
- return 0
3464
- }
3465
-
3466
- console.error(`✖ check-coverage: ${failures.length} failure(s)`)
3467
- for (const f of failures) {
3468
- console.error(` ${f}`)
3469
- }
3470
- return 1
3471
- }
3472
-
3473
- // ──────────────────────────────────────────────────────────────────────────────
3474
- // doctor — consumer repo integration health check
3475
- // ──────────────────────────────────────────────────────────────────────────────
3476
-
3477
- type DoctorResult = {
3478
- /** Number of hard failures (exit non-zero). */
3479
- hardFailures: number
3480
- /** Number of warnings (informational only). */
3481
- warnings: number
3482
- /**
3483
- * Number of checks that could not RUN (as opposed to ran and passed). Counted separately and
3484
- * exits non-zero on its own: under bun's isolated linker `doctor` used to drop two of its five
3485
- * checks and still print "All checks passed", which is the same false-green this whole surface
3486
- * exists to remove. A check that cannot run is not a check that passed.
3487
- */
3488
- skipped: number
3489
- }
3490
-
3491
- /**
3492
- * Which shape of consumer `doctor` is looking at.
3493
- *
3494
- * `framework` — the Mantine app the scaffold is for. `tokens-only` — a consumer that took the
3495
- * `--vx-*` layer and nothing else (rollhook: no Mantine, no manifest, `tokens:css` output). Telling
3496
- * the second to "run `basalt-ui init`" is the wrong instruction: init scaffolds Mantine rules,
3497
- * skills and a DESIGN.md it will never read. Auto-detected, forced by `--framework` / `--tokens-only`.
3498
- */
3499
- type DoctorProfile = 'framework' | 'tokens-only'
3500
-
3501
- type WorkspacePackage = { readonly name: string; readonly dir: string }
3502
-
3503
- /** {@link collectWorkspacePackages}'s return: the walked packages plus whether a `workspaces`
3504
- * field was present at all (distinguishes "nothing to compare" from "not a workspace root"). */
3505
- type WorkspaceWalk = {
3506
- readonly packages: readonly WorkspacePackage[]
3507
- readonly hasWorkspacesField: boolean
3508
- }
3509
-
3510
- /**
3511
- * Every subdirectory of `base` (name only), or `[]` if `base` doesn't exist / isn't a directory.
3512
- * `node_modules` is always excluded — a wildcard workspaces pattern must never descend into a
3513
- * dependency tree. `skipDotDirs` additionally excludes dot-directories; it is only set by the
3514
- * `*`/`**` wildcard descent in {@link expandPatternSegments}, never for an explicitly named
3515
- * literal segment, so a pattern like `.internal/*` still resolves.
3516
- */
3517
- function subdirNames(base: string, options: { skipDotDirs?: boolean } = {}): string[] {
3518
- let entries: string[]
3519
- try {
3520
- entries = readdirSync(base)
3521
- } catch {
3522
- return []
3523
- }
3524
- return entries.filter((name) => {
3525
- if (name === 'node_modules') return false
3526
- if (options.skipDotDirs && name.startsWith('.')) return false
3527
- try {
3528
- return statSync(resolve(base, name)).isDirectory()
3529
- } catch {
3530
- return false
3531
- }
3532
- })
3533
- }
3534
-
3535
- /**
3536
- * Expands one glob PATTERN's segments against the filesystem, starting from `baseDir`. Supports
3537
- * three segment kinds: a literal name, a single `*` wildcard (any one directory), and a `**`
3538
- * wildcard (zero or more directory levels, i.e. arbitrary depth) — covering a trailing single
3539
- * wildcard, an interior wildcard between literal segments, and a trailing double-wildcard alike,
3540
- * not just one trailing single-wildcard segment.
3541
- */
3542
- function expandPatternSegments(baseDir: string, segments: readonly string[]): string[] {
3543
- if (segments.length === 0) return [baseDir]
3544
- const [head, ...rest] = segments
3545
-
3546
- if (head === '**') {
3547
- // Zero levels: `**` consumes nothing and the rest of the pattern matches right here.
3548
- const matches = expandPatternSegments(baseDir, rest)
3549
- // One-or-more levels: descend into every subdirectory, `**` still active.
3550
- for (const name of subdirNames(baseDir, { skipDotDirs: true })) {
3551
- matches.push(...expandPatternSegments(resolve(baseDir, name), segments))
3552
- }
3553
- return matches
3554
- }
3555
-
3556
- const names =
3557
- head === '*'
3558
- ? subdirNames(baseDir, { skipDotDirs: true })
3559
- : subdirNames(baseDir).filter((n) => n === head)
3560
- return names.flatMap((name) => expandPatternSegments(resolve(baseDir, name), rest))
3561
- }
3562
-
3563
- /**
3564
- * Expands a root `package.json`'s `workspaces` patterns into concrete package directories.
3565
- * Supports literal segments, a single-wildcard segment, and a double-wildcard segment, both
3566
- * interior and trailing (e.g. `packages` followed by a double wildcard, or an interior single
3567
- * wildcard between two literal segments) — not just one trailing single-wildcard segment. A
3568
- * candidate directory that has no `package.json` of its own is skipped, not reported — an empty
3569
- * scaffold directory is not a workspace package. Also honours npm/bun/yarn `!`-prefixed exclusion
3570
- * entries (e.g. `["packages/*", "!packages/legacy"]`) — both the included and excluded patterns
3571
- * are expanded through the same machinery, then the excluded set is subtracted.
3572
- */
3573
- function expandWorkspaceGlobs(cwd: string, patterns: readonly string[]): string[] {
3574
- const expand = (pattern: string): string[] =>
3575
- expandPatternSegments(
3576
- cwd,
3577
- pattern.split('/').filter((s) => s.length > 0),
3578
- )
3579
-
3580
- const excluded = new Set(
3581
- patterns
3582
- .filter((pattern) => pattern.startsWith('!'))
3583
- .flatMap((pattern) => expand(pattern.replace(/^!+/, ''))),
3584
- )
3585
-
3586
- const dirs: string[] = []
3587
- for (const pattern of patterns) {
3588
- if (pattern.startsWith('!')) continue
3589
- for (const abs of expand(pattern)) {
3590
- if (excluded.has(abs)) continue
3591
- if (existsSync(resolve(abs, 'package.json'))) dirs.push(abs)
3592
- }
3593
- }
3594
- return dirs
3595
- }
3596
-
3597
- /**
3598
- * Every workspace package declared by `cwd`'s root `package.json` (npm/bun/yarn `workspaces`
3599
- * field, array or `{ packages: [...] }` object form), named + directory-resolved — PLUS the root
3600
- * manifest itself, labelled `"{name} (root)"`. The root is walked because it commonly carries a
3601
- * hoisted `ai` dev dependency that can itself skew against a workspace package's own major — that
3602
- * is the producer/consumer shape the whole check exists to catch, and it is invisible if only the
3603
- * expanded workspace dirs are inspected. `hasWorkspacesField` is `false` (and `packages` empty)
3604
- * when `cwd` has no `package.json`, no `workspaces` field, or the field is a shape this doesn't
3605
- * recognize — a repo-wide walk is a bonus check, not something that should ever throw doctor over
3606
- * an unrecognized `workspaces` shape.
3607
- */
3608
- function collectWorkspacePackages(cwd: string): WorkspaceWalk {
3609
- const raw = readIfExists(resolve(cwd, 'package.json'))
3610
- if (raw === null) return { packages: [], hasWorkspacesField: false }
3611
-
3612
- let parsed: { workspaces?: unknown; name?: string }
3613
- try {
3614
- parsed = JSON.parse(raw) as { workspaces?: unknown; name?: string }
3615
- } catch {
3616
- return { packages: [], hasWorkspacesField: false }
3617
- }
3618
-
3619
- const patterns = isStringArray(parsed.workspaces)
3620
- ? parsed.workspaces
3621
- : parsed.workspaces !== null &&
3622
- typeof parsed.workspaces === 'object' &&
3623
- isStringArray((parsed.workspaces as { packages?: unknown }).packages)
3624
- ? (parsed.workspaces as { packages: string[] }).packages
3625
- : null
3626
- if (patterns === null) return { packages: [], hasWorkspacesField: false }
3627
-
3628
- const rootName = parsed.name && parsed.name.length > 0 ? `${parsed.name} (root)` : '(root)'
3629
- const workspacePackages = expandWorkspaceGlobs(cwd, patterns).map((dir) => ({
3630
- name: readPackageName(dir),
3631
- dir,
3632
- }))
3633
-
3634
- return {
3635
- packages: [{ name: rootName, dir: cwd }, ...workspacePackages],
3636
- hasWorkspacesField: true,
3637
- }
3638
- }
3639
-
3640
- /** Narrows to a string array — shared by `collectWorkspacePackages`'s two `workspaces` shapes. */
3641
- function isStringArray(value: unknown): value is string[] {
3642
- return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
3643
- }
3644
-
3645
- /**
3646
- * First run of digits in a semver range string (`^7.0.15` → `7`), or null if there is none.
3647
- *
3648
- * Duplicated verbatim in `configs/oxlint-plugin.js` — that file must stay import-free from this
3649
- * package (it loads via `jsPlugins` out of a consumer's node_modules, before this package's own
3650
- * code is necessarily resolvable), so the duplication is structural, not an oversight. Keep both
3651
- * copies in sync by hand; a future parsing fix (pre-release suffixes, say) must land in both.
3652
- */
3653
- function majorOf(range: string | undefined): number | null {
3654
- if (typeof range !== 'string') return null
3655
- const match = range.match(/\d+/)
3656
- return match === null ? null : Number(match[0])
3657
- }
3658
-
3659
- /** The `ai` package's declared major at `dir`'s own `package.json`, or null if undeclared/unreadable. */
3660
- function aiMajorAt(dir: string): number | null {
3661
- const raw = readIfExists(resolve(dir, 'package.json'))
3662
- if (raw === null) return null
3663
- try {
3664
- const pkg = JSON.parse(raw) as {
3665
- dependencies?: Record<string, string>
3666
- devDependencies?: Record<string, string>
3667
- peerDependencies?: Record<string, string>
3668
- }
3669
- return majorOf(
3670
- pkg.dependencies?.['ai'] ?? pkg.devDependencies?.['ai'] ?? pkg.peerDependencies?.['ai'],
3671
- )
3672
- } catch {
3673
- return null
3674
- }
3675
- }
3676
-
3677
- /**
3678
- * Splits `basalt.aiMajorSkewReason` into a validated reason (or null) plus whether the key was
3679
- * present at all but failed validation. A present-but-invalid value (anything other than a
3680
- * non-empty string — including a bare `true`) is treated as absent for the pass/fail decision, same
3681
- * as a missing key, but doctor's failure message still calls out that the key exists and is
3682
- * malformed rather than silently falling back to the plain "no exemption" message — a forgotten key
3683
- * and a broken one are different mistakes and deserve different guidance.
3684
- */
3685
- function resolveAiMajorSkewReason(cfg: BasaltConfig): {
3686
- reason: string | null
3687
- presentButInvalid: boolean
3688
- } {
3689
- const raw = cfg.aiMajorSkewReason
3690
- if (raw === undefined) return { reason: null, presentButInvalid: false }
3691
- if (typeof raw === 'string' && raw.trim().length > 0) {
3692
- return { reason: raw, presentButInvalid: false }
3693
- }
3694
- return { reason: null, presentButInvalid: true }
3695
- }
3696
-
3697
- /**
3698
- * Check a consumer repo's basalt integration and print a pass/warn report.
3699
- *
3700
- * With the plugin retired there is exactly ONE version axis — the npm package — so doctor verifies
3701
- * one thing: the basalt-ui resolved in the consumer's node_modules against the version recorded in
3702
- * .basalt/manifest.json at the last init/sync. File-level drift is `sync --check`'s job, not
3703
- * doctor's.
3704
- *
3705
- * The checks are numbered in the order they run and print — there is no gap, and the number in a
3706
- * section header below is the number in the report.
3707
- *
3708
- * Hard failures (exit non-zero):
3709
- * 1. .basalt/manifest.json exists (init was run).
3710
- * 2. `basalt-resolves`: basalt-ui resolves from here. Unresolvable used to make checks silently
3711
- * VANISH from the report (5 checks became 3) while the footer still read "All checks passed".
3712
- * It is also the moment `bunx basalt-ui` stops using the pinned copy.
3713
- * 6. `guard-scan`: `check-theme` would scan MORE than zero files. `check-theme` already exits 1
3714
- * on "0 files scanned"; doctor disagreeing with it in the same repo is the bug.
3715
- * 7. `oxlint-preset`: the consumer's `.oxlintrc.json` actually `extends` the shipped preset AND
3716
- * that path RESOLVES. `init` keeps an existing config, so the framework's whole lint half can
3717
- * be off with nothing to say so — one repo carried six real violations invisibly across five
3718
- * minors that way. Matching the string alone reproduced the same false green one layer in: a
3719
- * well-shaped entry pointing at a path that no longer exists passed this check in the very
3720
- * tree where oxlint refused to start with `NotFound`.
3721
- * 8. `lefthook-preset`: the repo-root lefthook config's `extends` target resolves. lefthook
3722
- * merges a MISSING target into zero commands and exits 0 — no error, a clean `lefthook dump`,
3723
- * and no pre-commit gate. Nothing but a check can see that. Not-extending-at-all is a warning
3724
- * (a consumer may run their own hooks); extending a path that isn't there is a hard failure.
3725
- * 10. `ai-major-parity`: every workspace package that declares the `ai` package agrees on its
3726
- * major version. `basalt/ai-sdk-major` (the oxlint plugin rule) cannot catch this — it checks
3727
- * a linted file's NEAREST package.json, so a lint run scoped to one workspace package only
3728
- * ever sees that package's own `ai` major and is perfectly happy; the cross-package skew (one
3729
- * package streaming on `ai@5`, another parsing it on `ai@7`) only a repo-wide manifest walk
3730
- * can see. Skipped entirely when `cwd` has no `workspaces` field or none of its packages
3731
- * declare `ai`. A detected skew is exempt-able via `basalt.aiMajorSkewReason` (a non-empty
3732
- * reason string, not a bare `true`) in the consumer's package.json — an intentional
3733
- * producer/consumer split neutralized by a transform is a locked decision, not a defect, and
3734
- * the written reason IS the pin that a permanently-failing guard would otherwise lack. Missing
3735
- * or invalid (empty/non-string) still hard-fails exactly as if the key were absent.
3736
- *
3737
- * Warnings (non-fatal):
3738
- * 3. The installed node_modules/basalt-ui version matches the manifest's basaltVersion
3739
- * (a mismatch means the package was upgraded but `sync` never ran — the placed doctrine is
3740
- * stale).
3741
- * 4. The spacing scale still matches the one stamped into the manifest at the last init/sync
3742
- * (skipped when there is no manifest to compare against; a neutral pass when this CLI is
3743
- * demonstrably not the installed package — see the check).
3744
- * 5. The running CLI's own version matches the installed basalt-ui (catches a stale
3745
- * `bunx basalt-ui` npm fetch; best-effort, skipped if node_modules is absent).
3746
- * 9. The icon files `basaltAppPlugin` (basalt-ui/vite) references exist under the app package's
3747
- * public/ — the package found via `basalt.roots`, not just cwd, so a root-invoked run still
3748
- * reaches it. Never silently omitted: no plugin call anywhere is a pass that says so, a
3749
- * plugin call with no public/ beside it is a SKIPPED.
3750
- * 10 (second half). A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the
3751
- * exemption is stale and can be deleted; an exemption nobody revisits is how a real, later
3752
- * skew slips through unnoticed.
3753
- *
3754
- * An AMBIGUOUS project (no config here, several workspace packages carrying one) short-circuits
3755
- * before any of them, exactly as `check-theme` does: running the remaining checks against the wrong
3756
- * root buries the one real error under spurious ones.
3757
- *
3758
- * SKIPPED is now a third outcome beside pass/warn/fail and exits non-zero on its own — a check that
3759
- * cannot run is not a check that passed. "Not applicable to this profile" is a pass, not a skip.
3760
- *
3761
- * Returns the exit code: 0 = all good, 1 = one or more hard failures or unrunnable checks.
3762
- */
3763
- export function doctor(invocationCwd: string = process.cwd(), flags: string[] = []): number {
3764
- if (conflictingProfileFlags(flags)) {
3765
- console.error('basalt-ui doctor: --tokens-only and --framework are alternatives — pass one.')
3766
- return 1
3767
- }
3768
- const project = resolveProjectDir(invocationCwd)
3769
- // Ambiguity is terminal, exactly as it is for check-theme. Falling back to `invocationCwd` ran
3770
- // every remaining check against the WRONG root and buried the one real error under spurious
3771
- // ones — guard-scan reporting 0 files, .oxlintrc.json "missing" — which is this round's own
3772
- // false-report bug class reappearing inside the fix for it.
3773
- if (project.ambiguous !== null) {
3774
- console.error(
3775
- `\nbasalt-ui doctor — ${invocationCwd}\n\n` +
3776
- ` ✖ no basalt config at ${invocationCwd}, and ${project.ambiguous.length} packages ` +
3777
- `below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
3778
- 'run doctor from one of them, or set BASALT_CWD to pick.\n\n' +
3779
- '1 hard failure(s), 0 warning(s).\n',
3780
- )
3781
- return 1
3782
- }
3783
- const cwd = project.dir
3784
- const cfg = readBasaltConfig(cwd)
3785
- const result: DoctorResult = { hardFailures: 0, warnings: 0, skipped: 0 }
3786
- const lines: string[] = [`\nbasalt-ui doctor — ${cwd}\n`]
3787
-
3788
- function pass(msg: string): void {
3789
- lines.push(` ✓ ${msg}`)
3790
- }
3791
- function warn(msg: string): void {
3792
- lines.push(` ⚠ ${msg}`)
3793
- result.warnings++
3794
- }
3795
- function skip(msg: string): void {
3796
- lines.push(` ⊘ SKIPPED — ${msg}`)
3797
- result.skipped++
3798
- }
3799
- function fail(msg: string): void {
3800
- lines.push(` ✖ ${msg}`)
3801
- result.hardFailures++
3802
- }
3803
-
3804
- if (project.relocatedFrom !== null) {
3805
- lines.push(
3806
- ` → no basalt config at ${project.relocatedFrom} — reporting on ` +
3807
- `${relativePosix(project.relocatedFrom, cwd)}, where it lives.\n`,
3808
- )
3809
- }
3810
-
3811
- // ── Profile ────────────────────────────────────────────────────────────────
3812
- const manifestAbs = resolve(cwd, MANIFEST_PATH)
3813
- const manifestExists = existsSync(manifestAbs)
3814
- // Non-null ⇒ this directory is a package UNDER a configured repo, not an unscaffolded consumer.
3815
- // Every remedy below that would otherwise say "run `basalt-ui init`" points at the parent
3816
- // instead — see {@link parentInstallAdvice}.
3817
- const parentInstall = manifestExists ? null : findManifestAbove(cwd)
3818
- const profile = inferredProfile(cwd, cfg, flags)
3819
- if (profile === 'tokens-only') {
3820
- lines.push(
3821
- ' profile: tokens-only — checking the token layer only. The scaffold checks below are not\n' +
3822
- ' applicable; `basalt-ui init` is NOT the fix here, it places a Mantine doctrine you have no\n' +
3823
- ' use for. Pass --framework to force the full profile.\n',
3824
- )
3825
- // check-theme will NOT infer this — it silences 17 kinds, so it moves only on a declaration.
3826
- // Doctor is the surface that can safely detect the shape, so it is the one that has to say so.
3827
- if (cfg.profile === undefined) {
3828
- lines.push(
3829
- ' → inferred (no .basalt/manifest.json and no @mantine/core in this workspace). Write it\n' +
3830
- ' down as "basalt": { "profile": "tokens-only" } in package.json — check-theme does not\n' +
3831
- ' infer it, so today it still reports the Mantine-only kinds against this repo.\n',
3832
- )
3833
- }
3834
- }
3835
-
3836
- // ── Hard check 1: manifest exists ──────────────────────────────────────────
3837
- if (profile === 'tokens-only') {
3838
- pass(`${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to reconcile`)
3839
- } else if (manifestExists) {
3840
- pass(`${MANIFEST_PATH} exists`)
3841
- } else if (parentInstall !== null) {
3842
- fail(
3843
- `${MANIFEST_PATH} missing here, but this is not an unscaffolded consumer. ` +
3844
- `${parentInstallAdvice(cwd, parentInstall, 'doctor')} \`basalt-ui init\` is NOT the fix: ` +
3845
- 'it would scaffold a SECOND consumer beside the real one.',
3846
- )
3847
- } else {
3848
- fail(`${MANIFEST_PATH} missing — run \`basalt-ui init\` to scaffold the consumer repo`)
3849
- }
3850
-
3851
- // ── Hard check 2: basalt-ui resolves from here ─────────────────────────────
3852
- // Unresolvable used to make checks 3 and 5 VANISH from the report while the footer still read
3853
- // "All checks passed" — 5 checks silently became 3. It is also the moment `bunx basalt-ui`
3854
- // stops using the pinned copy and quietly downloads a different one from npm, so the failure
3855
- // has to be loud rather than absent.
3856
- const install = findBasaltInstall(cwd)
3857
- const installedVersion = install.version
3858
- if (install.dir === null) {
3859
- fail(
3860
- `basalt-ui does not resolve from ${cwd}, any directory above it, or any workspace package — ` +
3861
- 'so every version check below is unrunnable, `extends: [<preset>]` cannot resolve, and ' +
3862
- '`bunx basalt-ui` will silently fetch a DIFFERENT copy from npm instead of your pinned ' +
3863
- 'one. Add basalt-ui as a dependency here (or at the repo root) and re-run.',
3864
- )
3865
- } else if (install.how === 'cwd') {
3866
- pass(
3867
- `basalt-ui resolves at ./node_modules/basalt-ui (${installedVersion ?? 'unknown version'})`,
3868
- )
3869
- } else {
3870
- pass(
3871
- `basalt-ui resolves at ${relativePosix(cwd, install.dir)} (${installedVersion ?? 'unknown version'}, ` +
3872
- `found via the ${install.how}) — seeded \`extends\` paths and CI steps must use that path, ` +
3873
- 'not ./node_modules/basalt-ui.',
3874
- )
3875
- }
3876
-
3877
- // ── Warn check 3: installed basalt-ui version matches the manifest ─────────
3878
- // THE one version axis. The manifest records the version whose init/sync placed the doctrine;
3879
- // node_modules is what the app actually resolves. A mismatch means "upgrade landed, sync didn't".
3880
- if (profile === 'framework' && manifestExists && installedVersion === null) {
3881
- skip(
3882
- 'installed-vs-manifest version — basalt-ui did not resolve, so there is nothing to compare',
3883
- )
3884
- }
3885
- if (manifestExists && installedVersion !== null) {
3886
- const manifestVersion = readManifest(cwd).basaltVersion
3887
- if (manifestVersion === undefined) {
3888
- warn(
3889
- `${MANIFEST_PATH} records no basaltVersion (written by an older basalt-ui) — run ` +
3890
- '`basalt-ui sync` to refresh the doctrine and stamp the manifest.',
3891
- )
3892
- } else if (manifestVersion !== installedVersion) {
3893
- warn(
3894
- `installed basalt-ui (${installedVersion}) differs from the version that last synced ` +
3895
- `(${manifestVersion}) — the placed doctrine is stale; run \`basalt-ui sync\`.`,
3896
- )
3897
- } else {
3898
- pass(`installed basalt-ui (${installedVersion}) matches the manifest's basaltVersion`)
3899
- }
3900
- }
3901
-
3902
- const cliVersion = readFrameworkVersion(packageRoot())
3903
-
3904
- // ── Warn check 4: the spacing scale has not moved under the app ────────────
3905
- // The only check here that reports a change in RENDERED OUTPUT rather than in placed files. A
3906
- // retune of the spacing bases moves every surface in an app calling `createBasaltTheme()` bare,
3907
- // and majors are banned by design, so nothing in the version number says so — 1.2.0's retune
3908
- // shipped under "tighten the sidebar, open up components" and sat unverified in production for a
3909
- // day. Diffing the current scale against what last synced makes that move something a consumer
3910
- // is told rather than something they have to notice.
3911
- //
3912
- // The scale that matters is the INSTALLED package's — that is what the app renders with — but
3913
- // `deriveSpacing` here is the RUNNING CLI's, which a stale `bunx basalt-ui` fetch makes a
3914
- // different package. Rather than load the installed tokens entry (async, and doctor is sync),
3915
- // the comparison is skipped whenever the two versions are known to disagree: a false "matches"
3916
- // is worse than no answer. Check 5 below names that disagreement, so this stays a neutral note.
3917
- const cliIsTheInstall = installedVersion === null || installedVersion === cliVersion
3918
- if (profile === 'tokens-only') {
3919
- pass('spacing scale: n/a — a tokens-only consumer renders no basalt Mantine theme')
3920
- } else if (!manifestExists) {
3921
- // Printing NOTHING is the failure SKIPPED exists to end: the check simply vanished from the
3922
- // report whenever there was no manifest, which is precisely when it is least safe to assume.
3923
- skip(
3924
- `spacing scale — no ${MANIFEST_PATH} records the scale this app last synced with, so there ` +
3925
- 'is nothing to compare against',
3926
- )
3927
- } else if (!cliIsTheInstall) {
3928
- pass(
3929
- `spacing scale not compared — this CLI (${cliVersion}) is not the installed basalt-ui ` +
3930
- `(${installedVersion}), so its scale is not the one the app renders with`,
3931
- )
3932
- } else {
3933
- const recorded = readManifest(cwd).spacingScale
3934
- const current = deriveSpacing(0).scale as Record<string, number>
3935
- if (recorded === undefined) {
3936
- // Silent for a manifest written before this field existed — the version check above already
3937
- // says "run sync", and a second warning for the same cause is noise.
3938
- pass('spacing scale not yet recorded — `basalt-ui sync` will stamp it')
3939
- } else {
3940
- // Over the UNION of both key sets: a step the scale gained since the last sync is a move the
3941
- // app feels, and so is one it lost. Iterating only `current` would miss the second.
3942
- const steps = [...new Set([...Object.keys(current), ...Object.keys(recorded)])].toSorted()
3943
- const moved = steps
3944
- .filter((step) => recorded[step] !== current[step])
3945
- .map((step) => `${step} ${recorded[step] ?? '(none)'}→${current[step] ?? '(removed)'}`)
3946
- if (moved.length > 0) {
3947
- warn(
3948
- `the spacing scale moved since the last sync (${moved.join(', ')}) — every surface in an ` +
3949
- 'app calling createBasaltTheme() bare shifts with it. Review the app visually, then run ' +
3950
- '`basalt-ui sync` to record the new scale.',
3951
- )
3952
- } else {
3953
- pass('spacing scale matches the last sync')
3954
- }
3955
- }
3956
- }
3957
-
3958
- // ── Warn check 5: running CLI version matches the consumer's installed basalt-ui ────
3959
- // Catches the failure mode where bunx fetches a stale published package instead of the local
3960
- // install — the CLI that ran doctor and the package resolved from the consumer's node_modules
3961
- // silently disagree.
3962
- if (installedVersion === null) {
3963
- skip('CLI-vs-installed version — basalt-ui did not resolve, so there is no installed version')
3964
- } else if (installedVersion !== cliVersion) {
3965
- warn(
3966
- `running CLI version (${cliVersion}) differs from the installed basalt-ui version at ` +
3967
- `${install.dir === null ? 'node_modules' : relativePosix(cwd, install.dir)} (${installedVersion}) — ` +
3968
- 'likely a stale `bunx basalt-ui` fetch from npm; run the local bin ' +
3969
- '(`node_modules/.bin/basalt-ui`, or a package.json script) so the pinned copy is the one ' +
3970
- 'that runs.',
3971
- )
3972
- } else {
3973
- pass(`CLI version (${cliVersion}) matches the installed basalt-ui`)
3974
- }
3975
-
3976
- // ── Hard check 6: the guard would actually scan something ──────────────────
3977
- // `check-theme` exits 1 on "0 files scanned" — doctor reporting all-green in the SAME repo is
3978
- // the disagreement consumers reported: init ran, doctor passed, and the palette guard was a
3979
- // no-op the whole time because no `basalt.roots` described the workspace layout.
3980
- if (profile === 'tokens-only') {
3981
- pass('guard-scan: n/a — the theme guard is a Mantine-app check')
3982
- } else {
3983
- const scanned = scannableFiles(cwd, cfg)
3984
- if (scanned.length === 0) {
3985
- fail(
3986
- `guard-scan: check-theme would scan 0 files — the roots (${resolveRoots(cfg).join(', ')}) ` +
3987
- `match nothing under ${cwd}, so the palette guard is a no-op that still exits green in ` +
3988
- 'every gate that does not run it. Set "basalt": { "roots": [...] } in package.json.',
3989
- )
3990
- } else {
3991
- pass(
3992
- `guard-scan: check-theme covers ${scanned.length} file(s) under ${resolveRoots(cfg).join(', ')}`,
3993
- )
3994
- }
3995
- }
3996
-
3997
- // ── Hard check 7: the consumer's oxlint config extends the shipped preset ──
3998
- // `init` KEEPS an existing `.oxlintrc.json`, so a repo can carry the whole scaffold with the
3999
- // framework's lint half switched off and nothing anywhere saying so. One repo ran five minors
4000
- // that way and surfaced six real `basalt/no-raw-font-size` errors the moment it was wired.
4001
- if (profile === 'tokens-only') {
4002
- pass('oxlint-preset: n/a — the shipped preset is a Mantine/React preset')
4003
- } else {
4004
- const oxlintrcRaw = readIfExists(resolve(cwd, '.oxlintrc.json'))
4005
- if (oxlintrcRaw === null) {
4006
- fail(
4007
- '.oxlintrc.json missing — the shipped oxlint preset (the basalt/* design rules) is not ' +
4008
- 'active. ' +
4009
- (parentInstall === null
4010
- ? 'Run `basalt-ui init` to seed it.'
4011
- : `${parentInstallAdvice(cwd, parentInstall, 'doctor')} The preset is seeded once, at ` +
4012
- 'the install — not per package.'),
4013
- )
4014
- } else {
4015
- // JSONC: oxlint accepts comments and real consumer configs carry them.
4016
- const parsed = parseJsonc(oxlintrcRaw)
4017
- if (parsed === null) {
4018
- skip(
4019
- '.oxlintrc.json does not parse as JSON/JSONC — cannot tell whether it extends the preset',
4020
- )
4021
- } else {
4022
- const entry = basaltPresetEntry(parsed['extends'])
4023
- const correct = shippedAssetPath(install, cwd, 'configs/oxlint.json')
4024
- if (entry === null) {
4025
- fail(
4026
- '.oxlintrc.json does NOT extend the shipped preset — every basalt/* design rule is off ' +
4027
- `and oxlint reports green without them. Add "extends": ["${correct}"], or re-run ` +
4028
- '`basalt-ui init --merge-lint` to splice it in.',
4029
- )
4030
- } else if (!existsSync(resolve(cwd, entry)) && entry === correct && install.dir !== null) {
4031
- // The path is the one basalt resolves from and the asset still isn't there — a partial
4032
- // or corrupted install, not a stale path. Saying "repoint it at <the same string>" would
4033
- // be the unhelpful shape of a correct diagnosis.
4034
- fail(
4035
- `.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) — ` +
4036
- 'basalt-ui resolves there but ships no `configs/oxlint.json` at that path, so oxlint ' +
4037
- 'refuses to start (`NotFound`) and nothing is linted. Reinstall basalt-ui.',
4038
- )
4039
- } else if (!existsSync(resolve(cwd, entry))) {
4040
- // The string is right and the file is not there. oxlint refuses to start at all
4041
- // (`invalid config file … NotFound`), so a green line here is a check that proves the
4042
- // framework is ON while the linter is dead — the same false-green class this check was
4043
- // added to close, one layer in. `install` already computed the true path in this run.
4044
- fail(
4045
- `.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) — ` +
4046
- 'oxlint refuses to start on a missing extends target (`NotFound`), so nothing is ' +
4047
- `linted at all. Repoint it at "${correct}", where basalt-ui actually resolves.`,
4048
- )
4049
- } else {
4050
- pass(`.oxlintrc.json extends the shipped basalt-ui oxlint preset (${entry})`)
4051
- }
4052
- }
4053
- }
4054
- }
4055
-
4056
- // ── Check 8: the pre-commit hook actually runs the guard ───────────────────
4057
- // The loudest silent failure in the toolchain: lefthook merges a MISSING extends target into
4058
- // zero commands and exits 0 — `lefthook dump` prints the extends line, no commands, and a clean
4059
- // exit — so a repo whose path went stale has no pre-commit gate and nothing anywhere says so.
4060
- // Unlike oxlint there is no error to notice, which is why this has to be a check rather than a
4061
- // remedy line someone reads after a failure.
4062
- //
4063
- // But a BROKEN extends is the only half of this that can be asserted from the string. Whether a
4064
- // gate EXISTS is a different question, and 1.21.0 answered it by testing for the extends target
4065
- // — warning at a correctly-wired repo and prescribing a change that would have broken it. See
4066
- // {@link inspectLefthookGate}: `lefthook dump` where it runs, the config text where it doesn't,
4067
- // and an advisory that names the gap where neither settles it.
4068
- const repoRoot = findRepoRoot(cwd)
4069
- const lefthookGate =
4070
- profile === 'tokens-only' ? { kind: 'n/a' as const } : inspectLefthookGate(repoRoot)
4071
- const lefthookCorrect = shippedAssetPath(install, repoRoot, 'configs/lefthook.yml')
4072
- if (lefthookGate.kind === 'n/a') {
4073
- pass('lefthook-preset: n/a — a tokens-only consumer wires its own hooks')
4074
- } else if (lefthookGate.kind === 'no-file') {
4075
- pass(`lefthook-preset: n/a — no lefthook config at ${repoRoot}`)
4076
- } else if (lefthookGate.kind === 'extends-ok') {
4077
- pass(`${lefthookGate.file} extends the shipped lefthook preset (${lefthookGate.entry})`)
4078
- } else if (lefthookGate.kind === 'wired') {
4079
- // Extending the preset is basalt's recommendation, not the contract. The contract is that a
4080
- // commit cannot land without the guard running, and a repo spelling the jobs out with a
4081
- // per-command `root:` satisfies it — `extends` merges commands without their working
4082
- // directory, so for that repo the preset is the thing that would NOT work.
4083
- pass(
4084
- `lefthook-preset: ${lefthookGate.file} does not extend the shipped preset, but a pre-commit ` +
4085
- `command runs check-theme directly — the gate exists (read from ` +
4086
- (lefthookGate.proof === 'dump'
4087
- ? '`lefthook dump`, so extends/include/root: are all resolved).'
4088
- : 'the config text; `lefthook dump` was not runnable here, so an `include:` or a ' +
4089
- 'remote config was not consulted).'),
4090
- )
4091
- } else if (lefthookGate.kind === 'extends-broken') {
4092
- fail(
4093
- `${lefthookGate.file} extends "${lefthookGate.entry}", which does not exist ` +
4094
- `(${resolve(repoRoot, lefthookGate.entry)}) — lefthook merges a missing extends ` +
4095
- 'target into ZERO commands and still exits 0, so this repo has no pre-commit gate and ' +
4096
- `\`lefthook dump\` looks clean. Repoint it at "${lefthookCorrect}".`,
4097
- )
4098
- } else if (lefthookGate.kind === 'absent') {
4099
- // Warn, not fail — same tier the string-matching version used. A consumer may deliberately
4100
- // gate in CI only, and doctor does not get to promote its own advice to a build break.
4101
- warn(
4102
- `${lefthookGate.file}: \`lefthook dump\` resolves the merged config and NO pre-commit ` +
4103
- 'command runs check-theme — the theme guard is not gating commits. Either add ' +
4104
- `"extends: [${lefthookCorrect}]", or add your own command running ` +
4105
- `\`${basaltBinCommand(install, cwd)} check-theme\` (with \`root:\` if it must run in a ` +
4106
- 'subdirectory).',
4107
- )
4108
- } else {
4109
- warn(
4110
- `${lefthookGate.file} names no basalt gate and \`lefthook dump\` could not be run here, so ` +
4111
- 'this check cannot tell a missing gate from one wired through an `include:` or a remote ' +
4112
- 'config it never read — treat it as advisory. If the guard really is unwired, add ' +
4113
- `"extends: [${lefthookCorrect}]" or your own command running ` +
4114
- `\`${basaltBinCommand(install, cwd)} check-theme\`.`,
4115
- )
4116
- }
4117
-
4118
- // ── Warn check 8: the icon files basaltAppPlugin actually references exist ────
4119
- // basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references six filenames by default
4120
- // (the realfavicongenerator convention), but since 1.23.0 an app can NAME its own — so the option
4121
- // is read (see {@link readAppIconsOption}) rather than assumed.
4122
- //
4123
- // It used to read `cwd` alone, and in a monorepo the vite config lives in the app PACKAGE while
4124
- // the only invocation that exits 0 is the repo root. The check therefore vanished from the root
4125
- // run with no line at all — the failure mode SKIPPED exists to eliminate — leaving no single
4126
- // invocation that both passes and checks the icons. It now resolves the app package the same way
4127
- // the scan does, off `basalt.roots`; see {@link findAppPluginDir}.
4128
- if (profile === 'framework') {
4129
- const appDir = findAppPluginDir(cwd, cfg)
4130
- const iconsIn = appDir ?? cwd
4131
- const icons = readAppIconsOption(iconsIn)
4132
- const here = appDir === null || appDir === cwd ? '' : `${relativePosix(cwd, appDir)}/`
4133
- const publicDir = resolve(iconsIn, 'public')
4134
- if (appDir === null && !existsSync(publicDir)) {
4135
- // No `basaltAppPlugin(` anywhere under the declared roots and no public/ here: nothing in
4136
- // this project references icon files, so there is nothing to check — and saying so is the
4137
- // point. A check that vanishes without a word is indistinguishable from one that passed.
4138
- pass('app icons: no `basaltAppPlugin(` under this project and no public/ — nothing to check')
4139
- } else if (icons.kind === 'none') {
4140
- pass('basaltAppPlugin is configured with no icons — nothing to check')
4141
- } else if (!existsSync(publicDir)) {
4142
- skip(
4143
- `app icons — ${here === '' ? 'this package' : here.replace(/\/$/, '')} configures ` +
4144
- 'basaltAppPlugin, but there is no `public/` beside its vite config, so the files it ' +
4145
- "references cannot be located. Use Vite's public-dir convention, or set `icons: false` " +
4146
- 'if this app ships none.',
4147
- )
4148
- } else if (icons.kind === 'explicit') {
4149
- // The app NAMED its icons, so those are the only files it owes. Checking the six defaults
4150
- // here is what told 1.23.0's flagship adopter to generate five files it deliberately lacks.
4151
- const missing = icons.sources.filter(
4152
- (src) => !existsSync(resolve(publicDir, src.replace(/^\//, ''))),
4153
- )
4154
- if (missing.length > 0) {
4155
- warn(
4156
- `basaltAppPlugin declares icon(s) that are not in ${here}public/: ${missing.join(', ')} — ` +
4157
- 'the manifest and the head links will point at a 404. Place them, or drop them from ' +
4158
- 'the `icons` array.',
4159
- )
4160
- } else {
4161
- pass(
4162
- `${here}public/ has all ${icons.sources.length} icon file(s) basaltAppPlugin's ` +
4163
- '`icons` option names',
4164
- )
4165
- }
4166
- } else {
4167
- const dir = icons.kind === 'default' ? icons.dir : null
4168
- const iconRoot = dir === null ? publicDir : resolve(publicDir, dir.replace(/^\//, ''))
4169
- const shown = dir === null ? `${here}public/` : `${here}public/${dir.replace(/^\//, '')}`
4170
- const missingIcons = DEFAULT_APP_ICON_FILES.filter((f) => !existsSync(resolve(iconRoot, f)))
4171
- if (missingIcons.length > 0) {
4172
- warn(
4173
- `${shown} is missing basaltAppPlugin icon file(s): ${missingIcons.join(', ')} — generate ` +
4174
- `them (e.g. via realfavicongenerator.net) and place them at the ${shown} root, or name ` +
4175
- "the icons this app really has via the plugin's `icons` array.",
4176
- )
4177
- } else {
4178
- pass(`${shown} has all of basaltAppPlugin's default icon files`)
4179
- }
4180
- }
4181
- }
4182
-
4183
- // ── Hard check 9: ai package major version parity across workspace packages ─
4184
- // basalt/ai-sdk-major (the lint rule) is per-file against the NEAREST package.json, so a lint
4185
- // run scoped to one workspace package only ever sees that package's own `ai` major and is
4186
- // perfectly happy. The real defect is CROSS-package (argo defect 1: apps/api on ai@5 producing a
4187
- // stream apps/dashboard's ai@7 client can't parse) and only a repo-wide manifest walk sees it —
4188
- // this is that walk. Hard failure, not a warning: a skewed pair throws at runtime, it doesn't
4189
- // just look different. `basalt.aiMajorSkewReason` exempts an INTENTIONAL skew (e.g. a
4190
- // producer-side TransformStream neutralizing the divergent enum value) — see the BasaltConfig
4191
- // JSDoc for its exact semantics; a stale declaration (skew resolved, reason still present) warns
4192
- // instead of passing silently.
4193
- const workspaceWalk = collectWorkspacePackages(cwd)
4194
- const aiMajors = workspaceWalk.packages
4195
- .map((pkg) => ({ name: pkg.name, major: aiMajorAt(pkg.dir) }))
4196
- .filter((entry): entry is { name: string; major: number } => entry.major !== null)
4197
- const { reason: aiMajorSkewReason, presentButInvalid: aiMajorSkewReasonInvalid } =
4198
- resolveAiMajorSkewReason(cfg)
4199
- if (aiMajors.length > 0) {
4200
- const distinctMajors = new Set(aiMajors.map((entry) => entry.major))
4201
- if (distinctMajors.size > 1) {
4202
- const summary = aiMajors.map((entry) => `${entry.name}@ai${entry.major}`).join(', ')
4203
- if (aiMajorSkewReason !== null) {
4204
- pass(
4205
- `ai package major version mismatch across workspace packages: ${summary} — exempted via ` +
4206
- `basalt.aiMajorSkewReason: "${aiMajorSkewReason}"`,
4207
- )
4208
- } else if (aiMajorSkewReasonInvalid) {
4209
- fail(
4210
- `ai package major version mismatch across workspace packages: ${summary} — ` +
4211
- 'basalt.aiMajorSkewReason is present but is not a non-empty string (a bare `true` is ' +
4212
- 'not accepted); the exemption must carry a written reason, e.g. ' +
4213
- '`aiMajorSkewReason: "apps/api on ai@5, apps/dashboard on ai@7, neutralized by a ' +
4214
- 'producer-side TransformStream"`.',
4215
- )
4216
- } else {
4217
- fail(`ai package major version mismatch across workspace packages: ${summary}`)
4218
- }
4219
- } else {
4220
- const [major] = distinctMajors
4221
- if (aiMajorSkewReason !== null) {
4222
- warn(
4223
- `basalt.aiMajorSkewReason ("${aiMajorSkewReason}") is declared but the ai package major ` +
4224
- `already matches across all ${aiMajors.length} workspace package(s) declaring it ` +
4225
- `(ai@${major}) — the exemption is no longer needed and can be deleted.`,
4226
- )
4227
- } else {
4228
- pass(
4229
- `ai package major matches across ${aiMajors.length} workspace package(s) declaring it (ai@${major})`,
4230
- )
4231
- }
4232
- }
4233
- } else if (workspaceWalk.hasWorkspacesField) {
4234
- // A hard check that can silently not run is worse than one that is absent — say so.
4235
- pass('ai-major-parity: no workspace package declares the ai dependency — nothing to compare')
4236
- }
4237
-
4238
- // ── Report ──────────────────────────────────────────────────────────────────
4239
- // "All checks passed" is only printable when every check ran AND passed. A skipped check counts
4240
- // against the exit code on its own — the report that dropped two of five checks and still
4241
- // claimed green is the bug this footer exists to make impossible.
4242
- lines.push('')
4243
- if (result.hardFailures === 0 && result.warnings === 0 && result.skipped === 0) {
4244
- lines.push('All checks passed.')
4245
- } else {
4246
- const parts: string[] = []
4247
- if (result.hardFailures > 0) parts.push(`${result.hardFailures} hard failure(s)`)
4248
- if (result.skipped > 0) parts.push(`${result.skipped} check(s) SKIPPED (could not run)`)
4249
- parts.push(`${result.warnings} warning(s)`)
4250
- lines.push(`${parts.join(', ')}.`)
4251
- }
4252
- lines.push('')
4253
-
4254
- if (result.hardFailures > 0 || result.skipped > 0) {
4255
- console.error(lines.join('\n'))
4256
- return 1
4257
- }
4258
- console.log(lines.join('\n'))
4259
- return 0
4260
- }
4261
-
4262
- // ──────────────────────────────────────────────────────────────────────────────
4263
- // info — SURFACES-derived surface map
4264
- // ──────────────────────────────────────────────────────────────────────────────
4265
-
4266
- /** One row in the `basalt info` output — one per published JS subpath export. */
4267
- export type InfoSubpath = {
4268
- path: string
4269
- description: string
4270
- layer: string
4271
- rule: string | null
4272
- skills: readonly string[]
4273
- optionalPeers: string[]
4274
- }
4275
-
4276
- /** The stable JSON shape for `basalt info --json`. */
4277
- export type InfoOutput = {
4278
- name: string
4279
- version: string
4280
- subpaths: InfoSubpath[]
4281
- }
1782
+ }
4282
1783
 
4283
1784
  /**
4284
- * Print a human-readable (or JSON) map of the published basalt-ui surface derived from
4285
- * SURFACES + package.json. Every row is live-derived — the subpath list cannot drift.
4286
- *
4287
- * `basalt info` → human-readable table
4288
- * `basalt info --json` → stable JSON (InfoOutput shape)
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.
4289
1790
  */
4290
- export function info(flags: string[]): number {
4291
- const pkgRoot = packageRoot()
4292
-
4293
- // Read package.json for name, version, peerDependencies, peerDependenciesMeta, and exports.
4294
- let pkgName = 'basalt-ui'
4295
- let pkgVersion = '0.0.0'
4296
- let peerDeps: Record<string, string> = {}
4297
- let peerMeta: Record<string, { optional?: boolean }> = {}
4298
- let pkgExports: Record<string, unknown> = {}
4299
- try {
4300
- const pkg = JSON.parse(readFileSync(resolve(pkgRoot, 'package.json'), 'utf8')) as {
4301
- name?: string
4302
- version?: string
4303
- peerDependencies?: Record<string, string>
4304
- peerDependenciesMeta?: Record<string, { optional?: boolean }>
4305
- exports?: Record<string, unknown>
4306
- }
4307
- pkgName = pkg.name ?? pkgName
4308
- pkgVersion = pkg.version ?? pkgVersion
4309
- peerDeps = pkg.peerDependencies ?? {}
4310
- peerMeta = pkg.peerDependenciesMeta ?? {}
4311
- pkgExports = pkg.exports ?? {}
4312
- } catch {
4313
- // proceed with defaults
4314
- }
4315
-
4316
- // Derive optional peer set: all peers marked optional in peerDependenciesMeta.
4317
- const optionalPeerSet = new Set(
4318
- Object.entries(peerMeta)
4319
- .filter(([, m]) => m.optional === true)
4320
- .map(([name]) => name),
4321
- )
4322
-
4323
- // Build subpath rows from SURFACES — only real JS subpath keys (non-#, non-'./configs/*').
4324
- // Restrict to keys that also appear in package.json exports so we match what is published.
4325
- const publishedExportKeys = new Set(Object.keys(pkgExports))
4326
-
4327
- const subpaths: InfoSubpath[] = []
4328
- for (const [key, spec] of Object.entries(SURFACES) as [string, SurfaceSpec][]) {
4329
- if (key.startsWith('#')) continue
4330
- if (!publishedExportKeys.has(key)) continue
4331
-
4332
- const docSpec = spec.kind === 'doctrine' ? spec : null
4333
-
4334
- // Resolve optional peers from spec.optionalPeers (SURFACES SSOT), with versions from package.json.
4335
- const specPeers: readonly string[] =
4336
- docSpec !== null && 'optionalPeers' in docSpec && Array.isArray(docSpec.optionalPeers)
4337
- ? docSpec.optionalPeers
4338
- : []
4339
- const optionalPeers = specPeers
4340
- .filter((p) => optionalPeerSet.has(p) && p in peerDeps)
4341
- .map((p) => `${p}@${peerDeps[p]}`)
4342
-
4343
- subpaths.push({
4344
- path: `basalt-ui${key === '.' ? '' : key.slice(1)}`,
4345
- description: spec.description ?? `basalt-ui${key === '.' ? '' : key} subpath`,
4346
- layer: spec.layer,
4347
- rule: docSpec?.rule ?? null,
4348
- skills: docSpec?.skill ?? [],
4349
- optionalPeers,
4350
- })
4351
- }
4352
-
4353
- const output: InfoOutput = { name: pkgName, version: pkgVersion, subpaths }
4354
-
4355
- if (flags.includes('--json')) {
4356
- console.log(JSON.stringify(output, null, 2))
4357
- return 0
4358
- }
4359
-
4360
- // Human-readable output
4361
- console.log(`\nbasalt-ui v${pkgVersion} — published surface\n`)
4362
- const COL = { path: 32, layer: 18, rule: 18, skills: 36 }
4363
- const header = [
4364
- 'SUBPATH'.padEnd(COL.path),
4365
- 'LAYER'.padEnd(COL.layer),
4366
- 'RULE'.padEnd(COL.rule),
4367
- 'SKILLS'.padEnd(COL.skills),
4368
- 'OPTIONAL PEERS',
4369
- ].join(' ')
4370
- console.log(header)
4371
- console.log('-'.repeat(header.length))
4372
- for (const row of output.subpaths) {
4373
- const line = [
4374
- row.path.padEnd(COL.path),
4375
- row.layer.padEnd(COL.layer),
4376
- (row.rule ?? '—').padEnd(COL.rule),
4377
- (row.skills.join(', ') || '—').padEnd(COL.skills),
4378
- row.optionalPeers.join(', ') || '—',
4379
- ].join(' ')
4380
- console.log(line)
4381
- }
4382
- console.log('')
4383
- return 0
4384
- }
4385
-
4386
- /** The six filenames `basaltAppPlugin` references when `icons` is left at its default. */
4387
- const DEFAULT_APP_ICON_FILES = [
4388
- 'favicon.ico',
4389
- 'favicon.svg',
4390
- 'favicon-96x96.png',
4391
- 'apple-touch-icon.png',
4392
- 'web-app-manifest-192x192.png',
4393
- 'web-app-manifest-512x512.png',
4394
- ] as const
4395
-
4396
- const VITE_CONFIG_FILES = [
4397
- 'vite.config.ts',
4398
- 'vite.config.mts',
4399
- 'vite.config.cts',
4400
- 'vite.config.js',
4401
- 'vite.config.mjs',
4402
- 'vite.config.cjs',
4403
- ] as const
1791
+ function expandPatternSegments(baseDir: string, segments: readonly string[]): string[] {
1792
+ if (segments.length === 0) return [baseDir]
1793
+ const [head, ...rest] = segments
4404
1794
 
4405
- /**
4406
- * The directory whose vite config actually calls `basaltAppPlugin(` — cwd, or the app package the
4407
- * declared roots point into. Null when no vite config under this project calls it.
4408
- *
4409
- * `basalt.roots: ["apps/web/src"]` already names where the app is; the icon check just never used
4410
- * it, so from the repo root — the only invocation that can exit 0 on a monorepo layout — it found
4411
- * no vite config and silently emitted nothing. Each root is walked from its own directory up to
4412
- * cwd, nearest first, so `apps/web/src` resolves to `apps/web`, the package that owns both the
4413
- * vite config and the `public/` tree.
4414
- */
4415
- function findAppPluginDir(cwd: string, cfg: BasaltConfig): string | null {
4416
- const seen = new Set<string>()
4417
- const candidates: string[] = []
4418
- const add = (dir: string): void => {
4419
- if (seen.has(dir)) return
4420
- seen.add(dir)
4421
- candidates.push(dir)
4422
- }
4423
- add(cwd)
4424
- for (const root of resolveRoots(cfg)) {
4425
- let current = resolve(cwd, root)
4426
- // A root that escapes cwd (`../shared`) is not this project's app package — never walk out.
4427
- while (current !== cwd && !relative(cwd, current).startsWith('..')) {
4428
- add(current)
4429
- const parent = dirname(current)
4430
- if (parent === current) break
4431
- 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))
4432
1801
  }
1802
+ return matches
4433
1803
  }
4434
- for (const dir of candidates) {
4435
- const configPath = VITE_CONFIG_FILES.map((f) => resolve(dir, f)).find((f) => existsSync(f))
4436
- if (configPath === undefined) continue
4437
- if (readIfExists(configPath)?.includes('basaltAppPlugin(') === true) return dir
4438
- }
4439
- return null
4440
- }
4441
1804
 
4442
- /**
4443
- * What `basaltAppPlugin`'s `icons` option says this app actually has.
4444
- *
4445
- * `unknown` = no vite config, no `basaltAppPlugin(` call, or an `icons` value this bounded read
4446
- * cannot classify — all three fall back to the six-default check, so the gate can only ever get
4447
- * NARROWER than it was, never blinder.
4448
- */
4449
- type AppIconsOption =
4450
- | { kind: 'default'; dir: string | null }
4451
- | { kind: 'none' }
4452
- | { kind: 'explicit'; sources: readonly string[] }
4453
- | { kind: 'unknown' }
4454
-
4455
- /** The balanced `(`…`)` / `{`…`}` / `[`…`]` span starting at `open`, or null if it never closes. */
4456
- function balancedSpan(text: string, open: number): string | null {
4457
- const pairs: Record<string, string> = { '(': ')', '{': '}', '[': ']' }
4458
- const closer = pairs[text[open] as string]
4459
- if (closer === undefined) return null
4460
- let depth = 0
4461
- for (let i = open; i < text.length; i++) {
4462
- const ch = text[i] as string
4463
- if (ch === text[open]) depth++
4464
- else if (ch === closer) {
4465
- depth--
4466
- if (depth === 0) return text.slice(open, i + 1)
4467
- }
4468
- }
4469
- 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))
4470
1810
  }
4471
1811
 
4472
1812
  /**
4473
- * Read the `icons` option out of the consumer's vite config.
4474
- *
4475
- * 1.23.0 let `icons` name an app's REAL icon files; doctor's icon check kept demanding the six
4476
- * default filenames and never read the option, so adopting the release's headline feature produced
4477
- * a brand-new warning — on the very repo the feature was written for. A warning that fires BECAUSE
4478
- * you adopted the new API teaches people to ignore doctor.
4479
- *
4480
- * doctor does not run the build, so the plugin's resolved answer is not available to it; this is a
4481
- * bounded textual read of the config file, the same shape the guard uses on source. It is
4482
- * 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.
4483
1821
  */
4484
- function readAppIconsOption(cwd: string): AppIconsOption {
4485
- const configPath = VITE_CONFIG_FILES.map((f) => resolve(cwd, f)).find((f) => existsSync(f))
4486
- if (configPath === undefined) return { kind: 'unknown' }
4487
- const text = readIfExists(configPath)
4488
- if (text === null) return { kind: 'unknown' }
4489
- const call = text.indexOf('basaltAppPlugin(')
4490
- if (call === -1) return { kind: 'unknown' }
4491
- const args = balancedSpan(text, call + 'basaltAppPlugin'.length)
4492
- if (args === null) return { kind: 'unknown' }
4493
- const key = args.search(/\bicons\s*:/)
4494
- if (key === -1) return { kind: 'default', dir: null }
4495
- const valueStart = args.indexOf(':', key) + 1
4496
- const rest = args.slice(valueStart).trimStart()
4497
- if (rest.startsWith('false')) return { kind: 'none' }
4498
- const offset = args.length - rest.length
4499
- if (rest.startsWith('[')) {
4500
- const list = balancedSpan(args, offset)
4501
- if (list === null) return { kind: 'unknown' }
4502
- const sources = [...list.matchAll(/\bsrc\s*:\s*['"`]([^'"`]+)['"`]/g)].map(
4503
- (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),
4504
1827
  )
4505
- // An empty array means "this app has no icons" to the plugin, same as `false`.
4506
- return sources.length === 0 ? { kind: 'none' } : { kind: 'explicit', sources }
4507
- }
4508
- if (rest.startsWith('{')) {
4509
- const obj = balancedSpan(args, offset)
4510
- if (obj === null) return { kind: 'unknown' }
4511
- const dir = /\bdir\s*:\s*['"`]([^'"`]+)['"`]/.exec(obj)
4512
- return { kind: 'default', dir: dir === null ? null : (dir[1] as string) }
4513
- }
4514
- return { kind: 'unknown' }
4515
- }
4516
-
4517
- /** Read the value that follows a `--flag` in an argv slice; `undefined` when absent or terminal. */
4518
- function flagValue(flags: string[], name: string): string | undefined {
4519
- const i = flags.indexOf(name)
4520
- if (i === -1) return undefined
4521
- const value = flags[i + 1]
4522
- return value === undefined || value.startsWith('--') ? undefined : value
4523
- }
4524
-
4525
- /**
4526
- * The generated-file header: the canonical marker line (imported from the guard, which owns it),
4527
- * then the version + invocation that produced the file.
4528
- *
4529
- * Both lines are load-bearing, not decoration — `checkSource` only exempts a stylesheet whose line
4530
- * 1 is the marker verbatim AND whose line 2 parses as this provenance line. Reword either half and
4531
- * basalt starts reporting its own output again (116 findings in rollhook's token sheet).
4532
- */
4533
- function generatedHeader(version: string, command: string, flags: string[]): string {
4534
- const shown = flags.filter((f) => f !== '--check')
4535
- const invocation = shown.length === 0 ? command : `${command} ${shown.join(' ')}`
4536
- return `${GENERATED_HEADER_LINE}\n/* basalt-ui ${version} — \`basalt-ui ${invocation}\` */\n`
4537
- }
4538
-
4539
- /**
4540
- * The provenance line, split so only the VERSION token can be neutralized.
4541
- *
4542
- * Deliberately the same shape as the guard's `GENERATED_PROVENANCE_LINE` (`src/guard/index.ts`),
4543
- * which is what the `@generated` exemption tests line 2 against — the two must keep agreeing, so
4544
- * this is written to match, not to be lenient. A line that does not match is left VERBATIM, which
4545
- * is what makes a deleted, reworded or forged header fail `--check` instead of being blanked into
4546
- * agreement.
4547
- */
4548
- const PROVENANCE_VERSION =
4549
- /^(\/\* basalt-ui )(\d+\.\d+\.\d+\S*)( — `basalt-ui (?:tokens:css|fonts:css)[^`]*` \*\/)$/
4550
-
4551
- /** The provenance line's version token, or null when line 2 is not a provenance line at all. */
4552
- function provenanceVersion(css: string): string | null {
4553
- const match = PROVENANCE_VERSION.exec(css.split('\n')[1] ?? '')
4554
- return match === null ? null : (match[2] as string)
4555
- }
4556
-
4557
- /**
4558
- * The generated file with only the provenance line's VERSION TOKEN neutralized — what `--check`
4559
- * actually compares.
4560
- *
4561
- * `--check` used to gate the whole file byte-for-byte, so every basalt-ui release forced a
4562
- * mandatory no-op commit in every consumer that commits a generated sheet. 1.23.1 fixed that by
4563
- * blanking the whole of line 2 — and line 2 is the line that carries the exact invocation, which
4564
- * line 1 tells the reader to regenerate with. The gate stopped verifying the one instruction it
4565
- * hands out: rollhook's header was rewritten from `--only core --no-legacy-aliases` to `--only all
4566
- * --with-legacy-aliases` and `--check` passed clean.
4567
- *
4568
- * So the version token is replaced by a sentinel and the rest of the line still gates. A release
4569
- * alone never trips the check; a changed flag, a reworded header, and a header that is simply gone
4570
- * all do.
4571
- *
4572
- * The version STAYS in the file rather than being dropped, because the consumer this command exists
4573
- * for — a framework-free site running `bunx basalt-ui tokens:css` with no basalt in its dependency
4574
- * tree at all — has no other record of which version emitted it, and no `doctor` to ask.
4575
- *
4576
- * Line-aligned, never dropped, so the reported line counts still match the files on disk. The
4577
- * `@generated` exemption is untouched by all of this: nothing here changes what
4578
- * {@link generatedHeader} emits, and {@link PROVENANCE_VERSION} is matched to the guard's own
4579
- * provenance regex rather than replacing it.
4580
- */
4581
- function withoutProvenanceVersion(css: string): string {
4582
- const lines = css.split('\n')
4583
- const line = lines[1]
4584
- if (line === undefined) return css
4585
- const match = PROVENANCE_VERSION.exec(line)
4586
- if (match === null) return css
4587
- lines[1] = `${match[1] as string}<version>${match[3] as string}`
4588
- return lines.join('\n')
4589
- }
4590
-
4591
- /** The leading `major.minor.patch` of a version string as numbers, or null when it has none. */
4592
- function versionTriple(version: string): number[] | null {
4593
- const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version)
4594
- return match === null ? null : [Number(match[1]), Number(match[2]), Number(match[3])]
4595
- }
4596
-
4597
- /**
4598
- * How the on-disk provenance version relates to the one that just ran — measured, not assumed.
4599
- *
4600
- * The success message asserted the file "still names an older basalt-ui" without ever reading the
4601
- * token, so `0.0.1-nonsense` got the same sentence. Both sides are parsed; the numeric triples are
4602
- * compared and anything that does not order cleanly is reported as merely different.
4603
- */
4604
- function describeVersionSkew(onDisk: string, current: string): string {
4605
- const a = versionTriple(onDisk)
4606
- const b = versionTriple(current)
4607
- if (a === null || b === null) return 'a different'
4608
- for (let i = 0; i < 3; i++) {
4609
- const left = a[i] as number
4610
- const right = b[i] as number
4611
- if (left < right) return 'an older'
4612
- if (left > right) return 'a newer'
4613
- }
4614
- return 'a different'
4615
- }
4616
-
4617
- /**
4618
- * Space- and number-normalize the argument list of a legacy color function so the emitted file
4619
- * survives a normal repo's formatter.
4620
- *
4621
- * This is FORMATTING ONLY and deliberately the CLI's single deviation from "print exactly what
4622
- * `buildPaletteCss` returned": `rgba(255,255,255,0.6)` and `rgba(255, 255, 255, 0.6)` are the same
4623
- * colour, the shipped palette emits the first form on the dark side and the second on the light
4624
- * side, and a consumer committing the output ate a lint-ignore entry for the difference. The token
4625
- * VALUES are untouched — nothing here can change what basalt's tokens are.
4626
- *
4627
- * Trailing zeros go the same way and for the same reason: prettier rewrites `0.10` → `0.1`, so the
4628
- * two `rgba(28, 25, 23, 0.10)` alphas in the light shadow tokens were the whole reason the one
4629
- * framework-free consumer still could not lint its committed sheet — `--fix` and `tokens:css
4630
- * --check` disagreed forever, and the lint-ignore entry survived two releases. Only a bare decimal
4631
- * literal is touched (never a `%`, a `var()` or a unit), and `0.10`/`0.1` are the same number.
4632
- *
4633
- * Exported for tests, so the expectation can be computed rather than restated.
4634
- */
4635
- export function normalizeColorFunctions(css: string): string {
4636
- return css.replace(/\b(rgba?|hsla?)\(([^()]*)\)/g, (_match, fn: string, args: string) => {
4637
- const parts = args.split(',').map((part) => normalizeCssNumber(part.trim()))
4638
- return `${fn}(${parts.join(', ')})`
4639
- })
4640
- }
4641
1828
 
4642
- /** `0.10` → `0.1`, `1.0` → `1`; anything that is not a bare decimal literal is returned verbatim. */
4643
- function normalizeCssNumber(arg: string): string {
4644
- if (!/^\d+\.\d+$/.test(arg)) return arg
4645
- return arg.replace(/\.?0+$/, '')
4646
- }
4647
-
4648
- /** A scheme-class emission uses this sentinel attribute, then rewrites it to a class selector. */
4649
- 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
+ )
4650
1834
 
4651
- /**
4652
- * Write (or drift-check) a generated stylesheet. `--check` makes no writes and exits 1 when the
4653
- * file on disk differs from what would be emitted — the CI gate for a committed `tokens:css`
4654
- * artifact, mirroring `sync --check`. Without `--out` the content goes to stdout unchanged.
4655
- */
4656
- function emitGeneratedCss(content: string, flags: string[], cwd: string, command: string): number {
4657
- const out = flagValue(flags, '--out')
4658
- const check = flags.includes('--check')
4659
- if (out === undefined) {
4660
- if (check) {
4661
- console.error(
4662
- `${command}: --check needs --out <path> — there is nothing to compare stdout to.`,
4663
- )
4664
- return 1
4665
- }
4666
- process.stdout.write(content)
4667
- return 0
4668
- }
4669
- const target = isAbsolute(out) ? out : resolve(cwd, out)
4670
- const shown = relative(cwd, target) || target
4671
- if (check) {
4672
- const onDisk = readIfExists(target)
4673
- if (onDisk === null) {
4674
- console.error(
4675
- `✖ ${command} --check: ${shown} does not exist — run without --check to write it.`,
4676
- )
4677
- return 1
4678
- }
4679
- if (withoutProvenanceVersion(onDisk) === withoutProvenanceVersion(content)) {
4680
- const diskVersion = provenanceVersion(onDisk)
4681
- const currentVersion = provenanceVersion(content)
4682
- const skew =
4683
- diskVersion !== null && currentVersion !== null && diskVersion !== currentVersion
4684
- ? ` (Its provenance line names basalt-ui ${diskVersion}, ` +
4685
- `${describeVersionSkew(diskVersion, currentVersion)} release than the ` +
4686
- `${currentVersion} that just ran — only that version token is excluded from the ` +
4687
- 'comparison. Re-run without --check whenever you want it refreshed.)'
4688
- : ''
4689
- console.log(`✓ ${command} --check: ${shown} is up to date.${skew}`)
4690
- return 0
4691
- }
4692
- // Name the provenance line when it is the ONLY thing that moved: the diff is one line the
4693
- // reader is about to scroll past, and a rewritten invocation is the failure that bought this
4694
- // branch — line 1 tells them to regenerate with line 2, so line 2 has to be true.
4695
- const diskLines = onDisk.split('\n')
4696
- const wantLines = content.split('\n')
4697
- const provenanceOnly =
4698
- diskLines.length === wantLines.length &&
4699
- diskLines.every((line, i) => i === 1 || line === wantLines[i]) &&
4700
- diskLines[1] !== wantLines[1]
4701
- if (provenanceOnly) {
4702
- console.error(
4703
- `✖ ${command} --check: ${shown}'s \`@generated\` provenance line does not match the ` +
4704
- 'command that produced this content — the token values agree, line 2 does not.\n' +
4705
- ` on disk: ${diskLines[1]}\n` +
4706
- ` emitted: ${wantLines[1]}\n` +
4707
- 'Line 1 tells the reader to regenerate with line 2, so line 2 is gated: run the emitted ' +
4708
- 'invocation without --check and commit the result, or change the flags this check runs ' +
4709
- 'with to the ones the file was really built from. Only the version token is exempt.',
4710
- )
4711
- 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)
4712
1841
  }
4713
- console.error(
4714
- `✖ ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today ` +
4715
- `(on disk ${diskLines.length} lines, emitted ${wantLines.length}) — ` +
4716
- 're-run the same command without --check and commit the result. Only the `@generated` ' +
4717
- "provenance line's VERSION token is excluded from this comparison, so a release alone " +
4718
- 'never trips it — but its flags do.',
4719
- )
4720
- return 1
4721
- }
4722
- mkdirSync(dirname(target), { recursive: true })
4723
- writeFileSync(target, content)
4724
- console.log(`wrote ${content.split('\n').length} lines → ${shown}`)
4725
- return 0
4726
- }
4727
-
4728
- /**
4729
- * tokens:css — emit the `--vx-*` stylesheet, optionally retargeted.
4730
- *
4731
- * The escape hatch from installing anything: a static site runs this once (`bunx basalt-ui
4732
- * tokens:css --selector-attribute data-theme --out src/tokens.css`) and consumes basalt's token
4733
- * system as plain CSS, with no package in its dependency tree at all. `basalt-ui/tokens.css` is the
4734
- * same artifact for a consumer that does install; this command exists for the ones that shouldn't
4735
- * have to just to change a selector.
4736
- *
4737
- * It parses flags and calls `buildPaletteCss` for the token VALUES — the CLI and the API must not
4738
- * be able to disagree about what basalt's tokens are. What it adds is strictly file framing for an
4739
- * artifact a consumer COMMITS: the `@generated` header (the guard's skip marker), a trailing
4740
- * newline, and `rgba()` argument spacing. All three were reported by the one framework-free
4741
- * consumer as the reasons the output could not simply be committed.
4742
- */
4743
- export function tokensCss(flags: string[], cwd: string = process.cwd()): number {
4744
- const attribute = flagValue(flags, '--selector-attribute')
4745
- const schemeClass = flagValue(flags, '--selector-class')
4746
- const lightClass = flagValue(flags, '--light-class')
4747
- const darkValue = flagValue(flags, '--dark-value')
4748
- const lightValue = flagValue(flags, '--light-value')
4749
- const defaultScheme = flagValue(flags, '--default-scheme')
4750
-
4751
- const only = flagValue(flags, '--only')
4752
-
4753
- if (defaultScheme !== undefined && !['dark', 'light', 'none'].includes(defaultScheme)) {
4754
- console.error(
4755
- `tokens:css: --default-scheme must be dark, light or none (got '${defaultScheme}')`,
4756
- )
4757
- return 1
4758
- }
4759
- if (only !== undefined && !['core', 'all'].includes(only)) {
4760
- console.error(`tokens:css: --only must be core or all (got '${only}')`)
4761
- return 1
4762
- }
4763
- if (schemeClass !== undefined && attribute !== undefined) {
4764
- console.error(
4765
- 'tokens:css: --selector-class and --selector-attribute are alternatives — pass one.',
4766
- )
4767
- return 1
4768
- }
4769
- if (schemeClass !== undefined && /[^\w-]/.test(schemeClass)) {
4770
- console.error(
4771
- `tokens:css: --selector-class must be a plain CSS class name (got '${schemeClass}')`,
4772
- )
4773
- return 1
4774
- }
4775
- if (lightClass !== undefined && /[^\w-]/.test(lightClass)) {
4776
- console.error(`tokens:css: --light-class must be a plain CSS class name (got '${lightClass}')`)
4777
- return 1
4778
- }
4779
-
4780
- // A class selector is Tailwind's universal dark convention (`<html class="dark">`) and was
4781
- // reachable before only by parking dark on the bare `:root` via --default-scheme. `buildPaletteCss`
4782
- // emits attribute selectors, so the class form is produced by emitting against a sentinel
4783
- // attribute this CLI chose itself and rewriting exactly those selectors — a rewrite of strings
4784
- // the command generated deterministically, never of a token value.
4785
- const resolvedDarkValue = schemeClass !== undefined ? schemeClass : darkValue
4786
- const resolvedLightValue = schemeClass !== undefined ? (lightClass ?? 'light') : lightValue
4787
- const resolvedAttribute = schemeClass !== undefined ? SCHEME_CLASS_SENTINEL : attribute
4788
-
4789
- const scheme = {
4790
- ...(resolvedAttribute === undefined ? {} : { attribute: resolvedAttribute }),
4791
- ...(resolvedDarkValue === undefined ? {} : { darkValue: resolvedDarkValue }),
4792
- ...(resolvedLightValue === undefined ? {} : { lightValue: resolvedLightValue }),
4793
- }
4794
-
4795
- let css = buildPaletteCss({
4796
- ...(Object.keys(scheme).length === 0 ? {} : { scheme }),
4797
- ...(defaultScheme === undefined
4798
- ? {}
4799
- : { defaultScheme: defaultScheme as 'dark' | 'light' | 'none' }),
4800
- ...(flags.includes('--media-fallback') ? { mediaFallback: true } : {}),
4801
- ...(only === undefined ? {} : { only: only as 'core' | 'all' }),
4802
- // The tokens-only consumer is exactly the one who writes these names by hand, so they are also
4803
- // the one who wants the deprecated camelCase aliases gone — and this command is their only
4804
- // entry point. Without the flag the CLI and the API could disagree about what basalt's tokens
4805
- // are, which is the one thing this command is documented never to allow.
4806
- ...(flags.includes('--no-legacy-aliases') ? { legacyAliases: false } : {}),
4807
- })
4808
-
4809
- if (schemeClass !== undefined) {
4810
- css = css.replace(
4811
- new RegExp(`\\[${SCHEME_CLASS_SENTINEL}='([\\w-]+)'\\]`, 'g'),
4812
- (_match, value: string) => `.${value}`,
4813
- )
4814
- }
4815
-
4816
- const version = readFrameworkVersion(packageRoot())
4817
- const content = `${generatedHeader(version, 'tokens:css', flags)}${normalizeColorFunctions(css)}\n`
4818
- return emitGeneratedCss(content, flags, cwd, 'tokens:css')
4819
- }
4820
-
4821
- /** Every `--basalt-font-*` declaration in the shipped stylesheet, in source order. */
4822
- function readShippedFontDecls(pkgRoot: string): { name: string; value: string }[] {
4823
- const css = readSource(pkgRoot, 'dist/styles.css') ?? readSource(pkgRoot, 'src/styles.css')
4824
- if (css === null) return []
4825
- const decls: { name: string; value: string }[] = []
4826
- for (const match of css.matchAll(/(--basalt-font[\w-]*)\s*:\s*([^;]+);/g)) {
4827
- decls.push({
4828
- name: match[1] as string,
4829
- value: (match[2] as string).replace(/\s+/g, ' ').trim(),
4830
- })
4831
- }
4832
- return decls
4833
- }
4834
-
4835
- /**
4836
- * fonts:css — emit the shipped `--basalt-font-*` stacks as plain CSS.
4837
- *
4838
- * The typeface half of the framework-free route, which had no supported path at all: `tokens.css`
4839
- * emits no font vars, `styles.css` is the one place the defaults live and a framework-free consumer
4840
- * is told not to import it, and `buildFontsCss` returns `''` unless the caller already knows the
4841
- * stacks. The one non-Mantine consumer lost its font identity entirely and hardcoded the stacks —
4842
- * which the theme guard then flagged as `raw-font-family`.
4843
- *
4844
- * The declarations are READ from the shipped stylesheet rather than restated here, so this command
4845
- * and `basalt-ui/styles.css` can never name different typefaces. Note the fonts themselves are
4846
- * `@fontsource-variable/*` packages: without them the stacks fall through to their own system
4847
- * fallbacks, which is the intended framework-free behaviour.
4848
- */
4849
- export function fontsCss(flags: string[], cwd: string = process.cwd()): number {
4850
- const pkgRoot = packageRoot()
4851
- const decls = readShippedFontDecls(pkgRoot)
4852
- if (decls.length === 0) {
4853
- console.error(
4854
- 'fonts:css: could not read the shipped font stacks from styles.css — this is a packaging ' +
4855
- 'bug in basalt-ui, not a config error.',
4856
- )
4857
- return 1
4858
1842
  }
4859
- const body = `:root {\n${decls.map((d) => ` ${d.name}: ${d.value};`).join('\n')}\n}\n`
4860
- const header = generatedHeader(readFrameworkVersion(pkgRoot), 'fonts:css', flags)
4861
- return emitGeneratedCss(`${header}${body}`, flags, cwd, 'fonts:css')
1843
+ return dirs
4862
1844
  }
4863
1845
 
4864
1846
  /**
4865
- * guard-hook — PreToolUse stdin adapter.
4866
- *
4867
- * Reads a JSON PreToolUse payload from stdin, evaluates it against the consumer's GuardConfig
4868
- * (from the "basalt" key in the nearest package.json), and writes the Claude Code hook response
4869
- * to stdout. Always exits 0 — the hook must never block Claude on a parse error or non-file tool.
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.
4870
1856
  */
4871
- export async function guardHook(cwd: string = process.cwd()): Promise<number> {
4872
- let raw: string
4873
- try {
4874
- // Bun: Bun.stdin.text() drains stdin to a string; under Node fall back to manual drain.
4875
- if (typeof (globalThis as Record<string, unknown>)['Bun'] !== 'undefined') {
4876
- raw = await globalThis['Bun'].stdin.text()
4877
- } else {
4878
- const chunks: Buffer[] = []
4879
- for await (const chunk of process.stdin) chunks.push(chunk as Buffer)
4880
- raw = Buffer.concat(chunks).toString('utf8')
4881
- }
4882
- } catch {
4883
- // Unreadable stdin → allow
4884
- process.stdout.write(
4885
- '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
4886
- )
4887
- return 0
4888
- }
1857
+ function collectWorkspacePackages(cwd: string): WorkspaceWalk {
1858
+ const raw = readIfExists(resolve(cwd, 'package.json'))
1859
+ if (raw === null) return { packages: [], hasWorkspacesField: false }
4889
1860
 
4890
- let payload: unknown
1861
+ let parsed: { workspaces?: unknown; name?: string }
4891
1862
  try {
4892
- payload = JSON.parse(raw)
1863
+ parsed = JSON.parse(raw) as { workspaces?: unknown; name?: string }
4893
1864
  } catch {
4894
- // Malformed JSON → allow (never block on a bad payload)
4895
- process.stdout.write(
4896
- '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
4897
- )
4898
- return 0
1865
+ return { packages: [], hasWorkspacesField: false }
4899
1866
  }
4900
1867
 
4901
- const cfg = readBasaltConfig(cwd)
4902
- const guardCfg: GuardConfig = {
4903
- spacingSteps: cfg.spacingSteps ?? DEFAULT_GUARD_CONFIG.spacingSteps,
4904
- rawRadius: cfg.rawRadius ?? DEFAULT_GUARD_CONFIG.rawRadius,
4905
- forbiddenAccents: cfg.forbiddenAccents ?? DEFAULT_GUARD_CONFIG.forbiddenAccents,
4906
- mantineShadeIndex: cfg.mantineShadeIndex ?? DEFAULT_GUARD_CONFIG.mantineShadeIndex,
4907
- rawSurface: cfg.rawSurface ?? DEFAULT_GUARD_CONFIG.rawSurface,
4908
- cardWithBorder: cfg.cardWithBorder ?? DEFAULT_GUARD_CONFIG.cardWithBorder,
4909
- offSystemSurfaceVar: cfg.offSystemSurfaceVar ?? DEFAULT_GUARD_CONFIG.offSystemSurfaceVar,
4910
- rawHtmlLayout: cfg.rawHtmlLayout ?? DEFAULT_GUARD_CONFIG.rawHtmlLayout,
4911
- inlineSpacing: cfg.inlineSpacing ?? DEFAULT_GUARD_CONFIG.inlineSpacing,
4912
- inlineDisplay: cfg.inlineDisplay ?? DEFAULT_GUARD_CONFIG.inlineDisplay,
4913
- rawVisxAxis: cfg.rawVisxAxis ?? DEFAULT_GUARD_CONFIG.rawVisxAxis,
4914
- rawMotionValue: cfg.rawMotionValue ?? DEFAULT_GUARD_CONFIG.rawMotionValue,
4915
- unframedChart: cfg.unframedChart ?? DEFAULT_GUARD_CONFIG.unframedChart,
4916
- chartMissingAriaLabel: cfg.chartMissingAriaLabel ?? DEFAULT_GUARD_CONFIG.chartMissingAriaLabel,
4917
- rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
4918
- sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
4919
- allowComment: 'theme-allow',
4920
- exemptRules: resolveExemptRules(cfg),
4921
- severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
4922
- // Same detection check-theme uses: the hook must never block an edit over advice the app
4923
- // cannot take ("use @mantine/core's Select") in a repo with no Mantine.
4924
- ...(declaredProfile(cfg, []) === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
4925
- }
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 }
4926
1876
 
4927
- // Honor the consumer's roots / exempt / skip config so the hook never blocks edits to exempted
4928
- // palette source or files outside the guarded roots (mirrors checkTheme's file-walk scoping).
4929
- const roots = cfg.roots ?? DEFAULT_ROOTS
4930
- const exempt = new Set(cfg.exempt ?? defaultExempt(cfg))
4931
- const isInScope = (filePath: string): boolean => {
4932
- const abs = isAbsolute(filePath) ? filePath : resolve(cwd, filePath)
4933
- const rel = relative(cwd, abs).replace(/\\/g, '/')
4934
- if (rel === '' || rel.startsWith('..')) return false
4935
- if (SKIP.test(rel) || exempt.has(rel)) return false
4936
- return roots.some((root) => {
4937
- const r = root.replace(/\\/g, '/').replace(/\/+$/, '')
4938
- return rel === r || rel.startsWith(`${r}/`)
4939
- })
4940
- }
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
+ }))
4941
1882
 
4942
- const result = evaluateGuardHook(payload, guardCfg, { isInScope })
4943
-
4944
- if (result.permissionDecision === 'deny' && result.reason !== undefined) {
4945
- process.stdout.write(
4946
- JSON.stringify({
4947
- hookSpecificOutput: {
4948
- hookEventName: 'PreToolUse',
4949
- permissionDecision: 'deny',
4950
- permissionDecisionReason: result.reason,
4951
- },
4952
- }) + '\n',
4953
- )
4954
- } else {
4955
- process.stdout.write(
4956
- '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
4957
- )
1883
+ return {
1884
+ packages: [{ name: rootName, dir: cwd }, ...workspacePackages],
1885
+ hasWorkspacesField: true,
4958
1886
  }
4959
- return 0
4960
- }
4961
-
4962
- /**
4963
- * Every flag each subcommand accepts — the table the unknown-flag gate reads.
4964
- *
4965
- * An unrecognized flag used to be SILENTLY IGNORED and the command exited 0: `check-theme
4966
- * --audit-allow` scanned normally and reported success, `doctor --json` printed the human report.
4967
- * That is the same fail-open shape `--version` had, and it is worse on a CLI agents drive
4968
- * programmatically, where a typo'd gate reads as a passing gate.
4969
- */
4970
- const COMMAND_FLAGS: Record<string, readonly string[]> = {
4971
- init: ['--with-router', '--with-query', '--merge-lint'],
4972
- sync: ['--force', '--check', '--tokens-only', '--framework'],
4973
- 'check-theme': ['--audit-allows', '--tokens-only', '--framework'],
4974
- 'check-coverage': ['--write', '--check'],
4975
- info: ['--json'],
4976
- doctor: ['--tokens-only', '--framework'],
4977
- 'guard-hook': [],
4978
- 'tokens:css': [
4979
- '--out',
4980
- '--check',
4981
- '--selector-attribute',
4982
- '--selector-class',
4983
- '--light-class',
4984
- '--dark-value',
4985
- '--light-value',
4986
- '--default-scheme',
4987
- '--media-fallback',
4988
- '--only',
4989
- '--no-legacy-aliases',
4990
- ],
4991
- 'fonts:css': ['--out', '--check'],
4992
1887
  }
4993
1888
 
4994
- /** Flags whose VALUE is the next argv token — skipped by the gate so `--out dist/x.css` does not
4995
- * read `dist/x.css` as a second flag. */
4996
- const VALUE_FLAGS = new Set([
4997
- '--out',
4998
- '--selector-attribute',
4999
- '--selector-class',
5000
- '--light-class',
5001
- '--dark-value',
5002
- '--light-value',
5003
- '--default-scheme',
5004
- '--only',
5005
- ])
5006
-
5007
- /** The first flag `cmd` does not accept, or null. Unknown commands return null — the dispatcher's
5008
- * default branch already names those. */
5009
- function unknownFlag(cmd: string, flags: readonly string[]): string | null {
5010
- const allowed = COMMAND_FLAGS[cmd]
5011
- if (allowed === undefined) return null
5012
- for (let i = 0; i < flags.length; i++) {
5013
- const flag = flags[i] as string
5014
- if (!flag.startsWith('-')) continue
5015
- if (!allowed.includes(flag)) return flag
5016
- if (VALUE_FLAGS.has(flag)) i++
5017
- }
5018
- 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')
5019
1892
  }
5020
1893
 
5021
- /** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
5022
- const USAGE =
5023
- 'Usage: basalt-ui <--version | init [--with-router] [--with-query] [--merge-lint] |\n' +
5024
- ' sync [--force] [--check] [--tokens-only|--framework] |\n' +
5025
- ' check-theme [--audit-allows] | check-coverage [--write|--check] |\n' +
5026
- ' info [--json] |\n' +
5027
- ' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
5028
- 'check-theme [--tokens-only|--framework] [--audit-allows]\n' +
5029
- ' --audit-allows reports instead of scanning: every `theme-allow` annotation and every\n' +
5030
- ' `basalt.exemptRules` entry, with what each one still suppresses — proved by re-running the\n' +
5031
- ' guard with that one waiver neutralized, not by reading its text. Exits 1 on a waiver that\n' +
5032
- ' suppresses nothing (dead), which is what makes it usable as a CI gate.\n\n' +
5033
- 'tokens:css [--out <path>] [--check] [--selector-attribute <attr> | --selector-class <class>]\n' +
5034
- ' [--light-class <class>] [--dark-value <v>] [--light-value <v>]\n' +
5035
- ' [--default-scheme <dark|light|none>] [--media-fallback] [--only <core|all>]\n' +
5036
- ' [--no-legacy-aliases]\n' +
5037
- ' Emit the --vx-* stylesheet (stdout unless --out). Defaults reproduce basalt-ui/tokens.css.\n' +
5038
- ' --selector-class emits `:root.dark` instead of an attribute selector (the Tailwind convention).\n' +
5039
- ' --check writes nothing and exits 1 when --out differs from what would be emitted (a CI gate).\n' +
5040
- ' --no-legacy-aliases drops the deprecated camelCase spellings (--vx-accentFill and friends),\n' +
5041
- ' which are emitted by default as aliases of the canonical kebab-case names.\n\n' +
5042
- 'fonts:css [--out <path>] [--check]\n' +
5043
- ' Emit the shipped --basalt-font-* stacks as plain CSS — the typeface half of the token layer,\n' +
5044
- ' otherwise reachable only by importing styles.css.\n\n' +
5045
- 'doctor [--tokens-only|--framework]\n' +
5046
- ' Auto-detects a tokens-only consumer (no manifest + no @mantine/core) and checks only what\n' +
5047
- ' applies. A check that cannot RUN is reported as SKIPPED and exits non-zero.\n\n' +
5048
- 'sync [--force] [--check] [--tokens-only|--framework]\n' +
5049
- ' Refreshes an EXISTING install; it never creates one (that is init). A tokens-only consumer\n' +
5050
- ' has no scaffold to reconcile, so sync reports n/a and exits 0 there — as doctor does.\n\n' +
5051
- 'check-theme / doctor / sync all resolve their project the same way: they honour BASALT_CWD, and\n' +
5052
- 'relocate to the single package below the cwd carrying a basalt config when invoked from a repo\n' +
5053
- 'root that has none — several carrying one is ambiguous and exits 1 in ALL THREE.\n' +
5054
- '--tokens-only and --framework are alternatives; passing both is an error.\n\n' +
5055
- 'Every subcommand accepts --help / -h to print this message and exit without running.\n' +
5056
- '--version / -v prints the resolved package version and exits 0 — the one command that proves\n' +
5057
- 'WHICH basalt-ui ran, which a `bunx` cache can otherwise make a lie. An unknown command or an\n' +
5058
- 'unrecognized flag exits 1 and names what it did not understand; neither is ever ignored.'
5059
-
5060
1894
  /**
5061
1895
  * CLI dispatcher — parses argv (subcommand + flags) and returns the command's exit code. The bin
5062
1896
  * entry is the ONLY caller that translates this to process.exit, so init/sync/checkTheme stay free
@@ -5091,9 +1925,10 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
5091
1925
  if (cmd !== undefined) {
5092
1926
  const bad = unknownFlag(cmd, flags)
5093
1927
  if (bad !== null) {
1928
+ const accepted = Object.keys(COMMAND_OPTIONS[cmd] ?? {}).map((k) => `--${k}`)
5094
1929
  console.error(
5095
1930
  `basalt-ui ${cmd}: unrecognized flag '${bad}'. ` +
5096
- `Accepted: ${(COMMAND_FLAGS[cmd] ?? []).join(' ') || '(none)'} --help\n`,
1931
+ `Accepted: ${accepted.join(' ') || '(none)'} --help\n`,
5097
1932
  )
5098
1933
  return 1
5099
1934
  }
@@ -5112,10 +1947,6 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
5112
1947
  )
5113
1948
  case 'check-theme':
5114
1949
  return checkTheme(cwd, flags)
5115
- case 'check-coverage':
5116
- return checkCoverage(flags)
5117
- case 'info':
5118
- return info(flags)
5119
1950
  case 'doctor':
5120
1951
  return doctor(cwd, flags)
5121
1952
  case 'guard-hook':
@@ -5138,8 +1969,12 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
5138
1969
  }
5139
1970
  }
5140
1971
 
5141
- // Re-export the managed-file manifest for testing / introspection (no default export).
5142
- // `normalizeForLedger` is exported so tests can compute a fixture's expected ledger hash without
5143
- // duplicating the normalization algorithm — it stays call-site-internal to writeUnit/classify.
5144
- export { managedFiles, MANIFEST_PATH, normalizeForLedger, RULE_NAMES, SKILL_NAMES }
5145
- export type { ManagedFile, Manifest, SyncOptions, DoctorResult }
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'