@voxgig/apidef 6.3.7 → 6.3.9

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 (48) hide show
  1. package/dist/apidef.d.ts +3 -2
  2. package/dist/apidef.js +35 -5
  3. package/dist/apidef.js.map +1 -1
  4. package/dist/builder/entity/entity.d.ts +2 -1
  5. package/dist/builder/entity/entity.js +63 -0
  6. package/dist/builder/entity/entity.js.map +1 -1
  7. package/dist/builder/entity/info.js.map +1 -1
  8. package/dist/builder/entity.js.map +1 -1
  9. package/dist/builder/flow/flowHeuristic01.js.map +1 -1
  10. package/dist/builder/flow.js.map +1 -1
  11. package/dist/guide/guide.js +49 -6
  12. package/dist/guide/guide.js.map +1 -1
  13. package/dist/guide/heuristic01.js +99 -89
  14. package/dist/guide/heuristic01.js.map +1 -1
  15. package/dist/parse.js +151 -18
  16. package/dist/parse.js.map +1 -1
  17. package/dist/resolver.js.map +1 -1
  18. package/dist/transform/args.js.map +1 -1
  19. package/dist/transform/clean.js.map +1 -1
  20. package/dist/transform/entity.js.map +1 -1
  21. package/dist/transform/field.js +14 -0
  22. package/dist/transform/field.js.map +1 -1
  23. package/dist/transform/flow.js.map +1 -1
  24. package/dist/transform/flowstep.js.map +1 -1
  25. package/dist/transform/operation.js.map +1 -1
  26. package/dist/transform/select.js.map +1 -1
  27. package/dist/transform/top.d.ts +2 -1
  28. package/dist/transform/top.js +32 -2
  29. package/dist/transform/top.js.map +1 -1
  30. package/dist/transform.d.ts +8 -7
  31. package/dist/transform.js.map +1 -1
  32. package/dist/tsconfig.tsbuildinfo +1 -1
  33. package/dist/types.d.ts +97 -84
  34. package/dist/types.js.map +1 -1
  35. package/dist/utility.d.ts +6 -1
  36. package/dist/utility.js +205 -16
  37. package/dist/utility.js.map +1 -1
  38. package/package.json +8 -8
  39. package/src/apidef.ts +38 -4
  40. package/src/builder/entity/entity.ts +70 -1
  41. package/src/guide/guide.ts +50 -6
  42. package/src/guide/heuristic01.ts +37 -28
  43. package/src/parse.ts +161 -20
  44. package/src/transform/field.ts +16 -1
  45. package/src/transform/top.ts +37 -2
  46. package/src/tsconfig.json +1 -0
  47. package/src/types.ts +5 -0
  48. package/src/utility.ts +226 -16
package/src/apidef.ts CHANGED
@@ -61,6 +61,8 @@ import {
61
61
  writeFileSyncWarn,
62
62
  relativizePath,
63
63
  getModelPath,
64
+ VALID_CANON,
65
+ CANON_ONE,
64
66
  } from './utility'
65
67
 
66
68
 
@@ -75,6 +77,7 @@ import { flowstepTransform } from './transform/flowstep'
75
77
  import { cleanTransform } from './transform/clean'
76
78
 
77
79
  import { makeEntityBuilder } from './builder/entity'
80
+ import { gcEntityFiles } from './builder/entity/entity'
78
81
  import { makeFlowBuilder } from './builder/flow'
79
82
 
80
83
  // Log non-fatal wierdness.
