@voxgig/apidef 6.5.1 → 7.0.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 (52) hide show
  1. package/dist/apidef.js +39 -2
  2. package/dist/apidef.js.map +1 -1
  3. package/dist/guide/graphql01.d.ts +39 -0
  4. package/dist/guide/graphql01.js +331 -0
  5. package/dist/guide/graphql01.js.map +1 -0
  6. package/dist/guide/guide.js +70 -10
  7. package/dist/guide/guide.js.map +1 -1
  8. package/dist/guide/heuristic01.js +1 -0
  9. package/dist/guide/heuristic01.js.map +1 -1
  10. package/dist/model.d.ts +22 -1
  11. package/dist/parse/graphql.d.ts +46 -0
  12. package/dist/parse/graphql.js +205 -0
  13. package/dist/parse/graphql.js.map +1 -0
  14. package/dist/parse.js +19 -0
  15. package/dist/parse.js.map +1 -1
  16. package/dist/transform/args.js +49 -4
  17. package/dist/transform/args.js.map +1 -1
  18. package/dist/transform/entity.js +51 -3
  19. package/dist/transform/entity.js.map +1 -1
  20. package/dist/transform/field.js +71 -1
  21. package/dist/transform/field.js.map +1 -1
  22. package/dist/transform/graphql.d.ts +6 -0
  23. package/dist/transform/graphql.js +227 -0
  24. package/dist/transform/graphql.js.map +1 -0
  25. package/dist/transform/select.js +17 -2
  26. package/dist/transform/select.js.map +1 -1
  27. package/dist/transform/top.js +31 -2
  28. package/dist/transform/top.js.map +1 -1
  29. package/dist/tsconfig.tsbuildinfo +1 -1
  30. package/dist/types.d.ts +16 -1
  31. package/dist/types.js.map +1 -1
  32. package/dist/utility.d.ts +2 -1
  33. package/dist/utility.js +52 -4
  34. package/dist/utility.js.map +1 -1
  35. package/model/apidef.aontu +23 -0
  36. package/model/guide.aontu +22 -0
  37. package/package.json +11 -4
  38. package/src/apidef.ts +44 -2
  39. package/src/guide/graphql01.ts +516 -0
  40. package/src/guide/guide.ts +90 -12
  41. package/src/guide/heuristic01.ts +1 -0
  42. package/src/model.ts +46 -1
  43. package/src/parse/graphql.ts +302 -0
  44. package/src/parse.ts +23 -0
  45. package/src/transform/args.ts +54 -5
  46. package/src/transform/entity.ts +63 -3
  47. package/src/transform/field.ts +89 -2
  48. package/src/transform/graphql.ts +311 -0
  49. package/src/transform/select.ts +19 -2
  50. package/src/transform/top.ts +31 -2
  51. package/src/types.ts +40 -0
  52. package/src/utility.ts +54 -4
