@estiva-app/ui 0.35.1 → 0.36.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 (132) hide show
  1. package/dist/eslint/escape.d.ts +23 -6
  2. package/dist/eslint/escape.d.ts.map +1 -1
  3. package/dist/eslint/index.js +70 -9
  4. package/dist/eslint/index.js.map +2 -2
  5. package/dist/eslint/no-raw-element.d.ts +7 -0
  6. package/dist/eslint/no-raw-element.d.ts.map +1 -1
  7. package/dist/eslint/no-restyled-part.d.ts +8 -0
  8. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  9. package/dist/gates/app-checks.d.ts.map +1 -1
  10. package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
  11. package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
  12. package/dist/gates/chunk-SYVKGOWM.js +1131 -0
  13. package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
  14. package/dist/gates/ci.d.ts +18 -0
  15. package/dist/gates/ci.d.ts.map +1 -0
  16. package/dist/gates/cli.js +20 -3
  17. package/dist/gates/cli.js.map +2 -2
  18. package/dist/gates/create-app.d.ts.map +1 -1
  19. package/dist/gates/create-app.js +1 -1
  20. package/dist/gates/escapes.d.ts +23 -0
  21. package/dist/gates/escapes.d.ts.map +1 -0
  22. package/dist/gates/gate-config.d.ts.map +1 -1
  23. package/dist/gates/hook.d.ts +2 -0
  24. package/dist/gates/hook.d.ts.map +1 -1
  25. package/dist/gates/index.d.ts +5 -1
  26. package/dist/gates/index.d.ts.map +1 -1
  27. package/dist/gates/index.js +41 -185
  28. package/dist/gates/index.js.map +4 -4
  29. package/dist/gates/name-escapes.d.ts +14 -0
  30. package/dist/gates/name-escapes.d.ts.map +1 -0
  31. package/dist/gates/status.d.ts +3 -1
  32. package/dist/gates/status.d.ts.map +1 -1
  33. package/dist/gates/token-lint.d.ts.map +1 -1
  34. package/dist/gates/token-style.d.ts +21 -0
  35. package/dist/gates/token-style.d.ts.map +1 -0
  36. package/dist/index.js +13 -13
  37. package/dist/index.js.map +1 -1
  38. package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
  39. package/dist/registry/app.d.ts.map +1 -1
  40. package/dist/registry/behaviours.d.ts +71 -0
  41. package/dist/registry/behaviours.d.ts.map +1 -0
  42. package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
  43. package/dist/registry/build.d.ts.map +1 -1
  44. package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
  45. package/dist/registry/chunk-EVJW26UR.js.map +7 -0
  46. package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
  47. package/dist/registry/chunk-SIB44CES.js.map +7 -0
  48. package/dist/registry/{chunk-KMZIMB3G.js → chunk-XK747STJ.js} +46 -3
  49. package/dist/registry/chunk-XK747STJ.js.map +7 -0
  50. package/dist/registry/cli.js +93 -34
  51. package/dist/registry/cli.js.map +3 -3
  52. package/dist/registry/contract.d.ts +27 -1
  53. package/dist/registry/contract.d.ts.map +1 -1
  54. package/dist/registry/index.d.ts +1 -1
  55. package/dist/registry/index.d.ts.map +1 -1
  56. package/dist/registry/index.js +11 -5
  57. package/dist/registry/siblings.d.ts +9 -0
  58. package/dist/registry/siblings.d.ts.map +1 -1
  59. package/dist/registry/storymap.d.ts +19 -5
  60. package/dist/registry/storymap.d.ts.map +1 -1
  61. package/package.json +4 -1
  62. package/registry.json +293 -17
  63. package/skill/estiva-ui.md +2 -1
  64. package/src/AppShell.mdx +2 -1
  65. package/src/AttachmentCard.tsx +7 -7
  66. package/src/Button.tsx +1 -1
  67. package/src/Card.tsx +2 -2
  68. package/src/ChipInput.mdx +2 -0
  69. package/src/ChipInput.tsx +1 -1
  70. package/src/CommandPalette.tsx +3 -3
  71. package/src/DialogShell.tsx +1 -1
  72. package/src/EmptyState.mdx +4 -3
  73. package/src/Lightbox.tsx +1 -1
  74. package/src/NavItem.tsx +1 -1
  75. package/src/PersonTrigger.tsx +1 -1
  76. package/src/Reaction.tsx +1 -1
  77. package/src/ScrollArea.mdx +4 -0
  78. package/src/Select.tsx +1 -1
  79. package/src/TopBar.mdx +1 -1
  80. package/src/eslint/escape.ts +43 -14
  81. package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
  82. package/src/eslint/index.test.ts +2 -2
  83. package/src/eslint/no-copied-look.test.ts +1 -1
  84. package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
  85. package/src/eslint/no-handmade-empty-state.test.ts +2 -2
  86. package/src/eslint/no-handmade-header.test.ts +2 -2
  87. package/src/eslint/no-native-title.test.ts +2 -2
  88. package/src/eslint/no-raw-element.test.ts +47 -15
  89. package/src/eslint/no-raw-element.ts +53 -3
  90. package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
  91. package/src/eslint/no-restyled-part.test.ts +21 -2
  92. package/src/eslint/no-restyled-part.ts +30 -0
  93. package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
  94. package/src/gates/app-checks.ts +4 -3
  95. package/src/gates/ci.ts +152 -0
  96. package/src/gates/cli.ts +19 -3
  97. package/src/gates/count.ts +1 -1
  98. package/src/gates/create-app.test.ts +2 -2
  99. package/src/gates/create-app.ts +22 -16
  100. package/src/gates/escapes.ts +111 -0
  101. package/src/gates/gate-config.ts +6 -2
  102. package/src/gates/gates.test.ts +93 -1
  103. package/src/gates/hook.ts +44 -6
  104. package/src/gates/index.ts +5 -1
  105. package/src/gates/name-escapes.ts +77 -0
  106. package/src/gates/status.ts +45 -2
  107. package/src/gates/token-lint.ts +11 -4
  108. package/src/gates/token-style.ts +79 -0
  109. package/src/how.test.ts +282 -0
  110. package/src/pages.test.ts +17 -0
  111. package/src/registry/app.test.ts +34 -0
  112. package/src/registry/app.ts +52 -2
  113. package/src/registry/behaviours.ts +223 -0
  114. package/src/registry/build.ts +36 -0
  115. package/src/registry/cli.ts +37 -18
  116. package/src/registry/contract.test.ts +63 -3
  117. package/src/registry/contract.ts +75 -2
  118. package/src/registry/index.ts +1 -1
  119. package/src/registry/registry.test.ts +14 -0
  120. package/src/registry/siblings.test.ts +24 -1
  121. package/src/registry/siblings.ts +23 -0
  122. package/src/registry/storymap.test.ts +52 -31
  123. package/src/registry/storymap.ts +76 -37
  124. package/stories/Choosing.mdx +8 -2
  125. package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
  126. package/dist/gates/chunk-GI6M4P7H.js +0 -606
  127. package/dist/gates/chunk-GI6M4P7H.js.map +0 -7
  128. package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
  129. package/dist/registry/chunk-KMZIMB3G.js.map +0 -7
  130. package/dist/registry/chunk-XVII7P6G.js.map +0 -7
  131. /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
  132. /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
