@awarebydefault/display-case 1.0.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 (254) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +309 -0
  3. package/display-case.prompt.md +64 -0
  4. package/docs/ai-agents.md +126 -0
  5. package/docs/cli.md +99 -0
  6. package/docs/configuration.md +410 -0
  7. package/docs/documentation-panel.md +50 -0
  8. package/docs/examples/README.md +14 -0
  9. package/docs/examples/multi-variant.case.tsx +30 -0
  10. package/docs/examples/plain.case.tsx +22 -0
  11. package/docs/examples/tweak-control.placard.md +80 -0
  12. package/docs/examples/tweaks.case.tsx +39 -0
  13. package/docs/hierarchy.md +59 -0
  14. package/docs/quick-start.md +78 -0
  15. package/docs/style-engines.md +180 -0
  16. package/docs/testing.md +245 -0
  17. package/docs/theming.md +97 -0
  18. package/docs/tweaks.md +75 -0
  19. package/docs/writing-cases.md +144 -0
  20. package/docs/writing-placard-docs.md +194 -0
  21. package/package.json +113 -0
  22. package/skills/display-case-author-case/README.md +20 -0
  23. package/skills/display-case-author-case/SKILL.md +40 -0
  24. package/skills/display-case-author-placard-doc/README.md +24 -0
  25. package/skills/display-case-author-placard-doc/SKILL.md +65 -0
  26. package/skills/display-case-review/README.md +19 -0
  27. package/skills/display-case-review/SKILL.md +30 -0
  28. package/skills/display-case-snapshot/README.md +20 -0
  29. package/skills/display-case-snapshot/SKILL.md +29 -0
  30. package/src/checks/a11y-scanner.test.ts +240 -0
  31. package/src/checks/a11y-scanner.ts +410 -0
  32. package/src/checks/check-text.test.ts +53 -0
  33. package/src/checks/check-text.ts +78 -0
  34. package/src/checks/check.test.ts +194 -0
  35. package/src/checks/check.ts +473 -0
  36. package/src/checks/providers/pixelmatch-diff.test.ts +79 -0
  37. package/src/checks/providers/pixelmatch-diff.ts +30 -0
  38. package/src/checks/providers/playwright-driver.ts +104 -0
  39. package/src/checks/ssr-check.test.ts +73 -0
  40. package/src/checks/ssr-check.ts +96 -0
  41. package/src/checks/structure-check.cross-package.test.ts +165 -0
  42. package/src/checks/structure-check.test.ts +651 -0
  43. package/src/checks/structure-check.ts +988 -0
  44. package/src/checks/tokens-check.test.ts +159 -0
  45. package/src/checks/tokens-check.ts +162 -0
  46. package/src/cli.ts +218 -0
  47. package/src/commands/agents.test.ts +24 -0
  48. package/src/commands/agents.ts +28 -0
  49. package/src/commands/init-run.test.ts +123 -0
  50. package/src/commands/init.test.ts +63 -0
  51. package/src/commands/init.ts +412 -0
  52. package/src/commands/publish.test.ts +210 -0
  53. package/src/commands/publish.ts +292 -0
  54. package/src/core/affected.test.ts +99 -0
  55. package/src/core/affected.ts +144 -0
  56. package/src/core/catalog.test.ts +152 -0
  57. package/src/core/catalog.ts +92 -0
  58. package/src/core/discovery.test.ts +184 -0
  59. package/src/core/discovery.ts +250 -0
  60. package/src/core/manifest.ts +41 -0
  61. package/src/core/mdx-lite/__fixtures__/box-stub.tsx +7 -0
  62. package/src/core/mdx-lite/index.ts +393 -0
  63. package/src/core/mdx-lite/mdx-lite.test.ts +345 -0
  64. package/src/core/mdx-plugin.test.ts +60 -0
  65. package/src/core/mdx-plugin.ts +30 -0
  66. package/src/flow-step.test-d.ts +39 -0
  67. package/src/index.test.ts +100 -0
  68. package/src/index.ts +564 -0
  69. package/src/render/collect-styles.emotion.test.tsx +114 -0
  70. package/src/render/collect-styles.test.tsx +72 -0
  71. package/src/render/collect-styles.ts +33 -0
  72. package/src/render/documents.test.ts +184 -0
  73. package/src/render/documents.ts +88 -0
  74. package/src/render/render-node.test.tsx +160 -0
  75. package/src/render/render-node.tsx +133 -0
  76. package/src/render/ssr-primer.test.tsx +25 -0
  77. package/src/render/ssr-primer.tsx +54 -0
  78. package/src/render/ssr-render.test.tsx +142 -0
  79. package/src/render/ssr-render.tsx +63 -0
  80. package/src/render/ssr-shell.test.tsx +57 -0
  81. package/src/render/ssr-shell.tsx +54 -0
  82. package/src/server/prod-server.ts +237 -0
  83. package/src/server/server.test.ts +117 -0
  84. package/src/server/server.ts +1039 -0
  85. package/src/style-engine.test-d.ts +37 -0
  86. package/src/testing/test-helpers.ts +27 -0
  87. package/src/types/pixelmatch.d.ts +12 -0
  88. package/src/ui/browser-entry.tsx +51 -0
  89. package/src/ui/chrome.css +485 -0
  90. package/src/ui/design-system/README.md +88 -0
  91. package/src/ui/design-system/components/controls/Button.case.tsx +52 -0
  92. package/src/ui/design-system/components/controls/Button.css +89 -0
  93. package/src/ui/design-system/components/controls/Button.placard.md +14 -0
  94. package/src/ui/design-system/components/controls/Button.test.tsx +45 -0
  95. package/src/ui/design-system/components/controls/Button.tsx +41 -0
  96. package/src/ui/design-system/components/controls/IconButton.case.tsx +52 -0
  97. package/src/ui/design-system/components/controls/IconButton.css +67 -0
  98. package/src/ui/design-system/components/controls/IconButton.placard.md +13 -0
  99. package/src/ui/design-system/components/controls/IconButton.test.tsx +39 -0
  100. package/src/ui/design-system/components/controls/IconButton.tsx +47 -0
  101. package/src/ui/design-system/components/controls/Input.case.tsx +50 -0
  102. package/src/ui/design-system/components/controls/Input.css +52 -0
  103. package/src/ui/design-system/components/controls/Input.placard.md +12 -0
  104. package/src/ui/design-system/components/controls/Input.test.tsx +43 -0
  105. package/src/ui/design-system/components/controls/Input.tsx +45 -0
  106. package/src/ui/design-system/components/controls/Select.case.tsx +48 -0
  107. package/src/ui/design-system/components/controls/Select.css +44 -0
  108. package/src/ui/design-system/components/controls/Select.placard.md +15 -0
  109. package/src/ui/design-system/components/controls/Select.test.tsx +57 -0
  110. package/src/ui/design-system/components/controls/Select.tsx +58 -0
  111. package/src/ui/design-system/components/controls/SelectMenu.case.tsx +100 -0
  112. package/src/ui/design-system/components/controls/SelectMenu.css +72 -0
  113. package/src/ui/design-system/components/controls/SelectMenu.placard.md +18 -0
  114. package/src/ui/design-system/components/controls/SelectMenu.test.tsx +66 -0
  115. package/src/ui/design-system/components/controls/SelectMenu.tsx +377 -0
  116. package/src/ui/design-system/components/index.ts +66 -0
  117. package/src/ui/design-system/components/primer-specimen/ColorRamp.case.tsx +44 -0
  118. package/src/ui/design-system/components/primer-specimen/ColorRamp.placard.md +15 -0
  119. package/src/ui/design-system/components/primer-specimen/ColorRamp.tsx +51 -0
  120. package/src/ui/design-system/components/primer-specimen/DefinitionList.case.tsx +38 -0
  121. package/src/ui/design-system/components/primer-specimen/DefinitionList.placard.md +15 -0
  122. package/src/ui/design-system/components/primer-specimen/DefinitionList.tsx +41 -0
  123. package/src/ui/design-system/components/primer-specimen/FontFamilies.case.tsx +24 -0
  124. package/src/ui/design-system/components/primer-specimen/FontFamilies.placard.md +12 -0
  125. package/src/ui/design-system/components/primer-specimen/FontFamilies.tsx +41 -0
  126. package/src/ui/design-system/components/primer-specimen/GlyphGrid.case.tsx +27 -0
  127. package/src/ui/design-system/components/primer-specimen/GlyphGrid.placard.md +13 -0
  128. package/src/ui/design-system/components/primer-specimen/GlyphGrid.tsx +34 -0
  129. package/src/ui/design-system/components/primer-specimen/LayoutMock.case.tsx +36 -0
  130. package/src/ui/design-system/components/primer-specimen/LayoutMock.placard.md +7 -0
  131. package/src/ui/design-system/components/primer-specimen/LayoutMock.tsx +36 -0
  132. package/src/ui/design-system/components/primer-specimen/SpacingScale.case.tsx +20 -0
  133. package/src/ui/design-system/components/primer-specimen/SpacingScale.placard.md +12 -0
  134. package/src/ui/design-system/components/primer-specimen/SpacingScale.tsx +33 -0
  135. package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.case.tsx +56 -0
  136. package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.placard.md +17 -0
  137. package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.tsx +45 -0
  138. package/src/ui/design-system/components/primer-specimen/StatusList.case.tsx +17 -0
  139. package/src/ui/design-system/components/primer-specimen/StatusList.placard.md +16 -0
  140. package/src/ui/design-system/components/primer-specimen/StatusList.tsx +39 -0
  141. package/src/ui/design-system/components/primer-specimen/SwatchGrid.case.tsx +26 -0
  142. package/src/ui/design-system/components/primer-specimen/SwatchGrid.placard.md +15 -0
  143. package/src/ui/design-system/components/primer-specimen/SwatchGrid.tsx +42 -0
  144. package/src/ui/design-system/components/primer-specimen/TypeScale.case.tsx +23 -0
  145. package/src/ui/design-system/components/primer-specimen/TypeScale.placard.md +14 -0
  146. package/src/ui/design-system/components/primer-specimen/TypeScale.tsx +34 -0
  147. package/src/ui/design-system/components/primer-specimen/WeightSpecimen.case.tsx +28 -0
  148. package/src/ui/design-system/components/primer-specimen/WeightSpecimen.placard.md +15 -0
  149. package/src/ui/design-system/components/primer-specimen/WeightSpecimen.tsx +46 -0
  150. package/src/ui/design-system/components/primer-specimen/index.ts +31 -0
  151. package/src/ui/design-system/components/primer-specimen/styles.css +476 -0
  152. package/src/ui/design-system/components/shell/A11yPage.case.tsx +237 -0
  153. package/src/ui/design-system/components/shell/A11yPage.placard.md +15 -0
  154. package/src/ui/design-system/components/shell/CaseTemplate.case.tsx +32 -0
  155. package/src/ui/design-system/components/shell/CaseTemplate.placard.md +5 -0
  156. package/src/ui/design-system/components/shell/CasesPage.case.tsx +141 -0
  157. package/src/ui/design-system/components/shell/CasesPage.placard.md +12 -0
  158. package/src/ui/design-system/components/shell/PrimerPage.case.tsx +22 -0
  159. package/src/ui/design-system/components/shell/PrimerPage.placard.md +3 -0
  160. package/src/ui/design-system/components/shell/PrimerTemplate.case.tsx +22 -0
  161. package/src/ui/design-system/components/shell/PrimerTemplate.placard.md +5 -0
  162. package/src/ui/design-system/components/shell/ShellView.case.tsx +57 -0
  163. package/src/ui/design-system/components/shell/ShellView.placard.md +5 -0
  164. package/src/ui/design-system/components/shell/ShellView.tsx +678 -0
  165. package/src/ui/design-system/components/shell/shell-fixtures.tsx +727 -0
  166. package/src/ui/design-system/components/showcase/A11yBadge.case.tsx +46 -0
  167. package/src/ui/design-system/components/showcase/A11yBadge.css +27 -0
  168. package/src/ui/design-system/components/showcase/A11yBadge.placard.md +11 -0
  169. package/src/ui/design-system/components/showcase/A11yBadge.test.tsx +31 -0
  170. package/src/ui/design-system/components/showcase/A11yBadge.tsx +41 -0
  171. package/src/ui/design-system/components/showcase/A11yPanel.case.tsx +121 -0
  172. package/src/ui/design-system/components/showcase/A11yPanel.css +198 -0
  173. package/src/ui/design-system/components/showcase/A11yPanel.placard.md +19 -0
  174. package/src/ui/design-system/components/showcase/A11yPanel.test.tsx +81 -0
  175. package/src/ui/design-system/components/showcase/A11yPanel.tsx +144 -0
  176. package/src/ui/design-system/components/showcase/Chip.case.tsx +48 -0
  177. package/src/ui/design-system/components/showcase/Chip.css +51 -0
  178. package/src/ui/design-system/components/showcase/Chip.placard.md +13 -0
  179. package/src/ui/design-system/components/showcase/Chip.test.tsx +46 -0
  180. package/src/ui/design-system/components/showcase/Chip.tsx +54 -0
  181. package/src/ui/design-system/components/showcase/Eyebrow.case.tsx +30 -0
  182. package/src/ui/design-system/components/showcase/Eyebrow.css +16 -0
  183. package/src/ui/design-system/components/showcase/Eyebrow.placard.md +10 -0
  184. package/src/ui/design-system/components/showcase/Eyebrow.test.tsx +38 -0
  185. package/src/ui/design-system/components/showcase/Eyebrow.tsx +29 -0
  186. package/src/ui/design-system/components/showcase/FlowNav.case.tsx +35 -0
  187. package/src/ui/design-system/components/showcase/FlowNav.css +29 -0
  188. package/src/ui/design-system/components/showcase/FlowNav.placard.md +13 -0
  189. package/src/ui/design-system/components/showcase/FlowNav.test.tsx +48 -0
  190. package/src/ui/design-system/components/showcase/FlowNav.tsx +58 -0
  191. package/src/ui/design-system/components/showcase/ImpactTag.case.tsx +19 -0
  192. package/src/ui/design-system/components/showcase/ImpactTag.css +36 -0
  193. package/src/ui/design-system/components/showcase/ImpactTag.placard.md +14 -0
  194. package/src/ui/design-system/components/showcase/ImpactTag.test.tsx +40 -0
  195. package/src/ui/design-system/components/showcase/ImpactTag.tsx +35 -0
  196. package/src/ui/design-system/components/showcase/NavItem.case.tsx +86 -0
  197. package/src/ui/design-system/components/showcase/NavItem.css +111 -0
  198. package/src/ui/design-system/components/showcase/NavItem.placard.md +13 -0
  199. package/src/ui/design-system/components/showcase/NavItem.test.tsx +65 -0
  200. package/src/ui/design-system/components/showcase/NavItem.tsx +95 -0
  201. package/src/ui/design-system/components/showcase/RenderAddress.case.tsx +21 -0
  202. package/src/ui/design-system/components/showcase/RenderAddress.css +35 -0
  203. package/src/ui/design-system/components/showcase/RenderAddress.placard.md +7 -0
  204. package/src/ui/design-system/components/showcase/RenderAddress.test.tsx +26 -0
  205. package/src/ui/design-system/components/showcase/RenderAddress.tsx +43 -0
  206. package/src/ui/design-system/components/showcase/SegmentedToggle.case.tsx +84 -0
  207. package/src/ui/design-system/components/showcase/SegmentedToggle.css +61 -0
  208. package/src/ui/design-system/components/showcase/SegmentedToggle.placard.md +21 -0
  209. package/src/ui/design-system/components/showcase/SegmentedToggle.test.tsx +81 -0
  210. package/src/ui/design-system/components/showcase/SegmentedToggle.tsx +75 -0
  211. package/src/ui/design-system/components/showcase/Sidebar.case.tsx +67 -0
  212. package/src/ui/design-system/components/showcase/Sidebar.css +6 -0
  213. package/src/ui/design-system/components/showcase/Sidebar.placard.md +14 -0
  214. package/src/ui/design-system/components/showcase/Sidebar.test.tsx +32 -0
  215. package/src/ui/design-system/components/showcase/Sidebar.tsx +30 -0
  216. package/src/ui/design-system/components/showcase/Stage.case.tsx +51 -0
  217. package/src/ui/design-system/components/showcase/Stage.css +91 -0
  218. package/src/ui/design-system/components/showcase/Stage.placard.md +15 -0
  219. package/src/ui/design-system/components/showcase/Stage.test.tsx +84 -0
  220. package/src/ui/design-system/components/showcase/Stage.tsx +97 -0
  221. package/src/ui/design-system/components/showcase/TweaksPanel.case.tsx +81 -0
  222. package/src/ui/design-system/components/showcase/TweaksPanel.css +169 -0
  223. package/src/ui/design-system/components/showcase/TweaksPanel.placard.md +20 -0
  224. package/src/ui/design-system/components/showcase/TweaksPanel.tsx +230 -0
  225. package/src/ui/design-system/components/showcase/Wordmark.case.tsx +42 -0
  226. package/src/ui/design-system/components/showcase/Wordmark.css +31 -0
  227. package/src/ui/design-system/components/showcase/Wordmark.placard.md +10 -0
  228. package/src/ui/design-system/components/showcase/Wordmark.test.tsx +22 -0
  229. package/src/ui/design-system/components/showcase/Wordmark.tsx +22 -0
  230. package/src/ui/design-system/primer-specimens/brand.tsx +26 -0
  231. package/src/ui/design-system/primer-specimens/colors.tsx +83 -0
  232. package/src/ui/design-system/primer-specimens/components.tsx +308 -0
  233. package/src/ui/design-system/primer-specimens/foundations.tsx +71 -0
  234. package/src/ui/design-system/primer-specimens/index.ts +25 -0
  235. package/src/ui/design-system/primer-specimens/showcase.tsx +68 -0
  236. package/src/ui/design-system/primer-specimens/spacing.tsx +101 -0
  237. package/src/ui/design-system/primer-specimens/type.tsx +75 -0
  238. package/src/ui/design-system/primer.mdx +236 -0
  239. package/src/ui/design-system/styles.css +14 -0
  240. package/src/ui/design-system/tokens/colors.css +172 -0
  241. package/src/ui/design-system/tokens/fonts.css +18 -0
  242. package/src/ui/design-system/tokens/spacing.css +48 -0
  243. package/src/ui/design-system/tokens/typography.css +49 -0
  244. package/src/ui/markdown.test.tsx +54 -0
  245. package/src/ui/markdown.tsx +19 -0
  246. package/src/ui/primer-mount.tsx +76 -0
  247. package/src/ui/primer.css +175 -0
  248. package/src/ui/primer.tsx +277 -0
  249. package/src/ui/render-mount.tsx +284 -0
  250. package/src/ui/shell-core.test.ts +340 -0
  251. package/src/ui/shell-core.ts +295 -0
  252. package/src/ui/shell.tsx +60 -0
  253. package/src/ui/test-ids.ts +53 -0
  254. package/src/ui/use-shell.ts +1230 -0