@@ -144,6 +147,7 @@ function ApiDef(opts: ApiDefOptions) {
144
147
  // TODO: Validate spec
145
148
  ctx = {
146
149
  fs,
150
+ fsInjected: null != opts.fs,
147
151
  log,
148
152
  spec,
149
153
  opts,
@@ -199,7 +203,7 @@ function ApiDef(opts: ApiDefOptions) {
199
203
 
200
204
  // Step: guide (derive).
201
205
  if (!ctrl.step.guide) {
202
- return { ok: false, steps, start, end: Date.now(), ctrl }
206
+ return { ok: false, steps, start, end: Date.now(), ctrl, ctx }
203
207
  }
204
208
 
205
209
  const guideModel = await buildGuide(ctx)
@@ -214,8 +218,15 @@ function ApiDef(opts: ApiDefOptions) {
214
218
 
215
219
 
216
220
  // Step: transformers (transform spec and guide into core structures).
221
+ // Early stops return the model built so far: `ctrl.step.generate = false`
222
+ // is the documented way to build the model in memory without writing
223
+ // files (see AGENTS.md), which requires `apimodel` in the result. The Go
224
+ // port already returns it from every early return.
217
225
  if (!ctrl.step.transformers) {
218
- return { ok: true, steps, start, end: Date.now(), ctrl, guide: ctx.guide }
226
+ return {
227
+ ok: true, steps, start, end: Date.now(), ctrl,
228
+ guide: ctx.guide, apimodel: ctx.apimodel, ctx
229
+ }
219
230
  }
220
231
 
221
232
  await topTransform(ctx)
@@ -232,7 +243,10 @@ function ApiDef(opts: ApiDefOptions) {
232
243
 
233
244
  // Step: builders (build generated sub models).
234
245
  if (!ctrl.step.builders) {
235
- return { ok: true, steps, start, end: Date.now(), ctrl, guide: ctx.guide }
246
+ return {
247
+ ok: true, steps, start, end: Date.now(), ctrl,
248
+ guide: ctx.guide, apimodel: ctx.apimodel, ctx
249
+ }
236
250
  }
237
251
 
238
252
  const builders = [
@@ -247,7 +261,10 @@ function ApiDef(opts: ApiDefOptions) {
247
261
 
248
262
  // Step: generate (generate model files).
249
263
  if (!ctrl.step.generate) {
250
- return { ok: true, steps, start, end: Date.now(), ctrl, guide: ctx.guide }
264
+ return {
265
+ ok: true, steps, start, end: Date.now(), ctrl,
266
+ guide: ctx.guide, apimodel: ctx.apimodel, ctx
267
+ }
251
268
  }
252
269
 
253
270
  const jostraca = Jostraca({
@@ -285,6 +302,20 @@ function ApiDef(opts: ApiDefOptions) {
285
302
 
286
303
  steps.push('generate')
287
304
 
305
+ // Garbage-collect entity model files no longer derived from the def.
306
+ // The builders only ever WRITE: a spec change that removes or renames a
307
+ // derived entity used to leave the old <name>.aontu behind forever.
308
+ // Runs after generate so the current set is on disk; guarded so only
309
+ // apidef-generated files under this build's outprefix are touched.
310
+ try {
311
+ const kitEntity = (ctx.apimodel?.main as any)?.[KIT]?.entity || {}
312
+ gcEntityFiles(fs, log, opts.folder as string, opts.outprefix,
313
+ Object.keys(kitEntity))
314
+ }
315
+ catch (err: any) {
316
+ log.warn({ point: 'entity-gc-failed', err, note: String(err?.message) })
317
+ }
318
+
288
319
  const hasWarnings = 0 < warn.history.length
289
320
  const endnote =
290
321
  hasWarnings ? `PARTIAL BUILD! There were ${warn.history.length} warnings (see above).` :
@@ -450,6 +481,7 @@ export type {
450
481
  export {
451
482
  KIT,
452
483
  ApiDef,
484
+ gcEntityFiles,
453
485
  parse,
454
486
  formatJSONIC,
455
487
  depluralize,
@@ -457,4 +489,6 @@ export {
457
489
  slugToPascalCase,
458
490
  getModelPath,
459
491
  nom,
492
+ VALID_CANON,
493
+ CANON_ONE,
460
494
  }
@@ -66,6 +66,74 @@ function resolveEntity(
66
66
  }
67
67
 
68
68
 
69
+ // Garbage-collect orphaned entity model files.
70
+ //
71
+ // The builder above EMITS one <outprefix><name>.aontu per derived entity but
72
+ // never removes anything, so an entity that disappears from the def — a spec
73
+ // rename, a dropped path, a schema rename that changes the derived entity
74
+ // name — leaves its old file behind on every regen. The orphan is not in the
75
+ // regenerated entity-index barrel, so it is silently dead weight at best; at
76
+ // worst a later hand-include resurrects a stale surface.
77
+ //
78
+ // Deletion is guarded three ways, so nothing a user could own is touched:
79
+ // 1. only `<outprefix>*.aontu` files inside the entity folder are candidates
80
+ // (a different outprefix belongs to a different def sharing the folder);
81
+ // 2. the current entity set and the index barrel are always kept;
82
+ // 3. the file must START with the generated header (`# Entity: `) — a file
83
+ // apidef did not write is left alone.
84
+ //
85
+ // GC failure must never fail a build: errors are logged and swallowed.
86
+ function gcEntityFiles(
87
+ fs: any,
88
+ log: any,
89
+ modelFolder: string,
90
+ outprefix: string | undefined,
91
+ entityNames: string[],
92
+ ): string[] {
93
+ const removed: string[] = []
94
+ const prefix = null == outprefix ? '' : outprefix
95
+ const entityFolder = Path.join(modelFolder, 'entity')
96
+
97
+ const keep = new Set<string>(
98
+ entityNames.map((name) => prefix + name + '.aontu'))
99
+ keep.add(prefix + 'entity-index.aontu')
100
+
101
+ let entries: string[] = []
102
+ try {
103
+ entries = fs.readdirSync(entityFolder)
104
+ }
105
+ catch (_err: any) {
106
+ return removed // no entity folder yet — nothing to collect
107
+ }
108
+
109
+ for (const entry of entries) {
110
+ if (!entry.endsWith('.aontu')) { continue }
111
+ if (!entry.startsWith(prefix)) { continue }
112
+ if (keep.has(entry)) { continue }
113
+
114
+ const file = Path.join(entityFolder, entry)
115
+ try {
116
+ const head = String(fs.readFileSync(file)).slice(0, 64)
117
+ if (!head.startsWith('# Entity: ')) { continue }
118
+ fs.unlinkSync(file)
119
+ removed.push(entry)
120
+ log?.info?.({
121
+ point: 'entity-gc', file: entry,
122
+ note: `removed orphaned entity model file ${entry} (no longer derived from the def)`,
123
+ })
124
+ }
125
+ catch (err: any) {
126
+ log?.warn?.({
127
+ point: 'entity-gc-failed', file: entry, err,
128
+ note: `could not gc ${entry}: ${err?.message}`,
129
+ })
130
+ }
131
+ }
132
+
133
+ return removed
134
+ }
135
+
136
+
69
137
  function fieldAliases(_entity: any): string {
70
138
  // Field aliasing (mapping e.g. a `<name>_id` field onto the canonical
71
139
  // `id`) is not currently implemented. The original heuristic referenced
@@ -81,5 +149,6 @@ function fieldAliases(_entity: any): string {
81
149
 
82
150
 
83
151
  export {
84
- resolveEntity
152
+ resolveEntity,
153
+ gcEntityFiles,
85
154
  }
@@ -88,7 +88,36 @@ async function buildGuide(ctx: ApiDefContext): Promise<any> {
88
88
  errs,
89
89
  }
90
90
 
91
- opts.fs = ctx.fs
91
+ // Only forward a *genuinely injected* fs.
92
+ //
93
+ // aontu resolves `@`-includes through @tabnas/multisource, which does:
94
+ // const P = null != ctx.meta?.fs ? Path.posix : Path
95
+ // i.e. it switches to POSIX path semantics whenever an fs is present, on
96
+ // the assumption that an injected fs is memfs keyed by POSIX paths.
97
+ //
98
+ // apidef defaults ctx.fs to the real node:fs (`opts.fs || Fs`), so
99
+ // forwarding it unconditionally made multisource parse *Windows* paths
100
+ // with Path.posix. 'D:\...\guide\x-guide.aontu' contains no '/', so the
101
+ // include base resolved to '' and sibling includes were looked up against
102
+ // the cwd instead of the guide folder — every build failed on Windows
103
+ // with `source not found: <prefix>base-guide.aontu`. Linux and macOS were
104
+ // unaffected because there Path and Path.posix are the same module.
105
+ //
106
+ // Callers that supply a real memfs (e.g. apidef-validate) still get it,
107
+ // and still get the POSIX semantics they need.
108
+ //
109
+ // Uses the explicit ctx.fsInjected flag rather than `Fs !== ctx.fs`:
110
+ // esModuleInterop compiles `import * as Fs` to __importStar(), which
111
+ // builds a fresh wrapper per module, so identity comparison across
112
+ // modules is always false.
113
+ if (ctx.fsInjected) {
114
+ opts.fs = ctx.fs
115
+ }
116
+
117
+ // Record what was actually handed to aontu, not what we intended to hand
118
+ // it, so the regression test fails if this block is ever changed back to
119
+ // an unconditional assignment.
120
+ ctx.work.guideAontuFs = undefined !== opts.fs
92
121
 
93
122
  const guideModel = aontu.generate(src, opts)
94
123
 
@@ -212,15 +241,30 @@ async function buildBaseGuide(ctx: ApiDefContext) {
212
241
  items(path.op).map(([opname, op]: [string, GuidePathOp]) => {
213
242
  guideBlocks.push(` op: ${opname}: method: *${op.method}` +
214
243
  sw(0 < op.why_op.length ? ' # ' + op.why_op : ''))
215
- // Only the res transform is emitted, and only when set. (The
216
- // previous req-guarded block pushed a *second* res line built from
217
- // op.transform.res — emitting `transform: res: *undefined` whenever
218
- // a request was wrapped but the response was not.) Matches the Go
219
- // guide builder, which gates solely on res.
244
+ // Each transform is emitted only when set, and each on its own terms.
245
+ // (An earlier req-GUARDED block pushed a second res line built from
246
+ // op.transform.res — emitting `transform: res: *undefined` whenever a
247
+ // request was wrapped but the response was not. Hence the separate
248
+ // null checks below rather than one shared guard.)
220
249
  if (null != op.transform.res) {
221
250
  guideBlocks.push(
222
251
  ` op: ${opname}: transform: res: *${qt(op.transform.res)}|top`)
223
252
  }
253
+ // The req transform is a MAP of body property -> source expression
254
+ // (see closedBodyTransform), so it takes one line per property. THE
255
+ // SERIALISED GUIDE IS WHAT THE TRANSFORM STEP READS: a transform not
256
+ // written here never reaches the model, which is why restricting a
257
+ // closed request body had no effect until this existed. Only the map
258
+ // form is representable as aontu paths; a scalar req is left alone.
259
+ const reqmap: any = op.transform.req
260
+ if (null != reqmap && 'object' === typeof reqmap) {
261
+ items(reqmap).map(([bodykey, source]: [string, any]) => {
262
+ if ('string' === typeof source) {
263
+ guideBlocks.push(` op: ${opname}: transform: req: ` +
264
+ `${qs(bodykey)}: *${qt(source)}|top`)
265
+ }
266
+ })
267
+ }
224
268
  })
225
269
 
226
270
  guideBlocks.push(` }`)
@@ -7,6 +7,8 @@ import { each } from 'jostraca'
7
7
 
8
8
  import { size, merge, getelem, isempty, items, keysof } from '@voxgig/struct'
9
9
 
10
+ import { isEntityWrapperProp, envelopeProp, closedBodyTransform } from '../utility'
11
+
10
12
 
11
13
  import {
12
14
  ApiDefContext,
@@ -1126,17 +1128,48 @@ function ResolveTransform(spec: TaskSpec) {
1126
1128
  else if (isEntityWrapperProp(resprops[entdesc.name])) {
1127
1129
  transform.res = '`body.' + entdesc.name + '`'
1128
1130
  }
1131
+ else {
1132
+ // The wrapper is often named for the CARDINALITY rather than the
1133
+ // entity — `{item: {...}}` from a load, `{items: [...]}` from a list —
1134
+ // which the entity-name rules above cannot see. Left unwrapped, list()
1135
+ // hands back the envelope where the caller expects an array, and the
1136
+ // envelope key is mistaken for a field of the entity.
1137
+ const envelope = envelopeProp(resprops, opname)
1138
+ if (null != envelope) {
1139
+ transform.res = '`body.' + envelope + '`'
1140
+ }
1141
+ }
1129
1142
  }
1130
1143
 
1131
- const reqprops = getRequestBodySchema(mdesc.requestBody)
1144
+ // The SCHEMA is what closedBodyTransform needs (it reads
1145
+ // additionalProperties); the wrapper-name checks need its PROPERTIES. They
1146
+ // used to share one value and index the schema itself, so
1147
+ // `schema['todoitem']` was always undefined and the entity-name request
1148
+ // envelope was never detected — while the Go port read `.properties` and
1149
+ // did detect it. That divergence was inert only because `req` was never
1150
+ // serialised; now that it is, the two implementations would emit different
1151
+ // request bodies for the same spec.
1152
+ const reqschema = getRequestBodySchema(mdesc.requestBody)
1153
+ const reqprops = reqschema?.properties
1132
1154
  debugpath(pathStr, methodName, 'TRANSFORM-REQ', keysof(reqprops))
1133
- if (reqprops) {
1134
- if (reqprops[entdesc.origname]) {
1155
+ if (reqschema) {
1156
+ if (null != reqprops?.[entdesc.origname]) {
1135
1157
  transform.req = { [entdesc.origname]: '`reqdata`' }
1136
1158
  }
1137
- else if (reqprops[entdesc.name]) {
1159
+ else if (null != reqprops?.[entdesc.name]) {
1138
1160
  transform.req = { [entdesc.name]: '`reqdata`' }
1139
1161
  }
1162
+ else {
1163
+ // A CLOSED body schema names every property the server will accept, so
1164
+ // the body is those properties — not the whole request payload. The
1165
+ // payload also carries the op's PATH params (`id` for
1166
+ // `PUT /item/{id}`), and a closed shape rejects the entire request over
1167
+ // that one extra key: every update came back 400 with `invalid-data`.
1168
+ const body = closedBodyTransform(reqschema)
1169
+ if (null != body) {
1170
+ transform.req = body
1171
+ }
1172
+ }
1140
1173
  }
1141
1174
 
1142
1175
  if (!isempty(transform) && null != op[opname]) {
@@ -1365,30 +1398,6 @@ function getResponseSchema(response: any) {
1365
1398
  }
1366
1399
 
1367
1400
 
1368
- // A response property only "wraps" the entity when it is itself a structured
1369
- // value that could contain the entity: an object, an array, a $ref, or a
1370
- // composed (allOf/oneOf/anyOf) schema. A scalar property (string, integer,
1371
- // number, boolean) that merely shares the entity's name is a field of the
1372
- // entity, not a wrapper, so the response must not be unwrapped down to it.
1373
- function isEntityWrapperProp(propSchema: any): boolean {
1374
- if (null == propSchema || 'object' !== typeof propSchema) {
1375
- return false
1376
- }
1377
- if (null != propSchema.$ref) {
1378
- return true
1379
- }
1380
- if (null != propSchema.properties ||
1381
- null != propSchema.items ||
1382
- null != propSchema.allOf ||
1383
- null != propSchema.oneOf ||
1384
- null != propSchema.anyOf) {
1385
- return true
1386
- }
1387
- const t = propSchema.type
1388
- return 'object' === t || 'array' === t
1389
- }
1390
-
1391
-
1392
1401
  function inferEntityName(
1393
1402
  mdesc: any,
1394
1403
  parts: string[],
package/src/parse.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  /* Copyright (c) 2024-2025 Voxgig, MIT License */
2
2
 
3
- import { Jsonic } from 'jsonic'
4
- import { Yaml } from '@jsonic/yaml'
5
-
6
- import { decircular } from '@voxgig/util'
3
+ import { Jsonic } from '@tabnas/jsonic'
4
+ import { Yaml } from '@tabnas/yaml'
7
5
 
8
6
  import { relativizePath } from './utility'
9
7
 
10
8
 
11
- const yamlParser = Jsonic.make().use(Yaml)
9
+ // NOTE: @tabnas/yaml types its Plugin against @tabnas/parser, while
10
+ // Jsonic.use expects @tabnas/jsonic's own (structurally identical) Plugin
11
+ // type - hence the cast.
12
+ const yamlParser = Jsonic.make().use(Yaml as any)
12
13
 
13
14
  // Matches any line that is not purely a YAML comment or whitespace.
14
15
  const RE_HAS_CONTENT = /^\s*[^#\s]/m
@@ -100,12 +101,80 @@ async function parseOpenAPI(source: any, _meta?: any) {
100
101
  // Single-pass: add x-ref properties and resolve $ref pointers together.
101
102
  addXRefsAndResolve(parsed, parsed)
102
103
 
103
- const def = decircular(parsed)
104
+ const def = decycle(parsed)
104
105
 
105
106
  return def
106
107
  }
107
108
 
108
109
 
110
+ // Break reference cycles so the parsed spec stays JSON-serializable, WITHOUT
111
+ // destroying the structure sharing that $ref inlining deliberately creates.
112
+ //
113
+ // @voxgig/util's decircular() rebuilds the tree — it allocates a fresh object
114
+ // per *visit*, so a component reachable by k distinct paths is copied k times
115
+ // and the result is the tree-expansion of the DAG, size O(fanout^depth). That
116
+ // is catastrophic on exactly the shape real specs have (components reused
117
+ // across nesting levels): a 2.3 KB spec with 12 levels and 3 refs per level
118
+ // expanded to a 172 MB model, and 13 levels exhausted a 2 GB heap.
119
+ //
120
+ // Cycles are real here — inlining a self-referential schema makes the copy's
121
+ // `properties` the same object as the original's, so the copy contains
122
+ // itself — so they still have to be cut. This does it in place: only the edge
123
+ // that closes a cycle is replaced (with decircular's marker string, so the
124
+ // output shape is unchanged), every other node is visited exactly once and
125
+ // left shared. Linear in the number of distinct nodes.
126
+ function decycle(root: any) {
127
+ // Entry path of each node on the current ancestor chain; presence in this
128
+ // map is what identifies a back-edge. Nodes are removed on the way out, so
129
+ // a node reachable twice as a *sibling* is not treated as a cycle.
130
+ const onPath = new Map<any, string[]>()
131
+ // Fully-processed nodes. Revisiting one is legitimate sharing, not a cycle,
132
+ // and must not be walked (or copied) again.
133
+ const done = new WeakSet<any>()
134
+ const path: string[] = []
135
+
136
+ const walk = (node: any) => {
137
+ if (null == node || 'object' !== typeof node) return
138
+ if (done.has(node)) return
139
+
140
+ // The YAML parser hands back null-prototype objects. decircular() used to
141
+ // launder them into plain objects as a side effect of rebuilding the tree;
142
+ // parse()'s result is public, so keep that contract (callers reasonably
143
+ // expect `hasOwnProperty` etc. on a parsed spec) rather than leaking the
144
+ // parser's internal shape now that nothing is rebuilt.
145
+ if (!Array.isArray(node) && null === Object.getPrototypeOf(node)) {
146
+ Object.setPrototypeOf(node, Object.prototype)
147
+ }
148
+
149
+ onPath.set(node, path.slice())
150
+
151
+ const keys = Array.isArray(node) ?
152
+ node.map((_: any, i: number) => i) : Object.keys(node)
153
+
154
+ for (const key of keys as any[]) {
155
+ const val = node[key]
156
+ if (null == val || 'object' !== typeof val) continue
157
+
158
+ const cyclePath = onPath.get(val)
159
+ if (undefined !== cyclePath) {
160
+ node[key] = `[Circular *${cyclePath.join('.')}]`
161
+ continue
162
+ }
163
+
164
+ path.push(String(key))
165
+ walk(val)
166
+ path.pop()
167
+ }
168
+
169
+ onPath.delete(node)
170
+ done.add(node)
171
+ }
172
+
173
+ walk(root)
174
+ return root
175
+ }
176
+
177
+
109
178
  // Single-pass tree walk that:
110
179
  // 1. Preserves original $ref values as x-ref
111
180
  // 2. Resolves $ref JSON pointers in-place
@@ -117,6 +186,20 @@ async function parseOpenAPI(source: any, _meta?: any) {
117
186
  // across every site that referenced the same component. (A deep clone is
118
187
  // deliberately avoided: schemas can be self-referential, which would make
119
188
  // cloning non-terminating.)
189
+ // Keywords sitting beside a `$ref` on the *referring* node. OpenAPI 3.1 and
190
+ // JSON Schema 2020-12 both allow them (`description`, `required`,
191
+ // constraints, ...) and they still apply, so inlining must not drop them.
192
+ // Applied over the resolved target, so the local statement wins.
193
+ function refSiblings(node: any): any {
194
+ const out: any = {}
195
+ for (const k of Object.keys(node)) {
196
+ if ('$ref' === k) continue
197
+ out[k] = node[k]
198
+ }
199
+ return out
200
+ }
201
+
202
+
120
203
  function addXRefsAndResolve(obj: any, root: any, visited?: WeakSet<any>) {
121
204
  if (!obj || typeof obj !== 'object') return
122
205
  if (!visited) visited = new WeakSet()
@@ -131,7 +214,7 @@ function addXRefsAndResolve(obj: any, root: any, visited?: WeakSet<any>) {
131
214
  const xref = item.$ref
132
215
  const resolved = resolvePointer(root, xref)
133
216
  if (resolved !== undefined) {
134
- obj[i] = { ...resolved, 'x-ref': xref }
217
+ obj[i] = { ...resolved, ...refSiblings(item), 'x-ref': xref }
135
218
  addXRefsAndResolve(obj[i], root, visited)
136
219
  } else {
137
220
  item['x-ref'] = xref
@@ -150,7 +233,7 @@ function addXRefsAndResolve(obj: any, root: any, visited?: WeakSet<any>) {
150
233
  const xref = val.$ref
151
234
  const resolved = resolvePointer(root, xref)
152
235
  if (resolved !== undefined) {
153
- obj[key] = { ...resolved, 'x-ref': xref }
236
+ obj[key] = { ...resolved, ...refSiblings(val), 'x-ref': xref }
154
237
  addXRefsAndResolve(obj[key], root, visited)
155
238
  } else {
156
239
  val['x-ref'] = xref
@@ -165,22 +248,80 @@ function addXRefsAndResolve(obj: any, root: any, visited?: WeakSet<any>) {
165
248
  }
166
249
 
167
250
 
168
- // Follow a JSON pointer like "#/components/schemas/Planet"
251
+ // Follow a JSON pointer like "#/components/schemas/Planet".
252
+ //
253
+ // Alias components — `Foo: { $ref: '#/components/schemas/Bar' }` — are a
254
+ // normal OpenAPI idiom, so a pointer can land on another bare $ref node.
255
+ // Follow the chain to its end rather than returning the intermediate: the
256
+ // caller inlines `{ ...resolved }`, and a `$ref` *string* key in that spread
257
+ // is never followed by the object-valued recursion in addXRefsAndResolve, so
258
+ // stopping early yields a schema with no properties and every field is
259
+ // silently dropped. Whether that happened used to depend on whether `paths`
260
+ // or `components` came first in the document, because resolution reads a root
261
+ // the same walk is still mutating.
262
+ //
263
+ // `seen` holds pointer strings (not object identities) so a self- or
264
+ // mutually-referential alias cycle terminates instead of looping forever.
169
265
  function resolvePointer(root: any, ref: string): any {
170
- if (!ref.startsWith('#/')) return undefined
266
+ const seen = new Set<string>()
267
+ // Keywords sitting beside a `$ref` along the chain, outermost first.
268
+ // OpenAPI 3.1 / JSON Schema 2020-12 allow `$ref` to carry siblings
269
+ // (`description`, `required`, constraints, ...) and they still apply, so
270
+ // following the chain must not discard them. Merged onto the final target
271
+ // below, outermost last so the most specific alias wins.
272
+ const siblings: any[] = []
273
+ let current: any = undefined
274
+ let pointer = ref
275
+
276
+ for (; ;) {
277
+ if (!pointer.startsWith('#/')) return undefined
278
+ if (seen.has(pointer)) return undefined
279
+ seen.add(pointer)
280
+
281
+ const parts = pointer
282
+ .substring(2)
283
+ .split('/')
284
+ .map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'))
285
+
286
+ current = root
287
+ for (const part of parts) {
288
+ if (current == null || typeof current !== 'object') return undefined
289
+ current = current[part]
290
+ }
171
291
 
172
- const parts = ref
173
- .substring(2)
174
- .split('/')
175
- .map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'))
292
+ // Landed on another alias: follow it. Anything else is the target.
293
+ if (current != null &&
294
+ 'object' === typeof current &&
295
+ !Array.isArray(current) &&
296
+ 'string' === typeof current.$ref) {
297
+ const sib: any = {}
298
+ let hasSib = false
299
+ for (const k of Object.keys(current)) {
300
+ if ('$ref' === k) continue
301
+ sib[k] = current[k]
302
+ hasSib = true
303
+ }
304
+ if (hasSib) siblings.push(sib)
305
+ pointer = current.$ref
306
+ continue
307
+ }
176
308
 
177
- let current = root
178
- for (const part of parts) {
179
- if (current == null || typeof current !== 'object') return undefined
180
- current = current[part]
181
- }
309
+ if (0 === siblings.length) {
310
+ // No siblings anywhere on the chain: return the target itself, so
311
+ // multiple references keep sharing one object (see the note on
312
+ // addXRefsAndResolve — a fresh copy per site would defeat that).
313
+ return current
314
+ }
182
315
 
183
- return current
316
+ // Siblings present: a merged view is necessarily a new object. Apply
317
+ // deepest-first so the outermost alias's keywords win.
318
+ const merged: any = (null != current && 'object' === typeof current &&
319
+ !Array.isArray(current)) ? { ...current } : {}
320
+ for (let i = siblings.length - 1; 0 <= i; i--) {
321
+ Object.assign(merged, siblings[i])
322
+ }
323
+ return merged
324
+ }
184
325
  }
185
326
 
186
327
 
@@ -4,7 +4,7 @@ import { each, getx } from 'jostraca'
4
4
 
5
5
  import type { TransformResult, Transform } from '../transform'
6
6
 
7
- import { validator, canonize, inferFieldType, normalizeFieldName } from '../utility'
7
+ import { validator, canonize, inferFieldType, normalizeFieldName, envelopeProp } from '../utility'
8
8
 
9
9
  import { KIT } from '../types'
10
10
 
@@ -150,6 +150,21 @@ function findFieldDefs(
150
150
  fieldSets = getx(responses, '201 content "application/json" schema') ??
151
151
  getx(responses, '201 schema')
152
152
  }
153
+
154
+ // Single-entity responses get the same treatment the list branch above
155
+ // already gives collections: a body that is only an envelope around the
156
+ // entity — `{item: {...}}` — describes the WRAPPER, not the entity, so
157
+ // its sole property would otherwise be harvested as a field. That is
158
+ // how an entity `todoitem` ended up with a required `item` field of
159
+ // type object, which then appeared in the generated create/update data
160
+ // types. envelopeProp applies the same two rules used to pick the
161
+ // response transform, so the field list and the transform agree.
162
+ if ('list' != mop.name) {
163
+ const envelope = envelopeProp(fieldSets?.properties, mop.name)
164
+ if (null != envelope) {
165
+ fieldSets = fieldSets.properties[envelope]
166
+ }
167
+ }
153
168
  }
154
169
 
155
170
  // A QUERY (RFC 10008) request body is a filter/query schema, not the