@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 +12 -1
- package/codemods/README.md +18 -4
- package/codemods/format.ts +86 -0
- package/codemods/migrate/from-base-elements.ts +8 -2
- package/codemods/migrate/from-volt.ts +8 -2
- package/codemods/radix-to-base-ui.ts +300 -12
- package/dist/components/field/field-combobox.js +190 -181
- package/dist/components/field/field-combobox.js.map +1 -1
- package/dist/components/field/field-select.js +37 -22
- package/dist/components/field/field-select.js.map +1 -1
- package/dist/components/scroll-overflow/scroll-overflow.js +21 -21
- package/dist/components/scroll-overflow/scroll-overflow.js.map +1 -1
- package/dist/components/toggle/toggle.js +29 -26
- package/dist/components/toggle/toggle.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/renderer/served.js +19 -14
- package/dist/renderer/served.js.map +1 -1
- package/dist/tokens.json +1 -1
- package/package.json +4 -3
- package/scripts/migration-diagnostics-core.mjs +284 -0
- package/scripts/migration-sweep.mjs +10 -10
- package/testing/setup.cjs +87 -0
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
|
|
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
|
package/codemods/README.md
CHANGED
|
@@ -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
|
|
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)
|
|
42
|
-
|
|
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(
|
|
19
|
-
|
|
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(
|
|
19
|
-
|
|
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(
|
|
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(
|
|
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
|
-
?
|
|
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:
|
|
379
|
-
const
|
|
380
|
-
|
|
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,
|
|
388
|
-
: j.optionalCallExpression(handler,
|
|
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
|
}
|