@znight/graphql 0.0.0 → 0.2.1

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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1346 -0
  3. package/dist/compiler.d.mts +114 -0
  4. package/dist/compiler.mjs +4599 -0
  5. package/dist/compiler.mjs.map +1 -0
  6. package/dist/index.d.mts +583 -0
  7. package/dist/index.mjs +134 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/names-BlzDM5cF.mjs +111 -0
  10. package/dist/names-BlzDM5cF.mjs.map +1 -0
  11. package/dist/plugin-CR_wVfRU.mjs +3445 -0
  12. package/dist/plugin-CR_wVfRU.mjs.map +1 -0
  13. package/dist/testing.d.mts +101 -0
  14. package/dist/testing.mjs +306 -0
  15. package/dist/testing.mjs.map +1 -0
  16. package/dist/types-DQ713UgI.d.mts +266 -0
  17. package/package.json +97 -1
  18. package/src/compiler/analyze.ts +727 -0
  19. package/src/compiler/builder.ts +1561 -0
  20. package/src/compiler/checks.ts +105 -0
  21. package/src/compiler/conformance.ts +1068 -0
  22. package/src/compiler/constants.ts +80 -0
  23. package/src/compiler/data.ts +83 -0
  24. package/src/compiler/defaults.ts +92 -0
  25. package/src/compiler/discriminators.ts +53 -0
  26. package/src/compiler/docs.ts +55 -0
  27. package/src/compiler/generate.ts +419 -0
  28. package/src/compiler/graphql-js.ts +42 -0
  29. package/src/compiler/ids.ts +128 -0
  30. package/src/compiler/index.ts +98 -0
  31. package/src/compiler/locate.ts +82 -0
  32. package/src/compiler/model.ts +137 -0
  33. package/src/compiler/options.ts +85 -0
  34. package/src/compiler/scalars.ts +138 -0
  35. package/src/compiler/schema-first.ts +595 -0
  36. package/src/compiler/sdl-source.ts +529 -0
  37. package/src/compiler/sdl-view.ts +61 -0
  38. package/src/compiler/suggest.ts +39 -0
  39. package/src/context.ts +181 -0
  40. package/src/decisions.ts +169 -0
  41. package/src/decorators.ts +202 -0
  42. package/src/errors.ts +89 -0
  43. package/src/execution.ts +1064 -0
  44. package/src/format.ts +285 -0
  45. package/src/index.ts +89 -0
  46. package/src/limits.ts +177 -0
  47. package/src/loaders.ts +149 -0
  48. package/src/memory-pubsub.ts +133 -0
  49. package/src/metadata.ts +222 -0
  50. package/src/names.ts +47 -0
  51. package/src/options.ts +331 -0
  52. package/src/pipeline.ts +338 -0
  53. package/src/plugin.ts +579 -0
  54. package/src/pubsub.ts +36 -0
  55. package/src/results.ts +206 -0
  56. package/src/scalars.ts +138 -0
  57. package/src/schema-hash.ts +18 -0
  58. package/src/schema.ts +635 -0
  59. package/src/sdl.ts +530 -0
  60. package/src/testing.ts +454 -0
  61. package/src/tokens.ts +22 -0
  62. package/src/transport/abort.ts +80 -0
  63. package/src/transport/accept.ts +155 -0
  64. package/src/transport/graphiql.ts +106 -0
  65. package/src/transport/http.ts +373 -0
  66. package/src/transport/sse.ts +45 -0
  67. package/src/type-ref.ts +89 -0
  68. package/src/types.ts +225 -0
  69. package/src/validate-args.ts +48 -0
