@estiva-app/ui 0.35.2 → 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 (123) 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 +37 -183
  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-5K2TRR4X.js → chunk-XK747STJ.js} +18 -3
  49. package/dist/registry/chunk-XK747STJ.js.map +7 -0
  50. package/dist/registry/cli.js +7 -6
  51. package/dist/registry/cli.js.map +2 -2
  52. package/dist/registry/contract.d.ts +13 -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/package.json +4 -1
  58. package/registry.json +293 -17
  59. package/skill/estiva-ui.md +2 -1
  60. package/src/AppShell.mdx +2 -1
  61. package/src/AttachmentCard.tsx +7 -7
  62. package/src/Button.tsx +1 -1
  63. package/src/Card.tsx +2 -2
  64. package/src/ChipInput.mdx +2 -0
  65. package/src/ChipInput.tsx +1 -1
  66. package/src/CommandPalette.tsx +3 -3
  67. package/src/DialogShell.tsx +1 -1
  68. package/src/EmptyState.mdx +4 -3
  69. package/src/Lightbox.tsx +1 -1
  70. package/src/NavItem.tsx +1 -1
  71. package/src/PersonTrigger.tsx +1 -1
  72. package/src/Reaction.tsx +1 -1
  73. package/src/ScrollArea.mdx +4 -0
  74. package/src/Select.tsx +1 -1
  75. package/src/TopBar.mdx +1 -1
  76. package/src/eslint/escape.ts +43 -14
  77. package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
  78. package/src/eslint/index.test.ts +2 -2
  79. package/src/eslint/no-copied-look.test.ts +1 -1
  80. package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
  81. package/src/eslint/no-handmade-empty-state.test.ts +2 -2
  82. package/src/eslint/no-handmade-header.test.ts +2 -2
  83. package/src/eslint/no-native-title.test.ts +2 -2
  84. package/src/eslint/no-raw-element.test.ts +47 -15
  85. package/src/eslint/no-raw-element.ts +53 -3
  86. package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
  87. package/src/eslint/no-restyled-part.test.ts +21 -2
  88. package/src/eslint/no-restyled-part.ts +30 -0
  89. package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
  90. package/src/gates/app-checks.ts +4 -3
  91. package/src/gates/ci.ts +152 -0
  92. package/src/gates/cli.ts +19 -3
  93. package/src/gates/count.ts +1 -1
  94. package/src/gates/create-app.test.ts +2 -2
  95. package/src/gates/create-app.ts +22 -16
  96. package/src/gates/escapes.ts +111 -0
  97. package/src/gates/gate-config.ts +2 -0
  98. package/src/gates/gates.test.ts +86 -1
  99. package/src/gates/hook.ts +38 -4
  100. package/src/gates/index.ts +5 -1
  101. package/src/gates/name-escapes.ts +77 -0
  102. package/src/gates/status.ts +45 -2
  103. package/src/gates/token-lint.ts +11 -4
  104. package/src/gates/token-style.ts +79 -0
  105. package/src/how.test.ts +282 -0
  106. package/src/pages.test.ts +6 -0
  107. package/src/registry/app.test.ts +34 -0
  108. package/src/registry/app.ts +52 -2
  109. package/src/registry/behaviours.ts +223 -0
  110. package/src/registry/build.ts +36 -0
  111. package/src/registry/cli.ts +5 -3
  112. package/src/registry/contract.test.ts +27 -2
  113. package/src/registry/contract.ts +28 -3
  114. package/src/registry/index.ts +1 -1
  115. package/src/registry/registry.test.ts +14 -0
  116. package/dist/gates/chunk-DBMUQAR6.js +0 -607
  117. package/dist/gates/chunk-DBMUQAR6.js.map +0 -7
  118. package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
  119. package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
  120. package/dist/registry/chunk-5K2TRR4X.js.map +0 -7
  121. package/dist/registry/chunk-XVII7P6G.js.map +0 -7
  122. /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
  123. /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