@@ -36,6 +36,7 @@
36
36
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
37
37
  import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'
38
38
  import ts from 'typescript'
39
+ import { asBehaviours, baseUiBehaviours, drawnIn, handedOn, isBaseUi, rootsIn, splitTag } from './behaviours'
39
40
  import { firstSentence, pageOpening, readModule, sanitize, toId, PACKAGE_IMPORT, type Resolved } from './build'
40
41
  import { looksOf, parseForLooks } from '../eslint/looks-of'
41
42
  import { SCHEMA_VERSION, type AppFacts, type EntryClass, type FileWithoutPart, type Registry, type RegistryEntry } from './schema'
@@ -722,6 +723,50 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
722
723
  }
723
724
  const port = /(?:-p|--port)[\s=]+(\d+)/.exec(manifest.scripts?.storybook ?? '')?.[1] ?? '6006'
724
725
 
726
+ // What each part owns, from what it draws (B9): a pass-on owns what its package
727
+ // part owns; any other part what the whole of each part it draws does, followed
728
+ // through the app's own parts, and what the Base UI parts it draws do.
729
+ const packageOwns = new Map((theRegistry?.entries ?? []).map((e) => [e.name, e.ownsBehaviours.map((b) => b.id)]))
730
+ const drawnByFile = new Map<string, { drawn: Map<string, Set<string>>; roots: Map<string, Set<string>> }>()
731
+ const owned = new Map<string, Set<string>>()
732
+ const ownsOf = (p: Found, seen: Set<string>): Set<string> => {
733
+ const at = key(p.file, p.name)
734
+ const held = owned.get(at)
735
+ if (held) return held
736
+ const out = new Set<string>()
737
+ if (p.from) {
738
+ if (p.from.specifier === PACKAGE_IMPORT) for (const id of packageOwns.get(p.from.name) ?? []) out.add(id)
739
+ return out
740
+ }
741
+ const f = facts.get(p.file)
742
+ if (!f || !p.local || seen.has(at)) return out
743
+ seen.add(at)
744
+ if (!drawnByFile.has(p.file)) drawnByFile.set(p.file, { drawn: drawnIn(f.sf), roots: rootsIn(f.sf) })
745
+ const { drawn, roots } = drawnByFile.get(p.file)!
746
+ const imported = new Map(f.links.filter((l) => l.kind === 'import').flatMap((l) => l.names.map((n) => [n.local, { link: l, imported: n.imported }] as const)))
747
+ // The Base UI parts it draws, anywhere in it (rare in an app: the gate refuses most).
748
+ for (const tag of drawn.get(p.local) ?? []) {
749
+ const [local, piece] = splitTag(tag)
750
+ const from = imported.get(local)
751
+ if (from && isBaseUi(from.link.specifier)) for (const id of baseUiBehaviours(from.imported, piece)) out.add(id)
752
+ }
753
+ // The parts it is: what it hands back at its root, the package's or the app's own.
754
+ for (const tag of roots.get(p.local) ?? []) {
755
+ const from = imported.get(splitTag(tag)[0])
756
+ if (!from) continue
757
+ let inner: Iterable<string> = []
758
+ if (from.link.specifier === PACKAGE_IMPORT) inner = handedOn(from.imported, packageOwns.get(from.imported) ?? [])
759
+ else if (from.link.target && from.link.target !== '?') {
760
+ const part = origin(from.link.target, from.imported)
761
+ if (part) inner = handedOn(part.from?.name ?? part.name, ownsOf(part, seen))
762
+ }
763
+ for (const id of inner) out.add(id)
764
+ }
765
+ if (seen.size === 1) owned.set(at, out)
766
+ seen.delete(at)
767
+ return out
768
+ }
769
+
725
770
  const entries: RegistryEntry[] = []
