@astryxdesign/cli 0.7.2-canary.ebcc404 → 0.7.2-canary.f8f7cf1

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 (134) hide show
  1. package/README.md +9 -8
  2. package/api/build/help/help.mjs +1 -1
  3. package/api/component/_adapter.d.mts +14 -0
  4. package/api/component/_adapter.mjs +48 -0
  5. package/api/component/component.mjs +10 -2
  6. package/api/component/integration-export-name.test.mjs +233 -8
  7. package/api/docs/_adapter.d.mts +11 -0
  8. package/api/docs/_adapter.mjs +244 -1
  9. package/api/docs/integrationDocs.test.mjs +124 -0
  10. package/api/doctor/theme-checks.mjs +1 -1
  11. package/api/gap-report/gap-report.mjs +2 -1
  12. package/api/index.d.mts +2 -0
  13. package/api/index.mjs +2 -0
  14. package/api/init/run/run.mjs +2 -2
  15. package/api/integration/_adapter.d.mts +12 -0
  16. package/api/integration/_adapter.mjs +54 -0
  17. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  18. package/api/integration/add-contribution.mjs +8 -4
  19. package/api/integration/add-contribution.test.mjs +4 -2
  20. package/api/integration/authoring-checks.mjs +11 -2
  21. package/api/integration/authoring-checks.test.mjs +39 -0
  22. package/api/integration/authoring-checks.type.d.mts +9 -0
  23. package/api/integration/authoring-checks.type.mjs +14 -1
  24. package/api/integration/check/check.d.mts +96 -0
  25. package/api/integration/check/check.mjs +357 -0
  26. package/api/integration/check/check.test.mjs +489 -0
  27. package/api/integration/integration-check.type.d.mts +118 -0
  28. package/api/integration/integration-check.type.mjs +84 -0
  29. package/api/integration/integrationCheck.doc.d.mts +10 -0
  30. package/api/integration/integrationCheck.doc.mjs +73 -0
  31. package/api/integration/integrationComponentConflicts.doc.mjs +36 -5
  32. package/api/integration/pack-check.mjs +48 -3
  33. package/api/integration/pack-check.test.mjs +247 -0
  34. package/api/integration/validate-integration.mjs +8 -0
  35. package/api/integration/validate-integration.test.mjs +11 -0
  36. package/api/integration/validate-integration.type.d.mts +8 -0
  37. package/api/integration/validate-integration.type.mjs +12 -1
  38. package/api/integration/validateIntegration.doc.mjs +17 -3
  39. package/api/search/search.integration-check.test.mjs +24 -0
  40. package/api/search/search.mjs +20 -8
  41. package/api/theme/build/build.family.test.mjs +54 -6
  42. package/api/theme/build/build.mjs +114 -17
  43. package/api/theme/themeBuild.doc.mjs +4 -4
  44. package/assets/docs/README.md +10 -0
  45. package/assets/docs/author-a-theme.doc.mjs +1 -1
  46. package/assets/docs/getting-started.doc.mjs +3 -3
  47. package/assets/docs/theme.doc.dense.mjs +1 -1
  48. package/assets/docs/theme.doc.mjs +1 -1
  49. package/assets/docs/theme.doc.zh.mjs +1 -1
  50. package/assets/docs/tree/add-a-component.doc.mjs +10 -0
  51. package/assets/docs/tree/block-template.doc.mjs +2 -2
  52. package/assets/docs/tree/check-the-props.doc.mjs +196 -0
  53. package/assets/docs/tree/check-your-docs.doc.mjs +44 -3
  54. package/assets/docs/tree/checks.doc.mjs +21 -13
  55. package/assets/docs/tree/component-family.doc.mjs +5 -1
  56. package/assets/docs/tree/component-imports.doc.mjs +1 -1
  57. package/assets/docs/tree/describe-the-component.doc.mjs +1 -1
  58. package/assets/docs/tree/links.doc.mjs +2 -2
  59. package/assets/docs/tree/page-template.doc.mjs +1 -1
  60. package/assets/docs/tree/quick-start.doc.mjs +1 -1
  61. package/assets/docs/tree/replace-a-core-template.doc.mjs +1 -1
  62. package/assets/docs/tree/single-component.doc.mjs +3 -2
  63. package/assets/docs/tree/troubleshooting.doc.mjs +9 -1
  64. package/assets/docs/tree/type-scale.doc.mjs +3 -3
  65. package/assets/docs/tree/versioning.doc.mjs +1 -0
  66. package/assets/docs/tree/write-the-template-file.doc.mjs +2 -1
  67. package/assets/docs/use-a-theme.doc.mjs +2 -2
  68. package/assets/theme.template.ts +1 -1
  69. package/authoring/doctypes/component/component.doc.mjs +1 -1
  70. package/authoring/doctypes/template/integration-template.doc.d.mts +11 -0
  71. package/authoring/doctypes/template/integration-template.doc.mjs +49 -0
  72. package/authoring/doctypes/template/parse.d.mts +6 -2
  73. package/authoring/doctypes/template/parse.mjs +3 -1
  74. package/authoring/doctypes/template/template.doc.mjs +1 -1
  75. package/authoring/doctypes/template/type.ts +22 -0
  76. package/authoring/index.d.ts +1 -0
  77. package/authoring/integration/integration.doc.mjs +8 -1
  78. package/authoring/integration/parse.test.mjs +10 -0
  79. package/authoring/integration/schema.d.mts +2 -0
  80. package/authoring/integration/schema.mjs +1 -0
  81. package/authoring/integration/type.ts +4 -0
  82. package/clients/cli/commands/build-theme.batch-report.test.mjs +3 -1
  83. package/clients/cli/commands/build-theme.mjs +1 -1
  84. package/clients/cli/commands/build-theme.path-safety.test.mjs +155 -21
  85. package/clients/cli/commands/doctor-integration-components.doc.mjs +15 -4
  86. package/clients/cli/commands/doctor-integration-validate.doc.mjs +11 -2
  87. package/clients/cli/commands/doctor-integration.test.mjs +837 -0
  88. package/clients/cli/commands/doctor.mjs +190 -0
  89. package/clients/cli/commands/integration-check.doc.mjs +85 -0
  90. package/clients/cli/commands/integration-check.test.mjs +267 -0
  91. package/clients/cli/commands/integration.doc.mjs +6 -2
  92. package/clients/cli/commands/integration.mjs +107 -1
  93. package/clients/cli/commands/text-json-parity.test.mjs +5 -0
  94. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  95. package/clients/cli/index.mjs +1 -0
  96. package/foundation/config/project.mjs +82 -0
  97. package/foundation/discovery/authoring-self-docs.mjs +1 -0
  98. package/foundation/discovery/component-discovery.d.mts +108 -17
  99. package/foundation/discovery/component-discovery.export-names.test.mjs +469 -2
  100. package/foundation/discovery/component-discovery.mjs +380 -47
  101. package/foundation/discovery/component-replacement.test.mjs +20 -0
  102. package/foundation/discovery/doc-types.d.mts +157 -0
  103. package/foundation/discovery/doc-types.mjs +1224 -0
  104. package/foundation/discovery/doc-types.test.mjs +652 -0
  105. package/foundation/discovery/docs-discovery.d.mts +81 -0
  106. package/foundation/discovery/docs-discovery.mjs +57 -0
  107. package/foundation/discovery/integration-doc-files.d.mts +22 -0
  108. package/foundation/discovery/integration-doc-files.mjs +231 -0
  109. package/foundation/discovery/integration-doc-files.test.mjs +246 -0
  110. package/foundation/discovery/module-exports.d.mts +6 -0
  111. package/foundation/discovery/module-exports.mjs +15 -2
  112. package/foundation/discovery/package-tsconfig.d.mts +14 -0
  113. package/foundation/discovery/package-tsconfig.mjs +228 -0
  114. package/foundation/discovery/package-tsconfig.test.mjs +210 -0
  115. package/foundation/discovery/project-typescript.d.mts +23 -0
  116. package/foundation/discovery/project-typescript.mjs +61 -0
  117. package/foundation/discovery/prop-drift.d.mts +278 -0
  118. package/foundation/discovery/prop-drift.mjs +2263 -0
  119. package/foundation/discovery/prop-drift.test.mjs +2219 -0
  120. package/foundation/discovery/type-match.d.mts +53 -0
  121. package/foundation/discovery/type-match.mjs +660 -0
  122. package/foundation/discovery/type-match.test.mjs +169 -0
  123. package/foundation/discovery/type-text.d.mts +68 -0
  124. package/foundation/discovery/type-text.mjs +658 -0
  125. package/foundation/discovery/type-text.test.mjs +196 -0
  126. package/foundation/discovery/unresolved-references.d.mts +62 -0
  127. package/foundation/discovery/unresolved-references.mjs +790 -0
  128. package/foundation/discovery/unresolved-references.test.mjs +567 -0
  129. package/foundation/doc-compiler/doc-loads.test.mjs +6 -0
  130. package/foundation/doc-compiler/links.d.mts +9 -5
  131. package/foundation/doc-compiler/links.mjs +4 -2
  132. package/foundation/integrations/contribution-inventory.mjs +6 -1
  133. package/foundation/response/response-types.doc.mjs +7 -2
  134. package/package.json +9 -9
