@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,1197 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Documented props against a component's TypeScript props: each kind
5
+ * of finding, each component it cannot read, and the visible skip when the
6
+ * project has no TypeScript.
7
+ */
8
+
9
+ import * as fs from 'node:fs';
10
+ import * as os from 'node:os';
11
+ import * as path from 'node:path';
12
+ import {createRequire} from 'node:module';
13
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
14
+ import {
15
+ comparePropDocs,
16
+ comparePropTables,
17
+ TYPESCRIPT_MISSING_REASON,
18
+ } from './prop-drift.mjs';
19
+ import {unusableTypeScriptReason} from './project-typescript.mjs';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const TYPESCRIPT_DIR = path.dirname(require.resolve('typescript/package.json'));
23
+
24
+ /** @type {string} */
25
+ let pkg;
26
+
27
+ beforeEach(() => {
28
+ pkg = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-prop-drift-'));
29
+ fs.writeFileSync(
30
+ path.join(pkg, 'package.json'),
31
+ JSON.stringify({name: '@acme/widgets', version: '1.0.0'}),
32
+ );
33
+ fs.writeFileSync(
34
+ path.join(pkg, 'tsconfig.json'),
35
+ JSON.stringify({
36
+ compilerOptions: {
37
+ strict: true,
38
+ jsx: 'react-jsx',
39
+ module: 'esnext',
40
+ moduleResolution: 'bundler',
41
+ target: 'es2022',
42
+ },
43
+ }),
44
+ );
45
+ });
46
+
47
+ afterEach(() => {
48
+ fs.rmSync(pkg, {recursive: true, force: true});
49
+ });
50
+
51
+ function withTypeScript() {
52
+ fs.mkdirSync(path.join(pkg, 'node_modules'), {recursive: true});
53
+ fs.symlinkSync(
54
+ TYPESCRIPT_DIR,
55
+ path.join(pkg, 'node_modules', 'typescript'),
56
+ 'dir',
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Write `components/<name>.tsx` and its doc; returns its prop table, which
62
+ * also carries the component record (name, docPath, sourcePath).
63
+ * @param {string} name
64
+ * @param {string} source
65
+ * @param {Record<string, any>} doc
66
+ */
67
+ function component(name, source, doc) {
68
+ const dir = path.join(pkg, 'components');
69
+ fs.mkdirSync(dir, {recursive: true});
70
+ const sourcePath = path.join(dir, `${name}.tsx`);
71
+ const docPath = path.join(dir, `${name}.doc.mjs`);
72
+ fs.writeFileSync(sourcePath, source);
73
+ fs.writeFileSync(
74
+ docPath,
75
+ `export default ${JSON.stringify({
76
+ type: 'component',
77
+ name,
78
+ displayName: name,
79
+ description: 'Fixture.',
80
+ ...doc,
81
+ })};\n`,
82
+ );
83
+ return {
84
+ name,
85
+ docPath,
86
+ sourcePath,
87
+ doc: `components/${name}.doc.mjs`,
88
+ props: doc.props ?? [],
89
+ sources: [sourcePath],
90
+ };
91
+ }
92
+
93
+ /**
94
+ * @param {string} name
95
+ * @param {string} type
96
+ * @param {Record<string, unknown>} [extra]
97
+ */
98
+ const prop = (name, type, extra = {}) => ({
99
+ name,
100
+ type,
101
+ description: 'Fixture prop.',
102
+ ...extra,
103
+ });
104
+
105
+ /** @param {import('./prop-drift.mjs').PropDriftTable[]} tables */
106
+ function compare(tables) {
107
+ return comparePropTables(tables, {packageDir: pkg, resolveFrom: [pkg]});
108
+ }
109
+
110
+ /** @param {{findings: Array<{component: string, prop: string, kind: string}>}} report */
111
+ const kinds = report =>
112
+ report.findings.map(f => `${f.component}.${f.prop} ${f.kind}`);
113
+
114
+ describe('comparePropTables', () => {
115
+ it("reports a prop missing from the code, one missing from the doc, and a wrong required flag, each component's together", async () => {
116
+ withTypeScript();
117
+ const badge = component(
118
+ 'Badge',
119
+ `export type Tone = 'neutral' | 'info' | 'danger';
120
+ export interface BadgeProps {
121
+ label: string;
122
+ tone?: Tone;
123
+ count?: number;
124
+ }
125
+ export function Badge(props: BadgeProps) { return null; }`,
126
+ {
127
+ props: [
128
+ prop('label', 'string'),
129
+ prop('tone', "'neutral' | 'info'"),
130
+ prop('size', "'sm' | 'md'"),
131
+ ],
132
+ },
133
+ );
134
+ // A name that starts with another's sorts after all of the other's.
135
+ const count = component(
136
+ 'BadgeCount',
137
+ 'export function BadgeCount(props: {amount?: number}) { return null; }',
138
+ {props: []},
139
+ );
140
+
141
+ const report = await compare([count, badge]);
142
+
143
+ expect(report.status).toBe('checked');
144
+ expect(report.typescript).toMatch(/^\d+\.\d+/);
145
+ expect(report.components).toBe(2);
146
+ expect(kinds(report)).toEqual([
147
+ 'Badge.count not_in_doc',
148
+ 'Badge.label required_mismatch',
149
+ 'Badge.size not_in_code',
150
+ 'BadgeCount.amount not_in_doc',
151
+ ]);
152
+ expect(report.findings.every(f => f.severity === 'warning')).toBe(true);
153
+ expect(
154
+ report.findings.find(f => f.kind === 'required_mismatch'),
155
+ ).toMatchObject({
156
+ docRequired: false,
157
+ codeRequired: true,
158
+ doc: 'components/Badge.doc.mjs',
159
+ source: 'components/Badge.tsx',
160
+ });
161
+ });
162
+
163
+ it('reads every overload, one without props included, reads a prop only some members of a union require as optional, and holds every prop one entry names to its `required`', async () => {
164
+ withTypeScript();
165
+ const picker = component(
166
+ 'Picker',
167
+ `type Single = {isMulti?: false; value: string; onChange: (value: string) => void};
168
+ type Multi = {isMulti: true; value: string[]; onChange: (value: string[]) => void};
169
+ export type PickerProps = (Single | Multi) & {label: string};
170
+ export function Picker(props: PickerProps) { return null; }`,
171
+ {
172
+ props: [
173
+ prop('label', 'string', {required: true}),
174
+ prop('isMulti', 'boolean'),
175
+ prop('value', 'string', {required: true}),
176
+ prop('onChange', '(value: string) => void', {required: true}),
177
+ ],
178
+ },
179
+ );
180
+
181
+ const select = component(
182
+ 'Select',
183
+ `type Single = {multiple?: false; value?: string};
184
+ type Multi = {multiple: true; value?: string[]; max?: number};
185
+ export function Select(props: Single): null;
186
+ export function Select(props: Multi): null;
187
+ export function Select(props: Single | Multi) { return null; }`,
188
+ {
189
+ props: [
190
+ prop('multiple', 'boolean'),
191
+ prop('value', 'string | string[]'),
192
+ prop('max', 'number'),
193
+ ],
194
+ },
195
+ );
196
+ // An overload that takes no props makes every prop optional; a typed
197
+ // props type beside a `Record<string, any>` or a mapped `any` index is
198
+ // compared as it is beside an inline `[key: string]: any`.
199
+ const propless = component(
200
+ 'Propless',
201
+ 'export function Propless(): null;\nexport function Propless(props: {label: string}): null;\nexport function Propless(props?: {label: string}) { return null; }',
202
+ {props: [prop('label', 'string', {required: true})]},
203
+ );
204
+ const open = [
205
+ 'Record<string, any>',
206
+ 'Record<`data-${string}`, any>',
207
+ '{[K in string]?: any}',
208
+ ].map((index, i) =>
209
+ component(
210
+ `Open${i}`,
211
+ `export function Open${i}(props: {label?: string; size?: number} & ${index}) { return null; }`,
212
+ {props: [prop('label', 'string')]},
213
+ ),
214
+ );
215
+
216
+ // Required in one variant only: a caller may leave it out, so optional.
217
+ const menu = component(
218
+ 'Menu',
219
+ 'export function Menu(props: {items: string[]} | {alt: string}) { return null; }',
220
+ {props: [prop('items', 'X[]', {required: true}), prop('alt', 'X')]},
221
+ );
222
+ // One entry's `required` is a claim about every prop it names.
223
+ const pairs = ['label: string', 'label?: string'].map((label, i) =>
224
+ component(
225
+ `Pair${i}`,
226
+ `export function Pair${i}(props: {value: string; ${label}}) { return null; }`,
227
+ {props: [prop('value / label', 'string / string', {required: true})]},
228
+ ),
229
+ );
230
+ // Typed `any` through the package's own alias of `Omit`, written in the
231
+ // signature, as a named alias, or as an interface's base.
232
+ const without = ['Without<any>', 'P', 'Q', 'R'].map((props, i) =>
233
+ component(
234
+ `Without${i}`,
235
+ `type Without<T> = Omit<T, 'as'>; type P = Without<any>; interface Q extends Omit<any, 'as'> {} type R = Omit<P, 'ref'>;\nexport function Without${i}(props: ${props}) { return null; }`,
236
+ {props: [prop('label', 'string')]},
237
+ ),
238
+ );
239
+
240
+ const tables = [
241
+ picker,
242
+ select,
243
+ propless,
244
+ ...open,
245
+ menu,
246
+ ...pairs,
247
+ ...without,
248
+ ];
249
+ const report = await compare(tables);
250
+
251
+ expect(kinds(report)).toEqual([
252
+ 'Menu.items required_mismatch',
253
+ 'Open0.size not_in_doc',
254
+ 'Open1.size not_in_doc',
255
+ 'Open2.size not_in_doc',
256
+ 'Pair1.label required_mismatch',
257
+ 'Propless.label required_mismatch',
258
+ ]);
259
+ expect(report.components).toBe(9);
260
+ expect(report.propsNotChecked).toBeUndefined();
261
+ expect(report.unchecked.map(entry => entry.reason)).toEqual(
262
+ [0, 1, 2, 3].map(i =>
263
+ expect.stringMatching(`^Without${i}'s props are typed`),
264
+ ),
265
+ );
266
+ });
267
+
268
+ it('shows a method prop the doc leaves out with its function type, optional or not', async () => {
269
+ withTypeScript();
270
+ const picker = component(
271
+ 'Picker',
272
+ `export interface PickerProps {
273
+ label?: string;
274
+ onPick(value: string, index: number): void;
275
+ onClear?(reason: string): void;
276
+ get size(): number;
277
+ }
278
+ export function Picker(props: PickerProps) { return null; }`,
279
+ {props: [prop('label', 'string')]},
280
+ );
281
+
282
+ const report = await compare([picker]);
283
+
284
+ expect(
285
+ report.findings.map(f => `${f.prop} ${f.kind}: ${f.codeType}`),
286
+ ).toEqual([
287
+ 'onClear not_in_doc: (reason: string) => void',
288
+ 'onPick not_in_doc: (value: string, index: number) => void',
289
+ 'size not_in_doc: number',
290
+ ]);
291
+ });
292
+
293
+ it("prints a union's members in the order the source declares them", async () => {
294
+ withTypeScript();
295
+ // The same literals, made first in another order, would set the
296
+ // compiler's own order.
297
+ const ordered = component(
298
+ 'Ordered',
299
+ [
300
+ "type Other = 'md' | 'sm' | 'lg';",
301
+ 'export type Unrelated = Other;',
302
+ "type Small = 'lg' | 'sm';",
303
+ "export function Ordered(props: {size?: Small} | {size?: 'md'; label?: string}) { return null; }",
304
+ ].join('\n'),
305
+ {props: [prop('label', 'string')]},
306
+ );
307
+
308
+ const report = await compare([ordered]);
309
+
310
+ expect(
311
+ report.findings.find(finding => finding.prop === 'size')?.codeType,
312
+ ).toBe("'lg' | 'sm' | 'md'");
313
+ });
314
+
315
+ it("reads a prop that the package's defaultProps fills as optional", async () => {
316
+ withTypeScript();
317
+ // A library type that declares an optional `defaultProps`, as React's
318
+ // component types do, fills nothing; without strict null checks it reads
319
+ // as filling every prop.
320
+ const lib = path.join(pkg, 'node_modules', '@acme', 'lib');
321
+ fs.mkdirSync(lib, {recursive: true});
322
+ fs.writeFileSync(
323
+ path.join(lib, 'package.json'),
324
+ JSON.stringify({name: '@acme/lib', types: 'index.d.ts'}),
325
+ );
326
+ fs.writeFileSync(
327
+ path.join(lib, 'index.d.ts'),
328
+ `export interface Kind<P> { new (props: P): {}; defaultProps?: Partial<P> }
329
+ export declare function memo<T extends (p: any) => any>(c: T): {(p: Parameters<T>[0]): null; $$typeof: symbol; type: T};
330
+ export declare function lazy<T extends (p: any) => any>(f: () => Promise<{default: T}>): {(p: Parameters<T>[0]): null; $$typeof: symbol; _result: T};\n`,
331
+ );
332
+ fs.writeFileSync(
333
+ path.join(pkg, 'tsconfig.json'),
334
+ JSON.stringify({compilerOptions: {strict: false, jsx: 'react-jsx'}}),
335
+ );
336
+ const source = `import {type Kind, lazy, memo} from '@acme/lib';
337
+ declare class Component<P> { constructor(props: P); props: P }
338
+ export class Klass extends Component<{label: string; size: number}> {
339
+ static defaultProps = {size: 1};
340
+ }
341
+ export function Fn(props: {label: string; size: number}) { return null; }
342
+ Fn.defaultProps = {size: 1};
343
+ export function Gn(props: {label: string; size: number}) { return null; }
344
+ Gn.defaultProps = {size: 1};
345
+ export declare const Typed: Kind<{label: string; size: number}>;
346
+ export const Memoed = memo(Fn);
347
+ export const Lazied = lazy(() => Promise.resolve({default: Fn}));
348
+ export const Thrice = memo(memo(memo(Fn)));
349
+ export class Loose extends Component<{label: string; size: number}> { static defaultProps: any = {}; }`;
350
+ const props = [
351
+ prop('label', 'string'),
352
+ prop('size', 'number', {required: true}),
353
+ ];
354
+
355
+ const report = await compare([
356
+ ...'Klass Fn Typed Memoed Lazied Thrice Loose'
357
+ .split(' ')
358
+ .map(name => component(name, source, {props})),
359
+ component('Gn', source, {
360
+ props: [prop('label', 'string'), prop('size', 'number')],
361
+ }),
362
+ ]);
363
+
364
+ // JSX callers may leave out a prop defaultProps fills (for `memo` and
365
+ // `lazy`, the wrapped component's, two layers deep as React's types read
366
+ // it; `any` fills every prop), so a doc that marks it required disagrees
367
+ // with the code, and one that doesn't agrees.
368
+ expect(kinds(report)).toEqual([
369
+ 'Fn.label required_mismatch',
370
+ 'Fn.size required_mismatch',
371
+ 'Gn.label required_mismatch',
372
+ 'Klass.label required_mismatch',
373
+ 'Klass.size required_mismatch',
374
+ 'Lazied.label required_mismatch',
375
+ 'Lazied.size required_mismatch',
376
+ 'Loose.size required_mismatch',
377
+ 'Memoed.label required_mismatch',
378
+ 'Memoed.size required_mismatch',
379
+ 'Thrice.label required_mismatch',
380
+ 'Typed.label required_mismatch',
381
+ ]);
382
+ expect(
383
+ report.findings.find(f => f.component === 'Fn' && f.prop === 'size'),
384
+ ).toMatchObject({docRequired: true, codeRequired: false});
385
+ });
386
+
387
+ it('lists each component whose props it cannot read as not compared', async () => {
388
+ withTypeScript();
389
+ const tsconfig = path.join(pkg, 'tsconfig.json');
390
+ const config = JSON.parse(fs.readFileSync(tsconfig, 'utf8'));
391
+ config.compilerOptions.allowJs = true;
392
+ fs.writeFileSync(tsconfig, JSON.stringify(config));
393
+ /** @param {Record<string, string>} sources each component's props types */
394
+ const missing = sources =>
395
+ Object.entries(sources).map(([name, types]) =>
396
+ component(
397
+ name,
398
+ `import type {MissingProps} from '@acme/not-installed';\n${types}\nexport function ${name}(props: ${name}Props) { return null; }`,
399
+ {props: [prop('label', 'string')]},
400
+ ),
401
+ );
402
+ const unread = [
403
+ ...missing({
404
+ Ghost:
405
+ 'export interface GhostProps extends MissingProps { label?: string }',
406
+ Omitted:
407
+ "export interface OmittedProps extends Omit<MissingProps, 'title'> { label?: string }",
408
+ Phantom:
409
+ 'export type PhantomProps = Partial<MissingProps> & {label?: string};',
410
+ Named:
411
+ "type Base = Omit<MissingProps, 'title'>; export interface NamedProps extends Base { label?: string }",
412
+ Joined:
413
+ "type Base = Omit<MissingProps, 'title'>; export type JoinedProps = Base & {label?: string};",
414
+ Bare: "type Base = Omit<MissingProps, 'title'>; export type BareProps = Base;",
415
+ Mapped:
416
+ 'export type MappedProps = {[K in keyof MissingProps]?: string};',
417
+ Prefixed:
418
+ 'type On<K extends string> = {[P in `on${K}`]?: () => void}; export type PrefixedProps = On<MissingProps> & {label?: string};',
419
+ Picked:
420
+ "type Base = Pick<MissingProps, 'title'>; export type PickedProps = Base;",
421
+ Kept: "type W<T> = T & {label?: string}; export type KeptProps = W<Required<Pick<MissingProps, 'title'>>>;",
422
+ Nested:
423
+ "export type NestedProps = Partial<Omit<MissingProps, 'title'>> & {label?: string};",
424
+ Deep: 'interface L1 extends MissingProps {} interface L2 extends L1 {} interface L3 extends L2 {} interface L4 extends L3 {} interface L5 extends L4 {} export interface DeepProps extends L5 { label?: string }',
425
+ }),
426
+ component(
427
+ 'Wrapped',
428
+ `import {forwardRef} from 'react';
429
+ export const Wrapped = forwardRef<HTMLButtonElement, {label?: string}>((props, ref) => null);`,
430
+ {props: [prop('label', 'string')]},
431
+ ),
432
+ component(
433
+ 'Classy',
434
+ "import {MissingBase} from '@acme/not-installed';\nexport class Classy extends MissingBase<{label?: string}> {}\n",
435
+ {props: [prop('label', 'string')]},
436
+ ),
437
+ ];
438
+ // Each of these is the only reference in its files that does not resolve:
439
+ // a name its module does not export, a dependency's own missing import, a
440
+ // missing type package, and a side-effect import.
441
+ fs.writeFileSync(
442
+ path.join(pkg, 'components', 'theme.ts'),
443
+ 'export interface Theme { tone?: string }\n',
444
+ );
445
+ const dep = path.join(pkg, 'node_modules', '@acme', 'dep');
446
+ fs.mkdirSync(dep, {recursive: true});
447
+ fs.writeFileSync(
448
+ path.join(dep, 'package.json'),
449
+ JSON.stringify({name: '@acme/dep', types: 'index.d.ts'}),
450
+ );
451
+ fs.writeFileSync(
452
+ path.join(dep, 'index.d.ts'),
453
+ "import type {MissingProps} from '@acme/not-installed';\nexport type DepProps = MissingProps & {label?: string};\n",
454
+ );
455
+ const references = Object.entries({
456
+ Unnamed:
457
+ "import type {Gone} from './theme';\nexport function Unnamed(props: {label?: string; tone?: Gone}) { return null; }\n",
458
+ Peered:
459
+ "import type {DepProps} from '@acme/dep';\nexport function Peered(props: DepProps) { return null; }\n",
460
+ Referenced:
461
+ '/// <reference types="@acme/not-installed-types" />\nexport function Referenced(props: {label?: string}) { return null; }\n',
462
+ Effect:
463
+ "import '@acme/not-installed-effect';\nexport function Effect(props: {label?: string}) { return null; }\n",
464
+ // A type name declared nowhere, which only `keyof`, a `Record`'s keys
465
+ // or a template literal key reaches.
466
+ Omitting:
467
+ 'export function Omitting(props: Omit<{label: string; size?: number}, keyof NowhereProps>) { return null; }\n',
468
+ Recorded:
469
+ 'export function Recorded(props: Partial<Record<keyof NowhereProps, string>>) { return null; }\n',
470
+ Handlers:
471
+ 'export function Handlers(props: {[K in `on${NowhereKey}`]?: () => void}) { return null; }\n',
472
+ Aliased:
473
+ 'type Keys = keyof NowhereProps;\nexport function Aliased(props: {[K in Keys]?: string}) { return null; }\n',
474
+ // A value declared nowhere behind a binding, and a property that
475
+ // doesn't exist: the compiler can only give either its error type.
476
+ Valued:
477
+ 'const d = missingValue;\nexport function Valued(props: Omit<{label: string; size?: number}, keyof typeof d>) { return null; }\n',
478
+ Sized:
479
+ "interface Theme { colors: {a: string} }\nexport function Sized(props: Partial<Record<keyof Theme['sizes'], string>>) { return null; }\n",
480
+ // A value the compiler can't type, spread into an object, makes the
481
+ // object `any`.
482
+ Spread:
483
+ 'interface Theme { colors: {a: string} }\ndeclare const theme: Theme;\nconst defaults = {...theme.sizes};\nexport function Spread(props: Omit<{label: string; size?: number}, keyof typeof defaults>) { return null; }\n',
484
+ Toned:
485
+ 'const tones = {...undeclaredTones};\nexport function Toned(props: Partial<Record<keyof typeof tones, string>>) { return null; }\n',
486
+ }).map(([name, source]) =>
487
+ component(name, source, {props: [prop('label', 'string')]}),
488
+ );
489
+ // A name declared nowhere in a JavaScript component's JSDoc, which `tsc`
490
+ // doesn't report there.
491
+ const documented = component(
492
+ 'Documented',
493
+ '/** @param {Omit<{label: string; size?: number}, keyof NowhereJs>} props */\nexport function Documented(props) { return null; }\n',
494
+ {props: [prop('label', 'string')]},
495
+ );
496
+ const jsx = documented.sourcePath.replace(/\.tsx$/, '.jsx');
497
+ fs.renameSync(documented.sourcePath, jsx);
498
+ references.push({...documented, sourcePath: jsx, sources: [jsx]});
499
+ // A module found without types resolves: its names are `any`.
500
+ const untypedDir = path.join(pkg, 'node_modules', 'untyped-dep');
501
+ fs.mkdirSync(untypedDir, {recursive: true});
502
+ fs.writeFileSync(
503
+ path.join(untypedDir, 'package.json'),
504
+ JSON.stringify({name: 'untyped-dep', main: 'index.js'}),
505
+ );
506
+ fs.writeFileSync(
507
+ path.join(untypedDir, 'index.js'),
508
+ 'export const fmt = text => text;\n',
509
+ );
510
+ const untyped = component(
511
+ 'Untyped',
512
+ "import {fmt} from 'untyped-dep';\nexport function Untyped(props: {label?: string}) { fmt(props.label); return null; }\n",
513
+ {props: [prop('label', 'string')]},
514
+ );
515
+ const lonely = component(
516
+ 'Lonely',
517
+ 'export function Other(props: {label: string}) { return null; }\n',
518
+ {props: [prop('label', 'string', {required: true})]},
519
+ );
520
+ // An `any` index signature the package declares itself is no reason.
521
+ const open = component(
522
+ 'Open',
523
+ 'export function Open(props: {label?: string; [key: string]: any}) { return null; }\n',
524
+ {props: [prop('label', 'string')]},
525
+ );
526
+ // A missing import in the files a component comes from lists it, however
527
+ // the props reach it: only a member's type, a value's property, a
528
+ // conditional, `??`, a comma, or an own HOC that maps over `keyof P`.
529
+ const reached = Object.entries({
530
+ Member:
531
+ 'interface Holder<T> { label?: T } export function Member(props: Holder<MissingProps>) { return null; }',
532
+ Header:
533
+ 'function Inner(props: {label?: string}) { return null; } const Card = Object.assign(Inner, {Header: Inner}); export const Header = Card.Header;',
534
+ Chosen:
535
+ 'function Inner(props: {label?: string}) { return null; } export const Chosen = (globalThis as {x?: 1}).x ? Inner : Inner;',
536
+ Fallback:
537
+ 'function Inner(props: {label?: string}) { return null; } export const Fallback = (undefined as typeof Inner | undefined) ?? Inner;',
538
+ Comma:
539
+ 'function Inner(props: {label?: string}) { return null; } export const Comma = (0, Inner);',
540
+ Keyed:
541
+ 'function withKeys<P>(c: (props: P) => null) { return (props: {[K in keyof P]?: string}) => c(props as P); } export const Keyed = withKeys((props: MissingProps) => null);',
542
+ }).map(([name, source]) =>
543
+ component(
544
+ name,
545
+ `import type {MissingProps} from '@acme/not-installed';\n${source}\n`,
546
+ {props: [prop('label', 'string')]},
547
+ ),
548
+ );
549
+
550
+ // Props that take names from a type parameter, such as the element a
551
+ // component renders, accept any name until it is used.
552
+ const PROPS_OF = [
553
+ 'interface Intrinsic { div: {title?: string}; span: {lang?: string} }',
554
+ 'type ElementType = keyof Intrinsic | ((props: any) => unknown);',
555
+ 'type PropsOf<E> = E extends (props: infer P) => unknown ? P : E extends keyof Intrinsic ? Intrinsic[E] : {};',
556
+ "type Prefixed<N extends string> = {[K in 'value' | 'change' as `${N}${Capitalize<K>}`]: string};",
557
+ "type Pair<N extends string> = {label: string} & {[K in 'a' | 'b' as `${N}${K}`]: string};",
558
+ 'type Relayed<M extends string> = Prefixed<M>;',
559
+ 'type Except<O, X extends keyof O> = {[K in keyof O as K extends X ? never : K]: O[K]};',
560
+ 'type PickByValue<O, V> = {[K in keyof O as O[K] extends V ? K : never]: O[K]};',
561
+ ];
562
+ const generics = Object.entries({
563
+ Poly: "export type PolyProps<E extends ElementType> = {as?: E; padding?: number} & Omit<PropsOf<E>, 'as' | 'padding'>; export function Poly<E extends ElementType = 'div'>(props: PolyProps<E>) { return null; }",
564
+ Joint:
565
+ "export function Joint<E extends ElementType = 'div'>(props: {as?: E} & PropsOf<E>) { return null; }",
566
+ Partly:
567
+ "export function Partly<E extends ElementType = 'div'>(props: Partial<PropsOf<E>>) { return null; }",
568
+ Remapped:
569
+ 'export function Remapped<T extends Record<string, unknown> = {value: string}>(props: {[K in keyof T & string as `on${Capitalize<K>}Change`]?: (value: T[K]) => void}) { return null; }',
570
+ Inferred:
571
+ 'interface InferredBase { label?: string } export function Inferred<P extends InferredBase>(props: P) { return null; }',
572
+ // An `as` clause that names props from one: through an alias (in an
573
+ // intersection, or through another alias), inline, or behind a
574
+ // condition; and keys made from one.
575
+ PrefixAlias:
576
+ 'export function PrefixAlias<N extends string>(props: Prefixed<N>) { return null; }',
577
+ PrefixNested:
578
+ 'export function PrefixNested<N extends string>(props: Pair<N>) { return null; }',
579
+ PrefixDeep:
580
+ 'export function PrefixDeep<N extends string>(props: Relayed<N>) { return null; }',
581
+ Templated:
582
+ 'export function Templated<K extends string>(props: {[P in `on${K}`]?: () => void}) { return null; }',
583
+ PrefixInline:
584
+ "export function PrefixInline<N extends string>(props: {[K in 'value' | 'change' as `${N}${Capitalize<K>}`]?: string}) { return null; }",
585
+ PrefixGated:
586
+ "export function PrefixGated<T extends boolean>(props: {[K in 'value' | 'change' as T extends true ? K : never]?: string}) { return null; }",
587
+ // Props made from one that show the compiler no members at all.
588
+ Bagged:
589
+ 'interface Bag<T> {} export function Bagged<T>(props: Bag<T>) { return null; }',
590
+ }).map(([name, source]) =>
591
+ component(name, [...PROPS_OF, source].join('\n'), {
592
+ props: [prop('elevation', 'number')],
593
+ }),
594
+ );
595
+ // A generic whose own props take any name, or map keys of its own (an
596
+ // `as` clause over its own key, or an alias or key filter given fixed
597
+ // arguments), has names of its own: compared.
598
+ const own = (/** @type {string} */ names) =>
599
+ names.split(' ').map(name => prop(name, 'X', {required: true}));
600
+ const owned = [
601
+ ['GenericOpen', '{items: T[]; [key: string]: any}', 'items'],
602
+ ['GenericRecord', '{items: T[]} & Record<string, any>', 'items'],
603
+ ['GenericRecordKeys', "Record<'value' | 'initial', T>", 'initial value'],
604
+ ['GenericFiltered', "{[K in 'a' | 'b' as Exclude<K, 'b'>]: T}", 'a'],
605
+ ['GenericPrefixed', "Prefixed<'my'>", 'myChange myValue'],
606
+ ['GenericPair', "Pair<'my'>", 'label mya myb'],
607
+ ['GenericRelayed', "Relayed<'my'>", 'myChange myValue'],
608
+ [
609
+ 'GenericExcept',
610
+ "{items: T[]} & Except<{a: string; b: string}, 'b'>",
611
+ 'a items',
612
+ ],
613
+ [
614
+ 'GenericByValue',
615
+ '{items: T[]} & PickByValue<{onPick: () => void; size: number}, Function>',
616
+ 'items onPick',
617
+ ],
618
+ ].map(([name, props, names]) =>
619
+ component(
620
+ name,
621
+ [
622
+ ...PROPS_OF,
623
+ `export function ${name}<T>(props: ${props}) { return null; }`,
624
+ ].join('\n'),
625
+ {props: own(names)},
626
+ ),
627
+ );
628
+ // Props that are plainly empty, generic or not, are compared: TypeScript
629
+ // takes none of the documented props.
630
+ const empty = ['NoProps', 'NoPropsT<T>'].map(name =>
631
+ component(
632
+ name.replace('<T>', ''),
633
+ `export function ${name}(props: {}) { return null; }\n`,
634
+ {props: [prop('label', 'string')]},
635
+ ),
636
+ );
637
+
638
+ const report = await compare([
639
+ ...unread,
640
+ lonely,
641
+ open,
642
+ ...reached,
643
+ ...references,
644
+ untyped,
645
+ ...generics,
646
+ ...owned,
647
+ ...empty,
648
+ ]);
649
+
650
+ expect(kinds(report)).toEqual([
651
+ 'NoProps.label not_in_code',
652
+ 'NoPropsT.label not_in_code',
653
+ ]);
654
+ expect(report.components).toBe(13);
655
+ const generic_ =
656
+ 'Bagged Inferred Joint Partly Poly PrefixAlias PrefixDeep PrefixGated PrefixInline PrefixNested Remapped Templated'.split(
657
+ ' ',
658
+ );
659
+ expect(report.unchecked.map(u => u.component)).toEqual(
660
+ 'Aliased Bagged Bare Chosen Classy Comma Deep Documented Effect Fallback Ghost Handlers Header Inferred Joined Joint Kept Keyed Lonely Mapped Member Named Nested Omitted Omitting Partly Peered Phantom Picked Poly PrefixAlias PrefixDeep Prefixed PrefixGated PrefixInline PrefixNested Recorded Referenced Remapped Sized Spread Templated Toned Unnamed Valued Wrapped'.split(
661
+ ' ',
662
+ ),
663
+ );
664
+ // A type the compiler can't work out, though every reference resolves.
665
+ const untyped_ =
666
+ 'Aliased Documented Handlers Omitting Recorded Sized Spread Toned Valued'.split(
667
+ ' ',
668
+ );
669
+ for (const {component: name, reason} of report.unchecked) {
670
+ expect(reason).toContain(
671
+ name === 'Lonely'
672
+ ? 'Lonely.tsx has no export named Lonely'
673
+ : generic_.includes(name)
674
+ ? 'from a type parameter'
675
+ : untyped_.includes(name)
676
+ ? "a type the compiler can't work out"
677
+ : 'could not resolve',
678
+ );
679
+ }
680
+ });
681
+ });
682
+
683
+ /**
684
+ * A multi-component doc's entry.
685
+ * @param {string} name
686
+ * @param {any[]} props
687
+ */
688
+ const entry = (name, props) => ({
689
+ name,
690
+ displayName: name,
691
+ description: 'Fixture.',
692
+ props,
693
+ });
694
+
695
+ /** @param {import('./prop-drift.mjs').PropDriftComponent[]} components */
696
+ function compareDocs(components) {
697
+ return comparePropDocs(components, {packageDir: pkg, resolveFrom: [pkg]});
698
+ }
699
+
700
+ describe('comparePropDocs', () => {
701
+ it('compares each entry of a multi-component doc against its own export, and skips hooks', async () => {
702
+ withTypeScript();
703
+ const tabs = component(
704
+ 'Tabs',
705
+ `export function Tabs(props: {value: string}) { return null; }
706
+ export function Tab(props: {value: string; isDisabled?: boolean}) { return null; }
707
+ export function useTabs(options: {value: string}) { return options; }`,
708
+ {
709
+ components: [
710
+ entry('Tabs', [prop('value', 'string', {required: true})]),
711
+ entry('Tab', [
712
+ prop('value', 'string', {required: true}),
713
+ prop('isSelected', 'boolean'),
714
+ ]),
715
+ {
716
+ name: 'useTabs',
717
+ displayName: 'useTabs',
718
+ description: 'Fixture.',
719
+ params: [
720
+ {
721
+ name: 'options',
722
+ type: '{value: string}',
723
+ description: 'Fixture.',
724
+ },
725
+ ],
726
+ },
727
+ ],
728
+ },
729
+ );
730
+
731
+ const report = await compareDocs([tabs]);
732
+
733
+ expect(report.components).toBe(2);
734
+ expect(kinds(report)).toEqual([
735
+ 'Tab.isDisabled not_in_doc',
736
+ 'Tab.isSelected not_in_code',
737
+ ]);
738
+ });
739
+
740
+ it('compares a member record only against its own source, and the family record leaves that member alone', async () => {
741
+ withTypeScript();
742
+ const status = component(
743
+ 'Status',
744
+ 'export function Status(props: {value: string}) { return null; }\n',
745
+ {
746
+ components: [
747
+ entry('Status', [prop('value', 'string', {required: true})]),
748
+ entry('StatusGlyph', [
749
+ prop('size', 'number'),
750
+ prop('tone', 'string'),
751
+ ]),
752
+ ],
753
+ },
754
+ );
755
+ const glyphSource = path.join(pkg, 'components', 'StatusView.tsx');
756
+ fs.writeFileSync(
757
+ glyphSource,
758
+ 'export function StatusGlyph(props: {size?: number}) { return null; }\n',
759
+ );
760
+
761
+ const report = await compareDocs([
762
+ status,
763
+ {...status, name: 'StatusGlyph', sourcePath: glyphSource},
764
+ ]);
765
+
766
+ expect(report.unchecked).toEqual([]);
767
+ expect(report.components).toBe(2);
768
+ expect(kinds(report)).toEqual(['StatusGlyph.tone not_in_code']);
769
+ expect(report.findings[0].source).toBe('components/StatusView.tsx');
770
+ });
771
+
772
+ it('lists a member record that no file in the package declares as not compared', async () => {
773
+ withTypeScript();
774
+ const status = component(
775
+ 'Status',
776
+ 'export function Status(props: {value: string}) { return null; }\n',
777
+ {
778
+ components: [
779
+ entry('Status', [prop('value', 'string', {required: true})]),
780
+ entry('StatusGlyph', [prop('size', 'number')]),
781
+ ],
782
+ },
783
+ );
784
+
785
+ // Only another folder, or built output, declares the member.
786
+ const report = await compareDocs([
787
+ status,
788
+ {...status, name: 'StatusGlyph', sourcePath: null},
789
+ ]);
790
+
791
+ expect(report.components).toBe(1);
792
+ expect(report.findings).toEqual([]);
793
+ expect(report.unchecked).toEqual([
794
+ {
795
+ component: 'StatusGlyph',
796
+ doc: 'components/Status.doc.mjs',
797
+ reason: 'no source file for StatusGlyph',
798
+ },
799
+ ]);
800
+ });
801
+
802
+ it('compares every entry of a family doc named after another export, and lists one with no source as not compared', async () => {
803
+ withTypeScript();
804
+ const menu = component(
805
+ 'Menu',
806
+ [
807
+ 'export function MenuRoot(props: {open?: boolean}) { return null; }',
808
+ 'export function MenuItem(props: {value: string}) { return null; }',
809
+ ].join('\n'),
810
+ {
811
+ name: 'MenuRoot',
812
+ hiddenComponents: ['MenuItem', 'MenuPart'],
813
+ components: [
814
+ entry('MenuItem', [prop('label', 'string')]),
815
+ entry('MenuGhost', [prop('tone', 'string')]),
816
+ entry('MenuPart', [prop('tone', 'string')]),
817
+ ],
818
+ },
819
+ );
820
+ // A hidden entry declared in another file of the doc's folder.
821
+ fs.writeFileSync(
822
+ path.join(pkg, 'components', 'menu-parts.tsx'),
823
+ 'export function MenuPart(props: {depth?: number}) { return null; }\n',
824
+ );
825
+
826
+ // Discovery records the doc under `MenuRoot` and leaves the hidden
827
+ // `MenuItem` and `MenuPart` and the unbuilt `MenuGhost` without records
828
+ // of their own.
829
+ const report = await compareDocs([{...menu, name: 'MenuRoot'}]);
830
+
831
+ expect(kinds(report)).toEqual([
832
+ 'MenuItem.label not_in_code',
833
+ 'MenuItem.value not_in_doc',
834
+ 'MenuPart.depth not_in_doc',
835
+ 'MenuPart.tone not_in_code',
836
+ ]);
837
+ expect(report.unchecked).toEqual([
838
+ {
839
+ component: 'MenuGhost',
840
+ doc: 'components/Menu.doc.mjs',
841
+ reason: 'Menu.tsx has no export named MenuGhost',
842
+ },
843
+ ]);
844
+ });
845
+
846
+ it("compares a doc's own props with the export its name names, which another file can hold", async () => {
847
+ withTypeScript();
848
+ const status = component(
849
+ 'Status',
850
+ 'export function Status(props: {value: string}) { return null; }\n',
851
+ {name: 'StatusView', props: [prop('size', 'number')]},
852
+ );
853
+ const viewSource = path.join(pkg, 'components', 'StatusView.tsx');
854
+ fs.writeFileSync(
855
+ viewSource,
856
+ 'export function StatusView(props: {size?: number}) { return null; }\n',
857
+ );
858
+
859
+ // Discovery names the record after the file, or after the export.
860
+ const byFile = await compareDocs([status]);
861
+ const byExport = await compareDocs([
862
+ {...status, name: 'StatusView', sourcePath: viewSource},
863
+ ]);
864
+
865
+ for (const report of [byFile, byExport]) {
866
+ expect(report.unchecked).toEqual([]);
867
+ expect(report.components).toBe(1);
868
+ expect(report.findings).toEqual([]);
869
+ }
870
+ });
871
+
872
+ it('compares a table for one component in each doc that has one', async () => {
873
+ withTypeScript();
874
+ const title = prop('title', 'string', {required: true});
875
+ const list = component(
876
+ 'List',
877
+ 'export function List(props: {label: string}) { return null; }\n',
878
+ {
879
+ components: [
880
+ entry('List', [prop('label', 'string', {required: true})]),
881
+ entry('ListGroup', [title]),
882
+ ],
883
+ },
884
+ );
885
+ const group = component(
886
+ 'ListGroup',
887
+ 'export function ListGroup(props: {title: string}) { return null; }\n',
888
+ {props: [title, prop('stale', 'string')]},
889
+ );
890
+
891
+ const report = await compareDocs([list, group]);
892
+
893
+ expect(report.components).toBe(3);
894
+ expect(kinds(report)).toEqual(['ListGroup.stale not_in_code']);
895
+ expect(report.findings[0].doc).toBe('components/ListGroup.doc.mjs');
896
+ });
897
+
898
+ it('lists every component as not compared when a declaration file the config includes, or a type package it names, does not resolve', async () => {
899
+ withTypeScript();
900
+ const tsconfig = path.join(pkg, 'tsconfig.json');
901
+ const config = JSON.parse(fs.readFileSync(tsconfig, 'utf8'));
902
+ /** @param {object} options */
903
+ const configure = options =>
904
+ fs.writeFileSync(
905
+ tsconfig,
906
+ JSON.stringify({
907
+ ...config,
908
+ compilerOptions: {...config.compilerOptions, ...options},
909
+ include: ['components', 'types'],
910
+ }),
911
+ );
912
+ fs.mkdirSync(path.join(pkg, 'types'));
913
+ const globals = path.join(pkg, 'types', 'globals.d.ts');
914
+ const tables = () => [
915
+ component(
916
+ 'Omitted',
917
+ 'export function Omitted(props: Omit<{label: string; size?: number}, keyof MissingProps>) { return null; }\n',
918
+ {
919
+ props: [
920
+ prop('label', 'string', {required: true}),
921
+ prop('size', 'number'),
922
+ ],
923
+ },
924
+ ),
925
+ component(
926
+ 'Plain',
927
+ 'export function Plain(props: {label?: string}) { return null; }\n',
928
+ {props: [prop('label', 'string')]},
929
+ ),
930
+ ];
931
+ /** @param {RegExp} reason */
932
+ const unread = reason =>
933
+ ['Omitted', 'Plain'].map(name => ({
934
+ component: name,
935
+ doc: `components/${name}.doc.mjs`,
936
+ reason: expect.stringMatching(reason),
937
+ }));
938
+
939
+ // A global type made from a package that isn't installed.
940
+ configure({});
941
+ fs.writeFileSync(
942
+ globals,
943
+ "import type {Props} from '@acme/not-installed';\ndeclare global { type MissingProps = Props; }\n",
944
+ );
945
+ let report = await compare(tables());
946
+ expect(report.findings).toEqual([]);
947
+ expect(report.unchecked).toEqual(
948
+ unread(
949
+ /depends on `@acme\/not-installed` \(imported in \.\.\/types\/globals\.d\.ts\)/,
950
+ ),
951
+ );
952
+
953
+ // A global type made from a name declared nowhere.
954
+ fs.writeFileSync(
955
+ globals,
956
+ 'declare global { type MissingProps = NowhereProps; }\nexport {};\n',
957
+ );
958
+ report = await compare(tables());
959
+ expect(report.findings).toEqual([]);
960
+ expect(report.unchecked).toEqual(
961
+ unread(
962
+ /depends on `NowhereProps` \(used in \.\.\/types\/globals\.d\.ts\)/,
963
+ ),
964
+ );
965
+
966
+ // A type package the config names that isn't installed.
967
+ fs.writeFileSync(
968
+ globals,
969
+ 'declare global { type MissingProps = {id?: string}; }\nexport {};\n',
970
+ );
971
+ configure({types: ['@acme/not-installed-globals']});
972
+ report = await compare(tables());
973
+ expect(report.findings).toEqual([]);
974
+ expect(report.unchecked).toEqual(
975
+ unread(
976
+ /depends on the types `@acme\/not-installed-globals` \(named in \.\.\/tsconfig\.json\)/,
977
+ ),
978
+ );
979
+
980
+ // Both resolved: compared.
981
+ configure({types: []});
982
+ report = await compare(tables());
983
+ expect(report.unchecked).toEqual([]);
984
+ expect(report.components).toBe(2);
985
+ });
986
+
987
+ it("lists every table, with the error, when the package's TypeScript config has an error", async () => {
988
+ withTypeScript();
989
+ fs.writeFileSync(
990
+ path.join(pkg, 'tsconfig.json'),
991
+ JSON.stringify({extends: './missing-base.json'}),
992
+ );
993
+ const titled = component(
994
+ 'Titled',
995
+ 'export function Titled(props: {title?: string}) { return null; }\n',
996
+ {props: [prop('title', 'string')]},
997
+ );
998
+
999
+ const report = await compare([titled]);
1000
+
1001
+ expect(report.components).toBe(0);
1002
+ expect(report.unchecked).toEqual([
1003
+ expect.objectContaining({
1004
+ component: 'Titled',
1005
+ reason: expect.stringMatching(
1006
+ /config has an error.*missing-base\.json/,
1007
+ ),
1008
+ }),
1009
+ ]);
1010
+ });
1011
+
1012
+ it('accepts a name through an index signature only as TypeScript would', async () => {
1013
+ withTypeScript();
1014
+ const keyed = component(
1015
+ 'Keyed',
1016
+ [
1017
+ 'export interface KeyedProps {',
1018
+ ' [key: `data-${string}`]: string | undefined;',
1019
+ ' [key: `${string}:${string}`]: string;',
1020
+ ' [key: `col${number}`]: number;',
1021
+ ' [key: `on${Capitalize<string>}`]: () => void;',
1022
+ '}',
1023
+ 'export function Keyed(props: KeyedProps) { return null; }',
1024
+ ].join('\n'),
1025
+ {
1026
+ props: [
1027
+ prop('data-section', 'string'),
1028
+ prop('a:b', 'string'),
1029
+ prop('col2', 'number'),
1030
+ prop('onClick', '() => void'),
1031
+ prop('elevation', 'string'),
1032
+ prop('colSpan', 'string'),
1033
+ prop('onclick', 'string'),
1034
+ ],
1035
+ },
1036
+ );
1037
+ const empty = component(
1038
+ 'Empty',
1039
+ `interface EmptyProps { label?: string }
1040
+ export function Empty(props: EmptyProps | Record<string, never>) { return null; }`,
1041
+ {props: [prop('label', 'string'), prop('elevation', 'number')]},
1042
+ );
1043
+ const typedAny = component(
1044
+ 'TypedAny',
1045
+ 'export function TypedAny(props: any) { return null; }\n',
1046
+ {props: [prop('label', 'string')]},
1047
+ );
1048
+ const anyConst = component(
1049
+ 'AnyConst',
1050
+ 'export const AnyConst: any = null;\n',
1051
+ {props: [prop('label', 'string')]},
1052
+ );
1053
+
1054
+ const report = await compare([keyed, empty, typedAny, anyConst]);
1055
+
1056
+ expect(kinds(report)).toEqual([
1057
+ 'Empty.elevation not_in_code',
1058
+ 'Keyed.colSpan not_in_code',
1059
+ 'Keyed.elevation not_in_code',
1060
+ 'Keyed.onclick not_in_code',
1061
+ ]);
1062
+ expect(report.unchecked.map(u => [u.component, u.reason])).toEqual([
1063
+ ['AnyConst', expect.stringContaining('typed `any`')],
1064
+ ['TypedAny', expect.stringContaining('typed `any`')],
1065
+ ]);
1066
+ });
1067
+
1068
+ it("reads props through React's component types, lists the ones typed any or named by the element they render, and loads the ambient types tsconfig includes", async () => {
1069
+ withTypeScript();
1070
+ fs.mkdirSync(path.join(pkg, 'node_modules', '@types'), {recursive: true});
1071
+ fs.symlinkSync(
1072
+ path.dirname(require.resolve('@types/react/package.json')),
1073
+ path.join(pkg, 'node_modules', '@types', 'react'),
1074
+ 'dir',
1075
+ );
1076
+ const tsconfig = path.join(pkg, 'tsconfig.json');
1077
+ const config = JSON.parse(fs.readFileSync(tsconfig, 'utf8'));
1078
+ fs.writeFileSync(
1079
+ tsconfig,
1080
+ JSON.stringify({...config, include: ['components', 'types']}),
1081
+ );
1082
+ fs.mkdirSync(path.join(pkg, 'types'));
1083
+ fs.writeFileSync(
1084
+ path.join(pkg, 'types', 'ambient.d.ts'),
1085
+ 'interface AmbientProps { label?: string }\n',
1086
+ );
1087
+ const source = `import {forwardRef, memo, type FC, type ComponentType, type ComponentPropsWithoutRef, type ElementType} from 'react';
1088
+ export function Poly<E extends ElementType = 'div'>(props: {as?: E; padding?: number} & Omit<ComponentPropsWithoutRef<E>, 'as' | 'padding'>) { return null; }
1089
+ export const Fc: FC<any> = () => null;
1090
+ export const Memo = memo((props: any) => null);
1091
+ export const Fwd = forwardRef<HTMLDivElement, any>((props, ref) => null);
1092
+ export const FwdOmit = forwardRef<HTMLDivElement, Omit<any, 'as'>>((props, ref) => null);
1093
+ export const FwdPartial = memo(forwardRef<HTMLDivElement, Partial<any>>((props, ref) => null));
1094
+ export function Ambient(props: AmbientProps) { return null; }
1095
+ export const Typed: ComponentType<{label?: string}> = () => null;
1096
+ const withName = <P,>(c: ComponentType<P>): ComponentType<P> => c;
1097
+ export const Hoc = withName((props: {label?: string}) => null);`;
1098
+ const props = [prop('label', 'string'), prop('elevation', 'number')];
1099
+
1100
+ const report = await compare(
1101
+ 'Fc Memo Fwd FwdOmit FwdPartial Poly Ambient Typed Hoc'
1102
+ .split(' ')
1103
+ .map(name => component(name, source, {props})),
1104
+ );
1105
+
1106
+ expect(kinds(report)).toEqual(
1107
+ 'Ambient Hoc Typed'
1108
+ .split(' ')
1109
+ .map(name => `${name}.elevation not_in_code`),
1110
+ );
1111
+ expect(report.unchecked.map(u => [u.component, u.reason])).toEqual(
1112
+ ['Fc', 'Fwd', 'FwdOmit', 'FwdPartial', 'Memo', 'Poly'].map(name => [
1113
+ name,
1114
+ expect.stringContaining(
1115
+ name === 'Poly' ? 'from a type parameter' : 'typed `any`',
1116
+ ),
1117
+ ]),
1118
+ );
1119
+ });
1120
+
1121
+ it('reads names that list several props, spreads, and fields through arrays, tuples, unions and index signatures', async () => {
1122
+ withTypeScript();
1123
+ const menu = component(
1124
+ 'Menu',
1125
+ [
1126
+ 'export interface MenuProps {',
1127
+ ' onOpen?: () => void;',
1128
+ ' onClose?: () => void;',
1129
+ ' value?: {label: string; items: ReadonlyArray<{id: string}>};',
1130
+ ' labels?: Record<string, string>;',
1131
+ ' slots?: {[key: `slot-${string}`]: string};',
1132
+ " rows?: Array<{kind: 'a'; total: string} | {kind: 'b'}>;",
1133
+ ' grid?: Array<Array<{id: string}>>;',
1134
+ ' pair?: readonly [{id: string}, {id: number}];',
1135
+ '}',
1136
+ 'export function Menu(props: MenuProps) { return null; }',
1137
+ ].join('\n'),
1138
+ {
1139
+ props: [
1140
+ prop('onOpen / onClose', '() => void / () => void'),
1141
+ prop('...rest', 'Omit<OtherProps, "x">'),
1142
+ ...['value.label', 'value.items[].id', 'value.missing'].map(name =>
1143
+ prop(name, 'string'),
1144
+ ),
1145
+ ...['labels.close', 'slots.slot-a', 'slots.other'].map(name =>
1146
+ prop(name, 'string'),
1147
+ ),
1148
+ ...['rows[].total', 'grid[][].id', 'grid[][].size', 'pair[].id'].map(
1149
+ name => prop(name, 'string'),
1150
+ ),
1151
+ ],
1152
+ },
1153
+ );
1154
+
1155
+ expect(kinds(await compare([menu]))).toEqual([
1156
+ 'Menu.grid[][].size not_in_code',
1157
+ 'Menu.slots.other not_in_code',
1158
+ 'Menu.value.missing not_in_code',
1159
+ ]);
1160
+ });
1161
+
1162
+ it('skips with a reason, and compares nothing, without a TypeScript it can use', async () => {
1163
+ const badge = component(
1164
+ 'Badge',
1165
+ 'export function Badge(props: {label: string}) { return null; }\n',
1166
+ {props: [prop('label', 'number')]},
1167
+ );
1168
+ const skipped = (/** @type {string} */ reason) => ({
1169
+ status: 'skipped',
1170
+ reason,
1171
+ components: 0,
1172
+ findings: [],
1173
+ unchecked: [],
1174
+ });
1175
+
1176
+ expect(await compare([badge])).toEqual(skipped(TYPESCRIPT_MISSING_REASON));
1177
+ // TypeScript 7 has no JavaScript compiler API to load.
1178
+ const dir = path.join(pkg, 'node_modules', 'typescript');
1179
+ fs.mkdirSync(dir, {recursive: true});
1180
+ fs.writeFileSync(
1181
+ path.join(dir, 'package.json'),
1182
+ JSON.stringify({
1183
+ name: 'typescript',
1184
+ version: '7.0.0',
1185
+ exports: {'./package.json': './package.json'},
1186
+ }),
1187
+ );
1188
+ expect(await compare([badge])).toEqual(
1189
+ skipped(
1190
+ unusableTypeScriptReason(
1191
+ '7.0.0',
1192
+ 'documented props were not compared with the code',
1193
+ ),
1194
+ ),
1195
+ );
1196
+ });
1197
+ });