726
771
  for (const p of found) {
727
772
  const u = users.get(key(p.file, p.name))!
@@ -803,7 +848,12 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
803
848
  packageNamesake: namesakes.has(p.name) && !(pass && pass.specifier === PACKAGE_IMPORT && pass.name === p.name) ? p.name : null,
804
849
  defaultExport: p.defaultExport,
805
850
  }
806
- const { docsId, storyId } = pass ? { docsId: null, storyId: null } : storyOf(p)
851
+ const found = pass ? { docsId: null, storyId: null } : storyOf(p)
852
+ // A part with a page of its own and no story file links to its page, by the
853
+ // page's own title (Katerina's ruling B10, 25 September): Peek's EditedMarker.mdx
854
+ // shows the part in other parts' stories, and nothing linked it.
855
+ const titled = !found.docsId && pageText ? /<Meta\s+title=["'`]([^"'`]+)["'`]/.exec(readFileSync(join(app, page!), 'utf8'))?.[1] : undefined
856
+ const { docsId, storyId } = titled ? { docsId: `${sanitize(titled)}--docs`, storyId: found.storyId } : found
807
857
  entries.push({
808
858
  name: p.name,
809
859
  repo: repoName,
@@ -814,7 +864,7 @@ export function buildAppRegistry({ root = process.cwd(), repo, packageRegistry,
814
864
  purposeFrom,
815
865
  props: shape.props,
816
866
  variants: shape.variants,
817
- ownsBehaviours: [],
867
+ ownsBehaviours: asBehaviours(ownsOf(p, new Set())),
818
868
  status: declared?.deprecated ? 'deprecated' : 'stable',
819
869
  migrationStage: null,
820
870
  docsId,
@@ -0,0 +1,223 @@
1
+ /**
2
+ * What a part owns, worked out from what it draws (Katerina's ruling B9,
3
+ * 25 September).
4
+ *
5
+ * The plan's Gate 0 asked for "the behaviours it owns — derived from which Base
6
+ * UI parts it imports". Until 0.36.0 only "is built on Base UI" was; the rest
7
+ * was UIG-8's hand list of owners, which is right for the part that does the
8
+ * work and silent for every part built from it: ConfirmDialog draws DialogShell,
9
+ * whose page says it keeps focus inside, and the catalogue listed no focus for
10
+ * ConfirmDialog. Every app entry listed nothing at all.
11
+ *
12
+ * Now a part owns:
13
+ *
14
+ * - what the Base UI parts it draws do, from UIG-8's enumeration (`baseUi` on
15
+ * each behaviour, read from Base UI's source): all of it for a whole part
16
+ * (`Menu.Root`), what it is for a piece (`Menu.Item`);
17
+ * - what the whole of the part it **is** does (floating, focus, keys, scroll):
18
+ * the part it hands back at its root, followed through parts inside parts, in
19
+ * the package and in an app. ConfirmDialog returns a DialogShell, so it keeps
20
+ * focus inside; a page with a menu somewhere in it does not float, and a
21
+ * tooltip's floating stays the tooltip's;
22
+ * - and, in the package, what UIG-8's list says (the floor: a part like `Link`
23
+ * is reachable with Tab without any Base UI in it).
24
+ *
25
+ * Read from the code: a JSX tag inside the part's own declaration, or inside a
26
+ * function of the same file it draws. A root on one branch of a condition
27
+ * still counts, so the list says what a part can be, not what one screen shows.
28
+ */
29
+ import ts from 'typescript'
30
+ import { OWNED_BEHAVIOURS } from '../eslint/index'
31
+ import type { EntryBehaviour } from './schema'
32
+
33
+ /** `@base-ui/react`, its sub-paths, and the old `@base-ui-components/react` spelling. */
34
+ export const isBaseUi = (specifier: string) => /^@base-ui(?:-components)?\/react(?:\/|$)/.test(specifier)
35
+
36
+ /**
37
+ * What drawing a Base UI part gives, by behaviour id. The whole part (`Menu.Root`,
38
+ * or a part of one piece: `<Separator>`, `<Toggle>`) does all UIG-8 lists for it;
39
+ * a piece of one (`Menu.Item`, `Toolbar.Button`) only says what it is.
40
+ */
41
+ export function baseUiBehaviours(part: string, piece?: string): string[] {
42
+ const whole = !piece || piece === 'Root' || piece === 'Provider'
43
+ return OWNED_BEHAVIOURS.filter((owned) => owned.id === 'base-ui' || (owned.baseUi.includes(part) && (whole || owned.id === 'role'))).map((owned) => owned.id)
44
+ }
45
+
46
+ /**
47
+ * What a part hands on to the parts drawn around it: what the whole thing does.
48
+ * Being built on Base UI, a role and a Tab stop stay the inner part's: a banner
49
+ * with a button in it is not itself reachable with Tab.
50
+ */
51
+ export const HANDED_ON = new Set(['portal', 'press-outside', 'page-keys', 'focus', 'scroll-lock', 'follow', 'scroll'])
52
+
53
+ /** A hint drawn beside a part: its floating is its own, never the part's. */
54
+ export const HINTS = new Set(['Tooltip', 'WithTooltip', 'TooltipProvider', 'PreviewCard'])
55
+
56
+ /** What a part drawn inside another hands on to it. */
57
+ export const handedOn = (name: string, owned: Iterable<string>) => (HINTS.has(name) ? [] : [...owned].filter((id) => HANDED_ON.has(id)))
58
+
59
+ /** Behaviour ids as the catalogue writes them: in UIG-8's order, each once. */
60
+ export function asBehaviours(ids: Iterable<string>): EntryBehaviour[] {
61
+ const held = new Set(ids)
62
+ return OWNED_BEHAVIOURS.filter((owned) => held.has(owned.id)).map((owned) => ({ id: owned.id, behaviour: owned.behaviour }))
63
+ }
64
+
65
+ /** A tag as written: `Dialog.Popup`, `Button`. */
66
+ function tagText(tag: ts.JsxTagNameExpression): string | null {
67
+ if (ts.isIdentifier(tag)) return tag.text
68
+ if (ts.isPropertyAccessExpression(tag)) {
69
+ const inner = tagText(tag.expression as ts.JsxTagNameExpression)
70
+ return inner && `${inner}.${tag.name.text}`
71
+ }
72
+ return null
73
+ }
74
+
75
+ /** A tag's name and its piece: `['Dialog', 'Popup']`, `['Button', undefined]`. */
76
+ export const splitTag = (tag: string): [string, string | undefined] => {
77
+ const [root, piece] = tag.split('.')
78
+ return [root, piece]
79
+ }
80
+
81
+ /** Every tag drawn anywhere under `node`, as written. */
82
+ function tagsUnder(node: ts.Node): Set<string> {
83
+ const found = new Set<string>()
84
+ const visit = (n: ts.Node) => {
85
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
86
+ const tag = tagText(n.tagName)
87
+ if (tag && /^[A-Z]/.test(tag)) found.add(tag)
88
+ }
89
+ ts.forEachChild(n, visit)
90
+ }
91
+ visit(node)
92
+ return found
93
+ }
94
+
95
+ /**
96
+ * For each name a file declares at its top (a function, a `const`, a class):
97
+ * the tags drawn inside it, with the file's own functions it draws followed, so
98
+ * what is left are names the file imports or declares without a body.
99
+ */
100
+ export function drawnIn(sf: ts.SourceFile): Map<string, Set<string>> {
101
+ const direct = new Map<string, Set<string>>()
102
+ for (const statement of sf.statements) {
103
+ if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name) direct.set(statement.name.text, tagsUnder(statement))
104
+ else if (ts.isVariableStatement(statement)) {
105
+ for (const d of statement.declarationList.declarations) if (ts.isIdentifier(d.name) && d.initializer) direct.set(d.name.text, tagsUnder(d.initializer))
106
+ }
107
+ }
108
+ const closed = new Map<string, Set<string>>()
109
+ for (const name of direct.keys()) {
110
+ const out = new Set<string>()
111
+ const seen = new Set([name])
112
+ const stack = [name]
113
+ while (stack.length) {
114
+ for (const tag of direct.get(stack.pop() as string) ?? []) {
115
+ out.add(tag)
116
+ if (direct.has(tag) && !seen.has(tag)) {
117
+ seen.add(tag)
118
+ stack.push(tag)
119
+ }
120
+ }
121
+ }
122
+ closed.set(name, out)
123
+ }
124
+ return closed
125
+ }
126
+
127
+ /** The tags an expression hands back at its top: both sides of a condition, never a fragment's children. */
128
+ function rootsOfExpression(e: ts.Node | undefined, out: Set<string>): void {
129
+ if (!e) return
130
+ if (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isNonNullExpression(e)) return rootsOfExpression(e.expression, out)
131
+ if (ts.isConditionalExpression(e)) {
132
+ rootsOfExpression(e.whenTrue, out)
133
+ rootsOfExpression(e.whenFalse, out)
134
+ return
135
+ }
136
+ if (ts.isBinaryExpression(e)) {
137
+ if (e.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) rootsOfExpression(e.left, out)
138
+ rootsOfExpression(e.right, out)
139
+ return
140
+ }
141
+ // A fragment is several things side by side (a page and the dialog it opens):
142
+ // the part is none of them alone.
143
+ if (ts.isJsxFragment(e)) return
144
+ const tag = ts.isJsxElement(e) ? tagText(e.openingElement.tagName) : ts.isJsxSelfClosingElement(e) ? tagText(e.tagName) : null
145
+ if (tag && /^[A-Z]/.test(tag)) out.add(tag)
146
+ }
147
+
148
+ /** The tags a function hands back: every `return`, or an arrow's body, never a nested function's. */
149
+ function rootsOfFunction(fn: ts.Node): Set<string> {
150
+ const out = new Set<string>()
151
+ if (ts.isArrowFunction(fn) && !ts.isBlock(fn.body)) rootsOfExpression(fn.body, out)
152
+ const visit = (n: ts.Node) => {
153
+ if (n !== fn && (ts.isFunctionLike(n) || ts.isClassLike(n))) return
154
+ if (ts.isReturnStatement(n)) rootsOfExpression(n.expression, out)
155
+ ts.forEachChild(n, visit)
156
+ }
157
+ visit(fn)
158
+ return out
159
+ }
160
+
161
+ /** The function a declaration's value is: itself, or the one inside `forwardRef(…)`, `memo(…)`. */
162
+ function functionOf(node: ts.Node | undefined): ts.Node | undefined {
163
+ if (!node) return undefined
164
+ if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node)) return node
165
+ if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) return functionOf(node.expression)
166
+ if (ts.isCallExpression(node)) for (const argument of node.arguments) {
167
+ const fn = functionOf(argument)
168
+ if (fn) return fn
169
+ }
170
+ return undefined
171
+ }
172
+
173
+ /**
174
+ * For each function a file declares at its top: the parts it **is** — the tags it
175
+ * hands back at its root, following the file's own functions it hands back.
176
+ * `ConfirmDialog` returns a `DialogShell`, so it is one; a page with a menu
177
+ * somewhere in it is not a menu.
178
+ */
179
+ export function rootsIn(sf: ts.SourceFile): Map<string, Set<string>> {
180
+ const direct = new Map<string, Set<string>>()
181
+ for (const statement of sf.statements) {
182
+ if (ts.isFunctionDeclaration(statement) && statement.name) direct.set(statement.name.text, rootsOfFunction(statement))
183
+ else if (ts.isVariableStatement(statement)) {
184
+ for (const d of statement.declarationList.declarations) {
185
+ const fn = functionOf(d.initializer)
186
+ if (ts.isIdentifier(d.name) && fn) direct.set(d.name.text, rootsOfFunction(fn))
187
+ }
188
+ }
189
+ }
190
+ const closed = new Map<string, Set<string>>()
191
+ for (const name of direct.keys()) {
192
+ const out = new Set<string>()
193
+ const seen = new Set([name])
194
+ const stack = [name]
195
+ while (stack.length) {
196
+ for (const tag of direct.get(stack.pop() as string) ?? []) {
197
+ if (direct.has(tag)) {
198
+ if (!seen.has(tag)) {
199
+ seen.add(tag)
200
+ stack.push(tag)
201
+ }
202
+ } else out.add(tag)
203
+ }
204
+ }
205
+ closed.set(name, out)
206
+ }
207
+ return closed
208
+ }
209
+
210
+ /** Each imported value name → where it came from. */
211
+ export function importsOf(sf: ts.SourceFile): Map<string, { specifier: string; imported: string }> {
212
+ const out = new Map<string, { specifier: string; imported: string }>()
213
+ for (const statement of sf.statements) {
214
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue
215
+ const clause = statement.importClause
216
+ if (!clause || clause.isTypeOnly) continue
217
+ const specifier = statement.moduleSpecifier.text
218
+ if (clause.name) out.set(clause.name.text, { specifier, imported: 'default' })
219
+ const bindings = clause.namedBindings
220
+ if (bindings && ts.isNamedImports(bindings)) for (const e of bindings.elements) if (!e.isTypeOnly) out.set(e.name.text, { specifier, imported: (e.propertyName ?? e.name).text })
221
+ }
222
+ return out
223
+ }
@@ -19,6 +19,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
19
19
  import { join } from 'node:path'
20
20
  import ts from 'typescript'
21
21
  import { OWNED_BEHAVIOURS } from '../eslint/index'
22
+ import { asBehaviours, baseUiBehaviours, drawnIn, handedOn, importsOf, isBaseUi, rootsIn, splitTag } from './behaviours'
22
23
  import { looksOf, parseForLooks } from '../eslint/looks-of'
23
24
  import { SCHEMA_VERSION, type EntryBehaviour, type EntryKind, type EntryProp, type EntryVariant, type Registry, type RegistryEntry } from './schema'
24
25
 
@@ -654,6 +655,41 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
654
655
 
655
656
  if (problems.length) throw new Error(`the registry cannot be built:\n ${problems.join('\n ')}`)
656
657
 
658
+ // What each part owns, from what it draws (B9): the Base UI parts in it, and
659
+ // every part of the package inside it, followed; UIG-8's list is the floor.
660
+ const moduleByName = new Map(values.map((v) => [v.name, v.module]))
661
+ const drawing = new Map<string, { drawn: Map<string, Set<string>>; roots: Map<string, Set<string>>; imports: ReturnType<typeof importsOf> }>()
662
+ const drawingOf = (moduleName: string) => {
663
+ if (!drawing.has(moduleName)) {
664
+ const file = moduleOf(moduleName).file
665
+ const sf = ts.createSourceFile(file, readFileSync(join(root, file), 'utf8'), ts.ScriptTarget.Latest, true, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
666
+ drawing.set(moduleName, { drawn: drawnIn(sf), roots: rootsIn(sf), imports: importsOf(sf) })
667
+ }
668
+ return drawing.get(moduleName)!
669
+ }
670
+ const owns = (name: string, seen: Set<string>): Set<string> => {
671
+ const out = new Set(behavioursOf(name).map((b) => b.id))
672
+ const moduleName = moduleByName.get(name)
673
+ if (!moduleName || seen.has(name)) return out
674
+ seen.add(name)
675
+ const { drawn, roots, imports } = drawingOf(moduleName)
676
+ // The Base UI parts it draws, anywhere in it.
677
+ for (const tag of drawn.get(name) ?? []) {
678
+ const [local, piece] = splitTag(tag)
679
+ const from = imports.get(local)
680
+ if (from && isBaseUi(from.specifier)) for (const id of baseUiBehaviours(from.imported, piece)) out.add(id)
681
+ }
682
+ // The parts it is: what it hands back at its root.
683
+ for (const tag of roots.get(name) ?? []) {
684
+ const [local] = splitTag(tag)
685
+ const from = imports.get(local)
686
+ const part = from?.specifier.startsWith('./') ? from.imported : !from && local !== name ? local : undefined
687
+ if (part && moduleByName.has(part)) for (const id of handedOn(part, owns(part, seen))) out.add(id)
688
+ }
689
+ return out
690
+ }
691
+ for (const entry of entries) if (entry.kind === 'component') entry.ownsBehaviours = asBehaviours(owns(entry.name, new Set()))
692
+
657
693
  entries.sort((a, b) => a.name.localeCompare(b.name))
658
694
 
659
695
  return {
@@ -36,11 +36,11 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from
36
36
  import { tmpdir } from 'node:os'
37
37
  import { basename, dirname, join, relative, resolve } from 'node:path'
38
38
  import { fileURLToPath } from 'node:url'
39
- import { CONTRACT_KINDS, contractProblems, linkProblems } from './contract'
39
+ import { CONTRACT_KINDS, contractProblems, linkProblems, nameProblems } from './contract'
40
40
  import { findInRegistries, formatFindings } from './find'
41
- import { findSiblings, workspaceOf } from './siblings'
41
+ import { behindBy, findSiblings, workspaceOf } from './siblings'
42
42
  import { loaderPath, loaderProblem, loaderText, repositoryOf, settingsPath, settingsWithSearch } from './skill'
43
- import { storyMapProblems } from './storymap'
43
+ import { indexHeadings, storyMapProblems } from './storymap'
44
44
  import { CLASSES, validateRegistry, type Registry } from './schema'
45
45
 
46
46
  /**
@@ -132,11 +132,11 @@ async function buildApp(folder: string, repo: string | undefined): Promise<Regis
132
132
  * Reports, and says whether it passed. A folder with no Storybook has nothing
133
133
  * to check.
134
134
  */
135
- function checkLinks(registry: Registry, folder: string): boolean {
135
+ function checkLinks(registry: Registry, folder: string): { ok: boolean; headings?: Set<string>; titles?: Set<string> } {
136
136
  const bin = resolve(folder, 'node_modules', '.bin', process.platform === 'win32' ? 'storybook.cmd' : 'storybook')
137
137
  if (!existsSync(resolve(folder, '.storybook')) || !existsSync(bin)) {
138
138
  process.stdout.write('story links: no Storybook here, nothing to check\n')
139
- return true
139
+ return { ok: true }
140
140
  }
141
141
  const out = join(mkdtempSync(join(tmpdir(), 'estiva-ui-index-')), 'index.json')
142
142
  // A `.cmd` needs a shell on Windows, and Node wants that as one quoted line
@@ -147,17 +147,20 @@ function checkLinks(registry: Registry, folder: string): boolean {
147
147
  : spawnSync(bin, ['index', '-o', out, '--quiet'], { cwd: folder, encoding: 'utf8' })
148
148
  if (run.status !== 0 || !existsSync(out)) {
149
149
  process.stderr.write(`story links: Storybook could not index its stories, so no link can be trusted:\n${(run.stderr || run.stdout || '').trim()}\n`)
150
- return false
150
+ return { ok: false }
151
151
  }
152
- const ids = new Set(Object.keys((JSON.parse(readFileSync(out, 'utf8')) as { entries: Record<string, unknown> }).entries))
152
+ const entries = (JSON.parse(readFileSync(out, 'utf8')) as { entries: Record<string, { title?: string }> }).entries
153
+ const ids = new Set(Object.keys(entries))
154
+ const headings = indexHeadings(entries)
155
+ const titles = new Set(Object.values(entries).flatMap((e) => (e.title ? [e.title] : [])))
153
156
  const dead = linkProblems(registry, ids)
154
157
  const count = registry.entries.reduce((n, entry) => n + (entry.storyId ? 1 : 0) + (entry.docsId ? 1 : 0), 0)
155
158
  if (dead.length) {
156
159
  process.stderr.write(`story links: ${dead.length} of ${count} lead nowhere:\n ${dead.join('\n ')}\n`)
157
- return false
160
+ return { ok: false, headings, titles }
158
161
  }
159
162
  process.stdout.write(`story links: all ${count} open a story or docs page Storybook has\n`)
160
- return true
163
+ return { ok: true, headings, titles }
161
164
  }
162
165
 
163
166
  /**
@@ -179,12 +182,12 @@ async function checkGated(registry: Registry, folder: string, kinds?: string[]):
179
182
  }
180
183
 
181
184
  /**
182
- * The app's hand-written map of its Storybook — the heading order and the
183
- * Introduction — against the Storybook and the catalogue (`./storymap`).
184
- * Reports, and says whether it passed.
185
+ * The repository's hand-written map of its Storybook — the heading order and the
186
+ * Introduction — against the Storybook (its own index, when there is one) and,
187
+ * in an app, the catalogue (`./storymap`). Reports, and says whether it passed.
185
188
  */
186
- function checkStoryMap(registry: Registry, folder: string): boolean {
187
- const problems = storyMapProblems(registry, folder)
189
+ function checkStoryMap(folder: string, registry?: Registry, headings?: Set<string>): boolean {
190
+ const problems = storyMapProblems(folder, { ...(registry ? { registry } : {}), ...(headings ? { real: headings } : {}) })
188
191
  if (problems.length) {
189
192
  process.stderr.write(`storybook map: ${problems.length === 1 ? 'one line no longer matches' : `${problems.length} lines no longer match`}:\n ${problems.join('\n ')}\n`)
190
193
  return false
@@ -212,6 +215,8 @@ async function main(): Promise<number> {
212
215
  const registries: Registry[] = [readPackageRegistry()]
213
216
  // An app that cannot be read is said out loud, and the rest still answer.
214
217
  const add = async (folder: string, repo: string | undefined) => {
218
+ const behind = behindBy(folder)
219
+ if (behind) process.stderr.write(`note: ${repo ?? basename(folder)} (${folder}) is ${behind} commit${behind === 1 ? '' : 's'} behind main as last fetched — its answers may name parts that are gone; pull it\n`)
215
220
  try {
216
221
  registries.push(await buildApp(folder, repo))
217
222
  } catch (error) {
@@ -273,7 +278,10 @@ async function main(): Promise<number> {
273
278
  const registry = await buildApp(root, value('repo'))
274
279
  process.stdout.write(`${summary(registry)}\n`)
275
280
  // The usage-page contract and the story links (UIG-19): the one copy, run by every app's `gate` job.
276
- const broken = contractProblems(registry, root)
281
+ // A When not may name a part of the package or of this app (B8).
282
+ const pkg = readPackageRegistry()
283
+ const known = new Set([...pkg.entries, ...registry.entries].map((e) => e.name))
284
+ const broken = contractProblems(registry, root, known)
277
285
  if (broken.length) {
278
286
  process.stderr.write(`${broken.length === 1 ? 'a part breaks' : `${broken.length} parts break`} the usage-page contract:\n ${broken.join('\n ')}\n`)
279
287
  } else {
@@ -283,8 +291,17 @@ async function main(): Promise<number> {
283
291
  const linked = checkLinks(registry, root)
284
292
  const shown = await checkGated(registry, root)
285
293
  const loaded = checkLoader()
286
- const mapped = checkStoryMap(registry, root)
287
- return broken.length || !linked || !shown || !loaded || !mapped ? 1 : 0
294
+ const mapped = checkStoryMap(root, registry, linked.headings)
295
+ // What the pages name must still be there (R14): parts under When and When not,
296
+ // and the stories a Seen in line sends a reader to, here or in the package.
297
+ const stale = nameProblems(registry, root, {
298
+ known,
299
+ ...(linked.titles ? { titles: linked.titles } : {}),
300
+ packageIds: pkg.entries.flatMap((e) => [e.storyId, e.docsId].filter((id): id is string => Boolean(id))),
301
+ })
302
+ if (stale.length) process.stderr.write(`usage pages: ${stale.length === 1 ? 'one name leads' : `${stale.length} names lead`} nowhere:\n ${stale.join('\n ')}\n`)
303
+ else process.stdout.write('usage pages: every part and story they name is there\n')
304
+ return broken.length || stale.length || !linked.ok || !shown || !loaded || !mapped ? 1 : 0
288
305
  }
289
306
  const { buildRegistry, serializeRegistry } = await packageBuilder()
290
307
  const path = packageRegistryPath()
@@ -308,7 +325,9 @@ async function main(): Promise<number> {
308
325
  const linked = checkLinks(built, root)
309
326
  const shown = await checkGated(built, root, ['component'])
310
327
  const loaded = checkLoader()
311
- return linked && shown && loaded ? 0 : 1
328
+ // The package keeps a heading order too (R16): it left out Layout, and nothing saw.
329
+ const mapped = checkStoryMap(root, undefined, linked.headings)
330
+ return linked.ok && shown && loaded && mapped ? 0 : 1
312
331
  }
313
332
 
314
333
  case 'skill': {
@@ -18,7 +18,7 @@ import { tmpdir } from 'node:os'
18
18
  import { dirname, join } from 'node:path'
19
19
  import { afterAll, describe, expect, it } from 'vitest'
20
20
  import { buildAppRegistry } from './app'
21
- import { contractProblems, hasSeenIn, linkProblems, pageProblem } from './contract'
21
+ import { contractProblems, hasSeenIn, linkProblems, nameProblems, pageProblem, whenNotProblem } from './contract'
22
22
 
23
23
  const made: string[] = []
24
24
  afterAll(() => {
@@ -53,6 +53,8 @@ const PAGE = [
53
53
  '',
54
54
  'A word.',
55
55
  '',
56
+ 'No alternative: a count has only this badge.',
57
+ '',
56
58
  '## How',
57
59
  '',
58
60
  '```tsx',
@@ -78,7 +80,7 @@ describe('a usage page', () => {
78
80
  })
79
81
 
80
82
  it('fails with its sections out of order', () => {
81
- const swapped = PAGE.replace('## When not\n\nA word.\n', '').replace('## What it owns', '## When not\n\nA word.\n\n## What it owns')
83
+ const swapped = PAGE.replace('## When not\n\nA word.\n\nNo alternative: a count has only this badge.\n', '').replace('## What it owns', '## When not\n\nA word.\n\n## What it owns')
82
84
  expect(pageProblem(swapped)).toMatch(/^sections out of order/)
83
85
  })
84
86
 
@@ -86,8 +88,23 @@ describe('a usage page', () => {
86
88
  expect(pageProblem(PAGE.replace('A small count beside a name.\n', ''))).toMatch(/^no opening line/)
87
89
  })
88
90
 
89
- it('fails with no code under How', () => {
91
+ // R18: the code must be under How itself; one in a later section passed.
92
+ it('fails with no code under How, even with code further down', () => {
90
93
  expect(pageProblem(PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.'))).toBe('no code under "How"')
94
+ const later = PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.').replace('Nothing. It only draws.', '```tsx\n<Badge count={3} />\n```')
95
+ expect(pageProblem(later)).toBe('no code under "How"')
96
+ })
97
+
98
+ // B8: When not names the part to use instead, or says there is none.
99
+ it('asks When not to name another real part, or to say there is none', () => {
100
+ const known = new Set(['Badge', 'Once', 'Chip'])
101
+ const whenNot = (body: string) => PAGE.replace('A word.\n\nNo alternative: a count has only this badge.', body)
102
+ expect(whenNotProblem(PAGE, ['Badge'], known)).toBeNull()
103
+ expect(whenNotProblem(whenNot('A word. That is a **Chip**.'), ['Badge'], known)).toBeNull()
104
+ expect(whenNotProblem(whenNot('- A word.\n\nNo alternative: a count has only this.'), ['Badge'], known)).toBeNull()
105
+ expect(whenNotProblem(whenNot('A word.'), ['Badge'], known)).toMatch(/^"When not" names no other part/)
106
+ // Its own name, a name nothing has, and "No alternative:" mid-line do not count.
107
+ expect(whenNotProblem(whenNot('Not **Badge** twice, nor a `Widget`. See: No alternative: here.'), ['Badge'], known)).not.toBeNull()
91
108
  })
92
109
 
93
110
  it('reads a Windows checkout', () => {
@@ -133,6 +150,17 @@ describe('an app against the contract', () => {
133
150
  expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
134
151
  })
135
152
 
153
+ it("links a part with no story to its page by the page's title, and names one that links to nothing (B10)", () => {
154
+ const seen = PAGE.replace('A small count beside a name.\n', 'A small count beside a name.\n\n**Seen in** — *Parts/Card*, beside the title.\n')
155
+ const dir = app({ 'src/Badge.tsx': part('Badge'), 'src/Badge.mdx': seen })
156
+ const registry = buildAppRegistry({ root: dir, repo: 'fixture' })
157
+ expect(registry.entries.find((e) => e.name === 'Badge')).toMatchObject({ docsId: 'parts-badge--docs', storyId: null })
158
+ writeFileSync(join(dir, 'src/Badge.mdx'), seen.replace("<Meta title='Parts/Badge' />", '<Meta />'))
159
+ expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
160
+ 'Badge (reusable, src/Badge.tsx) links to nothing in Storybook: give src/Badge.mdx a `<Meta title="…" />`, or give the part a story',
161
+ ])
162
+ })
163
+
136
164
  it("counts a story file named after the part that draws its view, from the part's own file", () => {
137
165
  const dir = app({
138
166
  'src/Panel.tsx': `${part('Panel')}\n/** The half that only draws. */\nexport function PanelView() {\n return <div>view</div>\n}\n`,
@@ -156,3 +184,35 @@ describe('story links', () => {
156
184
  ])
157
185
  })
158
186
  })
187
+
188
+ // R14, the re-review after the audit before UIG-26. Every case is Peek's or Ship's own:
189
+ // StartTopicDialog sent readers to CreateTopicDialog and RepliesRow to HuddleCard, both
190
+ // deleted by FOL-23; EmptyState's Seen in named a story on two lines; Ship's Attachment
191
+ // named the package's story by a title it had lost.
192
+ describe('nameProblems', () => {
193
+ const page = (whenNot: string, seenIn = '') =>
194
+ PAGE.replace('A word.', whenNot).replace('A small count beside a name.', `A small count beside a name.${seenIn ? `\n\n${seenIn}` : ''}`)
195
+ const registry = (dir: string) => ({ entries: [{ name: 'Badge', docPage: 'src/Badge.mdx' }] }) as unknown as Parameters<typeof nameProblems>[0]
196
+ const known = new Set(['Badge', 'Chip'])
197
+
198
+ it('names a part under When not that no catalogue has, and passes one that exists', () => {
199
+ const dir = app({ 'src/Badge.mdx': page('A topic someone is invited to: that is **CreateTopicDialog**; a label is `Chip`.') })
200
+ expect(nameProblems(registry(dir), dir, { known })).toEqual(['src/Badge.mdx: "When not" names CreateTopicDialog, which is no part of the package or this app'])
201
+ })
202
+
203
+ it('reads a Seen in line against the Storybook, a title wrapped across lines too, and the package ids', () => {
204
+ const seen = '**Seen in** — *Parts/Badge*, *To be removed/\nTopicActivity* and\n*Media/AttachmentCard*.'
205
+ const dir = app({ 'src/Badge.mdx': page('A word.', seen) })
206
+ const titles = new Set(['Parts/Badge'])
207
+ expect(nameProblems(registry(dir), dir, { known, titles, packageIds: ['components-attachmentcard--document'] })).toEqual([
208
+ 'src/Badge.mdx: "Seen in" names To be removed/TopicActivity, which no Storybook has',
209
+ 'src/Badge.mdx: "Seen in" names Media/AttachmentCard, which no Storybook has',
210
+ ])
211
+ expect(nameProblems(registry(dir), dir, { known, titles: new Set(['Parts/Badge', 'To be removed/TopicActivity']), packageIds: ['media-attachmentcard--default'] })).toEqual([])
212
+ })
213
+
214
+ it('leaves the Seen in line alone when there is no Storybook index to read', () => {
215
+ const dir = app({ 'src/Badge.mdx': page('A word.', '**Seen in** — *Gone/Story*.') })
216
+ expect(nameProblems(registry(dir), dir, { known })).toEqual([])
217
+ })
218
+ })