@voxgig/apidef 8.11.0 → 8.12.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 (79) hide show
  1. package/dist/apidef.d.ts +4 -1
  2. package/dist/apidef.js +12 -34
  3. package/dist/apidef.js.map +1 -1
  4. package/dist/builder/entity/entity.js +0 -33
  5. package/dist/builder/entity/entity.js.map +1 -1
  6. package/dist/builder/entity/info.js +0 -4
  7. package/dist/builder/entity/info.js.map +1 -1
  8. package/dist/builder/flow.d.ts +4 -1
  9. package/dist/builder/flow.js +36 -27
  10. package/dist/builder/flow.js.map +1 -1
  11. package/dist/guide/graphql01.js +0 -55
  12. package/dist/guide/graphql01.js.map +1 -1
  13. package/dist/guide/guide.js +3 -126
  14. package/dist/guide/guide.js.map +1 -1
  15. package/dist/guide/heuristic01.js +40 -181
  16. package/dist/guide/heuristic01.js.map +1 -1
  17. package/dist/parse/graphql.js +0 -25
  18. package/dist/parse/graphql.js.map +1 -1
  19. package/dist/parse.js +51 -56
  20. package/dist/parse.js.map +1 -1
  21. package/dist/resolved.d.ts +23 -0
  22. package/dist/resolved.js +92 -0
  23. package/dist/resolved.js.map +1 -0
  24. package/dist/resolver.js +0 -2
  25. package/dist/resolver.js.map +1 -1
  26. package/dist/transform/args.js +2 -25
  27. package/dist/transform/args.js.map +1 -1
  28. package/dist/transform/casecollide.d.ts +3 -0
  29. package/dist/transform/casecollide.js +67 -0
  30. package/dist/transform/casecollide.js.map +1 -0
  31. package/dist/transform/contract.js +43 -38
  32. package/dist/transform/contract.js.map +1 -1
  33. package/dist/transform/entity.js +0 -56
  34. package/dist/transform/entity.js.map +1 -1
  35. package/dist/transform/field.js +1 -368
  36. package/dist/transform/field.js.map +1 -1
  37. package/dist/transform/flowstep.js +0 -49
  38. package/dist/transform/flowstep.js.map +1 -1
  39. package/dist/transform/graphql.js +0 -36
  40. package/dist/transform/graphql.js.map +1 -1
  41. package/dist/transform/operation.js +0 -23
  42. package/dist/transform/operation.js.map +1 -1
  43. package/dist/transform/top.js +1 -128
  44. package/dist/transform/top.js.map +1 -1
  45. package/dist/transform.js.map +1 -1
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/dist/types.d.ts +2 -0
  48. package/dist/types.js.map +1 -1
  49. package/dist/utility.d.ts +2 -1
  50. package/dist/utility.js +17 -316
  51. package/dist/utility.js.map +1 -1
  52. package/model/apidef.aon +20 -113
  53. package/model/guide.aon +1 -32
  54. package/package.json +5 -5
  55. package/src/apidef.ts +22 -33
  56. package/src/builder/entity/entity.ts +0 -33
  57. package/src/builder/entity/info.ts +0 -4
  58. package/src/builder/flow.ts +39 -24
  59. package/src/desc.ts +0 -22
  60. package/src/guide/graphql01.ts +0 -75
  61. package/src/guide/guide.ts +5 -127
  62. package/src/guide/heuristic01.ts +47 -182
  63. package/src/model.ts +6 -85
  64. package/src/parse/graphql.ts +0 -25
  65. package/src/parse.ts +61 -56
  66. package/src/resolved.ts +136 -0
  67. package/src/resolver.ts +0 -2
  68. package/src/transform/args.ts +2 -25
  69. package/src/transform/casecollide.ts +80 -0
  70. package/src/transform/contract.ts +44 -34
  71. package/src/transform/entity.ts +0 -56
  72. package/src/transform/field.ts +2 -368
  73. package/src/transform/flowstep.ts +0 -49
  74. package/src/transform/graphql.ts +0 -36
  75. package/src/transform/operation.ts +0 -23
  76. package/src/transform/top.ts +1 -131
  77. package/src/transform.ts +0 -1
  78. package/src/types.ts +4 -13
  79. package/src/utility.ts +18 -314
