@voxgig/apidef 2.4.1 → 3.1.0

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 (108) hide show
  1. package/dist/apidef.d.ts +7 -2
  2. package/dist/apidef.js +190 -114
  3. package/dist/apidef.js.map +1 -1
  4. package/dist/builder/entity/entity.d.ts +3 -0
  5. package/dist/builder/entity/{apiEntity.js → entity.js} +12 -9
  6. package/dist/builder/entity/entity.js.map +1 -0
  7. package/dist/builder/entity/info.d.ts +3 -0
  8. package/dist/builder/entity/info.js +22 -0
  9. package/dist/builder/entity/info.js.map +1 -0
  10. package/dist/builder/entity.js +7 -21
  11. package/dist/builder/entity.js.map +1 -1
  12. package/dist/builder/flow/flowHeuristic01.js +21 -11
  13. package/dist/builder/flow/flowHeuristic01.js.map +1 -1
  14. package/dist/builder/flow.d.ts +2 -1
  15. package/dist/builder/flow.js +39 -12
  16. package/dist/builder/flow.js.map +1 -1
  17. package/dist/def.d.ts +62 -0
  18. package/dist/def.js +4 -0
  19. package/dist/def.js.map +1 -0
  20. package/dist/desc.d.ts +87 -0
  21. package/dist/desc.js +4 -0
  22. package/dist/desc.js.map +1 -0
  23. package/dist/guide/guide.d.ts +2 -1
  24. package/dist/guide/guide.js +161 -30
  25. package/dist/guide/guide.js.map +1 -1
  26. package/dist/guide/heuristic01.d.ts +2 -1
  27. package/dist/guide/heuristic01.js +1122 -234
  28. package/dist/guide/heuristic01.js.map +1 -1
  29. package/dist/model.d.ts +90 -0
  30. package/dist/model.js +4 -0
  31. package/dist/model.js.map +1 -0
  32. package/dist/parse.d.ts +4 -3
  33. package/dist/parse.js +40 -46
  34. package/dist/parse.js.map +1 -1
  35. package/dist/resolver.js +2 -1
  36. package/dist/resolver.js.map +1 -1
  37. package/dist/transform/args.d.ts +3 -0
  38. package/dist/transform/args.js +54 -0
  39. package/dist/transform/args.js.map +1 -0
  40. package/dist/transform/clean.d.ts +2 -2
  41. package/dist/transform/clean.js +28 -3
  42. package/dist/transform/clean.js.map +1 -1
  43. package/dist/transform/entity.d.ts +9 -1
  44. package/dist/transform/entity.js +57 -41
  45. package/dist/transform/entity.js.map +1 -1
  46. package/dist/transform/field.d.ts +1 -1
  47. package/dist/transform/field.js +89 -65
  48. package/dist/transform/field.js.map +1 -1
  49. package/dist/transform/flow.d.ts +3 -0
  50. package/dist/transform/flow.js +26 -0
  51. package/dist/transform/flow.js.map +1 -0
  52. package/dist/transform/flowstep.d.ts +3 -0
  53. package/dist/transform/flowstep.js +145 -0
  54. package/dist/transform/flowstep.js.map +1 -0
  55. package/dist/transform/operation.d.ts +3 -3
  56. package/dist/transform/operation.js +101 -296
  57. package/dist/transform/operation.js.map +1 -1
  58. package/dist/transform/select.d.ts +3 -0
  59. package/dist/transform/select.js +65 -0
  60. package/dist/transform/select.js.map +1 -0
  61. package/dist/transform/top.js +24 -2
  62. package/dist/transform/top.js.map +1 -1
  63. package/dist/transform.d.ts +1 -1
  64. package/dist/transform.js +4 -0
  65. package/dist/transform.js.map +1 -1
  66. package/dist/tsconfig.tsbuildinfo +1 -1
  67. package/dist/types.d.ts +115 -14
  68. package/dist/types.js +4 -2
  69. package/dist/types.js.map +1 -1
  70. package/dist/utility.d.ts +34 -2
  71. package/dist/utility.js +444 -6
  72. package/dist/utility.js.map +1 -1
  73. package/model/apidef.jsonic +76 -1
  74. package/model/guide.jsonic +14 -44
  75. package/package.json +19 -16
  76. package/src/apidef.ts +258 -122
  77. package/src/builder/entity/{apiEntity.ts → entity.ts} +18 -11
  78. package/src/builder/entity/info.ts +53 -0
  79. package/src/builder/entity.ts +9 -35
  80. package/src/builder/flow/flowHeuristic01.ts +46 -12
  81. package/src/builder/flow.ts +54 -13
  82. package/src/def.ts +91 -0
  83. package/src/desc.ts +154 -0
  84. package/src/guide/guide.ts +208 -134
  85. package/src/guide/heuristic01.ts +1653 -272
  86. package/src/model.ts +143 -0
  87. package/src/parse.ts +50 -59
  88. package/src/resolver.ts +3 -1
  89. package/src/schematron.ts.off +317 -0
  90. package/src/transform/args.ts +98 -0
  91. package/src/transform/clean.ts +45 -11
  92. package/src/transform/entity.ts +96 -50
  93. package/src/transform/field.ts +136 -75
  94. package/src/transform/flow.ts +59 -0
  95. package/src/transform/flowstep.ts +242 -0
  96. package/src/transform/operation.ts +119 -419
  97. package/src/transform/select.ts +119 -0
  98. package/src/transform/top.ts +46 -4
  99. package/src/transform.ts +8 -4
  100. package/src/types.ts +181 -5
  101. package/src/utility.ts +567 -9
  102. package/dist/builder/entity/apiEntity.d.ts +0 -3
  103. package/dist/builder/entity/apiEntity.js.map +0 -1
  104. package/dist/builder/entity/def.d.ts +0 -3
  105. package/dist/builder/entity/def.js +0 -19
  106. package/dist/builder/entity/def.js.map +0 -1
  107. package/src/builder/entity/def.ts +0 -44
  108. package/src/guide.ts.off +0 -136
