@astryxdesign/cli 0.7.2-canary.f16dc9f → 0.7.2-canary.f8f7cf1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,1224 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Check each authored doc against its published authoring type.
|
|
5
|
+
*
|
|
6
|
+
* The compiler is the project's own `typescript`, resolved from the
|
|
7
|
+
* directories the caller names. Without it nothing is checked and the report
|
|
8
|
+
* says why: it never passes silently. The types are the ones this CLI
|
|
9
|
+
* publishes as `@astryxdesign/cli/authoring`, so a doc is checked against what
|
|
10
|
+
* the CLI that reads it documents.
|
|
11
|
+
*
|
|
12
|
+
* Each doc file's value (the export its root reads) is checked the way
|
|
13
|
+
* TypeScript checks `value satisfies Type` against the published type for its
|
|
14
|
+
* kind, so a doc passes exactly when `tsc` would accept it with that type, and
|
|
15
|
+
* an unknown field, a missing required field, and a value outside a field's
|
|
16
|
+
* type are each reported:
|
|
17
|
+
* - components root: `ComponentDoc` (read as TypeScript reads the union, with
|
|
18
|
+
* the member the doc's shape names, so messages are about that member); its
|
|
19
|
+
* `docsZh` and `docsDense` translations against `ComponentTranslationDoc`,
|
|
20
|
+
* or, when a translation carries props, `ComponentDoc` with no field
|
|
21
|
+
* required but each prop's and sub-component entry's `name`, as the CLI
|
|
22
|
+
* overlays them on the base doc by name;
|
|
23
|
+
* - hooks (Core): `HookDoc`, translations against `HookTranslationDoc`;
|
|
24
|
+
* - templates root: `IntegrationTemplateDoc` (`TemplateDoc`, whose `category`
|
|
25
|
+
* is a `TemplateCategory` value, for Core's own templates);
|
|
26
|
+
* - docs root: the type the doc's `type` names (`ReferenceDoc` when it names
|
|
27
|
+
* none), and a `<topic>.doc.<lang>.mjs` overlay against
|
|
28
|
+
* `ReferenceTranslationDoc`;
|
|
29
|
+
* - themes root: `ThemeDoc`.
|
|
30
|
+
* Every finding is a warning, names the field, and says how to fix it.
|
|
31
|
+
*
|
|
32
|
+
* @input Doc files with the root each was found in, the package root, and the
|
|
33
|
+
* directories to resolve `typescript` from.
|
|
34
|
+
* @output A DocTypesReport.
|
|
35
|
+
* @position The check `astryx doctor integration validate` runs on an
|
|
36
|
+
* integration's docs, and the Astryx repository's test runs on Core's own.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import * as fs from 'node:fs';
|
|
40
|
+
import * as path from 'node:path';
|
|
41
|
+
import {fileURLToPath} from 'node:url';
|
|
42
|
+
import {
|
|
43
|
+
resolveProjectTypeScript,
|
|
44
|
+
unusableTypeScriptReason,
|
|
45
|
+
} from './project-typescript.mjs';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {'components' | 'hooks' | 'templates' | 'docs' | 'docs-overlay' | 'themes'} DocRoot
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {object} DocFile
|
|
53
|
+
* @property {string} path absolute path to the doc file
|
|
54
|
+
* @property {DocRoot} root the contribution root it was found in
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {'unknown_field' | 'missing_field' | 'wrong_type'} DocTypeProblem
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {object} DocTypeFinding
|
|
63
|
+
* @property {string} doc the doc file, relative to the package root
|
|
64
|
+
* @property {number} line 1-based line of the field (or of the doc value)
|
|
65
|
+
* @property {string} export the export checked (`default`, `docs`, `docsZh`, …)
|
|
66
|
+
* @property {string} type the authoring type it was checked against
|
|
67
|
+
* @property {string} field the field's path in the doc, such as
|
|
68
|
+
* `usage.bestPractices[2].guidance`; empty for the doc value as a whole
|
|
69
|
+
* @property {DocTypeProblem} problem
|
|
70
|
+
* @property {'warning'} severity
|
|
71
|
+
* @property {string} message what TypeScript reports, shortened
|
|
72
|
+
* @property {string} fix what to change
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @typedef {object} DocTypesUnchecked
|
|
77
|
+
* @property {string} doc the doc file, relative to the package root
|
|
78
|
+
* @property {string} reason why it was not checked
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @typedef {object} DocTypesReport
|
|
83
|
+
* @property {'checked' | 'skipped'} status `skipped` means nothing was checked,
|
|
84
|
+
* and `reason` says why
|
|
85
|
+
* @property {string} [reason]
|
|
86
|
+
* @property {string} [typescript] the version of the compiler that checked
|
|
87
|
+
* @property {number} docs how many doc values were checked
|
|
88
|
+
* @property {DocTypeFinding[]} findings
|
|
89
|
+
* @property {DocTypesUnchecked[]} unchecked doc files with nothing to check,
|
|
90
|
+
* or not fully checked
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/** The line both surfaces print when the compiler is missing. */
|
|
94
|
+
export const DOC_TYPES_TYPESCRIPT_MISSING_REASON =
|
|
95
|
+
'TypeScript is not installed in this project, so docs were not checked against the authoring types. Install typescript and run this check again.';
|
|
96
|
+
|
|
97
|
+
const AUTHORING = '@astryxdesign/cli/authoring';
|
|
98
|
+
const AUTHORING_TYPES = path.resolve(
|
|
99
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
100
|
+
'..',
|
|
101
|
+
'..',
|
|
102
|
+
'authoring',
|
|
103
|
+
'index.d.ts',
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const TRANSLATIONS = ['docsZh', 'docsDense'];
|
|
107
|
+
|
|
108
|
+
/** How each root's module names its doc, in the readers' precedence order. */
|
|
109
|
+
const MAIN_EXPORTS = {
|
|
110
|
+
components: ['default', 'docs'],
|
|
111
|
+
hooks: ['default', 'docs'],
|
|
112
|
+
templates: ['default', 'doc'],
|
|
113
|
+
docs: ['docs', 'default'],
|
|
114
|
+
'docs-overlay': [],
|
|
115
|
+
themes: ['default', 'doc', 'docs'],
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The type a docs-root doc's `type` stamp names. */
|
|
119
|
+
const DOCS_ROOT_TYPES = {
|
|
120
|
+
generic: 'ReferenceDoc',
|
|
121
|
+
namespace: 'NamespaceDoc',
|
|
122
|
+
function: 'FunctionDoc',
|
|
123
|
+
schema: 'SchemaDoc',
|
|
124
|
+
command: 'CommandDoc',
|
|
125
|
+
enum: 'EnumDoc',
|
|
126
|
+
theme: 'ThemeDoc',
|
|
127
|
+
component: 'ComponentDoc',
|
|
128
|
+
page: 'IntegrationTemplateDoc',
|
|
129
|
+
block: 'IntegrationTemplateDoc',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Each pass sets aside at least one more field, so a doc needs at most one
|
|
134
|
+
* pass per field. One still finding new problems after this many is listed as
|
|
135
|
+
* not fully checked.
|
|
136
|
+
*/
|
|
137
|
+
const MAX_PASSES = 50;
|
|
138
|
+
|
|
139
|
+
/** TypeScript's assignability diagnostics: the only ones reported. */
|
|
140
|
+
const RELEVANT = new Set([
|
|
141
|
+
1360, 2322, 2353, 2559, 2561, 2739, 2740, 2741, 2820,
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @typedef {object} Target
|
|
146
|
+
* @property {string} exportName
|
|
147
|
+
* @property {any} node the doc value's expression
|
|
148
|
+
* @property {string} typeExpr the authoring type, as a type expression
|
|
149
|
+
* @property {string} typeName how findings name that type
|
|
150
|
+
* @property {any[]} blank nodes whose leading JSDoc annotations are blanked
|
|
151
|
+
* @property {Array<[number, number]>} blankRanges source ranges to blank
|
|
152
|
+
*/
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {string} name
|
|
156
|
+
*/
|
|
157
|
+
const authoring = name => `import('${AUTHORING}').${name}`;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The top-level property names and `type` stamp of an object literal, or null
|
|
161
|
+
* when the value is not one.
|
|
162
|
+
* @param {any} ts
|
|
163
|
+
* @param {any} node
|
|
164
|
+
* @returns {{names: Set<string>, stamp: string | null, componentsWithProps: boolean} | null}
|
|
165
|
+
*/
|
|
166
|
+
function shapeOf(ts, node) {
|
|
167
|
+
if (!ts.isObjectLiteralExpression(node)) return null;
|
|
168
|
+
const names = new Set();
|
|
169
|
+
/** @type {string | null} */
|
|
170
|
+
let stamp = null;
|
|
171
|
+
let componentsWithProps = false;
|
|
172
|
+
for (const property of node.properties) {
|
|
173
|
+
const key = property.name ? propertyName(ts, property.name) : null;
|
|
174
|
+
if (key == null) continue;
|
|
175
|
+
names.add(key);
|
|
176
|
+
if (
|
|
177
|
+
key === 'type' &&
|
|
178
|
+
ts.isPropertyAssignment(property) &&
|
|
179
|
+
ts.isStringLiteralLike(property.initializer)
|
|
180
|
+
) {
|
|
181
|
+
stamp = property.initializer.text;
|
|
182
|
+
}
|
|
183
|
+
if (
|
|
184
|
+
key === 'components' &&
|
|
185
|
+
ts.isPropertyAssignment(property) &&
|
|
186
|
+
ts.isArrayLiteralExpression(property.initializer)
|
|
187
|
+
) {
|
|
188
|
+
componentsWithProps = property.initializer.elements.some(
|
|
189
|
+
(/** @type {any} */ element) =>
|
|
190
|
+
ts.isObjectLiteralExpression(element) &&
|
|
191
|
+
element.properties.some(
|
|
192
|
+
(/** @type {any} */ p) =>
|
|
193
|
+
p.name && propertyName(ts, p.name) === 'props',
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return {names, stamp, componentsWithProps};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {any} ts
|
|
203
|
+
* @param {any} name
|
|
204
|
+
* @returns {string | null}
|
|
205
|
+
*/
|
|
206
|
+
function propertyName(ts, name) {
|
|
207
|
+
if (
|
|
208
|
+
ts.isIdentifier(name) ||
|
|
209
|
+
ts.isStringLiteralLike(name) ||
|
|
210
|
+
ts.isNumericLiteral(name) ||
|
|
211
|
+
ts.isPrivateIdentifier?.(name)
|
|
212
|
+
) {
|
|
213
|
+
return name.text;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The `ComponentDoc` member a doc's shape selects (`subComponentOf`,
|
|
220
|
+
* `components`, `props`), widened by every field the other members declare,
|
|
221
|
+
* typed `unknown`. That is how TypeScript reads a value against the union:
|
|
222
|
+
* a field any member declares is not unknown, and the value must be
|
|
223
|
+
* assignable to one member. Picking the member keeps the messages about it
|
|
224
|
+
* (`SingleComponentDoc requires displayName`) instead of whichever member
|
|
225
|
+
* TypeScript guesses.
|
|
226
|
+
* @param {{names: Set<string>} | null} shape
|
|
227
|
+
* @returns {{typeExpr: string, typeName: string}}
|
|
228
|
+
*/
|
|
229
|
+
function componentType(shape) {
|
|
230
|
+
const union = authoring('ComponentDoc');
|
|
231
|
+
const member = shape?.names.has('subComponentOf')
|
|
232
|
+
? `Extract<${union}, {subComponentOf: string}>`
|
|
233
|
+
: shape && shape.names.has('components') && !shape.names.has('props')
|
|
234
|
+
? authoring('MultiComponentDoc')
|
|
235
|
+
: shape && !shape.names.has('components')
|
|
236
|
+
? authoring('SingleComponentDoc')
|
|
237
|
+
: null;
|
|
238
|
+
if (!member) return {typeExpr: union, typeName: 'ComponentDoc'};
|
|
239
|
+
const allKeys = `(${union} extends infer U ? U extends unknown ? keyof U : never : never)`;
|
|
240
|
+
return {
|
|
241
|
+
typeExpr: `${member} & Partial<Record<Exclude<${allKeys}, keyof ${member}>, unknown>>`,
|
|
242
|
+
typeName: 'ComponentDoc',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The authoring type one doc value is checked against.
|
|
248
|
+
* @param {any} ts
|
|
249
|
+
* @param {DocRoot} root
|
|
250
|
+
* @param {string} exportName
|
|
251
|
+
* @param {any} node
|
|
252
|
+
* @param {boolean} [core] the docs are Core's own
|
|
253
|
+
* @returns {{typeExpr: string, typeName: string}}
|
|
254
|
+
*/
|
|
255
|
+
function expectedType(ts, root, exportName, node, core = false) {
|
|
256
|
+
const shape = shapeOf(ts, node);
|
|
257
|
+
const translation = TRANSLATIONS.includes(exportName);
|
|
258
|
+
if (root === 'components') {
|
|
259
|
+
if (
|
|
260
|
+
translation &&
|
|
261
|
+
!(shape?.names.has('props') || shape?.componentsWithProps)
|
|
262
|
+
) {
|
|
263
|
+
return {
|
|
264
|
+
typeExpr: authoring('ComponentTranslationDoc'),
|
|
265
|
+
typeName: 'ComponentTranslationDoc',
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// A translation with props overlays the base doc, which supplies every
|
|
269
|
+
// field it leaves out: each field it writes must match, none is required.
|
|
270
|
+
// Props and sub-component entries merge by `name`, the base doc keeping
|
|
271
|
+
// each prop's `type`, so an entry needs only its `name`.
|
|
272
|
+
if (translation) {
|
|
273
|
+
const full = componentType(shape);
|
|
274
|
+
const prop = `Partial<${authoring('ComponentPropDoc')}> & {name: string}`;
|
|
275
|
+
const entry = `Partial<Omit<${authoring('ComponentEntry')}, 'props'>> & {name: string; props?: Array<${prop}>}`;
|
|
276
|
+
return {
|
|
277
|
+
typeExpr: `Omit<Partial<${full.typeExpr}>, 'props' | 'components'> & {props?: Array<${prop}>; components?: Array<${entry}>}`,
|
|
278
|
+
typeName: full.typeName,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return componentType(shape);
|
|
282
|
+
}
|
|
283
|
+
if (root === 'hooks') {
|
|
284
|
+
if (translation && !shape?.names.has('params')) {
|
|
285
|
+
return {
|
|
286
|
+
typeExpr: authoring('HookTranslationDoc'),
|
|
287
|
+
typeName: 'HookTranslationDoc',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (translation) {
|
|
291
|
+
return {
|
|
292
|
+
typeExpr: `Partial<${authoring('HookDoc')}>`,
|
|
293
|
+
typeName: 'HookDoc',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return {typeExpr: authoring('HookDoc'), typeName: 'HookDoc'};
|
|
297
|
+
}
|
|
298
|
+
if (root === 'templates') {
|
|
299
|
+
const typeName = core ? 'TemplateDoc' : 'IntegrationTemplateDoc';
|
|
300
|
+
return {typeExpr: authoring(typeName), typeName};
|
|
301
|
+
}
|
|
302
|
+
if (root === 'docs-overlay') {
|
|
303
|
+
return {
|
|
304
|
+
typeExpr: authoring('ReferenceTranslationDoc'),
|
|
305
|
+
typeName: 'ReferenceTranslationDoc',
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (root === 'themes') {
|
|
309
|
+
return {typeExpr: authoring('ThemeDoc'), typeName: 'ThemeDoc'};
|
|
310
|
+
}
|
|
311
|
+
if (translation) {
|
|
312
|
+
return {
|
|
313
|
+
typeExpr: authoring('ReferenceTranslationDoc'),
|
|
314
|
+
typeName: 'ReferenceTranslationDoc',
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
const named =
|
|
318
|
+
shape?.stamp != null
|
|
319
|
+
? /** @type {Record<string, string>} */ (DOCS_ROOT_TYPES)[shape.stamp]
|
|
320
|
+
: undefined;
|
|
321
|
+
if (named === 'ComponentDoc') return componentType(shape);
|
|
322
|
+
const typeName =
|
|
323
|
+
core && named === 'IntegrationTemplateDoc'
|
|
324
|
+
? 'TemplateDoc'
|
|
325
|
+
: (named ?? 'ReferenceDoc');
|
|
326
|
+
return {typeExpr: authoring(typeName), typeName};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The exported values of a module, by export name: each statement and the
|
|
331
|
+
* expression it exports.
|
|
332
|
+
* @param {any} ts
|
|
333
|
+
* @param {any} sf
|
|
334
|
+
* @returns {Map<string, {statement: any, expression: any}>}
|
|
335
|
+
*/
|
|
336
|
+
function moduleExports(ts, sf) {
|
|
337
|
+
/** @type {Map<string, {statement: any, expression: any}>} */
|
|
338
|
+
const locals = new Map();
|
|
339
|
+
/** @type {Map<string, {statement: any, expression: any}>} */
|
|
340
|
+
const exported = new Map();
|
|
341
|
+
for (const statement of sf.statements) {
|
|
342
|
+
if (ts.isVariableStatement(statement)) {
|
|
343
|
+
const isExported = statement.modifiers?.some(
|
|
344
|
+
(/** @type {any} */ m) => m.kind === ts.SyntaxKind.ExportKeyword,
|
|
345
|
+
);
|
|
346
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
347
|
+
if (!ts.isIdentifier(declaration.name) || !declaration.initializer)
|
|
348
|
+
continue;
|
|
349
|
+
const entry = {statement, expression: declaration.initializer};
|
|
350
|
+
locals.set(declaration.name.text, entry);
|
|
351
|
+
if (isExported) exported.set(declaration.name.text, entry);
|
|
352
|
+
}
|
|
353
|
+
} else if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
|
|
354
|
+
exported.set('default', {statement, expression: statement.expression});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (const statement of sf.statements) {
|
|
358
|
+
if (
|
|
359
|
+
ts.isExportDeclaration(statement) &&
|
|
360
|
+
!statement.moduleSpecifier &&
|
|
361
|
+
statement.exportClause &&
|
|
362
|
+
ts.isNamedExports(statement.exportClause)
|
|
363
|
+
) {
|
|
364
|
+
for (const element of statement.exportClause.elements) {
|
|
365
|
+
const local = (element.propertyName ?? element.name).text;
|
|
366
|
+
const entry = locals.get(local);
|
|
367
|
+
if (entry) exported.set(element.name.text, entry);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// `export default docs` exports a local's initializer.
|
|
372
|
+
for (const [name, entry] of exported) {
|
|
373
|
+
let expression = entry.expression;
|
|
374
|
+
for (let hop = 0; hop < 3 && ts.isIdentifier(expression); hop++) {
|
|
375
|
+
const local = locals.get(expression.text);
|
|
376
|
+
if (!local) break;
|
|
377
|
+
expression = local.expression;
|
|
378
|
+
exported.set(name, {statement: local.statement, expression});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return exported;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Strip the wrappers around a doc value (parentheses, `satisfies`, `as`,
|
|
386
|
+
* JSDoc casts), returning the value and the wrappers.
|
|
387
|
+
* @param {any} ts
|
|
388
|
+
* @param {any} expression
|
|
389
|
+
*/
|
|
390
|
+
function unwrap(ts, expression) {
|
|
391
|
+
/** @type {any[]} */
|
|
392
|
+
const wrappers = [];
|
|
393
|
+
let node = expression;
|
|
394
|
+
for (;;) {
|
|
395
|
+
if (
|
|
396
|
+
ts.isParenthesizedExpression(node) ||
|
|
397
|
+
ts.isAsExpression(node) ||
|
|
398
|
+
ts.isNonNullExpression(node) ||
|
|
399
|
+
(ts.isSatisfiesExpression && ts.isSatisfiesExpression(node)) ||
|
|
400
|
+
(ts.isTypeAssertionExpression && ts.isTypeAssertionExpression(node))
|
|
401
|
+
) {
|
|
402
|
+
wrappers.push(node);
|
|
403
|
+
node = node.expression;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
return {node, wrappers};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The doc values one file holds, with the type each is checked against.
|
|
412
|
+
* @param {any} ts
|
|
413
|
+
* @param {any} sf
|
|
414
|
+
* @param {DocRoot} root
|
|
415
|
+
* @param {boolean} [core] the docs are Core's own
|
|
416
|
+
* @returns {Target[]}
|
|
417
|
+
*/
|
|
418
|
+
function targetsOf(ts, sf, root, core = false) {
|
|
419
|
+
const exported = moduleExports(ts, sf);
|
|
420
|
+
/** @type {string[]} */
|
|
421
|
+
const names = [];
|
|
422
|
+
const main = MAIN_EXPORTS[root].find(name => exported.has(name));
|
|
423
|
+
if (main) names.push(main);
|
|
424
|
+
if (root !== 'templates' && root !== 'themes') {
|
|
425
|
+
for (const name of TRANSLATIONS) if (exported.has(name)) names.push(name);
|
|
426
|
+
}
|
|
427
|
+
/** @type {Target[]} */
|
|
428
|
+
const targets = [];
|
|
429
|
+
const seen = new Set();
|
|
430
|
+
for (const exportName of names) {
|
|
431
|
+
const entry = /** @type {{statement: any, expression: any}} */ (
|
|
432
|
+
exported.get(exportName)
|
|
433
|
+
);
|
|
434
|
+
const {node, wrappers} = unwrap(ts, entry.expression);
|
|
435
|
+
if (seen.has(node)) continue;
|
|
436
|
+
seen.add(node);
|
|
437
|
+
/** @type {Array<[number, number]>} */
|
|
438
|
+
const blankRanges = [];
|
|
439
|
+
for (const wrapper of wrappers) {
|
|
440
|
+
if (ts.isTypeAssertionExpression?.(wrapper)) {
|
|
441
|
+
// `<X>` before the value.
|
|
442
|
+
blankRanges.push([
|
|
443
|
+
wrapper.getStart(sf),
|
|
444
|
+
wrapper.expression.getStart(sf),
|
|
445
|
+
]);
|
|
446
|
+
} else if (
|
|
447
|
+
// ` satisfies X` / ` as X` after the value.
|
|
448
|
+
!ts.isParenthesizedExpression(wrapper) &&
|
|
449
|
+
!ts.isNonNullExpression(wrapper) &&
|
|
450
|
+
wrapper.end > wrapper.expression.end
|
|
451
|
+
) {
|
|
452
|
+
blankRanges.push([wrapper.expression.end, wrapper.end]);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// The variable's own annotation: `const docs: ReferenceDoc = …`.
|
|
456
|
+
for (const declaration of ts.isVariableStatement(entry.statement)
|
|
457
|
+
? entry.statement.declarationList.declarations
|
|
458
|
+
: []) {
|
|
459
|
+
if (declaration.initializer === entry.expression && declaration.type) {
|
|
460
|
+
blankRanges.push([declaration.name.end, declaration.type.end]);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
targets.push({
|
|
464
|
+
exportName,
|
|
465
|
+
node,
|
|
466
|
+
...expectedType(ts, root, exportName, node, core),
|
|
467
|
+
blank: [entry.statement, ...wrappers],
|
|
468
|
+
blankRanges,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return targets;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The file's text with each doc value wrapped in a `satisfies` check of its
|
|
476
|
+
* type, the authors' own type annotations on it blanked (so a value is checked
|
|
477
|
+
* once, against the type for its root), and a map back to the original text.
|
|
478
|
+
* `removed` ranges (unknown fields already reported) are blanked too, and
|
|
479
|
+
* `casts` ranges (values already reported outside their type) are cast to
|
|
480
|
+
* `any`, so the next pass reports what TypeScript stopped at them to report.
|
|
481
|
+
* Blanking keeps every offset, so lines and fields map back unchanged.
|
|
482
|
+
* @param {any} ts
|
|
483
|
+
* @param {any} sf
|
|
484
|
+
* @param {Target[]} targets
|
|
485
|
+
* @param {boolean} isTs
|
|
486
|
+
* @param {Array<[number, number]>} [removed]
|
|
487
|
+
* @param {Array<[number, number]>} [casts]
|
|
488
|
+
*/
|
|
489
|
+
function instrument(ts, sf, targets, isTs, removed = [], casts = []) {
|
|
490
|
+
const text = sf.text;
|
|
491
|
+
const chars = text.split('');
|
|
492
|
+
const blank = (/** @type {number} */ from, /** @type {number} */ to) => {
|
|
493
|
+
for (let i = from; i < to; i++)
|
|
494
|
+
if (chars[i] !== '\n' && chars[i] !== '\r') chars[i] = ' ';
|
|
495
|
+
};
|
|
496
|
+
for (const [from, to] of removed) blank(from, to);
|
|
497
|
+
for (const target of targets) {
|
|
498
|
+
for (const node of target.blank) {
|
|
499
|
+
for (const range of ts.getLeadingCommentRanges(text, node.pos) ?? []) {
|
|
500
|
+
const comment = text.slice(range.pos, range.end);
|
|
501
|
+
if (
|
|
502
|
+
comment.startsWith('/**') &&
|
|
503
|
+
/@(?:type|satisfies)\b/.test(comment)
|
|
504
|
+
) {
|
|
505
|
+
blank(range.pos, range.end);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
for (const [from, to] of target.blankRanges) blank(from, to);
|
|
510
|
+
}
|
|
511
|
+
/** @type {Array<{at: number, text: string}>} */
|
|
512
|
+
const insertions = [];
|
|
513
|
+
for (const target of targets) {
|
|
514
|
+
const start = target.node.getStart(sf);
|
|
515
|
+
if (isTs) {
|
|
516
|
+
insertions.push({at: start, text: '('});
|
|
517
|
+
insertions.push({
|
|
518
|
+
at: target.node.end,
|
|
519
|
+
text: `) satisfies ${target.typeExpr}`,
|
|
520
|
+
});
|
|
521
|
+
} else {
|
|
522
|
+
insertions.push({
|
|
523
|
+
at: start,
|
|
524
|
+
text: `/** @satisfies {${target.typeExpr}} */ (`,
|
|
525
|
+
});
|
|
526
|
+
insertions.push({at: target.node.end, text: ')'});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
for (const [from, to] of casts) {
|
|
530
|
+
insertions.push({at: from, text: isTs ? '(' : '/** @type {any} */ ('});
|
|
531
|
+
insertions.push({at: to, text: isTs ? ' as any)' : ')'});
|
|
532
|
+
}
|
|
533
|
+
insertions.sort((a, b) => a.at - b.at);
|
|
534
|
+
let out = '';
|
|
535
|
+
let cursor = 0;
|
|
536
|
+
for (const insertion of insertions) {
|
|
537
|
+
out += chars.slice(cursor, insertion.at).join('') + insertion.text;
|
|
538
|
+
cursor = insertion.at;
|
|
539
|
+
}
|
|
540
|
+
out += chars.slice(cursor).join('');
|
|
541
|
+
/** @param {number} virtual */
|
|
542
|
+
const toOriginal = virtual => {
|
|
543
|
+
let shift = 0;
|
|
544
|
+
for (const insertion of insertions) {
|
|
545
|
+
const virtualAt = insertion.at + shift;
|
|
546
|
+
if (virtual < virtualAt) return virtual - shift;
|
|
547
|
+
if (virtual < virtualAt + insertion.text.length) return insertion.at;
|
|
548
|
+
shift += insertion.text.length;
|
|
549
|
+
}
|
|
550
|
+
return virtual - shift;
|
|
551
|
+
};
|
|
552
|
+
return {text: out, toOriginal};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* The path from a doc value to the field at `pos`: `a.b[2].c`.
|
|
557
|
+
* @param {any} ts
|
|
558
|
+
* @param {any} sf
|
|
559
|
+
* @param {any} root
|
|
560
|
+
* @param {number} pos
|
|
561
|
+
*/
|
|
562
|
+
function fieldPathAt(ts, sf, root, pos) {
|
|
563
|
+
/** @type {Array<string | number>} */
|
|
564
|
+
const parts = [];
|
|
565
|
+
let node = root;
|
|
566
|
+
for (;;) {
|
|
567
|
+
/** @type {any} */
|
|
568
|
+
let next;
|
|
569
|
+
ts.forEachChild(node, (/** @type {any} */ child) => {
|
|
570
|
+
if (!next && child.getStart(sf) <= pos && pos < child.end) next = child;
|
|
571
|
+
});
|
|
572
|
+
if (!next) break;
|
|
573
|
+
if (ts.isArrayLiteralExpression(node))
|
|
574
|
+
parts.push(node.elements.indexOf(next));
|
|
575
|
+
if (
|
|
576
|
+
(ts.isPropertyAssignment(next) ||
|
|
577
|
+
ts.isShorthandPropertyAssignment(next) ||
|
|
578
|
+
ts.isMethodDeclaration(next)) &&
|
|
579
|
+
next.name
|
|
580
|
+
) {
|
|
581
|
+
const key = propertyName(ts, next.name);
|
|
582
|
+
if (key != null) parts.push(key);
|
|
583
|
+
}
|
|
584
|
+
node = next;
|
|
585
|
+
}
|
|
586
|
+
return parts;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** @param {Array<string | number>} parts */
|
|
590
|
+
function formatPath(parts) {
|
|
591
|
+
return parts
|
|
592
|
+
.map((part, i) =>
|
|
593
|
+
typeof part === 'number' ? `[${part}]` : i === 0 ? part : `.${part}`,
|
|
594
|
+
)
|
|
595
|
+
.join('');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Shorten long quoted types in a TypeScript message.
|
|
600
|
+
* @param {string} message
|
|
601
|
+
*/
|
|
602
|
+
function shorten(message) {
|
|
603
|
+
return message
|
|
604
|
+
.replace(/\s+/g, ' ')
|
|
605
|
+
.replace(/'([^']{90,})'/g, (_, type) => `'${type.slice(0, 70)}…'`)
|
|
606
|
+
.trim();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Every message in a diagnostic's chain, outermost first.
|
|
611
|
+
* @param {any} ts
|
|
612
|
+
* @param {any} messageText
|
|
613
|
+
* @returns {string[]}
|
|
614
|
+
*/
|
|
615
|
+
function chainOf(ts, messageText) {
|
|
616
|
+
if (typeof messageText === 'string') return [messageText];
|
|
617
|
+
/** @type {string[]} */
|
|
618
|
+
const out = [];
|
|
619
|
+
/** @param {any} chain */
|
|
620
|
+
const visit = chain => {
|
|
621
|
+
out.push(chain.messageText);
|
|
622
|
+
if (chain.next?.length) visit(chain.next[0]);
|
|
623
|
+
};
|
|
624
|
+
visit(messageText);
|
|
625
|
+
return out;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* The source range of the property whose name holds `pos`, with the comma
|
|
630
|
+
* after it, or null.
|
|
631
|
+
* @param {any} ts
|
|
632
|
+
* @param {any} sf
|
|
633
|
+
* @param {any} root
|
|
634
|
+
* @param {number} pos
|
|
635
|
+
* @returns {[number, number] | null}
|
|
636
|
+
*/
|
|
637
|
+
function propertyRangeAt(ts, sf, root, pos) {
|
|
638
|
+
/** @type {any} */
|
|
639
|
+
let found = null;
|
|
640
|
+
let node = root;
|
|
641
|
+
for (;;) {
|
|
642
|
+
/** @type {any} */
|
|
643
|
+
let next;
|
|
644
|
+
ts.forEachChild(node, (/** @type {any} */ child) => {
|
|
645
|
+
if (!next && child.getStart(sf) <= pos && pos < child.end) next = child;
|
|
646
|
+
});
|
|
647
|
+
if (!next) break;
|
|
648
|
+
if (
|
|
649
|
+
(ts.isPropertyAssignment(next) ||
|
|
650
|
+
ts.isShorthandPropertyAssignment(next) ||
|
|
651
|
+
ts.isMethodDeclaration(next)) &&
|
|
652
|
+
next.name &&
|
|
653
|
+
next.name.getStart(sf) <= pos &&
|
|
654
|
+
pos < next.name.end
|
|
655
|
+
) {
|
|
656
|
+
found = next;
|
|
657
|
+
}
|
|
658
|
+
node = next;
|
|
659
|
+
}
|
|
660
|
+
if (!found) return null;
|
|
661
|
+
let end = found.end;
|
|
662
|
+
const text = sf.text;
|
|
663
|
+
let i = end;
|
|
664
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
665
|
+
if (text[i] === ',') end = i + 1;
|
|
666
|
+
return [found.getStart(sf), end];
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* The source range of the value a diagnostic at `pos` is about: the value of
|
|
671
|
+
* the property whose name holds `pos`, else the array element that holds it,
|
|
672
|
+
* or null.
|
|
673
|
+
* @param {any} ts
|
|
674
|
+
* @param {any} sf
|
|
675
|
+
* @param {any} root
|
|
676
|
+
* @param {number} pos
|
|
677
|
+
* @returns {[number, number] | null}
|
|
678
|
+
*/
|
|
679
|
+
function valueRangeAt(ts, sf, root, pos) {
|
|
680
|
+
/** @type {any} */
|
|
681
|
+
let found = null;
|
|
682
|
+
let node = root;
|
|
683
|
+
for (;;) {
|
|
684
|
+
/** @type {any} */
|
|
685
|
+
let next;
|
|
686
|
+
ts.forEachChild(node, (/** @type {any} */ child) => {
|
|
687
|
+
if (!next && child.getStart(sf) <= pos && pos < child.end) next = child;
|
|
688
|
+
});
|
|
689
|
+
if (!next) break;
|
|
690
|
+
if (
|
|
691
|
+
ts.isPropertyAssignment(next) &&
|
|
692
|
+
next.name.getStart(sf) <= pos &&
|
|
693
|
+
pos < next.name.end
|
|
694
|
+
) {
|
|
695
|
+
found = next.initializer;
|
|
696
|
+
} else if (ts.isArrayLiteralExpression(node)) {
|
|
697
|
+
found = next;
|
|
698
|
+
}
|
|
699
|
+
node = next;
|
|
700
|
+
}
|
|
701
|
+
if (!found || found === root) return null;
|
|
702
|
+
return [found.getStart(sf), found.end];
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* The source range of the innermost object literal, below the doc value
|
|
707
|
+
* itself, that holds `pos`, or null.
|
|
708
|
+
* @param {any} ts
|
|
709
|
+
* @param {any} sf
|
|
710
|
+
* @param {any} root
|
|
711
|
+
* @param {number} pos
|
|
712
|
+
* @returns {[number, number] | null}
|
|
713
|
+
*/
|
|
714
|
+
function objectRangeAt(ts, sf, root, pos) {
|
|
715
|
+
/** @type {any} */
|
|
716
|
+
let found = null;
|
|
717
|
+
let node = root;
|
|
718
|
+
for (;;) {
|
|
719
|
+
/** @type {any} */
|
|
720
|
+
let next;
|
|
721
|
+
ts.forEachChild(node, (/** @type {any} */ child) => {
|
|
722
|
+
if (!next && child.getStart(sf) <= pos && pos < child.end) next = child;
|
|
723
|
+
});
|
|
724
|
+
if (!next) break;
|
|
725
|
+
if (ts.isObjectLiteralExpression(next)) found = next;
|
|
726
|
+
node = next;
|
|
727
|
+
}
|
|
728
|
+
if (!found || found === root) return null;
|
|
729
|
+
return [found.getStart(sf), found.end];
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* The module a file re-exports one of `names` from (`export {default} from
|
|
734
|
+
* './x.mjs'`, or `export * from`), or null.
|
|
735
|
+
* @param {any} ts
|
|
736
|
+
* @param {any} sf
|
|
737
|
+
* @param {string[]} names
|
|
738
|
+
* @returns {string | null}
|
|
739
|
+
*/
|
|
740
|
+
function reexportSource(ts, sf, names) {
|
|
741
|
+
for (const statement of sf.statements) {
|
|
742
|
+
if (
|
|
743
|
+
!ts.isExportDeclaration(statement) ||
|
|
744
|
+
!statement.moduleSpecifier ||
|
|
745
|
+
!ts.isStringLiteral(statement.moduleSpecifier)
|
|
746
|
+
) {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
const clause = statement.exportClause;
|
|
750
|
+
if (
|
|
751
|
+
clause == null ||
|
|
752
|
+
(ts.isNamedExports(clause) &&
|
|
753
|
+
clause.elements.some(
|
|
754
|
+
/** @param {any} element */ element =>
|
|
755
|
+
names.includes(element.name.text),
|
|
756
|
+
))
|
|
757
|
+
) {
|
|
758
|
+
return statement.moduleSpecifier.text;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* A message with each quoted object type that holds a set-aside `any`
|
|
766
|
+
* replaced by "this value".
|
|
767
|
+
* @param {string} message
|
|
768
|
+
*/
|
|
769
|
+
function unquoteSetAside(message) {
|
|
770
|
+
return message
|
|
771
|
+
.replace(/\btype '\{[^']*\bany\b[^']*\}'/g, 'this value')
|
|
772
|
+
.replace(/^Type '\{[^']*\bany\b[^']*\}'/, 'This value');
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* A property name as TypeScript prints it, without the quotes a quoted key
|
|
777
|
+
* keeps (`"keyword"`).
|
|
778
|
+
* @param {string} name
|
|
779
|
+
*/
|
|
780
|
+
function unquote(name) {
|
|
781
|
+
return name.replace(/^"(.*)"$/, '$1');
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Findings for one diagnostic.
|
|
786
|
+
* @param {any} ts
|
|
787
|
+
* @param {any} diagnostic
|
|
788
|
+
* @param {Array<string | number>} astPath
|
|
789
|
+
* @param {{root: DocRoot, doc: string, line: number, exportName: string, typeName: string}} where
|
|
790
|
+
* @returns {DocTypeFinding[]}
|
|
791
|
+
*/
|
|
792
|
+
function findingsFor(ts, diagnostic, astPath, where) {
|
|
793
|
+
// The widened member reads as the member itself.
|
|
794
|
+
const chain = chainOf(ts, diagnostic.messageText).map(message =>
|
|
795
|
+
message.replace(/ & Partial<Record<[^']*/g, ''),
|
|
796
|
+
);
|
|
797
|
+
// "Types of property 'x' are incompatible." steps further down the doc.
|
|
798
|
+
const below = chain
|
|
799
|
+
.map(
|
|
800
|
+
message =>
|
|
801
|
+
/^Types of property '([^']+)' are incompatible/.exec(message)?.[1],
|
|
802
|
+
)
|
|
803
|
+
.filter(key => key != null);
|
|
804
|
+
const parts = [...astPath];
|
|
805
|
+
for (const key of below)
|
|
806
|
+
if (parts[parts.length - 1] !== key)
|
|
807
|
+
parts.push(/** @type {string} */ (key));
|
|
808
|
+
const leaf = chain[chain.length - 1];
|
|
809
|
+
const base = {
|
|
810
|
+
doc: where.doc,
|
|
811
|
+
line: where.line,
|
|
812
|
+
export: where.exportName,
|
|
813
|
+
type: where.typeName,
|
|
814
|
+
severity: /** @type {const} */ ('warning'),
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
const missing =
|
|
818
|
+
/Property '([^']+)' is missing in type/.exec(leaf)?.[1] != null
|
|
819
|
+
? [/Property '([^']+)' is missing in type/.exec(leaf)?.[1]]
|
|
820
|
+
: /is missing the following properties from type '[^']*': (.+?)(?:, and \d+ more)?\.?$/
|
|
821
|
+
.exec(leaf)?.[1]
|
|
822
|
+
?.split(', ');
|
|
823
|
+
if (missing?.length) {
|
|
824
|
+
const owner = /required in type '([^']+)'|from type '([^']+)'/.exec(leaf);
|
|
825
|
+
const named = owner?.[1] ?? owner?.[2];
|
|
826
|
+
const ownerType =
|
|
827
|
+
named != null && /^[A-Za-z_$][\w$]*$/.test(named)
|
|
828
|
+
? named
|
|
829
|
+
: where.typeName;
|
|
830
|
+
return missing.map(name => {
|
|
831
|
+
const field = formatPath([
|
|
832
|
+
...parts,
|
|
833
|
+
unquote(/** @type {string} */ (name)),
|
|
834
|
+
]);
|
|
835
|
+
return {
|
|
836
|
+
...base,
|
|
837
|
+
field,
|
|
838
|
+
problem: /** @type {const} */ ('missing_field'),
|
|
839
|
+
message: shorten(leaf),
|
|
840
|
+
fix: `Add \`${field}\`: ${shorten(ownerType)} requires it.`,
|
|
841
|
+
};
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const unknown =
|
|
846
|
+
/Object literal may only specify known properties, (?:and|but) '([^']+)' does not exist in type '([^']+)'(?:\. Did you mean to write '([^']+)'\?)?/.exec(
|
|
847
|
+
chain.find(message =>
|
|
848
|
+
/Object literal may only specify known properties/.test(message),
|
|
849
|
+
) ?? '',
|
|
850
|
+
);
|
|
851
|
+
if (unknown) {
|
|
852
|
+
const key = unquote(unknown[1]);
|
|
853
|
+
const owner = /^[A-Za-z_$][\w$]*$/.test(unknown[2])
|
|
854
|
+
? unknown[2]
|
|
855
|
+
: where.typeName;
|
|
856
|
+
const suggestion = unknown[3] == null ? undefined : unquote(unknown[3]);
|
|
857
|
+
if (parts[parts.length - 1] !== key) parts.push(key);
|
|
858
|
+
const field = formatPath(parts);
|
|
859
|
+
return [
|
|
860
|
+
{
|
|
861
|
+
...base,
|
|
862
|
+
field,
|
|
863
|
+
problem: 'unknown_field',
|
|
864
|
+
message: shorten(leaf),
|
|
865
|
+
fix: suggestion
|
|
866
|
+
? `Rename \`${field}\` to \`${suggestion}\`.`
|
|
867
|
+
: `Remove \`${field}\`: ${shorten(owner)} has no field \`${key}\`.`,
|
|
868
|
+
},
|
|
869
|
+
];
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const field = formatPath(parts);
|
|
873
|
+
const didYouMean = /Did you mean '([^']+)'\?/.exec(chain.join(' '))?.[1];
|
|
874
|
+
const assignable = [...chain]
|
|
875
|
+
.reverse()
|
|
876
|
+
.map(message => /is not assignable to type '([^']+)'/.exec(message)?.[1])
|
|
877
|
+
.find(type => type != null);
|
|
878
|
+
let fix;
|
|
879
|
+
if (field === 'type' && where.root === 'components') {
|
|
880
|
+
fix =
|
|
881
|
+
"This doc is in the components root, so it is a component doc: set type: 'component', or move it to the docs root.";
|
|
882
|
+
} else if (didYouMean) {
|
|
883
|
+
fix = `Change \`${field || 'the doc'}\` to ${didYouMean}.`;
|
|
884
|
+
} else if (assignable) {
|
|
885
|
+
fix = `Change \`${field || 'the doc'}\` to a value of type ${shorten(`'${assignable}'`).slice(1, -1)}.`;
|
|
886
|
+
} else {
|
|
887
|
+
fix = `Make \`${field || 'the doc'}\` match ${where.typeName}.`;
|
|
888
|
+
}
|
|
889
|
+
return [
|
|
890
|
+
{
|
|
891
|
+
...base,
|
|
892
|
+
field,
|
|
893
|
+
problem: 'wrong_type',
|
|
894
|
+
message: shorten(leaf),
|
|
895
|
+
fix,
|
|
896
|
+
},
|
|
897
|
+
];
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Check each doc file's values against the published authoring types.
|
|
902
|
+
* @param {DocFile[]} files
|
|
903
|
+
* @param {{packageDir: string, resolveFrom: Array<string | null | undefined>, core?: boolean}} options
|
|
904
|
+
* `core`: the docs are Core's own, so templates are held to TemplateDoc
|
|
905
|
+
* @returns {Promise<DocTypesReport>}
|
|
906
|
+
*/
|
|
907
|
+
export async function checkDocTypes(files, options) {
|
|
908
|
+
const {packageDir, resolveFrom, core = false} = options;
|
|
909
|
+
// With no doc files there is nothing to check, TypeScript or not.
|
|
910
|
+
if (files.length === 0) {
|
|
911
|
+
return {status: 'checked', docs: 0, findings: [], unchecked: []};
|
|
912
|
+
}
|
|
913
|
+
const compiler = resolveProjectTypeScript(resolveFrom);
|
|
914
|
+
if (!compiler?.ts) {
|
|
915
|
+
return {
|
|
916
|
+
status: 'skipped',
|
|
917
|
+
reason: compiler
|
|
918
|
+
? unusableTypeScriptReason(compiler.version, 'docs were not checked')
|
|
919
|
+
: DOC_TYPES_TYPESCRIPT_MISSING_REASON,
|
|
920
|
+
docs: 0,
|
|
921
|
+
findings: [],
|
|
922
|
+
unchecked: [],
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
const {ts, version} = compiler;
|
|
926
|
+
const [major = 0, minor = 0] = String(ts.versionMajorMinor ?? version)
|
|
927
|
+
.split('.')
|
|
928
|
+
.map(Number);
|
|
929
|
+
const hasJs = files.some(file => !/\.ts$/.test(file.path));
|
|
930
|
+
// Every doc is checked with `satisfies`, which needs TypeScript 4.9; its
|
|
931
|
+
// JSDoc form, for a JavaScript doc, needs 5.0.
|
|
932
|
+
const [needMajor, needMinor] = hasJs ? [5, 0] : [4, 9];
|
|
933
|
+
if (major < needMajor || (major === needMajor && minor < needMinor)) {
|
|
934
|
+
return {
|
|
935
|
+
status: 'skipped',
|
|
936
|
+
reason: `TypeScript ${version} cannot check ${hasJs ? 'a JavaScript doc' : 'a doc'} against its type (that needs TypeScript ${needMajor}.${needMinor} or later), so docs were not checked. Upgrade typescript and run this check again.`,
|
|
937
|
+
docs: 0,
|
|
938
|
+
findings: [],
|
|
939
|
+
unchecked: [],
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
const rel = (/** @type {string} */ file) =>
|
|
943
|
+
path.relative(packageDir, file).split(path.sep).join('/');
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* @typedef {object} Entry
|
|
947
|
+
* @property {DocFile} file
|
|
948
|
+
* @property {any} sf the original source
|
|
949
|
+
* @property {Target[]} targets
|
|
950
|
+
* @property {boolean} isTs
|
|
951
|
+
* @property {{text: string, toOriginal: (v: number) => number}} virtual
|
|
952
|
+
* @property {any} virtualSf
|
|
953
|
+
* @property {Array<[number, number]>} removed
|
|
954
|
+
* @property {Array<[number, number]>} casts
|
|
955
|
+
* @property {Set<string>} seen
|
|
956
|
+
*/
|
|
957
|
+
/** @type {Map<string, Entry>} */
|
|
958
|
+
const instrumented = new Map();
|
|
959
|
+
/** @type {DocTypesUnchecked[]} */
|
|
960
|
+
const unchecked = [];
|
|
961
|
+
for (const file of files) {
|
|
962
|
+
let text;
|
|
963
|
+
try {
|
|
964
|
+
text = fs.readFileSync(file.path, 'utf8');
|
|
965
|
+
} catch (err) {
|
|
966
|
+
unchecked.push({
|
|
967
|
+
doc: rel(file.path),
|
|
968
|
+
reason: `it cannot be read: ${err instanceof Error ? err.message : String(err)}`,
|
|
969
|
+
});
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
const isTs = /\.ts$/.test(file.path);
|
|
973
|
+
const sf = ts.createSourceFile(
|
|
974
|
+
file.path,
|
|
975
|
+
text,
|
|
976
|
+
ts.ScriptTarget.Latest,
|
|
977
|
+
true,
|
|
978
|
+
isTs ? ts.ScriptKind.TS : ts.ScriptKind.JS,
|
|
979
|
+
);
|
|
980
|
+
const targets = targetsOf(ts, sf, file.root, core);
|
|
981
|
+
if (targets.length === 0) {
|
|
982
|
+
const names = MAIN_EXPORTS[file.root];
|
|
983
|
+
const from = reexportSource(ts, sf, [...names, ...TRANSLATIONS]);
|
|
984
|
+
unchecked.push({
|
|
985
|
+
doc: rel(file.path),
|
|
986
|
+
reason: from
|
|
987
|
+
? `it re-exports its doc from ${from}, which this check does not follow, so it was not checked`
|
|
988
|
+
: names.length > 0
|
|
989
|
+
? `it exports no doc the CLI reads (${names.join(' or ')})`
|
|
990
|
+
: `it exports no translation the CLI reads (${TRANSLATIONS.join(' or ')})`,
|
|
991
|
+
});
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
const virtual = instrument(ts, sf, targets, isTs);
|
|
995
|
+
instrumented.set(path.resolve(file.path), {
|
|
996
|
+
file,
|
|
997
|
+
sf,
|
|
998
|
+
targets,
|
|
999
|
+
isTs,
|
|
1000
|
+
virtual,
|
|
1001
|
+
virtualSf: virtualSourceFile(ts, file.path, virtual.text, isTs),
|
|
1002
|
+
removed: [],
|
|
1003
|
+
casts: [],
|
|
1004
|
+
seen: new Set(),
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const options_ = {
|
|
1009
|
+
allowJs: true,
|
|
1010
|
+
checkJs: true,
|
|
1011
|
+
noEmit: true,
|
|
1012
|
+
skipLibCheck: true,
|
|
1013
|
+
strict: true,
|
|
1014
|
+
noImplicitAny: false,
|
|
1015
|
+
target: ts.ScriptTarget.ES2022,
|
|
1016
|
+
module: ts.ModuleKind.ESNext,
|
|
1017
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
1018
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
1019
|
+
resolveJsonModule: true,
|
|
1020
|
+
esModuleInterop: true,
|
|
1021
|
+
allowImportingTsExtensions: true,
|
|
1022
|
+
types: [],
|
|
1023
|
+
paths: {[AUTHORING]: [AUTHORING_TYPES]},
|
|
1024
|
+
};
|
|
1025
|
+
const host = ts.createCompilerHost(options_);
|
|
1026
|
+
const getSourceFile = host.getSourceFile.bind(host);
|
|
1027
|
+
// Every other file is parsed once, so a later pass reuses it.
|
|
1028
|
+
/** @type {Map<string, any>} */
|
|
1029
|
+
const parsed = new Map();
|
|
1030
|
+
host.getSourceFile = (
|
|
1031
|
+
/** @type {string} */ fileName,
|
|
1032
|
+
/** @type {any} */ languageVersion,
|
|
1033
|
+
/** @type {any} */ onError,
|
|
1034
|
+
/** @type {any} */ shouldCreate,
|
|
1035
|
+
) => {
|
|
1036
|
+
const entry = instrumented.get(path.resolve(fileName));
|
|
1037
|
+
if (entry) return entry.virtualSf;
|
|
1038
|
+
if (!parsed.has(fileName)) {
|
|
1039
|
+
parsed.set(
|
|
1040
|
+
fileName,
|
|
1041
|
+
getSourceFile(fileName, languageVersion, onError, shouldCreate),
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
return parsed.get(fileName);
|
|
1045
|
+
};
|
|
1046
|
+
const readFile = host.readFile.bind(host);
|
|
1047
|
+
host.readFile = (/** @type {string} */ fileName) =>
|
|
1048
|
+
instrumented.get(path.resolve(fileName))?.virtual.text ??
|
|
1049
|
+
readFile(fileName);
|
|
1050
|
+
|
|
1051
|
+
/** @type {DocTypeFinding[]} */
|
|
1052
|
+
const findings = [];
|
|
1053
|
+
const stamped = new Set(); // docs-root docs whose `type` names no type
|
|
1054
|
+
let checked = [...instrumented.values()].reduce(
|
|
1055
|
+
(sum, entry) => sum + entry.targets.length,
|
|
1056
|
+
0,
|
|
1057
|
+
);
|
|
1058
|
+
const rootNames = [...instrumented.values()].map(entry => entry.file.path);
|
|
1059
|
+
// TypeScript stops at an object's first unknown field, and a value outside
|
|
1060
|
+
// its type hides the rest of its object, so a doc is checked again with each
|
|
1061
|
+
// unknown field blanked and each such value cast to `any`, until nothing new
|
|
1062
|
+
// is found, the doc's own `type` too where its root fixes its type.
|
|
1063
|
+
/** @type {Entry[]} */
|
|
1064
|
+
let pending = [...instrumented.values()];
|
|
1065
|
+
/** @type {any} */
|
|
1066
|
+
let program;
|
|
1067
|
+
for (let pass = 0; pending.length > 0; pass++) {
|
|
1068
|
+
if (pass === MAX_PASSES) {
|
|
1069
|
+
for (const entry of pending) {
|
|
1070
|
+
unchecked.push({
|
|
1071
|
+
doc: rel(entry.file.path),
|
|
1072
|
+
reason: `TypeScript still found new problems after ${MAX_PASSES} passes, so not every field was checked; fix the ones reported and run the check again`,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
break;
|
|
1076
|
+
}
|
|
1077
|
+
program = ts.createProgram({
|
|
1078
|
+
rootNames,
|
|
1079
|
+
options: options_,
|
|
1080
|
+
host,
|
|
1081
|
+
oldProgram: program,
|
|
1082
|
+
});
|
|
1083
|
+
/** @type {Entry[]} */
|
|
1084
|
+
const again = [];
|
|
1085
|
+
for (const entry of pending) {
|
|
1086
|
+
const virtualSf = program.getSourceFile(entry.file.path);
|
|
1087
|
+
if (!virtualSf) continue;
|
|
1088
|
+
const doc = rel(entry.file.path);
|
|
1089
|
+
// Text the compiler cannot parse proves nothing about its types.
|
|
1090
|
+
const [syntax] = program.getSyntacticDiagnostics(virtualSf);
|
|
1091
|
+
if (syntax) {
|
|
1092
|
+
if (pass === 0) checked -= entry.targets.length;
|
|
1093
|
+
unchecked.push({
|
|
1094
|
+
doc,
|
|
1095
|
+
reason: `TypeScript ${version} cannot parse it with its type check added (${ts.flattenDiagnosticMessageText(syntax.messageText, ' ')}), so ${pass === 0 ? 'it was not checked' : 'not every field was checked'}`,
|
|
1096
|
+
});
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
let changed = false;
|
|
1100
|
+
for (const diagnostic of program.getSemanticDiagnostics(virtualSf)) {
|
|
1101
|
+
if (!RELEVANT.has(diagnostic.code) || diagnostic.start == null) {
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
const pos = entry.virtual.toOriginal(diagnostic.start);
|
|
1105
|
+
const target = entry.targets.find(
|
|
1106
|
+
candidate =>
|
|
1107
|
+
candidate.node.getStart(entry.sf) <= pos &&
|
|
1108
|
+
pos <= candidate.node.end,
|
|
1109
|
+
);
|
|
1110
|
+
if (!target) continue;
|
|
1111
|
+
const astPath = fieldPathAt(ts, entry.sf, target.node, pos);
|
|
1112
|
+
const line = entry.sf.getLineAndCharacterOfPosition(pos).line + 1;
|
|
1113
|
+
for (const finding of findingsFor(ts, diagnostic, astPath, {
|
|
1114
|
+
root: entry.file.root,
|
|
1115
|
+
doc,
|
|
1116
|
+
line,
|
|
1117
|
+
exportName: target.exportName,
|
|
1118
|
+
typeName: target.typeName,
|
|
1119
|
+
})) {
|
|
1120
|
+
if (finding.problem === 'unknown_field') {
|
|
1121
|
+
const range = propertyRangeAt(ts, entry.sf, target.node, pos);
|
|
1122
|
+
if (range && !entry.removed.some(([from]) => from === range[0])) {
|
|
1123
|
+
entry.removed.push(range);
|
|
1124
|
+
changed = true;
|
|
1125
|
+
}
|
|
1126
|
+
} else if (
|
|
1127
|
+
finding.problem === 'missing_field' &&
|
|
1128
|
+
/[.[]/.test(finding.field)
|
|
1129
|
+
) {
|
|
1130
|
+
// A nested object missing a field hides the doc's other
|
|
1131
|
+
// findings, so that object is set aside.
|
|
1132
|
+
const range =
|
|
1133
|
+
valueRangeAt(ts, entry.sf, target.node, pos) ??
|
|
1134
|
+
objectRangeAt(ts, entry.sf, target.node, pos);
|
|
1135
|
+
if (range && !entry.casts.some(([from]) => from === range[0])) {
|
|
1136
|
+
entry.casts.push(range);
|
|
1137
|
+
changed = true;
|
|
1138
|
+
}
|
|
1139
|
+
} else if (finding.problem === 'wrong_type') {
|
|
1140
|
+
// A deeper `type` picks the shape of the object that holds it, so
|
|
1141
|
+
// that object is set aside; a docs-root doc's own `type` is not.
|
|
1142
|
+
const picked =
|
|
1143
|
+
finding.field === 'type' &&
|
|
1144
|
+
entry.file.root === 'docs' &&
|
|
1145
|
+
!TRANSLATIONS.includes(target.exportName);
|
|
1146
|
+
if (picked && !stamped.has(doc)) {
|
|
1147
|
+
stamped.add(doc);
|
|
1148
|
+
unchecked.push({
|
|
1149
|
+
doc,
|
|
1150
|
+
reason:
|
|
1151
|
+
'its `type` names no type the docs root takes, so its other fields were not checked; fix the `type` and run the check again',
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
const range = picked
|
|
1155
|
+
? null
|
|
1156
|
+
: /\.type$/.test(finding.field)
|
|
1157
|
+
? objectRangeAt(ts, entry.sf, target.node, pos)
|
|
1158
|
+
: valueRangeAt(ts, entry.sf, target.node, pos);
|
|
1159
|
+
if (range && !entry.casts.some(([from]) => from === range[0])) {
|
|
1160
|
+
entry.casts.push(range);
|
|
1161
|
+
changed = true;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
const key = `${finding.export}\0${finding.field}\0${finding.problem}`;
|
|
1165
|
+
if (entry.seen.has(key)) continue;
|
|
1166
|
+
entry.seen.add(key);
|
|
1167
|
+
// After a pass sets values aside, TypeScript quotes them as `any`:
|
|
1168
|
+
// name the value instead of quoting text the doc does not have.
|
|
1169
|
+
if (pass > 0) finding.message = unquoteSetAside(finding.message);
|
|
1170
|
+
findings.push(finding);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
if (changed) {
|
|
1174
|
+
entry.virtual = instrument(
|
|
1175
|
+
ts,
|
|
1176
|
+
entry.sf,
|
|
1177
|
+
entry.targets,
|
|
1178
|
+
entry.isTs,
|
|
1179
|
+
entry.removed,
|
|
1180
|
+
entry.casts,
|
|
1181
|
+
);
|
|
1182
|
+
entry.virtualSf = virtualSourceFile(
|
|
1183
|
+
ts,
|
|
1184
|
+
entry.file.path,
|
|
1185
|
+
entry.virtual.text,
|
|
1186
|
+
entry.isTs,
|
|
1187
|
+
);
|
|
1188
|
+
again.push(entry);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
pending = again;
|
|
1192
|
+
}
|
|
1193
|
+
findings.sort(
|
|
1194
|
+
(a, b) =>
|
|
1195
|
+
a.doc.localeCompare(b.doc) ||
|
|
1196
|
+
a.line - b.line ||
|
|
1197
|
+
a.field.localeCompare(b.field),
|
|
1198
|
+
);
|
|
1199
|
+
unchecked.sort((a, b) => a.doc.localeCompare(b.doc));
|
|
1200
|
+
return {
|
|
1201
|
+
status: 'checked',
|
|
1202
|
+
typescript: version,
|
|
1203
|
+
docs: checked,
|
|
1204
|
+
// A docs-root doc whose `type` names no type reports only that.
|
|
1205
|
+
findings: findings.filter(f => !stamped.has(f.doc) || f.field === 'type'),
|
|
1206
|
+
unchecked,
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* @param {any} ts
|
|
1212
|
+
* @param {string} fileName
|
|
1213
|
+
* @param {string} text
|
|
1214
|
+
* @param {boolean} isTs
|
|
1215
|
+
*/
|
|
1216
|
+
function virtualSourceFile(ts, fileName, text, isTs) {
|
|
1217
|
+
return ts.createSourceFile(
|
|
1218
|
+
fileName,
|
|
1219
|
+
text,
|
|
1220
|
+
ts.ScriptTarget.Latest,
|
|
1221
|
+
true,
|
|
1222
|
+
isTs ? ts.ScriptKind.TS : ts.ScriptKind.JS,
|
|
1223
|
+
);
|
|
1224
|
+
}
|