@charcuterie/ui 0.1.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +9 -0
  2. package/dist/Accordion/Accordion.d.ts +82 -0
  3. package/dist/Accordion/Accordion.d.ts.map +1 -0
  4. package/dist/Accordion/Accordion.js +108 -0
  5. package/dist/Accordion/Accordion.js.map +1 -0
  6. package/dist/Accordion/AccordionSection.d.ts +28 -0
  7. package/dist/Accordion/AccordionSection.d.ts.map +1 -0
  8. package/dist/Accordion/AccordionSection.js +53 -0
  9. package/dist/Accordion/AccordionSection.js.map +1 -0
  10. package/dist/Field/Field.d.ts +104 -0
  11. package/dist/Field/Field.d.ts.map +1 -0
  12. package/dist/Field/Field.js +115 -0
  13. package/dist/Field/Field.js.map +1 -0
  14. package/dist/Field/FieldGroup.d.ts +58 -0
  15. package/dist/Field/FieldGroup.d.ts.map +1 -0
  16. package/dist/Field/FieldGroup.js +65 -0
  17. package/dist/Field/FieldGroup.js.map +1 -0
  18. package/dist/FileDropZone/FileDropZone.d.ts +63 -0
  19. package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
  20. package/dist/FileDropZone/FileDropZone.js +122 -0
  21. package/dist/FileDropZone/FileDropZone.js.map +1 -0
  22. package/dist/LogViewer/LogViewer.d.ts +90 -0
  23. package/dist/LogViewer/LogViewer.d.ts.map +1 -0
  24. package/dist/LogViewer/LogViewer.js +165 -0
  25. package/dist/LogViewer/LogViewer.js.map +1 -0
  26. package/dist/Menu/Menu.d.ts +81 -0
  27. package/dist/Menu/Menu.d.ts.map +1 -0
  28. package/dist/Menu/Menu.js +199 -0
  29. package/dist/Menu/Menu.js.map +1 -0
  30. package/dist/Menu/MenuAction.d.ts +24 -0
  31. package/dist/Menu/MenuAction.d.ts.map +1 -0
  32. package/dist/Menu/MenuAction.js +37 -0
  33. package/dist/Menu/MenuAction.js.map +1 -0
  34. package/dist/Select/Select.d.ts +87 -0
  35. package/dist/Select/Select.d.ts.map +1 -0
  36. package/dist/Select/Select.js +51 -0
  37. package/dist/Select/Select.js.map +1 -0
  38. package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
  39. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
  40. package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
  41. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
  42. package/dist/Toast/Toast.d.ts +54 -0
  43. package/dist/Toast/Toast.d.ts.map +1 -0
  44. package/dist/Toast/Toast.js +121 -0
  45. package/dist/Toast/Toast.js.map +1 -0
  46. package/dist/Toast/ToastRegion.d.ts +57 -0
  47. package/dist/Toast/ToastRegion.d.ts.map +1 -0
  48. package/dist/Toast/ToastRegion.js +41 -0
  49. package/dist/Toast/ToastRegion.js.map +1 -0
  50. package/dist/Toast/toastLifecycle.d.ts +34 -0
  51. package/dist/Toast/toastLifecycle.d.ts.map +1 -0
  52. package/dist/Toast/toastLifecycle.js +25 -0
  53. package/dist/Toast/toastLifecycle.js.map +1 -0
  54. package/dist/Tooltip/Tooltip.d.ts +82 -0
  55. package/dist/Tooltip/Tooltip.d.ts.map +1 -0
  56. package/dist/Tooltip/Tooltip.js +124 -0
  57. package/dist/Tooltip/Tooltip.js.map +1 -0
  58. package/dist/index.d.ts +26 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +13 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/mountStory.testHelpers.d.ts +0 -11
  63. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  64. package/dist/mountStory.testHelpers.js +27 -0
  65. package/dist/mountStory.testHelpers.js.map +1 -1
  66. package/dist/slotProps.d.ts +110 -0
  67. package/dist/slotProps.d.ts.map +1 -0
  68. package/dist/slotProps.js +39 -0
  69. package/dist/slotProps.js.map +1 -0
  70. package/package.json +3 -3
  71. package/src/Accordion/Accordion.mdx +83 -0
  72. package/src/Accordion/Accordion.stories.tsx +146 -0
  73. package/src/Accordion/Accordion.test.tsx +163 -0
  74. package/src/Accordion/Accordion.tsx +182 -0
  75. package/src/Accordion/AccordionSection.tsx +163 -0
  76. package/src/Field/Field.mdx +144 -0
  77. package/src/Field/Field.stories.tsx +280 -0
  78. package/src/Field/Field.test.tsx +344 -0
  79. package/src/Field/Field.tsx +212 -0
  80. package/src/Field/FieldGroup.tsx +138 -0
  81. package/src/FileDropZone/FileDropZone.mdx +78 -0
  82. package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
  83. package/src/FileDropZone/FileDropZone.test.tsx +230 -0
  84. package/src/FileDropZone/FileDropZone.tsx +234 -0
  85. package/src/LogViewer/LogViewer.mdx +123 -0
  86. package/src/LogViewer/LogViewer.stories.tsx +169 -0
  87. package/src/LogViewer/LogViewer.test.tsx +236 -0
  88. package/src/LogViewer/LogViewer.tsx +261 -0
  89. package/src/Menu/Menu.mdx +103 -0
  90. package/src/Menu/Menu.stories.tsx +161 -0
  91. package/src/Menu/Menu.test.tsx +202 -0
  92. package/src/Menu/Menu.tsx +300 -0
  93. package/src/Menu/MenuAction.tsx +95 -0
  94. package/src/Select/Select.mdx +74 -0
  95. package/src/Select/Select.stories.tsx +126 -0
  96. package/src/Select/Select.test.tsx +129 -0
  97. package/src/Select/Select.tsx +200 -0
  98. package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
  99. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
  100. package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
  101. package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
  102. package/src/Toast/Toast.mdx +84 -0
  103. package/src/Toast/Toast.stories.tsx +192 -0
  104. package/src/Toast/Toast.test.tsx +217 -0
  105. package/src/Toast/Toast.tsx +224 -0
  106. package/src/Toast/ToastRegion.tsx +98 -0
  107. package/src/Toast/toastLifecycle.ts +45 -0
  108. package/src/Tooltip/Tooltip.mdx +69 -0
  109. package/src/Tooltip/Tooltip.stories.tsx +104 -0
  110. package/src/Tooltip/Tooltip.test.tsx +138 -0
  111. package/src/Tooltip/Tooltip.tsx +193 -0
  112. package/src/index.ts +47 -0
  113. package/src/mountStory.testHelpers.ts +30 -0
  114. package/src/slotProps.ts +143 -0
  115. package/src/sourceRules.test.ts +221 -30
