banhaten 0.1.3 → 0.3.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 (275) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/CONTRIBUTING.md +44 -0
  3. package/README.md +65 -13
  4. package/SECURITY.md +15 -0
  5. package/banhaten.config.example.json +0 -2
  6. package/docs/design-system/README.md +9 -0
  7. package/docs/design-system/adoption-quality-remediation.md +87 -0
  8. package/docs/design-system/agent-usage.md +13 -0
  9. package/docs/design-system/appearance-presets.md +6 -1
  10. package/docs/design-system/component-docs-pattern.md +81 -0
  11. package/docs/design-system/component-implementation-ownership-audit.md +244 -0
  12. package/docs/design-system/component-quality-audit-2026-08-20.md +223 -0
  13. package/docs/design-system/component-showcase-consistency-report.md +2 -0
  14. package/docs/design-system/component-token-consistency-audit.md +13 -3
  15. package/docs/design-system/components/README.md +4 -2
  16. package/docs/design-system/components/accordion.md +16 -0
  17. package/docs/design-system/components/activity-feed.md +17 -1
  18. package/docs/design-system/components/activity-heatmap.md +73 -0
  19. package/docs/design-system/components/alert-dialog.md +17 -0
  20. package/docs/design-system/components/alert.md +46 -14
  21. package/docs/design-system/components/aspect-ratio.md +15 -0
  22. package/docs/design-system/components/attribute.md +16 -0
  23. package/docs/design-system/components/autocomplete.md +19 -0
  24. package/docs/design-system/components/avatar.md +18 -0
  25. package/docs/design-system/components/badge.md +19 -0
  26. package/docs/design-system/components/banner.md +19 -1
  27. package/docs/design-system/components/breadcrumbs.md +17 -0
  28. package/docs/design-system/components/button-group.md +35 -0
  29. package/docs/design-system/components/button.md +18 -0
  30. package/docs/design-system/components/card.md +26 -1
  31. package/docs/design-system/components/carousel.md +20 -3
  32. package/docs/design-system/components/catalog-components.md +18 -1
  33. package/docs/design-system/components/chart.md +15 -0
  34. package/docs/design-system/components/checkbox.md +16 -0
  35. package/docs/design-system/components/collapsible.md +15 -0
  36. package/docs/design-system/components/command.md +15 -0
  37. package/docs/design-system/components/context-menu.md +15 -0
  38. package/docs/design-system/components/date-picker.md +21 -1
  39. package/docs/design-system/components/divider.md +16 -0
  40. package/docs/design-system/components/empty-state.md +17 -0
  41. package/docs/design-system/components/field.md +17 -0
  42. package/docs/design-system/components/file-upload.md +18 -0
  43. package/docs/design-system/components/hover-card.md +15 -0
  44. package/docs/design-system/components/input-group.md +17 -0
  45. package/docs/design-system/components/input-otp.md +18 -0
  46. package/docs/design-system/components/input.md +43 -7
  47. package/docs/design-system/components/item.md +72 -0
  48. package/docs/design-system/components/kbd.md +18 -0
  49. package/docs/design-system/components/label.md +16 -0
  50. package/docs/design-system/components/menu.md +17 -0
  51. package/docs/design-system/components/menubar.md +15 -0
  52. package/docs/design-system/components/modal.md +25 -4
  53. package/docs/design-system/components/native-select.md +18 -0
  54. package/docs/design-system/components/navigation-menu.md +15 -0
  55. package/docs/design-system/components/onboarding-step-list-item.md +16 -1
  56. package/docs/design-system/components/page-header.md +17 -1
  57. package/docs/design-system/components/pagination.md +31 -8
  58. package/docs/design-system/components/popover.md +19 -0
  59. package/docs/design-system/components/progress-slider.md +18 -0
  60. package/docs/design-system/components/progress.md +17 -0
  61. package/docs/design-system/components/radio-card.md +16 -0
  62. package/docs/design-system/components/radio-group.md +17 -0
  63. package/docs/design-system/components/resizable.md +15 -0
  64. package/docs/design-system/components/scroll-area.md +20 -1
  65. package/docs/design-system/components/segmented-control.md +71 -0
  66. package/docs/design-system/components/select.md +44 -7
  67. package/docs/design-system/components/sheet.md +17 -0
  68. package/docs/design-system/components/sidebar.md +17 -0
  69. package/docs/design-system/components/skeleton.md +18 -0
  70. package/docs/design-system/components/slideout.md +17 -0
  71. package/docs/design-system/components/slider.md +20 -0
  72. package/docs/design-system/components/social-button.md +21 -3
  73. package/docs/design-system/components/spinner.md +16 -0
  74. package/docs/design-system/components/steps.md +19 -0
  75. package/docs/design-system/components/table.md +36 -4
  76. package/docs/design-system/components/tabs.md +17 -0
  77. package/docs/design-system/components/tag.md +17 -0
  78. package/docs/design-system/components/textarea.md +16 -0
  79. package/docs/design-system/components/timeline.md +17 -0
  80. package/docs/design-system/components/toast.md +17 -0
  81. package/docs/design-system/components/toggle.md +17 -0
  82. package/docs/design-system/components/toolbar.md +21 -1
  83. package/docs/design-system/components/tooltip.md +18 -1
  84. package/docs/design-system/density-and-elevation.md +61 -0
  85. package/docs/design-system/direction-and-localization.md +23 -0
  86. package/docs/design-system/form-recipes.md +73 -0
  87. package/docs/design-system/gap-promotion-policy.md +21 -0
  88. package/docs/design-system/gap-promotion-report.md +100 -0
  89. package/docs/design-system/installation.md +68 -0
  90. package/docs/design-system/launch-comparison-shadcn.md +98 -0
  91. package/docs/design-system/registry-and-cli.md +30 -7
  92. package/docs/design-system/release-policy.md +30 -0
  93. package/docs/design-system/roadmap.md +14 -0
  94. package/docs/design-system/support-matrix.md +23 -0
  95. package/docs/design-system/tabs.md +1 -1
  96. package/docs/design-system/token-governance.md +23 -1
  97. package/package.json +30 -15
  98. package/registry/component-contract.json +40 -0
  99. package/registry/components/alert-dialog.tsx +2 -2
  100. package/registry/components/alert.tsx +101 -76
  101. package/registry/components/autocomplete.tsx +23 -9
  102. package/registry/components/avatar.tsx +14 -10
  103. package/registry/components/badge.tsx +18 -18
  104. package/registry/components/button-group.tsx +324 -5
  105. package/registry/components/button.tsx +38 -17
  106. package/registry/components/card.tsx +87 -62
  107. package/registry/components/carousel.tsx +67 -5
  108. package/registry/components/chart.tsx +36 -3
  109. package/registry/components/checkbox.tsx +3 -3
  110. package/registry/components/collapsible.tsx +1 -1
  111. package/registry/components/command.tsx +38 -4
  112. package/registry/components/context-menu.tsx +1 -1
  113. package/registry/components/date-picker.tsx +312 -135
  114. package/registry/components/expanded/ActivityFeed.tsx +9 -22
  115. package/registry/components/expanded/ActivityHeatmap.tsx +64 -0
  116. package/registry/components/expanded/Banner.tsx +3 -4
  117. package/registry/components/expanded/Breadcrumbs.tsx +8 -5
  118. package/registry/components/expanded/CatalogComponentsShowcase.tsx +16 -17
  119. package/registry/components/expanded/CatalogDivider.tsx +4 -3
  120. package/registry/components/expanded/EmptyState.tsx +1 -1
  121. package/registry/components/expanded/FileUpload.tsx +16 -7
  122. package/registry/components/expanded/OnboardingStepListItem.tsx +2 -6
  123. package/registry/components/expanded/PageHeader.tsx +47 -56
  124. package/registry/components/expanded/Slideout.tsx +29 -424
  125. package/registry/components/expanded/Steps.tsx +151 -13
  126. package/registry/components/expanded/Table.tsx +161 -82
  127. package/registry/components/expanded/Timeline.tsx +14 -27
  128. package/registry/components/expanded/activityFeed.css +9 -9
  129. package/registry/components/expanded/activityHeatmap.css +66 -0
  130. package/registry/components/expanded/banner.css +21 -11
  131. package/registry/components/expanded/breadcrumbs.css +2 -2
  132. package/registry/components/expanded/divider.css +1 -1
  133. package/registry/components/expanded/emptyState.css +1 -1
  134. package/registry/components/expanded/fileUpload.css +7 -7
  135. package/registry/components/expanded/onboardingStepListItem.css +2 -2
  136. package/registry/components/expanded/pageHeader.css +14 -11
  137. package/registry/components/expanded/slideout.css +10 -690
  138. package/registry/components/expanded/steps.css +78 -15
  139. package/registry/components/expanded/table.css +60 -69
  140. package/registry/components/expanded/timeline.css +19 -18
  141. package/registry/components/field.tsx +61 -8
  142. package/registry/components/input-content.ts +1 -0
  143. package/registry/components/input-otp.tsx +13 -3
  144. package/registry/components/input.tsx +68 -43
  145. package/registry/components/item.tsx +189 -0
  146. package/registry/components/kbd.tsx +111 -4
  147. package/registry/components/menu.tsx +87 -251
  148. package/registry/components/menubar.tsx +1 -1
  149. package/registry/components/modal.tsx +143 -103
  150. package/registry/components/native-select.tsx +2 -2
  151. package/registry/components/navigation-menu.tsx +1 -1
  152. package/registry/components/pagination.css +36 -0
  153. package/registry/components/pagination.tsx +88 -64
  154. package/registry/components/popover.tsx +162 -664
  155. package/registry/components/progress-slider.tsx +1 -1
  156. package/registry/components/radio-card.tsx +116 -87
  157. package/registry/components/resizable.tsx +36 -2
  158. package/registry/components/scroll-area.tsx +30 -6
  159. package/registry/components/segmented-control.tsx +87 -0
  160. package/registry/components/select-content.tsx +2 -2
  161. package/registry/components/select.tsx +86 -27
  162. package/registry/components/sheet.tsx +1 -1
  163. package/registry/components/sidebar.tsx +12 -6
  164. package/registry/components/skeleton.css +0 -3
  165. package/registry/components/social-button.tsx +59 -30
  166. package/registry/components/spinner.tsx +18 -55
  167. package/registry/components/table-elements.tsx +128 -0
  168. package/registry/components/tabs.css +1 -1
  169. package/registry/components/tabs.tsx +13 -14
  170. package/registry/components/tag.tsx +3 -3
  171. package/registry/components/textarea.tsx +3 -3
  172. package/registry/components/toast.tsx +16 -11
  173. package/registry/components/toggle.tsx +4 -4
  174. package/registry/components/toolbar.tsx +94 -22
  175. package/registry/components/tooltip.tsx +2 -2
  176. package/registry/examples/alert-demo.tsx +25 -5
  177. package/registry/examples/attribute-demo.tsx +1 -1
  178. package/registry/examples/autocomplete-demo.tsx +3 -3
  179. package/registry/examples/avatar-demo.tsx +14 -11
  180. package/registry/examples/badge-demo.tsx +1 -1
  181. package/registry/examples/button-demo.tsx +6 -6
  182. package/registry/examples/button-group-demo.tsx +23 -2
  183. package/registry/examples/card-demo.tsx +46 -8
  184. package/registry/examples/carousel-demo.tsx +20 -2
  185. package/registry/examples/chart-demo.tsx +1 -1
  186. package/registry/examples/checkbox-demo.tsx +5 -5
  187. package/registry/examples/collection-filter-bar-demo.tsx +135 -0
  188. package/registry/examples/command-demo.tsx +3 -3
  189. package/registry/examples/{expanded/command-bar-demo.tsx → command-palette-demo.tsx} +5 -15
  190. package/registry/examples/context-menu-demo.tsx +1 -1
  191. package/registry/examples/control-density-contract-demo.tsx +61 -0
  192. package/registry/examples/date-picker-demo.tsx +12 -12
  193. package/registry/examples/divider-demo.tsx +3 -3
  194. package/registry/examples/expanded/activity-heatmap-demo.tsx +29 -0
  195. package/registry/examples/expanded/banner-demo.tsx +1 -1
  196. package/registry/examples/expanded/breadcrumbs-demo.tsx +4 -4
  197. package/registry/examples/expanded/empty-state-demo.tsx +1 -1
  198. package/registry/examples/expanded/file-upload-demo.tsx +1 -1
  199. package/registry/examples/expanded/onboarding-step-demo.tsx +1 -1
  200. package/registry/examples/expanded/slideout-demo.tsx +2 -2
  201. package/registry/examples/expanded/steps-demo.tsx +1 -1
  202. package/registry/examples/expanded/tabs-demo.tsx +1 -1
  203. package/registry/examples/expanded/timeline-demo.tsx +1 -1
  204. package/registry/examples/hover-card-demo.tsx +2 -2
  205. package/registry/examples/input-demo.tsx +6 -6
  206. package/registry/examples/input-otp-demo.tsx +4 -4
  207. package/registry/examples/item-demo.tsx +46 -0
  208. package/registry/examples/kbd-demo.tsx +1 -1
  209. package/registry/examples/label-demo.tsx +1 -1
  210. package/registry/examples/menu-demo.tsx +1 -1
  211. package/registry/examples/modal-demo.tsx +67 -9
  212. package/registry/examples/navigation-menu-demo.tsx +1 -1
  213. package/registry/examples/pagination-demo.tsx +10 -7
  214. package/registry/examples/popover-demo.tsx +19 -19
  215. package/registry/examples/progress-demo.tsx +1 -1
  216. package/registry/examples/progress-slider-demo.tsx +1 -1
  217. package/registry/examples/radio-card-demo.tsx +4 -4
  218. package/registry/examples/radio-group-demo.tsx +2 -2
  219. package/registry/examples/resizable-demo.tsx +1 -1
  220. package/registry/examples/results-toolbar-demo.tsx +44 -0
  221. package/registry/examples/scroll-area-demo.tsx +3 -3
  222. package/registry/examples/segmented-control-demo.tsx +39 -0
  223. package/registry/examples/select-demo.tsx +1 -1
  224. package/registry/examples/sidebar-demo.tsx +5 -3
  225. package/registry/examples/slider-demo.tsx +3 -3
  226. package/registry/examples/social-button-demo.tsx +18 -8
  227. package/registry/examples/spinner-demo.tsx +13 -13
  228. package/registry/examples/tabs-demo.tsx +1 -1
  229. package/registry/examples/tag-demo.tsx +1 -1
  230. package/registry/examples/textarea-demo.tsx +4 -4
  231. package/registry/examples/toast-demo.tsx +1 -1
  232. package/registry/examples/toggle-demo.tsx +6 -6
  233. package/registry/examples/tooltip-demo.tsx +4 -4
  234. package/registry/guidance/USAGE.md +27 -0
  235. package/registry/hooks/use-direction.ts +193 -13
  236. package/registry/index.json +5668 -375
  237. package/registry/styles/globals.css +2404 -3999
  238. package/registry.json +156 -149
  239. package/schema/config.schema.json +11 -8
  240. package/schema/registry.schema.json +150 -45
  241. package/schema/tokens.schema.json +134 -34
  242. package/src/cli/command-line.js +194 -0
  243. package/src/cli/config-editors.js +9 -0
  244. package/src/cli/doctor.js +399 -0
  245. package/src/cli/file-transaction.js +295 -0
  246. package/src/cli/global-css.js +153 -0
  247. package/src/cli/index.js +567 -1613
  248. package/src/cli/install-plan.js +592 -0
  249. package/src/cli/install-receipt.js +171 -0
  250. package/src/cli/jsonc.js +203 -0
  251. package/src/cli/package-install-state.js +254 -0
  252. package/src/cli/package-manager.js +238 -0
  253. package/src/cli/package-plan.js +185 -0
  254. package/src/cli/package-version.js +97 -0
  255. package/src/cli/registry-query.js +207 -0
  256. package/src/cli/reporting.js +55 -0
  257. package/src/cli/schema-validation.js +19 -0
  258. package/src/cli/source-scanner.js +686 -0
  259. package/src/cli/vite-config.js +478 -0
  260. package/tokens/banhaten.tokens.json +4444 -331
  261. package/docs/design-system/components/command-bar.md +0 -57
  262. package/registry/components/expanded/BannerBoard.tsx +0 -225
  263. package/registry/components/expanded/CatalogTag.tsx +0 -85
  264. package/registry/components/expanded/CommandBar.tsx +0 -386
  265. package/registry/components/expanded/IconExplorer.tsx +0 -612
  266. package/registry/components/expanded/TimelineShowcase.tsx +0 -158
  267. package/registry/components/expanded/commandBar.css +0 -470
  268. package/registry/components/expanded/iconExplorer.css +0 -764
  269. package/registry/components/expanded/iconPacks.ts +0 -866
  270. package/registry/components/expanded/tag.css +0 -148
  271. package/registry/examples/expanded/icons-demo.tsx +0 -5
  272. package/tokens/Color mode.zip +0 -0
  273. package/tokens/Numbers.zip +0 -0
  274. package/tokens/Radius.zip +0 -0
  275. package/tokens/Theme.zip +0 -0