package/src/model.ts ADDED
@@ -0,0 +1,143 @@
1
+ /* Copyright (c) 2024-2025 Voxgig, MIT License */
2
+
3
+ // Consolidated model types for the API model derived from OpenAPI specifications
4
+
5
+ import type { MethodName } from './types'
6
+
7
+
8
+ // Operation names available on entities
9
+ type OpName = 'load' | 'list' | 'create' | 'update' | 'remove' | 'patch' | 'head' | 'options'
10
+
11
+
12
+ type Model = {
13
+ main: {
14
+ kit: {
15
+ entity: Record<string, ModelEntity>
16
+ }
17
+ }
18
+ }
19
+
20
+
21
+ // Entity relationships information
22
+ type ModelEntityRelations = {
23
+ ancestors: string[][]
24
+ }
25
+
26
+
27
+ // Map of operations available on an entity
28
+ type ModelOpMap = Partial<Record<OpName, ModelOp | undefined>>
29
+
30
+
31
+ // Field-specific operation configuration
32
+ type ModelFieldOp = {
33
+ type: any // @voxgig/struct validation schema
34
+ req: boolean
35
+ }
36
+
37
+
38
+ // Entity field definition
39
+ type ModelField = {
40
+ name: string
41
+ type: any // @voxgig/struct validation schema
42
+ req: boolean
43
+ op: Partial<Record<OpName, ModelFieldOp>>
44
+ }
45
+
46
+
47
+ // Operation argument/parameter definition
48
+ type ModelArg = {
49
+ name: string
50
+ orig: string
51
+ type: any // @voxgig/struct validation schema
52
+ kind: 'param' | 'query' | 'header' | 'cookie'
53
+ reqd: boolean
54
+ }
55
+
56
+
57
+ // Alternative implementation of an operation
58
+ type ModelAlt = {
59
+ orig: string
60
+ method: MethodName
61
+ parts: string[]
62
+ rename: Partial<{
63
+ param: Record<string, string>
64
+ query: Record<string, string>
65
+ header: Record<string, string>
66
+ cookie: Record<string, string>
67
+ }>
68
+ args: Partial<{
69
+ param: ModelArg[]
70
+ query: ModelArg[]
71
+ header: ModelArg[]
72
+ cookie: ModelArg[]
73
+ }>
74
+ transform: {
75
+ req?: any
76
+ res?: any
77
+ }
78
+ select: {
79
+ exist: string[]
80
+ $action?: string
81
+ }
82
+ }
83
+
84
+
85
+ // Operation definition
86
+ type ModelOp = {
87
+ name: OpName
88
+ alts: ModelAlt[]
89
+ }
90
+
91
+
92
+ // Entity definition - core model entity with operations and fields
93
+ type ModelEntity = {
94
+ name: string,
95
+ op: ModelOpMap,
96
+ fields: ModelField[],
97
+ id: {
98
+ name: string,
99
+ field: string,
100
+ },
101
+ relations: ModelEntityRelations
102
+ }
103
+
104
+
105
+ type ModelEntityFlow = {
106
+ name: string,
107
+ entity: string
108
+ kind: string
109
+ // args: Record<string, string>
110
+ step: ModelEntityFlowStep[]
111
+ }
112
+
113
+
114
+ type ModelEntityFlowStep = {
115
+ op: OpName
116
+ input: Record<string, any>
117
+ match: Record<string, any>
118
+ data: Record<string, any>
119
+ spec: {
120
+ apply: string
121
+ def: Record<string, any>
122
+ }[]
123
+ valid: {
124
+ apply: string
125
+ def: Record<string, any>
126
+ }[]
127
+ }
128
+
129
+
130
+ export type {
131
+ OpName,
132
+ Model,
133
+ ModelEntityRelations,
134
+ ModelOpMap,
135
+ ModelFieldOp,
136
+ ModelField,
137
+ ModelArg,
138
+ ModelAlt,
139
+ ModelOp,
140
+ ModelEntity,
141
+ ModelEntityFlow,
142
+ ModelEntityFlowStep,
143
+ }
package/src/parse.ts CHANGED
@@ -1,20 +1,44 @@
1
- /* Copyright (c) 2024 Voxgig, MIT License */
1
+ /* Copyright (c) 2024-2025 Voxgig, MIT License */
2
2
 
