@astryxdesign/cli 0.7.2-canary.db3a2e7 → 0.7.2-canary.e26723d

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 (142) hide show
  1. package/README.md +7 -7
  2. package/api/build/help/help.mjs +1 -1
  3. package/api/component/_adapter.d.mts +41 -1
  4. package/api/component/_adapter.mjs +93 -7
  5. package/api/component/component.mjs +22 -5
  6. package/api/component/component.test.mjs +420 -167
  7. package/api/component/integration-export-name.test.mjs +644 -0
  8. package/api/component/list/list.mjs +42 -4
  9. package/api/docs/_adapter.mjs +207 -1
  10. package/api/docs/integrationDocs.test.mjs +124 -0
  11. package/api/doctor/theme-checks.mjs +1 -1
  12. package/api/gap-report/gap-report.mjs +67 -4
  13. package/api/init/run/run.mjs +2 -2
  14. package/api/integration/_adapter.d.mts +12 -0
  15. package/api/integration/_adapter.mjs +54 -0
  16. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  17. package/api/integration/add-contribution.mjs +7 -3
  18. package/api/integration/authoring-checks.mjs +11 -2
  19. package/api/integration/authoring-checks.test.mjs +39 -0
  20. package/api/integration/authoring-checks.type.d.mts +9 -0
  21. package/api/integration/authoring-checks.type.mjs +14 -1
  22. package/api/integration/integrationComponentConflicts.doc.mjs +21 -5
  23. package/api/integration/pack-check.mjs +61 -107
  24. package/api/integration/pack-check.test.mjs +359 -0
  25. package/api/integration/validate-integration.test.mjs +11 -0
  26. package/api/search/search.d.mts +9 -0
  27. package/api/search/search.mjs +108 -16
  28. package/api/search/search.test.mjs +36 -0
  29. package/api/swizzle/copy/copy.mjs +28 -14
  30. package/api/template/template.test.mjs +120 -3
  31. package/api/theme/build/build.family.test.mjs +16 -0
  32. package/api/theme/build/build.mjs +81 -22
  33. package/api/theme/themeBuild.doc.mjs +3 -3
  34. package/assets/codemods/transforms/next/__tests__/migrate-list-edge-compensation-to-edge.test.mjs +120 -0
  35. package/assets/codemods/transforms/next/index.mjs +11 -1
  36. package/assets/codemods/transforms/next/migrate-list-edge-compensation-to-edge.mjs +198 -0
  37. package/assets/docs/README.md +10 -0
  38. package/assets/docs/author-a-theme.doc.mjs +1 -1
  39. package/assets/docs/getting-started.doc.mjs +3 -3
  40. package/assets/docs/theme.doc.dense.mjs +1 -1
  41. package/assets/docs/theme.doc.mjs +1 -1
  42. package/assets/docs/theme.doc.zh.mjs +1 -1
  43. package/assets/docs/tree/add-a-component.doc.mjs +10 -0
  44. package/assets/docs/tree/check-the-props.doc.mjs +145 -0
  45. package/assets/docs/tree/checks.doc.mjs +4 -4
  46. package/assets/docs/tree/component-family.doc.mjs +5 -1
  47. package/assets/docs/tree/component-imports.doc.mjs +8 -0
  48. package/assets/docs/tree/describe-the-component.doc.mjs +1 -1
  49. package/assets/docs/tree/links.doc.mjs +2 -2
  50. package/assets/docs/tree/single-component.doc.mjs +3 -2
  51. package/assets/docs/tree/troubleshooting.doc.mjs +9 -1
  52. package/assets/docs/tree/type-scale.doc.mjs +3 -3
  53. package/assets/docs/tree/versioning.doc.mjs +1 -0
  54. package/assets/docs/tree/write-the-template-file.doc.mjs +2 -1
  55. package/assets/docs/use-a-theme.doc.mjs +2 -2
  56. package/assets/docs/working-with-ai.doc.mjs +43 -4
  57. package/assets/templates/blocks/components/Drawer/DrawerForm.doc.mjs +24 -0
  58. package/assets/templates/blocks/components/Drawer/DrawerForm.tsx +62 -0
  59. package/assets/templates/blocks/components/Drawer/DrawerScrim.doc.mjs +22 -0
  60. package/assets/templates/blocks/components/Drawer/DrawerScrim.tsx +68 -0
  61. package/assets/templates/blocks/components/Drawer/DrawerShowcase.doc.mjs +22 -0
  62. package/assets/templates/blocks/components/Drawer/DrawerShowcase.tsx +41 -0
  63. package/assets/templates/blocks/components/Drawer/DrawerStart.doc.mjs +21 -0
  64. package/assets/templates/blocks/components/Drawer/DrawerStart.tsx +41 -0
  65. package/assets/templates/blocks/components/Drawer/DrawerWidths.doc.mjs +22 -0
  66. package/assets/templates/blocks/components/Drawer/DrawerWidths.tsx +54 -0
  67. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.doc.mjs +15 -0
  68. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.tsx +73 -0
  69. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.doc.mjs +22 -0
  70. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.tsx +49 -0
  71. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.doc.mjs +21 -0
  72. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.tsx +42 -0
  73. package/assets/templates/pages/detail-page/page.tsx +3 -3
  74. package/assets/templates/pages/table-inbox/page.tsx +10 -8
  75. package/assets/theme.template.ts +1 -1
  76. package/authoring/doctypes/base/type.ts +6 -0
  77. package/authoring/doctypes/component/component.doc.mjs +4 -4
  78. package/authoring/doctypes/component/type.ts +6 -3
  79. package/authoring/doctypes/template/integration-template.doc.d.mts +11 -0
  80. package/authoring/doctypes/template/integration-template.doc.mjs +49 -0
  81. package/authoring/doctypes/template/parse.d.mts +6 -2
  82. package/authoring/doctypes/template/parse.mjs +3 -1
  83. package/authoring/doctypes/template/type.ts +22 -0
  84. package/authoring/index.d.ts +1 -0
  85. package/authoring/integration/integration.doc.mjs +8 -1
  86. package/authoring/integration/parse.test.mjs +10 -0
  87. package/authoring/integration/schema.d.mts +2 -0
  88. package/authoring/integration/schema.mjs +1 -0
  89. package/authoring/integration/type.ts +4 -0
  90. package/clients/cli/commands/build-theme.batch-report.test.mjs +3 -1
  91. package/clients/cli/commands/build-theme.mjs +1 -1
  92. package/clients/cli/commands/build-theme.path-safety.test.mjs +84 -0
  93. package/clients/cli/commands/build-theme.registry.test.mjs +5 -2
  94. package/clients/cli/commands/doctor-integration-components.doc.mjs +9 -3
  95. package/clients/cli/commands/doctor-integration.test.mjs +392 -0
  96. package/clients/cli/commands/doctor.mjs +74 -0
  97. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  98. package/clients/cli/lib/component-format.mjs +5 -1
  99. package/foundation/config/project.mjs +82 -0
  100. package/foundation/discovery/authoring-self-docs.mjs +1 -0
  101. package/foundation/discovery/component-discovery.d.mts +190 -10
  102. package/foundation/discovery/component-discovery.export-names.test.mjs +760 -0
  103. package/foundation/discovery/component-discovery.mjs +864 -56
  104. package/foundation/discovery/component-discovery.test.mjs +96 -0
  105. package/foundation/discovery/component-replacement.mjs +18 -0
  106. package/foundation/discovery/component-replacement.test.mjs +40 -0
  107. package/foundation/discovery/doc-types.d.mts +157 -0
  108. package/foundation/discovery/doc-types.mjs +1224 -0
  109. package/foundation/discovery/doc-types.test.mjs +652 -0
  110. package/foundation/discovery/docs-discovery.d.mts +81 -0
  111. package/foundation/discovery/docs-discovery.mjs +57 -0
  112. package/foundation/discovery/module-exports.d.mts +27 -0
  113. package/foundation/discovery/module-exports.mjs +356 -31
  114. package/foundation/discovery/module-exports.test.mjs +172 -0
  115. package/foundation/discovery/package-tsconfig.d.mts +14 -0
  116. package/foundation/discovery/package-tsconfig.mjs +228 -0
  117. package/foundation/discovery/package-tsconfig.test.mjs +210 -0
  118. package/foundation/discovery/project-typescript.d.mts +23 -0
  119. package/foundation/discovery/project-typescript.mjs +61 -0
  120. package/foundation/discovery/prop-drift.d.mts +220 -0
  121. package/foundation/discovery/prop-drift.mjs +1765 -0
  122. package/foundation/discovery/prop-drift.test.mjs +1197 -0
  123. package/foundation/discovery/template-adapter.mjs +74 -5
  124. package/foundation/discovery/type-match.d.mts +53 -0
  125. package/foundation/discovery/type-match.mjs +660 -0
  126. package/foundation/discovery/type-match.test.mjs +169 -0
  127. package/foundation/discovery/type-text.d.mts +68 -0
  128. package/foundation/discovery/type-text.mjs +658 -0
  129. package/foundation/discovery/type-text.test.mjs +196 -0
  130. package/foundation/discovery/unresolved-references.d.mts +62 -0
  131. package/foundation/discovery/unresolved-references.mjs +790 -0
  132. package/foundation/discovery/unresolved-references.test.mjs +567 -0
  133. package/foundation/doc-compiler/doc-loads.test.mjs +9 -1
  134. package/foundation/doc-compiler/inputs.mjs +23 -0
  135. package/foundation/doc-compiler/inputs.test.mjs +0 -1
  136. package/foundation/doc-compiler/links.d.mts +9 -5
  137. package/foundation/doc-compiler/links.mjs +4 -2
  138. package/foundation/integrations/contribution-inventory.mjs +6 -1
  139. package/foundation/response/response-types.doc.mjs +1 -1
  140. package/foundation/xle/registry.mjs +4 -0
  141. package/foundation/xle/registry.test.mjs +28 -0
  142. package/package.json +10 -10
