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