@@ -0,0 +1,143 @@
1
+ import type { InputHTMLAttributes } from "react"
2
+
3
+ /**
4
+ * The props a **cloning ancestor** injects into the control below it.
5
+ *
6
+ * `Field` and `Tooltip` both take one child and `cloneElement` onto
7
+ * it — "cloned, not wrapped", which is what lets a `Field` go around
8
+ * an `<input>`, a `Select`, or an app's own control without changing
9
+ * that control's layout. The contract has a hole in it the moment two
10
+ * of them are nested:
11
+ *
12
+ * ```tsx
13
+ * <Field label="Pattern" isRequired>
14
+ * <Tooltip label="A JavaScript regular expression">
15
+ * <input />
16
+ * </Tooltip>
17
+ * </Field>
18
+ * ```
19
+ *
20
+ * `Field` clones onto the **`Tooltip` element**, so `id`,
21
+ * `aria-describedby`, `aria-invalid` and `required` are handed to a
22
+ * component that declares none of them — and `cloneElement` does not
23
+ * care. React drops them without a warning, TypeScript never sees
24
+ * them (`Children.only` returns a `ReactElement` with `any` props),
25
+ * every test still passes, and the render is pixel-identical. The
26
+ * `<label>` then points at an `id` that is nowhere in the document.
27
+ *
28
+ * **Silent prop-dropping is the worst version of this**, so the rule
29
+ * is: *a slot component is a pass-through*. Anything in `SlotProps`
30
+ * that arrives from above is forwarded to the child this component
31
+ * clones onto, so a chain of slots behaves like the one control at
32
+ * the bottom of it.
33
+ *
34
+ * ### The list is closed, the forwarding is not
35
+ *
36
+ * Worth being exact, because the two are easy to conflate. This type
37
+ * is a closed list of five keys rather than an index signature: it is
38
+ * exactly the wiring `Field` writes plus the wiring `Tooltip` writes,
39
+ * it is greppable, and it is the set whose **merge semantics are
40
+ * defined** — which for four of them is last-write-wins and for
41
+ * `aria-describedby` is a join.
42
+ *
43
+ * The *runtime* forwards everything. Both components collect their
44
+ * received props with a rest spread and `mergeSlotProps` starts from
45
+ * `...receivedProps`, so an ancestor's `onPointerEnter`, its
46
+ * `onFocus`, its `ref` — none of which are named here — reach the
47
+ * control at the bottom too. That is not an oversight, it is load
48
+ * bearing: a `Tooltip` around a `Field` hands down `useHover`,
49
+ * `useFocus`, `useDismiss` and `refs.setReference`, and a `Field`
50
+ * that forwarded only the five keys below would leave the tip with no
51
+ * trigger and no anchor. A closed *runtime* filter would be
52
+ * mux-magic's `FieldTooltip` defect rebuilt inside the fix for it.
53
+ *
54
+ * The five keys, and who writes them:
55
+ *
56
+ * | Key | Written by |
57
+ * | --- | --- |
58
+ * | `aria-describedby` | `Field` (its description and its error) **and** `Tooltip` (its tip) — the one key that is a list, and the reason `mergeSlotProps` exists |
59
+ * | `aria-invalid` | `Field`, derived from `error` being present |
60
+ * | `aria-required` | `Field`, from `isRequired` — the announced constraint, for a composite control the browser does not validate |
61
+ * | `id` | `Field`, so its `<label htmlFor>` has something to point at. A caller may also pass one directly, which is how a consumer pins a stable id for a deep link or an autofill hint |
62
+ * | `required` | `Field`, from `isRequired` — the constraint the browser validates |
63
+ *
64
+ * ### Why these are `Pick`ed and not written out
65
+ *
66
+ * Three of the five are booleans whose names cannot start with `is`
67
+ * or `has`, because they are not our names — they are the DOM's, and
68
+ * they are spread onto a DOM node verbatim. The house rule has
69
+ * [no carve-out](../../../docs/decisions/2026-07-29-is-has-rule-has-no-external-api-carve-out.md)
70
+ * for that: a *type property* is a shape we own, an object literal
71
+ * key is someone else's contract, and no `eslint-disable` is
72
+ * available because unused disable directives are themselves an
73
+ * error.
74
+ *
75
+ * So this type does not declare a shape. It **selects** one out of
76
+ * React's own `InputHTMLAttributes`, which is the honest description
77
+ * of what it is: five keys of the props React already types for an
78
+ * `<input>`, forwarded unchanged. The same reasoning as the
79
+ * `Record<string, boolean>` in `createReactAdapter` — we are not
80
+ * describing React's shape, we are naming the keys of it we touch —
81
+ * and it has the side benefit that `aria-invalid` gets React's real
82
+ * type (`"grammar"` and `"spelling"` are legal values) rather than
83
+ * the `boolean` a hand-written version would have narrowed it to.
84
+ */
85
+ export type SlotProps = Pick<
86
+ InputHTMLAttributes<HTMLElement>,
87
+ | "aria-describedby"
88
+ | "aria-invalid"
89
+ | "aria-required"
90
+ | "id"
91
+ | "required"
92
+ >
93
+
94
+ /**
95
+ * Merge what arrived from above with what this component writes
96
+ * itself.
97
+ *
98
+ * Everything here is a single value and last-write-wins is correct
99
+ * for it — **except `aria-describedby`, which is a list**. Two slot
100
+ * components each writing their own onto one control is the entire
101
+ * nesting problem in one attribute: a `Field` naming its description
102
+ * and its error, and a `Tooltip` naming its tip. A plain spread keeps
103
+ * one of them and loses the other, which is the same silent drop by a
104
+ * shorter route.
105
+ *
106
+ * **Outer first.** A screen reader reads the list in order, and the
107
+ * field's own description and error are the control's primary
108
+ * explanation; a tip is supplementary by construction. The ordering
109
+ * inside a single `Field` — description, then error — is `Field`'s
110
+ * own concern and is unaffected.
111
+ *
112
+ * `undefined` in `ownProps` is *absence*, not an instruction to
113
+ * clear. Both callers spell their optional wiring as
114
+ * `isRequired || undefined`, so a plain spread would let an inner
115
+ * `Field` with no error erase an outer one's `aria-invalid` — the
116
+ * same silent drop, one level down.
117
+ */
118
+ export const mergeSlotProps = <
119
+ OwnProps extends Record<string, unknown>,
120
+ >(
121
+ receivedProps: SlotProps,
122
+ ownProps: OwnProps,
123
+ ): OwnProps & SlotProps => {
124
+ const definedOwnProps = Object.fromEntries(
125
+ Object.entries(ownProps).filter(
126
+ ([, value]) => value !== undefined,
127
+ ),
128
+ ) as OwnProps
129
+
130
+ const describedBy = [
131
+ receivedProps["aria-describedby"],
132
+ ownProps["aria-describedby"],
133
+ ]
134
+ .filter(Boolean)
135
+ .join(" ")
136
+
137
+ return {
138
+ ...receivedProps,
139
+ ...definedOwnProps,
140
+ "aria-describedby":
141
+ describedBy === "" ? undefined : describedBy,
142
+ }
143
+ }
@@ -1,14 +1,20 @@
1
1
  /**
2
- * The three rules about this package's source that no compiler and no
3
- * axe run can see.
2
+ * The rules about this package's source — and about the shape of the
3
+ * workspace around it — that no compiler and no axe run can see.
4
4
  *
5
- * All three come straight from the plan's verification list, and each
6
- * one is a thing the fleet does today that the library exists to
7
- * stop.
5
+ * They come straight from the plan's verification list, and each one
6
+ * is a thing the fleet does today that the library exists to stop.
7
+ *
8
+ * The last two reach past `@charcuterie/ui` into its siblings on
9
+ * purpose. `ui` sits at the top of the dependency graph, so it is the
10
+ * one package whose tests can see all three at once — which is why
11
+ * the plan's cross-package assertions live here rather than in a
12
+ * `packages/conformance` of their own
13
+ * ([decision](../../../docs/decisions/2026-07-31-conformance-is-not-a-package.md)).
8
14
  */