@@ -0,0 +1,1765 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Compare a documented prop table with the props the TypeScript source
5
+ * declares for the export it describes.
6
+ *
7
+ * The compiler is the project's own `typescript`. Without one it can use, the
8
+ * comparison is skipped and says why. Every finding is a warning.
9
+ *
10
+ * `comparePropDocs` finds each component doc's prop tables first: a doc's
11
+ * own `props` document the export its `name` names; each entry of a
12
+ * multi-component doc documents its own export, which a member record
13
+ * compares against its own source. Hook entries (`use…`) document parameters
14
+ * and returns, not props, and are not compared.
15
+ *
16
+ * Each table (a doc's `props`, or a multi-component doc's entry, with the
17
+ * export it documents and the files that may hold it) is compared with that
18
+ * export's props type:
19
+ * - `not_in_code`: a documented prop the props type does not have (a
20
+ * `data-*`-style name an index signature accepts counts as present);
21
+ * - `not_in_doc`: a prop the package's own source declares, or builds with a
22
+ * mapped type from its own keys, that the table does not list. Props from
23
+ * outside the package (React, the DOM, another package's types or keys) are
24
+ * never required, and neither are `className`, `style`, `xstyle`,
25
+ * `data-testid`, `ref`, `key`, or a prop whose declaration carries an
26
+ * `internal` JSDoc tag;
27
+ * - `required_mismatch`: `required` disagrees with whether every caller must
28
+ * pass the prop (one only some members of the props type require, or that
29
+ * the package's `defaultProps` fills, is optional, as it is to JSX
30
+ * callers; an entry that names several props claims its `required` for
31
+ * each).
32
+ *
33
+ * @input Component records (name, docPath, sourcePath), or prop tables, the
34
+ * package root, and the directories to resolve `typescript` from.
35
+ * @output A PropDriftReport.
36
+ * @position Shared by `astryx doctor integration components` and the Astryx
37
+ * repository's test over Core's own component docs.
38
+ */
39
+
40
+ import * as fs from 'node:fs';
41
+ import * as path from 'node:path';
42
+ import {folderSourceFor} from './component-discovery.mjs';
43
+ import {loadComponentDoc} from './component-loader.mjs';
44
+ import {readPackageTsconfig} from './package-tsconfig.mjs';
45
+ import {
46
+ resolveProjectTypeScript,
47
+ unusableTypeScriptReason,
48
+ } from './project-typescript.mjs';
49
+ import {
50
+ unresolvedGlobals,
51
+ unresolvedReference,
52
+ } from './unresolved-references.mjs';
53
+
54
+ /**
55
+ * @typedef {'not_in_code' | 'not_in_doc' | 'required_mismatch'} PropDriftKind
56
+ */
57
+
58
+ /**
59
+ * @typedef {object} PropDriftFinding
60
+ * @property {string} component the documented component (export) name
61
+ * @property {string} prop
62
+ * @property {PropDriftKind} kind
63
+ * @property {'warning'} severity
64
+ * @property {string} message
65
+ * @property {string} doc the doc file, relative to the package root
66
+ * @property {string} source the source file, relative to the package root
67
+ * @property {string} [docType] the documented type, as written
68
+ * @property {string} [codeType] the type as the source declares it
69
+ * @property {boolean} [docRequired]
70
+ * @property {boolean} [codeRequired]
71
+ */
72
+
73
+ /**
74
+ * @typedef {object} PropDriftUnchecked
75
+ * @property {string} component
76
+ * @property {string} doc the doc file, relative to the package root
77
+ * @property {string} reason why its props could not be read
78
+ */
79
+
80
+ /**
81
+ * @typedef {object} PropDriftReport
82
+ * @property {'checked' | 'skipped'} status `skipped` means nothing was
83
+ * compared, and `reason` says why
84
+ * @property {string} [reason]
85
+ * @property {string} [typescript] the version of the compiler that read the props
86
+ * @property {number} components how many documented prop tables were compared
87
+ * @property {PropDriftFinding[]} findings
88
+ * @property {PropDriftUnchecked[]} unchecked documented components whose props
89
+ * could not be read from the source
90
+ * @property {Array<PropDriftUnchecked & {props: string[]}>} [propsNotChecked]
91
+ * props a mapped type builds from keys the check can't trace, which a doc
92
+ * leaving out draws no warning for
93
+ */
94
+
95
+ /**
96
+ * @typedef {object} PropDriftTable
97
+ * @property {string} doc the doc file, relative to the package root
98
+ * @property {string} name the export the table documents
99
+ * @property {any[]} props the table's entries
100
+ * @property {string[]} sources the files that may hold the export, in order
101
+ */
102
+
103
+ /**
104
+ * @typedef {object} PropDriftComponent
105
+ * @property {string} name
106
+ * @property {string} docPath absolute path to the component's `.doc.*` file
107
+ * @property {string | null} sourcePath absolute path to its same-stem
108
+ * source, or null when no file in the package declares it (a family member
109
+ * only another folder or built output defines)
110
+ */
111
+
112
+ /**
113
+ * Props a doc never needs to list: the internal and styling props the
114
+ * `ComponentPropDoc` authoring guidance says to skip, `ref`, which Core's
115
+ * docs also leave out, and `key`, which React keeps for itself.
116
+ */
117
+ export const PROPS_NOT_DOCUMENTED = Object.freeze([
118
+ 'className',
119
+ 'style',
120
+ 'xstyle',
121
+ 'data-testid',
122
+ 'ref',
123
+ 'key',
124
+ ]);
125
+
126
+ /** The guidance's internal props: a declaration tagged `@internal`. */
127
+ const SKIP_TAGS = new Set(['internal']);
128
+ const HOOK_NAME = /^use[A-Z0-9]/;
129
+
130
+ /** The line both surfaces print when the compiler is missing. */
131
+ export const TYPESCRIPT_MISSING_REASON =
132
+ 'TypeScript is not installed in this project, so documented props were not compared with the code. Install typescript and run this check again.';
133
+
134
+ /**
135
+ * @template T
136
+ * @param {() => T} compute
137
+ * @returns {() => T}
138
+ */
139
+ function memo(compute) {
140
+ /** @type {{value: T} | null} */
141
+ let cached = null;
142
+ return () => {
143
+ if (cached == null) cached = {value: compute()};
144
+ return cached.value;
145
+ };
146
+ }
147
+
148
+ /** @type {WeakMap<Function, (file: string) => boolean>} */
149
+ const MINE = new WeakMap();
150
+
151
+ /**
152
+ * Whose type names the walk checks: the package's own files and any that
153
+ * aren't a dependency's. The same test for the same own-file test.
154
+ * @param {(file: string) => boolean} isOwnFile
155
+ */
156
+ function mineFor(isOwnFile) {
157
+ let mine = MINE.get(isOwnFile);
158
+ if (!mine) {
159
+ mine = (/** @type {string} */ file) =>
160
+ isOwnFile(file) || !/[\\/]node_modules[\\/]/.test(file);
161
+ MINE.set(isOwnFile, mine);
162
+ }
163
+ return mine;
164
+ }
165
+
166
+ /**
167
+ * A memoized "is this file part of the package's own source?" test.
168
+ * @param {string} root realpath of the package root
169
+ */
170
+ function ownFileTest(root) {
171
+ /** @type {Map<string, boolean>} */
172
+ const cache = new Map();
173
+ return (/** @type {string} */ file) => {
174
+ let own = cache.get(file);
175
+ if (own !== undefined) return own;
176
+ let real = file;
177
+ try {
178
+ real = fs.realpathSync(file);
179
+ } catch {
180
+ // A lib or virtual file: compare as given.
181
+ }
182
+ const rel = path.relative(root, real);
183
+ own =
184
+ rel !== '' &&
185
+ !rel.startsWith('..') &&
186
+ !path.isAbsolute(rel) &&
187
+ !rel.split(path.sep).includes('node_modules');
188
+ cache.set(file, own);
189
+ return own;
190
+ };
191
+ }
192
+
193
+ /**
194
+ * @typedef {object} CodeProp
195
+ * @property {string} name
196
+ * @property {boolean} required whether every caller must pass it: false
197
+ * when a member of the props type, an overload, or the package's
198
+ * `defaultProps` lets a caller leave it out
199
+ * @property {boolean} own declared in the package's own source, or built
200
+ * from keys it writes
201
+ * @property {boolean} [untraced] built from keys the check can't trace
202
+ * @property {boolean} skipTagged its declaration carries an internal JSDoc tag
203
+ * @property {string[]} declared the type as written, per own declaration
204
+ * @property {() => string[]} renderings the compiler's renderings, computed on demand
205
+ * @property {() => any[]} types the prop's type in each member of the props type
206
+ */
207
+
208
+ /**
209
+ * Read the props of one export.
210
+ * @param {any} ts
211
+ * @param {any} program
212
+ * @param {any} checker
213
+ * @param {string} sourcePath
214
+ * @param {string} exportName
215
+ * @param {(file: string) => boolean} isOwnFile
216
+ * @param {import('./unresolved-references.mjs').UnresolvedReference | null} ambient
217
+ * what the program's other roots cannot resolve, which any component may
218
+ * use
219
+ * @returns {{ok: true, props: Map<string, CodeProp>, accepts: (name: string) => boolean} | {ok: false, reason: string}}
220
+ */
221
+ function readCodeProps(
222
+ ts,
223
+ program,
224
+ checker,
225
+ sourcePath,
226
+ exportName,
227
+ isOwnFile,
228
+ ambient,
229
+ ) {
230
+ const sourceFile = program.getSourceFile(sourcePath);
231
+ const sourceName = path.basename(sourcePath);
232
+ if (!sourceFile) {
233
+ return {ok: false, reason: `the compiler could not read ${sourceName}`};
234
+ }
235
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
236
+ const exports = moduleSymbol ? checker.getExportsOfModule(moduleSymbol) : [];
237
+ const stem = sourceName.replace(/\.[cm]?[jt]sx?$/, '');
238
+ let symbol =
239
+ exports.find(/** @param {any} s */ s => s.name === exportName) ??
240
+ (stem === exportName
241
+ ? exports.find(/** @param {any} s */ s => s.name === 'default')
242
+ : undefined);
243
+ if (!symbol) {
244
+ return {
245
+ ok: false,
246
+ reason: `${sourceName} has no export named ${exportName}`,
247
+ };
248
+ }
249
+ if (symbol.flags & ts.SymbolFlags.Alias) {
250
+ symbol = checker.getAliasedSymbol(symbol);
251
+ }
252
+ // An import that does not resolve, in the files the component comes from,
253
+ // can make any part of its props a guess, whatever the code looks like.
254
+ // Some TypeScript versions read such a name as a plain `any`, so this comes
255
+ // first.
256
+ const own = [sourceFile, ...(symbol.declarations ?? [])]
257
+ .map(/** @param {any} n */ n => n.getSourceFile())
258
+ .filter(/** @param {any} sf */ sf => isOwnFile(sf.fileName));
259
+ // Type names are checked in the files that aren't a dependency's. One
260
+ // test per run, so the walk reads each file once for every component.
261
+ const mine = mineFor(isOwnFile);
262
+ const found = unresolvedReference(
263
+ ts,
264
+ program,
265
+ checker,
266
+ own,
267
+ () => true,
268
+ mine,
269
+ );
270
+ // A reference that doesn't resolve names what to install; a type the
271
+ // compiler can't give in the component's own files may only show a broken
272
+ // global one.
273
+ const missing = found && found.how !== 'used' ? found : (ambient ?? found);
274
+ if (missing) {
275
+ const where =
276
+ path.relative(path.dirname(sourcePath), missing.file) || sourceName;
277
+ return {
278
+ ok: false,
279
+ reason:
280
+ missing.how === 'used'
281
+ ? `${exportName} depends on ${missing.name} (used in ${where}), a type the compiler can't work out, so its props would be a guess. Check that the package's dependencies are installed and its types compile, then run this check again`
282
+ : `${exportName} depends on ${missing.name} (${missing.how} in ${where}), which the compiler could not resolve, usually a dependency that is not installed. Install the package's dependencies and run this check again`,
283
+ };
284
+ }
285
+ const type = checker.getTypeOfSymbolAtLocation(symbol, sourceFile);
286
+ // Each member of a union (`ComponentType<P>`: a class or a function).
287
+ const signatures = (type.isUnion() ? type.types : [type]).flatMap(
288
+ /** @param {any} t */ t => [
289
+ ...t.getCallSignatures(),
290
+ ...t.getConstructSignatures(),
291
+ ],
292
+ );
293
+ if (signatures.length === 0) {
294
+ // An `any` the source does not write comes from a type that does not
295
+ // resolve, such as `forwardRef` when React's types are not installed.
296
+ const writtenAny =
297
+ symbol.valueDeclaration?.type?.kind === ts.SyntaxKind.AnyKeyword;
298
+ const unresolved =
299
+ !writtenAny &&
300
+ (type.flags & ts.TypeFlags.Any ||
301
+ hasUnresolvedPart(ts, checker, type, 0, new Set()));
302
+ return {
303
+ ok: false,
304
+ reason: unresolved
305
+ ? `${exportName}'s own type includes a type the compiler could not resolve, usually an import whose package is not installed (such as React's types, for a forwardRef or memo component). Install the package's dependencies and run this check again`
306
+ : writtenAny
307
+ ? `${exportName} is typed \`any\`, so it has no props to compare`
308
+ : `${exportName} in ${sourceName} is not a component (it cannot be called with props)`,
309
+ };
310
+ }
311
+ const unresolved = {
312
+ ok: /** @type {const} */ (false),
313
+ reason: `${exportName}'s props type includes a type the compiler could not resolve, usually an import whose package is not installed. Install the package's dependencies and run this check again`,
314
+ };
315
+ // Every overload: its props type joins the others as a member, as a
316
+ // union's members do. An overload that takes no props makes every prop
317
+ // optional.
318
+ /** @type {any[]} */
319
+ const propsTypes = [];
320
+ let propless = false;
321
+ for (const signature of signatures) {
322
+ const parameter = signature.getParameters()[0];
323
+ if (parameter) {
324
+ propsTypes.push(checker.getTypeOfSymbolAtLocation(parameter, sourceFile));
325
+ } else {
326
+ propless = true;
327
+ }
328
+ }
329
+ /** @type {Map<string, CodeProp>} */
330
+ const props = new Map();
331
+ if (propsTypes.length === 0) return {ok: true, props, accepts: () => false};
332
+
333
+ // Props that are `any`, written so or made from it (`FC<any>`, or
334
+ // `forwardRef<E, any>`'s `Omit<any, 'ref'>`).
335
+ if (propsTypes.some(propsType => isAnyProps(ts, checker, propsType))) {
336
+ return {
337
+ ok: false,
338
+ reason: `${exportName}'s props are typed \`any\`, so there is nothing to compare them with`,
339
+ };
340
+ }
341
+ if (
342
+ propsTypes.some(propsType =>
343
+ hasUnresolvedPart(ts, checker, propsType, 0, new Set()),
344
+ )
345
+ ) {
346
+ return unresolved;
347
+ }
348
+
349
+ const members = propsTypes
350
+ .flatMap(propsType => (propsType.isUnion() ? propsType.types : [propsType]))
351
+ .filter(
352
+ /** @param {any} t */
353
+ t => !(t.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)),
354
+ );
355
+ // A generic component whose props take their names from a type parameter
356
+ // (the element it renders: `Omit<ComponentPropsWithoutRef<E>, 'as'>`, or a
357
+ // bare `props: P`) accepts names that are known only where it is used.
358
+ const generic = signatures.some(
359
+ /** @param {any} signature */ signature =>
360
+ (signature.getTypeParameters?.() ?? []).length > 0,
361
+ );
362
+ // Props that refer to a type parameter and show the compiler no members
363
+ // at all would draw a finding for every documented name: listed too.
364
+ const unnamed =
365
+ members.some(member => isDeferred(ts, checker, member, true)) &&
366
+ members.every(
367
+ member =>
368
+ checker.getPropertiesOfType(member).length === 0 &&
369
+ (checker.getIndexInfosOfType?.(member) ?? []).length === 0,
370
+ );
371
+ if (
372
+ generic &&
373
+ (unnamed ||
374
+ members.some(member => namesFromTypeParameter(ts, checker, member, 0)))
375
+ ) {
376
+ return {
377
+ ok: false,
378
+ reason: `${exportName}'s props take some of their names from a type parameter, such as the element it renders, so which props it accepts is known only where it is used`,
379
+ };
380
+ }
381
+ // Each member of a prop's type is printed on its own, and long prints are
382
+ // truncated: a type that expands to a huge structure (StyleX styles, say)
383
+ // never matches a documented type anyway, and printing it whole can take
384
+ // gigabytes.
385
+ const flags =
386
+ ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType |
387
+ ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope;
388
+
389
+ // `defaultProps` the package sets (a class's static field, one assigned to a
390
+ // function, or the wrapped component's through up to two layers of `memo`
391
+ // and `lazy`, as React's JSX types read it) lets JSX callers leave its props
392
+ // out; `any` fills every prop. React's own types declare an optional one on
393
+ // every component type.
394
+ let holder = type;
395
+ for (let i = 0; i < 2 && checker.getPropertyOfType(holder, '$$typeof'); i++) {
396
+ const inner =
397
+ checker.getPropertyOfType(holder, 'type') ??
398
+ checker.getPropertyOfType(holder, '_result');
399
+ if (!inner) break;
400
+ holder = checker.getTypeOfSymbolAtLocation(inner, sourceFile);
401
+ }
402
+ const defaultProps = checker.getPropertyOfType(holder, 'defaultProps');
403
+ const defaults = defaultProps?.declarations?.some(
404
+ /** @param {any} d */ d => isOwnFile(d.getSourceFile().fileName),
405
+ )
406
+ ? checker.getTypeOfSymbolAtLocation(defaultProps, sourceFile)
407
+ : null;
408
+ const fillsAll = !!defaults && !!(defaults.flags & ts.TypeFlags.Any);
409
+ const defaulted = new Set(
410
+ defaults
411
+ ? checker
412
+ .getPropertiesOfType(defaults)
413
+ .map(/** @param {any} s */ s => s.name)
414
+ : [],
415
+ );
416
+
417
+ /** @type {Map<string, {symbols: any[], optional: number}>} */
418
+ const byName = new Map();
419
+ for (const member of members) {
420
+ for (const prop of checker.getPropertiesOfType(member)) {
421
+ const entry = byName.get(prop.name) ?? {symbols: [], optional: 0};
422
+ entry.symbols.push(prop);
423
+ if (prop.flags & ts.SymbolFlags.Optional) entry.optional++;
424
+ byName.set(prop.name, entry);
425
+ }
426
+ }
427
+ if (fillsAll) for (const name of byName.keys()) defaulted.add(name);
428
+
429
+ /** @type {((prop: any) => string) | undefined} */
430
+ let origin;
431
+ // Read from the component's declaration, its props parameter's, and the
432
+ // wrapped component's for `memo` and `lazy`.
433
+ const originOf = (/** @type {any} */ prop) =>
434
+ (origin ??= mappedPropOrigin(ts, checker, isOwnFile, [
435
+ symbol.valueDeclaration ?? symbol.declarations?.[0],
436
+ ...[
437
+ ...signatures,
438
+ ...(holder === type ? [] : holder.getCallSignatures()),
439
+ ].map(
440
+ (/** @type {any} */ signature) =>
441
+ signature.getParameters()[0]?.valueDeclaration,
442
+ ),
443
+ ]))(prop);
444
+ for (const [name, {symbols, optional}] of byName) {
445
+ const declarations = symbols.flatMap(
446
+ /** @param {any} s */ s => s.declarations ?? [],
447
+ );
448
+ const ownDeclarations = declarations.filter(
449
+ /** @param {any} d */ d => isOwnFile(d.getSourceFile().fileName),
450
+ );
451
+ // A prop no declaration names, one a mapped type builds, is the
452
+ // package's own when its key comes from the package's own source.
453
+ const built =
454
+ declarations.length === 0 ? strongest(symbols.map(originOf)) : null;
455
+ // Optional when a caller may leave it out: an overload that takes no
456
+ // props, or a member of the props type that does not require it.
457
+ const required =
458
+ !defaulted.has(name) &&
459
+ !propless &&
460
+ symbols.length === members.length &&
461
+ optional === 0;
462
+ props.set(name, {
463
+ name,
464
+ required,
465
+ own: ownDeclarations.length > 0 || built === 'own',
466
+ untraced:
467
+ declarations.length === 0 && built !== 'own' && built !== 'foreign',
468
+ skipTagged: ownDeclarations.some(
469
+ /** @param {any} d */
470
+ d =>
471
+ ts
472
+ .getJSDocTags(d)
473
+ .some(
474
+ /** @param {any} tag */ tag => SKIP_TAGS.has(tag.tagName.text),
475
+ ),
476
+ ),
477
+ // A property's `type` is the prop's type; a method's is its return
478
+ // type, so a method prop reads as the compiler renders it.
479
+ declared: ownDeclarations
480
+ .filter(
481
+ /** @param {any} d */ d =>
482
+ d.type && !ts.isMethodSignature(d) && !ts.isMethodDeclaration(d),
483
+ )
484
+ .map(/** @param {any} d */ d => d.type.getText(d.getSourceFile())),
485
+ types: () =>
486
+ symbols.map(
487
+ /** @param {any} s */ s =>
488
+ checker.getTypeOfSymbolAtLocation(s, sourceFile),
489
+ ),
490
+ renderings: memo(() => {
491
+ /** @type {string[]} */
492
+ const printed = [];
493
+ for (const s of symbols) {
494
+ const propType = checker.getTypeOfSymbolAtLocation(s, sourceFile);
495
+ for (const part of inDeclaredOrder(ts, checker, propType, s)) {
496
+ printed.push(checker.typeToString(part, sourceFile, flags));
497
+ }
498
+ }
499
+ return printed;
500
+ }),
501
+ });
502
+ }
503
+
504
+ /** Does an index signature of the props type accept this name? */
505
+ const accepts = (/** @type {string} */ name) =>
506
+ members.some(
507
+ /** @param {any} member */ member =>
508
+ (checker.getIndexInfosOfType?.(member) ?? []).some(
509
+ /** @param {any} info */ info =>
510
+ !holdsNothing(ts, info.type) &&
511
+ keyAccepts(ts, checker, info.keyType, name),
512
+ ),
513
+ );
514
+ return {ok: true, props, accepts};
515
+ }
516
+
517
+ /**
518
+ * A prop type's union members in the order the source declares them,
519
+ * through the type aliases it names; members it doesn't reach, such as the
520
+ * `undefined` an optional prop adds, follow, sorted by their text. The
521
+ * compiler's own order follows when each type was first made, so it would
522
+ * move with whatever read the types first.
523
+ * @param {any} ts
524
+ * @param {any} checker
525
+ * @param {any} type
526
+ * @param {any} symbol the prop
527
+ * @returns {any[]}
528
+ */
529
+ function inDeclaredOrder(ts, checker, type, symbol) {
530
+ if (!type.isUnion()) return [type];
531
+ /** @type {any[]} */
532
+ const order = [];
533
+ const seen = new Set();
534
+ /** @type {(node: any) => void} */
535
+ const visit = node => {
536
+ if (ts.isParenthesizedTypeNode(node)) return visit(node.type);
537
+ if (ts.isUnionTypeNode(node)) return node.types.forEach(visit);
538
+ if (ts.isTypeReferenceNode(node) && !node.typeArguments) {
539
+ let target = checker.getSymbolAtLocation(node.typeName);
540
+ if (target && target.flags & ts.SymbolFlags.Alias) {
541
+ target = checker.getAliasedSymbol(target);
542
+ }
543
+ const alias = target?.declarations?.find(ts.isTypeAliasDeclaration);
544
+ if (alias && !alias.typeParameters) {
545
+ if (!seen.has(alias)) {
546
+ seen.add(alias);
547
+ visit(alias.type);
548
+ }
549
+ return;
550
+ }
551
+ }
552
+ const found = checker.getTypeFromTypeNode(node);
553
+ order.push(...(found.isUnion() ? found.types : [found]));
554
+ };
555
+ for (const declaration of symbol.declarations ?? []) {
556
+ if (declaration.type) visit(declaration.type);
557
+ }
558
+ /** @type {(t: any) => number} */
559
+ const place = t => {
560
+ const at = order.indexOf(t);
561
+ return at === -1 ? order.length : at;
562
+ };
563
+ /** @type {(t: any) => string} */
564
+ const text = t => checker.typeToString(t);
565
+ return [...type.types].sort(
566
+ (a, b) => place(a) - place(b) || text(a).localeCompare(text(b)),
567
+ );
568
+ }
569
+
570
+ /**
571
+ * The prop's type for a message: as the source writes it when its
572
+ * declarations agree, else (a method or accessor, or declarations that differ
573
+ * or narrow each other in an intersection) as the compiler prints it, without
574
+ * the `undefined` an optional prop adds.
575
+ * @param {CodeProp} code
576
+ */
577
+ function codeTypeText(code) {
578
+ const written = [...new Set(code.declared)];
579
+ const text =
580
+ written.length === 1
581
+ ? written[0]
582
+ : [
583
+ ...new Set(
584
+ code
585
+ .renderings()
586
+ .filter(
587
+ part => !(code.required !== true && part === 'undefined'),
588
+ ),
589
+ ),
590
+ ].join(' | ');
591
+ return text.replace(/\s+/g, ' ').trim();
592
+ }
593
+
594
+ /**
595
+ * The props one doc entry names. `a / b` documents each of `a` and `b`, with
596
+ * the entry's `required` (and the type split the same way when it splits
597
+ * into as many parts); `x.y` (or
598
+ * `x[].y`) documents a field of prop `x`; `...rest` documents a spread of
599
+ * another props type and is not compared.
600
+ * @param {any} raw
601
+ * @returns {Array<{name: string, written: string, path: string[], spread: boolean, type?: string, required?: boolean}>}
602
+ */
603
+ function documentedProps(raw) {
604
+ const names = String(raw.name)
605
+ .split(/\s+\/\s+/)
606
+ .map(name => name.trim())
607
+ .filter(Boolean);
608
+ const types = typeof raw.type === 'string' ? raw.type.split(/\s+\/\s+/) : [];
609
+ return names.map((written, i) => {
610
+ const spread = written.startsWith('...');
611
+ const [root = written, ...path] = written
612
+ .replace(/\[\]/g, '')
613
+ .split('.')
614
+ .filter(Boolean);
615
+ return {
616
+ name: spread ? written : root,
617
+ written,
618
+ path: spread ? [] : path,
619
+ spread,
620
+ type:
621
+ names.length === 1
622
+ ? raw.type
623
+ : types.length === names.length
624
+ ? types[i]
625
+ : undefined,
626
+ required: raw.required,
627
+ };
628
+ });
629
+ }
630
+
631
+ /**
632
+ * The types a field can sit on: each member of a union, through arrays and
633
+ * tuples at any depth (`grid[][].id`).
634
+ * @param {any} ts
635
+ * @param {any} checker
636
+ * @param {any} type
637
+ * @param {number} [depth]
638
+ * @returns {any[]}
639
+ */
640
+ function holders(ts, checker, type, depth = 0) {
641
+ if (depth > 8) return [type];
642
+ if (type.isUnion()) {
643
+ return type.types.flatMap(
644
+ /** @param {any} t */ t => holders(ts, checker, t, depth + 1),
645
+ );
646
+ }
647
+ const array =
648
+ ['Array', 'ReadonlyArray'].includes(type.symbol?.name) ||
649
+ type.target?.objectFlags & ts.ObjectFlags.Tuple;
650
+ if (array && type.objectFlags & ts.ObjectFlags.Reference) {
651
+ return checker
652
+ .getTypeArguments(type)
653
+ .flatMap(/** @param {any} t */ t => holders(ts, checker, t, depth + 1));
654
+ }
655
+ return [type];
656
+ }
657
+
658
+ /**
659
+ * Does a field path exist in a prop's type, in at least one member of a
660
+ * union (through arrays)?
661
+ * @param {any} ts
662
+ * @param {any} checker
663
+ * @param {CodeProp} code
664
+ * @param {string[]} fieldPath
665
+ */
666
+ function fieldExists(ts, checker, code, fieldPath) {
667
+ /** @type {any[]} */
668
+ let current = code.types();
669
+ for (const field of fieldPath) {
670
+ /** @type {any[]} */
671
+ const next = [];
672
+ for (const holder of current.flatMap(t => holders(ts, checker, t))) {
673
+ if (holder.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return true;
674
+ const prop = checker.getPropertyOfType(holder, field);
675
+ // Or a field an index signature takes (`Record<string, string>`).
676
+ const index = prop
677
+ ? null
678
+ : (checker.getIndexInfosOfType?.(holder) ?? []).find(
679
+ /** @param {any} info */ info =>
680
+ !holdsNothing(ts, info.type) &&
681
+ keyAccepts(ts, checker, info.keyType, field),
682
+ );
683
+ if (prop) next.push(checker.getTypeOfSymbol(prop));
684
+ else if (index) next.push(index.type);
685
+ }
686
+ if (next.length === 0) return false;
687
+ current = next;
688
+ }
689
+ return true;
690
+ }
691
+
692
+ /**
693
+ * Is a type the compiler's stand-in for a name it could not resolve?
694
+ * @param {any} ts
695
+ * @param {any} type
696
+ */
697
+ function isErrorType(ts, type) {
698
+ return (
699
+ (type.flags & ts.TypeFlags.Any) !== 0 && type.intrinsicName === 'error'
700
+ );
701
+ }
702
+
703
+ /**
704
+ * Do some of a generic component's prop names come from a type parameter:
705
+ * a deferred type, an index signature keyed or valued by a type made from
706
+ * one, or a mapped type whose keys are one (`Partial<P>`) or whose `as`
707
+ * clause uses one? An interface whose values alone use it (`ListProps<T>`),
708
+ * a mapping over keys of its own (`Record<'value', V>`), and an index that
709
+ * takes any name (`[key: string]: any`) give names of its own.
710
+ * @param {any} ts
711
+ * @param {any} checker
712
+ * @param {any} type
713
+ * @param {number} depth
714
+ * @returns {boolean}
715
+ */
716
+ function namesFromTypeParameter(ts, checker, type, depth) {
717
+ if (depth > 8) return false;
718
+ if (type.isUnion() || type.isIntersection()) {
719
+ return type.types.some(
720
+ /** @param {any} t */ t =>
721
+ namesFromTypeParameter(ts, checker, t, depth + 1),
722
+ );
723
+ }
724
+ if (isDeferred(ts, checker, type)) return true;
725
+ if (
726
+ (checker.getIndexInfosOfType?.(type) ?? []).some(
727
+ /** @param {any} info */ info =>
728
+ isDeferred(ts, checker, info.type, true) ||
729
+ isDeferred(ts, checker, info.keyType, true),
730
+ )
731
+ ) {
732
+ return true;
733
+ }
734
+ if ((type.objectFlags & ts.ObjectFlags.Mapped) === 0) return false;
735
+ // The keys, which the compiler works out when it reads the members.
736
+ checker.getPropertiesOfType(type);
737
+ return (
738
+ (!!type.constraintType && isDeferred(ts, checker, type.constraintType)) ||
739
+ renamedByTypeParameter(ts, checker, type)
740
+ );
741
+ }
742
+
743
+ /**
744
+ * Does a mapping's `as` clause use a type parameter besides its own key that
745
+ * is still open where the props are read: one the component declares, or one
746
+ * an alias passes on whose argument is still a type parameter (`` as
747
+ * `${N}${K}` ``, `as T extends true ? K : never`)? Given fixed arguments
748
+ * (`Prefixed<'my'>`, a key filter such as `Except<Props, 'b'>`), every key
749
+ * gets its name. Its own key, and an `infer` inside the clause, are known
750
+ * for each key.
751
+ * @param {any} ts
752
+ * @param {any} checker
753
+ * @param {any} type a mapped type
754
+ * @returns {boolean}
755
+ */
756
+ function renamedByTypeParameter(ts, checker, type) {
757
+ const clause = type.declaration?.nameType;
758
+ if (!clause) return false;
759
+ /** @type {(node: any) => boolean} */
760
+ const uses = node => {
761
+ const symbol = ts.isTypeReferenceNode(node)
762
+ ? checker.getSymbolAtLocation(node.typeName)
763
+ : undefined;
764
+ const declaration =
765
+ symbol && symbol.flags & ts.SymbolFlags.TypeParameter
766
+ ? symbol.declarations?.[0]
767
+ : undefined;
768
+ const inClause =
769
+ declaration?.getSourceFile() === clause.getSourceFile() &&
770
+ declaration.pos >= clause.pos &&
771
+ declaration.end <= clause.end;
772
+ if (
773
+ declaration &&
774
+ declaration !== type.declaration.typeParameter &&
775
+ !inClause
776
+ ) {
777
+ // Its value where the props are read, through the instantiation.
778
+ const value = valueIn(
779
+ ts,
780
+ checker,
781
+ checker.getDeclaredTypeOfSymbol(symbol),
782
+ type.mapper,
783
+ 0,
784
+ );
785
+ if (!value || isDeferred(ts, checker, value, true)) return true;
786
+ }
787
+ return !!ts.forEachChild(
788
+ node,
789
+ /** @param {any} child */ child => uses(child) || undefined,
790
+ );
791
+ };
792
+ return uses(clause);
793
+ }
794
+
795
+ /**
796
+ * The value an instantiation gives a type parameter, as the compiler's own
797
+ * mappers give it, through each mapper one combines; null when that takes
798
+ * more than following parameters (a value made from one, or a mapper of
799
+ * another form), which counts as open.
800
+ * @param {any} ts
801
+ * @param {any} checker
802
+ * @param {any} parameter
803
+ * @param {any} mapper
804
+ * @param {number} depth
805
+ * @returns {any}
806
+ */
807
+ function valueIn(ts, checker, parameter, mapper, depth) {
808
+ if (!mapper) return parameter;
809
+ if (depth > 32) return null;
810
+ if (mapper.mapper1 && mapper.mapper2) {
811
+ const first = valueIn(ts, checker, parameter, mapper.mapper1, depth + 1);
812
+ if (!first) return null;
813
+ if (first === parameter || first.flags & ts.TypeFlags.TypeParameter) {
814
+ return valueIn(ts, checker, first, mapper.mapper2, depth + 1);
815
+ }
816
+ return isDeferred(ts, checker, first, true) ? null : first;
817
+ }
818
+ if ('source' in mapper) {
819
+ return mapper.source === parameter ? mapper.target : parameter;
820
+ }
821
+ if (Array.isArray(mapper.sources)) {
822
+ const i = mapper.sources.indexOf(parameter);
823
+ if (i < 0) return parameter;
824
+ const target = mapper.targets?.[i];
825
+ return typeof target === 'function' ? target() : (target ?? null);
826
+ }
827
+ return typeof mapper.func === 'function' ? mapper.func(parameter) : null;
828
+ }
829
+
830
+ /**
831
+ * A type parameter, or a type the compiler defers until one is known (an
832
+ * indexed access, a conditional), or a union, intersection or template
833
+ * literal with one (`on${K}`, `Capitalize<K>`); with `deep`, also a type made
834
+ * from one (through alias or reference arguments).
835
+ * @param {any} ts
836
+ * @param {any} checker
837
+ * @param {any} t
838
+ * @param {boolean} [deep]
839
+ * @param {number} [depth]
840
+ * @returns {boolean}
841
+ */
842
+ function isDeferred(ts, checker, t, deep = false, depth = 0) {
843
+ const F = ts.TypeFlags;
844
+ if (
845
+ t.flags &
846
+ (F.TypeParameter |
847
+ F.IndexedAccess |
848
+ F.Conditional |
849
+ F.Substitution |
850
+ F.Index)
851
+ ) {
852
+ return true;
853
+ }
854
+ if (depth > 6) return false;
855
+ const parts = [
856
+ ...(t.isUnion() || t.isIntersection() ? t.types : []),
857
+ ...(t.flags & F.TemplateLiteral ? (t.types ?? []) : []),
858
+ ...(t.flags & F.StringMapping && t.type ? [t.type] : []),
859
+ ...(deep ? (t.aliasTypeArguments ?? []) : []),
860
+ ...(deep && t.objectFlags & ts.ObjectFlags.Reference
861
+ ? checker.getTypeArguments(t)
862
+ : []),
863
+ ];
864
+ return parts.some(u => isDeferred(ts, checker, u, deep, depth + 1));
865
+ }
866
+
867
+ /**
868
+ * Does an index signature's key take this prop name, as TypeScript would? A
869
+ * `string` key takes any name; a template literal key only the names that
870
+ * match it (`data-${string}`, `col${number}`, `on${Capitalize<string>}`).
871
+ * @param {any} ts
872
+ * @param {any} checker
873
+ * @param {any} key
874
+ * @param {string} name
875
+ */
876
+ function keyAccepts(ts, checker, key, name) {
877
+ if (key.flags & ts.TypeFlags.String) return true;
878
+ if (!(key.flags & ts.TypeFlags.TemplateLiteral)) return false;
879
+ if (checker.getStringLiteralType && checker.isTypeAssignableTo) {
880
+ return checker.isTypeAssignableTo(checker.getStringLiteralType(name), key);
881
+ }
882
+ // An older TypeScript has no literal-type factory: match the template.
883
+ /** @type {Record<string, string>} */
884
+ const mappings = {
885
+ Uppercase: '[^a-z]*',
886
+ Lowercase: '[^A-Z]*',
887
+ Capitalize: '(?:[^a-z][\\s\\S]*)?',
888
+ Uncapitalize: '(?:[^A-Z][\\s\\S]*)?',
889
+ };
890
+ const spans = (key.types ?? []).map(
891
+ /** @param {any} span */ span =>
892
+ span.flags & ts.TypeFlags.Number
893
+ ? '[-+]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][-+]?\\d+)?'
894
+ : (mappings[String(span.symbol?.escapedName)] ?? '[\\s\\S]*'),
895
+ );
896
+ const pattern = (key.texts ?? [])
897
+ .map(
898
+ (/** @type {string} */ text, /** @type {number} */ i) =>
899
+ text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + (spans[i] ?? ''),
900
+ )
901
+ .join('');
902
+ return new RegExp(`^${pattern}$`).test(name);
903
+ }
904
+
905
+ /**
906
+ * Can a value of this type be passed at all? `never`, or only `undefined`
907
+ * (`Record<string, never>`, the usual way to write "no props"), cannot.
908
+ * @param {any} ts
909
+ * @param {any} type
910
+ */
911
+ function holdsNothing(ts, type) {
912
+ const parts = type.isUnion() ? type.types : [type];
913
+ return parts.every(
914
+ /** @param {any} t */ t =>
915
+ t.flags & (ts.TypeFlags.Never | ts.TypeFlags.Undefined),
916
+ );
917
+ }
918
+
919
+ /** @param {Array<string | null>} found the answers for one key, by place */
920
+ const strongest = found =>
921
+ ['own', 'foreign', 'unknown'].find(answer => found.includes(answer)) ?? null;
922
+
923
+ /**
924
+ * Where the key of a prop a mapped type builds, which no declaration names,
925
+ * comes from: `own` when the package's own source writes it, as a literal or
926
+ * a property's declaration, `foreign` when another package's types do, and
927
+ * `unknown` when the check can't trace it. It reads the mapping where the
928
+ * component's declaration and props parameter reach it (and the component a
929
+ * `memo`, `lazy` or `forwardRef` call wraps), through aliases, a generic's
930
+ * arguments, `keyof`, `typeof`, and a conditional's branches (`Exclude`,
931
+ * `Extract`).
932
+ * @param {any} ts
933
+ * @param {any} checker
934
+ * @param {(file: string) => boolean} isOwnFile
935
+ * @param {any[]} starts
936
+ * @returns {(prop: any) => string}
937
+ */
938
+ function mappedPropOrigin(ts, checker, isOwnFile, starts) {
939
+ /** @type {Map<any, Map<any, any>[]>} each mapping, with its type arguments */
940
+ const sites = new Map();
941
+ let budget = 5000;
942
+ /** @param {any} node */
943
+ const where = node =>
944
+ isOwnFile(node.getSourceFile().fileName) ? 'own' : 'foreign';
945
+ /** @param {any} node */
946
+ const target = node => {
947
+ let symbol = checker.getSymbolAtLocation(
948
+ node.typeName ?? node.exprName ?? node.expression ?? node,
949
+ );
950
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias) {
951
+ symbol = checker.getAliasedSymbol(symbol);
952
+ }
953
+ return symbol;
954
+ };
955
+ /** @type {(params: any, args: any, env: Map<any, any>) => Map<any, any>} */
956
+ const bind = (params, args, env) => {
957
+ const next = new Map(env);
958
+ (params ?? []).forEach(
959
+ (/** @type {any} */ param, /** @type {number} */ i) => {
960
+ const node = args?.[i] ?? param.default;
961
+ if (node)
962
+ next.set(target(param.name), {node, env: args?.[i] ? env : next});
963
+ },
964
+ );
965
+ return next;
966
+ };
967
+ /** @type {(node: any, env: Map<any, any>, depth: number) => void} */
968
+ const visit = (node, env, depth) => {
969
+ if (!node || depth > 40 || budget-- <= 0) return;
970
+ if (ts.isMappedTypeNode(node)) {
971
+ sites.set(node, [...(sites.get(node) ?? []), env]);
972
+ }
973
+ const reference =
974
+ ts.isTypeReferenceNode(node) || ts.isExpressionWithTypeArguments(node);
975
+ // A component another binding wraps: `memo(Inner)`, `forwardRef(Impl)`.
976
+ const wrapped =
977
+ ts.isIdentifier(node) &&
978
+ (ts.isCallExpression(node.parent) ||
979
+ ts.isVariableDeclaration(node.parent)) &&
980
+ node !== node.parent.expression &&
981
+ node !== node.parent.name;
982
+ for (const declaration of reference || wrapped
983
+ ? (target(node)?.declarations ?? [])
984
+ : []) {
985
+ const own = where(declaration) === 'own';
986
+ if (reference && ts.isTypeAliasDeclaration(declaration)) {
987
+ visit(
988
+ declaration.type,
989
+ bind(declaration.typeParameters, node.typeArguments, env),
990
+ depth + 1,
991
+ );
992
+ } else if (reference && (own || !node.typeArguments)) {
993
+ // Another package's interface: only what it extends.
994
+ const next = bind(declaration.typeParameters, node.typeArguments, env);
995
+ if (own) visit(declaration, next, depth + 1);
996
+ else
997
+ for (const clause of declaration.heritageClauses ?? [])
998
+ visit(clause, next, depth + 1);
999
+ } else if (wrapped && own) {
1000
+ visit(declaration, env, depth + 1);
1001
+ }
1002
+ }
1003
+ ts.forEachChild(node, (/** @type {any} */ child) =>
1004
+ visit(child, env, depth + 1),
1005
+ );
1006
+ };
1007
+ /** @type {(node: any, key: string, env: Map<any, any>, depth: number) => string | null} */
1008
+ const keyOrigin = (node, key, env, depth) => {
1009
+ if (!node || depth > 40) return 'unknown';
1010
+ const next = (/** @type {any} */ inner) =>
1011
+ keyOrigin(inner, key, env, depth + 1);
1012
+ if (ts.isParenthesizedTypeNode(node)) return next(node.type);
1013
+ if (ts.isLiteralTypeNode(node)) {
1014
+ return node.literal.text === key ? where(node) : null;
1015
+ }
1016
+ if (ts.isUnionTypeNode(node)) return strongest(node.types.map(next));
1017
+ if (ts.isConditionalTypeNode(node)) {
1018
+ return strongest([next(node.trueType), next(node.falseType)]);
1019
+ }
1020
+ if (
1021
+ ts.isTypeOperatorNode(node) &&
1022
+ node.operator === ts.SyntaxKind.KeyOfKeyword
1023
+ ) {
1024
+ const of =
1025
+ ts.isTypeReferenceNode(node.type) && env.get(target(node.type));
1026
+ const prop = checker.getPropertyOfType(
1027
+ checker.getTypeFromTypeNode(of ? of.node : node.type),
1028
+ key,
1029
+ );
1030
+ return prop ? propOrigin(prop, depth + 1) : null;
1031
+ }
1032
+ if (ts.isArrayTypeNode(node)) return next(node.elementType);
1033
+ if (ts.isTupleTypeNode(node)) return strongest(node.elements.map(next));
1034
+ if (ts.isTypeOperatorNode(node)) return next(node.type);
1035
+ if (ts.isIndexedAccessTypeNode(node)) {
1036
+ // `Props['size']` reads the property's own type; `(typeof SIZES)[number]`
1037
+ // reads the value's.
1038
+ const index = ts.isLiteralTypeNode(node.indexType)
1039
+ ? node.indexType.literal.text
1040
+ : undefined;
1041
+ if (index === undefined) return next(node.objectType);
1042
+ const declared = checker.getPropertyOfType(
1043
+ checker.getTypeFromTypeNode(node.objectType),
1044
+ index,
1045
+ )?.declarations?.[0];
1046
+ return declared ? valueOrigin(declared, key, depth + 1) : 'unknown';
1047
+ }
1048
+ if (ts.isTypeQueryNode(node)) {
1049
+ return valueOrigin(target(node)?.valueDeclaration, key, depth + 1);
1050
+ }
1051
+ if (ts.isTypeReferenceNode(node)) {
1052
+ const symbol = target(node);
1053
+ const bound = symbol && env.get(symbol);
1054
+ if (bound) return keyOrigin(bound.node, key, bound.env, depth + 1);
1055
+ const alias = symbol?.declarations?.find(ts.isTypeAliasDeclaration);
1056
+ if (alias) {
1057
+ return keyOrigin(
1058
+ alias.type,
1059
+ key,
1060
+ bind(alias.typeParameters, node.typeArguments, env),
1061
+ depth + 1,
1062
+ );
1063
+ }
1064
+ // An enum: where it is declared. A type parameter no argument binds,
1065
+ // such as an `infer`, can't be traced.
1066
+ if (
1067
+ symbol &&
1068
+ symbol.flags & (ts.SymbolFlags.Enum | ts.SymbolFlags.EnumMember)
1069
+ ) {
1070
+ return where(symbol.declarations[0]);
1071
+ }
1072
+ }
1073
+ return 'unknown';
1074
+ };
1075
+ /**
1076
+ * Where a value's literal keys are written: its declared type, or the
1077
+ * literals its initializer writes, through `as const` and other bindings.
1078
+ * @type {(node: any, key: string, depth: number) => string | null}
1079
+ */
1080
+ const valueOrigin = (node, key, depth) => {
1081
+ if (!node || depth > 40) return 'unknown';
1082
+ if (ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) {
1083
+ return node.text === key ? where(node) : null;
1084
+ }
1085
+ if (ts.isArrayLiteralExpression(node)) {
1086
+ return strongest(
1087
+ node.elements.map((/** @type {any} */ e) =>
1088
+ valueOrigin(e, key, depth + 1),
1089
+ ),
1090
+ );
1091
+ }
1092
+ // A declared type or a cast, but not `as const` or `satisfies`.
1093
+ const written =
1094
+ node.type &&
1095
+ !ts.isSatisfiesExpression?.(node) &&
1096
+ node.type.typeName?.text !== 'const';
1097
+ if (written) return keyOrigin(node.type, key, new Map(), depth + 1);
1098
+ const inner = node.initializer ?? node.expression;
1099
+ if (inner) return valueOrigin(inner, key, depth + 1);
1100
+ if (ts.isIdentifier(node)) {
1101
+ let symbol = checker.getSymbolAtLocation(node);
1102
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias) {
1103
+ symbol = checker.getAliasedSymbol(symbol);
1104
+ }
1105
+ return valueOrigin(symbol?.valueDeclaration, key, depth + 1);
1106
+ }
1107
+ return 'unknown';
1108
+ };
1109
+ /** @type {(prop: any, depth: number) => string | null} */
1110
+ const propOrigin = (prop, depth) => {
1111
+ if (depth > 40) return 'unknown';
1112
+ const declarations = prop.declarations ?? [];
1113
+ if (declarations.length > 0) {
1114
+ return declarations.some((/** @type {any} */ d) => where(d) === 'own')
1115
+ ? 'own'
1116
+ : 'foreign';
1117
+ }
1118
+ const links = prop.links ?? prop;
1119
+ if (links.syntheticOrigin)
1120
+ return propOrigin(links.syntheticOrigin, depth + 1);
1121
+ if (links.containingType) {
1122
+ return strongest(
1123
+ links.containingType.types.map((/** @type {any} */ t) => {
1124
+ const part = checker.getPropertyOfType(t, prop.name);
1125
+ return part && part !== prop ? propOrigin(part, depth + 1) : null;
1126
+ }),
1127
+ );
1128
+ }
1129
+ const mapping = links.mappedType?.declaration;
1130
+ const key = links.keyType?.value;
1131
+ if (!mapping || key === undefined) return 'unknown';
1132
+ return strongest(
1133
+ (sites.get(mapping) ?? []).map(env =>
1134
+ keyOrigin(
1135
+ mapping.typeParameter.constraint,
1136
+ String(key),
1137
+ env,
1138
+ depth + 1,
1139
+ ),
1140
+ ),
1141
+ );
1142
+ };
1143
+ for (const start of starts) {
1144
+ if (start && where(start) === 'own') visit(start, new Map(), 0);
1145
+ }
1146
+ return prop => propOrigin(prop, 0) ?? 'unknown';
1147
+ }
1148
+
1149
+ /**
1150
+ * Are props `any`: a plain `any` (not one standing for a name that does not
1151
+ * resolve), or a type an alias makes from `any` by reading its keys, which
1152
+ * takes every name (`Omit<any, 'ref'>`, or React's `Omit` of one)? `Record<string, any>` only gives
1153
+ * names an `any` value, as an inline `[key: string]: any` does.
1154
+ * @param {any} ts
1155
+ * @param {any} checker
1156
+ * @param {any} type
1157
+ * @param {number} [depth] how far into an alias's arguments
1158
+ * @returns {boolean}
1159
+ */
1160
+ function isAnyProps(ts, checker, type, depth = 0) {
1161
+ if (type.flags & ts.TypeFlags.Any) return !isErrorType(ts, type);
1162
+ const parts = type.isUnion() || type.isIntersection() ? type.types : [type];
1163
+ /** @type {(node: any) => any} the symbol a reference names */
1164
+ const target = node => {
1165
+ const used = checker.getSymbolAtLocation(node.typeName ?? node.expression);
1166
+ return used && used.flags & ts.SymbolFlags.Alias
1167
+ ? checker.getAliasedSymbol(used)
1168
+ : used;
1169
+ };
1170
+ /** @type {(node: any) => any} */
1171
+ const aliasOf = node =>
1172
+ target(node)?.declarations?.find(ts.isTypeAliasDeclaration);
1173
+ // Whether an alias reads its `i`th parameter's keys: `keyof T`, or `T`
1174
+ // passed on to an alias that does, as `type Without<T> = Omit<T, 'as'>`.
1175
+ /** @type {(alias: any, i: number, depth?: number) => boolean} */
1176
+ const readsKeys = (alias, i, depth = 0) => {
1177
+ const name = alias?.typeParameters?.[i]?.name.text;
1178
+ /** @type {(node: any) => boolean} */
1179
+ const visit = node =>
1180
+ (ts.isTypeOperatorNode(node) &&
1181
+ node.operator === ts.SyntaxKind.KeyOfKeyword &&
1182
+ node.type.typeName?.text === name) ||
1183
+ (ts.isTypeReferenceNode(node) &&
1184
+ depth < 8 &&
1185
+ (node.typeArguments ?? []).some(
1186
+ (/** @type {any} */ arg, /** @type {number} */ j) =>
1187
+ arg.typeName?.text === name &&
1188
+ readsKeys(aliasOf(node), j, depth + 1),
1189
+ )) ||
1190
+ !!ts.forEachChild(
1191
+ node,
1192
+ (/** @type {any} */ child) => visit(child) || undefined,
1193
+ );
1194
+ return !!name && visit(alias);
1195
+ };
1196
+ /** @type {(symbol: any) => any[]} an alias's type, or an interface's bases */
1197
+ const shapeOf = symbol =>
1198
+ (symbol?.declarations ?? []).flatMap((/** @type {any} */ d) =>
1199
+ ts.isTypeAliasDeclaration(d)
1200
+ ? [d.type]
1201
+ : (d.heritageClauses?.flatMap((/** @type {any} */ c) => c.types) ?? []),
1202
+ );
1203
+ // `any`, or such a type, passed where an alias reads its keys, written in
1204
+ // the props type or one of the package's names for it (`type P =
1205
+ // Without<any>`, `interface P extends Omit<Omit<any, 'as'>, 'ref'>`).
1206
+ /** @type {(node: any, depth: number) => boolean} */
1207
+ const anyKeyed = (node, depth) => {
1208
+ if (!node || depth > 8) return false;
1209
+ if (node.kind === ts.SyntaxKind.AnyKeyword) return true;
1210
+ if (ts.isParenthesizedTypeNode(node)) return anyKeyed(node.type, depth + 1);
1211
+ if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) {
1212
+ return node.types.some((/** @type {any} */ t) => anyKeyed(t, depth + 1));
1213
+ }
1214
+ // A reference: `Name<…>`, or an interface's `extends Name<…>`.
1215
+ if (!node.typeName && !node.expression) return false;
1216
+ const alias = aliasOf(node);
1217
+ return node.typeArguments
1218
+ ? node.typeArguments.some(
1219
+ (/** @type {any} */ arg, /** @type {number} */ i) =>
1220
+ anyKeyed(arg, depth + 1) && readsKeys(alias, i),
1221
+ )
1222
+ : shapeOf(target(node)).some(n => anyKeyed(n, depth + 1));
1223
+ };
1224
+ return parts.some(
1225
+ /** @param {any} part */ part =>
1226
+ ((part.aliasTypeArguments ?? []).some(
1227
+ /** @type {(t: any, i: number) => boolean} */ (t, i) =>
1228
+ (t.flags & ts.TypeFlags.Any
1229
+ ? !isErrorType(ts, t)
1230
+ : depth < 4 && isAnyProps(ts, checker, t, depth + 1)) &&
1231
+ readsKeys(part.aliasSymbol?.declarations?.[0], i),
1232
+ ) ||
1233
+ shapeOf(part.aliasSymbol ?? part.symbol).some(n => anyKeyed(n, 0))) &&
1234
+ (checker.getIndexInfosOfType?.(part) ?? []).some(
1235
+ /** @param {any} info */ info => info.type.flags & ts.TypeFlags.Any,
1236
+ ),
1237
+ );
1238
+ }
1239
+
1240
+ /**
1241
+ * Is any part of a props type `any`, or built from a type the compiler could
1242
+ * not resolve (`Omit<Missing, 'x'>`, `Partial<Missing>`)? Then props it would
1243
+ * inherit are missing or unknown, and comparing would report documented
1244
+ * props as absent, or pass them unread.
1245
+ * @param {any} ts
1246
+ * @param {any} checker
1247
+ * @param {any} type
1248
+ * @param {number} depth
1249
+ * @param {Set<any>} seen types already read, so shared bases are read once
1250
+ * @returns {boolean}
1251
+ */
1252
+ function hasUnresolvedPart(ts, checker, type, depth, seen) {
1253
+ if (depth > 24 || seen.has(type)) return false;
1254
+ seen.add(type);
1255
+ if (type.flags & ts.TypeFlags.Any) return true;
1256
+ // A type made from another keeps what it was made from: an alias its
1257
+ // arguments, a generic reference its type arguments.
1258
+ const made = [
1259
+ ...(type.aliasTypeArguments ?? []),
1260
+ ...(type.objectFlags & ts.ObjectFlags.Reference
1261
+ ? checker.getTypeArguments(type)
1262
+ : []),
1263
+ ];
1264
+ if (made.some(/** @param {any} t */ t => isErrorType(ts, t))) return true;
1265
+ // A mapped type over one that does not resolve keeps no arguments behind a
1266
+ // named alias (`type Base = Pick<Missing, 'title'>`): its properties have
1267
+ // the compiler's error type.
1268
+ if (
1269
+ type.objectFlags & ts.ObjectFlags.Mapped &&
1270
+ checker
1271
+ .getPropertiesOfType(type)
1272
+ .some(
1273
+ /** @param {any} p */ p => isErrorType(ts, checker.getTypeOfSymbol(p)),
1274
+ )
1275
+ ) {
1276
+ return true;
1277
+ }
1278
+ // A mapped type written over one that does not resolve
1279
+ // (`{[K in keyof Missing]?: string}`) keeps it only in its declaration.
1280
+ const constraint = type.declaration?.typeParameter?.constraint;
1281
+ const mappedOver =
1282
+ constraint && ts.isTypeOperatorNode(constraint)
1283
+ ? constraint.type
1284
+ : constraint;
1285
+ if (
1286
+ type.objectFlags & ts.ObjectFlags.Mapped &&
1287
+ mappedOver &&
1288
+ isErrorType(ts, checker.getTypeFromTypeNode(mappedOver))
1289
+ ) {
1290
+ return true;
1291
+ }
1292
+ // A type mapped over one that does not resolve (`Omit<Missing, 'x'>`,
1293
+ // `Partial<Missing>`), reached directly, through a named alias, or nested,
1294
+ // reads as index signatures whose value is that unresolved type, which no
1295
+ // source declares.
1296
+ if (
1297
+ type.flags & ts.TypeFlags.Object &&
1298
+ (checker.getIndexInfosOfType?.(type) ?? []).some(
1299
+ /** @param {any} info */ info =>
1300
+ !info.declaration && isErrorType(ts, info.type),
1301
+ )
1302
+ ) {
1303
+ return true;
1304
+ }
1305
+ if (type.isUnion() || type.isIntersection()) {
1306
+ return type.types.some(
1307
+ /** @param {any} t */ t =>
1308
+ hasUnresolvedPart(ts, checker, t, depth + 1, seen),
1309
+ );
1310
+ }
1311
+ if (type.flags & ts.TypeFlags.Object) {
1312
+ const target =
1313
+ type.objectFlags & ts.ObjectFlags.Reference ? type.target : type;
1314
+ if (target.objectFlags & ts.ObjectFlags.ClassOrInterface) {
1315
+ // An unresolved `extends` is dropped from the base types, so read the
1316
+ // heritage clauses themselves.
1317
+ for (const declaration of target.symbol?.declarations ?? []) {
1318
+ for (const clause of declaration.heritageClauses ?? []) {
1319
+ for (const node of clause.types) {
1320
+ const base = checker.getTypeAtLocation(node);
1321
+ if (hasUnresolvedPart(ts, checker, base, depth + 1, seen))
1322
+ return true;
1323
+ }
1324
+ }
1325
+ }
1326
+ }
1327
+ }
1328
+ return false;
1329
+ }
1330
+
1331
+ /**
1332
+ * Compare each documented prop table with the TypeScript props of the export
1333
+ * it describes.
1334
+ * @param {PropDriftTable[]} tables
1335
+ * @param {{packageDir: string, resolveFrom: Array<string | null | undefined>}} options
1336
+ * @returns {PropDriftReport}
1337
+ */
1338
+ export function comparePropTables(tables, options) {
1339
+ const {packageDir, resolveFrom} = options;
1340
+ const compiler = resolveProjectTypeScript(resolveFrom);
1341
+ if (!compiler?.ts) {
1342
+ return {
1343
+ status: 'skipped',
1344
+ reason: compiler
1345
+ ? unusableTypeScriptReason(
1346
+ compiler.version,
1347
+ 'documented props were not compared with the code',
1348
+ )
1349
+ : TYPESCRIPT_MISSING_REASON,
1350
+ components: 0,
1351
+ findings: [],
1352
+ unchecked: [],
1353
+ };
1354
+ }
1355
+ const {ts, version} = compiler;
1356
+ let root = path.resolve(packageDir);
1357
+ try {
1358
+ root = fs.realpathSync(root);
1359
+ } catch {
1360
+ // Keep the resolved path.
1361
+ }
1362
+ const rel = (/** @type {string} */ file) =>
1363
+ path.relative(packageDir, file).split(path.sep).join('/');
1364
+
1365
+ /** @type {PropDriftUnchecked[]} */
1366
+ const unchecked = [];
1367
+ if (tables.length === 0) {
1368
+ return {
1369
+ status: 'checked',
1370
+ typescript: version,
1371
+ components: 0,
1372
+ findings: [],
1373
+ unchecked,
1374
+ };
1375
+ }
1376
+
1377
+ const project = readPackageTsconfig(
1378
+ ts,
1379
+ packageDir,
1380
+ tables.flatMap(table => table.sources),
1381
+ );
1382
+ if (project.error) {
1383
+ // Read without the package's own config, the types would be guesses.
1384
+ for (const table of tables) {
1385
+ unchecked.push({
1386
+ component: table.name,
1387
+ doc: table.doc,
1388
+ reason: `the package's TypeScript config has an error, so props were not read (${project.error})`,
1389
+ });
1390
+ }
1391
+ unchecked.sort((a, b) => a.component.localeCompare(b.component));
1392
+ return {
1393
+ status: 'checked',
1394
+ typescript: version,
1395
+ components: 0,
1396
+ findings: [],
1397
+ unchecked,
1398
+ };
1399
+ }
1400
+ const program = ts.createProgram({
1401
+ rootNames: [
1402
+ ...new Set([
1403
+ ...tables.flatMap(table => table.sources),
1404
+ ...project.declarations,
1405
+ ]),
1406
+ ],
1407
+ options: project.options,
1408
+ });
1409
+ const checker = program.getTypeChecker();
1410
+ const isOwnFile = ownFileTest(root);
1411
+ const ambient = unresolvedGlobals(
1412
+ ts,
1413
+ program,
1414
+ checker,
1415
+ project.declarations,
1416
+ project.options,
1417
+ isOwnFile,
1418
+ );
1419
+
1420
+ /** @type {PropDriftFinding[]} */
1421
+ const findings = [];
1422
+ /** @type {Array<PropDriftUnchecked & {props: string[]}>} */
1423
+ const propsNotChecked = [];
1424
+ let compared = 0;
1425
+ for (const table of tables) {
1426
+ /** @type {ReturnType<typeof readCodeProps> | null} */
1427
+ let read = null;
1428
+ let sourcePath = table.sources[0];
1429
+ for (const candidate of table.sources) {
1430
+ const attempt = readCodeProps(
1431
+ ts,
1432
+ program,
1433
+ checker,
1434
+ candidate,
1435
+ table.name,
1436
+ isOwnFile,
1437
+ ambient,
1438
+ );
1439
+ if (attempt.ok || read == null) {
1440
+ read = attempt;
1441
+ sourcePath = candidate;
1442
+ }
1443
+ if (attempt.ok) break;
1444
+ }
1445
+ if (read == null || !read.ok) {
1446
+ unchecked.push({
1447
+ component: table.name,
1448
+ doc: table.doc,
1449
+ reason:
1450
+ read != null && !read.ok
1451
+ ? read.reason
1452
+ : `no source file for ${table.name}`,
1453
+ });
1454
+ continue;
1455
+ }
1456
+ compared++;
1457
+ const source = rel(sourcePath);
1458
+ const documented = new Set();
1459
+ for (const raw of table.props) {
1460
+ if (!raw || typeof raw.name !== 'string') continue;
1461
+ const entries = documentedProps(raw);
1462
+ for (const entry of entries) {
1463
+ const propName = entry.name;
1464
+ if (entry.spread) continue;
1465
+ documented.add(propName);
1466
+ const code = read.props.get(propName);
1467
+ const base = {
1468
+ component: table.name,
1469
+ prop: entry.written,
1470
+ severity: /** @type {const} */ ('warning'),
1471
+ doc: table.doc,
1472
+ source,
1473
+ };
1474
+ if (entry.path.length > 0) {
1475
+ if (
1476
+ code
1477
+ ? fieldExists(ts, checker, code, entry.path)
1478
+ : read.accepts(propName)
1479
+ ) {
1480
+ continue;
1481
+ }
1482
+ findings.push({
1483
+ ...base,
1484
+ kind: 'not_in_code',
1485
+ message: code
1486
+ ? `"${entry.written}" is documented, but ${table.name}'s "${propName}" has no "${entry.path.join('.')}".`
1487
+ : `"${entry.written}" is documented, but ${table.name}'s props have no "${propName}".`,
1488
+ });
1489
+ continue;
1490
+ }
1491
+ if (!code) {
1492
+ if (read.accepts(propName)) continue;
1493
+ findings.push({
1494
+ ...base,
1495
+ kind: 'not_in_code',
1496
+ message: `"${propName}" is documented, but ${table.name}'s props have no "${propName}".`,
1497
+ ...(typeof entry.type === 'string' ? {docType: entry.type} : {}),
1498
+ });
1499
+ continue;
1500
+ }
1501
+ const docRequired = entry.required === true;
1502
+ if (docRequired !== code.required) {
1503
+ findings.push({
1504
+ ...base,
1505
+ kind: 'required_mismatch',
1506
+ message: code.required
1507
+ ? `"${propName}" is required in the code, but the doc does not mark it required.`
1508
+ : `"${propName}" is documented as required, but it is optional in the code.`,
1509
+ docRequired,
1510
+ codeRequired: code.required,
1511
+ });
1512
+ }
1513
+ }
1514
+ }
1515
+ /** @type {string[]} */
1516
+ const untraced = [];
1517
+ for (const code of read.props.values()) {
1518
+ if (
1519
+ documented.has(code.name) ||
1520
+ code.skipTagged ||
1521
+ PROPS_NOT_DOCUMENTED.includes(code.name)
1522
+ ) {
1523
+ continue;
1524
+ }
1525
+ if (!code.own) {
1526
+ if (code.untraced) untraced.push(code.name);
1527
+ continue;
1528
+ }
1529
+ findings.push({
1530
+ component: table.name,
1531
+ prop: code.name,
1532
+ kind: 'not_in_doc',
1533
+ severity: 'warning',
1534
+ message: `${table.name} declares "${code.name}", but the doc does not list it.`,
1535
+ doc: table.doc,
1536
+ source,
1537
+ codeType: codeTypeText(code),
1538
+ codeRequired: code.required,
1539
+ });
1540
+ }
1541
+ if (untraced.length > 0) {
1542
+ propsNotChecked.push({
1543
+ component: table.name,
1544
+ doc: table.doc,
1545
+ reason: `a mapped type builds them from keys the check can't trace to the package's source, so it did not check that the doc lists them`,
1546
+ props: untraced.sort(),
1547
+ });
1548
+ }
1549
+ }
1550
+
1551
+ // Field by field: collation ignores a separator such as NUL, which would
1552
+ // sort one component's findings among another's that shares its prefix.
1553
+ findings.sort(
1554
+ (a, b) =>
1555
+ a.component.localeCompare(b.component) ||
1556
+ a.prop.localeCompare(b.prop) ||
1557
+ a.kind.localeCompare(b.kind),
1558
+ );
1559
+ unchecked.sort((a, b) => a.component.localeCompare(b.component));
1560
+ return {
1561
+ status: 'checked',
1562
+ typescript: version,
1563
+ components: compared,
1564
+ findings,
1565
+ unchecked,
1566
+ ...(propsNotChecked.length > 0 ? {propsNotChecked} : {}),
1567
+ };
1568
+ }
1569
+
1570
+ /**
1571
+ * The prop tables one doc file documents, each with the export it describes.
1572
+ * A multi-component doc names each entry's export; an entry without `props`
1573
+ * (a reference to a sibling doc) and a hook entry are not prop tables.
1574
+ * @param {any} doc
1575
+ * @param {PropDriftComponent} component
1576
+ * @returns {Array<{name: string, props: any[]}>}
1577
+ */
1578
+ function propTables(doc, component) {
1579
+ if (!doc || typeof doc !== 'object') return [];
1580
+ /** @type {Array<{name: string, props: any[]}>} */
1581
+ const tables = [];
1582
+ // A doc's own props document the export its `name` names, which can be
1583
+ // another file's export than the one its file is named after.
1584
+ const own = ownName(doc, component);
1585
+ if (Array.isArray(doc.props) && !HOOK_NAME.test(own)) {
1586
+ tables.push({name: own, props: doc.props});
1587
+ }
1588
+ if (Array.isArray(doc.components)) {
1589
+ for (const entry of doc.components) {
1590
+ if (
1591
+ entry &&
1592
+ typeof entry.name === 'string' &&
1593
+ Array.isArray(entry.props) &&
1594
+ !(Array.isArray(doc.props) && entry.name === own) &&
1595
+ !HOOK_NAME.test(entry.name)
1596
+ ) {
1597
+ tables.push({name: entry.name, props: entry.props});
1598
+ }
1599
+ }
1600
+ }
1601
+ return tables;
1602
+ }
1603
+
1604
+ /**
1605
+ * The export a doc's own props document: its `name`, else its record's.
1606
+ * @param {any} doc
1607
+ * @param {PropDriftComponent} component
1608
+ */
1609
+ function ownName(doc, component) {
1610
+ return typeof doc.name === 'string' && doc.name !== ''
1611
+ ? doc.name
1612
+ : component.name;
1613
+ }
1614
+
1615
+ /**
1616
+ * The stem of a `<stem>.doc.{ts,mjs,js}` file: the name of the record that
1617
+ * owns the doc.
1618
+ * @param {string} docPath
1619
+ */
1620
+ function docStem(docPath) {
1621
+ return path.basename(docPath).replace(/\.doc\.(ts|mjs|js)$/, '');
1622
+ }
1623
+
1624
+ /**
1625
+ * Where an entry's export lives: `<entry>.tsx` beside the doc, else the doc's
1626
+ * own same-stem source, else the directory's `index.ts`/`index.tsx`, else the
1627
+ * `.tsx` in the doc's folder that declares it, as discovery finds a member's.
1628
+ * @param {string} name
1629
+ * @param {PropDriftComponent} component
1630
+ * @returns {string[]} candidates, in order
1631
+ */
1632
+ function sourcesForEntry(name, component) {
1633
+ const own = component.sourcePath ? [component.sourcePath] : [];
1634
+ if (name === component.name) return own;
1635
+ const dir = path.dirname(component.docPath);
1636
+ return [
1637
+ path.join(dir, `${name}.tsx`),
1638
+ ...own,
1639
+ path.join(dir, 'index.ts'),
1640
+ path.join(dir, 'index.tsx'),
1641
+ folderSourceFor(dir, name),
1642
+ ].filter(
1643
+ /** @type {(file: string | null, i: number, all: Array<string | null>) => file is string} */ (
1644
+ (file, i, all) =>
1645
+ file != null && all.indexOf(file) === i && fs.existsSync(file)
1646
+ ),
1647
+ );
1648
+ }
1649
+
1650
+ /**
1651
+ * Compare documented props with each component's TypeScript props: find each
1652
+ * doc's prop tables, then compare them (`comparePropTables`).
1653
+ * @param {PropDriftComponent[]} components
1654
+ * @param {{packageDir: string, resolveFrom: Array<string | null | undefined>, loadDoc?: (docPath: string) => Promise<any>}} options
1655
+ * @returns {Promise<PropDriftReport>}
1656
+ */
1657
+ export async function comparePropDocs(components, options) {
1658
+ const {packageDir, resolveFrom, loadDoc = loadComponentDoc} = options;
1659
+ const rel = (/** @type {string} */ file) =>
1660
+ path.relative(packageDir, file).split(path.sep).join('/');
1661
+
1662
+ /** @type {PropDriftTable[]} */
1663
+ const tables = [];
1664
+ /** @type {PropDriftUnchecked[]} */
1665
+ const unchecked = [];
1666
+ const seen = new Set();
1667
+ // A family doc can stand behind several records: its own, named after the
1668
+ // doc, and one per member entry that discovery records as a component of
1669
+ // its own. A member record compares only its own entry, against its own
1670
+ // source; the family record leaves those entries to the members.
1671
+ /** @type {Map<string, Set<string>>} */
1672
+ const namesByDoc = new Map();
1673
+ for (const component of components) {
1674
+ const names = namesByDoc.get(component.docPath) ?? new Set();
1675
+ names.add(component.name);
1676
+ namesByDoc.set(component.docPath, names);
1677
+ }
1678
+ /** @type {Map<string, Promise<any>>} */
1679
+ const docs = new Map();
1680
+ /** @type {Set<string>} docs whose own record is here */
1681
+ const owned = new Set();
1682
+ // Its own props, and every entry no member record compares: a hidden one,
1683
+ // or one discovery doesn't record. One whose source isn't found is listed
1684
+ // as not compared.
1685
+ const compareOwn = (
1686
+ /** @type {PropDriftComponent} */ component,
1687
+ /** @type {any} */ doc,
1688
+ ) => {
1689
+ const members = namesByDoc.get(component.docPath) ?? new Set();
1690
+ for (const table of propTables(doc, component)) {
1691
+ if (table.name !== component.name && members.has(table.name)) continue;
1692
+ const sources = sourcesForEntry(table.name, component);
1693
+ // One table per doc: two docs that each document a component both
1694
+ // get compared.
1695
+ const key = `${component.docPath}\0${sources[0]}\0${table.name}`;
1696
+ if (seen.has(key)) continue;
1697
+ seen.add(key);
1698
+ tables.push({doc: rel(component.docPath), ...table, sources});
1699
+ }
1700
+ };
1701
+ for (const component of components) {
1702
+ let doc;
1703
+ try {
1704
+ let loading = docs.get(component.docPath);
1705
+ if (!loading) {
1706
+ loading = Promise.resolve(loadDoc(component.docPath));
1707
+ docs.set(component.docPath, loading);
1708
+ }
1709
+ doc = await loading;
1710
+ } catch (err) {
1711
+ unchecked.push({
1712
+ component: component.name,
1713
+ doc: rel(component.docPath),
1714
+ reason: `its doc does not load: ${err instanceof Error ? err.message : String(err)}`,
1715
+ });
1716
+ continue;
1717
+ }
1718
+ // The doc's own record is named after its file, or after the export its
1719
+ // `name` names; any other record is a family member's.
1720
+ const ownRecord =
1721
+ component.name === docStem(component.docPath) ||
1722
+ (doc != null &&
1723
+ typeof doc === 'object' &&
1724
+ ownName(doc, component) === component.name);
1725
+ if (!ownRecord) {
1726
+ // A family member compares its own entry.
1727
+ const entry = Array.isArray(doc?.components)
1728
+ ? doc.components.find(
1729
+ (/** @type {any} */ candidate) =>
1730
+ candidate?.name === component.name &&
1731
+ Array.isArray(candidate.props),
1732
+ )
1733
+ : undefined;
1734
+ if (!entry || HOOK_NAME.test(component.name)) continue;
1735
+ const key = `${component.docPath}\0${component.sourcePath}\0${component.name}`;
1736
+ if (seen.has(key)) continue;
1737
+ seen.add(key);
1738
+ tables.push({
1739
+ doc: rel(component.docPath),
1740
+ name: component.name,
1741
+ props: entry.props,
1742
+ sources: component.sourcePath ? [component.sourcePath] : [],
1743
+ });
1744
+ continue;
1745
+ }
1746
+ owned.add(component.docPath);
1747
+ compareOwn(component, doc);
1748
+ }
1749
+ // A family doc whose own record is missing, because its same-stem source
1750
+ // is, still has its own props and unrecorded entries compared or listed.
1751
+ for (const [docPath, loading] of docs) {
1752
+ if (owned.has(docPath)) continue;
1753
+ const doc = await loading.catch(() => null);
1754
+ if (doc == null || typeof doc !== 'object') continue;
1755
+ compareOwn({name: docStem(docPath), docPath, sourcePath: null}, doc);
1756
+ }
1757
+ const report = comparePropTables(tables, {packageDir, resolveFrom});
1758
+ if (report.status === 'skipped') return report;
1759
+ return {
1760
+ ...report,
1761
+ unchecked: [...unchecked, ...report.unchecked].sort((a, b) =>
1762
+ a.component.localeCompare(b.component),
1763
+ ),
1764
+ };
1765
+ }