@@ -0,0 +1,516 @@
1
+ /* Copyright (c) 2024-2026 Voxgig, MIT License */
2
+
3
+ // GraphQL guide strategy: classify schema root fields into entities and
4
+ // operations, the way heuristic01 classifies REST paths.
5
+ //
6
+ // Classification is SHAPE FIRST, NAME SECOND. Verb spellings diverge wildly
7
+ // between GraphQL ecosystems (Hasura `insert_x_one`, Amplify `createTodo`,
8
+ // Linear `issueCreate`, PostGraphile `createUser` — and PostGraphile's
9
+ // inflector plugin can change them wholesale), but the type shapes do not:
10
+ // a query returning the entity type behind a single required id argument is
11
+ // a load in every one of them.
12
+ //
13
+ // Anything on Mutation that touches an entity but matches no CRUD shape
14
+ // becomes an ACTION on a canonical op, reaching the SDK as an
15
+ // `$action`-discriminated point — the same mechanism REST action paths
16
+ // (/planet/{id}/terraform) already use. Nothing is ever dropped silently.
17
+ //
18
+ // `classifyGraphQLField` is a pure function of plain JSON, so it is driven
19
+ // by a shared TSV fixture and can be ported to Go unchanged.
20
+
21
+ import { each } from 'jostraca'
22
+
23
+ import type {
24
+ ApiDefContext,
25
+ Guide,
26
+ GuideEntity,
27
+ GuidePath,
28
+ } from '../types'
29
+
30
+ import type { GqlDef, GqlField, GqlType } from '../parse/graphql'
31
+
32
+ import { canonize, depluralize, normalizeFieldName } from '../utility'
33
+
34
+
35
+ // Op the classifier can assign. 'update' hosts id-bearing actions,
36
+ // 'create' hosts id-less ones.
37
+ type GqlOpName = 'load' | 'list' | 'create' | 'update' | 'remove'
38
+
39
+
40
+ // Shape of a root field's return type, derived before classification so the
41
+ // classifier itself stays free of schema-object access.
42
+ type GqlRetShape = {
43
+ kind: 'entity' | 'connection' | 'list' | 'payload' | 'scalar' | 'other'
44
+ entity?: string
45
+ // Connection only: the field holding the node array ('nodes' | 'edges').
46
+ nodes?: string
47
+ // Payload only: the field the entity is wrapped in, if any.
48
+ unwrap?: string
49
+ // Payload only: a delete-ish payload carries no entity.
50
+ deleteish?: boolean
51
+ }
52
+
53
+
54
+ type GqlArgSig = {
55
+ name: string
56
+ gqltype: string
57
+ reqd: boolean
58
+ }
59
+
60
+
61
+ // Everything the classifier needs about one root field.
62
+ type GqlFieldSig = {
63
+ optype: 'query' | 'mutation'
64
+ name: string
65
+ args: GqlArgSig[]
66
+ ret: GqlRetShape
67
+ inputTypeName?: string
68
+ // Entity named by the field itself, used when the return type carries
69
+ // none (see nameEntityType).
70
+ nameEntity?: string
71
+ }
72
+
73
+
74
+ type GqlClassification = {
75
+ exclude?: boolean
76
+ entity?: string
77
+ op?: GqlOpName
78
+ action?: string
79
+ optype?: 'query' | 'mutation'
80
+ why: string[]
81
+ }
82
+
83
+
84
+ // Naming profile. Profiles only supply name patterns layered ON TOP of the
85
+ // shape rules; 'none' is the conservative default that relies on shape alone
86
+ // and leans on the guide file for anything ambiguous.
87
+ type GqlProfile = 'linear' | 'relay' | 'none'
88
+
89
+
90
+ const CREATE_RE = /^(create|insert|add|new)$/i
91
+ const UPDATE_RE = /^(update|edit|modify|patch|set)$/i
92
+ const REMOVE_RE = /^(delete|remove|destroy|drop)$/i
93
+
94
+ // Type-name suffixes that mark schema machinery rather than API entities.
95
+ const MACHINERY_RE =
96
+ /(Connection|Edge|PageInfo|Payload|Input|Filter|Comparator|Sort|OrderBy)$/
97
+
98
+
99
+ // Strip a leading entity name off a mutation field name, returning the
100
+ // residual verb. `issueCreate` -> `Create`; `createIssue` -> `create`.
101
+ function splitEntityVerb(fieldName: string, entity: string): string {
102
+ const lowerField = fieldName.toLowerCase()
103
+ const lowerEnt = entity.toLowerCase()
104
+
105
+ if (lowerField.startsWith(lowerEnt)) {
106
+ return fieldName.slice(entity.length)
107
+ }
108
+ if (lowerField.endsWith(lowerEnt)) {
109
+ return fieldName.slice(0, fieldName.length - entity.length)
110
+ }
111
+ return fieldName
112
+ }
113
+
114
+
115
+ // Does this field take a required id-ish argument?
116
+ //
117
+ // Any required id counts, not just a lone one: a command like
118
+ // `planetForbid(id: String!, forbid: Boolean!)` addresses an existing record
119
+ // just as much as `planetArchive(id: String!)` does. Requiring it to be the
120
+ // only required argument made an operation stop looking id-addressed the
121
+ // moment the API made a second argument mandatory, which flipped it from an
122
+ // update action to a create.
123
+ function idArg(args: GqlArgSig[]): GqlArgSig | undefined {
124
+ return args.find(
125
+ (a: GqlArgSig) => a.reqd && /^(id|.*Id)$/i.test(a.name))
126
+ }
127
+
128
+
129
+ // Classify one root field. PURE: plain-JSON in, plain-JSON out, no schema
130
+ // objects, no closures, no I/O — so a shared TSV fixture can drive this in
131
+ // both TypeScript and Go.
132
+ function classifyGraphQLField(
133
+ sig: GqlFieldSig,
134
+ profile: GqlProfile
135
+ ): GqlClassification {
136
+ const why: string[] = []
137
+ const ret = sig.ret
138
+
139
+ if ('scalar' === ret.kind || 'other' === ret.kind) {
140
+ why.push('ret:' + ret.kind)
141
+ return { exclude: true, why }
142
+ }
143
+
144
+ // A payload that carries no entity (Linear's DeletePayload is just
145
+ // `entityId`) still belongs to the entity its field NAMES. Without this,
146
+ // every such delete mutation is dropped and the entity silently loses its
147
+ // remove op. Confined to payload returns: a query returning a scalar must
148
+ // not be rescued by its name.
149
+ const entity = ret.entity ??
150
+ ('payload' === ret.kind ? sig.nameEntity : undefined)
151
+
152
+ if (null == entity) {
153
+ why.push('ret:' + ret.kind + ':no-entity')
154
+ return { exclude: true, why }
155
+ }
156
+
157
+ if (null == ret.entity) {
158
+ why.push('entity-by-name:' + entity)
159
+ }
160
+ const id = idArg(sig.args)
161
+
162
+ if ('query' === sig.optype) {
163
+ // Connection or list of the entity -> list.
164
+ if ('connection' === ret.kind || 'list' === ret.kind) {
165
+ why.push('query:' + ret.kind)
166
+ return { entity, op: 'list', optype: 'query', why }
167
+ }
168
+
169
+ // Single entity behind one required id -> load.
170
+ if ('entity' === ret.kind) {
171
+ if (null != id) {
172
+ why.push('query:entity:id=' + id.name)
173
+ return { entity, op: 'load', optype: 'query', why }
174
+ }
175
+
176
+ // No id argument: a singleton accessor (viewer, organization). Still a
177
+ // load — the op simply takes no id.
178
+ if (0 === sig.args.filter((a: GqlArgSig) => a.reqd).length) {
179
+ why.push('query:entity:singleton')
180
+ return { entity, op: 'load', optype: 'query', why }
181
+ }
182
+
183
+ why.push('query:entity:args')
184
+ return { entity, op: 'load', optype: 'query', why }
185
+ }
186
+
187
+ why.push('query:unmatched:' + ret.kind)
188
+ return { exclude: true, why }
189
+ }
190
+
191
+ // --- mutation ---
192
+ const verb = splitEntityVerb(sig.name, entity).replace(/^[_-]+/, '')
193
+ const input = sig.inputTypeName ?? ''
194
+
195
+ // create: <Entity>CreateInput, or a create-ish verb, and no id argument.
196
+ const createByInput = new RegExp('^' + entity + '(Create|Insert|New)Input$', 'i').test(input)
197
+ if (createByInput || (CREATE_RE.test(verb) && null == id)) {
198
+ why.push(createByInput ? 'mutation:input:' + input : 'mutation:verb:' + verb)
199
+ return { entity, op: 'create', optype: 'mutation', why }
200
+ }
201
+
202
+ // update: <Entity>UpdateInput, or an update-ish verb.
203
+ const updateByInput = new RegExp('^' + entity + '(Update|Edit|Patch|Set)Input$', 'i').test(input)
204
+ if (updateByInput || UPDATE_RE.test(verb)) {
205
+ why.push(updateByInput ? 'mutation:input:' + input : 'mutation:verb:' + verb)
206
+ return { entity, op: 'update', optype: 'mutation', why }
207
+ }
208
+
209
+ // remove: a delete-ish verb.
210
+ if (REMOVE_RE.test(verb)) {
211
+ why.push('mutation:verb:' + verb)
212
+ return { entity, op: 'remove', optype: 'mutation', why }
213
+ }
214
+
215
+ // Everything else on Mutation is a command: fold it onto a canonical op as
216
+ // an action, exactly as REST action paths are folded. Id-bearing commands
217
+ // ride update (they address an existing record); id-less ones ride create.
218
+ const action = canonize(normalizeFieldName(verb || sig.name))
219
+ const host: GqlOpName = null != id ? 'update' : 'create'
220
+ why.push('mutation:action:' + action + ':host=' + host +
221
+ (profile === 'none' ? '' : ':profile=' + profile))
222
+
223
+ return { entity, op: host, action, optype: 'mutation', why }
224
+ }
225
+
226
+
227
+ // Derive the return shape of a root field from the normalised type map.
228
+ // Separated from classification so the classifier stays plain-JSON pure.
229
+ function deriveRetShape(
230
+ field: GqlField,
231
+ types: Record<string, GqlType>
232
+ ): GqlRetShape {
233
+ const named = types[field.type]
234
+
235
+ if (null == named) {
236
+ return { kind: 'scalar' }
237
+ }
238
+
239
+ if ('SCALAR' === named.kind || 'ENUM' === named.kind) {
240
+ return { kind: 'scalar' }
241
+ }
242
+
243
+ if ('OBJECT' !== named.kind && 'INTERFACE' !== named.kind) {
244
+ return { kind: 'other' }
245
+ }
246
+
247
+ // Relay connection: has pageInfo plus nodes and/or edges.
248
+ const fnames = Object.keys(named.fields)
249
+ if (fnames.includes('pageInfo') &&
250
+ (fnames.includes('nodes') || fnames.includes('edges'))) {
251
+
252
+ if (fnames.includes('nodes')) {
253
+ return {
254
+ kind: 'connection',
255
+ entity: named.fields.nodes.type,
256
+ nodes: 'nodes',
257
+ }
258
+ }
259
+
260
+ // Edges-only connection: the entity is the EDGE'S NODE type, not the
261
+ // edge wrapper. Taking IssueEdge here would spread a fragment declared
262
+ // on IssueEdge inside `edges { node { ... } }` (a validation error) and
263
+ // unwrap the response to edge wrappers instead of entities.
264
+ const edgeType = types[named.fields.edges.type]
265
+ const nodeType = edgeType?.fields?.node?.type
266
+
267
+ if (null != nodeType) {
268
+ return { kind: 'connection', entity: nodeType, nodes: 'edges' }
269
+ }
270
+ }
271
+
272
+ // Mutation payload wrapper: <X>Payload holding the entity (plus success /
273
+ // lastSyncId style metadata).
274
+ if (/Payload$/.test(named.name)) {
275
+ // Candidates: single object-typed fields that are not machinery. LISTS
276
+ // are excluded — the widespread `errors: [UserError!]!` convention would
277
+ // otherwise win on sort order and make the payload's error collection the
278
+ // "entity", unwrapping errors instead of the record.
279
+ const candidates = fnames.filter((fname: string) => {
280
+ const f = named.fields[fname]
281
+ const ftype = types[f.type]
282
+ return null != ftype && 'OBJECT' === ftype.kind &&
283
+ !f.list && !MACHINERY_RE.test(ftype.name) &&
284
+ !/^(errors?|userErrors?)$/i.test(fname)
285
+ })
286
+
287
+ // Prefer the field whose name matches the payload's own entity prefix
288
+ // (IssuePayload -> issue), which is the convention every CRUD-regular
289
+ // GraphQL API follows; fall back to the single remaining candidate.
290
+ const prefix = named.name.replace(/Payload$/, '')
291
+ const byName = candidates.find((fname: string) =>
292
+ fname.toLowerCase() === prefix.toLowerCase() ||
293
+ types[named.fields[fname].type]?.name === prefix)
294
+
295
+ const chosen = byName ?? candidates[0]
296
+
297
+ if (null != chosen) {
298
+ return {
299
+ kind: 'payload',
300
+ entity: types[named.fields[chosen].type].name,
301
+ unwrap: chosen,
302
+ }
303
+ }
304
+
305
+ return { kind: 'payload', deleteish: true }
306
+ }
307
+
308
+ if (MACHINERY_RE.test(named.name)) {
309
+ return { kind: 'other' }
310
+ }
311
+
312
+ // A list of the entity is a list op even without connection machinery.
313
+ if (field.list) {
314
+ return { kind: 'list', entity: named.name }
315
+ }
316
+
317
+ return { kind: 'entity', entity: named.name }
318
+ }
319
+
320
+
321
+ // Build the classifier signature for a root field.
322
+ function fieldSig(
323
+ optype: 'query' | 'mutation',
324
+ field: GqlField,
325
+ types: Record<string, GqlType>
326
+ ): GqlFieldSig {
327
+ // The input-object argument, if any, drives create/update detection.
328
+ let inputTypeName: string | undefined = undefined
329
+ for (const arg of field.args) {
330
+ const at = types[arg.type]
331
+ if (null != at && 'INPUT_OBJECT' === at.kind) {
332
+ inputTypeName = at.name
333
+ break
334
+ }
335
+ }
336
+
337
+ return {
338
+ optype,
339
+ name: field.name,
340
+ args: field.args.map((a) => ({
341
+ name: a.name, gqltype: a.gqltype, reqd: a.reqd,
342
+ })),
343
+ ret: deriveRetShape(field, types),
344
+ inputTypeName,
345
+ nameEntity: nameEntityType(field.name, types),
346
+ }
347
+ }
348
+
349
+
350
+ // Verb suffixes a mutation field name may carry, longest first so
351
+ // `issueUnarchive` strips `Unarchive` rather than `Archive`.
352
+ const NAME_VERBS = [
353
+ 'Unarchive', 'Archive', 'Delete', 'Remove', 'Destroy',
354
+ 'Create', 'Update', 'Insert', 'Upsert',
355
+ ]
356
+
357
+
358
+ // Resolve the entity a mutation NAMES, for payloads that do not carry one.
359
+ // Linear's DeletePayload holds `entityId: String!` and nothing else, so
360
+ // `commentDelete` has no entity in its return type — the field name is the
361
+ // only signal, and by convention it is a reliable one.
362
+ function nameEntityType(
363
+ fieldName: string,
364
+ types: Record<string, GqlType>
365
+ ): string | undefined {
366
+ for (const verb of NAME_VERBS) {
367
+ if (!fieldName.endsWith(verb)) {
368
+ continue
369
+ }
370
+
371
+ const stem = fieldName.slice(0, fieldName.length - verb.length)
372
+ if ('' === stem) {
373
+ continue
374
+ }
375
+
376
+ const candidate = stem.charAt(0).toUpperCase() + stem.slice(1)
377
+ const gtype = types[candidate]
378
+
379
+ if (null != gtype && 'OBJECT' === gtype.kind) {
380
+ return candidate
381
+ }
382
+ }
383
+
384
+ return undefined
385
+ }
386
+
387
+
388
+ // Entity model name from a GraphQL type name: Issue -> issue,
389
+ // WorkflowState -> workflow_state (canonize handles the casing rules that
390
+ // the REST path classifier already uses).
391
+ function entityName(typeName: string): string {
392
+ return depluralize(canonize(normalizeFieldName(typeName)))
393
+ }
394
+
395
+
396
+ function newGuidePath(): GuidePath {
397
+ return {
398
+ why_path: [],
399
+ action: {},
400
+ rename: { param: {} },
401
+ op: {},
402
+ }
403
+ }
404
+
405
+
406
+ // The GraphQL guide strategy.
407
+ async function graphql01(ctx: ApiDefContext): Promise<Guide> {
408
+ const def: GqlDef = ctx.def
409
+ const profile: GqlProfile = (ctx.opts.profile ?? 'none') as GqlProfile
410
+
411
+ const guide: Guide = {
412
+ control: {},
413
+ entity: {},
414
+ metrics: {
415
+ count: {
416
+ path: 0,
417
+ field: 0,
418
+ method: 0,
419
+ tag: 0,
420
+ cmp: 0,
421
+ entity: 0,
422
+ origcmprefs: {},
423
+ },
424
+ found: {
425
+ tag: {},
426
+ cmp: {},
427
+ },
428
+ },
429
+ }
430
+
431
+ const types = def.types ?? {}
432
+
433
+ const roots: { optype: 'query' | 'mutation', fields: Record<string, GqlField> }[] = [
434
+ { optype: 'query', fields: def.query ?? {} },
435
+ { optype: 'mutation', fields: def.mutation ?? {} },
436
+ ]
437
+
438
+ for (const root of roots) {
439
+ // Sorted iteration: byte-stable guide output.
440
+ for (const fname of Object.keys(root.fields).sort()) {
441
+ guide.metrics.count.field++
442
+
443
+ const field = root.fields[fname]
444
+ const sig = fieldSig(root.optype, field, types)
445
+ const cls = classifyGraphQLField(sig, profile)
446
+
447
+ if (cls.exclude || null == cls.entity || null == cls.op) {
448
+ ctx.log.debug({
449
+ point: 'graphql-exclude',
450
+ field: fname,
451
+ note: cls.why.join(';'),
452
+ })
453
+ continue
454
+ }
455
+
456
+ const entname = entityName(cls.entity)
457
+
458
+ const gent: GuideEntity = guide.entity[entname] ?? {
459
+ name: entname,
460
+ orig: cls.entity,
461
+ field: {},
462
+ path: {},
463
+ }
464
+ guide.entity[entname] = gent
465
+
466
+ const gfields = (gent.field = gent.field ?? {})
467
+ const gpath: GuidePath = gfields[fname] ?? newGuidePath()
468
+ gfields[fname] = gpath
469
+
470
+ gpath.why_path.push(cls.why.join(';'))
471
+
472
+ if (null != cls.action) {
473
+ gpath.action[cls.action] = {
474
+ kind: 'graphql',
475
+ why_action: ['mutation:' + fname],
476
+ }
477
+ }
478
+
479
+ gpath.op[cls.op] = {
480
+ // GraphQL points synthesize POST; optype carries the real distinction.
481
+ method: 'POST',
482
+ optype: cls.optype,
483
+ why_op: cls.why.join(';') as any,
484
+ transform: { req: undefined, res: undefined },
485
+ }
486
+ }
487
+ }
488
+
489
+ guide.metrics.count.entity = Object.keys(guide.entity).length
490
+
491
+ ctx.log.info({
492
+ point: 'graphql-guide',
493
+ note: `entities=${guide.metrics.count.entity} ` +
494
+ `fields=${guide.metrics.count.field}`,
495
+ })
496
+
497
+ return guide
498
+ }
499
+
500
+
501
+ export {
502
+ graphql01,
503
+ classifyGraphQLField,
504
+ deriveRetShape,
505
+ fieldSig,
506
+ entityName,
507
+ }
508
+
509
+ export type {
510
+ GqlFieldSig,
511
+ GqlArgSig,
512
+ GqlRetShape,
513
+ GqlClassification,
514
+ GqlProfile,
515
+ GqlOpName,
516
+ }
@@ -10,6 +10,7 @@ import { items, isempty } from '@voxgig/struct'
10
10
 