@@ -0,0 +1,399 @@
1
+ import { createHash } from "node:crypto"
2
+ import fs from "node:fs/promises"
3
+ import path from "node:path"
4
+
5
+ const RECEIPT_FILE = ".banhaten/installed.json"
6
+ const GUIDANCE_FILE = ".banhaten/USAGE.md"
7
+ const SOURCE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx"])
8
+ const IGNORED_DIRECTORIES = new Set([
9
+ ".banhaten",
10
+ ".git",
11
+ ".next",
12
+ ".nuxt",
13
+ ".output",
14
+ ".turbo",
15
+ ".vercel",
16
+ "build",
17
+ "coverage",
18
+ "dist",
19
+ "node_modules",
20
+ "out",
21
+ ])
22
+ const BANHATEN_CONTROL_TAG =
23
+ /<(Input|Select|DatePicker|DateRangePicker|Button|ButtonGroup|ToggleGroup|SegmentedControl|ToolbarSearch|ToolbarSelect|ToolbarButton)(?=\s|\/?>)[\s\S]*?>/g
24
+ const GEOMETRY_ATTRIBUTE =
25
+ /(?:className|controlClassName|inputClassName|surfaceClassName|triggerClassName)\s*=\s*(?:["']([^"']*)["']|\{\s*["']([^"']*)["']\s*\})/g
26
+ const RAW_GEOMETRY_CLASS =
27
+ /(?:^|\s)(?:h-|min-h-|max-h-|p(?:x|y|s|e|t|r|b|l)?-|rounded(?:-|$)|text-\[)/
28
+
29
+ export async function inspectBanhatenProject({ cwd, registryVersion }) {
30
+ const projectRoot = path.resolve(cwd)
31
+ const diagnostics = []
32
+ const receiptPath = path.join(projectRoot, RECEIPT_FILE)
33
+ const guidancePath = path.join(projectRoot, GUIDANCE_FILE)
34
+ const receipt = await readJsonIfExists(receiptPath, diagnostics, projectRoot)
35
+
36
+ if (!receipt) {
37
+ diagnostics.push(
38
+ diagnostic({
39
+ code: "setup-not-initialized",
40
+ message: "No Banhaten install receipt was found.",
41
+ recommendation: "Run `banhaten init` before installing components.",
42
+ severity: "warning",
43
+ })
44
+ )
45
+ } else {
46
+ inspectReceiptVersion(receipt, registryVersion, diagnostics)
47
+ await inspectManagedFiles(projectRoot, receipt, diagnostics)
48
+ }
49
+
50
+ if (!(await exists(guidancePath))) {
51
+ diagnostics.push(
52
+ diagnostic({
53
+ code: "agent-guidance-missing",
54
+ file: GUIDANCE_FILE,
55
+ message: "The installed coding-agent usage rules are missing.",
56
+ recommendation: "Run `banhaten init` to install .banhaten/USAGE.md.",
57
+ severity: "warning",
58
+ })
59
+ )
60
+ }
61
+
62
+ const sourceFiles = await collectSourceFiles(projectRoot)
63
+ for (const filePath of sourceFiles) {
64
+ await inspectSourceFile(projectRoot, filePath, diagnostics)
65
+ }
66
+
67
+ diagnostics.sort(compareDiagnostics)
68
+ const summary = diagnostics.reduce(
69
+ (counts, item) => {
70
+ const key =
71
+ item.severity === "error"
72
+ ? "errors"
73
+ : item.severity === "warning"
74
+ ? "warnings"
75
+ : "info"
76
+ counts[key] += 1
77
+ return counts
78
+ },
79
+ { errors: 0, info: 0, warnings: 0 }
80
+ )
81
+
82
+ return {
83
+ cwd: projectRoot,
84
+ registryVersion,
85
+ installedVersion: receipt?.registryVersion ?? null,
86
+ installedComponents: Object.keys(receipt?.components || {}).sort(),
87
+ scannedFiles: sourceFiles.length,
88
+ summary,
89
+ diagnostics,
90
+ }
91
+ }
92
+
93
+ export function printBanhatenDoctor(report, { json = false, silent = false } = {}) {
94
+ if (json) {
95
+ console.log(JSON.stringify(report, null, 2))
96
+ return
97
+ }
98
+ if (silent) return
99
+
100
+ console.log("Banhaten doctor")
101
+ console.log(`Project: ${report.cwd}`)
102
+ console.log(
103
+ `Registry: ${report.installedVersion ?? "not installed"} -> ${report.registryVersion}`
104
+ )
105
+ console.log(
106
+ `Scanned ${report.scannedFiles} source files and ${report.installedComponents.length} installed components.`
107
+ )
108
+
109
+ if (report.diagnostics.length === 0) {
110
+ console.log("No design-system adoption issues found.")
111
+ return
112
+ }
113
+
114
+ for (const item of report.diagnostics) {
115
+ const location = item.file
116
+ ? ` ${item.file}${item.line ? `:${item.line}` : ""}`
117
+ : ""
118
+ console.log(`${item.severity.toUpperCase()} ${item.code}${location}`)
119
+ console.log(` ${item.message}`)
120
+ console.log(` ${item.recommendation}`)
121
+ }
122
+
123
+ console.log(
124
+ `${report.summary.errors} errors, ${report.summary.warnings} warnings, ${report.summary.info} info.`
125
+ )
126
+ }
127
+
128
+ function inspectReceiptVersion(receipt, registryVersion, diagnostics) {
129
+ if (receipt.schemaVersion !== 1 || typeof receipt.components !== "object") {
130
+ diagnostics.push(
131
+ diagnostic({
132
+ code: "install-receipt-invalid",
133
+ file: RECEIPT_FILE,
134
+ message: "The Banhaten install receipt has an unsupported shape.",
135
+ recommendation: "Re-run an explicit `banhaten add <component> --force` after reviewing local changes.",
136
+ severity: "error",
137
+ })
138
+ )
139
+ return
140
+ }
141
+
142
+ if (receipt.registryVersion !== registryVersion) {
143
+ diagnostics.push(
144
+ diagnostic({
145
+ code: "registry-version-drift",
146
+ file: RECEIPT_FILE,
147
+ message: `Installed registry version ${receipt.registryVersion ?? "unknown"} differs from CLI version ${registryVersion}.`,
148
+ recommendation: "Run `banhaten diff`, then update reviewed components with `banhaten update`.",
149
+ severity: "warning",
150
+ })
151
+ )
152
+ }
153
+ }
154
+
155
+ async function inspectManagedFiles(projectRoot, receipt, diagnostics) {
156
+ for (const [target, record] of Object.entries(receipt.managedFiles || {})) {
157
+ const resolved = path.resolve(projectRoot, target)
158
+ const relative = path.relative(projectRoot, resolved)
159
+
160
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
161
+ diagnostics.push(
162
+ diagnostic({
163
+ code: "managed-target-unsafe",
164
+ file: target,
165
+ message: "The install receipt points outside the project.",
166
+ recommendation: "Remove the unsafe receipt entry and re-run `banhaten init`.",
167
+ severity: "error",
168
+ })
169
+ )
170
+ continue
171
+ }
172
+
173
+ let content
174
+ try {
175
+ content = await fs.readFile(resolved)
176
+ } catch (error) {
177
+ if (error.code === "ENOENT") {
178
+ diagnostics.push(
179
+ diagnostic({
180
+ code: "managed-file-missing",
181
+ file: normalizePath(relative),
182
+ message: `The ${record.owner || "Banhaten"}-managed file is missing.`,
183
+ recommendation: "Run `banhaten diff`, then restore it with `banhaten update`.",
184
+ severity: "error",
185
+ })
186
+ )
187
+ continue
188
+ }
189
+ throw error
190
+ }
191
+
192
+ const actualHash = createHash("sha256").update(content).digest("hex")
193
+ if (record.sha256 && actualHash !== record.sha256) {
194
+ diagnostics.push(
195
+ diagnostic({
196
+ code: "managed-file-modified",
197
+ file: normalizePath(relative),
198
+ message: `The ${record.owner || "Banhaten"}-managed file differs from its install receipt.`,
199
+ recommendation: "Run `banhaten diff` and either keep the intentional extension or update the registry copy after review.",
200
+ severity: "warning",
201
+ })
202
+ )
203
+ }
204
+ }
205
+ }
206
+
207
+ async function inspectSourceFile(projectRoot, filePath, diagnostics) {
208
+ const source = await fs.readFile(filePath, "utf8")
209
+ if (source.length > 1024 * 1024) return
210
+ const file = normalizePath(path.relative(projectRoot, filePath))
211
+
212
+ for (const match of source.matchAll(BANHATEN_CONTROL_TAG)) {
213
+ const [tagSource, tagName] = match
214
+ const line = getLine(source, match.index)
215
+
216
+ for (const attribute of tagSource.matchAll(GEOMETRY_ATTRIBUTE)) {
217
+ const classes = attribute[1] ?? attribute[2] ?? ""
218
+ if (!RAW_GEOMETRY_CLASS.test(classes)) continue
219
+ diagnostics.push(
220
+ diagnostic({
221
+ code: "raw-control-geometry",
222
+ file,
223
+ line,
224
+ message: `${tagName} overrides control geometry with ${JSON.stringify(classes)}.`,
225
+ recommendation: "Use density for height and component tokens for padding, radius, and typography; keep classes for width and layout only.",
226
+ severity: "warning",
227
+ })
228
+ )
229
+ }
230
+
231
+ if (tagName === "Input") {
232
+ if (hasTruthyBooleanProp(tagSource, "hasLeadingIcon") &&
233
+ !hasProp(tagSource, "leadingIcon") &&
234
+ !hasProp(tagSource, "showAtSign")) {
235
+ diagnostics.push(
236
+ diagnostic({
237
+ code: "ambiguous-input-leading-icon",
238
+ file,
239
+ line,
240
+ message: "Input uses the deprecated hasLeadingIcon flag without an explicit icon.",
241
+ recommendation: "Pass leadingIcon={<Icon />} or use showAtSign for the literal @ affordance.",
242
+ severity: "warning",
243
+ })
244
+ )
245
+ }
246
+
247
+ if (hasTruthyBooleanProp(tagSource, "hasInformationIcon") &&
248
+ !hasProp(tagSource, "trailingIcon") &&
249
+ !hasProp(tagSource, "showInfo")) {
250
+ diagnostics.push(
251
+ diagnostic({
252
+ code: "ambiguous-input-information-icon",
253
+ file,
254
+ line,
255
+ message: "Input uses the deprecated hasInformationIcon flag without an explicit icon.",
256
+ recommendation: "Pass trailingIcon={<Icon />} or use showInfo for the literal information affordance.",
257
+ severity: "warning",
258
+ })
259
+ )
260
+ }
261
+
262
+ if (hasProp(tagSource, "shortcut") && !hasProp(tagSource, "shortcutKeys")) {
263
+ diagnostics.push(
264
+ diagnostic({
265
+ code: "legacy-input-shortcut",
266
+ file,
267
+ line,
268
+ message: "Input uses a single legacy shortcut keycap.",
269
+ recommendation: "Use shortcutKeys={[\"Mod\", \"K\"]} for platform-aware, multi-key rendering.",
270
+ severity: "warning",
271
+ })
272
+ )
273
+ }
274
+ }
275
+ }
276
+
277
+ inspectNativeTags(source, file, diagnostics)
278
+ }
279
+
280
+ function inspectNativeTags(source, file, diagnostics) {
281
+ for (const match of source.matchAll(/<select(?=\s|>)[\s\S]*?>/g)) {
282
+ diagnostics.push(
283
+ diagnostic({
284
+ code: "native-select-control",
285
+ file,
286
+ line: getLine(source, match.index),
287
+ message: "A native select bypasses the Banhaten Select contract.",
288
+ recommendation: "Use Select for product UI, unless native platform behavior is an explicit requirement.",
289
+ severity: "warning",
290
+ })
291
+ )
292
+ }
293
+
294
+ for (const match of source.matchAll(/<input(?=\s|>)[\s\S]*?>/g)) {
295
+ if (!/\btype\s*=\s*["']date["']/.test(match[0])) continue
296
+ diagnostics.push(
297
+ diagnostic({
298
+ code: "native-date-control",
299
+ file,
300
+ line: getLine(source, match.index),
301
+ message: "A native date input bypasses the Banhaten calendar and density contracts.",
302
+ recommendation: "Use DatePicker or DateRangePicker.",
303
+ severity: "warning",
304
+ })
305
+ )
306
+ }
307
+ }
308
+
309
+ async function collectSourceFiles(projectRoot) {
310
+ const files = []
311
+ await visit(projectRoot)
312
+ return files.sort()
313
+
314
+ async function visit(directory) {
315
+ const entries = await fs.readdir(directory, { withFileTypes: true })
316
+ for (const entry of entries) {
317
+ if (entry.name.startsWith(".") && entry.isDirectory()) {
318
+ if (IGNORED_DIRECTORIES.has(entry.name)) continue
319
+ }
320
+ if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue
321
+
322
+ const target = path.join(directory, entry.name)
323
+ if (entry.isDirectory()) {
324
+ await visit(target)
325
+ } else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
326
+ files.push(target)
327
+ }
328
+ }
329
+ }
330
+ }
331
+
332
+ function hasProp(tagSource, prop) {
333
+ return new RegExp(`\\b${prop}(?:\\s*=|(?=\\s|\\/?>))`).test(tagSource)
334
+ }
335
+
336
+ function hasTruthyBooleanProp(tagSource, prop) {
337
+ return new RegExp(
338
+ `\\b${prop}(?:\\s*=\\s*\\{\\s*true\\s*\\})?(?=\\s|\\/?>)`
339
+ ).test(tagSource)
340
+ }
341
+
342
+ function getLine(source, index = 0) {
343
+ return source.slice(0, index).split("\n").length
344
+ }
345
+
346
+ function diagnostic({ code, file, line, message, recommendation, severity }) {
347
+ return {
348
+ code,
349
+ severity,
350
+ ...(file ? { file } : {}),
351
+ ...(line ? { line } : {}),
352
+ message,
353
+ recommendation,
354
+ }
355
+ }
356
+
357
+ function compareDiagnostics(left, right) {
358
+ const severityOrder = { error: 0, warning: 1, info: 2 }
359
+ return (
360
+ severityOrder[left.severity] - severityOrder[right.severity] ||
361
+ (left.file || "").localeCompare(right.file || "") ||
362
+ (left.line || 0) - (right.line || 0) ||
363
+ left.code.localeCompare(right.code)
364
+ )
365
+ }
366
+
367
+ function normalizePath(value) {
368
+ return value.replaceAll("\\", "/")
369
+ }
370
+
371
+ async function exists(filePath) {
372
+ try {
373
+ await fs.access(filePath)
374
+ return true
375
+ } catch {
376
+ return false
377
+ }
378
+ }
379
+
380
+ async function readJsonIfExists(filePath, diagnostics, projectRoot) {
381
+ try {
382
+ return JSON.parse(await fs.readFile(filePath, "utf8"))
383
+ } catch (error) {
384
+ if (error.code === "ENOENT") return null
385
+ if (error instanceof SyntaxError) {
386
+ diagnostics.push(
387
+ diagnostic({
388
+ code: "install-receipt-invalid-json",
389
+ file: normalizePath(path.relative(projectRoot, filePath)),
390
+ message: "The Banhaten install receipt is not valid JSON.",
391
+ recommendation: "Review the file, then rebuild it with an explicit `banhaten add <component> --force`.",
392
+ severity: "error",
393
+ })
394
+ )
395
+ return null
396
+ }
397
+ throw error
398
+ }
399
+ }
@@ -0,0 +1,295 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+ import process from "node:process"
4
+
5
+ let activeFileTransaction = null
6
+ let transactionFileCounter = 0
7
+
8
+ export async function runFileTransaction(action) {
9
+ if (activeFileTransaction) {
10
+ throw new Error("nested file transactions are not supported")
11
+ }
12
+
13
+ const transaction = {
14
+ writes: new Map(),
15
+ preconditions: new Map(),
16
+ afterApply: [],
17
+ }
18
+ activeFileTransaction = transaction
19
+
20
+ try {
21
+ const result = await action()
22
+ await commitFileTransaction(transaction)
23
+ activeFileTransaction = null
24
+
25
+ for (const callback of transaction.afterApply) {
26
+ await callback()
27
+ }
28
+
29
+ return result
30
+ } catch (error) {
31
+ activeFileTransaction = null
32
+ throw error
33
+ }
34
+ }
35
+
36
+ export function deferUntilApplied(callback) {
37
+ if (!activeFileTransaction) {
38
+ return callback()
39
+ }
40
+
41
+ activeFileTransaction.afterApply.push(callback)
42
+ }
43
+
44
+ async function commitFileTransaction(transaction) {
45
+ const staged = []
46
+ const committed = []
47
+ const createdDirectories = new Set()
48
+ let failed = false
49
+
50
+ try {
51
+ await assertTransactionPreconditions(transaction)
52
+
53
+ for (const entry of transaction.writes.values()) {
54
+ await ensureDirectory(
55
+ path.dirname(entry.targetPath),
56
+ createdDirectories
57
+ )
58
+ const tempPath = `${entry.targetPath}.banhaten-${process.pid}-${transactionFileCounter += 1}.tmp`
59
+ const previous = await readFileSnapshot(entry.targetPath)
60
+
61
+ await fs.writeFile(tempPath, entry.content)
62
+ if (previous?.mode !== undefined) {
63
+ await fs.chmod(tempPath, previous.mode)
64
+ }
65
+ staged.push({ ...entry, tempPath, previous })
66
+ }
67
+
68
+ await assertTransactionPreconditions(transaction)
69
+
70
+ for (const entry of staged) {
71
+ await fs.rename(entry.tempPath, entry.targetPath)
72
+ committed.push(entry)
73
+ }
74
+ } catch (error) {
75
+ failed = true
76
+ for (const entry of [...committed].reverse()) {
77
+ await restoreFileSnapshot(entry.targetPath, entry.previous)
78
+ }
79
+
80
+ throw new Error(`file transaction failed and was rolled back: ${error.message}`)
81
+ } finally {
82
+ await Promise.all(
83
+ staged.map((entry) => fs.rm(entry.tempPath, { force: true }).catch(() => {}))
84
+ )
85
+ if (failed) {
86
+ await removeCreatedDirectories(createdDirectories)
87
+ }
88
+ }
89
+ }
90
+
91
+ async function ensureDirectory(targetPath, createdDirectories) {
92
+ const missing = []
93
+ let directory = targetPath
94
+
95
+ while (!(await exists(directory))) {
96
+ missing.push(directory)
97
+ const parent = path.dirname(directory)
98
+ if (parent === directory) break
99
+ directory = parent
100
+ }
101
+
102
+ for (const missingDirectory of missing.reverse()) {
103
+ try {
104
+ await fs.mkdir(missingDirectory)
105
+ createdDirectories.add(missingDirectory)
106
+ } catch (error) {
107
+ if (error.code !== "EEXIST") throw error
108
+ }
109
+ }
110
+ }
111
+
112
+ async function removeCreatedDirectories(createdDirectories) {
113
+ for (const directory of [...createdDirectories].reverse()) {
114
+ await fs.rmdir(directory).catch(() => {})
115
+ }
116
+ }
117
+
118
+ async function assertTransactionPreconditions(transaction) {
119
+ for (const [targetPath, expectedCurrent] of transaction.preconditions) {
120
+ const current = await readFileSnapshot(targetPath)
121
+ assertExpectedCurrent(targetPath, current?.content ?? null, expectedCurrent)
122
+ }
123
+ }
124
+
125
+ async function readFileSnapshot(targetPath) {
126
+ try {
127
+ const [content, stats] = await Promise.all([
128
+ fs.readFile(targetPath),
129
+ fs.stat(targetPath),
130
+ ])
131
+ return { content, mode: stats.mode }
132
+ } catch (error) {
133
+ if (error.code === "ENOENT") return null
134
+ throw error
135
+ }
136
+ }
137
+
138
+ async function restoreFileSnapshot(targetPath, snapshot) {
139
+ if (!snapshot) {
140
+ await fs.rm(targetPath, { force: true })
141
+ return
142
+ }
143
+
144
+ const tempPath = `${targetPath}.banhaten-rollback-${process.pid}-${transactionFileCounter += 1}.tmp`
145
+ await fs.writeFile(tempPath, snapshot.content)
146
+ await fs.chmod(tempPath, snapshot.mode)
147
+ await fs.rename(tempPath, targetPath)
148
+ }
149
+
150
+ export async function writeFile(targetPath, content, options) {
151
+ const alreadyExists = await exists(targetPath)
152
+ const isBinary = Buffer.isBuffer(content)
153
+ const contentBuffer = isBinary ? content : Buffer.from(content, "utf8")
154
+ const current = alreadyExists ? await fs.readFile(targetPath) : null
155
+ const isEqual = current?.equals(contentBuffer) || false
156
+ if (options.expectedCurrent !== undefined) {
157
+ const expectedCurrent = toExpectedBuffer(options.expectedCurrent)
158
+ assertExpectedCurrent(targetPath, current, expectedCurrent)
159
+ registerPrecondition(targetPath, expectedCurrent)
160
+ }
161
+
162
+ if (options.dryRun) {
163
+ if (!alreadyExists) return "would write"
164
+ if (isEqual) return "would keep"
165
+
166
+ return "would update"
167
+ }
168
+
169
+ if (alreadyExists && isEqual) {
170
+ return "kept"
171
+ }
172
+
173
+ if (!activeFileTransaction) {
174
+ throw new Error(`refusing non-transactional write to ${targetPath}`)
175
+ }
176
+
177
+ const existingPlan = activeFileTransaction.writes.get(targetPath)
178
+ if (existingPlan && !existingPlan.content.equals(contentBuffer)) {
179
+ throw new Error(`conflicting planned writes for ${targetPath}`)
180
+ }
181
+ activeFileTransaction.writes.set(targetPath, {
182
+ targetPath,
183
+ content: contentBuffer,
184
+ })
185
+
186
+ return alreadyExists ? "updated" : "wrote"
187
+ }
188
+
189
+ export async function assertFileUnchanged(targetPath, expectedCurrent) {
190
+ if (!activeFileTransaction) {
191
+ throw new Error(`refusing non-transactional precondition for ${targetPath}`)
192
+ }
193
+
194
+ const expectedBuffer = toExpectedBuffer(expectedCurrent)
195
+ const current = await readFileContent(targetPath)
196
+ assertExpectedCurrent(targetPath, current, expectedBuffer)
197
+ registerPrecondition(targetPath, expectedBuffer)
198
+ }
199
+
200
+ function registerPrecondition(targetPath, expectedCurrent) {
201
+ if (!activeFileTransaction) {
202
+ throw new Error(`refusing non-transactional precondition for ${targetPath}`)
203
+ }
204
+
205
+ const existing = activeFileTransaction.preconditions.get(targetPath)
206
+ if (existing !== undefined) {
207
+ assertExpectedCurrent(targetPath, existing, expectedCurrent)
208
+ return
209
+ }
210
+ activeFileTransaction.preconditions.set(targetPath, expectedCurrent)
211
+ }
212
+
213
+ function assertExpectedCurrent(targetPath, current, expected) {
214
+ const matches = expected === null
215
+ ? current === null
216
+ : current?.equals(expected) || false
217
+ if (!matches) {
218
+ throw new Error(
219
+ `${targetPath} changed while Banhaten was preparing the install plan`
220
+ )
221
+ }
222
+ }
223
+
224
+ function toExpectedBuffer(expectedCurrent) {
225
+ return expectedCurrent === null
226
+ ? null
227
+ : Buffer.from(expectedCurrent)
228
+ }
229
+
230
+ async function readFileContent(targetPath) {
231
+ try {
232
+ return await fs.readFile(targetPath)
233
+ } catch (error) {
234
+ if (error.code === "ENOENT") return null
235
+ throw error
236
+ }
237
+ }
238
+
239
+ export function formatUnifiedDiff(fileLabel, currentContent, nextContent) {
240
+ const current = toDiffLines(currentContent)
241
+ const next = toDiffLines(nextContent)
242
+ let prefix = 0
243
+
244
+ while (
245
+ prefix < current.length &&
246
+ prefix < next.length &&
247
+ current[prefix] === next[prefix]
248
+ ) {
249
+ prefix += 1
250
+ }
251
+
252
+ let currentSuffix = current.length
253
+ let nextSuffix = next.length
254
+ while (
255
+ currentSuffix > prefix &&
256
+ nextSuffix > prefix &&
257
+ current[currentSuffix - 1] === next[nextSuffix - 1]
258
+ ) {
259
+ currentSuffix -= 1
260
+ nextSuffix -= 1
261
+ }
262
+
263
+ const context = 3
264
+ const start = Math.max(0, prefix - context)
265
+ const currentEnd = Math.min(current.length, currentSuffix + context)
266
+ const nextEnd = Math.min(next.length, nextSuffix + context)
267
+ const currentCount = currentEnd - start
268
+ const nextCount = nextEnd - start
269
+ const output = [
270
+ `--- a/${fileLabel}`,
271
+ `+++ b/${fileLabel}`,
272
+ `@@ -${start + 1},${currentCount} +${start + 1},${nextCount} @@`,
273
+ ]
274
+
275
+ for (const line of current.slice(start, prefix)) output.push(` ${line}`)
276
+ for (const line of current.slice(prefix, currentSuffix)) output.push(`-${line}`)
277
+ for (const line of next.slice(prefix, nextSuffix)) output.push(`+${line}`)
278
+ for (const line of current.slice(currentSuffix, currentEnd)) output.push(` ${line}`)
279
+
280
+ return output.join("\n")
281
+ }
282
+
283
+ function toDiffLines(content) {
284
+ if (Buffer.isBuffer(content)) return [`Binary content (${content.byteLength} bytes)`]
285
+ return String(content).replace(/\r\n/g, "\n").split("\n")
286
+ }
287
+
288
+ async function exists(filePath) {
289
+ try {
290
+ await fs.access(filePath)
291
+ return true
292
+ } catch {
293
+ return false
294
+ }
295
+ }