@@ -0,0 +1,2219 @@
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, the loose type rules, the props a doc never needs, and the
6
+ * visible skip when the 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
+ PROPS_NOT_DOCUMENTED,
18
+ TYPESCRIPT_MISSING_REASON,
19
+ } from './prop-drift.mjs';
20
+ import {unusableTypeScriptReason} from './project-typescript.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const TYPESCRIPT_DIR = path.dirname(require.resolve('typescript/package.json'));
24
+
25
+ /** @type {string} */
26
+ let pkg;
27
+
28
+ beforeEach(() => {
29
+ pkg = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-prop-drift-'));
30
+ fs.writeFileSync(
31
+ path.join(pkg, 'package.json'),
32
+ JSON.stringify({name: '@acme/widgets', version: '1.0.0'}),
33
+ );
34
+ fs.writeFileSync(
35
+ path.join(pkg, 'tsconfig.json'),
36
+ JSON.stringify({
37
+ compilerOptions: {
38
+ strict: true,
39
+ jsx: 'react-jsx',
40
+ module: 'esnext',
41
+ moduleResolution: 'bundler',
42
+ target: 'es2022',
43
+ },
44
+ }),
45
+ );
46
+ });
47
+
48
+ afterEach(() => {
49
+ fs.rmSync(pkg, {recursive: true, force: true});
50
+ });
51
+
52
+ function withTypeScript() {
53
+ fs.mkdirSync(path.join(pkg, 'node_modules'), {recursive: true});
54
+ fs.symlinkSync(
55
+ TYPESCRIPT_DIR,
56
+ path.join(pkg, 'node_modules', 'typescript'),
57
+ 'dir',
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Write `components/<name>.tsx` and its doc; returns its prop table, which
63
+ * also carries the component record (name, docPath, sourcePath).
64
+ * @param {string} name
65
+ * @param {string} source
66
+ * @param {Record<string, any>} doc
67
+ */
68
+ function component(name, source, doc) {
69
+ const dir = path.join(pkg, 'components');
70
+ fs.mkdirSync(dir, {recursive: true});
71
+ const sourcePath = path.join(dir, `${name}.tsx`);
72
+ const docPath = path.join(dir, `${name}.doc.mjs`);
73
+ fs.writeFileSync(sourcePath, source);
74
+ fs.writeFileSync(
75
+ docPath,
76
+ `export default ${JSON.stringify({
77
+ type: 'component',
78
+ name,
79
+ displayName: name,
80
+ description: 'Fixture.',
81
+ ...doc,
82
+ })};\n`,
83
+ );
84
+ return {
85
+ name,
86
+ docPath,
87
+ sourcePath,
88
+ doc: `components/${name}.doc.mjs`,
89
+ props: doc.props ?? [],
90
+ sources: [sourcePath],
91
+ };
92
+ }
93
+
94
+ /**
95
+ * @param {string} name
96
+ * @param {string} type
97
+ * @param {Record<string, unknown>} [extra]
98
+ */
99
+ const prop = (name, type, extra = {}) => ({
100
+ name,
101
+ type,
102
+ description: 'Fixture prop.',
103
+ ...extra,
104
+ });
105
+
106
+ /** @param {import('./prop-drift.mjs').PropDriftTable[]} tables */
107
+ function compare(tables) {
108
+ return comparePropTables(tables, {packageDir: pkg, resolveFrom: [pkg]});
109
+ }
110
+
111
+ /** @param {{findings: Array<{component: string, prop: string, kind: string}>}} report */
112
+ const kinds = report =>
113
+ report.findings.map(f => `${f.component}.${f.prop} ${f.kind}`);
114
+
115
+ describe('comparePropTables', () => {
116
+ it('judges the type of a prop a mapped type of the package builds, and says why one another package builds is not judged', async () => {
117
+ withTypeScript();
118
+ const sizes = path.join(pkg, 'node_modules', '@acme', 'sizes');
119
+ fs.mkdirSync(sizes, {recursive: true});
120
+ fs.writeFileSync(
121
+ path.join(sizes, 'package.json'),
122
+ JSON.stringify({name: '@acme/sizes', types: 'index.d.ts'}),
123
+ );
124
+ fs.writeFileSync(
125
+ path.join(sizes, 'index.d.ts'),
126
+ "export type Sizes = Partial<Record<'sm' | 'md', string>>;\n",
127
+ );
128
+ const tables = [
129
+ component(
130
+ 'Mapped',
131
+ "export function Mapped(props: Partial<Record<'a' | 'b', 'x' | 'y'>>) { return null; }",
132
+ {props: [prop('a', "'x' | 'z'"), prop('b', "'x' | 'y'")]},
133
+ ),
134
+ component(
135
+ 'HandledCell',
136
+ "import type {Sizes} from '@acme/sizes';\nexport function HandledCell(props: Sizes) { return null; }",
137
+ {props: [prop('sm', 'number')]},
138
+ ),
139
+ // Keys the check can't trace, and a doc type that does not parse; and
140
+ // a name that starts with another's sorts after it.
141
+ component(
142
+ 'Handled',
143
+ "type A = string;\nexport function Handled(props: {[K in `on${'Open' | 'Close'}`]?: () => void} & {items?: ((items: A[]) => A)[]}) { return null; }",
144
+ {props: [prop('onOpen', '() => A'), prop('items', '((A[]) => A)[]')]},
145
+ ),
146
+ ];
147
+
148
+ const report = await compare(tables);
149
+
150
+ expect(kinds(report)).toEqual(['Mapped.a type_mismatch']);
151
+ expect(report.typesNotCompared).toEqual(
152
+ [
153
+ ['Handled', 'not a form the check judges exactly', ['items', 'onOpen']],
154
+ ['HandledCell', 'declared outside the package, not compared', ['sm']],
155
+ ].map(([component, reason, props]) => ({
156
+ component,
157
+ doc: `components/${component}.doc.mjs`,
158
+ reason,
159
+ props,
160
+ })),
161
+ );
162
+ });
163
+
164
+ it('judges no prop type, and says why, with a TypeScript older than 4.8', async () => {
165
+ const dir = path.join(pkg, 'node_modules', 'typescript');
166
+ fs.mkdirSync(dir, {recursive: true});
167
+ fs.writeFileSync(path.join(dir, 'package.json'), '{"version": "4.7.4"}');
168
+ const real = JSON.stringify(TYPESCRIPT_DIR);
169
+ fs.writeFileSync(
170
+ path.join(dir, 'index.js'),
171
+ `module.exports = {...require(${real}), version: '4.7.4'};`,
172
+ );
173
+ const source = 'export function Badge(p: {size?: string}) { return null; }';
174
+ const doc = {props: [prop('size', 'number')]};
175
+
176
+ expect(await compare([component('Badge', source, doc)])).toMatchObject({
177
+ findings: [],
178
+ typesCompared: 0,
179
+ typesSkipped: `TypeScript 4.7.4 is older than 4.8, the oldest the type comparison reads`,
180
+ });
181
+ });
182
+
183
+ it("judges a prop a package's mapping rewrites by its type, and keeps the text of one a mapping copies", async () => {
184
+ withTypeScript();
185
+ const types = `interface Base { size: 'sm' | 'md' }
186
+ interface Opt { tone?: 'info' | undefined }
187
+ type Many<T> = {[K in keyof T]: T[K][]};
188
+ type Nullable<T> = {[K in keyof T]: T[K] | null};
189
+ interface Pickable<T = string> { pick?: (value: T) => void }
190
+ type ChooserProps<T extends string = string> = {pick?: string} & {
191
+ change?: (value: readonly T[]) => void | Promise<void>;
192
+ };
193
+ interface ListProps<T> { value?: T; onChange?: (v: T) => void }
194
+ type MultiProps<T> = ListProps<T[]>;
195
+ import type {Item as Imported} from './item';
196
+ import type * as NS from './item';
197
+ interface Item { a: string }
198
+ interface ItemList<Item> { items?: Item[] }`;
199
+ const size = {props: [prop('size', "'sm' | 'md'", {required: true})]};
200
+ const pick = {props: [prop('pick', '(value: T) => void')]};
201
+ const tables = [
202
+ ['Many', 'Many<Base>', size],
203
+ ['Nulls', 'Nullable<Base>', size],
204
+ ['Picked', "Pick<Base, 'size'>", size],
205
+ [
206
+ 'Strict',
207
+ 'Required<Opt>',
208
+ {props: [prop('tone', "'info' | undefined", {required: true})]},
209
+ ],
210
+ // A generic component's prop keeps its declaration's text; one a
211
+ // concrete type instantiates does not.
212
+ ['Boxed', 'Pickable<number>', pick],
213
+ [
214
+ 'Chooser',
215
+ 'ChooserProps<T>',
216
+ {
217
+ props: [
218
+ prop('pick', 'string'),
219
+ prop('change', '(value: readonly T[]) => void | Promise<void>'),
220
+ ],
221
+ },
222
+ '<T extends string = string>',
223
+ ],
224
+ // Instantiated with a type built from the component's own parameter,
225
+ // the declaration's `T` is no longer the prop's type.
226
+ // A type argument that prints with the parameter's name elsewhere is
227
+ // still another type here.
228
+ ['Renamed', 'ItemList<Imported>', {props: [prop('items', 'Item[]')]}],
229
+ ['Spaced', 'ItemList<NS.Item>', {props: [prop('items', 'Item[]')]}],
230
+ ['Owned', 'ItemList<Item>', {props: [prop('items', 'Item[]')]}],
231
+ ...[
232
+ ['Listed', 'ListProps<T[]>', 'T', '(v: T) => void'],
233
+ ['Maybe', 'ListProps<T | null>', 'T', '(v: T) => void'],
234
+ ['Multi', 'MultiProps<T>', 'T[]', '(v: T[]) => void'],
235
+ ].map(([name, props, value, onChange]) => [
236
+ name,
237
+ props,
238
+ {props: [prop('value', value), prop('onChange', onChange)]},
239
+ '<T>',
240
+ ]),
241
+ ].map(([name, props, doc, generics = '']) =>
242
+ component(
243
+ name,
244
+ `${types}\nexport function ${name}${generics}(props: ${props}) { return null; }`,
245
+ doc,
246
+ ),
247
+ );
248
+
249
+ fs.writeFileSync(
250
+ path.join(pkg, 'components', 'item.ts'),
251
+ 'export interface Item { b: number }\n',
252
+ );
253
+ const report = await compare(tables);
254
+
255
+ expect(kinds(report)).toEqual([
256
+ 'Many.size type_mismatch',
257
+ 'Nulls.size type_mismatch',
258
+ 'Strict.tone type_mismatch',
259
+ ]);
260
+ // A message shows the type the mapping makes, not the source's text.
261
+ expect(report.findings.map(f => f.codeType)).toEqual([
262
+ "('sm' | 'md')[]",
263
+ "'sm' | 'md' | null",
264
+ "'info'",
265
+ ]);
266
+ expect(report.typesNotCompared).toEqual(
267
+ [
268
+ ['Boxed', ['pick']],
269
+ ['Listed', ['onChange', 'value']],
270
+ ['Maybe', ['onChange', 'value']],
271
+ ['Renamed', ['items']],
272
+ ['Spaced', ['items']],
273
+ ].map(([component, props]) => ({
274
+ component,
275
+ doc: `components/${component}.doc.mjs`,
276
+ reason: 'not a form the check judges exactly',
277
+ props,
278
+ })),
279
+ );
280
+ });
281
+
282
+ it("reports a prop missing from the code, one missing from the doc, a wrong required flag, and a wrong type, each component's together", async () => {
283
+ withTypeScript();
284
+ const badge = component(
285
+ 'Badge',
286
+ `export type Tone = 'neutral' | 'info' | 'danger';
287
+ export interface BadgeProps {
288
+ label: string;
289
+ tone?: Tone;
290
+ count?: number;
291
+ }
292
+ export function Badge(props: BadgeProps) { return null; }`,
293
+ {
294
+ props: [
295
+ prop('label', 'string'),
296
+ prop('tone', "'neutral' | 'info'"),
297
+ prop('size', "'sm' | 'md'"),
298
+ ],
299
+ },
300
+ );
301
+ // A name that starts with another's sorts after all of the other's.
302
+ const count = component(
303
+ 'BadgeCount',
304
+ 'export function BadgeCount(props: {amount?: number}) { return null; }',
305
+ {props: []},
306
+ );
307
+
308
+ const report = await compare([count, badge]);
309
+
310
+ expect(report.status).toBe('checked');
311
+ expect(report.typescript).toMatch(/^\d+\.\d+/);
312
+ expect(report.components).toBe(2);
313
+ expect(kinds(report)).toEqual([
314
+ 'Badge.count not_in_doc',
315
+ 'Badge.label required_mismatch',
316
+ 'Badge.size not_in_code',
317
+ 'Badge.tone type_mismatch',
318
+ 'BadgeCount.amount not_in_doc',
319
+ ]);
320
+ expect(report.findings.every(f => f.severity === 'warning')).toBe(true);
321
+ expect(report.findings.find(f => f.kind === 'type_mismatch')).toMatchObject(
322
+ {
323
+ docType: "'neutral' | 'info'",
324
+ codeType: 'Tone',
325
+ doc: 'components/Badge.doc.mjs',
326
+ source: 'components/Badge.tsx',
327
+ message: expect.stringContaining(
328
+ "Tone ('neutral' | 'info' | 'danger')",
329
+ ),
330
+ },
331
+ );
332
+ expect(
333
+ report.findings.find(f => f.kind === 'required_mismatch'),
334
+ ).toMatchObject({docRequired: false, codeRequired: true});
335
+ });
336
+
337
+ it("judges a field, a name an index signature takes, and a prop declared in another file by the type's name where the prop is declared, not by the name the type was declared with", async () => {
338
+ withTypeScript();
339
+ const dir = path.join(pkg, 'components');
340
+ fs.mkdirSync(dir, {recursive: true});
341
+ fs.writeFileSync(
342
+ path.join(dir, 'base.ts'),
343
+ "export interface Item { id: string }\nexport type Size = 'sm' | 'md';\n",
344
+ );
345
+ // Another file imports the same types under other names, and declares
346
+ // its own `Item` and `Size`.
347
+ fs.writeFileSync(
348
+ path.join(dir, 'types.ts'),
349
+ [
350
+ "import type {Item as BaseItem, Size as BaseSize} from './base';",
351
+ 'interface Item { label: string }',
352
+ "type Size = 'lg';",
353
+ 'export type ElsewhereProps = { size?: BaseSize; item?: BaseItem; onPick?: (item: BaseItem) => void; cfg?: {item: BaseItem}; [key: `size-${string}`]: BaseSize | undefined } & Required<Partial<{tone: BaseSize; entry: BaseItem}>>;',
354
+ ].join('\n'),
355
+ );
356
+ const here = component(
357
+ 'Renamed',
358
+ [
359
+ "import type {Item as BaseItem, Size as BaseSize} from './base';",
360
+ 'interface Item { label: string }',
361
+ "type Size = 'lg';",
362
+ 'export function Renamed(props: {cfg?: {item: BaseItem}; [key: `size-${string}`]: BaseSize | undefined}) { return null; }',
363
+ ].join('\n'),
364
+ {props: [prop('cfg.item', 'Item'), prop('size-a', 'Size')]},
365
+ );
366
+ const elsewhere = component(
367
+ 'Elsewhere',
368
+ "import type {ElsewhereProps} from './types';\nexport function Elsewhere(props: ElsewhereProps) { return null; }",
369
+ {
370
+ props: [
371
+ prop('size', 'Size'),
372
+ prop('tone', 'Size', {required: true}),
373
+ prop('entry', 'Item', {required: true}),
374
+ prop('item', 'Item'),
375
+ prop('onPick', '(item: Item) => void'),
376
+ prop('cfg.item', 'Item'),
377
+ prop('size-a', 'Size'),
378
+ ],
379
+ },
380
+ );
381
+ // The same docs, naming the types as each file does, are judged matches.
382
+ const named = component(
383
+ 'Named',
384
+ [
385
+ "import type {Item as BaseItem, Size as BaseSize} from './base';",
386
+ 'export function Named(props: {cfg?: {item: BaseItem}; [key: `size-${string}`]: BaseSize | undefined}) { return null; }',
387
+ ].join('\n'),
388
+ {props: [prop('cfg.item', 'BaseItem'), prop('size-a', 'BaseSize')]},
389
+ );
390
+
391
+ const report = await compare([here, elsewhere, named]);
392
+
393
+ // `Size` is `'lg'` where each prop is declared, which TypeScript rejects
394
+ // for `'sm' | 'md'`; `Item` is an object type there, not compared.
395
+ expect(kinds(report)).toEqual([
396
+ 'Elsewhere.size type_mismatch',
397
+ 'Elsewhere.size-a type_mismatch',
398
+ 'Elsewhere.tone type_mismatch',
399
+ 'Renamed.size-a type_mismatch',
400
+ ]);
401
+ expect(report.typesNotCompared).toEqual([
402
+ expect.objectContaining({
403
+ component: 'Elsewhere',
404
+ props: ['cfg.item', 'entry', 'item', 'onPick'],
405
+ }),
406
+ expect.objectContaining({component: 'Renamed', props: ['cfg.item']}),
407
+ ]);
408
+ expect(report.typesCompared).toBe(6);
409
+ });
410
+
411
+ it("reads `React.X` as React's own where the prop is declared, never as a bare name that means another type there", async () => {
412
+ withTypeScript();
413
+ fs.mkdirSync(path.join(pkg, 'node_modules', '@types'), {recursive: true});
414
+ fs.symlinkSync(
415
+ path.dirname(require.resolve('@types/react/package.json')),
416
+ path.join(pkg, 'node_modules', '@types', 'react'),
417
+ 'dir',
418
+ );
419
+ const keys = component(
420
+ 'Keys',
421
+ [
422
+ "import * as React from 'react';",
423
+ "type Key = 'a' | 'b';",
424
+ 'export function Keys(props: {onKey?: (event: KeyboardEvent) => void; rowKey?: string | number | bigint; pick?: Key; native?: React.Key}) { return null; }',
425
+ ].join('\n'),
426
+ {
427
+ props: [
428
+ prop('onKey', '(event: React.KeyboardEvent) => void'),
429
+ prop('rowKey', 'React.Key'),
430
+ prop('pick', 'React.Key'),
431
+ prop('native', 'React.Key'),
432
+ ],
433
+ },
434
+ );
435
+ // React's global namespace, with no import.
436
+ const global = component(
437
+ 'GlobalKeys',
438
+ 'export function GlobalKeys(props: {rowKey?: string | number | bigint}) { return null; }',
439
+ {props: [prop('rowKey', 'React.Key')]},
440
+ );
441
+
442
+ const report = await compare([keys, global]);
443
+
444
+ // The DOM's `KeyboardEvent` is not React's, and the file's own `Key` is
445
+ // not `React.Key`.
446
+ expect(kinds(report)).toEqual(['Keys.pick type_mismatch']);
447
+ expect(report.typesNotCompared).toEqual([
448
+ expect.objectContaining({component: 'Keys', props: ['onKey']}),
449
+ ]);
450
+ expect(report.typesCompared).toBe(4);
451
+ });
452
+
453
+ it('judges primitives, literals, arrays, string enums, and names for them exactly, in any order', async () => {
454
+ withTypeScript();
455
+ const card = component(
456
+ 'Card',
457
+ [
458
+ "type Tone = 'neutral' | 'info';",
459
+ "export enum Color { Red = 'red', Blue = 'blue' } export enum Hue { Red = 'red', Blue = 'blue' }",
460
+ 'export interface CardProps {',
461
+ ' label: string;',
462
+ ' tone?: Tone;',
463
+ ' items?: ReadonlyArray<string>;',
464
+ ' isOpen?: boolean;',
465
+ " size?: 'sm' | 'md' | number;",
466
+ ' color?: Color;',
467
+ ' count?: number;',
468
+ ' ids?: string[];',
469
+ ' hue?: Color;',
470
+ " shade?: Color; shades?: Color[]; pick?: 'red' | 'blue';",
471
+ '}',
472
+ 'export function Card(props: CardProps) { return null; }',
473
+ ].join('\n'),
474
+ {
475
+ props: [
476
+ prop('label', 'string', {required: true}),
477
+ prop('tone', '\'info\' | "neutral"'),
478
+ prop('items', 'Array<string>'),
479
+ prop('isOpen', 'true | false'),
480
+ prop('size', "number | 'md' | 'sm'"),
481
+ prop('color', 'Color'),
482
+ prop('count', 'string'),
483
+ prop('ids', 'number[]'),
484
+ prop('hue', "'red' | 'blue'"),
485
+ prop('shade', 'Hue'),
486
+ prop('shades', 'Hue[]'),
487
+ prop('pick', 'Color'),
488
+ ],
489
+ },
490
+ );
491
+
492
+ const report = await compare([card]);
493
+
494
+ // TypeScript takes neither `'red'` nor another enum's `Hue.Red` where the
495
+ // enum `Color` is expected, and `Color` does not take the `'red'` `pick` does.
496
+ expect(kinds(report)).toEqual([
497
+ 'Card.count type_mismatch',
498
+ 'Card.hue type_mismatch',
499
+ 'Card.ids type_mismatch',
500
+ 'Card.pick type_mismatch',
501
+ 'Card.shade type_mismatch',
502
+ 'Card.shades type_mismatch',
503
+ ]);
504
+ expect(report.typesCompared).toBe(12);
505
+ expect(report.typesNotCompared).toEqual([]);
506
+ });
507
+
508
+ it('reads a union the way TypeScript reduces it, and a default after `=` as no alias', async () => {
509
+ withTypeScript();
510
+ const icon = component(
511
+ 'Icon',
512
+ [
513
+ "type IconName = 'add' | 'close';",
514
+ "export enum Color { Red = 'red', Blue = 'blue' }",
515
+ 'type Step = 1 | 2;',
516
+ "type Size = 'sm' | 'md' | 'lg';",
517
+ 'export interface IconProps {',
518
+ ' icon?: IconName | string;',
519
+ ' color?: Color | string;',
520
+ ' step?: Step | number;',
521
+ ' isOpen?: boolean;',
522
+ ' icons?: string[];',
523
+ ' size?: Size;',
524
+ ' limit?: 1000 | 2000;',
525
+ ' width?: number | string;',
526
+ '}',
527
+ 'export function Icon(props: IconProps) { return null; }',
528
+ ].join('\n'),
529
+ {
530
+ props: [
531
+ prop('icon', 'IconName | string'),
532
+ prop('color', 'Color | string'),
533
+ prop('step', 'Step | number'),
534
+ prop('isOpen', 'boolean = false'),
535
+ prop('icons', '(IconName | string)[]'),
536
+ prop('size', "Size = 'md'"),
537
+ prop('limit', '1_000 | 2_000'),
538
+ prop('width', 'number (or a CSS length)'),
539
+ ],
540
+ },
541
+ );
542
+
543
+ const report = await compare([icon]);
544
+
545
+ expect(report.findings).toEqual([]);
546
+ expect(report.typesCompared).toBe(7);
547
+ });
548
+
549
+ it('sets aside null and undefined when the project has no strict null checks', async () => {
550
+ withTypeScript();
551
+ fs.writeFileSync(
552
+ path.join(pkg, 'tsconfig.json'),
553
+ JSON.stringify({compilerOptions: {strict: false, jsx: 'react-jsx'}}),
554
+ );
555
+ const loose = component(
556
+ 'Loose',
557
+ [
558
+ 'type MaybeNum = number | null;',
559
+ 'export function Loose(props: {count?: MaybeNum; counts?: MaybeNum[]}) { return null; }',
560
+ ].join('\n'),
561
+ {
562
+ props: [
563
+ prop('count', 'number | null'),
564
+ prop('counts', '(number | null)[]'),
565
+ ],
566
+ },
567
+ );
568
+
569
+ const report = await compare([loose]);
570
+
571
+ expect(report.findings).toEqual([]);
572
+ expect(report.typesCompared).toBe(2);
573
+ });
574
+
575
+ it('respects exactOptionalPropertyTypes: an optional prop takes undefined only when its type says so', async () => {
576
+ withTypeScript();
577
+ const tsconfig = path.join(pkg, 'tsconfig.json');
578
+ const config = JSON.parse(fs.readFileSync(tsconfig, 'utf8'));
579
+ config.compilerOptions.exactOptionalPropertyTypes = true;
580
+ fs.writeFileSync(tsconfig, JSON.stringify(config));
581
+ const exact = component(
582
+ 'Exact',
583
+ "type MaybeTone = 'a' | 'b' | 'c' | undefined;\nexport function Exact(props: {size?: 'sm' | 'md'; tone?: 'a' | undefined; mode?: 'x'; status?: {tone?: 'a' | 'b' | undefined; mode?: 'x'}; labels?: Record<string, string | undefined>; hue?: 'a' | 'b' | undefined; t6?: MaybeTone; [key: `x${string}`]: string | undefined}) { return null; }\n",
584
+ {
585
+ props: [
586
+ prop('size', "'sm' | 'md' | undefined"),
587
+ prop('tone', "'a' | undefined"),
588
+ prop('mode', "'x'"),
589
+ prop('status.tone', "'a' | 'b'"),
590
+ prop('status.mode', "'x'"),
591
+ prop('labels.close', 'string'),
592
+ prop('xTone', 'string'),
593
+ prop('hue', "'a' | 'b'"),
594
+ prop('t6', "'a' | 'b' | 'c'"),
595
+ ],
596
+ },
597
+ );
598
+ const variant = component(
599
+ 'Variant',
600
+ "export function Variant(props: {kind: 'a'; size?: 'sm' | undefined} | {kind: 'b'; size?: 'md' | undefined}) { return null; }\n",
601
+ {
602
+ props: [
603
+ prop('kind', "'a' | 'b'", {required: true}),
604
+ prop('size', "'sm' | 'md'"),
605
+ ],
606
+ },
607
+ );
608
+
609
+ const report = await compare([exact, variant]);
610
+
611
+ // TypeScript rejects `size={undefined}` here, and takes the others; a
612
+ // field that declares `undefined` takes it too, unlike its doc. A name an
613
+ // index signature takes is held to the index's type the same way as a
614
+ // field or as a prop.
615
+ expect(kinds(report)).toEqual([
616
+ 'Exact.hue type_mismatch',
617
+ 'Exact.size type_mismatch',
618
+ 'Exact.status.tone type_mismatch',
619
+ 'Exact.t6 type_mismatch',
620
+ 'Variant.size type_mismatch',
621
+ ]);
622
+ expect(report.typesCompared).toBe(11);
623
+ // The message shows the `undefined` the code declares, written once or by
624
+ // each member of a union, in the type and in its expansion.
625
+ for (const finding of report.findings.filter(
626
+ f => f.component === 'Variant' || f.prop === 'hue',
627
+ )) {
628
+ expect(finding.codeType).toMatch(/undefined/);
629
+ const declared = finding.message.split('but the code declares ')[1];
630
+ expect(declared).toMatch(/undefined/);
631
+ expect(declared.match(/\((.*)\)\.$/)?.[1] ?? 'undefined').toMatch(
632
+ /undefined/,
633
+ );
634
+ }
635
+ // Through an alias, the expansion shows it.
636
+ expect(report.findings.find(f => f.prop === 't6')?.message).toMatch(
637
+ /declares MaybeTone \(.*undefined.*\)\.$/,
638
+ );
639
+ });
640
+
641
+ it('holds a name an index signature takes, a field a Record takes, and a readonly array to the type TypeScript checks, and lists a field only an index takes and an `a / b` entry whose one type does not split', async () => {
642
+ withTypeScript();
643
+ const held = component(
644
+ 'Held',
645
+ [
646
+ 'export function Held(props: {',
647
+ ' labels?: Record<string, string>;',
648
+ ' tags?: string[];',
649
+ ' ids?: ReadonlyArray<string>;',
650
+ ' keys?: readonly string[];',
651
+ ' grid?: readonly string[][];',
652
+ ' rows?: (readonly string[])[];',
653
+ ' onPick?: (values: readonly string[]) => void;',
654
+ ' onDrop?: (values: readonly string[]) => void;',
655
+ ' pinned?: {readonly ids: readonly string[]};',
656
+ ' item?: {tags: readonly string[]};',
657
+ ' mix?: string[] | readonly number[];',
658
+ ' min?: number; max?: number; status?: {a: number};',
659
+ ' [key: `x${string}`]: string; [key: `y${string}`]: {color: string};',
660
+ '}) { return null; }',
661
+ ].join('\n'),
662
+ {
663
+ props: [
664
+ prop('labels.close', 'string | undefined'),
665
+ prop('xTone', 'string | undefined'),
666
+ prop('tags', 'readonly string[]'),
667
+ prop('ids', 'readonly string[]'),
668
+ prop('keys', 'string[]'),
669
+ prop('grid', '(readonly string[])[]'),
670
+ prop('rows', 'readonly string[][]'),
671
+ prop('onPick', '(values: string[]) => void'),
672
+ prop('onDrop', '(values: readonly string[])=>void'),
673
+ prop('pinned', '{readonly ids: readonly string[]}'),
674
+ prop('item', '{tags: string[]}'),
675
+ prop('mix', 'readonly string[] | number[]'),
676
+ prop('min / max', 'number'),
677
+ prop('status.a / status.b', 'number'),
678
+ prop('yTone.color', 'string'),
679
+ ],
680
+ },
681
+ );
682
+
683
+ const report = await compare([held]);
684
+
685
+ // TypeScript passes no `undefined` through an index that does not take
686
+ // it, and no readonly array where a mutable one is taken; a mutable
687
+ // array passes where a readonly one is.
688
+ expect(kinds(report)).toEqual([
689
+ 'Held.labels.close type_mismatch',
690
+ 'Held.status.b not_in_code',
691
+ 'Held.tags type_mismatch',
692
+ 'Held.xTone type_mismatch',
693
+ ]);
694
+ expect(report.typesCompared).toBe(7);
695
+ // `readonly` written as the code writes it matches; written somewhere
696
+ // else, at another depth, in a callback's parameter, in an object's
697
+ // field, or on another union member, it is not placed: listed, never a
698
+ // match, as are a field only an index takes and `a / b` with one type.
699
+ expect(
700
+ report.typesNotCompared.map(e => `${e.reason}: ${e.props.join(' ')}`),
701
+ ).toEqual([
702
+ 'not a form the check judges exactly: grid item max min mix onPick rows status.a yTone.color',
703
+ ]);
704
+ });
705
+
706
+ it('reads strict null checks as the TypeScript version defaults them', async () => {
707
+ withTypeScript();
708
+ fs.writeFileSync(
709
+ path.join(pkg, 'tsconfig.json'),
710
+ JSON.stringify({compilerOptions: {jsx: 'react-jsx'}}),
711
+ );
712
+ // The TypeScript the test links in: 6 checks strict null by default.
713
+ const ts = require(TYPESCRIPT_DIR);
714
+ const strictByDefault = ts.getStrictOptionValue({}, 'strictNullChecks');
715
+ const maybe = component(
716
+ 'Maybe',
717
+ 'export function Maybe(props: {count?: number | null}) { return null; }\n',
718
+ {props: [prop('count', 'number')]},
719
+ );
720
+
721
+ const report = await compare([maybe]);
722
+
723
+ expect(kinds(report)).toEqual(
724
+ strictByDefault ? ['Maybe.count type_mismatch'] : [],
725
+ );
726
+ });
727
+
728
+ it("lists a name two index signatures take and a field documented with one variant's type, and reads a default for one union member", async () => {
729
+ withTypeScript();
730
+ const pick = component(
731
+ 'Pick',
732
+ [
733
+ "export enum SizeE { Sm = 'sm', Md = 'md' }",
734
+ 'type Sel = {kind: "single"; value: string} | {kind: "multi"; value: string[]};',
735
+ 'export type PickProps = {',
736
+ ' sizeE?: SizeE;',
737
+ ' sel?: Sel;',
738
+ ' tri?: {v: string} | {v: number} | {v: boolean};',
739
+ ' sev?: {v: 1} | {v: 2} | {v: 3} | {v: 4} | {v: 5} | {v: 6} | {v: 7};',
740
+ ' labels?: {close: string} | Record<string, number>;',
741
+ ' [key: `data-${string}`]: string | number | undefined;',
742
+ ' [key: `data-track${string}`]: string | undefined;',
743
+ '} & ({mode: "str"; [key: `x${string}`]: string} | {mode: "num"; [key: `x${string}`]: number});',
744
+ 'export function Pick(props: PickProps) { return null; }',
745
+ ].join('\n'),
746
+ {
747
+ props: [
748
+ prop('sizeE', 'SizeE = SizeE.Md'),
749
+ prop('sel.value', 'string'),
750
+ prop('tri.v', 'string | number'),
751
+ prop('sev.v', '1 | 2'),
752
+ prop('labels.close', 'string | number'),
753
+ prop('data-tracker', 'string'),
754
+ prop('mode', "'str' | 'num'", {required: true}),
755
+ prop('xValue', 'number'),
756
+ ],
757
+ },
758
+ );
759
+
760
+ const report = await compare([pick]);
761
+
762
+ // TypeScript holds `data-tracker` to both signatures that take it, and
763
+ // `xValue` to the one of the member it is passed with; `sel.value`,
764
+ // `tri.v` and `sev.v` (past six variants, never reported) take every
765
+ // variant's type, and `labels.close` is documented with both.
766
+ expect(report.findings).toEqual([]);
767
+ expect(report.typesNotCompared).toEqual([
768
+ expect.objectContaining({
769
+ props: ['data-tracker', 'xValue'],
770
+ reason: 'not a form the check judges exactly',
771
+ }),
772
+ expect.objectContaining({
773
+ props: ['sel.value', 'sev.v', 'tri.v'],
774
+ reason: "the prop's variants give this field different types",
775
+ }),
776
+ ]);
777
+ expect(report.typesCompared).toBe(3);
778
+ });
779
+
780
+ it('reads fields in arrays of unions, a name one member indexes, and names only where the code can mean them', async () => {
781
+ withTypeScript();
782
+ const rows = component(
783
+ 'Rows',
784
+ [
785
+ 'type Items = number[];',
786
+ 'export type RowsProps = {',
787
+ " items?: Array<{kind: 'a'; value: string} | {kind: 'b'; value: number}>;",
788
+ ' list?: Items;',
789
+ " behavior?: 'auto' | 'smooth';",
790
+ ' [key: `data-${string}`]: string | undefined;',
791
+ "} & ({mode: 'fixed'; xSize: number} | ({mode: 'free'} & {[k: `x${string}`]: string}));",
792
+ 'export function Rows(props: RowsProps) { return null; }',
793
+ ].join('\n'),
794
+ {
795
+ props: [
796
+ prop('items[].value', 'string | number'),
797
+ prop('list', 'Items = string[]'),
798
+ prop('behavior', 'ScrollBehavior'),
799
+ prop('data-section', 'string'),
800
+ prop('mode', "'fixed' | 'free'", {required: true}),
801
+ prop('xSize', 'number | string'),
802
+ ],
803
+ },
804
+ );
805
+
806
+ const report = await compare([rows]);
807
+
808
+ // `ScrollBehavior` is the DOM's, which the code never names.
809
+ expect(kinds(report)).toEqual(['Rows.list type_mismatch']);
810
+ expect(report.typesNotCompared).toEqual([
811
+ expect.objectContaining({props: ['behavior']}),
812
+ ]);
813
+ });
814
+
815
+ it('judges a prop two intersected types declare by the type they narrow it to, and undefined on a required prop', async () => {
816
+ withTypeScript();
817
+ const narrow = component(
818
+ 'Narrow',
819
+ [
820
+ "type A = {size?: 'sm'; tone?: 'a' | 'b'};",
821
+ "type B = {size?: 'sm' | 'md'; tone?: 'a' | 'b'};",
822
+ 'export type NarrowProps = B & A & {isOpen: boolean; label: string | undefined};',
823
+ 'export function Narrow(props: NarrowProps) { return null; }',
824
+ ].join('\n'),
825
+ {
826
+ props: [
827
+ prop('size', "'sm' | 'md'"),
828
+ prop('tone', "'b' | 'a'"),
829
+ prop('isOpen', 'boolean | undefined', {required: true}),
830
+ prop('label', 'string', {required: true}),
831
+ ],
832
+ },
833
+ );
834
+
835
+ const report = await compare([narrow]);
836
+
837
+ // TypeScript takes only 'sm' for `size`, rejects `isOpen={undefined}`,
838
+ // and accepts `label={undefined}`.
839
+ expect(kinds(report)).toEqual([
840
+ 'Narrow.isOpen type_mismatch',
841
+ 'Narrow.label type_mismatch',
842
+ 'Narrow.size type_mismatch',
843
+ ]);
844
+ expect(report.findings.find(f => f.prop === 'size')?.codeType).toBe("'sm'");
845
+ });
846
+
847
+ it('lists every other form of type as not compared, never as a mismatch', async () => {
848
+ withTypeScript();
849
+ const thread = component(
850
+ 'Thread',
851
+ [
852
+ 'interface IconShape { path: string }',
853
+ 'type MaybePromise<T> = T | Promise<T>;',
854
+ 'type Parts = {a?: string; b?: number};',
855
+ "type A = {a: string}; type B = {b: number}; enum Color { Red = 'red' } namespace Theme { export enum Color { Red = 'red' } }",
856
+ 'export interface ThreadProps {',
857
+ ' onSubmit?: (text: string, files?: File[]) => MaybePromise<void>;',
858
+ ' onPick?: (value: string, index: number) => void;',
859
+ ' item?: A & B;',
860
+ ' parts?: Partial<Parts>;',
861
+ ' key2?: keyof (A & B);',
862
+ ' key3?: keyof (A & B);',
863
+ " target?: '_self' | '_blank' | (string & {});",
864
+ ' range?: [number, number?];',
865
+ ' labels?: Record<string, string>;',
866
+ ' icon?: IconShape;',
867
+ ' data?: any;',
868
+ ' extra?: unknown; tint?: Theme.Color;',
869
+ '}',
870
+ 'export function Thread(props: ThreadProps) { return null; }',
871
+ ].join('\n'),
872
+ {
873
+ props: [
874
+ prop('onSubmit', '(text: string) => Promise<number>'),
875
+ prop('onPick', '(value: string, index: number) => void'),
876
+ prop('item', '{a: string; b: number}'),
877
+ prop('parts', 'Parts'),
878
+ prop('key2', 'keyof (A & B)'),
879
+ prop('key3', "'a' | 'b'"),
880
+ prop('target', "'_self' | '_blank'"),
881
+ prop('range', '[number]'),
882
+ prop('labels', '{[key: string]: number}'),
883
+ prop('icon', 'IconName'),
884
+ prop('data', 'string'),
885
+ prop('data.id', 'string'),
886
+ prop('extra.kind', 'string'),
887
+ prop('tint', 'Color'),
888
+ ],
889
+ },
890
+ );
891
+
892
+ const report = await compare([thread]);
893
+
894
+ expect(report.findings).toEqual([]);
895
+ // Written exactly as the source writes them, or a literal union the
896
+ // code's type resolves to (`keyof (A & B)` is `'a' | 'b'`).
897
+ expect(report.typesCompared).toBe(3);
898
+ expect(
899
+ report.typesNotCompared.map(e => `${e.reason}: ${e.props.join(' ')}`),
900
+ ).toEqual([
901
+ 'not a form the check judges exactly: data data.id extra.kind icon item labels onSubmit parts range target tint',
902
+ ]);
903
+ });
904
+
905
+ it("compares a documented field's type, and a name an index signature takes, with the type the code gives it", async () => {
906
+ withTypeScript();
907
+ const note = component(
908
+ 'Note',
909
+ [
910
+ 'export interface NoteProps {',
911
+ " status?: {type: 'error' | 'warning'; message?: string};",
912
+ ' items?: Array<{id: string; label: string}>;',
913
+ ' labels?: Record<string, string>;',
914
+ ' [key: `data-${string}`]: string | undefined;',
915
+ ' [key: `aria-${string}`]: string | number | undefined;',
916
+ '}',
917
+ 'export function Note(props: NoteProps) { return null; }',
918
+ ].join('\n'),
919
+ {
920
+ props: [
921
+ prop('status.type', "'error' | 'info'"),
922
+ prop('items[].label', 'number'),
923
+ prop('labels.open', 'string'),
924
+ prop('labels.close', 'number'),
925
+ prop('data-section', 'string'),
926
+ prop('data-count', 'number'),
927
+ prop('aria-busy', 'boolean'),
928
+ ],
929
+ },
930
+ );
931
+
932
+ const report = await compare([note]);
933
+
934
+ expect(kinds(report)).toEqual([
935
+ 'Note.aria-busy type_mismatch',
936
+ 'Note.data-count type_mismatch',
937
+ 'Note.items[].label type_mismatch',
938
+ 'Note.labels.close type_mismatch',
939
+ 'Note.status.type type_mismatch',
940
+ ]);
941
+ expect(report.typesCompared).toBe(7);
942
+ });
943
+
944
+ it('accepts an alias spelled out beside its name, and reports one whose spelled-out values are stale', async () => {
945
+ withTypeScript();
946
+ const links = ["'link' | 'token'", "'link' | 'token' | 'button'"].map(
947
+ (values, i) =>
948
+ component(
949
+ `Link${i}`,
950
+ `export type LinkVariant = ${values};\nexport function Link${i}(props: {variant?: LinkVariant; tone?: LinkVariant}) { return null; }`,
951
+ {
952
+ props: [
953
+ prop('variant', "LinkVariant = 'link' | 'token'"),
954
+ prop('tone', "LinkVariant ('link' | 'token')"),
955
+ ],
956
+ },
957
+ ),
958
+ );
959
+
960
+ expect(kinds(await compare(links))).toEqual([
961
+ 'Link1.tone type_mismatch',
962
+ 'Link1.variant type_mismatch',
963
+ ]);
964
+ });
965
+
966
+ it("reads an optional prop documented with `| undefined` as the code reads it, and names an alias's members in the source's order", async () => {
967
+ withTypeScript();
968
+ const badge = component(
969
+ 'Badge',
970
+ [
971
+ "type Tone = 'neutral' | 'info';",
972
+ 'export function Badge(props: {tone?: Tone}) { return null; }',
973
+ ].join('\n'),
974
+ {props: [prop('tone', "'neutral' | 'info' | undefined")]},
975
+ );
976
+ const chip = component(
977
+ 'Chip',
978
+ "export function Chip(props: {tone?: 'a' | 'b' | undefined}) { return null; }\n",
979
+ {props: [prop('tone', "'a' | 'c'")]},
980
+ );
981
+
982
+ // The same literals, made first in another order, would set the
983
+ // compiler's own order.
984
+ const status = component(
985
+ 'Status',
986
+ [
987
+ "type Other = 'detached' | 'attached' | 'tooltip';",
988
+ 'export type Unrelated = Other;',
989
+ "type StatusVariant = 'tooltip' | 'attached' | 'detached';",
990
+ 'export function Status(props: {variant?: StatusVariant}) { return null; }',
991
+ ].join('\n'),
992
+ {props: [prop('variant', "'attached' | 'detached'")]},
993
+ );
994
+
995
+ expect(kinds(await compare([badge]))).toEqual([]);
996
+ // The optional prop's `undefined` the expansion sets aside is shown once.
997
+ const [finding] = (await compare([chip])).findings;
998
+ expect(finding.message).toMatch(/declares 'a' \| 'b' \| undefined\.$/);
999
+ expect((await compare([status])).findings.map(f => f.message)).toEqual([
1000
+ `"variant" is documented as 'attached' | 'detached', but the code declares StatusVariant ('tooltip' | 'attached' | 'detached').`,
1001
+ ]);
1002
+ });
1003
+
1004
+ it('judges a className the package declares, and lists standard props and props from other packages as not compared', async () => {
1005
+ withTypeScript();
1006
+ const base = path.join(pkg, 'node_modules', '@acme', 'base');
1007
+ fs.mkdirSync(base, {recursive: true});
1008
+ fs.writeFileSync(
1009
+ path.join(base, 'package.json'),
1010
+ JSON.stringify({name: '@acme/base', types: 'index.d.ts'}),
1011
+ );
1012
+ fs.writeFileSync(
1013
+ path.join(base, 'index.d.ts'),
1014
+ 'export interface BaseProps { style?: object; id?: string; tone?: {level: number}; [key: `data-${string}`]: string | undefined }\n',
1015
+ );
1016
+ // A name only another package's index signature, or a mapped type's,
1017
+ // takes is not judged either.
1018
+ const card = component(
1019
+ 'Card',
1020
+ "import type {BaseProps} from '@acme/base';\nexport function Card(props: BaseProps & {className?: string} & Record<`aria-${string}`, string>) { return null; }\n",
1021
+ {
1022
+ props: [
1023
+ prop('className', 'number'),
1024
+ prop('style', 'string'),
1025
+ prop('id', 'number'),
1026
+ prop('tone.level', 'string'),
1027
+ prop('tone.missing', 'string'),
1028
+ prop('data-variant', "'solid' | 'outline'"),
1029
+ prop('aria-note', 'number'),
1030
+ ],
1031
+ },
1032
+ );
1033
+
1034
+ const report = await compare([card]);
1035
+
1036
+ expect(kinds(report)).toEqual([
1037
+ 'Card.className type_mismatch',
1038
+ 'Card.tone.missing not_in_code',
1039
+ ]);
1040
+ expect(report.typesCompared).toBe(1);
1041
+ expect(
1042
+ report.typesNotCompared.map(e => `${e.reason}: ${e.props.join(' ')}`),
1043
+ ).toEqual([
1044
+ 'declared outside the package, not compared: data-variant id tone.level',
1045
+ 'not a form the check judges exactly: aria-note',
1046
+ 'standard prop, not compared: style',
1047
+ ]);
1048
+ });
1049
+
1050
+ it('does not ask a doc for props from other packages, or for the props the authoring guidance skips', async () => {
1051
+ expect(PROPS_NOT_DOCUMENTED).toEqual([
1052
+ 'className',
1053
+ 'style',
1054
+ 'xstyle',
1055
+ 'data-testid',
1056
+ 'ref',
1057
+ 'key',
1058
+ ]);
1059
+ withTypeScript();
1060
+ const base = path.join(pkg, 'node_modules', '@acme', 'base');
1061
+ fs.mkdirSync(base, {recursive: true});
1062
+ fs.writeFileSync(
1063
+ path.join(base, 'package.json'),
1064
+ JSON.stringify({name: '@acme/base', types: 'index.d.ts'}),
1065
+ );
1066
+ fs.writeFileSync(
1067
+ path.join(base, 'index.d.ts'),
1068
+ [
1069
+ 'export interface BaseProps { id?: string; onBlur?: () => void }',
1070
+ "export type Sizes = Partial<Record<'sm' | 'md', string>>;",
1071
+ "export interface Spacing extends Partial<Record<'gap', number>> {}",
1072
+ "export type Tone = 'info' | 'danger';",
1073
+ 'export type Flags<K extends string> = {[P in K]?: boolean};',
1074
+ 'export type FC<P> = (props: P) => null;',
1075
+ "export type Responsive<T> = {[K in 'sm' | 'lg']?: T};",
1076
+ "export type Breakpoint = 'sm' | 'lg';",
1077
+ 'export declare const BREAKPOINTS: readonly Breakpoint[];',
1078
+ 'export declare function memo<T extends (p: any) => null>(c: T): {(p: Parameters<T>[0]): null; $$typeof: symbol; type: T};',
1079
+ 'export declare function forwardRef<P>(render: (props: P, ref: unknown) => null): (props: P) => null;',
1080
+ ].join('\n'),
1081
+ );
1082
+ const panel = component(
1083
+ 'Panel',
1084
+ [
1085
+ "import type {BaseProps} from '@acme/base';",
1086
+ 'export interface PanelProps extends BaseProps {',
1087
+ ' title?: string;',
1088
+ ' className?: string;',
1089
+ ' style?: {color?: string};',
1090
+ ' xstyle?: unknown;',
1091
+ " 'data-testid'?: string;",
1092
+ ' ref?: unknown;',
1093
+ ' key?: string;',
1094
+ ' /** @deprecated Use `title`. */',
1095
+ ' heading?: string;',
1096
+ ' /** @internal */',
1097
+ ' debugName?: string;',
1098
+ '}',
1099
+ 'export function Panel(props: PanelProps) { return null; }',
1100
+ ].join('\n'),
1101
+ {props: [prop('title', 'string'), prop('id', 'number')]},
1102
+ );
1103
+
1104
+ // A prop a mapped type builds is the package's own when its key traces
1105
+ // to the package's source: through aliases, `Exclude`, a generic's
1106
+ // arguments, and a component `memo` or `forwardRef` wraps. A key another
1107
+ // package defines, behind an alias or not, is that package's, and one
1108
+ // the check can't trace is listed, not asked for.
1109
+ const mapped = Object.entries({
1110
+ Styled:
1111
+ '(props: {[K in StyleKey]?: BaseProps[K]} & Partial<Record<StyleKey, number>>)',
1112
+ Inlined: '(props: {[K in keyof BaseProps]?: BaseProps[K]})',
1113
+ Spread: '(props: Responsive<number>)',
1114
+ Memoed: ' = memo(Inner)',
1115
+ Forwarded: ' = forwardRef(Impl)',
1116
+ Excluded: "(props: {[K in Exclude<'a' | 'b' | 'c', 'c'>]?: string})",
1117
+ Templated: "(props: {[K in `on${Capitalize<'open'>}`]?: () => void})",
1118
+ // Another package's keys through an own interface's property, an
1119
+ // `infer`, or a constant bound to its value stay that package's.
1120
+ Indexed: "(props: {[K in Opts['size']]?: number})",
1121
+ Inferred: '(props: {[K in KeysOf<Record<Breakpoint, 1>>]?: number})',
1122
+ Rebound: '(props: {[K in (typeof SIZES)[number]]?: number})',
1123
+ Inline: "(props: {[K in 'a' | 'b']?: string})",
1124
+ Recorded: "(props: Partial<Record<'a' | 'b', string>>)",
1125
+ Remapped: "(props: {[K in 'open' as `on${Capitalize<K>}`]?: () => void})",
1126
+ Toned:
1127
+ "(props: Partial<Record<Tone | 'xl', boolean>> & {[K in Tone]?: string} & Partial<Record<keyof BaseProps, string>> & {[K in keyof BaseProps as `x${Capitalize<K>}`]?: boolean})",
1128
+ Flagged: "(props: Flags<'bold'>)",
1129
+ Extended: '(props: ExtendedProps)',
1130
+ Sized: '(props: Spaced & Partial<Sizes>)',
1131
+ }).map(([name, props]) =>
1132
+ component(
1133
+ name,
1134
+ [
1135
+ "import {type BaseProps, type Sizes, type Spacing, type Tone, type Flags, type FC, type Responsive, type Breakpoint, BREAKPOINTS, memo, forwardRef} from '@acme/base';",
1136
+ "interface ExtendedProps extends Partial<Record<'a', string>> {}",
1137
+ 'interface Spaced extends Spacing {}',
1138
+ 'type StyleKey = keyof BaseProps;',
1139
+ 'interface Opts { size: Breakpoint }',
1140
+ 'type KeysOf<T> = T extends Record<infer K, unknown> ? K : never;',
1141
+ 'const SIZES = BREAKPOINTS;',
1142
+ "function Inner(props: {[K in 'top' | 'bottom']?: number}) { return null; }",
1143
+ "function Impl(props: Partial<Record<'left', number>>, ref: unknown) { return null; }",
1144
+ props.startsWith(' =')
1145
+ ? `export const ${name}${props};`
1146
+ : `export function ${name}${props} { return null; }`,
1147
+ `export const Typed: FC<Partial<Record<'a', string>>> = () => null;`,
1148
+ ].join('\n'),
1149
+ {props: []},
1150
+ ),
1151
+ );
1152
+
1153
+ // A deprecated prop is still a prop the component accepts.
1154
+ const typed = {...mapped[0], name: 'Typed'};
1155
+ const report = await compare([panel, ...mapped, typed]);
1156
+ expect(kinds(report)).toEqual([
1157
+ 'Excluded.a not_in_doc',
1158
+ 'Excluded.b not_in_doc',
1159
+ 'Extended.a not_in_doc',
1160
+ 'Flagged.bold not_in_doc',
1161
+ 'Forwarded.left not_in_doc',
1162
+ 'Inline.a not_in_doc',
1163
+ 'Inline.b not_in_doc',
1164
+ 'Memoed.bottom not_in_doc',
1165
+ 'Memoed.top not_in_doc',
1166
+ 'Panel.heading not_in_doc',
1167
+ 'Recorded.a not_in_doc',
1168
+ 'Recorded.b not_in_doc',
1169
+ 'Remapped.onOpen not_in_doc',
1170
+ 'Toned.xl not_in_doc',
1171
+ 'Typed.a not_in_doc',
1172
+ ]);
1173
+ expect(report.propsNotChecked).toEqual([
1174
+ expect.objectContaining({component: 'Templated', props: ['onOpen']}),
1175
+ expect.objectContaining({component: 'Inferred', props: ['lg', 'sm']}),
1176
+ ]);
1177
+ // The alias and the inline form give the same answer: nothing asked.
1178
+ for (const name of ['Styled', 'Inlined']) {
1179
+ expect(kinds(report).filter(kind => kind.startsWith(name))).toEqual([]);
1180
+ }
1181
+ });
1182
+
1183
+ it('reads every overload, one without props included, matches a doc that describes one member of a discriminated union, reads a prop only some members require as optional, and holds every prop one entry names to its `required`', async () => {
1184
+ withTypeScript();
1185
+ const picker = component(
1186
+ 'Picker',
1187
+ `type Single = {isMulti?: false; value: string; onChange: (value: string) => void};
1188
+ type Multi = {isMulti: true; value: string[]; onChange: (value: string[]) => void};
1189
+ export type PickerProps = (Single | Multi) & {label: string};
1190
+ export function Picker(props: PickerProps) { return null; }`,
1191
+ {
1192
+ props: [
1193
+ prop('label', 'string', {required: true}),
1194
+ prop('isMulti', 'boolean'),
1195
+ prop('value', 'string', {required: true}),
1196
+ prop('onChange', '(value: string) => void', {required: true}),
1197
+ ],
1198
+ },
1199
+ );
1200
+
1201
+ const select = component(
1202
+ 'Select',
1203
+ `type Single = {multiple?: false; value?: string};
1204
+ type Multi = {multiple: true; value?: string[]; max?: number};
1205
+ export function Select(props: Single): null;
1206
+ export function Select(props: Multi): null;
1207
+ export function Select(props: Single | Multi) { return null; }`,
1208
+ {
1209
+ props: [
1210
+ prop('multiple', 'boolean'),
1211
+ prop('value', 'string | string[]'),
1212
+ prop('max', 'number'),
1213
+ ],
1214
+ },
1215
+ );
1216
+ // An overload that takes no props makes every prop optional; a typed
1217
+ // props type beside a `Record<string, any>` or a mapped `any` index is
1218
+ // compared as it is beside an inline `[key: string]: any`.
1219
+ const propless = component(
1220
+ 'Propless',
1221
+ 'export function Propless(): null;\nexport function Propless(props: {label: string}): null;\nexport function Propless(props?: {label: string}) { return null; }',
1222
+ {props: [prop('label', 'string', {required: true})]},
1223
+ );
1224
+ const open = [
1225
+ 'Record<string, any>',
1226
+ 'Record<`data-${string}`, any>',
1227
+ '{[K in string]?: any}',
1228
+ ].map((index, i) =>
1229
+ component(
1230
+ `Open${i}`,
1231
+ `export function Open${i}(props: {label?: string; size?: number} & ${index}) { return null; }`,
1232
+ {props: [prop('label', 'string')]},
1233
+ ),
1234
+ );
1235
+
1236
+ // Required in one variant only: a caller may leave it out, so optional.
1237
+ const menu = component(
1238
+ 'Menu',
1239
+ 'export function Menu(props: {items: string[]} | {alt: string}) { return null; }',
1240
+ {props: [prop('items', 'X[]', {required: true}), prop('alt', 'X')]},
1241
+ );
1242
+ // One entry's `required` is a claim about every prop it names.
1243
+ const pairs = ['label: string', 'label?: string'].map((label, i) =>
1244
+ component(
1245
+ `Pair${i}`,
1246
+ `export function Pair${i}(props: {value: string; ${label}}) { return null; }`,
1247
+ {props: [prop('value / label', 'string / string', {required: true})]},
1248
+ ),
1249
+ );
1250
+ // Typed `any` through the package's own alias of `Omit`, written in the
1251
+ // signature, as a named alias, or as an interface's base.
1252
+ const without = ['Without<any>', 'P', 'Q', 'R'].map((props, i) =>
1253
+ component(
1254
+ `Without${i}`,
1255
+ `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; }`,
1256
+ {props: [prop('label', 'string')]},
1257
+ ),
1258
+ );
1259
+
1260
+ const tables = [
1261
+ picker,
1262
+ select,
1263
+ propless,
1264
+ ...open,
1265
+ menu,
1266
+ ...pairs,
1267
+ ...without,
1268
+ ];
1269
+ const report = await compare(tables);
1270
+
1271
+ expect(kinds(report)).toEqual([
1272
+ 'Menu.items required_mismatch',
1273
+ 'Open0.size not_in_doc',
1274
+ 'Open1.size not_in_doc',
1275
+ 'Open2.size not_in_doc',
1276
+ 'Pair1.label required_mismatch',
1277
+ 'Propless.label required_mismatch',
1278
+ ]);
1279
+ expect(report.components).toBe(9);
1280
+ expect(report.propsNotChecked).toBeUndefined();
1281
+ expect(report.unchecked.map(entry => entry.reason)).toEqual(
1282
+ [0, 1, 2, 3].map(i =>
1283
+ expect.stringMatching(`^Without${i}'s props are typed`),
1284
+ ),
1285
+ );
1286
+ });
1287
+
1288
+ it('shows a method prop the doc leaves out with its function type, optional or not', async () => {
1289
+ withTypeScript();
1290
+ const picker = component(
1291
+ 'Picker',
1292
+ `export interface PickerProps {
1293
+ label?: string;
1294
+ onPick(value: string, index: number): void;
1295
+ onClear?(reason: string): void;
1296
+ get size(): number;
1297
+ }
1298
+ export function Picker(props: PickerProps) { return null; }`,
1299
+ {props: [prop('label', 'string')]},
1300
+ );
1301
+
1302
+ const report = await compare([picker]);
1303
+
1304
+ expect(
1305
+ report.findings.map(f => `${f.prop} ${f.kind}: ${f.codeType}`),
1306
+ ).toEqual([
1307
+ 'onClear not_in_doc: (reason: string) => void',
1308
+ 'onPick not_in_doc: (value: string, index: number) => void',
1309
+ 'size not_in_doc: number',
1310
+ ]);
1311
+ });
1312
+
1313
+ it("prints a union's members in the order the source declares them", async () => {
1314
+ withTypeScript();
1315
+ // The same literals, made first in another order, would set the
1316
+ // compiler's own order.
1317
+ const ordered = component(
1318
+ 'Ordered',
1319
+ [
1320
+ "type Other = 'md' | 'sm' | 'lg';",
1321
+ 'export type Unrelated = Other;',
1322
+ "type Small = 'lg' | 'sm';",
1323
+ "export function Ordered(props: {size?: Small} | {size?: 'md'; label?: string}) { return null; }",
1324
+ ].join('\n'),
1325
+ {props: [prop('label', 'string')]},
1326
+ );
1327
+
1328
+ const report = await compare([ordered]);
1329
+
1330
+ expect(
1331
+ report.findings.find(finding => finding.prop === 'size')?.codeType,
1332
+ ).toBe("'lg' | 'sm' | 'md'");
1333
+ });
1334
+
1335
+ it("reads a prop that the package's defaultProps fills as optional", async () => {
1336
+ withTypeScript();
1337
+ // A library type that declares an optional `defaultProps`, as React's
1338
+ // component types do, fills nothing; without strict null checks it reads
1339
+ // as filling every prop.
1340
+ const lib = path.join(pkg, 'node_modules', '@acme', 'lib');
1341
+ fs.mkdirSync(lib, {recursive: true});
1342
+ fs.writeFileSync(
1343
+ path.join(lib, 'package.json'),
1344
+ JSON.stringify({name: '@acme/lib', types: 'index.d.ts'}),
1345
+ );
1346
+ fs.writeFileSync(
1347
+ path.join(lib, 'index.d.ts'),
1348
+ `export interface Kind<P> { new (props: P): {}; defaultProps?: Partial<P> }
1349
+ export declare function memo<T extends (p: any) => any>(c: T): {(p: Parameters<T>[0]): null; $$typeof: symbol; type: T};
1350
+ export declare function lazy<T extends (p: any) => any>(f: () => Promise<{default: T}>): {(p: Parameters<T>[0]): null; $$typeof: symbol; _result: T};\n`,
1351
+ );
1352
+ fs.writeFileSync(
1353
+ path.join(pkg, 'tsconfig.json'),
1354
+ JSON.stringify({compilerOptions: {strict: false, jsx: 'react-jsx'}}),
1355
+ );
1356
+ const source = `import {type Kind, lazy, memo} from '@acme/lib';
1357
+ declare class Component<P> { constructor(props: P); props: P }
1358
+ export class Klass extends Component<{label: string; size: number}> {
1359
+ static defaultProps = {size: 1};
1360
+ }
1361
+ export function Fn(props: {label: string; size: number}) { return null; }
1362
+ Fn.defaultProps = {size: 1};
1363
+ export function Gn(props: {label: string; size: number}) { return null; }
1364
+ Gn.defaultProps = {size: 1};
1365
+ export declare const Typed: Kind<{label: string; size: number}>;
1366
+ export const Memoed = memo(Fn);
1367
+ export const Lazied = lazy(() => Promise.resolve({default: Fn}));
1368
+ export const Thrice = memo(memo(memo(Fn)));
1369
+ export class Loose extends Component<{label: string; size: number}> { static defaultProps: any = {}; }`;
1370
+ const props = [
1371
+ prop('label', 'string'),
1372
+ prop('size', 'number', {required: true}),
1373
+ ];
1374
+
1375
+ const report = await compare([
1376
+ ...'Klass Fn Typed Memoed Lazied Thrice Loose'
1377
+ .split(' ')
1378
+ .map(name => component(name, source, {props})),
1379
+ component('Gn', source, {
1380
+ props: [prop('label', 'string'), prop('size', 'number')],
1381
+ }),
1382
+ ]);
1383
+
1384
+ // JSX callers may leave out a prop defaultProps fills (for `memo` and
1385
+ // `lazy`, the wrapped component's, two layers deep as React's types read
1386
+ // it; `any` fills every prop), so a doc that marks it required disagrees
1387
+ // with the code, and one that doesn't agrees.
1388
+ expect(kinds(report)).toEqual([
1389
+ 'Fn.label required_mismatch',
1390
+ 'Fn.size required_mismatch',
1391
+ 'Gn.label required_mismatch',
1392
+ 'Klass.label required_mismatch',
1393
+ 'Klass.size required_mismatch',
1394
+ 'Lazied.label required_mismatch',
1395
+ 'Lazied.size required_mismatch',
1396
+ 'Loose.size required_mismatch',
1397
+ 'Memoed.label required_mismatch',
1398
+ 'Memoed.size required_mismatch',
1399
+ 'Thrice.label required_mismatch',
1400
+ 'Typed.label required_mismatch',
1401
+ ]);
1402
+ expect(
1403
+ report.findings.find(f => f.component === 'Fn' && f.prop === 'size'),
1404
+ ).toMatchObject({docRequired: true, codeRequired: false});
1405
+ });
1406
+
1407
+ it('lists each component whose props it cannot read as not compared', async () => {
1408
+ withTypeScript();
1409
+ const tsconfig = path.join(pkg, 'tsconfig.json');
1410
+ const config = JSON.parse(fs.readFileSync(tsconfig, 'utf8'));
1411
+ config.compilerOptions.allowJs = true;
1412
+ fs.writeFileSync(tsconfig, JSON.stringify(config));
1413
+ /** @param {Record<string, string>} sources each component's props types */
1414
+ const missing = sources =>
1415
+ Object.entries(sources).map(([name, types]) =>
1416
+ component(
1417
+ name,
1418
+ `import type {MissingProps} from '@acme/not-installed';\n${types}\nexport function ${name}(props: ${name}Props) { return null; }`,
1419
+ {props: [prop('label', 'string')]},
1420
+ ),
1421
+ );
1422
+ const unread = [
1423
+ ...missing({
1424
+ Ghost:
1425
+ 'export interface GhostProps extends MissingProps { label?: string }',
1426
+ Omitted:
1427
+ "export interface OmittedProps extends Omit<MissingProps, 'title'> { label?: string }",
1428
+ Phantom:
1429
+ 'export type PhantomProps = Partial<MissingProps> & {label?: string};',
1430
+ Named:
1431
+ "type Base = Omit<MissingProps, 'title'>; export interface NamedProps extends Base { label?: string }",
1432
+ Joined:
1433
+ "type Base = Omit<MissingProps, 'title'>; export type JoinedProps = Base & {label?: string};",
1434
+ Bare: "type Base = Omit<MissingProps, 'title'>; export type BareProps = Base;",
1435
+ Mapped:
1436
+ 'export type MappedProps = {[K in keyof MissingProps]?: string};',
1437
+ Prefixed:
1438
+ 'type On<K extends string> = {[P in `on${K}`]?: () => void}; export type PrefixedProps = On<MissingProps> & {label?: string};',
1439
+ Picked:
1440
+ "type Base = Pick<MissingProps, 'title'>; export type PickedProps = Base;",
1441
+ Kept: "type W<T> = T & {label?: string}; export type KeptProps = W<Required<Pick<MissingProps, 'title'>>>;",
1442
+ Nested:
1443
+ "export type NestedProps = Partial<Omit<MissingProps, 'title'>> & {label?: string};",
1444
+ 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 }',
1445
+ }),
1446
+ component(
1447
+ 'Wrapped',
1448
+ `import {forwardRef} from 'react';
1449
+ export const Wrapped = forwardRef<HTMLButtonElement, {label?: string}>((props, ref) => null);`,
1450
+ {props: [prop('label', 'string')]},
1451
+ ),
1452
+ component(
1453
+ 'Classy',
1454
+ "import {MissingBase} from '@acme/not-installed';\nexport class Classy extends MissingBase<{label?: string}> {}\n",
1455
+ {props: [prop('label', 'string')]},
1456
+ ),
1457
+ ];
1458
+ // Each of these is the only reference in its files that does not resolve:
1459
+ // a name its module does not export, a dependency's own missing import, a
1460
+ // missing type package, and a side-effect import.
1461
+ fs.writeFileSync(
1462
+ path.join(pkg, 'components', 'theme.ts'),
1463
+ 'export interface Theme { tone?: string }\n',
1464
+ );
1465
+ const dep = path.join(pkg, 'node_modules', '@acme', 'dep');
1466
+ fs.mkdirSync(dep, {recursive: true});
1467
+ fs.writeFileSync(
1468
+ path.join(dep, 'package.json'),
1469
+ JSON.stringify({name: '@acme/dep', types: 'index.d.ts'}),
1470
+ );
1471
+ fs.writeFileSync(
1472
+ path.join(dep, 'index.d.ts'),
1473
+ "import type {MissingProps} from '@acme/not-installed';\nexport type DepProps = MissingProps & {label?: string};\n",
1474
+ );
1475
+ const references = Object.entries({
1476
+ Unnamed:
1477
+ "import type {Gone} from './theme';\nexport function Unnamed(props: {label?: string; tone?: Gone}) { return null; }\n",
1478
+ Peered:
1479
+ "import type {DepProps} from '@acme/dep';\nexport function Peered(props: DepProps) { return null; }\n",
1480
+ Referenced:
1481
+ '/// <reference types="@acme/not-installed-types" />\nexport function Referenced(props: {label?: string}) { return null; }\n',
1482
+ Effect:
1483
+ "import '@acme/not-installed-effect';\nexport function Effect(props: {label?: string}) { return null; }\n",
1484
+ // A type name declared nowhere, which only `keyof`, a `Record`'s keys
1485
+ // or a template literal key reaches.
1486
+ Omitting:
1487
+ 'export function Omitting(props: Omit<{label: string; size?: number}, keyof NowhereProps>) { return null; }\n',
1488
+ Recorded:
1489
+ 'export function Recorded(props: Partial<Record<keyof NowhereProps, string>>) { return null; }\n',
1490
+ Handlers:
1491
+ 'export function Handlers(props: {[K in `on${NowhereKey}`]?: () => void}) { return null; }\n',
1492
+ Aliased:
1493
+ 'type Keys = keyof NowhereProps;\nexport function Aliased(props: {[K in Keys]?: string}) { return null; }\n',
1494
+ // A value declared nowhere behind a binding, and a property that
1495
+ // doesn't exist: the compiler can only give either its error type.
1496
+ Valued:
1497
+ 'const d = missingValue;\nexport function Valued(props: Omit<{label: string; size?: number}, keyof typeof d>) { return null; }\n',
1498
+ Sized:
1499
+ "interface Theme { colors: {a: string} }\nexport function Sized(props: Partial<Record<keyof Theme['sizes'], string>>) { return null; }\n",
1500
+ // A value the compiler can't type, spread into an object, makes the
1501
+ // object `any`.
1502
+ Spread:
1503
+ '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',
1504
+ Toned:
1505
+ 'const tones = {...undeclaredTones};\nexport function Toned(props: Partial<Record<keyof typeof tones, string>>) { return null; }\n',
1506
+ }).map(([name, source]) =>
1507
+ component(name, source, {props: [prop('label', 'string')]}),
1508
+ );
1509
+ // A name declared nowhere in a JavaScript component's JSDoc, which `tsc`
1510
+ // doesn't report there.
1511
+ const documented = component(
1512
+ 'Documented',
1513
+ '/** @param {Omit<{label: string; size?: number}, keyof NowhereJs>} props */\nexport function Documented(props) { return null; }\n',
1514
+ {props: [prop('label', 'string')]},
1515
+ );
1516
+ const jsx = documented.sourcePath.replace(/\.tsx$/, '.jsx');
1517
+ fs.renameSync(documented.sourcePath, jsx);
1518
+ references.push({...documented, sourcePath: jsx, sources: [jsx]});
1519
+ // A module found without types resolves: its names are `any`.
1520
+ const untypedDir = path.join(pkg, 'node_modules', 'untyped-dep');
1521
+ fs.mkdirSync(untypedDir, {recursive: true});
1522
+ fs.writeFileSync(
1523
+ path.join(untypedDir, 'package.json'),
1524
+ JSON.stringify({name: 'untyped-dep', main: 'index.js'}),
1525
+ );
1526
+ fs.writeFileSync(
1527
+ path.join(untypedDir, 'index.js'),
1528
+ 'export const fmt = text => text;\n',
1529
+ );
1530
+ const untyped = component(
1531
+ 'Untyped',
1532
+ "import {fmt} from 'untyped-dep';\nexport function Untyped(props: {label?: string}) { fmt(props.label); return null; }\n",
1533
+ {props: [prop('label', 'string')]},
1534
+ );
1535
+ const lonely = component(
1536
+ 'Lonely',
1537
+ 'export function Other(props: {label: string}) { return null; }\n',
1538
+ {props: [prop('label', 'string', {required: true})]},
1539
+ );
1540
+ // An `any` index signature the package declares itself is no reason.
1541
+ const open = component(
1542
+ 'Open',
1543
+ 'export function Open(props: {label?: string; [key: string]: any}) { return null; }\n',
1544
+ {props: [prop('label', 'string')]},
1545
+ );
1546
+ // A missing import in the files a component comes from lists it, however
1547
+ // the props reach it: only a member's type, a value's property, a
1548
+ // conditional, `??`, a comma, or an own HOC that maps over `keyof P`.
1549
+ const reached = Object.entries({
1550
+ Member:
1551
+ 'interface Holder<T> { label?: T } export function Member(props: Holder<MissingProps>) { return null; }',
1552
+ Header:
1553
+ 'function Inner(props: {label?: string}) { return null; } const Card = Object.assign(Inner, {Header: Inner}); export const Header = Card.Header;',
1554
+ Chosen:
1555
+ 'function Inner(props: {label?: string}) { return null; } export const Chosen = (globalThis as {x?: 1}).x ? Inner : Inner;',
1556
+ Fallback:
1557
+ 'function Inner(props: {label?: string}) { return null; } export const Fallback = (undefined as typeof Inner | undefined) ?? Inner;',
1558
+ Comma:
1559
+ 'function Inner(props: {label?: string}) { return null; } export const Comma = (0, Inner);',
1560
+ Keyed:
1561
+ '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);',
1562
+ }).map(([name, source]) =>
1563
+ component(
1564
+ name,
1565
+ `import type {MissingProps} from '@acme/not-installed';\n${source}\n`,
1566
+ {props: [prop('label', 'string')]},
1567
+ ),
1568
+ );
1569
+
1570
+ // Props that take names from a type parameter, such as the element a
1571
+ // component renders, accept any name until it is used.
1572
+ const PROPS_OF = [
1573
+ 'interface Intrinsic { div: {title?: string}; span: {lang?: string} }',
1574
+ 'type ElementType = keyof Intrinsic | ((props: any) => unknown);',
1575
+ 'type PropsOf<E> = E extends (props: infer P) => unknown ? P : E extends keyof Intrinsic ? Intrinsic[E] : {};',
1576
+ "type Prefixed<N extends string> = {[K in 'value' | 'change' as `${N}${Capitalize<K>}`]: string};",
1577
+ "type Pair<N extends string> = {label: string} & {[K in 'a' | 'b' as `${N}${K}`]: string};",
1578
+ 'type Relayed<M extends string> = Prefixed<M>;',
1579
+ 'type Except<O, X extends keyof O> = {[K in keyof O as K extends X ? never : K]: O[K]};',
1580
+ 'type PickByValue<O, V> = {[K in keyof O as O[K] extends V ? K : never]: O[K]};',
1581
+ ];
1582
+ const generics = Object.entries({
1583
+ 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; }",
1584
+ Joint:
1585
+ "export function Joint<E extends ElementType = 'div'>(props: {as?: E} & PropsOf<E>) { return null; }",
1586
+ Partly:
1587
+ "export function Partly<E extends ElementType = 'div'>(props: Partial<PropsOf<E>>) { return null; }",
1588
+ Remapped:
1589
+ '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; }',
1590
+ Inferred:
1591
+ 'interface InferredBase { label?: string } export function Inferred<P extends InferredBase>(props: P) { return null; }',
1592
+ // An `as` clause that names props from one: through an alias (in an
1593
+ // intersection, or through another alias), inline, or behind a
1594
+ // condition; and keys made from one.
1595
+ PrefixAlias:
1596
+ 'export function PrefixAlias<N extends string>(props: Prefixed<N>) { return null; }',
1597
+ PrefixNested:
1598
+ 'export function PrefixNested<N extends string>(props: Pair<N>) { return null; }',
1599
+ PrefixDeep:
1600
+ 'export function PrefixDeep<N extends string>(props: Relayed<N>) { return null; }',
1601
+ Templated:
1602
+ 'export function Templated<K extends string>(props: {[P in `on${K}`]?: () => void}) { return null; }',
1603
+ PrefixInline:
1604
+ "export function PrefixInline<N extends string>(props: {[K in 'value' | 'change' as `${N}${Capitalize<K>}`]?: string}) { return null; }",
1605
+ PrefixGated:
1606
+ "export function PrefixGated<T extends boolean>(props: {[K in 'value' | 'change' as T extends true ? K : never]?: string}) { return null; }",
1607
+ // Props made from one that show the compiler no members at all.
1608
+ Bagged:
1609
+ 'interface Bag<T> {} export function Bagged<T>(props: Bag<T>) { return null; }',
1610
+ }).map(([name, source]) =>
1611
+ component(name, [...PROPS_OF, source].join('\n'), {
1612
+ props: [prop('elevation', 'number')],
1613
+ }),
1614
+ );
1615
+ // A generic whose own props take any name, or map keys of its own (an
1616
+ // `as` clause over its own key, or an alias or key filter given fixed
1617
+ // arguments), has names of its own: compared.
1618
+ const own = (/** @type {string} */ names) =>
1619
+ names.split(' ').map(name => prop(name, 'X', {required: true}));
1620
+ const owned = [
1621
+ ['GenericOpen', '{items: T[]; [key: string]: any}', 'items'],
1622
+ ['GenericRecord', '{items: T[]} & Record<string, any>', 'items'],
1623
+ ['GenericRecordKeys', "Record<'value' | 'initial', T>", 'initial value'],
1624
+ ['GenericFiltered', "{[K in 'a' | 'b' as Exclude<K, 'b'>]: T}", 'a'],
1625
+ ['GenericPrefixed', "Prefixed<'my'>", 'myChange myValue'],
1626
+ ['GenericPair', "Pair<'my'>", 'label mya myb'],
1627
+ ['GenericRelayed', "Relayed<'my'>", 'myChange myValue'],
1628
+ [
1629
+ 'GenericExcept',
1630
+ "{items: T[]} & Except<{a: string; b: string}, 'b'>",
1631
+ 'a items',
1632
+ ],
1633
+ [
1634
+ 'GenericByValue',
1635
+ '{items: T[]} & PickByValue<{onPick: () => void; size: number}, Function>',
1636
+ 'items onPick',
1637
+ ],
1638
+ ].map(([name, props, names]) =>
1639
+ component(
1640
+ name,
1641
+ [
1642
+ ...PROPS_OF,
1643
+ `export function ${name}<T>(props: ${props}) { return null; }`,
1644
+ ].join('\n'),
1645
+ {props: own(names)},
1646
+ ),
1647
+ );
1648
+ // Props that are plainly empty, generic or not, are compared: TypeScript
1649
+ // takes none of the documented props.
1650
+ const empty = ['NoProps', 'NoPropsT<T>'].map(name =>
1651
+ component(
1652
+ name.replace('<T>', ''),
1653
+ `export function ${name}(props: {}) { return null; }\n`,
1654
+ {props: [prop('label', 'string')]},
1655
+ ),
1656
+ );
1657
+
1658
+ const report = await compare([
1659
+ ...unread,
1660
+ lonely,
1661
+ open,
1662
+ ...reached,
1663
+ ...references,
1664
+ untyped,
1665
+ ...generics,
1666
+ ...owned,
1667
+ ...empty,
1668
+ ]);
1669
+
1670
+ expect(kinds(report)).toEqual([
1671
+ 'NoProps.label not_in_code',
1672
+ 'NoPropsT.label not_in_code',
1673
+ ]);
1674
+ expect(report.components).toBe(13);
1675
+ const generic_ =
1676
+ 'Bagged Inferred Joint Partly Poly PrefixAlias PrefixDeep PrefixGated PrefixInline PrefixNested Remapped Templated'.split(
1677
+ ' ',
1678
+ );
1679
+ expect(report.unchecked.map(u => u.component)).toEqual(
1680
+ '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(
1681
+ ' ',
1682
+ ),
1683
+ );
1684
+ // A type the compiler can't work out, though every reference resolves.
1685
+ const untyped_ =
1686
+ 'Aliased Documented Handlers Omitting Recorded Sized Spread Toned Valued'.split(
1687
+ ' ',
1688
+ );
1689
+ for (const {component: name, reason} of report.unchecked) {
1690
+ expect(reason).toContain(
1691
+ name === 'Lonely'
1692
+ ? 'Lonely.tsx has no export named Lonely'
1693
+ : generic_.includes(name)
1694
+ ? 'from a type parameter'
1695
+ : untyped_.includes(name)
1696
+ ? "a type the compiler can't work out"
1697
+ : 'could not resolve',
1698
+ );
1699
+ }
1700
+ });
1701
+ });
1702
+
1703
+ /**
1704
+ * A multi-component doc's entry.
1705
+ * @param {string} name
1706
+ * @param {any[]} props
1707
+ */
1708
+ const entry = (name, props) => ({
1709
+ name,
1710
+ displayName: name,
1711
+ description: 'Fixture.',
1712
+ props,
1713
+ });
1714
+
1715
+ /** @param {import('./prop-drift.mjs').PropDriftComponent[]} components */
1716
+ function compareDocs(components) {
1717
+ return comparePropDocs(components, {packageDir: pkg, resolveFrom: [pkg]});
1718
+ }
1719
+
1720
+ describe('comparePropDocs', () => {
1721
+ it('compares each entry of a multi-component doc against its own export, and skips hooks', async () => {
1722
+ withTypeScript();
1723
+ const tabs = component(
1724
+ 'Tabs',
1725
+ `export function Tabs(props: {value: string}) { return null; }
1726
+ export function Tab(props: {value: string; isDisabled?: boolean}) { return null; }
1727
+ export function useTabs(options: {value: string}) { return options; }`,
1728
+ {
1729
+ components: [
1730
+ entry('Tabs', [prop('value', 'string', {required: true})]),
1731
+ entry('Tab', [
1732
+ prop('value', 'string', {required: true}),
1733
+ prop('isSelected', 'boolean'),
1734
+ ]),
1735
+ {
1736
+ name: 'useTabs',
1737
+ displayName: 'useTabs',
1738
+ description: 'Fixture.',
1739
+ params: [
1740
+ {
1741
+ name: 'options',
1742
+ type: '{value: string}',
1743
+ description: 'Fixture.',
1744
+ },
1745
+ ],
1746
+ },
1747
+ ],
1748
+ },
1749
+ );
1750
+
1751
+ const report = await compareDocs([tabs]);
1752
+
1753
+ expect(report.components).toBe(2);
1754
+ expect(kinds(report)).toEqual([
1755
+ 'Tab.isDisabled not_in_doc',
1756
+ 'Tab.isSelected not_in_code',
1757
+ ]);
1758
+ });
1759
+
1760
+ it('compares a member record only against its own source, and the family record leaves that member alone', async () => {
1761
+ withTypeScript();
1762
+ const status = component(
1763
+ 'Status',
1764
+ 'export function Status(props: {value: string}) { return null; }\n',
1765
+ {
1766
+ components: [
1767
+ entry('Status', [prop('value', 'string', {required: true})]),
1768
+ entry('StatusGlyph', [
1769
+ prop('size', 'number'),
1770
+ prop('tone', 'string'),
1771
+ ]),
1772
+ ],
1773
+ },
1774
+ );
1775
+ const glyphSource = path.join(pkg, 'components', 'StatusView.tsx');
1776
+ fs.writeFileSync(
1777
+ glyphSource,
1778
+ 'export function StatusGlyph(props: {size?: number}) { return null; }\n',
1779
+ );
1780
+
1781
+ const report = await compareDocs([
1782
+ status,
1783
+ {...status, name: 'StatusGlyph', sourcePath: glyphSource},
1784
+ ]);
1785
+
1786
+ expect(report.unchecked).toEqual([]);
1787
+ expect(report.components).toBe(2);
1788
+ expect(kinds(report)).toEqual(['StatusGlyph.tone not_in_code']);
1789
+ expect(report.findings[0].source).toBe('components/StatusView.tsx');
1790
+ });
1791
+
1792
+ it('lists a member record that no file in the package declares as not compared', async () => {
1793
+ withTypeScript();
1794
+ const status = component(
1795
+ 'Status',
1796
+ 'export function Status(props: {value: string}) { return null; }\n',
1797
+ {
1798
+ components: [
1799
+ entry('Status', [prop('value', 'string', {required: true})]),
1800
+ entry('StatusGlyph', [prop('size', 'number')]),
1801
+ ],
1802
+ },
1803
+ );
1804
+
1805
+ // Only another folder, or built output, declares the member.
1806
+ const report = await compareDocs([
1807
+ status,
1808
+ {...status, name: 'StatusGlyph', sourcePath: null},
1809
+ ]);
1810
+
1811
+ expect(report.components).toBe(1);
1812
+ expect(report.findings).toEqual([]);
1813
+ expect(report.unchecked).toEqual([
1814
+ {
1815
+ component: 'StatusGlyph',
1816
+ doc: 'components/Status.doc.mjs',
1817
+ reason: 'no source file for StatusGlyph',
1818
+ },
1819
+ ]);
1820
+ });
1821
+
1822
+ it('compares every entry of a family doc named after another export, and lists one with no source as not compared', async () => {
1823
+ withTypeScript();
1824
+ const menu = component(
1825
+ 'Menu',
1826
+ [
1827
+ 'export function MenuRoot(props: {open?: boolean}) { return null; }',
1828
+ 'export function MenuItem(props: {value: string}) { return null; }',
1829
+ ].join('\n'),
1830
+ {
1831
+ name: 'MenuRoot',
1832
+ hiddenComponents: ['MenuItem', 'MenuPart'],
1833
+ components: [
1834
+ entry('MenuItem', [prop('label', 'string')]),
1835
+ entry('MenuGhost', [prop('tone', 'string')]),
1836
+ entry('MenuPart', [prop('tone', 'string')]),
1837
+ ],
1838
+ },
1839
+ );
1840
+ // A hidden entry declared in another file of the doc's folder.
1841
+ fs.writeFileSync(
1842
+ path.join(pkg, 'components', 'menu-parts.tsx'),
1843
+ 'export function MenuPart(props: {depth?: number}) { return null; }\n',
1844
+ );
1845
+
1846
+ // Discovery records the doc under `MenuRoot` and leaves the hidden
1847
+ // `MenuItem` and `MenuPart` and the unbuilt `MenuGhost` without records
1848
+ // of their own.
1849
+ const report = await compareDocs([{...menu, name: 'MenuRoot'}]);
1850
+
1851
+ expect(kinds(report)).toEqual([
1852
+ 'MenuItem.label not_in_code',
1853
+ 'MenuItem.value not_in_doc',
1854
+ 'MenuPart.depth not_in_doc',
1855
+ 'MenuPart.tone not_in_code',
1856
+ ]);
1857
+ expect(report.unchecked).toEqual([
1858
+ {
1859
+ component: 'MenuGhost',
1860
+ doc: 'components/Menu.doc.mjs',
1861
+ reason: 'Menu.tsx has no export named MenuGhost',
1862
+ },
1863
+ ]);
1864
+ });
1865
+
1866
+ it("compares a doc's own props with the export its name names, which another file can hold", async () => {
1867
+ withTypeScript();
1868
+ const status = component(
1869
+ 'Status',
1870
+ 'export function Status(props: {value: string}) { return null; }\n',
1871
+ {name: 'StatusView', props: [prop('size', 'number')]},
1872
+ );
1873
+ const viewSource = path.join(pkg, 'components', 'StatusView.tsx');
1874
+ fs.writeFileSync(
1875
+ viewSource,
1876
+ 'export function StatusView(props: {size?: number}) { return null; }\n',
1877
+ );
1878
+
1879
+ // Discovery names the record after the file, or after the export.
1880
+ const byFile = await compareDocs([status]);
1881
+ const byExport = await compareDocs([
1882
+ {...status, name: 'StatusView', sourcePath: viewSource},
1883
+ ]);
1884
+
1885
+ for (const report of [byFile, byExport]) {
1886
+ expect(report.unchecked).toEqual([]);
1887
+ expect(report.components).toBe(1);
1888
+ expect(report.findings).toEqual([]);
1889
+ }
1890
+ });
1891
+
1892
+ it('compares a table for one component in each doc that has one', async () => {
1893
+ withTypeScript();
1894
+ const title = prop('title', 'string', {required: true});
1895
+ const list = component(
1896
+ 'List',
1897
+ 'export function List(props: {label: string}) { return null; }\n',
1898
+ {
1899
+ components: [
1900
+ entry('List', [prop('label', 'string', {required: true})]),
1901
+ entry('ListGroup', [title]),
1902
+ ],
1903
+ },
1904
+ );
1905
+ const group = component(
1906
+ 'ListGroup',
1907
+ 'export function ListGroup(props: {title: string}) { return null; }\n',
1908
+ {props: [title, prop('stale', 'string')]},
1909
+ );
1910
+
1911
+ const report = await compareDocs([list, group]);
1912
+
1913
+ expect(report.components).toBe(3);
1914
+ expect(kinds(report)).toEqual(['ListGroup.stale not_in_code']);
1915
+ expect(report.findings[0].doc).toBe('components/ListGroup.doc.mjs');
1916
+ });
1917
+
1918
+ it('lists every component as not compared when a declaration file the config includes, or a type package it names, does not resolve', async () => {
1919
+ withTypeScript();
1920
+ const tsconfig = path.join(pkg, 'tsconfig.json');
1921
+ const config = JSON.parse(fs.readFileSync(tsconfig, 'utf8'));
1922
+ /** @param {object} options */
1923
+ const configure = options =>
1924
+ fs.writeFileSync(
1925
+ tsconfig,
1926
+ JSON.stringify({
1927
+ ...config,
1928
+ compilerOptions: {...config.compilerOptions, ...options},
1929
+ include: ['components', 'types'],
1930
+ }),
1931
+ );
1932
+ fs.mkdirSync(path.join(pkg, 'types'));
1933
+ const globals = path.join(pkg, 'types', 'globals.d.ts');
1934
+ const tables = () => [
1935
+ component(
1936
+ 'Omitted',
1937
+ 'export function Omitted(props: Omit<{label: string; size?: number}, keyof MissingProps>) { return null; }\n',
1938
+ {
1939
+ props: [
1940
+ prop('label', 'string', {required: true}),
1941
+ prop('size', 'number'),
1942
+ ],
1943
+ },
1944
+ ),
1945
+ component(
1946
+ 'Plain',
1947
+ 'export function Plain(props: {label?: string}) { return null; }\n',
1948
+ {props: [prop('label', 'string')]},
1949
+ ),
1950
+ ];
1951
+ /** @param {RegExp} reason */
1952
+ const unread = reason =>
1953
+ ['Omitted', 'Plain'].map(name => ({
1954
+ component: name,
1955
+ doc: `components/${name}.doc.mjs`,
1956
+ reason: expect.stringMatching(reason),
1957
+ }));
1958
+
1959
+ // A global type made from a package that isn't installed.
1960
+ configure({});
1961
+ fs.writeFileSync(
1962
+ globals,
1963
+ "import type {Props} from '@acme/not-installed';\ndeclare global { type MissingProps = Props; }\n",
1964
+ );
1965
+ let report = await compare(tables());
1966
+ expect(report.findings).toEqual([]);
1967
+ expect(report.unchecked).toEqual(
1968
+ unread(
1969
+ /depends on `@acme\/not-installed` \(imported in \.\.\/types\/globals\.d\.ts\)/,
1970
+ ),
1971
+ );
1972
+
1973
+ // A global type made from a name declared nowhere.
1974
+ fs.writeFileSync(
1975
+ globals,
1976
+ 'declare global { type MissingProps = NowhereProps; }\nexport {};\n',
1977
+ );
1978
+ report = await compare(tables());
1979
+ expect(report.findings).toEqual([]);
1980
+ expect(report.unchecked).toEqual(
1981
+ unread(
1982
+ /depends on `NowhereProps` \(used in \.\.\/types\/globals\.d\.ts\)/,
1983
+ ),
1984
+ );
1985
+
1986
+ // A type package the config names that isn't installed.
1987
+ fs.writeFileSync(
1988
+ globals,
1989
+ 'declare global { type MissingProps = {id?: string}; }\nexport {};\n',
1990
+ );
1991
+ configure({types: ['@acme/not-installed-globals']});
1992
+ report = await compare(tables());
1993
+ expect(report.findings).toEqual([]);
1994
+ expect(report.unchecked).toEqual(
1995
+ unread(
1996
+ /depends on the types `@acme\/not-installed-globals` \(named in \.\.\/tsconfig\.json\)/,
1997
+ ),
1998
+ );
1999
+
2000
+ // Both resolved: compared.
2001
+ configure({types: []});
2002
+ report = await compare(tables());
2003
+ expect(report.unchecked).toEqual([]);
2004
+ expect(report.components).toBe(2);
2005
+ });
2006
+
2007
+ it("lists every table, with the error, when the package's TypeScript config has an error", async () => {
2008
+ withTypeScript();
2009
+ fs.writeFileSync(
2010
+ path.join(pkg, 'tsconfig.json'),
2011
+ JSON.stringify({extends: './missing-base.json'}),
2012
+ );
2013
+ const titled = component(
2014
+ 'Titled',
2015
+ 'export function Titled(props: {title?: string}) { return null; }\n',
2016
+ {props: [prop('title', 'string')]},
2017
+ );
2018
+
2019
+ const report = await compare([titled]);
2020
+
2021
+ expect(report.components).toBe(0);
2022
+ expect(report.unchecked).toEqual([
2023
+ expect.objectContaining({
2024
+ component: 'Titled',
2025
+ reason: expect.stringMatching(
2026
+ /config has an error.*missing-base\.json/,
2027
+ ),
2028
+ }),
2029
+ ]);
2030
+ });
2031
+
2032
+ it('accepts a name through an index signature only as TypeScript would', async () => {
2033
+ withTypeScript();
2034
+ const keyed = component(
2035
+ 'Keyed',
2036
+ [
2037
+ 'export interface KeyedProps {',
2038
+ ' [key: `data-${string}`]: string | undefined;',
2039
+ ' [key: `${string}:${string}`]: string;',
2040
+ ' [key: `col${number}`]: number;',
2041
+ ' [key: `on${Capitalize<string>}`]: () => void;',
2042
+ '}',
2043
+ 'export function Keyed(props: KeyedProps) { return null; }',
2044
+ ].join('\n'),
2045
+ {
2046
+ props: [
2047
+ prop('data-section', 'string'),
2048
+ prop('a:b', 'string'),
2049
+ prop('col2', 'number'),
2050
+ prop('onClick', '() => void'),
2051
+ prop('elevation', 'string'),
2052
+ prop('colSpan', 'string'),
2053
+ prop('onclick', 'string'),
2054
+ ],
2055
+ },
2056
+ );
2057
+ const empty = component(
2058
+ 'Empty',
2059
+ `interface EmptyProps { label?: string }
2060
+ export function Empty(props: EmptyProps | Record<string, never>) { return null; }`,
2061
+ {props: [prop('label', 'string'), prop('elevation', 'number')]},
2062
+ );
2063
+ const typedAny = component(
2064
+ 'TypedAny',
2065
+ 'export function TypedAny(props: any) { return null; }\n',
2066
+ {props: [prop('label', 'string')]},
2067
+ );
2068
+ const anyConst = component(
2069
+ 'AnyConst',
2070
+ 'export const AnyConst: any = null;\n',
2071
+ {props: [prop('label', 'string')]},
2072
+ );
2073
+
2074
+ const report = await compare([keyed, empty, typedAny, anyConst]);
2075
+
2076
+ expect(kinds(report)).toEqual([
2077
+ 'Empty.elevation not_in_code',
2078
+ 'Keyed.colSpan not_in_code',
2079
+ 'Keyed.elevation not_in_code',
2080
+ 'Keyed.onclick not_in_code',
2081
+ ]);
2082
+ expect(report.unchecked.map(u => [u.component, u.reason])).toEqual([
2083
+ ['AnyConst', expect.stringContaining('typed `any`')],
2084
+ ['TypedAny', expect.stringContaining('typed `any`')],
2085
+ ]);
2086
+ });
2087
+
2088
+ 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 () => {
2089
+ withTypeScript();
2090
+ fs.mkdirSync(path.join(pkg, 'node_modules', '@types'), {recursive: true});
2091
+ fs.symlinkSync(
2092
+ path.dirname(require.resolve('@types/react/package.json')),
2093
+ path.join(pkg, 'node_modules', '@types', 'react'),
2094
+ 'dir',
2095
+ );
2096
+ const tsconfig = path.join(pkg, 'tsconfig.json');
2097
+ const config = JSON.parse(fs.readFileSync(tsconfig, 'utf8'));
2098
+ fs.writeFileSync(
2099
+ tsconfig,
2100
+ JSON.stringify({...config, include: ['components', 'types']}),
2101
+ );
2102
+ fs.mkdirSync(path.join(pkg, 'types'));
2103
+ fs.writeFileSync(
2104
+ path.join(pkg, 'types', 'ambient.d.ts'),
2105
+ 'interface AmbientProps { label?: string }\n',
2106
+ );
2107
+ const source = `import {forwardRef, memo, type FC, type ComponentType, type ComponentPropsWithoutRef, type ElementType} from 'react';
2108
+ export function Poly<E extends ElementType = 'div'>(props: {as?: E; padding?: number} & Omit<ComponentPropsWithoutRef<E>, 'as' | 'padding'>) { return null; }
2109
+ export const Fc: FC<any> = () => null;
2110
+ export const Memo = memo((props: any) => null);
2111
+ export const Fwd = forwardRef<HTMLDivElement, any>((props, ref) => null);
2112
+ export const FwdOmit = forwardRef<HTMLDivElement, Omit<any, 'as'>>((props, ref) => null);
2113
+ export const FwdPartial = memo(forwardRef<HTMLDivElement, Partial<any>>((props, ref) => null));
2114
+ export function Ambient(props: AmbientProps) { return null; }
2115
+ export const Typed: ComponentType<{label?: string}> = () => null;
2116
+ const withName = <P,>(c: ComponentType<P>): ComponentType<P> => c;
2117
+ export const Hoc = withName((props: {label?: string}) => null);`;
2118
+ const props = [prop('label', 'string'), prop('elevation', 'number')];
2119
+
2120
+ const report = await compare(
2121
+ 'Fc Memo Fwd FwdOmit FwdPartial Poly Ambient Typed Hoc'
2122
+ .split(' ')
2123
+ .map(name => component(name, source, {props})),
2124
+ );
2125
+
2126
+ expect(kinds(report)).toEqual(
2127
+ 'Ambient Hoc Typed'
2128
+ .split(' ')
2129
+ .map(name => `${name}.elevation not_in_code`),
2130
+ );
2131
+ expect(report.unchecked.map(u => [u.component, u.reason])).toEqual(
2132
+ ['Fc', 'Fwd', 'FwdOmit', 'FwdPartial', 'Memo', 'Poly'].map(name => [
2133
+ name,
2134
+ expect.stringContaining(
2135
+ name === 'Poly' ? 'from a type parameter' : 'typed `any`',
2136
+ ),
2137
+ ]),
2138
+ );
2139
+ });
2140
+
2141
+ it('reads names that list several props, spreads, and fields through arrays, tuples, unions and index signatures', async () => {
2142
+ withTypeScript();
2143
+ const menu = component(
2144
+ 'Menu',
2145
+ [
2146
+ 'export interface MenuProps {',
2147
+ ' onOpen?: () => void;',
2148
+ ' onClose?: () => void;',
2149
+ ' value?: {label: string; items: ReadonlyArray<{id: string}>};',
2150
+ ' labels?: Record<string, string>;',
2151
+ ' slots?: {[key: `slot-${string}`]: string};',
2152
+ " rows?: Array<{kind: 'a'; total: string} | {kind: 'b'}>;",
2153
+ ' grid?: Array<Array<{id: string}>>;',
2154
+ ' pair?: readonly [{id: string}, {id: number}];',
2155
+ '}',
2156
+ 'export function Menu(props: MenuProps) { return null; }',
2157
+ ].join('\n'),
2158
+ {
2159
+ props: [
2160
+ prop('onOpen / onClose', '() => void / () => void'),
2161
+ prop('...rest', 'Omit<OtherProps, "x">'),
2162
+ ...['value.label', 'value.items[].id', 'value.missing'].map(name =>
2163
+ prop(name, 'string'),
2164
+ ),
2165
+ ...['labels.close', 'slots.slot-a', 'slots.other'].map(name =>
2166
+ prop(name, 'string'),
2167
+ ),
2168
+ ...['rows[].total', 'grid[][].id', 'grid[][].size', 'pair[].id'].map(
2169
+ name => prop(name, 'string'),
2170
+ ),
2171
+ ],
2172
+ },
2173
+ );
2174
+
2175
+ expect(kinds(await compare([menu]))).toEqual([
2176
+ 'Menu.grid[][].size not_in_code',
2177
+ 'Menu.slots.other not_in_code',
2178
+ 'Menu.value.missing not_in_code',
2179
+ ]);
2180
+ });
2181
+
2182
+ it('skips with a reason, and compares nothing, without a TypeScript it can use', async () => {
2183
+ const badge = component(
2184
+ 'Badge',
2185
+ 'export function Badge(props: {label: string}) { return null; }\n',
2186
+ {props: [prop('label', 'number')]},
2187
+ );
2188
+ const skipped = (/** @type {string} */ reason) => ({
2189
+ status: 'skipped',
2190
+ reason,
2191
+ components: 0,
2192
+ typesCompared: 0,
2193
+ findings: [],
2194
+ unchecked: [],
2195
+ typesNotCompared: [],
2196
+ });
2197
+
2198
+ expect(await compare([badge])).toEqual(skipped(TYPESCRIPT_MISSING_REASON));
2199
+ // TypeScript 7 has no JavaScript compiler API to load.
2200
+ const dir = path.join(pkg, 'node_modules', 'typescript');
2201
+ fs.mkdirSync(dir, {recursive: true});
2202
+ fs.writeFileSync(
2203
+ path.join(dir, 'package.json'),
2204
+ JSON.stringify({
2205
+ name: 'typescript',
2206
+ version: '7.0.0',
2207
+ exports: {'./package.json': './package.json'},
2208
+ }),
2209
+ );
2210
+ expect(await compare([badge])).toEqual(
2211
+ skipped(
2212
+ unusableTypeScriptReason(
2213
+ '7.0.0',
2214
+ 'documented props were not compared with the code',
2215
+ ),
2216
+ ),
2217
+ );
2218
+ });
2219
+ });