11
11
 
12
12
  import { heuristic01 } from './heuristic01'
13
+ import { graphql01 } from './graphql01'
13
14
 
14
15
 
15
16
  import {
@@ -165,6 +166,9 @@ async function buildBaseGuide(ctx: ApiDefContext) {
165
166
  if ('heuristic01' === ctx.opts.strategy) {
166
167
  baseguide = await heuristic01(ctx)
167
168
  }
169
+ else if ('graphql01' === ctx.opts.strategy) {
170
+ baseguide = await graphql01(ctx)
171
+ }
168
172
  else {
169
173
  throw new Error('Unknown guide strategy: ' + ctx.opts.strategy)
170
174
  }
@@ -201,22 +205,32 @@ async function buildBaseGuide(ctx: ApiDefContext) {
201
205
  metrics: count: path: ${metrics.count.path}
202
206
  metrics: count: method: ${metrics.count.method}`)
203
207
 
208
+ // Root-field count is GraphQL-only; omit it for REST guides so their
209
+ // emitted base-guide files stay byte-identical.
210
+ if (0 < (metrics.count.field ?? 0)) {
211
+ guideBlocks.push(` metrics: count: field: ${metrics.count.field}`)
212
+ }
213
+
204
214
  // NOTE: items(...) sorts the iteration elements, so the generated model code
205
215
  // is deterministic.
206
216
 
207
- items(baseguide.entity).map(([entname, entity]: [string, GuideEntity]) => {
208
-
209
- guideBlocks.push(`
210
- entity: ${entname}: {`
211
- // sw(0 < entity.why_name.length ? ' # name:' + entity.why_name.join(';') : '')
212
- )
213
-
214
- // NOTE: items(...) sorts the paths
215
- items(entity.path).map(([pathstr, path]: [string, GuidePath]) => {
216
- debugpath(pathstr, null, 'BASE-GUIDE', entname, pathstr,
217
+ // Emit one guide entry. REST guides key entries by path, GraphQL guides by
218
+ // schema root field (`branch`); the body is otherwise identical, so both
219
+ // share this emitter. GraphQL ops carry `optype` ALONGSIDE `method: POST`,
220
+ // which keeps every downstream transform that reads gop.method working
221
+ // unchanged while recording the query/mutation distinction.
222
+ const emitEntry = (
223
+ branch: 'path' | 'field',
224
+ entname: string,
225
+ entity: GuideEntity,
226
+ entrykey: string,
227
+ path: GuidePath
228
+ ) => {
229
+ {
230
+ debugpath(entrykey, null, 'BASE-GUIDE', entname, entrykey,
217
231
  formatJSONIC(path, { hsepd: 0, $: true, color: true }))
218
232
 
219
- guideBlocks.push(` path: ${qs(pathstr)}: {` +
233
+ guideBlocks.push(` ${branch}: ${qs(entrykey)}: {` +
220
234
  sw(0 < path.why_path.length ?
221
235
  ' # ent=' + entname + ';' +
222
236
  (entity.orig !== entname && null != entity.orig ? 'orig=' + entity.orig + ';' : '') +
@@ -241,6 +255,9 @@ async function buildBaseGuide(ctx: ApiDefContext) {
241
255
  items(path.op).map(([opname, op]: [string, GuidePathOp]) => {
242
256
  guideBlocks.push(` op: ${opname}: method: *${op.method}` +
243
257
  sw(0 < op.why_op.length ? ' # ' + op.why_op : ''))
258
+ if (null != op.optype) {
259
+ guideBlocks.push(` op: ${opname}: optype: *${op.optype}`)
260
+ }
244
261
  // Each transform is emitted only when set, and each on its own terms.
245
262
  // (An earlier req-GUARDED block pushed a second res line built from
246
263
  // op.transform.res — emitting `transform: res: *undefined` whenever a
@@ -268,7 +285,20 @@ async function buildBaseGuide(ctx: ApiDefContext) {
268
285
  })
269
286
 
270
287
  guideBlocks.push(` }`)
271
- })
288
+ }
289
+ }
290
+
291
+ items(baseguide.entity).map(([entname, entity]: [string, GuideEntity]) => {
292
+
293
+ guideBlocks.push(`
294
+ entity: ${entname}: {`)
295
+
296
+ // NOTE: items(...) sorts the entries, so output is deterministic.
297
+ items(entity.path).map(([pathstr, path]: [string, GuidePath]) =>
298
+ emitEntry('path', entname, entity, pathstr, path))
299
+
300
+ items((entity as any).field).map(([fieldstr, path]: [string, GuidePath]) =>
301
+ emitEntry('field', entname, entity, fieldstr, path))
272
302
 
273
303
  guideBlocks.push(` }`)
274
304
  })
@@ -304,7 +334,55 @@ async function buildBaseGuide(ctx: ApiDefContext) {
304
334
 
305
335
 
306
336
 
337
+ // GraphQL coverage guard: every Query/Mutation root field must either be
338
+ // assigned to an entity op or be deliberately excluded by the classifier
339
+ // (machinery types, scalar returns). Mirrors the REST PATH MISMATCH check —
340
+ // silence about an unclassified field is how an API silently loses surface.
341
+ function validateGraphqlBaseGuide(ctx: ApiDefContext, baseguide: any) {
342
+ const covered: Record<string, boolean> = {}
343
+
344
+ each(baseguide.entity, (entm: GuideEntity) => {
345
+ each((entm as any).field, (fieldm: GuidePath, fieldStr: string) => {
346
+ if (!isempty(fieldm.op)) {
347
+ covered[fieldStr] = true
348
+ }
349
+ })
350
+ })
351
+
352
+ const uncovered: string[] = []
353
+ for (const roots of [ctx.def?.query, ctx.def?.mutation]) {
354
+ for (const fname of Object.keys(roots ?? {}).sort()) {
355
+ if (!covered[fname]) {
356
+ uncovered.push(fname)
357
+ }
358
+ }
359
+ }
360
+
361
+ // Unclassified root fields are expected (scalars like `version`, machinery
362
+ // returns), so this is a warning rather than a hard failure — but it is
363
+ // always reported, so a missed entity is visible.
364
+ if (0 < uncovered.length) {
365
+ ctx.warn({
366
+ note: `GraphQL root fields not mapped to an entity op: ` +
367
+ uncovered.join(', '),
368
+ uncovered,
369
+ })
370
+ }
371
+
372
+ ctx.log.info({
373
+ point: 'graphql-coverage',
374
+ note: `mapped=${Object.keys(covered).length} unmapped=${uncovered.length}`,
375
+ })
376
+ }
377
+
378
+
307
379
  function validateBaseBuide(ctx: ApiDefContext, baseguide: any) {
380
+ // GraphQL guides key entries by root field, not path: the path-based
381
+ // reconciliation below has nothing to compare.
382
+ if (true === ctx.def?.graphql) {
383
+ return validateGraphqlBaseGuide(ctx, baseguide)
384
+ }
385
+
308
386
  const srcm: any = {}
309
387
 
310
388
  // Each orig path.
@@ -188,6 +188,7 @@ function Prepare(spec: TaskSpec) {
188
188
  metrics: {
189
189
  count: {
190
190
  path: 0,
191
+ field: 0,
191
192
  method: 0,
192
193
  tag: 0,
193
194
  cmp: 0,