@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,1068 @@
1
+ /**
2
+ * Type conformance of schema-first resolvers (RFC 0007 §5.4): the TypeScript signatures of the
3
+ * resolver methods, read through the type shapes API, against the SDL types. Output positions
4
+ * (return types, parent types, the properties of the returned objects) must be assignable to the
5
+ * SDL type; input positions (`@Args` parameters, input objects) must accept every value graphql-js
6
+ * passes. The walk follows the object fields without resolver through the properties of the
7
+ * TypeScript types (each SDL type and TypeScript declaration pair once), which is also where an
8
+ * object field that nothing provides is found (coverage, §5.3).
9
+ *
10
+ * Every mismatch is a ZNIGHT068 located on the TypeScript annotation, the SDL definition related.
11
+ * `any`, `unknown`, opaque types and missing annotations are not checked. The walk also decides
12
+ * `nullAsUndefined` of arguments and input fields and the discriminators of abstract types.
13
+ */
14
+ import type {
15
+ Diagnostic,
16
+ Location,
17
+ MethodInfo,
18
+ NamedShape,
19
+ ParameterInfo,
20
+ Shape,
21
+ ShapeOptions,
22
+ ShapeProperty,
23
+ ShapeResult,
24
+ ValidateContext,
25
+ } from '@znight/compiler'
26
+ import type {
27
+ ASTNode,
28
+ GraphQLAbstractType,
29
+ GraphQLField,
30
+ GraphQLInputObjectType,
31
+ GraphQLInputType,
32
+ GraphQLObjectType,
33
+ GraphQLOutputType,
34
+ GraphQLSchema,
35
+ } from 'graphql'
36
+ import type { FieldData, GraphQLData, ResolverData } from './data.ts'
37
+ import type { DiscriminatorModel } from './model.ts'
38
+ import type { ScalarDeclaration } from './scalars.ts'
39
+
40
+ import { ASYNC_ITERABLES, BASE_KEEP, Codes, compareStrings, OUTPUT_ITERABLES } from './constants.ts'
41
+ import { graphqlModules } from './graphql-js.ts'
42
+ import { GraphQLIds } from './ids.ts'
43
+ import { annotationLocation, returnTypeLocation } from './locate.ts'
44
+
45
+ type Context = ValidateContext<GraphQLData>
46
+
47
+ /** A resolver method bound to an SDL field. */
48
+ export interface Binding {
49
+ readonly resolver: ResolverData
50
+ readonly field: FieldData
51
+ readonly method: MethodInfo
52
+ /** The type declaring the field (a root type for root fields). */
53
+ readonly type: GraphQLObjectType
54
+ readonly sdlField: GraphQLField<unknown, unknown>
55
+ }
56
+
57
+ /** Inputs of {@link checkConformance}. */
58
+ export interface ConformanceInput {
59
+ readonly ctx: Context
60
+ readonly schema: GraphQLSchema
61
+ readonly locate: (node: ASTNode | null | undefined) => Location | undefined
62
+ /** `scalar<T>()` declarations by GraphQL name. */
63
+ readonly scalars: ReadonlyMap<string, ScalarDeclaration>
64
+ /** Sorted by resolver identity, then source order. */
65
+ readonly bindings: readonly Binding[]
66
+ /** Coordinates (`Event.owner`) bound to a resolver. */
67
+ readonly bound: ReadonlySet<string>
68
+ readonly report: (diagnostic: Diagnostic) => void
69
+ }
70
+
71
+ /** What the walk decides for the model. */
72
+ export interface ConformanceResult {
73
+ /** Coordinates of the arguments (`Query.books(limit:)`) and input fields (`BookFilter.genre`) whose `null` becomes `undefined`. */
74
+ readonly nullAsUndefined: ReadonlySet<string>
75
+ /** Discriminators of the abstract types; absent: `typename`. */
76
+ readonly discriminators: ReadonlyMap<string, DiscriminatorModel>
77
+ }
78
+
79
+ /** Where a TypeScript type is checked. */
80
+ interface Site {
81
+ /** What the type is, in messages: `EventResolver.event returns`, `User.email is`. */
82
+ readonly label: string
83
+ /** Where the TypeScript type comes from, in messages: `returned by EventResolver.event`. */
84
+ readonly origin: string
85
+ /** The SDL coordinate, in messages: `Query.event`, `Event.owner`, `Query.events(limit:)`. */
86
+ readonly coordinate: string
87
+ /** The TypeScript annotation (called only when a diagnostic is reported). */
88
+ readonly locate: () => Location | undefined
89
+ /** The SDL definition. */
90
+ readonly sdl: Location | undefined
91
+ /** An optional property (output: may be `undefined`). */
92
+ readonly optional: boolean
93
+ /** The type of the field or argument, when `type` is the type of its list items. */
94
+ readonly list?: string
95
+ }
96
+
97
+ /** Options of an input position. */
98
+ interface InputPosition {
99
+ /** The TypeScript value may be `undefined`: optional parameter or property, parameter default. */
100
+ readonly optional: boolean
101
+ /** The SDL argument or input field has a default value: never absent. */
102
+ readonly sdlDefault: boolean
103
+ /** Coordinate whose `nullAsUndefined` this position votes for. */
104
+ readonly vote?: string
105
+ /** A list item: never absent, and a `null` item stays `null` (no `nullAsUndefined`). */
106
+ readonly item?: true
107
+ }
108
+
109
+ /** A shape split into its non-nullish members. */
110
+ interface Parts {
111
+ readonly members: Shape[]
112
+ hasNull: boolean
113
+ hasUndefined: boolean
114
+ /** `unknown`, `any` or an opaque part: not checked. */
115
+ unchecked: boolean
116
+ }
117
+
118
+ const MAX_DEPTH = 64
119
+
120
+ /** Walks every binding; reports the mismatches. */
121
+ export function checkConformance(input: ConformanceInput): ConformanceResult {
122
+ return new Conformance(input).run()
123
+ }
124
+
125
+ class Conformance {
126
+ readonly #config: ConformanceInput
127
+ readonly #ctx: Context
128
+ readonly #options: ShapeOptions
129
+ readonly #named = new Map<string, NamedShape>()
130
+ readonly #visited = new Set<string>()
131
+ readonly #anonymous = new WeakMap<object, Set<string>>()
132
+ readonly #reported = new Set<string>()
133
+ readonly #votes = new Map<string, { accepts: number; rejects: Site[] }>()
134
+ /** Abstract type → member object type → class identities (`''`: not an exported class) returned for it. */
135
+ readonly #members = new Map<string, Map<string, Set<string>>>()
136
+ /** TypeScript members of abstract types that cannot be told apart (ZNIGHT064 unless every member is a class). */
137
+ readonly #untellable = new Map<
138
+ string,
139
+ { readonly name: string; readonly site: Site; readonly location?: Location }[]
140
+ >()
141
+ readonly #predicates = graphqlModules().type
142
+ /** Identities of the `T` of the `scalar<T>()` declarations. */
143
+ readonly #scalarTypes: ReadonlySet<string>
144
+
145
+ constructor(input: ConformanceInput) {
146
+ this.#config = input
147
+ this.#ctx = input.ctx
148
+ const keep = new Set<string>(BASE_KEEP)
149
+ for (const scalar of input.scalars.values()) keep.add(scalar.type)
150
+ this.#scalarTypes = new Set([...input.scalars.values()].map(scalar => scalar.type))
151
+ this.#options = Object.freeze({
152
+ keep: Object.freeze([...keep].sort(compareStrings)),
153
+ members: 'instance',
154
+ jsdoc: false,
155
+ })
156
+ }
157
+
158
+ run(): ConformanceResult {
159
+ for (const binding of this.#config.bindings) this.#binding(binding)
160
+ const nullAsUndefined = new Set<string>()
161
+ for (const [coordinate, vote] of [...this.#votes].sort(([a], [b]) => compareStrings(a, b))) {
162
+ if (vote.rejects.length === 0) continue
163
+ if (vote.accepts === 0) {
164
+ nullAsUndefined.add(coordinate)
165
+ continue
166
+ }
167
+ for (const site of vote.rejects) {
168
+ this.#mismatch(
169
+ site,
170
+ `${site.label} does not accept null, but another use of ${coordinate} does: null is passed as is`,
171
+ ['Accept null (| null) in every TypeScript type of the value, or none of them'],
172
+ )
173
+ }
174
+ }
175
+ return { nullAsUndefined, discriminators: this.#discriminators() }
176
+ }
177
+
178
+ // ------------------------------------------------------------------------------------ bindings
179
+
180
+ #binding(binding: Binding): void {
181
+ const { resolver, field, method, type, sdlField } = binding
182
+ const where = `${resolver.host.displayName}.${method.name}`
183
+ const coordinate = `${type.name}.${sdlField.name}`
184
+ const sdl = this.#config.locate(sdlField.astNode)
185
+ if (method.returnType !== undefined) {
186
+ const result = this.#shape({ returnOf: method })
187
+ const site: Site = {
188
+ label: `${where} returns`,
189
+ origin: `returned by ${where}`,
190
+ coordinate,
191
+ locate: () => returnTypeLocation(this.#ctx.project, method) ?? method.location,
192
+ sdl,
193
+ optional: false,
194
+ }
195
+ const root = result.root
196
+ const iterable = root.kind === 'reference' && ASYNC_ITERABLES.has(root.identity) ? root : undefined
197
+ if (field.operation === 'subscription') {
198
+ if (iterable !== undefined) {
199
+ this.#output(sdlField.type, iterable.typeArgs[0] ?? { kind: 'unknown' }, site)
200
+ } else if (!this.#parts(root).unchecked) {
201
+ this.#report({
202
+ code: Codes.InvalidResolver,
203
+ severity: 'error',
204
+ message: `@Subscription ${where} must return an async iterable (${coordinate})`,
205
+ location: site.locate() ?? method.location,
206
+ hints: [
207
+ `Declare it as an async generator (async *${method.name}(): AsyncGenerator<Event>) or return pubsub.subscribe(...) (AsyncIterable<Event>)`,
208
+ ],
209
+ })
210
+ }
211
+ } else if (iterable === undefined) {
212
+ this.#output(sdlField.type, root, site)
213
+ } else {
214
+ this.#mismatch(site, `${where} returns an async iterable: only Subscription fields stream values`, [
215
+ `Use @Subscription() for a stream of events, or return the value of ${coordinate}`,
216
+ ])
217
+ }
218
+ }
219
+ field.params.forEach((data, index) => {
220
+ const param = method.params[index] as ParameterInfo | undefined
221
+ if (param === undefined) return
222
+ if (data?.kind === 'args' && data.schema === undefined) this.#arguments(binding, data.name, param, where)
223
+ else if (data?.kind === 'parent') this.#parent(type, param, where)
224
+ })
225
+ }
226
+
227
+ /** `@Args('name')` (one argument) or `@Args()` (every argument, an object type). */
228
+ #arguments(binding: Binding, name: string | undefined, param: ParameterInfo, where: string): void {
229
+ const { type, sdlField } = binding
230
+ const coordinate = `${type.name}.${sdlField.name}`
231
+ const result = this.#shape({ parameter: param })
232
+ if (name !== undefined) {
233
+ const arg = sdlField.args.find(candidate => candidate.name === name)
234
+ if (arg === undefined) return // coverage (schema-first.ts)
235
+ this.#input(
236
+ arg.type,
237
+ result.root,
238
+ {
239
+ optional: param.optional || param.hasDefault,
240
+ sdlDefault: arg.default !== undefined,
241
+ vote: `${coordinate}(${name}:)`,
242
+ },
243
+ {
244
+ label: `parameter '${param.name}' of ${where}`,
245
+ origin: `parameter '${param.name}' of ${where}`,
246
+ coordinate: `${coordinate}(${name}:)`,
247
+ locate: () => param.typeLocation,
248
+ sdl: this.#config.locate(arg.astNode),
249
+ optional: false,
250
+ },
251
+ )
252
+ return
253
+ }
254
+ const site: Site = {
255
+ label: `@Args() parameter '${param.name}' of ${where}`,
256
+ origin: `@Args() parameter '${param.name}' of ${where}`,
257
+ coordinate,
258
+ locate: () => param.typeLocation,
259
+ sdl: this.#config.locate(sdlField.astNode),
260
+ optional: false,
261
+ }
262
+ const parts = this.#parts(result.root)
263
+ if (parts.unchecked) return
264
+ const object = parts.members.length === 1 ? (parts.members[0] as Shape) : undefined
265
+ const properties = object === undefined ? undefined : this.#propertiesOf(object)
266
+ if (properties === 'record') return
267
+ if (properties === undefined) {
268
+ this.#mismatch(site, `${site.label} must be an object type: it receives every argument of ${coordinate}`, [
269
+ `@Args('name') receives one argument`,
270
+ ])
271
+ return
272
+ }
273
+ for (const property of properties) {
274
+ const arg = sdlField.args.find(candidate => candidate.name === property.name)
275
+ const propertySite: Site = {
276
+ label: `property '${property.name}' of the @Args() parameter of ${where}`,
277
+ origin: site.origin,
278
+ coordinate: `${coordinate}(${property.name}:)`,
279
+ locate: () => annotationLocation(this.#ctx.project, property.location) ?? param.typeLocation,
280
+ sdl: this.#config.locate(arg?.astNode ?? sdlField.astNode),
281
+ optional: false,
282
+ }
283
+ if (arg === undefined) {
284
+ const names = sdlField.args.map(candidate => candidate.name)
285
+ this.#mismatch(
286
+ { ...propertySite, locate: () => property.location ?? param.typeLocation },
287
+ `${coordinate} has no argument '${property.name}' (${propertySite.label})`,
288
+ [names.length === 0 ? `${coordinate} takes no argument` : `Its arguments: ${names.join(', ')}`],
289
+ )
290
+ continue
291
+ }
292
+ this.#input(
293
+ arg.type,
294
+ property.shape,
295
+ { optional: property.optional, sdlDefault: arg.default !== undefined, vote: propertySite.coordinate },
296
+ propertySite,
297
+ )
298
+ }
299
+ }
300
+
301
+ /** `@Parent()`: its properties named like the unbound fields of the type conform to them. */
302
+ #parent(type: GraphQLObjectType, param: ParameterInfo, where: string): void {
303
+ const result = this.#shape({ parameter: param })
304
+ const site: Site = {
305
+ label: `@Parent() parameter '${param.name}' of ${where}`,
306
+ origin: `@Parent() parameter of ${where}`,
307
+ coordinate: type.name,
308
+ locate: () => param.typeLocation,
309
+ sdl: this.#config.locate(type.astNode),
310
+ optional: false,
311
+ }
312
+ const parts = this.#parts(result.root)
313
+ if (parts.unchecked) return
314
+ for (const member of parts.members) {
315
+ const properties = this.#propertiesOf(member)
316
+ if (properties === 'record') continue
317
+ if (properties === undefined) {
318
+ this.#mismatch(site, `${site.label} is ${this.#describe(member)}, not an object type (${type.name})`, [
319
+ `Type it with the TypeScript type of ${type.name}`,
320
+ ])
321
+ continue
322
+ }
323
+ const fields = type.getFields()
324
+ for (const property of properties) {
325
+ const field = fields[property.name]
326
+ if (field === undefined || this.#config.bound.has(`${type.name}.${field.name}`)) continue
327
+ this.#output(field.type, property.shape, this.#propertySite(site, type, field, property, member))
328
+ }
329
+ }
330
+ }
331
+
332
+ // -------------------------------------------------------------------------------------- output
333
+
334
+ #output(type: GraphQLOutputType, shape: Shape, site: Site, depth = 0): void {
335
+ if (depth > MAX_DEPTH) return
336
+ const parts = this.#parts(shape)
337
+ if (parts.unchecked) return
338
+ const { isNonNullType, isListType, isScalarType, isEnumType, isObjectType } = this.#predicates
339
+ let inner = type
340
+ if (isNonNullType(inner)) {
341
+ if (parts.hasNull || parts.hasUndefined || site.optional) {
342
+ const what = parts.hasNull ? 'null' : 'undefined'
343
+ const described = `${site.label} ${this.#describeParts(parts, site.optional)}, which may be ${what}`
344
+ if (site.list === undefined) {
345
+ this.#mismatch(site, `${described}, but ${site.coordinate} is ${String(type)} (non-null)`, [
346
+ `Return a value in every case, or make ${site.coordinate} nullable in the SDL (${String(inner.ofType)})`,
347
+ ])
348
+ } else {
349
+ this.#mismatch(
350
+ site,
351
+ `${described}, but the items of ${site.coordinate} (${site.list}) are ${String(type)} (non-null)`,
352
+ [`Return non-null items, or make the items of ${site.coordinate} nullable in the SDL`],
353
+ )
354
+ }
355
+ }
356
+ inner = inner.ofType
357
+ }
358
+ if (parts.members.length === 0) return
359
+ if (isListType(inner)) {
360
+ for (const member of parts.members) {
361
+ const item = this.#listItem(member, 'output')
362
+ if (item === undefined) {
363
+ this.#mismatch(
364
+ site,
365
+ `${site.label} ${this.#describe(member)}, not a list, but ${site.coordinate} is ${String(type)}`,
366
+ ['Return an array (readonly T[]), a Set or an Iterable'],
367
+ )
368
+ continue
369
+ }
370
+ for (const shape of item) {
371
+ this.#output(
372
+ inner.ofType,
373
+ shape,
374
+ { ...site, label: `${site.label} a list of`, optional: false, list: site.list ?? String(type) },
375
+ depth + 1,
376
+ )
377
+ }
378
+ }
379
+ return
380
+ }
381
+ if (isScalarType(inner)) {
382
+ for (const member of parts.members) {
383
+ if (this.#scalarAccepts(inner.name, member, 'output')) continue
384
+ this.#mismatch(
385
+ site,
386
+ `${site.label} ${this.#describe(member)}, which does not conform to ${inner.name} (${site.coordinate})`,
387
+ [this.#scalarHint(inner.name)],
388
+ )
389
+ }
390
+ return
391
+ }
392
+ if (isEnumType(inner)) {
393
+ const values = new Set(inner.getValues().map(value => value.name))
394
+ for (const member of parts.members) {
395
+ const literals = this.#literals(member)
396
+ if (literals === undefined) {
397
+ this.#mismatch(
398
+ site,
399
+ `${site.label} ${this.#describe(member)}, wider than the enum ${inner.name} (${site.coordinate})`,
400
+ [
401
+ `Return the values of ${inner.name}: ${[...values].join(', ')} (a TypeScript enum or a union of literals)`,
402
+ ],
403
+ )
404
+ continue
405
+ }
406
+ const unknown = literals.filter(literal => !values.has(String(literal)))
407
+ if (unknown.length > 0) {
408
+ this.#mismatch(
409
+ site,
410
+ `${site.label} ${unknown.map(literal => JSON.stringify(literal)).join(', ')}, not ${unknown.length === 1 ? 'a value' : 'values'} of the enum ${inner.name} (${site.coordinate})`,
411
+ [`Enum values are compared by value: ${[...values].join(', ')}`],
412
+ )
413
+ }
414
+ }
415
+ return
416
+ }
417
+ if (isObjectType(inner)) {
418
+ for (const member of parts.members) this.#object(inner, member, site, depth)
419
+ return
420
+ }
421
+ this.#abstract(inner, parts.members, site, depth)
422
+ }
423
+
424
+ /** An object type returned as `shape`: every field without resolver is a conforming property. */
425
+ #object(type: GraphQLObjectType, shape: Shape, site: Site, depth: number): void {
426
+ const properties = this.#propertiesOf(shape)
427
+ if (properties === 'record') return
428
+ if (properties === undefined) {
429
+ this.#mismatch(
430
+ site,
431
+ `${site.label} ${this.#describe(shape)}, not an object type, but ${site.coordinate} is ${type.name}`,
432
+ [`Return the TypeScript type of ${type.name} (an interface, a class or an object type)`],
433
+ )
434
+ return
435
+ }
436
+ if (!this.#visit(`output\0${type.name}`, shape)) return
437
+ const named = shape.kind === 'named' ? this.#named.get(shape.key) : undefined
438
+ const tsName = named?.name ?? 'the returned object type'
439
+ const byName = new Map(properties.map(property => [property.name, property]))
440
+ for (const field of Object.values(type.getFields())) {
441
+ const coordinate = `${type.name}.${field.name}`
442
+ if (this.#config.bound.has(coordinate)) continue
443
+ const property = byName.get(field.name)
444
+ if (property === undefined) {
445
+ const location = this.#config.locate(field.astNode)
446
+ const declaration = named?.location ?? site.locate()
447
+ this.#reportOnce(`provide\0${coordinate}\0${named?.key ?? site.label}`, {
448
+ code: Codes.SchemaMismatch,
449
+ severity: 'error',
450
+ message: `${coordinate} has no resolver, and ${tsName} (${site.origin}) has no property '${field.name}'`,
451
+ ...(location === undefined ? {} : { location }),
452
+ hints: [
453
+ `Add a field resolver: @ResolveField('${field.name}') in a @Resolver('${type.name}') class`,
454
+ `Or add '${field.name}' to ${tsName}`,
455
+ ],
456
+ ...(declaration === undefined
457
+ ? {}
458
+ : { related: [{ message: `${tsName} is declared here`, location: declaration }] }),
459
+ })
460
+ continue
461
+ }
462
+ this.#output(field.type, property.shape, this.#propertySite(site, type, field, property, shape), depth + 1)
463
+ }
464
+ }
465
+
466
+ /** A union or an interface: each TypeScript member is matched to a possible type. */
467
+ #abstract(type: GraphQLAbstractType, members: readonly Shape[], site: Site, depth: number): void {
468
+ const possible = this.#config.schema.getPossibleTypes(type)
469
+ const names = possible.map(object => object.name)
470
+ for (const member of members) {
471
+ const named = member.kind === 'named' ? this.#named.get(member.key) : undefined
472
+ const properties = this.#propertiesOf(member)
473
+ if (properties === undefined) {
474
+ this.#mismatch(
475
+ site,
476
+ `${site.label} ${this.#describe(member)}, not an object type, but ${site.coordinate} is ${type.name}`,
477
+ [`Return one of ${names.join(', ')}`],
478
+ )
479
+ continue
480
+ }
481
+ if (properties === 'record') continue
482
+ // graphql-codegen's interface types (`Node`): checked against the fields of the interface
483
+ if (named?.name === type.name && this.#predicates.isInterfaceType(type)) {
484
+ if (!this.#visit(`interface\0${type.name}`, member)) continue
485
+ const byName = new Map(properties.map(property => [property.name, property]))
486
+ for (const field of Object.values(type.getFields())) {
487
+ const property = byName.get(field.name)
488
+ if (property !== undefined) {
489
+ this.#output(field.type, property.shape, this.#propertySite(site, type, field, property, member), depth + 1)
490
+ }
491
+ }
492
+ continue
493
+ }
494
+ const typename = properties.find(property => property.name === '__typename')
495
+ const literal = typename?.shape.kind === 'literal' ? typename.shape.value : undefined
496
+ // a member is recognised by its `__typename` literal, else by its name
497
+ let target = typeof literal === 'string' ? literal : undefined
498
+ if (target === undefined && named !== undefined && names.includes(named.name)) target = named.name
499
+ if (target === undefined || !names.includes(target)) {
500
+ const what = named?.name ?? this.#describe(member)
501
+ if (names.length === 0) {
502
+ this.#mismatch(site, `${site.label} ${what}, but no object type implements ${type.name} in the SDL`, [
503
+ `Declare an implementation: type ${what} implements ${type.name} { ... }`,
504
+ ])
505
+ continue
506
+ }
507
+ this.#mismatch(
508
+ site,
509
+ `${site.label} ${what}, which is not ${names.length === 1 ? 'the member' : 'a member'} of ${type.name} (${names.join(', ')})`,
510
+ [
511
+ `Return ${names.join(' | ')}: a member is recognised by its __typename literal (__typename: '${names[0] ?? 'Member'}'), else by its name`,
512
+ ],
513
+ )
514
+ continue
515
+ }
516
+ const object = this.#config.schema.getType(target) as GraphQLObjectType
517
+ this.#object(object, member, site, depth + 1)
518
+ const identity =
519
+ named?.declaration === 'class' &&
520
+ named.typeArgs.length === 0 &&
521
+ this.#ctx.classInfo(named.identity)?.exported === true
522
+ ? named.identity
523
+ : ''
524
+ let byMember = this.#members.get(type.name)
525
+ if (byMember === undefined) {
526
+ byMember = new Map()
527
+ this.#members.set(type.name, byMember)
528
+ }
529
+ let identities = byMember.get(target)
530
+ if (identities === undefined) {
531
+ identities = new Set()
532
+ byMember.set(target, identities)
533
+ }
534
+ identities.add(identity)
535
+ if (typename === undefined && identity === '') {
536
+ let list = this.#untellable.get(type.name)
537
+ if (list === undefined) {
538
+ list = []
539
+ this.#untellable.set(type.name, list)
540
+ }
541
+ list.push({ name: named?.name ?? target, site, ...(named === undefined ? {} : { location: named.location }) })
542
+ }
543
+ }
544
+ }
545
+
546
+ // --------------------------------------------------------------------------------------- input
547
+
548
+ #input(type: GraphQLInputType, shape: Shape, position: InputPosition, site: Site, depth = 0): void {
549
+ if (depth > MAX_DEPTH) return
550
+ const parts = this.#parts(shape)
551
+ const { isNonNullType, isListType, isScalarType, isEnumType } = this.#predicates
552
+ let inner = type
553
+ if (isNonNullType(inner)) inner = inner.ofType
554
+ else {
555
+ const acceptsNull = parts.hasNull || parts.unchecked
556
+ // a list item is never absent, and a null item is never turned into undefined
557
+ const acceptsUndefined = position.item !== true && (parts.hasUndefined || position.optional || parts.unchecked)
558
+ if (position.item === true) {
559
+ if (!acceptsNull) {
560
+ this.#mismatch(
561
+ site,
562
+ `${site.label} does not accept null, but the items of ${site.coordinate} (${site.list ?? String(type)}) may be null`,
563
+ [`Accept null items ((T | null)[]), or make the items non-null in the SDL (${String(type)}!)`],
564
+ )
565
+ }
566
+ } else if (!position.sdlDefault && !acceptsUndefined) {
567
+ this.#mismatch(
568
+ site,
569
+ `${site.label} does not accept undefined, but ${site.coordinate} (${String(type)}) may be omitted`,
570
+ ['Make it optional (?: or | undefined), or make the SDL argument non-null or give it a default value'],
571
+ )
572
+ } else if (!acceptsNull && !acceptsUndefined) {
573
+ this.#mismatch(
574
+ site,
575
+ `${site.label} does not accept null, but ${site.coordinate} (${String(type)}) may be null`,
576
+ [`Accept null (| null) or undefined, or make it non-null in the SDL (${String(type)}!)`],
577
+ )
578
+ }
579
+ if (position.vote !== undefined && !parts.unchecked) {
580
+ let vote = this.#votes.get(position.vote)
581
+ if (vote === undefined) {
582
+ vote = { accepts: 0, rejects: [] }
583
+ this.#votes.set(position.vote, vote)
584
+ }
585
+ if (acceptsNull) vote.accepts++
586
+ else if (acceptsUndefined) vote.rejects.push(site)
587
+ }
588
+ }
589
+ if (parts.unchecked) return
590
+ if (parts.members.length === 0) {
591
+ this.#mismatch(site, `${site.label} accepts no ${String(inner)} value (${site.coordinate})`, [
592
+ `Type it with the TypeScript type of ${String(inner)}`,
593
+ ])
594
+ return
595
+ }
596
+ if (isListType(inner)) {
597
+ const lists = parts.members.map(member => this.#listItem(member, 'input')).filter(item => item !== undefined)
598
+ if (lists.length === 0) {
599
+ this.#mismatch(
600
+ site,
601
+ `${site.label} is ${this.#describeParts(parts, false)}, not an array, but ${site.coordinate} is ${String(type)}`,
602
+ ['Accept an array (readonly T[]): graphql-js always passes lists as arrays'],
603
+ )
604
+ return
605
+ }
606
+ for (const items of lists) {
607
+ for (const item of items) {
608
+ this.#input(
609
+ inner.ofType,
610
+ item,
611
+ { optional: false, sdlDefault: true, item: true },
612
+ { ...site, label: `${site.label} (items)`, list: site.list ?? String(type) },
613
+ depth + 1,
614
+ )
615
+ }
616
+ }
617
+ return
618
+ }
619
+ if (isScalarType(inner)) {
620
+ if (parts.members.some(member => this.#scalarAccepts(inner.name, member, 'input'))) return
621
+ this.#mismatch(
622
+ site,
623
+ `${site.label} is ${this.#describeParts(parts, false)}, which does not accept every ${inner.name} (${site.coordinate})`,
624
+ [this.#scalarHint(inner.name)],
625
+ )
626
+ return
627
+ }
628
+ if (isEnumType(inner)) {
629
+ const accepted = new Set<string>()
630
+ for (const member of parts.members) {
631
+ if (member.kind === 'string') return
632
+ for (const literal of this.#literals(member) ?? []) accepted.add(String(literal))
633
+ }
634
+ const missing = inner.getValues().filter(value => !accepted.has(value.name))
635
+ if (missing.length > 0) {
636
+ this.#mismatch(
637
+ site,
638
+ `${site.label} is ${this.#describeParts(parts, false)}, which does not accept ${missing.map(value => value.name).join(', ')} of the enum ${inner.name} (${site.coordinate})`,
639
+ [`Accept every value of ${inner.name} (graphql-codegen's enum type, or a union of every value)`],
640
+ )
641
+ }
642
+ return
643
+ }
644
+ const objects = parts.members.filter(member => this.#propertiesOf(member) !== undefined)
645
+ if (objects.length === 0) {
646
+ this.#mismatch(
647
+ site,
648
+ `${site.label} is ${this.#describeParts(parts, false)}, not an object type, but ${site.coordinate} is ${inner.name}`,
649
+ [`Type it with the TypeScript type of the input ${inner.name}`],
650
+ )
651
+ return
652
+ }
653
+ for (const object of objects) this.#inputObject(inner as GraphQLInputObjectType, object, site, depth)
654
+ }
655
+
656
+ /** An input object received as `shape`: each property is a field of the input, accepting its values. */
657
+ #inputObject(type: GraphQLInputObjectType, shape: Shape, site: Site, depth: number): void {
658
+ const properties = this.#propertiesOf(shape)
659
+ if (properties === undefined || properties === 'record') return
660
+ if (!this.#visit(`input\0${type.name}`, shape)) return
661
+ const fields = type.getFields()
662
+ for (const property of properties) {
663
+ if (property.name === '__typename') continue
664
+ const field = fields[property.name]
665
+ const propertySite: Site = {
666
+ label:
667
+ this.#tsName(shape) === undefined ? `property '${property.name}'` : `${this.#tsName(shape)}.${property.name}`,
668
+ origin: site.origin,
669
+ coordinate: `${type.name}.${property.name}`,
670
+ locate: () => annotationLocation(this.#ctx.project, property.location) ?? site.locate(),
671
+ sdl: this.#config.locate(field?.astNode ?? type.astNode),
672
+ optional: false,
673
+ }
674
+ if (field === undefined) {
675
+ this.#mismatch(
676
+ { ...propertySite, locate: () => property.location ?? site.locate() },
677
+ `${propertySite.label} is not a field of the input ${type.name}: it is always undefined`,
678
+ [`Remove it, or declare it in the SDL (input ${type.name} { ${property.name}: ... })`],
679
+ )
680
+ continue
681
+ }
682
+ this.#input(
683
+ field.type,
684
+ property.shape,
685
+ { optional: property.optional, sdlDefault: field.default !== undefined, vote: propertySite.coordinate },
686
+ propertySite,
687
+ depth + 1,
688
+ )
689
+ }
690
+ }
691
+
692
+ // ------------------------------------------------------------------------------------- scalars
693
+
694
+ /** A member (not nullish, not a union) conforms to a scalar: output, or accepts every value: input. */
695
+ #scalarAccepts(name: string, shape: Shape, position: 'input' | 'output'): boolean {
696
+ const custom = this.#config.scalars.get(name)
697
+ if (custom !== undefined) return this.#hasReference(shape, custom.type)
698
+ // the type of another scalar<T>() declaration (a branded id for an ID or a String): its
699
+ // representation is not known, string-like scalars only
700
+ if ((name === 'String' || name === 'ID') && shape.kind === 'reference' && this.#scalarTypes.has(shape.identity)) {
701
+ return true
702
+ }
703
+ const output = position === 'output'
704
+ switch (name) {
705
+ case 'String':
706
+ return this.#isString(shape, output)
707
+ case 'ID':
708
+ return this.#isString(shape, output) || (output && this.#isNumber(shape, true))
709
+ case 'Int':
710
+ case 'Float':
711
+ return this.#isNumber(shape, output)
712
+ case 'Boolean':
713
+ return shape.kind === 'boolean' || (output && shape.kind === 'literal' && typeof shape.value === 'boolean')
714
+ case 'DateTime':
715
+ return shape.kind === 'date'
716
+ case 'BigInt':
717
+ return shape.kind === 'bigint' || (output && this.#isNumber(shape, true))
718
+ default:
719
+ // JSON and the built-in scalars without TypeScript counterpart
720
+ return true
721
+ }
722
+ }
723
+
724
+ #scalarHint(name: string): string {
725
+ const custom = this.#config.scalars.get(name)
726
+ if (custom !== undefined) {
727
+ return `${name} is declared with scalar<${custom.type.slice(custom.type.indexOf('#') + 1)}>(): use that type (graphql-codegen: scalars: { ${name}: '...' })`
728
+ }
729
+ switch (name) {
730
+ case 'Int':
731
+ case 'Float':
732
+ return 'Use number (or the Int / Float aliases of @znight/graphql)'
733
+ case 'Boolean':
734
+ return 'Use boolean'
735
+ case 'DateTime':
736
+ return 'The built-in DateTime scalar is a Date'
737
+ case 'BigInt':
738
+ return 'The built-in BigInt scalar is a bigint'
739
+ default:
740
+ return 'Use string (string literals, string enums and branded strings conform in output positions)'
741
+ }
742
+ }
743
+
744
+ /** `string`, a branded string, the `ID` alias; output: also string literals and string enums. */
745
+ #isString(shape: Shape, output: boolean): boolean {
746
+ switch (shape.kind) {
747
+ case 'string':
748
+ return true
749
+ case 'literal':
750
+ return output && typeof shape.value === 'string'
751
+ case 'reference':
752
+ return shape.identity === GraphQLIds.ID
753
+ case 'intersection':
754
+ return shape.members.some(
755
+ member => member.kind === 'string' || (member.kind === 'reference' && member.identity === GraphQLIds.ID),
756
+ )
757
+ case 'named': {
758
+ const named = this.#named.get(shape.key)
759
+ return (
760
+ output &&
761
+ named?.declaration === 'enum' &&
762
+ (this.#literals(shape) ?? []).every(value => typeof value === 'string')
763
+ )
764
+ }
765
+ default:
766
+ return false
767
+ }
768
+ }
769
+
770
+ /** `number`, a branded number, the `Int` / `Float` aliases; output: also number literals and numeric enums. */
771
+ #isNumber(shape: Shape, output: boolean): boolean {
772
+ const alias = (candidate: Shape): boolean =>
773
+ candidate.kind === 'reference' &&
774
+ (candidate.identity === GraphQLIds.Int || candidate.identity === GraphQLIds.Float)
775
+ switch (shape.kind) {
776
+ case 'number':
777
+ return true
778
+ case 'literal':
779
+ return output && typeof shape.value === 'number'
780
+ case 'reference':
781
+ return alias(shape)
782
+ case 'intersection':
783
+ return shape.members.some(member => member.kind === 'number' || alias(member))
784
+ case 'named': {
785
+ const named = this.#named.get(shape.key)
786
+ return (
787
+ output &&
788
+ named?.declaration === 'enum' &&
789
+ (this.#literals(shape) ?? []).every(value => typeof value === 'number')
790
+ )
791
+ }
792
+ default:
793
+ return false
794
+ }
795
+ }
796
+
797
+ #hasReference(shape: Shape, identity: string): boolean {
798
+ if (shape.kind === 'reference') return shape.identity === identity
799
+ if (shape.kind === 'intersection') return shape.members.some(member => this.#hasReference(member, identity))
800
+ return false
801
+ }
802
+
803
+ // -------------------------------------------------------------------------------------- shapes
804
+
805
+ #shape(target: { readonly parameter: ParameterInfo } | { readonly returnOf: MethodInfo }): ShapeResult {
806
+ const result = this.#ctx.types.shape(target, this.#options)
807
+ for (const named of result.named.values()) this.#named.set(named.key, named)
808
+ return result
809
+ }
810
+
811
+ /** The non-nullish members of a shape, through unions and aliases (object aliases stay named). */
812
+ #parts(shape: Shape): Parts {
813
+ const parts: Parts = { members: [], hasNull: false, hasUndefined: false, unchecked: false }
814
+ const visit = (current: Shape, depth: number): void => {
815
+ if (depth > MAX_DEPTH) return
816
+ switch (current.kind) {
817
+ case 'null':
818
+ parts.hasNull = true
819
+ return
820
+ case 'undefined':
821
+ parts.hasUndefined = true
822
+ return
823
+ case 'unknown':
824
+ case 'opaque':
825
+ case 'schema':
826
+ parts.unchecked = true
827
+ return
828
+ case 'never':
829
+ return
830
+ case 'union':
831
+ for (const member of current.members) visit(member, depth + 1)
832
+ return
833
+ case 'named': {
834
+ const named = this.#named.get(current.key)
835
+ if (named === undefined) {
836
+ parts.unchecked = true
837
+ return
838
+ }
839
+ const target = named.shape
840
+ const isObject =
841
+ target.kind === 'object' ||
842
+ (target.kind === 'intersection' && this.#propertiesOf(target) !== undefined) ||
843
+ (target.kind === 'named' && this.#named.get(target.key)?.declaration !== 'alias')
844
+ if (named.declaration !== 'alias' || isObject) parts.members.push(current)
845
+ else visit(target, depth + 1)
846
+ return
847
+ }
848
+ default:
849
+ parts.members.push(current)
850
+ }
851
+ }
852
+ visit(shape, 0)
853
+ return parts
854
+ }
855
+
856
+ /** The item shapes of a list member (array, tuple, iterable); `undefined`: not a list. */
857
+ #listItem(shape: Shape, position: 'input' | 'output'): readonly Shape[] | undefined {
858
+ if (shape.kind === 'array') return [shape.items]
859
+ if (shape.kind === 'tuple')
860
+ return position === 'output' ? [...shape.items, ...(shape.rest === undefined ? [] : [shape.rest])] : undefined
861
+ if (shape.kind === 'reference' && position === 'output' && OUTPUT_ITERABLES.has(shape.identity)) {
862
+ return [shape.typeArgs[0] ?? { kind: 'unknown' }]
863
+ }
864
+ return undefined
865
+ }
866
+
867
+ /** The literal values of a member: a literal, an enum; `undefined`: not literals. */
868
+ #literals(shape: Shape): readonly (string | number | boolean)[] | undefined {
869
+ if (shape.kind === 'literal') return [shape.value]
870
+ if (shape.kind !== 'named') return undefined
871
+ const named = this.#named.get(shape.key)
872
+ if (named?.declaration !== 'enum') return undefined
873
+ if (named.enumMembers !== undefined) return named.enumMembers.map(member => member.value)
874
+ const target = named.shape
875
+ if (target.kind === 'literal') return [target.value]
876
+ if (target.kind === 'union' && target.members.every(member => member.kind === 'literal')) {
877
+ return target.members.map(member => (member as { value: string | number | boolean }).value)
878
+ }
879
+ return undefined
880
+ }
881
+
882
+ /** The properties of an object structure (intersections merged, later members win), `'record'` for index signatures. */
883
+ #propertiesOf(shape: Shape, depth = 0): readonly ShapeProperty[] | 'record' | undefined {
884
+ if (depth > MAX_DEPTH) return undefined
885
+ switch (shape.kind) {
886
+ case 'object':
887
+ return shape.additional === undefined ? shape.properties : 'record'
888
+ case 'named': {
889
+ const named = this.#named.get(shape.key)
890
+ return named === undefined || named.declaration === 'enum'
891
+ ? undefined
892
+ : this.#propertiesOf(named.shape, depth + 1)
893
+ }
894
+ case 'intersection': {
895
+ const merged = new Map<string, ShapeProperty>()
896
+ for (const member of shape.members) {
897
+ const properties = this.#propertiesOf(member, depth + 1)
898
+ if (properties === undefined || properties === 'record') return properties
899
+ for (const property of properties) {
900
+ merged.delete(property.name)
901
+ merged.set(property.name, property)
902
+ }
903
+ }
904
+ return [...merged.values()]
905
+ }
906
+ default:
907
+ return undefined
908
+ }
909
+ }
910
+
911
+ /** `true` the first time `(namespace, shape)` is visited: named shapes by key, anonymous ones by identity. */
912
+ #visit(namespace: string, shape: Shape): boolean {
913
+ if (shape.kind === 'named') {
914
+ const key = `${namespace}\0${shape.key}`
915
+ if (this.#visited.has(key)) return false
916
+ this.#visited.add(key)
917
+ return true
918
+ }
919
+ let seen = this.#anonymous.get(shape)
920
+ if (seen === undefined) {
921
+ seen = new Set()
922
+ this.#anonymous.set(shape, seen)
923
+ }
924
+ if (seen.has(namespace)) return false
925
+ seen.add(namespace)
926
+ return true
927
+ }
928
+
929
+ #propertySite(
930
+ site: Site,
931
+ type: GraphQLObjectType | GraphQLAbstractType,
932
+ field: GraphQLField<unknown, unknown>,
933
+ property: ShapeProperty,
934
+ owner: Shape,
935
+ ): Site {
936
+ const name = this.#tsName(owner)
937
+ return {
938
+ label: `${name === undefined ? `property '${property.name}'` : `${name}.${property.name}`} is`,
939
+ origin: `type of ${name === undefined ? `property '${property.name}'` : `${name}.${property.name}`}, ${site.origin}`,
940
+ coordinate: `${type.name}.${field.name}`,
941
+ locate: () => annotationLocation(this.#ctx.project, property.location) ?? site.locate(),
942
+ sdl: this.#config.locate(field.astNode),
943
+ optional: property.optional,
944
+ }
945
+ }
946
+
947
+ #tsName(shape: Shape): string | undefined {
948
+ return shape.kind === 'named' ? this.#named.get(shape.key)?.name : undefined
949
+ }
950
+
951
+ // ---------------------------------------------------------------------------------- discriminators
952
+
953
+ /**
954
+ * `instanceof` when every TypeScript member returned for an abstract type is an exported class
955
+ * (one per member), else `__typename`; members that are neither are ZNIGHT064.
956
+ */
957
+ #discriminators(): ReadonlyMap<string, DiscriminatorModel> {
958
+ const result = new Map<string, DiscriminatorModel>()
959
+ for (const [name, byMember] of [...this.#members].sort(([a], [b]) => compareStrings(a, b))) {
960
+ // every exported class returned for a member: instanceof first, __typename as the fallback
961
+ const classes: { identity: string; name: string; depth: number }[] = []
962
+ for (const [member, identities] of byMember) {
963
+ for (const identity of identities) {
964
+ if (identity !== '') classes.push({ identity, name: member, depth: this.#classDepth(identity) })
965
+ }
966
+ }
967
+ if (classes.length > 0) {
968
+ // subclasses first: `instanceof` matches the most derived class
969
+ classes.sort(
970
+ (a, b) => b.depth - a.depth || compareStrings(a.name, b.name) || compareStrings(a.identity, b.identity),
971
+ )
972
+ result.set(name, {
973
+ by: 'class',
974
+ classes: classes.map(({ identity, name: member }) => ({ identity, name: member })),
975
+ })
976
+ }
977
+ for (const entry of this.#untellable.get(name) ?? []) {
978
+ const location = entry.site.locate()
979
+ this.#reportOnce(`untellable\0${name}\0${entry.name}`, {
980
+ code: Codes.UnresolvableAbstractType,
981
+ severity: 'error',
982
+ message: `${entry.name} cannot be told apart among the members of ${name} at runtime: it declares no __typename and is not an exported class`,
983
+ ...(location === undefined ? {} : { location }),
984
+ hints: [
985
+ `Return objects carrying __typename (declare __typename: '${entry.name}' in the TypeScript type; graphql-codegen declares it), or return exported class instances`,
986
+ ],
987
+ ...(entry.location === undefined
988
+ ? {}
989
+ : { related: [{ message: `${entry.name} is declared here`, location: entry.location }] }),
990
+ })
991
+ }
992
+ }
993
+ return result
994
+ }
995
+
996
+ #classDepth(identity: string): number {
997
+ let depth = 0
998
+ let current = this.#ctx.classInfo(identity)?.superClass?.identity
999
+ const seen = new Set<string>()
1000
+ while (current !== undefined && !seen.has(current) && depth < MAX_DEPTH) {
1001
+ seen.add(current)
1002
+ depth++
1003
+ current = this.#ctx.classInfo(current)?.superClass?.identity
1004
+ }
1005
+ return depth
1006
+ }
1007
+
1008
+ // ------------------------------------------------------------------------------------ reporting
1009
+
1010
+ #report(diagnostic: Diagnostic): void {
1011
+ this.#config.report(diagnostic)
1012
+ }
1013
+
1014
+ #reportOnce(key: string, diagnostic: Diagnostic): void {
1015
+ if (this.#reported.has(key)) return
1016
+ this.#reported.add(key)
1017
+ this.#report(diagnostic)
1018
+ }
1019
+
1020
+ #mismatch(site: Site, message: string, hints: readonly string[]): void {
1021
+ const location = site.locate()
1022
+ const key = `${location?.file}:${location?.start}\0${message}`
1023
+ this.#reportOnce(key, {
1024
+ code: Codes.SchemaMismatch,
1025
+ severity: 'error',
1026
+ message,
1027
+ ...(location === undefined ? {} : { location }),
1028
+ ...(hints.length === 0 ? {} : { hints }),
1029
+ ...(site.sdl === undefined
1030
+ ? {}
1031
+ : { related: [{ message: `${site.coordinate} is declared here`, location: site.sdl }] }),
1032
+ })
1033
+ }
1034
+
1035
+ // ----------------------------------------------------------------------------------- messages
1036
+
1037
+ #describeParts(parts: Parts, optional: boolean): string {
1038
+ const members = parts.members.map(member => this.#describe(member))
1039
+ if (parts.hasNull) members.push('null')
1040
+ if (parts.hasUndefined || optional) members.push('undefined')
1041
+ return members.join(' | ')
1042
+ }
1043
+
1044
+ /** Short description of a shape for messages (`string`, `Book`, `'a'`, `Book[]`). */
1045
+ #describe(shape: Shape): string {
1046
+ switch (shape.kind) {
1047
+ case 'literal':
1048
+ return JSON.stringify(shape.value)
1049
+ case 'named':
1050
+ return this.#named.get(shape.key)?.name ?? shape.key.slice(shape.key.indexOf('#') + 1)
1051
+ case 'reference':
1052
+ return shape.name
1053
+ case 'opaque':
1054
+ return shape.text
1055
+ case 'array':
1056
+ return `${this.#describe(shape.items)}[]`
1057
+ case 'union':
1058
+ case 'intersection':
1059
+ return shape.members.map(member => this.#describe(member)).join(shape.kind === 'union' ? ' | ' : ' & ')
1060
+ case 'object':
1061
+ return 'an object literal type'
1062
+ case 'tuple':
1063
+ return 'a tuple'
1064
+ default:
1065
+ return shape.kind
1066
+ }
1067
+ }
1068
+ }