@@ -0,0 +1,988 @@
1
+ import { dirname, join, relative, resolve } from 'node:path'
2
+ import { Glob } from 'bun'
3
+ import { slugify } from '../core/catalog'
4
+ import {
5
+ discoverCaseFiles,
6
+ loadModules,
7
+ resolveConfig,
8
+ } from '../core/discovery'
9
+ import { segmentMdx } from '../core/mdx-lite'
10
+ import type {
11
+ CaseModule,
12
+ DisplayCaseConfig,
13
+ HierarchyLevel,
14
+ StructureRuleId,
15
+ StructureRuleOptions,
16
+ StructureSeverity,
17
+ } from '../index'
18
+ import { HIERARCHY_LEVELS } from '../index'
19
+ import { blankComments } from './check-text'
20
+
21
+ /**
22
+ * Static "structure" best-practice checks for a Display-Case-ingested package.
23
+ *
24
+ * Browser-free and side-effect-free: every rule reads files, the resolved
25
+ * config, and the loaded case modules — never a render or a server. The rules
26
+ * fall into three groups (file/config, catalog-integrity, composition), each
27
+ * independently disablable and severity-tunable via `config.check.structure`.
28
+ * See `openspec/specs/display-case/spec.md` and the change's design.md.
29
+ */
30
+
31
+ export interface StructureFinding {
32
+ rule: StructureRuleId
33
+ severity: StructureSeverity
34
+ /** Absolute path of the file the finding is attributed to. */
35
+ file: string
36
+ message: string
37
+ }
38
+
39
+ export interface StructureCheckResult {
40
+ findings: StructureFinding[]
41
+ }
42
+
43
+ /**
44
+ * What a rule returns: a finding minus its severity (filled in from the rule's
45
+ * resolved severity), unless the rule pins one itself — e.g. the scoped
46
+ * "unresolved showcase import" notice is always a warning even under an
47
+ * error-severity composition rule.
48
+ */
49
+ type RuleFinding = Omit<StructureFinding, 'severity'> & {
50
+ severity?: StructureSeverity
51
+ }
52
+
53
+ interface RuleDefault {
54
+ enabled: boolean
55
+ severity: StructureSeverity
56
+ }
57
+
58
+ /** Out-of-the-box enabled-state + severity for every rule. */
59
+ const RULE_DEFAULTS: Record<StructureRuleId, RuleDefault> = {
60
+ 'case-placard-coverage': { enabled: true, severity: 'error' },
61
+ 'no-orphaned-placard-doc': { enabled: true, severity: 'error' },
62
+ 'primer-present-and-used': { enabled: true, severity: 'error' },
63
+ 'setup-present': { enabled: true, severity: 'error' },
64
+ 'config-paths-exist': { enabled: true, severity: 'error' },
65
+ 'levels-classified': { enabled: true, severity: 'error' },
66
+ 'cases-load': { enabled: true, severity: 'error' },
67
+ 'flow-transitions-resolve': { enabled: true, severity: 'error' },
68
+ 'flow-multi-step': { enabled: true, severity: 'error' },
69
+ 'unique-slugs': { enabled: true, severity: 'error' },
70
+ 'tweak-defaults-valid': { enabled: true, severity: 'error' },
71
+ 'interactive-cases-keyed': { enabled: true, severity: 'error' },
72
+ 'atom-purity': { enabled: false, severity: 'error' },
73
+ 'no-downward-dependency': { enabled: false, severity: 'error' },
74
+ 'composes-lower-level': { enabled: false, severity: 'warn' },
75
+ 'level-fit': { enabled: false, severity: 'warn' },
76
+ }
77
+
78
+ /** `level-fit` default per-level promotion thresholds (lower-level child count). */
79
+ const DEFAULT_THRESHOLDS: Partial<Record<HierarchyLevel, number>> = {
80
+ molecule: 6,
81
+ organism: 12,
82
+ }
83
+
84
+ interface ResolvedRule {
85
+ enabled: boolean
86
+ severity: StructureSeverity
87
+ options: StructureRuleOptions
88
+ }
89
+
90
+ function resolveRule(
91
+ id: StructureRuleId,
92
+ config: DisplayCaseConfig,
93
+ ): ResolvedRule {
94
+ const def = RULE_DEFAULTS[id]
95
+ const setting = config.check?.structure?.rules?.[id]
96
+ if (setting === undefined) {
97
+ return { enabled: def.enabled, severity: def.severity, options: {} }
98
+ }
99
+ if (setting === false) {
100
+ return { enabled: false, severity: def.severity, options: {} }
101
+ }
102
+ if (setting === 'warn' || setting === 'error') {
103
+ return { enabled: true, severity: setting, options: {} }
104
+ }
105
+ return {
106
+ enabled: true,
107
+ severity: setting.severity ?? def.severity,
108
+ options: setting,
109
+ }
110
+ }
111
+
112
+ const levelIndex = (l: HierarchyLevel | null): number =>
113
+ l ? HIERARCHY_LEVELS.indexOf(l) : HIERARCHY_LEVELS.length
114
+
115
+ /** A `display-case: <token>` marker for any of the given tokens. */
116
+ function hasMarker(text: string, tokens: string[]): boolean {
117
+ return tokens.some((t) =>
118
+ new RegExp(`display-case:\\s*${t}(\\s|$)`).test(text),
119
+ )
120
+ }
121
+
122
+ // ── Shared inputs ────────────────────────────────────────────────────────────
123
+
124
+ interface SharedInputs {
125
+ pkgDir: string
126
+ config: DisplayCaseConfig
127
+ configPath: string
128
+ caseFiles: string[]
129
+ modules: { file: string; module: CaseModule }[]
130
+ loadErrors: { file: string; error: string }[]
131
+ /** Where the checks' own tooling resolves from (the display-case package). */
132
+ toolingDir: string
133
+ }
134
+
135
+ // ── File / config rules ──────────────────────────────────────────────────────
136
+
137
+ /** Component-module globs implied by `*.case.tsx` roots (sibling `*.tsx`). */
138
+ function componentGlobs(config: DisplayCaseConfig): string[] {
139
+ return config.roots
140
+ .filter((r) => r.endsWith('.case.tsx'))
141
+ .map((r) => r.replace(/\.case\.tsx$/, '.tsx'))
142
+ }
143
+
144
+ function isNonComponent(file: string): boolean {
145
+ return (
146
+ file.endsWith('.case.tsx') ||
147
+ file.endsWith('.d.ts') ||
148
+ file.endsWith('.test.tsx') ||
149
+ file.endsWith('.test.ts')
150
+ )
151
+ }
152
+
153
+ async function ruleCasePlacardCoverage(
154
+ s: SharedInputs,
155
+ ): Promise<Omit<StructureFinding, 'severity'>[]> {
156
+ const out: Omit<StructureFinding, 'severity'>[] = []
157
+ for (const pattern of componentGlobs(s.config)) {
158
+ const glob = new Glob(pattern)
159
+ for await (const file of glob.scan({ cwd: s.pkgDir, absolute: true })) {
160
+ if (file.includes('/node_modules/') || isNonComponent(file)) continue
161
+ const text = await Bun.file(file).text()
162
+ // `no-case` declares a module non-showcasable (existing convention), so it
163
+ // is fully exempt — a non-component needs neither a case nor a prompt.
164
+ if (hasMarker(text, ['no-case', 'allow-case-placard-coverage'])) continue
165
+ const base = file.replace(/\.tsx$/, '')
166
+ const needsPrompt = !hasMarker(text, ['no-placard'])
167
+ if (!(await Bun.file(`${base}.case.tsx`).exists())) {
168
+ out.push({
169
+ rule: 'case-placard-coverage',
170
+ file,
171
+ message:
172
+ 'missing colocated case file (expected a sibling *.case.tsx)',
173
+ })
174
+ }
175
+ if (needsPrompt && !(await Bun.file(`${base}.placard.md`).exists())) {
176
+ out.push({
177
+ rule: 'case-placard-coverage',
178
+ file,
179
+ message:
180
+ 'missing colocated usage doc (expected a sibling *.placard.md)',
181
+ })
182
+ }
183
+ }
184
+ }
185
+ return out
186
+ }
187
+
188
+ async function ruleNoOrphanedPlacardDoc(
189
+ s: SharedInputs,
190
+ ): Promise<Omit<StructureFinding, 'severity'>[]> {
191
+ const out: Omit<StructureFinding, 'severity'>[] = []
192
+ const placardGlobs = s.config.roots
193
+ .filter((r) => r.endsWith('.case.tsx'))
194
+ .map((r) => r.replace(/\.case\.tsx$/, '.placard.md'))
195
+ const seen = new Set<string>()
196
+ for (const pattern of placardGlobs) {
197
+ const glob = new Glob(pattern)
198
+ for await (const file of glob.scan({ cwd: s.pkgDir, absolute: true })) {
199
+ if (file.includes('/node_modules/') || seen.has(file)) continue
200
+ seen.add(file)
201
+ const text = await Bun.file(file).text()
202
+ if (hasMarker(text, ['allow-orphan', 'allow-no-orphaned-placard-doc'])) {
203
+ continue
204
+ }
205
+ const casePath = file.replace(/\.placard\.md$/, '.case.tsx')
206
+ if (!(await Bun.file(casePath).exists())) {
207
+ out.push({
208
+ rule: 'no-orphaned-placard-doc',
209
+ file,
210
+ message: 'orphaned usage doc (no sibling *.case.tsx)',
211
+ })
212
+ }
213
+ }
214
+ }
215
+ return out
216
+ }
217
+
218
+ async function ruleConfigPathsExist(
219
+ s: SharedInputs,
220
+ ): Promise<Omit<StructureFinding, 'severity'>[]> {
221
+ const out: Omit<StructureFinding, 'severity'>[] = []
222
+ for (const rel of s.config.globalStyles ?? []) {
223
+ if (!(await Bun.file(resolve(s.pkgDir, rel)).exists())) {
224
+ out.push({
225
+ rule: 'config-paths-exist',
226
+ file: s.configPath,
227
+ message: `globalStyles entry does not exist: ${rel}`,
228
+ })
229
+ }
230
+ }
231
+ if (s.config.baselineDir) {
232
+ const dir = resolve(s.pkgDir, s.config.baselineDir)
233
+ // A baseline dir may legitimately not exist yet (nothing recorded); only an
234
+ // absolute/explicit path that resolves to a *file* is wrong. Skip silently
235
+ // when absent — recording creates it — so this just guards a misconfigured
236
+ // path that collides with a file.
237
+ const f = Bun.file(dir)
238
+ if ((await f.exists()) && (await f.stat()).isFile()) {
239
+ out.push({
240
+ rule: 'config-paths-exist',
241
+ file: s.configPath,
242
+ message: `baselineDir points at a file, not a directory: ${s.config.baselineDir}`,
243
+ })
244
+ }
245
+ }
246
+ return out
247
+ }
248
+
249
+ async function ruleSetupPresent(
250
+ s: SharedInputs,
251
+ ): Promise<Omit<StructureFinding, 'severity'>[]> {
252
+ if (s.config.providers?.driver || s.config.providers?.diff) return []
253
+ const required = ['playwright', '@axe-core/playwright', 'pixelmatch', 'pngjs']
254
+ // The default backend resolves the toolchain relative to the display-case
255
+ // package (see check.ts → providers/*), NOT the consumer. Probe both: the
256
+ // setup is present if either the showcase or the checks' own tooling can
257
+ // resolve it, so a consumer that gets the toolchain transitively via
258
+ // display-case is not falsely flagged.
259
+ const probeDirs = [s.pkgDir, s.toolingDir]
260
+ const resolvable = (pkg: string): boolean =>
261
+ probeDirs.some((dir) => {
262
+ try {
263
+ Bun.resolveSync(pkg, dir)
264
+ return true
265
+ } catch {
266
+ return false
267
+ }
268
+ })
269
+ const missing = required.filter((pkg) => !resolvable(pkg))
270
+ if (missing.length === 0) return []
271
+ return [
272
+ {
273
+ rule: 'setup-present',
274
+ file: s.configPath,
275
+ message:
276
+ `render checks cannot run: missing ${missing.join(', ')}. Install the ` +
277
+ 'default toolchain (or run `display-case init --with-visual`), or set ' +
278
+ '`providers.driver`/`providers.diff` in the config.',
279
+ },
280
+ ]
281
+ }
282
+
283
+ async function rulePrimerPresentAndUsed(
284
+ s: SharedInputs,
285
+ ): Promise<Omit<StructureFinding, 'severity'>[]> {
286
+ const find = (message: string): Omit<StructureFinding, 'severity'>[] => [
287
+ { rule: 'primer-present-and-used', file: s.configPath, message },
288
+ ]
289
+ if (!s.config.primer) return find('no primer is configured')
290
+ const path = resolve(s.pkgDir, s.config.primer)
291
+ if (!(await Bun.file(path).exists())) {
292
+ return find(`configured primer does not exist: ${s.config.primer}`)
293
+ }
294
+ const text = await Bun.file(path).text()
295
+ let blocks: ReturnType<typeof segmentMdx>
296
+ try {
297
+ blocks = segmentMdx(text)
298
+ } catch (err) {
299
+ return find(
300
+ `could not parse primer: ${err instanceof Error ? err.message : String(err)}`,
301
+ )
302
+ }
303
+ // Count <Display> specimens across the document's block-level JSX, and confirm
304
+ // the primer has at least one prose block alongside them.
305
+ const displays = blocks
306
+ .filter((b) => b.kind === 'jsx')
307
+ .reduce((n, b) => n + b.tags.filter((t) => t === 'Display').length, 0)
308
+ const hasContent = blocks.some((b) => b.kind === 'markdown')
309
+ if (displays === 0) {
310
+ return find('primer embeds no <Display> specimen')
311
+ }
312
+ if (!hasContent) {
313
+ return find('primer has specimens but no prose content')
314
+ }
315
+ return []
316
+ }
317
+
318
+ // ── Catalog-integrity rules ──────────────────────────────────────────────────
319
+
320
+ function ruleCasesLoad(s: SharedInputs): Omit<StructureFinding, 'severity'>[] {
321
+ return s.loadErrors.map((e) => ({
322
+ rule: 'cases-load' as const,
323
+ file: e.file,
324
+ message: `case file failed to load: ${e.error}`,
325
+ }))
326
+ }
327
+
328
+ async function ruleLevelsClassified(
329
+ s: SharedInputs,
330
+ ): Promise<Omit<StructureFinding, 'severity'>[]> {
331
+ const out: Omit<StructureFinding, 'severity'>[] = []
332
+ for (const { file, module } of s.modules) {
333
+ if (module.level) continue
334
+ const text = await Bun.file(file).text()
335
+ if (hasMarker(text, ['unclassified', 'allow-levels-classified'])) continue
336
+ out.push({
337
+ rule: 'levels-classified',
338
+ file,
339
+ message: `component "${module.component}" declares no hierarchy level (unclassified)`,
340
+ })
341
+ }
342
+ return out
343
+ }
344
+
345
+ function ruleFlowTransitionsResolve(
346
+ s: SharedInputs,
347
+ ): Omit<StructureFinding, 'severity'>[] {
348
+ const out: Omit<StructureFinding, 'severity'>[] = []
349
+ for (const { file, module } of s.modules) {
350
+ if (!module.isFlow) continue
351
+ const stepIds = new Set(Object.keys(module.cases).map(slugify))
352
+ for (const [stepName, step] of Object.entries(module.cases)) {
353
+ if (typeof step === 'function' || !('transitions' in step)) continue
354
+ for (const target of step.transitions ?? []) {
355
+ if (!stepIds.has(slugify(target))) {
356
+ out.push({
357
+ rule: 'flow-transitions-resolve',
358
+ file,
359
+ message: `flow "${module.component}" step "${stepName}" transitions to unknown step "${target}"`,
360
+ })
361
+ }
362
+ }
363
+ }
364
+ }
365
+ return out
366
+ }
367
+
368
+ function ruleFlowMultiStep(
369
+ s: SharedInputs,
370
+ ): Omit<StructureFinding, 'severity'>[] {
371
+ const out: Omit<StructureFinding, 'severity'>[] = []
372
+ for (const { file, module } of s.modules) {
373
+ if (module.isFlow && Object.keys(module.cases).length <= 1) {
374
+ out.push({
375
+ rule: 'flow-multi-step',
376
+ file,
377
+ message: `flow "${module.component}" has ${Object.keys(module.cases).length} step(s); a flow needs more than one (use defineCases for a single state)`,
378
+ })
379
+ }
380
+ }
381
+ return out
382
+ }
383
+
384
+ function ruleUniqueSlugs(
385
+ s: SharedInputs,
386
+ ): Omit<StructureFinding, 'severity'>[] {
387
+ const out: Omit<StructureFinding, 'severity'>[] = []
388
+ const byComponentSlug = new Map<string, { file: string; name: string }[]>()
389
+ for (const { file, module } of s.modules) {
390
+ const slug = slugify(module.component)
391
+ const arr = byComponentSlug.get(slug) ?? []
392
+ arr.push({ file, name: module.component })
393
+ byComponentSlug.set(slug, arr)
394
+ // Case-slug collisions within this component.
395
+ const caseSlugs = new Map<string, string[]>()
396
+ for (const name of Object.keys(module.cases)) {
397
+ const cs = slugify(name)
398
+ const names = caseSlugs.get(cs) ?? []
399
+ names.push(name)
400
+ caseSlugs.set(cs, names)
401
+ }
402
+ for (const [cs, names] of caseSlugs) {
403
+ if (names.length > 1) {
404
+ out.push({
405
+ rule: 'unique-slugs',
406
+ file,
407
+ message: `component "${module.component}" has cases colliding on slug "${cs}": ${names.join(', ')}`,
408
+ })
409
+ }
410
+ }
411
+ }
412
+ for (const [slug, entries] of byComponentSlug) {
413
+ if (entries.length > 1) {
414
+ out.push({
415
+ rule: 'unique-slugs',
416
+ file: entries[0].file,
417
+ message: `components collide on slug "${slug}": ${entries.map((e) => e.name).join(', ')}`,
418
+ })
419
+ }
420
+ }
421
+ return out
422
+ }
423
+
424
+ function ruleTweakDefaultsValid(
425
+ s: SharedInputs,
426
+ ): Omit<StructureFinding, 'severity'>[] {
427
+ const out: Omit<StructureFinding, 'severity'>[] = []
428
+ for (const { file, module } of s.modules) {
429
+ for (const [caseName, c] of Object.entries(module.cases)) {
430
+ const tweaks = typeof c === 'function' ? null : c.tweaks
431
+ if (!tweaks) continue
432
+ for (const [tweakName, t] of Object.entries(tweaks)) {
433
+ if (t.kind === 'choice' && !t.options.includes(t.default as string)) {
434
+ out.push({
435
+ rule: 'tweak-defaults-valid',
436
+ file,
437
+ message: `${module.component} / ${caseName}: choice tweak "${tweakName}" default "${t.default}" is not one of its options`,
438
+ })
439
+ }
440
+ }
441
+ }
442
+ }
443
+ return out
444
+ }
445
+
446
+ // ── Case-content rules ───────────────────────────────────────────────────────
447
+
448
+ /**
449
+ * Find the end of a JSX opening tag that starts at `from` (`<`), tolerating
450
+ * `>` inside `{…}` attribute expressions (e.g. `onClick={() => a > b}`) and the
451
+ * `>` of an arrow `=>`. Returns the index of the tag-closing `>`.
452
+ */
453
+ function openingTagEnd(text: string, from: number): number {
454
+ let depth = 0
455
+ for (let i = from; i < text.length; i++) {
456
+ const c = text[i]
457
+ if (c === '{') depth++
458
+ else if (c === '}') depth--
459
+ else if (c === '>' && depth === 0 && text[i - 1] !== '=') return i
460
+ }
461
+ return text.length
462
+ }
463
+
464
+ /** Names of locally-defined components whose body calls a React state hook. */
465
+ function statefulLocalComponents(text: string): Set<string> {
466
+ // Every local component definition and where it starts.
467
+ const defs: { name: string; index: number }[] = []
468
+ const defRe = /(?:function\s+([A-Z]\w*)|const\s+([A-Z]\w*)\s*=)/g
469
+ for (let m = defRe.exec(text); m; m = defRe.exec(text)) {
470
+ defs.push({ name: m[1] ?? m[2], index: m.index })
471
+ }
472
+ // Attribute each `useState`/`useReducer` to the closest preceding definition —
473
+ // that component holds the state. (A pure helper in the same file is ignored.)
474
+ const stateful = new Set<string>()
475
+ const hookRe = /\buse(?:State|Reducer)\b/g
476
+ for (let h = hookRe.exec(text); h; h = hookRe.exec(text)) {
477
+ let owner: string | null = null
478
+ for (const d of defs) {
479
+ if (d.index < h.index) owner = d.name
480
+ else break
481
+ }
482
+ if (owner) stateful.add(owner)
483
+ }
484
+ return stateful
485
+ }
486
+
487
+ /**
488
+ * The browse chrome swaps cases *in place* — `root.render()` with no remount
489
+ * (render-mount.tsx) — so a stateful specimen rendered at the same tree position
490
+ * across cases keeps its `useState` value unless given a distinct `key`. Between
491
+ * cases whose props differ (a different selected id, a disjoint option set) the
492
+ * leaked state shows the wrong selection — or none at all — on switch. Flag a
493
+ * locally-defined stateful specimen rendered in ≥2 cases where any usage omits a
494
+ * `key`. (Single-use specimens always remount — the sibling case renders a
495
+ * different element — so they are safe.)
496
+ */
497
+ async function ruleInteractiveCasesKeyed(
498
+ s: SharedInputs,
499
+ ): Promise<RuleFinding[]> {
500
+ const out: RuleFinding[] = []
501
+ for (const file of s.caseFiles) {
502
+ if (file.includes('/node_modules/')) continue
503
+ const raw = await Bun.file(file).text()
504
+ if (hasMarker(raw, ['allow-interactive-cases-keyed'])) continue
505
+ // Scan with comments blanked (offsets preserved) so a `<Demo>` mentioned in
506
+ // prose — like this rule's own guidance comment — is never miscounted.
507
+ const text = blankComments(raw, false)
508
+ const stateful = statefulLocalComponents(text)
509
+ if (stateful.size === 0) continue
510
+ // Only the case thunks (which follow `defineCases(`) render specimens; the
511
+ // component definitions above it hold their own internal JSX, which is not a
512
+ // per-case mount and must not be counted.
513
+ const dc = text.search(/\bdefineCases\s*\(/)
514
+ if (dc < 0) continue
515
+ const region = text.slice(dc)
516
+ for (const name of stateful) {
517
+ const useRe = new RegExp(`<${name}(?![A-Za-z0-9_])`, 'g')
518
+ let total = 0
519
+ let unkeyed = 0
520
+ for (let m = useRe.exec(region); m; m = useRe.exec(region)) {
521
+ total++
522
+ const tag = region.slice(m.index, openingTagEnd(region, m.index) + 1)
523
+ if (!/\bkey\s*=/.test(tag)) unkeyed++
524
+ }
525
+ if (total >= 2 && unkeyed > 0) {
526
+ out.push({
527
+ rule: 'interactive-cases-keyed',
528
+ file,
529
+ message: `interactive specimen <${name}> is rendered in ${total} cases but ${unkeyed} omit a \`key\` — the browse chrome swaps cases in place (no remount), so React reuses <${name}>'s state across them, leaving a stale or empty selection on switch. Give each case's <${name}> a distinct \`key\` (see docs/writing-cases.md), or waive with a \`display-case: allow-interactive-cases-keyed\` comment.`,
530
+ })
531
+ }
532
+ }
533
+ }
534
+ return out
535
+ }
536
+
537
+ // ── Composition (import-graph) rules ─────────────────────────────────────────
538
+
539
+ /** Map an absolute component-source path to its declared level (built per pkg). */
540
+ type LevelMap = Map<string, HierarchyLevel | null>
541
+
542
+ function buildLevelMap(modules: SharedInputs['modules']): LevelMap {
543
+ const map: LevelMap = new Map()
544
+ for (const { file, module } of modules) {
545
+ map.set(file.replace(/\.case\.tsx$/, '.tsx'), module.level ?? null)
546
+ }
547
+ return map
548
+ }
549
+
550
+ interface ParsedImport {
551
+ source: string
552
+ names: string[]
553
+ typeOnly: boolean
554
+ }
555
+
556
+ const IMPORT_RE = /import\s+(type\s+)?([\s\S]*?)\s+from\s*['"]([^'"]+)['"]/g
557
+
558
+ // Reused across files. Bun's transpiler resolves which imports are *real* —
559
+ // ignoring commented-out and string-literal imports, and dropping (erased)
560
+ // type-only ones — so a composition dependency can't be conjured by a comment.
561
+ const IMPORT_SCANNER = new Bun.Transpiler({ loader: 'tsx' })
562
+
563
+ /** The set of genuine runtime import paths Bun's parser sees, or null if the
564
+ * file has syntax the scanner rejects (then the regex stands alone). */
565
+ function realImportPaths(code: string): Set<string> | null {
566
+ try {
567
+ return new Set(
568
+ IMPORT_SCANNER.scan(code)
569
+ .imports.filter((i) => i.kind === 'import-statement')
570
+ .map((i) => i.path),
571
+ )
572
+ } catch {
573
+ // Bun's scanner throws on a few JSX shapes (e.g. `key` after a spread). Fall
574
+ // back to the regex alone rather than dropping the file's imports entirely.
575
+ return null
576
+ }
577
+ }
578
+
579
+ /** Parse import statements for source + named bindings (skips bare side-effect
580
+ * imports). Bun's transpiler supplies the authoritative set of real imports; the
581
+ * regex contributes the named bindings it doesn't expose. */
582
+ function parseImports(code: string): ParsedImport[] {
583
+ const real = realImportPaths(code)
584
+ const out: ParsedImport[] = []
585
+ IMPORT_RE.lastIndex = 0
586
+ for (let m = IMPORT_RE.exec(code); m; m = IMPORT_RE.exec(code)) {
587
+ const typeOnly = Boolean(m[1])
588
+ const clause = m[2]
589
+ const source = m[3]
590
+ // When the scanner ran, trust it: keep only statements it confirmed are real
591
+ // runtime imports (this drops type-only, commented, and string-literal
592
+ // matches). When it threw, `real` is null and every regex match is kept.
593
+ if (real && !real.has(source)) continue
594
+ const names: string[] = []
595
+ const braced = clause.match(/\{([^}]*)\}/)
596
+ if (braced) {
597
+ for (const part of braced[1].split(',')) {
598
+ const name = part
599
+ .trim()
600
+ .split(/\s+as\s+/)[0]
601
+ .trim()
602
+ if (name && name !== 'type') names.push(name)
603
+ }
604
+ }
605
+ out.push({ source, names, typeOnly })
606
+ }
607
+ return out
608
+ }
609
+
610
+ const RESOLVE_EXTS = ['.tsx', '.ts', '.jsx', '.js']
611
+ function candidatePaths(base: string): string[] {
612
+ return [
613
+ base,
614
+ ...RESOLVE_EXTS.map((e) => base + e),
615
+ ...RESOLVE_EXTS.map((e) => join(base, `index${e}`)),
616
+ ]
617
+ }
618
+
619
+ /** Foreign workspace showcase catalog, cached per package root. */
620
+ interface ForeignShowcase {
621
+ pkgDir: string
622
+ levelMap: LevelMap
623
+ /** Re-export name → absolute target file (best-effort, common barrel form). */
624
+ reexports: Map<string, string>
625
+ }
626
+
627
+ const foreignCache = new Map<string, ForeignShowcase | null>()
628
+
629
+ async function loadForeignShowcase(
630
+ pkgDir: string,
631
+ ): Promise<ForeignShowcase | null> {
632
+ if (foreignCache.has(pkgDir)) return foreignCache.get(pkgDir) ?? null
633
+ let result: ForeignShowcase | null = null
634
+ try {
635
+ const hasConfig =
636
+ (await Bun.file(join(pkgDir, 'display-case.config.ts')).exists()) ||
637
+ (await Bun.file(join(pkgDir, 'display-case.config.tsx')).exists())
638
+ if (hasConfig) {
639
+ const { config } = await resolveConfig(pkgDir)
640
+ const files = await discoverCaseFiles(pkgDir, config)
641
+ const { modules } = await loadModules(files)
642
+ const levelMap = buildLevelMap(
643
+ modules.map((m) => ({ file: m.file, module: m.module })),
644
+ )
645
+ result = { pkgDir, levelMap, reexports: new Map() }
646
+ }
647
+ } catch {
648
+ result = null
649
+ }
650
+ foreignCache.set(pkgDir, result)
651
+ return result
652
+ }
653
+
654
+ const REEXPORT_RE = /export\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g
655
+
656
+ /** Resolve a named re-export through a barrel entry to a target component file. */
657
+ async function followReexport(
658
+ entryFile: string,
659
+ name: string,
660
+ ): Promise<string | null> {
661
+ let text: string
662
+ try {
663
+ text = await Bun.file(entryFile).text()
664
+ } catch {
665
+ return null
666
+ }
667
+ REEXPORT_RE.lastIndex = 0
668
+ for (let m = REEXPORT_RE.exec(text); m; m = REEXPORT_RE.exec(text)) {
669
+ const exported = m[1].split(',').map((p) =>
670
+ p
671
+ .trim()
672
+ .split(/\s+as\s+/)
673
+ .pop()
674
+ ?.trim(),
675
+ )
676
+ if (!exported.includes(name)) continue
677
+ for (const cand of candidatePaths(resolve(dirname(entryFile), m[2]))) {
678
+ if (await Bun.file(cand).exists()) return cand
679
+ }
680
+ }
681
+ return null
682
+ }
683
+
684
+ interface DepResolution {
685
+ /** Levels of resolved showcased components this import contributes. */
686
+ levels: (HierarchyLevel | null)[]
687
+ /** True when it looked like a workspace showcase import but couldn't resolve. */
688
+ unresolvedShowcase: boolean
689
+ }
690
+
691
+ async function resolveDependency(
692
+ imp: ParsedImport,
693
+ fromFile: string,
694
+ pkgDir: string,
695
+ levelMap: LevelMap,
696
+ ): Promise<DepResolution> {
697
+ const res: DepResolution = { levels: [], unresolvedShowcase: false }
698
+ if (imp.typeOnly) return res
699
+
700
+ // Relative import: resolve straight to a component file.
701
+ if (imp.source.startsWith('.')) {
702
+ const base = resolve(dirname(fromFile), imp.source)
703
+ for (const cand of candidatePaths(base)) {
704
+ if (levelMap.has(cand)) {
705
+ res.levels.push(levelMap.get(cand) ?? null)
706
+ return res
707
+ }
708
+ }
709
+ return res
710
+ }
711
+
712
+ // Bare specifier: cross-package. Only workspace showcases expose levels.
713
+ let entry: string
714
+ try {
715
+ entry = Bun.resolveSync(imp.source, pkgDir)
716
+ } catch {
717
+ return res
718
+ }
719
+ // Walk up from the resolved entry to a package root holding a showcase config.
720
+ let dir = dirname(entry)
721
+ let foreign: ForeignShowcase | null = null
722
+ for (let i = 0; i < 12; i++) {
723
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
724
+ foreign = await loadForeignShowcase(dir)
725
+ break
726
+ }
727
+ const parent = dirname(dir)
728
+ if (parent === dir) break
729
+ dir = parent
730
+ }
731
+ if (!foreign) return res
732
+ for (const name of imp.names) {
733
+ const target = await followReexport(entry, name)
734
+ if (target && foreign.levelMap.has(target)) {
735
+ res.levels.push(foreign.levelMap.get(target) ?? null)
736
+ } else {
737
+ res.unresolvedShowcase = true
738
+ }
739
+ }
740
+ return res
741
+ }
742
+
743
+ interface CompositionContext {
744
+ s: SharedInputs
745
+ levelMap: LevelMap
746
+ }
747
+
748
+ /** Per-component resolved dependency levels (shared across composition rules). */
749
+ async function resolveComponentDeps(ctx: CompositionContext): Promise<
750
+ {
751
+ file: string
752
+ module: CaseModule
753
+ depLevels: (HierarchyLevel | null)[]
754
+ unresolvedShowcase: boolean
755
+ }[]
756
+ > {
757
+ const out = []
758
+ for (const { file, module } of ctx.s.modules) {
759
+ if (module.isFlow || !module.level) continue
760
+ const compFile = file.replace(/\.case\.tsx$/, '.tsx')
761
+ if (!(await Bun.file(compFile).exists())) continue
762
+ const code = await Bun.file(compFile).text()
763
+ const imports = parseImports(code)
764
+ const depLevels: (HierarchyLevel | null)[] = []
765
+ let unresolvedShowcase = false
766
+ for (const imp of imports) {
767
+ const dep = await resolveDependency(
768
+ imp,
769
+ compFile,
770
+ ctx.s.pkgDir,
771
+ ctx.levelMap,
772
+ )
773
+ depLevels.push(...dep.levels)
774
+ if (dep.unresolvedShowcase) unresolvedShowcase = true
775
+ }
776
+ out.push({ file: compFile, module, depLevels, unresolvedShowcase })
777
+ }
778
+ return out
779
+ }
780
+
781
+ function markerExempt(code: string, id: StructureRuleId): boolean {
782
+ return hasMarker(code, [`allow-${id}`])
783
+ }
784
+
785
+ async function ruleAtomPurity(ctx: CompositionContext): Promise<RuleFinding[]> {
786
+ const out: RuleFinding[] = []
787
+ const deps = await resolveComponentDeps(ctx)
788
+ for (const d of deps) {
789
+ if (d.module.level !== 'atom') continue
790
+ const code = await Bun.file(d.file).text()
791
+ if (markerExempt(code, 'atom-purity')) continue
792
+ if (d.depLevels.length > 0) {
793
+ out.push({
794
+ rule: 'atom-purity',
795
+ file: d.file,
796
+ message: `atom "${d.module.component}" imports ${d.depLevels.length} other showcased component(s); atoms must be leaves`,
797
+ })
798
+ }
799
+ if (d.unresolvedShowcase) {
800
+ out.push({
801
+ rule: 'atom-purity',
802
+ severity: 'warn',
803
+ file: d.file,
804
+ message: `atom "${d.module.component}" imports a workspace showcase component that could not be resolved (skipped)`,
805
+ })
806
+ }
807
+ }
808
+ return out
809
+ }
810
+
811
+ async function ruleNoDownwardDependency(
812
+ ctx: CompositionContext,
813
+ ): Promise<RuleFinding[]> {
814
+ const out: RuleFinding[] = []
815
+ const deps = await resolveComponentDeps(ctx)
816
+ for (const d of deps) {
817
+ const own = levelIndex(d.module.level ?? null)
818
+ const code = await Bun.file(d.file).text()
819
+ if (markerExempt(code, 'no-downward-dependency')) continue
820
+ for (const dl of d.depLevels) {
821
+ if (levelIndex(dl) > own) {
822
+ out.push({
823
+ rule: 'no-downward-dependency',
824
+ file: d.file,
825
+ message: `${d.module.level} "${d.module.component}" imports a higher-level (${dl}) component; composition must flow upward`,
826
+ })
827
+ }
828
+ }
829
+ if (d.unresolvedShowcase) {
830
+ out.push({
831
+ rule: 'no-downward-dependency',
832
+ severity: 'warn',
833
+ file: d.file,
834
+ message: `"${d.module.component}" imports a workspace showcase component that could not be resolved (skipped)`,
835
+ })
836
+ }
837
+ }
838
+ return out
839
+ }
840
+
841
+ async function ruleComposesLowerLevel(
842
+ ctx: CompositionContext,
843
+ ): Promise<RuleFinding[]> {
844
+ const out: RuleFinding[] = []
845
+ const deps = await resolveComponentDeps(ctx)
846
+ for (const d of deps) {
847
+ if (d.module.level === 'atom') continue
848
+ const own = levelIndex(d.module.level ?? null)
849
+ const code = await Bun.file(d.file).text()
850
+ if (markerExempt(code, 'composes-lower-level')) continue
851
+ const hasLower = d.depLevels.some((dl) => levelIndex(dl) < own)
852
+ if (hasLower) continue
853
+ if (d.unresolvedShowcase) {
854
+ // Can't confirm composition through an unfollowable workspace import — warn
855
+ // rather than assert the component composes nothing lower.
856
+ out.push({
857
+ rule: 'composes-lower-level',
858
+ severity: 'warn',
859
+ file: d.file,
860
+ message: `${d.module.level} "${d.module.component}" imports a workspace showcase component that could not be resolved; cannot confirm lower-level composition`,
861
+ })
862
+ } else {
863
+ out.push({
864
+ rule: 'composes-lower-level',
865
+ file: d.file,
866
+ message: `${d.module.level} "${d.module.component}" composes no lower-level showcased component`,
867
+ })
868
+ }
869
+ }
870
+ return out
871
+ }
872
+
873
+ async function ruleLevelFit(
874
+ ctx: CompositionContext,
875
+ options: StructureRuleOptions,
876
+ ): Promise<Omit<StructureFinding, 'severity'>[]> {
877
+ const out: Omit<StructureFinding, 'severity'>[] = []
878
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...(options.thresholds ?? {}) }
879
+ const deps = await resolveComponentDeps(ctx)
880
+ for (const d of deps) {
881
+ const level = d.module.level
882
+ if (!level) continue
883
+ const threshold = thresholds[level]
884
+ if (threshold === undefined) continue
885
+ const code = await Bun.file(d.file).text()
886
+ if (markerExempt(code, 'level-fit')) continue
887
+ const own = levelIndex(level)
888
+ const lower = d.depLevels.filter((dl) => levelIndex(dl) < own).length
889
+ if (lower > threshold) {
890
+ out.push({
891
+ rule: 'level-fit',
892
+ file: d.file,
893
+ message: `${level} "${d.module.component}" composes ${lower} lower-level components (> ${threshold}); consider promoting it`,
894
+ })
895
+ }
896
+ }
897
+ return out
898
+ }
899
+
900
+ // ── Orchestrator ─────────────────────────────────────────────────────────────
901
+
902
+ function matchesIgnore(
903
+ file: string,
904
+ pkgDir: string,
905
+ globs: string[] | undefined,
906
+ ): boolean {
907
+ if (!globs?.length) return false
908
+ const rel = relative(pkgDir, file)
909
+ return globs.some((g) => new Glob(g).match(rel))
910
+ }
911
+
912
+ export interface StructureOptions {
913
+ /** Treat all warnings as errors (CLI `--strict`). Merged with config.check.structure.strict. */
914
+ strict?: boolean
915
+ /**
916
+ * Directory the checks' own tooling resolves from, for `setup-present`'s
917
+ * second probe. Defaults to the display-case package (where the visual backend
918
+ * actually resolves the toolchain). Overridable for tests.
919
+ */
920
+ toolingDir?: string
921
+ }
922
+
923
+ export async function checkStructure(
924
+ pkgDir: string,
925
+ opts: StructureOptions = {},
926
+ ): Promise<StructureCheckResult> {
927
+ const { config, configPath } = await resolveConfig(pkgDir)
928
+ const caseFiles = await discoverCaseFiles(pkgDir, config)
929
+ const { modules, errors } = await loadModules(caseFiles)
930
+ const shared: SharedInputs = {
931
+ pkgDir,
932
+ config,
933
+ configPath,
934
+ caseFiles,
935
+ modules: modules.map((m) => ({ file: m.file, module: m.module })),
936
+ loadErrors: errors,
937
+ // import.meta.dir is the display-case package's src — the same resolution
938
+ // scope the visual backend (providers/*) loads the toolchain from.
939
+ toolingDir: opts.toolingDir ?? resolve(import.meta.dir, '..'),
940
+ }
941
+ const levelMap = buildLevelMap(shared.modules)
942
+ const ctx: CompositionContext = { s: shared, levelMap }
943
+
944
+ // Each entry: rule id → produce its (severity-less) findings.
945
+ const runners: Record<
946
+ StructureRuleId,
947
+ (o: StructureRuleOptions) => RuleFinding[] | Promise<RuleFinding[]>
948
+ > = {
949
+ 'case-placard-coverage': () => ruleCasePlacardCoverage(shared),
950
+ 'no-orphaned-placard-doc': () => ruleNoOrphanedPlacardDoc(shared),
951
+ 'primer-present-and-used': () => rulePrimerPresentAndUsed(shared),
952
+ 'setup-present': () => ruleSetupPresent(shared),
953
+ 'config-paths-exist': () => ruleConfigPathsExist(shared),
954
+ 'levels-classified': () => ruleLevelsClassified(shared),
955
+ 'cases-load': () => ruleCasesLoad(shared),
956
+ 'flow-transitions-resolve': () => ruleFlowTransitionsResolve(shared),
957
+ 'flow-multi-step': () => ruleFlowMultiStep(shared),
958
+ 'unique-slugs': () => ruleUniqueSlugs(shared),
959
+ 'tweak-defaults-valid': () => ruleTweakDefaultsValid(shared),
960
+ 'interactive-cases-keyed': () => ruleInteractiveCasesKeyed(shared),
961
+ 'atom-purity': () => ruleAtomPurity(ctx),
962
+ 'no-downward-dependency': () => ruleNoDownwardDependency(ctx),
963
+ 'composes-lower-level': () => ruleComposesLowerLevel(ctx),
964
+ 'level-fit': (o) => ruleLevelFit(ctx, o),
965
+ }
966
+
967
+ const ids = Object.keys(runners) as StructureRuleId[]
968
+ const perRule = await Promise.all(
969
+ ids.map(async (id) => {
970
+ const rule = resolveRule(id, config)
971
+ if (!rule.enabled) return [] as StructureFinding[]
972
+ const raw = await runners[id](rule.options)
973
+ return raw
974
+ .filter((f) => !matchesIgnore(f.file, pkgDir, rule.options.ignore))
975
+ .map((f) => ({ ...f, severity: f.severity ?? rule.severity }))
976
+ }),
977
+ )
978
+
979
+ const strict = opts.strict || config.check?.structure?.strict
980
+ const findings = perRule
981
+ .flat()
982
+ .map((f) => (strict ? { ...f, severity: 'error' as const } : f))
983
+ .sort(
984
+ (a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule),
985
+ )
986
+
987
+ return { findings }
988
+ }