9
15
 
10
16
  import { readdir, readFile } from "node:fs/promises"
11
- import { join, relative, resolve } from "node:path"
17
+ import { dirname, join, relative, resolve } from "node:path"
12
18
 
13
19
  import { expect, test } from "vitest"
14
20
 
@@ -16,6 +22,24 @@ const sourceDirectory = resolve(import.meta.dirname)
16
22
 
17
23
  const packageDirectory = resolve(sourceDirectory, "..")
18
24
 
25
+ const packagesDirectory = resolve(packageDirectory, "..")
26
+
27
+ type PackageManifest = {
28
+ dependencies?: Record<string, string>
29
+ exports?: Record<string, string | { source?: string }>
30
+ peerDependencies?: Record<string, string>
31
+ }
32
+
33
+ const readManifest = async (
34
+ name: string,
35
+ ): Promise<PackageManifest> =>
36
+ JSON.parse(
37
+ await readFile(
38
+ join(packagesDirectory, name, "package.json"),
39
+ "utf8",
40
+ ),
41
+ ) as PackageManifest
42
+
19
43
  const getSourceFiles = async (
20
44
  directory: string,
21
45
  ): Promise<{ contents: string; file: string }[]> => {
@@ -250,12 +274,23 @@ test("the barrel is the only place components are re-exported", async () => {
250
274
  // than app-specific: `Alert` (rip-deck spells it four times, twice
251
275
  // with a byte-identical `TONE_CLASS`) and `SegmentedControl`.
252
276
  //
253
- // `TabTrigger` and `SegmentedOption` are deliberately not in this
254
- // count and not exported. Each is its own component's member —
255
- // its own file only because both registrations are effects and an
256
- // effect cannot run in a loop — and the `<Name>/<Name>.tsx`
257
- // pattern this regex matches is what keeps them out.
258
- expect(componentNames.length).toBe(16)
277
+ // M6 adds the nine P1 components: `Accordion`, `Field`,
278
+ // `FileDropZone`, `LogViewer`, `Menu`, `Select`,
279
+ // `SortableTableHeader`, `Toast`, and `Tooltip`.
280
+ //
281
+ // `TabTrigger`, `SegmentedOption`, `AccordionSection`, and
282
+ // `MenuAction` are deliberately not in this count and not
283
+ // exported. Each is its own component's member — its own file only
284
+ // because both registrations are effects and an effect cannot run
285
+ // in a loop — and the `<Name>/<Name>.tsx` pattern this regex
286
+ // matches is what keeps them out.
287
+ //
288
+ // `ToastRegion` is the one exception worth naming: it *is*
289
+ // exported, and it lives in `Toast/` rather than owning a
290
+ // directory because a region with no toasts in it is not a thing
291
+ // anybody renders. It is therefore outside this count by the same
292
+ // rule, and the barrel assertion below does not reach it.
293
+ expect(componentNames.length).toBe(25)
259
294
 
260
295
  for (const name of componentNames) {
261
296
  expect(barrel).toContain(`export { ${name} }`)
@@ -267,37 +302,193 @@ test("dependency direction is tokens ← logic ← ui", async () => {
267
302
  // depend on both; `logic` may depend on `tokens`; `tokens` depends
268
303
  // on nothing, which is what lets `castkit/packages/views` read a
269
304
  // colour without a React tree.
270
- const readPackage = async (name: string) =>
271
- JSON.parse(
272
- await readFile(
273
- join(packageDirectory, "..", name, "package.json"),
274
- "utf8",
275
- ),
276
- ) as {
277
- dependencies?: Record<string, string>
278
- peerDependencies?: Record<string, string>
279
- }
280
-
281
- const getCharcuterieDependencies = (manifest: {
282
- dependencies?: Record<string, string>
283
- peerDependencies?: Record<string, string>
284
- }) =>
305
+ const getCharcuterieDependencies = (
306
+ manifest: PackageManifest,
307
+ ) =>
285
308
  Object.keys({
286
309
  ...manifest.dependencies,
287
310
  ...manifest.peerDependencies,
288
311
  }).filter((name) => name.startsWith("@charcuterie/"))
289
312
 
290
313
  expect(
291
- getCharcuterieDependencies(await readPackage("tokens")),
314
+ getCharcuterieDependencies(
315
+ await readManifest("tokens"),
316
+ ),
292
317
  ).toEqual([])
293
318
 
294
319
  expect(
295
- getCharcuterieDependencies(await readPackage("logic")),
320
+ getCharcuterieDependencies(await readManifest("logic")),
296
321
  ).toEqual([])
297
322
 
298
323
  expect(
299
324
  getCharcuterieDependencies(
300
- await readPackage("ui"),
325
+ await readManifest("ui"),
301
326
  ).sort(),
302
327
  ).toEqual(["@charcuterie/logic", "@charcuterie/tokens"])
303
328
  })
329
+
330
+ /**
331
+ * What every published entry point is allowed to reach at runtime.
332
+ *
333
+ * This is what survives of the plan's `@charcuterie/conformance`
334
+ * package — "builds React19+TWv4 / Preact / Satori profiles" — once
335
+ * the three profiles are checked against what the library actually
336
+ * became. Two of them are already built and gated by name (`docs` is
337
+ * the React 19 + Tailwind v4 build; `logic`'s five-adapter suite is
338
+ * the Preact one), and the third has no components to render because
339
+ * the ePaper-safe subset was never built. What no gate covered is the
340
+ * claim each of those profiles actually rests on: **which runtime a
341
+ * consumer is forced to install by importing a given entry point.**
342
+ * That is the assertion below.
343
+ *
344
+ * Each of these numbers is load-bearing somewhere:
345
+ *
346
+ * - `tokens` reaches **nothing**. `castkit/packages/views` renders
347
+ * through Satori and must read a colour without a React tree.
348
+ * - `logic/core` reaches **nothing**, and `logic/preact` never
349
+ * reaches `react` — `preact/compat` is most of `slatecast`'s 60 KB
350
+ * gz budget, and an accidental `react` import there is a bundle
351
+ * blow-up that typechecks.
352
+ * - `ui/testing` reaches **nothing**, which is the promise
353
+ * `expectAgentDrivable.ts` makes in prose: it ships for consumers
354
+ * to hold *their* components to, and a published package must not
355
+ * drag a test framework into an app's dependency graph.
356
+ *
357
+ * A type-only import counts. It is erased from the bundle but not
358
+ * from the `.d.ts`, so a consumer still needs the package installed
359
+ * for their own typecheck to pass.
360
+ */
361
+ const ENTRY_POINT_RUNTIMES: Record<
362
+ string,
363
+ Record<string, string[]>
364
+ > = {
365
+ logic: {
366
+ ".": ["react"],
367
+ "./core": [],
368
+ "./jotai": ["jotai"],
369
+ "./preact": ["preact", "preact/hooks"],
370
+ "./signals": ["@preact/signals-core"],
371
+ },
372
+ tokens: {
373
+ ".": [],
374
+ "./epaper": [],
375
+ },
376
+ ui: {
377
+ ".": [
378
+ "@charcuterie/logic",
379
+ "@charcuterie/tokens",
380
+ "@floating-ui/react",
381
+ "react",
382
+ ],
383
+ "./testing": [],
384
+ "./tokens": ["@charcuterie/tokens"],
385
+ },
386
+ }
387
+
388
+ /**
389
+ * Every bare specifier reachable from `entryFile`, following relative
390
+ * imports and stopping at package boundaries.
391
+ *
392
+ * Every relative import in this workspace carries its extension, so
393
+ * "resolution" is a `join` — no `.js`/`index` guessing, and a typo
394
+ * fails loudly as a missing file rather than silently pruning the
395
+ * graph.
396
+ */
397
+ const collectRuntimeImports = async (entryFile: string) => {
398
+ const visited = new Set<string>()
399
+
400
+ const bareSpecifiers = new Set<string>()
401
+
402
+ const visit = async (file: string) => {
403
+ if (visited.has(file)) {
404
+ return
405
+ }
406
+
407
+ visited.add(file)
408
+
409
+ // Template literals go too. `createStatus`'s error message
410
+ // spells `from "${current}"`, which is prose about a state
411
+ // machine and not an import of a package called `${current}`.
412
+ const contents = stripComments(
413
+ await readFile(file, "utf8"),
414
+ ).replace(/`(?:[^`\\]|\\.)*`/g, "")
415
+
416
+ const specifiers = [
417
+ ...contents.matchAll(/\bfrom\s+"([^"]+)"/g),
418
+ ...contents.matchAll(/\bimport\s+"([^"]+)"/g),
419
+ ].map(([, specifier]) => specifier as string)
420
+
421
+ for (const specifier of specifiers) {
422
+ if (!specifier.startsWith(".")) {
423
+ bareSpecifiers.add(specifier)
424
+
425
+ continue
426
+ }
427
+
428
+ if (/\.css$/.test(specifier)) {
429
+ continue
430
+ }
431
+
432
+ await visit(resolve(dirname(file), specifier))
433
+ }
434
+ }
435
+
436
+ await visit(entryFile)
437
+
438
+ return [...bareSpecifiers].sort()
439
+ }
440
+
441
+ test.each(Object.keys(ENTRY_POINT_RUNTIMES))(
442
+ "every @charcuterie/%s entry point reaches only its own runtime",
443
+ async (packageName) => {
444
+ const manifest = await readManifest(packageName)
445
+
446
+ const entryPoints = Object.entries(
447
+ manifest.exports ?? {},
448
+ ).flatMap(([name, target]) =>
449
+ typeof target === "object" && target.source
450
+ ? [[name, target.source] as const]
451
+ : [],
452
+ )
453
+
454
+ // The table is exhaustive, so a new entry point cannot land
455
+ // without somebody deciding what it is allowed to pull in.
456
+ expect(
457
+ entryPoints.map(([name]) => name).sort(),
458
+ ).toEqual(
459
+ Object.keys(
460
+ ENTRY_POINT_RUNTIMES[packageName] ?? {},
461
+ ).sort(),
462
+ )
463
+
464
+ const declared = Object.keys({
465
+ ...manifest.dependencies,
466
+ ...manifest.peerDependencies,
467
+ })
468
+
469
+ for (const [name, source] of entryPoints) {
470
+ const reached = await collectRuntimeImports(
471
+ join(packagesDirectory, packageName, source),
472
+ )
473
+
474
+ expect({
475
+ entryPoint: `${packageName}${name.slice(1)}`,
476
+ reached,
477
+ }).toEqual({
478
+ entryPoint: `${packageName}${name.slice(1)}`,
479
+ reached: ENTRY_POINT_RUNTIMES[packageName]?.[name],
480
+ })
481
+
482
+ // And nothing reached is a phantom dependency —
483
+ // `preact/hooks` is declared as `preact`, which is the unit an
484
+ // install actually has.
485
+ for (const specifier of reached) {
486
+ const packageOfSpecifier = specifier.startsWith("@")
487
+ ? specifier.split("/").slice(0, 2).join("/")
488
+ : specifier.split("/")[0]
489
+
490
+ expect(declared).toContain(packageOfSpecifier)
491
+ }
492
+ }
493
+ },
494
+ )