package/src/apidef.ts CHANGED
@@ -75,10 +75,17 @@ import { graphqlTransform } from './transform/graphql'
75
75
  import { argsTransform } from './transform/args'
76
76
  import { selectTransform } from './transform/select'
77
77
  import { fieldTransform } from './transform/field'
78
+ import { casecollideTransform } from './transform/casecollide'
78
79
  import { flowTransform } from './transform/flow'
79
80
  import { flowstepTransform } from './transform/flowstep'
80
81
  import { cleanTransform } from './transform/clean'
81
82
 
83
+ import {
84
+ publishResolved, operationFacts, operationIndex, resolvedSpec,
85
+ } from './resolved'
86
+
87
+ import type { OperationFacts, ResolvedSpec } from './resolved'
88
+
82
89
  import { makeEntityBuilder } from './builder/entity'
83
90
  import { gcEntityFiles } from './builder/entity/entity'
84
91
  import { makeFlowBuilder } from './builder/flow'
@@ -87,18 +94,6 @@ import { makeFlowBuilder } from './builder/flow'
87
94
  const dlog = getdlog('apidef', __filename)
88
95
 
89
96
 
90
- // THE WARNINGS FILE IS A REVIEWABLE ARTIFACT, so it carries no clock.
91
- //
92
- // Every warning is stamped with `when: Date.now()` — useful in a live log,
93
- // and fatal in a file that consumers COMMIT. An SDK project regenerates and
94
- // commits `.sdk/apidef-warnings.txt`, and its CI asserts that a regeneration
95
- // reproduces the committed tree byte for byte; with a timestamp in it that
96
- // check can never pass, and every regeneration produces a diff saying
97
- // nothing about the warnings themselves. github-sdk failed exactly that way:
98
- // one file, three changed lines, all of them clocks.
99
- //
100
- // The timestamp stays on the in-memory history, where a caller streaming
101
- // warnings still wants it.
102
97
  function warningsFileText(history: any[]): string {
103
98
  return history
104
99
  .map((n: any) => {
@@ -111,7 +106,6 @@ function warningsFileText(history: any[]): string {
111
106
 
112
107
  function ApiDef(opts: ApiDefOptions) {
113
108
 
114
- // TODO: shape opts!
115
109
  const fs = opts.fs || Fs
116
110
  const pino = prettyPino('apidef', opts)
117
111
  const log = pino.child({ cmp: 'apidef' })
@@ -173,7 +167,6 @@ function ApiDef(opts: ApiDefOptions) {
173
167
  start
174
168
  })
175
169
 
176
- // TODO: Validate spec
177
170
  ctx = {
178
171
  fs,
179
172
  fsInjected: null != opts.fs,
@@ -189,7 +182,6 @@ function ApiDef(opts: ApiDefOptions) {
189
182
  note: {},
190
183
  warn,
191
184
 
192
- // TODO: remove (moved to guide)
193
185
  metrics: {
194
186
  count: {
195
187
  path: 0,
@@ -235,6 +227,7 @@ function ApiDef(opts: ApiDefOptions) {
235
227
  }
236
228
 
237
229
  ctx.def = def
230
+ ctx.resolved = publishResolved(spec.buildctx, spec.config?.kind ?? "openapi3", def)
238
231
 
239
232
  steps.push('parse')
240
233
 
@@ -276,6 +269,8 @@ function ApiDef(opts: ApiDefOptions) {
276
269
  await argsTransform(ctx)
277
270
  await selectTransform(ctx)
278
271
  await fieldTransform(ctx)
272
+ // Before flowTransform, so no flow is built for an entity it drops.
273
+ await casecollideTransform(ctx)
279
274
  await flowTransform(ctx)
280
275
  await flowstepTransform(ctx)
281
276
  await cleanTransform(ctx)
@@ -293,7 +288,6 @@ function ApiDef(opts: ApiDefOptions) {
293
288
  const builders = [
294
289
  await makeEntityBuilder(ctx),
295
290
 
296
- // TODO: move to sdkgen
297
291
  await makeFlowBuilder(ctx),
298
292
  ]
299
293
 
@@ -326,11 +320,6 @@ function ApiDef(opts: ApiDefOptions) {
326
320
  // folder: Path.dirname(opts.folder as string),
327
321
  folder: opts.folder,
328
322
  model: jmodel,
329
- // Overwrite the generated model source (.aontu) rather than 3-way merge:
330
- // merging against a drifting .jostraca base silently keeps stale files
331
- // and can inject <<<<<<< conflict markers. Generated output is
332
- // model-derived and never hand-edited. See sdkgen
333
- // docs/explanation/regeneration-overwrite.md.
334
323
  existing: { txt: { write: true, merge: false } }
335
324
  }, root)
336
325
 
@@ -343,11 +332,6 @@ function ApiDef(opts: ApiDefOptions) {
343
332
 
344
333
  steps.push('generate')
345
334
 
346
- // Garbage-collect entity model files no longer derived from the def.
347
- // The builders only ever WRITE: a spec change that removes or renames a
348
- // derived entity used to leave the old <name>.aontu behind forever.
349
- // Runs after generate so the current set is on disk; guarded so only
350
- // apidef-generated files under this build's outprefix are touched.
351
335
  try {
352
336
  const kitEntity = (ctx.apimodel?.main as any)?.[KIT]?.entity || {}
353
337
  gcEntityFiles(fs, log, opts.folder as string, opts.outprefix,
@@ -368,12 +352,6 @@ function ApiDef(opts: ApiDefOptions) {
368
352
  warningsFileText(warn.history))
369
353
  }
370
354
 
371
- // apidef writes model source files (entity, flow, guide aontu files) into
372
- // .sdk/model/. Downstream actions (sdkgen, etc.) read those via
373
- // sdk.aontu @-includes, so voxgig-model has to re-resolve the model
374
- // before the post-step actions run. Signal reload whenever jostraca
375
- // wrote or merged any files; if nothing changed on disk,
376
- // voxgig-model's resolveModel cache short-circuits the re-read.
377
355
  const jfiles = jres?.files
378
356
  const reload = !!jfiles && (
379
357
  (jfiles.written?.length ?? 0) > 0 ||
@@ -486,7 +464,9 @@ ApiDef.makeBuild = async function(opts: ApiDefOptions) {
486
464
 
487
465
  const ctrl = build.spec.buildargs?.apidef?.ctrl || {}
488
466
 
489
- return await apidef.generate({ model, build, config, ctrl })
467
+ // `buildctx` is the model build's own context, shared across its pre and
468
+ // post steps, and is where the resolved definition is published.
469
+ return await apidef.generate({ model, build, config, ctrl, buildctx: _ctx })
490
470
  }
491
471
 
492
472
  build.step = 'pre'
@@ -558,4 +538,13 @@ export {
558
538
  nom,
559
539
  VALID_CANON,
560
540
  CANON_ONE,
541
+
542
+ operationFacts,
543
+ operationIndex,
544
+ resolvedSpec,
545
+ }
546
+
547
+ export type {
548
+ OperationFacts,
549
+ ResolvedSpec,
561
550
  }
@@ -49,9 +49,6 @@ function resolveEntity(
49
49
 
50
50
  entityFiles.push({ name: entityFile, src: entitySrc })
51
51
 
52
- // `./` — aontu 0.65 reads a bare single-segment include as a PACKAGE
53
- // name (ADR-039), so `@"account.aon"` now resolves against the package
54
- // stores and refuses. A sibling file has to say it is one.
55
52
  barrel.push(`@"./${Path.basename(entityFile)}"`)
56
53
  }))
57
54
 
@@ -69,23 +66,6 @@ function resolveEntity(
69
66
  }
70
67
 
71
68
 
72
- // Garbage-collect orphaned entity model files.
73
- //
74
- // The builder above EMITS one <outprefix><name>.aon per derived entity but
75
- // never removes anything, so an entity that disappears from the def — a spec
76
- // rename, a dropped path, a schema rename that changes the derived entity
77
- // name — leaves its old file behind on every regen. The orphan is not in the
78
- // regenerated entity-index barrel, so it is silently dead weight at best; at
79
- // worst a later hand-include resurrects a stale surface.
80
- //
81
- // Deletion is guarded three ways, so nothing a user could own is touched:
82
- // 1. only `<outprefix>*.aon` / `*.aontu` files in the entity folder
83
- // (a different outprefix belongs to a different def sharing the folder);
84
- // 2. the current entity set and the index barrel are always kept;
85
- // 3. the file must START with the generated header (`# Entity: `) — a file
86
- // apidef did not write is left alone.
87
- //
88
- // GC failure must never fail a build: errors are logged and swallowed.
89
69
  function gcEntityFiles(
90
70
  fs: any,
91
71
  log: any,
@@ -110,11 +90,6 @@ function gcEntityFiles(
110
90
  }
111
91
 
112
92
  for (const entry of entries) {
113
- // BOTH extensions are candidates. `.aon` is what the builder emits
114
- // now; `.aontu` is what it emitted before the rename, and such files
115
- // are orphaned by definition — the regenerated index barrel no longer
116
- // includes them. The `# Entity: ` header guard below still applies, so
117
- // only a file apidef itself wrote is ever removed.
118
93
  if (!entry.endsWith('.aon') && !entry.endsWith('.aontu')) { continue }
119
94
  if (!entry.startsWith(prefix)) { continue }
120
95
  if (keep.has(entry)) { continue }
@@ -143,14 +118,6 @@ function gcEntityFiles(
143
118
 
144
119
 
145
120
  function fieldAliases(_entity: any): string {
146
- // Field aliasing (mapping e.g. a `<name>_id` field onto the canonical
147
- // `id`) is not currently implemented. The original heuristic referenced
148
- // properties that don't exist on the entity at this stage
149
- // (`entity.field`, `op.param`, `p.keys` — entities carry `fields`, ops
150
- // carry `points`, and `each` stamps `key$`), so it always produced `{}`
151
- // and would have thrown if any branch ran. Emit an empty alias map
152
- // explicitly until the alias semantics are specified.
153
- // Parity: go/builder.go buildFieldAliases (also `{}`).
154
121
  return '{}'
155
122
  }
156
123
 
@@ -33,10 +33,6 @@ function resolveInfo(
33
33
 
34
34
  const modelInfo = { main: { kit: { info: kit.info } } }
35
35
 
36
- // .trim() first so substring(1, len-1) strips the wrapping `{` and `}`.
37
- // Without it, formatJSONIC's trailing newline is removed instead of the
38
- // closing brace, leaving a dangling `}` in the output. Mirrors the sibling
39
- // entity builder (builder/entity/entity.ts), which trims for this reason.
40
36
  let modelDefSrc = formatJSONIC(modelInfo).trim()
41
37
 
42
38
  modelDefSrc =
@@ -1,5 +1,4 @@
1
1
 
2
- // TODO: move this to sdkgen
3
2
 
4
3
 
5
4
  import Path from 'node:path'
@@ -34,30 +33,20 @@ async function makeFlowBuilder(ctx: ApiDefContext): Promise<Function> {
34
33
  })
35
34
  }
36
35
 
37
- /*
38
- if ('heuristic01' === ctx.opts.strategy) {
39
- try {
40
- flows = await flowHeuristic01(ctx)
41
- }
42
- catch (err: any) {
43
- err.foo = { x: 1, y: [2] }
44
- err.foo.z = err.foo
36
+
37
+ const flownames: string[] = []
38
+ each(flows, (flow: any) => flownames.push(String(flow.name)))
39
+ const filebase = flowFileBases(flownames)
40
+
41
+ for (const name of flownames) {
42
+ if (name !== filebase[name] && ctx.warn) {
45
43
  ctx.warn({
46
44
  step: 'flow',
47
- note: 'Unable to resolve flows due to unexpected error: ' + err.message,
48
- err,
45
+ note: 'flow name ' + name + ' collides with another when case is' +
46
+ ' ignored: file written as ' + filebase[name] + '.aon'
49
47
  })
50
- return flowBuilder
51
48
  }
52
49
  }
53
- else {
54
- ctx.warn({
55
- step: 'flow',
56
- note: 'Unable to resolve flows: unknown guide strategy: ' + ctx.opts.strategy
57
- })
58
- return flowBuilder
59
- }
60
- */
61
50
 
62
51
  flowBuilder = () => {
63
52
 
@@ -70,7 +59,7 @@ async function makeFlowBuilder(ctx: ApiDefContext): Promise<Function> {
70
59
  let flowfile =
71
60
  Path.join(ctx.opts.folder, 'flow',
72
61
  (null == ctx.opts.outprefix ? '' : ctx.opts.outprefix) +
73
- flow.name + '.aon')
62
+ (filebase[flow.name] || flow.name) + '.aon')
74
63
 
75
64
  let flowModelSrc = formatJsonSrc(JSON.stringify(flow, null, 2))
76
65
 
@@ -79,8 +68,6 @@ async function makeFlowBuilder(ctx: ApiDefContext): Promise<Function> {
79
68
  main: ${KIT}: flow: ${flow.name}:
80
69
  ` + flowModelSrc
81
70
 
82
- // `./` — see the entity barrel: aontu 0.65 reads a bare
83
- // single-segment include as a package name and refuses it.
84
71
  barrel.push(`@"./${Path.basename(flowfile)}"`)
85
72
 
86
73
  File({ name: Path.basename(flowfile) }, () => Content(flowsrc))
@@ -100,5 +87,33 @@ main: ${KIT}: flow: ${flow.name}:
100
87
 
101
88
 
102
89
  export {
103
- makeFlowBuilder
90
+ makeFlowBuilder,
91
+ flowFileBases,
92
+ }
93
+
94
+
95
+ // Flow file base names, unique when case is ignored. Every member of a
96
+ // colliding group is suffixed in sorted-name order, so no member keeps the
97
+ // bare name and the result is the same on every run. Names that do not
98
+ // collide are returned unchanged.
99
+ function flowFileBases(names: string[]): { [name: string]: string } {
100
+ const bylower: { [lower: string]: string[] } = {}
101
+ for (const name of names) {
102
+ const lower = name.toLowerCase()
103
+ bylower[lower] = bylower[lower] || []
104
+ bylower[lower].push(name)
105
+ }
106
+
107
+ const base: { [name: string]: string } = {}
108
+ for (const lower of Object.keys(bylower)) {
109
+ const group = bylower[lower].sort()
110
+ if (1 === group.length) {
111
+ base[group[0]] = group[0]
112
+ }
113
+ else {
114
+ group.forEach((name, i) => (base[name] = name + '__' + (i + 1)))
115
+ }
116
+ }
117
+
118
+ return base
104
119
  }
package/src/desc.ts CHANGED
@@ -8,22 +8,10 @@ import type { ParameterDef } from './def'
8
8
 
9
9
 
10
10
 
11
- // // Intermediate Guide types used during analysis
12
- // type GuidePathRename = {
13
- // param?: Record<string, string>
14
- // }
15
11
 
16
- // type GuideOp = {
17
- // method: MethodName
18
- // }
19
12
 
20
- // type GuidePath = {
21
- // rename?: GuidePathRename
22
- // op?: Record<string, GuideOp>
23
- // }
24
13
 
25
14
 
26
- // Component analysis description
27
15
  type CmpDesc = {
28
16
  namedesc?: any,
29
17
  path_rate: number,
@@ -52,7 +40,6 @@ type MethodDesc = {
52
40
  }
53
41
 
54
42
 
55
- // Method entity relationship analysis
56
43
  type MethodEntityDesc = {
57
44
  ref: string
58
45
 
@@ -79,7 +66,6 @@ type MethodEntityDesc = {
79
66
  }
80
67
 
81
68
 
82
- // Entity analysis description
83
69
  type EntityDesc = {
84
70
  name: string
85
71
  origname: string
@@ -90,7 +76,6 @@ type EntityDesc = {
90
76
  }
91
77
 
92
78
 
93
- // Entity path relationship analysis
94
79
  type EntityPathDesc = {
95
80
  op: Record<string, any>
96
81
  pm: PathMatch
@@ -123,16 +108,13 @@ type PathSegment = {
123
108
  }
124
109
 
125
110
 
126
- // Path analysis description
127
111
  type PathDesc = {
128
112
  orig: string
129
113
  method: MethodName
130
114
  segments: PathSegment[]
131
- // rename: GuidePathRename
132
115
  rename: {
133
116
  param?: Record<string, any>
134
117
  }
135
- // op: GuidePath["op"]
136
118
  op: Record<string, {
137
119
  method: any
138
120
  transform: {
@@ -149,16 +131,12 @@ type PathDesc = {
149
131
  }
150
132
 
151
133
 
152
- // Operation analysis description
153
134
  type OpDesc = {
154
135
  paths: PathDesc[]
155
136
  }
156
137
 
157
138
 
158
139
  export type {
159
- // GuidePathRename,
160
- // GuideOp,
161
- // GuidePath,
162
140
  CmpDesc,
163
141
  BasicMethodDesc,
164
142
  MethodDesc,
@@ -1,22 +1,5 @@
1
1
  /* Copyright (c) 2024-2026 Voxgig, MIT License */
2
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
3
 
21
4
  import { each } from 'jostraca'
22
5
 
@@ -49,9 +32,7 @@ type GqlRetShape = {
49
32
  entity?: string
50
33
  // Connection only: the field holding the node array ('nodes' | 'edges').
51
34
  nodes?: string
52
- // Payload only: the field the entity is wrapped in, if any.
53
35
  unwrap?: string
54
- // Payload only: a delete-ish payload carries no entity.
55
36
  deleteish?: boolean
56
37
  }
57
38
 
@@ -63,7 +44,6 @@ type GqlArgSig = {
63
44
  }
64
45
 
65
46
 
66
- // Everything the classifier needs about one root field.
67
47
  type GqlFieldSig = {
68
48
  optype: 'query' | 'mutation'
69
49
  name: string
@@ -101,8 +81,6 @@ const MACHINERY_RE =
101
81
  /(Connection|Edge|PageInfo|Payload|Input|Filter|Comparator|Sort|OrderBy)$/
102
82
 
103
83
 
104
- // Strip a leading entity name off a mutation field name, returning the
105
- // residual verb. `issueCreate` -> `Create`; `createIssue` -> `create`.
106
84
  function splitEntityVerb(fieldName: string, entity: string): string {
107
85
  const lowerField = fieldName.toLowerCase()
108
86
  const lowerEnt = entity.toLowerCase()
@@ -117,14 +95,6 @@ function splitEntityVerb(fieldName: string, entity: string): string {
117
95
  }
118
96
 
119
97
 
120
- // Does this field take a required id-ish argument?
121
- //
122
- // Any required id counts, not just a lone one: a command like
123
- // `planetForbid(id: String!, forbid: Boolean!)` addresses an existing record
124
- // just as much as `planetArchive(id: String!)` does. Requiring it to be the
125
- // only required argument made an operation stop looking id-addressed the
126
- // moment the API made a second argument mandatory, which flipped it from an
127
- // update action to a create.
128
98
  function idArg(args: GqlArgSig[]): GqlArgSig | undefined {
129
99
  return args.find(
130
100
  (a: GqlArgSig) => a.reqd && /^(id|.*Id)$/i.test(a.name))
@@ -165,13 +135,11 @@ function classifyGraphQLField(
165
135
  const id = idArg(sig.args)
166
136
 
167
137
  if ('query' === sig.optype) {
168
- // Connection or list of the entity -> list.
169
138
  if ('connection' === ret.kind || 'list' === ret.kind) {
170
139
  why.push('query:' + ret.kind)
171
140
  return { entity, op: 'list', optype: 'query', why }
172
141
  }
173
142
 
174
- // Single entity behind one required id -> load.
175
143
  if ('entity' === ret.kind) {
176
144
  if (null != id) {
177
145
  why.push('query:entity:id=' + id.name)
@@ -193,7 +161,6 @@ function classifyGraphQLField(
193
161
  return { exclude: true, why }
194
162
  }
195
163
 
196
- // --- mutation ---
197
164
  const verb = splitEntityVerb(sig.name, entity).replace(/^[_-]+/, '')
198
165
  const input = sig.inputTypeName ?? ''
199
166
 
@@ -204,14 +171,12 @@ function classifyGraphQLField(
204
171
  return { entity, op: 'create', optype: 'mutation', why }
205
172
  }
206
173
 
207
- // update: <Entity>UpdateInput, or an update-ish verb.
208
174
  const updateByInput = new RegExp('^' + entity + '(Update|Edit|Patch|Set)Input$', 'i').test(input)
209
175
  if (updateByInput || UPDATE_RE.test(verb)) {
210
176
  why.push(updateByInput ? 'mutation:input:' + input : 'mutation:verb:' + verb)
211
177
  return { entity, op: 'update', optype: 'mutation', why }
212
178
  }
213
179
 
214
- // remove: a delete-ish verb.
215
180
  if (REMOVE_RE.test(verb)) {
216
181
  why.push('mutation:verb:' + verb)
217
182
  return { entity, op: 'remove', optype: 'mutation', why }
@@ -249,7 +214,6 @@ function deriveRetShape(
249
214
  return { kind: 'other' }
250
215
  }
251
216
 
252
- // Relay connection: has pageInfo plus nodes and/or edges.
253
217
  const fnames = Object.keys(named.fields)
254
218
  if (fnames.includes('pageInfo') &&
255
219
  (fnames.includes('nodes') || fnames.includes('edges'))) {
@@ -289,9 +253,6 @@ function deriveRetShape(
289
253
  !/^(errors?|userErrors?)$/i.test(fname)
290
254
  })
291
255
 
292
- // Prefer the field whose name matches the payload's own entity prefix
293
- // (IssuePayload -> issue), which is the convention every CRUD-regular
294
- // GraphQL API follows; fall back to the single remaining candidate.
295
256
  const prefix = named.name.replace(/Payload$/, '')
296
257
  const byName = candidates.find((fname: string) =>
297
258
  fname.toLowerCase() === prefix.toLowerCase() ||
@@ -314,7 +275,6 @@ function deriveRetShape(
314
275
  return { kind: 'other' }
315
276
  }
316
277
 
317
- // A list of the entity is a list op even without connection machinery.
318
278
  if (field.list) {
319
279
  return { kind: 'list', entity: named.name }
320
280
  }
@@ -323,13 +283,11 @@ function deriveRetShape(
323
283
  }
324
284
 
325
285
 
326
- // Build the classifier signature for a root field.
327
286
  function fieldSig(
328
287
  optype: 'query' | 'mutation',
329
288
  field: GqlField,
330
289
  types: Record<string, GqlType>
331
290
  ): GqlFieldSig {
332
- // The input-object argument, if any, drives create/update detection.
333
291
  let inputTypeName: string | undefined = undefined
334
292
  for (const arg of field.args) {
335
293
  const at = types[arg.type]
@@ -352,8 +310,6 @@ function fieldSig(
352
310
  }
353
311
 
354
312
 
355
- // Verb suffixes a mutation field name may carry, longest first so
356
- // `issueUnarchive` strips `Unarchive` rather than `Archive`.
357
313
  const NAME_VERBS = [
358
314
  'Unarchive', 'Archive', 'Delete', 'Remove', 'Destroy',
359
315
  'Create', 'Update', 'Insert', 'Upsert',
@@ -390,45 +346,16 @@ function nameEntityType(
390
346
  }
391
347
 
392
348
 
393
- // Entity model name from a GraphQL type name: Issue -> issue,
394
- // WorkflowState -> workflow_state (canonize handles the casing rules that
395
- // the REST path classifier already uses).
396
349
  function rawEntityName(typeName: string): string {
397
350
  return depluralize(canonize(normalizeFieldName(typeName)))
398
351
  }
399
352
 
400
353
 
401
- // The leading-digit guard is applied here rather than inherited: the REST
402
- // side gets it from `ensureMinEntityName`, which this path deliberately does
403
- // not call (its min-length padding is the REST classifier's rule, and
404
- // entities here merge by name on purpose). GraphQL type names cannot begin
405
- // with a digit, but they can begin with `_`, which `normalizeFieldName`
406
- // strips — so `_3DSSessions` reaches an SDK as the entity `3_ds_session` and
407
- // every generated language rejects the identifier.
408
354
  function entityName(typeName: string): string {
409
355
  return prefixLeadingDigit(rawEntityName(typeName))
410
356
  }
411
357
 
412
358
 
413
- // Two GraphQL types that canonize to ONE entity name merge, deliberately:
414
- // `_3DSSession` and `_3DSSessions` are the singular and plural spellings of
415
- // one thing, and `Issue`/`Issues` likewise. That is what canonicalization is
416
- // for, and the caller's `guide.entity[entname] ?? {…}` is how it happens.
417
- //
418
- // The leading-digit guard opens one collision that is NOT that. A guarded
419
- // name (`_3DSSessions` -> `3_ds_session` -> `n3_ds_session`) can land on a
420
- // name another type already owns natively (`N3DSSession` -> `n3_ds_session`).
421
- // Those are unrelated types, and merging them would fold two entities' fields
422
- // and ops together under a name whose `orig` records only the first — with no
423
- // second guide entry for a user to correct, since guide.aon is the only
424
- // correction surface (ADR-002).
425
- //
426
- // So a collision between a guarded name and an unguarded one takes a numeric
427
- // suffix, the same convention `ensureMinEntityName` uses on the REST side.
428
- // The scope is exactly the collisions this guard creates: when both names are
429
- // guarded, or neither is, the merge is what it was before the guard existed
430
- // and stands untouched. The same type re-encountered on another root field
431
- // always reuses its own entry.
432
359
  function resolveEntityName(
433
360
  typeName: string,
434
361
  entities: Record<string, GuideEntity>,
@@ -460,7 +387,6 @@ function newGuidePath(): GuidePath {
460
387
  }
461
388
 
462
389
 
463
- // The GraphQL guide strategy.
464
390
  async function graphql01(ctx: ApiDefContext): Promise<Guide> {
465
391
  const def: GqlDef = ctx.def
466
392
  const profile: GqlProfile = (ctx.opts.profile ?? 'none') as GqlProfile
@@ -493,7 +419,6 @@ async function graphql01(ctx: ApiDefContext): Promise<Guide> {
493
419
  ]
494
420
 
495
421
  for (const root of roots) {
496
- // Sorted iteration: byte-stable guide output.
497
422
  for (const fname of Object.keys(root.fields).sort()) {
498
423
  guide.metrics.count.field++
499
424