@epilot/zenith-react 0.2.0-next.4 → 0.2.0-next.5

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.
package/README.md CHANGED
@@ -163,10 +163,21 @@ npm install @epilot/zenith-react@next
163
163
  These are opt-in twice over — you name the tag, and a range like `^0.1.0` never
164
164
  resolves a prerelease. Pin an exact version if you adopt one.
165
165
 
166
+ ## Upgrading to 0.2.0
167
+
168
+ 0.2.0 moves every family from Radix to Base UI and serves the primitives from
169
+ the runtime. The "Upgrade to 0.2.0" page in the documentation's setup section
170
+ has the steps in order: the host serves the new runtime first (with `react` and
171
+ `react-dom` external), bump the packages, run the `radix-to-base-ui` codemod
172
+ (its output comes through your Prettier), run the migration sweep, add the test
173
+ setup, check the attribute renames, and keep the two rules for pages that still
174
+ mix Volt 1.x and Zenith.
175
+
166
176
  ## Reference
167
177
 
168
178
  The package is ESM-only and tree-shakeable. Resolved literal token values are in
169
- `@epilot/zenith-react/tokens.json`, and an optional Jest helper in
179
+ `@epilot/zenith-react/tokens.json`. For tests, `@epilot/zenith-react/testing/setup.cjs`
180
+ (a `PointerEvent` for jsdom 26 and older, Vitest and Jest) and an optional Jest helper in
170
181
  `@epilot/zenith-react/testing/jest-preset.cjs`.
171
182
 
172
183
  Component reference, guidelines and examples are on the documentation site — the
@@ -8,6 +8,12 @@ once on code that imports `@epilot/zenith-react`:
8
8
  bunx jscodeshift@17.3.0 --parser=tsx -t node_modules/@epilot/zenith-react/codemods/radix-to-base-ui.ts src/
9
9
  ```
10
10
 
11
+ Every transform here prints each file it rewrites through your project's Prettier (resolved
12
+ next to the file, with the config that applies to it), so the result passes your format check as
13
+ it is; `.prettierignore` is not consulted. Without Prettier in the project the output keeps
14
+ recast's formatting, the run says so once on stderr, and you run your formatter on the changed
15
+ files.
16
+
11
17
  What it rewrites:
12
18
 
13
19
  | Content part | Root whose `onOpenChange` takes the handlers | Props moved |
@@ -24,7 +30,12 @@ What it rewrites:
24
30
 
25
31
  Each handler becomes a check on Base UI's `eventDetails.reason` (`escape-key`, `outside-press`,
26
32
  `focus-out`), and one that calls `preventDefault()` keeps the overlay open through
27
- `eventDetails.cancel()`, as it did under Radix. A DropdownMenu item whose `onSelect` calls
33
+ `eventDetails.cancel()`, as it did under Radix. A handler is called with the event only when it
34
+ takes one: an inline function by its parameters, a reference by its declaration (a `const` or
35
+ `function`, `useCallback`, a type annotation, a destructured prop whose type is written in the
36
+ file, or one of those behind a relative import), so `const close = () => …` becomes `close?.()`
37
+ and typechecks. A reference the file cannot explain (a package import, a member expression, a
38
+ conditional) gets the event. A DropdownMenu item whose `onSelect` calls
28
39
  `preventDefault()` to keep the menu open is reported: the menu closes regardless under Base UI. Props
29
40
  a wrapper can still translate, such as `forceMount`, stay on the surface and need no rule.
30
41
 
@@ -38,8 +49,10 @@ Rules for a transform:
38
49
  `/* TODO(zenith-base-ui): … */` comment and an `api.report` line, so the run's output is the
39
50
  list of places a human has to look.
40
51
  - Fixtures: `__testfixtures__/radix-to-base-ui-<family>-<case>.input.tsx` and `.output.tsx`, the
41
- output being exactly what `--dry --print` emits (no report lines, no absolute paths). Cover, per
42
- family: the plain case, aliased imports, an inline handler, a handler reference, and one
52
+ output being exactly what `--dry --print` emits (no report lines, no absolute paths), formatted
53
+ by the repository's own Prettier config as the transform prints it. Cover, per
54
+ family: the plain case, aliased imports, an inline handler, a handler reference, a
55
+ zero-parameter reference (a `const`, a `function` and an import from `handlers.ts`), and one
43
56
  unsupported shape that gets the TODO.
44
57
  - `./run-fixtures.sh radix-to-base-ui` diffs every fixture of the transform. `fixtures.test.ts`
45
58
  also reruns the transform over each expected output and requires a no-op, and typechecks the
@@ -50,7 +63,8 @@ Rules for a transform:
50
63
 
51
64
  Two entry transforms in `migrate/` move consumer code onto `@epilot/zenith-react` from the
52
65
  predecessor libraries. Run one, work the report, then run `radix-to-base-ui.ts` (it is not
53
- chained: the predecessor APIs are the Radix-era ones):
66
+ chained: the predecessor APIs are the Radix-era ones). Both print through your project's
67
+ Prettier, as above:
54
68
 
55
69
  ```sh