3
3
  import { bundleFromString, createConfig } from '@redocly/openapi-core'
4
4
 
5
- import { each, snakify } from 'jostraca'
5
+ import decircular from 'decircular'
6
6
 
7
+ import { relativizePath } from './utility'
7
8
 
8
- import { depluralize } from './utility'
9
9
 
10
10
 
11
11
  // Parse an API definition source into a JSON sructure.
12
- async function parse(kind: string, source: any, meta?: any) {
12
+ async function parse(kind: string, source: any, meta: { file: string }) {
13
13
  if ('OpenAPI' === kind) {
14
- return parseOpenAPI(source, meta)
14
+
15
+ validateSource(kind, source, meta)
16
+
17
+ try {
18
+ const def = await parseOpenAPI(source, meta)
19
+ return def
20
+ }
21
+ catch (pe: any) {
22
+ if (pe.originalError) {
23
+ pe.originalError.message =
24
+ `@voxgig/apidef: parse: syntax: ${pe.originalError.message}` +
25
+ ` (${relativizePath(meta.file)})`
26
+ pe = pe.originalError
27
+ }
28
+ else {
29
+ pe.message =
30
+ `@voxgig/apidef: parse: internal: ${pe.message}` +
31
+ ` (${relativizePath(meta.file)})`
32
+ }
33
+
34
+ throw pe
35
+ }
15
36
  }
16
37
  else {
17
- throw new Error('@voxgig/apidef-parse: unknown kind: ' + kind)
38
+ throw new Error(
39
+ `@voxgig/apidef: parse: unknown kind: ${kind}` +
40
+ ` (${relativizePath(meta.file)})`
41
+ )
18
42
  }
19
43
  }
20
44
 
@@ -59,80 +83,47 @@ async function parseOpenAPI(source: any, meta?: any) {
59
83
  addXRefs(bundleWithRefs.bundle.parsed)
60
84
 
61
85
  // Serialize back to string with x-refs preserved
62
- const sourceWithXRefs = JSON.stringify(bundleWithRefs.bundle.parsed)
86
+ const sourceWithXRefs = JSON.stringify(decircular(bundleWithRefs.bundle.parsed))
63
87
 
64
88
  // Second pass: parse with dereferencing
65
89
  const bundle = await bundleFromString({
66
90
  source: sourceWithXRefs,
91
+ // source,
67
92
  config,
68
93
  dereference: true,
69
94
  })
70
95
 
71
- const def = bundle.bundle.parsed
96
+ const def = decircular(bundle.bundle.parsed)
72
97
 
73
98
  return def
74
99
  }
75
100
 
76
101
 
77
102
 
103
+ function validateSource(kind: string, source: any, meta: { file: string }) {
104
+ if (typeof source !== 'string') {
105
+ throw new Error(
106
+ `@voxgig/apidef: parse: ${kind}: source must be a string` +
107
+ ` (${relativizePath(meta.file)})`
108
+ )
109
+ }
78
110
 
111
+ // Remove YAML comment lines (lines that start with # after
112
+ // optional whitespace)
113
+ const withoutComments = source.replace(/^\s*#.*$/gm, '')
79
114
 
115
+ if (withoutComments.trim().length === 0) {
116
+ throw new Error(
117
+ `@voxgig/apidef: parse: ${kind}: source is empty` +
118
+ ` (${relativizePath(meta.file)})`
119
+ )
120
+ }
121
+ }
80
122
 
81
123
 
82
- // Make consistent changes to support semantic entities.
83
- function rewrite(def: any) {
84
- const paths = def.paths
85
- each(paths, (path) => {
86
- each(path.parameters, (param: any) => {
87
-
88
- let new_param = param.name
89
- let new_path = path.key$
90
-
91
- // Rename param if nane is "id", and not the final param.
92
- // Rewrite /foo/{id}/bar as /foo/{foo_id}/bar.
93
- // Avoids ambiguity with bar id.
94
- if (param.name.match(/^id$/i)) {
95
- let m = path.key$.match(/\/([^\/]+)\/{id\}\/[^\/]/)
96
-
97
- if (m) {
98
- const parent = depluralize(snakify(m[1]))
99
- new_param = `${parent}_id`
100
- new_path = path.key$.replace('{id}', '{' + new_param + '}')
101
- }
102
- }
103
- else {
104
- new_param = depluralize(snakify(param.name))
105
- new_path = path.key$.replace('{' + param.name + '}', '{' + new_param + '}')
106
- }
107
-
108
- let pathdef = paths[path.key$]
109
- delete paths[path.key$]
110
-
111
- paths[new_path] = pathdef
112
- path.key$ = new_path
113
-
114
- param.name = new_param
115
- })
116
- })
117
-
118
-
119
- sortkeys(def, 'paths')
120
- sortkeys(def, 'components')
121
-
122
- return def
123
- }
124
124
 
125
125
 
126
- function sortkeys(obj: any, prop: string) {
127
- const sorted: any = {}
128
- const sorted_keys = Object.keys(obj[prop]).sort()
129
- for (let sk of sorted_keys) {
130
- sorted[sk] = obj[prop][sk]
131
- }
132
- obj[prop] = sorted
133
- }
134
126
 
135
127
  export {
136
128
  parse,
137
- rewrite,
138
129
  }
package/src/resolver.ts CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  import Path from 'node:path'
5
5
 
6
+ import { relativizePath } from './utility'
7
+
6
8
 
7
9
  async function resolveElements(
8
10
  ctx: any,
@@ -77,7 +79,7 @@ async function resolveElement(
77
79
  }
78
80
  catch (e: any) {
79
81
  const err = new Error('Custom element not found: ' +
80
- customtpath + ': ' + e.message)
82
+ relativizePath(customtpath) + ': ' + e.message)
81
83
  log.error({ what: 'element', element: target + ': ' + en, fail: 'require', err })
82
84
  throw err
83
85
  }
@@ -0,0 +1,317 @@
1
+ type JSONSchema = Record<string, any>;
2
+
3
+ type ConvertOptions = {
4
+ /** Where to resolve $ref from. Use your OpenAPI doc’s components.schemas, or JSON Schema $defs. */
5
+ refRoots?: Array<Record<string, JSONSchema>>;
6
+ /** Optional: pass the entire root doc; only used if your $ref are absolute like #/components/schemas/X */
7
+ rootDoc?: Record<string, any>;
8
+ };
9
+
10
+ const TYPE_TOKEN: Record<string, string> = {
11
+ string: "$STRING",
12
+ number: "$NUMBER",
13
+ integer: "$INTEGER",
14
+ boolean: "$BOOLEAN",
15
+ null: "$NULL",
16
+ };
17
+
18
+ export function convert(
19
+ schema: JSONSchema | undefined,
20
+ opts: ConvertOptions = {}
21
+ ): any {
22
+ const seen = new Set<object>();
23
+ const resolvingRefs = new Set<string>();
24
+
25
+
26
+ const resolveRef = ($ref: string): JSONSchema | undefined => {
27
+ if (!$ref.startsWith("#/")) return undefined; // non-local refs not supported per spec; return undefined to fallback
28
+ const parts = $ref.slice(2).split("/").map(unescapeRefToken);
29
+ let node: any = opts.rootDoc ?? {};
30
+ for (const p of parts) node = node?.[p];
31
+ if (node) return node;
32
+
33
+ // Try common roots passed in refRoots (e.g., components.schemas, $defs)
34
+ for (const root of opts.refRoots ?? []) {
35
+ let probe: any = root;
36
+ for (const p of parts) probe = probe?.[p];
37
+ if (probe) return probe;
38
+ }
39
+ return undefined;
40
+ };
41
+
42
+ const expandRef = (sch: JSONSchema): JSONSchema => {
43
+ if (typeof sch?.$ref === "string") {
44
+ const target = resolveRef(sch.$ref);
45
+ if (target) return target;
46
+ // Not supported → per rule 10, “just expand the reference”; if we cannot, fall back to ANY.
47
+ return {};
48
+ }
49
+ return sch;
50
+ };
51
+
52
+ function convert(s: JSONSchema | undefined): any {
53
+ if (!s || Object.keys(s).length === 0) {
54
+ // Rule 13 – empty schema accepts anything
55
+ return "$ANY";
56
+ }
57
+
58
+ // Prevent cycles from blowing the stack on malformed docs
59
+ if (seen.has(s)) return "$ANY";
60
+ seen.add(s);
61
+
62
+ // // // Expand $ref immediately (Rule 10)
63
+ // // if (s.$ref) {
64
+ // // const tgt = expandRef(s);
65
+ // // // Merge local decorations on top of the ref target (common in OAS)
66
+ // // const merged = { ...tgt, ...without(s, ["$ref"]) };
67
+ // // const out = convert(merged);
68
+ // // seen.delete(s);
69
+ // // return out;
70
+ // // }
71
+
72
+
73
+ // // Expand $ref immediately (Rule 10)
74
+ // if (s.$ref) {
75
+ // const target = expandRef(s);
76
+ // const localDecor = without(s, ["$ref"]); // local fields that decorate the ref
77
+ // // Use the same semantics as allOf merging to deep-merge properties/openness/etc.
78
+ // const merged = mergeAllOf([target, localDecor]);
79
+ // const out = convert(merged);
80
+ // seen.delete(s);
81
+ // return out;
82
+ // }
83
+
84
+ if (s.$ref) {
85
+ const ref = String(s.$ref);
86
+ if (resolvingRefs.has(ref)) {
87
+ // cycle detected -> cannot expand, fall back to ANY
88
+ seen.delete(s);
89
+ return "$ANY";
90
+ }
91
+ resolvingRefs.add(ref);
92
+
93
+ const target = expandRef(s); // returns referenced schema or {}
94
+ const localDecor = without(s, ["$ref"]);
95
+ // merge like allOf so local decorations augment target
96
+ const merged = mergeAllOf([target, localDecor]);
97
+
98
+ const out = convert(merged);
99
+
100
+ resolvingRefs.delete(ref);
101
+ seen.delete(s);
102
+ return out;
103
+ }
104
+
105
+
106
+ // Composition
107
+ if (Array.isArray(s.allOf) && s.allOf.length > 0) {
108
+ const merged = mergeAllOf(s.allOf.map((x) => expandRef(x)));
109
+ // Carry over top-level decorations (e.g., nullable, annotations)
110
+ const mergedWithTop = { ...merged, ...without(s, ["allOf"]) };
111
+ const out = convert(mergedWithTop);
112
+ seen.delete(s);
113
+ return out;
114
+ }
115
+
116
+ if (Array.isArray(s.oneOf) && s.oneOf.length > 0) {
117
+ const alts = s.oneOf.map((x) => convert(expandRef(x)));
118
+ const out = ["$ONE", ...alts];
119
+ seen.delete(s);
120
+ return out;
121
+ }
122
+
123
+ if (Array.isArray(s.anyOf) && s.anyOf.length > 0) {
124
+ const alts = s.anyOf.map((x) => convert(expandRef(x)));
125
+ const out = ["$ANY", ...alts]; // Rule 3 – anyOf uses "$ANY" in the same directive position
126
+ seen.delete(s);
127
+ return out;
128
+ }
129
+
130
+ // Enum / const → $EXACT
131
+ if (Array.isArray(s.enum) && s.enum.length > 0) {
132
+ const out = ["$EXACT", ...s.enum];
133
+ seen.delete(s);
134
+ return out;
135
+ }
136
+ if (Object.prototype.hasOwnProperty.call(s, "const")) {
137
+ const out = ["$EXACT", s.const];
138
+ seen.delete(s);
139
+ return out;
140
+ }
141
+
142
+ // Handle OpenAPI nullable at this level by wrapping the base type later
143
+ const nullable = s.nullable === true;
144
+
145
+ // Type handling (could be string or array)
146
+ const t = s.type;
147
+ if (Array.isArray(t) && t.length > 0) {
148
+ // Union of primitives (and possibly null)
149
+ const tokens = t.map((tt) => TYPE_TOKEN[tt] ?? "$ANY");
150
+ const out = ["$ONE", ...tokens];
151
+ seen.delete(s);
152
+ return out;
153
+ }
154
+
155
+ // Objects
156
+ if (t === "object" || s.properties || s.additionalProperties !== undefined) {
157
+ const obj: Record<string, any> = {};
158
+
159
+ // $OPEN
160
+ if (s.additionalProperties === true) {
161
+ obj["$OPEN"] = true;
162
+ } else if (s.additionalProperties && typeof s.additionalProperties === "object") {
163
+ // We cannot express typed extras (Rule 6). Keep it open but untyped.
164
+ obj["$OPEN"] = true;
165
+ }
166
+ // $NOTE annotations (Rule 11)
167
+ const note: Record<string, any> = {};
168
+ if (s.readOnly === true) note.readOnly = true;
169
+ if (s.writeOnly === true) note.writeOnly = true;
170
+ if (s.deprecated === true) note.deprecated = true;
171
+ if (Object.keys(note).length > 0) obj["$NOTE"] = note;
172
+
173
+ const props = s.properties ?? {};
174
+ for (const [key, sub] of Object.entries<JSONSchema>(props)) {
175
+ const converted = convert(expandRef(sub));
176
+ const isNullable =
177
+ sub?.nullable === true ||
178
+ (Array.isArray(sub?.type) && sub.type.includes("null")) ||
179
+ includesNullViaOneAnyOf(sub);
180
+
181
+ obj[key] = isNullable ? wrapNullable(converted) : converted;
182
+ }
183
+
184
+ seen.delete(s);
185
+ return obj;
186
+ }
187
+
188
+ // Arrays
189
+ if (t === "array" || s.items) {
190
+ const items = s.items;
191
+ if (Array.isArray(items)) {
192
+ // Tuple validation → positional sub-schemas
193
+ const out = items.map((it) => convert(expandRef(it)));
194
+ seen.delete(s);
195
+ return out;
196
+ } else if (items && typeof items === "object") {
197
+ // Homogeneous array → ["$CHILD", sub]
198
+ const out = ["$CHILD", convert(expandRef(items))];
199
+ seen.delete(s);
200
+ return out;
201
+ } else {
202
+ // No items → accept any child element
203
+ seen.delete(s);
204
+ return [];
205
+ }
206
+ }
207
+
208
+ // Primitives
209
+ if (typeof t === "string" && TYPE_TOKEN[t]) {
210
+ const token = TYPE_TOKEN[t];
211
+ const base = token;
212
+
213
+ const out = nullable ? wrapNullable(base) : base;
214
+ seen.delete(s);
215
+ return out;
216
+ }
217
+
218
+ // No explicit type, but we might still infer:
219
+ if (s.properties || s.additionalProperties !== undefined) {
220
+ // already handled in object branch, but keep a guard
221
+ const out = convert({ ...s, type: "object" });
222
+ seen.delete(s);
223
+ return out;
224
+ }
225
+ if (s.items) {
226
+ const out = convert({ ...s, type: "array" });
227
+ seen.delete(s);
228
+ return out;
229
+ }
230
+
231
+ // Fallback
232
+ seen.delete(s);
233
+ return "$ANY";
234
+ }
235
+
236
+ const result = convert(schema);
237
+ return result;
238
+ }
239
+
240
+ /* -------------------------- helpers -------------------------- */
241
+
242
+ function unescapeRefToken(s: string) {
243
+ // JSON Pointer ~0 -> ~, ~1 -> /
244
+ return s.replace(/~1/g, "/").replace(/~0/g, "~");
245
+ }
246
+
247
+ function without<T extends Record<string, any>>(obj: T, keys: string[]): T {
248
+ const copy: any = {};
249
+ for (const k of Object.keys(obj)) if (!keys.includes(k)) copy[k] = obj[k];
250
+ return copy;
251
+ }
252
+
253
+ function includesNullViaOneAnyOf(s: JSONSchema): boolean {
254
+ const hasNullIn = (arr?: any[]) =>
255
+ Array.isArray(arr) &&
256
+ arr.some((x) => x?.type === "null" || (Array.isArray(x?.type) && x.type.includes("null")));
257
+ return hasNullIn(s.oneOf) || hasNullIn(s.anyOf);
258
+ }
259
+
260
+ function wrapNullable(inner: any) {
261
+ // Rule 1/4 – nullable → ["$ONE", inner, "$NULL"]
262
+ return Array.isArray(inner) && inner[0] === "$ONE"
263
+ ? inner.includes("$NULL")
264
+ ? inner
265
+ : ["$ONE", ...inner.slice(1), "$NULL"]
266
+ : ["$ONE", inner, "$NULL"];
267
+ }
268
+
269
+ function mergeAllOf(schemas: JSONSchema[]): JSONSchema {
270
+ const out: JSONSchema = {};
271
+ let anyObject = false;
272
+
273
+ for (const s of schemas) {
274
+ const schema = s || {};
275
+
276
+ if (schema.type === "object" || schema.properties || schema.additionalProperties !== undefined) {
277
+ anyObject = true;
278
+ out.type = "object";
279
+
280
+ // --- properties: merge without losing earlier branches ---
281
+ const srcProps = schema.properties ?? {};
282
+ const dstProps = (out.properties = out.properties ?? {});
283
+ for (const [k, v] of Object.entries(srcProps)) {
284
+ if (k in dstProps) {
285
+ // Combine the two property schemas using allOf semantics.
286
+ // This preserves earlier constraints like {type:'number'} and
287
+ // later decorations like {nullable:true}.
288
+ dstProps[k] = { allOf: [dstProps[k], v] };
289
+ } else {
290
+ dstProps[k] = v;
291
+ }
292
+ }
293
+
294
+ // --- additionalProperties: openness is "true if any true/object" ---
295
+ const ap = schema.additionalProperties;
296
+ if (ap === true || (ap && typeof ap === "object")) {
297
+ out.additionalProperties = true;
298
+ } else if (out.additionalProperties === undefined) {
299
+ out.additionalProperties = ap;
300
+ }
301
+
302
+ // --- required: union (not used in voxgig/struct but harmless to keep) ---
303
+ if (Array.isArray(schema.required)) {
304
+ const req = new Set([...(out.required ?? []), ...schema.required]);
305
+ out.required = Array.from(req);
306
+ }
307
+ } else {
308
+ // For non-object shapes in allOf, a shallow merge is the best we can do.
309
+ Object.assign(out, schema);
310
+ }
311
+ }
312
+
313
+ // If nothing was objecty, return a shallow merge across all schemas.
314
+ return anyObject
315
+ ? out
316
+ : (schemas.reduce((a, b) => ({ ...a, ...(b || {}) }), {}) as JSONSchema);
317
+ }