@astryxdesign/cli 0.7.2-canary.fbc03f7 → 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.
Files changed (139) hide show
  1. package/README.md +9 -8
  2. package/api/build/help/help.mjs +1 -1
  3. package/api/component/_adapter.d.mts +41 -1
  4. package/api/component/_adapter.mjs +93 -7
  5. package/api/component/component.mjs +22 -5
  6. package/api/component/integration-export-name.test.mjs +644 -0
  7. package/api/docs/_adapter.d.mts +11 -0
  8. package/api/docs/_adapter.mjs +244 -1
  9. package/api/docs/integrationDocs.test.mjs +124 -0
  10. package/api/doctor/theme-checks.mjs +1 -1
  11. package/api/gap-report/gap-report.mjs +67 -4
  12. package/api/index.d.mts +2 -0
  13. package/api/index.mjs +2 -0
  14. package/api/init/run/run.mjs +2 -2
  15. package/api/integration/_adapter.d.mts +12 -0
  16. package/api/integration/_adapter.mjs +54 -0
  17. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  18. package/api/integration/add-contribution.mjs +8 -4
  19. package/api/integration/add-contribution.test.mjs +4 -2
  20. package/api/integration/authoring-checks.mjs +11 -2
  21. package/api/integration/authoring-checks.test.mjs +39 -0
  22. package/api/integration/authoring-checks.type.d.mts +9 -0
  23. package/api/integration/authoring-checks.type.mjs +14 -1
  24. package/api/integration/check/check.d.mts +96 -0
  25. package/api/integration/check/check.mjs +357 -0
  26. package/api/integration/check/check.test.mjs +489 -0
  27. package/api/integration/integration-check.type.d.mts +118 -0
  28. package/api/integration/integration-check.type.mjs +84 -0
  29. package/api/integration/integrationCheck.doc.d.mts +10 -0
  30. package/api/integration/integrationCheck.doc.mjs +73 -0
  31. package/api/integration/integrationComponentConflicts.doc.mjs +36 -5
  32. package/api/integration/pack-check.mjs +51 -97
  33. package/api/integration/pack-check.test.mjs +359 -0
  34. package/api/integration/validate-integration.mjs +8 -0
  35. package/api/integration/validate-integration.test.mjs +11 -0
  36. package/api/integration/validate-integration.type.d.mts +8 -0
  37. package/api/integration/validate-integration.type.mjs +12 -1
  38. package/api/integration/validateIntegration.doc.mjs +17 -3
  39. package/api/search/search.d.mts +9 -0
  40. package/api/search/search.integration-check.test.mjs +24 -0
  41. package/api/search/search.mjs +100 -13
  42. package/api/swizzle/copy/copy.mjs +28 -14
  43. package/api/theme/build/build.family.test.mjs +54 -6
  44. package/api/theme/build/build.mjs +114 -17
  45. package/api/theme/themeBuild.doc.mjs +4 -4
  46. package/assets/docs/README.md +10 -0
  47. package/assets/docs/author-a-theme.doc.mjs +1 -1
  48. package/assets/docs/getting-started.doc.mjs +3 -3
  49. package/assets/docs/theme.doc.dense.mjs +1 -1
  50. package/assets/docs/theme.doc.mjs +1 -1
  51. package/assets/docs/theme.doc.zh.mjs +1 -1
  52. package/assets/docs/tree/add-a-component.doc.mjs +10 -0
  53. package/assets/docs/tree/block-template.doc.mjs +2 -2
  54. package/assets/docs/tree/check-the-props.doc.mjs +196 -0
  55. package/assets/docs/tree/check-your-docs.doc.mjs +44 -3
  56. package/assets/docs/tree/checks.doc.mjs +21 -13
  57. package/assets/docs/tree/component-family.doc.mjs +5 -1
  58. package/assets/docs/tree/component-imports.doc.mjs +8 -0
  59. package/assets/docs/tree/describe-the-component.doc.mjs +1 -1
  60. package/assets/docs/tree/links.doc.mjs +2 -2
  61. package/assets/docs/tree/page-template.doc.mjs +1 -1
  62. package/assets/docs/tree/quick-start.doc.mjs +1 -1
  63. package/assets/docs/tree/replace-a-core-template.doc.mjs +1 -1
  64. package/assets/docs/tree/single-component.doc.mjs +3 -2
  65. package/assets/docs/tree/troubleshooting.doc.mjs +9 -1
  66. package/assets/docs/tree/type-scale.doc.mjs +3 -3
  67. package/assets/docs/tree/versioning.doc.mjs +1 -0
  68. package/assets/docs/tree/write-the-template-file.doc.mjs +2 -1
  69. package/assets/docs/use-a-theme.doc.mjs +2 -2
  70. package/assets/theme.template.ts +1 -1
  71. package/authoring/doctypes/component/component.doc.mjs +2 -2
  72. package/authoring/doctypes/component/type.ts +6 -3
  73. package/authoring/doctypes/template/integration-template.doc.d.mts +11 -0
  74. package/authoring/doctypes/template/integration-template.doc.mjs +49 -0
  75. package/authoring/doctypes/template/parse.d.mts +6 -2
  76. package/authoring/doctypes/template/parse.mjs +3 -1
  77. package/authoring/doctypes/template/template.doc.mjs +1 -1
  78. package/authoring/doctypes/template/type.ts +22 -0
  79. package/authoring/index.d.ts +1 -0
  80. package/authoring/integration/integration.doc.mjs +8 -1
  81. package/authoring/integration/parse.test.mjs +10 -0
  82. package/authoring/integration/schema.d.mts +2 -0
  83. package/authoring/integration/schema.mjs +1 -0
  84. package/authoring/integration/type.ts +4 -0
  85. package/clients/cli/commands/build-theme.batch-report.test.mjs +3 -1
  86. package/clients/cli/commands/build-theme.mjs +1 -1
  87. package/clients/cli/commands/build-theme.path-safety.test.mjs +155 -21
  88. package/clients/cli/commands/doctor-integration-components.doc.mjs +15 -4
  89. package/clients/cli/commands/doctor-integration-validate.doc.mjs +11 -2
  90. package/clients/cli/commands/doctor-integration.test.mjs +837 -0
  91. package/clients/cli/commands/doctor.mjs +190 -0
  92. package/clients/cli/commands/integration-check.doc.mjs +85 -0
  93. package/clients/cli/commands/integration-check.test.mjs +267 -0
  94. package/clients/cli/commands/integration.doc.mjs +6 -2
  95. package/clients/cli/commands/integration.mjs +107 -1
  96. package/clients/cli/commands/text-json-parity.test.mjs +5 -0
  97. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  98. package/clients/cli/index.mjs +1 -0
  99. package/foundation/config/project.mjs +82 -0
  100. package/foundation/discovery/authoring-self-docs.mjs +1 -0
  101. package/foundation/discovery/component-discovery.d.mts +178 -10
  102. package/foundation/discovery/component-discovery.export-names.test.mjs +760 -0
  103. package/foundation/discovery/component-discovery.mjs +659 -62
  104. package/foundation/discovery/component-replacement.mjs +18 -0
  105. package/foundation/discovery/component-replacement.test.mjs +40 -0
  106. package/foundation/discovery/doc-types.d.mts +157 -0
  107. package/foundation/discovery/doc-types.mjs +1224 -0
  108. package/foundation/discovery/doc-types.test.mjs +652 -0
  109. package/foundation/discovery/docs-discovery.d.mts +81 -0
  110. package/foundation/discovery/docs-discovery.mjs +57 -0
  111. package/foundation/discovery/integration-doc-files.d.mts +22 -0
  112. package/foundation/discovery/integration-doc-files.mjs +231 -0
  113. package/foundation/discovery/integration-doc-files.test.mjs +246 -0
  114. package/foundation/discovery/module-exports.d.mts +27 -0
  115. package/foundation/discovery/module-exports.mjs +356 -31
  116. package/foundation/discovery/module-exports.test.mjs +172 -0
  117. package/foundation/discovery/package-tsconfig.d.mts +14 -0
  118. package/foundation/discovery/package-tsconfig.mjs +228 -0
  119. package/foundation/discovery/package-tsconfig.test.mjs +210 -0
  120. package/foundation/discovery/project-typescript.d.mts +23 -0
  121. package/foundation/discovery/project-typescript.mjs +61 -0
  122. package/foundation/discovery/prop-drift.d.mts +278 -0
  123. package/foundation/discovery/prop-drift.mjs +2263 -0
  124. package/foundation/discovery/prop-drift.test.mjs +2219 -0
  125. package/foundation/discovery/type-match.d.mts +53 -0
  126. package/foundation/discovery/type-match.mjs +660 -0
  127. package/foundation/discovery/type-match.test.mjs +169 -0
  128. package/foundation/discovery/type-text.d.mts +68 -0
  129. package/foundation/discovery/type-text.mjs +658 -0
  130. package/foundation/discovery/type-text.test.mjs +196 -0
  131. package/foundation/discovery/unresolved-references.d.mts +62 -0
  132. package/foundation/discovery/unresolved-references.mjs +790 -0
  133. package/foundation/discovery/unresolved-references.test.mjs +567 -0
  134. package/foundation/doc-compiler/doc-loads.test.mjs +6 -0
  135. package/foundation/doc-compiler/links.d.mts +9 -5
  136. package/foundation/doc-compiler/links.mjs +4 -2
  137. package/foundation/integrations/contribution-inventory.mjs +6 -1
  138. package/foundation/response/response-types.doc.mjs +7 -2
  139. package/package.json +9 -9
@@ -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
+ }