56
70
  bunx jscodeshift@17.3.0 --parser=tsx -t node_modules/@epilot/zenith-react/codemods/migrate/from-volt.ts src/
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Prints a transform's output the way the project's formatter would, so the written file passes
3
+ * the project's Prettier check (#114). Prettier is resolved from the file's own location, so it is
4
+ * the project's copy under npm, bun and pnpm alike, and runs with the config that applies to the
5
+ * file. A project without Prettier gets recast's output as it is and one note on stderr to run
6
+ * its formatter on the changed files.
7
+ */
8
+ import { createRequire } from "node:module"
9
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
10
+
11
+ type Prettier = {
12
+ resolveConfig: (
13
+ file: string,
14
+ options?: { editorconfig?: boolean }
15
+ ) => Promise<Record<string, unknown> | null>
16
+ format: (
17
+ source: string,
18
+ options?: Record<string, unknown>
19
+ ) => Promise<string> | string
20
+ }
21
+
22
+ const loaded = new Map<string, Promise<Prettier | undefined>>()
23
+ let noted = false
24
+
25
+ export async function formatOutput(
26
+ output: string,
27
+ filePath: string
28
+ ): Promise<string> {
29
+ const file = resolve(filePath)
30
+ const prettier = await loadPrettier(file)
31
+ if (!prettier) {
32
+ if (!noted) {
33
+ noted = true
34
+ process.stderr.write(
35
+ `zenith codemods: no Prettier found from ${dirname(file)}; run your formatter on the changed files\n`
36
+ )
37
+ }
38
+ return output
39
+ }
40
+ try {
41
+ const config =
42
+ (await prettier.resolveConfig(file, { editorconfig: true })) ?? {}
43
+ return await prettier.format(output, { ...config, filepath: file })
44
+ } catch (error) {
45
+ process.stderr.write(
46
+ `zenith codemods: Prettier could not format ${file}: ${String(error)}\n`
47
+ )
48
+ return output
49
+ }
50
+ }
51
+
52
+ /** One resolution per directory: the same Prettier serves every file beside it. */
53
+ function loadPrettier(file: string): Promise<Prettier | undefined> {
54
+ const dir = dirname(file)
55
+ let pending = loaded.get(dir)
56
+ if (!pending) {
57
+ pending = resolvePrettier(file)
58
+ loaded.set(dir, pending)
59
+ }
60
+ return pending
61
+ }
62
+
63
+ async function resolvePrettier(file: string): Promise<Prettier | undefined> {
64
+ try {
65
+ const id = createRequire(file).resolve("prettier")
66
+ if (!installedAbove(id, file)) return undefined
67
+ const loadedModule = (await import(id)) as { default?: Prettier } & Prettier
68
+ const prettier = loadedModule.default ?? loadedModule
69
+ return typeof prettier.format === "function" ? prettier : undefined
70
+ } catch {
71
+ return undefined
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Only the project's own copy formats: one in a `node_modules` above the file. Bun resolves a
77
+ * missing package from its global cache, which would format a project that never uses Prettier
78
+ * with Prettier's defaults.
79
+ */
80
+ function installedAbove(resolved: string, file: string): boolean {
81
+ const index = resolved.indexOf(`${sep}node_modules${sep}`)
82
+ if (index < 0) return false
83
+ const project = resolved.slice(0, index)
84
+ const below = relative(project, file)
85
+ return below !== "" && !below.startsWith("..") && !isAbsolute(below)
86
+ }
@@ -10,11 +10,17 @@
10
10
  import type { API, FileInfo, Options } from "jscodeshift"
11
11
  import { rules } from "./base-elements"
12
12
  import { runMigration } from "./lib/run-migration"
13
+ import { formatOutput } from "../format"
13
14
 
14
15
  export const parser = "tsx"
15
16
 
16
17
  export const SOURCE = "@epilot/base-elements"
17
18
 
18
- export default function transform(file: FileInfo, api: API, options: Options) {
19
- return runMigration({ file, api, options, source: SOURCE, rules })
19
+ export default async function transform(
20
+ file: FileInfo,
21
+ api: API,
22
+ options: Options
23
+ ): Promise<string | undefined> {
24
+ const output = runMigration({ file, api, options, source: SOURCE, rules })
25
+ return output === undefined ? undefined : formatOutput(output, file.path)
20
26
  }
@@ -10,11 +10,17 @@
10
10
  import type { API, FileInfo, Options } from "jscodeshift"
11
11
  import { runMigration } from "./lib/run-migration"
12
12
  import { rules } from "./volt"
13
+ import { formatOutput } from "../format"
13
14
 
14
15
  export const parser = "tsx"
15
16
 
16
17
  export const SOURCE = "@epilot/volt-ui-react"
17
18
 
18
- export default function transform(file: FileInfo, api: API, options: Options) {
19
- return runMigration({ file, api, options, source: SOURCE, rules })
19
+ export default async function transform(
20
+ file: FileInfo,
21
+ api: API,
22
+ options: Options
23
+ ): Promise<string | undefined> {
24
+ const output = runMigration({ file, api, options, source: SOURCE, rules })
25
+ return output === undefined ? undefined : formatOutput(output, file.path)
20
26
  }
@@ -22,6 +22,8 @@
22
22
  * placement props, `value=""` for no selection) stays as it is: the wrappers still take them and
23
23
  * translate them for Base UI.
24
24
  */
25
+ import { existsSync, readFileSync, statSync } from "node:fs"
26
+ import { dirname, resolve } from "node:path"
25
27
  import type {
26
28
  API,
27
29
  ArrowFunctionExpression,
@@ -39,6 +41,12 @@ import type {
39
41
  Options,
40
42
  VariableDeclaration,
41
43
  } from "jscodeshift"
44
+ import { formatOutput } from "./format"
45
+
46
+ type J = API["jscodeshift"]
47
+ type Scope = ASTPath["scope"]
48
+ /** Where a reference is looked up: the file's AST, its path (imports resolve against it) and depth. */
49
+ type Lookup = { j: J; root: Collection; path: string; depth: number }
42
50
 
43
51
  const ZENITH = "@epilot/zenith-react"
44
52
 
@@ -98,7 +106,11 @@ const TODO = "TODO(zenith-base-ui)"
98
106
 
99
107
  export const parser = "tsx"
100
108
 
101
- export default function transform(file: FileInfo, api: API, options: Options) {
109
+ export default async function transform(
110
+ file: FileInfo,
111
+ api: API,
112
+ options: Options
113
+ ): Promise<string | undefined> {
102
114
  const j = api.jscodeshift
103
115
  const root = j(file.source)
104
116
  const local = zenithLocalNames(j, root)
@@ -225,8 +237,16 @@ export default function transform(file: FileInfo, api: API, options: Options) {
225
237
  const test = comparisons.reduce((a, b) =>
226
238
  j.logicalExpression("||", a, b)
227
239
  )
240
+ const withEvent = handlerTakesEvent(
241
+ { j, root, path: file.path, depth: 0 },
242
+ contentPath.scope,
243
+ value
244
+ )
228
245
  checks.push(
229
- j.ifStatement(test, j.expressionStatement(callHandler(j, value)))
246
+ j.ifStatement(
247
+ test,
248
+ j.expressionStatement(callHandler(j, value, withEvent))
249
+ )
230
250
  )
231
251
  removeAttr(contentPath.node, h.node)
232
252
  }
@@ -336,7 +356,10 @@ export default function transform(file: FileInfo, api: API, options: Options) {
336
356
  if (todos.length) changed = true
337
357
 
338
358
  return changed
339
- ? root.toSource({ quote: options.quote ?? "single" })
359
+ ? formatOutput(
360
+ root.toSource({ quote: options.quote ?? "single" }),
361
+ file.path
362
+ )
340
363
  : undefined
341
364
  }
342
365
 
@@ -375,15 +398,280 @@ function eventField(j: API["jscodeshift"], field: string) {
375
398
  )
376
399
  }
377
400
 
378
- function callHandler(j: API["jscodeshift"], handler: never) {
379
- const event = j.memberExpression(
380
- j.identifier("eventDetails"),
381
- j.identifier("event")
382
- )
383
- const h = handler as { type: string; params?: unknown[] }
401
+ function callHandler(j: J, handler: never, withEvent: boolean) {
402
+ const h = handler as { type: string }
403
+ const args = withEvent
404
+ ? [j.memberExpression(j.identifier("eventDetails"), j.identifier("event"))]
405
+ : []
384
406
  // An inline function is always defined; a reference may be undefined (a conditional handler).
385
- // An inline function that ignores the event is called without it, so the output typechecks.
386
407
  return h.type === "ArrowFunctionExpression" || h.type === "FunctionExpression"
387
- ? j.callExpression(handler, h.params?.length ? [event] : [])
388
- : j.optionalCallExpression(handler, [event], true)
408
+ ? j.callExpression(handler, args)
409
+ : j.optionalCallExpression(handler, args, true)
410
+ }
411
+
412
+ /**
413
+ * Whether the handler is called with the event. A function that takes no parameter is called
414
+ * without it, so the output typechecks (TS2554): an inline function by its own parameters, a
415
+ * reference by the declaration it resolves to, in this file or in a relative import (a `const`
416
+ * or `function` declaration, `useCallback`, a type annotation, a destructured prop whose type is
417
+ * written in the file). A reference the file cannot explain (a package import, a member
418
+ * expression, a conditional) gets the event: an extra argument is harmless at runtime, and
419
+ * TypeScript reports the rare zero-parameter case.
420
+ */
421
+ function handlerTakesEvent(
422
+ lookup: Lookup,
423
+ scope: Scope,
424
+ handler: { type: string; params?: unknown[]; name?: string }
425
+ ): boolean {
426
+ if (
427
+ handler.type === "ArrowFunctionExpression" ||
428
+ handler.type === "FunctionExpression"
429
+ )
430
+ return (handler.params?.length ?? 0) > 0
431
+ if (handler.type !== "Identifier" || !handler.name) return true
432
+ const arity = referenceArity(lookup, scope, handler.name)
433
+ return arity === undefined || arity > 0
434
+ }
435
+
436
+ const MAX_DEPTH = 8
437
+
438
+ /** The number of parameters `name` is declared with, or undefined when the file does not say. */
439
+ function referenceArity(
440
+ lookup: Lookup,
441
+ scope: Scope,
442
+ name: string
443
+ ): number | undefined {
444
+ if (lookup.depth > MAX_DEPTH) return undefined
445
+ const declaring = scope?.lookup(name)
446
+ const [binding] = declaring?.getBindings()[name] ?? []
447
+ return binding ? bindingArity(lookup, binding) : undefined
448
+ }
449
+
450
+ function bindingArity(lookup: Lookup, binding: ASTPath): number | undefined {
451
+ const { j } = lookup
452
+ const id = binding.node
453
+ const parent = binding.parent?.node
454
+ // `const close: () => void = …`, a parameter `close: () => void`
455
+ if (j.Identifier.check(id) && id.typeAnnotation) {
456
+ const arity = typeArity(lookup, id.typeAnnotation.typeAnnotation)
457
+ if (arity !== undefined) return arity
458
+ }
459
+ if (j.VariableDeclarator.check(parent) && binding.name === "id")
460
+ return parent.init
461
+ ? expressionArity(lookup, binding.parent.scope, parent.init)
462
+ : undefined
463
+ if (j.FunctionDeclaration.check(parent) && binding.name === "id")
464
+ return parent.params.length
465
+ if (
466
+ j.ImportSpecifier.check(parent) ||
467
+ j.ImportDefaultSpecifier.check(parent)
468
+ ) {
469
+ const declaration = binding.parent.parent?.node
470
+ if (!j.ImportDeclaration.check(declaration)) return undefined
471
+ const imported = j.ImportSpecifier.check(parent)
472
+ ? String(parent.imported.name)
473
+ : "default"
474
+ return importedArity(lookup, String(declaration.source.value), imported)
475
+ }
476
+ // `({ onClose }: { onClose: () => void }) => …`: the destructured prop's type
477
+ if (
478
+ (j.ObjectProperty.check(parent) || j.Property.check(parent)) &&
479
+ binding.name === "value"
480
+ ) {
481
+ const pattern = binding.parent.parent?.node
482
+ const key = parent.key
483
+ const keyName = j.Identifier.check(key)
484
+ ? key.name
485
+ : j.StringLiteral.check(key)
486
+ ? key.value
487
+ : undefined
488
+ if (
489
+ !j.ObjectPattern.check(pattern) ||
490
+ !pattern.typeAnnotation ||
491
+ keyName === undefined
492
+ )
493
+ return undefined
494
+ const members = typeMembers(lookup, pattern.typeAnnotation.typeAnnotation)
495
+ for (const member of members ?? []) {
496
+ const memberKey = (member as { key?: unknown }).key
497
+ const memberName = j.Identifier.check(memberKey)
498
+ ? memberKey.name
499
+ : j.StringLiteral.check(memberKey)
500
+ ? memberKey.value
501
+ : undefined
502
+ if (memberName !== keyName) continue
503
+ if (j.TSMethodSignature.check(member)) return member.parameters.length
504
+ if (j.TSPropertySignature.check(member))
505
+ return member.typeAnnotation
506
+ ? typeArity(lookup, member.typeAnnotation.typeAnnotation)
507
+ : undefined
508
+ return undefined
509
+ }
510
+ }
511
+ return undefined
512
+ }
513
+
514
+ function expressionArity(
515
+ lookup: Lookup,
516
+ scope: Scope,
517
+ expression: unknown
518
+ ): number | undefined {
519
+ const { j } = lookup
520
+ if (
521
+ j.ArrowFunctionExpression.check(expression) ||
522
+ j.FunctionExpression.check(expression)
523
+ )
524
+ return expression.params.length
525
+ if (j.Identifier.check(expression))
526
+ return referenceArity(
527
+ { ...lookup, depth: lookup.depth + 1 },
528
+ scope,
529
+ expression.name
530
+ )
531
+ if (j.TSAsExpression.check(expression))
532
+ return (
533
+ typeArity(lookup, expression.typeAnnotation) ??
534
+ expressionArity(lookup, scope, expression.expression)
535
+ )
536
+ if (j.TSNonNullExpression.check(expression))
537
+ return expressionArity(lookup, scope, expression.expression)
538
+ // `useCallback(() => …, [])`: the callback's own parameters
539
+ if (j.CallExpression.check(expression)) {
540
+ const { callee } = expression
541
+ const hook = j.Identifier.check(callee)
542
+ ? callee.name
543
+ : j.MemberExpression.check(callee) && j.Identifier.check(callee.property)
544
+ ? callee.property.name
545
+ : undefined
546
+ return hook === "useCallback"
547
+ ? expressionArity(lookup, scope, expression.arguments[0])
548
+ : undefined
549
+ }
550
+ return undefined
551
+ }
552
+
553
+ /** The parameter count of a function type, through unions with undefined and aliases in the file. */
554
+ function typeArity(lookup: Lookup, type: unknown): number | undefined {
555
+ const { j, root } = lookup
556
+ if (j.TSFunctionType.check(type)) return type.parameters.length
557
+ if (j.TSParenthesizedType.check(type))
558
+ return typeArity(lookup, type.typeAnnotation)
559
+ if (j.TSUnionType.check(type)) {
560
+ const arities = type.types
561
+ .filter(
562
+ (t) => !j.TSUndefinedKeyword.check(t) && !j.TSNullKeyword.check(t)
563
+ )
564
+ .map((t) => typeArity(lookup, t))
565
+ return arities.length && arities.every((a) => a !== undefined)
566
+ ? Math.max(...(arities as number[]))
567
+ : undefined
568
+ }
569
+ if (
570
+ j.TSTypeReference.check(type) &&
571
+ j.Identifier.check(type.typeName) &&
572
+ !type.typeParameters &&
573
+ lookup.depth <= MAX_DEPTH
574
+ ) {
575
+ const alias = root
576
+ .find(j.TSTypeAliasDeclaration, { id: { name: type.typeName.name } })
577
+ .nodes()[0]
578
+ return alias
579
+ ? typeArity({ ...lookup, depth: lookup.depth + 1 }, alias.typeAnnotation)
580
+ : undefined
581
+ }
582
+ return undefined
583
+ }
584
+
585
+ /** The members of an object type written in the file: a literal, an alias, an interface, an intersection. */
586
+ function typeMembers(lookup: Lookup, type: unknown): unknown[] | undefined {
587
+ const { j, root } = lookup
588
+ if (j.TSTypeLiteral.check(type)) return type.members
589
+ if (j.TSParenthesizedType.check(type))
590
+ return typeMembers(lookup, type.typeAnnotation)
591
+ // A member found in one part of an intersection is one of the property's overloads.
592
+ if (j.TSIntersectionType.check(type))
593
+ return type.types.flatMap((t) => typeMembers(lookup, t) ?? [])
594
+ if (
595
+ j.TSTypeReference.check(type) &&
596
+ j.Identifier.check(type.typeName) &&
597
+ !type.typeParameters &&
598
+ lookup.depth <= MAX_DEPTH
599
+ ) {
600
+ const name = type.typeName.name
601
+ const deeper = { ...lookup, depth: lookup.depth + 1 }
602
+ const alias = root
603
+ .find(j.TSTypeAliasDeclaration, { id: { name } })
604
+ .nodes()[0]
605
+ if (alias) return typeMembers(deeper, alias.typeAnnotation)
606
+ // An interface's own members; one inherited through `extends` stays unknown.
607
+ const iface = root
608
+ .find(j.TSInterfaceDeclaration, { id: { name } })
609
+ .nodes()[0]
610
+ return iface?.body.body
611
+ }
612
+ return undefined
613
+ }
614
+
615
+ const MODULE_SUFFIXES = [
616
+ "",
617
+ ".ts",
618
+ ".tsx",
619
+ ".js",
620
+ ".jsx",
621
+ ".mjs",
622
+ "/index.ts",
623
+ "/index.tsx",
624
+ "/index.js",
625
+ ]
626
+
627
+ /** The arity of `imported` from a relative module, read from disk; a package import stays unknown. */
628
+ function importedArity(
629
+ lookup: Lookup,
630
+ source: string,
631
+ imported: string
632
+ ): number | undefined {
633
+ if (!source.startsWith(".") || lookup.depth > MAX_DEPTH) return undefined
634
+ const base = resolve(dirname(lookup.path), source)
635
+ const file = MODULE_SUFFIXES.map((suffix) => base + suffix).find(
636
+ (candidate) => existsSync(candidate) && statSync(candidate).isFile()
637
+ )
638
+ if (!file) return undefined
639
+ const { j } = lookup
640
+ let root: Collection
641
+ try {
642
+ root = j(readFileSync(file, "utf8"))
643
+ } catch {
644
+ return undefined
645
+ }
646
+ const other: Lookup = { j, root, path: file, depth: lookup.depth + 1 }
647
+ const program = root.find(j.Program).paths()[0]
648
+ const local = (name: string) => referenceArity(other, program.scope, name)
649
+
650
+ if (imported === "default") {
651
+ const declaration = root
652
+ .find(j.ExportDefaultDeclaration)
653
+ .nodes()[0]?.declaration
654
+ if (j.Identifier.check(declaration)) return local(declaration.name)
655
+ if (j.FunctionDeclaration.check(declaration))
656
+ return declaration.params.length
657
+ return expressionArity(other, program.scope, declaration)
658
+ }
659
+ for (const node of root.find(j.ExportNamedDeclaration).nodes()) {
660
+ for (const specifier of node.specifiers ?? []) {
661
+ if (!j.ExportSpecifier.check(specifier)) continue
662
+ if (String(specifier.exported.name) !== imported) continue
663
+ const localName = String(specifier.local?.name ?? specifier.exported.name)
664
+ return node.source
665
+ ? importedArity(other, String(node.source.value), localName)
666
+ : local(localName)
667
+ }
668
+ }
669
+ // `export const close = …` and `export function close() {}` are program-scope bindings.
670
+ const own = local(imported)
671
+ if (own !== undefined) return own
672
+ for (const node of root.find(j.ExportAllDeclaration).nodes()) {
673
+ const through = importedArity(other, String(node.source.value), imported)
674
+ if (through !== undefined) return through
675
+ }
676
+ return undefined
389
677
  }