@@ -0,0 +1,1561 @@
1
+ /**
2
+ * The schema model (RFC 0005 §6.2–§6.6): starting from the root fields, every reachable TypeScript
3
+ * type is read through the type shapes API (`ctx.types.shape()`, members `'instance'`, aliases kept
4
+ * as named shapes) and mapped to a GraphQL type, row by row of the §6.2 table. Names follow §6.4,
5
+ * objects, inputs and arguments §6.3; every problem is a diagnostic with a location, a hint and the
6
+ * path from the root field ("reached from Query.book → Book").
7
+ *
8
+ * Built once per compile (`validate`), read by `generate`. Shapes are cached by the compiler per
9
+ * options for the whole compile, so the walk only pays for what it visits once.
10
+ */
11
+ import type {
12
+ ClassInfo,
13
+ Diagnostic,
14
+ Location,
15
+ MethodInfo,
16
+ NamedShape,
17
+ ParameterInfo,
18
+ Shape,
19
+ ShapeOptions,
20
+ ShapeProperty,
21
+ ShapeResult,
22
+ ValidateContext,
23
+ } from '@znight/compiler'
24
+ import type { MetaEntry } from '@znight/http/compiler'
25
+ import type { GraphQLTypeRef } from '../type-ref.ts'
26
+ import type { FieldData, GraphQLData, ResolverData } from './data.ts'
27
+ import type {
28
+ ArgModel,
29
+ DiscriminantProperty,
30
+ DiscriminatorModel,
31
+ EnumValueModel,
32
+ FieldModel,
33
+ GraphQLModel,
34
+ InputModel,
35
+ ObjectModel,
36
+ ScalarModel,
37
+ TypeModel,
38
+ UnionModel,
39
+ } from './model.ts'
40
+ import type { ResolvedGraphQLCompilerOptions } from './options.ts'
41
+
42
+ import { isGraphQLName, RESERVED_ENUM_VALUES, ROOT_TYPE_NAMES, SPECIFIED_SCALARS } from '../names.ts'
43
+ import { schemaHash } from '../schema-hash.ts'
44
+ import { printSdl } from '../sdl.ts'
45
+ import { printTypeRef } from '../type-ref.ts'
46
+ import { checkDecisions, checkLoaders } from './checks.ts'
47
+ import { ASYNC_ITERABLES, BASE_KEEP, Codes, compareStrings, OUTPUT_ITERABLES } from './constants.ts'
48
+ import { externalValue } from './defaults.ts'
49
+ import { discriminatorOf } from './discriminators.ts'
50
+ import { defaultTagValue, deprecationOf, descriptionOf, isHidden, paramDescription, tagOf } from './docs.ts'
51
+ import { SCALAR_ALIASES } from './ids.ts'
52
+ import { annotationLocation } from './locate.ts'
53
+ import { readScalars, type ScalarDeclaration } from './scalars.ts'
54
+ import { sdlType } from './sdl-view.ts'
55
+
56
+ type Context = ValidateContext<GraphQLData>
57
+ type Position = 'input' | 'output'
58
+ type Operation = 'query' | 'mutation' | 'subscription'
59
+
60
+ /** Where a type is used: what diagnostics say and where they point. */
61
+ interface Use {
62
+ readonly position: Position
63
+ /** `Book.cover`, `Query.book`, `Query.books(limit:)`. */
64
+ readonly coordinate: string
65
+ /** Prefix of the trail of the types reached from here (`Query.book → Book.cover`). */
66
+ readonly trail: string
67
+ /** The trail of the owner of the coordinate (`Query.book → Book`), for "reached from". */
68
+ readonly reached: string | undefined
69
+ /** Location of the root field the walk started from. */
70
+ readonly root: Location | undefined
71
+ /** Where the type is written; called only when a diagnostic is reported. */
72
+ readonly locate: () => Location | undefined
73
+ }
74
+
75
+ /** A converted type: the reference (non-null when nothing made it nullable) and whether TS accepts `null`. */
76
+ interface Converted {
77
+ readonly ref: GraphQLTypeRef
78
+ readonly acceptsNull: boolean
79
+ }
80
+
81
+ /** A converted inner type: its reference and whether the type itself admits `null` (`JSONValue`). */
82
+ interface Inner {
83
+ readonly ref: GraphQLTypeRef
84
+ readonly nullable: boolean
85
+ }
86
+
87
+ /** A `@ResolveField` waiting for its parent type to be reached. */
88
+ interface PendingFieldResolver {
89
+ readonly resolver: ResolverData
90
+ readonly field: FieldData
91
+ readonly method: MethodInfo
92
+ attached: boolean
93
+ }
94
+
95
+ /** A default value to check once every type is complete. */
96
+ interface PendingDefault {
97
+ readonly target: ArgModel
98
+ readonly value: unknown
99
+ readonly use: Use
100
+ readonly location: Location | undefined
101
+ }
102
+
103
+ /** The first use of a declaration in a position (ZNIGHT063 "input and output"). */
104
+ interface PositionUse {
105
+ readonly coordinate: string
106
+ readonly location: Location | undefined
107
+ }
108
+
109
+ const NAME_HINT = 'GraphQL names match /^[_A-Za-z][_0-9A-Za-z]*$/ and do not start with __'
110
+ const RECORD_HINT =
111
+ "GraphQL objects have fixed fields: declare an interface, or use JSONValue (import type { JSONValue } from '@znight/graphql')"
112
+ const ROOT_NAMES: ReadonlySet<string> = new Set(Object.values(ROOT_TYPE_NAMES))
113
+
114
+ /** Builds the model; `undefined` when the application has no resolver. */
115
+ export function buildModel(ctx: Context, options: ResolvedGraphQLCompilerOptions): GraphQLModel | undefined {
116
+ const resolvers = [...ctx.stored].sort((a, b) => compareStrings(a.host.identity, b.host.identity))
117
+ if (resolvers.length === 0) return undefined
118
+ return new SchemaBuilder(ctx, options).build(resolvers)
119
+ }
120
+
121
+ class SchemaBuilder {
122
+ readonly #ctx: Context
123
+ readonly #number: 'Float' | 'Int'
124
+ readonly #shapeOptions: ShapeOptions
125
+ /** `scalar<T>()` declarations by the identity of `T`. */
126
+ readonly #scalars: ReadonlyMap<string, ScalarDeclaration>
127
+ /** Every named shape seen, by key. */
128
+ readonly #named = new Map<string, NamedShape>()
129
+ /** Models by GraphQL name (the first claimant). */
130
+ readonly #types = new Map<string, TypeModel>()
131
+ /** Models by declaration key. */
132
+ readonly #models = new Map<string, TypeModel>()
133
+ /** GraphQL names by declaration key. */
134
+ readonly #names = new Map<string, string | undefined>()
135
+ readonly #positions = new Map<string, { output?: PositionUse; input?: PositionUse }>()
136
+ readonly #reported = new Set<string>()
137
+ readonly #fieldResolvers = new Map<string, PendingFieldResolver[]>()
138
+ readonly #defaults: PendingDefault[] = []
139
+ readonly #methods = new Map<string, MethodInfo | undefined>()
140
+ #errors = 0
141
+
142
+ constructor(ctx: Context, options: ResolvedGraphQLCompilerOptions) {
143
+ this.#ctx = ctx
144
+ this.#number = options.number
145
+ const declarations = readScalars(ctx, diagnostic => this.#report(diagnostic))
146
+ this.#scalars = declarations
147
+ // shapes keep the scalar types as references: `T` of `scalar<T>()` maps to its scalar wherever it appears
148
+ const keep = [...new Set([...BASE_KEEP, ...declarations.keys()])].sort(compareStrings)
149
+ this.#shapeOptions = Object.freeze({ keep: Object.freeze(keep), members: 'instance', jsdoc: true })
150
+ }
151
+
152
+ build(resolvers: readonly ResolverData[]): GraphQLModel | undefined {
153
+ checkLoaders(this.#ctx, resolvers, diagnostic => this.#report(diagnostic))
154
+ this.#indexFieldResolvers(resolvers)
155
+ const roots = new Map<Operation, ObjectModel>()
156
+ const rootFields = new Map<string, { readonly field: FieldData; readonly resolver: ResolverData }>()
157
+ let hasQuery = false
158
+ for (const resolver of resolvers) {
159
+ for (const field of resolver.fields) {
160
+ if (field.operation === 'field') continue
161
+ if (field.operation === 'query') hasQuery = true
162
+ if (!field.complete) continue
163
+ const method = this.#method(resolver, field)
164
+ if (method === undefined) continue
165
+ const root = this.#rootModel(roots, field.operation)
166
+ const coordinate = `${root.name}.${field.name}`
167
+ const previous = rootFields.get(coordinate)
168
+ if (previous !== undefined) {
169
+ this.#report({
170
+ code: Codes.NameConflict,
171
+ severity: 'error',
172
+ message: `Two root fields are named ${coordinate}: ${resolver.host.displayName}.${field.method} and ${previous.resolver.host.displayName}.${previous.field.method}`,
173
+ location: field.location,
174
+ related: [{ message: 'the other field', location: previous.field.location }],
175
+ hints: [`Rename one of them: @${decoratorName(field.operation)}('otherName')`],
176
+ })
177
+ continue
178
+ }
179
+ rootFields.set(coordinate, { field, resolver })
180
+ root.fields.push(this.#resolverField(root, resolver, field, method))
181
+ }
182
+ }
183
+ if (!hasQuery) {
184
+ const first = resolvers[0] as ResolverData
185
+ this.#report({
186
+ code: Codes.InvalidResolver,
187
+ severity: 'error',
188
+ message: 'The application has GraphQL resolvers but no @Query field: GraphQL requires a query root type',
189
+ location: first.location,
190
+ hints: [`Add a @Query() method to a resolver (${first.host.displayName}, for instance)`],
191
+ })
192
+ }
193
+ this.#reportUnreachableFieldResolvers()
194
+ this.#checkDefaults()
195
+ this.#checkInputCycles()
196
+ checkDecisions(this.#ctx, resolvers, diagnostic => this.#report(diagnostic))
197
+ if (this.#errors > 0 || !hasQuery) return undefined
198
+ return this.#assemble(roots, resolvers)
199
+ }
200
+
201
+ // ------------------------------------------------------------------------------------- reporting
202
+
203
+ #report(diagnostic: Diagnostic): void {
204
+ if (diagnostic.severity === 'error') this.#errors++
205
+ this.#ctx.report(diagnostic)
206
+ }
207
+
208
+ /** Reports once per `key`. */
209
+ #reportOnce(key: string, diagnostic: Diagnostic): void {
210
+ if (this.#reported.has(key)) return
211
+ this.#reported.add(key)
212
+ this.#report(diagnostic)
213
+ }
214
+
215
+ #fail(code: Diagnostic['code'], use: Use, message: string, hints: readonly string[]): undefined {
216
+ const location = use.locate()
217
+ this.#report({
218
+ code,
219
+ severity: 'error',
220
+ message,
221
+ ...(location === undefined ? {} : { location }),
222
+ ...(hints.length === 0 ? {} : { hints }),
223
+ related: reachedFrom(use),
224
+ })
225
+ return undefined
226
+ }
227
+
228
+ // ------------------------------------------------------------------------------------ resolvers
229
+
230
+ #method(resolver: ResolverData, field: FieldData): MethodInfo | undefined {
231
+ const key = `${resolver.host.identity}#${field.method}`
232
+ if (!this.#methods.has(key)) {
233
+ const cls: ClassInfo | undefined = this.#ctx.classInfo(resolver.host.identity)
234
+ this.#methods.set(
235
+ key,
236
+ cls?.methods.find(method => method.name === field.method && method.kind === 'method'),
237
+ )
238
+ }
239
+ return this.#methods.get(key)
240
+ }
241
+
242
+ #shape(target: { readonly parameter: ParameterInfo } | { readonly returnOf: MethodInfo }): ShapeResult {
243
+ const result = this.#ctx.types.shape(target, this.#shapeOptions)
244
+ for (const named of result.named.values()) this.#named.set(named.key, named)
245
+ return result
246
+ }
247
+
248
+ #rootModel(roots: Map<Operation, ObjectModel>, operation: Operation): ObjectModel {
249
+ let root = roots.get(operation)
250
+ if (root === undefined) {
251
+ const name = ROOT_TYPE_NAMES[operation]
252
+ root = {
253
+ kind: 'object',
254
+ name,
255
+ key: `#root:${operation}`,
256
+ description: undefined,
257
+ location: undefined,
258
+ fields: [],
259
+ trail: name,
260
+ root: undefined,
261
+ classIdentity: undefined,
262
+ properties: [],
263
+ interfaces: [],
264
+ }
265
+ roots.set(operation, root)
266
+ this.#claim(root, undefined)
267
+ }
268
+ return root
269
+ }
270
+
271
+ /** A field backed by a resolver method (root field or field resolver). */
272
+ #resolverField(owner: ObjectModel, resolver: ResolverData, field: FieldData, method: MethodInfo): FieldModel {
273
+ const coordinate = `${owner.name}.${field.name}`
274
+ const isRoot = field.operation !== 'field'
275
+ const use: Use = {
276
+ position: 'output',
277
+ coordinate,
278
+ trail: isRoot ? coordinate : `${owner.trail}.${field.name}`,
279
+ reached: isRoot ? undefined : owner.trail,
280
+ root: isRoot ? method.location : owner.root,
281
+ locate: () => method.location,
282
+ }
283
+ const type = this.#returnType(resolver, field, method, use)
284
+ return {
285
+ name: field.name,
286
+ type: type === undefined ? 'String' : printTypeRef(type.ref),
287
+ description: descriptionOf(method.doc),
288
+ deprecationReason: deprecationOf(method.doc),
289
+ args: this.#arguments(field, method, use),
290
+ binding: { resolver, field },
291
+ location: field.location,
292
+ }
293
+ }
294
+
295
+ #returnType(resolver: ResolverData, field: FieldData, method: MethodInfo, use: Use): Converted | undefined {
296
+ const where = `${resolver.host.displayName}.${field.method}`
297
+ if (method.returnType === undefined) {
298
+ return this.#fail(Codes.InvalidType, use, `${where} has no return type annotation (${use.coordinate})`, [
299
+ `Annotate the return type: the schema is derived from it (${field.method}(...): Promise<Book>)`,
300
+ ])
301
+ }
302
+ const result = this.#shape({ returnOf: method })
303
+ const rootIssue = result.issues.find(issue => issue.named === undefined)
304
+ const located: Use = { ...use, locate: () => rootIssue?.location ?? method.location }
305
+ const root = result.root
306
+ const iterable = root.kind === 'reference' && ASYNC_ITERABLES.has(root.identity) ? root : undefined
307
+ if (field.operation === 'subscription') {
308
+ if (iterable === undefined) {
309
+ this.#report({
310
+ code: Codes.InvalidResolver,
311
+ severity: 'error',
312
+ message: `@Subscription ${where} must return an async iterable, not ${describeShape(root)}`,
313
+ location: method.location,
314
+ hints: [
315
+ `Declare it as an async generator (async *${field.method}(): AsyncGenerator<Event>) or return pubsub.subscribe(...) (AsyncIterable<Event>)`,
316
+ ],
317
+ })
318
+ return undefined
319
+ }
320
+ const item = iterable.typeArgs[0]
321
+ if (item === undefined) {
322
+ return this.#fail(
323
+ Codes.InvalidType,
324
+ located,
325
+ `${where} must declare the type of its events (${use.coordinate})`,
326
+ ['AsyncIterable<Event>'],
327
+ )
328
+ }
329
+ return this.#convert(item, false, located)
330
+ }
331
+ if (iterable !== undefined) {
332
+ return this.#fail(
333
+ Codes.InvalidType,
334
+ located,
335
+ `${where} returns an async iterable: only @Subscription fields stream values (${use.coordinate})`,
336
+ ['Use @Subscription() for a stream of events, or return a list (Promise<readonly Book[]>)'],
337
+ )
338
+ }
339
+ if (root.kind === 'undefined') {
340
+ return this.#fail(
341
+ Codes.InvalidType,
342
+ located,
343
+ `${where} returns void: GraphQL fields must return a value (${use.coordinate})`,
344
+ ['Return a payload type (the changed object) or a boolean'],
345
+ )
346
+ }
347
+ return this.#convert(root, false, located)
348
+ }
349
+
350
+ /** Arguments of a resolver field: `@Args('name')` parameters and the properties of `@Args()` types. */
351
+ #arguments(field: FieldData, method: MethodInfo, owner: Use): ArgModel[] {
352
+ const args: ArgModel[] = []
353
+ const seen = new Map<string, Location | undefined>()
354
+ const add = (arg: ArgModel, location: Location | undefined): void => {
355
+ if (seen.has(arg.name)) {
356
+ const first = seen.get(arg.name)
357
+ this.#report({
358
+ code: Codes.NameConflict,
359
+ severity: 'error',
360
+ message: `Argument '${arg.name}' of ${owner.coordinate} is declared twice`,
361
+ ...(location === undefined ? {} : { location }),
362
+ ...(first === undefined ? {} : { related: [{ message: 'first declared here', location: first }] }),
363
+ hints: ['Argument names must be unique: rename one of them'],
364
+ })
365
+ return
366
+ }
367
+ seen.set(arg.name, location)
368
+ args.push(arg)
369
+ }
370
+ field.params.forEach((data, index) => {
371
+ if (data?.kind !== 'args') return
372
+ const param = method.params[index] as ParameterInfo
373
+ const result = this.#shape({ parameter: param })
374
+ if (data.name !== undefined) {
375
+ const coordinate = `${owner.coordinate}(${data.name}:)`
376
+ const use: Use = {
377
+ position: 'input',
378
+ coordinate,
379
+ trail: `${owner.trail}(${data.name})`,
380
+ reached: owner.reached,
381
+ root: owner.root,
382
+ locate: () => param.typeLocation,
383
+ }
384
+ const arg = this.#parameterArgument(data.name, param, method, result, use)
385
+ if (arg !== undefined) add(arg, param.location)
386
+ return
387
+ }
388
+ const properties = this.#propertiesOf(result.root)
389
+ if (properties === 'record') {
390
+ this.#fail(
391
+ Codes.InvalidType,
392
+ { ...owner, locate: () => param.typeLocation },
393
+ `@Args() parameter '${param.name}' of ${owner.coordinate} cannot be a record or an index signature: GraphQL arguments are fixed`,
394
+ [RECORD_HINT, `@Args('${param.name}') declares one argument named '${param.name}'`],
395
+ )
396
+ return
397
+ }
398
+ if (properties === undefined) {
399
+ this.#fail(
400
+ Codes.InvalidType,
401
+ { ...owner, locate: () => param.typeLocation },
402
+ `@Args() parameter '${param.name}' of ${owner.coordinate} must have an object type: its properties are the arguments`,
403
+ [`@Args('${param.name}') declares one argument named '${param.name}'`],
404
+ )
405
+ return
406
+ }
407
+ for (const property of properties) {
408
+ if (isHidden(property.doc)) continue
409
+ const coordinate = `${owner.coordinate}(${property.name}:)`
410
+ const use: Use = {
411
+ position: 'input',
412
+ coordinate,
413
+ trail: `${owner.trail}(${property.name})`,
414
+ reached: owner.reached,
415
+ root: owner.root,
416
+ locate: () => annotationLocation(this.#ctx.project, property.location) ?? param.typeLocation,
417
+ }
418
+ const arg = this.#inputValue(property, use)
419
+ if (arg !== undefined) add(arg, property.location ?? param.location)
420
+ }
421
+ })
422
+ return args
423
+ }
424
+
425
+ /** `@Args('limit') limit: Int = 20`. */
426
+ #parameterArgument(
427
+ name: string,
428
+ param: ParameterInfo,
429
+ method: MethodInfo,
430
+ result: ShapeResult,
431
+ use: Use,
432
+ ): ArgModel | undefined {
433
+ if (!isGraphQLName(name)) {
434
+ this.#fail(Codes.NameConflict, use, `'${name}' is not a valid GraphQL argument name (${use.coordinate})`, [
435
+ NAME_HINT,
436
+ ])
437
+ return undefined
438
+ }
439
+ const converted = this.#convert(result.root, param.optional, use)
440
+ if (converted === undefined) return undefined
441
+ let ref = converted.ref
442
+ const arg: ArgModel = {
443
+ name,
444
+ type: '',
445
+ ref,
446
+ description: descriptionOf(param.doc) ?? paramDescription(method.doc, param.name),
447
+ deprecationReason: deprecationOf(param.doc),
448
+ defaultValue: undefined,
449
+ nullAsUndefined: false,
450
+ location: param.location,
451
+ }
452
+ const initializer = param.initializer
453
+ let hasDefault = false
454
+ if (param.hasDefault) {
455
+ if (initializer?.evaluated === true && initializer.ref === undefined && !containsRef(initializer.value)) {
456
+ this.#defaults.push({ target: arg, value: initializer.value, use, location: initializer.location })
457
+ hasDefault = true
458
+ } else {
459
+ // a default the compiler cannot read: the argument becomes optional, the TypeScript default applies
460
+ ref = { ...ref, nonNull: false }
461
+ }
462
+ }
463
+ arg.ref = ref
464
+ arg.type = printTypeRef(ref)
465
+ arg.nullAsUndefined = !ref.nonNull && !converted.acceptsNull
466
+ this.#checkDeprecatedInput(arg, hasDefault, use)
467
+ return arg
468
+ }
469
+
470
+ /** A property as an argument or an input field (§6.3): `@default`, `@deprecated`, null handling. */
471
+ #inputValue(property: ShapeProperty, use: Use): ArgModel | undefined {
472
+ if (!isGraphQLName(property.name)) {
473
+ this.#fail(
474
+ Codes.NameConflict,
475
+ { ...use, locate: () => property.location },
476
+ `'${property.name}' is not a valid GraphQL name (${use.coordinate})`,
477
+ [NAME_HINT, 'Hide the property with /** @gqlHidden */'],
478
+ )
479
+ return undefined
480
+ }
481
+ if (property.kind === 'getter') {
482
+ this.#fail(Codes.InvalidInput, use, `${use.coordinate} is a getter: input values are plain data`, [
483
+ 'Declare a property instead, or hide the getter with /** @gqlHidden */',
484
+ ])
485
+ return undefined
486
+ }
487
+ const converted = this.#convert(property.shape, property.optional, use)
488
+ if (converted === undefined) return undefined
489
+ const arg: ArgModel = {
490
+ name: property.name,
491
+ type: printTypeRef(converted.ref),
492
+ ref: converted.ref,
493
+ description: descriptionOf(property.doc),
494
+ deprecationReason: deprecationOf(property.doc),
495
+ defaultValue: undefined,
496
+ nullAsUndefined: !converted.ref.nonNull && !converted.acceptsNull,
497
+ location: property.location,
498
+ }
499
+ const tag = tagOf(property.doc, 'default')
500
+ if (tag !== undefined)
501
+ this.#defaults.push({ target: arg, value: defaultTagValue(tag), use, location: tag.location })
502
+ this.#checkDeprecatedInput(arg, tag !== undefined, use)
503
+ return arg
504
+ }
505
+
506
+ /** graphql-js rejects a deprecated argument or input field that is required. */
507
+ #checkDeprecatedInput(arg: ArgModel, hasDefault: boolean, use: Use): void {
508
+ if (arg.deprecationReason === undefined || !arg.ref.nonNull || hasDefault) return
509
+ this.#fail(Codes.InvalidInput, use, `${use.coordinate} is required and cannot be deprecated`, [
510
+ 'Make it optional (?: T or T | undefined) or give it a default value before deprecating it',
511
+ ])
512
+ }
513
+
514
+ // ------------------------------------------------------------------------------ field resolvers
515
+
516
+ #indexFieldResolvers(resolvers: readonly ResolverData[]): void {
517
+ for (const resolver of resolvers) {
518
+ for (const field of resolver.fields) {
519
+ if (field.operation !== 'field' || !field.complete) continue
520
+ const method = this.#method(resolver, field)
521
+ if (method === undefined) continue
522
+ const index = field.params.findIndex(param => param?.kind === 'parent')
523
+ const param = method.params[index]
524
+ if (param === undefined) continue
525
+ const result = this.#shape({ parameter: param })
526
+ const key = this.#objectKeyOf(result.root)
527
+ if (key === undefined) {
528
+ this.#report({
529
+ code: Codes.InvalidResolver,
530
+ severity: 'error',
531
+ message: `The @Parent() parameter '${param.name}' of ${resolver.host.displayName}.${field.method} must have an object type, not ${describeShape(result.root)}`,
532
+ location: param.typeLocation,
533
+ hints: [
534
+ 'The parent type is the object type the field is added to: an interface, a class or a named alias of an object type',
535
+ ],
536
+ })
537
+ continue
538
+ }
539
+ let list = this.#fieldResolvers.get(key)
540
+ if (list === undefined) {
541
+ list = []
542
+ this.#fieldResolvers.set(key, list)
543
+ }
544
+ list.push({ resolver, field, method, attached: false })
545
+ }
546
+ }
547
+ // field resolvers are appended by resolver token, then source order (§7.3)
548
+ for (const list of this.#fieldResolvers.values()) {
549
+ list.sort(
550
+ (a, b) =>
551
+ compareStrings(a.resolver.host.identity, b.resolver.host.identity) ||
552
+ a.field.location.start - b.field.location.start,
553
+ )
554
+ }
555
+ }
556
+
557
+ #attachFieldResolvers(model: ObjectModel): void {
558
+ const pending = this.#fieldResolvers.get(model.key)
559
+ if (pending === undefined) return
560
+ for (const entry of pending) {
561
+ entry.attached = true
562
+ const field = this.#resolverField(model, entry.resolver, entry.field, entry.method)
563
+ const index = model.fields.findIndex(existing => existing.name === field.name)
564
+ if (index === -1) {
565
+ model.fields.push(field)
566
+ continue
567
+ }
568
+ const existing = model.fields[index] as FieldModel
569
+ const where = `${entry.resolver.host.displayName}.${entry.field.method}`
570
+ if (existing.binding !== undefined) {
571
+ this.#report({
572
+ code: Codes.NameConflict,
573
+ severity: 'error',
574
+ message: `Two field resolvers add ${model.name}.${field.name}: ${where} and ${existing.binding.resolver.host.displayName}.${existing.binding.field.method}`,
575
+ location: entry.field.location,
576
+ related: [{ message: 'the other field resolver', location: existing.binding.field.location }],
577
+ hints: ["Keep one of them, or rename one: @ResolveField('otherName')"],
578
+ })
579
+ continue
580
+ }
581
+ if (existing.type !== field.type) {
582
+ this.#report({
583
+ code: Codes.NameConflict,
584
+ severity: 'error',
585
+ message: `@ResolveField ${where} returns ${field.type}, but the property ${model.name}.${field.name} is ${existing.type}`,
586
+ location: entry.field.location,
587
+ ...(existing.location === undefined
588
+ ? {}
589
+ : { related: [{ message: 'the property', location: existing.location }] }),
590
+ hints: [
591
+ 'A field resolver replacing a property must resolve the same GraphQL type: change its return type, or rename the field',
592
+ ],
593
+ })
594
+ continue
595
+ }
596
+ // replaces the property field in place: same position, the method's documentation first
597
+ model.fields[index] = {
598
+ ...field,
599
+ description: field.description ?? existing.description,
600
+ deprecationReason: field.deprecationReason ?? existing.deprecationReason,
601
+ }
602
+ }
603
+ }
604
+
605
+ #reportUnreachableFieldResolvers(): void {
606
+ for (const [key, list] of this.#fieldResolvers) {
607
+ for (const entry of list) {
608
+ if (entry.attached) continue
609
+ const name = this.#named.get(key)?.name ?? key
610
+ this.#report({
611
+ code: Codes.InvalidResolver,
612
+ severity: 'warning',
613
+ message: `@ResolveField ${entry.resolver.host.displayName}.${entry.field.method} adds a field to ${name}, which no root field reaches as an output type: it is not part of the schema`,
614
+ location: entry.field.location,
615
+ hints: [`Return ${name} from a @Query (or another reachable field), or remove the field resolver`],
616
+ })
617
+ }
618
+ }
619
+ }
620
+
621
+ // ----------------------------------------------------------------------------------- conversion
622
+
623
+ /**
624
+ * The GraphQL type of a shape: `null`, `undefined` and `optional` make it nullable, everything
625
+ * else is non-null (§6.2).
626
+ */
627
+ #convert(shape: Shape, optional: boolean, use: Use): Converted | undefined {
628
+ let nullable = optional
629
+ let acceptsNull = false
630
+ let inner = shape
631
+ if (shape.kind === 'union') {
632
+ const rest = shape.members.filter(member => member.kind !== 'null' && member.kind !== 'undefined')
633
+ if (rest.length !== shape.members.length) {
634
+ nullable = true
635
+ acceptsNull = shape.members.some(member => member.kind === 'null')
636
+ }
637
+ if (rest.length === 0) {
638
+ return this.#fail(Codes.InvalidType, use, `${use.coordinate} is always null or undefined`, [
639
+ 'Declare the type of the value',
640
+ ])
641
+ }
642
+ inner = rest.length === 1 ? (rest[0] as Shape) : { kind: 'union', members: rest }
643
+ }
644
+ const converted = this.#inner(inner, use)
645
+ if (converted === undefined) return undefined
646
+ const isNullable = nullable || converted.nullable
647
+ return { ref: { ...converted.ref, nonNull: !isNullable }, acceptsNull: acceptsNull || converted.nullable }
648
+ }
649
+
650
+ #inner(shape: Shape, use: Use): Inner | undefined {
651
+ switch (shape.kind) {
652
+ case 'string':
653
+ return refTo('String')
654
+ case 'number':
655
+ return refTo(this.#number)
656
+ case 'boolean':
657
+ return refTo('Boolean')
658
+ case 'literal':
659
+ if (typeof shape.value === 'string') return refTo('String')
660
+ return refTo(typeof shape.value === 'number' ? this.#number : 'Boolean')
661
+ case 'date':
662
+ return this.#builtin('DateTime', use)
663
+ case 'bigint':
664
+ return this.#builtin('BigInt', use)
665
+ case 'array': {
666
+ const item = this.#convert(shape.items, false, use)
667
+ return item === undefined ? undefined : { ref: { kind: 'list', of: item.ref, nonNull: false }, nullable: false }
668
+ }
669
+ case 'named':
670
+ return this.#namedType(shape.key, use)
671
+ case 'reference':
672
+ return this.#reference(shape, use)
673
+ case 'url':
674
+ return this.#fail(Codes.InvalidType, use, `Cannot map 'URL' to a GraphQL type (${use.coordinate})`, [
675
+ "Expose a string (url.href), or declare a scalar: export const UrlScalar = scalar<URL>('URL', { ... })",
676
+ ])
677
+ case 'binary':
678
+ return this.#fail(Codes.InvalidType, use, `Cannot map binary data to a GraphQL type (${use.coordinate})`, [
679
+ "Expose a URL (coverUrl: string), hide the property with /** @gqlHidden */, or declare a scalar: export const Base64 = scalar<Uint8Array>('Base64', { ... })",
680
+ ])
681
+ case 'unknown':
682
+ return this.#fail(
683
+ Codes.InvalidType,
684
+ use,
685
+ `Cannot map 'unknown' or 'any' to a GraphQL type (${use.coordinate})`,
686
+ ["Declare the type, or use JSONValue (import type { JSONValue } from '@znight/graphql') for arbitrary JSON"],
687
+ )
688
+ case 'never':
689
+ case 'null':
690
+ case 'undefined':
691
+ return this.#fail(Codes.InvalidType, use, `Cannot map '${shape.kind}' to a GraphQL type (${use.coordinate})`, [
692
+ 'Declare the type of the value',
693
+ ])
694
+ case 'tuple':
695
+ return this.#fail(Codes.InvalidType, use, `Cannot map a tuple to a GraphQL type (${use.coordinate})`, [
696
+ 'Use an array (readonly T[]) or a named object type',
697
+ ])
698
+ case 'object':
699
+ if (shape.additional !== undefined) {
700
+ return this.#fail(
701
+ Codes.InvalidType,
702
+ use,
703
+ `Cannot map a record or an index signature to a GraphQL type (${use.coordinate})`,
704
+ [RECORD_HINT],
705
+ )
706
+ }
707
+ return this.#fail(
708
+ Codes.InvalidType,
709
+ use,
710
+ `Cannot map an anonymous object type to a GraphQL type (${use.coordinate})`,
711
+ ['GraphQL types have names: declare it (export interface BookStats { ... }) and use it'],
712
+ )
713
+ case 'intersection':
714
+ return this.#fail(
715
+ Codes.InvalidType,
716
+ use,
717
+ `Cannot map an anonymous intersection to a GraphQL type (${use.coordinate})`,
718
+ ['Name it: export type BookWithAuthor = Book & { readonly author: Author }'],
719
+ )
720
+ case 'union':
721
+ return this.#anonymousUnion(shape.members, use)
722
+ case 'schema':
723
+ return this.#fail(Codes.InvalidType, use, `Cannot map a schema type to a GraphQL type (${use.coordinate})`, [
724
+ 'Declare the TypeScript type: the annotation is the GraphQL type',
725
+ ])
726
+ case 'opaque':
727
+ return this.#opaque(shape, use)
728
+ }
729
+ }
730
+
731
+ #anonymousUnion(members: readonly Shape[], use: Use): undefined {
732
+ if (members.every(member => member.kind === 'literal' && typeof member.value === 'string')) {
733
+ const values = members.map(member => `'${(member as { value: string }).value}'`).join(' | ')
734
+ return this.#fail(
735
+ Codes.InvalidType,
736
+ use,
737
+ `Cannot map the anonymous union ${values} to a GraphQL type (${use.coordinate})`,
738
+ [`Name it to make it an enum: export type Sort = ${values}`],
739
+ )
740
+ }
741
+ if (members.every(member => member.kind === 'named')) {
742
+ return this.#fail(Codes.InvalidType, use, `Cannot map an anonymous union to a GraphQL type (${use.coordinate})`, [
743
+ `GraphQL unions have names: export type SearchResult = ${members.map(member => this.#named.get((member as { key: string }).key)?.name ?? '?').join(' | ')}`,
744
+ ])
745
+ }
746
+ return this.#fail(
747
+ Codes.InvalidType,
748
+ use,
749
+ `Cannot map the union ${members.map(describeShape).join(' | ')} to a GraphQL type (${use.coordinate})`,
750
+ ['GraphQL unions only contain object types: declare one type, or an object type per case'],
751
+ )
752
+ }
753
+
754
+ #opaque(shape: Extract<Shape, { kind: 'opaque' }>, use: Use): undefined {
755
+ const text = shape.text
756
+ let hints: string[]
757
+ switch (shape.reason) {
758
+ case 'non-json':
759
+ if (text.startsWith('Promise')) {
760
+ hints = ['A Promise is only unwrapped at the top of a resolver return type: declare the resolved type']
761
+ } else if (text.startsWith('Map') || text.startsWith('WeakMap')) {
762
+ hints = [
763
+ "Use an array of entries, a named object type, or JSONValue (import type { JSONValue } from '@znight/graphql')",
764
+ ]
765
+ } else {
766
+ hints = ['Declare a plain data type (an interface, an array, a scalar)']
767
+ }
768
+ break
769
+ case 'missing-annotation':
770
+ hints = ['Annotate the type: the compiler reads annotations only']
771
+ break
772
+ case 'function':
773
+ hints = ['Functions are not data: return the value, or hide the member with /** @gqlHidden */']
774
+ break
775
+ case 'type-parameter':
776
+ hints = ['Give the type arguments explicitly']
777
+ break
778
+ default:
779
+ hints = ['Declare the structure with an interface or a named alias']
780
+ }
781
+ return this.#fail(Codes.InvalidType, use, `Cannot map '${text}' to a GraphQL type (${use.coordinate})`, hints)
782
+ }
783
+
784
+ #reference(shape: Extract<Shape, { kind: 'reference' }>, use: Use): Inner | undefined {
785
+ const alias = SCALAR_ALIASES.get(shape.identity)
786
+ if (alias === 'JSON') {
787
+ // JSONValue includes null: the field is nullable
788
+ const json = this.#builtin('JSON', use)
789
+ return json === undefined ? undefined : { ref: json.ref, nullable: true }
790
+ }
791
+ if (alias !== undefined) return refTo(alias === 'Float' ? 'Float' : alias)
792
+ const scalar = this.#scalars.get(shape.identity)
793
+ if (scalar !== undefined) return this.#customScalar(scalar, use)
794
+ if (OUTPUT_ITERABLES.has(shape.identity)) {
795
+ if (use.position === 'input') {
796
+ return this.#fail(Codes.InvalidInput, use, `${use.coordinate} is a ${shape.name}: input lists are arrays`, [
797
+ 'Declare an array (readonly T[]) for input values',
798
+ ])
799
+ }
800
+ const item = shape.typeArgs[0]
801
+ if (item === undefined)
802
+ return this.#fail(Codes.InvalidType, use, `${use.coordinate}: ${shape.name} needs a type argument`, [])
803
+ const converted = this.#convert(item, false, use)
804
+ return converted === undefined
805
+ ? undefined
806
+ : { ref: { kind: 'list', of: converted.ref, nonNull: false }, nullable: false }
807
+ }
808
+ if (ASYNC_ITERABLES.has(shape.identity)) {
809
+ return this.#fail(Codes.InvalidType, use, `Cannot map '${shape.name}' to a GraphQL type (${use.coordinate})`, [
810
+ 'Async iterables are only the return type of @Subscription methods',
811
+ ])
812
+ }
813
+ return this.#fail(Codes.InvalidType, use, `Cannot map '${shape.name}' to a GraphQL type (${use.coordinate})`, [
814
+ 'Declare a plain data type (an interface, an array, a scalar)',
815
+ ])
816
+ }
817
+
818
+ // ------------------------------------------------------------------------------------- scalars
819
+
820
+ #builtin(name: 'DateTime' | 'BigInt' | 'JSON', use: Use): Inner | undefined {
821
+ const key = `#builtin:${name}`
822
+ if (!this.#models.has(key)) {
823
+ const model: ScalarModel = {
824
+ kind: 'scalar',
825
+ name,
826
+ key,
827
+ description: undefined,
828
+ location: undefined,
829
+ definition: undefined,
830
+ specifiedByURL: undefined,
831
+ }
832
+ this.#models.set(key, model)
833
+ if (!this.#claim(model, use)) return undefined
834
+ }
835
+ return refTo(name)
836
+ }
837
+
838
+ #customScalar(scalar: ScalarDeclaration, use: Use): Inner | undefined {
839
+ const key = `#scalar:${scalar.identity}`
840
+ if (!this.#models.has(key)) {
841
+ const model: ScalarModel = {
842
+ kind: 'scalar',
843
+ name: scalar.name,
844
+ key,
845
+ description: scalar.description,
846
+ location: scalar.location,
847
+ definition: scalar.identity,
848
+ specifiedByURL: scalar.specifiedByURL,
849
+ }
850
+ this.#models.set(key, model)
851
+ if (!this.#claim(model, use)) return undefined
852
+ }
853
+ return refTo(scalar.name)
854
+ }
855
+
856
+ // ------------------------------------------------------------------------------- named types
857
+
858
+ #namedType(key: string, use: Use): Inner | undefined {
859
+ const named = this.#named.get(key)
860
+ if (named === undefined)
861
+ return this.#fail(Codes.InvalidType, use, `Cannot resolve the type of ${use.coordinate}`, [])
862
+ switch (named.declaration) {
863
+ case 'enum':
864
+ return this.#enum(named, use)
865
+ case 'alias':
866
+ return this.#alias(named, use)
867
+ default:
868
+ return this.#object(named, named, use)
869
+ }
870
+ }
871
+
872
+ /**
873
+ * A named alias: an object type (object literal, intersection, utility type, generic instance),
874
+ * an enum (string literals), a union (object types), or transparent (`type Email = string`,
875
+ * `type Books = Book[]`, `type Author = Person`).
876
+ */
877
+ #alias(alias: NamedShape, use: Use): Inner | undefined {
878
+ const shape = alias.shape
879
+ switch (shape.kind) {
880
+ case 'object':
881
+ case 'intersection':
882
+ return this.#object(alias, alias, use)
883
+ case 'named': {
884
+ const target = this.#named.get(shape.key)
885
+ if (
886
+ target !== undefined &&
887
+ target.declaration !== 'alias' &&
888
+ target.declaration !== 'enum' &&
889
+ target.typeArgs.length > 0
890
+ ) {
891
+ // `type BookList = Page<Book>`: the alias names the instance
892
+ return this.#object(alias, target, use)
893
+ }
894
+ return this.#namedType(shape.key, use)
895
+ }
896
+ case 'union': {
897
+ const rest = shape.members.filter(member => member.kind !== 'null' && member.kind !== 'undefined')
898
+ const nullable = rest.length !== shape.members.length
899
+ if (rest.length === 1) {
900
+ const inner = this.#inner(rest[0] as Shape, use)
901
+ return inner === undefined ? undefined : { ref: inner.ref, nullable: inner.nullable || nullable }
902
+ }
903
+ let converted: Inner | undefined
904
+ if (rest.every(member => member.kind === 'literal')) converted = this.#literalEnum(alias, rest, use)
905
+ else if (rest.length > 0 && rest.every(member => member.kind === 'named'))
906
+ converted = this.#union(alias, rest, use)
907
+ else converted = this.#anonymousUnion(rest, use)
908
+ return converted === undefined ? undefined : { ref: converted.ref, nullable }
909
+ }
910
+ default:
911
+ return this.#inner(shape, use)
912
+ }
913
+ }
914
+
915
+ /**
916
+ * The declaration key of the object type a shape maps to in output position (the parent of a
917
+ * field resolver), `undefined` when it is not an object type. Mirrors {@link #alias}.
918
+ */
919
+ #objectKeyOf(shape: Shape): string | undefined {
920
+ if (shape.kind === 'union') {
921
+ const rest = shape.members.filter(member => member.kind !== 'null' && member.kind !== 'undefined')
922
+ return rest.length === 1 ? this.#objectKeyOf(rest[0] as Shape) : undefined
923
+ }
924
+ if (shape.kind !== 'named') return undefined
925
+ const named = this.#named.get(shape.key)
926
+ if (named === undefined || named.declaration === 'enum') return undefined
927
+ if (named.declaration !== 'alias') return named.key
928
+ const target = named.shape
929
+ if (target.kind === 'object' || target.kind === 'intersection') return named.key
930
+ if (target.kind === 'named') {
931
+ const instance = this.#named.get(target.key)
932
+ if (
933
+ instance !== undefined &&
934
+ instance.declaration !== 'alias' &&
935
+ instance.declaration !== 'enum' &&
936
+ instance.typeArgs.length > 0
937
+ ) {
938
+ return named.key
939
+ }
940
+ }
941
+ return this.#objectKeyOf(target)
942
+ }
943
+
944
+ /** An object type (output) or an input object type (input) for `named`, structured as `structure`. */
945
+ #object(named: NamedShape, structure: NamedShape, use: Use): Inner | undefined {
946
+ const key = named.key
947
+ if (!this.#checkPosition(named, use)) return undefined
948
+ const existing = this.#models.get(key)
949
+ if (existing !== undefined) return this.#known(existing)
950
+ const name = this.#typeName(named, use)
951
+ if (name === undefined) return undefined
952
+ const properties = this.#propertiesOf(structure.shape)
953
+ if (properties === 'record') {
954
+ return this.#fail(
955
+ Codes.InvalidType,
956
+ use,
957
+ `Cannot map ${named.name}, a record or an index signature, to a GraphQL type (${use.coordinate})`,
958
+ [RECORD_HINT],
959
+ )
960
+ }
961
+ if (properties === undefined) {
962
+ return this.#fail(
963
+ Codes.InvalidType,
964
+ use,
965
+ `Cannot map ${named.name} to a GraphQL object type (${use.coordinate})`,
966
+ ['Declare an interface, a class or a named object type'],
967
+ )
968
+ }
969
+ const description = descriptionOf(named.doc) ?? (structure === named ? undefined : descriptionOf(structure.doc))
970
+ const trail = `${use.trail} → ${name}`
971
+ if (use.position === 'input') {
972
+ const model: InputModel = { kind: 'input', name, key, description, location: named.location, fields: [] }
973
+ this.#models.set(key, model)
974
+ if (!this.#claim(model, use)) return undefined
975
+ for (const property of properties) {
976
+ if (isHidden(property.doc)) continue
977
+ const fieldUse: Use = {
978
+ position: 'input',
979
+ coordinate: `${name}.${property.name}`,
980
+ trail: `${trail}.${property.name}`,
981
+ reached: trail,
982
+ root: use.root,
983
+ locate: () => annotationLocation(this.#ctx.project, property.location),
984
+ }
985
+ const field = this.#inputValue(property, fieldUse)
986
+ if (field !== undefined) model.fields.push(field)
987
+ }
988
+ return refTo(name)
989
+ }
990
+ const model: ObjectModel = {
991
+ kind: 'object',
992
+ name,
993
+ key,
994
+ description,
995
+ location: named.location,
996
+ fields: [],
997
+ trail,
998
+ root: use.root,
999
+ classIdentity:
1000
+ structure.declaration === 'class' && structure.typeArgs.length === 0 && structure === named
1001
+ ? structure.identity
1002
+ : undefined,
1003
+ properties: properties.map(property => this.#discriminant(property)),
1004
+ interfaces: [],
1005
+ }
1006
+ this.#models.set(key, model)
1007
+ if (!this.#claim(model, use)) return undefined
1008
+ for (const property of properties) {
1009
+ const field = this.#outputField(model, property)
1010
+ if (field !== undefined) model.fields.push(field)
1011
+ }
1012
+ this.#attachFieldResolvers(model)
1013
+ return refTo(name)
1014
+ }
1015
+
1016
+ /** A property as a union discriminator candidate: its literal value, through aliases (`kind: BookKind`). */
1017
+ #discriminant(property: ShapeProperty): DiscriminantProperty {
1018
+ let shape = property.shape
1019
+ // `type BookKind = 'book'`, `type Kind = BookKind`; the bound only guards against alias cycles
1020
+ for (let depth = 0; shape.kind === 'named' && depth < 32; depth++) {
1021
+ const named = this.#named.get(shape.key)
1022
+ if (named?.declaration !== 'alias') break
1023
+ shape = named.shape
1024
+ }
1025
+ return {
1026
+ name: property.name,
1027
+ optional: property.optional,
1028
+ literal: shape.kind === 'literal' ? shape.value : undefined,
1029
+ }
1030
+ }
1031
+
1032
+ #outputField(model: ObjectModel, property: ShapeProperty): FieldModel | undefined {
1033
+ // hidden members, functions (methods stored as properties) and the union discriminator `__typename`
1034
+ if (isHidden(property.doc) || property.name === '__typename') return undefined
1035
+ if (property.shape.kind === 'opaque' && property.shape.reason === 'function') return undefined
1036
+ const use: Use = {
1037
+ position: 'output',
1038
+ coordinate: `${model.name}.${property.name}`,
1039
+ trail: `${model.trail}.${property.name}`,
1040
+ reached: model.trail,
1041
+ root: model.root,
1042
+ locate: () => annotationLocation(this.#ctx.project, property.location),
1043
+ }
1044
+ if (!isGraphQLName(property.name)) {
1045
+ return this.#fail(
1046
+ Codes.NameConflict,
1047
+ { ...use, locate: () => property.location },
1048
+ `'${property.name}' is not a valid GraphQL field name (${use.coordinate})`,
1049
+ [NAME_HINT, 'Hide the property with /** @gqlHidden */'],
1050
+ )
1051
+ }
1052
+ const converted = this.#convert(property.shape, property.optional, use)
1053
+ if (converted === undefined) return undefined
1054
+ return {
1055
+ name: property.name,
1056
+ type: printTypeRef(converted.ref),
1057
+ description: descriptionOf(property.doc),
1058
+ deprecationReason: deprecationOf(property.doc),
1059
+ args: [],
1060
+ binding: undefined,
1061
+ location: property.location,
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * The properties of an object structure (intersections merged, later members win), `'record'` for
1067
+ * a record or an index signature (ZNIGHT060: GraphQL objects have fixed fields).
1068
+ */
1069
+ #propertiesOf(shape: Shape): readonly ShapeProperty[] | 'record' | undefined {
1070
+ switch (shape.kind) {
1071
+ case 'object':
1072
+ return shape.additional === undefined ? shape.properties : 'record'
1073
+ case 'named': {
1074
+ const named = this.#named.get(shape.key)
1075
+ return named === undefined || named.declaration === 'enum' ? undefined : this.#propertiesOf(named.shape)
1076
+ }
1077
+ case 'intersection': {
1078
+ const merged = new Map<string, ShapeProperty>()
1079
+ for (const member of shape.members) {
1080
+ const properties = this.#propertiesOf(member)
1081
+ if (properties === undefined || properties === 'record') return properties
1082
+ for (const property of properties) {
1083
+ merged.delete(property.name)
1084
+ merged.set(property.name, property)
1085
+ }
1086
+ }
1087
+ return [...merged.values()]
1088
+ }
1089
+ default:
1090
+ return undefined
1091
+ }
1092
+ }
1093
+
1094
+ /** A declaration is used in one position: input or output (ZNIGHT063 otherwise). */
1095
+ #checkPosition(named: NamedShape, use: Use): boolean {
1096
+ let uses = this.#positions.get(named.key)
1097
+ if (uses === undefined) {
1098
+ uses = {}
1099
+ this.#positions.set(named.key, uses)
1100
+ }
1101
+ const other = use.position === 'input' ? uses.output : uses.input
1102
+ if (uses[use.position] === undefined) uses[use.position] = { coordinate: use.coordinate, location: use.locate() }
1103
+ if (other === undefined) return true
1104
+ const output = use.position === 'output' ? use.coordinate : other.coordinate
1105
+ const input = use.position === 'input' ? use.coordinate : other.coordinate
1106
+ const inputOwner = input.includes('(') ? input.slice(0, input.indexOf('(')) : input.split('.')[0]
1107
+ this.#reportOnce(`position\0${named.key}`, {
1108
+ code: Codes.InvalidInput,
1109
+ severity: 'error',
1110
+ message: `'${named.name}' is used as an output type (${output}) and as an input type (${input})`,
1111
+ location: named.location,
1112
+ hints: [
1113
+ `GraphQL needs two types: declare \`export interface ${named.name}Input extends ${named.name} {}\` and use it in ${inputOwner}`,
1114
+ ],
1115
+ related: [
1116
+ ...(uses.output?.location === undefined
1117
+ ? []
1118
+ : [{ message: `used as output: ${output}`, location: uses.output.location }]),
1119
+ ...(uses.input?.location === undefined
1120
+ ? []
1121
+ : [{ message: `used as input: ${input}`, location: uses.input.location }]),
1122
+ ],
1123
+ })
1124
+ return false
1125
+ }
1126
+
1127
+ /**
1128
+ * The GraphQL name of a declaration (§6.4): `@gqlName`, the declaration name, or for a generic
1129
+ * instance the names of its type arguments followed by the declaration name (`BookPage`).
1130
+ */
1131
+ #typeName(named: NamedShape, use: Use): string | undefined {
1132
+ if (this.#names.has(named.key)) return this.#names.get(named.key)
1133
+ let name: string | undefined
1134
+ const explicit = tagOf(named.doc, 'gqlName')?.text.trim()
1135
+ if (explicit !== undefined && explicit !== '') name = explicit
1136
+ else if (named.typeArgs.length === 0) name = named.name
1137
+ else {
1138
+ const args = named.typeArgs.map(arg => this.#argumentName(arg))
1139
+ const missing = args.findIndex(arg => arg === undefined)
1140
+ if (missing === -1) name = `${args.join('')}${named.name}`
1141
+ else {
1142
+ this.#reportOnce(`argument\0${named.key}`, {
1143
+ code: Codes.InvalidType,
1144
+ severity: 'error',
1145
+ message: `Cannot name the GraphQL type of ${named.name}<...> (${use.coordinate}): its type argument ${describeShape(named.typeArgs[missing] as Shape)} has no GraphQL name`,
1146
+ location: named.location,
1147
+ related: reachedFrom(use),
1148
+ hints: [
1149
+ `Name the instance with an alias (export type BookPages = ${named.name}<...>) or with /** @gqlName Name */`,
1150
+ ],
1151
+ })
1152
+ }
1153
+ }
1154
+ this.#names.set(named.key, name)
1155
+ return name
1156
+ }
1157
+
1158
+ /** The GraphQL name a type argument contributes to a generic instance name. */
1159
+ #argumentName(shape: Shape): string | undefined {
1160
+ switch (shape.kind) {
1161
+ case 'string':
1162
+ return 'String'
1163
+ case 'number':
1164
+ return this.#number
1165
+ case 'boolean':
1166
+ return 'Boolean'
1167
+ case 'date':
1168
+ return 'DateTime'
1169
+ case 'bigint':
1170
+ return 'BigInt'
1171
+ case 'reference': {
1172
+ const alias = SCALAR_ALIASES.get(shape.identity)
1173
+ if (alias !== undefined) return alias
1174
+ return this.#scalars.get(shape.identity)?.name
1175
+ }
1176
+ case 'union': {
1177
+ const rest = shape.members.filter(member => member.kind !== 'null' && member.kind !== 'undefined')
1178
+ return rest.length === 1 ? this.#argumentName(rest[0] as Shape) : undefined
1179
+ }
1180
+ case 'named': {
1181
+ const named = this.#named.get(shape.key)
1182
+ if (named === undefined) return undefined
1183
+ if (named.declaration === 'alias') {
1184
+ const target = named.shape
1185
+ const instance = target.kind === 'named' ? this.#named.get(target.key) : undefined
1186
+ const isObject =
1187
+ target.kind === 'object' ||
1188
+ target.kind === 'intersection' ||
1189
+ (instance !== undefined &&
1190
+ instance.declaration !== 'alias' &&
1191
+ instance.declaration !== 'enum' &&
1192
+ instance.typeArgs.length > 0)
1193
+ const isEnum = target.kind === 'union' && target.members.every(member => member.kind === 'literal')
1194
+ const isUnion = target.kind === 'union' && target.members.filter(member => member.kind === 'named').length > 1
1195
+ if (!isObject && !isEnum && !isUnion) return this.#argumentName(target)
1196
+ }
1197
+ return this.#typeName(named, {
1198
+ position: 'output',
1199
+ coordinate: named.name,
1200
+ trail: named.name,
1201
+ reached: undefined,
1202
+ root: undefined,
1203
+ locate: () => named.location,
1204
+ })
1205
+ }
1206
+ default:
1207
+ return undefined
1208
+ }
1209
+ }
1210
+
1211
+ /**
1212
+ * Registers a model under its GraphQL name: the name must be valid, not reserved, and not taken
1213
+ * by another declaration (ZNIGHT061).
1214
+ */
1215
+ #claim(model: TypeModel, use: Use | undefined): boolean {
1216
+ const name = model.name
1217
+ const isRoot = model.key.startsWith('#root:')
1218
+ const location = model.location ?? use?.locate()
1219
+ let problem: string | undefined
1220
+ if (!isGraphQLName(name)) problem = `'${name}' is not a valid GraphQL type name`
1221
+ else if (!isRoot && ROOT_NAMES.has(name)) problem = `'${name}' is the name of a root operation type`
1222
+ else if (SPECIFIED_SCALARS.has(name)) problem = `'${name}' is the name of a specified GraphQL scalar`
1223
+ if (problem !== undefined) {
1224
+ this.#reportOnce(`name\0${model.key}`, {
1225
+ code: Codes.NameConflict,
1226
+ severity: 'error',
1227
+ message: `${problem} (${model.key.startsWith('#') ? name : model.key})`,
1228
+ ...(location === undefined ? {} : { location }),
1229
+ related: use === undefined ? [] : reachedFrom(use),
1230
+ hints: [isGraphQLName(name) ? 'Rename the declaration, or name it with /** @gqlName OtherName */' : NAME_HINT],
1231
+ })
1232
+ return false
1233
+ }
1234
+ const existing = this.#types.get(name)
1235
+ if (existing === undefined) {
1236
+ this.#types.set(name, model)
1237
+ return true
1238
+ }
1239
+ if (existing === model) return true
1240
+ const pair = [existing.key, model.key].sort(compareStrings).join('\0')
1241
+ this.#reportOnce(`conflict\0${pair}`, {
1242
+ code: Codes.NameConflict,
1243
+ severity: 'error',
1244
+ message: `Two types are named '${name}' in the GraphQL schema: ${describeKey(model)} and ${describeKey(existing)}`,
1245
+ ...(location === undefined ? {} : { location }),
1246
+ related: [
1247
+ ...(existing.location === undefined ? [] : [{ message: 'the other declaration', location: existing.location }]),
1248
+ ...(use === undefined ? [] : reachedFrom(use)),
1249
+ ],
1250
+ hints: ['Rename one of them, or name one with /** @gqlName OtherName */'],
1251
+ })
1252
+ return false
1253
+ }
1254
+
1255
+ // -------------------------------------------------------------------------------------- enums
1256
+
1257
+ /** A model met again: its reference, unless its name was refused (already reported). */
1258
+ #known(model: TypeModel): Inner | undefined {
1259
+ return this.#types.get(model.name) === model ? refTo(model.name) : undefined
1260
+ }
1261
+
1262
+ /** A TypeScript `enum`: names = member names, internal values = member values. */
1263
+ #enum(named: NamedShape, use: Use): Inner | undefined {
1264
+ const existing = this.#models.get(named.key)
1265
+ if (existing !== undefined) return this.#known(existing)
1266
+ const name = this.#typeName(named, use)
1267
+ if (name === undefined) return undefined
1268
+ const members = named.enumMembers
1269
+ if (members === undefined || members.length === 0) {
1270
+ return this.#fail(
1271
+ Codes.InvalidType,
1272
+ use,
1273
+ `The values of the enum ${named.name} cannot be evaluated (${use.coordinate})`,
1274
+ ['Use literal member values (A = "a", B = 1)'],
1275
+ )
1276
+ }
1277
+ const values: EnumValueModel[] = []
1278
+ for (const member of members) {
1279
+ if (!this.#checkEnumValueName(member.name, named, member.location, use)) continue
1280
+ values.push({
1281
+ name: member.name,
1282
+ value: member.value,
1283
+ description: descriptionOf(member.doc),
1284
+ deprecationReason: deprecationOf(member.doc),
1285
+ })
1286
+ }
1287
+ return this.#registerEnum(named, name, values, use)
1288
+ }
1289
+
1290
+ /** A named alias of a union of string literals: names = values = the literals, in source order. */
1291
+ #literalEnum(named: NamedShape, members: readonly Shape[], use: Use): Inner | undefined {
1292
+ const existing = this.#models.get(named.key)
1293
+ if (existing !== undefined) return this.#known(existing)
1294
+ const literals = named.literals ?? members.map(member => (member as Extract<Shape, { kind: 'literal' }>).value)
1295
+ if (!literals.every(value => typeof value === 'string')) {
1296
+ return this.#fail(
1297
+ Codes.InvalidType,
1298
+ use,
1299
+ `Cannot map ${named.name} (${literals.map(value => JSON.stringify(value)).join(' | ')}) to a GraphQL type (${use.coordinate}): only unions of string literals are enums`,
1300
+ ['Declare a TypeScript enum (enum Level { Low = 1, High = 2 }), or use Int'],
1301
+ )
1302
+ }
1303
+ const name = this.#typeName(named, use)
1304
+ if (name === undefined) return undefined
1305
+ const values: EnumValueModel[] = []
1306
+ for (const literal of literals as readonly string[]) {
1307
+ if (!this.#checkEnumValueName(literal, named, named.location, use)) continue
1308
+ values.push({ name: literal, value: literal, description: undefined, deprecationReason: undefined })
1309
+ }
1310
+ return this.#registerEnum(named, name, values, use)
1311
+ }
1312
+
1313
+ #checkEnumValueName(value: string, named: NamedShape, location: Location, use: Use): boolean {
1314
+ if (isGraphQLName(value) && !RESERVED_ENUM_VALUES.has(value)) return true
1315
+ this.#reportOnce(`enum\0${named.key}\0${value}`, {
1316
+ code: Codes.NameConflict,
1317
+ severity: 'error',
1318
+ message: `'${value}' of ${named.name} is not a valid GraphQL enum value`,
1319
+ location,
1320
+ related: reachedFrom(use),
1321
+ hints: [`${NAME_HINT}; true, false and null are reserved`],
1322
+ })
1323
+ return false
1324
+ }
1325
+
1326
+ #registerEnum(named: NamedShape, name: string, values: readonly EnumValueModel[], use: Use): Inner | undefined {
1327
+ const model: TypeModel = {
1328
+ kind: 'enum',
1329
+ name,
1330
+ key: named.key,
1331
+ description: descriptionOf(named.doc),
1332
+ location: named.location,
1333
+ values,
1334
+ }
1335
+ this.#models.set(named.key, model)
1336
+ if (!this.#claim(model, use)) return undefined
1337
+ return refTo(name)
1338
+ }
1339
+
1340
+ // ------------------------------------------------------------------------------------- unions
1341
+
1342
+ /** A named alias of a union of object types (output only), with its runtime discriminator. */
1343
+ #union(alias: NamedShape, members: readonly Shape[], use: Use): Inner | undefined {
1344
+ if (use.position === 'input') {
1345
+ return this.#fail(
1346
+ Codes.InvalidInput,
1347
+ use,
1348
+ `${use.coordinate} is the union ${alias.name}: unions cannot be input values`,
1349
+ ['Declare one input type with optional fields, or one argument per case'],
1350
+ )
1351
+ }
1352
+ const existing = this.#models.get(alias.key)
1353
+ if (existing !== undefined) return this.#known(existing)
1354
+ const name = this.#typeName(alias, use)
1355
+ if (name === undefined) return undefined
1356
+ const model: UnionModel = {
1357
+ kind: 'union',
1358
+ name,
1359
+ key: alias.key,
1360
+ description: descriptionOf(alias.doc),
1361
+ location: alias.location,
1362
+ members: [],
1363
+ discriminator: undefined,
1364
+ }
1365
+ this.#models.set(alias.key, model)
1366
+ if (!this.#claim(model, use)) return undefined
1367
+ const trail = `${use.trail} → ${name}`
1368
+ const objects: ObjectModel[] = []
1369
+ // a failed member is reported: no discriminator then (members that collapse into one object, as
1370
+ // `Book | BookAlias`, are not failures)
1371
+ let failed = false
1372
+ for (const member of members) {
1373
+ const memberUse: Use = { ...use, coordinate: name, trail, reached: trail, locate: () => alias.location }
1374
+ const converted = this.#inner(member, memberUse)
1375
+ if (converted === undefined) {
1376
+ failed = true
1377
+ continue
1378
+ }
1379
+ const target = converted.ref.kind === 'named' ? this.#types.get(converted.ref.name) : undefined
1380
+ if (target?.kind !== 'object') {
1381
+ failed = true
1382
+ this.#fail(
1383
+ Codes.InvalidType,
1384
+ memberUse,
1385
+ `The union ${name} contains ${describeShape(member)}, which is not an object type`,
1386
+ ['GraphQL unions only contain object types'],
1387
+ )
1388
+ continue
1389
+ }
1390
+ if (!objects.includes(target)) objects.push(target)
1391
+ }
1392
+ objects.sort((a, b) => compareStrings(a.name, b.name))
1393
+ model.members = objects.map(object => object.name)
1394
+ if (!failed) model.discriminator = this.#discriminator(model, objects)
1395
+ return refTo(name)
1396
+ }
1397
+
1398
+ /**
1399
+ * How the runtime tells the members apart (§5.7): `instanceof` when every member is an exported
1400
+ * class, a `__typename` property, a literal property with a distinct value per member, else
1401
+ * ZNIGHT064.
1402
+ */
1403
+ #discriminator(union: UnionModel, members: readonly ObjectModel[]): DiscriminatorModel | undefined {
1404
+ const found = discriminatorOf(members, identity => this.#ctx.classInfo(identity))
1405
+ if (found !== undefined) return found
1406
+ const names = members.map(member => member.name)
1407
+ const one = names.length === 1
1408
+ const listed = one ? (names[0] as string) : `${names.slice(0, -1).join(', ')} and ${names.at(-1) as string}`
1409
+ const allClasses = members.every(member => member.classIdentity !== undefined)
1410
+ let why: string
1411
+ if (allClasses) why = one ? 'is a class that is not exported' : 'are classes that are not all exported'
1412
+ else why = one ? 'has no literal property' : 'have no common literal property'
1413
+ this.#report({
1414
+ code: Codes.UnresolvableAbstractType,
1415
+ severity: 'error',
1416
+ message: `The members of union ${union.name} cannot be told apart at runtime: ${listed} ${why}`,
1417
+ ...(union.location === undefined ? {} : { location: union.location }),
1418
+ hints: [
1419
+ allClasses
1420
+ ? 'Export the member classes: the generated module imports them for instanceof'
1421
+ : `Add a literal property to each member (${members.map(member => `readonly kind: '${lowerFirst(member.name)}'`).join(' / ')}), or make the members classes`,
1422
+ ],
1423
+ })
1424
+ return undefined
1425
+ }
1426
+
1427
+ // --------------------------------------------------------------------------------- final checks
1428
+
1429
+ /** Defaults (`@default`, parameter defaults) against their types (ZNIGHT063), as external values. */
1430
+ #checkDefaults(): void {
1431
+ for (const pending of this.#defaults) {
1432
+ const result = externalValue(pending.value, pending.target.ref, this.#types)
1433
+ if ('error' in result) {
1434
+ const location = pending.location ?? pending.use.locate()
1435
+ this.#report({
1436
+ code: Codes.InvalidInput,
1437
+ severity: 'error',
1438
+ message: `Invalid default value ${describeValue(pending.value)} for ${pending.use.coordinate} (${pending.target.type}): ${result.error}`,
1439
+ ...(location === undefined ? {} : { location }),
1440
+ related: reachedFrom(pending.use),
1441
+ hints: ['Defaults are JSON values of the argument type (enum values as their member value or name)'],
1442
+ })
1443
+ continue
1444
+ }
1445
+ pending.target.defaultValue = result.value
1446
+ if (pending.target.ref.nonNull) pending.target.nullAsUndefined = false
1447
+ }
1448
+ }
1449
+
1450
+ /** GraphQL forbids a cycle of non-null input fields (ZNIGHT063). */
1451
+ #checkInputCycles(): void {
1452
+ const inputs = [...this.#models.values()]
1453
+ .filter((model): model is InputModel => model.kind === 'input')
1454
+ .sort((a, b) => compareStrings(a.name, b.name))
1455
+ const state = new Map<string, 'visiting' | 'done'>()
1456
+ const stack: string[] = []
1457
+ const visit = (model: InputModel): void => {
1458
+ state.set(model.name, 'visiting')
1459
+ stack.push(model.name)
1460
+ for (const field of model.fields) {
1461
+ if (field.ref.kind !== 'named' || !field.ref.nonNull) continue
1462
+ const target = this.#types.get(field.ref.name)
1463
+ if (target?.kind !== 'input') continue
1464
+ const status = state.get(target.name)
1465
+ if (status === 'visiting') {
1466
+ const cycle = [...stack.slice(stack.indexOf(target.name)), target.name]
1467
+ this.#report({
1468
+ code: Codes.InvalidInput,
1469
+ severity: 'error',
1470
+ message: `The input types ${cycle.join(' → ')} reference each other through non-null fields: no value can be written`,
1471
+ ...(target.location === undefined ? {} : { location: target.location }),
1472
+ hints: ['Make one of the fields of the cycle optional (?: T) or a list'],
1473
+ })
1474
+ } else if (status === undefined) {
1475
+ visit(target)
1476
+ }
1477
+ }
1478
+ stack.pop()
1479
+ state.set(model.name, 'done')
1480
+ }
1481
+ for (const model of inputs) if (!state.has(model.name)) visit(model)
1482
+ }
1483
+
1484
+ // ------------------------------------------------------------------------------------ assembly
1485
+
1486
+ #assemble(roots: ReadonlyMap<Operation, ObjectModel>, resolvers: readonly ResolverData[]): GraphQLModel {
1487
+ const types = [...this.#types.values()].sort((a, b) => compareStrings(a.name, b.name))
1488
+ const meta: MetaEntry[] = []
1489
+ for (const resolver of resolvers) for (const field of resolver.fields) meta.push(...field.meta)
1490
+ const rootNames = {
1491
+ query: ROOT_TYPE_NAMES.query,
1492
+ ...(roots.has('mutation') ? { mutation: ROOT_TYPE_NAMES.mutation } : {}),
1493
+ ...(roots.has('subscription') ? { subscription: ROOT_TYPE_NAMES.subscription } : {}),
1494
+ }
1495
+ const sdl = printSdl({ roots: rootNames, types: types.map(sdlType) })
1496
+ return { roots: rootNames, types, meta, sdl, schemaHash: schemaHash(sdl) }
1497
+ }
1498
+ }
1499
+
1500
+ // ------------------------------------------------------------------------------------------ helpers
1501
+
1502
+ /** A (nullable, until `#convert` decides) reference to a named type. */
1503
+ function refTo(name: string): Inner {
1504
+ return { ref: { kind: 'named', name, nonNull: false }, nullable: false }
1505
+ }
1506
+
1507
+ function reachedFrom(use: Use): { readonly message: string; readonly location: Location }[] {
1508
+ return use.reached === undefined || use.root === undefined
1509
+ ? []
1510
+ : [{ message: `reached from ${use.reached}`, location: use.root }]
1511
+ }
1512
+
1513
+ function decoratorName(operation: Operation): string {
1514
+ return ROOT_TYPE_NAMES[operation]
1515
+ }
1516
+
1517
+ function describeKey(model: TypeModel): string {
1518
+ return model.key.startsWith('#') ? `the ${model.kind} ${model.name}` : model.key
1519
+ }
1520
+
1521
+ /** Short description of a shape for messages (`string`, `Book`, `Map<string, number>`). */
1522
+ function describeShape(shape: Shape): string {
1523
+ switch (shape.kind) {
1524
+ case 'literal':
1525
+ return JSON.stringify(shape.value)
1526
+ case 'named':
1527
+ return shape.key.slice(shape.key.indexOf('#') + 1)
1528
+ case 'reference':
1529
+ return shape.name
1530
+ case 'opaque':
1531
+ return shape.text
1532
+ case 'array':
1533
+ return `${describeShape(shape.items)}[]`
1534
+ case 'union':
1535
+ return shape.members.map(describeShape).join(' | ')
1536
+ case 'object':
1537
+ return 'an object literal type'
1538
+ default:
1539
+ return shape.kind
1540
+ }
1541
+ }
1542
+
1543
+ function describeValue(value: unknown): string {
1544
+ try {
1545
+ return JSON.stringify(value) ?? String(value)
1546
+ } catch {
1547
+ return String(value)
1548
+ }
1549
+ }
1550
+
1551
+ function containsRef(value: unknown): boolean {
1552
+ if (typeof value !== 'object' || value === null) return false
1553
+ if (Array.isArray(value)) return value.some(containsRef)
1554
+ const prototype: unknown = Object.getPrototypeOf(value)
1555
+ if (prototype !== Object.prototype && prototype !== null) return true
1556
+ return Object.values(value).some(containsRef)
1557
+ }
1558
+
1559
+ function lowerFirst(text: string): string {
1560
+ return `${text.charAt(0).toLowerCase()}${text.slice(1)}`
1561
+ }