@@ -0,0 +1,79 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * The token contract's inline-style check, where a selector cannot reach
4
+ * (the second review's R9, 25 September). `no-restricted-syntax` reads a
5
+ * `style={{ … }}` written in place; a style kept in a `const` and passed by name
6
+ * got through every lint in all three repos:
7
+ *
8
+ * const look = { background: 'red' }
9
+ * <div style={look} />
10
+ *
11
+ * This follows the name to its `const` in the same file and reads the keys
12
+ * there, quoted or not. It reports on the key, where the value is written, so
13
+ * an escape sits beside it. A style the code works out while it runs passes:
14
+ * nothing can read it.
15
+ */
16
+ import type { ESLint, Rule, Scope } from 'eslint'
17
+
18
+ /** The style keys outside the token contract: colours, a font size, borders and shadows. */
19
+ export const INLINE_STYLE_TOKEN_KEYS = /^(color|background|backgroundColor|backgroundImage|fill|stroke|fontSize|border|borderTop|borderRight|borderBottom|borderLeft|borderBlock|borderInline|borderStyle|boxShadow|textShadow)$|Color$/
20
+
21
+ export const INLINE_STYLE_MESSAGE =
22
+ 'An inline style that sets a colour, a font size, a border or a shadow is outside the token contract: use a token class. If the value is computed (a palette, a size from a prop), say why in an eslint-disable comment. Width, height and transforms are fine.'
23
+
24
+ interface Node {
25
+ type: string
26
+ [key: string]: unknown
27
+ }
28
+
29
+ const keyOf = (property: Node): string | undefined => {
30
+ if (property.computed) return undefined
31
+ const key = property.key as Node
32
+ return key.type === 'Identifier' ? (key.name as string) : key.type === 'Literal' && typeof key.value === 'string' ? key.value : undefined
33
+ }
34
+
35
+ /** The object a `const` of this name holds, in this scope or one around it. */
36
+ function constObject(name: string, scope: Scope.Scope | null): Node | undefined {
37
+ for (let current = scope; current; current = current.upper) {
38
+ const variable = current.set.get(name)
39
+ if (!variable) continue
40
+ const definition = variable.defs[0] as unknown as { type: string; node: Node; parent?: Node } | undefined
41
+ if (variable.defs.length !== 1 || definition?.type !== 'Variable' || definition.parent?.kind !== 'const') return undefined
42
+ let init = definition.node.init as Node | undefined
43
+ while (init && (init.type === 'TSAsExpression' || init.type === 'TSSatisfiesExpression')) init = init.expression as Node
44
+ return init?.type === 'ObjectExpression' ? init : undefined
45
+ }
46
+ return undefined
47
+ }
48
+
49
+ const noTokenStyle: Rule.RuleModule = {
50
+ meta: {
51
+ type: 'problem',
52
+ docs: { description: 'No colour, font size, border or shadow in a style kept in a const' },
53
+ schema: [],
54
+ messages: { token: INLINE_STYLE_MESSAGE },
55
+ },
56
+ create(context) {
57
+ const reported = new Set<unknown>()
58
+ return {
59
+ JSXAttribute(ruleNode: Rule.Node) {
60
+ const attribute = ruleNode as unknown as Node
61
+ if ((attribute.name as Node).name !== 'style') return
62
+ const value = attribute.value as Node | null
63
+ const expression = value?.type === 'JSXExpressionContainer' ? (value.expression as Node) : undefined
64
+ if (expression?.type !== 'Identifier') return
65
+ const object = constObject(expression.name as string, context.sourceCode.getScope(ruleNode))
66
+ for (const property of (object?.properties as Node[] | undefined) ?? []) {
67
+ if (property.type !== 'Property' || reported.has(property)) continue
68
+ const key = keyOf(property)
69
+ if (!key || !INLINE_STYLE_TOKEN_KEYS.test(key)) continue
70
+ reported.add(property)
71
+ context.report({ node: property as unknown as Rule.Node, messageId: 'token' })
72
+ }
73
+ },
74
+ }
75
+ },
76
+ }
77
+
78
+ /** The plugin, as the token lint registers it: `token-style/no-token-style`. */
79
+ export const tokenStylePlugin: ESLint.Plugin = { rules: { 'no-token-style': noTokenStyle } }
@@ -0,0 +1,282 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * The code under every page's `## How` compiles against the package (UIG-14:
4
+ * "Every How compiles — checked, not assumed"; Katerina's ruling R18, 25
5
+ * September). It was compiled once, by hand, on 19 September, and never again.
6
+ *
7
+ * Each ```tsx block under `## How` becomes a module of its own:
8
+ *
9
+ * 1. Its own `import` lines, verbatim; `'@estiva-app/ui'` is this package's
10
+ * `src/index.ts`, so a wrong name in an import fails.
11
+ * 2. Every part it did not import, imported (a page shows a part's companions
12
+ * without their import line).
13
+ * 3. The rest as the body of a component: all JSX, or statements then JSX, or
14
+ * statements alone, whichever parses.
15
+ * 4. What the page leaves to the reader is a placeholder: a lower-case name or
16
+ * an ALL_CAPS one is `any`, a React hook comes from React, an `Icon…` from
17
+ * Tabler. A capitalised name the package does not have is an error — that is
18
+ * what a wrong part name looks like — except the reader's own stand-ins
19
+ * below, which the pages name on purpose.
20
+ *
21
+ * Then it is type-checked with the package's own settings. A part name, a prop
22
+ * name and a prop's literal value are checked; a prop given a placeholder is not
23
+ * (a placeholder is `any`).
24
+ */
25
+ import { readdirSync, readFileSync } from 'node:fs'
26
+ import { createRequire } from 'node:module'
27
+ import { join } from 'node:path'
28
+ import ts from 'typescript'
29
+ import { describe, expect, it } from 'vitest'
30
+
31
+ const REPO = process.cwd()
32
+ const norm = (p: string) => p.replace(/\\/g, '/').toLowerCase()
33
+
34
+ /** The reader's own components a page draws around a part (Katerina's ruling R18: an allow-list). */
35
+ const STAND_INS = ['App', 'Conversation', 'ItemDetail', 'ItemRow', 'ListRow', 'Row', 'YourList']
36
+ /** Browser globals a page may mean for real; any other lower-case lib.dom name is a placeholder (`name`, `open`). */
37
+ const REAL_GLOBALS = new Set(['window', 'document', 'console', 'fetch', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'requestAnimationFrame', 'navigator', 'localStorage', 'sessionStorage', 'globalThis', 'queueMicrotask', 'structuredClone'])
38
+
39
+ interface Block {
40
+ page: string
41
+ /** The .mdx line of the block's first line of code. */
42
+ start: number
43
+ lines: string[]
44
+ }
45
+
46
+ function howBlocks(page: string, text: string): Block[] {
47
+ const lines = text.split(/\r?\n/)
48
+ const blocks: Block[] = []
49
+ let inHow = false
50
+ let fence: string | null = null
51
+ let start = 0
52
+ let buf: string[] = []
53
+ lines.forEach((line, i) => {
54
+ if (fence !== null) {
55
+ if (/^\s*```\s*$/.test(line)) {
56
+ if (inHow && fence === 'tsx') blocks.push({ page, start, lines: buf })
57
+ fence = null
58
+ buf = []
59
+ } else buf.push(line)
60
+ return
61
+ }
62
+ const open = /^\s*```(\S*)/.exec(line)
63
+ if (open) {
64
+ fence = open[1] || '(none)'
65
+ start = i + 2
66
+ return
67
+ }
68
+ if (/^## /.test(line)) inHow = /^## How\b/.test(line)
69
+ })
70
+ return blocks
71
+ }
72
+
73
+ function indexValueExports(): string[] {
74
+ const sf = ts.createSourceFile('index.ts', readFileSync(join(REPO, 'src/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
75
+ const names: string[] = []
76
+ for (const s of sf.statements) {
77
+ if (ts.isExportDeclaration(s) && !s.isTypeOnly && s.exportClause && ts.isNamedExports(s.exportClause)) for (const e of s.exportClause.elements) if (!e.isTypeOnly) names.push(e.name.text)
78
+ }
79
+ return names
80
+ }
81
+
82
+ const require = createRequire(join(REPO, 'package.json'))
83
+ const REACT = new Set(Object.keys(require('react') as object))
84
+ const TABLER = new Set(Object.keys(require('@tabler/icons-react') as object))
85
+ const INDEX = indexValueExports()
86
+
87
+ function splitImports(lines: string[]) {
88
+ const imports: { text: string; line: number }[] = []
89
+ const body = lines.slice()
90
+ for (let i = 0; i < lines.length; i++) {
91
+ if (!/^import\b/.test(lines[i])) continue
92
+ let j = i
93
+ while (j < lines.length && !/from\s+['"][^'"]+['"]\s*;?\s*$/.test(lines[j]) && !/^import\s+['"]/.test(lines[j])) j++
94
+ imports.push({ text: lines.slice(i, j + 1).join('\n'), line: i })
95
+ for (let k = i; k <= j; k++) body[k] = ''
96
+ i = j
97
+ }
98
+ return { imports, body }
99
+ }
100
+
101
+ function importedNames(imports: { text: string }[]): Set<string> {
102
+ const names = new Set<string>()
103
+ const sf = ts.createSourceFile('i.ts', imports.map((x) => x.text).join('\n'), ts.ScriptTarget.Latest, true)
104
+ for (const s of sf.statements) {
105
+ const c = ts.isImportDeclaration(s) ? s.importClause : undefined
106
+ if (!c) continue
107
+ if (c.name) names.add(c.name.text)
108
+ if (c.namedBindings && ts.isNamedImports(c.namedBindings)) for (const e of c.namedBindings.elements) names.add(e.name.text)
109
+ if (c.namedBindings && ts.isNamespaceImport(c.namedBindings)) names.add(c.namedBindings.name.text)
110
+ }
111
+ return names
112
+ }
113
+
114
+ const jsxComments = (lines: string[]) => lines.map((l) => l.replace(/^\/\/(.*)$/, '{/*$1*/}'))
115
+ const parses = (text: string) => (ts.transpileModule(text, { fileName: 'x.tsx', reportDiagnostics: true, compilerOptions: { jsx: ts.JsxEmit.ReactJSX } }).diagnostics ?? []).length === 0
116
+
117
+ /** All JSX; statements then JSX; statements alone. The first that parses wins. */
118
+ function wrappings(body: string[]) {
119
+ const out: { pre: string[]; jsx: string[] | null }[] = [{ pre: [], jsx: body }]
120
+ body.forEach((l, k) => {
121
+ if (k > 0 && /^[<{]/.test(l) && body.slice(0, k).some((x) => x.trim() && !/^\s*\/\//.test(x))) out.push({ pre: body.slice(0, k), jsx: body.slice(k) })
122
+ })
123
+ out.push({ pre: body, jsx: null })
124
+ return out
125
+ }
126
+
127
+ interface Extra {
128
+ react: Set<string>
129
+ tabler: Set<string>
130
+ declared: Set<string>
131
+ }
132
+
133
+ function build(block: Block, extra: Extra): { text: string; map: (number | null)[] } {
134
+ const { imports, body } = splitImports(block.lines)
135
+ const have = importedNames(imports)
136
+ const lines: string[] = []
137
+ const map: (number | null)[] = []
138
+ const push = (text: string, mdx: number | null) => {
139
+ for (const t of text.split('\n')) {
140
+ lines.push(t)
141
+ map.push(mdx)
142
+ }
143
+ }
144
+ for (const imp of imports) imp.text.split('\n').forEach((t, k) => push(t, block.start + imp.line + k))
145
+ const auto = INDEX.filter((n) => !have.has(n))
146
+ if (auto.length) push(`import { ${auto.join(', ')} } from '@estiva-app/ui'`, null)
147
+ const react = [...extra.react].filter((n) => !have.has(n))
148
+ if (react.length) push(`import { ${react.join(', ')} } from 'react'`, null)
149
+ const tabler = [...extra.tabler].filter((n) => !have.has(n))
150
+ if (tabler.length) push(`import { ${tabler.join(', ')} } from '@tabler/icons-react'`, null)
151
+ for (const d of extra.declared) push(`declare const ${d}: any`, null)
152
+ for (const s of STAND_INS) if (!have.has(s)) push(`declare const ${s}: (props: any) => React.JSX.Element`, null)
153
+
154
+ for (const w of wrappings(body)) {
155
+ const L = lines.slice()
156
+ const M = map.slice()
157
+ const add = (t: string, mdx: number | null) => {
158
+ L.push(t)
159
+ M.push(mdx)
160
+ }
161
+ add('export function __How() {', null)
162
+ w.pre.forEach((t, k) => add(t, block.start + k))
163
+ if (w.jsx) {
164
+ add('return (<>', null)
165
+ jsxComments(w.jsx).forEach((t, k) => add(t, block.start + w.pre.length + k))
166
+ add('</>)', null)
167
+ }
168
+ add('}', null)
169
+ const text = L.join('\n')
170
+ if (parses(text)) return { text, map: M }
171
+ }
172
+ return { text: `${lines.join('\n')}\nexport function __How() {\n${body.join('\n')}\n}`, map: [...map, null, ...body.map((_, k) => block.start + k), null] }
173
+ }
174
+
175
+ function compilerOptions(): ts.CompilerOptions {
176
+ const raw = ts.parseConfigFileTextToJson('tsconfig.json', readFileSync(join(REPO, 'tsconfig.json'), 'utf8')).config
177
+ const parsed = ts.convertCompilerOptionsFromJson(raw.compilerOptions, REPO)
178
+ // A placeholder is `any`: `people.map((p) => …)` would fail noImplicitAny on `p`,
179
+ // the placeholder's fault, not the page's. A callback prop's parameter keeps the part's type.
180
+ return { ...parsed.options, noEmit: true, noImplicitAny: false, baseUrl: REPO, paths: { '@estiva-app/ui': ['./src/index.ts'] }, types: [] }
181
+ }
182
+
183
+ function makeHost(options: ts.CompilerOptions, virtual: Map<string, string>): ts.CompilerHost {
184
+ const host = ts.createCompilerHost(options, true)
185
+ const read = host.readFile.bind(host)
186
+ const exists = host.fileExists.bind(host)
187
+ host.readFile = (f) => virtual.get(norm(f)) ?? read(f)
188
+ host.fileExists = (f) => virtual.has(norm(f)) || exists(f)
189
+ host.getSourceFile = (f, lang) => {
190
+ const t = host.readFile(f)
191
+ return t === undefined ? undefined : ts.createSourceFile(f, t, lang, true)
192
+ }
193
+ host.getCurrentDirectory = () => REPO
194
+ return host
195
+ }
196
+
197
+ interface Result {
198
+ page: string
199
+ start: number
200
+ errors: { line: number | null; code: number; text: string }[]
201
+ }
202
+
203
+ /** Compile every block, adding placeholders until nothing new is found. */
204
+ function compileAll(blocks: Block[]): Result[] {
205
+ const options = compilerOptions()
206
+ const state: Extra[] = blocks.map(() => ({ react: new Set(), tabler: new Set(), declared: new Set() }))
207
+ const fileOf = blocks.map((b) => `${REPO}/src/__how__${b.page.replace(/\W+/g, '_')}_${b.start}.tsx`)
208
+ let program: ts.Program | undefined
209
+ let built: ReturnType<typeof build>[] = []
210
+ for (let round = 0; round < 8; round++) {
211
+ built = blocks.map((b, i) => build(b, state[i]))
212
+ const virtual = new Map(fileOf.map((f, i) => [norm(f), built[i].text]))
213
+ program = ts.createProgram(fileOf, options, makeHost(options, virtual), program)
214
+ const checker = program.getTypeChecker()
215
+ let added = 0
216
+ fileOf.forEach((f, i) => {
217
+ const s = state[i]
218
+ const sf = program!.getSourceFile(f)!
219
+ for (const d of ts.getPreEmitDiagnostics(program!, sf)) {
220
+ if (d.code !== 2304 && d.code !== 2552) continue
221
+ const x = /Cannot find name '([^']+)'/.exec(ts.flattenDiagnosticMessageText(d.messageText, ' '))?.[1]
222
+ if (!x || s.react.has(x) || s.tabler.has(x) || s.declared.has(x)) continue
223
+ if (REACT.has(x)) s.react.add(x)
224
+ else if (/^Icon[A-Z]/.test(x) && TABLER.has(x)) s.tabler.add(x)
225
+ else if (/^[a-z_$]/.test(x) || /^[A-Z][A-Z0-9_]+$/.test(x)) s.declared.add(x)
226
+ else continue
227
+ added++
228
+ }
229
+ // A placeholder that is also a browser global (`name`, `open`) never reports
230
+ // "Cannot find name": it silently takes the global's type. Shadow those too.
231
+ const walk = (node: ts.Node) => {
232
+ if (ts.isIdentifier(node) && built[i].map[sf.getLineAndCharacterOfPosition(node.getStart(sf)).line] != null) {
233
+ const p = node.parent
234
+ const member = (ts.isPropertyAccessExpression(p) && p.name === node) || (ts.isPropertyAssignment(p) && p.name === node) || ts.isJsxAttribute(p)
235
+ const x = node.text
236
+ if (!member && /^[a-z]/.test(x) && !REAL_GLOBALS.has(x) && !s.declared.has(x)) {
237
+ const sym = checker.getSymbolAtLocation(node)
238
+ const decls = sym?.declarations ?? []
239
+ if (sym && decls.length && decls.every((d) => program!.isSourceFileDefaultLibrary(d.getSourceFile())) && sym.flags & (ts.SymbolFlags.Variable | ts.SymbolFlags.Function)) {
240
+ s.declared.add(x)
241
+ added++
242
+ }
243
+ }
244
+ }
245
+ ts.forEachChild(node, walk)
246
+ }
247
+ walk(sf)
248
+ })
249
+ if (!added) break
250
+ }
251
+ return blocks.map((b, i) => {
252
+ const sf = program!.getSourceFile(fileOf[i])!
253
+ const errors = ts.getPreEmitDiagnostics(program!, sf).filter((d) => d.file === sf).map((d) => ({
254
+ line: built[i].map[sf.getLineAndCharacterOfPosition(d.start ?? 0).line] ?? null,
255
+ code: d.code,
256
+ text: ts.flattenDiagnosticMessageText(d.messageText, ' '),
257
+ }))
258
+ return { page: b.page, start: b.start, errors }
259
+ })
260
+ }
261
+
262
+ const pages = readdirSync(join(REPO, 'src')).filter((f) => f.endsWith('.mdx')).sort()
263
+ const blocks = pages.flatMap((f) => howBlocks(`src/${f}`, readFileSync(join(REPO, 'src', f), 'utf8')))
264
+
265
+ describe('the code under How', () => {
266
+ it('compiles on every page, and goes red on a wrong part, prop, value or import', { timeout: 120_000 }, () => {
267
+ expect(blocks.length).toBeGreaterThan(50)
268
+ const button = blocks.find((b) => b.page === 'src/Button.mdx')!
269
+ const broken = (name: string, change: (line: string) => string) => ({ ...button, page: `src/BROKEN-${name}.mdx`, lines: button.lines.map(change) })
270
+ const probes = [
271
+ broken('part', (l) => l.replace('<Button ', '<Buton ').replace('</Button>', '</Buton>')),
272
+ broken('prop', (l) => l.replace('variant="primary"', 'varient="primary"')),
273
+ broken('value', (l) => l.replace('variant="primary"', 'variant="big"')),
274
+ broken('import', (l) => l.replace('import { Button }', 'import { Buton }')),
275
+ ]
276
+ const results = compileAll([...blocks, ...probes])
277
+ const shown = (r: Result) => r.errors.map((e) => `${r.page}:${e.line ?? '?'} TS${e.code} ${e.text}`)
278
+ expect(results.slice(0, blocks.length).flatMap(shown)).toEqual([])
279
+ // Each broken copy must fail, or the check proves nothing.
280
+ for (const r of results.slice(blocks.length)) expect(r.errors.length, r.page).toBeGreaterThan(0)
281
+ })
282
+ })
package/src/pages.test.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  import { readdirSync, readFileSync } from 'node:fs'
17
17
  import { describe, expect, it } from 'vitest'
18
18
  import { OWNED_BEHAVIOURS } from './eslint/no-rebuilt-behaviour'
19
+ import { whenNotProblem } from './registry/contract'
19
20
 
20
21
  /** How a page words each behaviour. `base-ui` is how a part is built, not what it does for a caller. */
21
22
  const ROW: Record<string, RegExp> = {
@@ -119,6 +120,11 @@ describe('the page contract', () => {
119
120
  expect(names.filter((n) => !exported.has(n) && !pages.includes(n))).toEqual([])
120
121
  })
121
122
 
123
+ // B8 (25 September): When not names the part to use instead, or says there is none.
124
+ it('names another part under When not, or starts a line "No alternative:"', () => {
125
+ expect(whenNotProblem(t, partsOf(page), exported)).toBeNull()
126
+ })
127
+
122
128
  if (WAITING.includes(page)) return
123
129
 
124
130
  it('says what it owns, as the checker does', () => {
@@ -173,6 +173,22 @@ describe("an app's catalogue", () => {
173
173
  expect(registry.storybook.devUrl).toBe('http://localhost:6123')
174
174
  })
175
175
 
176
+ // C6: UIG-19's "adding an unclassified component fails the build — proved", proved.
177
+ it('fails a part with no class, and gives every part the builder finds one', () => {
178
+ const unclassified = structuredClone(registry)
179
+ const one = unclassified.entries.find((e) => e.app?.class === 'one-off')!
180
+ delete (one.app as Partial<NonNullable<RegistryEntry['app']>>).class
181
+ expect(validateRegistry(unclassified)).toContain(`entries[${unclassified.entries.indexOf(one)}] (${one.name}).app.class is undefined`)
182
+ const bare = structuredClone(registry)
183
+ bare.entries[0].app = null
184
+ expect(validateRegistry(bare).join('\n')).toContain('app is missing on an app entry')
185
+ // A part added to the app is classified the moment it exists.
186
+ const dir = app({ ...FIXTURE, 'src/components/Added.tsx': '/** A part nobody has sorted. */\nexport function Added() {\n return <div />\n}\n' })
187
+ const added = buildAppRegistry({ root: dir, repo: 'fixture', packageRegistry })
188
+ expect(added.entries.find((e) => e.name === 'Added')?.app?.class).toBe('unused')
189
+ expect(added.entries.every((e) => ['re-export', 'reusable', 'one-off', 'promote-candidate', 'unused'].includes(e.app?.class ?? ''))).toBe(true)
190
+ })
191
+
176
192
  it('counts every .tsx outside stories and tests, and says why a file holds no part', () => {
177
193
  // main.tsx, App.tsx, 2 pass-ons, Frame, Card, Timeline, Sync, Ctx, Lonely, Shown, Wide, EmptyState, Lazy, HomePage, Other, hooks.tsx
178
194
  expect(registry.builtFrom.files).toBe(17)
@@ -230,6 +246,24 @@ describe("an app's catalogue", () => {
230
246
  expect(entry('Shown').app).toMatchObject({ class: 'unused', reason: 'No file of the app uses it; only its stories do.' })
231
247
  })
232
248
 
249
+ // B9: an app part owns what the part it is owns, followed through the app's own parts.
250
+ it('gives an app part what the part it returns owns, and a page nothing of its dialogs', () => {
251
+ const dir = app({
252
+ 'src/ui/Floating.tsx': "/** Hands on the package's Popover. */\nexport { Popover } from '@estiva-app/ui'\n",
253
+ 'src/DeleteDialog.tsx': "import { ConfirmDialog } from '@estiva-app/ui'\n/** Asks before deleting. */\nexport function DeleteDialog() {\n return <ConfirmDialog open title=\"Delete?\" onConfirm={() => {}} onOpenChange={() => {}} />\n}\n",
254
+ 'src/Ask.tsx': "import { DeleteDialog } from './DeleteDialog'\n/** The app's own wrapper of it. */\nexport function Ask() {\n return open ? <DeleteDialog /> : null\n}\n",
255
+ 'src/Page.tsx': "import { Ask } from './Ask'\n/** A page that opens it. */\nexport function Page() {\n return (\n <>\n <main>Page</main>\n <Ask />\n </>\n )\n}\n",
256
+ 'src/main.tsx': "import { Page } from './Page'\nimport { Popover } from './ui/Floating'\nexport const all = [<Page key=\"p\" />, <Popover key=\"f\" />]\n",
257
+ })
258
+ const entries = buildAppRegistry({ root: dir, repo: 'x', packageRegistry }).entries
259
+ const ids = (name: string) => entries.find((e) => e.name === name)!.ownsBehaviours.map((owned) => owned.id)
260
+ expect(ids('DeleteDialog')).toEqual(expect.arrayContaining(['portal', 'focus', 'scroll-lock']))
261
+ expect(ids('DeleteDialog')).not.toContain('tab-stop')
262
+ expect(ids('Ask')).toEqual(ids('DeleteDialog'))
263
+ expect(ids('Page')).toEqual([])
264
+ expect(ids('Popover')).toEqual(packageRegistry.entries.find((e) => e.name === 'Popover')!.ownsBehaviours.map((owned) => owned.id))
265
+ })
266
+
233
267
  it('takes a kind written beside the part, with its reason', () => {
234
268
  expect(entry('Wide').app).toMatchObject({ class: 'reusable', written: true, reason: 'general; one screen uses it so far', usedIn: ['src/pages/HomePage.tsx'] })
235
269
  expect(entry('Wide').purpose).toBe('A general strip of things.')
@@ -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,